@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.
- package/README.md +3 -3
- package/bin/pc.js +3 -1
- package/docs/plans/2026-01-30-wa-auth-integration-design.md +118 -0
- package/docs/plans/2026-01-30-wa-auth-integration-plan.md +776 -0
- package/docs/plans/2026-02-03-multi-prompt-capture-design.md +501 -0
- package/hooks/codex-capture.js +58 -33
- package/hooks/gemini-capture.js +37 -24
- package/hooks/prompt-capture.js +23 -34
- package/package.json +3 -2
- package/src/commands/login.js +125 -41
- package/src/commands/logout.js +6 -0
- package/src/commands/push.js +1 -29
- package/src/commands/save.js +21 -2
- package/src/commands/setup.js +45 -44
- package/src/commands/status.js +11 -0
- package/src/lib/config.js +28 -1
- package/src/lib/crypto.js +39 -0
- package/src/lib/device-transfer.js +43 -0
- package/src/lib/keychain.js +80 -0
- package/src/lib/state.js +61 -0
- package/src/lib/websocket.js +2 -6
- package/tests/device-login.test.js +121 -0
- package/tests/device-transfer.test.js +69 -0
- package/tests/keychain.test.js +40 -0
package/src/commands/setup.js
CHANGED
|
@@ -6,7 +6,7 @@ import { homedir } from 'os';
|
|
|
6
6
|
import { execFileSync } from 'child_process';
|
|
7
7
|
|
|
8
8
|
// Config paths
|
|
9
|
-
const
|
|
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
|
|
42
|
-
if (existsSync(
|
|
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(
|
|
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
|
|
53
|
-
const dir = dirname(
|
|
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(
|
|
57
|
+
writeFileSync(CLAUDE_SETTINGS_PATH, JSON.stringify(settings, null, 2));
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
function isClaudeHookInstalled(
|
|
61
|
-
const
|
|
62
|
-
return
|
|
63
|
-
hook
|
|
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?.
|
|
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
|
|
170
|
+
const settings = getClaudeSettings();
|
|
171
171
|
|
|
172
|
-
if (isClaudeHookInstalled(
|
|
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
|
-
|
|
188
|
-
!hook
|
|
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
|
-
//
|
|
193
|
-
if (!
|
|
194
|
-
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
|
|
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?.
|
|
276
|
-
settings.hooks.
|
|
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
|
-
//
|
|
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.
|
|
293
|
-
settings.hooks.
|
|
293
|
+
if (!settings.hooks.BeforeAgent) {
|
|
294
|
+
settings.hooks.BeforeAgent = [];
|
|
294
295
|
}
|
|
295
296
|
|
|
296
|
-
settings.hooks.
|
|
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
|
|
317
|
-
if (isClaudeHookInstalled(
|
|
318
|
-
|
|
319
|
-
!hook
|
|
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
|
-
|
|
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?.
|
|
343
|
-
geminiSettings.hooks.
|
|
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
|
}
|
package/src/commands/status.js
CHANGED
|
@@ -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 =
|
|
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
|
+
}
|
package/src/lib/state.js
ADDED
|
@@ -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 };
|
package/src/lib/websocket.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
+
});
|