Skip to content

Commit b45de68

Browse files
websterclaude
andcommitted
feat(extension): add acceptance tests and fix env override + error handling
- acceptance/extension_test.go: acceptance tests for extension dispatch, CIRCLE_TOKEN injection, unknown-command fallthrough, and exit code propagation - buildEnv: replace duplicate env keys in-place instead of appending, so the injected token wins over any pre-existing CIRCLE_TOKEN in the shell env - Run: use clierrors.New instead of fmt.Errorf for structured error output - rootUnknownCommand: accept rootName param so binary renames (e.g. cci) work - main.go: format CLIError from extension via the normal error formatter Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent a5f6740 commit b45de68

3 files changed

Lines changed: 243 additions & 12 deletions

File tree

acceptance/extension_test.go

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
// Copyright (c) 2026 Circle Internet Services, Inc.
2+
//
3+
// Permission is hereby granted, free of charge, to any person obtaining a copy
4+
// of this software and associated documentation files (the "Software"), to deal
5+
// in the Software without restriction, including without limitation the rights
6+
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7+
// copies of the Software, and to permit persons to whom the Software is
8+
// furnished to do so, subject to the following conditions:
9+
//
10+
// The above copyright notice and this permission notice shall be included in
11+
// all copies or substantial portions of the Software.
12+
//
13+
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14+
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15+
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16+
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17+
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18+
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19+
// SOFTWARE.
20+
//
21+
// SPDX-License-Identifier: MIT
22+
23+
package acceptance_test
24+
25+
import (
26+
"os"
27+
"path/filepath"
28+
"runtime"
29+
"strings"
30+
"testing"
31+
32+
"gotest.tools/v3/assert"
33+
"gotest.tools/v3/assert/cmp"
34+
"gotest.tools/v3/skip"
35+
36+
"github.com/CircleCI-Public/circleci-cli-v2/internal/testing/binary"
37+
testenv "github.com/CircleCI-Public/circleci-cli-v2/internal/testing/env"
38+
)
39+
40+
// buildExtension writes a shell script as a fake circleci-<name> extension
41+
// 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.
43+
func buildExtension(t *testing.T, extDir, name string) string {
44+
t.Helper()
45+
skip.If(t, runtime.GOOS == "windows", "shell-script extensions are not supported on Windows")
46+
47+
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"
49+
err := os.WriteFile(path, []byte(script), 0o755)
50+
assert.NilError(t, err)
51+
return path
52+
}
53+
54+
// withExtDir prepends extDir to PATH in the given environ slice.
55+
func withExtDir(environ []string, extDir string) []string {
56+
out := make([]string, 0, len(environ)+1)
57+
out = append(out, environ...)
58+
for i, v := range out {
59+
if strings.HasPrefix(v, "PATH=") {
60+
out[i] = "PATH=" + extDir + string(os.PathListSeparator) + v[len("PATH="):]
61+
return out
62+
}
63+
}
64+
return append(out, "PATH="+extDir)
65+
}
66+
67+
// TestExtensionDispatch verifies that an unknown command is transparently
68+
// dispatched to circleci-<name> when the binary exists in PATH.
69+
func TestExtensionDispatch(t *testing.T) {
70+
extDir := t.TempDir()
71+
buildExtension(t, extDir, "hello")
72+
73+
env := testenv.New(t)
74+
env.Token = testToken
75+
76+
result := binary.RunCLI(t, binary.RunOpts{
77+
Binary: binaryPath,
78+
Args: []string{"hello", "arg1", "arg2"},
79+
Env: withExtDir(env.Environ(), extDir),
80+
WorkDir: t.TempDir(),
81+
})
82+
83+
assert.Equal(t, result.ExitCode, 0, "stderr: %s", result.Stderr)
84+
assert.Check(t, cmp.Contains(result.Stdout, "args:arg1 arg2"),
85+
"extension did not receive correct args; stdout: %q", result.Stdout)
86+
}
87+
88+
// TestExtensionEnvInjection verifies that CIRCLE_TOKEN and CIRCLE_URL are
89+
// injected into the extension's environment from the CLI's resolved config.
90+
func TestExtensionEnvInjection(t *testing.T) {
91+
extDir := t.TempDir()
92+
buildExtension(t, extDir, "hello")
93+
94+
env := testenv.New(t)
95+
env.Token = testToken
96+
97+
result := binary.RunCLI(t, binary.RunOpts{
98+
Binary: binaryPath,
99+
Args: []string{"hello"},
100+
Env: withExtDir(env.Environ(), extDir),
101+
WorkDir: t.TempDir(),
102+
})
103+
104+
assert.Equal(t, result.ExitCode, 0, "stderr: %s", result.Stderr)
105+
assert.Check(t, cmp.Contains(result.Stdout, "token:"+testToken),
106+
"CIRCLE_TOKEN not injected; stdout: %q", result.Stdout)
107+
}
108+
109+
// TestExtensionExitCodePropagated verifies that the extension's exit code is
110+
// propagated back to the caller unchanged.
111+
func TestExtensionExitCodePropagated(t *testing.T) {
112+
skip.If(t, runtime.GOOS == "windows", "shell-script extensions are not supported on Windows")
113+
114+
extDir := t.TempDir()
115+
path := filepath.Join(extDir, "circleci-fail")
116+
err := os.WriteFile(path, []byte("#!/bin/sh\nexit 42\n"), 0o755)
117+
assert.NilError(t, err)
118+
119+
env := testenv.New(t)
120+
121+
result := binary.RunCLI(t, binary.RunOpts{
122+
Binary: binaryPath,
123+
Args: []string{"fail"},
124+
Env: withExtDir(env.Environ(), extDir),
125+
WorkDir: t.TempDir(),
126+
})
127+
128+
assert.Equal(t, result.ExitCode, 42, "expected exit code 42 from extension")
129+
}
130+
131+
// TestExtensionNotFoundShowsOriginalError verifies that when no matching
132+
// extension exists, the original "unknown command" error from Cobra is shown.
133+
func TestExtensionNotFoundShowsOriginalError(t *testing.T) {
134+
env := testenv.New(t)
135+
136+
result := binary.RunCLI(t, binary.RunOpts{
137+
Binary: binaryPath,
138+
Args: []string{"no-such-command-xyz"},
139+
Env: env.Environ(),
140+
WorkDir: t.TempDir(),
141+
})
142+
143+
assert.Check(t, result.ExitCode != 0, "expected non-zero exit for unknown command")
144+
assert.Check(t, cmp.Contains(result.Stderr, "unknown command"),
145+
"expected 'unknown command' in stderr; got: %q", result.Stderr)
146+
}
147+
148+
// TestExtensionNestedUnknownNotIntercepted verifies that unknown subcommands
149+
// within a known group (e.g. "circleci pipeline foo") are not dispatched to
150+
// any extension — only top-level unknown commands are intercepted.
151+
func TestExtensionNestedUnknownNotIntercepted(t *testing.T) {
152+
skip.If(t, runtime.GOOS == "windows", "shell-script extensions are not supported on Windows")
153+
154+
// Put a circleci-foo extension on PATH to confirm it is NOT invoked.
155+
extDir := t.TempDir()
156+
buildExtension(t, extDir, "foo")
157+
158+
env := testenv.New(t)
159+
env.Token = testToken
160+
161+
result := binary.RunCLI(t, binary.RunOpts{
162+
Binary: binaryPath,
163+
Args: []string{"pipeline", "foo"},
164+
Env: withExtDir(env.Environ(), extDir),
165+
WorkDir: t.TempDir(),
166+
})
167+
168+
// The key assertion: the extension script prints "args:" — if that appears,
169+
// the extension was wrongly invoked. Whether the group shows help or an error
170+
// is pre-existing behavior outside this feature's scope.
171+
assert.Check(t, !strings.Contains(result.Stdout, "args:"),
172+
"extension should NOT have been invoked for a nested unknown command; stdout: %q", result.Stdout)
173+
}

internal/extension/extension.go

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import (
3737
"strings"
3838

3939
"github.com/CircleCI-Public/circleci-cli-v2/internal/config"
40+
clierrors "github.com/CircleCI-Public/circleci-cli-v2/internal/errors"
4041
"github.com/CircleCI-Public/circleci-cli-v2/internal/gitremote"
4142
)
4243

@@ -77,7 +78,11 @@ func Run(ctx context.Context, name string, args []string, configPath string) err
7778
// Exit with the extension's exit code, not our own error message.
7879
os.Exit(cmd.ProcessState.ExitCode())
7980
}
80-
return fmt.Errorf("extension %q failed: %w", binary, err)
81+
return clierrors.New(
82+
"extension.exec_failed",
83+
"Extension failed",
84+
fmt.Sprintf("extension %q could not be executed: %s", binary, err),
85+
)
8186
}
8287
return nil
8388
}
@@ -110,8 +115,20 @@ func buildEnv(ctx context.Context, configPath string) []string {
110115
}
111116

112117
for k, v := range overlays {
113-
if v != "" {
114-
env = append(env, k+"="+v)
118+
if v == "" {
119+
continue
120+
}
121+
prefix := k + "="
122+
replaced := false
123+
for i, e := range env {
124+
if strings.HasPrefix(e, prefix) {
125+
env[i] = prefix + v
126+
replaced = true
127+
break
128+
}
129+
}
130+
if !replaced {
131+
env = append(env, prefix+v)
115132
}
116133
}
117134
return env

main.go

Lines changed: 50 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -56,15 +56,24 @@ func run() int {
5656
// Before reporting "unknown command", try the implicit extension mechanism:
5757
// any executable named "circleci-<name>" in PATH is a valid extension.
5858
// Only intercept top-level unknown commands (error ends with `for "circleci"`).
59-
if name, ok := rootUnknownCommand(err); ok {
59+
if name, ok := rootUnknownCommand(err, rootCmd.Name()); ok {
6060
configPath, _ := rootCmd.Flags().GetString("config")
6161
extArgs := extensionArgs(name)
6262
extErr := extension.Run(ctx, name, extArgs, configPath)
6363
if extErr == nil {
6464
return clierrors.ExitSuccess
6565
}
6666
// Extension not found — fall through and show the original cobra error.
67-
if !errors.As(extErr, new(*extension.ErrNotFound)) {
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())
74+
}
75+
return cliErr.ExitCode
76+
}
6877
_, _ = fmt.Fprintln(os.Stderr, extErr)
6978
return clierrors.ExitGeneralError
7079
}
@@ -87,15 +96,19 @@ func run() int {
8796
// rootUnknownCommand reports whether err is Cobra's "unknown command" error
8897
// for the root command specifically (not a nested group). Returns the command
8998
// name that was not found.
90-
func rootUnknownCommand(err error) (string, bool) {
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) {
91103
msg := err.Error()
92104
// Cobra formats this as: unknown command "foo" for "circleci"
93105
const prefix = `unknown command "`
94106
if !strings.HasPrefix(msg, prefix) {
95107
return "", false
96108
}
97109
// Must be for the root, not a subgroup like "circleci pipeline".
98-
if !strings.HasSuffix(msg, `for "circleci"`) {
110+
suffix := fmt.Sprintf(` for %q`, rootName)
111+
if !strings.HasSuffix(msg, suffix) {
99112
return "", false
100113
}
101114
name, _, ok := strings.Cut(msg[len(prefix):], `"`)
@@ -105,14 +118,42 @@ func rootUnknownCommand(err error) (string, bool) {
105118
return name, true
106119
}
107120

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+
108132
// extensionArgs returns the arguments to pass to the extension binary.
109-
// It scans os.Args for the first non-flag positional argument matching name
110-
// and returns everything after it.
133+
// It scans os.Args, skipping global flags and their values, and returns
134+
// everything after the first positional argument matching name.
111135
func extensionArgs(name string) []string {
112-
for i, arg := range os.Args[1:] {
113-
if !strings.HasPrefix(arg, "-") && arg == name {
114-
return os.Args[i+2:]
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:]
115155
}
156+
i++
116157
}
117158
return nil
118159
}

0 commit comments

Comments
 (0)