diff --git a/README.md b/README.md index 2424fa1..da9b1c8 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,30 @@ instant login # Log in to your instanode.dev account instant whoami # Show current account ``` +### Targeting an environment + +Every `new` verb accepts an optional `--env` flag that the API honors +(default: `development`; CLAUDE.md rule 11): + +```bash +instant db new --name app-db --env production +instant cache new --name app-cache --env staging +``` + +The response prints both the resolved `env` and — when the server downgraded +a request (e.g. anonymous caller asking for `production`) — an +`env_override_reason` line explaining why. + +## Multi-service stacks + +`instant stack new` is a CLI follow-up — not shipped yet. For multi-service +stacks today, use either the MCP `create_stack` tool (Claude Code, Cursor, +any MCP client) or a direct `POST /stacks/new` call against the API. The +request schema lives at `https://api.instanode.dev/openapi.json`. + +Single-service deploys via the CLI are also still a follow-up — `instant +deploy --help` prints the canonical MCP/curl paths. + ## Build from source ```bash diff --git a/cmd/bughunt_b15_test.go b/cmd/bughunt_b15_test.go index 293541b..7a73610 100644 --- a/cmd/bughunt_b15_test.go +++ b/cmd/bughunt_b15_test.go @@ -295,12 +295,42 @@ func TestExtras_StorageWebhookVector(t *testing.T) { } } +// authSetupForTest wires a saved API key + base URL into the package-global +// HTTPClient so haveAuth() returns true. CLI-MCP-11 made `instant resource +// ` (and `resource delete`) auth-required up-front — every test that +// exercises those commands now needs an authenticated session even when the +// mock would otherwise serve an anonymous path-token request. +// +// Returns a cleanup function the caller MUST defer/t.Cleanup so per-test +// auth state doesn't leak across tests. +func authSetupForTest(t *testing.T, srv string) func() { + t.Helper() + cfg := &cliconfig.Config{ + APIKey: "inst_test_pat", + Email: "test@example.com", + APIBaseURL: srv, + } + if err := cfg.Save(); err != nil { + t.Fatalf("authSetupForTest: save cfg: %v", err) + } + // Re-init the package HTTP client so it picks up the saved token via + // the authTransport. APIBaseURL gets stomped by initConfig from the + // saved cfg, so restore the mock URL afterwards. + initConfig() + APIBaseURL = srv + return func() { + _ = cliconfig.Clear() + _ = secretstore.Delete() + } +} + // TestExtras_ResourceDetail asserts `instant resource ` GETs the // detail endpoint and renders the connection URL + resource type. func TestExtras_ResourceDetail(t *testing.T) { c := newITContext(t) resetProvisionFlags() _, token := c.provisionViaCLI("db", "detail-db") + t.Cleanup(authSetupForTest(t, c.srv)) stdout, _ := captureStdout(t, func() { _, _, err := run("resource", token) @@ -322,10 +352,15 @@ func TestExtras_ResourceDetail(t *testing.T) { // TestExtras_ResourceDeleteRequiresYes asserts the destructive command // REFUSES to delete without --yes when stdin is not a TTY. Agents that // pipe input would otherwise risk accidental deletes. +// +// CLI-MCP-11: the test sets up auth so the --yes gate (not the auth gate) +// is the one being exercised — otherwise this test would pass for the +// wrong reason after the unauth short-circuit landed. func TestExtras_ResourceDeleteRequiresYes(t *testing.T) { c := newITContext(t) resetProvisionFlags() _, token := c.provisionViaCLI("db", "guarded-db") + t.Cleanup(authSetupForTest(t, c.srv)) // Pipe stdin to /dev/null so the "not a TTY" branch fires. origStdin := os.Stdin @@ -340,6 +375,10 @@ func TestExtras_ResourceDeleteRequiresYes(t *testing.T) { if err == nil { t.Fatalf("delete without --yes (non-TTY) must error; resource %s would be lost", token) } + // Specifically the --yes gate, not the auth gate. + if strings.Contains(err.Error(), "authentication required") { + t.Fatalf("delete must fail on --yes gate, not auth gate: %v", err) + } // The mock must still have the resource. if c.mock.count() == 0 { t.Errorf("delete without --yes must NOT actually delete; mock is now empty") @@ -352,6 +391,7 @@ func TestExtras_ResourceDeleteWithYes(t *testing.T) { c := newITContext(t) resetProvisionFlags() _, token := c.provisionViaCLI("db", "doomed-db") + t.Cleanup(authSetupForTest(t, c.srv)) stdout, _ := captureStdout(t, func() { _, _, err := run("resource", "delete", token, "--yes") @@ -381,6 +421,7 @@ func TestExtras_ResourceDelete_JSON(t *testing.T) { c := newITContext(t) resetProvisionFlags() _, token := c.provisionViaCLI("db", "doomed-json-db") + t.Cleanup(authSetupForTest(t, c.srv)) stdout, _ := captureStdout(t, func() { _, _, err := run("resource", "delete", token, "--yes", "--json") diff --git a/cmd/cli_mcp_gaps_test.go b/cmd/cli_mcp_gaps_test.go new file mode 100644 index 0000000..6abb999 --- /dev/null +++ b/cmd/cli_mcp_gaps_test.go @@ -0,0 +1,239 @@ +package cmd + +// cli_mcp_gaps_test.go — regression tests for the BugBash QA round-2 +// CLI-MCP gaps closed in the `fix/cli-hygiene-env-passthrough` PR. +// +// CLI-MCP-8 — `--env` flag is parsed, forwarded in the request body, +// and the resolved env is surfaced in the human output. +// CLI-MCP-9 — `instant deploy` parent Short text is explicitly labeled +// as a stub so the root help row carries the pointer. +// CLI-MCP-11 — `instant resource ` and `instant resource delete +// ` exit 3 (ExitAuthRequired) when the caller is +// unauthenticated, BEFORE any side effects, matching the +// contract `instant resources` (list) already honors. +// +// Each test pins the fix — reverting it would re-introduce a documented +// QA-found gap. + +import ( + "strings" + "testing" +) + +// ── CLI-MCP-8: --env flag plumbing ─────────────────────────────────────────── + +// TestCLI_MCP_8_EnvFlagForwarded asserts that `instant db new --name X --env Y` +// includes "env":"Y" in the request body — the mock echoes the resolved env +// back so we can assert on the wire shape via the mock's recorded resource. +// +// Why this matters: until CLI-MCP-8 the CLI dropped --env entirely, forcing +// agents that needed a non-default environment to fall back to curl +// (CLAUDE.md rule 11 — empty `env` lands in "development"). +func TestCLI_MCP_8_EnvFlagForwarded(t *testing.T) { + c := newITContext(t) + resetProvisionFlags() + + stdout, _ := captureStdout(t, func() { + _, _, err := run("db", "new", "--name", "env-fwd-db", "--env", "production") + if err != nil { + t.Fatalf("db new --env: %v", err) + } + }) + // Resolved env line surfaces in the human output (CLI-MCP-8 acceptance). + if !strings.Contains(stdout, "env production") { + t.Errorf("expected `env production` line in output, got %q", stdout) + } + + // The mock parses and echoes body.Env back as the resource's env. List + // it to confirm the value reached the server. + tok := lastSavedToken(t) + if tok == "" { + t.Fatalf("no token persisted after provision") + } + t.Cleanup(func() { c.deleteResource(tok) }) + c.mock.mu.Lock() + defer c.mock.mu.Unlock() + res, ok := c.mock.resources[tok] + if !ok { + t.Fatalf("mock has no record of token %s", tok) + } + if res.Env != "production" { + t.Errorf("server received env=%q, want %q", res.Env, "production") + } +} + +// TestCLI_MCP_8_EnvFlagOmittedKeepsServerDefault asserts that omitting --env +// does NOT inject an env field — the server sees an absent key and applies +// its documented default (development). The mock mirrors this: empty body.Env +// → resolves to "development". +func TestCLI_MCP_8_EnvFlagOmittedKeepsServerDefault(t *testing.T) { + c := newITContext(t) + resetProvisionFlags() + + stdout, _ := captureStdout(t, func() { + _, _, err := run("cache", "new", "--name", "env-default-cache") + if err != nil { + t.Fatalf("cache new (no --env): %v", err) + } + }) + if !strings.Contains(stdout, "env development") { + t.Errorf("expected resolved env=development line, got %q", stdout) + } + tok := lastSavedToken(t) + if tok == "" { + t.Fatalf("no token persisted") + } + t.Cleanup(func() { c.deleteResource(tok) }) +} + +// TestCLI_MCP_8_EnvFallbackOnLegacyAPI asserts the CLI surfaces +// `env development` (not the empty string) when the server response omits +// the `env` field entirely — the documented behavior against an API build +// that predates migration 026. +func TestCLI_MCP_8_EnvFallbackOnLegacyAPI(t *testing.T) { + c := newITContext(t) + resetProvisionFlags() + c.mock.mu.Lock() + c.mock.omitEnvInProvision = true + c.mock.mu.Unlock() + + stdout, _ := captureStdout(t, func() { + _, _, err := run("db", "new", "--name", "env-legacy-db") + if err != nil { + t.Fatalf("db new (legacy API): %v", err) + } + }) + if !strings.Contains(stdout, "env development") { + t.Errorf("expected legacy fallback `env development`, got %q", stdout) + } + tok := lastSavedToken(t) + t.Cleanup(func() { c.deleteResource(tok) }) +} + +// TestCLI_MCP_8_EnvOverrideReasonSurfaced asserts that when the server +// returns env_override_reason (e.g. anon caller asked for production and +// got demoted), the CLI prints that reason on its own line. +func TestCLI_MCP_8_EnvOverrideReasonSurfaced(t *testing.T) { + c := newITContext(t) + resetProvisionFlags() + c.mock.mu.Lock() + c.mock.envOverrideReason = "anonymous tier cannot target production; downgraded to development" + c.mock.mu.Unlock() + + stdout, _ := captureStdout(t, func() { + _, _, err := run("db", "new", "--name", "env-override-db", "--env", "production") + if err != nil { + t.Fatalf("db new --env production: %v", err) + } + }) + if !strings.Contains(stdout, "env_override_reason") { + t.Errorf("expected env_override_reason line in output, got %q", stdout) + } + if !strings.Contains(stdout, "anonymous tier cannot target production") { + t.Errorf("expected reason text in output, got %q", stdout) + } + tok := lastSavedToken(t) + t.Cleanup(func() { c.deleteResource(tok) }) +} + +// TestCLI_MCP_8_EnvFlagAllProvisioningVerbs asserts every provisioning verb +// (db, cache, nosql, queue, storage, webhook, vector) accepts --env. A typo +// in init() that forgot to bind --env on one group would be caught here. +func TestCLI_MCP_8_EnvFlagAllProvisioningVerbs(t *testing.T) { + c := newITContext(t) + for _, tc := range []struct{ group, name string }{ + {"db", "env-all-db"}, + {"cache", "env-all-cache"}, + {"nosql", "env-all-nosql"}, + {"queue", "env-all-queue"}, + {"storage", "env-all-storage"}, + {"webhook", "env-all-webhook"}, + {"vector", "env-all-vector"}, + } { + t.Run(tc.group, func(t *testing.T) { + resetProvisionFlags() + _, _ = captureStdout(t, func() { + _, _, err := run(tc.group, "new", "--name", tc.name, "--env", "staging") + if err != nil { + t.Fatalf("%s new --env: %v", tc.group, err) + } + }) + tok := lastSavedToken(t) + if tok == "" { + t.Fatalf("%s: no token persisted", tc.group) + } + c.mock.mu.Lock() + res, ok := c.mock.resources[tok] + c.mock.mu.Unlock() + if !ok { + t.Fatalf("%s: mock missing token", tc.group) + } + if res.Env != "staging" { + t.Errorf("%s: server saw env=%q, want %q", tc.group, res.Env, "staging") + } + t.Cleanup(func() { c.deleteResource(tok) }) + }) + } +} + +// ── CLI-MCP-9: deploy parent help text labels as stub ──────────────────────── + +// TestCLI_MCP_9_DeployShortLabelsStub asserts the cobra Short for the +// `instant deploy` parent contains the literal "[stub" marker AND points at +// the canonical alternative path (MCP create_deploy / POST /deploy/new). +// The Short string is what surfaces in `instant --help` one-liners — an +// agent's first contact with this command MUST carry the pointer. +func TestCLI_MCP_9_DeployShortLabelsStub(t *testing.T) { + if deployCmd.Short == "" { + t.Fatal("deploy command has no Short text") + } + if !strings.Contains(deployCmd.Short, "[stub") { + t.Errorf("deploy.Short missing `[stub` marker: %q", deployCmd.Short) + } + if !strings.Contains(deployCmd.Short, "create_deploy") && + !strings.Contains(deployCmd.Short, "/deploy/new") { + t.Errorf("deploy.Short must point at MCP `create_deploy` or `POST /deploy/new`: %q", + deployCmd.Short) + } +} + +// ── CLI-MCP-11: resource detail/delete unauth → exit 3 ────────────────────── + +// TestCLI_MCP_11_ResourceDetail_Unauth_ExitsAuthRequired asserts that +// calling `instant resource ` without auth exits with +// ExitAuthRequired (3), BEFORE any API call. Reverting the haveAuth() +// short-circuit would let an anonymous caller reach the API and either +// succeed (token-as-bearer pattern) or 404 (exit 1) — neither matches the +// documented contract that read commands require auth. +func TestCLI_MCP_11_ResourceDetail_Unauth_ExitsAuthRequired(t *testing.T) { + newITContext(t) // anonymous: no authSetupForTest call + + _, _, err := run("resource", "some-token") + if err == nil { + t.Fatal("resource (unauth) must error, got nil") + } + if code := ExitCodeFor(err); code != ExitAuthRequired { + t.Errorf("resource (unauth) exit code = %d, want %d (ExitAuthRequired)", + code, ExitAuthRequired) + } + if !strings.Contains(err.Error(), "authentication required") { + t.Errorf("expected `authentication required` message, got %q", err.Error()) + } +} + +// TestCLI_MCP_11_ResourceDelete_Unauth_ExitsAuthRequired asserts the +// destructive path also exits 3 on unauth, BEFORE the --yes confirmation +// prompt. An agent that ran `instant resource delete X --yes` without auth +// previously had no deterministic exit code to branch on. +func TestCLI_MCP_11_ResourceDelete_Unauth_ExitsAuthRequired(t *testing.T) { + newITContext(t) // anonymous + + _, _, err := run("resource", "delete", "some-token", "--yes") + if err == nil { + t.Fatal("resource delete (unauth) must error, got nil") + } + if code := ExitCodeFor(err); code != ExitAuthRequired { + t.Errorf("resource delete (unauth) exit code = %d, want %d (ExitAuthRequired)", + code, ExitAuthRequired) + } +} diff --git a/cmd/coverage_provision_test.go b/cmd/coverage_provision_test.go index 54605ac..8480e04 100644 --- a/cmd/coverage_provision_test.go +++ b/cmd/coverage_provision_test.go @@ -15,7 +15,7 @@ func TestProvisionResource_NetworkError(t *testing.T) { APIBaseURL = "http://127.0.0.1:1" t.Cleanup(func() { APIBaseURL = prev }) - _, err := provisionResource("/db/new", "x") + _, err := provisionResource("/db/new", "x", "") if err == nil { t.Fatal("expected network error") } @@ -34,7 +34,7 @@ func TestProvisionResource_SessionExpired(t *testing.T) { HTTPClient = &http.Client{Transport: &authTransport{base: http.DefaultTransport, apiKey: "k"}} t.Cleanup(func() { HTTPClient = prevC }) - _, err := provisionResource("/db/new", "x") + _, err := provisionResource("/db/new", "x", "") if err == nil || !strings.Contains(err.Error(), "session expired") { t.Errorf("got %v", err) } @@ -49,7 +49,7 @@ func TestProvisionResource_APIError(t *testing.T) { APIBaseURL = srv.URL t.Cleanup(func() { APIBaseURL = prev }) - _, err := provisionResource("/db/new", "x") + _, err := provisionResource("/db/new", "x", "") if err == nil { t.Fatal("expected api error") } @@ -64,7 +64,7 @@ func TestProvisionResource_BadJSON(t *testing.T) { APIBaseURL = srv.URL t.Cleanup(func() { APIBaseURL = prev }) - _, err := provisionResource("/db/new", "x") + _, err := provisionResource("/db/new", "x", "") if err == nil || !strings.Contains(err.Error(), "parsing") { t.Errorf("got %v", err) } @@ -79,7 +79,7 @@ func TestProvisionResource_UnexpectedResponse(t *testing.T) { APIBaseURL = srv.URL t.Cleanup(func() { APIBaseURL = prev }) - _, err := provisionResource("/db/new", "x") + _, err := provisionResource("/db/new", "x", "") if err == nil || !strings.Contains(err.Error(), "unexpected response") { t.Errorf("got %v", err) } diff --git a/cmd/deploy_stub.go b/cmd/deploy_stub.go index 6fd6f22..bd16d88 100644 --- a/cmd/deploy_stub.go +++ b/cmd/deploy_stub.go @@ -36,8 +36,14 @@ import ( ) var deployCmd = &cobra.Command{ - Use: "deploy", - Short: "Deploy an application (CLI surface coming; use MCP or curl today)", + Use: "deploy", + // CLI-MCP-9: label the parent (and every sub-sub-command, below) as a + // stub so `instant --help` / `instant deploy --help` make it + // unambiguous that these verbs are NOT implemented in the CLI yet. + // The Short string surfaces in the root command list — that one row is + // the agent's first signal, so it has to carry the "use MCP or curl" + // pointer. + Short: "[stub — current MCP/API path: POST /deploy/new or create_deploy via MCP. CLI deploy verbs not yet implemented]", Long: `Deploy commands are not implemented in the CLI yet. The platform exposes the full deploy API at: diff --git a/cmd/extras.go b/cmd/extras.go index 1d1bffa..9683589 100644 --- a/cmd/extras.go +++ b/cmd/extras.go @@ -103,11 +103,22 @@ var ( ) // runResourceDetail GETs /api/v1/resources/:token and renders the result. +// +// CLI-MCP-11: this command requires auth — the token in the URL identifies +// the resource being inspected, NOT the caller. Pre-checking haveAuth() and +// short-circuiting with errAuthRequired (exit 3) keeps the exit-code +// contract consistent with `instant resources` (list), regardless of +// whether the API treats a path token as a bearer for some routes today. +// The post-call 401 branch below stays as defense-in-depth (covers expired +// PAT, server-side policy change, etc.). func runResourceDetail(cmd *cobra.Command, token string) error { token = strings.TrimSpace(token) if token == "" { return fmt.Errorf("token is required") } + if !haveAuth() { + return errAuthRequired("authentication required — run `instant login` first") + } url := fmt.Sprintf("%s/api/v1/resources/%s", APIBaseURL, token) req, err := http.NewRequest(http.MethodGet, url, nil) if err != nil { @@ -206,11 +217,19 @@ func runResourceDetail(cmd *cobra.Command, token string) error { // runResourceDelete DELETEs /api/v1/resources/:token. Requires --yes (or a // 'y' from an interactive terminal) to actually fire the request — destructive // commands MUST NOT silently delete on a typo'd token. +// +// CLI-MCP-11 (paired with runResourceDetail): a destructive command without +// auth must exit 3 BEFORE any side effects (including the interactive +// confirmation prompt) — otherwise an unauth user can be coaxed into +// confirming a delete that then 401s with the wrong exit code. func runResourceDelete(cmd *cobra.Command, token string) error { token = strings.TrimSpace(token) if token == "" { return fmt.Errorf("token is required") } + if !haveAuth() { + return errAuthRequired("authentication required — run `instant login` first") + } if !resourceDeleteYes { // Interactive confirmation when stdin is a TTY; otherwise abort // with a clear message so a piped invocation can't silently delete. @@ -271,10 +290,13 @@ func runResourceDelete(cmd *cobra.Command, token string) error { } func init() { - // storage / webhook / vector — same --name plumbing as monitor.go. + // storage / webhook / vector — same --name + --env plumbing as monitor.go. + // CLI-MCP-8: --env is forwarded on every provisioning verb. for _, c := range []*cobra.Command{storageNewCmd, webhookNewCmd, vectorNewCmd} { c.Flags().StringVar(&resourceName, "name", "", "Resource name (required, 1–64 chars, matches ^[A-Za-z0-9][A-Za-z0-9 _-]*$)") + c.Flags().StringVar(&resourceEnv, "env", "", + "Provisioning environment (default: server-side \"development\"; common: development|staging|production)") _ = c.MarkFlagRequired("name") } storageCmd.AddCommand(storageNewCmd) diff --git a/cmd/integration_test.go b/cmd/integration_test.go index 3eabf27..9d91c50 100644 --- a/cmd/integration_test.go +++ b/cmd/integration_test.go @@ -211,14 +211,23 @@ func lastSavedToken(t *testing.T) string { return store.Entries[len(store.Entries)-1].Token } -// resetProvisionFlags clears the global --name flag between table cases. -// makeProvisionCmd binds the package-global resourceName; cobra retains the -// last-parsed value, so a follow-up test could see stale state. +// resetProvisionFlags clears the global --name + --env flags between table +// cases. makeProvisionCmd binds the package-globals resourceName and +// resourceEnv; cobra retains the last-parsed value, so a follow-up test +// could see stale state. All seven provisioning groups (db / cache / nosql +// / queue / storage / webhook / vector) are reset. func resetProvisionFlags() { resourceName = "" - for _, group := range []*cobra.Command{dbCmd, cacheCmd, nosqlCmd, queueCmd} { + resourceEnv = "" + for _, group := range []*cobra.Command{dbCmd, cacheCmd, nosqlCmd, queueCmd, storageCmd, webhookCmd, vectorCmd} { for _, sub := range group.Commands() { _ = sub.Flags().Set("name", "") + // --env is optional so it may not be bound on older builds; the + // Set call is best-effort and silently no-ops on a missing flag. + if fl := sub.Flags().Lookup("env"); fl != nil { + _ = fl.Value.Set("") + fl.Changed = false + } } } } diff --git a/cmd/monitor.go b/cmd/monitor.go index 8f57d33..3099eb8 100644 --- a/cmd/monitor.go +++ b/cmd/monitor.go @@ -34,6 +34,20 @@ var nameRegexp = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9 _-]*$`) // resourceName is bound to the required --name flag on every `new` command. var resourceName string +// resourceEnv is bound to the optional --env flag on every `new` command. +// +// CLI-MCP-8 (BugBash QA round 2): every provisioning verb on the CLI used to +// drop `env` on the request body. The API has honored an `env` parameter +// since migration 026 (defaults to "development" when omitted — CLAUDE.md +// rule 11). Without a CLI surface, an agent had no way to provision into +// "production" without falling back to curl. Empty here means "don't send +// the field" — the server applies its documented default (development). +// Values are not validated client-side; the server enforces the regex + +// policy and surfaces a structured 400 if invalid, so we don't second-guess +// it (this also keeps the CLI forward-compatible with future env-policy +// changes). +var resourceEnv string + // validateResourceName applies the server-side name contract locally so the // CLI fails fast with a clear message instead of a bare HTTP 400. func validateResourceName(name string) error { @@ -134,7 +148,7 @@ func makeProvisionCmd(endpoint, resourceType string) func(*cobra.Command, []stri return err } - creds, err := provisionResource(endpoint, name) + creds, err := provisionResource(endpoint, name, resourceEnv) if err != nil { return fmt.Errorf("provisioning failed: %w", err) } @@ -169,6 +183,19 @@ func makeProvisionCmd(endpoint, resourceType string) func(*cobra.Command, []stri if creds.Tier != "" { fmt.Printf("tier %s\n", creds.Tier) } + // CLI-MCP-8: surface the resolved env (and env_override_reason when + // the server downgraded the request — e.g. anonymous caller asking + // for production gets demoted to development with a reason string). + // Empty `creds.Env` against an older API build still prints the + // "development" fallback used for the local tokens cache above. + envOut := creds.Env + if envOut == "" { + envOut = "development" + } + fmt.Printf("env %s\n", envOut) + if creds.EnvOverrideReason != "" { + fmt.Printf("env_override_reason %s\n", creds.EnvOverrideReason) + } if creds.Note != "" { fmt.Printf("\n%s\n", creds.Note) } @@ -191,9 +218,14 @@ type provisionResponse struct { // default (CLAUDE.md rule 11) — when empty. Used to key the local // tokens cache so B15-P1 (7) anonymous-up idempotency can match on // (type, name, env) without an API list call. - Env string `json:"env"` - Note string `json:"note"` - Upgrade string `json:"upgrade"` + Env string `json:"env"` + // EnvOverrideReason is set by the API when the requested env was + // downgraded server-side (e.g. anonymous caller asking for production + // gets demoted to "development" with a reason). CLI surfaces it so the + // user sees WHY their requested env didn't stick. May be empty. + EnvOverrideReason string `json:"env_override_reason"` + Note string `json:"note"` + Upgrade string `json:"upgrade"` } // provisionResource calls POST {APIBaseURL}{endpoint} and returns parsed credentials. @@ -201,9 +233,18 @@ type provisionResponse struct { // T16 P1-2: a 401 against an authenticated request returns the uniform // errSessionExpired() error so the exit-code contract is consistent across // `resources`, `up`, and direct provisioning. -func provisionResource(endpoint, name string) (*provisionResponse, error) { +// +// CLI-MCP-8: `env` is the optional `--env` flag. Empty == "don't send the +// field" so the server applies its documented default (development). A +// non-empty value is forwarded verbatim; server-side validation owns the +// regex + policy. +func provisionResource(endpoint, name, env string) (*provisionResponse, error) { url := APIBaseURL + endpoint - body, _ := json.Marshal(map[string]string{"name": name}) + payload := map[string]string{"name": name} + if env != "" { + payload["env"] = env + } + body, _ := json.Marshal(payload) resp, err := HTTPClient.Post(url, "application/json", bytes.NewReader(body)) if err != nil { @@ -300,8 +341,12 @@ With --json, output is a machine-readable JSON array of token entries func init() { // --name is REQUIRED on every provisioning command. Cobra surfaces a // clear `required flag(s) "name" not set` error before RunE runs. + // --env is OPTIONAL (CLI-MCP-8). Empty == server default ("development", + // CLAUDE.md rule 11); set to "production" / "staging" / etc. to override. for _, c := range []*cobra.Command{dbNewCmd, cacheNewCmd, nosqlNewCmd, queueNewCmd} { c.Flags().StringVar(&resourceName, "name", "", "Resource name (required, 1–64 chars, matches ^[A-Za-z0-9][A-Za-z0-9 _-]*$)") + c.Flags().StringVar(&resourceEnv, "env", "", + "Provisioning environment (default: server-side \"development\"; common: development|staging|production)") _ = c.MarkFlagRequired("name") } diff --git a/cmd/monitor_test.go b/cmd/monitor_test.go index 2ef6802..bee4837 100644 --- a/cmd/monitor_test.go +++ b/cmd/monitor_test.go @@ -29,7 +29,7 @@ func freshProvisionCmd(endpoint, resourceType string) (root *cobra.Command, name if err := validateResourceName(bound); err != nil { return err } - _, err := provisionResource(endpoint, bound) + _, err := provisionResource(endpoint, bound, "") return err }, } diff --git a/cmd/testapi_test.go b/cmd/testapi_test.go index 99867a7..f5565d7 100644 --- a/cmd/testapi_test.go +++ b/cmd/testapi_test.go @@ -90,6 +90,16 @@ type mockAPI struct { // CLI that mistakenly sends id would 404. This pins the contract that // /api/v1/resources/:id/credentials expects the token (a UUID). idDifferentFromToken bool + + // ── CLI-MCP-8 — env-shape modes for provision responses ───────────── + // envOverrideReason, when non-empty, is echoed in the response so the + // CLI's env_override_reason print path is exercised (server downgraded + // the requested env, e.g. anon → development with a reason). + envOverrideReason string + // omitEnvInProvision, when true, drops the `env` field from the + // provisioning response entirely — simulates an older API build that + // predates migration 026 so the CLI's empty-env fallback path runs. + omitEnvInProvision bool } // injectErrorOnProvision arms the mock to return a structured error envelope @@ -366,6 +376,21 @@ func (m *mockAPI) handleProvision(w http.ResponseWriter, r *http.Request, rtype "ok": true, "token": token, "name": body.Name, "tier": "anonymous", "env": env, } + // CLI-MCP-8 — exercise the optional env_override_reason and the + // pre-mig-026 "no env field at all" shapes. Both knobs disarm on the + // next read so a test arms once and asserts deterministically. + m.mu.Lock() + reason := m.envOverrideReason + omitEnv := m.omitEnvInProvision + m.envOverrideReason = "" + m.omitEnvInProvision = false + m.mu.Unlock() + if reason != "" { + resp["env_override_reason"] = reason + } + if omitEnv { + delete(resp, "env") + } if rtype == "webhook" { res.ReceiveURL = "https://hooks.instanode.dev/" + token resp["receive_url"] = res.ReceiveURL