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 +6 -0
- package/package.json +1 -1
- package/src/bin/cli.js +23 -26
- 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 +18 -1
- package/test/isolation-log-utils.test.js +17 -3
- package/test/screen-integration.test.js +98 -1
package/CHANGELOG.md
CHANGED
package/package.json
CHANGED
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:
|
|
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
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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 ||
|
|
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
|
|
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
|
};
|
package/src/lib/isolation.js
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
|
|
298
|
-
|
|
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
|
|
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
|
}
|
|
@@ -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;
|
|
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
|
|
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');
|