Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
410 changes: 410 additions & 0 deletions cmd/agent_dx_followups_test.go

Large diffs are not rendered by default.

8 changes: 4 additions & 4 deletions cmd/coverage_push95_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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" {
Expand Down
11 changes: 6 additions & 5 deletions cmd/coverage_units_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
37 changes: 28 additions & 9 deletions cmd/discover.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <token>`, `… 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
Expand Down Expand Up @@ -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).
Expand Down
18 changes: 16 additions & 2 deletions cmd/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
}
}

Expand Down
14 changes: 14 additions & 0 deletions cmd/extras.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,10 @@
Long: `Show, delete, or operate on a single resource by token.

instant resource <token> Print the resource's metadata + connection URL
instant resource creds <token> 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 <token> Tear down the resource. Requires --yes
(or an interactive 'y' confirmation) to
actually delete — printing nothing
Expand All @@ -96,6 +100,16 @@
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 {
Expand Down Expand Up @@ -235,7 +249,7 @@
return w.Flush()
}

// runResourceDelete DELETEs /api/v1/resources/:token. Requires --yes (or a

Check warning on line 252 in cmd/extras.go

View workflow job for this annotation

GitHub Actions / typos

"DELET" should be "DELETE".
// 'y' from an interactive terminal) to actually fire the request — destructive
// commands MUST NOT silently delete on a typo'd token.
//
Expand Down
25 changes: 23 additions & 2 deletions cmd/json_error.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
11 changes: 10 additions & 1 deletion cmd/monitor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
63 changes: 63 additions & 0 deletions cmd/operate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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"`
Expand Down
1 change: 1 addition & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <token> Show detail for a single resource by token
instant resource creds <token> Re-fetch a resource's connection URL by token
instant resource delete <token> Delete a resource (use --yes to skip confirm)
instant resource pause <token> Suspend a resource without deleting it (Pro+)
instant resource resume <token> Un-pause a suspended resource (Pro+)
Expand Down
26 changes: 17 additions & 9 deletions cmd/testapi_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
17 changes: 17 additions & 0 deletions cmd/up.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading