@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/app-child.js DELETED
@@ -1,1038 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * TalkToCode CLI - Child Process (App Bundle)
4
- *
5
- * This is the child process that contains all application logic.
6
- * It is spawned by the updater process (updater.ts).
7
- *
8
- * This file is what gets updated during updates - the updater stays constant.
9
- */
10
- import { Command } from 'commander';
11
- import { io } from 'socket.io-client';
12
- import { v4 as uuidv4 } from 'uuid';
13
- import fs from 'fs/promises';
14
- import fsSync from 'fs';
15
- import path from 'path';
16
- import os, { networkInterfaces } from 'os';
17
- import { Buffer } from 'buffer';
18
- import crypto from 'crypto';
19
- import { createProject, deleteProject } from './projectManager.js';
20
- import { agentSessionManager } from './agentSession.js';
21
- import { registerGitHubHandlers } from './githubHandlers.js';
22
- import { registerCloudflaredHandlers } from './cloudflaredHandlers.js';
23
- import { registerContainerHandlers } from './containerHandlers.js';
24
- import { registerSessionHandlers } from './sessionHandlers.js';
25
- import { registerAppHandlers } from './appHandlers.js';
26
- import { registerFsHandlers } from './fsHandlers.js';
27
- import { registerUpdateHandlers } from './updateHandlers.js';
28
- import { execSync } from 'child_process';
29
- import readline from 'readline';
30
- import { fileURLToPath } from 'url';
31
- import { createHash } from 'crypto';
32
- // ============ Update IPC (Child -> Updater) ============
33
- /**
34
- * Request update from parent updater process
35
- */
36
- function requestUpdateFromParent() {
37
- const updaterPid = process.env.TTC_UPDATER_PID;
38
- if (updaterPid) {
39
- try {
40
- // Send SIGUSR1 to parent to request update
41
- process.kill(parseInt(updaterPid, 10), 'SIGUSR1');
42
- }
43
- catch (error) {
44
- console.error('Failed to request update from parent:', error);
45
- }
46
- }
47
- }
48
- // ============ Constants ============
49
- const CONFIG_DIR = path.join(os.homedir(), '.talk-to-code');
50
- const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
51
- const DEVICE_ID_FILE = path.join(CONFIG_DIR, 'device-id.json');
52
- const DEVICE_CONFIG_FILE = path.join(CONFIG_DIR, 'device-config.json');
53
- const AI_CONFIG_FILE = path.join(CONFIG_DIR, 'ai-config.json');
54
- const DEFAULT_API_URL = 'https://api.talk-to-code.com';
55
- // ============ Helpers ============
56
- async function readConfig() {
57
- try {
58
- const data = await fs.readFile(CONFIG_FILE, 'utf-8');
59
- return JSON.parse(data);
60
- }
61
- catch {
62
- return { apiUrl: DEFAULT_API_URL };
63
- }
64
- }
65
- async function writeConfig(config) {
66
- await fs.mkdir(CONFIG_DIR, { recursive: true });
67
- await fs.writeFile(CONFIG_FILE, JSON.stringify(config, null, 2));
68
- }
69
- /**
70
- * Fetch AI config from server and save to ~/.talk-to-code/ai-config.json
71
- */
72
- async function fetchAiConfig(authToken) {
73
- try {
74
- const config = await readConfig();
75
- const res = await fetch(`${config.apiUrl}/config/ai`, {
76
- headers: { Authorization: `Bearer ${authToken}` },
77
- });
78
- if (!res.ok) {
79
- console.log(`[fetchAiConfig] Server returned ${res.status}`);
80
- return false;
81
- }
82
- const data = await res.json();
83
- if (!data.success || !data.aiConfig) {
84
- console.log('[fetchAiConfig] No AI config available for this user');
85
- return false;
86
- }
87
- await fs.writeFile(AI_CONFIG_FILE, JSON.stringify(data.aiConfig, null, 2));
88
- console.log('[fetchAiConfig] AI config saved successfully');
89
- return true;
90
- }
91
- catch (error) {
92
- console.log(`[fetchAiConfig] Failed: ${error.message}`);
93
- return false;
94
- }
95
- }
96
- /** TTL cache for ai-config.json reads */
97
- let _aiCfgCache = null;
98
- const _AI_CFG_TTL = 5_000;
99
- function readAiConfigCached() {
100
- const now = Date.now();
101
- if (_aiCfgCache && (now - _aiCfgCache.ts) < _AI_CFG_TTL)
102
- return _aiCfgCache.raw;
103
- try {
104
- const raw = fsSync.readFileSync(AI_CONFIG_FILE, 'utf-8');
105
- _aiCfgCache = { raw, ts: now };
106
- return raw;
107
- }
108
- catch {
109
- return '';
110
- }
111
- }
112
- function hasAiCredentials() {
113
- try {
114
- const raw = readAiConfigCached();
115
- if (!raw)
116
- return false;
117
- const j = JSON.parse(raw);
118
- return typeof j.authToken === 'string' && j.authToken.trim().length > 0;
119
- }
120
- catch {
121
- return false;
122
- }
123
- }
124
- async function readDeviceAuthToken() {
125
- const env = process.env.TTC_AUTH_TOKEN?.trim();
126
- if (env)
127
- return env;
128
- try {
129
- const raw = await fs.readFile(DEVICE_CONFIG_FILE, 'utf-8');
130
- const j = JSON.parse(raw);
131
- return typeof j.authToken === 'string' ? j.authToken.trim() : undefined;
132
- }
133
- catch {
134
- return undefined;
135
- }
136
- }
137
- async function readDiskDeviceConfig() {
138
- try {
139
- const raw = await fs.readFile(DEVICE_CONFIG_FILE, 'utf-8');
140
- return JSON.parse(raw);
141
- }
142
- catch {
143
- return {};
144
- }
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
- async function maybeFetchAiConfigIfMissing() {
165
- if (hasAiCredentials())
166
- return;
167
- const jwt = await readDeviceAuthToken();
168
- if (!jwt)
169
- return;
170
- const ok = await fetchAiConfig(jwt);
171
- if (!ok && !hasAiCredentials()) {
172
- const cfg = await readConfig();
173
- 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.`);
174
- }
175
- }
176
- function scheduleAiConfigSync(authToken) {
177
- void fetchAiConfig(authToken).then((ok) => {
178
- if (!ok && !hasAiCredentials()) {
179
- console.warn('[CLI] AI config sync failed; check backend /config/ai and apiUrl.');
180
- }
181
- });
182
- }
183
- async function getDeviceId() {
184
- try {
185
- const data = await fs.readFile(DEVICE_ID_FILE, 'utf-8');
186
- return JSON.parse(data).deviceId;
187
- }
188
- catch {
189
- const deviceId = uuidv4();
190
- await fs.mkdir(CONFIG_DIR, { recursive: true });
191
- await fs.writeFile(DEVICE_ID_FILE, JSON.stringify({ deviceId }, null, 2));
192
- return deviceId;
193
- }
194
- }
195
- function getLocalIpAddress() {
196
- const nets = networkInterfaces();
197
- for (const name of Object.keys(nets)) {
198
- for (const net of nets[name]) {
199
- if (net.family === 'IPv4' && !net.internal) {
200
- return net.address;
201
- }
202
- }
203
- }
204
- return 'Unknown';
205
- }
206
- function getHostname() {
207
- return os.hostname() || 'Unknown';
208
- }
209
- async function connect() {
210
- const config = await readConfig();
211
- const deviceId = await getDeviceId();
212
- return new Promise((resolve, reject) => {
213
- const socket = io(config.apiUrl, {
214
- transports: ['websocket', 'polling'],
215
- });
216
- socket.on('connect', () => {
217
- socket.emit('register', { type: 'cli', deviceId });
218
- resolve(socket);
219
- });
220
- socket.on('registered', ({ type }) => {
221
- console.log(`Connected as ${type}`);
222
- });
223
- socket.on('connect_error', (err) => {
224
- reject(err);
225
- });
226
- });
227
- }
228
- const CURRENT_FILE = fileURLToPath(import.meta.url);
229
- const __dirname = path.dirname(CURRENT_FILE);
230
- function getCliHash() {
231
- return createHash('sha256').update(fsSync.readFileSync(CURRENT_FILE)).digest('hex');
232
- }
233
- async function checkForUpdate() {
234
- try {
235
- const config = await readConfig();
236
- const res = await fetch(`${config.apiUrl}/update/check`, {
237
- method: 'POST',
238
- headers: { 'Content-Type': 'application/json' },
239
- body: JSON.stringify({ hash: getCliHash(), platform: os.platform() })
240
- });
241
- if (!res.ok)
242
- return null;
243
- return await res.json();
244
- }
245
- catch {
246
- return null;
247
- }
248
- }
249
- async function replaceSelf(tarballBuffer) {
250
- const extractDir = path.join(os.tmpdir(), `ttc-update-${Date.now()}`);
251
- await fs.mkdir(extractDir, { recursive: true });
252
- const tarPath = path.join(extractDir, 'ttc-cli.tar.gz');
253
- await fs.writeFile(tarPath, tarballBuffer);
254
- execSync(`tar -xzf "${tarPath}" -C "${extractDir}"`);
255
- await fs.unlink(tarPath);
256
- // Preserve user config/token files (never overwrite on update)
257
- const preserveFiles = ['device-config.json', 'device-id.json', 'config.json'];
258
- const preserved = {};
259
- for (const f of preserveFiles) {
260
- try {
261
- preserved[f] = await fs.readFile(path.join(CONFIG_DIR, f));
262
- }
263
- catch {
264
- /* file may not exist */
265
- }
266
- }
267
- const cliDest = path.join(CONFIG_DIR, 'cli');
268
- const sharedDest = path.join(CONFIG_DIR, 'shared');
269
- await fs.rm(cliDest, { recursive: true, force: true });
270
- await fs.rm(sharedDest, { recursive: true, force: true });
271
- await fs.cp(path.join(extractDir, 'cli'), cliDest, { recursive: true });
272
- await fs.cp(path.join(extractDir, 'shared'), sharedDest, { recursive: true });
273
- await fs.copyFile(path.join(extractDir, 'package.json'), path.join(CONFIG_DIR, 'package.json'));
274
- await fs.rm(extractDir, { recursive: true, force: true });
275
- // Restore preserved config
276
- for (const f of preserveFiles) {
277
- if (preserved[f])
278
- await fs.writeFile(path.join(CONFIG_DIR, f), preserved[f]);
279
- }
280
- console.log('āœ“ CLI updated');
281
- const npmPaths = [path.join(os.homedir(), '.nvm/versions/node/v22/bin/npm'), path.join(os.homedir(), '.nvm/versions/node/v20/bin/npm')];
282
- const npmBin = npmPaths.find(p => fsSync.existsSync(p)) || 'npm';
283
- try {
284
- execSync(`"${npmBin}" install --omit=dev`, { cwd: CONFIG_DIR, stdio: 'inherit' });
285
- console.log('āœ“ Dependencies updated');
286
- }
287
- catch {
288
- console.warn('⚠ npm install failed');
289
- }
290
- }
291
- async function selfUpdate(force = false) {
292
- const info = await checkForUpdate();
293
- if (!info || !info.updateAvailable) {
294
- console.log('āœ“ Already up to date');
295
- return;
296
- }
297
- console.log('šŸ“¦ Update available!');
298
- if (!force && process.stdin.isTTY) {
299
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
300
- const answer = await new Promise(r => rl.question('Update now? [Y/n] ', a => { rl.close(); r(a.trim()); }));
301
- if (answer.toLowerCase().startsWith('n'))
302
- return;
303
- }
304
- if (!info.downloadUrl || !info.hash)
305
- return;
306
- console.log('Downloading...');
307
- const res = await fetch(info.downloadUrl);
308
- if (!res.ok)
309
- throw new Error('Download failed');
310
- const bundle = Buffer.from(await res.arrayBuffer());
311
- if (createHash('sha256').update(bundle).digest('hex') !== info.hash) {
312
- throw new Error('Hash mismatch');
313
- }
314
- await replaceSelf(bundle);
315
- process.exit(0);
316
- }
317
- // ============ Commands ============
318
- async function registerDevice(name, email) {
319
- try {
320
- const deviceId = await getDeviceId();
321
- let deviceEmail = email;
322
- // If we already have a valid token, skip approval email and just register
323
- try {
324
- const deviceConfig = await readDiskDeviceConfig();
325
- const existingToken = deviceConfig.authToken;
326
- if (existingToken && typeof existingToken === 'string') {
327
- const config = await readConfig();
328
- const meRes = await fetch(`${config.apiUrl}/auth/me`, {
329
- headers: { Authorization: `Bearer ${existingToken}` }
330
- });
331
- const meData = await meRes.json();
332
- if (meRes.ok && meData.success && meData.user?.email) {
333
- console.log(`āœ“ Using existing auth token for ${meData.user.email}`);
334
- deviceEmail = deviceEmail || meData.user.email;
335
- const socket = await connect();
336
- await new Promise((resolve, reject) => {
337
- socket.emit('device:register', {
338
- deviceId,
339
- name: name || `CLI Device ${deviceId.slice(0, 8)}`,
340
- ipAddress: getLocalIpAddress(),
341
- hostname: getHostname(),
342
- email: deviceEmail
343
- }, ({ success, device, error }) => {
344
- socket.disconnect();
345
- if (success && device) {
346
- console.log(`āœ“ Device registered!`);
347
- console.log(`Device ID: ${device.deviceId}`);
348
- console.log(`Name: ${device.name}`);
349
- console.log(`Email: ${device.email}`);
350
- resolve();
351
- }
352
- else {
353
- reject(new Error(error || 'Registration failed'));
354
- }
355
- });
356
- });
357
- await fetchAiConfig(existingToken);
358
- return;
359
- }
360
- }
361
- }
362
- catch {
363
- // No config or invalid token - continue with approval flow
364
- }
365
- // Prompt for email if not provided
366
- if (!deviceEmail) {
367
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
368
- deviceEmail = await new Promise((resolve) => {
369
- rl.question('Enter your email address: ', (answer) => {
370
- rl.close();
371
- resolve(answer.trim());
372
- });
373
- });
374
- }
375
- if (!deviceEmail?.includes('@')) {
376
- console.error('āœ— Valid email address is required');
377
- throw new Error('Valid email address is required');
378
- }
379
- // Generate 32-byte random token (base64url encoded)
380
- const randomBytes = crypto.randomBytes(32);
381
- const approvalToken = randomBytes.toString('base64url'); // base64url encoding (no padding, URL-safe)
382
- console.log(`\nšŸ“§ Sending approval request to ${deviceEmail}...`);
383
- // Send approval request
384
- const config = await readConfig();
385
- const response = await fetch(`${config.apiUrl}/auth/approval-request`, {
386
- method: 'POST',
387
- headers: {
388
- 'Content-Type': 'application/json'
389
- },
390
- body: JSON.stringify({
391
- email: deviceEmail,
392
- deviceId,
393
- approvalToken
394
- })
395
- });
396
- const result = await response.json();
397
- if (!result.success) {
398
- console.error(`āœ— Failed to send approval request: ${result.error || 'Unknown error'}`);
399
- throw new Error(result.error || 'Failed to send approval request');
400
- }
401
- console.log(`āœ“ Approval email sent! Check your inbox (${deviceEmail})`);
402
- console.log(`ā³ Waiting for approval...`);
403
- // Poll every 5 seconds for approval
404
- const maxAttempts = 120; // 10 minutes max (120 * 5s)
405
- let attempts = 0;
406
- const checkApproval = async () => {
407
- attempts++;
408
- try {
409
- const statusResponse = await fetch(`${config.apiUrl}/auth/approval-status?token=${approvalToken}`);
410
- const statusResult = await statusResponse.json();
411
- if (statusResult.success && statusResult.approved) {
412
- return statusResult.token || null; // Return permanent auth token
413
- }
414
- if (statusResult.error && !statusResult.pending) {
415
- console.error(`\nāœ— ${statusResult.error}`);
416
- return null;
417
- }
418
- return null; // Still pending
419
- }
420
- catch (error) {
421
- console.error(`\nāœ— Error checking approval status: ${error.message}`);
422
- return null;
423
- }
424
- };
425
- // Poll until approved or timeout
426
- while (attempts < maxAttempts) {
427
- await new Promise(resolve => setTimeout(resolve, 5000)); // Wait 5 seconds
428
- const token = await checkApproval();
429
- if (token) {
430
- // Device approved! Store permanent token
431
- await writeDeviceConfigMerged({ email: deviceEmail, authToken: token });
432
- console.log(`\nāœ“ Device approved!`);
433
- // Register device with backend and wait for completion
434
- const socket = await connect();
435
- await new Promise((resolve, reject) => {
436
- socket.emit('device:register', {
437
- deviceId,
438
- name: name || `CLI Device ${deviceId.slice(0, 8)}`,
439
- ipAddress: getLocalIpAddress(),
440
- hostname: getHostname(),
441
- email: deviceEmail
442
- }, ({ success, device, error }) => {
443
- socket.disconnect();
444
- if (success && device) {
445
- console.log(`āœ“ Device registered!`);
446
- console.log(`Device ID: ${device.deviceId}`);
447
- console.log(`Name: ${device.name}`);
448
- console.log(`Email: ${device.email}`);
449
- console.log(`IP Address: ${device.ipAddress || 'Unknown'}`);
450
- console.log(`Hostname: ${device.hostname || 'Unknown'}`);
451
- resolve();
452
- }
453
- else {
454
- reject(new Error(error || 'Registration failed'));
455
- }
456
- });
457
- });
458
- await fetchAiConfig(token);
459
- return; // Successfully registered
460
- }
461
- // Show progress every 30 seconds
462
- if (attempts % 6 === 0) {
463
- process.stdout.write(`\rā³ Still waiting... (${attempts * 5}s)`);
464
- }
465
- }
466
- console.error(`\nāœ— Approval timeout. Please check your email and try again.`);
467
- throw new Error('Approval timeout');
468
- }
469
- catch (error) {
470
- console.error('Failed to register device:', error.message);
471
- throw error;
472
- }
473
- }
474
- // Active sessions map - persists across reconnections
475
- const activeSessions = new Map();
476
- async function runDaemon(foreground = false, email) {
477
- const config = await readConfig();
478
- const deviceId = await getDeviceId();
479
- const ipAddress = getLocalIpAddress();
480
- const hostname = getHostname();
481
- // Silent update check on startup
482
- checkForUpdate().then(info => {
483
- if (info?.updateAvailable)
484
- console.log('šŸ“¦ Update available: run "ttc update"');
485
- }).catch(() => { });
486
- if (foreground)
487
- console.log('=== TalkToCode CLI (Foreground) ===');
488
- else
489
- console.log('Starting daemon...');
490
- console.log(`API: ${config.apiUrl}`);
491
- console.log(`Device: ${deviceId}`);
492
- console.log(`Host: ${hostname}`);
493
- // Test if server is reachable
494
- if (foreground) {
495
- try {
496
- const url = new URL(config.apiUrl);
497
- const testUrl = `${url.protocol}//${url.host}`;
498
- console.log(`Testing connection to ${testUrl}...`);
499
- const response = await fetch(testUrl, {
500
- method: 'GET',
501
- signal: AbortSignal.timeout(5000)
502
- });
503
- console.log(`āœ“ Server is reachable (HTTP ${response.status})`);
504
- }
505
- catch (error) {
506
- console.error(`āœ— Cannot reach server: ${error.message}`);
507
- console.error(` Make sure the backend server is running at ${config.apiUrl}`);
508
- }
509
- }
510
- console.log('');
511
- await maybeFetchAiConfigIfMissing();
512
- let socket = null;
513
- let reconnectTimeout = null;
514
- let reconnectAttempts = 0;
515
- const MAX_RECONNECT_ATTEMPTS = Infinity; // Keep trying forever
516
- const connectAndRegister = async () => {
517
- try {
518
- socket = io(config.apiUrl, {
519
- transports: ['websocket', 'polling'],
520
- reconnection: true,
521
- reconnectionDelay: 1000,
522
- reconnectionDelayMax: 10000,
523
- reconnectionAttempts: MAX_RECONNECT_ATTEMPTS,
524
- timeout: 20000,
525
- forceNew: true,
526
- upgrade: true,
527
- });
528
- // Wire socket reference for agentSession to fetch history from backend
529
- agentSessionManager.setSocketRef(socket);
530
- if (foreground) {
531
- console.log(`Attempting to connect to: ${config.apiUrl}`);
532
- }
533
- // Register event handlers once (outside registered callback to prevent duplicates)
534
- // Project handlers
535
- socket.on('project:create', async (data, callback) => {
536
- try {
537
- if (foreground) {
538
- console.log(`šŸ“ Creating project: ${data.name} at ${data.path}`);
539
- }
540
- const result = await createProject({
541
- projectId: data.projectId || uuidv4(),
542
- name: data.name,
543
- path: data.path,
544
- sourcePath: data.sourcePath
545
- });
546
- if (result.success) {
547
- if (foreground) {
548
- console.log(`āœ“ Project created: ${data.name} at ${result.actualPath || data.path}`);
549
- }
550
- else {
551
- console.log(`Project created: ${data.name} at ${result.actualPath || data.path}`);
552
- }
553
- }
554
- else {
555
- if (foreground) {
556
- console.error(`āœ— Failed to create project: ${result.error || 'Unknown error'}`);
557
- }
558
- }
559
- callback?.({
560
- success: result.success,
561
- error: result.error,
562
- actualPath: result.actualPath
563
- });
564
- }
565
- catch (error) {
566
- if (foreground) {
567
- console.error(`āœ— Error creating project: ${error.message}`);
568
- }
569
- callback?.({ success: false, error: error.message });
570
- }
571
- });
572
- socket.on('project:delete', async (data, callback) => {
573
- try {
574
- const { projectId, path } = data;
575
- if (!path) {
576
- callback?.({ success: false, error: 'Project path required for deletion' });
577
- return;
578
- }
579
- if (foreground) {
580
- console.log(`šŸ—‘ļø Deleting project: ${projectId} at ${path}`);
581
- }
582
- const result = await deleteProject(path);
583
- if (result.success) {
584
- if (foreground) {
585
- console.log(`āœ“ Project deleted: ${projectId} at ${path}`);
586
- }
587
- else {
588
- console.log(`Project deleted: ${projectId} at ${path}`);
589
- }
590
- }
591
- else {
592
- if (foreground) {
593
- console.error(`āœ— Failed to delete project: ${result.error || 'Unknown error'}`);
594
- }
595
- }
596
- callback?.(result);
597
- }
598
- catch (error) {
599
- if (foreground) {
600
- console.error(`āœ— Error deleting project: ${error.message}`);
601
- }
602
- callback?.({ success: false, error: error.message });
603
- }
604
- });
605
- // GitHub handlers (extracted to cli/githubHandlers.ts)
606
- registerGitHubHandlers(socket, foreground);
607
- // FS handlers (extracted to cli/fsHandlers.ts)
608
- registerFsHandlers(socket);
609
- // Update handlers (extracted to cli/updateHandlers.ts)
610
- registerUpdateHandlers(socket, foreground, readConfig, requestUpdateFromParent);
611
- // Session handlers (extracted to cli/sessionHandlers.ts)
612
- registerSessionHandlers(socket, foreground, activeSessions, () => socket);
613
- // App control handlers (extracted to cli/appHandlers.ts)
614
- registerAppHandlers(socket, foreground);
615
- socket.on('connect', () => {
616
- if (foreground) {
617
- console.log(`āœ“ Connected to backend at ${config.apiUrl}`);
618
- if (socket) {
619
- console.log(` Socket ID: ${socket.id}`);
620
- }
621
- }
622
- else {
623
- console.log('Connected to backend');
624
- }
625
- reconnectAttempts = 0;
626
- // Register as CLI device
627
- if (socket) {
628
- socket.emit('register', { type: 'cli', deviceId });
629
- }
630
- });
631
- socket.on('registered', async ({ type }) => {
632
- if (foreground) {
633
- console.log(`āœ“ Registered as ${type}`);
634
- }
635
- else {
636
- console.log(`Registered as ${type}`);
637
- }
638
- // Check for auth token: env TTC_AUTH_TOKEN or device-config.json (from previous approval)
639
- let authToken = process.env.TTC_AUTH_TOKEN;
640
- let deviceEmail = email;
641
- let skipEmailFlow = false;
642
- // Load from device-config if no env token (reuse state from previous approval)
643
- if (!authToken) {
644
- try {
645
- const deviceConfig = await readDiskDeviceConfig();
646
- authToken = deviceConfig.authToken;
647
- if (!deviceEmail && deviceConfig.email)
648
- deviceEmail = deviceConfig.email;
649
- }
650
- catch {
651
- // No device config yet
652
- }
653
- }
654
- if (authToken) {
655
- try {
656
- const parts = authToken.split('.');
657
- if (parts.length === 3) {
658
- const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString());
659
- if (payload.type === 'device_auth' && payload.email) {
660
- deviceEmail = payload.email;
661
- skipEmailFlow = true;
662
- if (foreground) {
663
- console.log(`āœ“ Using stored auth token for ${deviceEmail}`);
664
- }
665
- }
666
- }
667
- }
668
- catch (err) {
669
- if (foreground) {
670
- console.warn(`āš ļø Invalid auth token in config, falling back to normal auth`);
671
- }
672
- authToken = undefined;
673
- }
674
- }
675
- // Load device email from config if not using token and not provided
676
- if (!deviceEmail && !skipEmailFlow) {
677
- try {
678
- const deviceConfig = await readDiskDeviceConfig();
679
- deviceEmail = deviceConfig.email;
680
- }
681
- catch {
682
- // No device config yet
683
- }
684
- }
685
- // Register device with backend
686
- let needsApprovalLoggedOnce = false;
687
- const registerDevice = () => {
688
- // Determine device name
689
- const containerName = process.env.CONTAINER_NAME;
690
- const deviceName = containerName ? `Container: ${containerName}` : `CLI Device ${hostname}`;
691
- socket.emit('device:register', {
692
- deviceId,
693
- name: deviceName,
694
- ipAddress,
695
- hostname,
696
- email: deviceEmail,
697
- // When using auth token, include the token for verification
698
- authToken: skipEmailFlow ? authToken : undefined
699
- }, ({ success, needsApproval, message, device, error }) => {
700
- if (success && device) {
701
- needsApprovalLoggedOnce = false;
702
- if (foreground) {
703
- console.log(`āœ“ Device registered: ${device.name}`);
704
- if (device.approved) {
705
- console.log(`āœ“ Device approved and ready`);
706
- }
707
- }
708
- else {
709
- console.log(`Device registered: ${device.name}${device.approved ? ' (approved)' : ' (pending approval)'}`);
710
- }
711
- // Save config: email + authToken (preserve token so next run can reuse)
712
- if (device.email) {
713
- void writeDeviceConfigMerged({
714
- email: device.email,
715
- ...(authToken ? { authToken } : {}),
716
- }).catch(() => { });
717
- }
718
- if (authToken) {
719
- scheduleAiConfigSync(authToken);
720
- }
721
- }
722
- else if (needsApproval) {
723
- // Persist email when approval was requested so restarts don't prompt again
724
- if (deviceEmail) {
725
- void writeDeviceConfigMerged({ email: deviceEmail }).catch(() => { });
726
- }
727
- // Only log once to avoid flooding logs every 30s on heartbeat
728
- if (!needsApprovalLoggedOnce) {
729
- needsApprovalLoggedOnce = true;
730
- if (foreground) {
731
- console.log(`\nāš ļø ${message || 'Device approval required'}`);
732
- if (!deviceEmail) {
733
- console.log(` Please run: npx tsx index.ts register --email your@email.com`);
734
- }
735
- else {
736
- console.log(` Check your email (${deviceEmail}) and click the approval link.`);
737
- }
738
- }
739
- else {
740
- console.log(`āš ļø Device approval required: ${message || 'Please approve device'}`);
741
- }
742
- }
743
- }
744
- else if (error) {
745
- console.error(`āœ— Registration error: ${error}`);
746
- }
747
- });
748
- };
749
- registerDevice();
750
- // Update lastSeen every 30 seconds while connected
751
- const heartbeatInterval = setInterval(() => {
752
- if (socket?.connected) {
753
- registerDevice();
754
- }
755
- else {
756
- clearInterval(heartbeatInterval);
757
- }
758
- }, 30000);
759
- socket.heartbeatInterval = heartbeatInterval;
760
- });
761
- // Cloudflared handlers (extracted to cli/cloudflaredHandlers.ts)
762
- registerCloudflaredHandlers(socket, foreground);
763
- // Container handlers (extracted to cli/containerHandlers.ts)
764
- registerContainerHandlers(socket, foreground, path.dirname(CURRENT_FILE));
765
- socket.on('disconnect', (reason) => {
766
- if (foreground) {
767
- console.log(`\nāš ļø Disconnected: ${reason}`);
768
- console.log(' Attempting to reconnect...');
769
- }
770
- else {
771
- console.log(`Disconnected: ${reason}`);
772
- }
773
- // Clear heartbeat interval
774
- if (socket.heartbeatInterval) {
775
- clearInterval(socket.heartbeatInterval);
776
- }
777
- if (reason === 'io server disconnect') {
778
- // Server disconnected, reconnect manually
779
- socket.connect();
780
- }
781
- else {
782
- // Client disconnect or transport close: schedule full reconnect so process stays alive
783
- if (reconnectTimeout)
784
- clearTimeout(reconnectTimeout);
785
- const delay = 2000;
786
- if (!foreground)
787
- console.log(`Reconnecting in ${delay}ms...`);
788
- reconnectTimeout = setTimeout(connectAndRegister, delay);
789
- }
790
- });
791
- socket.on('connect_error', (err) => {
792
- reconnectAttempts++;
793
- if (foreground) {
794
- console.error(`āœ— Connection error (attempt ${reconnectAttempts}): ${err.message}`);
795
- if (err.type) {
796
- console.error(` Error type: ${err.type}`);
797
- }
798
- if (err.description) {
799
- console.error(` Error description: ${err.description}`);
800
- }
801
- console.error(` Connecting to: ${config.apiUrl}`);
802
- if (err.cause) {
803
- console.error(` Cause:`, err.cause);
804
- }
805
- }
806
- else {
807
- console.error(`Connection error (attempt ${reconnectAttempts}):`, err.message);
808
- }
809
- });
810
- // Keep process alive
811
- socket.on('error', (err) => {
812
- if (foreground) {
813
- console.error(`āœ— Socket error: ${err}`);
814
- }
815
- else {
816
- console.error('Socket error:', err);
817
- }
818
- });
819
- }
820
- catch (error) {
821
- if (foreground) {
822
- console.error(`āœ— Failed to connect: ${error.message}`);
823
- }
824
- else {
825
- console.error('Failed to connect:', error.message);
826
- }
827
- reconnectAttempts++;
828
- if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
829
- const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 30000);
830
- if (foreground) {
831
- console.log(`ā³ Reconnecting in ${delay}ms...`);
832
- }
833
- else {
834
- console.log(`Reconnecting in ${delay}ms...`);
835
- }
836
- reconnectTimeout = setTimeout(connectAndRegister, delay);
837
- }
838
- }
839
- };
840
- // Handle graceful shutdown
841
- const shutdown = () => {
842
- if (foreground) {
843
- console.log('\n\nšŸ›‘ Shutting down gracefully...');
844
- }
845
- else {
846
- console.log('\nShutting down...');
847
- }
848
- if (reconnectTimeout) {
849
- clearTimeout(reconnectTimeout);
850
- }
851
- if (socket) {
852
- // Clear heartbeat interval
853
- if (socket.heartbeatInterval) {
854
- clearInterval(socket.heartbeatInterval);
855
- }
856
- socket.disconnect();
857
- }
858
- process.exit(0);
859
- };
860
- process.on('SIGTERM', shutdown);
861
- process.on('SIGINT', shutdown);
862
- process.on('SIGHUP', shutdown);
863
- // Start connection
864
- await connectAndRegister();
865
- // Keep process alive
866
- process.stdin.resume();
867
- }
868
- // ============ CLI Program ============
869
- const program = new Command();
870
- program
871
- .name('ttc')
872
- .description('TalkToCode CLI - Simple install and manage')
873
- .version('1.0.0');
874
- // Config command
875
- program
876
- .command('config')
877
- .description('Get or set config (api-url)')
878
- .option('--api-url <url>', 'API base URL')
879
- .action(async (opts) => {
880
- const config = await readConfig();
881
- if (opts.apiUrl !== undefined) {
882
- config.apiUrl = opts.apiUrl;
883
- await writeConfig(config);
884
- console.log('Set api-url:', config.apiUrl);
885
- }
886
- else {
887
- console.log('Config:', JSON.stringify(config, null, 2));
888
- }
889
- process.exit(0);
890
- });
891
- // Install command
892
- program
893
- .command('install')
894
- .description('Install and register TTC CLI (installs as pm2 daemon)')
895
- .argument('[email]', 'Email address for device approval')
896
- .action(async (email) => {
897
- try {
898
- // First register the device
899
- await registerDevice(undefined, email);
900
- // After successful registration, install as service (Linux only)
901
- if (process.platform === 'win32') {
902
- console.log('\nāœ“ Device registered.');
903
- console.log('On Windows, run the daemon manually: ttc daemon');
904
- console.log('To run in background: start /B node path\\to\\ttc daemon');
905
- }
906
- else {
907
- console.log('\nšŸ“¦ Installing TTC with pm2...');
908
- // install-service.sh: bundled at __dirname, or in ~/.talk-to-code for curl install
909
- let installScript = path.join(__dirname, 'install-service.sh');
910
- if (!fsSync.existsSync(installScript)) {
911
- installScript = path.join(CONFIG_DIR, 'install-service.sh');
912
- }
913
- if (!fsSync.existsSync(installScript)) {
914
- installScript = path.join(__dirname, '..', 'install-service.sh');
915
- }
916
- const scriptDir = path.dirname(installScript);
917
- try {
918
- await fs.access(installScript);
919
- const { execSync } = await import('child_process');
920
- execSync(`bash "${installScript}"`, {
921
- stdio: 'inherit',
922
- cwd: scriptDir,
923
- env: { ...process.env, PATH: process.env.PATH }
924
- });
925
- console.log('\nāœ“ TTC installation complete!');
926
- console.log('The service is now running in the background.');
927
- }
928
- catch (scriptErr) {
929
- const err = scriptErr;
930
- console.log('\n⚠ Service installation had issues, but device is registered.');
931
- if (err.message)
932
- console.error('Error:', err.message);
933
- console.log('You can start the daemon manually with: ttc daemon');
934
- }
935
- }
936
- }
937
- catch (error) {
938
- const errorMsg = error.message;
939
- if (errorMsg.includes('approval') || errorMsg.includes('email')) {
940
- // Registration failed - exit with error
941
- process.exit(1);
942
- }
943
- // Service installation failed, but device is registered
944
- console.log('\n⚠ Service installation had issues, but device is registered.');
945
- console.log('You can start the daemon manually with: ttc daemon');
946
- }
947
- // Exit after install is complete
948
- process.exit(0);
949
- });
950
- // Uninstall command
951
- program
952
- .command('uninstall')
953
- .description('Uninstall TTC CLI (removes service and config)')
954
- .action(async () => {
955
- console.log('šŸ—‘ļø Uninstalling TTC CLI...');
956
- if (process.platform === 'win32') {
957
- console.log('On Windows there is no service to remove.');
958
- console.log('To remove TTC completely:');
959
- console.log(' 1. Delete the ttc executable or folder');
960
- console.log(' 2. Remove config: rmdir /s /q "%USERPROFILE%\\.talk-to-code"');
961
- }
962
- else {
963
- let installScript = path.join(__dirname, 'install-service.sh');
964
- if (!fsSync.existsSync(installScript))
965
- installScript = path.join(CONFIG_DIR, 'install-service.sh');
966
- if (!fsSync.existsSync(installScript))
967
- installScript = path.join(__dirname, '..', 'install-service.sh');
968
- const scriptDir = path.dirname(installScript);
969
- try {
970
- await fs.access(installScript);
971
- const { execSync } = await import('child_process');
972
- execSync(`bash "${installScript}" --uninstall`, {
973
- stdio: 'inherit',
974
- cwd: scriptDir,
975
- env: { ...process.env, PATH: process.env.PATH }
976
- });
977
- console.log('\nāœ“ Service uninstalled!');
978
- console.log('To remove TTC completely, delete the binary:');
979
- console.log(' rm ~/.local/bin/ttc');
980
- }
981
- catch (error) {
982
- console.log('\n⚠ Service uninstall script not found or failed.');
983
- console.log('To remove TTC completely, delete:');
984
- console.log(' rm ~/.local/bin/ttc');
985
- console.log(' rm -rf ~/.talk-to-code');
986
- }
987
- }
988
- // Exit after uninstall is complete
989
- process.exit(0);
990
- });
991
- // Update command
992
- program
993
- .command('update')
994
- .description('Check for and apply CLI updates')
995
- .option('-f, --force', 'Update without confirmation')
996
- .action(async (options) => {
997
- await selfUpdate(options.force);
998
- process.exit(0);
999
- });
1000
- program
1001
- .command('check-update')
1002
- .description('Check for updates')
1003
- .action(async () => {
1004
- const info = await checkForUpdate();
1005
- if (!info)
1006
- process.exit(1);
1007
- if (!info.updateAvailable) {
1008
- console.log('āœ“ Up to date');
1009
- process.exit(0);
1010
- }
1011
- console.log('šŸ“¦ Update available');
1012
- console.log('Run "ttc update" to install');
1013
- process.exit(0);
1014
- });
1015
- // Run (connect to backend and process prompts)
1016
- program
1017
- .command('run')
1018
- .description('Run CLI and connect to backend')
1019
- .option('--email <email>', 'Email for device approval')
1020
- .action((options) => {
1021
- runDaemon(false, options.email).catch((error) => {
1022
- console.error('Run error:', error.message);
1023
- process.exit(1);
1024
- });
1025
- });
1026
- // Hidden daemon command (internal alias)
1027
- program
1028
- .command('daemon', { hidden: true })
1029
- .description('Run daemon (internal)')
1030
- .option('--foreground', 'Run in foreground', false)
1031
- .option('--email <email>', 'Email address')
1032
- .action((options) => {
1033
- runDaemon(options.foreground, options.email).catch((error) => {
1034
- console.error('Daemon error:', error.message);
1035
- process.exit(1);
1036
- });
1037
- });
1038
- program.parse();