start-command 0.25.5 → 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 +17 -1
- package/README.md +2 -1
- package/bunfig.toml +2 -2
- package/eslint.config.mjs +1 -1
- package/package.json +2 -2
- package/src/bin/cli.js +48 -67
- package/src/lib/args-parser.js +88 -7
- package/src/lib/execution-control.js +317 -0
- package/src/lib/isolation.js +22 -4
- package/src/lib/status-formatter.js +153 -1
- package/src/lib/usage.js +74 -0
- package/test/args-parser-control.js +71 -0
- package/test/{args-parser.test.js → args-parser.js} +28 -4
- 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} +73 -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,21 @@
|
|
|
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
|
+
|
|
13
|
+
## 0.26.0
|
|
14
|
+
|
|
15
|
+
### Minor Changes
|
|
16
|
+
|
|
17
|
+
- 01fbd90: Add `--list` to show all tracked command executions in Links Notation, JSON, or text format.
|
|
18
|
+
|
|
3
19
|
## 0.25.5
|
|
4
20
|
|
|
5
21
|
### Patch Changes
|
|
@@ -9,7 +25,7 @@
|
|
|
9
25
|
releases are now tagged `js-v<version>` with title `[JavaScript] <version>`,
|
|
10
26
|
matching the `rust-v<version>` / `[Rust] <version>` convention needed for the
|
|
11
27
|
mono-repo. Extracted the tag/title construction into `scripts/release-name.mjs`
|
|
12
|
-
with unit tests in `test/release-name.
|
|
28
|
+
with unit tests in `test/release-name.mjs`. See `docs/case-studies/issue-108/`
|
|
13
29
|
for the full analysis.
|
|
14
30
|
|
|
15
31
|
## 0.25.4
|
package/README.md
CHANGED
|
@@ -21,6 +21,7 @@ npm install -g start-command
|
|
|
21
21
|
$ ls -la
|
|
22
22
|
$ npm test
|
|
23
23
|
$ git status
|
|
24
|
+
$ --list
|
|
24
25
|
```
|
|
25
26
|
|
|
26
27
|
See the project-wide [README](../README.md), [docs/USAGE.md](../docs/USAGE.md),
|
|
@@ -31,7 +32,7 @@ and [docs/PIPES.md](../docs/PIPES.md) for the full user-facing guide.
|
|
|
31
32
|
```bash
|
|
32
33
|
cd js
|
|
33
34
|
bun install
|
|
34
|
-
bun test
|
|
35
|
+
bun run test
|
|
35
36
|
bun run lint
|
|
36
37
|
```
|
|
37
38
|
|
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
|
@@ -31,10 +31,12 @@ const {
|
|
|
31
31
|
} = require('../lib/user-manager');
|
|
32
32
|
const { handleFailure } = require('../lib/failure-handler');
|
|
33
33
|
const { ExecutionStore, ExecutionRecord } = require('../lib/execution-store');
|
|
34
|
-
const { queryStatus } = require('../lib/status-formatter');
|
|
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');
|
|
39
|
+
const { printUsage } = require('../lib/usage');
|
|
38
40
|
|
|
39
41
|
// Configuration from environment variables
|
|
40
42
|
const config = {
|
|
@@ -193,6 +195,27 @@ if (wrapperOptions.status) {
|
|
|
193
195
|
process.exit(0);
|
|
194
196
|
}
|
|
195
197
|
|
|
198
|
+
// Handle --list flag
|
|
199
|
+
if (wrapperOptions.list) {
|
|
200
|
+
handleListQuery(wrapperOptions.outputFormat);
|
|
201
|
+
process.exit(0);
|
|
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
|
+
|
|
196
219
|
// Handle --cleanup flag
|
|
197
220
|
if (wrapperOptions.cleanup) {
|
|
198
221
|
handleCleanup(wrapperOptions.cleanupDryRun);
|
|
@@ -267,6 +290,26 @@ function handleStatusQuery(uuid, outputFormat) {
|
|
|
267
290
|
}
|
|
268
291
|
}
|
|
269
292
|
|
|
293
|
+
function handleListQuery(outputFormat) {
|
|
294
|
+
const result = listExecutions(getExecutionStore(), outputFormat);
|
|
295
|
+
if (result.success) {
|
|
296
|
+
console.log(result.output);
|
|
297
|
+
} else {
|
|
298
|
+
console.error(`Error: ${result.error}`);
|
|
299
|
+
process.exit(1);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
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
|
+
|
|
270
313
|
/**
|
|
271
314
|
* Handle --cleanup flag
|
|
272
315
|
* Cleans up stale "executing" records (processes that crashed or were killed)
|
|
@@ -314,72 +357,6 @@ function handleCleanup(dryRun) {
|
|
|
314
357
|
}
|
|
315
358
|
}
|
|
316
359
|
|
|
317
|
-
/** Print usage information */
|
|
318
|
-
function printUsage() {
|
|
319
|
-
console.log(`Usage: $ [options] [--] <command> | $ --status <uuid> [--output-format <fmt>]
|
|
320
|
-
|
|
321
|
-
Options:
|
|
322
|
-
--isolated, -i <env> Run in isolated environment (screen, tmux, docker, ssh)
|
|
323
|
-
--attached, -a Run in attached mode (foreground)
|
|
324
|
-
--detached, -d Run in detached mode (background)
|
|
325
|
-
--session, -s <name> Session name for isolation
|
|
326
|
-
--session-id <uuid> Session UUID for tracking (auto-generated if not provided)
|
|
327
|
-
--session-name <uuid> Alias for --session-id
|
|
328
|
-
--image <image> Docker image (required for docker isolation)
|
|
329
|
-
--endpoint <endpoint> SSH endpoint (required for ssh isolation, e.g., user@host)
|
|
330
|
-
--isolated-user, -u [name] Create isolated user with same permissions
|
|
331
|
-
--keep-user Keep isolated user after command completes
|
|
332
|
-
--keep-alive, -k Keep isolation environment alive after command exits
|
|
333
|
-
--auto-remove-docker-container Auto-remove docker container after exit
|
|
334
|
-
--shell <shell> Shell to use in isolation environments: auto, bash, zsh, sh (default: auto)
|
|
335
|
-
--use-command-stream Use command-stream library for execution (experimental)
|
|
336
|
-
--status <id> Show status of execution by UUID or session name (--output-format: links-notation|json|text)
|
|
337
|
-
--cleanup Clean up stale "executing" records (crashed/killed processes)
|
|
338
|
-
--cleanup-dry-run Show stale records that would be cleaned up (without cleaning)
|
|
339
|
-
--version, -v Show version information
|
|
340
|
-
|
|
341
|
-
Examples:
|
|
342
|
-
$ echo "Hello World"
|
|
343
|
-
$ bun test
|
|
344
|
-
$ --isolated tmux -- bun start
|
|
345
|
-
$ -i screen -d bun start
|
|
346
|
-
$ --isolated docker --image oven/bun:latest -- bun install
|
|
347
|
-
$ --isolated ssh --endpoint user@remote.server -- ls -la
|
|
348
|
-
$ --isolated-user -- npm test # Create isolated user
|
|
349
|
-
$ -u myuser -- npm start # Custom username
|
|
350
|
-
$ -i screen --isolated-user -- npm test # Combine with process isolation
|
|
351
|
-
$ --isolated-user --keep-user -- npm start
|
|
352
|
-
$ --use-command-stream echo "Hello" # Use command-stream library`);
|
|
353
|
-
console.log('');
|
|
354
|
-
console.log('Piping with $:');
|
|
355
|
-
console.log(' echo "hi" | $ agent # Preferred - pipe TO $ command');
|
|
356
|
-
console.log(
|
|
357
|
-
' $ \'echo "hi" | agent\' # Alternative - quote entire pipeline'
|
|
358
|
-
);
|
|
359
|
-
console.log('');
|
|
360
|
-
console.log('Quoting for special characters:');
|
|
361
|
-
console.log(" $ 'npm test && npm build' # Wrap for logical operators");
|
|
362
|
-
console.log(" $ 'cat file > output.txt' # Wrap for redirections");
|
|
363
|
-
console.log('');
|
|
364
|
-
console.log('Features:');
|
|
365
|
-
console.log(' - Logs all output to temporary directory');
|
|
366
|
-
console.log(' - Displays timestamps and exit codes');
|
|
367
|
-
console.log(
|
|
368
|
-
' - Auto-reports failures for NPM packages (when gh is available)'
|
|
369
|
-
);
|
|
370
|
-
console.log(' - Natural language command aliases (via substitutions.lino)');
|
|
371
|
-
console.log(' - Process isolation via screen, tmux, or docker');
|
|
372
|
-
console.log('');
|
|
373
|
-
console.log('Alias examples:');
|
|
374
|
-
console.log(' $ install lodash npm package -> npm install lodash');
|
|
375
|
-
console.log(
|
|
376
|
-
' $ install 4.17.21 version of lodash npm package -> npm install lodash@4.17.21'
|
|
377
|
-
);
|
|
378
|
-
console.log(
|
|
379
|
-
' $ clone https://github.com/user/repo repository -> git clone https://github.com/user/repo'
|
|
380
|
-
);
|
|
381
|
-
}
|
|
382
|
-
|
|
383
360
|
/**
|
|
384
361
|
* Run command in isolation mode
|
|
385
362
|
* @param {object} options - Wrapper options
|
|
@@ -494,6 +471,7 @@ async function runWithIsolation(
|
|
|
494
471
|
uuid: sessionId, // Use the provided session ID
|
|
495
472
|
command: cmd,
|
|
496
473
|
logPath: logFilePath,
|
|
474
|
+
pid: process.pid,
|
|
497
475
|
shell,
|
|
498
476
|
workingDirectory: process.cwd(),
|
|
499
477
|
options: {
|
|
@@ -605,6 +583,9 @@ async function runWithIsolation(
|
|
|
605
583
|
|
|
606
584
|
// Update execution record: detached keeps "executing" (resolved at query time)
|
|
607
585
|
if (executionRecord && store) {
|
|
586
|
+
if (result.containerId) {
|
|
587
|
+
executionRecord.options.containerId = result.containerId;
|
|
588
|
+
}
|
|
608
589
|
if (mode !== 'detached') {
|
|
609
590
|
executionRecord.complete(exitCode);
|
|
610
591
|
}
|
package/src/lib/args-parser.js
CHANGED
|
@@ -20,7 +20,10 @@
|
|
|
20
20
|
* --use-command-stream Use command-stream library for command execution (experimental)
|
|
21
21
|
* --verbose Enable verbose/debug output (sets START_VERBOSE=1)
|
|
22
22
|
* --status <uuid> Show status of a previous command execution by UUID
|
|
23
|
-
* --
|
|
23
|
+
* --list List all tracked command executions
|
|
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
|
|
24
27
|
* --cleanup Clean up stale "executing" records (processes that crashed or were killed)
|
|
25
28
|
* --cleanup-dry-run Show stale records that would be cleaned up (without actually cleaning)
|
|
26
29
|
*/
|
|
@@ -52,6 +55,10 @@ const MAX_ISOLATION_DEPTH = 7;
|
|
|
52
55
|
*/
|
|
53
56
|
const VALID_OUTPUT_FORMATS = ['links-notation', 'json', 'text'];
|
|
54
57
|
|
|
58
|
+
function hasValue(value) {
|
|
59
|
+
return value !== null && value !== undefined;
|
|
60
|
+
}
|
|
61
|
+
|
|
55
62
|
/**
|
|
56
63
|
* UUID v4 regex pattern for validation
|
|
57
64
|
*/
|
|
@@ -169,7 +176,10 @@ function parseArgs(args) {
|
|
|
169
176
|
shell: 'auto', // Shell to use in isolation environments: auto, bash, zsh, sh
|
|
170
177
|
useCommandStream: false, // Use command-stream library for command execution
|
|
171
178
|
status: null, // UUID to show status for
|
|
172
|
-
|
|
179
|
+
list: false, // List all tracked execution records
|
|
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
|
|
173
183
|
cleanup: false, // Clean up stale "executing" records
|
|
174
184
|
cleanupDryRun: false, // Show what would be cleaned without actually cleaning
|
|
175
185
|
};
|
|
@@ -430,7 +440,61 @@ function parseOption(args, index, options) {
|
|
|
430
440
|
|
|
431
441
|
// --status=<value>
|
|
432
442
|
if (arg.startsWith('--status=')) {
|
|
433
|
-
|
|
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;
|
|
492
|
+
return 1;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// --list
|
|
496
|
+
if (arg === '--list') {
|
|
497
|
+
options.list = true;
|
|
434
498
|
return 1;
|
|
435
499
|
}
|
|
436
500
|
|
|
@@ -648,7 +712,7 @@ function validateOptions(options) {
|
|
|
648
712
|
}
|
|
649
713
|
|
|
650
714
|
// Validate output format
|
|
651
|
-
if (options.outputFormat
|
|
715
|
+
if (hasValue(options.outputFormat)) {
|
|
652
716
|
if (!VALID_OUTPUT_FORMATS.includes(options.outputFormat)) {
|
|
653
717
|
throw new Error(
|
|
654
718
|
`Invalid output format: "${options.outputFormat}". Valid options are: ${VALID_OUTPUT_FORMATS.join(', ')}`
|
|
@@ -656,9 +720,26 @@ function validateOptions(options) {
|
|
|
656
720
|
}
|
|
657
721
|
}
|
|
658
722
|
|
|
659
|
-
//
|
|
660
|
-
|
|
661
|
-
|
|
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
|
+
);
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
// Output format is only valid with read-only query modes
|
|
739
|
+
if (options.outputFormat && !options.status && !options.list) {
|
|
740
|
+
throw new Error(
|
|
741
|
+
'--output-format option is only valid with --status or --list'
|
|
742
|
+
);
|
|
662
743
|
}
|
|
663
744
|
|
|
664
745
|
// Validate shell option
|
|
@@ -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
|
+
};
|