diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index ddc09179..3acb0c07 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -73,27 +73,47 @@ async function checkCliVersion(): Promise { } async function checkApiKeyPresence(flagValue?: string): Promise { - const resolved = await resolveApiKeyAsync(flagValue); - if (!resolved) { + try { + const resolved = await resolveApiKeyAsync(flagValue); + if (!resolved) { + return { + name: 'API Key', + status: 'fail', + message: 'No API key found', + detail: 'Run: resend login', + }; + } + const profileInfo = resolved.profile + ? `, profile: ${resolved.profile}` + : ''; + return { + name: 'API Key', + status: 'pass', + message: `${maskKey(resolved.key)} (source: ${resolved.source}${profileInfo})`, + }; + } catch (err) { return { name: 'API Key', status: 'fail', - message: 'No API key found', - detail: 'Run: resend login', + message: errorMessage(err, 'Credential backend error'), + detail: 'Check that your credential storage backend is available', }; } - const profileInfo = resolved.profile ? `, profile: ${resolved.profile}` : ''; - return { - name: 'API Key', - status: 'pass', - message: `${maskKey(resolved.key)} (source: ${resolved.source}${profileInfo})`, - }; } async function checkApiValidationAndDomains( flagValue?: string, ): Promise { - const resolved = await resolveApiKeyAsync(flagValue); + let resolved: Awaited>; + try { + resolved = await resolveApiKeyAsync(flagValue); + } catch { + return { + name: 'API Validation', + status: 'fail', + message: 'Skipped — credential backend unavailable', + }; + } if (!resolved) { return { name: 'API Validation', diff --git a/src/lib/client.ts b/src/lib/client.ts index 0235c1ff..f0d0f56d 100644 --- a/src/lib/client.ts +++ b/src/lib/client.ts @@ -93,10 +93,12 @@ export async function requireClient( return new Resend(resolved.key); } catch (err) { + const msg = errorMessage(err, 'Failed to create client'); + const isBackendError = msg.includes('Credential backend unavailable'); outputError( { - message: errorMessage(err, 'Failed to create client'), - code: 'auth_error', + message: msg, + code: isBackendError ? 'credential_store_error' : 'auth_error', }, { json: opts.json }, ); diff --git a/src/lib/config.ts b/src/lib/config.ts index 86abc08e..6405ece2 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -13,6 +13,7 @@ import { getCredentialBackend, SERVICE_NAME, } from './credential-store'; +import { errorMessage } from './output'; export type ApiKeyPermission = 'full_access' | 'sending_access'; @@ -328,15 +329,19 @@ export async function resolveApiKeyAsync( creds?.active_profile || 'default'; - // If storage is 'secure_storage', try credential backend first if (creds?.storage === 'secure_storage') { const backend = await getCredentialBackend(); - const key = await backend.get(SERVICE_NAME, profile); - if (key) { - const permission = creds.profiles[profile]?.permission; - return { key, source: 'secure_storage', profile, permission }; + try { + const key = await backend.get(SERVICE_NAME, profile); + if (key) { + const permission = creds.profiles[profile]?.permission; + return { key, source: 'secure_storage', profile, permission }; + } + } catch (err) { + throw new Error( + `Credential backend unavailable: ${errorMessage(err, 'unknown error')}`, + ); } - // Fall through: profile may not be migrated yet (api_key still in file) } // File-based storage (or unmigrated profile in mixed state) diff --git a/src/lib/credential-backends/linux.ts b/src/lib/credential-backends/linux.ts index c0f0bcd3..3641fceb 100644 --- a/src/lib/credential-backends/linux.ts +++ b/src/lib/credential-backends/linux.ts @@ -50,17 +50,19 @@ export class LinuxBackend implements CredentialBackend { readonly isSecure = true; async get(service: string, account: string): Promise { - const { stdout, code } = await run('secret-tool', [ + const { stdout, stderr, code } = await run('secret-tool', [ 'lookup', 'service', service, 'account', account, ]); - if (code !== 0 || !stdout.trim()) { - return null; + if (code === 0 || code === 1) { + return stdout.trim() || null; } - return stdout.trim(); + throw new Error( + `Failed to read from Secret Service (exit code ${code}): ${stderr.trim()}`, + ); } async set(service: string, account: string, secret: string): Promise { diff --git a/src/lib/credential-backends/windows.ts b/src/lib/credential-backends/windows.ts index 57114451..b127a032 100644 --- a/src/lib/credential-backends/windows.ts +++ b/src/lib/credential-backends/windows.ts @@ -72,15 +72,24 @@ export class WindowsBackend implements CredentialBackend { $c = $v.Retrieve('${psEscape(service)}', '${psEscape(account)}') $c.RetrievePassword() Write-Output $c.Password - } catch { + } catch [System.Exception] { + if ($_.Exception.Message -match 'Element not found') { + exit 44 + } + Write-Error $_.Exception.Message exit 1 } `; - const { stdout, code } = await runPowershell(script); - if (code !== 0 || !stdout.trim()) { + const { stdout, stderr, code } = await runPowershell(script); + if (code === 44) { return null; } - return stdout.trim(); + if (code !== 0) { + throw new Error( + `Failed to read from Windows Credential Manager (exit code ${code}): ${stderr.trim()}`, + ); + } + return stdout.trim() || null; } async set(service: string, account: string, secret: string): Promise { diff --git a/tests/lib/config-async.test.ts b/tests/lib/config-async.test.ts index a5d7c364..b72dff35 100644 --- a/tests/lib/config-async.test.ts +++ b/tests/lib/config-async.test.ts @@ -167,6 +167,46 @@ describe('resolveApiKeyAsync', () => { const result = await resolveApiKeyAsync(); expect(result).toBeNull(); }); + + test('propagates credential backend errors instead of returning null', async () => { + const configDir = join(tmpDir, 'resend'); + mkdirSync(configDir, { recursive: true }); + writeFileSync( + join(configDir, 'credentials.json'), + JSON.stringify({ + active_profile: 'default', + storage: 'secure_storage', + profiles: { default: {} }, + }), + ); + + const mockBackend = { + get: vi + .fn() + .mockRejectedValue( + new Error( + 'Failed to read from Secret Service (exit code 5): dbus timeout', + ), + ), + set: vi.fn(), + delete: vi.fn(), + isAvailable: vi.fn().mockResolvedValue(true), + name: 'mock-backend', + isSecure: true, + }; + + vi.resetModules(); + vi.doMock('../../src/lib/credential-store', () => ({ + getCredentialBackend: vi.fn().mockResolvedValue(mockBackend), + SERVICE_NAME: 'resend-cli', + resetCredentialBackend: vi.fn(), + })); + + const { resolveApiKeyAsync } = await import('../../src/lib/config'); + await expect(resolveApiKeyAsync()).rejects.toThrow( + 'Credential backend unavailable', + ); + }); }); describe('storeApiKeyAsync', () => { diff --git a/tests/lib/credential-backends/linux.test.ts b/tests/lib/credential-backends/linux.test.ts new file mode 100644 index 00000000..08c66ce5 --- /dev/null +++ b/tests/lib/credential-backends/linux.test.ts @@ -0,0 +1,120 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +type ExecFileCallback = ( + err: { code: number } | null, + stdout: string, + stderr: string, +) => void; + +vi.mock('node:child_process', () => { + const mockExecFile = vi.fn( + (_cmd: string, _args: string[], _opts: unknown, cb: ExecFileCallback) => { + cb(null, '', ''); + }, + ); + + const mockSpawn = vi.fn(() => ({ + stdin: { on: vi.fn(), write: vi.fn(), end: vi.fn() }, + stdout: { on: vi.fn() }, + stderr: { on: vi.fn() }, + on: vi.fn((event: string, cb: (code: number) => void) => { + if (event === 'close') { + setTimeout(() => cb(0), 0); + } + }), + })); + + return { execFile: mockExecFile, spawn: mockSpawn }; +}); + +describe('LinuxBackend', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns credential when secret-tool exits with 0 and has output', async () => { + const { execFile } = await import('node:child_process'); + vi.mocked(execFile).mockImplementation( + (_cmd: unknown, _args: unknown, _opts: unknown, cb: unknown) => { + (cb as ExecFileCallback)(null, 're_my_key\n', ''); + return undefined as never; + }, + ); + const { LinuxBackend } = await import( + '../../../src/lib/credential-backends/linux' + ); + const backend = new LinuxBackend(); + const result = await backend.get('resend-cli', 'default'); + expect(result).toBe('re_my_key'); + }); + + it('returns null when secret-tool exits with 0 but has no output', async () => { + const { execFile } = await import('node:child_process'); + vi.mocked(execFile).mockImplementation( + (_cmd: unknown, _args: unknown, _opts: unknown, cb: unknown) => { + (cb as ExecFileCallback)(null, '', ''); + return undefined as never; + }, + ); + const { LinuxBackend } = await import( + '../../../src/lib/credential-backends/linux' + ); + const backend = new LinuxBackend(); + const result = await backend.get('resend-cli', 'default'); + expect(result).toBeNull(); + }); + + it('returns null when secret-tool exits with 1 (credential not found)', async () => { + const { execFile } = await import('node:child_process'); + vi.mocked(execFile).mockImplementation( + (_cmd: unknown, _args: unknown, _opts: unknown, cb: unknown) => { + (cb as ExecFileCallback)({ code: 1 }, '', ''); + return undefined as never; + }, + ); + const { LinuxBackend } = await import( + '../../../src/lib/credential-backends/linux' + ); + const backend = new LinuxBackend(); + const result = await backend.get('resend-cli', 'default'); + expect(result).toBeNull(); + }); + + it('throws when secret-tool exits with unexpected code', async () => { + const { execFile } = await import('node:child_process'); + vi.mocked(execFile).mockImplementation( + (_cmd: unknown, _args: unknown, _opts: unknown, cb: unknown) => { + (cb as ExecFileCallback)({ code: 5 }, '', 'dbus timeout'); + return undefined as never; + }, + ); + const { LinuxBackend } = await import( + '../../../src/lib/credential-backends/linux' + ); + const backend = new LinuxBackend(); + await expect(backend.get('resend-cli', 'default')).rejects.toThrow( + 'Failed to read from Secret Service (exit code 5): dbus timeout', + ); + }); + + it('throws when secret-tool exits with null code (killed/timeout)', async () => { + const { execFile } = await import('node:child_process'); + vi.mocked(execFile).mockImplementation( + (_cmd: unknown, _args: unknown, _opts: unknown, cb: unknown) => { + (cb as ExecFileCallback)( + { code: null as unknown as number }, + '', + 'process timed out', + ); + return undefined as never; + }, + ); + const { LinuxBackend } = await import( + '../../../src/lib/credential-backends/linux' + ); + const backend = new LinuxBackend(); + await expect(backend.get('resend-cli', 'default')).rejects.toThrow( + 'Failed to read from Secret Service (exit code null)', + ); + }); +});