diff --git a/README.md b/README.md index af5ed04..645681e 100644 --- a/README.md +++ b/README.md @@ -12,20 +12,20 @@ npm install @doist/cli-core ## What's in it -| Module | Key exports | Purpose | -| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `auth` (subpath) | `attachLoginCommand`, `attachLogoutCommand`, `attachStatusCommand`, `attachTokenViewCommand`, `attachAccountListCommand`, `attachAccountUseCommand`, `attachAccountCurrentCommand`, `attachAccountRemoveCommand`, `runOAuthFlow`, `refreshAccessToken`, `createPkceProvider`, `createDcrProvider`, `createSecureStore`, `createKeyringTokenStore`, `migrateLegacyAuth`, `persistBundle`, `bundleFromExchange`, PKCE helpers, `AuthProvider` / `TokenStore` / `TokenBundle` / `ActiveBundleSnapshot` / `RefreshInput` / `AccountRef` / `ClearedAccount` / `SecureStore` / `UserRecordStore` types, `AttachLogoutRevokeContext` / `AttachAccountListContext` / `AttachAccountCurrentContext` / `AttachAccountRemoveContext` | OAuth runtime plus the Commander attachers for ` [auth] login` / `logout` / `status` / `token` and ` account list` / `use` / `current` / `remove`. `attachLogoutCommand` accepts an optional `revokeToken` hook for best-effort server-side token revocation. Ships the standard public-client PKCE flow (`createPkceProvider`), the RFC 7591 Dynamic Client Registration flow (`createDcrProvider`, with optional RFC 8707 resource indicators, refresh-token support, and `loadClient`/`saveClient` client caching), a thin cross-platform OS-keyring wrapper (`createSecureStore`), and a multi-account keyring-backed `TokenStore` (`createKeyringTokenStore`) that stores secrets in the OS credential manager and degrades to plaintext in the consumer's config when the keyring is unavailable (WSL/headless Linux/containers). The store contract supports an optional `setBundle(account, bundle)` write method (required on `KeyringTokenStore`) so consumers that need refresh-token persistence can opt in via `TokenBundle`; `active()` stays narrow (access token + account only) so callers that don't need refresh state don't pay extra keyring IPC. `AuthProvider` and `TokenStore` remain the escape hatches for fully bespoke backends (device code, magic-link, …). `logout` / `status` / `token` always attach `--user ` and thread the parsed ref to `store.active(ref)` (and `store.clear(ref)` on `logout`). `commander` (when using the attachers), `open` (browser launch), `@napi-rs/keyring` (when using `createSecureStore` or the keyring `TokenStore`), and `oauth4webapi` (when a consumer opts into silent refresh or uses `createDcrProvider`) are optional peer/optional deps. | -| `commands` (subpath) | `registerChangelogCommand`, `registerUpdateCommand` (+ semver helpers) | Commander wiring for cli-core's standard commands (e.g. ` changelog`, ` update`, ` update switch`). **Requires** `commander` as an optional peer-dep. | -| `config` | `getConfigPath`, `readConfig`, `readConfigStrict`, `writeConfig`, `updateConfig`, `CoreConfig`, `UpdateChannel` | Read / write a per-CLI JSON config file with typed error codes; `CoreConfig` is the shape of fields cli-core itself owns (extend it for per-CLI fields). | -| `empty` | `printEmpty` | Print an empty-state message gated on `--json` / `--ndjson` so machine consumers never see human strings on stdout. | -| `errors` | `CliError` | Typed CLI error class with `code` and exit-code mapping. | -| `global-args` | `parseGlobalArgs`, `stripUserFlag`, `createGlobalArgsStore`, `createAccessibleGate`, `createSpinnerGate`, `getProgressJsonlPath`, `isProgressJsonlEnabled` | Parse well-known global flags (`--json`, `--ndjson`, `--quiet`, `--verbose`, `--accessible`, `--no-spinner`, `--progress-jsonl`, `--user `) and derive predicates from them. `stripUserFlag` removes `--user` tokens from argv so the cleaned array can be forwarded to Commander when the flag has no root-program attachment. | -| `json` | `formatJson`, `formatNdjson` | Stable JSON / newline-delimited JSON formatting for stdout. | -| `markdown` (subpath) | `preloadMarkdown`, `renderMarkdown`, `TerminalRendererOptions` | Lazy-init terminal markdown renderer. **Requires** `marked` and `marked-terminal-renderer` as peer-deps — install only if your CLI uses this subpath. | -| `options` | `ViewOptions` | Type contract for `{ json?, ndjson? }` per-command options that machine-output gates derive from. | -| `spinner` | `createSpinner` | Loading spinner factory wrapping `yocto-spinner` with disable gates. | -| `terminal` | `isCI`, `isStderrTTY`, `isStdinTTY`, `isStdoutTTY` | TTY / CI detection helpers. | -| `testing` (subpath) | `describeEmptyMachineOutput`, `createTestProgram`, `captureConsole`, `captureStream`, `buildTokenStore`, `buildSingleEntryStore`, `ingenEntries`, `alanGrant` / `ellieSattler` / `ianMalcolm`, `TestAccount` / `StoreEntry` / `TokenStoreHarness` / `MatchAccount` types | Vitest helpers + fixtures reusable by consuming CLIs: a parametrised empty-state suite (`--json` / `--ndjson` / human modes); a Commander test-program builder (`createTestProgram`; the whole subpath **requires** `commander` since the barrel re-exports it); console / stdout-stderr spies that silence + auto-restore (`captureConsole` / `captureStream`, call inside a test or `beforeEach`); and a canonical stateful in-memory `TokenStore` mock plus shared account fixtures (`buildTokenStore` / `buildSingleEntryStore`) modelling `createKeyringTokenStore`'s default-selection contract — pass `matchAccount` to mirror a consumer's own ref-matching (numeric-id / case-insensitive label). | +| Module | Key exports | Purpose | +| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `auth` (subpath) | `attachLoginCommand`, `attachLogoutCommand`, `attachStatusCommand`, `attachTokenViewCommand`, `attachRefreshTokenViewCommand`, `attachAccountListCommand`, `attachAccountUseCommand`, `attachAccountCurrentCommand`, `attachAccountRemoveCommand`, `runOAuthFlow`, `refreshAccessToken`, `createPkceProvider`, `createDcrProvider`, `createSecureStore`, `createKeyringTokenStore`, `migrateLegacyAuth`, `persistBundle`, `bundleFromExchange`, PKCE helpers, `AuthProvider` / `TokenStore` / `TokenBundle` / `ActiveBundleSnapshot` / `RefreshInput` / `AccountRef` / `ClearedAccount` / `SecureStore` / `UserRecordStore` types, `AttachLogoutRevokeContext` / `AttachAccountListContext` / `AttachAccountCurrentContext` / `AttachAccountRemoveContext` | OAuth runtime plus the Commander attachers for ` [auth] login` / `logout` / `status` / `token` / `refresh-token view` and ` account list` / `use` / `current` / `remove`. `attachLogoutCommand` accepts an optional `revokeToken` hook for best-effort server-side token revocation. Ships the standard public-client PKCE flow (`createPkceProvider`), the RFC 7591 Dynamic Client Registration flow (`createDcrProvider`, with optional RFC 8707 resource indicators, refresh-token support, and `loadClient`/`saveClient` client caching), a thin cross-platform OS-keyring wrapper (`createSecureStore`), and a multi-account keyring-backed `TokenStore` (`createKeyringTokenStore`) that stores secrets in the OS credential manager and degrades to plaintext in the consumer's config when the keyring is unavailable (WSL/headless Linux/containers). The store contract supports an optional `setBundle(account, bundle)` write method (required on `KeyringTokenStore`) so consumers that need refresh-token persistence can opt in via `TokenBundle`; `active()` stays narrow (access token + account only) so callers that don't need refresh state don't pay extra keyring IPC. `AuthProvider` and `TokenStore` remain the escape hatches for fully bespoke backends (device code, magic-link, …). `logout` / `status` / `token` / `refresh-token view` always attach `--user ` and thread the parsed ref to the matching store read (`store.active(ref)`, `store.activeBundle(ref)`, or `store.clear(ref)`). `commander` (when using the attachers), `open` (browser launch), `@napi-rs/keyring` (when using `createSecureStore` or the keyring `TokenStore`), and `oauth4webapi` (when a consumer opts into silent refresh or uses `createDcrProvider`) are optional peer/optional deps. | +| `commands` (subpath) | `registerChangelogCommand`, `registerUpdateCommand` (+ semver helpers) | Commander wiring for cli-core's standard commands (e.g. ` changelog`, ` update`, ` update switch`). **Requires** `commander` as an optional peer-dep. | +| `config` | `getConfigPath`, `readConfig`, `readConfigStrict`, `writeConfig`, `updateConfig`, `CoreConfig`, `UpdateChannel` | Read / write a per-CLI JSON config file with typed error codes; `CoreConfig` is the shape of fields cli-core itself owns (extend it for per-CLI fields). | +| `empty` | `printEmpty` | Print an empty-state message gated on `--json` / `--ndjson` so machine consumers never see human strings on stdout. | +| `errors` | `CliError` | Typed CLI error class with `code` and exit-code mapping. | +| `global-args` | `parseGlobalArgs`, `stripUserFlag`, `createGlobalArgsStore`, `createAccessibleGate`, `createSpinnerGate`, `getProgressJsonlPath`, `isProgressJsonlEnabled` | Parse well-known global flags (`--json`, `--ndjson`, `--quiet`, `--verbose`, `--accessible`, `--no-spinner`, `--progress-jsonl`, `--user `) and derive predicates from them. `stripUserFlag` removes `--user` tokens from argv so the cleaned array can be forwarded to Commander when the flag has no root-program attachment. | +| `json` | `formatJson`, `formatNdjson` | Stable JSON / newline-delimited JSON formatting for stdout. | +| `markdown` (subpath) | `preloadMarkdown`, `renderMarkdown`, `TerminalRendererOptions` | Lazy-init terminal markdown renderer. **Requires** `marked` and `marked-terminal-renderer` as peer-deps — install only if your CLI uses this subpath. | +| `options` | `ViewOptions` | Type contract for `{ json?, ndjson? }` per-command options that machine-output gates derive from. | +| `spinner` | `createSpinner` | Loading spinner factory wrapping `yocto-spinner` with disable gates. | +| `terminal` | `isCI`, `isStderrTTY`, `isStdinTTY`, `isStdoutTTY` | TTY / CI detection helpers. | +| `testing` (subpath) | `describeEmptyMachineOutput`, `createTestProgram`, `captureConsole`, `captureStream`, `buildTokenStore`, `buildSingleEntryStore`, `ingenEntries`, `alanGrant` / `ellieSattler` / `ianMalcolm`, `TestAccount` / `StoreEntry` / `TokenStoreHarness` / `MatchAccount` types | Vitest helpers + fixtures reusable by consuming CLIs: a parametrised empty-state suite (`--json` / `--ndjson` / human modes); a Commander test-program builder (`createTestProgram`; the whole subpath **requires** `commander` since the barrel re-exports it); console / stdout-stderr spies that silence + auto-restore (`captureConsole` / `captureStream`, call inside a test or `beforeEach`); and a canonical stateful in-memory `TokenStore` mock plus shared account fixtures (`buildTokenStore` / `buildSingleEntryStore`) modelling `createKeyringTokenStore`'s default-selection contract — pass `matchAccount` to mirror a consumer's own ref-matching (numeric-id / case-insensitive label). | ## Usage @@ -254,13 +254,14 @@ The DCR-issued `client_id` (and `client_secret`, if returned) are stashed in the Both `createPkceProvider` and `createDcrProvider` accept an optional `errorHints: string[]` that is prepended to every `CliError` they throw. Use it for CLI-specific remediation that should accompany every auth failure (e.g. `['Try again: tw auth login', 'Or set TWIST_API_TOKEN environment variable']`). Server-returned response bodies (for non-2xx replies) are appended after the user hints so the actionable hint stays at the top. -#### Sibling attachers (`logout` / `status` / `token`) +#### Sibling attachers (`logout` / `status` / `token` / `refresh-token view`) -The same registrar shape covers the other three auth subcommands. Each returns the new `Command` for chaining and shares the same `TokenStore` instance. +The same registrar shape covers the other auth subcommands. Each returns the new `Command` for chaining and shares the same `TokenStore` instance. ```ts import { attachLogoutCommand, + attachRefreshTokenViewCommand, attachStatusCommand, attachTokenViewCommand, } from '@doist/cli-core/auth' @@ -296,6 +297,10 @@ attachTokenViewCommand(auth, { store, envVarName: 'TODOIST_API_TOKEN', // refuse to print when the env var is populated }) + +attachRefreshTokenViewCommand(auth, { + store, +}) ``` `attachLogoutCommand` snapshots `store.active(ref)` when either `--user ` is supplied or one of the consumer hooks (`revokeToken` / `onCleared`) needs the prior account, calls `store.clear(ref)`, awaits `revokeToken({ token, account, ref, view, flags })` for best-effort server-side revocation, emits `✓ Logged out` (human) or `{ "ok": true }` (`--json`, silent under `--ndjson`), and finally fires `onCleared({ account, ref, view, flags })`. `ref` is the parsed `--user` argument (or `undefined`) so consumers can distinguish "nothing was stored" (`account: null`, `ref: undefined`) from "cleared an unreadable record by ref" (`account: null`, `ref: "me"`). `revokeToken` failures are always swallowed; the pre-flight snapshot's error contract is covered in the `--user ` section below. The exported `AttachLogoutRevokeContext` is the ctx type for typing standalone revoke implementations. @@ -306,6 +311,8 @@ Both attachers strip the standard `--json` / `--ndjson` / `--user` registrar fla `attachTokenViewCommand` writes the bare stored token to stdout (no envelope, pipe-safe) and appends a trailing newline only when stdout is a TTY. When `envVarName` is set and the env var is populated, it throws `CliError('TOKEN_FROM_ENV', …)` to avoid disclosing a token the CLI did not manage. Defaults to subcommand name `token`; pass `name: 'view'` to nest under an existing `token` group. +`attachRefreshTokenViewCommand` wires `refresh-token view`, reads `store.activeBundle(ref)`, and writes the bare stored refresh token to stdout with the same pipe-safe newline behavior. It throws `CliError('AUTH_REFRESH_UNAVAILABLE', …)` when the store does not implement `activeBundle` or the matched credential has no refresh token. Pass `groupName` / `name` to customize the parent and child command names. + #### Account selection (`account list` / `use` / `current` / `remove`) For multi-account CLIs, `attachAccountListCommand`, `attachAccountUseCommand`, `attachAccountCurrentCommand`, and `attachAccountRemoveCommand` wire `account list`, `account use `, `account current`, and `account remove ` against the `TokenStore` contract (`list()` / `setDefault()` / `clear()` / `active()` / the optional token-free `activeAccount()`). Attach them to an `account` parent command — the same registrar shape as the auth siblings. @@ -559,7 +566,7 @@ The helper is best-effort throughout: any failure (offline keyring, network erro #### `--user ` and multi-user wiring -The three account-touching attachers (`attachLogoutCommand` / `attachStatusCommand` / `attachTokenViewCommand`) always attach `--user ` on their subcommand. `attachLogoutCommand` threads the parsed ref to both `store.active(ref)` and `store.clear(ref)`; `attachStatusCommand` and `attachTokenViewCommand` only call `store.active(ref)`. When `--user` is supplied but `store.active(ref)` returns `null`, each attacher throws `CliError('ACCOUNT_NOT_FOUND', …)` so the user sees a typed miss rather than `NOT_AUTHENTICATED` or a silent `✓ Logged out`. Single-user stores returning `null` for a non-matching ref is the supported way to feed this guard. +The account-touching attachers (`attachLogoutCommand` / `attachStatusCommand` / `attachTokenViewCommand` / `attachRefreshTokenViewCommand`) always attach `--user ` on their subcommand. `attachLogoutCommand` threads the parsed ref to both `store.active(ref)` and `store.clear(ref)`; `attachStatusCommand` and `attachTokenViewCommand` call `store.active(ref)`; `attachRefreshTokenViewCommand` calls `store.activeBundle(ref)`. When `--user` is supplied but the relevant read returns `null`, each attacher throws `CliError('ACCOUNT_NOT_FOUND', …)` so the user sees a typed miss rather than `NOT_AUTHENTICATED` or a silent `✓ Logged out`. Single-user stores returning `null` for a non-matching ref is the supported way to feed this guard. For pre-subcommand `--user` (` --user alice some-cmd`) that should apply to non-auth commands too, parse it globally and strip from argv before handing to Commander: @@ -578,9 +585,9 @@ await program.parseAsync([ Account-selection resolvers (env > `--user` > default > single-only > error), `account list`, and `account use` subcommands stay per-CLI for now — cli-core ships only the contract until at least one consumer has shipped these end-to-end. -`ACCOUNT_NOT_FOUND` is thrown by the account-touching attachers when `--user ` was supplied but `store.active(ref)` returned `null`. `NO_ACCOUNT_SELECTED` is reserved for consumer-thrown resolver failures (multiple accounts stored, no default, no `--user`); cli-core does not throw it itself. +`ACCOUNT_NOT_FOUND` is thrown by the account-touching attachers when `--user ` was supplied but the relevant store read returned `null`. `NO_ACCOUNT_SELECTED` is reserved for consumer-thrown resolver failures (multiple accounts stored, no default, no `--user`); cli-core does not throw it itself. -A `TokenStore` MAY throw `CliError('AUTH_STORE_READ_FAILED', …)` from `active(ref)` when a matching record exists but the token itself can't be read (e.g. an OS keyring backing the store is offline). `attachLogoutCommand` catches this specific code on the explicit-ref path and proceeds with `clear(ref)` — local logout doesn't need the token, and the `revokeToken` hook is skipped because there's no token to send. Every other error from `active(ref)` (notably `ACCOUNT_NOT_FOUND` from a genuine ref miss, plus any consumer-thrown code) still propagates so a real miss isn't masked. Without `--user`, the logout pre-flight swallows any snapshot read failure so the local clear always runs. `attachStatusCommand` and `attachTokenViewCommand` propagate `AUTH_STORE_READ_FAILED` since they have no way to render or print without the token. +A `TokenStore` MAY throw `CliError('AUTH_STORE_READ_FAILED', …)` from `active(ref)` or `activeBundle(ref)` when a matching record exists but the token itself can't be read (e.g. an OS keyring backing the store is offline). `attachLogoutCommand` catches this specific code on the explicit-ref path and proceeds with `clear(ref)` — local logout doesn't need the token, and the `revokeToken` hook is skipped because there's no token to send. Every other error from `active(ref)` (notably `ACCOUNT_NOT_FOUND` from a genuine ref miss, plus any consumer-thrown code) still propagates so a real miss isn't masked. Without `--user`, the logout pre-flight swallows any snapshot read failure so the local clear always runs. `attachStatusCommand`, `attachTokenViewCommand`, and `attachRefreshTokenViewCommand` propagate `AUTH_STORE_READ_FAILED` since they have no way to render or print without the token. #### Custom `AuthProvider` (non-PKCE, non-DCR flows) @@ -623,18 +630,19 @@ The `handshake` is shared mutable state across hooks. `runOAuthFlow` folds the r Every failure in this subpath surfaces as a `CliError`: -| Code | Cause | -| ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `AUTH_OAUTH_FAILED` | Provider returned `?error=...`, the flow was aborted via `signal`, or the callback server stopped before completion. | -| `AUTH_CALLBACK_TIMEOUT` | No valid callback within `timeoutMs` (default 3 minutes). | -| `AUTH_PORT_BIND_FAILED` | Could not bind any port in `[preferredPort, preferredPort + portFallbackCount]`, or `--callback-port` was out of range. | -| `AUTH_DCR_FAILED` | `createDcrProvider` registration failed (network error, non-`201`, non-JSON body, response missing `client_id`, or the `oauth4webapi` peer dep isn't installed). | -| `AUTH_TOKEN_EXCHANGE_FAILED` | Token endpoint network error, non-2xx response, non-JSON body, or missing `access_token`. | -| `AUTH_STORE_WRITE_FAILED` | `TokenStore.set` threw a non-`CliError`. (`CliError`s thrown from `set` propagate unchanged.) | -| `NOT_AUTHENTICATED` | `status` / `token` ran with an empty `TokenStore` (and no `onNotAuthenticated` callback for `status`). Default message: `'Not signed in.'`. | -| `TOKEN_FROM_ENV` | `attachTokenViewCommand` refused to print: `envVarName` was set and the env var is populated. | -| `NO_ACCOUNT_SELECTED` | Reserved for consumer-thrown resolver failures when multiple accounts are stored without a default and no `--user` was supplied. | -| `ACCOUNT_NOT_FOUND` | `logout` / `status` / `token` were invoked with `--user ` but `store.active(ref)` returned `null`. Also reserved for consumer resolvers when a ref doesn't match any stored account. | +| Code | Cause | +| ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `AUTH_OAUTH_FAILED` | Provider returned `?error=...`, the flow was aborted via `signal`, or the callback server stopped before completion. | +| `AUTH_CALLBACK_TIMEOUT` | No valid callback within `timeoutMs` (default 3 minutes). | +| `AUTH_PORT_BIND_FAILED` | Could not bind any port in `[preferredPort, preferredPort + portFallbackCount]`, or `--callback-port` was out of range. | +| `AUTH_DCR_FAILED` | `createDcrProvider` registration failed (network error, non-`201`, non-JSON body, response missing `client_id`, or the `oauth4webapi` peer dep isn't installed). | +| `AUTH_TOKEN_EXCHANGE_FAILED` | Token endpoint network error, non-2xx response, non-JSON body, or missing `access_token`. | +| `AUTH_STORE_WRITE_FAILED` | `TokenStore.set` threw a non-`CliError`. (`CliError`s thrown from `set` propagate unchanged.) | +| `AUTH_REFRESH_UNAVAILABLE` | `refreshAccessToken` or `refresh-token view` could not use a stored refresh token: no refresh token, missing bundle support, no provider hook, or missing refresh peer dependency. | +| `NOT_AUTHENTICATED` | `status` / `token` / `refresh-token view` ran with an empty `TokenStore` (and no `onNotAuthenticated` callback for `status`). Default message: `'Not signed in.'`. | +| `TOKEN_FROM_ENV` | `attachTokenViewCommand` refused to print: `envVarName` was set and the env var is populated. | +| `NO_ACCOUNT_SELECTED` | Reserved for consumer-thrown resolver failures when multiple accounts are stored without a default and no `--user` was supplied. | +| `ACCOUNT_NOT_FOUND` | `logout` / `status` / `token` / `refresh-token view` were invoked with `--user ` but the relevant store read returned `null`. Also reserved for consumer resolvers when a ref doesn't match any stored account. | The consumer's top-level error handler formats and exits. diff --git a/src/auth/index.ts b/src/auth/index.ts index 61eada3..f83650d 100644 --- a/src/auth/index.ts +++ b/src/auth/index.ts @@ -26,6 +26,8 @@ export type { } from './logout.js' export { attachStatusCommand } from './status.js' export type { AttachStatusCommandOptions, AttachStatusContext } from './status.js' +export { attachRefreshTokenViewCommand } from './refresh-token-view.js' +export type { AttachRefreshTokenViewCommandOptions } from './refresh-token-view.js' export { attachTokenViewCommand } from './token-view.js' export type { AttachTokenViewCommandOptions } from './token-view.js' export { diff --git a/src/auth/refresh-token-view.test.ts b/src/auth/refresh-token-view.test.ts new file mode 100644 index 0000000..98a3c45 --- /dev/null +++ b/src/auth/refresh-token-view.test.ts @@ -0,0 +1,189 @@ +import { describe, expect, it } from 'vitest' + +import { CliError } from '../errors.js' +import { buildProgram, installCapturedStream } from '../test-support/cli-harness.js' +import { + type TestAccount as Account, + type TokenStoreHarness, + alanGrant, + buildTokenStore, +} from '../testing/accounts.js' +import { attachRefreshTokenViewCommand } from './refresh-token-view.js' +import type { TokenBundle } from './types.js' + +const account = alanGrant +const defaultBundle: TokenBundle = { + accessToken: 'tok-xyz', + refreshToken: 'refresh-xyz', +} + +function buildStore( + bundle: TokenBundle | null = defaultBundle, + overrides?: Parameters>[0]['overrides'], +): TokenStoreHarness { + return buildTokenStore({ + entries: bundle ? [{ account, isDefault: true, bundle }] : [], + overrides, + }) +} + +describe('attachRefreshTokenViewCommand', () => { + const stdoutSpy = installCapturedStream() + + it('writes exactly the bare refresh token (no trailing newline) when stdout is not a TTY', async () => { + const { program, parent: auth } = buildProgram('auth') + const { store } = buildStore() + attachRefreshTokenViewCommand(auth, { store }) + + const originalTTY = process.stdout.isTTY + Object.defineProperty(process.stdout, 'isTTY', { value: false, configurable: true }) + try { + await program.parseAsync(['node', 'cli', 'auth', 'refresh-token', 'view']) + } finally { + Object.defineProperty(process.stdout, 'isTTY', { + value: originalTTY, + configurable: true, + }) + } + + const emitted = stdoutSpy() + .mock.calls.map((call: unknown[]) => call[0]) + .join('') + expect(emitted).toBe('refresh-xyz') + expect(stdoutSpy()).toHaveBeenCalledTimes(1) + }) + + it('appends a newline only when stdout is a TTY', async () => { + const { program, parent: auth } = buildProgram('auth') + const { store } = buildStore() + attachRefreshTokenViewCommand(auth, { store }) + + const originalTTY = process.stdout.isTTY + Object.defineProperty(process.stdout, 'isTTY', { value: true, configurable: true }) + try { + await program.parseAsync(['node', 'cli', 'auth', 'refresh-token', 'view']) + } finally { + Object.defineProperty(process.stdout, 'isTTY', { + value: originalTTY, + configurable: true, + }) + } + + const emitted = stdoutSpy() + .mock.calls.map((call: unknown[]) => call[0]) + .join('') + expect(emitted).toBe('refresh-xyz\n') + }) + + it('throws CliError(NOT_AUTHENTICATED) when the store is empty', async () => { + const { program, parent: auth } = buildProgram('auth') + const { store } = buildStore(null) + attachRefreshTokenViewCommand(auth, { store }) + + await expect( + program.parseAsync(['node', 'cli', 'auth', 'refresh-token', 'view']), + ).rejects.toMatchObject({ + constructor: CliError, + code: 'NOT_AUTHENTICATED', + }) + expect(stdoutSpy()).not.toHaveBeenCalled() + }) + + it('throws AUTH_REFRESH_UNAVAILABLE when the store cannot read bundles', async () => { + const { program, parent: auth } = buildProgram('auth') + const { store, activeSpy } = buildStore(defaultBundle, { activeBundle: undefined }) + attachRefreshTokenViewCommand(auth, { store }) + + await expect( + program.parseAsync(['node', 'cli', 'auth', 'refresh-token', 'view']), + ).rejects.toMatchObject({ + constructor: CliError, + code: 'AUTH_REFRESH_UNAVAILABLE', + }) + expect(activeSpy).not.toHaveBeenCalled() + expect(stdoutSpy()).not.toHaveBeenCalled() + }) + + it('throws AUTH_REFRESH_UNAVAILABLE when the active bundle has no refresh token', async () => { + const { program, parent: auth } = buildProgram('auth') + const { store } = buildStore({ accessToken: 'tok-xyz' }) + attachRefreshTokenViewCommand(auth, { store }) + + await expect( + program.parseAsync(['node', 'cli', 'auth', 'refresh-token', 'view']), + ).rejects.toMatchObject({ + constructor: CliError, + code: 'AUTH_REFRESH_UNAVAILABLE', + }) + expect(stdoutSpy()).not.toHaveBeenCalled() + }) + + it('registers under custom group and view names when supplied', async () => { + const { program, parent: auth } = buildProgram('auth') + const { store } = buildStore() + const cmd = attachRefreshTokenViewCommand(auth, { + store, + groupName: 'refresh', + name: 'show', + }) + + expect(cmd.name()).toBe('show') + + await program.parseAsync(['node', 'cli', 'auth', 'refresh', 'show']) + expect(stdoutSpy()).toHaveBeenCalledWith('refresh-xyz') + }) + + it('returns the view Command so the consumer can chain', () => { + const { parent: auth } = buildProgram('auth') + const { store } = buildStore() + const cmd = attachRefreshTokenViewCommand(auth, { store }) + + expect(cmd.name()).toBe('view') + }) + + it('threads --user ref to store.activeBundle(ref) and prints the matched refresh token', async () => { + const { program, parent: auth } = buildProgram('auth') + const { store, activeSpy } = buildStore() + attachRefreshTokenViewCommand(auth, { store }) + + await program.parseAsync([ + 'node', + 'cli', + 'auth', + 'refresh-token', + 'view', + '--user', + 'alan@ingen.com', + ]) + + expect(store.activeBundle).toHaveBeenCalledWith('alan@ingen.com') + expect(activeSpy).not.toHaveBeenCalled() + expect(stdoutSpy()).toHaveBeenCalledWith('refresh-xyz') + }) + + it('calls store.activeBundle(undefined) when --user is absent', async () => { + const { program, parent: auth } = buildProgram('auth') + const { store, activeSpy } = buildStore() + attachRefreshTokenViewCommand(auth, { store }) + + await program.parseAsync(['node', 'cli', 'auth', 'refresh-token', 'view']) + + expect(store.activeBundle).toHaveBeenCalledWith(undefined) + expect(activeSpy).not.toHaveBeenCalled() + expect(stdoutSpy()).toHaveBeenCalledWith('refresh-xyz') + }) + + it('throws ACCOUNT_NOT_FOUND when --user does not match a stored account', async () => { + const { program, parent: auth } = buildProgram('auth') + const { store } = buildStore(null) + attachRefreshTokenViewCommand(auth, { store }) + + await expect( + program.parseAsync(['node', 'cli', 'auth', 'refresh-token', 'view', '--user', 'ghost']), + ).rejects.toMatchObject({ + constructor: CliError, + code: 'ACCOUNT_NOT_FOUND', + }) + expect(stdoutSpy()).not.toHaveBeenCalled() + }) +}) diff --git a/src/auth/refresh-token-view.ts b/src/auth/refresh-token-view.ts new file mode 100644 index 0000000..bccdba8 --- /dev/null +++ b/src/auth/refresh-token-view.ts @@ -0,0 +1,60 @@ +import type { Command } from 'commander' +import { CliError } from '../errors.js' +import { isStdoutTTY } from '../terminal.js' +import type { AuthAccount, TokenStore } from './types.js' +import { accountNotFoundError, attachUserFlag, extractUserRef } from './user-flag.js' + +export type AttachRefreshTokenViewCommandOptions = { + store: TokenStore + /** Parent subcommand name. Defaults to `'refresh-token'`. */ + groupName?: string + groupDescription?: string + /** View subcommand name. Defaults to `'view'`. */ + name?: string + description?: string +} + +/** + * Attach a "print the saved refresh token" subcommand to `parent`. By default + * this creates ` refresh-token view`. Writes the bare refresh token to + * stdout with no envelope so the output is pipe-safe. Throws + * `CliError('AUTH_REFRESH_UNAVAILABLE', ...)` when the store cannot read full + * bundles or the matched credential has no refresh token. + */ +export function attachRefreshTokenViewCommand( + parent: Command, + options: AttachRefreshTokenViewCommandOptions, +): Command { + const group = parent + .command(options.groupName ?? 'refresh-token') + .description(options.groupDescription ?? 'Manage the saved refresh token') + const command = group + .command(options.name ?? 'view') + .description(options.description ?? 'Print the saved refresh token') + + return attachUserFlag(command).action(async (cmd: Record) => { + if (!options.store.activeBundle) { + throw new CliError( + 'AUTH_REFRESH_UNAVAILABLE', + 'TokenStore must implement activeBundle to view refresh tokens.', + ) + } + + const ref = extractUserRef(cmd) + const snapshot = await options.store.activeBundle(ref) + if (!snapshot) { + if (ref !== undefined) throw accountNotFoundError(ref) + throw new CliError('NOT_AUTHENTICATED', 'Not signed in.') + } + + if (!snapshot.bundle.refreshToken) { + throw new CliError( + 'AUTH_REFRESH_UNAVAILABLE', + 'Stored credential has no refresh token.', + ) + } + + process.stdout.write(snapshot.bundle.refreshToken) + if (isStdoutTTY()) process.stdout.write('\n') + }) +}