codewhisper-agent 0.1.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/bin/cli.js +347 -0
- package/package.json +39 -0
- package/src/agent.js +440 -0
- package/src/config-store.js +106 -0
- package/src/crypto.js +50 -0
package/bin/cli.js
ADDED
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const Agent = require('../src/agent');
|
|
4
|
+
const ConfigStore = require('../src/config-store');
|
|
5
|
+
const { generateKeypair, deriveSharedSecret } = require('../src/crypto');
|
|
6
|
+
|
|
7
|
+
// --- Parse CLI args ---
|
|
8
|
+
const args = process.argv.slice(2);
|
|
9
|
+
|
|
10
|
+
function printHelp() {
|
|
11
|
+
console.log(`
|
|
12
|
+
codewhisper-agent - Control AI coding CLIs from your phone
|
|
13
|
+
|
|
14
|
+
Usage:
|
|
15
|
+
codewhisper-agent [options]
|
|
16
|
+
|
|
17
|
+
Options:
|
|
18
|
+
--login Pair with your phone using a pairing code
|
|
19
|
+
--name <name> Agent name (default: project folder name)
|
|
20
|
+
--agent-id <id> Agent ID (auto-generated if not set)
|
|
21
|
+
--user-id <id> User ID (set via --login pairing)
|
|
22
|
+
--project <path> Project directory path
|
|
23
|
+
--cli <claude|gemini> CLI type (default: claude)
|
|
24
|
+
--cli-path <path> Custom CLI binary path
|
|
25
|
+
--backend <url> Backend WebSocket URL
|
|
26
|
+
--permission <mode> Permission mode: full|approve|readonly (default: full)
|
|
27
|
+
--allowed-tools <t> Comma-separated tools for approve mode
|
|
28
|
+
--config Show current config
|
|
29
|
+
--reset Reset config to defaults
|
|
30
|
+
--help Show this help
|
|
31
|
+
`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function parseArgs() {
|
|
35
|
+
const opts = {};
|
|
36
|
+
for (let i = 0; i < args.length; i++) {
|
|
37
|
+
switch (args[i]) {
|
|
38
|
+
case '--name': opts.agentName = args[++i]; break;
|
|
39
|
+
case '--agent-id': opts.agentId = args[++i]; break;
|
|
40
|
+
case '--user-id': opts.userId = args[++i]; break;
|
|
41
|
+
case '--project': opts.projectPath = args[++i]; break;
|
|
42
|
+
case '--cli': opts.cliType = args[++i]; break;
|
|
43
|
+
case '--cli-path': opts.cliPath = args[++i]; break;
|
|
44
|
+
case '--backend': opts.backendWsUrl = args[++i]; break;
|
|
45
|
+
case '--permission': opts.permissionMode = args[++i]; break;
|
|
46
|
+
case '--allowed-tools': opts.allowedTools = args[++i]; break;
|
|
47
|
+
case '--login': opts._login = true; break;
|
|
48
|
+
case '--config': opts._showConfig = true; break;
|
|
49
|
+
case '--reset': opts._reset = true; break;
|
|
50
|
+
case '--help': case '-h': printHelp(); process.exit(0);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return opts;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const opts = parseArgs();
|
|
57
|
+
|
|
58
|
+
if (opts._reset) {
|
|
59
|
+
const fs = require('fs');
|
|
60
|
+
const path = require('path');
|
|
61
|
+
const os = require('os');
|
|
62
|
+
const configDir = path.join(os.homedir(), '.codewhisper');
|
|
63
|
+
const configFile = path.join(configDir, 'config.json');
|
|
64
|
+
const projectsDir = path.join(configDir, 'projects');
|
|
65
|
+
if (fs.existsSync(configFile)) fs.unlinkSync(configFile);
|
|
66
|
+
if (fs.existsSync(projectsDir)) fs.rmSync(projectsDir, { recursive: true });
|
|
67
|
+
console.log('Config reset to defaults.');
|
|
68
|
+
process.exit(0);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Determine project path: CLI arg > CWD
|
|
72
|
+
const projectPath = opts.projectPath || process.cwd();
|
|
73
|
+
const configStore = new ConfigStore(projectPath);
|
|
74
|
+
|
|
75
|
+
// Set projectPath in config if not already
|
|
76
|
+
if (!configStore.get('projectPath')) {
|
|
77
|
+
configStore.set('projectPath', projectPath);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Default agent name to project folder name if not set by flag or config
|
|
81
|
+
const path = require('path');
|
|
82
|
+
if (!opts.agentName && configStore.get('agentName') === 'my-pc') {
|
|
83
|
+
const folderName = path.basename(projectPath);
|
|
84
|
+
if (folderName) configStore.set('agentName', folderName);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Merge CLI args into saved config
|
|
88
|
+
const { _showConfig, _reset, _login, ...overrides } = opts;
|
|
89
|
+
const cleanOverrides = Object.fromEntries(Object.entries(overrides).filter(([, v]) => v !== undefined));
|
|
90
|
+
if (Object.keys(cleanOverrides).length > 0) {
|
|
91
|
+
configStore.setAll(cleanOverrides);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (_showConfig) {
|
|
95
|
+
console.log('\nCurrent config:\n');
|
|
96
|
+
console.log(JSON.stringify(configStore.getAll(), null, 2));
|
|
97
|
+
process.exit(0);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// --- Pairing helpers ---
|
|
101
|
+
|
|
102
|
+
function getBackendHttpUrl() {
|
|
103
|
+
const wsUrl = configStore.get('backendWsUrl');
|
|
104
|
+
return wsUrl.replace(/^wss:/, 'https:').replace(/^ws:/, 'http:');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function generateAgentId() {
|
|
108
|
+
const os = require('os');
|
|
109
|
+
const crypto = require('crypto');
|
|
110
|
+
const hostname = os.hostname().toLowerCase().replace(/[^a-z0-9]/g, '').slice(0, 12) || 'agent';
|
|
111
|
+
const suffix = crypto.randomBytes(2).toString('hex');
|
|
112
|
+
return `${hostname}-${suffix}`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function promptForCode() {
|
|
116
|
+
const readline = require('readline');
|
|
117
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
118
|
+
return new Promise((resolve) => {
|
|
119
|
+
rl.question('\x1b[36m Enter pairing code: \x1b[0m', (answer) => {
|
|
120
|
+
rl.close();
|
|
121
|
+
resolve(answer.trim().toUpperCase());
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function claimPairingCode(code, agentPublicKey) {
|
|
127
|
+
const baseUrl = getBackendHttpUrl();
|
|
128
|
+
const res = await fetch(`${baseUrl}/api/pairing/claim`, {
|
|
129
|
+
method: 'POST',
|
|
130
|
+
headers: { 'Content-Type': 'application/json' },
|
|
131
|
+
body: JSON.stringify({ code, agent_public_key: agentPublicKey || undefined }),
|
|
132
|
+
});
|
|
133
|
+
const data = await res.json();
|
|
134
|
+
if (!res.ok) {
|
|
135
|
+
throw new Error(data.detail || 'Failed to claim pairing code');
|
|
136
|
+
}
|
|
137
|
+
return data;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function generateAgentPairingCode(agentPublicKey) {
|
|
141
|
+
const baseUrl = getBackendHttpUrl();
|
|
142
|
+
const res = await fetch(`${baseUrl}/api/pairing/generate-for-agent`, {
|
|
143
|
+
method: 'POST',
|
|
144
|
+
headers: { 'Content-Type': 'application/json' },
|
|
145
|
+
body: JSON.stringify({ agent_public_key: agentPublicKey || undefined }),
|
|
146
|
+
});
|
|
147
|
+
const data = await res.json();
|
|
148
|
+
if (!res.ok) {
|
|
149
|
+
throw new Error(data.detail || 'Failed to generate pairing code');
|
|
150
|
+
}
|
|
151
|
+
return data;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function pollPairing(pairingId) {
|
|
155
|
+
const baseUrl = getBackendHttpUrl();
|
|
156
|
+
const res = await fetch(`${baseUrl}/api/pairing/poll/${pairingId}`);
|
|
157
|
+
const data = await res.json();
|
|
158
|
+
if (!res.ok) {
|
|
159
|
+
throw new Error(data.detail || 'Poll failed');
|
|
160
|
+
}
|
|
161
|
+
return data;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function displayQrCode(url) {
|
|
165
|
+
const qrcode = require('qrcode-terminal');
|
|
166
|
+
return new Promise((resolve) => {
|
|
167
|
+
qrcode.generate(url, { small: true }, (qr) => {
|
|
168
|
+
console.log(qr);
|
|
169
|
+
resolve();
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function qrLoginFlow() {
|
|
175
|
+
console.log('\n\x1b[36m Generating pairing code...\x1b[0m\n');
|
|
176
|
+
|
|
177
|
+
// Generate X25519 keypair for E2E encryption
|
|
178
|
+
const kp = generateKeypair();
|
|
179
|
+
configStore.set('publicKey', kp.publicKey);
|
|
180
|
+
configStore.set('secretKey', kp.secretKey);
|
|
181
|
+
|
|
182
|
+
const { code, pairing_id, expires_at } = await generateAgentPairingCode(kp.publicKey);
|
|
183
|
+
const deepLink = `codewhisper://pair?code=${code}`;
|
|
184
|
+
|
|
185
|
+
// Display QR code
|
|
186
|
+
await displayQrCode(deepLink);
|
|
187
|
+
|
|
188
|
+
console.log(` \x1b[36mScan the QR code above with CodeWhisper app\x1b[0m`);
|
|
189
|
+
console.log(` \x1b[36mOr enter this code manually:\x1b[0m \x1b[1;37m${code}\x1b[0m`);
|
|
190
|
+
console.log(` \x1b[90mExpires: ${new Date(expires_at).toLocaleTimeString()}\x1b[0m\n`);
|
|
191
|
+
console.log(' Waiting for phone to claim...');
|
|
192
|
+
|
|
193
|
+
// Poll every 2 seconds
|
|
194
|
+
const POLL_INTERVAL = 2000;
|
|
195
|
+
const expiresMs = new Date(expires_at).getTime();
|
|
196
|
+
|
|
197
|
+
return new Promise((resolve, reject) => {
|
|
198
|
+
const interval = setInterval(async () => {
|
|
199
|
+
if (Date.now() > expiresMs) {
|
|
200
|
+
clearInterval(interval);
|
|
201
|
+
reject(new Error('Pairing code expired'));
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
try {
|
|
205
|
+
const result = await pollPairing(pairing_id);
|
|
206
|
+
if (result.claimed && result.user_id) {
|
|
207
|
+
clearInterval(interval);
|
|
208
|
+
// Derive shared secret if phone sent its public key
|
|
209
|
+
if (result.phone_public_key) {
|
|
210
|
+
const shared = deriveSharedSecret(kp.secretKey, result.phone_public_key);
|
|
211
|
+
configStore.set('sharedSecret', shared);
|
|
212
|
+
console.log(' \x1b[32mE2E encryption enabled\x1b[0m');
|
|
213
|
+
}
|
|
214
|
+
resolve(result.user_id);
|
|
215
|
+
}
|
|
216
|
+
} catch {
|
|
217
|
+
// ignore poll errors, keep trying until expiry
|
|
218
|
+
}
|
|
219
|
+
}, POLL_INTERVAL);
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function loginFlow() {
|
|
224
|
+
console.log('\n\x1b[36m Pairing with your phone...\x1b[0m\n');
|
|
225
|
+
|
|
226
|
+
// Try QR flow first, fall back to manual code entry
|
|
227
|
+
try {
|
|
228
|
+
const userId = await qrLoginFlow();
|
|
229
|
+
configStore.set('userId', userId);
|
|
230
|
+
if (!configStore.get('agentId')) {
|
|
231
|
+
configStore.set('agentId', generateAgentId());
|
|
232
|
+
}
|
|
233
|
+
console.log('\n\x1b[32m ✓ Paired successfully!\x1b[0m');
|
|
234
|
+
console.log(` User ID: ${userId}`);
|
|
235
|
+
console.log(` Agent ID: ${configStore.get('agentId')}\n`);
|
|
236
|
+
return;
|
|
237
|
+
} catch (err) {
|
|
238
|
+
console.log(`\n\x1b[33m QR pairing failed: ${err.message}\x1b[0m`);
|
|
239
|
+
console.log(' \x1b[33mFalling back to manual code entry...\x1b[0m\n');
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Manual fallback (original flow)
|
|
243
|
+
// Generate X25519 keypair for E2E encryption
|
|
244
|
+
const manualKp = generateKeypair();
|
|
245
|
+
configStore.set('publicKey', manualKp.publicKey);
|
|
246
|
+
configStore.set('secretKey', manualKp.secretKey);
|
|
247
|
+
|
|
248
|
+
console.log(' Open CodeWhisper on your phone and tap "Add Agent" to get a code.\n');
|
|
249
|
+
const code = await promptForCode();
|
|
250
|
+
if (!code) {
|
|
251
|
+
console.error('\n No code entered. Exiting.');
|
|
252
|
+
process.exit(1);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
try {
|
|
256
|
+
const result = await claimPairingCode(code, manualKp.publicKey);
|
|
257
|
+
configStore.set('userId', result.user_id);
|
|
258
|
+
|
|
259
|
+
// Derive shared secret if phone sent its public key
|
|
260
|
+
if (result.phone_public_key) {
|
|
261
|
+
const shared = deriveSharedSecret(manualKp.secretKey, result.phone_public_key);
|
|
262
|
+
configStore.set('sharedSecret', shared);
|
|
263
|
+
console.log(' \x1b[32mE2E encryption enabled\x1b[0m');
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (!configStore.get('agentId')) {
|
|
267
|
+
configStore.set('agentId', generateAgentId());
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
console.log('\n\x1b[32m ✓ Paired successfully!\x1b[0m');
|
|
271
|
+
console.log(` User ID: ${result.user_id}`);
|
|
272
|
+
console.log(` Agent ID: ${configStore.get('agentId')}\n`);
|
|
273
|
+
} catch (err) {
|
|
274
|
+
console.error(`\n\x1b[31m ✗ Pairing failed: ${err.message}\x1b[0m`);
|
|
275
|
+
process.exit(1);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// --- Main ---
|
|
280
|
+
|
|
281
|
+
async function main() {
|
|
282
|
+
// Login if explicitly requested or if userId is missing
|
|
283
|
+
const needsLogin = _login || !configStore.get('userId');
|
|
284
|
+
if (needsLogin) {
|
|
285
|
+
await loginFlow();
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Auto-generate agentId if still missing
|
|
289
|
+
if (!configStore.get('agentId')) {
|
|
290
|
+
const agentId = generateAgentId();
|
|
291
|
+
configStore.set('agentId', agentId);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const config = configStore.getAll();
|
|
295
|
+
|
|
296
|
+
// --- Start agent ---
|
|
297
|
+
console.log(`
|
|
298
|
+
╔═══════════════════════════════════════╗
|
|
299
|
+
║ CodeWhisper Agent ║
|
|
300
|
+
╚═══════════════════════════════════════╝
|
|
301
|
+
`);
|
|
302
|
+
console.log(` Agent: ${config.agentName} (${config.agentId})`);
|
|
303
|
+
console.log(` CLI: ${config.cliType}`);
|
|
304
|
+
console.log(` Project: ${config.projectPath || '(not set)'}`);
|
|
305
|
+
console.log(` Mode: ${config.permissionMode || 'full'}`);
|
|
306
|
+
console.log(` Backend: ${config.backendWsUrl}`);
|
|
307
|
+
console.log('');
|
|
308
|
+
|
|
309
|
+
const agent = new Agent(config, {
|
|
310
|
+
onConnected: () => {
|
|
311
|
+
console.log('\x1b[32m● Connected to backend\x1b[0m');
|
|
312
|
+
console.log(' Waiting for commands from your phone...\n');
|
|
313
|
+
},
|
|
314
|
+
onDisconnected: (reason) => {
|
|
315
|
+
console.log(`\x1b[31m● Disconnected: ${reason}\x1b[0m`);
|
|
316
|
+
},
|
|
317
|
+
onLog: (msg) => {
|
|
318
|
+
console.log(` ${msg}`);
|
|
319
|
+
},
|
|
320
|
+
onActivity: (activity) => {
|
|
321
|
+
if (activity.type === 'command') {
|
|
322
|
+
console.log(`\n\x1b[36m▶ Command: ${activity.prompt}\x1b[0m`);
|
|
323
|
+
} else if (activity.type === 'completed') {
|
|
324
|
+
console.log(`\x1b[32m✓ Completed\x1b[0m\n`);
|
|
325
|
+
}
|
|
326
|
+
},
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
agent.start();
|
|
330
|
+
|
|
331
|
+
// Graceful shutdown
|
|
332
|
+
process.on('SIGINT', () => {
|
|
333
|
+
console.log('\nShutting down...');
|
|
334
|
+
agent.stop();
|
|
335
|
+
process.exit(0);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
process.on('SIGTERM', () => {
|
|
339
|
+
agent.stop();
|
|
340
|
+
process.exit(0);
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
main().catch((err) => {
|
|
345
|
+
console.error(`\nFatal: ${err.message}`);
|
|
346
|
+
process.exit(1);
|
|
347
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "codewhisper-agent",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "CLI agent for CodeWhisper - remotely control AI coding CLIs (Claude Code, Gemini CLI and more) from your mobile",
|
|
5
|
+
"main": "src/agent.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"codewhisper-agent": "./bin/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"start": "node bin/cli.js"
|
|
11
|
+
},
|
|
12
|
+
"keywords": [
|
|
13
|
+
"codewhisper",
|
|
14
|
+
"claude-code",
|
|
15
|
+
"gemini-cli",
|
|
16
|
+
"remote",
|
|
17
|
+
"mobile",
|
|
18
|
+
"ai",
|
|
19
|
+
"coding",
|
|
20
|
+
"agent"
|
|
21
|
+
],
|
|
22
|
+
"author": "wecodeapps",
|
|
23
|
+
"license": "MIT",
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"qrcode-terminal": "^0.12.0",
|
|
26
|
+
"tweetnacl": "^1.0.3",
|
|
27
|
+
"tweetnacl-util": "^0.15.1",
|
|
28
|
+
"ws": "^8.18.0"
|
|
29
|
+
},
|
|
30
|
+
"engines": {
|
|
31
|
+
"node": ">=18.0.0"
|
|
32
|
+
},
|
|
33
|
+
"files": [
|
|
34
|
+
"bin/",
|
|
35
|
+
"src/agent.js",
|
|
36
|
+
"src/config-store.js",
|
|
37
|
+
"src/crypto.js"
|
|
38
|
+
]
|
|
39
|
+
}
|
package/src/agent.js
ADDED
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
const { spawn, execFile } = require('child_process');
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const WebSocket = require('ws');
|
|
5
|
+
const { encrypt, decrypt } = require('./crypto');
|
|
6
|
+
|
|
7
|
+
class Agent {
|
|
8
|
+
constructor(config, callbacks) {
|
|
9
|
+
this._config = config;
|
|
10
|
+
this._cb = callbacks;
|
|
11
|
+
this._ws = null;
|
|
12
|
+
this._connected = false;
|
|
13
|
+
this._heartbeatTimer = null;
|
|
14
|
+
this._reconnectTimer = null;
|
|
15
|
+
this._currentProc = null;
|
|
16
|
+
this._cliReady = false;
|
|
17
|
+
this._cliVersion = '';
|
|
18
|
+
this._stopped = false;
|
|
19
|
+
this._sessionId = null; // Claude Code conversation ID for --resume
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async start() {
|
|
23
|
+
this._stopped = false;
|
|
24
|
+
this._log('Agent starting...');
|
|
25
|
+
await this._setupCli();
|
|
26
|
+
if (!this._cliReady) {
|
|
27
|
+
this._cb.onDisconnected?.(`${this._config.cliType} CLI not found`);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
this._connect();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
stop() {
|
|
34
|
+
this._stopped = true;
|
|
35
|
+
this._killProcess();
|
|
36
|
+
clearInterval(this._heartbeatTimer);
|
|
37
|
+
clearTimeout(this._reconnectTimer);
|
|
38
|
+
if (this._ws) {
|
|
39
|
+
this._ws.close();
|
|
40
|
+
this._ws = null;
|
|
41
|
+
}
|
|
42
|
+
this._connected = false;
|
|
43
|
+
this._log('Agent stopped');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
getCliStatus() {
|
|
47
|
+
return { ready: this._cliReady, version: this._cliVersion, type: this._config.cliType };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// --- CLI ---
|
|
51
|
+
|
|
52
|
+
_getCliPath() {
|
|
53
|
+
return this._config.cliPath || this._config.cliType || 'claude';
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
_setupCli() {
|
|
57
|
+
return new Promise((resolve) => {
|
|
58
|
+
const cliPath = this._getCliPath();
|
|
59
|
+
execFile(cliPath, ['--version'], (err, stdout) => {
|
|
60
|
+
if (err) {
|
|
61
|
+
this._cliReady = false;
|
|
62
|
+
this._log(`${this._config.cliType} CLI not found at: ${cliPath}`);
|
|
63
|
+
} else {
|
|
64
|
+
this._cliReady = true;
|
|
65
|
+
this._cliVersion = stdout.trim();
|
|
66
|
+
this._log(`${this._config.cliType} CLI found: ${this._cliVersion}`);
|
|
67
|
+
}
|
|
68
|
+
resolve();
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
_buildCliArgs(prompt) {
|
|
74
|
+
if (this._config.cliType === 'gemini') {
|
|
75
|
+
return ['-p', prompt];
|
|
76
|
+
}
|
|
77
|
+
// Claude: --resume keeps the same conversation going
|
|
78
|
+
const args = ['-p', prompt, '--output-format', 'stream-json', '--verbose'];
|
|
79
|
+
|
|
80
|
+
// Permission mode
|
|
81
|
+
const mode = this._config.permissionMode || 'full';
|
|
82
|
+
if (mode === 'full') {
|
|
83
|
+
args.push('--dangerously-skip-permissions');
|
|
84
|
+
}
|
|
85
|
+
// 'approve' mode: no skip flag → Claude Code will need approval via allowedTools
|
|
86
|
+
// 'readonly' mode: add --permission-mode readonly (Claude Code blocks writes)
|
|
87
|
+
|
|
88
|
+
if (this._sessionId) {
|
|
89
|
+
args.push('--resume', this._sessionId);
|
|
90
|
+
this._log(`Resuming session: ${this._sessionId.substring(0, 8)}...`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Allowed tools for approve mode
|
|
94
|
+
if (mode === 'approve' && this._config.allowedTools) {
|
|
95
|
+
for (const tool of this._config.allowedTools.split(',').map(t => t.trim()).filter(Boolean)) {
|
|
96
|
+
args.push('--allowedTools', tool);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return args;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async *_executeCli(prompt) {
|
|
104
|
+
const cliPath = this._getCliPath();
|
|
105
|
+
const args = this._buildCliArgs(prompt);
|
|
106
|
+
|
|
107
|
+
this._log(`Executing: ${this._config.cliType} -p '${prompt.substring(0, 60)}'`);
|
|
108
|
+
|
|
109
|
+
const proc = spawn(cliPath, args, {
|
|
110
|
+
cwd: this._config.projectPath || undefined,
|
|
111
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
112
|
+
});
|
|
113
|
+
this._currentProc = proc;
|
|
114
|
+
|
|
115
|
+
let buffer = '';
|
|
116
|
+
for await (const chunk of proc.stdout) {
|
|
117
|
+
buffer += chunk.toString();
|
|
118
|
+
const lines = buffer.split('\n');
|
|
119
|
+
buffer = lines.pop();
|
|
120
|
+
|
|
121
|
+
for (const line of lines) {
|
|
122
|
+
if (!line.trim()) continue;
|
|
123
|
+
|
|
124
|
+
if (this._config.cliType === 'claude') {
|
|
125
|
+
try {
|
|
126
|
+
const data = JSON.parse(line);
|
|
127
|
+
const content = this._extractContent(data);
|
|
128
|
+
if (content) yield content;
|
|
129
|
+
} catch {
|
|
130
|
+
yield line;
|
|
131
|
+
}
|
|
132
|
+
} else {
|
|
133
|
+
yield line;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if (buffer.trim()) yield buffer;
|
|
138
|
+
|
|
139
|
+
await new Promise((resolve) => {
|
|
140
|
+
if (proc.exitCode !== null) return resolve();
|
|
141
|
+
proc.on('close', resolve);
|
|
142
|
+
});
|
|
143
|
+
this._currentProc = null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
_extractContent(data) {
|
|
147
|
+
// Capture session ID from init or result events
|
|
148
|
+
if (data.session_id && !this._sessionId) {
|
|
149
|
+
this._sessionId = data.session_id;
|
|
150
|
+
this._log(`Session started: ${this._sessionId.substring(0, 8)}...`);
|
|
151
|
+
}
|
|
152
|
+
if (data.type === 'system' && data.session_id) {
|
|
153
|
+
this._sessionId = data.session_id;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (data.type === 'assistant') {
|
|
157
|
+
const blocks = data.message?.content || [];
|
|
158
|
+
const parts = blocks.filter((b) => b.type === 'text').map((b) => b.text || '');
|
|
159
|
+
return parts.length ? parts.join('\n') : null;
|
|
160
|
+
}
|
|
161
|
+
if (data.type === 'result') {
|
|
162
|
+
// Result event may also contain session_id
|
|
163
|
+
if (data.session_id) this._sessionId = data.session_id;
|
|
164
|
+
return data.result || null;
|
|
165
|
+
}
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
_killProcess() {
|
|
170
|
+
if (this._currentProc && this._currentProc.exitCode === null) {
|
|
171
|
+
this._currentProc.kill('SIGTERM');
|
|
172
|
+
setTimeout(() => {
|
|
173
|
+
if (this._currentProc?.exitCode === null) this._currentProc.kill('SIGKILL');
|
|
174
|
+
}, 5000);
|
|
175
|
+
this._currentProc = null;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// --- Git ---
|
|
180
|
+
|
|
181
|
+
_gitRun(...args) {
|
|
182
|
+
return new Promise((resolve) => {
|
|
183
|
+
execFile('git', args, { cwd: this._config.projectPath || '.' }, (err, stdout, stderr) => {
|
|
184
|
+
if (err) resolve('');
|
|
185
|
+
else resolve(stdout.trim());
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async _gitStatus() {
|
|
191
|
+
const [raw, branch] = await Promise.all([
|
|
192
|
+
this._gitRun('status', '--porcelain'),
|
|
193
|
+
this._gitRun('branch', '--show-current'),
|
|
194
|
+
]);
|
|
195
|
+
const files = raw.split('\n').filter((l) => l.length >= 3).map((l) => ({
|
|
196
|
+
status: l.substring(0, 2).trim(),
|
|
197
|
+
path: l.substring(3),
|
|
198
|
+
}));
|
|
199
|
+
return { branch, files };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async _gitLog(limit = 10) {
|
|
203
|
+
const raw = await this._gitRun('log', `-${limit}`, '--pretty=format:%H||%an||%s||%ci');
|
|
204
|
+
return raw.split('\n').filter(Boolean).map((line) => {
|
|
205
|
+
const [hash, author, message, date] = line.split('||', 4);
|
|
206
|
+
return { hash: hash?.substring(0, 8), author, message, date };
|
|
207
|
+
}).filter((c) => c.date);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// --- WebSocket ---
|
|
211
|
+
|
|
212
|
+
_connect() {
|
|
213
|
+
if (this._stopped) return;
|
|
214
|
+
|
|
215
|
+
const params = new URLSearchParams({
|
|
216
|
+
user_id: this._config.userId,
|
|
217
|
+
name: this._config.agentName,
|
|
218
|
+
project_path: this._config.projectPath,
|
|
219
|
+
cli_type: this._config.cliType,
|
|
220
|
+
});
|
|
221
|
+
const url = `${this._config.backendWsUrl}/ws/agent/${this._config.agentId}?${params}`;
|
|
222
|
+
|
|
223
|
+
this._log(`Connecting to backend...`);
|
|
224
|
+
const ws = new WebSocket(url);
|
|
225
|
+
this._ws = ws;
|
|
226
|
+
|
|
227
|
+
ws.on('open', () => {
|
|
228
|
+
this._connected = true;
|
|
229
|
+
this._log('Connected to backend');
|
|
230
|
+
this._cb.onConnected?.();
|
|
231
|
+
this._startHeartbeat();
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
ws.on('message', (raw) => {
|
|
235
|
+
try {
|
|
236
|
+
const data = JSON.parse(raw.toString());
|
|
237
|
+
this._onMessage(data);
|
|
238
|
+
} catch {}
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
ws.on('close', () => {
|
|
242
|
+
this._connected = false;
|
|
243
|
+
this._stopHeartbeat();
|
|
244
|
+
this._cb.onDisconnected?.('Connection closed');
|
|
245
|
+
this._scheduleReconnect();
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
ws.on('error', (err) => {
|
|
249
|
+
this._connected = false;
|
|
250
|
+
this._stopHeartbeat();
|
|
251
|
+
this._cb.onDisconnected?.(err.message);
|
|
252
|
+
this._scheduleReconnect();
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
_scheduleReconnect() {
|
|
257
|
+
if (this._stopped) return;
|
|
258
|
+
this._reconnectTimer = setTimeout(() => this._connect(), this._config.reconnectInterval * 1000);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
_send(message) {
|
|
262
|
+
if (this._ws?.readyState !== WebSocket.OPEN) return;
|
|
263
|
+
|
|
264
|
+
const sharedSecret = this._config.sharedSecret;
|
|
265
|
+
if (sharedSecret && message.type !== 'pong') {
|
|
266
|
+
const encrypted = encrypt(message.payload || {}, sharedSecret);
|
|
267
|
+
if (encrypted) {
|
|
268
|
+
this._ws.send(JSON.stringify({
|
|
269
|
+
type: message.type,
|
|
270
|
+
agent_id: this._config.agentId,
|
|
271
|
+
encrypted,
|
|
272
|
+
}));
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
this._ws.send(JSON.stringify(message));
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
_startHeartbeat() {
|
|
280
|
+
this._stopHeartbeat();
|
|
281
|
+
this._heartbeatTimer = setInterval(() => this._send({ type: 'pong' }), this._config.heartbeatInterval * 1000);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
_stopHeartbeat() {
|
|
285
|
+
clearInterval(this._heartbeatTimer);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// --- Message handling ---
|
|
289
|
+
|
|
290
|
+
_onMessage(data) {
|
|
291
|
+
// E2E decryption: if message has encrypted field, decrypt it
|
|
292
|
+
if (data.encrypted && this._config.sharedSecret) {
|
|
293
|
+
const decrypted = decrypt(data.encrypted, this._config.sharedSecret);
|
|
294
|
+
if (decrypted) {
|
|
295
|
+
data.payload = decrypted;
|
|
296
|
+
} else {
|
|
297
|
+
this._log('Failed to decrypt message');
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const payload = data.payload || {};
|
|
303
|
+
|
|
304
|
+
if (data.type === 'command') {
|
|
305
|
+
const cmdType = payload.command_type || 'natural_language';
|
|
306
|
+
if (cmdType.startsWith('git_')) {
|
|
307
|
+
this._handleGit(payload);
|
|
308
|
+
} else {
|
|
309
|
+
this._handleCommand(payload);
|
|
310
|
+
}
|
|
311
|
+
} else if (data.type === 'settings_sync' || data.type === 'settings_update') {
|
|
312
|
+
this._applySettings(payload);
|
|
313
|
+
} else if (data.type === 'ping') {
|
|
314
|
+
this._send({ type: 'pong' });
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
async _handleCommand(payload) {
|
|
319
|
+
const commandId = payload.command_id || '';
|
|
320
|
+
const prompt = payload.prompt || '';
|
|
321
|
+
const cmdType = payload.command_type || 'natural_language';
|
|
322
|
+
|
|
323
|
+
this._log(`Command received: type=${cmdType}, id=${commandId}`);
|
|
324
|
+
this._cb.onActivity?.({ type: 'command', prompt, commandId, time: new Date().toISOString() });
|
|
325
|
+
|
|
326
|
+
if (cmdType === 'kill') {
|
|
327
|
+
this._killProcess();
|
|
328
|
+
this._send({ type: 'completed', payload: { command_id: commandId, output: 'Cancelled' } });
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
if (cmdType === 'new_session') {
|
|
333
|
+
this._sessionId = null;
|
|
334
|
+
this._log('Session reset — next command starts fresh');
|
|
335
|
+
this._send({ type: 'completed', payload: { command_id: commandId, output: 'New session started' } });
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (cmdType === 'spawn_agent') {
|
|
340
|
+
this._handleSpawnAgent(payload);
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (cmdType === 'stop_agent') {
|
|
345
|
+
this._send({ type: 'completed', payload: { command_id: commandId, output: 'Agent stopping' } });
|
|
346
|
+
setTimeout(() => process.exit(0), 500);
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
if (!this._cliReady) {
|
|
351
|
+
this._send({ type: 'error', payload: { command_id: commandId, error: 'CLI not ready' } });
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const outputLines = [];
|
|
356
|
+
try {
|
|
357
|
+
for await (const chunk of this._executeCli(prompt)) {
|
|
358
|
+
outputLines.push(chunk);
|
|
359
|
+
this._send({ type: 'stream', payload: { command_id: commandId, line: chunk } });
|
|
360
|
+
}
|
|
361
|
+
} catch (e) {
|
|
362
|
+
this._send({ type: 'error', payload: { command_id: commandId, error: e.message } });
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
this._send({ type: 'completed', payload: { command_id: commandId, output: outputLines.join('') } });
|
|
367
|
+
this._cb.onActivity?.({ type: 'completed', commandId, time: new Date().toISOString() });
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
_handleSpawnAgent(payload) {
|
|
371
|
+
const commandId = payload.command_id || '';
|
|
372
|
+
const projectPath = payload.project_path;
|
|
373
|
+
|
|
374
|
+
if (!projectPath || !fs.existsSync(projectPath)) {
|
|
375
|
+
this._send({ type: 'error', payload: { command_id: commandId, error: `Path not found: ${projectPath}` } });
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
try {
|
|
380
|
+
const cliScript = path.resolve(__dirname, '..', 'bin', 'cli.js');
|
|
381
|
+
const args = [cliScript, '--project', projectPath];
|
|
382
|
+
if (payload.cli_type) args.push('--cli', payload.cli_type);
|
|
383
|
+
if (payload.agent_name) args.push('--name', payload.agent_name);
|
|
384
|
+
|
|
385
|
+
const child = spawn(process.execPath, args, {
|
|
386
|
+
detached: true,
|
|
387
|
+
stdio: 'ignore',
|
|
388
|
+
cwd: projectPath,
|
|
389
|
+
});
|
|
390
|
+
child.unref();
|
|
391
|
+
|
|
392
|
+
this._log(`Spawned child agent (PID ${child.pid}) for ${projectPath}`);
|
|
393
|
+
this._send({ type: 'completed', payload: { command_id: commandId, output: `Agent spawned for ${projectPath}` } });
|
|
394
|
+
} catch (e) {
|
|
395
|
+
this._send({ type: 'error', payload: { command_id: commandId, error: e.message } });
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
async _handleGit(payload) {
|
|
400
|
+
const cmdType = payload.command_type;
|
|
401
|
+
|
|
402
|
+
if (cmdType === 'git_status') {
|
|
403
|
+
const data = await this._gitStatus();
|
|
404
|
+
this._send({ type: 'status', payload: { git_status: data } });
|
|
405
|
+
} else if (cmdType === 'git_log') {
|
|
406
|
+
const data = await this._gitLog(payload.limit || 10);
|
|
407
|
+
this._send({ type: 'status', payload: { git_log: data } });
|
|
408
|
+
} else if (cmdType === 'git_diff') {
|
|
409
|
+
const [diff, staged] = await Promise.all([this._gitRun('diff'), this._gitRun('diff', '--staged')]);
|
|
410
|
+
this._send({ type: 'status', payload: { git_diff: diff, git_diff_staged: staged } });
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
_applySettings(settings) {
|
|
415
|
+
const keyMap = {
|
|
416
|
+
permission_mode: 'permissionMode',
|
|
417
|
+
allowed_tools: 'allowedTools',
|
|
418
|
+
cli_type: 'cliType',
|
|
419
|
+
cli_path: 'cliPath',
|
|
420
|
+
project_path: 'projectPath',
|
|
421
|
+
auto_connect: 'autoConnect',
|
|
422
|
+
reconnect_interval: 'reconnectInterval',
|
|
423
|
+
heartbeat_interval: 'heartbeatInterval',
|
|
424
|
+
};
|
|
425
|
+
for (const [dbKey, configKey] of Object.entries(keyMap)) {
|
|
426
|
+
if (settings[dbKey] !== undefined) {
|
|
427
|
+
this._config[configKey] = settings[dbKey];
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
this._log(`Settings synced from server`);
|
|
431
|
+
this._cb.onSettingsUpdate?.(this._config);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
_log(msg) {
|
|
435
|
+
const ts = new Date().toISOString().substring(11, 19);
|
|
436
|
+
this._cb.onLog?.(`${ts} ${msg}`);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
module.exports = Agent;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const os = require('os');
|
|
4
|
+
const crypto = require('crypto');
|
|
5
|
+
|
|
6
|
+
const CONFIG_DIR = path.join(os.homedir(), '.codewhisper');
|
|
7
|
+
const GLOBAL_FILE = path.join(CONFIG_DIR, 'config.json');
|
|
8
|
+
const PROJECTS_DIR = path.join(CONFIG_DIR, 'projects');
|
|
9
|
+
|
|
10
|
+
// Global keys — shared across all projects
|
|
11
|
+
const GLOBAL_KEYS = ['userId', 'backendWsUrl', 'secretKey', 'publicKey', 'sharedSecret'];
|
|
12
|
+
|
|
13
|
+
const DEFAULTS = {
|
|
14
|
+
agentName: 'my-pc',
|
|
15
|
+
agentId: '',
|
|
16
|
+
userId: '',
|
|
17
|
+
backendWsUrl: 'wss://codewhisper-api.onrender.com',
|
|
18
|
+
projectPath: '',
|
|
19
|
+
cliType: 'claude',
|
|
20
|
+
cliPath: '',
|
|
21
|
+
autoConnect: true,
|
|
22
|
+
permissionMode: 'full', // full | approve | readonly
|
|
23
|
+
allowedTools: '', // comma-separated for approve mode
|
|
24
|
+
reconnectInterval: 5,
|
|
25
|
+
heartbeatInterval: 30,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
class ConfigStore {
|
|
29
|
+
constructor(projectPath) {
|
|
30
|
+
this._projectPath = projectPath || '';
|
|
31
|
+
this._ensureDirs();
|
|
32
|
+
this._config = this._load();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
_ensureDirs() {
|
|
36
|
+
if (!fs.existsSync(CONFIG_DIR)) {
|
|
37
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
38
|
+
}
|
|
39
|
+
if (!fs.existsSync(PROJECTS_DIR)) {
|
|
40
|
+
fs.mkdirSync(PROJECTS_DIR, { recursive: true });
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
_projectFile() {
|
|
45
|
+
if (!this._projectPath) return null;
|
|
46
|
+
const hash = crypto.createHash('md5').update(this._projectPath).digest('hex').slice(0, 12);
|
|
47
|
+
return path.join(PROJECTS_DIR, `${hash}.json`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
_loadFile(filePath) {
|
|
51
|
+
try {
|
|
52
|
+
if (filePath && fs.existsSync(filePath)) {
|
|
53
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
54
|
+
}
|
|
55
|
+
} catch {}
|
|
56
|
+
return {};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
_load() {
|
|
60
|
+
const global = this._loadFile(GLOBAL_FILE);
|
|
61
|
+
const project = this._loadFile(this._projectFile());
|
|
62
|
+
return { ...DEFAULTS, ...global, ...project };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
_save() {
|
|
66
|
+
// Split config: global keys → global file, rest → project file
|
|
67
|
+
const globalData = this._loadFile(GLOBAL_FILE);
|
|
68
|
+
const projectData = {};
|
|
69
|
+
|
|
70
|
+
for (const [key, value] of Object.entries(this._config)) {
|
|
71
|
+
if (DEFAULTS[key] === undefined) continue; // skip unknown keys
|
|
72
|
+
if (GLOBAL_KEYS.includes(key)) {
|
|
73
|
+
globalData[key] = value;
|
|
74
|
+
} else {
|
|
75
|
+
projectData[key] = value;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
fs.writeFileSync(GLOBAL_FILE, JSON.stringify(globalData, null, 2));
|
|
80
|
+
|
|
81
|
+
const projFile = this._projectFile();
|
|
82
|
+
if (projFile) {
|
|
83
|
+
fs.writeFileSync(projFile, JSON.stringify(projectData, null, 2));
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
getAll() {
|
|
88
|
+
return { ...this._config };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
get(key) {
|
|
92
|
+
return this._config[key];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
set(key, value) {
|
|
96
|
+
this._config[key] = value;
|
|
97
|
+
this._save();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
setAll(config) {
|
|
101
|
+
this._config = { ...this._config, ...config };
|
|
102
|
+
this._save();
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
module.exports = ConfigStore;
|
package/src/crypto.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
const nacl = require('tweetnacl');
|
|
2
|
+
const { decodeBase64, encodeBase64, decodeUTF8, encodeUTF8 } = require('tweetnacl-util');
|
|
3
|
+
|
|
4
|
+
function generateKeypair() {
|
|
5
|
+
const kp = nacl.box.keyPair();
|
|
6
|
+
return {
|
|
7
|
+
publicKey: encodeBase64(kp.publicKey),
|
|
8
|
+
secretKey: encodeBase64(kp.secretKey),
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function deriveSharedSecret(mySecretKeyB64, theirPublicKeyB64) {
|
|
13
|
+
const mySecretKey = decodeBase64(mySecretKeyB64);
|
|
14
|
+
const theirPublicKey = decodeBase64(theirPublicKeyB64);
|
|
15
|
+
const shared = nacl.box.before(theirPublicKey, mySecretKey);
|
|
16
|
+
return encodeBase64(shared);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function encrypt(payloadObj, sharedKeyB64) {
|
|
20
|
+
const sharedKey = decodeBase64(sharedKeyB64);
|
|
21
|
+
const nonce = nacl.randomBytes(nacl.box.nonceLength);
|
|
22
|
+
const message = decodeUTF8(JSON.stringify(payloadObj));
|
|
23
|
+
const encrypted = nacl.box.after(message, nonce, sharedKey);
|
|
24
|
+
if (!encrypted) return null;
|
|
25
|
+
|
|
26
|
+
// Concatenate nonce + ciphertext, then base64 encode
|
|
27
|
+
const full = new Uint8Array(nonce.length + encrypted.length);
|
|
28
|
+
full.set(nonce);
|
|
29
|
+
full.set(encrypted, nonce.length);
|
|
30
|
+
return encodeBase64(full);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function decrypt(encryptedB64, sharedKeyB64) {
|
|
34
|
+
try {
|
|
35
|
+
const sharedKey = decodeBase64(sharedKeyB64);
|
|
36
|
+
const full = decodeBase64(encryptedB64);
|
|
37
|
+
if (full.length < nacl.box.nonceLength) return null;
|
|
38
|
+
|
|
39
|
+
const nonce = full.slice(0, nacl.box.nonceLength);
|
|
40
|
+
const ciphertext = full.slice(nacl.box.nonceLength);
|
|
41
|
+
const decrypted = nacl.box.open.after(ciphertext, nonce, sharedKey);
|
|
42
|
+
if (!decrypted) return null;
|
|
43
|
+
|
|
44
|
+
return JSON.parse(encodeUTF8(decrypted));
|
|
45
|
+
} catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
module.exports = { generateKeypair, deriveSharedSecret, encrypt, decrypt };
|