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:
Snider 2026-02-01 10:48:44 +00:00 committed by GitHub
parent 3ee353f880
commit fdc108c69e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
105 changed files with 1608 additions and 674 deletions

23
.gitattributes vendored Normal file
View 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

View file

@ -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{

View file

@ -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"

View file

@ -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"

View file

@ -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"
) )

View file

@ -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"))

View file

@ -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 {

View file

@ -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

View file

@ -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)

View file

@ -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")
}
}) })
} }

View file

@ -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
} }

View file

@ -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
} }

View file

@ -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()

View file

@ -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.

View file

@ -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

View file

@ -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
) )

View file

@ -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()
} }

View file

@ -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)

View file

@ -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.

View file

@ -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 {

View file

@ -28,7 +28,7 @@ const (
ColourAmber600 = "#d97706" ColourAmber600 = "#d97706"
ColourOrange500 = "#f97316" ColourOrange500 = "#f97316"
ColourYellow500 = "#eab308" ColourYellow500 = "#eab308"
ColourEmerald500= "#10b981" ColourEmerald500 = "#10b981"
ColourPurple500 = "#a855f7" ColourPurple500 = "#a855f7"
ColourViolet400 = "#a78bfa" ColourViolet400 = "#a78bfa"
ColourViolet500 = "#8b5cf6" ColourViolet500 = "#8b5cf6"
@ -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

View file

@ -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 {

View file

@ -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)

View file

@ -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")

View file

@ -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"),

View file

@ -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(),

View file

@ -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":

View file

@ -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"),

View file

@ -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)

View file

@ -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"),

View file

@ -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"),

View file

@ -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"),

View file

@ -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"),

View file

@ -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()
} }

View file

@ -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

View file

@ -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()

View file

@ -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
) )

View file

@ -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.

View file

@ -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

View file

@ -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)
} }

View file

@ -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
} }

View file

@ -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
View 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)
}

View file

@ -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)
} }

View file

@ -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", "."}
if qaFix {
args = []string{"-w", "."}
} }
return []string{"-l", "."} 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 runCheck(ctx context.Context, dir string, check QACheck) error { func runCheckCapture(ctx context.Context, dir string, check QACheck) (string, error) {
// Handle internal checks
if check.Command == "_internal_" {
return runInternalCheck(check)
}
// 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)
}
} }

View file

@ -797,4 +797,3 @@ func TestProgressSubjectMatchesExpected(t *testing.T) {
}) })
} }
} }

View file

@ -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()

View file

@ -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",

View file

@ -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
) )

View file

@ -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)

View file

@ -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)

View file

@ -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() {

View file

@ -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{

View file

@ -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")
}) })

View file

@ -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)

View file

@ -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"
) )

View file

@ -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,

View file

@ -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")

View file

@ -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"
) )

View file

@ -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
}

View file

@ -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
} }

View file

@ -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
View 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"
}
}

View file

@ -41,4 +41,5 @@ func AddQACommands(root *cli.Command) {
addReviewCommand(qaCmd) addReviewCommand(qaCmd)
addHealthCommand(qaCmd) addHealthCommand(qaCmd)
addIssuesCommand(qaCmd) addIssuesCommand(qaCmd)
addDocblockCommand(qaCmd)
} }

View file

@ -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,

View file

@ -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)
} }

View file

@ -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

View file

@ -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")

View file

@ -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) {

View file

@ -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"
) )

View file

@ -9,7 +9,6 @@ import (
"strconv" "strconv"
"strings" "strings"
"github.com/host-uk/core/pkg/i18n" "github.com/host-uk/core/pkg/i18n"
) )

View file

@ -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)

View file

@ -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",