Skip to content

Commit 0bac55c

Browse files
websterclaude
andcommitted
fix(extension): address review findings 2-6
- Replace brittle Cobra error-string matching and hand-maintained globalFlagsWithValues allowlist with rootCmd.Find() + Flags().Args(), so extension dispatch is robust to new flags and Cobra version changes - Return ErrExited from Run instead of calling os.Exit directly, so deferred cleanup runs and Run is testable - Rename injected env vars CIRCLE_TOKEN/CIRCLE_URL → CIRCLECI_TOKEN/CIRCLECI_HOST (and VCS/project vars) to match the project's documented CIRCLECI_ prefix - Extract skipUnlessShell helper in acceptance tests to consolidate the Windows skip guard in one place Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent b45de68 commit 0bac55c

3 files changed

Lines changed: 68 additions & 107 deletions

File tree

acceptance/extension_test.go

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -37,15 +37,22 @@ import (
3737
testenv "github.com/CircleCI-Public/circleci-cli-v2/internal/testing/env"
3838
)
3939

40+
// skipUnlessShell skips the test on platforms where shell scripts cannot be
41+
// executed as extension binaries (i.e. Windows).
42+
func skipUnlessShell(t *testing.T) {
43+
t.Helper()
44+
skip.If(t, runtime.GOOS == "windows", "shell-script extensions are not supported on Windows")
45+
}
46+
4047
// buildExtension writes a shell script as a fake circleci-<name> extension
4148
// into extDir and returns the full path. The script prints its positional args
42-
// and the CIRCLE_TOKEN / CIRCLE_URL env vars, then exits 0.
49+
// and the CIRCLECI_TOKEN / CIRCLECI_HOST env vars, then exits 0.
4350
func buildExtension(t *testing.T, extDir, name string) string {
4451
t.Helper()
45-
skip.If(t, runtime.GOOS == "windows", "shell-script extensions are not supported on Windows")
52+
skipUnlessShell(t)
4653

4754
path := filepath.Join(extDir, "circleci-"+name)
48-
script := "#!/bin/sh\nprintf 'args:%s\\n' \"$*\"\nprintf 'token:%s\\n' \"$CIRCLE_TOKEN\"\nprintf 'url:%s\\n' \"$CIRCLE_URL\"\n"
55+
script := "#!/bin/sh\nprintf 'args:%s\\n' \"$*\"\nprintf 'token:%s\\n' \"$CIRCLECI_TOKEN\"\nprintf 'url:%s\\n' \"$CIRCLECI_HOST\"\n"
4956
err := os.WriteFile(path, []byte(script), 0o755)
5057
assert.NilError(t, err)
5158
return path
@@ -85,7 +92,7 @@ func TestExtensionDispatch(t *testing.T) {
8592
"extension did not receive correct args; stdout: %q", result.Stdout)
8693
}
8794

88-
// TestExtensionEnvInjection verifies that CIRCLE_TOKEN and CIRCLE_URL are
95+
// TestExtensionEnvInjection verifies that CIRCLECI_TOKEN and CIRCLECI_HOST are
8996
// injected into the extension's environment from the CLI's resolved config.
9097
func TestExtensionEnvInjection(t *testing.T) {
9198
extDir := t.TempDir()
@@ -103,13 +110,15 @@ func TestExtensionEnvInjection(t *testing.T) {
103110

104111
assert.Equal(t, result.ExitCode, 0, "stderr: %s", result.Stderr)
105112
assert.Check(t, cmp.Contains(result.Stdout, "token:"+testToken),
106-
"CIRCLE_TOKEN not injected; stdout: %q", result.Stdout)
113+
"CIRCLECI_TOKEN not injected; stdout: %q", result.Stdout)
114+
assert.Check(t, cmp.Contains(result.Stdout, "url:https://circleci.com"),
115+
"CIRCLECI_HOST not injected; stdout: %q", result.Stdout)
107116
}
108117

