Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions skills/outline-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <token> # Save a personal API token (validates via auth.info, resolves identity)
ol auth token <token> --base-url <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 <id|name> auth token view # Print a specific stored account's token (--user is a root flag, before the command)
```

### Accounts
Expand Down
221 changes: 221 additions & 0 deletions src/commands/auth-token.test.ts
Original file line number Diff line number Diff line change
@@ -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<string>>(),
)

vi.mock('../lib/auth-provider.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../lib/auth-provider.js')>()
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<Command> {
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 () => {
Comment thread
scottlovegrove marked this conversation as resolved.
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']])
})
})
81 changes: 71 additions & 10 deletions src/commands/auth.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -33,6 +43,48 @@ function resolvePreferredCallbackPort(): number {
return parsed
}

async function saveToken(
store: OutlineTokenStore,
token: string | undefined,
options: { baseUrl?: string },
): Promise<void> {
if (!token) {
if (!process.stdin.isTTY) {
throw new CliError('NO_TOKEN', 'No token provided', [
'Pass it as an argument: ol auth token <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')

Expand All @@ -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')
Expand Down Expand Up @@ -152,4 +197,20 @@ export function registerAuthCommand(program: Command): void {
logClearResult(store, view.json || view.ndjson)
},
})

const tokenCmd = auth
.command('token [token]')
Comment thread
scottlovegrove marked this conversation as resolved.
Comment thread
scottlovegrove marked this conversation as resolved.
.description('Save an Outline API token for CLI auth (or use the `view` subcommand)')
.option('--base-url <url>', 'Outline base URL the token belongs to')
.action((token: string | undefined, options: { baseUrl?: string }) =>
saveToken(store, token, options),
)

attachTokenViewCommand<OutlineAccount>(tokenCmd, {
name: 'view',
store: refAware,
Comment thread
scottlovegrove marked this conversation as resolved.
envVarName: TOKEN_ENV_VAR,
description:
'Print the stored token for the active user (or --user <ref>) to stdout for scripts',
})
}
15 changes: 15 additions & 0 deletions src/lib/auth-output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading