Skip to content

Commit c25941c

Browse files
refactor(auth): delegate status + logout to @doist/cli-core/auth (#72)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 8b7d747 commit c25941c

6 files changed

Lines changed: 258 additions & 43 deletions

File tree

skills/outline-cli/SKILL.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,9 @@ ol auth login --callback-port <port> # Override local OAuth callback port
8080
ol auth login --read-only # Request read-only scopes (where supported by the Outline instance)
8181
ol auth login --json | --ndjson # Machine-readable success envelope
8282
ol auth status # Show current auth state
83+
ol auth status --json | --ndjson # Machine-readable status envelope ({id, team, baseUrl, source})
8384
ol auth logout # Clear saved credentials
85+
ol auth logout --json | --ndjson # Machine-readable logout envelope ({ok: true}; --ndjson is silent)
8486
```
8587

8688
### Update & Changelog

src/__tests__/auth-command.test.ts

Lines changed: 166 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Command } from 'commander'
2-
import { afterEach, describe, expect, it, vi } from 'vitest'
2+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
33

44
vi.mock('../lib/auth.js', () => ({
55
getApiToken: async () => 'test-token',
@@ -11,8 +11,17 @@ vi.mock('../lib/auth.js', () => ({
1111

1212
vi.mock('../lib/api.js', () => ({ apiRequest: vi.fn() }))
1313

14+
vi.mock('../lib/config.js', () => ({
15+
getConfig: vi.fn(async () => ({})),
16+
setConfig: vi.fn(),
17+
updateConfig: vi.fn(),
18+
getConfigPath: () => '/tmp/outline-cli-test-config.json',
19+
}))
20+
1421
// Stub cli-core's `attachLoginCommand` so we can inspect the surface contract
1522
// (chained flags, env-driven port, success hook) without running the flow.
23+
// `attachStatusCommand` and `attachLogoutCommand` fall through to the real
24+
// cli-core implementations so the integration is exercised end-to-end.
1625
vi.mock('@doist/cli-core/auth', async () => ({
1726
...(await vi.importActual<typeof import('@doist/cli-core/auth')>('@doist/cli-core/auth')),
1827
attachLoginCommand: vi.fn(),
@@ -26,12 +35,25 @@ async function captureAttachOptions() {
2635
const program = new Command()
2736
program.exitOverride()
2837
registerAuthCommand(program)
29-
return { options: vi.mocked(attachLoginCommand).mock.calls[0][1], login }
38+
return { options: vi.mocked(attachLoginCommand).mock.calls[0][1], login, program }
3039
}
3140

41+
async function buildProgram(): Promise<Command> {
42+
const { program } = await captureAttachOptions()
43+
return program
44+
}
45+
46+
beforeEach(() => {
47+
vi.resetModules()
48+
delete process.env.OUTLINE_API_TOKEN
49+
delete process.env.OUTLINE_URL
50+
})
51+
3252
afterEach(() => {
3353
vi.clearAllMocks()
3454
delete process.env.OUTLINE_OAUTH_CALLBACK_PORT
55+
delete process.env.OUTLINE_API_TOKEN
56+
delete process.env.OUTLINE_URL
3557
})
3658

3759
describe('registerAuthCommand', () => {
@@ -69,3 +91,145 @@ describe('registerAuthCommand', () => {
6991
expect(options.preferredPort).toBe(54969)
7092
})
7193
})
94+
95+
describe('auth status subcommand', () => {
96+
const AUTH_INFO = {
97+
user: { id: 'user-uuid', name: 'Ada Lovelace', email: 'ada@example.com' },
98+
team: { name: 'Analytics', subdomain: 'analytics' },
99+
}
100+
101+
async function importApiMock() {
102+
const { apiRequest } = await import('../lib/api.js')
103+
return vi.mocked(apiRequest)
104+
}
105+
106+
it('renders the human status from the env-token snapshot path', async () => {
107+
process.env.OUTLINE_API_TOKEN = 'env-token'
108+
const logs: string[] = []
109+
vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => {
110+
logs.push(args.join(' '))
111+
})
112+
const apiRequest = await importApiMock()
113+
apiRequest.mockResolvedValue({ data: AUTH_INFO })
114+
115+
const program = await buildProgram()
116+
await program.parseAsync(['node', 'ol', 'auth', 'status'])
117+
118+
expect(apiRequest).toHaveBeenCalledWith(
119+
'auth.info',
120+
{},
121+
{ token: 'env-token', baseUrl: 'https://test.outline.com' },
122+
)
123+
expect(logs.some((l) => l.includes('Authenticated'))).toBe(true)
124+
expect(logs.some((l) => l.includes('Team:') && l.includes('Analytics'))).toBe(true)
125+
expect(logs.some((l) => l.includes('Ada Lovelace') && l.includes('ada@example.com'))).toBe(
126+
true,
127+
)
128+
expect(logs.some((l) => l.includes('Token source: env'))).toBe(true)
129+
})
130+
131+
it('emits a PII-free JSON envelope under --json', async () => {
132+
process.env.OUTLINE_API_TOKEN = 'env-token'
133+
const logs: string[] = []
134+
vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => {
135+
logs.push(args.join(' '))
136+
})
137+
const apiRequest = await importApiMock()
138+
apiRequest.mockResolvedValue({ data: AUTH_INFO })
139+
140+
const program = await buildProgram()
141+
await program.parseAsync(['node', 'ol', 'auth', 'status', '--json'])
142+
143+
expect(logs).toHaveLength(1)
144+
const payload = JSON.parse(logs[0])
145+
expect(payload).toEqual({
146+
id: 'user-uuid',
147+
team: 'Analytics',
148+
baseUrl: 'https://test.outline.com',
149+
source: 'env',
150+
})
151+
expect(payload).not.toHaveProperty('name')
152+
expect(payload).not.toHaveProperty('email')
153+
})
154+
155+
it('emits a single newline-free NDJSON line under --ndjson', async () => {
156+
process.env.OUTLINE_API_TOKEN = 'env-token'
157+
const logs: string[] = []
158+
vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => {
159+
logs.push(args.join(' '))
160+
})
161+
const apiRequest = await importApiMock()
162+
apiRequest.mockResolvedValue({ data: AUTH_INFO })
163+
164+
const program = await buildProgram()
165+
await program.parseAsync(['node', 'ol', 'auth', 'status', '--ndjson'])
166+
167+
expect(logs).toHaveLength(1)
168+
expect(logs[0]).not.toContain('\n')
169+
expect(JSON.parse(logs[0])).toEqual({
170+
id: 'user-uuid',
171+
team: 'Analytics',
172+
baseUrl: 'https://test.outline.com',
173+
source: 'env',
174+
})
175+
})
176+
177+
it('translates a 401 from auth.info into a NO_TOKEN CliError', async () => {
178+
process.env.OUTLINE_API_TOKEN = 'expired-token'
179+
const apiRequest = await importApiMock()
180+
apiRequest.mockRejectedValue(new Error('API error: 401 Unauthorized'))
181+
182+
const program = await buildProgram()
183+
await expect(program.parseAsync(['node', 'ol', 'auth', 'status'])).rejects.toMatchObject({
184+
code: 'NO_TOKEN',
185+
})
186+
})
187+
188+
it('throws NOT_AUTHENTICATED when no token is stored at all', async () => {
189+
const program = await buildProgram()
190+
await expect(program.parseAsync(['node', 'ol', 'auth', 'status'])).rejects.toMatchObject({
191+
code: 'NOT_AUTHENTICATED',
192+
})
193+
})
194+
})
195+
196+
describe('auth logout subcommand', () => {
197+
it('clears the token and prints the registrar success line', async () => {
198+
const logs: string[] = []
199+
vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => {
200+
logs.push(args.join(' '))
201+
})
202+
const { clearConfig } = await import('../lib/auth.js')
203+
204+
const program = await buildProgram()
205+
await program.parseAsync(['node', 'ol', 'auth', 'logout'])
206+
207+
expect(clearConfig).toHaveBeenCalledTimes(1)
208+
expect(logs).toContain('✓ Logged out')
209+
})
210+
211+
it('emits {"ok": true} under --json and skips the human success line', async () => {
212+
const logs: string[] = []
213+
vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => {
214+
logs.push(args.join(' '))
215+
})
216+
217+
const program = await buildProgram()
218+
await program.parseAsync(['node', 'ol', 'auth', 'logout', '--json'])
219+
220+
expect(logs).toHaveLength(1)
221+
expect(JSON.parse(logs[0])).toEqual({ ok: true })
222+
})
223+
224+
it('stays silent on stdout under --ndjson', async () => {
225+
const logs: string[] = []
226+
vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => {
227+
logs.push(args.join(' '))
228+
})
229+
230+
const program = await buildProgram()
231+
await program.parseAsync(['node', 'ol', 'auth', 'logout', '--ndjson'])
232+
233+
expect(logs).toEqual([])
234+
})
235+
})

src/commands/auth.ts

Lines changed: 69 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,21 @@
1-
import { attachLoginCommand } from '@doist/cli-core/auth'
1+
import { attachLoginCommand, attachLogoutCommand, attachStatusCommand } from '@doist/cli-core/auth'
22
import chalk from 'chalk'
33
import type { Command } from 'commander'
44
import { apiRequest } from '../lib/api.js'
55
import { renderError, renderSuccess } from '../lib/auth-pages.js'
6-
import { createOutlineAuthProvider, createOutlineTokenStore } from '../lib/auth-provider.js'
7-
import { clearConfig, getBaseUrl, getTokenSource } from '../lib/auth.js'
8-
import { formatError } from '../lib/output.js'
6+
import {
7+
type AuthInfoResponse,
8+
createOutlineAuthProvider,
9+
createOutlineTokenStore,
10+
type OutlineAccount,
11+
} from '../lib/auth-provider.js'
12+
import { CliError } from '../lib/errors.js'
913

1014
const DEFAULT_OAUTH_CALLBACK_PORT = 54969
1115

12-
type AuthInfoResponse = {
13-
user: { name: string; email: string }
14-
team: { name: string; subdomain: string }
16+
type StatusData = {
17+
email: string
18+
source: 'env' | 'config'
1519
}
1620

1721
function resolvePreferredCallbackPort(): number {
@@ -52,42 +56,67 @@ export function registerAuthCommand(program: Command): void {
5256
'OAuth client ID to use for this login (saved for future logins)',
5357
)
5458

55-
auth.command('status')
56-
.description('Show current authentication state')
57-
.action(async () => {
58-
const source = await getTokenSource()
59-
if (!source) {
60-
console.log(chalk.yellow('Not authenticated. Run: ol auth login'))
61-
return
62-
}
63-
64-
console.log(chalk.dim(`Token source: ${source}`))
65-
console.log(chalk.dim(`Base URL: ${await getBaseUrl()}`))
59+
// `attachStatusCommand` guarantees `fetchLive` runs before `renderText` /
60+
// `renderJson` within a single invocation, so the stash is always
61+
// populated by the time the render hooks read it.
62+
let statusData: StatusData | null = null
6663

64+
attachStatusCommand<OutlineAccount>(auth, {
65+
store,
66+
description: 'Show current authentication state',
67+
async fetchLive({ token, account }) {
6768
try {
68-
const { data } = await apiRequest<AuthInfoResponse>('auth.info')
69-
console.log(`Team: ${chalk.bold(data.team.name)}`)
70-
console.log(`User: ${data.user.name} (${data.user.email})`)
71-
} catch (err) {
72-
console.error(
73-
formatError(
74-
'AUTH_VERIFICATION_FAILED',
75-
`Could not fetch auth info: ${(err as Error).message}`,
76-
[
77-
'Check that your API token is valid',
78-
'Verify the base URL is correct',
79-
"Run 'ol auth login' to re-authenticate",
80-
],
81-
),
69+
const { data: info } = await apiRequest<AuthInfoResponse>(
70+
'auth.info',
71+
{},
72+
{ token, baseUrl: account.baseUrl },
8273
)
83-
process.exit(1)
74+
statusData = {
75+
email: info.user.email,
76+
source: process.env.OUTLINE_API_TOKEN ? 'env' : 'config',
77+
}
78+
return {
79+
...account,
80+
id: info.user.id,
81+
label: info.user.name,
82+
teamName: info.team.name,
83+
}
84+
} catch (err) {
85+
const message = err instanceof Error ? err.message : ''
86+
if (/\b401\b/.test(message) || /Authentication required/i.test(message)) {
87+
throw new CliError('NO_TOKEN', 'Not authenticated (token expired or invalid)', [
88+
'Run `ol auth login` to re-authenticate',
89+
])
90+
}
91+
throw err
8492
}
85-
})
93+
},
94+
renderText({ account }) {
95+
if (!statusData) throw new Error('status renderText called before fetchLive')
96+
return [
97+
`${chalk.green('✓')} Authenticated`,
98+
` Team: ${chalk.bold(account.teamName ?? '')}`,
99+
` User: ${account.label} (${statusData.email})`,
100+
` Base URL: ${account.baseUrl}`,
101+
` Token source: ${statusData.source}`,
102+
]
103+
},
104+
renderJson({ account }) {
105+
if (!statusData) throw new Error('status renderJson called before fetchLive')
106+
return {
107+
id: account.id,
108+
team: account.teamName,
109+
baseUrl: account.baseUrl,
110+
source: statusData.source,
111+
}
112+
},
113+
onNotAuthenticated() {
114+
throw new CliError('NOT_AUTHENTICATED', 'Not authenticated. Run: ol auth login')
115+
},
116+
})
86117

87-
auth.command('logout')
88-
.description('Clear saved authentication')
89-
.action(async () => {
90-
await clearConfig()
91-
console.log('Logged out.')
92-
})
118+
attachLogoutCommand<OutlineAccount>(auth, {
119+
store,
120+
description: 'Clear saved authentication',
121+
})
93122
}

src/lib/auth-provider.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import { CliError } from './errors.js'
1515

1616
const DEFAULT_BASE_URL = 'https://app.getoutline.com'
1717

18-
type AuthInfoResponse = {
18+
export type AuthInfoResponse = {
1919
user: { id: string; name: string; email: string }
2020
team: { name: string; subdomain: string }
2121
}
@@ -206,6 +206,23 @@ export function createOutlineTokenStore(): TokenStore<OutlineAccount> {
206206

207207
return {
208208
async active(ref?: AccountRef) {
209+
// Env token wins per the `getApiToken` cascade. Surface it as a
210+
// snapshot with placeholder identity fields — `status`'s
211+
// `fetchLive` re-derives the canonical account from the API, so
212+
// the empty id/label here are never rendered. Returning a
213+
// snapshot here is what makes `attachStatusCommand` /
214+
// `attachLogoutCommand` work for `OUTLINE_API_TOKEN`-only users.
215+
const envToken = process.env.OUTLINE_API_TOKEN?.trim()
216+
if (envToken) {
217+
const account: OutlineAccount = {
218+
id: '',
219+
label: '',
220+
baseUrl: await getBaseUrl(),
221+
oauthClientId: '',
222+
}
223+
if (ref !== undefined && !matchesRef(account, ref)) throw refMismatch(ref)
224+
return { token: envToken, account }
225+
}
209226
const snapshot = deriveSnapshot(await getConfig())
210227
if (ref === undefined) return snapshot
211228
if (!snapshot || !matchesRef(snapshot.account, ref)) throw refMismatch(ref)

src/lib/errors.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export type ErrorCode =
1010
| 'CONFLICTING_OPTIONS'
1111
| 'INVALID_PARENT'
1212
| 'MISSING_OPTION'
13+
| 'NO_TOKEN'
1314
| 'OAUTH_CALLBACK_PORT_INVALID'
1415
| 'OAUTH_CALLBACK_SERVER_FAILED'
1516
| 'OAUTH_CLIENT_ID_REQUIRED'

src/lib/skills/content.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,9 @@ ol auth login --callback-port <port> # Override local OAuth callback port
7979
ol auth login --read-only # Request read-only scopes (where supported by the Outline instance)
8080
ol auth login --json | --ndjson # Machine-readable success envelope
8181
ol auth status # Show current auth state
82+
ol auth status --json | --ndjson # Machine-readable status envelope ({id, team, baseUrl, source})
8283
ol auth logout # Clear saved credentials
84+
ol auth logout --json | --ndjson # Machine-readable logout envelope ({ok: true}; --ndjson is silent)
8385
\`\`\`
8486
8587
### Update & Changelog

0 commit comments

Comments
 (0)