From e032f89bef41763190f9ae99ea563db84ec560a2 Mon Sep 17 00:00:00 2001 From: Arnold Cubici-Jones <108676317+AJCJ1@users.noreply.github.com> Date: Wed, 19 Aug 2026 09:30:43 +0100 Subject: [PATCH 1/8] feat(cli)!: device login, account management, and credential resources - urlbox login device flow: browser approve, org/project pickers, session plus the project's render key/secret pair stored so rendering works immediately - session commands: logout, whoami/me, orgs list/select, projects list/select/create/rename/enable/disable/delete/defaults, usage - org credential resources: storage, proxies, llm groups (list/show/create/update/delete, llm test/models) and projects storage|proxy|llm assign/unassign - BREAKING: urlbox auth removed; login is the interactive path, URLBOX_API_SECRET stays for CI; doctor reworked with session-aware checks; CHANGELOG v0.11.0 entry included - text mode renders shared lipgloss tables and KV boxes; link prints its signed URL; doctor prints per-check hints with correct failure glyph - confirm gates (retype for delete, y/n for disable, --yes for agents); creates accept a positional name; deleting the active project re-selects; disabling a disabled project is a stated no-op - --no-retry/--max-retries on session commands; compatibility suite pins every pre-existing command across legacy and post-login configs --- CHANGELOG.md | 44 + README.md | 167 +- SURFACE.txt | 414 +- ...agement-port-1-login-context-management.md | 3961 +++++++++++++++++ ...26-08-12-account-management-port-design.md | 127 + .../2026-08-12-plan1-agent-layer.md | 449 ++ .../2026-08-14-text-surface-audit.md | 42 + .../2026-08-18-plan2-agent-layer.md | 226 + .../verification/manual-checklist.md | 184 + go.mod | 17 +- go.sum | 51 +- internal/api/http_client.go | 2 +- internal/api/http_client_test.go | 8 +- internal/api/session_client.go | 184 + internal/api/session_client_test.go | 104 + internal/api/smoke_test.go | 4 +- internal/cmd/auth.go | 318 -- internal/cmd/auth_preflight.go | 2 +- internal/cmd/auth_test.go | 725 --- internal/cmd/compat_session_config_test.go | 201 + internal/cmd/config.go | 55 +- internal/cmd/config_session_keys_test.go | 67 + internal/cmd/config_test.go | 37 +- internal/cmd/credbody.go | 270 ++ internal/cmd/credbody_test.go | 115 + internal/cmd/credkind.go | 151 + internal/cmd/credkind_test.go | 146 + internal/cmd/doctor.go | 216 +- internal/cmd/doctor_test.go | 187 +- internal/cmd/error_hints_test.go | 6 +- internal/cmd/link.go | 10 +- internal/cmd/link_test.go | 23 +- internal/cmd/llm.go | 407 ++ internal/cmd/llm_test.go | 426 ++ internal/cmd/login.go | 198 + internal/cmd/login_hint.go | 6 + internal/cmd/login_render_e2e_test.go | 61 + internal/cmd/login_resolve.go | 161 + internal/cmd/login_resolve_test.go | 192 + internal/cmd/login_test.go | 308 ++ internal/cmd/logout.go | 67 + internal/cmd/logout_test.go | 87 + internal/cmd/masking.go | 34 + internal/cmd/masking_test.go | 61 + internal/cmd/nameid.go | 80 + internal/cmd/nameid_test.go | 111 + internal/cmd/orgs.go | 198 + internal/cmd/orgs_test.go | 145 + internal/cmd/projects.go | 890 ++++ internal/cmd/projects_assign_test.go | 246 + internal/cmd/projects_crud_test.go | 696 +++ internal/cmd/projects_show_test.go | 137 + internal/cmd/projects_test.go | 195 + internal/cmd/proxies.go | 303 ++ internal/cmd/proxies_test.go | 343 ++ internal/cmd/render_test.go | 4 +- internal/cmd/rendercred.go | 58 + internal/cmd/rendercred_test.go | 63 + internal/cmd/root.go | 10 +- internal/cmd/root_test.go | 13 + internal/cmd/secret_input.go | 2 +- internal/cmd/session_helpers.go | 148 + internal/cmd/session_retry_test.go | 82 + internal/cmd/stdin_tty.go | 26 + internal/cmd/storage.go | 436 ++ internal/cmd/storage_test.go | 335 ++ internal/cmd/text_render_test.go | 136 + internal/cmd/usage.go | 54 + internal/cmd/usage_test.go | 48 + internal/cmd/whoami.go | 97 + internal/cmd/whoami_test.go | 91 + internal/config/config.go | 2 +- internal/config/profile.go | 14 +- internal/config/profile_session_test.go | 72 + internal/config/resolve.go | 28 +- internal/deviceauth/poll.go | 49 + internal/deviceauth/poll_test.go | 151 + internal/output/envelope.go | 15 + internal/output/envelope_test.go | 6 +- internal/output/errors_test.go | 2 +- internal/output/format.go | 22 +- internal/output/format_test.go | 65 + internal/output/render.go | 93 + internal/output/render_test.go | 138 + internal/output/style.go | 15 + internal/output/text_view_test.go | 105 + internal/prompt/prompt.go | 92 + internal/prompt/prompt_test.go | 40 + npm/README.md | 25 +- skills/SKILL.md | 88 +- 90 files changed, 15183 insertions(+), 1307 deletions(-) create mode 100644 docs/superpowers/plans/2026-08-12-account-management-port-1-login-context-management.md create mode 100644 docs/superpowers/specs/2026-08-12-account-management-port-design.md create mode 100644 docs/superpowers/verification/2026-08-12-plan1-agent-layer.md create mode 100644 docs/superpowers/verification/2026-08-14-text-surface-audit.md create mode 100644 docs/superpowers/verification/2026-08-18-plan2-agent-layer.md create mode 100644 docs/superpowers/verification/manual-checklist.md create mode 100644 internal/api/session_client.go create mode 100644 internal/api/session_client_test.go delete mode 100644 internal/cmd/auth.go delete mode 100644 internal/cmd/auth_test.go create mode 100644 internal/cmd/compat_session_config_test.go create mode 100644 internal/cmd/config_session_keys_test.go create mode 100644 internal/cmd/credbody.go create mode 100644 internal/cmd/credbody_test.go create mode 100644 internal/cmd/credkind.go create mode 100644 internal/cmd/credkind_test.go create mode 100644 internal/cmd/llm.go create mode 100644 internal/cmd/llm_test.go create mode 100644 internal/cmd/login.go create mode 100644 internal/cmd/login_hint.go create mode 100644 internal/cmd/login_render_e2e_test.go create mode 100644 internal/cmd/login_resolve.go create mode 100644 internal/cmd/login_resolve_test.go create mode 100644 internal/cmd/login_test.go create mode 100644 internal/cmd/logout.go create mode 100644 internal/cmd/logout_test.go create mode 100644 internal/cmd/masking.go create mode 100644 internal/cmd/masking_test.go create mode 100644 internal/cmd/nameid.go create mode 100644 internal/cmd/nameid_test.go create mode 100644 internal/cmd/orgs.go create mode 100644 internal/cmd/orgs_test.go create mode 100644 internal/cmd/projects.go create mode 100644 internal/cmd/projects_assign_test.go create mode 100644 internal/cmd/projects_crud_test.go create mode 100644 internal/cmd/projects_show_test.go create mode 100644 internal/cmd/projects_test.go create mode 100644 internal/cmd/proxies.go create mode 100644 internal/cmd/proxies_test.go create mode 100644 internal/cmd/rendercred.go create mode 100644 internal/cmd/rendercred_test.go create mode 100644 internal/cmd/session_helpers.go create mode 100644 internal/cmd/session_retry_test.go create mode 100644 internal/cmd/stdin_tty.go create mode 100644 internal/cmd/storage.go create mode 100644 internal/cmd/storage_test.go create mode 100644 internal/cmd/text_render_test.go create mode 100644 internal/cmd/usage.go create mode 100644 internal/cmd/usage_test.go create mode 100644 internal/cmd/whoami.go create mode 100644 internal/cmd/whoami_test.go create mode 100644 internal/config/profile_session_test.go create mode 100644 internal/deviceauth/poll.go create mode 100644 internal/deviceauth/poll_test.go create mode 100644 internal/output/render.go create mode 100644 internal/output/render_test.go create mode 100644 internal/output/text_view_test.go create mode 100644 internal/prompt/prompt.go create mode 100644 internal/prompt/prompt_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index cf0c967..b6d28b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,50 @@ All notable changes to the `urlbox` CLI are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project follows [SemVer](https://semver.org/spec/v2.0.0.html). +## v0.11.0 — 2026-08-19 + +**Browser login and account management.** `urlbox login` signs in via +the browser and stores the session and the active project's render +credentials. Adds org and project switching, project CRUD, usage, and +management of the organisation's storage, proxy, and LLM credentials. + +### Breaking + +- **`urlbox auth` is removed.** Interactive setup is `urlbox login`. + CI and headless environments are unchanged: set `URLBOX_API_SECRET` + and every render command works exactly as before. Scripts that + called `urlbox auth --api-secret*` should write the profile with + `urlbox config set api_secret` (same flags, same masking) or move + to the env var. +- **`urlbox doctor` now checks the session world** — nine checks + including session validity, active org/project, and render-credential + validity (the old `auth` check folded into `render_credential`). + A machine that authenticates only via `URLBOX_API_SECRET` fails the + three session checks and exits 3. + +### Added + +- `login`, `logout`, `whoami` (alias `me`), `usage`, `orgs list|select`. +- `projects list|select|show|create|rename|enable|disable|delete` and + `projects defaults show|set|remove` — with retype-to-confirm deletes, + a y/n gate on disable, and `--yes` to skip every prompt for agents. + Deleting the active project re-selects the survivor or offers a picker. +- `storage`, `proxies`, `llm` groups (list/show/create/update/delete, + plus `llm test` and `llm models`) and + `projects storage|proxy|llm assign|unassign`. Secrets are masked in + every human view; `--reveal` unhides. Every `create` takes the name + positionally (`--name` works too). +- Lists render as tables and detail views as aligned blocks in text + mode; JSON output is unchanged. +- `--no-retry` / `--max-retries` on the session commands, matching + `render` and `status`. + +### Fixed + +- `link` in text mode now prints the signed URL it generates. +- `doctor` in text mode now prints the per-check table with hints, and + no longer shows a `✓` when checks failed. + ## v0.10.0 — 2026-05-19 **Early-access version reset.** The v1.0.0–v1.0.4 line (published diff --git a/README.md b/README.md index 8ee0c8c..5c3c06b 100644 --- a/README.md +++ b/README.md @@ -56,8 +56,8 @@ The secret authenticates render API calls. The **API key** (publishable, ## Quick Start ```sh -# One-time: store your API secret (get it from urlbox.com/dashboard/projects) -urlbox auth --api-secret ubx_sk_xxxxxxxxxxxx +# One-time: sign in through the browser (CI/headless: set URLBOX_API_SECRET instead) +urlbox login # Render a URL — saves screenshot.png to the current directory urlbox render https://example.com --output screenshot.png @@ -76,6 +76,17 @@ urlbox render https://example.com --curl # Verify install, config, and credentials urlbox doctor +# Sign in through the browser (CI/headless: set URLBOX_API_SECRET instead) +urlbox login + +# Inspect the signed-in account, organisations, and projects +urlbox whoami +urlbox orgs list +urlbox projects list + +# Render usage summary for the active organisation +urlbox usage + # Self-discovery for agents urlbox commands --output-format json # full command catalog urlbox render --help --agent # structured JSON help @@ -166,21 +177,24 @@ Local hard errors that always reject before sending: payloads larger than 1 MiB, URL-like fields with control characters, malformed JSON. Everything else flows to the API. -### `auth` +### `login` -Saves your Urlbox API secret to `~/.config/urlbox/config.json` (mode 0600). The -env var `URLBOX_API_SECRET` takes precedence at runtime if both are set. +Signs in through your browser using the device flow. Prints a short code, opens +the approval page, and once you approve stores a session, sets your active +organisation and project, and fetches the active project's render credential so +render commands work immediately. ```sh -# Non-interactive (preferred for agents and CI) -urlbox auth --api-secret sec_xxxxxxxxxxxx +# Sign in and pick org + project interactively +urlbox login -# Interactive (humans on a TTY) — prompts once with masked echo -urlbox auth +# Skip the pickers +urlbox login --org acme --project production ``` -The interactive path is gated on stdin AND stderr being TTYs, so headless -agents and piped invocations always require `--api-secret`. +CI and headless environments should set `URLBOX_API_SECRET` instead — the device +flow needs a browser. `URLBOX_API_SECRET` takes precedence at runtime over the +stored render credential. ### `config` @@ -204,7 +218,7 @@ urlbox config profile delete work **Profile-target resolution for `config set` / `config get`** (per-profile keys): -- 0 profiles: errors with "No profiles configured" — bootstrap with `urlbox auth`. +- 0 profiles: errors with "No profiles configured" — bootstrap with `urlbox login`. - 1 profile: `--profile` is implicit; `config set api_secret sk_xxx` Just Works. - 2+ profiles: `--profile` is required; the error lists configured names. @@ -251,6 +265,115 @@ asset) and `failed` / `error` (exit 10). Non-terminal states (`created`, `retrying`, `processing`) without `--wait` return `ok: true` with a breadcrumb suggesting `urlbox status --wait`. +### `storage` + +Manage the active organisation's storage credentials. Storage credentials are +owned by the organisation and assigned to projects — create one once, then +assign it to any project's renders. Secrets are masked on display; pass +`--reveal` for full values (JSON output always includes them in full). + +```sh +# List, show (masked), show with secrets revealed +urlbox storage list +urlbox storage show prod-bucket +urlbox storage show prod-bucket --reveal + +# Create (name as a positional or --name; typed flags or a full --json payload; typed flags win) +urlbox storage create prod --provider aws_s3 --bucket b --region us-east-1 --key k --secret s +urlbox storage create --json '{"name":"prod","type":"s3","provider":"aws_s3","bucket":"b","key":"k","secret":"s","region":"us-east-1"}' + +# Create and assign to a project in one step +urlbox storage create prod --provider aws_s3 --bucket b --assign-to my-project + +# Update only the fields you pass +urlbox storage update prod --region eu-west-1 + +# Delete (retype-to-confirm; --yes skips the prompt) +urlbox storage delete prod --yes +``` + +Providers: `aws_s3`, `google_cloud_storage`, `cloudflare_r2`, `backblaze_b2`, +`digitalocean_spaces`, `wasabi`, `custom`, `azure`. A name or id resolves the +target; ids carry the `store_` prefix. + +### `proxies` + +Manage the active organisation's proxy pools (alias: `proxy`). Proxy pools are +owned by the organisation and assigned to projects. Proxy URLs routinely embed +credentials, so the password portion is masked on display; pass `--reveal` for +full values (JSON output always includes them in full). + +```sh +# List, show (password masked), show revealed +urlbox proxies list +urlbox proxies show eu +urlbox proxies show eu --reveal + +# Create with one or more proxy URLs (name as a positional or --name; --url is repeatable) +urlbox proxies create eu --url http://user:pass@host:8080 + +# Create and assign to a project +urlbox proxies create eu --url http://user:pass@host:8080 --assign-to my-project + +# Update the name and/or the whole URL list (any --url replaces the list) +urlbox proxies update eu --url http://user:pass@host:8080 + +# Delete (retype-to-confirm; --yes skips the prompt) +urlbox proxies delete eu --yes +``` + +A name or id resolves the target; ids carry the `pool_` prefix. + +### `llm` + +Manage the active organisation's LLM credentials. LLM credentials are owned by +the organisation and assigned to projects. Secrets are masked on display; pass +`--reveal` for full values (JSON output always includes them in full). + +```sh +# List, show (masked), show revealed +urlbox llm list +urlbox llm show openai-prod +urlbox llm show openai-prod --reveal + +# Create (name as a positional or --name; typed flags or a full --json payload; typed flags win) +urlbox llm create openai --provider openai --api-key sk-… +urlbox llm create openai --provider openai --api-key sk-… --assign-to my-project + +# Update only the fields you pass +urlbox llm update openai --model gpt-5-mini + +# Test the stored credential's connection, list the provider's model ids +urlbox llm test openai +urlbox llm models openai + +# Delete (retype-to-confirm; --yes skips the prompt) +urlbox llm delete openai --yes +``` + +Providers include `openai`, `anthropic`, `azure`, `amazon-bedrock`, and +`google-vertex`. `llm test` returns exit 0 with `Connection OK` on success, or +a non-zero exit with the provider's error on failure. A name or id resolves the +target; ids carry the `llm_` prefix. + +### `projects assign` / `unassign` + +Assign an org-owned credential to a project, or unassign the project's current +one. A project holds at most one storage credential, one proxy pool, and one +LLM credential. `` is `storage`, `proxy`, or `llm`. + +```sh +# Assign a credential (by name or id) to a project (by name or id) +urlbox projects storage assign my-project prod-bucket +urlbox projects proxy assign my-project eu +urlbox projects llm assign my-project openai + +# Unassign the project's current credential of that kind +urlbox projects storage unassign my-project +urlbox projects proxy unassign my-project +urlbox projects llm unassign my-project +``` + ### `dashboard` Opens https://urlbox.com/dashboard in your default browser. On headless @@ -272,20 +395,29 @@ In a terminal, output is a human-readable table. When piped or with `--output-fo $ urlbox commands Available commands: - auth Configure API credentials commands List all available commands config Inspect and modify CLI configuration dashboard Open the Urlbox dashboard in your browser doctor Check installation, configuration, network, and credentials link Generate an HMAC-signed render URL (no API call) + llm Manage org LLM credentials + login Sign in via your browser (device flow) + logout Sign out and revoke this device's session + orgs Manage the active organisation pdf Render a URL as PDF (alias for `render --format pdf --full-page`) + projects Manage projects and the active project + proxies Manage org proxy pools render Render a URL to a screenshot, PDF, video, or other format schema Print JSON Schemas describing Urlbox API payloads screenshot Capture a screenshot (alias for `render --format png`) skill Agent skill content status Look up the state of an async render + storage Manage org storage credentials upgrade Update urlbox to the latest version + usage Show the organisation's render usage for the current period + version Print CLI version, commit, and build date video Render a URL as MP4 video (alias for `render --format mp4`) + whoami Show the signed-in user and active context Use "urlbox --help" for more information about a command. ``` @@ -293,8 +425,9 @@ Use "urlbox --help" for more information about a command. ### `doctor` Diagnoses installation, configuration, network, and credential issues. Runs -seven checks: version, install method, config file, API secret, DNS, API -reachability, and credential validity. Exits non-zero if any check fails. +nine checks: version, install method, config file, session, active org, active +project, render credential, DNS, and API reachability. +Exits non-zero if any check fails. ```sh urlbox doctor @@ -415,8 +548,8 @@ urlbox --profile render # 2. Env var — preferred for CI / containers export URLBOX_API_SECRET=sec_xxxxxxxxxxxx -# 3. Persisted to ~/.config/urlbox/config.json (mode 0600) -urlbox auth --api-secret sec_xxxxxxxxxxxx +# 3. Sign in through the browser — persists a session + render credential +urlbox login ``` The full priority chain: diff --git a/SURFACE.txt b/SURFACE.txt index f1f3d96..4976bcb 100644 --- a/SURFACE.txt +++ b/SURFACE.txt @@ -17,15 +17,6 @@ urlbox --agent urlbox --jq urlbox --output-format urlbox --profile -urlbox auth -urlbox auth --agent -urlbox auth --api-secret -urlbox auth --api-secret-file -urlbox auth --api-secret-stdin -urlbox auth --force -urlbox auth --jq -urlbox auth --output-format -urlbox auth --profile urlbox commands urlbox commands --agent urlbox commands --jq @@ -105,6 +96,114 @@ urlbox link [url] --json urlbox link [url] --output-format urlbox link [url] --profile urlbox link [url] --url +urlbox llm +urlbox llm --agent +urlbox llm --jq +urlbox llm --max-retries +urlbox llm --no-retry +urlbox llm --output-format +urlbox llm --profile +urlbox llm create +urlbox llm create --agent +urlbox llm create --api-key +urlbox llm create --assign-to +urlbox llm create --base-url +urlbox llm create --jq +urlbox llm create --json +urlbox llm create --max-retries +urlbox llm create --model +urlbox llm create --name +urlbox llm create --no-retry +urlbox llm create --output-format +urlbox llm create --profile +urlbox llm create --provider +urlbox llm delete +urlbox llm delete --agent +urlbox llm delete --jq +urlbox llm delete --max-retries +urlbox llm delete --no-retry +urlbox llm delete --output-format +urlbox llm delete --profile +urlbox llm delete --yes +urlbox llm list +urlbox llm list --agent +urlbox llm list --jq +urlbox llm list --max-retries +urlbox llm list --no-retry +urlbox llm list --output-format +urlbox llm list --profile +urlbox llm models +urlbox llm models --agent +urlbox llm models --jq +urlbox llm models --max-retries +urlbox llm models --no-retry +urlbox llm models --output-format +urlbox llm models --profile +urlbox llm show +urlbox llm show --agent +urlbox llm show --jq +urlbox llm show --max-retries +urlbox llm show --no-retry +urlbox llm show --output-format +urlbox llm show --profile +urlbox llm show --reveal +urlbox llm test +urlbox llm test --agent +urlbox llm test --jq +urlbox llm test --max-retries +urlbox llm test --no-retry +urlbox llm test --output-format +urlbox llm test --profile +urlbox llm update +urlbox llm update --agent +urlbox llm update --api-key +urlbox llm update --base-url +urlbox llm update --jq +urlbox llm update --json +urlbox llm update --max-retries +urlbox llm update --model +urlbox llm update --name +urlbox llm update --no-retry +urlbox llm update --output-format +urlbox llm update --profile +urlbox llm update --provider +urlbox login +urlbox login --agent +urlbox login --jq +urlbox login --max-retries +urlbox login --no-retry +urlbox login --org +urlbox login --output-format +urlbox login --profile +urlbox login --project +urlbox logout +urlbox logout --agent +urlbox logout --jq +urlbox logout --max-retries +urlbox logout --no-retry +urlbox logout --output-format +urlbox logout --profile +urlbox orgs +urlbox orgs --agent +urlbox orgs --jq +urlbox orgs --max-retries +urlbox orgs --no-retry +urlbox orgs --output-format +urlbox orgs --profile +urlbox orgs list +urlbox orgs list --agent +urlbox orgs list --jq +urlbox orgs list --max-retries +urlbox orgs list --no-retry +urlbox orgs list --output-format +urlbox orgs list --profile +urlbox orgs select [name-or-id] +urlbox orgs select [name-or-id] --agent +urlbox orgs select [name-or-id] --jq +urlbox orgs select [name-or-id] --max-retries +urlbox orgs select [name-or-id] --no-retry +urlbox orgs select [name-or-id] --output-format +urlbox orgs select [name-or-id] --profile urlbox pdf [url] urlbox pdf [url] --agent urlbox pdf [url] --api-secret @@ -136,6 +235,216 @@ urlbox pdf [url] --user-agent urlbox pdf [url] --wait-until urlbox pdf [url] --webhook-url urlbox pdf [url] --width +urlbox projects +urlbox projects --agent +urlbox projects --jq +urlbox projects --max-retries +urlbox projects --no-retry +urlbox projects --output-format +urlbox projects --profile +urlbox projects create +urlbox projects create --agent +urlbox projects create --jq +urlbox projects create --max-retries +urlbox projects create --no-retry +urlbox projects create --output-format +urlbox projects create --profile +urlbox projects create --select +urlbox projects defaults +urlbox projects defaults --agent +urlbox projects defaults --jq +urlbox projects defaults --max-retries +urlbox projects defaults --no-retry +urlbox projects defaults --output-format +urlbox projects defaults --profile +urlbox projects defaults remove +urlbox projects defaults remove --agent +urlbox projects defaults remove --jq +urlbox projects defaults remove --max-retries +urlbox projects defaults remove --no-retry +urlbox projects defaults remove --output-format +urlbox projects defaults remove --profile +urlbox projects defaults remove --yes +urlbox projects defaults set --json +urlbox projects defaults set --json --agent +urlbox projects defaults set --json --jq +urlbox projects defaults set --json --json +urlbox projects defaults set --json --max-retries +urlbox projects defaults set --json --merge +urlbox projects defaults set --json --no-retry +urlbox projects defaults set --json --output-format +urlbox projects defaults set --json --profile +urlbox projects defaults show +urlbox projects defaults show --agent +urlbox projects defaults show --jq +urlbox projects defaults show --max-retries +urlbox projects defaults show --no-retry +urlbox projects defaults show --output-format +urlbox projects defaults show --profile +urlbox projects delete +urlbox projects delete --agent +urlbox projects delete --jq +urlbox projects delete --max-retries +urlbox projects delete --no-retry +urlbox projects delete --output-format +urlbox projects delete --profile +urlbox projects delete --yes +urlbox projects disable +urlbox projects disable --agent +urlbox projects disable --jq +urlbox projects disable --max-retries +urlbox projects disable --no-retry +urlbox projects disable --output-format +urlbox projects disable --profile +urlbox projects disable --yes +urlbox projects enable +urlbox projects enable --agent +urlbox projects enable --jq +urlbox projects enable --max-retries +urlbox projects enable --no-retry +urlbox projects enable --output-format +urlbox projects enable --profile +urlbox projects list +urlbox projects list --agent +urlbox projects list --jq +urlbox projects list --max-retries +urlbox projects list --no-retry +urlbox projects list --output-format +urlbox projects list --profile +urlbox projects llm +urlbox projects llm --agent +urlbox projects llm --jq +urlbox projects llm --max-retries +urlbox projects llm --no-retry +urlbox projects llm --output-format +urlbox projects llm --profile +urlbox projects llm assign +urlbox projects llm assign --agent +urlbox projects llm assign --jq +urlbox projects llm assign --max-retries +urlbox projects llm assign --no-retry +urlbox projects llm assign --output-format +urlbox projects llm assign --profile +urlbox projects llm unassign +urlbox projects llm unassign --agent +urlbox projects llm unassign --jq +urlbox projects llm unassign --max-retries +urlbox projects llm unassign --no-retry +urlbox projects llm unassign --output-format +urlbox projects llm unassign --profile +urlbox projects proxy +urlbox projects proxy --agent +urlbox projects proxy --jq +urlbox projects proxy --max-retries +urlbox projects proxy --no-retry +urlbox projects proxy --output-format +urlbox projects proxy --profile +urlbox projects proxy assign +urlbox projects proxy assign --agent +urlbox projects proxy assign --jq +urlbox projects proxy assign --max-retries +urlbox projects proxy assign --no-retry +urlbox projects proxy assign --output-format +urlbox projects proxy assign --profile +urlbox projects proxy unassign +urlbox projects proxy unassign --agent +urlbox projects proxy unassign --jq +urlbox projects proxy unassign --max-retries +urlbox projects proxy unassign --no-retry +urlbox projects proxy unassign --output-format +urlbox projects proxy unassign --profile +urlbox projects rename +urlbox projects rename --agent +urlbox projects rename --jq +urlbox projects rename --max-retries +urlbox projects rename --no-retry +urlbox projects rename --output-format +urlbox projects rename --profile +urlbox projects select [name-or-id] +urlbox projects select [name-or-id] --agent +urlbox projects select [name-or-id] --jq +urlbox projects select [name-or-id] --max-retries +urlbox projects select [name-or-id] --no-retry +urlbox projects select [name-or-id] --output-format +urlbox projects select [name-or-id] --profile +urlbox projects show +urlbox projects show --agent +urlbox projects show --jq +urlbox projects show --max-retries +urlbox projects show --no-retry +urlbox projects show --output-format +urlbox projects show --profile +urlbox projects show --reveal +urlbox projects storage +urlbox projects storage --agent +urlbox projects storage --jq +urlbox projects storage --max-retries +urlbox projects storage --no-retry +urlbox projects storage --output-format +urlbox projects storage --profile +urlbox projects storage assign +urlbox projects storage assign --agent +urlbox projects storage assign --jq +urlbox projects storage assign --max-retries +urlbox projects storage assign --no-retry +urlbox projects storage assign --output-format +urlbox projects storage assign --profile +urlbox projects storage unassign +urlbox projects storage unassign --agent +urlbox projects storage unassign --jq +urlbox projects storage unassign --max-retries +urlbox projects storage unassign --no-retry +urlbox projects storage unassign --output-format +urlbox projects storage unassign --profile +urlbox proxies +urlbox proxies --agent +urlbox proxies --jq +urlbox proxies --max-retries +urlbox proxies --no-retry +urlbox proxies --output-format +urlbox proxies --profile +urlbox proxies create +urlbox proxies create --agent +urlbox proxies create --assign-to +urlbox proxies create --jq +urlbox proxies create --max-retries +urlbox proxies create --name +urlbox proxies create --no-retry +urlbox proxies create --output-format +urlbox proxies create --profile +urlbox proxies create --url +urlbox proxies delete +urlbox proxies delete --agent +urlbox proxies delete --jq +urlbox proxies delete --max-retries +urlbox proxies delete --no-retry +urlbox proxies delete --output-format +urlbox proxies delete --profile +urlbox proxies delete --yes +urlbox proxies list +urlbox proxies list --agent +urlbox proxies list --jq +urlbox proxies list --max-retries +urlbox proxies list --no-retry +urlbox proxies list --output-format +urlbox proxies list --profile +urlbox proxies show +urlbox proxies show --agent +urlbox proxies show --jq +urlbox proxies show --max-retries +urlbox proxies show --no-retry +urlbox proxies show --output-format +urlbox proxies show --profile +urlbox proxies show --reveal +urlbox proxies update +urlbox proxies update --agent +urlbox proxies update --jq +urlbox proxies update --max-retries +urlbox proxies update --name +urlbox proxies update --no-retry +urlbox proxies update --output-format +urlbox proxies update --profile +urlbox proxies update --url urlbox render [url] urlbox render [url] --agent urlbox render [url] --api-secret @@ -240,11 +549,91 @@ urlbox status --poll-interval urlbox status --profile urlbox status --timeout urlbox status --wait +urlbox storage +urlbox storage --agent +urlbox storage --jq +urlbox storage --max-retries +urlbox storage --no-retry +urlbox storage --output-format +urlbox storage --profile +urlbox storage create +urlbox storage create --account-name +urlbox storage create --agent +urlbox storage create --assign-to +urlbox storage create --bucket +urlbox storage create --cdn-host +urlbox storage create --container-name +urlbox storage create --endpoint +urlbox storage create --jq +urlbox storage create --json +urlbox storage create --key +urlbox storage create --max-retries +urlbox storage create --name +urlbox storage create --no-retry +urlbox storage create --object-lock +urlbox storage create --output-format +urlbox storage create --private-bucket +urlbox storage create --profile +urlbox storage create --provider +urlbox storage create --region +urlbox storage create --sas-token +urlbox storage create --secret +urlbox storage delete +urlbox storage delete --agent +urlbox storage delete --jq +urlbox storage delete --max-retries +urlbox storage delete --no-retry +urlbox storage delete --output-format +urlbox storage delete --profile +urlbox storage delete --yes +urlbox storage list +urlbox storage list --agent +urlbox storage list --jq +urlbox storage list --max-retries +urlbox storage list --no-retry +urlbox storage list --output-format +urlbox storage list --profile +urlbox storage show +urlbox storage show --agent +urlbox storage show --jq +urlbox storage show --max-retries +urlbox storage show --no-retry +urlbox storage show --output-format +urlbox storage show --profile +urlbox storage show --reveal +urlbox storage update +urlbox storage update --account-name +urlbox storage update --agent +urlbox storage update --bucket +urlbox storage update --cdn-host +urlbox storage update --container-name +urlbox storage update --endpoint +urlbox storage update --jq +urlbox storage update --json +urlbox storage update --key +urlbox storage update --max-retries +urlbox storage update --name +urlbox storage update --no-retry +urlbox storage update --object-lock +urlbox storage update --output-format +urlbox storage update --private-bucket +urlbox storage update --profile +urlbox storage update --provider +urlbox storage update --region +urlbox storage update --sas-token +urlbox storage update --secret urlbox upgrade urlbox upgrade --agent urlbox upgrade --jq urlbox upgrade --output-format urlbox upgrade --profile +urlbox usage +urlbox usage --agent +urlbox usage --jq +urlbox usage --max-retries +urlbox usage --no-retry +urlbox usage --output-format +urlbox usage --profile urlbox version urlbox version --agent urlbox version --jq @@ -281,3 +670,10 @@ urlbox video [url] --user-agent urlbox video [url] --wait-until urlbox video [url] --webhook-url urlbox video [url] --width +urlbox whoami +urlbox whoami --agent +urlbox whoami --jq +urlbox whoami --max-retries +urlbox whoami --no-retry +urlbox whoami --output-format +urlbox whoami --profile diff --git a/docs/superpowers/plans/2026-08-12-account-management-port-1-login-context-management.md b/docs/superpowers/plans/2026-08-12-account-management-port-1-login-context-management.md new file mode 100644 index 0000000..5445ed4 --- /dev/null +++ b/docs/superpowers/plans/2026-08-12-account-management-port-1-login-context-management.md @@ -0,0 +1,3961 @@ +# Account Management Port — Plan 1: Login, Context, Management Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Bring spec slices 1–3 of `docs/superpowers/specs/2026-08-12-account-management-port-design.md` into this repo: session config fields, the existing-command compatibility net, a session-authenticated API client, browser device-flow `login`/`logout`, `whoami`, `orgs`, `projects` (context + CRUD + defaults), and `usage`. + +**Architecture:** Behaviour is ported from `/Users/arnoldcubici-jones/Code/work/cli` (branch `feat/device-login`, the behaviour spec); every command is written natively in this repo's idioms (cobra, `output.Envelope`/`CLIError` closed codes, `config.Resolve`/`Update`, `apitest` fakes, `internal/clock`). Five framework-free logic pieces are transplanted with their tests. Slices 4–5 (credential resources, auth sweep) are Plan 2 — do not touch `auth.go`, `doctor.go`, or storage/proxies/llm here. + +**Tech Stack:** Go 1.23, cobra, charmbracelet/huh (new dep, picker only), lipgloss (present), httptest via `internal/api/apitest`, `internal/clock`. + +## Global Constraints + +- TDD from commit one: failing test → minimal implementation → green — every task, no exceptions. +- `make ci` (fmt-check, lint, test, build, surface-check) green at the end of every task; `make surface-snapshot` + commit `SURFACE.txt` alongside any surface change. +- stdout is for structured data only; stderr is for human messages — never mixed. +- Errors only from the closed set in `internal/output/errors.go`; "not logged in"/"session expired" → `output.ErrAuth` with hint `Run \`urlbox login\``. +- Every command writes envelopes via `writeEnvelope`/`writeEnvelopeWithQuietData` (`internal/cmd/config.go:614-650`); breadcrumbs point at the natural next command. +- gofumpt formatting (`make fmt`); zero code comments except where a constraint cannot be expressed in code. +- Profiles stay undocumented: no new help text mentions profiles beyond the inherited `--profile` flag. +- NO commits during tasks: mark the task complete and move on — all work accumulates uncommitted for a single review-gated commit at the end (this overrides the usual per-task commit convention). +- Do not modify: `auth.go`, `doctor.go`, `link.go`, `dashboard.go`, `skill.go`, `commands.go`, `upgrade.go`, `render*.go`, `screenshot.go`, `pdf.go`, `video.go`, `status.go` (Plan 2 owns the auth sweep; render-family behaviour must not change). + +**Source-repo shorthand:** `SRC = /Users/arnoldcubici-jones/Code/work/cli` (read-only reference). Target repo root is this repo. + +--- + +### Task 1: Profile session fields + +**Files:** +- Modify: `internal/config/profile.go` +- Modify: `internal/config/resolve.go` (extract profile-selection helper) +- Test: `internal/config/profile_session_test.go` (create) + +**Interfaces:** +- Produces: `Profile.SessionToken`, `Profile.ActiveOrg`, `Profile.ActiveProject` (JSON `session_token`, `active_org`, `active_project`); `config.ProfileName(flagProfile, envProfile string, overlay *RepoOverlay, cfg *Config) string` — the profile-selection chain (flag → repo overlay → env → default_profile → "default") reused by Resolve and by every session command in later tasks. + +- [ ] **Step 1: Write the failing test** + +Create `internal/config/profile_session_test.go`: + +```go +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestProfileSessionFieldsRoundTrip(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + err := Save(&Config{ + DefaultProfile: "default", + Profiles: map[string]Profile{"default": { + APISecret: "sk_live_1234567890", + SessionToken: "sess_tok_abcdef123456", + ActiveOrg: "org_01hxyz", + ActiveProject: "proj_01habc", + }}, + }) + if err != nil { + t.Fatalf("save: %v", err) + } + loaded, err := Load() + if err != nil { + t.Fatalf("load: %v", err) + } + p := loaded.Profiles["default"] + if p.SessionToken != "sess_tok_abcdef123456" { + t.Fatalf("session token dropped on roundtrip: %+v", p) + } + if p.ActiveOrg != "org_01hxyz" || p.ActiveProject != "proj_01habc" { + t.Fatalf("active org/project dropped: %+v", p) + } + info, err := os.Stat(Path()) + if err != nil { + t.Fatalf("stat: %v", err) + } + if info.Mode().Perm() != 0o600 { + t.Fatalf("config mode = %v, want 0600", info.Mode().Perm()) + } + if filepath.Dir(Path()) == "" { + t.Fatal("empty config dir") + } +} + +func TestProfileIsEmptyCountsSessionFields(t *testing.T) { + if (Profile{SessionToken: "tok"}).IsEmpty() { + t.Fatal("profile with only a session token must not be IsEmpty") + } + if !(Profile{}).IsEmpty() { + t.Fatal("zero profile must be IsEmpty") + } +} + +func TestProfileNameSelectionChain(t *testing.T) { + cfg := &Config{DefaultProfile: "team", Profiles: map[string]Profile{"team": {}}} + if got := ProfileName("flagged", "enved", &RepoOverlay{Profile: "repo"}, cfg); got != "flagged" { + t.Fatalf("flag must win, got %q", got) + } + if got := ProfileName("", "enved", &RepoOverlay{Profile: "repo"}, cfg); got != "repo" { + t.Fatalf("repo overlay must beat env, got %q", got) + } + if got := ProfileName("", "enved", nil, cfg); got != "enved" { + t.Fatalf("env must beat default_profile, got %q", got) + } + if got := ProfileName("", "", nil, cfg); got != "team" { + t.Fatalf("default_profile must beat literal default, got %q", got) + } + if got := ProfileName("", "", nil, &Config{}); got != "default" { + t.Fatalf("fallback must be default, got %q", got) + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/config/ -run 'TestProfileSession|TestProfileIsEmpty|TestProfileName' -v` +Expected: FAIL — `p.SessionToken undefined`, `undefined: ProfileName` (compile errors are the failing state for new fields). + +- [ ] **Step 3: Write minimal implementation** + +In `internal/config/profile.go`, replace the whole file with: + +```go +package config + +type Profile struct { + APIKey string `json:"api_key,omitempty"` + APISecret string `json:"api_secret,omitempty"` + APIHost string `json:"api_host,omitempty"` + SessionToken string `json:"session_token,omitempty"` + ActiveOrg string `json:"active_org,omitempty"` + ActiveProject string `json:"active_project,omitempty"` +} + +func (p Profile) IsEmpty() bool { + return p.APIKey == "" && p.APISecret == "" && p.APIHost == "" && + p.SessionToken == "" && p.ActiveOrg == "" && p.ActiveProject == "" +} +``` + +In `internal/config/resolve.go`, add after the `Source` type: + +```go +func ProfileName(flagProfile, envProfile string, overlay *RepoOverlay, cfg *Config) string { + switch { + case flagProfile != "": + return flagProfile + case overlay != nil && overlay.Profile != "": + return overlay.Profile + case envProfile != "": + return envProfile + case cfg != nil && cfg.DefaultProfile != "": + return cfg.DefaultProfile + default: + return "default" + } +} +``` + +and replace the profile-selection `switch` inside `Resolve` (the one assigning `r.Profile, r.Source.Profile`) with: + +```go + r.Profile = ProfileName(opts.FlagProfile, opts.EnvProfile, opts.RepoOverlay, opts.Config) + switch { + case opts.FlagProfile != "": + r.Source.Profile = "flag" + case opts.RepoOverlay != nil && opts.RepoOverlay.Profile != "": + r.Source.Profile = "repo" + case opts.EnvProfile != "": + r.Source.Profile = "env" + case opts.Config != nil && opts.Config.DefaultProfile != "": + r.Source.Profile = "default_profile" + default: + r.Source.Profile = "default" + } +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/config/ -v` +Expected: PASS, including every pre-existing config test (`resolve_test.go` must stay green — the extraction must not change selection behaviour). + +- [ ] **Step 5: Run `make ci`** + +Expected: green. `surface-check` unchanged (no command surface touched). + +- [ ] **Step 6: Mark task complete — NO commit** (work accumulates for one review-gated commit at the end). + +--- + +### Task 2: Existing-command compatibility suite + +**Files:** +- Test: `internal/cmd/compat_session_config_test.go` (create) + +**Interfaces:** +- Consumes: `cmd.Execute(args []string, stdout, stderr io.Writer) int` (root.go:36), `apitest.New`/`SuccessJSON`. +- Produces: `writeCompatConfig(t, dir, withSession bool)` and `compatCases()` used again by Task 16's verification step. + +- [ ] **Step 1: Write the failing-then-guarding test** + +Create `internal/cmd/compat_session_config_test.go`: + +```go +package cmd + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "testing" +) + +const compatSecret = "sk_test_abcdefgh12345678" + +func writeCompatConfig(t *testing.T, dir string, withSession bool) { + t.Helper() + profile := map[string]string{ + "api_key": "pk_test_key", + "api_secret": compatSecret, + } + if withSession { + profile["session_token"] = "sess_tok_compat_123456" + profile["active_org"] = "org_compat" + profile["active_project"] = "proj_compat" + } + cfg := map[string]any{ + "default_profile": "default", + "profiles": map[string]any{"default": profile}, + } + b, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + t.Fatalf("marshal: %v", err) + } + if err := os.MkdirAll(filepath.Join(dir, "urlbox"), 0o700); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, "urlbox", "config.json"), b, 0o600); err != nil { + t.Fatalf("write: %v", err) + } +} + +type compatCase struct { + name string + args []string +} + +func compatCases() []compatCase { + return []compatCase{ + {"render dry-run", []string{"render", "https://example.com", "--dry-run", "--output-format", "json"}}, + {"screenshot dry-run", []string{"screenshot", "https://example.com", "--dry-run", "--output-format", "json"}}, + {"pdf dry-run", []string{"pdf", "https://example.com", "--dry-run", "--output-format", "json"}}, + {"render curl", []string{"render", "https://example.com", "--curl", "--output-format", "json"}}, + {"link", []string{"link", "https://example.com", "--output-format", "json"}}, + {"config get secret", []string{"config", "get", "api_secret", "--output-format", "json"}}, + {"config path", []string{"config", "path", "--output-format", "quiet"}}, + {"config profile list", []string{"config", "profile", "list", "--output-format", "json"}}, + {"schema", []string{"schema", "render", "--output-format", "json"}}, + {"commands", []string{"commands", "--output-format", "json"}}, + {"version", []string{"version"}}, + } +} + +func runCompat(t *testing.T, args []string) (string, string, int) { + t.Helper() + var stdout, stderr bytes.Buffer + code := Execute(args, &stdout, &stderr) + return stdout.String(), stderr.String(), code +} + +func TestSessionFieldsDoNotChangeExistingCommands(t *testing.T) { + for _, tc := range compatCases() { + t.Run(tc.name, func(t *testing.T) { + legacyDir := t.TempDir() + writeCompatConfig(t, legacyDir, false) + t.Setenv("XDG_CONFIG_HOME", legacyDir) + legacyOut, legacyErr, legacyCode := runCompat(t, tc.args) + + sessionDir := t.TempDir() + writeCompatConfig(t, sessionDir, true) + t.Setenv("XDG_CONFIG_HOME", sessionDir) + sessionOut, sessionErr, sessionCode := runCompat(t, tc.args) + + if legacyCode != sessionCode { + t.Fatalf("exit code changed: legacy=%d session=%d\nlegacy stderr: %s\nsession stderr: %s", + legacyCode, sessionCode, legacyErr, sessionErr) + } + normalize := func(s, dir string) string { + return bytes.NewBufferString(s).String() + } + if normalize(legacyOut, legacyDir) != normalize(sessionOut, sessionDir) && + tc.name != "config path" { + t.Fatalf("stdout changed:\nlegacy: %s\nsession: %s", legacyOut, sessionOut) + } + }) + } +} + +func TestConfigSetPreservesSessionFields(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + _, stderr, code := runCompat(t, []string{"config", "set", "api_host", "https://api.urlbox.com"}) + if code != 0 { + t.Fatalf("config set failed (%d): %s", code, stderr) + } + b, err := os.ReadFile(filepath.Join(dir, "urlbox", "config.json")) + if err != nil { + t.Fatalf("read config: %v", err) + } + var cfg struct { + Profiles map[string]map[string]string `json:"profiles"` + } + if err := json.Unmarshal(b, &cfg); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if cfg.Profiles["default"]["session_token"] != "sess_tok_compat_123456" { + t.Fatalf("config set dropped session_token: %s", b) + } +} +``` + +- [ ] **Step 2: Run the suite** + +Run: `go test ./internal/cmd/ -run 'TestSessionFields|TestConfigSetPreserves' -v` +Expected: PASS on a correct Task 1 (`config set` goes through `config.Update` → `Save`, which now round-trips the struct fields). If `TestConfigSetPreservesSessionFields` FAILS, Task 1's struct fields are wrong — fix there, not here. `config path` differs by tempdir path — the case is exempted from stdout comparison but still asserts equal exit codes. + +- [ ] **Step 3: Adjust for repo reality** + +The `link` and `config get` cases may legitimately differ from the exact args above if flags differ in this repo — before finalising, run each case manually (`go run ./cmd/urlbox `) and fix ONLY the test's argument lists to the repo's real surface (never relax the equality assertions). + +- [ ] **Step 4: Run `make ci`** + +Expected: green. + +- [ ] **Step 5: Mark task complete — NO commit.** + +--- + +### Task 3: Session-authenticated API client + +**Files:** +- Create: `internal/api/session_client.go` +- Test: `internal/api/session_client_test.go` (create) + +**Interfaces:** +- Consumes: `RetryDo`, `DefaultRetryConfig`, `mapStatusToCLIError`, `BuildUserAgent` (all in `internal/api`), `output.CLIError`. +- Produces: + - `api.NewSessionClient(baseURL, token string) *SessionClient` + - `(*SessionClient) GetJSON(ctx context.Context, path string, out any) error` + - `(*SessionClient) PostJSON(ctx context.Context, path string, body, out any) error` + - `(*SessionClient) PatchJSON(ctx context.Context, path string, body, out any) error` + - `(*SessionClient) DeleteJSON(ctx context.Context, path string, out any) error` + - `(*SessionClient) DoRaw(ctx context.Context, method, path string, body any) (int, map[string]any, error)` — no error-code mapping; the device-poll loop (Task 4) reads RFC error strings from the raw body. + - `type SessionAPI interface { GetJSON(ctx context.Context, path string, out any) error; PostJSON(ctx context.Context, path string, body, out any) error; PatchJSON(ctx context.Context, path string, body, out any) error; DeleteJSON(ctx context.Context, path string, out any) error }` — every command and transplant tests against this, not the concrete client. + +- [ ] **Step 1: Write the failing test** + +Create `internal/api/session_client_test.go`: + +```go +package api + +import ( + "context" + "errors" + "testing" + + "github.com/urlbox/urlbox-cli/internal/api/apitest" + "github.com/urlbox/urlbox-cli/internal/output" +) + +func TestSessionClientSendsBearerTokenAndUserAgent(t *testing.T) { + srv := apitest.New(apitest.SuccessJSON(`{"ok":true}`)) + t.Cleanup(srv.Close) + c := NewSessionClient(srv.URL(), "sess_tok_123") + var out map[string]any + if err := c.GetJSON(context.Background(), "/v1/auth/get-session", &out); err != nil { + t.Fatalf("get: %v", err) + } + reqs := srv.Requests() + if len(reqs) != 1 { + t.Fatalf("want 1 request, got %d", len(reqs)) + } + if got := reqs[0].Header.Get("Authorization"); got != "Bearer sess_tok_123" { + t.Fatalf("auth header = %q", got) + } + if got := reqs[0].Header.Get("User-Agent"); got == "" { + t.Fatal("missing User-Agent") + } + if reqs[0].Path != "/v1/auth/get-session" { + t.Fatalf("path = %q", reqs[0].Path) + } +} + +func Test401MapsToAuthWithLoginHint(t *testing.T) { + srv := apitest.New(apitest.ScriptedResponse{Status: 401, Body: `{"error":{"message":"unauthorized"}}`}) + t.Cleanup(srv.Close) + c := NewSessionClient(srv.URL(), "sess_expired") + err := c.GetJSON(context.Background(), "/v2/usage", nil) + var cli *output.CLIError + if !errors.As(err, &cli) { + t.Fatalf("want CLIError, got %T %v", err, err) + } + if cli.Code != output.ErrAuth { + t.Fatalf("code = %q, want auth", cli.Code) + } + if cli.Hint == "" || cli.Hint != "Run `urlbox login` — your session is missing or expired." { + t.Fatalf("hint = %q", cli.Hint) + } +} + +func TestDoRawReturnsBodyWithoutMapping(t *testing.T) { + srv := apitest.New(apitest.ScriptedResponse{Status: 400, Body: `{"error":"authorization_pending"}`}) + t.Cleanup(srv.Close) + c := NewSessionClient(srv.URL(), "") + status, data, err := c.DoRaw(context.Background(), "POST", "/v1/auth/device/token", map[string]string{"a": "b"}) + if err != nil { + t.Fatalf("DoRaw transport error: %v", err) + } + if status != 400 { + t.Fatalf("status = %d", status) + } + if data["error"] != "authorization_pending" { + t.Fatalf("data = %#v", data) + } +} + +func TestSessionClientRetries429(t *testing.T) { + srv := apitest.New(apitest.RetryAfterSeconds(0), apitest.SuccessJSON(`{"fine":true}`)) + t.Cleanup(srv.Close) + c := NewSessionClient(srv.URL(), "tok") + var out map[string]any + if err := c.GetJSON(context.Background(), "/v2/projects", &out); err != nil { + t.Fatalf("expected retry to succeed: %v", err) + } + if len(srv.Requests()) != 2 { + t.Fatalf("want 2 requests (retry), got %d", len(srv.Requests())) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/api/ -run 'TestSessionClient|Test401Maps|TestDoRaw' -v` +Expected: FAIL — `undefined: NewSessionClient`. + +- [ ] **Step 3: Write minimal implementation** + +Create `internal/api/session_client.go`: + +```go +package api + +import ( + "bytes" + "context" + "crypto/tls" + "encoding/json" + "errors" + "io" + "net/http" + "strings" + "time" + + "github.com/urlbox/urlbox-cli/internal/output" + "github.com/urlbox/urlbox-cli/internal/version" +) + +type SessionAPI interface { + GetJSON(ctx context.Context, path string, out any) error + PostJSON(ctx context.Context, path string, body, out any) error + PatchJSON(ctx context.Context, path string, body, out any) error + DeleteJSON(ctx context.Context, path string, out any) error +} + +type SessionClient struct { + BaseURL string + Token string + UserAgent string + Timeout time.Duration + Retry RetryConfig + HTTP *http.Client +} + +func NewSessionClient(baseURL, token string) *SessionClient { + timeout := 30 * time.Second + return &SessionClient{ + BaseURL: baseURL, + Token: token, + UserAgent: BuildUserAgent(version.Version), + Timeout: timeout, + Retry: DefaultRetryConfig(), + HTTP: &http.Client{ + Timeout: timeout, + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12}, + }, + }, + } +} + +func (c *SessionClient) GetJSON(ctx context.Context, path string, out any) error { + return c.doJSON(ctx, http.MethodGet, path, nil, out) +} + +func (c *SessionClient) PostJSON(ctx context.Context, path string, body, out any) error { + if body == nil { + body = map[string]string{} + } + return c.doJSON(ctx, http.MethodPost, path, body, out) +} + +func (c *SessionClient) PatchJSON(ctx context.Context, path string, body, out any) error { + return c.doJSON(ctx, http.MethodPatch, path, body, out) +} + +func (c *SessionClient) DeleteJSON(ctx context.Context, path string, out any) error { + return c.doJSON(ctx, http.MethodDelete, path, nil, out) +} + +func (c *SessionClient) doJSON(ctx context.Context, method, path string, body, out any) error { + resp, respBody, err := c.send(ctx, method, path, body) + if err != nil { + return err + } + if resp.StatusCode >= 400 { + cli := mapStatusToCLIError(resp, respBody) + if cli.Code == output.ErrAuth { + return output.NewCLIError( + output.ErrAuth, + cli.Message, + "Run `urlbox login` — your session is missing or expired.", + ) + } + return cli + } + if out == nil || len(respBody) == 0 { + return nil + } + if err := json.Unmarshal(respBody, out); err != nil { + return output.NewCLIError(output.ErrServer, "failed to parse API response", err.Error()) + } + return nil +} + +func (c *SessionClient) DoRaw(ctx context.Context, method, path string, body any) (int, map[string]any, error) { + resp, respBody, err := c.send(ctx, method, path, body) + if err != nil { + return 0, nil, err + } + data := map[string]any{} + if len(respBody) > 0 { + if jerr := json.Unmarshal(respBody, &data); jerr != nil { + return resp.StatusCode, map[string]any{}, nil + } + } + return resp.StatusCode, data, nil +} + +func (c *SessionClient) send(ctx context.Context, method, path string, body any) (*http.Response, []byte, error) { + var bodyBytes []byte + if body != nil { + b, err := json.Marshal(body) + if err != nil { + return nil, nil, output.NewCLIError(output.ErrUsage, "failed to encode request body", err.Error()) + } + bodyBytes = b + } + send := func() (*http.Response, error) { + var reader io.Reader + if bodyBytes != nil { + reader = bytes.NewReader(bodyBytes) + } + req, err := http.NewRequestWithContext(ctx, method, c.BaseURL+path, reader) + if err != nil { + return nil, err + } + if c.Token != "" { + req.Header.Set("Authorization", "Bearer "+c.Token) + } + if bodyBytes != nil { + req.Header.Set("Content-Type", "application/json") + } + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", c.UserAgent) + return c.HTTP.Do(req) + } + resp, err := RetryDo(ctx, c.Retry, send) + if err != nil { + code := output.ErrNetwork + if errors.Is(err, context.DeadlineExceeded) || + strings.Contains(err.Error(), context.DeadlineExceeded.Error()) { + code = output.ErrTimeout + } + return nil, nil, output.NewCLIError(code, err.Error(), + "Check your internet connection and the API host (URLBOX_API_HOST).") + } + defer func() { _ = resp.Body.Close() }() + respBody, readErr := io.ReadAll(resp.Body) + if readErr != nil { + return nil, nil, output.NewCLIError(output.ErrNetwork, readErr.Error(), "Check your internet connection.") + } + return resp, respBody, nil +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/api/ -v` +Expected: PASS (new tests plus all pre-existing api tests). If `apitest.ScriptedResponse` literal field names differ, match `internal/api/apitest/server.go:46` exactly. + +- [ ] **Step 5: Run `make ci`** — expected green. + +- [ ] **Step 6: Mark task complete — NO commit.** + +--- + +### Task 4: Transplant — device-poll state machine + +**Files:** +- Create: `internal/deviceauth/poll.go` +- Test: `internal/deviceauth/poll_test.go` (create) +- Source reference: `SRC/internal/cmd/login.go:149-194` (`pollForToken`, `codeFromError`) + +**Interfaces:** +- Consumes: `clock.Clock` (`internal/clock`), `output.CLIError`. +- Produces: + - `type Exchange struct { AccessToken string; RFCCode string; Err error }` + - `deviceauth.Poll(clk clock.Clock, interval, expiresIn int, exchange func() Exchange) (string, *output.CLIError)` — Task 9's login command calls this with a closure over `SessionClient.DoRaw`. + +Behaviour ported verbatim from source: interval floor 5s; sleep-then-poll; `authorization_pending` continues; `slow_down` adds 5s to the interval; `access_denied` → auth error "Login denied."; `expired_token` or deadline → auth error "Code expired — run `urlbox login` again."; transport errors from exchange continue polling until the deadline (parity: source ignored non-RFC errors). + +- [ ] **Step 1: Write the failing test** + +Create `internal/deviceauth/poll_test.go`: + +```go +package deviceauth + +import ( + "testing" + "time" + + "github.com/urlbox/urlbox-cli/internal/clock" + "github.com/urlbox/urlbox-cli/internal/output" +) + +func runPoll(t *testing.T, interval, expiresIn int, script []Exchange) (string, *output.CLIError, *clock.FakeClock) { + t.Helper() + fc := clock.NewFake(time.Unix(1_700_000_000, 0)) + i := 0 + exchange := func() Exchange { + if i >= len(script) { + t.Fatalf("poll exceeded script (%d calls)", i) + } + e := script[i] + i++ + return e + } + type result struct { + token string + cli *output.CLIError + } + done := make(chan result, 1) + go func() { + tok, cli := Poll(fc, interval, expiresIn, exchange) + done <- result{tok, cli} + }() + deadline := time.After(5 * time.Second) + for { + select { + case r := <-done: + return r.token, r.cli, fc + case <-deadline: + t.Fatal("poll did not finish") + default: + if fc.WaitForSleeper(10 * time.Millisecond) { + fc.Advance(10 * time.Second) + } + } + } +} + +func TestPollSucceedsAfterPending(t *testing.T) { + tok, cli, _ := runPoll(t, 5, 300, []Exchange{ + {RFCCode: "authorization_pending"}, + {RFCCode: "authorization_pending"}, + {AccessToken: "sess_tok_win"}, + }) + if cli != nil { + t.Fatalf("unexpected error: %v", cli) + } + if tok != "sess_tok_win" { + t.Fatalf("token = %q", tok) + } +} + +func TestPollSlowDownBacksOff(t *testing.T) { + fc := clock.NewFake(time.Unix(1_700_000_000, 0)) + calls := 0 + var gaps []time.Duration + last := fc.Now() + exchange := func() Exchange { + gaps = append(gaps, fc.Since(last)) + last = fc.Now() + calls++ + if calls == 1 { + return Exchange{RFCCode: "slow_down"} + } + return Exchange{AccessToken: "tok"} + } + done := make(chan struct{}) + go func() { + _, _ = Poll(fc, 5, 300, exchange) + close(done) + }() + for { + select { + case <-done: + if gaps[0] != 5*time.Second { + t.Fatalf("first gap = %v, want 5s", gaps[0]) + } + if gaps[1] != 10*time.Second { + t.Fatalf("post-slow_down gap = %v, want 10s", gaps[1]) + } + return + default: + if fc.WaitForSleeper(10 * time.Millisecond) { + fc.Advance(1 * time.Second) + } + } + } +} + +func TestPollDeniedStopsWithAuthError(t *testing.T) { + _, cli, _ := runPoll(t, 5, 300, []Exchange{{RFCCode: "access_denied"}}) + if cli == nil || cli.Code != output.ErrAuth { + t.Fatalf("want auth error, got %v", cli) + } + if cli.Message != "Login denied." { + t.Fatalf("message = %q", cli.Message) + } +} + +func TestPollExpiredTokenStops(t *testing.T) { + _, cli, _ := runPoll(t, 5, 300, []Exchange{{RFCCode: "expired_token"}}) + if cli == nil || cli.Code != output.ErrAuth { + t.Fatalf("want auth error, got %v", cli) + } +} + +func TestPollDeadlineExpires(t *testing.T) { + script := make([]Exchange, 4) + for i := range script { + script[i] = Exchange{RFCCode: "authorization_pending"} + } + _, cli, _ := runPoll(t, 5, 12, script) + if cli == nil || cli.Code != output.ErrAuth { + t.Fatalf("want auth expiry error, got %v", cli) + } +} + +func TestPollIntervalFloor(t *testing.T) { + fc := clock.NewFake(time.Unix(1_700_000_000, 0)) + start := fc.Now() + var firstGap time.Duration + done := make(chan struct{}) + go func() { + _, _ = Poll(fc, 0, 60, func() Exchange { + firstGap = fc.Since(start) + return Exchange{AccessToken: "tok"} + }) + close(done) + }() + for { + select { + case <-done: + if firstGap != 5*time.Second { + t.Fatalf("gap with interval=0 is %v, want 5s floor", firstGap) + } + return + default: + if fc.WaitForSleeper(10 * time.Millisecond) { + fc.Advance(1 * time.Second) + } + } + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/deviceauth/ -v` +Expected: FAIL — package does not exist / `undefined: Poll`. + +- [ ] **Step 3: Write the implementation (transplanted logic, house error types)** + +Create `internal/deviceauth/poll.go`: + +```go +package deviceauth + +import ( + "time" + + "github.com/urlbox/urlbox-cli/internal/clock" + "github.com/urlbox/urlbox-cli/internal/output" +) + +type Exchange struct { + AccessToken string + RFCCode string + Err error +} + +func Poll(clk clock.Clock, interval, expiresIn int, exchange func() Exchange) (string, *output.CLIError) { + if interval <= 0 { + interval = 5 + } + deadline := clk.Now().Add(time.Duration(expiresIn) * time.Second) + for clk.Now().Before(deadline) { + clk.Sleep(time.Duration(interval) * time.Second) + e := exchange() + if e.Err == nil && e.AccessToken != "" { + return e.AccessToken, nil + } + switch e.RFCCode { + case "authorization_pending", "": + continue + case "slow_down": + interval += 5 + continue + case "access_denied": + return "", output.NewCLIError(output.ErrAuth, "Login denied.", "Approve the request in your browser, then run `urlbox login` again.") + case "expired_token": + return "", output.NewCLIError(output.ErrAuth, "Code expired — run `urlbox login` again.", "Device codes are short-lived; restart the login to get a fresh code.") + } + } + return "", output.NewCLIError(output.ErrAuth, "Code expired — run `urlbox login` again.", "Device codes are short-lived; restart the login to get a fresh code.") +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/deviceauth/ -race -v` +Expected: PASS. The `-race` flag is mandatory here (goroutine + FakeClock). + +- [ ] **Step 5: Run `make ci`** — expected green. + +- [ ] **Step 6: Mark task complete — NO commit.** + +--- + +### Task 5: Transplant — name-or-id resolution + list helpers + +(Ordered before the org/project resolution transplant because that transplant consumes `nameID`/`resolveNameOrID`/`fetchList` — a hard dependency; the spec's five-transplant list is otherwise unchanged.) + +**Files:** +- Create: `internal/cmd/nameid.go` +- Test: `internal/cmd/nameid_test.go` (create) +- Source reference: `SRC/internal/cmd/credentials.go:80-115,174-187,240-256` + +**Interfaces:** +- Consumes: `api.SessionAPI` (Task 3), `output.CLIError`. +- Produces (package `cmd`): + - `type nameID struct { ID, Name string }` + - `resolveNameOrID(arg, prefix string, rows []nameID, kind string) (nameID, *output.CLIError)` — prefix-id passthrough, case-insensitive name match, ambiguity → `ErrValidation` listing candidate ids in the hint, no match → `ErrNotFound`. + - `toNameIDs(items []map[string]any) []nameID` + - `fetchList(ctx context.Context, client api.SessionAPI, path, key string) ([]map[string]any, error)` + - `valueOrEmpty(v any) string` + +- [ ] **Step 1: Write the failing test** + +Create `internal/cmd/nameid_test.go`: + +```go +package cmd + +import ( + "strings" + "testing" + + "github.com/urlbox/urlbox-cli/internal/output" +) + +func TestResolveNameOrIDPrefixedIDPassesThrough(t *testing.T) { + rows := []nameID{{ID: "proj_known", Name: "Site"}} + got, cli := resolveNameOrID("proj_unknown", "proj_", rows, "project") + if cli != nil { + t.Fatalf("unexpected error: %v", cli) + } + if got.ID != "proj_unknown" { + t.Fatalf("id = %q, want passthrough", got.ID) + } + got, cli = resolveNameOrID("proj_known", "proj_", rows, "project") + if cli != nil || got.Name != "Site" { + t.Fatalf("known id should resolve row, got %+v %v", got, cli) + } +} + +func TestResolveNameOrIDMatchesNameCaseInsensitive(t *testing.T) { + rows := []nameID{{ID: "proj_1", Name: "Production"}, {ID: "proj_2", Name: "Staging"}} + got, cli := resolveNameOrID("pRoDuCtIoN", "proj_", rows, "project") + if cli != nil || got.ID != "proj_1" { + t.Fatalf("got %+v %v", got, cli) + } +} + +func TestResolveNameOrIDNoMatchIsNotFound(t *testing.T) { + _, cli := resolveNameOrID("nope", "proj_", []nameID{{ID: "proj_1", Name: "A"}}, "project") + if cli == nil || cli.Code != output.ErrNotFound { + t.Fatalf("want not_found, got %v", cli) + } +} + +func TestResolveNameOrIDAmbiguityListsIDs(t *testing.T) { + rows := []nameID{{ID: "proj_1", Name: "Dup"}, {ID: "proj_2", Name: "dup"}} + _, cli := resolveNameOrID("dup", "proj_", rows, "project") + if cli == nil || cli.Code != output.ErrValidation { + t.Fatalf("want validation, got %v", cli) + } + if !strings.Contains(cli.Hint, "proj_1") || !strings.Contains(cli.Hint, "proj_2") { + t.Fatalf("hint must list candidate ids, got %q", cli.Hint) + } +} + +func TestToNameIDs(t *testing.T) { + rows := toNameIDs([]map[string]any{{"id": "proj_1", "name": "A"}, {"id": 7, "name": nil}}) + if rows[0] != (nameID{ID: "proj_1", Name: "A"}) { + t.Fatalf("row0 = %+v", rows[0]) + } + if rows[1] != (nameID{}) { + t.Fatalf("non-string fields must map to empty, got %+v", rows[1]) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/cmd/ -run 'TestResolveNameOrID|TestToNameIDs' -v` +Expected: FAIL — `undefined: nameID`. + +- [ ] **Step 3: Write the implementation** + +Create `internal/cmd/nameid.go`: + +```go +package cmd + +import ( + "context" + "fmt" + "strings" + + "github.com/urlbox/urlbox-cli/internal/api" + "github.com/urlbox/urlbox-cli/internal/output" +) + +type nameID struct { + ID string + Name string +} + +func valueOrEmpty(v any) string { + s, _ := v.(string) + return s +} + +func resolveNameOrID(arg, prefix string, rows []nameID, kind string) (nameID, *output.CLIError) { + if strings.HasPrefix(arg, prefix) { + for _, r := range rows { + if r.ID == arg { + return r, nil + } + } + return nameID{ID: arg}, nil + } + var matches []nameID + for _, r := range rows { + if strings.EqualFold(r.Name, arg) { + matches = append(matches, r) + } + } + switch len(matches) { + case 1: + return matches[0], nil + case 0: + return nameID{}, output.NewCLIError( + output.ErrNotFound, + fmt.Sprintf("no %s matching %q", kind, arg), + fmt.Sprintf("List them with `urlbox %ss list`, then pass a name or id.", kind), + ) + default: + ids := make([]string, len(matches)) + for i, m := range matches { + ids[i] = m.ID + } + return nameID{}, output.NewCLIError( + output.ErrValidation, + fmt.Sprintf("%q matches multiple %ss", arg, kind), + "Use one of the ids instead: "+strings.Join(ids, ", "), + ) + } +} + +func toNameIDs(items []map[string]any) []nameID { + rows := make([]nameID, len(items)) + for i, m := range items { + rows[i] = nameID{ID: valueOrEmpty(m["id"]), Name: valueOrEmpty(m["name"])} + } + return rows +} + +func fetchList(ctx context.Context, client api.SessionAPI, path, key string) ([]map[string]any, error) { + var resp map[string]any + if err := client.GetJSON(ctx, path, &resp); err != nil { + return nil, err + } + items, _ := resp[key].([]any) + out := make([]map[string]any, 0, len(items)) + for _, item := range items { + if m, ok := item.(map[string]any); ok { + out = append(out, m) + } + } + return out, nil +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/cmd/ -run 'TestResolveNameOrID|TestToNameIDs' -v` +Expected: PASS. + +- [ ] **Step 5: Run `make ci`** — expected green. + +- [ ] **Step 6: Mark task complete — NO commit.** + +--- + +### Task 6: Transplant — login org/project resolution + +**Files:** +- Create: `internal/cmd/login_resolve.go` +- Test: `internal/cmd/login_resolve_test.go` (create) +- Source reference: `SRC/internal/cmd/login.go:227-381`, `SRC/internal/cmd/whoami.go:60-73` + +**Interfaces:** +- Consumes: `api.SessionAPI`, `nameID`, `resolveNameOrID`, `fetchList`, `toNameIDs` (Task 5). +- Produces (package `cmd`): + - `type pickFunc func(label string, options []string, active int) (int, error)` — Task 7's `prompt.SelectOne` satisfies it in production; tests inject stubs. + - `type orgListRow struct { ID, Name, PublicID string }` (JSON `id`, `name`, `publicId`) + - `type sessionResponse struct { User struct{ Email string }; Session struct{ ActiveOrganizationID, ActiveOrganizationPublicID string } }` (JSON tags as in source: `user.email`, `session.activeOrganizationId`, `session.activeOrganizationPublicId`) + - `type resolvedOrg struct { publicID, name, email string }` + - `matchOrg(orgs []orgListRow, arg string) (orgListRow, bool)` + - `resolveActiveOrg(ctx context.Context, client api.SessionAPI, orgFlag string, pick pickFunc) (resolvedOrg, *output.CLIError)` + - `resolveActiveProject(ctx context.Context, client api.SessionAPI, projectFlag string, pick pickFunc) (nameID, *output.CLIError)` + - `activeOrgName(ctx context.Context, client api.SessionAPI) string` + - `errNotInteractivePick = errors.New("not an interactive terminal")` — sentinel a pickFunc returns off-TTY; both resolvers map it to `ErrUsage` naming the bypass flag. + +- [ ] **Step 1: Write the failing test** + +Create `internal/cmd/login_resolve_test.go`: + +```go +package cmd + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "github.com/urlbox/urlbox-cli/internal/output" +) + +type fakeSession struct { + gets map[string]string + posts []struct { + Path string + Body any + } + postResponses map[string]string +} + +func (f *fakeSession) GetJSON(_ context.Context, path string, out any) error { + body, ok := f.gets[path] + if !ok { + return output.NewCLIError(output.ErrNotFound, "no fake for "+path, "") + } + return json.Unmarshal([]byte(body), out) +} + +func (f *fakeSession) PostJSON(_ context.Context, path string, body, out any) error { + f.posts = append(f.posts, struct { + Path string + Body any + }{path, body}) + if resp, ok := f.postResponses[path]; ok && out != nil { + return json.Unmarshal([]byte(resp), out) + } + return nil +} + +func (f *fakeSession) PatchJSON(_ context.Context, path string, body, out any) error { return nil } +func (f *fakeSession) DeleteJSON(_ context.Context, path string, out any) error { return nil } + +func neverPick(string, []string, int) (int, error) { + panic("picker must not be called") +} + +func notInteractive(string, []string, int) (int, error) { + return -1, errNotInteractivePick +} + +func sessionJSON(email, activeID, publicID string) string { + return `{"user":{"email":"` + email + `"},"session":{"activeOrganizationId":"` + activeID + `","activeOrganizationPublicId":"` + publicID + `"}}` +} + +func TestResolveActiveOrgSingleOrgSilently(t *testing.T) { + f := &fakeSession{gets: map[string]string{ + "/v1/auth/organization/list": `[{"id":"7","name":"Acme","publicId":"org_acme"}]`, + "/v1/auth/get-session": sessionJSON("a@urlbox.com", "7", "org_acme"), + }} + got, cli := resolveActiveOrg(context.Background(), f, "", neverPick) + if cli != nil { + t.Fatalf("unexpected: %v", cli) + } + if got.publicID != "org_acme" || got.name != "Acme" || got.email != "a@urlbox.com" { + t.Fatalf("got %+v", got) + } + if len(f.posts) != 1 || f.posts[0].Path != "/v1/auth/organization/set-active" { + t.Fatalf("set-active not called: %+v", f.posts) + } + b, _ := json.Marshal(f.posts[0].Body) + if !strings.Contains(string(b), `"organizationId":"7"`) { + t.Fatalf("set-active must send the numeric id, sent %s", b) + } +} + +func TestResolveActiveOrgFlagMatchesPublicIDNumericIDAndName(t *testing.T) { + list := `[{"id":"1","name":"One","publicId":"org_one"},{"id":"2","name":"Two","publicId":"org_two"}]` + for _, flag := range []string{"org_two", "2", "tWo"} { + f := &fakeSession{gets: map[string]string{ + "/v1/auth/organization/list": list, + "/v1/auth/get-session": sessionJSON("a@urlbox.com", "2", "org_two"), + }} + got, cli := resolveActiveOrg(context.Background(), f, flag, neverPick) + if cli != nil || got.publicID != "org_two" { + t.Fatalf("flag %q: got %+v %v", flag, got, cli) + } + } +} + +func TestResolveActiveOrgUnknownFlagErrors(t *testing.T) { + f := &fakeSession{gets: map[string]string{ + "/v1/auth/organization/list": `[{"id":"1","name":"One","publicId":"org_one"}]`, + }} + _, cli := resolveActiveOrg(context.Background(), f, "nope", neverPick) + if cli == nil || cli.Code != output.ErrNotFound { + t.Fatalf("want not_found, got %v", cli) + } +} + +func TestResolveActiveOrgMultiplePicks(t *testing.T) { + f := &fakeSession{gets: map[string]string{ + "/v1/auth/organization/list": `[{"id":"1","name":"One","publicId":"org_one"},{"id":"2","name":"Two","publicId":"org_two"}]`, + "/v1/auth/get-session": sessionJSON("a@urlbox.com", "2", "org_two"), + }} + pick := func(_ string, options []string, _ int) (int, error) { + if len(options) != 2 { + t.Fatalf("options = %v", options) + } + return 1, nil + } + got, cli := resolveActiveOrg(context.Background(), f, "", pick) + if cli != nil || got.name != "Two" { + t.Fatalf("got %+v %v", got, cli) + } +} + +func TestResolveActiveOrgNonInteractiveNamesFlag(t *testing.T) { + f := &fakeSession{gets: map[string]string{ + "/v1/auth/organization/list": `[{"id":"1","name":"One","publicId":"org_one"},{"id":"2","name":"Two","publicId":"org_two"}]`, + }} + _, cli := resolveActiveOrg(context.Background(), f, "", notInteractive) + if cli == nil || cli.Code != output.ErrUsage || !strings.Contains(cli.Hint, "--org") { + t.Fatalf("want usage error naming --org, got %v", cli) + } +} + +func TestResolveActiveOrgZeroOrgs(t *testing.T) { + f := &fakeSession{gets: map[string]string{"/v1/auth/organization/list": `[]`}} + _, cli := resolveActiveOrg(context.Background(), f, "", neverPick) + if cli == nil || cli.Code != output.ErrNotFound { + t.Fatalf("want not_found, got %v", cli) + } +} + +func TestResolveActiveProjectMatrix(t *testing.T) { + zero := &fakeSession{gets: map[string]string{"/v2/projects": `{"projects":[]}`}} + got, cli := resolveActiveProject(context.Background(), zero, "", neverPick) + if cli != nil || got.ID != "" { + t.Fatalf("zero projects: got %+v %v", got, cli) + } + + one := &fakeSession{gets: map[string]string{"/v2/projects": `{"projects":[{"id":"proj_1","name":"Only"}]}`}} + got, cli = resolveActiveProject(context.Background(), one, "", neverPick) + if cli != nil || got.ID != "proj_1" { + t.Fatalf("one project: got %+v %v", got, cli) + } + + many := &fakeSession{gets: map[string]string{"/v2/projects": `{"projects":[{"id":"proj_1","name":"A"},{"id":"proj_2","name":"B"}]}`}} + got, cli = resolveActiveProject(context.Background(), many, "", func(_ string, _ []string, _ int) (int, error) { return 1, nil }) + if cli != nil || got.ID != "proj_2" { + t.Fatalf("picker path: got %+v %v", got, cli) + } + + got, cli = resolveActiveProject(context.Background(), many, "b", neverPick) + if cli != nil || got.ID != "proj_2" { + t.Fatalf("flag path: got %+v %v", got, cli) + } + + _, cli = resolveActiveProject(context.Background(), many, "", notInteractive) + if cli == nil || cli.Code != output.ErrUsage || !strings.Contains(cli.Hint, "--project") { + t.Fatalf("want usage error naming --project, got %v", cli) + } +} + +func TestActiveOrgNameFallbacks(t *testing.T) { + named := &fakeSession{gets: map[string]string{ + "/v1/auth/get-session": sessionJSON("a@urlbox.com", "7", "org_x"), + "/v1/auth/organization/list": `[{"id":"7","name":"Acme","publicId":"org_x"}]`, + }} + if got := activeOrgName(context.Background(), named); got != "Acme" { + t.Fatalf("got %q", got) + } + none := &fakeSession{gets: map[string]string{ + "/v1/auth/get-session": sessionJSON("a@urlbox.com", "", ""), + }} + if got := activeOrgName(context.Background(), none); got != "(none)" { + t.Fatalf("got %q", got) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/cmd/ -run 'TestResolveActiveOrg|TestResolveActiveProject|TestActiveOrgName' -v` +Expected: FAIL — `undefined: resolveActiveOrg`. + +- [ ] **Step 3: Write the implementation** + +Create `internal/cmd/login_resolve.go`: + +```go +package cmd + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/urlbox/urlbox-cli/internal/api" + "github.com/urlbox/urlbox-cli/internal/output" +) + +type pickFunc func(label string, options []string, active int) (int, error) + +var errNotInteractivePick = errors.New("not an interactive terminal") + +type orgListRow struct { + ID string `json:"id"` + Name string `json:"name"` + PublicID string `json:"publicId"` +} + +type sessionResponse struct { + User struct { + Email string `json:"email"` + } `json:"user"` + Session struct { + ActiveOrganizationID string `json:"activeOrganizationId"` + ActiveOrganizationPublicID string `json:"activeOrganizationPublicId"` + } `json:"session"` +} + +type resolvedOrg struct { + publicID string + name string + email string +} + +func matchOrg(orgs []orgListRow, arg string) (orgListRow, bool) { + for _, o := range orgs { + if o.PublicID == arg || o.ID == arg || strings.EqualFold(o.Name, arg) { + return o, true + } + } + return orgListRow{}, false +} + +func resolveActiveOrg(ctx context.Context, client api.SessionAPI, orgFlag string, pick pickFunc) (resolvedOrg, *output.CLIError) { + var orgs []orgListRow + if err := client.GetJSON(ctx, "/v1/auth/organization/list", &orgs); err != nil { + return resolvedOrg{}, asCLIError(err) + } + if len(orgs) == 0 { + return resolvedOrg{}, output.NewCLIError(output.ErrNotFound, + "your account has no organisation", + "Create one in the dashboard at https://urlbox.com/dashboard, then run `urlbox login` again.") + } + chosen := orgs[0] + if orgFlag != "" { + match, ok := matchOrg(orgs, orgFlag) + if !ok { + return resolvedOrg{}, output.NewCLIError(output.ErrNotFound, + fmt.Sprintf("no organisation matching %q", orgFlag), + "Run `urlbox orgs list` to see your organisations.") + } + chosen = match + } else if len(orgs) > 1 { + names := make([]string, len(orgs)) + for i, o := range orgs { + names[i] = o.Name + } + idx, err := pick("Select an organisation:", names, -1) + if err != nil { + if errors.Is(err, errNotInteractivePick) { + return resolvedOrg{}, output.NewCLIError(output.ErrUsage, + "multiple organisations and no interactive terminal", + "Pass --org to choose one non-interactively.") + } + return resolvedOrg{}, output.NewCLIError(output.ErrUsage, err.Error(), "") + } + chosen = orgs[idx] + } + if err := client.PostJSON(ctx, "/v1/auth/organization/set-active", + map[string]string{"organizationId": chosen.ID}, nil); err != nil { + return resolvedOrg{}, asCLIError(err) + } + var session sessionResponse + if err := client.GetJSON(ctx, "/v1/auth/get-session", &session); err != nil { + return resolvedOrg{}, asCLIError(err) + } + return resolvedOrg{ + publicID: session.Session.ActiveOrganizationPublicID, + name: chosen.Name, + email: session.User.Email, + }, nil +} + +func resolveActiveProject(ctx context.Context, client api.SessionAPI, projectFlag string, pick pickFunc) (nameID, *output.CLIError) { + projects, err := fetchList(ctx, client, "/v2/projects", "projects") + if err != nil { + return nameID{}, asCLIError(err) + } + rows := toNameIDs(projects) + if len(rows) == 0 { + return nameID{}, nil + } + if projectFlag != "" { + return resolveNameOrID(projectFlag, "proj_", rows, "project") + } + if len(rows) == 1 { + return rows[0], nil + } + names := make([]string, len(rows)) + for i, r := range rows { + names[i] = r.Name + } + idx, perr := pick("Select the active project (used by render):", names, -1) + if perr != nil { + if errors.Is(perr, errNotInteractivePick) { + return nameID{}, output.NewCLIError(output.ErrUsage, + "multiple projects and no interactive terminal", + "Pass --project , or run `urlbox projects select` later.") + } + return nameID{}, output.NewCLIError(output.ErrUsage, perr.Error(), "") + } + return rows[idx], nil +} + +func activeOrgName(ctx context.Context, client api.SessionAPI) string { + var session sessionResponse + if err := client.GetJSON(ctx, "/v1/auth/get-session", &session); err != nil { + return "(none)" + } + activeID := session.Session.ActiveOrganizationID + if activeID == "" { + return "(none)" + } + var orgs []orgListRow + if err := client.GetJSON(ctx, "/v1/auth/organization/list", &orgs); err == nil { + for _, o := range orgs { + if o.ID == activeID { + return o.Name + } + } + } + if session.Session.ActiveOrganizationPublicID != "" { + return session.Session.ActiveOrganizationPublicID + } + return "(none)" +} + +func asCLIError(err error) *output.CLIError { + var cli *output.CLIError + if errors.As(err, &cli) { + return cli + } + return output.NewCLIError(output.ErrServer, err.Error(), "") +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/cmd/ -run 'TestResolveActiveOrg|TestResolveActiveProject|TestActiveOrgName' -v` +Expected: PASS. + +- [ ] **Step 5: Run `make ci`** — expected green. + +- [ ] **Step 6: Mark task complete — NO commit.** + +--- + +### Task 7: Picker component (internal/prompt) + +**Files:** +- Create: `internal/prompt/prompt.go` +- Test: `internal/prompt/prompt_test.go` (create) +- Modify: `go.mod`/`go.sum` (add `github.com/charmbracelet/huh`) +- Source reference: `SRC/internal/prompt/prompt.go` (behaviour parity; theme adapted to this repo) + +**Interfaces:** +- Produces (package `prompt`): + - `prompt.ErrNotInteractive` (sentinel error) + - `prompt.SelectOne(label string, options []string, active int) (int, error)` — satisfies Task 6's `pickFunc` via a thin adapter in the login command (Task 9). + - `prompt.TypeToConfirm(title, expected string) error` — used by `projects delete` (Task 15) and Plan 2's credential deletes. +- House rules honoured: draws via huh (stderr-backed), colors from huh's charm theme (lipgloss/termenv → `NO_COLOR` respected automatically), non-TTY → `ErrNotInteractive`, never hangs. + +- [ ] **Step 1: Add the dependency** + +Run: `go get github.com/charmbracelet/huh@latest && go mod tidy` +Expected: `go.mod` gains `github.com/charmbracelet/huh`; build stays green (`go build ./...`). + +- [ ] **Step 2: Write the failing test** + +Create `internal/prompt/prompt_test.go` (non-TTY paths only — test stdin is never a terminal, which is exactly the guard under test; interactive navigation is covered by the manual checklist): + +```go +package prompt + +import ( + "errors" + "testing" +) + +func TestSelectOneNonTTYReturnsErrNotInteractive(t *testing.T) { + _, err := SelectOne("pick:", []string{"a", "b"}, -1) + if !errors.Is(err, ErrNotInteractive) { + t.Fatalf("want ErrNotInteractive, got %v", err) + } +} + +func TestSelectOneEmptyOptions(t *testing.T) { + _, err := SelectOne("pick:", nil, -1) + if err == nil { + t.Fatal("want error for zero options") + } +} + +func TestTypeToConfirmNonTTY(t *testing.T) { + err := TypeToConfirm("retype:", "expected") + if !errors.Is(err, ErrNotInteractive) { + t.Fatalf("want ErrNotInteractive, got %v", err) + } +} +``` + +- [ ] **Step 3: Run test to verify it fails** + +Run: `go test ./internal/prompt/ -v` +Expected: FAIL — package does not exist. + +- [ ] **Step 4: Write the implementation** + +Create `internal/prompt/prompt.go`: + +```go +package prompt + +import ( + "errors" + "fmt" + "os" + "strings" + + "github.com/charmbracelet/huh" + "golang.org/x/term" +) + +var ErrNotInteractive = errors.New("not an interactive terminal") + +func theme() *huh.Theme { + t := huh.ThemeCharm() + t.Focused.Base = t.Focused.Base.MarginBottom(1) + t.Blurred.Base = t.Blurred.Base.MarginBottom(1) + return t +} + +func SelectOne(label string, options []string, active int) (int, error) { + if !term.IsTerminal(int(os.Stdin.Fd())) { + return -1, ErrNotInteractive + } + if len(options) == 0 { + return -1, errors.New("no options to choose from") + } + opts := make([]huh.Option[int], len(options)) + for i, o := range options { + display := o + if i == active { + display = o + " (current)" + } + opts[i] = huh.NewOption(display, i) + } + choice := 0 + if active >= 0 && active < len(options) { + choice = active + } + if err := huh.NewSelect[int](). + Title(label). + Options(opts...). + Value(&choice). + WithTheme(theme()). + Run(); err != nil { + return -1, err + } + return choice, nil +} + +func TypeToConfirm(title, expected string) error { + if !term.IsTerminal(int(os.Stdin.Fd())) { + return ErrNotInteractive + } + var typed string + if err := huh.NewInput(). + Title(title). + Value(&typed). + WithTheme(theme()). + Run(); err != nil { + return err + } + if strings.TrimSpace(typed) != expected { + return fmt.Errorf("confirmation did not match %q — aborted", expected) + } + return nil +} +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `go test ./internal/prompt/ -v` +Expected: PASS. + +- [ ] **Step 6: Run `make ci`** — expected green (lint may flag the new dep's indirect requirements; `go mod tidy` fixes). + +- [ ] **Step 7: Mark task complete — NO commit.** + +--- + +### Task 8: Transplant — render-credential fetch + +**Files:** +- Create: `internal/cmd/rendercred.go` +- Test: `internal/cmd/rendercred_test.go` (create) +- Source reference: `SRC/internal/cmd/credentials.go:189-238` + +**Interfaces:** +- Consumes: `api.SessionAPI`, `fetchList`, `valueOrEmpty` (Task 5), `pickFunc` (Task 6). +- Produces (package `cmd`): + - `pickAPISecret(creds []map[string]any) string` — first non-revoked credential's `apiSecret`. + - `fetchRenderSecret(ctx context.Context, client api.SessionAPI, org, project string) (string, error)` — `GET /v2/organisation/{org}/projects/{project}/api-credentials`, key `apiCredentials`. + - `ensureRenderSecret(ctx context.Context, client api.SessionAPI, org, project string, interactive bool, pick pickFunc) (secret string, issued bool, err error)` — offers at a TTY, auto-issues otherwise (`POST` same path), never fails a skip. + +- [ ] **Step 1: Write the failing test** + +Create `internal/cmd/rendercred_test.go`: + +```go +package cmd + +import ( + "context" + "testing" +) + +func TestPickAPISecretSkipsRevoked(t *testing.T) { + creds := []map[string]any{ + {"apiSecret": "sk_revoked", "revoked": true}, + {"apiSecret": "sk_live", "revoked": false}, + } + if got := pickAPISecret(creds); got != "sk_live" { + t.Fatalf("got %q", got) + } + if got := pickAPISecret([]map[string]any{{"revoked": true, "apiSecret": "x"}}); got != "" { + t.Fatalf("all-revoked must be empty, got %q", got) + } +} + +func TestEnsureRenderSecretReturnsExisting(t *testing.T) { + f := &fakeSession{gets: map[string]string{ + "/v2/organisation/org_1/projects/proj_1/api-credentials": `{"apiCredentials":[{"apiSecret":"sk_have","revoked":false}]}`, + }} + secret, issued, err := ensureRenderSecret(context.Background(), f, "org_1", "proj_1", false, neverPick) + if err != nil || issued || secret != "sk_have" { + t.Fatalf("got %q issued=%v err=%v", secret, issued, err) + } + if len(f.posts) != 0 { + t.Fatalf("must not issue when a credential exists") + } +} + +func TestEnsureRenderSecretAutoIssuesNonInteractive(t *testing.T) { + f := &fakeSession{ + gets: map[string]string{ + "/v2/organisation/org_1/projects/proj_1/api-credentials": `{"apiCredentials":[]}`, + }, + postResponses: map[string]string{ + "/v2/organisation/org_1/projects/proj_1/api-credentials": `{"apiSecret":"sk_new"}`, + }, + } + secret, issued, err := ensureRenderSecret(context.Background(), f, "org_1", "proj_1", false, neverPick) + if err != nil || !issued || secret != "sk_new" { + t.Fatalf("got %q issued=%v err=%v", secret, issued, err) + } +} + +func TestEnsureRenderSecretInteractiveSkip(t *testing.T) { + f := &fakeSession{gets: map[string]string{ + "/v2/organisation/org_1/projects/proj_1/api-credentials": `{"apiCredentials":[]}`, + }} + skip := func(_ string, options []string, _ int) (int, error) { return 1, nil } + secret, issued, err := ensureRenderSecret(context.Background(), f, "org_1", "proj_1", true, skip) + if err != nil || issued || secret != "" { + t.Fatalf("skip must return empty: %q %v %v", secret, issued, err) + } + if len(f.posts) != 0 { + t.Fatal("skip must not issue") + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/cmd/ -run 'TestPickAPISecret|TestEnsureRenderSecret' -v` +Expected: FAIL — `undefined: pickAPISecret`. + +- [ ] **Step 3: Write the implementation** + +Create `internal/cmd/rendercred.go`: + +```go +package cmd + +import ( + "context" + "errors" + + "github.com/urlbox/urlbox-cli/internal/api" +) + +func pickAPISecret(creds []map[string]any) string { + for _, c := range creds { + if revoked, _ := c["revoked"].(bool); revoked { + continue + } + if secret := valueOrEmpty(c["apiSecret"]); secret != "" { + return secret + } + } + return "" +} + +func apiCredentialsPath(org, project string) string { + return "/v2/organisation/" + org + "/projects/" + project + "/api-credentials" +} + +func fetchRenderSecret(ctx context.Context, client api.SessionAPI, org, project string) (string, error) { + creds, err := fetchList(ctx, client, apiCredentialsPath(org, project), "apiCredentials") + if err != nil { + return "", err + } + return pickAPISecret(creds), nil +} + +func ensureRenderSecret(ctx context.Context, client api.SessionAPI, org, project string, interactive bool, pick pickFunc) (string, bool, error) { + secret, err := fetchRenderSecret(ctx, client, org, project) + if err != nil || secret != "" { + return secret, false, err + } + if interactive { + idx, perr := pick("No render credential on this project — issue one?", []string{"Issue a new credential", "Skip"}, 0) + if perr != nil && !errors.Is(perr, errNotInteractivePick) { + return "", false, nil + } + if perr == nil && idx == 1 { + return "", false, nil + } + } + var created map[string]any + if err := client.PostJSON(ctx, apiCredentialsPath(org, project), map[string]string{}, &created); err != nil { + return "", false, err + } + return valueOrEmpty(created["apiSecret"]), true, nil +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/cmd/ -run 'TestPickAPISecret|TestEnsureRenderSecret' -v` +Expected: PASS. + +- [ ] **Step 5: Run `make ci`** — expected green. + +- [ ] **Step 6: Mark task complete — NO commit.** + +--- + +### Task 9: `login` command + +**Files:** +- Create: `internal/cmd/login.go` +- Create: `internal/cmd/session_helpers.go` +- Test: `internal/cmd/login_test.go` (create) +- Modify: `internal/cmd/root.go` (register `newLoginCmd()` in the `AddCommand` block, root.go:225-240) +- Source reference: `SRC/internal/cmd/login.go:32-147` + +**Interfaces:** +- Consumes: `deviceauth.Poll` (Task 4), `resolveActiveOrg`/`resolveActiveProject` (Task 6), `prompt.SelectOne` (Task 7), `ensureRenderSecret` (Task 8), `config.ProfileName`/`config.Update` (Task 1), `api.NewSessionClient` (Task 3), `writeEnvelope`/`writeEnvelopeWithQuietData` (config.go:614-650), `internal/browser`. +- Produces (package `cmd`, reused by Tasks 10–15): + - `sessionHost(cmd *cobra.Command) (host, profileName string, cliErr *output.CLIError)` — resolves API host + profile name through `config.Resolve` with the persistent `--profile` flag, `loadRepoOverlay()`, and env vars. + - `loadSession(cmd *cobra.Command) (*sessionState, *output.CLIError)` where `type sessionState struct { Host, ProfileName string; Profile config.Profile; Client *api.SessionClient }` — returns `output.ErrAuth` ("not logged in — run `urlbox login`", hint names `urlbox login`) when the profile has no `SessionToken`. + - `updateProfile(profileName string, mutate func(*config.Profile)) *output.CLIError` — lockfile-guarded write via `config.Update`. + - `promptPick pickFunc` — adapter over `prompt.SelectOne` translating `prompt.ErrNotInteractive` → `errNotInteractivePick`. + - Package test-injection vars mirroring status.go's pattern: `loginClock clock.Clock` (+`SetLoginClockForTest`/`ResetLoginClockForTest`), `loginOpener` (+`SetLoginOpenerForTest`/`ResetLoginOpenerForTest`). + +- [ ] **Step 0: Read the opener pattern** + +Read `internal/browser/opener.go` (104 lines) and the opener usage in `internal/cmd/dashboard.go`. Mirror the exact interface and injection-var pattern for `loginOpener` — if the interface method is not literally `Open(url string) error`, adapt the code below to the real signature; change nothing else. + +- [ ] **Step 1: Write the failing test** + +Create `internal/cmd/login_test.go`: + +```go +package cmd + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "testing" + "time" + + "github.com/urlbox/urlbox-cli/internal/api/apitest" + "github.com/urlbox/urlbox-cli/internal/clock" +) + +func advanceClockUntil(t *testing.T, fc *clock.FakeClock, done <-chan struct{}) { + t.Helper() + go func() { + for { + select { + case <-done: + return + default: + if fc.WaitForSleeper(5 * time.Millisecond) { + fc.Advance(10 * time.Second) + } + } + } + }() +} + +func TestLoginFullFlowSingleOrgSingleProject(t *testing.T) { + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New( + apitest.SuccessJSON(`{"device_code":"dev_1","user_code":"ABCD-1234","verification_uri_complete":"https://urlbox.com/device?code=ABCD-1234","interval":5,"expires_in":300}`), + apitest.SuccessJSON(`{"access_token":"sess_tok_new"}`), + apitest.SuccessJSON(`[{"id":"7","name":"Acme","publicId":"org_acme"}]`), + apitest.SuccessJSON(`{}`), + apitest.SuccessJSON(`{"user":{"email":"a@urlbox.com"},"session":{"activeOrganizationId":"7","activeOrganizationPublicId":"org_acme"}}`), + apitest.SuccessJSON(`{"projects":[{"id":"proj_1","name":"Main"}]}`), + apitest.SuccessJSON(`{"apiCredentials":[{"apiSecret":"sk_fetched","revoked":false}]}`), + ) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + + fc := clock.NewFake(time.Unix(1_700_000_000, 0)) + SetLoginClockForTest(fc) + t.Cleanup(ResetLoginClockForTest) + done := make(chan struct{}) + defer close(done) + advanceClockUntil(t, fc, done) + + var stdout, stderr bytes.Buffer + code := Execute([]string{"login", "--output-format", "json"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit %d\nstdout: %s\nstderr: %s", code, stdout.String(), stderr.String()) + } + + var env struct { + OK bool `json:"ok"` + Data struct { + Email string `json:"email"` + Org struct { + ID string `json:"id"` + Name string `json:"name"` + } `json:"org"` + Project *struct { + ID string `json:"id"` + } `json:"project"` + Render struct { + Credential string `json:"credential"` + } `json:"render"` + } `json:"data"` + } + if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { + t.Fatalf("stdout not an envelope: %v\n%s", err, stdout.String()) + } + if !env.OK || env.Data.Email != "a@urlbox.com" || env.Data.Org.ID != "org_acme" { + t.Fatalf("envelope: %s", stdout.String()) + } + if env.Data.Project == nil || env.Data.Project.ID != "proj_1" { + t.Fatalf("project: %s", stdout.String()) + } + if env.Data.Render.Credential != "ready" { + t.Fatalf("render status: %s", stdout.String()) + } + + b, err := os.ReadFile(filepath.Join(dir, "urlbox", "config.json")) + if err != nil { + t.Fatalf("read config: %v", err) + } + var cfg struct { + Profiles map[string]map[string]string `json:"profiles"` + } + if err := json.Unmarshal(b, &cfg); err != nil { + t.Fatalf("unmarshal config: %v", err) + } + p := cfg.Profiles["default"] + if p["session_token"] != "sess_tok_new" || p["active_org"] != "org_acme" || + p["active_project"] != "proj_1" || p["api_secret"] != "sk_fetched" { + t.Fatalf("profile after login: %#v", p) + } + + reqs := srv.Requests() + if reqs[0].Path != "/v1/auth/device/code" { + t.Fatalf("first call %q", reqs[0].Path) + } + if !bytes.Contains(reqs[0].Body, []byte(`"client_id":"urlbox-cli"`)) { + t.Fatalf("device/code body: %s", reqs[0].Body) + } + if reqs[1].Path != "/v1/auth/device/token" { + t.Fatalf("second call %q", reqs[1].Path) + } + if got := reqs[2].Header.Get("Authorization"); got != "Bearer sess_tok_new" { + t.Fatalf("org list auth header %q", got) + } + if stderrStr := stderr.String(); !bytes.Contains([]byte(stderrStr), []byte("ABCD-1234")) { + t.Fatalf("user code must print to stderr, got: %s", stderrStr) + } +} + +func TestLoginDeniedExitsAuth(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + srv := apitest.New( + apitest.SuccessJSON(`{"device_code":"dev_1","user_code":"ABCD-1234","verification_uri_complete":"https://urlbox.com/device","interval":5,"expires_in":300}`), + apitest.ScriptedResponse{Status: 400, Body: `{"error":"access_denied"}`}, + ) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + fc := clock.NewFake(time.Unix(1_700_000_000, 0)) + SetLoginClockForTest(fc) + t.Cleanup(ResetLoginClockForTest) + done := make(chan struct{}) + defer close(done) + advanceClockUntil(t, fc, done) + + var stdout, stderr bytes.Buffer + code := Execute([]string{"login", "--output-format", "json"}, &stdout, &stderr) + if code != 3 { + t.Fatalf("exit = %d, want 3 (auth)\nstdout: %s\nstderr: %s", code, stdout.String(), stderr.String()) + } + if !bytes.Contains(stdout.Bytes(), []byte(`"code":"auth"`)) { + t.Fatalf("error envelope: %s", stdout.String()) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/cmd/ -run TestLogin -v` +Expected: FAIL — `undefined: SetLoginClockForTest` / unknown command "login". + +- [ ] **Step 3: Write the session helpers** + +Create `internal/cmd/session_helpers.go`: + +```go +package cmd + +import ( + "errors" + "os" + + "github.com/spf13/cobra" + + "github.com/urlbox/urlbox-cli/internal/api" + "github.com/urlbox/urlbox-cli/internal/config" + "github.com/urlbox/urlbox-cli/internal/output" + "github.com/urlbox/urlbox-cli/internal/prompt" +) + +type sessionState struct { + Host string + ProfileName string + Profile config.Profile + Client *api.SessionClient +} + +func sessionHost(cmd *cobra.Command) (string, string, *output.CLIError) { + cfg, cfgErr := config.LoadOrCLIError() + if cfgErr != nil { + return "", "", cfgErr + } + flagProfile, _ := cmd.Root().PersistentFlags().GetString("profile") + overlay, ovErr := loadRepoOverlay() + if ovErr != nil { + return "", "", ovErr + } + resolved, rerr := config.Resolve(config.ResolveOptions{ + FlagProfile: flagProfile, + EnvAPISecret: os.Getenv(config.EnvAPISecret), + EnvAPIHost: os.Getenv(config.EnvAPIHost), + EnvProfile: os.Getenv(config.EnvProfile), + RepoOverlay: overlay, + Config: cfg, + }) + if rerr != nil { + var cli *output.CLIError + if errors.As(rerr, &cli) { + return "", "", cli + } + return "", "", output.NewCLIError(output.ErrUsage, rerr.Error(), "Run `urlbox config path` to locate the config file.") + } + return resolved.APIHost, resolved.Profile, nil +} + +func loadSession(cmd *cobra.Command) (*sessionState, *output.CLIError) { + host, profileName, cliErr := sessionHost(cmd) + if cliErr != nil { + return nil, cliErr + } + cfg, cfgErr := config.LoadOrCLIError() + if cfgErr != nil { + return nil, cfgErr + } + profile := cfg.Profiles[profileName] + if profile.SessionToken == "" { + return nil, output.NewCLIError( + output.ErrAuth, + "not logged in", + "Run `urlbox login` to sign in via your browser.", + ) + } + return &sessionState{ + Host: host, + ProfileName: profileName, + Profile: profile, + Client: api.NewSessionClient(host, profile.SessionToken), + }, nil +} + +func updateProfile(profileName string, mutate func(*config.Profile)) *output.CLIError { + err := config.Update(func(c *config.Config) error { + p := c.Profiles[profileName] + mutate(&p) + c.Profiles[profileName] = p + if c.DefaultProfile == "" { + c.DefaultProfile = profileName + } + return nil + }) + if err == nil { + return nil + } + var cli *output.CLIError + if errors.As(err, &cli) { + return cli + } + return output.NewCLIError(output.ErrForbidden, "could not write config: "+err.Error(), + "Check the permissions of the config directory (`urlbox config path`).") +} + +func promptPick(label string, options []string, active int) (int, error) { + idx, err := prompt.SelectOne(label, options, active) + if errors.Is(err, prompt.ErrNotInteractive) { + return -1, errNotInteractivePick + } + return idx, err +} +``` + +- [ ] **Step 4: Write the login command** + +Create `internal/cmd/login.go`: + +```go +package cmd + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + + "github.com/urlbox/urlbox-cli/internal/api" + "github.com/urlbox/urlbox-cli/internal/browser" + "github.com/urlbox/urlbox-cli/internal/clock" + "github.com/urlbox/urlbox-cli/internal/config" + "github.com/urlbox/urlbox-cli/internal/deviceauth" + "github.com/urlbox/urlbox-cli/internal/output" +) + +var loginClock clock.Clock = clock.New() + +func SetLoginClockForTest(c clock.Clock) { loginClock = c } + +func ResetLoginClockForTest() { loginClock = clock.New() } + +var loginOpener browser.Opener = browser.OSOpener{} + +func SetLoginOpenerForTest(o browser.Opener) { loginOpener = o } + +func ResetLoginOpenerForTest() { loginOpener = browser.OSOpener{} } + +type deviceCodeResponse struct { + DeviceCode string `json:"device_code"` + UserCode string `json:"user_code"` + VerificationURI string `json:"verification_uri_complete"` + Interval int `json:"interval"` + ExpiresIn int `json:"expires_in"` +} + +type loginFlags struct { + org string + project string +} + +func newLoginCmd() *cobra.Command { + f := &loginFlags{} + c := &cobra.Command{ + Use: "login", + Short: "Sign in via your browser (device flow)", + Long: `Sign in to Urlbox via your browser. + +Prints a short code and opens the approval page; once you approve, the CLI +stores a session for management commands, sets your active organisation and +project, and fetches the active project's render credential so render +commands work immediately. + +CI and headless environments should set URLBOX_API_SECRET instead — the +device flow needs a browser. + +Examples: + urlbox login + urlbox login --org acme --project production + urlbox login --output-format json`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return runLogin(cmd, f) + }, + } + c.Flags().StringVar(&f.org, "org", "", "Organisation to make active (name or id) — skips the picker") + c.Flags().StringVar(&f.project, "project", "", "Project to make active (name or id) — skips the picker") + return c +} + +func runLogin(cmd *cobra.Command, f *loginFlags) error { + ctx := context.Background() + host, profileName, cliErr := sessionHost(cmd) + if cliErr != nil { + return cliErr + } + stderr := cmd.ErrOrStderr() + anon := api.NewSessionClient(host, "") + + var code deviceCodeResponse + if err := anon.PostJSON(ctx, "/v1/auth/device/code", map[string]string{"client_id": "urlbox-cli"}, &code); err != nil { + return asCLIError(err) + } + + fmt.Fprintf(stderr, "Your code: %s\n", code.UserCode) + fmt.Fprintf(stderr, "Open this URL to continue: %s\n", code.VerificationURI) + formatFlag, _ := cmd.Root().PersistentFlags().GetString("output-format") + if formatFlag != "json" && formatFlag != "quiet" { + _ = loginOpener.Open(code.VerificationURI) + } + fmt.Fprintln(stderr, "Waiting for approval…") + + exchange := func() deviceauth.Exchange { + status, data, err := anon.DoRaw(ctx, "POST", "/v1/auth/device/token", map[string]string{ + "grant_type": "urn:ietf:params:oauth:grant-type:device_code", + "device_code": code.DeviceCode, + "client_id": "urlbox-cli", + }) + if err != nil { + return deviceauth.Exchange{Err: err} + } + if status < 400 { + return deviceauth.Exchange{AccessToken: valueOrEmpty(data["access_token"])} + } + return deviceauth.Exchange{RFCCode: valueOrEmpty(data["error"])} + } + token, pollErr := deviceauth.Poll(loginClock, code.Interval, code.ExpiresIn, exchange) + if pollErr != nil { + return pollErr + } + + if cliErr := updateProfile(profileName, func(p *config.Profile) { p.SessionToken = token }); cliErr != nil { + return cliErr + } + + authed := api.NewSessionClient(host, token) + org, orgErr := resolveActiveOrg(ctx, authed, f.org, promptPick) + if orgErr != nil { + return orgErr + } + if org.publicID != "" { + if cliErr := updateProfile(profileName, func(p *config.Profile) { p.ActiveOrg = org.publicID }); cliErr != nil { + return cliErr + } + } + + project, projErr := resolveActiveProject(ctx, authed, f.project, promptPick) + if projErr != nil { + return projErr + } + renderStatus := "none" + if project.ID != "" { + if cliErr := updateProfile(profileName, func(p *config.Profile) { p.ActiveProject = project.ID }); cliErr != nil { + return cliErr + } + interactive := formatFlag != "json" && formatFlag != "quiet" + secret, issued, err := ensureRenderSecret(ctx, authed, org.publicID, project.ID, interactive, promptPick) + switch { + case err != nil: + fmt.Fprintf(stderr, "Logged in, but could not fetch the render credential: %v\n", err) + renderStatus = "error" + case secret != "": + if cliErr := updateProfile(profileName, func(p *config.Profile) { p.APISecret = secret }); cliErr != nil { + fmt.Fprintf(stderr, "Logged in, but could not save the render credential: %v\n", cliErr) + renderStatus = "error" + } else if issued { + renderStatus = "issued" + } else { + renderStatus = "ready" + } + } + } else { + fmt.Fprintln(stderr, "No projects in this organisation yet.") + } + + data := map[string]any{ + "email": org.email, + "org": map[string]any{"id": org.publicID, "name": org.name}, + "project": nil, + "render": map[string]any{"credential": renderStatus}, + } + if project.ID != "" { + data["project"] = map[string]any{"id": project.ID, "name": project.Name} + } + summary := fmt.Sprintf("Logged in as %s — org %s", org.email, org.name) + breadcrumbs := []output.Breadcrumb{{ + Action: "render", + Cmd: "urlbox screenshot https://example.com --output hello.png", + }} + env := output.NewEnvelope("login", data, summary, breadcrumbs) + return writeEnvelopeWithQuietData(cmd, env, org.email) +} +``` + +- [ ] **Step 5: Register the command** + +In `internal/cmd/root.go`, inside the `AddCommand` block (root.go:225-240), add in the alphabetical position: + +```go + cmd.AddCommand(newLoginCmd()) +``` + +- [ ] **Step 6: Run tests to verify they pass** + +Run: `go test ./internal/cmd/ -run 'TestLogin' -race -v` +Expected: PASS both. If the compat suite (Task 2) now fails, login broke an existing path — fix before proceeding. + +- [ ] **Step 7: Run `make surface-snapshot` then `make ci`** + +Expected: `SURFACE.txt` gains `urlbox login` + its flags (`--org`, `--project`, inherited persistent flags); `ci` green. + +- [ ] **Step 8: Mark task complete — NO commit.** + +--- + +### Task 10: `logout` command + +**Files:** +- Create: `internal/cmd/logout.go` +- Test: `internal/cmd/logout_test.go` (create) +- Modify: `internal/cmd/root.go` (register) +- Source reference: `SRC/internal/cmd/logout.go` + +**Interfaces:** +- Consumes: `loadSession`-style config access (but logout must not hard-fail when not logged in), `updateProfile`, `api.NewSessionClient`. + +- [ ] **Step 1: Write the failing test** + +Create `internal/cmd/logout_test.go`: + +```go +package cmd + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/urlbox/urlbox-cli/internal/api/apitest" +) + +func readProfileMap(t *testing.T, dir string) map[string]string { + t.Helper() + b, err := os.ReadFile(filepath.Join(dir, "urlbox", "config.json")) + if err != nil { + t.Fatalf("read config: %v", err) + } + var cfg struct { + Profiles map[string]map[string]string `json:"profiles"` + } + if err := json.Unmarshal(b, &cfg); err != nil { + t.Fatalf("unmarshal: %v", err) + } + return cfg.Profiles["default"] +} + +func TestLogoutRevokesAndClears(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New(apitest.SuccessJSON(`{}`)) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + + var stdout, stderr bytes.Buffer + code := Execute([]string{"logout", "--output-format", "json"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit %d\n%s\n%s", code, stdout.String(), stderr.String()) + } + reqs := srv.Requests() + if len(reqs) != 1 || reqs[0].Path != "/v1/auth/sign-out" { + t.Fatalf("requests: %+v", reqs) + } + if got := reqs[0].Header.Get("Authorization"); got != "Bearer sess_tok_compat_123456" { + t.Fatalf("auth header %q", got) + } + p := readProfileMap(t, dir) + for _, key := range []string{"session_token", "active_org", "active_project", "api_key", "api_secret"} { + if p[key] != "" { + t.Fatalf("%s not cleared: %#v", key, p) + } + } +} + +func TestLogoutOfflineStillClearsLocally(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + t.Setenv("URLBOX_API_HOST", "http://127.0.0.1:1") + + var stdout, stderr bytes.Buffer + code := Execute([]string{"logout", "--output-format", "json"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("offline logout must still succeed, exit %d\n%s", code, stderr.String()) + } + if !bytes.Contains(stderr.Bytes(), []byte("clearing local login anyway")) { + t.Fatalf("expected warning on stderr, got: %s", stderr.String()) + } + if p := readProfileMap(t, dir); p["session_token"] != "" { + t.Fatalf("token not cleared: %#v", p) + } +} + +func TestLogoutWhenNotLoggedIn(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, false) + t.Setenv("XDG_CONFIG_HOME", dir) + var stdout, stderr bytes.Buffer + code := Execute([]string{"logout", "--output-format", "json"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("logout without a session must be a no-op success, exit %d", code) + } + if !bytes.Contains(stdout.Bytes(), []byte(`"ok":true`)) { + t.Fatalf("envelope: %s", stdout.String()) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/cmd/ -run TestLogout -v` +Expected: FAIL — unknown command "logout". + +- [ ] **Step 3: Write the implementation** + +Create `internal/cmd/logout.go`: + +```go +package cmd + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + + "github.com/urlbox/urlbox-cli/internal/api" + "github.com/urlbox/urlbox-cli/internal/config" + "github.com/urlbox/urlbox-cli/internal/output" +) + +func newLogoutCmd() *cobra.Command { + return &cobra.Command{ + Use: "logout", + Short: "Sign out and revoke this device's session", + Long: `Sign out of Urlbox on this machine. + +Revokes only this device's session server-side (your dashboard and other +devices stay signed in) and clears the stored session, active organisation, +active project, and render credential. If the server is unreachable the +local state is cleared anyway. + +Examples: + urlbox logout + urlbox logout --output-format json`, + Args: cobra.NoArgs, + RunE: runLogout, + } +} + +func runLogout(cmd *cobra.Command, _ []string) error { + host, profileName, cliErr := sessionHost(cmd) + if cliErr != nil { + return cliErr + } + cfg, cfgErr := config.LoadOrCLIError() + if cfgErr != nil { + return cfgErr + } + profile := cfg.Profiles[profileName] + if profile.SessionToken == "" { + env := output.NewEnvelope("logout", map[string]any{"logged_out": false}, "Not logged in.", nil) + return writeEnvelope(cmd, env) + } + + client := api.NewSessionClient(host, profile.SessionToken) + if err := client.PostJSON(context.Background(), "/v1/auth/sign-out", map[string]string{}, nil); err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), + "Warning: could not reach the server to revoke the session (%v); clearing local login anyway.\n", err) + } + + if cliErr := updateProfile(profileName, func(p *config.Profile) { + p.SessionToken = "" + p.ActiveOrg = "" + p.ActiveProject = "" + p.APIKey = "" + p.APISecret = "" + }); cliErr != nil { + return cliErr + } + + env := output.NewEnvelope("logout", map[string]any{"logged_out": true}, "Logged out.", nil) + return writeEnvelope(cmd, env) +} +``` + +Register in `internal/cmd/root.go`: `cmd.AddCommand(newLogoutCmd())`. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/cmd/ -run TestLogout -v` +Expected: PASS all three. + +- [ ] **Step 5: Run `make surface-snapshot` then `make ci`** — expected green, `urlbox logout` in SURFACE.txt. + +- [ ] **Step 6: Mark task complete — NO commit.** + +--- + +### Task 11: `whoami` command (alias `me`) + +**Files:** +- Create: `internal/cmd/whoami.go` +- Test: `internal/cmd/whoami_test.go` (create) +- Modify: `internal/cmd/root.go` (register) +- Source reference: `SRC/internal/cmd/whoami.go` + +**Interfaces:** +- Consumes: `loadSession`, `activeOrgName`, `fetchList`, `toNameIDs`, `sessionResponse`. + +- [ ] **Step 1: Write the failing test** + +Create `internal/cmd/whoami_test.go`: + +```go +package cmd + +import ( + "bytes" + "encoding/json" + "testing" + + "github.com/urlbox/urlbox-cli/internal/api/apitest" +) + +func TestWhoamiNotLoggedIn(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, false) + t.Setenv("XDG_CONFIG_HOME", dir) + var stdout, stderr bytes.Buffer + code := Execute([]string{"whoami", "--output-format", "json"}, &stdout, &stderr) + if code != 3 { + t.Fatalf("exit = %d, want 3 (auth)", code) + } + if !bytes.Contains(stdout.Bytes(), []byte(`"code":"auth"`)) || + !bytes.Contains(stdout.Bytes(), []byte("urlbox login")) { + t.Fatalf("error envelope must carry auth code + login hint: %s", stdout.String()) + } +} + +func TestWhoamiHappyPath(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New( + apitest.SuccessJSON(`{"user":{"email":"a@urlbox.com"},"session":{"activeOrganizationId":"7","activeOrganizationPublicId":"org_acme"}}`), + apitest.SuccessJSON(`{"projects":[{"id":"proj_compat","name":"Main"}]}`), + apitest.SuccessJSON(`{"user":{"email":"a@urlbox.com"},"session":{"activeOrganizationId":"7","activeOrganizationPublicId":"org_acme"}}`), + apitest.SuccessJSON(`[{"id":"7","name":"Acme","publicId":"org_acme"}]`), + ) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + + var stdout, stderr bytes.Buffer + code := Execute([]string{"whoami", "--output-format", "json"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit %d\n%s\n%s", code, stdout.String(), stderr.String()) + } + var env struct { + Data struct { + Email string `json:"email"` + Org struct { + ID string `json:"id"` + Name string `json:"name"` + } `json:"org"` + Project struct { + ID string `json:"id"` + Name string `json:"name"` + } `json:"project"` + } `json:"data"` + } + if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { + t.Fatalf("decode: %v\n%s", err, stdout.String()) + } + if env.Data.Email != "a@urlbox.com" || env.Data.Org.ID != "org_acme" || + env.Data.Org.Name != "Acme" || env.Data.Project.ID != "proj_compat" { + t.Fatalf("data: %s", stdout.String()) + } +} + +func TestWhoamiExpiredSessionIsAuthError(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New( + apitest.SuccessJSON(`{"user":{"email":""},"session":{"activeOrganizationId":"","activeOrganizationPublicId":""}}`), + ) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + var stdout, stderr bytes.Buffer + code := Execute([]string{"whoami", "--output-format", "json"}, &stdout, &stderr) + if code != 3 { + t.Fatalf("dead token must exit 3, got %d\n%s", code, stdout.String()) + } +} + +func TestMeAliasWorks(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, false) + t.Setenv("XDG_CONFIG_HOME", dir) + var stdout, stderr bytes.Buffer + code := Execute([]string{"me", "--output-format", "json"}, &stdout, &stderr) + if code != 3 { + t.Fatalf("me alias must route to whoami, exit %d", code) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/cmd/ -run 'TestWhoami|TestMeAlias' -v` +Expected: FAIL — unknown command "whoami". + +- [ ] **Step 3: Write the implementation** + +Create `internal/cmd/whoami.go`: + +```go +package cmd + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + + "github.com/urlbox/urlbox-cli/internal/output" +) + +func newWhoamiCmd() *cobra.Command { + return &cobra.Command{ + Use: "whoami", + Aliases: []string{"me"}, + Short: "Show the signed-in user and active context", + Long: `Show who you are signed in as, plus the active organisation and project. + +Examples: + urlbox whoami + urlbox whoami --output-format json`, + Args: cobra.NoArgs, + RunE: runWhoami, + } +} + +func runWhoami(cmd *cobra.Command, _ []string) error { + sess, cliErr := loadSession(cmd) + if cliErr != nil { + return cliErr + } + ctx := context.Background() + var session sessionResponse + if err := sess.Client.GetJSON(ctx, "/v1/auth/get-session", &session); err != nil { + return asCLIError(err) + } + if session.User.Email == "" { + return output.NewCLIError(output.ErrAuth, "not logged in", + "Your session has expired. Run `urlbox login` to sign in again.") + } + + var project nameID + if sess.Profile.ActiveProject != "" { + if projects, err := fetchList(ctx, sess.Client, "/v2/projects", "projects"); err == nil { + for _, r := range toNameIDs(projects) { + if r.ID == sess.Profile.ActiveProject { + project = r + break + } + } + if project.ID == "" { + project = nameID{ID: sess.Profile.ActiveProject} + } + } + } + + orgName := activeOrgName(ctx, sess.Client) + data := map[string]any{ + "email": session.User.Email, + "org": map[string]any{ + "id": session.Session.ActiveOrganizationPublicID, + "name": orgName, + }, + "project": nil, + } + if project.ID != "" { + data["project"] = map[string]any{"id": project.ID, "name": project.Name} + } + summary := fmt.Sprintf("Signed in as %s — org %s", session.User.Email, orgName) + env := output.NewEnvelope("whoami", data, summary, nil) + return writeEnvelopeWithQuietData(cmd, env, session.User.Email) +} +``` + +Register in `internal/cmd/root.go`: `cmd.AddCommand(newWhoamiCmd())`. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/cmd/ -run 'TestWhoami|TestMeAlias' -v` +Expected: PASS all four. + +- [ ] **Step 5: Run `make surface-snapshot` then `make ci`** — expected green. + +- [ ] **Step 6: Mark task complete — NO commit.** + +--- + +### Task 12: `orgs` command group (alias `org`) + +**Files:** +- Create: `internal/cmd/orgs.go` +- Test: `internal/cmd/orgs_test.go` (create) +- Modify: `internal/cmd/root.go` (register) +- Source reference: `SRC/internal/cmd/org.go` — read lines 95-215 fully before implementing `select`; the code below ports list + select faithfully (select = set-active, persist public id, then re-resolve project + render credential exactly like login steps 6-7, which is the spec's "select refreshes the stored render credential"). + +**Interfaces:** +- Consumes: `loadSession`, `matchOrg`, `resolveActiveProject`, `ensureRenderSecret`, `updateProfile`, `promptPick`. + +- [ ] **Step 1: Write the failing test** + +Create `internal/cmd/orgs_test.go`: + +```go +package cmd + +import ( + "bytes" + "testing" + + "github.com/urlbox/urlbox-cli/internal/api/apitest" +) + +func TestOrgsListMarksActive(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New( + apitest.SuccessJSON(`[{"id":"1","name":"One","publicId":"org_one"},{"id":"2","name":"Two","publicId":"org_two"}]`), + apitest.SuccessJSON(`{"user":{"email":"a@urlbox.com"},"session":{"activeOrganizationId":"2","activeOrganizationPublicId":"org_two"}}`), + ) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + var stdout, stderr bytes.Buffer + code := Execute([]string{"orgs", "list", "--output-format", "json"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit %d\n%s\n%s", code, stdout.String(), stderr.String()) + } + for _, want := range []string{`"org_one"`, `"org_two"`, `"active":true`} { + if !bytes.Contains(stdout.Bytes(), []byte(want)) { + t.Fatalf("missing %s in: %s", want, stdout.String()) + } + } +} + +func TestOrgsSelectPositionalSwitchesAndRefreshesProject(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New( + apitest.SuccessJSON(`[{"id":"1","name":"One","publicId":"org_one"},{"id":"2","name":"Two","publicId":"org_two"}]`), + apitest.SuccessJSON(`{}`), + apitest.SuccessJSON(`{"user":{"email":"a@urlbox.com"},"session":{"activeOrganizationId":"1","activeOrganizationPublicId":"org_one"}}`), + apitest.SuccessJSON(`{"projects":[{"id":"proj_9","name":"OtherOrgProj"}]}`), + apitest.SuccessJSON(`{"apiCredentials":[{"apiSecret":"sk_other_org","revoked":false}]}`), + ) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + var stdout, stderr bytes.Buffer + code := Execute([]string{"orgs", "select", "one", "--output-format", "json"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit %d\n%s\n%s", code, stdout.String(), stderr.String()) + } + p := readProfileMap(t, dir) + if p["active_org"] != "org_one" || p["active_project"] != "proj_9" || p["api_secret"] != "sk_other_org" { + t.Fatalf("profile after select: %#v", p) + } + reqs := srv.Requests() + if reqs[1].Path != "/v1/auth/organization/set-active" { + t.Fatalf("second call %q", reqs[1].Path) + } + if !bytes.Contains(reqs[1].Body, []byte(`"organizationId":"1"`)) { + t.Fatalf("set-active body: %s", reqs[1].Body) + } +} + +func TestOrgsSelectNonInteractiveWithoutArg(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New( + apitest.SuccessJSON(`[{"id":"1","name":"One","publicId":"org_one"},{"id":"2","name":"Two","publicId":"org_two"}]`), + ) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + var stdout, stderr bytes.Buffer + code := Execute([]string{"orgs", "select", "--output-format", "json"}, &stdout, &stderr) + if code != 1 { + t.Fatalf("want usage exit 1, got %d\n%s", code, stdout.String()) + } + if !bytes.Contains(stdout.Bytes(), []byte("name-or-id")) { + t.Fatalf("error must name the positional: %s", stdout.String()) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/cmd/ -run TestOrgs -v` +Expected: FAIL — unknown command "orgs". + +- [ ] **Step 3: Write the implementation** + +Create `internal/cmd/orgs.go`: + +```go +package cmd + +import ( + "context" + "errors" + "fmt" + + "github.com/spf13/cobra" + + "github.com/urlbox/urlbox-cli/internal/config" + "github.com/urlbox/urlbox-cli/internal/output" +) + +func newOrgsCmd() *cobra.Command { + c := &cobra.Command{ + Use: "orgs", + Aliases: []string{"org"}, + Short: "Manage the active organisation", + } + list := &cobra.Command{ + Use: "list", + Short: "List your organisations", + Args: cobra.NoArgs, + RunE: runOrgsList, + } + sel := &cobra.Command{ + Use: "select [name-or-id]", + Short: "Set the active organisation", + Args: cobra.MaximumNArgs(1), + RunE: runOrgsSelect, + } + c.AddCommand(list, sel) + return c +} + +func runOrgsList(cmd *cobra.Command, _ []string) error { + sess, cliErr := loadSession(cmd) + if cliErr != nil { + return cliErr + } + ctx := context.Background() + var orgs []orgListRow + if err := sess.Client.GetJSON(ctx, "/v1/auth/organization/list", &orgs); err != nil { + return asCLIError(err) + } + var session sessionResponse + _ = sess.Client.GetJSON(ctx, "/v1/auth/get-session", &session) + activeID := session.Session.ActiveOrganizationID + + rows := make([]map[string]any, len(orgs)) + activeName := "" + for i, o := range orgs { + active := o.ID != "" && o.ID == activeID + if active { + activeName = o.Name + } + rows[i] = map[string]any{"id": o.PublicID, "name": o.Name, "active": active} + } + summary := fmt.Sprintf("%d organisations", len(orgs)) + if activeName != "" { + summary = fmt.Sprintf("%d organisations — active: %s", len(orgs), activeName) + } + env := output.NewEnvelope("orgs list", map[string]any{"organisations": rows}, summary, nil) + return writeEnvelope(cmd, env) +} + +func runOrgsSelect(cmd *cobra.Command, args []string) error { + sess, cliErr := loadSession(cmd) + if cliErr != nil { + return cliErr + } + ctx := context.Background() + var orgs []orgListRow + if err := sess.Client.GetJSON(ctx, "/v1/auth/organization/list", &orgs); err != nil { + return asCLIError(err) + } + if len(orgs) == 0 { + return output.NewCLIError(output.ErrNotFound, "no organisations", + "Create one in the dashboard at https://urlbox.com/dashboard.") + } + + var chosen orgListRow + if len(args) == 1 { + match, ok := matchOrg(orgs, args[0]) + if !ok { + return output.NewCLIError(output.ErrNotFound, + fmt.Sprintf("no organisation matching %q", args[0]), + "Run `urlbox orgs list` to see your organisations.") + } + chosen = match + } else { + names := make([]string, len(orgs)) + active := -1 + var session sessionResponse + _ = sess.Client.GetJSON(ctx, "/v1/auth/get-session", &session) + for i, o := range orgs { + names[i] = o.Name + if o.ID == session.Session.ActiveOrganizationID { + active = i + } + } + idx, err := promptPick("Select the active organisation:", names, active) + if err != nil { + if errors.Is(err, errNotInteractivePick) { + return output.NewCLIError(output.ErrUsage, + "selection needs an interactive terminal", + "Pass the organisation directly: `urlbox orgs select `.") + } + return output.NewCLIError(output.ErrUsage, err.Error(), "") + } + chosen = orgs[idx] + } + + if err := sess.Client.PostJSON(ctx, "/v1/auth/organization/set-active", + map[string]string{"organizationId": chosen.ID}, nil); err != nil { + return asCLIError(err) + } + var session sessionResponse + if err := sess.Client.GetJSON(ctx, "/v1/auth/get-session", &session); err != nil { + return asCLIError(err) + } + publicID := session.Session.ActiveOrganizationPublicID + if cliErr := updateProfile(sess.ProfileName, func(p *config.Profile) { + p.ActiveOrg = publicID + p.ActiveProject = "" + p.APISecret = "" + }); cliErr != nil { + return cliErr + } + + project, projErr := resolveActiveProject(ctx, sess.Client, "", func(label string, options []string, active int) (int, error) { + return -1, errNotInteractivePick + }) + renderStatus := "none" + if projErr == nil && project.ID != "" { + if cliErr := updateProfile(sess.ProfileName, func(p *config.Profile) { p.ActiveProject = project.ID }); cliErr == nil { + if secret, issued, err := ensureRenderSecret(ctx, sess.Client, publicID, project.ID, false, promptPick); err == nil && secret != "" { + if updateProfile(sess.ProfileName, func(p *config.Profile) { p.APISecret = secret }) == nil { + if issued { + renderStatus = "issued" + } else { + renderStatus = "ready" + } + } + } + } + } + if projErr == nil && project.ID == "" { + fmt.Fprintln(cmd.ErrOrStderr(), "No projects in this organisation yet — run `urlbox projects select` after creating one.") + } + if projErr != nil { + fmt.Fprintln(cmd.ErrOrStderr(), "Several projects in this organisation — run `urlbox projects select` to pick one.") + } + + data := map[string]any{ + "org": map[string]any{"id": publicID, "name": chosen.Name}, + "render": map[string]any{"credential": renderStatus}, + } + if project.ID != "" { + data["project"] = map[string]any{"id": project.ID, "name": project.Name} + } + env := output.NewEnvelope("orgs select", data, + fmt.Sprintf("Active organisation: %s", chosen.Name), nil) + return writeEnvelopeWithQuietData(cmd, env, publicID) +} +``` + +Register in `internal/cmd/root.go`: `cmd.AddCommand(newOrgsCmd())`. + +- [ ] **Step 4: Cross-check against source** + +Read `SRC/internal/cmd/org.go:95-215`. If the source's `runOrgSelect` differs materially from the port above (beyond output plumbing), align the port to the source's behaviour and extend the tests to pin the difference. Known intended deltas (do not "fix"): envelopes replace RenderList tables; project re-resolution is non-interactive with a stderr pointer to `urlbox projects select`. + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `go test ./internal/cmd/ -run TestOrgs -v` +Expected: PASS all three. + +- [ ] **Step 6: Run `make surface-snapshot` then `make ci`** — expected green. + +- [ ] **Step 7: Mark task complete — NO commit.** + +--- + +### Task 13: `projects` group — context (`list`, `select`) + +**Files:** +- Create: `internal/cmd/projects.go` +- Test: `internal/cmd/projects_test.go` (create) +- Modify: `internal/cmd/root.go` (register) +- Source reference: `SRC/internal/cmd/projects.go:18-223` — read before implementing; port behaviour, express in envelopes. + +**Interfaces:** +- Consumes: `loadSession`, `fetchList`, `toNameIDs`, `resolveNameOrID`, `ensureRenderSecret`, `updateProfile`, `promptPick`. +- Produces: `newProjectsCmd() *cobra.Command` (Use `projects`, Aliases `["project"]`) — Task 15 adds subcommands to this same constructor; `requireActiveOrg(sess *sessionState) (string, *output.CLIError)`. + +- [ ] **Step 1: Write the failing test** + +Create `internal/cmd/projects_test.go`: + +```go +package cmd + +import ( + "bytes" + "testing" + + "github.com/urlbox/urlbox-cli/internal/api/apitest" +) + +func TestProjectsListMarksActive(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New( + apitest.SuccessJSON(`{"projects":[{"id":"proj_compat","name":"Main"},{"id":"proj_2","name":"Side"}]}`), + ) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + var stdout, stderr bytes.Buffer + code := Execute([]string{"projects", "list", "--output-format", "json"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit %d\n%s\n%s", code, stdout.String(), stderr.String()) + } + for _, want := range []string{`"proj_compat"`, `"proj_2"`, `"active":true`} { + if !bytes.Contains(stdout.Bytes(), []byte(want)) { + t.Fatalf("missing %s: %s", want, stdout.String()) + } + } +} + +func TestProjectsSelectPositionalRefreshesCredential(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New( + apitest.SuccessJSON(`{"projects":[{"id":"proj_compat","name":"Main"},{"id":"proj_2","name":"Side"}]}`), + apitest.SuccessJSON(`{"apiCredentials":[{"apiSecret":"sk_side","revoked":false}]}`), + ) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + var stdout, stderr bytes.Buffer + code := Execute([]string{"projects", "select", "side", "--output-format", "json"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit %d\n%s\n%s", code, stdout.String(), stderr.String()) + } + p := readProfileMap(t, dir) + if p["active_project"] != "proj_2" || p["api_secret"] != "sk_side" { + t.Fatalf("profile after select: %#v", p) + } +} + +func TestProjectsSelectNonInteractiveWithoutArg(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New( + apitest.SuccessJSON(`{"projects":[{"id":"proj_1","name":"A"},{"id":"proj_2","name":"B"}]}`), + ) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + var stdout, stderr bytes.Buffer + code := Execute([]string{"projects", "select", "--output-format", "json"}, &stdout, &stderr) + if code != 1 { + t.Fatalf("want usage exit 1, got %d\n%s", code, stdout.String()) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/cmd/ -run TestProjects -v` +Expected: FAIL — unknown command "projects". + +- [ ] **Step 3: Write the implementation** + +Create `internal/cmd/projects.go`: + +```go +package cmd + +import ( + "context" + "errors" + "fmt" + + "github.com/spf13/cobra" + + "github.com/urlbox/urlbox-cli/internal/config" + "github.com/urlbox/urlbox-cli/internal/output" +) + +func newProjectsCmd() *cobra.Command { + c := &cobra.Command{ + Use: "projects", + Aliases: []string{"project"}, + Short: "Manage projects and the active project", + } + list := &cobra.Command{ + Use: "list", + Short: "List the active organisation's projects", + Args: cobra.NoArgs, + RunE: runProjectsList, + } + sel := &cobra.Command{ + Use: "select [name-or-id]", + Short: "Set the active project (used by render)", + Args: cobra.MaximumNArgs(1), + RunE: runProjectsSelect, + } + c.AddCommand(list, sel) + return c +} + +func requireActiveOrg(sess *sessionState) (string, *output.CLIError) { + if sess.Profile.ActiveOrg == "" { + return "", output.NewCLIError(output.ErrUsage, "no active organisation", + "Run `urlbox orgs select` (or `urlbox login`) first.") + } + return sess.Profile.ActiveOrg, nil +} + +func runProjectsList(cmd *cobra.Command, _ []string) error { + sess, cliErr := loadSession(cmd) + if cliErr != nil { + return cliErr + } + projects, err := fetchList(context.Background(), sess.Client, "/v2/projects", "projects") + if err != nil { + return asCLIError(err) + } + rows := make([]map[string]any, len(projects)) + activeName := "" + for i, m := range projects { + id := valueOrEmpty(m["id"]) + active := id != "" && id == sess.Profile.ActiveProject + if active { + activeName = valueOrEmpty(m["name"]) + } + m["active"] = active + rows[i] = m + } + summary := fmt.Sprintf("%d projects", len(rows)) + if activeName != "" { + summary = fmt.Sprintf("%d projects — active: %s", len(rows), activeName) + } + env := output.NewEnvelope("projects list", map[string]any{"projects": rows}, summary, nil) + return writeEnvelope(cmd, env) +} + +func runProjectsSelect(cmd *cobra.Command, args []string) error { + sess, cliErr := loadSession(cmd) + if cliErr != nil { + return cliErr + } + ctx := context.Background() + projects, err := fetchList(ctx, sess.Client, "/v2/projects", "projects") + if err != nil { + return asCLIError(err) + } + rows := toNameIDs(projects) + if len(rows) == 0 { + return output.NewCLIError(output.ErrNotFound, "no projects in the active organisation", + "Create one with `urlbox projects create `.") + } + + var chosen nameID + if len(args) == 1 { + var resErr *output.CLIError + chosen, resErr = resolveNameOrID(args[0], "proj_", rows, "project") + if resErr != nil { + return resErr + } + } else { + names := make([]string, len(rows)) + active := -1 + for i, r := range rows { + names[i] = r.Name + if r.ID == sess.Profile.ActiveProject { + active = i + } + } + idx, perr := promptPick("Select the active project (used by render):", names, active) + if perr != nil { + if errors.Is(perr, errNotInteractivePick) { + return output.NewCLIError(output.ErrUsage, + "selection needs an interactive terminal", + "Pass the project directly: `urlbox projects select `.") + } + return output.NewCLIError(output.ErrUsage, perr.Error(), "") + } + chosen = rows[idx] + } + + if cliErr := updateProfile(sess.ProfileName, func(p *config.Profile) { p.ActiveProject = chosen.ID }); cliErr != nil { + return cliErr + } + renderStatus := "none" + if org := sess.Profile.ActiveOrg; org != "" { + formatFlag, _ := cmd.Root().PersistentFlags().GetString("output-format") + interactive := formatFlag != "json" && formatFlag != "quiet" + if secret, issued, err := ensureRenderSecret(ctx, sess.Client, org, chosen.ID, interactive, promptPick); err == nil && secret != "" { + if updateProfile(sess.ProfileName, func(p *config.Profile) { p.APISecret = secret }) == nil { + if issued { + renderStatus = "issued" + } else { + renderStatus = "ready" + } + } + } + } + + data := map[string]any{ + "project": map[string]any{"id": chosen.ID, "name": chosen.Name}, + "render": map[string]any{"credential": renderStatus}, + } + env := output.NewEnvelope("projects select", data, + fmt.Sprintf("Active project: %s", chosen.Name), nil) + return writeEnvelopeWithQuietData(cmd, env, chosen.ID) +} +``` + +Register in `internal/cmd/root.go`: `cmd.AddCommand(newProjectsCmd())`. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/cmd/ -run TestProjects -v` +Expected: PASS all three. + +- [ ] **Step 5: Run `make surface-snapshot` then `make ci`** — expected green. + +- [ ] **Step 6: Mark task complete — NO commit.** + +--- + +### Task 14: `usage` command + +**Files:** +- Create: `internal/cmd/usage.go` +- Test: `internal/cmd/usage_test.go` (create) +- Modify: `internal/cmd/root.go` (register) +- Source reference: `SRC/internal/cmd/usage.go` + +- [ ] **Step 1: Write the failing test** + +Create `internal/cmd/usage_test.go`: + +```go +package cmd + +import ( + "bytes" + "encoding/json" + "testing" + + "github.com/urlbox/urlbox-cli/internal/api/apitest" +) + +func TestUsageHappyPath(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New( + apitest.SuccessJSON(`{"rendersUsed":120,"renderQuota":1000,"period":{"start":"2026-08-01","end":"2026-08-31"}}`), + ) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + var stdout, stderr bytes.Buffer + code := Execute([]string{"usage", "--output-format", "json"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit %d\n%s\n%s", code, stdout.String(), stderr.String()) + } + var env struct { + Data map[string]any `json:"data"` + } + if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { + t.Fatalf("decode: %v", err) + } + if env.Data["renders_used"] != float64(120) || env.Data["render_quota"] != float64(1000) { + t.Fatalf("data: %#v", env.Data) + } + if env.Data["current_period_start"] != "2026-08-01" { + t.Fatalf("period: %#v", env.Data) + } +} + +func TestUsageNotLoggedIn(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, false) + t.Setenv("XDG_CONFIG_HOME", dir) + var stdout, stderr bytes.Buffer + code := Execute([]string{"usage", "--output-format", "json"}, &stdout, &stderr) + if code != 3 { + t.Fatalf("exit = %d, want 3", code) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/cmd/ -run TestUsage -v` +Expected: FAIL — unknown command "usage". + +- [ ] **Step 3: Write the implementation** + +Create `internal/cmd/usage.go`: + +```go +package cmd + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + + "github.com/urlbox/urlbox-cli/internal/output" +) + +func newUsageCmd() *cobra.Command { + return &cobra.Command{ + Use: "usage", + Short: "Show the organisation's render usage for the current period", + Args: cobra.NoArgs, + RunE: runUsage, + } +} + +func runUsage(cmd *cobra.Command, _ []string) error { + sess, cliErr := loadSession(cmd) + if cliErr != nil { + return cliErr + } + var usage struct { + RendersUsed int `json:"rendersUsed"` + RenderQuota int `json:"renderQuota"` + Period struct { + Start string `json:"start"` + End string `json:"end"` + } `json:"period"` + } + if err := sess.Client.GetJSON(context.Background(), "/v2/usage", &usage); err != nil { + return asCLIError(err) + } + data := map[string]any{ + "renders_used": usage.RendersUsed, + "render_quota": usage.RenderQuota, + "current_period_start": usage.Period.Start, + "current_period_end": usage.Period.End, + } + summary := fmt.Sprintf("Renders used: %d / %d", usage.RendersUsed, usage.RenderQuota) + env := output.NewEnvelope("usage", data, summary, nil) + return writeEnvelopeWithQuietData(cmd, env, fmt.Sprintf("%d", usage.RendersUsed)) +} +``` + +Register in `internal/cmd/root.go`: `cmd.AddCommand(newUsageCmd())`. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/cmd/ -run TestUsage -v` +Expected: PASS both. + +- [ ] **Step 5: Run `make surface-snapshot` then `make ci`** — expected green. + +- [ ] **Step 6: Mark task complete — NO commit.** + +--- + +### Task 15: `projects` CRUD + defaults + +**Files:** +- Modify: `internal/cmd/projects.go` (extend `newProjectsCmd`) +- Test: `internal/cmd/projects_crud_test.go` (create) +- Source reference: `SRC/internal/cmd/projects.go:224-1003` — read `runProjectsShow/Create/Update/Rename/SetEnabled/Delete/Defaults*` before implementing; endpoints below are pinned from that file. + +**Interfaces:** +- Consumes: `requireActiveOrg` (Task 13), `prompt.TypeToConfirm` (Task 7), everything from Task 13. +- Endpoints (pinned from source): create `POST /v2/projects` `{name}`; show `GET /v2/organisation/{org}/projects/{id}`; rename/enable/disable `PATCH /v2/organisation/{org}/projects/{id}` (`{"name":…}` / `{"enabled":…}`); delete `DELETE /v2/organisation/{org}/projects/{id}`; defaults read from the show endpoint's `defaultOptions`; defaults write `PATCH /v2/organisation/{org}/projects/{id}/render-defaults` `{"options": }`. + +- [ ] **Step 1: Write the failing test** + +Create `internal/cmd/projects_crud_test.go`: + +```go +package cmd + +import ( + "bytes" + "testing" + + "github.com/urlbox/urlbox-cli/internal/api/apitest" +) + +func TestProjectsCreate(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New(apitest.SuccessJSON(`{"project":{"id":"proj_new","name":"Fresh"}}`)) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + var stdout, stderr bytes.Buffer + code := Execute([]string{"projects", "create", "Fresh", "--output-format", "json"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit %d\n%s\n%s", code, stdout.String(), stderr.String()) + } + reqs := srv.Requests() + if reqs[0].Method != "POST" || reqs[0].Path != "/v2/projects" { + t.Fatalf("request: %+v", reqs[0]) + } + if !bytes.Contains(reqs[0].Body, []byte(`"name":"Fresh"`)) { + t.Fatalf("body: %s", reqs[0].Body) + } +} + +func TestProjectsRename(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New( + apitest.SuccessJSON(`{"projects":[{"id":"proj_1","name":"Old"}]}`), + apitest.SuccessJSON(`{"project":{"id":"proj_1","name":"New"}}`), + ) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + var stdout, stderr bytes.Buffer + code := Execute([]string{"projects", "rename", "old", "New", "--output-format", "json"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit %d\n%s\n%s", code, stdout.String(), stderr.String()) + } + reqs := srv.Requests() + if reqs[1].Method != "PATCH" || reqs[1].Path != "/v2/organisation/org_compat/projects/proj_1" { + t.Fatalf("request: %+v", reqs[1]) + } + if !bytes.Contains(reqs[1].Body, []byte(`"name":"New"`)) { + t.Fatalf("body: %s", reqs[1].Body) + } +} + +func TestProjectsDeleteRequiresYesOffTTY(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New(apitest.SuccessJSON(`{"projects":[{"id":"proj_1","name":"Doomed"}]}`)) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + var stdout, stderr bytes.Buffer + code := Execute([]string{"projects", "delete", "doomed", "--output-format", "json"}, &stdout, &stderr) + if code == 0 { + t.Fatalf("delete without --yes off-TTY must fail\n%s", stdout.String()) + } + if !bytes.Contains(stdout.Bytes(), []byte("--yes")) { + t.Fatalf("error must name --yes: %s", stdout.String()) + } +} + +func TestProjectsDeleteWithYes(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New( + apitest.SuccessJSON(`{"projects":[{"id":"proj_1","name":"Doomed"}]}`), + apitest.SuccessJSON(`{}`), + ) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + var stdout, stderr bytes.Buffer + code := Execute([]string{"projects", "delete", "doomed", "--yes", "--output-format", "json"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit %d\n%s\n%s", code, stdout.String(), stderr.String()) + } + reqs := srv.Requests() + if reqs[1].Method != "DELETE" || reqs[1].Path != "/v2/organisation/org_compat/projects/proj_1" { + t.Fatalf("request: %+v", reqs[1]) + } +} + +func TestProjectsDefaultsSetAndRemove(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New( + apitest.SuccessJSON(`{"projects":[{"id":"proj_1","name":"Main"}]}`), + apitest.SuccessJSON(`{"project":{"id":"proj_1","defaultOptions":{"format":"png"}}}`), + apitest.SuccessJSON(`{"projects":[{"id":"proj_1","name":"Main"}]}`), + apitest.SuccessJSON(`{"project":{"id":"proj_1"}}`), + ) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + + var stdout, stderr bytes.Buffer + code := Execute([]string{"projects", "defaults", "set", "main", "--json", `{"format":"png"}`, "--output-format", "json"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("set exit %d\n%s\n%s", code, stdout.String(), stderr.String()) + } + reqs := srv.Requests() + if reqs[1].Method != "PATCH" || reqs[1].Path != "/v2/organisation/org_compat/projects/proj_1/render-defaults" { + t.Fatalf("set request: %+v", reqs[1]) + } + if !bytes.Contains(reqs[1].Body, []byte(`"format":"png"`)) { + t.Fatalf("set body: %s", reqs[1].Body) + } + + stdout.Reset() + stderr.Reset() + code = Execute([]string{"projects", "defaults", "remove", "main", "--output-format", "json"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("remove exit %d\n%s\n%s", code, stdout.String(), stderr.String()) + } + reqs = srv.Requests() + if !bytes.Contains(reqs[3].Body, []byte(`"options":null`)) { + t.Fatalf("remove body: %s", reqs[3].Body) + } +} + +func TestProjectsCrudNeedsActiveOrg(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + t.Setenv("URLBOX_API_HOST", "http://127.0.0.1:1") + cfgPath := dir + "/urlbox/config.json" + _ = cfgPath + var stdout, stderr bytes.Buffer + code := Execute([]string{"config", "set", "active_org", "", "--output-format", "json"}, &stdout, &stderr) + _ = code + stdout.Reset() + code = Execute([]string{"projects", "rename", "proj_x", "New", "--output-format", "json"}, &stdout, &stderr) + if code != 1 { + t.Fatalf("no active org must be usage exit 1, got %d\n%s", code, stdout.String()) + } + if !bytes.Contains(stdout.Bytes(), []byte("orgs select")) { + t.Fatalf("hint must name orgs select: %s", stdout.String()) + } +} +``` + +Note: `TestProjectsCrudNeedsActiveOrg` depends on Task 16's `config set active_org`; until Task 16 lands, blank the field by writing the fixture with `withSession` variant that omits `active_org` — add a third fixture writer if needed (`writeCompatConfigNoOrg`) instead of depending on Task 16 ordering. Implement whichever is reached first; the assertion (usage error naming `orgs select`) is the contract. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/cmd/ -run 'TestProjectsCreate|TestProjectsRename|TestProjectsDelete|TestProjectsDefaults|TestProjectsCrudNeeds' -v` +Expected: FAIL — unknown subcommands. + +- [ ] **Step 3: Extend `newProjectsCmd`** + +Add to `internal/cmd/projects.go` (new subcommands appended inside `newProjectsCmd` after `c.AddCommand(list, sel)`, plus the run functions): + +```go + show := &cobra.Command{ + Use: "show ", + Short: "Show one project", + Args: cobra.ExactArgs(1), + RunE: runProjectsShow, + } + create := &cobra.Command{ + Use: "create ", + Short: "Create a project in the active organisation", + Args: cobra.ExactArgs(1), + RunE: runProjectsCreate, + } + rename := &cobra.Command{ + Use: "rename ", + Short: "Rename a project", + Args: cobra.ExactArgs(2), + RunE: runProjectsRename, + } + enable := &cobra.Command{ + Use: "enable ", + Short: "Enable a project", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runProjectsSetEnabled(cmd, args, true) + }, + } + disable := &cobra.Command{ + Use: "disable ", + Short: "Disable a project", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runProjectsSetEnabled(cmd, args, false) + }, + } + var yes bool + del := &cobra.Command{ + Use: "delete ", + Short: "Delete a project", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runProjectsDelete(cmd, args, yes) + }, + } + del.Flags().BoolVar(&yes, "yes", false, "Skip the retype-to-confirm prompt") + defaults := &cobra.Command{ + Use: "defaults", + Short: "Manage the project's default render options", + } + defaultsShow := &cobra.Command{ + Use: "show ", + Short: "Show default render options", + Args: cobra.ExactArgs(1), + RunE: runProjectsDefaultsShow, + } + var defaultsJSON string + var defaultsMerge bool + defaultsSet := &cobra.Command{ + Use: "set --json ", + Short: "Set default render options", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runProjectsDefaultsSet(cmd, args, defaultsJSON, defaultsMerge) + }, + } + defaultsSet.Flags().StringVar(&defaultsJSON, "json", "", "Default options as a JSON object") + defaultsSet.Flags().BoolVar(&defaultsMerge, "merge", false, "Merge into the existing defaults instead of replacing them") + defaultsRemove := &cobra.Command{ + Use: "remove ", + Short: "Remove all default render options", + Args: cobra.ExactArgs(1), + RunE: runProjectsDefaultsRemove, + } + defaults.AddCommand(defaultsShow, defaultsSet, defaultsRemove) + c.AddCommand(show, create, rename, enable, disable, del, defaults) +``` + +And the run functions (same file): + +```go +func resolveProjectArg(cmd *cobra.Command, sess *sessionState, arg string) (nameID, *output.CLIError) { + projects, err := fetchList(context.Background(), sess.Client, "/v2/projects", "projects") + if err != nil { + return nameID{}, asCLIError(err) + } + return resolveNameOrID(arg, "proj_", toNameIDs(projects), "project") +} + +func projectPath(org, id string) string { + return "/v2/organisation/" + org + "/projects/" + id +} + +func runProjectsShow(cmd *cobra.Command, args []string) error { + sess, cliErr := loadSession(cmd) + if cliErr != nil { + return cliErr + } + org, orgErr := requireActiveOrg(sess) + if orgErr != nil { + return orgErr + } + resolved, resErr := resolveProjectArg(cmd, sess, args[0]) + if resErr != nil { + return resErr + } + var resp map[string]any + if err := sess.Client.GetJSON(context.Background(), projectPath(org, resolved.ID), &resp); err != nil { + return asCLIError(err) + } + env := output.NewEnvelope("projects show", resp, + fmt.Sprintf("Project %s", resolved.ID), nil) + return writeEnvelope(cmd, env) +} + +func runProjectsCreate(cmd *cobra.Command, args []string) error { + sess, cliErr := loadSession(cmd) + if cliErr != nil { + return cliErr + } + var resp map[string]any + if err := sess.Client.PostJSON(context.Background(), "/v2/projects", + map[string]string{"name": args[0]}, &resp); err != nil { + return asCLIError(err) + } + env := output.NewEnvelope("projects create", resp, + fmt.Sprintf("Created project %s", args[0]), + []output.Breadcrumb{{Action: "activate", Cmd: "urlbox projects select " + args[0]}}) + return writeEnvelope(cmd, env) +} + +func runProjectsRename(cmd *cobra.Command, args []string) error { + sess, cliErr := loadSession(cmd) + if cliErr != nil { + return cliErr + } + org, orgErr := requireActiveOrg(sess) + if orgErr != nil { + return orgErr + } + resolved, resErr := resolveProjectArg(cmd, sess, args[0]) + if resErr != nil { + return resErr + } + var resp map[string]any + if err := sess.Client.PatchJSON(context.Background(), projectPath(org, resolved.ID), + map[string]string{"name": args[1]}, &resp); err != nil { + return asCLIError(err) + } + env := output.NewEnvelope("projects rename", resp, + fmt.Sprintf("Renamed %s to %s", resolved.ID, args[1]), nil) + return writeEnvelope(cmd, env) +} + +func runProjectsSetEnabled(cmd *cobra.Command, args []string, enabled bool) error { + sess, cliErr := loadSession(cmd) + if cliErr != nil { + return cliErr + } + org, orgErr := requireActiveOrg(sess) + if orgErr != nil { + return orgErr + } + resolved, resErr := resolveProjectArg(cmd, sess, args[0]) + if resErr != nil { + return resErr + } + var resp map[string]any + if err := sess.Client.PatchJSON(context.Background(), projectPath(org, resolved.ID), + map[string]bool{"enabled": enabled}, &resp); err != nil { + return asCLIError(err) + } + verb := "Enabled" + if !enabled { + verb = "Disabled" + } + env := output.NewEnvelope("projects "+map[bool]string{true: "enable", false: "disable"}[enabled], + resp, fmt.Sprintf("%s %s", verb, resolved.ID), nil) + return writeEnvelope(cmd, env) +} + +func runProjectsDelete(cmd *cobra.Command, args []string, yes bool) error { + sess, cliErr := loadSession(cmd) + if cliErr != nil { + return cliErr + } + org, orgErr := requireActiveOrg(sess) + if orgErr != nil { + return orgErr + } + resolved, resErr := resolveProjectArg(cmd, sess, args[0]) + if resErr != nil { + return resErr + } + if !yes { + name := resolved.Name + if name == "" { + name = resolved.ID + } + if err := prompt.TypeToConfirm(fmt.Sprintf("Type %q to confirm deletion:", name), name); err != nil { + if errors.Is(err, prompt.ErrNotInteractive) { + return output.NewCLIError(output.ErrUsage, + "deletion needs confirmation", + "Re-run with --yes to confirm non-interactively.") + } + return output.NewCLIError(output.ErrUsage, err.Error(), "") + } + } + if err := sess.Client.DeleteJSON(context.Background(), projectPath(org, resolved.ID), nil); err != nil { + return asCLIError(err) + } + env := output.NewEnvelope("projects delete", + map[string]any{"deleted": resolved.ID}, + fmt.Sprintf("Deleted project %s", resolved.ID), nil) + return writeEnvelope(cmd, env) +} + +func runProjectsDefaultsShow(cmd *cobra.Command, args []string) error { + sess, cliErr := loadSession(cmd) + if cliErr != nil { + return cliErr + } + org, orgErr := requireActiveOrg(sess) + if orgErr != nil { + return orgErr + } + resolved, resErr := resolveProjectArg(cmd, sess, args[0]) + if resErr != nil { + return resErr + } + var resp map[string]any + if err := sess.Client.GetJSON(context.Background(), projectPath(org, resolved.ID), &resp); err != nil { + return asCLIError(err) + } + defaults := map[string]any{} + if project, ok := resp["project"].(map[string]any); ok { + if d, ok := project["defaultOptions"].(map[string]any); ok { + defaults = d + } + } + env := output.NewEnvelope("projects defaults show", + map[string]any{"project": resolved.ID, "defaults": defaults}, + fmt.Sprintf("%d default options on %s", len(defaults), resolved.ID), nil) + return writeEnvelope(cmd, env) +} + +func runProjectsDefaultsSet(cmd *cobra.Command, args []string, jsonBody string, merge bool) error { + sess, cliErr := loadSession(cmd) + if cliErr != nil { + return cliErr + } + org, orgErr := requireActiveOrg(sess) + if orgErr != nil { + return orgErr + } + if jsonBody == "" { + return output.NewCLIError(output.ErrUsage, "missing --json", + `Pass the defaults as a JSON object: --json '{"format":"png"}'.`) + } + var options map[string]any + if err := json.Unmarshal([]byte(jsonBody), &options); err != nil { + return output.NewCLIError(output.ErrUsage, "--json is not a valid JSON object: "+err.Error(), + `Example: --json '{"format":"png","full_page":true}'.`) + } + resolved, resErr := resolveProjectArg(cmd, sess, args[0]) + if resErr != nil { + return resErr + } + final := options + if merge { + var current map[string]any + if err := sess.Client.GetJSON(context.Background(), projectPath(org, resolved.ID), ¤t); err != nil { + return asCLIError(err) + } + merged := map[string]any{} + if project, ok := current["project"].(map[string]any); ok { + if d, ok := project["defaultOptions"].(map[string]any); ok { + for k, v := range d { + merged[k] = v + } + } + } + for k, v := range options { + merged[k] = v + } + final = merged + } + var resp map[string]any + if err := sess.Client.PatchJSON(context.Background(), + projectPath(org, resolved.ID)+"/render-defaults", + map[string]any{"options": final}, &resp); err != nil { + return asCLIError(err) + } + env := output.NewEnvelope("projects defaults set", resp, + fmt.Sprintf("Set %d default options on %s", len(final), resolved.ID), nil) + return writeEnvelope(cmd, env) +} + +func runProjectsDefaultsRemove(cmd *cobra.Command, args []string) error { + sess, cliErr := loadSession(cmd) + if cliErr != nil { + return cliErr + } + org, orgErr := requireActiveOrg(sess) + if orgErr != nil { + return orgErr + } + resolved, resErr := resolveProjectArg(cmd, sess, args[0]) + if resErr != nil { + return resErr + } + var resp map[string]any + if err := sess.Client.PatchJSON(context.Background(), + projectPath(org, resolved.ID)+"/render-defaults", + map[string]any{"options": nil}, &resp); err != nil { + return asCLIError(err) + } + env := output.NewEnvelope("projects defaults remove", resp, + fmt.Sprintf("Removed default options from %s", resolved.ID), nil) + return writeEnvelope(cmd, env) +} +``` + +Add the imports `encoding/json` and `github.com/urlbox/urlbox-cli/internal/prompt` to `internal/cmd/projects.go`. + +- [ ] **Step 4: Cross-check against source** + +Read `SRC/internal/cmd/projects.go:224-1003`. Align any endpoint/body divergence to the source (they are pinned above from that file; this step is verification, not discovery). The `--merge` read path deliberately uses the org-scoped show endpoint (source line 925). + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `go test ./internal/cmd/ -race -v` +Expected: PASS — the new CRUD tests plus every earlier task's tests plus the Task 2 compatibility suite. + +- [ ] **Step 6: Run `make surface-snapshot` then `make ci`** — expected green. + +- [ ] **Step 7: Mark task complete — NO commit.** + +--- + +### Task 16: Config keys, in-repo docs, final surface, agent-layer verification + +**Files:** +- Modify: `internal/cmd/config.go` (the `config get` read switch and the `config set` write switch — locate both with `grep -n '"api_secret"' internal/cmd/config.go`) +- Test: `internal/cmd/config_session_keys_test.go` (create) +- Modify: `skills/SKILL.md`, `README.md`, `npm/README.md` +- Create: `docs/superpowers/verification/2026-08-12-plan1-agent-layer.md` + +**Interfaces:** +- Consumes: `maskSecret` (auth.go:313 — stays in place; Plan 2's auth sweep must relocate it before deleting auth.go), Task 1 fields. + +- [ ] **Step 1: Write the failing test** + +Create `internal/cmd/config_session_keys_test.go`: + +```go +package cmd + +import ( + "bytes" + "testing" +) + +func TestConfigGetSessionTokenMasked(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + var stdout, stderr bytes.Buffer + code := Execute([]string{"config", "get", "session_token", "--output-format", "json"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit %d\n%s\n%s", code, stdout.String(), stderr.String()) + } + if bytes.Contains(stdout.Bytes(), []byte("sess_tok_compat_123456")) { + t.Fatalf("session token leaked unmasked: %s", stdout.String()) + } + if !bytes.Contains(stdout.Bytes(), []byte("sess")) { + t.Fatalf("masked value missing: %s", stdout.String()) + } +} + +func TestConfigGetSessionTokenReveal(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + var stdout, stderr bytes.Buffer + code := Execute([]string{"config", "get", "session_token", "--reveal", "--output-format", "json"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit %d\n%s", code, stderr.String()) + } + if !bytes.Contains(stdout.Bytes(), []byte("sess_tok_compat_123456")) { + t.Fatalf("--reveal must show the token: %s", stdout.String()) + } +} + +func TestConfigGetActiveOrgAndProject(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + for key, want := range map[string]string{"active_org": "org_compat", "active_project": "proj_compat"} { + var stdout, stderr bytes.Buffer + code := Execute([]string{"config", "get", key, "--output-format", "json"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("%s exit %d", key, code) + } + if !bytes.Contains(stdout.Bytes(), []byte(want)) { + t.Fatalf("%s: %s", key, stdout.String()) + } + } +} + +func TestConfigSetActiveProject(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + var stdout, stderr bytes.Buffer + code := Execute([]string{"config", "set", "active_project", "proj_other"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit %d\n%s", code, stderr.String()) + } + if p := readProfileMap(t, dir); p["active_project"] != "proj_other" { + t.Fatalf("profile: %#v", p) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/cmd/ -run TestConfigGetSession -v` +Expected: FAIL — unknown config key `session_token` (whatever exact error the existing unknown-key path produces). + +- [ ] **Step 3: Extend the config key switches** + +In `internal/cmd/config.go`: add `"session_token"` (masked with `maskSecret` unless the existing `--reveal` flag on `config get` is set — mirror the `api_secret` case exactly), `"active_org"`, and `"active_project"` (plain string cases) to BOTH the `config get` read switch and the `config set` write switch (the one feeding `writeProfileValue` / the equivalent). Also extend the valid-keys list in the command's help/error text. `config set session_token` validates through `config.ValidateSecretValue`, exactly as `api_secret` does. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/cmd/ -run TestConfig -v` +Expected: PASS — the new tests plus all pre-existing config command tests. + +- [ ] **Step 5: In-repo docs sweep (additive only — auth references are Plan 2's)** + +1. `skills/SKILL.md`: add the new commands to the command inventory, matching the file's existing list format, with one line each: `login` (browser sign-in; agents: use URLBOX_API_SECRET instead), `logout`, `whoami`/`me`, `orgs list|select`, `projects list|select|show|create|rename|enable|disable|delete|defaults`, `usage`. +2. `README.md`: in the command list, add `urlbox login`, `urlbox whoami`, `urlbox orgs list`, `urlbox projects list`, `urlbox usage` example lines, following the existing bullet format. Do NOT touch existing `urlbox auth` text (Plan 2). +3. `npm/README.md`: mirror the README additions. + +- [ ] **Step 6: Final surface + gates** + +Run: `make surface-snapshot && make ci && go test ./... -race` +Expected: all green; `SURFACE.txt` diff shows ONLY additions (login, logout, whoami, orgs, projects, usage + their flags). + +- [ ] **Step 7: Agent-layer verification (record actual output)** + +Create `docs/superpowers/verification/2026-08-12-plan1-agent-layer.md`. Build the binary (`go build -o bin/urlbox ./cmd/urlbox`). For EACH of the two config states (legacy: profile with api_key+api_secret only; post-login: plus session_token/active_org/active_project — construct both in a scratch `XDG_CONFIG_HOME`), run every pre-existing command exactly as listed and paste the actual stdout/stderr/exit-code into the doc: + +``` +bin/urlbox version +bin/urlbox commands --output-format json +bin/urlbox schema render --output-format json | head -5 +bin/urlbox render https://example.com --dry-run --output-format json +bin/urlbox screenshot https://example.com --dry-run --output-format json +bin/urlbox pdf https://example.com --dry-run --output-format json +bin/urlbox render https://example.com --curl +bin/urlbox link https://example.com +bin/urlbox config path +bin/urlbox config get api_secret +bin/urlbox config profile list +bin/urlbox doctor || true +bin/urlbox dashboard --output-format json +``` + +plus the new commands in their logged-out state (`login` interrupted with Ctrl-C after the code prints, `whoami`, `usage`, `orgs list`, `projects list` — each must produce the auth error envelope, exit 3). Any behavioural difference between the two states for a pre-existing command is a STOP-the-line bug. This doc is the input to the human-layer checklist (Plan 2's release gate). + +- [ ] **Step 8: Mark task complete — NO commit.** Plan 1 ends here; Plan 2 (credential resources + auth sweep) starts from this uncommitted state. + +--- + +## Self-Review + +**1. Spec coverage (slices 1–3):** config fields → Task 1; compatibility net → Task 2 (+16 Step 7); session client + 401 mapping → Task 3; five transplants → Tasks 4 (poll), 5 (name-or-id), 6 (org/project matrix + id translation), 8 (render credential; masking + payload-mapping transplants belong to Plan 2's credential resources, where their behaviour lives); picker → Task 7; login flow steps 1–8 → Task 9; logout → Task 10; whoami → Task 11; orgs → Task 12; project context → Task 13; usage → Task 14; projects CRUD + defaults → Task 15; config keys/docs/surface/agent-verification → Task 16. Envelope/error/surface/help conventions are embedded per-task. Gaps: none for slices 1–3; masking + payload-mapping transplants explicitly deferred to Plan 2 with their consuming commands. + +**2. Placeholder scan:** no TBDs; every code step carries complete code; the two "cross-check against source" steps (12.4, 15.4) are verification steps with pinned expected behaviour, not deferred design. Task 15's active-org test note resolves its own ordering dependency (`writeCompatConfigNoOrg` fallback). + +**3. Type consistency:** `nameID`/`pickFunc`/`errNotInteractivePick`/`sessionState`/`asCLIError` defined once (Tasks 5/6/9) and consumed with identical signatures in Tasks 8–15; `deviceauth.Exchange{AccessToken, RFCCode, Err}` matches Task 9's exchange closure; `config.ProfileName(flag, env, overlay, cfg)` matches Task 9's `sessionHost` usage; `writeEnvelope(cmd, env)` / `writeEnvelopeWithQuietData(cmd, env, scalar)` used exactly as defined in `internal/cmd/config.go:614-650`; `apitest.ScriptedResponse{Status, Body}` literals match `internal/api/apitest/server.go:46`. One deliberate adaptation: source's `resolveNameOrID` returned `error`, here it returns `*output.CLIError` (house closed-code requirement) — all call sites in Tasks 13/15 use the CLIError form consistently. diff --git a/docs/superpowers/specs/2026-08-12-account-management-port-design.md b/docs/superpowers/specs/2026-08-12-account-management-port-design.md new file mode 100644 index 0000000..ee6babc --- /dev/null +++ b/docs/superpowers/specs/2026-08-12-account-management-port-design.md @@ -0,0 +1,127 @@ +# Account management + device login — port design + +**Status:** DESIGN / approved in conversation, pending written review. +**Target repo:** `urlbox/urlbox-cli` (this repo — the shipped `@urlbox/cli`). +**Behaviour source:** the `urlbox/cli` repo, branch `feat/device-login` (PR #8, approved there), whose account-management surface was built against the deployed device-auth API. That implementation is the behaviour spec; this repo's conventions are the implementation spec. Where they pull apart, behaviour comes from the source and expression comes from this repo. + +## Goal + +Bring the session/account-management surface into the shipped CLI: browser device-flow `login`, session management, org/project context, `/v2` management commands, and org-owned credential resources (storage, proxies, LLM) — with every pre-existing command provably unaffected. + +## Scope + +**Added (all net-new here):** + +- `login`, `logout`, `whoami` (alias `me`) +- `orgs list|select` (alias `org`) +- `projects` (alias `project`): list/select/create/update/delete + `defaults` (on `/v2`) — one group; `select` sets the active project +- `usage` +- `storage`, `proxies`, `llm` groups: list/show/create/update/delete; `llm test`, `llm models` +- `projects storage|proxy|llm assign|unassign`; create→assign prompt; `--assign-to` + +**Removed:** the `auth` command (see "The auth removal sweep"). + +**Not ported:** `batch`, `jobs`, `webhook` (they exist only in the source repo; explicitly out). + +**Untouched in behaviour:** `render`, `screenshot`/`pdf`/`video`, `link`, `status`, `dashboard`, `skill`, `commands`, `schema`, `upgrade`, `version` — and all packaging/release machinery (goreleaser, npm, brew, scoop). New commands ship with the next ordinary release. Two existing commands are deliberately *extended*, not preserved byte-for-byte: `config` (learns the new profile keys) and `doctor` (learns the session world; loses its `urlbox auth` references in the sweep). + +## Credential model + +Two credentials, two clients, one profile: + +| Surface | Credential | Commands | +|---|---|---| +| Rendering (`/v1` render, status) | API secret (`api_secret`, as today) | `render` family, `status`, `link` | +| Management (`/v2` + better-auth) | Session token (`session_token`, new) | everything added above | + +- `Profile` gains `session_token`, `active_org` (`org_…` public id), `active_project` (`proj_…` public id). Real struct fields so every save path preserves them; the config validators accept them. +- A second, session-authenticated constructor joins the existing client in `internal/api`, riding the same retry/backoff, status→error-code mapping, timeouts, and user-agent. A command constructs the client for its surface and physically cannot send the wrong credential. +- `login` stores the fetched render secret in the profile's existing `api_secret` field — the field the render pipeline already reads. No render-path code changes. + +## The login flow + +1. `POST /v1/auth/device/code` (`client_id: "urlbox-cli"`) → code + verification URL. +2. Print the code and URL, best-effort open the browser (house opener; failure is non-fatal — the URL is already printed). +3. Poll `POST /v1/auth/device/token` per the server's `interval`; honour `slow_down` backoff, `authorization_pending`, denial, and the `expires_in` window. +4. Store the session token (config write is lockfile-guarded; file mode 0600 as today). +5. Resolve the active org: `GET /v1/auth/organization/list`; one org → silent; several → picker (`--org ` bypasses). `POST /v1/auth/organization/set-active` with better-auth's internal id, then read the public id back from `GET /v1/auth/get-session` and store that. +6. Resolve the active project the same way (`--project` bypasses; zero projects → say so and continue). +7. Fetch the active project's render credential over the session (issuing one if the project has none) and store it as `api_secret`. Best-effort: failure warns but never fails the login. +8. Success envelope: email, org, project, render-credential status (`ready`/`issued`/`none`/`error`). + +`logout`: `POST /v1/auth/sign-out` (revokes only this session), then clear `session_token`/`active_org`/`active_project` plus the login-installed `api_key`/`api_secret` locally (matching the source implementation). Server failure still clears local state, with a warning. `whoami`: `GET /v1/auth/get-session` + active context; JSON carries email and org/project ids/names. + +## Management + credential resources (behaviour summary) + +- Path-less `/v2` where the session's active org drives resolution (`/v2/projects`, `/v2/usage`, `/v2/me`); org-scoped `/v2/organisation/{org}/…` for credential resources and project sub-resources, using the stored public org id. +- Lists show an ASSIGNED count (in-use vs orphaned at a glance); proxy URLs never appear in lists (they embed passwords). +- `show` masks secrets (storage key/secret/SAS token, LLM API keys/cloud credentials, the password component of proxy URLs — only that component); `--reveal` unhides, matching the house pattern. +- `create`/`update` are flag-driven with `--json` for full/partial payloads; updates send only what was passed (partial PATCH); `proxies update` replaces the whole URL list (help text says so); LLM `--provider` is create-only. +- Positional resource/project arguments accept a public id (prefix match: `store_`/`pool_`/`llm_`/`proj_`) or a name; names resolve via the list; ambiguity errors listing the matching ids. +- After a TTY `create`: offer to assign to a project (skippable); `--assign-to` does it non-interactively; non-TTY without the flag: create, print, exit. +- Agent-hard requirements: every interactive affordance has a flag equivalent; every data command supports JSON output; deletes take `--yes`; non-TTY never hangs — pickers error naming the flag to pass. + +## House-convention integration + +Every new command complies with the existing machinery, no exceptions: + +- **Output:** success/error envelopes; errors only from the closed ten-code set — "not logged in" and "session expired" map to `auth` (exit 3) with hint `Run \`urlbox login\``; name-resolution misses → `not_found`; ambiguous names → `validation` with the candidate ids in the hint. Breadcrumbs point at the natural next command (login → first screenshot). `--jq`, `--agent`, `--output-format`, `--profile` inherited from root. stdout = data, stderr = humans. Quiet-mode scalars defined per command at plan time. +- **Surface contract:** every command/flag lands in `SURFACE.txt` via `make surface-snapshot`, committed with the code, every slice. +- **Config:** all writes through the lockfile-guarded update path; new keys supported by `config get/set/show` with session token masked (`--reveal` to unhide). +- **Help:** house style — multi-paragraph Long, `Examples:` block, footgun warnings, exit codes documented where they vary. +- **Picker:** this repo's first interactive select component. Draws to stderr only, uses the existing lipgloss styles, respects `NO_COLOR`, returns a clean error on non-TTY naming the bypass flag. Used by login, `orgs select`, `project select`, create→assign. +- **Quality gates:** TDD throughout; `make ci` (fmt-check, lint, test, build, surface-check) green at every slice boundary. + +## Transplants (approach C) + +Command wrappers are written fresh in cobra + envelopes. Five framework-free logic pieces move across from the source repo *with their unit tests*, imports adjusted, wired into house plumbing: + +1. Device-poll state machine (interval, `slow_down` backoff, pending, denial, expiry) — told time via `internal/clock` so tests fast-forward. +2. Login's org/project resolution sequence (zero/one/many × zero/one/many, and the internal-id → public-id translation through set-active + get-session). +3. Name-or-id resolution (prefix precedence, list lookup, ambiguity error listing ids). +4. Secret-masking rules (including password-only masking inside proxy URLs). +5. Flag → `/v2` payload mapping (partial-PATCH semantics, s3-vs-azure field sets, whole-list proxy replacement). + +## The auth removal sweep + +`urlbox auth` is removed: `login` is the interactive door; `URLBOX_API_SECRET` (already supported) is the CI/headless path. The removal is deliberate and will appear in the `SURFACE.txt` diff (that file makes removals reviewed, not forbidden). The sweep updates every reference to the old flow in the same PR: `doctor` checks and hints, error hints, help text, README, `npm/README.md`, and the agent skill. `doctor` additionally learns the session world: logged in, session valid, active org/project set, render credential present. + +## Profiles: kept as plumbing, undocumented + +The multi-profile machinery is untouched — it is load-bearing for every existing command (all credential resolution routes through it; the shipped on-disk config format is profile-shaped; 57 surface lines and the most-tested config logic depend on it). Session fields live inside a profile, exactly as the source implementation does. But profiles are not documented to users: no docs-site coverage, no mention in new help text beyond the inherited `--profile` flag. Multi-account support ("log into two accounts side by side") exists silently; publicising it is a possible docs-only follow-up, expected never to be needed (one account reaches many orgs). + +## Compatibility & regression protection (the "don't break render" clause) + +`login` writes into the same file every existing command reads. Protections, built in slice 1 before anything else stacks on them: + +- New fields added to the config struct and validators so no save path drops them and no read path rejects them. +- A compatibility suite runs every pre-existing command against two config states — legacy (no session fields) and post-login — asserting identical behaviour: `render` dry-run, `screenshot`, `link` signing, `status`, `doctor`, `config get`/`config path`/`config profile list`, `dashboard`, `schema`, `commands`, `version`. +- e2e binary test for the login → render sequence. +- Each existing command's config interaction is enumerated in the implementation plan; none assumed. + +## Verification protocol (two layers, both mandatory) + +1. **Agent layer, per slice:** drive every pre-existing command one at a time in a real terminal against both config states and record actual output — alongside the automated suites and `make ci`. +2. **Human layer, merge gate:** a written manual checklist — exact commands with expected results, login through render through credential groups — run by hand against production. Nothing is declared working from tests alone. + +## Build order + +1. **Foundation** — config fields, session client, `login`, `logout`; compatibility suite exists and passes. Real login works end-to-end against production at the end of this slice. +2. **Context** — `whoami`/`me`, `orgs list|select`, `project list|select`, the picker. +3. **Management** — `projects` CRUD + defaults, `usage`. +4. **Credential resources** — `storage`/`proxies`/`llm`, assign/unassign, create→assign, `--assign-to`. +5. **Auth sweep** — remove `auth`, update all references, final surface snapshot. + +Delivered as one PR on a feature branch here, slices as reviewable commits. + +## Out of scope / follow-ups + +- `batch`, `jobs`, `webhook` — not ported. +- `storage test` / `proxies test` — no `/v2` endpoints yet (LLM is the only resource with test/models); follow-up CLI PR when the endpoints ship. +- Docs site + blog updates — resume after this PR merges (task list already prepared in the mono). +- Retirement of the source repo and its PR #8 — separate conversation, not this PR. +- Publicising multi-account profiles — docs-only follow-up, if ever. + +## Endpoints consumed (all deployed in production today) + +`POST /v1/auth/device/code` · `POST /v1/auth/device/token` · `GET /v1/auth/organization/list` · `POST /v1/auth/organization/set-active` · `GET /v1/auth/get-session` · `POST /v1/auth/sign-out` · `GET /v2/me` · `GET /v2/usage` · `GET/POST… /v2/projects` and org-scoped project routes incl. render-defaults · `/v2/organisation/{org}/storage-credentials[/{id}]` · `/v2/organisation/{org}/proxies[/{id}]` · `/v2/organisation/{org}/llm-credentials[/{id}]` + `/test` + `/models` · `PUT/DELETE /v2/organisation/{org}/projects/{project}/storage-credential|proxy|llm-credential` · the project api-credential fetch/issue routes used by login step 7. The mono needs zero changes; exact paths are pinned from the source implementation at plan time. diff --git a/docs/superpowers/verification/2026-08-12-plan1-agent-layer.md b/docs/superpowers/verification/2026-08-12-plan1-agent-layer.md new file mode 100644 index 0000000..d5a2936 --- /dev/null +++ b/docs/superpowers/verification/2026-08-12-plan1-agent-layer.md @@ -0,0 +1,449 @@ +# Plan 1 — Agent-layer verification (2026-08-12) + +Final agent-layer verification for Plan 1 (account management port: login / +logout / whoami / orgs / projects / usage + session config keys). This doc is +the input to the human-layer checklist that gates the Plan 2 release. + +## Environment + +- Branch: `feat/account-management` (tip `e1db0d3`, working tree contains the + Task 16 changes: config session keys, docs sweep). +- Binary under test: `bin/urlbox` built from this tree via + `go build -o bin/urlbox ./cmd/urlbox` (go1.24.0 darwin/arm64). +- Gates before this run: `make ci` green (fmt-check, lint 0 issues, + `go test -race -cover ./...` all pass, build, surface-check), + `make surface-snapshot` produced no `SURFACE.txt` diff (the new commands were + already snapshotted by Tasks 9–15; Task 16 added no new flags/commands). + +## Two config states + +Constructed in scratch `XDG_CONFIG_HOME` directories. + +**Legacy (pre-login shape)** — `api_key` + `api_secret` only: + +```json +{ + "default_profile": "default", + "profiles": { + "default": { + "api_key": "ubx_pk_legacy_key", + "api_secret": "ubx_sk_legacy_secret_abcdef" + } + } +} +``` + +**Post-login shape** — plus `session_token` / `active_org` / `active_project`: + +```json +{ + "default_profile": "default", + "profiles": { + "default": { + "api_key": "ubx_pk_legacy_key", + "api_secret": "ubx_sk_legacy_secret_abcdef", + "session_token": "sess_tok_post_login_9988776655", + "active_org": "org_postlogin", + "active_project": "proj_postlogin" + } + } +} +``` + +## PART A — Pre-existing commands, both states + +Every pre-existing command from the brief was run against BOTH config states and +compared byte-for-byte (stdout AND stderr AND exit code). **Any behavioural +difference between the two states for a pre-existing command is a STOP-the-line +bug.** None was found. + +| Command | Exit | stdout parity legacy vs post | Result | +|--------------------------------------------------------------|------|------------------------------|--------| +| `version` | 0 | identical | PASS | +| `commands --output-format json` | 0 | identical | PASS | +| `schema render --output-format json \| head -5` | 0 | identical | PASS | +| `render https://example.com --dry-run --output-format json` | 0 | identical | PASS | +| `screenshot https://example.com --dry-run --output-format json` | 0 | identical | PASS | +| `pdf https://example.com --dry-run --output-format json` | 0 | identical | PASS | +| `render https://example.com --curl` | 0 | identical | PASS | +| `link https://example.com` | 0 | identical | PASS | +| `config path` | 0 | path differs by design* | PASS | +| `config get api_secret` | 0 | identical | PASS | +| `config profile list` | 0 | identical | PASS | +| `doctor` | 3 | identical (config-file path normalized)** | PASS | +| `dashboard --output-format json` | 0 | identical | PASS | + +\* `config path` prints its own `XDG_CONFIG_HOME` path — the only per-state +difference is the path value itself; shape and exit code are identical. + +\*\* `doctor` reaches the real API to validate credentials; both states carry the +same (fake) secret so the `auth` check fails identically (HTTP 400 "Api Key does +not exist", exit 3). After normalizing the state-specific `config_file` path, the +two envelopes are byte-identical. + +### Representative actual output (legacy state) + +`bin/urlbox version` + +```json +{ + "ok": true, + "command": "version", + "data": { "commit": "none", "date": "unknown", "version": "dev" }, + "summary": "urlbox dev (commit: none, built: unknown)" +} +``` + +`bin/urlbox render https://example.com --curl` + +```json +{ + "ok": true, + "command": "render", + "data": { + "curl": "curl -X POST 'https://api.urlbox.com/v1/screenshot' -H 'Authorization: Bearer $URLBOX_API_SECRET' -H 'Content-Type: application/json' -d '{\"url\":\"https://example.com\"}'" + }, + "summary": "Equivalent curl command (no API call made)", + "breadcrumbs": [{ "action": "run", "cmd": "urlbox render " }] +} +``` + +`bin/urlbox config get api_secret` (masked by default): + +```json +{ + "ok": true, + "command": "config get", + "data": { "key": "api_secret", "profile": "default", "value": "ubx_…ef" }, + "summary": "api_secret = \"ubx_…ef\"" +} +``` + +`bin/urlbox config profile list`: + +```json +{ + "ok": true, + "command": "config profile list", + "data": { + "default": "default", + "profiles": [ + { "api_host": "", "is_default": true, "masked_secret": "ubx_…ef", "name": "default" } + ] + }, + "summary": "1 profile(s); default = \"default\"" +} +``` + +`bin/urlbox doctor` (exit 3 — auth check fails on the fake secret; identical +across both states): + +```json +{ + "ok": false, + "command": "doctor", + "data": { + "checks": [ + { "name": "version", "status": "ok", "message": "dev" }, + { "name": "install_method", "status": "warn", "message": "unknown", "hint": "Install via brew, scoop, npm, or curl install.sh for upgrade support" }, + { "name": "config_file", "status": "ok", "message": "/urlbox/config.json" }, + { "name": "api_secret", "status": "ok", "message": "configured (file)" }, + { "name": "dns", "status": "ok", "message": "api.urlbox.com resolves" }, + { "name": "api_reachable", "status": "ok", "message": "HTTP 200 from https://api.urlbox.com" }, + { "name": "auth", "status": "fail", "message": "API returned 400: Api Key does not exist", "hint": "Re-run `urlbox auth --api-secret ` with a valid secret, or check `urlbox config get api_secret --reveal` against the dashboard." } + ], + "status": "fail" + }, + "summary": "Some checks failed — see hints for next steps", + "breadcrumbs": [{ "action": "auth", "cmd": "urlbox auth --api-secret " }] +} +``` + +`bin/urlbox dashboard --output-format json`: + +```json +{ + "ok": true, + "command": "dashboard", + "data": { "url": "https://urlbox.com/dashboard" }, + "summary": "Dashboard URL emitted (no browser launched in machine-readable mode)", + "breadcrumbs": [{ "action": "copy", "cmd": "https://urlbox.com/dashboard" }] +} +``` + +## PART B — New commands, logged-out state + +`login` was interrupted with SIGINT (Ctrl-C) after the code prints; +`whoami` / `usage` / `orgs list` / `projects list` were run against the legacy +(no-session) state. Each session command must produce the `auth` error envelope, +exit 3. + +`bin/urlbox login` (SIGINT after code prints): + +``` +[stderr] +Your code: HTEB4RLW +Open this URL to continue: https://urlbox.com/device?user_code=HTEB4RLW +Waiting for approval… +[stdout] (empty) +``` + +Human messages (code + verification URL + "Waiting…") go to **stderr**; stdout +stays empty. On interrupt the config is left untouched — no `session_token` is +written. Real device flow (hits the live API to mint the device code). + +| Command (logged-out) | Exit | Envelope | Result | +|-------------------------------------|------|----------|--------| +| `whoami --output-format json` | 3 | auth | PASS | +| `usage --output-format json` | 3 | auth | PASS | +| `orgs list --output-format json` | 3 | auth | PASS | +| `projects list --output-format json`| 3 | auth | PASS | + +Actual (identical shape for all four; `command` field differs): + +```json +{ + "ok": false, + "command": "whoami", + "error": "not logged in — run `urlbox login`", + "code": "auth", + "hint": "Run `urlbox login` to sign in via your browser." +} +``` + +Text-mode (`--output-format text`) error path for the same four commands — empty +stdout, `Error:` + `Hint:` to stderr, exit 3: + +``` +[stderr] +Error: not logged in — run `urlbox login` +Hint: Run `urlbox login` to sign in via your browser. +``` + +## PART C — New commands, success layout (mock API, post-login) + +To record the human/text-mode success layout (the envelope text formatter path) +and the JSON data shape, the four session commands were driven against a local +mock API (`URLBOX_API_HOST=http://127.0.0.1:8791`) with a valid `session_token`. +This exercises the success formatter that the logged-out state cannot reach. + +### Text mode (human layout) — the deferred "record text-mode output" item + +``` +### $ bin/urlbox whoami --output-format text +✓ Signed in as dev@example.com — org Acme Inc (exit 0) + +### $ bin/urlbox orgs list --output-format text +✓ 2 organisations — active: Acme Inc (exit 0) + +### $ bin/urlbox projects list --output-format text +✓ 2 projects — active: Website Shots (exit 0) + +### $ bin/urlbox usage --output-format text +✓ Renders used: 1240 / 5000 (exit 0) +``` + +The text formatter renders a single `✓ ` line to stdout on success. + +### JSON data shape (same runs) + +`whoami`: + +```json +{ + "ok": true, + "command": "whoami", + "data": { + "email": "dev@example.com", + "org": { "id": "org_postlogin", "name": "Acme Inc" }, + "project": { "id": "proj_postlogin", "name": "Website Shots" } + }, + "summary": "Signed in as dev@example.com — org Acme Inc" +} +``` + +`orgs list`: + +```json +{ + "ok": true, + "command": "orgs list", + "data": { + "organisations": [ + { "active": true, "id": "org_postlogin", "name": "Acme Inc" }, + { "active": false, "id": "org_globex", "name": "Globex" } + ] + }, + "summary": "2 organisations — active: Acme Inc" +} +``` + +`projects list`: + +```json +{ + "ok": true, + "command": "projects list", + "data": { + "projects": [ + { "active": true, "id": "proj_postlogin", "name": "Website Shots" }, + { "active": false, "id": "proj_other", "name": "Marketing" } + ] + }, + "summary": "2 projects — active: Website Shots" +} +``` + +`usage`: + +```json +{ + "ok": true, + "command": "usage", + "data": { + "current_period_end": "2026-08-31", + "current_period_start": "2026-08-01", + "render_quota": 5000, + "renders_used": 1240 + }, + "summary": "Renders used: 1240 / 5000" +} +``` + +## PART D — New config keys (session_token / active_org / active_project) + +Run against the post-login state. + +| Action | Result | Note | +|---------------------------------------------------|--------|------| +| `config get session_token` | `sess…55` (exit 0) | masked by default (like `api_secret`) | +| `config get session_token --reveal` | `sess_tok_post_login_9988776655` (exit 0) | `--reveal` unhides | +| `config get active_org` | `org_postlogin` (exit 0) | plain string | +| `config get active_project` | `proj_postlogin` (exit 0) | plain string | +| `config set active_project proj_newvalue` | exit 0 | persisted; quiet read returns `"proj_newvalue"` | +| `config set session_token sess_tok_rotated_xyz789`| exit 0 | validated via `ValidateSecretValue`; echo masked to `sess…89`; raw value persisted on disk | +| `config get bogus_key` | exit 1, `usage` | hint lists all seven keys | + +`config get session_token` (masked): + +```json +{ + "ok": true, + "command": "config get", + "data": { "key": "session_token", "profile": "default", "value": "sess…55" }, + "summary": "session_token = \"sess…55\"" +} +``` + +`config get session_token --reveal`: + +```json +{ + "ok": true, + "command": "config get", + "data": { "key": "session_token", "profile": "default", "value": "sess_tok_post_login_9988776655" }, + "summary": "session_token = \"sess_tok_post_login_9988776655\"" +} +``` + +`config set session_token …` — echo masked, raw value persisted: + +```json +{ + "ok": true, + "command": "config set", + "data": { "key": "session_token", "profile": "default", "value": "sess…89" }, + "summary": "session_token set in profile \"default\"", + "breadcrumbs": [{ "action": "verify", "cmd": "urlbox config get session_token" }] +} +``` + +On disk after the set (raw, unmasked — masking is display-only): + +```json +"session_token": "sess_tok_rotated_xyz789", +"active_project": "proj_newvalue" +``` + +`config get bogus_key` — unknown-key error with the updated hint: + +```json +{ + "ok": false, + "command": "config get", + "error": "Unknown config key: bogus_key", + "code": "usage", + "hint": "Supported: api_key, api_secret, api_host, default_profile, session_token, active_org, active_project" +} +``` + +## PART E — Deferred verification: picker draws on stderr + +The interactive picker (`internal/prompt.SelectOne`, huh/bubbletea) was driven +under a real PTY (via `expect`), with the child process's **stdout (fd1) +redirected to a plain file** and its **stderr (fd2) redirected to a separate +file**. stdin stayed the PTY so the `IsTerminal(stdin)` gate passes and +bubbletea initializes. + +Options presented: `["Acme", "Globex", "Initech"]`, active index 1 (Globex). +Enter accepted the active option. + +Result: + +- **fd1 (stdout)** — 14 bytes, exactly `CHOSE_INDEX=1\n`. Zero terminal/UI + escape codes leaked to stdout (verified by hexdump). stdout stayed clean data. +- **fd2 (stderr)** — 503 bytes containing bubbletea's terminal-init sequences + (`ESC[?25l`, `ESC[?2004h`, `ESC[?1004h`) AND the rendered list. ANSI-stripped: + +``` +┃ Choose an org +┃ Acme +┃ > Globex (current) +┃ Initech +``` + +**Conclusion: the picker renders on stderr; stdout carries only the structured +result.** This is guaranteed at the library level — huh's `Form.Run` passes +`tea.WithOutput(os.Stderr)` (huh@v1.0.0 `form.go:112`), and `SelectOne` does not +override it. The `CHOSE_INDEX=1` on stdout also confirms the select completed and +returned the correct index. + +### `ACCESSIBLE=1` and accessible mode (record only — no code change) + +The upstream note is that huh falls back to **stdout** in accessible mode. Two +cases were recorded (same PTY + split-fd setup): + +- **`ACCESSIBLE=1` set:** huh@v1.0.0 does NOT treat `ACCESSIBLE` as the trigger + (that env var was the trigger in older huh releases). The normal interactive + TUI still rendered on **stderr** (fd2 = 503 bytes with the box UI); stdout + stayed clean (`CHOSE_INDEX=1`). No behaviour change vs the default. + +- **`TERM=dumb` (huh@v1.0.0's actual accessible trigger, `form.go:124`):** the + plain numbered accessible prompt rendered on **stdout** (fd1 = 101 bytes): + + ``` + Choose an org + 1. Acme + 2. Globex (current) + 3. Initech + Enter a number between 1 and 3: + ``` + + fd2 (stderr) was empty. This is the documented accessible-mode fallback: + in accessible mode huh writes to `cmp.Or(f.output, os.Stdout)` (`form.go:672`) + and `SelectOne` leaves `f.output` unset, so it lands on stdout. **Recorded per + the brief; no code change made.** Accessible mode is opt-in and only reached + under a `TERM=dumb` terminal, which is not the default agent/CI path. + +## Summary + +- Pre-existing commands: **identical across both config states** (no + STOP-the-line behavioural difference). PASS on every cell. +- New session commands logged-out: **auth envelope, exit 3** on all four. PASS. +- `login`: prints code + URL to stderr, waits, interrupt leaves config + untouched. PASS. +- New session commands success layout (text + JSON) recorded via mock API. +- New config keys: get/set/mask/reveal/validate all correct; unknown-key hint + updated. PASS. +- Picker: **renders on stderr, stdout stays clean data**. Accessible-mode + fallback to stdout recorded (TERM=dumb), no code change. diff --git a/docs/superpowers/verification/2026-08-14-text-surface-audit.md b/docs/superpowers/verification/2026-08-14-text-surface-audit.md new file mode 100644 index 0000000..3b3509f --- /dev/null +++ b/docs/superpowers/verification/2026-08-14-text-surface-audit.md @@ -0,0 +1,42 @@ +# Text-surface consistency audit — 2026-08-14 + +Rule being audited: lists render tables; details render KV; mutations render a +summary naming what the user named, plus KV of what changed. Every row driven +live against production on the current branch build. + +## Already consistent (no change) + +| Command | Text output today | +|---|---| +| `version`, `config get/set/path` | scalar one-liners — correct for scalar commands | +| `whoami`, `usage`, `login` | summary + KV block | +| `orgs list`, `projects list` | summary + table with `●` active marker | +| `doctor` | summary (`✗` on failure) + per-check table with hints | +| `link` | summary + KV incl. full signed URL | +| `logout` | summary — nothing else to show | + +## Bugs found during audit (fix already dispatched, one commit in flight) + +| Site | Defect | +|---|---| +| `projects show` | KV never renders: reads response through a nonexistent nested `"project"` key; summary shows raw id; `webhookKey` unmasked → KV + name summary + masked key + `--reveal` | +| `projects defaults show` | reports `0 default options` when defaults EXIST — same wrong nested read. Write path verified fine in production (`defaultOptions` persists) | +| `projects defaults set --merge` | same nested read → merge silently becomes overwrite | + +## Deviations for approval (D1–D6) + +| # | Command(s) | Today | Proposed target | +|---|---|---|---| +| D1 | `orgs select`, `projects select` | summary only | summary + KV of the new context (org, project, render-credential status) | +| D2 | `projects create` | `Created project audit-tmp` | + KV (name, id) so the id is visible without a follow-up call | +| D3 | `rename`/`enable`/`disable`/`delete`/`defaults set/remove` | summaries print the raw id (`Disabled proj_paygm8iqos`) though the user typed a name | summaries name the project (`Disabled audit-tmp2 (proj_…)`) | +| D4 | `defaults set` | `Set 1 default options` | grammar: `1 default option` / `N default options`; plus KV of the resulting defaults | +| D5 | `render`/`screenshot`/`pdf`/`video --dry-run` (shipped surface) | summary only; validated payload invisible in text | + KV of the validated options | +| D6 | `schema render` (shipped surface) | prints a title, no schema | print the schema body in text mode | + +Out of scope, unchanged: `commands`, `skill`, `status`, `dashboard`, `upgrade`, +`auth` (bespoke outputs, working; `auth` dies in Plan 2 anyway). + +Plan 2 rule: every new command (storage/proxies/llm) ships against the +consistency rule above from day one — lists=table, show=KV+masking, mutations= +named summary + changed-state KV. diff --git a/docs/superpowers/verification/2026-08-18-plan2-agent-layer.md b/docs/superpowers/verification/2026-08-18-plan2-agent-layer.md new file mode 100644 index 0000000..a5114c7 --- /dev/null +++ b/docs/superpowers/verification/2026-08-18-plan2-agent-layer.md @@ -0,0 +1,226 @@ +# Plan 2 — Agent-layer verification (2026-08-18) + +Agent-layer verification for Plan 2 (org-owned credential resources + +`urlbox auth` removal): the storage / proxies / llm command groups, the +`projects assign|unassign` sub-commands, doctor's new session-world +checks, and the auth-command removal. This doc is the input to the human-layer +manual checklist that gates the Plan 2 release. + +## Environment + +- Branch: `feat/account-management` (tip `963f807`, plus this task's test/doc + changes in the working tree). +- Binary under test: `bin/urlbox` built from this tree via `make build` + (`go build ... -o bin/urlbox ./cmd/urlbox`), go1.24 darwin/arm64. +- Gates: `make ci` green (fmt-check, lint 0 issues, `go test -race -cover + ./...`, build, surface-check) and `make surface-snapshot` produced **no** + `SURFACE.txt` diff — Plan 2's commands were snapshotted by their own tasks; + Task 8 adds no new commands or flags. +- The machine carries a real logged-in session (`arnold@urlbox.com`, org + `AJ's Org` / `org_u6jgi2c27o`, project `Default` / `proj_2j6ehq29fy`), used + for the read-only and gate-behaviour drives against production. **No + credentials were created, updated, assigned, or deleted against + production** — every destructive/creating verb was driven only to its + client-side gate (auth gate, usage gate, resolution failure, or the + non-interactive confirmation refusal), which fires before any write leaves + the CLI. + +## Two config states + +The logged-out drives use a scratch `XDG_CONFIG_HOME` holding the pre-login +(legacy) shape — `api_key` + `api_secret` only, with a deliberately fake +secret: + +```json +{ + "default_profile": "default", + "profiles": { + "default": { + "api_key": "pk_test_key", + "api_secret": "ubx_sk_fake000000000000000000000000" + } + } +} +``` + +The logged-in drives use the machine's real config (session present). + +## PART A — Plan-1 regression, logged out (text mode) + +Every Plan-1 command re-driven against the legacy config to confirm Plan 2 did +not disturb them. `render`/`link`/`status` are session-independent (they use +`api_secret`); the account commands correctly hit the auth gate. + +| Command | Exit | Output shape | Result | +|---------|------|--------------|--------| +| `version` | 0 | version envelope | PASS | +| `render https://example.com --dry-run --output-format text` | 0 | `✓ Dry run: payload validated, no API call made` | PASS | +| `screenshot https://example.com --dry-run --output-format text` | 0 | same dry-run line | PASS | +| `pdf https://example.com --dry-run --output-format text` | 0 | same dry-run line | PASS | +| `link https://example.com --output-format text` | 0 | boxed URL/FORMAT/KEY table | PASS | +| `config path --output-format text` | 0 | `✓ Config file path: …/lo/urlbox/config.json` | PASS | +| `whoami --output-format text` | 3 | `Error: not logged in — run \`urlbox login\`` | PASS | +| `orgs list --output-format text` | 3 | same not-logged-in error | PASS | +| `projects list --output-format text` | 3 | same not-logged-in error | PASS | +| `status render_abc123 --no-retry --output-format text` | 5 | `Error: Render render_abc123 not found` (real 404) | PASS | + +## PART B — New groups, logged-out guard behaviour (text mode) + +Every new command driven against the legacy config. All 21 return the unified +not-logged-in error and `auth` / exit 3 — the auth gate fires before any org +resolution or network call, so a logged-out caller can never leak a request. + +| Command | Exit | Result | +|---------|------|--------| +| `storage list` | 3 | PASS | +| `proxies list` | 3 | PASS | +| `llm list` | 3 | PASS | +| `storage show foo` | 3 | PASS | +| `proxies show foo` | 3 | PASS | +| `llm show foo` | 3 | PASS | +| `storage create --name x --provider aws_s3 --bucket b --region r --key k --secret s` | 3 | PASS | +| `proxies create --name x --url http://u:p@h:8080` | 3 | PASS | +| `llm create --name x --provider openai --api-key sk-fake` | 3 | PASS | +| `storage update foo --region r2` | 3 | PASS | +| `proxies update foo --name y` | 3 | PASS | +| `llm update foo --model gpt-5` | 3 | PASS | +| `storage delete foo --yes` | 3 | PASS | +| `proxies delete foo --yes` | 3 | PASS | +| `llm delete foo --yes` | 3 | PASS | +| `llm test foo` | 3 | PASS | +| `llm models foo` | 3 | PASS | +| `projects storage assign proj cred` | 3 | PASS | +| `projects proxy assign proj cred` | 3 | PASS | +| `projects llm assign proj cred` | 3 | PASS | +| `projects storage unassign proj` | 3 | PASS | + +Representative output (identical shape for all 21): + +``` +$ urlbox storage create --name x --provider aws_s3 --bucket b --region r --key k --secret s --output-format text +Error: not logged in — run `urlbox login` +Hint: Run `urlbox login` to sign in. +[exit=3] +``` + +## PART C — New groups, `--help` surfaces + +`--help` printed for each group and each `projects ` sub-group; every +verb is listed with its short description, and the group long-help carries the +"owned by the organisation, assigned to projects" framing and the +`--reveal` note. + +| `--help` target | Verbs listed | Result | +|-----------------|--------------|--------| +| `storage` | create, delete, list, show, update | PASS | +| `proxies` (alias `proxy`) | create, delete, list, show, update | PASS | +| `llm` | create, delete, list, models, show, test, update | PASS | +| `projects storage` | assign, unassign | PASS | +| `projects proxy` | assign, unassign | PASS | +| `projects llm` | assign, unassign | PASS | + +## PART D — New groups, gate behaviour against the live session + +Driven with the machine's real session. Read-only lists are safe; the +write/resolve/confirm paths were driven only to their client-side gate. + +### D.1 Read-only lists (safe, no writes) + +The org has no credentials of any kind, so each list renders an empty table, +exit 0 — proving the authenticated read path reaches production cleanly. + +| Command | Exit | Output | Result | +|---------|------|--------|--------| +| `storage list --output-format text` | 0 | empty `BUCKET/ID/PROVIDER/ENDPOINT/KEY/ASSIGNED` table | PASS | +| `proxies list --output-format text` | 0 | empty `ID/NAME/URLS/ASSIGNED` table | PASS | +| `llm list --output-format text` | 0 | empty `ID/NAME/PROVIDER/MODEL/ASSIGNED` table | PASS | + +### D.2 Non-interactive delete confirmation gate + +Driven with a `store_`/`pool_`/`llm_`-prefixed id (resolves without a list +lookup) and non-TTY stdin (` --help` for usage, or `urlbox commands` for the full surface. +[exit=1] +``` + +| Command | Exit | Result | +|---------|------|--------| +| `auth` (logged in) | 1 | PASS | +| `auth` (logged out) | 1 | PASS | + +## Result + +All drives PASS. No defect found; no source change made. The full lifecycle of +each credential group (create → assign → update → delete against production, +plus `llm test`/`models` against a real key) is handed to the human-layer +manual checklist, which owns the billable-safe production writes. diff --git a/docs/superpowers/verification/manual-checklist.md b/docs/superpowers/verification/manual-checklist.md new file mode 100644 index 0000000..dd36f2f --- /dev/null +++ b/docs/superpowers/verification/manual-checklist.md @@ -0,0 +1,184 @@ +# Combined manual pass — Plans 1+2. Comments = expected outcome. One billable step, marked. + +cp ~/.config/urlbox/config.json ~/.config/urlbox/config.json.bak + +# ── logged-out guards ── +urlbox logout +# ok even if already logged out +urlbox whoami +# not logged in — run `urlbox login`, exit 3 +urlbox usage +# exit 3 +urlbox orgs list +# exit 3 +urlbox storage list +# exit 3 +urlbox proxies list +# exit 3 +urlbox llm list +# exit 3 +urlbox auth +# MUST FAIL with unknown command — proves the removed auth command is really gone +urlbox doctor +# table: session/active_org/active_project/render_credential rows FAIL with login hints, summary ✗ + +# ── login ── +urlbox login +# browser opens, code shown, org/project pickers if several; ends: email + org + project + render credential ready/issued +ls -la ~/.config/urlbox/config.json +# -rw------- (0600) +cat ~/.config/urlbox/config.json +# has session_token, active_org (org_…), active_project (proj_…), api_key AND api_secret +urlbox whoami +# boxed KV: SIGNED IN / ORG / PROJECT +urlbox me +# same (alias) +urlbox usage +# boxed KV: renders used / quota / period +urlbox orgs list +# table, ● on active row +urlbox projects list +# table, ● on active row +urlbox doctor +# all rows ✓ (install_method may warn) + +# ── rendering funded by login ── +urlbox screenshot https://example.com --dry-run +# ✓ payload validated, no API call +urlbox screenshot https://example.com --output check.png +# BILLABLE (1 render) — writes ./check.png (output is sandboxed to cwd by design), opens, is example.com +urlbox link https://example.com --format png +# KV incl. full signed URL +curl -sI '' | head -1 +# NOT 401/403 (pair matches) + +# ── config keys ── +urlbox config get session_token +# masked (abcd…xy) +urlbox config get session_token --reveal +# full token +urlbox config get active_org +# org_… unmasked +urlbox config get active_project +# proj_… unmasked + +# ── projects CRUD ── +urlbox projects create tmp-check +# created, proj_… id shown in JSON; TTY asks "Switch to this project?" — answer n +urlbox projects show tmp-check +# boxed KV: NAME/ID/ENABLED/ENGINE/WEBHOOK KEY (masked)/CREATED +urlbox projects show tmp-check --reveal +# webhook key in full +urlbox projects rename tmp-check tmp-check2 +# renamed +urlbox projects disable tmp-check2 +# asks "Disable project tmp-check2?" — answer n → stays enabled, says so +urlbox projects disable tmp-check2 +# answer y → disabled +urlbox projects enable tmp-check2 +# enabled, no gate +urlbox projects defaults set tmp-check2 --json '{"width":1280}' +# set +urlbox projects defaults show tmp-check2 +# width 1280 +urlbox projects defaults set tmp-check2 --json '{"full_page":true}' --merge +# merged +urlbox projects defaults show tmp-check2 +# width 1280 AND full_page true +urlbox projects defaults remove tmp-check2 --yes +# removed +urlbox projects delete tmp-check2 +# retype prompt — type it WRONG once → refuses; run again, type correctly → deleted + +# ── create --select + delete-active ── +urlbox projects create tmp-sel --select +# Created project tmp-sel (now active) +urlbox whoami +# PROJECT = tmp-sel +urlbox projects delete tmp-sel --yes +# Deleted … (was your active project). Several projects remain, so the TTY shows a picker seeded at the first. +# Pick one → stderr: Now active: , api_secret refreshed. Skip → stderr: Select one with `urlbox projects select`. +urlbox whoami +# picked → PROJECT = ; skipped → PROJECT = (none) +urlbox projects select Default +# active again; api_secret refreshed + +# ── storage (fake values; writes to prod org, cleaned below) ── +urlbox storage list +# table: BUCKET/ID/PROVIDER/ENDPOINT/KEY/ASSIGNED (or empty) +urlbox storage create --name fake-s3 --provider aws_s3 --bucket fake-bucket --region us-east-1 --key FAKEKEY --secret FAKESECRET +# created store_…; TTY asks assign-to-project — skip +urlbox storage show fake-s3 +# boxed KV, KEY/SECRET masked +urlbox storage show fake-s3 --reveal +# secrets in full +urlbox storage update fake-s3 --region eu-west-1 +# updated (partial patch) +urlbox projects storage assign Default fake-s3 +# Assigned fake-s3 to Default +urlbox projects storage unassign Default +# Unassigned the storage credential from Default +urlbox storage delete fake-s3 --yes +# deleted + +# ── proxies (fake values) ── +urlbox proxies list +# table: ID/NAME/URLS(count)/ASSIGNED +urlbox proxies create --name fake-pool --url 'http://user:hunter2@127.0.0.1:9999' +# created pool_…; skip assign +urlbox proxies show fake-pool +# URL shows http://user:****@127.0.0.1:9999 — hunter2 NOT visible +urlbox proxies show fake-pool --reveal +# hunter2 visible +urlbox proxies update fake-pool --url 'http://user:hunter2@127.0.0.1:9998' +# whole URL list replaced (help text warns about this) +urlbox projects proxy assign Default fake-pool +# assigned +urlbox projects proxy unassign Default +# unassigned +urlbox proxies delete fake-pool --yes +# deleted + +# ── llm (fake key) ── +urlbox llm list +# table: ID/NAME/PROVIDER/MODEL/ASSIGNED +urlbox llm create --name fake-llm --provider openai --api-key sk-fake123 +# created llm_…; skip assign +urlbox llm show fake-llm +# boxed KV, apiKey masked +urlbox llm show fake-llm --reveal +# sk-fake123 visible +urlbox llm update fake-llm --model gpt-5-mini +# updated; provider NOT changeable +urlbox llm test fake-llm +# Connection failed (fake key — expected), exit 1 +urlbox llm models fake-llm +# fails (fake key — expected) +urlbox projects llm assign Default fake-llm +# assigned +urlbox projects llm unassign Default +# unassigned +urlbox llm delete fake-llm --yes +# deleted + +# ── agent / non-TTY safety ── +echo | urlbox orgs select +# instant clean error naming , no hang +echo | urlbox projects delete Default +# refuses (needs confirmation / --yes) — does NOT delete, no API call +urlbox whoami --output-format json +# envelope with email/org/project +urlbox whoami --jq .data.email +# bare email +urlbox storage list --output-format json +# raw envelope + +# ── logout / re-login ── +urlbox logout +# session revoked; config cleared of session_token/active_org/active_project/api_key/api_secret +urlbox whoami +# exit 3 +urlbox login +# second login works end to end + +# done — optionally restore: cp ~/.config/urlbox/config.json.bak ~/.config/urlbox/config.json diff --git a/go.mod b/go.mod index 6420852..97b4f6d 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/urlbox/urlbox-cli go 1.24.0 require ( + github.com/charmbracelet/huh v1.0.0 github.com/charmbracelet/lipgloss v1.1.0 github.com/charmbracelet/log v1.0.0 github.com/itchyny/gojq v0.12.19 @@ -15,21 +16,33 @@ require ( ) require ( + github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/catppuccin/go v0.3.0 // indirect + github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect + github.com/charmbracelet/bubbletea v1.3.6 // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect - github.com/charmbracelet/x/ansi v0.8.0 // indirect - github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/ansi v0.9.3 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13 // indirect + github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect github.com/charmbracelet/x/term v0.2.1 // indirect github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/uax29/v2 v2.3.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/go-logfmt/logfmt v0.6.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/itchyny/timefmt-go v0.1.8 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect + golang.org/x/sync v0.16.0 // indirect golang.org/x/sys v0.38.0 // indirect golang.org/x/text v0.27.0 // indirect ) diff --git a/go.sum b/go.sum index 4343970..0c90112 100644 --- a/go.sum +++ b/go.sum @@ -1,26 +1,58 @@ +github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= +github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= +github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= +github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= +github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws= +github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw= +github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU= +github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc= github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/huh v1.0.0 h1:wOnedH8G4qzJbmhftTqrpppyqHakl/zbbNdXIWJyIxw= +github.com/charmbracelet/huh v1.0.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= github.com/charmbracelet/log v1.0.0 h1:HVVVMmfOorfj3BA9i8X8UL69Hoz9lI0PYwXfJvOdRc4= github.com/charmbracelet/log v1.0.0/go.mod h1:uYgY3SmLpwJWxmlrPwXvzVYujxis1vAKRV/0VQB7yWA= -github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= -github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= -github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= -github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0= +github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= +github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U= +github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI= +github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4= github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4= github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/go-logfmt/logfmt v0.6.1 h1:4hvbpePJKnIzH1B+8OR/JPbTx37NktoI9LE2QZBBkvE= github.com/go-logfmt/logfmt v0.6.1/go.mod h1:EV2pOAQoZaT1ZXZbqDl5hrymndi4SY9ED9/z6CO0XAk= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= @@ -35,8 +67,16 @@ github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69 github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= +github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -56,6 +96,9 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavM github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= diff --git a/internal/api/http_client.go b/internal/api/http_client.go index 68460aa..b847370 100644 --- a/internal/api/http_client.go +++ b/internal/api/http_client.go @@ -189,7 +189,7 @@ func mapStatusToCLIError(resp *http.Response, body []byte) *output.CLIError { msg = "API rejected the request: not authenticated" } return output.NewCLIError(output.ErrAuth, msg, - "Run `urlbox auth --api-secret ` to set or update your API secret.") + "Run `urlbox login` to refresh your credentials, or set URLBOX_API_SECRET.") case resp.StatusCode == http.StatusForbidden: msg := apiMsg if msg == "" { diff --git a/internal/api/http_client_test.go b/internal/api/http_client_test.go index 030c1ce..990921d 100644 --- a/internal/api/http_client_test.go +++ b/internal/api/http_client_test.go @@ -167,8 +167,8 @@ func TestHTTPClient_Render_401_MapsToAuth(t *testing.T) { if cli.Code != output.ErrAuth { t.Errorf("Code=%q, want %q", cli.Code, output.ErrAuth) } - if !strings.Contains(cli.Hint, "urlbox auth") { - t.Errorf("Hint=%q, want a pointer to `urlbox auth`", cli.Hint) + if !strings.Contains(cli.Hint, "urlbox login") { + t.Errorf("Hint=%q, want a pointer to `urlbox login`", cli.Hint) } } @@ -320,8 +320,8 @@ func TestHTTPClient_Render_400_ApiKeyNotFound_MapsToAuth(t *testing.T) { if !strings.Contains(cli.Message, "Api Key does not exist") { t.Errorf("Message=%q should lift the nested error.message", cli.Message) } - if !strings.Contains(cli.Hint, "urlbox auth") { - t.Errorf("Hint=%q, want pointer to `urlbox auth`", cli.Hint) + if !strings.Contains(cli.Hint, "urlbox login") { + t.Errorf("Hint=%q, want pointer to `urlbox login`", cli.Hint) } } diff --git a/internal/api/session_client.go b/internal/api/session_client.go new file mode 100644 index 0000000..3fc302f --- /dev/null +++ b/internal/api/session_client.go @@ -0,0 +1,184 @@ +package api + +import ( + "bytes" + "context" + "crypto/tls" + "encoding/json" + "errors" + "io" + "net/http" + "strings" + "time" + + "github.com/urlbox/urlbox-cli/internal/output" + "github.com/urlbox/urlbox-cli/internal/version" +) + +// SessionAPI is the session-authenticated request surface every +// account-management command depends on, so commands and their tests can +// target this interface rather than the concrete client. +type SessionAPI interface { + GetJSON(ctx context.Context, path string, out any) error + PostJSON(ctx context.Context, path string, body, out any) error + PatchJSON(ctx context.Context, path string, body, out any) error + PutJSON(ctx context.Context, path string, body, out any) error + DeleteJSON(ctx context.Context, path string, out any) error +} + +// SessionClient talks to the Urlbox API using a session bearer token rather +// than an API secret. It rides the shared request path (RetryDo, error +// mapping, user-agent); the only differences are the credential and the 401 +// mapping (auth + "Run `urlbox login`" hint). +type SessionClient struct { + baseURL string + token string + userAgent string + timeout time.Duration + retry RetryConfig + http *http.Client +} + +var _ SessionAPI = (*SessionClient)(nil) + +// NewSessionClient constructs a SessionClient for baseURL authenticated with +// the given session token. baseURL must be non-empty. +func NewSessionClient(baseURL, token string) *SessionClient { + timeout := 30 * time.Second + return &SessionClient{ + baseURL: baseURL, + token: token, + userAgent: BuildUserAgent(version.Version), + timeout: timeout, + retry: DefaultRetryConfig(), + http: &http.Client{ + Timeout: timeout, + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12}, + }, + }, + } +} + +// SetRetryConfig overrides the client's retry policy. Used by the central +// session-client builder to thread the --no-retry / --max-retries flags in; +// NewSessionClient keeps DefaultRetryConfig() for callers that don't set one. +func (c *SessionClient) SetRetryConfig(cfg RetryConfig) { + c.retry = cfg +} + +// GetJSON performs a GET and decodes the response body into out (may be nil). +func (c *SessionClient) GetJSON(ctx context.Context, path string, out any) error { + return c.doJSON(ctx, http.MethodGet, path, nil, out) +} + +// PostJSON performs a POST with a JSON body and decodes the response into out. +func (c *SessionClient) PostJSON(ctx context.Context, path string, body, out any) error { + if body == nil { + body = map[string]string{} + } + return c.doJSON(ctx, http.MethodPost, path, body, out) +} + +// PatchJSON performs a PATCH with a JSON body and decodes the response into out. +func (c *SessionClient) PatchJSON(ctx context.Context, path string, body, out any) error { + return c.doJSON(ctx, http.MethodPatch, path, body, out) +} + +// PutJSON performs a PUT with a JSON body and decodes the response into out. +func (c *SessionClient) PutJSON(ctx context.Context, path string, body, out any) error { + return c.doJSON(ctx, http.MethodPut, path, body, out) +} + +// DeleteJSON performs a DELETE and decodes the response body into out (may be nil). +func (c *SessionClient) DeleteJSON(ctx context.Context, path string, out any) error { + return c.doJSON(ctx, http.MethodDelete, path, nil, out) +} + +func (c *SessionClient) doJSON(ctx context.Context, method, path string, body, out any) error { + resp, respBody, err := c.send(ctx, method, path, body) //nolint:bodyclose // send closes the body before returning + if err != nil { + return err + } + if resp.StatusCode >= 400 { + cli := mapStatusToCLIError(resp, respBody) + if cli.Code == output.ErrAuth { + return output.NewCLIError( + output.ErrAuth, + cli.Message, + "Run `urlbox login` to sign in.", + ) + } + return cli + } + if out == nil || len(respBody) == 0 { + return nil + } + if err := json.Unmarshal(respBody, out); err != nil { + return output.NewCLIError(output.ErrServer, "failed to parse API response", err.Error()) + } + return nil +} + +// DoRaw sends a request and returns the status and parsed JSON body without +// mapping non-2xx responses to CLI errors. The device-poll loop reads RFC +// error strings (e.g. authorization_pending) straight from the raw body. +func (c *SessionClient) DoRaw(ctx context.Context, method, path string, body any) (status int, data map[string]any, err error) { + resp, respBody, sendErr := c.send(ctx, method, path, body) //nolint:bodyclose // send closes the body before returning + if sendErr != nil { + return 0, nil, sendErr + } + data = map[string]any{} + if len(respBody) > 0 { + if jerr := json.Unmarshal(respBody, &data); jerr != nil { + return resp.StatusCode, map[string]any{}, nil + } + } + return resp.StatusCode, data, nil +} + +func (c *SessionClient) send(ctx context.Context, method, path string, body any) (*http.Response, []byte, error) { + var bodyBytes []byte + if body != nil { + b, err := json.Marshal(body) + if err != nil { + return nil, nil, output.NewCLIError(output.ErrUsage, "failed to encode request body", err.Error()) + } + bodyBytes = b + } + send := func() (*http.Response, error) { + var reader io.Reader + if bodyBytes != nil { + reader = bytes.NewReader(bodyBytes) + } + req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, reader) + if err != nil { + return nil, err + } + if c.token != "" { + req.Header.Set("Authorization", "Bearer "+c.token) + } + if bodyBytes != nil { + req.Header.Set("Content-Type", "application/json") + } + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", c.userAgent) + return c.http.Do(req) + } + resp, err := RetryDo(ctx, c.retry, send) + if err != nil { + code := output.ErrNetwork + if errors.Is(err, context.DeadlineExceeded) || + strings.Contains(err.Error(), context.DeadlineExceeded.Error()) { + code = output.ErrTimeout + } + return nil, nil, output.NewCLIError(code, err.Error(), + "Check your internet connection and the API host (URLBOX_API_HOST).") + } + defer func() { _ = resp.Body.Close() }() + respBody, readErr := io.ReadAll(resp.Body) + if readErr != nil { + return nil, nil, output.NewCLIError(output.ErrNetwork, readErr.Error(), "Check your internet connection.") + } + return resp, respBody, nil +} diff --git a/internal/api/session_client_test.go b/internal/api/session_client_test.go new file mode 100644 index 0000000..d5ab997 --- /dev/null +++ b/internal/api/session_client_test.go @@ -0,0 +1,104 @@ +package api + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/urlbox/urlbox-cli/internal/api/apitest" + "github.com/urlbox/urlbox-cli/internal/output" +) + +func TestSessionClientSendsBearerTokenAndUserAgent(t *testing.T) { + srv := apitest.New(apitest.SuccessJSON(`{"ok":true}`)) + t.Cleanup(srv.Close) + c := NewSessionClient(srv.URL(), "sess_tok_123") + var out map[string]any + if err := c.GetJSON(context.Background(), "/v1/auth/get-session", &out); err != nil { + t.Fatalf("get: %v", err) + } + reqs := srv.Requests() + if len(reqs) != 1 { + t.Fatalf("want 1 request, got %d", len(reqs)) + } + if got := reqs[0].Header.Get("Authorization"); got != "Bearer sess_tok_123" { + t.Fatalf("auth header = %q", got) + } + if got := reqs[0].Header.Get("User-Agent"); got == "" { + t.Fatal("missing User-Agent") + } + if reqs[0].Path != "/v1/auth/get-session" { + t.Fatalf("path = %q", reqs[0].Path) + } +} + +func Test401MapsToAuthWithLoginHint(t *testing.T) { + srv := apitest.New(apitest.ScriptedResponse{Status: 401, Body: `{"error":{"message":"unauthorized"}}`}) + t.Cleanup(srv.Close) + c := NewSessionClient(srv.URL(), "sess_expired") + err := c.GetJSON(context.Background(), "/v2/usage", nil) + var cli *output.CLIError + if !errors.As(err, &cli) { + t.Fatalf("want CLIError, got %T %v", err, err) + } + if cli.Code != output.ErrAuth { + t.Fatalf("code = %q, want auth", cli.Code) + } + if cli.Hint == "" || cli.Hint != "Run `urlbox login` to sign in." { + t.Fatalf("hint = %q", cli.Hint) + } +} + +func TestDoRawReturnsBodyWithoutMapping(t *testing.T) { + srv := apitest.New(apitest.ScriptedResponse{Status: 400, Body: `{"error":"authorization_pending"}`}) + t.Cleanup(srv.Close) + c := NewSessionClient(srv.URL(), "") + status, data, err := c.DoRaw(context.Background(), "POST", "/v1/auth/device/token", map[string]string{"a": "b"}) + if err != nil { + t.Fatalf("DoRaw transport error: %v", err) + } + if status != 400 { + t.Fatalf("status = %d", status) + } + if data["error"] != "authorization_pending" { + t.Fatalf("data = %#v", data) + } +} + +func TestSessionClientPutJSONSendsBearerAndBody(t *testing.T) { + srv := apitest.New(apitest.SuccessJSON(`{"ok":true}`)) + t.Cleanup(srv.Close) + c := NewSessionClient(srv.URL(), "sess_tok") + var out map[string]any + if err := c.PutJSON(context.Background(), "/v2/organisation/org_1/projects/proj_1/proxy", map[string]string{"proxyId": "pool_1"}, &out); err != nil { + t.Fatalf("unexpected error: %v", err) + } + reqs := srv.Requests() + if len(reqs) != 1 { + t.Fatalf("want 1 request, got %d", len(reqs)) + } + req := reqs[0] + if req.Method != "PUT" || req.Path != "/v2/organisation/org_1/projects/proj_1/proxy" { + t.Fatalf("unexpected request: %s %s", req.Method, req.Path) + } + if got := req.Header.Get("Authorization"); got != "Bearer sess_tok" { + t.Fatalf("auth header = %q", got) + } + if !strings.Contains(string(req.Body), `"proxyId":"pool_1"`) { + t.Fatalf("body missing proxyId: %s", req.Body) + } +} + +func TestSessionClientRetries429(t *testing.T) { + srv := apitest.New(apitest.RetryAfterSeconds(0), apitest.SuccessJSON(`{"fine":true}`)) + t.Cleanup(srv.Close) + c := NewSessionClient(srv.URL(), "tok") + var out map[string]any + if err := c.GetJSON(context.Background(), "/v2/projects", &out); err != nil { + t.Fatalf("expected retry to succeed: %v", err) + } + if len(srv.Requests()) != 2 { + t.Fatalf("want 2 requests (retry), got %d", len(srv.Requests())) + } +} diff --git a/internal/api/smoke_test.go b/internal/api/smoke_test.go index 9625459..c726296 100644 --- a/internal/api/smoke_test.go +++ b/internal/api/smoke_test.go @@ -129,8 +129,8 @@ func TestSmoke_AuthFails_BadSecret(t *testing.T) { if cli.Code != output.ErrAuth { t.Errorf("Code=%q, want %q (bad secret should map to auth regardless of HTTP status)", cli.Code, output.ErrAuth) } - if !strings.Contains(cli.Hint, "urlbox auth") { - t.Errorf("Hint=%q, want pointer to `urlbox auth`", cli.Hint) + if !strings.Contains(cli.Hint, "urlbox login") { + t.Errorf("Hint=%q, want pointer to `urlbox login`", cli.Hint) } } diff --git a/internal/cmd/auth.go b/internal/cmd/auth.go deleted file mode 100644 index ec23bf1..0000000 --- a/internal/cmd/auth.go +++ /dev/null @@ -1,318 +0,0 @@ -package cmd - -import ( - "errors" - "fmt" - "io" - "os" - "strings" - - "github.com/spf13/cobra" - "golang.org/x/term" - - "github.com/urlbox/urlbox-cli/internal/config" - "github.com/urlbox/urlbox-cli/internal/output" -) - -// stdinTTYOverride forces the TTY result used by auth's interactive gate (test helper). -// Nil = real detection. -var stdinTTYOverride *bool - -// SetStdinTTYForTest forces stdin TTY detection for tests. -func SetStdinTTYForTest(v bool) { stdinTTYOverride = &v } - -// ResetStdinTTYForTest clears the stdin override. -func ResetStdinTTYForTest() { stdinTTYOverride = nil } - -func isStdinTTY(r io.Reader) bool { - if stdinTTYOverride != nil { - return *stdinTTYOverride - } - if f, ok := r.(*os.File); ok { - return term.IsTerminal(int(f.Fd())) //nolint:gosec // file descriptors fit in int on every platform Go supports - } - return false -} - -// AuthSecretReader reads one secret from the user with masked echo. -// Returns the typed value (no trailing newline) and any read error. -type AuthSecretReader func() (string, error) - -var authSecretReader AuthSecretReader = defaultAuthSecretReader - -// SetAuthSecretReaderForTest injects a stub secret reader. -func SetAuthSecretReaderForTest(f AuthSecretReader) { authSecretReader = f } - -// ResetAuthSecretReaderForTest restores the real masked-prompt reader. -func ResetAuthSecretReaderForTest() { authSecretReader = defaultAuthSecretReader } - -// defaultAuthSecretReader reads stdin with terminal echo disabled. Caller is -// responsible for printing the prompt label BEFORE the read AND for printing -// the trailing newline AFTER the read on the cobra writer (so this stays a -// pure I/O primitive that test stubs can replace, and so the newline -// participates in cobra writer plumbing). -func defaultAuthSecretReader() (string, error) { - b, err := term.ReadPassword(int(os.Stdin.Fd())) //nolint:gosec // file descriptors fit in int on every platform Go supports - if err != nil { - return "", err - } - return string(b), nil -} - -// AuthConfirmReader reads one line of plain text from the user — used for -// y/N confirmation prompts (overwrite guard). Echoes input; not for -// secrets. -type AuthConfirmReader func() (string, error) - -var authConfirmReader AuthConfirmReader = defaultAuthConfirmReader - -// SetAuthConfirmReaderForTest injects a stub confirm reader. -func SetAuthConfirmReaderForTest(f AuthConfirmReader) { authConfirmReader = f } - -// ResetAuthConfirmReaderForTest restores the default reader. -func ResetAuthConfirmReaderForTest() { authConfirmReader = defaultAuthConfirmReader } - -func defaultAuthConfirmReader() (string, error) { - var line string - _, err := fmt.Fscanln(os.Stdin, &line) - return line, err -} - -func newAuthCmd() *cobra.Command { - var apiSecret, apiSecretFile string - var apiSecretStdin, force bool - c := &cobra.Command{ - Use: "auth", - Short: "Configure API credentials", - Long: `Save your Urlbox API secret to the local config file. - -Find your API secret in your project's settings on the dashboard: - https://urlbox.com/dashboard/projects (open your project → API Secret) - -Non-interactive (preferred for agents and CI): - printf %s "$URLBOX_API_SECRET" | urlbox auth --api-secret-stdin - urlbox auth --api-secret-file ~/.config/urlbox/secret - urlbox auth --api-secret # least safe: visible in ps + shell history - -Interactive (humans, on a TTY): - urlbox auth # prompts once for the secret with masked echo - -The env var URLBOX_API_SECRET takes precedence at runtime over the saved value. - -Profile selection: --profile writes to ; URLBOX_PROFILE= -writes to that name; otherwise auth writes to the configured -default_profile (creating "default" if no profiles exist). - -The per-repo overlay (.urlbox/config.json with a "profile" field) is -DELIBERATELY IGNORED by auth. Overlay is a runtime-only read layer for -render/link/status/doctor — auth is a write command and must target a -concrete, named profile to avoid surprise clobbers when CWD changes. -If you want auth to target the overlay's profile, pass --profile - explicitly.`, - Args: cobra.NoArgs, - RunE: func(cmd *cobra.Command, _ []string) error { - secret, cliErr := resolveAPISecretInput(secretStdin, cmd.ErrOrStderr(), apiSecret, cmd.Flags().Changed("api-secret"), apiSecretStdin, apiSecretFile) - if cliErr != nil { - return cliErr - } - - interactive := secret == "" && !apiSecretStdin && apiSecretFile == "" && isStdinTTY(cmd.InOrStdin()) && isStderrTTY(cmd.ErrOrStderr()) - if interactive { - // Prompt label on stderr — keeps stdout clean for --output-format json. - // Pre-prompt pointer to where the secret lives, so a first-time - // user doesn't have to hunt or guess the URL. - _, _ = fmt.Fprintln(cmd.ErrOrStderr(), "Find your API secret in your project's settings: https://urlbox.com/dashboard/projects") - _, _ = fmt.Fprint(cmd.ErrOrStderr(), "API secret: ") - s, err := authSecretReader() - if err != nil { - return output.NewCLIError(output.ErrUsage, "auth cancelled", err.Error()) - } - // Newline after masked input lands on the cobra writer so it - // participates in test capture + caller redirection. - _, _ = fmt.Fprintln(cmd.ErrOrStderr()) - secret = s - } - - if secret == "" { - if interactive { - // User was on a TTY and pressed Enter at the prompt — tell them so, - // don't suggest "run interactively on a TTY" (they already did). - return output.NewCLIError( - output.ErrUsage, - "empty API secret", - "Run 'urlbox auth' again and paste your secret at the prompt.", - ) - } - return output.NewCLIError( - output.ErrUsage, - "missing API secret", - "Pipe via --api-secret-stdin, read from --api-secret-file , pass --api-secret , export URLBOX_API_SECRET, or run interactively on a TTY. Find your API secret in your project's settings at https://urlbox.com/dashboard/projects.", - ) - } - - // Validate the resolved secret value through the same gate every - // secret-writing path uses (auth / config set / profile create). - // Catches whitespace-only, leading/trailing whitespace artifacts, - // and embedded control characters. Round 6 class-fix. - validated, vErr := config.ValidateSecretValue(secret) - if vErr != nil { - return vErr - } - secret = validated - - // Round 7 CC class-fix: the entire read-modify-write happens - // inside config.Update so the file lock makes it atomic w.r.t. - // parallel processes. The TTY prompt logic stays outside the - // mutate fn (prompting under a lock would deadlock other - // processes for the prompt's lifetime); on conflict, prompt - // then retry the Update with force=true. - flagProfile, _ := cmd.Root().PersistentFlags().GetString("profile") - envProfile := os.Getenv(config.EnvProfile) - - var profileName string - runUpdate := func(allowOverwrite bool) error { - return config.Update(func(cfg *config.Config) error { - // Round 4 C1: honor --profile and URLBOX_PROFILE. Before this, - // auth always wrote to cfg.DefaultProfile, silently dropping - // any --profile flag, which turned the overwrite guard into - // the 2026-05-08-incident-class clobber when callers - // intended a non-default target. Precedence: flag > env > - // cfg.DefaultProfile > "default". - switch { - case flagProfile != "": - if _, ok := cfg.Profiles[flagProfile]; !ok { - // Round 7 EE: every "user named a profile that - // doesn't exist" site reports ErrNotFound now, - // matching profile delete/default and the - // unified config.Resolve (render/status/link/doctor). - return output.NewCLIError( - output.ErrNotFound, - `Profile "`+flagProfile+`" does not exist`, - "Run 'urlbox config profile list' to see available profiles, or `urlbox config profile create "+flagProfile+"` first.", - ) - } - profileName = flagProfile - case envProfile != "": - if _, ok := cfg.Profiles[envProfile]; !ok { - return output.NewCLIError( - output.ErrNotFound, - `Profile "`+envProfile+`" does not exist (URLBOX_PROFILE)`, - "Run 'urlbox config profile list' to see available profiles, or unset URLBOX_PROFILE.", - ) - } - profileName = envProfile - default: - profileName = cfg.DefaultProfile - if profileName == "" { - profileName = "default" - cfg.DefaultProfile = profileName - } - } - p := cfg.Profiles[profileName] - - // Overwrite guard (Round 1 S-C3): same-secret re-save is - // idempotent; different-secret overwrite needs allowOverwrite. - if p.APISecret != "" && p.APISecret != secret && !allowOverwrite { - return output.NewCLIError( - output.ErrConflict, - fmt.Sprintf("profile %q already has an API secret (%s); overwrite refused", profileName, maskSecret(p.APISecret)), - "Pass --force to overwrite, or use `urlbox config profile create ` for a separate profile. This guard prevents the 2026-05-08 incident class where an agent silently clobbers a real secret.", - ) - } - p.APISecret = secret - cfg.Profiles[profileName] = p - return nil - }) - } - - err := runUpdate(force) - if err != nil { - // Interactive TTY: if the inner conflict was an overwrite - // guard and we're on a TTY, prompt the user. On confirm, - // retry the Update with allowOverwrite=true. - var cli *output.CLIError - if errors.As(err, &cli) && cli.Code == output.ErrConflict && - isStdinTTY(cmd.InOrStdin()) && isStderrTTY(cmd.ErrOrStderr()) { - // Re-read just for the prompt display (out-of-lock; we - // re-check inside Update on retry). - if existing, _ := config.Load(); existing != nil { - p := existing.Profiles[profileName] - if !confirmAuthOverwrite(cmd, p.APISecret, secret) { - return output.NewCLIError( - output.ErrConflict, - "auth cancelled — existing secret preserved", - "Re-run with --force to overwrite without prompt, or use `urlbox config profile create ` for a separate profile.", - ) - } - } - err = runUpdate(true) - } - } - if err != nil { - var cli *output.CLIError - if errors.As(err, &cli) { - return cli - } - return output.NewCLIError( - output.ErrServer, - "failed to save config", - err.Error(), - ) - } - - masked := maskSecret(secret) - env := output.NewEnvelope( - "auth", - map[string]string{ - "masked_secret": masked, - "profile": profileName, - "config_path": config.Path(), - }, - fmt.Sprintf("API secret configured (%s)", masked), - []output.Breadcrumb{ - {Action: "verify", Cmd: "urlbox doctor"}, - {Action: "render", Cmd: "urlbox render "}, - }, - ) - - formatFlag, _ := cmd.Root().PersistentFlags().GetString("output-format") - jqExpr, _ := cmd.Root().PersistentFlags().GetString("jq") - stdout := cmd.OutOrStdout() - format := output.ResolveFormat(formatFlag, stdout) - styles := output.NewStylesForWriter(stdout) - - if jqExpr != "" { - return output.WriteEnvelopeWithJQ(stdout, env, jqExpr, format == output.FormatQuiet) - } - formatter := output.NewFormatter(format, styles) - return formatter.WriteSuccess(stdout, env) - }, - } - c.Flags().StringVar(&apiSecret, "api-secret", "", "Urlbox API secret (skip the interactive prompt — leaks into ps and shell history; prefer --api-secret-stdin or --api-secret-file)") - c.Flags().BoolVar(&apiSecretStdin, "api-secret-stdin", false, "Read the API secret from stdin until EOF (recommended for CI / agents)") - c.Flags().StringVar(&apiSecretFile, "api-secret-file", "", "Read the API secret from the given file (trailing newline trimmed)") - c.Flags().BoolVar(&force, "force", false, "Overwrite an existing secret on the default profile without confirmation (CI-safe escape hatch for the overwrite guard)") - return c -} - -// confirmAuthOverwrite prompts the user on stderr whether to replace the -// existing default-profile secret. Returns true if the user types y / yes -// (case-insensitive). Used only on interactive TTYs; non-TTY callers -// require --force instead. -func confirmAuthOverwrite(cmd *cobra.Command, existing, replacement string) bool { - _, _ = fmt.Fprintf(cmd.ErrOrStderr(), - "Replacing existing secret %s with %s. Proceed? [y/N]: ", - maskSecret(existing), maskSecret(replacement)) - answer, _ := authConfirmReader() - _, _ = fmt.Fprintln(cmd.ErrOrStderr()) - answer = strings.ToLower(strings.TrimSpace(answer)) - return answer == "y" || answer == "yes" -} - -// maskSecret returns a redacted form of the API secret for safe display. -func maskSecret(s string) string { - if len(s) < 8 { - return "***" - } - return s[:4] + "…" + s[len(s)-2:] -} diff --git a/internal/cmd/auth_preflight.go b/internal/cmd/auth_preflight.go index 3ee838e..460c4eb 100644 --- a/internal/cmd/auth_preflight.go +++ b/internal/cmd/auth_preflight.go @@ -26,6 +26,6 @@ func requireSecret(resolved *config.Resolved) *output.CLIError { return output.NewCLIError( output.ErrAuth, "no API secret configured", - "Run `urlbox auth --api-secret ` to store one, or set URLBOX_API_SECRET in the environment. Get your secret from https://urlbox.com/dashboard/projects.", + loginHint+" CI and headless environments can set URLBOX_API_SECRET in the environment instead.", ) } diff --git a/internal/cmd/auth_test.go b/internal/cmd/auth_test.go deleted file mode 100644 index 829394f..0000000 --- a/internal/cmd/auth_test.go +++ /dev/null @@ -1,725 +0,0 @@ -package cmd_test - -import ( - "bytes" - "encoding/json" - "os" - "path/filepath" - "strings" - "testing" - - "github.com/urlbox/urlbox-cli/internal/cmd" - "github.com/urlbox/urlbox-cli/internal/config" -) - -func TestAuth_RequiresAPISecret(t *testing.T) { - t.Setenv("XDG_CONFIG_HOME", t.TempDir()) - var stdout, stderr bytes.Buffer - exit := cmd.Execute([]string{"auth"}, &stdout, &stderr) - if exit == 0 { - t.Fatal("expected non-zero exit on missing --api-secret") - } -} - -func TestAuth_WritesConfigFile(t *testing.T) { - t.Setenv("XDG_CONFIG_HOME", t.TempDir()) - t.Setenv("URLBOX_API_SECRET", "") - - var stdout, stderr bytes.Buffer - exit := cmd.Execute([]string{"auth", "--api-secret", "sec_xxxxxxxxxxxx"}, &stdout, &stderr) - if exit != 0 { - t.Fatalf("exit=%d stdout=%s stderr=%s", exit, stdout.String(), stderr.String()) - } - - c, err := config.Load() - if err != nil { - t.Fatalf("config.Load: %v", err) - } - if got := c.Profiles[c.DefaultProfile].APISecret; got != "sec_xxxxxxxxxxxx" { - t.Fatalf("secret not persisted; got %q", got) - } -} - -func TestAuth_FlagPath_StoresInAPISecret(t *testing.T) { - t.Setenv("XDG_CONFIG_HOME", t.TempDir()) - t.Setenv("URLBOX_API_SECRET", "") - - var stdout, stderr bytes.Buffer - exit := cmd.Execute([]string{"auth", "--api-secret", "sec_xxx"}, &stdout, &stderr) - if exit != 0 { - t.Fatalf("exit=%d stderr=%s", exit, stderr.String()) - } - c, err := config.Load() - if err != nil { - t.Fatal(err) - } - if c.Profiles["default"].APISecret != "sec_xxx" { - t.Errorf("APISecret = %q, want sec_xxx", c.Profiles["default"].APISecret) - } - if c.Profiles["default"].APIKey != "" { - t.Errorf("APIKey unexpectedly populated: %q (publishable-key field; should be empty after auth)", c.Profiles["default"].APIKey) - } -} - -func TestAuth_OldAPIKeyFlag_RemovedFromHelp(t *testing.T) { - var stdout, stderr bytes.Buffer - cmd.Execute([]string{"auth", "--help"}, &stdout, &stderr) - help := stdout.String() + stderr.String() - if strings.Contains(help, "--api-key") { - t.Error("--api-key should be gone in v0.6.0; replaced by --api-secret") - } - if !strings.Contains(help, "--api-secret") { - t.Error("--api-secret missing from --help") - } -} - -// Regression guard: --help and the missing-secret error envelope must both -// point users at the dashboard URL where they can copy their API secret. -// Field-report observation: agents (and humans) were inventing wrong URLs -// (urlbox.com/dashboard/api-secrets, etc.) because the CLI never said where -// to find the secret. Pinning the canonical pointer here. -func TestAuth_HelpAndErrorPointAtDashboardURL(t *testing.T) { - const wantURL = "urlbox.com/dashboard/projects" - - var stdout, stderr bytes.Buffer - cmd.Execute([]string{"auth", "--help"}, &stdout, &stderr) - help := stdout.String() + stderr.String() - if !strings.Contains(help, wantURL) { - t.Errorf("--help should point at %q so users know where to grab their secret; got:\n%s", wantURL, help) - } - - // Now exercise the missing-secret path and check the error envelope's hint. - t.Setenv("XDG_CONFIG_HOME", t.TempDir()) - t.Setenv("URLBOX_API_SECRET", "") - stdout.Reset() - stderr.Reset() - exit := cmd.Execute([]string{"auth", "--output-format", "json"}, &stdout, &stderr) - if exit == 0 { - t.Fatal("expected non-zero exit on missing --api-secret") - } - var env map[string]any - if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { - t.Fatalf("not JSON: %v\nstdout: %s", err, stdout.String()) - } - hint, _ := env["hint"].(string) - if !strings.Contains(hint, wantURL) { - t.Errorf("missing-secret hint should point at %q; got %q", wantURL, hint) - } -} - -func TestAuth_OutputEnvelopeMasksSecret(t *testing.T) { - t.Setenv("XDG_CONFIG_HOME", t.TempDir()) - t.Setenv("URLBOX_API_SECRET", "") - - var stdout, stderr bytes.Buffer - exit := cmd.Execute([]string{"auth", "--api-secret", "sec_supersecretvalue", "--output-format", "json"}, &stdout, &stderr) - if exit != 0 { - t.Fatalf("exit=%d", exit) - } - - var env map[string]any - if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { - t.Fatalf("not JSON: %v\nout: %s", err, stdout.String()) - } - summary, _ := env["summary"].(string) - if strings.Contains(summary, "supersecretvalue") { - t.Fatalf("summary should mask the secret: %q", summary) - } - if !strings.Contains(summary, "sec_") { - t.Fatalf("summary should show prefix: %q", summary) - } - data, _ := env["data"].(map[string]any) - if ms, _ := data["masked_secret"].(string); strings.Contains(ms, "supersecretvalue") { - t.Fatalf("masked_secret leaked secret: %q", ms) - } -} - -func TestAuth_RejectsEmptySecret(t *testing.T) { - t.Setenv("XDG_CONFIG_HOME", t.TempDir()) - var stdout, stderr bytes.Buffer - exit := cmd.Execute([]string{"auth", "--api-secret", ""}, &stdout, &stderr) - if exit == 0 { - t.Fatal("expected non-zero exit on empty secret") - } -} - -func TestAuth_HasBreadcrumbs(t *testing.T) { - t.Setenv("XDG_CONFIG_HOME", t.TempDir()) - var stdout, stderr bytes.Buffer - cmd.Execute([]string{"auth", "--api-secret", "sec_test1234", "--output-format", "json"}, &stdout, &stderr) - - var env map[string]any - _ = json.Unmarshal(stdout.Bytes(), &env) - bcs, _ := env["breadcrumbs"].([]any) - if len(bcs) == 0 { - t.Fatalf("expected breadcrumbs, got: %v", env["breadcrumbs"]) - } -} - -func TestAuth_NonInteractive_NoSecret_StillUsageError(t *testing.T) { - t.Setenv("XDG_CONFIG_HOME", t.TempDir()) - cmd.SetStdinTTYForTest(false) - defer cmd.ResetStdinTTYForTest() - - var stdout, stderr bytes.Buffer - exit := cmd.Execute([]string{"auth"}, &stdout, &stderr) - if exit != 1 { - t.Fatalf("exit=%d, want 1 (usage)", exit) - } - var env map[string]any - if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { - t.Fatalf("not JSON: %v", err) - } - if env["code"] != "usage" { - t.Errorf("code=%v", env["code"]) - } - if env["error"] != "missing API secret" { - t.Errorf("error=%v", env["error"]) - } -} - -// The interactive path requires a pty — we don't drive a real pty in CI. -// Instead we inject a stub secret-reader and verify the dispatcher selects -// the interactive branch when both stdin and stderr are TTYs. -func TestAuth_InteractivePath_DispatchedWhenTTY(t *testing.T) { - t.Setenv("XDG_CONFIG_HOME", t.TempDir()) - cmd.SetStdinTTYForTest(true) - cmd.SetStderrTTYForTest(true) - defer cmd.ResetStdinTTYForTest() - defer cmd.ResetStderrTTYForTest() - - called := false - cmd.SetAuthSecretReaderForTest(func() (string, error) { - called = true - return "sec_prompt", nil - }) - defer cmd.ResetAuthSecretReaderForTest() - - var stdout, stderr bytes.Buffer - exit := cmd.Execute([]string{"auth"}, &stdout, &stderr) - if exit != 0 { - t.Fatalf("exit=%d stderr=%s", exit, stderr.String()) - } - if !called { - t.Fatal("interactive secret reader was not called") - } - if !strings.Contains(stderr.String(), "API secret:") { - t.Errorf("expected prompt label on stderr, got %q", stderr.String()) - } - - c, err := config.Load() - if err != nil { - t.Fatal(err) - } - if c.Profiles["default"].APISecret != "sec_prompt" { - t.Errorf("APISecret = %q, want sec_prompt", c.Profiles["default"].APISecret) - } -} - -// Regression guard: the trailing newline emitted after the masked password -// read must land on the cobra-injected stderr writer (cmd.ErrOrStderr()), -// not on the process-global os.Stderr. Otherwise tests can't capture or -// redirect it, and the writer-plumbing convention breaks. -func TestAuth_InteractivePrompt_NewlineGoesToCobraStderr(t *testing.T) { - t.Setenv("XDG_CONFIG_HOME", t.TempDir()) - cmd.SetStdinTTYForTest(true) - cmd.SetStderrTTYForTest(true) - t.Cleanup(func() { - cmd.ResetStdinTTYForTest() - cmd.ResetStderrTTYForTest() - }) - cmd.SetAuthSecretReaderForTest(func() (string, error) { - return "ubx_sk_test12345678", nil - }) - t.Cleanup(cmd.ResetAuthSecretReaderForTest) - - var stdout, stderr bytes.Buffer - exit := cmd.Execute([]string{"auth"}, &stdout, &stderr) - if exit != 0 { - t.Fatalf("exit=%d stderr=%s", exit, stderr.String()) - } - if !strings.Contains(stderr.String(), "API secret:") { - t.Errorf("stderr missing prompt; got %q", stderr.String()) - } - if !strings.HasSuffix(stderr.String(), "\n") { - t.Errorf("stderr missing trailing newline; got %q", stderr.String()) - } -} - -func TestAuth_InteractivePath_EmptyInput_UsageError(t *testing.T) { - t.Setenv("XDG_CONFIG_HOME", t.TempDir()) - cmd.SetStdinTTYForTest(true) - cmd.SetStderrTTYForTest(true) - defer cmd.ResetStdinTTYForTest() - defer cmd.ResetStderrTTYForTest() - - cmd.SetAuthSecretReaderForTest(func() (string, error) { - return "", nil - }) - defer cmd.ResetAuthSecretReaderForTest() - - var stdout, stderr bytes.Buffer - exit := cmd.Execute([]string{"auth"}, &stdout, &stderr) - if exit != 1 { - t.Fatalf("exit=%d, want 1", exit) - } - var env map[string]any - if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { - t.Fatalf("not JSON: %v", err) - } - if env["code"] != "usage" { - t.Errorf("code=%v", env["code"]) - } - // The interactive empty-input error must NOT suggest "run interactively - // on a TTY" — the user just did that. It should say so. - if env["error"] != "empty API secret" { - t.Errorf("error=%v, want %q", env["error"], "empty API secret") - } - if hint, _ := env["hint"].(string); strings.Contains(hint, "run interactively on a TTY") { - t.Errorf("interactive hint shouldn't suggest TTY (user is already on one): %q", hint) - } -} - -// TestAuth_APISecretStdin_ReadsAndSaves pins the --api-secret-stdin path: -// pipe the secret on stdin, no argv leak, no shell-history exposure. -// Closes Round 1 S-C2. -func TestAuth_APISecretStdin_ReadsAndSaves(t *testing.T) { - t.Setenv("XDG_CONFIG_HOME", t.TempDir()) - t.Setenv("URLBOX_API_SECRET", "") - cmd.SetSecretStdinForTest(strings.NewReader("sec_stdin_abcdefghij\n")) - t.Cleanup(cmd.ResetSecretStdinForTest) - - var stdout, stderr bytes.Buffer - exit := cmd.Execute([]string{"auth", "--api-secret-stdin"}, &stdout, &stderr) - if exit != 0 { - t.Fatalf("exit=%d stdout=%s stderr=%s", exit, stdout.String(), stderr.String()) - } - c, err := config.Load() - if err != nil { - t.Fatal(err) - } - if got := c.Profiles[c.DefaultProfile].APISecret; got != "sec_stdin_abcdefghij" { - t.Errorf("APISecret=%q, want sec_stdin_abcdefghij", got) - } - // stdin path must NOT trigger the TTY history warning. - if strings.Contains(stderr.String(), "shell history") { - t.Errorf("stdin path should not warn about shell history; got %q", stderr.String()) - } -} - -// TestAuth_APISecretFile_ReadsAndSaves pins the --api-secret-file path. -func TestAuth_APISecretFile_ReadsAndSaves(t *testing.T) { - t.Setenv("XDG_CONFIG_HOME", t.TempDir()) - t.Setenv("URLBOX_API_SECRET", "") - - dir := t.TempDir() - p := filepath.Join(dir, "secret.txt") - if err := os.WriteFile(p, []byte("sec_file_klmnopqrst\n"), 0o600); err != nil { - t.Fatal(err) - } - - var stdout, stderr bytes.Buffer - exit := cmd.Execute([]string{"auth", "--api-secret-file", p}, &stdout, &stderr) - if exit != 0 { - t.Fatalf("exit=%d stdout=%s stderr=%s", exit, stdout.String(), stderr.String()) - } - c, err := config.Load() - if err != nil { - t.Fatal(err) - } - if got := c.Profiles[c.DefaultProfile].APISecret; got != "sec_file_klmnopqrst" { - t.Errorf("APISecret=%q, want sec_file_klmnopqrst", got) - } -} - -// TestAuth_APISecretFlag_OnTTY_PrintsHistoryWarning pins UX I5: -// --api-secret on a TTY emits a stderr warning about shell history. -// On a non-TTY (CI), the warning is suppressed. -func TestAuth_APISecretFlag_OnTTY_PrintsHistoryWarning(t *testing.T) { - t.Setenv("XDG_CONFIG_HOME", t.TempDir()) - t.Setenv("URLBOX_API_SECRET", "") - cmd.SetStderrTTYForTest(true) - t.Cleanup(cmd.ResetStderrTTYForTest) - - var stdout, stderr bytes.Buffer - exit := cmd.Execute([]string{"auth", "--api-secret", "sec_argv_uvwxyz1234"}, &stdout, &stderr) - if exit != 0 { - t.Fatalf("exit=%d stdout=%s stderr=%s", exit, stdout.String(), stderr.String()) - } - if !strings.Contains(stderr.String(), "shell history") { - t.Errorf("TTY stderr should warn about shell history; got %q", stderr.String()) - } -} - -// TestAuth_APISecretFlag_OnNonTTY_NoWarning pins suppression in CI / pipelines. -func TestAuth_APISecretFlag_OnNonTTY_NoWarning(t *testing.T) { - t.Setenv("XDG_CONFIG_HOME", t.TempDir()) - t.Setenv("URLBOX_API_SECRET", "") - cmd.SetStderrTTYForTest(false) - t.Cleanup(cmd.ResetStderrTTYForTest) - - var stdout, stderr bytes.Buffer - exit := cmd.Execute([]string{"auth", "--api-secret", "sec_ci_qwerty5678"}, &stdout, &stderr) - if exit != 0 { - t.Fatalf("exit=%d stdout=%s stderr=%s", exit, stdout.String(), stderr.String()) - } - if strings.Contains(stderr.String(), "warning") { - t.Errorf("non-TTY should NOT emit shell-history warning; got %q", stderr.String()) - } -} - -// TestAuth_MutexBetweenSecretInputFlags pins that passing more than one of -// --api-secret, --api-secret-stdin, --api-secret-file is a usage error. -func TestAuth_MutexBetweenSecretInputFlags(t *testing.T) { - t.Setenv("XDG_CONFIG_HOME", t.TempDir()) - var stdout, stderr bytes.Buffer - exit := cmd.Execute([]string{"auth", "--api-secret", "sec_x", "--api-secret-stdin"}, &stdout, &stderr) - if exit == 0 { - t.Fatal("expected non-zero exit on mutex violation") - } - var env map[string]any - _ = json.Unmarshal(stdout.Bytes(), &env) - if env["code"] != "usage" { - t.Errorf("code=%v, want usage", env["code"]) - } - if !strings.Contains(env["error"].(string), "at most one") { - t.Errorf("error should say 'at most one'; got %q", env["error"]) - } -} - -// TestAuth_Overwrite_NonTTY_RequiresForce pins S-C3 non-TTY behavior: -// when the default profile already has a secret AND a different new -// secret arrives non-interactively, refuse to overwrite. This is the -// 2026-05-08 incident-class guard. Pass --force to opt in. -func TestAuth_Overwrite_NonTTY_RequiresForce(t *testing.T) { - t.Setenv("XDG_CONFIG_HOME", t.TempDir()) - t.Setenv("URLBOX_API_SECRET", "") - cmd.SetStdinTTYForTest(false) - cmd.SetStderrTTYForTest(false) - t.Cleanup(cmd.ResetStdinTTYForTest) - t.Cleanup(cmd.ResetStderrTTYForTest) - - // Seed an existing secret. - var stdout, stderr bytes.Buffer - if exit := cmd.Execute([]string{"auth", "--api-secret", "sec_original_abcdef"}, &stdout, &stderr); exit != 0 { - t.Fatalf("seed exit=%d", exit) - } - - // Attempt a different secret WITHOUT --force. - stdout.Reset() - stderr.Reset() - exit := cmd.Execute([]string{"auth", "--api-secret", "sec_DIFFERENT_uvwxyz", "--output-format", "json"}, &stdout, &stderr) - if exit == 0 { - t.Fatal("expected non-zero exit when overwrite refused in non-TTY mode") - } - var env map[string]any - _ = json.Unmarshal(stdout.Bytes(), &env) - if env["code"] != "conflict" { - t.Errorf("code=%v, want conflict", env["code"]) - } - if hint, _ := env["hint"].(string); !strings.Contains(hint, "--force") { - t.Errorf("hint should mention --force; got %q", hint) - } - - // Verify original secret was NOT clobbered. - c, err := config.Load() - if err != nil { - t.Fatal(err) - } - if c.Profiles[c.DefaultProfile].APISecret != "sec_original_abcdef" { - t.Errorf("secret was clobbered; got %q, want sec_original_abcdef", - c.Profiles[c.DefaultProfile].APISecret) - } -} - -// TestAuth_Overwrite_Force_Overwrites pins that --force bypasses the -// guard non-interactively. -func TestAuth_Overwrite_Force_Overwrites(t *testing.T) { - t.Setenv("XDG_CONFIG_HOME", t.TempDir()) - t.Setenv("URLBOX_API_SECRET", "") - cmd.SetStdinTTYForTest(false) - cmd.SetStderrTTYForTest(false) - t.Cleanup(cmd.ResetStdinTTYForTest) - t.Cleanup(cmd.ResetStderrTTYForTest) - - var stdout, stderr bytes.Buffer - if exit := cmd.Execute([]string{"auth", "--api-secret", "sec_original_abcdef"}, &stdout, &stderr); exit != 0 { - t.Fatalf("seed exit=%d", exit) - } - stdout.Reset() - stderr.Reset() - exit := cmd.Execute([]string{"auth", "--api-secret", "sec_NEW_overwritten12", "--force"}, &stdout, &stderr) - if exit != 0 { - t.Fatalf("exit=%d (--force should permit overwrite); stderr=%s", exit, stderr.String()) - } - c, err := config.Load() - if err != nil { - t.Fatal(err) - } - if c.Profiles[c.DefaultProfile].APISecret != "sec_NEW_overwritten12" { - t.Errorf("--force did not overwrite; got %q", c.Profiles[c.DefaultProfile].APISecret) - } -} - -// TestAuth_Overwrite_SameSecret_NoGuard pins the idempotent case: if the -// new secret matches the existing, no guard / prompt fires. -func TestAuth_Overwrite_SameSecret_NoGuard(t *testing.T) { - t.Setenv("XDG_CONFIG_HOME", t.TempDir()) - t.Setenv("URLBOX_API_SECRET", "") - cmd.SetStdinTTYForTest(false) - cmd.SetStderrTTYForTest(false) - t.Cleanup(cmd.ResetStdinTTYForTest) - t.Cleanup(cmd.ResetStderrTTYForTest) - - var stdout, stderr bytes.Buffer - if exit := cmd.Execute([]string{"auth", "--api-secret", "sec_identical_xyz789"}, &stdout, &stderr); exit != 0 { - t.Fatalf("seed exit=%d", exit) - } - stdout.Reset() - stderr.Reset() - exit := cmd.Execute([]string{"auth", "--api-secret", "sec_identical_xyz789"}, &stdout, &stderr) - if exit != 0 { - t.Fatalf("exit=%d (same-secret re-save should succeed silently)", exit) - } -} - -// TestAuth_Overwrite_TTY_Prompts pins S-C3 TTY behavior: prompt y/N -// via the test-injectable confirm-reader. "y" accepts, "n" cancels. -func TestAuth_Overwrite_TTY_PromptAccept(t *testing.T) { - t.Setenv("XDG_CONFIG_HOME", t.TempDir()) - t.Setenv("URLBOX_API_SECRET", "") - cmd.SetStdinTTYForTest(true) - cmd.SetStderrTTYForTest(true) - t.Cleanup(cmd.ResetStdinTTYForTest) - t.Cleanup(cmd.ResetStderrTTYForTest) - - var stdout, stderr bytes.Buffer - cmd.SetStdinTTYForTest(false) - if exit := cmd.Execute([]string{"auth", "--api-secret", "sec_seed_abcdefghij"}, &stdout, &stderr); exit != 0 { - t.Fatalf("seed exit=%d", exit) - } - cmd.SetStdinTTYForTest(true) - - cmd.SetAuthConfirmReaderForTest(func() (string, error) { return "y", nil }) - t.Cleanup(cmd.ResetAuthConfirmReaderForTest) - - stdout.Reset() - stderr.Reset() - exit := cmd.Execute([]string{"auth", "--api-secret", "sec_replaced_xyz123"}, &stdout, &stderr) - if exit != 0 { - t.Fatalf("exit=%d, want 0 (prompt accepted); stderr=%s", exit, stderr.String()) - } - if !strings.Contains(stderr.String(), "Replacing existing secret") { - t.Errorf("stderr should show overwrite confirmation prompt; got %q", stderr.String()) - } - c, _ := config.Load() - if c.Profiles[c.DefaultProfile].APISecret != "sec_replaced_xyz123" { - t.Errorf("after 'y' prompt, secret = %q, want sec_replaced_xyz123", - c.Profiles[c.DefaultProfile].APISecret) - } -} - -func TestAuth_Overwrite_TTY_PromptReject(t *testing.T) { - t.Setenv("XDG_CONFIG_HOME", t.TempDir()) - t.Setenv("URLBOX_API_SECRET", "") - cmd.SetStdinTTYForTest(false) - cmd.SetStderrTTYForTest(false) - t.Cleanup(cmd.ResetStdinTTYForTest) - t.Cleanup(cmd.ResetStderrTTYForTest) - - var stdout, stderr bytes.Buffer - if exit := cmd.Execute([]string{"auth", "--api-secret", "sec_seed_abcdefghij"}, &stdout, &stderr); exit != 0 { - t.Fatalf("seed exit=%d", exit) - } - - cmd.SetStdinTTYForTest(true) - cmd.SetStderrTTYForTest(true) - cmd.SetAuthConfirmReaderForTest(func() (string, error) { return "n", nil }) - t.Cleanup(cmd.ResetAuthConfirmReaderForTest) - - stdout.Reset() - stderr.Reset() - exit := cmd.Execute([]string{"auth", "--api-secret", "sec_REJECTED_abc456", "--output-format", "json"}, &stdout, &stderr) - // TTY reject and non-TTY refuse both yield "we did not save because of - // existing state" — exit code 7 (ErrConflict) in both paths. The `code` - // field in the JSON envelope is identical too; the message text disambiguates - // for humans. Round 2 architecture M1. - if exit != 7 { - t.Fatalf("exit=%d, want 7 (conflict); stdout=%s", exit, stdout.String()) - } - var env map[string]any - _ = json.Unmarshal(stdout.Bytes(), &env) - if env["code"] != "conflict" { - t.Errorf("code=%v, want conflict", env["code"]) - } - c, _ := config.Load() - if c.Profiles[c.DefaultProfile].APISecret != "sec_seed_abcdefghij" { - t.Errorf("after 'n' prompt, original secret was clobbered: %q", c.Profiles[c.DefaultProfile].APISecret) - } -} - -// TestAuth_ProfileFlag_TargetsNamedProfile pins the C1 fix from Round 4 -// adversarial review: `urlbox auth --profile ` must write the new -// secret into the named profile, NOT silently fall back to default. -// -// Before this fix, `--profile` was ignored entirely by auth — meaning a -// caller intending to set up a non-default profile would unknowingly -// clobber the default profile's secret. With --force, the clobber was -// silent (the overwrite guard fired against the wrong profile name and -// then --force bypassed it). -func TestAuth_ProfileFlag_TargetsNamedProfile(t *testing.T) { - t.Setenv("XDG_CONFIG_HOME", t.TempDir()) - t.Setenv("URLBOX_API_SECRET", "") - cmd.SetStdinTTYForTest(false) - cmd.SetStderrTTYForTest(false) - t.Cleanup(cmd.ResetStdinTTYForTest) - t.Cleanup(cmd.ResetStderrTTYForTest) - - // Seed two profiles so default_profile != target. `config profile create` - // makes the FIRST created profile the default, so create `default` first - // to anchor it, then create `staging` as the non-default target. - var stdout, stderr bytes.Buffer - if exit := cmd.Execute([]string{"config", "profile", "create", "default", "--api-secret", "sec_default_seed12"}, &stdout, &stderr); exit != 0 { - t.Fatalf("seed default exit=%d stderr=%s", exit, stderr.String()) - } - stdout.Reset() - stderr.Reset() - if exit := cmd.Execute([]string{"config", "profile", "create", "staging"}, &stdout, &stderr); exit != 0 { - t.Fatalf("seed staging exit=%d stderr=%s", exit, stderr.String()) - } - - stdout.Reset() - stderr.Reset() - exit := cmd.Execute([]string{"--profile", "staging", "auth", "--api-secret", "sec_staging_xxxxxxx", "--output-format", "json"}, &stdout, &stderr) - if exit != 0 { - t.Fatalf("exit=%d, want 0; stderr=%s; stdout=%s", exit, stderr.String(), stdout.String()) - } - var env map[string]any - _ = json.Unmarshal(stdout.Bytes(), &env) - data, _ := env["data"].(map[string]any) - if data["profile"] != "staging" { - t.Errorf("envelope profile=%v, want staging", data["profile"]) - } - - c, _ := config.Load() - if c.Profiles["staging"].APISecret != "sec_staging_xxxxxxx" { - t.Errorf("staging.APISecret=%q, want sec_staging_xxxxxxx", c.Profiles["staging"].APISecret) - } - // default profile must NOT have been touched. - if c.Profiles["default"].APISecret != "sec_default_seed12" { - t.Errorf("default.APISecret was unexpectedly mutated: %q", c.Profiles["default"].APISecret) - } -} - -// TestAuth_ProfileFlag_UnknownProfile_Errors pins Round 4 C1 + Round 7 EE: -// an unknown --profile name must error rather than silently write to -// default. Round 4 closed the silent-clobber (errored with ErrUsage); -// Round 7 EE aligns the envelope shape to ErrNotFound exit 5 with -// command="auth" — same as profile delete/default and config.Resolve. -// Every "user named a profile that doesn't exist" site in the CLI now -// returns the same envelope. -func TestAuth_ProfileFlag_UnknownProfile_Errors(t *testing.T) { - t.Setenv("XDG_CONFIG_HOME", t.TempDir()) - t.Setenv("URLBOX_API_SECRET", "") - cmd.SetStdinTTYForTest(false) - cmd.SetStderrTTYForTest(false) - t.Cleanup(cmd.ResetStdinTTYForTest) - t.Cleanup(cmd.ResetStderrTTYForTest) - - // Seed a real default profile so we can prove --profile bogus didn't clobber it. - var stdout, stderr bytes.Buffer - if exit := cmd.Execute([]string{"auth", "--api-secret", "sec_default_xxxxx"}, &stdout, &stderr); exit != 0 { - t.Fatalf("seed exit=%d stderr=%s", exit, stderr.String()) - } - - stdout.Reset() - stderr.Reset() - // Even with --force, an unknown profile name must error rather than - // silently overwrite default. This closes the Round 4 adversarial repro. - exit := cmd.Execute([]string{"--profile", "NONEXISTENT", "auth", "--api-secret", "sec_attacker_yy", "--force", "--output-format", "json"}, &stdout, &stderr) - if exit != 5 { - t.Fatalf("--profile NONEXISTENT should exit 5 (not_found); got exit=%d stdout=%s", exit, stdout.String()) - } - var env map[string]any - _ = json.Unmarshal(stdout.Bytes(), &env) - if env["code"] != "not_found" { - t.Errorf("code=%v, want not_found", env["code"]) - } - if !strings.Contains(env["error"].(string), "NONEXISTENT") { - t.Errorf("error should name the rejected profile; got %q", env["error"]) - } - if env["command"] != "auth" { - t.Errorf("command=%v, want auth", env["command"]) - } - - // Verify default profile was NOT clobbered — this is the load-bearing assertion. - c, _ := config.Load() - if c.Profiles["default"].APISecret != "sec_default_xxxxx" { - t.Errorf("default.APISecret was clobbered by bogus --profile: %q", c.Profiles["default"].APISecret) - } -} - -// TestAuth_EnvProfile_TargetsNamedProfile pins parallel behavior for the -// URLBOX_PROFILE env var. -func TestAuth_EnvProfile_TargetsNamedProfile(t *testing.T) { - t.Setenv("XDG_CONFIG_HOME", t.TempDir()) - t.Setenv("URLBOX_API_SECRET", "") - cmd.SetStdinTTYForTest(false) - cmd.SetStderrTTYForTest(false) - t.Cleanup(cmd.ResetStdinTTYForTest) - t.Cleanup(cmd.ResetStderrTTYForTest) - - // Seed two profiles so default != target, then point URLBOX_PROFILE at - // the non-default one and verify auth respects it. - var stdout, stderr bytes.Buffer - if exit := cmd.Execute([]string{"config", "profile", "create", "default", "--api-secret", "sec_default_seed12"}, &stdout, &stderr); exit != 0 { - t.Fatalf("seed default exit=%d stderr=%s", exit, stderr.String()) - } - stdout.Reset() - stderr.Reset() - if exit := cmd.Execute([]string{"config", "profile", "create", "prod"}, &stdout, &stderr); exit != 0 { - t.Fatalf("seed prod exit=%d stderr=%s", exit, stderr.String()) - } - - t.Setenv("URLBOX_PROFILE", "prod") - stdout.Reset() - stderr.Reset() - exit := cmd.Execute([]string{"auth", "--api-secret", "sec_prod_zzzzzzzz"}, &stdout, &stderr) - if exit != 0 { - t.Fatalf("exit=%d stderr=%s", exit, stderr.String()) - } - c, _ := config.Load() - if c.Profiles["prod"].APISecret != "sec_prod_zzzzzzzz" { - t.Errorf("prod.APISecret=%q, want sec_prod_zzzzzzzz", c.Profiles["prod"].APISecret) - } - if c.Profiles["default"].APISecret != "sec_default_seed12" { - t.Errorf("default.APISecret unexpectedly mutated: %q", c.Profiles["default"].APISecret) - } -} - -// TestAuth_APISecretFlag_EmptyValue_Errors pins Round 4 M3: explicit -// `--api-secret ""` previously fell through silently to env/profile, -// which is the worst option for a user trying to test "what happens -// with no auth?". Now it errors loudly. -func TestAuth_APISecretFlag_EmptyValue_Errors(t *testing.T) { - t.Setenv("XDG_CONFIG_HOME", t.TempDir()) - t.Setenv("URLBOX_API_SECRET", "") - cmd.SetStdinTTYForTest(false) - cmd.SetStderrTTYForTest(false) - t.Cleanup(cmd.ResetStdinTTYForTest) - t.Cleanup(cmd.ResetStderrTTYForTest) - - var stdout, stderr bytes.Buffer - exit := cmd.Execute([]string{"auth", "--api-secret", "", "--output-format", "json"}, &stdout, &stderr) - if exit == 0 { - t.Fatalf("--api-secret \"\" should error; got exit 0, stdout=%s", stdout.String()) - } - var env map[string]any - _ = json.Unmarshal(stdout.Bytes(), &env) - if env["code"] != "usage" { - t.Errorf("code=%v, want usage", env["code"]) - } - if !strings.Contains(env["error"].(string), "empty") { - t.Errorf("error should mention empty; got %q", env["error"]) - } -} diff --git a/internal/cmd/compat_session_config_test.go b/internal/cmd/compat_session_config_test.go new file mode 100644 index 0000000..afa99a3 --- /dev/null +++ b/internal/cmd/compat_session_config_test.go @@ -0,0 +1,201 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "testing" +) + +const compatSecret = "sk_test_abcdefgh12345678" + +// unreachableHost is a closed port on loopback: connection-refused is +// immediate and identical regardless of which config state is loaded, so +// commands that must reach the network fail fast and deterministically. +const unreachableHost = "http://127.0.0.1:1" + +func writeCompatConfig(t *testing.T, dir string, withSession bool) { + t.Helper() + profile := map[string]string{ + "api_key": "pk_test_key", + "api_secret": compatSecret, + } + if withSession { + profile["session_token"] = "sess_tok_compat_123456" + profile["active_org"] = "org_compat" + profile["active_project"] = "proj_compat" + } + cfg := map[string]any{ + "default_profile": "default", + "profiles": map[string]any{"default": profile}, + } + b, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + t.Fatalf("marshal: %v", err) + } + if err := os.MkdirAll(filepath.Join(dir, "urlbox"), 0o700); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, "urlbox", "config.json"), b, 0o600); err != nil { + t.Fatalf("write: %v", err) + } +} + +type compatCase struct { + name string + args []string + // host, when set, is exported as URLBOX_API_HOST for the case so + // network-touching commands hit a controlled endpoint instead of the + // real API. + host string + // exemptStdout / exemptStderr skip the byte-identity assertion for that + // stream when the output is legitimately state-dependent (e.g. it embeds + // the config path, which lives under the per-state XDG dir). + exemptStdout bool + exemptStderr bool +} + +func compatCases() []compatCase { + return []compatCase{ + {name: "render dry-run", args: []string{"render", "https://example.com", "--dry-run", "--output-format", "json"}}, + {name: "screenshot dry-run", args: []string{"screenshot", "https://example.com", "--dry-run", "--output-format", "json"}}, + {name: "pdf dry-run", args: []string{"pdf", "https://example.com", "--dry-run", "--output-format", "json"}}, + {name: "render curl", args: []string{"render", "https://example.com", "--curl", "--output-format", "json"}}, + {name: "link", args: []string{"link", "https://example.com", "--output-format", "json"}}, + {name: "config get secret", args: []string{"config", "get", "api_secret", "--output-format", "json"}}, + {name: "config path", args: []string{"config", "path", "--output-format", "quiet"}, exemptStdout: true}, + {name: "config profile list", args: []string{"config", "profile", "list", "--output-format", "json"}}, + {name: "schema", args: []string{"schema", "render", "--output-format", "json"}}, + {name: "commands", args: []string{"commands", "--output-format", "json"}}, + {name: "version", args: []string{"version"}}, + // status reaches the network; --no-retry keeps the connection-refused + // failure immediate so both states fail identically and fast. + {name: "status", args: []string{"status", "ps_abc123", "--no-retry", "--output-format", "json"}, host: unreachableHost}, + // doctor's config_file check echoes config.Path(), which lives under + // the per-state XDG dir, so its stdout is legitimately state-dependent. + {name: "doctor", args: []string{"doctor", "--output-format", "json"}, host: unreachableHost, exemptStdout: true}, + // dashboard in json mode emits a fixed URL envelope and launches no + // browser — no network, no path, so it must be byte-identical. + {name: "dashboard", args: []string{"dashboard", "--output-format", "json"}}, + // auth was removed in Plan 2 (login replaces it); it now resolves as + // an unknown command before any session load, so both states emit the + // identical routing error. + {name: "auth removed", args: []string{"auth", "--output-format", "json"}}, + } +} + +func runCompat(t *testing.T, args []string) (stdoutStr, stderrStr string, code int) { + t.Helper() + var stdout, stderr bytes.Buffer + code = Execute(args, &stdout, &stderr) + return stdout.String(), stderr.String(), code +} + +func TestSessionFieldsDoNotChangeExistingCommands(t *testing.T) { + for _, tc := range compatCases() { + t.Run(tc.name, func(t *testing.T) { + if tc.host != "" { + t.Setenv("URLBOX_API_HOST", tc.host) + } + + legacyDir := t.TempDir() + writeCompatConfig(t, legacyDir, false) + t.Setenv("XDG_CONFIG_HOME", legacyDir) + legacyOut, legacyErr, legacyCode := runCompat(t, tc.args) + + sessionDir := t.TempDir() + writeCompatConfig(t, sessionDir, true) + t.Setenv("XDG_CONFIG_HOME", sessionDir) + sessionOut, sessionErr, sessionCode := runCompat(t, tc.args) + + if legacyCode != sessionCode { + t.Fatalf("exit code changed: legacy=%d session=%d\nlegacy stderr: %s\nsession stderr: %s", + legacyCode, sessionCode, legacyErr, sessionErr) + } + if !tc.exemptStdout && legacyOut != sessionOut { + t.Fatalf("stdout changed:\nlegacy: %s\nsession: %s", legacyOut, sessionOut) + } + if !tc.exemptStderr && legacyErr != sessionErr { + t.Fatalf("stderr changed:\nlegacy: %s\nsession: %s", legacyErr, sessionErr) + } + }) + } +} + +func TestConfigSetPreservesSessionFields(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + _, stderr, code := runCompat(t, []string{"config", "set", "api_host", "https://api.urlbox.com"}) + if code != 0 { + t.Fatalf("config set failed (%d): %s", code, stderr) + } + b, err := os.ReadFile(filepath.Join(dir, "urlbox", "config.json")) + if err != nil { + t.Fatalf("read config: %v", err) + } + var cfg struct { + Profiles map[string]map[string]string `json:"profiles"` + } + if err := json.Unmarshal(b, &cfg); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if cfg.Profiles["default"]["session_token"] != "sess_tok_compat_123456" { + t.Fatalf("config set dropped session_token: %s", b) + } +} + +// The Plan-2 credential groups (storage / proxies / llm) require a session and +// an active org, so their output is legitimately session-state-dependent — the +// two-state byte-identity suite does not apply. This pins the divergence +// instead: the legacy (logged-out) state must fail at the auth gate with the +// unified not-logged-in error (exit 3), and the post-login state must get past +// the gate and fail only at the network boundary of the unreachable host +// (exit 11). Any regression that leaks a session requirement into the legacy +// path, or short-circuits the network for a logged-in caller, breaks one arm. +// --no-retry keeps the logged-in arm's connection-refused failure to a single +// attempt so the suite doesn't spend the default retry budget per group. +func TestCredentialGroupsGuardBySessionState(t *testing.T) { + groups := []struct { + name string + args []string + }{ + {name: "storage list", args: []string{"storage", "list", "--no-retry", "--output-format", "json"}}, + {name: "proxies list", args: []string{"proxies", "list", "--no-retry", "--output-format", "json"}}, + {name: "llm list", args: []string{"llm", "list", "--no-retry", "--output-format", "json"}}, + } + for _, g := range groups { + t.Run(g.name, func(t *testing.T) { + t.Setenv("URLBOX_API_HOST", unreachableHost) + + legacyDir := t.TempDir() + writeCompatConfig(t, legacyDir, false) + t.Setenv("XDG_CONFIG_HOME", legacyDir) + legacyOut, _, legacyCode := runCompat(t, g.args) + if legacyCode != 3 { + t.Fatalf("legacy exit code = %d, want 3 (auth)\nstdout: %s", legacyCode, legacyOut) + } + if !bytes.Contains([]byte(legacyOut), []byte(notLoggedInMsg)) { + t.Fatalf("legacy output missing not-logged-in message:\n%s", legacyOut) + } + if !bytes.Contains([]byte(legacyOut), []byte(`"code": "auth"`)) { + t.Fatalf("legacy output missing auth code:\n%s", legacyOut) + } + + sessionDir := t.TempDir() + writeCompatConfig(t, sessionDir, true) + t.Setenv("XDG_CONFIG_HOME", sessionDir) + sessionOut, _, sessionCode := runCompat(t, g.args) + if sessionCode != 11 { + t.Fatalf("session exit code = %d, want 11 (network)\nstdout: %s", sessionCode, sessionOut) + } + if !bytes.Contains([]byte(sessionOut), []byte(`"code": "network"`)) { + t.Fatalf("session output missing network code:\n%s", sessionOut) + } + if bytes.Contains([]byte(sessionOut), []byte(notLoggedInMsg)) { + t.Fatalf("session output must clear the auth gate, still shows not-logged-in:\n%s", sessionOut) + } + }) + } +} diff --git a/internal/cmd/config.go b/internal/cmd/config.go index 9e28164..3680c53 100644 --- a/internal/cmd/config.go +++ b/internal/cmd/config.go @@ -17,7 +17,7 @@ import ( "github.com/urlbox/urlbox-cli/internal/output" ) -var supportedConfigKeys = []string{"api_key", "api_secret", "api_host", "default_profile"} +var supportedConfigKeys = []string{"api_key", "api_secret", "api_host", "default_profile", "session_token", "active_org", "active_project"} // profileNameRE pins the allowed shape of a profile name: must start with // an alphanumeric, then 0–63 more alphanumerics / underscore / hyphen, @@ -49,7 +49,7 @@ func unknownKeyError(key string) *output.CLIError { return output.NewCLIError( output.ErrUsage, "Unknown config key: "+key, - "Supported: api_key, api_secret, api_host, default_profile", + "Supported: api_key, api_secret, api_host, default_profile, session_token, active_org, active_project", ) } @@ -257,7 +257,7 @@ func newProfileDeleteCmd() *cobra.Command { return output.NewCLIError( output.ErrConflict, `Cannot delete the only profile "`+name+`"`, - "Create another profile first, or run 'urlbox auth' to start fresh.", + "Create another profile first, or run `urlbox login` to start fresh.", ) } if name == cfg.DefaultProfile { @@ -311,10 +311,10 @@ func newConfigGetCmd() *cobra.Command { Short: "Read a config value", Long: `Read a config value from the resolved profile. -For api_secret, the raw value is masked by default (Round 1 UX I1) to -avoid leaking into scrollback / clipboard / log capture. Pass --reveal -to print the unmasked secret (intended for clipboard-copy workflows -with eyes on the screen).`, +For api_secret and session_token, the raw value is masked by default +(Round 1 UX I1) to avoid leaking into scrollback / clipboard / log +capture. Pass --reveal to print the unmasked value (intended for +clipboard-copy workflows with eyes on the screen).`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { key := args[0] @@ -341,7 +341,7 @@ with eyes on the screen).`, } value := readKey(c, profileName, key) display := value - if key == "api_secret" && !reveal && value != "" { + if (key == "api_secret" || key == "session_token") && !reveal && value != "" { display = maskSecret(value) } env := output.NewEnvelope( @@ -353,7 +353,7 @@ with eyes on the screen).`, return writeEnvelopeWithQuietData(cmd, env, display) }, } - c.Flags().BoolVar(&reveal, "reveal", false, "Print api_secret unmasked (default: masked)") + c.Flags().BoolVar(&reveal, "reveal", false, "Print api_secret / session_token unmasked (default: masked)") return c } @@ -367,11 +367,11 @@ func newConfigSetCmd() *cobra.Command { For per-profile keys (api_key, api_secret, api_host) the target profile is: - the only profile, if exactly one is configured (no --profile required); - the value of --profile, if given (must already exist); - - otherwise an error: with 0 profiles, run urlbox auth first; + - otherwise an error: with 0 profiles, run urlbox login first; with 2+, --profile is required. Setting api_secret on a profile that already has a different secret -refuses unless --force is passed — the same guard ` + "`urlbox auth`" + ` uses. +refuses unless --force is passed. The default_profile key is top-level and always writes regardless of profile count.`, @@ -381,10 +381,10 @@ profile count.`, if !isSupportedKey(key) { return unknownKeyError(key) } - // Validate api_secret value through the same gate every secret- - // writing path uses. Rejects empty / whitespace / control chars. - // Round 6 class-fix. - if key == "api_secret" { + // Validate api_secret / session_token values through the same gate + // every secret-writing path uses. Rejects empty / whitespace / + // control chars. Round 6 class-fix. + if key == "api_secret" || key == "session_token" { validated, vErr := config.ValidateSecretValue(val) if vErr != nil { return vErr @@ -409,7 +409,7 @@ profile count.`, return output.NewCLIError( output.ErrUsage, "No profiles configured", - "Run `urlbox auth --api-secret ` to create one.", + "Run `urlbox login` to create one.", ) } if _, ok := c.Profiles[val]; !ok { @@ -447,16 +447,13 @@ profile count.`, return perr } profileName = name - // Overwrite guard for api_secret — same shape as `urlbox auth`'s - // guard (Round 1 S-C3). Without this, `config set api_secret X` - // was the unguarded back door past the auth-side protection. if key == "api_secret" && !force { existing := c.Profiles[name].APISecret if existing != "" && existing != val { return output.NewCLIError( output.ErrConflict, fmt.Sprintf("profile %q already has an API secret (%s); overwrite refused", name, maskSecret(existing)), - "Pass --force to overwrite, or use `urlbox config profile create ` for a separate profile. This guard mirrors `urlbox auth`'s protection.", + "Pass --force to overwrite, or use `urlbox config profile create ` for a separate profile.", ) } } @@ -475,7 +472,7 @@ profile count.`, // The raw value is still persisted on disk; only the // human/agent-facing echo is masked. displayVal := val - if key == "api_secret" && val != "" { + if (key == "api_secret" || key == "session_token") && val != "" { displayVal = maskSecret(val) } env := output.NewEnvelope( @@ -487,7 +484,7 @@ profile count.`, return writeEnvelope(cmd, env) }, } - c.Flags().BoolVar(&force, "force", false, "Overwrite an existing api_secret without confirmation (mirrors `urlbox auth --force`).") + c.Flags().BoolVar(&force, "force", false, "Overwrite an existing api_secret without confirmation.") return c } @@ -510,7 +507,7 @@ func resolveTargetProfile(cmd *cobra.Command, c *config.Config) (string, error) return "", output.NewCLIError( output.ErrUsage, "No profiles configured", - "Run `urlbox auth --api-secret ` to create one.", + "Run `urlbox login` to create one.", ) } flagProfile, _ := cmd.Root().PersistentFlags().GetString("profile") @@ -589,6 +586,12 @@ func readKey(c *config.Config, profile, key string) string { return p.APISecret case "api_host": return p.APIHost + case "session_token": + return p.SessionToken + case "active_org": + return p.ActiveOrg + case "active_project": + return p.ActiveProject } return "" } @@ -606,6 +609,12 @@ func writeKey(c *config.Config, profile, key, val string) { p.APISecret = val case "api_host": p.APIHost = val + case "session_token": + p.SessionToken = val + case "active_org": + p.ActiveOrg = val + case "active_project": + p.ActiveProject = val } c.Profiles[profile] = p } diff --git a/internal/cmd/config_session_keys_test.go b/internal/cmd/config_session_keys_test.go new file mode 100644 index 0000000..c0dc5c2 --- /dev/null +++ b/internal/cmd/config_session_keys_test.go @@ -0,0 +1,67 @@ +package cmd + +import ( + "bytes" + "testing" +) + +func TestConfigGetSessionTokenMasked(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + var stdout, stderr bytes.Buffer + code := Execute([]string{"config", "get", "session_token", "--output-format", "json"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit %d\n%s\n%s", code, stdout.String(), stderr.String()) + } + if bytes.Contains(stdout.Bytes(), []byte("sess_tok_compat_123456")) { + t.Fatalf("session token leaked unmasked: %s", stdout.String()) + } + if !bytes.Contains(stdout.Bytes(), []byte("sess")) { + t.Fatalf("masked value missing: %s", stdout.String()) + } +} + +func TestConfigGetSessionTokenReveal(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + var stdout, stderr bytes.Buffer + code := Execute([]string{"config", "get", "session_token", "--reveal", "--output-format", "json"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit %d\n%s", code, stderr.String()) + } + if !bytes.Contains(stdout.Bytes(), []byte("sess_tok_compat_123456")) { + t.Fatalf("--reveal must show the token: %s", stdout.String()) + } +} + +func TestConfigGetActiveOrgAndProject(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + for key, want := range map[string]string{"active_org": "org_compat", "active_project": "proj_compat"} { + var stdout, stderr bytes.Buffer + code := Execute([]string{"config", "get", key, "--output-format", "json"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("%s exit %d", key, code) + } + if !bytes.Contains(stdout.Bytes(), []byte(want)) { + t.Fatalf("%s: %s", key, stdout.String()) + } + } +} + +func TestConfigSetActiveProject(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + var stdout, stderr bytes.Buffer + code := Execute([]string{"config", "set", "active_project", "proj_other"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit %d\n%s", code, stderr.String()) + } + if p := readProfileMap(t, dir); p["active_project"] != "proj_other" { + t.Fatalf("profile: %#v", p) + } +} diff --git a/internal/cmd/config_test.go b/internal/cmd/config_test.go index 40104d7..eb92260 100644 --- a/internal/cmd/config_test.go +++ b/internal/cmd/config_test.go @@ -121,7 +121,7 @@ func TestConfigSet_NoProfiles_Errors(t *testing.T) { if env["error"] != "No profiles configured" { t.Errorf("error=%v", env["error"]) } - if got, want := env["hint"], "Run `urlbox auth --api-secret ` to create one."; got != want { + if got, want := env["hint"], "Run `urlbox login` to create one."; got != want { t.Errorf("hint=%v want=%v", got, want) } } @@ -255,7 +255,7 @@ func TestConfigSet_UnknownKey_Errors(t *testing.T) { if env["error"] != "Unknown config key: favorite_color" { t.Errorf("error=%v", env["error"]) } - if env["hint"] != "Supported: api_key, api_secret, api_host, default_profile" { + if env["hint"] != "Supported: api_key, api_secret, api_host, default_profile, session_token, active_org, active_project" { t.Errorf("hint=%v", env["hint"]) } } @@ -450,14 +450,11 @@ func must(t *testing.T, err error) { func TestConfigGet_APISecret_MaskedByDefault(t *testing.T) { dir := t.TempDir() t.Setenv("XDG_CONFIG_HOME", dir) + seedConfig(t, dir, map[string]config.Profile{ + "default": {APISecret: "sec_supersecretvalue12"}, + }) var stdout, stderr bytes.Buffer - if exit := cmd.Execute([]string{"auth", "--api-secret", "sec_supersecretvalue12"}, &stdout, &stderr); exit != 0 { - t.Fatalf("auth seed exit=%d stderr=%s", exit, stderr.String()) - } - - stdout.Reset() - stderr.Reset() exit := cmd.Execute([]string{"config", "get", "api_secret", "--output-format", "json"}, &stdout, &stderr) if exit != 0 { t.Fatalf("exit=%d stderr=%s", exit, stderr.String()) @@ -484,14 +481,11 @@ func TestConfigGet_APISecret_MaskedByDefault(t *testing.T) { func TestConfigGet_APISecret_RevealFlag(t *testing.T) { dir := t.TempDir() t.Setenv("XDG_CONFIG_HOME", dir) + seedConfig(t, dir, map[string]config.Profile{ + "default": {APISecret: "sec_reveal_target_12"}, + }) var stdout, stderr bytes.Buffer - if exit := cmd.Execute([]string{"auth", "--api-secret", "sec_reveal_target_12"}, &stdout, &stderr); exit != 0 { - t.Fatalf("auth seed exit=%d stderr=%s", exit, stderr.String()) - } - - stdout.Reset() - stderr.Reset() exit := cmd.Execute([]string{"config", "get", "api_secret", "--reveal", "--output-format", "json"}, &stdout, &stderr) if exit != 0 { t.Fatalf("exit=%d stderr=%s", exit, stderr.String()) @@ -511,13 +505,11 @@ func TestConfigGet_APISecret_RevealFlag(t *testing.T) { func TestConfigGet_APISecret_QuietMode_AlsoMasks(t *testing.T) { dir := t.TempDir() t.Setenv("XDG_CONFIG_HOME", dir) + seedConfig(t, dir, map[string]config.Profile{ + "default": {APISecret: "sec_quietmasked_xyz"}, + }) var stdout, stderr bytes.Buffer - if exit := cmd.Execute([]string{"auth", "--api-secret", "sec_quietmasked_xyz"}, &stdout, &stderr); exit != 0 { - t.Fatalf("auth seed exit=%d stderr=%s", exit, stderr.String()) - } - stdout.Reset() - stderr.Reset() exit := cmd.Execute([]string{"config", "get", "api_secret", "--output-format", "quiet"}, &stdout, &stderr) if exit != 0 { t.Fatalf("exit=%d stderr=%s", exit, stderr.String()) @@ -947,10 +939,9 @@ func TestConfigSet_APISecret_RejectsBadValues(t *testing.T) { } } -// TestConfigSet_APISecret_OverwriteGuard pins the parity with auth's -// guard: setting a DIFFERENT secret without --force should refuse, the -// same way `urlbox auth --api-secret ` refuses. Previously config -// set was the unguarded back door. +// TestConfigSet_APISecret_OverwriteGuard pins the overwrite guard: +// setting a DIFFERENT secret without --force should refuse. Previously +// config set was the unguarded back door. func TestConfigSet_APISecret_OverwriteGuard(t *testing.T) { dir := t.TempDir() t.Setenv("XDG_CONFIG_HOME", dir) diff --git a/internal/cmd/credbody.go b/internal/cmd/credbody.go new file mode 100644 index 0000000..51ffad5 --- /dev/null +++ b/internal/cmd/credbody.go @@ -0,0 +1,270 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/urlbox/urlbox-cli/internal/output" +) + +type storageFlags struct { + name, provider, bucket, region, endpoint, key, secret, cdnHost string + accountName, containerName, sasToken string + privateBucket, objectLock bool + set map[string]bool +} + +type llmFlags struct { + name, provider, apiKey, model, baseURL string + set map[string]bool +} + +var s3Providers = map[string]bool{ + "aws_s3": true, + "google_cloud_storage": true, + "cloudflare_r2": true, + "backblaze_b2": true, + "digitalocean_spaces": true, + "wasabi": true, + "custom": true, + "minio": true, +} + +var s3ProvidersNeedingEndpoint = map[string]bool{ + "google_cloud_storage": true, + "cloudflare_r2": true, + "backblaze_b2": true, + "digitalocean_spaces": true, + "wasabi": true, + "custom": true, + "minio": true, +} + +func parseJSONBody(jsonBody string) (map[string]any, *output.CLIError) { + body := map[string]any{} + if jsonBody == "" { + return body, nil + } + if err := json.Unmarshal([]byte(jsonBody), &body); err != nil { + return nil, output.NewCLIError(output.ErrUsage, "--json is not a valid JSON object: "+err.Error(), + `Example: --json '{"key":"value"}'.`) + } + return body, nil +} + +func buildStorageBody(jsonBody string, f storageFlags, requireCreate bool) (map[string]any, *output.CLIError) { //nolint:gocritic // storageFlags is the flag-value struct passed by value from the command layer + body, cliErr := parseJSONBody(jsonBody) + if cliErr != nil { + return nil, cliErr + } + if f.set["name"] { + body["name"] = f.name + } + if f.set["bucket"] { + body["bucket"] = f.bucket + } + if f.set["region"] { + body["region"] = f.region + } + if f.set["endpoint"] { + body["endpoint"] = f.endpoint + } + if f.set["key"] { + body["key"] = f.key + } + if f.set["secret"] { + body["secret"] = f.secret + } + if f.set["cdn-host"] { + body["cdnHost"] = f.cdnHost + } + if f.set["account-name"] { + body["accountName"] = f.accountName + } + if f.set["container-name"] { + body["containerName"] = f.containerName + } + if f.set["sas-token"] { + body["sasToken"] = f.sasToken + } + if f.set["private-bucket"] { + body["privateBucket"] = f.privateBucket + } + if f.set["object-lock"] { + body["objectLock"] = f.objectLock + } + if f.set["provider"] { + switch { + case f.provider == "azure": + body["type"] = "azure" + delete(body, "provider") + case s3Providers[f.provider]: + body["type"] = "s3" + body["provider"] = f.provider + default: + return nil, output.NewCLIError(output.ErrUsage, + fmt.Sprintf("unknown --provider %q (one of: aws_s3, google_cloud_storage, cloudflare_r2, backblaze_b2, digitalocean_spaces, wasabi, custom, azure)", f.provider), + "Pass a supported --provider value.") + } + } + if requireCreate { + if cliErr := validateStorageCreate(body); cliErr != nil { + return nil, cliErr + } + } + return body, nil +} + +func validateStorageCreate(body map[string]any) *output.CLIError { + usage := func(msg string) *output.CLIError { + return output.NewCLIError(output.ErrUsage, msg, "Pass the flags the message names.") + } + if _, ok := body["name"]; !ok { + return usage("--name is required") + } + credType := valueOrEmpty(body["type"]) + provider := valueOrEmpty(body["provider"]) + switch credType { + case "azure": + if valueOrEmpty(body["accountName"]) == "" || + valueOrEmpty(body["containerName"]) == "" || + valueOrEmpty(body["sasToken"]) == "" { + return usage("azure requires --account-name, --container-name, and --sas-token") + } + return nil + case "s3": + if valueOrEmpty(body["bucket"]) == "" || + valueOrEmpty(body["key"]) == "" || + valueOrEmpty(body["secret"]) == "" || + valueOrEmpty(body["region"]) == "" { + return usage("storage requires --bucket, --key, --secret, and --region") + } + if s3ProvidersNeedingEndpoint[provider] && valueOrEmpty(body["endpoint"]) == "" { + return usage("--endpoint is required for non-AWS providers") + } + if provider == "cloudflare_r2" && valueOrEmpty(body["cdnHost"]) == "" { + return usage("--cdn-host is required for this provider") + } + return nil + default: + return usage("--provider is required (one of: aws_s3, google_cloud_storage, cloudflare_r2, backblaze_b2, digitalocean_spaces, wasabi, custom, azure)") + } +} + +func buildProxyCreateBody(name string, urls []string) (map[string]any, *output.CLIError) { + if name == "" { + return nil, output.NewCLIError(output.ErrUsage, "--name is required", "Pass --name for the proxy pool.") + } + if len(urls) == 0 { + return nil, output.NewCLIError(output.ErrUsage, "at least one --url is required", "Pass one or more --url flags.") + } + proxies := make([]map[string]any, len(urls)) + for i, u := range urls { + proxies[i] = map[string]any{"url": u} + } + return map[string]any{"name": name, "proxies": proxies}, nil +} + +func mergeProxyUpdate(existing map[string]any, name string, urls []string, set map[string]bool) map[string]any { + finalName := valueOrEmpty(existing["name"]) + if set["name"] { + finalName = name + } + var proxies []map[string]any + if len(urls) > 0 { + for _, u := range urls { + proxies = append(proxies, map[string]any{"url": u}) + } + } else { + entries, _ := existing["proxies"].([]any) + for _, e := range entries { + entry, _ := e.(map[string]any) + if entry == nil { + continue + } + item := map[string]any{"url": valueOrEmpty(entry["url"])} + if n := valueOrEmpty(entry["name"]); n != "" { + item["name"] = n + } + proxies = append(proxies, item) + } + } + return map[string]any{"name": finalName, "proxies": proxies} +} + +func buildLlmBody(jsonBody string, f llmFlags, requireCreate bool) (map[string]any, *output.CLIError) { //nolint:gocritic // llmFlags is the flag-value struct passed by value from the command layer + body, cliErr := parseJSONBody(jsonBody) + if cliErr != nil { + return nil, cliErr + } + if f.set["name"] { + body["name"] = f.name + } + if f.set["provider"] { + body["provider"] = f.provider + } + if f.set["api-key"] { + body["apiKey"] = f.apiKey + } + if f.set["model"] { + body["model"] = f.model + } + if f.set["base-url"] { + body["baseUrl"] = f.baseURL + } + if requireCreate { + if _, ok := body["name"]; !ok { + return nil, output.NewCLIError(output.ErrUsage, "--name is required", "Pass --name for the credential.") + } + if _, ok := body["provider"]; !ok { + return nil, output.NewCLIError(output.ErrUsage, "--provider is required", "Pass --provider for the credential.") + } + if cliErr := validateLlmCreate(body); cliErr != nil { + return nil, cliErr + } + } else if _, ok := body["provider"]; ok { + return nil, output.NewCLIError(output.ErrUsage, + "provider is immutable after create — create a new credential instead", + "Create a new credential with the desired provider.") + } + return body, nil +} + +func validateLlmCreate(body map[string]any) *output.CLIError { + usage := func(msg string) *output.CLIError { + return output.NewCLIError(output.ErrUsage, msg, "Pass the fields the message names.") + } + missing := func(fields ...string) []string { + var out []string + for _, f := range fields { + if valueOrEmpty(body[f]) == "" { + out = append(out, f) + } + } + return out + } + provider := valueOrEmpty(body["provider"]) + switch provider { + case "amazon-bedrock": + if m := missing("awsRegion", "awsAccessKeyId", "awsSecretAccessKey"); len(m) > 0 { + return usage("amazon-bedrock requires " + strings.Join(m, ", ") + " — pass them via --json") + } + case "google-vertex": + if m := missing("gcpProject", "gcpLocation", "gcpServiceAccountJson"); len(m) > 0 { + return usage("google-vertex requires " + strings.Join(m, ", ") + " — pass them via --json") + } + case "azure": + if len(missing("apiKey")) > 0 { + return usage("--api-key is required") + } + if len(missing("azureResourceName")) > 0 && len(missing("baseUrl")) > 0 { + return usage("azure requires --base-url or azureResourceName (via --json)") + } + default: + if len(missing("apiKey")) > 0 { + return usage("--api-key is required") + } + } + return nil +} diff --git a/internal/cmd/credbody_test.go b/internal/cmd/credbody_test.go new file mode 100644 index 0000000..b5ec49a --- /dev/null +++ b/internal/cmd/credbody_test.go @@ -0,0 +1,115 @@ +package cmd + +import ( + "strings" + "testing" +) + +func TestBuildStorageBodyProviderDrivesType(t *testing.T) { + f := storageFlags{ + name: "s3 cred", provider: "cloudflare_r2", bucket: "b", region: "auto", key: "k", secret: "s", endpoint: "https://x.r2.cloudflarestorage.com", cdnHost: "cdn.example.com", + set: map[string]bool{"name": true, "provider": true, "bucket": true, "region": true, "key": true, "secret": true, "endpoint": true, "cdn-host": true}, + } + body, cliErr := buildStorageBody("", f, true) + if cliErr != nil { + t.Fatalf("unexpected: %v", cliErr) + } + if body["type"] != "s3" || body["provider"] != "cloudflare_r2" { + t.Fatalf("type/provider: %v %v", body["type"], body["provider"]) + } +} + +func TestBuildStorageBodyAzureDropsProvider(t *testing.T) { + f := storageFlags{ + name: "az", provider: "azure", accountName: "acct", containerName: "cont", sasToken: "sv=…", + set: map[string]bool{"name": true, "provider": true, "account-name": true, "container-name": true, "sas-token": true}, + } + body, cliErr := buildStorageBody("", f, true) + if cliErr != nil { + t.Fatalf("unexpected: %v", cliErr) + } + if body["type"] != "azure" { + t.Fatalf("type: %v", body["type"]) + } + if _, ok := body["provider"]; ok { + t.Fatalf("azure body must not carry provider") + } +} + +func TestBuildStorageBodyS3MissingFieldsErrors(t *testing.T) { + f := storageFlags{name: "x", provider: "aws_s3", set: map[string]bool{"name": true, "provider": true}} + _, cliErr := buildStorageBody("", f, true) + if cliErr == nil || !strings.Contains(cliErr.Message, "--bucket") { + t.Fatalf("want required-fields error, got %v", cliErr) + } +} + +func TestBuildStorageBodyUpdateSendsOnlyChanged(t *testing.T) { + f := storageFlags{region: "eu-west-1", set: map[string]bool{"region": true}} + body, cliErr := buildStorageBody("", f, false) + if cliErr != nil { + t.Fatalf("unexpected: %v", cliErr) + } + if len(body) != 1 || body["region"] != "eu-west-1" { + t.Fatalf("partial body wrong: %v", body) + } +} + +func TestMergeProxyUpdateCarriesExistingForward(t *testing.T) { + existing := map[string]any{"name": "eu pool", "proxies": []any{ + map[string]any{"id": "proxy_1", "name": "one", "url": "http://u:p@a:1"}, + }} + body := mergeProxyUpdate(existing, "", nil, map[string]bool{}) + if body["name"] != "eu pool" { + t.Fatalf("name not carried: %v", body["name"]) + } + proxies := body["proxies"].([]map[string]any) + if len(proxies) != 1 || proxies[0]["url"] != "http://u:p@a:1" || proxies[0]["name"] != "one" { + t.Fatalf("existing entries not carried: %v", proxies) + } +} + +func TestMergeProxyUpdateReplacesWholeListWhenURLsGiven(t *testing.T) { + existing := map[string]any{"name": "eu pool", "proxies": []any{map[string]any{"url": "http://old:1"}}} + body := mergeProxyUpdate(existing, "", []string{"http://new:1", "http://new:2"}, map[string]bool{}) + proxies := body["proxies"].([]map[string]any) + if len(proxies) != 2 || proxies[0]["url"] != "http://new:1" { + t.Fatalf("list not replaced: %v", proxies) + } +} + +func TestBuildProxyCreateBodyRequiresNameAndURL(t *testing.T) { + body, cliErr := buildProxyCreateBody("eu", []string{"http://a:1", "http://b:2"}) + if cliErr != nil { + t.Fatalf("unexpected: %v", cliErr) + } + if body["name"] != "eu" { + t.Fatalf("name: %v", body["name"]) + } + proxies := body["proxies"].([]map[string]any) + if len(proxies) != 2 || proxies[0]["url"] != "http://a:1" { + t.Fatalf("proxies: %v", proxies) + } + if _, cliErr := buildProxyCreateBody("", []string{"http://a:1"}); cliErr == nil || !strings.Contains(cliErr.Message, "--name") { + t.Fatalf("want name-required error, got %v", cliErr) + } + if _, cliErr := buildProxyCreateBody("eu", nil); cliErr == nil || !strings.Contains(cliErr.Message, "--url") { + t.Fatalf("want url-required error, got %v", cliErr) + } +} + +func TestBuildLlmBodyUpdateRejectsProvider(t *testing.T) { + f := llmFlags{provider: "openai", set: map[string]bool{"provider": true}} + _, cliErr := buildLlmBody("", f, false) + if cliErr == nil || !strings.Contains(cliErr.Message, "immutable") { + t.Fatalf("want provider-immutable error, got %v", cliErr) + } +} + +func TestBuildLlmBodyBedrockRequiresAwsFields(t *testing.T) { + f := llmFlags{name: "b", provider: "amazon-bedrock", set: map[string]bool{"name": true, "provider": true}} + _, cliErr := buildLlmBody("", f, true) + if cliErr == nil || !strings.Contains(cliErr.Message, "awsRegion") { + t.Fatalf("want bedrock required-fields error, got %v", cliErr) + } +} diff --git a/internal/cmd/credkind.go b/internal/cmd/credkind.go new file mode 100644 index 0000000..66be4cf --- /dev/null +++ b/internal/cmd/credkind.go @@ -0,0 +1,151 @@ +package cmd + +import ( + "context" + "fmt" + "strconv" + "strings" + + "github.com/spf13/cobra" + + "github.com/urlbox/urlbox-cli/internal/api" + "github.com/urlbox/urlbox-cli/internal/output" +) + +func createName(cmd *cobra.Command, args []string, flagName string) (string, *output.CLIError) { + var positional string + if len(args) == 1 { + positional = args[0] + } + if positional != "" && flagName != "" && positional != flagName { + return "", output.NewCLIError(output.ErrUsage, + fmt.Sprintf("name given twice and they differ: positional %q vs --name %q", positional, flagName), + "Pass the name once — as the positional argument or --name, not both.") + } + if positional != "" { + return positional, nil + } + return flagName, nil +} + +func assignedCount(m map[string]any) string { + items, _ := m["assignedProjectIds"].([]any) + return strconv.Itoa(len(items)) +} + +type credKind struct { + noun string + article string + group string + pathPart string + bodyKey string + prefix string + listPath string + listKey string + listCmd string + matchFields []string +} + +var ( + storageKind = credKind{noun: "storage credential", article: "a", group: "storage", pathPart: "storage-credential", bodyKey: "storageCredentialId", prefix: "store_", listPath: "storage-credentials", listKey: "storageCredentials", listCmd: "urlbox storage list", matchFields: []string{"bucket", "containerName"}} + proxyKind = credKind{noun: "proxy pool", article: "a", group: "proxy", pathPart: "proxy", bodyKey: "proxyId", prefix: "pool_", listPath: "proxies", listKey: "proxies", listCmd: "urlbox proxies list"} + llmKind = credKind{noun: "LLM credential", article: "an", group: "llm", pathPart: "llm-credential", bodyKey: "llmCredentialId", prefix: "llm_", listPath: "llm-credentials", listKey: "llmCredentials", listCmd: "urlbox llm list"} +) + +func (k credKind) orgListPath(org string) string { //nolint:gocritic // credKind is a value descriptor passed by value throughout + return "/v2/organisation/" + org + "/" + k.listPath +} + +func (k credKind) resourcePath(org, id string) string { //nolint:gocritic // credKind is a value descriptor passed by value throughout + return k.orgListPath(org) + "/" + id +} + +func (k credKind) assignPath(org, project string) string { //nolint:gocritic // credKind is a value descriptor passed by value throughout + return "/v2/organisation/" + org + "/projects/" + project + "/" + k.pathPart +} + +func resolveCredArg(items []map[string]any, arg string, kind credKind) (nameID, *output.CLIError) { //nolint:gocritic // credKind is a value descriptor passed by value throughout + if strings.HasPrefix(arg, kind.prefix) { + for _, m := range items { + if valueOrEmpty(m["id"]) == arg { + return nameID{ID: arg, Name: valueOrEmpty(m["name"])}, nil + } + } + return nameID{ID: arg}, nil + } + fields := append([]string{"name"}, kind.matchFields...) + var matches []nameID + for _, m := range items { + for _, f := range fields { + v := valueOrEmpty(m[f]) + if v != "" && strings.EqualFold(v, arg) { + matches = append(matches, nameID{ID: valueOrEmpty(m["id"]), Name: valueOrEmpty(m["name"])}) + break + } + } + } + switch len(matches) { + case 1: + return matches[0], nil + case 0: + return nameID{}, output.NewCLIError( + output.ErrNotFound, + fmt.Sprintf("no %s matching %q", kind.noun, arg), + fmt.Sprintf("List them with `%s`, then pass a name or id.", kind.listCmd), + ) + default: + ids := make([]string, len(matches)) + for i, m := range matches { + ids[i] = m.ID + } + return nameID{}, output.NewCLIError( + output.ErrValidation, + fmt.Sprintf("%q matches multiple %ss", arg, kind.noun), + "Use one of the ids instead: "+strings.Join(ids, ", "), + ) + } +} + +type assignOutcome struct { + Attempted bool + Project nameID + Err error +} + +func maybeAssignAfterCreate(ctx context.Context, client api.SessionAPI, org string, kind credKind, createdID, assignTo string, interactive bool) assignOutcome { //nolint:gocritic // credKind is a value descriptor passed by value throughout + assign := func(project nameID) assignOutcome { + if err := client.PutJSON(ctx, kind.assignPath(org, project.ID), map[string]string{kind.bodyKey: createdID}, nil); err != nil { + return assignOutcome{Attempted: true, Project: project, Err: err} + } + return assignOutcome{Attempted: true, Project: project} + } + if assignTo != "" { + projects, err := fetchList(ctx, client, "/v2/projects", "projects") + if err != nil { + return assignOutcome{Attempted: true, Err: err} + } + project, cliErr := resolveNameOrID(assignTo, "proj_", toNameIDs(projects), "project") + if cliErr != nil { + return assignOutcome{Attempted: true, Err: cliErr} + } + return assign(project) + } + if !interactive { + return assignOutcome{} + } + projects, err := fetchList(ctx, client, "/v2/projects", "projects") + if err != nil || len(projects) == 0 { + return assignOutcome{} + } + rows := toNameIDs(projects) + options := make([]string, 0, len(rows)+1) + options = append(options, "Don't assign") + for _, p := range rows { + options = append(options, p.Name) + } + idx, err := promptPick("Assign to a project?", options, 0) + if err != nil || idx == 0 { + return assignOutcome{} + } + return assign(rows[idx-1]) +} diff --git a/internal/cmd/credkind_test.go b/internal/cmd/credkind_test.go new file mode 100644 index 0000000..18de9cb --- /dev/null +++ b/internal/cmd/credkind_test.go @@ -0,0 +1,146 @@ +package cmd + +import ( + "context" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/urlbox/urlbox-cli/internal/output" +) + +func TestCreateNamePositionalOnly(t *testing.T) { + name, cliErr := createName(&cobra.Command{}, []string{"prod"}, "") + if cliErr != nil || name != "prod" { + t.Fatalf("positional-only: name=%q err=%v", name, cliErr) + } +} + +func TestCreateNameFlagOnly(t *testing.T) { + name, cliErr := createName(&cobra.Command{}, nil, "prod") + if cliErr != nil || name != "prod" { + t.Fatalf("flag-only: name=%q err=%v", name, cliErr) + } +} + +func TestCreateNameBothEqual(t *testing.T) { + name, cliErr := createName(&cobra.Command{}, []string{"prod"}, "prod") + if cliErr != nil || name != "prod" { + t.Fatalf("both-equal: name=%q err=%v", name, cliErr) + } +} + +func TestCreateNameBothDifferentErrors(t *testing.T) { + _, cliErr := createName(&cobra.Command{}, []string{"a"}, "b") + if cliErr == nil { + t.Fatalf("both-different must error") + } + if cliErr.Code != output.ErrUsage { + t.Fatalf("code = %q, want %q", cliErr.Code, output.ErrUsage) + } + if !strings.Contains(cliErr.Message, "a") || !strings.Contains(cliErr.Message, "b") { + t.Fatalf("message must name both %q and %q, got %q", "a", "b", cliErr.Message) + } + if cliErr.Hint == "" { + t.Fatalf("conflict error must carry a hint") + } +} + +func TestCreateNameNeitherReturnsEmpty(t *testing.T) { + name, cliErr := createName(&cobra.Command{}, nil, "") + if cliErr != nil || name != "" { + t.Fatalf("neither: name=%q err=%v", name, cliErr) + } +} + +func TestCredKindPaths(t *testing.T) { + if got := storageKind.assignPath("org_1", "proj_1"); got != "/v2/organisation/org_1/projects/proj_1/storage-credential" { + t.Fatalf("assignPath: %s", got) + } + if got := proxyKind.orgListPath("org_1"); got != "/v2/organisation/org_1/proxies" { + t.Fatalf("orgListPath: %s", got) + } + if got := llmKind.resourcePath("org_1", "llm_9"); got != "/v2/organisation/org_1/llm-credentials/llm_9" { + t.Fatalf("resourcePath: %s", got) + } +} + +func TestResolveCredArgMatchesBucketField(t *testing.T) { + items := []map[string]any{ + {"id": "store_1", "name": "prod", "bucket": "prod-bucket"}, + {"id": "store_2", "name": "staging", "bucket": "stg-bucket"}, + } + got, cliErr := resolveCredArg(items, "prod-bucket", storageKind) + if cliErr != nil || got.ID != "store_1" { + t.Fatalf("got %+v err %v", got, cliErr) + } +} + +func TestResolveCredArgAmbiguousListsIDs(t *testing.T) { + items := []map[string]any{ + {"id": "pool_1", "name": "eu"}, + {"id": "pool_2", "name": "EU"}, + } + _, cliErr := resolveCredArg(items, "eu", proxyKind) + if cliErr == nil || !strings.Contains(cliErr.Hint, "pool_1") || !strings.Contains(cliErr.Hint, "pool_2") { + t.Fatalf("want ambiguity error listing ids, got %v", cliErr) + } +} + +func TestResolveCredArgUnknownPrefixedIDPassesThrough(t *testing.T) { + got, cliErr := resolveCredArg(nil, "llm_unknown", llmKind) + if cliErr != nil || got.ID != "llm_unknown" { + t.Fatalf("got %+v err %v", got, cliErr) + } +} + +func TestMaybeAssignAfterCreateAssignTo(t *testing.T) { + f := &fakeSession{gets: map[string]string{ + "/v2/projects": `{"projects":[{"id":"proj_1","name":"Site"}]}`, + }} + outcome := maybeAssignAfterCreate(context.Background(), f, "org_1", storageKind, "store_9", "Site", false) + if !outcome.Attempted || outcome.Err != nil || outcome.Project.ID != "proj_1" { + t.Fatalf("outcome = %+v", outcome) + } + if len(f.puts) != 1 || f.puts[0].Path != "/v2/organisation/org_1/projects/proj_1/storage-credential" { + t.Fatalf("put not made to assign path: %+v", f.puts) + } + body, _ := f.puts[0].Body.(map[string]string) + if body["storageCredentialId"] != "store_9" { + t.Fatalf("put body = %+v", f.puts[0].Body) + } +} + +func TestMaybeAssignAfterCreateNonInteractiveSkips(t *testing.T) { + f := &fakeSession{gets: map[string]string{}} + outcome := maybeAssignAfterCreate(context.Background(), f, "org_1", proxyKind, "pool_9", "", false) + if outcome.Attempted || len(f.puts) != 0 { + t.Fatalf("non-interactive with no --assign-to must not attempt: %+v puts=%+v", outcome, f.puts) + } +} + +func TestMaybeAssignAfterCreateInteractiveNoProjectsSkips(t *testing.T) { + f := &fakeSession{gets: map[string]string{ + "/v2/projects": `{"projects":[]}`, + }} + outcome := maybeAssignAfterCreate(context.Background(), f, "org_1", llmKind, "llm_9", "", true) + if outcome.Attempted || len(f.puts) != 0 { + t.Fatalf("interactive with zero projects must skip: %+v puts=%+v", outcome, f.puts) + } +} + +func TestResolveCredArgNotFoundHint(t *testing.T) { + _, cliErr := resolveCredArg(nil, "nope", storageKind) + if cliErr == nil || !strings.Contains(cliErr.Hint, "urlbox storage list") { + t.Fatalf("want not-found hint naming the list command, got %v", cliErr) + } + _, cliErr = resolveCredArg(nil, "nope", proxyKind) + if cliErr == nil || !strings.Contains(cliErr.Hint, "urlbox proxies list") { + t.Fatalf("want proxies list hint, got %v", cliErr) + } + _, cliErr = resolveCredArg(nil, "nope", llmKind) + if cliErr == nil || !strings.Contains(cliErr.Hint, "urlbox llm list") { + t.Fatalf("want llm list hint, got %v", cliErr) + } +} diff --git a/internal/cmd/doctor.go b/internal/cmd/doctor.go index be51c98..5ec2d77 100644 --- a/internal/cmd/doctor.go +++ b/internal/cmd/doctor.go @@ -20,7 +20,8 @@ import ( "github.com/urlbox/urlbox-cli/internal/version" ) -// httpTimeout caps each individual HTTP check (api_reachable, auth). +// httpTimeout caps each individual HTTP check (api_reachable and the +// render_credential live probe). // Set to 10s rather than the original 5s to absorb cold-container // startup costs — Round 5 CI-1 reproed a false-fail on the first // invocation in a fresh container because DNS+TCP+TLS to api.urlbox.com @@ -42,13 +43,15 @@ func newDoctorCmd() *cobra.Command { Use: "doctor", Short: "Check installation, configuration, network, and credentials", Long: `Runs a series of self-checks: version, install method, config file, -API key, DNS resolution, API reachability, and credential validity. +session, active organisation and project, render credential, DNS +resolution, and API reachability. Exits non-zero if any check fails.`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { - // Sized to fit DNS + api_reachable + auth (each httpTimeout - // = 10s) plus a little headroom. Round 5 CI-1 bumped the - // per-check timeout to absorb cold-start latency. + // Sized to fit session + DNS + api_reachable + the + // render_credential probe (each httpTimeout = 10s) plus a + // little headroom. Round 5 CI-1 bumped the per-check timeout + // to absorb cold-start latency. ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) defer cancel() @@ -85,7 +88,12 @@ Exits non-zero if any check fails.`, ) } - checks := runDoctorChecks(ctx, resolved) + profile := config.Profile{} + if cfg != nil { + profile = cfg.Profiles[resolved.Profile] + } + + checks := runDoctorChecks(ctx, resolved, &profile) anyFail := false for _, c := range checks { if c.Status == "fail" { @@ -108,7 +116,7 @@ Exits non-zero if any check fails.`, map[string]any{"checks": checks, "status": overall}, summary, []output.Breadcrumb{ - {Action: "auth", Cmd: "urlbox auth --api-secret "}, + {Action: "login", Cmd: "urlbox login"}, }, ) // Reflect failure state on the envelope's `ok` field so JSON @@ -116,6 +124,7 @@ Exits non-zero if any check fails.`, if anyFail { env.OK = false } + env.SetTable([]string{"", "CHECK", "MESSAGE", "HINT"}, doctorCheckRows(checks), -1) formatFlag, _ := cmd.Root().PersistentFlags().GetString("output-format") jqExpr, _ := cmd.Root().PersistentFlags().GetString("jq") @@ -146,7 +155,7 @@ Exits non-zero if any check fails.`, // The contract: // 3 (auth) — credential / api_secret problem // 11 (network) — DNS / unreachable - // 10 (server) — last resort / non-2xx auth response + // 10 (server) — last resort / defensive default return &output.CLIError{ Code: doctorExitCode(checks), Message: summary, @@ -158,6 +167,25 @@ Exits non-zero if any check fails.`, } } +func doctorCheckRows(checks []Check) [][]string { + rows := make([][]string, len(checks)) + for i, c := range checks { + rows[i] = []string{checkStatusGlyph(c.Status), c.Name, c.Message, c.Hint} + } + return rows +} + +func checkStatusGlyph(status string) string { + switch status { + case "ok": + return "✓" + case "warn": + return "!" + default: + return "✗" + } +} + // doctorExitCode maps the worst failing check to the closed-set exit // code so agents reading the exit value can branch on the failure // category. Priority: auth-related credential issues first (3), then @@ -169,7 +197,7 @@ func doctorExitCode(checks []Check) output.ErrorCode { continue } switch c.Name { - case "api_secret", "auth": + case "session", "render_credential": hasAuth = true case "dns", "api_reachable": hasNetwork = true @@ -188,7 +216,7 @@ func doctorExitCode(checks []Check) output.ErrorCode { return output.ErrServer // unreachable when anyFail, defensive default } -func runDoctorChecks(ctx context.Context, resolved *config.Resolved) []Check { +func runDoctorChecks(ctx context.Context, resolved *config.Resolved, profile *config.Profile) []Check { host := api.ResolveAPIHost() if resolved != nil && resolved.APIHost != "" { host = resolved.APIHost @@ -197,10 +225,12 @@ func runDoctorChecks(ctx context.Context, resolved *config.Resolved) []Check { checkVersion(), checkInstallMethod(), checkConfigFile(), - checkAPISecret(resolved), + checkSession(ctx, host, profile), + checkActiveOrg(profile), + checkActiveProject(profile), + checkRenderCredential(ctx, host, resolved), checkDNS(ctx, host), checkAPIReachable(ctx, host), - checkAuth(ctx, host, resolved), } } @@ -234,128 +264,158 @@ func checkConfigFile() Check { Name: "config_file", Status: "warn", Message: "missing", - Hint: "Run `urlbox auth --api-secret ` to create", + Hint: loginHint, } } -func checkAPISecret(resolved *config.Resolved) Check { - if resolved == nil || resolved.APISecret == "" { +func checkSession(ctx context.Context, host string, profile *config.Profile) Check { + if profile.SessionToken == "" { return Check{ - Name: "api_secret", + Name: "session", Status: "fail", - Message: "no API secret found", - Hint: "Set URLBOX_API_SECRET or run `urlbox auth --api-secret-stdin` (`--api-secret ` is the legacy form and leaks via ps/shell history).", + Message: notLoggedInMsg, + Hint: loginHint, } } - // resolved.Source.APISecret is one of: flag / env / repo / profile. - src := resolved.Source.APISecret - if src == "profile" { - src = "file" // friendlier label (matches legacy behavior) + client := api.NewSessionClient(host, profile.SessionToken) + var session sessionResponse + if err := client.GetJSON(ctx, "/v1/auth/get-session", &session); err != nil { + return Check{ + Name: "session", + Status: "fail", + Message: "session token rejected", + Hint: loginHint, + } } - return Check{Name: "api_secret", Status: "ok", Message: "configured (" + src + ")"} + if session.User.Email == "" { + return Check{ + Name: "session", + Status: "fail", + Message: "session expired", + Hint: loginHint, + } + } + return Check{Name: "session", Status: "ok", Message: "signed in as " + session.User.Email} } -func checkDNS(ctx context.Context, host string) Check { - u, err := url.Parse(host) - if err != nil || u.Host == "" { - return Check{Name: "dns", Status: "warn", Message: "no host to check"} - } - if _, err := net.DefaultResolver.LookupHost(ctx, u.Hostname()); err != nil { +func checkActiveOrg(profile *config.Profile) Check { + if profile.ActiveOrg == "" { return Check{ - Name: "dns", + Name: "active_org", Status: "fail", - Message: err.Error(), - Hint: "Check network / DNS resolver", + Message: "no active organisation", + Hint: "Select one with `urlbox orgs select`.", } } - return Check{Name: "dns", Status: "ok", Message: u.Hostname() + " resolves"} + return Check{Name: "active_org", Status: "ok", Message: profile.ActiveOrg} } -func checkAPIReachable(ctx context.Context, host string) Check { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, host+"/", http.NoBody) - if err != nil { - return Check{Name: "api_reachable", Status: "fail", Message: err.Error()} - } - req.Header.Set("User-Agent", api.BuildUserAgent(version.Version)) - client := &http.Client{Timeout: httpTimeout} - resp, err := client.Do(req) - if err != nil { - return Check{Name: "api_reachable", Status: "fail", Message: err.Error()} - } - defer func() { _ = resp.Body.Close() }() - return Check{ - Name: "api_reachable", - Status: "ok", - Message: fmt.Sprintf("HTTP %d from %s", resp.StatusCode, host), +func checkActiveProject(profile *config.Profile) Check { + if profile.ActiveProject == "" { + return Check{ + Name: "active_project", + Status: "fail", + Message: "no active project", + Hint: "Select one with `urlbox projects select`.", + } } + return Check{Name: "active_project", Status: "ok", Message: profile.ActiveProject} } -func checkAuth(ctx context.Context, host string, resolved *config.Resolved) Check { - key := "" - if resolved != nil { - key = resolved.APISecret +func checkRenderCredential(ctx context.Context, host string, resolved *config.Resolved) Check { + if resolved == nil || resolved.APISecret == "" { + return Check{ + Name: "render_credential", + Status: "fail", + Message: "no render credential", + Hint: loginHint + " CI and headless environments can set URLBOX_API_SECRET instead.", + } } - if key == "" { - return Check{Name: "auth", Status: "warn", Message: "skipped (no API secret)"} + src := resolved.Source.APISecret + if src == "profile" { + src = "file" } + endpoint := host + "/v1/user/me" req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, http.NoBody) if err != nil { - return Check{Name: "auth", Status: "fail", Message: err.Error()} + return Check{Name: "render_credential", Status: "fail", Message: err.Error()} } - req.Header.Set("Authorization", "Bearer "+key) + req.Header.Set("Authorization", "Bearer "+resolved.APISecret) req.Header.Set("User-Agent", api.BuildUserAgent(version.Version)) client := &http.Client{Timeout: httpTimeout} resp, err := client.Do(req) if err != nil { - return Check{Name: "auth", Status: "fail", Message: err.Error()} + return Check{Name: "render_credential", Status: "fail", Message: err.Error()} } defer func() { _ = resp.Body.Close() }() - // Auth status is determined by the HTTP class: - // - 2xx → credentials accepted - // - 401/403 → explicit credential rejection - // - other 4xx (e.g. 400 "Api Key does not exist", 404, 429) → fail too - // - 5xx → warn; we can't tell whether creds are valid when the API is sick - // - // Round 4 H2: before this, only 401/403/5xx fell out of "ok". A real - // production 400 with body `{"error":{"code":"ApiKeyNotFound",...}}` - // silently reported "credentials valid", which let CI green-light a - // broken secret. switch { case resp.StatusCode >= 200 && resp.StatusCode < 300: - return Check{Name: "auth", Status: "ok", Message: "credentials valid"} + return Check{Name: "render_credential", Status: "ok", Message: "valid (" + src + ")"} case resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden: return Check{ - Name: "auth", + Name: "render_credential", Status: "fail", - Message: "credentials rejected", - Hint: "Re-run `urlbox auth --api-secret ` with a valid secret", + Message: "credential invalid", + Hint: loginHint, } case resp.StatusCode >= 500: return Check{ - Name: "auth", + Name: "render_credential", Status: "warn", Message: fmt.Sprintf("API returned %d", resp.StatusCode), } default: - // Non-2xx, non-401/403, non-5xx — typically a 400 ApiKeyNotFound - // or 404. Treat as a credential failure; surface the API's - // error message if it parses as the standard envelope. msg := fmt.Sprintf("API returned %d", resp.StatusCode) if apiMsg := readAPIErrorMessage(resp); apiMsg != "" { msg = fmt.Sprintf("API returned %d: %s", resp.StatusCode, apiMsg) } return Check{ - Name: "auth", + Name: "render_credential", Status: "fail", Message: msg, - Hint: "Re-run `urlbox auth --api-secret ` with a valid secret, or check `urlbox config get api_secret --reveal` against the dashboard.", + Hint: loginHint + " Or check `urlbox config get api_secret --reveal` against the dashboard.", } } } +func checkDNS(ctx context.Context, host string) Check { + u, err := url.Parse(host) + if err != nil || u.Host == "" { + return Check{Name: "dns", Status: "warn", Message: "no host to check"} + } + if _, err := net.DefaultResolver.LookupHost(ctx, u.Hostname()); err != nil { + return Check{ + Name: "dns", + Status: "fail", + Message: err.Error(), + Hint: "Check network / DNS resolver", + } + } + return Check{Name: "dns", Status: "ok", Message: u.Hostname() + " resolves"} +} + +func checkAPIReachable(ctx context.Context, host string) Check { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, host+"/", http.NoBody) + if err != nil { + return Check{Name: "api_reachable", Status: "fail", Message: err.Error()} + } + req.Header.Set("User-Agent", api.BuildUserAgent(version.Version)) + client := &http.Client{Timeout: httpTimeout} + resp, err := client.Do(req) + if err != nil { + return Check{Name: "api_reachable", Status: "fail", Message: err.Error()} + } + defer func() { _ = resp.Body.Close() }() + return Check{ + Name: "api_reachable", + Status: "ok", + Message: fmt.Sprintf("HTTP %d from %s", resp.StatusCode, host), + } +} + // readAPIErrorMessage best-effort extracts `error.message` from the Urlbox // API's standard error envelope: {"error":{"code":"...","message":"..."}}. // Returns "" on any parse / shape failure — callers must fall back to a diff --git a/internal/cmd/doctor_test.go b/internal/cmd/doctor_test.go index 97cd79a..3acfcf6 100644 --- a/internal/cmd/doctor_test.go +++ b/internal/cmd/doctor_test.go @@ -44,15 +44,47 @@ func extractCheck(t *testing.T, env map[string]any, name string) map[string]any return nil } +func hasCheck(t *testing.T, env map[string]any, name string) bool { + t.Helper() + data, ok := env["data"].(map[string]any) + if !ok { + t.Fatalf("data not a map: %v", env["data"]) + } + checks, ok := data["checks"].([]any) + if !ok { + t.Fatalf("checks not an array: %v", data["checks"]) + } + for _, c := range checks { + m, _ := c.(map[string]any) + if m["name"] == name { + return true + } + } + return false +} + func TestDoctor_AllChecksPass_Exit0(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/v1/auth/get-session" { + _, _ = w.Write([]byte(`{"user":{"email":"a@urlbox.com"},"session":{"activeOrganizationPublicId":"org_acme"}}`)) + return + } w.WriteHeader(http.StatusOK) })) defer srv.Close() - t.Setenv("XDG_CONFIG_HOME", t.TempDir()) - t.Setenv("URLBOX_API_SECRET", "sec_test") + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir) + t.Setenv("URLBOX_API_SECRET", "") t.Setenv("URLBOX_API_HOST", srv.URL) + seedConfig(t, dir, map[string]config.Profile{ + "default": { + APISecret: "sec_test", + SessionToken: "sess_tok", + ActiveOrg: "org_acme", + ActiveProject: "proj_1", + }, + }) env, exit, _, _ := runDoctor(t) if exit != 0 { @@ -62,28 +94,68 @@ func TestDoctor_AllChecksPass_Exit0(t *testing.T) { t.Fatalf("ok=%v", env["ok"]) } - // Validate at least these checks are present - for _, name := range []string{"version", "install_method", "config_file", "api_secret", "dns", "api_reachable", "auth"} { + for _, name := range []string{"version", "install_method", "config_file", "session", "active_org", "active_project", "render_credential", "dns", "api_reachable"} { _ = extractCheck(t, env, name) } + + rc := extractCheck(t, env, "render_credential") + if rc["status"] != "ok" { + t.Fatalf("render_credential status = %v want ok", rc["status"]) + } + if msg, _ := rc["message"].(string); msg != "valid (file)" { + t.Fatalf("render_credential message = %q, want \"valid (file)\"", msg) + } } -func TestDoctor_NoAPISecret_FailsAPISecretCheck(t *testing.T) { +func TestDoctor_NoRenderCredential_FailsRenderCredentialCheck(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", t.TempDir()) t.Setenv("URLBOX_API_SECRET", "") env, exit, _, _ := runDoctor(t) - c := extractCheck(t, env, "api_secret") + c := extractCheck(t, env, "render_credential") if c["status"] != "fail" { - t.Fatalf("api_secret status = %v want fail", c["status"]) + t.Fatalf("render_credential status = %v want fail", c["status"]) + } + if hint, _ := c["hint"].(string); !strings.Contains(hint, "urlbox login") { + t.Fatalf("render_credential hint should point at login; got %q", hint) } if exit == 0 { t.Fatal("expected non-zero exit when checks fail") } } -func TestDoctor_AuthFailure_FailsAuthCheck(t *testing.T) { +func TestDoctor_LoggedOut_FailsSessionCheck(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv("URLBOX_API_SECRET", "") + + env, exit, _, _ := runDoctor(t) + + c := extractCheck(t, env, "session") + if c["status"] != "fail" { + t.Fatalf("session status = %v want fail", c["status"]) + } + if hint, _ := c["hint"].(string); hint != "Run `urlbox login` to sign in." { + t.Fatalf("session hint = %q, want the unified login hint", hint) + } + for _, name := range []string{"active_org", "active_project"} { + if extractCheck(t, env, name)["status"] != "fail" { + t.Fatalf("%s should fail when logged out", name) + } + } + if hasCheck(t, env, "auth") { + t.Fatal("auth check name should no longer appear — folded into render_credential") + } + if exit == 0 { + t.Fatal("expected non-zero exit when logged out") + } +} + +// TestDoctor_CredentialRejected_FailsRenderCredential pins the folded +// behaviour: a present secret the API rejects (401-class) marks +// render_credential fail with "credential invalid" and the pinned login +// hint, and drives the auth-class exit code (3). +func TestDoctor_CredentialRejected_FailsRenderCredential(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusUnauthorized) })) @@ -95,25 +167,66 @@ func TestDoctor_AuthFailure_FailsAuthCheck(t *testing.T) { env, exit, _, _ := runDoctor(t) - c := extractCheck(t, env, "auth") + c := extractCheck(t, env, "render_credential") if c["status"] != "fail" { - t.Fatalf("auth check status = %v want fail", c["status"]) + t.Fatalf("render_credential status = %v want fail", c["status"]) } - if !strings.Contains(c["message"].(string), "credentials") && - !strings.Contains(c["hint"].(string), "auth") { - t.Fatalf("auth check message should reference credentials: %v", c) + if msg, _ := c["message"].(string); msg != "credential invalid" { + t.Fatalf("render_credential message = %q, want \"credential invalid\"", msg) } - if exit == 0 { - t.Fatal("expected non-zero exit on auth failure") + if hint, _ := c["hint"].(string); hint != "Run `urlbox login` to sign in." { + t.Fatalf("render_credential hint = %q, want the pinned login hint", hint) + } + if exit != 3 { + t.Fatalf("exit = %d, want 3 (auth class)", exit) + } +} + +// TestDoctor_CredentialValid_PassesRenderCredential pins the positive +// fold: a present secret the API accepts (2xx) marks render_credential ✓ +// with the source-tagged "valid (env)" message. +func TestDoctor_CredentialValid_PassesRenderCredential(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/v1/auth/get-session" { + _, _ = w.Write([]byte(`{"user":{"email":"a@urlbox.com"},"session":{"activeOrganizationPublicId":"org_acme"}}`)) + return + } + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir) + t.Setenv("URLBOX_API_SECRET", "sec_good") + t.Setenv("URLBOX_API_HOST", srv.URL) + seedConfig(t, dir, map[string]config.Profile{ + "default": { + SessionToken: "sess_tok", + ActiveOrg: "org_acme", + ActiveProject: "proj_1", + }, + }) + + env, exit, _, _ := runDoctor(t) + if exit != 0 { + t.Fatalf("exit=%d env=%v", exit, env) + } + c := extractCheck(t, env, "render_credential") + if c["status"] != "ok" { + t.Fatalf("render_credential status = %v want ok", c["status"]) + } + if msg, _ := c["message"].(string); msg != "valid (env)" { + t.Fatalf("render_credential message = %q, want \"valid (env)\"", msg) } } -// TestDoctor_AuthBadRequest_FailsAuthCheck pins Round 4 H2: the auth check -// previously only treated 401/403/5xx as failure. A real-world 400 from -// /v1/user/me with body {"error":{"code":"ApiKeyNotFound",...}} fell into -// the default arm and was reported as "credentials valid" — a critical -// false-positive that let CI green-light a broken secret. -func TestDoctor_AuthBadRequest_FailsAuthCheck(t *testing.T) { +// TestDoctor_CredentialBadRequest_FailsRenderCredential pins Round 4 H2 +// through the fold: the probe previously only treated 401/403/5xx as +// failure. A real-world 400 from /v1/user/me with body +// {"error":{"code":"ApiKeyNotFound",...}} fell into the default arm and +// was reported valid — a critical false-positive that let CI green-light +// a broken secret. +func TestDoctor_CredentialBadRequest_FailsRenderCredential(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusBadRequest) _, _ = w.Write([]byte(`{"error":{"code":"ApiKeyNotFound","message":"Api Key does not exist"}}`)) @@ -126,18 +239,19 @@ func TestDoctor_AuthBadRequest_FailsAuthCheck(t *testing.T) { env, exit, _, _ := runDoctor(t) - c := extractCheck(t, env, "auth") + c := extractCheck(t, env, "render_credential") if c["status"] != "fail" { - t.Fatalf("auth check on HTTP 400 should fail; got %v (full check: %v)", c["status"], c) + t.Fatalf("render_credential on HTTP 400 should fail; got %v (full check: %v)", c["status"], c) } if exit == 0 { - t.Fatal("expected non-zero exit when auth check fails") + t.Fatal("expected non-zero exit when render_credential fails") } } -// TestDoctor_AuthNotFound_FailsAuthCheck pins another non-2xx, non-401/403 -// case — 404 on /v1/user/me should not be "credentials valid" either. -func TestDoctor_AuthNotFound_FailsAuthCheck(t *testing.T) { +// TestDoctor_CredentialNotFound_FailsRenderCredential pins another +// non-2xx, non-401/403 case — 404 on /v1/user/me should not be valid +// either. +func TestDoctor_CredentialNotFound_FailsRenderCredential(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNotFound) })) @@ -149,12 +263,12 @@ func TestDoctor_AuthNotFound_FailsAuthCheck(t *testing.T) { env, exit, _, _ := runDoctor(t) - c := extractCheck(t, env, "auth") + c := extractCheck(t, env, "render_credential") if c["status"] != "fail" { - t.Fatalf("auth check on HTTP 404 should fail; got %v", c["status"]) + t.Fatalf("render_credential on HTTP 404 should fail; got %v", c["status"]) } if exit == 0 { - t.Fatal("expected non-zero exit when auth check fails") + t.Fatal("expected non-zero exit when render_credential fails") } } @@ -249,6 +363,10 @@ func TestDoctor_HonorsProfileFlag_ValidTargetsThatProfile(t *testing.T) { if r.URL.Path == "/v1/user/me" { seenAuth = r.Header.Get("Authorization") } + if r.URL.Path == "/v1/auth/get-session" { + _, _ = w.Write([]byte(`{"user":{"email":"a@urlbox.com"},"session":{"activeOrganizationPublicId":"org_acme"}}`)) + return + } w.WriteHeader(http.StatusOK) })) defer srv.Close() @@ -259,7 +377,12 @@ func TestDoctor_HonorsProfileFlag_ValidTargetsThatProfile(t *testing.T) { t.Setenv("URLBOX_API_HOST", srv.URL) seedConfig(t, dir, map[string]config.Profile{ "default": {APISecret: "sec_default_xx"}, - "work": {APISecret: "sec_work_yy"}, + "work": { + APISecret: "sec_work_yy", + SessionToken: "sess_work", + ActiveOrg: "org_acme", + ActiveProject: "proj_1", + }, }) env, exit, _, _ := runDoctor(t, "--profile", "work") diff --git a/internal/cmd/error_hints_test.go b/internal/cmd/error_hints_test.go index 2405a04..9eae025 100644 --- a/internal/cmd/error_hints_test.go +++ b/internal/cmd/error_hints_test.go @@ -91,11 +91,11 @@ func TestNoEmptyCLIErrorHints(t *testing.T) { // // - "urlbox config show" — no such subcommand. Closest real: `config get`, // `config path`, `config profile list`. -// - "urlbox auth --api-key" — flag was removed in v0.6.0; auth takes -// `--api-secret`. Pinned removed in auth_test.go. +// - "urlbox auth" — the auth command was removed in favour of `urlbox +// login`; no production hint may point users at it. var ghostCommandSubstrings = []string{ "urlbox config show", - "urlbox auth --api-key", + "urlbox auth", } // TestNoGhostCommandsInHints walks production .go files and fails when diff --git a/internal/cmd/link.go b/internal/cmd/link.go index 3ddefca..c9a8b55 100644 --- a/internal/cmd/link.go +++ b/internal/cmd/link.go @@ -170,9 +170,6 @@ func runLink(cmd *cobra.Command, args []string, f *linkFlags) error { } if resolved.APIKey == "" { - // Round 5 First-1: the previous hint referenced `urlbox auth` - // which doesn't take --api-key — sent users down a dead end. - // Both surviving suggestions actually set api_key. return output.NewCLIError( output.ErrAuth, "Missing publishable API key", @@ -183,7 +180,7 @@ func runLink(cmd *cobra.Command, args []string, f *linkFlags) error { return output.NewCLIError( output.ErrAuth, "Missing API secret", - "Pass --api-secret, set URLBOX_API_SECRET, or run `urlbox auth`. "+ + "Pass --api-secret, set URLBOX_API_SECRET, or run `urlbox login`. "+ "`urlbox link` cannot sign without the secret.", ) } @@ -223,6 +220,11 @@ func runLink(cmd *cobra.Command, args []string, f *linkFlags) error { }, } env := output.NewEnvelope("link", data, summary, breadcrumbs) + env.SetKV([][2]string{ + {"URL", signed}, + {"Format", formatUsed}, + {"Key", resolved.APIKey}, + }) return writeEnvelope(cmd, env) } diff --git a/internal/cmd/link_test.go b/internal/cmd/link_test.go index 6e00e64..84d8b34 100644 --- a/internal/cmd/link_test.go +++ b/internal/cmd/link_test.go @@ -391,9 +391,6 @@ func TestLink_MissingAPIKey_AuthError(t *testing.T) { if env["error"] != "Missing publishable API key" { t.Errorf("error=%q", env["error"]) } - // Round 5 First-1: hint no longer references `urlbox auth` (dead end — - // auth doesn't take --api-key). New hint points at config set / config - // profile create. Verified by TestLink_MissingAPIKey_HintDoesNotMisleadUserToAuth. if !strings.Contains(env["hint"].(string), "config set api_key") && !strings.Contains(env["hint"].(string), "config profile create") { t.Errorf("hint should point at config set / config profile create; got: %s", env["hint"]) } @@ -432,8 +429,8 @@ func TestLink_MissingAPISecret_AuthError_PinnedEnvelope(t *testing.T) { t.Errorf("error mismatch: %q", env["error"]) } hint := env["hint"].(string) - if !strings.Contains(hint, "urlbox auth") { - t.Errorf("hint should mention urlbox auth; got: %s", hint) + if !strings.Contains(hint, "urlbox login") { + t.Errorf("hint should mention urlbox login; got: %s", hint) } if !strings.Contains(hint, "secret") { t.Errorf("hint should mention secret; got: %s", hint) @@ -546,12 +543,10 @@ func TestLink_PositionalAndURLFlag_FlagWins(t *testing.T) { } } -// TestLink_MissingAPIKey_HintDoesNotMisleadUserToAuth pins Round 5 -// First-1: the "Missing publishable API key" hint used to say "run -// urlbox auth" but auth doesn't take an --api-key flag — running it -// won't fix the error. Hint must point at a command that ACTUALLY -// sets the api_key. -func TestLink_MissingAPIKey_HintDoesNotMisleadUserToAuth(t *testing.T) { +// TestLink_MissingAPIKey_HintPointsAtRealCommand pins that the "Missing +// publishable API key" hint points at a command that ACTUALLY sets the +// api_key, rather than a dead end. +func TestLink_MissingAPIKey_HintPointsAtRealCommand(t *testing.T) { dir := t.TempDir() t.Setenv("XDG_CONFIG_HOME", dir) seedConfig(t, dir, map[string]config.Profile{ @@ -566,12 +561,6 @@ func TestLink_MissingAPIKey_HintDoesNotMisleadUserToAuth(t *testing.T) { var env map[string]any _ = json.Unmarshal(stdout.Bytes(), &env) hint, _ := env["hint"].(string) - // The misleading "run urlbox auth" reference must be gone — auth - // has no --api-key flag, so the hint led users into a dead end. - if strings.Contains(hint, "urlbox auth") { - t.Errorf("hint must NOT reference `urlbox auth` (it has no --api-key); got %q", hint) - } - // The hint must point at a command that actually does set api_key. if !strings.Contains(hint, "config set") && !strings.Contains(hint, "config profile create") { t.Errorf("hint should point at `config set api_key` or `config profile create --api-key`; got %q", hint) } diff --git a/internal/cmd/llm.go b/internal/cmd/llm.go new file mode 100644 index 0000000..71e9970 --- /dev/null +++ b/internal/cmd/llm.go @@ -0,0 +1,407 @@ +package cmd + +import ( + "context" + "fmt" + "strconv" + "strings" + + "github.com/spf13/cobra" + + "github.com/urlbox/urlbox-cli/internal/output" +) + +func newLlmCmd() *cobra.Command { + c := &cobra.Command{ + Use: "llm", + Short: "Manage org LLM credentials", + Long: `Manage the active organisation's LLM credentials. + +LLM credentials are owned by the organisation and assigned to projects. +Create one once, then assign it to any project's renders. + +Secrets are masked on display — pass --reveal for full values (JSON output +always includes them in full). + +Examples: + urlbox llm list + urlbox llm show openai-prod --reveal + urlbox llm create --name openai --provider openai --api-key sk-… --assign-to my-project + urlbox llm update openai --model gpt-5-mini + urlbox llm test openai + urlbox llm models openai + urlbox llm delete openai`, + } + list := &cobra.Command{ + Use: "list", + Short: "List the organisation's LLM credentials", + Args: cobra.NoArgs, + RunE: runLlmList, + } + var showReveal bool + show := &cobra.Command{ + Use: "show ", + Short: "Show one LLM credential", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runLlmShow(cmd, args, showReveal) + }, + } + show.Flags().BoolVar(&showReveal, "reveal", false, "Print secrets unmasked (default: masked)") + var ( + createFlags llmFlags + createJSON string + createAssignTo string + ) + create := &cobra.Command{ + Use: "create ", + Short: "Create an LLM credential", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runLlmCreate(cmd, args, createJSON, createFlags, createAssignTo) + }, + } + bindLlmFlags(create, &createFlags) + create.Flags().StringVar(&createJSON, "json", "", "Full payload as a JSON object (typed flags win)") + create.Flags().StringVar(&createAssignTo, "assign-to", "", "Assign to this project after create") + var ( + updateFlags llmFlags + updateJSON string + ) + update := &cobra.Command{ + Use: "update ", + Short: "Update an LLM credential (only the flags you pass)", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runLlmUpdate(cmd, args, updateJSON, updateFlags) + }, + } + bindLlmFlags(update, &updateFlags) + update.Flags().StringVar(&updateJSON, "json", "", "Fields to update as a JSON object (typed flags win)") + var deleteYes bool + del := &cobra.Command{ + Use: "delete ", + Short: "Delete an LLM credential", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runLlmDelete(cmd, args, deleteYes) + }, + } + del.Flags().BoolVar(&deleteYes, "yes", false, "Skip the retype-to-confirm prompt") + test := &cobra.Command{ + Use: "test ", + Short: "Test the stored credential's connection", + Args: cobra.ExactArgs(1), + RunE: runLlmTest, + } + models := &cobra.Command{ + Use: "models ", + Short: "List the provider's model ids", + Args: cobra.ExactArgs(1), + RunE: runLlmModels, + } + c.AddCommand(list, show, create, update, del, test, models) + attachSessionRetryFlags(c) + return c +} + +func bindLlmFlags(cmd *cobra.Command, f *llmFlags) { + cmd.Flags().StringVar(&f.name, "name", "", "Credential name") + cmd.Flags().StringVar(&f.provider, "provider", "", "LLM provider (openai|anthropic|azure|amazon-bedrock|google-vertex|…)") + cmd.Flags().StringVar(&f.apiKey, "api-key", "", "Provider API key") + cmd.Flags().StringVar(&f.model, "model", "", "Default model") + cmd.Flags().StringVar(&f.baseURL, "base-url", "", "Custom base URL") +} + +func llmFlagsChanged(cmd *cobra.Command, f *llmFlags) { + f.set = map[string]bool{} + for _, name := range []string{"name", "provider", "api-key", "model", "base-url"} { + if cmd.Flags().Changed(name) { + f.set[name] = true + } + } +} + +func llmListRows(creds []map[string]any) [][]string { + rows := make([][]string, len(creds)) + for i, c := range creds { + rows[i] = []string{ + valueOrEmpty(c["id"]), valueOrEmpty(c["name"]), + valueOrEmpty(c["provider"]), valueOrEmpty(c["model"]), assignedCount(c), + } + } + return rows +} + +func llmDetailPairs(c map[string]any, reveal bool) [][2]string { + pairs := [][2]string{ + {"ID", valueOrEmpty(c["id"])}, + {"Name", valueOrEmpty(c["name"])}, + {"Provider", valueOrEmpty(c["provider"])}, + } + if model := valueOrEmpty(c["model"]); model != "" { + pairs = append(pairs, [2]string{"Model", model}) + } + if baseURL := valueOrEmpty(c["baseUrl"]); baseURL != "" { + pairs = append(pairs, [2]string{"Base URL", baseURL}) + } + secretFields := []struct{ label, key string }{ + {"API key", "apiKey"}, + {"AWS access key id", "awsAccessKeyId"}, + {"AWS secret access key", "awsSecretAccessKey"}, + {"AWS session token", "awsSessionToken"}, + {"GCP service account", "gcpServiceAccountJson"}, + } + for _, s := range secretFields { + if v := valueOrEmpty(c[s.key]); v != "" { + pairs = append(pairs, [2]string{s.label, revealOrMask(v, reveal)}) + } + } + pairs = append(pairs, + [2]string{"Assigned projects", assignedCount(c)}, + [2]string{"Created", valueOrEmpty(c["createdAt"])}, + ) + return pairs +} + +func llmTestMessage(result map[string]any) (string, bool) { + if ok, _ := result["ok"].(bool); ok { + return "Connection OK", true + } + if reason := valueOrEmpty(result["error"]); reason != "" { + return "Connection failed: " + reason, false + } + return "Connection failed", false +} + +func runLlmList(cmd *cobra.Command, _ []string) error { + sess, cliErr := loadSession(cmd) + if cliErr != nil { + return cliErr + } + org, orgErr := requireActiveOrg(sess) + if orgErr != nil { + return orgErr + } + ctx := context.Background() + items, err := fetchList(ctx, sess.Client, llmKind.orgListPath(org), llmKind.listKey) + if err != nil { + return asCLIError(err) + } + env := output.NewEnvelope("llm list", + map[string]any{"llmCredentials": items}, + fmt.Sprintf("%d LLM credentials", len(items)), nil) + env.SetTable([]string{"ID", "NAME", "PROVIDER", "MODEL", "ASSIGNED"}, llmListRows(items), -1) + return writeEnvelopeWithQuietData(cmd, env, strconv.Itoa(len(items))) +} + +func runLlmShow(cmd *cobra.Command, args []string, reveal bool) error { + sess, cliErr := loadSession(cmd) + if cliErr != nil { + return cliErr + } + org, orgErr := requireActiveOrg(sess) + if orgErr != nil { + return orgErr + } + ctx := context.Background() + items, err := fetchList(ctx, sess.Client, llmKind.orgListPath(org), llmKind.listKey) + if err != nil { + return asCLIError(err) + } + resolved, resErr := resolveCredArg(items, args[0], llmKind) + if resErr != nil { + return resErr + } + var detail map[string]any + if err := sess.Client.GetJSON(ctx, llmKind.resourcePath(org, resolved.ID), &detail); err != nil { + return asCLIError(err) + } + name := valueOrEmpty(detail["name"]) + if name == "" { + name = valueOrEmpty(detail["id"]) + } + env := output.NewEnvelope("llm show", detail, + fmt.Sprintf("LLM credential %s", name), nil) + env.SetKV(llmDetailPairs(detail, reveal)) + return writeEnvelopeWithQuietData(cmd, env, valueOrEmpty(detail["id"])) +} + +func runLlmCreate(cmd *cobra.Command, args []string, jsonBody string, flags llmFlags, assignTo string) error { //nolint:gocritic // llmFlags is the flag-value struct passed by value from the command layer + sess, cliErr := loadSession(cmd) + if cliErr != nil { + return cliErr + } + org, orgErr := requireActiveOrg(sess) + if orgErr != nil { + return orgErr + } + llmFlagsChanged(cmd, &flags) + resolvedName, nameErr := createName(cmd, args, flags.name) + if nameErr != nil { + return nameErr + } + if resolvedName != "" { + flags.name = resolvedName + flags.set["name"] = true + } + body, bodyErr := buildLlmBody(jsonBody, flags, true) + if bodyErr != nil { + return bodyErr + } + ctx := context.Background() + var created map[string]any + if err := sess.Client.PostJSON(ctx, llmKind.orgListPath(org), body, &created); err != nil { + return asCLIError(err) + } + createdID := valueOrEmpty(created["id"]) + name := valueOrEmpty(created["name"]) + if name == "" { + name = createdID + } + outcome := maybeAssignAfterCreate(ctx, sess.Client, org, llmKind, createdID, assignTo, interactiveText(cmd)) + return reportCredCreate(cmd, "llm create", llmKind, created, name, createdID, outcome) +} + +func runLlmUpdate(cmd *cobra.Command, args []string, jsonBody string, flags llmFlags) error { //nolint:gocritic // llmFlags is the flag-value struct passed by value from the command layer + sess, cliErr := loadSession(cmd) + if cliErr != nil { + return cliErr + } + org, orgErr := requireActiveOrg(sess) + if orgErr != nil { + return orgErr + } + llmFlagsChanged(cmd, &flags) + body, bodyErr := buildLlmBody(jsonBody, flags, false) + if bodyErr != nil { + return bodyErr + } + if len(body) == 0 { + return output.NewCLIError(output.ErrUsage, + "nothing to update — pass at least one field flag or --json", + "Pass a field flag (e.g. --model) or --json.") + } + ctx := context.Background() + resolved, resErr := resolveLlmArg(ctx, sess, org, args[0]) + if resErr != nil { + return resErr + } + var updated map[string]any + if err := sess.Client.PatchJSON(ctx, llmKind.resourcePath(org, resolved.ID), body, &updated); err != nil { + return asCLIError(err) + } + name := resolved.Name + if name == "" { + name = resolved.ID + } + env := output.NewEnvelope("llm update", updated, + fmt.Sprintf("Updated LLM credential %s", name), nil) + return writeEnvelopeWithQuietData(cmd, env, resolved.ID) +} + +func runLlmDelete(cmd *cobra.Command, args []string, yes bool) error { + sess, cliErr := loadSession(cmd) + if cliErr != nil { + return cliErr + } + org, orgErr := requireActiveOrg(sess) + if orgErr != nil { + return orgErr + } + ctx := context.Background() + items, err := fetchList(ctx, sess.Client, llmKind.orgListPath(org), llmKind.listKey) + if err != nil { + return asCLIError(err) + } + resolved, resErr := resolveCredArg(items, args[0], llmKind) + if resErr != nil { + return resErr + } + name := resolved.Name + if name == "" { + name = resolved.ID + } + if !yes { + if err := confirmDeletion(name); err != nil { + return err + } + } + if err := sess.Client.DeleteJSON(ctx, llmKind.resourcePath(org, resolved.ID), nil); err != nil { + return asCLIError(err) + } + env := output.NewEnvelope("llm delete", + map[string]any{"deleted": resolved.ID}, + fmt.Sprintf("Deleted LLM credential %s", name), nil) + return writeEnvelopeWithQuietData(cmd, env, resolved.ID) +} + +func runLlmTest(cmd *cobra.Command, args []string) error { + sess, cliErr := loadSession(cmd) + if cliErr != nil { + return cliErr + } + org, orgErr := requireActiveOrg(sess) + if orgErr != nil { + return orgErr + } + ctx := context.Background() + resolved, resErr := resolveLlmArg(ctx, sess, org, args[0]) + if resErr != nil { + return resErr + } + var result map[string]any + if err := sess.Client.PostJSON(ctx, llmKind.resourcePath(org, resolved.ID)+"/test", map[string]any{}, &result); err != nil { + return asCLIError(err) + } + message, ok := llmTestMessage(result) + env := &output.Envelope{OK: ok, Command: "llm test", Data: result, Summary: message} + if err := writeEnvelope(cmd, env); err != nil { + return err + } + if ok { + return nil + } + return &output.CLIError{Code: output.ErrUsage, Message: message, Silent: true} +} + +func runLlmModels(cmd *cobra.Command, args []string) error { + sess, cliErr := loadSession(cmd) + if cliErr != nil { + return cliErr + } + org, orgErr := requireActiveOrg(sess) + if orgErr != nil { + return orgErr + } + ctx := context.Background() + resolved, resErr := resolveLlmArg(ctx, sess, org, args[0]) + if resErr != nil { + return resErr + } + var result map[string]any + if err := sess.Client.PostJSON(ctx, llmKind.resourcePath(org, resolved.ID)+"/models", map[string]any{}, &result); err != nil { + return asCLIError(err) + } + models, _ := result["models"].([]any) + rows := make([][]string, len(models)) + for i, m := range models { + rows[i] = []string{valueOrEmpty(m)} + } + env := output.NewEnvelope("llm models", result, + fmt.Sprintf("%d models", len(models)), nil) + env.SetTable([]string{"MODEL"}, rows, -1) + return writeEnvelope(cmd, env) +} + +func resolveLlmArg(ctx context.Context, sess *sessionState, org, arg string) (nameID, *output.CLIError) { + var items []map[string]any + if !strings.HasPrefix(arg, llmKind.prefix) { + fetched, err := fetchList(ctx, sess.Client, llmKind.orgListPath(org), llmKind.listKey) + if err != nil { + return nameID{}, asCLIError(err) + } + items = fetched + } + return resolveCredArg(items, arg, llmKind) +} diff --git a/internal/cmd/llm_test.go b/internal/cmd/llm_test.go new file mode 100644 index 0000000..ff1b3a1 --- /dev/null +++ b/internal/cmd/llm_test.go @@ -0,0 +1,426 @@ +package cmd + +import ( + "bytes" + "testing" + + "github.com/urlbox/urlbox-cli/internal/api/apitest" +) + +const llmListJSON = `{"llmCredentials":[ + {"id":"llm_1","name":"openai-prod","provider":"openai","model":"gpt-5","baseUrl":null,"apiKey":"sk-verysecretkey123","awsAccessKeyId":null,"awsSecretAccessKey":null,"awsSessionToken":null,"gcpServiceAccountJson":null,"assignedProjectIds":["proj_1"],"createdAt":"2026-08-01T00:00:00.000Z"}, + {"id":"llm_2","name":"bedrock","provider":"amazon-bedrock","model":null,"baseUrl":null,"apiKey":null,"awsAccessKeyId":"AKIAFAKEFAKEFAKE","awsSecretAccessKey":"sk_fake_aws_secret_value","awsSessionToken":"tok_fake_session","gcpServiceAccountJson":null,"assignedProjectIds":[],"createdAt":"2026-08-02T00:00:00.000Z"}]}` + +const llmOneJSON = `{"id":"llm_2","name":"bedrock","provider":"amazon-bedrock","model":"claude-3","baseUrl":null,"apiKey":null,"awsAccessKeyId":"AKIAFAKEFAKEFAKE","awsSecretAccessKey":"sk_fake_aws_secret_value","awsSessionToken":"tok_fake_session","gcpServiceAccountJson":"{\"type\":\"service_account\"}","assignedProjectIds":[],"createdAt":"2026-08-02T00:00:00.000Z"}` + +func TestLlmListRendersTable(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New(apitest.SuccessJSON(llmListJSON)) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + var stdout, stderr bytes.Buffer + code := Execute([]string{"llm", "list", "--output-format", "text"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit %d\n%s\n%s", code, stdout.String(), stderr.String()) + } + reqs := srv.Requests() + if reqs[0].Method != "GET" || reqs[0].Path != "/v2/organisation/org_compat/llm-credentials" { + t.Fatalf("request: %+v", reqs[0]) + } + out := stdout.String() + for _, want := range []string{"ID", "NAME", "PROVIDER", "MODEL", "ASSIGNED", "llm_1", "openai-prod", "openai", "gpt-5", "amazon-bedrock"} { + if !bytes.Contains(stdout.Bytes(), []byte(want)) { + t.Fatalf("list output missing %q: %s", want, out) + } + } + if bytes.Contains(stdout.Bytes(), []byte("sk-verysecretkey123")) { + t.Fatalf("list must never print the api key: %s", out) + } +} + +func TestLlmShowMasksSecretsRevealUnhides(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New( + apitest.SuccessJSON(llmListJSON), + apitest.SuccessJSON(llmOneJSON), + ) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + var stdout, stderr bytes.Buffer + code := Execute([]string{"llm", "show", "llm_2", "--output-format", "text"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit %d\n%s\n%s", code, stdout.String(), stderr.String()) + } + reqs := srv.Requests() + if reqs[1].Method != "GET" || reqs[1].Path != "/v2/organisation/org_compat/llm-credentials/llm_2" { + t.Fatalf("show request: %+v", reqs[1]) + } + out := stdout.String() + for _, want := range []string{"NAME", "ID", "PROVIDER", "amazon-bedrock"} { + if !bytes.Contains(stdout.Bytes(), []byte(want)) { + t.Fatalf("show output missing %q: %s", want, out) + } + } + for _, secret := range []string{"AKIAFAKEFAKEFAKE", "sk_fake_aws_secret_value", "tok_fake_session", "service_account"} { + if bytes.Contains(stdout.Bytes(), []byte(secret)) { + t.Fatalf("show must mask %q without --reveal: %s", secret, out) + } + } + + srv2 := apitest.New( + apitest.SuccessJSON(llmListJSON), + apitest.SuccessJSON(llmOneJSON), + ) + t.Cleanup(srv2.Close) + t.Setenv("URLBOX_API_HOST", srv2.URL()) + var revealOut, revealErr bytes.Buffer + code = Execute([]string{"llm", "show", "llm_2", "--reveal", "--output-format", "text"}, &revealOut, &revealErr) + if code != 0 { + t.Fatalf("reveal exit %d\n%s\n%s", code, revealOut.String(), revealErr.String()) + } + for _, want := range []string{"AKIAFAKEFAKEFAKE", "sk_fake_aws_secret_value", "tok_fake_session"} { + if !bytes.Contains(revealOut.Bytes(), []byte(want)) { + t.Fatalf("--reveal must show full %q: %s", want, revealOut.String()) + } + } +} + +func TestLlmCreateSendsTypedFlagsAndJSONMerge(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New(apitest.SuccessJSON(`{"id":"llm_new","name":"x","provider":"openai","assignedProjectIds":[]}`)) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + var stdout, stderr bytes.Buffer + code := Execute([]string{ + "llm", "create", + "--name", "x", "--provider", "openai", "--api-key", "sk-1", + "--model", "gpt-5", "--base-url", "https://api.example.com", + "--json", `{"temperature":0.2}`, + "--output-format", "json", + }, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit %d\n%s\n%s", code, stdout.String(), stderr.String()) + } + reqs := srv.Requests() + if reqs[0].Method != "POST" || reqs[0].Path != "/v2/organisation/org_compat/llm-credentials" { + t.Fatalf("create request: %+v", reqs[0]) + } + for _, want := range []string{ + `"name":"x"`, `"provider":"openai"`, `"apiKey":"sk-1"`, + `"model":"gpt-5"`, `"baseUrl":"https://api.example.com"`, `"temperature":0.2`, + } { + if !bytes.Contains(reqs[0].Body, []byte(want)) { + t.Fatalf("create body missing %s: %s", want, reqs[0].Body) + } + } +} + +func TestLlmCreatePositionalName(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New(apitest.SuccessJSON(`{"id":"llm_new","name":"openai","provider":"openai","assignedProjectIds":[]}`)) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + var stdout, stderr bytes.Buffer + code := Execute([]string{ + "llm", "create", "openai", + "--provider", "openai", "--api-key", "sk-1", + "--output-format", "json", + }, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit %d\n%s\n%s", code, stdout.String(), stderr.String()) + } + reqs := srv.Requests() + if reqs[0].Method != "POST" || reqs[0].Path != "/v2/organisation/org_compat/llm-credentials" { + t.Fatalf("create request: %+v", reqs[0]) + } + if !bytes.Contains(reqs[0].Body, []byte(`"name":"openai"`)) { + t.Fatalf("create body must carry the positional name: %s", reqs[0].Body) + } +} + +func TestLlmCreatePositionalConflictsWithFlag(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New(apitest.SuccessJSON(`{}`)) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + var stdout, stderr bytes.Buffer + code := Execute([]string{ + "llm", "create", "a", "--name", "b", + "--provider", "openai", "--api-key", "sk-1", + "--output-format", "json", + }, &stdout, &stderr) + if code == 0 { + t.Fatalf("conflicting name must fail\n%s", stdout.String()) + } + if len(srv.Requests()) != 0 { + t.Fatalf("conflict must make no API call: %+v", srv.Requests()) + } + if !bytes.Contains(stdout.Bytes(), []byte("--name")) { + t.Fatalf("conflict error must name the flag: %s", stdout.String()) + } +} + +func TestLlmUpdatePartialPatch(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New(apitest.SuccessJSON(`{"id":"llm_1","name":"openai-prod","model":"gpt-5-mini"}`)) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + var stdout, stderr bytes.Buffer + code := Execute([]string{"llm", "update", "llm_1", "--model", "gpt-5-mini", "--output-format", "json"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit %d\n%s\n%s", code, stdout.String(), stderr.String()) + } + reqs := srv.Requests() + if len(reqs) != 1 { + t.Fatalf("prefixed-id update must make no list call, got %d requests: %+v", len(reqs), reqs) + } + if reqs[0].Method != "PATCH" || reqs[0].Path != "/v2/organisation/org_compat/llm-credentials/llm_1" { + t.Fatalf("update request: %+v", reqs[0]) + } + if string(reqs[0].Body) != `{"model":"gpt-5-mini"}` { + t.Fatalf("update body must be exactly the changed field: %s", reqs[0].Body) + } +} + +func TestLlmUpdateProviderIsImmutable(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New() + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + var stdout, stderr bytes.Buffer + code := Execute([]string{"llm", "update", "llm_1", "--provider", "anthropic", "--output-format", "json"}, &stdout, &stderr) + if code == 0 { + t.Fatalf("changing --provider on update must fail\n%s", stdout.String()) + } + if !bytes.Contains(stdout.Bytes(), []byte("provider is immutable after create — create a new credential instead")) { + t.Fatalf("error must state provider is immutable: %s", stdout.String()) + } + if len(srv.Requests()) != 0 { + t.Fatalf("immutable-provider rejection must be client-side, got requests: %+v", srv.Requests()) + } +} + +func TestLlmCreateBedrockRequiresAwsFields(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New() + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + var stdout, stderr bytes.Buffer + code := Execute([]string{"llm", "create", "--name", "b", "--provider", "amazon-bedrock", "--output-format", "json"}, &stdout, &stderr) + if code == 0 { + t.Fatalf("bedrock create without AWS fields must fail\n%s", stdout.String()) + } + if !bytes.Contains(stdout.Bytes(), []byte("amazon-bedrock requires awsRegion, awsAccessKeyId, awsSecretAccessKey — pass them via --json")) { + t.Fatalf("error must name the missing bedrock fields: %s", stdout.String()) + } + if len(srv.Requests()) != 0 { + t.Fatalf("validation must be client-side, got requests: %+v", srv.Requests()) + } +} + +func TestLlmDeleteRequiresYesOffTTY(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New(apitest.SuccessJSON(llmListJSON)) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + var stdout, stderr bytes.Buffer + code := Execute([]string{"llm", "delete", "llm_1", "--output-format", "json"}, &stdout, &stderr) + if code == 0 { + t.Fatalf("delete without --yes off-TTY must fail\n%s", stdout.String()) + } + if !bytes.Contains(stdout.Bytes(), []byte("--yes")) { + t.Fatalf("error must name --yes: %s", stdout.String()) + } + for _, r := range srv.Requests() { + if r.Method == "DELETE" { + t.Fatalf("no DELETE must be issued without confirmation: %+v", r) + } + } +} + +func TestLlmDeleteWithYes(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New( + apitest.SuccessJSON(llmListJSON), + apitest.SuccessJSON(`{"id":"llm_1","deleted":true}`), + ) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + var stdout, stderr bytes.Buffer + code := Execute([]string{"llm", "delete", "llm_1", "--yes", "--output-format", "text"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit %d\n%s\n%s", code, stdout.String(), stderr.String()) + } + reqs := srv.Requests() + last := reqs[len(reqs)-1] + if last.Method != "DELETE" || last.Path != "/v2/organisation/org_compat/llm-credentials/llm_1" { + t.Fatalf("delete request: %+v", last) + } + if !bytes.Contains(stdout.Bytes(), []byte("openai-prod")) { + t.Fatalf("delete summary must name the credential: %s", stdout.String()) + } +} + +func TestLlmCreateAssignTo(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New( + apitest.SuccessJSON(`{"id":"llm_new","name":"x","provider":"openai","assignedProjectIds":[]}`), + apitest.SuccessJSON(`{"projects":[{"id":"proj_1","name":"Main"}]}`), + apitest.SuccessJSON(`{"id":"proj_1","name":"Main","llmCredentialId":"llm_new"}`), + ) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + var stdout, stderr bytes.Buffer + code := Execute([]string{ + "llm", "create", + "--name", "x", "--provider", "openai", "--api-key", "sk-1", + "--assign-to", "proj_1", + "--output-format", "json", + }, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit %d\n%s\n%s", code, stdout.String(), stderr.String()) + } + reqs := srv.Requests() + var put *apitest.CapturedRequest + for i := range reqs { + if reqs[i].Method == "PUT" { + put = &reqs[i] + } + } + if put == nil { + t.Fatalf("assign must issue a PUT, requests: %+v", reqs) + } + if put.Path != "/v2/organisation/org_compat/projects/proj_1/llm-credential" { + t.Fatalf("assign PUT path: %s", put.Path) + } + if !bytes.Contains(put.Body, []byte(`"llmCredentialId":"llm_new"`)) { + t.Fatalf("assign body missing llmCredentialId: %s", put.Body) + } + if !bytes.Contains(stdout.Bytes(), []byte(`"assigned"`)) { + t.Fatalf("envelope data must carry the assigned project: %s", stdout.String()) + } +} + +func TestLlmNotFoundNameHint(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New(apitest.SuccessJSON(llmListJSON)) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + var stdout, stderr bytes.Buffer + code := Execute([]string{"llm", "show", "nope", "--output-format", "json"}, &stdout, &stderr) + if code == 0 { + t.Fatalf("show of an unknown name must fail\n%s", stdout.String()) + } + if !bytes.Contains(stdout.Bytes(), []byte("urlbox llm list")) { + t.Fatalf("not-found hint must name `urlbox llm list`: %s", stdout.String()) + } +} + +func TestLlmTestOkAndError(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New(apitest.SuccessJSON(`{"ok":true}`)) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + var stdout, stderr bytes.Buffer + code := Execute([]string{"llm", "test", "llm_1", "--output-format", "text"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("ok test must exit 0\n%s\n%s", stdout.String(), stderr.String()) + } + reqs := srv.Requests() + last := reqs[len(reqs)-1] + if last.Method != "POST" || last.Path != "/v2/organisation/org_compat/llm-credentials/llm_1/test" { + t.Fatalf("test request: %+v", last) + } + if string(last.Body) != `{}` { + t.Fatalf("test body must be an empty object: %s", last.Body) + } + if !bytes.Contains(stdout.Bytes(), []byte("Connection OK")) { + t.Fatalf("ok test must render Connection OK: %s", stdout.String()) + } + + srv2 := apitest.New(apitest.SuccessJSON(`{"ok":false,"error":"bad key"}`)) + t.Cleanup(srv2.Close) + t.Setenv("URLBOX_API_HOST", srv2.URL()) + var failOut, failErr bytes.Buffer + code = Execute([]string{"llm", "test", "llm_1", "--output-format", "text"}, &failOut, &failErr) + if code != 1 { + t.Fatalf("failed test must exit 1, got %d\n%s\n%s", code, failOut.String(), failErr.String()) + } + if !bytes.Contains(failOut.Bytes(), []byte("bad key")) { + t.Fatalf("failed test summary must carry the reason: %s", failOut.String()) + } +} + +func TestLlmTestErrorJSONEnvelopeNotOk(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New(apitest.SuccessJSON(`{"ok":false,"error":"bad key"}`)) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + var stdout, stderr bytes.Buffer + code := Execute([]string{"llm", "test", "llm_1", "--output-format", "json"}, &stdout, &stderr) + if code != 1 { + t.Fatalf("failed test must exit 1, got %d\n%s\n%s", code, stdout.String(), stderr.String()) + } + if !bytes.Contains(stdout.Bytes(), []byte(`"ok": false`)) { + t.Fatalf("json envelope for a failed test must be ok:false: %s", stdout.String()) + } + if !bytes.Contains(stdout.Bytes(), []byte("bad key")) { + t.Fatalf("json envelope must carry the error: %s", stdout.String()) + } +} + +func TestLlmModels(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New(apitest.SuccessJSON(`{"models":["gpt-a","gpt-b"]}`)) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + var stdout, stderr bytes.Buffer + code := Execute([]string{"llm", "models", "llm_1", "--output-format", "text"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit %d\n%s\n%s", code, stdout.String(), stderr.String()) + } + reqs := srv.Requests() + last := reqs[len(reqs)-1] + if last.Method != "POST" || last.Path != "/v2/organisation/org_compat/llm-credentials/llm_1/models" { + t.Fatalf("models request: %+v", last) + } + if string(last.Body) != `{}` { + t.Fatalf("models body must be an empty object: %s", last.Body) + } + out := stdout.String() + for _, want := range []string{"MODEL", "gpt-a", "gpt-b"} { + if !bytes.Contains(stdout.Bytes(), []byte(want)) { + t.Fatalf("models output missing %q: %s", want, out) + } + } +} diff --git a/internal/cmd/login.go b/internal/cmd/login.go new file mode 100644 index 0000000..a7e7826 --- /dev/null +++ b/internal/cmd/login.go @@ -0,0 +1,198 @@ +package cmd + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + + "github.com/urlbox/urlbox-cli/internal/browser" + "github.com/urlbox/urlbox-cli/internal/clock" + "github.com/urlbox/urlbox-cli/internal/config" + "github.com/urlbox/urlbox-cli/internal/deviceauth" + "github.com/urlbox/urlbox-cli/internal/output" +) + +var loginClock clock.Clock = clock.New() + +// SetLoginClockForTest swaps the package-level loginClock so the device-poll +// loop runs in synthetic time. Pair with t.Cleanup(ResetLoginClockForTest). +func SetLoginClockForTest(c clock.Clock) { loginClock = c } + +// ResetLoginClockForTest restores the real wall clock. +func ResetLoginClockForTest() { loginClock = clock.New() } + +var loginOpener browser.Opener = browser.NewOSOpener() + +// SetLoginOpenerForTest swaps in a fake browser.Opener for the login command. +// Pair with t.Cleanup(ResetLoginOpenerForTest). +func SetLoginOpenerForTest(o browser.Opener) { loginOpener = o } + +// ResetLoginOpenerForTest restores the production OSOpener. +func ResetLoginOpenerForTest() { loginOpener = browser.NewOSOpener() } + +type deviceCodeResponse struct { + DeviceCode string `json:"device_code"` + UserCode string `json:"user_code"` + VerificationURI string `json:"verification_uri_complete"` + Interval int `json:"interval"` + ExpiresIn int `json:"expires_in"` +} + +type loginFlags struct { + org string + project string +} + +func newLoginCmd() *cobra.Command { + f := &loginFlags{} + c := &cobra.Command{ + Use: "login", + Short: "Sign in via your browser (device flow)", + Long: `Sign in to Urlbox via your browser. + +Prints a short code and opens the approval page; once you approve, the CLI +stores a session for management commands, sets your active organisation and +project, and fetches the active project's render credential so render +commands work immediately. + +CI and headless environments should set URLBOX_API_SECRET instead — the +device flow needs a browser. + +Examples: + urlbox login + urlbox login --org acme --project production + urlbox login --output-format json`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return runLogin(cmd, f) + }, + } + c.Flags().StringVar(&f.org, "org", "", "Organisation to make active (name or id) — skips the picker") + c.Flags().StringVar(&f.project, "project", "", "Project to make active (name or id) — skips the picker") + attachSessionRetryFlags(c) + return c +} + +func renderCredentialLabel(status string) string { + switch status { + case "issued": + return "ready (new credential issued)" + case "ready": + return "ready" + case "error": + return "error (see messages above)" + default: + return "none" + } +} + +func runLogin(cmd *cobra.Command, f *loginFlags) error { + ctx := context.Background() + host, profileName, cliErr := sessionHost(cmd) + if cliErr != nil { + return cliErr + } + stderr := cmd.ErrOrStderr() + anon := newSessionClient(cmd, host, "") + + var code deviceCodeResponse + if err := anon.PostJSON(ctx, "/v1/auth/device/code", map[string]string{"client_id": "urlbox-cli"}, &code); err != nil { + return asCLIError(err) + } + + _, _ = fmt.Fprintf(stderr, "Your code: %s\n", code.UserCode) + _, _ = fmt.Fprintf(stderr, "Open this URL to continue: %s\n", code.VerificationURI) + formatFlag, _ := cmd.Root().PersistentFlags().GetString("output-format") + format := output.ResolveFormat(formatFlag, cmd.OutOrStdout()) + interactive := format != output.FormatJSON && format != output.FormatQuiet + if interactive { + _ = loginOpener.Open(code.VerificationURI) + } + _, _ = fmt.Fprintln(stderr, "Waiting for approval…") + + exchange := func() deviceauth.Exchange { + status, data, err := anon.DoRaw(ctx, "POST", "/v1/auth/device/token", map[string]string{ + "grant_type": "urn:ietf:params:oauth:grant-type:device_code", + "device_code": code.DeviceCode, + "client_id": "urlbox-cli", + }) + if err != nil { + return deviceauth.Exchange{Err: err} + } + if status < 400 { + return deviceauth.Exchange{AccessToken: valueOrEmpty(data["access_token"])} + } + return deviceauth.Exchange{RFCCode: valueOrEmpty(data["error"])} + } + token, pollErr := deviceauth.Poll(loginClock, code.Interval, code.ExpiresIn, exchange) + if pollErr != nil { + return pollErr + } + + if cliErr := updateProfile(profileName, func(p *config.Profile) { p.SessionToken = token }); cliErr != nil { + return cliErr + } + + authed := newSessionClient(cmd, host, token) + org, orgErr := resolveActiveOrg(ctx, authed, f.org, promptPick) + if orgErr != nil { + return orgErr + } + if org.publicID != "" { + if cliErr := updateProfile(profileName, func(p *config.Profile) { p.ActiveOrg = org.publicID }); cliErr != nil { + return cliErr + } + } + + project, projErr := resolveActiveProject(ctx, authed, f.project, promptPick) + if projErr != nil { + return projErr + } + renderStatus := "none" + if project.ID != "" { + if cliErr := updateProfile(profileName, func(p *config.Profile) { p.ActiveProject = project.ID }); cliErr != nil { + return cliErr + } + cred, issued, err := ensureRenderCredential(ctx, authed, org.publicID, project.ID, interactive, promptPick) + switch { + case err != nil: + _, _ = fmt.Fprintf(stderr, "Logged in, but could not fetch the render credential: %v\n", err) + renderStatus = "error" + case cred.secret != "": + if cliErr := updateProfile(profileName, func(p *config.Profile) { + p.APIKey = cred.key + p.APISecret = cred.secret + }); cliErr != nil { + _, _ = fmt.Fprintf(stderr, "Logged in, but could not save the render credential: %v\n", cliErr) + renderStatus = "error" + } else if issued { + renderStatus = "issued" + } else { + renderStatus = "ready" + } + } + } else { + _, _ = fmt.Fprintln(stderr, "No projects in this organisation yet.") + } + + data := map[string]any{ + "email": org.email, + "org": map[string]any{"id": org.publicID, "name": org.name}, + "project": nil, + "render": map[string]any{"credential": renderStatus}, + } + if project.ID != "" { + data["project"] = map[string]any{"id": project.ID, "name": project.Name} + } + summary := fmt.Sprintf("Logged in as %s — org %s", org.email, org.name) + breadcrumbs := []output.Breadcrumb{{ + Action: "render", + Cmd: "urlbox screenshot https://example.com --output hello.png", + }} + env := output.NewEnvelope("login", data, summary, breadcrumbs) + pairs := identityKVPairs(org.email, org.name, org.publicID, project) + pairs = append(pairs, [2]string{"Render", renderCredentialLabel(renderStatus)}) + env.SetKV(pairs) + return writeEnvelopeWithQuietData(cmd, env, org.email) +} diff --git a/internal/cmd/login_hint.go b/internal/cmd/login_hint.go new file mode 100644 index 0000000..52c4ea1 --- /dev/null +++ b/internal/cmd/login_hint.go @@ -0,0 +1,6 @@ +package cmd + +const ( + loginHint = "Run `urlbox login` to sign in." + notLoggedInMsg = "not logged in — run `urlbox login`" +) diff --git a/internal/cmd/login_render_e2e_test.go b/internal/cmd/login_render_e2e_test.go new file mode 100644 index 0000000..f132a63 --- /dev/null +++ b/internal/cmd/login_render_e2e_test.go @@ -0,0 +1,61 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "testing" + "time" + + "github.com/urlbox/urlbox-cli/internal/api/apitest" + "github.com/urlbox/urlbox-cli/internal/clock" +) + +// TestLoginThenRenderDryRun is the spec-promised login → render sequence: a +// scripted device login writes a real profile (session token + render secret), +// then `render --dry-run` reads that same profile and validates the payload +// without touching the network. It exercises the compatibility promise that +// login writes into the same file render reads. +func TestLoginThenRenderDryRun(t *testing.T) { + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New( + apitest.SuccessJSON(`{"device_code":"dev_1","user_code":"ABCD-1234","verification_uri_complete":"https://urlbox.com/device?code=ABCD-1234","interval":5,"expires_in":300}`), + apitest.SuccessJSON(`{"access_token":"sess_tok_new"}`), + apitest.SuccessJSON(`[{"id":"7","name":"Acme","publicId":"org_acme"}]`), + apitest.SuccessJSON(`{}`), + apitest.SuccessJSON(`{"user":{"email":"a@urlbox.com"},"session":{"activeOrganizationId":"7","activeOrganizationPublicId":"org_acme"}}`), + apitest.SuccessJSON(`{"projects":[{"id":"proj_1","name":"Main"}]}`), + apitest.SuccessJSON(`{"apiCredentials":[{"apiKey":"pk_fetched","apiSecret":"sk_fetched","revoked":false}]}`), + ) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + + fc := clock.NewFake(time.Unix(1_700_000_000, 0)) + SetLoginClockForTest(fc) + t.Cleanup(ResetLoginClockForTest) + done := make(chan struct{}) + defer close(done) + advanceClockUntil(t, fc, done) + + var loginOut, loginErr bytes.Buffer + if code := Execute([]string{"login", "--output-format", "json"}, &loginOut, &loginErr); code != 0 { + t.Fatalf("login exit %d\n%s\n%s", code, loginOut.String(), loginErr.String()) + } + + var renderOut, renderErr bytes.Buffer + code := Execute([]string{"render", "https://example.com", "--dry-run", "--output-format", "json"}, &renderOut, &renderErr) + if code != 0 { + t.Fatalf("render dry-run exit %d\n%s\n%s", code, renderOut.String(), renderErr.String()) + } + + var env struct { + OK bool `json:"ok"` + Data map[string]any `json:"data"` + } + if err := json.Unmarshal(renderOut.Bytes(), &env); err != nil { + t.Fatalf("render dry-run stdout not an envelope: %v\n%s", err, renderOut.String()) + } + if !env.OK { + t.Fatalf("render dry-run should succeed against the logged-in profile: %s", renderOut.String()) + } +} diff --git a/internal/cmd/login_resolve.go b/internal/cmd/login_resolve.go new file mode 100644 index 0000000..2ee0974 --- /dev/null +++ b/internal/cmd/login_resolve.go @@ -0,0 +1,161 @@ +package cmd + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/urlbox/urlbox-cli/internal/api" + "github.com/urlbox/urlbox-cli/internal/output" +) + +type pickFunc func(label string, options []string, active int) (int, error) + +var errNotInteractivePick = errors.New("not an interactive terminal") + +type orgListRow struct { + ID string `json:"id"` + Name string `json:"name"` + PublicID string `json:"publicId"` +} + +type sessionResponse struct { + User struct { + Email string `json:"email"` + } `json:"user"` + Session struct { + ActiveOrganizationID string `json:"activeOrganizationId"` + ActiveOrganizationPublicID string `json:"activeOrganizationPublicId"` + } `json:"session"` +} + +type resolvedOrg struct { + publicID string + name string + email string +} + +func matchOrg(orgs []orgListRow, arg string) (orgListRow, bool) { + for _, o := range orgs { + if o.PublicID == arg || o.ID == arg || strings.EqualFold(o.Name, arg) { + return o, true + } + } + return orgListRow{}, false +} + +func resolveActiveOrg(ctx context.Context, client api.SessionAPI, orgFlag string, pick pickFunc) (resolvedOrg, *output.CLIError) { + var orgs []orgListRow + if err := client.GetJSON(ctx, "/v1/auth/organization/list", &orgs); err != nil { + return resolvedOrg{}, asCLIError(err) + } + if len(orgs) == 0 { + return resolvedOrg{}, output.NewCLIError(output.ErrNotFound, + "your account has no organisation", + "Create one in the dashboard at https://urlbox.com/dashboard, then run `urlbox login` again.") + } + chosen := orgs[0] + if orgFlag != "" { + match, ok := matchOrg(orgs, orgFlag) + if !ok { + return resolvedOrg{}, output.NewCLIError(output.ErrNotFound, + fmt.Sprintf("no organisation matching %q", orgFlag), + "Run `urlbox orgs list` to see your organisations.") + } + chosen = match + } else if len(orgs) > 1 { + names := make([]string, len(orgs)) + for i, o := range orgs { + names[i] = o.Name + } + idx, err := pick("Select an organisation:", names, -1) + if err != nil { + if errors.Is(err, errNotInteractivePick) { + return resolvedOrg{}, output.NewCLIError(output.ErrUsage, + "multiple organisations and no interactive terminal", + "Pass --org to choose one non-interactively.") + } + return resolvedOrg{}, output.NewCLIError(output.ErrUsage, err.Error(), + "Pass --org to choose one non-interactively.") + } + chosen = orgs[idx] + } + if err := client.PostJSON(ctx, "/v1/auth/organization/set-active", + map[string]string{"organizationId": chosen.ID}, nil); err != nil { + return resolvedOrg{}, asCLIError(err) + } + var session sessionResponse + if err := client.GetJSON(ctx, "/v1/auth/get-session", &session); err != nil { + return resolvedOrg{}, asCLIError(err) + } + return resolvedOrg{ + publicID: session.Session.ActiveOrganizationPublicID, + name: chosen.Name, + email: session.User.Email, + }, nil +} + +func resolveActiveProject(ctx context.Context, client api.SessionAPI, projectFlag string, pick pickFunc) (nameID, *output.CLIError) { + projects, err := fetchList(ctx, client, "/v2/projects", "projects") + if err != nil { + return nameID{}, asCLIError(err) + } + rows := toNameIDs(projects) + if len(rows) == 0 { + return nameID{}, nil + } + if projectFlag != "" { + return resolveNameOrID(projectFlag, "proj_", rows, "project") + } + if len(rows) == 1 { + return rows[0], nil + } + names := make([]string, len(rows)) + for i, r := range rows { + names[i] = r.Name + } + idx, perr := pick("Select the active project (used by render):", names, -1) + if perr != nil { + if errors.Is(perr, errNotInteractivePick) { + return nameID{}, output.NewCLIError(output.ErrUsage, + "multiple projects and no interactive terminal", + "Pass --project , or run `urlbox projects select` later.") + } + return nameID{}, output.NewCLIError(output.ErrUsage, perr.Error(), + "Pass --project , or run `urlbox projects select` later.") + } + return rows[idx], nil +} + +func activeOrgName(ctx context.Context, client api.SessionAPI) string { + var session sessionResponse + if err := client.GetJSON(ctx, "/v1/auth/get-session", &session); err != nil { + return "(none)" + } + activeID := session.Session.ActiveOrganizationID + if activeID == "" { + return "(none)" + } + var orgs []orgListRow + if err := client.GetJSON(ctx, "/v1/auth/organization/list", &orgs); err == nil { + for _, o := range orgs { + if o.ID == activeID { + return o.Name + } + } + } + if session.Session.ActiveOrganizationPublicID != "" { + return session.Session.ActiveOrganizationPublicID + } + return "(none)" +} + +func asCLIError(err error) *output.CLIError { + var cli *output.CLIError + if errors.As(err, &cli) { + return cli + } + return output.NewCLIError(output.ErrServer, err.Error(), + "Run `urlbox doctor` to verify connectivity, then try again.") +} diff --git a/internal/cmd/login_resolve_test.go b/internal/cmd/login_resolve_test.go new file mode 100644 index 0000000..ded09dc --- /dev/null +++ b/internal/cmd/login_resolve_test.go @@ -0,0 +1,192 @@ +package cmd + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "github.com/urlbox/urlbox-cli/internal/output" +) + +type fakeSession struct { + gets map[string]string + posts []struct { + Path string + Body any + } + puts []struct { + Path string + Body any + } + postResponses map[string]string +} + +func (f *fakeSession) GetJSON(_ context.Context, path string, out any) error { + body, ok := f.gets[path] + if !ok { + return output.NewCLIError(output.ErrNotFound, "no fake for "+path, "") + } + return json.Unmarshal([]byte(body), out) +} + +func (f *fakeSession) PostJSON(_ context.Context, path string, body, out any) error { + f.posts = append(f.posts, struct { + Path string + Body any + }{path, body}) + if resp, ok := f.postResponses[path]; ok && out != nil { + return json.Unmarshal([]byte(resp), out) + } + return nil +} + +func (f *fakeSession) PatchJSON(_ context.Context, path string, body, out any) error { return nil } + +func (f *fakeSession) PutJSON(_ context.Context, path string, body, out any) error { + f.puts = append(f.puts, struct { + Path string + Body any + }{path, body}) + return nil +} + +func (f *fakeSession) DeleteJSON(_ context.Context, path string, out any) error { return nil } + +func neverPick(string, []string, int) (int, error) { + panic("picker must not be called") +} + +func notInteractive(string, []string, int) (int, error) { + return -1, errNotInteractivePick +} + +func sessionJSON(email, activeID, publicID string) string { + return `{"user":{"email":"` + email + `"},"session":{"activeOrganizationId":"` + activeID + `","activeOrganizationPublicId":"` + publicID + `"}}` +} + +func TestResolveActiveOrgSingleOrgSilently(t *testing.T) { + f := &fakeSession{gets: map[string]string{ + "/v1/auth/organization/list": `[{"id":"7","name":"Acme","publicId":"org_acme"}]`, + "/v1/auth/get-session": sessionJSON("a@urlbox.com", "7", "org_acme"), + }} + got, cli := resolveActiveOrg(context.Background(), f, "", neverPick) + if cli != nil { + t.Fatalf("unexpected: %v", cli) + } + if got.publicID != "org_acme" || got.name != "Acme" || got.email != "a@urlbox.com" { + t.Fatalf("got %+v", got) + } + if len(f.posts) != 1 || f.posts[0].Path != "/v1/auth/organization/set-active" { + t.Fatalf("set-active not called: %+v", f.posts) + } + b, _ := json.Marshal(f.posts[0].Body) + if !strings.Contains(string(b), `"organizationId":"7"`) { + t.Fatalf("set-active must send the numeric id, sent %s", b) + } +} + +func TestResolveActiveOrgFlagMatchesPublicIDNumericIDAndName(t *testing.T) { + list := `[{"id":"1","name":"One","publicId":"org_one"},{"id":"2","name":"Two","publicId":"org_two"}]` + for _, flag := range []string{"org_two", "2", "tWo"} { + f := &fakeSession{gets: map[string]string{ + "/v1/auth/organization/list": list, + "/v1/auth/get-session": sessionJSON("a@urlbox.com", "2", "org_two"), + }} + got, cli := resolveActiveOrg(context.Background(), f, flag, neverPick) + if cli != nil || got.publicID != "org_two" { + t.Fatalf("flag %q: got %+v %v", flag, got, cli) + } + } +} + +func TestResolveActiveOrgUnknownFlagErrors(t *testing.T) { + f := &fakeSession{gets: map[string]string{ + "/v1/auth/organization/list": `[{"id":"1","name":"One","publicId":"org_one"}]`, + }} + _, cli := resolveActiveOrg(context.Background(), f, "nope", neverPick) + if cli == nil || cli.Code != output.ErrNotFound { + t.Fatalf("want not_found, got %v", cli) + } +} + +func TestResolveActiveOrgMultiplePicks(t *testing.T) { + f := &fakeSession{gets: map[string]string{ + "/v1/auth/organization/list": `[{"id":"1","name":"One","publicId":"org_one"},{"id":"2","name":"Two","publicId":"org_two"}]`, + "/v1/auth/get-session": sessionJSON("a@urlbox.com", "2", "org_two"), + }} + pick := func(_ string, options []string, _ int) (int, error) { + if len(options) != 2 { + t.Fatalf("options = %v", options) + } + return 1, nil + } + got, cli := resolveActiveOrg(context.Background(), f, "", pick) + if cli != nil || got.name != "Two" { + t.Fatalf("got %+v %v", got, cli) + } +} + +func TestResolveActiveOrgNonInteractiveNamesFlag(t *testing.T) { + f := &fakeSession{gets: map[string]string{ + "/v1/auth/organization/list": `[{"id":"1","name":"One","publicId":"org_one"},{"id":"2","name":"Two","publicId":"org_two"}]`, + }} + _, cli := resolveActiveOrg(context.Background(), f, "", notInteractive) + if cli == nil || cli.Code != output.ErrUsage || !strings.Contains(cli.Hint, "--org") { + t.Fatalf("want usage error naming --org, got %v", cli) + } +} + +func TestResolveActiveOrgZeroOrgs(t *testing.T) { + f := &fakeSession{gets: map[string]string{"/v1/auth/organization/list": `[]`}} + _, cli := resolveActiveOrg(context.Background(), f, "", neverPick) + if cli == nil || cli.Code != output.ErrNotFound { + t.Fatalf("want not_found, got %v", cli) + } +} + +func TestResolveActiveProjectMatrix(t *testing.T) { + zero := &fakeSession{gets: map[string]string{"/v2/projects": `{"projects":[]}`}} + got, cli := resolveActiveProject(context.Background(), zero, "", neverPick) + if cli != nil || got.ID != "" { + t.Fatalf("zero projects: got %+v %v", got, cli) + } + + one := &fakeSession{gets: map[string]string{"/v2/projects": `{"projects":[{"id":"proj_1","name":"Only"}]}`}} + got, cli = resolveActiveProject(context.Background(), one, "", neverPick) + if cli != nil || got.ID != "proj_1" { + t.Fatalf("one project: got %+v %v", got, cli) + } + + many := &fakeSession{gets: map[string]string{"/v2/projects": `{"projects":[{"id":"proj_1","name":"A"},{"id":"proj_2","name":"B"}]}`}} + got, cli = resolveActiveProject(context.Background(), many, "", func(_ string, _ []string, _ int) (int, error) { return 1, nil }) + if cli != nil || got.ID != "proj_2" { + t.Fatalf("picker path: got %+v %v", got, cli) + } + + got, cli = resolveActiveProject(context.Background(), many, "b", neverPick) + if cli != nil || got.ID != "proj_2" { + t.Fatalf("flag path: got %+v %v", got, cli) + } + + _, cli = resolveActiveProject(context.Background(), many, "", notInteractive) + if cli == nil || cli.Code != output.ErrUsage || !strings.Contains(cli.Hint, "--project") { + t.Fatalf("want usage error naming --project, got %v", cli) + } +} + +func TestActiveOrgNameFallbacks(t *testing.T) { + named := &fakeSession{gets: map[string]string{ + "/v1/auth/get-session": sessionJSON("a@urlbox.com", "7", "org_x"), + "/v1/auth/organization/list": `[{"id":"7","name":"Acme","publicId":"org_x"}]`, + }} + if got := activeOrgName(context.Background(), named); got != "Acme" { + t.Fatalf("got %q", got) + } + none := &fakeSession{gets: map[string]string{ + "/v1/auth/get-session": sessionJSON("a@urlbox.com", "", ""), + }} + if got := activeOrgName(context.Background(), none); got != "(none)" { + t.Fatalf("got %q", got) + } +} diff --git a/internal/cmd/login_test.go b/internal/cmd/login_test.go new file mode 100644 index 0000000..8d30266 --- /dev/null +++ b/internal/cmd/login_test.go @@ -0,0 +1,308 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/spf13/cobra" + + "github.com/urlbox/urlbox-cli/internal/api/apitest" + "github.com/urlbox/urlbox-cli/internal/clock" + "github.com/urlbox/urlbox-cli/internal/output" +) + +func writeConfig(t *testing.T, dir, body string) { + t.Helper() + cfgDir := filepath.Join(dir, "urlbox") + if err := os.MkdirAll(cfgDir, 0o700); err != nil { + t.Fatalf("mkdir config dir: %v", err) + } + if err := os.WriteFile(filepath.Join(cfgDir, "config.json"), []byte(body), 0o600); err != nil { + t.Fatalf("write config: %v", err) + } +} + +func loginSubcommand(t *testing.T) *cobra.Command { + t.Helper() + var out, errOut bytes.Buffer + root := newRootCmd(&out, &errOut) + for _, c := range root.Commands() { + if c.Name() == "login" { + return c + } + } + t.Fatal("login subcommand not registered on root") + return nil +} + +func advanceClockUntil(t *testing.T, fc *clock.FakeClock, done <-chan struct{}) { + t.Helper() + go func() { + for { + select { + case <-done: + return + default: + if fc.WaitForSleeper(5 * time.Millisecond) { + fc.Advance(10 * time.Second) + } + } + } + }() +} + +func TestLoginFullFlowSingleOrgSingleProject(t *testing.T) { + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New( + apitest.SuccessJSON(`{"device_code":"dev_1","user_code":"ABCD-1234","verification_uri_complete":"https://urlbox.com/device?code=ABCD-1234","interval":5,"expires_in":300}`), + apitest.SuccessJSON(`{"access_token":"sess_tok_new"}`), + apitest.SuccessJSON(`[{"id":"7","name":"Acme","publicId":"org_acme"}]`), + apitest.SuccessJSON(`{}`), + apitest.SuccessJSON(`{"user":{"email":"a@urlbox.com"},"session":{"activeOrganizationId":"7","activeOrganizationPublicId":"org_acme"}}`), + apitest.SuccessJSON(`{"projects":[{"id":"proj_1","name":"Main"}]}`), + apitest.SuccessJSON(`{"apiCredentials":[{"apiKey":"pk_fetched","apiSecret":"sk_fetched","revoked":false}]}`), + ) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + + fc := clock.NewFake(time.Unix(1_700_000_000, 0)) + SetLoginClockForTest(fc) + t.Cleanup(ResetLoginClockForTest) + done := make(chan struct{}) + defer close(done) + advanceClockUntil(t, fc, done) + + var stdout, stderr bytes.Buffer + code := Execute([]string{"login", "--output-format", "json"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit %d\nstdout: %s\nstderr: %s", code, stdout.String(), stderr.String()) + } + + var env struct { + OK bool `json:"ok"` + Data struct { + Email string `json:"email"` + Org struct { + ID string `json:"id"` + Name string `json:"name"` + } `json:"org"` + Project *struct { + ID string `json:"id"` + } `json:"project"` + Render struct { + Credential string `json:"credential"` + } `json:"render"` + } `json:"data"` + } + if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { + t.Fatalf("stdout not an envelope: %v\n%s", err, stdout.String()) + } + if !env.OK || env.Data.Email != "a@urlbox.com" || env.Data.Org.ID != "org_acme" { + t.Fatalf("envelope: %s", stdout.String()) + } + if env.Data.Project == nil || env.Data.Project.ID != "proj_1" { + t.Fatalf("project: %s", stdout.String()) + } + if env.Data.Render.Credential != "ready" { + t.Fatalf("render status: %s", stdout.String()) + } + + b, err := os.ReadFile(filepath.Join(dir, "urlbox", "config.json")) + if err != nil { + t.Fatalf("read config: %v", err) + } + var cfg struct { + Profiles map[string]map[string]string `json:"profiles"` + } + if err := json.Unmarshal(b, &cfg); err != nil { + t.Fatalf("unmarshal config: %v", err) + } + p := cfg.Profiles["default"] + if p["session_token"] != "sess_tok_new" || p["active_org"] != "org_acme" || + p["active_project"] != "proj_1" || p["api_secret"] != "sk_fetched" || p["api_key"] != "pk_fetched" { + t.Fatalf("profile after login: %#v", p) + } + + reqs := srv.Requests() + if reqs[0].Path != "/v1/auth/device/code" { + t.Fatalf("first call %q", reqs[0].Path) + } + if !bytes.Contains(reqs[0].Body, []byte(`"client_id":"urlbox-cli"`)) { + t.Fatalf("device/code body: %s", reqs[0].Body) + } + if reqs[1].Path != "/v1/auth/device/token" { + t.Fatalf("second call %q", reqs[1].Path) + } + if got := reqs[2].Header.Get("Authorization"); got != "Bearer sess_tok_new" { + t.Fatalf("org list auth header %q", got) + } + if stderrStr := stderr.String(); !bytes.Contains([]byte(stderrStr), []byte("ABCD-1234")) { + t.Fatalf("user code must print to stderr, got: %s", stderrStr) + } +} + +type recordingOpener struct{ opened []string } + +func (r *recordingOpener) Open(url string) error { + r.opened = append(r.opened, url) + return nil +} + +func TestLoginQuietPrintsEmailScalar(t *testing.T) { + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New( + apitest.SuccessJSON(`{"device_code":"dev_1","user_code":"ABCD-1234","verification_uri_complete":"https://urlbox.com/device?code=ABCD-1234","interval":5,"expires_in":300}`), + apitest.SuccessJSON(`{"access_token":"sess_tok_new"}`), + apitest.SuccessJSON(`[{"id":"7","name":"Acme","publicId":"org_acme"}]`), + apitest.SuccessJSON(`{}`), + apitest.SuccessJSON(`{"user":{"email":"a@urlbox.com"},"session":{"activeOrganizationId":"7","activeOrganizationPublicId":"org_acme"}}`), + apitest.SuccessJSON(`{"projects":[{"id":"proj_1","name":"Main"}]}`), + apitest.SuccessJSON(`{"apiCredentials":[{"apiKey":"pk_fetched","apiSecret":"sk_fetched","revoked":false}]}`), + ) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + + fc := clock.NewFake(time.Unix(1_700_000_000, 0)) + SetLoginClockForTest(fc) + t.Cleanup(ResetLoginClockForTest) + done := make(chan struct{}) + defer close(done) + advanceClockUntil(t, fc, done) + + rec := &recordingOpener{} + SetLoginOpenerForTest(rec) + t.Cleanup(ResetLoginOpenerForTest) + + var stdout, stderr bytes.Buffer + code := Execute([]string{"login", "--output-format", "quiet"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit %d\nstdout: %s\nstderr: %s", code, stdout.String(), stderr.String()) + } + out := strings.TrimSpace(stdout.String()) + if out != `"a@urlbox.com"` { + t.Fatalf("quiet stdout should be the bare email scalar; got %q", out) + } + if len(rec.opened) != 0 { + t.Fatalf("quiet mode must not open the browser; opened %v", rec.opened) + } +} + +func TestLoginPersistsKeyAndSecretFromSameCredential(t *testing.T) { + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New( + apitest.SuccessJSON(`{"device_code":"dev_1","user_code":"ABCD-1234","verification_uri_complete":"https://urlbox.com/device?code=ABCD-1234","interval":5,"expires_in":300}`), + apitest.SuccessJSON(`{"access_token":"sess_tok_new"}`), + apitest.SuccessJSON(`[{"id":"7","name":"Acme","publicId":"org_acme"}]`), + apitest.SuccessJSON(`{}`), + apitest.SuccessJSON(`{"user":{"email":"a@urlbox.com"},"session":{"activeOrganizationId":"7","activeOrganizationPublicId":"org_acme"}}`), + apitest.SuccessJSON(`{"projects":[{"id":"proj_1","name":"Main"}]}`), + apitest.SuccessJSON(`{"apiCredentials":[{"apiKey":"pk_revoked","apiSecret":"sk_revoked","revoked":true},{"apiKey":"pk_live","apiSecret":"sk_live","revoked":false}]}`), + ) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + + fc := clock.NewFake(time.Unix(1_700_000_000, 0)) + SetLoginClockForTest(fc) + t.Cleanup(ResetLoginClockForTest) + done := make(chan struct{}) + defer close(done) + advanceClockUntil(t, fc, done) + + var stdout, stderr bytes.Buffer + code := Execute([]string{"login", "--output-format", "json"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit %d\nstdout: %s\nstderr: %s", code, stdout.String(), stderr.String()) + } + + b, err := os.ReadFile(filepath.Join(dir, "urlbox", "config.json")) + if err != nil { + t.Fatalf("read config: %v", err) + } + var cfg struct { + Profiles map[string]map[string]string `json:"profiles"` + } + if err := json.Unmarshal(b, &cfg); err != nil { + t.Fatalf("unmarshal config: %v", err) + } + p := cfg.Profiles["default"] + if p["api_key"] != "pk_live" || p["api_secret"] != "sk_live" { + t.Fatalf("key and secret must come from the same non-revoked credential: %#v", p) + } +} + +func TestLoginDeniedExitsAuth(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + srv := apitest.New( + apitest.SuccessJSON(`{"device_code":"dev_1","user_code":"ABCD-1234","verification_uri_complete":"https://urlbox.com/device","interval":5,"expires_in":300}`), + apitest.ScriptedResponse{Status: 400, Body: `{"error":"access_denied"}`}, + ) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + fc := clock.NewFake(time.Unix(1_700_000_000, 0)) + SetLoginClockForTest(fc) + t.Cleanup(ResetLoginClockForTest) + done := make(chan struct{}) + defer close(done) + advanceClockUntil(t, fc, done) + + var stdout, stderr bytes.Buffer + code := Execute([]string{"login", "--output-format", "json"}, &stdout, &stderr) + if code != 3 { + t.Fatalf("exit = %d, want 3 (auth)\nstdout: %s\nstderr: %s", code, stdout.String(), stderr.String()) + } + var env map[string]any + if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { + t.Fatalf("stdout not an envelope: %v\n%s", err, stdout.String()) + } + if env["code"] != "auth" { + t.Fatalf("error envelope code = %v, want auth: %s", env["code"], stdout.String()) + } +} + +func TestLoadSessionNoTokenReturnsAuth(t *testing.T) { + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir) + writeConfig(t, dir, `{"default_profile":"default","profiles":{"default":{"api_host":"https://api.urlbox.com"}}}`) + + _, cliErr := loadSession(loginSubcommand(t)) + if cliErr == nil { + t.Fatal("expected an auth error when the profile has no session token") + } + if cliErr.Code != output.ErrAuth { + t.Fatalf("code = %q, want auth", cliErr.Code) + } + if cliErr.Hint == "" { + t.Fatal("hint must be non-empty") + } +} + +func TestLoadSessionWithTokenBuildsClient(t *testing.T) { + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir) + writeConfig(t, dir, `{"default_profile":"default","profiles":{"default":{"api_host":"https://api.urlbox.com","session_token":"sess_tok"}}}`) + + state, cliErr := loadSession(loginSubcommand(t)) + if cliErr != nil { + t.Fatalf("unexpected error: %v", cliErr) + } + if state.ProfileName != "default" { + t.Fatalf("profile name = %q, want default", state.ProfileName) + } + if state.Profile.SessionToken != "sess_tok" { + t.Fatalf("session token = %q, want sess_tok", state.Profile.SessionToken) + } + if state.Client == nil { + t.Fatal("client must be constructed") + } + if state.Host != "https://api.urlbox.com" { + t.Fatalf("host = %q, want https://api.urlbox.com", state.Host) + } +} diff --git a/internal/cmd/logout.go b/internal/cmd/logout.go new file mode 100644 index 0000000..59437a1 --- /dev/null +++ b/internal/cmd/logout.go @@ -0,0 +1,67 @@ +package cmd + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + + "github.com/urlbox/urlbox-cli/internal/config" + "github.com/urlbox/urlbox-cli/internal/output" +) + +func newLogoutCmd() *cobra.Command { + c := &cobra.Command{ + Use: "logout", + Short: "Sign out and revoke this device's session", + Long: `Sign out of Urlbox on this machine. + +Revokes only this device's session server-side (your dashboard and other +devices stay signed in) and clears the stored session, active organisation, +active project, and render credential. If the server is unreachable the +local state is cleared anyway. + +Examples: + urlbox logout + urlbox logout --output-format json`, + Args: cobra.NoArgs, + RunE: runLogout, + } + attachSessionRetryFlags(c) + return c +} + +func runLogout(cmd *cobra.Command, _ []string) error { + host, profileName, cliErr := sessionHost(cmd) + if cliErr != nil { + return cliErr + } + cfg, cfgErr := config.LoadOrCLIError() + if cfgErr != nil { + return cfgErr + } + profile := cfg.Profiles[profileName] + if profile.SessionToken == "" { + env := output.NewEnvelope("logout", map[string]any{"logged_out": false}, "Not logged in.", nil) + return writeEnvelope(cmd, env) + } + + client := newSessionClient(cmd, host, profile.SessionToken) + if err := client.PostJSON(context.Background(), "/v1/auth/sign-out", map[string]string{}, nil); err != nil { + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), + "Warning: could not reach the server to revoke the session (%v); clearing local login anyway.\n", err) + } + + if cliErr := updateProfile(profileName, func(p *config.Profile) { + p.SessionToken = "" + p.ActiveOrg = "" + p.ActiveProject = "" + p.APIKey = "" + p.APISecret = "" + }); cliErr != nil { + return cliErr + } + + env := output.NewEnvelope("logout", map[string]any{"logged_out": true}, "Logged out.", nil) + return writeEnvelope(cmd, env) +} diff --git a/internal/cmd/logout_test.go b/internal/cmd/logout_test.go new file mode 100644 index 0000000..c6996c8 --- /dev/null +++ b/internal/cmd/logout_test.go @@ -0,0 +1,87 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/urlbox/urlbox-cli/internal/api/apitest" +) + +func readProfileMap(t *testing.T, dir string) map[string]string { + t.Helper() + b, err := os.ReadFile(filepath.Join(dir, "urlbox", "config.json")) + if err != nil { + t.Fatalf("read config: %v", err) + } + var cfg struct { + Profiles map[string]map[string]string `json:"profiles"` + } + if err := json.Unmarshal(b, &cfg); err != nil { + t.Fatalf("unmarshal: %v", err) + } + return cfg.Profiles["default"] +} + +func TestLogoutRevokesAndClears(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New(apitest.SuccessJSON(`{}`)) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + + var stdout, stderr bytes.Buffer + code := Execute([]string{"logout", "--output-format", "json"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit %d\n%s\n%s", code, stdout.String(), stderr.String()) + } + reqs := srv.Requests() + if len(reqs) != 1 || reqs[0].Path != "/v1/auth/sign-out" { + t.Fatalf("requests: %+v", reqs) + } + if got := reqs[0].Header.Get("Authorization"); got != "Bearer sess_tok_compat_123456" { + t.Fatalf("auth header %q", got) + } + p := readProfileMap(t, dir) + for _, key := range []string{"session_token", "active_org", "active_project", "api_key", "api_secret"} { + if p[key] != "" { + t.Fatalf("%s not cleared: %#v", key, p) + } + } +} + +func TestLogoutOfflineStillClearsLocally(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + t.Setenv("URLBOX_API_HOST", "http://127.0.0.1:1") + + var stdout, stderr bytes.Buffer + code := Execute([]string{"logout", "--no-retry", "--output-format", "json"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("offline logout must still succeed, exit %d\n%s", code, stderr.String()) + } + if !bytes.Contains(stderr.Bytes(), []byte("clearing local login anyway")) { + t.Fatalf("expected warning on stderr, got: %s", stderr.String()) + } + if p := readProfileMap(t, dir); p["session_token"] != "" { + t.Fatalf("token not cleared: %#v", p) + } +} + +func TestLogoutWhenNotLoggedIn(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, false) + t.Setenv("XDG_CONFIG_HOME", dir) + var stdout, stderr bytes.Buffer + code := Execute([]string{"logout", "--output-format", "json"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("logout without a session must be a no-op success, exit %d", code) + } + if !bytes.Contains(stdout.Bytes(), []byte(`"ok": true`)) { + t.Fatalf("envelope: %s", stdout.String()) + } +} diff --git a/internal/cmd/masking.go b/internal/cmd/masking.go new file mode 100644 index 0000000..b2614eb --- /dev/null +++ b/internal/cmd/masking.go @@ -0,0 +1,34 @@ +package cmd + +import ( + "net/url" + "strings" +) + +// maskSecret returns a redacted form of the API secret for safe display. +func maskSecret(s string) string { + if len(s) < 8 { + return "***" + } + return s[:4] + "…" + s[len(s)-2:] +} + +func maskProxyURL(raw string, reveal bool) string { + if reveal { + return raw + } + parsed, err := url.Parse(raw) + if err != nil { + return maskSecret(raw) + } + if parsed.User == nil { + if strings.Contains(raw, "@") { + return maskSecret(raw) + } + return raw + } + if _, hasPassword := parsed.User.Password(); hasPassword { + parsed.User = url.UserPassword(parsed.User.Username(), "****") + } + return strings.Replace(parsed.String(), "%2A%2A%2A%2A", "****", 1) +} diff --git a/internal/cmd/masking_test.go b/internal/cmd/masking_test.go new file mode 100644 index 0000000..a3ca58c --- /dev/null +++ b/internal/cmd/masking_test.go @@ -0,0 +1,61 @@ +package cmd + +import ( + "strings" + "testing" +) + +func TestMaskProxyURLMasksOnlyPassword(t *testing.T) { + got := maskProxyURL("http://user:hunter2@proxy.example.com:8080", false) + want := "http://user:****@proxy.example.com:8080" + if got != want { + t.Fatalf("got %q want %q", got, want) + } +} + +func TestMaskProxyURLRevealReturnsRaw(t *testing.T) { + raw := "http://user:hunter2@proxy.example.com:8080" + if got := maskProxyURL(raw, true); got != raw { + t.Fatalf("got %q want %q", got, raw) + } +} + +func TestMaskProxyURLNoPasswordUntouched(t *testing.T) { + raw := "http://proxy.example.com:8080" + if got := maskProxyURL(raw, false); got != raw { + t.Fatalf("got %q want %q", got, raw) + } +} + +func TestMaskProxyURLSchemelessWithAtFullMasks(t *testing.T) { + got := maskProxyURL("user:hunter2@proxy.example.com:8080", false) + if got == "user:hunter2@proxy.example.com:8080" { + t.Fatalf("schemeless credential string must not pass through unmasked") + } +} + +func TestMaskProxyURLHidesPassword(t *testing.T) { + got := maskProxyURL("http://user:hunter2@proxy.example.com:8080", false) + if strings.Contains(got, "hunter2") { + t.Fatalf("password leaked: %q", got) + } + if !strings.Contains(got, "proxy.example.com") { + t.Fatalf("host missing: %q", got) + } +} + +func TestMaskProxyURLMasksSchemelessCredentials(t *testing.T) { + got := maskProxyURL("user:hunter2@proxy.example.com:8080", false) + if strings.Contains(got, "hunter2") { + t.Fatalf("password leaked for scheme-less url: %q", got) + } +} + +func TestMaskProxyURLRevealAndNoAuthPassThrough(t *testing.T) { + if got := maskProxyURL("http://user:hunter2@p.example.com", true); !strings.Contains(got, "hunter2") { + t.Fatalf("reveal should keep password: %q", got) + } + if got := maskProxyURL("http://plain.example.com:3128", false); got != "http://plain.example.com:3128" { + t.Fatalf("auth-less url must pass through: %q", got) + } +} diff --git a/internal/cmd/nameid.go b/internal/cmd/nameid.go new file mode 100644 index 0000000..bfbaa9a --- /dev/null +++ b/internal/cmd/nameid.go @@ -0,0 +1,80 @@ +package cmd + +import ( + "context" + "fmt" + "strings" + + "github.com/urlbox/urlbox-cli/internal/api" + "github.com/urlbox/urlbox-cli/internal/output" +) + +type nameID struct { + ID string + Name string +} + +func valueOrEmpty(v any) string { + s, _ := v.(string) + return s +} + +func resolveNameOrID(arg, prefix string, rows []nameID, kind string) (nameID, *output.CLIError) { + if strings.HasPrefix(arg, prefix) { + for _, r := range rows { + if r.ID == arg { + return r, nil + } + } + return nameID{ID: arg}, nil + } + var matches []nameID + for _, r := range rows { + if strings.EqualFold(r.Name, arg) { + matches = append(matches, r) + } + } + switch len(matches) { + case 1: + return matches[0], nil + case 0: + return nameID{}, output.NewCLIError( + output.ErrNotFound, + fmt.Sprintf("no %s matching %q", kind, arg), + fmt.Sprintf("List them with `urlbox %ss list`, then pass a name or id.", kind), + ) + default: + ids := make([]string, len(matches)) + for i, m := range matches { + ids[i] = m.ID + } + return nameID{}, output.NewCLIError( + output.ErrValidation, + fmt.Sprintf("%q matches multiple %ss", arg, kind), + "Use one of the ids instead: "+strings.Join(ids, ", "), + ) + } +} + +func toNameIDs(items []map[string]any) []nameID { + rows := make([]nameID, len(items)) + for i, m := range items { + rows[i] = nameID{ID: valueOrEmpty(m["id"]), Name: valueOrEmpty(m["name"])} + } + return rows +} + +func fetchList(ctx context.Context, client api.SessionAPI, path, key string) ([]map[string]any, error) { + var resp map[string]any + if err := client.GetJSON(ctx, path, &resp); err != nil { + return nil, err + } + items, _ := resp[key].([]any) + out := make([]map[string]any, 0, len(items)) + for _, item := range items { + if m, ok := item.(map[string]any); ok { + out = append(out, m) + } + } + return out, nil +} diff --git a/internal/cmd/nameid_test.go b/internal/cmd/nameid_test.go new file mode 100644 index 0000000..6d200c8 --- /dev/null +++ b/internal/cmd/nameid_test.go @@ -0,0 +1,111 @@ +package cmd + +import ( + "context" + "strings" + "testing" + + "github.com/urlbox/urlbox-cli/internal/output" +) + +type stubListAPI struct { + resp map[string]any + err error +} + +func (s stubListAPI) GetJSON(_ context.Context, _ string, out any) error { + if s.err != nil { + return s.err + } + if dst, ok := out.(*map[string]any); ok { + *dst = s.resp + } + return nil +} + +func (s stubListAPI) PostJSON(context.Context, string, any, any) error { return nil } +func (s stubListAPI) PatchJSON(context.Context, string, any, any) error { return nil } +func (s stubListAPI) PutJSON(context.Context, string, any, any) error { return nil } +func (s stubListAPI) DeleteJSON(context.Context, string, any) error { return nil } + +func TestResolveNameOrIDPrefixedIDPassesThrough(t *testing.T) { + rows := []nameID{{ID: "proj_known", Name: "Site"}} + got, cli := resolveNameOrID("proj_unknown", "proj_", rows, "project") + if cli != nil { + t.Fatalf("unexpected error: %v", cli) + } + if got.ID != "proj_unknown" { + t.Fatalf("id = %q, want passthrough", got.ID) + } + got, cli = resolveNameOrID("proj_known", "proj_", rows, "project") + if cli != nil || got.Name != "Site" { + t.Fatalf("known id should resolve row, got %+v %v", got, cli) + } +} + +func TestResolveNameOrIDMatchesNameCaseInsensitive(t *testing.T) { + rows := []nameID{{ID: "proj_1", Name: "Production"}, {ID: "proj_2", Name: "Staging"}} + got, cli := resolveNameOrID("pRoDuCtIoN", "proj_", rows, "project") + if cli != nil || got.ID != "proj_1" { + t.Fatalf("got %+v %v", got, cli) + } +} + +func TestResolveNameOrIDNoMatchIsNotFound(t *testing.T) { + _, cli := resolveNameOrID("nope", "proj_", []nameID{{ID: "proj_1", Name: "A"}}, "project") + if cli == nil || cli.Code != output.ErrNotFound { + t.Fatalf("want not_found, got %v", cli) + } +} + +func TestResolveNameOrIDAmbiguityListsIDs(t *testing.T) { + rows := []nameID{{ID: "proj_1", Name: "Dup"}, {ID: "proj_2", Name: "dup"}} + _, cli := resolveNameOrID("dup", "proj_", rows, "project") + if cli == nil || cli.Code != output.ErrValidation { + t.Fatalf("want validation, got %v", cli) + } + if !strings.Contains(cli.Hint, "proj_1") || !strings.Contains(cli.Hint, "proj_2") { + t.Fatalf("hint must list candidate ids, got %q", cli.Hint) + } +} + +func TestFetchListExtractsObjectsUnderKey(t *testing.T) { + client := stubListAPI{resp: map[string]any{ + "projects": []any{ + map[string]any{"id": "proj_1", "name": "A"}, + "not-an-object", + map[string]any{"id": "proj_2", "name": "B"}, + }, + }} + out, err := fetchList(context.Background(), client, "/v2/projects", "projects") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(out) != 2 { + t.Fatalf("want 2 object rows, got %d: %+v", len(out), out) + } + if valueOrEmpty(out[0]["id"]) != "proj_1" || valueOrEmpty(out[1]["id"]) != "proj_2" { + t.Fatalf("rows = %+v", out) + } +} + +func TestFetchListMissingKeyIsEmpty(t *testing.T) { + client := stubListAPI{resp: map[string]any{}} + out, err := fetchList(context.Background(), client, "/v2/projects", "projects") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(out) != 0 { + t.Fatalf("want empty, got %+v", out) + } +} + +func TestToNameIDs(t *testing.T) { + rows := toNameIDs([]map[string]any{{"id": "proj_1", "name": "A"}, {"id": 7, "name": nil}}) + if rows[0] != (nameID{ID: "proj_1", Name: "A"}) { + t.Fatalf("row0 = %+v", rows[0]) + } + if rows[1] != (nameID{}) { + t.Fatalf("non-string fields must map to empty, got %+v", rows[1]) + } +} diff --git a/internal/cmd/orgs.go b/internal/cmd/orgs.go new file mode 100644 index 0000000..0db499b --- /dev/null +++ b/internal/cmd/orgs.go @@ -0,0 +1,198 @@ +package cmd + +import ( + "context" + "errors" + "fmt" + + "github.com/spf13/cobra" + + "github.com/urlbox/urlbox-cli/internal/config" + "github.com/urlbox/urlbox-cli/internal/output" +) + +var orgsProjectPick pickFunc = promptPick + +// SetOrgsProjectPickForTest swaps the picker used by the post-switch project +// step in `orgs select`. Pair with t.Cleanup(ResetOrgsProjectPickForTest). +func SetOrgsProjectPickForTest(p pickFunc) { orgsProjectPick = p } + +// ResetOrgsProjectPickForTest restores the production picker. +func ResetOrgsProjectPickForTest() { orgsProjectPick = promptPick } + +func newOrgsCmd() *cobra.Command { + c := &cobra.Command{ + Use: "orgs", + Aliases: []string{"org"}, + Short: "Manage the active organisation", + } + list := &cobra.Command{ + Use: "list", + Short: "List your organisations", + Args: cobra.NoArgs, + RunE: runOrgsList, + } + sel := &cobra.Command{ + Use: "select [name-or-id]", + Short: "Set the active organisation", + Args: cobra.MaximumNArgs(1), + RunE: runOrgsSelect, + } + c.AddCommand(list, sel) + attachSessionRetryFlags(c) + return c +} + +func runOrgsList(cmd *cobra.Command, _ []string) error { + sess, cliErr := loadSession(cmd) + if cliErr != nil { + return cliErr + } + ctx := context.Background() + var orgs []orgListRow + if err := sess.Client.GetJSON(ctx, "/v1/auth/organization/list", &orgs); err != nil { + return asCLIError(err) + } + var session sessionResponse + _ = sess.Client.GetJSON(ctx, "/v1/auth/get-session", &session) + activeID := session.Session.ActiveOrganizationID + + rows := make([]map[string]any, len(orgs)) + tableRows := make([][]string, len(orgs)) + activeName := "" + activeIndex := -1 + for i, o := range orgs { + active := o.ID != "" && o.ID == activeID + if active { + activeName = o.Name + activeIndex = i + } + rows[i] = map[string]any{"id": o.PublicID, "name": o.Name, "active": active} + tableRows[i] = []string{o.Name, o.PublicID} + } + summary := fmt.Sprintf("%d organisations", len(orgs)) + if activeName != "" { + summary = fmt.Sprintf("%d organisations — active: %s", len(orgs), activeName) + } + env := output.NewEnvelope("orgs list", map[string]any{"organisations": rows}, summary, nil) + env.SetTable([]string{"NAME", "ID"}, tableRows, activeIndex) + return writeEnvelope(cmd, env) +} + +func runOrgsSelect(cmd *cobra.Command, args []string) error { + sess, cliErr := loadSession(cmd) + if cliErr != nil { + return cliErr + } + ctx := context.Background() + var orgs []orgListRow + if err := sess.Client.GetJSON(ctx, "/v1/auth/organization/list", &orgs); err != nil { + return asCLIError(err) + } + if len(orgs) == 0 { + return output.NewCLIError(output.ErrNotFound, "no organisations", + "Create one in the dashboard at https://urlbox.com/dashboard.") + } + + var chosen orgListRow + if len(args) == 1 { + match, ok := matchOrg(orgs, args[0]) + if !ok { + return output.NewCLIError(output.ErrNotFound, + fmt.Sprintf("no organisation matching %q", args[0]), + "Run `urlbox orgs list` to see your organisations.") + } + chosen = match + } else { + names := make([]string, len(orgs)) + active := -1 + var session sessionResponse + _ = sess.Client.GetJSON(ctx, "/v1/auth/get-session", &session) + for i, o := range orgs { + names[i] = o.Name + if o.ID == session.Session.ActiveOrganizationID { + active = i + } + } + idx, err := promptPick("Select the active organisation:", names, active) + if err != nil { + if errors.Is(err, errNotInteractivePick) { + return output.NewCLIError(output.ErrUsage, + "selection needs an interactive terminal", + "Pass the organisation directly: `urlbox orgs select `.") + } + return output.NewCLIError(output.ErrUsage, err.Error(), + "Pass the organisation directly: `urlbox orgs select `.") + } + chosen = orgs[idx] + } + + if err := sess.Client.PostJSON(ctx, "/v1/auth/organization/set-active", + map[string]string{"organizationId": chosen.ID}, nil); err != nil { + return asCLIError(err) + } + var session sessionResponse + if err := sess.Client.GetJSON(ctx, "/v1/auth/get-session", &session); err != nil { + return asCLIError(err) + } + publicID := session.Session.ActiveOrganizationPublicID + if cliErr := updateProfile(sess.ProfileName, func(p *config.Profile) { + p.ActiveOrg = publicID + p.ActiveProject = "" + p.APISecret = "" + }); cliErr != nil { + return cliErr + } + + formatFlag, _ := cmd.Root().PersistentFlags().GetString("output-format") + interactive := formatFlag != "json" && formatFlag != "quiet" + projectPick := orgsProjectPick + if !interactive { + projectPick = func(_ string, _ []string, _ int) (int, error) { return -1, errNotInteractivePick } + } + + project, projErr := resolveActiveProject(ctx, sess.Client, "", projectPick) + renderStatus := "none" + if projErr == nil && project.ID != "" { + if cliErr := updateProfile(sess.ProfileName, func(p *config.Profile) { p.ActiveProject = project.ID }); cliErr == nil { + if cred, issued, err := ensureRenderCredential(ctx, sess.Client, publicID, project.ID, interactive, projectPick); err == nil && cred.secret != "" { + if updateProfile(sess.ProfileName, func(p *config.Profile) { + p.APIKey = cred.key + p.APISecret = cred.secret + }) == nil { + if issued { + renderStatus = "issued" + } else { + renderStatus = "ready" + } + } + } + } + } + if projErr == nil && project.ID == "" { + _, _ = fmt.Fprintln(cmd.ErrOrStderr(), "No projects in this organisation yet — run `urlbox projects select` after creating one.") + } + if projErr != nil { + if isNonInteractiveProjectStep(projErr) { + _, _ = fmt.Fprintln(cmd.ErrOrStderr(), "Several projects in this organisation — run `urlbox projects select` to pick one.") + } else { + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "org switched, but no active project set: %v\n", projErr) + } + } + + data := map[string]any{ + "org": map[string]any{"id": publicID, "name": chosen.Name}, + "render": map[string]any{"credential": renderStatus}, + } + if project.ID != "" { + data["project"] = map[string]any{"id": project.ID, "name": project.Name} + } + env := output.NewEnvelope("orgs select", data, + fmt.Sprintf("Active organisation: %s", chosen.Name), nil) + return writeEnvelopeWithQuietData(cmd, env, publicID) +} + +func isNonInteractiveProjectStep(err error) bool { + var cli *output.CLIError + return errors.As(err, &cli) && cli.Code == output.ErrUsage +} diff --git a/internal/cmd/orgs_test.go b/internal/cmd/orgs_test.go new file mode 100644 index 0000000..4a83cf4 --- /dev/null +++ b/internal/cmd/orgs_test.go @@ -0,0 +1,145 @@ +package cmd + +import ( + "bytes" + "testing" + + "github.com/urlbox/urlbox-cli/internal/api/apitest" +) + +func TestOrgsListMarksActive(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New( + apitest.SuccessJSON(`[{"id":"1","name":"One","publicId":"org_one"},{"id":"2","name":"Two","publicId":"org_two"}]`), + apitest.SuccessJSON(`{"user":{"email":"a@urlbox.com"},"session":{"activeOrganizationId":"2","activeOrganizationPublicId":"org_two"}}`), + ) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + var stdout, stderr bytes.Buffer + code := Execute([]string{"orgs", "list", "--output-format", "json"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit %d\n%s\n%s", code, stdout.String(), stderr.String()) + } + for _, want := range []string{`"org_one"`, `"org_two"`, `"active": true`} { + if !bytes.Contains(stdout.Bytes(), []byte(want)) { + t.Fatalf("missing %s in: %s", want, stdout.String()) + } + } +} + +func TestOrgsSelectPositionalSwitchesAndRefreshesProject(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New( + apitest.SuccessJSON(`[{"id":"1","name":"One","publicId":"org_one"},{"id":"2","name":"Two","publicId":"org_two"}]`), + apitest.SuccessJSON(`{}`), + apitest.SuccessJSON(`{"user":{"email":"a@urlbox.com"},"session":{"activeOrganizationId":"1","activeOrganizationPublicId":"org_one"}}`), + apitest.SuccessJSON(`{"projects":[{"id":"proj_9","name":"OtherOrgProj"}]}`), + apitest.SuccessJSON(`{"apiCredentials":[{"apiKey":"pk_other_org","apiSecret":"sk_other_org","revoked":false}]}`), + ) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + var stdout, stderr bytes.Buffer + code := Execute([]string{"orgs", "select", "one", "--output-format", "json"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit %d\n%s\n%s", code, stdout.String(), stderr.String()) + } + p := readProfileMap(t, dir) + if p["active_org"] != "org_one" || p["active_project"] != "proj_9" || + p["api_secret"] != "sk_other_org" || p["api_key"] != "pk_other_org" { + t.Fatalf("profile after select: %#v", p) + } + reqs := srv.Requests() + if reqs[1].Path != "/v1/auth/organization/set-active" { + t.Fatalf("second call %q", reqs[1].Path) + } + if !bytes.Contains(reqs[1].Body, []byte(`"organizationId":"1"`)) { + t.Fatalf("set-active body: %s", reqs[1].Body) + } +} + +func TestOrgsSelectInteractiveProjectPicker(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New( + apitest.SuccessJSON(`[{"id":"1","name":"One","publicId":"org_one"},{"id":"2","name":"Two","publicId":"org_two"}]`), + apitest.SuccessJSON(`{}`), + apitest.SuccessJSON(`{"user":{"email":"a@urlbox.com"},"session":{"activeOrganizationId":"1","activeOrganizationPublicId":"org_one"}}`), + apitest.SuccessJSON(`{"projects":[{"id":"proj_a","name":"Alpha"},{"id":"proj_b","name":"Beta"}]}`), + apitest.SuccessJSON(`{"apiCredentials":[{"apiKey":"pk_beta","apiSecret":"sk_beta","revoked":false}]}`), + ) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + + picked := false + SetOrgsProjectPickForTest(func(_ string, options []string, _ int) (int, error) { + picked = true + if len(options) != 2 { + t.Fatalf("picker options = %v, want 2", options) + } + return 1, nil + }) + t.Cleanup(ResetOrgsProjectPickForTest) + + var stdout, stderr bytes.Buffer + code := Execute([]string{"orgs", "select", "one", "--output-format", "text"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit %d\n%s\n%s", code, stdout.String(), stderr.String()) + } + if !picked { + t.Fatal("interactive project picker was not invoked") + } + p := readProfileMap(t, dir) + if p["active_project"] != "proj_b" || p["api_secret"] != "sk_beta" || p["api_key"] != "pk_beta" { + t.Fatalf("picked project must be persisted: %#v", p) + } +} + +func TestOrgsSelectProjectStepServerErrorReportsNoActiveProject(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New( + apitest.SuccessJSON(`[{"id":"1","name":"One","publicId":"org_one"},{"id":"2","name":"Two","publicId":"org_two"}]`), + apitest.SuccessJSON(`{}`), + apitest.SuccessJSON(`{"user":{"email":"a@urlbox.com"},"session":{"activeOrganizationId":"1","activeOrganizationPublicId":"org_one"}}`), + apitest.ServerError(500), + ) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + + var stdout, stderr bytes.Buffer + code := Execute([]string{"orgs", "select", "one", "--output-format", "json"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("org switch must still exit 0 on a project-step error, got %d\n%s\n%s", code, stdout.String(), stderr.String()) + } + if !bytes.Contains(stderr.Bytes(), []byte("no active project set")) { + t.Fatalf("stderr must report the project-step failure: %s", stderr.String()) + } + if bytes.Contains(stderr.Bytes(), []byte("Several projects")) { + t.Fatalf("a 500 must not be reported as several projects: %s", stderr.String()) + } +} + +func TestOrgsSelectNonInteractiveWithoutArg(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New( + apitest.SuccessJSON(`[{"id":"1","name":"One","publicId":"org_one"},{"id":"2","name":"Two","publicId":"org_two"}]`), + ) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + var stdout, stderr bytes.Buffer + code := Execute([]string{"orgs", "select", "--output-format", "json"}, &stdout, &stderr) + if code != 1 { + t.Fatalf("want usage exit 1, got %d\n%s", code, stdout.String()) + } + if !bytes.Contains(stdout.Bytes(), []byte("name-or-id")) { + t.Fatalf("error must name the positional: %s", stdout.String()) + } +} diff --git a/internal/cmd/projects.go b/internal/cmd/projects.go new file mode 100644 index 0000000..8733332 --- /dev/null +++ b/internal/cmd/projects.go @@ -0,0 +1,890 @@ +package cmd + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "sort" + "strings" + + "github.com/spf13/cobra" + + "github.com/urlbox/urlbox-cli/internal/config" + "github.com/urlbox/urlbox-cli/internal/output" + "github.com/urlbox/urlbox-cli/internal/prompt" +) + +var confirmPrompt = prompt.Confirm + +// SetConfirmPromptForTest swaps the yes/no confirm prompt used by the projects +// commands (create's switch offer and disable's guard). Pair with +// t.Cleanup(ResetConfirmPromptForTest). +func SetConfirmPromptForTest(c func(string) (bool, error)) { confirmPrompt = c } + +// ResetConfirmPromptForTest restores the production confirm prompt. +func ResetConfirmPromptForTest() { confirmPrompt = prompt.Confirm } + +var deleteProjectPick pickFunc = promptPick + +// SetDeleteProjectPickForTest swaps the picker used to re-resolve the active +// project after deleting it. Pair with t.Cleanup(ResetDeleteProjectPickForTest). +func SetDeleteProjectPickForTest(p pickFunc) { deleteProjectPick = p } + +// ResetDeleteProjectPickForTest restores the production picker. +func ResetDeleteProjectPickForTest() { deleteProjectPick = promptPick } + +func newProjectsCmd() *cobra.Command { + c := &cobra.Command{ + Use: "projects", + Aliases: []string{"project"}, + Short: "Manage projects and the active project", + } + list := &cobra.Command{ + Use: "list", + Short: "List the active organisation's projects", + Args: cobra.NoArgs, + RunE: runProjectsList, + } + sel := &cobra.Command{ + Use: "select [name-or-id]", + Short: "Set the active project (used by render)", + Args: cobra.MaximumNArgs(1), + RunE: runProjectsSelect, + } + var showReveal bool + show := &cobra.Command{ + Use: "show ", + Short: "Show one project", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runProjectsShow(cmd, args, showReveal) + }, + } + show.Flags().BoolVar(&showReveal, "reveal", false, "Print the webhook key unmasked (default: masked)") + var createSelect bool + create := &cobra.Command{ + Use: "create ", + Short: "Create a project in the active organisation", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runProjectsCreate(cmd, args, createSelect) + }, + } + create.Flags().BoolVar(&createSelect, "select", false, "Make the new project the active one and refresh the render credential") + rename := &cobra.Command{ + Use: "rename ", + Short: "Rename a project", + Args: cobra.ExactArgs(2), + RunE: runProjectsRename, + } + enable := &cobra.Command{ + Use: "enable ", + Short: "Enable a project", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runProjectsSetEnabled(cmd, args, true, true) + }, + } + var disableYes bool + disable := &cobra.Command{ + Use: "disable ", + Short: "Disable a project", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runProjectsSetEnabled(cmd, args, false, disableYes) + }, + } + disable.Flags().BoolVar(&disableYes, "yes", false, "Confirm disabling the project (stops its renders)") + var yes bool + del := &cobra.Command{ + Use: "delete ", + Short: "Delete a project", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runProjectsDelete(cmd, args, yes) + }, + } + del.Flags().BoolVar(&yes, "yes", false, "Skip the retype-to-confirm prompt") + defaults := &cobra.Command{ + Use: "defaults", + Short: "Manage the project's default render options", + } + defaultsShow := &cobra.Command{ + Use: "show ", + Short: "Show default render options", + Args: cobra.ExactArgs(1), + RunE: runProjectsDefaultsShow, + } + var defaultsJSON string + var defaultsMerge bool + defaultsSet := &cobra.Command{ + Use: "set --json ", + Short: "Set default render options", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runProjectsDefaultsSet(cmd, args, defaultsJSON, defaultsMerge) + }, + } + defaultsSet.Flags().StringVar(&defaultsJSON, "json", "", "Default options as a JSON object") + defaultsSet.Flags().BoolVar(&defaultsMerge, "merge", false, "Merge into the existing defaults instead of replacing them") + var defaultsRemoveYes bool + defaultsRemove := &cobra.Command{ + Use: "remove ", + Short: "Remove all default render options", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runProjectsDefaultsRemove(cmd, args, defaultsRemoveYes) + }, + } + defaultsRemove.Flags().BoolVar(&defaultsRemoveYes, "yes", false, "Skip the retype-to-confirm prompt") + defaults.AddCommand(defaultsShow, defaultsSet, defaultsRemove) + c.AddCommand(list, sel, show, create, rename, enable, disable, del, defaults) + c.AddCommand( + newProjectsCredSubCmd(storageKind), + newProjectsCredSubCmd(proxyKind), + newProjectsCredSubCmd(llmKind), + ) + attachSessionRetryFlags(c) + return c +} + +func newProjectsCredSubCmd(kind credKind) *cobra.Command { //nolint:gocritic // credKind is a value descriptor passed by value throughout + group := &cobra.Command{ + Use: kind.group, + Short: fmt.Sprintf("Assign or unassign the project's %s", kind.noun), + } + assign := &cobra.Command{ + Use: "assign <" + kind.group + ">", + Short: fmt.Sprintf("Assign %s %s to a project", kind.article, kind.noun), + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + return runProjectsCredAssign(cmd, args, kind) + }, + } + unassign := &cobra.Command{ + Use: "unassign ", + Short: fmt.Sprintf("Unassign the project's %s", kind.noun), + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runProjectsCredUnassign(cmd, args, kind) + }, + } + group.AddCommand(assign, unassign) + return group +} + +func runProjectsCredAssign(cmd *cobra.Command, args []string, kind credKind) error { //nolint:gocritic // credKind is a value descriptor passed by value throughout + sess, cliErr := loadSession(cmd) + if cliErr != nil { + return cliErr + } + org, orgErr := requireActiveOrg(sess) + if orgErr != nil { + return orgErr + } + project, resErr := resolveProjectArg(sess, args[0]) + if resErr != nil { + return resErr + } + ctx := context.Background() + items, err := fetchList(ctx, sess.Client, kind.orgListPath(org), kind.listKey) + if err != nil { + return asCLIError(err) + } + cred, credErr := resolveCredArg(items, args[1], kind) + if credErr != nil { + return credErr + } + var resp map[string]any + if err := sess.Client.PutJSON(ctx, kind.assignPath(org, project.ID), + map[string]string{kind.bodyKey: cred.ID}, &resp); err != nil { + return asCLIError(err) + } + credName := cred.Name + if credName == "" { + credName = cred.ID + } + env := output.NewEnvelope("projects "+kind.group+" assign", resp, + fmt.Sprintf("Assigned %s to %s", credName, projectLabel(project)), nil) + return writeEnvelope(cmd, env) +} + +func runProjectsCredUnassign(cmd *cobra.Command, args []string, kind credKind) error { //nolint:gocritic // credKind is a value descriptor passed by value throughout + sess, cliErr := loadSession(cmd) + if cliErr != nil { + return cliErr + } + org, orgErr := requireActiveOrg(sess) + if orgErr != nil { + return orgErr + } + project, resErr := resolveProjectArg(sess, args[0]) + if resErr != nil { + return resErr + } + var resp map[string]any + if err := sess.Client.DeleteJSON(context.Background(), kind.assignPath(org, project.ID), &resp); err != nil { + return asCLIError(err) + } + env := output.NewEnvelope("projects "+kind.group+" unassign", resp, + fmt.Sprintf("Unassigned the %s from %s", kind.noun, projectLabel(project)), nil) + return writeEnvelope(cmd, env) +} + +func projectLabel(project nameID) string { + if project.Name != "" { + return project.Name + } + return project.ID +} + +func runProjectsList(cmd *cobra.Command, _ []string) error { + sess, cliErr := loadSession(cmd) + if cliErr != nil { + return cliErr + } + projects, err := fetchList(context.Background(), sess.Client, "/v2/projects", "projects") + if err != nil { + return asCLIError(err) + } + rows := make([]map[string]any, len(projects)) + tableRows := make([][]string, len(projects)) + activeName := "" + activeIndex := -1 + for i, m := range projects { + id := valueOrEmpty(m["id"]) + active := id != "" && id == sess.Profile.ActiveProject + if active { + activeName = valueOrEmpty(m["name"]) + activeIndex = i + } + m["active"] = active + rows[i] = m + tableRows[i] = []string{ + valueOrEmpty(m["name"]), + id, + projectStatusLabel(m), + valueOrEmpty(m["engineVersion"]), + } + } + summary := fmt.Sprintf("%d projects", len(rows)) + if activeName != "" { + summary = fmt.Sprintf("%d projects — active: %s", len(rows), activeName) + } + env := output.NewEnvelope("projects list", map[string]any{"projects": rows}, summary, nil) + env.SetTable([]string{"NAME", "ID", "STATUS", "ENGINE"}, tableRows, activeIndex) + return writeEnvelope(cmd, env) +} + +func runProjectsSelect(cmd *cobra.Command, args []string) error { + sess, cliErr := loadSession(cmd) + if cliErr != nil { + return cliErr + } + ctx := context.Background() + projects, err := fetchList(ctx, sess.Client, "/v2/projects", "projects") + if err != nil { + return asCLIError(err) + } + rows := toNameIDs(projects) + if len(rows) == 0 { + return output.NewCLIError(output.ErrNotFound, "no projects in the active organisation", + "Create one with `urlbox projects create `.") + } + + var chosen nameID + if len(args) == 1 { + chosen, cliErr = resolveNameOrID(args[0], "proj_", rows, "project") + if cliErr != nil { + return cliErr + } + } else { + names := make([]string, len(rows)) + active := -1 + for i, r := range rows { + names[i] = r.Name + if r.ID == sess.Profile.ActiveProject { + active = i + } + } + idx, perr := promptPick("Select the active project (used by render):", names, active) + if perr != nil { + if errors.Is(perr, errNotInteractivePick) { + return output.NewCLIError(output.ErrUsage, + "selection needs an interactive terminal", + "Pass the project directly: `urlbox projects select `.") + } + return output.NewCLIError(output.ErrUsage, perr.Error(), + "Pass the project directly: `urlbox projects select `.") + } + chosen = rows[idx] + } + + renderStatus, cliErr := activateProject(cmd, sess, chosen) + if cliErr != nil { + return cliErr + } + + data := map[string]any{ + "project": map[string]any{"id": chosen.ID, "name": chosen.Name}, + "render": map[string]any{"credential": renderStatus}, + } + env := output.NewEnvelope("projects select", data, + fmt.Sprintf("Active project: %s", chosen.Name), nil) + return writeEnvelopeWithQuietData(cmd, env, chosen.ID) +} + +func activateProject(cmd *cobra.Command, sess *sessionState, chosen nameID) (string, *output.CLIError) { + if cliErr := updateProfile(sess.ProfileName, func(p *config.Profile) { p.ActiveProject = chosen.ID }); cliErr != nil { + return "none", cliErr + } + renderStatus := "none" + if org := sess.Profile.ActiveOrg; org != "" { + formatFlag, _ := cmd.Root().PersistentFlags().GetString("output-format") + interactive := formatFlag != "json" && formatFlag != "quiet" + if cred, issued, err := ensureRenderCredential(context.Background(), sess.Client, org, chosen.ID, interactive, promptPick); err == nil && cred.secret != "" { + if updateProfile(sess.ProfileName, func(p *config.Profile) { + p.APIKey = cred.key + p.APISecret = cred.secret + }) == nil { + if issued { + renderStatus = "issued" + } else { + renderStatus = "ready" + } + } + } + } + return renderStatus, nil +} + +func projectStatusLabel(project map[string]any) string { + if enabled, ok := project["enabled"].(bool); ok { + if enabled { + return "enabled" + } + return "disabled" + } + return "unknown" +} + +func projectDetailPairs(project map[string]any, reveal bool) [][2]string { + pairs := [][2]string{ + {"Name", valueOrEmpty(project["name"])}, + {"ID", valueOrEmpty(project["id"])}, + {"Enabled", projectStatusLabel(project)}, + {"Engine", valueOrEmpty(project["engineVersion"])}, + } + appendIf := func(label, key string) { + if v := valueOrEmpty(project[key]); v != "" { + pairs = append(pairs, [2]string{label, v}) + } + } + appendIf("Region", "region") + appendIf("Queue", "renderQueue") + if key := valueOrEmpty(project["webhookKey"]); key != "" { + if !reveal { + key = maskSecret(key) + } + pairs = append(pairs, [2]string{"Webhook key", key}) + } + appendIf("Storage credential", "storageCredentialId") + appendIf("Proxy", "proxyId") + appendIf("LLM credential", "llmCredentialId") + appendIf("Created", "createdAt") + return pairs +} + +func optionsKVPairs(options map[string]any) [][2]string { + keys := make([]string, 0, len(options)) + for k := range options { + keys = append(keys, k) + } + sort.Strings(keys) + pairs := make([][2]string, 0, len(keys)) + for _, k := range keys { + pairs = append(pairs, [2]string{k, formatOptionValue(options[k])}) + } + return pairs +} + +func formatOptionValue(v any) string { + switch vv := v.(type) { + case string: + return vv + case bool: + return fmt.Sprintf("%t", vv) + case float64: + if vv == float64(int64(vv)) { + return fmt.Sprintf("%d", int64(vv)) + } + return fmt.Sprintf("%v", vv) + default: + b, err := json.Marshal(vv) + if err != nil { + return fmt.Sprintf("%v", vv) + } + return string(b) + } +} + +func resolveProjectArg(sess *sessionState, arg string) (nameID, *output.CLIError) { + projects, err := fetchList(context.Background(), sess.Client, "/v2/projects", "projects") + if err != nil { + return nameID{}, asCLIError(err) + } + return resolveNameOrID(arg, "proj_", toNameIDs(projects), "project") +} + +func resolveProjectArgWithEnabled(sess *sessionState, arg string) (nameID, *bool, *output.CLIError) { + if strings.HasPrefix(arg, "proj_") { + project, resErr := resolveProjectArg(sess, arg) + return project, nil, resErr + } + projects, err := fetchList(context.Background(), sess.Client, "/v2/projects", "projects") + if err != nil { + return nameID{}, nil, asCLIError(err) + } + project, resErr := resolveNameOrID(arg, "proj_", toNameIDs(projects), "project") + if resErr != nil { + return nameID{}, nil, resErr + } + for _, row := range projects { + if valueOrEmpty(row["id"]) == project.ID { + if enabled, ok := row["enabled"].(bool); ok { + return project, &enabled, nil + } + break + } + } + return project, nil, nil +} + +func projectPath(org, id string) string { + return "/v2/organisation/" + org + "/projects/" + id +} + +func runProjectsShow(cmd *cobra.Command, args []string, reveal bool) error { + sess, cliErr := loadSession(cmd) + if cliErr != nil { + return cliErr + } + org, orgErr := requireActiveOrg(sess) + if orgErr != nil { + return orgErr + } + resolved, resErr := resolveProjectArg(sess, args[0]) + if resErr != nil { + return resErr + } + var resp map[string]any + if err := sess.Client.GetJSON(context.Background(), projectPath(org, resolved.ID), &resp); err != nil { + return asCLIError(err) + } + name := valueOrEmpty(resp["name"]) + if name == "" { + name = valueOrEmpty(resp["id"]) + } + env := output.NewEnvelope("projects show", resp, + fmt.Sprintf("Project %s", name), nil) + env.SetKV(projectDetailPairs(resp, reveal)) + return writeEnvelope(cmd, env) +} + +func runProjectsCreate(cmd *cobra.Command, args []string, selectNew bool) error { + sess, cliErr := loadSession(cmd) + if cliErr != nil { + return cliErr + } + var resp map[string]any + if err := sess.Client.PostJSON(context.Background(), "/v2/projects", + map[string]string{"name": args[0]}, &resp); err != nil { + return asCLIError(err) + } + created := createdProjectNameID(resp, args[0]) + + switched := false + if wantSwitchToNewProject(cmd, selectNew, created) { + if _, aerr := activateProject(cmd, sess, created); aerr != nil { + return aerr + } + switched = true + } + + if switched { + env := output.NewEnvelope("projects create", withSelected(resp), + fmt.Sprintf("Created project %s (now active)", args[0]), nil) + return writeEnvelope(cmd, env) + } + env := output.NewEnvelope("projects create", resp, + fmt.Sprintf("Created project %s", args[0]), + []output.Breadcrumb{{Action: "activate", Cmd: "urlbox projects select " + args[0]}}) + return writeEnvelope(cmd, env) +} + +func createdProjectNameID(resp map[string]any, fallbackName string) nameID { + src := resp + if nested, ok := resp["project"].(map[string]any); ok { + src = nested + } + name := valueOrEmpty(src["name"]) + if name == "" { + name = fallbackName + } + return nameID{ID: valueOrEmpty(src["id"]), Name: name} +} + +func interactiveText(cmd *cobra.Command) bool { + formatFlag, _ := cmd.Root().PersistentFlags().GetString("output-format") + return output.ResolveFormat(formatFlag, cmd.OutOrStdout()) == output.FormatText +} + +func wantSwitchToNewProject(cmd *cobra.Command, selectNew bool, created nameID) bool { + if created.ID == "" { + return false + } + if selectNew { + return true + } + if !interactiveText(cmd) { + return false + } + ok, err := confirmPrompt("Switch to this project?") + if err != nil { + return false + } + return ok +} + +func withSelected(resp map[string]any) map[string]any { + out := make(map[string]any, len(resp)+1) + for k, v := range resp { + out[k] = v + } + out["selected"] = true + return out +} + +func runProjectsRename(cmd *cobra.Command, args []string) error { + sess, cliErr := loadSession(cmd) + if cliErr != nil { + return cliErr + } + org, orgErr := requireActiveOrg(sess) + if orgErr != nil { + return orgErr + } + resolved, resErr := resolveProjectArg(sess, args[0]) + if resErr != nil { + return resErr + } + var resp map[string]any + if err := sess.Client.PatchJSON(context.Background(), projectPath(org, resolved.ID), + map[string]string{"name": args[1]}, &resp); err != nil { + return asCLIError(err) + } + env := output.NewEnvelope("projects rename", resp, + fmt.Sprintf("Renamed %s to %s", resolved.ID, args[1]), nil) + return writeEnvelope(cmd, env) +} + +func disableNeedsInteractiveConfirm(cmd *cobra.Command) bool { + return interactiveText(cmd) +} + +func confirmDisable(cmd *cobra.Command, name string) (bool, error) { + ok, err := confirmPrompt(fmt.Sprintf("Disable project %s?", name)) + if err != nil { + if errors.Is(err, prompt.ErrNotInteractive) { + return false, output.NewCLIError(output.ErrUsage, + "disabling stops the project's renders", + "Re-run with --yes to confirm non-interactively.") + } + return false, output.NewCLIError(output.ErrUsage, err.Error(), + "Re-run with --yes to confirm non-interactively.") + } + return ok, nil +} + +func runProjectsSetEnabled(cmd *cobra.Command, args []string, enabled, yes bool) error { + if !enabled && !yes && !disableNeedsInteractiveConfirm(cmd) { + return output.NewCLIError(output.ErrUsage, + "disabling stops the project's renders", + "Re-run with --yes to confirm.") + } + sess, cliErr := loadSession(cmd) + if cliErr != nil { + return cliErr + } + org, orgErr := requireActiveOrg(sess) + if orgErr != nil { + return orgErr + } + if enabled { + resolved, resErr := resolveProjectArg(sess, args[0]) + if resErr != nil { + return resErr + } + return patchEnabled(cmd, sess, org, resolved, true) + } + resolved, currentEnabled, resErr := resolveProjectArgWithEnabled(sess, args[0]) + if resErr != nil { + return resErr + } + name := resolved.Name + if name == "" { + name = resolved.ID + } + if currentEnabled != nil && !*currentEnabled { + env := output.NewEnvelope("projects disable", + map[string]any{"id": resolved.ID, "enabled": false}, + fmt.Sprintf("%s is already disabled", name), nil) + return writeEnvelope(cmd, env) + } + if !yes { + ok, confirmErr := confirmDisable(cmd, name) + if confirmErr != nil { + return confirmErr + } + if !ok { + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Left %s enabled.\n", name) + return nil + } + } + return patchEnabled(cmd, sess, org, resolved, false) +} + +func patchEnabled(cmd *cobra.Command, sess *sessionState, org string, resolved nameID, enabled bool) error { + var resp map[string]any + if err := sess.Client.PatchJSON(context.Background(), projectPath(org, resolved.ID), + map[string]bool{"enabled": enabled}, &resp); err != nil { + return asCLIError(err) + } + verb := "Enabled" + command := "projects enable" + if !enabled { + verb = "Disabled" + command = "projects disable" + } + env := output.NewEnvelope(command, resp, + fmt.Sprintf("%s %s", verb, resolved.ID), nil) + return writeEnvelope(cmd, env) +} + +func runProjectsDelete(cmd *cobra.Command, args []string, yes bool) error { + sess, cliErr := loadSession(cmd) + if cliErr != nil { + return cliErr + } + org, orgErr := requireActiveOrg(sess) + if orgErr != nil { + return orgErr + } + resolved, resErr := resolveProjectArg(sess, args[0]) + if resErr != nil { + return resErr + } + if !yes { + name := resolved.Name + if name == "" { + name = resolved.ID + } + if err := prompt.TypeToConfirm(fmt.Sprintf("Type %q to confirm deletion:", name), name); err != nil { + if errors.Is(err, prompt.ErrNotInteractive) { + return output.NewCLIError(output.ErrUsage, + "deletion needs confirmation", + "Re-run with --yes to confirm non-interactively.") + } + return output.NewCLIError(output.ErrUsage, err.Error(), + "Re-run with --yes to confirm non-interactively.") + } + } + if err := sess.Client.DeleteJSON(context.Background(), projectPath(org, resolved.ID), nil); err != nil { + return asCLIError(err) + } + data := map[string]any{"deleted": resolved.ID} + summary := fmt.Sprintf("Deleted project %s", resolved.ID) + wasActive := resolved.ID == sess.Profile.ActiveProject + if wasActive { + data["was_active"] = true + summary = fmt.Sprintf("Deleted project %s (was your active project)", resolved.ID) + if nowActive, cliErr := reResolveActiveAfterDelete(cmd, sess, org); cliErr != nil { + return cliErr + } else if nowActive.ID != "" { + data["now_active"] = map[string]any{"id": nowActive.ID, "name": nowActive.Name} + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Now active: %s\n", nowActive.Name) + } else { + _, _ = fmt.Fprintln(cmd.ErrOrStderr(), "Select one with `urlbox projects select`.") + } + } + env := output.NewEnvelope("projects delete", data, summary, nil) + return writeEnvelope(cmd, env) +} + +func clearActiveProject(sess *sessionState) *output.CLIError { + return updateProfile(sess.ProfileName, func(p *config.Profile) { + p.ActiveProject = "" + p.APIKey = "" + p.APISecret = "" + }) +} + +func reResolveActiveAfterDelete(cmd *cobra.Command, sess *sessionState, org string) (nameID, *output.CLIError) { + projects, err := fetchList(context.Background(), sess.Client, "/v2/projects", "projects") + if err != nil { + return nameID{}, clearActiveProject(sess) + } + rows := toNameIDs(projects) + switch { + case len(rows) == 0: + return nameID{}, clearActiveProject(sess) + case len(rows) == 1: + return activateSurvivor(cmd, sess, org, rows[0]) + case interactiveText(cmd): + names := make([]string, len(rows)) + for i, r := range rows { + names[i] = r.Name + } + idx, perr := deleteProjectPick("Select the active project (used by render):", names, 0) + if perr != nil || idx < 0 || idx >= len(rows) { + return nameID{}, clearActiveProject(sess) + } + return activateSurvivor(cmd, sess, org, rows[idx]) + default: + return nameID{}, clearActiveProject(sess) + } +} + +func activateSurvivor(cmd *cobra.Command, sess *sessionState, org string, survivor nameID) (nameID, *output.CLIError) { + if cliErr := clearActiveProject(sess); cliErr != nil { + return nameID{}, cliErr + } + if cliErr := updateProfile(sess.ProfileName, func(p *config.Profile) { p.ActiveProject = survivor.ID }); cliErr != nil { + return nameID{}, cliErr + } + if cred, _, credErr := ensureRenderCredential(context.Background(), sess.Client, org, survivor.ID, interactiveText(cmd), deleteProjectPick); credErr == nil && cred.secret != "" { + _ = updateProfile(sess.ProfileName, func(p *config.Profile) { + p.APIKey = cred.key + p.APISecret = cred.secret + }) + } + return survivor, nil +} + +func runProjectsDefaultsShow(cmd *cobra.Command, args []string) error { + sess, cliErr := loadSession(cmd) + if cliErr != nil { + return cliErr + } + org, orgErr := requireActiveOrg(sess) + if orgErr != nil { + return orgErr + } + resolved, resErr := resolveProjectArg(sess, args[0]) + if resErr != nil { + return resErr + } + var resp map[string]any + if err := sess.Client.GetJSON(context.Background(), projectPath(org, resolved.ID), &resp); err != nil { + return asCLIError(err) + } + defaults := map[string]any{} + if d, ok := resp["defaultOptions"].(map[string]any); ok { + defaults = d + } + env := output.NewEnvelope("projects defaults show", + map[string]any{"project": resolved.ID, "defaults": defaults}, + fmt.Sprintf("%d default options on %s", len(defaults), resolved.ID), nil) + env.SetKV(optionsKVPairs(defaults)) + return writeEnvelope(cmd, env) +} + +func runProjectsDefaultsSet(cmd *cobra.Command, args []string, jsonBody string, merge bool) error { + sess, cliErr := loadSession(cmd) + if cliErr != nil { + return cliErr + } + org, orgErr := requireActiveOrg(sess) + if orgErr != nil { + return orgErr + } + if jsonBody == "" { + return output.NewCLIError(output.ErrUsage, "missing --json", + `Pass the defaults as a JSON object: --json '{"format":"png"}'.`) + } + var options map[string]any + if err := json.Unmarshal([]byte(jsonBody), &options); err != nil { + return output.NewCLIError(output.ErrUsage, "--json is not a valid JSON object: "+err.Error(), + `Example: --json '{"format":"png","full_page":true}'.`) + } + resolved, resErr := resolveProjectArg(sess, args[0]) + if resErr != nil { + return resErr + } + final := options + if merge { + var current map[string]any + if err := sess.Client.GetJSON(context.Background(), projectPath(org, resolved.ID), ¤t); err != nil { + return asCLIError(err) + } + merged := map[string]any{} + if d, ok := current["defaultOptions"].(map[string]any); ok { + for k, v := range d { + merged[k] = v + } + } + for k, v := range options { + merged[k] = v + } + final = merged + } + var resp map[string]any + if err := sess.Client.PatchJSON(context.Background(), + projectPath(org, resolved.ID)+"/render-defaults", + map[string]any{"options": final}, &resp); err != nil { + return asCLIError(err) + } + env := output.NewEnvelope("projects defaults set", resp, + fmt.Sprintf("Set %d default options on %s", len(final), resolved.ID), nil) + return writeEnvelope(cmd, env) +} + +func runProjectsDefaultsRemove(cmd *cobra.Command, args []string, yes bool) error { + sess, cliErr := loadSession(cmd) + if cliErr != nil { + return cliErr + } + org, orgErr := requireActiveOrg(sess) + if orgErr != nil { + return orgErr + } + resolved, resErr := resolveProjectArg(sess, args[0]) + if resErr != nil { + return resErr + } + if !yes { + name := resolved.Name + if name == "" { + name = resolved.ID + } + if err := prompt.TypeToConfirm(fmt.Sprintf("Type %q to confirm removing defaults:", name), name); err != nil { + if errors.Is(err, prompt.ErrNotInteractive) { + return output.NewCLIError(output.ErrUsage, + "removing defaults needs confirmation", + "Re-run with --yes to confirm non-interactively.") + } + return output.NewCLIError(output.ErrUsage, err.Error(), + "Re-run with --yes to confirm non-interactively.") + } + } + var resp map[string]any + if err := sess.Client.PatchJSON(context.Background(), + projectPath(org, resolved.ID)+"/render-defaults", + map[string]any{"options": nil}, &resp); err != nil { + return asCLIError(err) + } + env := output.NewEnvelope("projects defaults remove", resp, + fmt.Sprintf("Removed default options from %s", resolved.ID), nil) + return writeEnvelope(cmd, env) +} diff --git a/internal/cmd/projects_assign_test.go b/internal/cmd/projects_assign_test.go new file mode 100644 index 0000000..7f4c633 --- /dev/null +++ b/internal/cmd/projects_assign_test.go @@ -0,0 +1,246 @@ +package cmd + +import ( + "bytes" + "testing" + + "github.com/urlbox/urlbox-cli/internal/api/apitest" +) + +const projectAssignedJSON = `{"id":"proj_1","name":"Main","enabled":true,"storageCredentialId":"store_1","proxyId":"pool_1","llmCredentialId":"llm_1","createdAt":"2026-08-01T00:00:00.000Z"}` + +const projectUnassignedJSON = `{"id":"proj_1","name":"Main","enabled":true,"storageCredentialId":null,"proxyId":null,"llmCredentialId":null,"createdAt":"2026-08-01T00:00:00.000Z"}` + +const projectListJSON = `{"projects":[{"id":"proj_1","name":"Main"}]}` + +func TestProjectsStorageAssignResolvesBothAndPuts(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New( + apitest.SuccessJSON(projectListJSON), + apitest.SuccessJSON(storageListJSON), + apitest.SuccessJSON(projectAssignedJSON), + ) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + var stdout, stderr bytes.Buffer + code := Execute([]string{"projects", "storage", "assign", "main", "prod", "--output-format", "text"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit %d\n%s\n%s", code, stdout.String(), stderr.String()) + } + reqs := srv.Requests() + put := reqs[len(reqs)-1] + if put.Method != "PUT" || put.Path != "/v2/organisation/org_compat/projects/proj_1/storage-credential" { + t.Fatalf("assign request: %+v", put) + } + if !bytes.Contains(put.Body, []byte(`"storageCredentialId":"store_1"`)) { + t.Fatalf("assign body missing storageCredentialId: %s", put.Body) + } + if !bytes.Contains(stdout.Bytes(), []byte("Assigned prod to Main")) { + t.Fatalf("summary must name both credential and project: %s", stdout.String()) + } +} + +func TestProjectsProxyAssignResolvesBothAndPuts(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New( + apitest.SuccessJSON(projectListJSON), + apitest.SuccessJSON(proxyListJSON), + apitest.SuccessJSON(projectAssignedJSON), + ) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + var stdout, stderr bytes.Buffer + code := Execute([]string{"projects", "proxy", "assign", "main", "eu", "--output-format", "text"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit %d\n%s\n%s", code, stdout.String(), stderr.String()) + } + reqs := srv.Requests() + put := reqs[len(reqs)-1] + if put.Method != "PUT" || put.Path != "/v2/organisation/org_compat/projects/proj_1/proxy" { + t.Fatalf("assign request: %+v", put) + } + if !bytes.Contains(put.Body, []byte(`"proxyId":"pool_1"`)) { + t.Fatalf("assign body missing proxyId: %s", put.Body) + } + if !bytes.Contains(stdout.Bytes(), []byte("Assigned eu to Main")) { + t.Fatalf("summary must name both pool and project: %s", stdout.String()) + } +} + +func TestProjectsLlmAssignResolvesBothAndPuts(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New( + apitest.SuccessJSON(projectListJSON), + apitest.SuccessJSON(llmListJSON), + apitest.SuccessJSON(projectAssignedJSON), + ) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + var stdout, stderr bytes.Buffer + code := Execute([]string{"projects", "llm", "assign", "main", "openai-prod", "--output-format", "text"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit %d\n%s\n%s", code, stdout.String(), stderr.String()) + } + reqs := srv.Requests() + put := reqs[len(reqs)-1] + if put.Method != "PUT" || put.Path != "/v2/organisation/org_compat/projects/proj_1/llm-credential" { + t.Fatalf("assign request: %+v", put) + } + if !bytes.Contains(put.Body, []byte(`"llmCredentialId":"llm_1"`)) { + t.Fatalf("assign body missing llmCredentialId: %s", put.Body) + } + if !bytes.Contains(stdout.Bytes(), []byte("Assigned openai-prod to Main")) { + t.Fatalf("summary must name both credential and project: %s", stdout.String()) + } +} + +func TestProjectsStorageUnassignDeletes(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New( + apitest.SuccessJSON(projectListJSON), + apitest.SuccessJSON(projectUnassignedJSON), + ) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + var stdout, stderr bytes.Buffer + code := Execute([]string{"projects", "storage", "unassign", "main", "--output-format", "text"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit %d\n%s\n%s", code, stdout.String(), stderr.String()) + } + reqs := srv.Requests() + del := reqs[len(reqs)-1] + if del.Method != "DELETE" || del.Path != "/v2/organisation/org_compat/projects/proj_1/storage-credential" { + t.Fatalf("unassign request: %+v", del) + } + if !bytes.Contains(stdout.Bytes(), []byte("Unassigned the storage credential from Main")) { + t.Fatalf("summary must name the noun and project: %s", stdout.String()) + } +} + +func TestProjectsProxyUnassignDeletes(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New( + apitest.SuccessJSON(projectListJSON), + apitest.SuccessJSON(projectUnassignedJSON), + ) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + var stdout, stderr bytes.Buffer + code := Execute([]string{"projects", "proxy", "unassign", "main", "--output-format", "text"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit %d\n%s\n%s", code, stdout.String(), stderr.String()) + } + reqs := srv.Requests() + del := reqs[len(reqs)-1] + if del.Method != "DELETE" || del.Path != "/v2/organisation/org_compat/projects/proj_1/proxy" { + t.Fatalf("unassign request: %+v", del) + } + if !bytes.Contains(stdout.Bytes(), []byte("Unassigned the proxy pool from Main")) { + t.Fatalf("summary must name the noun and project: %s", stdout.String()) + } +} + +func TestProjectsLlmUnassignDeletes(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New( + apitest.SuccessJSON(projectListJSON), + apitest.SuccessJSON(projectUnassignedJSON), + ) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + var stdout, stderr bytes.Buffer + code := Execute([]string{"projects", "llm", "unassign", "main", "--output-format", "text"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit %d\n%s\n%s", code, stdout.String(), stderr.String()) + } + reqs := srv.Requests() + del := reqs[len(reqs)-1] + if del.Method != "DELETE" || del.Path != "/v2/organisation/org_compat/projects/proj_1/llm-credential" { + t.Fatalf("unassign request: %+v", del) + } + if !bytes.Contains(stdout.Bytes(), []byte("Unassigned the LLM credential from Main")) { + t.Fatalf("summary must name the noun and project: %s", stdout.String()) + } +} + +func TestProjectsAssignJSONCarriesReturnedProject(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New( + apitest.SuccessJSON(projectListJSON), + apitest.SuccessJSON(storageListJSON), + apitest.SuccessJSON(projectAssignedJSON), + ) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + var stdout, stderr bytes.Buffer + code := Execute([]string{"projects", "storage", "assign", "proj_1", "store_1", "--output-format", "json"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit %d\n%s\n%s", code, stdout.String(), stderr.String()) + } + for _, want := range []string{`"storageCredentialId": "store_1"`, `"id": "proj_1"`} { + if !bytes.Contains(stdout.Bytes(), []byte(want)) { + t.Fatalf("json envelope must carry the returned project, missing %s: %s", want, stdout.String()) + } + } +} + +func TestProjectsAssignUnknownProjectHint(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New(apitest.SuccessJSON(projectListJSON)) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + var stdout, stderr bytes.Buffer + code := Execute([]string{"projects", "storage", "assign", "nope", "store_1", "--output-format", "json"}, &stdout, &stderr) + if code == 0 { + t.Fatalf("assign to an unknown project must fail\n%s", stdout.String()) + } + if !bytes.Contains(stdout.Bytes(), []byte("urlbox projects list")) { + t.Fatalf("not-found hint must name `urlbox projects list`: %s", stdout.String()) + } + for _, r := range srv.Requests() { + if r.Method == "PUT" { + t.Fatalf("no PUT must be issued when the project is unknown: %+v", r) + } + } +} + +func TestProjectsAssignUnknownCredHint(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New( + apitest.SuccessJSON(projectListJSON), + apitest.SuccessJSON(storageListJSON), + ) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + var stdout, stderr bytes.Buffer + code := Execute([]string{"projects", "storage", "assign", "main", "nope", "--output-format", "json"}, &stdout, &stderr) + if code == 0 { + t.Fatalf("assign of an unknown credential must fail\n%s", stdout.String()) + } + if !bytes.Contains(stdout.Bytes(), []byte("urlbox storage list")) { + t.Fatalf("not-found hint must name `urlbox storage list`: %s", stdout.String()) + } + for _, r := range srv.Requests() { + if r.Method == "PUT" { + t.Fatalf("no PUT must be issued when the credential is unknown: %+v", r) + } + } +} diff --git a/internal/cmd/projects_crud_test.go b/internal/cmd/projects_crud_test.go new file mode 100644 index 0000000..cfd95c0 --- /dev/null +++ b/internal/cmd/projects_crud_test.go @@ -0,0 +1,696 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/urlbox/urlbox-cli/internal/api/apitest" +) + +func writeCompatConfigNoOrg(t *testing.T, dir string) { + t.Helper() + cfg := map[string]any{ + "default_profile": "default", + "profiles": map[string]any{ + "default": map[string]string{ + "api_key": "pk_test_key", + "api_secret": compatSecret, + "session_token": "sess_tok_compat_123456", + "active_project": "proj_compat", + }, + }, + } + b, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + t.Fatalf("marshal: %v", err) + } + if err := os.MkdirAll(filepath.Join(dir, "urlbox"), 0o700); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, "urlbox", "config.json"), b, 0o600); err != nil { + t.Fatalf("write: %v", err) + } +} + +func TestProjectsCreate(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New(apitest.SuccessJSON(`{"project":{"id":"proj_new","name":"Fresh"}}`)) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + var stdout, stderr bytes.Buffer + code := Execute([]string{"projects", "create", "Fresh", "--output-format", "json"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit %d\n%s\n%s", code, stdout.String(), stderr.String()) + } + reqs := srv.Requests() + if reqs[0].Method != "POST" || reqs[0].Path != "/v2/projects" { + t.Fatalf("request: %+v", reqs[0]) + } + if !bytes.Contains(reqs[0].Body, []byte(`"name":"Fresh"`)) { + t.Fatalf("body: %s", reqs[0].Body) + } +} + +func TestProjectsRename(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New( + apitest.SuccessJSON(`{"projects":[{"id":"proj_1","name":"Old"}]}`), + apitest.SuccessJSON(`{"project":{"id":"proj_1","name":"New"}}`), + ) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + var stdout, stderr bytes.Buffer + code := Execute([]string{"projects", "rename", "old", "New", "--output-format", "json"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit %d\n%s\n%s", code, stdout.String(), stderr.String()) + } + reqs := srv.Requests() + if reqs[1].Method != "PATCH" || reqs[1].Path != "/v2/organisation/org_compat/projects/proj_1" { + t.Fatalf("request: %+v", reqs[1]) + } + if !bytes.Contains(reqs[1].Body, []byte(`"name":"New"`)) { + t.Fatalf("body: %s", reqs[1].Body) + } +} + +func TestProjectsDeleteRequiresYesOffTTY(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New(apitest.SuccessJSON(`{"projects":[{"id":"proj_1","name":"Doomed"}]}`)) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + var stdout, stderr bytes.Buffer + code := Execute([]string{"projects", "delete", "doomed", "--output-format", "json"}, &stdout, &stderr) + if code == 0 { + t.Fatalf("delete without --yes off-TTY must fail\n%s", stdout.String()) + } + if !bytes.Contains(stdout.Bytes(), []byte("--yes")) { + t.Fatalf("error must name --yes: %s", stdout.String()) + } +} + +func TestProjectsDeleteWithYes(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New( + apitest.SuccessJSON(`{"projects":[{"id":"proj_1","name":"Doomed"}]}`), + apitest.SuccessJSON(`{}`), + ) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + var stdout, stderr bytes.Buffer + code := Execute([]string{"projects", "delete", "doomed", "--yes", "--output-format", "json"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit %d\n%s\n%s", code, stdout.String(), stderr.String()) + } + reqs := srv.Requests() + if reqs[1].Method != "DELETE" || reqs[1].Path != "/v2/organisation/org_compat/projects/proj_1" { + t.Fatalf("request: %+v", reqs[1]) + } +} + +func readProfile(t *testing.T, dir string) map[string]string { + t.Helper() + b, err := os.ReadFile(filepath.Join(dir, "urlbox", "config.json")) + if err != nil { + t.Fatalf("read config: %v", err) + } + var cfg struct { + Profiles map[string]map[string]string `json:"profiles"` + } + if err := json.Unmarshal(b, &cfg); err != nil { + t.Fatalf("unmarshal: %v", err) + } + return cfg.Profiles["default"] +} + +func TestProjectsDeleteActiveSeveralRemainingOffTTYClearsProfile(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New( + apitest.SuccessJSON(`{"projects":[{"id":"proj_compat","name":"Doomed"}]}`), + apitest.SuccessJSON(`{}`), + apitest.SuccessJSON(`{"projects":[{"id":"proj_a","name":"Alpha"},{"id":"proj_b","name":"Beta"}]}`), + ) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + var stdout, stderr bytes.Buffer + code := Execute([]string{"projects", "delete", "doomed", "--yes", "--output-format", "json"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit %d\n%s\n%s", code, stdout.String(), stderr.String()) + } + reqs := srv.Requests() + if reqs[1].Method != "DELETE" || reqs[1].Path != "/v2/organisation/org_compat/projects/proj_compat" { + t.Fatalf("request: %+v", reqs[1]) + } + if len(reqs) != 3 { + t.Fatalf("several-remaining off-TTY must fetch the list then stop, got %d: %+v", len(reqs), reqs) + } + profile := readProfile(t, dir) + for _, key := range []string{"active_project", "api_key", "api_secret"} { + if profile[key] != "" { + t.Fatalf("deleting the active project must clear %s on disk, got %q", key, profile[key]) + } + } + if !bytes.Contains(stdout.Bytes(), []byte("(was your active project)")) { + t.Fatalf("summary must mark the active project: %s", stdout.String()) + } + if bytes.Contains(stdout.Bytes(), []byte("now_active")) { + t.Fatalf("off-TTY must not auto-select a survivor: %s", stdout.String()) + } + if !strings.Contains(stderr.String(), "urlbox projects select") { + t.Fatalf("stderr must point at projects select: %q", stderr.String()) + } +} + +func TestProjectsDeleteActiveOneRemainingAutoSelects(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New( + apitest.SuccessJSON(`{"projects":[{"id":"proj_compat","name":"Doomed"}]}`), + apitest.SuccessJSON(`{}`), + apitest.SuccessJSON(`{"projects":[{"id":"proj_solo","name":"Solo"}]}`), + apitest.SuccessJSON(`{"apiCredentials":[{"apiKey":"pk_solo","apiSecret":"sk_solo","revoked":false}]}`), + ) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + var stdout, stderr bytes.Buffer + code := Execute([]string{"projects", "delete", "doomed", "--yes", "--output-format", "json"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit %d\n%s\n%s", code, stdout.String(), stderr.String()) + } + reqs := srv.Requests() + if reqs[3].Method != "GET" || reqs[3].Path != "/v2/organisation/org_compat/projects/proj_solo/api-credentials" { + t.Fatalf("survivor credential must be fetched: %+v", reqs[3]) + } + profile := readProfile(t, dir) + if profile["active_project"] != "proj_solo" || profile["api_key"] != "pk_solo" || profile["api_secret"] != "sk_solo" { + t.Fatalf("the lone survivor must become active with its credential: %#v", profile) + } + if !strings.Contains(stderr.String(), "Now active: Solo") { + t.Fatalf("stderr must report the new active project: %q", stderr.String()) + } + for _, want := range []string{`"now_active"`, `"proj_solo"`, `"Solo"`} { + if !bytes.Contains(stdout.Bytes(), []byte(want)) { + t.Fatalf("data must carry now_active %s: %s", want, stdout.String()) + } + } + if !bytes.Contains(stdout.Bytes(), []byte("(was your active project)")) { + t.Fatalf("summary must still mark the deleted project: %s", stdout.String()) + } +} + +func TestProjectsDeleteActiveSeveralRemainingPickerSelects(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New( + apitest.SuccessJSON(`{"projects":[{"id":"proj_compat","name":"Doomed"}]}`), + apitest.SuccessJSON(`{}`), + apitest.SuccessJSON(`{"projects":[{"id":"proj_a","name":"Alpha"},{"id":"proj_b","name":"Beta"}]}`), + apitest.SuccessJSON(`{"apiCredentials":[{"apiKey":"pk_beta","apiSecret":"sk_beta","revoked":false}]}`), + ) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + + picked := false + SetDeleteProjectPickForTest(func(_ string, options []string, active int) (int, error) { + picked = true + if len(options) != 2 { + t.Fatalf("picker options = %v, want 2", options) + } + if active != 0 { + t.Fatalf("picker must seed at index 0, got %d", active) + } + return 1, nil + }) + t.Cleanup(ResetDeleteProjectPickForTest) + + var stdout, stderr bytes.Buffer + code := Execute([]string{"projects", "delete", "doomed", "--yes", "--output-format", "text"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit %d\n%s\n%s", code, stdout.String(), stderr.String()) + } + if !picked { + t.Fatal("interactive survivor picker was not invoked") + } + profile := readProfile(t, dir) + if profile["active_project"] != "proj_b" || profile["api_key"] != "pk_beta" || profile["api_secret"] != "sk_beta" { + t.Fatalf("picked survivor must become active with its credential: %#v", profile) + } + if !strings.Contains(stderr.String(), "Now active: Beta") { + t.Fatalf("stderr must report the picked project: %q", stderr.String()) + } +} + +func TestProjectsDeleteActiveOneRemainingOffTTYIssuesWithoutPrompt(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New( + apitest.SuccessJSON(`{"projects":[{"id":"proj_compat","name":"Doomed"}]}`), + apitest.SuccessJSON(`{}`), + apitest.SuccessJSON(`{"projects":[{"id":"proj_solo","name":"Solo"}]}`), + apitest.SuccessJSON(`{"apiCredentials":[]}`), + apitest.SuccessJSON(`{"apiKey":"pk_solo","apiSecret":"sk_solo"}`), + ) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + + SetDeleteProjectPickForTest(func(_ string, _ []string, _ int) (int, error) { + t.Fatalf("off-TTY survivor path must not prompt to issue a credential") + return 0, nil + }) + t.Cleanup(ResetDeleteProjectPickForTest) + + var stdout, stderr bytes.Buffer + code := Execute([]string{"projects", "delete", "doomed", "--yes"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit %d\n%s\n%s", code, stdout.String(), stderr.String()) + } + reqs := srv.Requests() + if reqs[3].Method != "GET" || reqs[3].Path != "/v2/organisation/org_compat/projects/proj_solo/api-credentials" { + t.Fatalf("survivor credential must be fetched: %+v", reqs[3]) + } + if reqs[4].Method != "POST" || reqs[4].Path != "/v2/organisation/org_compat/projects/proj_solo/api-credentials" { + t.Fatalf("off-TTY survivor lacking a credential must issue one silently: %+v", reqs[4]) + } + profile := readProfile(t, dir) + if profile["active_project"] != "proj_solo" || profile["api_key"] != "pk_solo" || profile["api_secret"] != "sk_solo" { + t.Fatalf("the lone survivor must become active with its issued credential: %#v", profile) + } +} + +func TestProjectsDeleteActiveListErrorFallsBackToPointer(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New( + apitest.SuccessJSON(`{"projects":[{"id":"proj_compat","name":"Doomed"}]}`), + apitest.SuccessJSON(`{}`), + apitest.ServerError(500), + ) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + var stdout, stderr bytes.Buffer + code := Execute([]string{"projects", "delete", "doomed", "--yes", "--output-format", "json"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("a failed list re-resolve must not fail the delete: exit %d\n%s\n%s", code, stdout.String(), stderr.String()) + } + profile := readProfile(t, dir) + for _, key := range []string{"active_project", "api_key", "api_secret"} { + if profile[key] != "" { + t.Fatalf("list error must clear %s on disk, got %q", key, profile[key]) + } + } + if bytes.Contains(stdout.Bytes(), []byte("now_active")) { + t.Fatalf("list error must not auto-select a survivor: %s", stdout.String()) + } + if !strings.Contains(stderr.String(), "urlbox projects select") { + t.Fatalf("stderr must point at projects select: %q", stderr.String()) + } +} + +func TestProjectsDeleteNonActiveLeavesProfileUnchanged(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + before, err := os.ReadFile(filepath.Join(dir, "urlbox", "config.json")) + if err != nil { + t.Fatalf("read config: %v", err) + } + srv := apitest.New( + apitest.SuccessJSON(`{"projects":[{"id":"proj_other","name":"Doomed"}]}`), + apitest.SuccessJSON(`{}`), + ) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + var stdout, stderr bytes.Buffer + code := Execute([]string{"projects", "delete", "doomed", "--yes", "--output-format", "json"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit %d\n%s\n%s", code, stdout.String(), stderr.String()) + } + after, err := os.ReadFile(filepath.Join(dir, "urlbox", "config.json")) + if err != nil { + t.Fatalf("read config: %v", err) + } + if !bytes.Equal(before, after) { + t.Fatalf("deleting a non-active project must leave the profile bytes unchanged\nbefore: %s\nafter: %s", before, after) + } + if bytes.Contains(stdout.Bytes(), []byte("was your active project")) { + t.Fatalf("non-active delete must not claim the active project: %s", stdout.String()) + } +} + +func TestProjectsDefaultsSetAndRemove(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New( + apitest.SuccessJSON(`{"projects":[{"id":"proj_1","name":"Main"}]}`), + apitest.SuccessJSON(`{"project":{"id":"proj_1","defaultOptions":{"format":"png"}}}`), + apitest.SuccessJSON(`{"projects":[{"id":"proj_1","name":"Main"}]}`), + apitest.SuccessJSON(`{"project":{"id":"proj_1"}}`), + ) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + + var stdout, stderr bytes.Buffer + code := Execute([]string{"projects", "defaults", "set", "main", "--json", `{"format":"png"}`, "--output-format", "json"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("set exit %d\n%s\n%s", code, stdout.String(), stderr.String()) + } + reqs := srv.Requests() + if reqs[1].Method != "PATCH" || reqs[1].Path != "/v2/organisation/org_compat/projects/proj_1/render-defaults" { + t.Fatalf("set request: %+v", reqs[1]) + } + if !bytes.Contains(reqs[1].Body, []byte(`"format":"png"`)) { + t.Fatalf("set body: %s", reqs[1].Body) + } + + stdout.Reset() + stderr.Reset() + code = Execute([]string{"projects", "defaults", "remove", "main", "--yes", "--output-format", "json"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("remove exit %d\n%s\n%s", code, stdout.String(), stderr.String()) + } + reqs = srv.Requests() + if !bytes.Contains(reqs[3].Body, []byte(`"options":null`)) { + t.Fatalf("remove body: %s", reqs[3].Body) + } +} + +func TestProjectsDefaultsShowReadsFlatShape(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New( + apitest.SuccessJSON(`{"projects":[{"id":"proj_1","name":"Main"}]}`), + apitest.SuccessJSON(`{"id":"proj_1","name":"Main","defaultOptions":{"width":1280,"format":"png"}}`), + ) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + var stdout, stderr bytes.Buffer + code := Execute([]string{"projects", "defaults", "show", "main", "--output-format", "json"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit %d\n%s\n%s", code, stdout.String(), stderr.String()) + } + for _, want := range []string{`"width": 1280`, `"format": "png"`, `2 default options`} { + if !bytes.Contains(stdout.Bytes(), []byte(want)) { + t.Fatalf("defaults show must read flat defaultOptions, missing %s: %s", want, stdout.String()) + } + } +} + +func TestProjectsDefaultsSetMergeReadsFlatShape(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New( + apitest.SuccessJSON(`{"projects":[{"id":"proj_1","name":"Main"}]}`), + apitest.SuccessJSON(`{"id":"proj_1","name":"Main","defaultOptions":{"a":1}}`), + apitest.SuccessJSON(`{"id":"proj_1"}`), + ) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + var stdout, stderr bytes.Buffer + code := Execute([]string{"projects", "defaults", "set", "main", "--merge", "--json", `{"b":2}`, "--output-format", "json"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit %d\n%s\n%s", code, stdout.String(), stderr.String()) + } + reqs := srv.Requests() + patch := reqs[len(reqs)-1] + if patch.Method != "PATCH" || patch.Path != "/v2/organisation/org_compat/projects/proj_1/render-defaults" { + t.Fatalf("merge PATCH request: %+v", patch) + } + for _, want := range []string{`"a":1`, `"b":2`} { + if !bytes.Contains(patch.Body, []byte(want)) { + t.Fatalf("merge must PATCH existing + new keys, missing %s: %s", want, patch.Body) + } + } +} + +func TestProjectsDefaultsRemoveRequiresYesOffTTY(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New(apitest.SuccessJSON(`{"projects":[{"id":"proj_1","name":"Main"}]}`)) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + var stdout, stderr bytes.Buffer + code := Execute([]string{"projects", "defaults", "remove", "main", "--output-format", "json"}, &stdout, &stderr) + if code == 0 { + t.Fatalf("remove without --yes off-TTY must fail\n%s", stdout.String()) + } + if !bytes.Contains(stdout.Bytes(), []byte("--yes")) { + t.Fatalf("error must name --yes: %s", stdout.String()) + } + for _, r := range srv.Requests() { + if r.Method == "PATCH" { + t.Fatalf("no PATCH must be made without confirmation: %+v", r) + } + } +} + +func TestProjectsDisableRequiresYes(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New(apitest.SuccessJSON(`{"projects":[{"id":"proj_1","name":"Main"}]}`)) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + var stdout, stderr bytes.Buffer + code := Execute([]string{"projects", "disable", "main", "--output-format", "json"}, &stdout, &stderr) + if code == 0 { + t.Fatalf("disable without --yes must fail\n%s", stdout.String()) + } + if !bytes.Contains(stdout.Bytes(), []byte("--yes")) { + t.Fatalf("error must name --yes: %s", stdout.String()) + } + if len(srv.Requests()) != 0 { + t.Fatalf("no API call must be made without --yes: %+v", srv.Requests()) + } +} + +func TestProjectsDisableWithYes(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New( + apitest.SuccessJSON(`{"projects":[{"id":"proj_1","name":"Main"}]}`), + apitest.SuccessJSON(`{"project":{"id":"proj_1","enabled":false}}`), + ) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + var stdout, stderr bytes.Buffer + code := Execute([]string{"projects", "disable", "main", "--yes", "--output-format", "json"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit %d\n%s\n%s", code, stdout.String(), stderr.String()) + } + reqs := srv.Requests() + if reqs[1].Method != "PATCH" || reqs[1].Path != "/v2/organisation/org_compat/projects/proj_1" { + t.Fatalf("request: %+v", reqs[1]) + } + if !bytes.Contains(reqs[1].Body, []byte(`"enabled":false`)) { + t.Fatalf("body: %s", reqs[1].Body) + } +} + +func TestProjectsDisableAcceptedConfirmDisables(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New( + apitest.SuccessJSON(`{"projects":[{"id":"proj_1","name":"Main"}]}`), + apitest.SuccessJSON(`{"project":{"id":"proj_1","enabled":false}}`), + ) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + + SetConfirmPromptForTest(func(string) (bool, error) { return true, nil }) + t.Cleanup(ResetConfirmPromptForTest) + + var stdout, stderr bytes.Buffer + code := Execute([]string{"projects", "disable", "main", "--output-format", "text"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit %d\n%s\n%s", code, stdout.String(), stderr.String()) + } + reqs := srv.Requests() + if len(reqs) != 2 { + t.Fatalf("accepted confirm must resolve then PATCH, got %d: %+v", len(reqs), reqs) + } + if reqs[1].Method != "PATCH" || reqs[1].Path != "/v2/organisation/org_compat/projects/proj_1" { + t.Fatalf("request: %+v", reqs[1]) + } + if !bytes.Contains(reqs[1].Body, []byte(`"enabled":false`)) { + t.Fatalf("body: %s", reqs[1].Body) + } +} + +func TestProjectsDisableDeclinedConfirmMakesNoPatch(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New(apitest.SuccessJSON(`{"projects":[{"id":"proj_1","name":"Main"}]}`)) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + + SetConfirmPromptForTest(func(string) (bool, error) { return false, nil }) + t.Cleanup(ResetConfirmPromptForTest) + + var stdout, stderr bytes.Buffer + code := Execute([]string{"projects", "disable", "main", "--output-format", "text"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("declining the disable must exit 0, got %d\n%s\n%s", code, stdout.String(), stderr.String()) + } + for _, r := range srv.Requests() { + if r.Method == "PATCH" { + t.Fatalf("declined disable must make no PATCH: %+v", r) + } + } + if !strings.Contains(stderr.String(), "Left Main enabled.") { + t.Fatalf("declined disable must report the project was left enabled: %q", stderr.String()) + } +} + +func TestProjectsDisableAlreadyDisabledByNameSkipsConfirmAndPatch(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New(apitest.SuccessJSON(`{"projects":[{"id":"proj_1","name":"Main","enabled":false}]}`)) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + + SetConfirmPromptForTest(func(string) (bool, error) { + t.Fatalf("already-disabled project must not prompt for confirmation") + return false, nil + }) + t.Cleanup(ResetConfirmPromptForTest) + + var stdout, stderr bytes.Buffer + code := Execute([]string{"projects", "disable", "main", "--output-format", "text"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("already-disabled must exit 0, got %d\n%s\n%s", code, stdout.String(), stderr.String()) + } + reqs := srv.Requests() + if len(reqs) != 1 { + t.Fatalf("already-disabled must fetch the list only, got %d: %+v", len(reqs), reqs) + } + if reqs[0].Method == "PATCH" { + t.Fatalf("already-disabled must make no PATCH: %+v", reqs[0]) + } + if !bytes.Contains(stdout.Bytes(), []byte("is already disabled")) { + t.Fatalf("summary must state the project is already disabled: %s", stdout.String()) + } +} + +func TestProjectsDisableEnabledByNameConfirmsAndPatches(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New( + apitest.SuccessJSON(`{"projects":[{"id":"proj_1","name":"Main","enabled":true}]}`), + apitest.SuccessJSON(`{"project":{"id":"proj_1","enabled":false}}`), + ) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + + SetConfirmPromptForTest(func(string) (bool, error) { return true, nil }) + t.Cleanup(ResetConfirmPromptForTest) + + var stdout, stderr bytes.Buffer + code := Execute([]string{"projects", "disable", "main", "--output-format", "text"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit %d\n%s\n%s", code, stdout.String(), stderr.String()) + } + reqs := srv.Requests() + if len(reqs) != 2 { + t.Fatalf("enabled project must resolve then PATCH, got %d: %+v", len(reqs), reqs) + } + if reqs[1].Method != "PATCH" || reqs[1].Path != "/v2/organisation/org_compat/projects/proj_1" { + t.Fatalf("request: %+v", reqs[1]) + } + if !bytes.Contains(reqs[1].Body, []byte(`"enabled":false`)) { + t.Fatalf("body: %s", reqs[1].Body) + } +} + +func TestProjectsDisableAlreadyDisabledByPrefixedIDPatches(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New( + apitest.SuccessJSON(`{"projects":[{"id":"proj_1","name":"Main","enabled":false}]}`), + apitest.SuccessJSON(`{"project":{"id":"proj_1","enabled":false}}`), + ) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + + var stdout, stderr bytes.Buffer + code := Execute([]string{"projects", "disable", "proj_1", "--yes", "--output-format", "json"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit %d\n%s\n%s", code, stdout.String(), stderr.String()) + } + reqs := srv.Requests() + if len(reqs) != 2 { + t.Fatalf("prefixed-id disable must not skip the PATCH, got %d: %+v", len(reqs), reqs) + } + if reqs[1].Method != "PATCH" || reqs[1].Path != "/v2/organisation/org_compat/projects/proj_1" { + t.Fatalf("request: %+v", reqs[1]) + } + if !bytes.Contains(reqs[1].Body, []byte(`"enabled":false`)) { + t.Fatalf("body: %s", reqs[1].Body) + } +} + +func TestProjectsDisableAlreadyDisabledByNameWithYesSkipsPatch(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New(apitest.SuccessJSON(`{"projects":[{"id":"proj_1","name":"Main","enabled":false}]}`)) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + + var stdout, stderr bytes.Buffer + code := Execute([]string{"projects", "disable", "main", "--yes", "--output-format", "json"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("already-disabled with --yes must exit 0, got %d\n%s\n%s", code, stdout.String(), stderr.String()) + } + reqs := srv.Requests() + if len(reqs) != 1 { + t.Fatalf("already-disabled with --yes must fetch the list only, got %d: %+v", len(reqs), reqs) + } + if reqs[0].Method == "PATCH" { + t.Fatalf("already-disabled with --yes must make no PATCH: %+v", reqs[0]) + } + if !bytes.Contains(stdout.Bytes(), []byte("is already disabled")) { + t.Fatalf("summary must state the project is already disabled: %s", stdout.String()) + } +} + +func TestProjectsCrudNeedsActiveOrg(t *testing.T) { + dir := t.TempDir() + writeCompatConfigNoOrg(t, dir) + t.Setenv("XDG_CONFIG_HOME", dir) + t.Setenv("URLBOX_API_HOST", "http://127.0.0.1:1") + var stdout, stderr bytes.Buffer + code := Execute([]string{"projects", "rename", "proj_x", "New", "--output-format", "json"}, &stdout, &stderr) + if code != 1 { + t.Fatalf("no active org must be usage exit 1, got %d\n%s", code, stdout.String()) + } + if !bytes.Contains(stdout.Bytes(), []byte("orgs select")) { + t.Fatalf("hint must name orgs select: %s", stdout.String()) + } +} diff --git a/internal/cmd/projects_show_test.go b/internal/cmd/projects_show_test.go new file mode 100644 index 0000000..f083ece --- /dev/null +++ b/internal/cmd/projects_show_test.go @@ -0,0 +1,137 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + + "github.com/urlbox/urlbox-cli/internal/api/apitest" +) + +const projectShowDetailJSON = `{ + "createdAt": "2026-08-14T10:18:34.436Z", + "defaultOptions": null, + "enabled": true, + "engineVersion": "latest", + "id": "proj_o5xpfa7z94", + "llmCredentialId": null, + "mongoProjectId": null, + "name": "plan1-manual-check", + "proxyId": null, + "region": null, + "renderQueue": null, + "storageCredentialId": null, + "tokenless": true, + "webhookKey": "ubx_whk_FAKE0000EXAMPLE0000" +}` + +func TestProjectsShow_TextMode_KVRendersFromFlatShape(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New( + apitest.SuccessJSON(`{"projects":[{"id":"proj_o5xpfa7z94","name":"plan1-manual-check"}]}`), + apitest.SuccessJSON(projectShowDetailJSON), + ) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + + out, stderrOut, code := runTextMode(t, "projects", "show", "plan1-manual-check") + if code != 0 { + t.Fatalf("exit %d\n%s\n%s", code, out, stderrOut) + } + for _, want := range []string{"plan1-manual-check", "proj_o5xpfa7z94", "NAME", "ID", "WEBHOOK KEY"} { + if !strings.Contains(out, want) { + t.Errorf("projects show text KV missing %q, got:\n%s", want, out) + } + } + if !strings.Contains(out, "ubx_…00") { + t.Errorf("webhook key should be masked, got:\n%s", out) + } + if strings.Contains(out, "ubx_whk_FAKE0000EXAMPLE0000") { + t.Errorf("full webhook key must not appear without --reveal, got:\n%s", out) + } +} + +func TestProjectsShow_TextMode_KVSurfacesNameNoSummaryLine(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New( + apitest.SuccessJSON(`{"projects":[{"id":"proj_o5xpfa7z94","name":"plan1-manual-check"}]}`), + apitest.SuccessJSON(projectShowDetailJSON), + ) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + + out, stderrOut, code := runTextMode(t, "projects", "show", "plan1-manual-check") + if code != 0 { + t.Fatalf("exit %d\n%s\n%s", code, out, stderrOut) + } + if !strings.Contains(out, "plan1-manual-check") { + t.Errorf("KV view should surface the project name, got:\n%s", out) + } + if strings.Contains(out, "Project plan1-manual-check") { + t.Errorf("ok+view should omit the 'Project ' summary line, got:\n%s", out) + } +} + +func TestProjectsShow_Reveal_ShowsFullWebhookKey(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New( + apitest.SuccessJSON(`{"projects":[{"id":"proj_o5xpfa7z94","name":"plan1-manual-check"}]}`), + apitest.SuccessJSON(projectShowDetailJSON), + ) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + + out, stderrOut, code := runTextMode(t, "projects", "show", "plan1-manual-check", "--reveal") + if code != 0 { + t.Fatalf("exit %d\n%s\n%s", code, out, stderrOut) + } + if !strings.Contains(out, "ubx_whk_FAKE0000EXAMPLE0000") { + t.Errorf("--reveal should print the full webhook key, got:\n%s", out) + } +} + +func TestProjectsShow_JSON_ByteIdenticalToServerResponse(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New( + apitest.SuccessJSON(`{"projects":[{"id":"proj_o5xpfa7z94","name":"plan1-manual-check"}]}`), + apitest.SuccessJSON(projectShowDetailJSON), + ) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + + var stdout, stderr bytes.Buffer + code := Execute([]string{"projects", "show", "plan1-manual-check", "--output-format", "json"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit %d\n%s\n%s", code, stdout.String(), stderr.String()) + } + var env struct { + Data json.RawMessage `json:"data"` + } + if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { + t.Fatalf("unmarshal envelope: %v\n%s", err, stdout.String()) + } + var got, want map[string]any + if err := json.Unmarshal(env.Data, &got); err != nil { + t.Fatalf("unmarshal data: %v", err) + } + if err := json.Unmarshal([]byte(projectShowDetailJSON), &want); err != nil { + t.Fatalf("unmarshal fixture: %v", err) + } + gb, _ := json.Marshal(got) + wb, _ := json.Marshal(want) + if !bytes.Equal(gb, wb) { + t.Errorf("JSON data must be the raw server response.\n got: %s\nwant: %s", gb, wb) + } + if bytes.Contains(env.Data, []byte("ubx_…")) { + t.Errorf("JSON output must carry the verbatim webhook key, not the masked form:\n%s", env.Data) + } +} diff --git a/internal/cmd/projects_test.go b/internal/cmd/projects_test.go new file mode 100644 index 0000000..d34fdb1 --- /dev/null +++ b/internal/cmd/projects_test.go @@ -0,0 +1,195 @@ +package cmd + +import ( + "bytes" + "strings" + "testing" + + "github.com/urlbox/urlbox-cli/internal/api/apitest" +) + +func TestProjectsListMarksActive(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New( + apitest.SuccessJSON(`{"projects":[{"id":"proj_compat","name":"Main"},{"id":"proj_2","name":"Side"}]}`), + ) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + var stdout, stderr bytes.Buffer + code := Execute([]string{"projects", "list", "--output-format", "json"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit %d\n%s\n%s", code, stdout.String(), stderr.String()) + } + for _, want := range []string{`"proj_compat"`, `"proj_2"`, `"active": true`} { + if !bytes.Contains(stdout.Bytes(), []byte(want)) { + t.Fatalf("missing %s: %s", want, stdout.String()) + } + } +} + +func TestProjectsSelectPositionalRefreshesCredential(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New( + apitest.SuccessJSON(`{"projects":[{"id":"proj_compat","name":"Main"},{"id":"proj_2","name":"Side"}]}`), + apitest.SuccessJSON(`{"apiCredentials":[{"apiKey":"pk_side","apiSecret":"sk_side","revoked":false}]}`), + ) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + var stdout, stderr bytes.Buffer + code := Execute([]string{"projects", "select", "side", "--output-format", "json"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit %d\n%s\n%s", code, stdout.String(), stderr.String()) + } + p := readProfileMap(t, dir) + if p["active_project"] != "proj_2" || p["api_secret"] != "sk_side" || p["api_key"] != "pk_side" { + t.Fatalf("profile after select: %#v", p) + } +} + +func TestProjectsCreateSelectPersistsAndRefreshesCredential(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New( + apitest.SuccessJSON(`{"id":"proj_new","name":"Fresh"}`), + apitest.SuccessJSON(`{"apiCredentials":[{"apiKey":"pk_fresh","apiSecret":"sk_fresh","revoked":false}]}`), + ) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + + var stdout, stderr bytes.Buffer + code := Execute([]string{"projects", "create", "Fresh", "--select", "--output-format", "json"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit %d\n%s\n%s", code, stdout.String(), stderr.String()) + } + + p := readProfileMap(t, dir) + if p["active_project"] != "proj_new" || p["api_key"] != "pk_fresh" || p["api_secret"] != "sk_fresh" { + t.Fatalf("profile after create --select: %#v", p) + } + + reqs := srv.Requests() + if len(reqs) != 2 { + t.Fatalf("create --select should POST the project then fetch the credential, got %d requests: %+v", len(reqs), reqs) + } + if reqs[0].Method != "POST" || reqs[0].Path != "/v2/projects" { + t.Fatalf("first request must create the project: %+v", reqs[0]) + } + if reqs[1].Method != "GET" || reqs[1].Path != "/v2/organisation/org_compat/projects/proj_new/api-credentials" { + t.Fatalf("second request must fetch the new project's render credential: %+v", reqs[1]) + } + + if !bytes.Contains(stdout.Bytes(), []byte(`"selected": true`)) { + t.Fatalf("envelope must note the switch with selected:true:\n%s", stdout.String()) + } + if !bytes.Contains(stdout.Bytes(), []byte("now active")) { + t.Fatalf("summary must reflect the switch:\n%s", stdout.String()) + } +} + +func TestProjectsCreateNonTTYWithoutSelectMakesNoExtraCalls(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New(apitest.SuccessJSON(`{"id":"proj_new","name":"Fresh"}`)) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + + var stdout, stderr bytes.Buffer + code := Execute([]string{"projects", "create", "Fresh", "--output-format", "json"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit %d\n%s\n%s", code, stdout.String(), stderr.String()) + } + + if reqs := srv.Requests(); len(reqs) != 1 { + t.Fatalf("create without --select off-TTY must only POST the project, got %d: %+v", len(reqs), reqs) + } + p := readProfileMap(t, dir) + if p["active_project"] != "proj_compat" { + t.Fatalf("active project must be untouched, got %q", p["active_project"]) + } + if bytes.Contains(stdout.Bytes(), []byte(`"selected": true`)) { + t.Fatalf("no switch happened, selected:true must not appear:\n%s", stdout.String()) + } + if bytes.Contains(stdout.Bytes(), []byte("now active")) { + t.Fatalf("summary must not claim a switch:\n%s", stdout.String()) + } +} + +func TestProjectsCreateDeclinedConfirmSkipsSwitchToleratesWrappedResponse(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New(apitest.SuccessJSON(`{"project":{"id":"proj_new","name":"Fresh"}}`)) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + + SetConfirmPromptForTest(func(string) (bool, error) { return false, nil }) + t.Cleanup(ResetConfirmPromptForTest) + + var stdout, stderr bytes.Buffer + code := Execute([]string{"projects", "create", "Fresh", "--output-format", "text"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit %d\n%s\n%s", code, stdout.String(), stderr.String()) + } + + if reqs := srv.Requests(); len(reqs) != 1 { + t.Fatalf("declining the switch must make no extra calls, got %d: %+v", len(reqs), reqs) + } + p := readProfileMap(t, dir) + if p["active_project"] != "proj_compat" { + t.Fatalf("declined switch must leave the active project untouched, got %q", p["active_project"]) + } + if strings.Contains(stdout.String(), "now active") { + t.Fatalf("declined switch must not claim the project is active:\n%s", stdout.String()) + } +} + +func TestProjectsCreateAcceptedConfirmSwitches(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New( + apitest.SuccessJSON(`{"id":"proj_new","name":"Fresh"}`), + apitest.SuccessJSON(`{"apiCredentials":[{"apiKey":"pk_fresh","apiSecret":"sk_fresh","revoked":false}]}`), + ) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + + SetConfirmPromptForTest(func(string) (bool, error) { return true, nil }) + t.Cleanup(ResetConfirmPromptForTest) + + var stdout, stderr bytes.Buffer + code := Execute([]string{"projects", "create", "Fresh", "--output-format", "text"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit %d\n%s\n%s", code, stdout.String(), stderr.String()) + } + + p := readProfileMap(t, dir) + if p["active_project"] != "proj_new" || p["api_secret"] != "sk_fresh" { + t.Fatalf("accepted switch must activate the new project: %#v", p) + } + if !strings.Contains(stdout.String(), "now active") { + t.Fatalf("accepted switch summary must say now active:\n%s", stdout.String()) + } +} + +func TestProjectsSelectNonInteractiveWithoutArg(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New( + apitest.SuccessJSON(`{"projects":[{"id":"proj_1","name":"A"},{"id":"proj_2","name":"B"}]}`), + ) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + var stdout, stderr bytes.Buffer + code := Execute([]string{"projects", "select", "--output-format", "json"}, &stdout, &stderr) + if code != 1 { + t.Fatalf("want usage exit 1, got %d\n%s", code, stdout.String()) + } +} diff --git a/internal/cmd/proxies.go b/internal/cmd/proxies.go new file mode 100644 index 0000000..371f585 --- /dev/null +++ b/internal/cmd/proxies.go @@ -0,0 +1,303 @@ +package cmd + +import ( + "context" + "fmt" + "strconv" + "strings" + + "github.com/spf13/cobra" + + "github.com/urlbox/urlbox-cli/internal/output" +) + +func newProxiesCmd() *cobra.Command { + c := &cobra.Command{ + Use: "proxies", + Aliases: []string{"proxy"}, + Short: "Manage org proxy pools", + Long: `Manage the active organisation's proxy pools. + +Proxy pools are owned by the organisation and assigned to projects. +Create one once, then assign it to any project's renders. + +Proxy URLs routinely embed credentials, so the password portion is masked +on display — pass --reveal for full values (JSON output always includes them +in full). + +Examples: + urlbox proxies list + urlbox proxies show eu --reveal + urlbox proxies create --name eu --url http://user:pass@host:8080 --assign-to my-project + urlbox proxies update eu --url http://user:pass@host:8080 + urlbox proxies delete eu`, + } + list := &cobra.Command{ + Use: "list", + Short: "List the organisation's proxy pools", + Args: cobra.NoArgs, + RunE: runProxiesList, + } + var showReveal bool + show := &cobra.Command{ + Use: "show ", + Short: "Show one proxy pool", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runProxiesShow(cmd, args, showReveal) + }, + } + show.Flags().BoolVar(&showReveal, "reveal", false, "Print proxy URLs unmasked (default: passwords masked)") + var ( + createName string + createURLs []string + createAssignTo string + ) + create := &cobra.Command{ + Use: "create ", + Short: "Create a proxy pool", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runProxiesCreate(cmd, args, createName, createURLs, createAssignTo) + }, + } + create.Flags().StringVar(&createName, "name", "", "Pool name") + create.Flags().StringArrayVar(&createURLs, "url", nil, "Proxy URL (repeatable)") + create.Flags().StringVar(&createAssignTo, "assign-to", "", "Assign to this project after create") + var ( + updateName string + updateURLs []string + ) + update := &cobra.Command{ + Use: "update ", + Short: "Update a proxy pool (name and/or the whole URL list)", + Long: `Update a proxy pool. + +The server replaces the pool's proxy list with exactly what you send: passing any --url replaces the whole list; omitting --url keeps the existing list.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runProxiesUpdate(cmd, args, updateName, updateURLs) + }, + } + update.Flags().StringVar(&updateName, "name", "", "Pool name") + update.Flags().StringArrayVar(&updateURLs, "url", nil, "Proxy URL (repeatable; any --url replaces the whole list)") + var deleteYes bool + del := &cobra.Command{ + Use: "delete ", + Short: "Delete a proxy pool", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runProxiesDelete(cmd, args, deleteYes) + }, + } + del.Flags().BoolVar(&deleteYes, "yes", false, "Skip the retype-to-confirm prompt") + c.AddCommand(list, show, create, update, del) + attachSessionRetryFlags(c) + return c +} + +func proxyListRows(pools []map[string]any) [][]string { + rows := make([][]string, len(pools)) + for i, p := range pools { + entries, _ := p["proxies"].([]any) + rows[i] = []string{ + valueOrEmpty(p["id"]), valueOrEmpty(p["name"]), + strconv.Itoa(len(entries)), assignedCount(p), + } + } + return rows +} + +func proxyDetailPairs(pool map[string]any, reveal bool) [][2]string { + pairs := [][2]string{ + {"ID", valueOrEmpty(pool["id"])}, + {"Name", valueOrEmpty(pool["name"])}, + } + entries, _ := pool["proxies"].([]any) + for _, e := range entries { + entry, _ := e.(map[string]any) + if entry == nil { + continue + } + label := valueOrEmpty(entry["name"]) + if label == "" { + label = valueOrEmpty(entry["id"]) + } + pairs = append(pairs, [2]string{label, maskProxyURL(valueOrEmpty(entry["url"]), reveal)}) + } + pairs = append(pairs, + [2]string{"Assigned projects", assignedCount(pool)}, + [2]string{"Created", valueOrEmpty(pool["createdAt"])}, + ) + return pairs +} + +func runProxiesList(cmd *cobra.Command, _ []string) error { + sess, cliErr := loadSession(cmd) + if cliErr != nil { + return cliErr + } + org, orgErr := requireActiveOrg(sess) + if orgErr != nil { + return orgErr + } + ctx := context.Background() + items, err := fetchList(ctx, sess.Client, proxyKind.orgListPath(org), proxyKind.listKey) + if err != nil { + return asCLIError(err) + } + env := output.NewEnvelope("proxies list", + map[string]any{"proxies": items}, + fmt.Sprintf("%d proxy pools", len(items)), nil) + env.SetTable([]string{"ID", "NAME", "URLS", "ASSIGNED"}, proxyListRows(items), -1) + return writeEnvelopeWithQuietData(cmd, env, strconv.Itoa(len(items))) +} + +func runProxiesShow(cmd *cobra.Command, args []string, reveal bool) error { + sess, cliErr := loadSession(cmd) + if cliErr != nil { + return cliErr + } + org, orgErr := requireActiveOrg(sess) + if orgErr != nil { + return orgErr + } + ctx := context.Background() + items, err := fetchList(ctx, sess.Client, proxyKind.orgListPath(org), proxyKind.listKey) + if err != nil { + return asCLIError(err) + } + resolved, resErr := resolveCredArg(items, args[0], proxyKind) + if resErr != nil { + return resErr + } + var detail map[string]any + if err := sess.Client.GetJSON(ctx, proxyKind.resourcePath(org, resolved.ID), &detail); err != nil { + return asCLIError(err) + } + name := valueOrEmpty(detail["name"]) + if name == "" { + name = valueOrEmpty(detail["id"]) + } + env := output.NewEnvelope("proxies show", detail, + fmt.Sprintf("Proxy pool %s", name), nil) + env.SetKV(proxyDetailPairs(detail, reveal)) + return writeEnvelopeWithQuietData(cmd, env, valueOrEmpty(detail["id"])) +} + +func runProxiesCreate(cmd *cobra.Command, args []string, name string, urls []string, assignTo string) error { + sess, cliErr := loadSession(cmd) + if cliErr != nil { + return cliErr + } + org, orgErr := requireActiveOrg(sess) + if orgErr != nil { + return orgErr + } + resolvedName, nameErr := createName(cmd, args, name) + if nameErr != nil { + return nameErr + } + body, bodyErr := buildProxyCreateBody(resolvedName, urls) + if bodyErr != nil { + return bodyErr + } + ctx := context.Background() + var created map[string]any + if err := sess.Client.PostJSON(ctx, proxyKind.orgListPath(org), body, &created); err != nil { + return asCLIError(err) + } + createdID := valueOrEmpty(created["id"]) + createdName := valueOrEmpty(created["name"]) + if createdName == "" { + createdName = createdID + } + outcome := maybeAssignAfterCreate(ctx, sess.Client, org, proxyKind, createdID, assignTo, interactiveText(cmd)) + return reportCredCreate(cmd, "proxies create", proxyKind, created, createdName, createdID, outcome) +} + +func runProxiesUpdate(cmd *cobra.Command, args []string, name string, urls []string) error { + sess, cliErr := loadSession(cmd) + if cliErr != nil { + return cliErr + } + org, orgErr := requireActiveOrg(sess) + if orgErr != nil { + return orgErr + } + nameSet := cmd.Flags().Changed("name") + if !nameSet && len(urls) == 0 { + return output.NewCLIError(output.ErrUsage, + "nothing to update — pass --name and/or --url", + "Pass --name and/or --url (any --url replaces the whole list).") + } + ctx := context.Background() + resolved, resErr := resolveProxyArg(ctx, sess, org, args[0]) + if resErr != nil { + return resErr + } + var existing map[string]any + if err := sess.Client.GetJSON(ctx, proxyKind.resourcePath(org, resolved.ID), &existing); err != nil { + return asCLIError(err) + } + body := mergeProxyUpdate(existing, name, urls, map[string]bool{"name": nameSet}) + var updated map[string]any + if err := sess.Client.PatchJSON(ctx, proxyKind.resourcePath(org, resolved.ID), body, &updated); err != nil { + return asCLIError(err) + } + label := valueOrEmpty(updated["name"]) + if label == "" { + label = resolved.ID + } + env := output.NewEnvelope("proxies update", updated, + fmt.Sprintf("Updated proxy pool %s", label), nil) + return writeEnvelopeWithQuietData(cmd, env, resolved.ID) +} + +func runProxiesDelete(cmd *cobra.Command, args []string, yes bool) error { + sess, cliErr := loadSession(cmd) + if cliErr != nil { + return cliErr + } + org, orgErr := requireActiveOrg(sess) + if orgErr != nil { + return orgErr + } + ctx := context.Background() + items, err := fetchList(ctx, sess.Client, proxyKind.orgListPath(org), proxyKind.listKey) + if err != nil { + return asCLIError(err) + } + resolved, resErr := resolveCredArg(items, args[0], proxyKind) + if resErr != nil { + return resErr + } + name := resolved.Name + if name == "" { + name = resolved.ID + } + if !yes { + if err := confirmDeletion(name); err != nil { + return err + } + } + if err := sess.Client.DeleteJSON(ctx, proxyKind.resourcePath(org, resolved.ID), nil); err != nil { + return asCLIError(err) + } + env := output.NewEnvelope("proxies delete", + map[string]any{"deleted": resolved.ID}, + fmt.Sprintf("Deleted proxy pool %s", name), nil) + return writeEnvelopeWithQuietData(cmd, env, resolved.ID) +} + +func resolveProxyArg(ctx context.Context, sess *sessionState, org, arg string) (nameID, *output.CLIError) { + var items []map[string]any + if !strings.HasPrefix(arg, proxyKind.prefix) { + fetched, err := fetchList(ctx, sess.Client, proxyKind.orgListPath(org), proxyKind.listKey) + if err != nil { + return nameID{}, asCLIError(err) + } + items = fetched + } + return resolveCredArg(items, arg, proxyKind) +} diff --git a/internal/cmd/proxies_test.go b/internal/cmd/proxies_test.go new file mode 100644 index 0000000..30700d2 --- /dev/null +++ b/internal/cmd/proxies_test.go @@ -0,0 +1,343 @@ +package cmd + +import ( + "bytes" + "testing" + + "github.com/urlbox/urlbox-cli/internal/api/apitest" +) + +const proxyListJSON = `{"proxies":[ + {"id":"pool_1","name":"eu","proxies":[{"id":"proxy_1","name":"one","url":"http://user:hunter2@p1.example.com:8080"},{"id":"proxy_2","name":"","url":"http://p2.example.com:8080"}],"assignedProjectIds":["proj_1"],"createdAt":"2026-08-01T00:00:00.000Z"}]}` + +const proxyOneJSON = `{"id":"pool_1","name":"eu","proxies":[{"id":"proxy_1","name":"one","url":"http://user:hunter2@p1.example.com:8080"},{"id":"proxy_2","name":"","url":"http://p2.example.com:8080"}],"assignedProjectIds":["proj_1"],"createdAt":"2026-08-01T00:00:00.000Z"}` + +func TestProxiesListShowsCountNeverURLs(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New(apitest.SuccessJSON(proxyListJSON)) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + var stdout, stderr bytes.Buffer + code := Execute([]string{"proxies", "list", "--output-format", "text"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit %d\n%s\n%s", code, stdout.String(), stderr.String()) + } + reqs := srv.Requests() + if reqs[0].Method != "GET" || reqs[0].Path != "/v2/organisation/org_compat/proxies" { + t.Fatalf("request: %+v", reqs[0]) + } + out := stdout.String() + for _, want := range []string{"pool_1", "eu", "URLS", "ASSIGNED"} { + if !bytes.Contains(stdout.Bytes(), []byte(want)) { + t.Fatalf("list output missing %q: %s", want, out) + } + } + for _, leak := range []string{"hunter2", "p1.example.com", "p2.example.com"} { + if bytes.Contains(stdout.Bytes(), []byte(leak)) { + t.Fatalf("list must never print URLs, leaked %q: %s", leak, out) + } + } +} + +func TestProxiesShowMasksPasswordOnly(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New( + apitest.SuccessJSON(proxyListJSON), + apitest.SuccessJSON(proxyOneJSON), + ) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + var stdout, stderr bytes.Buffer + code := Execute([]string{"proxies", "show", "pool_1", "--output-format", "text"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit %d\n%s\n%s", code, stdout.String(), stderr.String()) + } + reqs := srv.Requests() + if reqs[1].Method != "GET" || reqs[1].Path != "/v2/organisation/org_compat/proxies/pool_1" { + t.Fatalf("show request: %+v", reqs[1]) + } + out := stdout.String() + if !bytes.Contains(stdout.Bytes(), []byte("http://user:****@p1.example.com:8080")) { + t.Fatalf("show must mask the password portion only: %s", out) + } + if !bytes.Contains(stdout.Bytes(), []byte("http://p2.example.com:8080")) { + t.Fatalf("passwordless URL must be shown untouched: %s", out) + } + if bytes.Contains(stdout.Bytes(), []byte("hunter2")) { + t.Fatalf("show must not leak the password without --reveal: %s", out) + } + + srv2 := apitest.New( + apitest.SuccessJSON(proxyListJSON), + apitest.SuccessJSON(proxyOneJSON), + ) + t.Cleanup(srv2.Close) + t.Setenv("URLBOX_API_HOST", srv2.URL()) + var revealOut, revealErr bytes.Buffer + code = Execute([]string{"proxies", "show", "pool_1", "--reveal", "--output-format", "text"}, &revealOut, &revealErr) + if code != 0 { + t.Fatalf("reveal exit %d\n%s\n%s", code, revealOut.String(), revealErr.String()) + } + if !bytes.Contains(revealOut.Bytes(), []byte("hunter2")) { + t.Fatalf("--reveal must show the full password: %s", revealOut.String()) + } +} + +func TestProxiesCreateBody(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New(apitest.SuccessJSON(`{"id":"pool_new","name":"eu","proxies":[{"id":"proxy_a","name":"","url":"http://a:1"}],"assignedProjectIds":[]}`)) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + var stdout, stderr bytes.Buffer + code := Execute([]string{ + "proxies", "create", + "--name", "eu", "--url", "http://a:1", "--url", "http://b:2", + "--output-format", "json", + }, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit %d\n%s\n%s", code, stdout.String(), stderr.String()) + } + reqs := srv.Requests() + if reqs[0].Method != "POST" || reqs[0].Path != "/v2/organisation/org_compat/proxies" { + t.Fatalf("create request: %+v", reqs[0]) + } + if string(reqs[0].Body) != `{"name":"eu","proxies":[{"url":"http://a:1"},{"url":"http://b:2"}]}` { + t.Fatalf("create body wrong: %s", reqs[0].Body) + } +} + +func TestProxiesCreatePositionalName(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New(apitest.SuccessJSON(`{"id":"pool_new","name":"eu","proxies":[{"id":"proxy_a","name":"","url":"http://a:1"}],"assignedProjectIds":[]}`)) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + var stdout, stderr bytes.Buffer + code := Execute([]string{ + "proxies", "create", "eu", + "--url", "http://a:1", "--url", "http://b:2", + "--output-format", "json", + }, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit %d\n%s\n%s", code, stdout.String(), stderr.String()) + } + reqs := srv.Requests() + if reqs[0].Method != "POST" || reqs[0].Path != "/v2/organisation/org_compat/proxies" { + t.Fatalf("create request: %+v", reqs[0]) + } + if string(reqs[0].Body) != `{"name":"eu","proxies":[{"url":"http://a:1"},{"url":"http://b:2"}]}` { + t.Fatalf("create body must carry the positional name: %s", reqs[0].Body) + } +} + +func TestProxiesCreatePositionalConflictsWithFlag(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New(apitest.SuccessJSON(`{}`)) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + var stdout, stderr bytes.Buffer + code := Execute([]string{ + "proxies", "create", "a", "--name", "b", "--url", "http://a:1", + "--output-format", "json", + }, &stdout, &stderr) + if code == 0 { + t.Fatalf("conflicting name must fail\n%s", stdout.String()) + } + if len(srv.Requests()) != 0 { + t.Fatalf("conflict must make no API call: %+v", srv.Requests()) + } + if !bytes.Contains(stdout.Bytes(), []byte("--name")) { + t.Fatalf("conflict error must name the flag: %s", stdout.String()) + } +} + +func TestProxiesUpdateReplacesWholeList(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New( + apitest.SuccessJSON(proxyOneJSON), + apitest.SuccessJSON(`{"id":"pool_1","name":"eu"}`), + ) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + var stdout, stderr bytes.Buffer + code := Execute([]string{ + "proxies", "update", "pool_1", + "--url", "http://new:1", "--url", "http://new:2", + "--output-format", "json", + }, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit %d\n%s\n%s", code, stdout.String(), stderr.String()) + } + reqs := srv.Requests() + if reqs[0].Method != "GET" || reqs[0].Path != "/v2/organisation/org_compat/proxies/pool_1" { + t.Fatalf("update must fetch the existing pool first: %+v", reqs[0]) + } + if reqs[1].Method != "PATCH" || reqs[1].Path != "/v2/organisation/org_compat/proxies/pool_1" { + t.Fatalf("update request: %+v", reqs[1]) + } + if string(reqs[1].Body) != `{"name":"eu","proxies":[{"url":"http://new:1"},{"url":"http://new:2"}]}` { + t.Fatalf("update must replace the whole list with exactly the flags sent: %s", reqs[1].Body) + } +} + +func TestProxiesUpdateNameOnlyCarriesListForward(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New( + apitest.SuccessJSON(proxyOneJSON), + apitest.SuccessJSON(`{"id":"pool_1","name":"west"}`), + ) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + var stdout, stderr bytes.Buffer + code := Execute([]string{ + "proxies", "update", "pool_1", "--name", "west", "--output-format", "json", + }, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit %d\n%s\n%s", code, stdout.String(), stderr.String()) + } + reqs := srv.Requests() + if reqs[0].Method != "GET" || reqs[0].Path != "/v2/organisation/org_compat/proxies/pool_1" { + t.Fatalf("update must fetch the existing pool first: %+v", reqs[0]) + } + if reqs[1].Method != "PATCH" { + t.Fatalf("update request: %+v", reqs[1]) + } + want := `{"name":"west","proxies":[{"name":"one","url":"http://user:hunter2@p1.example.com:8080"},{"url":"http://p2.example.com:8080"}]}` + if string(reqs[1].Body) != want { + t.Fatalf("name-only update must carry the existing list forward:\n got %s\nwant %s", reqs[1].Body, want) + } +} + +func TestProxiesUpdateHelpWarnsWholeListReplacement(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + var stdout, stderr bytes.Buffer + code := Execute([]string{"proxies", "update", "--help"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit %d\n%s\n%s", code, stdout.String(), stderr.String()) + } + want := "The server replaces the pool's proxy list with exactly what you send: passing any --url replaces the whole list; omitting --url keeps the existing list." + if !bytes.Contains(stdout.Bytes(), []byte(want)) { + t.Fatalf("update --help must state the whole-list-replacement rule: %s", stdout.String()) + } +} + +func TestProxiesDeleteRequiresYesOffTTY(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New(apitest.SuccessJSON(proxyListJSON)) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + var stdout, stderr bytes.Buffer + code := Execute([]string{"proxies", "delete", "pool_1", "--output-format", "json"}, &stdout, &stderr) + if code == 0 { + t.Fatalf("delete without --yes off-TTY must fail\n%s", stdout.String()) + } + if !bytes.Contains(stdout.Bytes(), []byte("--yes")) { + t.Fatalf("error must name --yes: %s", stdout.String()) + } + for _, r := range srv.Requests() { + if r.Method == "DELETE" { + t.Fatalf("no DELETE must be issued without confirmation: %+v", r) + } + } +} + +func TestProxiesDeleteWithYes(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New( + apitest.SuccessJSON(proxyListJSON), + apitest.SuccessJSON(`{"id":"pool_1","deleted":true}`), + ) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + var stdout, stderr bytes.Buffer + code := Execute([]string{"proxies", "delete", "pool_1", "--yes", "--output-format", "text"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit %d\n%s\n%s", code, stdout.String(), stderr.String()) + } + reqs := srv.Requests() + last := reqs[len(reqs)-1] + if last.Method != "DELETE" || last.Path != "/v2/organisation/org_compat/proxies/pool_1" { + t.Fatalf("delete request: %+v", last) + } + if !bytes.Contains(stdout.Bytes(), []byte("eu")) { + t.Fatalf("delete summary must name the pool: %s", stdout.String()) + } +} + +func TestProxiesCreateAssignTo(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New( + apitest.SuccessJSON(`{"id":"pool_new","name":"eu","proxies":[{"id":"proxy_a","url":"http://a:1"}],"assignedProjectIds":[]}`), + apitest.SuccessJSON(`{"projects":[{"id":"proj_1","name":"Main"}]}`), + apitest.SuccessJSON(`{"id":"proj_1","name":"Main","proxyId":"pool_new"}`), + ) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + var stdout, stderr bytes.Buffer + code := Execute([]string{ + "proxies", "create", + "--name", "eu", "--url", "http://a:1", + "--assign-to", "proj_1", + "--output-format", "json", + }, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit %d\n%s\n%s", code, stdout.String(), stderr.String()) + } + reqs := srv.Requests() + var put *apitest.CapturedRequest + for i := range reqs { + if reqs[i].Method == "PUT" { + put = &reqs[i] + } + } + if put == nil { + t.Fatalf("assign must issue a PUT, requests: %+v", reqs) + } + if put.Path != "/v2/organisation/org_compat/projects/proj_1/proxy" { + t.Fatalf("assign PUT path: %s", put.Path) + } + if !bytes.Contains(put.Body, []byte(`"proxyId":"pool_new"`)) { + t.Fatalf("assign body missing proxyId: %s", put.Body) + } + if !bytes.Contains(stdout.Bytes(), []byte(`"assigned"`)) { + t.Fatalf("envelope data must carry the assigned project: %s", stdout.String()) + } +} + +func TestProxiesNotFoundNameHint(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New(apitest.SuccessJSON(proxyListJSON)) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + var stdout, stderr bytes.Buffer + code := Execute([]string{"proxies", "show", "nope", "--output-format", "json"}, &stdout, &stderr) + if code == 0 { + t.Fatalf("show of an unknown name must fail\n%s", stdout.String()) + } + if !bytes.Contains(stdout.Bytes(), []byte("urlbox proxies list")) { + t.Fatalf("not-found hint must name `urlbox proxies list`: %s", stdout.String()) + } +} diff --git a/internal/cmd/render_test.go b/internal/cmd/render_test.go index 31c1121..cf1fa42 100644 --- a/internal/cmd/render_test.go +++ b/internal/cmd/render_test.go @@ -1616,8 +1616,8 @@ func TestRender_NoSecretConfigured_FailsLocallyWithCLIVocabulary(t *testing.T) { t.Errorf("expected CLI vocabulary 'API secret'; got %q", msg) } hint, _ := env["hint"].(string) - if !strings.Contains(hint, "urlbox auth") { - t.Errorf("hint should mention `urlbox auth`; got %q", hint) + if !strings.Contains(hint, "urlbox login") { + t.Errorf("hint should mention `urlbox login`; got %q", hint) } } diff --git a/internal/cmd/rendercred.go b/internal/cmd/rendercred.go new file mode 100644 index 0000000..f3cece5 --- /dev/null +++ b/internal/cmd/rendercred.go @@ -0,0 +1,58 @@ +package cmd + +import ( + "context" + "errors" + + "github.com/urlbox/urlbox-cli/internal/api" +) + +type renderCredential struct { + key string + secret string +} + +func pickAPICredential(creds []map[string]any) renderCredential { + for _, c := range creds { + if revoked, _ := c["revoked"].(bool); revoked { + continue + } + if secret := valueOrEmpty(c["apiSecret"]); secret != "" { + return renderCredential{key: valueOrEmpty(c["apiKey"]), secret: secret} + } + } + return renderCredential{} +} + +func apiCredentialsPath(org, project string) string { + return "/v2/organisation/" + org + "/projects/" + project + "/api-credentials" +} + +func fetchRenderCredential(ctx context.Context, client api.SessionAPI, org, project string) (renderCredential, error) { + creds, err := fetchList(ctx, client, apiCredentialsPath(org, project), "apiCredentials") + if err != nil { + return renderCredential{}, err + } + return pickAPICredential(creds), nil +} + +func ensureRenderCredential(ctx context.Context, client api.SessionAPI, org, project string, interactive bool, pick pickFunc) (cred renderCredential, issued bool, err error) { + cred, err = fetchRenderCredential(ctx, client, org, project) + if err != nil || cred.secret != "" { + return cred, false, err + } + if interactive { + idx, perr := pick("No render credential on this project — issue one?", []string{"Issue a new credential", "Skip"}, 0) + if perr != nil && !errors.Is(perr, errNotInteractivePick) { + return renderCredential{}, false, nil + } + if perr == nil && idx == 1 { + return renderCredential{}, false, nil + } + } + var created map[string]any + if err := client.PostJSON(ctx, apiCredentialsPath(org, project), map[string]string{}, &created); err != nil { + return renderCredential{}, false, err + } + return renderCredential{key: valueOrEmpty(created["apiKey"]), secret: valueOrEmpty(created["apiSecret"])}, true, nil +} diff --git a/internal/cmd/rendercred_test.go b/internal/cmd/rendercred_test.go new file mode 100644 index 0000000..322872f --- /dev/null +++ b/internal/cmd/rendercred_test.go @@ -0,0 +1,63 @@ +package cmd + +import ( + "context" + "testing" +) + +func TestPickAPICredentialSkipsRevoked(t *testing.T) { + creds := []map[string]any{ + {"apiKey": "pk_revoked", "apiSecret": "sk_revoked", "revoked": true}, + {"apiKey": "pk_live", "apiSecret": "sk_live", "revoked": false}, + } + got := pickAPICredential(creds) + if got.key != "pk_live" || got.secret != "sk_live" { + t.Fatalf("got %+v", got) + } + empty := pickAPICredential([]map[string]any{{"revoked": true, "apiKey": "pk", "apiSecret": "sk"}}) + if empty.key != "" || empty.secret != "" { + t.Fatalf("all-revoked must be empty, got %+v", empty) + } +} + +func TestEnsureRenderCredentialReturnsExisting(t *testing.T) { + f := &fakeSession{gets: map[string]string{ + "/v2/organisation/org_1/projects/proj_1/api-credentials": `{"apiCredentials":[{"apiKey":"pk_have","apiSecret":"sk_have","revoked":false}]}`, + }} + cred, issued, err := ensureRenderCredential(context.Background(), f, "org_1", "proj_1", false, neverPick) + if err != nil || issued || cred.key != "pk_have" || cred.secret != "sk_have" { + t.Fatalf("got %+v issued=%v err=%v", cred, issued, err) + } + if len(f.posts) != 0 { + t.Fatalf("must not issue when a credential exists") + } +} + +func TestEnsureRenderCredentialAutoIssuesNonInteractive(t *testing.T) { + f := &fakeSession{ + gets: map[string]string{ + "/v2/organisation/org_1/projects/proj_1/api-credentials": `{"apiCredentials":[]}`, + }, + postResponses: map[string]string{ + "/v2/organisation/org_1/projects/proj_1/api-credentials": `{"apiKey":"pk_new","apiSecret":"sk_new"}`, + }, + } + cred, issued, err := ensureRenderCredential(context.Background(), f, "org_1", "proj_1", false, neverPick) + if err != nil || !issued || cred.key != "pk_new" || cred.secret != "sk_new" { + t.Fatalf("got %+v issued=%v err=%v", cred, issued, err) + } +} + +func TestEnsureRenderCredentialInteractiveSkip(t *testing.T) { + f := &fakeSession{gets: map[string]string{ + "/v2/organisation/org_1/projects/proj_1/api-credentials": `{"apiCredentials":[]}`, + }} + skip := func(_ string, options []string, _ int) (int, error) { return 1, nil } + cred, issued, err := ensureRenderCredential(context.Background(), f, "org_1", "proj_1", true, skip) + if err != nil || issued || cred.key != "" || cred.secret != "" { + t.Fatalf("skip must return empty: %+v %v %v", cred, issued, err) + } + if len(f.posts) != 0 { + t.Fatal("skip must not issue") + } +} diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 9d004c5..a011811 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -225,19 +225,27 @@ func newRootCmd(stdout, stderr io.Writer) *cobra.Command { cmd.AddCommand(newUpgradeCmd(stdout, stderr)) cmd.AddCommand(newCommandsCmd(stdout, stderr)) cmd.AddCommand(newSurfaceCmd(cmd)) - cmd.AddCommand(newAuthCmd()) cmd.AddCommand(newConfigCmd()) cmd.AddCommand(newDashboardCmd()) cmd.AddCommand(newDoctorCmd()) cmd.AddCommand(newLinkCmd()) + cmd.AddCommand(newLlmCmd()) + cmd.AddCommand(newLoginCmd()) + cmd.AddCommand(newLogoutCmd()) + cmd.AddCommand(newOrgsCmd()) cmd.AddCommand(newPdfCmd()) + cmd.AddCommand(newProjectsCmd()) + cmd.AddCommand(newProxiesCmd()) cmd.AddCommand(newRenderCmd()) cmd.AddCommand(newSchemaCmd()) cmd.AddCommand(newScreenshotCmd()) cmd.AddCommand(newSkillCmd()) cmd.AddCommand(newStatusCmd()) + cmd.AddCommand(newStorageCmd()) + cmd.AddCommand(newUsageCmd()) cmd.AddCommand(newVersionCmd()) cmd.AddCommand(newVideoCmd()) + cmd.AddCommand(newWhoamiCmd()) return cmd } diff --git a/internal/cmd/root_test.go b/internal/cmd/root_test.go index b73fa02..a73d153 100644 --- a/internal/cmd/root_test.go +++ b/internal/cmd/root_test.go @@ -67,6 +67,19 @@ func TestRootCommand_UnknownSubcommand(t *testing.T) { } } +func TestNoAuthCommandRemains(t *testing.T) { + stdout := &bytes.Buffer{} + stderr := &bytes.Buffer{} + code := cmd.Execute([]string{"auth"}, stdout, stderr) + if code == 0 { + t.Fatal("`urlbox auth` must be an unknown command after removal") + } + out := stdout.String() + if !strings.Contains(out, "unknown") || !strings.Contains(out, "auth") { + t.Errorf("expected unknown-command error for `auth`, got %q", out) + } +} + func TestRootCommand_NoArgs_ShowsHelp(t *testing.T) { stdout := &bytes.Buffer{} stderr := &bytes.Buffer{} diff --git a/internal/cmd/secret_input.go b/internal/cmd/secret_input.go index 3a9d079..16eedaa 100644 --- a/internal/cmd/secret_input.go +++ b/internal/cmd/secret_input.go @@ -117,7 +117,7 @@ func resolveAPISecretInput(stdin io.Reader, stderr io.Writer, direct string, dir return "", output.NewCLIError( output.ErrUsage, "--api-secret-stdin received no secret on stdin", - "Pipe the secret on stdin, e.g. `printf %s \"$URLBOX_API_SECRET\" | urlbox auth --api-secret-stdin`.", + "Pipe the secret on stdin, e.g. `printf %s \"$URLBOX_API_SECRET\" | urlbox config profile create default --api-secret-stdin`.", ) } return s, nil diff --git a/internal/cmd/session_helpers.go b/internal/cmd/session_helpers.go new file mode 100644 index 0000000..13b0535 --- /dev/null +++ b/internal/cmd/session_helpers.go @@ -0,0 +1,148 @@ +package cmd + +import ( + "errors" + "os" + + "github.com/spf13/cobra" + + "github.com/urlbox/urlbox-cli/internal/api" + "github.com/urlbox/urlbox-cli/internal/config" + "github.com/urlbox/urlbox-cli/internal/output" + "github.com/urlbox/urlbox-cli/internal/prompt" +) + +type sessionState struct { + Host string + ProfileName string + Profile config.Profile + Client *api.SessionClient +} + +// attachSessionRetryFlags registers --no-retry and --max-retries as persistent +// flags on a top-level session command so every subcommand inherits them. Names, +// help text, and defaults are byte-identical to the render/status surface; the +// values are consumed centrally in newSessionClient. +func attachSessionRetryFlags(cmd *cobra.Command) { + cmd.PersistentFlags().Bool("no-retry", false, "Disable automatic retries on 429 / 5xx") + cmd.PersistentFlags().Int("max-retries", api.DefaultRetryConfig().MaxRetries, "Maximum retry attempts on 429 / 5xx") +} + +// sessionRetryConfig reads the two retry flags off cmd (inherited from the +// top-level session command) and builds the matching RetryConfig. --no-retry +// wins over --max-retries, matching the render/status consumption semantics. +func sessionRetryConfig(cmd *cobra.Command) api.RetryConfig { + noRetry, _ := cmd.Flags().GetBool("no-retry") + if noRetry { + return api.NoRetryConfig() + } + cfg := api.DefaultRetryConfig() + if maxRetries, err := cmd.Flags().GetInt("max-retries"); err == nil { + cfg.MaxRetries = maxRetries + } + return cfg +} + +// newSessionClient is the one construction site for session clients: it wires +// the retry policy from cmd's flags into the client. All session commands route +// through here (directly or via loadSession) so the flags take effect uniformly. +func newSessionClient(cmd *cobra.Command, host, token string) *api.SessionClient { + client := api.NewSessionClient(host, token) + client.SetRetryConfig(sessionRetryConfig(cmd)) + return client +} + +func sessionHost(cmd *cobra.Command) (host, profileName string, cliErr *output.CLIError) { + cfg, cfgErr := config.LoadOrCLIError() + if cfgErr != nil { + return "", "", cfgErr + } + flagProfile, _ := cmd.Root().PersistentFlags().GetString("profile") + overlay, ovErr := loadRepoOverlay() + if ovErr != nil { + return "", "", ovErr + } + resolved, rerr := config.Resolve(config.ResolveOptions{ + FlagProfile: flagProfile, + EnvAPISecret: os.Getenv(config.EnvAPISecret), + EnvAPIHost: os.Getenv(config.EnvAPIHost), + EnvProfile: os.Getenv(config.EnvProfile), + RepoOverlay: overlay, + Config: cfg, + }) + if rerr != nil { + var cli *output.CLIError + if errors.As(rerr, &cli) { + return "", "", cli + } + return "", "", output.NewCLIError(output.ErrUsage, rerr.Error(), "Run `urlbox config path` to locate the config file.") + } + host = resolved.APIHost + if host == "" { + host = api.ResolveAPIHost() + } + return host, resolved.Profile, nil +} + +func loadSession(cmd *cobra.Command) (*sessionState, *output.CLIError) { + host, profileName, cliErr := sessionHost(cmd) + if cliErr != nil { + return nil, cliErr + } + cfg, cfgErr := config.LoadOrCLIError() + if cfgErr != nil { + return nil, cfgErr + } + profile := cfg.Profiles[profileName] + if profile.SessionToken == "" { + return nil, output.NewCLIError( + output.ErrAuth, + notLoggedInMsg, + loginHint, + ) + } + return &sessionState{ + Host: host, + ProfileName: profileName, + Profile: profile, + Client: newSessionClient(cmd, host, profile.SessionToken), + }, nil +} + +func updateProfile(profileName string, mutate func(*config.Profile)) *output.CLIError { + err := config.Update(func(c *config.Config) error { + p := c.Profiles[profileName] + mutate(&p) + c.Profiles[profileName] = p + if c.DefaultProfile == "" { + c.DefaultProfile = profileName + } + return nil + }) + if err == nil { + return nil + } + var cli *output.CLIError + if errors.As(err, &cli) { + return cli + } + return output.NewCLIError(output.ErrForbidden, "could not write config: "+err.Error(), + "Check the permissions of the config directory (`urlbox config path`).") +} + +func requireActiveOrg(sess *sessionState) (string, *output.CLIError) { + if sess.Profile.ActiveOrg == "" { + return "", output.NewCLIError(output.ErrUsage, + "no active organisation", + "Select one with `urlbox orgs select`.") + } + return sess.Profile.ActiveOrg, nil +} + +func promptPick(label string, options []string, active int) (int, error) { + idx, err := prompt.SelectOne(label, options, active) + if errors.Is(err, prompt.ErrNotInteractive) { + return -1, errNotInteractivePick + } + return idx, err +} diff --git a/internal/cmd/session_retry_test.go b/internal/cmd/session_retry_test.go new file mode 100644 index 0000000..e9a864c --- /dev/null +++ b/internal/cmd/session_retry_test.go @@ -0,0 +1,82 @@ +package cmd + +import ( + "bytes" + "strings" + "testing" + + "github.com/urlbox/urlbox-cli/internal/api/apitest" +) + +func retryable500() apitest.ScriptedResponse { + return apitest.ScriptedResponse{Status: 500} +} + +func TestSessionNoRetryStopsAfterOneAttempt(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New(retryable500(), retryable500(), retryable500(), retryable500()) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + + var stdout, stderr bytes.Buffer + code := Execute([]string{"whoami", "--no-retry", "--output-format", "json"}, &stdout, &stderr) + if code == 0 { + t.Fatalf("expected non-zero exit on server error, got 0\n%s", stdout.String()) + } + if reqs := srv.Requests(); len(reqs) != 1 { + t.Fatalf("--no-retry must make exactly 1 attempt, got %d", len(reqs)) + } +} + +func TestSessionMaxRetriesOneMakesTwoAttempts(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New(retryable500(), retryable500(), retryable500(), retryable500()) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + + var stdout, stderr bytes.Buffer + code := Execute([]string{"whoami", "--max-retries", "1", "--output-format", "json"}, &stdout, &stderr) + if code == 0 { + t.Fatalf("expected non-zero exit on server error, got 0\n%s", stdout.String()) + } + if reqs := srv.Requests(); len(reqs) != 2 { + t.Fatalf("--max-retries 1 must make exactly 2 attempts, got %d", len(reqs)) + } +} + +func TestSessionDefaultRetriesUseFullBudget(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + scripts := make([]apitest.ScriptedResponse, 6) + for i := range scripts { + scripts[i] = retryable500() + } + srv := apitest.New(scripts...) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + + var stdout, stderr bytes.Buffer + code := Execute([]string{"whoami", "--max-retries", "2", "--output-format", "json"}, &stdout, &stderr) + if code == 0 { + t.Fatalf("expected non-zero exit on server error, got 0\n%s", stdout.String()) + } + if reqs := srv.Requests(); len(reqs) != 3 { + t.Fatalf("--max-retries 2 must make exactly 3 attempts, got %d", len(reqs)) + } +} + +func TestSessionRetryFlagsInheritedBySubcommandHelp(t *testing.T) { + var stdout, stderr bytes.Buffer + Execute([]string{"orgs", "list", "--help"}, &stdout, &stderr) + help := stdout.String() + stderr.String() + for _, want := range []string{"--no-retry", "--max-retries"} { + if !strings.Contains(help, want) { + t.Fatalf("orgs list --help missing inherited %s\n%s", want, help) + } + } +} diff --git a/internal/cmd/stdin_tty.go b/internal/cmd/stdin_tty.go new file mode 100644 index 0000000..d95b15a --- /dev/null +++ b/internal/cmd/stdin_tty.go @@ -0,0 +1,26 @@ +package cmd + +import ( + "io" + "os" + + "golang.org/x/term" +) + +var stdinTTYOverride *bool + +// SetStdinTTYForTest forces stdin TTY detection for tests. +func SetStdinTTYForTest(v bool) { stdinTTYOverride = &v } + +// ResetStdinTTYForTest clears the stdin override. +func ResetStdinTTYForTest() { stdinTTYOverride = nil } + +func isStdinTTY(r io.Reader) bool { + if stdinTTYOverride != nil { + return *stdinTTYOverride + } + if f, ok := r.(*os.File); ok { + return term.IsTerminal(int(f.Fd())) //nolint:gosec // file descriptors fit in int on every platform Go supports + } + return false +} diff --git a/internal/cmd/storage.go b/internal/cmd/storage.go new file mode 100644 index 0000000..0bfccb5 --- /dev/null +++ b/internal/cmd/storage.go @@ -0,0 +1,436 @@ +package cmd + +import ( + "context" + "errors" + "fmt" + "strconv" + "strings" + + "github.com/spf13/cobra" + + "github.com/urlbox/urlbox-cli/internal/output" + "github.com/urlbox/urlbox-cli/internal/prompt" +) + +func newStorageCmd() *cobra.Command { + c := &cobra.Command{ + Use: "storage", + Short: "Manage org storage credentials", + Long: `Manage the active organisation's storage credentials. + +Storage credentials are owned by the organisation and assigned to projects. +Create one once, then assign it to any project's renders. + +Secrets are masked on display — pass --reveal for full values (JSON output +always includes them in full). + +Examples: + urlbox storage list + urlbox storage show prod-bucket --reveal + urlbox storage create --name prod --provider aws_s3 --bucket b --region us-east-1 --key k --secret s --assign-to my-project + urlbox storage update prod --region eu-west-1 + urlbox storage delete prod`, + } + list := &cobra.Command{ + Use: "list", + Short: "List the organisation's storage credentials", + Args: cobra.NoArgs, + RunE: runStorageList, + } + var showReveal bool + show := &cobra.Command{ + Use: "show ", + Short: "Show one storage credential", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runStorageShow(cmd, args, showReveal) + }, + } + show.Flags().BoolVar(&showReveal, "reveal", false, "Print secrets unmasked (default: masked)") + var ( + createFlags storageFlags + createJSON string + createAssignTo string + ) + create := &cobra.Command{ + Use: "create ", + Short: "Create a storage credential", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runStorageCreate(cmd, args, createJSON, createFlags, createAssignTo) + }, + } + bindStorageFlags(create, &createFlags) + create.Flags().StringVar(&createJSON, "json", "", "Full payload as a JSON object (typed flags win)") + create.Flags().StringVar(&createAssignTo, "assign-to", "", "Assign to this project after create") + var ( + updateFlags storageFlags + updateJSON string + ) + update := &cobra.Command{ + Use: "update ", + Short: "Update a storage credential (only the flags you pass)", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runStorageUpdate(cmd, args, updateJSON, updateFlags) + }, + } + bindStorageFlags(update, &updateFlags) + update.Flags().StringVar(&updateJSON, "json", "", "Fields to update as a JSON object (typed flags win)") + var deleteYes bool + del := &cobra.Command{ + Use: "delete ", + Short: "Delete a storage credential", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runStorageDelete(cmd, args, deleteYes) + }, + } + del.Flags().BoolVar(&deleteYes, "yes", false, "Skip the retype-to-confirm prompt") + c.AddCommand(list, show, create, update, del) + attachSessionRetryFlags(c) + return c +} + +func bindStorageFlags(cmd *cobra.Command, f *storageFlags) { + cmd.Flags().StringVar(&f.name, "name", "", "Credential name") + cmd.Flags().StringVar(&f.provider, "provider", "", "Storage provider (aws_s3|google_cloud_storage|cloudflare_r2|backblaze_b2|digitalocean_spaces|wasabi|custom|azure)") + cmd.Flags().StringVar(&f.bucket, "bucket", "", "Bucket name") + cmd.Flags().StringVar(&f.region, "region", "", "Bucket region") + cmd.Flags().StringVar(&f.endpoint, "endpoint", "", "Custom endpoint") + cmd.Flags().StringVar(&f.key, "key", "", "Access key") + cmd.Flags().StringVar(&f.secret, "secret", "", "Secret key") + cmd.Flags().StringVar(&f.cdnHost, "cdn-host", "", "CDN host") + cmd.Flags().StringVar(&f.accountName, "account-name", "", "Azure account name") + cmd.Flags().StringVar(&f.containerName, "container-name", "", "Azure container name") + cmd.Flags().StringVar(&f.sasToken, "sas-token", "", "Azure SAS token") + cmd.Flags().BoolVar(&f.privateBucket, "private-bucket", false, "Bucket is private") + cmd.Flags().BoolVar(&f.objectLock, "object-lock", false, "Bucket has object lock") +} + +func storageFlagsChanged(cmd *cobra.Command, f *storageFlags) { + f.set = map[string]bool{} + for _, name := range []string{ + "name", "provider", "bucket", "region", "endpoint", "key", "secret", + "cdn-host", "account-name", "container-name", "sas-token", + "private-bucket", "object-lock", + } { + if cmd.Flags().Changed(name) { + f.set[name] = true + } + } +} + +var storageProviderLabels = map[string]string{ + "aws_s3": "AWS S3", + "google_cloud_storage": "Google Cloud", + "cloudflare_r2": "Cloudflare R2", + "backblaze_b2": "Backblaze B2", + "digitalocean_spaces": "DigitalOcean", + "wasabi": "Wasabi", + "custom": "Custom", + "minio": "MinIO", + "azure": "Azure", +} + +func storageProviderLabel(c map[string]any) string { + provider := valueOrEmpty(c["provider"]) + if valueOrEmpty(c["type"]) == "azure" { + provider = "azure" + } + if label, ok := storageProviderLabels[provider]; ok { + return label + } + return provider +} + +func storageEndpointCell(c map[string]any) string { + if endpoint := valueOrEmpty(c["endpoint"]); endpoint != "" { + return endpoint + } + if valueOrEmpty(c["provider"]) == "aws_s3" { + return "AWS S3 (default)" + } + return "" +} + +func storageListRows(creds []map[string]any) [][]string { + rows := make([][]string, len(creds)) + for i, c := range creds { + bucket := valueOrEmpty(c["bucket"]) + if bucket == "" { + bucket = valueOrEmpty(c["containerName"]) + } + key := valueOrEmpty(c["key"]) + if key != "" { + key = maskSecret(key) + } + rows[i] = []string{ + bucket, valueOrEmpty(c["id"]), storageProviderLabel(c), + storageEndpointCell(c), key, assignedCount(c), + } + } + return rows +} + +func storageDetailPairs(c map[string]any, reveal bool) [][2]string { + pairs := [][2]string{ + {"ID", valueOrEmpty(c["id"])}, + {"Name", valueOrEmpty(c["name"])}, + } + if provider := storageProviderLabel(c); provider != "" { + pairs = append(pairs, [2]string{"Provider", provider}) + } + if bucket := valueOrEmpty(c["bucket"]); bucket != "" { + pairs = append(pairs, [2]string{"Bucket", bucket}) + } + if region := valueOrEmpty(c["region"]); region != "" { + pairs = append(pairs, [2]string{"Region", region}) + } + if endpoint := valueOrEmpty(c["endpoint"]); endpoint != "" { + pairs = append(pairs, [2]string{"Endpoint", endpoint}) + } + if account := valueOrEmpty(c["accountName"]); account != "" { + pairs = append(pairs, [2]string{"Azure account", account}) + } + if container := valueOrEmpty(c["containerName"]); container != "" { + pairs = append(pairs, [2]string{"Azure container", container}) + } + visibility := "public" + if private, _ := c["privateBucket"].(bool); private { + visibility = "private" + } + pairs = append(pairs, [2]string{"Visibility", visibility}) + if locked, _ := c["objectLock"].(bool); locked { + pairs = append(pairs, [2]string{"Object lock", "yes"}) + } + if cdn := valueOrEmpty(c["cdnHost"]); cdn != "" { + pairs = append(pairs, [2]string{"CDN", cdn}) + } + if key := valueOrEmpty(c["key"]); key != "" { + pairs = append(pairs, [2]string{"Key", revealOrMask(key, reveal)}) + } + if secret := valueOrEmpty(c["secret"]); secret != "" { + pairs = append(pairs, [2]string{"Secret", revealOrMask(secret, reveal)}) + } + if sas := valueOrEmpty(c["sasToken"]); sas != "" { + pairs = append(pairs, [2]string{"SAS token", revealOrMask(sas, reveal)}) + } + pairs = append(pairs, + [2]string{"Assigned projects", assignedCount(c)}, + [2]string{"Created", valueOrEmpty(c["createdAt"])}, + ) + return pairs +} + +func revealOrMask(value string, reveal bool) string { + if reveal { + return value + } + return maskSecret(value) +} + +func runStorageList(cmd *cobra.Command, _ []string) error { + sess, cliErr := loadSession(cmd) + if cliErr != nil { + return cliErr + } + org, orgErr := requireActiveOrg(sess) + if orgErr != nil { + return orgErr + } + ctx := context.Background() + items, err := fetchList(ctx, sess.Client, storageKind.orgListPath(org), storageKind.listKey) + if err != nil { + return asCLIError(err) + } + env := output.NewEnvelope("storage list", + map[string]any{"storageCredentials": items}, + fmt.Sprintf("%d storage credentials", len(items)), nil) + env.SetTable([]string{"BUCKET", "ID", "PROVIDER", "ENDPOINT", "KEY", "ASSIGNED"}, storageListRows(items), -1) + return writeEnvelopeWithQuietData(cmd, env, strconv.Itoa(len(items))) +} + +func runStorageShow(cmd *cobra.Command, args []string, reveal bool) error { + sess, cliErr := loadSession(cmd) + if cliErr != nil { + return cliErr + } + org, orgErr := requireActiveOrg(sess) + if orgErr != nil { + return orgErr + } + ctx := context.Background() + items, err := fetchList(ctx, sess.Client, storageKind.orgListPath(org), storageKind.listKey) + if err != nil { + return asCLIError(err) + } + resolved, resErr := resolveCredArg(items, args[0], storageKind) + if resErr != nil { + return resErr + } + var detail map[string]any + if err := sess.Client.GetJSON(ctx, storageKind.resourcePath(org, resolved.ID), &detail); err != nil { + return asCLIError(err) + } + name := valueOrEmpty(detail["name"]) + if name == "" { + name = valueOrEmpty(detail["id"]) + } + env := output.NewEnvelope("storage show", detail, + fmt.Sprintf("Storage credential %s", name), nil) + env.SetKV(storageDetailPairs(detail, reveal)) + return writeEnvelopeWithQuietData(cmd, env, valueOrEmpty(detail["id"])) +} + +func runStorageCreate(cmd *cobra.Command, args []string, jsonBody string, flags storageFlags, assignTo string) error { //nolint:gocritic // storageFlags is the flag-value struct passed by value from the command layer + sess, cliErr := loadSession(cmd) + if cliErr != nil { + return cliErr + } + org, orgErr := requireActiveOrg(sess) + if orgErr != nil { + return orgErr + } + storageFlagsChanged(cmd, &flags) + resolvedName, nameErr := createName(cmd, args, flags.name) + if nameErr != nil { + return nameErr + } + if resolvedName != "" { + flags.name = resolvedName + flags.set["name"] = true + } + body, bodyErr := buildStorageBody(jsonBody, flags, true) + if bodyErr != nil { + return bodyErr + } + ctx := context.Background() + var created map[string]any + if err := sess.Client.PostJSON(ctx, storageKind.orgListPath(org), body, &created); err != nil { + return asCLIError(err) + } + createdID := valueOrEmpty(created["id"]) + name := valueOrEmpty(created["name"]) + if name == "" { + name = createdID + } + outcome := maybeAssignAfterCreate(ctx, sess.Client, org, storageKind, createdID, assignTo, interactiveText(cmd)) + return reportCredCreate(cmd, "storage create", storageKind, created, name, createdID, outcome) +} + +func reportCredCreate(cmd *cobra.Command, command string, kind credKind, created map[string]any, name, createdID string, outcome assignOutcome) error { //nolint:gocritic // credKind is a value descriptor passed by value throughout + if outcome.Err != nil { + return output.NewCLIError(output.ErrServer, + fmt.Sprintf("created %s but could not assign it: %v", createdID, outcome.Err), + fmt.Sprintf("Assign it later with `urlbox projects %s assign %s`.", kind.group, createdID)) + } + var assigned any + summary := fmt.Sprintf("Created %s %s", kind.noun, name) + if outcome.Attempted { + assigned = map[string]any{"project": map[string]any{"id": outcome.Project.ID, "name": outcome.Project.Name}} + summary = fmt.Sprintf("Created %s %s (assigned to %s)", kind.noun, name, outcome.Project.Name) + } + env := output.NewEnvelope(command, + map[string]any{"credential": created, "assigned": assigned}, summary, nil) + return writeEnvelopeWithQuietData(cmd, env, createdID) +} + +func runStorageUpdate(cmd *cobra.Command, args []string, jsonBody string, flags storageFlags) error { //nolint:gocritic // storageFlags is the flag-value struct passed by value from the command layer + sess, cliErr := loadSession(cmd) + if cliErr != nil { + return cliErr + } + org, orgErr := requireActiveOrg(sess) + if orgErr != nil { + return orgErr + } + storageFlagsChanged(cmd, &flags) + body, bodyErr := buildStorageBody(jsonBody, flags, false) + if bodyErr != nil { + return bodyErr + } + if len(body) == 0 { + return output.NewCLIError(output.ErrUsage, + "nothing to update — pass at least one field flag or --json", + "Pass a field flag (e.g. --region) or --json.") + } + ctx := context.Background() + resolved, resErr := resolveStorageArg(ctx, sess, org, args[0]) + if resErr != nil { + return resErr + } + var updated map[string]any + if err := sess.Client.PatchJSON(ctx, storageKind.resourcePath(org, resolved.ID), body, &updated); err != nil { + return asCLIError(err) + } + name := resolved.Name + if name == "" { + name = resolved.ID + } + env := output.NewEnvelope("storage update", updated, + fmt.Sprintf("Updated storage credential %s", name), nil) + return writeEnvelopeWithQuietData(cmd, env, resolved.ID) +} + +func runStorageDelete(cmd *cobra.Command, args []string, yes bool) error { + sess, cliErr := loadSession(cmd) + if cliErr != nil { + return cliErr + } + org, orgErr := requireActiveOrg(sess) + if orgErr != nil { + return orgErr + } + ctx := context.Background() + items, err := fetchList(ctx, sess.Client, storageKind.orgListPath(org), storageKind.listKey) + if err != nil { + return asCLIError(err) + } + resolved, resErr := resolveCredArg(items, args[0], storageKind) + if resErr != nil { + return resErr + } + name := resolved.Name + if name == "" { + name = resolved.ID + } + if !yes { + if err := confirmDeletion(name); err != nil { + return err + } + } + if err := sess.Client.DeleteJSON(ctx, storageKind.resourcePath(org, resolved.ID), nil); err != nil { + return asCLIError(err) + } + env := output.NewEnvelope("storage delete", + map[string]any{"deleted": resolved.ID}, + fmt.Sprintf("Deleted storage credential %s", name), nil) + return writeEnvelopeWithQuietData(cmd, env, resolved.ID) +} + +func resolveStorageArg(ctx context.Context, sess *sessionState, org, arg string) (nameID, *output.CLIError) { + var items []map[string]any + if !strings.HasPrefix(arg, storageKind.prefix) { + fetched, err := fetchList(ctx, sess.Client, storageKind.orgListPath(org), storageKind.listKey) + if err != nil { + return nameID{}, asCLIError(err) + } + items = fetched + } + return resolveCredArg(items, arg, storageKind) +} + +func confirmDeletion(name string) *output.CLIError { + if err := prompt.TypeToConfirm(fmt.Sprintf("Type %q to confirm deletion:", name), name); err != nil { + if errors.Is(err, prompt.ErrNotInteractive) { + return output.NewCLIError(output.ErrUsage, + "deletion needs confirmation", + "Re-run with --yes to confirm non-interactively.") + } + return output.NewCLIError(output.ErrUsage, err.Error(), + "Re-run with --yes to confirm non-interactively.") + } + return nil +} diff --git a/internal/cmd/storage_test.go b/internal/cmd/storage_test.go new file mode 100644 index 0000000..e467416 --- /dev/null +++ b/internal/cmd/storage_test.go @@ -0,0 +1,335 @@ +package cmd + +import ( + "bytes" + "testing" + + "github.com/urlbox/urlbox-cli/internal/api/apitest" +) + +const storageListJSON = `{"storageCredentials":[ + {"id":"store_1","type":"s3","name":"prod","provider":"aws_s3","bucket":"prod-bucket","region":"us-east-1","endpoint":null,"privateBucket":false,"objectLock":false,"accountName":null,"containerName":null,"cdnHost":null,"key":"AKIAFAKEFAKEFAKE","secret":"sk_fake_secret_value","sasToken":null,"assignedProjectIds":["proj_1"],"createdAt":"2026-08-01T00:00:00.000Z"}, + {"id":"store_2","type":"azure","name":"az","provider":null,"bucket":null,"region":null,"endpoint":null,"privateBucket":true,"objectLock":false,"accountName":"acct","containerName":"cont","cdnHost":null,"key":null,"secret":null,"sasToken":"sv=fake","assignedProjectIds":[],"createdAt":"2026-08-02T00:00:00.000Z"}]}` + +const storageOneJSON = `{"id":"store_1","type":"s3","name":"prod","provider":"aws_s3","bucket":"prod-bucket","region":"us-east-1","endpoint":null,"privateBucket":false,"objectLock":false,"accountName":null,"containerName":null,"cdnHost":null,"key":"AKIAFAKEFAKEFAKE","secret":"sk_fake_secret_value","sasToken":null,"assignedProjectIds":["proj_1"],"createdAt":"2026-08-01T00:00:00.000Z"}` + +func TestStorageListRendersTableMaskedKey(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New(apitest.SuccessJSON(storageListJSON)) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + var stdout, stderr bytes.Buffer + code := Execute([]string{"storage", "list", "--output-format", "text"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit %d\n%s\n%s", code, stdout.String(), stderr.String()) + } + reqs := srv.Requests() + if reqs[0].Method != "GET" || reqs[0].Path != "/v2/organisation/org_compat/storage-credentials" { + t.Fatalf("request: %+v", reqs[0]) + } + out := stdout.String() + for _, want := range []string{"prod-bucket", "store_1", "AWS S3", "AWS S3 (default)", "cont", "Azure"} { + if !bytes.Contains(stdout.Bytes(), []byte(want)) { + t.Fatalf("list output missing %q: %s", want, out) + } + } + if bytes.Contains(stdout.Bytes(), []byte("AKIAFAKEFAKEFAKE")) { + t.Fatalf("list must mask the key, raw value present: %s", out) + } + if !bytes.Contains(stdout.Bytes(), []byte("…")) { + t.Fatalf("list must show a masked key with an ellipsis: %s", out) + } +} + +func TestStorageShowMasksSecretsRevealUnhides(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New( + apitest.SuccessJSON(storageListJSON), + apitest.SuccessJSON(storageOneJSON), + ) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + var stdout, stderr bytes.Buffer + code := Execute([]string{"storage", "show", "store_1", "--output-format", "text"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit %d\n%s\n%s", code, stdout.String(), stderr.String()) + } + reqs := srv.Requests() + if reqs[1].Method != "GET" || reqs[1].Path != "/v2/organisation/org_compat/storage-credentials/store_1" { + t.Fatalf("show request: %+v", reqs[1]) + } + out := stdout.String() + for _, want := range []string{"NAME", "ID", "PROVIDER", "BUCKET", "VISIBILITY", "public"} { + if !bytes.Contains(stdout.Bytes(), []byte(want)) { + t.Fatalf("show output missing %q: %s", want, out) + } + } + if bytes.Contains(stdout.Bytes(), []byte("AKIAFAKEFAKEFAKE")) { + t.Fatalf("show must mask the key without --reveal: %s", out) + } + if bytes.Contains(stdout.Bytes(), []byte("sk_fake_secret_value")) { + t.Fatalf("show must mask the secret without --reveal: %s", out) + } + + srv2 := apitest.New( + apitest.SuccessJSON(storageListJSON), + apitest.SuccessJSON(storageOneJSON), + ) + t.Cleanup(srv2.Close) + t.Setenv("URLBOX_API_HOST", srv2.URL()) + var revealOut, revealErr bytes.Buffer + code = Execute([]string{"storage", "show", "store_1", "--reveal", "--output-format", "text"}, &revealOut, &revealErr) + if code != 0 { + t.Fatalf("reveal exit %d\n%s\n%s", code, revealOut.String(), revealErr.String()) + } + for _, want := range []string{"AKIAFAKEFAKEFAKE", "sk_fake_secret_value"} { + if !bytes.Contains(revealOut.Bytes(), []byte(want)) { + t.Fatalf("--reveal must show full %q: %s", want, revealOut.String()) + } + } +} + +func TestStorageCreateSendsProviderDrivenBody(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New(apitest.SuccessJSON(`{"id":"store_new","type":"s3","name":"x","provider":"cloudflare_r2","bucket":"b","assignedProjectIds":[]}`)) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + var stdout, stderr bytes.Buffer + code := Execute([]string{ + "storage", "create", + "--name", "x", "--provider", "cloudflare_r2", "--bucket", "b", "--region", "auto", + "--key", "k", "--secret", "s", "--endpoint", "https://x.r2.cloudflarestorage.com", + "--cdn-host", "cdn.example.com", + "--output-format", "json", + }, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit %d\n%s\n%s", code, stdout.String(), stderr.String()) + } + reqs := srv.Requests() + if reqs[0].Method != "POST" || reqs[0].Path != "/v2/organisation/org_compat/storage-credentials" { + t.Fatalf("create request: %+v", reqs[0]) + } + for _, want := range []string{ + `"type":"s3"`, `"provider":"cloudflare_r2"`, `"name":"x"`, `"bucket":"b"`, + `"region":"auto"`, `"key":"k"`, `"secret":"s"`, + `"endpoint":"https://x.r2.cloudflarestorage.com"`, `"cdnHost":"cdn.example.com"`, + } { + if !bytes.Contains(reqs[0].Body, []byte(want)) { + t.Fatalf("create body missing %s: %s", want, reqs[0].Body) + } + } +} + +func TestStorageCreatePositionalName(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New(apitest.SuccessJSON(`{"id":"store_new","type":"s3","name":"prod","provider":"aws_s3","bucket":"b","assignedProjectIds":[]}`)) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + var stdout, stderr bytes.Buffer + code := Execute([]string{ + "storage", "create", "prod", + "--provider", "aws_s3", "--bucket", "b", "--region", "us-east-1", + "--key", "k", "--secret", "s", + "--output-format", "json", + }, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit %d\n%s\n%s", code, stdout.String(), stderr.String()) + } + reqs := srv.Requests() + if reqs[0].Method != "POST" || reqs[0].Path != "/v2/organisation/org_compat/storage-credentials" { + t.Fatalf("create request: %+v", reqs[0]) + } + if !bytes.Contains(reqs[0].Body, []byte(`"name":"prod"`)) { + t.Fatalf("create body must carry the positional name: %s", reqs[0].Body) + } +} + +func TestStorageCreatePositionalConflictsWithFlag(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New(apitest.SuccessJSON(`{}`)) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + var stdout, stderr bytes.Buffer + code := Execute([]string{ + "storage", "create", "a", "--name", "b", + "--provider", "aws_s3", "--bucket", "x", "--region", "us-east-1", "--key", "k", "--secret", "s", + "--output-format", "json", + }, &stdout, &stderr) + if code == 0 { + t.Fatalf("conflicting name must fail\n%s", stdout.String()) + } + if len(srv.Requests()) != 0 { + t.Fatalf("conflict must make no API call: %+v", srv.Requests()) + } + if !bytes.Contains(stdout.Bytes(), []byte("--name")) { + t.Fatalf("conflict error must name the flag: %s", stdout.String()) + } +} + +func TestStorageUpdatePartialPatch(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New(apitest.SuccessJSON(`{"id":"store_1","name":"prod","region":"eu-west-1"}`)) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + var stdout, stderr bytes.Buffer + code := Execute([]string{"storage", "update", "store_1", "--region", "eu-west-1", "--output-format", "json"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit %d\n%s\n%s", code, stdout.String(), stderr.String()) + } + reqs := srv.Requests() + if len(reqs) != 1 { + t.Fatalf("prefixed-id update must make no list call, got %d requests: %+v", len(reqs), reqs) + } + if reqs[0].Method != "PATCH" || reqs[0].Path != "/v2/organisation/org_compat/storage-credentials/store_1" { + t.Fatalf("update request: %+v", reqs[0]) + } + if string(reqs[0].Body) != `{"region":"eu-west-1"}` { + t.Fatalf("update body must be exactly the changed field: %s", reqs[0].Body) + } +} + +func TestStorageDeleteRequiresYesOffTTY(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New(apitest.SuccessJSON(storageListJSON)) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + var stdout, stderr bytes.Buffer + code := Execute([]string{"storage", "delete", "store_1", "--output-format", "json"}, &stdout, &stderr) + if code == 0 { + t.Fatalf("delete without --yes off-TTY must fail\n%s", stdout.String()) + } + if !bytes.Contains(stdout.Bytes(), []byte("--yes")) { + t.Fatalf("error must name --yes: %s", stdout.String()) + } + for _, r := range srv.Requests() { + if r.Method == "DELETE" { + t.Fatalf("no DELETE must be issued without confirmation: %+v", r) + } + } +} + +func TestStorageDeleteWithYes(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New( + apitest.SuccessJSON(storageListJSON), + apitest.SuccessJSON(`{"id":"store_1","deleted":true}`), + ) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + var stdout, stderr bytes.Buffer + code := Execute([]string{"storage", "delete", "store_1", "--yes", "--output-format", "text"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit %d\n%s\n%s", code, stdout.String(), stderr.String()) + } + reqs := srv.Requests() + last := reqs[len(reqs)-1] + if last.Method != "DELETE" || last.Path != "/v2/organisation/org_compat/storage-credentials/store_1" { + t.Fatalf("delete request: %+v", last) + } + if !bytes.Contains(stdout.Bytes(), []byte("prod")) { + t.Fatalf("delete summary must name the credential: %s", stdout.String()) + } +} + +func TestStorageCreateAssignTo(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New( + apitest.SuccessJSON(`{"id":"store_new","type":"s3","name":"x","provider":"aws_s3","bucket":"b","assignedProjectIds":[]}`), + apitest.SuccessJSON(`{"projects":[{"id":"proj_1","name":"Main"}]}`), + apitest.SuccessJSON(`{"id":"proj_1","name":"Main","storageCredentialId":"store_new"}`), + ) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + var stdout, stderr bytes.Buffer + code := Execute([]string{ + "storage", "create", + "--name", "x", "--provider", "aws_s3", "--bucket", "b", "--region", "us-east-1", + "--key", "k", "--secret", "s", + "--assign-to", "proj_1", + "--output-format", "json", + }, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit %d\n%s\n%s", code, stdout.String(), stderr.String()) + } + reqs := srv.Requests() + var put *apitest.CapturedRequest + for i := range reqs { + if reqs[i].Method == "PUT" { + put = &reqs[i] + } + } + if put == nil { + t.Fatalf("assign must issue a PUT, requests: %+v", reqs) + } + if put.Path != "/v2/organisation/org_compat/projects/proj_1/storage-credential" { + t.Fatalf("assign PUT path: %s", put.Path) + } + if !bytes.Contains(put.Body, []byte(`"storageCredentialId":"store_new"`)) { + t.Fatalf("assign body missing storageCredentialId: %s", put.Body) + } + if !bytes.Contains(stdout.Bytes(), []byte(`"assigned"`)) { + t.Fatalf("envelope data must carry the assigned project: %s", stdout.String()) + } +} + +func TestStorageCreateNoAssignHasNilAssigned(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New(apitest.SuccessJSON(`{"id":"store_new","type":"s3","name":"x","provider":"aws_s3","bucket":"b","assignedProjectIds":[]}`)) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + var stdout, stderr bytes.Buffer + code := Execute([]string{ + "storage", "create", + "--name", "x", "--provider", "aws_s3", "--bucket", "b", "--region", "us-east-1", + "--key", "k", "--secret", "s", + "--output-format", "json", + }, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit %d\n%s\n%s", code, stdout.String(), stderr.String()) + } + for _, r := range srv.Requests() { + if r.Method == "PUT" { + t.Fatalf("no assign must happen without --assign-to: %+v", r) + } + } + if !bytes.Contains(stdout.Bytes(), []byte(`"assigned": null`)) { + t.Fatalf("envelope data must carry assigned:null when not assigned: %s", stdout.String()) + } +} + +func TestStorageNotFoundNameHint(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New(apitest.SuccessJSON(storageListJSON)) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + var stdout, stderr bytes.Buffer + code := Execute([]string{"storage", "show", "nope", "--output-format", "json"}, &stdout, &stderr) + if code == 0 { + t.Fatalf("show of an unknown name must fail\n%s", stdout.String()) + } + if !bytes.Contains(stdout.Bytes(), []byte("urlbox storage list")) { + t.Fatalf("not-found hint must name `urlbox storage list`: %s", stdout.String()) + } +} diff --git a/internal/cmd/text_render_test.go b/internal/cmd/text_render_test.go new file mode 100644 index 0000000..10e617b --- /dev/null +++ b/internal/cmd/text_render_test.go @@ -0,0 +1,136 @@ +package cmd + +import ( + "bytes" + "strings" + "testing" + + "github.com/urlbox/urlbox-cli/internal/api/apitest" +) + +func runTextMode(t *testing.T, args ...string) (stdout, stderr string, code int) { + t.Helper() + t.Setenv("NO_COLOR", "1") + var out, errBuf bytes.Buffer + code = Execute(append(args, "--output-format", "text"), &out, &errBuf) + return out.String(), errBuf.String(), code +} + +func TestOrgsList_TextMode_TableWithMarker(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New( + apitest.SuccessJSON(`[{"id":"1","name":"One","publicId":"org_one"},{"id":"2","name":"Two","publicId":"org_two"}]`), + apitest.SuccessJSON(`{"user":{"email":"a@urlbox.com"},"session":{"activeOrganizationId":"2","activeOrganizationPublicId":"org_two"}}`), + ) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + + out, stderrOut, code := runTextMode(t, "orgs", "list") + if code != 0 { + t.Fatalf("exit %d\n%s\n%s", code, out, stderrOut) + } + for _, want := range []string{"One", "Two", "org_one", "org_two", "●"} { + if !strings.Contains(out, want) { + t.Errorf("orgs list text missing %q, got:\n%s", want, out) + } + } + activeLine := "" + for _, line := range strings.Split(out, "\n") { + if strings.Contains(line, "Two") { + activeLine = line + } + } + if !strings.Contains(activeLine, "●") { + t.Errorf("active marker should be on the Two row, got %q", activeLine) + } +} + +func TestWhoami_TextMode_KVContainsEmail(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New( + apitest.SuccessJSON(`{"user":{"email":"me@urlbox.com"},"session":{"activeOrganizationId":"1","activeOrganizationPublicId":"org_one"}}`), + apitest.SuccessJSON(`[{"id":"1","name":"Acme","publicId":"org_one"}]`), + apitest.SuccessJSON(`{"projects":[{"id":"proj_compat","name":"Prod"}]}`), + ) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + + out, stderrOut, code := runTextMode(t, "whoami") + if code != 0 { + t.Fatalf("exit %d\n%s\n%s", code, out, stderrOut) + } + if !strings.Contains(out, "me@urlbox.com") { + t.Errorf("whoami text KV should contain email, got:\n%s", out) + } + if !strings.Contains(out, "SIGNED IN") { + t.Errorf("whoami text KV should carry the uppercased 'SIGNED IN' label, got:\n%s", out) + } +} + +func TestUsage_TextMode_KVContainsFields(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New( + apitest.SuccessJSON(`{"rendersUsed":42,"renderQuota":1000,"period":{"start":"2026-08-01","end":"2026-08-31"}}`), + ) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + + out, stderrOut, code := runTextMode(t, "usage") + if code != 0 { + t.Fatalf("exit %d\n%s\n%s", code, out, stderrOut) + } + for _, want := range []string{"42", "1000", "2026-08-01"} { + if !strings.Contains(out, want) { + t.Errorf("usage text KV missing %q, got:\n%s", want, out) + } + } +} + +func TestLink_TextMode_ContainsFullSignedURL(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + + out, stderrOut, code := runTextMode(t, "link", "https://example.com") + if code != 0 { + t.Fatalf("exit %d\n%s\n%s", code, out, stderrOut) + } + if !strings.Contains(out, "/v1/pk_test_key/") { + t.Errorf("link text should contain the full signed URL path, got:\n%s", out) + } + if !strings.Contains(out, "url=https") { + t.Errorf("link text should contain the encoded url query, got:\n%s", out) + } + if !strings.Contains(out, "URL") { + t.Errorf("link text KV should carry a URL label, got:\n%s", out) + } +} + +func TestDoctor_TextMode_FailureShowsGlyphAndHint(t *testing.T) { + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir) + t.Setenv("URLBOX_API_HOST", "http://127.0.0.1:1") + + out, _, code := runTextMode(t, "doctor") + if code == 0 { + t.Fatalf("doctor with no secret + unreachable host should fail, got exit 0:\n%s", out) + } + if !strings.Contains(out, "✗") { + t.Errorf("doctor failure summary should use ✗, got:\n%s", out) + } + if strings.Contains(strings.SplitN(out, "\n", 2)[0], "✓") { + t.Errorf("doctor failure top line must not print ✓, got:\n%s", out) + } + if !strings.Contains(out, "render_credential") { + t.Errorf("doctor text should list per-check names, got:\n%s", out) + } + if !strings.Contains(strings.ToLower(out), "hint") { + t.Errorf("doctor text should surface a hint line for the failing check, got:\n%s", out) + } +} diff --git a/internal/cmd/usage.go b/internal/cmd/usage.go new file mode 100644 index 0000000..62f78b7 --- /dev/null +++ b/internal/cmd/usage.go @@ -0,0 +1,54 @@ +package cmd + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + + "github.com/urlbox/urlbox-cli/internal/output" +) + +func newUsageCmd() *cobra.Command { + c := &cobra.Command{ + Use: "usage", + Short: "Show the organisation's render usage for the current period", + Args: cobra.NoArgs, + RunE: runUsage, + } + attachSessionRetryFlags(c) + return c +} + +func runUsage(cmd *cobra.Command, _ []string) error { + sess, cliErr := loadSession(cmd) + if cliErr != nil { + return cliErr + } + var usage struct { + RendersUsed int `json:"rendersUsed"` + RenderQuota int `json:"renderQuota"` + Period struct { + Start string `json:"start"` + End string `json:"end"` + } `json:"period"` + } + if err := sess.Client.GetJSON(context.Background(), "/v2/usage", &usage); err != nil { + return asCLIError(err) + } + data := map[string]any{ + "renders_used": usage.RendersUsed, + "render_quota": usage.RenderQuota, + "current_period_start": usage.Period.Start, + "current_period_end": usage.Period.End, + } + summary := fmt.Sprintf("Renders used: %d / %d", usage.RendersUsed, usage.RenderQuota) + env := output.NewEnvelope("usage", data, summary, nil) + env.SetKV([][2]string{ + {"Renders used", fmt.Sprintf("%d", usage.RendersUsed)}, + {"Render quota", fmt.Sprintf("%d", usage.RenderQuota)}, + {"Period start", usage.Period.Start}, + {"Period end", usage.Period.End}, + }) + return writeEnvelopeWithQuietData(cmd, env, fmt.Sprintf("%d", usage.RendersUsed)) +} diff --git a/internal/cmd/usage_test.go b/internal/cmd/usage_test.go new file mode 100644 index 0000000..c9a1388 --- /dev/null +++ b/internal/cmd/usage_test.go @@ -0,0 +1,48 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "testing" + + "github.com/urlbox/urlbox-cli/internal/api/apitest" +) + +func TestUsageHappyPath(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New( + apitest.SuccessJSON(`{"rendersUsed":120,"renderQuota":1000,"period":{"start":"2026-08-01","end":"2026-08-31"}}`), + ) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + var stdout, stderr bytes.Buffer + code := Execute([]string{"usage", "--output-format", "json"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit %d\n%s\n%s", code, stdout.String(), stderr.String()) + } + var env struct { + Data map[string]any `json:"data"` + } + if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { + t.Fatalf("decode: %v", err) + } + if env.Data["renders_used"] != float64(120) || env.Data["render_quota"] != float64(1000) { + t.Fatalf("data: %#v", env.Data) + } + if env.Data["current_period_start"] != "2026-08-01" { + t.Fatalf("period: %#v", env.Data) + } +} + +func TestUsageNotLoggedIn(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, false) + t.Setenv("XDG_CONFIG_HOME", dir) + var stdout, stderr bytes.Buffer + code := Execute([]string{"usage", "--output-format", "json"}, &stdout, &stderr) + if code != 3 { + t.Fatalf("exit = %d, want 3", code) + } +} diff --git a/internal/cmd/whoami.go b/internal/cmd/whoami.go new file mode 100644 index 0000000..389df2f --- /dev/null +++ b/internal/cmd/whoami.go @@ -0,0 +1,97 @@ +package cmd + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + + "github.com/urlbox/urlbox-cli/internal/output" +) + +func newWhoamiCmd() *cobra.Command { + c := &cobra.Command{ + Use: "whoami", + Aliases: []string{"me"}, + Short: "Show the signed-in user and active context", + Long: `Show who you are signed in as, plus the active organisation and project. + +Examples: + urlbox whoami + urlbox whoami --output-format json`, + Args: cobra.NoArgs, + RunE: runWhoami, + } + attachSessionRetryFlags(c) + return c +} + +func runWhoami(cmd *cobra.Command, _ []string) error { + sess, cliErr := loadSession(cmd) + if cliErr != nil { + return cliErr + } + ctx := context.Background() + var session sessionResponse + if err := sess.Client.GetJSON(ctx, "/v1/auth/get-session", &session); err != nil { + return asCLIError(err) + } + if session.User.Email == "" { + return output.NewCLIError(output.ErrAuth, notLoggedInMsg, loginHint) + } + + project := resolveWhoamiProject(ctx, sess) + orgName := activeOrgName(ctx, sess.Client) + + data := map[string]any{ + "email": session.User.Email, + "org": map[string]any{ + "id": session.Session.ActiveOrganizationPublicID, + "name": orgName, + }, + "project": nil, + } + if project.ID != "" { + data["project"] = map[string]any{"id": project.ID, "name": project.Name} + } + summary := fmt.Sprintf("Signed in as %s — org %s", session.User.Email, orgName) + env := output.NewEnvelope("whoami", data, summary, nil) + env.SetKV(identityKVPairs(session.User.Email, orgName, session.Session.ActiveOrganizationPublicID, project)) + return writeEnvelopeWithQuietData(cmd, env, session.User.Email) +} + +func identityKVPairs(email, orgName, orgID string, project nameID) [][2]string { + org := orgName + if orgID != "" { + if org == "" { + org = orgID + } else { + org += " (" + orgID + ")" + } + } + proj := "(none)" + if project.ID != "" { + if project.Name == "" { + proj = project.ID + } else { + proj = project.Name + " (" + project.ID + ")" + } + } + return [][2]string{{"Signed in", email}, {"Org", org}, {"Project", proj}} +} + +func resolveWhoamiProject(ctx context.Context, sess *sessionState) nameID { + if sess.Profile.ActiveProject == "" { + return nameID{} + } + projects, err := fetchList(ctx, sess.Client, "/v2/projects", "projects") + if err != nil { + return nameID{ID: sess.Profile.ActiveProject} + } + for _, r := range toNameIDs(projects) { + if r.ID == sess.Profile.ActiveProject { + return r + } + } + return nameID{ID: sess.Profile.ActiveProject} +} diff --git a/internal/cmd/whoami_test.go b/internal/cmd/whoami_test.go new file mode 100644 index 0000000..1a8a63c --- /dev/null +++ b/internal/cmd/whoami_test.go @@ -0,0 +1,91 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "testing" + + "github.com/urlbox/urlbox-cli/internal/api/apitest" +) + +func TestWhoamiNotLoggedIn(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, false) + t.Setenv("XDG_CONFIG_HOME", dir) + var stdout, stderr bytes.Buffer + code := Execute([]string{"whoami", "--output-format", "json"}, &stdout, &stderr) + if code != 3 { + t.Fatalf("exit = %d, want 3 (auth)", code) + } + if !bytes.Contains(stdout.Bytes(), []byte(`"code": "auth"`)) || + !bytes.Contains(stdout.Bytes(), []byte("urlbox login")) { + t.Fatalf("error envelope must carry auth code + login hint: %s", stdout.String()) + } +} + +func TestWhoamiHappyPath(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New( + apitest.SuccessJSON(`{"user":{"email":"a@urlbox.com"},"session":{"activeOrganizationId":"7","activeOrganizationPublicId":"org_acme"}}`), + apitest.SuccessJSON(`{"projects":[{"id":"proj_compat","name":"Main"}]}`), + apitest.SuccessJSON(`{"user":{"email":"a@urlbox.com"},"session":{"activeOrganizationId":"7","activeOrganizationPublicId":"org_acme"}}`), + apitest.SuccessJSON(`[{"id":"7","name":"Acme","publicId":"org_acme"}]`), + ) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + + var stdout, stderr bytes.Buffer + code := Execute([]string{"whoami", "--output-format", "json"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit %d\n%s\n%s", code, stdout.String(), stderr.String()) + } + var env struct { + Data struct { + Email string `json:"email"` + Org struct { + ID string `json:"id"` + Name string `json:"name"` + } `json:"org"` + Project struct { + ID string `json:"id"` + Name string `json:"name"` + } `json:"project"` + } `json:"data"` + } + if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { + t.Fatalf("decode: %v\n%s", err, stdout.String()) + } + if env.Data.Email != "a@urlbox.com" || env.Data.Org.ID != "org_acme" || + env.Data.Org.Name != "Acme" || env.Data.Project.ID != "proj_compat" { + t.Fatalf("data: %s", stdout.String()) + } +} + +func TestWhoamiExpiredSessionIsAuthError(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New( + apitest.SuccessJSON(`{"user":{"email":""},"session":{"activeOrganizationId":"","activeOrganizationPublicId":""}}`), + ) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + var stdout, stderr bytes.Buffer + code := Execute([]string{"whoami", "--output-format", "json"}, &stdout, &stderr) + if code != 3 { + t.Fatalf("dead token must exit 3, got %d\n%s", code, stdout.String()) + } +} + +func TestMeAliasWorks(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, false) + t.Setenv("XDG_CONFIG_HOME", dir) + var stdout, stderr bytes.Buffer + code := Execute([]string{"me", "--output-format", "json"}, &stdout, &stderr) + if code != 3 { + t.Fatalf("me alias must route to whoami, exit %d", code) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 32e7ecd..9c9d33e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -83,7 +83,7 @@ func LoadOrCLIError() (*Config, *output.CLIError) { return nil, output.NewCLIError( output.ErrUsage, "config file is malformed JSON: "+err.Error(), - "Edit "+Path()+" to fix the JSON, or remove the file and run `urlbox auth --api-secret ` to recreate.", + "Edit "+Path()+" to fix the JSON, or remove the file and run `urlbox login` to recreate.", ) } // Everything else (filesystem I/O, encoding, etc.) — still local, so diff --git a/internal/config/profile.go b/internal/config/profile.go index 27ebef4..12dd6ab 100644 --- a/internal/config/profile.go +++ b/internal/config/profile.go @@ -2,12 +2,18 @@ package config // Profile is one named credential set persisted in Config.Profiles. type Profile struct { - APIKey string `json:"api_key,omitempty"` - APISecret string `json:"api_secret,omitempty"` - APIHost string `json:"api_host,omitempty"` + APIKey string `json:"api_key,omitempty"` + APISecret string `json:"api_secret,omitempty"` + APIHost string `json:"api_host,omitempty"` + SessionToken string `json:"session_token,omitempty"` + ActiveOrg string `json:"active_org,omitempty"` + ActiveProject string `json:"active_project,omitempty"` } // IsEmpty reports whether the profile has no credentials at all. +// +//nolint:gocritic // value receiver is required: callers invoke IsEmpty on non-addressable composite literals. func (p Profile) IsEmpty() bool { - return p.APIKey == "" && p.APISecret == "" && p.APIHost == "" + return p.APIKey == "" && p.APISecret == "" && p.APIHost == "" && + p.SessionToken == "" && p.ActiveOrg == "" && p.ActiveProject == "" } diff --git a/internal/config/profile_session_test.go b/internal/config/profile_session_test.go new file mode 100644 index 0000000..25982d4 --- /dev/null +++ b/internal/config/profile_session_test.go @@ -0,0 +1,72 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestProfileSessionFieldsRoundTrip(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + err := Save(&Config{ + DefaultProfile: "default", + Profiles: map[string]Profile{"default": { + APISecret: "sk_live_1234567890", + SessionToken: "sess_tok_abcdef123456", + ActiveOrg: "org_01hxyz", + ActiveProject: "proj_01habc", + }}, + }) + if err != nil { + t.Fatalf("save: %v", err) + } + loaded, err := Load() + if err != nil { + t.Fatalf("load: %v", err) + } + p := loaded.Profiles["default"] + if p.SessionToken != "sess_tok_abcdef123456" { + t.Fatalf("session token dropped on roundtrip: %+v", p) + } + if p.ActiveOrg != "org_01hxyz" || p.ActiveProject != "proj_01habc" { + t.Fatalf("active org/project dropped: %+v", p) + } + info, err := os.Stat(Path()) + if err != nil { + t.Fatalf("stat: %v", err) + } + if info.Mode().Perm() != 0o600 { + t.Fatalf("config mode = %v, want 0600", info.Mode().Perm()) + } + if filepath.Dir(Path()) == "" { + t.Fatal("empty config dir") + } +} + +func TestProfileIsEmptyCountsSessionFields(t *testing.T) { + if (Profile{SessionToken: "tok"}).IsEmpty() { + t.Fatal("profile with only a session token must not be IsEmpty") + } + if !(Profile{}).IsEmpty() { + t.Fatal("zero profile must be IsEmpty") + } +} + +func TestProfileNameSelectionChain(t *testing.T) { + cfg := &Config{DefaultProfile: "team", Profiles: map[string]Profile{"team": {}}} + if got := ProfileName("flagged", "enved", &RepoOverlay{Profile: "repo"}, cfg); got != "flagged" { + t.Fatalf("flag must win, got %q", got) + } + if got := ProfileName("", "enved", &RepoOverlay{Profile: "repo"}, cfg); got != "repo" { + t.Fatalf("repo overlay must beat env, got %q", got) + } + if got := ProfileName("", "enved", nil, cfg); got != "enved" { + t.Fatalf("env must beat default_profile, got %q", got) + } + if got := ProfileName("", "", nil, cfg); got != "team" { + t.Fatalf("default_profile must beat literal default, got %q", got) + } + if got := ProfileName("", "", nil, &Config{}); got != "default" { + t.Fatalf("fallback must be default, got %q", got) + } +} diff --git a/internal/config/resolve.go b/internal/config/resolve.go index 723c52d..57cb9af 100644 --- a/internal/config/resolve.go +++ b/internal/config/resolve.go @@ -40,6 +40,23 @@ type Source struct { APIKey, APISecret, APIHost, Profile string } +// ProfileName resolves the active profile name from the priority chain: +// flag → repo overlay → env → default_profile → "default". +func ProfileName(flagProfile, envProfile string, overlay *RepoOverlay, cfg *Config) string { + switch { + case flagProfile != "": + return flagProfile + case overlay != nil && overlay.Profile != "": + return overlay.Profile + case envProfile != "": + return envProfile + case cfg != nil && cfg.DefaultProfile != "": + return cfg.DefaultProfile + default: + return "default" + } +} + // Resolve flattens opts into a single Resolved. // // Resolve is the SINGLE chokepoint where credential and host values @@ -116,17 +133,18 @@ func Resolve(opts ResolveOptions) (*Resolved, error) { } r := &Resolved{} + r.Profile = ProfileName(opts.FlagProfile, opts.EnvProfile, opts.RepoOverlay, opts.Config) switch { case opts.FlagProfile != "": - r.Profile, r.Source.Profile = opts.FlagProfile, "flag" + r.Source.Profile = "flag" case opts.RepoOverlay != nil && opts.RepoOverlay.Profile != "": - r.Profile, r.Source.Profile = opts.RepoOverlay.Profile, "repo" + r.Source.Profile = "repo" case opts.EnvProfile != "": - r.Profile, r.Source.Profile = opts.EnvProfile, "env" + r.Source.Profile = "env" case opts.Config != nil && opts.Config.DefaultProfile != "": - r.Profile, r.Source.Profile = opts.Config.DefaultProfile, "default_profile" + r.Source.Profile = "default_profile" default: - r.Profile, r.Source.Profile = "default", "default" + r.Source.Profile = "default" } var profile Profile diff --git a/internal/deviceauth/poll.go b/internal/deviceauth/poll.go new file mode 100644 index 0000000..a27bd6e --- /dev/null +++ b/internal/deviceauth/poll.go @@ -0,0 +1,49 @@ +// Package deviceauth drives the OAuth device-authorization polling loop the +// login command uses to exchange a device code for a session token. +package deviceauth + +import ( + "time" + + "github.com/urlbox/urlbox-cli/internal/clock" + "github.com/urlbox/urlbox-cli/internal/output" +) + +// Exchange is one device-token exchange result. AccessToken is set on success; +// RFCCode carries the RFC 8628 error string (authorization_pending, slow_down, +// access_denied, expired_token); Err carries a transport-level failure. +type Exchange struct { + AccessToken string + RFCCode string + Err error +} + +// Poll runs the device-authorization polling loop against exchange until it +// returns a token, the flow is denied or expired, or expiresIn elapses on clk. +// interval floors at 5s; slow_down widens it by 5s. Transport errors from +// exchange are ignored and polling continues until the deadline. +func Poll(clk clock.Clock, interval, expiresIn int, exchange func() Exchange) (string, *output.CLIError) { + if interval <= 0 { + interval = 5 + } + deadline := clk.Now().Add(time.Duration(expiresIn) * time.Second) + for clk.Now().Before(deadline) { + clk.Sleep(time.Duration(interval) * time.Second) + e := exchange() + if e.Err == nil && e.AccessToken != "" { + return e.AccessToken, nil + } + switch e.RFCCode { + case "authorization_pending", "": + continue + case "slow_down": + interval += 5 + continue + case "access_denied": + return "", output.NewCLIError(output.ErrAuth, "Login denied.", "Approve the request in your browser, then run `urlbox login` again.") + case "expired_token": + return "", output.NewCLIError(output.ErrAuth, "Code expired — run `urlbox login` again.", "Device codes are short-lived; restart the login to get a fresh code.") + } + } + return "", output.NewCLIError(output.ErrAuth, "Code expired — run `urlbox login` again.", "Device codes are short-lived; restart the login to get a fresh code.") +} diff --git a/internal/deviceauth/poll_test.go b/internal/deviceauth/poll_test.go new file mode 100644 index 0000000..96652f1 --- /dev/null +++ b/internal/deviceauth/poll_test.go @@ -0,0 +1,151 @@ +package deviceauth + +import ( + "testing" + "time" + + "github.com/urlbox/urlbox-cli/internal/clock" + "github.com/urlbox/urlbox-cli/internal/output" +) + +func runPoll(t *testing.T, interval, expiresIn int, script []Exchange) (string, *output.CLIError, *clock.FakeClock) { + t.Helper() + fc := clock.NewFake(time.Unix(1_700_000_000, 0)) + i := 0 + exchange := func() Exchange { + if i >= len(script) { + t.Fatalf("poll exceeded script (%d calls)", i) + } + e := script[i] + i++ + return e + } + type result struct { + token string + cli *output.CLIError + } + done := make(chan result, 1) + go func() { + tok, cli := Poll(fc, interval, expiresIn, exchange) + done <- result{tok, cli} + }() + deadline := time.After(5 * time.Second) + for { + select { + case r := <-done: + return r.token, r.cli, fc + case <-deadline: + t.Fatal("poll did not finish") + default: + if fc.WaitForSleeper(10 * time.Millisecond) { + fc.Advance(10 * time.Second) + } + } + } +} + +func TestPollSucceedsAfterPending(t *testing.T) { + tok, cli, _ := runPoll(t, 5, 300, []Exchange{ + {RFCCode: "authorization_pending"}, + {RFCCode: "authorization_pending"}, + {AccessToken: "sess_tok_win"}, + }) + if cli != nil { + t.Fatalf("unexpected error: %v", cli) + } + if tok != "sess_tok_win" { + t.Fatalf("token = %q", tok) + } +} + +func TestPollSlowDownBacksOff(t *testing.T) { + fc := clock.NewFake(time.Unix(1_700_000_000, 0)) + calls := 0 + var gaps []time.Duration + last := fc.Now() + exchange := func() Exchange { + gaps = append(gaps, fc.Since(last)) + last = fc.Now() + calls++ + if calls == 1 { + return Exchange{RFCCode: "slow_down"} + } + return Exchange{AccessToken: "tok"} + } + done := make(chan struct{}) + go func() { + _, _ = Poll(fc, 5, 300, exchange) + close(done) + }() + for { + select { + case <-done: + if gaps[0] != 5*time.Second { + t.Fatalf("first gap = %v, want 5s", gaps[0]) + } + if gaps[1] != 10*time.Second { + t.Fatalf("post-slow_down gap = %v, want 10s", gaps[1]) + } + return + default: + if fc.WaitForSleeper(10 * time.Millisecond) { + fc.Advance(1 * time.Second) + } + } + } +} + +func TestPollDeniedStopsWithAuthError(t *testing.T) { + _, cli, _ := runPoll(t, 5, 300, []Exchange{{RFCCode: "access_denied"}}) + if cli == nil || cli.Code != output.ErrAuth { + t.Fatalf("want auth error, got %v", cli) + } + if cli.Message != "Login denied." { + t.Fatalf("message = %q", cli.Message) + } +} + +func TestPollExpiredTokenStops(t *testing.T) { + _, cli, _ := runPoll(t, 5, 300, []Exchange{{RFCCode: "expired_token"}}) + if cli == nil || cli.Code != output.ErrAuth { + t.Fatalf("want auth error, got %v", cli) + } +} + +func TestPollDeadlineExpires(t *testing.T) { + script := make([]Exchange, 4) + for i := range script { + script[i] = Exchange{RFCCode: "authorization_pending"} + } + _, cli, _ := runPoll(t, 5, 12, script) + if cli == nil || cli.Code != output.ErrAuth { + t.Fatalf("want auth expiry error, got %v", cli) + } +} + +func TestPollIntervalFloor(t *testing.T) { + fc := clock.NewFake(time.Unix(1_700_000_000, 0)) + start := fc.Now() + var firstGap time.Duration + done := make(chan struct{}) + go func() { + _, _ = Poll(fc, 0, 60, func() Exchange { + firstGap = fc.Since(start) + return Exchange{AccessToken: "tok"} + }) + close(done) + }() + for { + select { + case <-done: + if firstGap != 5*time.Second { + t.Fatalf("gap with interval=0 is %v, want 5s floor", firstGap) + } + return + default: + if fc.WaitForSleeper(10 * time.Millisecond) { + fc.Advance(1 * time.Second) + } + } + } +} diff --git a/internal/output/envelope.go b/internal/output/envelope.go index 034b8e9..be13d54 100644 --- a/internal/output/envelope.go +++ b/internal/output/envelope.go @@ -22,6 +22,21 @@ type Envelope struct { // envelope for json/quiet modes; text mode still prints them inline // to stderr where humans expect them. Warnings []string `json:"warnings,omitempty"` + // view is the optional text-mode presentation. It never serialises, so + // json/quiet output stays byte-identical whether or not a command sets + // it. Only text mode reads it, after the summary line. + view *textView `json:"-"` +} + +// SetTable attaches a text-mode table view (list commands). activeIndex marks +// the active row with a ● marker, or -1 for none. +func (e *Envelope) SetTable(headers []string, rows [][]string, activeIndex int) { + e.view = &textView{headers: headers, rows: rows, activeIndex: activeIndex} +} + +// SetKV attaches a text-mode key/value view (detail commands). +func (e *Envelope) SetKV(pairs [][2]string) { + e.view = &textView{kv: pairs} } // ErrorEnvelope is the error response shape. diff --git a/internal/output/envelope_test.go b/internal/output/envelope_test.go index f9c0f36..2a5385e 100644 --- a/internal/output/envelope_test.go +++ b/internal/output/envelope_test.go @@ -42,7 +42,7 @@ func TestNewEnvelope_OmitsEmptyOptionals(t *testing.T) { } func TestNewErrorEnvelope_AllFields(t *testing.T) { - cliErr := output.NewCLIError(output.ErrAuth, "unauthorized", "run urlbox auth") + cliErr := output.NewCLIError(output.ErrAuth, "unauthorized", "run urlbox login") env := output.NewErrorEnvelope("render", cliErr) if env.OK { t.Error("expected OK to be false") @@ -56,8 +56,8 @@ func TestNewErrorEnvelope_AllFields(t *testing.T) { if env.Code != "auth" { t.Errorf("Code = %q, want %q", env.Code, "auth") } - if env.Hint != "run urlbox auth" { - t.Errorf("Hint = %q, want %q", env.Hint, "run urlbox auth") + if env.Hint != "run urlbox login" { + t.Errorf("Hint = %q, want %q", env.Hint, "run urlbox login") } } diff --git a/internal/output/errors_test.go b/internal/output/errors_test.go index 81c1111..37e4023 100644 --- a/internal/output/errors_test.go +++ b/internal/output/errors_test.go @@ -62,7 +62,7 @@ func TestCLIError_ExitCode_UnknownDefaultsToServer(t *testing.T) { } func TestCLIError_CanBeUnwrappedWithErrorsAs(t *testing.T) { - original := output.NewCLIError(output.ErrAuth, "unauthorized", "run urlbox auth") + original := output.NewCLIError(output.ErrAuth, "unauthorized", "run urlbox login") wrapped := errors.New("command failed: " + original.Error()) _ = wrapped // We just verify CLIError works with errors.As directly var target *output.CLIError diff --git a/internal/output/format.go b/internal/output/format.go index 30758ca..40a1262 100644 --- a/internal/output/format.go +++ b/internal/output/format.go @@ -49,16 +49,22 @@ type TextFormatter struct { styles Styles } -// WriteSuccess writes a human-readable success message. The data block -// is INTENTIONALLY omitted — Round 5 Power-2 flagged the previous -// behavior (✓ followed by a JSON dump of .data) as noisy: -// the summary line carries the load-bearing facts, and structured -// consumers should reach for --output-format json. Text mode is for -// humans reading their terminal. +// WriteSuccess writes human-readable output. When the envelope is ok and carries +// a text view (SetTable/SetKV), only the view is rendered — the view is the +// content and the summary would just be noise above it. Otherwise the summary +// line is written first, glyph-coded by env.OK (✓ for ok, ✗ for not-ok — doctor +// sets ok=false on failing checks), so a failing view keeps its ✗ headline and a +// viewless success keeps its summary. Structured consumers reach for +// --output-format json, where summary always rides in the envelope. func (f *TextFormatter) WriteSuccess(w io.Writer, env *Envelope) error { - if env.Summary != "" { - _, _ = fmt.Fprintln(w, f.styles.Success.Render("✓ "+env.Summary)) + if env.Summary != "" && (!env.OK || env.view == nil) { + if env.OK { + _, _ = fmt.Fprintln(w, f.styles.Success.Render("✓ "+env.Summary)) + } else { + _, _ = fmt.Fprintln(w, f.styles.Error.Render("✗ "+env.Summary)) + } } + env.view.render(w, &f.styles) return nil } diff --git a/internal/output/format_test.go b/internal/output/format_test.go index 59baf02..6c788bc 100644 --- a/internal/output/format_test.go +++ b/internal/output/format_test.go @@ -86,6 +86,71 @@ func TestTextFormatter_WriteError_ContainsMessage(t *testing.T) { } } +func TestTextFormatter_WriteSuccess_OkWithView_ViewOnlyNoSummary(t *testing.T) { + t.Setenv("NO_COLOR", "1") + env := output.NewEnvelope("orgs list", map[string]any{"n": 2}, "2 organisations", nil) + env.SetTable([]string{"NAME", "ID"}, [][]string{{"Acme", "org_a"}}, -1) + buf := &bytes.Buffer{} + f := output.NewFormatter(output.FormatText, output.NewStylesForWriter(buf)) + + if err := f.WriteSuccess(buf, env); err != nil { + t.Fatalf("WriteSuccess error: %v", err) + } + out := buf.String() + if strings.Contains(out, "2 organisations") { + t.Errorf("ok+view text should omit the summary line, got:\n%s", out) + } + if !strings.Contains(out, "Acme") || !strings.Contains(out, "org_a") { + t.Errorf("ok+view text should still render the view, got:\n%s", out) + } +} + +func TestTextFormatter_WriteSuccess_NotOkWithView_SummaryThenView(t *testing.T) { + t.Setenv("NO_COLOR", "1") + env := output.NewEnvelope("doctor", map[string]any{"status": "fail"}, "Some checks failed", nil) + env.OK = false + env.SetTable([]string{"CHECK", "STATUS"}, [][]string{{"api_secret", "fail"}}, -1) + buf := &bytes.Buffer{} + f := output.NewFormatter(output.FormatText, output.NewStylesForWriter(buf)) + + if err := f.WriteSuccess(buf, env); err != nil { + t.Fatalf("WriteSuccess error: %v", err) + } + out := buf.String() + if !strings.Contains(out, "Some checks failed") { + t.Errorf("not-ok+view text should keep the summary line, got:\n%s", out) + } + if !strings.Contains(out, "✗") { + t.Errorf("not-ok+view summary should use ✗, got:\n%s", out) + } + if !strings.Contains(out, "api_secret") { + t.Errorf("not-ok+view text should render the view, got:\n%s", out) + } + summaryIdx := strings.Index(out, "Some checks failed") + viewIdx := strings.Index(out, "api_secret") + if summaryIdx > viewIdx { + t.Errorf("summary must appear above the view, got:\n%s", out) + } +} + +func TestTextFormatter_WriteSuccess_OkNoView_SummaryAsToday(t *testing.T) { + t.Setenv("NO_COLOR", "1") + env := output.NewEnvelope("logout", nil, "Logged out", nil) + buf := &bytes.Buffer{} + f := output.NewFormatter(output.FormatText, output.NewStylesForWriter(buf)) + + if err := f.WriteSuccess(buf, env); err != nil { + t.Fatalf("WriteSuccess error: %v", err) + } + out := buf.String() + if !strings.Contains(out, "Logged out") { + t.Errorf("ok+no-view text should keep the summary line, got:\n%s", out) + } + if !strings.Contains(out, "✓") { + t.Errorf("ok+no-view summary should use ✓, got:\n%s", out) + } +} + func TestQuietFormatter_WriteSuccess_DataOnly(t *testing.T) { env := output.NewEnvelope("test", map[string]string{"id": "abc"}, "summary", nil) buf := &bytes.Buffer{} diff --git a/internal/output/render.go b/internal/output/render.go new file mode 100644 index 0000000..ddd61fd --- /dev/null +++ b/internal/output/render.go @@ -0,0 +1,93 @@ +package output + +import ( + "fmt" + "io" + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/lipgloss/table" +) + +type textView struct { + headers []string + rows [][]string + activeIndex int + kv [][2]string +} + +func (v *textView) render(w io.Writer, styles *Styles) { + if v == nil { + return + } + if v.kv != nil { + RenderKV(w, styles, v.kv) + return + } + RenderList(w, styles, v.headers, v.rows, v.activeIndex) +} + +// RenderList writes a styled table for any "list" command. headers are the +// column titles; rows are the cell values (one []string per row, same length as +// headers). activeIndex marks the active row (the active org or project) with a +// leading ● marker plus emphasis, or -1 for none. The marker column keeps the +// active row identifiable once colour is stripped (NO_COLOR / piped output). +func RenderList(w io.Writer, styles *Styles, headers []string, rows [][]string, activeIndex int) { + fullHeaders := headers + fullRows := rows + if activeIndex >= 0 { + fullHeaders = append([]string{""}, headers...) + fullRows = make([][]string, len(rows)) + for i, r := range rows { + marker := " " + if i == activeIndex { + marker = "●" + } + fullRows[i] = append([]string{marker}, r...) + } + } + + t := table.New(). + Border(lipgloss.NormalBorder()). + BorderStyle(styles.Border). + Headers(fullHeaders...). + Rows(fullRows...). + StyleFunc(func(row, _ int) lipgloss.Style { + switch row { + case table.HeaderRow: + return styles.Header.Padding(0, 1) + case activeIndex: + return styles.Active.Padding(0, 1) + default: + return lipgloss.NewStyle().Padding(0, 1) + } + }) + _, _ = fmt.Fprintln(w, t) +} + +// RenderKV writes a bordered two-column table for detail output (whoami, login +// summaries, project detail). It draws the same visual language as RenderList: +// a NormalBorder table with the shared border colour, a label column and a value +// column, no header row. Labels are uppercased and take the RenderList header +// styling so tables and detail views read as one product. An empty pairs writes +// nothing. +func RenderKV(w io.Writer, styles *Styles, pairs [][2]string) { + if len(pairs) == 0 { + return + } + rows := make([][]string, len(pairs)) + for i, p := range pairs { + rows[i] = []string{strings.ToUpper(p[0]), p[1]} + } + t := table.New(). + Border(lipgloss.NormalBorder()). + BorderStyle(styles.Border). + Rows(rows...). + StyleFunc(func(_, col int) lipgloss.Style { + if col == 0 { + return styles.Header.Padding(0, 1) + } + return lipgloss.NewStyle().Padding(0, 1) + }) + _, _ = fmt.Fprintln(w, t) +} diff --git a/internal/output/render_test.go b/internal/output/render_test.go new file mode 100644 index 0000000..ded86a2 --- /dev/null +++ b/internal/output/render_test.go @@ -0,0 +1,138 @@ +package output_test + +import ( + "bytes" + "regexp" + "strings" + "testing" + + "github.com/urlbox/urlbox-cli/internal/output" +) + +var ansiPattern = regexp.MustCompile(`\x1b\[[0-9;]*m`) + +func stripANSI(s string) string { + return ansiPattern.ReplaceAllString(s, "") +} + +func TestRenderList_NoColor_ContainsRowsAndMarker(t *testing.T) { + t.Setenv("NO_COLOR", "1") + buf := &bytes.Buffer{} + styles := output.NewStylesForWriter(buf) + + output.RenderList(buf, styles, + []string{"NAME", "ID"}, + [][]string{{"Acme", "org_a"}, {"Globex", "org_b"}}, + 1, + ) + + out := buf.String() + for _, want := range []string{"NAME", "ID", "Acme", "org_a", "Globex", "org_b", "●"} { + if !strings.Contains(out, want) { + t.Errorf("RenderList output missing %q, got:\n%s", want, out) + } + } + markerLine := "" + for _, line := range strings.Split(out, "\n") { + if strings.Contains(line, "Globex") { + markerLine = line + } + } + if !strings.Contains(markerLine, "●") { + t.Errorf("active marker ● should be on the Globex row, got line %q", markerLine) + } + if acmeLine := lineContaining(out, "Acme"); strings.Contains(acmeLine, "●") { + t.Errorf("marker must not appear on the inactive Acme row, got %q", acmeLine) + } +} + +func TestRenderList_NegativeActive_NoMarkerColumn(t *testing.T) { + t.Setenv("NO_COLOR", "1") + buf := &bytes.Buffer{} + styles := output.NewStylesForWriter(buf) + + output.RenderList(buf, styles, + []string{"NAME", "ID"}, + [][]string{{"Acme", "org_a"}}, + -1, + ) + + out := buf.String() + if strings.Contains(out, "●") { + t.Errorf("no row is active, marker ● should not appear, got:\n%s", out) + } + if !strings.Contains(out, "Acme") { + t.Errorf("expected row content, got:\n%s", out) + } +} + +func TestRenderKV_NoColor_BorderedUppercaseLabels(t *testing.T) { + t.Setenv("NO_COLOR", "1") + buf := &bytes.Buffer{} + styles := output.NewStylesForWriter(buf) + + output.RenderKV(buf, styles, [][2]string{ + {"Signed in", "user@example.com"}, + {"Org", "Acme (org_a)"}, + {"Project", "(none)"}, + }) + + out := buf.String() + for _, want := range []string{ + "SIGNED IN", "user@example.com", + "ORG", "Acme (org_a)", + "PROJECT", "(none)", + } { + if !strings.Contains(out, want) { + t.Errorf("RenderKV output missing %q, got:\n%s", want, out) + } + } + for _, box := range []string{"┌", "┐", "└", "┘", "│", "─", "┬", "┴"} { + if !strings.Contains(out, box) { + t.Errorf("RenderKV should draw a bordered table, missing %q, got:\n%s", box, out) + } + } + if strings.Contains(out, "Signed in") { + t.Errorf("labels must be uppercased, still saw mixed-case %q:\n%s", "Signed in", out) + } +} + +func TestRenderKV_StyledLabelPathExercised(t *testing.T) { + t.Setenv("NO_COLOR", "1") + buf := &bytes.Buffer{} + styles := output.NewStylesForWriter(buf) + styles.Header = styles.Header.Transform(strings.ToLower) + + output.RenderKV(buf, styles, [][2]string{{"Name", "Value"}}) + + out := stripANSI(buf.String()) + if !strings.Contains(out, "name") { + t.Errorf("label cell must go through styles.Header (its transform ran), got:\n%q", out) + } + if strings.Contains(out, "value") { + t.Errorf("value cell must not take the header style (transform must not touch it), got:\n%q", out) + } + if !strings.Contains(out, "Value") { + t.Errorf("value must survive intact, got:\n%q", out) + } +} + +func TestRenderKV_Empty_NoOutput(t *testing.T) { + t.Setenv("NO_COLOR", "1") + buf := &bytes.Buffer{} + styles := output.NewStylesForWriter(buf) + + output.RenderKV(buf, styles, nil) + if buf.String() != "" { + t.Errorf("empty KV should write nothing, got %q", buf.String()) + } +} + +func lineContaining(s, needle string) string { + for _, line := range strings.Split(s, "\n") { + if strings.Contains(line, needle) { + return line + } + } + return "" +} diff --git a/internal/output/style.go b/internal/output/style.go index f184b12..9c636f0 100644 --- a/internal/output/style.go +++ b/internal/output/style.go @@ -8,6 +8,12 @@ import ( "github.com/muesli/termenv" ) +const ( + brandIndigo = lipgloss.Color("#4f46e5") + brandIndigoLight = lipgloss.Color("#818cf8") + brandMuted = lipgloss.Color("#64748b") +) + // Styles holds the terminal styles used across the CLI. type Styles struct { Success lipgloss.Style @@ -15,6 +21,9 @@ type Styles struct { Warning lipgloss.Style Muted lipgloss.Style Bold lipgloss.Style + Header lipgloss.Style + Active lipgloss.Style + Border lipgloss.Style } // NewStyles creates a new set of terminal styles. @@ -34,6 +43,9 @@ func NewStyles() Styles { Warning: renderer.NewStyle().Foreground(lipgloss.Color("3")), Muted: renderer.NewStyle().Foreground(lipgloss.Color("8")), Bold: renderer.NewStyle().Bold(true), + Header: renderer.NewStyle().Bold(true).Foreground(brandIndigoLight), + Active: renderer.NewStyle().Bold(true).Foreground(brandIndigo), + Border: renderer.NewStyle().Foreground(brandMuted), } } @@ -53,5 +65,8 @@ func NewStylesForWriter(w io.Writer) *Styles { Warning: renderer.NewStyle().Foreground(lipgloss.Color("3")), Muted: renderer.NewStyle().Foreground(lipgloss.Color("8")), Bold: renderer.NewStyle().Bold(true), + Header: renderer.NewStyle().Bold(true).Foreground(brandIndigoLight), + Active: renderer.NewStyle().Bold(true).Foreground(brandIndigo), + Border: renderer.NewStyle().Foreground(brandMuted), } } diff --git a/internal/output/text_view_test.go b/internal/output/text_view_test.go new file mode 100644 index 0000000..fbda985 --- /dev/null +++ b/internal/output/text_view_test.go @@ -0,0 +1,105 @@ +package output_test + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + + "github.com/urlbox/urlbox-cli/internal/output" +) + +func TestTextFormatter_FailureUsesErrorGlyph(t *testing.T) { + t.Setenv("NO_COLOR", "1") + env := output.NewEnvelope("doctor", map[string]any{"status": "fail"}, "Some checks failed", nil) + env.OK = false + buf := &bytes.Buffer{} + f := output.NewFormatter(output.FormatText, output.NewStylesForWriter(buf)) + + if err := f.WriteSuccess(buf, env); err != nil { + t.Fatalf("WriteSuccess error: %v", err) + } + out := buf.String() + if !strings.Contains(out, "✗") { + t.Errorf("ok=false summary should use ✗, got %q", out) + } + if strings.Contains(out, "✓") { + t.Errorf("ok=false summary must not print ✓, got %q", out) + } +} + +func TestTextFormatter_SuccessUsesCheckGlyph(t *testing.T) { + t.Setenv("NO_COLOR", "1") + env := output.NewEnvelope("orgs list", nil, "2 organisations", nil) + buf := &bytes.Buffer{} + f := output.NewFormatter(output.FormatText, output.NewStylesForWriter(buf)) + + if err := f.WriteSuccess(buf, env); err != nil { + t.Fatalf("WriteSuccess error: %v", err) + } + if !strings.Contains(buf.String(), "✓") { + t.Errorf("ok=true summary should use ✓, got %q", buf.String()) + } +} + +func TestTextFormatter_RendersTableView(t *testing.T) { + t.Setenv("NO_COLOR", "1") + env := output.NewEnvelope("orgs list", map[string]any{"n": 2}, "2 organisations", nil) + env.SetTable([]string{"NAME", "ID"}, [][]string{{"Acme", "org_a"}, {"Globex", "org_b"}}, 1) + buf := &bytes.Buffer{} + f := output.NewFormatter(output.FormatText, output.NewStylesForWriter(buf)) + + if err := f.WriteSuccess(buf, env); err != nil { + t.Fatalf("WriteSuccess error: %v", err) + } + out := buf.String() + for _, want := range []string{"Acme", "Globex", "org_a", "●"} { + if !strings.Contains(out, want) { + t.Errorf("table text view missing %q, got:\n%s", want, out) + } + } + if strings.Contains(out, "2 organisations") { + t.Errorf("ok+table view should omit the summary line, got:\n%s", out) + } +} + +func TestTextFormatter_RendersKVView(t *testing.T) { + t.Setenv("NO_COLOR", "1") + env := output.NewEnvelope("whoami", map[string]any{"email": "u@x.com"}, "Signed in as u@x.com", nil) + env.SetKV([][2]string{{"Signed in", "u@x.com"}, {"Org", "Acme (org_a)"}}) + buf := &bytes.Buffer{} + f := output.NewFormatter(output.FormatText, output.NewStylesForWriter(buf)) + + if err := f.WriteSuccess(buf, env); err != nil { + t.Fatalf("WriteSuccess error: %v", err) + } + out := buf.String() + for _, want := range []string{"u@x.com", "Acme (org_a)"} { + if !strings.Contains(out, want) { + t.Errorf("KV text view missing %q, got:\n%s", want, out) + } + } + if strings.Contains(out, "Signed in as u@x.com") { + t.Errorf("ok+KV view should omit the summary line, got:\n%s", out) + } +} + +func TestTextView_NotSerialisedInJSON(t *testing.T) { + env := output.NewEnvelope("orgs list", map[string]any{"n": 2}, "2 organisations", nil) + env.SetTable([]string{"NAME"}, [][]string{{"Acme"}}, -1) + buf := &bytes.Buffer{} + f := output.NewFormatter(output.FormatJSON, output.NewStylesForWriter(buf)) + + if err := f.WriteSuccess(buf, env); err != nil { + t.Fatalf("WriteSuccess error: %v", err) + } + var raw map[string]json.RawMessage + if err := json.Unmarshal(buf.Bytes(), &raw); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + for _, forbidden := range []string{"NAME", "Acme", "table", "kv", "textView", "text_view"} { + if strings.Contains(buf.String(), forbidden) { + t.Errorf("JSON output leaked text-view content %q:\n%s", forbidden, buf.String()) + } + } +} diff --git a/internal/prompt/prompt.go b/internal/prompt/prompt.go new file mode 100644 index 0000000..46be111 --- /dev/null +++ b/internal/prompt/prompt.go @@ -0,0 +1,92 @@ +package prompt + +import ( + "errors" + "fmt" + "os" + "strings" + + "github.com/charmbracelet/huh" + "golang.org/x/term" +) + +// ErrNotInteractive is returned when a prompt is attempted without a terminal. +var ErrNotInteractive = errors.New("not an interactive terminal") + +func theme() *huh.Theme { + t := huh.ThemeCharm() + t.Focused.Base = t.Focused.Base.MarginBottom(1) + t.Blurred.Base = t.Blurred.Base.MarginBottom(1) + return t +} + +// SelectOne draws an interactive single-choice list to stderr and returns the +// index of the chosen option. It returns ErrNotInteractive when stdin is not a +// terminal. +func SelectOne(label string, options []string, active int) (int, error) { + if len(options) == 0 { + return -1, errors.New("no options to choose from") + } + if !term.IsTerminal(int(os.Stdin.Fd())) { //nolint:gosec // file descriptors fit in int on every platform Go supports + return -1, ErrNotInteractive + } + opts := make([]huh.Option[int], len(options)) + for i, o := range options { + display := o + if i == active { + display = o + " (current)" + } + opts[i] = huh.NewOption(display, i) + } + choice := 0 + if active >= 0 && active < len(options) { + choice = active + } + if err := huh.NewSelect[int](). + Title(label). + Options(opts...). + Value(&choice). + WithTheme(theme()). + Run(); err != nil { + return -1, err + } + return choice, nil +} + +// Confirm draws an interactive yes/no prompt to stderr and returns the answer. +// It returns ErrNotInteractive when stdin is not a terminal. +func Confirm(title string) (bool, error) { + if !term.IsTerminal(int(os.Stdin.Fd())) { //nolint:gosec // file descriptors fit in int on every platform Go supports + return false, ErrNotInteractive + } + answer := false + if err := huh.NewConfirm(). + Title(title). + Value(&answer). + WithTheme(theme()). + Run(); err != nil { + return false, err + } + return answer, nil +} + +// TypeToConfirm draws an interactive input to stderr and returns nil only when +// the typed text matches expected. It returns ErrNotInteractive when stdin is +// not a terminal. +func TypeToConfirm(title, expected string) error { + if !term.IsTerminal(int(os.Stdin.Fd())) { //nolint:gosec // file descriptors fit in int on every platform Go supports + return ErrNotInteractive + } + var typed string + if err := huh.NewInput(). + Title(title). + Value(&typed). + WithTheme(theme()). + Run(); err != nil { + return err + } + if strings.TrimSpace(typed) != expected { + return fmt.Errorf("confirmation did not match %q — aborted", expected) + } + return nil +} diff --git a/internal/prompt/prompt_test.go b/internal/prompt/prompt_test.go new file mode 100644 index 0000000..ca3de2a --- /dev/null +++ b/internal/prompt/prompt_test.go @@ -0,0 +1,40 @@ +package prompt + +import ( + "errors" + "testing" +) + +func TestSelectOneNonTTYReturnsErrNotInteractive(t *testing.T) { + _, err := SelectOne("pick:", []string{"a", "b"}, -1) + if !errors.Is(err, ErrNotInteractive) { + t.Fatalf("want ErrNotInteractive, got %v", err) + } +} + +func TestSelectOneEmptyOptions(t *testing.T) { + _, err := SelectOne("pick:", nil, -1) + if err == nil { + t.Fatal("want error for zero options") + } + if errors.Is(err, ErrNotInteractive) { + t.Fatalf("empty-options must not be reported as not-interactive: %v", err) + } + if err.Error() != "no options to choose from" { + t.Fatalf("want the empty-options error, got %v", err) + } +} + +func TestTypeToConfirmNonTTY(t *testing.T) { + err := TypeToConfirm("retype:", "expected") + if !errors.Is(err, ErrNotInteractive) { + t.Fatalf("want ErrNotInteractive, got %v", err) + } +} + +func TestConfirmNonTTYReturnsErrNotInteractive(t *testing.T) { + _, err := Confirm("Switch to this project?") + if !errors.Is(err, ErrNotInteractive) { + t.Fatalf("want ErrNotInteractive, got %v", err) + } +} diff --git a/npm/README.md b/npm/README.md index 0d70074..117b9cf 100644 --- a/npm/README.md +++ b/npm/README.md @@ -10,10 +10,10 @@ npm install -g @urlbox/cli ## Usage -Grab your API secret from [urlbox.com/dashboard/projects](https://urlbox.com/dashboard/projects) (open the project → "API Secret"). Secrets look like `ubx_sk_…`. +Sign in through your browser with `urlbox login` (CI/headless: set `URLBOX_API_SECRET` instead — grab a secret from [urlbox.com/dashboard/projects](https://urlbox.com/dashboard/projects)). ```sh -urlbox auth --api-secret ubx_sk_xxxxxxxxxxxx # one-time +urlbox login # one-time, browser sign-in urlbox render https://example.com --output home.png # capture & save urlbox screenshot https://example.com --output home.png # alias: --format png urlbox pdf https://example.com --output home.pdf # alias: --format pdf --full-page @@ -35,6 +35,27 @@ urlbox link --url https://example.com --output-format quiet # Open the Urlbox dashboard in your browser urlbox dashboard +# Sign in through the browser (CI/headless: set URLBOX_API_SECRET instead) +urlbox login +urlbox whoami # signed-in account, org, project +urlbox orgs list # organisations you belong to +urlbox projects list # projects in the active org +urlbox usage # render usage for the active organisation + +# Org-owned credentials — create once, assign to projects (secrets masked; --reveal to show) +urlbox storage list # storage credentials (S3, GCS, R2, Azure, ...) +urlbox storage create prod --provider aws_s3 --bucket b --region us-east-1 --key k --secret s +urlbox proxies list # proxy pools (alias: proxy) +urlbox proxies create eu --url http://user:pass@host:8080 +urlbox llm list # LLM credentials +urlbox llm create openai --provider openai --api-key sk-… +urlbox llm test openai # check the stored credential's connection +urlbox storage show prod --reveal # show one, secrets unmasked +urlbox storage update prod --region eu-west-1 # update only the flags you pass +urlbox storage delete prod --yes # retype-to-confirm; --yes skips the prompt +urlbox projects storage assign my-project prod # assign a credential to a project (kind: storage|proxy|llm) +urlbox projects storage unassign my-project # unassign the project's current one + # Self-discovery urlbox commands --output-format json # full command catalog urlbox render --help --agent # structured JSON help diff --git a/skills/SKILL.md b/skills/SKILL.md index 4f3d017..6a2ec0c 100644 --- a/skills/SKILL.md +++ b/skills/SKILL.md @@ -152,7 +152,6 @@ documents the well-known options, but the API accepts more. | Command | Purpose | |----------------------------------|--------------------------------------------------------| -| `urlbox auth` | Save API secret (`--api-secret `; or interactive) | | `urlbox commands` | List every command + flag | | `urlbox config get ` | Read a config value | | `urlbox config set ` | Write a config value | @@ -164,6 +163,16 @@ documents the well-known options, but the API accepts more. | `urlbox dashboard` | Open the Urlbox dashboard in the user's browser | | `urlbox doctor` | Diagnose install, config, network, credentials | | `urlbox link` | Generate an HMAC-signed render URL with no API call | +| `urlbox login` | Browser sign-in (agents: use `URLBOX_API_SECRET` instead) | +| `urlbox logout` | Revoke and clear this device's saved session | +| `urlbox whoami` / `urlbox me` | Show the signed-in account, org, and active project | +| `urlbox orgs list\|select` | List organisations or switch the active one | +| `urlbox projects list\|select\|show\|create\|rename\|enable\|disable\|delete\|defaults` | Manage projects and their defaults | +| `urlbox projects assign\|unassign` | Assign/unassign a project's `storage`, `proxy`, or `llm` credential | +| `urlbox usage` | Show the render usage summary for the active organisation | +| `urlbox storage list\|show\|create\|update\|delete` | Manage org storage credentials (S3/GCS/R2/Azure/...) | +| `urlbox proxies list\|show\|create\|update\|delete` | Manage org proxy pools (alias: `proxy`) | +| `urlbox llm list\|show\|create\|update\|delete\|test\|models` | Manage org LLM credentials | | `urlbox render ` | Capture a screenshot, PDF, or video of a web page | | `urlbox screenshot ` | Alias for `render --format png` (also `urlbox shot`) | | `urlbox pdf ` | Alias for `render --format pdf --full-page` | @@ -283,7 +292,7 @@ the render likely captured a captcha page rather than the target content. |--------------|------|------------------------------------------------------------| | `usage` | 1 | bad flags / missing url | | `validation` | 2 | payload failed schema validation; see `hint` for the fix | -| `auth` | 3 | missing/invalid API secret; run `urlbox auth --api-secret` | +| `auth` | 3 | missing/invalid credentials; run `urlbox login` (CI: set `URLBOX_API_SECRET`) | | `forbidden` | 4 | account/plan doesn't allow this feature | | `not_found` | 5 | endpoint or render ID unknown | | `rate_limit` | 6 | retry budget exhausted; back off and retry | @@ -354,6 +363,53 @@ With `--wait`, the deadline is governed by `--timeout`; if it elapses before a terminal state, the envelope is `usage` / exit 1 with a hint to raise `--timeout` or re-run later. +## storage / proxies / llm: org-owned credentials + +Storage credentials, proxy pools, and LLM credentials are owned by the active +organisation and assigned to projects — create one once, then assign it to any +project's renders. All three groups share the same verb set (`list`, `show`, +`create`, `update`, `delete`); `llm` adds `test` and `models`. They require a +session and an active org (agents on `URLBOX_API_SECRET` alone are not signed +in — these commands return `auth` / exit 3 until `urlbox login` runs). + +Secrets are masked on display. Text output masks by default; pass `--reveal` +to unmask. JSON output (`--output-format json`) always contains the full +values. A target is resolved by name or id (ids: `store_`, `pool_`, `llm_`). + +```sh +# List / show (JSON gives full, machine-readable records) +urlbox storage list --output-format json +urlbox proxies show eu --reveal +urlbox llm show openai --output-format json + +# Create — name as a positional or --name; typed flags or a full --json payload +# (typed flags win); --assign-to attaches the new credential to a project in the +# same call. +urlbox storage create prod --provider aws_s3 --bucket b --region us-east-1 --key k --secret s +urlbox proxies create eu --url http://user:pass@host:8080 --assign-to my-project +urlbox llm create openai --provider openai --api-key sk-… --assign-to my-project + +# Update sends only the fields you pass (proxies: any --url replaces the list) +urlbox storage update prod --region eu-west-1 +urlbox llm update openai --model gpt-5-mini + +# llm test / models exercise the stored credential against the provider +urlbox llm test openai # exit 0 + "Connection OK", or non-zero + provider error +urlbox llm models openai # table/JSON of the provider's model ids + +# Delete is retype-to-confirm. Agents (non-interactive) MUST pass --yes; +# without it and without a TTY the command errors `usage` / exit 1. +urlbox storage delete prod --yes +``` + +Assign / unassign a project's credential of a given `` (`storage`, +`proxy`, `llm`) — a project holds at most one of each kind: + +```sh +urlbox projects storage assign my-project prod +urlbox projects llm unassign my-project +``` + ## dashboard: open the Urlbox dashboard `urlbox dashboard` opens https://urlbox.com/dashboard in the user's @@ -440,7 +496,7 @@ on a profile directly: `urlbox --profile config set api_host https://...` `config set` and `config get` adapt to the profile count: -- **0 profiles:** `config set` errors with "No profiles configured" — run `urlbox auth --api-secret ` to bootstrap. +- **0 profiles:** `config set` errors with "No profiles configured" — run `urlbox login` to bootstrap. - **1 profile:** `--profile` is implicit; `urlbox config set api_secret sk_xxx` Just Works. - **2+ profiles:** `--profile` is required; the error lists configured names. @@ -451,38 +507,38 @@ default_profile ` requires `` to exist as a profile. ### For agents and CI (non-interactive) -All options below are agent-safe — none prompts. Listed in order of -secret-hygiene; prefer the higher items. +The device flow needs a browser, so agents and CI authenticate with the render +secret directly. All options below are agent-safe — none prompts. Listed in +order of secret-hygiene; prefer the higher items. ```sh -# A — pipe on stdin (no argv leak, no shell-history exposure) -printf %s "$URLBOX_API_SECRET" | urlbox auth --api-secret-stdin +# A — env var (never touches the config file) +URLBOX_API_SECRET= urlbox render -# B — read from a file (handy when the secret already lives on disk) -urlbox auth --api-secret-file /run/secrets/urlbox +# B — pipe on stdin (no argv leak, no shell-history exposure) +printf %s "$URLBOX_API_SECRET" | urlbox config profile create default --api-secret-stdin -# C — env var (never touches the config file) -URLBOX_API_SECRET= urlbox render +# C — read from a file (handy when the secret already lives on disk) +urlbox config profile create default --api-secret-file /run/secrets/urlbox # D — argv flag (leaks into `ps` and shell history; emits a TTY warning) -urlbox auth --api-secret +urlbox config profile create default --api-secret urlbox doctor --output-format json # JSON envelope: ok/not-ok ``` `--api-secret-stdin` and `--api-secret-file` are accepted by every -command that takes `--api-secret` (auth, render, status, link, +command that takes `--api-secret` (render, status, link, config profile create, and the render aliases screenshot/pdf/video). Mutually exclusive — pass at most one. ### For humans on a TTY ```sh -urlbox auth # prompts once for the secret with masked echo +urlbox login # browser sign-in; stores session + render credential ``` -Saves to `~/.config/urlbox/config.json` (mode 0600), under the default profile -(creates one if none exists). Verify with `urlbox doctor`. +Verify with `urlbox doctor`. ## Coming next From 7fa0b7883e5549446d36c9b60e9488d2fc4fbe4d Mon Sep 17 00:00:00 2001 From: Arnold Cubici-Jones <108676317+AJCJ1@users.noreply.github.com> Date: Wed, 19 Aug 2026 10:32:49 +0100 Subject: [PATCH 2/8] docs(cli): rewrite README and npm readme for v1.1.0 --- CHANGELOG.md | 2 +- README.md | 616 ++++++++++------------------------------------- npm/README.md | 71 +----- npm/package.json | 2 +- 4 files changed, 142 insertions(+), 549 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b6d28b7..dd92389 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ All notable changes to the `urlbox` CLI are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project follows [SemVer](https://semver.org/spec/v2.0.0.html). -## v0.11.0 — 2026-08-19 +## v1.1.0 — 2026-08-19 **Browser login and account management.** `urlbox login` signs in via the browser and stores the session and the active project's render diff --git a/README.md b/README.md index 5c3c06b..b1443d3 100644 --- a/README.md +++ b/README.md @@ -1,587 +1,231 @@ # Urlbox CLI -The official command-line interface for the [Urlbox](https://urlbox.com) screenshot and web automation API. Render screenshots, PDFs, videos, and extracted content from URLs or HTML. +The official command-line interface for [Urlbox](https://urlbox.com), the website screenshot API. Render screenshots, PDFs, videos, and extracted content from any URL or raw HTML — straight from your terminal, a CI pipeline, or an AI agent. Bring your own AI to analyse renders, S3 storage to store them securely, and proxies to change a render's point of view. + +Read our API's [docs](https://urlbox.com/docs) here. Are you an AI agent? Those docs are available as markdown files [here](https://urlbox.com/llms.txt). + +Every command speaks JSON for your AI agents, and points you at the next step. The full CLI docs are live at [urlbox.com/docs/cli](https://urlbox.com/docs/cli). ## Install -### macOS (Homebrew) +```sh +# npm (cross-platform) +npm install -g @urlbox/cli -``` +# macOS (Homebrew) brew install urlbox/tap/urlbox -``` - -### Windows (Scoop) -``` +# Windows (Scoop) scoop bucket add urlbox https://github.com/urlbox/homebrew-tap scoop install urlbox -``` - -> **Note:** the Scoop bucket is co-located inside the `homebrew-tap` repo -> (the goreleaser pipeline writes the Scoop manifest into a `bucket/` -> subdirectory of that repo). The URL above is correct even though it -> reads `homebrew-tap` — Scoop finds the manifest under `bucket/urlbox.json`. - -### npm (cross-platform) - -``` -npm install -g @urlbox/cli -``` - -### Shell script (macOS/Linux) - -``` -curl -fsSL https://cli.urlbox.com/install.sh | sh -``` -### Linux packages (deb/rpm/apk) - -Download the appropriate package from the [latest release](https://github.com/urlbox/urlbox-cli/releases/latest). - -### Go - -``` +# Go go install github.com/urlbox/urlbox-cli/cmd/urlbox@latest ``` -## Getting your API secret - -Grab your API secret from your Urlbox project at -[urlbox.com/dashboard/projects](https://urlbox.com/dashboard/projects) -(open the project, then "API Secret"). Secrets look like `ubx_sk_…`. +Linux `.deb`/`.rpm`/`.apk` packages and a `curl | sh` installer are covered in [the install docs](https://urlbox.com/docs/cli/install). -The secret authenticates render API calls. The **API key** (publishable, -`ubx_pk_…`) is separate — it's only needed for `urlbox link` URL signing. - -## Quick Start +Confirm it worked: ```sh -# One-time: sign in through the browser (CI/headless: set URLBOX_API_SECRET instead) -urlbox login - -# Render a URL — saves screenshot.png to the current directory -urlbox render https://example.com --output screenshot.png - -# Aliases: same pipeline, different default format -urlbox screenshot https://example.com --output home.png # PNG -urlbox pdf https://example.com --output home.pdf # PDF + full page -urlbox video https://example.com --output home.mp4 # MP4 - -# Preview the merged payload without making an API call (no credit burn) -urlbox render https://example.com --format pdf --dry-run - -# Generate a copy-pasteable curl command (secret redacted) -urlbox render https://example.com --curl - -# Verify install, config, and credentials urlbox doctor - -# Sign in through the browser (CI/headless: set URLBOX_API_SECRET instead) -urlbox login - -# Inspect the signed-in account, organisations, and projects -urlbox whoami -urlbox orgs list -urlbox projects list - -# Render usage summary for the active organisation -urlbox usage - -# Self-discovery for agents -urlbox commands --output-format json # full command catalog -urlbox render --help --agent # structured JSON help -urlbox schema render # JSON Schema of every render option ``` -## Commands - -### `render` - -Capture a URL as a screenshot, PDF, or video. +## Quick start ```sh -# Simplest — positional URL, format flag, optional output -urlbox render https://example.com --format png --output home.png - -# Full payload via --json (preferred for non-trivial config) -urlbox render --json '{"url":"https://example.com","format":"pdf","width":1920,"full_page":true}' - -# --json from stdin or @file -echo '{"url":"https://example.com"}' | urlbox render --json - -urlbox render --json @opts.json - -# Built-in presets layer in defaults (preset < json < flags) -urlbox render https://example.com --preset mobile # iPhone viewport -urlbox render https://example.com --preset desktop # 1920×1080 -urlbox render https://example.com --preset pdf-a4 # PDF + A4 page -urlbox render https://example.com --preset article # block ads, retina, mostrequestsfinished (news/article) - -# Preview without calling the API -urlbox render https://example.com --dry-run - -# Generate an equivalent curl command (secret redacted as $URLBOX_API_SECRET) -urlbox render https://example.com --curl +# Sign in once through your browser +urlbox login -# Open the result in your browser after rendering -urlbox render https://example.com --open +# Render a page and save it +urlbox render https://example.com --output home.png -# Async: queue and return a renderId -urlbox render https://example.com --async --webhook-url https://hooks.example/cb +# Sign a render URL locally, no API call https://urlbox.com/docs/api/rest-api-vs-render-links#render-links +urlbox link --url https://example.com --output-format quiet ``` -**Aliases** (thin wrappers; share the entire render pipeline): - -- `urlbox screenshot ` (also `urlbox shot`) — pre-sets `--format png`. -- `urlbox pdf ` — pre-sets `--format pdf --full-page`. -- `urlbox video ` — pre-sets `--format mp4`. - -User-supplied flags override the alias defaults: `urlbox screenshot foo.com --format webp`. - -**Reliability:** the CLI retries automatically on 429 / 5xx / generic -network errors (3 attempts, 1s/2s/4s backoff with ±20% jitter, respects -`Retry-After`). Disable with `--no-retry`; cap with `--max-retries N`. - -**Timeouts are NOT retried.** A render that exceeds `--timeout duration` -(default `60s`) produces an error envelope with `code: "timeout"` and a -hint listing three recovery paths: retry the same command, raise -`--timeout`, or switch to `--async --webhook-url`. Heavy renders are slow -on every attempt, so silent auto-retry rarely helps — the agent picks the -strategy. - -**Upstream errors:** when the rendered page itself returned an HTTP error -(login wall, captcha, rate limit), the success envelope's `data` includes -`upstreamOk: false` plus `upstreamStatus` (the page's HTTP code). The -summary line warns. Don't treat the bytes as authoritative — the render -likely captured a captcha page rather than the target content. - -**Output sandbox:** `--output ` is canonicalized and asserted to stay -under the current working directory. Parent escapes (`../`), absolute paths -outside CWD, and symlinks pointing outside CWD are rejected. +You can check out the full walkthrough at [urlbox.com/docs/cli/quickstart](https://urlbox.com/docs/cli/quickstart). -#### Validation contract (v0.9.0+) - -The CLI ships an embedded JSON Schema documenting the well-known render -options. As of v0.9.0, validation splits cleanly: - -- **Typed flags** (`--width`, `--format`, `--wait-until`, ...) are validated - locally — the CLI catches type errors and invalid enum values before any - network call. -- **`--json`** is a passthrough: the Urlbox API performs all option - validation. Any current or future API option works via `--json` without - needing a CLI update. If a `--json` key looks like a typo of a documented - option, the CLI prints a `warning: ...` line to stderr and still sends - the request verbatim — the agent or user reads the warning and decides - whether to re-run with the suggested spelling. - -Local hard errors that always reject before sending: payloads larger than -1 MiB, URL-like fields with control characters, malformed JSON. Everything -else flows to the API. - -### `login` - -Signs in through your browser using the device flow. Prints a short code, opens -the approval page, and once you approve stores a session, sets your active -organisation and project, and fetches the active project's render credential so -render commands work immediately. +## Authentication ```sh -# Sign in and pick org + project interactively urlbox login - -# Skip the pickers -urlbox login --org acme --project production ``` -CI and headless environments should set `URLBOX_API_SECRET` instead — the device -flow needs a browser. `URLBOX_API_SECRET` takes precedence at runtime over the -stored render credential. - -### `config` - -Inspect and modify the persisted configuration. Supports multiple named -profiles for working across accounts. +Your browser opens, you approve, and the CLI stores your session plus the active project's render credential — renders work immediately. -```sh -# Read / write a value -urlbox config get api_secret -urlbox config set api_host https://api.urlbox.com - -# Show where the config file lives -urlbox config path - -# Profile management -urlbox config profile list -urlbox config profile create work --api-secret sec_work -urlbox config profile default work -urlbox config profile delete work -``` - -**Profile-target resolution for `config set` / `config get`** (per-profile keys): +In CI and headless environments, where the browser can't open, set the `URLBOX_API_SECRET` env var instead (the secret looks like `ubx_sk_…`, found under your project in the [dashboard](https://urlbox.com/dashboard/projects)). For a one-shot override on a single command there's also `--api-secret-stdin` / `--api-secret-file` — avoid the bare `--api-secret`, which leaks into `ps` and shell history. -- 0 profiles: errors with "No profiles configured" — bootstrap with `urlbox login`. -- 1 profile: `--profile` is implicit; `config set api_secret sk_xxx` Just Works. -- 2+ profiles: `--profile` is required; the error lists configured names. +When more than one source is present, the highest-priority wins: **command flag → environment variable → a per-repo `.urlbox/config.json` overlay → your stored config**. -`default_profile` is top-level and exempt from this rule, but the named profile -must exist (you can't set a dangling default). +Verify what the CLI resolved with `urlbox doctor`. More detail at [urlbox.com/docs/cli/authentication](https://urlbox.com/docs/cli/authentication). -### `link` +## Commands -Generate an HMAC-SHA256 signed render URL **without** calling the API. Pure -local crypto — useful for embedding URLs in templates, emails, or static -sites, and for inspecting the canonical query a render request would use. +### Rendering ```sh -# Minimal -urlbox link --url https://example.com - -# Full payload via --json -urlbox link --json '{"url":"https://example.com","width":1920,"full_page":true}' --format png - -# Raw URL only (one line, no envelope) -urlbox link --url https://example.com --output-format quiet +urlbox render https://example.com --format png --output home.png +urlbox render --json '{"url":"https://example.com","format":"pdf","full_page":true}' ``` -Requires both the publishable API key AND the API secret. If you want the -rendered asset (not just the URL), use `urlbox render`. +`render` captures a page as a screenshot, PDF, video, or extracted content. Options merge in the order **preset → `--json` → flags**, so a typed flag always wins. -### `status` +| Command | Does | +|---------|------| +| `render ` | Capture a page in any format | +| `screenshot ` (alias `shot`) | `render --format png` | +| `pdf ` | `render --format pdf --full-page` | +| `video ` | `render --format mp4` | -Check or poll the status of an async render queued by `urlbox render --async`. +Handy render flags: -```sh -# One-shot status check -urlbox status ps_abc123 +- `--preset ` layers in defaults before anything else. Built-in presets: `mobile` (iPhone viewport), `desktop` (1920×1080), `pdf-a4` (PDF on A4), `article` (block ads, retina, wait for most requests). +- `--dry-run` validates the merged payload without calling the API. +- `--curl` prints the equivalent curl command with the secret redacted. +- `--open` opens the result in your browser after rendering. +- `--output ` saves the file. Paths are sandboxed to the current directory — escapes and symlinks pointing outside it are rejected. -# Poll every 5s until terminal (default --timeout 60s) -urlbox status ps_abc123 --wait - -# Custom cadence -urlbox status ps_abc123 --wait --timeout 5m --poll-interval 10s -``` - -Terminal statuses are `succeeded` (exit 0, `data.renderUrl` points at the -asset) and `failed` / `error` (exit 10). Non-terminal states (`created`, -`retrying`, `processing`) without `--wait` return `ok: true` with a -breadcrumb suggesting `urlbox status --wait`. - -### `storage` +`--json` is a passthrough: any current or future API option works through it, and the Urlbox API validates it. Typed flags are checked locally before any network call. See [urlbox.com/docs/cli/rendering](https://urlbox.com/docs/cli/rendering) and the full option list at [urlbox.com/docs/cli/json-and-schema](https://urlbox.com/docs/cli/json-and-schema). -Manage the active organisation's storage credentials. Storage credentials are -owned by the organisation and assigned to projects — create one once, then -assign it to any project's renders. Secrets are masked on display; pass -`--reveal` for full values (JSON output always includes them in full). +### Signed links ```sh -# List, show (masked), show with secrets revealed -urlbox storage list -urlbox storage show prod-bucket -urlbox storage show prod-bucket --reveal - -# Create (name as a positional or --name; typed flags or a full --json payload; typed flags win) -urlbox storage create prod --provider aws_s3 --bucket b --region us-east-1 --key k --secret s -urlbox storage create --json '{"name":"prod","type":"s3","provider":"aws_s3","bucket":"b","key":"k","secret":"s","region":"us-east-1"}' - -# Create and assign to a project in one step -urlbox storage create prod --provider aws_s3 --bucket b --assign-to my-project - -# Update only the fields you pass -urlbox storage update prod --region eu-west-1 - -# Delete (retype-to-confirm; --yes skips the prompt) -urlbox storage delete prod --yes +urlbox link --url https://example.com --output-format quiet ``` -Providers: `aws_s3`, `google_cloud_storage`, `cloudflare_r2`, `backblaze_b2`, -`digitalocean_spaces`, `wasabi`, `custom`, `azure`. A name or id resolves the -target; ids carry the `store_` prefix. +`link` builds an HMAC-SHA256 signed render URL with pure local crypto — no API call, no render. Useful for embedding in templates, emails, or static sites. It needs both the publishable API key and the API secret. More at [urlbox.com/docs/cli/signed-links](https://urlbox.com/docs/cli/signed-links). -### `proxies` - -Manage the active organisation's proxy pools (alias: `proxy`). Proxy pools are -owned by the organisation and assigned to projects. Proxy URLs routinely embed -credentials, so the password portion is masked on display; pass `--reveal` for -full values (JSON output always includes them in full). +### Async renders ```sh -# List, show (password masked), show revealed -urlbox proxies list -urlbox proxies show eu -urlbox proxies show eu --reveal - -# Create with one or more proxy URLs (name as a positional or --name; --url is repeatable) -urlbox proxies create eu --url http://user:pass@host:8080 - -# Create and assign to a project -urlbox proxies create eu --url http://user:pass@host:8080 --assign-to my-project - -# Update the name and/or the whole URL list (any --url replaces the list) -urlbox proxies update eu --url http://user:pass@host:8080 - -# Delete (retype-to-confirm; --yes skips the prompt) -urlbox proxies delete eu --yes +urlbox render https://example.com --async --webhook-url https://hooks.example/cb +urlbox status ps_abc123 --wait ``` -A name or id resolves the target; ids carry the `pool_` prefix. - -### `llm` +Pass `--async` to queue a render and get a `renderId` back immediately. `status` checks it, and `status --wait` polls (every 2s by default) until it reaches a terminal state — `succeeded` or `failed`. Webhooks and long-running renders are covered at [urlbox.com/docs/cli/async-and-webhooks](https://urlbox.com/docs/cli/async-and-webhooks). -Manage the active organisation's LLM credentials. LLM credentials are owned by -the organisation and assigned to projects. Secrets are masked on display; pass -`--reveal` for full values (JSON output always includes them in full). +### Account and context ```sh -# List, show (masked), show revealed -urlbox llm list -urlbox llm show openai-prod -urlbox llm show openai-prod --reveal - -# Create (name as a positional or --name; typed flags or a full --json payload; typed flags win) -urlbox llm create openai --provider openai --api-key sk-… -urlbox llm create openai --provider openai --api-key sk-… --assign-to my-project - -# Update only the fields you pass -urlbox llm update openai --model gpt-5-mini - -# Test the stored credential's connection, list the provider's model ids -urlbox llm test openai -urlbox llm models openai - -# Delete (retype-to-confirm; --yes skips the prompt) -urlbox llm delete openai --yes +urlbox login # sign in +urlbox whoami # who am I, and which org/project is active (alias: me) +urlbox orgs list # organisations you belong to (alias: org) +urlbox orgs select acme # switch the active org +urlbox projects list # projects in the active org +urlbox usage # render usage for the current period ``` -Providers include `openai`, `anthropic`, `azure`, `amazon-bedrock`, and -`google-vertex`. `llm test` returns exit 0 with `Connection OK` on success, or -a non-zero exit with the provider's error on failure. A name or id resolves the -target; ids carry the `llm_` prefix. - -### `projects assign` / `unassign` +| Command | Does | +|---------|------| +| `login` / `logout` | Sign in through the browser; sign out and revoke this device's session | +| `whoami` (alias `me`) | Show the signed-in user and active org/project | +| `orgs list` / `orgs select` | List or switch your active organisation | +| `projects list` / `select` / `show` | Browse and switch the active project | +| `projects create` / `rename` / `enable` / `disable` / `delete` | Manage projects | +| `projects defaults show` / `set` / `remove` | Manage a project's default render options | +| `usage` | Render usage summary for the active org | -Assign an org-owned credential to a project, or unassign the project's current -one. A project holds at most one storage credential, one proxy pool, and one -LLM credential. `` is `storage`, `proxy`, or `llm`. - -```sh -# Assign a credential (by name or id) to a project (by name or id) -urlbox projects storage assign my-project prod-bucket -urlbox projects proxy assign my-project eu -urlbox projects llm assign my-project openai - -# Unassign the project's current credential of that kind -urlbox projects storage unassign my-project -urlbox projects proxy unassign my-project -urlbox projects llm unassign my-project -``` +Deletes and disables prompt for confirmation; pass `--yes` to skip the prompt (agents should). Details at [urlbox.com/docs/cli/configuration](https://urlbox.com/docs/cli/configuration). -### `dashboard` +### Org resources -Opens https://urlbox.com/dashboard in your default browser. On headless -hosts (no `DISPLAY` / `WAYLAND_DISPLAY` on Linux, unsupported OS) the URL -is printed to stderr and the envelope still arrives on stdout, so agents -and pipelines get `data.url` regardless of host. +Storage credentials, proxy pools, and LLM credentials belong to the organisation. Create one once, then assign it to any project. ```sh -urlbox dashboard -``` - -### `commands` - -Lists all available commands, their descriptions, and flags. - -In a terminal, output is a human-readable table. When piped or with `--output-format json`, output is a structured JSON catalog suitable for agent and script consumption. - -``` -$ urlbox commands -Available commands: - - commands List all available commands - config Inspect and modify CLI configuration - dashboard Open the Urlbox dashboard in your browser - doctor Check installation, configuration, network, and credentials - link Generate an HMAC-signed render URL (no API call) - llm Manage org LLM credentials - login Sign in via your browser (device flow) - logout Sign out and revoke this device's session - orgs Manage the active organisation - pdf Render a URL as PDF (alias for `render --format pdf --full-page`) - projects Manage projects and the active project - proxies Manage org proxy pools - render Render a URL to a screenshot, PDF, video, or other format - schema Print JSON Schemas describing Urlbox API payloads - screenshot Capture a screenshot (alias for `render --format png`) - skill Agent skill content - status Look up the state of an async render - storage Manage org storage credentials - upgrade Update urlbox to the latest version - usage Show the organisation's render usage for the current period - version Print CLI version, commit, and build date - video Render a URL as MP4 video (alias for `render --format mp4`) - whoami Show the signed-in user and active context - -Use "urlbox --help" for more information about a command. +urlbox storage list +urlbox storage create prod --provider aws_s3 --bucket b --region us-east-1 --key k --secret s +urlbox projects storage assign my-project prod ``` -### `doctor` +| Group | Verbs | +|-------|-------| +| `storage` | `list` `show` `create` `update` `delete` | +| `proxies` (alias `proxy`) | `list` `show` `create` `update` `delete` | +| `llm` | `list` `show` `create` `update` `delete` `test` `models` | +| `projects assign` / `unassign` | Attach or detach a project's `storage`, `proxy`, or `llm` credential | -Diagnoses installation, configuration, network, and credential issues. Runs -nine checks: version, install method, config file, session, active org, active -project, render credential, DNS, and API reachability. -Exits non-zero if any check fails. +Secrets are masked in every human-readable view — pass `--reveal` to unmask (JSON output always shows them in full). Deletes are retype-to-confirm; `--yes` skips the prompt. A target resolves by name or id (`store_…`, `pool_…`, `llm_…`). -```sh -urlbox doctor -urlbox doctor --output-format json --jq '.data.checks[] | select(.status != "ok")' -``` +### Utilities -### `schema` +| Command | Does | +|---------|------| +| `config get` / `set` / `path` | Read and write the stored config | +| `schema render` | Print the JSON Schema of every render option | +| `commands` | List every command and flag (human table, or JSON when piped) | +| `skill show` / `install` | Print or install the agent skill (see below) | +| `doctor` | Check version, config, session, credentials, and API reachability | +| `dashboard` | Open the Urlbox dashboard in your browser | +| `upgrade` | Update to the latest version via the detected install method | +| `version` | Print the version, commit, and build date | -Prints the JSON Schema that describes an Urlbox API payload. Use this to -discover every valid option and its type — handy for agents building requests -or for humans exploring the available render options. +Troubleshooting guide: [urlbox.com/docs/cli/troubleshooting](https://urlbox.com/docs/cli/troubleshooting). Full reference: [urlbox.com/docs/cli/command-reference](https://urlbox.com/docs/cli/command-reference). -```sh -# Full schema in the standard envelope -urlbox schema render +## Output -# Discover render options -urlbox schema render --jq '.data.properties | keys' +Every command returns one of three formats via `--output-format`: -# Raw schema only (no envelope) -urlbox schema render --output-format quiet -``` - -When `--json` is used to send payloads (Phase 4 onward), this same schema is -applied for client-side validation before any network call. Validation -failures return error code `validation` (exit code 2). See `urlbox skill show` -for the full validation contract. +| Format | Default when | Shape | +|--------|--------------|-------| +| `text` | stdout is a terminal | Human-readable, with colour | +| `json` | stdout is piped | Full envelope | +| `quiet` | — | Raw `data` only, no envelope | -### `skill` +Success and error responses use a stable envelope: -Prints the embedded `SKILL.md` — a one-page agent guide describing the output -contract, error codes, discovery commands, and authentication flow. Useful as -context for an LLM agent. - -```sh -urlbox skill show +```json +{ "ok": true, "command": "render", "data": {}, "summary": "...", "breadcrumbs": [] } +{ "ok": false, "command": "render", "error": "...", "code": "...", "hint": "..." } ``` -`urlbox skill install --target ` writes the embedded `SKILL.md` to the -well-known skill directory of your agent tooling so the agent picks it up -automatically on next launch. Supported targets: - -| Target | User scope | Project scope | -|-----------------|--------------------------------------------|--------------------------------| -| `claude-code` | `~/.claude/skills/urlbox/SKILL.md` | `.claude/skills/urlbox/SKILL.md` | -| `cursor` | `~/.cursor/skills/urlbox/SKILL.md` | `.cursor/skills/urlbox/SKILL.md` | -| `codex` | `~/.agents/skills/urlbox/SKILL.md` | `.agents/skills/urlbox/SKILL.md` | -| `opencode` | `~/.config/opencode/skills/urlbox/SKILL.md` | `.opencode/skills/urlbox/SKILL.md` | +Data goes to stdout, messages and warnings go to stderr, so piping stays clean. The `NO_COLOR` environment variable disables colour. Filter any response inline with the built-in `--jq ` flag — no external `jq` binary needed: ```sh -urlbox skill install --target cursor --scope user --yes +urlbox doctor --output-format json --jq '.data.checks[] | select(.status != "ok")' ``` -Use `--scope user` to install once across all projects (under `$HOME`); use -`--scope project` to commit the skill to the current repo so teammates inherit -it. - -### `upgrade` - -Updates urlbox to the latest version. Automatically detects how you installed it (Homebrew, Scoop, npm, or Go) and runs the appropriate update command. If the install method can't be detected, it prints all available upgrade commands so you can pick the right one. - -## Output Formats +Each error `code` maps to a fixed exit code: -All commands support three output formats via the `--output-format` flag: +| Code | Exit | | Code | Exit | +|------|------|---|------|------| +| `usage` | 1 | | `rate_limit` | 6 | +| `validation` | 2 | | `conflict` | 7 | +| `auth` | 3 | | `server` | 10 | +| `forbidden` | 4 | | `network` | 11 | +| `not_found` | 5 | | `timeout` | 11 | -| Format | Flag | Description | -|--------|------|-------------| -| `text` | `--output-format text` | Human-readable with colors. Default in a terminal. | -| `json` | `--output-format json` | Full JSON envelope with `ok`, `command`, `data`, `summary`, and `breadcrumbs` fields. Default when piped. | -| `quiet` | `--output-format quiet` | Raw data only (no envelope wrapper). | +Renders retry automatically on 429, 5xx, and network errors — up to 3 retries (4 attempts total), with backoff. Disable with `--no-retry`, cap with `--max-retries`. Timeouts are never retried; raise `--timeout` or switch to `--async`. More at [urlbox.com/docs/cli/output-and-scripting](https://urlbox.com/docs/cli/output-and-scripting). -**Auto-detection:** When no `--output-format` flag is given, the CLI uses `text` if stdout is a TTY (interactive terminal) and `json` if stdout is piped to another program. This means scripts and agents get structured JSON by default without any extra flags. +## For AI agents -The `NO_COLOR` environment variable is respected — when set, terminal colors are disabled. - -## Filtering output with `--jq` - -Every command supports a built-in `--jq ` flag, powered by [gojq](https://github.com/itchyny/gojq). The expression runs over the JSON envelope, or against `.data` when combined with `--output-format quiet`. No external `jq` binary required. +The CLI is built to be driven by an agent. Everything is discoverable and non-interactive: ```sh -# Pull a single field -urlbox commands --output-format json --jq '.data.commands[].name' - -# Filter to failing doctor checks only -urlbox doctor --output-format json --jq '.data.checks[] | select(.status != "ok")' +# Install the skill so your agent auto-discovers the CLI next session +urlbox skill install --target claude-code --scope user --yes -# Use --output-format quiet to run jq directly against .data -urlbox commands --output-format quiet --jq '.commands | length' +# Discover the surface +urlbox commands --output-format json # every command and flag +urlbox render --help --agent # structured JSON help +urlbox schema render # every render option and its type ``` -## Agent integration - -Three discovery layers built specifically for LLM agents: +Pipe any command and it defaults to JSON. Add `--yes` to skip confirmation prompts, `--org` / `--project` to pin context, and `URLBOX_API_SECRET` to authenticate without a browser. Skill install targets are `claude-code`, `cursor`, `codex`, and `opencode`. Setup guide: [urlbox.com/docs/cli/ai-agents](https://urlbox.com/docs/cli/ai-agents). -```sh -# 1. Full command catalog as JSON -urlbox commands --output-format json +## Versioning -# 2. Structured help for any command -urlbox commands --help --agent -urlbox doctor --help --agent - -# 3. The CLI's agent skill (embedded as SKILL.md) -urlbox skill show -``` +Versions `v0.1` through `v0.10` were early access. A `v1.0.0`–`v1.0.4` line was published in May 2026, then reset back to `v0.10.0` because the surface wasn't ready to carry a v1 stability promise. This release is **v1.1.0** — the first official stable release. The `1.0.x` numbers are unavailable on npm because that earlier line used them. -The CLI's exposed surface (every command and flag, at every level) is committed to `SURFACE.txt` and enforced in CI. New commands and flags can be added freely; renaming or removing one fails the surface gate, so downstream agents and scripts never break silently. - -## Authentication - -Three ways to provide your Urlbox API secret. The CLI picks the highest-priority -source available (highest first): +## Development ```sh -# 1. CLI flag — one-shot, doesn't touch the config file -urlbox --profile render - -# 2. Env var — preferred for CI / containers -export URLBOX_API_SECRET=sec_xxxxxxxxxxxx - -# 3. Sign in through the browser — persists a session + render credential -urlbox login +make build # build to bin/urlbox +make test # run tests with the race detector +make ci # fmt-check, lint, test, build, surface-check ``` -The full priority chain: - -1. CLI flag (`--profile `) -2. Env vars (`URLBOX_API_SECRET`, `URLBOX_PROFILE`, `URLBOX_API_HOST`) -3. The named profile (selected via `--profile`, `URLBOX_PROFILE`, or the - stored `default_profile`) -4. Per-repo overrides at `.urlbox/config.json` (walks from CWD up to `$HOME`) -5. The global default profile in `~/.config/urlbox/config.json` - -Multiple profiles let you keep credentials per account, environment, or repo. -See `urlbox config profile --help` for management commands. - -Verify with `urlbox doctor`. - -## Development - -| Target | Description | -|--------|-------------| -| `make ci` | Run all checks: fmt-check, lint, test, build, surface-check | -| `make test` | Run tests with race detector | -| `make e2e` | Run end-to-end tests | -| `make e2e-verbose` | Run E2E tests with colored output | -| `make lint` | Run golangci-lint | -| `make fmt` | Format with gofumpt | -| `make build` | Build binary to `bin/urlbox` | -| `make surface-snapshot` | Regenerate `SURFACE.txt` from the built binary | -| `make surface-check` | Fail if `SURFACE.txt` is stale or has breaking changes | -| `make clean` | Remove `bin/` and `dist/` | - -`SURFACE.txt` is the canonical contract of every command and flag. Run `make surface-snapshot` after intentionally adding a flag or command, then commit the updated file alongside the code change. +`SURFACE.txt` is the canonical contract of every command and flag; a CI check fails if it drifts. Run `make surface-snapshot` after intentionally adding a command or flag, and commit the update alongside the code. ## License diff --git a/npm/README.md b/npm/README.md index 117b9cf..2416549 100644 --- a/npm/README.md +++ b/npm/README.md @@ -1,85 +1,34 @@ # @urlbox/cli -The official CLI for the [Urlbox](https://urlbox.com) screenshot and web automation API. +The official CLI for [Urlbox](https://urlbox.com), the website screenshot API. Render screenshots, PDFs, videos, and extracted content from any URL or raw HTML. ## Install -``` +```sh npm install -g @urlbox/cli ``` -## Usage - -Sign in through your browser with `urlbox login` (CI/headless: set `URLBOX_API_SECRET` instead — grab a secret from [urlbox.com/dashboard/projects](https://urlbox.com/dashboard/projects)). +## Quick start ```sh -urlbox login # one-time, browser sign-in -urlbox render https://example.com --output home.png # capture & save -urlbox screenshot https://example.com --output home.png # alias: --format png -urlbox pdf https://example.com --output home.pdf # alias: --format pdf --full-page -urlbox video https://example.com --output home.mp4 # alias: --format mp4 - -urlbox render https://example.com --dry-run # preview payload, no API call -urlbox render https://example.com --curl # paste-able curl, secret redacted -urlbox render https://example.com --open # open result in browser -urlbox render https://example.com --preset article # news/article preset -urlbox render https://example.com --timeout 3m # raise per-attempt budget (default 60s) - -# Async: queue a render, then poll for completion -urlbox render https://example.com --async # returns a renderId -urlbox status ps_abc123 --wait # poll until terminal - -# Sign a render URL locally (no API call) — for templates / CDNs -urlbox link --url https://example.com --output-format quiet - -# Open the Urlbox dashboard in your browser -urlbox dashboard - -# Sign in through the browser (CI/headless: set URLBOX_API_SECRET instead) +# Sign in once through your browser (CI/headless: set URLBOX_API_SECRET instead) urlbox login -urlbox whoami # signed-in account, org, project -urlbox orgs list # organisations you belong to -urlbox projects list # projects in the active org -urlbox usage # render usage for the active organisation - -# Org-owned credentials — create once, assign to projects (secrets masked; --reveal to show) -urlbox storage list # storage credentials (S3, GCS, R2, Azure, ...) -urlbox storage create prod --provider aws_s3 --bucket b --region us-east-1 --key k --secret s -urlbox proxies list # proxy pools (alias: proxy) -urlbox proxies create eu --url http://user:pass@host:8080 -urlbox llm list # LLM credentials -urlbox llm create openai --provider openai --api-key sk-… -urlbox llm test openai # check the stored credential's connection -urlbox storage show prod --reveal # show one, secrets unmasked -urlbox storage update prod --region eu-west-1 # update only the flags you pass -urlbox storage delete prod --yes # retype-to-confirm; --yes skips the prompt -urlbox projects storage assign my-project prod # assign a credential to a project (kind: storage|proxy|llm) -urlbox projects storage unassign my-project # unassign the project's current one -# Self-discovery -urlbox commands --output-format json # full command catalog -urlbox render --help --agent # structured JSON help -urlbox schema render # JSON Schema of render options -urlbox skill show # one-page agent guide -urlbox skill install --target claude-code --scope user --yes # auto-discover by your agent (also: cursor, codex, opencode) - -# Diagnostics -urlbox doctor # version + config + auth + reachability +# Render a page and save it +urlbox render https://example.com --output home.png ``` -All commands support `--output-format json|text|quiet` and a built-in `--jq ` filter (no external `jq` binary needed). - -**Validation contract (v0.9.0+):** typed flags (`--width`, `--format`, `--wait-until`, ...) are validated locally — fast feedback for type errors and invalid enum values. The `--json` option is a passthrough: the Urlbox API performs all option validation, so any current or future API option works via `--json` without needing a CLI update. If a `--json` key looks like a typo of a documented option, the CLI prints a `warning: ...` to stderr and still sends the request verbatim. +In CI, set `URLBOX_API_SECRET` (your project's secret, `ubx_sk_…`, from the [dashboard](https://urlbox.com/dashboard/projects)) and skip `login`. -Multi-account workflows use named profiles (`urlbox --profile ...`); see `urlbox config profile --help`. +Every command speaks JSON when piped, supports a built-in `--jq ` filter, and returns a clear exit code. ## How it works -The npm package is a thin wrapper around the native Go binary. During `postinstall`, it downloads the pre-built binary for your platform and architecture (macOS, Linux, or Windows; amd64 or arm64) from the GitHub release. Running `urlbox` then spawns that binary with your arguments. +This npm package is a thin wrapper around the native Go binary. On `postinstall` it downloads the pre-built binary for your platform (macOS, Linux, or Windows; amd64 or arm64) from the GitHub release, and `urlbox` spawns it with your arguments. ## Documentation -Full documentation and additional install methods: [github.com/urlbox/urlbox-cli](https://github.com/urlbox/urlbox-cli) +Full docs, more install methods, and the command reference: [urlbox.com/docs/cli](https://urlbox.com/docs/cli) and [github.com/urlbox/urlbox-cli](https://github.com/urlbox/urlbox-cli). ## License diff --git a/npm/package.json b/npm/package.json index f25fdb1..ae6e61c 100644 --- a/npm/package.json +++ b/npm/package.json @@ -1,7 +1,7 @@ { "name": "@urlbox/cli", "version": "0.0.0", - "description": "Official CLI for the Urlbox screenshot and web automation API", + "description": "Official CLI for the Urlbox website screenshot API", "license": "MIT", "repository": { "type": "git", From b5f1e60a9e76bc0320637effa1b33b4d2c3dcc3b Mon Sep 17 00:00:00 2001 From: gdameneses Date: Wed, 19 Aug 2026 13:39:07 +0100 Subject: [PATCH 3/8] =?UTF-8?q?fix(auth):=20restore=20`urlbox=20auth`=20?= =?UTF-8?q?=E2=80=94=20the=20headless=20bootstrap=20`login`=20cannot=20ser?= =?UTF-8?q?ve?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The device flow needs a browser, so removing `urlbox auth` left no way to write a secret into a fresh config on a headless box, in CI, or under an agent. `config set api_secret` cannot fill the gap: with zero profiles it errors "No profiles configured" on main and on this branch alike, so the migration path the changelog suggested never worked from a clean machine. Restores auth.go and its tests, minus the stdin-TTY helpers and maskSecret that moved to stdin_tty.go and masking.go on this branch. SURFACE.txt gains its nine `urlbox auth` lines back, so this release is purely additive. Hints now name both onboarding paths via a shared credentialHint: `login` for a browser, `auth` / URLBOX_API_SECRET for everything else. Pointing a browserless environment at `login` is a dead end. TestNoAuthCommandRemains becomes TestAuthCommandStillRegistered, and the ghost-command blocklist goes back to pinning the removed `--api-key` flag rather than the whole command. --- README.md | 11 +- SURFACE.txt | 9 + internal/cmd/auth.go | 289 ++++++++++++ internal/cmd/auth_preflight.go | 2 +- internal/cmd/auth_test.go | 725 +++++++++++++++++++++++++++++++ internal/cmd/config.go | 6 +- internal/cmd/config_test.go | 10 +- internal/cmd/error_hints_test.go | 7 +- internal/cmd/login_hint.go | 8 +- internal/cmd/root.go | 1 + internal/cmd/root_test.go | 18 +- internal/cmd/secret_input.go | 2 +- internal/config/config.go | 2 +- npm/README.md | 2 +- skills/SKILL.md | 21 +- 15 files changed, 1086 insertions(+), 27 deletions(-) create mode 100644 internal/cmd/auth.go create mode 100644 internal/cmd/auth_test.go diff --git a/README.md b/README.md index b1443d3..fdf13bd 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,15 @@ urlbox login Your browser opens, you approve, and the CLI stores your session plus the active project's render credential — renders work immediately. -In CI and headless environments, where the browser can't open, set the `URLBOX_API_SECRET` env var instead (the secret looks like `ubx_sk_…`, found under your project in the [dashboard](https://urlbox.com/dashboard/projects)). For a one-shot override on a single command there's also `--api-secret-stdin` / `--api-secret-file` — avoid the bare `--api-secret`, which leaks into `ps` and shell history. +In CI and headless environments, where the browser can't open, use `urlbox auth` or the `URLBOX_API_SECRET` env var instead (the secret looks like `ubx_sk_…`, found under your project in the [dashboard](https://urlbox.com/dashboard/projects)): + +```sh +printf %s "$URLBOX_API_SECRET" | urlbox auth --api-secret-stdin # persists to the config file +urlbox auth --api-secret-file /run/secrets/urlbox # or read it from disk +URLBOX_API_SECRET=ubx_sk_… urlbox render https://example.com # or stay stateless +``` + +Avoid the bare `--api-secret`, which leaks into `ps` and shell history. `--api-secret-stdin` / `--api-secret-file` also work as a one-shot override on any single command. When more than one source is present, the highest-priority wins: **command flag → environment variable → a per-repo `.urlbox/config.json` overlay → your stored config**. @@ -119,6 +127,7 @@ urlbox usage # render usage for the current period | Command | Does | |---------|------| | `login` / `logout` | Sign in through the browser; sign out and revoke this device's session | +| `auth` | Store an API secret without a browser — the CI, container, and agent path | | `whoami` (alias `me`) | Show the signed-in user and active org/project | | `orgs list` / `orgs select` | List or switch your active organisation | | `projects list` / `select` / `show` | Browse and switch the active project | diff --git a/SURFACE.txt b/SURFACE.txt index 4976bcb..1192a6c 100644 --- a/SURFACE.txt +++ b/SURFACE.txt @@ -17,6 +17,15 @@ urlbox --agent urlbox --jq urlbox --output-format urlbox --profile +urlbox auth +urlbox auth --agent +urlbox auth --api-secret +urlbox auth --api-secret-file +urlbox auth --api-secret-stdin +urlbox auth --force +urlbox auth --jq +urlbox auth --output-format +urlbox auth --profile urlbox commands urlbox commands --agent urlbox commands --jq diff --git a/internal/cmd/auth.go b/internal/cmd/auth.go new file mode 100644 index 0000000..b09567b --- /dev/null +++ b/internal/cmd/auth.go @@ -0,0 +1,289 @@ +package cmd + +import ( + "errors" + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + "golang.org/x/term" + + "github.com/urlbox/urlbox-cli/internal/config" + "github.com/urlbox/urlbox-cli/internal/output" +) + +// AuthSecretReader reads one secret from the user with masked echo. +// Returns the typed value (no trailing newline) and any read error. +type AuthSecretReader func() (string, error) + +var authSecretReader AuthSecretReader = defaultAuthSecretReader + +// SetAuthSecretReaderForTest injects a stub secret reader. +func SetAuthSecretReaderForTest(f AuthSecretReader) { authSecretReader = f } + +// ResetAuthSecretReaderForTest restores the real masked-prompt reader. +func ResetAuthSecretReaderForTest() { authSecretReader = defaultAuthSecretReader } + +// defaultAuthSecretReader reads stdin with terminal echo disabled. Caller is +// responsible for printing the prompt label BEFORE the read AND for printing +// the trailing newline AFTER the read on the cobra writer (so this stays a +// pure I/O primitive that test stubs can replace, and so the newline +// participates in cobra writer plumbing). +func defaultAuthSecretReader() (string, error) { + b, err := term.ReadPassword(int(os.Stdin.Fd())) //nolint:gosec // file descriptors fit in int on every platform Go supports + if err != nil { + return "", err + } + return string(b), nil +} + +// AuthConfirmReader reads one line of plain text from the user — used for +// y/N confirmation prompts (overwrite guard). Echoes input; not for +// secrets. +type AuthConfirmReader func() (string, error) + +var authConfirmReader AuthConfirmReader = defaultAuthConfirmReader + +// SetAuthConfirmReaderForTest injects a stub confirm reader. +func SetAuthConfirmReaderForTest(f AuthConfirmReader) { authConfirmReader = f } + +// ResetAuthConfirmReaderForTest restores the default reader. +func ResetAuthConfirmReaderForTest() { authConfirmReader = defaultAuthConfirmReader } + +func defaultAuthConfirmReader() (string, error) { + var line string + _, err := fmt.Fscanln(os.Stdin, &line) + return line, err +} + +func newAuthCmd() *cobra.Command { + var apiSecret, apiSecretFile string + var apiSecretStdin, force bool + c := &cobra.Command{ + Use: "auth", + Short: "Configure API credentials", + Long: `Save your Urlbox API secret to the local config file. + +Find your API secret in your project's settings on the dashboard: + https://urlbox.com/dashboard/projects (open your project → API Secret) + +Non-interactive (preferred for agents and CI): + printf %s "$URLBOX_API_SECRET" | urlbox auth --api-secret-stdin + urlbox auth --api-secret-file ~/.config/urlbox/secret + urlbox auth --api-secret # least safe: visible in ps + shell history + +Interactive (humans, on a TTY): + urlbox auth # prompts once for the secret with masked echo + +The env var URLBOX_API_SECRET takes precedence at runtime over the saved value. + +Profile selection: --profile writes to ; URLBOX_PROFILE= +writes to that name; otherwise auth writes to the configured +default_profile (creating "default" if no profiles exist). + +The per-repo overlay (.urlbox/config.json with a "profile" field) is +DELIBERATELY IGNORED by auth. Overlay is a runtime-only read layer for +render/link/status/doctor — auth is a write command and must target a +concrete, named profile to avoid surprise clobbers when CWD changes. +If you want auth to target the overlay's profile, pass --profile + explicitly.`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + secret, cliErr := resolveAPISecretInput(secretStdin, cmd.ErrOrStderr(), apiSecret, cmd.Flags().Changed("api-secret"), apiSecretStdin, apiSecretFile) + if cliErr != nil { + return cliErr + } + + interactive := secret == "" && !apiSecretStdin && apiSecretFile == "" && isStdinTTY(cmd.InOrStdin()) && isStderrTTY(cmd.ErrOrStderr()) + if interactive { + // Prompt label on stderr — keeps stdout clean for --output-format json. + // Pre-prompt pointer to where the secret lives, so a first-time + // user doesn't have to hunt or guess the URL. + _, _ = fmt.Fprintln(cmd.ErrOrStderr(), "Find your API secret in your project's settings: https://urlbox.com/dashboard/projects") + _, _ = fmt.Fprint(cmd.ErrOrStderr(), "API secret: ") + s, err := authSecretReader() + if err != nil { + return output.NewCLIError(output.ErrUsage, "auth cancelled", err.Error()) + } + // Newline after masked input lands on the cobra writer so it + // participates in test capture + caller redirection. + _, _ = fmt.Fprintln(cmd.ErrOrStderr()) + secret = s + } + + if secret == "" { + if interactive { + // User was on a TTY and pressed Enter at the prompt — tell them so, + // don't suggest "run interactively on a TTY" (they already did). + return output.NewCLIError( + output.ErrUsage, + "empty API secret", + "Run 'urlbox auth' again and paste your secret at the prompt.", + ) + } + return output.NewCLIError( + output.ErrUsage, + "missing API secret", + "Pipe via --api-secret-stdin, read from --api-secret-file , pass --api-secret , export URLBOX_API_SECRET, or run interactively on a TTY. Find your API secret in your project's settings at https://urlbox.com/dashboard/projects.", + ) + } + + // Validate the resolved secret value through the same gate every + // secret-writing path uses (auth / config set / profile create). + // Catches whitespace-only, leading/trailing whitespace artifacts, + // and embedded control characters. Round 6 class-fix. + validated, vErr := config.ValidateSecretValue(secret) + if vErr != nil { + return vErr + } + secret = validated + + // Round 7 CC class-fix: the entire read-modify-write happens + // inside config.Update so the file lock makes it atomic w.r.t. + // parallel processes. The TTY prompt logic stays outside the + // mutate fn (prompting under a lock would deadlock other + // processes for the prompt's lifetime); on conflict, prompt + // then retry the Update with force=true. + flagProfile, _ := cmd.Root().PersistentFlags().GetString("profile") + envProfile := os.Getenv(config.EnvProfile) + + var profileName string + runUpdate := func(allowOverwrite bool) error { + return config.Update(func(cfg *config.Config) error { + // Round 4 C1: honor --profile and URLBOX_PROFILE. Before this, + // auth always wrote to cfg.DefaultProfile, silently dropping + // any --profile flag, which turned the overwrite guard into + // the 2026-05-08-incident-class clobber when callers + // intended a non-default target. Precedence: flag > env > + // cfg.DefaultProfile > "default". + switch { + case flagProfile != "": + if _, ok := cfg.Profiles[flagProfile]; !ok { + // Round 7 EE: every "user named a profile that + // doesn't exist" site reports ErrNotFound now, + // matching profile delete/default and the + // unified config.Resolve (render/status/link/doctor). + return output.NewCLIError( + output.ErrNotFound, + `Profile "`+flagProfile+`" does not exist`, + "Run 'urlbox config profile list' to see available profiles, or `urlbox config profile create "+flagProfile+"` first.", + ) + } + profileName = flagProfile + case envProfile != "": + if _, ok := cfg.Profiles[envProfile]; !ok { + return output.NewCLIError( + output.ErrNotFound, + `Profile "`+envProfile+`" does not exist (URLBOX_PROFILE)`, + "Run 'urlbox config profile list' to see available profiles, or unset URLBOX_PROFILE.", + ) + } + profileName = envProfile + default: + profileName = cfg.DefaultProfile + if profileName == "" { + profileName = "default" + cfg.DefaultProfile = profileName + } + } + p := cfg.Profiles[profileName] + + // Overwrite guard (Round 1 S-C3): same-secret re-save is + // idempotent; different-secret overwrite needs allowOverwrite. + if p.APISecret != "" && p.APISecret != secret && !allowOverwrite { + return output.NewCLIError( + output.ErrConflict, + fmt.Sprintf("profile %q already has an API secret (%s); overwrite refused", profileName, maskSecret(p.APISecret)), + "Pass --force to overwrite, or use `urlbox config profile create ` for a separate profile. This guard prevents the 2026-05-08 incident class where an agent silently clobbers a real secret.", + ) + } + p.APISecret = secret + cfg.Profiles[profileName] = p + return nil + }) + } + + err := runUpdate(force) + if err != nil { + // Interactive TTY: if the inner conflict was an overwrite + // guard and we're on a TTY, prompt the user. On confirm, + // retry the Update with allowOverwrite=true. + var cli *output.CLIError + if errors.As(err, &cli) && cli.Code == output.ErrConflict && + isStdinTTY(cmd.InOrStdin()) && isStderrTTY(cmd.ErrOrStderr()) { + // Re-read just for the prompt display (out-of-lock; we + // re-check inside Update on retry). + if existing, _ := config.Load(); existing != nil { + p := existing.Profiles[profileName] + if !confirmAuthOverwrite(cmd, p.APISecret, secret) { + return output.NewCLIError( + output.ErrConflict, + "auth cancelled — existing secret preserved", + "Re-run with --force to overwrite without prompt, or use `urlbox config profile create ` for a separate profile.", + ) + } + } + err = runUpdate(true) + } + } + if err != nil { + var cli *output.CLIError + if errors.As(err, &cli) { + return cli + } + return output.NewCLIError( + output.ErrServer, + "failed to save config", + err.Error(), + ) + } + + masked := maskSecret(secret) + env := output.NewEnvelope( + "auth", + map[string]string{ + "masked_secret": masked, + "profile": profileName, + "config_path": config.Path(), + }, + fmt.Sprintf("API secret configured (%s)", masked), + []output.Breadcrumb{ + {Action: "verify", Cmd: "urlbox doctor"}, + {Action: "render", Cmd: "urlbox render "}, + }, + ) + + formatFlag, _ := cmd.Root().PersistentFlags().GetString("output-format") + jqExpr, _ := cmd.Root().PersistentFlags().GetString("jq") + stdout := cmd.OutOrStdout() + format := output.ResolveFormat(formatFlag, stdout) + styles := output.NewStylesForWriter(stdout) + + if jqExpr != "" { + return output.WriteEnvelopeWithJQ(stdout, env, jqExpr, format == output.FormatQuiet) + } + formatter := output.NewFormatter(format, styles) + return formatter.WriteSuccess(stdout, env) + }, + } + c.Flags().StringVar(&apiSecret, "api-secret", "", "Urlbox API secret (skip the interactive prompt — leaks into ps and shell history; prefer --api-secret-stdin or --api-secret-file)") + c.Flags().BoolVar(&apiSecretStdin, "api-secret-stdin", false, "Read the API secret from stdin until EOF (recommended for CI / agents)") + c.Flags().StringVar(&apiSecretFile, "api-secret-file", "", "Read the API secret from the given file (trailing newline trimmed)") + c.Flags().BoolVar(&force, "force", false, "Overwrite an existing secret on the default profile without confirmation (CI-safe escape hatch for the overwrite guard)") + return c +} + +// confirmAuthOverwrite prompts the user on stderr whether to replace the +// existing default-profile secret. Returns true if the user types y / yes +// (case-insensitive). Used only on interactive TTYs; non-TTY callers +// require --force instead. +func confirmAuthOverwrite(cmd *cobra.Command, existing, replacement string) bool { + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), + "Replacing existing secret %s with %s. Proceed? [y/N]: ", + maskSecret(existing), maskSecret(replacement)) + answer, _ := authConfirmReader() + _, _ = fmt.Fprintln(cmd.ErrOrStderr()) + answer = strings.ToLower(strings.TrimSpace(answer)) + return answer == "y" || answer == "yes" +} diff --git a/internal/cmd/auth_preflight.go b/internal/cmd/auth_preflight.go index 460c4eb..5c1f3b1 100644 --- a/internal/cmd/auth_preflight.go +++ b/internal/cmd/auth_preflight.go @@ -26,6 +26,6 @@ func requireSecret(resolved *config.Resolved) *output.CLIError { return output.NewCLIError( output.ErrAuth, "no API secret configured", - loginHint+" CI and headless environments can set URLBOX_API_SECRET in the environment instead.", + credentialHint, ) } diff --git a/internal/cmd/auth_test.go b/internal/cmd/auth_test.go new file mode 100644 index 0000000..829394f --- /dev/null +++ b/internal/cmd/auth_test.go @@ -0,0 +1,725 @@ +package cmd_test + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/urlbox/urlbox-cli/internal/cmd" + "github.com/urlbox/urlbox-cli/internal/config" +) + +func TestAuth_RequiresAPISecret(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + var stdout, stderr bytes.Buffer + exit := cmd.Execute([]string{"auth"}, &stdout, &stderr) + if exit == 0 { + t.Fatal("expected non-zero exit on missing --api-secret") + } +} + +func TestAuth_WritesConfigFile(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv("URLBOX_API_SECRET", "") + + var stdout, stderr bytes.Buffer + exit := cmd.Execute([]string{"auth", "--api-secret", "sec_xxxxxxxxxxxx"}, &stdout, &stderr) + if exit != 0 { + t.Fatalf("exit=%d stdout=%s stderr=%s", exit, stdout.String(), stderr.String()) + } + + c, err := config.Load() + if err != nil { + t.Fatalf("config.Load: %v", err) + } + if got := c.Profiles[c.DefaultProfile].APISecret; got != "sec_xxxxxxxxxxxx" { + t.Fatalf("secret not persisted; got %q", got) + } +} + +func TestAuth_FlagPath_StoresInAPISecret(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv("URLBOX_API_SECRET", "") + + var stdout, stderr bytes.Buffer + exit := cmd.Execute([]string{"auth", "--api-secret", "sec_xxx"}, &stdout, &stderr) + if exit != 0 { + t.Fatalf("exit=%d stderr=%s", exit, stderr.String()) + } + c, err := config.Load() + if err != nil { + t.Fatal(err) + } + if c.Profiles["default"].APISecret != "sec_xxx" { + t.Errorf("APISecret = %q, want sec_xxx", c.Profiles["default"].APISecret) + } + if c.Profiles["default"].APIKey != "" { + t.Errorf("APIKey unexpectedly populated: %q (publishable-key field; should be empty after auth)", c.Profiles["default"].APIKey) + } +} + +func TestAuth_OldAPIKeyFlag_RemovedFromHelp(t *testing.T) { + var stdout, stderr bytes.Buffer + cmd.Execute([]string{"auth", "--help"}, &stdout, &stderr) + help := stdout.String() + stderr.String() + if strings.Contains(help, "--api-key") { + t.Error("--api-key should be gone in v0.6.0; replaced by --api-secret") + } + if !strings.Contains(help, "--api-secret") { + t.Error("--api-secret missing from --help") + } +} + +// Regression guard: --help and the missing-secret error envelope must both +// point users at the dashboard URL where they can copy their API secret. +// Field-report observation: agents (and humans) were inventing wrong URLs +// (urlbox.com/dashboard/api-secrets, etc.) because the CLI never said where +// to find the secret. Pinning the canonical pointer here. +func TestAuth_HelpAndErrorPointAtDashboardURL(t *testing.T) { + const wantURL = "urlbox.com/dashboard/projects" + + var stdout, stderr bytes.Buffer + cmd.Execute([]string{"auth", "--help"}, &stdout, &stderr) + help := stdout.String() + stderr.String() + if !strings.Contains(help, wantURL) { + t.Errorf("--help should point at %q so users know where to grab their secret; got:\n%s", wantURL, help) + } + + // Now exercise the missing-secret path and check the error envelope's hint. + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv("URLBOX_API_SECRET", "") + stdout.Reset() + stderr.Reset() + exit := cmd.Execute([]string{"auth", "--output-format", "json"}, &stdout, &stderr) + if exit == 0 { + t.Fatal("expected non-zero exit on missing --api-secret") + } + var env map[string]any + if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { + t.Fatalf("not JSON: %v\nstdout: %s", err, stdout.String()) + } + hint, _ := env["hint"].(string) + if !strings.Contains(hint, wantURL) { + t.Errorf("missing-secret hint should point at %q; got %q", wantURL, hint) + } +} + +func TestAuth_OutputEnvelopeMasksSecret(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv("URLBOX_API_SECRET", "") + + var stdout, stderr bytes.Buffer + exit := cmd.Execute([]string{"auth", "--api-secret", "sec_supersecretvalue", "--output-format", "json"}, &stdout, &stderr) + if exit != 0 { + t.Fatalf("exit=%d", exit) + } + + var env map[string]any + if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { + t.Fatalf("not JSON: %v\nout: %s", err, stdout.String()) + } + summary, _ := env["summary"].(string) + if strings.Contains(summary, "supersecretvalue") { + t.Fatalf("summary should mask the secret: %q", summary) + } + if !strings.Contains(summary, "sec_") { + t.Fatalf("summary should show prefix: %q", summary) + } + data, _ := env["data"].(map[string]any) + if ms, _ := data["masked_secret"].(string); strings.Contains(ms, "supersecretvalue") { + t.Fatalf("masked_secret leaked secret: %q", ms) + } +} + +func TestAuth_RejectsEmptySecret(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + var stdout, stderr bytes.Buffer + exit := cmd.Execute([]string{"auth", "--api-secret", ""}, &stdout, &stderr) + if exit == 0 { + t.Fatal("expected non-zero exit on empty secret") + } +} + +func TestAuth_HasBreadcrumbs(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + var stdout, stderr bytes.Buffer + cmd.Execute([]string{"auth", "--api-secret", "sec_test1234", "--output-format", "json"}, &stdout, &stderr) + + var env map[string]any + _ = json.Unmarshal(stdout.Bytes(), &env) + bcs, _ := env["breadcrumbs"].([]any) + if len(bcs) == 0 { + t.Fatalf("expected breadcrumbs, got: %v", env["breadcrumbs"]) + } +} + +func TestAuth_NonInteractive_NoSecret_StillUsageError(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + cmd.SetStdinTTYForTest(false) + defer cmd.ResetStdinTTYForTest() + + var stdout, stderr bytes.Buffer + exit := cmd.Execute([]string{"auth"}, &stdout, &stderr) + if exit != 1 { + t.Fatalf("exit=%d, want 1 (usage)", exit) + } + var env map[string]any + if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { + t.Fatalf("not JSON: %v", err) + } + if env["code"] != "usage" { + t.Errorf("code=%v", env["code"]) + } + if env["error"] != "missing API secret" { + t.Errorf("error=%v", env["error"]) + } +} + +// The interactive path requires a pty — we don't drive a real pty in CI. +// Instead we inject a stub secret-reader and verify the dispatcher selects +// the interactive branch when both stdin and stderr are TTYs. +func TestAuth_InteractivePath_DispatchedWhenTTY(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + cmd.SetStdinTTYForTest(true) + cmd.SetStderrTTYForTest(true) + defer cmd.ResetStdinTTYForTest() + defer cmd.ResetStderrTTYForTest() + + called := false + cmd.SetAuthSecretReaderForTest(func() (string, error) { + called = true + return "sec_prompt", nil + }) + defer cmd.ResetAuthSecretReaderForTest() + + var stdout, stderr bytes.Buffer + exit := cmd.Execute([]string{"auth"}, &stdout, &stderr) + if exit != 0 { + t.Fatalf("exit=%d stderr=%s", exit, stderr.String()) + } + if !called { + t.Fatal("interactive secret reader was not called") + } + if !strings.Contains(stderr.String(), "API secret:") { + t.Errorf("expected prompt label on stderr, got %q", stderr.String()) + } + + c, err := config.Load() + if err != nil { + t.Fatal(err) + } + if c.Profiles["default"].APISecret != "sec_prompt" { + t.Errorf("APISecret = %q, want sec_prompt", c.Profiles["default"].APISecret) + } +} + +// Regression guard: the trailing newline emitted after the masked password +// read must land on the cobra-injected stderr writer (cmd.ErrOrStderr()), +// not on the process-global os.Stderr. Otherwise tests can't capture or +// redirect it, and the writer-plumbing convention breaks. +func TestAuth_InteractivePrompt_NewlineGoesToCobraStderr(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + cmd.SetStdinTTYForTest(true) + cmd.SetStderrTTYForTest(true) + t.Cleanup(func() { + cmd.ResetStdinTTYForTest() + cmd.ResetStderrTTYForTest() + }) + cmd.SetAuthSecretReaderForTest(func() (string, error) { + return "ubx_sk_test12345678", nil + }) + t.Cleanup(cmd.ResetAuthSecretReaderForTest) + + var stdout, stderr bytes.Buffer + exit := cmd.Execute([]string{"auth"}, &stdout, &stderr) + if exit != 0 { + t.Fatalf("exit=%d stderr=%s", exit, stderr.String()) + } + if !strings.Contains(stderr.String(), "API secret:") { + t.Errorf("stderr missing prompt; got %q", stderr.String()) + } + if !strings.HasSuffix(stderr.String(), "\n") { + t.Errorf("stderr missing trailing newline; got %q", stderr.String()) + } +} + +func TestAuth_InteractivePath_EmptyInput_UsageError(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + cmd.SetStdinTTYForTest(true) + cmd.SetStderrTTYForTest(true) + defer cmd.ResetStdinTTYForTest() + defer cmd.ResetStderrTTYForTest() + + cmd.SetAuthSecretReaderForTest(func() (string, error) { + return "", nil + }) + defer cmd.ResetAuthSecretReaderForTest() + + var stdout, stderr bytes.Buffer + exit := cmd.Execute([]string{"auth"}, &stdout, &stderr) + if exit != 1 { + t.Fatalf("exit=%d, want 1", exit) + } + var env map[string]any + if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { + t.Fatalf("not JSON: %v", err) + } + if env["code"] != "usage" { + t.Errorf("code=%v", env["code"]) + } + // The interactive empty-input error must NOT suggest "run interactively + // on a TTY" — the user just did that. It should say so. + if env["error"] != "empty API secret" { + t.Errorf("error=%v, want %q", env["error"], "empty API secret") + } + if hint, _ := env["hint"].(string); strings.Contains(hint, "run interactively on a TTY") { + t.Errorf("interactive hint shouldn't suggest TTY (user is already on one): %q", hint) + } +} + +// TestAuth_APISecretStdin_ReadsAndSaves pins the --api-secret-stdin path: +// pipe the secret on stdin, no argv leak, no shell-history exposure. +// Closes Round 1 S-C2. +func TestAuth_APISecretStdin_ReadsAndSaves(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv("URLBOX_API_SECRET", "") + cmd.SetSecretStdinForTest(strings.NewReader("sec_stdin_abcdefghij\n")) + t.Cleanup(cmd.ResetSecretStdinForTest) + + var stdout, stderr bytes.Buffer + exit := cmd.Execute([]string{"auth", "--api-secret-stdin"}, &stdout, &stderr) + if exit != 0 { + t.Fatalf("exit=%d stdout=%s stderr=%s", exit, stdout.String(), stderr.String()) + } + c, err := config.Load() + if err != nil { + t.Fatal(err) + } + if got := c.Profiles[c.DefaultProfile].APISecret; got != "sec_stdin_abcdefghij" { + t.Errorf("APISecret=%q, want sec_stdin_abcdefghij", got) + } + // stdin path must NOT trigger the TTY history warning. + if strings.Contains(stderr.String(), "shell history") { + t.Errorf("stdin path should not warn about shell history; got %q", stderr.String()) + } +} + +// TestAuth_APISecretFile_ReadsAndSaves pins the --api-secret-file path. +func TestAuth_APISecretFile_ReadsAndSaves(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv("URLBOX_API_SECRET", "") + + dir := t.TempDir() + p := filepath.Join(dir, "secret.txt") + if err := os.WriteFile(p, []byte("sec_file_klmnopqrst\n"), 0o600); err != nil { + t.Fatal(err) + } + + var stdout, stderr bytes.Buffer + exit := cmd.Execute([]string{"auth", "--api-secret-file", p}, &stdout, &stderr) + if exit != 0 { + t.Fatalf("exit=%d stdout=%s stderr=%s", exit, stdout.String(), stderr.String()) + } + c, err := config.Load() + if err != nil { + t.Fatal(err) + } + if got := c.Profiles[c.DefaultProfile].APISecret; got != "sec_file_klmnopqrst" { + t.Errorf("APISecret=%q, want sec_file_klmnopqrst", got) + } +} + +// TestAuth_APISecretFlag_OnTTY_PrintsHistoryWarning pins UX I5: +// --api-secret on a TTY emits a stderr warning about shell history. +// On a non-TTY (CI), the warning is suppressed. +func TestAuth_APISecretFlag_OnTTY_PrintsHistoryWarning(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv("URLBOX_API_SECRET", "") + cmd.SetStderrTTYForTest(true) + t.Cleanup(cmd.ResetStderrTTYForTest) + + var stdout, stderr bytes.Buffer + exit := cmd.Execute([]string{"auth", "--api-secret", "sec_argv_uvwxyz1234"}, &stdout, &stderr) + if exit != 0 { + t.Fatalf("exit=%d stdout=%s stderr=%s", exit, stdout.String(), stderr.String()) + } + if !strings.Contains(stderr.String(), "shell history") { + t.Errorf("TTY stderr should warn about shell history; got %q", stderr.String()) + } +} + +// TestAuth_APISecretFlag_OnNonTTY_NoWarning pins suppression in CI / pipelines. +func TestAuth_APISecretFlag_OnNonTTY_NoWarning(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv("URLBOX_API_SECRET", "") + cmd.SetStderrTTYForTest(false) + t.Cleanup(cmd.ResetStderrTTYForTest) + + var stdout, stderr bytes.Buffer + exit := cmd.Execute([]string{"auth", "--api-secret", "sec_ci_qwerty5678"}, &stdout, &stderr) + if exit != 0 { + t.Fatalf("exit=%d stdout=%s stderr=%s", exit, stdout.String(), stderr.String()) + } + if strings.Contains(stderr.String(), "warning") { + t.Errorf("non-TTY should NOT emit shell-history warning; got %q", stderr.String()) + } +} + +// TestAuth_MutexBetweenSecretInputFlags pins that passing more than one of +// --api-secret, --api-secret-stdin, --api-secret-file is a usage error. +func TestAuth_MutexBetweenSecretInputFlags(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + var stdout, stderr bytes.Buffer + exit := cmd.Execute([]string{"auth", "--api-secret", "sec_x", "--api-secret-stdin"}, &stdout, &stderr) + if exit == 0 { + t.Fatal("expected non-zero exit on mutex violation") + } + var env map[string]any + _ = json.Unmarshal(stdout.Bytes(), &env) + if env["code"] != "usage" { + t.Errorf("code=%v, want usage", env["code"]) + } + if !strings.Contains(env["error"].(string), "at most one") { + t.Errorf("error should say 'at most one'; got %q", env["error"]) + } +} + +// TestAuth_Overwrite_NonTTY_RequiresForce pins S-C3 non-TTY behavior: +// when the default profile already has a secret AND a different new +// secret arrives non-interactively, refuse to overwrite. This is the +// 2026-05-08 incident-class guard. Pass --force to opt in. +func TestAuth_Overwrite_NonTTY_RequiresForce(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv("URLBOX_API_SECRET", "") + cmd.SetStdinTTYForTest(false) + cmd.SetStderrTTYForTest(false) + t.Cleanup(cmd.ResetStdinTTYForTest) + t.Cleanup(cmd.ResetStderrTTYForTest) + + // Seed an existing secret. + var stdout, stderr bytes.Buffer + if exit := cmd.Execute([]string{"auth", "--api-secret", "sec_original_abcdef"}, &stdout, &stderr); exit != 0 { + t.Fatalf("seed exit=%d", exit) + } + + // Attempt a different secret WITHOUT --force. + stdout.Reset() + stderr.Reset() + exit := cmd.Execute([]string{"auth", "--api-secret", "sec_DIFFERENT_uvwxyz", "--output-format", "json"}, &stdout, &stderr) + if exit == 0 { + t.Fatal("expected non-zero exit when overwrite refused in non-TTY mode") + } + var env map[string]any + _ = json.Unmarshal(stdout.Bytes(), &env) + if env["code"] != "conflict" { + t.Errorf("code=%v, want conflict", env["code"]) + } + if hint, _ := env["hint"].(string); !strings.Contains(hint, "--force") { + t.Errorf("hint should mention --force; got %q", hint) + } + + // Verify original secret was NOT clobbered. + c, err := config.Load() + if err != nil { + t.Fatal(err) + } + if c.Profiles[c.DefaultProfile].APISecret != "sec_original_abcdef" { + t.Errorf("secret was clobbered; got %q, want sec_original_abcdef", + c.Profiles[c.DefaultProfile].APISecret) + } +} + +// TestAuth_Overwrite_Force_Overwrites pins that --force bypasses the +// guard non-interactively. +func TestAuth_Overwrite_Force_Overwrites(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv("URLBOX_API_SECRET", "") + cmd.SetStdinTTYForTest(false) + cmd.SetStderrTTYForTest(false) + t.Cleanup(cmd.ResetStdinTTYForTest) + t.Cleanup(cmd.ResetStderrTTYForTest) + + var stdout, stderr bytes.Buffer + if exit := cmd.Execute([]string{"auth", "--api-secret", "sec_original_abcdef"}, &stdout, &stderr); exit != 0 { + t.Fatalf("seed exit=%d", exit) + } + stdout.Reset() + stderr.Reset() + exit := cmd.Execute([]string{"auth", "--api-secret", "sec_NEW_overwritten12", "--force"}, &stdout, &stderr) + if exit != 0 { + t.Fatalf("exit=%d (--force should permit overwrite); stderr=%s", exit, stderr.String()) + } + c, err := config.Load() + if err != nil { + t.Fatal(err) + } + if c.Profiles[c.DefaultProfile].APISecret != "sec_NEW_overwritten12" { + t.Errorf("--force did not overwrite; got %q", c.Profiles[c.DefaultProfile].APISecret) + } +} + +// TestAuth_Overwrite_SameSecret_NoGuard pins the idempotent case: if the +// new secret matches the existing, no guard / prompt fires. +func TestAuth_Overwrite_SameSecret_NoGuard(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv("URLBOX_API_SECRET", "") + cmd.SetStdinTTYForTest(false) + cmd.SetStderrTTYForTest(false) + t.Cleanup(cmd.ResetStdinTTYForTest) + t.Cleanup(cmd.ResetStderrTTYForTest) + + var stdout, stderr bytes.Buffer + if exit := cmd.Execute([]string{"auth", "--api-secret", "sec_identical_xyz789"}, &stdout, &stderr); exit != 0 { + t.Fatalf("seed exit=%d", exit) + } + stdout.Reset() + stderr.Reset() + exit := cmd.Execute([]string{"auth", "--api-secret", "sec_identical_xyz789"}, &stdout, &stderr) + if exit != 0 { + t.Fatalf("exit=%d (same-secret re-save should succeed silently)", exit) + } +} + +// TestAuth_Overwrite_TTY_Prompts pins S-C3 TTY behavior: prompt y/N +// via the test-injectable confirm-reader. "y" accepts, "n" cancels. +func TestAuth_Overwrite_TTY_PromptAccept(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv("URLBOX_API_SECRET", "") + cmd.SetStdinTTYForTest(true) + cmd.SetStderrTTYForTest(true) + t.Cleanup(cmd.ResetStdinTTYForTest) + t.Cleanup(cmd.ResetStderrTTYForTest) + + var stdout, stderr bytes.Buffer + cmd.SetStdinTTYForTest(false) + if exit := cmd.Execute([]string{"auth", "--api-secret", "sec_seed_abcdefghij"}, &stdout, &stderr); exit != 0 { + t.Fatalf("seed exit=%d", exit) + } + cmd.SetStdinTTYForTest(true) + + cmd.SetAuthConfirmReaderForTest(func() (string, error) { return "y", nil }) + t.Cleanup(cmd.ResetAuthConfirmReaderForTest) + + stdout.Reset() + stderr.Reset() + exit := cmd.Execute([]string{"auth", "--api-secret", "sec_replaced_xyz123"}, &stdout, &stderr) + if exit != 0 { + t.Fatalf("exit=%d, want 0 (prompt accepted); stderr=%s", exit, stderr.String()) + } + if !strings.Contains(stderr.String(), "Replacing existing secret") { + t.Errorf("stderr should show overwrite confirmation prompt; got %q", stderr.String()) + } + c, _ := config.Load() + if c.Profiles[c.DefaultProfile].APISecret != "sec_replaced_xyz123" { + t.Errorf("after 'y' prompt, secret = %q, want sec_replaced_xyz123", + c.Profiles[c.DefaultProfile].APISecret) + } +} + +func TestAuth_Overwrite_TTY_PromptReject(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv("URLBOX_API_SECRET", "") + cmd.SetStdinTTYForTest(false) + cmd.SetStderrTTYForTest(false) + t.Cleanup(cmd.ResetStdinTTYForTest) + t.Cleanup(cmd.ResetStderrTTYForTest) + + var stdout, stderr bytes.Buffer + if exit := cmd.Execute([]string{"auth", "--api-secret", "sec_seed_abcdefghij"}, &stdout, &stderr); exit != 0 { + t.Fatalf("seed exit=%d", exit) + } + + cmd.SetStdinTTYForTest(true) + cmd.SetStderrTTYForTest(true) + cmd.SetAuthConfirmReaderForTest(func() (string, error) { return "n", nil }) + t.Cleanup(cmd.ResetAuthConfirmReaderForTest) + + stdout.Reset() + stderr.Reset() + exit := cmd.Execute([]string{"auth", "--api-secret", "sec_REJECTED_abc456", "--output-format", "json"}, &stdout, &stderr) + // TTY reject and non-TTY refuse both yield "we did not save because of + // existing state" — exit code 7 (ErrConflict) in both paths. The `code` + // field in the JSON envelope is identical too; the message text disambiguates + // for humans. Round 2 architecture M1. + if exit != 7 { + t.Fatalf("exit=%d, want 7 (conflict); stdout=%s", exit, stdout.String()) + } + var env map[string]any + _ = json.Unmarshal(stdout.Bytes(), &env) + if env["code"] != "conflict" { + t.Errorf("code=%v, want conflict", env["code"]) + } + c, _ := config.Load() + if c.Profiles[c.DefaultProfile].APISecret != "sec_seed_abcdefghij" { + t.Errorf("after 'n' prompt, original secret was clobbered: %q", c.Profiles[c.DefaultProfile].APISecret) + } +} + +// TestAuth_ProfileFlag_TargetsNamedProfile pins the C1 fix from Round 4 +// adversarial review: `urlbox auth --profile ` must write the new +// secret into the named profile, NOT silently fall back to default. +// +// Before this fix, `--profile` was ignored entirely by auth — meaning a +// caller intending to set up a non-default profile would unknowingly +// clobber the default profile's secret. With --force, the clobber was +// silent (the overwrite guard fired against the wrong profile name and +// then --force bypassed it). +func TestAuth_ProfileFlag_TargetsNamedProfile(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv("URLBOX_API_SECRET", "") + cmd.SetStdinTTYForTest(false) + cmd.SetStderrTTYForTest(false) + t.Cleanup(cmd.ResetStdinTTYForTest) + t.Cleanup(cmd.ResetStderrTTYForTest) + + // Seed two profiles so default_profile != target. `config profile create` + // makes the FIRST created profile the default, so create `default` first + // to anchor it, then create `staging` as the non-default target. + var stdout, stderr bytes.Buffer + if exit := cmd.Execute([]string{"config", "profile", "create", "default", "--api-secret", "sec_default_seed12"}, &stdout, &stderr); exit != 0 { + t.Fatalf("seed default exit=%d stderr=%s", exit, stderr.String()) + } + stdout.Reset() + stderr.Reset() + if exit := cmd.Execute([]string{"config", "profile", "create", "staging"}, &stdout, &stderr); exit != 0 { + t.Fatalf("seed staging exit=%d stderr=%s", exit, stderr.String()) + } + + stdout.Reset() + stderr.Reset() + exit := cmd.Execute([]string{"--profile", "staging", "auth", "--api-secret", "sec_staging_xxxxxxx", "--output-format", "json"}, &stdout, &stderr) + if exit != 0 { + t.Fatalf("exit=%d, want 0; stderr=%s; stdout=%s", exit, stderr.String(), stdout.String()) + } + var env map[string]any + _ = json.Unmarshal(stdout.Bytes(), &env) + data, _ := env["data"].(map[string]any) + if data["profile"] != "staging" { + t.Errorf("envelope profile=%v, want staging", data["profile"]) + } + + c, _ := config.Load() + if c.Profiles["staging"].APISecret != "sec_staging_xxxxxxx" { + t.Errorf("staging.APISecret=%q, want sec_staging_xxxxxxx", c.Profiles["staging"].APISecret) + } + // default profile must NOT have been touched. + if c.Profiles["default"].APISecret != "sec_default_seed12" { + t.Errorf("default.APISecret was unexpectedly mutated: %q", c.Profiles["default"].APISecret) + } +} + +// TestAuth_ProfileFlag_UnknownProfile_Errors pins Round 4 C1 + Round 7 EE: +// an unknown --profile name must error rather than silently write to +// default. Round 4 closed the silent-clobber (errored with ErrUsage); +// Round 7 EE aligns the envelope shape to ErrNotFound exit 5 with +// command="auth" — same as profile delete/default and config.Resolve. +// Every "user named a profile that doesn't exist" site in the CLI now +// returns the same envelope. +func TestAuth_ProfileFlag_UnknownProfile_Errors(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv("URLBOX_API_SECRET", "") + cmd.SetStdinTTYForTest(false) + cmd.SetStderrTTYForTest(false) + t.Cleanup(cmd.ResetStdinTTYForTest) + t.Cleanup(cmd.ResetStderrTTYForTest) + + // Seed a real default profile so we can prove --profile bogus didn't clobber it. + var stdout, stderr bytes.Buffer + if exit := cmd.Execute([]string{"auth", "--api-secret", "sec_default_xxxxx"}, &stdout, &stderr); exit != 0 { + t.Fatalf("seed exit=%d stderr=%s", exit, stderr.String()) + } + + stdout.Reset() + stderr.Reset() + // Even with --force, an unknown profile name must error rather than + // silently overwrite default. This closes the Round 4 adversarial repro. + exit := cmd.Execute([]string{"--profile", "NONEXISTENT", "auth", "--api-secret", "sec_attacker_yy", "--force", "--output-format", "json"}, &stdout, &stderr) + if exit != 5 { + t.Fatalf("--profile NONEXISTENT should exit 5 (not_found); got exit=%d stdout=%s", exit, stdout.String()) + } + var env map[string]any + _ = json.Unmarshal(stdout.Bytes(), &env) + if env["code"] != "not_found" { + t.Errorf("code=%v, want not_found", env["code"]) + } + if !strings.Contains(env["error"].(string), "NONEXISTENT") { + t.Errorf("error should name the rejected profile; got %q", env["error"]) + } + if env["command"] != "auth" { + t.Errorf("command=%v, want auth", env["command"]) + } + + // Verify default profile was NOT clobbered — this is the load-bearing assertion. + c, _ := config.Load() + if c.Profiles["default"].APISecret != "sec_default_xxxxx" { + t.Errorf("default.APISecret was clobbered by bogus --profile: %q", c.Profiles["default"].APISecret) + } +} + +// TestAuth_EnvProfile_TargetsNamedProfile pins parallel behavior for the +// URLBOX_PROFILE env var. +func TestAuth_EnvProfile_TargetsNamedProfile(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv("URLBOX_API_SECRET", "") + cmd.SetStdinTTYForTest(false) + cmd.SetStderrTTYForTest(false) + t.Cleanup(cmd.ResetStdinTTYForTest) + t.Cleanup(cmd.ResetStderrTTYForTest) + + // Seed two profiles so default != target, then point URLBOX_PROFILE at + // the non-default one and verify auth respects it. + var stdout, stderr bytes.Buffer + if exit := cmd.Execute([]string{"config", "profile", "create", "default", "--api-secret", "sec_default_seed12"}, &stdout, &stderr); exit != 0 { + t.Fatalf("seed default exit=%d stderr=%s", exit, stderr.String()) + } + stdout.Reset() + stderr.Reset() + if exit := cmd.Execute([]string{"config", "profile", "create", "prod"}, &stdout, &stderr); exit != 0 { + t.Fatalf("seed prod exit=%d stderr=%s", exit, stderr.String()) + } + + t.Setenv("URLBOX_PROFILE", "prod") + stdout.Reset() + stderr.Reset() + exit := cmd.Execute([]string{"auth", "--api-secret", "sec_prod_zzzzzzzz"}, &stdout, &stderr) + if exit != 0 { + t.Fatalf("exit=%d stderr=%s", exit, stderr.String()) + } + c, _ := config.Load() + if c.Profiles["prod"].APISecret != "sec_prod_zzzzzzzz" { + t.Errorf("prod.APISecret=%q, want sec_prod_zzzzzzzz", c.Profiles["prod"].APISecret) + } + if c.Profiles["default"].APISecret != "sec_default_seed12" { + t.Errorf("default.APISecret unexpectedly mutated: %q", c.Profiles["default"].APISecret) + } +} + +// TestAuth_APISecretFlag_EmptyValue_Errors pins Round 4 M3: explicit +// `--api-secret ""` previously fell through silently to env/profile, +// which is the worst option for a user trying to test "what happens +// with no auth?". Now it errors loudly. +func TestAuth_APISecretFlag_EmptyValue_Errors(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv("URLBOX_API_SECRET", "") + cmd.SetStdinTTYForTest(false) + cmd.SetStderrTTYForTest(false) + t.Cleanup(cmd.ResetStdinTTYForTest) + t.Cleanup(cmd.ResetStderrTTYForTest) + + var stdout, stderr bytes.Buffer + exit := cmd.Execute([]string{"auth", "--api-secret", "", "--output-format", "json"}, &stdout, &stderr) + if exit == 0 { + t.Fatalf("--api-secret \"\" should error; got exit 0, stdout=%s", stdout.String()) + } + var env map[string]any + _ = json.Unmarshal(stdout.Bytes(), &env) + if env["code"] != "usage" { + t.Errorf("code=%v, want usage", env["code"]) + } + if !strings.Contains(env["error"].(string), "empty") { + t.Errorf("error should mention empty; got %q", env["error"]) + } +} diff --git a/internal/cmd/config.go b/internal/cmd/config.go index 3680c53..1bdbdd7 100644 --- a/internal/cmd/config.go +++ b/internal/cmd/config.go @@ -257,7 +257,7 @@ func newProfileDeleteCmd() *cobra.Command { return output.NewCLIError( output.ErrConflict, `Cannot delete the only profile "`+name+`"`, - "Create another profile first, or run `urlbox login` to start fresh.", + "Create another profile first, or run `urlbox login` / `urlbox auth --api-secret ` to start fresh.", ) } if name == cfg.DefaultProfile { @@ -409,7 +409,7 @@ profile count.`, return output.NewCLIError( output.ErrUsage, "No profiles configured", - "Run `urlbox login` to create one.", + credentialHint, ) } if _, ok := c.Profiles[val]; !ok { @@ -507,7 +507,7 @@ func resolveTargetProfile(cmd *cobra.Command, c *config.Config) (string, error) return "", output.NewCLIError( output.ErrUsage, "No profiles configured", - "Run `urlbox login` to create one.", + credentialHint, ) } flagProfile, _ := cmd.Root().PersistentFlags().GetString("profile") diff --git a/internal/cmd/config_test.go b/internal/cmd/config_test.go index eb92260..c688f75 100644 --- a/internal/cmd/config_test.go +++ b/internal/cmd/config_test.go @@ -121,8 +121,14 @@ func TestConfigSet_NoProfiles_Errors(t *testing.T) { if env["error"] != "No profiles configured" { t.Errorf("error=%v", env["error"]) } - if got, want := env["hint"], "Run `urlbox login` to create one."; got != want { - t.Errorf("hint=%v want=%v", got, want) + // The hint must name BOTH onboarding paths. `login` needs a browser, so a + // headless box pointed only at `login` has nowhere to go; `auth` is the + // route that works there. + hint, _ := env["hint"].(string) + for _, want := range []string{"urlbox login", "urlbox auth"} { + if !strings.Contains(hint, want) { + t.Errorf("hint must mention %q for the headless path; got %q", want, hint) + } } } diff --git a/internal/cmd/error_hints_test.go b/internal/cmd/error_hints_test.go index 9eae025..e87be27 100644 --- a/internal/cmd/error_hints_test.go +++ b/internal/cmd/error_hints_test.go @@ -91,11 +91,12 @@ func TestNoEmptyCLIErrorHints(t *testing.T) { // // - "urlbox config show" — no such subcommand. Closest real: `config get`, // `config path`, `config profile list`. -// - "urlbox auth" — the auth command was removed in favour of `urlbox -// login`; no production hint may point users at it. +// - "urlbox auth --api-key" — flag was removed in v0.6.0; auth takes +// `--api-secret`. Pinned removed in auth_test.go. (The `auth` command +// itself stays: it is the headless bootstrap path `login` cannot serve.) var ghostCommandSubstrings = []string{ "urlbox config show", - "urlbox auth", + "urlbox auth --api-key", } // TestNoGhostCommandsInHints walks production .go files and fails when diff --git a/internal/cmd/login_hint.go b/internal/cmd/login_hint.go index 52c4ea1..e773458 100644 --- a/internal/cmd/login_hint.go +++ b/internal/cmd/login_hint.go @@ -1,6 +1,12 @@ package cmd const ( - loginHint = "Run `urlbox login` to sign in." + loginHint = "Run `urlbox login` to sign in." + + // credentialHint covers both onboarding paths. The device flow needs a + // browser, so anything that only wants a render credential must also + // point at the headless route — otherwise CI, agents, and any + // browserless box are told to run a command that cannot work there. + credentialHint = "Run `urlbox login` to sign in, or for headless/CI use `urlbox auth --api-secret ` (also --api-secret-stdin / --api-secret-file) or set URLBOX_API_SECRET. Get your secret from https://urlbox.com/dashboard/projects." //nolint:gosec // user-facing help text naming flags, not a credential notLoggedInMsg = "not logged in — run `urlbox login`" ) diff --git a/internal/cmd/root.go b/internal/cmd/root.go index a011811..cbe68d6 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -225,6 +225,7 @@ func newRootCmd(stdout, stderr io.Writer) *cobra.Command { cmd.AddCommand(newUpgradeCmd(stdout, stderr)) cmd.AddCommand(newCommandsCmd(stdout, stderr)) cmd.AddCommand(newSurfaceCmd(cmd)) + cmd.AddCommand(newAuthCmd()) cmd.AddCommand(newConfigCmd()) cmd.AddCommand(newDashboardCmd()) cmd.AddCommand(newDoctorCmd()) diff --git a/internal/cmd/root_test.go b/internal/cmd/root_test.go index a73d153..19ac720 100644 --- a/internal/cmd/root_test.go +++ b/internal/cmd/root_test.go @@ -67,16 +67,22 @@ func TestRootCommand_UnknownSubcommand(t *testing.T) { } } -func TestNoAuthCommandRemains(t *testing.T) { +// TestAuthCommandStillRegistered guards the headless bootstrap path. +// `urlbox login` needs a browser, so `urlbox auth` remains the only +// non-interactive way to write a secret into a fresh config on a machine +// that has never been logged in. Removing it regresses CI and agent setup. +func TestAuthCommandStillRegistered(t *testing.T) { stdout := &bytes.Buffer{} stderr := &bytes.Buffer{} code := cmd.Execute([]string{"auth"}, stdout, stderr) - if code == 0 { - t.Fatal("`urlbox auth` must be an unknown command after removal") - } out := stdout.String() - if !strings.Contains(out, "unknown") || !strings.Contains(out, "auth") { - t.Errorf("expected unknown-command error for `auth`, got %q", out) + if strings.Contains(out, "unknown command") { + t.Fatalf("`urlbox auth` must stay registered as the headless bootstrap path; got %q", out) + } + // No secret supplied and no TTY, so it fails on input — not on the + // command being missing. Exit code is usage, not the unknown-command path. + if code == 0 { + t.Errorf("expected a usage failure with no secret supplied, got exit 0: %q", out) } } diff --git a/internal/cmd/secret_input.go b/internal/cmd/secret_input.go index 16eedaa..3a9d079 100644 --- a/internal/cmd/secret_input.go +++ b/internal/cmd/secret_input.go @@ -117,7 +117,7 @@ func resolveAPISecretInput(stdin io.Reader, stderr io.Writer, direct string, dir return "", output.NewCLIError( output.ErrUsage, "--api-secret-stdin received no secret on stdin", - "Pipe the secret on stdin, e.g. `printf %s \"$URLBOX_API_SECRET\" | urlbox config profile create default --api-secret-stdin`.", + "Pipe the secret on stdin, e.g. `printf %s \"$URLBOX_API_SECRET\" | urlbox auth --api-secret-stdin`.", ) } return s, nil diff --git a/internal/config/config.go b/internal/config/config.go index 9c9d33e..4715c41 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -83,7 +83,7 @@ func LoadOrCLIError() (*Config, *output.CLIError) { return nil, output.NewCLIError( output.ErrUsage, "config file is malformed JSON: "+err.Error(), - "Edit "+Path()+" to fix the JSON, or remove the file and run `urlbox login` to recreate.", + "Edit "+Path()+" to fix the JSON, or remove the file and run `urlbox login` (or `urlbox auth --api-secret ` on a headless box) to recreate.", ) } // Everything else (filesystem I/O, encoding, etc.) — still local, so diff --git a/npm/README.md b/npm/README.md index 2416549..d97fdca 100644 --- a/npm/README.md +++ b/npm/README.md @@ -18,7 +18,7 @@ urlbox login urlbox render https://example.com --output home.png ``` -In CI, set `URLBOX_API_SECRET` (your project's secret, `ubx_sk_…`, from the [dashboard](https://urlbox.com/dashboard/projects)) and skip `login`. +In CI, skip `login` — it needs a browser. Either set `URLBOX_API_SECRET` (your project's secret, `ubx_sk_…`, from the [dashboard](https://urlbox.com/dashboard/projects)), or persist it once with `printf %s "$URLBOX_API_SECRET" | urlbox auth --api-secret-stdin`. Every command speaks JSON when piped, supports a built-in `--jq ` filter, and returns a clear exit code. diff --git a/skills/SKILL.md b/skills/SKILL.md index 6a2ec0c..56bcd57 100644 --- a/skills/SKILL.md +++ b/skills/SKILL.md @@ -163,7 +163,8 @@ documents the well-known options, but the API accepts more. | `urlbox dashboard` | Open the Urlbox dashboard in the user's browser | | `urlbox doctor` | Diagnose install, config, network, credentials | | `urlbox link` | Generate an HMAC-signed render URL with no API call | -| `urlbox login` | Browser sign-in (agents: use `URLBOX_API_SECRET` instead) | +| `urlbox auth` | Save API secret headlessly (`--api-secret`/`-stdin`/`-file`) | +| `urlbox login` | Browser sign-in (agents: use `urlbox auth` or `URLBOX_API_SECRET`) | | `urlbox logout` | Revoke and clear this device's saved session | | `urlbox whoami` / `urlbox me` | Show the signed-in account, org, and active project | | `urlbox orgs list\|select` | List organisations or switch the active one | @@ -292,7 +293,7 @@ the render likely captured a captcha page rather than the target content. |--------------|------|------------------------------------------------------------| | `usage` | 1 | bad flags / missing url | | `validation` | 2 | payload failed schema validation; see `hint` for the fix | -| `auth` | 3 | missing/invalid credentials; run `urlbox login` (CI: set `URLBOX_API_SECRET`) | +| `auth` | 3 | missing/invalid credentials; run `urlbox login`, or `urlbox auth` / `URLBOX_API_SECRET` when headless | | `forbidden` | 4 | account/plan doesn't allow this feature | | `not_found` | 5 | endpoint or render ID unknown | | `rate_limit` | 6 | retry budget exhausted; back off and retry | @@ -496,7 +497,7 @@ on a profile directly: `urlbox --profile config set api_host https://...` `config set` and `config get` adapt to the profile count: -- **0 profiles:** `config set` errors with "No profiles configured" — run `urlbox login` to bootstrap. +- **0 profiles:** `config set` errors with "No profiles configured" — run `urlbox login` (browser) or `urlbox auth --api-secret ` (headless) to bootstrap. - **1 profile:** `--profile` is implicit; `urlbox config set api_secret sk_xxx` Just Works. - **2+ profiles:** `--profile` is required; the error lists configured names. @@ -516,19 +517,25 @@ order of secret-hygiene; prefer the higher items. URLBOX_API_SECRET= urlbox render # B — pipe on stdin (no argv leak, no shell-history exposure) -printf %s "$URLBOX_API_SECRET" | urlbox config profile create default --api-secret-stdin +printf %s "$URLBOX_API_SECRET" | urlbox auth --api-secret-stdin # C — read from a file (handy when the secret already lives on disk) -urlbox config profile create default --api-secret-file /run/secrets/urlbox +urlbox auth --api-secret-file /run/secrets/urlbox # D — argv flag (leaks into `ps` and shell history; emits a TTY warning) -urlbox config profile create default --api-secret +urlbox auth --api-secret urlbox doctor --output-format json # JSON envelope: ok/not-ok ``` +`urlbox auth` is the headless bootstrap: it writes the secret into the +config file (mode 0600), creating a profile if none exists. Use it when +there is no browser — `urlbox login` cannot run in CI, in a container, +or under an agent. `urlbox config profile create --api-secret*` +does the same for a *named* profile. + `--api-secret-stdin` and `--api-secret-file` are accepted by every -command that takes `--api-secret` (render, status, link, +command that takes `--api-secret` (auth, render, status, link, config profile create, and the render aliases screenshot/pdf/video). Mutually exclusive — pass at most one. From 594fd968952456531a05dd7c741eae5faec49f3f Mon Sep 17 00:00:00 2001 From: gdameneses Date: Wed, 19 Aug 2026 13:39:16 +0100 Subject: [PATCH 4/8] fix(doctor): credential-only setups warn instead of fail; single-attempt session probe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `doctor` exited 3 on a perfectly healthy machine. The session, active_org and active_project checks read profile fields that only `login` writes, so any setup authenticating with a render credential — URLBOX_API_SECRET, a stored profile, the repo overlay — failed three checks even with a valid, working credential and render_credential reporting ok. Verified against a local API: main exits 0 in that configuration, this branch exited 3, which retired `doctor` as a CI health gate. When a credential resolves, those three checks now report warn and explain themselves; overall status returns to ok. With no credentials at all the behaviour is unchanged — still fail, still exit 3. Also drops the session probe to a single attempt. Every other check here probes once; retrying four times with backoff only made `doctor` hang ~7s against an unreachable host without changing the diagnosis. --- CHANGELOG.md | 20 +++++++++-------- internal/cmd/doctor.go | 51 ++++++++++++++++++++++++++++++++++++------ 2 files changed, 55 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dd92389..21982cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,19 +11,21 @@ the browser and stores the session and the active project's render credentials. Adds org and project switching, project CRUD, usage, and management of the organisation's storage, proxy, and LLM credentials. -### Breaking +### Changed -- **`urlbox auth` is removed.** Interactive setup is `urlbox login`. - CI and headless environments are unchanged: set `URLBOX_API_SECRET` - and every render command works exactly as before. Scripts that - called `urlbox auth --api-secret*` should write the profile with - `urlbox config set api_secret` (same flags, same masking) or move - to the env var. +- **`urlbox login` is the new interactive path; `urlbox auth` stays.** + The device flow needs a browser, so `auth` remains the way to write a + secret on a headless box, in CI, or from an agent — unchanged, with + `--api-secret`, `--api-secret-stdin`, and `--api-secret-file` all + behaving exactly as before. Nothing is removed from the surface: this + release is additive. - **`urlbox doctor` now checks the session world** — nine checks including session validity, active org/project, and render-credential validity (the old `auth` check folded into `render_credential`). - A machine that authenticates only via `URLBOX_API_SECRET` fails the - three session checks and exits 3. + On a machine that authenticates with a render credential only + (`URLBOX_API_SECRET`, a stored profile, or the repo overlay) the three + session checks report `warn`, not `fail`, and `doctor` still exits 0 — + a CI box that never logged in is a supported setup, not a broken one. ### Added diff --git a/internal/cmd/doctor.go b/internal/cmd/doctor.go index 5ec2d77..cd255cd 100644 --- a/internal/cmd/doctor.go +++ b/internal/cmd/doctor.go @@ -221,13 +221,23 @@ func runDoctorChecks(ctx context.Context, resolved *config.Resolved, profile *co if resolved != nil && resolved.APIHost != "" { host = resolved.APIHost } + // A render credential resolved from a flag, URLBOX_API_SECRET, the repo + // overlay, or a stored profile is a complete, supported setup: it is the + // documented CI/headless path and every render command works on it. The + // session checks below still report what they find, but they downgrade + // to "warn" in that case — a machine that never logged in is not broken, + // it just isn't using the browser flow. Without this, `urlbox doctor` + // exits 3 on a perfectly healthy CI box and stops being usable as a + // health gate. + credentialOnly := resolved != nil && resolved.APISecret != "" + return []Check{ checkVersion(), checkInstallMethod(), checkConfigFile(), - checkSession(ctx, host, profile), - checkActiveOrg(profile), - checkActiveProject(profile), + checkSession(ctx, host, profile, credentialOnly), + checkActiveOrg(profile, credentialOnly), + checkActiveProject(profile, credentialOnly), checkRenderCredential(ctx, host, resolved), checkDNS(ctx, host), checkAPIReachable(ctx, host), @@ -268,8 +278,16 @@ func checkConfigFile() Check { } } -func checkSession(ctx context.Context, host string, profile *config.Profile) Check { +func checkSession(ctx context.Context, host string, profile *config.Profile, credentialOnly bool) Check { if profile.SessionToken == "" { + if credentialOnly { + return Check{ + Name: "session", + Status: "warn", + Message: "not logged in (using a render credential)", + Hint: "Optional: `urlbox login` adds account management (orgs, projects, usage).", + } + } return Check{ Name: "session", Status: "fail", @@ -277,7 +295,12 @@ func checkSession(ctx context.Context, host string, profile *config.Profile) Che Hint: loginHint, } } + // One attempt, like every other check here. checkRenderCredential, + // checkAPIReachable, and checkDNS all probe once; a session probe that + // retries 4x with backoff just makes `doctor` hang for ~7s on an + // unreachable host without changing the diagnosis. client := api.NewSessionClient(host, profile.SessionToken) + client.SetRetryConfig(api.NoRetryConfig()) var session sessionResponse if err := client.GetJSON(ctx, "/v1/auth/get-session", &session); err != nil { return Check{ @@ -298,8 +321,15 @@ func checkSession(ctx context.Context, host string, profile *config.Profile) Che return Check{Name: "session", Status: "ok", Message: "signed in as " + session.User.Email} } -func checkActiveOrg(profile *config.Profile) Check { +func checkActiveOrg(profile *config.Profile, credentialOnly bool) Check { if profile.ActiveOrg == "" { + if credentialOnly { + return Check{ + Name: "active_org", + Status: "warn", + Message: "none (not needed for rendering)", + } + } return Check{ Name: "active_org", Status: "fail", @@ -310,8 +340,15 @@ func checkActiveOrg(profile *config.Profile) Check { return Check{Name: "active_org", Status: "ok", Message: profile.ActiveOrg} } -func checkActiveProject(profile *config.Profile) Check { +func checkActiveProject(profile *config.Profile, credentialOnly bool) Check { if profile.ActiveProject == "" { + if credentialOnly { + return Check{ + Name: "active_project", + Status: "warn", + Message: "none (not needed for rendering)", + } + } return Check{ Name: "active_project", Status: "fail", @@ -328,7 +365,7 @@ func checkRenderCredential(ctx context.Context, host string, resolved *config.Re Name: "render_credential", Status: "fail", Message: "no render credential", - Hint: loginHint + " CI and headless environments can set URLBOX_API_SECRET instead.", + Hint: credentialHint, } } src := resolved.Source.APISecret From 9c0867e3c0b19c0c2bbb57b3260c975a5de3fee6 Mon Sep 17 00:00:00 2001 From: gdameneses Date: Wed, 19 Aug 2026 13:39:50 +0100 Subject: [PATCH 5/8] =?UTF-8?q?test(session):=20no-op=20retry=20backoff=20?= =?UTF-8?q?in=20tests=20=E2=80=94=20internal/cmd=2040s=20->=208s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The session retry tests assert how many attempts were made, never how long they took, but they slept the real 1s/2s/4s budget: internal/cmd went from 6s on main to 40s here, and `make test` adds -race on top. RetryConfig.Sleep was already injectable. sessionRetrySleep threads a no-op through the one session-client construction site, installed by TestMain in the test binary only — nil in production, so real backoff is untouched, and scoped to session clients so render and status keep their own sleep. Every assertion is unchanged. --- internal/cmd/main_test.go | 18 ++++++++++++++++++ internal/cmd/session_helpers.go | 11 +++++++++++ 2 files changed, 29 insertions(+) create mode 100644 internal/cmd/main_test.go diff --git a/internal/cmd/main_test.go b/internal/cmd/main_test.go new file mode 100644 index 0000000..cc4f0e4 --- /dev/null +++ b/internal/cmd/main_test.go @@ -0,0 +1,18 @@ +package cmd + +import ( + "os" + "testing" + "time" +) + +// TestMain disables the session-client retry backoff for the whole package. +// +// The session retry tests assert how many attempts were made, never how long +// they took, so the real 1s/2s/4s budget bought nothing but wall-clock: the +// package went from ~6s to ~40s. Only session clients are affected — the +// render and status retry paths keep their production sleep. +func TestMain(m *testing.M) { + sessionRetrySleep = func(time.Duration) {} + os.Exit(m.Run()) +} diff --git a/internal/cmd/session_helpers.go b/internal/cmd/session_helpers.go index 13b0535..4232bd6 100644 --- a/internal/cmd/session_helpers.go +++ b/internal/cmd/session_helpers.go @@ -3,6 +3,7 @@ package cmd import ( "errors" "os" + "time" "github.com/spf13/cobra" @@ -40,9 +41,19 @@ func sessionRetryConfig(cmd *cobra.Command) api.RetryConfig { if maxRetries, err := cmd.Flags().GetInt("max-retries"); err == nil { cfg.MaxRetries = maxRetries } + if sessionRetrySleep != nil { + cfg.Sleep = sessionRetrySleep + } return cfg } +// sessionRetrySleep replaces the retry backoff sleep for session clients when +// non-nil. Production leaves it nil, so api.DefaultRetryConfig's time.Sleep +// stands and real backoff is unchanged. The test binary installs a no-op in +// TestMain: the session retry tests assert attempt *counts*, never durations, +// so sleeping the real 1s/2s/4s budget only added wall-clock to CI. +var sessionRetrySleep func(time.Duration) + // newSessionClient is the one construction site for session clients: it wires // the retry policy from cmd's flags into the client. All session commands route // through here (directly or via loadSession) so the flags take effect uniformly. From 0957458f1bb6b9d04971c8b29914716112ab180b Mon Sep 17 00:00:00 2001 From: gdameneses Date: Wed, 19 Aug 2026 14:18:15 +0100 Subject: [PATCH 6/8] feat(storage,proxies,llm,projects): mask credentials in JSON, not only in text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Masking was a text-mode affordance: `storage show` printed `sup3…3t` while the same command with stdout on a pipe returned `"secret":"sup3rs3cr3t"` in full. Because the CLI resolves a non-TTY stdout to JSON, that made the unmasked form the default for exactly the consumers that persist it — agents, CI logs, `| tee` transcripts — and left `--reveal` as a no-op there, a flag that reads like a protection while protecting nothing. JSON now masks the same fields the KV views mask: storage key/secret/sasToken, every LLM provider secret (apiKey, the three AWS fields, the GCP service account), the password component of a proxy URL, and a project's webhookKey. `--reveal` unhides both surfaces from one switch, and joins `list` — previously show-only, though `proxies list` and `storage list` carry the same material. Non-secret fields pass through byte-for-byte, so `--jq` over ids, names, buckets and regions is unaffected; only the named fields change, and only when they hold a non-empty string, so a null stays null instead of becoming "***". The masked copy is always fresh: the KV builders mask the raw response themselves, and redacting in place would double-mask what a human sees. Proxy masking keeps scheme, host and port legible and replaces only the password, so pools stay tellable apart without exposing the credential. TestProjectsShow_JSON_ByteIdenticalToServerResponse pinned the old contract; it becomes …PassesServerResponseThroughWithSecretsMasked, still asserting the raw passthrough for every non-secret field and now asserting --reveal restores the response verbatim. --- README.md | 2 +- internal/cmd/llm.go | 12 ++- internal/cmd/projects.go | 2 +- internal/cmd/projects_show_test.go | 79 +++++++++------ internal/cmd/proxies.go | 18 ++-- internal/cmd/redact.go | 108 +++++++++++++++++++++ internal/cmd/redact_test.go | 150 +++++++++++++++++++++++++++++ internal/cmd/storage.go | 12 ++- skills/SKILL.md | 12 ++- 9 files changed, 347 insertions(+), 48 deletions(-) create mode 100644 internal/cmd/redact.go create mode 100644 internal/cmd/redact_test.go diff --git a/README.md b/README.md index fdf13bd..bd4209f 100644 --- a/README.md +++ b/README.md @@ -154,7 +154,7 @@ urlbox projects storage assign my-project prod | `llm` | `list` `show` `create` `update` `delete` `test` `models` | | `projects assign` / `unassign` | Attach or detach a project's `storage`, `proxy`, or `llm` credential | -Secrets are masked in every human-readable view — pass `--reveal` to unmask (JSON output always shows them in full). Deletes are retype-to-confirm; `--yes` skips the prompt. A target resolves by name or id (`store_…`, `pool_…`, `llm_…`). +Secrets are masked by default in both text and JSON — pass `--reveal` on `list` or `show` to unmask. Deletes are retype-to-confirm; `--yes` skips the prompt. A target resolves by name or id (`store_…`, `pool_…`, `llm_…`). ### Utilities diff --git a/internal/cmd/llm.go b/internal/cmd/llm.go index 71e9970..77ed45d 100644 --- a/internal/cmd/llm.go +++ b/internal/cmd/llm.go @@ -32,12 +32,16 @@ Examples: urlbox llm models openai urlbox llm delete openai`, } + var listReveal bool list := &cobra.Command{ Use: "list", Short: "List the organisation's LLM credentials", Args: cobra.NoArgs, - RunE: runLlmList, + RunE: func(cmd *cobra.Command, args []string) error { + return runLlmList(cmd, args, listReveal) + }, } + list.Flags().BoolVar(&listReveal, "reveal", false, "Print secrets unmasked (default: masked)") var showReveal bool show := &cobra.Command{ Use: "show ", @@ -174,7 +178,7 @@ func llmTestMessage(result map[string]any) (string, bool) { return "Connection failed", false } -func runLlmList(cmd *cobra.Command, _ []string) error { +func runLlmList(cmd *cobra.Command, _ []string, reveal bool) error { sess, cliErr := loadSession(cmd) if cliErr != nil { return cliErr @@ -189,7 +193,7 @@ func runLlmList(cmd *cobra.Command, _ []string) error { return asCLIError(err) } env := output.NewEnvelope("llm list", - map[string]any{"llmCredentials": items}, + map[string]any{"llmCredentials": redactMaps(items, llmSecretFields, reveal)}, fmt.Sprintf("%d LLM credentials", len(items)), nil) env.SetTable([]string{"ID", "NAME", "PROVIDER", "MODEL", "ASSIGNED"}, llmListRows(items), -1) return writeEnvelopeWithQuietData(cmd, env, strconv.Itoa(len(items))) @@ -221,7 +225,7 @@ func runLlmShow(cmd *cobra.Command, args []string, reveal bool) error { if name == "" { name = valueOrEmpty(detail["id"]) } - env := output.NewEnvelope("llm show", detail, + env := output.NewEnvelope("llm show", redactMap(detail, llmSecretFields, reveal), fmt.Sprintf("LLM credential %s", name), nil) env.SetKV(llmDetailPairs(detail, reveal)) return writeEnvelopeWithQuietData(cmd, env, valueOrEmpty(detail["id"])) diff --git a/internal/cmd/projects.go b/internal/cmd/projects.go index 8733332..b4739af 100644 --- a/internal/cmd/projects.go +++ b/internal/cmd/projects.go @@ -486,7 +486,7 @@ func runProjectsShow(cmd *cobra.Command, args []string, reveal bool) error { if name == "" { name = valueOrEmpty(resp["id"]) } - env := output.NewEnvelope("projects show", resp, + env := output.NewEnvelope("projects show", redactMap(resp, projectSecretFields, reveal), fmt.Sprintf("Project %s", name), nil) env.SetKV(projectDetailPairs(resp, reveal)) return writeEnvelope(cmd, env) diff --git a/internal/cmd/projects_show_test.go b/internal/cmd/projects_show_test.go index f083ece..9fbcf18 100644 --- a/internal/cmd/projects_show_test.go +++ b/internal/cmd/projects_show_test.go @@ -97,41 +97,66 @@ func TestProjectsShow_Reveal_ShowsFullWebhookKey(t *testing.T) { } } -func TestProjectsShow_JSON_ByteIdenticalToServerResponse(t *testing.T) { - dir := t.TempDir() - writeCompatConfig(t, dir, true) - t.Setenv("XDG_CONFIG_HOME", dir) - srv := apitest.New( - apitest.SuccessJSON(`{"projects":[{"id":"proj_o5xpfa7z94","name":"plan1-manual-check"}]}`), - apitest.SuccessJSON(projectShowDetailJSON), - ) - t.Cleanup(srv.Close) - t.Setenv("URLBOX_API_HOST", srv.URL()) - - var stdout, stderr bytes.Buffer - code := Execute([]string{"projects", "show", "plan1-manual-check", "--output-format", "json"}, &stdout, &stderr) - if code != 0 { - t.Fatalf("exit %d\n%s\n%s", code, stdout.String(), stderr.String()) - } - var env struct { - Data json.RawMessage `json:"data"` +func TestProjectsShow_JSON_PassesServerResponseThroughWithSecretsMasked(t *testing.T) { + show := func(t *testing.T, args ...string) json.RawMessage { + t.Helper() + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New( + apitest.SuccessJSON(`{"projects":[{"id":"proj_o5xpfa7z94","name":"plan1-manual-check"}]}`), + apitest.SuccessJSON(projectShowDetailJSON), + ) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + var stdout, stderr bytes.Buffer + if code := Execute(args, &stdout, &stderr); code != 0 { + t.Fatalf("exit %d\n%s\n%s", code, stdout.String(), stderr.String()) + } + var env struct { + Data json.RawMessage `json:"data"` + } + if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { + t.Fatalf("unmarshal envelope: %v\n%s", err, stdout.String()) + } + return env.Data } - if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { - t.Fatalf("unmarshal envelope: %v\n%s", err, stdout.String()) + + var want map[string]any + if err := json.Unmarshal([]byte(projectShowDetailJSON), &want); err != nil { + t.Fatalf("unmarshal fixture: %v", err) } - var got, want map[string]any - if err := json.Unmarshal(env.Data, &got); err != nil { + + // Default: every field is the raw server response except the credential + // material, which is masked the same way the text view masks it. + var got map[string]any + masked := show(t, "projects", "show", "plan1-manual-check", "--output-format", "json") + if err := json.Unmarshal(masked, &got); err != nil { t.Fatalf("unmarshal data: %v", err) } - if err := json.Unmarshal([]byte(projectShowDetailJSON), &want); err != nil { - t.Fatalf("unmarshal fixture: %v", err) + if got["webhookKey"] == want["webhookKey"] { + t.Errorf("webhookKey must be masked by default: %s", masked) } + delete(got, "webhookKey") + delete(want, "webhookKey") gb, _ := json.Marshal(got) wb, _ := json.Marshal(want) if !bytes.Equal(gb, wb) { - t.Errorf("JSON data must be the raw server response.\n got: %s\nwant: %s", gb, wb) + t.Errorf("non-secret fields must pass through untouched.\n got: %s\nwant: %s", gb, wb) + } + + // --reveal returns the response verbatim, webhook key included. + revealed := show(t, "projects", "show", "plan1-manual-check", "--reveal", "--output-format", "json") + var full, fixture map[string]any + if err := json.Unmarshal(revealed, &full); err != nil { + t.Fatalf("unmarshal revealed: %v", err) + } + if err := json.Unmarshal([]byte(projectShowDetailJSON), &fixture); err != nil { + t.Fatalf("unmarshal fixture: %v", err) } - if bytes.Contains(env.Data, []byte("ubx_…")) { - t.Errorf("JSON output must carry the verbatim webhook key, not the masked form:\n%s", env.Data) + fb, _ := json.Marshal(full) + xb, _ := json.Marshal(fixture) + if !bytes.Equal(fb, xb) { + t.Errorf("--reveal must return the raw server response.\n got: %s\nwant: %s", fb, xb) } } diff --git a/internal/cmd/proxies.go b/internal/cmd/proxies.go index 371f585..a07608f 100644 --- a/internal/cmd/proxies.go +++ b/internal/cmd/proxies.go @@ -21,9 +21,9 @@ func newProxiesCmd() *cobra.Command { Proxy pools are owned by the organisation and assigned to projects. Create one once, then assign it to any project's renders. -Proxy URLs routinely embed credentials, so the password portion is masked -on display — pass --reveal for full values (JSON output always includes them -in full). +Proxy URLs routinely embed credentials, so the password portion is masked in +both text and JSON output — pass --reveal for full values. The host and port +stay legible either way. Examples: urlbox proxies list @@ -32,12 +32,16 @@ Examples: urlbox proxies update eu --url http://user:pass@host:8080 urlbox proxies delete eu`, } + var listReveal bool list := &cobra.Command{ Use: "list", Short: "List the organisation's proxy pools", Args: cobra.NoArgs, - RunE: runProxiesList, + RunE: func(cmd *cobra.Command, args []string) error { + return runProxiesList(cmd, args, listReveal) + }, } + list.Flags().BoolVar(&listReveal, "reveal", false, "Print proxy URLs unmasked (default: passwords masked)") var showReveal bool show := &cobra.Command{ Use: "show ", @@ -132,7 +136,7 @@ func proxyDetailPairs(pool map[string]any, reveal bool) [][2]string { return pairs } -func runProxiesList(cmd *cobra.Command, _ []string) error { +func runProxiesList(cmd *cobra.Command, _ []string, reveal bool) error { sess, cliErr := loadSession(cmd) if cliErr != nil { return cliErr @@ -147,7 +151,7 @@ func runProxiesList(cmd *cobra.Command, _ []string) error { return asCLIError(err) } env := output.NewEnvelope("proxies list", - map[string]any{"proxies": items}, + map[string]any{"proxies": redactProxyPools(items, reveal)}, fmt.Sprintf("%d proxy pools", len(items)), nil) env.SetTable([]string{"ID", "NAME", "URLS", "ASSIGNED"}, proxyListRows(items), -1) return writeEnvelopeWithQuietData(cmd, env, strconv.Itoa(len(items))) @@ -179,7 +183,7 @@ func runProxiesShow(cmd *cobra.Command, args []string, reveal bool) error { if name == "" { name = valueOrEmpty(detail["id"]) } - env := output.NewEnvelope("proxies show", detail, + env := output.NewEnvelope("proxies show", redactProxyPool(detail, reveal), fmt.Sprintf("Proxy pool %s", name), nil) env.SetKV(proxyDetailPairs(detail, reveal)) return writeEnvelopeWithQuietData(cmd, env, valueOrEmpty(detail["id"])) diff --git a/internal/cmd/redact.go b/internal/cmd/redact.go new file mode 100644 index 0000000..943329d --- /dev/null +++ b/internal/cmd/redact.go @@ -0,0 +1,108 @@ +package cmd + +// Credential material is masked in the JSON envelope by default, not just in +// the text views. stdout on a pipe resolves to JSON without any flag, so JSON +// is what agents, CI logs and `| tee` transcripts capture — the places a +// long-lived storage secret or proxy password is hardest to recall once +// written. `--reveal` is the single switch that unhides both surfaces, matching +// `config get api_secret --reveal`. +// +// The masked copy is always a fresh map: the text KV builders take the raw +// response and apply their own masking, so redacting in place would double-mask +// and change what a human sees. + +// storageSecretFields are the storage-credential response fields that carry +// credential material. Mirrors what storageDetailPairs masks. +var storageSecretFields = []string{"key", "secret", "sasToken"} + +// llmSecretFields are the LLM-credential response fields that carry credential +// material, across every provider shape. Mirrors what llmDetailPairs masks. +var llmSecretFields = []string{ + "apiKey", + "awsAccessKeyId", + "awsSecretAccessKey", + "awsSessionToken", + "gcpServiceAccountJson", +} + +// projectSecretFields are the project response fields that carry credential +// material. Mirrors what projectDetailPairs masks. +var projectSecretFields = []string{"webhookKey"} + +// redactMap returns a copy of m with every named field masked. Fields that are +// absent, empty, or not strings are left exactly as they came off the wire, so +// a null stays null rather than becoming "***". +func redactMap(m map[string]any, fields []string, reveal bool) map[string]any { + if reveal || m == nil { + return m + } + out := make(map[string]any, len(m)) + for k, v := range m { + out[k] = v + } + for _, f := range fields { + if s := valueOrEmpty(out[f]); s != "" { + out[f] = maskSecret(s) + } + } + return out +} + +// redactMaps applies redactMap across a list, returning a new slice. +func redactMaps(items []map[string]any, fields []string, reveal bool) []map[string]any { + if reveal { + return items + } + out := make([]map[string]any, len(items)) + for i, m := range items { + out[i] = redactMap(m, fields, reveal) + } + return out +} + +// redactProxyPool returns a copy of a proxy pool with the password component of +// every entry URL masked. Only the password is touched — scheme, host and port +// stay legible so an operator can still tell pools apart. +func redactProxyPool(pool map[string]any, reveal bool) map[string]any { + if reveal || pool == nil { + return pool + } + out := make(map[string]any, len(pool)) + for k, v := range pool { + out[k] = v + } + entries, _ := pool["proxies"].([]any) + if entries == nil { + return out + } + masked := make([]any, 0, len(entries)) + for _, e := range entries { + entry, ok := e.(map[string]any) + if !ok { + masked = append(masked, e) + continue + } + copied := make(map[string]any, len(entry)) + for k, v := range entry { + copied[k] = v + } + if raw := valueOrEmpty(entry["url"]); raw != "" { + copied["url"] = maskProxyURL(raw, false) + } + masked = append(masked, copied) + } + out["proxies"] = masked + return out +} + +// redactProxyPools applies redactProxyPool across a list, returning a new slice. +func redactProxyPools(pools []map[string]any, reveal bool) []map[string]any { + if reveal { + return pools + } + out := make([]map[string]any, len(pools)) + for i, p := range pools { + out[i] = redactProxyPool(p, reveal) + } + return out +} diff --git a/internal/cmd/redact_test.go b/internal/cmd/redact_test.go new file mode 100644 index 0000000..1b4c93a --- /dev/null +++ b/internal/cmd/redact_test.go @@ -0,0 +1,150 @@ +package cmd + +import ( + "bytes" + "testing" + + "github.com/urlbox/urlbox-cli/internal/api/apitest" +) + +// The JSON envelope is what agents and pipelines read: `urlbox storage list` +// with stdout on a pipe resolves to JSON without any flag. These tests pin the +// rule that credential material is masked there by default, exactly as it is in +// the text views, and that --reveal is the single switch that unhides both. + +func TestStorageListJSONMasksSecretsByDefault(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New(apitest.SuccessJSON(storageListJSON)) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + var stdout, stderr bytes.Buffer + code := Execute([]string{"storage", "list", "--output-format", "json"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit %d\n%s\n%s", code, stdout.String(), stderr.String()) + } + for _, secret := range []string{"AKIAFAKEFAKEFAKE", "sk_fake_secret_value", "sv=fake"} { + if bytes.Contains(stdout.Bytes(), []byte(secret)) { + t.Fatalf("JSON list leaked %q: %s", secret, stdout.String()) + } + } + // Non-secret fields survive untouched. + for _, want := range []string{`"prod-bucket"`, `"store_1"`, `"us-east-1"`} { + if !bytes.Contains(stdout.Bytes(), []byte(want)) { + t.Fatalf("JSON list dropped %s: %s", want, stdout.String()) + } + } +} + +func TestStorageListJSONRevealUnhides(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New(apitest.SuccessJSON(storageListJSON)) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + var stdout, stderr bytes.Buffer + code := Execute([]string{"storage", "list", "--reveal", "--output-format", "json"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit %d\n%s\n%s", code, stdout.String(), stderr.String()) + } + for _, secret := range []string{"AKIAFAKEFAKEFAKE", "sk_fake_secret_value"} { + if !bytes.Contains(stdout.Bytes(), []byte(secret)) { + t.Fatalf("--reveal must show %q: %s", secret, stdout.String()) + } + } +} + +func TestStorageShowJSONMasksSecretsRevealUnhides(t *testing.T) { + run := func(t *testing.T, args ...string) string { + t.Helper() + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New( + apitest.SuccessJSON(storageListJSON), + apitest.SuccessJSON(storageOneJSON), + ) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + var stdout, stderr bytes.Buffer + if code := Execute(args, &stdout, &stderr); code != 0 { + t.Fatalf("exit %d\n%s\n%s", code, stdout.String(), stderr.String()) + } + return stdout.String() + } + masked := run(t, "storage", "show", "store_1", "--output-format", "json") + if bytes.Contains([]byte(masked), []byte("sk_fake_secret_value")) { + t.Fatalf("JSON show leaked the secret: %s", masked) + } + revealed := run(t, "storage", "show", "store_1", "--reveal", "--output-format", "json") + if !bytes.Contains([]byte(revealed), []byte("sk_fake_secret_value")) { + t.Fatalf("--reveal must show the secret: %s", revealed) + } +} + +func TestLLMJSONMasksEveryCredentialField(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + body := `{"llmCredentials":[{"id":"llm_1","name":"main","provider":"openai","model":"gpt-4o",` + + `"apiKey":"sk-leakme000000","awsAccessKeyId":"AKIALEAK0000","awsSecretAccessKey":"awssecretleak",` + + `"awsSessionToken":"sessleak","gcpServiceAccountJson":"{\"private_key\":\"gcpleak\"}","assignedProjectIds":[]}]}` + srv := apitest.New(apitest.SuccessJSON(body)) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + var stdout, stderr bytes.Buffer + code := Execute([]string{"llm", "list", "--output-format", "json"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit %d\n%s\n%s", code, stdout.String(), stderr.String()) + } + for _, secret := range []string{"sk-leakme000000", "AKIALEAK0000", "awssecretleak", "sessleak", "gcpleak"} { + if bytes.Contains(stdout.Bytes(), []byte(secret)) { + t.Fatalf("JSON llm list leaked %q: %s", secret, stdout.String()) + } + } +} + +func TestProxiesJSONMasksPasswordKeepsHost(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + body := `{"proxies":[{"id":"pool_1","name":"eu","assignedProjectIds":[],` + + `"proxies":[{"url":"http://user:hunter2@proxy.example.com:8080"}]}]}` + srv := apitest.New(apitest.SuccessJSON(body)) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + var stdout, stderr bytes.Buffer + code := Execute([]string{"proxies", "list", "--output-format", "json"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit %d\n%s\n%s", code, stdout.String(), stderr.String()) + } + if bytes.Contains(stdout.Bytes(), []byte("hunter2")) { + t.Fatalf("JSON proxies list leaked the password: %s", stdout.String()) + } + // The host stays legible — masking the password must not blind the operator. + if !bytes.Contains(stdout.Bytes(), []byte("proxy.example.com:8080")) { + t.Fatalf("JSON proxies list dropped the host: %s", stdout.String()) + } +} + +func TestProjectsShowJSONMasksWebhookKey(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New( + apitest.SuccessJSON(`{"projects":[{"id":"proj_1","name":"prod"}]}`), + apitest.SuccessJSON(`{"id":"proj_1","name":"prod","enabled":true,"webhookKey":"whk_leakme00000"}`), + ) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + var stdout, stderr bytes.Buffer + code := Execute([]string{"projects", "show", "proj_1", "--output-format", "json"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit %d\n%s\n%s", code, stdout.String(), stderr.String()) + } + if bytes.Contains(stdout.Bytes(), []byte("whk_leakme00000")) { + t.Fatalf("JSON projects show leaked the webhook key: %s", stdout.String()) + } +} diff --git a/internal/cmd/storage.go b/internal/cmd/storage.go index 0bfccb5..fbca99e 100644 --- a/internal/cmd/storage.go +++ b/internal/cmd/storage.go @@ -32,12 +32,16 @@ Examples: urlbox storage update prod --region eu-west-1 urlbox storage delete prod`, } + var listReveal bool list := &cobra.Command{ Use: "list", Short: "List the organisation's storage credentials", Args: cobra.NoArgs, - RunE: runStorageList, + RunE: func(cmd *cobra.Command, args []string) error { + return runStorageList(cmd, args, listReveal) + }, } + list.Flags().BoolVar(&listReveal, "reveal", false, "Print secrets unmasked (default: masked)") var showReveal bool show := &cobra.Command{ Use: "show ", @@ -231,7 +235,7 @@ func revealOrMask(value string, reveal bool) string { return maskSecret(value) } -func runStorageList(cmd *cobra.Command, _ []string) error { +func runStorageList(cmd *cobra.Command, _ []string, reveal bool) error { sess, cliErr := loadSession(cmd) if cliErr != nil { return cliErr @@ -246,7 +250,7 @@ func runStorageList(cmd *cobra.Command, _ []string) error { return asCLIError(err) } env := output.NewEnvelope("storage list", - map[string]any{"storageCredentials": items}, + map[string]any{"storageCredentials": redactMaps(items, storageSecretFields, reveal)}, fmt.Sprintf("%d storage credentials", len(items)), nil) env.SetTable([]string{"BUCKET", "ID", "PROVIDER", "ENDPOINT", "KEY", "ASSIGNED"}, storageListRows(items), -1) return writeEnvelopeWithQuietData(cmd, env, strconv.Itoa(len(items))) @@ -278,7 +282,7 @@ func runStorageShow(cmd *cobra.Command, args []string, reveal bool) error { if name == "" { name = valueOrEmpty(detail["id"]) } - env := output.NewEnvelope("storage show", detail, + env := output.NewEnvelope("storage show", redactMap(detail, storageSecretFields, reveal), fmt.Sprintf("Storage credential %s", name), nil) env.SetKV(storageDetailPairs(detail, reveal)) return writeEnvelopeWithQuietData(cmd, env, valueOrEmpty(detail["id"])) diff --git a/skills/SKILL.md b/skills/SKILL.md index 56bcd57..8ec66b5 100644 --- a/skills/SKILL.md +++ b/skills/SKILL.md @@ -373,13 +373,17 @@ project's renders. All three groups share the same verb set (`list`, `show`, session and an active org (agents on `URLBOX_API_SECRET` alone are not signed in — these commands return `auth` / exit 3 until `urlbox login` runs). -Secrets are masked on display. Text output masks by default; pass `--reveal` -to unmask. JSON output (`--output-format json`) always contains the full -values. A target is resolved by name or id (ids: `store_`, `pool_`, `llm_`). +Secrets are masked by default in **both** text and JSON — storage keys and +secrets, SAS tokens, LLM API keys and cloud credentials, the password component +of proxy URLs, and a project's webhook key. Pass `--reveal` (on `list` and +`show`) to get the full values. Non-secret fields always pass through +untouched, so `--jq` over ids, names, buckets and regions works either way. +A target is resolved by name or id (ids: `store_`, `pool_`, `llm_`). ```sh -# List / show (JSON gives full, machine-readable records) +# List / show — JSON is machine-readable and masked by default urlbox storage list --output-format json +urlbox storage list --reveal --output-format json # full values when you need them urlbox proxies show eu --reveal urlbox llm show openai --output-format json From 3c8f7cdf406db54327f5c644f6f25fa0c490267f Mon Sep 17 00:00:00 2001 From: gdameneses Date: Wed, 19 Aug 2026 14:18:30 +0100 Subject: [PATCH 7/8] fix(orgs): complete the org switch in one step and say when it didn't MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switching organisations drops the stored render credential on purpose — it belongs to a project in the org being left, and keeping it would let `render` bill the previous organisation silently. That part is right and stays. Three things around it were not. It cleared api_secret but left api_key, so a publishable key from the previous org survived in the profile; the credential is a pair and both halves go. When the new org had several projects and no terminal to pick with, the switch finished in a deliberately incomplete state — no active project, no credential — and reported it only on stderr, returning ok:true with an unqualified "Active organisation: X". An agent reads stdout, so it saw plain success and discovered the missing credential on its next render. `login` hits the identical branch of resolveActiveProject and fails loudly there; the same situation now travels in the envelope here too: the summary names the project count and a breadcrumb points at `urlbox projects select`. The exit code stays 0 — the org did switch — but the envelope no longer claims the context is complete when it isn't. And there was no way to avoid the gap at all non-interactively. `orgs select` now takes --project, mirroring `login --org --project`, so the switch and the new active project land in one call. An unresolvable --project is a hard error rather than a half-finished switch: the caller named something that isn't there. resolveActiveProject returns the project count alongside the choice so callers can report it; login ignores it. --- CHANGELOG.md | 15 ++- SURFACE.txt | 4 + internal/cmd/login.go | 2 +- internal/cmd/login_resolve.go | 17 ++-- internal/cmd/login_resolve_test.go | 30 +++--- internal/cmd/orgs.go | 69 ++++++++++--- internal/cmd/orgs_select_context_test.go | 118 +++++++++++++++++++++++ 7 files changed, 214 insertions(+), 41 deletions(-) create mode 100644 internal/cmd/orgs_select_context_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 21982cb..c87bb31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,9 +36,18 @@ management of the organisation's storage, proxy, and LLM credentials. Deleting the active project re-selects the survivor or offers a picker. - `storage`, `proxies`, `llm` groups (list/show/create/update/delete, plus `llm test` and `llm models`) and - `projects storage|proxy|llm assign|unassign`. Secrets are masked in - every human view; `--reveal` unhides. Every `create` takes the name - positionally (`--name` works too). + `projects storage|proxy|llm assign|unassign`. Secrets are masked by + default in **both** text and JSON — storage keys/secrets, SAS tokens, + LLM API keys and cloud credentials, the password inside a proxy URL, + and a project's webhook key. `--reveal` unhides, on `list` as well as + `show`; non-secret fields always pass through untouched. Every + `create` takes the name positionally (`--name` works too). +- `orgs select --project ` switches organisation and lands + the new active project in one step. Switching organisations clears the + stored render credential (it belongs to a project in the organisation + you are leaving); when the new organisation has several projects and + nothing to pick with, the envelope now says so — summary names the + count and a breadcrumb points at `urlbox projects select`. - Lists render as tables and detail views as aligned blocks in text mode; JSON output is unchanged. - `--no-retry` / `--max-retries` on the session commands, matching diff --git a/SURFACE.txt b/SURFACE.txt index 1192a6c..809f702 100644 --- a/SURFACE.txt +++ b/SURFACE.txt @@ -141,6 +141,7 @@ urlbox llm list --max-retries urlbox llm list --no-retry urlbox llm list --output-format urlbox llm list --profile +urlbox llm list --reveal urlbox llm models urlbox llm models --agent urlbox llm models --jq @@ -213,6 +214,7 @@ urlbox orgs select [name-or-id] --max-retries urlbox orgs select [name-or-id] --no-retry urlbox orgs select [name-or-id] --output-format urlbox orgs select [name-or-id] --profile +urlbox orgs select [name-or-id] --project urlbox pdf [url] urlbox pdf [url] --agent urlbox pdf [url] --api-secret @@ -437,6 +439,7 @@ urlbox proxies list --max-retries urlbox proxies list --no-retry urlbox proxies list --output-format urlbox proxies list --profile +urlbox proxies list --reveal urlbox proxies show urlbox proxies show --agent urlbox proxies show --jq @@ -602,6 +605,7 @@ urlbox storage list --max-retries urlbox storage list --no-retry urlbox storage list --output-format urlbox storage list --profile +urlbox storage list --reveal urlbox storage show urlbox storage show --agent urlbox storage show --jq diff --git a/internal/cmd/login.go b/internal/cmd/login.go index a7e7826..20d2106 100644 --- a/internal/cmd/login.go +++ b/internal/cmd/login.go @@ -145,7 +145,7 @@ func runLogin(cmd *cobra.Command, f *loginFlags) error { } } - project, projErr := resolveActiveProject(ctx, authed, f.project, promptPick) + project, _, projErr := resolveActiveProject(ctx, authed, f.project, promptPick) if projErr != nil { return projErr } diff --git a/internal/cmd/login_resolve.go b/internal/cmd/login_resolve.go index 2ee0974..a886388 100644 --- a/internal/cmd/login_resolve.go +++ b/internal/cmd/login_resolve.go @@ -96,20 +96,21 @@ func resolveActiveOrg(ctx context.Context, client api.SessionAPI, orgFlag string }, nil } -func resolveActiveProject(ctx context.Context, client api.SessionAPI, projectFlag string, pick pickFunc) (nameID, *output.CLIError) { +func resolveActiveProject(ctx context.Context, client api.SessionAPI, projectFlag string, pick pickFunc) (chosen nameID, count int, cliErr *output.CLIError) { projects, err := fetchList(ctx, client, "/v2/projects", "projects") if err != nil { - return nameID{}, asCLIError(err) + return nameID{}, 0, asCLIError(err) } rows := toNameIDs(projects) if len(rows) == 0 { - return nameID{}, nil + return nameID{}, 0, nil } if projectFlag != "" { - return resolveNameOrID(projectFlag, "proj_", rows, "project") + picked, resErr := resolveNameOrID(projectFlag, "proj_", rows, "project") + return picked, len(rows), resErr } if len(rows) == 1 { - return rows[0], nil + return rows[0], 1, nil } names := make([]string, len(rows)) for i, r := range rows { @@ -118,14 +119,14 @@ func resolveActiveProject(ctx context.Context, client api.SessionAPI, projectFla idx, perr := pick("Select the active project (used by render):", names, -1) if perr != nil { if errors.Is(perr, errNotInteractivePick) { - return nameID{}, output.NewCLIError(output.ErrUsage, + return nameID{}, len(rows), output.NewCLIError(output.ErrUsage, "multiple projects and no interactive terminal", "Pass --project , or run `urlbox projects select` later.") } - return nameID{}, output.NewCLIError(output.ErrUsage, perr.Error(), + return nameID{}, len(rows), output.NewCLIError(output.ErrUsage, perr.Error(), "Pass --project , or run `urlbox projects select` later.") } - return rows[idx], nil + return rows[idx], len(rows), nil } func activeOrgName(ctx context.Context, client api.SessionAPI) string { diff --git a/internal/cmd/login_resolve_test.go b/internal/cmd/login_resolve_test.go index ded09dc..d951cb5 100644 --- a/internal/cmd/login_resolve_test.go +++ b/internal/cmd/login_resolve_test.go @@ -147,32 +147,36 @@ func TestResolveActiveOrgZeroOrgs(t *testing.T) { func TestResolveActiveProjectMatrix(t *testing.T) { zero := &fakeSession{gets: map[string]string{"/v2/projects": `{"projects":[]}`}} - got, cli := resolveActiveProject(context.Background(), zero, "", neverPick) - if cli != nil || got.ID != "" { - t.Fatalf("zero projects: got %+v %v", got, cli) + got, count, cli := resolveActiveProject(context.Background(), zero, "", neverPick) + if cli != nil || got.ID != "" || count != 0 { + t.Fatalf("zero projects: got %+v count=%d %v", got, count, cli) } one := &fakeSession{gets: map[string]string{"/v2/projects": `{"projects":[{"id":"proj_1","name":"Only"}]}`}} - got, cli = resolveActiveProject(context.Background(), one, "", neverPick) - if cli != nil || got.ID != "proj_1" { - t.Fatalf("one project: got %+v %v", got, cli) + got, count, cli = resolveActiveProject(context.Background(), one, "", neverPick) + if cli != nil || got.ID != "proj_1" || count != 1 { + t.Fatalf("one project: got %+v count=%d %v", got, count, cli) } many := &fakeSession{gets: map[string]string{"/v2/projects": `{"projects":[{"id":"proj_1","name":"A"},{"id":"proj_2","name":"B"}]}`}} - got, cli = resolveActiveProject(context.Background(), many, "", func(_ string, _ []string, _ int) (int, error) { return 1, nil }) - if cli != nil || got.ID != "proj_2" { - t.Fatalf("picker path: got %+v %v", got, cli) + got, count, cli = resolveActiveProject(context.Background(), many, "", func(_ string, _ []string, _ int) (int, error) { return 1, nil }) + if cli != nil || got.ID != "proj_2" || count != 2 { + t.Fatalf("picker path: got %+v count=%d %v", got, count, cli) } - got, cli = resolveActiveProject(context.Background(), many, "b", neverPick) - if cli != nil || got.ID != "proj_2" { - t.Fatalf("flag path: got %+v %v", got, cli) + got, count, cli = resolveActiveProject(context.Background(), many, "b", neverPick) + if cli != nil || got.ID != "proj_2" || count != 2 { + t.Fatalf("flag path: got %+v count=%d %v", got, count, cli) } - _, cli = resolveActiveProject(context.Background(), many, "", notInteractive) + // The count travels with the error too — `orgs select` reports "N projects". + _, count, cli = resolveActiveProject(context.Background(), many, "", notInteractive) if cli == nil || cli.Code != output.ErrUsage || !strings.Contains(cli.Hint, "--project") { t.Fatalf("want usage error naming --project, got %v", cli) } + if count != 2 { + t.Fatalf("ambiguous path must still report the count, got %d", count) + } } func TestActiveOrgNameFallbacks(t *testing.T) { diff --git a/internal/cmd/orgs.go b/internal/cmd/orgs.go index 0db499b..4061605 100644 --- a/internal/cmd/orgs.go +++ b/internal/cmd/orgs.go @@ -32,12 +32,29 @@ func newOrgsCmd() *cobra.Command { Args: cobra.NoArgs, RunE: runOrgsList, } + var selProject string sel := &cobra.Command{ Use: "select [name-or-id]", Short: "Set the active organisation", - Args: cobra.MaximumNArgs(1), - RunE: runOrgsSelect, - } + Long: `Set the active organisation. + +Switching organisations clears the stored render credential: it belongs to a +project in the organisation you are leaving, so keeping it would let ` + "`render`" + ` +bill the previous organisation. The CLI picks the new organisation's project up +again automatically when there is exactly one; when there are several, pass +--project to finish the switch in a single step instead of following up with +` + "`urlbox projects select`" + `. + +Examples: + urlbox orgs select acme + urlbox orgs select acme --project production + urlbox orgs select --output-format json`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runOrgsSelect(cmd, args, selProject) + }, + } + sel.Flags().StringVar(&selProject, "project", "", "Project to make active after the switch (name or id) — skips the picker") c.AddCommand(list, sel) attachSessionRetryFlags(c) return c @@ -79,7 +96,7 @@ func runOrgsList(cmd *cobra.Command, _ []string) error { return writeEnvelope(cmd, env) } -func runOrgsSelect(cmd *cobra.Command, args []string) error { +func runOrgsSelect(cmd *cobra.Command, args []string, projectFlag string) error { sess, cliErr := loadSession(cmd) if cliErr != nil { return cliErr @@ -136,9 +153,13 @@ func runOrgsSelect(cmd *cobra.Command, args []string) error { return asCLIError(err) } publicID := session.Session.ActiveOrganizationPublicID + // The render credential is project-scoped and the project belongs to the + // organisation being left, so both halves go — a surviving api_key would + // still name the previous org. if cliErr := updateProfile(sess.ProfileName, func(p *config.Profile) { p.ActiveOrg = publicID p.ActiveProject = "" + p.APIKey = "" p.APISecret = "" }); cliErr != nil { return cliErr @@ -151,7 +172,12 @@ func runOrgsSelect(cmd *cobra.Command, args []string) error { projectPick = func(_ string, _ []string, _ int) (int, error) { return -1, errNotInteractivePick } } - project, projErr := resolveActiveProject(ctx, sess.Client, "", projectPick) + project, projectCount, projErr := resolveActiveProject(ctx, sess.Client, projectFlag, projectPick) + // An explicit --project that cannot be resolved is a real failure, not a + // half-finished switch: the caller named something that isn't there. + if projErr != nil && projectFlag != "" { + return projErr + } renderStatus := "none" if projErr == nil && project.ID != "" { if cliErr := updateProfile(sess.ProfileName, func(p *config.Profile) { p.ActiveProject = project.ID }); cliErr == nil { @@ -169,15 +195,27 @@ func runOrgsSelect(cmd *cobra.Command, args []string) error { } } } - if projErr == nil && project.ID == "" { - _, _ = fmt.Fprintln(cmd.ErrOrStderr(), "No projects in this organisation yet — run `urlbox projects select` after creating one.") - } - if projErr != nil { - if isNonInteractiveProjectStep(projErr) { - _, _ = fmt.Fprintln(cmd.ErrOrStderr(), "Several projects in this organisation — run `urlbox projects select` to pick one.") - } else { - _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "org switched, but no active project set: %v\n", projErr) - } + // The switch can land without an active project (none exist yet, or several + // and nothing to pick with). That state is expected, but it leaves `render` + // without a credential, so it has to travel in the envelope — an agent reads + // stdout and never sees a stderr line. + summary := fmt.Sprintf("Active organisation: %s", chosen.Name) + var breadcrumbs []output.Breadcrumb + switch { + case projErr == nil && project.ID == "": + summary += " — no projects yet" + _, _ = fmt.Fprintln(cmd.ErrOrStderr(), "No projects in this organisation yet — create one, then run `urlbox projects select`.") + breadcrumbs = []output.Breadcrumb{{Action: "create project", Cmd: "urlbox projects create "}} + case projErr != nil && isNonInteractiveProjectStep(projErr): + summary += fmt.Sprintf(" — %d projects; pick one next", projectCount) + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), + "%d projects in this organisation — run `urlbox orgs select %s --project ` or `urlbox projects select` to finish.\n", + projectCount, chosen.Name) + breadcrumbs = []output.Breadcrumb{{Action: "pick project", Cmd: "urlbox projects select"}} + case projErr != nil: + summary += " — no active project" + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "org switched, but no active project set: %v\n", projErr) + breadcrumbs = []output.Breadcrumb{{Action: "pick project", Cmd: "urlbox projects select"}} } data := map[string]any{ @@ -187,8 +225,7 @@ func runOrgsSelect(cmd *cobra.Command, args []string) error { if project.ID != "" { data["project"] = map[string]any{"id": project.ID, "name": project.Name} } - env := output.NewEnvelope("orgs select", data, - fmt.Sprintf("Active organisation: %s", chosen.Name), nil) + env := output.NewEnvelope("orgs select", data, summary, breadcrumbs) return writeEnvelopeWithQuietData(cmd, env, publicID) } diff --git a/internal/cmd/orgs_select_context_test.go b/internal/cmd/orgs_select_context_test.go new file mode 100644 index 0000000..dc67880 --- /dev/null +++ b/internal/cmd/orgs_select_context_test.go @@ -0,0 +1,118 @@ +package cmd + +import ( + "bytes" + "testing" + + "github.com/urlbox/urlbox-cli/internal/api/apitest" +) + +// Switching orgs deliberately drops the stored render credential: it belongs to +// a project in the org being left, and keeping it would let `urlbox render` +// bill the previous organisation silently. These tests pin the two things that +// must be true *around* that drop — it clears the whole credential pair, and it +// never reports an incomplete context as an unqualified success. + +const twoOrgsJSON = `[{"id":"1","name":"One","publicId":"org_one"},{"id":"2","name":"Two","publicId":"org_two"}]` + +const sessionOrgOneJSON = `{"user":{"email":"a@urlbox.com"},"session":{"activeOrganizationId":"1","activeOrganizationPublicId":"org_one"}}` + +func TestOrgsSelectAmbiguousProjectsClearsWholeCredentialPair(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New( + apitest.SuccessJSON(twoOrgsJSON), + apitest.SuccessJSON(`{}`), + apitest.SuccessJSON(sessionOrgOneJSON), + apitest.SuccessJSON(`{"projects":[{"id":"proj_a","name":"alpha"},{"id":"proj_b","name":"beta"}]}`), + ) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + var stdout, stderr bytes.Buffer + Execute([]string{"orgs", "select", "one", "--output-format", "json"}, &stdout, &stderr) + p := readProfileMap(t, dir) + if p["api_secret"] != "" { + t.Fatalf("api_secret must be cleared on an org switch: %#v", p) + } + if p["api_key"] != "" { + t.Fatalf("api_key must be cleared with the secret — a stale key from the previous org survived: %#v", p) + } + if p["active_project"] != "" { + t.Fatalf("active_project must be cleared: %#v", p) + } +} + +func TestOrgsSelectAmbiguousProjectsReportsIncompleteContext(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New( + apitest.SuccessJSON(twoOrgsJSON), + apitest.SuccessJSON(`{}`), + apitest.SuccessJSON(sessionOrgOneJSON), + apitest.SuccessJSON(`{"projects":[{"id":"proj_a","name":"alpha"},{"id":"proj_b","name":"beta"}]}`), + ) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + var stdout, stderr bytes.Buffer + Execute([]string{"orgs", "select", "one", "--output-format", "json"}, &stdout, &stderr) + // The next command must be reachable from the envelope alone — an agent + // reading stdout never sees the stderr line. + if !bytes.Contains(stdout.Bytes(), []byte("urlbox projects select")) { + t.Fatalf("envelope must breadcrumb to `urlbox projects select`: %s", stdout.String()) + } + if !bytes.Contains(stdout.Bytes(), []byte("2 projects")) { + t.Fatalf("summary must say the context is unfinished: %s", stdout.String()) + } +} + +func TestOrgsSelectProjectFlagCompletesInOneStep(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New( + apitest.SuccessJSON(twoOrgsJSON), + apitest.SuccessJSON(`{}`), + apitest.SuccessJSON(sessionOrgOneJSON), + apitest.SuccessJSON(`{"projects":[{"id":"proj_a","name":"alpha"},{"id":"proj_b","name":"beta"}]}`), + apitest.SuccessJSON(`{"apiCredentials":[{"apiKey":"pk_beta","apiSecret":"sk_beta","revoked":false}]}`), + ) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + var stdout, stderr bytes.Buffer + code := Execute([]string{"orgs", "select", "one", "--project", "beta", "--output-format", "json"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit %d\n%s\n%s", code, stdout.String(), stderr.String()) + } + p := readProfileMap(t, dir) + if p["active_org"] != "org_one" || p["active_project"] != "proj_b" || + p["api_key"] != "pk_beta" || p["api_secret"] != "sk_beta" { + t.Fatalf("--project must land a complete context in one call: %#v", p) + } + if !bytes.Contains(stdout.Bytes(), []byte(`"credential": "ready"`)) { + t.Fatalf("render credential should be ready: %s", stdout.String()) + } +} + +func TestOrgsSelectProjectFlagUnknownProjectErrors(t *testing.T) { + dir := t.TempDir() + writeCompatConfig(t, dir, true) + t.Setenv("XDG_CONFIG_HOME", dir) + srv := apitest.New( + apitest.SuccessJSON(twoOrgsJSON), + apitest.SuccessJSON(`{}`), + apitest.SuccessJSON(sessionOrgOneJSON), + apitest.SuccessJSON(`{"projects":[{"id":"proj_a","name":"alpha"}]}`), + ) + t.Cleanup(srv.Close) + t.Setenv("URLBOX_API_HOST", srv.URL()) + var stdout, stderr bytes.Buffer + code := Execute([]string{"orgs", "select", "one", "--project", "nope", "--output-format", "json"}, &stdout, &stderr) + if code == 0 { + t.Fatalf("an unknown --project must not exit 0: %s", stdout.String()) + } + if !bytes.Contains(stdout.Bytes(), []byte(`"not_found"`)) { + t.Fatalf("expected a not_found envelope: %s", stdout.String()) + } +} From e4d636d56adf61ae966fa4ec07f4678867cabc38 Mon Sep 17 00:00:00 2001 From: gdameneses Date: Wed, 19 Aug 2026 14:48:17 +0100 Subject: [PATCH 8/8] =?UTF-8?q?release:=20v1.1.0=20=E2=80=94=20return=20to?= =?UTF-8?q?=20the=20v1=20line=20and=20make=20SURFACE=20the=20stability=20p?= =?UTF-8?q?romise?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v1.0.0 is published and immutable, so v1.0 cannot be reissued; v1.1.0 is the closest available version and the correct one by semver anyway — this release adds a command surface rather than patching one. The other free slot near 1.0, v1.0.5, would drop a live release into the middle of the deprecated v1.0.x range, where anyone pinning ~1.0 sees five deprecated versions and one live. SURFACE.txt stops describing itself as pre-1.0. From here the file is the promise: nothing listed is removed or renamed inside the v1 line without a major bump. That is the sentence the 0.x reset existed to be able to write. The v0.10.0 entry's closing line ("The v1.x line is deprecated on npm") is now false and says so, pointing at the v1.1.0 entry. Clearing the five npm deprecation notices is a publish-time step, not a code one. Also flags the installer consequence of leaving 0.x: npm/install.js gates its sigstore policy on the version string, so on v1 a missing bundle is a hard install failure rather than a sha256-only fallback. goreleaser signs checksums.txt, so this is a tightening, not a break — but it is now load-bearing and belongs in the release notes. --- CHANGELOG.md | 16 ++++++++++++++-- SURFACE.txt | 5 +++-- internal/surface/snapshot.go | 5 +++-- 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c87bb31..c2423ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,18 @@ and the project follows [SemVer](https://semver.org/spec/v2.0.0.html). ## v1.1.0 — 2026-08-19 +**Back on v1, and this time it holds.** The 0.x early-access reset +(v0.10.0) did its job: the surface settled. `SURFACE.txt` is now a +stability promise rather than a change log — nothing in it is removed or +renamed inside the v1 line without a major bump. + +v1.0.0–v1.0.4 stay published but are superseded; their npm deprecation +notices pointing at 0.10.x are cleared with this release, and anyone +still on that line upgrades straight to v1.1.0. Users on 0.10.0 upgrade +normally. Note for the v1 line: the npm installer now *requires* the +sigstore bundle rather than falling back to sha256-only — set +`URLBOX_ALLOW_UNSIGNED=1` only if a proxy makes the bundle unreachable. + **Browser login and account management.** `urlbox login` signs in via the browser and stores the session and the active project's render credentials. Adds org and project switching, project CRUD, usage, and @@ -74,8 +86,8 @@ number changes. Migrate with: - Homebrew: `brew update && brew upgrade urlbox` (will pick 0.10.0) - Scoop: `scoop update urlbox` -The v1.x line is deprecated on npm. The v1.x GitHub Releases stay -in place as a historical record. +The v1.x line was deprecated on npm at the time of this reset. That +deprecation is lifted with v1.1.0 — see that entry. ## v1.0.4 — 2026-05-15 diff --git a/SURFACE.txt b/SURFACE.txt index 809f702..3acd8e0 100644 --- a/SURFACE.txt +++ b/SURFACE.txt @@ -1,8 +1,9 @@ # Urlbox CLI surface contract. # Every line below is the current public surface: removals and renames # fail `make surface-check` in CI so they're caught and reviewed, not -# so they're forbidden. Pre-1.0 — surface may still change between -# minor versions. New entries are always fine. +# so they're forbidden. From v1.0 this file is the stability promise: +# nothing listed below is removed or renamed within the v1 line without +# a major bump. New entries are always fine. # # Excluded by design: # - cobra builtins: `help` subcommand, `--help`/`-h`, `--version` diff --git a/internal/surface/snapshot.go b/internal/surface/snapshot.go index 7dfc014..c2a9ca7 100644 --- a/internal/surface/snapshot.go +++ b/internal/surface/snapshot.go @@ -28,8 +28,9 @@ func Header() []string { "# Urlbox CLI surface contract.", "# Every line below is the current public surface: removals and renames", "# fail `make surface-check` in CI so they're caught and reviewed, not", - "# so they're forbidden. Pre-1.0 — surface may still change between", - "# minor versions. New entries are always fine.", + "# so they're forbidden. From v1.0 this file is the stability promise:", + "# nothing listed below is removed or renamed within the v1 line without", + "# a major bump. New entries are always fine.", "#", "# Excluded by design:", "# - cobra builtins: `help` subcommand, `--help`/`-h`, `--version`",