diff --git a/cmd/agent_dx_fixes_test.go b/cmd/agent_dx_fixes_test.go new file mode 100644 index 0000000..9ab1450 --- /dev/null +++ b/cmd/agent_dx_fixes_test.go @@ -0,0 +1,608 @@ +package cmd + +// agent_dx_fixes_test.go — coverage for the three agent-DX fixes shipped in +// fix/cli-agent-dx-whoami-json-headless: +// +// B1 whoami validates the bearer token against GET /auth/me instead of +// reflecting local config (a bogus token now reports NOT authenticated). +// B2 every provisioning `new` verb honors --json (machine-readable token + +// connection_url + environment). +// U2 login honors $BROWSER and adds --no-browser for headless/agent boxes. +// +// Tests follow the existing httptest-mock convention (newITContext): a stateful +// fake API the CLI talks to with zero network access. + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/InstaNode-dev/cli/internal/cliconfig" + "github.com/InstaNode-dev/cli/internal/secretstore" +) + +// ── B1: whoami real server validation ─────────────────────────────────────── + +// TestWhoami_BogusToken_ReportsNotAuthenticated is the headline B1 regression: +// an agent gating on `whoami --json | jq .authenticated` must NOT get a false +// positive for a token the server rejects. The mock returns 401 on /auth/me, +// so the bogus token reports authenticated:false with a non-zero exit. +func TestWhoami_BogusToken_ReportsNotAuthenticated(t *testing.T) { + c := newITContext(t) + _ = cliconfig.Clear() + _ = secretstore.Delete() + resetJSONFlags() + + // Mock rejects every /auth/me with 401 for this test. + c.mock.mu.Lock() + c.mock.rejectAuthMe = true + c.mock.mu.Unlock() + + t.Setenv("INSTANT_TOKEN", "inst_bogus_garbage_token") + + stdout, _ := captureStdout(t, func() { + _, _, err := run("whoami", "--json") + if err == nil { + t.Fatal("bogus token must produce a non-nil error (non-zero exit)") + } + if got := ExitCodeFor(err); got != ExitAuthRequired { + t.Errorf("bogus token exit code = %d, want %d", got, ExitAuthRequired) + } + }) + + var out map[string]any + if err := json.Unmarshal([]byte(stdout), &out); err != nil { + t.Fatalf("whoami --json must emit a single JSON object; got %q (err %v)", stdout, err) + } + if out["authenticated"] != false { + t.Errorf("bogus token: authenticated must be false, got %v", out["authenticated"]) + } + if note, _ := out["error"].(string); note == "" { + t.Error("bogus token: error note must be populated so an agent can branch") + } + // Exactly ONE JSON object on stdout (no duplicate error envelope). + if strings.Count(strings.TrimSpace(stdout), "\n}") != 1 { + t.Errorf("whoami --json must emit exactly one envelope; got %q", stdout) + } +} + +// TestWhoami_ValidToken_PopulatesFromServer asserts a server-validated token +// reports authenticated:true with tier/email sourced from /auth/me (not local +// config), exit 0. +func TestWhoami_ValidToken_PopulatesFromServer(t *testing.T) { + c := newITContext(t) + _ = cliconfig.Clear() + _ = secretstore.Delete() + resetJSONFlags() + _ = c + + t.Setenv("INSTANT_TOKEN", "inst_valid_token") + + stdout, _ := captureStdout(t, func() { + _, _, err := run("whoami", "--json") + if err != nil { + t.Fatalf("valid token must exit 0, got: %v", err) + } + }) + var out map[string]any + if err := json.Unmarshal([]byte(stdout), &out); err != nil { + t.Fatalf("whoami --json invalid JSON: %q (%v)", stdout, err) + } + if out["authenticated"] != true { + t.Errorf("valid token: authenticated must be true, got %v", out["authenticated"]) + } + // Mock /auth/me returns tier=pro, email=tester@instanode.dev. + if got, _ := out["tier"].(string); got != "pro" { + t.Errorf("tier must come from /auth/me, got %q", got) + } + if got, _ := out["email"].(string); got != "tester@instanode.dev" { + t.Errorf("email must come from /auth/me, got %q", got) + } +} + +// TestWhoami_ValidToken_HumanOutput covers the non-JSON validated branch. +func TestWhoami_ValidToken_HumanOutput(t *testing.T) { + c := newITContext(t) + _ = cliconfig.Clear() + _ = secretstore.Delete() + resetJSONFlags() + _ = c + + t.Setenv("INSTANT_TOKEN", "inst_valid_token_human") + stdout, _ := captureStdout(t, func() { + _, _, err := run("whoami") + if err != nil { + t.Fatalf("valid token (human) must exit 0, got %v", err) + } + }) + for _, want := range []string{"Email:", "Plan:", "Team:", "pro", "API URL:", "Key:", "Stored:"} { + if !strings.Contains(stdout, want) { + t.Errorf("human whoami missing %q; got:\n%s", want, stdout) + } + } +} + +// TestWhoami_BogusToken_HumanOutput covers the non-JSON server-rejected branch. +func TestWhoami_BogusToken_HumanOutput(t *testing.T) { + c := newITContext(t) + _ = cliconfig.Clear() + _ = secretstore.Delete() + resetJSONFlags() + + c.mock.mu.Lock() + c.mock.rejectAuthMe = true + c.mock.mu.Unlock() + + t.Setenv("INSTANT_TOKEN", "inst_bogus_human") + stdout, _ := captureStdout(t, func() { + _, _, err := run("whoami") + if err == nil { + t.Fatal("bogus token (human) must produce a non-nil error") + } + }) + if !strings.Contains(stdout, "Not authenticated") { + t.Errorf("human reject path must say 'Not authenticated'; got:\n%s", stdout) + } +} + +// TestWhoami_Offline_DistinctFromLoggedOut asserts that a transport failure +// (server unreachable) reports authenticated:false WITH an error note and a +// generic (exit 1) code — distinct from a clean logout — so a flaky network +// never masquerades as "logged out". +func TestWhoami_Offline_DistinctFromLoggedOut(t *testing.T) { + c := newITContext(t) + _ = cliconfig.Clear() + _ = secretstore.Delete() + resetJSONFlags() + + // Point the CLI at a dead server so the next request fails at the + // transport layer (drives the offline path). + _ = c + pointAtDeadServer() + + t.Setenv("INSTANT_TOKEN", "inst_token_but_server_down") + stdout, _ := captureStdout(t, func() { + _, _, err := run("whoami", "--json") + if err == nil { + t.Fatal("offline check must produce a non-nil error") + } + if got := ExitCodeFor(err); got != ExitGeneric { + t.Errorf("offline exit code = %d, want %d (generic, not auth)", got, ExitGeneric) + } + }) + var out map[string]any + if err := json.Unmarshal([]byte(stdout), &out); err != nil { + t.Fatalf("offline whoami --json must still be valid JSON; got %q (%v)", stdout, err) + } + if out["authenticated"] != false { + t.Errorf("offline: authenticated must be false, got %v", out["authenticated"]) + } + note, _ := out["error"].(string) + if !strings.Contains(note, "could not reach the server") { + t.Errorf("offline error note must explain unreachable server, got %q", note) + } +} + +// TestWhoami_Offline_HumanOutput covers the non-JSON offline branch. +func TestWhoami_Offline_HumanOutput(t *testing.T) { + c := newITContext(t) + _ = cliconfig.Clear() + _ = secretstore.Delete() + resetJSONFlags() + _ = c + pointAtDeadServer() + + t.Setenv("INSTANT_TOKEN", "inst_token_server_down_human") + stdout, _ := captureStdout(t, func() { + _, _, err := run("whoami") + if err == nil { + t.Fatal("offline (human) must produce a non-nil error") + } + }) + if !strings.Contains(stdout, "Could not verify credentials") { + t.Errorf("human offline path must surface a 'Could not verify' line; got:\n%s", stdout) + } +} + +// TestWhoami_NoToken_JSON covers the anonymous --json branch. +func TestWhoami_NoToken_JSON(t *testing.T) { + c := newITContext(t) + _ = cliconfig.Clear() + _ = secretstore.Delete() + resetJSONFlags() + _ = c + + stdout, _ := captureStdout(t, func() { + _, _, err := run("whoami", "--json") + if err != nil { + t.Fatalf("no-token whoami --json must exit 0, got %v", err) + } + }) + var out map[string]any + if err := json.Unmarshal([]byte(stdout), &out); err != nil { + t.Fatalf("no-token whoami --json invalid JSON: %q (%v)", stdout, err) + } + if out["authenticated"] != false { + t.Errorf("no token: authenticated must be false, got %v", out["authenticated"]) + } + if api, _ := out["api_url"].(string); api == "" { + t.Error("no token: api_url must still resolve to a non-empty string") + } +} + +// TestWhoami_ServerError_TreatedAsOffline drives the default switch arm in +// validateTokenWithServer: a 500 from /auth/me is "couldn't confirm" (offline), +// not "rejected". +func TestWhoami_ServerError_TreatedAsOffline(t *testing.T) { + c := newITContext(t) + _ = cliconfig.Clear() + _ = secretstore.Delete() + resetJSONFlags() + + c.mock.mu.Lock() + c.mock.authMeStatus = http.StatusInternalServerError + c.mock.mu.Unlock() + + t.Setenv("INSTANT_TOKEN", "inst_token_5xx") + stdout, _ := captureStdout(t, func() { + _, _, err := run("whoami", "--json") + if err == nil { + t.Fatal("5xx /auth/me must produce a non-nil error") + } + if got := ExitCodeFor(err); got != ExitGeneric { + t.Errorf("5xx exit code = %d, want %d (offline)", got, ExitGeneric) + } + }) + var out map[string]any + _ = json.Unmarshal([]byte(stdout), &out) + if note, _ := out["error"].(string); !strings.Contains(note, "could not reach the server") { + t.Errorf("5xx must classify as offline; error note = %q", note) + } +} + +// TestWhoami_BadJSONFromServer covers the json.Unmarshal error branch in +// validateTokenWithServer (200 with a non-JSON body → treated as offline). +func TestWhoami_BadJSONFromServer(t *testing.T) { + c := newITContext(t) + _ = cliconfig.Clear() + _ = secretstore.Delete() + resetJSONFlags() + + c.mock.mu.Lock() + c.mock.authMeBadBody = true + c.mock.mu.Unlock() + + t.Setenv("INSTANT_TOKEN", "inst_token_badjson") + _, _ = captureStdout(t, func() { + _, _, err := run("whoami", "--json") + if err == nil { + t.Fatal("garbage /auth/me body must produce a non-nil error") + } + }) +} + +// TestWhoami_TokenFlagPath covers the --token precedence branch in runWhoami +// (the global --token flag wins over a saved login / env). +func TestWhoami_TokenFlagPath(t *testing.T) { + c := newITContext(t) + _ = cliconfig.Clear() + _ = secretstore.Delete() + resetJSONFlags() + _ = c + + stdout, _ := captureStdout(t, func() { + _, _, err := run("--token", "inst_flag_token", "whoami", "--json") + if err != nil { + t.Fatalf("whoami --token --json must exit 0 (mock validates), got %v", err) + } + }) + var out map[string]any + if err := json.Unmarshal([]byte(stdout), &out); err != nil { + t.Fatalf("invalid JSON: %q (%v)", stdout, err) + } + if out["authenticated"] != true { + t.Errorf("--token path: authenticated must be true, got %v", out["authenticated"]) + } +} + +// TestWhoami_APIURLDefaultFallback covers the api_url resolution falling all +// the way through to defaultAPIBaseURL: clear config + no INSTANT_API_URL + +// empty package APIBaseURL forces the final fallback branch. No token, so it +// never touches the network. +func TestWhoami_APIURLDefaultFallback(t *testing.T) { + c := newITContext(t) + _ = cliconfig.Clear() + _ = secretstore.Delete() + resetJSONFlags() + _ = c + + prev := APIBaseURL + APIBaseURL = "" // force the defaultAPIBaseURL branch + t.Cleanup(func() { APIBaseURL = prev }) + + stdout, _ := captureStdout(t, func() { + _, _, err := run("whoami", "--json") + if err != nil { + t.Fatalf("no-token whoami --json must exit 0, got %v", err) + } + }) + var out map[string]any + _ = json.Unmarshal([]byte(stdout), &out) + if api, _ := out["api_url"].(string); api != defaultAPIBaseURL { + t.Errorf("api_url must fall back to %q, got %q", defaultAPIBaseURL, api) + } +} + +// TestWhoamiErrorNote_OfflineNoErr covers the offline branch of whoamiErrorNote +// where v.err is nil (the generic "could not reach the server" string). +func TestWhoamiErrorNote_OfflineNoErr(t *testing.T) { + got := whoamiErrorNote(whoamiValidation{offline: true}) + if got != "could not reach the server to validate the token" { + t.Errorf("offline-no-err note = %q", got) + } + withErr := whoamiErrorNote(whoamiValidation{offline: true, err: errSessionExpired()}) + if !strings.Contains(withErr, "could not reach the server") { + t.Errorf("offline-with-err note = %q", withErr) + } + rejected := whoamiErrorNote(whoamiValidation{}) + if rejected != "the server rejected the presented token" { + t.Errorf("rejected note = %q", rejected) + } +} + +// TestWhoami_ConfigLoadError covers the cliconfig.Load() error branch in +// runWhoami: a malformed ~/.instant-config makes Load return an error, which +// whoami funnels through wrapJSONErr. +func TestWhoami_ConfigLoadError(t *testing.T) { + c := newITContext(t) + _ = cliconfig.Clear() + _ = secretstore.Delete() + resetJSONFlags() + _ = c + + home, _ := os.UserHomeDir() + cfgPath := filepath.Join(home, ".instant-config") + if err := os.WriteFile(cfgPath, []byte("{not valid json"), 0o600); err != nil { + t.Fatalf("write malformed config: %v", err) + } + t.Cleanup(func() { _ = os.Remove(cfgPath) }) + + _, _ = captureStdout(t, func() { + _, _, err := run("whoami", "--json") + if err == nil { + t.Fatal("malformed config must surface a load error") + } + }) +} + +// TestValidateTokenWithServer_EmptyToken covers the early-return guard and the +// request-build error path is exercised by passing an invalid URL. +func TestValidateTokenWithServer_EmptyToken(t *testing.T) { + if v := validateTokenWithServer("https://x.example", " "); v.validated || v.offline { + t.Errorf("empty token: want validated=false offline=false, got %+v", v) + } + // Invalid URL → http.NewRequest fails → offline with err. + if v := validateTokenWithServer("http://\x7f", "tok"); !v.offline || v.err == nil { + t.Errorf("bad URL: want offline with err, got %+v", v) + } +} + +// ── B2: provision verbs honor --json ──────────────────────────────────────── + +// TestProvision_JSON_AllVerbs asserts every provisioning `new` verb accepts +// --json and emits the full structured response (token + connection_url + +// environment). The webhook verb returns receive_url instead. +func TestProvision_JSON_AllVerbs(t *testing.T) { + cases := []struct { + group string + urlKey string // which URL field carries the connection + wantEnv string + }{ + {"db", "connection_url", "development"}, + {"cache", "connection_url", "development"}, + {"nosql", "connection_url", "development"}, + {"queue", "connection_url", "development"}, + {"storage", "connection_url", "development"}, + {"vector", "connection_url", "development"}, + {"webhook", "receive_url", "development"}, + } + for _, tc := range cases { + t.Run(tc.group, func(t *testing.T) { + c := newITContext(t) + resetProvisionFlags() + + var token string + stdout, _ := captureStdout(t, func() { + _, _, err := run(tc.group, "new", "--name", tc.group+"-json", "--json") + if err != nil { + t.Fatalf("%s new --json: %v", tc.group, err) + } + }) + var out map[string]any + if err := json.Unmarshal([]byte(stdout), &out); err != nil { + t.Fatalf("%s new --json must emit valid JSON; got %q (%v)", tc.group, stdout, err) + } + if out["ok"] != true { + t.Errorf("%s: ok must be true, got %v", tc.group, out["ok"]) + } + tk, _ := out["token"].(string) + if tk == "" { + t.Errorf("%s: token must be present in JSON output", tc.group) + } + token = tk + if u, _ := out[tc.urlKey].(string); u == "" { + t.Errorf("%s: %s must be present, got %v", tc.group, tc.urlKey, out) + } + if env, _ := out["environment"].(string); env != tc.wantEnv { + t.Errorf("%s: environment = %q, want %q", tc.group, env, tc.wantEnv) + } + if env, _ := out["env"].(string); env != tc.wantEnv { + t.Errorf("%s: env = %q, want %q", tc.group, env, tc.wantEnv) + } + // Clean up so the mandatory leak sweep stays green. + c.deleteResource(token) + }) + } +} + +// TestProvision_JSON_WithEnvAndOverride covers the env_override_reason field on +// the JSON output (non-empty env + server-supplied override reason). +func TestProvision_JSON_WithEnvAndOverride(t *testing.T) { + c := newITContext(t) + resetProvisionFlags() + + c.mock.mu.Lock() + c.mock.envOverrideReason = "anonymous callers can't target production" + c.mock.mu.Unlock() + + var token string + stdout, _ := captureStdout(t, func() { + _, _, err := run("db", "new", "--name", "ovr-db", "--env", "production", "--json") + if err != nil { + t.Fatalf("db new --env production --json: %v", err) + } + }) + var out map[string]any + if err := json.Unmarshal([]byte(stdout), &out); err != nil { + t.Fatalf("invalid JSON: %q (%v)", stdout, err) + } + token, _ = out["token"].(string) + if reason, _ := out["env_override_reason"].(string); reason == "" { + t.Error("env_override_reason must surface in JSON output when the server set it") + } + c.deleteResource(token) +} + +// TestProvision_JSON_ErrorEnvelope asserts a 402/limit error in --json mode +// funnels through the shared envelope (so an agent piping to jq never crashes). +func TestProvision_JSON_ErrorEnvelope(t *testing.T) { + c := newITContext(t) + resetProvisionFlags() + + c.mock.injectErrorOnProvision(http.StatusPaymentRequired, "limit_reached", + "deployment limit reached", "upgrade to a paid plan", "https://instanode.dev/pricing") + + stdout, _ := captureStdout(t, func() { + _, _, err := run("db", "new", "--name", "over-limit", "--json") + if err == nil { + t.Fatal("402 provision must produce a non-nil error") + } + }) + var env map[string]any + if err := json.Unmarshal([]byte(stdout), &env); err != nil { + t.Fatalf("--json provision error must emit a JSON envelope; got %q (%v)", stdout, err) + } + if env["ok"] != false { + t.Errorf("error envelope ok must be false, got %v", env["ok"]) + } + if c.mock.count() != 0 { + t.Error("failed provision must not leave a resource") + } +} + +// TestProvision_JSON_OmittedEnvDefaults covers the env-empty → "development" +// fallback feeding into the JSON output (older-API shape, no env field). +func TestProvision_JSON_OmittedEnvDefaults(t *testing.T) { + c := newITContext(t) + resetProvisionFlags() + + c.mock.mu.Lock() + c.mock.omitEnvInProvision = true + c.mock.mu.Unlock() + + var token string + stdout, _ := captureStdout(t, func() { + _, _, err := run("cache", "new", "--name", "noenv-cache", "--json") + if err != nil { + t.Fatalf("cache new --json: %v", err) + } + }) + var out map[string]any + _ = json.Unmarshal([]byte(stdout), &out) + token, _ = out["token"].(string) + if env, _ := out["environment"].(string); env != "development" { + t.Errorf("omitted env must default to development in JSON, got %q", env) + } + c.deleteResource(token) +} + +// ── U2: $BROWSER + --no-browser ───────────────────────────────────────────── + +// TestBrowserLauncher_HonorsBROWSER asserts $BROWSER overrides the per-GOOS +// default on every platform. +func TestBrowserLauncher_HonorsBROWSER(t *testing.T) { + t.Setenv(browserEnvVar, "my-custom-opener") + for _, goos := range []string{"darwin", "linux", "windows", "plan9", ""} { + name, args := browserLauncherForGOOS(goos, "https://instanode.dev/x") + if name != "my-custom-opener" { + t.Errorf("goos=%q: $BROWSER must win, got launcher %q", goos, name) + } + if len(args) != 1 || args[0] != "https://instanode.dev/x" { + t.Errorf("goos=%q: $BROWSER launcher must receive the URL as a single arg, got %v", goos, args) + } + } +} + +// TestBrowserLauncher_BROWSERWhitespaceIgnored asserts a whitespace-only +// $BROWSER is treated as unset (falls through to the per-GOOS default). +func TestBrowserLauncher_BROWSERWhitespaceIgnored(t *testing.T) { + t.Setenv(browserEnvVar, " ") + name, _ := browserLauncherForGOOS("linux", "https://instanode.dev/x") + if name != "xdg-open" { + t.Errorf("whitespace $BROWSER must fall through to xdg-open, got %q", name) + } +} + +// TestLogin_NoBrowser_PrintsURLAndPolls covers the --no-browser path: the auth +// URL + session id are printed and login completes via polling WITHOUT ever +// invoking openBrowser. The mock completes auth immediately. +func TestLogin_NoBrowser_PrintsURLAndPolls(t *testing.T) { + c := newITContext(t) + _ = cliconfig.Clear() + _ = secretstore.Delete() + resetJSONFlags() + + // Make the device-flow poll resolve on the first tick. + c.mock.mu.Lock() + c.mock.authComplete = true + c.mock.mu.Unlock() + + // Speed the poll up so the test is fast. + prevInterval := pollInterval + pollInterval = 1 + t.Cleanup(func() { pollInterval = prevInterval }) + + loginNoBrowser = true + t.Cleanup(func() { loginNoBrowser = false }) + + stdout, _ := captureStdout(t, func() { + _, _, err := run("login", "--no-browser") + if err != nil { + t.Fatalf("login --no-browser: %v", err) + } + }) + if !strings.Contains(stdout, "Open this URL to sign in:") { + t.Errorf("--no-browser must print the auth URL; got:\n%s", stdout) + } + if !strings.Contains(stdout, "Session:") { + t.Errorf("--no-browser must print the session id; got:\n%s", stdout) + } + if !strings.Contains(stdout, "Logged in as") { + t.Errorf("--no-browser must still complete login via polling; got:\n%s", stdout) + } +} + +// pointAtDeadServer re-points the package-global APIBaseURL at an immediately +// closed httptest server so the next CLI request fails at the transport layer +// (drives whoami's offline path). initConfig leaves APIBaseURL untouched when +// neither INSTANT_API_URL nor a saved config is set, so this value survives the +// run() → OnInitialize cycle. +func pointAtDeadServer() { + dead := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + dead.Close() + APIBaseURL = dead.URL +} diff --git a/cmd/bughunt_p2_test.go b/cmd/bughunt_p2_test.go index cb7be9f..8a29d5c 100644 --- a/cmd/bughunt_p2_test.go +++ b/cmd/bughunt_p2_test.go @@ -448,8 +448,12 @@ func TestBugHunt_T16_P3_WhoamiJSONOutputNeverLeaksToken(t *testing.T) { if out["authenticated"] != true { t.Errorf("whoami --json: authenticated must be true; got %v", out["authenticated"]) } - if got, _ := out["email"].(string); got != "json-leak-test@example.com" { - t.Errorf("whoami --json email field: got %q", got) + // B-whoami: email is now the SERVER's authoritative identity from + // /auth/me (the mock returns tester@instanode.dev), not the stale local + // config — whoami validates against the server rather than reflecting the + // on-disk config it was previously a mirror of. + if got, _ := out["email"].(string); got != "tester@instanode.dev" { + t.Errorf("whoami --json email field (from /auth/me): got %q", got) } // `key_display` must exist but only carry the truncated form. keyDisp, _ := out["key_display"].(string) diff --git a/cmd/extras.go b/cmd/extras.go index bf7be07..755d1df 100644 --- a/cmd/extras.go +++ b/cmd/extras.go @@ -318,6 +318,10 @@ func init() { "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)") + // B-provision-json: parity with db/cache/nosql/queue — every + // provisioning verb honors --json (machine-readable token + url). + c.Flags().BoolVar(&provisionJSON, "json", false, + "Emit the provisioning result as a JSON object instead of human-readable lines") _ = c.MarkFlagRequired("name") } storageCmd.AddCommand(storageNewCmd) diff --git a/cmd/integration_test.go b/cmd/integration_test.go index 9d91c50..836a6c6 100644 --- a/cmd/integration_test.go +++ b/cmd/integration_test.go @@ -219,14 +219,18 @@ func lastSavedToken(t *testing.T) string { func resetProvisionFlags() { resourceName = "" resourceEnv = "" + provisionJSON = false 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 + // --env / --json are optional so they may not be bound on older + // builds; the Set call is best-effort and silently no-ops on a + // missing flag. + for _, flagName := range []string{"env", "json"} { + if fl := sub.Flags().Lookup(flagName); fl != nil { + _ = fl.Value.Set(fl.DefValue) + fl.Changed = false + } } } } @@ -388,11 +392,32 @@ func resetJSONFlags() { resourcesJSON = false statusJSON = false whoamiJSON = false + provisionJSON = false resourceDetailJSON = false resourceDeleteYes = false resourcesFilter = nil resourcesLimit = 0 adHocToken = "" + + // B-provision-json: the --json flag lives on the nested ` new` + // sub-sub-commands (db new, cache new, …), which the top-level loop below + // doesn't reach. Clear the per-command Changed state so a prior test's + // `--json` doesn't leak into the next via jsonModeOn's flag walk. + for _, group := range []string{"db", "cache", "nosql", "queue", "storage", "webhook", "vector"} { + for _, sub := range rootCmd.Commands() { + if sub.Use != group { + continue + } + for _, leaf := range sub.Commands() { + for _, flagName := range []string{"json", "name", "env"} { + if fl := leaf.Flags().Lookup(flagName); fl != nil { + _ = fl.Value.Set(fl.DefValue) + fl.Changed = false + } + } + } + } + } for _, c := range []struct { cmd string flag string diff --git a/cmd/json_error.go b/cmd/json_error.go index 1bb1d17..d91f384 100644 --- a/cmd/json_error.go +++ b/cmd/json_error.go @@ -79,7 +79,7 @@ func jsonModeOn(cmd *cobra.Command) bool { // Also honor the package-global toggles set by cobra's BoolVar bindings // — these are how the existing whoami/resources/status commands carry // the flag. They're already wired by the time RunE fires. - if resourcesJSON || statusJSON || whoamiJSON { + if resourcesJSON || statusJSON || whoamiJSON || provisionJSON { return true } return false diff --git a/cmd/login.go b/cmd/login.go index bec7a28..3c71315 100644 --- a/cmd/login.go +++ b/cmd/login.go @@ -48,6 +48,20 @@ var pollTimeout = 10 * time.Minute // is 5 minutes; tests lower it. var tierUpgradeTimeout = 5 * time.Minute +// browserEnvVar is the conventional Unix variable naming a user's preferred +// URL opener (e.g. `BROWSER=firefox` or `BROWSER='wslview'`). U2: honored +// before the per-GOOS default so a headless/agent box with a custom opener +// works without code changes. Named const so the env lookup has one spelling. +const browserEnvVar = "BROWSER" + +// loginNoBrowser is bound to `instant login --no-browser`. U2: on a headless +// agent box, attempting to spawn open/xdg-open/rundll32 is useless — it prints +// "Could not open browser automatically" then blocks polling for 5 minutes +// with no escape. With --no-browser the CLI prints the auth URL + session id +// to stdout (so a human or supervising agent can open it elsewhere) and polls +// without EVER trying to launch a browser. +var loginNoBrowser bool + var loginCmd = &cobra.Command{ Use: "login", Short: "Log in to instanode.dev and save credentials locally", @@ -62,7 +76,11 @@ Subsequent commands will use it automatically for authenticated API calls. If you upgrade to a paid plan, run `+"`instant login`"+` again to refresh your tier — or the CLI will detect it automatically on the next API call. -If the browser flow times out or you're on a headless machine, skip it: +On a headless machine, pass `+"`--no-browser`"+`: the CLI prints the login URL +and session id and polls without trying to launch a browser. It also honors the +`+"`BROWSER`"+` environment variable when choosing how to open the URL. + +If the browser flow times out or you can't sign in at all, skip it: mint a Personal Access Token at https://instanode.dev/app/settings, then authenticate any command with `+"`instant --token ...`"+` or by exporting `+"`INSTANT_TOKEN=`"+` in your shell. @@ -82,6 +100,10 @@ show exactly which resources you have running and pre-fill your plan. } func init() { + // U2: --no-browser makes login usable on headless/agent boxes — print the + // auth URL + session id and poll, never trying to spawn a browser. + loginCmd.Flags().BoolVar(&loginNoBrowser, "no-browser", false, + "Print the login URL + session id and poll without launching a browser (headless/agent boxes)") rootCmd.AddCommand(loginCmd) rootCmd.AddCommand(upgradeCmd) } @@ -109,10 +131,20 @@ func runLogin(cmd *cobra.Command, args []string) error { return fmt.Errorf("starting login: %w", err) } - // Step 2: Open the browser. - fmt.Printf("Opening browser to:\n %s\n\n", session.AuthURL) - fmt.Println("Waiting for you to sign in… (Ctrl-C to cancel)") - openBrowser(session.AuthURL) + // Step 2: Surface the auth URL. U2: with --no-browser (headless / agent + // box) we print the URL + session id to stdout and NEVER attempt to launch + // a browser — spawning open/xdg-open on a headless host is useless and the + // failure message was followed by a 5-minute silent poll. The interactive + // happy path is unchanged: print + best-effort browser launch. + if loginNoBrowser { + fmt.Printf("Open this URL to sign in:\n %s\n", session.AuthURL) + fmt.Printf("Session: %s\n\n", session.SessionID) + fmt.Println("Waiting for you to sign in… (Ctrl-C to cancel)") + } else { + fmt.Printf("Opening browser to:\n %s\n\n", session.AuthURL) + fmt.Println("Waiting for you to sign in… (Ctrl-C to cancel)") + openBrowser(session.AuthURL) + } // Step 3: Poll until the user completes auth or we time out. result, err := pollForAuthCompletion(session.SessionID) @@ -360,9 +392,19 @@ func safeBrowserURL(raw string) (string, error) { // not matching runtime.GOOS would otherwise be uncovered, which is what // our 100%-patch-coverage gate cares about). // +// U2: $BROWSER takes precedence over the per-GOOS default on EVERY platform. +// On a headless Linux agent there is no xdg-open, but the operator may export +// `BROWSER=wslview` (WSL) or any custom opener; honoring it lets login work +// without a code change. The $BROWSER value is treated as a bare command name +// (no shell parsing) so a hostile config can't inject extra args — the safe +// URL is passed as a single argument exactly as for the built-in helpers. +// // nil result means "no known helper for this GOOS"; caller should skip the // exec attempt and tell the user to open the URL manually. func browserLauncherForGOOS(goos, safeURL string) (name string, args []string) { + if b := strings.TrimSpace(os.Getenv(browserEnvVar)); b != "" { + return b, []string{safeURL} + } switch goos { case "darwin": return "open", []string{safeURL} diff --git a/cmd/monitor.go b/cmd/monitor.go index 9207641..3169a97 100644 --- a/cmd/monitor.go +++ b/cmd/monitor.go @@ -37,6 +37,15 @@ 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 +// provisionJSON is bound to the --json flag on every provisioning `new` +// command (db/cache/nosql/queue/storage/webhook/vector). B-provision-json: +// provisioning is the wedge action an agent most needs machine-readable — it +// must capture the returned token + connection_url programmatically. Before +// this flag, `instant db new --name x --json` failed with `unknown flag: +// --json` and an agent had to scrape the human-readable lines. Matches the +// --json convention already on resources/status/whoami/resource. +var provisionJSON bool + // 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 @@ -155,7 +164,10 @@ func makeProvisionCmd(endpoint, resourceType string) func(*cobra.Command, []stri creds, err := provisionResource(endpoint, name, resourceEnv) if err != nil { - return fmt.Errorf("provisioning failed: %w", err) + // B-provision-json: in --json mode, errors funnel through the + // shared envelope so an agent piping into jq never crashes on a + // 402/429/network failure. + return wrapJSONErr(cmd, fmt.Errorf("provisioning failed: %w", err)) } // Save token locally for `instant status` + B15-P1 (7) anon-up @@ -183,11 +195,6 @@ 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) - 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). @@ -197,6 +204,22 @@ func makeProvisionCmd(endpoint, resourceType string) func(*cobra.Command, []stri if envOut == "" { envOut = "development" } + + // B-provision-json: emit the full structured response so an agent can + // capture token + connection_url + environment in one machine-readable + // blob. Matches the two-space-indent convention of every other --json + // command. The resolved env is echoed under both `env` (raw server + // field) and `environment` (the /deploy/new-style alias) so an agent + // keys off whichever it already uses. + if provisionJSON { + return emitProvisionJSON(resourceType, creds, envOut) + } + + fmt.Printf("ok %-8s %s\n", resourceType, creds.Token) + fmt.Printf("url %s\n", creds.ConnectionURL) + if creds.Tier != "" { + fmt.Printf("tier %s\n", creds.Tier) + } fmt.Printf("env %s\n", envOut) if creds.EnvOverrideReason != "" { fmt.Printf("env_override_reason %s\n", creds.EnvOverrideReason) @@ -208,6 +231,47 @@ func makeProvisionCmd(endpoint, resourceType string) func(*cobra.Command, []stri } } +// provisionJSONOutput is the stable schema emitted by every provisioning verb +// under --json. It surfaces the full success response an agent needs to wire +// up the resource: the token (for later `instant resource …` calls), the +// connection_url (or receive_url for webhooks), the resolved environment, and +// the tier/note/override metadata the human path already prints. +type provisionJSONOutput struct { + OK bool `json:"ok"` + ResourceType string `json:"resource_type"` + Token string `json:"token"` + Name string `json:"name"` + ConnectionURL string `json:"connection_url,omitempty"` + ReceiveURL string `json:"receive_url,omitempty"` + Tier string `json:"tier,omitempty"` + Env string `json:"env"` + Environment string `json:"environment"` + EnvOverrideReason string `json:"env_override_reason,omitempty"` + Note string `json:"note,omitempty"` +} + +// emitProvisionJSON writes the structured provisioning result to stdout with +// the shared two-space indentation. resolvedEnv is the env after the empty → +// "development" fallback so the JSON never reports an empty environment. +func emitProvisionJSON(resourceType string, creds *provisionResponse, resolvedEnv string) error { + out := provisionJSONOutput{ + OK: true, + ResourceType: resourceType, + Token: creds.Token, + Name: creds.Name, + ConnectionURL: creds.ConnectionURL, + ReceiveURL: creds.ReceiveURL, + Tier: creds.Tier, + Env: resolvedEnv, + Environment: resolvedEnv, + EnvOverrideReason: creds.EnvOverrideReason, + Note: creds.Note, + } + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(out) +} + // provisionResponse is the shape returned by POST /{service}/new endpoints. // /webhook/new returns receive_url instead of connection_url. type provisionResponse struct { @@ -380,6 +444,10 @@ func init() { 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)") + // B-provision-json: emit the full structured response (token, + // connection_url, environment, …) instead of the human-readable lines. + c.Flags().BoolVar(&provisionJSON, "json", false, + "Emit the provisioning result as a JSON object instead of human-readable lines") _ = c.MarkFlagRequired("name") } diff --git a/cmd/root.go b/cmd/root.go index c50127a..ba9314e 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -16,12 +16,18 @@ import ( var _ = httpListTimeout // documented constant; referenced in tests / future refactor +// defaultAPIBaseURL is the canonical production endpoint used when neither +// INSTANT_API_URL nor a saved config supplies one. Named const (not an inline +// literal) so every fallback site — initConfig here and whoami's api_url +// resolver — references one source of truth and can never drift. +const defaultAPIBaseURL = "https://api.instanode.dev" + // APIBaseURL is the instanode.dev API base URL. // Resolved at init from (in priority order): // 1. INSTANT_API_URL env var // 2. ~/.instant-config api_base_url // 3. Default: https://api.instanode.dev -var APIBaseURL = "https://api.instanode.dev" +var APIBaseURL = defaultAPIBaseURL // adHocToken is bound to the global --token flag. B15-P2: ad-hoc auth // override that doesn't require exporting INSTANT_TOKEN or running diff --git a/cmd/testapi_test.go b/cmd/testapi_test.go index f5565d7..d31fa24 100644 --- a/cmd/testapi_test.go +++ b/cmd/testapi_test.go @@ -100,6 +100,17 @@ type mockAPI struct { // provisioning response entirely — simulates an older API build that // predates migration 026 so the CLI's empty-env fallback path runs. omitEnvInProvision bool + + // ── B-whoami — /auth/me validation modes ──────────────────────────── + // rejectAuthMe makes GET /auth/me return 401 (token rejected) so the + // whoami "server says not authenticated" path can be exercised. + rejectAuthMe bool + // authMeStatus, when non-zero, overrides the /auth/me response status — + // used to drive the "unexpected status → treated as offline" branch. + authMeStatus int + // authMeBadBody, when true, returns a 200 with a non-JSON body so the + // whoami unmarshal-error (offline) branch runs. + authMeBadBody bool } // injectErrorOnProvision arms the mock to return a structured error envelope @@ -254,6 +265,26 @@ func (m *mockAPI) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } if path == "/auth/me" && r.Method == http.MethodGet { + m.mu.Lock() + reject := m.rejectAuthMe + status := m.authMeStatus + badBody := m.authMeBadBody + m.mu.Unlock() + if reject { + writeJSON(w, http.StatusUnauthorized, map[string]any{ + "ok": false, "error": "invalid token", + }) + return + } + if badBody { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("this is not json {")) + return + } + if status != 0 { + writeJSON(w, status, map[string]any{"ok": false, "error": "boom"}) + return + } writeJSON(w, http.StatusOK, map[string]string{ "tier": "pro", "email": "tester@instanode.dev", "team_name": "Test Team", }) diff --git a/cmd/whoami.go b/cmd/whoami.go index 42ff3f8..b124b5d 100644 --- a/cmd/whoami.go +++ b/cmd/whoami.go @@ -2,7 +2,10 @@ package cmd import ( "encoding/json" + "errors" "fmt" + "io" + "net/http" "os" "strings" @@ -11,6 +14,11 @@ import ( "github.com/spf13/cobra" ) +// authMePath is the server endpoint that validates the caller's bearer token +// and returns the canonical identity (tier/email/team). Named const (not an +// inline literal) so whoami and login share one path and can never drift. +const authMePath = "/auth/me" + // whoamiJSON is the --json flag for `instant whoami`. T16 P3: machine-readable // identity output for agents. The bearer token is NEVER included even in JSON // mode — only the truncated display form and the secret-backend name (P1-1). @@ -18,13 +26,19 @@ var whoamiJSON bool // whoamiJSONOutput is the stable schema emitted by `whoami --json`. Fields: // -// authenticated true when a credential is on disk / in the keychain -// email customer email, "" when anonymous -// tier effective plan tier (anonymous, hobby, pro, ...) -// team_name team display name, "" if unset +// authenticated true ONLY when the server validated the bearer token +// (B-whoami: was previously a pure local reflector that +// reported true for ANY non-empty token — a false positive +// an agent gating on `.authenticated` could not detect). +// email customer email from /auth/me, "" when anonymous +// tier effective plan tier from /auth/me (anonymous, hobby, ...) +// team_name team display name from /auth/me, "" if unset // api_url resolved api base URL // key_display truncated key for display (NEVER the full token) // secret_backend "macOS Keychain" / "libsecret" / "on-disk fallback" / etc. +// error distinct, non-empty when validation could not be performed +// (network/offline) — lets an agent tell "definitely not +// authenticated" from "couldn't reach the server to check". type whoamiJSONOutput struct { Authenticated bool `json:"authenticated"` Email string `json:"email"` @@ -33,6 +47,75 @@ type whoamiJSONOutput struct { APIURL string `json:"api_url"` KeyDisplay string `json:"key_display"` SecretBackend string `json:"secret_backend"` + Error string `json:"error,omitempty"` +} + +// authMeResponse is the subset of GET /auth/me the CLI consumes to confirm a +// token is real and surface the server's authoritative identity. +type authMeResponse struct { + Tier string `json:"tier"` + Email string `json:"email"` + TeamName string `json:"team_name"` +} + +// whoamiValidation is the outcome of asking the server who we are. validated +// is true only when /auth/me returned 200 for the presented token. offline is +// true when the request could not be completed (network error / no server) — +// distinct from "the server said this token is invalid" (validated=false, +// offline=false). me holds the server identity on success. +type whoamiValidation struct { + validated bool + offline bool + err error + me authMeResponse +} + +// validateTokenWithServer calls GET /auth/me with the given bearer token and +// classifies the outcome. A 200 means the token is real (validated). A 401/403 +// means the token is bogus or expired (not validated, not offline). A transport +// error (DNS/connection refused/timeout) means we couldn't check (offline) — +// the caller surfaces this as a distinct state rather than a false "logged out". +// +// The token is sent on an explicit per-request Authorization header rather than +// relying on the package authTransport, because `whoami` resolves token +// precedence (--token > INSTANT_TOKEN > saved login) itself and must validate +// exactly the token it will report on. +func validateTokenWithServer(apiURL, token string) whoamiValidation { + if strings.TrimSpace(token) == "" { + return whoamiValidation{validated: false, offline: false} + } + req, err := http.NewRequest(http.MethodGet, apiURL+authMePath, nil) + if err != nil { + return whoamiValidation{validated: false, offline: true, err: err} + } + req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(token)) + + resp, err := HTTPClient.Do(req) + if err != nil { + return whoamiValidation{validated: false, offline: true, err: err} + } + defer func() { _ = resp.Body.Close() }() + raw, _ := io.ReadAll(resp.Body) + + switch resp.StatusCode { + case http.StatusOK: + var me authMeResponse + if jerr := json.Unmarshal(raw, &me); jerr != nil { + return whoamiValidation{validated: false, offline: true, err: jerr} + } + return whoamiValidation{validated: true, me: me} + case http.StatusUnauthorized, http.StatusForbidden: + // Server explicitly rejected the token — definitively not authenticated. + return whoamiValidation{validated: false, offline: false} + default: + // Any other status (5xx, unexpected) means we couldn't confirm — treat + // as offline so a flaky server doesn't masquerade as "logged out". + return whoamiValidation{ + validated: false, + offline: true, + err: fmt.Errorf("server returned %d validating token", resp.StatusCode), + } + } } var whoamiCmd = &cobra.Command{ @@ -40,83 +123,177 @@ var whoamiCmd = &cobra.Command{ Short: "Show the currently authenticated account", Long: `Show the currently authenticated account. +whoami validates the bearer token against the server (GET /auth/me) before +reporting "authenticated": a bogus or expired token reports NOT authenticated +(exit 3), so an agent can gate on the result. If the server can't be reached, +whoami reports authenticated:false with a distinct "error" note rather than a +false positive. + With --json, output is a machine-readable identity object. The bearer token is NEVER included even in JSON mode (T16 P1-1); only a truncated display form and the secret-backend name are surfaced. `, - RunE: func(cmd *cobra.Command, args []string) error { - cfg, err := cliconfig.Load() - if err != nil { - return wrapJSONErr(cmd, err) - } + RunE: runWhoami, +} - // B15-P0 (1) / B15-P2 — auth token precedence: --token flag > - // INSTANT_TOKEN env > cliconfig (keychain/file). Mirrors the order - // already implemented in cmd/root.go::initConfig so `whoami` and - // the HTTP-client wiring agree on which token wins. Whitespace is - // trimmed at every source so a stray newline from `$(cat .pat)` - // doesn't break Authorization headers (B15-P1). - if flagTok := strings.TrimSpace(adHocToken); flagTok != "" { - cfg.APIKey = flagTok - if cfg.Tier == "" { - cfg.Tier = "flag-token" - } - } else if envTok := strings.TrimSpace(os.Getenv("INSTANT_TOKEN")); envTok != "" { - cfg.APIKey = envTok - // Mark it as authenticated even when the on-disk config is empty - // (typical for env-token / agent runs that never `instant login`). - if cfg.Tier == "" { - cfg.Tier = "env-token" - } - } +func runWhoami(cmd *cobra.Command, args []string) error { + cfg, err := cliconfig.Load() + if err != nil { + return wrapJSONErr(cmd, err) + } - // B15-P1 — resolve api_url so --json never emits api_url:"". - // Priority: cfg.APIBaseURL > INSTANT_API_URL env > APIBaseURL package var > hardcoded default. - apiURL := cfg.APIBaseURL - if apiURL == "" { - apiURL = strings.TrimSpace(os.Getenv("INSTANT_API_URL")) - } - if apiURL == "" { - apiURL = APIBaseURL + // Auth token precedence: --token flag > INSTANT_TOKEN env > cliconfig + // (keychain/file). Mirrors cmd/root.go::initConfig so whoami validates + // exactly the token the rest of the CLI would use. Whitespace is trimmed + // at every source so a stray newline from `$(cat .pat)` doesn't break the + // Authorization header. + token := strings.TrimSpace(cfg.APIKey) + if flagTok := strings.TrimSpace(adHocToken); flagTok != "" { + token = flagTok + } else if envTok := strings.TrimSpace(os.Getenv("INSTANT_TOKEN")); envTok != "" { + token = envTok + } + + // Resolve api_url so --json never emits api_url:"". + // Priority: cfg.APIBaseURL > INSTANT_API_URL env > APIBaseURL package var > hardcoded default. + apiURL := cfg.APIBaseURL + if apiURL == "" { + apiURL = strings.TrimSpace(os.Getenv("INSTANT_API_URL")) + } + if apiURL == "" { + apiURL = APIBaseURL + } + if apiURL == "" { + apiURL = defaultAPIBaseURL + } + + // No token at all → cleanly anonymous, never touch the network. + if token == "" { + return whoamiReportAnonymous(cmd, cfg, apiURL) + } + + // B-whoami: REAL validation. The previous implementation reported + // authenticated:true for ANY non-empty token (a local reflector). We now + // confirm against GET /auth/me so an agent gating on `.authenticated` + // can trust it. + v := validateTokenWithServer(apiURL, token) + return whoamiReport(cmd, cfg, apiURL, token, v) +} + +// whoamiReportAnonymous renders the no-token path (genuinely anonymous). +func whoamiReportAnonymous(cmd *cobra.Command, cfg *cliconfig.Config, apiURL string) error { + if whoamiJSON { + return encodeWhoamiJSON(whoamiJSONOutput{ + Authenticated: false, + APIURL: apiURL, + SecretBackend: cfg.SecretBackendName(), + }) + } + fmt.Println("Not logged in (anonymous mode).") + fmt.Printf("Run `instant login` to authenticate, or `instant db new` to provision a database without an account.\n") + return nil +} + +// whoamiReport renders the result of a server validation attempt and returns +// the correct exit-code-bearing error (nil when authenticated, errSessionExpired +// when the server rejected the token, a plain error when offline). +func whoamiReport(cmd *cobra.Command, cfg *cliconfig.Config, apiURL, token string, v whoamiValidation) error { + keyDisplay := secretstore.TruncateForDisplay(token) + backend := cfg.SecretBackendName() + + if whoamiJSON { + out := whoamiJSONOutput{ + Authenticated: v.validated, + Email: v.me.Email, + Tier: v.me.Tier, + TeamName: v.me.TeamName, + APIURL: apiURL, + KeyDisplay: keyDisplay, + SecretBackend: backend, } - if apiURL == "" { - apiURL = "https://api.instanode.dev" + if !v.validated { + out.Error = whoamiErrorNote(v) } + // Encode errors to stdout are not actionable (matches json_error.go's + // _ = enc.Encode convention); the exit code below carries the result. + _ = encodeWhoamiJSON(out) + // The identity envelope above already carries the `error` field, so we + // must NOT funnel through wrapJSONErr (it would emit a SECOND envelope). + // Return the bare exit-code error so cobra exits non-zero while keeping + // stdout to a single JSON object. + cmd.SilenceUsage = true + cmd.SilenceErrors = true + return whoamiExitCode(v) + } - if whoamiJSON { - out := whoamiJSONOutput{ - Authenticated: cfg.IsAuthenticated(), - Email: cfg.Email, - Tier: cfg.EffectiveTier(), - TeamName: cfg.TeamName, - APIURL: apiURL, - KeyDisplay: secretstore.TruncateForDisplay(cfg.APIKey), - SecretBackend: cfg.SecretBackendName(), - } - enc := json.NewEncoder(os.Stdout) - enc.SetIndent("", " ") - return enc.Encode(out) + if v.validated { + fmt.Printf("Email: %s\n", v.me.Email) + fmt.Printf("Plan: %s\n", v.me.Tier) + if v.me.TeamName != "" { + fmt.Printf("Team: %s\n", v.me.TeamName) } + fmt.Printf("API URL: %s\n", apiURL) + // T16 P1-1: never display more than 8 chars of the bearer token, and + // surface which backend holds it. + fmt.Printf("Key: %s\n", keyDisplay) + fmt.Printf("Stored: %s\n", backend) + return nil + } - if !cfg.IsAuthenticated() { - fmt.Println("Not logged in (anonymous mode).") - fmt.Printf("Run `instant login` to authenticate, or `instant db new` to provision a database without an account.\n") - return nil - } + if v.offline { + fmt.Printf("Could not verify credentials: %s\n", whoamiErrorNote(v)) + fmt.Printf("API URL: %s\n", apiURL) + fmt.Printf("Key: %s\n", keyDisplay) + } else { + fmt.Println("Not authenticated: the server rejected the presented token.") + fmt.Println("Run `instant login`, or set INSTANT_TOKEN to a valid Personal Access Token.") + } + // The human-readable lines above ARE the user-facing message; silence + // cobra's own "Error: …\nUsage:" block so it isn't printed twice. + cmd.SilenceUsage = true + cmd.SilenceErrors = true + return whoamiExitCode(v) +} - fmt.Printf("Email: %s\n", cfg.Email) - fmt.Printf("Plan: %s\n", cfg.EffectiveTier()) - if cfg.TeamName != "" { - fmt.Printf("Team: %s\n", cfg.TeamName) +// whoamiErrorNote returns a stable, human-readable explanation for a failed +// validation. For the offline case it surfaces a generic, agent-stable phrase +// (not the raw Go transport string) so scripts can branch on it. +func whoamiErrorNote(v whoamiValidation) string { + if v.offline { + if v.err != nil { + return "could not reach the server to validate the token: " + v.err.Error() } - fmt.Printf("API URL: %s\n", apiURL) - // T16 P1-1: never display more than 8 chars of the bearer token, - // and surface which backend holds it so the user can tell - // "macOS Keychain" from "on-disk fallback". - fmt.Printf("Key: %s\n", secretstore.TruncateForDisplay(cfg.APIKey)) - fmt.Printf("Stored: %s\n", cfg.SecretBackendName()) + return "could not reach the server to validate the token" + } + return "the server rejected the presented token" +} + +// whoamiExitCode maps a validation outcome to the CLI's exit-code contract: +// a confirmed token is success (nil → exit 0); a server rejection is a +// session/auth problem (errSessionExpired → exit 3); an offline check that +// could not confirm is a generic failure (exit 1). The returned error carries +// the exit code only — the human/JSON output has already been written by the +// caller, so this never prints anything itself. +func whoamiExitCode(v whoamiValidation) error { + if v.validated { return nil - }, + } + if v.offline { + base := errors.New("could not validate credentials") + if v.err != nil { + base = fmt.Errorf("could not validate credentials: %w", v.err) + } + return withExitCode(ExitGeneric, base) + } + return errSessionExpired() +} + +// encodeWhoamiJSON writes the identity object to stdout with the shared +// two-space indentation used by every other --json command. +func encodeWhoamiJSON(out whoamiJSONOutput) error { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(out) } var logoutCmd = &cobra.Command{