@promptcellar/pc 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,7 +6,7 @@ import { homedir } from 'os';
6
6
  import { execFileSync } from 'child_process';
7
7
 
8
8
  // Config paths
9
- const CLAUDE_HOOKS_PATH = join(homedir(), '.claude', 'hooks.json');
9
+ const CLAUDE_SETTINGS_PATH = join(homedir(), '.claude', 'settings.json');
10
10
  const CODEX_CONFIG_PATH = join(homedir(), '.codex', 'config.toml');
11
11
  const GEMINI_SETTINGS_PATH = join(homedir(), '.gemini', 'settings.json');
12
12
 
@@ -37,11 +37,11 @@ function detectInstalledTools() {
37
37
  return tools;
38
38
  }
39
39
 
40
- // Claude Code hooks
41
- function getClaudeHooksConfig() {
42
- if (existsSync(CLAUDE_HOOKS_PATH)) {
40
+ // Claude Code settings (hooks are in settings.json under "hooks" key)
41
+ function getClaudeSettings() {
42
+ if (existsSync(CLAUDE_SETTINGS_PATH)) {
43
43
  try {
44
- return JSON.parse(readFileSync(CLAUDE_HOOKS_PATH, 'utf8'));
44
+ return JSON.parse(readFileSync(CLAUDE_SETTINGS_PATH, 'utf8'));
45
45
  } catch {
46
46
  return {};
47
47
  }
@@ -49,18 +49,18 @@ function getClaudeHooksConfig() {
49
49
  return {};
50
50
  }
51
51
 
52
- function saveClaudeHooksConfig(config) {
53
- const dir = dirname(CLAUDE_HOOKS_PATH);
52
+ function saveClaudeSettings(settings) {
53
+ const dir = dirname(CLAUDE_SETTINGS_PATH);
54
54
  if (!existsSync(dir)) {
55
55
  mkdirSync(dir, { recursive: true });
56
56
  }
57
- writeFileSync(CLAUDE_HOOKS_PATH, JSON.stringify(config, null, 2));
57
+ writeFileSync(CLAUDE_SETTINGS_PATH, JSON.stringify(settings, null, 2));
58
58
  }
59
59
 
60
- function isClaudeHookInstalled(config) {
61
- const stopHooks = config.Stop || [];
62
- return stopHooks.some(hook =>
63
- hook.command && hook.command.includes(HOOK_SCRIPT_NAME)
60
+ function isClaudeHookInstalled(settings) {
61
+ const matchers = settings.hooks?.UserPromptSubmit || [];
62
+ return matchers.some(matcher =>
63
+ matcher.hooks?.some(hook => hook.command?.includes(HOOK_SCRIPT_NAME))
64
64
  );
65
65
  }
66
66
 
@@ -101,7 +101,7 @@ function getGeminiSettings() {
101
101
  }
102
102
 
103
103
  function isGeminiHookInstalled(settings) {
104
- const hooks = settings.hooks?.AfterAgent || [];
104
+ const hooks = settings.hooks?.BeforeAgent || [];
105
105
  return hooks.some(h =>
106
106
  h.hooks?.some(hook => hook.command?.includes('pc-gemini-capture'))
107
107
  );
@@ -167,9 +167,9 @@ export async function setup() {
167
167
  async function setupClaudeCode() {
168
168
  console.log(chalk.cyan('\nConfiguring Claude Code...'));
169
169
 
170
- const config = getClaudeHooksConfig();
170
+ const settings = getClaudeSettings();
171
171
 
172
- if (isClaudeHookInstalled(config)) {
172
+ if (isClaudeHookInstalled(settings)) {
173
173
  console.log(chalk.yellow(' Hook already installed.'));
174
174
 
175
175
  const { reinstall } = await inquirer.prompt([{
@@ -183,23 +183,30 @@ async function setupClaudeCode() {
183
183
  return;
184
184
  }
185
185
 
186
- // Remove existing hook
187
- config.Stop = (config.Stop || []).filter(hook =>
188
- !hook.command || !hook.command.includes(HOOK_SCRIPT_NAME)
186
+ // Remove existing hook matchers that contain our hook
187
+ settings.hooks.UserPromptSubmit = (settings.hooks.UserPromptSubmit || []).filter(matcher =>
188
+ !matcher.hooks?.some(hook => hook.command?.includes(HOOK_SCRIPT_NAME))
189
189
  );
190
190
  }
191
191
 
192
- // Add the hook
193
- if (!config.Stop) {
194
- config.Stop = [];
192
+ // Ensure hooks.UserPromptSubmit exists
193
+ if (!settings.hooks) {
194
+ settings.hooks = {};
195
+ }
196
+ if (!settings.hooks.UserPromptSubmit) {
197
+ settings.hooks.UserPromptSubmit = [];
195
198
  }
196
199
 
197
- config.Stop.push({
198
- command: `pc-capture`,
199
- description: 'Capture prompts to PromptCellar'
200
+ // Add the UserPromptSubmit hook
201
+ settings.hooks.UserPromptSubmit.push({
202
+ matcher: '*',
203
+ hooks: [{
204
+ type: 'command',
205
+ command: 'pc-capture'
206
+ }]
200
207
  });
201
208
 
202
- saveClaudeHooksConfig(config);
209
+ saveClaudeSettings(settings);
203
210
  console.log(chalk.green(' Hook installed successfully.'));
204
211
  }
205
212
 
@@ -272,28 +279,22 @@ async function setupGemini() {
272
279
  }
273
280
 
274
281
  // Remove existing hook
275
- if (settings.hooks?.AfterAgent) {
276
- settings.hooks.AfterAgent = settings.hooks.AfterAgent.filter(h =>
282
+ if (settings.hooks?.BeforeAgent) {
283
+ settings.hooks.BeforeAgent = settings.hooks.BeforeAgent.filter(h =>
277
284
  !h.hooks?.some(hook => hook.command?.includes('pc-gemini-capture'))
278
285
  );
279
286
  }
280
287
  }
281
288
 
282
- // Ensure hooks are enabled
283
- if (!settings.hooksConfig) {
284
- settings.hooksConfig = {};
285
- }
286
- settings.hooksConfig.enabled = true;
287
-
288
- // Add the AfterAgent hook
289
+ // Add the BeforeAgent hook
289
290
  if (!settings.hooks) {
290
291
  settings.hooks = {};
291
292
  }
292
- if (!settings.hooks.AfterAgent) {
293
- settings.hooks.AfterAgent = [];
293
+ if (!settings.hooks.BeforeAgent) {
294
+ settings.hooks.BeforeAgent = [];
294
295
  }
295
296
 
296
- settings.hooks.AfterAgent.push({
297
+ settings.hooks.BeforeAgent.push({
297
298
  matcher: '*',
298
299
  hooks: [{
299
300
  name: 'promptcellar-capture',
@@ -313,12 +314,12 @@ export async function unsetup() {
313
314
  let removed = false;
314
315
 
315
316
  // Remove Claude hook
316
- const claudeConfig = getClaudeHooksConfig();
317
- if (isClaudeHookInstalled(claudeConfig)) {
318
- claudeConfig.Stop = (claudeConfig.Stop || []).filter(hook =>
319
- !hook.command || !hook.command.includes(HOOK_SCRIPT_NAME)
317
+ const claudeSettings = getClaudeSettings();
318
+ if (isClaudeHookInstalled(claudeSettings)) {
319
+ claudeSettings.hooks.UserPromptSubmit = (claudeSettings.hooks.UserPromptSubmit || []).filter(matcher =>
320
+ !matcher.hooks?.some(hook => hook.command?.includes(HOOK_SCRIPT_NAME))
320
321
  );
321
- saveClaudeHooksConfig(claudeConfig);
322
+ saveClaudeSettings(claudeSettings);
322
323
  console.log(chalk.green(' Removed Claude Code hook.'));
323
324
  removed = true;
324
325
  }
@@ -339,8 +340,8 @@ export async function unsetup() {
339
340
  // Remove Gemini hook
340
341
  const geminiSettings = getGeminiSettings();
341
342
  if (isGeminiHookInstalled(geminiSettings)) {
342
- if (geminiSettings.hooks?.AfterAgent) {
343
- geminiSettings.hooks.AfterAgent = geminiSettings.hooks.AfterAgent.filter(h =>
343
+ if (geminiSettings.hooks?.BeforeAgent) {
344
+ geminiSettings.hooks.BeforeAgent = geminiSettings.hooks.BeforeAgent.filter(h =>
344
345
  !h.hooks?.some(hook => hook.command?.includes('pc-gemini-capture'))
345
346
  );
346
347
  }
@@ -2,6 +2,7 @@ import chalk from 'chalk';
2
2
  import { isLoggedIn, getApiUrl, getCaptureLevel, getSessionId } from '../lib/config.js';
3
3
  import { testConnection } from '../lib/api.js';
4
4
  import { getGitContext } from '../lib/context.js';
5
+ import { loadVaultKey } from '../lib/keychain.js';
5
6
 
6
7
  export async function status() {
7
8
  console.log(chalk.bold('\nPromptCellar CLI Status\n'));
@@ -21,6 +22,16 @@ export async function status() {
21
22
  // Capture level
22
23
  console.log(` Capture level: ${chalk.cyan(getCaptureLevel())}`);
23
24
 
25
+ // Vault status
26
+ const vaultKey = await loadVaultKey();
27
+ if (vaultKey) {
28
+ console.log(` Vault: ${chalk.green('configured')}`);
29
+ } else if (isLoggedIn()) {
30
+ console.log(` Vault: ${chalk.yellow('key missing')} - run pc login`);
31
+ } else {
32
+ console.log(` Vault: ${chalk.yellow('not configured')} - capture disabled`);
33
+ }
34
+
24
35
  // Session ID
25
36
  console.log(` Session ID: ${chalk.dim(getSessionId().slice(0, 8))}...`);
26
37
 
package/src/lib/config.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { randomUUID } from 'node:crypto';
1
2
  import Conf from 'conf';
2
3
 
3
4
  const config = new Conf({
@@ -19,6 +20,14 @@ const config = new Conf({
19
20
  sessionId: {
20
21
  type: 'string',
21
22
  default: ''
23
+ },
24
+ accountUrl: {
25
+ type: 'string',
26
+ default: 'https://account.weldedanvil.com'
27
+ },
28
+ vaultAvailable: {
29
+ type: 'boolean',
30
+ default: false
22
31
  }
23
32
  }
24
33
  });
@@ -33,6 +42,8 @@ export function setApiKey(key) {
33
42
 
34
43
  export function clearApiKey() {
35
44
  config.delete('apiKey');
45
+ config.delete('sessionId');
46
+ config.set('vaultAvailable', false);
36
47
  }
37
48
 
38
49
  export function getApiUrl() {
@@ -57,12 +68,28 @@ export function setCaptureLevel(level) {
57
68
  export function getSessionId() {
58
69
  let sessionId = config.get('sessionId');
59
70
  if (!sessionId) {
60
- sessionId = crypto.randomUUID();
71
+ sessionId = randomUUID();
61
72
  config.set('sessionId', sessionId);
62
73
  }
63
74
  return sessionId;
64
75
  }
65
76
 
77
+ export function getAccountUrl() {
78
+ return config.get('accountUrl');
79
+ }
80
+
81
+ export function setAccountUrl(url) {
82
+ config.set('accountUrl', url);
83
+ }
84
+
85
+ export function isVaultAvailable() {
86
+ return config.get('vaultAvailable');
87
+ }
88
+
89
+ export function setVaultAvailable(available) {
90
+ config.set('vaultAvailable', available);
91
+ }
92
+
66
93
  export function isLoggedIn() {
67
94
  return !!config.get('apiKey');
68
95
  }
@@ -0,0 +1,39 @@
1
+ import { createCipheriv, randomBytes } from 'crypto';
2
+
3
+ function b64urlEncode(buffer) {
4
+ return Buffer.from(buffer)
5
+ .toString('base64')
6
+ .replace(/\+/g, '-')
7
+ .replace(/\//g, '_')
8
+ .replace(/=+$/, '');
9
+ }
10
+
11
+ function b64urlDecode(str) {
12
+ let base64 = str.replace(/-/g, '+').replace(/_/g, '/');
13
+ while (base64.length % 4) base64 += '=';
14
+ return Buffer.from(base64, 'base64');
15
+ }
16
+
17
+ export function encryptPrompt(plaintext, keyBase64url) {
18
+ const key = b64urlDecode(keyBase64url);
19
+ if (key.length !== 32) {
20
+ throw new Error('Invalid vault key length');
21
+ }
22
+
23
+ const iv = randomBytes(12);
24
+ const cipher = createCipheriv('aes-256-gcm', key, iv);
25
+
26
+ const encoded = Buffer.from(plaintext, 'utf8');
27
+ const encrypted = Buffer.concat([cipher.update(encoded), cipher.final()]);
28
+ const authTag = cipher.getAuthTag();
29
+
30
+ // AES-GCM ciphertext = encrypted + authTag (WebCrypto format)
31
+ const ciphertext = Buffer.concat([encrypted, authTag]);
32
+
33
+ return {
34
+ encrypted_content: b64urlEncode(ciphertext),
35
+ content_iv: b64urlEncode(iv),
36
+ };
37
+ }
38
+
39
+ export { b64urlEncode, b64urlDecode };
@@ -0,0 +1,43 @@
1
+ import { generateKeyPair, publicEncrypt, privateDecrypt, constants } from 'crypto';
2
+ import { promisify } from 'util';
3
+ import { b64urlEncode, b64urlDecode } from './crypto.js';
4
+
5
+ const generateKeyPairAsync = promisify(generateKeyPair);
6
+
7
+ /**
8
+ * Generate an RSA-OAEP 2048 keypair for device-transfer envelope encryption.
9
+ * Returns { publicKeySpkiB64url, privateKeyPem }.
10
+ */
11
+ export async function generateTransferKeyPair() {
12
+ const { publicKey, privateKey } = await generateKeyPairAsync('rsa', {
13
+ modulusLength: 2048,
14
+ publicKeyEncoding: { type: 'spki', format: 'der' },
15
+ privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
16
+ });
17
+
18
+ return {
19
+ publicKeySpkiB64url: b64urlEncode(publicKey),
20
+ privateKeyPem: privateKey,
21
+ };
22
+ }
23
+
24
+ /**
25
+ * Decrypt a transfer payload encrypted with RSA-OAEP + SHA-256.
26
+ * @param {string} ciphertextB64url - base64url-encoded ciphertext
27
+ * @param {string} privateKeyPem - PEM-encoded PKCS#8 private key
28
+ * @returns {string} base64url-encoded master key
29
+ */
30
+ export function decryptTransfer(ciphertextB64url, privateKeyPem) {
31
+ const ciphertext = b64urlDecode(ciphertextB64url);
32
+
33
+ const plaintext = privateDecrypt(
34
+ {
35
+ key: privateKeyPem,
36
+ oaepHash: 'sha256',
37
+ padding: constants.RSA_PKCS1_OAEP_PADDING,
38
+ },
39
+ ciphertext,
40
+ );
41
+
42
+ return b64urlEncode(plaintext);
43
+ }
@@ -0,0 +1,80 @@
1
+ import keytar from 'keytar';
2
+ import { readFileSync, writeFileSync, unlinkSync, mkdirSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { homedir } from 'node:os';
5
+
6
+ const SERVICE = 'promptcellar';
7
+ const ACCOUNT = 'vault-master';
8
+
9
+ // File-based fallback when keychain is locked/unavailable
10
+ const FALLBACK_DIR = join(homedir(), '.config', 'promptcellar');
11
+ const FALLBACK_FILE = join(FALLBACK_DIR, '.vault-key');
12
+
13
+ function readFallback() {
14
+ try {
15
+ return readFileSync(FALLBACK_FILE, 'utf8').trim() || null;
16
+ } catch {
17
+ return null;
18
+ }
19
+ }
20
+
21
+ function writeFallback(value) {
22
+ mkdirSync(FALLBACK_DIR, { recursive: true, mode: 0o700 });
23
+ writeFileSync(FALLBACK_FILE, value, { mode: 0o600 });
24
+ }
25
+
26
+ function deleteFallback() {
27
+ try {
28
+ unlinkSync(FALLBACK_FILE);
29
+ } catch {
30
+ // ignore
31
+ }
32
+ }
33
+
34
+ export function createKeychainClient(service = SERVICE, account = ACCOUNT) {
35
+ return {
36
+ store: (keyB64url) => storeVaultKey(keyB64url),
37
+ load: () => loadVaultKey(),
38
+ clear: () => clearVaultKey(),
39
+ };
40
+ }
41
+
42
+ export async function storeVaultKey(keyB64url) {
43
+ try {
44
+ await keytar.setPassword(SERVICE, ACCOUNT, keyB64url);
45
+ return;
46
+ } catch {
47
+ // Keychain unavailable — use file fallback
48
+ }
49
+ writeFallback(keyB64url);
50
+ }
51
+
52
+ export async function loadVaultKey() {
53
+ try {
54
+ const val = await keytar.getPassword(SERVICE, ACCOUNT);
55
+ if (val) return val;
56
+ } catch {
57
+ // Keychain unavailable — try file fallback
58
+ }
59
+ return readFallback();
60
+ }
61
+
62
+ export async function clearVaultKey() {
63
+ try {
64
+ await keytar.deletePassword(SERVICE, ACCOUNT);
65
+ } catch {
66
+ // ignore
67
+ }
68
+ deleteFallback();
69
+ }
70
+
71
+ export async function requireVaultKey({
72
+ silent = false,
73
+ loadVaultKeyFn = loadVaultKey,
74
+ } = {}) {
75
+ const key = await loadVaultKeyFn();
76
+ if (!key && !silent) {
77
+ throw new Error('Vault key missing. Run pc login to reauthorize.');
78
+ }
79
+ return key;
80
+ }
@@ -0,0 +1,61 @@
1
+ import Conf from 'conf';
2
+
3
+ const state = new Conf({
4
+ projectName: 'promptcellar',
5
+ configName: 'capture-state',
6
+ schema: {
7
+ threads: {
8
+ type: 'object',
9
+ default: {}
10
+ }
11
+ }
12
+ });
13
+
14
+ const MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours
15
+
16
+ /**
17
+ * Get the last captured index for a thread.
18
+ * @param {string} threadId
19
+ * @returns {number}
20
+ */
21
+ export function getLastCapturedIndex(threadId) {
22
+ const threads = state.get('threads');
23
+ return threads[threadId]?.lastIndex || 0;
24
+ }
25
+
26
+ /**
27
+ * Save the last captured index for a thread.
28
+ * @param {string} threadId
29
+ * @param {number} lastIndex
30
+ */
31
+ export function saveLastCapturedIndex(threadId, lastIndex) {
32
+ const threads = state.get('threads');
33
+ threads[threadId] = {
34
+ lastIndex,
35
+ updatedAt: Date.now()
36
+ };
37
+ state.set('threads', threads);
38
+ }
39
+
40
+ /**
41
+ * Clean up stale thread entries older than maxAgeMs.
42
+ * @param {number} maxAgeMs - Default 24 hours
43
+ */
44
+ export function cleanupStaleThreads(maxAgeMs = MAX_AGE_MS) {
45
+ const threads = state.get('threads');
46
+ const now = Date.now();
47
+ let changed = false;
48
+
49
+ for (const [threadId, data] of Object.entries(threads)) {
50
+ if (now - data.updatedAt > maxAgeMs) {
51
+ delete threads[threadId];
52
+ changed = true;
53
+ }
54
+ }
55
+
56
+ if (changed) {
57
+ state.set('threads', threads);
58
+ }
59
+ }
60
+
61
+ export default { getLastCapturedIndex, saveLastCapturedIndex, cleanupStaleThreads };
@@ -3,7 +3,7 @@ import { getApiKey, getApiUrl, getSessionId } from './config.js';
3
3
  import { getFullContext } from './context.js';
4
4
 
5
5
  let socket = null;
6
- let reconnectTimer = null;
6
+
7
7
 
8
8
  export function connect(tool, onPromptReceived) {
9
9
  const apiKey = getApiKey();
@@ -47,7 +47,7 @@ export function connect(tool, onPromptReceived) {
47
47
  });
48
48
 
49
49
  socket.on('error', (data) => {
50
- console.error('WebSocket error:', data.message);
50
+ console.error('WebSocket error:', data?.message || data || 'Unknown error');
51
51
  });
52
52
 
53
53
  socket.on('disconnect', (reason) => {
@@ -65,10 +65,6 @@ export function disconnect() {
65
65
  socket.disconnect();
66
66
  socket = null;
67
67
  }
68
- if (reconnectTimer) {
69
- clearTimeout(reconnectTimer);
70
- reconnectTimer = null;
71
- }
72
68
  }
73
69
 
74
70
  export function isConnected() {
@@ -0,0 +1,121 @@
1
+ import { describe, it, beforeEach, afterEach } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import * as login from '../src/commands/login.js';
4
+
5
+ const TEST_ACCOUNT_URL = 'https://account.test';
6
+
7
+ describe('device login helpers', () => {
8
+ describe('requestDeviceCode', () => {
9
+ let originalFetch;
10
+
11
+ beforeEach(() => {
12
+ originalFetch = global.fetch;
13
+ });
14
+
15
+ afterEach(() => {
16
+ global.fetch = originalFetch;
17
+ });
18
+
19
+ it('includes transfer_public_key when provided', async () => {
20
+ const { requestDeviceCode } = login;
21
+ assert.equal(typeof requestDeviceCode, 'function', 'requestDeviceCode should be exported');
22
+
23
+ let capturedBody;
24
+ global.fetch = async (url, options) => {
25
+ capturedBody = JSON.parse(options.body);
26
+ return {
27
+ ok: true,
28
+ json: async () => ({
29
+ device_code: 'device-123',
30
+ user_code: 'user-456',
31
+ verification_url: 'https://example.com/device',
32
+ expires_in: 600,
33
+ }),
34
+ };
35
+ };
36
+
37
+ const data = await requestDeviceCode(TEST_ACCOUNT_URL, 'spki-key');
38
+
39
+ assert.equal(capturedBody.transfer_public_key, 'spki-key');
40
+ assert.ok(capturedBody.client_id, 'client_id should be set');
41
+ assert.equal(data.device_code, 'device-123');
42
+ });
43
+ });
44
+
45
+ describe('finalizeDeviceLogin', () => {
46
+ it('rejects when vault_transfer is missing', async () => {
47
+ const { finalizeDeviceLogin } = login;
48
+ assert.equal(typeof finalizeDeviceLogin, 'function', 'finalizeDeviceLogin should be exported');
49
+
50
+ await assert.rejects(
51
+ () => finalizeDeviceLogin({
52
+ approval: { access_token: 'token' },
53
+ privateKeyPem: 'private-key',
54
+ apiUrl: 'https://api.test',
55
+ deviceName: 'Test Device',
56
+ decryptTransferFn: () => 'key',
57
+ storeVaultKeyFn: async () => {},
58
+ exchangeTokenForApiKeyFn: async () => ({}),
59
+ }),
60
+ /vault transfer/i,
61
+ );
62
+ });
63
+
64
+ it('stores vault key before exchanging token', async () => {
65
+ const { finalizeDeviceLogin } = login;
66
+ assert.equal(typeof finalizeDeviceLogin, 'function', 'finalizeDeviceLogin should be exported');
67
+
68
+ const calls = [];
69
+ const approval = { access_token: 'token', vault_transfer: 'ciphertext' };
70
+
71
+ const result = await finalizeDeviceLogin({
72
+ approval,
73
+ privateKeyPem: 'private-key',
74
+ apiUrl: 'https://api.test',
75
+ deviceName: 'Test Device',
76
+ decryptTransferFn: () => {
77
+ calls.push('decrypt');
78
+ return 'master-key';
79
+ },
80
+ storeVaultKeyFn: async (key) => {
81
+ calls.push(['store', key]);
82
+ },
83
+ exchangeTokenForApiKeyFn: async (apiUrl, token, deviceName) => {
84
+ calls.push(['exchange', apiUrl, token, deviceName]);
85
+ return { api_key: 'api-key', email: 'user@example.com' };
86
+ },
87
+ });
88
+
89
+ assert.deepEqual(calls[0], 'decrypt');
90
+ assert.deepEqual(calls[1], ['store', 'master-key']);
91
+ assert.deepEqual(calls[2], ['exchange', 'https://api.test', 'token', 'Test Device']);
92
+ assert.equal(result.api_key, 'api-key');
93
+ });
94
+
95
+ it('does not exchange token when keychain store fails', async () => {
96
+ const { finalizeDeviceLogin } = login;
97
+ assert.equal(typeof finalizeDeviceLogin, 'function', 'finalizeDeviceLogin should be exported');
98
+
99
+ let exchangeCalled = false;
100
+ await assert.rejects(
101
+ () => finalizeDeviceLogin({
102
+ approval: { access_token: 'token', vault_transfer: 'ciphertext' },
103
+ privateKeyPem: 'private-key',
104
+ apiUrl: 'https://api.test',
105
+ deviceName: 'Test Device',
106
+ decryptTransferFn: () => 'master-key',
107
+ storeVaultKeyFn: async () => {
108
+ throw new Error('keychain failed');
109
+ },
110
+ exchangeTokenForApiKeyFn: async () => {
111
+ exchangeCalled = true;
112
+ return {};
113
+ },
114
+ }),
115
+ /keychain failed/i,
116
+ );
117
+
118
+ assert.equal(exchangeCalled, false);
119
+ });
120
+ });
121
+ });