Skip to content

Commit 67af56f

Browse files
fix(cli): CLI-MCP-8/9/11 — --env passthrough, deploy stub label, resource unauth exit 3 (#17)
Closes three CLI gaps surfaced by BugBash QA round 2 (see backlog `fix/cli-hygiene-env-passthrough`): CLI-MCP-8 — all seven provisioning verbs (db / cache / nosql / queue / storage / webhook / vector) now accept `--env <name>`. Empty == server default (development, CLAUDE.md rule 11); a non-empty value is forwarded verbatim in the request body. The human output surfaces the resolved env plus, when present, the server's env_override_reason so a downgrade (e.g. anon caller asking for production demoted to development) is visible. CLI-MCP-9 — `instant deploy` parent cobra.Short is now explicitly labeled as a stub and points at the canonical alternative (MCP `create_deploy` / POST /deploy/new) so the root command list row carries the pointer. CLI-MCP-11 — `instant resource <token>` and `instant resource delete <token>` short-circuit with errAuthRequired (exit 3) when the caller is unauthenticated, BEFORE any side effects. Matches the contract that `instant resources` (list) already honors. Tests updated to set up auth where they previously relied on the anonymous path-token bypass. README — adds a Multi-service stacks pointer (MCP `create_stack` / POST /stacks/new) plus `--env` usage examples. Tests: * cmd/cli_mcp_gaps_test.go — 3 new regression test groups covering env-flag forwarding (per verb, table-driven across all seven), deploy stub Short label, and unauth exit code on both resource verbs. * Existing TestExtras_Resource* tests updated to call authSetupForTest() so the auth gate fires before the unrelated assertion. * resetProvisionFlags() clears the new resourceEnv global between cases and now covers all seven provisioning groups. `make ci` green; coverage 95.2% (above 95% floor). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 4cd4678 commit 67af56f

10 files changed

Lines changed: 430 additions & 19 deletions

README.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,30 @@ instant login # Log in to your instanode.dev account
2525
instant whoami # Show current account
2626
```
2727

28+
### Targeting an environment
29+
30+
Every `new` verb accepts an optional `--env` flag that the API honors
31+
(default: `development`; CLAUDE.md rule 11):
32+
33+
```bash
34+
instant db new --name app-db --env production
35+
instant cache new --name app-cache --env staging
36+
```
37+
38+
The response prints both the resolved `env` and — when the server downgraded
39+
a request (e.g. anonymous caller asking for `production`) — an
40+
`env_override_reason` line explaining why.
41+
42+
## Multi-service stacks
43+
44+
`instant stack new` is a CLI follow-up — not shipped yet. For multi-service
45+
stacks today, use either the MCP `create_stack` tool (Claude Code, Cursor,
46+
any MCP client) or a direct `POST /stacks/new` call against the API. The
47+
request schema lives at `https://api.instanode.dev/openapi.json`.
48+
49+
Single-service deploys via the CLI are also still a follow-up — `instant
50+
deploy --help` prints the canonical MCP/curl paths.
51+
2852
## Build from source
2953

3054
```bash

cmd/bughunt_b15_test.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,12 +295,42 @@ func TestExtras_StorageWebhookVector(t *testing.T) {
295295
}
296296
}
297297

298+
// authSetupForTest wires a saved API key + base URL into the package-global
299+
// HTTPClient so haveAuth() returns true. CLI-MCP-11 made `instant resource
300+
// <token>` (and `resource delete`) auth-required up-front — every test that
301+
// exercises those commands now needs an authenticated session even when the
302+
// mock would otherwise serve an anonymous path-token request.
303+
//
304+
// Returns a cleanup function the caller MUST defer/t.Cleanup so per-test
305+
// auth state doesn't leak across tests.
306+
func authSetupForTest(t *testing.T, srv string) func() {
307+
t.Helper()
308+
cfg := &cliconfig.Config{
309+
APIKey: "inst_test_pat",
310+
Email: "test@example.com",
311+
APIBaseURL: srv,
312+
}
313+
if err := cfg.Save(); err != nil {
314+
t.Fatalf("authSetupForTest: save cfg: %v", err)
315+
}
316+
// Re-init the package HTTP client so it picks up the saved token via
317+
// the authTransport. APIBaseURL gets stomped by initConfig from the
318+
// saved cfg, so restore the mock URL afterwards.
319+
initConfig()
320+
APIBaseURL = srv
321+
return func() {
322+
_ = cliconfig.Clear()
323+
_ = secretstore.Delete()
324+
}
325+
}
326+
298327
// TestExtras_ResourceDetail asserts `instant resource <token>` GETs the
299328
// detail endpoint and renders the connection URL + resource type.
300329
func TestExtras_ResourceDetail(t *testing.T) {
301330
c := newITContext(t)
302331
resetProvisionFlags()
303332
_, token := c.provisionViaCLI("db", "detail-db")
333+
t.Cleanup(authSetupForTest(t, c.srv))
304334

305335
stdout, _ := captureStdout(t, func() {
306336
_, _, err := run("resource", token)
@@ -322,10 +352,15 @@ func TestExtras_ResourceDetail(t *testing.T) {
322352
// TestExtras_ResourceDeleteRequiresYes asserts the destructive command
323353
// REFUSES to delete without --yes when stdin is not a TTY. Agents that
324354
// pipe input would otherwise risk accidental deletes.
355+
//
356+
// CLI-MCP-11: the test sets up auth so the --yes gate (not the auth gate)
357+
// is the one being exercised — otherwise this test would pass for the
358+
// wrong reason after the unauth short-circuit landed.
325359
func TestExtras_ResourceDeleteRequiresYes(t *testing.T) {
326360
c := newITContext(t)
327361
resetProvisionFlags()
328362
_, token := c.provisionViaCLI("db", "guarded-db")
363+
t.Cleanup(authSetupForTest(t, c.srv))
329364

330365
// Pipe stdin to /dev/null so the "not a TTY" branch fires.
331366
origStdin := os.Stdin
@@ -340,6 +375,10 @@ func TestExtras_ResourceDeleteRequiresYes(t *testing.T) {
340375
if err == nil {
341376
t.Fatalf("delete without --yes (non-TTY) must error; resource %s would be lost", token)
342377
}
378+
// Specifically the --yes gate, not the auth gate.
379+
if strings.Contains(err.Error(), "authentication required") {
380+
t.Fatalf("delete must fail on --yes gate, not auth gate: %v", err)
381+
}
343382
// The mock must still have the resource.
344383
if c.mock.count() == 0 {
345384
t.Errorf("delete without --yes must NOT actually delete; mock is now empty")
@@ -352,6 +391,7 @@ func TestExtras_ResourceDeleteWithYes(t *testing.T) {
352391
c := newITContext(t)
353392
resetProvisionFlags()
354393
_, token := c.provisionViaCLI("db", "doomed-db")
394+
t.Cleanup(authSetupForTest(t, c.srv))
355395

356396
stdout, _ := captureStdout(t, func() {
357397
_, _, err := run("resource", "delete", token, "--yes")
@@ -381,6 +421,7 @@ func TestExtras_ResourceDelete_JSON(t *testing.T) {
381421
c := newITContext(t)
382422
resetProvisionFlags()
383423
_, token := c.provisionViaCLI("db", "doomed-json-db")
424+
t.Cleanup(authSetupForTest(t, c.srv))
384425

385426
stdout, _ := captureStdout(t, func() {
386427
_, _, err := run("resource", "delete", token, "--yes", "--json")

cmd/cli_mcp_gaps_test.go

Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
package cmd
2+
3+
// cli_mcp_gaps_test.go — regression tests for the BugBash QA round-2
4+
// CLI-MCP gaps closed in the `fix/cli-hygiene-env-passthrough` PR.
5+
//
6+
// CLI-MCP-8 — `--env` flag is parsed, forwarded in the request body,
7+
// and the resolved env is surfaced in the human output.
8+
// CLI-MCP-9 — `instant deploy` parent Short text is explicitly labeled
9+
// as a stub so the root help row carries the pointer.
10+
// CLI-MCP-11 — `instant resource <token>` and `instant resource delete
11+
// <token>` exit 3 (ExitAuthRequired) when the caller is
12+
// unauthenticated, BEFORE any side effects, matching the
13+
// contract `instant resources` (list) already honors.
14+
//
15+
// Each test pins the fix — reverting it would re-introduce a documented
16+
// QA-found gap.
17+
18+
import (
19+
"strings"
20+
"testing"
21+
)
22+
23+
// ── CLI-MCP-8: --env flag plumbing ───────────────────────────────────────────
24+
25+
// TestCLI_MCP_8_EnvFlagForwarded asserts that `instant db new --name X --env Y`
26+
// includes "env":"Y" in the request body — the mock echoes the resolved env
27+
// back so we can assert on the wire shape via the mock's recorded resource.
28+
//
29+
// Why this matters: until CLI-MCP-8 the CLI dropped --env entirely, forcing
30+
// agents that needed a non-default environment to fall back to curl
31+
// (CLAUDE.md rule 11 — empty `env` lands in "development").
32+
func TestCLI_MCP_8_EnvFlagForwarded(t *testing.T) {
33+
c := newITContext(t)
34+
resetProvisionFlags()
35+
36+
stdout, _ := captureStdout(t, func() {
37+
_, _, err := run("db", "new", "--name", "env-fwd-db", "--env", "production")
38+
if err != nil {
39+
t.Fatalf("db new --env: %v", err)
40+
}
41+
})
42+
// Resolved env line surfaces in the human output (CLI-MCP-8 acceptance).
43+
if !strings.Contains(stdout, "env production") {
44+
t.Errorf("expected `env production` line in output, got %q", stdout)
45+
}
46+
47+
// The mock parses and echoes body.Env back as the resource's env. List
48+
// it to confirm the value reached the server.
49+
tok := lastSavedToken(t)
50+
if tok == "" {
51+
t.Fatalf("no token persisted after provision")
52+
}
53+
t.Cleanup(func() { c.deleteResource(tok) })
54+
c.mock.mu.Lock()
55+
defer c.mock.mu.Unlock()
56+
res, ok := c.mock.resources[tok]
57+
if !ok {
58+
t.Fatalf("mock has no record of token %s", tok)
59+
}
60+
if res.Env != "production" {
61+
t.Errorf("server received env=%q, want %q", res.Env, "production")
62+
}
63+
}
64+
65+
// TestCLI_MCP_8_EnvFlagOmittedKeepsServerDefault asserts that omitting --env
66+
// does NOT inject an env field — the server sees an absent key and applies
67+
// its documented default (development). The mock mirrors this: empty body.Env
68+
// → resolves to "development".
69+
func TestCLI_MCP_8_EnvFlagOmittedKeepsServerDefault(t *testing.T) {
70+
c := newITContext(t)
71+
resetProvisionFlags()
72+
73+
stdout, _ := captureStdout(t, func() {
74+
_, _, err := run("cache", "new", "--name", "env-default-cache")
75+
if err != nil {
76+
t.Fatalf("cache new (no --env): %v", err)
77+
}
78+
})
79+
if !strings.Contains(stdout, "env development") {
80+
t.Errorf("expected resolved env=development line, got %q", stdout)
81+
}
82+
tok := lastSavedToken(t)
83+
if tok == "" {
84+
t.Fatalf("no token persisted")
85+
}
86+
t.Cleanup(func() { c.deleteResource(tok) })
87+
}
88+
89+
// TestCLI_MCP_8_EnvFallbackOnLegacyAPI asserts the CLI surfaces
90+
// `env development` (not the empty string) when the server response omits
91+
// the `env` field entirely — the documented behavior against an API build
92+
// that predates migration 026.
93+
func TestCLI_MCP_8_EnvFallbackOnLegacyAPI(t *testing.T) {
94+
c := newITContext(t)
95+
resetProvisionFlags()
96+
c.mock.mu.Lock()
97+
c.mock.omitEnvInProvision = true
98+
c.mock.mu.Unlock()
99+
100+
stdout, _ := captureStdout(t, func() {
101+
_, _, err := run("db", "new", "--name", "env-legacy-db")
102+
if err != nil {
103+
t.Fatalf("db new (legacy API): %v", err)
104+
}
105+
})
106+
if !strings.Contains(stdout, "env development") {
107+
t.Errorf("expected legacy fallback `env development`, got %q", stdout)
108+
}
109+
tok := lastSavedToken(t)
110+
t.Cleanup(func() { c.deleteResource(tok) })
111+
}
112+
113+
// TestCLI_MCP_8_EnvOverrideReasonSurfaced asserts that when the server
114+
// returns env_override_reason (e.g. anon caller asked for production and
115+
// got demoted), the CLI prints that reason on its own line.
116+
func TestCLI_MCP_8_EnvOverrideReasonSurfaced(t *testing.T) {
117+
c := newITContext(t)
118+
resetProvisionFlags()
119+
c.mock.mu.Lock()
120+
c.mock.envOverrideReason = "anonymous tier cannot target production; downgraded to development"
121+
c.mock.mu.Unlock()
122+
123+
stdout, _ := captureStdout(t, func() {
124+
_, _, err := run("db", "new", "--name", "env-override-db", "--env", "production")
125+
if err != nil {
126+
t.Fatalf("db new --env production: %v", err)
127+
}
128+
})
129+
if !strings.Contains(stdout, "env_override_reason") {
130+
t.Errorf("expected env_override_reason line in output, got %q", stdout)
131+
}
132+
if !strings.Contains(stdout, "anonymous tier cannot target production") {
133+
t.Errorf("expected reason text in output, got %q", stdout)
134+
}
135+
tok := lastSavedToken(t)
136+
t.Cleanup(func() { c.deleteResource(tok) })
137+
}
138+
139+
// TestCLI_MCP_8_EnvFlagAllProvisioningVerbs asserts every provisioning verb
140+
// (db, cache, nosql, queue, storage, webhook, vector) accepts --env. A typo
141+
// in init() that forgot to bind --env on one group would be caught here.
142+
func TestCLI_MCP_8_EnvFlagAllProvisioningVerbs(t *testing.T) {
143+
c := newITContext(t)
144+
for _, tc := range []struct{ group, name string }{
145+
{"db", "env-all-db"},
146+
{"cache", "env-all-cache"},
147+
{"nosql", "env-all-nosql"},
148+
{"queue", "env-all-queue"},
149+
{"storage", "env-all-storage"},
150+
{"webhook", "env-all-webhook"},
151+
{"vector", "env-all-vector"},
152+
} {
153+
t.Run(tc.group, func(t *testing.T) {
154+
resetProvisionFlags()
155+
_, _ = captureStdout(t, func() {
156+
_, _, err := run(tc.group, "new", "--name", tc.name, "--env", "staging")
157+
if err != nil {
158+
t.Fatalf("%s new --env: %v", tc.group, err)
159+
}
160+
})
161+
tok := lastSavedToken(t)
162+
if tok == "" {
163+
t.Fatalf("%s: no token persisted", tc.group)
164+
}
165+
c.mock.mu.Lock()
166+
res, ok := c.mock.resources[tok]
167+
c.mock.mu.Unlock()
168+
if !ok {
169+
t.Fatalf("%s: mock missing token", tc.group)
170+
}
171+
if res.Env != "staging" {
172+
t.Errorf("%s: server saw env=%q, want %q", tc.group, res.Env, "staging")
173+
}
174+
t.Cleanup(func() { c.deleteResource(tok) })
175+
})
176+
}
177+
}
178+
179+
// ── CLI-MCP-9: deploy parent help text labels as stub ────────────────────────
180+
181+
// TestCLI_MCP_9_DeployShortLabelsStub asserts the cobra Short for the
182+
// `instant deploy` parent contains the literal "[stub" marker AND points at
183+
// the canonical alternative path (MCP create_deploy / POST /deploy/new).
184+
// The Short string is what surfaces in `instant --help` one-liners — an
185+
// agent's first contact with this command MUST carry the pointer.
186+
func TestCLI_MCP_9_DeployShortLabelsStub(t *testing.T) {
187+
if deployCmd.Short == "" {
188+
t.Fatal("deploy command has no Short text")
189+
}
190+
if !strings.Contains(deployCmd.Short, "[stub") {
191+
t.Errorf("deploy.Short missing `[stub` marker: %q", deployCmd.Short)
192+
}
193+
if !strings.Contains(deployCmd.Short, "create_deploy") &&
194+
!strings.Contains(deployCmd.Short, "/deploy/new") {
195+
t.Errorf("deploy.Short must point at MCP `create_deploy` or `POST /deploy/new`: %q",
196+
deployCmd.Short)
197+
}
198+
}
199+
200+
// ── CLI-MCP-11: resource detail/delete unauth → exit 3 ──────────────────────
201+
202+
// TestCLI_MCP_11_ResourceDetail_Unauth_ExitsAuthRequired asserts that
203+
// calling `instant resource <token>` without auth exits with
204+
// ExitAuthRequired (3), BEFORE any API call. Reverting the haveAuth()
205+
// short-circuit would let an anonymous caller reach the API and either
206+
// succeed (token-as-bearer pattern) or 404 (exit 1) — neither matches the
207+
// documented contract that read commands require auth.
208+
func TestCLI_MCP_11_ResourceDetail_Unauth_ExitsAuthRequired(t *testing.T) {
209+
newITContext(t) // anonymous: no authSetupForTest call
210+
211+
_, _, err := run("resource", "some-token")
212+
if err == nil {
213+
t.Fatal("resource <token> (unauth) must error, got nil")
214+
}
215+
if code := ExitCodeFor(err); code != ExitAuthRequired {
216+
t.Errorf("resource <token> (unauth) exit code = %d, want %d (ExitAuthRequired)",
217+
code, ExitAuthRequired)
218+
}
219+
if !strings.Contains(err.Error(), "authentication required") {
220+
t.Errorf("expected `authentication required` message, got %q", err.Error())
221+
}
222+
}
223+
224+
// TestCLI_MCP_11_ResourceDelete_Unauth_ExitsAuthRequired asserts the
225+
// destructive path also exits 3 on unauth, BEFORE the --yes confirmation
226+
// prompt. An agent that ran `instant resource delete X --yes` without auth
227+
// previously had no deterministic exit code to branch on.
228+
func TestCLI_MCP_11_ResourceDelete_Unauth_ExitsAuthRequired(t *testing.T) {
229+
newITContext(t) // anonymous
230+
231+
_, _, err := run("resource", "delete", "some-token", "--yes")
232+
if err == nil {
233+
t.Fatal("resource delete (unauth) must error, got nil")
234+
}
235+
if code := ExitCodeFor(err); code != ExitAuthRequired {
236+
t.Errorf("resource delete (unauth) exit code = %d, want %d (ExitAuthRequired)",
237+
code, ExitAuthRequired)
238+
}
239+
}

0 commit comments

Comments
 (0)