@ts47andres/exeggutor 1.1.3 → 1.1.5

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/src/cli.js CHANGED
@@ -1,613 +1,613 @@
1
- // Exeggutor CLI - main command implementations.
2
- // Each exported function implements a specific CLI command, communicating
3
- // with the backend via HTTP API or managing server processes directly.
4
-
5
- const { existsSync, readFileSync, writeFileSync, mkdirSync, appendFileSync } = require('fs');
6
- const { resolve } = require('path');
7
- const { spawn, execSync } = require('child_process');
8
- const http = require('http');
9
- const { startServers, stopServers, findAvailablePort } = require('./server-manager');
10
-
11
- const PKG = resolve(__dirname, '..', 'package.json');
12
-
13
- // Loads the package version from package.json.
14
- function getVersion(root) {
15
- try {
16
- return JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8')).version || '1.0.0';
17
- } catch {
18
- return '1.0.0';
19
- }
20
- }
21
-
22
- // Prints the current version.
23
- function showVersion(root) {
24
- console.log(`Exeggutor v${getVersion(root)}`);
25
- }
26
-
27
- // Prints the help text with all available commands.
28
- function showHelp() {
29
- console.log(`
30
- Exeggutor - Terminal Multiplexer & Git Worktree Manager
31
-
32
- Usage:
33
- exeggutor [command]
34
-
35
- Commands:
36
- (no args) Start all servers in background
37
- --start Start all servers in background
38
- --stop, --kill Stop all running servers
39
- --restart Restart all servers
40
- --status, -s Show status of servers and workspaces
41
- --open Open dashboard in default browser
42
- --log Show server logs (tail)
43
- --version, -v Show version
44
- --help, -h Show this help
45
-
46
- Workspace Commands:
47
- --workspaces, -w List all workspaces
48
- --create-workspace <name> <path>
49
- Register a new workspace
50
- --delete-workspace <hash>
51
- Delete a workspace and all its terminals
52
- --terminals <hash> List terminals in a workspace
53
- --add-terminal <hash> [name]
54
- Add a new terminal to a workspace
55
- --rename <ws-hash> <term-hash> <new-name>
56
- Rename a terminal
57
- --close <ws-hash> <term-name-or-hash>
58
- Close a terminal
59
-
60
- Remote Access:
61
- --tailscale Enable remote browser access via Tailscale
62
- --show-token Print the auth token for remote login
63
-
64
- Service Management:
65
- --install-service Install auto-start on system boot
66
- --remove-service Remove auto-start service
67
-
68
- Notes:
69
- - Workspace hashes are the short IDs shown in --workspaces
70
- - Terminal identifiers can be name or hash (shown in --terminals)
71
- - Servers must be running for workspace/terminal commands
72
- `);
73
- }
74
-
75
- // Starts all servers in background, auto-resolving ports.
76
- async function handleStartServers(root, configPath, extraArgs) {
77
- const config = loadConfig(configPath); // Loaded configuration settings dictionary.
78
-
79
- // Detect --tailscale flag among extra arguments
80
- const tailscaleMode = Array.isArray(extraArgs) && extraArgs.includes('--tailscale'); // Whether to expose over Tailscale.
81
-
82
- // Generate secure token if not present
83
- if (!config.authToken) {
84
- config.authToken = require('crypto').randomBytes(16).toString('hex'); // Secure random hex token.
85
- }
86
-
87
- // Resolve port
88
- const backendPort = config.backendPort || await findAvailablePort(17492); // Resolved server API port.
89
-
90
- // Save port to config for consistency
91
- config.backendPort = backendPort;
92
- config.root = root;
93
- saveConfig(configPath, config);
94
-
95
- console.log(`Starting Exeggutor...`);
96
- console.log(` Port: ${backendPort}`);
97
- if (tailscaleMode) {
98
- console.log(` Mode: Tailscale (remote access enabled)`);
99
- }
100
-
101
- // Set up log directory
102
- const logDir = resolve(require('os').homedir(), '.exeggutor-logs');
103
- if (!existsSync(logDir)) {
104
- mkdirSync(logDir, { recursive: true });
105
- }
106
-
107
- const result = startServers(root, config, logDir, tailscaleMode);
108
-
109
- if (!result) {
110
- console.error('Failed to start servers.');
111
- process.exit(1);
112
- }
113
-
114
- console.log(`PID: ${result.backendPid}`);
115
- if (tailscaleMode) {
116
- console.log(`Remote: http://<tailscale-ip>:${backendPort}`);
117
- }
118
- console.log(`Dashboard: http://localhost:${backendPort}`);
119
- console.log('Logs: ~/.exeggutor-logs/');
120
- console.log('Use "exeggutor --stop" to stop all servers.');
121
- console.log('Use "exeggutor --open" to open in browser.');
122
- }
123
-
124
- // Stops all running servers.
125
- function stopServersCmd(configPath) {
126
- const config = loadConfig(configPath);
127
- stopServers(config);
128
- config.backendPid = undefined;
129
- saveConfig(configPath, config);
130
- console.log('All servers stopped.');
131
- }
132
-
133
- // Restarts all servers.
134
- async function restartServers(root, configPath) {
135
- stopServersCmd(configPath);
136
- // Wait a moment for ports to be released
137
- await new Promise(r => setTimeout(r, 1500));
138
- await handleStartServers(root, configPath, []);
139
- }
140
-
141
- // Shows status of servers and optionally workspaces.
142
- async function showStatus(configPath) {
143
- const config = loadConfig(configPath);
144
- const backendPort = config.backendPort || 17492;
145
-
146
- console.log('Exeggutor Status');
147
- console.log('================\n');
148
-
149
- // Check backend
150
- try {
151
- const alive = await pingBackend(backendPort);
152
- if (alive) {
153
- console.log(`Exeggutor: RUNNING (port ${backendPort})`);
154
- } else {
155
- console.log(`Exeggutor: STOPPED`);
156
- }
157
- } catch {
158
- console.log('Exeggutor: STOPPED');
159
- }
160
-
161
- // PID
162
- if (config.backendPid) console.log(`PID: ${config.backendPid}`);
163
-
164
- console.log('');
165
-
166
- // Fetch workspaces from backend
167
- try {
168
- const data = await apiGet(backendPort, '/api/workspaces');
169
- const workspaces = JSON.parse(data);
170
- if (!workspaces || workspaces.length === 0) {
171
- console.log('No workspaces registered.');
172
- return;
173
- }
174
- for (const ws of workspaces) {
175
- console.log(`\nWorkspace: ${ws.name} (${ws.id})`);
176
- console.log(` Path: ${ws.path}`);
177
- console.log(` Terminals: ${ws.tabs.length}`);
178
- for (const tab of ws.tabs) {
179
- const branchInfo = tab.branch ? ` [branch: ${tab.branch}]` : '';
180
- console.log(` - ${tab.name} (${tab.id})${branchInfo}`);
181
- }
182
- }
183
- } catch (err) {
184
- console.log('Could not connect to backend to fetch workspace details.');
185
- console.log(` (Is the server running on port ${backendPort}?)`);
186
- }
187
- }
188
-
189
- // Opens the dashboard in the default browser via a one-time session code instead of exposing the token in the URL.
190
- async function openDashboard(configPath) {
191
- const config = loadConfig(configPath); // Loaded configuration settings.
192
- const port = config.backendPort || 17492; // Active backend port.
193
- const token = config.authToken || ''; // Active authorization token.
194
-
195
- let url = `http://localhost:${port}/`; // Formatted dashboard URL without token.
196
- if (token) {
197
- try {
198
- const response = await apiPost(port, '/api/auth/issue-session', {});
199
- const parsed = JSON.parse(response); // Parsed session code response.
200
- url = `http://localhost:${port}/?code=${parsed.code}`; // Dashboard URL with one-time session code.
201
- } catch (err) {
202
- // Fallback to opening without code — frontend will show the unauthorized screen.
203
- }
204
- }
205
-
206
- const platform = process.platform;
207
- try {
208
- if (platform === 'win32') {
209
- execSync(`start "" "${url}"`, { shell: true });
210
- } else if (platform === 'darwin') {
211
- execSync(`open "${url}"`);
212
- } else {
213
- execSync(`xdg-open "${url}"`);
214
- }
215
- console.log(`Opened dashboard: ${url}`);
216
- } catch (err) {
217
- console.error(`Could not open browser. Visit: ${url}`);
218
- }
219
- }
220
-
221
- // Shows logs (last N lines from log files).
222
- function showLogs(configPath) {
223
- const logDir = resolve(require('os').homedir(), '.exeggutor-logs');
224
- const backendLog = resolve(logDir, 'backend.log');
225
- const frontendLog = resolve(logDir, 'frontend.log');
226
-
227
- console.log('Server Logs (last 20 lines each):\n');
228
-
229
- if (existsSync(backendLog)) {
230
- console.log('--- Backend ---');
231
- const lines = readFileSync(backendLog, 'utf8').split('\n').filter(Boolean);
232
- const tail = lines.slice(-20);
233
- tail.forEach(l => console.log(l));
234
- } else {
235
- console.log('(backend log not found)');
236
- }
237
-
238
- console.log('');
239
-
240
- if (existsSync(frontendLog)) {
241
- console.log('--- Frontend ---');
242
- const lines = readFileSync(frontendLog, 'utf8').split('\n').filter(Boolean);
243
- const tail = lines.slice(-20);
244
- tail.forEach(l => console.log(l));
245
- } else {
246
- console.log('(frontend log not found)');
247
- }
248
- }
249
-
250
- // Lists all workspaces.
251
- async function listWorkspaces(configPath) {
252
- const config = loadConfig(configPath);
253
- const backendPort = config.backendPort || 17492;
254
-
255
- try {
256
- const data = await apiGet(backendPort, '/api/workspaces');
257
- const workspaces = JSON.parse(data);
258
- if (!workspaces || workspaces.length === 0) {
259
- console.log('No workspaces registered.');
260
- return;
261
- }
262
- console.log('Workspaces:\n');
263
- for (const ws of workspaces) {
264
- console.log(` ${ws.id.padEnd(12)} ${ws.name.padEnd(20)} ${ws.path}`);
265
- }
266
- } catch (err) {
267
- console.error('Could not connect to backend. Is it running?');
268
- process.exit(1);
269
- }
270
- }
271
-
272
- // Lists terminals in a workspace.
273
- async function listTerminals(configPath, wsHash) {
274
- const config = loadConfig(configPath);
275
- const backendPort = config.backendPort || 17492;
276
-
277
- try {
278
- const data = await apiGet(backendPort, '/api/workspaces');
279
- const workspaces = JSON.parse(data);
280
- const ws = workspaces.find(w => w.id === wsHash || w.name === wsHash);
281
- if (!ws) {
282
- console.error(`Workspace not found: ${wsHash}`);
283
- process.exit(1);
284
- }
285
- console.log(`Terminals for workspace "${ws.name}" (${ws.id}):\n`);
286
- if (ws.tabs.length === 0) {
287
- console.log(' No terminals.');
288
- return;
289
- }
290
- for (const tab of ws.tabs) {
291
- const branchInfo = tab.branch ? ` [branch: ${tab.branch}]` : '';
292
- console.log(` ${tab.id.padEnd(12)} ${tab.name}${branchInfo}`);
293
- }
294
- } catch (err) {
295
- console.error('Could not connect to backend. Is it running?');
296
- process.exit(1);
297
- }
298
- }
299
-
300
- // Creates a new workspace via the API.
301
- async function createWorkspace(configPath, name, wsPath) {
302
- const config = loadConfig(configPath);
303
- const backendPort = config.backendPort || 17492;
304
-
305
- try {
306
- const result = await apiPost(backendPort, '/api/workspaces', { name, path: wsPath });
307
- const ws = JSON.parse(result);
308
- console.log(`Workspace created:`);
309
- console.log(` ID: ${ws.id}`);
310
- console.log(` Name: ${ws.name}`);
311
- console.log(` Path: ${ws.path}`);
312
- } catch (err) {
313
- console.error('Failed to create workspace:', err.message);
314
- process.exit(1);
315
- }
316
- }
317
-
318
- // Adds a new terminal to a workspace via the API.
319
- async function addTerminal(configPath, wsHash, termName) {
320
- const config = loadConfig(configPath);
321
- const backendPort = config.backendPort || 17492;
322
-
323
- try {
324
- const data = await apiGet(backendPort, '/api/workspaces');
325
- const workspaces = JSON.parse(data);
326
- const ws = workspaces.find(w => w.id === wsHash || w.name === wsHash);
327
- if (!ws) {
328
- console.error(`Workspace not found: ${wsHash}`);
329
- process.exit(1);
330
- }
331
- const result = await apiPost(backendPort, `/api/workspaces/${ws.id}/tabs`, { name: termName });
332
- const tab = JSON.parse(result);
333
- console.log(`Terminal added:`);
334
- console.log(` ID: ${tab.id}`);
335
- console.log(` Name: ${tab.name}`);
336
- console.log(` Workspace: ${ws.name}`);
337
- } catch (err) {
338
- console.error('Failed to add terminal:', err.message);
339
- process.exit(1);
340
- }
341
- }
342
-
343
- // Renames a terminal via the API.
344
- async function renameTerminal(configPath, wsHash, termHash, newName) {
345
- const config = loadConfig(configPath);
346
- const backendPort = config.backendPort || 17492;
347
-
348
- try {
349
- const data = await apiGet(backendPort, '/api/workspaces');
350
- const workspaces = JSON.parse(data);
351
- const ws = workspaces.find(w => w.id === wsHash || w.name === wsHash);
352
- if (!ws) {
353
- console.error(`Workspace not found: ${wsHash}`);
354
- process.exit(1);
355
- }
356
- const tab = ws.tabs.find(t => t.id === termHash || t.name === termHash);
357
- if (!tab) {
358
- console.error(`Terminal not found: ${termHash}`);
359
- process.exit(1);
360
- }
361
- await apiPut(backendPort, `/api/workspaces/${ws.id}/tabs/${tab.id}`, { name: newName });
362
- console.log(`Terminal renamed: ${tab.name} -> ${newName}`);
363
- } catch (err) {
364
- console.error('Failed to rename terminal:', err.message);
365
- process.exit(1);
366
- }
367
- }
368
-
369
- // Closes (deletes) a terminal via the API.
370
- async function closeTerminal(configPath, wsHash, termId) {
371
- const config = loadConfig(configPath);
372
- const backendPort = config.backendPort || 17492;
373
-
374
- try {
375
- const data = await apiGet(backendPort, '/api/workspaces');
376
- const workspaces = JSON.parse(data);
377
- const ws = workspaces.find(w => w.id === wsHash || w.name === wsHash);
378
- if (!ws) {
379
- console.error(`Workspace not found: ${wsHash}`);
380
- process.exit(1);
381
- }
382
- const tab = ws.tabs.find(t => t.id === termId || t.name === termId);
383
- if (!tab) {
384
- console.error(`Terminal not found: ${termId}`);
385
- process.exit(1);
386
- }
387
- await apiDelete(backendPort, `/api/workspaces/${ws.id}/tabs/${tab.id}`);
388
- console.log(`Terminal closed: ${tab.name} (${tab.id})`);
389
- } catch (err) {
390
- console.error('Failed to close terminal:', err.message);
391
- process.exit(1);
392
- }
393
- }
394
-
395
- // Deletes a workspace via the API.
396
- async function deleteWorkspace(configPath, wsHash) {
397
- const config = loadConfig(configPath);
398
- const backendPort = config.backendPort || 17492;
399
-
400
- try {
401
- const data = await apiGet(backendPort, '/api/workspaces');
402
- const workspaces = JSON.parse(data);
403
- const ws = workspaces.find(w => w.id === wsHash || w.name === wsHash);
404
- if (!ws) {
405
- console.error(`Workspace not found: ${wsHash}`);
406
- process.exit(1);
407
- }
408
- await apiDelete(backendPort, `/api/workspaces/${ws.id}`);
409
- console.log(`Workspace deleted: ${ws.name} (${ws.id})`);
410
- } catch (err) {
411
- console.error('Failed to delete workspace:', err.message);
412
- process.exit(1);
413
- }
414
- }
415
-
416
- // Installs auto-start service.
417
- async function installAutostart(root) {
418
- const autostartModule = require('./autostart');
419
- await autostartModule.install(root);
420
- console.log('Auto-start service installed. Exeggutor will start on system boot.');
421
- }
422
-
423
- // Removes auto-start service.
424
- async function removeAutostart() {
425
- const autostartModule = require('./autostart');
426
- await autostartModule.remove();
427
- console.log('Auto-start service removed.');
428
- }
429
-
430
- // ---------------------------------------------------------------------------
431
- // Utility functions
432
-
433
- // Loads the runtime config from ~/.exeggutor.json.
434
- function loadConfig(configPath) {
435
- try {
436
- if (existsSync(configPath)) {
437
- return JSON.parse(readFileSync(configPath, 'utf8'));
438
- }
439
- } catch { /* ignore corrupted config */ }
440
- return {};
441
- }
442
-
443
- // Saves the runtime config to ~/.exeggutor.json.
444
- function saveConfig(configPath, config) {
445
- try {
446
- writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf8');
447
- } catch (err) {
448
- console.error('Warning: could not save config:', err.message);
449
- }
450
- }
451
-
452
- // Helper to resolve the active authentication token.
453
- function getAuthToken() {
454
- try {
455
- const configPath = resolve(require('os').homedir(), '.exeggutor.json'); // Path to CLI config file.
456
- if (existsSync(configPath)) {
457
- const cfg = JSON.parse(readFileSync(configPath, 'utf8')); // Loaded configuration settings.
458
- return cfg.authToken || '';
459
- }
460
- } catch (_) {}
461
- return '';
462
- }
463
-
464
- // Pings the backend to check if it is alive.
465
- function pingBackend(port) {
466
- return new Promise((resolve) => {
467
- const token = getAuthToken(); // Active authentication token.
468
- const options = {
469
- headers: token ? { 'Authorization': `Bearer ${token}` } : {}
470
- }; // Request options with auth header.
471
- const req = http.get(`http://localhost:${port}/api/workspaces`, options, (res) => {
472
- resolve(res.statusCode < 500);
473
- });
474
- req.on('error', () => resolve(false));
475
- req.setTimeout(3000, () => { req.destroy(); resolve(false); });
476
- });
477
- }
478
-
479
- // HTTP GET helper.
480
- function apiGet(port, path) {
481
- return new Promise((resolve, reject) => {
482
- const token = getAuthToken(); // Active authentication token.
483
- const options = {
484
- headers: { 'Authorization': `Bearer ${token}` }
485
- }; // Request options dictionary.
486
- http.get(`http://localhost:${port}${path}`, options, (res) => {
487
- let data = ''; // Accumulated response text.
488
- res.on('data', c => data += c);
489
- res.on('end', () => {
490
- if (res.statusCode >= 200 && res.statusCode < 300) {
491
- resolve(data);
492
- } else {
493
- reject(new Error(`HTTP ${res.statusCode}: ${data}`));
494
- }
495
- });
496
- }).on('error', reject);
497
- });
498
- }
499
-
500
- // HTTP POST helper.
501
- function apiPost(port, path, body) {
502
- return new Promise((resolve, reject) => {
503
- const token = getAuthToken(); // Active authentication token.
504
- const json = JSON.stringify(body); // Serialized payload body.
505
- const req = http.request(`http://localhost:${port}${path}`, {
506
- method: 'POST',
507
- headers: {
508
- 'Content-Type': 'application/json',
509
- 'Content-Length': Buffer.byteLength(json),
510
- 'Authorization': `Bearer ${token}`
511
- },
512
- }, (res) => {
513
- let data = ''; // Accumulated response text.
514
- res.on('data', c => data += c);
515
- res.on('end', () => {
516
- if (res.statusCode >= 200 && res.statusCode < 300) {
517
- resolve(data);
518
- } else {
519
- reject(new Error(`HTTP ${res.statusCode}: ${data}`));
520
- }
521
- });
522
- });
523
- req.on('error', reject);
524
- req.write(json);
525
- req.end();
526
- });
527
- }
528
-
529
- // HTTP PUT helper.
530
- function apiPut(port, path, body) {
531
- return new Promise((resolve, reject) => {
532
- const token = getAuthToken(); // Active authentication token.
533
- const json = JSON.stringify(body); // Serialized payload body.
534
- const req = http.request(`http://localhost:${port}${path}`, {
535
- method: 'PUT',
536
- headers: {
537
- 'Content-Type': 'application/json',
538
- 'Content-Length': Buffer.byteLength(json),
539
- 'Authorization': `Bearer ${token}`
540
- },
541
- }, (res) => {
542
- let data = ''; // Accumulated response text.
543
- res.on('data', c => data += c);
544
- res.on('end', () => {
545
- if (res.statusCode >= 200 && res.statusCode < 300) {
546
- resolve(data);
547
- } else {
548
- reject(new Error(`HTTP ${res.statusCode}: ${data}`));
549
- }
550
- });
551
- });
552
- req.on('error', reject);
553
- req.write(json);
554
- req.end();
555
- });
556
- }
557
-
558
- // HTTP DELETE helper.
559
- function apiDelete(port, path) {
560
- return new Promise((resolve, reject) => {
561
- const token = getAuthToken(); // Active authentication token.
562
- const req = http.request(`http://localhost:${port}${path}`, {
563
- method: 'DELETE',
564
- headers: {
565
- 'Authorization': `Bearer ${token}`
566
- },
567
- }, (res) => {
568
- let data = ''; // Accumulated response text.
569
- res.on('data', c => data += c);
570
- res.on('end', () => {
571
- if (res.statusCode >= 200 && res.statusCode < 300) {
572
- resolve(data);
573
- } else {
574
- reject(new Error(`HTTP ${res.statusCode}: ${data}`));
575
- }
576
- });
577
- });
578
- req.on('error', reject);
579
- req.end();
580
- });
581
- }
582
-
583
- // Prints the auth token from the config for remote login.
584
- function showToken(configPath) {
585
- const config = loadConfig(configPath);
586
- if (config.authToken) {
587
- console.log(config.authToken);
588
- } else {
589
- console.error('No auth token found in config. Start exeggutor first to generate one.');
590
- process.exit(1);
591
- }
592
- }
593
-
594
- module.exports = {
595
- showVersion,
596
- showHelp,
597
- startServers: handleStartServers,
598
- stopServers: stopServersCmd,
599
- restartServers,
600
- showStatus,
601
- openDashboard,
602
- showLogs,
603
- listWorkspaces,
604
- listTerminals,
605
- createWorkspace,
606
- addTerminal,
607
- renameTerminal,
608
- closeTerminal,
609
- deleteWorkspace,
610
- installAutostart,
611
- removeAutostart,
612
- showToken,
613
- };
1
+ // Exeggutor CLI - main command implementations.
2
+ // Each exported function implements a specific CLI command, communicating
3
+ // with the backend via HTTP API or managing server processes directly.
4
+
5
+ const { existsSync, readFileSync, writeFileSync, mkdirSync, appendFileSync } = require('fs');
6
+ const { resolve } = require('path');
7
+ const { spawn, execSync } = require('child_process');
8
+ const http = require('http');
9
+ const { startServers, stopServers, findAvailablePort } = require('./server-manager');
10
+
11
+ const PKG = resolve(__dirname, '..', 'package.json');
12
+
13
+ // Loads the package version from package.json.
14
+ function getVersion(root) {
15
+ try {
16
+ return JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8')).version || '1.0.0';
17
+ } catch {
18
+ return '1.0.0';
19
+ }
20
+ }
21
+
22
+ // Prints the current version.
23
+ function showVersion(root) {
24
+ console.log(`Exeggutor v${getVersion(root)}`);
25
+ }
26
+
27
+ // Prints the help text with all available commands.
28
+ function showHelp() {
29
+ console.log(`
30
+ Exeggutor - Terminal Multiplexer & Git Worktree Manager
31
+
32
+ Usage:
33
+ exeggutor [command]
34
+
35
+ Commands:
36
+ (no args) Start all servers in background
37
+ --start Start all servers in background
38
+ --stop, --kill Stop all running servers
39
+ --restart Restart all servers
40
+ --status, -s Show status of servers and workspaces
41
+ --open Open dashboard in default browser
42
+ --log Show server logs (tail)
43
+ --version, -v Show version
44
+ --help, -h Show this help
45
+
46
+ Workspace Commands:
47
+ --workspaces, -w List all workspaces
48
+ --create-workspace <name> <path>
49
+ Register a new workspace
50
+ --delete-workspace <hash>
51
+ Delete a workspace and all its terminals
52
+ --terminals <hash> List terminals in a workspace
53
+ --add-terminal <hash> [name]
54
+ Add a new terminal to a workspace
55
+ --rename <ws-hash> <term-hash> <new-name>
56
+ Rename a terminal
57
+ --close <ws-hash> <term-name-or-hash>
58
+ Close a terminal
59
+
60
+ Remote Access:
61
+ --tailscale Enable remote browser access via Tailscale
62
+ --show-token Print the auth token for remote login
63
+
64
+ Service Management:
65
+ --install-service Install auto-start on system boot
66
+ --remove-service Remove auto-start service
67
+
68
+ Notes:
69
+ - Workspace hashes are the short IDs shown in --workspaces
70
+ - Terminal identifiers can be name or hash (shown in --terminals)
71
+ - Servers must be running for workspace/terminal commands
72
+ `);
73
+ }
74
+
75
+ // Starts all servers in background, auto-resolving ports.
76
+ async function handleStartServers(root, configPath, extraArgs) {
77
+ const config = loadConfig(configPath); // Loaded configuration settings dictionary.
78
+
79
+ // Detect --tailscale flag among extra arguments
80
+ const tailscaleMode = Array.isArray(extraArgs) && extraArgs.includes('--tailscale'); // Whether to expose over Tailscale.
81
+
82
+ // Generate secure token if not present
83
+ if (!config.authToken) {
84
+ config.authToken = require('crypto').randomBytes(16).toString('hex'); // Secure random hex token.
85
+ }
86
+
87
+ // Resolve port
88
+ const backendPort = config.backendPort || await findAvailablePort(17492); // Resolved server API port.
89
+
90
+ // Save port to config for consistency
91
+ config.backendPort = backendPort;
92
+ config.root = root;
93
+ saveConfig(configPath, config);
94
+
95
+ console.log(`Starting Exeggutor...`);
96
+ console.log(` Port: ${backendPort}`);
97
+ if (tailscaleMode) {
98
+ console.log(` Mode: Tailscale (remote access enabled)`);
99
+ }
100
+
101
+ // Set up log directory
102
+ const logDir = resolve(require('os').homedir(), '.exeggutor-logs');
103
+ if (!existsSync(logDir)) {
104
+ mkdirSync(logDir, { recursive: true });
105
+ }
106
+
107
+ const result = startServers(root, config, logDir, tailscaleMode);
108
+
109
+ if (!result) {
110
+ console.error('Failed to start servers.');
111
+ process.exit(1);
112
+ }
113
+
114
+ console.log(`PID: ${result.backendPid}`);
115
+ if (tailscaleMode) {
116
+ console.log(`Remote: http://<tailscale-ip>:${backendPort}`);
117
+ }
118
+ console.log(`Dashboard: http://localhost:${backendPort}`);
119
+ console.log('Logs: ~/.exeggutor-logs/');
120
+ console.log('Use "exeggutor --stop" to stop all servers.');
121
+ console.log('Use "exeggutor --open" to open in browser.');
122
+ }
123
+
124
+ // Stops all running servers.
125
+ function stopServersCmd(configPath) {
126
+ const config = loadConfig(configPath);
127
+ stopServers(config);
128
+ config.backendPid = undefined;
129
+ saveConfig(configPath, config);
130
+ console.log('All servers stopped.');
131
+ }
132
+
133
+ // Restarts all servers.
134
+ async function restartServers(root, configPath) {
135
+ stopServersCmd(configPath);
136
+ // Wait a moment for ports to be released
137
+ await new Promise(r => setTimeout(r, 1500));
138
+ await handleStartServers(root, configPath, []);
139
+ }
140
+
141
+ // Shows status of servers and optionally workspaces.
142
+ async function showStatus(configPath) {
143
+ const config = loadConfig(configPath);
144
+ const backendPort = config.backendPort || 17492;
145
+
146
+ console.log('Exeggutor Status');
147
+ console.log('================\n');
148
+
149
+ // Check backend
150
+ try {
151
+ const alive = await pingBackend(backendPort);
152
+ if (alive) {
153
+ console.log(`Exeggutor: RUNNING (port ${backendPort})`);
154
+ } else {
155
+ console.log(`Exeggutor: STOPPED`);
156
+ }
157
+ } catch {
158
+ console.log('Exeggutor: STOPPED');
159
+ }
160
+
161
+ // PID
162
+ if (config.backendPid) console.log(`PID: ${config.backendPid}`);
163
+
164
+ console.log('');
165
+
166
+ // Fetch workspaces from backend
167
+ try {
168
+ const data = await apiGet(backendPort, '/api/workspaces');
169
+ const workspaces = JSON.parse(data);
170
+ if (!workspaces || workspaces.length === 0) {
171
+ console.log('No workspaces registered.');
172
+ return;
173
+ }
174
+ for (const ws of workspaces) {
175
+ console.log(`\nWorkspace: ${ws.name} (${ws.id})`);
176
+ console.log(` Path: ${ws.path}`);
177
+ console.log(` Terminals: ${ws.tabs.length}`);
178
+ for (const tab of ws.tabs) {
179
+ const branchInfo = tab.branch ? ` [branch: ${tab.branch}]` : '';
180
+ console.log(` - ${tab.name} (${tab.id})${branchInfo}`);
181
+ }
182
+ }
183
+ } catch (err) {
184
+ console.log('Could not connect to backend to fetch workspace details.');
185
+ console.log(` (Is the server running on port ${backendPort}?)`);
186
+ }
187
+ }
188
+
189
+ // Opens the dashboard in the default browser via a one-time session code instead of exposing the token in the URL.
190
+ async function openDashboard(configPath) {
191
+ const config = loadConfig(configPath); // Loaded configuration settings.
192
+ const port = config.backendPort || 17492; // Active backend port.
193
+ const token = config.authToken || ''; // Active authorization token.
194
+
195
+ let url = `http://localhost:${port}/`; // Formatted dashboard URL without token.
196
+ if (token) {
197
+ try {
198
+ const response = await apiPost(port, '/api/auth/issue-session', {});
199
+ const parsed = JSON.parse(response); // Parsed session code response.
200
+ url = `http://localhost:${port}/?code=${parsed.code}`; // Dashboard URL with one-time session code.
201
+ } catch (err) {
202
+ // Fallback to opening without code — frontend will show the unauthorized screen.
203
+ }
204
+ }
205
+
206
+ const platform = process.platform;
207
+ try {
208
+ if (platform === 'win32') {
209
+ execSync(`start "" "${url}"`, { shell: true });
210
+ } else if (platform === 'darwin') {
211
+ execSync(`open "${url}"`);
212
+ } else {
213
+ execSync(`xdg-open "${url}"`);
214
+ }
215
+ console.log(`Opened dashboard: ${url}`);
216
+ } catch (err) {
217
+ console.error(`Could not open browser. Visit: ${url}`);
218
+ }
219
+ }
220
+
221
+ // Shows logs (last N lines from log files).
222
+ function showLogs(configPath) {
223
+ const logDir = resolve(require('os').homedir(), '.exeggutor-logs');
224
+ const backendLog = resolve(logDir, 'backend.log');
225
+ const frontendLog = resolve(logDir, 'frontend.log');
226
+
227
+ console.log('Server Logs (last 20 lines each):\n');
228
+
229
+ if (existsSync(backendLog)) {
230
+ console.log('--- Backend ---');
231
+ const lines = readFileSync(backendLog, 'utf8').split('\n').filter(Boolean);
232
+ const tail = lines.slice(-20);
233
+ tail.forEach(l => console.log(l));
234
+ } else {
235
+ console.log('(backend log not found)');
236
+ }
237
+
238
+ console.log('');
239
+
240
+ if (existsSync(frontendLog)) {
241
+ console.log('--- Frontend ---');
242
+ const lines = readFileSync(frontendLog, 'utf8').split('\n').filter(Boolean);
243
+ const tail = lines.slice(-20);
244
+ tail.forEach(l => console.log(l));
245
+ } else {
246
+ console.log('(frontend log not found)');
247
+ }
248
+ }
249
+
250
+ // Lists all workspaces.
251
+ async function listWorkspaces(configPath) {
252
+ const config = loadConfig(configPath);
253
+ const backendPort = config.backendPort || 17492;
254
+
255
+ try {
256
+ const data = await apiGet(backendPort, '/api/workspaces');
257
+ const workspaces = JSON.parse(data);
258
+ if (!workspaces || workspaces.length === 0) {
259
+ console.log('No workspaces registered.');
260
+ return;
261
+ }
262
+ console.log('Workspaces:\n');
263
+ for (const ws of workspaces) {
264
+ console.log(` ${ws.id.padEnd(12)} ${ws.name.padEnd(20)} ${ws.path}`);
265
+ }
266
+ } catch (err) {
267
+ console.error('Could not connect to backend. Is it running?');
268
+ process.exit(1);
269
+ }
270
+ }
271
+
272
+ // Lists terminals in a workspace.
273
+ async function listTerminals(configPath, wsHash) {
274
+ const config = loadConfig(configPath);
275
+ const backendPort = config.backendPort || 17492;
276
+
277
+ try {
278
+ const data = await apiGet(backendPort, '/api/workspaces');
279
+ const workspaces = JSON.parse(data);
280
+ const ws = workspaces.find(w => w.id === wsHash || w.name === wsHash);
281
+ if (!ws) {
282
+ console.error(`Workspace not found: ${wsHash}`);
283
+ process.exit(1);
284
+ }
285
+ console.log(`Terminals for workspace "${ws.name}" (${ws.id}):\n`);
286
+ if (ws.tabs.length === 0) {
287
+ console.log(' No terminals.');
288
+ return;
289
+ }
290
+ for (const tab of ws.tabs) {
291
+ const branchInfo = tab.branch ? ` [branch: ${tab.branch}]` : '';
292
+ console.log(` ${tab.id.padEnd(12)} ${tab.name}${branchInfo}`);
293
+ }
294
+ } catch (err) {
295
+ console.error('Could not connect to backend. Is it running?');
296
+ process.exit(1);
297
+ }
298
+ }
299
+
300
+ // Creates a new workspace via the API.
301
+ async function createWorkspace(configPath, name, wsPath) {
302
+ const config = loadConfig(configPath);
303
+ const backendPort = config.backendPort || 17492;
304
+
305
+ try {
306
+ const result = await apiPost(backendPort, '/api/workspaces', { name, path: wsPath });
307
+ const ws = JSON.parse(result);
308
+ console.log(`Workspace created:`);
309
+ console.log(` ID: ${ws.id}`);
310
+ console.log(` Name: ${ws.name}`);
311
+ console.log(` Path: ${ws.path}`);
312
+ } catch (err) {
313
+ console.error('Failed to create workspace:', err.message);
314
+ process.exit(1);
315
+ }
316
+ }
317
+
318
+ // Adds a new terminal to a workspace via the API.
319
+ async function addTerminal(configPath, wsHash, termName) {
320
+ const config = loadConfig(configPath);
321
+ const backendPort = config.backendPort || 17492;
322
+
323
+ try {
324
+ const data = await apiGet(backendPort, '/api/workspaces');
325
+ const workspaces = JSON.parse(data);
326
+ const ws = workspaces.find(w => w.id === wsHash || w.name === wsHash);
327
+ if (!ws) {
328
+ console.error(`Workspace not found: ${wsHash}`);
329
+ process.exit(1);
330
+ }
331
+ const result = await apiPost(backendPort, `/api/workspaces/${ws.id}/tabs`, { name: termName });
332
+ const tab = JSON.parse(result);
333
+ console.log(`Terminal added:`);
334
+ console.log(` ID: ${tab.id}`);
335
+ console.log(` Name: ${tab.name}`);
336
+ console.log(` Workspace: ${ws.name}`);
337
+ } catch (err) {
338
+ console.error('Failed to add terminal:', err.message);
339
+ process.exit(1);
340
+ }
341
+ }
342
+
343
+ // Renames a terminal via the API.
344
+ async function renameTerminal(configPath, wsHash, termHash, newName) {
345
+ const config = loadConfig(configPath);
346
+ const backendPort = config.backendPort || 17492;
347
+
348
+ try {
349
+ const data = await apiGet(backendPort, '/api/workspaces');
350
+ const workspaces = JSON.parse(data);
351
+ const ws = workspaces.find(w => w.id === wsHash || w.name === wsHash);
352
+ if (!ws) {
353
+ console.error(`Workspace not found: ${wsHash}`);
354
+ process.exit(1);
355
+ }
356
+ const tab = ws.tabs.find(t => t.id === termHash || t.name === termHash);
357
+ if (!tab) {
358
+ console.error(`Terminal not found: ${termHash}`);
359
+ process.exit(1);
360
+ }
361
+ await apiPut(backendPort, `/api/workspaces/${ws.id}/tabs/${tab.id}`, { name: newName });
362
+ console.log(`Terminal renamed: ${tab.name} -> ${newName}`);
363
+ } catch (err) {
364
+ console.error('Failed to rename terminal:', err.message);
365
+ process.exit(1);
366
+ }
367
+ }
368
+
369
+ // Closes (deletes) a terminal via the API.
370
+ async function closeTerminal(configPath, wsHash, termId) {
371
+ const config = loadConfig(configPath);
372
+ const backendPort = config.backendPort || 17492;
373
+
374
+ try {
375
+ const data = await apiGet(backendPort, '/api/workspaces');
376
+ const workspaces = JSON.parse(data);
377
+ const ws = workspaces.find(w => w.id === wsHash || w.name === wsHash);
378
+ if (!ws) {
379
+ console.error(`Workspace not found: ${wsHash}`);
380
+ process.exit(1);
381
+ }
382
+ const tab = ws.tabs.find(t => t.id === termId || t.name === termId);
383
+ if (!tab) {
384
+ console.error(`Terminal not found: ${termId}`);
385
+ process.exit(1);
386
+ }
387
+ await apiDelete(backendPort, `/api/workspaces/${ws.id}/tabs/${tab.id}`);
388
+ console.log(`Terminal closed: ${tab.name} (${tab.id})`);
389
+ } catch (err) {
390
+ console.error('Failed to close terminal:', err.message);
391
+ process.exit(1);
392
+ }
393
+ }
394
+
395
+ // Deletes a workspace via the API.
396
+ async function deleteWorkspace(configPath, wsHash) {
397
+ const config = loadConfig(configPath);
398
+ const backendPort = config.backendPort || 17492;
399
+
400
+ try {
401
+ const data = await apiGet(backendPort, '/api/workspaces');
402
+ const workspaces = JSON.parse(data);
403
+ const ws = workspaces.find(w => w.id === wsHash || w.name === wsHash);
404
+ if (!ws) {
405
+ console.error(`Workspace not found: ${wsHash}`);
406
+ process.exit(1);
407
+ }
408
+ await apiDelete(backendPort, `/api/workspaces/${ws.id}`);
409
+ console.log(`Workspace deleted: ${ws.name} (${ws.id})`);
410
+ } catch (err) {
411
+ console.error('Failed to delete workspace:', err.message);
412
+ process.exit(1);
413
+ }
414
+ }
415
+
416
+ // Installs auto-start service.
417
+ async function installAutostart(root) {
418
+ const autostartModule = require('./autostart');
419
+ await autostartModule.install(root);
420
+ console.log('Auto-start service installed. Exeggutor will start on system boot.');
421
+ }
422
+
423
+ // Removes auto-start service.
424
+ async function removeAutostart() {
425
+ const autostartModule = require('./autostart');
426
+ await autostartModule.remove();
427
+ console.log('Auto-start service removed.');
428
+ }
429
+
430
+ // ---------------------------------------------------------------------------
431
+ // Utility functions
432
+
433
+ // Loads the runtime config from ~/.exeggutor.json.
434
+ function loadConfig(configPath) {
435
+ try {
436
+ if (existsSync(configPath)) {
437
+ return JSON.parse(readFileSync(configPath, 'utf8'));
438
+ }
439
+ } catch { /* ignore corrupted config */ }
440
+ return {};
441
+ }
442
+
443
+ // Saves the runtime config to ~/.exeggutor.json.
444
+ function saveConfig(configPath, config) {
445
+ try {
446
+ writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf8');
447
+ } catch (err) {
448
+ console.error('Warning: could not save config:', err.message);
449
+ }
450
+ }
451
+
452
+ // Helper to resolve the active authentication token.
453
+ function getAuthToken() {
454
+ try {
455
+ const configPath = resolve(require('os').homedir(), '.exeggutor.json'); // Path to CLI config file.
456
+ if (existsSync(configPath)) {
457
+ const cfg = JSON.parse(readFileSync(configPath, 'utf8')); // Loaded configuration settings.
458
+ return cfg.authToken || '';
459
+ }
460
+ } catch (_) {}
461
+ return '';
462
+ }
463
+
464
+ // Pings the backend to check if it is alive.
465
+ function pingBackend(port) {
466
+ return new Promise((resolve) => {
467
+ const token = getAuthToken(); // Active authentication token.
468
+ const options = {
469
+ headers: token ? { 'Authorization': `Bearer ${token}` } : {}
470
+ }; // Request options with auth header.
471
+ const req = http.get(`http://localhost:${port}/api/workspaces`, options, (res) => {
472
+ resolve(res.statusCode < 500);
473
+ });
474
+ req.on('error', () => resolve(false));
475
+ req.setTimeout(3000, () => { req.destroy(); resolve(false); });
476
+ });
477
+ }
478
+
479
+ // HTTP GET helper.
480
+ function apiGet(port, path) {
481
+ return new Promise((resolve, reject) => {
482
+ const token = getAuthToken(); // Active authentication token.
483
+ const options = {
484
+ headers: { 'Authorization': `Bearer ${token}` }
485
+ }; // Request options dictionary.
486
+ http.get(`http://localhost:${port}${path}`, options, (res) => {
487
+ let data = ''; // Accumulated response text.
488
+ res.on('data', c => data += c);
489
+ res.on('end', () => {
490
+ if (res.statusCode >= 200 && res.statusCode < 300) {
491
+ resolve(data);
492
+ } else {
493
+ reject(new Error(`HTTP ${res.statusCode}: ${data}`));
494
+ }
495
+ });
496
+ }).on('error', reject);
497
+ });
498
+ }
499
+
500
+ // HTTP POST helper.
501
+ function apiPost(port, path, body) {
502
+ return new Promise((resolve, reject) => {
503
+ const token = getAuthToken(); // Active authentication token.
504
+ const json = JSON.stringify(body); // Serialized payload body.
505
+ const req = http.request(`http://localhost:${port}${path}`, {
506
+ method: 'POST',
507
+ headers: {
508
+ 'Content-Type': 'application/json',
509
+ 'Content-Length': Buffer.byteLength(json),
510
+ 'Authorization': `Bearer ${token}`
511
+ },
512
+ }, (res) => {
513
+ let data = ''; // Accumulated response text.
514
+ res.on('data', c => data += c);
515
+ res.on('end', () => {
516
+ if (res.statusCode >= 200 && res.statusCode < 300) {
517
+ resolve(data);
518
+ } else {
519
+ reject(new Error(`HTTP ${res.statusCode}: ${data}`));
520
+ }
521
+ });
522
+ });
523
+ req.on('error', reject);
524
+ req.write(json);
525
+ req.end();
526
+ });
527
+ }
528
+
529
+ // HTTP PUT helper.
530
+ function apiPut(port, path, body) {
531
+ return new Promise((resolve, reject) => {
532
+ const token = getAuthToken(); // Active authentication token.
533
+ const json = JSON.stringify(body); // Serialized payload body.
534
+ const req = http.request(`http://localhost:${port}${path}`, {
535
+ method: 'PUT',
536
+ headers: {
537
+ 'Content-Type': 'application/json',
538
+ 'Content-Length': Buffer.byteLength(json),
539
+ 'Authorization': `Bearer ${token}`
540
+ },
541
+ }, (res) => {
542
+ let data = ''; // Accumulated response text.
543
+ res.on('data', c => data += c);
544
+ res.on('end', () => {
545
+ if (res.statusCode >= 200 && res.statusCode < 300) {
546
+ resolve(data);
547
+ } else {
548
+ reject(new Error(`HTTP ${res.statusCode}: ${data}`));
549
+ }
550
+ });
551
+ });
552
+ req.on('error', reject);
553
+ req.write(json);
554
+ req.end();
555
+ });
556
+ }
557
+
558
+ // HTTP DELETE helper.
559
+ function apiDelete(port, path) {
560
+ return new Promise((resolve, reject) => {
561
+ const token = getAuthToken(); // Active authentication token.
562
+ const req = http.request(`http://localhost:${port}${path}`, {
563
+ method: 'DELETE',
564
+ headers: {
565
+ 'Authorization': `Bearer ${token}`
566
+ },
567
+ }, (res) => {
568
+ let data = ''; // Accumulated response text.
569
+ res.on('data', c => data += c);
570
+ res.on('end', () => {
571
+ if (res.statusCode >= 200 && res.statusCode < 300) {
572
+ resolve(data);
573
+ } else {
574
+ reject(new Error(`HTTP ${res.statusCode}: ${data}`));
575
+ }
576
+ });
577
+ });
578
+ req.on('error', reject);
579
+ req.end();
580
+ });
581
+ }
582
+
583
+ // Prints the auth token from the config for remote login.
584
+ function showToken(configPath) {
585
+ const config = loadConfig(configPath);
586
+ if (config.authToken) {
587
+ console.log(config.authToken);
588
+ } else {
589
+ console.error('No auth token found in config. Start exeggutor first to generate one.');
590
+ process.exit(1);
591
+ }
592
+ }
593
+
594
+ module.exports = {
595
+ showVersion,
596
+ showHelp,
597
+ startServers: handleStartServers,
598
+ stopServers: stopServersCmd,
599
+ restartServers,
600
+ showStatus,
601
+ openDashboard,
602
+ showLogs,
603
+ listWorkspaces,
604
+ listTerminals,
605
+ createWorkspace,
606
+ addTerminal,
607
+ renameTerminal,
608
+ closeTerminal,
609
+ deleteWorkspace,
610
+ installAutostart,
611
+ removeAutostart,
612
+ showToken,
613
+ };