diff --git a/pkg/binary/hook.go b/pkg/binary/hook.go new file mode 100644 index 0000000..c57ed62 --- /dev/null +++ b/pkg/binary/hook.go @@ -0,0 +1,60 @@ +package binary + +import ( + "fmt" + "io" + "os" + "os/exec" +) + +// RunHook executes a shell command with B_EVENT, B_NAME, B_VERSION, B_FILE +// env vars set. dir is the working directory (project root). stdout/stderr +// accept io.Writer so callers can route through the CLI's IO streams +// (respecting --quiet / output capture). nil writers default to io.Discard. +// Returns nil on success, the exec error on failure. Callers decide whether +// to treat the error as fatal or as a warning. +// +// The command runs via "sh -c" — hooks are POSIX-only. This is consistent +// with the existing env onPreSync/onPostSync hooks in pkg/env/env.go. +func RunHook(command, dir, event, name, version, file string, stdout, stderr io.Writer) error { + if command == "" { + return nil + } + if stdout == nil { + stdout = io.Discard + } + if stderr == nil { + stderr = io.Discard + } + cmd := exec.Command("sh", "-c", command) + cmd.Dir = dir + cmd.Stdout = stdout + cmd.Stderr = stderr + // Build env from parent process, filtering out the four hook-specific + // variables (B_EVENT, B_NAME, B_VERSION, B_FILE) so our values take + // guaranteed precedence regardless of platform. + hookVars := map[string]bool{ + "B_EVENT=": true, "B_NAME=": true, "B_VERSION=": true, "B_FILE=": true, + } + env := make([]string, 0, len(os.Environ())+4) + for _, e := range os.Environ() { + skip := false + for prefix := range hookVars { + if len(e) >= len(prefix) && e[:len(prefix)] == prefix { + skip = true + break + } + } + if !skip { + env = append(env, e) + } + } + env = append(env, + fmt.Sprintf("B_EVENT=%s", event), + fmt.Sprintf("B_NAME=%s", name), + fmt.Sprintf("B_VERSION=%s", version), + fmt.Sprintf("B_FILE=%s", file), + ) + cmd.Env = env + return cmd.Run() +} diff --git a/pkg/binary/hook_test.go b/pkg/binary/hook_test.go new file mode 100644 index 0000000..c79bf9a --- /dev/null +++ b/pkg/binary/hook_test.go @@ -0,0 +1,80 @@ +package binary + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestRunHook_SetsEnvVars(t *testing.T) { + tmp := t.TempDir() + out := filepath.Join(tmp, "env.txt") + + cmd := `printf "%s\n%s\n%s\n%s" "$B_EVENT" "$B_NAME" "$B_VERSION" "$B_FILE" > ` + out + if err := RunHook(cmd, tmp, "install", "kubectl", "v1.28.0", "/usr/local/bin/kubectl", os.Stdout, os.Stderr); err != nil { + t.Fatalf("RunHook: %v", err) + } + + data, err := os.ReadFile(out) + if err != nil { + t.Fatalf("read: %v", err) + } + want := "install\nkubectl\nv1.28.0\n/usr/local/bin/kubectl" + if string(data) != want { + t.Errorf("env vars:\ngot: %q\nwant: %q", data, want) + } +} + +func TestRunHook_EmptyIsNoOp(t *testing.T) { + if err := RunHook("", t.TempDir(), "install", "x", "v1", "/x", nil, nil); err != nil { + t.Errorf("empty hook should be no-op, got: %v", err) + } +} + +func TestRunHook_NonZeroExitReturnsError(t *testing.T) { + if err := RunHook("exit 1", t.TempDir(), "update", "x", "v1", "/x", nil, nil); err == nil { + t.Error("expected error on non-zero exit") + } +} + +func TestRunHook_RunsInDir(t *testing.T) { + tmp := t.TempDir() + // Resolve symlinks so the comparison works on macOS where + // /var → /private/var and t.TempDir() returns the unresolved path. + realTmp, err := filepath.EvalSymlinks(tmp) + if err != nil { + t.Fatal(err) + } + out := filepath.Join(tmp, "pwd.txt") + cmd := "pwd > " + out + if err := RunHook(cmd, tmp, "install", "x", "v1", "/x", nil, nil); err != nil { + t.Fatalf("RunHook: %v", err) + } + data, err := os.ReadFile(out) + if err != nil { + t.Fatal(err) + } + got := strings.TrimSpace(string(data)) + if got != realTmp { + t.Errorf("hook ran in %q, want %q", got, realTmp) + } +} + +func TestRunHook_WritesToProvidedStreams(t *testing.T) { + var out bytes.Buffer + if err := RunHook("echo hello", t.TempDir(), "install", "x", "v1", "/x", &out, nil); err != nil { + t.Fatalf("RunHook: %v", err) + } + if out.String() != "hello\n" { + t.Errorf("stdout = %q, want %q", out.String(), "hello\n") + } +} + +func TestRunHook_NilWritersDefaultToDiscard(t *testing.T) { + // Should not panic with nil writers. + if err := RunHook("echo ok", t.TempDir(), "install", "x", "v1", "/x", nil, nil); err != nil { + t.Errorf("unexpected error: %v", err) + } +} diff --git a/pkg/binary/types.go b/pkg/binary/types.go index e4af004..0b8c0ed 100644 --- a/pkg/binary/types.go +++ b/pkg/binary/types.go @@ -51,6 +51,7 @@ type Binary struct { AssetFilter string `json:"-"` // glob pattern to filter release assets (e.g. "argsh-so-*") SelectAsset SelectAssetFunc `json:"-"` // interactive asset selector for ambiguous matches ResolvedAsset *provider.Asset `json:"-"` // pre-resolved asset (skips matching during download) + OnPost string `json:"-"` // shell command to run after successful install/update } type LocalBinary struct { @@ -64,6 +65,12 @@ type LocalBinary struct { Alias string `json:"alias,omitempty"` // Asset is a glob pattern to filter release assets (e.g. "argsh-so-*") Asset string `json:"asset,omitempty"` + // OnPost is a shell command (POSIX, via "sh -c") run after a successful + // install/update of this binary. Receives B_EVENT (install|update), + // B_NAME, B_VERSION, B_FILE. Only runs when the on-disk binary actually + // changed — skipped on no-op installs, digest-match skips, and + // --dry-run. Non-zero exit is surfaced as a warning, not a fatal error. + OnPost string `json:"onPost,omitempty" yaml:"onPost,omitempty"` // IsProviderRef is true when Name is a provider ref (e.g. github.com/derailed/k9s) IsProviderRef bool `json:"-" yaml:"-"` } diff --git a/pkg/cli/cli_extra_test.go b/pkg/cli/cli_extra_test.go index 84110b3..22948ad 100644 --- a/pkg/cli/cli_extra_test.go +++ b/pkg/cli/cli_extra_test.go @@ -1374,6 +1374,36 @@ func TestAddToConfig_WithAlias(t *testing.T) { } } +func TestAddToConfig_WithOnPost(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, ".bin", "b.yaml") + + o := &InstallOptions{ + SharedOptions: &SharedOptions{ + IO: &streams.IO{Out: &bytes.Buffer{}, ErrOut: &bytes.Buffer{}}, + ConfigPath: configPath, + loadedConfigPath: configPath, + Config: &state.State{}, + }, + OnPost: "argsh builtin ${B_EVENT}", + } + + binaries := []*binary.Binary{ + {Name: "argsh", AutoDetect: true, ProviderRef: "github.com/arg-sh/argsh", OnPost: "argsh builtin ${B_EVENT}"}, + } + if err := o.addToConfig(binaries); err != nil { + t.Fatalf("addToConfig() error = %v", err) + } + + cfg, _ := state.LoadConfigFromPath(configPath) + if len(cfg.Binaries) != 1 { + t.Fatalf("got %d binaries, want 1", len(cfg.Binaries)) + } + if cfg.Binaries[0].OnPost != "argsh builtin ${B_EVENT}" { + t.Errorf("onPost = %q, want %q", cfg.Binaries[0].OnPost, "argsh builtin ${B_EVENT}") + } +} + // --- addEnvToConfig tests --- func TestAddEnvToConfig_New(t *testing.T) { diff --git a/pkg/cli/install.go b/pkg/cli/install.go index a65cac4..837f4f2 100644 --- a/pkg/cli/install.go +++ b/pkg/cli/install.go @@ -40,6 +40,7 @@ type InstallOptions struct { Fix bool // Pin version in b.yaml Alias string // Alias for the binary Asset string // Asset filter glob pattern + OnPost string // Shell command to run after install/update specifiedBinaries []*binary.Binary // Binaries specified on command line envInstalls []envInstall // SCP-style env installs configEnvRefs []string // env refs to sync from config @@ -93,6 +94,7 @@ func NewInstallCmd(shared *SharedOptions) *cobra.Command { cmd.Flags().BoolVar(&o.Fix, "fix", false, "Pin the specified version in b.yaml") cmd.Flags().StringVar(&o.Alias, "alias", "", "Alias for the binary") cmd.Flags().StringVar(&o.Asset, "asset", "", "Glob pattern to filter release assets (e.g. \"argsh-so-*\")") + cmd.Flags().StringVar(&o.OnPost, "on-post", "", "Shell command to run after install/update (saved to b.yaml with --add)") return cmd } @@ -149,6 +151,9 @@ func (o *InstallOptions) Complete(args []string) error { if o.Asset != "" { b.AssetFilter = o.Asset } + if o.OnPost != "" { + b.OnPost = o.OnPost + } o.specifiedBinaries = append(o.specifiedBinaries, b) } @@ -251,12 +256,24 @@ func (o *InstallOptions) installBinaries(binaries []*binary.Binary) error { b.Tracker = tracker b.Writer = pw + // Track whether a download actually happened so we only run + // the onPost hook when the binary changed — not on a no-op + // "already exists" skip from EnsureBinary(false). + wasMissing := !b.BinaryExists() var err error if o.Force { err = b.DownloadBinary() } else { err = b.EnsureBinary(false) // Don't update, just ensure } + downloaded := err == nil && (o.Force || wasMissing) + + // Run onPost hook only when a download actually happened. + if downloaded && b.OnPost != "" { + if hookErr := binary.RunHook(b.OnPost, o.ProjectRoot(), "install", b.Name, b.Version, b.BinaryPath(), o.IO.ErrOut, o.IO.ErrOut); hookErr != nil { + fmt.Fprintf(o.IO.ErrOut, "Warning: onPost hook for %s failed: %v\n", b.Name, hookErr) + } + } name := b.Name if b.Alias != "" { @@ -331,6 +348,9 @@ func (o *InstallOptions) addToConfig(binaries []*binary.Binary) error { if b.AssetFilter != "" { entry.Asset = b.AssetFilter } + if b.OnPost != "" { + entry.OnPost = b.OnPost + } config.Binaries = append(config.Binaries, entry) } } diff --git a/pkg/cli/shared.go b/pkg/cli/shared.go index b741498..aee8037 100644 --- a/pkg/cli/shared.go +++ b/pkg/cli/shared.go @@ -105,6 +105,9 @@ func (o *SharedOptions) resolveBinary(lb *binary.LocalBinary) (*binary.Binary, b if lb.File != "" { b.File = lb.File } + if lb.OnPost != "" { + b.OnPost = lb.OnPost + } } return b, ok @@ -162,6 +165,9 @@ func (o *SharedOptions) GetBinary(name string) (*binary.Binary, bool) { if configEntry.Asset != "" { b.AssetFilter = configEntry.Asset } + if configEntry.OnPost != "" { + b.OnPost = configEntry.OnPost + } } return b, true } @@ -200,6 +206,9 @@ func (o *SharedOptions) GetBinariesFromConfig() []*binary.Binary { if lb.Asset != "" { b.AssetFilter = lb.Asset } + if lb.OnPost != "" { + b.OnPost = lb.OnPost + } result = append(result, b) } else if b, ok := o.resolveBinary(lb); ok { result = append(result, b) diff --git a/pkg/cli/update.go b/pkg/cli/update.go index 8ef89aa..c5cff4e 100644 --- a/pkg/cli/update.go +++ b/pkg/cli/update.go @@ -895,10 +895,12 @@ func (o *UpdateOptions) updateBinaries(binaries []*binary.Binary) error { var err error attempted := false + downloaded := false switch { case o.Force: attempted = true err = b.DownloadBinary() + downloaded = err == nil case digestMatchesLock(b, lk, freshDigests[b.Name]) && b.BinaryExists(): // Manifest digest matches the locked one AND the binary // is actually on disk: upstream hasn't moved since the @@ -914,19 +916,52 @@ func (o *UpdateOptions) updateBinaries(binaries []*binary.Binary) error { // otherwise keep the old binary for mutable tags like 'cli'. attempted = true err = b.DownloadBinary() + downloaded = err == nil default: // EnsureBinary's internal skip check may or may not // download; treat it as "attempted" only on error so a // failed preset update doesn't poison the lock either. + // Detect whether a download happened via two signals: + // - binary was missing → any successful EnsureBinary + // must have downloaded it + // - binary existed → compare SHA before/after + // Only compute hashes when a hook might run — avoids + // O(file-size) work for binaries without hooks. + wasMissing := !b.BinaryExists() + var beforeHash string + needHookCheck := b.OnPost != "" && !o.effectiveDryRun() + if !wasMissing && needHookCheck { + beforeHash, _ = lock.SHA256File(b.BinaryPath()) + } err = b.EnsureBinary(true) if err != nil { attempted = true + } else if wasMissing { + downloaded = true + } else if needHookCheck { + // If we can't hash, assume the file changed — better + // to run the hook unnecessarily than silently skip it + // due to an unrelated I/O error. + afterHash, shaErr := lock.SHA256File(b.BinaryPath()) + if shaErr != nil || beforeHash == "" { + downloaded = true + } else { + downloaded = beforeHash != afterHash + } } } outcomeMu.Lock() downloadFailed[b.Name] = attempted && err != nil outcomeMu.Unlock() + // Run onPost hook only when a download actually changed the + // binary, and not in dry-run mode. + if downloaded && b.OnPost != "" && !o.effectiveDryRun() { + if hookErr := binary.RunHook(b.OnPost, o.ProjectRoot(), "update", b.Name, b.Version, b.BinaryPath(), o.IO.ErrOut, o.IO.ErrOut); hookErr != nil { + fmt.Fprintf(o.IO.ErrOut, "Warning: onPost hook for %s failed: %v\n", b.Name, hookErr) + } + } + doneLabel := name + " updated" if b.Alias != "" { doneLabel = b.Alias + " (" + color.New(color.FgYellow).Sprint(b.Name) + ") updated" diff --git a/pkg/state/types.go b/pkg/state/types.go index c944799..71f8790 100644 --- a/pkg/state/types.go +++ b/pkg/state/types.go @@ -372,6 +372,11 @@ func (list *BinaryList) MarshalYAML() (interface{}, error) { config["asset"] = b.Asset } + // Add post-install/update hook + if b.OnPost != "" { + config["onPost"] = b.OnPost + } + // If we have any configuration, use it; otherwise use empty struct if len(config) > 0 { result[b.Name] = config diff --git a/pkg/state/types_test.go b/pkg/state/types_test.go index 869acf3..5d6c118 100644 --- a/pkg/state/types_test.go +++ b/pkg/state/types_test.go @@ -563,6 +563,32 @@ func TestBinaryListMarshalYAML_WithAsset(t *testing.T) { } } +func TestBinaryListMarshalYAML_OnPostRoundTrip(t *testing.T) { + list := BinaryList{ + {Name: "github.com/arg-sh/argsh", OnPost: "argsh builtin ${B_EVENT}"}, + } + data, err := yaml.Marshal(&list) + if err != nil { + t.Fatalf("marshal: %v", err) + } + s := string(data) + if !strings.Contains(s, "onPost:") { + t.Errorf("marshal output missing onPost field:\n%s", s) + } + + // Unmarshal and verify round-trip. + var list2 BinaryList + if err := yaml.Unmarshal(data, &list2); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(list2) != 1 { + t.Fatalf("got %d binaries, want 1", len(list2)) + } + if list2[0].OnPost != "argsh builtin ${B_EVENT}" { + t.Errorf("onPost = %q, want %q", list2[0].OnPost, "argsh builtin ${B_EVENT}") + } +} + func TestBinaryListUnmarshalYAML_NilBinary(t *testing.T) { input := ` terraform: diff --git a/pkg/state/yamlmerge.go b/pkg/state/yamlmerge.go index d16dfb9..aac6e6e 100644 --- a/pkg/state/yamlmerge.go +++ b/pkg/state/yamlmerge.go @@ -37,7 +37,7 @@ func managedKey(path []string, key string) bool { case "binaries": // Matches BinaryList.MarshalYAML. switch key { - case "version", "enforced", "alias", "file", "asset": + case "version", "enforced", "alias", "file", "asset", "onPost": return true } return false