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
37 changes: 37 additions & 0 deletions src/lib/config-store.spec.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -60,6 +60,40 @@ vi.mock('@napi-rs/keyring', () => ({
},
}));

// On darwin, config-store routes through DarwinSecurityEntry instead of the
// native Entry. Back it with the SAME map + availability flag so every test
// behaves identically on all platforms.
vi.mock('./darwin-keychain.js', () => ({
DarwinSecurityEntry: class MockDarwinSecurityEntry {
private key: string;

constructor(service: string, account: string) {
this.key = `${service}:${account}`;
}

getPassword(): string | null {
if (!keyringAvailable) {
throw new Error('Keyring not available');
}
return mockKeyring.get(this.key) ?? null;
}

setPassword(password: string): void {
if (!keyringAvailable) {
throw new Error('Keyring not available');
}
mockKeyring.set(this.key, password);
}

deletePassword(): void {
if (!keyringAvailable && mockKeyring.has(this.key)) {
throw new Error('Keyring not available');
}
mockKeyring.delete(this.key);
}
},
}));

// Mock os.homedir BEFORE importing config-store module
vi.mock('node:os', async (importOriginal) => {
const original = await importOriginal<typeof import('node:os')>();
Expand DownExpand Up@@ -176,6 +210,9 @@ describe('config-store', () => {
it('returns null for corrupted file', () => {
saveConfig(sampleConfig);
writeFileSync(configFile, 'not valid json');
// External corruption is only observable from a fresh process —
// config reads are cached in-process. Reset to simulate one.
setInsecureConfigStorage(true);
expect(getConfig()).toBeNull();
});
});
Expand Down
204 changes: 19 additions & 185 deletions src/lib/config-store.ts
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,13 @@
/**
* CLI config storage abstraction with keyring support and file fallback.
* CLI config storage: keychain-backed with file fallback, via KeyringStore
* (backend selection, in-process cache, migration — see keyring-store.ts).
*
* Stores environment configurations (names, API keys, endpoints) separately
* from OAuth credentials. Uses a second keyring entry under the same service.
*
* Storage priority:
* 1. If insecure storage forced: use file only
* 2. Try keyring, fall back to file with warning if unavailable
*/

import { Entry } from '@napi-rs/keyring';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { logWarn } from '../utils/debug.js';
import { observeHostFailure } from './host-probe.js';
import { KeyringStore } from './keyring-store.js';

interface BaseEnvironmentConfig {
name: string;
Expand DownExpand Up@@ -51,186 +44,28 @@ export interface CliConfig {
environments: Record<string, EnvironmentConfig>;
}

const SERVICE_NAME = 'workos-cli';
const ACCOUNT_NAME = 'config';

let fallbackWarningShown = false;
let forceInsecureStorage = false;
let migrationAttempted = false;
const store = new KeyringStore<CliConfig>({
serviceName: 'workos-cli',
accountName: 'config',
fileName: 'config.json',
label: 'config',
verifySaveReadBack: true,
});

export function setInsecureConfigStorage(value: boolean): void {
forceInsecureStorage = value;
migrationAttempted = false;
}

function getConfigDir(): string {
return path.join(os.homedir(), '.workos');
}

function getConfigFilePath(): string {
return path.join(getConfigDir(), 'config.json');
}

function fileExists(): boolean {
return fs.existsSync(getConfigFilePath());
}

function readFromFile(): CliConfig | null {
if (!fileExists()) return null;
const filePath = getConfigFilePath();
try {
const content = fs.readFileSync(filePath, 'utf-8');
return JSON.parse(content);
} catch (error) {
observeHostFailure('home-fs', error, {
operation: 'read',
target: filePath,
label: 'config fallback file',
});
logWarn('Failed to read config file:', error);
return null;
}
}

function writeToFile(config: CliConfig): void {
const dir = getConfigDir();
const filePath = getConfigFilePath();
try {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
}
fs.writeFileSync(filePath, JSON.stringify(config, null, 2), {
mode: 0o600,
});
} catch (error) {
observeHostFailure('home-fs', error, {
operation: 'write',
target: filePath,
label: 'config fallback file',
});
throw error;
}
}

function deleteFile(): void {
const filePath = getConfigFilePath();
if (fileExists()) {
try {
fs.unlinkSync(filePath);
} catch (error) {
observeHostFailure('home-fs', error, {
operation: 'delete',
target: filePath,
label: 'config fallback file',
});
throw error;
}
}
}

function getKeyringEntry(): Entry {
return new Entry(SERVICE_NAME, ACCOUNT_NAME);
}

function readFromKeyring(): CliConfig | null {
try {
const entry = getKeyringEntry();
const data = entry.getPassword();
if (!data) return null;
return JSON.parse(data);
} catch (error) {
logWarn('Failed to read config from keyring:', error);
observeHostFailure('keychain', error, {
operation: 'read',
target: `${SERVICE_NAME}/${ACCOUNT_NAME}`,
label: 'config keychain entry',
});
return null;
}
}

function writeToKeyring(config: CliConfig): boolean {
try {
const entry = getKeyringEntry();
entry.setPassword(JSON.stringify(config));
return true;
} catch (error) {
logWarn('Failed to write config to keyring:', error);
observeHostFailure('keychain', error, {
operation: 'write',
target: `${SERVICE_NAME}/${ACCOUNT_NAME}`,
label: 'config keychain entry',
});
return false;
}
}

function deleteFromKeyring(): void {
try {
const entry = getKeyringEntry();
entry.deletePassword();
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
if (!msg.includes('not found') && !msg.includes('No such')) {
logWarn('Failed to delete config from keyring:', error);
observeHostFailure('keychain', error, {
operation: 'delete',
target: `${SERVICE_NAME}/${ACCOUNT_NAME}`,
label: 'config keychain entry',
});
}
}
}

function showFallbackWarning(): void {
if (fallbackWarningShown || forceInsecureStorage) return;
fallbackWarningShown = true;
logWarn(
'Unable to store config in system keyring. Using file storage.',
'Config saved to ~/.workos/config.json',
'Use --insecure-storage to suppress this warning.',
);
store.setInsecure(value);
}

export function getConfig(): CliConfig | null {
if (forceInsecureStorage) return readFromFile();

const keyringConfig = readFromKeyring();
if (keyringConfig) return keyringConfig;

const fileConfig = readFromFile();
if (fileConfig) {
if (!migrationAttempted) {
migrationAttempted = true;
writeToKeyring(fileConfig);
}
return fileConfig;
}

return null;
return store.get();
}

export function saveConfig(config: CliConfig): void {
if (forceInsecureStorage) return writeToFile(config);

if (!writeToKeyring(config)) {
showFallbackWarning();
writeToFile(config);
return;
}

// Verify the keyring write is readable (guards against silent keyring failures
// where setPassword succeeds but getPassword returns null in the same process)
if (!readFromKeyring()) {
logWarn('Keyring write succeeded but read-back failed — falling back to file');
writeToFile(config);
}
store.save(config);
}

export function clearConfig(): void {
deleteFromKeyring();
deleteFile();
migrationAttempted = false;
store.clear();
}

export function getActiveEnvironment(): EnvironmentConfig | null {
Expand DownExpand Up@@ -261,16 +96,16 @@ export function freshEnvKey(config: CliConfig, base: string): string {
}

export function getConfigPath(): string {
return getConfigFilePath();
return store.filePath;
}

/**
* Diagnostic info about config storage state — for debugging config persistence failures.
*/
export function diagnoseConfig(): string[] {
const lines: string[] = [];
const filePath = getConfigFilePath();
const filePresent = fileExists();
const filePath = store.filePath;
const filePresent = fs.existsSync(filePath);

lines.push(`file: ${filePath} (exists=${filePresent})`);

Expand All@@ -286,8 +121,7 @@ export function diagnoseConfig(): string[] {
}

try {
const entry = getKeyringEntry();
const data = entry.getPassword();
const data = store.readKeyringRaw();
if (data) {
const parsed = JSON.parse(data) as Partial<CliConfig>;
const envCount = parsed.environments ? Object.keys(parsed.environments).length : 0;
Expand All@@ -299,7 +133,7 @@ export function diagnoseConfig(): string[] {
lines.push(`keyring: error — ${e instanceof Error ? e.message : String(e)}`);
}

lines.push(`insecureStorage=${forceInsecureStorage}`);
lines.push(`insecureStorage=${store.insecure}`);
return lines;
}

Expand Down
Loading