Skip to content

Commit 6c6a5df

Browse files
fix(cli): creds-recovery verb, webhook URL print, env-token 401 advice, full token in list (#33)
Four agent-DX follow-ups found in a live cohort dogfood (round 2, building on #32): F1 (P0): add `instant resource creds <token>` (alias `credentials`) — GETs /api/v1/resources/:id/credentials and prints the connection_url (webhook receive_url fallback; --json for the full response). Closes the broken first-provision recovery loop: `db new` frequently hits the 60s client timeout and the URL — printed only by `new` — was otherwise lost forever. Path fragment is the named const resourceCredsSuffix. F2 (P1): `webhook new` printed a blank `url` line — the human path read creds.ConnectionURL only, but /webhook/new returns receive_url. Fall back to ReceiveURL like the local token-store code already does. (--json already emits receive_url via emitProvisionJSON; verified.) F3 (P1): a 401 with INSTANT_TOKEN set advised `instant login`, which is useless — the env var SHADOWS any saved login. errSessionExpired() + classifyError() now branch on token SOURCE (new authFromEnvToken helper): an env-sourced reject tells the user to fix/unset INSTANT_TOKEN. This also fixes the dead session_expired JSON code (a 401 was mis-coded auth_required because errSessionExpired carries ExitAuthRequired). F4 (P2): `instant resources` truncated the token (`d3cef90f-a75…`) — the exact value every other command needs as an argument, so it was un-copyable. Print the FULL token; truncate the NAME column instead (new truncateName + nameDisplayMaxLen). --json unchanged. Tests: cmd/agent_dx_followups_test.go covers all four (creds happy/alias/json/ webhook-fallback/unauth/missing-token/empty/parse/server-error + token fallback; webhook receive_url human+json; env-vs-saved 401 advice + json action + flag-over-env; full-token + name-truncation + truncateName unit). 100%-patch on the diff; make ci green (build+vet+race+lint). Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent f98d9b1 commit 6c6a5df

12 files changed

Lines changed: 609 additions & 32 deletions

cmd/agent_dx_followups_test.go

Lines changed: 410 additions & 0 deletions
Large diffs are not rendered by default.

cmd/coverage_push95_test.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -149,10 +149,10 @@ func TestClassifyError_GenericURLError(t *testing.T) {
149149
}
150150

151151
func TestClassifyError_SessionExpired(t *testing.T) {
152-
// errSessionExpired returns an *ExitCodeError with ExitAuthRequired,
153-
// so classifyError catches it in the auth_required branch first. To
154-
// reach the lowercase-contains("session expired") branch we need a
155-
// plain error whose message contains the phrase.
152+
// A plain (non-ExitCodeError) error carrying the phrase reaches the
153+
// default-arm "session expired" classifier. (errSessionExpired's
154+
// ExitCodeError variant is now handled inside the ExitAuthRequired arm
155+
// after F3 — see TestClassifyError_AllBranches.)
156156
err := errors.New("oops: session expired token")
157157
c, _, _ := classifyError(err)
158158
if c != "session_expired" {

cmd/coverage_units_test.go

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -178,13 +178,14 @@ func TestClassifyError_AllBranches(t *testing.T) {
178178
if c, _, _ := classifyError(errResourceFailed(errors.New("x"))); c != "resource_failed" {
179179
t.Errorf("resource -> %q", c)
180180
}
181-
// errSessionExpired is an *ExitCodeError with Code==ExitAuthRequired, so it
182-
// classifies as auth_required (the switch matches the code before the
183-
// message phrase). The dedicated "session_expired" branch is only reached
184-
// for a *plain* error whose message contains the phrase.
185-
if c, _, _ := classifyError(errSessionExpired()); c != "auth_required" {
181+
// errSessionExpired is an *ExitCodeError with Code==ExitAuthRequired, but
182+
// F3 now distinguishes a rejected-token "session expired" from a genuine
183+
// "never authenticated" inside the ExitAuthRequired arm — so it classifies
184+
// as session_expired (accurate code) rather than the old auth_required wart.
185+
if c, _, _ := classifyError(errSessionExpired()); c != "session_expired" {
186186
t.Errorf("session-as-exitcode -> %q", c)
187187
}
188+
// A plain error carrying the phrase still reaches the default-arm branch.
188189
if c, _, _ := classifyError(errors.New("the session expired, sorry")); c != "session_expired" {
189190
t.Errorf("session phrase -> %q", c)
190191
}

cmd/discover.go

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -172,19 +172,17 @@ func runResources(cmd *cobra.Command) error {
172172
return nil
173173
}
174174

175+
// F4: print the FULL token — it is the exact argument every other command
176+
// (`instant resource <token>`, `… creds`, `… delete`, …) needs, so a
177+
// truncated `d3cef90f-a75…` made the default list view un-copyable. The
178+
// NAME column is truncated instead when it's long, since a name is
179+
// human-facing and rarely the value being copied verbatim.
175180
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
176181
_, _ = fmt.Fprintln(w, "TOKEN\tTYPE\tNAME\tTIER\tSTATUS")
177182
for _, r := range result.Items {
178-
shortToken := r.Token
179-
if len(shortToken) > 12 {
180-
shortToken = shortToken[:12] + "…"
181-
}
182-
name := r.Name
183-
if name == "" {
184-
name = "-"
185-
}
183+
name := truncateName(r.Name)
186184
_, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n",
187-
shortToken, r.ResourceType, name, r.Tier, r.Status)
185+
r.Token, r.ResourceType, name, r.Tier, r.Status)
188186
}
189187
_ = w.Flush()
190188
return nil
@@ -247,6 +245,27 @@ func matchResourceFilters(filters map[string]string, rType, env, status, tier, n
247245
return true
248246
}
249247

248+
// nameDisplayMaxLen caps the NAME column in the `instant resources` table.
249+
// F4: the token now prints in full (it is the copy-paste argument every other
250+
// command needs); the human-facing NAME is the column we truncate when width
251+
// is tight. A trailing ellipsis signals truncation. "" renders as "-".
252+
const nameDisplayMaxLen = 24
253+
254+
// truncateName renders a resource name for the table: "-" when empty, the
255+
// name unchanged when within nameDisplayMaxLen, else a one-ellipsis truncation.
256+
func truncateName(name string) string {
257+
if name == "" {
258+
return "-"
259+
}
260+
// Count runes (not bytes) so a multibyte name truncates on a character
261+
// boundary and the column width math stays correct.
262+
runes := []rune(name)
263+
if len(runes) <= nameDisplayMaxLen {
264+
return name
265+
}
266+
return string(runes[:nameDisplayMaxLen]) + "…"
267+
}
268+
250269
// lower / eqFold — tiny strings helpers kept local so this file does not
251270
// re-import strings (the runResources path already does, but the filter
252271
// helpers stay testable in isolation).

cmd/errors.go

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,12 +99,26 @@ func errAuthRequired(detail string) error {
9999
// a bearer token. Tests assert on this exact wording so the contract is
100100
// stable for downstream agents.
101101
//
102+
// F3: the advice branches on token SOURCE. When the active token came from
103+
// INSTANT_TOKEN (env), `instant login` is useless guidance — the env var
104+
// SHADOWS any saved login, so re-logging-in changes nothing until
105+
// INSTANT_TOKEN is fixed or unset. In that case we tell the user to fix/unset
106+
// the env var instead. A flag/saved-login token keeps the original `instant
107+
// login` guidance.
108+
//
102109
// IMPORTANT: keep the literal phrase "session expired" in the message — the
103-
// hermetic suite (and the project's "shipped ≠ verified" rules) grep for it.
110+
// hermetic suite, json_error.go's session_expired classifier, and the
111+
// project's "shipped ≠ verified" rules all grep for it. Both branches retain
112+
// it; only the trailing actionable clause differs.
104113
func errSessionExpired() error {
114+
msg := "session expired — run `instant login` to re-authenticate"
115+
if authFromEnvToken() {
116+
msg = "session expired — INSTANT_TOKEN is set but the server rejected it; " +
117+
"fix or unset INSTANT_TOKEN (it shadows any saved `instant login`)"
118+
}
105119
return &ExitCodeError{
106120
Code: ExitSessionExpired,
107-
Err: errors.New("session expired — run `instant login` to re-authenticate"),
121+
Err: errors.New(msg),
108122
}
109123
}
110124

cmd/extras.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,10 @@ var resourceCmd = &cobra.Command{
7171
Long: `Show, delete, or operate on a single resource by token.
7272
7373
instant resource <token> Print the resource's metadata + connection URL
74+
instant resource creds <token> Re-fetch the connection URL alone
75+
(alias: credentials; GET …/credentials).
76+
Recovers the URL after a provision that
77+
timed out client-side before printing it.
7478
instant resource delete <token> Tear down the resource. Requires --yes
7579
(or an interactive 'y' confirmation) to
7680
actually delete — printing nothing
@@ -96,6 +100,16 @@ listed in 'instant resources'.`,
96100
return wrapJSONErr(cmd, fmt.Errorf("instant resource delete: token argument is required"))
97101
}
98102
return wrapJSONErr(cmd, runResourceDelete(cmd, args[1]))
103+
case "creds", "credentials":
104+
// F1 — re-fetch a resource's connection URL (GET …/credentials).
105+
// Closes the broken first-provision recovery loop: `db new`
106+
// frequently hits the 60s client timeout and the connection URL
107+
// is otherwise lost forever (it's only printed by `new`). Handler
108+
// lives in operate.go alongside the other GET-by-token verbs.
109+
if len(args) < 2 {
110+
return wrapJSONErr(cmd, fmt.Errorf("instant resource %s: token argument is required", verb))
111+
}
112+
return wrapJSONErr(cmd, runResourceCredentials(args[1]))
99113
case "pause", "resume", "rotate", "backup", "backups":
100114
// Wave-2 A4 operate verbs — handlers live in operate.go.
101115
if len(args) < 2 {

cmd/json_error.go

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,14 @@ func classifyError(err error) (code, message, agentAction string) {
9898
if errors.As(err, &ec) {
9999
switch ec.Code {
100100
case ExitAuthRequired:
101+
// F3: errSessionExpired() carries ExitSessionExpired (== ExitAuthRequired),
102+
// so a 401 lands here. Distinguish a rejected-token "session expired" from
103+
// a genuine "never authenticated" so the code + agent_action are accurate,
104+
// and branch the advice on token SOURCE (an INSTANT_TOKEN-sourced reject is
105+
// not fixed by `instant login` — the env var shadows it).
106+
if strings.Contains(strings.ToLower(msg), "session expired") {
107+
return "session_expired", msg, sessionExpiredAction()
108+
}
101109
return "auth_required", msg,
102110
"run `instant login`, or set INSTANT_TOKEN to a Personal Access Token"
103111
case ExitResourceFailed:
@@ -131,13 +139,26 @@ func classifyError(err error) (code, message, agentAction string) {
131139
}
132140

133141
// Default: surface the raw message; agents read .error code regardless.
142+
// (Reached for the plain-error errSessionExpiredSentinel path — the
143+
// ExitCodeError variant is handled in the switch above.)
134144
if strings.Contains(strings.ToLower(msg), "session expired") {
135-
return "session_expired", msg,
136-
"run `instant login` to re-authenticate"
145+
return "session_expired", msg, sessionExpiredAction()
137146
}
138147
return "cli_error", msg, ""
139148
}
140149

150+
// sessionExpiredAction returns the agent_action for a rejected-token (401)
151+
// error, branched on token SOURCE (F3). An INSTANT_TOKEN-sourced reject is not
152+
// fixed by `instant login` — the env var shadows any saved login — so advise
153+
// fixing/unsetting it instead. Mirrors errSessionExpired()'s human-message
154+
// branch so the JSON envelope and the text path stay consistent.
155+
func sessionExpiredAction() string {
156+
if authFromEnvToken() {
157+
return "fix or unset INSTANT_TOKEN — it shadows any saved `instant login`"
158+
}
159+
return "run `instant login` to re-authenticate"
160+
}
161+
141162
// wrapJSONErr emits a JSON error envelope on stdout when --json is on, and
142163
// returns the same error to the caller so cobra's exit-code path still fires
143164
// (with usage printing silenced). When --json is OFF, returns err unchanged

cmd/monitor.go

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -216,7 +216,16 @@ func makeProvisionCmd(endpoint, resourceType string) func(*cobra.Command, []stri
216216
}
217217

218218
fmt.Printf("ok %-8s %s\n", resourceType, creds.Token)
219-
fmt.Printf("url %s\n", creds.ConnectionURL)
219+
// F2: /webhook/new returns receive_url (NOT connection_url), so a
220+
// bare creds.ConnectionURL printed `url ` (blank). Fall back to
221+
// ReceiveURL like the local token-store code above already does, so
222+
// webhook provisions show their real receiver URL. (--json already
223+
// emits both fields via emitProvisionJSON.)
224+
provisionURL := creds.ConnectionURL
225+
if provisionURL == "" {
226+
provisionURL = creds.ReceiveURL
227+
}
228+
fmt.Printf("url %s\n", provisionURL)
220229
if creds.Tier != "" {
221230
fmt.Printf("tier %s\n", creds.Tier)
222231
}

cmd/operate.go

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ const (
6767
resourceRotateSuffix = "/rotate-credentials" // POST rotate password
6868
resourceBackupSuffix = "/backup" // POST ad-hoc backup (tier-gated)
6969
resourceBackupsList = "/backups" // GET list backups
70+
resourceCredsSuffix = "/credentials" // GET re-fetch connection URL (no rotation)
7071
)
7172

7273
// operateJSON is the shared --json toggle for every operate-verb command.
@@ -587,6 +588,68 @@ func runResourceOperate(verb, token string) error {
587588
return nil
588589
}
589590

591+
// ── resource creds (re-fetch the connection URL) ─────────────────────────────
592+
593+
// resourceCredentialsResult mirrors GET /api/v1/resources/:id/credentials
594+
// (api/internal/handlers/resource.go GetCredentials). The real endpoint
595+
// returns connection_url plus the resource identity; ReceiveURL is decoded
596+
// too so a webhook-shaped response (receiver URL) still renders.
597+
type resourceCredentialsResult struct {
598+
OK bool `json:"ok"`
599+
ID string `json:"id"`
600+
Token string `json:"token"`
601+
ResourceType string `json:"resource_type"`
602+
Env string `json:"env"`
603+
ConnectionURL string `json:"connection_url"`
604+
ReceiveURL string `json:"receive_url"`
605+
}
606+
607+
// runResourceCredentials re-fetches a resource's connection URL by token —
608+
// the recovery path for a provision whose `new` call hit the 60s client
609+
// timeout before printing the URL (the URL is otherwise unrecoverable). GETs
610+
// /api/v1/resources/:token/credentials and prints the connection_url (or the
611+
// webhook receive_url fallback); --json emits the full structured response.
612+
func runResourceCredentials(token string) error {
613+
token = strings.TrimSpace(token)
614+
if token == "" {
615+
return fmt.Errorf("token is required")
616+
}
617+
url := fmt.Sprintf("%s%s/%s%s", APIBaseURL, resourcesBasePath,
618+
neturl.PathEscape(token), resourceCredsSuffix)
619+
raw, err := doOperate(http.MethodGet, url, nil, true)
620+
if err != nil {
621+
return err
622+
}
623+
var res resourceCredentialsResult
624+
if err := json.Unmarshal(raw, &res); err != nil {
625+
return fmt.Errorf("parsing response: %w", err)
626+
}
627+
if resourceDetailJSON || operateJSON {
628+
return emitJSON(res)
629+
}
630+
// Webhooks have no connection_url — fall back to receive_url so the
631+
// receiver URL still surfaces (mirrors the provision + detail paths).
632+
connURL := res.ConnectionURL
633+
if connURL == "" {
634+
connURL = res.ReceiveURL
635+
}
636+
tok := res.Token
637+
if tok == "" {
638+
tok = token
639+
}
640+
fmt.Printf("ok creds %s\n", tok)
641+
if connURL != "" {
642+
fmt.Printf("url %s\n", connURL)
643+
}
644+
if res.ResourceType != "" {
645+
fmt.Printf("type %s\n", res.ResourceType)
646+
}
647+
if res.Env != "" {
648+
fmt.Printf("env %s\n", res.Env)
649+
}
650+
return nil
651+
}
652+
590653
// backupRow is one row from GET /api/v1/resources/:token/backups.
591654
type backupRow struct {
592655
BackupID string `json:"backup_id"`

cmd/root.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ Examples:
115115
instant vector new --name app-vec Provision a Postgres+pgvector resource
116116
instant resources List your provisioned resources (requires login)
117117
instant resource <token> Show detail for a single resource by token
118+
instant resource creds <token> Re-fetch a resource's connection URL by token
118119
instant resource delete <token> Delete a resource (use --yes to skip confirm)
119120
instant resource pause <token> Suspend a resource without deleting it (Pro+)
120121
instant resource resume <token> Un-pause a suspended resource (Pro+)

0 commit comments

Comments
 (0)