start-command 0.27.2 → 0.29.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 +14 -0
- package/package.json +1 -1
- package/src/bin/cli.js +25 -0
- package/src/lib/args-parser.js +134 -9
- package/src/lib/command-builder.js +17 -0
- package/src/lib/isolation.js +72 -1
- package/src/lib/log-uploader.js +192 -0
- package/src/lib/usage.js +10 -2
- package/test/args-parser-control.js +7 -1
- package/test/args-parser-isolation-alias.js +41 -0
- package/test/args-parser.js +30 -0
- package/test/docker-runtime-options.js +245 -0
- package/test/status-query.js +103 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# start-command
|
|
2
2
|
|
|
3
|
+
## 0.29.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- Add Docker isolation runtime controls: `--volume`/`-v`, `--mount`, `--env`/`-e`, and `--privileged`. These are threaded into the underlying `docker run` invocation and recorded in `--status`/`--list` metadata, allowing callers to mount tool credentials, pass environment variables, and run Docker-in-Docker images without wrapping `docker run` themselves.
|
|
8
|
+
|
|
9
|
+
Add `--isolation` as an alias for `--isolated` and fail fast on unknown wrapper options.
|
|
10
|
+
|
|
11
|
+
## 0.28.0
|
|
12
|
+
|
|
13
|
+
### Minor Changes
|
|
14
|
+
|
|
15
|
+
- 38d1fe4: Add `--upload-log <id>` to upload a stored execution log with `gh-upload-log`, installing the uploader on demand when it is missing.
|
|
16
|
+
|
|
3
17
|
## 0.27.2
|
|
4
18
|
|
|
5
19
|
### Patch Changes
|
package/package.json
CHANGED
package/src/bin/cli.js
CHANGED
|
@@ -22,6 +22,8 @@ const {
|
|
|
22
22
|
appendLogFile,
|
|
23
23
|
createLogPath,
|
|
24
24
|
getDefaultDockerImage,
|
|
25
|
+
buildDockerRuntimeStatusLines,
|
|
26
|
+
buildDockerRuntimeMetadata,
|
|
25
27
|
} = require('../lib/isolation');
|
|
26
28
|
const {
|
|
27
29
|
createIsolatedUser,
|
|
@@ -33,6 +35,7 @@ const { handleFailure } = require('../lib/failure-handler');
|
|
|
33
35
|
const { ExecutionStore, ExecutionRecord } = require('../lib/execution-store');
|
|
34
36
|
const { queryStatus, listExecutions } = require('../lib/status-formatter');
|
|
35
37
|
const { ControlAction, controlExecution } = require('../lib/execution-control');
|
|
38
|
+
const { uploadExecutionLog } = require('../lib/log-uploader');
|
|
36
39
|
const { printVersion } = require('../lib/version');
|
|
37
40
|
const { createStartBlock, createFinishBlock } = require('../lib/output-blocks');
|
|
38
41
|
const { runWithBunSpawn, runWithNodeSpawn } = require('../lib/spawn-helpers');
|
|
@@ -201,6 +204,12 @@ if (wrapperOptions.list) {
|
|
|
201
204
|
process.exit(0);
|
|
202
205
|
}
|
|
203
206
|
|
|
207
|
+
// Handle --upload-log flag
|
|
208
|
+
if (wrapperOptions.uploadLog) {
|
|
209
|
+
const exitCode = handleUploadLogQuery(wrapperOptions.uploadLog);
|
|
210
|
+
process.exit(exitCode);
|
|
211
|
+
}
|
|
212
|
+
|
|
204
213
|
// Handle --stop flag
|
|
205
214
|
if (wrapperOptions.stop !== null && wrapperOptions.stop !== undefined) {
|
|
206
215
|
handleControlQuery(wrapperOptions.stop, ControlAction.STOP);
|
|
@@ -300,6 +309,16 @@ function handleListQuery(outputFormat) {
|
|
|
300
309
|
}
|
|
301
310
|
}
|
|
302
311
|
|
|
312
|
+
function handleUploadLogQuery(identifier) {
|
|
313
|
+
const result = uploadExecutionLog(getExecutionStore(), identifier);
|
|
314
|
+
if (result.success) {
|
|
315
|
+
return result.exitCode || 0;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
console.error(`Error: ${result.error}`);
|
|
319
|
+
return result.exitCode || 1;
|
|
320
|
+
}
|
|
321
|
+
|
|
303
322
|
function handleControlQuery(identifier, action) {
|
|
304
323
|
const result = controlExecution(getExecutionStore(), identifier, action);
|
|
305
324
|
if (result.success) {
|
|
@@ -453,6 +472,7 @@ async function runWithIsolation(
|
|
|
453
472
|
if (effectiveImage) {
|
|
454
473
|
extraLines.push(`[Isolation] Image: ${effectiveImage}`);
|
|
455
474
|
}
|
|
475
|
+
extraLines.push(...buildDockerRuntimeStatusLines(options));
|
|
456
476
|
if (options.endpoint) {
|
|
457
477
|
extraLines.push(`[Isolation] Endpoint: ${options.endpoint}`);
|
|
458
478
|
}
|
|
@@ -479,6 +499,7 @@ async function runWithIsolation(
|
|
|
479
499
|
isolationMode: mode,
|
|
480
500
|
sessionName,
|
|
481
501
|
image: effectiveImage,
|
|
502
|
+
...buildDockerRuntimeMetadata(options),
|
|
482
503
|
endpoint: options.endpoint,
|
|
483
504
|
user: options.user,
|
|
484
505
|
keepAlive: options.keepAlive,
|
|
@@ -553,6 +574,10 @@ async function runWithIsolation(
|
|
|
553
574
|
user: createdUser,
|
|
554
575
|
keepAlive: options.keepAlive,
|
|
555
576
|
autoRemoveDockerContainer: options.autoRemoveDockerContainer,
|
|
577
|
+
volumes: options.volumes,
|
|
578
|
+
mounts: options.mounts,
|
|
579
|
+
env: options.env,
|
|
580
|
+
privileged: options.privileged,
|
|
556
581
|
shell: options.shell,
|
|
557
582
|
logPath: logFilePath,
|
|
558
583
|
});
|
package/src/lib/args-parser.js
CHANGED
|
@@ -6,11 +6,15 @@
|
|
|
6
6
|
* 2. $ [wrapper-options] command [command-options]
|
|
7
7
|
*
|
|
8
8
|
* Wrapper Options:
|
|
9
|
-
* --isolated, -i <backend>
|
|
9
|
+
* --isolated, --isolation, -i <backend> Run in isolated environment (screen, tmux, docker, ssh)
|
|
10
10
|
* --attached, -a Run in attached mode (foreground)
|
|
11
11
|
* --detached, -d Run in detached mode (background)
|
|
12
12
|
* --session, -s <name> Session name for isolation
|
|
13
13
|
* --image <image> Docker image (optional, defaults to OS-matched image)
|
|
14
|
+
* --volume, -v <host:container[:mode]> Docker bind mount/volume (repeatable, docker only)
|
|
15
|
+
* --mount <mount-spec> Docker --mount spec (repeatable, docker only)
|
|
16
|
+
* --env, -e <KEY=VALUE> Environment variable for docker container (repeatable, docker only)
|
|
17
|
+
* --privileged Run docker container in privileged mode (docker only)
|
|
14
18
|
* --endpoint <endpoint> SSH endpoint (required for ssh isolation, e.g., user@host)
|
|
15
19
|
* --isolated-user, -u [username] Create isolated user with same permissions (auto-generated name if not specified)
|
|
16
20
|
* --keep-user Keep isolated user after command completes (don't delete)
|
|
@@ -21,6 +25,7 @@
|
|
|
21
25
|
* --verbose Enable verbose/debug output (sets START_VERBOSE=1)
|
|
22
26
|
* --status <uuid> Show status of a previous command execution by UUID
|
|
23
27
|
* --list List all tracked command executions
|
|
28
|
+
* --upload-log <uuid-or-session> Upload the stored log for a tracked execution
|
|
24
29
|
* --output-format <format> Output format for status/list (links-notation, json, text)
|
|
25
30
|
* --stop <uuid-or-session-name> Send CTRL+C/SIGINT to a detached execution
|
|
26
31
|
* --terminate <uuid-or-session-name> Terminate a detached execution immediately
|
|
@@ -166,6 +171,10 @@ function parseArgs(args) {
|
|
|
166
171
|
sessionId: null, // Session ID (UUID) for tracking - auto-generated if not provided
|
|
167
172
|
image: null, // Docker image (current level)
|
|
168
173
|
imageStack: null, // Docker images for each level (with nulls for non-docker levels)
|
|
174
|
+
volumes: [], // Docker bind mounts/volumes (-v/--volume), applied to docker levels
|
|
175
|
+
mounts: [], // Docker --mount specs, applied to docker levels
|
|
176
|
+
env: [], // Docker environment variables (-e/--env, KEY=VALUE), applied to docker levels
|
|
177
|
+
privileged: false, // Run docker container in privileged mode
|
|
169
178
|
endpoint: null, // SSH endpoint (current level, e.g., user@host)
|
|
170
179
|
endpointStack: null, // SSH endpoints for each level (with nulls for non-ssh levels)
|
|
171
180
|
user: false, // Create isolated user
|
|
@@ -177,6 +186,7 @@ function parseArgs(args) {
|
|
|
177
186
|
useCommandStream: false, // Use command-stream library for command execution
|
|
178
187
|
status: null, // UUID to show status for
|
|
179
188
|
list: false, // List all tracked execution records
|
|
189
|
+
uploadLog: null, // UUID/session name whose stored log should be uploaded
|
|
180
190
|
outputFormat: null, // Output format for status/list (links-notation, json, text)
|
|
181
191
|
stop: null, // UUID/session name to stop gracefully
|
|
182
192
|
terminate: null, // UUID/session name to terminate immediately
|
|
@@ -204,9 +214,7 @@ function parseArgs(args) {
|
|
|
204
214
|
if (arg.startsWith('-')) {
|
|
205
215
|
const consumed = parseOption(args, i, wrapperOptions);
|
|
206
216
|
if (consumed === 0) {
|
|
207
|
-
|
|
208
|
-
commandArgs = args.slice(i);
|
|
209
|
-
break;
|
|
217
|
+
throw new Error(`Unknown wrapper option: ${arg}`);
|
|
210
218
|
}
|
|
211
219
|
i += consumed;
|
|
212
220
|
} else {
|
|
@@ -237,6 +245,9 @@ function parseWrapperArgs(args, options) {
|
|
|
237
245
|
while (i < args.length) {
|
|
238
246
|
const consumed = parseOption(args, i, options);
|
|
239
247
|
if (consumed === 0) {
|
|
248
|
+
if (args[i].startsWith('-')) {
|
|
249
|
+
throw new Error(`Unknown wrapper option: ${args[i]}`);
|
|
250
|
+
}
|
|
240
251
|
if (DEBUG) {
|
|
241
252
|
console.warn(`Unknown wrapper option: ${args[i]}`);
|
|
242
253
|
}
|
|
@@ -257,8 +268,8 @@ function parseWrapperArgs(args, options) {
|
|
|
257
268
|
function parseOption(args, index, options) {
|
|
258
269
|
const arg = args[index];
|
|
259
270
|
|
|
260
|
-
// --isolated or -i
|
|
261
|
-
if (arg === '--isolated' || arg === '-i') {
|
|
271
|
+
// --isolated, --isolation, or -i
|
|
272
|
+
if (arg === '--isolated' || arg === '--isolation' || arg === '-i') {
|
|
262
273
|
if (index + 1 < args.length && !args[index + 1].startsWith('-')) {
|
|
263
274
|
const value = args[index + 1];
|
|
264
275
|
parseIsolatedValue(value, options);
|
|
@@ -270,8 +281,8 @@ function parseOption(args, index, options) {
|
|
|
270
281
|
}
|
|
271
282
|
}
|
|
272
283
|
|
|
273
|
-
// --isolated=<value>
|
|
274
|
-
if (arg.startsWith('--isolated=')) {
|
|
284
|
+
// --isolated=<value> or --isolation=<value>
|
|
285
|
+
if (arg.startsWith('--isolated=') || arg.startsWith('--isolation=')) {
|
|
275
286
|
const value = arg.split('=')[1];
|
|
276
287
|
parseIsolatedValue(value, options);
|
|
277
288
|
return 1;
|
|
@@ -323,6 +334,62 @@ function parseOption(args, index, options) {
|
|
|
323
334
|
return 1;
|
|
324
335
|
}
|
|
325
336
|
|
|
337
|
+
// --volume or -v (for docker) - repeatable bind mount / volume
|
|
338
|
+
if (arg === '--volume' || arg === '-v') {
|
|
339
|
+
if (index + 1 < args.length && !args[index + 1].startsWith('-')) {
|
|
340
|
+
options.volumes.push(args[index + 1]);
|
|
341
|
+
return 2;
|
|
342
|
+
} else {
|
|
343
|
+
throw new Error(
|
|
344
|
+
`Option ${arg} requires a volume argument (host:container[:mode])`
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// --volume=<value> or -v=<value>
|
|
350
|
+
if (arg.startsWith('--volume=') || arg.startsWith('-v=')) {
|
|
351
|
+
options.volumes.push(arg.slice(arg.indexOf('=') + 1));
|
|
352
|
+
return 1;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// --mount (for docker) - repeatable mount spec
|
|
356
|
+
if (arg === '--mount') {
|
|
357
|
+
if (index + 1 < args.length && !args[index + 1].startsWith('-')) {
|
|
358
|
+
options.mounts.push(args[index + 1]);
|
|
359
|
+
return 2;
|
|
360
|
+
} else {
|
|
361
|
+
throw new Error(`Option ${arg} requires a mount spec argument`);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// --mount=<value>
|
|
366
|
+
if (arg.startsWith('--mount=')) {
|
|
367
|
+
options.mounts.push(arg.slice('--mount='.length));
|
|
368
|
+
return 1;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// --env or -e (for docker) - repeatable environment variable
|
|
372
|
+
if (arg === '--env' || arg === '-e') {
|
|
373
|
+
if (index + 1 < args.length && !args[index + 1].startsWith('-')) {
|
|
374
|
+
options.env.push(args[index + 1]);
|
|
375
|
+
return 2;
|
|
376
|
+
} else {
|
|
377
|
+
throw new Error(`Option ${arg} requires a KEY=VALUE argument`);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// --env=<value> or -e=<value>
|
|
382
|
+
if (arg.startsWith('--env=') || arg.startsWith('-e=')) {
|
|
383
|
+
options.env.push(arg.slice(arg.indexOf('=') + 1));
|
|
384
|
+
return 1;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// --privileged (for docker)
|
|
388
|
+
if (arg === '--privileged') {
|
|
389
|
+
options.privileged = true;
|
|
390
|
+
return 1;
|
|
391
|
+
}
|
|
392
|
+
|
|
326
393
|
// --endpoint (for ssh) - supports sequence for stacked isolation
|
|
327
394
|
if (arg === '--endpoint') {
|
|
328
395
|
if (index + 1 < args.length && !args[index + 1].startsWith('-')) {
|
|
@@ -450,6 +517,28 @@ function parseOption(args, index, options) {
|
|
|
450
517
|
return 1;
|
|
451
518
|
}
|
|
452
519
|
|
|
520
|
+
// --upload-log <uuid-or-session-name>
|
|
521
|
+
if (arg === '--upload-log') {
|
|
522
|
+
if (index + 1 < args.length && !args[index + 1].startsWith('-')) {
|
|
523
|
+
options.uploadLog = args[index + 1];
|
|
524
|
+
return 2;
|
|
525
|
+
} else {
|
|
526
|
+
throw new Error(`Option ${arg} requires a UUID or session name argument`);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// --upload-log=<value>
|
|
531
|
+
if (arg.startsWith('--upload-log=')) {
|
|
532
|
+
const value = arg.slice('--upload-log='.length);
|
|
533
|
+
if (!value) {
|
|
534
|
+
throw new Error(
|
|
535
|
+
`Option --upload-log requires a UUID or session name argument`
|
|
536
|
+
);
|
|
537
|
+
}
|
|
538
|
+
options.uploadLog = value;
|
|
539
|
+
return 1;
|
|
540
|
+
}
|
|
541
|
+
|
|
453
542
|
// --stop <uuid-or-session-name>
|
|
454
543
|
if (arg === '--stop') {
|
|
455
544
|
if (index + 1 < args.length && !args[index + 1].startsWith('-')) {
|
|
@@ -531,6 +620,35 @@ function parseOption(args, index, options) {
|
|
|
531
620
|
return 0;
|
|
532
621
|
}
|
|
533
622
|
|
|
623
|
+
/**
|
|
624
|
+
* Throw if docker runtime options (--volume, --mount, --env, --privileged)
|
|
625
|
+
* are present but the isolation configuration does not include docker.
|
|
626
|
+
* @param {object} options - Parsed options
|
|
627
|
+
* @throws {Error} If a docker-only option is set without docker isolation
|
|
628
|
+
*/
|
|
629
|
+
function validateDockerRuntimeOptionsRequireDocker(options) {
|
|
630
|
+
if (options.volumes && options.volumes.length > 0) {
|
|
631
|
+
throw new Error(
|
|
632
|
+
'--volume option is only valid when isolation stack includes docker'
|
|
633
|
+
);
|
|
634
|
+
}
|
|
635
|
+
if (options.mounts && options.mounts.length > 0) {
|
|
636
|
+
throw new Error(
|
|
637
|
+
'--mount option is only valid when isolation stack includes docker'
|
|
638
|
+
);
|
|
639
|
+
}
|
|
640
|
+
if (options.env && options.env.length > 0) {
|
|
641
|
+
throw new Error(
|
|
642
|
+
'--env option is only valid when isolation stack includes docker'
|
|
643
|
+
);
|
|
644
|
+
}
|
|
645
|
+
if (options.privileged) {
|
|
646
|
+
throw new Error(
|
|
647
|
+
'--privileged option is only valid when isolation stack includes docker'
|
|
648
|
+
);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
534
652
|
/**
|
|
535
653
|
* Validate parsed options
|
|
536
654
|
* @param {object} options - Parsed options
|
|
@@ -653,6 +771,11 @@ function validateOptions(options) {
|
|
|
653
771
|
);
|
|
654
772
|
}
|
|
655
773
|
|
|
774
|
+
// Docker runtime options (--volume, --mount, --env, --privileged) require docker
|
|
775
|
+
if (!stack.includes('docker')) {
|
|
776
|
+
validateDockerRuntimeOptionsRequireDocker(options);
|
|
777
|
+
}
|
|
778
|
+
|
|
656
779
|
// User isolation is not supported with Docker as first level
|
|
657
780
|
if (options.user && currentBackend === 'docker') {
|
|
658
781
|
throw new Error(
|
|
@@ -677,6 +800,7 @@ function validateOptions(options) {
|
|
|
677
800
|
'--endpoint option is only valid when isolation stack includes ssh'
|
|
678
801
|
);
|
|
679
802
|
}
|
|
803
|
+
validateDockerRuntimeOptionsRequireDocker(options);
|
|
680
804
|
}
|
|
681
805
|
|
|
682
806
|
// Session name is only valid with isolation
|
|
@@ -724,6 +848,7 @@ function validateOptions(options) {
|
|
|
724
848
|
const queryModes = [
|
|
725
849
|
hasValue(options.status),
|
|
726
850
|
options.list,
|
|
851
|
+
hasValue(options.uploadLog),
|
|
727
852
|
hasValue(options.stop),
|
|
728
853
|
hasValue(options.terminate),
|
|
729
854
|
options.cleanup,
|
|
@@ -731,7 +856,7 @@ function validateOptions(options) {
|
|
|
731
856
|
|
|
732
857
|
if (queryModes > 1) {
|
|
733
858
|
throw new Error(
|
|
734
|
-
'Cannot combine --status, --list, --stop, --terminate, or --cleanup in the same invocation'
|
|
859
|
+
'Cannot combine --status, --list, --upload-log, --stop, --terminate, or --cleanup in the same invocation'
|
|
735
860
|
);
|
|
736
861
|
}
|
|
737
862
|
|
|
@@ -54,6 +54,23 @@ function buildNextLevelCommand(options, command) {
|
|
|
54
54
|
}
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
// Docker runtime options are flat (not per-level); forward them only when a
|
|
58
|
+
// remaining level still uses docker so the nested $ invocation can apply them.
|
|
59
|
+
if (remainingStack.includes('docker')) {
|
|
60
|
+
for (const volume of options.volumes || []) {
|
|
61
|
+
parts.push(`--volume "${volume}"`);
|
|
62
|
+
}
|
|
63
|
+
for (const mount of options.mounts || []) {
|
|
64
|
+
parts.push(`--mount "${mount}"`);
|
|
65
|
+
}
|
|
66
|
+
for (const envVar of options.env || []) {
|
|
67
|
+
parts.push(`--env "${envVar}"`);
|
|
68
|
+
}
|
|
69
|
+
if (options.privileged) {
|
|
70
|
+
parts.push('--privileged');
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
57
74
|
// Pass through global flags
|
|
58
75
|
if (options.detached) {
|
|
59
76
|
parts.push('--detached');
|
package/src/lib/isolation.js
CHANGED
|
@@ -486,10 +486,75 @@ const {
|
|
|
486
486
|
canRunLinuxDockerImages,
|
|
487
487
|
} = require('./docker-utils');
|
|
488
488
|
|
|
489
|
+
/**
|
|
490
|
+
* Build the docker run runtime argument list contributed by configurable
|
|
491
|
+
* container options: privileged mode, environment variables, volumes/bind
|
|
492
|
+
* mounts, and --mount specs. Returned in a stable order so they can be spliced
|
|
493
|
+
* into the `docker run` argv before the image name.
|
|
494
|
+
* @param {object} options - Options (privileged, env, volumes, mounts)
|
|
495
|
+
* @returns {string[]} Docker CLI arguments
|
|
496
|
+
*/
|
|
497
|
+
function buildDockerRuntimeArgs(options = {}) {
|
|
498
|
+
const args = [];
|
|
499
|
+
if (options.privileged) {
|
|
500
|
+
args.push('--privileged');
|
|
501
|
+
}
|
|
502
|
+
for (const envVar of options.env || []) {
|
|
503
|
+
args.push('-e', envVar);
|
|
504
|
+
}
|
|
505
|
+
for (const volume of options.volumes || []) {
|
|
506
|
+
args.push('-v', volume);
|
|
507
|
+
}
|
|
508
|
+
for (const mount of options.mounts || []) {
|
|
509
|
+
args.push('--mount', mount);
|
|
510
|
+
}
|
|
511
|
+
return args;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* Build the human-readable `[Isolation]` status lines for docker runtime
|
|
516
|
+
* options (volumes, mounts, env, privileged). Empty collections and a falsy
|
|
517
|
+
* privileged flag contribute no lines.
|
|
518
|
+
* @param {object} options - Options (volumes, mounts, env, privileged)
|
|
519
|
+
* @returns {string[]} Status lines for the start block / log header
|
|
520
|
+
*/
|
|
521
|
+
function buildDockerRuntimeStatusLines(options = {}) {
|
|
522
|
+
const lines = [];
|
|
523
|
+
if (options.volumes && options.volumes.length > 0) {
|
|
524
|
+
lines.push(`[Isolation] Volumes: ${options.volumes.join(', ')}`);
|
|
525
|
+
}
|
|
526
|
+
if (options.mounts && options.mounts.length > 0) {
|
|
527
|
+
lines.push(`[Isolation] Mounts: ${options.mounts.join(', ')}`);
|
|
528
|
+
}
|
|
529
|
+
if (options.env && options.env.length > 0) {
|
|
530
|
+
lines.push(`[Isolation] Env: ${options.env.join(', ')}`);
|
|
531
|
+
}
|
|
532
|
+
if (options.privileged) {
|
|
533
|
+
lines.push(`[Isolation] Privileged: true`);
|
|
534
|
+
}
|
|
535
|
+
return lines;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* Build the execution-record metadata for docker runtime options, normalizing
|
|
540
|
+
* empty collections and a falsy privileged flag to `null`.
|
|
541
|
+
* @param {object} options - Options (volumes, mounts, env, privileged)
|
|
542
|
+
* @returns {{volumes: ?string[], mounts: ?string[], env: ?string[], privileged: ?boolean}}
|
|
543
|
+
*/
|
|
544
|
+
function buildDockerRuntimeMetadata(options = {}) {
|
|
545
|
+
return {
|
|
546
|
+
volumes:
|
|
547
|
+
options.volumes && options.volumes.length > 0 ? options.volumes : null,
|
|
548
|
+
mounts: options.mounts && options.mounts.length > 0 ? options.mounts : null,
|
|
549
|
+
env: options.env && options.env.length > 0 ? options.env : null,
|
|
550
|
+
privileged: options.privileged || null,
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
|
|
489
554
|
/**
|
|
490
555
|
* Run command in Docker container
|
|
491
556
|
* @param {string} command - Command to execute
|
|
492
|
-
* @param {object} options - Options (image, session/name, detached, user, keepAlive, autoRemoveDockerContainer)
|
|
557
|
+
* @param {object} options - Options (image, session/name, detached, user, keepAlive, autoRemoveDockerContainer, volumes, mounts, env, privileged)
|
|
493
558
|
* @returns {Promise<{success: boolean, containerName: string, message: string}>}
|
|
494
559
|
*/
|
|
495
560
|
function runInDocker(command, options = {}) {
|
|
@@ -558,6 +623,8 @@ function runInDocker(command, options = {}) {
|
|
|
558
623
|
dockerArgs.push('--user', options.user);
|
|
559
624
|
}
|
|
560
625
|
|
|
626
|
+
dockerArgs.push(...buildDockerRuntimeArgs(options));
|
|
627
|
+
|
|
561
628
|
const effectiveCommand = options.keepAlive
|
|
562
629
|
? `${command}; exec ${shellToUse}`
|
|
563
630
|
: command;
|
|
@@ -640,6 +707,7 @@ function runInDocker(command, options = {}) {
|
|
|
640
707
|
if (options.user) {
|
|
641
708
|
dockerArgs.push('--user', options.user);
|
|
642
709
|
}
|
|
710
|
+
dockerArgs.push(...buildDockerRuntimeArgs(options));
|
|
643
711
|
if (DEBUG) {
|
|
644
712
|
console.log(`[DEBUG] shell: ${shellToUse}`);
|
|
645
713
|
}
|
|
@@ -784,6 +852,9 @@ module.exports = {
|
|
|
784
852
|
runInScreen,
|
|
785
853
|
runInTmux,
|
|
786
854
|
runInDocker,
|
|
855
|
+
buildDockerRuntimeArgs,
|
|
856
|
+
buildDockerRuntimeStatusLines,
|
|
857
|
+
buildDockerRuntimeMetadata,
|
|
787
858
|
runInSsh,
|
|
788
859
|
runIsolated,
|
|
789
860
|
runAsIsolatedUser,
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helpers for uploading stored execution logs with gh-upload-log.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const os = require('os');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const { spawnSync } = require('child_process');
|
|
9
|
+
|
|
10
|
+
function isExecutable(filePath) {
|
|
11
|
+
try {
|
|
12
|
+
fs.accessSync(filePath, fs.constants.X_OK);
|
|
13
|
+
return true;
|
|
14
|
+
} catch {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function isCommandFile(filePath) {
|
|
20
|
+
if (process.platform === 'win32') {
|
|
21
|
+
return fs.existsSync(filePath);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return isExecutable(filePath);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function getPathCommandNames(commandName) {
|
|
28
|
+
if (process.platform !== 'win32' || path.extname(commandName)) {
|
|
29
|
+
return [commandName];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const extensions = (process.env.PATHEXT || '.COM;.EXE;.BAT;.CMD')
|
|
33
|
+
.split(';')
|
|
34
|
+
.filter(Boolean);
|
|
35
|
+
|
|
36
|
+
return [commandName, ...extensions.map((ext) => `${commandName}${ext}`)];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function resolveCommandFromPath(commandName) {
|
|
40
|
+
const pathValue = process.env.PATH || '';
|
|
41
|
+
for (const pathEntry of pathValue.split(path.delimiter)) {
|
|
42
|
+
if (!pathEntry) {
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const directory = pathEntry.replace(/^"|"$/g, '');
|
|
47
|
+
for (const candidateName of getPathCommandNames(commandName)) {
|
|
48
|
+
const candidate = path.join(directory, candidateName);
|
|
49
|
+
if (isCommandFile(candidate)) {
|
|
50
|
+
return candidate;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function resolveCommand(commandName) {
|
|
59
|
+
const isWindows = process.platform === 'win32';
|
|
60
|
+
const lookupCommand = isWindows ? 'where' : 'which';
|
|
61
|
+
const pathMatch = resolveCommandFromPath(commandName);
|
|
62
|
+
if (pathMatch) {
|
|
63
|
+
return pathMatch;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
const result = spawnSync(lookupCommand, [commandName], {
|
|
68
|
+
encoding: 'utf8',
|
|
69
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
70
|
+
});
|
|
71
|
+
if (result.status === 0 && result.stdout.trim()) {
|
|
72
|
+
return result.stdout.trim().split(/\r?\n/)[0];
|
|
73
|
+
}
|
|
74
|
+
} catch {
|
|
75
|
+
// Fall through to common locations.
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (!isWindows && commandName === 'gh-upload-log') {
|
|
79
|
+
const bunGlobalPath = path.join(os.homedir(), '.bun', 'bin', commandName);
|
|
80
|
+
if (isExecutable(bunGlobalPath)) {
|
|
81
|
+
return bunGlobalPath;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function shouldRunThroughShell(command) {
|
|
89
|
+
return process.platform === 'win32' && /\.(cmd|bat)$/i.test(command);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function runCommand(command, args, options = {}) {
|
|
93
|
+
return spawnSync(command, args, {
|
|
94
|
+
...options,
|
|
95
|
+
shell: shouldRunThroughShell(command),
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function runInstall(command, displayName, args) {
|
|
100
|
+
console.log(
|
|
101
|
+
`gh-upload-log not found; installing with: ${displayName} ${args.join(' ')}`
|
|
102
|
+
);
|
|
103
|
+
const result = runCommand(command, args, { stdio: 'inherit' });
|
|
104
|
+
return result.status === 0;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function ensureGhUploadLogAvailable() {
|
|
108
|
+
const existing = resolveCommand('gh-upload-log');
|
|
109
|
+
if (existing) {
|
|
110
|
+
return { success: true, command: existing };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const installers = [
|
|
114
|
+
['bun', ['install', '-g', 'gh-upload-log']],
|
|
115
|
+
['npm', ['install', '-g', 'gh-upload-log']],
|
|
116
|
+
];
|
|
117
|
+
|
|
118
|
+
for (const [command, args] of installers) {
|
|
119
|
+
const installer = resolveCommand(command);
|
|
120
|
+
if (!installer) {
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (runInstall(installer, command, args)) {
|
|
124
|
+
const installed = resolveCommand('gh-upload-log');
|
|
125
|
+
if (installed) {
|
|
126
|
+
return { success: true, command: installed };
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
success: false,
|
|
133
|
+
error:
|
|
134
|
+
'gh-upload-log is not installed and automatic installation did not make it available on PATH.',
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function uploadLogPath(logPath) {
|
|
139
|
+
if (!logPath) {
|
|
140
|
+
return {
|
|
141
|
+
success: false,
|
|
142
|
+
error: 'Execution record does not have a log path.',
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
if (!fs.existsSync(logPath)) {
|
|
146
|
+
return { success: false, error: `Log file not found: ${logPath}` };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const availability = ensureGhUploadLogAvailable();
|
|
150
|
+
if (!availability.success) {
|
|
151
|
+
return availability;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const result = runCommand(availability.command, [logPath], {
|
|
155
|
+
stdio: 'inherit',
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
const exitCode =
|
|
159
|
+
result.status !== null && result.status !== undefined ? result.status : 1;
|
|
160
|
+
if (exitCode !== 0) {
|
|
161
|
+
return {
|
|
162
|
+
success: false,
|
|
163
|
+
exitCode,
|
|
164
|
+
error: `gh-upload-log exited with code ${exitCode}`,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return { success: true, exitCode: 0 };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function uploadExecutionLog(store, identifier) {
|
|
172
|
+
if (!store) {
|
|
173
|
+
return { success: false, error: 'Execution tracking is disabled.' };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const record = store.get(identifier);
|
|
177
|
+
if (!record) {
|
|
178
|
+
return {
|
|
179
|
+
success: false,
|
|
180
|
+
error: `No execution found with UUID or session name: ${identifier}`,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return uploadLogPath(record.logPath);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
module.exports = {
|
|
188
|
+
ensureGhUploadLogAvailable,
|
|
189
|
+
resolveCommand,
|
|
190
|
+
uploadExecutionLog,
|
|
191
|
+
uploadLogPath,
|
|
192
|
+
};
|
package/src/lib/usage.js
CHANGED
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
/** Print usage information */
|
|
2
2
|
function printUsage() {
|
|
3
|
-
console.log(`Usage: $ [options] [--] <command> | $ --status <uuid> [--output-format <fmt>] | $ --list [--output-format <fmt>] | $ --stop <id> | $ --terminate <id>
|
|
3
|
+
console.log(`Usage: $ [options] [--] <command> | $ --status <uuid> [--output-format <fmt>] | $ --list [--output-format <fmt>] | $ --upload-log <id> | $ --stop <id> | $ --terminate <id>
|
|
4
4
|
|
|
5
5
|
Options:
|
|
6
|
-
--isolated, -i <env> Run in isolated environment (screen, tmux, docker, ssh)
|
|
6
|
+
--isolated, --isolation, -i <env> Run in isolated environment (screen, tmux, docker, ssh)
|
|
7
7
|
--attached, -a Run in attached mode (foreground)
|
|
8
8
|
--detached, -d Run in detached mode (background)
|
|
9
9
|
--session, -s <name> Session name for isolation
|
|
10
10
|
--session-id <uuid> Session UUID for tracking (auto-generated if not provided)
|
|
11
11
|
--session-name <uuid> Alias for --session-id
|
|
12
12
|
--image <image> Docker image (optional, defaults to OS-matched image)
|
|
13
|
+
--volume, -v <spec> Docker bind mount/volume host:container[:mode] (repeatable, docker only)
|
|
14
|
+
--mount <spec> Docker --mount spec (repeatable, docker only)
|
|
15
|
+
--env, -e <KEY=VALUE> Environment variable for docker container (repeatable, docker only)
|
|
16
|
+
--privileged Run docker container in privileged mode (docker only)
|
|
13
17
|
--endpoint <endpoint> SSH endpoint (required for ssh isolation, e.g., user@host)
|
|
14
18
|
--isolated-user, -u [name] Create isolated user with same permissions
|
|
15
19
|
--keep-user Keep isolated user after command completes
|
|
@@ -19,6 +23,7 @@ Options:
|
|
|
19
23
|
--use-command-stream Use command-stream library for execution (experimental)
|
|
20
24
|
--status <id> Show status of execution by UUID or session name (--output-format: links-notation|json|text)
|
|
21
25
|
--list List all tracked executions (--output-format: links-notation|json|text)
|
|
26
|
+
--upload-log <id> Upload the stored log for an execution UUID or session name
|
|
22
27
|
--stop <id> Send CTRL+C/SIGINT to a detached isolated execution
|
|
23
28
|
--terminate <id> Terminate a detached isolated execution immediately
|
|
24
29
|
--cleanup Clean up stale "executing" records (crashed/killed processes)
|
|
@@ -32,6 +37,8 @@ Examples:
|
|
|
32
37
|
$ -i screen -d bun start
|
|
33
38
|
$ --isolated docker -- echo "hi" # uses OS-matched default image
|
|
34
39
|
$ --isolated docker --image ghcr.io/link-foundation/box-js:latest -- bun --version
|
|
40
|
+
$ -i docker -v ~/.config/gh:/root/.config/gh -e TOKEN=abc -- gh repo list
|
|
41
|
+
$ -i docker --image konard/hive-mind-dind:latest --privileged -- solve ...
|
|
35
42
|
$ --isolated ssh --endpoint user@remote.server -- ls -la
|
|
36
43
|
$ --isolated-user -- npm test # Create isolated user
|
|
37
44
|
$ -u myuser -- npm start # Custom username
|
|
@@ -39,6 +46,7 @@ Examples:
|
|
|
39
46
|
$ --isolated-user --keep-user -- npm start
|
|
40
47
|
$ --list # List stored execution records
|
|
41
48
|
$ --list --output-format json # List stored records as JSON
|
|
49
|
+
$ --upload-log my-screen-session # Upload stored execution log
|
|
42
50
|
$ --stop my-screen-session # Ask detached execution to stop gracefully
|
|
43
51
|
$ --terminate my-screen-session # Terminate detached execution immediately
|
|
44
52
|
$ --use-command-stream echo "Hello" # Use command-stream library`);
|
|
@@ -54,7 +54,13 @@ describe('control options', () => {
|
|
|
54
54
|
it('should reject combining query and control modes', () => {
|
|
55
55
|
assert.throws(() => {
|
|
56
56
|
parseArgs(['--status', 'uuid-here', '--stop', 'my-session']);
|
|
57
|
-
}, /Cannot combine --status, --list, --stop, --terminate, or --cleanup/);
|
|
57
|
+
}, /Cannot combine --status, --list, --upload-log, --stop, --terminate, or --cleanup/);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('should reject combining upload-log with control modes', () => {
|
|
61
|
+
assert.throws(() => {
|
|
62
|
+
parseArgs(['--upload-log', 'uuid-here', '--terminate', 'my-session']);
|
|
63
|
+
}, /Cannot combine --status, --list, --upload-log, --stop, --terminate, or --cleanup/);
|
|
58
64
|
});
|
|
59
65
|
|
|
60
66
|
it('should reject output-format with control modes', () => {
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Regression tests for --isolation alias handling and unknown wrapper options.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const { describe, it } = require('node:test');
|
|
7
|
+
const assert = require('assert');
|
|
8
|
+
const { parseArgs } = require('../src/lib/args-parser');
|
|
9
|
+
|
|
10
|
+
describe('parseArgs --isolation alias', () => {
|
|
11
|
+
it('should parse --isolation alias with value', () => {
|
|
12
|
+
const result = parseArgs(['--isolation', 'docker', '--', 'echo', 'hi']);
|
|
13
|
+
assert.strictEqual(result.wrapperOptions.isolated, 'docker');
|
|
14
|
+
assert.strictEqual(result.command, 'echo hi');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('should parse --isolation=value alias format', () => {
|
|
18
|
+
const result = parseArgs(['--isolation=screen', '--', 'ls', '-la']);
|
|
19
|
+
assert.strictEqual(result.wrapperOptions.isolated, 'screen');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('should parse --isolation alias before command without separator', () => {
|
|
23
|
+
const result = parseArgs(['--isolation', 'docker', 'echo', 'hi']);
|
|
24
|
+
assert.strictEqual(result.wrapperOptions.isolated, 'docker');
|
|
25
|
+
assert.strictEqual(result.command, 'echo hi');
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe('parseArgs unknown wrapper options', () => {
|
|
30
|
+
it('should throw for unknown wrapper option before separator', () => {
|
|
31
|
+
assert.throws(() => {
|
|
32
|
+
parseArgs(['--unknown-wrapper', 'value', '--', 'echo', 'hi']);
|
|
33
|
+
}, /Unknown wrapper option: --unknown-wrapper/);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('should throw for unknown wrapper option without separator', () => {
|
|
37
|
+
assert.throws(() => {
|
|
38
|
+
parseArgs(['--unknown-wrapper', 'value', 'echo', 'hi']);
|
|
39
|
+
}, /Unknown wrapper option: --unknown-wrapper/);
|
|
40
|
+
});
|
|
41
|
+
});
|
package/test/args-parser.js
CHANGED
|
@@ -832,6 +832,36 @@ describe('status option', () => {
|
|
|
832
832
|
});
|
|
833
833
|
});
|
|
834
834
|
|
|
835
|
+
describe('upload-log option', () => {
|
|
836
|
+
it('should parse --upload-log with UUID or session name', () => {
|
|
837
|
+
const result = parseArgs(['--upload-log', 'my-session']);
|
|
838
|
+
assert.strictEqual(result.wrapperOptions.uploadLog, 'my-session');
|
|
839
|
+
assert.strictEqual(result.command, '');
|
|
840
|
+
});
|
|
841
|
+
|
|
842
|
+
it('should parse --upload-log=value format', () => {
|
|
843
|
+
const result = parseArgs(['--upload-log=my-session']);
|
|
844
|
+
assert.strictEqual(result.wrapperOptions.uploadLog, 'my-session');
|
|
845
|
+
});
|
|
846
|
+
|
|
847
|
+
it('should throw error for missing --upload-log argument', () => {
|
|
848
|
+
assert.throws(() => {
|
|
849
|
+
parseArgs(['--upload-log']);
|
|
850
|
+
}, /requires a UUID or session name argument/);
|
|
851
|
+
});
|
|
852
|
+
|
|
853
|
+
it('should throw error for empty --upload-log=value argument', () => {
|
|
854
|
+
assert.throws(() => {
|
|
855
|
+
parseArgs(['--upload-log=']);
|
|
856
|
+
}, /requires a UUID or session name argument/);
|
|
857
|
+
});
|
|
858
|
+
|
|
859
|
+
it('should default uploadLog to null', () => {
|
|
860
|
+
const result = parseArgs(['echo', 'hello']);
|
|
861
|
+
assert.strictEqual(result.wrapperOptions.uploadLog, null);
|
|
862
|
+
});
|
|
863
|
+
});
|
|
864
|
+
|
|
835
865
|
describe('list option', () => {
|
|
836
866
|
it('should parse --list flag', () => {
|
|
837
867
|
const result = parseArgs(['--list']);
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Tests for Docker runtime options: --volume/-v, --mount, --env/-e, --privileged
|
|
4
|
+
*
|
|
5
|
+
* Reproduces issue #132: callers need to configure bind mounts, volumes,
|
|
6
|
+
* environment variables, and privileged mode for the docker isolation backend
|
|
7
|
+
* so they can mount tool credentials and run Docker-in-Docker images without
|
|
8
|
+
* wrapping `docker run` themselves.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const { describe, it } = require('node:test');
|
|
12
|
+
const assert = require('assert');
|
|
13
|
+
const { parseArgs } = require('../src/lib/args-parser');
|
|
14
|
+
const {
|
|
15
|
+
buildDockerRuntimeArgs,
|
|
16
|
+
buildDockerRuntimeStatusLines,
|
|
17
|
+
buildDockerRuntimeMetadata,
|
|
18
|
+
} = require('../src/lib/isolation');
|
|
19
|
+
|
|
20
|
+
describe('Docker runtime options parsing', () => {
|
|
21
|
+
it('should parse repeatable --volume and -v', () => {
|
|
22
|
+
const result = parseArgs([
|
|
23
|
+
'--isolated',
|
|
24
|
+
'docker',
|
|
25
|
+
'--volume',
|
|
26
|
+
'/host/a:/container/a',
|
|
27
|
+
'-v',
|
|
28
|
+
'/host/b:/container/b:ro',
|
|
29
|
+
'--',
|
|
30
|
+
'ls',
|
|
31
|
+
]);
|
|
32
|
+
assert.deepStrictEqual(result.wrapperOptions.volumes, [
|
|
33
|
+
'/host/a:/container/a',
|
|
34
|
+
'/host/b:/container/b:ro',
|
|
35
|
+
]);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('should parse --volume=value and -v=value', () => {
|
|
39
|
+
const result = parseArgs([
|
|
40
|
+
'-i',
|
|
41
|
+
'docker',
|
|
42
|
+
'--volume=/host/a:/c/a',
|
|
43
|
+
'-v=/host/b:/c/b',
|
|
44
|
+
'--',
|
|
45
|
+
'ls',
|
|
46
|
+
]);
|
|
47
|
+
assert.deepStrictEqual(result.wrapperOptions.volumes, [
|
|
48
|
+
'/host/a:/c/a',
|
|
49
|
+
'/host/b:/c/b',
|
|
50
|
+
]);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('should parse repeatable --mount', () => {
|
|
54
|
+
const result = parseArgs([
|
|
55
|
+
'-i',
|
|
56
|
+
'docker',
|
|
57
|
+
'--mount',
|
|
58
|
+
'type=bind,src=/h,dst=/c',
|
|
59
|
+
'--mount=type=volume,src=vol,dst=/data',
|
|
60
|
+
'--',
|
|
61
|
+
'ls',
|
|
62
|
+
]);
|
|
63
|
+
assert.deepStrictEqual(result.wrapperOptions.mounts, [
|
|
64
|
+
'type=bind,src=/h,dst=/c',
|
|
65
|
+
'type=volume,src=vol,dst=/data',
|
|
66
|
+
]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('should parse repeatable --env and -e', () => {
|
|
70
|
+
const result = parseArgs([
|
|
71
|
+
'-i',
|
|
72
|
+
'docker',
|
|
73
|
+
'--env',
|
|
74
|
+
'FOO=bar',
|
|
75
|
+
'-e',
|
|
76
|
+
'GH_TOKEN=secret',
|
|
77
|
+
'--env=BAZ=qux',
|
|
78
|
+
'--',
|
|
79
|
+
'env',
|
|
80
|
+
]);
|
|
81
|
+
assert.deepStrictEqual(result.wrapperOptions.env, [
|
|
82
|
+
'FOO=bar',
|
|
83
|
+
'GH_TOKEN=secret',
|
|
84
|
+
'BAZ=qux',
|
|
85
|
+
]);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('should parse --privileged', () => {
|
|
89
|
+
const result = parseArgs(['-i', 'docker', '--privileged', '--', 'ls']);
|
|
90
|
+
assert.strictEqual(result.wrapperOptions.privileged, true);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('should default runtime options to empty/false', () => {
|
|
94
|
+
const result = parseArgs(['-i', 'docker', '--', 'ls']);
|
|
95
|
+
assert.deepStrictEqual(result.wrapperOptions.volumes, []);
|
|
96
|
+
assert.deepStrictEqual(result.wrapperOptions.mounts, []);
|
|
97
|
+
assert.deepStrictEqual(result.wrapperOptions.env, []);
|
|
98
|
+
assert.strictEqual(result.wrapperOptions.privileged, false);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('should throw when --volume requires an argument', () => {
|
|
102
|
+
assert.throws(() => {
|
|
103
|
+
parseArgs(['-i', 'docker', '--volume', '--', 'ls']);
|
|
104
|
+
}, /requires a volume argument/);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('should throw when --env requires an argument', () => {
|
|
108
|
+
assert.throws(() => {
|
|
109
|
+
parseArgs(['-i', 'docker', '--env', '--', 'ls']);
|
|
110
|
+
}, /requires a KEY=VALUE argument/);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe('Docker runtime options validation', () => {
|
|
115
|
+
it('should reject --volume with non-docker backend', () => {
|
|
116
|
+
assert.throws(() => {
|
|
117
|
+
parseArgs(['-i', 'tmux', '-v', '/a:/b', '--', 'ls']);
|
|
118
|
+
}, /--volume option is only valid when isolation stack includes docker/);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('should reject --mount without isolation', () => {
|
|
122
|
+
assert.throws(() => {
|
|
123
|
+
parseArgs(['--mount', 'type=bind,src=/a,dst=/b', '--', 'ls']);
|
|
124
|
+
}, /--mount option is only valid when isolation stack includes docker/);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('should reject --env with non-docker backend', () => {
|
|
128
|
+
assert.throws(() => {
|
|
129
|
+
parseArgs(['-i', 'ssh', '--endpoint', 'u@h', '-e', 'A=1', '--', 'ls']);
|
|
130
|
+
}, /--env option is only valid when isolation stack includes docker/);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('should reject --privileged without docker', () => {
|
|
134
|
+
assert.throws(() => {
|
|
135
|
+
parseArgs(['--privileged', '--', 'ls']);
|
|
136
|
+
}, /--privileged option is only valid when isolation stack includes docker/);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('should accept runtime options when stack includes docker', () => {
|
|
140
|
+
const result = parseArgs([
|
|
141
|
+
'-i',
|
|
142
|
+
'screen docker',
|
|
143
|
+
'-v',
|
|
144
|
+
'/a:/b',
|
|
145
|
+
'-e',
|
|
146
|
+
'A=1',
|
|
147
|
+
'--privileged',
|
|
148
|
+
'--',
|
|
149
|
+
'ls',
|
|
150
|
+
]);
|
|
151
|
+
assert.deepStrictEqual(result.wrapperOptions.volumes, ['/a:/b']);
|
|
152
|
+
assert.deepStrictEqual(result.wrapperOptions.env, ['A=1']);
|
|
153
|
+
assert.strictEqual(result.wrapperOptions.privileged, true);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
describe('buildDockerRuntimeArgs', () => {
|
|
158
|
+
it('should build empty args by default', () => {
|
|
159
|
+
assert.deepStrictEqual(buildDockerRuntimeArgs({}), []);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('should add --privileged first', () => {
|
|
163
|
+
assert.deepStrictEqual(buildDockerRuntimeArgs({ privileged: true }), [
|
|
164
|
+
'--privileged',
|
|
165
|
+
]);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('should expand env, volumes, and mounts in order', () => {
|
|
169
|
+
const args = buildDockerRuntimeArgs({
|
|
170
|
+
privileged: true,
|
|
171
|
+
env: ['FOO=bar', 'GH_TOKEN=secret'],
|
|
172
|
+
volumes: ['/h/a:/c/a', '/h/b:/c/b:ro'],
|
|
173
|
+
mounts: ['type=bind,src=/h,dst=/c'],
|
|
174
|
+
});
|
|
175
|
+
assert.deepStrictEqual(args, [
|
|
176
|
+
'--privileged',
|
|
177
|
+
'-e',
|
|
178
|
+
'FOO=bar',
|
|
179
|
+
'-e',
|
|
180
|
+
'GH_TOKEN=secret',
|
|
181
|
+
'-v',
|
|
182
|
+
'/h/a:/c/a',
|
|
183
|
+
'-v',
|
|
184
|
+
'/h/b:/c/b:ro',
|
|
185
|
+
'--mount',
|
|
186
|
+
'type=bind,src=/h,dst=/c',
|
|
187
|
+
]);
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
describe('buildDockerRuntimeStatusLines', () => {
|
|
192
|
+
it('should return no lines for empty options', () => {
|
|
193
|
+
assert.deepStrictEqual(buildDockerRuntimeStatusLines({}), []);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('should format volumes, mounts, env, and privileged lines', () => {
|
|
197
|
+
const lines = buildDockerRuntimeStatusLines({
|
|
198
|
+
volumes: ['/h:/c:ro'],
|
|
199
|
+
mounts: ['type=bind,src=/h,dst=/c'],
|
|
200
|
+
env: ['FOO=bar'],
|
|
201
|
+
privileged: true,
|
|
202
|
+
});
|
|
203
|
+
assert.deepStrictEqual(lines, [
|
|
204
|
+
'[Isolation] Volumes: /h:/c:ro',
|
|
205
|
+
'[Isolation] Mounts: type=bind,src=/h,dst=/c',
|
|
206
|
+
'[Isolation] Env: FOO=bar',
|
|
207
|
+
'[Isolation] Privileged: true',
|
|
208
|
+
]);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('should join multiple volumes with a comma', () => {
|
|
212
|
+
assert.deepStrictEqual(
|
|
213
|
+
buildDockerRuntimeStatusLines({ volumes: ['/a:/a', '/b:/b'] }),
|
|
214
|
+
['[Isolation] Volumes: /a:/a, /b:/b']
|
|
215
|
+
);
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
describe('buildDockerRuntimeMetadata', () => {
|
|
220
|
+
it('should null out empty collections and falsy privileged', () => {
|
|
221
|
+
assert.deepStrictEqual(buildDockerRuntimeMetadata({}), {
|
|
222
|
+
volumes: null,
|
|
223
|
+
mounts: null,
|
|
224
|
+
env: null,
|
|
225
|
+
privileged: null,
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('should pass through populated collections and privileged', () => {
|
|
230
|
+
assert.deepStrictEqual(
|
|
231
|
+
buildDockerRuntimeMetadata({
|
|
232
|
+
volumes: ['/h:/c'],
|
|
233
|
+
mounts: ['type=bind,src=/h,dst=/c'],
|
|
234
|
+
env: ['FOO=bar'],
|
|
235
|
+
privileged: true,
|
|
236
|
+
}),
|
|
237
|
+
{
|
|
238
|
+
volumes: ['/h:/c'],
|
|
239
|
+
mounts: ['type=bind,src=/h,dst=/c'],
|
|
240
|
+
env: ['FOO=bar'],
|
|
241
|
+
privileged: true,
|
|
242
|
+
}
|
|
243
|
+
);
|
|
244
|
+
});
|
|
245
|
+
});
|
package/test/status-query.js
CHANGED
|
@@ -32,7 +32,7 @@ function cleanupTestDir() {
|
|
|
32
32
|
|
|
33
33
|
// Helper to run CLI command
|
|
34
34
|
function runCli(args, env = {}) {
|
|
35
|
-
const result = spawnSync(
|
|
35
|
+
const result = spawnSync(process.execPath, [CLI_PATH, ...args], {
|
|
36
36
|
encoding: 'utf8',
|
|
37
37
|
env: {
|
|
38
38
|
...process.env,
|
|
@@ -48,6 +48,26 @@ function runCli(args, env = {}) {
|
|
|
48
48
|
};
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
function createExecutable(filePath, content) {
|
|
52
|
+
fs.writeFileSync(filePath, content, 'utf8');
|
|
53
|
+
fs.chmodSync(filePath, 0o755);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function createFakeUploader(fakeBin, outputPrefix) {
|
|
57
|
+
if (process.platform === 'win32') {
|
|
58
|
+
createExecutable(
|
|
59
|
+
path.join(fakeBin, 'gh-upload-log.cmd'),
|
|
60
|
+
`@echo off\r\necho ${outputPrefix}: %1\r\n`
|
|
61
|
+
);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
createExecutable(
|
|
66
|
+
path.join(fakeBin, 'gh-upload-log'),
|
|
67
|
+
`#!/bin/sh\necho "${outputPrefix}: $1"\n`
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
51
71
|
describe('--status query functionality', () => {
|
|
52
72
|
let store;
|
|
53
73
|
let testRecord;
|
|
@@ -241,6 +261,88 @@ describe('--status query functionality', () => {
|
|
|
241
261
|
});
|
|
242
262
|
});
|
|
243
263
|
|
|
264
|
+
describe('--upload-log functionality', () => {
|
|
265
|
+
it('should run gh-upload-log with the stored execution log path', () => {
|
|
266
|
+
const fakeBin = fs.mkdtempSync(path.join(os.tmpdir(), 'upload-log-bin-'));
|
|
267
|
+
const logPath = path.join(TEST_APP_FOLDER, 'command.log');
|
|
268
|
+
fs.writeFileSync(logPath, 'captured command output\n', 'utf8');
|
|
269
|
+
createFakeUploader(fakeBin, 'fake uploader received');
|
|
270
|
+
|
|
271
|
+
testRecord.logPath = logPath;
|
|
272
|
+
store.save(testRecord);
|
|
273
|
+
|
|
274
|
+
const result = runCli(['--upload-log', testRecord.uuid], {
|
|
275
|
+
PATH: fakeBin,
|
|
276
|
+
HOME: fakeBin,
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
expect(result.exitCode).toBe(0);
|
|
280
|
+
expect(result.stdout).toContain(`fake uploader received: ${logPath}`);
|
|
281
|
+
expect(result.stderr).toBe('');
|
|
282
|
+
|
|
283
|
+
fs.rmSync(fakeBin, { recursive: true, force: true });
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it('should install gh-upload-log when it is missing before uploading', () => {
|
|
287
|
+
if (process.platform === 'win32') {
|
|
288
|
+
console.log(' Skipping: shell fixture uses POSIX scripts');
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const fakeBin = fs.mkdtempSync(
|
|
293
|
+
path.join(os.tmpdir(), 'upload-log-install-bin-')
|
|
294
|
+
);
|
|
295
|
+
const installMarker = path.join(fakeBin, 'install.log');
|
|
296
|
+
const logPath = path.join(TEST_APP_FOLDER, 'install-command.log');
|
|
297
|
+
fs.writeFileSync(logPath, 'captured command output\n', 'utf8');
|
|
298
|
+
|
|
299
|
+
createExecutable(
|
|
300
|
+
path.join(fakeBin, 'bun'),
|
|
301
|
+
[
|
|
302
|
+
'#!/bin/sh',
|
|
303
|
+
`echo "$@" > "${installMarker}"`,
|
|
304
|
+
`cat > "${path.join(fakeBin, 'gh-upload-log')}" <<'SCRIPT'`,
|
|
305
|
+
'#!/bin/sh',
|
|
306
|
+
'echo "installed uploader received: $1"',
|
|
307
|
+
'SCRIPT',
|
|
308
|
+
`chmod +x "${path.join(fakeBin, 'gh-upload-log')}"`,
|
|
309
|
+
'exit 0',
|
|
310
|
+
'',
|
|
311
|
+
].join('\n')
|
|
312
|
+
);
|
|
313
|
+
|
|
314
|
+
testRecord.logPath = logPath;
|
|
315
|
+
store.save(testRecord);
|
|
316
|
+
|
|
317
|
+
const result = runCli(['--upload-log', testRecord.uuid], {
|
|
318
|
+
PATH: `${fakeBin}${path.delimiter}/usr/bin${path.delimiter}/bin`,
|
|
319
|
+
HOME: fakeBin,
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
expect(result.exitCode).toBe(0);
|
|
323
|
+
expect(fs.readFileSync(installMarker, 'utf8').trim()).toBe(
|
|
324
|
+
'install -g gh-upload-log'
|
|
325
|
+
);
|
|
326
|
+
expect(result.stdout).toContain('gh-upload-log not found');
|
|
327
|
+
expect(result.stdout).toContain(
|
|
328
|
+
`installed uploader received: ${logPath}`
|
|
329
|
+
);
|
|
330
|
+
|
|
331
|
+
fs.rmSync(fakeBin, { recursive: true, force: true });
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it('should show an error when the stored log file is missing', () => {
|
|
335
|
+
testRecord.logPath = path.join(TEST_APP_FOLDER, 'missing.log');
|
|
336
|
+
store.save(testRecord);
|
|
337
|
+
|
|
338
|
+
const result = runCli(['--upload-log', testRecord.uuid]);
|
|
339
|
+
|
|
340
|
+
expect(result.exitCode).toBe(1);
|
|
341
|
+
expect(result.stderr).toContain('Log file not found');
|
|
342
|
+
expect(result.stderr).toContain(testRecord.logPath);
|
|
343
|
+
});
|
|
344
|
+
});
|
|
345
|
+
|
|
244
346
|
describe('executing status', () => {
|
|
245
347
|
it('should show executing status for ongoing commands', () => {
|
|
246
348
|
// Create an executing (not completed) record
|