start-command 0.25.2 → 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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # start-command
2
2
 
3
+ ## 0.25.3
4
+
5
+ ### Patch Changes
6
+
7
+ - 873781e: Record detached isolation output in the tracked log path in real time.
8
+
3
9
  ## 0.25.2
4
10
 
5
11
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "start-command",
3
- "version": "0.25.2",
3
+ "version": "0.25.3",
4
4
  "description": "Gamification of coding, execute any command with ability to auto-report issues on GitHub",
5
5
  "main": "src/bin/cli.js",
6
6
  "exports": {
package/src/bin/cli.js CHANGED
@@ -2,7 +2,6 @@
2
2
 
3
3
  const process = require('process');
4
4
  const os = require('os');
5
- const fs = require('fs');
6
5
  const path = require('path');
7
6
 
8
7
  // Import modules
@@ -20,6 +19,7 @@ const {
20
19
  createLogHeader,
21
20
  createLogFooter,
22
21
  writeLogFile,
22
+ appendLogFile,
23
23
  createLogPath,
24
24
  getDefaultDockerImage,
25
25
  } = require('../lib/isolation');
@@ -405,7 +405,7 @@ async function runWithIsolation(
405
405
  }
406
406
 
407
407
  // Create log file path
408
- const logFilePath = createLogPath(environment || 'direct');
408
+ const logFilePath = createLogPath(environment || 'direct', sessionId);
409
409
 
410
410
  // Get session name (will be generated by runIsolated if not provided)
411
411
  const sessionName =
@@ -559,6 +559,8 @@ async function runWithIsolation(
559
559
  );
560
560
  }
561
561
 
562
+ writeLogFile(logFilePath, logContent);
563
+
562
564
  let result;
563
565
 
