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.
- package/CHANGELOG.md +14 -0
- package/package.json +1 -1
- package/src/bin/cli.js +29 -34
- package/src/lib/args-parser.js +2 -2
- package/src/lib/execution-store.js +14 -5
- package/src/lib/isolation-log-utils.js +80 -3
- package/src/lib/isolation.js +80 -50
- package/src/lib/screen-isolation.js +121 -7
- package/src/lib/spawn-helpers.js +32 -9
- package/src/lib/status-formatter.js +125 -4
- package/test/isolation-log-utils.test.js +17 -3
- package/test/screen-integration.test.js +98 -1
- package/test/session-name-status.test.js +311 -0
|
@@ -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
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
};
|
package/src/lib/spawn-helpers.js
CHANGED
|
@@ -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
|
|
154
|
+
// Write log footer
|
|
137
155
|
try {
|
|
138
|
-
|
|
139
|
-
logContent +=
|
|
140
|
-
|
|
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
|
-
|
|
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
|
|
295
|
+
// Write log footer
|
|
276
296
|
try {
|
|
277
|
-
|
|
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
|
-
|
|
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,
|
|
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(
|
|
243
|
+
const record = store.get(identifier);
|
|
130
244
|
if (!record) {
|
|
131
|
-
return {
|
|
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(
|
|
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
|
|
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,
|
|
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');
|