Skip to content
Open
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
2 changes: 2 additions & 0 deletions skills/outline-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,9 @@ ol auth login --callback-port <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, 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
Expand Down
168 changes: 166 additions & 2 deletions src/__tests__/auth-command.test.ts
Original file line number Diff line number Diff line change
@@ -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',
Expand All @@ -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<typeof import('@doist/cli-core/auth')>('@doist/cli-core/auth')),
attachLoginCommand: vi.fn(),
Expand All @@ -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<Command> {
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', () => {
Expand Down Expand Up @@ -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([])
})
})
109 changes: 69 additions & 40 deletions src/commands/auth.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,21 @@
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 {
type AuthInfoResponse,
createOutlineAuthProvider,
createOutlineTokenStore,
type OutlineAccount,
} from '../lib/auth-provider.js'
import { CliError } from '../lib/errors.js'

const DEFAULT_OAUTH_CALLBACK_PORT = 54969

type AuthInfoResponse = {
user: { name: string; email: string }
team: { name: string; subdomain: string }
type StatusData = {
email: string
source: 'env' | 'config'
}

function resolvePreferredCallbackPort(): number {
Expand Down Expand Up @@ -52,42 +56,67 @@ 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<OutlineAccount>(auth, {
Comment thread
scottlovegrove marked this conversation as resolved.
Comment thread
scottlovegrove marked this conversation as resolved.
store,
description: 'Show current authentication state',
async fetchLive({ token, account }) {
try {
const { data } = await apiRequest<AuthInfoResponse>('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 } = await apiRequest<AuthInfoResponse>(
'auth.info',
{},
{ token, baseUrl: account.baseUrl },
)
process.exit(1)
statusData = {
email: info.user.email,
source: process.env.OUTLINE_API_TOKEN ? 'env' : 'config',
}
return {
...account,
id: info.user.id,
label: info.user.name,
teamName: info.team.name,
}
} catch (err) {
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',
])
}
throw err
}
})
},
renderText({ account }) {
if (!statusData) throw new Error('status renderText called before fetchLive')
return [
`${chalk.green('✓')} Authenticated`,
` Team: ${chalk.bold(account.teamName ?? '')}`,
` User: ${account.label} (${statusData.email})`,
` Base URL: ${account.baseUrl}`,
` Token source: ${statusData.source}`,
]
},
renderJson({ account }) {
if (!statusData) throw new Error('status renderJson called before fetchLive')
return {
id: account.id,
team: account.teamName,
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<OutlineAccount>(auth, {
Comment thread
scottlovegrove marked this conversation as resolved.
store,
description: 'Clear saved authentication',
})
}
19 changes: 18 additions & 1 deletion src/lib/auth-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
}
Expand Down Expand Up @@ -206,6 +206,23 @@ export function createOutlineTokenStore(): TokenStore<OutlineAccount> {

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)
Expand Down
1 change: 1 addition & 0 deletions src/lib/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
2 changes: 2 additions & 0 deletions src/lib/skills/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,9 @@ ol auth login --callback-port <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, 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
Expand Down
Loading