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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,10 @@ variables:
- `ee hydrate <environment>` - Generate an env file from the shell environment + schema defaults
- `ee push [origin] <environment>` - 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 <agent>` - Install the ee usage guide for your AI coding agent (see below)
- `ee` - Inspect/filter the current shell's environment variables

Expand Down
5 changes: 5 additions & 0 deletions internal/command/assets/ee-usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <agent>` — install this guide for a coding agent

Writes this usage guide into the convention expected by the selected coding
Expand Down
13 changes: 10 additions & 3 deletions internal/origin/cloudflare.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package origin
import (
"encoding/json"
"fmt"
"os/exec"
"strings"
)

Expand Down Expand Up @@ -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()
Expand All @@ -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()
Expand Down
98 changes: 88 additions & 10 deletions internal/origin/toolcheck.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,28 +3,106 @@ package origin
import (
"fmt"
"os/exec"
"strings"
)

var toolInstallHints = map[string]string{
"gh": "https://cli.github.com",
"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()
}
146 changes: 146 additions & 0 deletions internal/origin/toolcheck_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading