From f63c1ccec8d4dc3e2c72ef358efd5500524ed589 Mon Sep 17 00:00:00 2001 From: Jasper Duizendstra Date: Fri, 5 Dec 2025 14:48:54 +0000 Subject: [PATCH] feat(factory): Add 'tools' command Introduces 'contextvibes factory tools' to force-rebuild development tools (govulncheck, golangci-lint) against the current Go version, resolving Nix environment mismatches. --- .idx/contextvibes.nix | 27 ++--- cmd/factory/factory.go | 2 + cmd/factory/tools/tools.go | 57 ++++++++++ cmd/factory/tools/tools.md.tpl | 10 ++ internal/github/client.go | 2 +- internal/workflow/tools_steps.go | 177 +++++++++++++++++++++++++++++++ 6 files changed, 253 insertions(+), 22 deletions(-) create mode 100644 cmd/factory/tools/tools.go create mode 100644 cmd/factory/tools/tools.md.tpl create mode 100644 internal/workflow/tools_steps.go diff --git a/.idx/contextvibes.nix b/.idx/contextvibes.nix index 637db79..ccfa985 100644 --- a/.idx/contextvibes.nix +++ b/.idx/contextvibes.nix @@ -1,32 +1,17 @@ -#.idx/contextvibes.nix +# .idx/contextvibes.nix { pkgs }: pkgs.stdenv.mkDerivation { pname = "contextvibes"; - version = "0.2.1"; + version = "0.5.0"; - # We wrap the fetchurl call in parentheses to apply overrideAttrs. - # This modifies the fixed-output derivation created by fetchurl. - src = (pkgs.fetchurl { - # URL for the release asset. - url = "https://github.com/contextvibes/cli/releases/download/v0.4.1/contextvibes"; - # SHA256 hash of the downloaded file. - sha256 = "sha256:09db14ee2d8258aaedd66ac507c6045cb260efd6bc1acb3bbc22e59db70bdcd7"; - }).overrideAttrs (finalAttrs: previousAttrs: { - # Enable structured attributes to allow passing complex sets. - __structuredAttrs = true; - - # The Critical Fix: - # Explicitly instruct Nix to ignore any store path references found in the downloaded file. - # 'out' refers to the default output of the fetchurl derivation. - unsafeDiscardReferences = { - out = true; - }; - }); + src = pkgs.fetchurl { + url = "https://github.com/contextvibes/cli/releases/download/v0.5.0/contextvibes"; + sha256 = "sha256:c519ee03b6b77721dfc78bb03b638c3327096affafd8968d49b2bbd9a89ffc10"; + }; dontUnpack = true; - # Install the binary into the output directory. installPhase = '' mkdir -p $out/bin install -m 755 -D $src $out/bin/contextvibes diff --git a/cmd/factory/factory.go b/cmd/factory/factory.go index 9851cb2..c1eed7d 100644 --- a/cmd/factory/factory.go +++ b/cmd/factory/factory.go @@ -15,6 +15,7 @@ import ( "github.com/contextvibes/cli/cmd/factory/status" "github.com/contextvibes/cli/cmd/factory/sync" "github.com/contextvibes/cli/cmd/factory/tidy" + "github.com/contextvibes/cli/cmd/factory/tools" // Added "github.com/spf13/cobra" ) @@ -41,4 +42,5 @@ func init() { FactoryCmd.AddCommand(deploy.DeployCmd) FactoryCmd.AddCommand(scrub.ScrubCmd) FactoryCmd.AddCommand(setupidentity.SetupIdentityCmd) + FactoryCmd.AddCommand(tools.ToolsCmd) // Added } diff --git a/cmd/factory/tools/tools.go b/cmd/factory/tools/tools.go new file mode 100644 index 0000000..ab8f165 --- /dev/null +++ b/cmd/factory/tools/tools.go @@ -0,0 +1,57 @@ +// Package tools provides the command to manage the development toolchain. +package tools + +import ( + _ "embed" + + "github.com/contextvibes/cli/internal/cmddocs" + "github.com/contextvibes/cli/internal/globals" + "github.com/contextvibes/cli/internal/ui" + "github.com/contextvibes/cli/internal/workflow" + "github.com/spf13/cobra" +) + +//go:embed tools.md.tpl +var toolsLongDescription string + +// ToolsCmd represents the tools command. +// +//nolint:exhaustruct,gochecknoglobals // Cobra commands are defined with partial structs and globals by design. +var ToolsCmd = &cobra.Command{ + Use: "tools", + Short: "Force rebuilds and installs development tools (fixes Nix version mismatch).", + RunE: func(cmd *cobra.Command, _ []string) error { + presenter := ui.NewPresenter(cmd.OutOrStdout(), cmd.ErrOrStderr()) + ctx := cmd.Context() + + runner := workflow.NewRunner(presenter, globals.AssumeYes) + + return runner.Run( + ctx, + "Updating Development Toolchain", + &workflow.CheckGoEnvStep{ + ExecClient: globals.ExecClient, + Presenter: presenter, + }, + &workflow.ConfigurePathStep{ + Presenter: presenter, + AssumeYes: globals.AssumeYes, + }, + &workflow.InstallGoToolsStep{ + ExecClient: globals.ExecClient, + Presenter: presenter, + }, + ) + }, +} + +//nolint:gochecknoinits // Cobra requires init() for command registration. +func init() { + desc, err := cmddocs.ParseAndExecute(toolsLongDescription, nil) + if err != nil { + panic(err) + } + + ToolsCmd.Short = desc.Short + ToolsCmd.Long = desc.Long +} diff --git a/cmd/factory/tools/tools.md.tpl b/cmd/factory/tools/tools.md.tpl new file mode 100644 index 0000000..9415ea9 --- /dev/null +++ b/cmd/factory/tools/tools.md.tpl @@ -0,0 +1,10 @@ +# Force rebuilds and installs development tools. + +This command addresses environment mismatches where tools provided by Nix (like +`govulncheck` or `golangci-lint`) may be compiled with an older Go version than +the one currently active in the shell. + +It performs the following: +1. Verifies the current Go environment. +2. Ensures `$HOME/go/bin` is prepended to your `PATH` in `.bashrc` (to prioritize local tools). +3. Force-reinstalls standard tools using `go install -a`, ensuring they are compiled with the current Go version. diff --git a/internal/github/client.go b/internal/github/client.go index 542a068..d7e4fcf 100644 --- a/internal/github/client.go +++ b/internal/github/client.go @@ -224,7 +224,7 @@ func (c *Client) GetProjectByNumber(ctx context.Context, number int) (*ProjectWi variables := map[string]any{ "owner": githubv4.String(c.owner), - //nolint:gosec // G115: Project number is unlikely to overflow int32. + "number": githubv4.Int(number), } diff --git a/internal/workflow/tools_steps.go b/internal/workflow/tools_steps.go new file mode 100644 index 0000000..53a2185 --- /dev/null +++ b/internal/workflow/tools_steps.go @@ -0,0 +1,177 @@ +package workflow + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/contextvibes/cli/internal/exec" +) + +// CheckGoEnvStep verifies Go is installed and logs the version. +type CheckGoEnvStep struct { + ExecClient *exec.ExecutorClient + Presenter PresenterInterface +} + +// Description returns the step description. +func (s *CheckGoEnvStep) Description() string { + return "Verify System Go Version" +} + +// PreCheck checks if go is in PATH. +func (s *CheckGoEnvStep) PreCheck(_ context.Context) error { + if !s.ExecClient.CommandExists("go") { + //nolint:err113 // Dynamic error is appropriate here. + return errors.New("go executable not found in PATH") + } + + return nil +} + +// Execute runs the step logic. +func (s *CheckGoEnvStep) Execute(ctx context.Context) error { + out, _, err := s.ExecClient.CaptureOutput(ctx, ".", "go", "version") + if err != nil { + return fmt.Errorf("failed to get go version: %w", err) + } + + s.Presenter.Info("Detected: %s", strings.TrimSpace(out)) + + return nil +} + +// ConfigurePathStep ensures $HOME/go/bin is in .bashrc. +type ConfigurePathStep struct { + Presenter PresenterInterface + AssumeYes bool +} + +// Description returns the step description. +func (s *ConfigurePathStep) Description() string { + return "Configure shell PATH for local Go tools" +} + +// PreCheck performs pre-flight checks. +func (s *ConfigurePathStep) PreCheck(_ context.Context) error { return nil } + +// Execute runs the step logic. +func (s *ConfigurePathStep) Execute(_ context.Context) error { + home, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("failed to get user home dir: %w", err) + } + + rcFile := filepath.Join(home, ".bashrc") + //nolint:gosec // Reading user config is intended. + contentBytes, err := os.ReadFile(rcFile) + if err != nil && !os.IsNotExist(err) { + return fmt.Errorf("failed to read %s: %w", rcFile, err) + } + + content := string(contentBytes) + + targetLine := "export PATH=$HOME/go/bin:$PATH" + if strings.Contains(content, targetLine) { + s.Presenter.Info("PATH configuration already present in %s.", rcFile) + + return nil + } + + s.Presenter.Warning("Local Go bin path is missing from %s.", rcFile) + s.Presenter.Info("Proposed addition:") + s.Presenter.Detail("# Go Tools (Local overrides System/Nix)") + s.Presenter.Detail(targetLine) + + if !s.AssumeYes { + confirm, err := s.Presenter.PromptForConfirmation("Append this to your .bashrc?") + if err != nil { + return fmt.Errorf("prompt failed: %w", err) + } + + if !confirm { + s.Presenter.Info("Skipping PATH configuration.") + + return nil + } + } + + //nolint:mnd,gosec // 0644 is standard for .bashrc. + bashrcFile, err := os.OpenFile(rcFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) + if err != nil { + return fmt.Errorf("failed to open %s: %w", rcFile, err) + } + //nolint:errcheck // Defer close is sufficient. + defer bashrcFile.Close() + + if _, err := bashrcFile.WriteString("\n# Go Tools (Local overrides System/Nix)\n" + targetLine + "\n"); err != nil { + return fmt.Errorf("failed to write to %s: %w", rcFile, err) + } + + s.Presenter.Success("Updated %s. Run 'source %s' after this command completes.", rcFile, rcFile) + + return nil +} + +// InstallGoToolsStep force-installs the required tools. +type InstallGoToolsStep struct { + ExecClient *exec.ExecutorClient + Presenter PresenterInterface +} + +// Description returns the step description. +func (s *InstallGoToolsStep) Description() string { + return "Force rebuild and install Go tools" +} + +// PreCheck performs pre-flight checks. +func (s *InstallGoToolsStep) PreCheck(_ context.Context) error { return nil } + +// Execute runs the step logic. +func (s *InstallGoToolsStep) Execute(ctx context.Context) error { + tools := []string{ + "golang.org/x/vuln/cmd/govulncheck@latest", + "github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest", + "golang.org/x/tools/cmd/deadcode@latest", + "golang.org/x/tools/cmd/goimports@latest", + "golang.org/x/tools/cmd/stringer@latest", + "golang.org/x/tools/cmd/godoc@latest", + } + + // Ensure GOBIN is set for this session so installs go to the right place + home, _ := os.UserHomeDir() + goBin := filepath.Join(home, "go", "bin") + + for _, tool := range tools { + s.Presenter.Step("Installing %s...", tool) + // -a forces rebuild + err := s.ExecClient.Execute(ctx, ".", "go", "install", "-a", tool) + if err != nil { + s.Presenter.Error("Failed to install %s: %v", tool, err) + + return fmt.Errorf("failed to install %s: %w", tool, err) + } + } + + // Verification + s.Presenter.Newline() + s.Presenter.Info("Verifying govulncheck resolution...") + + // We check where the command resolves *now* + out, _, _ := s.ExecClient.CaptureOutput(ctx, ".", "which", "govulncheck") + resolvedPath := strings.TrimSpace(out) + expectedPath := filepath.Join(goBin, "govulncheck") + + if resolvedPath == expectedPath { + s.Presenter.Success("govulncheck resolves to %s", resolvedPath) + } else { + s.Presenter.Warning("govulncheck resolves to %s", resolvedPath) + s.Presenter.Advice("Expected: %s", expectedPath) + s.Presenter.Advice("Please run 'source ~/.bashrc' to update your current shell.") + } + + return nil +}