diff --git a/cmd/agent_dx_followups_test.go b/cmd/agent_dx_followups_test.go new file mode 100644 index 0000000..94c8f91 --- /dev/null +++ b/cmd/agent_dx_followups_test.go @@ -0,0 +1,410 @@ +package cmd + +// agent_dx_followups_test.go — coverage for the four agent-DX follow-ups +// shipped in fix/cli-creds-verb-webhook-url-401-token (cohort dogfood round 2, +// follow-ups to #32): +// +// F1 `instant resource creds ` (alias credentials) re-fetches the +// connection URL via GET …/credentials — the recovery path for a +// provision whose `new` call timed out before printing the URL. +// F2 `webhook new` prints the receive_url (it was printing a blank `url` +// because the human path read creds.ConnectionURL only). +// F3 a 401 with INSTANT_TOKEN set advises fixing/unsetting INSTANT_TOKEN +// (which shadows any saved login), not `instant login`. +// F4 `instant resources` prints the FULL token (un-copyable truncation +// fixed) and truncates the NAME column instead when long. +// +// Tests follow the established httptest-mock conventions (operateServer / +// newITContext + captureStdout) used by operate_test.go and integration_test.go. + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + "testing" +) + +// ── F1: instant resource creds ─────────────────────────────────────── + +// TestF1_ResourceCreds_RefetchesConnectionURL is the headline F1 regression: +// after a `db new` that timed out client-side, the connection URL is only +// recoverable via GET /api/v1/resources/:token/credentials. `instant resource +// creds ` must GET exactly that path and print the connection_url. +func TestF1_ResourceCreds_RefetchesConnectionURL(t *testing.T) { + var gotMethod, gotPath string + operateServer(t, true, func(w http.ResponseWriter, r *http.Request) { + gotMethod, gotPath = r.Method, r.URL.Path + _, _ = fmt.Fprint(w, `{"ok":true,"id":"res-9","token":"tok-1","resource_type":"postgres","env":"production","connection_url":"postgres://u:p@host/db"}`) + }) + stdout, _ := captureStdout(t, func() { + _, _, err := run("resource", "creds", "tok-1") + if err != nil { + t.Fatalf("resource creds: %v", err) + } + }) + if gotMethod != http.MethodGet || gotPath != "/api/v1/resources/tok-1/credentials" { + t.Errorf("must GET /api/v1/resources/:token/credentials, got %s %s", gotMethod, gotPath) + } + for _, want := range []string{"tok-1", "postgres://u:p@host/db", "postgres", "production"} { + if !strings.Contains(stdout, want) { + t.Errorf("creds output missing %q: %q", want, stdout) + } + } +} + +// TestF1_ResourceCredentialsAlias asserts the `credentials` alias hits the same +// path as `creds`. +func TestF1_ResourceCredentialsAlias(t *testing.T) { + var gotPath string + operateServer(t, true, func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + _, _ = fmt.Fprint(w, `{"ok":true,"token":"tok-2","connection_url":"redis://h:6379"}`) + }) + stdout, _ := captureStdout(t, func() { + _, _, err := run("resource", "credentials", "tok-2") + if err != nil { + t.Fatalf("resource credentials: %v", err) + } + }) + if gotPath != "/api/v1/resources/tok-2/credentials" { + t.Errorf("credentials alias must GET …/credentials, got %s", gotPath) + } + if !strings.Contains(stdout, "redis://h:6379") { + t.Errorf("alias output missing url: %q", stdout) + } +} + +// TestF1_ResourceCreds_JSON asserts --json emits the full structured response. +func TestF1_ResourceCreds_JSON(t *testing.T) { + operateServer(t, true, func(w http.ResponseWriter, r *http.Request) { + _, _ = fmt.Fprint(w, `{"ok":true,"id":"res-1","token":"tok-1","resource_type":"postgres","env":"production","connection_url":"postgres://u:p@host/db"}`) + }) + stdout, _ := captureStdout(t, func() { + _, _, err := run("resource", "creds", "tok-1", "--json") + if err != nil { + t.Fatalf("resource creds --json: %v", err) + } + }) + var res resourceCredentialsResult + if err := json.Unmarshal([]byte(stdout), &res); err != nil { + t.Fatalf("--json output is not JSON: %v\n%q", err, stdout) + } + if res.ConnectionURL != "postgres://u:p@host/db" || res.ResourceType != "postgres" || res.Env != "production" { + t.Errorf("unexpected JSON payload: %+v", res) + } +} + +// TestF1_ResourceCreds_WebhookReceiveURLFallback asserts a credentials response +// with no connection_url (a webhook) falls back to receive_url in the human path. +func TestF1_ResourceCreds_WebhookReceiveURLFallback(t *testing.T) { + operateServer(t, true, func(w http.ResponseWriter, r *http.Request) { + _, _ = fmt.Fprint(w, `{"ok":true,"token":"tok-h","resource_type":"webhook","receive_url":"https://hooks.instanode.dev/tok-h"}`) + }) + stdout, _ := captureStdout(t, func() { + _, _, err := run("resource", "creds", "tok-h") + if err != nil { + t.Fatalf("resource creds (webhook): %v", err) + } + }) + if !strings.Contains(stdout, "https://hooks.instanode.dev/tok-h") { + t.Errorf("webhook creds must fall back to receive_url, got %q", stdout) + } +} + +// TestF1_ResourceCreds_TokenFallback asserts that when the credentials +// response omits `token` (older API shape), the CLI falls back to the +// argument token in the printed `ok` line. +func TestF1_ResourceCreds_TokenFallback(t *testing.T) { + operateServer(t, true, func(w http.ResponseWriter, r *http.Request) { + // No `token` field in the response body. + _, _ = fmt.Fprint(w, `{"ok":true,"connection_url":"postgres://u:p@host/db"}`) + }) + stdout, _ := captureStdout(t, func() { + _, _, err := run("resource", "creds", "tok-arg") + if err != nil { + t.Fatalf("resource creds (no token in body): %v", err) + } + }) + if !strings.Contains(stdout, "ok creds tok-arg") { + t.Errorf("must fall back to the argument token when the body omits it, got %q", stdout) + } +} + +// TestF1_ResourceCreds_Unauthenticated asserts the verb short-circuits with +// exit 3 BEFORE any API round trip when anonymous (it's auth-required: the +// token in the URL identifies the resource, not the caller). +func TestF1_ResourceCreds_Unauthenticated(t *testing.T) { + called := false + operateServer(t, false, func(w http.ResponseWriter, r *http.Request) { called = true }) + _, _, err := run("resource", "creds", "tok-1") + if err == nil || !strings.Contains(err.Error(), "authentication required") { + t.Fatalf("expected auth-required error, got %v", err) + } + if ExitCodeFor(err) != ExitAuthRequired { + t.Errorf("exit code = %d, want %d", ExitCodeFor(err), ExitAuthRequired) + } + if called { + t.Error("anonymous creds must short-circuit BEFORE the API round trip") + } +} + +// TestF1_ResourceCreds_MissingToken asserts a missing token is a usage error. +func TestF1_ResourceCreds_MissingToken(t *testing.T) { + operateServer(t, true, func(w http.ResponseWriter, r *http.Request) { + t.Error("missing token must fail before any API call") + }) + for _, verb := range []string{"creds", "credentials"} { + _, _, err := run("resource", verb) + if err == nil || !strings.Contains(err.Error(), "token argument is required") { + t.Errorf("resource %s (no token): expected usage error, got %v", verb, err) + } + } +} + +// TestF1_ResourceCreds_EmptyToken drives the trimmed-empty-token guard. +func TestF1_ResourceCreds_EmptyToken(t *testing.T) { + err := runResourceCredentials(" ") + if err == nil || !strings.Contains(err.Error(), "token is required") { + t.Errorf("expected token-required error, got %v", err) + } +} + +// TestF1_ResourceCreds_ParseError drives the malformed-body branch. +func TestF1_ResourceCreds_ParseError(t *testing.T) { + operateServer(t, true, func(w http.ResponseWriter, r *http.Request) { + _, _ = fmt.Fprint(w, `{not-json`) + }) + var err error + _, _ = captureStdout(t, func() { _, _, err = run("resource", "creds", "tok-1") }) + if err == nil || !strings.Contains(err.Error(), "parsing response") { + t.Errorf("expected parse error, got %v", err) + } +} + +// TestF1_ResourceCreds_ServerError asserts a non-2xx surfaces the envelope. +func TestF1_ResourceCreds_ServerError(t *testing.T) { + operateServer(t, true, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, _ = fmt.Fprint(w, `{"ok":false,"error":"no_connection_url","message":"This resource does not have a connection URL"}`) + }) + var err error + _, _ = captureStdout(t, func() { _, _, err = run("resource", "creds", "tok-1") }) + if err == nil || !strings.Contains(err.Error(), "does not have a connection URL") { + t.Errorf("expected 400 envelope surfaced, got %v", err) + } +} + +// ── F2: webhook new prints receive_url ─────────────────────────────────────── + +// TestF2_WebhookNew_PrintsReceiveURL is the headline F2 regression: a webhook +// provision returns receive_url (NOT connection_url), and the human print path +// must show it instead of a blank `url` line. +func TestF2_WebhookNew_PrintsReceiveURL(t *testing.T) { + c := newITContext(t) + resetProvisionFlags() + out, _ := c.provisionViaCLI("webhook", "app-hook") + + // The mock mints receive_url = https://hooks.instanode.dev/. + if !strings.Contains(out, "https://hooks.instanode.dev/") { + t.Errorf("webhook new must print the receive URL, got %q", out) + } + // Regression pin: the url line must NOT be blank. + for _, line := range strings.Split(out, "\n") { + if strings.HasPrefix(line, "url") && strings.TrimSpace(strings.TrimPrefix(line, "url")) == "" { + t.Errorf("webhook new printed a BLANK url line: %q", out) + } + } +} + +// TestF2_WebhookNew_JSONHasReceiveURL verifies the #32 --json provision output +// already carries receive_url for webhooks (rule 12: verify, don't assume). +func TestF2_WebhookNew_JSONHasReceiveURL(t *testing.T) { + c := newITContext(t) + resetProvisionFlags() + + var token string + stdout, _ := captureStdout(t, func() { + _, _, err := run("webhook", "new", "--name", "json-hook", "--json") + if err != nil { + t.Fatalf("webhook new --json: %v", err) + } + }) + var out provisionJSONOutput + if err := json.Unmarshal([]byte(stdout), &out); err != nil { + t.Fatalf("webhook new --json output is not JSON: %v\n%q", err, stdout) + } + if out.ReceiveURL == "" { + t.Errorf("webhook new --json must include receive_url, got %+v", out) + } + if out.ConnectionURL != "" { + t.Errorf("webhook new --json must NOT carry a connection_url, got %q", out.ConnectionURL) + } + token = out.Token + t.Cleanup(func() { c.deleteResource(token) }) +} + +// ── F3: 401 with INSTANT_TOKEN set gives env-aware advice ──────────────────── + +// TestF3_SessionExpired_EnvTokenAdvice asserts that when the rejected token +// came from INSTANT_TOKEN, the message tells the user to fix/unset +// INSTANT_TOKEN (which shadows any saved login) instead of `instant login`. +func TestF3_SessionExpired_EnvTokenAdvice(t *testing.T) { + operateServer(t, false, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + }) + // INSTANT_TOKEN is the token source here (operateServer(false) cleared any + // saved login). initConfig fires on Execute and wires it via authTransport. + t.Setenv("INSTANT_TOKEN", "inst_bogus_env_token") + + _, _, err := run("resource", "creds", "tok-1") + if err == nil || !strings.Contains(err.Error(), "session expired") { + t.Fatalf("expected session-expired on 401-with-env-token, got %v", err) + } + if !strings.Contains(err.Error(), "INSTANT_TOKEN") { + t.Errorf("env-token 401 must advise fixing/unsetting INSTANT_TOKEN, got %q", err.Error()) + } + if strings.Contains(err.Error(), "run `instant login`") { + t.Errorf("env-token 401 must NOT advise `instant login` (shadowed), got %q", err.Error()) + } + if ExitCodeFor(err) != ExitSessionExpired { + t.Errorf("exit code = %d, want %d", ExitCodeFor(err), ExitSessionExpired) + } +} + +// TestF3_SessionExpired_SavedLoginAdvice asserts the ORIGINAL `instant login` +// advice still fires when the rejected token came from a saved login (no env). +func TestF3_SessionExpired_SavedLoginAdvice(t *testing.T) { + operateServer(t, true, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + }) + _, _, err := run("resource", "creds", "tok-1") + if err == nil || !strings.Contains(err.Error(), "session expired") { + t.Fatalf("expected session-expired on 401-with-saved-login, got %v", err) + } + if !strings.Contains(err.Error(), "run `instant login`") { + t.Errorf("saved-login 401 must advise `instant login`, got %q", err.Error()) + } + if strings.Contains(err.Error(), "INSTANT_TOKEN is set") { + t.Errorf("saved-login 401 must NOT mention INSTANT_TOKEN shadowing, got %q", err.Error()) + } +} + +// TestF3_SessionExpired_EnvTokenJSONAction asserts the --json envelope's +// agent_action branches on the env-token source too. +func TestF3_SessionExpired_EnvTokenJSONAction(t *testing.T) { + operateServer(t, false, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + }) + resetJSONFlags() // clear any --json toggle leaked by a prior test + t.Setenv("INSTANT_TOKEN", "inst_bogus_env_token") + + stdout, _ := captureStdout(t, func() { + _, _, _ = run("resources", "--json") + }) + var env jsonErrorEnvelope + if err := json.Unmarshal([]byte(stdout), &env); err != nil { + t.Fatalf("--json error must be a JSON envelope: %v\n%q", err, stdout) + } + if env.Error != "session_expired" { + t.Errorf("error code = %q, want session_expired", env.Error) + } + if !strings.Contains(env.AgentAction, "INSTANT_TOKEN") { + t.Errorf("env-token agent_action must mention INSTANT_TOKEN, got %q", env.AgentAction) + } +} + +// TestF3_AuthFromEnvToken_FlagOverridesEnv asserts a --token flag wins over +// INSTANT_TOKEN, so authFromEnvToken() reports false (the flag, not env, is the +// source). +func TestF3_AuthFromEnvToken_FlagOverridesEnv(t *testing.T) { + t.Setenv("INSTANT_TOKEN", "env-tok") + prev := adHocToken + adHocToken = "flag-tok" + t.Cleanup(func() { adHocToken = prev }) + if authFromEnvToken() { + t.Error("authFromEnvToken must be false when --token overrides INSTANT_TOKEN") + } +} + +// ── F4: resources table prints the full token ──────────────────────────────── + +// TestF4_ResourcesTable_FullToken asserts the `instant resources` table prints +// the FULL token (the exact argument every other command needs), never the old +// `…` truncation. +func TestF4_ResourcesTable_FullToken(t *testing.T) { + const fullToken = "d3cef90f-a75e-4c1c-9b2e-0123456789ab" + operateServer(t, true, func(w http.ResponseWriter, r *http.Request) { + _, _ = fmt.Fprintf(w, `{"ok":true,"total":1,"items":[ + {"token":%q,"resource_type":"postgres","name":"app-db","tier":"hobby","status":"active"} + ]}`, fullToken) + }) + resetJSONFlags() // clear any --json toggle leaked by a prior test + stdout, _ := captureStdout(t, func() { + _, _, err := run("resources") + if err != nil { + t.Fatalf("resources: %v", err) + } + }) + if !strings.Contains(stdout, fullToken) { + t.Errorf("resources table must print the FULL token %q, got %q", fullToken, stdout) + } + if strings.Contains(stdout, fullToken[:12]+"…") { + t.Errorf("resources table must NOT truncate the token, got %q", stdout) + } +} + +// TestF4_ResourcesTable_TruncatesLongName asserts a long NAME is truncated with +// an ellipsis (the column we trade off so the token stays full) while a short +// name renders verbatim and an empty name renders "-". +func TestF4_ResourcesTable_TruncatesLongName(t *testing.T) { + longName := strings.Repeat("x", nameDisplayMaxLen+5) + operateServer(t, true, func(w http.ResponseWriter, r *http.Request) { + _, _ = fmt.Fprintf(w, `{"ok":true,"total":3,"items":[ + {"token":"tok-long","resource_type":"postgres","name":%q,"tier":"hobby","status":"active"}, + {"token":"tok-short","resource_type":"redis","name":"short","tier":"hobby","status":"active"}, + {"token":"tok-empty","resource_type":"webhook","name":"","tier":"anonymous","status":"active"} + ]}`, longName) + }) + resetJSONFlags() // clear any --json toggle leaked by a prior test + stdout, _ := captureStdout(t, func() { + _, _, err := run("resources") + if err != nil { + t.Fatalf("resources: %v", err) + } + }) + if !strings.Contains(stdout, "…") { + t.Errorf("long name must be truncated with an ellipsis, got %q", stdout) + } + if strings.Contains(stdout, longName) { + t.Errorf("the full long name must NOT appear, got %q", stdout) + } + if !strings.Contains(stdout, "short") { + t.Errorf("a short name must render verbatim, got %q", stdout) + } + // Each full token must survive. + for _, tok := range []string{"tok-long", "tok-short", "tok-empty"} { + if !strings.Contains(stdout, tok) { + t.Errorf("token %q must print in full, got %q", tok, stdout) + } + } +} + +// TestF4_TruncateName_Unit drives truncateName directly across its branches — +// empty, within-cap, over-cap, and a multibyte name (rune-boundary safety). +func TestF4_TruncateName_Unit(t *testing.T) { + cases := []struct { + in, want string + }{ + {"", "-"}, + {"app-db", "app-db"}, + {strings.Repeat("a", nameDisplayMaxLen), strings.Repeat("a", nameDisplayMaxLen)}, + {strings.Repeat("a", nameDisplayMaxLen+1), strings.Repeat("a", nameDisplayMaxLen) + "…"}, + {strings.Repeat("é", nameDisplayMaxLen+2), strings.Repeat("é", nameDisplayMaxLen) + "…"}, + } + for _, tc := range cases { + if got := truncateName(tc.in); got != tc.want { + t.Errorf("truncateName(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} diff --git a/cmd/coverage_push95_test.go b/cmd/coverage_push95_test.go index 9341acb..4a3119f 100644 --- a/cmd/coverage_push95_test.go +++ b/cmd/coverage_push95_test.go @@ -149,10 +149,10 @@ func TestClassifyError_GenericURLError(t *testing.T) { } func TestClassifyError_SessionExpired(t *testing.T) { - // errSessionExpired returns an *ExitCodeError with ExitAuthRequired, - // so classifyError catches it in the auth_required branch first. To - // reach the lowercase-contains("session expired") branch we need a - // plain error whose message contains the phrase. + // A plain (non-ExitCodeError) error carrying the phrase reaches the + // default-arm "session expired" classifier. (errSessionExpired's + // ExitCodeError variant is now handled inside the ExitAuthRequired arm + // after F3 — see TestClassifyError_AllBranches.) err := errors.New("oops: session expired token") c, _, _ := classifyError(err) if c != "session_expired" { diff --git a/cmd/coverage_units_test.go b/cmd/coverage_units_test.go index 1129fb9..a0bb67a 100644 --- a/cmd/coverage_units_test.go +++ b/cmd/coverage_units_test.go @@ -178,13 +178,14 @@ func TestClassifyError_AllBranches(t *testing.T) { if c, _, _ := classifyError(errResourceFailed(errors.New("x"))); c != "resource_failed" { t.Errorf("resource -> %q", c) } - // errSessionExpired is an *ExitCodeError with Code==ExitAuthRequired, so it - // classifies as auth_required (the switch matches the code before the - // message phrase). The dedicated "session_expired" branch is only reached - // for a *plain* error whose message contains the phrase. - if c, _, _ := classifyError(errSessionExpired()); c != "auth_required" { + // errSessionExpired is an *ExitCodeError with Code==ExitAuthRequired, but + // F3 now distinguishes a rejected-token "session expired" from a genuine + // "never authenticated" inside the ExitAuthRequired arm — so it classifies + // as session_expired (accurate code) rather than the old auth_required wart. + if c, _, _ := classifyError(errSessionExpired()); c != "session_expired" { t.Errorf("session-as-exitcode -> %q", c) } + // A plain error carrying the phrase still reaches the default-arm branch. if c, _, _ := classifyError(errors.New("the session expired, sorry")); c != "session_expired" { t.Errorf("session phrase -> %q", c) } diff --git a/cmd/discover.go b/cmd/discover.go index e80c387..7acdc54 100644 --- a/cmd/discover.go +++ b/cmd/discover.go @@ -172,19 +172,17 @@ func runResources(cmd *cobra.Command) error { return nil } + // F4: print the FULL token — it is the exact argument every other command + // (`instant resource `, `… creds`, `… delete`, …) needs, so a + // truncated `d3cef90f-a75…` made the default list view un-copyable. The + // NAME column is truncated instead when it's long, since a name is + // human-facing and rarely the value being copied verbatim. w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) _, _ = fmt.Fprintln(w, "TOKEN\tTYPE\tNAME\tTIER\tSTATUS") for _, r := range result.Items { - shortToken := r.Token - if len(shortToken) > 12 { - shortToken = shortToken[:12] + "…" - } - name := r.Name - if name == "" { - name = "-" - } + name := truncateName(r.Name) _, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", - shortToken, r.ResourceType, name, r.Tier, r.Status) + r.Token, r.ResourceType, name, r.Tier, r.Status) } _ = w.Flush() return nil @@ -247,6 +245,27 @@ func matchResourceFilters(filters map[string]string, rType, env, status, tier, n return true } +// nameDisplayMaxLen caps the NAME column in the `instant resources` table. +// F4: the token now prints in full (it is the copy-paste argument every other +// command needs); the human-facing NAME is the column we truncate when width +// is tight. A trailing ellipsis signals truncation. "" renders as "-". +const nameDisplayMaxLen = 24 + +// truncateName renders a resource name for the table: "-" when empty, the +// name unchanged when within nameDisplayMaxLen, else a one-ellipsis truncation. +func truncateName(name string) string { + if name == "" { + return "-" + } + // Count runes (not bytes) so a multibyte name truncates on a character + // boundary and the column width math stays correct. + runes := []rune(name) + if len(runes) <= nameDisplayMaxLen { + return name + } + return string(runes[:nameDisplayMaxLen]) + "…" +} + // lower / eqFold — tiny strings helpers kept local so this file does not // re-import strings (the runResources path already does, but the filter // helpers stay testable in isolation). diff --git a/cmd/errors.go b/cmd/errors.go index 2fe12d4..3016bfc 100644 --- a/cmd/errors.go +++ b/cmd/errors.go @@ -99,12 +99,26 @@ func errAuthRequired(detail string) error { // a bearer token. Tests assert on this exact wording so the contract is // stable for downstream agents. // +// F3: the advice branches on token SOURCE. When the active token came from +// INSTANT_TOKEN (env), `instant login` is useless guidance — the env var +// SHADOWS any saved login, so re-logging-in changes nothing until +// INSTANT_TOKEN is fixed or unset. In that case we tell the user to fix/unset +// the env var instead. A flag/saved-login token keeps the original `instant +// login` guidance. +// // IMPORTANT: keep the literal phrase "session expired" in the message — the -// hermetic suite (and the project's "shipped ≠ verified" rules) grep for it. +// hermetic suite, json_error.go's session_expired classifier, and the +// project's "shipped ≠ verified" rules all grep for it. Both branches retain +// it; only the trailing actionable clause differs. func errSessionExpired() error { + msg := "session expired — run `instant login` to re-authenticate" + if authFromEnvToken() { + msg = "session expired — INSTANT_TOKEN is set but the server rejected it; " + + "fix or unset INSTANT_TOKEN (it shadows any saved `instant login`)" + } return &ExitCodeError{ Code: ExitSessionExpired, - Err: errors.New("session expired — run `instant login` to re-authenticate"), + Err: errors.New(msg), } } diff --git a/cmd/extras.go b/cmd/extras.go index 755d1df..2d6d337 100644 --- a/cmd/extras.go +++ b/cmd/extras.go @@ -71,6 +71,10 @@ var resourceCmd = &cobra.Command{ Long: `Show, delete, or operate on a single resource by token. instant resource Print the resource's metadata + connection URL + instant resource creds Re-fetch the connection URL alone + (alias: credentials; GET …/credentials). + Recovers the URL after a provision that + timed out client-side before printing it. instant resource delete Tear down the resource. Requires --yes (or an interactive 'y' confirmation) to actually delete — printing nothing @@ -96,6 +100,16 @@ listed in 'instant resources'.`, return wrapJSONErr(cmd, fmt.Errorf("instant resource delete: token argument is required")) } return wrapJSONErr(cmd, runResourceDelete(cmd, args[1])) + case "creds", "credentials": + // F1 — re-fetch a resource's connection URL (GET …/credentials). + // Closes the broken first-provision recovery loop: `db new` + // frequently hits the 60s client timeout and the connection URL + // is otherwise lost forever (it's only printed by `new`). Handler + // lives in operate.go alongside the other GET-by-token verbs. + if len(args) < 2 { + return wrapJSONErr(cmd, fmt.Errorf("instant resource %s: token argument is required", verb)) + } + return wrapJSONErr(cmd, runResourceCredentials(args[1])) case "pause", "resume", "rotate", "backup", "backups": // Wave-2 A4 operate verbs — handlers live in operate.go. if len(args) < 2 { diff --git a/cmd/json_error.go b/cmd/json_error.go index d91f384..ea262d5 100644 --- a/cmd/json_error.go +++ b/cmd/json_error.go @@ -98,6 +98,14 @@ func classifyError(err error) (code, message, agentAction string) { if errors.As(err, &ec) { switch ec.Code { case ExitAuthRequired: + // F3: errSessionExpired() carries ExitSessionExpired (== ExitAuthRequired), + // so a 401 lands here. Distinguish a rejected-token "session expired" from + // a genuine "never authenticated" so the code + agent_action are accurate, + // and branch the advice on token SOURCE (an INSTANT_TOKEN-sourced reject is + // not fixed by `instant login` — the env var shadows it). + if strings.Contains(strings.ToLower(msg), "session expired") { + return "session_expired", msg, sessionExpiredAction() + } return "auth_required", msg, "run `instant login`, or set INSTANT_TOKEN to a Personal Access Token" case ExitResourceFailed: @@ -131,13 +139,26 @@ func classifyError(err error) (code, message, agentAction string) { } // Default: surface the raw message; agents read .error code regardless. + // (Reached for the plain-error errSessionExpiredSentinel path — the + // ExitCodeError variant is handled in the switch above.) if strings.Contains(strings.ToLower(msg), "session expired") { - return "session_expired", msg, - "run `instant login` to re-authenticate" + return "session_expired", msg, sessionExpiredAction() } return "cli_error", msg, "" } +// sessionExpiredAction returns the agent_action for a rejected-token (401) +// error, branched on token SOURCE (F3). An INSTANT_TOKEN-sourced reject is not +// fixed by `instant login` — the env var shadows any saved login — so advise +// fixing/unsetting it instead. Mirrors errSessionExpired()'s human-message +// branch so the JSON envelope and the text path stay consistent. +func sessionExpiredAction() string { + if authFromEnvToken() { + return "fix or unset INSTANT_TOKEN — it shadows any saved `instant login`" + } + return "run `instant login` to re-authenticate" +} + // wrapJSONErr emits a JSON error envelope on stdout when --json is on, and // returns the same error to the caller so cobra's exit-code path still fires // (with usage printing silenced). When --json is OFF, returns err unchanged diff --git a/cmd/monitor.go b/cmd/monitor.go index 3169a97..80c982e 100644 --- a/cmd/monitor.go +++ b/cmd/monitor.go @@ -216,7 +216,16 @@ func makeProvisionCmd(endpoint, resourceType string) func(*cobra.Command, []stri } fmt.Printf("ok %-8s %s\n", resourceType, creds.Token) - fmt.Printf("url %s\n", creds.ConnectionURL) + // F2: /webhook/new returns receive_url (NOT connection_url), so a + // bare creds.ConnectionURL printed `url ` (blank). Fall back to + // ReceiveURL like the local token-store code above already does, so + // webhook provisions show their real receiver URL. (--json already + // emits both fields via emitProvisionJSON.) + provisionURL := creds.ConnectionURL + if provisionURL == "" { + provisionURL = creds.ReceiveURL + } + fmt.Printf("url %s\n", provisionURL) if creds.Tier != "" { fmt.Printf("tier %s\n", creds.Tier) } diff --git a/cmd/operate.go b/cmd/operate.go index 74fc823..cce4077 100644 --- a/cmd/operate.go +++ b/cmd/operate.go @@ -67,6 +67,7 @@ const ( resourceRotateSuffix = "/rotate-credentials" // POST rotate password resourceBackupSuffix = "/backup" // POST ad-hoc backup (tier-gated) resourceBackupsList = "/backups" // GET list backups + resourceCredsSuffix = "/credentials" // GET re-fetch connection URL (no rotation) ) // operateJSON is the shared --json toggle for every operate-verb command. @@ -587,6 +588,68 @@ func runResourceOperate(verb, token string) error { return nil } +// ── resource creds (re-fetch the connection URL) ───────────────────────────── + +// resourceCredentialsResult mirrors GET /api/v1/resources/:id/credentials +// (api/internal/handlers/resource.go GetCredentials). The real endpoint +// returns connection_url plus the resource identity; ReceiveURL is decoded +// too so a webhook-shaped response (receiver URL) still renders. +type resourceCredentialsResult struct { + OK bool `json:"ok"` + ID string `json:"id"` + Token string `json:"token"` + ResourceType string `json:"resource_type"` + Env string `json:"env"` + ConnectionURL string `json:"connection_url"` + ReceiveURL string `json:"receive_url"` +} + +// runResourceCredentials re-fetches a resource's connection URL by token — +// the recovery path for a provision whose `new` call hit the 60s client +// timeout before printing the URL (the URL is otherwise unrecoverable). GETs +// /api/v1/resources/:token/credentials and prints the connection_url (or the +// webhook receive_url fallback); --json emits the full structured response. +func runResourceCredentials(token string) error { + token = strings.TrimSpace(token) + if token == "" { + return fmt.Errorf("token is required") + } + url := fmt.Sprintf("%s%s/%s%s", APIBaseURL, resourcesBasePath, + neturl.PathEscape(token), resourceCredsSuffix) + raw, err := doOperate(http.MethodGet, url, nil, true) + if err != nil { + return err + } + var res resourceCredentialsResult + if err := json.Unmarshal(raw, &res); err != nil { + return fmt.Errorf("parsing response: %w", err) + } + if resourceDetailJSON || operateJSON { + return emitJSON(res) + } + // Webhooks have no connection_url — fall back to receive_url so the + // receiver URL still surfaces (mirrors the provision + detail paths). + connURL := res.ConnectionURL + if connURL == "" { + connURL = res.ReceiveURL + } + tok := res.Token + if tok == "" { + tok = token + } + fmt.Printf("ok creds %s\n", tok) + if connURL != "" { + fmt.Printf("url %s\n", connURL) + } + if res.ResourceType != "" { + fmt.Printf("type %s\n", res.ResourceType) + } + if res.Env != "" { + fmt.Printf("env %s\n", res.Env) + } + return nil +} + // backupRow is one row from GET /api/v1/resources/:token/backups. type backupRow struct { BackupID string `json:"backup_id"` diff --git a/cmd/root.go b/cmd/root.go index ba9314e..ec599ed 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -115,6 +115,7 @@ Examples: instant vector new --name app-vec Provision a Postgres+pgvector resource instant resources List your provisioned resources (requires login) instant resource Show detail for a single resource by token + instant resource creds Re-fetch a resource's connection URL by token instant resource delete Delete a resource (use --yes to skip confirm) instant resource pause Suspend a resource without deleting it (Pro+) instant resource resume Un-pause a suspended resource (Pro+) diff --git a/cmd/testapi_test.go b/cmd/testapi_test.go index d31fa24..2f3da1b 100644 --- a/cmd/testapi_test.go +++ b/cmd/testapi_test.go @@ -513,16 +513,24 @@ func (m *mockAPI) handleCredentials(w http.ResponseWriter, token string) { writeJSON(w, http.StatusNotFound, map[string]any{"ok": false, "error": "not found"}) return } - if res.ConnectionURL == "" { - // Webhooks have no connection_url — mirror the real API. - writeJSON(w, http.StatusNotFound, map[string]any{ - "ok": false, "error": "resource has no connection_url", - }) - return + // Mirror the live GetCredentials shape (api/internal/handlers/resource.go): + // ok + id + token + resource_type + env + connection_url. A webhook has no + // connection_url — the real API 400s, but we surface receive_url so the + // `instant resource creds` webhook fallback (F1/F2) is exercisable. + out := map[string]any{ + "ok": true, + "id": res.ID, + "token": res.Token, + "resource_type": res.ResourceType, + "env": res.Env, } - writeJSON(w, http.StatusOK, map[string]any{ - "ok": true, "connection_url": res.ConnectionURL, - }) + if res.ConnectionURL != "" { + out["connection_url"] = res.ConnectionURL + } + if res.ReceiveURL != "" { + out["receive_url"] = res.ReceiveURL + } + writeJSON(w, http.StatusOK, out) } // handleDetail mirrors GET /api/v1/resources/:token from the real API. diff --git a/cmd/up.go b/cmd/up.go index 363b5ad..2539d96 100644 --- a/cmd/up.go +++ b/cmd/up.go @@ -373,6 +373,23 @@ func haveAuth() bool { return strings.TrimSpace(os.Getenv("INSTANT_TOKEN")) != "" } +// authFromEnvToken reports whether the bearer token in play came from the +// INSTANT_TOKEN env var (and NOT from a --token flag or a saved `instant +// login`). Precedence mirrors initConfig: --token > INSTANT_TOKEN > saved +// login — so the env is the source only when --token is empty AND +// INSTANT_TOKEN is set. +// +// F3: a 401 against an INSTANT_TOKEN-sourced token must NOT advise `instant +// login` — the env var SHADOWS any saved login, so re-logging-in changes +// nothing until INSTANT_TOKEN is fixed or unset. errSessionExpired branches +// its guidance on this. +func authFromEnvToken() bool { + if strings.TrimSpace(adHocToken) != "" { + return false + } + return strings.TrimSpace(os.Getenv("INSTANT_TOKEN")) != "" +} + // errSessionExpiredSentinel is a private marker error returned by the // fetch helpers when the server returned 401 to an authenticated request. // Callers translate this into the user-facing errSessionExpired() so the