109118
// TestExtensionExitCodePropagated verifies that the extension's exit code is
110119
// propagated back to the caller unchanged.
111120
func TestExtensionExitCodePropagated(t *testing.T) {
112-
skip.If(t, runtime.GOOS == "windows", "shell-script extensions are not supported on Windows")
121+
skipUnlessShell(t)
113122

114123
extDir := t.TempDir()
115124
path := filepath.Join(extDir, "circleci-fail")
@@ -149,7 +158,7 @@ func TestExtensionNotFoundShowsOriginalError(t *testing.T) {
149158
// within a known group (e.g. "circleci pipeline foo") are not dispatched to
150159
// any extension — only top-level unknown commands are intercepted.
151160
func TestExtensionNestedUnknownNotIntercepted(t *testing.T) {
152-
skip.If(t, runtime.GOOS == "windows", "shell-script extensions are not supported on Windows")
161+
skipUnlessShell(t)
153162

154163
// Put a circleci-foo extension on PATH to confirm it is NOT invoked.
155164
extDir := t.TempDir()

internal/extension/extension.go

Lines changed: 23 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
//
2525
// Any executable named "circleci-<name>" found in PATH is treated as an
2626
// extension and can be invoked transparently as "circleci <name>". The
27-
// extension receives CIRCLE_TOKEN, CIRCLE_URL, and best-effort project
27+
// extension receives CIRCLECI_TOKEN, CIRCLECI_HOST, and best-effort project
2828
// metadata via environment variables so it can call the CircleCI API without
2929
// reimplementing authentication.
3030
package extension
@@ -50,6 +50,17 @@ func (e *ErrNotFound) Error() string {
5050
return fmt.Sprintf("unknown command %q — and no extension %q found in PATH", e.Name, "circleci-"+e.Name)
5151
}
5252

53+
// ErrExited is returned by Run when the extension process exits with a
54+
// non-zero status code. The caller should exit with Code rather than printing
55+
// an error message — the extension is responsible for its own error output.
56+
type ErrExited struct {
57+
Code int
58+
}
59+
60+
func (e *ErrExited) Error() string {
61+
return fmt.Sprintf("extension exited with code %d", e.Code)
62+
}
63+
5364
// Run looks up circleci-<name> in PATH and execs it with args, injecting
5465
// CircleCI environment variables. configPath is the --config flag value
5566
// (empty means use the default XDG path). The current process is replaced
@@ -58,14 +69,14 @@ func (e *ErrNotFound) Error() string {
5869
//
5970
// If no matching binary is found, ErrNotFound is returned and the caller
6071
// should show the original "unknown command" error instead.
61-
func Run(ctx context.Context, name string, args []string, configPath string) error {
72+
func Run(ctx context.Context, name string, args []string, configPath string, secureStorage bool) error {
6273
binary := "circleci-" + name
6374
path, err := exec.LookPath(binary)
6475
if err != nil {
6576
return &ErrNotFound{Name: name}
6677
}
6778

68-
env := buildEnv(ctx, configPath)
79+
env := buildEnv(ctx, configPath, secureStorage)
6980

7081
cmd := exec.CommandContext(ctx, path, args...) //#nosec:G204 // path comes from LookPath, args are user-supplied CLI args for the extension
7182
cmd.Stdin = os.Stdin
@@ -75,8 +86,7 @@ func Run(ctx context.Context, name string, args []string, configPath string) err
7586

7687
if err := cmd.Run(); err != nil {
7788
if cmd.ProcessState != nil {
78-
// Exit with the extension's exit code, not our own error message.
79-
os.Exit(cmd.ProcessState.ExitCode())
89+
return &ErrExited{Code: cmd.ProcessState.ExitCode()}
8090
}
8191
return clierrors.New(
8292
"extension.exec_failed",
@@ -88,29 +98,29 @@ func Run(ctx context.Context, name string, args []string, configPath string) err
8898
}
8999

90100
// buildEnv constructs the environment for the extension process. It starts
91-
// from the current process environment and overlays CIRCLE_* variables so
101+
// from the current process environment and overlays CIRCLECI_* variables so
92102
// extensions can call the CircleCI API without reimplementing auth.
93-
func buildEnv(ctx context.Context, configPath string) []string {
103+
func buildEnv(ctx context.Context, configPath string, secureStorage bool) []string {
94104
env := os.Environ()
95105

96-
cfg, err := config.LoadFrom(ctx, configPath, false)
106+
cfg, err := config.LoadFrom(ctx, configPath, secureStorage)
97107
if err != nil {
98108
cfg = &config.Config{}
99109
}
100110

101111
overlays := map[string]string{
102-
"CIRCLE_TOKEN": cfg.EffectiveToken(),
103-
"CIRCLE_URL": cfg.EffectiveHost(),
112+
"CIRCLECI_TOKEN": cfg.EffectiveToken(),
113+
"CIRCLECI_HOST": cfg.EffectiveHost(),
104114
}
105115

106116
// Best-effort: inject project metadata from git remote. Failures are
107117
// silently ignored — the extension is responsible for handling missing vars.
108118
if info, err := gitremote.Detect(); err == nil {
109119
parts := strings.SplitN(info.Slug, "/", 3)
110120
if len(parts) == 3 {
111-
overlays["CIRCLE_VCS_TYPE"] = vcsLong(parts[0])
112-
overlays["CIRCLE_PROJECT_USERNAME"] = parts[1]
113-
overlays["CIRCLE_PROJECT_REPONAME"] = parts[2]
121+
overlays["CIRCLECI_VCS_TYPE"] = vcsLong(parts[0])
122+
overlays["CIRCLECI_PROJECT_USERNAME"] = parts[1]
123+
overlays["CIRCLECI_PROJECT_REPONAME"] = parts[2]
114124
}
115125
}
116126

main.go

Lines changed: 29 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@ import (
2828
"fmt"
2929
"os"
3030
"os/signal"
31-
"strings"
3231
"syscall"
3332

3433
"github.com/CircleCI-Public/circleci-cli-v2/internal/cmd/root"
@@ -53,29 +52,37 @@ func run() int {
5352
rootCmd := root.NewRootCmd(version)
5453
rootCmd.SetContext(ctx)
5554
if err := rootCmd.Execute(); err != nil {
56-
// Before reporting "unknown command", try the implicit extension mechanism:
57-
// any executable named "circleci-<name>" in PATH is a valid extension.
58-
// Only intercept top-level unknown commands (error ends with `for "circleci"`).
59-
if name, ok := rootUnknownCommand(err, rootCmd.Name()); ok {
60-
configPath, _ := rootCmd.Flags().GetString("config")
61-
extArgs := extensionArgs(name)
62-
extErr := extension.Run(ctx, name, extArgs, configPath)
63-
if extErr == nil {
64-
return clierrors.ExitSuccess
65-
}
66-
// Extension not found — fall through and show the original cobra error.
67-
var notFound *extension.ErrNotFound
68-
if !errors.As(extErr, &notFound) {
69-
if cliErr, ok := errors.AsType[*clierrors.CLIError](extErr); ok {
70-
if jsonFlagPresent() {
71-
_, _ = fmt.Fprint(os.Stderr, cliErr.FormatJSON())
72-
} else {
73-
_, _ = fmt.Fprint(os.Stderr, cliErr.Format())
55+
// Try the implicit extension mechanism before reporting "unknown command".
56+
// rootCmd.Flags().Args() gives the positional args cobra parsed; Find tells
57+
// us whether the first one matches a registered subcommand or not.
58+
if posArgs := rootCmd.Flags().Args(); len(posArgs) > 0 {
59+
name := posArgs[0]
60+
if found, _, _ := rootCmd.Find([]string{name}); found == rootCmd {
61+
configPath, _ := rootCmd.Flags().GetString("config")
62+
insecureStorage, _ := rootCmd.Flags().GetBool("insecure-storage")
63+
extErr := extension.Run(ctx, name, posArgs[1:], configPath, !insecureStorage)
64+
if extErr == nil {
65+
return clierrors.ExitSuccess
66+
}
67+
if exited, ok := errors.AsType[*extension.ErrExited](extErr); ok {
68+
if exited.Code > 0 {
69+
return exited.Code
7470
}
75-
return cliErr.ExitCode
71+
return clierrors.ExitGeneralError
72+
}
73+
// Extension not found — fall through and show the original cobra error.
74+
if _, notFound := errors.AsType[*extension.ErrNotFound](extErr); !notFound {
75+
if cliErr, ok := errors.AsType[*clierrors.CLIError](extErr); ok {
76+
if jsonFlagPresent() {
77+
_, _ = fmt.Fprint(os.Stderr, cliErr.FormatJSON())
78+
} else {
79+
_, _ = fmt.Fprint(os.Stderr, cliErr.Format())
80+
}
81+
return cliErr.ExitCode
82+
}
83+
_, _ = fmt.Fprintln(os.Stderr, extErr)
84+
return clierrors.ExitGeneralError
7685
}
77-
_, _ = fmt.Fprintln(os.Stderr, extErr)
78-
return clierrors.ExitGeneralError
7986
}
8087
}
8188

@@ -93,71 +100,6 @@ func run() int {
93100
return clierrors.ExitSuccess
94101
}
95102

96-
// rootUnknownCommand reports whether err is Cobra's "unknown command" error
97-
// for the root command specifically (not a nested group). Returns the command
98-
// name that was not found.
99-
//
100-
// Note: this relies on Cobra's internal error string format. The suffix uses
101-
// rootName so binary renames (e.g. "cci") are handled correctly.
102-
func rootUnknownCommand(err error, rootName string) (string, bool) {
103-
msg := err.Error()
104-
// Cobra formats this as: unknown command "foo" for "circleci"
105-
const prefix = `unknown command "`
106-
if !strings.HasPrefix(msg, prefix) {
107-
return "", false
108-
}
109-
// Must be for the root, not a subgroup like "circleci pipeline".
110-
suffix := fmt.Sprintf(` for %q`, rootName)
111-
if !strings.HasSuffix(msg, suffix) {
112-
return "", false
113-
}
114-
name, _, ok := strings.Cut(msg[len(prefix):], `"`)
115-
if !ok {
116-
return "", false
117-
}
118-
return name, true
119-
}
120-
121-
// globalFlagsWithValues is the set of root-level persistent flags that consume
122-
// the following token as their value (i.e. not boolean flags). We skip over
123-
// these and their values when scanning os.Args for the extension command name,
124-
// so that a flag value that happens to equal the extension name is not mistaken
125-
// for the positional command argument.
126-
var globalFlagsWithValues = map[string]bool{
127-
"--config": true,
128-
"-c": true,
129-
"--theme": true,
130-
}
131-
132-
// extensionArgs returns the arguments to pass to the extension binary.
133-
// It scans os.Args, skipping global flags and their values, and returns
134-
// everything after the first positional argument matching name.
135-
func extensionArgs(name string) []string {
136-
args := os.Args[1:]
137-
i := 0
138-
for i < len(args) {
139-
arg := args[i]
140-
if strings.HasPrefix(arg, "-") {
141-
if strings.Contains(arg, "=") {
142-
// --flag=value form: single token, no separate value token
143-
i++
144-
continue
145-
}
146-
if globalFlagsWithValues[arg] {
147-
i += 2 // skip flag and its value token
148-
continue
149-
}
150-
i++ // boolean flag
151-
continue
152-
}
153-
if arg == name {
154-
return args[i+1:]
155-
}
156-
i++
157-
}
158-
return nil
159-
}
160-
161103
// jsonFlagPresent reports whether --json appears anywhere in the raw argument
162104
// list. This is intentionally a simple scan rather than full flag parsing —
163105
// we only need it to format errors before Cobra has had a chance to run.

0 commit comments

Comments
 (0)