From 6a4a0b43f6c3b012d87d7c612117de2ca0f42b87 Mon Sep 17 00:00:00 2001 From: Scott Lovegrove Date: Sun, 24 May 2026 15:09:10 +0100 Subject: [PATCH 1/4] feat(account): account command group (cli-core attachers) Wire cli-core's account attachers into a new `ol account` group: list / use / current / remove. The multi-account storage (users[] config + keyring) and the `--user` selector / store-wrap shipped in #77; this surfaces it to users. - `ol account` defaults to `list`; default account marked (accessible-aware) - `ol account current` honours `--user` and reports OUTLINE_API_TOKEN / legacy single-user sources via onNotAuthenticated - machine output drops the OAuth client id - SKILL_CONTENT gains an Accounts section (SKILL.md regenerated) Co-Authored-By: Claude Opus 4.7 (1M context) --- skills/outline-cli/SKILL.md | 14 +++ src/_fixtures/auth.ts | 9 ++ src/commands/account.test.ts | 200 +++++++++++++++++++++++++++++++++++ src/commands/account.ts | 150 ++++++++++++++++++++++++++ src/index.ts | 2 + src/lib/skills/content.ts | 14 +++ 6 files changed, 389 insertions(+) create mode 100644 src/commands/account.test.ts create mode 100644 src/commands/account.ts diff --git a/skills/outline-cli/SKILL.md b/skills/outline-cli/SKILL.md index 78b7ab2..844834f 100644 --- a/skills/outline-cli/SKILL.md +++ b/skills/outline-cli/SKILL.md @@ -15,6 +15,7 @@ Use this skill when the user wants to interact with their Outline wiki/knowledge - `ol doc open ` - Open document in browser - `ol doc create --title "Title" --collection ` - Create document - `ol col list` - List collections +- `ol account` - List stored accounts; `ol account use ` sets the default ## Output Formats @@ -89,6 +90,19 @@ ol auth logout # Clear saved credentials ol auth logout --json | --ndjson # Machine-readable logout envelope ({ok: true}; --ndjson is silent) ``` +### Accounts +```bash +ol account # List stored accounts (default subcommand) +ol account list # List stored accounts, default marked +ol account list --json | --ndjson # Machine-readable list ({accounts, default}; --ndjson streams one per line) +ol account current # Show the active account (honours --user and OUTLINE_API_TOKEN) +ol account current --json | --ndjson # Machine-readable active account ({id, label, teamName, baseUrl, isDefault}) +ol account use # Set the default account used when --user is omitted +ol account use --json # Machine-readable envelope ({ok: true, default: }; --ndjson is silent) +ol account remove # Remove a stored account (clears keyring + config entry) +ol account remove --json # Machine-readable envelope ({ok: true, removed: }; --ndjson is silent) +``` + ### Update & Changelog ```bash ol update # Update CLI to latest version diff --git a/src/_fixtures/auth.ts b/src/_fixtures/auth.ts index 61ac351..c407f73 100644 --- a/src/_fixtures/auth.ts +++ b/src/_fixtures/auth.ts @@ -11,6 +11,15 @@ export const STORED_ACCOUNT: OutlineAccount = { teamName: 'Analytics', } +/** Secondary persisted `OutlineAccount` on a different instance — for multi-account tests. */ +export const STORED_ACCOUNT_BOB: OutlineAccount = { + id: 'bob-uuid', + label: 'Bob', + baseUrl: 'https://bob.example.com', + oauthClientId: 'cid-bob', + teamName: 'Engineering', +} + /** v1 plaintext config snapshot that round-trips to `STORED_ACCOUNT`. */ export const LEGACY_CONFIG = { api_token: 'tk_legacy_plaintext', diff --git a/src/commands/account.test.ts b/src/commands/account.test.ts new file mode 100644 index 0000000..a0c9605 --- /dev/null +++ b/src/commands/account.test.ts @@ -0,0 +1,200 @@ +import { captureConsole, createTestProgram } from '@doist/cli-core/testing' +import type { Command } from 'commander' +import { type MockInstance, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { STORED_ACCOUNT, STORED_ACCOUNT_BOB } from '../_fixtures/auth.js' +import type { OutlineAccount } from '../lib/outline-account.js' +import { matchOutlineAccount } from '../lib/outline-account.js' + +// In-memory stand-in for the keyring store: `setDefault` / `clear` resolve the +// raw `` through the real `matchOutlineAccount` (id or display name), so +// ref-matching is exercised rather than stubbed. +const storeMocks = vi.hoisted(() => ({ + list: vi.fn(), + setDefault: vi.fn(), + clear: vi.fn(), + active: vi.fn(), + activeBundle: vi.fn(), + activeAccount: vi.fn(), + set: vi.fn(), + setBundle: vi.fn(), + getLastStorageResult: vi.fn(), + getLastClearResult: vi.fn(() => undefined), +})) + +const legacyMock = vi.hoisted(() => ({ isLegacyAuthActive: vi.fn(async () => false) })) + +vi.mock('../lib/auth-provider.js', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + createOutlineTokenStore: () => storeMocks, + isLegacyAuthActive: legacyMock.isLegacyAuthActive, + } +}) + +function lines(spy: MockInstance): string { + return spy.mock.calls.map((args) => args.join(' ')).join('\n') +} + +function seedStore(...records: Array): void { + const list = records.map((spec) => + Array.isArray(spec) + ? { account: spec[0], isDefault: true } + : { account: spec, isDefault: false }, + ) + storeMocks.list.mockResolvedValue(list) + storeMocks.activeAccount.mockImplementation(async (ref?: string) => { + if (ref === undefined) return list.find((entry) => entry.isDefault) ?? null + return list.find((entry) => matchOutlineAccount(entry.account, ref)) ?? null + }) + storeMocks.setDefault.mockImplementation(async (ref: string) => { + const match = list.find((entry) => matchOutlineAccount(entry.account, ref)) + if (!match) { + const { CliError } = await import('../lib/errors.js') + throw new CliError('ACCOUNT_NOT_FOUND', `No stored account matches "${ref}".`) + } + for (const entry of list) entry.isDefault = entry === match + }) + storeMocks.clear.mockImplementation(async (ref: string) => { + const index = list.findIndex((entry) => matchOutlineAccount(entry.account, ref)) + if (index < 0) return null + const [removed] = list.splice(index, 1) + return { account: removed.account, wasDefault: removed.isDefault } + }) +} + +async function buildProgram(): Promise { + const { registerAccountCommand } = await import('./account.js') + return createTestProgram(registerAccountCommand) +} + +let logSpy: MockInstance + +beforeEach(() => { + vi.resetModules() + delete process.env.OUTLINE_API_TOKEN + logSpy = captureConsole('log') +}) + +afterEach(() => { + vi.clearAllMocks() + delete process.env.OUTLINE_API_TOKEN + // Reset argv so a `--user` set by one test can't leak into the next via the + // (real) global-args parser. + process.argv = ['node', 'ol'] +}) + +describe('account command', () => { + describe('list', () => { + it('renders all stored accounts with the default marker', async () => { + seedStore([STORED_ACCOUNT, 'default'], STORED_ACCOUNT_BOB) + const program = await buildProgram() + await program.parseAsync(['node', 'ol', 'account', 'list']) + const out = lines(logSpy) + expect(out).toContain('Ada') + expect(out).toContain('Bob') + expect(out).toContain(`id:${STORED_ACCOUNT.id}`) + expect(out).toMatch(/default/) + }) + + it('prints the empty-state message when nothing is stored', async () => { + seedStore() + const program = await buildProgram() + await program.parseAsync(['node', 'ol', 'account', 'list']) + expect(lines(logSpy)).toMatch(/No stored accounts/) + }) + + it('runs by default when no subcommand is given (ol account)', async () => { + seedStore([STORED_ACCOUNT, 'default']) + const program = await buildProgram() + await program.parseAsync(['node', 'ol', 'account']) + expect(lines(logSpy)).toContain('Ada') + }) + + it('emits a {accounts, default} envelope under --json', async () => { + seedStore([STORED_ACCOUNT, 'default'], STORED_ACCOUNT_BOB) + const program = await buildProgram() + await program.parseAsync(['node', 'ol', 'account', 'list', '--json']) + const payload = JSON.parse(lines(logSpy)) + expect(payload.default).toBe(STORED_ACCOUNT.id) + expect(payload.accounts).toHaveLength(2) + expect(payload.accounts[0]).toMatchObject({ id: STORED_ACCOUNT.id, isDefault: true }) + // The OAuth client id is intentionally dropped from machine output. + expect(payload.accounts[0]).not.toHaveProperty('oauthClientId') + }) + }) + + describe('use', () => { + it('sets the default account and echoes the ref', async () => { + seedStore([STORED_ACCOUNT, 'default'], STORED_ACCOUNT_BOB) + const program = await buildProgram() + await program.parseAsync(['node', 'ol', 'account', 'use', STORED_ACCOUNT_BOB.id]) + expect(storeMocks.setDefault).toHaveBeenCalledWith(STORED_ACCOUNT_BOB.id) + expect(lines(logSpy)).toContain(`Default account set to ${STORED_ACCOUNT_BOB.id}`) + }) + + it('propagates ACCOUNT_NOT_FOUND for an unknown ref', async () => { + seedStore([STORED_ACCOUNT, 'default']) + const program = await buildProgram() + await expect( + program.parseAsync(['node', 'ol', 'account', 'use', 'nobody']), + ).rejects.toHaveProperty('code', 'ACCOUNT_NOT_FOUND') + }) + }) + + describe('remove', () => { + it('clears the account by display name and prints the removed label', async () => { + seedStore([STORED_ACCOUNT, 'default'], STORED_ACCOUNT_BOB) + const program = await buildProgram() + await program.parseAsync(['node', 'ol', 'account', 'remove', 'bob']) + expect(storeMocks.clear).toHaveBeenCalledWith('bob') + expect(lines(logSpy)).toContain('Removed Bob') + }) + + it('notes a cleared default when removing the default account', async () => { + seedStore([STORED_ACCOUNT, 'default']) + const program = await buildProgram() + await program.parseAsync(['node', 'ol', 'account', 'remove', STORED_ACCOUNT.id]) + const out = lines(logSpy) + expect(out).toContain('Removed Ada') + expect(out).toMatch(/Cleared default account/) + }) + }) + + describe('current', () => { + it('renders the active stored account', async () => { + seedStore([STORED_ACCOUNT, 'default'], STORED_ACCOUNT_BOB) + const program = await buildProgram() + await program.parseAsync(['node', 'ol', 'account', 'current']) + const out = lines(logSpy) + expect(out).toContain('Ada') + expect(out).toContain(STORED_ACCOUNT.baseUrl) + }) + + it('reports the env-token source when OUTLINE_API_TOKEN is set', async () => { + seedStore([STORED_ACCOUNT, 'default']) + process.env.OUTLINE_API_TOKEN = 'tok-env' + const program = await buildProgram() + await program.parseAsync(['node', 'ol', 'account', 'current']) + expect(lines(logSpy)).toContain('OUTLINE_API_TOKEN') + expect(storeMocks.activeAccount).not.toHaveBeenCalled() + }) + + it('reports the legacy source when a legacy session is active', async () => { + seedStore() + legacyMock.isLegacyAuthActive.mockResolvedValue(true) + const program = await buildProgram() + await program.parseAsync(['node', 'ol', 'account', 'current']) + expect(lines(logSpy)).toMatch(/legacy single-user credentials/) + }) + + it('throws NOT_AUTHENTICATED when nothing is active', async () => { + seedStore() + legacyMock.isLegacyAuthActive.mockResolvedValue(false) + const program = await buildProgram() + await expect( + program.parseAsync(['node', 'ol', 'account', 'current']), + ).rejects.toHaveProperty('code', 'NOT_AUTHENTICATED') + }) + }) +}) diff --git a/src/commands/account.ts b/src/commands/account.ts new file mode 100644 index 0000000..8b1d66e --- /dev/null +++ b/src/commands/account.ts @@ -0,0 +1,150 @@ +import { emitView } from '@doist/cli-core' +import { + type AccountRef, + attachAccountCurrentCommand, + attachAccountListCommand, + attachAccountRemoveCommand, + attachAccountUseCommand, +} from '@doist/cli-core/auth' +import chalk from 'chalk' +import type { Command } from 'commander' +import { TOKEN_ENV_VAR } from '../lib/auth-constants.js' +import { + createOutlineTokenStore, + isLegacyAuthActive, + type OutlineAccount, + type OutlineTokenStore, +} from '../lib/auth-provider.js' +import { CliError } from '../lib/errors.js' +import { getRequestedUserRef, isAccessible } from '../lib/global-args.js' +import { withUserRefAware } from '../lib/user-ref-store.js' +import { logTokenStorageResult } from './auth.js' + +/** `