diff --git a/skills/outline-cli/SKILL.md b/skills/outline-cli/SKILL.md index b228966..4f2d520 100644 --- a/skills/outline-cli/SKILL.md +++ b/skills/outline-cli/SKILL.md @@ -88,6 +88,11 @@ ol auth status # Show current auth state ol auth status --json | --ndjson # Machine-readable status envelope ({id, team, baseUrl, source}) ol auth logout # Clear saved credentials ol auth logout --json | --ndjson # Machine-readable logout envelope ({ok: true}; --ndjson is silent) +ol auth token # Save a personal API token (validates via auth.info, resolves identity) +ol auth token --base-url # Save a token for a specific Outline instance +ol auth token # Prompt for the token (hidden input; errors in non-interactive shells) +ol auth token view # Print the bare stored token to stdout for scripts (no newline when piped; refuses when OUTLINE_API_TOKEN is set) +ol --user auth token view # Print a specific stored account's token (--user is a root flag, before the command) ``` ### Accounts diff --git a/src/commands/auth-token.test.ts b/src/commands/auth-token.test.ts new file mode 100644 index 0000000..85be749 --- /dev/null +++ b/src/commands/auth-token.test.ts @@ -0,0 +1,221 @@ +import { captureConsole, captureStream, createTestProgram } from '@doist/cli-core/testing' +import type { Command } from 'commander' +import { type MockInstance, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { AUTH_INFO, STORED_ACCOUNT, STORED_ACCOUNT_BOB } from '../_fixtures/auth.js' +import type { CliError } from '../lib/errors.js' + +// `auth token` save drives the raw store's `set` + `getLastStorageResult`; +// `auth token view` (real cli-core attacher) reads through the ref-aware store's +// `active` / `activeAccount`. Stub the store so neither path touches a keyring. +const storeMocks = vi.hoisted(() => ({ + set: vi.fn(), + getLastStorageResult: vi.fn(() => undefined), + active: vi.fn(), + activeAccount: vi.fn(async () => ({ account: STORED_ACCOUNT, isDefault: true })), +})) + +// Stub the shared masked prompt so the interactive (no-argument) save path is +// testable without a real TTY. `identifyAccount` / `resolveBaseUrl` stay real. +const promptMock = vi.hoisted(() => + vi.fn<(q: string, o?: { hidden?: boolean }) => Promise>(), +) + +vi.mock('../lib/auth-provider.js', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, createOutlineTokenStore: () => storeMocks, prompt: promptMock } +}) + +vi.mock('../lib/api.js', () => ({ apiRequest: vi.fn() })) + +function lines(spy: MockInstance): string { + return spy.mock.calls.map((args) => args.join(' ')).join('\n') +} + +async function buildProgram(): Promise { + const { registerAuthCommand } = await import('./auth.js') + return createTestProgram(registerAuthCommand) +} + +async function importApiMock() { + const { apiRequest } = await import('../lib/api.js') + return vi.mocked(apiRequest) +} + +// `process.stdin` is a shared global; mutating `isTTY` would bleed across tests +// (resetModules doesn't isolate it), so snapshot and restore it every test. +const ORIGINAL_STDIN_ISTTY = process.stdin.isTTY +function setStdinIsTTY(value: boolean | undefined): void { + Object.defineProperty(process.stdin, 'isTTY', { value, configurable: true }) +} + +beforeEach(() => { + vi.resetModules() + delete process.env.OUTLINE_API_TOKEN + delete process.env.OUTLINE_URL +}) + +afterEach(() => { + vi.clearAllMocks() + delete process.env.OUTLINE_API_TOKEN + delete process.env.OUTLINE_URL + process.argv = ['node', 'ol'] + setStdinIsTTY(ORIGINAL_STDIN_ISTTY) +}) + +describe('auth token (save)', () => { + it('validates via auth.info, stores the resolved account, and confirms', async () => { + const log = captureConsole() + const apiRequest = await importApiMock() + apiRequest.mockResolvedValueOnce({ data: AUTH_INFO }) + + const program = await buildProgram() + await program.parseAsync([ + 'node', + 'ol', + 'auth', + 'token', + 'tok-paste', + '--base-url', + 'https://wiki.test', + ]) + + expect(apiRequest).toHaveBeenCalledWith( + 'auth.info', + {}, + { token: 'tok-paste', baseUrl: 'https://wiki.test' }, + ) + expect(storeMocks.set).toHaveBeenCalledWith( + { + id: 'user-uuid', + label: 'Ada Lovelace', + baseUrl: 'https://wiki.test', + oauthClientId: '', + teamName: 'Analytics', + }, + 'tok-paste', + ) + expect(lines(log)).toContain('Saved token for Ada Lovelace (Analytics)') + }) + + it('collapses any auth.info failure into a leak-free AUTH_VERIFICATION_FAILED', async () => { + const apiRequest = await importApiMock() + // Outline's real invalid-token error carries no status code (api.ts drops + // it when the body has a message); the wrapper must hide it entirely. + apiRequest.mockRejectedValueOnce(new Error('API error: Unable to decode token')) + + const program = await buildProgram() + const err = (await program + .parseAsync([ + 'node', + 'ol', + 'auth', + 'token', + 'bad-token', + '--base-url', + 'https://wiki.test', + ]) + .catch((e: unknown) => e)) as CliError + + expect(err.code).toBe('AUTH_VERIFICATION_FAILED') + expect(err.message).toBe('Could not verify the token with Outline') + expect(err.message).not.toContain('Unable to decode token') + expect(err.hints).toEqual(expect.arrayContaining([expect.stringContaining('--base-url')])) + expect(storeMocks.set).not.toHaveBeenCalled() + }) + + it('throws NO_TOKEN when no token is given in a non-interactive shell', async () => { + setStdinIsTTY(false) + const program = await buildProgram() + await expect(program.parseAsync(['node', 'ol', 'auth', 'token'])).rejects.toHaveProperty( + 'code', + 'NO_TOKEN', + ) + expect(promptMock).not.toHaveBeenCalled() + }) + + it('reads the token from a masked prompt when no argument is given in a TTY', async () => { + setStdinIsTTY(true) + promptMock.mockResolvedValueOnce('tok-prompt') + const apiRequest = await importApiMock() + apiRequest.mockResolvedValueOnce({ data: AUTH_INFO }) + + const program = await buildProgram() + await program.parseAsync(['node', 'ol', 'auth', 'token', '--base-url', 'https://wiki.test']) + + expect(promptMock).toHaveBeenCalledWith('API token: ', { hidden: true }) + expect(storeMocks.set).toHaveBeenCalledWith( + expect.objectContaining({ id: 'user-uuid', label: 'Ada Lovelace' }), + 'tok-prompt', + ) + }) + + it('suppresses the human confirmation in machine-output mode', async () => { + const log = captureConsole() + const apiRequest = await importApiMock() + apiRequest.mockResolvedValueOnce({ data: AUTH_INFO }) + + // `--json` is a root selector read off argv by global-args, not a + // commander option on `auth token`, so warm the cache rather than + // passing it through parseAsync. + const { resetGlobalArgs } = await import('../lib/global-args.js') + process.argv = ['node', 'ol', '--json', 'auth', 'token'] + resetGlobalArgs() + + const program = await buildProgram() + await program.parseAsync([ + 'node', + 'ol', + 'auth', + 'token', + 'tok-paste', + '--base-url', + 'https://wiki.test', + ]) + + expect(storeMocks.set).toHaveBeenCalled() + expect(lines(log)).toEqual('') + }) +}) + +describe('auth token view', () => { + it('writes the bare stored token to stdout with no envelope or newline', async () => { + storeMocks.active.mockResolvedValueOnce({ token: 'stored-tok', account: STORED_ACCOUNT }) + const out = captureStream('stdout') + + const program = await buildProgram() + await program.parseAsync(['node', 'ol', 'auth', 'token', 'view']) + + expect(out.mock.calls).toEqual([['stored-tok']]) + }) + + it('refuses to print when OUTLINE_API_TOKEN is set', async () => { + process.env.OUTLINE_API_TOKEN = 'env-token' + const program = await buildProgram() + await expect( + program.parseAsync(['node', 'ol', 'auth', 'token', 'view']), + ).rejects.toHaveProperty('code', 'TOKEN_FROM_ENV') + }) + + it('routes a global --user through the ref-aware store', async () => { + storeMocks.active.mockImplementationOnce(async (ref?: string) => + ref === 'Bob' + ? { token: 'tok-bob', account: STORED_ACCOUNT_BOB } + : { token: 'tok-ada', account: STORED_ACCOUNT }, + ) + storeMocks.activeAccount.mockResolvedValueOnce({ + account: STORED_ACCOUNT_BOB, + isDefault: false, + }) + const out = captureStream('stdout') + + const { resetGlobalArgs } = await import('../lib/global-args.js') + process.argv = ['node', 'ol', '--user', 'Bob', 'auth', 'token', 'view'] + resetGlobalArgs() + + const program = await buildProgram() + await program.parseAsync(['node', 'ol', 'auth', 'token', 'view']) + + expect(storeMocks.active).toHaveBeenCalledWith('Bob') + expect(out.mock.calls).toEqual([['tok-bob']]) + }) +}) diff --git a/src/commands/auth.ts b/src/commands/auth.ts index 93a67b7..f91fed9 100644 --- a/src/commands/auth.ts +++ b/src/commands/auth.ts @@ -1,19 +1,29 @@ -import { attachLoginCommand, attachLogoutCommand, attachStatusCommand } from '@doist/cli-core/auth' +import { + attachLoginCommand, + attachLogoutCommand, + attachStatusCommand, + attachTokenViewCommand, +} from '@doist/cli-core/auth' import chalk from 'chalk' import type { Command } from 'commander' import { apiRequest } from '../lib/api.js' -import { logClearResult, logTokenStorageResult } from '../lib/auth-output.js' +import { TOKEN_ENV_VAR } from '../lib/auth-constants.js' +import { logClearResult, logSaveResult } from '../lib/auth-output.js' import { renderError, renderSuccess } from '../lib/auth-pages.js' import { type AuthInfoResponse, createOutlineAuthProvider, createOutlineTokenStore, getActiveTokenSource, + identifyAccount, type OutlineAccount, type OutlineTokenStore, + prompt, + resolveBaseUrl, } from '../lib/auth-provider.js' import { refreshedTokenForStatus } from '../lib/auth.js' import { CliError } from '../lib/errors.js' +import { isJsonMode } from '../lib/global-args.js' import { withUserRefAware } from '../lib/user-ref-store.js' const DEFAULT_OAUTH_CALLBACK_PORT = 54969 @@ -33,6 +43,48 @@ function resolvePreferredCallbackPort(): number { return parsed } +async function saveToken( + store: OutlineTokenStore, + token: string | undefined, + options: { baseUrl?: string }, +): Promise { + if (!token) { + if (!process.stdin.isTTY) { + throw new CliError('NO_TOKEN', 'No token provided', [ + 'Pass it as an argument: ol auth token ', + 'Run in an interactive terminal to be prompted for it', + 'Set OUTLINE_API_TOKEN to authenticate without storing a token', + 'Or use OAuth: ol auth login', + ]) + } + token = await prompt('API token: ', { hidden: true }) + } + const trimmed = token.trim() + if (!trimmed) throw new CliError('NO_TOKEN', 'No token provided') + + const baseUrl = await resolveBaseUrl({ baseUrl: options.baseUrl }) + + // A freshly pasted token is verified by probing `auth.info`. Any failure + // (bad token, wrong instance, unreachable host) collapses to one stable + // error — never surface the raw API/network string. + let account: OutlineAccount + try { + account = await identifyAccount(trimmed, baseUrl) + } catch { + throw new CliError('AUTH_VERIFICATION_FAILED', 'Could not verify the token with Outline', [ + 'Check the token value', + `Check --base-url matches the instance the token came from (used: ${baseUrl})`, + ]) + } + await store.set(account, trimmed) + + const machine = isJsonMode() + if (!machine) { + console.log(chalk.green('✓'), `Saved token for ${account.label} (${account.teamName})`) + } + logSaveResult(store, machine) +} + export function registerAuthCommand(program: Command): void { const auth = program.command('auth').description('Manage authentication') @@ -55,14 +107,7 @@ export function registerAuthCommand(program: Command): void { if (!isMachineOutput) { console.log(chalk.green(`Authenticated to ${account.teamName} as ${account.label}`)) } - const result = store.getLastStorageResult() - if (result) { - logTokenStorageResult( - result, - 'Token stored securely in the system credential manager', - isMachineOutput, - ) - } + logSaveResult(store, isMachineOutput) }, }) .description('Authenticate with an Outline instance via OAuth') @@ -152,4 +197,20 @@ export function registerAuthCommand(program: Command): void { logClearResult(store, view.json || view.ndjson) }, }) + + const tokenCmd = auth + .command('token [token]') + .description('Save an Outline API token for CLI auth (or use the `view` subcommand)') + .option('--base-url ', 'Outline base URL the token belongs to') + .action((token: string | undefined, options: { baseUrl?: string }) => + saveToken(store, token, options), + ) + + attachTokenViewCommand(tokenCmd, { + name: 'view', + store: refAware, + envVarName: TOKEN_ENV_VAR, + description: + 'Print the stored token for the active user (or --user ) to stdout for scripts', + }) } diff --git a/src/lib/auth-output.ts b/src/lib/auth-output.ts index 75cee7f..7958e37 100644 --- a/src/lib/auth-output.ts +++ b/src/lib/auth-output.ts @@ -21,6 +21,21 @@ export function logTokenStorageResult( } } +/** + * Surface the result of a token save (`auth login` / `auth token`): the + * confirmation goes to stdout, any keyring-fallback warning to stderr. Shared so + * both save flows keep identical machine-output and warning behavior. + */ +export function logSaveResult(store: OutlineTokenStore, isMachineOutput: boolean): void { + const result = store.getLastStorageResult() + if (!result) return + logTokenStorageResult( + result, + 'Token stored securely in the system credential manager', + isMachineOutput, + ) +} + /** * Surface the result of a token clear (`auth logout` / `account remove`): the * confirmation goes to stdout, any keyring-fallback warning to stderr. Shared so diff --git a/src/lib/auth-provider.test.ts b/src/lib/auth-provider.test.ts index 24d238e..37d16f3 100644 --- a/src/lib/auth-provider.test.ts +++ b/src/lib/auth-provider.test.ts @@ -1,3 +1,5 @@ +import { Writable } from 'node:stream' +import { captureStream } from '@doist/cli-core/testing' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { AUTH_INFO, @@ -12,6 +14,9 @@ import { vi.mock('../transport/fetch-with-retry.js', () => ({ fetchWithRetry: vi.fn() })) vi.mock('./api.js', () => ({ apiRequest: vi.fn() })) +const readlineMocks = vi.hoisted(() => ({ createInterface: vi.fn() })) +vi.mock('node:readline/promises', () => ({ createInterface: readlineMocks.createInterface })) + const migrateMocks = vi.hoisted(() => ({ runMigrateLegacyAuth: vi.fn(), })) @@ -583,6 +588,56 @@ describe('resolveActiveAccountSource', () => { }) }) +describe('prompt + resolveBaseUrl', () => { + beforeEach(() => { + readlineMocks.createInterface.mockReset() + delete process.env.OUTLINE_URL + configMocks.getConfig.mockReset().mockResolvedValue({}) + }) + + it('prompt({ hidden }) echoes the label but masks the typed characters', async () => { + const stderr = captureStream('stderr') + let output: Writable | undefined + readlineMocks.createInterface.mockImplementation((opts: { output: Writable }) => { + output = opts.output + return { + // Mirror readline: the prompt label is written synchronously + // (before `prompt` mutes), the keystroke echo lands afterwards. + question: (label: string) => { + output?.write(label) + return new Promise((resolve) => { + queueMicrotask(() => { + output?.write('secret') + resolve(' secret ') + }) + }) + }, + close: vi.fn(), + } + }) + + const { prompt } = await import('./auth-provider.js') + const answer = await prompt('API token: ', { hidden: true }) + + expect(answer).toBe('secret') + const written = stderr.mock.calls.map((c) => String(c[0])).join('') + expect(written).toContain('API token: ') + expect(written).not.toContain('secret') + }) + + it('resolveBaseUrl returns the configured default without prompting in a non-TTY shell', async () => { + const original = process.stdin.isTTY + Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true }) + try { + const { resolveBaseUrl } = await import('./auth-provider.js') + await expect(resolveBaseUrl()).resolves.toBe('https://app.getoutline.com') + expect(readlineMocks.createInterface).not.toHaveBeenCalled() + } finally { + Object.defineProperty(process.stdin, 'isTTY', { value: original, configurable: true }) + } + }) +}) + describe('matchOutlineAccount', () => { it('matches the UUID exactly and the label case-insensitively', async () => { const { matchOutlineAccount } = await import('./auth-provider.js') diff --git a/src/lib/auth-provider.ts b/src/lib/auth-provider.ts index 9ef8d26..ddac110 100644 --- a/src/lib/auth-provider.ts +++ b/src/lib/auth-provider.ts @@ -1,4 +1,5 @@ import { createInterface } from 'node:readline/promises' +import { Writable } from 'node:stream' import { type AccountRef, type AuthProvider, @@ -27,33 +28,84 @@ export type AuthInfoResponse = { export type OutlineTokenStore = KeyringTokenStore -function stringFlag(flags: Record, key: string): string | undefined { - const value = flags[key] +function stringFlag(value: unknown): string | undefined { return typeof value === 'string' && value.trim() ? value.trim() : undefined } -async function prompt(question: string): Promise { - // Output to stderr so `--json` / `--ndjson` envelopes on stdout stay clean. - const rl = createInterface({ input: process.stdin, output: process.stderr }) +/** + * Read a line from stdin, prompting on stderr so `--json` / `--ndjson` + * envelopes on stdout stay clean. With `hidden: true` the typed characters are + * masked (for secrets): the prompt label is shown, then readline's echo is + * routed through a muted `Writable` so keystrokes don't leak to the terminal. + */ +export async function prompt( + question: string, + options: { hidden?: boolean } = {}, +): Promise { + if (!options.hidden) { + const rl = createInterface({ input: process.stdin, output: process.stderr }) + try { + return (await rl.question(question)).trim() + } finally { + rl.close() + } + } + // Echo the prompt label, then mute so typed characters aren't shown. A muted + // `Writable` (public API) avoids poking readline's private `_writeToOutput`. + let muted = false + const output = new Writable({ + write(chunk, _encoding, callback) { + if (!muted) process.stderr.write(chunk) + callback() + }, + }) + const rl = createInterface({ input: process.stdin, output, terminal: true }) try { - return (await rl.question(question)).trim() + const pending = rl.question(question) + muted = true + const answer = (await pending).trim() + process.stderr.write('\n') + return answer } finally { rl.close() } } -async function resolveBaseUrl(flags: Record): Promise { - const fromFlag = stringFlag(flags, 'baseUrl') +export async function resolveBaseUrl(options: { baseUrl?: unknown } = {}): Promise { + const fromFlag = stringFlag(options.baseUrl) if (fromFlag) return fromFlag.replace(/\/$/, '') const fromEnv = process.env.OUTLINE_URL?.trim() if (fromEnv) return fromEnv.replace(/\/$/, '') const configured = await getBaseUrl() + // Never block a non-interactive shell (CI, piped input) on a prompt — + // `auth token` is meant to be scriptable, so fall back to the default. + if (!process.stdin.isTTY) return configured.replace(/\/$/, '') const answered = await prompt(`Base URL (default: ${configured}): `) return (answered || configured).replace(/\/$/, '') } +/** + * Verify a token by probing `auth.info` and map the response to the persisted + * account shape. Shared by the OAuth `validate` hook and `auth token` so the + * identity-resolution rules live in one place. + */ +export async function identifyAccount( + token: string, + baseUrl: string, + oauthClientId?: string, +): Promise { + const { data } = await apiRequest('auth.info', {}, { token, baseUrl }) + return makeOutlineAccount({ + id: data.user.id, + label: data.user.name, + baseUrl, + oauthClientId, + teamName: data.team.name, + }) +} + async function resolveClientId(flags: Record): Promise { - const fromFlag = stringFlag(flags, 'clientId') + const fromFlag = stringFlag(flags.clientId) if (fromFlag) return fromFlag const existing = await getOAuthClientId() if (existing) return existing @@ -106,18 +158,7 @@ export function createOutlineAuthProvider(): AuthProvider { }, validate: async ({ token, handshake }) => { const base = baseUrl ?? (await getBaseUrl()) - const { data } = await apiRequest( - 'auth.info', - {}, - { token, baseUrl: base }, - ) - return makeOutlineAccount({ - id: data.user.id, - label: data.user.name, - baseUrl: base, - oauthClientId: handshake.clientId as string, - teamName: data.team.name, - }) + return identifyAccount(token, base, handshake.clientId as string) }, fetchImpl: outlineFetch, }) diff --git a/src/lib/skills/content.ts b/src/lib/skills/content.ts index 0db8cb8..c7cf596 100644 --- a/src/lib/skills/content.ts +++ b/src/lib/skills/content.ts @@ -87,6 +87,11 @@ ol auth status # Show current auth state ol auth status --json | --ndjson # Machine-readable status envelope ({id, team, baseUrl, source}) ol auth logout # Clear saved credentials ol auth logout --json | --ndjson # Machine-readable logout envelope ({ok: true}; --ndjson is silent) +ol auth token # Save a personal API token (validates via auth.info, resolves identity) +ol auth token --base-url # Save a token for a specific Outline instance +ol auth token # Prompt for the token (hidden input; errors in non-interactive shells) +ol auth token view # Print the bare stored token to stdout for scripts (no newline when piped; refuses when OUTLINE_API_TOKEN is set) +ol --user auth token view # Print a specific stored account's token (--user is a root flag, before the command) \`\`\` ### Accounts