From 4b938bcbaadd9cc8fe013cc2a17cab4dccb720ac Mon Sep 17 00:00:00 2001 From: Scott Lovegrove Date: Fri, 15 May 2026 16:17:29 +0100 Subject: [PATCH 1/2] feat(auth): adopt cli-core 0.12.0 multi-user TokenStore shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bump @doist/cli-core to 0.12.0 and reshape OutlineTokenStore to the new uniformly-multi-user TokenStore contract: - `active(ref?)` / `clear(ref?)` honour the optional AccountRef. With a ref, ref-mismatch throws `ACCOUNT_NOT_FOUND` (via a shared `resolveByRef` helper) instead of silently returning null / no-op-ing, so `auth status --user ` / `auth logout --user ` will surface a real error rather than the misleading "Not authenticated" / silent ✓ Logged out. - New `list()` returns the single stored account flagged as default; empty array when nothing is stored. - New `setDefault(ref)` validates the ref against the stored account. Ref matching: exact id (UUID, case-sensitive) or case-insensitive label. Registers `ACCOUNT_NOT_FOUND` in the local `ErrorCode` union and adds ref-aware coverage in `src/__tests__/auth-provider.test.ts`. Storage backend stays single-slot — this is contract compliance so the upstream `--user ` flag attached by `attachLogoutCommand` / `attachStatusCommand` / `attachTokenViewCommand` (when wired) lands cleanly. Multi-account storage is a future PR. Co-Authored-By: Claude Opus 4.7 (1M context) --- package-lock.json | 16 ++--- package.json | 2 +- src/__tests__/auth-provider.test.ts | 99 +++++++++++++++++++++++++++++ src/lib/auth-provider.ts | 95 ++++++++++++++++++++------- src/lib/errors.ts | 1 + 5 files changed, 181 insertions(+), 32 deletions(-) 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..0305808 100644 --- a/src/__tests__/auth-provider.test.ts +++ b/src/__tests__/auth-provider.test.ts @@ -176,4 +176,103 @@ describe('OutlineTokenStore', () => { const after = JSON.parse(readFileSync(TEST_CONFIG_PATH, 'utf8')) expect(after).toEqual({ update_channel: 'pre-release' }) }) + + describe('ref-aware lookups', () => { + const STORED_CONFIG = { + api_token: 'tok', + base_url: 'https://wiki.example.com', + oauth_client_id: 'cid-xyz', + auth_user_id: 'user-uuid', + auth_user_name: 'Ada', + auth_team_name: 'Analytics', + } + const STORED_ACCOUNT = { + id: 'user-uuid', + label: 'Ada', + baseUrl: 'https://wiki.example.com', + oauthClientId: 'cid-xyz', + teamName: 'Analytics', + } + + it('active(ref) returns the snapshot when the id ref matches', async () => { + writeFileSync(TEST_CONFIG_PATH, JSON.stringify(STORED_CONFIG)) + const { createOutlineTokenStore } = await import('../lib/auth-provider.js') + expect(await createOutlineTokenStore().active('user-uuid')).toEqual({ + token: 'tok', + account: STORED_ACCOUNT, + }) + }) + + it('active(ref) matches the stored label case-insensitively', async () => { + writeFileSync(TEST_CONFIG_PATH, JSON.stringify(STORED_CONFIG)) + const { createOutlineTokenStore } = await import('../lib/auth-provider.js') + expect(await createOutlineTokenStore().active('ADA')).toEqual({ + token: 'tok', + account: STORED_ACCOUNT, + }) + }) + + it('active(ref) throws ACCOUNT_NOT_FOUND on mismatch', async () => { + writeFileSync(TEST_CONFIG_PATH, JSON.stringify(STORED_CONFIG)) + 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('user-uuid')).rejects.toMatchObject({ + code: 'ACCOUNT_NOT_FOUND', + }) + }) + + it('clear(ref) clears the config when the ref matches', async () => { + writeFileSync( + TEST_CONFIG_PATH, + JSON.stringify({ ...STORED_CONFIG, update_channel: 'stable' }), + ) + const { createOutlineTokenStore } = await import('../lib/auth-provider.js') + await createOutlineTokenStore().clear('user-uuid') + 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(STORED_CONFIG)) + 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(STORED_CONFIG) + }) + + it('list() returns the stored account flagged as default', async () => { + writeFileSync(TEST_CONFIG_PATH, JSON.stringify(STORED_CONFIG)) + const { createOutlineTokenStore } = await import('../lib/auth-provider.js') + expect(await createOutlineTokenStore().list()).toEqual([ + { account: STORED_ACCOUNT, 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(STORED_CONFIG)) + const { createOutlineTokenStore } = await import('../lib/auth-provider.js') + await expect(createOutlineTokenStore().setDefault('user-uuid')).resolves.toBeUndefined() + }) + + it('setDefault(ref) throws ACCOUNT_NOT_FOUND when the ref does not match', async () => { + writeFileSync(TEST_CONFIG_PATH, JSON.stringify(STORED_CONFIG)) + 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..ff17627 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, @@ -10,6 +11,7 @@ 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 { CliError } from './errors.js' const DEFAULT_BASE_URL = 'https://app.getoutline.com' @@ -160,29 +162,61 @@ export function createOutlineAuthProvider(): AuthProvider { } export function createOutlineTokenStore(): TokenStore { + async function loadStoredSnapshot(): Promise<{ + token: string + account: OutlineAccount + } | null> { + const config = await getConfig() + 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() + } + + /** + * Single source of truth for ref-aware lookups. Returns the snapshot + * when `ref` matches the stored account, throws `ACCOUNT_NOT_FOUND` + * otherwise (including when nothing is stored). + */ + async function resolveByRef( + ref: AccountRef, + ): Promise<{ token: string; account: OutlineAccount }> { + const snapshot = await loadStoredSnapshot() + if (!snapshot || !matchesRef(snapshot.account, ref)) { + throw new CliError('ACCOUNT_NOT_FOUND', `No stored account matches "${ref}".`) + } + return snapshot + } + 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) { + if (ref === undefined) return loadStoredSnapshot() + return resolveByRef(ref) }, async set(account, token) { await updateConfig({ @@ -194,8 +228,23 @@ export function createOutlineTokenStore(): TokenStore { auth_team_name: account.teamName, }) }, - async clear() { + 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. + if (ref !== undefined) { + await resolveByRef(ref) + } await clearConfig() }, + async list() { + const snapshot = await loadStoredSnapshot() + return snapshot ? [{ account: snapshot.account, isDefault: true }] : [] + }, + async setDefault(ref: AccountRef) { + await resolveByRef(ref) + // Single-user store — already the default once `ref` matches. + }, } } 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' From 7d24a017d2f798a0d7af4589e75ae6a92054f78f Mon Sep 17 00:00:00 2001 From: Scott Lovegrove Date: Fri, 15 May 2026 16:27:13 +0100 Subject: [PATCH 2/2] fix(auth): trim clear(ref) to one read + dedupe ref-aware fixtures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address PR review: - P2 perf: `clear(ref)` was reading the config file twice (once via `resolveByRef` then again via `clearConfig`). Replace `resolveByRef` with an inline `deriveSnapshot(config)` pure function used by every ref-aware path (`active(ref)` / `clear(ref)` / `list()` / `setDefault(ref)`) and let `clearConfig` accept an optional pre-loaded config so ref-based logout stays at 1 read + 1 write. - P2 test gap: add `clear(ref)` with no stored config — guards against a regression to the old silent no-op when the store is empty, which would let `attachLogoutCommand` emit ✓ Logged out for a ref that never had any backing account. - P3 fixtures: drop the duplicate `STORED_ACCOUNT` / `STORED_CONFIG` fixtures and reuse the existing `sampleAccount` + a derived `sampleConfig` so future account-shape changes live in one place. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/__tests__/auth-provider.test.ts | 75 ++++++++++++++++------------- src/lib/auth-provider.ts | 49 +++++++++---------- src/lib/auth.ts | 10 ++-- 3 files changed, 71 insertions(+), 63 deletions(-) diff --git a/src/__tests__/auth-provider.test.ts b/src/__tests__/auth-provider.test.ts index 0305808..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() @@ -178,42 +188,26 @@ describe('OutlineTokenStore', () => { }) describe('ref-aware lookups', () => { - const STORED_CONFIG = { - api_token: 'tok', - base_url: 'https://wiki.example.com', - oauth_client_id: 'cid-xyz', - auth_user_id: 'user-uuid', - auth_user_name: 'Ada', - auth_team_name: 'Analytics', - } - const STORED_ACCOUNT = { - id: 'user-uuid', - label: 'Ada', - baseUrl: 'https://wiki.example.com', - oauthClientId: 'cid-xyz', - teamName: 'Analytics', - } - it('active(ref) returns the snapshot when the id ref matches', async () => { - writeFileSync(TEST_CONFIG_PATH, JSON.stringify(STORED_CONFIG)) + writeFileSync(TEST_CONFIG_PATH, JSON.stringify(sampleConfig)) const { createOutlineTokenStore } = await import('../lib/auth-provider.js') - expect(await createOutlineTokenStore().active('user-uuid')).toEqual({ - token: 'tok', - account: STORED_ACCOUNT, + 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(STORED_CONFIG)) + writeFileSync(TEST_CONFIG_PATH, JSON.stringify(sampleConfig)) const { createOutlineTokenStore } = await import('../lib/auth-provider.js') expect(await createOutlineTokenStore().active('ADA')).toEqual({ - token: 'tok', - account: STORED_ACCOUNT, + token: sampleConfig.api_token, + account: sampleAccount, }) }) it('active(ref) throws ACCOUNT_NOT_FOUND on mismatch', async () => { - writeFileSync(TEST_CONFIG_PATH, JSON.stringify(STORED_CONFIG)) + 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', @@ -222,7 +216,7 @@ describe('OutlineTokenStore', () => { 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('user-uuid')).rejects.toMatchObject({ + await expect(createOutlineTokenStore().active(sampleAccount.id)).rejects.toMatchObject({ code: 'ACCOUNT_NOT_FOUND', }) }) @@ -230,29 +224,40 @@ describe('OutlineTokenStore', () => { it('clear(ref) clears the config when the ref matches', async () => { writeFileSync( TEST_CONFIG_PATH, - JSON.stringify({ ...STORED_CONFIG, update_channel: 'stable' }), + JSON.stringify({ ...sampleConfig, update_channel: 'stable' }), ) const { createOutlineTokenStore } = await import('../lib/auth-provider.js') - await createOutlineTokenStore().clear('user-uuid') + 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(STORED_CONFIG)) + 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(STORED_CONFIG) + 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(STORED_CONFIG)) + writeFileSync(TEST_CONFIG_PATH, JSON.stringify(sampleConfig)) const { createOutlineTokenStore } = await import('../lib/auth-provider.js') expect(await createOutlineTokenStore().list()).toEqual([ - { account: STORED_ACCOUNT, isDefault: true }, + { account: sampleAccount, isDefault: true }, ]) }) @@ -262,13 +267,15 @@ describe('OutlineTokenStore', () => { }) it('setDefault(ref) resolves silently when the ref matches', async () => { - writeFileSync(TEST_CONFIG_PATH, JSON.stringify(STORED_CONFIG)) + writeFileSync(TEST_CONFIG_PATH, JSON.stringify(sampleConfig)) const { createOutlineTokenStore } = await import('../lib/auth-provider.js') - await expect(createOutlineTokenStore().setDefault('user-uuid')).resolves.toBeUndefined() + 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(STORED_CONFIG)) + 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 ff17627..4a860d2 100644 --- a/src/lib/auth-provider.ts +++ b/src/lib/auth-provider.ts @@ -10,7 +10,7 @@ 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' @@ -162,11 +162,13 @@ export function createOutlineAuthProvider(): AuthProvider { } export function createOutlineTokenStore(): TokenStore { - async function loadStoredSnapshot(): Promise<{ - token: string - account: OutlineAccount - } | null> { - const config = await getConfig() + /** + * 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 @@ -198,25 +200,16 @@ export function createOutlineTokenStore(): TokenStore { return account.label.toLowerCase() === ref.toLowerCase() } - /** - * Single source of truth for ref-aware lookups. Returns the snapshot - * when `ref` matches the stored account, throws `ACCOUNT_NOT_FOUND` - * otherwise (including when nothing is stored). - */ - async function resolveByRef( - ref: AccountRef, - ): Promise<{ token: string; account: OutlineAccount }> { - const snapshot = await loadStoredSnapshot() - if (!snapshot || !matchesRef(snapshot.account, ref)) { - throw new CliError('ACCOUNT_NOT_FOUND', `No stored account matches "${ref}".`) - } - return snapshot + function refMismatch(ref: AccountRef): CliError { + return new CliError('ACCOUNT_NOT_FOUND', `No stored account matches "${ref}".`) } return { async active(ref?: AccountRef) { - if (ref === undefined) return loadStoredSnapshot() - return resolveByRef(ref) + 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({ @@ -232,18 +225,22 @@ export function createOutlineTokenStore(): TokenStore { // 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. + // 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) { - await resolveByRef(ref) + const snapshot = deriveSnapshot(config) + if (!snapshot || !matchesRef(snapshot.account, ref)) throw refMismatch(ref) } - await clearConfig() + await clearConfig(config) }, async list() { - const snapshot = await loadStoredSnapshot() + const snapshot = deriveSnapshot(await getConfig()) return snapshot ? [{ account: snapshot.account, isDefault: true }] : [] }, async setDefault(ref: AccountRef) { - await resolveByRef(ref) + 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