diff --git a/README.md b/README.md index c5ff5c5..b10350b 100644 --- a/README.md +++ b/README.md @@ -17,12 +17,13 @@ npm install @doist/cli-core | `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` / `CredentialStore` 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`, with optional RFC 8707 resource indicators and client ID metadata document client IDs), 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`) with strict system, explicit plaintext, and fallback storage policies. 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. | +| `empty` | `printEmpty` | Print an empty-state message gated on `--json` / `--ndjson` / `--ids-only` 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. | +| `global-args` | `parseGlobalArgs`, `stripUserFlag`, `createGlobalArgsStore`, `createAccessibleGate`, `createSpinnerGate`, `getProgressJsonlPath`, `isProgressJsonlEnabled` | Parse well-known global flags (`--json`, `--ndjson`, `--ids-only`, `--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. | +| `ids` | `formatIds`, `outputIds` | Format or emit one stable string or numeric ID per line. Empty results stay silent; optional pagination notices go to stderr. | | `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. | +| `options` | `OUTPUT_MODES`, `OutputMode`, `ViewOptions`, `ListViewOptions`, `resolveOutputMode` | Canonical output-mode contracts; `ListViewOptions` adds `idsOnly?`, and the resolver rejects conflicting machine-output flags. | | `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). | diff --git a/src/commands/errors.ts b/src/commands/errors.ts index e5bfe92..a0b1c72 100644 --- a/src/commands/errors.ts +++ b/src/commands/errors.ts @@ -1,9 +1,10 @@ /** - * Error codes thrown by `@doist/cli-core/commands` registration helpers. Folded - * into the `CliErrorCode` aggregator in `../errors.ts` so consumers don't have - * to redeclare them in their own `TCode` union when catching. + * Error codes thrown by shared command and output-option helpers. Folded into + * the `CliErrorCode` aggregator in `../errors.ts` so consumers don't have to + * redeclare them in their own `TCode` union when catching. */ export type CommandErrorCode = + | 'CONFLICTING_OPTIONS' | 'INVALID_TYPE' | 'FILE_READ_ERROR' | 'INVALID_FLAGS' diff --git a/src/empty.test.ts b/src/empty.test.ts index b2c7dc8..76fce97 100644 --- a/src/empty.test.ts +++ b/src/empty.test.ts @@ -12,6 +12,7 @@ describeEmptyMachineOutput('printEmpty (contract via describeEmptyMachineOutput) run: async (extraArgs) => { printEmpty({ options: { + idsOnly: extraArgs.includes('--ids-only'), json: extraArgs.includes('--json'), ndjson: extraArgs.includes('--ndjson'), }, @@ -19,6 +20,7 @@ describeEmptyMachineOutput('printEmpty (contract via describeEmptyMachineOutput) }) }, humanMessage: HUMAN_MESSAGE, + idsOnly: true, }) describe('printEmpty (extras)', () => { @@ -46,8 +48,10 @@ describe('printEmpty (extras)', () => { writeSpy = undefined }) - it('prefers --json over --ndjson when both flags are set', () => { - printEmpty({ options: { json: true, ndjson: true }, message: 'unused' }) - expect(captured).toBe('[]\n') + it('rejects conflicting output modes', () => { + expect(() => + printEmpty({ options: { json: true, ndjson: true }, message: 'unused' }), + ).toThrow('Options --json, --ndjson are mutually exclusive.') + expect(captured).toBe('') }) }) diff --git a/src/empty.ts b/src/empty.ts index abf8c88..bf6fdd1 100644 --- a/src/empty.ts +++ b/src/empty.ts @@ -1,21 +1,29 @@ import { formatJson } from './json.js' -import type { ViewOptions } from './options.js' +import { type ListViewOptions, resolveOutputMode } from './options.js' /** * Gate the empty-state print on the active output mode: * --json → prints exactly `'[]'` * --ndjson → prints nothing (no stray newline; ndjson EOF = end of stream) + * --ids-only → prints nothing * neither → prints the human-readable message * * Use at every list/array empty-result branch so machine consumers never see - * human strings on stdout when they asked for `--json` / `--ndjson`. + * human strings on stdout when they asked for a machine-output mode. */ -export function printEmpty({ options, message }: { options: ViewOptions; message: string }): void { - if (options.json) { +export function printEmpty({ + options, + message, +}: { + options: ListViewOptions + message: string +}): void { + const outputMode = resolveOutputMode(options) + if (outputMode === 'json') { console.log(formatJson([])) return } - if (options.ndjson) { + if (outputMode === 'ndjson' || outputMode === 'ids-only') { return } console.log(message) diff --git a/src/global-args.test.ts b/src/global-args.test.ts index ff81928..a11f4c1 100644 --- a/src/global-args.test.ts +++ b/src/global-args.test.ts @@ -14,6 +14,7 @@ import { describe('parseGlobalArgs', () => { it('defaults every field to false/0', () => { expect(parseGlobalArgs([])).toEqual({ + idsOnly: false, json: false, ndjson: false, quiet: false, @@ -25,6 +26,7 @@ describe('parseGlobalArgs', () => { }) it.each([ + ['--ids-only', 'idsOnly', true], ['--json', 'json', true], ['--ndjson', 'ndjson', true], ['--quiet', 'quiet', true], @@ -266,6 +268,7 @@ describe('createSpinnerGate', () => { }) it.each([ + ['--ids-only'], ['--json'], ['--ndjson'], ['--no-spinner'], diff --git a/src/global-args.ts b/src/global-args.ts index d608544..6d359de 100644 --- a/src/global-args.ts +++ b/src/global-args.ts @@ -13,10 +13,10 @@ * twist's `--non-interactive`) can layer their own fields over `GlobalArgs`. */ -import type { ViewOptions } from './options.js' +import type { ListViewOptions } from './options.js' import { isCI } from './terminal.js' -export type GlobalArgs = Required> & { +export type GlobalArgs = Required> & { quiet: boolean verbose: 0 | 1 | 2 | 3 | 4 accessible: boolean @@ -77,6 +77,7 @@ export function parseGlobalArgs(argv?: string[]): GlobalArgs { const args = argv ?? process.argv.slice(2) const result: GlobalArgs = { + idsOnly: false, json: false, ndjson: false, quiet: false, @@ -91,7 +92,9 @@ export function parseGlobalArgs(argv?: string[]): GlobalArgs { if (arg === '--') break - if (arg === '--json') { + if (arg === '--ids-only') { + result.idsOnly = true + } else if (arg === '--json') { result.json = true } else if (arg === '--ndjson') { result.ndjson = true @@ -256,7 +259,7 @@ export type SpinnerGateOptions = { * Build a `shouldDisableSpinner` predicate. Disables on: * - env var equals `'false'` * - `isCI()` - * - any of `--json`, `--ndjson`, `--no-spinner`, `--progress-jsonl`, `--verbose` + * - any of `--json`, `--ndjson`, `--ids-only`, `--no-spinner`, `--progress-jsonl`, `--verbose` * - `extraTriggers?.()` returning true * * Pair with `createSpinner({ isDisabled })` from `./spinner.js`. @@ -288,6 +291,7 @@ export function createSpinnerGate(opts: SpinnerGateOptions): () => boolean { if ( args.json || args.ndjson || + args.idsOnly || args.noSpinner || isProgressJsonlEnabled(args) || args.verbose > 0 diff --git a/src/ids.test.ts b/src/ids.test.ts new file mode 100644 index 0000000..a642962 --- /dev/null +++ b/src/ids.test.ts @@ -0,0 +1,43 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { formatIds, outputIds } from './ids.js' + +describe('formatIds', () => { + it('formats string and numeric IDs one per line', () => { + expect(formatIds([{ id: 'task-1' }, { id: 42 }], (item) => item.id)).toBe('task-1\n42') + }) + + it('returns an empty string for no results', () => { + expect(formatIds([], (item: { id: string }) => item.id)).toBe('') + }) +}) + +describe('outputIds', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('writes IDs to stdout in one block', () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => {}) + + outputIds([{ id: 'a' }, { id: 'b' }], (item) => item.id) + + expect(log).toHaveBeenCalledOnce() + expect(log).toHaveBeenCalledWith('a\nb') + }) + + it('writes nothing to stdout for no results', () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => {}) + + outputIds([], (item: { id: string }) => item.id) + + expect(log).not.toHaveBeenCalled() + }) + + it('writes pagination notices to stderr', () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + + outputIds([], (item: { id: string }) => item.id, 'More results exist.') + + expect(error).toHaveBeenCalledWith('More results exist.') + }) +}) diff --git a/src/ids.ts b/src/ids.ts new file mode 100644 index 0000000..4b24e65 --- /dev/null +++ b/src/ids.ts @@ -0,0 +1,18 @@ +/** Format stable IDs as one value per line, with no trailing newline. */ +export function formatIds(items: readonly T[], getId: (item: T) => string | number): string { + return items.map((item) => String(getId(item))).join('\n') +} + +/** + * Write stable IDs to stdout and an optional pagination notice to stderr. + * Empty results write nothing to stdout. + */ +export function outputIds( + items: readonly T[], + getId: (item: T) => string | number, + paginationNotice = '', +): void { + const output = formatIds(items, getId) + if (output) console.log(output) + if (paginationNotice) console.error(paginationNotice) +} diff --git a/src/index.ts b/src/index.ts index 02a5b0a..4b16eb1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -34,8 +34,9 @@ export type { SpinnerGateOptions, } from './global-args.js' export { formatJson, formatNdjson } from './json.js' -export { emitView } from './options.js' -export type { ViewOptions } from './options.js' +export { formatIds, outputIds } from './ids.js' +export { emitView, OUTPUT_MODES, resolveOutputMode } from './options.js' +export type { ListViewOptions, OutputMode, ViewOptions } from './options.js' export { createSpinner } from './spinner.js' export type { LoadingSpinner, diff --git a/src/options.test.ts b/src/options.test.ts index 11126b7..dadb229 100644 --- a/src/options.test.ts +++ b/src/options.test.ts @@ -1,5 +1,5 @@ -import { describe, expectTypeOf, it } from 'vitest' -import type { ViewOptions } from './options.js' +import { describe, expect, expectTypeOf, it } from 'vitest' +import { type ListViewOptions, type ViewOptions, resolveOutputMode } from './options.js' describe('ViewOptions', () => { it('declares json and ndjson as optional booleans', () => { @@ -19,3 +19,38 @@ describe('ViewOptions', () => { expectTypeOf(narrow).toMatchTypeOf() }) }) + +describe('ListViewOptions', () => { + it('adds the optional IDs-only flag to the canonical view options', () => { + const opts: ListViewOptions = { idsOnly: true, json: false, ndjson: false } + expectTypeOf(opts).toMatchTypeOf<{ + idsOnly?: boolean + json?: boolean + ndjson?: boolean + }>() + }) +}) + +describe('resolveOutputMode', () => { + it.each([ + [{}, 'human'], + [{ json: true }, 'json'], + [{ ndjson: true }, 'ndjson'], + [{ idsOnly: true }, 'ids-only'], + ] as const)('resolves %o to %s', (options, expected) => { + expect(resolveOutputMode(options)).toBe(expected) + }) + + it.each([ + [{ json: true, ndjson: true }, 'Options --json, --ndjson are mutually exclusive.'], + [{ json: true, idsOnly: true }, 'Options --json, --ids-only are mutually exclusive.'], + [{ ndjson: true, idsOnly: true }, 'Options --ndjson, --ids-only are mutually exclusive.'], + ])('rejects conflicting output flags in %o', (options, message) => { + try { + resolveOutputMode(options) + expect.fail('Expected conflicting output flags to throw') + } catch (error) { + expect(error).toMatchObject({ code: 'CONFLICTING_OPTIONS', message }) + } + }) +}) diff --git a/src/options.ts b/src/options.ts index aa64455..f57d63f 100644 --- a/src/options.ts +++ b/src/options.ts @@ -1,5 +1,12 @@ +import { CliError } from './errors.js' import { formatJson, formatNdjson } from './json.js' +/** Canonical output modes shared by Doist CLIs. */ +export const OUTPUT_MODES = ['human', 'json', 'ndjson', 'ids-only'] as const + +/** A canonical output mode shared by Doist CLIs. */ +export type OutputMode = (typeof OUTPUT_MODES)[number] + /** * Shared shape for commands that respect the canonical machine-output flags. * Seeded narrow so the type only declares what cli-core helpers actually read @@ -14,6 +21,33 @@ export type ViewOptions = { ndjson?: boolean } +/** Shared shape for list commands that can emit only stable result IDs. */ +export type ListViewOptions = ViewOptions & { + idsOnly?: boolean +} + +const OUTPUT_FLAGS: ReadonlyArray<{ + enabled: (options: ListViewOptions) => boolean + flag: string + mode: Exclude +}> = [ + { enabled: (options) => Boolean(options.json), flag: '--json', mode: 'json' }, + { enabled: (options) => Boolean(options.ndjson), flag: '--ndjson', mode: 'ndjson' }, + { enabled: (options) => Boolean(options.idsOnly), flag: '--ids-only', mode: 'ids-only' }, +] + +/** Resolve the selected canonical output mode and reject conflicting flags. */ +export function resolveOutputMode(options: ListViewOptions): OutputMode { + const selected = OUTPUT_FLAGS.filter(({ enabled }) => enabled(options)) + if (selected.length > 1) { + throw new CliError( + 'CONFLICTING_OPTIONS', + `Options ${selected.map(({ flag }) => flag).join(', ')} are mutually exclusive.`, + ) + } + return selected[0]?.mode ?? 'human' +} + /** * `--json` / `--ndjson` / human emitter. `humanLines` is a thunk so the * human-mode strings (chalk colouring, conditional formatting) are never diff --git a/src/testing/empty-output.ts b/src/testing/empty-output.ts index 63068de..daeae5a 100644 --- a/src/testing/empty-output.ts +++ b/src/testing/empty-output.ts @@ -4,12 +4,15 @@ type EmptyOutputConfig = { setup: () => void | Promise run: (extraArgs: string[]) => Promise humanMessage: string | RegExp + /** Also assert the optional `--ids-only` empty-output contract. */ + idsOnly?: boolean } /** * Asserts the standard `printEmpty` contract for a command: * --json → writes exactly `'[]\n'` to stdout * --ndjson → writes nothing to stdout (no stray newline) + * --ids-only → writes nothing when `idsOnly` is enabled in the config * neither → writes exactly the human message + `\n` to stdout * * Captures bytes from both `console.log` (which vitest intercepts before @@ -56,6 +59,13 @@ export function describeEmptyMachineOutput(label: string, config: EmptyOutputCon expect(captured).toBe('') }) + if (config.idsOnly) { + it('writes nothing to stdout for --ids-only', async () => { + await config.run(['--ids-only']) + expect(captured).toBe('') + }) + } + it('writes exactly the human message to stdout when no machine flag is set', async () => { await config.run([]) if (typeof config.humanMessage === 'string') {