From c1823423f3157e01a7ead6e72b616a23481f7040 Mon Sep 17 00:00:00 2001 From: Scott Lovegrove Date: Sat, 16 May 2026 09:30:01 +0100 Subject: [PATCH 1/2] refactor(auth): delegate status + logout to @doist/cli-core/auth Replaces hand-rolled `ol auth status` / `ol auth logout` with cli-core's `attachStatusCommand` / `attachLogoutCommand` registrars (mirrors Doist/twist-cli#222). Both subcommands gain `--json` / `--ndjson` for free, and status now translates 401 into the standard `NO_TOKEN` envelope with a re-auth hint instead of the ad-hoc `AUTH_VERIFICATION_FAILED` path. Co-Authored-By: Claude Opus 4.7 (1M context) --- skills/outline-cli/SKILL.md | 2 + src/commands/auth.ts | 112 ++++++++++++++++++++++++------------ src/lib/errors.ts | 1 + src/lib/skills/content.ts | 2 + 4 files changed, 79 insertions(+), 38 deletions(-) diff --git a/skills/outline-cli/SKILL.md b/skills/outline-cli/SKILL.md index 499b739..e2579f2 100644 --- a/skills/outline-cli/SKILL.md +++ b/skills/outline-cli/SKILL.md @@ -80,7 +80,9 @@ ol auth login --callback-port # Override local OAuth callback port ol auth login --read-only # Request read-only scopes (where supported by the Outline instance) ol auth login --json | --ndjson # Machine-readable success envelope ol auth status # Show current auth state +ol auth status --json | --ndjson # Machine-readable status envelope ({id, name, email, team, baseUrl, source}) ol auth logout # Clear saved credentials +ol auth logout --json | --ndjson # Machine-readable logout envelope ({ok: true}; --ndjson is silent) ``` ### Update & Changelog diff --git a/src/commands/auth.ts b/src/commands/auth.ts index 2a66206..38bd450 100644 --- a/src/commands/auth.ts +++ b/src/commands/auth.ts @@ -1,19 +1,28 @@ -import { attachLoginCommand } from '@doist/cli-core/auth' +import { attachLoginCommand, attachLogoutCommand, attachStatusCommand } from '@doist/cli-core/auth' import chalk from 'chalk' import type { Command } from 'commander' import { apiRequest } from '../lib/api.js' import { renderError, renderSuccess } from '../lib/auth-pages.js' -import { createOutlineAuthProvider, createOutlineTokenStore } from '../lib/auth-provider.js' -import { clearConfig, getBaseUrl, getTokenSource } from '../lib/auth.js' -import { formatError } from '../lib/output.js' +import { + createOutlineAuthProvider, + createOutlineTokenStore, + type OutlineAccount, +} from '../lib/auth-provider.js' +import { getTokenSource } from '../lib/auth.js' +import { CliError } from '../lib/errors.js' const DEFAULT_OAUTH_CALLBACK_PORT = 54969 type AuthInfoResponse = { - user: { name: string; email: string } + user: { id: string; name: string; email: string } team: { name: string; subdomain: string } } +type StatusData = { + info: AuthInfoResponse + source: 'env' | 'config' +} + function resolvePreferredCallbackPort(): number { const raw = process.env.OUTLINE_OAUTH_CALLBACK_PORT?.trim() if (!raw) return DEFAULT_OAUTH_CALLBACK_PORT @@ -52,42 +61,69 @@ export function registerAuthCommand(program: Command): void { 'OAuth client ID to use for this login (saved for future logins)', ) - auth.command('status') - .description('Show current authentication state') - .action(async () => { - const source = await getTokenSource() - if (!source) { - console.log(chalk.yellow('Not authenticated. Run: ol auth login')) - return - } - - console.log(chalk.dim(`Token source: ${source}`)) - console.log(chalk.dim(`Base URL: ${await getBaseUrl()}`)) + // `attachStatusCommand` guarantees `fetchLive` runs before `renderText` / + // `renderJson` within a single invocation, so the stash is always + // populated by the time the render hooks read it. + let statusData: StatusData | null = null + attachStatusCommand(auth, { + store, + description: 'Show current authentication state', + async fetchLive({ token, account }) { try { - const { data } = await apiRequest('auth.info') - console.log(`Team: ${chalk.bold(data.team.name)}`) - console.log(`User: ${data.user.name} (${data.user.email})`) - } catch (err) { - console.error( - formatError( - 'AUTH_VERIFICATION_FAILED', - `Could not fetch auth info: ${(err as Error).message}`, - [ - 'Check that your API token is valid', - 'Verify the base URL is correct', - "Run 'ol auth login' to re-authenticate", - ], + const [{ data: info }, source] = await Promise.all([ + apiRequest( + 'auth.info', + {}, + { token, baseUrl: account.baseUrl }, ), - ) - process.exit(1) + getTokenSource(), + ]) + statusData = { info, source: source ?? 'config' } + return { + ...account, + id: info.user.id, + label: info.user.name, + teamName: info.team.name, + } + } catch (err) { + const message = (err as Error).message ?? '' + if (/\b401\b/.test(message) || /Authentication required/i.test(message)) { + throw new CliError('NO_TOKEN', 'Not authenticated (token expired or invalid)', [ + 'Run `ol auth login` to re-authenticate', + ]) + } + throw err } - }) + }, + renderText({ account }) { + if (!statusData) throw new Error('status renderText called before fetchLive') + return [ + `${chalk.green('✓')} Authenticated`, + ` Team: ${chalk.bold(statusData.info.team.name)}`, + ` User: ${statusData.info.user.name} (${statusData.info.user.email})`, + ` Base URL: ${account.baseUrl}`, + ` Token source: ${statusData.source}`, + ] + }, + renderJson({ account }) { + if (!statusData) throw new Error('status renderJson called before fetchLive') + return { + id: statusData.info.user.id, + name: statusData.info.user.name, + email: statusData.info.user.email, + team: statusData.info.team.name, + baseUrl: account.baseUrl, + source: statusData.source, + } + }, + onNotAuthenticated() { + throw new CliError('NOT_AUTHENTICATED', 'Not authenticated. Run: ol auth login') + }, + }) - auth.command('logout') - .description('Clear saved authentication') - .action(async () => { - await clearConfig() - console.log('Logged out.') - }) + attachLogoutCommand(auth, { + store, + description: 'Clear saved authentication', + }) } diff --git a/src/lib/errors.ts b/src/lib/errors.ts index c3c6cbd..e9c21f1 100644 --- a/src/lib/errors.ts +++ b/src/lib/errors.ts @@ -10,6 +10,7 @@ export type ErrorCode = | 'CONFLICTING_OPTIONS' | 'INVALID_PARENT' | 'MISSING_OPTION' + | 'NO_TOKEN' | 'OAUTH_CALLBACK_PORT_INVALID' | 'OAUTH_CALLBACK_SERVER_FAILED' | 'OAUTH_CLIENT_ID_REQUIRED' diff --git a/src/lib/skills/content.ts b/src/lib/skills/content.ts index 38895ff..23af2bd 100644 --- a/src/lib/skills/content.ts +++ b/src/lib/skills/content.ts @@ -79,7 +79,9 @@ ol auth login --callback-port # Override local OAuth callback port ol auth login --read-only # Request read-only scopes (where supported by the Outline instance) ol auth login --json | --ndjson # Machine-readable success envelope ol auth status # Show current auth state +ol auth status --json | --ndjson # Machine-readable status envelope ({id, name, email, team, baseUrl, source}) ol auth logout # Clear saved credentials +ol auth logout --json | --ndjson # Machine-readable logout envelope ({ok: true}; --ndjson is silent) \`\`\` ### Update & Changelog From 5f5910fa747c56b77e39aece966d9ff5c0b13a55 Mon Sep 17 00:00:00 2001 From: Scott Lovegrove Date: Sat, 16 May 2026 09:40:11 +0100 Subject: [PATCH 2/2] review: address PR #72 feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Restore env-token support: `OutlineTokenStore.active()` now returns a placeholder snapshot when `OUTLINE_API_TOKEN` is set, so `attachStatusCommand` / `attachLogoutCommand` work end-to-end for env-only users. `fetchLive` re-derives the canonical account from `auth.info` so the placeholder id/label are never rendered. - Drop PII (`name`, `email`) from the `auth status --json` / `--ndjson` envelope per the Internal AI Tools standard. Payload is now `{id, team, baseUrl, source}`. - Slim the `statusData` stash to the two fields that don't round-trip through `account` (`email` for human render, `source` for both modes). Renderers read shared fields from `account` directly. - Derive `source` from `process.env.OUTLINE_API_TOKEN` instead of an extra `getTokenSource()` disk read — `fetchLive` already proved the token works, the only remaining question is env vs config. - Export `AuthInfoResponse` from `auth-provider.ts` and drop the duplicate in `auth.ts`. - Narrow the error in `fetchLive`'s 401 branch with `instanceof Error` rather than a forced `as Error` cast. - Add status + logout integration tests in `auth-command.test.ts` covering: human / `--json` / `--ndjson` renders, 401 → `NO_TOKEN` translation, `NOT_AUTHENTICATED` when nothing is stored, and the three logout output modes. - Regenerate `skills/outline-cli/SKILL.md` for the trimmed JSON payload. Co-Authored-By: Claude Opus 4.7 (1M context) --- skills/outline-cli/SKILL.md | 2 +- src/__tests__/auth-command.test.ts | 168 ++++++++++++++++++++++++++++- src/commands/auth.ts | 39 +++---- src/lib/auth-provider.ts | 19 +++- src/lib/skills/content.ts | 2 +- 5 files changed, 202 insertions(+), 28 deletions(-) diff --git a/skills/outline-cli/SKILL.md b/skills/outline-cli/SKILL.md index e2579f2..f8d3f7c 100644 --- a/skills/outline-cli/SKILL.md +++ b/skills/outline-cli/SKILL.md @@ -80,7 +80,7 @@ ol auth login --callback-port # Override local OAuth callback port ol auth login --read-only # Request read-only scopes (where supported by the Outline instance) ol auth login --json | --ndjson # Machine-readable success envelope ol auth status # Show current auth state -ol auth status --json | --ndjson # Machine-readable status envelope ({id, name, email, team, baseUrl, source}) +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) ``` diff --git a/src/__tests__/auth-command.test.ts b/src/__tests__/auth-command.test.ts index e76476d..1b2016f 100644 --- a/src/__tests__/auth-command.test.ts +++ b/src/__tests__/auth-command.test.ts @@ -1,5 +1,5 @@ import { Command } from 'commander' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('../lib/auth.js', () => ({ getApiToken: async () => 'test-token', @@ -11,8 +11,17 @@ vi.mock('../lib/auth.js', () => ({ vi.mock('../lib/api.js', () => ({ apiRequest: vi.fn() })) +vi.mock('../lib/config.js', () => ({ + getConfig: vi.fn(async () => ({})), + setConfig: vi.fn(), + updateConfig: vi.fn(), + getConfigPath: () => '/tmp/outline-cli-test-config.json', +})) + // Stub cli-core's `attachLoginCommand` so we can inspect the surface contract // (chained flags, env-driven port, success hook) without running the flow. +// `attachStatusCommand` and `attachLogoutCommand` fall through to the real +// cli-core implementations so the integration is exercised end-to-end. vi.mock('@doist/cli-core/auth', async () => ({ ...(await vi.importActual('@doist/cli-core/auth')), attachLoginCommand: vi.fn(), @@ -26,12 +35,25 @@ async function captureAttachOptions() { const program = new Command() program.exitOverride() registerAuthCommand(program) - return { options: vi.mocked(attachLoginCommand).mock.calls[0][1], login } + return { options: vi.mocked(attachLoginCommand).mock.calls[0][1], login, program } } +async function buildProgram(): Promise { + const { program } = await captureAttachOptions() + return program +} + +beforeEach(() => { + vi.resetModules() + delete process.env.OUTLINE_API_TOKEN + delete process.env.OUTLINE_URL +}) + afterEach(() => { vi.clearAllMocks() delete process.env.OUTLINE_OAUTH_CALLBACK_PORT + delete process.env.OUTLINE_API_TOKEN + delete process.env.OUTLINE_URL }) describe('registerAuthCommand', () => { @@ -69,3 +91,145 @@ describe('registerAuthCommand', () => { expect(options.preferredPort).toBe(54969) }) }) + +describe('auth status subcommand', () => { + const AUTH_INFO = { + user: { id: 'user-uuid', name: 'Ada Lovelace', email: 'ada@example.com' }, + team: { name: 'Analytics', subdomain: 'analytics' }, + } + + async function importApiMock() { + const { apiRequest } = await import('../lib/api.js') + return vi.mocked(apiRequest) + } + + it('renders the human status from the env-token snapshot path', async () => { + process.env.OUTLINE_API_TOKEN = 'env-token' + const logs: string[] = [] + vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { + logs.push(args.join(' ')) + }) + const apiRequest = await importApiMock() + apiRequest.mockResolvedValue({ data: AUTH_INFO }) + + const program = await buildProgram() + await program.parseAsync(['node', 'ol', 'auth', 'status']) + + expect(apiRequest).toHaveBeenCalledWith( + 'auth.info', + {}, + { token: 'env-token', baseUrl: 'https://test.outline.com' }, + ) + expect(logs.some((l) => l.includes('Authenticated'))).toBe(true) + expect(logs.some((l) => l.includes('Team:') && l.includes('Analytics'))).toBe(true) + expect(logs.some((l) => l.includes('Ada Lovelace') && l.includes('ada@example.com'))).toBe( + true, + ) + expect(logs.some((l) => l.includes('Token source: env'))).toBe(true) + }) + + it('emits a PII-free JSON envelope under --json', async () => { + process.env.OUTLINE_API_TOKEN = 'env-token' + const logs: string[] = [] + vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { + logs.push(args.join(' ')) + }) + const apiRequest = await importApiMock() + apiRequest.mockResolvedValue({ data: AUTH_INFO }) + + const program = await buildProgram() + await program.parseAsync(['node', 'ol', 'auth', 'status', '--json']) + + expect(logs).toHaveLength(1) + const payload = JSON.parse(logs[0]) + expect(payload).toEqual({ + id: 'user-uuid', + team: 'Analytics', + baseUrl: 'https://test.outline.com', + source: 'env', + }) + expect(payload).not.toHaveProperty('name') + expect(payload).not.toHaveProperty('email') + }) + + it('emits a single newline-free NDJSON line under --ndjson', async () => { + process.env.OUTLINE_API_TOKEN = 'env-token' + const logs: string[] = [] + vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { + logs.push(args.join(' ')) + }) + const apiRequest = await importApiMock() + apiRequest.mockResolvedValue({ data: AUTH_INFO }) + + const program = await buildProgram() + await program.parseAsync(['node', 'ol', 'auth', 'status', '--ndjson']) + + expect(logs).toHaveLength(1) + expect(logs[0]).not.toContain('\n') + expect(JSON.parse(logs[0])).toEqual({ + id: 'user-uuid', + team: 'Analytics', + baseUrl: 'https://test.outline.com', + source: 'env', + }) + }) + + it('translates a 401 from auth.info into a NO_TOKEN CliError', async () => { + process.env.OUTLINE_API_TOKEN = 'expired-token' + const apiRequest = await importApiMock() + apiRequest.mockRejectedValue(new Error('API error: 401 Unauthorized')) + + const program = await buildProgram() + await expect(program.parseAsync(['node', 'ol', 'auth', 'status'])).rejects.toMatchObject({ + code: 'NO_TOKEN', + }) + }) + + it('throws NOT_AUTHENTICATED when no token is stored at all', async () => { + const program = await buildProgram() + await expect(program.parseAsync(['node', 'ol', 'auth', 'status'])).rejects.toMatchObject({ + code: 'NOT_AUTHENTICATED', + }) + }) +}) + +describe('auth logout subcommand', () => { + it('clears the token and prints the registrar success line', async () => { + const logs: string[] = [] + vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { + logs.push(args.join(' ')) + }) + const { clearConfig } = await import('../lib/auth.js') + + const program = await buildProgram() + await program.parseAsync(['node', 'ol', 'auth', 'logout']) + + expect(clearConfig).toHaveBeenCalledTimes(1) + expect(logs).toContain('✓ Logged out') + }) + + it('emits {"ok": true} under --json and skips the human success line', async () => { + const logs: string[] = [] + vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { + logs.push(args.join(' ')) + }) + + const program = await buildProgram() + await program.parseAsync(['node', 'ol', 'auth', 'logout', '--json']) + + expect(logs).toHaveLength(1) + expect(JSON.parse(logs[0])).toEqual({ ok: true }) + }) + + it('stays silent on stdout under --ndjson', async () => { + const logs: string[] = [] + vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { + logs.push(args.join(' ')) + }) + + const program = await buildProgram() + await program.parseAsync(['node', 'ol', 'auth', 'logout', '--ndjson']) + + expect(logs).toEqual([]) + }) +}) diff --git a/src/commands/auth.ts b/src/commands/auth.ts index 38bd450..8e40c0d 100644 --- a/src/commands/auth.ts +++ b/src/commands/auth.ts @@ -4,22 +4,17 @@ import type { Command } from 'commander' import { apiRequest } from '../lib/api.js' import { renderError, renderSuccess } from '../lib/auth-pages.js' import { + type AuthInfoResponse, createOutlineAuthProvider, createOutlineTokenStore, type OutlineAccount, } from '../lib/auth-provider.js' -import { getTokenSource } from '../lib/auth.js' import { CliError } from '../lib/errors.js' const DEFAULT_OAUTH_CALLBACK_PORT = 54969 -type AuthInfoResponse = { - user: { id: string; name: string; email: string } - team: { name: string; subdomain: string } -} - type StatusData = { - info: AuthInfoResponse + email: string source: 'env' | 'config' } @@ -71,15 +66,15 @@ export function registerAuthCommand(program: Command): void { description: 'Show current authentication state', async fetchLive({ token, account }) { try { - const [{ data: info }, source] = await Promise.all([ - apiRequest( - 'auth.info', - {}, - { token, baseUrl: account.baseUrl }, - ), - getTokenSource(), - ]) - statusData = { info, source: source ?? 'config' } + const { data: info } = await apiRequest( + 'auth.info', + {}, + { token, baseUrl: account.baseUrl }, + ) + statusData = { + email: info.user.email, + source: process.env.OUTLINE_API_TOKEN ? 'env' : 'config', + } return { ...account, id: info.user.id, @@ -87,7 +82,7 @@ export function registerAuthCommand(program: Command): void { teamName: info.team.name, } } catch (err) { - const message = (err as Error).message ?? '' + const message = err instanceof Error ? err.message : '' if (/\b401\b/.test(message) || /Authentication required/i.test(message)) { throw new CliError('NO_TOKEN', 'Not authenticated (token expired or invalid)', [ 'Run `ol auth login` to re-authenticate', @@ -100,8 +95,8 @@ export function registerAuthCommand(program: Command): void { if (!statusData) throw new Error('status renderText called before fetchLive') return [ `${chalk.green('✓')} Authenticated`, - ` Team: ${chalk.bold(statusData.info.team.name)}`, - ` User: ${statusData.info.user.name} (${statusData.info.user.email})`, + ` Team: ${chalk.bold(account.teamName ?? '')}`, + ` User: ${account.label} (${statusData.email})`, ` Base URL: ${account.baseUrl}`, ` Token source: ${statusData.source}`, ] @@ -109,10 +104,8 @@ export function registerAuthCommand(program: Command): void { renderJson({ account }) { if (!statusData) throw new Error('status renderJson called before fetchLive') return { - id: statusData.info.user.id, - name: statusData.info.user.name, - email: statusData.info.user.email, - team: statusData.info.team.name, + id: account.id, + team: account.teamName, baseUrl: account.baseUrl, source: statusData.source, } diff --git a/src/lib/auth-provider.ts b/src/lib/auth-provider.ts index 4a860d2..4254e40 100644 --- a/src/lib/auth-provider.ts +++ b/src/lib/auth-provider.ts @@ -15,7 +15,7 @@ import { CliError } from './errors.js' const DEFAULT_BASE_URL = 'https://app.getoutline.com' -type AuthInfoResponse = { +export type AuthInfoResponse = { user: { id: string; name: string; email: string } team: { name: string; subdomain: string } } @@ -206,6 +206,23 @@ export function createOutlineTokenStore(): TokenStore { return { async active(ref?: AccountRef) { + // Env token wins per the `getApiToken` cascade. Surface it as a + // snapshot with placeholder identity fields — `status`'s + // `fetchLive` re-derives the canonical account from the API, so + // the empty id/label here are never rendered. Returning a + // snapshot here is what makes `attachStatusCommand` / + // `attachLogoutCommand` work for `OUTLINE_API_TOKEN`-only users. + const envToken = process.env.OUTLINE_API_TOKEN?.trim() + if (envToken) { + const account: OutlineAccount = { + id: '', + label: '', + baseUrl: await getBaseUrl(), + oauthClientId: '', + } + if (ref !== undefined && !matchesRef(account, ref)) throw refMismatch(ref) + return { token: envToken, account } + } const snapshot = deriveSnapshot(await getConfig()) if (ref === undefined) return snapshot if (!snapshot || !matchesRef(snapshot.account, ref)) throw refMismatch(ref) diff --git a/src/lib/skills/content.ts b/src/lib/skills/content.ts index 23af2bd..7d3aa2d 100644 --- a/src/lib/skills/content.ts +++ b/src/lib/skills/content.ts @@ -79,7 +79,7 @@ ol auth login --callback-port # Override local OAuth callback port ol auth login --read-only # Request read-only scopes (where supported by the Outline instance) ol auth login --json | --ndjson # Machine-readable success envelope ol auth status # Show current auth state -ol auth status --json | --ndjson # Machine-readable status envelope ({id, name, email, team, baseUrl, source}) +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) \`\`\`