Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 6 additions & 21 deletions .idx/contextvibes.nix
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 2 additions & 0 deletions cmd/factory/factory.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand All @@ -41,4 +42,5 @@ func init() {
FactoryCmd.AddCommand(deploy.DeployCmd)
FactoryCmd.AddCommand(scrub.ScrubCmd)
FactoryCmd.AddCommand(setupidentity.SetupIdentityCmd)
FactoryCmd.AddCommand(tools.ToolsCmd) // Added
}
57 changes: 57 additions & 0 deletions cmd/factory/tools/tools.go
Original file line number Diff line number Diff line change
@@ -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
}
10 changes: 10 additions & 0 deletions cmd/factory/tools/tools.md.tpl
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion internal/github/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
}

Expand Down
177 changes: 177 additions & 0 deletions internal/workflow/tools_steps.go
Original file line number Diff line number Diff line change
@@ -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
}