@exreve/exk 1.0.50 → 1.0.52
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/dist/agentSession.js +51 -14
- package/dist/cli/agentSession.js +1456 -0
- package/dist/cli/app-child.js +1038 -0
- package/dist/cli/appHandlers.js +142 -0
- package/dist/cli/appManager.js +212 -0
- package/dist/cli/appRunner.js +383 -0
- package/dist/cli/cloudflaredHandlers.js +279 -0
- package/dist/cli/containerHandlers.js +193 -0
- package/dist/cli/fsHandlers.js +86 -0
- package/dist/cli/githubHandlers.js +525 -0
- package/dist/cli/index.js +1262 -0
- package/dist/cli/moduleMcpServer.js +284 -0
- package/dist/cli/openaiAdapter.js +181 -0
- package/dist/cli/projectAnalyzer.js +330 -0
- package/dist/cli/projectManager.js +69 -0
- package/dist/cli/runnerGenerator.js +210 -0
- package/dist/cli/sessionHandlers.js +271 -0
- package/dist/cli/shared/types.js +3 -0
- package/dist/cli/skills/index.js +117 -0
- package/dist/cli/transferService.js +284 -0
- package/dist/cli/type-checks.js +13 -0
- package/dist/cli/updateHandlers.js +82 -0
- package/dist/cli/updater.js +422 -0
- package/dist/shared/types.js +34 -1
- package/dist/ttc-cli.tar.gz +0 -0
- package/package.json +1 -1
|
@@ -0,0 +1,1262 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from 'commander';
|
|
3
|
+
import { io } from 'socket.io-client';
|
|
4
|
+
import { v4 as uuidv4 } from 'uuid';
|
|
5
|
+
import fs from 'fs/promises';
|
|
6
|
+
import fsSync from 'fs';
|
|
7
|
+
import path from 'path';
|
|
8
|
+
import os, { networkInterfaces } from 'os';
|
|
9
|
+
import { Buffer } from 'buffer';
|
|
10
|
+
import crypto from 'crypto';
|
|
11
|
+
import { createProject, deleteProject } from './projectManager.js';
|
|
12
|
+
import { startSending, startReceiving } from './transferService.js';
|
|
13
|
+
import { registerGitHubHandlers } from './githubHandlers.js';
|
|
14
|
+
import { registerFsHandlers } from './fsHandlers.js';
|
|
15
|
+
import { registerAppHandlers } from './appHandlers.js';
|
|
16
|
+
import { registerSessionHandlers } from './sessionHandlers.js';
|
|
17
|
+
import { registerContainerHandlers } from './containerHandlers.js';
|
|
18
|
+
import { registerCloudflaredHandlers } from './cloudflaredHandlers.js';
|
|
19
|
+
import { spawn, execSync } from 'child_process';
|
|
20
|
+
import readline from 'readline';
|
|
21
|
+
import { fileURLToPath } from 'url';
|
|
22
|
+
import { createHash } from 'crypto';
|
|
23
|
+
// ============ Constants ============
|
|
24
|
+
const CONFIG_DIR = path.join(os.homedir(), '.talk-to-code');
|
|
25
|
+
const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
|
|
26
|
+
const DEVICE_ID_FILE = path.join(CONFIG_DIR, 'device-id.json');
|
|
27
|
+
const DEVICE_CONFIG_FILE = path.join(CONFIG_DIR, 'device-config.json');
|
|
28
|
+
const DEFAULT_API_URL = 'https://api.talk-to-code.com';
|
|
29
|
+
// ============ Helpers ============
|
|
30
|
+
async function readConfig() {
|
|
31
|
+
try {
|
|
32
|
+
const data = await fs.readFile(CONFIG_FILE, 'utf-8');
|
|
33
|
+
return JSON.parse(data);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return { apiUrl: DEFAULT_API_URL };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
async function writeConfig(config) {
|
|
40
|
+
await fs.mkdir(CONFIG_DIR, { recursive: true });
|
|
41
|
+
await fs.writeFile(CONFIG_FILE, JSON.stringify(config, null, 2));
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Fetch AI config from server and save to ~/.talk-to-code/ai-config.json
|
|
45
|
+
* Called after device registration and on daemon start
|
|
46
|
+
*/
|
|
47
|
+
async function fetchAiConfig(authToken) {
|
|
48
|
+
try {
|
|
49
|
+
const config = await readConfig();
|
|
50
|
+
const res = await fetch(`${config.apiUrl}/config/ai`, {
|
|
51
|
+
headers: { Authorization: `Bearer ${authToken}` }
|
|
52
|
+
});
|
|
53
|
+
if (!res.ok) {
|
|
54
|
+
console.log(`[fetchAiConfig] Server returned ${res.status}`);
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
const data = await res.json();
|
|
58
|
+
if (!data.success || !data.aiConfig) {
|
|
59
|
+
console.log('[fetchAiConfig] No AI config available for this user');
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
await fs.writeFile(AI_CONFIG_FILE, JSON.stringify(data.aiConfig, null, 2));
|
|
63
|
+
console.log('[fetchAiConfig] AI config saved successfully');
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
console.log(`[fetchAiConfig] Failed: ${error.message}`);
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const AI_CONFIG_FILE = path.join(CONFIG_DIR, 'ai-config.json');
|
|
72
|
+
const PROXY_CONFIG_FILE = path.join(CONFIG_DIR, 'proxy.json');
|
|
73
|
+
/** Write proxy toggle state to disk */
|
|
74
|
+
async function writeProxyConfig(cfg) {
|
|
75
|
+
await fs.mkdir(CONFIG_DIR, { recursive: true });
|
|
76
|
+
await fs.writeFile(PROXY_CONFIG_FILE, JSON.stringify(cfg, null, 2));
|
|
77
|
+
}
|
|
78
|
+
/** TTL cache for ai-config.json reads (shared by getProxyUrl / hasAiCredentials) */
|
|
79
|
+
let _aiCfgCache = null;
|
|
80
|
+
const _AI_CFG_TTL = 5_000;
|
|
81
|
+
function readAiConfigCached() {
|
|
82
|
+
const now = Date.now();
|
|
83
|
+
if (_aiCfgCache && (now - _aiCfgCache.ts) < _AI_CFG_TTL)
|
|
84
|
+
return _aiCfgCache.raw;
|
|
85
|
+
try {
|
|
86
|
+
const raw = fsSync.readFileSync(AI_CONFIG_FILE, 'utf-8');
|
|
87
|
+
_aiCfgCache = { raw, ts: now };
|
|
88
|
+
return raw;
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return '';
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/** Get the proxy URL from ai-config.json (saved from backend) */
|
|
95
|
+
function getProxyUrl() {
|
|
96
|
+
try {
|
|
97
|
+
const raw = readAiConfigCached();
|
|
98
|
+
if (!raw)
|
|
99
|
+
return '';
|
|
100
|
+
const j = JSON.parse(raw);
|
|
101
|
+
return typeof j.proxy === 'string' ? j.proxy.trim() : '';
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return '';
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/** True if ai-config.json has a model API key (not read from host ANTHROPIC_* env). */
|
|
108
|
+
function hasAiCredentials() {
|
|
109
|
+
try {
|
|
110
|
+
const raw = readAiConfigCached();
|
|
111
|
+
if (!raw)
|
|
112
|
+
return false;
|
|
113
|
+
const j = JSON.parse(raw);
|
|
114
|
+
return typeof j.authToken === 'string' && j.authToken.trim().length > 0;
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
async function readDeviceAuthToken() {
|
|
121
|
+
const env = process.env.TTC_AUTH_TOKEN?.trim();
|
|
122
|
+
if (env)
|
|
123
|
+
return env;
|
|
124
|
+
try {
|
|
125
|
+
const raw = await fs.readFile(DEVICE_CONFIG_FILE, 'utf-8');
|
|
126
|
+
const j = JSON.parse(raw);
|
|
127
|
+
return typeof j.authToken === 'string' ? j.authToken.trim() : undefined;
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
return undefined;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
async function readDiskDeviceConfig() {
|
|
134
|
+
try {
|
|
135
|
+
const raw = await fs.readFile(DEVICE_CONFIG_FILE, 'utf-8');
|
|
136
|
+
return JSON.parse(raw);
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
return {};
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Update device-config.json without dropping an existing authToken.
|
|
144
|
+
* Omit authToken to keep the file’s current token; pass authToken: '' to clear (rare).
|
|
145
|
+
*/
|
|
146
|
+
async function writeDeviceConfigMerged(partial) {
|
|
147
|
+
const prev = await readDiskDeviceConfig();
|
|
148
|
+
const email = partial.email !== undefined ? partial.email : prev.email;
|
|
149
|
+
let token;
|
|
150
|
+
if (partial.authToken !== undefined) {
|
|
151
|
+
token = partial.authToken.trim() || undefined;
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
token = typeof prev.authToken === 'string' && prev.authToken.trim() ? prev.authToken.trim() : undefined;
|
|
155
|
+
}
|
|
156
|
+
const out = {};
|
|
157
|
+
if (email)
|
|
158
|
+
out.email = email;
|
|
159
|
+
if (token)
|
|
160
|
+
out.authToken = token;
|
|
161
|
+
await fs.mkdir(CONFIG_DIR, { recursive: true });
|
|
162
|
+
await fs.writeFile(DEVICE_CONFIG_FILE, JSON.stringify(out, null, 2));
|
|
163
|
+
}
|
|
164
|
+
/** If AI cache is empty but we have a device JWT, try GET /config/ai once (non-fatal). */
|
|
165
|
+
async function maybeFetchAiConfigIfMissing() {
|
|
166
|
+
if (hasAiCredentials())
|
|
167
|
+
return;
|
|
168
|
+
const jwt = await readDeviceAuthToken();
|
|
169
|
+
if (!jwt)
|
|
170
|
+
return;
|
|
171
|
+
const ok = await fetchAiConfig(jwt);
|
|
172
|
+
if (!ok && !hasAiCredentials()) {
|
|
173
|
+
const cfg = await readConfig();
|
|
174
|
+
console.warn(`[CLI] No AI key in ai-config.json yet. Could not sync from ${cfg.apiUrl}/config/ai — agent prompts will fail until this succeeds.`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
function scheduleAiConfigSync(authToken) {
|
|
178
|
+
void fetchAiConfig(authToken).then((ok) => {
|
|
179
|
+
if (!ok && !hasAiCredentials()) {
|
|
180
|
+
console.warn('[CLI] AI config sync failed; check backend /config/ai and apiUrl.');
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
async function getDeviceId() {
|
|
185
|
+
try {
|
|
186
|
+
const data = await fs.readFile(DEVICE_ID_FILE, 'utf-8');
|
|
187
|
+
return JSON.parse(data).deviceId;
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
const deviceId = uuidv4();
|
|
191
|
+
await fs.mkdir(CONFIG_DIR, { recursive: true });
|
|
192
|
+
await fs.writeFile(DEVICE_ID_FILE, JSON.stringify({ deviceId }, null, 2));
|
|
193
|
+
return deviceId;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
function getLocalIpAddress() {
|
|
197
|
+
const nets = networkInterfaces();
|
|
198
|
+
for (const name of Object.keys(nets)) {
|
|
199
|
+
for (const net of nets[name]) {
|
|
200
|
+
if (net.family === 'IPv4' && !net.internal) {
|
|
201
|
+
return net.address;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return 'Unknown';
|
|
206
|
+
}
|
|
207
|
+
function getHostname() {
|
|
208
|
+
return os.hostname() || 'Unknown';
|
|
209
|
+
}
|
|
210
|
+
async function connect() {
|
|
211
|
+
const config = await readConfig();
|
|
212
|
+
const deviceId = await getDeviceId();
|
|
213
|
+
return new Promise((resolve, reject) => {
|
|
214
|
+
const socket = io(config.apiUrl, {
|
|
215
|
+
transports: ['websocket', 'polling'],
|
|
216
|
+
});
|
|
217
|
+
socket.on('connect', () => {
|
|
218
|
+
socket.emit('register', { type: 'cli', deviceId });
|
|
219
|
+
resolve(socket);
|
|
220
|
+
});
|
|
221
|
+
socket.on('registered', ({ type }) => {
|
|
222
|
+
console.log(`Connected as ${type}`);
|
|
223
|
+
});
|
|
224
|
+
socket.on('connect_error', (err) => {
|
|
225
|
+
reject(err);
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
// ============ Version Helpers ============
|
|
230
|
+
/** Read CLI version from the npm package.json (works when installed via npm i -g @exreve/exk) */
|
|
231
|
+
function getCliVersion() {
|
|
232
|
+
try {
|
|
233
|
+
// Compiled JS is in dist/, package.json is one level up
|
|
234
|
+
const pkgPath = path.join(__dirname, '..', 'package.json');
|
|
235
|
+
const raw = fsSync.readFileSync(pkgPath, 'utf-8');
|
|
236
|
+
const pkg = JSON.parse(raw);
|
|
237
|
+
return pkg.version || '0.0.0';
|
|
238
|
+
}
|
|
239
|
+
catch {
|
|
240
|
+
return '0.0.0';
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
const CURRENT_FILE = fileURLToPath(import.meta.url);
|
|
244
|
+
const __dirname = path.dirname(CURRENT_FILE);
|
|
245
|
+
function getCliHash() {
|
|
246
|
+
return createHash('sha256').update(fsSync.readFileSync(CURRENT_FILE)).digest('hex');
|
|
247
|
+
}
|
|
248
|
+
async function checkForUpdate() {
|
|
249
|
+
try {
|
|
250
|
+
const config = await readConfig();
|
|
251
|
+
const res = await fetch(`${config.apiUrl}/update/check`, {
|
|
252
|
+
method: 'POST',
|
|
253
|
+
headers: { 'Content-Type': 'application/json' },
|
|
254
|
+
body: JSON.stringify({ hash: getCliHash(), platform: os.platform() })
|
|
255
|
+
});
|
|
256
|
+
if (!res.ok)
|
|
257
|
+
return null;
|
|
258
|
+
return await res.json();
|
|
259
|
+
}
|
|
260
|
+
catch {
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
// ============ Commands ============
|
|
265
|
+
async function registerDevice(name, email) {
|
|
266
|
+
try {
|
|
267
|
+
const deviceId = await getDeviceId();
|
|
268
|
+
let deviceEmail = email;
|
|
269
|
+
// If we already have a valid token, skip approval email and just register
|
|
270
|
+
try {
|
|
271
|
+
const deviceConfig = await readDiskDeviceConfig();
|
|
272
|
+
const existingToken = deviceConfig.authToken;
|
|
273
|
+
if (existingToken && typeof existingToken === 'string') {
|
|
274
|
+
const config = await readConfig();
|
|
275
|
+
const meRes = await fetch(`${config.apiUrl}/auth/me`, {
|
|
276
|
+
headers: { Authorization: `Bearer ${existingToken}` }
|
|
277
|
+
});
|
|
278
|
+
const meData = await meRes.json();
|
|
279
|
+
if (meRes.ok && meData.success && meData.user?.email) {
|
|
280
|
+
console.log(`✓ Using existing auth token for ${meData.user.email}`);
|
|
281
|
+
deviceEmail = deviceEmail || meData.user.email;
|
|
282
|
+
const socket = await connect();
|
|
283
|
+
await new Promise((resolve, reject) => {
|
|
284
|
+
socket.emit('device:register', {
|
|
285
|
+
deviceId,
|
|
286
|
+
name: name || `CLI Device ${deviceId.slice(0, 8)}`,
|
|
287
|
+
ipAddress: getLocalIpAddress(),
|
|
288
|
+
hostname: getHostname(),
|
|
289
|
+
email: deviceEmail,
|
|
290
|
+
cliVersion: getCliVersion()
|
|
291
|
+
}, ({ success, device, error }) => {
|
|
292
|
+
socket.disconnect();
|
|
293
|
+
if (success && device) {
|
|
294
|
+
console.log(`✓ Device registered!`);
|
|
295
|
+
console.log(`Device ID: ${device.deviceId}`);
|
|
296
|
+
console.log(`Name: ${device.name}`);
|
|
297
|
+
console.log(`Email: ${device.email}`);
|
|
298
|
+
resolve();
|
|
299
|
+
}
|
|
300
|
+
else {
|
|
301
|
+
reject(new Error(error || 'Registration failed'));
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
// Fetch AI config from server after registration
|
|
306
|
+
await fetchAiConfig(existingToken);
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
catch {
|
|
312
|
+
// No config or invalid token - continue with approval flow
|
|
313
|
+
}
|
|
314
|
+
// Prompt for email if not provided
|
|
315
|
+
if (!deviceEmail) {
|
|
316
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
317
|
+
deviceEmail = await new Promise((resolve) => {
|
|
318
|
+
rl.question('Enter your email address: ', (answer) => {
|
|
319
|
+
rl.close();
|
|
320
|
+
resolve(answer.trim());
|
|
321
|
+
});
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
if (!deviceEmail?.includes('@')) {
|
|
325
|
+
console.error('✗ Valid email address is required');
|
|
326
|
+
throw new Error('Valid email address is required');
|
|
327
|
+
}
|
|
328
|
+
// Generate 32-byte random token (base64url encoded)
|
|
329
|
+
const randomBytes = crypto.randomBytes(32);
|
|
330
|
+
const approvalToken = randomBytes.toString('base64url'); // base64url encoding (no padding, URL-safe)
|
|
331
|
+
console.log(`\n📧 Sending approval request to ${deviceEmail}...`);
|
|
332
|
+
// Send approval request
|
|
333
|
+
const config = await readConfig();
|
|
334
|
+
const response = await fetch(`${config.apiUrl}/auth/approval-request`, {
|
|
335
|
+
method: 'POST',
|
|
336
|
+
headers: {
|
|
337
|
+
'Content-Type': 'application/json'
|
|
338
|
+
},
|
|
339
|
+
body: JSON.stringify({
|
|
340
|
+
email: deviceEmail,
|
|
341
|
+
deviceId,
|
|
342
|
+
approvalToken
|
|
343
|
+
})
|
|
344
|
+
});
|
|
345
|
+
const result = await response.json();
|
|
346
|
+
if (!result.success) {
|
|
347
|
+
console.error(`✗ Failed to send approval request: ${result.error || 'Unknown error'}`);
|
|
348
|
+
throw new Error(result.error || 'Failed to send approval request');
|
|
349
|
+
}
|
|
350
|
+
console.log(`✓ Approval email sent! Check your inbox (${deviceEmail})`);
|
|
351
|
+
console.log(`⏳ Waiting for approval...`);
|
|
352
|
+
// Poll every 5 seconds for approval
|
|
353
|
+
const maxAttempts = 120; // 10 minutes max (120 * 5s)
|
|
354
|
+
let attempts = 0;
|
|
355
|
+
const checkApproval = async () => {
|
|
356
|
+
attempts++;
|
|
357
|
+
try {
|
|
358
|
+
const statusResponse = await fetch(`${config.apiUrl}/auth/approval-status?token=${approvalToken}`);
|
|
359
|
+
const statusResult = await statusResponse.json();
|
|
360
|
+
if (statusResult.success && statusResult.approved) {
|
|
361
|
+
return statusResult.token || null; // Return permanent auth token
|
|
362
|
+
}
|
|
363
|
+
if (statusResult.error && !statusResult.pending) {
|
|
364
|
+
console.error(`\n✗ ${statusResult.error}`);
|
|
365
|
+
return null;
|
|
366
|
+
}
|
|
367
|
+
return null; // Still pending
|
|
368
|
+
}
|
|
369
|
+
catch (error) {
|
|
370
|
+
console.error(`\n✗ Error checking approval status: ${error.message}`);
|
|
371
|
+
return null;
|
|
372
|
+
}
|
|
373
|
+
};
|
|
374
|
+
// Poll until approved or timeout
|
|
375
|
+
while (attempts < maxAttempts) {
|
|
376
|
+
await new Promise(resolve => setTimeout(resolve, 5000)); // Wait 5 seconds
|
|
377
|
+
const token = await checkApproval();
|
|
378
|
+
if (token) {
|
|
379
|
+
// Device approved! Store permanent token
|
|
380
|
+
await writeDeviceConfigMerged({ email: deviceEmail, authToken: token });
|
|
381
|
+
console.log(`\n✓ Device approved!`);
|
|
382
|
+
// Register device with backend and wait for completion
|
|
383
|
+
const socket = await connect();
|
|
384
|
+
await new Promise((resolve, reject) => {
|
|
385
|
+
socket.emit('device:register', {
|
|
386
|
+
deviceId,
|
|
387
|
+
name: name || `CLI Device ${deviceId.slice(0, 8)}`,
|
|
388
|
+
ipAddress: getLocalIpAddress(),
|
|
389
|
+
hostname: getHostname(),
|
|
390
|
+
email: deviceEmail,
|
|
391
|
+
cliVersion: getCliVersion()
|
|
392
|
+
}, ({ success, device, error }) => {
|
|
393
|
+
socket.disconnect();
|
|
394
|
+
if (success && device) {
|
|
395
|
+
console.log(`✓ Device registered!`);
|
|
396
|
+
console.log(`Device ID: ${device.deviceId}`);
|
|
397
|
+
console.log(`Name: ${device.name}`);
|
|
398
|
+
console.log(`Email: ${device.email}`);
|
|
399
|
+
console.log(`IP Address: ${device.ipAddress || 'Unknown'}`);
|
|
400
|
+
console.log(`Hostname: ${device.hostname || 'Unknown'}`);
|
|
401
|
+
resolve();
|
|
402
|
+
}
|
|
403
|
+
else {
|
|
404
|
+
reject(new Error(error || 'Registration failed'));
|
|
405
|
+
}
|
|
406
|
+
});
|
|
407
|
+
});
|
|
408
|
+
// Fetch AI config from server after registration
|
|
409
|
+
await fetchAiConfig(token);
|
|
410
|
+
return; // Successfully registered
|
|
411
|
+
}
|
|
412
|
+
// Show progress every 30 seconds
|
|
413
|
+
if (attempts % 6 === 0) {
|
|
414
|
+
process.stdout.write(`\r⏳ Still waiting... (${attempts * 5}s)`);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
console.error(`\n✗ Approval timeout. Please check your email and try again.`);
|
|
418
|
+
throw new Error('Approval timeout');
|
|
419
|
+
}
|
|
420
|
+
catch (error) {
|
|
421
|
+
console.error('Failed to register device:', error.message);
|
|
422
|
+
throw error;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
// Active sessions map - persists across reconnections
|
|
426
|
+
const activeSessions = new Map();
|
|
427
|
+
async function runDaemon(foreground = false, email) {
|
|
428
|
+
const config = await readConfig();
|
|
429
|
+
const deviceId = await getDeviceId();
|
|
430
|
+
const ipAddress = getLocalIpAddress();
|
|
431
|
+
const hostname = getHostname();
|
|
432
|
+
// Silent update check on startup
|
|
433
|
+
checkForUpdate().then(info => {
|
|
434
|
+
if (info?.updateAvailable)
|
|
435
|
+
console.log('📦 Update available: run "ttc update"');
|
|
436
|
+
}).catch(() => { });
|
|
437
|
+
if (foreground)
|
|
438
|
+
console.log('=== TalkToCode CLI (Foreground) ===');
|
|
439
|
+
else
|
|
440
|
+
console.log('Starting daemon...');
|
|
441
|
+
console.log(`API: ${config.apiUrl}`);
|
|
442
|
+
console.log(`Device: ${deviceId}`);
|
|
443
|
+
console.log(`Host: ${hostname}`);
|
|
444
|
+
// Test if server is reachable
|
|
445
|
+
if (foreground) {
|
|
446
|
+
try {
|
|
447
|
+
const url = new URL(config.apiUrl);
|
|
448
|
+
const testUrl = `${url.protocol}//${url.host}`;
|
|
449
|
+
console.log(`Testing connection to ${testUrl}...`);
|
|
450
|
+
const response = await fetch(testUrl, {
|
|
451
|
+
method: 'GET',
|
|
452
|
+
signal: AbortSignal.timeout(5000)
|
|
453
|
+
});
|
|
454
|
+
console.log(`✓ Server is reachable (HTTP ${response.status})`);
|
|
455
|
+
}
|
|
456
|
+
catch (error) {
|
|
457
|
+
console.error(`✗ Cannot reach server: ${error.message}`);
|
|
458
|
+
console.error(` Make sure the backend server is running at ${config.apiUrl}`);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
console.log('');
|
|
462
|
+
await maybeFetchAiConfigIfMissing();
|
|
463
|
+
let socket = null;
|
|
464
|
+
let reconnectTimeout = null;
|
|
465
|
+
let reconnectAttempts = 0;
|
|
466
|
+
const MAX_RECONNECT_ATTEMPTS = Infinity; // Keep trying forever
|
|
467
|
+
const connectAndRegister = async () => {
|
|
468
|
+
try {
|
|
469
|
+
// Destroy any existing socket to prevent zombie connections
|
|
470
|
+
if (socket) {
|
|
471
|
+
socket.removeAllListeners();
|
|
472
|
+
socket.disconnect();
|
|
473
|
+
socket = null;
|
|
474
|
+
}
|
|
475
|
+
socket = io(config.apiUrl, {
|
|
476
|
+
transports: ['websocket', 'polling'],
|
|
477
|
+
reconnection: true,
|
|
478
|
+
reconnectionDelay: 1000,
|
|
479
|
+
reconnectionDelayMax: 10000,
|
|
480
|
+
reconnectionAttempts: MAX_RECONNECT_ATTEMPTS,
|
|
481
|
+
timeout: 20000,
|
|
482
|
+
forceNew: true,
|
|
483
|
+
upgrade: true,
|
|
484
|
+
});
|
|
485
|
+
if (foreground) {
|
|
486
|
+
console.log(`Attempting to connect to: ${config.apiUrl}`);
|
|
487
|
+
}
|
|
488
|
+
// Register event handlers once (outside registered callback to prevent duplicates)
|
|
489
|
+
// Project handlers
|
|
490
|
+
socket.on('project:create', async (data, callback) => {
|
|
491
|
+
try {
|
|
492
|
+
if (foreground) {
|
|
493
|
+
console.log(`📁 Creating project: ${data.name} at ${data.path}`);
|
|
494
|
+
}
|
|
495
|
+
const result = await createProject({
|
|
496
|
+
projectId: data.projectId || uuidv4(),
|
|
497
|
+
name: data.name,
|
|
498
|
+
path: data.path,
|
|
499
|
+
sourcePath: data.sourcePath
|
|
500
|
+
});
|
|
501
|
+
if (result.success) {
|
|
502
|
+
if (foreground) {
|
|
503
|
+
console.log(`✓ Project created: ${data.name} at ${result.actualPath || data.path}`);
|
|
504
|
+
}
|
|
505
|
+
else {
|
|
506
|
+
console.log(`Project created: ${data.name} at ${result.actualPath || data.path}`);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
else {
|
|
510
|
+
if (foreground) {
|
|
511
|
+
console.error(`✗ Failed to create project: ${result.error || 'Unknown error'}`);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
callback?.({
|
|
515
|
+
success: result.success,
|
|
516
|
+
error: result.error,
|
|
517
|
+
actualPath: result.actualPath
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
catch (error) {
|
|
521
|
+
if (foreground) {
|
|
522
|
+
console.error(`✗ Error creating project: ${error.message}`);
|
|
523
|
+
}
|
|
524
|
+
callback?.({ success: false, error: error.message });
|
|
525
|
+
}
|
|
526
|
+
});
|
|
527
|
+
socket.on('project:delete', async (data, callback) => {
|
|
528
|
+
try {
|
|
529
|
+
const { projectId, path } = data;
|
|
530
|
+
if (!path) {
|
|
531
|
+
callback?.({ success: false, error: 'Project path required for deletion' });
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
if (foreground) {
|
|
535
|
+
console.log(`🗑️ Deleting project: ${projectId} at ${path}`);
|
|
536
|
+
}
|
|
537
|
+
const result = await deleteProject(path);
|
|
538
|
+
if (result.success) {
|
|
539
|
+
if (foreground) {
|
|
540
|
+
console.log(`✓ Project deleted: ${projectId} at ${path}`);
|
|
541
|
+
}
|
|
542
|
+
else {
|
|
543
|
+
console.log(`Project deleted: ${projectId} at ${path}`);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
else {
|
|
547
|
+
if (foreground) {
|
|
548
|
+
console.error(`✗ Failed to delete project: ${result.error || 'Unknown error'}`);
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
callback?.(result);
|
|
552
|
+
}
|
|
553
|
+
catch (error) {
|
|
554
|
+
if (foreground) {
|
|
555
|
+
console.error(`✗ Error deleting project: ${error.message}`);
|
|
556
|
+
}
|
|
557
|
+
callback?.({ success: false, error: error.message });
|
|
558
|
+
}
|
|
559
|
+
});
|
|
560
|
+
// GitHub handlers (delegated to githubHandlers module for proper response format)
|
|
561
|
+
registerGitHubHandlers(socket, foreground);
|
|
562
|
+
// Filesystem handlers (delegated to fsHandlers module)
|
|
563
|
+
registerFsHandlers(socket);
|
|
564
|
+
// Session handlers (delegated to sessionHandlers module)
|
|
565
|
+
registerSessionHandlers(socket, foreground, activeSessions, () => socket);
|
|
566
|
+
// App control handlers (delegated to appHandlers module)
|
|
567
|
+
registerAppHandlers(socket, foreground);
|
|
568
|
+
// Handle image save request - saves base64 images to tmp directory
|
|
569
|
+
socket.on('image:save', async (data) => {
|
|
570
|
+
try {
|
|
571
|
+
const { images, saveCallbackId, projectPath } = data;
|
|
572
|
+
if (foreground) {
|
|
573
|
+
console.log(`[CLI] 🖼️ Received request to save ${images.length} image(s)`);
|
|
574
|
+
}
|
|
575
|
+
// Use project-specific tmp directory (inside the project)
|
|
576
|
+
// If projectPath is provided, use it; otherwise fall back to current working directory
|
|
577
|
+
const basePath = projectPath || process.cwd();
|
|
578
|
+
const tmpDir = path.join(basePath, 'tmp');
|
|
579
|
+
await fs.mkdir(tmpDir, { recursive: true });
|
|
580
|
+
if (foreground) {
|
|
581
|
+
console.log(`[CLI] 📁 Saving to directory: ${tmpDir}`);
|
|
582
|
+
}
|
|
583
|
+
let savedCount = 0;
|
|
584
|
+
const errors = [];
|
|
585
|
+
for (const image of images) {
|
|
586
|
+
try {
|
|
587
|
+
const imagePath = path.join(tmpDir, image.name);
|
|
588
|
+
// Convert base64 to buffer and write to file
|
|
589
|
+
const buffer = Buffer.from(image.data, 'base64');
|
|
590
|
+
await fs.writeFile(imagePath, buffer);
|
|
591
|
+
if (foreground) {
|
|
592
|
+
console.log(`[CLI] ✓ Saved image: ${imagePath}`);
|
|
593
|
+
}
|
|
594
|
+
savedCount++;
|
|
595
|
+
}
|
|
596
|
+
catch (saveError) {
|
|
597
|
+
const errorMsg = `Failed to save ${image.name}: ${saveError.message}`;
|
|
598
|
+
errors.push(errorMsg);
|
|
599
|
+
if (foreground) {
|
|
600
|
+
console.error(`[CLI] ✗ ${errorMsg}`);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
// Send response back via app:control:response
|
|
605
|
+
console.log(`[CLI] Sending app:control:response with appControlId=${saveCallbackId}, success=${errors.length === 0}, savedCount=${savedCount}`);
|
|
606
|
+
socket.emit('app:control:response', {
|
|
607
|
+
appControlId: saveCallbackId,
|
|
608
|
+
success: errors.length === 0,
|
|
609
|
+
savedCount,
|
|
610
|
+
error: errors.length > 0 ? errors.join('; ') : undefined
|
|
611
|
+
});
|
|
612
|
+
console.log(`[CLI] Emitted app:control:response`);
|
|
613
|
+
if (foreground) {
|
|
614
|
+
if (errors.length > 0) {
|
|
615
|
+
console.log(`[CLI] ⚠️ Saved ${savedCount}/${images.length} images with errors`);
|
|
616
|
+
}
|
|
617
|
+
else {
|
|
618
|
+
console.log(`[CLI] ✓ Successfully saved all ${savedCount} images to ${tmpDir}`);
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
catch (error) {
|
|
623
|
+
console.error(`[CLI] ✗ Error saving images: ${error.message}`);
|
|
624
|
+
// Send error response
|
|
625
|
+
console.log(`[CLI] Sending error app:control:response with appControlId=${data.saveCallbackId}`);
|
|
626
|
+
socket.emit('app:control:response', {
|
|
627
|
+
appControlId: data.saveCallbackId,
|
|
628
|
+
success: false,
|
|
629
|
+
error: error.message
|
|
630
|
+
});
|
|
631
|
+
console.log(`[CLI] Emitted error app:control:response`);
|
|
632
|
+
}
|
|
633
|
+
});
|
|
634
|
+
// Handle folder transfer — this device is the source (pack & upload)
|
|
635
|
+
socket.on('transfer:pack', (data) => {
|
|
636
|
+
if (foreground) {
|
|
637
|
+
console.log(`[transfer] Pack request: ${data.sourcePath} (${data.selectedItems?.length || 0} items) → device ${data.destDeviceId.slice(0, 8)}`);
|
|
638
|
+
}
|
|
639
|
+
startSending(socket, data, foreground);
|
|
640
|
+
});
|
|
641
|
+
// Handle folder transfer — this device is the destination (download & extract)
|
|
642
|
+
socket.on('transfer:pull', (data) => {
|
|
643
|
+
if (foreground) {
|
|
644
|
+
console.log(`[transfer] Pull request: device ${data.sourceDeviceId.slice(0, 8)} → ${data.destPath} (${(data.totalBytes / (1024 * 1024)).toFixed(1)} MB)`);
|
|
645
|
+
}
|
|
646
|
+
startReceiving(socket, data, foreground);
|
|
647
|
+
});
|
|
648
|
+
socket.on('connect', () => {
|
|
649
|
+
if (foreground) {
|
|
650
|
+
console.log(`✓ Connected to backend at ${config.apiUrl}`);
|
|
651
|
+
if (socket) {
|
|
652
|
+
console.log(` Socket ID: ${socket.id}`);
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
else {
|
|
656
|
+
console.log('Connected to backend');
|
|
657
|
+
}
|
|
658
|
+
reconnectAttempts = 0;
|
|
659
|
+
// Register as CLI device
|
|
660
|
+
if (socket) {
|
|
661
|
+
socket.emit('register', { type: 'cli', deviceId });
|
|
662
|
+
}
|
|
663
|
+
});
|
|
664
|
+
socket.on('registered', async ({ type }) => {
|
|
665
|
+
if (foreground) {
|
|
666
|
+
console.log(`✓ Registered as ${type}`);
|
|
667
|
+
}
|
|
668
|
+
else {
|
|
669
|
+
console.log(`Registered as ${type}`);
|
|
670
|
+
}
|
|
671
|
+
// Check for auth token: env TTC_AUTH_TOKEN or device-config.json (from previous approval)
|
|
672
|
+
let authToken = process.env.TTC_AUTH_TOKEN;
|
|
673
|
+
let deviceEmail = email;
|
|
674
|
+
let skipEmailFlow = false;
|
|
675
|
+
// Load from device-config if no env token (reuse state from previous approval)
|
|
676
|
+
if (!authToken) {
|
|
677
|
+
try {
|
|
678
|
+
const deviceConfig = await readDiskDeviceConfig();
|
|
679
|
+
authToken = deviceConfig.authToken;
|
|
680
|
+
if (!deviceEmail && deviceConfig.email)
|
|
681
|
+
deviceEmail = deviceConfig.email;
|
|
682
|
+
}
|
|
683
|
+
catch {
|
|
684
|
+
// No device config yet
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
if (authToken) {
|
|
688
|
+
try {
|
|
689
|
+
const parts = authToken.split('.');
|
|
690
|
+
if (parts.length === 3) {
|
|
691
|
+
const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString());
|
|
692
|
+
if (payload.type === 'device_auth' && payload.email) {
|
|
693
|
+
deviceEmail = payload.email;
|
|
694
|
+
skipEmailFlow = true;
|
|
695
|
+
if (foreground) {
|
|
696
|
+
console.log(`✓ Using stored auth token for ${deviceEmail}`);
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
catch (err) {
|
|
702
|
+
if (foreground) {
|
|
703
|
+
console.warn(`⚠️ Invalid auth token in config, falling back to normal auth`);
|
|
704
|
+
}
|
|
705
|
+
authToken = undefined;
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
// Load device email from config if not using token and not provided
|
|
709
|
+
if (!deviceEmail && !skipEmailFlow) {
|
|
710
|
+
try {
|
|
711
|
+
const deviceConfig = await readDiskDeviceConfig();
|
|
712
|
+
deviceEmail = deviceConfig.email;
|
|
713
|
+
}
|
|
714
|
+
catch {
|
|
715
|
+
// No device config yet
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
// Register device with backend
|
|
719
|
+
let needsApprovalLoggedOnce = false;
|
|
720
|
+
const registerDevice = () => {
|
|
721
|
+
// Determine device name
|
|
722
|
+
const containerName = process.env.CONTAINER_NAME;
|
|
723
|
+
const deviceName = containerName ? `Container: ${containerName}` : `CLI Device ${hostname}`;
|
|
724
|
+
socket.emit('device:register', {
|
|
725
|
+
deviceId,
|
|
726
|
+
name: deviceName,
|
|
727
|
+
ipAddress,
|
|
728
|
+
hostname,
|
|
729
|
+
email: deviceEmail,
|
|
730
|
+
cliVersion: getCliVersion(),
|
|
731
|
+
// When using auth token, include the token for verification
|
|
732
|
+
authToken: skipEmailFlow ? authToken : undefined
|
|
733
|
+
}, ({ success, needsApproval, message, device, error }) => {
|
|
734
|
+
if (success && device) {
|
|
735
|
+
needsApprovalLoggedOnce = false;
|
|
736
|
+
if (foreground) {
|
|
737
|
+
console.log(`✓ Device registered: ${device.name}`);
|
|
738
|
+
if (device.approved) {
|
|
739
|
+
console.log(`✓ Device approved and ready`);
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
else {
|
|
743
|
+
console.log(`Device registered: ${device.name}${device.approved ? ' (approved)' : ' (pending approval)'}`);
|
|
744
|
+
}
|
|
745
|
+
// Save config: merge email; keep existing file authToken if in-memory token absent
|
|
746
|
+
if (device.email) {
|
|
747
|
+
void writeDeviceConfigMerged({
|
|
748
|
+
email: device.email,
|
|
749
|
+
...(authToken ? { authToken } : {}),
|
|
750
|
+
}).catch(() => { });
|
|
751
|
+
}
|
|
752
|
+
if (authToken) {
|
|
753
|
+
scheduleAiConfigSync(authToken);
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
else if (needsApproval) {
|
|
757
|
+
// Persist email when approval was requested; do not wipe authToken
|
|
758
|
+
if (deviceEmail) {
|
|
759
|
+
void writeDeviceConfigMerged({ email: deviceEmail }).catch(() => { });
|
|
760
|
+
}
|
|
761
|
+
// Only log once to avoid flooding logs every 30s on heartbeat
|
|
762
|
+
if (!needsApprovalLoggedOnce) {
|
|
763
|
+
needsApprovalLoggedOnce = true;
|
|
764
|
+
if (foreground) {
|
|
765
|
+
console.log(`\n⚠️ ${message || 'Device approval required'}`);
|
|
766
|
+
if (!deviceEmail) {
|
|
767
|
+
console.log(` Please run: npx tsx index.ts register --email your@email.com`);
|
|
768
|
+
}
|
|
769
|
+
else {
|
|
770
|
+
console.log(` Check your email (${deviceEmail}) and click the approval link.`);
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
else {
|
|
774
|
+
console.log(`⚠️ Device approval required: ${message || 'Please approve device'}`);
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
else if (error) {
|
|
779
|
+
console.error(`✗ Registration error: ${error}`);
|
|
780
|
+
}
|
|
781
|
+
});
|
|
782
|
+
};
|
|
783
|
+
registerDevice();
|
|
784
|
+
// Update lastSeen every 30 seconds while connected
|
|
785
|
+
const heartbeatInterval = setInterval(() => {
|
|
786
|
+
if (socket?.connected) {
|
|
787
|
+
registerDevice();
|
|
788
|
+
}
|
|
789
|
+
else {
|
|
790
|
+
clearInterval(heartbeatInterval);
|
|
791
|
+
}
|
|
792
|
+
}, 30000);
|
|
793
|
+
socket.heartbeatInterval = heartbeatInterval;
|
|
794
|
+
});
|
|
795
|
+
// ========== Version & Update Handlers ==========
|
|
796
|
+
// Respond with CLI version info
|
|
797
|
+
socket.on('version:info', (_data, callback) => {
|
|
798
|
+
callback({
|
|
799
|
+
success: true,
|
|
800
|
+
version: getCliVersion(),
|
|
801
|
+
hash: getCliHash(),
|
|
802
|
+
date: new Date().toISOString(),
|
|
803
|
+
nodeVersion: process.version,
|
|
804
|
+
platform: os.platform(),
|
|
805
|
+
arch: os.arch()
|
|
806
|
+
});
|
|
807
|
+
});
|
|
808
|
+
// Force update: npm update -g @exreve/exk then restart PM2
|
|
809
|
+
socket.on('force-update', (_data, callback) => {
|
|
810
|
+
if (foreground) {
|
|
811
|
+
console.log('[force-update] Received force update command from server');
|
|
812
|
+
}
|
|
813
|
+
callback?.({ success: true, message: 'Update initiated' });
|
|
814
|
+
// Run npm update in background, then restart
|
|
815
|
+
const npmPaths = [
|
|
816
|
+
path.join(os.homedir(), '.nvm/versions/node/v22/bin/npm'),
|
|
817
|
+
path.join(os.homedir(), '.nvm/versions/node/v20/bin/npm')
|
|
818
|
+
];
|
|
819
|
+
const npmBin = npmPaths.find(p => fsSync.existsSync(p)) || 'npm';
|
|
820
|
+
const updateProcess = spawn(npmBin, ['update', '-g', '@exreve/exk'], {
|
|
821
|
+
stdio: 'pipe',
|
|
822
|
+
detached: true
|
|
823
|
+
});
|
|
824
|
+
let output = '';
|
|
825
|
+
updateProcess.stdout?.on('data', (d) => { output += d.toString(); });
|
|
826
|
+
updateProcess.stderr?.on('data', (d) => { output += d.toString(); });
|
|
827
|
+
updateProcess.on('close', (code) => {
|
|
828
|
+
if (foreground) {
|
|
829
|
+
console.log(`[force-update] npm update exited with code ${code}`);
|
|
830
|
+
if (output)
|
|
831
|
+
console.log(output);
|
|
832
|
+
}
|
|
833
|
+
// Restart PM2 process "cli" after a short delay
|
|
834
|
+
setTimeout(() => {
|
|
835
|
+
try {
|
|
836
|
+
execSync('pm2 restart cli', { stdio: 'inherit' });
|
|
837
|
+
}
|
|
838
|
+
catch {
|
|
839
|
+
// If pm2 restart fails, just exit and let pm2 restart us
|
|
840
|
+
process.exit(0);
|
|
841
|
+
}
|
|
842
|
+
}, 2000);
|
|
843
|
+
});
|
|
844
|
+
updateProcess.on('error', (err) => {
|
|
845
|
+
if (foreground) {
|
|
846
|
+
console.error(`[force-update] npm update failed: ${err.message}`);
|
|
847
|
+
}
|
|
848
|
+
});
|
|
849
|
+
});
|
|
850
|
+
// ========== update:start handler (legacy compatibility) ==========
|
|
851
|
+
socket.on('update:start', (_data, callback) => {
|
|
852
|
+
// Use npm-based self-update
|
|
853
|
+
if (foreground) {
|
|
854
|
+
console.log('[update:start] Starting npm self-update...');
|
|
855
|
+
}
|
|
856
|
+
callback?.({ success: true, message: 'Update started via npm' });
|
|
857
|
+
const npmPaths = [
|
|
858
|
+
path.join(os.homedir(), '.nvm/versions/node/v22/bin/npm'),
|
|
859
|
+
path.join(os.homedir(), '.nvm/versions/node/v20/bin/npm')
|
|
860
|
+
];
|
|
861
|
+
const npmBin = npmPaths.find(p => fsSync.existsSync(p)) || 'npm';
|
|
862
|
+
const updateProcess = spawn(npmBin, ['update', '-g', '@exreve/exk'], {
|
|
863
|
+
stdio: 'pipe',
|
|
864
|
+
detached: true
|
|
865
|
+
});
|
|
866
|
+
updateProcess.on('close', () => {
|
|
867
|
+
setTimeout(() => {
|
|
868
|
+
try {
|
|
869
|
+
execSync('pm2 restart cli', { stdio: 'inherit' });
|
|
870
|
+
}
|
|
871
|
+
catch {
|
|
872
|
+
process.exit(0);
|
|
873
|
+
}
|
|
874
|
+
}, 2000);
|
|
875
|
+
});
|
|
876
|
+
});
|
|
877
|
+
// Cloudflared handlers (delegated to cloudflaredHandlers module)
|
|
878
|
+
registerCloudflaredHandlers(socket, foreground);
|
|
879
|
+
// Container handlers (delegated to containerHandlers module)
|
|
880
|
+
registerContainerHandlers(socket, foreground, __dirname);
|
|
881
|
+
socket.on('disconnect', (reason) => {
|
|
882
|
+
if (foreground) {
|
|
883
|
+
console.log(`\n⚠️ Disconnected: ${reason}`);
|
|
884
|
+
console.log(' Attempting to reconnect...');
|
|
885
|
+
}
|
|
886
|
+
else {
|
|
887
|
+
console.log(`Disconnected: ${reason}`);
|
|
888
|
+
}
|
|
889
|
+
// Clear heartbeat interval
|
|
890
|
+
if (socket.heartbeatInterval) {
|
|
891
|
+
clearInterval(socket.heartbeatInterval);
|
|
892
|
+
}
|
|
893
|
+
if (reason === 'io server disconnect') {
|
|
894
|
+
// Server disconnected, reconnect manually
|
|
895
|
+
socket.connect();
|
|
896
|
+
}
|
|
897
|
+
else {
|
|
898
|
+
// Client disconnect or transport close: schedule full reconnect so process stays alive
|
|
899
|
+
if (reconnectTimeout)
|
|
900
|
+
clearTimeout(reconnectTimeout);
|
|
901
|
+
const delay = 2000;
|
|
902
|
+
if (!foreground)
|
|
903
|
+
console.log(`Reconnecting in ${delay}ms...`);
|
|
904
|
+
reconnectTimeout = setTimeout(connectAndRegister, delay);
|
|
905
|
+
}
|
|
906
|
+
});
|
|
907
|
+
socket.on('connect_error', (err) => {
|
|
908
|
+
reconnectAttempts++;
|
|
909
|
+
if (foreground) {
|
|
910
|
+
console.error(`✗ Connection error (attempt ${reconnectAttempts}): ${err.message}`);
|
|
911
|
+
if (err.type) {
|
|
912
|
+
console.error(` Error type: ${err.type}`);
|
|
913
|
+
}
|
|
914
|
+
if (err.description) {
|
|
915
|
+
console.error(` Error description: ${err.description}`);
|
|
916
|
+
}
|
|
917
|
+
console.error(` Connecting to: ${config.apiUrl}`);
|
|
918
|
+
if (err.cause) {
|
|
919
|
+
console.error(` Cause:`, err.cause);
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
else {
|
|
923
|
+
console.error(`Connection error (attempt ${reconnectAttempts}):`, err.message);
|
|
924
|
+
}
|
|
925
|
+
});
|
|
926
|
+
// Keep process alive
|
|
927
|
+
socket.on('error', (err) => {
|
|
928
|
+
if (foreground) {
|
|
929
|
+
console.error(`✗ Socket error: ${err}`);
|
|
930
|
+
}
|
|
931
|
+
else {
|
|
932
|
+
console.error('Socket error:', err);
|
|
933
|
+
}
|
|
934
|
+
});
|
|
935
|
+
}
|
|
936
|
+
catch (error) {
|
|
937
|
+
if (foreground) {
|
|
938
|
+
console.error(`✗ Failed to connect: ${error.message}`);
|
|
939
|
+
}
|
|
940
|
+
else {
|
|
941
|
+
console.error('Failed to connect:', error.message);
|
|
942
|
+
}
|
|
943
|
+
reconnectAttempts++;
|
|
944
|
+
if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
|
|
945
|
+
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 30000);
|
|
946
|
+
if (foreground) {
|
|
947
|
+
console.log(`⏳ Reconnecting in ${delay}ms...`);
|
|
948
|
+
}
|
|
949
|
+
else {
|
|
950
|
+
console.log(`Reconnecting in ${delay}ms...`);
|
|
951
|
+
}
|
|
952
|
+
reconnectTimeout = setTimeout(connectAndRegister, delay);
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
};
|
|
956
|
+
// Handle graceful shutdown
|
|
957
|
+
const shutdown = () => {
|
|
958
|
+
if (foreground) {
|
|
959
|
+
console.log('\n\n🛑 Shutting down gracefully...');
|
|
960
|
+
}
|
|
961
|
+
else {
|
|
962
|
+
console.log('\nShutting down...');
|
|
963
|
+
}
|
|
964
|
+
if (reconnectTimeout) {
|
|
965
|
+
clearTimeout(reconnectTimeout);
|
|
966
|
+
}
|
|
967
|
+
if (socket) {
|
|
968
|
+
// Clear heartbeat interval
|
|
969
|
+
if (socket.heartbeatInterval) {
|
|
970
|
+
clearInterval(socket.heartbeatInterval);
|
|
971
|
+
}
|
|
972
|
+
socket.disconnect();
|
|
973
|
+
}
|
|
974
|
+
process.exit(0);
|
|
975
|
+
};
|
|
976
|
+
process.on('SIGTERM', shutdown);
|
|
977
|
+
process.on('SIGINT', shutdown);
|
|
978
|
+
process.on('SIGHUP', shutdown);
|
|
979
|
+
// Start connection
|
|
980
|
+
await connectAndRegister();
|
|
981
|
+
// Keep process alive
|
|
982
|
+
process.stdin.resume();
|
|
983
|
+
}
|
|
984
|
+
// ============ CLI Program ============
|
|
985
|
+
const program = new Command();
|
|
986
|
+
program
|
|
987
|
+
.name('exk')
|
|
988
|
+
.description('exk - Control Claude CLI with voice and programmable interfaces')
|
|
989
|
+
.version(getCliVersion());
|
|
990
|
+
// Config command
|
|
991
|
+
program
|
|
992
|
+
.command('config')
|
|
993
|
+
.description('Get or set config (api-url)')
|
|
994
|
+
.option('--api-url <url>', 'API base URL')
|
|
995
|
+
.action(async (opts) => {
|
|
996
|
+
const config = await readConfig();
|
|
997
|
+
if (opts.apiUrl !== undefined) {
|
|
998
|
+
config.apiUrl = opts.apiUrl;
|
|
999
|
+
await writeConfig(config);
|
|
1000
|
+
console.log('Set api-url:', config.apiUrl);
|
|
1001
|
+
}
|
|
1002
|
+
else {
|
|
1003
|
+
console.log('Config:', JSON.stringify(config, null, 2));
|
|
1004
|
+
}
|
|
1005
|
+
process.exit(0);
|
|
1006
|
+
});
|
|
1007
|
+
// Register command
|
|
1008
|
+
program
|
|
1009
|
+
.command('register')
|
|
1010
|
+
.description('Register this device and install as persistent daemon')
|
|
1011
|
+
.argument('[email]', 'Email address for device approval')
|
|
1012
|
+
.action(async (email) => {
|
|
1013
|
+
try {
|
|
1014
|
+
// First register the device
|
|
1015
|
+
await registerDevice(undefined, email);
|
|
1016
|
+
// After successful registration, install as PM2 service (Linux/macOS only)
|
|
1017
|
+
if (process.platform === 'win32') {
|
|
1018
|
+
console.log('\n✓ Device registered.');
|
|
1019
|
+
console.log('On Windows, run the daemon manually: exk daemon');
|
|
1020
|
+
}
|
|
1021
|
+
else {
|
|
1022
|
+
console.log('\n📦 Installing exk daemon with pm2...');
|
|
1023
|
+
const exkBin = process.argv[1] || 'exk';
|
|
1024
|
+
try {
|
|
1025
|
+
// Check if pm2 is installed
|
|
1026
|
+
try {
|
|
1027
|
+
execSync('which pm2', { stdio: 'ignore' });
|
|
1028
|
+
}
|
|
1029
|
+
catch {
|
|
1030
|
+
console.log('Installing pm2...');
|
|
1031
|
+
execSync('npm i -g pm2', { stdio: 'inherit' });
|
|
1032
|
+
}
|
|
1033
|
+
// Delete existing pm2 process if any, then start fresh
|
|
1034
|
+
try {
|
|
1035
|
+
execSync('pm2 delete cli 2>/dev/null', { stdio: 'ignore' });
|
|
1036
|
+
}
|
|
1037
|
+
catch { }
|
|
1038
|
+
// Start exk daemon with pm2
|
|
1039
|
+
execSync(`pm2 start "${exkBin}" --name cli --interpreter none -- daemon`, {
|
|
1040
|
+
stdio: 'inherit'
|
|
1041
|
+
});
|
|
1042
|
+
// Configure pm2 to start on boot (creates systemd/launchd service)
|
|
1043
|
+
let startupConfigured = false;
|
|
1044
|
+
try {
|
|
1045
|
+
const startupResult = execSync('pm2 startup 2>&1', { encoding: 'utf-8' });
|
|
1046
|
+
if (startupResult.includes('sudo') || startupResult.includes('[PM2] You have to run')) {
|
|
1047
|
+
// pm2 startup needs sudo - try running the suggested command
|
|
1048
|
+
const match = startupResult.match(/(sudo .+)/);
|
|
1049
|
+
if (match) {
|
|
1050
|
+
console.log('\n⚙ PM2 startup requires admin privileges.');
|
|
1051
|
+
console.log('Run this command to enable auto-start on reboot:');
|
|
1052
|
+
console.log(` ${match[1]}`);
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
else {
|
|
1056
|
+
startupConfigured = true;
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
catch (err) {
|
|
1060
|
+
const execErr = err;
|
|
1061
|
+
const output = execErr.stderr || '';
|
|
1062
|
+
const match = output.match(/(sudo .+)/);
|
|
1063
|
+
if (match) {
|
|
1064
|
+
console.log('\n⚙ PM2 startup requires admin privileges.');
|
|
1065
|
+
console.log('Run this command to enable auto-start on reboot:');
|
|
1066
|
+
console.log(` ${match[1]}`);
|
|
1067
|
+
}
|
|
1068
|
+
// Non-critical - pm2 startup may need sudo
|
|
1069
|
+
}
|
|
1070
|
+
// Save pm2 process list for auto-restart on reboot
|
|
1071
|
+
try {
|
|
1072
|
+
execSync('pm2 save', { stdio: 'inherit' });
|
|
1073
|
+
}
|
|
1074
|
+
catch {
|
|
1075
|
+
// Non-critical
|
|
1076
|
+
}
|
|
1077
|
+
console.log('\n✓ exk installation complete!');
|
|
1078
|
+
console.log('The service is now running in the background.');
|
|
1079
|
+
if (startupConfigured) {
|
|
1080
|
+
console.log('✓ Auto-start on reboot is configured.');
|
|
1081
|
+
}
|
|
1082
|
+
console.log('\nUseful commands:');
|
|
1083
|
+
console.log(' pm2 logs cli - View logs');
|
|
1084
|
+
console.log(' pm2 restart cli - Restart service');
|
|
1085
|
+
console.log(' pm2 stop cli - Stop service');
|
|
1086
|
+
console.log(' pm2 delete cli - Remove service');
|
|
1087
|
+
}
|
|
1088
|
+
catch (err) {
|
|
1089
|
+
const error = err;
|
|
1090
|
+
console.log('\n⚠ PM2 installation had issues, but device is registered.');
|
|
1091
|
+
const execErr = error;
|
|
1092
|
+
const errDetail = execErr.stderr?.toString() || execErr.stdout?.toString() || error.message;
|
|
1093
|
+
if (errDetail)
|
|
1094
|
+
console.error('Error:', errDetail);
|
|
1095
|
+
console.log('You can start the daemon manually with: exk daemon');
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
catch (error) {
|
|
1100
|
+
const err = error;
|
|
1101
|
+
const errorMsg = err.message || String(error);
|
|
1102
|
+
if (errorMsg.includes('approval') || errorMsg.includes('email')) {
|
|
1103
|
+
// Registration failed - exit with error
|
|
1104
|
+
process.exit(1);
|
|
1105
|
+
}
|
|
1106
|
+
// Service installation failed, but device is registered
|
|
1107
|
+
console.log('\n⚠ Service installation had issues, but device is registered.');
|
|
1108
|
+
console.error('Error:', err.stderr?.toString() || errorMsg);
|
|
1109
|
+
console.log('You can start the daemon manually with: exk daemon');
|
|
1110
|
+
}
|
|
1111
|
+
// Exit after install is complete
|
|
1112
|
+
process.exit(0);
|
|
1113
|
+
});
|
|
1114
|
+
// Uninstall command
|
|
1115
|
+
program
|
|
1116
|
+
.command('uninstall')
|
|
1117
|
+
.description('Uninstall exk daemon (removes pm2 service and config)')
|
|
1118
|
+
.action(async () => {
|
|
1119
|
+
console.log('Uninstalling exk daemon...');
|
|
1120
|
+
if (process.platform === 'win32') {
|
|
1121
|
+
console.log('On Windows there is no service to remove.');
|
|
1122
|
+
console.log('To remove exk completely: npm uninstall -g @exreve/exk');
|
|
1123
|
+
}
|
|
1124
|
+
else {
|
|
1125
|
+
try {
|
|
1126
|
+
execSync('pm2 delete cli', { stdio: 'inherit' });
|
|
1127
|
+
try {
|
|
1128
|
+
execSync('pm2 save', { stdio: 'inherit' });
|
|
1129
|
+
}
|
|
1130
|
+
catch { /* non-critical */ }
|
|
1131
|
+
console.log('\n✓ Service uninstalled!');
|
|
1132
|
+
console.log('To remove the package completely: npm uninstall -g @exreve/exk');
|
|
1133
|
+
}
|
|
1134
|
+
catch {
|
|
1135
|
+
console.log('\n⚠ PM2 service not found or already removed.');
|
|
1136
|
+
console.log('To remove exk: npm uninstall -g @exreve/exk');
|
|
1137
|
+
console.log(' rm -rf ~/.talk-to-code');
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
// Exit after uninstall is complete
|
|
1141
|
+
process.exit(0);
|
|
1142
|
+
});
|
|
1143
|
+
// Update command - uses npm to update the global package
|
|
1144
|
+
program
|
|
1145
|
+
.command('update')
|
|
1146
|
+
.description('Update exk to the latest version via npm')
|
|
1147
|
+
.option('-f, --force', 'Update without confirmation')
|
|
1148
|
+
.action(async (options) => {
|
|
1149
|
+
const currentVersion = getCliVersion();
|
|
1150
|
+
console.log(`Current version: ${currentVersion}`);
|
|
1151
|
+
// Check latest version from npm
|
|
1152
|
+
try {
|
|
1153
|
+
const latestVersion = execSync('npm view @exreve/exk version', { encoding: 'utf-8' }).trim();
|
|
1154
|
+
console.log(`Latest version: ${latestVersion}`);
|
|
1155
|
+
if (latestVersion === currentVersion) {
|
|
1156
|
+
console.log('✓ Already up to date');
|
|
1157
|
+
process.exit(0);
|
|
1158
|
+
}
|
|
1159
|
+
if (!options.force && process.stdin.isTTY) {
|
|
1160
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
1161
|
+
const answer = await new Promise(r => rl.question('Update now? [Y/n] ', a => { rl.close(); r(a.trim()); }));
|
|
1162
|
+
if (answer.toLowerCase().startsWith('n')) {
|
|
1163
|
+
process.exit(0);
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
console.log('Updating...');
|
|
1167
|
+
execSync('npm i -g @exreve/exk@latest', { stdio: 'inherit' });
|
|
1168
|
+
console.log(`\n✓ Updated to ${latestVersion}`);
|
|
1169
|
+
// Restart PM2 if running
|
|
1170
|
+
try {
|
|
1171
|
+
execSync('pm2 restart cli', { stdio: 'inherit' });
|
|
1172
|
+
}
|
|
1173
|
+
catch {
|
|
1174
|
+
// Not running under PM2, that's fine
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
catch (error) {
|
|
1178
|
+
console.error('Update failed:', error.message);
|
|
1179
|
+
process.exit(1);
|
|
1180
|
+
}
|
|
1181
|
+
process.exit(0);
|
|
1182
|
+
});
|
|
1183
|
+
program
|
|
1184
|
+
.command('check-update')
|
|
1185
|
+
.description('Check for updates')
|
|
1186
|
+
.action(async () => {
|
|
1187
|
+
const currentVersion = getCliVersion();
|
|
1188
|
+
console.log(`Current version: ${currentVersion}`);
|
|
1189
|
+
try {
|
|
1190
|
+
const latestVersion = execSync('npm view @exreve/exk version', { encoding: 'utf-8' }).trim();
|
|
1191
|
+
console.log(`Latest version: ${latestVersion}`);
|
|
1192
|
+
if (latestVersion === currentVersion) {
|
|
1193
|
+
console.log('✓ Up to date');
|
|
1194
|
+
}
|
|
1195
|
+
else {
|
|
1196
|
+
console.log(`📦 Update available: ${currentVersion} → ${latestVersion}`);
|
|
1197
|
+
console.log('Run "exk update" to install');
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
catch (error) {
|
|
1201
|
+
console.error('Failed to check for updates:', error.message);
|
|
1202
|
+
process.exit(1);
|
|
1203
|
+
}
|
|
1204
|
+
process.exit(0);
|
|
1205
|
+
});
|
|
1206
|
+
// Run (connect to backend and process prompts)
|
|
1207
|
+
program
|
|
1208
|
+
.command('run')
|
|
1209
|
+
.description('Run CLI and connect to backend')
|
|
1210
|
+
.option('--email <email>', 'Email for device approval')
|
|
1211
|
+
.action((options) => {
|
|
1212
|
+
runDaemon(false, options.email).catch((error) => {
|
|
1213
|
+
console.error('Run error:', error.message);
|
|
1214
|
+
process.exit(1);
|
|
1215
|
+
});
|
|
1216
|
+
});
|
|
1217
|
+
// Hidden daemon command (internal alias)
|
|
1218
|
+
program
|
|
1219
|
+
.command('daemon', { hidden: true })
|
|
1220
|
+
.description('Run daemon (internal)')
|
|
1221
|
+
.option('--foreground', 'Run in foreground', false)
|
|
1222
|
+
.option('--email <email>', 'Email address')
|
|
1223
|
+
.action((options) => {
|
|
1224
|
+
runDaemon(options.foreground, options.email).catch((error) => {
|
|
1225
|
+
console.error('Daemon error:', error.message);
|
|
1226
|
+
process.exit(1);
|
|
1227
|
+
});
|
|
1228
|
+
});
|
|
1229
|
+
// Enable proxy command
|
|
1230
|
+
program
|
|
1231
|
+
.command('enable')
|
|
1232
|
+
.description('Enable a feature (e.g. proxy)')
|
|
1233
|
+
.argument('<feature>', 'Feature to enable (proxy)')
|
|
1234
|
+
.action(async (feature) => {
|
|
1235
|
+
if (feature !== 'proxy') {
|
|
1236
|
+
console.error(`Unknown feature: ${feature}. Available: proxy`);
|
|
1237
|
+
process.exit(1);
|
|
1238
|
+
}
|
|
1239
|
+
const proxyUrl = getProxyUrl();
|
|
1240
|
+
if (!proxyUrl) {
|
|
1241
|
+
console.log('No proxy URL configured. Run "exk daemon" first to sync config from server.');
|
|
1242
|
+
process.exit(1);
|
|
1243
|
+
}
|
|
1244
|
+
await writeProxyConfig({ enabled: true });
|
|
1245
|
+
console.log(`Proxy enabled: ${proxyUrl}`);
|
|
1246
|
+
process.exit(0);
|
|
1247
|
+
});
|
|
1248
|
+
// Disable proxy command
|
|
1249
|
+
program
|
|
1250
|
+
.command('disable')
|
|
1251
|
+
.description('Disable a feature (e.g. proxy)')
|
|
1252
|
+
.argument('<feature>', 'Feature to disable (proxy)')
|
|
1253
|
+
.action(async (feature) => {
|
|
1254
|
+
if (feature !== 'proxy') {
|
|
1255
|
+
console.error(`Unknown feature: ${feature}. Available: proxy`);
|
|
1256
|
+
process.exit(1);
|
|
1257
|
+
}
|
|
1258
|
+
await writeProxyConfig({ enabled: false });
|
|
1259
|
+
console.log('Proxy disabled');
|
|
1260
|
+
process.exit(0);
|
|
1261
|
+
});
|
|
1262
|
+
program.parse();
|