start-command 0.24.5 → 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,22 @@
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
+
3
20
  ## 0.24.5
4
21
 
5
22
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "start-command",
3
- "version": "0.24.5",
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": {
@@ -106,10 +106,7 @@ function isCommandAvailable(command) {
106
106
  }
107
107
  }
108
108
 
109
- /**
110
- * Get the shell to use for command execution
111
- * @returns {{shell: string, shellArgs: string[]}} Shell path and args
112
- */
109
+ /** Get the shell to use for command execution. */
113
110
  function getShell() {
114
111
  const isWindows = process.platform === 'win32';
115
112
  const shell = isWindows ? 'cmd.exe' : process.env.SHELL || '/bin/sh';
@@ -217,11 +214,28 @@ function getShellInteractiveFlag(shellPath) {
217
214
  const shellName = shellPath.split('/').pop();
218
215
  return shellName === 'bash' || shellName === 'zsh' ? '-i' : null;
219
216
  }
217
+ const SHELL_NAMES = ['bash', 'zsh', 'sh', 'fish', 'ksh', 'csh', 'tcsh', 'dash'];
220
218
  /** True if command is a bare shell invocation (no -c); avoids bash-inside-bash (issue #84). */
221
219
  function isInteractiveShellCommand(command) {
222
220
  const parts = command.trim().split(/\s+/);
223
- const shells = ['bash', 'zsh', 'sh', 'fish', 'ksh', 'csh', 'tcsh', 'dash'];
224
- 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);
225
239
  }
226
240
 
227
241
  /** Returns true if the current process has a TTY attached. */
