@exreve/exk 1.0.55 → 1.0.56

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/index.js DELETED
@@ -1,1262 +0,0 @@
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();