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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "start-command",
3
- "version": "0.24.4",
3
+ "version": "0.24.6",
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": {
@@ -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
- const shells = ['bash', 'zsh', 'sh', 'fish', 'ksh', 'csh', 'tcsh', 'dash'];
223
- return shells.includes(path.basename(parts[0])) && !parts.includes('-c');
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
- if (!isCommandAvailable('docker')) {
722
- return Promise.resolve({
723
- success: false,
724
- containerName: null,
725
- message:
726
- 'Docker is not installed. Install Docker from https://docs.docker.com/get-docker/',
727
- });
728
- }
729
-
730
- if (!isDockerAvailable()) {
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
- const { createCommandLine } = require('./output-blocks');
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
- : [...shellArgs, '-c', effectiveCommand];
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
+ });