564
566
  if (environment) {
@@ -566,7 +568,7 @@ async function runWithIsolation(
566
568
  // Note: Isolation environments currently use native spawn/execSync
567
569
  // Future: Add command-stream support with raw() function for multiplexers
568
570
  result = await runIsolated(environment, cmd, {
569
- session: options.session,
571
+ session: sessionName,
570
572
  image: effectiveImage,
571
573
  endpoint: options.endpoint,
572
574
  detached: mode === 'detached',
@@ -574,6 +576,7 @@ async function runWithIsolation(
574
576
  keepAlive: options.keepAlive,
575
577
  autoRemoveDockerContainer: options.autoRemoveDockerContainer,
576
578
  shell: options.shell,
579
+ logPath: logFilePath,
577
580
  });
578
581
  } else if (createdUser) {
579
582
  // Run directly as the created user (no isolation environment)
@@ -592,12 +595,13 @@ async function runWithIsolation(
592
595
  if (!result.success && result.message) {
593
596
  console.error(`Error: ${result.message}`);
594
597
  }
595
- // Add result to log content
596
- logContent += `${result.message}\n`;
597
- logContent += createLogFooter(endTime, exitCode);
598
-
599
- // Write log file
600
- writeLogFile(logFilePath, logContent);
598
+ if (mode === 'detached' && result.success) {
599
+ appendLogFile(logFilePath, `${result.message}\n`);
600
+ } else {
601
+ // Add result to log content
602
+ appendLogFile(logFilePath, `${result.message}\n`);
603
+ appendLogFile(logFilePath, createLogFooter(endTime, exitCode));
604
+ }
601
605
 
602
606
  // Update execution record: detached keeps "executing" (resolved at query time)
603
607
  if (executionRecord && store) {
@@ -674,9 +678,7 @@ async function runDirect(cmd, sessionId) {
674
678
  const shellArgs = isWindows ? ['-Command', cmd] : ['-c', cmd];
675
679
 
676
680
  // Setup logging
677
- const logDir = config.logDir || os.tmpdir();
678
- const logFilename = generateLogFilename();
679
- const logFilePath = path.join(logDir, logFilename);
681
+ const logFilePath = createLogPath('direct', sessionId);
680
682
 
681
683
  let logContent = '';
682
684
  const startTime = getTimestamp();
@@ -825,9 +827,7 @@ async function runDirectWithCommandStream(
825
827
  const shell = isWindows ? 'powershell.exe' : process.env.SHELL || '/bin/sh';
826
828
 
827
829
  // Setup logging
828
- const logDir = config.logDir || os.tmpdir();
829
- const logFilename = generateLogFilename();
830
- const logFilePath = path.join(logDir, logFilename);
830
+ const logFilePath = createLogPath('direct', sessionId);
831
831
 
832
832
  let logContent = '';
833
833
  const startTime = getTimestamp();
@@ -911,6 +911,7 @@ async function runDirectWithCommandStream(
911
911
 
912
912
  let exitCode = 0;
913
913
  try {
914
+ writeLogFile(logFilePath, logContent);
914
915
  // Use raw() to pass the command without auto-escaping
915
916
  // This is important for complex commands with pipes, redirects, etc.
916
917
  const result = await $cmd`${raw(cmd)}`;
@@ -924,14 +925,17 @@ async function runDirectWithCommandStream(
924
925
  // Collect output for log
925
926
  if (result.stdout) {
926
927
  logContent += result.stdout;
928
+ appendLogFile(logFilePath, result.stdout);
927
929
  }
928
930
  if (result.stderr) {
929
931
  logContent += result.stderr;
932
+ appendLogFile(logFilePath, result.stderr);
930
933
  }
931
934
  } catch (err) {
932
935
  exitCode = err.code || 1;
933
936
  const errorMessage = `Error executing command: ${err.message}`;
934
937
  logContent += `\n${errorMessage}\n`;
938
+ appendLogFile(logFilePath, `\n${errorMessage}\n`);
935
939
  console.error(`\n${errorMessage}`);
936
940
  }
937
941
 
@@ -944,7 +948,10 @@ async function runDirectWithCommandStream(
944
948
 
945
949
  // Write log file
946
950
  try {
947
- fs.writeFileSync(logFilePath, logContent, 'utf8');
951
+ appendLogFile(
952
+ logFilePath,
953
+ `\n${'='.repeat(50)}\nFinished: ${endTime}\nExit Code: ${exitCode}\n`
954
+ );
948
955
  } catch (err) {
949
956
  console.error(`\nWarning: Could not save log file: ${err.message}`);
950
957
  }
@@ -985,13 +992,3 @@ async function runDirectWithCommandStream(
985
992
 
986
993
  process.exit(exitCode);
987
994
  }
988
-
989
- /**
990
- * Generate unique log filename for direct execution
991
- * @returns {string} Log filename
992
- */
993
- function generateLogFilename() {
994
- const timestamp = Date.now();
995
- const random = Math.random().toString(36).substring(2, 8);
996
- return `start-command-${timestamp}-${random}.log`;
997
- }
@@ -5,6 +5,18 @@ const fs = require('fs');
5
5
  const os = require('os');
6
6
  const path = require('path');
7
7
 
8
+ function getTempRoot() {
9
+ return process.env.START_TEMP_ROOT || path.join(os.tmpdir(), 'start-command');
10
+ }
11
+
12
+ function ensureDirectory(dirPath) {
13
+ fs.mkdirSync(dirPath, { recursive: true });
14
+ }
15
+
16
+ function ensureParentDirectory(filePath) {
17
+ ensureDirectory(path.dirname(filePath));
18
+ }
19
+
8
20
  /**
9
21
  * Generate timestamp for logging
10
22
  * @returns {string} ISO timestamp without 'T' and 'Z'
@@ -77,6 +89,7 @@ function createLogFooter(endTime, exitCode) {
77
89
  */
78
90
  function writeLogFile(logPath, content) {
79
91
  try {
92
+ ensureParentDirectory(logPath);
80
93
  fs.writeFileSync(logPath, content, 'utf8');
81
94
  return true;
82
95
  } catch (err) {
@@ -85,23 +98,79 @@ function writeLogFile(logPath, content) {
85
98
  }
86
99
  }
87
100
 
101
+ /**
102
+ * Append to a log file, creating its parent directory when needed.
103
+ * @param {string} logPath - Path to log file
104
+ * @param {string} content - Log content to append
105
+ * @returns {boolean} Success status
106
+ */
107
+ function appendLogFile(logPath, content) {
108
+ try {
109
+ ensureParentDirectory(logPath);
110
+ fs.appendFileSync(logPath, content, 'utf8');
111
+ return true;
112
+ } catch (err) {
113
+ console.error(`\nWarning: Could not append log file: ${err.message}`);
114
+ return false;
115
+ }
116
+ }
117
+
88
118
  /**
89
119
  * Get log directory from environment or use system temp
90
120
  * @returns {string} Log directory path
91
121
  */
92
122
  function getLogDir() {
93
- return process.env.START_LOG_DIR || os.tmpdir();
123
+ return process.env.START_LOG_DIR || path.join(getTempRoot(), 'logs');
124
+ }
125
+
126
+ /**
127
+ * Get a start-command temporary directory for sidecar files.
128
+ * @param {...string} segments - Optional path segments below the temp directory
129
+ * @returns {string} Directory path
130
+ */
131
+ function getTempDir(...segments) {
132
+ const dir = path.join(getTempRoot(), 'tmp', ...segments);
133
+ ensureDirectory(dir);
134
+ return dir;
94
135
  }
95
136
 
96
137
  /**
97
138
  * Create log file path
98
139
  * @param {string} environment - The isolation environment
140
+ * @param {string|null} executionId - Optional execution UUID/session ID
99
141
  * @returns {string} Full path to log file
100
142
  */
101
- function createLogPath(environment) {
143
+ function createLogPath(environment, executionId = null) {
102
144
  const logDir = getLogDir();
145
+ if (executionId) {
146
+ return environment === 'direct'
147
+ ? path.join(logDir, 'direct', `${executionId}.log`)
148
+ : path.join(logDir, 'isolation', environment, `${executionId}.log`);
149
+ }
103
150
  const logFilename = generateLogFilename(environment);
104
- return path.join(logDir, logFilename);
151
+ return environment === 'direct'
152
+ ? path.join(logDir, 'direct', logFilename)
153
+ : path.join(logDir, 'isolation', environment, logFilename);
154
+ }
155
+
156
+ function shellQuote(value) {
157
+ return `'${String(value).replace(/'/g, "'\\''")}'`;
158
+ }
159
+
160
+ function createShellLogFooterSnippet() {
161
+ const dateCommand =
162
+ "date '+%Y-%m-%d %H:%M:%S.%3N' 2>/dev/null || date '+%Y-%m-%d %H:%M:%S'";
163
+ return `printf '\\n==================================================\\nFinished: %s\\nExit Code: %s\\n' "$(${dateCommand})" "$__start_command_exit"`;
164
+ }
165
+
166
+ function wrapCommandWithLogFooter(command, options = {}) {
167
+ const shell = options.shell || 'sh';
168
+ const keepAlive = Boolean(options.keepAlive);
169
+ const footer = createShellLogFooterSnippet();
170
+ const afterFooter = keepAlive
171
+ ? `exec ${shellQuote(shell)}`
172
+ : 'exit "$__start_command_exit"';
173
+ return `(${command}); __start_command_exit=$?; ${footer}; ${afterFooter}`;
105
174
  }
106
175
 
107
176
  /**
@@ -136,12 +205,20 @@ function runAsIsolatedUser(cmd, username) {
136
205
  }
137
206
 
138
207
  module.exports = {
208
+ getTempRoot,
209
+ ensureDirectory,
210
+ ensureParentDirectory,
139
211
  getTimestamp,
140
212
  generateLogFilename,
141
213
  createLogHeader,
142
214
  createLogFooter,
143
215
  writeLogFile,
216
+ appendLogFile,
144
217
  getLogDir,
218
+ getTempDir,
145
219
  createLogPath,
220
+ shellQuote,
221
+ createShellLogFooterSnippet,
222
+ wrapCommandWithLogFooter,
146
223
  runAsIsolatedUser,
147
224
  };
@@ -13,8 +13,15 @@ const {
13
13
  getScreenVersion,
14
14
  supportsLogfileOption,
15
15
  runScreenWithLogCapture: _runScreenWithLogCapture,
16
+ startDetachedScreenWithLogCapture,
16
17
  resetScreenVersionCache,
17
18
  } = require('./screen-isolation');
19
+ const {
20
+ appendLogFile,
21
+ createShellLogFooterSnippet,
22
+ shellQuote,
23
+ wrapCommandWithLogFooter,
24
+ } = require('./isolation-log-utils');
18
25
 
19
26
  /**
20
27
  * Check if a command is available on the system
@@ -170,14 +177,21 @@ function wrapCommandWithUser(command, user) {
170
177
  * @param {string|null} user - Username to run command as (optional)
171
178
  * @returns {Promise<{success: boolean, sessionName: string, message: string, output: string}>}
172
179
  */
173
- function runScreenWithLogCapture(command, sessionName, shellInfo, user = null) {
180
+ function runScreenWithLogCapture(
181
+ command,
182
+ sessionName,
183
+ shellInfo,
184
+ user = null,
185
+ logPath = null
186
+ ) {
174
187
  return _runScreenWithLogCapture(
175
188
  command,
176
189
  sessionName,
177
190
  shellInfo,
178
191
  user,
179
192
  wrapCommandWithUser,
180
- isInteractiveShellCommand
193
+ isInteractiveShellCommand,
194
+ logPath
181
195
  );
182
196
  }
183
197
 
@@ -199,54 +213,24 @@ function runInScreen(command, options = {}) {
199
213
 
200
214
  const sessionName = options.session || generateSessionName('screen');
201
215
  const shellInfo = getShell();
202
- const { shell, shellArg } = shellInfo;
203
216
 
204
217
  try {
205
- // Wrap command with user switch if specified
206
- let effectiveCommand = wrapCommandWithUser(command, options.user);
207
-
208
218
  if (options.detached) {
209
- if (options.keepAlive) {
210
- effectiveCommand = `${effectiveCommand}; exec ${shell}`;
211
- }
212
-
213
- const screenArgs = isInteractiveShellCommand(command)
214
- ? ['-dmS', sessionName, ...command.trim().split(/\s+/)]
215
- : ['-dmS', sessionName, shell, shellArg, effectiveCommand];
216
-
217
- if (DEBUG) {
218
- console.log(`[DEBUG] Running: screen ${screenArgs.join(' ')}`);
219
- console.log(`[DEBUG] keepAlive: ${options.keepAlive || false}`);
220
- }
221
-
222
- // Use spawnSync with array args (not execSync string) to avoid quoting issues (issue #25)
223
- const result = spawnSync('screen', screenArgs, {
224
- stdio: 'inherit',
225
- });
226
-
227
- if (result.error) {
228
- throw result.error;
229
- }
230
-
231
- let message = `Command started in detached screen session: ${sessionName}`;
232
- if (options.keepAlive) {
233
- message += `\nSession will stay alive after command completes.`;
234
- } else {
235
- message += `\nSession will exit automatically after command completes.`;
236
- }
237
- message += `\nReattach with: screen -r ${sessionName}`;
238
-
239
- return Promise.resolve({
240
- success: true,
241
- sessionName,
242
- message,
243
- });
219
+ return Promise.resolve(
220
+ startDetachedScreenWithLogCapture(command, sessionName, shellInfo, {
221
+ user: options.user,
222
+ keepAlive: options.keepAlive,
223
+ logPath: options.logPath,
224
+ wrapCommandWithUser,
225
+ })
226
+ );
244
227
  } else {
245
228
  return runScreenWithLogCapture(
246
229
  command,
247
230
  sessionName,
248
231
  shellInfo,
249
- options.user
232
+ options.user,
233
+ options.logPath
250
234
  );
251
235
  }
252
236
  } catch (err) {
@@ -294,12 +278,40 @@ function runInTmux(command, options = {}) {
294
278
  console.log(`[DEBUG] keepAlive: ${options.keepAlive || false}`);
295
279
  }
296
280
 
297
- execSync(
298
- `tmux new-session -d -s "${sessionName}" "${effectiveCommand}"`,
299
- {
281
+ if (options.logPath) {
282
+ const loggedCommand = wrapCommandWithLogFooter(effectiveCommand, {
283
+ shell,
284
+ keepAlive: options.keepAlive,
285
+ });
286
+ spawnSync('tmux', ['new-session', '-d', '-s', sessionName, shell], {
300
287
  stdio: 'inherit',
301
- }
302
- );
288
+ });
289
+ spawnSync(
290
+ 'tmux',
291
+ [
292
+ 'pipe-pane',
293
+ '-t',
294
+ sessionName,
295
+ '-o',
296
+ `cat >> ${shellQuote(options.logPath)}`,
297
+ ],
298
+ { stdio: 'inherit' }
299
+ );
300
+ spawnSync(
301
+ 'tmux',
302
+ ['send-keys', '-t', sessionName, loggedCommand, 'C-m'],
303
+ {
304
+ stdio: 'inherit',
305
+ }
306
+ );
307
+ } else {
308
+ execSync(
309
+ `tmux new-session -d -s "${sessionName}" "${effectiveCommand}"`,
310
+ {
311
+ stdio: 'inherit',
312
+ }
313
+ );
314
+ }
303
315
 
304
316
  let message = `Command started in detached tmux session: ${sessionName}`;
305
317
  if (options.keepAlive) {
@@ -401,8 +413,8 @@ function runInSsh(command, options = {}) {
401
413
  ? `${remoteShell} ${shellInteractiveFlag}`
402
414
  : remoteShell;
403
415
  const remoteCommand = isInteractiveShellCommand(command)
404
- ? `nohup ${command} > /tmp/${sessionName}.log 2>&1 &`
405
- : `nohup ${shellInvocation} -c ${JSON.stringify(command)} > /tmp/${sessionName}.log 2>&1 &`;
416
+ ? `mkdir -p /tmp/start-command/logs/isolation/ssh && nohup ${command} > /tmp/start-command/logs/isolation/ssh/${sessionName}.log 2>&1 &`
417
+ : `mkdir -p /tmp/start-command/logs/isolation/ssh && nohup ${shellInvocation} -c ${JSON.stringify(command)} > /tmp/start-command/logs/isolation/ssh/${sessionName}.log 2>&1 &`;
406
418
  const sshArgs = [sshTarget, remoteCommand];
407
419
 
408
420
  if (DEBUG) {
@@ -419,7 +431,7 @@ function runInSsh(command, options = {}) {
419
431
  return Promise.resolve({
420
432
  success: true,
421
433
  sessionName,
422
- message: `Command started in detached SSH session on ${sshTarget}\nSession: ${sessionName}\nView logs: ssh ${sshTarget} "tail -f /tmp/${sessionName}.log"`,
434
+ message: `Command started in detached SSH session on ${sshTarget}\nSession: ${sessionName}\nView logs: ssh ${sshTarget} "tail -f /tmp/start-command/logs/isolation/ssh/${sessionName}.log"`,
423
435
  });
424
436
  } else {
425
437
  const extraFlags = shellInteractiveFlag ? [shellInteractiveFlag] : [];
@@ -568,6 +580,19 @@ function runInDocker(command, options = {}) {
568
580
  encoding: 'utf8',
569
581
  }).trim();
570
582
 
583
+ if (options.logPath) {
584
+ const loggerScript = [
585
+ `docker logs -f ${shellQuote(containerName)} >> ${shellQuote(options.logPath)} 2>&1`,
586
+ `__start_command_exit=$(docker inspect -f '{{.State.ExitCode}}' ${shellQuote(containerName)} 2>/dev/null || printf '%s' '-1')`,
587
+ `${createShellLogFooterSnippet()} >> ${shellQuote(options.logPath)}`,
588
+ ].join('; ');
589
+ const logger = spawn('sh', ['-c', loggerScript], {
590
+ detached: true,
591
+ stdio: 'ignore',
592
+ });
593
+ logger.unref();
594
+ }
595
+
571
596
  let message = `Command started in detached docker container: ${containerName}`;
572
597
  message += `\nContainer ID: ${containerId.substring(0, 12)}`;
573
598
  if (options.keepAlive) {
@@ -582,6 +607,9 @@ function runInDocker(command, options = {}) {
582
607
  }
583
608
  message += `\nAttach with: docker attach ${containerName}`;
584
609
  message += `\nView logs: docker logs ${containerName}`;
610
+ if (options.logPath) {
611
+ message += `\nLive log: ${options.logPath}`;
612
+ }
585
613
 
586
614
  return Promise.resolve({
587
615
  success: true,
@@ -696,6 +724,7 @@ function runIsolated(backend, command, options = {}) {
696
724
  ? options.endpointStack[0]
697
725
  : options.endpoint,
698
726
  session: options.sessionStack ? options.sessionStack[0] : options.session,
727
+ logPath: options.logPath,
699
728
  };
700
729
 
701
730
  switch (backend) {
@@ -746,6 +775,7 @@ module.exports = {
746
775
  createLogHeader,
747
776
  createLogFooter,
748
777
  writeLogFile,
778
+ appendLogFile,
749
779
  getLogDir,
750
780
  createLogPath,
751
781
  getScreenVersion,
@@ -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
  }
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  const { execSync } = require('child_process');
11
+ const fs = require('fs');
11
12
  const {
12
13
  escapeForLinksNotation,
13
14
  formatAsNestedLinksNotation,
@@ -72,6 +73,22 @@ function isDetachedSessionAlive(record) {
72
73
  }
73
74
  }
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
+
75
92
  /**
76
93
  * Enrich execution record with live session status for detached executions.
77
94
  * If a record shows "executing" but the detached session has actually ended,
@@ -99,7 +116,7 @@ function enrichDetachedStatus(record) {
99
116
  // Session ended but record says executing - correct it
100
117
  enriched.status = 'executed';
101
118
  if (enriched.exitCode === null || enriched.exitCode === undefined) {
102
- enriched.exitCode = -1; // Unknown exit code
119
+ enriched.exitCode = readExitCodeFromLog(enriched.logPath) ?? -1;
103
120
  }
104
121
  if (!enriched.endTime) {
105
122
  enriched.endTime = new Date().toISOString();
@@ -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');