start-command 0.26.0 → 0.27.0
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 +11 -1
- package/README.md +1 -1
- package/bunfig.toml +2 -2
- package/eslint.config.mjs +1 -1
- package/package.json +2 -2
- package/src/bin/cli.js +30 -0
- package/src/lib/args-parser.js +72 -6
- package/src/lib/execution-control.js +317 -0
- package/src/lib/isolation.js +22 -4
- package/src/lib/status-formatter.js +46 -2
- package/src/lib/usage.js +5 -1
- package/test/args-parser-control.js +71 -0
- package/test/{args-parser.test.js → args-parser.js} +1 -1
- package/test/cli.js +260 -0
- package/test/docker-autoremove.js +175 -0
- package/test/execution-control.js +253 -0
- package/test/{isolation.test.js → isolation.js} +4 -2
- package/test/merge-changesets.mjs +154 -0
- package/test/release-name.mjs +117 -0
- package/test/{screen-integration.test.js → screen-integration.js} +1 -1
- package/test/{ssh-integration.test.js → ssh-integration.js} +1 -1
- package/test/{status-query.test.js → status-query.js} +2 -0
- package/test/{substitution.test.js → substitution.js} +1 -2
- package/test/{user-manager.test.js → user-manager.js} +17 -0
- package/test/cli.test.js +0 -218
- package/test/docker-autoremove.test.js +0 -164
- package/test/release-name.test.mjs +0 -34
- /package/test/{args-parser-shell.test.js → args-parser-shell.js} +0 -0
- /package/test/{echo-integration.test.js → echo-integration.js} +0 -0
- /package/test/{execution-store.test.js → execution-store.js} +0 -0
- /package/test/{failure-handler.test.js → failure-handler.js} +0 -0
- /package/test/{isolation-cleanup.test.js → isolation-cleanup.js} +0 -0
- /package/test/{isolation-log-utils.test.js → isolation-log-utils.js} +0 -0
- /package/test/{isolation-stacking.test.js → isolation-stacking.js} +0 -0
- /package/test/{output-blocks.test.js → output-blocks.js} +0 -0
- /package/test/{public-exports.test.js → public-exports.js} +0 -0
- /package/test/{regression-84.test.js → regression-84.js} +0 -0
- /package/test/{regression-89.test.js → regression-89.js} +0 -0
- /package/test/{regression-91.test.js → regression-91.js} +0 -0
- /package/test/{sequence-parser.test.js → sequence-parser.js} +0 -0
- /package/test/{session-name-status.test.js → session-name-status.js} +0 -0
- /package/test/{version.test.js → version.js} +0 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# start-command
|
|
2
2
|
|
|
3
|
+
## 0.27.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- Add `--stop` and `--terminate` controls for detached isolated executions and include best-effort process IDs in status output.
|
|
8
|
+
|
|
9
|
+
Fix CI release note generation so language-prefixed GitHub Releases read the package changelog and include exact-version badges.
|
|
10
|
+
|
|
11
|
+
Fix release pipeline so multi-changeset merges and JS GitHub Releases work end-to-end. The `merge-changesets.mjs` helper now reads the package name from `package.json` and accepts a `--working-dir` flag, fixing the `ENOENT: no such file or directory, scandir '.changeset'` failure on the JS release workflow when more than one changeset is pending. JS GitHub Releases now also include the exact-version npm badge alongside the existing Rust crates badge.
|
|
12
|
+
|
|
3
13
|
## 0.26.0
|
|
4
14
|
|
|
5
15
|
### Minor Changes
|
|
@@ -15,7 +25,7 @@
|
|
|
15
25
|
releases are now tagged `js-v<version>` with title `[JavaScript] <version>`,
|
|
16
26
|
matching the `rust-v<version>` / `[Rust] <version>` convention needed for the
|
|
17
27
|
mono-repo. Extracted the tag/title construction into `scripts/release-name.mjs`
|
|
18
|
-
with unit tests in `test/release-name.
|
|
28
|
+
with unit tests in `test/release-name.mjs`. See `docs/case-studies/issue-108/`
|
|
19
29
|
for the full analysis.
|
|
20
30
|
|
|
21
31
|
## 0.25.4
|
package/README.md
CHANGED
package/bunfig.toml
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
# Bun configuration
|
|
2
|
-
#
|
|
3
|
-
#
|
|
2
|
+
# Tests use repository naming without conventional suffixes, so package scripts pass
|
|
3
|
+
# the test files explicitly instead of relying on Bun's default discovery.
|
package/eslint.config.mjs
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "start-command",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.27.0",
|
|
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": {
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
"$": "./src/bin/cli.js"
|
|
12
12
|
},
|
|
13
13
|
"scripts": {
|
|
14
|
-
"test": "bun
|
|
14
|
+
"test": "bun ../scripts/run-js-tests.mjs",
|
|
15
15
|
"lint": "eslint .",
|
|
16
16
|
"lint:fix": "eslint . --fix",
|
|
17
17
|
"format": "prettier --write .",
|
package/src/bin/cli.js
CHANGED
|
@@ -32,6 +32,7 @@ const {
|
|
|
32
32
|
const { handleFailure } = require('../lib/failure-handler');
|
|
33
33
|
const { ExecutionStore, ExecutionRecord } = require('../lib/execution-store');
|
|
34
34
|
const { queryStatus, listExecutions } = require('../lib/status-formatter');
|
|
35
|
+
const { ControlAction, controlExecution } = require('../lib/execution-control');
|
|
35
36
|
const { printVersion } = require('../lib/version');
|
|
36
37
|
const { createStartBlock, createFinishBlock } = require('../lib/output-blocks');
|
|
37
38
|
const { runWithBunSpawn, runWithNodeSpawn } = require('../lib/spawn-helpers');
|
|
@@ -200,6 +201,21 @@ if (wrapperOptions.list) {
|
|
|
200
201
|
process.exit(0);
|
|
201
202
|
}
|
|
202
203
|
|
|
204
|
+
// Handle --stop flag
|
|
205
|
+
if (wrapperOptions.stop !== null && wrapperOptions.stop !== undefined) {
|
|
206
|
+
handleControlQuery(wrapperOptions.stop, ControlAction.STOP);
|
|
207
|
+
process.exit(0);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Handle --terminate flag
|
|
211
|
+
if (
|
|
212
|
+
wrapperOptions.terminate !== null &&
|
|
213
|
+
wrapperOptions.terminate !== undefined
|
|
214
|
+
) {
|
|
215
|
+
handleControlQuery(wrapperOptions.terminate, ControlAction.TERMINATE);
|
|
216
|
+
process.exit(0);
|
|
217
|
+
}
|
|
218
|
+
|
|
203
219
|
// Handle --cleanup flag
|
|
204
220
|
if (wrapperOptions.cleanup) {
|
|
205
221
|
handleCleanup(wrapperOptions.cleanupDryRun);
|
|
@@ -284,6 +300,16 @@ function handleListQuery(outputFormat) {
|
|
|
284
300
|
}
|
|
285
301
|
}
|
|
286
302
|
|
|
303
|
+
function handleControlQuery(identifier, action) {
|
|
304
|
+
const result = controlExecution(getExecutionStore(), identifier, action);
|
|
305
|
+
if (result.success) {
|
|
306
|
+
console.log(result.output);
|
|
307
|
+
} else {
|
|
308
|
+
console.error(`Error: ${result.error}`);
|
|
309
|
+
process.exit(1);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
287
313
|
/**
|
|
288
314
|
* Handle --cleanup flag
|
|
289
315
|
* Cleans up stale "executing" records (processes that crashed or were killed)
|
|
@@ -445,6 +471,7 @@ async function runWithIsolation(
|
|
|
445
471
|
uuid: sessionId, // Use the provided session ID
|
|
446
472
|
command: cmd,
|
|
447
473
|
logPath: logFilePath,
|
|
474
|
+
pid: process.pid,
|
|
448
475
|
shell,
|
|
449
476
|
workingDirectory: process.cwd(),
|
|
450
477
|
options: {
|
|
@@ -556,6 +583,9 @@ async function runWithIsolation(
|
|
|
556
583
|
|
|
557
584
|
// Update execution record: detached keeps "executing" (resolved at query time)
|
|
558
585
|
if (executionRecord && store) {
|
|
586
|
+
if (result.containerId) {
|
|
587
|
+
executionRecord.options.containerId = result.containerId;
|
|
588
|
+
}
|
|
559
589
|
if (mode !== 'detached') {
|
|
560
590
|
executionRecord.complete(exitCode);
|
|
561
591
|
}
|
package/src/lib/args-parser.js
CHANGED
|
@@ -22,6 +22,8 @@
|
|
|
22
22
|
* --status <uuid> Show status of a previous command execution by UUID
|
|
23
23
|
* --list List all tracked command executions
|
|
24
24
|
* --output-format <format> Output format for status/list (links-notation, json, text)
|
|
25
|
+
* --stop <uuid-or-session-name> Send CTRL+C/SIGINT to a detached execution
|
|
26
|
+
* --terminate <uuid-or-session-name> Terminate a detached execution immediately
|
|
25
27
|
* --cleanup Clean up stale "executing" records (processes that crashed or were killed)
|
|
26
28
|
* --cleanup-dry-run Show stale records that would be cleaned up (without actually cleaning)
|
|
27
29
|
*/
|
|
@@ -53,6 +55,10 @@ const MAX_ISOLATION_DEPTH = 7;
|
|
|
53
55
|
*/
|
|
54
56
|
const VALID_OUTPUT_FORMATS = ['links-notation', 'json', 'text'];
|
|
55
57
|
|
|
58
|
+
function hasValue(value) {
|
|
59
|
+
return value !== null && value !== undefined;
|
|
60
|
+
}
|
|
61
|
+
|
|
56
62
|
/**
|
|
57
63
|
* UUID v4 regex pattern for validation
|
|
58
64
|
*/
|
|
@@ -172,6 +178,8 @@ function parseArgs(args) {
|
|
|
172
178
|
status: null, // UUID to show status for
|
|
173
179
|
list: false, // List all tracked execution records
|
|
174
180
|
outputFormat: null, // Output format for status/list (links-notation, json, text)
|
|
181
|
+
stop: null, // UUID/session name to stop gracefully
|
|
182
|
+
terminate: null, // UUID/session name to terminate immediately
|
|
175
183
|
cleanup: false, // Clean up stale "executing" records
|
|
176
184
|
cleanupDryRun: false, // Show what would be cleaned without actually cleaning
|
|
177
185
|
};
|
|
@@ -432,7 +440,55 @@ function parseOption(args, index, options) {
|
|
|
432
440
|
|
|
433
441
|
// --status=<value>
|
|
434
442
|
if (arg.startsWith('--status=')) {
|
|
435
|
-
|
|
443
|
+
const value = arg.slice('--status='.length);
|
|
444
|
+
if (!value) {
|
|
445
|
+
throw new Error(
|
|
446
|
+
`Option --status requires a UUID or session name argument`
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
options.status = value;
|
|
450
|
+
return 1;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// --stop <uuid-or-session-name>
|
|
454
|
+
if (arg === '--stop') {
|
|
455
|
+
if (index + 1 < args.length && !args[index + 1].startsWith('-')) {
|
|
456
|
+
options.stop = args[index + 1];
|
|
457
|
+
return 2;
|
|
458
|
+
} else {
|
|
459
|
+
throw new Error(`Option ${arg} requires a UUID or session name argument`);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// --stop=<value>
|
|
464
|
+
if (arg.startsWith('--stop=')) {
|
|
465
|
+
const value = arg.slice('--stop='.length);
|
|
466
|
+
if (!value) {
|
|
467
|
+
throw new Error(`Option --stop requires a UUID or session name argument`);
|
|
468
|
+
}
|
|
469
|
+
options.stop = value;
|
|
470
|
+
return 1;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// --terminate <uuid-or-session-name>
|
|
474
|
+
if (arg === '--terminate') {
|
|
475
|
+
if (index + 1 < args.length && !args[index + 1].startsWith('-')) {
|
|
476
|
+
options.terminate = args[index + 1];
|
|
477
|
+
return 2;
|
|
478
|
+
} else {
|
|
479
|
+
throw new Error(`Option ${arg} requires a UUID or session name argument`);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// --terminate=<value>
|
|
484
|
+
if (arg.startsWith('--terminate=')) {
|
|
485
|
+
const value = arg.slice('--terminate='.length);
|
|
486
|
+
if (!value) {
|
|
487
|
+
throw new Error(
|
|
488
|
+
`Option --terminate requires a UUID or session name argument`
|
|
489
|
+
);
|
|
490
|
+
}
|
|
491
|
+
options.terminate = value;
|
|
436
492
|
return 1;
|
|
437
493
|
}
|
|
438
494
|
|
|
@@ -656,7 +712,7 @@ function validateOptions(options) {
|
|
|
656
712
|
}
|
|
657
713
|
|
|
658
714
|
// Validate output format
|
|
659
|
-
if (options.outputFormat
|
|
715
|
+
if (hasValue(options.outputFormat)) {
|
|
660
716
|
if (!VALID_OUTPUT_FORMATS.includes(options.outputFormat)) {
|
|
661
717
|
throw new Error(
|
|
662
718
|
`Invalid output format: "${options.outputFormat}". Valid options are: ${VALID_OUTPUT_FORMATS.join(', ')}`
|
|
@@ -664,12 +720,22 @@ function validateOptions(options) {
|
|
|
664
720
|
}
|
|
665
721
|
}
|
|
666
722
|
|
|
667
|
-
//
|
|
668
|
-
|
|
669
|
-
|
|
723
|
+
// Query/control modes are mutually exclusive
|
|
724
|
+
const queryModes = [
|
|
725
|
+
hasValue(options.status),
|
|
726
|
+
options.list,
|
|
727
|
+
hasValue(options.stop),
|
|
728
|
+
hasValue(options.terminate),
|
|
729
|
+
options.cleanup,
|
|
730
|
+
].filter(Boolean).length;
|
|
731
|
+
|
|
732
|
+
if (queryModes > 1) {
|
|
733
|
+
throw new Error(
|
|
734
|
+
'Cannot combine --status, --list, --stop, --terminate, or --cleanup in the same invocation'
|
|
735
|
+
);
|
|
670
736
|
}
|
|
671
737
|
|
|
672
|
-
// Output format is only valid with query modes
|
|
738
|
+
// Output format is only valid with read-only query modes
|
|
673
739
|
if (options.outputFormat && !options.status && !options.list) {
|
|
674
740
|
throw new Error(
|
|
675
741
|
'--output-format option is only valid with --status or --list'
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detached execution control helpers.
|
|
3
|
+
*
|
|
4
|
+
* Maps start-command execution records back to their native isolation backend
|
|
5
|
+
* controls so `--stop` and `--terminate` can target the stored session name.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const { spawnSync } = require('child_process');
|
|
9
|
+
const {
|
|
10
|
+
escapeForLinksNotation,
|
|
11
|
+
formatAsNestedLinksNotation,
|
|
12
|
+
} = require('./output-blocks');
|
|
13
|
+
|
|
14
|
+
const ControlAction = {
|
|
15
|
+
STOP: 'stop',
|
|
16
|
+
TERMINATE: 'terminate',
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
function runCommand(command, args) {
|
|
20
|
+
const result = spawnSync(command, args, {
|
|
21
|
+
encoding: 'utf8',
|
|
22
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
success: !result.error && result.status === 0,
|
|
27
|
+
stdout: result.stdout || '',
|
|
28
|
+
stderr: result.stderr || '',
|
|
29
|
+
status: result.status,
|
|
30
|
+
error: result.error ? result.error.message : null,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function parsePid(value) {
|
|
35
|
+
const pid = Number.parseInt(String(value).trim(), 10);
|
|
36
|
+
return Number.isFinite(pid) && pid > 0 ? pid : null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function parsePids(output) {
|
|
40
|
+
return output
|
|
41
|
+
.split(/\s+/)
|
|
42
|
+
.map(parsePid)
|
|
43
|
+
.filter((pid) => pid !== null);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function parseScreenPid(screenListOutput, sessionName) {
|
|
47
|
+
for (const line of screenListOutput.split('\n')) {
|
|
48
|
+
const firstColumn = line.trim().split(/\s+/)[0] || '';
|
|
49
|
+
const match = firstColumn.match(/^(\d+)\.(.+)$/);
|
|
50
|
+
if (match && match[2] === sessionName) {
|
|
51
|
+
return parsePid(match[1]);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function collectDescendantPids(rootPid, runner = runCommand) {
|
|
58
|
+
const descendants = [];
|
|
59
|
+
const seen = new Set([rootPid]);
|
|
60
|
+
const queue = [rootPid];
|
|
61
|
+
|
|
62
|
+
while (queue.length > 0) {
|
|
63
|
+
const parentPid = queue.shift();
|
|
64
|
+
const result = runner('pgrep', ['-P', String(parentPid)]);
|
|
65
|
+
if (!result.success && !result.stdout) {
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
for (const childPid of parsePids(result.stdout)) {
|
|
70
|
+
if (seen.has(childPid)) {
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
seen.add(childPid);
|
|
74
|
+
descendants.push(childPid);
|
|
75
|
+
queue.push(childPid);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return descendants;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function addIfPresent(target, key, value) {
|
|
83
|
+
if (value !== null && value !== undefined) {
|
|
84
|
+
if (!Array.isArray(value) || value.length > 0) {
|
|
85
|
+
target[key] = value;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function collectProcessIds(record, runner = runCommand) {
|
|
91
|
+
if (!record) {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const processIds = {};
|
|
96
|
+
const opts = record.options || {};
|
|
97
|
+
const sessionName = opts.sessionName;
|
|
98
|
+
const isolated = opts.isolated;
|
|
99
|
+
|
|
100
|
+
addIfPresent(processIds, 'wrapperPid', record.pid);
|
|
101
|
+
|
|
102
|
+
if (!sessionName || !isolated) {
|
|
103
|
+
return Object.keys(processIds).length > 0 ? processIds : null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (isolated === 'screen') {
|
|
107
|
+
const result = runner('screen', ['-ls']);
|
|
108
|
+
const output = `${result.stdout || ''}${result.stderr || ''}`;
|
|
109
|
+
const screenPid = parseScreenPid(output, sessionName);
|
|
110
|
+
addIfPresent(processIds, 'screenPid', screenPid);
|
|
111
|
+
if (screenPid) {
|
|
112
|
+
addIfPresent(
|
|
113
|
+
processIds,
|
|
114
|
+
'commandPids',
|
|
115
|
+
collectDescendantPids(screenPid, runner)
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
} else if (isolated === 'tmux') {
|
|
119
|
+
const tmuxPidResult = runner('tmux', [
|
|
120
|
+
'display-message',
|
|
121
|
+
'-p',
|
|
122
|
+
'-t',
|
|
123
|
+
sessionName,
|
|
124
|
+
'#{pid}',
|
|
125
|
+
]);
|
|
126
|
+
addIfPresent(processIds, 'tmuxPid', parsePid(tmuxPidResult.stdout));
|
|
127
|
+
|
|
128
|
+
const panePidResult = runner('tmux', [
|
|
129
|
+
'list-panes',
|
|
130
|
+
'-t',
|
|
131
|
+
sessionName,
|
|
132
|
+
'-F',
|
|
133
|
+
'#{pane_pid}',
|
|
134
|
+
]);
|
|
135
|
+
const panePids = parsePids(panePidResult.stdout);
|
|
136
|
+
addIfPresent(processIds, 'panePids', panePids);
|
|
137
|
+
|
|
138
|
+
const commandPids = [
|
|
139
|
+
...new Set(
|
|
140
|
+
panePids.flatMap((panePid) => collectDescendantPids(panePid, runner))
|
|
141
|
+
),
|
|
142
|
+
];
|
|
143
|
+
addIfPresent(processIds, 'commandPids', commandPids);
|
|
144
|
+
} else if (isolated === 'docker') {
|
|
145
|
+
addIfPresent(processIds, 'containerId', opts.containerId);
|
|
146
|
+
const result = runner('docker', [
|
|
147
|
+
'inspect',
|
|
148
|
+
'-f',
|
|
149
|
+
'{{.Id}} {{.State.Pid}}',
|
|
150
|
+
sessionName,
|
|
151
|
+
]);
|
|
152
|
+
if (result.success && result.stdout.trim()) {
|
|
153
|
+
const [containerId, pidValue] = result.stdout.trim().split(/\s+/);
|
|
154
|
+
addIfPresent(processIds, 'containerId', containerId);
|
|
155
|
+
addIfPresent(processIds, 'containerPid', parsePid(pidValue));
|
|
156
|
+
}
|
|
157
|
+
} else if (isolated === 'ssh') {
|
|
158
|
+
addIfPresent(processIds, 'remotePid', opts.remotePid);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return Object.keys(processIds).length > 0 ? processIds : null;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function getControlCommand(record, action) {
|
|
165
|
+
const opts = record.options || {};
|
|
166
|
+
const backend = opts.isolated;
|
|
167
|
+
const sessionName = opts.sessionName;
|
|
168
|
+
|
|
169
|
+
if (!sessionName) {
|
|
170
|
+
return {
|
|
171
|
+
error: 'Execution record does not contain an isolation session name.',
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (opts.isolationMode !== 'detached') {
|
|
176
|
+
return {
|
|
177
|
+
error: 'Only detached isolated executions can be stopped or terminated.',
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (action === ControlAction.STOP) {
|
|
182
|
+
switch (backend) {
|
|
183
|
+
case 'screen':
|
|
184
|
+
return {
|
|
185
|
+
command: 'screen',
|
|
186
|
+
args: ['-S', sessionName, '-X', 'stuff', '\x03'],
|
|
187
|
+
method: 'CTRL_C',
|
|
188
|
+
message: `Sent CTRL+C to detached screen session: ${sessionName}`,
|
|
189
|
+
};
|
|
190
|
+
case 'tmux':
|
|
191
|
+
return {
|
|
192
|
+
command: 'tmux',
|
|
193
|
+
args: ['send-keys', '-t', sessionName, 'C-c'],
|
|
194
|
+
method: 'CTRL_C',
|
|
195
|
+
message: `Sent CTRL+C to detached tmux session: ${sessionName}`,
|
|
196
|
+
};
|
|
197
|
+
case 'docker':
|
|
198
|
+
return {
|
|
199
|
+
command: 'docker',
|
|
200
|
+
args: ['kill', '--signal=SIGINT', sessionName],
|
|
201
|
+
method: 'SIGINT',
|
|
202
|
+
message: `Sent SIGINT to detached docker container: ${sessionName}`,
|
|
203
|
+
};
|
|
204
|
+
default:
|
|
205
|
+
return {
|
|
206
|
+
error: `Stopping detached ${backend || 'unknown'} executions is not supported.`,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (action === ControlAction.TERMINATE) {
|
|
212
|
+
switch (backend) {
|
|
213
|
+
case 'screen':
|
|
214
|
+
return {
|
|
215
|
+
command: 'screen',
|
|
216
|
+
args: ['-S', sessionName, '-X', 'quit'],
|
|
217
|
+
method: 'SCREEN_QUIT',
|
|
218
|
+
message: `Terminated detached screen session: ${sessionName}`,
|
|
219
|
+
};
|
|
220
|
+
case 'tmux':
|
|
221
|
+
return {
|
|
222
|
+
command: 'tmux',
|
|
223
|
+
args: ['kill-session', '-t', sessionName],
|
|
224
|
+
method: 'KILL_SESSION',
|
|
225
|
+
message: `Terminated detached tmux session: ${sessionName}`,
|
|
226
|
+
};
|
|
227
|
+
case 'docker':
|
|
228
|
+
return {
|
|
229
|
+
command: 'docker',
|
|
230
|
+
args: ['kill', sessionName],
|
|
231
|
+
method: 'SIGKILL',
|
|
232
|
+
message: `Terminated detached docker container: ${sessionName}`,
|
|
233
|
+
};
|
|
234
|
+
default:
|
|
235
|
+
return {
|
|
236
|
+
error: `Terminating detached ${backend || 'unknown'} executions is not supported.`,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return { error: `Unknown control action: ${action}` };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function formatControlResultAsLinksNotation(result) {
|
|
245
|
+
const lines = [
|
|
246
|
+
'executionControl',
|
|
247
|
+
` action ${escapeForLinksNotation(result.action)}`,
|
|
248
|
+
` identifier ${escapeForLinksNotation(result.identifier)}`,
|
|
249
|
+
` uuid ${escapeForLinksNotation(result.uuid)}`,
|
|
250
|
+
` status ${escapeForLinksNotation(result.status)}`,
|
|
251
|
+
` backend ${escapeForLinksNotation(result.backend)}`,
|
|
252
|
+
` sessionName ${escapeForLinksNotation(result.sessionName)}`,
|
|
253
|
+
` method ${escapeForLinksNotation(result.method)}`,
|
|
254
|
+
];
|
|
255
|
+
|
|
256
|
+
if (result.processIds) {
|
|
257
|
+
lines.push(' processIds');
|
|
258
|
+
lines.push(formatAsNestedLinksNotation(result.processIds, 2, 2));
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
lines.push(` message ${escapeForLinksNotation(result.message)}`);
|
|
262
|
+
return lines.join('\n');
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function controlExecution(store, identifier, action, runner = runCommand) {
|
|
266
|
+
if (!store) {
|
|
267
|
+
return { success: false, error: 'Execution tracking is disabled.' };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const record = store.get(identifier);
|
|
271
|
+
if (!record) {
|
|
272
|
+
return {
|
|
273
|
+
success: false,
|
|
274
|
+
error: `No execution found with UUID or session name: ${identifier}`,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const control = getControlCommand(record, action);
|
|
279
|
+
if (control.error) {
|
|
280
|
+
return { success: false, error: control.error };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const result = runner(control.command, control.args);
|
|
284
|
+
if (!result.success) {
|
|
285
|
+
const detail =
|
|
286
|
+
result.stderr || result.error || `exit code ${result.status}`;
|
|
287
|
+
return {
|
|
288
|
+
success: false,
|
|
289
|
+
error: `Failed to ${action} ${record.options.isolated} session "${record.options.sessionName}": ${detail}`,
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const output = formatControlResultAsLinksNotation({
|
|
294
|
+
action,
|
|
295
|
+
identifier,
|
|
296
|
+
uuid: record.uuid,
|
|
297
|
+
status: action === ControlAction.STOP ? 'signal-sent' : 'terminated',
|
|
298
|
+
backend: record.options.isolated,
|
|
299
|
+
sessionName: record.options.sessionName,
|
|
300
|
+
method: control.method,
|
|
301
|
+
processIds: collectProcessIds(record, runner),
|
|
302
|
+
message: control.message,
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
return { success: true, output };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
module.exports = {
|
|
309
|
+
ControlAction,
|
|
310
|
+
collectDescendantPids,
|
|
311
|
+
collectProcessIds,
|
|
312
|
+
controlExecution,
|
|
313
|
+
formatControlResultAsLinksNotation,
|
|
314
|
+
getControlCommand,
|
|
315
|
+
parseScreenPid,
|
|
316
|
+
runCommand,
|
|
317
|
+
};
|
package/src/lib/isolation.js
CHANGED
|
@@ -545,9 +545,13 @@ function runInDocker(command, options = {}) {
|
|
|
545
545
|
|
|
546
546
|
try {
|
|
547
547
|
if (options.detached) {
|
|
548
|
-
const dockerArgs = ['run', '-d'
|
|
548
|
+
const dockerArgs = ['run', '-d'];
|
|
549
|
+
if (options.keepAlive) {
|
|
550
|
+
dockerArgs.push('-i', '-t');
|
|
551
|
+
}
|
|
552
|
+
dockerArgs.push('--name', containerName);
|
|
549
553
|
if (options.autoRemoveDockerContainer) {
|
|
550
|
-
dockerArgs.
|
|
554
|
+
dockerArgs.push('--rm');
|
|
551
555
|
}
|
|
552
556
|
|
|
553
557
|
if (options.user) {
|
|
@@ -576,9 +580,23 @@ function runInDocker(command, options = {}) {
|
|
|
576
580
|
);
|
|
577
581
|
}
|
|
578
582
|
|
|
579
|
-
const
|
|
583
|
+
const dockerResult = spawnSync('docker', dockerArgs, {
|
|
580
584
|
encoding: 'utf8',
|
|
581
|
-
|
|
585
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
if (dockerResult.error) {
|
|
589
|
+
throw dockerResult.error;
|
|
590
|
+
}
|
|
591
|
+
if (dockerResult.status !== 0) {
|
|
592
|
+
const dockerError =
|
|
593
|
+
dockerResult.stderr.trim() ||
|
|
594
|
+
dockerResult.stdout.trim() ||
|
|
595
|
+
`docker exited with code ${dockerResult.status}`;
|
|
596
|
+
throw new Error(dockerError);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
const containerId = dockerResult.stdout.trim();
|
|
582
600
|
|
|
583
601
|
if (options.logPath) {
|
|
584
602
|
const loggerScript = [
|
|
@@ -13,6 +13,7 @@ const {
|
|
|
13
13
|
escapeForLinksNotation,
|
|
14
14
|
formatAsNestedLinksNotation,
|
|
15
15
|
} = require('./output-blocks');
|
|
16
|
+
const { collectProcessIds } = require('./execution-control');
|
|
16
17
|
|
|
17
18
|
/**
|
|
18
19
|
* Check if a detached isolation session is still running
|
|
@@ -165,6 +166,47 @@ function attachCurrentTime(record) {
|
|
|
165
166
|
return wrapped;
|
|
166
167
|
}
|
|
167
168
|
|
|
169
|
+
/**
|
|
170
|
+
* Wrap a record so its serialized form includes best-effort process IDs.
|
|
171
|
+
* The original record is not mutated.
|
|
172
|
+
* @param {Object} record - Execution record
|
|
173
|
+
* @returns {Object} Record-like object with processIds in toObject()
|
|
174
|
+
*/
|
|
175
|
+
function attachProcessIds(record) {
|
|
176
|
+
const processIds = collectProcessIds(record);
|
|
177
|
+
if (!processIds) {
|
|
178
|
+
return record;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const wrapped = Object.create(Object.getPrototypeOf(record));
|
|
182
|
+
Object.assign(wrapped, record);
|
|
183
|
+
wrapped.processIds = processIds;
|
|
184
|
+
|
|
185
|
+
const sourceToObject =
|
|
186
|
+
typeof record.toObject === 'function'
|
|
187
|
+
? () => record.toObject()
|
|
188
|
+
: () => ({ ...record });
|
|
189
|
+
|
|
190
|
+
wrapped.toObject = function () {
|
|
191
|
+
const base = sourceToObject();
|
|
192
|
+
const ordered = {};
|
|
193
|
+
let inserted = false;
|
|
194
|
+
for (const [key, value] of Object.entries(base)) {
|
|
195
|
+
ordered[key] = value;
|
|
196
|
+
if (key === 'pid') {
|
|
197
|
+
ordered.processIds = processIds;
|
|
198
|
+
inserted = true;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
if (!inserted) {
|
|
202
|
+
ordered.processIds = processIds;
|
|
203
|
+
}
|
|
204
|
+
return ordered;
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
return wrapped;
|
|
208
|
+
}
|
|
209
|
+
|
|
168
210
|
/**
|
|
169
211
|
* Format execution record as Links Notation (indented style)
|
|
170
212
|
* Uses nested Links notation for object values (like options) instead of JSON
|
|
@@ -288,7 +330,7 @@ function indentBlock(block, spaces) {
|
|
|
288
330
|
}
|
|
289
331
|
|
|
290
332
|
function prepareRecordForList(record) {
|
|
291
|
-
return attachCurrentTime(enrichDetachedStatus(record));
|
|
333
|
+
return attachCurrentTime(attachProcessIds(enrichDetachedStatus(record)));
|
|
292
334
|
}
|
|
293
335
|
|
|
294
336
|
/**
|
|
@@ -374,8 +416,9 @@ function queryStatus(store, identifier, outputFormat) {
|
|
|
374
416
|
try {
|
|
375
417
|
// Enrich detached execution status with live session check
|
|
376
418
|
const enrichedRecord = enrichDetachedStatus(record);
|
|
419
|
+
const withProcessIds = attachProcessIds(enrichedRecord);
|
|
377
420
|
// Attach currentTime so callers can see how long an executing command has been running
|
|
378
|
-
const withCurrentTime = attachCurrentTime(
|
|
421
|
+
const withCurrentTime = attachCurrentTime(withProcessIds);
|
|
379
422
|
return {
|
|
380
423
|
success: true,
|
|
381
424
|
output: formatRecord(withCurrentTime, outputFormat || 'links-notation'),
|
|
@@ -421,4 +464,5 @@ module.exports = {
|
|
|
421
464
|
isDetachedSessionAlive,
|
|
422
465
|
enrichDetachedStatus,
|
|
423
466
|
attachCurrentTime,
|
|
467
|
+
attachProcessIds,
|
|
424
468
|
};
|