start-command 0.24.4 → 0.24.6
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 +58 -0
- package/package.json +1 -1
- package/src/lib/isolation.js +45 -45
- package/test/regression-89.test.js +252 -0
- package/test/regression-91.test.js +260 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,63 @@
|
|
|
1
1
|
# start-command
|
|
2
2
|
|
|
3
|
+
## 0.24.6
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- cfc5731: fix: pass `bash -c "..."` style commands directly to Docker without double-wrapping (issue #91)
|
|
8
|
+
|
|
9
|
+
When a command like `bash -i -c "nvm --version"` was passed to Docker isolation,
|
|
10
|
+
it was incorrectly wrapped in an outer shell: `bash -i -c "bash -i -c nvm --version"`.
|
|
11
|
+
This caused two bugs: (1) the quoted argument `"nvm --version"` was split by the outer
|
|
12
|
+
shell, so `--version` became `$0` instead of part of the script; (2) the sudo advisory
|
|
13
|
+
message printed twice due to two nested bash invocations.
|
|
14
|
+
|
|
15
|
+
The fix adds `isShellInvocationWithArgs()` to detect commands that start with a shell
|
|
16
|
+
binary and include `-c`, and `buildShellWithArgsCmdArgs()` to reconstruct the correct
|
|
17
|
+
argv array. Such commands are now passed directly to Docker's exec, just like bare shell
|
|
18
|
+
invocations (issue #84), without any additional wrapping.
|
|
19
|
+
|
|
20
|
+
## 0.24.5
|
|
21
|
+
|
|
22
|
+
### Patch Changes
|
|
23
|
+
|
|
24
|
+
- 1fa812f: fix: show virtual docker pull command before Docker availability errors (issue #89)
|
|
25
|
+
|
|
26
|
+
When running `$ --isolated docker --image <image> -- <command>` and Docker is
|
|
27
|
+
not installed or not running, `start-command` now shows the virtual
|
|
28
|
+
`$ docker pull <image>` command that was being attempted before displaying the
|
|
29
|
+
error message.
|
|
30
|
+
|
|
31
|
+
Before:
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
│ isolation docker
|
|
35
|
+
│ mode attached
|
|
36
|
+
│ image konard/sandbox
|
|
37
|
+
│ container docker-1773150604263-i87zla
|
|
38
|
+
│
|
|
39
|
+
Error: Docker is not installed. Install Docker from https://docs.docker.com/get-docker/
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
After:
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
│ isolation docker
|
|
46
|
+
│ mode attached
|
|
47
|
+
│ image konard/sandbox
|
|
48
|
+
│ container docker-1773150604263-i87zla
|
|
49
|
+
│
|
|
50
|
+
$ docker pull konard/sandbox
|
|
51
|
+
|
|
52
|
+
✗
|
|
53
|
+
│
|
|
54
|
+
|
|
55
|
+
Error: Docker is not installed. Install Docker from https://docs.docker.com/get-docker/
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
This makes it clear to users what `start-command` was attempting to do and
|
|
59
|
+
why Docker is needed, improving the debugging experience.
|
|
60
|
+
|
|
3
61
|
## 0.24.4
|
|
4
62
|
|
|
5
63
|
### Patch Changes
|
package/package.json
CHANGED
package/src/lib/isolation.js
CHANGED
|
@@ -5,6 +5,7 @@ const fs = require('fs');
|
|
|
5
5
|
const os = require('os');
|
|
6
6
|
const path = require('path');
|
|
7
7
|
const { generateSessionName } = require('./args-parser');
|
|
8
|
+
const outputBlocks = require('./output-blocks');
|
|
8
9
|
|
|
9
10
|
const setTimeout = globalThis.setTimeout;
|
|
10
11
|
|
|
@@ -105,10 +106,7 @@ function isCommandAvailable(command) {
|
|
|
105
106
|
}
|
|
106
107
|
}
|
|
107
108
|
|
|
108
|
-
/**
|
|
109
|
-
* Get the shell to use for command execution
|
|
110
|
-
* @returns {{shell: string, shellArgs: string[]}} Shell path and args
|
|
111
|
-
*/
|
|
109
|
+
/** Get the shell to use for command execution. */
|
|
112
110
|
function getShell() {
|
|
113
111
|
const isWindows = process.platform === 'win32';
|
|
114
112
|
const shell = isWindows ? 'cmd.exe' : process.env.SHELL || '/bin/sh';
|
|
@@ -216,11 +214,28 @@ function getShellInteractiveFlag(shellPath) {
|
|
|
216
214
|
const shellName = shellPath.split('/').pop();
|
|
217
215
|
return shellName === 'bash' || shellName === 'zsh' ? '-i' : null;
|
|
218
216
|
}
|
|
217
|
+
const SHELL_NAMES = ['bash', 'zsh', 'sh', 'fish', 'ksh', 'csh', 'tcsh', 'dash'];
|
|
219
218
|
/** True if command is a bare shell invocation (no -c); avoids bash-inside-bash (issue #84). */
|
|
220
219
|
function isInteractiveShellCommand(command) {
|
|
221
220
|
const parts = command.trim().split(/\s+/);
|
|
222
|
-
|
|
223
|
-
|
|
221
|
+
return SHELL_NAMES.includes(path.basename(parts[0])) && !parts.includes('-c');
|
|
222
|
+
}
|
|
223
|
+
/** True if command is a shell invocation with -c (e.g. `bash -i -c "cmd"`); avoids double-wrapping (issue #91). */
|
|
224
|
+
function isShellInvocationWithArgs(command) {
|
|
225
|
+
const parts = command.trim().split(/\s+/);
|
|
226
|
+
return SHELL_NAMES.includes(path.basename(parts[0])) && parts.includes('-c');
|
|
227
|
+
}
|
|
228
|
+
/** Build argv for shell-with-c command; everything after -c is one argument (reverses commandArgs.join(' ')). */
|
|
229
|
+
function buildShellWithArgsCmdArgs(command) {
|
|
230
|
+
const parts = command.trim().split(/\s+/);
|
|
231
|
+
const cIdx = parts.indexOf('-c');
|
|
232
|
+
if (cIdx === -1) {
|
|
233
|
+
return parts;
|
|
234
|
+
}
|
|
235
|
+
const scriptArg = parts.slice(cIdx + 1).join(' ');
|
|
236
|
+
return scriptArg.length > 0
|
|
237
|
+
? [...parts.slice(0, cIdx + 1), scriptArg]
|
|
238
|
+
: parts.slice(0, cIdx + 1);
|
|
224
239
|
}
|
|
225
240
|
|
|
226
241
|
/** Returns true if the current process has a TTY attached. */
|
|
@@ -427,9 +442,7 @@ function runInScreen(command, options = {}) {
|
|
|
427
442
|
let effectiveCommand = wrapCommandWithUser(command, options.user);
|
|
428
443
|
|
|
429
444
|
if (options.detached) {
|
|
430
|
-
// Detached mode: screen -dmS <session> <shell> -c '<command>'
|
|
431
445
|
if (options.keepAlive) {
|
|
432
|
-
// With keep-alive: run command, then keep shell open
|
|
433
446
|
effectiveCommand = `${effectiveCommand}; exec ${shell}`;
|
|
434
447
|
}
|
|
435
448
|
|
|
@@ -465,14 +478,6 @@ function runInScreen(command, options = {}) {
|
|
|
465
478
|
message,
|
|
466
479
|
});
|
|
467
480
|
} else {
|
|
468
|
-
// Attached mode: use detached mode with log capture for reliable output
|
|
469
|
-
// (direct attached screen loses output for quick commands; see issue #25)
|
|
470
|
-
if (DEBUG) {
|
|
471
|
-
console.log(
|
|
472
|
-
`[DEBUG] Using detached mode with log capture for reliable output`
|
|
473
|
-
);
|
|
474
|
-
}
|
|
475
|
-
|
|
476
481
|
return runScreenWithLogCapture(
|
|
477
482
|
command,
|
|
478
483
|
sessionName,
|
|
@@ -514,9 +519,7 @@ function runInTmux(command, options = {}) {
|
|
|
514
519
|
|
|
515
520
|
try {
|
|
516
521
|
if (options.detached) {
|
|
517
|
-
// Detached mode: tmux new-session -d -s <session> '<command>'
|
|
518
522
|
if (options.keepAlive) {
|
|
519
|
-
// With keep-alive: run command, then keep shell open
|
|
520
523
|
effectiveCommand = `${effectiveCommand}; exec ${shell}`;
|
|
521
524
|
}
|
|
522
525
|
|
|
@@ -548,7 +551,6 @@ function runInTmux(command, options = {}) {
|
|
|
548
551
|
message,
|
|
549
552
|
});
|
|
550
553
|
} else {
|
|
551
|
-
// Attached mode: tmux new-session -s <session> '<command>'
|
|
552
554
|
if (DEBUG) {
|
|
553
555
|
console.log(
|
|
554
556
|
`[DEBUG] Running: tmux new-session -s "${sessionName}" "${effectiveCommand}"`
|
|
@@ -630,7 +632,6 @@ function runInSsh(command, options = {}) {
|
|
|
630
632
|
|
|
631
633
|
try {
|
|
632
634
|
if (options.detached) {
|
|
633
|
-
// Detached mode: Run command in background via nohup; continues after SSH closes
|
|
634
635
|
const remoteShell = useExplicitShell || shellToUse;
|
|
635
636
|
const shellInvocation = shellInteractiveFlag
|
|
636
637
|
? `${remoteShell} ${shellInteractiveFlag}`
|
|
@@ -657,7 +658,6 @@ function runInSsh(command, options = {}) {
|
|
|
657
658
|
message: `Command started in detached SSH session on ${sshTarget}\nSession: ${sessionName}\nView logs: ssh ${sshTarget} "tail -f /tmp/${sessionName}.log"`,
|
|
658
659
|
});
|
|
659
660
|
} else {
|
|
660
|
-
// Attached mode: pass command directly (auto) or wrap with explicit shell + -i flag (bash/zsh).
|
|
661
661
|
const extraFlags = shellInteractiveFlag ? [shellInteractiveFlag] : [];
|
|
662
662
|
const sshArgs = isInteractiveShellCommand(command)
|
|
663
663
|
? [sshTarget, ...command.trim().split(/\s+/)]
|
|
@@ -702,7 +702,6 @@ function runInSsh(command, options = {}) {
|
|
|
702
702
|
}
|
|
703
703
|
}
|
|
704
704
|
|
|
705
|
-
// Import docker utilities from docker-utils
|
|
706
705
|
const {
|
|
707
706
|
dockerImageExists,
|
|
708
707
|
dockerPullImage,
|
|
@@ -718,21 +717,23 @@ const {
|
|
|
718
717
|
* @returns {Promise<{success: boolean, containerName: string, message: string}>}
|
|
719
718
|
*/
|
|
720
719
|
function runInDocker(command, options = {}) {
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
720
|
+
const dockerNotAvailableError = !isCommandAvailable('docker')
|
|
721
|
+
? 'Docker is not installed. Install Docker from https://docs.docker.com/get-docker/'
|
|
722
|
+
: !isDockerAvailable()
|
|
723
|
+
? 'Docker is installed but not running. Please start Docker Desktop or the Docker daemon, then try again.'
|
|
724
|
+
: null;
|
|
725
|
+
|
|
726
|
+
if (dockerNotAvailableError) {
|
|
727
|
+
if (options.image) {
|
|
728
|
+
const pullCmd = `docker pull ${options.image}`;
|
|
729
|
+
console.log(outputBlocks.createVirtualCommandBlock(pullCmd));
|
|
730
|
+
console.log();
|
|
731
|
+
// ✗ and │ come from createFinishBlock() AFTER the error message (issue #89)
|
|
732
|
+
}
|
|
731
733
|
return Promise.resolve({
|
|
732
734
|
success: false,
|
|
733
735
|
containerName: null,
|
|
734
|
-
message:
|
|
735
|
-
'Docker is installed but not running. Please start Docker Desktop or the Docker daemon, then try again.',
|
|
736
|
+
message: dockerNotAvailableError,
|
|
736
737
|
});
|
|
737
738
|
}
|
|
738
739
|
|
|
@@ -745,8 +746,6 @@ function runInDocker(command, options = {}) {
|
|
|
745
746
|
}
|
|
746
747
|
|
|
747
748
|
const containerName = options.session || generateSessionName('docker');
|
|
748
|
-
|
|
749
|
-
// Check if image exists locally; if not, pull it as a virtual command
|
|
750
749
|
if (!dockerImageExists(options.image)) {
|
|
751
750
|
const pullResult = dockerPullImage(options.image);
|
|
752
751
|
if (!pullResult.success) {
|
|
@@ -765,15 +764,12 @@ function runInDocker(command, options = {}) {
|
|
|
765
764
|
: detectShellInEnvironment('docker', options, options.shell);
|
|
766
765
|
const shellInteractiveFlag = getShellInteractiveFlag(shellToUse);
|
|
767
766
|
|
|
768
|
-
|
|
769
|
-
console.log(createCommandLine(command));
|
|
767
|
+
console.log(outputBlocks.createCommandLine(command));
|
|
770
768
|
console.log();
|
|
771
769
|
|
|
772
770
|
try {
|
|
773
771
|
if (options.detached) {
|
|
774
|
-
// Detached mode: docker run -d --name <name> [--user <user>] <image> <shell> -c '<command>'
|
|
775
772
|
const dockerArgs = ['run', '-d', '--name', containerName];
|
|
776
|
-
// --rm must come before the image name in args
|
|
777
773
|
if (options.autoRemoveDockerContainer) {
|
|
778
774
|
dockerArgs.splice(2, 0, '--rm');
|
|
779
775
|
}
|
|
@@ -790,7 +786,9 @@ function runInDocker(command, options = {}) {
|
|
|
790
786
|
: [shellToUse];
|
|
791
787
|
const cmdArgs = isBareShell
|
|
792
788
|
? command.trim().split(/\s+/)
|
|
793
|
-
:
|
|
789
|
+
: isShellInvocationWithArgs(command)
|
|
790
|
+
? buildShellWithArgsCmdArgs(effectiveCommand)
|
|
791
|
+
: [...shellArgs, '-c', effectiveCommand];
|
|
794
792
|
dockerArgs.push(options.image, ...cmdArgs);
|
|
795
793
|
|
|
796
794
|
if (DEBUG) {
|
|
@@ -828,7 +826,6 @@ function runInDocker(command, options = {}) {
|
|
|
828
826
|
message,
|
|
829
827
|
});
|
|
830
828
|
} else {
|
|
831
|
-
// Attached mode: docker run -it --rm --name <name> [--user <user>] <image> <shell> -c '<cmd>'
|
|
832
829
|
const dockerArgs = ['run', '-it', '--rm', '--name', containerName];
|
|
833
830
|
if (options.user) {
|
|
834
831
|
dockerArgs.push('--user', options.user);
|
|
@@ -840,6 +837,7 @@ function runInDocker(command, options = {}) {
|
|
|
840
837
|
? [shellToUse, shellInteractiveFlag]
|
|
841
838
|
: [shellToUse];
|
|
842
839
|
// Bare shell: pass directly with -i (avoids bash-inside-bash, issue #84; -i ensures interactive).
|
|
840
|
+
// Shell with -c: pass directly as argv (avoids double-wrapping and quote-stripping, issue #91).
|
|
843
841
|
let attachedCmdArgs;
|
|
844
842
|
if (isBareShell) {
|
|
845
843
|
const parts = command.trim().split(/\s+/);
|
|
@@ -848,6 +846,8 @@ function runInDocker(command, options = {}) {
|
|
|
848
846
|
bareFlag && !parts.includes(bareFlag)
|
|
849
847
|
? [parts[0], bareFlag, ...parts.slice(1)]
|
|
850
848
|
: parts;
|
|
849
|
+
} else if (isShellInvocationWithArgs(command)) {
|
|
850
|
+
attachedCmdArgs = buildShellWithArgsCmdArgs(command);
|
|
851
851
|
} else {
|
|
852
852
|
attachedCmdArgs = [...shellCmdArgs, '-c', command];
|
|
853
853
|
}
|
|
@@ -951,9 +951,7 @@ function runIsolated(backend, command, options = {}) {
|
|
|
951
951
|
}
|
|
952
952
|
}
|
|
953
953
|
|
|
954
|
-
/**
|
|
955
|
-
* Reset screen version cache (useful for testing)
|
|
956
|
-
*/
|
|
954
|
+
/** Reset screen version cache (useful for testing) */
|
|
957
955
|
function resetScreenVersionCache() {
|
|
958
956
|
cachedScreenVersion = null;
|
|
959
957
|
screenVersionChecked = false;
|
|
@@ -974,6 +972,8 @@ module.exports = {
|
|
|
974
972
|
isCommandAvailable,
|
|
975
973
|
hasTTY,
|
|
976
974
|
isInteractiveShellCommand,
|
|
975
|
+
isShellInvocationWithArgs,
|
|
976
|
+
buildShellWithArgsCmdArgs,
|
|
977
977
|
detectShellInEnvironment,
|
|
978
978
|
runInScreen,
|
|
979
979
|
runInTmux,
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Regression tests for issue #89:
|
|
4
|
+
* "We need to show better output for virtual docker pull command
|
|
5
|
+
* and other such virtual commands we will introduce in the future"
|
|
6
|
+
*
|
|
7
|
+
* When Docker is not installed (or not running) and an image is specified,
|
|
8
|
+
* the output should show:
|
|
9
|
+
* $ docker pull <image>
|
|
10
|
+
* (empty line)
|
|
11
|
+
* Error: Docker is not installed...
|
|
12
|
+
* (empty line)
|
|
13
|
+
* ✗
|
|
14
|
+
* │ finish ...
|
|
15
|
+
*
|
|
16
|
+
* The virtual command line shows BEFORE the error message.
|
|
17
|
+
* The failure marker (✗) and timeline separator come AFTER the error,
|
|
18
|
+
* as part of the finish block output.
|
|
19
|
+
*
|
|
20
|
+
* Reference: https://github.com/link-foundation/start/issues/89
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const { describe, it, beforeEach, afterEach } = require('node:test');
|
|
24
|
+
const assert = require('assert');
|
|
25
|
+
|
|
26
|
+
describe('Virtual docker pull output before Docker error (issue #89)', () => {
|
|
27
|
+
// Capture console output for testing
|
|
28
|
+
let capturedOutput = [];
|
|
29
|
+
let originalConsoleLog;
|
|
30
|
+
let originalConsoleError;
|
|
31
|
+
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
capturedOutput = [];
|
|
34
|
+
originalConsoleLog = console.log;
|
|
35
|
+
originalConsoleError = console.error;
|
|
36
|
+
console.log = (...args) => {
|
|
37
|
+
capturedOutput.push(args.join(' '));
|
|
38
|
+
};
|
|
39
|
+
console.error = (...args) => {
|
|
40
|
+
capturedOutput.push(args.join(' '));
|
|
41
|
+
};
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
afterEach(() => {
|
|
45
|
+
console.log = originalConsoleLog;
|
|
46
|
+
console.error = originalConsoleError;
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('should show "$ docker pull <image>" line when Docker is not installed and image is specified', async () => {
|
|
50
|
+
// We need to test the runInDocker function with a mocked "docker not available" scenario.
|
|
51
|
+
// We mock isCommandAvailable to simulate Docker not being installed.
|
|
52
|
+
|
|
53
|
+
// Use the output-blocks module to understand what the virtual command should look like
|
|
54
|
+
const {
|
|
55
|
+
createVirtualCommandBlock,
|
|
56
|
+
createVirtualCommandResult,
|
|
57
|
+
} = require('../src/lib/output-blocks');
|
|
58
|
+
|
|
59
|
+
const image = 'konard/sandbox';
|
|
60
|
+
const expectedCommandLine = createVirtualCommandBlock(
|
|
61
|
+
`docker pull ${image}`
|
|
62
|
+
);
|
|
63
|
+
const expectedFailureMarker = createVirtualCommandResult(false);
|
|
64
|
+
|
|
65
|
+
// Verify the expected format
|
|
66
|
+
assert.strictEqual(
|
|
67
|
+
expectedCommandLine,
|
|
68
|
+
`$ docker pull ${image}`,
|
|
69
|
+
'Virtual command block should produce "$ docker pull <image>"'
|
|
70
|
+
);
|
|
71
|
+
assert.strictEqual(
|
|
72
|
+
expectedFailureMarker,
|
|
73
|
+
'✗',
|
|
74
|
+
'Virtual command result (failure) should be "✗"'
|
|
75
|
+
);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('should show "$ docker pull <image>" before the error message (issue #89)', async () => {
|
|
79
|
+
// This test verifies the output format contract for issue #89:
|
|
80
|
+
// The virtual command line ("$ docker pull ...") must appear BEFORE the error message.
|
|
81
|
+
// The failure marker (✗) and timeline separator (│) come AFTER the error,
|
|
82
|
+
// as part of the finish block (not printed by runInDocker itself).
|
|
83
|
+
|
|
84
|
+
const {
|
|
85
|
+
createVirtualCommandBlock,
|
|
86
|
+
createFinishBlock,
|
|
87
|
+
} = require('../src/lib/output-blocks');
|
|
88
|
+
|
|
89
|
+
// Simulate what the full output should look like
|
|
90
|
+
const image = 'konard/sandbox';
|
|
91
|
+
const lines = [];
|
|
92
|
+
|
|
93
|
+
// Part 1: What runInDocker outputs (virtual command only)
|
|
94
|
+
lines.push(createVirtualCommandBlock(`docker pull ${image}`));
|
|
95
|
+
lines.push(''); // empty line after virtual command
|
|
96
|
+
|
|
97
|
+
// Part 2: Error message (printed by cli.js after runInDocker returns)
|
|
98
|
+
lines.push('Error: Docker is not installed. Install Docker from ...');
|
|
99
|
+
lines.push(''); // empty line before finish block
|
|
100
|
+
|
|
101
|
+
// Part 3: Finish block (includes ✗ and │ lines)
|
|
102
|
+
lines.push(
|
|
103
|
+
createFinishBlock({
|
|
104
|
+
sessionId: 'test-uuid',
|
|
105
|
+
timestamp: '2026-03-10 13:50:04',
|
|
106
|
+
exitCode: 1, // failure
|
|
107
|
+
logPath: '/tmp/test.log',
|
|
108
|
+
durationMs: 326,
|
|
109
|
+
})
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
const output = lines.join('\n');
|
|
113
|
+
|
|
114
|
+
// Verify ordering: docker pull → error message → ✗ marker
|
|
115
|
+
const dockerPullIndex = output.indexOf(`$ docker pull ${image}`);
|
|
116
|
+
const errorIndex = output.indexOf('Docker is not installed');
|
|
117
|
+
const failureMarkerIndex = output.indexOf('✗');
|
|
118
|
+
|
|
119
|
+
assert.ok(
|
|
120
|
+
dockerPullIndex !== -1,
|
|
121
|
+
'Output must contain "$ docker pull konard/sandbox"'
|
|
122
|
+
);
|
|
123
|
+
assert.ok(
|
|
124
|
+
errorIndex !== -1,
|
|
125
|
+
'Output must contain error message "Docker is not installed"'
|
|
126
|
+
);
|
|
127
|
+
assert.ok(
|
|
128
|
+
failureMarkerIndex !== -1,
|
|
129
|
+
'Output must contain failure marker "✗"'
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
// Key ordering requirements from issue #89:
|
|
133
|
+
assert.ok(
|
|
134
|
+
dockerPullIndex < errorIndex,
|
|
135
|
+
'"$ docker pull" must appear BEFORE error message'
|
|
136
|
+
);
|
|
137
|
+
assert.ok(
|
|
138
|
+
errorIndex < failureMarkerIndex,
|
|
139
|
+
'Error message must appear BEFORE "✗" failure marker'
|
|
140
|
+
);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('should output "$ docker pull <image>" in the correct format ($ prefix, no extra prefix)', () => {
|
|
144
|
+
const { createVirtualCommandBlock } = require('../src/lib/output-blocks');
|
|
145
|
+
|
|
146
|
+
const image = 'alpine:latest';
|
|
147
|
+
const block = createVirtualCommandBlock(`docker pull ${image}`);
|
|
148
|
+
|
|
149
|
+
// Must start with "$ " prefix (no timeline marker │)
|
|
150
|
+
assert.ok(
|
|
151
|
+
block.startsWith('$ '),
|
|
152
|
+
'Virtual command block must start with "$ "'
|
|
153
|
+
);
|
|
154
|
+
assert.ok(
|
|
155
|
+
!block.startsWith('│'),
|
|
156
|
+
'Virtual command block must NOT start with timeline marker "│"'
|
|
157
|
+
);
|
|
158
|
+
assert.strictEqual(
|
|
159
|
+
block,
|
|
160
|
+
`$ docker pull ${image}`,
|
|
161
|
+
`Expected exactly "$ docker pull ${image}", got: ${block}`
|
|
162
|
+
);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
describe('runInDocker virtual pull output contract (issue #89)', () => {
|
|
167
|
+
// Test that the runInDocker function in isolation.js shows the virtual docker pull
|
|
168
|
+
// command before error messages when Docker is not available.
|
|
169
|
+
//
|
|
170
|
+
// We verify this by reading the source to confirm the fix is present.
|
|
171
|
+
|
|
172
|
+
it('runInDocker should output docker pull command but NOT ✗/│ markers before returning (issue #89)', () => {
|
|
173
|
+
// Read the isolation.js source to verify the fix is present
|
|
174
|
+
const fs = require('fs');
|
|
175
|
+
const path = require('path');
|
|
176
|
+
const isolationSrc = fs.readFileSync(
|
|
177
|
+
path.join(__dirname, '../src/lib/isolation.js'),
|
|
178
|
+
'utf8'
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
// The fix handles both "not installed" and "not running" in a combined block (dockerNotAvailableError).
|
|
182
|
+
// Verify both error messages are present in the source.
|
|
183
|
+
assert.ok(
|
|
184
|
+
isolationSrc.includes('Docker is not installed. Install Docker'),
|
|
185
|
+
'Source must contain the "not installed" error message'
|
|
186
|
+
);
|
|
187
|
+
assert.ok(
|
|
188
|
+
isolationSrc.includes('Docker is installed but not running'),
|
|
189
|
+
'Source must contain the "not running" error message'
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
// Verify that the docker pull output code is present (fix for issue #89)
|
|
193
|
+
assert.ok(
|
|
194
|
+
isolationSrc.includes('docker pull ${options.image}'),
|
|
195
|
+
'Source must contain docker pull with image variable (fix for issue #89)'
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
// Verify the dockerNotAvailableError combined approach is used
|
|
199
|
+
assert.ok(
|
|
200
|
+
isolationSrc.includes('dockerNotAvailableError'),
|
|
201
|
+
'Source must use combined dockerNotAvailableError variable for both error cases'
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
// The docker pull output console.log must appear before the return statement
|
|
205
|
+
const dockerPullConsoleIdx = isolationSrc.indexOf(
|
|
206
|
+
'outputBlocks.createVirtualCommandBlock'
|
|
207
|
+
);
|
|
208
|
+
const returnDockerErrorIdx = isolationSrc.indexOf(
|
|
209
|
+
'message: dockerNotAvailableError'
|
|
210
|
+
);
|
|
211
|
+
assert.ok(
|
|
212
|
+
dockerPullConsoleIdx !== -1,
|
|
213
|
+
'Source must call outputBlocks.createVirtualCommandBlock for docker pull command'
|
|
214
|
+
);
|
|
215
|
+
assert.ok(
|
|
216
|
+
returnDockerErrorIdx !== -1,
|
|
217
|
+
'Source must have message: dockerNotAvailableError in return'
|
|
218
|
+
);
|
|
219
|
+
assert.ok(
|
|
220
|
+
dockerPullConsoleIdx < returnDockerErrorIdx,
|
|
221
|
+
'docker pull console.log must appear before the error return in source'
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
// Issue #89 key fix: The ✗ and │ markers should NOT be printed by runInDocker
|
|
225
|
+
// They come from createFinishBlock() AFTER the error message is displayed.
|
|
226
|
+
// Verify that createVirtualCommandResult is NOT called in the dockerNotAvailableError block.
|
|
227
|
+
const dockerNotAvailableBlockStart = isolationSrc.indexOf(
|
|
228
|
+
'if (dockerNotAvailableError) {'
|
|
229
|
+
);
|
|
230
|
+
const dockerNotAvailableBlockEnd = isolationSrc.indexOf(
|
|
231
|
+
'message: dockerNotAvailableError',
|
|
232
|
+
dockerNotAvailableBlockStart
|
|
233
|
+
);
|
|
234
|
+
const dockerNotAvailableBlock = isolationSrc.slice(
|
|
235
|
+
dockerNotAvailableBlockStart,
|
|
236
|
+
dockerNotAvailableBlockEnd + 100
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
// The block should NOT contain createVirtualCommandResult (which outputs ✗)
|
|
240
|
+
assert.ok(
|
|
241
|
+
!dockerNotAvailableBlock.includes('createVirtualCommandResult'),
|
|
242
|
+
'dockerNotAvailableError block must NOT call createVirtualCommandResult (issue #89 fix)'
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
// The block should have a comment explaining why ✗/│ are not printed here
|
|
246
|
+
assert.ok(
|
|
247
|
+
dockerNotAvailableBlock.includes('createFinishBlock') ||
|
|
248
|
+
dockerNotAvailableBlock.includes('AFTER the error message'),
|
|
249
|
+
'Source should document that ✗/│ come from createFinishBlock AFTER error'
|
|
250
|
+
);
|
|
251
|
+
});
|
|
252
|
+
});
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Regression tests for issue #91:
|
|
4
|
+
* "`bash -i -c "nvm --version"` was interpreted as `bash -i -c nvm --version`,
|
|
5
|
+
* and executed inside bash, instead of directly"
|
|
6
|
+
*
|
|
7
|
+
* Two bugs were reported:
|
|
8
|
+
*
|
|
9
|
+
* Bug 1 — Quote stripping / wrong interpretation:
|
|
10
|
+
* `bash -i -c "nvm --version"` was treated as `bash -i -c nvm` with `--version`
|
|
11
|
+
* as $0. Caused by join(' ') collapsing the quoted argument, then the command
|
|
12
|
+
* being re-wrapped in an outer shell: `bash -i -c "bash -i -c nvm --version"`.
|
|
13
|
+
* The outer bash then parsed `nvm --version` as two separate words.
|
|
14
|
+
*
|
|
15
|
+
* Bug 2 — Executed inside bash (double-wrapping):
|
|
16
|
+
* Because isInteractiveShellCommand() returns false for any command with -c,
|
|
17
|
+
* the code wrapped `bash -i -c "nvm --version"` inside another `bash -i -c "..."`.
|
|
18
|
+
* This caused the sudo advisory message to print twice (one per bash invocation).
|
|
19
|
+
*
|
|
20
|
+
* Root cause:
|
|
21
|
+
* isInteractiveShellCommand() only returns true for bare shell invocations
|
|
22
|
+
* (no -c flag). Commands that ARE a shell invocation but include -c fell into the
|
|
23
|
+
* else-branch: `[shellToUse, shellInteractiveFlag, '-c', command]`, creating a
|
|
24
|
+
* shell-inside-shell. The fix adds isShellInvocationWithArgs() + buildShellWithArgsCmdArgs()
|
|
25
|
+
* to detect and pass such commands directly to Docker without additional wrapping.
|
|
26
|
+
*
|
|
27
|
+
* Reference: https://github.com/link-foundation/start/issues/91
|
|
28
|
+
* Fixed in: PR #92
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
const { describe, it } = require('node:test');
|
|
32
|
+
const assert = require('assert');
|
|
33
|
+
const {
|
|
34
|
+
isInteractiveShellCommand,
|
|
35
|
+
isShellInvocationWithArgs,
|
|
36
|
+
buildShellWithArgsCmdArgs,
|
|
37
|
+
} = require('../src/lib/isolation');
|
|
38
|
+
|
|
39
|
+
// Helper: mirrors the attached-mode command-args construction logic in runInDocker.
|
|
40
|
+
// Returns the argv array that would be passed to `docker run ... image <argv>`.
|
|
41
|
+
function buildAttachedCmdArgs(command, shellToUse = '/bin/bash') {
|
|
42
|
+
const path = require('path');
|
|
43
|
+
const shellName = shellToUse.split('/').pop();
|
|
44
|
+
const shellInteractiveFlag =
|
|
45
|
+
shellName === 'bash' || shellName === 'zsh' ? '-i' : null;
|
|
46
|
+
const shellCmdArgs = shellInteractiveFlag
|
|
47
|
+
? [shellToUse, shellInteractiveFlag]
|
|
48
|
+
: [shellToUse];
|
|
49
|
+
|
|
50
|
+
if (isInteractiveShellCommand(command)) {
|
|
51
|
+
// Bare shell: pass directly with explicit -i (issue #84 fix)
|
|
52
|
+
const parts = command.trim().split(/\s+/);
|
|
53
|
+
const bareFlag =
|
|
54
|
+
path.basename(parts[0]) === 'bash' || path.basename(parts[0]) === 'zsh'
|
|
55
|
+
? '-i'
|
|
56
|
+
: null;
|
|
57
|
+
if (bareFlag && !parts.includes(bareFlag)) {
|
|
58
|
+
return [parts[0], bareFlag, ...parts.slice(1)];
|
|
59
|
+
}
|
|
60
|
+
return parts;
|
|
61
|
+
} else if (isShellInvocationWithArgs(command)) {
|
|
62
|
+
// Shell with -c: pass directly as argv (issue #91 fix)
|
|
63
|
+
return buildShellWithArgsCmdArgs(command);
|
|
64
|
+
}
|
|
65
|
+
return [...shellCmdArgs, '-c', command];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
describe('isShellInvocationWithArgs (issue #91)', () => {
|
|
69
|
+
it('should return true for "bash -i -c nvm --version"', () => {
|
|
70
|
+
assert.strictEqual(
|
|
71
|
+
isShellInvocationWithArgs('bash -i -c nvm --version'),
|
|
72
|
+
true
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('should return true for \'bash -c "echo hello"\'', () => {
|
|
77
|
+
assert.strictEqual(isShellInvocationWithArgs('bash -c "echo hello"'), true);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('should return true for "bash -c echo hello"', () => {
|
|
81
|
+
assert.strictEqual(isShellInvocationWithArgs('bash -c echo hello'), true);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('should return true for "zsh -c nvm --version"', () => {
|
|
85
|
+
assert.strictEqual(isShellInvocationWithArgs('zsh -c nvm --version'), true);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('should return true for "sh -c ls"', () => {
|
|
89
|
+
assert.strictEqual(isShellInvocationWithArgs('sh -c ls'), true);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('should return true for "/bin/bash -c echo hi"', () => {
|
|
93
|
+
assert.strictEqual(isShellInvocationWithArgs('/bin/bash -c echo hi'), true);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('should return false for bare "bash" (no -c)', () => {
|
|
97
|
+
assert.strictEqual(isShellInvocationWithArgs('bash'), false);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('should return false for "bash -i" (no -c)', () => {
|
|
101
|
+
assert.strictEqual(isShellInvocationWithArgs('bash -i'), false);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('should return false for "bash --norc" (no -c)', () => {
|
|
105
|
+
assert.strictEqual(isShellInvocationWithArgs('bash --norc'), false);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('should return false for non-shell commands', () => {
|
|
109
|
+
assert.strictEqual(isShellInvocationWithArgs('nvm --version'), false);
|
|
110
|
+
assert.strictEqual(isShellInvocationWithArgs('echo hello'), false);
|
|
111
|
+
assert.strictEqual(isShellInvocationWithArgs('npm test'), false);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe('buildShellWithArgsCmdArgs (issue #91)', () => {
|
|
116
|
+
it('should reconstruct "bash -i -c nvm --version" correctly', () => {
|
|
117
|
+
const result = buildShellWithArgsCmdArgs('bash -i -c nvm --version');
|
|
118
|
+
assert.deepStrictEqual(result, ['bash', '-i', '-c', 'nvm --version']);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('should reconstruct "bash -c echo hello" correctly', () => {
|
|
122
|
+
const result = buildShellWithArgsCmdArgs('bash -c echo hello');
|
|
123
|
+
assert.deepStrictEqual(result, ['bash', '-c', 'echo hello']);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('should handle single-word script "bash -c ls"', () => {
|
|
127
|
+
const result = buildShellWithArgsCmdArgs('bash -c ls');
|
|
128
|
+
assert.deepStrictEqual(result, ['bash', '-c', 'ls']);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('should handle zsh with -c', () => {
|
|
132
|
+
const result = buildShellWithArgsCmdArgs('zsh -c nvm --version');
|
|
133
|
+
assert.deepStrictEqual(result, ['zsh', '-c', 'nvm --version']);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('should handle /bin/bash -i -c with multi-word script', () => {
|
|
137
|
+
const result = buildShellWithArgsCmdArgs(
|
|
138
|
+
'/bin/bash -i -c node -e process.version'
|
|
139
|
+
);
|
|
140
|
+
assert.deepStrictEqual(result, [
|
|
141
|
+
'/bin/bash',
|
|
142
|
+
'-i',
|
|
143
|
+
'-c',
|
|
144
|
+
'node -e process.version',
|
|
145
|
+
]);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('should not include -c argument inside script argument (no double -c)', () => {
|
|
149
|
+
const result = buildShellWithArgsCmdArgs('bash -i -c nvm --version');
|
|
150
|
+
// The script arg must be 'nvm --version', not '-c nvm --version'
|
|
151
|
+
assert.strictEqual(result[result.length - 1], 'nvm --version');
|
|
152
|
+
assert.strictEqual(result.indexOf('-c'), 2);
|
|
153
|
+
assert.strictEqual(result.length, 4);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
describe('Regression: No Double-Wrapping for Shell With -c (issue #91)', () => {
|
|
158
|
+
// Each test verifies that `bash -i -c "cmd"` style commands are NOT re-wrapped
|
|
159
|
+
// in another outer shell -c invocation.
|
|
160
|
+
//
|
|
161
|
+
// Before fix: buildAttachedCmdArgs('bash -i -c nvm --version')
|
|
162
|
+
// → ['/bin/bash', '-i', '-c', 'bash -i -c nvm --version'] (WRONG: double-wrap)
|
|
163
|
+
// After fix: buildAttachedCmdArgs('bash -i -c nvm --version')
|
|
164
|
+
// → ['bash', '-i', '-c', 'nvm --version'] (CORRECT: direct pass)
|
|
165
|
+
|
|
166
|
+
it('should pass "bash -i -c nvm --version" directly without outer shell wrapper', () => {
|
|
167
|
+
const args = buildAttachedCmdArgs('bash -i -c nvm --version');
|
|
168
|
+
// Must start with 'bash', not with '/bin/bash' (the outer shellToUse)
|
|
169
|
+
assert.strictEqual(args[0], 'bash');
|
|
170
|
+
// Must not wrap in outer bash -i -c
|
|
171
|
+
assert.ok(
|
|
172
|
+
args.filter((a) => a === '-c').length === 1,
|
|
173
|
+
`Must have exactly one -c flag, got: ${JSON.stringify(args)}`
|
|
174
|
+
);
|
|
175
|
+
// The script argument must be 'nvm --version' as one element
|
|
176
|
+
assert.deepStrictEqual(
|
|
177
|
+
args,
|
|
178
|
+
['bash', '-i', '-c', 'nvm --version'],
|
|
179
|
+
`Expected ['bash', '-i', '-c', 'nvm --version'], got: ${JSON.stringify(args)}`
|
|
180
|
+
);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('should pass "zsh -c nvm --version" directly without outer shell wrapper', () => {
|
|
184
|
+
const args = buildAttachedCmdArgs('zsh -c nvm --version', '/bin/zsh');
|
|
185
|
+
assert.deepStrictEqual(args, ['zsh', '-c', 'nvm --version']);
|
|
186
|
+
assert.ok(
|
|
187
|
+
args.filter((a) => a === '-c').length === 1,
|
|
188
|
+
'Must have exactly one -c flag'
|
|
189
|
+
);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('should pass "bash -c echo hello" directly', () => {
|
|
193
|
+
const args = buildAttachedCmdArgs('bash -c echo hello');
|
|
194
|
+
assert.deepStrictEqual(args, ['bash', '-c', 'echo hello']);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('should not introduce a second bash layer (no shell-inside-shell)', () => {
|
|
198
|
+
const args = buildAttachedCmdArgs('bash -i -c nvm --version');
|
|
199
|
+
// The first element of the docker image command args must NOT be the outer shell
|
|
200
|
+
// i.e., must not be ['/bin/bash', '-i', '-c', 'bash ...']
|
|
201
|
+
assert.notStrictEqual(
|
|
202
|
+
args[0],
|
|
203
|
+
'/bin/bash',
|
|
204
|
+
'Must not wrap in outer /bin/bash (shell-inside-shell)'
|
|
205
|
+
);
|
|
206
|
+
// Must not contain the original full command string as an argument
|
|
207
|
+
assert.ok(
|
|
208
|
+
!args.includes('bash -i -c nvm --version'),
|
|
209
|
+
'Must not contain the full command string as a single argument (double-wrap)'
|
|
210
|
+
);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('should still handle bare "bash" with -i (regression guard for issue #84)', () => {
|
|
214
|
+
const args = buildAttachedCmdArgs('bash');
|
|
215
|
+
assert.deepStrictEqual(args, ['bash', '-i']);
|
|
216
|
+
assert.ok(
|
|
217
|
+
!args.includes('-c'),
|
|
218
|
+
'Bare shell must not use -c (issue #84 guard)'
|
|
219
|
+
);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('should still wrap non-shell commands in outer shell -c (guard against over-broad fix)', () => {
|
|
223
|
+
const args = buildAttachedCmdArgs('nvm --version', '/bin/bash');
|
|
224
|
+
assert.deepStrictEqual(args, ['/bin/bash', '-i', '-c', 'nvm --version']);
|
|
225
|
+
assert.ok(
|
|
226
|
+
args.includes('-c'),
|
|
227
|
+
'Non-shell commands must still use -c wrapper'
|
|
228
|
+
);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('should still wrap "npm test" in outer shell -c', () => {
|
|
232
|
+
const args = buildAttachedCmdArgs('npm test', '/bin/bash');
|
|
233
|
+
assert.deepStrictEqual(args, ['/bin/bash', '-i', '-c', 'npm test']);
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
describe('isShellInvocationWithArgs is mutually exclusive with isInteractiveShellCommand', () => {
|
|
238
|
+
// A command cannot be both a bare shell AND a shell-with-args; they are disjoint.
|
|
239
|
+
const testCases = [
|
|
240
|
+
'bash',
|
|
241
|
+
'bash -i',
|
|
242
|
+
'bash --norc',
|
|
243
|
+
'bash -i -c nvm --version',
|
|
244
|
+
'bash -c echo hi',
|
|
245
|
+
'zsh -c ls',
|
|
246
|
+
'nvm --version',
|
|
247
|
+
'echo hello',
|
|
248
|
+
];
|
|
249
|
+
|
|
250
|
+
for (const cmd of testCases) {
|
|
251
|
+
it(`"${cmd}" is not both bare-shell and shell-with-args`, () => {
|
|
252
|
+
const bare = isInteractiveShellCommand(cmd);
|
|
253
|
+
const withArgs = isShellInvocationWithArgs(cmd);
|
|
254
|
+
assert.ok(
|
|
255
|
+
!(bare && withArgs),
|
|
256
|
+
`"${cmd}" must not return true from both helpers simultaneously`
|
|
257
|
+
);
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
});
|