diff --git a/package-lock.json b/package-lock.json index a78fc2c..b208940 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "hasInstallScript": true, "license": "MIT", "dependencies": { - "@doist/cli-core": "0.9.0", + "@doist/cli-core": "0.12.0", "chalk": "5.6.2", "commander": "14.0.2", "marked": "18.0.3", @@ -132,13 +132,13 @@ } }, "node_modules/@doist/cli-core": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/@doist/cli-core/-/cli-core-0.9.0.tgz", - "integrity": "sha512-38uTt+DSFCMuuPkdWIl9Dm7XrjGlwG15eI0DrnaKEYm+AGizzP6tY7m1AZAT5aQXAJOCU6uYXAvqaqpni+PcLg==", + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@doist/cli-core/-/cli-core-0.12.0.tgz", + "integrity": "sha512-6dk9mL+HMmkfijcO9t+AtXO+2vxV6Gj5thD91sExNJfInnmQzFhMSYOJ/8u/k5gzpD9wyVAxL+q+bzt8s9eeQw==", "license": "MIT", "dependencies": { "chalk": "5.6.2", - "yocto-spinner": "1.1.0" + "yocto-spinner": "1.2.0" }, "engines": { "node": ">=20.18.1" @@ -9956,9 +9956,9 @@ } }, "node_modules/yocto-spinner": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/yocto-spinner/-/yocto-spinner-1.1.0.tgz", - "integrity": "sha512-/BY0AUXnS7IKO354uLLA2eRcWiqDifEbd6unXCsOxkFDAkhgUL3PH9X2bFoaU0YchnDXsF+iKleeTLJGckbXfA==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/yocto-spinner/-/yocto-spinner-1.2.0.tgz", + "integrity": "sha512-Yw0hUB6UA3o4YUgKy3oSe9a4cxoaZ9sBfYDw+JSxo6Id0KoJGoxzPA24qqUXYKBWABs/zDSGTz9kww7t3F0XGw==", "license": "MIT", "dependencies": { "yoctocolors": "^2.1.1" diff --git a/package.json b/package.json index 0971913..7a9b68e 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "node": ">=20.18.1" }, "dependencies": { - "@doist/cli-core": "0.9.0", + "@doist/cli-core": "0.12.0", "chalk": "5.6.2", "commander": "14.0.2", "marked": "18.0.3", diff --git a/src/__tests__/auth-provider.test.ts b/src/__tests__/auth-provider.test.ts index 8c96615..46b8578 100644 --- a/src/__tests__/auth-provider.test.ts +++ b/src/__tests__/auth-provider.test.ts @@ -144,6 +144,16 @@ describe('OutlineTokenStore', () => { teamName: 'Analytics', } + /** Config-file shape that round-trips to `sampleAccount`. */ + const sampleConfig = { + api_token: 'tok', + base_url: sampleAccount.baseUrl, + oauth_client_id: sampleAccount.oauthClientId, + auth_user_id: sampleAccount.id, + auth_user_name: sampleAccount.label, + auth_team_name: sampleAccount.teamName, + } + it('round-trips token + account through the config file', async () => { const { createOutlineTokenStore } = await import('../lib/auth-provider.js') const store = createOutlineTokenStore() @@ -176,4 +186,100 @@ describe('OutlineTokenStore', () => { const after = JSON.parse(readFileSync(TEST_CONFIG_PATH, 'utf8')) expect(after).toEqual({ update_channel: 'pre-release' }) }) + + describe('ref-aware lookups', () => { + it('active(ref) returns the snapshot when the id ref matches', async () => { + writeFileSync(TEST_CONFIG_PATH, JSON.stringify(sampleConfig)) + const { createOutlineTokenStore } = await import('../lib/auth-provider.js') + expect(await createOutlineTokenStore().active(sampleAccount.id)).toEqual({ + token: sampleConfig.api_token, + account: sampleAccount, + }) + }) + + it('active(ref) matches the stored label case-insensitively', async () => { + writeFileSync(TEST_CONFIG_PATH, JSON.stringify(sampleConfig)) + const { createOutlineTokenStore } = await import('../lib/auth-provider.js') + expect(await createOutlineTokenStore().active('ADA')).toEqual({ + token: sampleConfig.api_token, + account: sampleAccount, + }) + }) + + it('active(ref) throws ACCOUNT_NOT_FOUND on mismatch', async () => { + writeFileSync(TEST_CONFIG_PATH, JSON.stringify(sampleConfig)) + const { createOutlineTokenStore } = await import('../lib/auth-provider.js') + await expect(createOutlineTokenStore().active('other')).rejects.toMatchObject({ + code: 'ACCOUNT_NOT_FOUND', + }) + }) + + it('active(ref) throws ACCOUNT_NOT_FOUND when no token is stored', async () => { + const { createOutlineTokenStore } = await import('../lib/auth-provider.js') + await expect(createOutlineTokenStore().active(sampleAccount.id)).rejects.toMatchObject({ + code: 'ACCOUNT_NOT_FOUND', + }) + }) + + it('clear(ref) clears the config when the ref matches', async () => { + writeFileSync( + TEST_CONFIG_PATH, + JSON.stringify({ ...sampleConfig, update_channel: 'stable' }), + ) + const { createOutlineTokenStore } = await import('../lib/auth-provider.js') + await createOutlineTokenStore().clear(sampleAccount.id) + const after = JSON.parse(readFileSync(TEST_CONFIG_PATH, 'utf8')) + expect(after).toEqual({ update_channel: 'stable' }) + }) + + it('clear(ref) throws ACCOUNT_NOT_FOUND on mismatch and does not touch storage', async () => { + writeFileSync(TEST_CONFIG_PATH, JSON.stringify(sampleConfig)) + const { createOutlineTokenStore } = await import('../lib/auth-provider.js') + await expect(createOutlineTokenStore().clear('other')).rejects.toMatchObject({ + code: 'ACCOUNT_NOT_FOUND', + }) + const after = JSON.parse(readFileSync(TEST_CONFIG_PATH, 'utf8')) + expect(after).toEqual(sampleConfig) + }) + + it('clear(ref) throws ACCOUNT_NOT_FOUND when no token is stored at all', async () => { + // Guards against a regression to the old silent no-op when the + // store is empty — `attachLogoutCommand` would otherwise emit + // ✓ Logged out for a ref that never had any backing account. + const { createOutlineTokenStore } = await import('../lib/auth-provider.js') + await expect(createOutlineTokenStore().clear(sampleAccount.id)).rejects.toMatchObject({ + code: 'ACCOUNT_NOT_FOUND', + }) + expect(existsSync(TEST_CONFIG_PATH)).toBe(false) + }) + + it('list() returns the stored account flagged as default', async () => { + writeFileSync(TEST_CONFIG_PATH, JSON.stringify(sampleConfig)) + const { createOutlineTokenStore } = await import('../lib/auth-provider.js') + expect(await createOutlineTokenStore().list()).toEqual([ + { account: sampleAccount, isDefault: true }, + ]) + }) + + it('list() returns an empty array when no token is stored', async () => { + const { createOutlineTokenStore } = await import('../lib/auth-provider.js') + expect(await createOutlineTokenStore().list()).toEqual([]) + }) + + it('setDefault(ref) resolves silently when the ref matches', async () => { + writeFileSync(TEST_CONFIG_PATH, JSON.stringify(sampleConfig)) + const { createOutlineTokenStore } = await import('../lib/auth-provider.js') + await expect( + createOutlineTokenStore().setDefault(sampleAccount.id), + ).resolves.toBeUndefined() + }) + + it('setDefault(ref) throws ACCOUNT_NOT_FOUND when the ref does not match', async () => { + writeFileSync(TEST_CONFIG_PATH, JSON.stringify(sampleConfig)) + const { createOutlineTokenStore } = await import('../lib/auth-provider.js') + await expect(createOutlineTokenStore().setDefault('other')).rejects.toMatchObject({ + code: 'ACCOUNT_NOT_FOUND', + }) + }) + }) }) diff --git a/src/lib/auth-provider.ts b/src/lib/auth-provider.ts index 25b9272..4a860d2 100644 --- a/src/lib/auth-provider.ts +++ b/src/lib/auth-provider.ts @@ -1,5 +1,6 @@ import { createInterface } from 'node:readline/promises' import { + type AccountRef, type AuthAccount, type AuthProvider, deriveChallenge, @@ -9,7 +10,8 @@ import { import { fetchWithRetry } from '../transport/fetch-with-retry.js' import { apiRequest } from './api.js' import { clearConfig, getBaseUrl, getOAuthClientId } from './auth.js' -import { getConfig, updateConfig } from './config.js' +import { type Config, getConfig, updateConfig } from './config.js' +import { CliError } from './errors.js' const DEFAULT_BASE_URL = 'https://app.getoutline.com' @@ -160,29 +162,54 @@ export function createOutlineAuthProvider(): AuthProvider { } export function createOutlineTokenStore(): TokenStore { + /** + * Derive a snapshot from an already-loaded config. Pure, so ref-aware + * callers can validate without a second config read. + */ + function deriveSnapshot( + config: Partial, + ): { token: string; account: OutlineAccount } | null { + if (!config.api_token) return null + const id = config.auth_user_id + const label = config.auth_user_name + if (!id || !label) { + // Stored token predates this adapter (env var, pre-upgrade + // config). No persisted identity to round-trip. + return null + } + return { + token: config.api_token, + account: { + id, + label, + baseUrl: config.base_url ?? DEFAULT_BASE_URL, + oauthClientId: config.oauth_client_id ?? '', + teamName: config.auth_team_name, + }, + } + } + + /** + * Match the stored account against `--user `. Outline accounts use + * UUID ids and a display name — id matches are case-sensitive (UUIDs + * are canonical), label matches are case-insensitive so users can pass + * the name they see in `auth status` regardless of casing. + */ + function matchesRef(account: OutlineAccount, ref: AccountRef): boolean { + if (account.id === ref) return true + return account.label.toLowerCase() === ref.toLowerCase() + } + + function refMismatch(ref: AccountRef): CliError { + return new CliError('ACCOUNT_NOT_FOUND', `No stored account matches "${ref}".`) + } + return { - async active() { - const config = await getConfig() - if (!config.api_token) return null - const baseUrl = config.base_url ?? DEFAULT_BASE_URL - const oauthClientId = config.oauth_client_id ?? '' - const id = config.auth_user_id - const label = config.auth_user_name - if (!id || !label) { - // Stored token predates this adapter (env var, pre-upgrade - // config). No persisted identity to round-trip. - return null - } - return { - token: config.api_token, - account: { - id, - label, - baseUrl, - oauthClientId, - teamName: config.auth_team_name, - }, - } + async active(ref?: AccountRef) { + const snapshot = deriveSnapshot(await getConfig()) + if (ref === undefined) return snapshot + if (!snapshot || !matchesRef(snapshot.account, ref)) throw refMismatch(ref) + return snapshot }, async set(account, token) { await updateConfig({ @@ -194,8 +221,27 @@ export function createOutlineTokenStore(): TokenStore { auth_team_name: account.teamName, }) }, - async clear() { - await clearConfig() + async clear(ref?: AccountRef) { + // With `ref`, validate before touching storage so a mismatch is + // an `ACCOUNT_NOT_FOUND` error rather than a silent success — + // `attachLogoutCommand` treats any non-throwing `clear()` as + // success. Load the config once and hand it to `clearConfig` + // so ref-based logout stays at one read + one write. + const config = await getConfig() + if (ref !== undefined) { + const snapshot = deriveSnapshot(config) + if (!snapshot || !matchesRef(snapshot.account, ref)) throw refMismatch(ref) + } + await clearConfig(config) + }, + async list() { + const snapshot = deriveSnapshot(await getConfig()) + return snapshot ? [{ account: snapshot.account, isDefault: true }] : [] + }, + async setDefault(ref: AccountRef) { + const snapshot = deriveSnapshot(await getConfig()) + if (!snapshot || !matchesRef(snapshot.account, ref)) throw refMismatch(ref) + // Single-user store — already the default once `ref` matches. }, } } diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 21fc7a7..1077fab 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -41,9 +41,13 @@ export async function getTokenSource(): Promise<'env' | 'config' | null> { * Clear the auth-related keys without deleting the file. The config is now * shared with non-auth settings (notably `update_channel`); a blanket unlink * would silently reset the user's update-channel preference too. + * + * Pass `existing` to skip the read when the caller has already loaded the + * config (e.g. ref-validated logout) — keeps that flow at one read + one + * write instead of two reads. */ -export async function clearConfig(): Promise { - const existing = await getConfig() +export async function clearConfig(existing?: Partial): Promise { + const config = existing ?? (await getConfig()) const { api_token, base_url, @@ -52,7 +56,7 @@ export async function clearConfig(): Promise { auth_user_name, auth_team_name, ...rest - } = existing + } = config void api_token void base_url void oauth_client_id diff --git a/src/lib/errors.ts b/src/lib/errors.ts index 2f4ae4a..c3c6cbd 100644 --- a/src/lib/errors.ts +++ b/src/lib/errors.ts @@ -4,6 +4,7 @@ export { CoreCliError as BaseCliError } export type { ErrorType } from '@doist/cli-core' export type ErrorCode = + | 'ACCOUNT_NOT_FOUND' | 'AUTH_VERIFICATION_FAILED' | 'CONFIRMATION_REQUIRED' | 'CONFLICTING_OPTIONS'