diff --git a/README.md b/README.md index 201fdbe..139ae03 100644 --- a/README.md +++ b/README.md @@ -66,16 +66,20 @@ Detailed command documentation lives in [docs/commands/README.md](./docs/command | Command | Description | |---------|-------------| -| [`codex-auth list [--live] [--active] [--api\|--skip-api]`](./docs/commands/list.md) | List stored accounts and usage state | +| [`codex-auth list [--live] [--active] [--api\|--skip-api] [--json]`](./docs/commands/list.md) | List stored accounts and usage state | | [`codex-auth login [--device-auth]`](./docs/commands/login.md) | Run `codex login`, then add the current account | | [`codex-auth switch [--live] [--api\|--skip-api]`](./docs/commands/switch.md) | Switch the active account interactively | | [`codex-auth switch `](./docs/commands/switch.md) | Switch directly by row number or account selector | +| [`codex-auth switch --json`](./docs/commands/switch.md) | Switch directly with JSON output | | [`codex-auth remove [--live] [--api\|--skip-api]`](./docs/commands/remove.md) | Remove accounts interactively | | [`codex-auth remove [...]`](./docs/commands/remove.md) | Remove accounts by selector | | [`codex-auth remove --all`](./docs/commands/remove.md) | Remove all stored accounts | +| [`codex-auth remove ( [...]\|--all) --json`](./docs/commands/remove.md) | Remove accounts with JSON output | | [`codex-auth alias set `](./docs/commands/alias.md) | Set an alias for an account | | [`codex-auth alias clear `](./docs/commands/alias.md) | Clear the alias for an account | +Machine-readable output for GUI integrations is documented in [docs/json-api.md](./docs/json-api.md). + ### Import and Maintenance | Command | Description | diff --git a/docs/api.md b/docs/api.md index c06b24b..82888de 100644 --- a/docs/api.md +++ b/docs/api.md @@ -79,49 +79,49 @@ That scope includes: `chatgpt_user_id` is the user identity for this flow. A single user may have multiple workspace `chatgpt_account_id` values, and those values can be legacy account ids or organization fallback ids. -This means a `free`, `plus`, or `pro` record can still trigger a grouped Team-name refresh when it belongs to the same `chatgpt_user_id` as Team records. +This means a personal-plan record can still participate in a grouped workspace-name refresh when it belongs to the same `chatgpt_user_id` as Business, Enterprise, or Edu workspace records. Account metadata refresh is attempted only when: - the scope contains more than one record -- the scope contains at least one Team record -- at least one Team record in that scope still has `account_name = null` +- the scope contains at least one Business, Enterprise, or Edu workspace record +- at least one workspace record in that scope still has `account_name = null` ## Apply Rules After a successful account metadata response: - returned entries are matched by `chatgpt_account_id` -- matched records overwrite the stored `account_name`, even when a Team record already had an older value -- in-scope Team records, or in-scope records that already had an `account_name`, are cleared back to `null` when they are not returned by the response +- matched records overwrite the stored `account_name`, even when a workspace record already had an older value +- in-scope workspace records, or in-scope records that already had an `account_name`, are cleared back to `null` when they are not returned by the response - records outside the scope are left unchanged ## Examples Example 1: -- active record: `user@example.com / Team #1 / account_name = null` -- same grouped scope: `user@example.com / Team #2 / account_name = null` +- active record: `user@example.com / Business #1 / account_name = null` +- same grouped scope: `user@example.com / Business #2 / account_name = null` Running `codex-auth list` should issue an account metadata request. If the API returns: - `team-1 -> "Workspace Alpha"` - `team-2 -> "Workspace Beta"` -Then both grouped Team records are updated. +Then both grouped Business workspace records are updated. Example 2: - active record: `user@example.com / Pro / account_name = null` -- same grouped scope: `user@example.com / Team #1 / account_name = null` -- same grouped scope: `user@example.com / Team #2 / account_name = "Old Workspace"` +- same grouped scope: `user@example.com / Business #1 / account_name = null` +- same grouped scope: `user@example.com / Business #2 / account_name = "Old Workspace"` -Running `codex-auth list` should still issue an account metadata request, because the grouped scope still has missing Team names. If the API returns: +Running `codex-auth list` should still issue an account metadata request, because the grouped scope still has missing workspace names. If the API returns: - `team-1 -> "Prod Workspace"` - `team-2 -> "Sandbox Workspace"` Then: -- `Team #1` is filled with `Prod Workspace` -- `Team #2` is overwritten from `Old Workspace` to `Sandbox Workspace` +- `Business #1` is filled with `Prod Workspace` +- `Business #2` is overwritten from `Old Workspace` to `Sandbox Workspace` diff --git a/docs/brainstorm/2026-07-03-json-api-design.md b/docs/brainstorm/2026-07-03-json-api-design.md new file mode 100644 index 0000000..a03bc15 --- /dev/null +++ b/docs/brainstorm/2026-07-03-json-api-design.md @@ -0,0 +1,327 @@ +# JSON API Layer for GUI Integration — Design + +Date: 2026-07-03 +Status: Approved for implementation + +## Background and Goal + +The GUI and CLI are released at the same version. The GUI starts one-shot +`codex-auth` subprocesses, serializes mutating calls, drains stderr for +diagnostics, and consumes one machine-readable document from stdout. + +The JSON contract must expose final CLI decisions rather than registry or +backend implementation details. In particular, callers must not reproduce +plan mappings, selector matching, usage fallback, account ordering, or active +account resolution. + +Login and long-running transports remain out of scope. + +## Scope + +JSON mode supports these non-interactive commands: + +- `list [--api|--skip-api] [--active] --json` +- `switch --json` +- `remove [...] --json` +- `remove --all --json` + +The following remain CLI-only conveniences and are not part of the JSON API: + +- `switch -` +- interactive switch and remove pickers +- live terminal views + +Consequently, `switch - --json`, `switch --json` without a query, interactive +`remove --json`, and every `--live --json` combination are usage errors. + +## Processing Model + +The workflows are organized around observable stages rather than "pure +compute" functions: + +```text +load and optionally refresh state + -> resolve selectors and derived account data + -> apply a mutation, when requested + -> build a structured outcome + -> render human text or JSON +``` + +Loading, refresh, auth synchronization, and persistence perform I/O. Resolution +and structured outcome construction are shared by the human and JSON paths so +that their behavior cannot drift. + +Expected domain failures are structured outcomes. Zig error returns are kept +for unexpected allocation, filesystem, process, and invariant failures. + +## JSON-Mode Guarantees + +1. Stdout contains exactly one JSON document followed by one newline. +2. Every document contains `"schema_version": 1`. +3. Exit code `0` means success, `1` means a handled operation error, and `2` + means invalid command usage. +4. Human diagnostics and warnings are written to stderr and never appear as + programmable JSON fields. +5. All user-facing strings are English. +6. `account_key` is the stable operation identifier. Display `number` is an + ephemeral convenience valid only for the account ordering returned by that + invocation. + +## Canonical Plan Semantics + +`AccountView.plan` is the final product plan selected by the CLI. The GUI does +not receive or interpret backend plan families. + +Backend, auth, usage, session, and legacy registry values are normalized at +their input boundaries: + +| Input value | Canonical plan | Human label | +|-------------|----------------|-------------| +| `team` | `business` | Business | +| `self_serve_business_usage_based` | `business` | Business | +| `business` | `enterprise` | Enterprise | +| `enterprise_cbp_usage_based` | `enterprise` | Enterprise | +| `enterprise`, `hc` | `enterprise` | Enterprise | +| `education`, `edu` | `edu` | Edu | + +Other known canonical values are `free`, `go`, `plus`, `prolite`, and `pro`. +Unrecognized input becomes `unknown`; absent input remains `null`. + +The selected plan for an account is: + +```text +last_usage.plan_type, when present +otherwise AccountRecord.plan from auth +``` + +Both sources are canonical before selection. JSON therefore exposes only +`plan`; it does not expose `effective_plan`, `raw_plan`, `auth_plan`, or require +the GUI to map values. + +Workspace behavior, including account-name refresh, uses canonical product +semantics. `business`, `enterprise`, and `edu` are workspace plans. No business +logic checks a legacy `team` enum value. + +Registry schema 4 is the final persisted format for this feature. When reading +schema 3 data, stored `team` migrates to `business` and stored `business` +migrates to `enterprise`, including `last_usage.plan_type`. Schema 4 reads and +writes canonical values only. + +## Account View + +All success and ambiguity documents reuse this per-account shape: + +```json +{ + "number": 1, + "account_key": "user-abc::account-123", + "email": "a@example.com", + "alias": "work", + "account_name": null, + "plan": "business", + "auth_mode": "chatgpt", + "active": true, + "created_at": 1730000000, + "last_used_at": 1730001000, + "usage": { + "source": "cache", + "updated_at": 1730002000, + "primary": { + "used_percent": 12.5, + "window_minutes": 300, + "resets_at": 1730010000 + }, + "secondary": null, + "credits": { + "has_credits": false, + "unlimited": false, + "balance": null + }, + "reset_credits": null, + "refresh": { + "requested": true, + "method": "api", + "status": "http_error", + "http_status": 503, + "error_code": null + } + } +} +``` + +Empty aliases and account names serialize as `null`. + +## Usage Snapshot and Refresh Outcome + +Usage data and the refresh performed by the current command are independent: + +- `source` describes the displayed snapshot: `api`, `local`, `cache`, or + `none`. +- Snapshot fields remain available after refresh failure. A failed API call + must not hide a usable cached snapshot. +- `updated_at` is the stored snapshot update timestamp. It is not a timestamp + for the current attempt and may remain unchanged after an equal successful + response. +- `refresh.requested` explicitly states whether this invocation requested a + refresh for the account. +- `refresh.method` is `api`, `local`, or `null`. +- `refresh.status` is `not_requested`, `ok`, `no_data`, `http_error`, + `missing_auth`, or `error`. +- `http_status` and `error_code` are nullable structured details. +- `credits.has_credits` is retained even when false; it is not inferred from + whether the credits object exists. + +For `switch` and `remove`, which do not refresh usage, a stored snapshot has +`source: "cache"` and `refresh.status: "not_requested"`. + +## Selector Resolution and Mutation Semantics + +Exact `account_key` matching runs before display-number and fuzzy matching for +both human and JSON commands. + +`switch --json` never prompts. Ambiguity returns `ambiguous_query` with +candidate account views. + +`remove` resolves every selector before applying any mutation. If any selector +is ambiguous or not found, no account is removed. JSON returns one +`selector_resolution_failed` error containing every selector resolution: + +Candidate objects are abbreviated in this example; actual candidates use the +complete account-view shape above. + +```json +{ + "schema_version": 1, + "error": { + "code": "selector_resolution_failed", + "message": "one or more selectors could not be resolved", + "resolutions": [ + { + "selector": "work", + "status": "ambiguous", + "account_key": null, + "candidates": [ + { + "number": 1, + "account_key": "user-a::account-a", + "email": "work-a@example.com" + }, + { + "number": 2, + "account_key": "user-b::account-b", + "email": "work-b@example.com" + } + ] + }, + { + "selector": "missing", + "status": "not_found", + "account_key": null, + "candidates": [] + } + ] + } +} +``` + +Resolved selectors are also included with `status: "resolved"` and their +`account_key`. Repeated selectors may resolve to the same account; mutation +deduplicates account keys. + +Resolution atomicity is a logical guarantee, not a multi-file transaction. +Once mutation starts, auth snapshots, active auth, and registry persistence can +fail between filesystem operations. Such failures return `state_uncertain`. +The GUI must run `list --json` before deciding whether to retry. + +## Success Documents + +### List + +```json +{ + "schema_version": 1, + "command": "list", + "active_account_key": "user-abc::account-123", + "accounts": [] +} +``` + +Account order matches the human table. + +### Switch + +```json +{ + "schema_version": 1, + "command": "switch", + "switched_to": {} +} +``` + +Normal switching may update the CLI's internal previous-account pointer, but +that pointer is not part of the JSON request or response contract. + +### Remove + +```json +{ + "schema_version": 1, + "command": "remove", + "removed": [], + "new_active_account_key": null +} +``` + +## Errors + +Operation errors use exit code 1: + +```json +{ + "schema_version": 1, + "error": { + "code": "account_not_found", + "message": "no account matches \"work\"" + } +} +``` + +Initial error codes are: + +| Code | Meaning | +|------|---------| +| `account_not_found` | A switch query has no match | +| `ambiguous_query` | A switch query has multiple matches | +| `selector_resolution_failed` | At least one remove selector is ambiguous or missing | +| `curl_unavailable` | An explicitly required API refresh cannot find curl | +| `registry_error` | State could not be loaded or synchronized before mutation | +| `state_uncertain` | A filesystem or persistence failure occurred after mutation began | +| `usage` | Invalid command usage; exit code 2 | + +## Compatibility Policy + +Clients for schema 1 must: + +- ignore unknown object fields; +- treat unknown error codes as generic handled failures; +- treat unknown enum values as `unknown` or an equivalent fallback; +- use `schema_version` to reject an unsupported breaking schema. + +Within schema 1, adding optional fields, error codes, or enum values is +non-breaking because the fallbacks above are mandatory. Removing or renaming a +field, changing a field type, changing the meaning of an existing enum value, +making an optional field required, or changing command/exit-code semantics is +breaking and requires a schema-version increment. + +## Verification + +Coverage must include: + +1. JSON success, handled-error, and usage-error documents. +2. Flag order independence after pre-scanning for `--json`. +3. CLI-only previous switching and rejection in JSON mode. +4. Cached usage retained across API, auth, and internal refresh failures. +5. API, local, cache, and none usage sources. +6. Canonical plan parsing and schema 3-to-4 registry migration. +7. Complete multi-selector resolution and no deletion on any resolution error. +8. Human output regressions for list, switch, and remove. diff --git a/docs/commands/list.md b/docs/commands/list.md index b5f69f3..345bc58 100644 --- a/docs/commands/list.md +++ b/docs/commands/list.md @@ -8,6 +8,7 @@ codex-auth list --active codex-auth list --live codex-auth list --api codex-auth list --skip-api +codex-auth list --json ``` ## Behavior @@ -25,6 +26,7 @@ codex-auth list --skip-api - `--api` is accepted as an explicit equivalent to default mode. - `--skip-api` forbids remote API calls for this command. - `--live` keeps refreshing the terminal view and requires a TTY. +- `--json` emits one machine-readable JSON document and cannot be combined with `--live`. When local-only refresh is active, only the active account can be updated from local rollout files. Non-active rows use the stored registry snapshot. @@ -37,4 +39,8 @@ When local-only refresh is active, only the active account can be updated from l - In non-live output, `RESET CREDITS` shows the stored reset-credit count when remote usage refresh provides it. - Remote refresh failures can render row overlays such as `401`, `403`, `TimedOut`, or `MissingAuth`. - `LAST ACTIVITY` is based on the last stored usage update time. +- `--json` returns accounts in the same display order and includes the same row numbers shown by the table. +- JSON plan values are final product plans; callers do not map backend Team/Business identifiers. +- JSON usage keeps the displayable snapshot separate from the current invocation's refresh result, so cached values remain visible after refresh failure. +- Non-fatal warnings are written to stderr and are not JSON fields. - Shared table layout policy is documented in [docs/table-layout.md](../table-layout.md). diff --git a/docs/commands/remove.md b/docs/commands/remove.md index 2389de0..b6fa141 100644 --- a/docs/commands/remove.md +++ b/docs/commands/remove.md @@ -7,6 +7,7 @@ codex-auth remove [--api|--skip-api] codex-auth remove --live [--api|--skip-api] codex-auth remove [...] codex-auth remove --all +codex-auth remove ( [...]|--all) --json ``` ## Interactive Remove @@ -30,7 +31,7 @@ codex-auth remove --all `codex-auth remove [...]` removes one or more accounts using stored local data. -Selectors can match: +Selectors first try an exact `account_key` match. Otherwise, they can match: - displayed row number, - alias fragment, @@ -42,11 +43,16 @@ Selector-based remove does not accept `--live`, `--api`, or `--skip-api`. If a selector matches multiple accounts in a TTY, `remove` asks for confirmation. If stdin is not a TTY, ambiguous matches fail and the user must refine the selector. +With `--json`, selector-based remove never asks for confirmation. Every selector +is resolved before deletion. If any selector is ambiguous or missing, no +account is removed and one JSON error reports all selector resolutions. + ## Remove All `codex-auth remove --all` clears all accounts tracked in `registry.json`. - It does not accept `--live`, `--api`, or `--skip-api`. +- `--json` emits one machine-readable JSON document. - It deletes managed account snapshots and matching managed backups. - It leaves malformed or unidentifiable backup files in place. @@ -60,3 +66,8 @@ When the removed account was active: - malformed or unsyncable `auth.json` is left untouched. After a successful deletion, stdout prints `Removed N account(s): ...` in removal order. + +Selector resolution is logically atomic for both human and JSON modes. A +filesystem failure after mutation starts may still leave state partially +changed; JSON reports this as `state_uncertain`, after which callers must list +state again before retrying. diff --git a/docs/commands/switch.md b/docs/commands/switch.md index ba81749..23e2d6e 100644 --- a/docs/commands/switch.md +++ b/docs/commands/switch.md @@ -7,6 +7,7 @@ codex-auth switch - codex-auth switch [--api|--skip-api] codex-auth switch --live [--api|--skip-api] codex-auth switch +codex-auth switch --json ``` ## Previous Switch @@ -17,6 +18,7 @@ codex-auth switch - The command fails when no previous account has been recorded. - The command fails when the recorded previous account was removed. - `switch -` does not accept `--live`, `--api`, or `--skip-api`. +- Previous-account switching is a human CLI convenience and cannot be combined with `--json`. ## Interactive Switch @@ -49,6 +51,9 @@ Selectors can match: If one account matches, it switches immediately. If multiple accounts match, the command falls back to interactive selection. Query mode does not accept `--live`, `--api`, or `--skip-api`. +With `--json`, query mode never opens the interactive picker. Ambiguous queries fail with a JSON error document that includes candidate accounts. +The returned `switched_to.plan` is already normalized to the final product plan. + ## Switch Effects When switching succeeds: @@ -58,3 +63,5 @@ When switching succeeds: 3. `active_account_key` is updated in `registry.json`. 4. `previous_active_account_key` records the account that was active before the switch, when one exists. 5. The success message uses the same identity label as singleton rows, for example `Switched to me(test@example.com)`. + +The previous-account pointer is internal CLI state and is not included in JSON responses. diff --git a/docs/json-api.md b/docs/json-api.md new file mode 100644 index 0000000..1e1c8a9 --- /dev/null +++ b/docs/json-api.md @@ -0,0 +1,220 @@ +# JSON API + +`codex-auth` provides a versioned JSON contract for its same-version GUI and +for automation callers. The GUI invokes commands serially, reads exactly one +JSON document from stdout, and drains stderr without using warning text for +program logic. + +## Compatibility + +Every document contains `"schema_version": 1`. Clients must ignore unknown +object fields and use generic fallbacks for unknown error codes and enum +values. Adding optional fields, error codes, or enum values is non-breaking in +schema 1. Removing or renaming fields, changing field types or existing value +semantics, making optional data required, or changing exit-code behavior is +breaking and requires a schema-version increment. + +Stdout contains exactly one JSON document followed by a newline. Diagnostics +and warnings use stderr and are not part of the JSON contract. + +| Exit code | Meaning | +|-----------|---------| +| `0` | Success | +| `1` | Handled operation error; stdout contains a JSON error document | +| `2` | Invalid command usage; stdout contains a JSON usage error when `--json` was recognized | + +## Supported Commands + +```shell +codex-auth list [--api|--skip-api] [--active] --json +codex-auth switch --json +codex-auth remove [...] --json +codex-auth remove --all --json +``` + +Interactive and live paths are not supported. `switch -` remains a human CLI +shortcut and is rejected when combined with `--json`. + +## Account Objects + +```json +{ + "number": 1, + "account_key": "user-abc::account-123", + "email": "a@example.com", + "alias": "work", + "account_name": null, + "plan": "business", + "auth_mode": "chatgpt", + "active": true, + "created_at": 1730000000, + "last_used_at": 1730001000, + "usage": { + "source": "cache", + "updated_at": 1730002000, + "primary": { + "used_percent": 12.5, + "window_minutes": 300, + "resets_at": 1730010000 + }, + "secondary": null, + "credits": { + "has_credits": false, + "unlimited": false, + "balance": null + }, + "reset_credits": null, + "refresh": { + "requested": true, + "method": "api", + "status": "http_error", + "http_status": 503, + "error_code": null + } + } +} +``` + +`account_key` is stable and should be used for switch/remove calls. `number` is +an ephemeral display selector valid only for the ordering returned by the +current invocation. Empty aliases and account names are `null`. + +`plan` is already normalized by the CLI. Important mappings are: + +| Input observed by the CLI | JSON plan | +|---------------------------|-----------| +| `team`, `self_serve_business_usage_based` | `business` | +| `business`, `enterprise_cbp_usage_based`, `enterprise`, `hc` | `enterprise` | +| `education`, `edu` | `edu` | + +The GUI must not repeat this mapping. When both auth and stored usage provide a +plan, the usage plan wins. + +### Usage + +`usage.source` describes the displayed snapshot: + +| Source | Meaning | +|--------|---------| +| `api` | Confirmed by an API response in this invocation | +| `local` | Read from a local Codex session in this invocation | +| `cache` | Loaded from the registry; a refresh was not requested or did not replace it | +| `none` | No displayable snapshot is available | + +Snapshot fields remain present after refresh failure. `updated_at` is the +stored snapshot update timestamp and may remain unchanged after an equal +successful response. + +`usage.refresh` describes only the current invocation: + +| Field | Values | +|-------|--------| +| `requested` | Boolean | +| `method` | `api`, `local`, or `null` | +| `status` | `not_requested`, `ok`, `no_data`, `http_error`, `missing_auth`, `error` | +| `http_status` | HTTP status or `null` | +| `error_code` | Structured API/internal error name or `null` | + +The credits object retains `has_credits`; callers must not infer it from object +presence or balance. + +## Success Documents + +### List + +```json +{ + "schema_version": 1, + "command": "list", + "active_account_key": "user-abc::account-123", + "accounts": [] +} +``` + +### Switch + +```json +{ + "schema_version": 1, + "command": "switch", + "switched_to": {} +} +``` + +### Remove + +```json +{ + "schema_version": 1, + "command": "remove", + "removed": [], + "new_active_account_key": null +} +``` + +## Error Documents + +```json +{ + "schema_version": 1, + "error": { + "code": "account_not_found", + "message": "no account matches \"work\"" + } +} +``` + +Switch ambiguity adds `candidates` containing account objects. + +Remove resolves every selector before mutation. If any selector is missing or +ambiguous, no account is removed and the error contains all resolutions: + +Candidate objects are abbreviated in this example; each real candidate uses +the complete account-object shape documented above. + +```json +{ + "schema_version": 1, + "error": { + "code": "selector_resolution_failed", + "message": "one or more selectors could not be resolved", + "resolutions": [ + { + "selector": "work", + "status": "ambiguous", + "account_key": null, + "candidates": [ + { + "number": 1, + "account_key": "user-a::account-a", + "email": "work-a@example.com" + }, + { + "number": 2, + "account_key": "user-b::account-b", + "email": "work-b@example.com" + } + ] + }, + { + "selector": "missing", + "status": "not_found", + "account_key": null, + "candidates": [] + } + ] + } +} +``` + +Resolution status values are `resolved`, `ambiguous`, and `not_found`. + +| Error code | Meaning | +|------------|---------| +| `account_not_found` | Switch query has no match | +| `ambiguous_query` | Switch query has multiple matches | +| `selector_resolution_failed` | Remove selector resolution failed atomically | +| `curl_unavailable` | Required API refresh cannot find curl | +| `registry_error` | State failed before mutation began | +| `state_uncertain` | Persistence failed after mutation began; run `list --json` before retrying | +| `usage` | Invalid command usage | diff --git a/docs/schema-migration.md b/docs/schema-migration.md index 8ac53a1..dd91c0d 100644 --- a/docs/schema-migration.md +++ b/docs/schema-migration.md @@ -10,21 +10,21 @@ This document defines how `codex-auth` versions the on-disk `~/.codex/accounts/r ## Current Policy -- The latest binary supports every released schema. Right now that means: - - legacy `version = 2` - - current `schema_version = 4` -- The current binary also accepts current-layout files that still use the old top-level key `version = 3`, or still carry the old global `last_attributed_rollout` shape, and rewrites them once to normalized `schema_version = 4`. +- The current persisted format is `schema_version = 4`. +- The compatibility guarantee for this release is migration from released schema `3` to the final schema `4` format. +- The legacy `version = 2` loader remains available, but it is not the compatibility boundary for this change. +- The current binary accepts current-layout files that still use the old top-level key `version = 3`, or still carry the old global `last_attributed_rollout` shape, and rewrites them to normalized `schema_version = 4`. - If the binary sees a newer `schema_version` than it understands, it fails with `UnsupportedRegistryVersion` and must not write the file. ## Upgrade Behavior - User-visible behavior is always “upgrade directly to the latest supported schema”. - Internally, migrations are implemented as a chain of `Vn -> Vn+1` steps. -- In the current code, supported automatic migration is `version = 2 -> schema_version = 4`; older current-layout schema `3` files are rewritten once as schema `4`. -- Current-layout schema `4` files are also rewritten once when they are missing normalized current fields such as `previous_active_account_key`. +- The guaranteed automatic migration is `schema_version = 3 -> schema_version = 4`. +- The existing legacy `version = 2` loader also rewrites directly to schema `4`. - Users are not expected to install intermediate `codex-auth` versions. -## Released Schemas +## Schema History - `version = 2` - Email-keyed account snapshots @@ -43,6 +43,10 @@ This document defines how `codex-auth` versions the on-disk `~/.codex/accounts/r - Live refresh interval stored as top-level `interval_seconds` - Older `live.interval_seconds` and removed `auto_switch` blocks are omitted on rewrite - Adds top-level `previous_active_account_key` for `codex-auth -` and `codex-auth switch -` + - Persisted plans use final product semantics rather than backend identifiers + - Legacy `team` becomes `business` + - Legacy `business` becomes `enterprise` + - The same conversion applies to per-account `last_usage.plan_type` ## When To Bump `schema_version` diff --git a/docs/table-layout.md b/docs/table-layout.md index 6169357..98ee256 100644 --- a/docs/table-layout.md +++ b/docs/table-layout.md @@ -58,7 +58,7 @@ Wide enough: ```text ACCOUNT PLAN 5H WEEKLY LAST ACTIVITY work-main Business 31% 42% Now -very-long-account-name@example.com Team 88% 71% - +very-long-account-name@example.com Business 88% 71% - ``` Medium: @@ -66,7 +66,7 @@ Medium: ```text ACCOUNT PLAN 5H WEEKLY LAST work-main Business 31% 42% Now -very-l.mple.com Team 88% 71% - +very-l.mple.com Business 88% 71% - ``` Narrow: diff --git a/src/api/usage.zig b/src/api/usage.zig index ef73977..e7d0e8d 100644 --- a/src/api/usage.zig +++ b/src/api/usage.zig @@ -369,15 +369,7 @@ fn parsePlanType(v: std.json.Value) ?registry.PlanType { else => return null, }; - if (std.ascii.eqlIgnoreCase(plan_name, "free")) return .free; - if (std.ascii.eqlIgnoreCase(plan_name, "plus")) return .plus; - if (std.ascii.eqlIgnoreCase(plan_name, "prolite")) return .prolite; - if (std.ascii.eqlIgnoreCase(plan_name, "pro")) return .pro; - if (std.ascii.eqlIgnoreCase(plan_name, "team")) return .team; - if (std.ascii.eqlIgnoreCase(plan_name, "business")) return .business; - if (std.ascii.eqlIgnoreCase(plan_name, "enterprise")) return .enterprise; - if (std.ascii.eqlIgnoreCase(plan_name, "edu")) return .edu; - return .unknown; + return registry.normalizePlanType(plan_name); } fn ceilMinutes(seconds: i64) ?i64 { diff --git a/src/auth/auth.zig b/src/auth/auth.zig index f5cc12c..e3ea7f7 100644 --- a/src/auth/auth.zig +++ b/src/auth/auth.zig @@ -327,15 +327,7 @@ fn base64UrlNoPadDecode(allocator: std.mem.Allocator, input: []const u8) ![]u8 { } fn parsePlanType(s: []const u8) registry.PlanType { - if (std.ascii.eqlIgnoreCase(s, "free")) return .free; - if (std.ascii.eqlIgnoreCase(s, "plus")) return .plus; - if (std.ascii.eqlIgnoreCase(s, "prolite")) return .prolite; - if (std.ascii.eqlIgnoreCase(s, "pro")) return .pro; - if (std.ascii.eqlIgnoreCase(s, "team")) return .team; - if (std.ascii.eqlIgnoreCase(s, "business")) return .business; - if (std.ascii.eqlIgnoreCase(s, "enterprise")) return .enterprise; - if (std.ascii.eqlIgnoreCase(s, "edu")) return .edu; - return .unknown; + return registry.normalizePlanType(s); } fn organizationAccountIdAlloc(allocator: std.mem.Allocator, auth_obj: std.json.ObjectMap) !?[]u8 { diff --git a/src/cli/commands/common.zig b/src/cli/commands/common.zig index e368c90..02ea411 100644 --- a/src/cli/commands/common.zig +++ b/src/cli/commands/common.zig @@ -10,13 +10,31 @@ pub fn usageErrorResult( topic: types.HelpTopic, comptime fmt: []const u8, args: anytype, +) !types.ParseResult { + return usageErrorResultWithJson(allocator, topic, false, fmt, args); +} + +pub fn usageErrorResultWithJson( + allocator: std.mem.Allocator, + topic: types.HelpTopic, + json: bool, + comptime fmt: []const u8, + args: anytype, ) !types.ParseResult { return .{ .usage_error = .{ .topic = topic, .message = try std.fmt.allocPrint(allocator, fmt, args), + .json = json, } }; } +pub fn argsContainFlag(args: []const [:0]const u8, flag: []const u8) bool { + for (args) |raw_arg| { + if (std.mem.eql(u8, std.mem.sliceTo(raw_arg, 0), flag)) return true; + } + return false; +} + pub fn parseSimpleCommandArgs( allocator: std.mem.Allocator, command_name: []const u8, diff --git a/src/cli/commands/list.zig b/src/cli/commands/list.zig index bdfa193..b713f93 100644 --- a/src/cli/commands/list.zig +++ b/src/cli/commands/list.zig @@ -3,6 +3,7 @@ const types = @import("../types.zig"); const common = @import("common.zig"); pub fn parse(allocator: std.mem.Allocator, args: []const [:0]const u8) !types.ParseResult { + const json_requested = common.argsContainFlag(args, "--json"); if (args.len == 1 and common.isHelpFlag(std.mem.sliceTo(args[0], 0))) { return .{ .command = .{ .help = .list } }; } @@ -11,34 +12,42 @@ pub fn parse(allocator: std.mem.Allocator, args: []const [:0]const u8) !types.Pa for (args) |raw_arg| { const arg = std.mem.sliceTo(raw_arg, 0); if (std.mem.eql(u8, arg, "--live")) { - if (opts.live) return common.usageErrorResult(allocator, .list, "duplicate `--live` for `list`.", .{}); + if (opts.live) return common.usageErrorResultWithJson(allocator, .list, json_requested, "duplicate `--live` for `list`.", .{}); opts.live = true; continue; } + if (std.mem.eql(u8, arg, "--json")) { + if (opts.json) return common.usageErrorResultWithJson(allocator, .list, true, "duplicate `--json` for `list`.", .{}); + opts.json = true; + continue; + } if (std.mem.eql(u8, arg, "--active")) { - if (opts.active_only) return common.usageErrorResult(allocator, .list, "duplicate `--active` for `list`.", .{}); + if (opts.active_only) return common.usageErrorResultWithJson(allocator, .list, json_requested, "duplicate `--active` for `list`.", .{}); opts.active_only = true; continue; } if (std.mem.eql(u8, arg, "--api")) { switch (opts.api_mode) { .default => opts.api_mode = .force_api, - .force_api => return common.usageErrorResult(allocator, .list, "duplicate `--api` for `list`.", .{}), - .skip_api => return common.usageErrorResult(allocator, .list, "`--api` cannot be combined with `--skip-api` for `list`.", .{}), + .force_api => return common.usageErrorResultWithJson(allocator, .list, json_requested, "duplicate `--api` for `list`.", .{}), + .skip_api => return common.usageErrorResultWithJson(allocator, .list, json_requested, "`--api` cannot be combined with `--skip-api` for `list`.", .{}), } continue; } if (std.mem.eql(u8, arg, "--skip-api")) { switch (opts.api_mode) { .default => opts.api_mode = .skip_api, - .skip_api => return common.usageErrorResult(allocator, .list, "duplicate `--skip-api` for `list`.", .{}), - .force_api => return common.usageErrorResult(allocator, .list, "`--skip-api` cannot be combined with `--api` for `list`.", .{}), + .skip_api => return common.usageErrorResultWithJson(allocator, .list, json_requested, "duplicate `--skip-api` for `list`.", .{}), + .force_api => return common.usageErrorResultWithJson(allocator, .list, json_requested, "`--skip-api` cannot be combined with `--api` for `list`.", .{}), } continue; } - if (common.isHelpFlag(arg)) return common.usageErrorResult(allocator, .list, "`--help` must be used by itself for `list`.", .{}); - if (std.mem.startsWith(u8, arg, "-")) return common.usageErrorResult(allocator, .list, "unknown flag `{s}` for `list`.", .{arg}); - return common.usageErrorResult(allocator, .list, "unexpected argument `{s}` for `list`.", .{arg}); + if (common.isHelpFlag(arg)) return common.usageErrorResultWithJson(allocator, .list, json_requested, "`--help` must be used by itself for `list`.", .{}); + if (std.mem.startsWith(u8, arg, "-")) return common.usageErrorResultWithJson(allocator, .list, json_requested, "unknown flag `{s}` for `list`.", .{arg}); + return common.usageErrorResultWithJson(allocator, .list, json_requested, "unexpected argument `{s}` for `list`.", .{arg}); + } + if (opts.live and opts.json) { + return common.usageErrorResultWithJson(allocator, .list, true, "`--live` cannot be combined with `--json`.", .{}); } return .{ .command = .{ .list = opts } }; } diff --git a/src/cli/commands/remove.zig b/src/cli/commands/remove.zig index 9a1aebc..73a8f59 100644 --- a/src/cli/commands/remove.zig +++ b/src/cli/commands/remove.zig @@ -3,13 +3,15 @@ const types = @import("../types.zig"); const common = @import("common.zig"); pub fn parse(allocator: std.mem.Allocator, args: []const [:0]const u8) !types.ParseResult { + const json_requested = common.argsContainFlag(args, "--json"); if (args.len == 1 and common.isHelpFlag(std.mem.sliceTo(args[0], 0))) { return .{ .command = .{ .help = .remove_account } }; } var selectors = std.ArrayList([]const u8).empty; - errdefer common.freeOwnedStringList(allocator, selectors.items); defer selectors.deinit(allocator); + var selectors_transferred = false; + defer if (!selectors_transferred) common.freeOwnedStringList(allocator, selectors.items); var opts: types.RemoveOptions = .{ .selectors = &.{}, .all = false, @@ -17,46 +19,58 @@ pub fn parse(allocator: std.mem.Allocator, args: []const [:0]const u8) !types.Pa for (args) |raw_arg| { const arg = std.mem.sliceTo(raw_arg, 0); if (std.mem.eql(u8, arg, "--live")) { - if (opts.live) return common.usageErrorResult(allocator, .remove_account, "duplicate `--live` for `remove`.", .{}); + if (opts.live) return common.usageErrorResultWithJson(allocator, .remove_account, json_requested, "duplicate `--live` for `remove`.", .{}); opts.live = true; continue; } + if (std.mem.eql(u8, arg, "--json")) { + if (opts.json) return common.usageErrorResultWithJson(allocator, .remove_account, true, "duplicate `--json` for `remove`.", .{}); + opts.json = true; + continue; + } if (std.mem.eql(u8, arg, "--api")) { switch (opts.api_mode) { .default => opts.api_mode = .force_api, - .force_api => return common.usageErrorResult(allocator, .remove_account, "duplicate `--api` for `remove`.", .{}), - .skip_api => return common.usageErrorResult(allocator, .remove_account, "`--api` cannot be combined with `--skip-api` for `remove`.", .{}), + .force_api => return common.usageErrorResultWithJson(allocator, .remove_account, json_requested, "duplicate `--api` for `remove`.", .{}), + .skip_api => return common.usageErrorResultWithJson(allocator, .remove_account, json_requested, "`--api` cannot be combined with `--skip-api` for `remove`.", .{}), } continue; } if (std.mem.eql(u8, arg, "--skip-api")) { switch (opts.api_mode) { .default => opts.api_mode = .skip_api, - .skip_api => return common.usageErrorResult(allocator, .remove_account, "duplicate `--skip-api` for `remove`.", .{}), - .force_api => return common.usageErrorResult(allocator, .remove_account, "`--skip-api` cannot be combined with `--api` for `remove`.", .{}), + .skip_api => return common.usageErrorResultWithJson(allocator, .remove_account, json_requested, "duplicate `--skip-api` for `remove`.", .{}), + .force_api => return common.usageErrorResultWithJson(allocator, .remove_account, json_requested, "`--skip-api` cannot be combined with `--api` for `remove`.", .{}), } continue; } if (std.mem.eql(u8, arg, "--all")) { if (opts.all or selectors.items.len != 0) { - return common.usageErrorResult(allocator, .remove_account, "`remove` cannot combine `--all` with another selector.", .{}); + return common.usageErrorResultWithJson(allocator, .remove_account, json_requested, "`remove` cannot combine `--all` with another selector.", .{}); } opts.all = true; continue; } - if (std.mem.startsWith(u8, arg, "-")) return common.usageErrorResult(allocator, .remove_account, "unknown flag `{s}` for `remove`.", .{arg}); - if (opts.all) return common.usageErrorResult(allocator, .remove_account, "`remove` cannot combine `--all` with another selector.", .{}); + if (std.mem.startsWith(u8, arg, "-")) return common.usageErrorResultWithJson(allocator, .remove_account, json_requested, "unknown flag `{s}` for `remove`.", .{arg}); + if (opts.all) return common.usageErrorResultWithJson(allocator, .remove_account, json_requested, "`remove` cannot combine `--all` with another selector.", .{}); try selectors.append(allocator, try allocator.dupe(u8, arg)); } + if (opts.live and opts.json) { + return common.usageErrorResultWithJson(allocator, .remove_account, true, "`--live` cannot be combined with `--json`.", .{}); + } + if (opts.json and !opts.all and selectors.items.len == 0) { + return common.usageErrorResultWithJson(allocator, .remove_account, true, "`remove --json` requires selectors or `--all`.", .{}); + } if ((opts.live or opts.api_mode != .default) and (opts.all or selectors.items.len != 0)) { - common.freeOwnedStringList(allocator, selectors.items); - return common.usageErrorResult( + return common.usageErrorResultWithJson( allocator, .remove_account, + json_requested, "`remove ...` and `remove --all` do not support `--live`, `--api`, or `--skip-api`.", .{}, ); } opts.selectors = try selectors.toOwnedSlice(allocator); + selectors_transferred = true; return .{ .command = .{ .remove_account = opts } }; } diff --git a/src/cli/commands/switch.zig b/src/cli/commands/switch.zig index 16f2eeb..794d894 100644 --- a/src/cli/commands/switch.zig +++ b/src/cli/commands/switch.zig @@ -3,6 +3,7 @@ const types = @import("../types.zig"); const common = @import("common.zig"); pub fn parse(allocator: std.mem.Allocator, args: []const [:0]const u8) !types.ParseResult { + const json_requested = common.argsContainFlag(args, "--json"); if (args.len == 1 and common.isHelpFlag(std.mem.sliceTo(args[0], 0))) { return .{ .command = .{ .help = .switch_account } }; } @@ -13,21 +14,29 @@ pub fn parse(allocator: std.mem.Allocator, args: []const [:0]const u8) !types.Pa if (std.mem.eql(u8, arg, "--live")) { if (opts.live) { freeTarget(allocator, opts.target); - return common.usageErrorResult(allocator, .switch_account, "duplicate `--live` for `switch`.", .{}); + return common.usageErrorResultWithJson(allocator, .switch_account, json_requested, "duplicate `--live` for `switch`.", .{}); } opts.live = true; continue; } + if (std.mem.eql(u8, arg, "--json")) { + if (opts.json) { + freeTarget(allocator, opts.target); + return common.usageErrorResultWithJson(allocator, .switch_account, true, "duplicate `--json` for `switch`.", .{}); + } + opts.json = true; + continue; + } if (std.mem.eql(u8, arg, "--api")) { switch (opts.api_mode) { .default => opts.api_mode = .force_api, .force_api => { freeTarget(allocator, opts.target); - return common.usageErrorResult(allocator, .switch_account, "duplicate `--api` for `switch`.", .{}); + return common.usageErrorResultWithJson(allocator, .switch_account, json_requested, "duplicate `--api` for `switch`.", .{}); }, .skip_api => { freeTarget(allocator, opts.target); - return common.usageErrorResult(allocator, .switch_account, "`--api` cannot be combined with `--skip-api` for `switch`.", .{}); + return common.usageErrorResultWithJson(allocator, .switch_account, json_requested, "`--api` cannot be combined with `--skip-api` for `switch`.", .{}); }, } continue; @@ -37,33 +46,44 @@ pub fn parse(allocator: std.mem.Allocator, args: []const [:0]const u8) !types.Pa .default => opts.api_mode = .skip_api, .skip_api => { freeTarget(allocator, opts.target); - return common.usageErrorResult(allocator, .switch_account, "duplicate `--skip-api` for `switch`.", .{}); + return common.usageErrorResultWithJson(allocator, .switch_account, json_requested, "duplicate `--skip-api` for `switch`.", .{}); }, .force_api => { freeTarget(allocator, opts.target); - return common.usageErrorResult(allocator, .switch_account, "`--skip-api` cannot be combined with `--api` for `switch`.", .{}); + return common.usageErrorResultWithJson(allocator, .switch_account, json_requested, "`--skip-api` cannot be combined with `--api` for `switch`.", .{}); }, } continue; } if (std.mem.startsWith(u8, arg, "-") and !std.mem.eql(u8, arg, "-")) { freeTarget(allocator, opts.target); - return common.usageErrorResult(allocator, .switch_account, "unknown flag `{s}` for `switch`.", .{arg}); + return common.usageErrorResultWithJson(allocator, .switch_account, json_requested, "unknown flag `{s}` for `switch`.", .{arg}); } if (opts.target != .picker) { freeTarget(allocator, opts.target); - return common.usageErrorResult(allocator, .switch_account, "unexpected extra query `{s}` for `switch`.", .{arg}); + return common.usageErrorResultWithJson(allocator, .switch_account, json_requested, "unexpected extra query `{s}` for `switch`.", .{arg}); } opts.target = if (std.mem.eql(u8, arg, "-")) .previous else .{ .query = try allocator.dupe(u8, arg) }; } + if (opts.live and opts.json) { + freeTarget(allocator, opts.target); + return common.usageErrorResultWithJson(allocator, .switch_account, true, "`--live` cannot be combined with `--json`.", .{}); + } + if (opts.json and opts.target == .picker) { + return common.usageErrorResultWithJson(allocator, .switch_account, true, "`switch --json` requires an explicit account query.", .{}); + } + if (opts.json and opts.target == .previous) { + return common.usageErrorResultWithJson(allocator, .switch_account, true, "previous-account switching is CLI-only and cannot be combined with `--json`.", .{}); + } if (opts.target != .picker and (opts.api_mode != .default or opts.live)) { freeTarget(allocator, opts.target); - return common.usageErrorResult( + return common.usageErrorResultWithJson( allocator, .switch_account, + json_requested, "`switch -|` does not support `--live`, `--api`, or `--skip-api`.", .{}, ); diff --git a/src/cli/help.zig b/src/cli/help.zig index 70142ed..4196e51 100644 --- a/src/cli/help.zig +++ b/src/cli/help.zig @@ -37,7 +37,7 @@ pub fn writeHelp( try writeCommandSummary(out, use_color, "help ", "Show command-specific help"); try writeCommandSummary(out, use_color, "--version, -V", "Show version"); try writeCommandSummary(out, use_color, "-", "Switch to the previous active account"); - try writeCommandSummary(out, use_color, "list [--live] [--active] [--api|--skip-api]", "List available accounts"); + try writeCommandSummary(out, use_color, "list [--live] [--active] [--api|--skip-api] [--json]", "List available accounts"); try writeCommandSummary(out, use_color, "login [--device-auth]", "Login and add the current account"); try writeCommandSummary(out, use_color, "import", "Import auth files or rebuild registry"); try writeCommandDetail(out, use_color, "import [--alias ]"); @@ -48,10 +48,12 @@ pub fn writeHelp( try writeCommandDetail(out, use_color, "switch -"); try writeCommandDetail(out, use_color, "switch [--live] [--api|--skip-api]"); try writeCommandDetail(out, use_color, "switch "); + try writeCommandDetail(out, use_color, "switch --json"); try writeCommandSummary(out, use_color, "remove", "Remove one or more accounts"); try writeCommandDetail(out, use_color, "remove [--live] [--api|--skip-api]"); try writeCommandDetail(out, use_color, "remove ..."); try writeCommandDetail(out, use_color, "remove --all"); + try writeCommandDetail(out, use_color, "remove (...|--all) --json"); try writeCommandSummary(out, use_color, "alias", "Set or clear account aliases"); try writeCommandDetail(out, use_color, "alias set "); try writeCommandDetail(out, use_color, "alias clear "); @@ -193,7 +195,7 @@ fn writeUsageLines(out: *std.Io.Writer, topic: HelpTopic) !void { try out.writeAll(" codex-auth --help\n"); try out.writeAll(" codex-auth help \n"); }, - .list => try out.writeAll(" codex-auth list [--live] [--active] [--api|--skip-api]\n"), + .list => try out.writeAll(" codex-auth list [--live] [--active] [--api|--skip-api] [--json]\n"), .login => { try out.writeAll(" codex-auth login\n"); try out.writeAll(" codex-auth login --device-auth\n"); @@ -211,11 +213,13 @@ fn writeUsageLines(out: *std.Io.Writer, topic: HelpTopic) !void { try out.writeAll(" codex-auth switch -\n"); try out.writeAll(" codex-auth switch [--live] [--api|--skip-api]\n"); try out.writeAll(" codex-auth switch \n"); + try out.writeAll(" codex-auth switch --json\n"); }, .remove_account => { try out.writeAll(" codex-auth remove [--live] [--api|--skip-api]\n"); try out.writeAll(" codex-auth remove ...\n"); try out.writeAll(" codex-auth remove --all\n"); + try out.writeAll(" codex-auth remove (...|--all) --json\n"); }, .alias => { try out.writeAll(" codex-auth alias set \n"); @@ -263,6 +267,7 @@ fn writeOptionLines(out: *std.Io.Writer, topic: HelpTopic) !void { try out.writeAll(" --active Refresh only the active account before rendering.\n"); try out.writeAll(" --api Load usage and account data from APIs.\n"); try out.writeAll(" --skip-api Load usage and account data from local data only (may be inaccurate).\n"); + try out.writeAll(" --json Emit one machine-readable JSON document.\n"); }, .login => { try out.writeAll(" --device-auth Run `codex login --device-auth` before adding the account.\n"); @@ -281,6 +286,7 @@ fn writeOptionLines(out: *std.Io.Writer, topic: HelpTopic) !void { try out.writeAll(" --live Open the live switch UI.\n"); try out.writeAll(" --api Load usage and account data from APIs.\n"); try out.writeAll(" --skip-api Load usage and account data from local data only (may be inaccurate).\n"); + try out.writeAll(" --json Emit one machine-readable JSON document for non-interactive switch.\n"); try out.writeAll(" \n"); try out.writeAll(" Switch directly when the target resolves to one account.\n"); try out.writeAll(" - Switch to the previous active account.\n"); @@ -290,6 +296,7 @@ fn writeOptionLines(out: *std.Io.Writer, topic: HelpTopic) !void { try out.writeAll(" --api Load usage and account data from APIs.\n"); try out.writeAll(" --skip-api Load usage and account data from local data only (may be inaccurate).\n"); try out.writeAll(" --all Remove every stored account.\n"); + try out.writeAll(" --json Emit one machine-readable JSON document for non-interactive remove.\n"); try out.writeAll(" ...\n"); try out.writeAll(" Remove one or more matching accounts.\n"); }, diff --git a/src/cli/json_output.zig b/src/cli/json_output.zig new file mode 100644 index 0000000..02ef96d --- /dev/null +++ b/src/cli/json_output.zig @@ -0,0 +1,316 @@ +const std = @import("std"); +const io_util = @import("../core/io_util.zig"); +const registry = @import("../registry/root.zig"); +const results = @import("../workflows/results.zig"); + +const schema_version: u32 = 1; + +pub fn printListResult(result: *const results.ListResult) !void { + var stdout: io_util.Stdout = undefined; + stdout.init(); + const out = stdout.out(); + try writeListResult(out, result); + try out.writeAll("\n"); + try out.flush(); +} + +pub fn printSwitchResult(result: *const results.SwitchResult) !void { + var stdout: io_util.Stdout = undefined; + stdout.init(); + const out = stdout.out(); + try writeSwitchResult(out, result); + try out.writeAll("\n"); + try out.flush(); +} + +pub fn printRemoveResult(result: *const results.RemoveResult) !void { + var stdout: io_util.Stdout = undefined; + stdout.init(); + const out = stdout.out(); + try writeRemoveResult(out, result); + try out.writeAll("\n"); + try out.flush(); +} + +pub fn printError(code: []const u8, message: []const u8, candidates: ?[]const results.AccountView) !void { + var stdout: io_util.Stdout = undefined; + stdout.init(); + const out = stdout.out(); + try writeError(out, code, message, candidates); + try out.writeAll("\n"); + try out.flush(); +} + +pub fn printUsageError(message: []const u8) !void { + try printError("usage", message, null); +} + +pub fn printSelectorResolutionError(resolutions: []const results.SelectorResolutionView) !void { + var stdout: io_util.Stdout = undefined; + stdout.init(); + const out = stdout.out(); + try writeSelectorResolutionError(out, resolutions); + try out.writeAll("\n"); + try out.flush(); +} + +pub fn writeListResult(out: *std.Io.Writer, result: *const results.ListResult) !void { + var jw: std.json.Stringify = .{ .writer = out, .options = .{} }; + try jw.beginObject(); + try writeSchemaVersion(&jw); + try jw.objectField("command"); + try jw.write("list"); + try jw.objectField("active_account_key"); + try jw.write(result.active_account_key); + try jw.objectField("accounts"); + try writeAccountArray(&jw, result.accounts); + try jw.endObject(); +} + +pub fn writeSwitchResult(out: *std.Io.Writer, result: *const results.SwitchResult) !void { + var jw: std.json.Stringify = .{ .writer = out, .options = .{} }; + try jw.beginObject(); + try writeSchemaVersion(&jw); + try jw.objectField("command"); + try jw.write("switch"); + try jw.objectField("switched_to"); + try writeAccount(&jw, &result.switched_to); + try jw.endObject(); +} + +pub fn writeRemoveResult(out: *std.Io.Writer, result: *const results.RemoveResult) !void { + var jw: std.json.Stringify = .{ .writer = out, .options = .{} }; + try jw.beginObject(); + try writeSchemaVersion(&jw); + try jw.objectField("command"); + try jw.write("remove"); + try jw.objectField("removed"); + try writeAccountArray(&jw, result.removed); + try jw.objectField("new_active_account_key"); + try jw.write(result.new_active_account_key); + try jw.endObject(); +} + +pub fn writeError( + out: *std.Io.Writer, + code: []const u8, + message: []const u8, + candidates: ?[]const results.AccountView, +) !void { + var jw: std.json.Stringify = .{ .writer = out, .options = .{} }; + try jw.beginObject(); + try writeSchemaVersion(&jw); + try jw.objectField("error"); + try jw.beginObject(); + try jw.objectField("code"); + try jw.write(code); + try jw.objectField("message"); + try jw.write(message); + if (candidates) |items| { + try jw.objectField("candidates"); + try writeAccountArray(&jw, items); + } + try jw.endObject(); + try jw.endObject(); +} + +pub fn writeSelectorResolutionError( + out: *std.Io.Writer, + resolutions: []const results.SelectorResolutionView, +) !void { + var jw: std.json.Stringify = .{ .writer = out, .options = .{} }; + try jw.beginObject(); + try writeSchemaVersion(&jw); + try jw.objectField("error"); + try jw.beginObject(); + try jw.objectField("code"); + try jw.write("selector_resolution_failed"); + try jw.objectField("message"); + try jw.write("one or more selectors could not be resolved"); + try jw.objectField("resolutions"); + try jw.beginArray(); + for (resolutions) |*resolution| { + try writeSelectorResolution(&jw, resolution); + } + try jw.endArray(); + try jw.endObject(); + try jw.endObject(); +} + +fn writeSchemaVersion(jw: *std.json.Stringify) !void { + try jw.objectField("schema_version"); + try jw.write(schema_version); +} + +fn writeAccountArray(jw: *std.json.Stringify, accounts: []const results.AccountView) !void { + try jw.beginArray(); + for (accounts) |*account| { + try writeAccount(jw, account); + } + try jw.endArray(); +} + +fn writeAccount(jw: *std.json.Stringify, account: *const results.AccountView) !void { + try jw.beginObject(); + try jw.objectField("number"); + try jw.write(account.number); + try jw.objectField("account_key"); + try jw.write(account.account_key); + try jw.objectField("email"); + try jw.write(account.email); + try jw.objectField("alias"); + try jw.write(account.alias); + try jw.objectField("account_name"); + try jw.write(account.account_name); + try jw.objectField("plan"); + try writeOptionalText(jw, planName(account.plan)); + try jw.objectField("auth_mode"); + try writeOptionalText(jw, authModeName(account.auth_mode)); + try jw.objectField("active"); + try jw.write(account.active); + try jw.objectField("created_at"); + try jw.write(account.created_at); + try jw.objectField("last_used_at"); + try jw.write(account.last_used_at); + try jw.objectField("usage"); + try writeUsage(jw, &account.usage); + try jw.endObject(); +} + +fn writeUsage(jw: *std.json.Stringify, usage: *const results.UsageView) !void { + try jw.beginObject(); + try jw.objectField("source"); + try jw.write(usageSourceName(usage.source)); + try jw.objectField("updated_at"); + try jw.write(usage.updated_at); + try jw.objectField("primary"); + try writeWindow(jw, usage.primary); + try jw.objectField("secondary"); + try writeWindow(jw, usage.secondary); + try jw.objectField("credits"); + try writeCredits(jw, usage.credits); + try jw.objectField("reset_credits"); + try jw.write(usage.reset_credits); + try jw.objectField("refresh"); + try writeUsageRefresh(jw, &usage.refresh); + try jw.endObject(); +} + +fn usageSourceName(source: results.UsageSource) []const u8 { + return switch (source) { + .api => "api", + .local => "local", + .cache => "cache", + .none => "none", + }; +} + +fn writeUsageRefresh(jw: *std.json.Stringify, refresh: *const results.UsageRefreshView) !void { + try jw.beginObject(); + try jw.objectField("requested"); + try jw.write(refresh.requested); + try jw.objectField("method"); + if (refresh.method) |method| { + try jw.write(switch (method) { + .api => "api", + .local => "local", + }); + } else { + try jw.write(null); + } + try jw.objectField("status"); + try jw.write(switch (refresh.status) { + .not_requested => "not_requested", + .ok => "ok", + .no_data => "no_data", + .http_error => "http_error", + .missing_auth => "missing_auth", + .error_status => "error", + }); + try jw.objectField("http_status"); + try jw.write(refresh.http_status); + try jw.objectField("error_code"); + try jw.write(refresh.error_code); + try jw.endObject(); +} + +fn writeWindow(jw: *std.json.Stringify, window: ?registry.RateLimitWindow) !void { + const value = window orelse { + try jw.write(null); + return; + }; + try jw.beginObject(); + try jw.objectField("used_percent"); + try jw.write(value.used_percent); + try jw.objectField("window_minutes"); + try jw.write(value.window_minutes); + try jw.objectField("resets_at"); + try jw.write(value.resets_at); + try jw.endObject(); +} + +fn writeCredits(jw: *std.json.Stringify, credits: ?results.CreditsView) !void { + const value = credits orelse { + try jw.write(null); + return; + }; + try jw.beginObject(); + try jw.objectField("has_credits"); + try jw.write(value.has_credits); + try jw.objectField("unlimited"); + try jw.write(value.unlimited); + try jw.objectField("balance"); + try jw.write(value.balance); + try jw.endObject(); +} + +fn writeOptionalText(jw: *std.json.Stringify, value: ?[]const u8) !void { + if (value) |text| { + try jw.write(text); + } else { + try jw.write(null); + } +} + +fn planName(plan: ?registry.PlanType) ?[]const u8 { + const value = plan orelse return null; + return switch (value) { + .free => "free", + .go => "go", + .plus => "plus", + .prolite => "prolite", + .pro => "pro", + .business => "business", + .enterprise => "enterprise", + .edu => "edu", + .unknown => "unknown", + }; +} + +fn writeSelectorResolution( + jw: *std.json.Stringify, + resolution: *const results.SelectorResolutionView, +) !void { + try jw.beginObject(); + try jw.objectField("selector"); + try jw.write(resolution.selector); + try jw.objectField("status"); + try jw.write(switch (resolution.status) { + .resolved => "resolved", + .ambiguous => "ambiguous", + .not_found => "not_found", + }); + try jw.objectField("account_key"); + try jw.write(resolution.account_key); + try jw.objectField("candidates"); + try writeAccountArray(jw, resolution.candidates); + try jw.endObject(); +} + +fn authModeName(auth_mode: ?registry.AuthMode) ?[]const u8 { + const value = auth_mode orelse return null; + return switch (value) { + .chatgpt => "chatgpt", + .apikey => "apikey", + }; +} diff --git a/src/cli/root.zig b/src/cli/root.zig index 38fcb21..0200390 100644 --- a/src/cli/root.zig +++ b/src/cli/root.zig @@ -1,6 +1,7 @@ pub const types = @import("types.zig"); pub const commands = @import("commands/root.zig"); pub const help = @import("help.zig"); +pub const json_output = @import("json_output.zig"); pub const output = @import("output.zig"); pub const login = @import("login.zig"); pub const picker = @import("picker.zig"); diff --git a/src/cli/types.zig b/src/cli/types.zig index 3bf3ab6..e65792d 100644 --- a/src/cli/types.zig +++ b/src/cli/types.zig @@ -8,6 +8,7 @@ pub const ListOptions = struct { live: bool = false, api_mode: ApiMode = .default, active_only: bool = false, + json: bool = false, }; pub const LoginOptions = struct { device_auth: bool = false, @@ -33,12 +34,14 @@ pub const SwitchOptions = struct { target: SwitchTarget = .picker, live: bool = false, api_mode: ApiMode = .default, + json: bool = false, }; pub const RemoveOptions = struct { selectors: [][]const u8, all: bool, live: bool = false, api_mode: ApiMode = .default, + json: bool = false, }; pub const AliasSetOptions = struct { selector: []u8, @@ -101,6 +104,7 @@ pub const Command = union(enum) { pub const UsageError = struct { topic: HelpTopic, message: []u8, + json: bool = false, }; pub const ParseResult = union(enum) { diff --git a/src/registry/account_ops.zig b/src/registry/account_ops.zig index 682a433..102d38c 100644 --- a/src/registry/account_ops.zig +++ b/src/registry/account_ops.zig @@ -20,7 +20,7 @@ const freeAccountRecord = common.freeAccountRecord; const freeRateLimitSnapshot = common.freeRateLimitSnapshot; const replaceOptionalStringAlloc = common.replaceOptionalStringAlloc; const cloneOptionalStringAlloc = common.cloneOptionalStringAlloc; -const resolvePlan = common.resolvePlan; +const resolveDisplayPlan = common.resolveDisplayPlan; const readFileIfExists = clean.readFileIfExists; const fileEqualsBytes = clean.fileEqualsBytes; const backupDir = clean.backupDir; @@ -446,9 +446,12 @@ pub fn hasStoredAccountName(rec: *const AccountRecord) bool { return account_name.len != 0; } -pub fn isTeamAccount(rec: *const AccountRecord) bool { - const plan = resolvePlan(rec) orelse return false; - return plan == .team; +pub fn isWorkspaceAccount(rec: *const AccountRecord) bool { + const plan = resolveDisplayPlan(rec) orelse return false; + return switch (plan) { + .business, .enterprise, .edu => true, + else => false, + }; } pub fn inAccountNameRefreshScope(reg: *const Registry, chatgpt_user_id: []const u8, rec: *const AccountRecord) bool { @@ -459,29 +462,29 @@ pub fn inAccountNameRefreshScope(reg: *const Registry, chatgpt_user_id: []const pub fn hasMissingAccountNameForUser(reg: *const Registry, chatgpt_user_id: []const u8) bool { for (reg.accounts.items) |rec| { if (!inAccountNameRefreshScope(reg, chatgpt_user_id, &rec)) continue; - if (isTeamAccount(&rec) and !hasStoredAccountName(&rec)) return true; + if (isWorkspaceAccount(&rec) and !hasStoredAccountName(&rec)) return true; } return false; } -pub fn shouldFetchTeamAccountNamesForUser(reg: *const Registry, chatgpt_user_id: []const u8) bool { +pub fn shouldFetchWorkspaceAccountNamesForUser(reg: *const Registry, chatgpt_user_id: []const u8) bool { var account_count: usize = 0; - var has_team_account = false; - var has_missing_team_account_name = false; + var has_workspace_account = false; + var has_missing_workspace_account_name = false; for (reg.accounts.items) |rec| { if (!inAccountNameRefreshScope(reg, chatgpt_user_id, &rec)) continue; account_count += 1; - if (!isTeamAccount(&rec)) continue; + if (!isWorkspaceAccount(&rec)) continue; - has_team_account = true; + has_workspace_account = true; if (!hasStoredAccountName(&rec)) { - has_missing_team_account_name = true; + has_missing_workspace_account_name = true; } } - if (!has_team_account or !has_missing_team_account_name) return false; + if (!has_workspace_account or !has_missing_workspace_account_name) return false; return account_count > 1; } @@ -510,7 +513,7 @@ pub fn applyAccountNamesForUser( break; } - if (!matched and !isTeamAccount(rec) and !hasStoredAccountName(rec)) continue; + if (!matched and !isWorkspaceAccount(rec) and !hasStoredAccountName(rec)) continue; if (try replaceOptionalStringAlloc(allocator, &rec.account_name, account_name)) { changed = true; } diff --git a/src/registry/common.zig b/src/registry/common.zig index 27d43cc..3a97f9d 100644 --- a/src/registry/common.zig +++ b/src/registry/common.zig @@ -6,7 +6,7 @@ const c_time = @cImport({ @cInclude("time.h"); }); -pub const PlanType = enum { free, plus, prolite, pro, team, business, enterprise, edu, unknown }; +pub const PlanType = enum { free, go, plus, prolite, pro, business, enterprise, edu, unknown }; pub const AuthMode = enum { chatgpt, apikey }; pub const current_schema_version: u32 = 4; pub const min_supported_schema_version: u32 = 2; @@ -122,10 +122,10 @@ pub fn resolveDisplayPlan(rec: *const AccountRecord) ?PlanType { pub fn planLabel(plan: PlanType) []const u8 { return switch (plan) { .free => "Free", + .go => "Go", .plus => "Plus", .prolite => "Pro Lite", .pro => "Pro", - .team => "Business", .business => "Business", .enterprise => "Enterprise", .edu => "Edu", diff --git a/src/registry/parse.zig b/src/registry/parse.zig index 842ad49..f3a6655 100644 --- a/src/registry/parse.zig +++ b/src/registry/parse.zig @@ -9,15 +9,33 @@ const RateLimitWindow = common.RateLimitWindow; const RolloutSignature = common.RolloutSignature; const CreditsSnapshot = common.CreditsSnapshot; -pub fn parsePlanType(s: []const u8) ?PlanType { - if (std.mem.eql(u8, s, "free")) return .free; - if (std.mem.eql(u8, s, "plus")) return .plus; - if (std.mem.eql(u8, s, "prolite")) return .prolite; - if (std.mem.eql(u8, s, "pro")) return .pro; - if (std.mem.eql(u8, s, "team")) return .team; - if (std.mem.eql(u8, s, "business")) return .business; - if (std.mem.eql(u8, s, "enterprise")) return .enterprise; - if (std.mem.eql(u8, s, "edu")) return .edu; +pub fn normalizePlanType(s: []const u8) PlanType { + if (std.ascii.eqlIgnoreCase(s, "free")) return .free; + if (std.ascii.eqlIgnoreCase(s, "go")) return .go; + if (std.ascii.eqlIgnoreCase(s, "plus")) return .plus; + if (std.ascii.eqlIgnoreCase(s, "prolite")) return .prolite; + if (std.ascii.eqlIgnoreCase(s, "pro")) return .pro; + if (std.ascii.eqlIgnoreCase(s, "team") or + std.ascii.eqlIgnoreCase(s, "self_serve_business_usage_based")) return .business; + if (std.ascii.eqlIgnoreCase(s, "business") or + std.ascii.eqlIgnoreCase(s, "enterprise_cbp_usage_based") or + std.ascii.eqlIgnoreCase(s, "enterprise") or + std.ascii.eqlIgnoreCase(s, "hc")) return .enterprise; + if (std.ascii.eqlIgnoreCase(s, "education") or std.ascii.eqlIgnoreCase(s, "edu")) return .edu; + return .unknown; +} + +pub fn parseStoredPlanType(s: []const u8, schema_version: u32) PlanType { + if (schema_version < 4) return normalizePlanType(s); + + if (std.ascii.eqlIgnoreCase(s, "free")) return .free; + if (std.ascii.eqlIgnoreCase(s, "go")) return .go; + if (std.ascii.eqlIgnoreCase(s, "plus")) return .plus; + if (std.ascii.eqlIgnoreCase(s, "prolite")) return .prolite; + if (std.ascii.eqlIgnoreCase(s, "pro")) return .pro; + if (std.ascii.eqlIgnoreCase(s, "business")) return .business; + if (std.ascii.eqlIgnoreCase(s, "enterprise")) return .enterprise; + if (std.ascii.eqlIgnoreCase(s, "edu")) return .edu; return .unknown; } @@ -27,7 +45,7 @@ pub fn parseAuthMode(s: []const u8) ?AuthMode { return null; } -pub fn parseUsage(allocator: std.mem.Allocator, v: std.json.Value) ?RateLimitSnapshot { +pub fn parseUsage(allocator: std.mem.Allocator, v: std.json.Value, schema_version: u32) ?RateLimitSnapshot { const obj = switch (v) { .object => |o| o, else => return null, @@ -36,7 +54,7 @@ pub fn parseUsage(allocator: std.mem.Allocator, v: std.json.Value) ?RateLimitSna if (obj.get("plan_type")) |p| { switch (p) { - .string => |s| snap.plan_type = parsePlanType(s), + .string => |s| snap.plan_type = parseStoredPlanType(s, schema_version), else => {}, } } diff --git a/src/registry/root.zig b/src/registry/root.zig index f17868f..1098d3e 100644 --- a/src/registry/root.zig +++ b/src/registry/root.zig @@ -34,6 +34,8 @@ pub const AccountRecord = common.AccountRecord; pub const resolvePlan = common.resolvePlan; pub const resolveDisplayPlan = common.resolveDisplayPlan; pub const planLabel = common.planLabel; +pub const normalizePlanType = parse.normalizePlanType; +pub const parseStoredPlanType = parse.parseStoredPlanType; pub const Registry = common.Registry; pub const defaultApiConfig = common.defaultApiConfig; pub const defaultLiveConfig = common.defaultLiveConfig; @@ -130,7 +132,7 @@ pub const usageScoreAt = account_ops.usageScoreAt; pub const remainingPercentAt = account_ops.remainingPercentAt; pub const resolveRateWindow = account_ops.resolveRateWindow; pub const hasMissingAccountNameForUser = account_ops.hasMissingAccountNameForUser; -pub const shouldFetchTeamAccountNamesForUser = account_ops.shouldFetchTeamAccountNamesForUser; +pub const shouldFetchWorkspaceAccountNamesForUser = account_ops.shouldFetchWorkspaceAccountNamesForUser; pub const activeChatgptUserId = account_ops.activeChatgptUserId; pub const applyAccountNamesForUser = account_ops.applyAccountNamesForUser; pub const activateAccountByKey = account_ops.activateAccountByKey; diff --git a/src/registry/storage.zig b/src/registry/storage.zig index 66a0c39..39d006c 100644 --- a/src/registry/storage.zig +++ b/src/registry/storage.zig @@ -36,7 +36,7 @@ const copyManagedFile = common.copyManagedFile; const copyFile = common.copyFile; const hardenSensitiveFile = common.hardenSensitiveFile; const normalizeEmailAlloc = common.normalizeEmailAlloc; -const parsePlanType = parse.parsePlanType; +const parseStoredPlanType = parse.parseStoredPlanType; const parseAuthMode = parse.parseAuthMode; const parseUsage = parse.parseUsage; const parseLiveConfig = parse.parseLiveConfig; @@ -108,7 +108,7 @@ fn parseLegacyAccountRecord(allocator: std.mem.Allocator, obj: std.json.ObjectMa if (obj.get("plan")) |p| { switch (p) { - .string => |s| rec.plan = parsePlanType(s), + .string => |s| rec.plan = parseStoredPlanType(s, 2), else => {}, } } @@ -119,7 +119,7 @@ fn parseLegacyAccountRecord(allocator: std.mem.Allocator, obj: std.json.ObjectMa } } if (obj.get("last_usage")) |u| { - rec.last_usage = parseUsage(allocator, u); + rec.last_usage = parseUsage(allocator, u, 2); } return rec; } @@ -292,7 +292,7 @@ fn loadLegacyRegistryV2( else => continue, }; if (obj.get("account_key") != null) { - const rec = try parseAccountRecord(allocator, obj); + const rec = try parseAccountRecord(allocator, obj, 2); try upsertAccount(allocator, ®, rec); } else { try legacy_accounts.append(allocator, try parseLegacyAccountRecord(allocator, obj)); @@ -312,7 +312,7 @@ fn loadLegacyRegistryV2( return reg; } -fn loadCurrentRegistry(allocator: std.mem.Allocator, root_obj: std.json.ObjectMap) !Registry { +fn loadCurrentRegistry(allocator: std.mem.Allocator, root_obj: std.json.ObjectMap, schema_version: u32) !Registry { if (root_obj.get("active_email") != null) return error.UnsupportedRegistryLayout; var reg = defaultRegistry(); @@ -343,7 +343,7 @@ fn loadCurrentRegistry(allocator: std.mem.Allocator, root_obj: std.json.ObjectMa .object => |o| o, else => continue, }; - const rec = try parseAccountRecord(allocator, obj); + const rec = try parseAccountRecord(allocator, obj, schema_version); try upsertAccount(allocator, ®, rec); } }, @@ -453,7 +453,7 @@ pub fn loadRegistry(allocator: std.mem.Allocator, codex_home: []const u8) !Regis (schema_version == current_schema_version and currentLayoutNeedsRewrite(root_obj)); var reg = switch (schema_version) { 2 => try loadLegacyRegistryV2(allocator, codex_home, root_obj), - 3, 4 => try loadCurrentRegistry(allocator, root_obj), + 3, 4 => try loadCurrentRegistry(allocator, root_obj, schema_version), else => { std.log.err( "registry schema_version {d} is older than the minimum supported {d}; use an intermediate codex-auth release or import --purge", diff --git a/src/registry/storage_parse.zig b/src/registry/storage_parse.zig index 6217251..9bcfe14 100644 --- a/src/registry/storage_parse.zig +++ b/src/registry/storage_parse.zig @@ -7,12 +7,12 @@ const AccountRecord = common.AccountRecord; const freeAccountRecord = common.freeAccountRecord; const normalizeEmailAlloc = common.normalizeEmailAlloc; const parseAuthMode = parse.parseAuthMode; -const parsePlanType = parse.parsePlanType; +const parseStoredPlanType = parse.parseStoredPlanType; const parseRolloutSignature = parse.parseRolloutSignature; const parseUsage = parse.parseUsage; const readInt = parse.readInt; -pub fn parseAccountRecord(allocator: std.mem.Allocator, obj: std.json.ObjectMap) !AccountRecord { +pub fn parseAccountRecord(allocator: std.mem.Allocator, obj: std.json.ObjectMap, schema_version: u32) !AccountRecord { const account_key_val = obj.get("account_key") orelse return error.MissingAccountKey; const email_val = obj.get("email") orelse return error.MissingEmail; const alias_val = obj.get("alias") orelse return error.MissingAlias; @@ -53,7 +53,7 @@ pub fn parseAccountRecord(allocator: std.mem.Allocator, obj: std.json.ObjectMap) if (obj.get("plan")) |p| { switch (p) { - .string => |s| rec.plan = parsePlanType(s), + .string => |s| rec.plan = parseStoredPlanType(s, schema_version), else => {}, } } @@ -64,7 +64,7 @@ pub fn parseAccountRecord(allocator: std.mem.Allocator, obj: std.json.ObjectMap) } } if (obj.get("last_usage")) |u| { - rec.last_usage = parseUsage(allocator, u); + rec.last_usage = parseUsage(allocator, u, schema_version); } if (obj.get("last_local_rollout")) |v| { rec.last_local_rollout = parseRolloutSignature(allocator, v); diff --git a/src/session.zig b/src/session.zig index 2b91db4..a1dcc1b 100644 --- a/src/session.zig +++ b/src/session.zig @@ -471,15 +471,7 @@ fn parseCredits(allocator: std.mem.Allocator, parsed: UsageCreditsJson) registry } fn parsePlanType(s: []const u8) registry.PlanType { - if (std.ascii.eqlIgnoreCase(s, "free")) return .free; - if (std.ascii.eqlIgnoreCase(s, "plus")) return .plus; - if (std.ascii.eqlIgnoreCase(s, "prolite")) return .prolite; - if (std.ascii.eqlIgnoreCase(s, "pro")) return .pro; - if (std.ascii.eqlIgnoreCase(s, "team")) return .team; - if (std.ascii.eqlIgnoreCase(s, "business")) return .business; - if (std.ascii.eqlIgnoreCase(s, "enterprise")) return .enterprise; - if (std.ascii.eqlIgnoreCase(s, "edu")) return .edu; - return .unknown; + return registry.normalizePlanType(s); } fn parseTimestampMs(s: []const u8) ?i64 { diff --git a/src/tui/display.zig b/src/tui/display.zig index e6b17bd..2341363 100644 --- a/src/tui/display.zig +++ b/src/tui/display.zig @@ -138,8 +138,8 @@ fn lessThanByDisplayOrder(ctx: SortContext, lhs: usize, rhs: usize) bool { fn planSortRank(plan: ?registry.PlanType) u8 { return switch (plan orelse .unknown) { - .team, .business, .enterprise, .edu => 0, - .free, .plus, .prolite, .pro => 1, + .business, .enterprise, .edu => 0, + .free, .go, .plus, .prolite, .pro => 1, else => 2, }; } diff --git a/src/workflows/account_names.zig b/src/workflows/account_names.zig index 794a1cd..79339c3 100644 --- a/src/workflows/account_names.zig +++ b/src/workflows/account_names.zig @@ -115,7 +115,7 @@ pub fn maybeRefreshAccountNamesForAuthInfoWithAccountApiEnabled( account_api_enabled: bool, ) !bool { const chatgpt_user_id = info.chatgpt_user_id orelse return false; - if (!shouldRefreshTeamAccountNamesForUserScopeWithAccountApiEnabled(reg, chatgpt_user_id, account_api_enabled)) return false; + if (!shouldRefreshWorkspaceAccountNamesForUserScopeWithAccountApiEnabled(reg, chatgpt_user_id, account_api_enabled)) return false; const access_token = info.access_token orelse return false; const chatgpt_account_id = info.chatgpt_account_id orelse return false; @@ -166,7 +166,7 @@ pub fn refreshAccountNamesForActiveAuthWithAccountApiEnabled( account_api_enabled: bool, ) !bool { const active_user_id = registry.activeChatgptUserId(reg) orelse return false; - if (!shouldRefreshTeamAccountNamesForUserScopeWithAccountApiEnabled(reg, active_user_id, account_api_enabled)) return false; + if (!shouldRefreshWorkspaceAccountNamesForUserScopeWithAccountApiEnabled(reg, active_user_id, account_api_enabled)) return false; var info = (try loadActiveAuthInfoForAccountRefresh(allocator, codex_home)) orelse return false; defer info.deinit(allocator); @@ -250,17 +250,17 @@ pub fn refreshAccountNamesForListWithAccountApiEnabled( ); } -pub fn shouldRefreshTeamAccountNamesForUserScope(reg: *registry.Registry, chatgpt_user_id: []const u8) bool { - return shouldRefreshTeamAccountNamesForUserScopeWithAccountApiEnabled(reg, chatgpt_user_id, reg.api.account); +pub fn shouldRefreshWorkspaceAccountNamesForUserScope(reg: *registry.Registry, chatgpt_user_id: []const u8) bool { + return shouldRefreshWorkspaceAccountNamesForUserScopeWithAccountApiEnabled(reg, chatgpt_user_id, reg.api.account); } -pub fn shouldRefreshTeamAccountNamesForUserScopeWithAccountApiEnabled( +pub fn shouldRefreshWorkspaceAccountNamesForUserScopeWithAccountApiEnabled( reg: *registry.Registry, chatgpt_user_id: []const u8, account_api_enabled: bool, ) bool { if (!account_api_enabled) return false; - return registry.shouldFetchTeamAccountNamesForUser(reg, chatgpt_user_id); + return registry.shouldFetchWorkspaceAccountNamesForUser(reg, chatgpt_user_id); } pub fn refreshAccountNamesAfterImport( diff --git a/src/workflows/list.zig b/src/workflows/list.zig index d1c0e2f..e5b9e4f 100644 --- a/src/workflows/list.zig +++ b/src/workflows/list.zig @@ -5,6 +5,7 @@ const registry = @import("../registry/root.zig"); const account_names = @import("account_names.zig"); const live_flow = @import("live.zig"); const preflight = @import("preflight.zig"); +const results = @import("results.zig"); const usage_refresh = @import("usage.zig"); const defaultAccountFetcher = account_names.defaultAccountFetcher; @@ -19,6 +20,17 @@ const switchLiveRuntimeMaybeStartRefresh = live_flow.switchLiveRuntimeMaybeStart const switchLiveRuntimeMaybeTakeUpdatedDisplay = live_flow.switchLiveRuntimeMaybeTakeUpdatedDisplay; const switchLiveRuntimeBuildStatusLine = live_flow.switchLiveRuntimeBuildStatusLine; +const ListComputation = struct { + reg: registry.Registry, + usage_state: usage_refresh.ForegroundUsageRefreshState, + + fn deinit(self: *ListComputation, allocator: std.mem.Allocator) void { + self.usage_state.deinit(allocator); + self.reg.deinit(allocator); + self.* = undefined; + } +}; + pub fn handleList(allocator: std.mem.Allocator, codex_home: []const u8, opts: cli.types.ListOptions) !void { if (opts.live) { try ensureLiveTty(.list); @@ -62,8 +74,28 @@ pub fn handleList(allocator: std.mem.Allocator, codex_home: []const u8, opts: cl return; } + if (opts.json) { + var result = computeList(allocator, codex_home, opts) catch |err| return printJsonWorkflowError(err); + defer result.deinit(allocator); + for (result.warnings) |warning| std.log.warn("{s}", .{warning}); + try cli.json_output.printListResult(&result); + return; + } + + var computed = try computeListState(allocator, codex_home, opts); + defer computed.deinit(allocator); + try format.printAccountsWithUsageOverrides(&computed.reg, computed.usage_state.usage_overrides); +} + +pub fn computeList(allocator: std.mem.Allocator, codex_home: []const u8, opts: cli.types.ListOptions) !results.ListResult { + var computed = try computeListState(allocator, codex_home, opts); + defer computed.deinit(allocator); + return try results.buildListResult(allocator, &computed.reg, &computed.usage_state); +} + +fn computeListState(allocator: std.mem.Allocator, codex_home: []const u8, opts: cli.types.ListOptions) !ListComputation { var reg = try registry.loadRegistry(allocator, codex_home); - defer reg.deinit(allocator); + errdefer reg.deinit(allocator); if (try registry.syncActiveAccountFromAuth(allocator, codex_home, ®)) { try registry.saveRegistry(allocator, codex_home, ®); } @@ -88,7 +120,7 @@ pub fn handleList(allocator: std.mem.Allocator, codex_home: []const u8, opts: cl usage_api_enabled, opts.active_only, ); - defer usage_state.deinit(allocator); + errdefer usage_state.deinit(allocator); try maybeRefreshForegroundAccountNamesWithAccountApiEnabled( allocator, codex_home, @@ -97,5 +129,26 @@ pub fn handleList(allocator: std.mem.Allocator, codex_home: []const u8, opts: cl defaultAccountFetcher, account_api_enabled, ); - try format.printAccountsWithUsageOverrides(®, usage_state.usage_overrides); + return .{ + .reg = reg, + .usage_state = usage_state, + }; +} + +fn printJsonWorkflowError(err: anyerror) anyerror { + switch (err) { + error.OutOfMemory => return err, + error.CurlRequired => { + try cli.json_output.printError( + "curl_unavailable", + "curl is required for API-backed refresh. Install curl or use --skip-api.", + null, + ); + return err; + }, + else => { + try cli.json_output.printError("registry_error", @errorName(err), null); + return error.RegistryError; + }, + } } diff --git a/src/workflows/preflight.zig b/src/workflows/preflight.zig index f646286..6ef58c6 100644 --- a/src/workflows/preflight.zig +++ b/src/workflows/preflight.zig @@ -11,13 +11,18 @@ const ForegroundUsageRefreshTarget = targets.ForegroundUsageRefreshTarget; const LiveTtyTarget = targets.LiveTtyTarget; const liveTtyPreflightError = targets.liveTtyPreflightError; const shouldRefreshForegroundUsage = targets.shouldRefreshForegroundUsage; -const shouldRefreshTeamAccountNamesForUserScopeWithAccountApiEnabled = account_names.shouldRefreshTeamAccountNamesForUserScopeWithAccountApiEnabled; +const shouldRefreshWorkspaceAccountNamesForUserScopeWithAccountApiEnabled = account_names.shouldRefreshWorkspaceAccountNamesForUserScopeWithAccountApiEnabled; const loadActiveAuthInfoForAccountRefresh = account_names.loadActiveAuthInfoForAccountRefresh; pub fn isHandledCliError(err: anyerror) bool { return err == error.AccountNotFound or + err == error.AmbiguousQuery or + err == error.AmbiguousSelector or + err == error.SelectorResolutionFailed or + err == error.StateUncertain or err == error.NoPreviousAccount or err == error.PreviousAccountUnavailable or + err == error.RegistryError or err == error.CodexLoginFailed or err == error.ListLiveRequiresTty or err == error.TuiOutputUnavailable or @@ -83,7 +88,7 @@ pub fn shouldPreflightCurlForForegroundTargetWithApiEnabled( } const active_user_id = registry.activeChatgptUserId(reg) orelse return false; - if (!shouldRefreshTeamAccountNamesForUserScopeWithAccountApiEnabled(reg, active_user_id, account_api_enabled)) { + if (!shouldRefreshWorkspaceAccountNamesForUserScopeWithAccountApiEnabled(reg, active_user_id, account_api_enabled)) { return false; } diff --git a/src/workflows/query.zig b/src/workflows/query.zig index dedc5fe..ae181d5 100644 --- a/src/workflows/query.zig +++ b/src/workflows/query.zig @@ -21,6 +21,10 @@ pub fn resolveSwitchQueryLocally( reg: *registry.Registry, query: []const u8, ) !SwitchQueryResolution { + if (registry.findAccountIndexByAccountKey(reg, query)) |account_idx| { + return .{ .direct = reg.accounts.items[account_idx].account_key }; + } + if (try findAccountIndexByDisplayNumber(allocator, reg, query)) |account_idx| { return .{ .direct = reg.accounts.items[account_idx].account_key }; } diff --git a/src/workflows/remove.zig b/src/workflows/remove.zig index 23548a8..5fe29ed 100644 --- a/src/workflows/remove.zig +++ b/src/workflows/remove.zig @@ -6,6 +6,7 @@ const live_flow = @import("live.zig"); const preflight = @import("preflight.zig"); const query_mod = @import("query.zig"); const active_auth = @import("active_auth.zig"); +const results = @import("results.zig"); const ensureLiveTty = preflight.ensureLiveTty; const findMatchingAccountsForRemove = query_mod.findMatchingAccountsForRemove; @@ -29,7 +30,59 @@ fn freeOwnedStrings(allocator: std.mem.Allocator, items: []const []const u8) voi for (items) |item| allocator.free(@constCast(item)); } +const RemoveSelectorMatch = union(enum) { + resolved: usize, + ambiguous: []usize, + not_found, + + fn deinit(self: *@This(), allocator: std.mem.Allocator) void { + switch (self.*) { + .ambiguous => |indices| allocator.free(indices), + else => {}, + } + self.* = undefined; + } +}; + +const RemoveSelectorResolution = struct { + selector: []const u8, + match: RemoveSelectorMatch, + + fn deinit(self: *@This(), allocator: std.mem.Allocator) void { + self.match.deinit(allocator); + self.* = undefined; + } +}; + +const RemoveResolutionSet = struct { + resolutions: []RemoveSelectorResolution, + selected: []usize, + + fn deinit(self: *@This(), allocator: std.mem.Allocator) void { + for (self.resolutions) |*resolution| resolution.deinit(allocator); + allocator.free(self.resolutions); + allocator.free(self.selected); + self.* = undefined; + } + + fn hasNotFound(self: *const @This()) bool { + for (self.resolutions) |resolution| { + if (resolution.match == .not_found) return true; + } + return false; + } + + fn hasAmbiguous(self: *const @This()) bool { + for (self.resolutions) |resolution| { + if (resolution.match == .ambiguous) return true; + } + return false; + } +}; + pub fn handleRemove(allocator: std.mem.Allocator, codex_home: []const u8, opts: cli.types.RemoveOptions) !void { + if (opts.json) return handleRemoveJson(allocator, codex_home, opts); + const interactive_remove = !opts.all and opts.selectors.len == 0; if (interactive_remove and opts.live) { try ensureLiveTty(.remove_account); @@ -137,44 +190,21 @@ pub fn handleRemove(allocator: std.mem.Allocator, codex_home: []const u8, opts: selected = try allocator.alloc(usize, reg.accounts.items.len); for (selected.?, 0..) |*slot, idx| slot.* = idx; } else if (opts.selectors.len != 0) { - var selected_list = std.ArrayList(usize).empty; - defer selected_list.deinit(allocator); + var resolution_set = try resolveRemoveSelectors(allocator, ®, opts.selectors); + defer resolution_set.deinit(allocator); var missing_selectors = std.ArrayList([]const u8).empty; defer missing_selectors.deinit(allocator); - var requires_confirmation = false; - - for (opts.selectors) |selector| { - if (try findAccountIndexByDisplayNumber(allocator, ®, selector)) |account_idx| { - if (!selectionContainsIndex(selected_list.items, account_idx)) { - try selected_list.append(allocator, account_idx); - } - continue; - } - - var matches = try findMatchingAccountsForRemove(allocator, ®, selector); - defer matches.deinit(allocator); - - if (matches.items.len == 0) { - try missing_selectors.append(allocator, selector); - continue; - } - if (matches.items.len > 1) { - requires_confirmation = true; - } - for (matches.items) |account_idx| { - if (!selectionContainsIndex(selected_list.items, account_idx)) { - try selected_list.append(allocator, account_idx); - } - } + for (resolution_set.resolutions) |resolution| { + if (resolution.match == .not_found) try missing_selectors.append(allocator, resolution.selector); } - if (missing_selectors.items.len != 0) { + if (resolution_set.hasNotFound()) { try cli.output.printAccountNotFoundErrors(missing_selectors.items); return error.AccountNotFound; } - if (selected_list.items.len == 0) return; - if (requires_confirmation) { - var matched_labels = try cli.output.buildRemoveLabels(allocator, ®, selected_list.items); + if (resolution_set.selected.len == 0) return; + if (resolution_set.hasAmbiguous()) { + var matched_labels = try cli.output.buildRemoveLabels(allocator, ®, resolution_set.selected); defer { freeOwnedStrings(allocator, matched_labels.items); matched_labels.deinit(allocator); @@ -186,7 +216,7 @@ pub fn handleRemove(allocator: std.mem.Allocator, codex_home: []const u8, opts: if (!(try cli.output.confirmRemoveMatches(matched_labels.items))) return; } - selected = try allocator.dupe(usize, selected_list.items); + selected = try allocator.dupe(usize, resolution_set.selected); } else { selected = cli.picker.selectAccountsToRemoveWithUsageOverrides( allocator, @@ -217,3 +247,192 @@ pub fn handleRemove(allocator: std.mem.Allocator, codex_home: []const u8, opts: try removeSelectedAccountsAndPersist(allocator, codex_home, ®, selected.?, opts.all); try cli.output.printRemoveSummary(removed_labels.items); } + +fn handleRemoveJson(allocator: std.mem.Allocator, codex_home: []const u8, opts: cli.types.RemoveOptions) !void { + var reg = registry.loadRegistry(allocator, codex_home) catch |err| return printJsonWorkflowError(err); + defer reg.deinit(allocator); + + if (registry.syncActiveAccountFromAuth(allocator, codex_home, ®) catch |err| return printJsonWorkflowError(err)) { + registry.saveRegistry(allocator, codex_home, ®) catch |err| return printJsonWorkflowError(err); + } + + var selected: []usize = undefined; + var selected_owned = false; + defer if (selected_owned) allocator.free(selected); + if (opts.all) { + selected = try allocator.alloc(usize, reg.accounts.items.len); + selected_owned = true; + for (selected, 0..) |*slot, idx| slot.* = idx; + } else { + var resolution_set = resolveRemoveSelectors(allocator, ®, opts.selectors) catch |err| return printJsonWorkflowError(err); + defer resolution_set.deinit(allocator); + if (resolution_set.hasNotFound() or resolution_set.hasAmbiguous()) { + const resolution_views = buildSelectorResolutionViews(allocator, ®, resolution_set.resolutions) catch |err| return printJsonWorkflowError(err); + defer results.deinitSelectorResolutionViews(allocator, resolution_views); + try cli.json_output.printSelectorResolutionError(resolution_views); + return error.SelectorResolutionFailed; + } + selected = try allocator.dupe(usize, resolution_set.selected); + selected_owned = true; + } + + const removed_views = results.buildAccountViewsForIndices(allocator, ®, null, selected) catch |err| return printJsonWorkflowError(err); + var removed_views_owned = true; + defer if (removed_views_owned) results.deinitAccountViews(allocator, removed_views); + + if (selected.len != 0) { + removeSelectedAccountsAndPersist(allocator, codex_home, ®, selected, opts.all) catch |err| return printJsonMutationError(err); + } + + var result: results.RemoveResult = .{ + .removed = removed_views, + .new_active_account_key = try optionalDupe(allocator, reg.active_account_key), + }; + removed_views_owned = false; + defer result.deinit(allocator); + + try cli.json_output.printRemoveResult(&result); +} + +fn appendSelectedIndex( + allocator: std.mem.Allocator, + selected_list: *std.ArrayList(usize), + account_idx: usize, +) !void { + if (!selectionContainsIndex(selected_list.items, account_idx)) { + try selected_list.append(allocator, account_idx); + } +} + +fn resolveRemoveSelectors( + allocator: std.mem.Allocator, + reg: *registry.Registry, + selectors: []const []const u8, +) !RemoveResolutionSet { + var resolutions = std.ArrayList(RemoveSelectorResolution).empty; + errdefer { + for (resolutions.items) |*resolution| resolution.deinit(allocator); + resolutions.deinit(allocator); + } + var selected = std.ArrayList(usize).empty; + errdefer selected.deinit(allocator); + + for (selectors) |selector| { + if (registry.findAccountIndexByAccountKey(reg, selector)) |account_idx| { + try appendSelectedIndex(allocator, &selected, account_idx); + try resolutions.append(allocator, .{ .selector = selector, .match = .{ .resolved = account_idx } }); + continue; + } + + if (try findAccountIndexByDisplayNumber(allocator, reg, selector)) |account_idx| { + try appendSelectedIndex(allocator, &selected, account_idx); + try resolutions.append(allocator, .{ .selector = selector, .match = .{ .resolved = account_idx } }); + continue; + } + + var matches = try findMatchingAccountsForRemove(allocator, reg, selector); + defer matches.deinit(allocator); + switch (matches.items.len) { + 0 => try resolutions.append(allocator, .{ .selector = selector, .match = .not_found }), + 1 => { + const account_idx = matches.items[0]; + try appendSelectedIndex(allocator, &selected, account_idx); + try resolutions.append(allocator, .{ .selector = selector, .match = .{ .resolved = account_idx } }); + }, + else => { + const ambiguous = try allocator.dupe(usize, matches.items); + errdefer allocator.free(ambiguous); + for (matches.items) |account_idx| try appendSelectedIndex(allocator, &selected, account_idx); + try resolutions.append(allocator, .{ .selector = selector, .match = .{ .ambiguous = ambiguous } }); + }, + } + } + + const owned_resolutions = try resolutions.toOwnedSlice(allocator); + errdefer { + for (owned_resolutions) |*resolution| resolution.deinit(allocator); + allocator.free(owned_resolutions); + } + const owned_selected = try selected.toOwnedSlice(allocator); + return .{ .resolutions = owned_resolutions, .selected = owned_selected }; +} + +fn buildSelectorResolutionViews( + allocator: std.mem.Allocator, + reg: *const registry.Registry, + resolutions: []const RemoveSelectorResolution, +) ![]results.SelectorResolutionView { + var views = std.ArrayList(results.SelectorResolutionView).empty; + errdefer { + for (views.items) |*view| view.deinit(allocator); + views.deinit(allocator); + } + + for (resolutions) |resolution| { + const selector = try allocator.dupe(u8, resolution.selector); + var selector_owned = true; + errdefer if (selector_owned) allocator.free(selector); + var view: results.SelectorResolutionView = switch (resolution.match) { + .resolved => |account_idx| blk: { + const candidates = try allocator.alloc(results.AccountView, 0); + errdefer allocator.free(candidates); + const account_key = try allocator.dupe(u8, reg.accounts.items[account_idx].account_key); + break :blk .{ + .selector = selector, + .status = .resolved, + .account_key = account_key, + .candidates = candidates, + }; + }, + .ambiguous => |indices| .{ + .selector = selector, + .status = .ambiguous, + .account_key = null, + .candidates = try results.buildAccountViewsForIndices(allocator, reg, null, indices), + }, + .not_found => .{ + .selector = selector, + .status = .not_found, + .account_key = null, + .candidates = try allocator.alloc(results.AccountView, 0), + }, + }; + selector_owned = false; + errdefer view.deinit(allocator); + try views.append(allocator, view); + } + + return try views.toOwnedSlice(allocator); +} + +fn optionalDupe(allocator: std.mem.Allocator, value: ?[]const u8) !?[]u8 { + return if (value) |text| try allocator.dupe(u8, text) else null; +} + +fn printJsonWorkflowError(err: anyerror) anyerror { + switch (err) { + error.OutOfMemory => return err, + error.CurlRequired => { + try cli.json_output.printError( + "curl_unavailable", + "curl is required for API-backed refresh. Install curl or use --skip-api.", + null, + ); + return err; + }, + else => { + try cli.json_output.printError("registry_error", @errorName(err), null); + return error.RegistryError; + }, + } +} + +fn printJsonMutationError(err: anyerror) anyerror { + if (err == error.OutOfMemory) return err; + try cli.json_output.printError( + "state_uncertain", + "the remove operation could not be completed; stored state may have changed; run `list --json` before retrying", + null, + ); + return error.StateUncertain; +} diff --git a/src/workflows/results.zig b/src/workflows/results.zig new file mode 100644 index 0000000..916f3db --- /dev/null +++ b/src/workflows/results.zig @@ -0,0 +1,451 @@ +const std = @import("std"); +const display_rows = @import("../tui/display.zig"); +const registry = @import("../registry/root.zig"); +const usage_refresh = @import("usage.zig"); + +pub const UsageSource = enum { + api, + local, + cache, + none, +}; + +pub const UsageRefreshMethod = enum { + api, + local, +}; + +pub const UsageRefreshStatus = enum { + not_requested, + ok, + no_data, + http_error, + missing_auth, + error_status, +}; + +pub const UsageRefreshView = struct { + requested: bool, + method: ?UsageRefreshMethod, + status: UsageRefreshStatus, + http_status: ?u16 = null, + error_code: ?[]u8 = null, + + pub fn deinit(self: *UsageRefreshView, allocator: std.mem.Allocator) void { + if (self.error_code) |error_code| allocator.free(error_code); + self.* = undefined; + } +}; + +pub const CreditsView = struct { + has_credits: bool, + unlimited: bool, + balance: ?[]u8, + + pub fn deinit(self: *CreditsView, allocator: std.mem.Allocator) void { + if (self.balance) |balance| allocator.free(balance); + self.* = undefined; + } +}; + +pub const UsageView = struct { + source: UsageSource, + updated_at: ?i64 = null, + primary: ?registry.RateLimitWindow = null, + secondary: ?registry.RateLimitWindow = null, + credits: ?CreditsView = null, + reset_credits: ?i64 = null, + refresh: UsageRefreshView, + + pub fn deinit(self: *UsageView, allocator: std.mem.Allocator) void { + if (self.credits) |*credits| credits.deinit(allocator); + self.refresh.deinit(allocator); + self.* = undefined; + } +}; + +pub const AccountView = struct { + number: usize, + account_key: []u8, + email: []u8, + alias: ?[]u8, + account_name: ?[]u8, + plan: ?registry.PlanType, + auth_mode: ?registry.AuthMode, + active: bool, + created_at: i64, + last_used_at: ?i64, + usage: UsageView, + + pub fn deinit(self: *AccountView, allocator: std.mem.Allocator) void { + allocator.free(self.account_key); + allocator.free(self.email); + if (self.alias) |alias| allocator.free(alias); + if (self.account_name) |account_name| allocator.free(account_name); + self.usage.deinit(allocator); + self.* = undefined; + } +}; + +pub const ListResult = struct { + active_account_key: ?[]u8, + accounts: []AccountView, + warnings: [][]u8, + + pub fn deinit(self: *ListResult, allocator: std.mem.Allocator) void { + if (self.active_account_key) |account_key| allocator.free(account_key); + deinitAccountViews(allocator, self.accounts); + for (self.warnings) |warning| allocator.free(warning); + allocator.free(self.warnings); + self.* = undefined; + } +}; + +pub const SwitchResult = struct { + switched_to: AccountView, + + pub fn deinit(self: *SwitchResult, allocator: std.mem.Allocator) void { + self.switched_to.deinit(allocator); + self.* = undefined; + } +}; + +pub const RemoveResult = struct { + removed: []AccountView, + new_active_account_key: ?[]u8, + + pub fn deinit(self: *RemoveResult, allocator: std.mem.Allocator) void { + deinitAccountViews(allocator, self.removed); + if (self.new_active_account_key) |account_key| allocator.free(account_key); + self.* = undefined; + } +}; + +pub const SelectorResolutionStatus = enum { + resolved, + ambiguous, + not_found, +}; + +pub const SelectorResolutionView = struct { + selector: []u8, + status: SelectorResolutionStatus, + account_key: ?[]u8, + candidates: []AccountView, + + pub fn deinit(self: *SelectorResolutionView, allocator: std.mem.Allocator) void { + allocator.free(self.selector); + if (self.account_key) |account_key| allocator.free(account_key); + deinitAccountViews(allocator, self.candidates); + self.* = undefined; + } +}; + +pub fn deinitSelectorResolutionViews(allocator: std.mem.Allocator, resolutions: []SelectorResolutionView) void { + for (resolutions) |*resolution| resolution.deinit(allocator); + allocator.free(resolutions); +} + +pub fn deinitAccountViews(allocator: std.mem.Allocator, accounts: []AccountView) void { + for (accounts) |*account| account.deinit(allocator); + allocator.free(accounts); +} + +pub fn buildListResult( + allocator: std.mem.Allocator, + reg: *const registry.Registry, + usage_state: ?*const usage_refresh.ForegroundUsageRefreshState, +) !ListResult { + const active_account_key = try dupeOptional(allocator, reg.active_account_key); + errdefer if (active_account_key) |account_key| allocator.free(account_key); + + const accounts = try buildAccountViewsForIndices(allocator, reg, usage_state, null); + errdefer deinitAccountViews(allocator, accounts); + + const warnings = try buildUsageWarnings(allocator, reg, usage_state); + errdefer { + for (warnings) |warning| allocator.free(warning); + allocator.free(warnings); + } + + return .{ + .active_account_key = active_account_key, + .accounts = accounts, + .warnings = warnings, + }; +} + +pub fn buildSwitchResult( + allocator: std.mem.Allocator, + reg: *const registry.Registry, + account_key: []const u8, +) !SwitchResult { + var switched_to = try buildAccountViewForKey(allocator, reg, null, account_key); + errdefer switched_to.deinit(allocator); + + return .{ + .switched_to = switched_to, + }; +} + +pub fn buildRemoveResult( + allocator: std.mem.Allocator, + reg_before_remove: *const registry.Registry, + selected: []const usize, + new_active_account_key: ?[]const u8, +) !RemoveResult { + const removed = try buildAccountViewsForIndices(allocator, reg_before_remove, null, selected); + errdefer deinitAccountViews(allocator, removed); + + const new_active = try dupeOptional(allocator, new_active_account_key); + errdefer if (new_active) |key| allocator.free(key); + + return .{ + .removed = removed, + .new_active_account_key = new_active, + }; +} + +pub fn buildAccountViewsForIndices( + allocator: std.mem.Allocator, + reg: *const registry.Registry, + usage_state: ?*const usage_refresh.ForegroundUsageRefreshState, + account_indices: ?[]const usize, +) ![]AccountView { + var display = try display_rows.buildDisplayRows(allocator, reg, null); + defer display.deinit(allocator); + + var accounts = std.ArrayList(AccountView).empty; + errdefer { + for (accounts.items) |*account| account.deinit(allocator); + accounts.deinit(allocator); + } + + var number: usize = 0; + for (display.rows) |row| { + const account_idx = row.account_index orelse continue; + number += 1; + if (!includesIndex(account_indices, account_idx)) continue; + + const outcome = usageOutcomeForIndex(usage_state, account_idx); + const view = try buildAccountView(allocator, reg, account_idx, number, outcome); + try accounts.append(allocator, view); + } + + return try accounts.toOwnedSlice(allocator); +} + +fn buildAccountViewForKey( + allocator: std.mem.Allocator, + reg: *const registry.Registry, + usage_state: ?*const usage_refresh.ForegroundUsageRefreshState, + account_key: []const u8, +) !AccountView { + const account_idx = registry.findAccountIndexByAccountKey(@constCast(reg), account_key) orelse return error.AccountNotFound; + const selected = [_]usize{account_idx}; + const views = try buildAccountViewsForIndices(allocator, reg, usage_state, &selected); + std.debug.assert(views.len == 1); + defer allocator.free(views); + return views[0]; +} + +fn buildAccountView( + allocator: std.mem.Allocator, + reg: *const registry.Registry, + account_idx: usize, + number: usize, + outcome: ?usage_refresh.ForegroundUsageOutcome, +) !AccountView { + const rec = ®.accounts.items[account_idx]; + + const account_key = try allocator.dupe(u8, rec.account_key); + errdefer allocator.free(account_key); + const email = try allocator.dupe(u8, rec.email); + errdefer allocator.free(email); + const alias = try dupeOptionalNonEmpty(allocator, rec.alias); + errdefer if (alias) |value| allocator.free(value); + const account_name = try dupeOptionalNonEmpty(allocator, rec.account_name orelse ""); + errdefer if (account_name) |value| allocator.free(value); + var usage = try buildUsageView(allocator, rec, outcome); + errdefer usage.deinit(allocator); + + return .{ + .number = number, + .account_key = account_key, + .email = email, + .alias = alias, + .account_name = account_name, + .plan = registry.resolveDisplayPlan(rec), + .auth_mode = rec.auth_mode, + .active = isActive(reg, account_idx), + .created_at = rec.created_at, + .last_used_at = rec.last_used_at, + .usage = usage, + }; +} + +fn buildUsageView( + allocator: std.mem.Allocator, + rec: *const registry.AccountRecord, + outcome: ?usage_refresh.ForegroundUsageOutcome, +) !UsageView { + var refresh = try buildUsageRefreshView(allocator, outcome); + errdefer refresh.deinit(allocator); + + const snapshot = rec.last_usage orelse return .{ + .source = .none, + .refresh = refresh, + }; + const credits = try cloneCreditsView(allocator, snapshot.credits); + errdefer if (credits) |*value| value.deinit(allocator); + + return .{ + .source = usageSource(outcome), + .updated_at = rec.last_usage_at, + .primary = snapshot.primary, + .secondary = snapshot.secondary, + .credits = credits, + .reset_credits = snapshot.reset_credits, + .refresh = refresh, + }; +} + +fn cloneCreditsView(allocator: std.mem.Allocator, credits: ?registry.CreditsSnapshot) !?CreditsView { + const value = credits orelse return null; + + const balance = try dupeOptional(allocator, value.balance); + errdefer if (balance) |text| allocator.free(text); + + return .{ + .has_credits = value.has_credits, + .unlimited = value.unlimited, + .balance = balance, + }; +} + +fn usageSource(outcome: ?usage_refresh.ForegroundUsageOutcome) UsageSource { + const value = outcome orelse return .cache; + if (!value.received_snapshot) return .cache; + return switch (value.method) { + .api => .api, + .local => .local, + .none => .cache, + }; +} + +fn buildUsageRefreshView( + allocator: std.mem.Allocator, + outcome: ?usage_refresh.ForegroundUsageOutcome, +) !UsageRefreshView { + const value = outcome orelse return .{ + .requested = false, + .method = null, + .status = .not_requested, + }; + if (!value.attempted) return .{ + .requested = false, + .method = null, + .status = .not_requested, + }; + + const method: ?UsageRefreshMethod = switch (value.method) { + .api => .api, + .local => .local, + .none => null, + }; + if (value.missing_auth) return .{ + .requested = true, + .method = method, + .status = .missing_auth, + }; + if (value.status_code) |status_code| { + if (status_code != 200) { + const error_code = if (value.error_code) |code| + try allocator.dupe(u8, code.text()) + else + null; + return .{ + .requested = true, + .method = method, + .status = .http_error, + .http_status = status_code, + .error_code = error_code, + }; + } + } + if (value.error_name) |error_name| return .{ + .requested = true, + .method = method, + .status = .error_status, + .error_code = try allocator.dupe(u8, error_name), + }; + return .{ + .requested = true, + .method = method, + .status = if (value.received_snapshot) .ok else .no_data, + }; +} + +fn buildUsageWarnings( + allocator: std.mem.Allocator, + reg: *const registry.Registry, + usage_state: ?*const usage_refresh.ForegroundUsageRefreshState, +) ![][]u8 { + const state = usage_state orelse { + var warnings = std.ArrayList([]u8).empty; + return try warnings.toOwnedSlice(allocator); + }; + + var warnings = std.ArrayList([]u8).empty; + errdefer { + for (warnings.items) |warning| allocator.free(warning); + warnings.deinit(allocator); + } + + for (state.outcomes, 0..) |outcome, idx| { + if (!outcome.attempted) continue; + if (outcome.error_name == null) continue; + if (idx >= reg.accounts.items.len) continue; + try warnings.append( + allocator, + try std.fmt.allocPrint( + allocator, + "usage refresh failed for {s}: {s}", + .{ reg.accounts.items[idx].email, outcome.error_name.? }, + ), + ); + } + + return try warnings.toOwnedSlice(allocator); +} + +fn usageOutcomeForIndex( + usage_state: ?*const usage_refresh.ForegroundUsageRefreshState, + account_idx: usize, +) ?usage_refresh.ForegroundUsageOutcome { + const state = usage_state orelse return null; + if (account_idx >= state.outcomes.len) return null; + return state.outcomes[account_idx]; +} + +fn includesIndex(indices: ?[]const usize, account_idx: usize) bool { + const selected = indices orelse return true; + for (selected) |idx| { + if (idx == account_idx) return true; + } + return false; +} + +fn isActive(reg: *const registry.Registry, account_idx: usize) bool { + const active_account_key = reg.active_account_key orelse return false; + return std.mem.eql(u8, active_account_key, reg.accounts.items[account_idx].account_key); +} + +fn dupeOptional(allocator: std.mem.Allocator, value: ?[]const u8) !?[]u8 { + return if (value) |text| try allocator.dupe(u8, text) else null; +} + +fn dupeOptionalNonEmpty(allocator: std.mem.Allocator, value: []const u8) !?[]u8 { + if (value.len == 0) return null; + return try allocator.dupe(u8, value); +} diff --git a/src/workflows/root.zig b/src/workflows/root.zig index 02f9bc5..bf6bc6c 100644 --- a/src/workflows/root.zig +++ b/src/workflows/root.zig @@ -27,6 +27,7 @@ const alias_workflow = @import("alias.zig"); const workflow_env = @import("env.zig"); const targets = @import("targets.zig"); const usage_refresh = @import("usage.zig"); +pub const results = @import("results.zig"); pub const nowMilliseconds = workflow_env.nowMilliseconds; pub const nowSeconds = workflow_env.nowSeconds; @@ -54,7 +55,7 @@ const loadActiveAuthInfoForAccountRefresh = account_names.loadActiveAuthInfoForA pub const refreshAccountNamesAfterLogin = account_names.refreshAccountNamesAfterLogin; pub const refreshAccountNamesAfterSwitch = account_names.refreshAccountNamesAfterSwitch; pub const refreshAccountNamesForList = account_names.refreshAccountNamesForList; -const shouldRefreshTeamAccountNamesForUserScopeWithAccountApiEnabled = account_names.shouldRefreshTeamAccountNamesForUserScopeWithAccountApiEnabled; +const shouldRefreshWorkspaceAccountNamesForUserScopeWithAccountApiEnabled = account_names.shouldRefreshWorkspaceAccountNamesForUserScopeWithAccountApiEnabled; pub const refreshAccountNamesAfterImport = account_names.refreshAccountNamesAfterImport; const loadSingleFileImportAuthInfo = account_names.loadSingleFileImportAuthInfo; pub const reconcileActiveAuthAfterRemove = active_auth.reconcileActiveAuthAfterRemove; @@ -121,7 +122,11 @@ fn runMain(init: std.process.Init.Minimal) !void { const cmd = switch (parsed) { .command => |command| command, .usage_error => |usage_err| { - try cli.output.printUsageError(&usage_err); + if (usage_err.json) { + try cli.json_output.printUsageError(usage_err.message); + } else { + try cli.output.printUsageError(&usage_err); + } return error.InvalidCliUsage; }, }; @@ -131,7 +136,11 @@ fn runMain(init: std.process.Init.Minimal) !void { .help => false, else => true, }; - const codex_home = if (needs_codex_home) try registry.resolveCodexHome(allocator) else null; + const json_requested = commandWantsJson(&cmd); + const codex_home = if (needs_codex_home) + registry.resolveCodexHome(allocator) catch |err| return printJsonStartupError(err, json_requested) + else + null; defer if (codex_home) |path| allocator.free(path); switch (cmd) { @@ -153,6 +162,21 @@ fn runMain(init: std.process.Init.Minimal) !void { } } +fn commandWantsJson(cmd: *const cli.types.Command) bool { + return switch (cmd.*) { + .list => |opts| opts.json, + .switch_account => |opts| opts.json, + .remove_account => |opts| opts.json, + else => false, + }; +} + +fn printJsonStartupError(err: anyerror, json_requested: bool) anyerror { + if (!json_requested or err == error.OutOfMemory) return err; + try cli.json_output.printError("registry_error", @errorName(err), null); + return error.RegistryError; +} + fn freeOwnedStrings(allocator: std.mem.Allocator, items: []const []const u8) void { for (items) |item| allocator.free(@constCast(item)); } diff --git a/src/workflows/switch.zig b/src/workflows/switch.zig index 1082840..28ae9af 100644 --- a/src/workflows/switch.zig +++ b/src/workflows/switch.zig @@ -4,6 +4,7 @@ const registry = @import("../registry/root.zig"); const live_flow = @import("live.zig"); const preflight = @import("preflight.zig"); const query_mod = @import("query.zig"); +const results = @import("results.zig"); const ensureLiveTty = preflight.ensureLiveTty; const resolveSwitchQueryLocally = query_mod.resolveSwitchQueryLocally; @@ -113,6 +114,8 @@ fn handleSwitchQuery( opts: cli.types.SwitchOptions, query: []const u8, ) !void { + if (opts.json) return handleSwitchQueryJson(allocator, codex_home, query); + var reg = try registry.loadRegistry(allocator, codex_home); defer reg.deinit(allocator); if (try registry.syncActiveAccountFromAuth(allocator, codex_home, ®)) { @@ -155,6 +158,7 @@ fn handleSwitchPrevious( codex_home: []const u8, opts: cli.types.SwitchOptions, ) !void { + std.debug.assert(!opts.json); std.debug.assert(opts.api_mode == .default); std.debug.assert(!opts.live); @@ -187,3 +191,77 @@ fn handleSwitchPrevious( try registry.saveRegistry(allocator, codex_home, ®); try cli.output.printSwitchedAccount(allocator, ®, previous_account_key); } + +fn handleSwitchQueryJson( + allocator: std.mem.Allocator, + codex_home: []const u8, + query: []const u8, +) !void { + var reg = registry.loadRegistry(allocator, codex_home) catch |err| return printJsonWorkflowError(err); + defer reg.deinit(allocator); + if (registry.syncActiveAccountFromAuth(allocator, codex_home, ®) catch |err| return printJsonWorkflowError(err)) { + registry.saveRegistry(allocator, codex_home, ®) catch |err| return printJsonWorkflowError(err); + } + + var resolution = resolveSwitchQueryLocally(allocator, ®, query) catch |err| return printJsonWorkflowError(err); + defer resolution.deinit(allocator); + + const selected_account_key = switch (resolution) { + .not_found => { + const message = try std.fmt.allocPrint(allocator, "no account matches \"{s}\"", .{query}); + defer allocator.free(message); + try cli.json_output.printError("account_not_found", message, null); + return error.AccountNotFound; + }, + .direct => |account_key| account_key, + .multiple => |matches| { + const candidates = try results.buildAccountViewsForIndices(allocator, ®, null, matches.items); + defer results.deinitAccountViews(allocator, candidates); + const message = try std.fmt.allocPrint(allocator, "query \"{s}\" matches multiple accounts", .{query}); + defer allocator.free(message); + try cli.json_output.printError("ambiguous_query", message, candidates); + return error.AmbiguousQuery; + }, + }; + + registry.activateAccountByKey(allocator, codex_home, ®, selected_account_key) catch |err| return printJsonMutationError(err, "switch"); + registry.saveRegistry(allocator, codex_home, ®) catch |err| return printJsonMutationError(err, "switch"); + + var result = results.buildSwitchResult( + allocator, + ®, + selected_account_key, + ) catch |err| return printJsonWorkflowError(err); + defer result.deinit(allocator); + try cli.json_output.printSwitchResult(&result); +} + +fn printJsonWorkflowError(err: anyerror) anyerror { + switch (err) { + error.OutOfMemory => return err, + error.CurlRequired => { + try cli.json_output.printError( + "curl_unavailable", + "curl is required for API-backed refresh. Install curl or use --skip-api.", + null, + ); + return err; + }, + else => { + try cli.json_output.printError("registry_error", @errorName(err), null); + return error.RegistryError; + }, + } +} + +fn printJsonMutationError(err: anyerror, operation: []const u8) anyerror { + if (err == error.OutOfMemory) return err; + const message = try std.fmt.allocPrint( + std.heap.page_allocator, + "the {s} operation could not be completed; stored state may have changed; run `list --json` before retrying", + .{operation}, + ); + defer std.heap.page_allocator.free(message); + try cli.json_output.printError("state_uncertain", message, null); + return error.StateUncertain; +} diff --git a/src/workflows/usage.zig b/src/workflows/usage.zig index 468c4e5..5caf820 100644 --- a/src/workflows/usage.zig +++ b/src/workflows/usage.zig @@ -19,6 +19,7 @@ pub const ForegroundUsagePoolInitFn = *const fn ( n_jobs: usize, ) anyerror!void; const ForegroundUsageWorkerResult = struct { + requested: bool = false, status_code: ?u16 = null, error_code: ?usage_api.ResponseErrorCode = null, missing_auth: bool = false, @@ -33,6 +34,12 @@ const ForegroundUsageWorkerResult = struct { } }; +pub const UsageRefreshMethod = enum { + none, + api, + local, +}; + pub fn shouldRefreshChatGptUsageForAccount(rec: *const registry.AccountRecord) bool { return rec.auth_mode == null or rec.auth_mode.? != .apikey; } @@ -43,11 +50,12 @@ fn skipsChatGptUsage(rec: *const registry.AccountRecord) bool { pub const ForegroundUsageOutcome = struct { attempted: bool = false, + method: UsageRefreshMethod = .none, status_code: ?u16 = null, error_code: ?usage_api.ResponseErrorCode = null, missing_auth: bool = false, error_name: ?[]const u8 = null, - has_usage_windows: bool = false, + received_snapshot: bool = false, updated: bool = false, unchanged: bool = false, }; @@ -299,8 +307,38 @@ pub fn refreshForegroundUsageForDisplayWithApiFetchersWithPoolInitUsingApiEnable if (!usage_api_enabled) { state.local_only_mode = true; - if (try refreshActiveUsageFromLocalSessions(allocator, codex_home, reg)) { - if (persist_registry) try registry.saveRegistry(allocator, codex_home, reg); + const account_key = reg.active_account_key orelse return state; + const account_idx = registry.findAccountIndexByAccountKey(reg, account_key) orelse return state; + const outcome = &state.outcomes[account_idx]; + outcome.attempted = true; + outcome.method = .local; + state.attempted += 1; + + const refresh_result = refreshActiveUsageFromLocalSessions(allocator, codex_home, reg) catch |err| switch (err) { + error.OutOfMemory => return err, + else => { + outcome.error_name = @errorName(err); + state.failed += 1; + return state; + }, + }; + + switch (refresh_result) { + .updated => { + outcome.received_snapshot = true; + outcome.updated = true; + state.updated += 1; + if (persist_registry) try registry.saveRegistry(allocator, codex_home, reg); + }, + .unchanged => { + outcome.received_snapshot = true; + outcome.unchanged = true; + state.unchanged += 1; + }, + .no_data => { + outcome.unchanged = true; + state.unchanged += 1; + }, } return state; } @@ -338,6 +376,7 @@ pub fn refreshForegroundUsageForDisplayWithApiFetchersWithPoolInitUsingApiEnable for (fetch_account_indices.items, 0..) |account_idx, fetch_idx| { const account = ®.accounts.items[account_idx]; auth_paths[fetch_idx] = try registry.accountAuthPath(auth_path_arena, codex_home, account.account_key); + worker_results[account_idx].requested = true; } const batch_results = fetch_batch( @@ -351,7 +390,7 @@ pub fn refreshForegroundUsageForDisplayWithApiFetchersWithPoolInitUsingApiEnable const error_name = @errorName(err); for (fetch_account_indices.items) |account_idx| { const worker_result = &worker_results[account_idx]; - worker_result.* = .{ .error_name = error_name }; + worker_result.* = .{ .requested = true, .error_name = error_name }; } break :batch_fetch; }, @@ -364,6 +403,7 @@ pub fn refreshForegroundUsageForDisplayWithApiFetchersWithPoolInitUsingApiEnable for (batch_results, 0..) |*batch_result, fetch_idx| { const idx = fetch_account_indices.items[fetch_idx]; worker_results[idx] = .{ + .requested = true, .status_code = batch_result.status_code, .error_code = batch_result.error_code, .missing_auth = batch_result.missing_auth, @@ -407,13 +447,15 @@ pub fn refreshForegroundUsageForDisplayWithApiFetchersWithPoolInitUsingApiEnable if (!std.mem.eql(u8, reg.accounts.items[idx].account_key, key)) continue; } const outcome = &state.outcomes[idx]; + if (!worker_result.requested) continue; outcome.* = .{ .attempted = true, + .method = .api, .status_code = worker_result.status_code, .error_code = worker_result.error_code, .missing_auth = worker_result.missing_auth, .error_name = worker_result.error_name, - .has_usage_windows = worker_result.snapshot != null, + .received_snapshot = worker_result.snapshot != null, }; state.attempted += 1; @@ -444,16 +486,22 @@ pub fn refreshForegroundUsageForDisplayWithApiFetchersWithPoolInitUsingApiEnable return state; } +const LocalUsageRefreshResult = enum { + no_data, + unchanged, + updated, +}; + fn refreshActiveUsageFromLocalSessions( allocator: std.mem.Allocator, codex_home: []const u8, reg: *registry.Registry, -) !bool { +) !LocalUsageRefreshResult { const latest_usage = sessions.scanLatestUsageWithSource(allocator, codex_home) catch |err| switch (err) { - error.FileNotFound => return false, + error.FileNotFound => return .no_data, else => return err, }; - if (latest_usage == null) return false; + if (latest_usage == null) return .no_data; var latest = latest_usage.?; var snapshot_consumed = false; @@ -464,21 +512,21 @@ fn refreshActiveUsageFromLocalSessions( } } - const account_key = reg.active_account_key orelse return false; + const account_key = reg.active_account_key orelse return .no_data; const activated_at_ms = reg.active_account_activated_at_ms orelse 0; - if (latest.event_timestamp_ms < activated_at_ms) return false; - const idx = registry.findAccountIndexByAccountKey(reg, account_key) orelse return false; + if (latest.event_timestamp_ms < activated_at_ms) return .no_data; + const idx = registry.findAccountIndexByAccountKey(reg, account_key) orelse return .no_data; const signature: registry.RolloutSignature = .{ .path = latest.path, .event_timestamp_ms = latest.event_timestamp_ms, }; - if (registry.rolloutSignaturesEqual(reg.accounts.items[idx].last_local_rollout, signature)) return false; + if (registry.rolloutSignaturesEqual(reg.accounts.items[idx].last_local_rollout, signature)) return .unchanged; registry.updateUsage(allocator, reg, account_key, latest.snapshot); snapshot_consumed = true; try registry.setAccountLastLocalRollout(allocator, ®.accounts.items[idx], latest.path, latest.event_timestamp_ms); - return true; + return .updated; } pub fn initForegroundUsagePool( @@ -596,21 +644,24 @@ fn foregroundUsageRefreshWorker( return; } + results[account_idx].requested = true; + var arena_state = std.heap.ArenaAllocator.init(std.heap.smp_allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); const auth_path = registry.accountAuthPath(arena, codex_home, reg.accounts.items[account_idx].account_key) catch |err| { - results[account_idx] = .{ .error_name = @errorName(err) }; + results[account_idx] = .{ .requested = true, .error_name = @errorName(err) }; return; }; const fetch_result = usage_fetcher(arena, auth_path) catch |err| { - results[account_idx] = .{ .error_name = @errorName(err) }; + results[account_idx] = .{ .requested = true, .error_name = @errorName(err) }; return; }; var result: ForegroundUsageWorkerResult = .{ + .requested = true, .status_code = fetch_result.status_code, .error_code = fetch_result.error_code, .missing_auth = fetch_result.missing_auth, @@ -619,6 +670,7 @@ fn foregroundUsageRefreshWorker( if (fetch_result.snapshot) |snapshot| { result.snapshot = registry.cloneRateLimitSnapshot(allocator, snapshot) catch |err| { results[account_idx] = .{ + .requested = true, .status_code = fetch_result.status_code, .error_code = fetch_result.error_code, .missing_auth = fetch_result.missing_auth, diff --git a/tests/api_usage_test.zig b/tests/api_usage_test.zig index bf05763..cdfeebd 100644 --- a/tests/api_usage_test.zig +++ b/tests/api_usage_test.zig @@ -56,7 +56,7 @@ test "parse usage api response maps live usage windows and plan" { const snapshot = (try usage_api.parseUsageResponse(gpa, body)) orelse return error.TestExpectedEqual; defer registry.freeRateLimitSnapshot(gpa, &snapshot); - try std.testing.expectEqual(registry.PlanType.team, snapshot.plan_type.?); + try std.testing.expectEqual(registry.PlanType.business, snapshot.plan_type.?); try std.testing.expectEqual(@as(f64, 11.0), snapshot.primary.?.used_percent); try std.testing.expectEqual(@as(?i64, 300), snapshot.primary.?.window_minutes); try std.testing.expectEqual(@as(?i64, 10080), snapshot.secondary.?.window_minutes); @@ -130,6 +130,27 @@ test "parse usage api response maps prolite plan" { try std.testing.expectEqual(registry.PlanType.prolite, snapshot.plan_type.?); } +test "parse usage api response maps backend business to enterprise" { + const gpa = std.testing.allocator; + const body = + \\{ + \\ "plan_type": "business", + \\ "rate_limit": { + \\ "primary_window": { + \\ "used_percent": 1, + \\ "limit_window_seconds": 18000, + \\ "reset_at": 1773491460 + \\ }, + \\ "secondary_window": null + \\ } + \\} + ; + + const snapshot = (try usage_api.parseUsageResponse(gpa, body)) orelse return error.TestExpectedEqual; + defer registry.freeRateLimitSnapshot(gpa, &snapshot); + try std.testing.expectEqual(registry.PlanType.enterprise, snapshot.plan_type.?); +} + test "fetch usage for API key auth skips ChatGPT usage refresh without missing auth" { const gpa = std.testing.allocator; var tmp = std.testing.tmpDir(.{}); diff --git a/tests/cli_behavior_test.zig b/tests/cli_behavior_test.zig index b76b4e6..bd3541b 100644 --- a/tests/cli_behavior_test.zig +++ b/tests/cli_behavior_test.zig @@ -349,6 +349,39 @@ test "Scenario: Given list with api flag when parsing then forced api mode is pr } } +test "Scenario: Given list with json flag when parsing then json mode is preserved" { + const gpa = std.testing.allocator; + const args = [_][:0]const u8{ "codex-auth", "list", "--skip-api", "--json" }; + var result = try cli.commands.parseArgs(gpa, &args); + defer cli.commands.freeParseResult(gpa, &result); + + switch (result) { + .command => |cmd| switch (cmd) { + .list => |opts| { + try std.testing.expect(opts.json); + try std.testing.expectEqual(cli.types.ApiMode.skip_api, opts.api_mode); + }, + else => return error.TestExpectedEqual, + }, + else => return error.TestExpectedEqual, + } +} + +test "Scenario: Given list with live and json flags when parsing then json usage error is returned" { + const gpa = std.testing.allocator; + const args = [_][:0]const u8{ "codex-auth", "list", "--live", "--json" }; + var result = try cli.commands.parseArgs(gpa, &args); + defer cli.commands.freeParseResult(gpa, &result); + + switch (result) { + .usage_error => |usage_err| { + try std.testing.expect(usage_err.json); + try std.testing.expect(std.mem.indexOf(u8, usage_err.message, "`--live` cannot be combined with `--json`") != null); + }, + else => return error.TestExpectedEqual, + } +} + test "Scenario: Given login with removed no-login flag when parsing then usage error is returned" { const gpa = std.testing.allocator; const args = [_][:0]const u8{ "codex-auth", "login", "--no-login" }; @@ -409,7 +442,7 @@ test "Scenario: Given help when rendering then login and command help notes are const help = aw.written(); try std.testing.expect(std.mem.indexOf(u8, help, "Commands:") != null); - try std.testing.expect(std.mem.indexOf(u8, help, "list [--live] [--active] [--api|--skip-api]") != null); + try std.testing.expect(std.mem.indexOf(u8, help, "list [--live] [--active] [--api|--skip-api] [--json]") != null); try std.testing.expect(std.mem.indexOf(u8, help, "switch [--live] [--api|--skip-api]") != null); try std.testing.expect(std.mem.indexOf(u8, help, "alias set ") != null); try std.testing.expect(std.mem.indexOf(u8, help, "config live --interval ") != null); @@ -426,7 +459,7 @@ test "Scenario: Given simple command help when rendering then examples are omitt const help = aw.written(); try std.testing.expect(std.mem.indexOf(u8, help, "codex-auth list") != null); try std.testing.expect(std.mem.indexOf(u8, help, "List available accounts.") != null); - try std.testing.expect(std.mem.indexOf(u8, help, "Usage:\n codex-auth list [--live] [--active] [--api|--skip-api]\n") != null); + try std.testing.expect(std.mem.indexOf(u8, help, "Usage:\n codex-auth list [--live] [--active] [--api|--skip-api] [--json]\n") != null); try std.testing.expect(std.mem.indexOf(u8, help, "Options:\n --live") != null); try std.testing.expect(std.mem.indexOf(u8, help, "--active Refresh only the active account before rendering.") != null); try std.testing.expect(std.mem.indexOf(u8, help, "--skip-api Load usage and account data from local data only (may be inaccurate).") != null); @@ -1050,6 +1083,72 @@ test "Scenario: Given switch dash when parsing then previous switch is selected" } } +test "Scenario: Given switch json query when parsing then query target and json mode are preserved" { + const gpa = std.testing.allocator; + const args = [_][:0]const u8{ "codex-auth", "switch", "user@example.com", "--json" }; + var result = try cli.commands.parseArgs(gpa, &args); + defer cli.commands.freeParseResult(gpa, &result); + + switch (result) { + .command => |cmd| switch (cmd) { + .switch_account => |opts| { + try std.testing.expect(opts.json); + switch (opts.target) { + .query => |query| try std.testing.expectEqualStrings("user@example.com", query), + else => return error.TestExpectedEqual, + } + }, + else => return error.TestExpectedEqual, + }, + else => return error.TestExpectedEqual, + } +} + +test "Scenario: Given switch dash with json when parsing then previous switching is rejected" { + const gpa = std.testing.allocator; + const args = [_][:0]const u8{ "codex-auth", "switch", "-", "--json" }; + var result = try cli.commands.parseArgs(gpa, &args); + defer cli.commands.freeParseResult(gpa, &result); + + switch (result) { + .usage_error => |usage_err| { + try std.testing.expect(usage_err.json); + try std.testing.expect(std.mem.indexOf(u8, usage_err.message, "previous-account switching is CLI-only") != null); + }, + else => return error.TestExpectedEqual, + } +} + +test "Scenario: Given switch previous flag with json when parsing then the unsupported flag is rejected" { + const gpa = std.testing.allocator; + const args = [_][:0]const u8{ "codex-auth", "switch", "--previous", "--json" }; + var result = try cli.commands.parseArgs(gpa, &args); + defer cli.commands.freeParseResult(gpa, &result); + + switch (result) { + .usage_error => |usage_err| { + try std.testing.expect(usage_err.json); + try std.testing.expect(std.mem.indexOf(u8, usage_err.message, "unknown flag `--previous`") != null); + }, + else => return error.TestExpectedEqual, + } +} + +test "Scenario: Given switch json without target when parsing then json usage error is returned" { + const gpa = std.testing.allocator; + const args = [_][:0]const u8{ "codex-auth", "switch", "--json" }; + var result = try cli.commands.parseArgs(gpa, &args); + defer cli.commands.freeParseResult(gpa, &result); + + switch (result) { + .usage_error => |usage_err| { + try std.testing.expect(usage_err.json); + try std.testing.expect(std.mem.indexOf(u8, usage_err.message, "requires an explicit account query") != null); + }, + else => return error.TestExpectedEqual, + } +} + test "Scenario: Given switch query with skip-api flag when parsing then usage error is returned" { const gpa = std.testing.allocator; const args = [_][:0]const u8{ "codex-auth", "switch", "--skip-api", "02" }; @@ -1242,6 +1341,54 @@ test "Scenario: Given remove with all flag when parsing then all mode is preserv } } +test "Scenario: Given remove all with json flag when parsing then all and json modes are preserved" { + const gpa = std.testing.allocator; + const args = [_][:0]const u8{ "codex-auth", "remove", "--all", "--json" }; + var result = try cli.commands.parseArgs(gpa, &args); + defer cli.commands.freeParseResult(gpa, &result); + + switch (result) { + .command => |cmd| switch (cmd) { + .remove_account => |opts| { + try std.testing.expect(opts.all); + try std.testing.expect(opts.json); + }, + else => return error.TestExpectedEqual, + }, + else => return error.TestExpectedEqual, + } +} + +test "Scenario: Given remove json without selector when parsing then json usage error is returned" { + const gpa = std.testing.allocator; + const args = [_][:0]const u8{ "codex-auth", "remove", "--json" }; + var result = try cli.commands.parseArgs(gpa, &args); + defer cli.commands.freeParseResult(gpa, &result); + + switch (result) { + .usage_error => |usage_err| { + try std.testing.expect(usage_err.json); + try std.testing.expect(std.mem.indexOf(u8, usage_err.message, "requires selectors or `--all`") != null); + }, + else => return error.TestExpectedEqual, + } +} + +test "Scenario: Given remove selector then invalid json flag when parsing then selector is released" { + const gpa = std.testing.allocator; + const args = [_][:0]const u8{ "codex-auth", "remove", "alpha", "--bad", "--json" }; + var result = try cli.commands.parseArgs(gpa, &args); + defer cli.commands.freeParseResult(gpa, &result); + + switch (result) { + .usage_error => |usage_err| { + try std.testing.expect(usage_err.json); + try std.testing.expect(std.mem.indexOf(u8, usage_err.message, "unknown flag `--bad`") != null); + }, + else => return error.TestExpectedEqual, + } +} + test "Scenario: Given remove with multiple selectors when parsing then all selectors are preserved" { const gpa = std.testing.allocator; const args = [_][:0]const u8{ "codex-auth", "remove", "01", "b@example.com", "03" }; @@ -1414,8 +1561,8 @@ test "Scenario: Given singleton aliases from different emails when building remo var reg = makeRegistry(); defer reg.deinit(gpa); - try appendAccount(gpa, ®, "user-4QmYj7PkN2sLx8AcVbR3TwHd::67fe2bbb-0de6-49a4-b2b3-d1df366d1faf", "alpha@example.com", "work", .team); - try appendAccount(gpa, ®, "user-8LnCq5VzR1mHx9SfKpT4JdWe::518a44d9-ba75-4bad-87e5-ae9377042960", "beta@example.com", "work", .team); + try appendAccount(gpa, ®, "user-4QmYj7PkN2sLx8AcVbR3TwHd::67fe2bbb-0de6-49a4-b2b3-d1df366d1faf", "alpha@example.com", "work", .business); + try appendAccount(gpa, ®, "user-8LnCq5VzR1mHx9SfKpT4JdWe::518a44d9-ba75-4bad-87e5-ae9377042960", "beta@example.com", "work", .business); const indices = [_]usize{ 0, 1 }; var labels = try cli.output.buildRemoveLabels(gpa, ®, &indices); @@ -1434,9 +1581,9 @@ test "Scenario: Given singleton account names from different emails when buildin var reg = makeRegistry(); defer reg.deinit(gpa); - try appendAccount(gpa, ®, "user-4QmYj7PkN2sLx8AcVbR3TwHd::67fe2bbb-0de6-49a4-b2b3-d1df366d1faf", "alpha@example.com", "", .team); + try appendAccount(gpa, ®, "user-4QmYj7PkN2sLx8AcVbR3TwHd::67fe2bbb-0de6-49a4-b2b3-d1df366d1faf", "alpha@example.com", "", .business); reg.accounts.items[0].account_name = try gpa.dupe(u8, "Workspace"); - try appendAccount(gpa, ®, "user-8LnCq5VzR1mHx9SfKpT4JdWe::518a44d9-ba75-4bad-87e5-ae9377042960", "beta@example.com", "", .team); + try appendAccount(gpa, ®, "user-8LnCq5VzR1mHx9SfKpT4JdWe::518a44d9-ba75-4bad-87e5-ae9377042960", "beta@example.com", "", .business); reg.accounts.items[1].account_name = try gpa.dupe(u8, "Workspace"); const indices = [_]usize{ 0, 1 }; diff --git a/tests/cli_integration_test.zig b/tests/cli_integration_test.zig index 50af228..8338d25 100644 --- a/tests/cli_integration_test.zig +++ b/tests/cli_integration_test.zig @@ -2819,6 +2819,343 @@ test "Scenario: Given list with skip-api when running list then it does not requ try std.testing.expectEqualStrings("", result.stderr); } +test "Scenario: Given list json with skip-api when running list then stdout is one JSON document" { + const gpa = std.testing.allocator; + const project_root = try projectRootAlloc(gpa); + defer gpa.free(project_root); + try buildCliBinary(gpa, project_root); + + var tmp = fs.tmpDir(.{}); + defer tmp.cleanup(); + + const home_root = try tmp.dir.realpathAlloc(gpa, "."); + defer gpa.free(home_root); + + try seedRegistryWithAccounts(gpa, home_root, "alpha@example.com", &[_]SeedAccount{ + .{ .email = "alpha@example.com", .alias = "alpha" }, + .{ .email = "beta@example.com", .alias = "beta" }, + }); + var cached_usage = makeUsageSnapshot(25.0, 40.0); + cached_usage.plan_type = .business; + cached_usage.credits = .{ + .has_credits = false, + .unlimited = false, + .balance = null, + }; + try setStoredUsageSnapshotForAccount(gpa, home_root, "alpha@example.com", cached_usage, 123, 0); + + try tmp.dir.makePath("empty-bin"); + const empty_path = try tmp.dir.realpathAlloc(gpa, "empty-bin"); + defer gpa.free(empty_path); + + const result = try runCliWithIsolatedHomeAndPath( + gpa, + project_root, + home_root, + empty_path, + &[_][]const u8{ "list", "--skip-api", "--json" }, + ); + defer gpa.free(result.stdout); + defer gpa.free(result.stderr); + + try expectSuccess(result); + try std.testing.expectEqualStrings("", result.stderr); + + var parsed = try std.json.parseFromSlice(std.json.Value, gpa, result.stdout, .{}); + defer parsed.deinit(); + const root = parsed.value.object; + try std.testing.expectEqual(@as(i64, 1), root.get("schema_version").?.integer); + try std.testing.expectEqualStrings("list", root.get("command").?.string); + try std.testing.expectEqual(@as(usize, 2), root.get("accounts").?.array.items.len); + try std.testing.expectEqualStrings("alpha@example.com", root.get("accounts").?.array.items[0].object.get("email").?.string); + const active_account = root.get("accounts").?.array.items[0].object; + try std.testing.expectEqualStrings("business", active_account.get("plan").?.string); + const active_usage = active_account.get("usage").?.object; + try std.testing.expectEqualStrings("cache", active_usage.get("source").?.string); + try std.testing.expectEqual(@as(i64, 123), active_usage.get("updated_at").?.integer); + try std.testing.expectEqual(@as(i64, 25), active_usage.get("primary").?.object.get("used_percent").?.integer); + const credits = active_usage.get("credits").?.object; + try std.testing.expect(!credits.get("has_credits").?.bool); + try std.testing.expect(!credits.get("unlimited").?.bool); + try std.testing.expect(credits.get("balance").? == .null); + try std.testing.expect(active_usage.get("refresh").?.object.get("requested").?.bool); + try std.testing.expectEqualStrings("local", active_usage.get("refresh").?.object.get("method").?.string); + try std.testing.expectEqualStrings("no_data", active_usage.get("refresh").?.object.get("status").?.string); + try std.testing.expect(root.get("warnings") == null); +} + +test "Scenario: Given backend Team and Business plans when listing json then the CLI returns final product plans" { + const gpa = std.testing.allocator; + const project_root = try projectRootAlloc(gpa); + defer gpa.free(project_root); + try buildCliBinary(gpa, project_root); + + var tmp = fs.tmpDir(.{}); + defer tmp.cleanup(); + + const home_root = try tmp.dir.realpathAlloc(gpa, "."); + defer gpa.free(home_root); + try seedRegistryWithAccounts(gpa, home_root, "alpha@example.com", &[_]SeedAccount{ + .{ .email = "alpha@example.com", .alias = "alpha" }, + }); + + const team_auth = try fixtures.authJsonWithEmailPlan(gpa, "alpha@example.com", "team"); + defer gpa.free(team_auth); + try tmp.dir.writeFile(.{ .sub_path = ".codex/auth.json", .data = team_auth }); + + const team_result = try runCliWithIsolatedHome( + gpa, + project_root, + home_root, + &[_][]const u8{ "list", "--skip-api", "--json" }, + ); + defer gpa.free(team_result.stdout); + defer gpa.free(team_result.stderr); + try expectSuccess(team_result); + try std.testing.expectEqualStrings("", team_result.stderr); + + var team_parsed = try std.json.parseFromSlice(std.json.Value, gpa, team_result.stdout, .{}); + defer team_parsed.deinit(); + try std.testing.expectEqualStrings( + "business", + team_parsed.value.object.get("accounts").?.array.items[0].object.get("plan").?.string, + ); + + const business_auth = try fixtures.authJsonWithEmailPlan(gpa, "alpha@example.com", "business"); + defer gpa.free(business_auth); + try tmp.dir.writeFile(.{ .sub_path = ".codex/auth.json", .data = business_auth }); + + const business_result = try runCliWithIsolatedHome( + gpa, + project_root, + home_root, + &[_][]const u8{ "list", "--skip-api", "--json" }, + ); + defer gpa.free(business_result.stdout); + defer gpa.free(business_result.stderr); + try expectSuccess(business_result); + try std.testing.expectEqualStrings("", business_result.stderr); + + var business_parsed = try std.json.parseFromSlice(std.json.Value, gpa, business_result.stdout, .{}); + defer business_parsed.deinit(); + try std.testing.expectEqualStrings( + "enterprise", + business_parsed.value.object.get("accounts").?.array.items[0].object.get("plan").?.string, + ); +} + +test "Scenario: Given cached usage when an API refresh fails then list json keeps the snapshot and reports refresh metadata" { + const gpa = std.testing.allocator; + const project_root = try projectRootAlloc(gpa); + defer gpa.free(project_root); + try buildCliBinary(gpa, project_root); + + var tmp = fs.tmpDir(.{}); + defer tmp.cleanup(); + + const home_root = try tmp.dir.realpathAlloc(gpa, "."); + defer gpa.free(home_root); + + try seedRegistryWithAccounts(gpa, home_root, "alpha@example.com", &[_]SeedAccount{ + .{ .email = "alpha@example.com", .alias = "alpha" }, + }); + var cached_usage = makeUsageSnapshot(37.0, 52.0); + cached_usage.plan_type = .enterprise; + cached_usage.credits = .{ + .has_credits = false, + .unlimited = false, + .balance = null, + }; + try setStoredUsageSnapshotForAccount(gpa, home_root, "alpha@example.com", cached_usage, 456, 0); + + const codex_home = try codexHomeAlloc(gpa, home_root); + defer gpa.free(codex_home); + const account_key = try fixtures.accountKeyForEmailAlloc(gpa, "alpha@example.com"); + defer gpa.free(account_key); + const account_auth_path = try registry.accountAuthPath(gpa, codex_home, account_key); + defer gpa.free(account_auth_path); + const account_auth = try fixtures.authJsonWithEmailPlan(gpa, "alpha@example.com", "business"); + defer gpa.free(account_auth); + try fs.cwd().writeFile(.{ .sub_path = account_auth_path, .data = account_auth }); + + try writeFailingFakeCurl(gpa, tmp.dir, project_root); + const fake_curl_dir = try tmp.dir.realpathAlloc(gpa, "fake-curl-bin"); + defer gpa.free(fake_curl_dir); + const path_override = try prependPathEntryAlloc(gpa, fake_curl_dir); + defer gpa.free(path_override); + + const result = try runCliWithIsolatedHomeAndPath( + gpa, + project_root, + home_root, + path_override, + &[_][]const u8{ "list", "--active", "--json" }, + ); + defer gpa.free(result.stdout); + defer gpa.free(result.stderr); + + try expectSuccess(result); + try std.testing.expect(std.mem.indexOf(u8, result.stderr, "usage refresh failed for alpha@example.com: RequestFailed") != null); + + var parsed = try std.json.parseFromSlice(std.json.Value, gpa, result.stdout, .{}); + defer parsed.deinit(); + const root = parsed.value.object; + try std.testing.expect(root.get("warnings") == null); + const account = root.get("accounts").?.array.items[0].object; + try std.testing.expectEqualStrings("enterprise", account.get("plan").?.string); + const usage = account.get("usage").?.object; + try std.testing.expectEqualStrings("cache", usage.get("source").?.string); + try std.testing.expectEqual(@as(i64, 456), usage.get("updated_at").?.integer); + try std.testing.expectEqual(@as(i64, 37), usage.get("primary").?.object.get("used_percent").?.integer); + try std.testing.expect(!usage.get("credits").?.object.get("has_credits").?.bool); + const refresh = usage.get("refresh").?.object; + try std.testing.expect(refresh.get("requested").?.bool); + try std.testing.expectEqualStrings("api", refresh.get("method").?.string); + try std.testing.expectEqualStrings("error", refresh.get("status").?.string); + try std.testing.expect(refresh.get("http_status").? == .null); + try std.testing.expectEqualStrings("RequestFailed", refresh.get("error_code").?.string); +} + +test "Scenario: Given list json with missing CODEX_HOME when running list then stdout is a JSON registry error" { + const gpa = std.testing.allocator; + const project_root = try projectRootAlloc(gpa); + defer gpa.free(project_root); + try buildCliBinary(gpa, project_root); + + var tmp = fs.tmpDir(.{}); + defer tmp.cleanup(); + + const home_root = try tmp.dir.realpathAlloc(gpa, "."); + defer gpa.free(home_root); + const missing_codex_home = try fs.path.join(gpa, &[_][]const u8{ home_root, "missing-codex-home" }); + defer gpa.free(missing_codex_home); + + const result = try runCliWithIsolatedHomeAndCodexHome( + gpa, + project_root, + home_root, + missing_codex_home, + &[_][]const u8{ "list", "--json" }, + ); + defer gpa.free(result.stdout); + defer gpa.free(result.stderr); + + switch (result.term) { + .exited => |code| try std.testing.expectEqual(@as(u8, 1), code), + else => return error.TestUnexpectedResult, + } + + var parsed = try std.json.parseFromSlice(std.json.Value, gpa, result.stdout, .{}); + defer parsed.deinit(); + const err = parsed.value.object.get("error").?.object; + try std.testing.expectEqualStrings("registry_error", err.get("code").?.string); +} + +test "Scenario: Given switch json query when running switch then active account changes and JSON is emitted" { + const gpa = std.testing.allocator; + const project_root = try projectRootAlloc(gpa); + defer gpa.free(project_root); + try buildCliBinary(gpa, project_root); + + var tmp = fs.tmpDir(.{}); + defer tmp.cleanup(); + + const home_root = try tmp.dir.realpathAlloc(gpa, "."); + defer gpa.free(home_root); + + try seedRegistryWithAccounts(gpa, home_root, "alpha@example.com", &[_]SeedAccount{ + .{ .email = "alpha@example.com", .alias = "alpha" }, + .{ .email = "beta@example.com", .alias = "beta" }, + }); + + const codex_home = try codexHomeAlloc(gpa, home_root); + defer gpa.free(codex_home); + const alpha_key = try fixtures.accountKeyForEmailAlloc(gpa, "alpha@example.com"); + defer gpa.free(alpha_key); + const beta_key = try fixtures.accountKeyForEmailAlloc(gpa, "beta@example.com"); + defer gpa.free(beta_key); + const alpha_path = try registry.accountAuthPath(gpa, codex_home, alpha_key); + defer gpa.free(alpha_path); + const beta_path = try registry.accountAuthPath(gpa, codex_home, beta_key); + defer gpa.free(beta_path); + const alpha_auth = try fixtures.authJsonWithEmailPlan(gpa, "alpha@example.com", "pro"); + defer gpa.free(alpha_auth); + const beta_auth = try fixtures.authJsonWithEmailPlan(gpa, "beta@example.com", "pro"); + defer gpa.free(beta_auth); + try fs.cwd().writeFile(.{ .sub_path = alpha_path, .data = alpha_auth }); + try fs.cwd().writeFile(.{ .sub_path = beta_path, .data = beta_auth }); + try tmp.dir.writeFile(.{ .sub_path = ".codex/auth.json", .data = alpha_auth }); + + try tmp.dir.makePath("empty-bin"); + const empty_path = try tmp.dir.realpathAlloc(gpa, "empty-bin"); + defer gpa.free(empty_path); + + const result = try runCliWithIsolatedHomeAndPath( + gpa, + project_root, + home_root, + empty_path, + &[_][]const u8{ "switch", "beta", "--json" }, + ); + defer gpa.free(result.stdout); + defer gpa.free(result.stderr); + + try expectSuccess(result); + try std.testing.expectEqualStrings("", result.stderr); + + var parsed = try std.json.parseFromSlice(std.json.Value, gpa, result.stdout, .{}); + defer parsed.deinit(); + const root = parsed.value.object; + try std.testing.expectEqualStrings("switch", root.get("command").?.string); + try std.testing.expectEqualStrings("beta@example.com", root.get("switched_to").?.object.get("email").?.string); + try std.testing.expect(root.get("previous_active_account_key") == null); + + var loaded = try registry.loadRegistry(gpa, codex_home); + defer loaded.deinit(gpa); + try std.testing.expectEqualStrings(beta_key, loaded.active_account_key.?); +} + +test "Scenario: Given switch json ambiguous query when running switch then candidates are returned" { + const gpa = std.testing.allocator; + const project_root = try projectRootAlloc(gpa); + defer gpa.free(project_root); + try buildCliBinary(gpa, project_root); + + var tmp = fs.tmpDir(.{}); + defer tmp.cleanup(); + + const home_root = try tmp.dir.realpathAlloc(gpa, "."); + defer gpa.free(home_root); + + try seedRegistryWithAccounts(gpa, home_root, "alpha@example.com", &[_]SeedAccount{ + .{ .email = "alpha@example.com", .alias = "team" }, + .{ .email = "beta@example.com", .alias = "team" }, + }); + + try tmp.dir.makePath("empty-bin"); + const empty_path = try tmp.dir.realpathAlloc(gpa, "empty-bin"); + defer gpa.free(empty_path); + + const result = try runCliWithIsolatedHomeAndPath( + gpa, + project_root, + home_root, + empty_path, + &[_][]const u8{ "switch", "team", "--json" }, + ); + defer gpa.free(result.stdout); + defer gpa.free(result.stderr); + + try expectFailure(result); + try std.testing.expectEqualStrings("", result.stderr); + + var parsed = try std.json.parseFromSlice(std.json.Value, gpa, result.stdout, .{}); + defer parsed.deinit(); + const err = parsed.value.object.get("error").?.object; + try std.testing.expectEqualStrings("ambiguous_query", err.get("code").?.string); + try std.testing.expectEqual(@as(usize, 2), err.get("candidates").?.array.items.len); +} + test "Scenario: Given switch query with api flag when running switch then it returns a usage error" { const gpa = std.testing.allocator; const project_root = try projectRootAlloc(gpa); @@ -3898,7 +4235,7 @@ test "Scenario: Given remove query with duplicate-email accounts when running re defer gpa.free(codex_home); var reg = fixtures.makeEmptyRegistry(); defer reg.deinit(gpa); - try appendCustomAccount(gpa, ®, "user-a::acct-work", "alice@example.com", "work", .team); + try appendCustomAccount(gpa, ®, "user-a::acct-work", "alice@example.com", "work", .business); try appendCustomAccount(gpa, ®, "user-b::acct-personal", "alice@example.com", "personal", .plus); reg.active_account_key = try gpa.dupe(u8, "user-a::acct-work"); reg.active_account_activated_at_ms = std.Io.Timestamp.now(app_runtime.io(), .real).toMilliseconds(); @@ -4037,6 +4374,128 @@ test "Scenario: Given remove all when running remove then it clears all accounts try std.testing.expectError(error.FileNotFound, fs.cwd().openFile(active_auth_path, .{})); } +test "Scenario: Given remove json selector then invalid flag when running remove then it exits with JSON usage error" { + const gpa = std.testing.allocator; + const project_root = try projectRootAlloc(gpa); + defer gpa.free(project_root); + try buildCliBinary(gpa, project_root); + + var tmp = fs.tmpDir(.{}); + defer tmp.cleanup(); + + const home_root = try tmp.dir.realpathAlloc(gpa, "."); + defer gpa.free(home_root); + + const result = try runCliWithIsolatedHome(gpa, project_root, home_root, &[_][]const u8{ "remove", "alpha", "--bad", "--json" }); + defer gpa.free(result.stdout); + defer gpa.free(result.stderr); + + switch (result.term) { + .exited => |code| try std.testing.expectEqual(@as(u8, 2), code), + else => return error.TestUnexpectedResult, + } + try std.testing.expectEqualStrings("", result.stderr); + + var parsed = try std.json.parseFromSlice(std.json.Value, gpa, result.stdout, .{}); + defer parsed.deinit(); + const err = parsed.value.object.get("error").?.object; + try std.testing.expectEqualStrings("usage", err.get("code").?.string); + try std.testing.expect(std.mem.indexOf(u8, err.get("message").?.string, "unknown flag `--bad`") != null); +} + +test "Scenario: Given ambiguous and missing remove selectors in json mode then every resolution is returned and nothing is deleted" { + const gpa = std.testing.allocator; + const project_root = try projectRootAlloc(gpa); + defer gpa.free(project_root); + try buildCliBinary(gpa, project_root); + + var tmp = fs.tmpDir(.{}); + defer tmp.cleanup(); + + const home_root = try tmp.dir.realpathAlloc(gpa, "."); + defer gpa.free(home_root); + + try seedRegistryWithAccounts(gpa, home_root, "keeper@example.com", &[_]SeedAccount{ + .{ .email = "west@example.com", .alias = "ops-west" }, + .{ .email = "east@example.com", .alias = "ops-east" }, + .{ .email = "keeper@example.com", .alias = "keeper" }, + }); + + const result = try runCliWithIsolatedHome( + gpa, + project_root, + home_root, + &[_][]const u8{ "remove", "ops", "missing", "--json" }, + ); + defer gpa.free(result.stdout); + defer gpa.free(result.stderr); + + try expectFailure(result); + try std.testing.expectEqualStrings("", result.stderr); + + var parsed = try std.json.parseFromSlice(std.json.Value, gpa, result.stdout, .{}); + defer parsed.deinit(); + const err = parsed.value.object.get("error").?.object; + try std.testing.expectEqualStrings("selector_resolution_failed", err.get("code").?.string); + const resolutions = err.get("resolutions").?.array.items; + try std.testing.expectEqual(@as(usize, 2), resolutions.len); + try std.testing.expectEqualStrings("ops", resolutions[0].object.get("selector").?.string); + try std.testing.expectEqualStrings("ambiguous", resolutions[0].object.get("status").?.string); + try std.testing.expect(resolutions[0].object.get("account_key").? == .null); + try std.testing.expectEqual(@as(usize, 2), resolutions[0].object.get("candidates").?.array.items.len); + try std.testing.expectEqualStrings("missing", resolutions[1].object.get("selector").?.string); + try std.testing.expectEqualStrings("not_found", resolutions[1].object.get("status").?.string); + try std.testing.expect(resolutions[1].object.get("account_key").? == .null); + try std.testing.expectEqual(@as(usize, 0), resolutions[1].object.get("candidates").?.array.items.len); + + const codex_home = try codexHomeAlloc(gpa, home_root); + defer gpa.free(codex_home); + var loaded = try registry.loadRegistry(gpa, codex_home); + defer loaded.deinit(gpa); + try std.testing.expectEqual(@as(usize, 3), loaded.accounts.items.len); + const keeper_key = try fixtures.accountKeyForEmailAlloc(gpa, "keeper@example.com"); + defer gpa.free(keeper_key); + try std.testing.expectEqualStrings(keeper_key, loaded.active_account_key.?); +} + +test "Scenario: Given remove all json when running remove then removed accounts are returned" { + const gpa = std.testing.allocator; + const project_root = try projectRootAlloc(gpa); + defer gpa.free(project_root); + try buildCliBinary(gpa, project_root); + + var tmp = fs.tmpDir(.{}); + defer tmp.cleanup(); + + const home_root = try tmp.dir.realpathAlloc(gpa, "."); + defer gpa.free(home_root); + + try seedRegistryWithAccounts(gpa, home_root, "alpha@example.com", &[_]SeedAccount{ + .{ .email = "alpha@example.com", .alias = "alpha" }, + .{ .email = "beta@example.com", .alias = "beta" }, + }); + + const result = try runCliWithIsolatedHome(gpa, project_root, home_root, &[_][]const u8{ "remove", "--all", "--json" }); + defer gpa.free(result.stdout); + defer gpa.free(result.stderr); + + try expectSuccess(result); + try std.testing.expectEqualStrings("", result.stderr); + + var parsed = try std.json.parseFromSlice(std.json.Value, gpa, result.stdout, .{}); + defer parsed.deinit(); + const root = parsed.value.object; + try std.testing.expectEqualStrings("remove", root.get("command").?.string); + try std.testing.expectEqual(@as(usize, 2), root.get("removed").?.array.items.len); + try std.testing.expect(root.get("new_active_account_key").? == .null); + + const codex_home = try codexHomeAlloc(gpa, home_root); + defer gpa.free(codex_home); + var loaded = try registry.loadRegistry(gpa, codex_home); + defer loaded.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), loaded.accounts.items.len); +} + test "Scenario: Given remove all with malformed auth json when running remove then registry is cleared but auth json is preserved" { const gpa = std.testing.allocator; const project_root = try projectRootAlloc(gpa); diff --git a/tests/cli_picker_test.zig b/tests/cli_picker_test.zig index 4b7180b..9e0931f 100644 --- a/tests/cli_picker_test.zig +++ b/tests/cli_picker_test.zig @@ -200,7 +200,7 @@ fn appendNumberedTestAccount( defer allocator.free(record_key); const email = try std.fmt.allocPrint(allocator, "account-{d:0>3}@example.com", .{idx}); defer allocator.free(email); - try appendTestAccount(allocator, reg, record_key, email, "", .team); + try appendTestAccount(allocator, reg, record_key, email, "", .business); } fn testUsageSnapshot(now: i64, used_5h: f64, used_weekly: f64) registry.RateLimitSnapshot { @@ -442,7 +442,7 @@ test "Scenario: Given a narrow live viewport when rendering then the account col "user-1::acc-1", "very-long-account-name-that-should-not-wrap@example.com", "", - .team, + .business, ); try appendTestAccount( gpa, @@ -450,7 +450,7 @@ test "Scenario: Given a narrow live viewport when rendering then the account col "user-1::acc-2", "another-very-long-account-name-that-should-not-wrap@example.com", "", - .team, + .business, ); var rows = try buildSwitchRows(gpa, ®); @@ -932,7 +932,7 @@ test "Scenario: Given grouped accounts when rendering switch list then child row var reg = makeTestRegistry(); defer reg.deinit(gpa); - try appendTestAccount(gpa, ®, "user-1::acc-1", "user@example.com", "", .team); + try appendTestAccount(gpa, ®, "user-1::acc-1", "user@example.com", "", .business); reg.accounts.items[0].account_name = try gpa.dupe(u8, "Als's Workspace"); try appendTestAccount(gpa, ®, "user-1::acc-2", "user@example.com", "", .free); @@ -954,7 +954,7 @@ test "Scenario: Given usage overrides when rendering switch list then failed row var reg = makeTestRegistry(); defer reg.deinit(gpa); - try appendTestAccount(gpa, ®, "user-1::acc-1", "user@example.com", "", .team); + try appendTestAccount(gpa, ®, "user-1::acc-1", "user@example.com", "", .business); try appendTestAccount(gpa, ®, "user-1::acc-2", "user@example.com", "", .free); const usage_overrides = [_]?[]const u8{ null, "401" }; @@ -975,7 +975,7 @@ test "Scenario: Given usage overrides when selecting switch accounts then errore var reg = makeTestRegistry(); defer reg.deinit(gpa); - try appendTestAccount(gpa, ®, "user-1::acc-1", "healthy@example.com", "", .team); + try appendTestAccount(gpa, ®, "user-1::acc-1", "healthy@example.com", "", .business); try appendTestAccount(gpa, ®, "user-1::acc-2", "failed@example.com", "", .free); const usage_overrides = [_]?[]const u8{ null, "401" }; @@ -992,9 +992,9 @@ test "Scenario: Given live switch navigation shortcuts when an account is unavai var reg = makeTestRegistry(); defer reg.deinit(gpa); - try appendTestAccount(gpa, ®, "user-1::acc-1", "healthy-a@example.com", "", .team); + try appendTestAccount(gpa, ®, "user-1::acc-1", "healthy-a@example.com", "", .business); try appendTestAccount(gpa, ®, "user-1::acc-2", "failed@example.com", "", .free); - try appendTestAccount(gpa, ®, "user-1::acc-3", "healthy-b@example.com", "", .team); + try appendTestAccount(gpa, ®, "user-1::acc-3", "healthy-b@example.com", "", .business); const usage_overrides = [_]?[]const u8{ null, "401", null }; var rows = try buildSwitchRowsWithUsageOverrides(gpa, ®, &usage_overrides); @@ -1039,9 +1039,9 @@ test "Scenario: Given active usage at zero when picking a live auto-switch targe const now = std.Io.Timestamp.now(app_runtime.io(), .real).toSeconds(); - try appendTestAccount(gpa, ®, "user-1::acc-1", "active@example.com", "", .team); - try appendTestAccount(gpa, ®, "user-1::acc-2", "exhausted@example.com", "", .team); - try appendTestAccount(gpa, ®, "user-1::acc-3", "healthy@example.com", "", .team); + try appendTestAccount(gpa, ®, "user-1::acc-1", "active@example.com", "", .business); + try appendTestAccount(gpa, ®, "user-1::acc-2", "exhausted@example.com", "", .business); + try appendTestAccount(gpa, ®, "user-1::acc-3", "healthy@example.com", "", .business); reg.active_account_key = try gpa.dupe(u8, "user-1::acc-1"); reg.accounts.items[0].last_usage = testUsageSnapshotWithResets(now, 100, 20, 3600, 7 * 24 * 3600); reg.accounts.items[1].last_usage = testUsageSnapshotWithResets(now, 100, 10, 30 * 60, 6 * 60 * 60); @@ -1068,8 +1068,8 @@ test "Scenario: Given active usage above zero when picking a live auto-switch ta const now = std.Io.Timestamp.now(app_runtime.io(), .real).toSeconds(); - try appendTestAccount(gpa, ®, "user-1::acc-1", "active@example.com", "", .team); - try appendTestAccount(gpa, ®, "user-1::acc-2", "healthy@example.com", "", .team); + try appendTestAccount(gpa, ®, "user-1::acc-1", "active@example.com", "", .business); + try appendTestAccount(gpa, ®, "user-1::acc-2", "healthy@example.com", "", .business); reg.active_account_key = try gpa.dupe(u8, "user-1::acc-1"); reg.accounts.items[0].last_usage = testUsageSnapshotWithResets(now, 99, 20, 3600, 7 * 24 * 3600); reg.accounts.items[1].last_usage = testUsageSnapshotWithResets(now, 50, 50, 30 * 60, 30 * 60); @@ -1092,7 +1092,7 @@ test "Scenario: Given usage overrides when rendering switch list then errored ro var reg = makeTestRegistry(); defer reg.deinit(gpa); - try appendTestAccount(gpa, ®, "user-1::acc-1", "healthy@example.com", "", .team); + try appendTestAccount(gpa, ®, "user-1::acc-1", "healthy@example.com", "", .business); try appendTestAccount(gpa, ®, "user-1::acc-2", "failed@example.com", "", .free); const usage_overrides = [_]?[]const u8{ null, "401" }; @@ -1116,8 +1116,8 @@ test "Scenario: Given an active account when rendering switch list then non-curs var reg = makeTestRegistry(); defer reg.deinit(gpa); - try appendTestAccount(gpa, ®, "user-1::acc-1", "cursor@example.com", "", .team); - try appendTestAccount(gpa, ®, "user-1::acc-2", "active@example.com", "", .team); + try appendTestAccount(gpa, ®, "user-1::acc-1", "cursor@example.com", "", .business); + try appendTestAccount(gpa, ®, "user-1::acc-2", "active@example.com", "", .business); reg.active_account_key = try gpa.dupe(u8, "user-1::acc-2"); var rows = try buildSwitchRows(gpa, ®); @@ -1163,8 +1163,8 @@ test "Scenario: Given the active account is selected when rendering switch list var reg = makeTestRegistry(); defer reg.deinit(gpa); - try appendTestAccount(gpa, ®, "user-1::acc-1", "active@example.com", "", .team); - try appendTestAccount(gpa, ®, "user-1::acc-2", "other@example.com", "", .team); + try appendTestAccount(gpa, ®, "user-1::acc-1", "active@example.com", "", .business); + try appendTestAccount(gpa, ®, "user-1::acc-2", "other@example.com", "", .business); reg.active_account_key = try gpa.dupe(u8, "user-1::acc-1"); var rows = try buildSwitchRows(gpa, ®); @@ -1213,8 +1213,8 @@ test "Scenario: Given an active account when rendering remove list then non-curs var reg = makeTestRegistry(); defer reg.deinit(gpa); - try appendTestAccount(gpa, ®, "user-1::acc-1", "cursor@example.com", "", .team); - try appendTestAccount(gpa, ®, "user-1::acc-2", "active@example.com", "", .team); + try appendTestAccount(gpa, ®, "user-1::acc-1", "cursor@example.com", "", .business); + try appendTestAccount(gpa, ®, "user-1::acc-2", "active@example.com", "", .business); reg.active_account_key = try gpa.dupe(u8, "user-1::acc-2"); var rows = try buildSwitchRows(gpa, ®); @@ -1252,8 +1252,8 @@ test "Scenario: Given the active account is the remove cursor then the cursor ma var reg = makeTestRegistry(); defer reg.deinit(gpa); - try appendTestAccount(gpa, ®, "user-1::acc-1", "active@example.com", "", .team); - try appendTestAccount(gpa, ®, "user-1::acc-2", "other@example.com", "", .team); + try appendTestAccount(gpa, ®, "user-1::acc-1", "active@example.com", "", .business); + try appendTestAccount(gpa, ®, "user-1::acc-2", "other@example.com", "", .business); reg.active_account_key = try gpa.dupe(u8, "user-1::acc-1"); var rows = try buildSwitchRows(gpa, ®); @@ -1353,7 +1353,7 @@ test "Scenario: Given switch live feedback when rendering switch screen then the var reg = makeTestRegistry(); defer reg.deinit(gpa); - try appendTestAccount(gpa, ®, "user-1::acc-1", "healthy@example.com", "", .team); + try appendTestAccount(gpa, ®, "user-1::acc-1", "healthy@example.com", "", .business); var rows = try buildSwitchRows(gpa, ®); defer rows.deinit(gpa); @@ -1383,7 +1383,7 @@ test "Scenario: Given switch live feedback with color when rendering switch scre var reg = makeTestRegistry(); defer reg.deinit(gpa); - try appendTestAccount(gpa, ®, "user-1::acc-1", "healthy@example.com", "", .team); + try appendTestAccount(gpa, ®, "user-1::acc-1", "healthy@example.com", "", .business); var rows = try buildSwitchRows(gpa, ®); defer rows.deinit(gpa); @@ -1425,7 +1425,7 @@ test "Scenario: Given live screen status and footers with color when rendering t var reg = makeTestRegistry(); defer reg.deinit(gpa); - try appendTestAccount(gpa, ®, "user-1::acc-1", "healthy@example.com", "", .team); + try appendTestAccount(gpa, ®, "user-1::acc-1", "healthy@example.com", "", .business); var rows = try buildSwitchRows(gpa, ®); defer rows.deinit(gpa); @@ -1508,7 +1508,7 @@ test "Scenario: Given usage overrides when rendering remove list then failed row var reg = makeTestRegistry(); defer reg.deinit(gpa); - try appendTestAccount(gpa, ®, "user-1::acc-1", "user@example.com", "", .team); + try appendTestAccount(gpa, ®, "user-1::acc-1", "user@example.com", "", .business); try appendTestAccount(gpa, ®, "user-1::acc-2", "user@example.com", "", .free); const usage_overrides = [_]?[]const u8{ null, "401" }; @@ -1530,7 +1530,7 @@ test "Scenario: Given usage overrides when rendering switch list with color then var reg = makeTestRegistry(); defer reg.deinit(gpa); - try appendTestAccount(gpa, ®, "user-1::acc-1", "user@example.com", "", .team); + try appendTestAccount(gpa, ®, "user-1::acc-1", "user@example.com", "", .business); try appendTestAccount(gpa, ®, "user-1::acc-2", "user@example.com", "", .free); const usage_overrides = [_]?[]const u8{ null, "401" }; @@ -1551,7 +1551,7 @@ test "Scenario: Given usage overrides when rendering remove list with color then var reg = makeTestRegistry(); defer reg.deinit(gpa); - try appendTestAccount(gpa, ®, "user-1::acc-1", "user@example.com", "", .team); + try appendTestAccount(gpa, ®, "user-1::acc-1", "user@example.com", "", .business); try appendTestAccount(gpa, ®, "user-1::acc-2", "user@example.com", "", .free); const usage_overrides = [_]?[]const u8{ null, "401" }; @@ -1578,7 +1578,7 @@ test "Scenario: Given a usage snapshot plan when building switch rows then the d .primary = null, .secondary = null, .credits = null, - .plan_type = .team, + .plan_type = .business, }; var rows = try buildSwitchRows(gpa, ®); diff --git a/tests/registry_test.zig b/tests/registry_test.zig index 8807304..a2dcdef 100644 --- a/tests/registry_test.zig +++ b/tests/registry_test.zig @@ -366,7 +366,7 @@ test "setting same active account preserves previous active account key" { try std.testing.expectEqualStrings(alpha_key, reg.previous_active_account_key.?); } -test "plan labels are human-readable while registry stores raw plan values" { +test "plan labels are human-readable while registry stores canonical plan values" { const gpa = std.testing.allocator; var tmp = fs.tmpDir(.{}); defer tmp.cleanup(); @@ -391,7 +391,7 @@ test "plan labels are human-readable while registry stores raw plan values" { try std.testing.expectEqualStrings("Free", registry.planLabel(.free)); try std.testing.expectEqualStrings("Plus", registry.planLabel(.plus)); try std.testing.expectEqualStrings("Pro Lite", registry.planLabel(.prolite)); - try std.testing.expectEqualStrings("Business", registry.planLabel(.team)); + try std.testing.expectEqualStrings("Business", registry.planLabel(.business)); } test "resolveDisplayPlan prefers a usage snapshot plan over the stored auth plan" { @@ -404,12 +404,21 @@ test "resolveDisplayPlan prefers a usage snapshot plan over the stored auth plan .primary = null, .secondary = null, .credits = null, - .plan_type = .team, + .plan_type = .business, }; try reg.accounts.append(gpa, rec); try std.testing.expectEqual(registry.PlanType.plus, registry.resolvePlan(®.accounts.items[0]).?); - try std.testing.expectEqual(registry.PlanType.team, registry.resolveDisplayPlan(®.accounts.items[0]).?); + try std.testing.expectEqual(registry.PlanType.business, registry.resolveDisplayPlan(®.accounts.items[0]).?); +} + +test "backend plan values normalize to final product plans" { + try std.testing.expectEqual(registry.PlanType.business, registry.normalizePlanType("team")); + try std.testing.expectEqual(registry.PlanType.business, registry.normalizePlanType("self_serve_business_usage_based")); + try std.testing.expectEqual(registry.PlanType.enterprise, registry.normalizePlanType("business")); + try std.testing.expectEqual(registry.PlanType.enterprise, registry.normalizePlanType("enterprise_cbp_usage_based")); + try std.testing.expectEqual(registry.PlanType.enterprise, registry.normalizePlanType("hc")); + try std.testing.expectEqual(registry.PlanType.go, registry.normalizePlanType("go")); } test "registry load defaults missing account_name field to null" { @@ -511,6 +520,76 @@ test "registry load normalizes schema four without previous active account key" try std.testing.expect(std.mem.indexOf(u8, contents, "\"previous_active_account_key\": null") != null); } +test "schema three registry plan values migrate to canonical product plans" { + const gpa = std.testing.allocator; + var tmp = fs.tmpDir(.{}); + defer tmp.cleanup(); + + const codex_home = try tmp.dir.realpathAlloc(gpa, "."); + defer gpa.free(codex_home); + try tmp.dir.makePath("accounts"); + try tmp.dir.writeFile(.{ + .sub_path = "accounts/registry.json", + .data = + \\{ + \\ "schema_version": 3, + \\ "active_account_key": null, + \\ "active_account_activated_at_ms": null, + \\ "previous_active_account_key": null, + \\ "interval_seconds": 60, + \\ "accounts": [ + \\ { + \\ "account_key": "user-a::account-a", + \\ "chatgpt_account_id": "account-a", + \\ "chatgpt_user_id": "user-a", + \\ "email": "business@example.com", + \\ "alias": "", + \\ "account_name": null, + \\ "plan": "team", + \\ "auth_mode": "chatgpt", + \\ "created_at": 1, + \\ "last_used_at": null, + \\ "last_usage": { "plan_type": "business" }, + \\ "last_usage_at": 2, + \\ "last_local_rollout": null + \\ }, + \\ { + \\ "account_key": "user-b::account-b", + \\ "chatgpt_account_id": "account-b", + \\ "chatgpt_user_id": "user-b", + \\ "email": "enterprise@example.com", + \\ "alias": "", + \\ "account_name": null, + \\ "plan": "business", + \\ "auth_mode": "chatgpt", + \\ "created_at": 1, + \\ "last_used_at": null, + \\ "last_usage": null, + \\ "last_usage_at": null, + \\ "last_local_rollout": null + \\ } + \\ ] + \\} + , + }); + + var loaded = try registry.loadRegistry(gpa, codex_home); + defer loaded.deinit(gpa); + try std.testing.expectEqual(registry.PlanType.business, loaded.accounts.items[0].plan.?); + try std.testing.expectEqual(registry.PlanType.enterprise, loaded.accounts.items[0].last_usage.?.plan_type.?); + try std.testing.expectEqual(registry.PlanType.enterprise, loaded.accounts.items[1].plan.?); + + var file = try tmp.dir.openFile("accounts/registry.json", .{}); + defer file.close(); + const contents = try file.readToEndAlloc(gpa, 10 * 1024 * 1024); + defer gpa.free(contents); + try std.testing.expect(std.mem.indexOf(u8, contents, "\"schema_version\": 4") != null); + try std.testing.expect(std.mem.indexOf(u8, contents, "\"plan\": \"team\"") == null); + try std.testing.expect(std.mem.indexOf(u8, contents, "\"plan\": \"business\"") != null); + try std.testing.expect(std.mem.indexOf(u8, contents, "\"plan\": \"enterprise\"") != null); + try std.testing.expect(std.mem.indexOf(u8, contents, "\"plan_type\": \"enterprise\"") != null); +} + test "registry save/load round-trips account_name string" { const gpa = std.testing.allocator; var tmp = fs.tmpDir(.{}); @@ -576,7 +655,7 @@ test "applyAccountNamesForUser updates same-user records across personal and tea var reg = makeEmptyRegistry(); defer reg.deinit(gpa); - var team = try makeAccountRecord(gpa, "same@example.com", "", .team, .chatgpt, 1); + var team = try makeAccountRecord(gpa, "same@example.com", "", .business, .chatgpt, 1); try setRecordIds(gpa, &team, "user-shared", "acct-team"); team.account_name = try gpa.dupe(u8, "Legacy Workspace"); try reg.accounts.append(gpa, team); @@ -585,7 +664,7 @@ test "applyAccountNamesForUser updates same-user records across personal and tea try setRecordIds(gpa, &plus, "user-shared", "acct-plus"); try reg.accounts.append(gpa, plus); - var other = try makeAccountRecord(gpa, "other@example.com", "", .team, .chatgpt, 3); + var other = try makeAccountRecord(gpa, "other@example.com", "", .business, .chatgpt, 3); try setRecordIds(gpa, &other, "user-other", "acct-other"); other.account_name = try gpa.dupe(u8, "Unrelated Workspace"); try reg.accounts.append(gpa, other); @@ -1298,7 +1377,7 @@ test "clean uses a whitelist and only removes non-current entries under accounts var reg = makeEmptyRegistry(); defer reg.deinit(gpa); - const active_record = try makeAccountRecord(gpa, "keep@example.com", "", .team, .chatgpt, 1); + const active_record = try makeAccountRecord(gpa, "keep@example.com", "", .business, .chatgpt, 1); try reg.accounts.append(gpa, active_record); try registry.saveRegistry(gpa, codex_home, ®); @@ -1353,7 +1432,7 @@ test "clean preserves account snapshots when registry is missing" { var reg = makeEmptyRegistry(); defer reg.deinit(gpa); - const keep_record = try makeAccountRecord(gpa, "keep@example.com", "", .team, .chatgpt, 1); + const keep_record = try makeAccountRecord(gpa, "keep@example.com", "", .business, .chatgpt, 1); try reg.accounts.append(gpa, keep_record); try registry.saveRegistry(gpa, codex_home, ®); @@ -1400,7 +1479,7 @@ test "remove accounts deletes matching snapshots and auth backups only for remov var reg = makeEmptyRegistry(); defer reg.deinit(gpa); try reg.accounts.append(gpa, try makeAccountRecord(gpa, "remove@example.com", "", .plus, .chatgpt, 1)); - try reg.accounts.append(gpa, try makeAccountRecord(gpa, "keep@example.com", "", .team, .chatgpt, 2)); + try reg.accounts.append(gpa, try makeAccountRecord(gpa, "keep@example.com", "", .business, .chatgpt, 2)); const remove_account_key = try accountKeyForEmailAlloc(gpa, "remove@example.com"); defer gpa.free(remove_account_key); @@ -1453,7 +1532,7 @@ test "remove accounts clears previous active account when previous is removed" { var reg = makeEmptyRegistry(); defer reg.deinit(gpa); try reg.accounts.append(gpa, try makeAccountRecord(gpa, "previous@example.com", "", .plus, .chatgpt, 1)); - try reg.accounts.append(gpa, try makeAccountRecord(gpa, "active@example.com", "", .team, .chatgpt, 2)); + try reg.accounts.append(gpa, try makeAccountRecord(gpa, "active@example.com", "", .business, .chatgpt, 2)); const previous_key = try accountKeyForEmailAlloc(gpa, "previous@example.com"); defer gpa.free(previous_key); diff --git a/tests/tui_display_test.zig b/tests/tui_display_test.zig index 83c7c9c..cbb5672 100644 --- a/tests/tui_display_test.zig +++ b/tests/tui_display_test.zig @@ -68,8 +68,8 @@ test "Scenario: Given same email with two team accounts and one plus account whe var reg = makeRegistry(); defer reg.deinit(gpa); - try appendAccount(gpa, ®, "user-ESYgcy2QkOGZc0NoxSlFCeVT::67fe2bbb-0de6-49a4-b2b3-d1df366d1faf", "user@example.com", "", .team); - try appendAccount(gpa, ®, "user-ESYgcy2QkOGZc0NoxSlFCeVT::518a44d9-ba75-4bad-87e5-ae9377042960", "user@example.com", "", .team); + try appendAccount(gpa, ®, "user-ESYgcy2QkOGZc0NoxSlFCeVT::67fe2bbb-0de6-49a4-b2b3-d1df366d1faf", "user@example.com", "", .business); + try appendAccount(gpa, ®, "user-ESYgcy2QkOGZc0NoxSlFCeVT::518a44d9-ba75-4bad-87e5-ae9377042960", "user@example.com", "", .business); try appendAccount(gpa, ®, "user-ESYgcy2QkOGZc0NoxSlFCeVT::a4021fa5-998b-4774-989f-784fa69c367b", "user@example.com", "", .plus); try registry.setActiveAccountKey(gpa, ®, "user-ESYgcy2QkOGZc0NoxSlFCeVT::518a44d9-ba75-4bad-87e5-ae9377042960"); @@ -91,8 +91,8 @@ test "Scenario: Given grouped accounts with aliases when building display rows t var reg = makeRegistry(); defer reg.deinit(gpa); - try appendAccount(gpa, ®, "user-ESYgcy2QkOGZc0NoxSlFCeVT::67fe2bbb-0de6-49a4-b2b3-d1df366d1faf", "user@example.com", "work", .team); - try appendAccount(gpa, ®, "user-ESYgcy2QkOGZc0NoxSlFCeVT::518a44d9-ba75-4bad-87e5-ae9377042960", "user@example.com", "backup", .team); + try appendAccount(gpa, ®, "user-ESYgcy2QkOGZc0NoxSlFCeVT::67fe2bbb-0de6-49a4-b2b3-d1df366d1faf", "user@example.com", "work", .business); + try appendAccount(gpa, ®, "user-ESYgcy2QkOGZc0NoxSlFCeVT::518a44d9-ba75-4bad-87e5-ae9377042960", "user@example.com", "backup", .business); var rows = try display_rows.buildDisplayRows(gpa, ®, null); defer rows.deinit(gpa); @@ -108,7 +108,7 @@ test "Scenario: Given grouped accounts with a prolite record when building displ defer reg.deinit(gpa); try appendAccount(gpa, ®, "user-ESYgcy2QkOGZc0NoxSlFCeVT::67fe2bbb-0de6-49a4-b2b3-d1df366d1faf", "user@example.com", "", .prolite); - try appendAccount(gpa, ®, "user-ESYgcy2QkOGZc0NoxSlFCeVT::518a44d9-ba75-4bad-87e5-ae9377042960", "user@example.com", "", .team); + try appendAccount(gpa, ®, "user-ESYgcy2QkOGZc0NoxSlFCeVT::518a44d9-ba75-4bad-87e5-ae9377042960", "user@example.com", "", .business); var rows = try display_rows.buildDisplayRows(gpa, ®, null); defer rows.deinit(gpa); @@ -129,7 +129,7 @@ test "Scenario: Given a grouped account with a fresher usage plan when building .primary = null, .secondary = null, .credits = null, - .plan_type = .team, + .plan_type = .business, }; try appendAccount(gpa, ®, "user-ESYgcy2QkOGZc0NoxSlFCeVT::518a44d9-ba75-4bad-87e5-ae9377042960", "user@example.com", "", .free); @@ -147,7 +147,7 @@ test "Scenario: Given same-email accounts filtered down to one row when building var reg = makeRegistry(); defer reg.deinit(gpa); - try appendAccount(gpa, ®, "user-ESYgcy2QkOGZc0NoxSlFCeVT::67fe2bbb-0de6-49a4-b2b3-d1df366d1faf", "user@example.com", "work", .team); + try appendAccount(gpa, ®, "user-ESYgcy2QkOGZc0NoxSlFCeVT::67fe2bbb-0de6-49a4-b2b3-d1df366d1faf", "user@example.com", "work", .business); reg.accounts.items[0].account_name = try gpa.dupe(u8, "Primary Workspace"); try appendAccount(gpa, ®, "user-ESYgcy2QkOGZc0NoxSlFCeVT::a4021fa5-998b-4774-989f-784fa69c367b", "user@example.com", "", .plus); @@ -170,12 +170,12 @@ test "Scenario: Given singleton accounts with alias and account name combination var reg = makeRegistry(); defer reg.deinit(gpa); - try appendAccount(gpa, ®, "user-4QmYj7PkN2sLx8AcVbR3TwHd::67fe2bbb-0de6-49a4-b2b3-d1df366d1faf", "alias-name@example.com", "work", .team); + try appendAccount(gpa, ®, "user-4QmYj7PkN2sLx8AcVbR3TwHd::67fe2bbb-0de6-49a4-b2b3-d1df366d1faf", "alias-name@example.com", "work", .business); reg.accounts.items[0].account_name = try gpa.dupe(u8, "Primary Workspace"); - try appendAccount(gpa, ®, "user-8LnCq5VzR1mHx9SfKpT4JdWe::518a44d9-ba75-4bad-87e5-ae9377042960", "alias-only@example.com", "backup", .team); - try appendAccount(gpa, ®, "user-2RbFk6NsQ8vLp3XtJmW7CyHa::a4021fa5-998b-4774-989f-784fa69c367b", "name-only@example.com", "", .team); + try appendAccount(gpa, ®, "user-8LnCq5VzR1mHx9SfKpT4JdWe::518a44d9-ba75-4bad-87e5-ae9377042960", "alias-only@example.com", "backup", .business); + try appendAccount(gpa, ®, "user-2RbFk6NsQ8vLp3XtJmW7CyHa::a4021fa5-998b-4774-989f-784fa69c367b", "name-only@example.com", "", .business); reg.accounts.items[2].account_name = try gpa.dupe(u8, "Sandbox"); - try appendAccount(gpa, ®, "user-9TwHs4KmP7xNc2LdVrQ6BjYe::d8f0f19d-7b6f-4db8-b7a8-07b9fbf5774a", "fallback@example.com", "", .team); + try appendAccount(gpa, ®, "user-9TwHs4KmP7xNc2LdVrQ6BjYe::d8f0f19d-7b6f-4db8-b7a8-07b9fbf5774a", "fallback@example.com", "", .business); var rows = try display_rows.buildDisplayRows(gpa, ®, null); defer rows.deinit(gpa); @@ -192,9 +192,9 @@ test "Scenario: Given mixed singleton and grouped accounts when building display var reg = makeRegistry(); defer reg.deinit(gpa); - try appendAccount(gpa, ®, "user-6JpMv8XrT3nLc9QsHbW4DyKa::67fe2bbb-0de6-49a4-b2b3-d1df366d1faf", "solo@example.com", "solo", .team); + try appendAccount(gpa, ®, "user-6JpMv8XrT3nLc9QsHbW4DyKa::67fe2bbb-0de6-49a4-b2b3-d1df366d1faf", "solo@example.com", "solo", .business); reg.accounts.items[0].account_name = try gpa.dupe(u8, "Solo Workspace"); - try appendAccount(gpa, ®, "user-1ZdKr5NtV8mQx3LsHpW7CyFb::518a44d9-ba75-4bad-87e5-ae9377042960", "user@example.com", "work", .team); + try appendAccount(gpa, ®, "user-1ZdKr5NtV8mQx3LsHpW7CyFb::518a44d9-ba75-4bad-87e5-ae9377042960", "user@example.com", "work", .business); reg.accounts.items[1].account_name = try gpa.dupe(u8, "Primary Workspace"); try appendAccount(gpa, ®, "user-1ZdKr5NtV8mQx3LsHpW7CyFb::a4021fa5-998b-4774-989f-784fa69c367b", "user@example.com", "", .plus); @@ -214,9 +214,9 @@ test "Scenario: Given grouped accounts with account names when building display var reg = makeRegistry(); defer reg.deinit(gpa); - try appendAccount(gpa, ®, "user-ESYgcy2QkOGZc0NoxSlFCeVT::67fe2bbb-0de6-49a4-b2b3-d1df366d1faf", "user@example.com", "work", .team); + try appendAccount(gpa, ®, "user-ESYgcy2QkOGZc0NoxSlFCeVT::67fe2bbb-0de6-49a4-b2b3-d1df366d1faf", "user@example.com", "work", .business); reg.accounts.items[0].account_name = try gpa.dupe(u8, "Primary Workspace"); - try appendAccount(gpa, ®, "user-ESYgcy2QkOGZc0NoxSlFCeVT::518a44d9-ba75-4bad-87e5-ae9377042960", "user@example.com", "", .team); + try appendAccount(gpa, ®, "user-ESYgcy2QkOGZc0NoxSlFCeVT::518a44d9-ba75-4bad-87e5-ae9377042960", "user@example.com", "", .business); reg.accounts.items[1].account_name = try gpa.dupe(u8, "Backup Workspace"); try appendAccount(gpa, ®, "user-ESYgcy2QkOGZc0NoxSlFCeVT::a4021fa5-998b-4774-989f-784fa69c367b", "user@example.com", "", .plus); diff --git a/tests/tui_table_test.zig b/tests/tui_table_test.zig index da6d0b5..ffed761 100644 --- a/tests/tui_table_test.zig +++ b/tests/tui_table_test.zig @@ -113,7 +113,7 @@ test "writeAccountsTable shows zero-padded row numbers for selectable accounts" var reg = makeTestRegistry(); defer reg.deinit(gpa); - try appendTestAccount(gpa, ®, "user-1::acc-1", "user@example.com", "", .team); + try appendTestAccount(gpa, ®, "user-1::acc-1", "user@example.com", "", .business); reg.accounts.items[0].account_name = try gpa.dupe(u8, "Als's Workspace"); try appendTestAccount(gpa, ®, "user-1::acc-2", "user@example.com", "", .free); @@ -131,7 +131,7 @@ test "writeAccountsTable keeps usage headers short" { var reg = makeTestRegistry(); defer reg.deinit(gpa); - try appendTestAccount(gpa, ®, "user-1::acc-1", "user@example.com", "", .team); + try appendTestAccount(gpa, ®, "user-1::acc-1", "user@example.com", "", .business); var buffer: [2048]u8 = undefined; var writer: std.Io.Writer = .fixed(&buffer); @@ -171,7 +171,7 @@ test "writeAccountsTable shows usage override statuses for failed refreshes" { var reg = makeTestRegistry(); defer reg.deinit(gpa); - try appendTestAccount(gpa, ®, "user-1::acc-1", "user@example.com", "", .team); + try appendTestAccount(gpa, ®, "user-1::acc-1", "user@example.com", "", .business); try appendTestAccount(gpa, ®, "user-1::acc-2", "user@example.com", "", .free); const usage_overrides = [_]?[]const u8{ null, "403" }; @@ -189,7 +189,7 @@ test "writeAccountsTable highlights usage override rows in red when color is ena var reg = makeTestRegistry(); defer reg.deinit(gpa); - try appendTestAccount(gpa, ®, "user-1::acc-1", "user@example.com", "", .team); + try appendTestAccount(gpa, ®, "user-1::acc-1", "user@example.com", "", .business); try appendTestAccount(gpa, ®, "user-1::acc-2", "user@example.com", "", .free); const usage_overrides = [_]?[]const u8{ null, "403" }; @@ -207,7 +207,7 @@ test "writeAccountsTable uses cyan headers green active rows and default normal var reg = makeTestRegistry(); defer reg.deinit(gpa); - try appendTestAccount(gpa, ®, "user-1::acc-1", "active@example.com", "", .team); + try appendTestAccount(gpa, ®, "user-1::acc-1", "active@example.com", "", .business); try appendTestAccount(gpa, ®, "user-1::acc-2", "normal@example.com", "", .free); reg.active_account_key = try gpa.dupe(u8, "user-1::acc-1"); @@ -231,7 +231,7 @@ test "writeAccountsTable prefers usage snapshot plan labels over stored auth pla .primary = null, .secondary = null, .credits = null, - .plan_type = .team, + .plan_type = .business, }; var buffer: [2048]u8 = undefined; diff --git a/tests/workflows_core_test.zig b/tests/workflows_core_test.zig index f0ff8af..69413ab 100644 --- a/tests/workflows_core_test.zig +++ b/tests/workflows_core_test.zig @@ -307,7 +307,7 @@ test "Scenario: Given alias, email, and account name queries when finding matchi var reg = makeRegistry(); defer reg.deinit(gpa); - try appendAccount(gpa, ®, "user-A1B2C3D4E5F6::67fe2bbb-0de6-49a4-b2b3-d1df366d1faf", "user@example.com", "team-work", .team); + try appendAccount(gpa, ®, "user-A1B2C3D4E5F6::67fe2bbb-0de6-49a4-b2b3-d1df366d1faf", "user@example.com", "team-work", .business); try appendAccount(gpa, ®, "user-Z9Y8X7W6V5U4::518a44d9-ba75-4bad-87e5-ae9377042960", "other@example.com", "", .plus); reg.accounts.items[1].account_name = try gpa.dupe(u8, "Ops Workspace"); @@ -332,7 +332,7 @@ test "Scenario: Given account_id query when finding matching accounts then it is var reg = makeRegistry(); defer reg.deinit(gpa); - try appendAccount(gpa, ®, "user-A1B2C3D4E5F6::67fe2bbb-0de6-49a4-b2b3-d1df366d1faf", "user@example.com", "work", .team); + try appendAccount(gpa, ®, "user-A1B2C3D4E5F6::67fe2bbb-0de6-49a4-b2b3-d1df366d1faf", "user@example.com", "work", .business); var matches = try main_mod.findMatchingAccounts(gpa, ®, "67fe2bbb"); defer matches.deinit(gpa); @@ -436,7 +436,7 @@ test "Scenario: Given switch query with one local match when resolving locally t var reg = makeRegistry(); defer reg.deinit(gpa); - try appendAccount(gpa, ®, primary_record_key, "active@example.com", "primary", .team); + try appendAccount(gpa, ®, primary_record_key, "active@example.com", "primary", .business); try appendAccount(gpa, ®, secondary_record_key, "backup@example.com", "secondary", .plus); var resolution = try main_mod.resolveSwitchQueryLocally(gpa, ®, "backup@"); @@ -453,7 +453,7 @@ test "Scenario: Given switch query with a display number when resolving locally var reg = makeRegistry(); defer reg.deinit(gpa); - try appendAccount(gpa, ®, primary_record_key, "alpha@example.com", "alpha", .team); + try appendAccount(gpa, ®, primary_record_key, "alpha@example.com", "alpha", .business); try appendAccount(gpa, ®, secondary_record_key, "beta@example.com", "beta", .plus); var resolution = try main_mod.resolveSwitchQueryLocally(gpa, ®, "02"); @@ -465,13 +465,30 @@ test "Scenario: Given switch query with a display number when resolving locally } } +test "Scenario: Given switch query with an exact account key when resolving locally then it wins directly" { + const gpa = std.testing.allocator; + var reg = makeRegistry(); + defer reg.deinit(gpa); + + try appendAccount(gpa, ®, primary_record_key, "user@example.com", "team-a", .business); + try appendAccount(gpa, ®, secondary_record_key, "user@example.com", "team-b", .business); + + var resolution = try main_mod.resolveSwitchQueryLocally(gpa, ®, secondary_record_key); + defer resolution.deinit(gpa); + + switch (resolution) { + .direct => |account_key| try std.testing.expectEqualStrings(secondary_record_key, account_key), + else => return error.TestExpectedEqual, + } +} + test "Scenario: Given switch query with multiple local matches when resolving locally then it keeps the local picker set" { const gpa = std.testing.allocator; var reg = makeRegistry(); defer reg.deinit(gpa); - try appendAccount(gpa, ®, primary_record_key, "user@example.com", "team-a", .team); - try appendAccount(gpa, ®, secondary_record_key, "user@example.com", "team-b", .team); + try appendAccount(gpa, ®, primary_record_key, "user@example.com", "team-a", .business); + try appendAccount(gpa, ®, secondary_record_key, "user@example.com", "team-b", .business); var resolution = try main_mod.resolveSwitchQueryLocally(gpa, ®, "team"); defer resolution.deinit(gpa); @@ -524,7 +541,7 @@ test "Scenario: Given api usage refresh for list and switch when refreshing fore if (std.mem.eql(u8, account_id, primary_account_id)) { return .{ - .snapshot = snapshot(.team, 18, 39), + .snapshot = snapshot(.business, 18, 39), .status_code = 200, }; } @@ -549,8 +566,8 @@ test "Scenario: Given api usage refresh for list and switch when refreshing fore var reg = makeRegistry(); defer reg.deinit(gpa); - try appendAccount(gpa, ®, primary_record_key, "user@example.com", "", .team); - try appendAccount(gpa, ®, secondary_record_key, "user@example.com", "", .team); + try appendAccount(gpa, ®, primary_record_key, "user@example.com", "", .business); + try appendAccount(gpa, ®, secondary_record_key, "user@example.com", "", .business); try appendAccount(gpa, ®, tertiary_record_key, "user@example.com", "", .plus); try registry.setActiveAccountKey(gpa, ®, primary_record_key); @@ -583,11 +600,67 @@ test "Scenario: Given api usage refresh for list and switch when refreshing fore try std.testing.expectEqual(@as(?u16, 403), state.outcomes[1].status_code); try std.testing.expect(state.outcomes[2].unchanged); - try std.testing.expectEqual(@as(?registry.PlanType, .team), reg.accounts.items[0].last_usage.?.plan_type); + try std.testing.expectEqual(@as(?registry.PlanType, .business), reg.accounts.items[0].last_usage.?.plan_type); try std.testing.expectEqual(@as(f64, 18), reg.accounts.items[0].last_usage.?.primary.?.used_percent); try std.testing.expectEqual(@as(f64, 55), reg.accounts.items[2].last_usage.?.secondary.?.used_percent); } +test "Scenario: Given cached usage and a local refresh error when listing then the cached snapshot remains available" { + const gpa = std.testing.allocator; + var tmp = fs.tmpDir(.{}); + defer tmp.cleanup(); + + const codex_home = try tmp.dir.realpathAlloc(gpa, "."); + defer gpa.free(codex_home); + try tmp.dir.writeFile(.{ .sub_path = "sessions", .data = "not a directory" }); + + var reg = makeRegistry(); + defer reg.deinit(gpa); + try appendAccount(gpa, ®, primary_record_key, "user@example.com", "", .business); + try registry.setActiveAccountKey(gpa, ®, primary_record_key); + reg.accounts.items[0].last_usage = .{ + .primary = .{ + .used_percent = 25, + .window_minutes = 300, + .resets_at = 1773491460, + }, + .secondary = null, + .credits = null, + .plan_type = .business, + }; + reg.accounts.items[0].last_usage_at = 123; + + var state = try main_mod.refreshForegroundUsageForDisplayWithApiFetchersWithPoolInitUsingApiEnabledAndPersistAndActiveOnly( + gpa, + codex_home, + ®, + usage_api.fetchUsageForAuthPathDetailed, + null, + main_mod.initForegroundUsagePool, + false, + false, + false, + false, + ); + defer state.deinit(gpa); + + try std.testing.expect(state.local_only_mode); + try std.testing.expectEqual(@as(usize, 1), state.attempted); + try std.testing.expectEqual(@as(usize, 1), state.failed); + try std.testing.expect(state.outcomes[0].attempted); + try std.testing.expect(state.outcomes[0].error_name != null); + + var result = try main_mod.results.buildListResult(gpa, ®, &state); + defer result.deinit(gpa); + try std.testing.expectEqual(@as(usize, 1), result.accounts.len); + try std.testing.expectEqual(main_mod.results.UsageSource.cache, result.accounts[0].usage.source); + try std.testing.expectEqual(@as(?i64, 123), result.accounts[0].usage.updated_at); + try std.testing.expectEqual(@as(f64, 25), result.accounts[0].usage.primary.?.used_percent); + try std.testing.expectEqual(main_mod.results.UsageRefreshStatus.error_status, result.accounts[0].usage.refresh.status); + try std.testing.expect(result.accounts[0].usage.refresh.error_code != null); + try std.testing.expectEqual(@as(usize, 1), result.warnings.len); +} + test "Scenario: Given active-only foreground usage refresh then only the active account is requested" { const gpa = std.testing.allocator; var tmp = fs.tmpDir(.{}); @@ -623,7 +696,7 @@ test "Scenario: Given active-only foreground usage refresh then only the active const results = try allocator.alloc(usage_api.BatchUsageFetchResult, auth_paths.len); for (results) |*result| { result.* = .{ - .snapshot = snapshot(.team, 18, 40), + .snapshot = snapshot(.business, 18, 40), .status_code = 200, }; } @@ -635,8 +708,8 @@ test "Scenario: Given active-only foreground usage refresh then only the active var reg = makeRegistry(); defer reg.deinit(gpa); - try appendAccount(gpa, ®, primary_record_key, "user@example.com", "", .team); - try appendAccount(gpa, ®, secondary_record_key, "user@example.com", "", .team); + try appendAccount(gpa, ®, primary_record_key, "user@example.com", "", .business); + try appendAccount(gpa, ®, secondary_record_key, "user@example.com", "", .business); try registry.setActiveAccountKey(gpa, ®, secondary_record_key); try writeAccountSnapshotWithIds(gpa, codex_home, "user@example.com", "team", shared_user_id, primary_account_id); @@ -700,13 +773,14 @@ test "Scenario: Given API key auth when refreshing foreground usage then overrid defer state.deinit(gpa); try std.testing.expect(!state.local_only_mode); - try std.testing.expectEqual(@as(usize, 1), state.attempted); + try std.testing.expectEqual(@as(usize, 0), state.attempted); try std.testing.expectEqual(@as(usize, 0), state.updated); try std.testing.expectEqual(@as(usize, 0), state.failed); - try std.testing.expectEqual(@as(usize, 1), state.unchanged); + try std.testing.expectEqual(@as(usize, 0), state.unchanged); try std.testing.expect(state.usage_overrides[0] == null); try std.testing.expect(!state.outcomes[0].missing_auth); - try std.testing.expect(state.outcomes[0].unchanged); + try std.testing.expect(!state.outcomes[0].attempted); + try std.testing.expect(!state.outcomes[0].unchanged); } test "Scenario: Given API key registry record with stale snapshot when refreshing foreground usage then MissingAuth is not shown" { @@ -734,12 +808,13 @@ test "Scenario: Given API key registry record with stale snapshot when refreshin var state = try main_mod.refreshForegroundUsageForDisplay(gpa, codex_home, ®); defer state.deinit(gpa); - try std.testing.expectEqual(@as(usize, 1), state.attempted); + try std.testing.expectEqual(@as(usize, 0), state.attempted); try std.testing.expectEqual(@as(usize, 0), state.failed); - try std.testing.expectEqual(@as(usize, 1), state.unchanged); + try std.testing.expectEqual(@as(usize, 0), state.unchanged); try std.testing.expect(state.usage_overrides[0] == null); try std.testing.expect(!state.outcomes[0].missing_auth); - try std.testing.expect(state.outcomes[0].unchanged); + try std.testing.expect(!state.outcomes[0].attempted); + try std.testing.expect(!state.outcomes[0].unchanged); } test "Scenario: Given more than five foreground usage jobs when refreshing usage then pool init is capped at five workers" { @@ -775,7 +850,7 @@ test "Scenario: Given more than five foreground usage jobs when refreshing usage defer gpa.free(account_id); const record_key = try std.fmt.allocPrint(gpa, "{s}::{s}", .{ user_id, account_id }); defer gpa.free(record_key); - try appendAccount(gpa, ®, record_key, email, "", .team); + try appendAccount(gpa, ®, record_key, email, "", .business); } var state = try main_mod.refreshForegroundUsageForDisplayWithApiFetcherWithPoolInit( @@ -821,7 +896,7 @@ test "Scenario: Given foreground usage returns response error code then status o var reg = makeRegistry(); defer reg.deinit(gpa); - try appendAccount(gpa, ®, primary_record_key, "user@example.com", "", .team); + try appendAccount(gpa, ®, primary_record_key, "user@example.com", "", .business); try writeAccountSnapshotWithIds(gpa, codex_home, "user@example.com", "team", shared_user_id, primary_account_id); var state = try main_mod.refreshForegroundUsageForDisplayWithApiFetcherWithPoolInit( @@ -891,7 +966,7 @@ test "Scenario: Given thread pool init failure when refreshing foreground usage if (std.mem.eql(u8, account_id, primary_account_id)) { return .{ - .snapshot = snapshot(.team, 22, 41), + .snapshot = snapshot(.business, 22, 41), .status_code = 200, }; } @@ -913,8 +988,8 @@ test "Scenario: Given thread pool init failure when refreshing foreground usage var reg = makeRegistry(); defer reg.deinit(gpa); - try appendAccount(gpa, ®, primary_record_key, "user@example.com", "", .team); - try appendAccount(gpa, ®, secondary_record_key, "user@example.com", "", .team); + try appendAccount(gpa, ®, primary_record_key, "user@example.com", "", .business); + try appendAccount(gpa, ®, secondary_record_key, "user@example.com", "", .business); try registry.setActiveAccountKey(gpa, ®, primary_record_key); try writeAccountSnapshotWithIds(gpa, codex_home, "user@example.com", "team", shared_user_id, primary_account_id); @@ -946,8 +1021,8 @@ test "Scenario: Given list with missing team names when running foreground accou var reg = makeRegistry(); defer reg.deinit(gpa); - try appendAccount(gpa, ®, primary_record_key, "user@example.com", "", .team); - try appendAccount(gpa, ®, secondary_record_key, "user@example.com", "", .team); + try appendAccount(gpa, ®, primary_record_key, "user@example.com", "", .business); + try appendAccount(gpa, ®, secondary_record_key, "user@example.com", "", .business); try registry.setActiveAccountKey(gpa, ®, primary_record_key); try writeActiveAuthWithIds(gpa, codex_home, "user@example.com", "team", shared_user_id, primary_account_id); @@ -975,8 +1050,8 @@ test "Scenario: Given switch with missing team names when running foreground acc var reg = makeRegistry(); defer reg.deinit(gpa); - try appendAccount(gpa, ®, primary_record_key, "user@example.com", "", .team); - try appendAccount(gpa, ®, secondary_record_key, "user@example.com", "", .team); + try appendAccount(gpa, ®, primary_record_key, "user@example.com", "", .business); + try appendAccount(gpa, ®, secondary_record_key, "user@example.com", "", .business); try registry.setActiveAccountKey(gpa, ®, primary_record_key); try writeActiveAuthWithIds(gpa, codex_home, "user@example.com", "team", shared_user_id, primary_account_id); @@ -994,20 +1069,25 @@ test "Scenario: Given switch with missing team names when running foreground acc try std.testing.expectEqualStrings("Backup Workspace", loaded.accounts.items[1].account_name.?); } -test "Scenario: Given team name fetch candidates when checking grouped-account policy then only ambiguous team users qualify" { +test "Scenario: Given workspace name fetch candidates when checking grouped-account policy then Business and Enterprise users qualify" { const gpa = std.testing.allocator; var reg = makeRegistry(); defer reg.deinit(gpa); - try appendAccount(gpa, ®, primary_record_key, "same-user@example.com", "", .team); + const enterprise_user_id = "user-enterprise"; + + try appendAccount(gpa, ®, primary_record_key, "same-user@example.com", "", .business); try appendAccount(gpa, ®, secondary_record_key, "same-user@example.com", "", .free); - try appendAccount(gpa, ®, standalone_team_record_key, "solo-team@example.com", "", .team); + try appendAccount(gpa, ®, standalone_team_record_key, "solo-team@example.com", "", .business); try appendAccount(gpa, ®, "user-plus-only::acct-plus-a", "plus-only@example.com", "", .plus); try appendAccount(gpa, ®, "user-plus-only::acct-plus-b", "plus-only-alt@example.com", "", .plus); + try appendAccount(gpa, ®, enterprise_user_id ++ "::acct-enterprise", "enterprise@example.com", "", .enterprise); + try appendAccount(gpa, ®, enterprise_user_id ++ "::acct-personal", "enterprise-personal@example.com", "", .plus); - try std.testing.expect(registry.shouldFetchTeamAccountNamesForUser(®, shared_user_id)); - try std.testing.expect(!registry.shouldFetchTeamAccountNamesForUser(®, standalone_team_user_id)); - try std.testing.expect(!registry.shouldFetchTeamAccountNamesForUser(®, "user-plus-only")); + try std.testing.expect(registry.shouldFetchWorkspaceAccountNamesForUser(®, shared_user_id)); + try std.testing.expect(!registry.shouldFetchWorkspaceAccountNamesForUser(®, standalone_team_user_id)); + try std.testing.expect(!registry.shouldFetchWorkspaceAccountNamesForUser(®, "user-plus-only")); + try std.testing.expect(registry.shouldFetchWorkspaceAccountNamesForUser(®, enterprise_user_id)); } test "Scenario: Given a standalone team account when building display rows and refreshing names then it keeps the email label and skips requests" { @@ -1020,7 +1100,7 @@ test "Scenario: Given a standalone team account when building display rows and r var reg = makeRegistry(); defer reg.deinit(gpa); - try appendAccount(gpa, ®, standalone_team_record_key, "solo-team@example.com", "", .team); + try appendAccount(gpa, ®, standalone_team_record_key, "solo-team@example.com", "", .business); try registry.setActiveAccountKey(gpa, ®, standalone_team_record_key); try writeActiveAuthWithIds(gpa, codex_home, "solo-team@example.com", "team", standalone_team_user_id, standalone_team_account_id); @@ -1028,7 +1108,7 @@ test "Scenario: Given a standalone team account when building display rows and r defer rows.deinit(gpa); try std.testing.expectEqual(@as(usize, 1), rows.rows.len); try std.testing.expect(std.mem.eql(u8, rows.rows[0].account_cell, "solo-team@example.com")); - try std.testing.expect(!registry.shouldFetchTeamAccountNamesForUser(®, standalone_team_user_id)); + try std.testing.expect(!registry.shouldFetchWorkspaceAccountNamesForUser(®, standalone_team_user_id)); var info = try parseAuthInfoWithIds(gpa, "solo-team@example.com", "team", standalone_team_user_id, standalone_team_account_id); defer info.deinit(gpa); @@ -1062,8 +1142,8 @@ test "Scenario: Given grouped team accounts with account api disabled when refre var reg = makeRegistry(); defer reg.deinit(gpa); reg.api.account = false; - try appendAccount(gpa, ®, primary_record_key, "user@example.com", "", .team); - try appendAccount(gpa, ®, secondary_record_key, "user@example.com", "", .team); + try appendAccount(gpa, ®, primary_record_key, "user@example.com", "", .business); + try appendAccount(gpa, ®, secondary_record_key, "user@example.com", "", .business); try registry.setActiveAccountKey(gpa, ®, primary_record_key); try writeActiveAuthWithIds(gpa, codex_home, "user@example.com", "team", shared_user_id, primary_account_id); @@ -1092,8 +1172,8 @@ test "Scenario: Given login with missing account names when refreshing metadata var reg = makeRegistry(); defer reg.deinit(gpa); - try appendAccount(gpa, ®, primary_record_key, "user@example.com", "", .team); - try appendAccount(gpa, ®, secondary_record_key, "user@example.com", "", .team); + try appendAccount(gpa, ®, primary_record_key, "user@example.com", "", .business); + try appendAccount(gpa, ®, secondary_record_key, "user@example.com", "", .business); var info = try parseAuthInfoWithIds(gpa, "user@example.com", "team", shared_user_id, primary_account_id); defer info.deinit(gpa); @@ -1117,8 +1197,8 @@ test "Scenario: Given switched account with missing account names when refreshin var reg = makeRegistry(); defer reg.deinit(gpa); - try appendAccount(gpa, ®, primary_record_key, "user@example.com", "", .team); - try appendAccount(gpa, ®, secondary_record_key, "user@example.com", "", .team); + try appendAccount(gpa, ®, primary_record_key, "user@example.com", "", .business); + try appendAccount(gpa, ®, secondary_record_key, "user@example.com", "", .business); try registry.setActiveAccountKey(gpa, ®, primary_record_key); try writeActiveAuthWithIds(gpa, codex_home, "user@example.com", "team", shared_user_id, primary_account_id); @@ -1136,8 +1216,8 @@ test "Scenario: Given single-file import with missing account names when refresh var reg = makeRegistry(); defer reg.deinit(gpa); - try appendAccount(gpa, ®, primary_record_key, "user@example.com", "", .team); - try appendAccount(gpa, ®, secondary_record_key, "user@example.com", "", .team); + try appendAccount(gpa, ®, primary_record_key, "user@example.com", "", .business); + try appendAccount(gpa, ®, secondary_record_key, "user@example.com", "", .business); var info = try parseAuthInfoWithIds(gpa, "user@example.com", "team", shared_user_id, primary_account_id); defer info.deinit(gpa); @@ -1153,8 +1233,8 @@ test "Scenario: Given directory import or purge when refreshing account names th var reg = makeRegistry(); defer reg.deinit(gpa); - try appendAccount(gpa, ®, primary_record_key, "user@example.com", "", .team); - try appendAccount(gpa, ®, secondary_record_key, "user@example.com", "", .team); + try appendAccount(gpa, ®, primary_record_key, "user@example.com", "", .business); + try appendAccount(gpa, ®, secondary_record_key, "user@example.com", "", .business); var info = try parseAuthInfoWithIds(gpa, "user@example.com", "team", shared_user_id, primary_account_id); defer info.deinit(gpa); @@ -1178,11 +1258,11 @@ test "Scenario: Given list refresh when only other users have missing account na var reg = makeRegistry(); defer reg.deinit(gpa); - try appendAccount(gpa, ®, primary_record_key, "user@example.com", "", .team); - try appendAccount(gpa, ®, secondary_record_key, "user@example.com", "", .team); + try appendAccount(gpa, ®, primary_record_key, "user@example.com", "", .business); + try appendAccount(gpa, ®, secondary_record_key, "user@example.com", "", .business); reg.accounts.items[0].account_name = try gpa.dupe(u8, "Primary Workspace"); reg.accounts.items[1].account_name = try gpa.dupe(u8, "Backup Workspace"); - try appendAccount(gpa, ®, "user-OTHER::acct-OTHER", "other@example.com", "", .team); + try appendAccount(gpa, ®, "user-OTHER::acct-OTHER", "other@example.com", "", .business); try registry.setActiveAccountKey(gpa, ®, primary_record_key); try writeActiveAuthWithIds(gpa, codex_home, "user@example.com", "team", shared_user_id, primary_account_id); @@ -1201,9 +1281,9 @@ test "Scenario: Given list refresh with missing active-user account names when r var reg = makeRegistry(); defer reg.deinit(gpa); - try appendAccount(gpa, ®, primary_record_key, "user@example.com", "", .team); - try appendAccount(gpa, ®, secondary_record_key, "user@example.com", "", .team); - try appendAccount(gpa, ®, "user-OTHER::acct-OTHER", "other@example.com", "", .team); + try appendAccount(gpa, ®, primary_record_key, "user@example.com", "", .business); + try appendAccount(gpa, ®, secondary_record_key, "user@example.com", "", .business); + try appendAccount(gpa, ®, "user-OTHER::acct-OTHER", "other@example.com", "", .business); try registry.setActiveAccountKey(gpa, ®, primary_record_key); try writeActiveAuthWithIds(gpa, codex_home, "user@example.com", "team", shared_user_id, primary_account_id); @@ -1230,8 +1310,8 @@ test "Scenario: Given list refresh with team names missing under the same user w var reg = makeRegistry(); defer reg.deinit(gpa); - try appendAccount(gpa, ®, shared_user_id ++ "::" ++ primary_account_id, "same-user@example.com", "", .team); - try appendAccount(gpa, ®, shared_user_id ++ "::" ++ secondary_account_id, "same-user@example.com", "", .team); + try appendAccount(gpa, ®, shared_user_id ++ "::" ++ primary_account_id, "same-user@example.com", "", .business); + try appendAccount(gpa, ®, shared_user_id ++ "::" ++ secondary_account_id, "same-user@example.com", "", .business); reg.accounts.items[1].account_name = try gpa.dupe(u8, "Old Backup Workspace"); try appendAccount(gpa, ®, shared_user_id ++ "::" ++ tertiary_account_id, "same-user@example.com", "", .plus); try registry.setActiveAccountKey(gpa, ®, shared_user_id ++ "::" ++ tertiary_account_id); @@ -1267,7 +1347,7 @@ test "Scenario: Given removed active account with remaining accounts when reconc const gamma_key = try fixtures.accountKeyForEmailAlloc(gpa, "gamma@example.com"); defer gpa.free(gamma_key); try appendAccount(gpa, ®, alpha_key, "alpha@example.com", "", .plus); - try appendAccount(gpa, ®, gamma_key, "gamma@example.com", "", .team); + try appendAccount(gpa, ®, gamma_key, "gamma@example.com", "", .business); const now = std.Io.Timestamp.now(app_runtime.io(), .real).toSeconds(); reg.accounts.items[0].last_usage = .{ @@ -1280,7 +1360,7 @@ test "Scenario: Given removed active account with remaining accounts when reconc .primary = .{ .used_percent = 0, .window_minutes = 300, .resets_at = now + 3600 }, .secondary = null, .credits = null, - .plan_type = .team, + .plan_type = .business, }; try writeSnapshot(gpa, codex_home, "alpha@example.com", "plus"); @@ -1320,7 +1400,7 @@ test "Scenario: Given stale active key with remaining accounts when reconciling const gamma_key = try fixtures.accountKeyForEmailAlloc(gpa, "gamma@example.com"); defer gpa.free(gamma_key); try appendAccount(gpa, ®, alpha_key, "alpha@example.com", "", .plus); - try appendAccount(gpa, ®, gamma_key, "gamma@example.com", "", .team); + try appendAccount(gpa, ®, gamma_key, "gamma@example.com", "", .business); reg.active_account_key = try gpa.dupe(u8, "user-stale::acct-stale"); reg.active_account_activated_at_ms = 1; @@ -1335,7 +1415,7 @@ test "Scenario: Given stale active key with remaining accounts when reconciling .primary = .{ .used_percent = 0, .window_minutes = 300, .resets_at = now + 3600 }, .secondary = null, .credits = null, - .plan_type = .team, + .plan_type = .business, }; try writeSnapshot(gpa, codex_home, "alpha@example.com", "plus"); diff --git a/tests/workflows_live_test.zig b/tests/workflows_live_test.zig index cf72219..c5f3838 100644 --- a/tests/workflows_live_test.zig +++ b/tests/workflows_live_test.zig @@ -137,7 +137,7 @@ fn appendLiveMergeTestAccount( .email = try allocator.dupe(u8, email), .alias = try allocator.dupe(u8, alias), .account_name = null, - .plan = .team, + .plan = .business, .auth_mode = .chatgpt, .created_at = 1, .last_used_at = null,