From 44e5af5da060745aec9929cb8512639fd8855f6b Mon Sep 17 00:00:00 2001 From: nima Date: Fri, 3 Jul 2026 23:52:34 +0200 Subject: [PATCH] feat(origin): run wrangler via bunx/npx when not on PATH wrangler is often installed as a project dependency rather than globally, so 'ee push'/'ee auth' failed with 'not found on PATH' even though the project could run it. Add a tool resolver that prefers a binary on PATH and falls back to a JavaScript package runner (bunx, then npx -y) for npm-based tools like wrangler, resolving the project-local install or fetching on the fly. - ResolveTool/ToolCommand centralize resolution; CheckTool and RunCommand now go through it, and cloudflare push uses ToolCommand - Improved not-found message points at both the install docs and the bunx/npx fallback - Unit tests cover PATH-first, bunx/npx fallback and ordering, and the no-runner error (lookPath is injectable for deterministic tests) - Document the fallback in the README and the ee-usage skill Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 4 + internal/command/assets/ee-usage.md | 5 + internal/origin/cloudflare.go | 13 ++- internal/origin/toolcheck.go | 98 +++++++++++++++++-- internal/origin/toolcheck_test.go | 146 ++++++++++++++++++++++++++++ 5 files changed, 253 insertions(+), 13 deletions(-) create mode 100644 internal/origin/toolcheck_test.go diff --git a/README.md b/README.md index 6280edb..59f0db2 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,10 @@ variables: - `ee hydrate ` - Generate an env file from the shell environment + schema defaults - `ee push [origin] ` - Push secrets to a remote origin (GitHub, Cloudflare) - `ee auth [tool]` - Check authentication status for origin CLI tools (`gh`, `wrangler`) + +> Cloudflare pushes use `wrangler`. If it isn't on your `PATH`, `ee` automatically +> runs it via `bunx wrangler` or `npx wrangler`, so a project-local install (or no +> install at all, with `bun`/`npx` fetching it on demand) works without a global setup. - `ee skill ` - Install the ee usage guide for your AI coding agent (see below) - `ee` - Inspect/filter the current shell's environment variables diff --git a/internal/command/assets/ee-usage.md b/internal/command/assets/ee-usage.md index 7cac416..7d65dd5 100644 --- a/internal/command/assets/ee-usage.md +++ b/internal/command/assets/ee-usage.md @@ -309,6 +309,11 @@ name can be omitted. Checks `gh` (GitHub) and `wrangler` (Cloudflare). Run before `ee push`. +If `wrangler` is not on your `PATH` (common when it is a project dependency +rather than a global install), `ee` automatically falls back to running it via a +JavaScript package runner — `bunx wrangler` or `npx wrangler` — so no global +install is required as long as `bun` or `node`/`npm` is available. + ### `ee skill ` — install this guide for a coding agent Writes this usage guide into the convention expected by the selected coding diff --git a/internal/origin/cloudflare.go b/internal/origin/cloudflare.go index e0760ee..d8604ff 100644 --- a/internal/origin/cloudflare.go +++ b/internal/origin/cloudflare.go @@ -3,7 +3,6 @@ package origin import ( "encoding/json" "fmt" - "os/exec" "strings" ) @@ -75,7 +74,10 @@ func (c *Cloudflare) pushBulk(values map[string]string, dryRun bool) (*PushResul } args := []string{"secret", "bulk", "--name", c.cfg.Worker} - cmd := exec.Command("wrangler", args...) + cmd, err := ToolCommand("wrangler", args...) + if err != nil { + return nil, err + } cmd.Stdin = strings.NewReader(string(jsonData)) out, err := cmd.CombinedOutput() @@ -100,7 +102,12 @@ func (c *Cloudflare) pushIndividual(values map[string]string, dryRun bool) (*Pus } args := []string{"secret", "put", key, "--name", c.cfg.Worker} - cmd := exec.Command("wrangler", args...) + cmd, err := ToolCommand("wrangler", args...) + if err != nil { + result.Errors = append(result.Errors, + fmt.Errorf("failed to resolve wrangler for secret %s: %w", key, err)) + continue + } cmd.Stdin = strings.NewReader(values[key]) out, err := cmd.CombinedOutput() diff --git a/internal/origin/toolcheck.go b/internal/origin/toolcheck.go index 76331a4..1460657 100644 --- a/internal/origin/toolcheck.go +++ b/internal/origin/toolcheck.go @@ -3,6 +3,7 @@ package origin import ( "fmt" "os/exec" + "strings" ) var toolInstallHints = map[string]string{ @@ -10,21 +11,98 @@ var toolInstallHints = map[string]string{ "wrangler": "https://developers.cloudflare.com/workers/wrangler/install-and-update/", } -// CheckTool verifies that a CLI tool is available on PATH. -func CheckTool(name string) error { - _, err := exec.LookPath(name) - if err != nil { - hint := toolInstallHints[name] +// npmRunnableTools maps a logical tool name to the npm package/binary that can +// be executed through a JavaScript package runner (bunx/npx). These tools are +// commonly installed as a project dependency rather than globally on PATH, so +// when they are missing from PATH we fall back to running them via a runner, +// which resolves the project-local install (node_modules/.bin) or fetches the +// package on the fly. +var npmRunnableTools = map[string]string{ + "wrangler": "wrangler", +} + +// jsRunners lists the supported JavaScript package runners in preference order. +// bunx is tried first (it is faster and also resolves node_modules), then npx. +var jsRunners = []string{"bunx", "npx"} + +// lookPath is a package-level indirection over exec.LookPath so tests can +// simulate which executables are available. +var lookPath = exec.LookPath + +// runnerArgv builds the argument vector for invoking pkg through the given +// runner. npx is passed -y so it never prompts before installing on the fly. +func runnerArgv(runner, pkg string) []string { + if runner == "npx" { + return []string{"npx", "-y", pkg} + } + return []string{runner, pkg} +} + +// ResolveTool returns the argument vector used to invoke a logical tool name. +// It prefers a binary found directly on PATH; for npm-based tools it falls back +// to a JavaScript runner (bunx/npx) when the binary is not on PATH. +func ResolveTool(name string) ([]string, error) { + if _, err := lookPath(name); err == nil { + return []string{name}, nil + } + + if pkg, ok := npmRunnableTools[name]; ok { + for _, runner := range jsRunners { + if _, err := lookPath(runner); err == nil { + return runnerArgv(runner, pkg), nil + } + } + } + + return nil, notFoundError(name) +} + +// notFoundError builds a helpful "not found" error, mentioning the runner +// fallback for npm-based tools. +func notFoundError(name string) error { + hint := toolInstallHints[name] + if _, runnable := npmRunnableTools[name]; runnable { + msg := fmt.Sprintf( + "'%s' not found on PATH and no JavaScript runner (%s) is available to run it", + name, strings.Join(jsRunners, " or "), + ) if hint != "" { - return fmt.Errorf("'%s' CLI not found on PATH. Install it from %s", name, hint) + msg += fmt.Sprintf(". Install it from %s, or install bun/node so it can run via %s", + hint, strings.Join(jsRunners, "/")) } - return fmt.Errorf("'%s' CLI not found on PATH", name) + return fmt.Errorf("%s", msg) + } + + if hint != "" { + return fmt.Errorf("'%s' CLI not found on PATH. Install it from %s", name, hint) } - return nil + return fmt.Errorf("'%s' CLI not found on PATH", name) +} + +// CheckTool verifies that a CLI tool can be resolved, either directly on PATH +// or via a JavaScript runner fallback for npm-based tools. +func CheckTool(name string) error { + _, err := ResolveTool(name) + return err } -// RunCommand executes a command and returns its combined output. +// ToolCommand builds an *exec.Cmd for a logical tool name, resolving PATH and +// runner fallbacks. Callers may further configure the returned command (for +// example, setting Stdin) before running it. +func ToolCommand(name string, args ...string) (*exec.Cmd, error) { + argv, err := ResolveTool(name) + if err != nil { + return nil, err + } + full := append(argv, args...) + return exec.Command(full[0], full[1:]...), nil +} + +// RunCommand executes a resolved tool command and returns its combined output. func RunCommand(name string, args ...string) ([]byte, error) { - cmd := exec.Command(name, args...) + cmd, err := ToolCommand(name, args...) + if err != nil { + return nil, err + } return cmd.CombinedOutput() } diff --git a/internal/origin/toolcheck_test.go b/internal/origin/toolcheck_test.go new file mode 100644 index 0000000..34348f5 --- /dev/null +++ b/internal/origin/toolcheck_test.go @@ -0,0 +1,146 @@ +package origin + +import ( + "errors" + "os/exec" + "reflect" + "strings" + "testing" +) + +// withLookPath temporarily replaces the package lookPath with one that reports +// the given set of executables as present, restoring the original afterwards. +func withLookPath(t *testing.T, present ...string) { + t.Helper() + set := make(map[string]bool, len(present)) + for _, p := range present { + set[p] = true + } + orig := lookPath + lookPath = func(name string) (string, error) { + if set[name] { + return "/usr/bin/" + name, nil + } + return "", &exec.Error{Name: name, Err: exec.ErrNotFound} + } + t.Cleanup(func() { lookPath = orig }) +} + +func TestResolveTool(t *testing.T) { + tests := []struct { + name string + tool string + present []string + want []string + wantErr bool + }{ + { + name: "wrangler on PATH is used directly", + tool: "wrangler", + present: []string{"wrangler", "bunx", "npx"}, + want: []string{"wrangler"}, + }, + { + name: "falls back to bunx when wrangler missing", + tool: "wrangler", + present: []string{"bunx", "npx"}, + want: []string{"bunx", "wrangler"}, + }, + { + name: "falls back to npx when only npx available", + tool: "wrangler", + present: []string{"npx"}, + want: []string{"npx", "-y", "wrangler"}, + }, + { + name: "prefers bunx over npx", + tool: "wrangler", + present: []string{"npx", "bunx"}, + want: []string{"bunx", "wrangler"}, + }, + { + name: "errors when wrangler and runners all missing", + tool: "wrangler", + present: []string{}, + wantErr: true, + }, + { + name: "gh on PATH is used directly", + tool: "gh", + present: []string{"gh"}, + want: []string{"gh"}, + }, + { + name: "gh has no runner fallback", + tool: "gh", + present: []string{"bunx", "npx"}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + withLookPath(t, tt.present...) + got, err := ResolveTool(tt.tool) + if tt.wantErr { + if err == nil { + t.Fatalf("expected error, got argv %v", got) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("ResolveTool(%q) = %v, want %v", tt.tool, got, tt.want) + } + }) + } +} + +func TestCheckToolWithRunnerFallback(t *testing.T) { + withLookPath(t, "bunx") + if err := CheckTool("wrangler"); err != nil { + t.Errorf("expected wrangler to resolve via bunx, got error: %v", err) + } +} + +func TestCheckToolMissingMentionsRunners(t *testing.T) { + withLookPath(t) // nothing available + err := CheckTool("wrangler") + if err == nil { + t.Fatal("expected error when wrangler and runners are unavailable") + } + msg := err.Error() + for _, want := range []string{"bunx", "npx"} { + if !strings.Contains(msg, want) { + t.Errorf("error message %q should mention %q", msg, want) + } + } +} + +func TestToolCommandBuildsArgv(t *testing.T) { + withLookPath(t, "npx") + cmd, err := ToolCommand("wrangler", "whoami") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // cmd.Args includes the resolved runner prefix plus the tool args. + want := []string{"npx", "-y", "wrangler", "whoami"} + if !reflect.DeepEqual(cmd.Args, want) { + t.Errorf("cmd.Args = %v, want %v", cmd.Args, want) + } +} + +func TestRunCommandErrorsWhenUnresolvable(t *testing.T) { + withLookPath(t) // nothing available + _, err := RunCommand("wrangler", "whoami") + if err == nil { + t.Fatal("expected error when wrangler cannot be resolved") + } + // Should be the resolver error, not an exec start error. + var execErr *exec.Error + if errors.As(err, &execErr) { + t.Errorf("expected resolver error, got exec error: %v", err) + } +}