feat: git command, build improvements, and go fmt git-aware (#74)
* feat(go): make go fmt git-aware by default - By default, only check changed Go files (modified, staged, untracked) - Add --all flag to check all files (previous behaviour) - Reduces noise when running fmt on large codebases Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat(build): minimal output by default, add missing i18n - Default output now shows single line: "Success Built N artifacts (dir)" - Add --verbose/-v flag to show full detailed output - Add all missing i18n translations for build commands - Errors still show failure reason in minimal mode Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat: add root-level `core git` command - Create pkg/gitcmd with git workflow commands as root menu - Export command builders from pkg/dev (AddCommitCommand, etc.) - Commands available under both `core git` and `core dev` for compatibility - Git commands: health, commit, push, pull, work, sync, apply - GitHub orchestration stays in dev: issues, reviews, ci, impact Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat(qa): add docblock coverage checking Implement docblock/docstring coverage analysis for Go code: - New `core qa docblock` command to check coverage - Shows compact file:line list when under threshold - Integrate with `core go qa` as a default check - Add --docblock-threshold flag (default 80%) The checker uses Go AST parsing to find exported symbols (functions, types, consts, vars) without documentation. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: address CodeRabbit review feedback - Fix doc comment: "status" → "health" in gitcmd package - Implement --check flag for `core go fmt` (exits non-zero if files need formatting) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * docs: add docstrings for 100% coverage Add documentation comments to all exported symbols: - pkg/build: ProjectType constants - pkg/cli: LogLevel, RenderStyle, TableStyle - pkg/framework: ServiceFor, MustServiceFor, Core.Core - pkg/git: GitError.Error, GitError.Unwrap - pkg/i18n: Handler Match/Handle methods - pkg/log: Level constants - pkg/mcp: Tool input/output types - pkg/php: Service constants, QA types, service methods - pkg/process: ServiceError.Error - pkg/repos: RepoType constants - pkg/setup: ChangeType, ChangeCategory constants - pkg/workspace: AddWorkspaceCommands Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * chore: standardize line endings to LF Add .gitattributes to enforce LF line endings for all text files. Normalize all existing files to use Unix-style line endings. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: address CodeRabbit review feedback - cmd_format.go: validate --check/--fix mutual exclusivity, capture stderr - cmd_docblock.go: return error instead of os.Exit(1) for proper error handling Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: address CodeRabbit review feedback (round 2) - linuxkit.go: propagate state update errors, handle cmd.Wait() errors in waitForExit - mcp.go: guard against empty old_string in editDiff to prevent runaway edits - cmd_docblock.go: log parse errors instead of silently skipping Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
3ee353f880
commit
fdc108c69e
105 changed files with 1608 additions and 674 deletions
23
.gitattributes
vendored
Normal file
23
.gitattributes
vendored
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
# Normalize all text files to LF
|
||||||
|
* text=auto eol=lf
|
||||||
|
|
||||||
|
# Ensure shell scripts use LF
|
||||||
|
*.sh text eol=lf
|
||||||
|
|
||||||
|
# Ensure Go files use LF
|
||||||
|
*.go text eol=lf
|
||||||
|
|
||||||
|
# Ensure JSON/YAML use LF
|
||||||
|
*.json text eol=lf
|
||||||
|
*.yaml text eol=lf
|
||||||
|
*.yml text eol=lf
|
||||||
|
|
||||||
|
# Binary files
|
||||||
|
*.png binary
|
||||||
|
*.jpg binary
|
||||||
|
*.gif binary
|
||||||
|
*.ico binary
|
||||||
|
*.woff binary
|
||||||
|
*.woff2 binary
|
||||||
|
*.ttf binary
|
||||||
|
*.eot binary
|
||||||
|
|
@ -369,7 +369,8 @@ func scanFile(fset *token.FileSet, filename string, file *ast.File) []KeyUsage {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for T(), C(), i18n.T(), i18n.C()
|
// Check for T(), C(), i18n.T(), i18n.C()
|
||||||
if funcName == "T" || funcName == "i18n.T" || funcName == "_" || funcName == "i18n._" {
|
switch funcName {
|
||||||
|
case "T", "i18n.T", "_", "i18n._":
|
||||||
if key := extractStringArg(call, 0); key != "" {
|
if key := extractStringArg(call, 0); key != "" {
|
||||||
pos := fset.Position(call.Pos())
|
pos := fset.Position(call.Pos())
|
||||||
usages = append(usages, KeyUsage{
|
usages = append(usages, KeyUsage{
|
||||||
|
|
@ -379,7 +380,7 @@ func scanFile(fset *token.FileSet, filename string, file *ast.File) []KeyUsage {
|
||||||
Function: "T",
|
Function: "T",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
} else if funcName == "C" || funcName == "i18n.C" {
|
case "C", "i18n.C":
|
||||||
if key := extractStringArg(call, 0); key != "" {
|
if key := extractStringArg(call, 0); key != "" {
|
||||||
pos := fset.Position(call.Pos())
|
pos := fset.Position(call.Pos())
|
||||||
usages = append(usages, KeyUsage{
|
usages = append(usages, KeyUsage{
|
||||||
|
|
@ -389,7 +390,7 @@ func scanFile(fset *token.FileSet, filename string, file *ast.File) []KeyUsage {
|
||||||
Function: "C",
|
Function: "C",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
} else if funcName == "I" || funcName == "i18n.I" {
|
case "I", "i18n.I":
|
||||||
if key := extractStringArg(call, 0); key != "" {
|
if key := extractStringArg(call, 0); key != "" {
|
||||||
pos := fset.Position(call.Pos())
|
pos := fset.Position(call.Pos())
|
||||||
usages = append(usages, KeyUsage{
|
usages = append(usages, KeyUsage{
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@
|
||||||
// - setup: Repository cloning and setup
|
// - setup: Repository cloning and setup
|
||||||
// - doctor: Environment health checks
|
// - doctor: Environment health checks
|
||||||
// - test: Test runner with coverage
|
// - test: Test runner with coverage
|
||||||
|
// - qa: Quality assurance workflows
|
||||||
|
|
||||||
package variants
|
package variants
|
||||||
|
|
||||||
|
|
@ -29,9 +30,11 @@ import (
|
||||||
_ "github.com/host-uk/core/pkg/dev"
|
_ "github.com/host-uk/core/pkg/dev"
|
||||||
_ "github.com/host-uk/core/pkg/docs"
|
_ "github.com/host-uk/core/pkg/docs"
|
||||||
_ "github.com/host-uk/core/pkg/doctor"
|
_ "github.com/host-uk/core/pkg/doctor"
|
||||||
|
_ "github.com/host-uk/core/pkg/gitcmd"
|
||||||
_ "github.com/host-uk/core/pkg/go"
|
_ "github.com/host-uk/core/pkg/go"
|
||||||
_ "github.com/host-uk/core/pkg/php"
|
_ "github.com/host-uk/core/pkg/php"
|
||||||
_ "github.com/host-uk/core/pkg/pkgcmd"
|
_ "github.com/host-uk/core/pkg/pkgcmd"
|
||||||
|
_ "github.com/host-uk/core/pkg/qa"
|
||||||
_ "github.com/host-uk/core/pkg/sdk"
|
_ "github.com/host-uk/core/pkg/sdk"
|
||||||
_ "github.com/host-uk/core/pkg/security"
|
_ "github.com/host-uk/core/pkg/security"
|
||||||
_ "github.com/host-uk/core/pkg/setup"
|
_ "github.com/host-uk/core/pkg/setup"
|
||||||
|
|
|
||||||
|
|
@ -295,12 +295,6 @@ func (c *Client) checkResponse(resp *http.Response) error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// mustReadAll reads all bytes from a reader, returning empty slice on error.
|
|
||||||
func mustReadAll(r io.Reader) []byte {
|
|
||||||
data, _ := io.ReadAll(r)
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ping tests the connection to the API server.
|
// Ping tests the connection to the API server.
|
||||||
func (c *Client) Ping(ctx context.Context) error {
|
func (c *Client) Ping(ctx context.Context) error {
|
||||||
const op = "agentic.Client.Ping"
|
const op = "agentic.Client.Ping"
|
||||||
|
|
|
||||||
|
|
@ -10,13 +10,21 @@ import (
|
||||||
// ProjectType represents a detected project type.
|
// ProjectType represents a detected project type.
|
||||||
type ProjectType string
|
type ProjectType string
|
||||||
|
|
||||||
|
// Project type constants for build detection.
|
||||||
const (
|
const (
|
||||||
|
// ProjectTypeGo indicates a standard Go project with go.mod.
|
||||||
ProjectTypeGo ProjectType = "go"
|
ProjectTypeGo ProjectType = "go"
|
||||||
|
// ProjectTypeWails indicates a Wails desktop application.
|
||||||
ProjectTypeWails ProjectType = "wails"
|
ProjectTypeWails ProjectType = "wails"
|
||||||
|
// ProjectTypeNode indicates a Node.js project with package.json.
|
||||||
ProjectTypeNode ProjectType = "node"
|
ProjectTypeNode ProjectType = "node"
|
||||||
|
// ProjectTypePHP indicates a PHP/Laravel project with composer.json.
|
||||||
ProjectTypePHP ProjectType = "php"
|
ProjectTypePHP ProjectType = "php"
|
||||||
|
// ProjectTypeDocker indicates a Docker-based project with Dockerfile.
|
||||||
ProjectTypeDocker ProjectType = "docker"
|
ProjectTypeDocker ProjectType = "docker"
|
||||||
|
// ProjectTypeLinuxKit indicates a LinuxKit VM configuration.
|
||||||
ProjectTypeLinuxKit ProjectType = "linuxkit"
|
ProjectTypeLinuxKit ProjectType = "linuxkit"
|
||||||
|
// ProjectTypeTaskfile indicates a project using Taskfile automation.
|
||||||
ProjectTypeTaskfile ProjectType = "taskfile"
|
ProjectTypeTaskfile ProjectType = "taskfile"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ var (
|
||||||
outputDir string
|
outputDir string
|
||||||
doArchive bool
|
doArchive bool
|
||||||
doChecksum bool
|
doChecksum bool
|
||||||
|
verbose bool
|
||||||
|
|
||||||
// Docker/LinuxKit specific flags
|
// Docker/LinuxKit specific flags
|
||||||
configPath string
|
configPath string
|
||||||
|
|
@ -62,7 +63,7 @@ var buildCmd = &cobra.Command{
|
||||||
Short: i18n.T("cmd.build.short"),
|
Short: i18n.T("cmd.build.short"),
|
||||||
Long: i18n.T("cmd.build.long"),
|
Long: i18n.T("cmd.build.long"),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
return runProjectBuild(buildType, ciMode, targets, outputDir, doArchive, doChecksum, configPath, format, push, imageName, noSign, notarize)
|
return runProjectBuild(buildType, ciMode, targets, outputDir, doArchive, doChecksum, configPath, format, push, imageName, noSign, notarize, verbose)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -101,6 +102,7 @@ func initBuildFlags() {
|
||||||
// Main build command flags
|
// Main build command flags
|
||||||
buildCmd.Flags().StringVar(&buildType, "type", "", i18n.T("cmd.build.flag.type"))
|
buildCmd.Flags().StringVar(&buildType, "type", "", i18n.T("cmd.build.flag.type"))
|
||||||
buildCmd.Flags().BoolVar(&ciMode, "ci", false, i18n.T("cmd.build.flag.ci"))
|
buildCmd.Flags().BoolVar(&ciMode, "ci", false, i18n.T("cmd.build.flag.ci"))
|
||||||
|
buildCmd.Flags().BoolVarP(&verbose, "verbose", "v", false, i18n.T("common.flag.verbose"))
|
||||||
buildCmd.Flags().StringVar(&targets, "targets", "", i18n.T("cmd.build.flag.targets"))
|
buildCmd.Flags().StringVar(&targets, "targets", "", i18n.T("cmd.build.flag.targets"))
|
||||||
buildCmd.Flags().StringVar(&outputDir, "output", "", i18n.T("cmd.build.flag.output"))
|
buildCmd.Flags().StringVar(&outputDir, "output", "", i18n.T("cmd.build.flag.output"))
|
||||||
buildCmd.Flags().BoolVar(&doArchive, "archive", true, i18n.T("cmd.build.flag.archive"))
|
buildCmd.Flags().BoolVar(&doArchive, "archive", true, i18n.T("cmd.build.flag.archive"))
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
// runProjectBuild handles the main `core build` command with auto-detection.
|
// runProjectBuild handles the main `core build` command with auto-detection.
|
||||||
func runProjectBuild(buildType string, ciMode bool, targetsFlag string, outputDir string, doArchive bool, doChecksum bool, configPath string, format string, push bool, imageName string, noSign bool, notarize bool) error {
|
func runProjectBuild(buildType string, ciMode bool, targetsFlag string, outputDir string, doArchive bool, doChecksum bool, configPath string, format string, push bool, imageName string, noSign bool, notarize bool, verbose bool) error {
|
||||||
// Get current working directory as project root
|
// Get current working directory as project root
|
||||||
projectDir, err := os.Getwd()
|
projectDir, err := os.Getwd()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -80,8 +80,8 @@ func runProjectBuild(buildType string, ciMode bool, targetsFlag string, outputDi
|
||||||
binaryName = filepath.Base(projectDir)
|
binaryName = filepath.Base(projectDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Print build info (unless CI mode)
|
// Print build info (verbose mode only)
|
||||||
if !ciMode {
|
if verbose && !ciMode {
|
||||||
fmt.Printf("%s %s\n", buildHeaderStyle.Render(i18n.T("cmd.build.label.build")), i18n.T("cmd.build.building_project"))
|
fmt.Printf("%s %s\n", buildHeaderStyle.Render(i18n.T("cmd.build.label.build")), i18n.T("cmd.build.building_project"))
|
||||||
fmt.Printf(" %s %s\n", i18n.T("cmd.build.label.type"), buildTargetStyle.Render(string(projectType)))
|
fmt.Printf(" %s %s\n", i18n.T("cmd.build.label.type"), buildTargetStyle.Render(string(projectType)))
|
||||||
fmt.Printf(" %s %s\n", i18n.T("cmd.build.label.output"), buildTargetStyle.Render(outputDir))
|
fmt.Printf(" %s %s\n", i18n.T("cmd.build.label.output"), buildTargetStyle.Render(outputDir))
|
||||||
|
|
@ -120,12 +120,12 @@ func runProjectBuild(buildType string, ciMode bool, targetsFlag string, outputDi
|
||||||
artifacts, err := builder.Build(ctx, cfg, buildTargets)
|
artifacts, err := builder.Build(ctx, cfg, buildTargets)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if !ciMode {
|
if !ciMode {
|
||||||
fmt.Printf("%s %s: %v\n", buildErrorStyle.Render(i18n.T("common.label.error")), i18n.T("common.error.failed", map[string]any{"Action": "build"}), err)
|
fmt.Printf("%s %v\n", buildErrorStyle.Render(i18n.T("common.label.error")), err)
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if !ciMode {
|
if verbose && !ciMode {
|
||||||
fmt.Printf("%s %s\n", buildSuccessStyle.Render(i18n.T("common.label.success")), i18n.T("cmd.build.built_artifacts", map[string]interface{}{"Count": len(artifacts)}))
|
fmt.Printf("%s %s\n", buildSuccessStyle.Render(i18n.T("common.label.success")), i18n.T("cmd.build.built_artifacts", map[string]interface{}{"Count": len(artifacts)}))
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
for _, artifact := range artifacts {
|
for _, artifact := range artifacts {
|
||||||
|
|
@ -151,7 +151,7 @@ func runProjectBuild(buildType string, ciMode bool, targetsFlag string, outputDi
|
||||||
}
|
}
|
||||||
|
|
||||||
if signCfg.Enabled && runtime.GOOS == "darwin" {
|
if signCfg.Enabled && runtime.GOOS == "darwin" {
|
||||||
if !ciMode {
|
if verbose && !ciMode {
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
fmt.Printf("%s %s\n", buildHeaderStyle.Render(i18n.T("cmd.build.label.sign")), i18n.T("cmd.build.signing_binaries"))
|
fmt.Printf("%s %s\n", buildHeaderStyle.Render(i18n.T("cmd.build.label.sign")), i18n.T("cmd.build.signing_binaries"))
|
||||||
}
|
}
|
||||||
|
|
@ -182,7 +182,7 @@ func runProjectBuild(buildType string, ciMode bool, targetsFlag string, outputDi
|
||||||
// Archive artifacts if enabled
|
// Archive artifacts if enabled
|
||||||
var archivedArtifacts []build.Artifact
|
var archivedArtifacts []build.Artifact
|
||||||
if doArchive && len(artifacts) > 0 {
|
if doArchive && len(artifacts) > 0 {
|
||||||
if !ciMode {
|
if verbose && !ciMode {
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
fmt.Printf("%s %s\n", buildHeaderStyle.Render(i18n.T("cmd.build.label.archive")), i18n.T("cmd.build.creating_archives"))
|
fmt.Printf("%s %s\n", buildHeaderStyle.Render(i18n.T("cmd.build.label.archive")), i18n.T("cmd.build.creating_archives"))
|
||||||
}
|
}
|
||||||
|
|
@ -195,7 +195,7 @@ func runProjectBuild(buildType string, ciMode bool, targetsFlag string, outputDi
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if !ciMode {
|
if verbose && !ciMode {
|
||||||
for _, artifact := range archivedArtifacts {
|
for _, artifact := range archivedArtifacts {
|
||||||
relPath, err := filepath.Rel(projectDir, artifact.Path)
|
relPath, err := filepath.Rel(projectDir, artifact.Path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -213,19 +213,19 @@ func runProjectBuild(buildType string, ciMode bool, targetsFlag string, outputDi
|
||||||
// Compute checksums if enabled
|
// Compute checksums if enabled
|
||||||
var checksummedArtifacts []build.Artifact
|
var checksummedArtifacts []build.Artifact
|
||||||
if doChecksum && len(archivedArtifacts) > 0 {
|
if doChecksum && len(archivedArtifacts) > 0 {
|
||||||
checksummedArtifacts, err = computeAndWriteChecksums(ctx, projectDir, outputDir, archivedArtifacts, signCfg, ciMode)
|
checksummedArtifacts, err = computeAndWriteChecksums(ctx, projectDir, outputDir, archivedArtifacts, signCfg, ciMode, verbose)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
} else if doChecksum && len(artifacts) > 0 && !doArchive {
|
} else if doChecksum && len(artifacts) > 0 && !doArchive {
|
||||||
// Checksum raw binaries if archiving is disabled
|
// Checksum raw binaries if archiving is disabled
|
||||||
checksummedArtifacts, err = computeAndWriteChecksums(ctx, projectDir, outputDir, artifacts, signCfg, ciMode)
|
checksummedArtifacts, err = computeAndWriteChecksums(ctx, projectDir, outputDir, artifacts, signCfg, ciMode, verbose)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Output results for CI mode
|
// Output results
|
||||||
if ciMode {
|
if ciMode {
|
||||||
// Determine which artifacts to output (prefer checksummed > archived > raw)
|
// Determine which artifacts to output (prefer checksummed > archived > raw)
|
||||||
var outputArtifacts []build.Artifact
|
var outputArtifacts []build.Artifact
|
||||||
|
|
@ -243,14 +243,21 @@ func runProjectBuild(buildType string, ciMode bool, targetsFlag string, outputDi
|
||||||
return fmt.Errorf("%s: %w", i18n.T("common.error.failed", map[string]any{"Action": "marshal artifacts"}), err)
|
return fmt.Errorf("%s: %w", i18n.T("common.error.failed", map[string]any{"Action": "marshal artifacts"}), err)
|
||||||
}
|
}
|
||||||
fmt.Println(string(output))
|
fmt.Println(string(output))
|
||||||
|
} else if !verbose {
|
||||||
|
// Minimal output: just success with artifact count
|
||||||
|
fmt.Printf("%s %s %s\n",
|
||||||
|
buildSuccessStyle.Render(i18n.T("common.label.success")),
|
||||||
|
i18n.T("cmd.build.built_artifacts", map[string]interface{}{"Count": len(artifacts)}),
|
||||||
|
buildDimStyle.Render(fmt.Sprintf("(%s)", outputDir)),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// computeAndWriteChecksums computes checksums for artifacts and writes CHECKSUMS.txt.
|
// computeAndWriteChecksums computes checksums for artifacts and writes CHECKSUMS.txt.
|
||||||
func computeAndWriteChecksums(ctx context.Context, projectDir, outputDir string, artifacts []build.Artifact, signCfg signing.SignConfig, ciMode bool) ([]build.Artifact, error) {
|
func computeAndWriteChecksums(ctx context.Context, projectDir, outputDir string, artifacts []build.Artifact, signCfg signing.SignConfig, ciMode bool, verbose bool) ([]build.Artifact, error) {
|
||||||
if !ciMode {
|
if verbose && !ciMode {
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
fmt.Printf("%s %s\n", buildHeaderStyle.Render(i18n.T("cmd.build.label.checksum")), i18n.T("cmd.build.computing_checksums"))
|
fmt.Printf("%s %s\n", buildHeaderStyle.Render(i18n.T("cmd.build.label.checksum")), i18n.T("cmd.build.computing_checksums"))
|
||||||
}
|
}
|
||||||
|
|
@ -282,7 +289,7 @@ func computeAndWriteChecksums(ctx context.Context, projectDir, outputDir string,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !ciMode {
|
if verbose && !ciMode {
|
||||||
for _, artifact := range checksummedArtifacts {
|
for _, artifact := range checksummedArtifacts {
|
||||||
relPath, err := filepath.Rel(projectDir, artifact.Path)
|
relPath, err := filepath.Rel(projectDir, artifact.Path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,7 @@ func downloadPWA(baseURL, destDir string) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("%s %s: %w", i18n.T("common.error.failed", map[string]any{"Action": "fetch URL"}), baseURL, err)
|
return fmt.Errorf("%s %s: %w", i18n.T("common.error.failed", map[string]any{"Action": "fetch URL"}), baseURL, err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
body, err := io.ReadAll(resp.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -152,7 +152,7 @@ func fetchManifest(manifestURL string) (map[string]interface{}, error) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
var manifest map[string]interface{}
|
var manifest map[string]interface{}
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&manifest); err != nil {
|
if err := json.NewDecoder(resp.Body).Decode(&manifest); err != nil {
|
||||||
|
|
@ -195,7 +195,7 @@ func downloadAsset(assetURL, destDir string) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
u, err := url.Parse(assetURL)
|
u, err := url.Parse(assetURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -211,7 +211,7 @@ func downloadAsset(assetURL, destDir string) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer out.Close()
|
defer func() { _ = out.Close() }()
|
||||||
|
|
||||||
_, err = io.Copy(out, resp.Body)
|
_, err = io.Copy(out, resp.Body)
|
||||||
return err
|
return err
|
||||||
|
|
@ -310,13 +310,13 @@ func copyDir(src, dst string) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer srcFile.Close()
|
defer func() { _ = srcFile.Close() }()
|
||||||
|
|
||||||
dstFile, err := os.Create(dstPath)
|
dstFile, err := os.Create(dstPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer dstFile.Close()
|
defer func() { _ = dstFile.Close() }()
|
||||||
|
|
||||||
_, err = io.Copy(dstFile, srcFile)
|
_, err = io.Copy(dstFile, srcFile)
|
||||||
return err
|
return err
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,7 @@ func (b *WailsBuilder) Build(ctx context.Context, cfg *build.Config, targets []b
|
||||||
if detected, _ := taskBuilder.Detect(cfg.ProjectDir); detected {
|
if detected, _ := taskBuilder.Detect(cfg.ProjectDir); detected {
|
||||||
return taskBuilder.Build(ctx, cfg, targets)
|
return taskBuilder.Build(ctx, cfg, targets)
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("Wails v3 projects require a Taskfile for building")
|
return nil, fmt.Errorf("wails v3 projects require a Taskfile for building")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wails v2 strategy: Use 'wails build'
|
// Wails v2 strategy: Use 'wails build'
|
||||||
|
|
@ -252,15 +252,6 @@ func fileExists(path string) bool {
|
||||||
return !info.IsDir()
|
return !info.IsDir()
|
||||||
}
|
}
|
||||||
|
|
||||||
// dirExists checks if a directory exists.
|
|
||||||
func dirExists(path string) bool {
|
|
||||||
info, err := os.Stat(path)
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return info.IsDir()
|
|
||||||
}
|
|
||||||
|
|
||||||
// fileOrDirExists checks if a file or directory exists.
|
// fileOrDirExists checks if a file or directory exists.
|
||||||
func fileOrDirExists(path string) bool {
|
func fileOrDirExists(path string) bool {
|
||||||
_, err := os.Stat(path)
|
_, err := os.Stat(path)
|
||||||
|
|
|
||||||
|
|
@ -60,35 +60,6 @@ tasks:
|
||||||
return dir
|
return dir
|
||||||
}
|
}
|
||||||
|
|
||||||
// setupWailsTestProjectWithFrontend creates a Wails project with frontend directory.
|
|
||||||
func setupWailsTestProjectWithFrontend(t *testing.T, lockFile string) string {
|
|
||||||
t.Helper()
|
|
||||||
dir := setupWailsTestProject(t)
|
|
||||||
|
|
||||||
// Create frontend directory
|
|
||||||
frontendDir := filepath.Join(dir, "frontend")
|
|
||||||
err := os.MkdirAll(frontendDir, 0755)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Create package.json
|
|
||||||
packageJSON := `{
|
|
||||||
"name": "frontend",
|
|
||||||
"scripts": {
|
|
||||||
"build": "echo building frontend"
|
|
||||||
}
|
|
||||||
}`
|
|
||||||
err = os.WriteFile(filepath.Join(frontendDir, "package.json"), []byte(packageJSON), 0644)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Create lock file if specified
|
|
||||||
if lockFile != "" {
|
|
||||||
err = os.WriteFile(filepath.Join(frontendDir, lockFile), []byte(""), 0644)
|
|
||||||
require.NoError(t, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return dir
|
|
||||||
}
|
|
||||||
|
|
||||||
// setupWailsV2TestProject creates a Wails v2 project structure.
|
// setupWailsV2TestProject creates a Wails v2 project structure.
|
||||||
func setupWailsV2TestProject(t *testing.T) string {
|
func setupWailsV2TestProject(t *testing.T) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
@ -181,12 +152,8 @@ func TestWailsBuilder_Build_V2_Good(t *testing.T) {
|
||||||
// This will likely fail in a real run because we can't easily mock the full wails v2 build process
|
// This will likely fail in a real run because we can't easily mock the full wails v2 build process
|
||||||
// (which needs a valid project with main.go etc).
|
// (which needs a valid project with main.go etc).
|
||||||
// But it validates we are trying to run the command.
|
// But it validates we are trying to run the command.
|
||||||
// For now, we expect an error but check it's the *right* error (from wails CLI)
|
// For now, we just verify it attempts the build - error is expected
|
||||||
_, err := builder.Build(context.Background(), cfg, targets)
|
_, _ = builder.Build(context.Background(), cfg, targets)
|
||||||
if err != nil {
|
|
||||||
// If it fails, it should be because wails build failed, not because logic was wrong
|
|
||||||
// assert.Contains(t, err.Error(), "wails build failed")
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -162,7 +162,7 @@ func ConfigExists(dir string) bool {
|
||||||
func (cfg *BuildConfig) ToTargets() []Target {
|
func (cfg *BuildConfig) ToTargets() []Target {
|
||||||
targets := make([]Target, len(cfg.Targets))
|
targets := make([]Target, len(cfg.Targets))
|
||||||
for i, t := range cfg.Targets {
|
for i, t := range cfg.Targets {
|
||||||
targets[i] = Target{OS: t.OS, Arch: t.Arch}
|
targets[i] = Target(t)
|
||||||
}
|
}
|
||||||
return targets
|
return targets
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -100,7 +100,7 @@ func (p *PIDFile) Acquire() error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Stale PID file, remove it
|
// Stale PID file, remove it
|
||||||
os.Remove(p.path)
|
_ = os.Remove(p.path)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure directory exists
|
// Ensure directory exists
|
||||||
|
|
@ -183,13 +183,13 @@ func (h *HealthServer) Start() error {
|
||||||
for _, check := range checks {
|
for _, check := range checks {
|
||||||
if err := check(); err != nil {
|
if err := check(); err != nil {
|
||||||
w.WriteHeader(http.StatusServiceUnavailable)
|
w.WriteHeader(http.StatusServiceUnavailable)
|
||||||
fmt.Fprintf(w, "unhealthy: %v\n", err)
|
_, _ = fmt.Fprintf(w, "unhealthy: %v\n", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
fmt.Fprintln(w, "ok")
|
_, _ = fmt.Fprintln(w, "ok")
|
||||||
})
|
})
|
||||||
|
|
||||||
mux.HandleFunc("/ready", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/ready", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
@ -199,12 +199,12 @@ func (h *HealthServer) Start() error {
|
||||||
|
|
||||||
if !ready {
|
if !ready {
|
||||||
w.WriteHeader(http.StatusServiceUnavailable)
|
w.WriteHeader(http.StatusServiceUnavailable)
|
||||||
fmt.Fprintln(w, "not ready")
|
_, _ = fmt.Fprintln(w, "not ready")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
fmt.Fprintln(w, "ready")
|
_, _ = fmt.Fprintln(w, "ready")
|
||||||
})
|
})
|
||||||
|
|
||||||
listener, err := net.Listen("tcp", h.addr)
|
listener, err := net.Listen("tcp", h.addr)
|
||||||
|
|
@ -322,7 +322,7 @@ func (d *Daemon) Start() error {
|
||||||
if d.health != nil {
|
if d.health != nil {
|
||||||
if err := d.health.Start(); err != nil {
|
if err := d.health.Start(); err != nil {
|
||||||
if d.pid != nil {
|
if d.pid != nil {
|
||||||
d.pid.Release()
|
_ = d.pid.Release()
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -96,7 +96,7 @@ func TestHealthServer(t *testing.T) {
|
||||||
|
|
||||||
err := hs.Start()
|
err := hs.Start()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer hs.Stop(context.Background())
|
defer func() { _ = hs.Stop(context.Background()) }()
|
||||||
|
|
||||||
addr := hs.Addr()
|
addr := hs.Addr()
|
||||||
require.NotEmpty(t, addr)
|
require.NotEmpty(t, addr)
|
||||||
|
|
@ -135,7 +135,7 @@ func TestHealthServer(t *testing.T) {
|
||||||
|
|
||||||
err := hs.Start()
|
err := hs.Start()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer hs.Stop(context.Background())
|
defer func() { _ = hs.Stop(context.Background()) }()
|
||||||
|
|
||||||
addr := hs.Addr()
|
addr := hs.Addr()
|
||||||
|
|
||||||
|
|
@ -193,7 +193,7 @@ func TestDaemon(t *testing.T) {
|
||||||
|
|
||||||
err := d.Start()
|
err := d.Start()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer d.Stop()
|
defer func() { _ = d.Stop() }()
|
||||||
|
|
||||||
err = d.Start()
|
err = d.Start()
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
|
|
@ -218,7 +218,7 @@ func TestDaemon(t *testing.T) {
|
||||||
|
|
||||||
err := d.Start()
|
err := d.Start()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer d.Stop()
|
defer func() { _ = d.Stop() }()
|
||||||
|
|
||||||
addr := d.HealthAddr()
|
addr := d.HealthAddr()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ func NewI18nService(opts I18nOptions) func(*framework.Core) (any, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if opts.Language != "" {
|
if opts.Language != "" {
|
||||||
svc.SetLanguage(opts.Language)
|
_ = svc.SetLanguage(opts.Language)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set mode if specified
|
// Set mode if specified
|
||||||
|
|
@ -131,7 +131,7 @@ func (s *I18nService) T(key string, args ...map[string]any) string {
|
||||||
|
|
||||||
// SetLanguage changes the current language.
|
// SetLanguage changes the current language.
|
||||||
func (s *I18nService) SetLanguage(lang string) {
|
func (s *I18nService) SetLanguage(lang string) {
|
||||||
s.svc.SetLanguage(lang)
|
_ = s.svc.SetLanguage(lang)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Language returns the current language.
|
// Language returns the current language.
|
||||||
|
|
|
||||||
|
|
@ -98,9 +98,10 @@ func isValidRegion(r Region) bool {
|
||||||
func findMatchingBracket(s string, start int) int {
|
func findMatchingBracket(s string, start int) int {
|
||||||
depth := 0
|
depth := 0
|
||||||
for i := start; i < len(s); i++ {
|
for i := start; i < len(s); i++ {
|
||||||
if s[i] == '[' {
|
switch s[i] {
|
||||||
|
case '[':
|
||||||
depth++
|
depth++
|
||||||
} else if s[i] == ']' {
|
case ']':
|
||||||
depth--
|
depth--
|
||||||
if depth == 0 {
|
if depth == 0 {
|
||||||
return i
|
return i
|
||||||
|
|
|
||||||
|
|
@ -8,11 +8,17 @@ import (
|
||||||
// LogLevel aliases for backwards compatibility.
|
// LogLevel aliases for backwards compatibility.
|
||||||
type LogLevel = log.Level
|
type LogLevel = log.Level
|
||||||
|
|
||||||
|
// Log level constants aliased from the log package.
|
||||||
const (
|
const (
|
||||||
|
// LogLevelQuiet suppresses all output.
|
||||||
LogLevelQuiet = log.LevelQuiet
|
LogLevelQuiet = log.LevelQuiet
|
||||||
|
// LogLevelError shows only error messages.
|
||||||
LogLevelError = log.LevelError
|
LogLevelError = log.LevelError
|
||||||
|
// LogLevelWarn shows warnings and errors.
|
||||||
LogLevelWarn = log.LevelWarn
|
LogLevelWarn = log.LevelWarn
|
||||||
|
// LogLevelInfo shows info, warnings, and errors.
|
||||||
LogLevelInfo = log.LevelInfo
|
LogLevelInfo = log.LevelInfo
|
||||||
|
// LogLevelDebug shows all messages including debug.
|
||||||
LogLevelDebug = log.LevelDebug
|
LogLevelDebug = log.LevelDebug
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,11 +14,11 @@ func captureOutput(f func()) string {
|
||||||
|
|
||||||
f()
|
f()
|
||||||
|
|
||||||
w.Close()
|
_ = w.Close()
|
||||||
os.Stdout = old
|
os.Stdout = old
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
io.Copy(&buf, r)
|
_, _ = io.Copy(&buf, r)
|
||||||
return buf.String()
|
return buf.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,8 +30,6 @@ func Prompt(label, defaultVal string) (string, error) {
|
||||||
return input, nil
|
return input, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Select presents numbered options and returns the selected value.
|
// Select presents numbered options and returns the selected value.
|
||||||
func Select(label string, options []string) (string, error) {
|
func Select(label string, options []string) (string, error) {
|
||||||
fmt.Println(label)
|
fmt.Println(label)
|
||||||
|
|
|
||||||
|
|
@ -8,16 +8,25 @@ import (
|
||||||
// RenderStyle controls how layouts are rendered.
|
// RenderStyle controls how layouts are rendered.
|
||||||
type RenderStyle int
|
type RenderStyle int
|
||||||
|
|
||||||
|
// Render style constants for layout output.
|
||||||
const (
|
const (
|
||||||
RenderFlat RenderStyle = iota // No borders
|
// RenderFlat uses no borders or decorations.
|
||||||
RenderSimple // --- separators
|
RenderFlat RenderStyle = iota
|
||||||
RenderBoxed // Unicode box drawing
|
// RenderSimple uses --- separators between sections.
|
||||||
|
RenderSimple
|
||||||
|
// RenderBoxed uses Unicode box drawing characters.
|
||||||
|
RenderBoxed
|
||||||
)
|
)
|
||||||
|
|
||||||
var currentRenderStyle = RenderFlat
|
var currentRenderStyle = RenderFlat
|
||||||
|
|
||||||
|
// UseRenderFlat sets the render style to flat (no borders).
|
||||||
func UseRenderFlat() { currentRenderStyle = RenderFlat }
|
func UseRenderFlat() { currentRenderStyle = RenderFlat }
|
||||||
|
|
||||||
|
// UseRenderSimple sets the render style to simple (--- separators).
|
||||||
func UseRenderSimple() { currentRenderStyle = RenderSimple }
|
func UseRenderSimple() { currentRenderStyle = RenderSimple }
|
||||||
|
|
||||||
|
// UseRenderBoxed sets the render style to boxed (Unicode box drawing).
|
||||||
func UseRenderBoxed() { currentRenderStyle = RenderBoxed }
|
func UseRenderBoxed() { currentRenderStyle = RenderBoxed }
|
||||||
|
|
||||||
// Render outputs the layout to terminal.
|
// Render outputs the layout to terminal.
|
||||||
|
|
|
||||||
|
|
@ -141,11 +141,9 @@ func Shutdown() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
instance.cancel()
|
instance.cancel()
|
||||||
instance.core.ServiceShutdown(instance.ctx)
|
_ = instance.core.ServiceShutdown(instance.ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// --- Signal Service (internal) ---
|
// --- Signal Service (internal) ---
|
||||||
|
|
||||||
type signalService struct {
|
type signalService struct {
|
||||||
|
|
|
||||||
|
|
@ -112,6 +112,7 @@ type Table struct {
|
||||||
Style TableStyle
|
Style TableStyle
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TableStyle configures the appearance of table output.
|
||||||
type TableStyle struct {
|
type TableStyle struct {
|
||||||
HeaderStyle *AnsiStyle
|
HeaderStyle *AnsiStyle
|
||||||
CellStyle *AnsiStyle
|
CellStyle *AnsiStyle
|
||||||
|
|
|
||||||
|
|
@ -20,8 +20,6 @@ func GhAuthenticated() bool {
|
||||||
return strings.Contains(string(output), "Logged in")
|
return strings.Contains(string(output), "Logged in")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// ConfirmOption configures Confirm behaviour.
|
// ConfirmOption configures Confirm behaviour.
|
||||||
type ConfirmOption func(*confirmConfig)
|
type ConfirmOption func(*confirmConfig)
|
||||||
|
|
||||||
|
|
@ -473,8 +471,6 @@ func ChooseMultiAction[T any](verb, subject string, items []T, opts ...ChooseOpt
|
||||||
return ChooseMulti(question, items, opts...)
|
return ChooseMulti(question, items, opts...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// GitClone clones a GitHub repository to the specified path.
|
// GitClone clones a GitHub repository to the specified path.
|
||||||
// Prefers 'gh repo clone' if authenticated, falls back to SSH.
|
// Prefers 'gh repo clone' if authenticated, falls back to SSH.
|
||||||
func GitClone(ctx context.Context, org, repo, path string) error {
|
func GitClone(ctx context.Context, org, repo, path string) error {
|
||||||
|
|
|
||||||
|
|
@ -137,7 +137,7 @@ func (m *LinuxKitManager) Run(ctx context.Context, image string, opts RunOptions
|
||||||
|
|
||||||
// Start the process
|
// Start the process
|
||||||
if err := cmd.Start(); err != nil {
|
if err := cmd.Start(); err != nil {
|
||||||
logFile.Close()
|
_ = logFile.Close()
|
||||||
return nil, fmt.Errorf("failed to start VM: %w", err)
|
return nil, fmt.Errorf("failed to start VM: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -146,13 +146,13 @@ func (m *LinuxKitManager) Run(ctx context.Context, image string, opts RunOptions
|
||||||
// Save state
|
// Save state
|
||||||
if err := m.state.Add(container); err != nil {
|
if err := m.state.Add(container); err != nil {
|
||||||
// Try to kill the process we just started
|
// Try to kill the process we just started
|
||||||
cmd.Process.Kill()
|
_ = cmd.Process.Kill()
|
||||||
logFile.Close()
|
_ = logFile.Close()
|
||||||
return nil, fmt.Errorf("failed to save state: %w", err)
|
return nil, fmt.Errorf("failed to save state: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close log file handle (process has its own)
|
// Close log file handle (process has its own)
|
||||||
logFile.Close()
|
_ = logFile.Close()
|
||||||
|
|
||||||
// Start a goroutine to wait for process exit and update state
|
// Start a goroutine to wait for process exit and update state
|
||||||
go m.waitForExit(container.ID, cmd)
|
go m.waitForExit(container.ID, cmd)
|
||||||
|
|
@ -170,12 +170,12 @@ func (m *LinuxKitManager) Run(ctx context.Context, image string, opts RunOptions
|
||||||
|
|
||||||
stderr, err := cmd.StderrPipe()
|
stderr, err := cmd.StderrPipe()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logFile.Close()
|
_ = logFile.Close()
|
||||||
return nil, fmt.Errorf("failed to get stderr pipe: %w", err)
|
return nil, fmt.Errorf("failed to get stderr pipe: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := cmd.Start(); err != nil {
|
if err := cmd.Start(); err != nil {
|
||||||
logFile.Close()
|
_ = logFile.Close()
|
||||||
return nil, fmt.Errorf("failed to start VM: %w", err)
|
return nil, fmt.Errorf("failed to start VM: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -183,19 +183,19 @@ func (m *LinuxKitManager) Run(ctx context.Context, image string, opts RunOptions
|
||||||
|
|
||||||
// Save state before waiting
|
// Save state before waiting
|
||||||
if err := m.state.Add(container); err != nil {
|
if err := m.state.Add(container); err != nil {
|
||||||
cmd.Process.Kill()
|
_ = cmd.Process.Kill()
|
||||||
logFile.Close()
|
_ = logFile.Close()
|
||||||
return nil, fmt.Errorf("failed to save state: %w", err)
|
return nil, fmt.Errorf("failed to save state: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Copy output to both log and stdout
|
// Copy output to both log and stdout
|
||||||
go func() {
|
go func() {
|
||||||
mw := io.MultiWriter(logFile, os.Stdout)
|
mw := io.MultiWriter(logFile, os.Stdout)
|
||||||
io.Copy(mw, stdout)
|
_, _ = io.Copy(mw, stdout)
|
||||||
}()
|
}()
|
||||||
go func() {
|
go func() {
|
||||||
mw := io.MultiWriter(logFile, os.Stderr)
|
mw := io.MultiWriter(logFile, os.Stderr)
|
||||||
io.Copy(mw, stderr)
|
_, _ = io.Copy(mw, stderr)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Wait for the process to complete
|
// Wait for the process to complete
|
||||||
|
|
@ -205,20 +205,26 @@ func (m *LinuxKitManager) Run(ctx context.Context, image string, opts RunOptions
|
||||||
container.Status = StatusStopped
|
container.Status = StatusStopped
|
||||||
}
|
}
|
||||||
|
|
||||||
logFile.Close()
|
_ = logFile.Close()
|
||||||
m.state.Update(container)
|
if err := m.state.Update(container); err != nil {
|
||||||
|
return container, fmt.Errorf("update container state: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
return container, nil
|
return container, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// waitForExit monitors a detached process and updates state when it exits.
|
// waitForExit monitors a detached process and updates state when it exits.
|
||||||
func (m *LinuxKitManager) waitForExit(id string, cmd *exec.Cmd) {
|
func (m *LinuxKitManager) waitForExit(id string, cmd *exec.Cmd) {
|
||||||
cmd.Wait()
|
err := cmd.Wait()
|
||||||
|
|
||||||
container, ok := m.state.Get(id)
|
container, ok := m.state.Get(id)
|
||||||
if ok {
|
if ok {
|
||||||
|
if err != nil {
|
||||||
|
container.Status = StatusError
|
||||||
|
} else {
|
||||||
container.Status = StatusStopped
|
container.Status = StatusStopped
|
||||||
m.state.Update(container)
|
}
|
||||||
|
_ = m.state.Update(container)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -238,7 +244,7 @@ func (m *LinuxKitManager) Stop(ctx context.Context, id string) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Process doesn't exist, update state
|
// Process doesn't exist, update state
|
||||||
container.Status = StatusStopped
|
container.Status = StatusStopped
|
||||||
m.state.Update(container)
|
_ = m.state.Update(container)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -246,14 +252,14 @@ func (m *LinuxKitManager) Stop(ctx context.Context, id string) error {
|
||||||
if err := process.Signal(syscall.SIGTERM); err != nil {
|
if err := process.Signal(syscall.SIGTERM); err != nil {
|
||||||
// Process might already be gone
|
// Process might already be gone
|
||||||
container.Status = StatusStopped
|
container.Status = StatusStopped
|
||||||
m.state.Update(container)
|
_ = m.state.Update(container)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for graceful shutdown with timeout
|
// Wait for graceful shutdown with timeout
|
||||||
done := make(chan struct{})
|
done := make(chan struct{})
|
||||||
go func() {
|
go func() {
|
||||||
process.Wait()
|
_, _ = process.Wait()
|
||||||
close(done)
|
close(done)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
|
@ -262,11 +268,11 @@ func (m *LinuxKitManager) Stop(ctx context.Context, id string) error {
|
||||||
// Process exited gracefully
|
// Process exited gracefully
|
||||||
case <-time.After(10 * time.Second):
|
case <-time.After(10 * time.Second):
|
||||||
// Force kill
|
// Force kill
|
||||||
process.Signal(syscall.SIGKILL)
|
_ = process.Signal(syscall.SIGKILL)
|
||||||
<-done
|
<-done
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
// Context cancelled
|
// Context cancelled
|
||||||
process.Signal(syscall.SIGKILL)
|
_ = process.Signal(syscall.SIGKILL)
|
||||||
return ctx.Err()
|
return ctx.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -283,7 +289,7 @@ func (m *LinuxKitManager) List(ctx context.Context) ([]*Container, error) {
|
||||||
if c.Status == StatusRunning {
|
if c.Status == StatusRunning {
|
||||||
if !isProcessRunning(c.PID) {
|
if !isProcessRunning(c.PID) {
|
||||||
c.Status = StatusStopped
|
c.Status = StatusStopped
|
||||||
m.state.Update(c)
|
_ = m.state.Update(c)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -346,7 +352,7 @@ func newFollowReader(ctx context.Context, path string) (*followReader, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Seek to end
|
// Seek to end
|
||||||
file.Seek(0, io.SeekEnd)
|
_, _ = file.Seek(0, io.SeekEnd)
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(ctx)
|
ctx, cancel := context.WithCancel(ctx)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -186,7 +186,7 @@ func TestLinuxKitManager_Stop_Good(t *testing.T) {
|
||||||
PID: 999999, // Non-existent PID
|
PID: 999999, // Non-existent PID
|
||||||
StartedAt: time.Now(),
|
StartedAt: time.Now(),
|
||||||
}
|
}
|
||||||
manager.State().Add(container)
|
_ = manager.State().Add(container)
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
err := manager.Stop(ctx, "abc12345")
|
err := manager.Stop(ctx, "abc12345")
|
||||||
|
|
@ -213,17 +213,18 @@ func TestLinuxKitManager_Stop_Bad_NotFound(t *testing.T) {
|
||||||
func TestLinuxKitManager_Stop_Bad_NotRunning(t *testing.T) {
|
func TestLinuxKitManager_Stop_Bad_NotRunning(t *testing.T) {
|
||||||
manager, _, tmpDir := newTestManager(t)
|
manager, _, tmpDir := newTestManager(t)
|
||||||
statePath := filepath.Join(tmpDir, "containers.json")
|
statePath := filepath.Join(tmpDir, "containers.json")
|
||||||
state, _ := LoadState(statePath)
|
state, err := LoadState(statePath)
|
||||||
|
require.NoError(t, err)
|
||||||
manager = NewLinuxKitManagerWithHypervisor(state, NewMockHypervisor())
|
manager = NewLinuxKitManagerWithHypervisor(state, NewMockHypervisor())
|
||||||
|
|
||||||
container := &Container{
|
container := &Container{
|
||||||
ID: "abc12345",
|
ID: "abc12345",
|
||||||
Status: StatusStopped,
|
Status: StatusStopped,
|
||||||
}
|
}
|
||||||
state.Add(container)
|
_ = state.Add(container)
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
err := manager.Stop(ctx, "abc12345")
|
err = manager.Stop(ctx, "abc12345")
|
||||||
|
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
assert.Contains(t, err.Error(), "not running")
|
assert.Contains(t, err.Error(), "not running")
|
||||||
|
|
@ -232,11 +233,12 @@ func TestLinuxKitManager_Stop_Bad_NotRunning(t *testing.T) {
|
||||||
func TestLinuxKitManager_List_Good(t *testing.T) {
|
func TestLinuxKitManager_List_Good(t *testing.T) {
|
||||||
manager, _, tmpDir := newTestManager(t)
|
manager, _, tmpDir := newTestManager(t)
|
||||||
statePath := filepath.Join(tmpDir, "containers.json")
|
statePath := filepath.Join(tmpDir, "containers.json")
|
||||||
state, _ := LoadState(statePath)
|
state, err := LoadState(statePath)
|
||||||
|
require.NoError(t, err)
|
||||||
manager = NewLinuxKitManagerWithHypervisor(state, NewMockHypervisor())
|
manager = NewLinuxKitManagerWithHypervisor(state, NewMockHypervisor())
|
||||||
|
|
||||||
state.Add(&Container{ID: "aaa11111", Status: StatusStopped})
|
_ = state.Add(&Container{ID: "aaa11111", Status: StatusStopped})
|
||||||
state.Add(&Container{ID: "bbb22222", Status: StatusStopped})
|
_ = state.Add(&Container{ID: "bbb22222", Status: StatusStopped})
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
containers, err := manager.List(ctx)
|
containers, err := manager.List(ctx)
|
||||||
|
|
@ -248,11 +250,12 @@ func TestLinuxKitManager_List_Good(t *testing.T) {
|
||||||
func TestLinuxKitManager_List_Good_VerifiesRunningStatus(t *testing.T) {
|
func TestLinuxKitManager_List_Good_VerifiesRunningStatus(t *testing.T) {
|
||||||
manager, _, tmpDir := newTestManager(t)
|
manager, _, tmpDir := newTestManager(t)
|
||||||
statePath := filepath.Join(tmpDir, "containers.json")
|
statePath := filepath.Join(tmpDir, "containers.json")
|
||||||
state, _ := LoadState(statePath)
|
state, err := LoadState(statePath)
|
||||||
|
require.NoError(t, err)
|
||||||
manager = NewLinuxKitManagerWithHypervisor(state, NewMockHypervisor())
|
manager = NewLinuxKitManagerWithHypervisor(state, NewMockHypervisor())
|
||||||
|
|
||||||
// Add a "running" container with a fake PID that doesn't exist
|
// Add a "running" container with a fake PID that doesn't exist
|
||||||
state.Add(&Container{
|
_ = state.Add(&Container{
|
||||||
ID: "abc12345",
|
ID: "abc12345",
|
||||||
Status: StatusRunning,
|
Status: StatusRunning,
|
||||||
PID: 999999, // PID that almost certainly doesn't exist
|
PID: 999999, // PID that almost certainly doesn't exist
|
||||||
|
|
@ -272,17 +275,18 @@ func TestLinuxKitManager_Logs_Good(t *testing.T) {
|
||||||
|
|
||||||
// Create a log file manually
|
// Create a log file manually
|
||||||
logsDir := filepath.Join(tmpDir, "logs")
|
logsDir := filepath.Join(tmpDir, "logs")
|
||||||
os.MkdirAll(logsDir, 0755)
|
require.NoError(t, os.MkdirAll(logsDir, 0755))
|
||||||
|
|
||||||
container := &Container{ID: "abc12345"}
|
container := &Container{ID: "abc12345"}
|
||||||
manager.State().Add(container)
|
_ = manager.State().Add(container)
|
||||||
|
|
||||||
// Override the default logs dir for testing by creating the log file
|
// Override the default logs dir for testing by creating the log file
|
||||||
// at the expected location
|
// at the expected location
|
||||||
logContent := "test log content\nline 2\n"
|
logContent := "test log content\nline 2\n"
|
||||||
logPath, _ := LogPath("abc12345")
|
logPath, err := LogPath("abc12345")
|
||||||
os.MkdirAll(filepath.Dir(logPath), 0755)
|
require.NoError(t, err)
|
||||||
os.WriteFile(logPath, []byte(logContent), 0644)
|
require.NoError(t, os.MkdirAll(filepath.Dir(logPath), 0755))
|
||||||
|
require.NoError(t, os.WriteFile(logPath, []byte(logContent), 0644))
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
reader, err := manager.Logs(ctx, "abc12345", false)
|
reader, err := manager.Logs(ctx, "abc12345", false)
|
||||||
|
|
@ -309,9 +313,10 @@ func TestLinuxKitManager_Logs_Bad_NoLogFile(t *testing.T) {
|
||||||
manager, _, _ := newTestManager(t)
|
manager, _, _ := newTestManager(t)
|
||||||
|
|
||||||
// Use a unique ID that won't have a log file
|
// Use a unique ID that won't have a log file
|
||||||
uniqueID, _ := GenerateID()
|
uniqueID, err := GenerateID()
|
||||||
|
require.NoError(t, err)
|
||||||
container := &Container{ID: uniqueID}
|
container := &Container{ID: uniqueID}
|
||||||
manager.State().Add(container)
|
_ = manager.State().Add(container)
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
reader, err := manager.Logs(ctx, uniqueID, false)
|
reader, err := manager.Logs(ctx, uniqueID, false)
|
||||||
|
|
@ -341,7 +346,7 @@ func TestLinuxKitManager_Exec_Bad_NotRunning(t *testing.T) {
|
||||||
manager, _, _ := newTestManager(t)
|
manager, _, _ := newTestManager(t)
|
||||||
|
|
||||||
container := &Container{ID: "abc12345", Status: StatusStopped}
|
container := &Container{ID: "abc12345", Status: StatusStopped}
|
||||||
manager.State().Add(container)
|
_ = manager.State().Add(container)
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
err := manager.Exec(ctx, "abc12345", []string{"ls"})
|
err := manager.Exec(ctx, "abc12345", []string{"ls"})
|
||||||
|
|
@ -420,19 +425,19 @@ func TestQemuHypervisor_BuildCommand_Good(t *testing.T) {
|
||||||
assert.Contains(t, args, "-nographic")
|
assert.Contains(t, args, "-nographic")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
func TestLinuxKitManager_Logs_Good_Follow(t *testing.T) {
|
func TestLinuxKitManager_Logs_Good_Follow(t *testing.T) {
|
||||||
manager, _, _ := newTestManager(t)
|
manager, _, _ := newTestManager(t)
|
||||||
|
|
||||||
// Create a unique container ID
|
// Create a unique container ID
|
||||||
uniqueID, _ := GenerateID()
|
uniqueID, err := GenerateID()
|
||||||
|
require.NoError(t, err)
|
||||||
container := &Container{ID: uniqueID}
|
container := &Container{ID: uniqueID}
|
||||||
manager.State().Add(container)
|
_ = manager.State().Add(container)
|
||||||
|
|
||||||
// Create a log file at the expected location
|
// Create a log file at the expected location
|
||||||
logPath, err := LogPath(uniqueID)
|
logPath, err := LogPath(uniqueID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
os.MkdirAll(filepath.Dir(logPath), 0755)
|
require.NoError(t, os.MkdirAll(filepath.Dir(logPath), 0755))
|
||||||
|
|
||||||
// Write initial content
|
// Write initial content
|
||||||
err = os.WriteFile(logPath, []byte("initial log content\n"), 0644)
|
err = os.WriteFile(logPath, []byte("initial log content\n"), 0644)
|
||||||
|
|
@ -455,8 +460,7 @@ func TestLinuxKitManager_Logs_Good_Follow(t *testing.T) {
|
||||||
assert.Equal(t, "EOF", readErr.Error())
|
assert.Equal(t, "EOF", readErr.Error())
|
||||||
|
|
||||||
// Close the reader
|
// Close the reader
|
||||||
err = reader.Close()
|
assert.NoError(t, reader.Close())
|
||||||
assert.NoError(t, err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFollowReader_Read_Good_WithData(t *testing.T) {
|
func TestFollowReader_Read_Good_WithData(t *testing.T) {
|
||||||
|
|
@ -480,7 +484,7 @@ func TestFollowReader_Read_Good_WithData(t *testing.T) {
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
_, err = f.WriteString("new line\n")
|
_, err = f.WriteString("new line\n")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
f.Close()
|
require.NoError(t, f.Close())
|
||||||
|
|
||||||
// Give the reader time to poll
|
// Give the reader time to poll
|
||||||
time.Sleep(150 * time.Millisecond)
|
time.Sleep(150 * time.Millisecond)
|
||||||
|
|
@ -513,7 +517,7 @@ func TestFollowReader_Read_Good_ContextCancel(t *testing.T) {
|
||||||
_, readErr := reader.Read(buf)
|
_, readErr := reader.Read(buf)
|
||||||
assert.Equal(t, "EOF", readErr.Error())
|
assert.Equal(t, "EOF", readErr.Error())
|
||||||
|
|
||||||
reader.Close()
|
_ = reader.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFollowReader_Close_Good(t *testing.T) {
|
func TestFollowReader_Close_Good(t *testing.T) {
|
||||||
|
|
@ -766,7 +770,7 @@ func TestLinuxKitManager_Stop_Good_ProcessExitedWhileRunning(t *testing.T) {
|
||||||
PID: 999999, // Non-existent PID
|
PID: 999999, // Non-existent PID
|
||||||
StartedAt: time.Now(),
|
StartedAt: time.Now(),
|
||||||
}
|
}
|
||||||
manager.State().Add(container)
|
_ = manager.State().Add(container)
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
err := manager.Stop(ctx, "test1234")
|
err := manager.Stop(ctx, "test1234")
|
||||||
|
|
|
||||||
|
|
@ -34,8 +34,8 @@ var (
|
||||||
applyContinue bool // Continue on error
|
applyContinue bool // Continue on error
|
||||||
)
|
)
|
||||||
|
|
||||||
// addApplyCommand adds the 'apply' command to dev.
|
// AddApplyCommand adds the 'apply' command to dev.
|
||||||
func addApplyCommand(parent *cli.Command) {
|
func AddApplyCommand(parent *cli.Command) {
|
||||||
applyCmd := &cli.Command{
|
applyCmd := &cli.Command{
|
||||||
Use: "apply",
|
Use: "apply",
|
||||||
Short: i18n.T("cmd.dev.apply.short"),
|
Short: i18n.T("cmd.dev.apply.short"),
|
||||||
|
|
|
||||||
|
|
@ -63,9 +63,7 @@ type StatusBundleOptions struct {
|
||||||
// Includes: dev (orchestration), git services. No agentic - commits not available.
|
// Includes: dev (orchestration), git services. No agentic - commits not available.
|
||||||
func NewStatusBundle(opts StatusBundleOptions) (*StatusBundle, error) {
|
func NewStatusBundle(opts StatusBundleOptions) (*StatusBundle, error) {
|
||||||
c, err := framework.New(
|
c, err := framework.New(
|
||||||
framework.WithService(NewService(ServiceOptions{
|
framework.WithService(NewService(ServiceOptions(opts))),
|
||||||
RegistryPath: opts.RegistryPath,
|
|
||||||
})),
|
|
||||||
framework.WithService(git.NewService(git.ServiceOptions{})),
|
framework.WithService(git.NewService(git.ServiceOptions{})),
|
||||||
// No agentic service - TaskCommit will be unhandled
|
// No agentic service - TaskCommit will be unhandled
|
||||||
framework.WithServiceLock(),
|
framework.WithServiceLock(),
|
||||||
|
|
|
||||||
|
|
@ -229,11 +229,12 @@ func printWorkflowRun(run WorkflowRun) {
|
||||||
case "failure":
|
case "failure":
|
||||||
status = ciFailureStyle.Render("x")
|
status = ciFailureStyle.Render("x")
|
||||||
case "":
|
case "":
|
||||||
if run.Status == "in_progress" {
|
switch run.Status {
|
||||||
|
case "in_progress":
|
||||||
status = ciPendingStyle.Render("*")
|
status = ciPendingStyle.Render("*")
|
||||||
} else if run.Status == "queued" {
|
case "queued":
|
||||||
status = ciPendingStyle.Render("o")
|
status = ciPendingStyle.Render("o")
|
||||||
} else {
|
default:
|
||||||
status = ciSkippedStyle.Render("-")
|
status = ciSkippedStyle.Render("-")
|
||||||
}
|
}
|
||||||
case "skipped":
|
case "skipped":
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,8 @@ var (
|
||||||
commitAll bool
|
commitAll bool
|
||||||
)
|
)
|
||||||
|
|
||||||
// addCommitCommand adds the 'commit' command to the given parent command.
|
// AddCommitCommand adds the 'commit' command to the given parent command.
|
||||||
func addCommitCommand(parent *cli.Command) {
|
func AddCommitCommand(parent *cli.Command) {
|
||||||
commitCmd := &cli.Command{
|
commitCmd := &cli.Command{
|
||||||
Use: "commit",
|
Use: "commit",
|
||||||
Short: i18n.T("cmd.dev.commit.short"),
|
Short: i18n.T("cmd.dev.commit.short"),
|
||||||
|
|
|
||||||
|
|
@ -68,16 +68,16 @@ func AddDevCommands(root *cli.Command) {
|
||||||
}
|
}
|
||||||
root.AddCommand(devCmd)
|
root.AddCommand(devCmd)
|
||||||
|
|
||||||
// Git operations
|
// Git operations (also available under 'core git')
|
||||||
addWorkCommand(devCmd)
|
AddWorkCommand(devCmd)
|
||||||
addHealthCommand(devCmd)
|
AddHealthCommand(devCmd)
|
||||||
addCommitCommand(devCmd)
|
AddCommitCommand(devCmd)
|
||||||
addPushCommand(devCmd)
|
AddPushCommand(devCmd)
|
||||||
addPullCommand(devCmd)
|
AddPullCommand(devCmd)
|
||||||
|
|
||||||
// Safe git operations for AI agents
|
// Safe git operations for AI agents (also available under 'core git')
|
||||||
addFileSyncCommand(devCmd)
|
AddFileSyncCommand(devCmd)
|
||||||
addApplyCommand(devCmd)
|
AddApplyCommand(devCmd)
|
||||||
|
|
||||||
// GitHub integration
|
// GitHub integration
|
||||||
addIssuesCommand(devCmd)
|
addIssuesCommand(devCmd)
|
||||||
|
|
|
||||||
|
|
@ -31,8 +31,8 @@ var (
|
||||||
fileSyncPush bool
|
fileSyncPush bool
|
||||||
)
|
)
|
||||||
|
|
||||||
// addFileSyncCommand adds the 'sync' command to dev for file syncing.
|
// AddFileSyncCommand adds the 'sync' command to dev for file syncing.
|
||||||
func addFileSyncCommand(parent *cli.Command) {
|
func AddFileSyncCommand(parent *cli.Command) {
|
||||||
syncCmd := &cli.Command{
|
syncCmd := &cli.Command{
|
||||||
Use: "sync <file-or-dir>",
|
Use: "sync <file-or-dir>",
|
||||||
Short: i18n.T("cmd.dev.file_sync.short"),
|
Short: i18n.T("cmd.dev.file_sync.short"),
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,8 @@ var (
|
||||||
healthVerbose bool
|
healthVerbose bool
|
||||||
)
|
)
|
||||||
|
|
||||||
// addHealthCommand adds the 'health' command to the given parent command.
|
// AddHealthCommand adds the 'health' command to the given parent command.
|
||||||
func addHealthCommand(parent *cli.Command) {
|
func AddHealthCommand(parent *cli.Command) {
|
||||||
healthCmd := &cli.Command{
|
healthCmd := &cli.Command{
|
||||||
Use: "health",
|
Use: "health",
|
||||||
Short: i18n.T("cmd.dev.health.short"),
|
Short: i18n.T("cmd.dev.health.short"),
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,8 @@ var (
|
||||||
pullAll bool
|
pullAll bool
|
||||||
)
|
)
|
||||||
|
|
||||||
// addPullCommand adds the 'pull' command to the given parent command.
|
// AddPullCommand adds the 'pull' command to the given parent command.
|
||||||
func addPullCommand(parent *cli.Command) {
|
func AddPullCommand(parent *cli.Command) {
|
||||||
pullCmd := &cli.Command{
|
pullCmd := &cli.Command{
|
||||||
Use: "pull",
|
Use: "pull",
|
||||||
Short: i18n.T("cmd.dev.pull.short"),
|
Short: i18n.T("cmd.dev.pull.short"),
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,8 @@ var (
|
||||||
pushForce bool
|
pushForce bool
|
||||||
)
|
)
|
||||||
|
|
||||||
// addPushCommand adds the 'push' command to the given parent command.
|
// AddPushCommand adds the 'push' command to the given parent command.
|
||||||
func addPushCommand(parent *cli.Command) {
|
func AddPushCommand(parent *cli.Command) {
|
||||||
pushCmd := &cli.Command{
|
pushCmd := &cli.Command{
|
||||||
Use: "push",
|
Use: "push",
|
||||||
Short: i18n.T("cmd.dev.push.short"),
|
Short: i18n.T("cmd.dev.push.short"),
|
||||||
|
|
|
||||||
|
|
@ -20,8 +20,8 @@ var (
|
||||||
workRegistryPath string
|
workRegistryPath string
|
||||||
)
|
)
|
||||||
|
|
||||||
// addWorkCommand adds the 'work' command to the given parent command.
|
// AddWorkCommand adds the 'work' command to the given parent command.
|
||||||
func addWorkCommand(parent *cli.Command) {
|
func AddWorkCommand(parent *cli.Command) {
|
||||||
workCmd := &cli.Command{
|
workCmd := &cli.Command{
|
||||||
Use: "work",
|
Use: "work",
|
||||||
Short: i18n.T("cmd.dev.work.short"),
|
Short: i18n.T("cmd.dev.work.short"),
|
||||||
|
|
@ -342,5 +342,3 @@ func claudeEditCommit(ctx context.Context, repoPath, repoName, registryPath stri
|
||||||
|
|
||||||
return cmd.Run()
|
return cmd.Run()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@ func (s *CDNSource) LatestVersion(ctx context.Context) (string, error) {
|
||||||
if err != nil || resp.StatusCode != 200 {
|
if err != nil || resp.StatusCode != 200 {
|
||||||
return "latest", nil
|
return "latest", nil
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
// For now, just return latest - could parse manifest for version
|
// For now, just return latest - could parse manifest for version
|
||||||
return "latest", nil
|
return "latest", nil
|
||||||
|
|
@ -64,7 +64,7 @@ func (s *CDNSource) Download(ctx context.Context, dest string, progress func(dow
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("cdn.Download: %w", err)
|
return fmt.Errorf("cdn.Download: %w", err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
if resp.StatusCode != 200 {
|
if resp.StatusCode != 200 {
|
||||||
return fmt.Errorf("cdn.Download: HTTP %d", resp.StatusCode)
|
return fmt.Errorf("cdn.Download: HTTP %d", resp.StatusCode)
|
||||||
|
|
@ -81,7 +81,7 @@ func (s *CDNSource) Download(ctx context.Context, dest string, progress func(dow
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("cdn.Download: %w", err)
|
return fmt.Errorf("cdn.Download: %w", err)
|
||||||
}
|
}
|
||||||
defer f.Close()
|
defer func() { _ = f.Close() }()
|
||||||
|
|
||||||
// Copy with progress
|
// Copy with progress
|
||||||
total := resp.ContentLength
|
total := resp.ContentLength
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ func TestCDNSource_LatestVersion_Good(t *testing.T) {
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.URL.Path == "/manifest.json" {
|
if r.URL.Path == "/manifest.json" {
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
fmt.Fprint(w, `{"version": "1.2.3"}`)
|
_, _ = fmt.Fprint(w, `{"version": "1.2.3"}`)
|
||||||
} else {
|
} else {
|
||||||
w.WriteHeader(http.StatusNotFound)
|
w.WriteHeader(http.StatusNotFound)
|
||||||
}
|
}
|
||||||
|
|
@ -56,7 +56,7 @@ func TestCDNSource_Download_Good(t *testing.T) {
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.URL.Path == "/test.img" {
|
if r.URL.Path == "/test.img" {
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
fmt.Fprint(w, content)
|
_, _ = fmt.Fprint(w, content)
|
||||||
} else {
|
} else {
|
||||||
w.WriteHeader(http.StatusNotFound)
|
w.WriteHeader(http.StatusNotFound)
|
||||||
}
|
}
|
||||||
|
|
@ -151,7 +151,7 @@ func TestCDNSource_Download_Good_NoProgress(t *testing.T) {
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(content)))
|
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(content)))
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
fmt.Fprint(w, content)
|
_, _ = fmt.Fprint(w, content)
|
||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
|
|
@ -180,7 +180,7 @@ func TestCDNSource_Download_Good_LargeFile(t *testing.T) {
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(content)))
|
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(content)))
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
w.Write(content)
|
_, _ = w.Write(content)
|
||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
|
|
@ -268,7 +268,7 @@ func TestCDNSource_Download_Good_CreatesDestDir(t *testing.T) {
|
||||||
content := "test content"
|
content := "test content"
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
fmt.Fprint(w, content)
|
_, _ = fmt.Fprint(w, content)
|
||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,6 @@ var (
|
||||||
headerStyle = cli.HeaderStyle
|
headerStyle = cli.HeaderStyle
|
||||||
confirm = cli.Confirm
|
confirm = cli.Confirm
|
||||||
docsFoundStyle = cli.SuccessStyle
|
docsFoundStyle = cli.SuccessStyle
|
||||||
docsMissingStyle = cli.DimStyle
|
|
||||||
docsFileStyle = cli.InfoStyle
|
docsFileStyle = cli.InfoStyle
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -356,6 +356,7 @@ func (c *Core) Display() Display {
|
||||||
return d
|
return d
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Core returns self, implementing the CoreProvider interface.
|
||||||
func (c *Core) Core() *Core { return c }
|
func (c *Core) Core() *Core { return c }
|
||||||
|
|
||||||
// Assets returns the embedded filesystem containing the application's assets.
|
// Assets returns the embedded filesystem containing the application's assets.
|
||||||
|
|
|
||||||
|
|
@ -73,8 +73,6 @@ type Stoppable interface {
|
||||||
|
|
||||||
// Core is the central application object that manages services, assets, and communication.
|
// Core is the central application object that manages services, assets, and communication.
|
||||||
type Core struct {
|
type Core struct {
|
||||||
once sync.Once
|
|
||||||
initErr error
|
|
||||||
App any // GUI runtime (e.g., Wails App) - set by WithApp option
|
App any // GUI runtime (e.g., Wails App) - set by WithApp option
|
||||||
assets embed.FS
|
assets embed.FS
|
||||||
Features *Features
|
Features *Features
|
||||||
|
|
|
||||||
|
|
@ -55,11 +55,12 @@ func NewServiceRuntime[T any](c *Core, opts T) *ServiceRuntime[T] {
|
||||||
return core.NewServiceRuntime(c, opts)
|
return core.NewServiceRuntime(c, opts)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-export generic functions
|
// ServiceFor retrieves a typed service from the core container by name.
|
||||||
func ServiceFor[T any](c *Core, name string) (T, error) {
|
func ServiceFor[T any](c *Core, name string) (T, error) {
|
||||||
return core.ServiceFor[T](c, name)
|
return core.ServiceFor[T](c, name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MustServiceFor retrieves a typed service or panics if not found.
|
||||||
func MustServiceFor[T any](c *Core, name string) T {
|
func MustServiceFor[T any](c *Core, name string) T {
|
||||||
return core.MustServiceFor[T](c, name)
|
return core.MustServiceFor[T](c, name)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -249,6 +249,7 @@ type GitError struct {
|
||||||
Stderr string
|
Stderr string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Error returns the git error message, preferring stderr output.
|
||||||
func (e *GitError) Error() string {
|
func (e *GitError) Error() string {
|
||||||
// Return just the stderr message, trimmed
|
// Return just the stderr message, trimmed
|
||||||
msg := strings.TrimSpace(e.Stderr)
|
msg := strings.TrimSpace(e.Stderr)
|
||||||
|
|
@ -258,6 +259,7 @@ func (e *GitError) Error() string {
|
||||||
return e.Err.Error()
|
return e.Err.Error()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Unwrap returns the underlying error for error chain inspection.
|
||||||
func (e *GitError) Unwrap() error {
|
func (e *GitError) Unwrap() error {
|
||||||
return e.Err
|
return e.Err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -70,10 +70,7 @@ func (s *Service) OnStartup(ctx context.Context) error {
|
||||||
func (s *Service) handleQuery(c *framework.Core, q framework.Query) (any, bool, error) {
|
func (s *Service) handleQuery(c *framework.Core, q framework.Query) (any, bool, error) {
|
||||||
switch m := q.(type) {
|
switch m := q.(type) {
|
||||||
case QueryStatus:
|
case QueryStatus:
|
||||||
statuses := Status(context.Background(), StatusOptions{
|
statuses := Status(context.Background(), StatusOptions(m))
|
||||||
Paths: m.Paths,
|
|
||||||
Names: m.Names,
|
|
||||||
})
|
|
||||||
s.lastStatus = statuses
|
s.lastStatus = statuses
|
||||||
return statuses, true, nil
|
return statuses, true, nil
|
||||||
|
|
||||||
|
|
|
||||||
44
pkg/gitcmd/cmd_git.go
Normal file
44
pkg/gitcmd/cmd_git.go
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
// Package gitcmd provides git workflow commands as a root-level command.
|
||||||
|
//
|
||||||
|
// Git Operations:
|
||||||
|
// - health: Show status across repos
|
||||||
|
// - commit: Claude-assisted commit message generation
|
||||||
|
// - push: Push repos with unpushed commits
|
||||||
|
// - pull: Pull repos that are behind remote
|
||||||
|
// - work: Combined status, commit, and push workflow
|
||||||
|
//
|
||||||
|
// Safe Operations (for AI agents):
|
||||||
|
// - file-sync: Sync files across repos with auto commit/push
|
||||||
|
// - apply: Run command across repos with auto commit/push
|
||||||
|
package gitcmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/host-uk/core/pkg/cli"
|
||||||
|
"github.com/host-uk/core/pkg/dev"
|
||||||
|
"github.com/host-uk/core/pkg/i18n"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
cli.RegisterCommands(AddGitCommands)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddGitCommands registers the 'git' command and all subcommands.
|
||||||
|
func AddGitCommands(root *cli.Command) {
|
||||||
|
gitCmd := &cli.Command{
|
||||||
|
Use: "git",
|
||||||
|
Short: i18n.T("cmd.git.short"),
|
||||||
|
Long: i18n.T("cmd.git.long"),
|
||||||
|
}
|
||||||
|
root.AddCommand(gitCmd)
|
||||||
|
|
||||||
|
// Import git commands from dev package
|
||||||
|
dev.AddHealthCommand(gitCmd) // Shows repo status
|
||||||
|
dev.AddCommitCommand(gitCmd)
|
||||||
|
dev.AddPushCommand(gitCmd)
|
||||||
|
dev.AddPullCommand(gitCmd)
|
||||||
|
dev.AddWorkCommand(gitCmd)
|
||||||
|
|
||||||
|
// Safe operations for AI agents
|
||||||
|
dev.AddFileSyncCommand(gitCmd)
|
||||||
|
dev.AddApplyCommand(gitCmd)
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,11 @@
|
||||||
package gocmd
|
package gocmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/host-uk/core/pkg/cli"
|
"github.com/host-uk/core/pkg/cli"
|
||||||
"github.com/host-uk/core/pkg/i18n"
|
"github.com/host-uk/core/pkg/i18n"
|
||||||
|
|
@ -12,14 +15,34 @@ var (
|
||||||
fmtFix bool
|
fmtFix bool
|
||||||
fmtDiff bool
|
fmtDiff bool
|
||||||
fmtCheck bool
|
fmtCheck bool
|
||||||
|
fmtAll bool
|
||||||
)
|
)
|
||||||
|
|
||||||
func addGoFmtCommand(parent *cli.Command) {
|
func addGoFmtCommand(parent *cli.Command) {
|
||||||
fmtCmd := &cli.Command{
|
fmtCmd := &cli.Command{
|
||||||
Use: "fmt",
|
Use: "fmt",
|
||||||
Short: "Format Go code",
|
Short: "Format Go code",
|
||||||
Long: "Format Go code using goimports or gofmt",
|
Long: "Format Go code using goimports or gofmt. By default only checks changed files.",
|
||||||
RunE: func(cmd *cli.Command, args []string) error {
|
RunE: func(cmd *cli.Command, args []string) error {
|
||||||
|
// Get list of files to check
|
||||||
|
var files []string
|
||||||
|
if fmtAll {
|
||||||
|
// Check all Go files
|
||||||
|
files = []string{"."}
|
||||||
|
} else {
|
||||||
|
// Only check changed Go files (git-aware)
|
||||||
|
files = getChangedGoFiles()
|
||||||
|
if len(files) == 0 {
|
||||||
|
cli.Print("%s\n", i18n.T("cmd.go.fmt.no_changes"))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate flag combinations
|
||||||
|
if fmtCheck && fmtFix {
|
||||||
|
return cli.Err("--check and --fix are mutually exclusive")
|
||||||
|
}
|
||||||
|
|
||||||
fmtArgs := []string{}
|
fmtArgs := []string{}
|
||||||
if fmtFix {
|
if fmtFix {
|
||||||
fmtArgs = append(fmtArgs, "-w")
|
fmtArgs = append(fmtArgs, "-w")
|
||||||
|
|
@ -30,7 +53,7 @@ func addGoFmtCommand(parent *cli.Command) {
|
||||||
if !fmtFix && !fmtDiff {
|
if !fmtFix && !fmtDiff {
|
||||||
fmtArgs = append(fmtArgs, "-l")
|
fmtArgs = append(fmtArgs, "-l")
|
||||||
}
|
}
|
||||||
fmtArgs = append(fmtArgs, ".")
|
fmtArgs = append(fmtArgs, files...)
|
||||||
|
|
||||||
// Try goimports first, fall back to gofmt
|
// Try goimports first, fall back to gofmt
|
||||||
var execCmd *exec.Cmd
|
var execCmd *exec.Cmd
|
||||||
|
|
@ -40,6 +63,20 @@ func addGoFmtCommand(parent *cli.Command) {
|
||||||
execCmd = exec.Command("gofmt", fmtArgs...)
|
execCmd = exec.Command("gofmt", fmtArgs...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For --check mode, capture output to detect unformatted files
|
||||||
|
if fmtCheck {
|
||||||
|
output, err := execCmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
os.Stderr.Write(output)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(output) > 0 {
|
||||||
|
os.Stdout.Write(output)
|
||||||
|
return cli.Err("files need formatting (use --fix)")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
execCmd.Stdout = os.Stdout
|
execCmd.Stdout = os.Stdout
|
||||||
execCmd.Stderr = os.Stderr
|
execCmd.Stderr = os.Stderr
|
||||||
return execCmd.Run()
|
return execCmd.Run()
|
||||||
|
|
@ -47,25 +84,85 @@ func addGoFmtCommand(parent *cli.Command) {
|
||||||
}
|
}
|
||||||
|
|
||||||
fmtCmd.Flags().BoolVar(&fmtFix, "fix", false, i18n.T("common.flag.fix"))
|
fmtCmd.Flags().BoolVar(&fmtFix, "fix", false, i18n.T("common.flag.fix"))
|
||||||
fmtCmd.Flags().BoolVar(&fmtDiff, "diff", false, "Show diff of changes")
|
fmtCmd.Flags().BoolVar(&fmtDiff, "diff", false, i18n.T("common.flag.diff"))
|
||||||
fmtCmd.Flags().BoolVar(&fmtCheck, "check", false, "Check if formatted (exit 1 if not)")
|
fmtCmd.Flags().BoolVar(&fmtCheck, "check", false, i18n.T("cmd.go.fmt.flag.check"))
|
||||||
|
fmtCmd.Flags().BoolVar(&fmtAll, "all", false, i18n.T("cmd.go.fmt.flag.all"))
|
||||||
|
|
||||||
parent.AddCommand(fmtCmd)
|
parent.AddCommand(fmtCmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
var lintFix bool
|
// getChangedGoFiles returns Go files that have been modified, staged, or are untracked.
|
||||||
|
func getChangedGoFiles() []string {
|
||||||
|
var files []string
|
||||||
|
|
||||||
|
// Get modified and staged files
|
||||||
|
cmd := exec.Command("git", "diff", "--name-only", "--diff-filter=ACMR", "HEAD")
|
||||||
|
output, err := cmd.Output()
|
||||||
|
if err == nil {
|
||||||
|
files = append(files, filterGoFiles(string(output))...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get untracked files
|
||||||
|
cmd = exec.Command("git", "ls-files", "--others", "--exclude-standard")
|
||||||
|
output, err = cmd.Output()
|
||||||
|
if err == nil {
|
||||||
|
files = append(files, filterGoFiles(string(output))...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deduplicate
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
var unique []string
|
||||||
|
for _, f := range files {
|
||||||
|
if !seen[f] {
|
||||||
|
seen[f] = true
|
||||||
|
// Verify file exists (might have been deleted)
|
||||||
|
if _, err := os.Stat(f); err == nil {
|
||||||
|
unique = append(unique, f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return unique
|
||||||
|
}
|
||||||
|
|
||||||
|
// filterGoFiles filters a newline-separated list of files to only include .go files.
|
||||||
|
func filterGoFiles(output string) []string {
|
||||||
|
var goFiles []string
|
||||||
|
scanner := bufio.NewScanner(strings.NewReader(output))
|
||||||
|
for scanner.Scan() {
|
||||||
|
file := strings.TrimSpace(scanner.Text())
|
||||||
|
if file != "" && filepath.Ext(file) == ".go" {
|
||||||
|
goFiles = append(goFiles, file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return goFiles
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
lintFix bool
|
||||||
|
lintAll bool
|
||||||
|
)
|
||||||
|
|
||||||
func addGoLintCommand(parent *cli.Command) {
|
func addGoLintCommand(parent *cli.Command) {
|
||||||
lintCmd := &cli.Command{
|
lintCmd := &cli.Command{
|
||||||
Use: "lint",
|
Use: "lint",
|
||||||
Short: "Run golangci-lint",
|
Short: "Run golangci-lint",
|
||||||
Long: "Run golangci-lint for comprehensive static analysis",
|
Long: "Run golangci-lint for comprehensive static analysis. By default only lints changed files.",
|
||||||
RunE: func(cmd *cli.Command, args []string) error {
|
RunE: func(cmd *cli.Command, args []string) error {
|
||||||
lintArgs := []string{"run"}
|
lintArgs := []string{"run"}
|
||||||
if lintFix {
|
if lintFix {
|
||||||
lintArgs = append(lintArgs, "--fix")
|
lintArgs = append(lintArgs, "--fix")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !lintAll {
|
||||||
|
// Use --new-from-rev=HEAD to only report issues in uncommitted changes
|
||||||
|
// This is golangci-lint's native way to handle incremental linting
|
||||||
|
lintArgs = append(lintArgs, "--new-from-rev=HEAD")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always lint all packages
|
||||||
|
lintArgs = append(lintArgs, "./...")
|
||||||
|
|
||||||
execCmd := exec.Command("golangci-lint", lintArgs...)
|
execCmd := exec.Command("golangci-lint", lintArgs...)
|
||||||
execCmd.Stdout = os.Stdout
|
execCmd.Stdout = os.Stdout
|
||||||
execCmd.Stderr = os.Stderr
|
execCmd.Stderr = os.Stderr
|
||||||
|
|
@ -74,6 +171,7 @@ func addGoLintCommand(parent *cli.Command) {
|
||||||
}
|
}
|
||||||
|
|
||||||
lintCmd.Flags().BoolVar(&lintFix, "fix", false, i18n.T("common.flag.fix"))
|
lintCmd.Flags().BoolVar(&lintFix, "fix", false, i18n.T("common.flag.fix"))
|
||||||
|
lintCmd.Flags().BoolVar(&lintAll, "all", false, i18n.T("cmd.go.lint.flag.all"))
|
||||||
|
|
||||||
parent.AddCommand(lintCmd)
|
parent.AddCommand(lintCmd)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
621
pkg/go/cmd_qa.go
621
pkg/go/cmd_qa.go
|
|
@ -2,89 +2,351 @@ package gocmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/host-uk/core/pkg/cli"
|
"github.com/host-uk/core/pkg/cli"
|
||||||
"github.com/host-uk/core/pkg/i18n"
|
"github.com/host-uk/core/pkg/i18n"
|
||||||
|
"github.com/host-uk/core/pkg/qa"
|
||||||
)
|
)
|
||||||
|
|
||||||
var qaFix bool
|
// QA command flags - comprehensive options for all agents
|
||||||
|
var (
|
||||||
|
qaFix bool
|
||||||
|
qaChanged bool
|
||||||
|
qaAll bool
|
||||||
|
qaSkip string
|
||||||
|
qaOnly string
|
||||||
|
qaCoverage bool
|
||||||
|
qaThreshold float64
|
||||||
|
qaDocblockThreshold float64
|
||||||
|
qaJSON bool
|
||||||
|
qaVerbose bool
|
||||||
|
qaQuiet bool
|
||||||
|
qaTimeout time.Duration
|
||||||
|
qaShort bool
|
||||||
|
qaRace bool
|
||||||
|
qaBench bool
|
||||||
|
qaFailFast bool
|
||||||
|
qaMod bool
|
||||||
|
qaCI bool
|
||||||
|
)
|
||||||
|
|
||||||
func addGoQACommand(parent *cli.Command) {
|
func addGoQACommand(parent *cli.Command) {
|
||||||
qaCmd := &cli.Command{
|
qaCmd := &cli.Command{
|
||||||
Use: "qa",
|
Use: "qa",
|
||||||
Short: "Run QA checks",
|
Short: "Run QA checks",
|
||||||
Long: "Run code quality checks: formatting, vetting, linting, and testing",
|
Long: `Run comprehensive code quality checks for Go projects.
|
||||||
RunE: runGoQADefault,
|
|
||||||
|
Checks available: fmt, vet, lint, test, race, vuln, sec, bench, docblock
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
core go qa # Default: fmt, lint, test
|
||||||
|
core go qa --fix # Auto-fix formatting and lint issues
|
||||||
|
core go qa --only=test # Only run tests
|
||||||
|
core go qa --skip=vuln,sec # Skip vulnerability and security scans
|
||||||
|
core go qa --coverage --threshold=80 # Require 80% coverage
|
||||||
|
core go qa --changed # Only check changed files (git-aware)
|
||||||
|
core go qa --ci # CI mode: strict, coverage, fail-fast
|
||||||
|
core go qa --race --short # Quick tests with race detection
|
||||||
|
core go qa --json # Output results as JSON`,
|
||||||
|
RunE: runGoQA,
|
||||||
}
|
}
|
||||||
|
|
||||||
qaCmd.PersistentFlags().BoolVar(&qaFix, "fix", false, i18n.T("common.flag.fix"))
|
// Fix and modification flags (persistent so subcommands inherit them)
|
||||||
|
qaCmd.PersistentFlags().BoolVar(&qaFix, "fix", false, "Auto-fix issues where possible")
|
||||||
|
qaCmd.PersistentFlags().BoolVar(&qaMod, "mod", false, "Run go mod tidy before checks")
|
||||||
|
|
||||||
// Subcommands for individual checks
|
// Scope flags
|
||||||
qaCmd.AddCommand(&cli.Command{
|
qaCmd.PersistentFlags().BoolVar(&qaChanged, "changed", false, "Only check changed files (git-aware)")
|
||||||
Use: "fmt",
|
qaCmd.PersistentFlags().BoolVar(&qaAll, "all", false, "Check all files (override git-aware)")
|
||||||
Short: "Check/fix code formatting",
|
qaCmd.PersistentFlags().StringVar(&qaSkip, "skip", "", "Skip checks (comma-separated: fmt,vet,lint,test,race,vuln,sec,bench)")
|
||||||
RunE: func(cmd *cli.Command, args []string) error { return runQAChecks([]string{"fmt"}) },
|
qaCmd.PersistentFlags().StringVar(&qaOnly, "only", "", "Only run these checks (comma-separated)")
|
||||||
})
|
|
||||||
|
|
||||||
qaCmd.AddCommand(&cli.Command{
|
// Coverage flags
|
||||||
Use: "vet",
|
qaCmd.PersistentFlags().BoolVar(&qaCoverage, "coverage", false, "Include coverage reporting")
|
||||||
Short: "Run go vet",
|
qaCmd.PersistentFlags().BoolVarP(&qaCoverage, "cov", "c", false, "Include coverage reporting (shorthand)")
|
||||||
RunE: func(cmd *cli.Command, args []string) error { return runQAChecks([]string{"vet"}) },
|
qaCmd.PersistentFlags().Float64Var(&qaThreshold, "threshold", 0, "Minimum coverage threshold (0-100), fail if below")
|
||||||
})
|
qaCmd.PersistentFlags().Float64Var(&qaDocblockThreshold, "docblock-threshold", 80, "Minimum docblock coverage threshold (0-100)")
|
||||||
|
|
||||||
qaCmd.AddCommand(&cli.Command{
|
// Test flags
|
||||||
Use: "lint",
|
qaCmd.PersistentFlags().BoolVar(&qaShort, "short", false, "Run tests with -short flag")
|
||||||
Short: "Run golangci-lint",
|
qaCmd.PersistentFlags().BoolVar(&qaRace, "race", false, "Include race detection in tests")
|
||||||
RunE: func(cmd *cli.Command, args []string) error { return runQAChecks([]string{"lint"}) },
|
qaCmd.PersistentFlags().BoolVar(&qaBench, "bench", false, "Include benchmarks")
|
||||||
})
|
|
||||||
|
|
||||||
qaCmd.AddCommand(&cli.Command{
|
// Output flags
|
||||||
Use: "test",
|
qaCmd.PersistentFlags().BoolVar(&qaJSON, "json", false, "Output results as JSON")
|
||||||
Short: "Run tests",
|
qaCmd.PersistentFlags().BoolVarP(&qaVerbose, "verbose", "v", false, "Show verbose output")
|
||||||
RunE: func(cmd *cli.Command, args []string) error { return runQAChecks([]string{"test"}) },
|
qaCmd.PersistentFlags().BoolVarP(&qaQuiet, "quiet", "q", false, "Only show errors")
|
||||||
})
|
|
||||||
|
|
||||||
qaCmd.AddCommand(&cli.Command{
|
// Control flags
|
||||||
Use: "race",
|
qaCmd.PersistentFlags().DurationVar(&qaTimeout, "timeout", 10*time.Minute, "Timeout for all checks")
|
||||||
Short: "Run tests with race detector",
|
qaCmd.PersistentFlags().BoolVar(&qaFailFast, "fail-fast", false, "Stop on first failure")
|
||||||
RunE: func(cmd *cli.Command, args []string) error { return runQAChecks([]string{"race"}) },
|
qaCmd.PersistentFlags().BoolVar(&qaCI, "ci", false, "CI mode: strict checks, coverage required, fail-fast")
|
||||||
})
|
|
||||||
|
|
||||||
qaCmd.AddCommand(&cli.Command{
|
|
||||||
Use: "vuln",
|
|
||||||
Short: "Check for vulnerabilities",
|
|
||||||
RunE: func(cmd *cli.Command, args []string) error { return runQAChecks([]string{"vuln"}) },
|
|
||||||
})
|
|
||||||
|
|
||||||
qaCmd.AddCommand(&cli.Command{
|
|
||||||
Use: "sec",
|
|
||||||
Short: "Run security scanner",
|
|
||||||
RunE: func(cmd *cli.Command, args []string) error { return runQAChecks([]string{"sec"}) },
|
|
||||||
})
|
|
||||||
|
|
||||||
|
// Preset subcommands for convenience
|
||||||
qaCmd.AddCommand(&cli.Command{
|
qaCmd.AddCommand(&cli.Command{
|
||||||
Use: "quick",
|
Use: "quick",
|
||||||
Short: "Quick QA: fmt, vet, lint",
|
Short: "Quick QA: fmt, vet, lint (no tests)",
|
||||||
RunE: func(cmd *cli.Command, args []string) error { return runQAChecks([]string{"fmt", "vet", "lint"}) },
|
RunE: func(cmd *cli.Command, args []string) error { qaOnly = "fmt,vet,lint"; return runGoQA(cmd, args) },
|
||||||
})
|
})
|
||||||
|
|
||||||
qaCmd.AddCommand(&cli.Command{
|
qaCmd.AddCommand(&cli.Command{
|
||||||
Use: "full",
|
Use: "full",
|
||||||
Short: "Full QA: all checks including race, vuln, sec",
|
Short: "Full QA: all checks including race, vuln, sec",
|
||||||
RunE: func(cmd *cli.Command, args []string) error {
|
RunE: func(cmd *cli.Command, args []string) error {
|
||||||
return runQAChecks([]string{"fmt", "vet", "lint", "test", "race", "vuln", "sec"})
|
qaOnly = "fmt,vet,lint,test,race,vuln,sec"
|
||||||
|
return runGoQA(cmd, args)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
qaCmd.AddCommand(&cli.Command{
|
||||||
|
Use: "pre-commit",
|
||||||
|
Short: "Pre-commit checks: fmt --fix, lint --fix, test --short",
|
||||||
|
RunE: func(cmd *cli.Command, args []string) error {
|
||||||
|
qaFix = true
|
||||||
|
qaShort = true
|
||||||
|
qaOnly = "fmt,lint,test"
|
||||||
|
return runGoQA(cmd, args)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
qaCmd.AddCommand(&cli.Command{
|
||||||
|
Use: "pr",
|
||||||
|
Short: "PR checks: full QA with coverage threshold",
|
||||||
|
RunE: func(cmd *cli.Command, args []string) error {
|
||||||
|
qaCoverage = true
|
||||||
|
if qaThreshold == 0 {
|
||||||
|
qaThreshold = 50 // Default PR threshold
|
||||||
|
}
|
||||||
|
qaOnly = "fmt,vet,lint,test"
|
||||||
|
return runGoQA(cmd, args)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
parent.AddCommand(qaCmd)
|
parent.AddCommand(qaCmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
// runGoQADefault runs the default QA checks (fmt, vet, lint, test)
|
// QAResult holds the result of a QA run for JSON output
|
||||||
func runGoQADefault(cmd *cli.Command, args []string) error {
|
type QAResult struct {
|
||||||
return runQAChecks([]string{"fmt", "vet", "lint", "test"})
|
Success bool `json:"success"`
|
||||||
|
Duration string `json:"duration"`
|
||||||
|
Checks []CheckResult `json:"checks"`
|
||||||
|
Coverage *float64 `json:"coverage,omitempty"`
|
||||||
|
Threshold *float64 `json:"threshold,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckResult holds the result of a single check
|
||||||
|
type CheckResult struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Passed bool `json:"passed"`
|
||||||
|
Duration string `json:"duration"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
Output string `json:"output,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func runGoQA(cmd *cli.Command, args []string) error {
|
||||||
|
// Apply CI mode defaults
|
||||||
|
if qaCI {
|
||||||
|
qaCoverage = true
|
||||||
|
qaFailFast = true
|
||||||
|
if qaThreshold == 0 {
|
||||||
|
qaThreshold = 50
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cwd, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
return cli.Wrap(err, i18n.T("i18n.fail.get", "working directory"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect if this is a Go project
|
||||||
|
if _, err := os.Stat("go.mod"); os.IsNotExist(err) {
|
||||||
|
return cli.Err("not a Go project (no go.mod found)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine which checks to run
|
||||||
|
checkNames := determineChecks()
|
||||||
|
|
||||||
|
if !qaJSON && !qaQuiet {
|
||||||
|
cli.Print("%s %s\n\n", cli.DimStyle.Render(i18n.Label("qa")), i18n.ProgressSubject("run", "Go QA"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run go mod tidy if requested
|
||||||
|
if qaMod {
|
||||||
|
if !qaQuiet {
|
||||||
|
cli.Print("%s %s\n", cli.DimStyle.Render("→"), "Running go mod tidy...")
|
||||||
|
}
|
||||||
|
modCmd := exec.Command("go", "mod", "tidy")
|
||||||
|
modCmd.Dir = cwd
|
||||||
|
if err := modCmd.Run(); err != nil {
|
||||||
|
return cli.Wrap(err, "go mod tidy failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), qaTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
startTime := time.Now()
|
||||||
|
checks := buildChecks(checkNames)
|
||||||
|
results := make([]CheckResult, 0, len(checks))
|
||||||
|
passed := 0
|
||||||
|
failed := 0
|
||||||
|
|
||||||
|
for _, check := range checks {
|
||||||
|
checkStart := time.Now()
|
||||||
|
|
||||||
|
if !qaJSON && !qaQuiet {
|
||||||
|
cli.Print("%s %s\n", cli.DimStyle.Render("→"), i18n.Progress(check.Name))
|
||||||
|
}
|
||||||
|
|
||||||
|
output, err := runCheckCapture(ctx, cwd, check)
|
||||||
|
checkDuration := time.Since(checkStart)
|
||||||
|
|
||||||
|
result := CheckResult{
|
||||||
|
Name: check.Name,
|
||||||
|
Duration: checkDuration.Round(time.Millisecond).String(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
result.Passed = false
|
||||||
|
result.Error = err.Error()
|
||||||
|
if qaVerbose {
|
||||||
|
result.Output = output
|
||||||
|
}
|
||||||
|
failed++
|
||||||
|
|
||||||
|
if !qaJSON && !qaQuiet {
|
||||||
|
cli.Print(" %s %s\n", cli.ErrorStyle.Render(cli.Glyph(":cross:")), err.Error())
|
||||||
|
if qaVerbose && output != "" {
|
||||||
|
cli.Text(output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if qaFailFast {
|
||||||
|
results = append(results, result)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
result.Passed = true
|
||||||
|
if qaVerbose {
|
||||||
|
result.Output = output
|
||||||
|
}
|
||||||
|
passed++
|
||||||
|
|
||||||
|
if !qaJSON && !qaQuiet {
|
||||||
|
cli.Print(" %s %s\n", cli.SuccessStyle.Render(cli.Glyph(":check:")), i18n.T("i18n.done.pass"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
results = append(results, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run coverage if requested
|
||||||
|
var coverageVal *float64
|
||||||
|
if qaCoverage && !qaFailFast || (qaCoverage && failed == 0) {
|
||||||
|
cov, err := runCoverage(ctx, cwd)
|
||||||
|
if err == nil {
|
||||||
|
coverageVal = &cov
|
||||||
|
if !qaJSON && !qaQuiet {
|
||||||
|
cli.Print("\n%s %.1f%%\n", cli.DimStyle.Render("Coverage:"), cov)
|
||||||
|
}
|
||||||
|
if qaThreshold > 0 && cov < qaThreshold {
|
||||||
|
failed++
|
||||||
|
if !qaJSON && !qaQuiet {
|
||||||
|
cli.Print(" %s Coverage %.1f%% below threshold %.1f%%\n",
|
||||||
|
cli.ErrorStyle.Render(cli.Glyph(":cross:")), cov, qaThreshold)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
duration := time.Since(startTime).Round(time.Millisecond)
|
||||||
|
|
||||||
|
// JSON output
|
||||||
|
if qaJSON {
|
||||||
|
qaResult := QAResult{
|
||||||
|
Success: failed == 0,
|
||||||
|
Duration: duration.String(),
|
||||||
|
Checks: results,
|
||||||
|
Coverage: coverageVal,
|
||||||
|
}
|
||||||
|
if qaThreshold > 0 {
|
||||||
|
qaResult.Threshold = &qaThreshold
|
||||||
|
}
|
||||||
|
enc := json.NewEncoder(os.Stdout)
|
||||||
|
enc.SetIndent("", " ")
|
||||||
|
return enc.Encode(qaResult)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Summary
|
||||||
|
if !qaQuiet {
|
||||||
|
cli.Blank()
|
||||||
|
if failed > 0 {
|
||||||
|
cli.Print("%s %s, %s (%s)\n",
|
||||||
|
cli.ErrorStyle.Render(cli.Glyph(":cross:")),
|
||||||
|
i18n.T("i18n.count.check", passed)+" "+i18n.T("i18n.done.pass"),
|
||||||
|
i18n.T("i18n.count.check", failed)+" "+i18n.T("i18n.done.fail"),
|
||||||
|
duration)
|
||||||
|
} else {
|
||||||
|
cli.Print("%s %s (%s)\n",
|
||||||
|
cli.SuccessStyle.Render(cli.Glyph(":check:")),
|
||||||
|
i18n.T("i18n.count.check", passed)+" "+i18n.T("i18n.done.pass"),
|
||||||
|
duration)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if failed > 0 {
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func determineChecks() []string {
|
||||||
|
// If --only is specified, use those
|
||||||
|
if qaOnly != "" {
|
||||||
|
return strings.Split(qaOnly, ",")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default checks
|
||||||
|
checks := []string{"fmt", "lint", "test", "docblock"}
|
||||||
|
|
||||||
|
// Add race if requested
|
||||||
|
if qaRace {
|
||||||
|
// Replace test with race (which includes test)
|
||||||
|
for i, c := range checks {
|
||||||
|
if c == "test" {
|
||||||
|
checks[i] = "race"
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add bench if requested
|
||||||
|
if qaBench {
|
||||||
|
checks = append(checks, "bench")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove skipped checks
|
||||||
|
if qaSkip != "" {
|
||||||
|
skipMap := make(map[string]bool)
|
||||||
|
for _, s := range strings.Split(qaSkip, ",") {
|
||||||
|
skipMap[strings.TrimSpace(s)] = true
|
||||||
|
}
|
||||||
|
filtered := make([]string, 0, len(checks))
|
||||||
|
for _, c := range checks {
|
||||||
|
if !skipMap[c] {
|
||||||
|
filtered = append(filtered, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
checks = filtered
|
||||||
|
}
|
||||||
|
|
||||||
|
return checks
|
||||||
}
|
}
|
||||||
|
|
||||||
// QACheck represents a single QA check.
|
// QACheck represents a single QA check.
|
||||||
|
|
@ -94,127 +356,92 @@ type QACheck struct {
|
||||||
Args []string
|
Args []string
|
||||||
}
|
}
|
||||||
|
|
||||||
func runQAChecks(checkNames []string) error {
|
func buildChecks(names []string) []QACheck {
|
||||||
cwd, err := os.Getwd()
|
|
||||||
if err != nil {
|
|
||||||
return cli.Wrap(err, i18n.T("i18n.fail.get", "working directory"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Detect if this is a Go project
|
|
||||||
if _, err := os.Stat("go.mod"); os.IsNotExist(err) {
|
|
||||||
return cli.Err("not a Go project (no %s found)", i18n.T("gram.word.go_mod"))
|
|
||||||
}
|
|
||||||
|
|
||||||
cli.Print("%s %s\n\n", cli.DimStyle.Render(i18n.Label("qa")), i18n.ProgressSubject("run", "Go QA"))
|
|
||||||
|
|
||||||
checks := buildChecksForNames(checkNames)
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
startTime := time.Now()
|
|
||||||
passed := 0
|
|
||||||
failed := 0
|
|
||||||
|
|
||||||
for _, check := range checks {
|
|
||||||
cli.Print("%s %s\n", cli.DimStyle.Render("→"), i18n.Progress(check.Name))
|
|
||||||
|
|
||||||
if err := runCheck(ctx, cwd, check); err != nil {
|
|
||||||
cli.Print(" %s %s\n", cli.ErrorStyle.Render(cli.Glyph(":cross:")), err.Error())
|
|
||||||
failed++
|
|
||||||
} else {
|
|
||||||
cli.Print(" %s %s\n", cli.SuccessStyle.Render(cli.Glyph(":check:")), i18n.T("i18n.done.pass"))
|
|
||||||
passed++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Summary
|
|
||||||
cli.Blank()
|
|
||||||
duration := time.Since(startTime).Round(time.Millisecond)
|
|
||||||
|
|
||||||
if failed > 0 {
|
|
||||||
cli.Print("%s %s, %s (%s)\n",
|
|
||||||
cli.ErrorStyle.Render(cli.Glyph(":cross:")),
|
|
||||||
i18n.T("i18n.count.check", passed)+" "+i18n.T("i18n.done.pass"),
|
|
||||||
i18n.T("i18n.count.check", failed)+" "+i18n.T("i18n.done.fail"),
|
|
||||||
duration)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
cli.Print("%s %s (%s)\n",
|
|
||||||
cli.SuccessStyle.Render(cli.Glyph(":check:")),
|
|
||||||
i18n.T("i18n.count.check", passed)+" "+i18n.T("i18n.done.pass"),
|
|
||||||
duration)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildChecksForNames(names []string) []QACheck {
|
|
||||||
allChecks := map[string]QACheck{
|
|
||||||
"fmt": {
|
|
||||||
Name: "format",
|
|
||||||
Command: "gofmt",
|
|
||||||
Args: fmtArgs(qaFix),
|
|
||||||
},
|
|
||||||
"vet": {
|
|
||||||
Name: "vet",
|
|
||||||
Command: "go",
|
|
||||||
Args: []string{"vet", "./..."},
|
|
||||||
},
|
|
||||||
"lint": {
|
|
||||||
Name: "lint",
|
|
||||||
Command: "golangci-lint",
|
|
||||||
Args: lintArgs(qaFix),
|
|
||||||
},
|
|
||||||
"test": {
|
|
||||||
Name: "test",
|
|
||||||
Command: "go",
|
|
||||||
Args: []string{"test", "./..."},
|
|
||||||
},
|
|
||||||
"race": {
|
|
||||||
Name: "test",
|
|
||||||
Command: "go",
|
|
||||||
Args: []string{"test", "-race", "./..."},
|
|
||||||
},
|
|
||||||
"vuln": {
|
|
||||||
Name: "scan",
|
|
||||||
Command: "govulncheck",
|
|
||||||
Args: []string{"./..."},
|
|
||||||
},
|
|
||||||
"sec": {
|
|
||||||
Name: "scan",
|
|
||||||
Command: "gosec",
|
|
||||||
Args: []string{"-quiet", "./..."},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
var checks []QACheck
|
var checks []QACheck
|
||||||
for _, name := range names {
|
for _, name := range names {
|
||||||
if check, ok := allChecks[name]; ok {
|
name = strings.TrimSpace(name)
|
||||||
|
check := buildCheck(name)
|
||||||
|
if check.Command != "" {
|
||||||
checks = append(checks, check)
|
checks = append(checks, check)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return checks
|
return checks
|
||||||
}
|
}
|
||||||
|
|
||||||
func fmtArgs(fix bool) []string {
|
func buildCheck(name string) QACheck {
|
||||||
if fix {
|
switch name {
|
||||||
return []string{"-w", "."}
|
case "fmt", "format":
|
||||||
}
|
args := []string{"-l", "."}
|
||||||
return []string{"-l", "."}
|
if qaFix {
|
||||||
|
args = []string{"-w", "."}
|
||||||
}
|
}
|
||||||
|
return QACheck{Name: "format", Command: "gofmt", Args: args}
|
||||||
|
|
||||||
func lintArgs(fix bool) []string {
|
case "vet":
|
||||||
|
return QACheck{Name: "vet", Command: "go", Args: []string{"vet", "./..."}}
|
||||||
|
|
||||||
|
case "lint":
|
||||||
args := []string{"run"}
|
args := []string{"run"}
|
||||||
if fix {
|
if qaFix {
|
||||||
args = append(args, "--fix")
|
args = append(args, "--fix")
|
||||||
}
|
}
|
||||||
|
if qaChanged && !qaAll {
|
||||||
|
args = append(args, "--new-from-rev=HEAD")
|
||||||
|
}
|
||||||
args = append(args, "./...")
|
args = append(args, "./...")
|
||||||
return args
|
return QACheck{Name: "lint", Command: "golangci-lint", Args: args}
|
||||||
|
|
||||||
|
case "test":
|
||||||
|
args := []string{"test"}
|
||||||
|
if qaShort {
|
||||||
|
args = append(args, "-short")
|
||||||
|
}
|
||||||
|
if qaVerbose {
|
||||||
|
args = append(args, "-v")
|
||||||
|
}
|
||||||
|
args = append(args, "./...")
|
||||||
|
return QACheck{Name: "test", Command: "go", Args: args}
|
||||||
|
|
||||||
|
case "race":
|
||||||
|
args := []string{"test", "-race"}
|
||||||
|
if qaShort {
|
||||||
|
args = append(args, "-short")
|
||||||
|
}
|
||||||
|
if qaVerbose {
|
||||||
|
args = append(args, "-v")
|
||||||
|
}
|
||||||
|
args = append(args, "./...")
|
||||||
|
return QACheck{Name: "race", Command: "go", Args: args}
|
||||||
|
|
||||||
|
case "bench":
|
||||||
|
args := []string{"test", "-bench=.", "-benchmem", "-run=^$"}
|
||||||
|
args = append(args, "./...")
|
||||||
|
return QACheck{Name: "bench", Command: "go", Args: args}
|
||||||
|
|
||||||
|
case "vuln":
|
||||||
|
return QACheck{Name: "vuln", Command: "govulncheck", Args: []string{"./..."}}
|
||||||
|
|
||||||
|
case "sec":
|
||||||
|
return QACheck{Name: "sec", Command: "gosec", Args: []string{"-quiet", "./..."}}
|
||||||
|
|
||||||
|
case "docblock":
|
||||||
|
// Special internal check - handled separately
|
||||||
|
return QACheck{Name: "docblock", Command: "_internal_"}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return QACheck{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runCheckCapture(ctx context.Context, dir string, check QACheck) (string, error) {
|
||||||
|
// Handle internal checks
|
||||||
|
if check.Command == "_internal_" {
|
||||||
|
return runInternalCheck(check)
|
||||||
}
|
}
|
||||||
|
|
||||||
func runCheck(ctx context.Context, dir string, check QACheck) error {
|
|
||||||
// Check if command exists
|
// Check if command exists
|
||||||
if _, err := exec.LookPath(check.Command); err != nil {
|
if _, err := exec.LookPath(check.Command); err != nil {
|
||||||
return cli.Err("%s: %s", check.Command, i18n.T("i18n.done.miss"))
|
return "", cli.Err("%s: not installed", check.Command)
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd := exec.CommandContext(ctx, check.Command, check.Args...)
|
cmd := exec.CommandContext(ctx, check.Command, check.Args...)
|
||||||
|
|
@ -224,18 +451,100 @@ func runCheck(ctx context.Context, dir string, check QACheck) error {
|
||||||
if check.Name == "format" && len(check.Args) > 0 && check.Args[0] == "-l" {
|
if check.Name == "format" && len(check.Args) > 0 && check.Args[0] == "-l" {
|
||||||
output, err := cmd.Output()
|
output, err := cmd.Output()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return string(output), err
|
||||||
}
|
}
|
||||||
if len(output) > 0 {
|
if len(output) > 0 {
|
||||||
// Show files that need formatting
|
// Show files that need formatting
|
||||||
|
if !qaQuiet && !qaJSON {
|
||||||
cli.Text(string(output))
|
cli.Text(string(output))
|
||||||
return cli.Err("%s (use --fix)", i18n.T("i18n.fail.format", i18n.T("i18n.count.file", len(output))))
|
|
||||||
}
|
}
|
||||||
return nil
|
return string(output), cli.Err("files need formatting (use --fix)")
|
||||||
|
}
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// For other commands, stream or capture based on quiet mode
|
||||||
|
if qaQuiet || qaJSON {
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
return string(output), err
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.Stdout = os.Stdout
|
cmd.Stdout = os.Stdout
|
||||||
cmd.Stderr = os.Stderr
|
cmd.Stderr = os.Stderr
|
||||||
|
return "", cmd.Run()
|
||||||
return cmd.Run()
|
}
|
||||||
|
|
||||||
|
func runCoverage(ctx context.Context, dir string) (float64, error) {
|
||||||
|
args := []string{"test", "-cover", "-coverprofile=/tmp/coverage.out"}
|
||||||
|
if qaShort {
|
||||||
|
args = append(args, "-short")
|
||||||
|
}
|
||||||
|
args = append(args, "./...")
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(ctx, "go", args...)
|
||||||
|
cmd.Dir = dir
|
||||||
|
if !qaQuiet && !qaJSON {
|
||||||
|
cmd.Stdout = os.Stdout
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse coverage
|
||||||
|
coverCmd := exec.CommandContext(ctx, "go", "tool", "cover", "-func=/tmp/coverage.out")
|
||||||
|
output, err := coverCmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse last line for total coverage
|
||||||
|
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
|
||||||
|
if len(lines) == 0 {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
lastLine := lines[len(lines)-1]
|
||||||
|
fields := strings.Fields(lastLine)
|
||||||
|
if len(fields) < 3 {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse percentage (e.g., "45.6%")
|
||||||
|
pctStr := strings.TrimSuffix(fields[len(fields)-1], "%")
|
||||||
|
var pct float64
|
||||||
|
if _, err := fmt.Sscanf(pctStr, "%f", &pct); err == nil {
|
||||||
|
return pct, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// runInternalCheck runs internal Go-based checks (not external commands).
|
||||||
|
func runInternalCheck(check QACheck) (string, error) {
|
||||||
|
switch check.Name {
|
||||||
|
case "docblock":
|
||||||
|
result, err := qa.CheckDocblockCoverage([]string{"./..."})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
result.Threshold = qaDocblockThreshold
|
||||||
|
result.Passed = result.Coverage >= qaDocblockThreshold
|
||||||
|
|
||||||
|
if !result.Passed {
|
||||||
|
var output strings.Builder
|
||||||
|
output.WriteString(fmt.Sprintf("Docblock coverage: %.1f%% (threshold: %.1f%%)\n",
|
||||||
|
result.Coverage, qaDocblockThreshold))
|
||||||
|
for _, m := range result.Missing {
|
||||||
|
output.WriteString(fmt.Sprintf("%s:%d\n", m.File, m.Line))
|
||||||
|
}
|
||||||
|
return output.String(), cli.Err("docblock coverage %.1f%% below threshold %.1f%%",
|
||||||
|
result.Coverage, qaDocblockThreshold)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("Docblock coverage: %.1f%%", result.Coverage), nil
|
||||||
|
|
||||||
|
default:
|
||||||
|
return "", cli.Err("unknown internal check: %s", check.Name)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -797,4 +797,3 @@ func TestProgressSubjectMatchesExpected(t *testing.T) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,10 +11,12 @@ import (
|
||||||
// LabelHandler handles i18n.label.{word} → "Status:" patterns.
|
// LabelHandler handles i18n.label.{word} → "Status:" patterns.
|
||||||
type LabelHandler struct{}
|
type LabelHandler struct{}
|
||||||
|
|
||||||
|
// Match returns true for keys starting with "i18n.label.".
|
||||||
func (h LabelHandler) Match(key string) bool {
|
func (h LabelHandler) Match(key string) bool {
|
||||||
return strings.HasPrefix(key, "i18n.label.")
|
return strings.HasPrefix(key, "i18n.label.")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle transforms label keys into formatted labels with colons.
|
||||||
func (h LabelHandler) Handle(key string, args []any, next func() string) string {
|
func (h LabelHandler) Handle(key string, args []any, next func() string) string {
|
||||||
word := strings.TrimPrefix(key, "i18n.label.")
|
word := strings.TrimPrefix(key, "i18n.label.")
|
||||||
return Label(word)
|
return Label(word)
|
||||||
|
|
@ -23,10 +25,12 @@ func (h LabelHandler) Handle(key string, args []any, next func() string) string
|
||||||
// ProgressHandler handles i18n.progress.{verb} → "Building..." patterns.
|
// ProgressHandler handles i18n.progress.{verb} → "Building..." patterns.
|
||||||
type ProgressHandler struct{}
|
type ProgressHandler struct{}
|
||||||
|
|
||||||
|
// Match returns true for keys starting with "i18n.progress.".
|
||||||
func (h ProgressHandler) Match(key string) bool {
|
func (h ProgressHandler) Match(key string) bool {
|
||||||
return strings.HasPrefix(key, "i18n.progress.")
|
return strings.HasPrefix(key, "i18n.progress.")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle transforms progress keys into gerund phrases like "Building...".
|
||||||
func (h ProgressHandler) Handle(key string, args []any, next func() string) string {
|
func (h ProgressHandler) Handle(key string, args []any, next func() string) string {
|
||||||
verb := strings.TrimPrefix(key, "i18n.progress.")
|
verb := strings.TrimPrefix(key, "i18n.progress.")
|
||||||
if len(args) > 0 {
|
if len(args) > 0 {
|
||||||
|
|
@ -40,10 +44,12 @@ func (h ProgressHandler) Handle(key string, args []any, next func() string) stri
|
||||||
// CountHandler handles i18n.count.{noun} → "5 files" patterns.
|
// CountHandler handles i18n.count.{noun} → "5 files" patterns.
|
||||||
type CountHandler struct{}
|
type CountHandler struct{}
|
||||||
|
|
||||||
|
// Match returns true for keys starting with "i18n.count.".
|
||||||
func (h CountHandler) Match(key string) bool {
|
func (h CountHandler) Match(key string) bool {
|
||||||
return strings.HasPrefix(key, "i18n.count.")
|
return strings.HasPrefix(key, "i18n.count.")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle transforms count keys into pluralized phrases like "5 files".
|
||||||
func (h CountHandler) Handle(key string, args []any, next func() string) string {
|
func (h CountHandler) Handle(key string, args []any, next func() string) string {
|
||||||
noun := strings.TrimPrefix(key, "i18n.count.")
|
noun := strings.TrimPrefix(key, "i18n.count.")
|
||||||
if len(args) > 0 {
|
if len(args) > 0 {
|
||||||
|
|
@ -56,10 +62,12 @@ func (h CountHandler) Handle(key string, args []any, next func() string) string
|
||||||
// DoneHandler handles i18n.done.{verb} → "File deleted" patterns.
|
// DoneHandler handles i18n.done.{verb} → "File deleted" patterns.
|
||||||
type DoneHandler struct{}
|
type DoneHandler struct{}
|
||||||
|
|
||||||
|
// Match returns true for keys starting with "i18n.done.".
|
||||||
func (h DoneHandler) Match(key string) bool {
|
func (h DoneHandler) Match(key string) bool {
|
||||||
return strings.HasPrefix(key, "i18n.done.")
|
return strings.HasPrefix(key, "i18n.done.")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle transforms done keys into past-tense completion messages.
|
||||||
func (h DoneHandler) Handle(key string, args []any, next func() string) string {
|
func (h DoneHandler) Handle(key string, args []any, next func() string) string {
|
||||||
verb := strings.TrimPrefix(key, "i18n.done.")
|
verb := strings.TrimPrefix(key, "i18n.done.")
|
||||||
if len(args) > 0 {
|
if len(args) > 0 {
|
||||||
|
|
@ -73,10 +81,12 @@ func (h DoneHandler) Handle(key string, args []any, next func() string) string {
|
||||||
// FailHandler handles i18n.fail.{verb} → "Failed to delete file" patterns.
|
// FailHandler handles i18n.fail.{verb} → "Failed to delete file" patterns.
|
||||||
type FailHandler struct{}
|
type FailHandler struct{}
|
||||||
|
|
||||||
|
// Match returns true for keys starting with "i18n.fail.".
|
||||||
func (h FailHandler) Match(key string) bool {
|
func (h FailHandler) Match(key string) bool {
|
||||||
return strings.HasPrefix(key, "i18n.fail.")
|
return strings.HasPrefix(key, "i18n.fail.")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle transforms fail keys into failure messages like "Failed to delete".
|
||||||
func (h FailHandler) Handle(key string, args []any, next func() string) string {
|
func (h FailHandler) Handle(key string, args []any, next func() string) string {
|
||||||
verb := strings.TrimPrefix(key, "i18n.fail.")
|
verb := strings.TrimPrefix(key, "i18n.fail.")
|
||||||
if len(args) > 0 {
|
if len(args) > 0 {
|
||||||
|
|
@ -90,10 +100,12 @@ func (h FailHandler) Handle(key string, args []any, next func() string) string {
|
||||||
// NumericHandler handles i18n.numeric.{format} → formatted numbers.
|
// NumericHandler handles i18n.numeric.{format} → formatted numbers.
|
||||||
type NumericHandler struct{}
|
type NumericHandler struct{}
|
||||||
|
|
||||||
|
// Match returns true for keys starting with "i18n.numeric.".
|
||||||
func (h NumericHandler) Match(key string) bool {
|
func (h NumericHandler) Match(key string) bool {
|
||||||
return strings.HasPrefix(key, "i18n.numeric.")
|
return strings.HasPrefix(key, "i18n.numeric.")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle transforms numeric keys into locale-formatted numbers.
|
||||||
func (h NumericHandler) Handle(key string, args []any, next func() string) string {
|
func (h NumericHandler) Handle(key string, args []any, next func() string) string {
|
||||||
if len(args) == 0 {
|
if len(args) == 0 {
|
||||||
return next()
|
return next()
|
||||||
|
|
|
||||||
|
|
@ -170,15 +170,63 @@
|
||||||
"flag.push": "Push container image to registry",
|
"flag.push": "Push container image to registry",
|
||||||
"flag.targets": "Comma-separated build targets (e.g., linux/amd64,darwin/arm64)",
|
"flag.targets": "Comma-separated build targets (e.g., linux/amd64,darwin/arm64)",
|
||||||
"flag.type": "Project type override (go, wails, node, php)",
|
"flag.type": "Project type override (go, wails, node, php)",
|
||||||
|
"label.build": "Build",
|
||||||
|
"label.type": "Type:",
|
||||||
|
"label.output": "Output:",
|
||||||
|
"label.binary": "Binary:",
|
||||||
|
"label.targets": "Targets:",
|
||||||
|
"label.sign": "Sign",
|
||||||
|
"label.archive": "Archive",
|
||||||
|
"label.checksum": "Checksum",
|
||||||
|
"label.ok": "OK",
|
||||||
|
"building_project": "Building project",
|
||||||
|
"built_artifacts": "Built {{.Count}} artifacts",
|
||||||
|
"signing_binaries": "Signing binaries",
|
||||||
|
"creating_archives": "Creating archives",
|
||||||
|
"computing_checksums": "Computing checksums",
|
||||||
"error.invalid_target": "invalid target format \"{{.Target}}\", expected OS/arch (e.g., linux/amd64)",
|
"error.invalid_target": "invalid target format \"{{.Target}}\", expected OS/arch (e.g., linux/amd64)",
|
||||||
"error.no_project_type": "no supported project type detected in {{.Dir}}\nSupported types: go (go.mod), wails (wails.json), node (package.json), php (composer.json)",
|
"error.no_project_type": "no supported project type detected in {{.Dir}}\nSupported types: go (go.mod), wails (wails.json), node (package.json), php (composer.json)",
|
||||||
|
"error.no_targets": "no build targets specified",
|
||||||
|
"error.signing_failed": "signing failed",
|
||||||
|
"error.notarization_failed": "notarization failed",
|
||||||
|
"error.archive_failed": "archive creation failed",
|
||||||
|
"error.checksum_failed": "checksum generation failed",
|
||||||
|
"error.gpg_signing_failed": "GPG signing failed",
|
||||||
|
"error.node_not_implemented": "Node.js builds not yet implemented",
|
||||||
|
"error.php_not_implemented": "PHP builds not yet implemented",
|
||||||
|
"error.unsupported_type": "unsupported project type",
|
||||||
"from_path.short": "Build from a local directory",
|
"from_path.short": "Build from a local directory",
|
||||||
|
"from_path.flag.path": "Path to application directory",
|
||||||
|
"from_path.starting": "Building from path:",
|
||||||
|
"from_path.generating_template": "Generating application template...",
|
||||||
|
"from_path.copying_files": "Copying application files...",
|
||||||
|
"from_path.compiling": "Compiling application...",
|
||||||
|
"from_path.success": "Build complete:",
|
||||||
|
"from_path.error.invalid_path": "invalid path",
|
||||||
|
"from_path.error.must_be_directory": "path must be a directory",
|
||||||
|
"from_path.error.go_mod_tidy": "go mod tidy failed",
|
||||||
|
"from_path.error.go_build": "go build failed",
|
||||||
"pwa.short": "Build from a live PWA URL",
|
"pwa.short": "Build from a live PWA URL",
|
||||||
|
"pwa.flag.url": "URL of the PWA to build",
|
||||||
|
"pwa.starting": "Building PWA from URL:",
|
||||||
|
"pwa.downloading_to": "Downloading to:",
|
||||||
|
"pwa.no_manifest": "No manifest.json found, using defaults",
|
||||||
|
"pwa.found_manifest": "Found manifest:",
|
||||||
|
"pwa.download_complete": "Download complete",
|
||||||
|
"pwa.error.no_manifest_tag": "no manifest link tag found in HTML",
|
||||||
"sdk.short": "Generate API SDKs from OpenAPI spec",
|
"sdk.short": "Generate API SDKs from OpenAPI spec",
|
||||||
"sdk.long": "Generate API SDKs from an OpenAPI specification file. Supports multiple languages including TypeScript, Go, and PHP.",
|
"sdk.long": "Generate API SDKs from an OpenAPI specification file. Supports multiple languages including TypeScript, Go, and PHP.",
|
||||||
"sdk.flag.dry_run": "Show what would be generated without writing files",
|
"sdk.flag.dry_run": "Show what would be generated without writing files",
|
||||||
"sdk.flag.lang": "Target language (typescript, go, php)",
|
"sdk.flag.lang": "Target language (typescript, go, php)",
|
||||||
"sdk.flag.version": "SDK version to generate"
|
"sdk.flag.version": "SDK version to generate",
|
||||||
|
"sdk.label": "SDK",
|
||||||
|
"sdk.generating": "Generating SDK",
|
||||||
|
"sdk.dry_run_mode": "(dry run - no files will be written)",
|
||||||
|
"sdk.language_label": "Language:",
|
||||||
|
"sdk.languages_label": "Languages:",
|
||||||
|
"sdk.would_generate": "Would generate SDK",
|
||||||
|
"sdk.generated_label": "Generated:",
|
||||||
|
"sdk.complete": "SDK generation complete"
|
||||||
},
|
},
|
||||||
"ci": {
|
"ci": {
|
||||||
"short": "Publish releases (dry-run by default)",
|
"short": "Publish releases (dry-run by default)",
|
||||||
|
|
@ -298,13 +346,22 @@
|
||||||
"install_macos": "brew install git gh php composer node pnpm docker",
|
"install_macos": "brew install git gh php composer node pnpm docker",
|
||||||
"ssh_missing": "SSH key missing - run: ssh-keygen && gh ssh-key add"
|
"ssh_missing": "SSH key missing - run: ssh-keygen && gh ssh-key add"
|
||||||
},
|
},
|
||||||
|
"git": {
|
||||||
|
"short": "Git workflow commands",
|
||||||
|
"long": "Git workflow commands for managing repositories. Includes status, commit, push, pull operations and safe multi-repo commands for AI agents."
|
||||||
|
},
|
||||||
"go": {
|
"go": {
|
||||||
"short": "Go development tools",
|
"short": "Go development tools",
|
||||||
"long": "Go development tools including testing, formatting, linting, and module management.",
|
"long": "Go development tools including testing, formatting, linting, and module management.",
|
||||||
"test.short": "Run Go tests",
|
"test.short": "Run Go tests",
|
||||||
"cov.short": "Run tests with coverage report",
|
"cov.short": "Run tests with coverage report",
|
||||||
"fmt.short": "Format Go code",
|
"fmt.short": "Format Go code",
|
||||||
|
"fmt.no_changes": "No changed Go files to format.",
|
||||||
|
"fmt.flag.check": "Check if formatted (exit 1 if not)",
|
||||||
|
"fmt.flag.all": "Check all files, not just changed ones",
|
||||||
"lint.short": "Run golangci-lint",
|
"lint.short": "Run golangci-lint",
|
||||||
|
"lint.no_changes": "No changed Go files to lint.",
|
||||||
|
"lint.flag.all": "Lint all files, not just changed ones",
|
||||||
"install.short": "Install Go binary",
|
"install.short": "Install Go binary",
|
||||||
"mod.short": "Module management",
|
"mod.short": "Module management",
|
||||||
"work.short": "Workspace management",
|
"work.short": "Workspace management",
|
||||||
|
|
@ -497,7 +554,13 @@
|
||||||
"issues.category.triage": "Needs Triage",
|
"issues.category.triage": "Needs Triage",
|
||||||
"issues.hint.needs_response": "commented recently",
|
"issues.hint.needs_response": "commented recently",
|
||||||
"issues.hint.blocked": "Waiting on dependency",
|
"issues.hint.blocked": "Waiting on dependency",
|
||||||
"issues.hint.triage": "Add labels and assignee"
|
"issues.hint.triage": "Add labels and assignee",
|
||||||
|
"docblock.short": "Check docstring coverage for Go code",
|
||||||
|
"docblock.long": "Analyse Go packages for docstring coverage on exported symbols. Checks functions, types, constants, and variables.",
|
||||||
|
"docblock.flag.threshold": "Minimum coverage percentage required",
|
||||||
|
"docblock.missing_docs": "Missing documentation:",
|
||||||
|
"docblock.coverage": "Docstring coverage:",
|
||||||
|
"docblock.use_verbose": "Run with -v to see missing docstrings"
|
||||||
},
|
},
|
||||||
"test": {
|
"test": {
|
||||||
"short": "Run Go tests with coverage",
|
"short": "Run Go tests with coverage",
|
||||||
|
|
@ -542,6 +605,7 @@
|
||||||
},
|
},
|
||||||
"label": {
|
"label": {
|
||||||
"done": "Done",
|
"done": "Done",
|
||||||
|
"success": "Success",
|
||||||
"error": "Error",
|
"error": "Error",
|
||||||
"warning": "Warning",
|
"warning": "Warning",
|
||||||
"info": "Info",
|
"info": "Info",
|
||||||
|
|
|
||||||
|
|
@ -24,11 +24,17 @@ import (
|
||||||
// Level defines logging verbosity.
|
// Level defines logging verbosity.
|
||||||
type Level int
|
type Level int
|
||||||
|
|
||||||
|
// Logging level constants ordered by increasing verbosity.
|
||||||
const (
|
const (
|
||||||
|
// LevelQuiet suppresses all log output.
|
||||||
LevelQuiet Level = iota
|
LevelQuiet Level = iota
|
||||||
|
// LevelError shows only error messages.
|
||||||
LevelError
|
LevelError
|
||||||
|
// LevelWarn shows warnings and errors.
|
||||||
LevelWarn
|
LevelWarn
|
||||||
|
// LevelInfo shows informational messages, warnings, and errors.
|
||||||
LevelInfo
|
LevelInfo
|
||||||
|
// LevelDebug shows all messages including debug details.
|
||||||
LevelDebug
|
LevelDebug
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -87,37 +87,44 @@ func (s *Service) registerTools() {
|
||||||
}, s.getSupportedLanguages)
|
}, s.getSupportedLanguages)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tool input/output types
|
// Tool input/output types for MCP file operations.
|
||||||
|
|
||||||
|
// ReadFileInput contains parameters for reading a file.
|
||||||
type ReadFileInput struct {
|
type ReadFileInput struct {
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ReadFileOutput contains the result of reading a file.
|
||||||
type ReadFileOutput struct {
|
type ReadFileOutput struct {
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
Language string `json:"language"`
|
Language string `json:"language"`
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WriteFileInput contains parameters for writing a file.
|
||||||
type WriteFileInput struct {
|
type WriteFileInput struct {
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WriteFileOutput contains the result of writing a file.
|
||||||
type WriteFileOutput struct {
|
type WriteFileOutput struct {
|
||||||
Success bool `json:"success"`
|
Success bool `json:"success"`
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListDirectoryInput contains parameters for listing a directory.
|
||||||
type ListDirectoryInput struct {
|
type ListDirectoryInput struct {
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListDirectoryOutput contains the result of listing a directory.
|
||||||
type ListDirectoryOutput struct {
|
type ListDirectoryOutput struct {
|
||||||
Entries []DirectoryEntry `json:"entries"`
|
Entries []DirectoryEntry `json:"entries"`
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DirectoryEntry represents a single entry in a directory listing.
|
||||||
type DirectoryEntry struct {
|
type DirectoryEntry struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
|
|
@ -125,66 +132,80 @@ type DirectoryEntry struct {
|
||||||
Size int64 `json:"size"`
|
Size int64 `json:"size"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CreateDirectoryInput contains parameters for creating a directory.
|
||||||
type CreateDirectoryInput struct {
|
type CreateDirectoryInput struct {
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CreateDirectoryOutput contains the result of creating a directory.
|
||||||
type CreateDirectoryOutput struct {
|
type CreateDirectoryOutput struct {
|
||||||
Success bool `json:"success"`
|
Success bool `json:"success"`
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeleteFileInput contains parameters for deleting a file.
|
||||||
type DeleteFileInput struct {
|
type DeleteFileInput struct {
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeleteFileOutput contains the result of deleting a file.
|
||||||
type DeleteFileOutput struct {
|
type DeleteFileOutput struct {
|
||||||
Success bool `json:"success"`
|
Success bool `json:"success"`
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RenameFileInput contains parameters for renaming a file.
|
||||||
type RenameFileInput struct {
|
type RenameFileInput struct {
|
||||||
OldPath string `json:"oldPath"`
|
OldPath string `json:"oldPath"`
|
||||||
NewPath string `json:"newPath"`
|
NewPath string `json:"newPath"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RenameFileOutput contains the result of renaming a file.
|
||||||
type RenameFileOutput struct {
|
type RenameFileOutput struct {
|
||||||
Success bool `json:"success"`
|
Success bool `json:"success"`
|
||||||
OldPath string `json:"oldPath"`
|
OldPath string `json:"oldPath"`
|
||||||
NewPath string `json:"newPath"`
|
NewPath string `json:"newPath"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FileExistsInput contains parameters for checking file existence.
|
||||||
type FileExistsInput struct {
|
type FileExistsInput struct {
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FileExistsOutput contains the result of checking file existence.
|
||||||
type FileExistsOutput struct {
|
type FileExistsOutput struct {
|
||||||
Exists bool `json:"exists"`
|
Exists bool `json:"exists"`
|
||||||
IsDir bool `json:"isDir"`
|
IsDir bool `json:"isDir"`
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DetectLanguageInput contains parameters for detecting file language.
|
||||||
type DetectLanguageInput struct {
|
type DetectLanguageInput struct {
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DetectLanguageOutput contains the detected programming language.
|
||||||
type DetectLanguageOutput struct {
|
type DetectLanguageOutput struct {
|
||||||
Language string `json:"language"`
|
Language string `json:"language"`
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetSupportedLanguagesInput is an empty struct for the languages query.
|
||||||
type GetSupportedLanguagesInput struct{}
|
type GetSupportedLanguagesInput struct{}
|
||||||
|
|
||||||
|
// GetSupportedLanguagesOutput contains the list of supported languages.
|
||||||
type GetSupportedLanguagesOutput struct {
|
type GetSupportedLanguagesOutput struct {
|
||||||
Languages []LanguageInfo `json:"languages"`
|
Languages []LanguageInfo `json:"languages"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LanguageInfo describes a supported programming language.
|
||||||
type LanguageInfo struct {
|
type LanguageInfo struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Extensions []string `json:"extensions"`
|
Extensions []string `json:"extensions"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EditDiffInput contains parameters for editing a file via diff.
|
||||||
type EditDiffInput struct {
|
type EditDiffInput struct {
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
OldString string `json:"old_string"`
|
OldString string `json:"old_string"`
|
||||||
|
|
@ -192,6 +213,7 @@ type EditDiffInput struct {
|
||||||
ReplaceAll bool `json:"replace_all,omitempty"`
|
ReplaceAll bool `json:"replace_all,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EditDiffOutput contains the result of a diff-based edit operation.
|
||||||
type EditDiffOutput struct {
|
type EditDiffOutput struct {
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
Success bool `json:"success"`
|
Success bool `json:"success"`
|
||||||
|
|
@ -308,6 +330,10 @@ func (s *Service) getSupportedLanguages(ctx context.Context, req *mcp.CallToolRe
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) editDiff(ctx context.Context, req *mcp.CallToolRequest, input EditDiffInput) (*mcp.CallToolResult, EditDiffOutput, error) {
|
func (s *Service) editDiff(ctx context.Context, req *mcp.CallToolRequest, input EditDiffInput) (*mcp.CallToolResult, EditDiffOutput, error) {
|
||||||
|
if input.OldString == "" {
|
||||||
|
return nil, EditDiffOutput{}, fmt.Errorf("old_string cannot be empty")
|
||||||
|
}
|
||||||
|
|
||||||
content, err := os.ReadFile(input.Path)
|
content, err := os.ReadFile(input.Path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, EditDiffOutput{}, fmt.Errorf("failed to read file: %w", err)
|
return nil, EditDiffOutput{}, fmt.Errorf("failed to read file: %w", err)
|
||||||
|
|
|
||||||
|
|
@ -105,7 +105,6 @@ func AddPHPCommands(root *cobra.Command) {
|
||||||
}
|
}
|
||||||
root.AddCommand(phpCmd)
|
root.AddCommand(phpCmd)
|
||||||
|
|
||||||
|
|
||||||
// Development
|
// Development
|
||||||
addPHPDevCommand(phpCmd)
|
addPHPDevCommand(phpCmd)
|
||||||
addPHPLogsCommand(phpCmd)
|
addPHPLogsCommand(phpCmd)
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@ import (
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
||||||
"github.com/host-uk/core/pkg/cli"
|
"github.com/host-uk/core/pkg/cli"
|
||||||
"github.com/host-uk/core/pkg/i18n"
|
"github.com/host-uk/core/pkg/i18n"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
@ -157,7 +156,7 @@ func runPHPDev(opts phpDevOptions) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
cli.Print("%s %s\n", errorStyle.Render(i18n.Label("warning")), i18n.T("i18n.fail.get", "logs"))
|
cli.Print("%s %s\n", errorStyle.Render(i18n.Label("warning")), i18n.T("i18n.fail.get", "logs"))
|
||||||
} else {
|
} else {
|
||||||
defer logsReader.Close()
|
defer func() { _ = logsReader.Close() }()
|
||||||
|
|
||||||
scanner := bufio.NewScanner(logsReader)
|
scanner := bufio.NewScanner(logsReader)
|
||||||
for scanner.Scan() {
|
for scanner.Scan() {
|
||||||
|
|
|
||||||
|
|
@ -132,7 +132,7 @@ func BuildDocker(ctx context.Context, opts DockerBuildOptions) error {
|
||||||
if err := os.WriteFile(tempDockerfile, []byte(content), 0644); err != nil {
|
if err := os.WriteFile(tempDockerfile, []byte(content), 0644); err != nil {
|
||||||
return cli.WrapVerb(err, "write", "Dockerfile")
|
return cli.WrapVerb(err, "write", "Dockerfile")
|
||||||
}
|
}
|
||||||
defer os.Remove(tempDockerfile)
|
defer func() { _ = os.Remove(tempDockerfile) }()
|
||||||
|
|
||||||
dockerfilePath = tempDockerfile
|
dockerfilePath = tempDockerfile
|
||||||
}
|
}
|
||||||
|
|
@ -233,7 +233,7 @@ func BuildLinuxKit(ctx context.Context, opts LinuxKitBuildOptions) error {
|
||||||
if err := os.WriteFile(tempYAML, []byte(content), 0644); err != nil {
|
if err := os.WriteFile(tempYAML, []byte(content), 0644); err != nil {
|
||||||
return cli.WrapVerb(err, "write", "template")
|
return cli.WrapVerb(err, "write", "template")
|
||||||
}
|
}
|
||||||
defer os.Remove(tempYAML)
|
defer func() { _ = os.Remove(tempYAML) }()
|
||||||
|
|
||||||
// Build LinuxKit image
|
// Build LinuxKit image
|
||||||
args := []string{
|
args := []string{
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package php
|
package php
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
@ -106,12 +107,12 @@ func TestLookupLinuxKit_Bad(t *testing.T) {
|
||||||
origPath := os.Getenv("PATH")
|
origPath := os.Getenv("PATH")
|
||||||
origCommonPaths := commonLinuxKitPaths
|
origCommonPaths := commonLinuxKitPaths
|
||||||
defer func() {
|
defer func() {
|
||||||
os.Setenv("PATH", origPath)
|
_ = os.Setenv("PATH", origPath)
|
||||||
commonLinuxKitPaths = origCommonPaths
|
commonLinuxKitPaths = origCommonPaths
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Set PATH to empty and clear common paths
|
// Set PATH to empty and clear common paths
|
||||||
os.Setenv("PATH", "")
|
_ = os.Setenv("PATH", "")
|
||||||
commonLinuxKitPaths = []string{}
|
commonLinuxKitPaths = []string{}
|
||||||
|
|
||||||
_, err := lookupLinuxKit()
|
_, err := lookupLinuxKit()
|
||||||
|
|
@ -202,7 +203,7 @@ func TestBuildDocker_Bad(t *testing.T) {
|
||||||
|
|
||||||
t.Run("fails for non-PHP project", func(t *testing.T) {
|
t.Run("fails for non-PHP project", func(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
err := BuildDocker(nil, DockerBuildOptions{ProjectDir: dir})
|
err := BuildDocker(context.TODO(), DockerBuildOptions{ProjectDir: dir})
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
assert.Contains(t, err.Error(), "not a PHP project")
|
assert.Contains(t, err.Error(), "not a PHP project")
|
||||||
})
|
})
|
||||||
|
|
@ -213,7 +214,7 @@ func TestBuildLinuxKit_Bad(t *testing.T) {
|
||||||
|
|
||||||
t.Run("fails for non-PHP project", func(t *testing.T) {
|
t.Run("fails for non-PHP project", func(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
err := BuildLinuxKit(nil, LinuxKitBuildOptions{ProjectDir: dir})
|
err := BuildLinuxKit(context.TODO(), LinuxKitBuildOptions{ProjectDir: dir})
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
assert.Contains(t, err.Error(), "not a PHP project")
|
assert.Contains(t, err.Error(), "not a PHP project")
|
||||||
})
|
})
|
||||||
|
|
@ -221,7 +222,7 @@ func TestBuildLinuxKit_Bad(t *testing.T) {
|
||||||
|
|
||||||
func TestServeProduction_Bad(t *testing.T) {
|
func TestServeProduction_Bad(t *testing.T) {
|
||||||
t.Run("fails without image name", func(t *testing.T) {
|
t.Run("fails without image name", func(t *testing.T) {
|
||||||
err := ServeProduction(nil, ServeOptions{})
|
err := ServeProduction(context.TODO(), ServeOptions{})
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
assert.Contains(t, err.Error(), "image name is required")
|
assert.Contains(t, err.Error(), "image name is required")
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -92,7 +92,7 @@ func LoadCoolifyConfigFromFile(path string) (*CoolifyConfig, error) {
|
||||||
}
|
}
|
||||||
return nil, cli.WrapVerb(err, "open", ".env file")
|
return nil, cli.WrapVerb(err, "open", ".env file")
|
||||||
}
|
}
|
||||||
defer file.Close()
|
defer func() { _ = file.Close() }()
|
||||||
|
|
||||||
content, err := io.ReadAll(file)
|
content, err := io.ReadAll(file)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -177,7 +177,7 @@ func (c *CoolifyClient) TriggerDeploy(ctx context.Context, appID string, force b
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, cli.Wrap(err, "request failed")
|
return nil, cli.Wrap(err, "request failed")
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusAccepted {
|
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusAccepted {
|
||||||
return nil, c.parseError(resp)
|
return nil, c.parseError(resp)
|
||||||
|
|
@ -210,7 +210,7 @@ func (c *CoolifyClient) GetDeployment(ctx context.Context, appID, deploymentID s
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, cli.Wrap(err, "request failed")
|
return nil, cli.Wrap(err, "request failed")
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return nil, c.parseError(resp)
|
return nil, c.parseError(resp)
|
||||||
|
|
@ -242,7 +242,7 @@ func (c *CoolifyClient) ListDeployments(ctx context.Context, appID string, limit
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, cli.Wrap(err, "request failed")
|
return nil, cli.Wrap(err, "request failed")
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return nil, c.parseError(resp)
|
return nil, c.parseError(resp)
|
||||||
|
|
@ -280,7 +280,7 @@ func (c *CoolifyClient) Rollback(ctx context.Context, appID, deploymentID string
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, cli.Wrap(err, "request failed")
|
return nil, cli.Wrap(err, "request failed")
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusAccepted {
|
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusAccepted {
|
||||||
return nil, c.parseError(resp)
|
return nil, c.parseError(resp)
|
||||||
|
|
@ -312,7 +312,7 @@ func (c *CoolifyClient) GetApp(ctx context.Context, appID string) (*CoolifyApp,
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, cli.Wrap(err, "request failed")
|
return nil, cli.Wrap(err, "request failed")
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return nil, c.parseError(resp)
|
return nil, c.parseError(resp)
|
||||||
|
|
|
||||||
|
|
@ -11,11 +11,17 @@ import (
|
||||||
// DetectedService represents a service that was detected in a Laravel project.
|
// DetectedService represents a service that was detected in a Laravel project.
|
||||||
type DetectedService string
|
type DetectedService string
|
||||||
|
|
||||||
|
// Detected service constants for Laravel projects.
|
||||||
const (
|
const (
|
||||||
|
// ServiceFrankenPHP indicates FrankenPHP server is detected.
|
||||||
ServiceFrankenPHP DetectedService = "frankenphp"
|
ServiceFrankenPHP DetectedService = "frankenphp"
|
||||||
|
// ServiceVite indicates Vite frontend bundler is detected.
|
||||||
ServiceVite DetectedService = "vite"
|
ServiceVite DetectedService = "vite"
|
||||||
|
// ServiceHorizon indicates Laravel Horizon queue dashboard is detected.
|
||||||
ServiceHorizon DetectedService = "horizon"
|
ServiceHorizon DetectedService = "horizon"
|
||||||
|
// ServiceReverb indicates Laravel Reverb WebSocket server is detected.
|
||||||
ServiceReverb DetectedService = "reverb"
|
ServiceReverb DetectedService = "reverb"
|
||||||
|
// ServiceRedis indicates Redis cache/queue backend is detected.
|
||||||
ServiceRedis DetectedService = "redis"
|
ServiceRedis DetectedService = "redis"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/host-uk/core/pkg/cli"
|
"github.com/host-uk/core/pkg/cli"
|
||||||
|
"github.com/host-uk/core/pkg/i18n"
|
||||||
)
|
)
|
||||||
|
|
||||||
// FormatOptions configures PHP code formatting.
|
// FormatOptions configures PHP code formatting.
|
||||||
|
|
@ -60,15 +61,20 @@ type AnalyseOptions struct {
|
||||||
// FormatterType represents the detected formatter.
|
// FormatterType represents the detected formatter.
|
||||||
type FormatterType string
|
type FormatterType string
|
||||||
|
|
||||||
|
// Formatter type constants.
|
||||||
const (
|
const (
|
||||||
|
// FormatterPint indicates Laravel Pint code formatter.
|
||||||
FormatterPint FormatterType = "pint"
|
FormatterPint FormatterType = "pint"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AnalyserType represents the detected static analyser.
|
// AnalyserType represents the detected static analyser.
|
||||||
type AnalyserType string
|
type AnalyserType string
|
||||||
|
|
||||||
|
// Static analyser type constants.
|
||||||
const (
|
const (
|
||||||
|
// AnalyserPHPStan indicates standard PHPStan analyser.
|
||||||
AnalyserPHPStan AnalyserType = "phpstan"
|
AnalyserPHPStan AnalyserType = "phpstan"
|
||||||
|
// AnalyserLarastan indicates Laravel-specific Larastan analyser.
|
||||||
AnalyserLarastan AnalyserType = "larastan"
|
AnalyserLarastan AnalyserType = "larastan"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -279,7 +285,9 @@ type PsalmOptions struct {
|
||||||
// PsalmType represents the detected Psalm configuration.
|
// PsalmType represents the detected Psalm configuration.
|
||||||
type PsalmType string
|
type PsalmType string
|
||||||
|
|
||||||
|
// Psalm configuration type constants.
|
||||||
const (
|
const (
|
||||||
|
// PsalmStandard indicates standard Psalm configuration.
|
||||||
PsalmStandard PsalmType = "psalm"
|
PsalmStandard PsalmType = "psalm"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -695,9 +703,13 @@ type QAOptions struct {
|
||||||
// QAStage represents a stage in the QA pipeline.
|
// QAStage represents a stage in the QA pipeline.
|
||||||
type QAStage string
|
type QAStage string
|
||||||
|
|
||||||
|
// QA pipeline stage constants.
|
||||||
const (
|
const (
|
||||||
|
// QAStageQuick runs fast checks only (audit, fmt, stan).
|
||||||
QAStageQuick QAStage = "quick"
|
QAStageQuick QAStage = "quick"
|
||||||
|
// QAStageStandard runs standard checks including tests.
|
||||||
QAStageStandard QAStage = "standard"
|
QAStageStandard QAStage = "standard"
|
||||||
|
// QAStageFull runs all checks including slow security scans.
|
||||||
QAStageFull QAStage = "full"
|
QAStageFull QAStage = "full"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -816,7 +828,7 @@ func RunSecurityChecks(ctx context.Context, opts SecurityOptions) (*SecurityResu
|
||||||
for _, audit := range auditResults {
|
for _, audit := range auditResults {
|
||||||
check := SecurityCheck{
|
check := SecurityCheck{
|
||||||
ID: audit.Tool + "_audit",
|
ID: audit.Tool + "_audit",
|
||||||
Name: strings.Title(audit.Tool) + " Security Audit",
|
Name: i18n.Title(audit.Tool) + " Security Audit",
|
||||||
Description: "Check " + audit.Tool + " dependencies for vulnerabilities",
|
Description: "Check " + audit.Tool + " dependencies for vulnerabilities",
|
||||||
Severity: "critical",
|
Severity: "critical",
|
||||||
Passed: audit.Vulnerabilities == 0 && audit.Error == nil,
|
Passed: audit.Vulnerabilities == 0 && audit.Error == nil,
|
||||||
|
|
|
||||||
|
|
@ -220,6 +220,7 @@ type FrankenPHPOptions struct {
|
||||||
KeyFile string
|
KeyFile string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Start launches the FrankenPHP Octane server.
|
||||||
func (s *FrankenPHPService) Start(ctx context.Context) error {
|
func (s *FrankenPHPService) Start(ctx context.Context) error {
|
||||||
args := []string{
|
args := []string{
|
||||||
"artisan", "octane:start",
|
"artisan", "octane:start",
|
||||||
|
|
@ -239,6 +240,7 @@ func (s *FrankenPHPService) Start(ctx context.Context) error {
|
||||||
return s.startProcess(ctx, "php", args, nil)
|
return s.startProcess(ctx, "php", args, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stop terminates the FrankenPHP server process.
|
||||||
func (s *FrankenPHPService) Stop() error {
|
func (s *FrankenPHPService) Stop() error {
|
||||||
return s.stopProcess()
|
return s.stopProcess()
|
||||||
}
|
}
|
||||||
|
|
@ -277,6 +279,7 @@ type ViteOptions struct {
|
||||||
PackageManager string
|
PackageManager string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Start launches the Vite development server.
|
||||||
func (s *ViteService) Start(ctx context.Context) error {
|
func (s *ViteService) Start(ctx context.Context) error {
|
||||||
var cmdName string
|
var cmdName string
|
||||||
var args []string
|
var args []string
|
||||||
|
|
@ -299,6 +302,7 @@ func (s *ViteService) Start(ctx context.Context) error {
|
||||||
return s.startProcess(ctx, cmdName, args, nil)
|
return s.startProcess(ctx, cmdName, args, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stop terminates the Vite development server.
|
||||||
func (s *ViteService) Stop() error {
|
func (s *ViteService) Stop() error {
|
||||||
return s.stopProcess()
|
return s.stopProcess()
|
||||||
}
|
}
|
||||||
|
|
@ -319,10 +323,12 @@ func NewHorizonService(dir string) *HorizonService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Start launches the Laravel Horizon queue worker.
|
||||||
func (s *HorizonService) Start(ctx context.Context) error {
|
func (s *HorizonService) Start(ctx context.Context) error {
|
||||||
return s.startProcess(ctx, "php", []string{"artisan", "horizon"}, nil)
|
return s.startProcess(ctx, "php", []string{"artisan", "horizon"}, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stop terminates Horizon using its terminate command.
|
||||||
func (s *HorizonService) Stop() error {
|
func (s *HorizonService) Stop() error {
|
||||||
// Horizon has its own terminate command
|
// Horizon has its own terminate command
|
||||||
cmd := exec.Command("php", "artisan", "horizon:terminate")
|
cmd := exec.Command("php", "artisan", "horizon:terminate")
|
||||||
|
|
@ -358,6 +364,7 @@ type ReverbOptions struct {
|
||||||
Port int
|
Port int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Start launches the Laravel Reverb WebSocket server.
|
||||||
func (s *ReverbService) Start(ctx context.Context) error {
|
func (s *ReverbService) Start(ctx context.Context) error {
|
||||||
args := []string{
|
args := []string{
|
||||||
"artisan", "reverb:start",
|
"artisan", "reverb:start",
|
||||||
|
|
@ -367,6 +374,7 @@ func (s *ReverbService) Start(ctx context.Context) error {
|
||||||
return s.startProcess(ctx, "php", args, nil)
|
return s.startProcess(ctx, "php", args, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stop terminates the Reverb WebSocket server.
|
||||||
func (s *ReverbService) Stop() error {
|
func (s *ReverbService) Stop() error {
|
||||||
return s.stopProcess()
|
return s.stopProcess()
|
||||||
}
|
}
|
||||||
|
|
@ -400,6 +408,7 @@ type RedisOptions struct {
|
||||||
ConfigFile string
|
ConfigFile string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Start launches the Redis server.
|
||||||
func (s *RedisService) Start(ctx context.Context) error {
|
func (s *RedisService) Start(ctx context.Context) error {
|
||||||
args := []string{
|
args := []string{
|
||||||
"--port", cli.Sprintf("%d", s.port),
|
"--port", cli.Sprintf("%d", s.port),
|
||||||
|
|
@ -414,6 +423,7 @@ func (s *RedisService) Start(ctx context.Context) error {
|
||||||
return s.startProcess(ctx, "redis-server", args, nil)
|
return s.startProcess(ctx, "redis-server", args, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stop terminates Redis using the shutdown command.
|
||||||
func (s *RedisService) Stop() error {
|
func (s *RedisService) Stop() error {
|
||||||
// Try graceful shutdown via redis-cli
|
// Try graceful shutdown via redis-cli
|
||||||
cmd := exec.Command("redis-cli", "-p", cli.Sprintf("%d", s.port), "shutdown", "nosave")
|
cmd := exec.Command("redis-cli", "-p", cli.Sprintf("%d", s.port), "shutdown", "nosave")
|
||||||
|
|
|
||||||
|
|
@ -40,8 +40,11 @@ type TestOptions struct {
|
||||||
// TestRunner represents the detected test runner.
|
// TestRunner represents the detected test runner.
|
||||||
type TestRunner string
|
type TestRunner string
|
||||||
|
|
||||||
|
// Test runner type constants.
|
||||||
const (
|
const (
|
||||||
|
// TestRunnerPest indicates Pest testing framework.
|
||||||
TestRunnerPest TestRunner = "pest"
|
TestRunnerPest TestRunner = "pest"
|
||||||
|
// TestRunnerPHPUnit indicates PHPUnit testing framework.
|
||||||
TestRunnerPHPUnit TestRunner = "phpunit"
|
TestRunnerPHPUnit TestRunner = "phpunit"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -165,18 +165,3 @@ func (p *Process) CloseStdin() error {
|
||||||
p.stdin = nil
|
p.stdin = nil
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// setStatus updates the process status (internal use).
|
|
||||||
func (p *Process) setStatus(status Status) {
|
|
||||||
p.mu.Lock()
|
|
||||||
defer p.mu.Unlock()
|
|
||||||
p.Status = status
|
|
||||||
}
|
|
||||||
|
|
||||||
// setExitCode sets the exit code and duration (internal use).
|
|
||||||
func (p *Process) setExitCode(code int, duration time.Duration) {
|
|
||||||
p.mu.Lock()
|
|
||||||
defer p.mu.Unlock()
|
|
||||||
p.ExitCode = code
|
|
||||||
p.Duration = duration
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -127,6 +127,7 @@ type ServiceError struct {
|
||||||
msg string
|
msg string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Error returns the service error message.
|
||||||
func (e *ServiceError) Error() string {
|
func (e *ServiceError) Error() string {
|
||||||
return e.msg
|
return e.msg
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -255,4 +255,3 @@ func TestService_Clear(t *testing.T) {
|
||||||
assert.Len(t, svc.List(), 0)
|
assert.Len(t, svc.List(), 0)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
353
pkg/qa/cmd_docblock.go
Normal file
353
pkg/qa/cmd_docblock.go
Normal file
|
|
@ -0,0 +1,353 @@
|
||||||
|
// cmd_docblock.go implements docblock/docstring coverage checking for Go code.
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
//
|
||||||
|
// core qa docblock # Check current directory
|
||||||
|
// core qa docblock ./pkg/... # Check specific packages
|
||||||
|
// core qa docblock --threshold=80 # Require 80% coverage
|
||||||
|
package qa
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"go/ast"
|
||||||
|
"go/parser"
|
||||||
|
"go/token"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/host-uk/core/pkg/cli"
|
||||||
|
"github.com/host-uk/core/pkg/i18n"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Docblock command flags
|
||||||
|
var (
|
||||||
|
docblockThreshold float64
|
||||||
|
docblockVerbose bool
|
||||||
|
docblockJSON bool
|
||||||
|
)
|
||||||
|
|
||||||
|
// addDocblockCommand adds the 'docblock' command to qa.
|
||||||
|
func addDocblockCommand(parent *cli.Command) {
|
||||||
|
docblockCmd := &cli.Command{
|
||||||
|
Use: "docblock [packages...]",
|
||||||
|
Short: i18n.T("cmd.qa.docblock.short"),
|
||||||
|
Long: i18n.T("cmd.qa.docblock.long"),
|
||||||
|
RunE: func(cmd *cli.Command, args []string) error {
|
||||||
|
paths := args
|
||||||
|
if len(paths) == 0 {
|
||||||
|
paths = []string{"./..."}
|
||||||
|
}
|
||||||
|
return RunDocblockCheck(paths, docblockThreshold, docblockVerbose, docblockJSON)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
docblockCmd.Flags().Float64Var(&docblockThreshold, "threshold", 80, i18n.T("cmd.qa.docblock.flag.threshold"))
|
||||||
|
docblockCmd.Flags().BoolVarP(&docblockVerbose, "verbose", "v", false, i18n.T("common.flag.verbose"))
|
||||||
|
docblockCmd.Flags().BoolVar(&docblockJSON, "json", false, i18n.T("common.flag.json"))
|
||||||
|
|
||||||
|
parent.AddCommand(docblockCmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DocblockResult holds the result of a docblock coverage check.
|
||||||
|
type DocblockResult struct {
|
||||||
|
Coverage float64 `json:"coverage"`
|
||||||
|
Threshold float64 `json:"threshold"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
Documented int `json:"documented"`
|
||||||
|
Missing []MissingDocblock `json:"missing,omitempty"`
|
||||||
|
Passed bool `json:"passed"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MissingDocblock represents an exported symbol without documentation.
|
||||||
|
type MissingDocblock struct {
|
||||||
|
File string `json:"file"`
|
||||||
|
Line int `json:"line"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Kind string `json:"kind"` // func, type, const, var
|
||||||
|
Reason string `json:"reason,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunDocblockCheck checks docblock coverage for the given packages.
|
||||||
|
func RunDocblockCheck(paths []string, threshold float64, verbose, jsonOutput bool) error {
|
||||||
|
result, err := CheckDocblockCoverage(paths)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
result.Threshold = threshold
|
||||||
|
result.Passed = result.Coverage >= threshold
|
||||||
|
|
||||||
|
if jsonOutput {
|
||||||
|
data, err := json.MarshalIndent(result, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Println(string(data))
|
||||||
|
if !result.Passed {
|
||||||
|
return cli.Err("docblock coverage %.1f%% below threshold %.1f%%", result.Coverage, threshold)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort missing by file then line
|
||||||
|
sort.Slice(result.Missing, func(i, j int) bool {
|
||||||
|
if result.Missing[i].File != result.Missing[j].File {
|
||||||
|
return result.Missing[i].File < result.Missing[j].File
|
||||||
|
}
|
||||||
|
return result.Missing[i].Line < result.Missing[j].Line
|
||||||
|
})
|
||||||
|
|
||||||
|
// Print result
|
||||||
|
if verbose && len(result.Missing) > 0 {
|
||||||
|
cli.Print("%s\n\n", i18n.T("cmd.qa.docblock.missing_docs"))
|
||||||
|
for _, m := range result.Missing {
|
||||||
|
cli.Print(" %s:%d: %s %s\n",
|
||||||
|
dimStyle.Render(m.File),
|
||||||
|
m.Line,
|
||||||
|
dimStyle.Render(m.Kind),
|
||||||
|
m.Name,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
cli.Blank()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Summary
|
||||||
|
coverageStr := fmt.Sprintf("%.1f%%", result.Coverage)
|
||||||
|
thresholdStr := fmt.Sprintf("%.1f%%", threshold)
|
||||||
|
|
||||||
|
if result.Passed {
|
||||||
|
cli.Print("%s %s %s/%s (%s >= %s)\n",
|
||||||
|
successStyle.Render(i18n.T("common.label.success")),
|
||||||
|
i18n.T("cmd.qa.docblock.coverage"),
|
||||||
|
fmt.Sprintf("%d", result.Documented),
|
||||||
|
fmt.Sprintf("%d", result.Total),
|
||||||
|
successStyle.Render(coverageStr),
|
||||||
|
thresholdStr,
|
||||||
|
)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cli.Print("%s %s %s/%s (%s < %s)\n",
|
||||||
|
errorStyle.Render(i18n.T("common.label.error")),
|
||||||
|
i18n.T("cmd.qa.docblock.coverage"),
|
||||||
|
fmt.Sprintf("%d", result.Documented),
|
||||||
|
fmt.Sprintf("%d", result.Total),
|
||||||
|
errorStyle.Render(coverageStr),
|
||||||
|
thresholdStr,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Always show compact file:line list when failing (token-efficient for AI agents)
|
||||||
|
if len(result.Missing) > 0 {
|
||||||
|
cli.Blank()
|
||||||
|
for _, m := range result.Missing {
|
||||||
|
cli.Print("%s:%d\n", m.File, m.Line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return cli.Err("docblock coverage %.1f%% below threshold %.1f%%", result.Coverage, threshold)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckDocblockCoverage analyzes Go packages for docblock coverage.
|
||||||
|
func CheckDocblockCoverage(patterns []string) (*DocblockResult, error) {
|
||||||
|
result := &DocblockResult{}
|
||||||
|
|
||||||
|
// Expand patterns to actual directories
|
||||||
|
dirs, err := expandPatterns(patterns)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
fset := token.NewFileSet()
|
||||||
|
|
||||||
|
for _, dir := range dirs {
|
||||||
|
pkgs, err := parser.ParseDir(fset, dir, func(fi os.FileInfo) bool {
|
||||||
|
return !strings.HasSuffix(fi.Name(), "_test.go")
|
||||||
|
}, parser.ParseComments)
|
||||||
|
if err != nil {
|
||||||
|
// Log parse errors but continue to check other directories
|
||||||
|
fmt.Fprintf(os.Stderr, "warning: failed to parse %s: %v\n", dir, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, pkg := range pkgs {
|
||||||
|
for filename, file := range pkg.Files {
|
||||||
|
checkFile(fset, filename, file, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.Total > 0 {
|
||||||
|
result.Coverage = float64(result.Documented) / float64(result.Total) * 100
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// expandPatterns expands Go package patterns like ./... to actual directories.
|
||||||
|
func expandPatterns(patterns []string) ([]string, error) {
|
||||||
|
var dirs []string
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
|
||||||
|
for _, pattern := range patterns {
|
||||||
|
if strings.HasSuffix(pattern, "/...") {
|
||||||
|
// Recursive pattern
|
||||||
|
base := strings.TrimSuffix(pattern, "/...")
|
||||||
|
if base == "." {
|
||||||
|
base = "."
|
||||||
|
}
|
||||||
|
err := filepath.Walk(base, func(path string, info os.FileInfo, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return nil // Skip errors
|
||||||
|
}
|
||||||
|
if !info.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Skip vendor, testdata, and hidden directories (but not "." itself)
|
||||||
|
name := info.Name()
|
||||||
|
if name == "vendor" || name == "testdata" || (strings.HasPrefix(name, ".") && name != ".") {
|
||||||
|
return filepath.SkipDir
|
||||||
|
}
|
||||||
|
// Check if directory has Go files
|
||||||
|
if hasGoFiles(path) && !seen[path] {
|
||||||
|
dirs = append(dirs, path)
|
||||||
|
seen[path] = true
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Single directory
|
||||||
|
path := pattern
|
||||||
|
if !seen[path] && hasGoFiles(path) {
|
||||||
|
dirs = append(dirs, path)
|
||||||
|
seen[path] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return dirs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasGoFiles checks if a directory contains Go files.
|
||||||
|
func hasGoFiles(dir string) bool {
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, entry := range entries {
|
||||||
|
if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".go") && !strings.HasSuffix(entry.Name(), "_test.go") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkFile analyzes a single file for docblock coverage.
|
||||||
|
func checkFile(fset *token.FileSet, filename string, file *ast.File, result *DocblockResult) {
|
||||||
|
// Make filename relative if possible
|
||||||
|
if cwd, err := os.Getwd(); err == nil {
|
||||||
|
if rel, err := filepath.Rel(cwd, filename); err == nil {
|
||||||
|
filename = rel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, decl := range file.Decls {
|
||||||
|
switch d := decl.(type) {
|
||||||
|
case *ast.FuncDecl:
|
||||||
|
// Skip unexported functions
|
||||||
|
if !ast.IsExported(d.Name.Name) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Skip methods on unexported types
|
||||||
|
if d.Recv != nil && len(d.Recv.List) > 0 {
|
||||||
|
if recvType := getReceiverTypeName(d.Recv.List[0].Type); recvType != "" && !ast.IsExported(recvType) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Total++
|
||||||
|
if d.Doc != nil && len(d.Doc.List) > 0 {
|
||||||
|
result.Documented++
|
||||||
|
} else {
|
||||||
|
pos := fset.Position(d.Pos())
|
||||||
|
result.Missing = append(result.Missing, MissingDocblock{
|
||||||
|
File: filename,
|
||||||
|
Line: pos.Line,
|
||||||
|
Name: d.Name.Name,
|
||||||
|
Kind: "func",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
case *ast.GenDecl:
|
||||||
|
for _, spec := range d.Specs {
|
||||||
|
switch s := spec.(type) {
|
||||||
|
case *ast.TypeSpec:
|
||||||
|
if !ast.IsExported(s.Name.Name) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result.Total++
|
||||||
|
// Type can have doc on GenDecl or TypeSpec
|
||||||
|
if (d.Doc != nil && len(d.Doc.List) > 0) || (s.Doc != nil && len(s.Doc.List) > 0) {
|
||||||
|
result.Documented++
|
||||||
|
} else {
|
||||||
|
pos := fset.Position(s.Pos())
|
||||||
|
result.Missing = append(result.Missing, MissingDocblock{
|
||||||
|
File: filename,
|
||||||
|
Line: pos.Line,
|
||||||
|
Name: s.Name.Name,
|
||||||
|
Kind: "type",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
case *ast.ValueSpec:
|
||||||
|
// Check exported consts and vars
|
||||||
|
for _, name := range s.Names {
|
||||||
|
if !ast.IsExported(name.Name) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result.Total++
|
||||||
|
// Value can have doc on GenDecl or ValueSpec
|
||||||
|
if (d.Doc != nil && len(d.Doc.List) > 0) || (s.Doc != nil && len(s.Doc.List) > 0) {
|
||||||
|
result.Documented++
|
||||||
|
} else {
|
||||||
|
pos := fset.Position(name.Pos())
|
||||||
|
result.Missing = append(result.Missing, MissingDocblock{
|
||||||
|
File: filename,
|
||||||
|
Line: pos.Line,
|
||||||
|
Name: name.Name,
|
||||||
|
Kind: kindFromToken(d.Tok),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getReceiverTypeName extracts the type name from a method receiver.
|
||||||
|
func getReceiverTypeName(expr ast.Expr) string {
|
||||||
|
switch t := expr.(type) {
|
||||||
|
case *ast.Ident:
|
||||||
|
return t.Name
|
||||||
|
case *ast.StarExpr:
|
||||||
|
return getReceiverTypeName(t.X)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// kindFromToken returns a string representation of the token kind.
|
||||||
|
func kindFromToken(tok token.Token) string {
|
||||||
|
switch tok {
|
||||||
|
case token.CONST:
|
||||||
|
return "const"
|
||||||
|
case token.VAR:
|
||||||
|
return "var"
|
||||||
|
default:
|
||||||
|
return "value"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -41,4 +41,5 @@ func AddQACommands(root *cli.Command) {
|
||||||
addReviewCommand(qaCmd)
|
addReviewCommand(qaCmd)
|
||||||
addHealthCommand(qaCmd)
|
addHealthCommand(qaCmd)
|
||||||
addIssuesCommand(qaCmd)
|
addIssuesCommand(qaCmd)
|
||||||
|
addDocblockCommand(qaCmd)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import (
|
||||||
"text/template"
|
"text/template"
|
||||||
|
|
||||||
"github.com/host-uk/core/pkg/build"
|
"github.com/host-uk/core/pkg/build"
|
||||||
|
"github.com/host-uk/core/pkg/i18n"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed templates/chocolatey/*.tmpl templates/chocolatey/tools/*.tmpl
|
//go:embed templates/chocolatey/*.tmpl templates/chocolatey/tools/*.tmpl
|
||||||
|
|
@ -79,7 +80,7 @@ func (p *ChocolateyPublisher) Publish(ctx context.Context, release *Release, pub
|
||||||
|
|
||||||
data := chocolateyTemplateData{
|
data := chocolateyTemplateData{
|
||||||
PackageName: packageName,
|
PackageName: packageName,
|
||||||
Title: fmt.Sprintf("%s CLI", strings.Title(projectName)),
|
Title: fmt.Sprintf("%s CLI", i18n.Title(projectName)),
|
||||||
Description: fmt.Sprintf("%s CLI", projectName),
|
Description: fmt.Sprintf("%s CLI", projectName),
|
||||||
Repository: repo,
|
Repository: repo,
|
||||||
Version: version,
|
Version: version,
|
||||||
|
|
|
||||||
|
|
@ -204,7 +204,7 @@ func (p *NpmPublisher) executePublish(ctx context.Context, data npmTemplateData,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create .npmrc with token
|
// Create .npmrc with token
|
||||||
npmrc := fmt.Sprintf("//registry.npmjs.org/:_authToken=${NPM_TOKEN}\n")
|
npmrc := "//registry.npmjs.org/:_authToken=${NPM_TOKEN}\n"
|
||||||
if err := os.WriteFile(filepath.Join(tmpDir, ".npmrc"), []byte(npmrc), 0600); err != nil {
|
if err := os.WriteFile(filepath.Join(tmpDir, ".npmrc"), []byte(npmrc), 0600); err != nil {
|
||||||
return fmt.Errorf("npm.Publish: failed to write .npmrc: %w", err)
|
return fmt.Errorf("npm.Publish: failed to write .npmrc: %w", err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,10 +31,15 @@ type RegistryDefaults struct {
|
||||||
// RepoType indicates the role of a repository in the ecosystem.
|
// RepoType indicates the role of a repository in the ecosystem.
|
||||||
type RepoType string
|
type RepoType string
|
||||||
|
|
||||||
|
// Repository type constants for ecosystem classification.
|
||||||
const (
|
const (
|
||||||
|
// RepoTypeFoundation indicates core foundation packages.
|
||||||
RepoTypeFoundation RepoType = "foundation"
|
RepoTypeFoundation RepoType = "foundation"
|
||||||
|
// RepoTypeModule indicates reusable module packages.
|
||||||
RepoTypeModule RepoType = "module"
|
RepoTypeModule RepoType = "module"
|
||||||
|
// RepoTypeProduct indicates end-user product applications.
|
||||||
RepoTypeProduct RepoType = "product"
|
RepoTypeProduct RepoType = "product"
|
||||||
|
// RepoTypeTemplate indicates starter templates.
|
||||||
RepoTypeTemplate RepoType = "template"
|
RepoTypeTemplate RepoType = "template"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -219,7 +224,7 @@ func detectOrg(repoPath string) string {
|
||||||
func (r *Registry) List() []*Repo {
|
func (r *Registry) List() []*Repo {
|
||||||
repos := make([]*Repo, 0, len(r.Repos))
|
repos := make([]*Repo, 0, len(r.Repos))
|
||||||
for _, repo := range r.Repos {
|
for _, repo := range r.Repos {
|
||||||
repos = repos
|
|
||||||
repos = append(repos, repo)
|
repos = append(repos, repo)
|
||||||
}
|
}
|
||||||
return repos
|
return repos
|
||||||
|
|
|
||||||
|
|
@ -64,8 +64,6 @@ func TestSDK_GenerateLanguage_Bad(t *testing.T) {
|
||||||
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
s := New(tmpDir, nil)
|
s := New(tmpDir, nil)
|
||||||
|
|
||||||
err = s.GenerateLanguage(context.Background(), "invalid-lang")
|
err = s.GenerateLanguage(context.Background(), "invalid-lang")
|
||||||
|
|
|
||||||
|
|
@ -150,22 +150,6 @@ func runGHAPI(endpoint string) ([]byte, error) {
|
||||||
return output, nil
|
return output, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// severityRank returns a numeric rank for severity (higher = more severe).
|
|
||||||
func severityRank(severity string) int {
|
|
||||||
switch strings.ToLower(severity) {
|
|
||||||
case "critical":
|
|
||||||
return 4
|
|
||||||
case "high":
|
|
||||||
return 3
|
|
||||||
case "medium":
|
|
||||||
return 2
|
|
||||||
case "low":
|
|
||||||
return 1
|
|
||||||
default:
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// severityStyle returns the appropriate style for a severity level.
|
// severityStyle returns the appropriate style for a severity level.
|
||||||
func severityStyle(severity string) *cli.AnsiStyle {
|
func severityStyle(severity string) *cli.AnsiStyle {
|
||||||
switch strings.ToLower(severity) {
|
switch strings.ToLower(severity) {
|
||||||
|
|
|
||||||
|
|
@ -14,20 +14,30 @@ import (
|
||||||
// ChangeType indicates the type of change being made.
|
// ChangeType indicates the type of change being made.
|
||||||
type ChangeType string
|
type ChangeType string
|
||||||
|
|
||||||
|
// Change type constants for GitHub configuration diffs.
|
||||||
const (
|
const (
|
||||||
|
// ChangeCreate indicates a new resource to be created.
|
||||||
ChangeCreate ChangeType = "create"
|
ChangeCreate ChangeType = "create"
|
||||||
|
// ChangeUpdate indicates an existing resource to be updated.
|
||||||
ChangeUpdate ChangeType = "update"
|
ChangeUpdate ChangeType = "update"
|
||||||
|
// ChangeDelete indicates a resource to be deleted.
|
||||||
ChangeDelete ChangeType = "delete"
|
ChangeDelete ChangeType = "delete"
|
||||||
|
// ChangeSkip indicates a resource that requires no changes.
|
||||||
ChangeSkip ChangeType = "skip"
|
ChangeSkip ChangeType = "skip"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ChangeCategory groups changes by type.
|
// ChangeCategory groups changes by type.
|
||||||
type ChangeCategory string
|
type ChangeCategory string
|
||||||
|
|
||||||
|
// Change category constants for grouping GitHub configuration changes.
|
||||||
const (
|
const (
|
||||||
|
// CategoryLabel indicates label-related changes.
|
||||||
CategoryLabel ChangeCategory = "label"
|
CategoryLabel ChangeCategory = "label"
|
||||||
|
// CategoryWebhook indicates webhook-related changes.
|
||||||
CategoryWebhook ChangeCategory = "webhook"
|
CategoryWebhook ChangeCategory = "webhook"
|
||||||
|
// CategoryProtection indicates branch protection changes.
|
||||||
CategoryProtection ChangeCategory = "protection"
|
CategoryProtection ChangeCategory = "protection"
|
||||||
|
// CategorySecurity indicates security settings changes.
|
||||||
CategorySecurity ChangeCategory = "security"
|
CategorySecurity ChangeCategory = "security"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ import (
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
|
||||||
"github.com/host-uk/core/pkg/i18n"
|
"github.com/host-uk/core/pkg/i18n"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,12 @@ package main
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/ioutil"
|
|
||||||
"os"
|
"os"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
// Read package.json
|
// Read package.json
|
||||||
data, err := ioutil.ReadFile("package.json")
|
data, err := os.ReadFile("package.json")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Println("Error reading package.json, skipping version file generation.")
|
fmt.Println("Error reading package.json, skipping version file generation.")
|
||||||
os.Exit(0)
|
os.Exit(0)
|
||||||
|
|
@ -26,7 +25,7 @@ func main() {
|
||||||
|
|
||||||
// Create the version file
|
// Create the version file
|
||||||
content := fmt.Sprintf("package updater\n\n// Generated by go:generate. DO NOT EDIT.\n\nconst PkgVersion = %q\n", pkg.Version)
|
content := fmt.Sprintf("package updater\n\n// Generated by go:generate. DO NOT EDIT.\n\nconst PkgVersion = %q\n", pkg.Version)
|
||||||
err = ioutil.WriteFile("version.go", []byte(content), 0644)
|
err = os.WriteFile("version.go", []byte(content), 0644)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("Error writing version file: %v\n", err)
|
fmt.Printf("Error writing version file: %v\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import (
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// AddWorkspaceCommands registers workspace management commands.
|
||||||
func AddWorkspaceCommands(root *cobra.Command) {
|
func AddWorkspaceCommands(root *cobra.Command) {
|
||||||
wsCmd := &cobra.Command{
|
wsCmd := &cobra.Command{
|
||||||
Use: "workspace",
|
Use: "workspace",
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue