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
608 changes: 608 additions & 0 deletions cmd/agent_dx_fixes_test.go

Large diffs are not rendered by default.

8 changes: 6 additions & 2 deletions cmd/bughunt_p2_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions cmd/extras.go
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,7 @@
return w.Flush()
}

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

Check warning on line 238 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 Expand Up @@ -318,6 +318,10 @@
"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)
Expand Down
35 changes: 30 additions & 5 deletions cmd/integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
// and the resolved-env default (empty env => "development", CLAUDE.md rule 11).
//
// RESOURCE CLEANUP IS MANDATORY. Every test that provisions a resource:
// 1. defers a per-resource teardown that DELETEs it from the mock, AND

Check warning on line 22 in cmd/integration_test.go

View workflow job for this annotation

GitHub Actions / typos

"DELET" should be "DELETE".
// 2. is wrapped by the suite-level TestMain sweep which fails the run if
// ANY resource is left behind on the mock API.
// A test that provisions and does not clean up is therefore a hard failure.
Expand Down Expand Up @@ -219,14 +219,18 @@
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
}
}
}
}
Expand Down Expand Up @@ -388,11 +392,32 @@
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 `<group> 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
Expand Down
2 changes: 1 addition & 1 deletion cmd/json_error.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
52 changes: 47 additions & 5 deletions cmd/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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 <pat> ...`"+` or by exporting
`+"`INSTANT_TOKEN=<pat>`"+` in your shell.
Expand All @@ -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)
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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}
Expand Down
80 changes: 74 additions & 6 deletions cmd/monitor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand 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).
Expand All @@ -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)
Expand All @@ -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 {
Expand Down Expand Up @@ -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")
}

Expand Down
8 changes: 7 additions & 1 deletion cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 31 additions & 0 deletions cmd/testapi_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
})
Expand Down
Loading
Loading