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
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@
"node": ">=20.18.1"
},
"dependencies": {
"@doist/cli-core": "0.19.0",
"@doist/cli-core": "0.23.0",
"chalk": "5.6.2",
"commander": "14.0.2",
"marked": "18.0.3",
Expand Down
4 changes: 4 additions & 0 deletions skills/outline-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ All list commands support:
- `--ndjson` - Newline-delimited JSON (streaming)
- `--full` - Include all fields in JSON

## Global Options

- `--user <id|name>` - Act as a specific stored account, matched by Outline user ID or display name. Each `ol auth login` stores an account (accounts can live on different Outline instances), and `--user` selects which one a command runs as; the token, base URL, and OAuth client ID all resolve from that account. Must be placed **before** the command, e.g. `ol --user scott@example.com doc list`. Omitted, commands use the default account. Overridden by `OUTLINE_API_TOKEN` when set.

## Document References

Documents can be referenced by:
Expand Down
27 changes: 27 additions & 0 deletions src/_fixtures/auth.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { MigrateAuthResult } from '@doist/cli-core/auth'
import type { Config } from '../lib/config.js'
import type { OutlineAccount } from '../lib/outline-account.js'

/** Canonical persisted `OutlineAccount` used across auth tests. */
Expand Down Expand Up @@ -36,6 +37,32 @@ export const AUTH_INFO = {
team: { name: 'Analytics', subdomain: 'analytics' },
} as const

/**
* Two v2 accounts on different Outline instances — Ada is the default, Bob the
* secondary — for exercising the `--user` selector. Tokens are plaintext
* fallbacks so the store resolves them without a live keyring.
*/
export const TWO_USER_CONFIG: Config = {
config_version: 2,
users: [
{
id: 'id-ada',
name: 'Ada',
base_url: 'https://ada.example.com',
oauth_client_id: 'cid-ada',
token: 'tok-ada',
},
{
id: 'id-bob',
name: 'Bob',
base_url: 'https://bob.example.com',
oauth_client_id: 'cid-bob',
token: 'tok-bob',
},
],
default_user_id: 'id-ada',
}

/** Stand-in for a cli-core `migrateLegacyAuth` skip result. */
export const SKIPPED_RESULT: MigrateAuthResult<OutlineAccount> = {
status: 'skipped',
Expand Down
35 changes: 34 additions & 1 deletion src/commands/auth-command.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Command } from 'commander'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { AUTH_INFO } from '../_fixtures/auth.js'
import { AUTH_INFO, TWO_USER_CONFIG } from '../_fixtures/auth.js'

vi.mock('../lib/auth.js', () => ({
getApiToken: async () => 'test-token',
Expand Down Expand Up @@ -71,6 +71,9 @@ afterEach(() => {
delete process.env.OUTLINE_OAUTH_CALLBACK_PORT
delete process.env.OUTLINE_API_TOKEN
delete process.env.OUTLINE_URL
// Reset argv so a `--user` set by one test can't leak into the next via
// the (real) global-args parser.
process.argv = ['node', 'ol']
})

describe('registerAuthCommand', () => {
Expand Down Expand Up @@ -176,6 +179,36 @@ describe('auth status subcommand', () => {
})
})

it('honors a global --user via the wrapped store (routes to that account instance)', async () => {
// Exercises the real ref-aware store wiring (not a fake): a global
// `--user` before the command must reach `attachStatusCommand`'s store
// and resolve the named account, not the default. Guards against a
// regression where `registerAuthCommand` passes the raw store.
const { getConfig } = await import('../lib/config.js')
vi.mocked(getConfig).mockResolvedValue(TWO_USER_CONFIG)
const { resetGlobalArgs } = await import('../lib/global-args.js')
process.argv = ['node', 'ol', '--user', 'Bob', 'auth', 'status']
resetGlobalArgs()

const apiRequest = await importApiMock()
apiRequest.mockResolvedValue({ data: AUTH_INFO })

const program = await buildProgram()
await program.parseAsync(['node', 'ol', 'auth', 'status'])

// The probe used Bob's token + instance — proof the global --user flowed
// through the wrapped store rather than defaulting to Ada.
expect(apiRequest).toHaveBeenCalledWith(
'auth.info',
{},
{ token: 'tok-bob', baseUrl: 'https://bob.example.com' },
)

// `vi.clearAllMocks()` only clears calls, not implementations, so restore
// the config default to keep this override from leaking into later tests.
vi.mocked(getConfig).mockResolvedValue({})
})

it('translates a 401 from auth.info into a NO_TOKEN CliError', async () => {
process.env.OUTLINE_API_TOKEN = 'expired-token'
const apiRequest = await importApiMock()
Expand Down
15 changes: 12 additions & 3 deletions src/commands/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
} from '../lib/auth-provider.js'
import { refreshedTokenForStatus } from '../lib/auth.js'
import { CliError } from '../lib/errors.js'
import { withUserRefAware } from '../lib/user-ref-store.js'

const DEFAULT_OAUTH_CALLBACK_PORT = 54969

Expand Down Expand Up @@ -64,6 +65,10 @@ export function registerAuthCommand(program: Command): void {

const provider = createOutlineAuthProvider()
const store: OutlineTokenStore = createOutlineTokenStore()
// Honours a global `ol --user <ref>` placed before `auth status` / `auth
// logout`; login always targets the freshly authenticated account, so it
// keeps the raw store.
const refAware = withUserRefAware(store)

attachLoginCommand(auth, {
provider,
Expand Down Expand Up @@ -103,7 +108,7 @@ export function registerAuthCommand(program: Command): void {
let statusData: StatusData | null = null

attachStatusCommand<OutlineAccount>(auth, {
store,
store: refAware,
Comment thread
scottlovegrove marked this conversation as resolved.
description: 'Show current authentication state',
async fetchLive({ account, token }) {
try {
Expand All @@ -120,7 +125,11 @@ export function registerAuthCommand(program: Command): void {
{},
{ token: liveToken, baseUrl: account.baseUrl },
),
getActiveTokenSource(),
// Scope the source to the selected account so `auth status
// --user <ref>` reports where *that* account's token lives,
// not the default/env source. Empty id (env/legacy snapshot)
// falls back to the default cascade.
getActiveTokenSource(account.id || undefined),
])
statusData = { email: info.user.email, source }
return {
Expand Down Expand Up @@ -164,7 +173,7 @@ export function registerAuthCommand(program: Command): void {
})

attachLogoutCommand<OutlineAccount>(auth, {
store,
store: refAware,
description: 'Clear saved authentication',
onCleared({ view }) {
const result = store.getLastClearResult()
Expand Down
22 changes: 18 additions & 4 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { registerSearchCommand } from './commands/search.js'
import { registerSkillCommand } from './commands/skill.js'
import { registerUpdateCommand } from './commands/update/index.js'
import { BaseCliError } from './lib/errors.js'
import { isJsonMode } from './lib/global-args.js'
import { applyUserSelector, isJsonMode } from './lib/global-args.js'
import { formatError, formatErrorJson } from './lib/output.js'

const program = new Command()
Expand All @@ -23,6 +23,10 @@ program
.addHelpText(
'after',
`
Global options:
--user <id|name> Act as a specific stored account (place before the command,
e.g. \`ol --user scott@example.com document list\`).

Note for AI/LLM agents:
Use --json or --ndjson flags for unambiguous, parseable output.
Default JSON shows essential fields; use --full for all fields.`,
Expand All @@ -36,11 +40,21 @@ registerSkillCommand(program)
registerChangelogCommand(program)
registerUpdateCommand(program)

program.parseAsync().catch((err: Error) => {
function reportError(err: unknown): never {
if (err instanceof BaseCliError) {
console.error(isJsonMode() ? formatErrorJson(err) : formatError(err))
} else {
console.error(err.message)
console.error(err instanceof Error ? err.message : String(err))
}
process.exit(1)
})
}

// Commander has no root `--user` option, so validate it and strip it from argv
// before parsing (see `applyUserSelector` for the warm-cache-then-strip order).
try {
applyUserSelector(new Set(program.commands.map((c) => c.name())))
} catch (err) {
reportError(err)
}

program.parseAsync().catch(reportError)
6 changes: 4 additions & 2 deletions src/lib/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

const authMocks = vi.hoisted(() => ({
getApiToken: vi.fn(async () => 'test-token'),
getBaseUrl: vi.fn(async () => 'https://test.outline.com'),
getRequestContext: vi.fn(async () => ({ baseUrl: 'https://test.outline.com' })),
proactiveRefresh: vi.fn(async () => undefined),
reactiveRefresh: vi.fn(async () => false),
}))
Expand All @@ -17,7 +17,9 @@ describe('apiRequest', () => {
beforeEach(() => {
delete process.env.OUTLINE_API_TOKEN
authMocks.getApiToken.mockReset().mockResolvedValue('test-token')
authMocks.getBaseUrl.mockReset().mockResolvedValue('https://test.outline.com')
authMocks.getRequestContext
.mockReset()
.mockResolvedValue({ baseUrl: 'https://test.outline.com' })
authMocks.proactiveRefresh.mockReset().mockResolvedValue(undefined)
authMocks.reactiveRefresh.mockReset().mockResolvedValue(false)
})
Expand Down
35 changes: 26 additions & 9 deletions src/lib/api.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { fetchWithRetry } from '../transport/fetch-with-retry.js'
import { TOKEN_ENV_VAR } from './auth-constants.js'
import { getApiToken, getBaseUrl, proactiveRefresh, reactiveRefresh } from './auth.js'
import { getApiToken, getRequestContext, proactiveRefresh, reactiveRefresh } from './auth.js'
import { type SpinnerOptions, withSpinner } from './spinner.js'

type RefreshHandshake = { baseUrl: string; clientId: string }

/**
* Spinner configuration mapping API paths to spinner options.
* Blue for read operations, green for creates, yellow for updates/deletes.
Expand Down Expand Up @@ -58,11 +60,16 @@ export type ApiRequestOverrides = {
* On the managed path, prefer the token `proactiveRefresh` resolved (rotated
* or current) so unrefreshable/access-only accounts stay on a single store
* read; only fall back to `getApiToken` when proactive refresh bows out.
* `handshake` pins the refresh to the `--user` account's instance.
*/
async function resolveRequestToken(managed: boolean, override?: string): Promise<string> {
async function resolveRequestToken(
managed: boolean,
override?: string,
handshake?: RefreshHandshake,
): Promise<string> {
if (override) return override
if (managed) {
const refreshed = await proactiveRefresh()
const refreshed = await proactiveRefresh(handshake)
if (refreshed) return refreshed
}
return getApiToken()
Expand All @@ -80,11 +87,21 @@ async function rawApiRequest<T>(
// override or the `OUTLINE_API_TOKEN` env var is taken as-is.
const managed = !overrides.token && !process.env[TOKEN_ENV_VAR]?.trim()

// Resolve the base URL and the (proactively refreshed) token in parallel.
const [resolvedBaseUrl, resolvedToken] = await Promise.all([
overrides.baseUrl ? Promise.resolve(overrides.baseUrl.replace(/\/$/, '')) : getBaseUrl(),
resolveRequestToken(managed, overrides.token),
])
// A caller-supplied base URL (login validate, auth status) skips account
// resolution — those paths pass an explicit token, so no refresh runs.
// Otherwise resolve the request's base URL and, for a `--user` account, the
// refresh handshake that keeps rotation pinned to that account's instance.
let resolvedBaseUrl: string
let handshake: RefreshHandshake | undefined
if (overrides.baseUrl) {
resolvedBaseUrl = overrides.baseUrl.replace(/\/$/, '')
} else {
const ctx = await getRequestContext()
resolvedBaseUrl = ctx.baseUrl
handshake = ctx.handshake
}

const resolvedToken = await resolveRequestToken(managed, overrides.token, handshake)

const performRequest = (token: string) =>
fetchWithRetry({
Expand All @@ -104,7 +121,7 @@ async function rawApiRequest<T>(
// Reactive path: a 401 on a managed token triggers a forced rotation and
// a single retry. `reactiveRefresh` throws `NoTokenError` when the refresh
// token is gone, so an unrecoverable 401 surfaces the re-login hint.
if (res.status === 401 && managed && (await reactiveRefresh())) {
if (res.status === 401 && managed && (await reactiveRefresh(handshake))) {
res = await performRequest(await getApiToken())
}

Expand Down
Loading
Loading