From 79542d00df23d3b7ee6d03e72d972ba98dc3353e Mon Sep 17 00:00:00 2001 From: Scott Lovegrove Date: Fri, 22 May 2026 22:44:38 +0100 Subject: [PATCH 1/3] feat(auth): add account list/use Commander attachers Adds attachAccountListCommand + attachAccountUseCommand under the /auth subpath, giving consumers `account list` / `account use ` for multi-account management. They consume TokenStore.list() / setDefault(), which were required since 0.12.0 but had no consumer inside cli-core. Mirrors the existing attachStatusCommand / attachLogoutCommand patterns: optional renderText/renderJson overrides with sensible defaults, json envelope { accounts, default } with ndjson one-object-per-line streaming, and the logout-style success envelope (ndjson silent) for `use`. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/auth/account.test.ts | 346 +++++++++++++++++++++++++++++++++++++++ src/auth/account.ts | 149 +++++++++++++++++ src/auth/index.ts | 6 + 3 files changed, 501 insertions(+) create mode 100644 src/auth/account.test.ts create mode 100644 src/auth/account.ts diff --git a/src/auth/account.test.ts b/src/auth/account.test.ts new file mode 100644 index 0000000..1c5a2c6 --- /dev/null +++ b/src/auth/account.test.ts @@ -0,0 +1,346 @@ +import { Command } from 'commander' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { CliError } from '../errors.js' +import { formatJson, formatNdjson } from '../json.js' +import { + type AttachAccountListCommandOptions, + type AttachAccountUseCommandOptions, + attachAccountListCommand, + attachAccountUseCommand, +} from './account.js' +import type { TokenStore } from './types.js' + +type Account = { id: string; label?: string; email: string } + +const a1: Account = { id: '1', label: 'Alice', email: 'alice@b' } +const a2: Account = { id: '2', label: 'Bob', email: 'bob@b' } + +type Entry = { account: Account; isDefault: boolean } +const bothAccounts: Entry[] = [ + { account: a1, isDefault: true }, + { account: a2, isDefault: false }, +] + +function buildStore(entries: Entry[] = bothAccounts): { + store: TokenStore + listSpy: ReturnType + setDefaultSpy: ReturnType +} { + const listSpy = vi.fn(async () => entries) + const setDefaultSpy = vi.fn(async () => {}) + const store: TokenStore = { + active: vi.fn(async () => null), + set: vi.fn(), + clear: vi.fn(), + list: listSpy, + setDefault: setDefaultSpy, + } + return { store, listSpy, setDefaultSpy } +} + +function buildList( + overrides: Partial> = {}, + store?: TokenStore, +): { program: Command; command: Command } { + const resolvedStore = store ?? buildStore().store + const program = new Command() + program.exitOverride() + const account = program.command('account') + const command = attachAccountListCommand(account, { + store: resolvedStore, + ...overrides, + }) + return { program, command } +} + +function buildUse( + overrides: Partial> = {}, + store?: TokenStore, +): { program: Command; command: Command } { + const resolvedStore = store ?? buildStore().store + const program = new Command() + program.exitOverride() + const account = program.command('account') + const command = attachAccountUseCommand(account, { + store: resolvedStore, + ...overrides, + }) + return { program, command } +} + +describe('attachAccountListCommand', () => { + let logSpy: ReturnType + + beforeEach(() => { + logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + }) + + afterEach(() => { + logSpy.mockRestore() + }) + + it('renders default human lines with a (default) marker only on the default entry', async () => { + const { program } = buildList() + + await program.parseAsync(['node', 'cli', 'account', 'list']) + + const emitted = logSpy.mock.calls.map((call: unknown[]) => call[0]) + expect(emitted).toEqual(['Alice (id:1) (default)', 'Bob (id:2)']) + }) + + it('emits a custom renderText string', async () => { + const renderText = vi.fn(() => 'one line') + const { program } = buildList({ renderText }) + + await program.parseAsync(['node', 'cli', 'account', 'list']) + + expect(renderText).toHaveBeenCalledWith({ + accounts: bothAccounts, + default: '1', + view: { json: false, ndjson: false }, + flags: {}, + }) + expect(logSpy).toHaveBeenCalledWith('one line') + }) + + it('emits each line when renderText returns an array', async () => { + const renderText = vi.fn(() => ['line 1', 'line 2', 'line 3']) + const { program } = buildList({ renderText }) + + await program.parseAsync(['node', 'cli', 'account', 'list']) + + const emitted = logSpy.mock.calls.map((call: unknown[]) => call[0]).join('\n') + expect(emitted).toBe('line 1\nline 2\nline 3') + }) + + it('emits the default envelope under --json', async () => { + const { program } = buildList() + + await program.parseAsync(['node', 'cli', 'account', 'list', '--json']) + + expect(logSpy).toHaveBeenCalledWith( + formatJson({ + accounts: [ + { account: a1, isDefault: true }, + { account: a2, isDefault: false }, + ], + default: '1', + }), + ) + }) + + it('invokes renderJson per account under --json and keeps the envelope default', async () => { + const renderJson = vi.fn( + ({ account, isDefault }: { account: Account; isDefault: boolean }) => ({ + name: account.label, + isDefault, + }), + ) + const { program } = buildList({ renderJson }) + + await program.parseAsync(['node', 'cli', 'account', 'list', '--json']) + + expect(renderJson).toHaveBeenCalledTimes(2) + expect(renderJson).toHaveBeenNthCalledWith(1, { account: a1, isDefault: true, flags: {} }) + expect(renderJson).toHaveBeenNthCalledWith(2, { account: a2, isDefault: false, flags: {} }) + expect(logSpy).toHaveBeenCalledWith( + formatJson({ + accounts: [ + { name: 'Alice', isDefault: true }, + { name: 'Bob', isDefault: false }, + ], + default: '1', + }), + ) + }) + + it('streams one object per account under --ndjson with no envelope', async () => { + const { program } = buildList() + + await program.parseAsync(['node', 'cli', 'account', 'list', '--ndjson']) + + expect(logSpy).toHaveBeenCalledWith( + formatNdjson([ + { account: a1, isDefault: true }, + { account: a2, isDefault: false }, + ]), + ) + }) + + it('does not invoke renderJson in human mode', async () => { + const renderJson = vi.fn(() => ({ x: 1 })) + const { program } = buildList({ renderJson }) + + await program.parseAsync(['node', 'cli', 'account', 'list']) + + expect(renderJson).not.toHaveBeenCalled() + }) + + it('emits an empty envelope under --json when no accounts are stored', async () => { + const { program } = buildList({}, buildStore([]).store) + + await program.parseAsync(['node', 'cli', 'account', 'list', '--json']) + + expect(logSpy).toHaveBeenCalledWith(formatJson({ accounts: [], default: null })) + }) + + it('emits nothing under --ndjson when no accounts are stored', async () => { + const { program } = buildList({}, buildStore([]).store) + + await program.parseAsync(['node', 'cli', 'account', 'list', '--ndjson']) + + expect(logSpy).not.toHaveBeenCalled() + }) + + it('emits the default empty-state message in human mode when no accounts are stored', async () => { + const { program } = buildList({}, buildStore([]).store) + + await program.parseAsync(['node', 'cli', 'account', 'list']) + + expect(logSpy).toHaveBeenCalledWith('No accounts stored.') + }) + + it('reports default null when no entry is marked default', async () => { + const store = buildStore([ + { account: a1, isDefault: false }, + { account: a2, isDefault: false }, + ]).store + const { program } = buildList({}, store) + + await program.parseAsync(['node', 'cli', 'account', 'list', '--json']) + + expect(logSpy).toHaveBeenCalledWith( + formatJson({ + accounts: [ + { account: a1, isDefault: false }, + { account: a2, isDefault: false }, + ], + default: null, + }), + ) + }) + + it('exposes consumer-attached options in flags but strips --json / --ndjson', async () => { + const renderText = vi.fn(() => 'ok') + const { program, command } = buildList({ renderText }) + command.option('--full', 'Show extended fields') + + await program.parseAsync(['node', 'cli', 'account', 'list', '--full']) + + expect(renderText).toHaveBeenCalledWith({ + accounts: bothAccounts, + default: '1', + view: { json: false, ndjson: false }, + flags: { full: true }, + }) + }) + + it('returns the new Command so the consumer can chain', () => { + const { command } = buildList() + + expect(command.name()).toBe('list') + }) +}) + +describe('attachAccountUseCommand', () => { + let logSpy: ReturnType + + beforeEach(() => { + logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + }) + + afterEach(() => { + logSpy.mockRestore() + }) + + it('calls setDefault and prints the success line in human mode', async () => { + const built = buildStore() + const { program } = buildUse({}, built.store) + + await program.parseAsync(['node', 'cli', 'account', 'use', 'alice@b']) + + expect(built.setDefaultSpy).toHaveBeenCalledWith('alice@b') + expect(logSpy).toHaveBeenCalledWith('✓ Default account set to alice@b') + }) + + it('emits the success envelope under --json', async () => { + const { program } = buildUse() + + await program.parseAsync(['node', 'cli', 'account', 'use', 'alice@b', '--json']) + + expect(logSpy).toHaveBeenCalledWith(formatJson({ ok: true, default: 'alice@b' })) + }) + + it('is silent under --ndjson but still calls setDefault', async () => { + const built = buildStore() + const { program } = buildUse({}, built.store) + + await program.parseAsync(['node', 'cli', 'account', 'use', 'alice@b', '--ndjson']) + + expect(built.setDefaultSpy).toHaveBeenCalledWith('alice@b') + expect(logSpy).not.toHaveBeenCalled() + }) + + it('echoes the raw user-supplied ref, not a resolved id', async () => { + const { program } = buildUse() + + await program.parseAsync(['node', 'cli', 'account', 'use', 'alice@b', '--json']) + + expect(logSpy).toHaveBeenCalledWith(formatJson({ ok: true, default: 'alice@b' })) + }) + + it('propagates ACCOUNT_NOT_FOUND from setDefault and prints nothing', async () => { + const thrown = new CliError('ACCOUNT_NOT_FOUND', 'No stored account matches "ghost".') + const built = buildStore() + built.setDefaultSpy.mockRejectedValueOnce(thrown) + const { program } = buildUse({}, built.store) + + await expect(program.parseAsync(['node', 'cli', 'account', 'use', 'ghost'])).rejects.toBe( + thrown, + ) + expect(logSpy).not.toHaveBeenCalled() + }) + + it('awaits onDefaultSet after the success line', async () => { + const order: string[] = [] + const built = buildStore() + built.setDefaultSpy.mockImplementationOnce(async () => { + order.push('setDefault') + }) + const onDefaultSet = vi.fn(async () => { + await Promise.resolve() + order.push('onDefaultSet') + }) + const { program } = buildUse({ onDefaultSet }, built.store) + + await program.parseAsync(['node', 'cli', 'account', 'use', 'alice@b']) + + expect(onDefaultSet).toHaveBeenCalledWith({ + ref: 'alice@b', + view: { json: false, ndjson: false }, + flags: {}, + }) + expect(order).toEqual(['setDefault', 'onDefaultSet']) + }) + + it('exposes consumer-attached options in flags but strips --json / --ndjson', async () => { + const onDefaultSet = vi.fn() + const { program, command } = buildUse({ onDefaultSet }) + command.option('--full', 'Extra') + + await program.parseAsync(['node', 'cli', 'account', 'use', 'alice@b', '--full']) + + expect(onDefaultSet).toHaveBeenCalledWith({ + ref: 'alice@b', + view: { json: false, ndjson: false }, + flags: { full: true }, + }) + }) + + it('returns the new Command so the consumer can chain', () => { + const { command } = buildUse() + + expect(command.name()).toBe('use') + }) +}) diff --git a/src/auth/account.ts b/src/auth/account.ts new file mode 100644 index 0000000..76a7a6a --- /dev/null +++ b/src/auth/account.ts @@ -0,0 +1,149 @@ +import type { Command } from 'commander' +import { formatJson, formatNdjson } from '../json.js' +import type { ViewOptions } from '../options.js' +import type { AccountRef, AuthAccount, TokenStore } from './types.js' + +export type AttachAccountListContext = { + /** Every stored account with its default marker, in store order. */ + accounts: ReadonlyArray<{ account: TAccount; isDefault: boolean }> + /** The default account's ref (its `id`), or `null` when nothing is stored. */ + default: AccountRef | null + /** `--json` / `--ndjson` flag values, both present (defaulted to `false`). */ + view: Required + /** Consumer-attached options. The registrar flags (`--json`, `--ndjson`) are stripped. */ + flags: Record +} + +export type AttachAccountListCommandOptions = { + store: TokenStore + description?: string + /** + * Human-mode renderer over the full list. May return a single string or an + * array of lines; lines are joined with `\n` on output. Defaults to one + * line per account (`