Skip to content
60 changes: 60 additions & 0 deletions pkg/binary/hook.go
Original file line number Diff line number Diff line change
@@ -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
Comment thread
fentas marked this conversation as resolved.
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),
)
Comment thread
fentas marked this conversation as resolved.
cmd.Env = env
return cmd.Run()
}
80 changes: 80 additions & 0 deletions pkg/binary/hook_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
7 changes: 7 additions & 0 deletions pkg/binary/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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:"-"`
}
30 changes: 30 additions & 0 deletions pkg/cli/cli_extra_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
20 changes: 20 additions & 0 deletions pkg/cli/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -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 != "" {
Expand Down Expand Up @@ -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)
}
}
Expand Down
9 changes: 9 additions & 0 deletions pkg/cli/shared.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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)
Expand Down
35 changes: 35 additions & 0 deletions pkg/cli/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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())
}
Comment thread
fentas marked this conversation as resolved.
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
Comment thread
fentas marked this conversation as resolved.
}
}
}
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"
Expand Down
5 changes: 5 additions & 0 deletions pkg/state/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading