start-command 0.25.1 → 0.25.3

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.
@@ -2,8 +2,12 @@
2
2
 
3
3
  const { execSync, spawnSync } = require('child_process');
4
4
  const fs = require('fs');
5
- const os = require('os');
6
5
  const path = require('path');
6
+ const {
7
+ ensureParentDirectory,
8
+ getTempDir,
9
+ wrapCommandWithLogFooter,
10
+ } = require('./isolation-log-utils');
7
11
 
8
12
  const setTimeout = globalThis.setTimeout;
9
13
 
@@ -131,14 +135,21 @@ function runScreenWithLogCapture(
131
135
  shellInfo,
132
136
  user = null,
133
137
  wrapCommandWithUser,
134
- isInteractiveShellCommand
138
+ isInteractiveShellCommand,
139
+ logPath = null
135
140
  ) {
136
141
  const { shell, shellArg } = shellInfo;
137
- const logFile = path.join(os.tmpdir(), `screen-output-${sessionName}.log`);
142
+ const screenTempDir = getTempDir('isolation', 'screen');
143
+ const logFile =
144
+ logPath || path.join(screenTempDir, `screen-output-${sessionName}.log`);
145
+ const shouldCleanupLogFile = !logPath;
138
146
  const exitCodeFile = path.join(
139
- os.tmpdir(),
147
+ screenTempDir,
140
148
  `screen-exit-${sessionName}.code`
141
149
  );
150
+ ensureParentDirectory(logFile);
151
+ const logStartOffset =
152
+ logPath && fs.existsSync(logFile) ? fs.statSync(logFile).size : 0;
142
153
 
143
154
  return new Promise((resolve) => {
144
155
  try {
@@ -159,7 +170,7 @@ function runScreenWithLogCapture(
159
170
  // - `logfile <path>` sets the output log path (replaces -Logfile CLI option)
160
171
  // - `logfile flush 0` forces immediate buffer flush (prevents output loss)
161
172
  // - `deflog on` enables logging for any subsequently created windows
162
- const screenrcFile = path.join(os.tmpdir(), `screenrc-${sessionName}`);
173
+ const screenrcFile = path.join(screenTempDir, `screenrc-${sessionName}`);
163
174
  const screenrcContent = [
164
175
  `logfile ${logFile}`,
165
176
  'logfile flush 0',
@@ -243,7 +254,7 @@ function runScreenWithLogCapture(
243
254
 
244
255
  let output = '';
245
256
  try {
246
- output = fs.readFileSync(logFile, 'utf8');
257
+ output = fs.readFileSync(logFile, 'utf8').slice(logStartOffset);
247
258
  } catch {
248
259
  // Log file might not exist if command produced no output
249
260
  }
@@ -313,7 +324,10 @@ function runScreenWithLogCapture(
313
324
 
314
325
  // Clean up temp files
315
326
  const cleanupTempFiles = () => {
316
- for (const f of [logFile, screenrcFile, exitCodeFile]) {
327
+ const filesToRemove = shouldCleanupLogFile
328
+ ? [logFile, screenrcFile, exitCodeFile]
329
+ : [screenrcFile, exitCodeFile];
330
+ for (const f of filesToRemove) {
317
331
  try {
318
332
  fs.unlinkSync(f);
319
333
  } catch {
@@ -392,6 +406,105 @@ function runScreenWithLogCapture(
392
406
  });
393
407
  }
394
408
 
409
+ /**
410
+ * Start a detached GNU Screen session with live command output appended to logPath.
411
+ * The command itself prints the final footer through the terminal so screen writes it
412
+ * after the command output in the same log stream.
413
+ */
414
+ function startDetachedScreenWithLogCapture(
415
+ command,
416
+ sessionName,
417
+ shellInfo,
418
+ options = {}
419
+ ) {
420
+ const { shell, shellArg } = shellInfo;
421
+ const {
422
+ user = null,
423
+ keepAlive = false,
424
+ logPath = null,
425
+ wrapCommandWithUser,
426
+ } = options;
427
+
428
+ const screenTempDir = getTempDir('isolation', 'screen');
429
+ const logFile =
430
+ logPath || path.join(screenTempDir, `screen-output-${sessionName}.log`);
431
+ ensureParentDirectory(logFile);
432
+
433
+ const screenrcFile = path.join(screenTempDir, `screenrc-${sessionName}`);
434
+ const screenrcContent = [
435
+ `logfile ${logFile}`,
436
+ 'logfile flush 0',
437
+ 'deflog on',
438
+ '',
439
+ ].join('\n');
440
+
441
+ try {
442
+ fs.writeFileSync(screenrcFile, screenrcContent);
443
+ } catch (err) {
444
+ return {
445
+ success: false,
446
+ sessionName,
447
+ message: `Failed to create screenrc for logging: ${err.message}`,
448
+ };
449
+ }
450
+
451
+ const effectiveCommand = wrapCommandWithLogFooter(
452
+ wrapCommandWithUser ? wrapCommandWithUser(command, user) : command,
453
+ { shell, keepAlive }
454
+ );
455
+ const screenArgs = [
456
+ '-dmS',
457
+ sessionName,
458
+ '-L',
459
+ '-c',
460
+ screenrcFile,
461
+ shell,
462
+ shellArg,
463
+ effectiveCommand,
464
+ ];
465
+
466
+ if (isDebug()) {
467
+ console.error(`[screen-isolation] Running: screen ${screenArgs.join(' ')}`);
468
+ console.error(`[screen-isolation] screenrc: ${screenrcContent.trim()}`);
469
+ console.error(`[screen-isolation] Log file: ${logFile}`);
470
+ }
471
+
472
+ try {
473
+ const result = spawnSync('screen', screenArgs, { stdio: 'inherit' });
474
+ if (result.error) {
475
+ throw result.error;
476
+ }
477
+ if (result.status !== 0) {
478
+ return {
479
+ success: false,
480
+ sessionName,
481
+ message: `Failed to start screen session (exit code ${result.status})`,
482
+ };
483
+ }
484
+ } catch (err) {
485
+ return {
486
+ success: false,
487
+ sessionName,
488
+ message: `Failed to run in screen: ${err.message}`,
489
+ };
490
+ }
491
+
492
+ let message = `Command started in detached screen session: ${sessionName}`;
493
+ if (keepAlive) {
494
+ message += `\nSession will stay alive after command completes.`;
495
+ } else {
496
+ message += `\nSession will exit automatically after command completes.`;
497
+ }
498
+ message += `\nReattach with: screen -r ${sessionName}`;
499
+ message += `\nLive log: ${logFile}`;
500
+
501
+ return {
502
+ success: true,
503
+ sessionName,
504
+ message,
505
+ };
506
+ }
507
+
395
508
  /** Reset screen version cache (useful for testing) */
396
509
  function resetScreenVersionCache() {
397
510
  cachedScreenVersion = null;
@@ -402,5 +515,6 @@ module.exports = {
402
515
  getScreenVersion,
403
516
  supportsLogfileOption,
404
517
  runScreenWithLogCapture,
518
+ startDetachedScreenWithLogCapture,
405
519
  resetScreenVersionCache,
406
520
  };
@@ -11,6 +11,20 @@
11
11
 
12
12
  const { spawn } = require('child_process');
13
13
  const fs = require('fs');
14
+ const path = require('path');
15
+
16
+ function ensureParentDirectory(filePath) {
17
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
18
+ }
19
+
20
+ function writeInitialLog(logFilePath, logContent) {
21
+ ensureParentDirectory(logFilePath);
22
+ fs.writeFileSync(logFilePath, logContent || '', 'utf8');
23
+ }
24
+
25
+ function appendLog(logFilePath, content) {
26
+ fs.appendFileSync(logFilePath, content, 'utf8');
27
+ }
14
28
 
15
29
  /**
16
30
  * Run command using Bun.spawn (for Bun runtime)
@@ -44,6 +58,8 @@ async function runWithBunSpawn(options) {
44
58
  let logContent = options.logContent || '';
45
59
 
46
60
  try {
61
+ writeInitialLog(logFilePath, logContent);
62
+
47
63
  // Spawn the process using Bun's native API
48
64
  const proc = Bun.spawn([shell, ...shellArgs], {
49
65
  stdout: 'pipe',
@@ -87,6 +103,7 @@ async function runWithBunSpawn(options) {
87
103
  }
88
104
  const text = decoder.decode(value);
89
105
  process.stdout.write(text);
106
+ appendLog(logFilePath, text);
90
107
  output += text;
91
108
  }
92
109
  } catch (err) {
@@ -109,6 +126,7 @@ async function runWithBunSpawn(options) {
109
126
  }
110
127
  const text = decoder.decode(value);
111
128
  process.stderr.write(text);
129
+ appendLog(logFilePath, text);
112
130
  output += text;
113
131
  }
114
132
  } catch (err) {
@@ -133,12 +151,11 @@ async function runWithBunSpawn(options) {
133
151
  const durationMs = Date.now() - startTimeMs;
134
152
  const endTime = new Date().toISOString().replace('T', ' ').substring(0, 23);
135
153
 
136
- // Write log file
154
+ // Write log footer
137
155
  try {
138
- logContent += `\n${'='.repeat(50)}\n`;
139
- logContent += `Finished: ${endTime}\n`;
140
- logContent += `Exit Code: ${exitCode}\n`;
141
- fs.writeFileSync(logFilePath, logContent, 'utf8');
156
+ const footer = `\n${'='.repeat(50)}\nFinished: ${endTime}\nExit Code: ${exitCode}\n`;
157
+ logContent += footer;
158
+ appendLog(logFilePath, footer);
142
159
  } catch (err) {
143
160
  console.error(`\nWarning: Could not save log file: ${err.message}`);
144
161
  }
@@ -175,7 +192,7 @@ async function runWithBunSpawn(options) {
175
192
 
176
193
  // Write log file
177
194
  try {
178
- fs.writeFileSync(logFilePath, logContent, 'utf8');
195
+ writeInitialLog(logFilePath, logContent);
179
196
  } catch (writeErr) {
180
197
  console.error(`\nWarning: Could not save log file: ${writeErr.message}`);
181
198
  }
@@ -223,6 +240,7 @@ function runWithNodeSpawn(options) {
223
240
  } = options;
224
241
 
225
242
  let logContent = options.logContent || '';
243
+ writeInitialLog(logFilePath, logContent);
226
244
 
227
245
  // Execute the command with captured output
228
246
  const child = spawn(shell, shellArgs, {
@@ -248,6 +266,7 @@ function runWithNodeSpawn(options) {
248
266
  child.stdout.on('data', (data) => {
249
267
  const text = data.toString();
250
268
  process.stdout.write(text);
269
+ appendLog(logFilePath, text);
251
270
  logContent += text;
252
271
  });
253
272
 
@@ -255,6 +274,7 @@ function runWithNodeSpawn(options) {
255
274
  child.stderr.on('data', (data) => {
256
275
  const text = data.toString();
257
276
  process.stderr.write(text);
277
+ appendLog(logFilePath, text);
258
278
  logContent += text;
259
279
  });
260
280
 
@@ -272,9 +292,12 @@ function runWithNodeSpawn(options) {
272
292
  logContent += `Finished: ${endTime}\n`;
273
293
  logContent += `Exit Code: ${exitCode}\n`;
274
294
 
275
- // Write log file
295
+ // Write log footer
276
296
  try {
277
- fs.writeFileSync(logFilePath, logContent, 'utf8');
297
+ appendLog(
298
+ logFilePath,
299
+ `\n${'='.repeat(50)}\nFinished: ${endTime}\nExit Code: ${exitCode}\n`
300
+ );
278
301
  } catch (err) {
279
302
  console.error(`\nWarning: Could not save log file: ${err.message}`);
280
303
  }
@@ -312,7 +335,7 @@ function runWithNodeSpawn(options) {
312
335
 
313
336
  // Write log file
314
337
  try {
315
- fs.writeFileSync(logFilePath, logContent, 'utf8');
338
+ writeInitialLog(logFilePath, logContent);
316
339
  } catch (writeErr) {
317
340
  console.error(`\nWarning: Could not save log file: ${writeErr.message}`);
318
341
  }
@@ -7,11 +7,125 @@
7
7
  * - Text: Human-readable text format
8
8
  */
9
9
 
10
+ const { execSync } = require('child_process');
11
+ const fs = require('fs');
10
12
  const {
11
13
  escapeForLinksNotation,
12
14
  formatAsNestedLinksNotation,
13
15
  } = require('./output-blocks');
14
16
 
17
+ /**
18
+ * Check if a detached isolation session is still running
19
+ * @param {Object} record - Execution record
20
+ * @returns {boolean|null} true if running, false if not, null if unable to determine
21
+ */
22
+ function isDetachedSessionAlive(record) {
23
+ const opts = record.options || {};
24
+ const sessionName = opts.sessionName;
25
+ const isolationMode = opts.isolationMode;
26
+ const isolated = opts.isolated;
27
+
28
+ if (!sessionName || isolationMode !== 'detached') {
29
+ return null;
30
+ }
31
+
32
+ try {
33
+ switch (isolated) {
34
+ case 'screen': {
35
+ const output = execSync('screen -ls', {
36
+ encoding: 'utf8',
37
+ stdio: ['pipe', 'pipe', 'pipe'],
38
+ });
39
+ return output.includes(sessionName);
40
+ }
41
+ case 'tmux': {
42
+ execSync(`tmux has-session -t ${sessionName}`, {
43
+ stdio: ['pipe', 'pipe', 'pipe'],
44
+ });
45
+ return true;
46
+ }
47
+ case 'docker': {
48
+ const output = execSync(
49
+ `docker inspect -f "{{.State.Running}}" ${sessionName}`,
50
+ { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }
51
+ );
52
+ return output.trim() === 'true';
53
+ }
54
+ case 'ssh': {
55
+ // For SSH, check if the PID is still running on remote would require
56
+ // re-connecting. Fall back to checking the local wrapper PID.
57
+ if (record.pid) {
58
+ try {
59
+ process.kill(record.pid, 0);
60
+ return true;
61
+ } catch {
62
+ return false;
63
+ }
64
+ }
65
+ return null;
66
+ }
67
+ default:
68
+ return null;
69
+ }
70
+ } catch {
71
+ // Command failed - session is likely not running
72
+ return false;
73
+ }
74
+ }
75
+
76
+ function readExitCodeFromLog(logPath) {
77
+ if (!logPath) {
78
+ return null;
79
+ }
80
+ try {
81
+ const content = fs.readFileSync(logPath, 'utf8');
82
+ const matches = [...content.matchAll(/Exit Code:\s*(-?\d+)/g)];
83
+ if (matches.length === 0) {
84
+ return null;
85
+ }
86
+ return parseInt(matches[matches.length - 1][1], 10);
87
+ } catch {
88
+ return null;
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Enrich execution record with live session status for detached executions.
94
+ * If a record shows "executing" but the detached session has actually ended,
95
+ * returns an updated copy with status "executed". If it shows "executed" but
96
+ * the session is still running, returns a copy with status "executing".
97
+ * @param {Object} record - Execution record
98
+ * @returns {Object} Possibly updated execution record
99
+ */
100
+ function enrichDetachedStatus(record) {
101
+ const alive = isDetachedSessionAlive(record);
102
+ if (alive === null) {
103
+ return record;
104
+ }
105
+
106
+ // Create a shallow copy to avoid mutating the original
107
+ const enriched = Object.create(Object.getPrototypeOf(record));
108
+ Object.assign(enriched, record);
109
+
110
+ if (alive && enriched.status === 'executed') {
111
+ // Session still running but record says executed - correct it
112
+ enriched.status = 'executing';
113
+ enriched.exitCode = null;
114
+ enriched.endTime = null;
115
+ } else if (!alive && enriched.status === 'executing') {
116
+ // Session ended but record says executing - correct it
117
+ enriched.status = 'executed';
118
+ if (enriched.exitCode === null || enriched.exitCode === undefined) {
119
+ enriched.exitCode = readExitCodeFromLog(enriched.logPath) ?? -1;
120
+ }
121
+ if (!enriched.endTime) {
122
+ enriched.endTime = new Date().toISOString();
123
+ }
124
+ }
125
+
126
+ return enriched;
127
+ }
128
+
15
129
  /**
16
130
  * Format execution record as Links Notation (indented style)
17
131
  * Uses nested Links notation for object values (like options) instead of JSON
@@ -122,18 +236,23 @@ function formatRecord(record, format) {
122
236
  * @param {string|null} outputFormat - Output format (links-notation, json, text)
123
237
  * @returns {{success: boolean, output?: string, error?: string}}
124
238
  */
125
- function queryStatus(store, uuid, outputFormat) {
239
+ function queryStatus(store, identifier, outputFormat) {
126
240
  if (!store) {
127
241
  return { success: false, error: 'Execution tracking is disabled.' };
128
242
  }
129
- const record = store.get(uuid);
243
+ const record = store.get(identifier);
130
244
  if (!record) {
131
- return { success: false, error: `No execution found with UUID: ${uuid}` };
245
+ return {
246
+ success: false,
247
+ error: `No execution found with UUID or session name: ${identifier}`,
248
+ };
132
249
  }
133
250
  try {
251
+ // Enrich detached execution status with live session check
252
+ const enrichedRecord = enrichDetachedStatus(record);
134
253
  return {
135
254
  success: true,
136
- output: formatRecord(record, outputFormat || 'links-notation'),
255
+ output: formatRecord(enrichedRecord, outputFormat || 'links-notation'),
137
256
  };
138
257
  } catch (err) {
139
258
  return { success: false, error: err.message };
@@ -145,4 +264,6 @@ module.exports = {
145
264
  formatRecordAsText,
146
265
  formatRecord,
147
266
  queryStatus,
267
+ isDetachedSessionAlive,
268
+ enrichDetachedStatus,
148
269
  };
@@ -14,6 +14,7 @@ const {
14
14
  createLogFooter,
15
15
  getLogDir,
16
16
  createLogPath,
17
+ getTempRoot,
17
18
  } = require('../src/lib/isolation-log-utils');
18
19
 
19
20
  describe('isolation-log-utils', () => {
@@ -194,13 +195,12 @@ describe('isolation-log-utils', () => {
194
195
  }
195
196
  });
196
197
 
197
- it('should fall back to os.tmpdir() when START_LOG_DIR is not set', () => {
198
- const os = require('os');
198
+ it('should fall back to /tmp/start-command/logs when START_LOG_DIR is not set', () => {
199
199
  const original = process.env.START_LOG_DIR;
200
200
  delete process.env.START_LOG_DIR;
201
201
  try {
202
202
  const dir = getLogDir();
203
- assert.strictEqual(dir, os.tmpdir());
203
+ assert.strictEqual(dir, path.join(getTempRoot(), 'logs'));
204
204
  } finally {
205
205
  if (original !== undefined) {
206
206
  process.env.START_LOG_DIR = original;
@@ -230,5 +230,19 @@ describe('isolation-log-utils', () => {
230
230
  const logPath = createLogPath('screen');
231
231
  assert.ok(logPath.startsWith(logDir));
232
232
  });
233
+
234
+ it('should create stable isolation log path when execution id is provided', () => {
235
+ const logPath = createLogPath('screen', 'uuid-123');
236
+ assert.ok(
237
+ logPath.endsWith(
238
+ path.join('logs', 'isolation', 'screen', 'uuid-123.log')
239
+ )
240
+ );
241
+ });
242
+
243
+ it('should create stable direct log path without duplicated environment segment', () => {
244
+ const logPath = createLogPath('direct', 'uuid-123');
245
+ assert.ok(logPath.endsWith(path.join('logs', 'direct', 'uuid-123.log')));
246
+ });
233
247
  });
234
248
  });
@@ -7,9 +7,27 @@
7
7
 
8
8
  const { describe, it } = require('node:test');
9
9
  const assert = require('assert');
10
- const { execSync } = require('child_process');
10
+ const { execSync, spawnSync } = require('child_process');
11
+ const crypto = require('crypto');
12
+ const fs = require('fs');
13
+ const os = require('os');
14
+ const path = require('path');
11
15
  const { isCommandAvailable, runInScreen } = require('../src/lib/isolation');
12
16
 
17
+ function waitForFileContent(filePath, predicate, timeoutMs = 5000) {
18
+ const start = Date.now();
19
+ while (Date.now() - start < timeoutMs) {
20
+ if (fs.existsSync(filePath)) {
21
+ const content = fs.readFileSync(filePath, 'utf8');
22
+ if (predicate(content)) {
23
+ return content;
24
+ }
25
+ }
26
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 100);
27
+ }
28
+ return fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf8') : '';
29
+ }
30
+
13
31
  describe('Screen Integration Tests', () => {
14
32
  describe('runInScreen (if available)', () => {
15
33
  it('should run command in detached screen session', async () => {
@@ -38,6 +56,85 @@ describe('Screen Integration Tests', () => {
38
56
  }
39
57
  });
40
58
 
59
+ it('should stream detached screen output to the tracked CLI log path', () => {
60
+ if (!isCommandAvailable('screen')) {
61
+ console.log(' Skipping: screen not installed');
62
+ return;
63
+ }
64
+
65
+ const executionId = crypto.randomUUID();
66
+ const sessionName = `test-screen-log-${Date.now()}`;
67
+ const appFolder = fs.mkdtempSync(
68
+ path.join(os.tmpdir(), 'start-command-test-store-')
69
+ );
70
+ const cliPath = path.join(__dirname, '..', 'src', 'bin', 'cli.js');
71
+ const command =
72
+ 'printf "detached-log-test-1\\n"; sleep 0.2; printf "detached-log-test-2\\n"';
73
+
74
+ const result = spawnSync(
75
+ process.execPath,
76
+ [
77
+ cliPath,
78
+ '--session-id',
79
+ executionId,
80
+ '-i',
81
+ 'screen',
82
+ '-d',
83
+ '--session',
84
+ sessionName,
85
+ '--',
86
+ command,
87
+ ],
88
+ {
89
+ encoding: 'utf8',
90
+ env: {
91
+ ...process.env,
92
+ START_APP_FOLDER: appFolder,
93
+ START_DISABLE_AUTO_ISSUE: '1',
94
+ },
95
+ }
96
+ );
97
+
98
+ assert.strictEqual(
99
+ result.status,
100
+ 0,
101
+ `CLI failed:\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`
102
+ );
103
+
104
+ const logPathMatch = result.stdout.match(/│ log\s+(.+)/);
105
+ assert.ok(result.stdout.includes(`│ screen ${sessionName}`));
106
+ assert.ok(logPathMatch, `Expected log path in output:\n${result.stdout}`);
107
+
108
+ const logPath = logPathMatch[1].trim();
109
+ assert.ok(
110
+ logPath.endsWith(
111
+ path.join('logs', 'isolation', 'screen', `${executionId}.log`)
112
+ ),
113
+ `Unexpected log path: ${logPath}`
114
+ );
115
+
116
+ const logContent = waitForFileContent(
117
+ logPath,
118
+ (content) =>
119
+ content.includes('detached-log-test-1') &&
120
+ content.includes('detached-log-test-2') &&
121
+ content.includes('Exit Code: 0')
122
+ );
123
+
124
+ assert.ok(
125
+ logContent.includes('detached-log-test-1'),
126
+ `Missing first output line in log:\n${logContent}`
127
+ );
128
+ assert.ok(
129
+ logContent.includes('detached-log-test-2'),
130
+ `Missing second output line in log:\n${logContent}`
131
+ );
132
+ assert.ok(
133
+ logContent.includes('Command started in detached screen session'),
134
+ `Missing detached start message in log:\n${logContent}`
135
+ );
136
+ });
137
+
41
138
  it('should run command in attached mode and capture output (issue #15)', async () => {
42
139
  if (!isCommandAvailable('screen')) {
43
140
  console.log(' Skipping: screen not installed');