@@ -428,9 +442,7 @@ function runInScreen(command, options = {}) {
428
442
  let effectiveCommand = wrapCommandWithUser(command, options.user);
429
443
 
430
444
  if (options.detached) {
431
- // Detached mode: screen -dmS <session> <shell> -c '<command>'
432
445
  if (options.keepAlive) {
433
- // With keep-alive: run command, then keep shell open
434
446
  effectiveCommand = `${effectiveCommand}; exec ${shell}`;
435
447
  }
436
448
 
@@ -466,14 +478,6 @@ function runInScreen(command, options = {}) {
466
478
  message,
467
479
  });
468
480
  } else {
469
- // Attached mode: use detached mode with log capture for reliable output
470
- // (direct attached screen loses output for quick commands; see issue #25)
471
- if (DEBUG) {
472
- console.log(
473
- `[DEBUG] Using detached mode with log capture for reliable output`
474
- );
475
- }
476
-
477
481
  return runScreenWithLogCapture(
478
482
  command,
479
483
  sessionName,
@@ -515,9 +519,7 @@ function runInTmux(command, options = {}) {
515
519
 
516
520
  try {
517
521
  if (options.detached) {
518
- // Detached mode: tmux new-session -d -s <session> '<command>'
519
522
  if (options.keepAlive) {
520
- // With keep-alive: run command, then keep shell open
521
523
  effectiveCommand = `${effectiveCommand}; exec ${shell}`;
522
524
  }
523
525
 
@@ -549,7 +551,6 @@ function runInTmux(command, options = {}) {
549
551
  message,
550
552
  });
551
553
  } else {
552
- // Attached mode: tmux new-session -s <session> '<command>'
553
554
  if (DEBUG) {
554
555
  console.log(
555
556
  `[DEBUG] Running: tmux new-session -s "${sessionName}" "${effectiveCommand}"`
@@ -631,7 +632,6 @@ function runInSsh(command, options = {}) {
631
632
 
632
633
  try {
633
634
  if (options.detached) {
634
- // Detached mode: Run command in background via nohup; continues after SSH closes
635
635
  const remoteShell = useExplicitShell || shellToUse;
636
636
  const shellInvocation = shellInteractiveFlag
637
637
  ? `${remoteShell} ${shellInteractiveFlag}`
@@ -658,7 +658,6 @@ function runInSsh(command, options = {}) {
658
658
  message: `Command started in detached SSH session on ${sshTarget}\nSession: ${sessionName}\nView logs: ssh ${sshTarget} "tail -f /tmp/${sessionName}.log"`,
659
659
  });
660
660
  } else {
661
- // Attached mode: pass command directly (auto) or wrap with explicit shell + -i flag (bash/zsh).
662
661
  const extraFlags = shellInteractiveFlag ? [shellInteractiveFlag] : [];
663
662
  const sshArgs = isInteractiveShellCommand(command)
664
663
  ? [sshTarget, ...command.trim().split(/\s+/)]
@@ -747,8 +746,6 @@ function runInDocker(command, options = {}) {
747
746
  }
748
747
 
749
748
  const containerName = options.session || generateSessionName('docker');
750
-
751
- // Check if image exists locally; if not, pull it as a virtual command
752
749
  if (!dockerImageExists(options.image)) {
753
750
  const pullResult = dockerPullImage(options.image);
754
751
  if (!pullResult.success) {
@@ -772,9 +769,7 @@ function runInDocker(command, options = {}) {
772
769
 
773
770
  try {
774
771
  if (options.detached) {
775
- // Detached mode: docker run -d --name <name> [--user <user>] <image> <shell> -c '<command>'
776
772
  const dockerArgs = ['run', '-d', '--name', containerName];
777
- // --rm must come before the image name in args
778
773
  if (options.autoRemoveDockerContainer) {
779
774
  dockerArgs.splice(2, 0, '--rm');
780
775
  }
@@ -791,7 +786,9 @@ function runInDocker(command, options = {}) {
791
786
  : [shellToUse];
792
787
  const cmdArgs = isBareShell
793
788
  ? command.trim().split(/\s+/)
794
- : [...shellArgs, '-c', effectiveCommand];
789
+ : isShellInvocationWithArgs(command)
790
+ ? buildShellWithArgsCmdArgs(effectiveCommand)
791
+ : [...shellArgs, '-c', effectiveCommand];
795
792
  dockerArgs.push(options.image, ...cmdArgs);
796
793
 
797
794
  if (DEBUG) {
@@ -829,7 +826,6 @@ function runInDocker(command, options = {}) {
829
826
  message,
830
827
  });
831
828
  } else {
832
- // Attached mode: docker run -it --rm --name <name> [--user <user>] <image> <shell> -c '<cmd>'
833
829
  const dockerArgs = ['run', '-it', '--rm', '--name', containerName];
834
830
  if (options.user) {
835
831
  dockerArgs.push('--user', options.user);
@@ -841,6 +837,7 @@ function runInDocker(command, options = {}) {
841
837
  ? [shellToUse, shellInteractiveFlag]
842
838
  : [shellToUse];
843
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).
844
841
  let attachedCmdArgs;
845
842
  if (isBareShell) {
846
843
  const parts = command.trim().split(/\s+/);
@@ -849,6 +846,8 @@ function runInDocker(command, options = {}) {
849
846
  bareFlag && !parts.includes(bareFlag)
850
847
  ? [parts[0], bareFlag, ...parts.slice(1)]
851
848
  : parts;
849
+ } else if (isShellInvocationWithArgs(command)) {
850
+ attachedCmdArgs = buildShellWithArgsCmdArgs(command);
852
851
  } else {
853
852
  attachedCmdArgs = [...shellCmdArgs, '-c', command];
854
853
  }
@@ -973,6 +972,8 @@ module.exports = {
973
972
  isCommandAvailable,
974
973
  hasTTY,
975
974
  isInteractiveShellCommand,
975
+ isShellInvocationWithArgs,
976
+ buildShellWithArgsCmdArgs,
976
977
  detectShellInEnvironment,
977
978
  runInScreen,
978
979
  runInTmux,
@@ -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
+ });