start-command 0.24.5 → 0.24.7

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,38 @@
1
1
  # start-command
2
2
 
3
+ ## 0.24.7
4
+
5
+ ### Patch Changes
6
+
7
+ - 1eef620: fix: display `bash -c "..."` commands with quotes in command line output (issue #91)
8
+
9
+ When a command like `bash -i -c nvm --version` was passed to Docker isolation,
10
+ the displayed command line was missing quotes around the `-c` script argument,
11
+ making the output misleading (showing `bash -i -c nvm --version` instead of
12
+ `bash -i -c "nvm --version"`).
13
+
14
+ A new `buildDisplayCommand()` helper is added in `shell-utils.js` that quotes
15
+ any space-containing `-c` script arguments so the displayed command accurately
16
+ reflects how it was interpreted. Shell command helpers are extracted from
17
+ `isolation.js` into a new `shell-utils.js` module to keep file sizes within limits.
18
+
19
+ ## 0.24.6
20
+
21
+ ### Patch Changes
22
+
23
+ - cfc5731: fix: pass `bash -c "..."` style commands directly to Docker without double-wrapping (issue #91)
24
+
25
+ When a command like `bash -i -c "nvm --version"` was passed to Docker isolation,
26
+ it was incorrectly wrapped in an outer shell: `bash -i -c "bash -i -c nvm --version"`.
27
+ This caused two bugs: (1) the quoted argument `"nvm --version"` was split by the outer
28
+ shell, so `--version` became `$0` instead of part of the script; (2) the sudo advisory
29
+ message printed twice due to two nested bash invocations.
30
+
31
+ The fix adds `isShellInvocationWithArgs()` to detect commands that start with a shell
32
+ binary and include `-c`, and `buildShellWithArgsCmdArgs()` to reconstruct the correct
33
+ argv array. Such commands are now passed directly to Docker's exec, just like bare shell
34
+ invocations (issue #84), without any additional wrapping.
35
+
3
36
  ## 0.24.5
4
37
 
5
38
  ### 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.7",
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,12 +214,12 @@ function getShellInteractiveFlag(shellPath) {
217
214
  const shellName = shellPath.split('/').pop();
218
215
  return shellName === 'bash' || shellName === 'zsh' ? '-i' : null;
219
216
  }
220
- /** True if command is a bare shell invocation (no -c); avoids bash-inside-bash (issue #84). */
221
- function isInteractiveShellCommand(command) {
222
- 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');
225
- }
217
+ const {
218
+ isInteractiveShellCommand,
219
+ isShellInvocationWithArgs,
220
+ buildShellWithArgsCmdArgs,
221
+ buildDisplayCommand,
222
+ } = require('./shell-utils');
226
223
 
227
224
  /** Returns true if the current process has a TTY attached. */
228
225
  function hasTTY() {
@@ -428,9 +425,7 @@ function runInScreen(command, options = {}) {
428
425
  let effectiveCommand = wrapCommandWithUser(command, options.user);
429
426
 
430
427
  if (options.detached) {
431
- // Detached mode: screen -dmS <session> <shell> -c '<command>'
432
428
  if (options.keepAlive) {
433
- // With keep-alive: run command, then keep shell open
434
429
  effectiveCommand = `${effectiveCommand}; exec ${shell}`;
435
430
  }
436
431
 
@@ -466,14 +461,6 @@ function runInScreen(command, options = {}) {
466
461
  message,
467
462
  });
468
463
  } 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
464
  return runScreenWithLogCapture(
478
465
  command,
479
466
  sessionName,
@@ -515,9 +502,7 @@ function runInTmux(command, options = {}) {
515
502
 
516
503
  try {
517
504
  if (options.detached) {
518
- // Detached mode: tmux new-session -d -s <session> '<command>'
519
505
  if (options.keepAlive) {
520
- // With keep-alive: run command, then keep shell open
521
506
  effectiveCommand = `${effectiveCommand}; exec ${shell}`;
522
507
  }
523
508
 
@@ -549,7 +534,6 @@ function runInTmux(command, options = {}) {
549
534
  message,
550
535
  });
551
536
  } else {
552
- // Attached mode: tmux new-session -s <session> '<command>'
553
537
  if (DEBUG) {
554
538
  console.log(
555
539
  `[DEBUG] Running: tmux new-session -s "${sessionName}" "${effectiveCommand}"`
@@ -631,7 +615,6 @@ function runInSsh(command, options = {}) {
631
615
 
632
616
  try {
633
617
  if (options.detached) {
634
- // Detached mode: Run command in background via nohup; continues after SSH closes
635
618
  const remoteShell = useExplicitShell || shellToUse;
636
619
  const shellInvocation = shellInteractiveFlag
637
620
  ? `${remoteShell} ${shellInteractiveFlag}`
@@ -658,7 +641,6 @@ function runInSsh(command, options = {}) {
658
641
  message: `Command started in detached SSH session on ${sshTarget}\nSession: ${sessionName}\nView logs: ssh ${sshTarget} "tail -f /tmp/${sessionName}.log"`,
659
642
  });
660
643
  } else {
661
- // Attached mode: pass command directly (auto) or wrap with explicit shell + -i flag (bash/zsh).
662
644
  const extraFlags = shellInteractiveFlag ? [shellInteractiveFlag] : [];
663
645
  const sshArgs = isInteractiveShellCommand(command)
664
646
  ? [sshTarget, ...command.trim().split(/\s+/)]
@@ -747,8 +729,6 @@ function runInDocker(command, options = {}) {
747
729
  }
748
730
 
749
731
  const containerName = options.session || generateSessionName('docker');
750
-
751
- // Check if image exists locally; if not, pull it as a virtual command
752
732
  if (!dockerImageExists(options.image)) {
753
733
  const pullResult = dockerPullImage(options.image);
754
734
  if (!pullResult.success) {
@@ -767,14 +747,12 @@ function runInDocker(command, options = {}) {
767
747
  : detectShellInEnvironment('docker', options, options.shell);
768
748
  const shellInteractiveFlag = getShellInteractiveFlag(shellToUse);
769
749
 
770
- console.log(outputBlocks.createCommandLine(command));
750
+ console.log(outputBlocks.createCommandLine(buildDisplayCommand(command)));
771
751
  console.log();
772
752
 
773
753
  try {
774
754
  if (options.detached) {
775
- // Detached mode: docker run -d --name <name> [--user <user>] <image> <shell> -c '<command>'
776
755
  const dockerArgs = ['run', '-d', '--name', containerName];
777
- // --rm must come before the image name in args
778
756
  if (options.autoRemoveDockerContainer) {
779
757
  dockerArgs.splice(2, 0, '--rm');
780
758
  }
@@ -791,7 +769,9 @@ function runInDocker(command, options = {}) {
791
769
  : [shellToUse];
792
770
  const cmdArgs = isBareShell
793
771
  ? command.trim().split(/\s+/)
794
- : [...shellArgs, '-c', effectiveCommand];
772
+ : isShellInvocationWithArgs(command)
773
+ ? buildShellWithArgsCmdArgs(effectiveCommand)
774
+ : [...shellArgs, '-c', effectiveCommand];
795
775
  dockerArgs.push(options.image, ...cmdArgs);
796
776
 
797
777
  if (DEBUG) {
@@ -829,7 +809,6 @@ function runInDocker(command, options = {}) {
829
809
  message,
830
810
  });
831
811
  } else {
832
- // Attached mode: docker run -it --rm --name <name> [--user <user>] <image> <shell> -c '<cmd>'
833
812
  const dockerArgs = ['run', '-it', '--rm', '--name', containerName];
834
813
  if (options.user) {
835
814
  dockerArgs.push('--user', options.user);
@@ -841,6 +820,7 @@ function runInDocker(command, options = {}) {
841
820
  ? [shellToUse, shellInteractiveFlag]
842
821
  : [shellToUse];
843
822
  // Bare shell: pass directly with -i (avoids bash-inside-bash, issue #84; -i ensures interactive).
823
+ // Shell with -c: pass directly as argv (avoids double-wrapping and quote-stripping, issue #91).
844
824
  let attachedCmdArgs;
845
825
  if (isBareShell) {
846
826
  const parts = command.trim().split(/\s+/);
@@ -849,6 +829,8 @@ function runInDocker(command, options = {}) {
849
829
  bareFlag && !parts.includes(bareFlag)
850
830
  ? [parts[0], bareFlag, ...parts.slice(1)]
851
831
  : parts;
832
+ } else if (isShellInvocationWithArgs(command)) {
833
+ attachedCmdArgs = buildShellWithArgsCmdArgs(command);
852
834
  } else {
853
835
  attachedCmdArgs = [...shellCmdArgs, '-c', command];
854
836
  }
@@ -973,6 +955,9 @@ module.exports = {
973
955
  isCommandAvailable,
974
956
  hasTTY,
975
957
  isInteractiveShellCommand,
958
+ isShellInvocationWithArgs,
959
+ buildShellWithArgsCmdArgs,
960
+ buildDisplayCommand,
976
961
  detectShellInEnvironment,
977
962
  runInScreen,
978
963
  runInTmux,
@@ -0,0 +1,47 @@
1
+ /** Shell command detection and argument-building utilities for start-command */
2
+
3
+ const path = require('path');
4
+
5
+ const SHELL_NAMES = ['bash', 'zsh', 'sh', 'fish', 'ksh', 'csh', 'tcsh', 'dash'];
6
+
7
+ /** True if command is a bare shell invocation (no -c); avoids bash-inside-bash (issue #84). */
8
+ function isInteractiveShellCommand(command) {
9
+ const parts = command.trim().split(/\s+/);
10
+ return SHELL_NAMES.includes(path.basename(parts[0])) && !parts.includes('-c');
11
+ }
12
+
13
+ /** True if command is a shell invocation with -c (e.g. `bash -i -c "cmd"`); avoids double-wrapping (issue #91). */
14
+ function isShellInvocationWithArgs(command) {
15
+ const parts = command.trim().split(/\s+/);
16
+ return SHELL_NAMES.includes(path.basename(parts[0])) && parts.includes('-c');
17
+ }
18
+
19
+ /** Build argv for shell-with-c command; everything after -c is one argument (reverses commandArgs.join(' ')). */
20
+ function buildShellWithArgsCmdArgs(command) {
21
+ const parts = command.trim().split(/\s+/);
22
+ const cIdx = parts.indexOf('-c');
23
+ if (cIdx === -1) {
24
+ return parts;
25
+ }
26
+ const scriptArg = parts.slice(cIdx + 1).join(' ');
27
+ return scriptArg.length > 0
28
+ ? [...parts.slice(0, cIdx + 1), scriptArg]
29
+ : parts.slice(0, cIdx + 1);
30
+ }
31
+
32
+ /** Build a display string for a command, quoting arguments that contain spaces (issue #91). */
33
+ function buildDisplayCommand(command) {
34
+ if (!isShellInvocationWithArgs(command)) {
35
+ return command;
36
+ }
37
+ const argv = buildShellWithArgsCmdArgs(command);
38
+ return argv.map((arg) => (arg.includes(' ') ? `"${arg}"` : arg)).join(' ');
39
+ }
40
+
41
+ module.exports = {
42
+ SHELL_NAMES,
43
+ isInteractiveShellCommand,
44
+ isShellInvocationWithArgs,
45
+ buildShellWithArgsCmdArgs,
46
+ buildDisplayCommand,
47
+ };
@@ -0,0 +1,296 @@
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
+ buildDisplayCommand,
38
+ } = require('../src/lib/isolation');
39
+
40
+ // Helper: mirrors the attached-mode command-args construction logic in runInDocker.
41
+ // Returns the argv array that would be passed to `docker run ... image <argv>`.
42
+ function buildAttachedCmdArgs(command, shellToUse = '/bin/bash') {
43
+ const path = require('path');
44
+ const shellName = shellToUse.split('/').pop();
45
+ const shellInteractiveFlag =
46
+ shellName === 'bash' || shellName === 'zsh' ? '-i' : null;
47
+ const shellCmdArgs = shellInteractiveFlag
48
+ ? [shellToUse, shellInteractiveFlag]
49
+ : [shellToUse];
50
+
51
+ if (isInteractiveShellCommand(command)) {
52
+ // Bare shell: pass directly with explicit -i (issue #84 fix)
53
+ const parts = command.trim().split(/\s+/);
54
+ const bareFlag =
55
+ path.basename(parts[0]) === 'bash' || path.basename(parts[0]) === 'zsh'
56
+ ? '-i'
57
+ : null;
58
+ if (bareFlag && !parts.includes(bareFlag)) {
59
+ return [parts[0], bareFlag, ...parts.slice(1)];
60
+ }
61
+ return parts;
62
+ } else if (isShellInvocationWithArgs(command)) {
63
+ // Shell with -c: pass directly as argv (issue #91 fix)
64
+ return buildShellWithArgsCmdArgs(command);
65
+ }
66
+ return [...shellCmdArgs, '-c', command];
67
+ }
68
+
69
+ describe('isShellInvocationWithArgs (issue #91)', () => {
70
+ it('should return true for "bash -i -c nvm --version"', () => {
71
+ assert.strictEqual(
72
+ isShellInvocationWithArgs('bash -i -c nvm --version'),
73
+ true
74
+ );
75
+ });
76
+
77
+ it('should return true for \'bash -c "echo hello"\'', () => {
78
+ assert.strictEqual(isShellInvocationWithArgs('bash -c "echo hello"'), true);
79
+ });
80
+
81
+ it('should return true for "bash -c echo hello"', () => {
82
+ assert.strictEqual(isShellInvocationWithArgs('bash -c echo hello'), true);
83
+ });
84
+
85
+ it('should return true for "zsh -c nvm --version"', () => {
86
+ assert.strictEqual(isShellInvocationWithArgs('zsh -c nvm --version'), true);
87
+ });
88
+
89
+ it('should return true for "sh -c ls"', () => {
90
+ assert.strictEqual(isShellInvocationWithArgs('sh -c ls'), true);
91
+ });
92
+
93
+ it('should return true for "/bin/bash -c echo hi"', () => {
94
+ assert.strictEqual(isShellInvocationWithArgs('/bin/bash -c echo hi'), true);
95
+ });
96
+
97
+ it('should return false for bare "bash" (no -c)', () => {
98
+ assert.strictEqual(isShellInvocationWithArgs('bash'), false);
99
+ });
100
+
101
+ it('should return false for "bash -i" (no -c)', () => {
102
+ assert.strictEqual(isShellInvocationWithArgs('bash -i'), false);
103
+ });
104
+
105
+ it('should return false for "bash --norc" (no -c)', () => {
106
+ assert.strictEqual(isShellInvocationWithArgs('bash --norc'), false);
107
+ });
108
+
109
+ it('should return false for non-shell commands', () => {
110
+ assert.strictEqual(isShellInvocationWithArgs('nvm --version'), false);
111
+ assert.strictEqual(isShellInvocationWithArgs('echo hello'), false);
112
+ assert.strictEqual(isShellInvocationWithArgs('npm test'), false);
113
+ });
114
+ });
115
+
116
+ describe('buildShellWithArgsCmdArgs (issue #91)', () => {
117
+ it('should reconstruct "bash -i -c nvm --version" correctly', () => {
118
+ const result = buildShellWithArgsCmdArgs('bash -i -c nvm --version');
119
+ assert.deepStrictEqual(result, ['bash', '-i', '-c', 'nvm --version']);
120
+ });
121
+
122
+ it('should reconstruct "bash -c echo hello" correctly', () => {
123
+ const result = buildShellWithArgsCmdArgs('bash -c echo hello');
124
+ assert.deepStrictEqual(result, ['bash', '-c', 'echo hello']);
125
+ });
126
+
127
+ it('should handle single-word script "bash -c ls"', () => {
128
+ const result = buildShellWithArgsCmdArgs('bash -c ls');
129
+ assert.deepStrictEqual(result, ['bash', '-c', 'ls']);
130
+ });
131
+
132
+ it('should handle zsh with -c', () => {
133
+ const result = buildShellWithArgsCmdArgs('zsh -c nvm --version');
134
+ assert.deepStrictEqual(result, ['zsh', '-c', 'nvm --version']);
135
+ });
136
+
137
+ it('should handle /bin/bash -i -c with multi-word script', () => {
138
+ const result = buildShellWithArgsCmdArgs(
139
+ '/bin/bash -i -c node -e process.version'
140
+ );
141
+ assert.deepStrictEqual(result, [
142
+ '/bin/bash',
143
+ '-i',
144
+ '-c',
145
+ 'node -e process.version',
146
+ ]);
147
+ });
148
+
149
+ it('should not include -c argument inside script argument (no double -c)', () => {
150
+ const result = buildShellWithArgsCmdArgs('bash -i -c nvm --version');
151
+ // The script arg must be 'nvm --version', not '-c nvm --version'
152
+ assert.strictEqual(result[result.length - 1], 'nvm --version');
153
+ assert.strictEqual(result.indexOf('-c'), 2);
154
+ assert.strictEqual(result.length, 4);
155
+ });
156
+ });
157
+
158
+ describe('Regression: No Double-Wrapping for Shell With -c (issue #91)', () => {
159
+ // Each test verifies that `bash -i -c "cmd"` style commands are NOT re-wrapped
160
+ // in another outer shell -c invocation.
161
+ //
162
+ // Before fix: buildAttachedCmdArgs('bash -i -c nvm --version')
163
+ // → ['/bin/bash', '-i', '-c', 'bash -i -c nvm --version'] (WRONG: double-wrap)
164
+ // After fix: buildAttachedCmdArgs('bash -i -c nvm --version')
165
+ // → ['bash', '-i', '-c', 'nvm --version'] (CORRECT: direct pass)
166
+
167
+ it('should pass "bash -i -c nvm --version" directly without outer shell wrapper', () => {
168
+ const args = buildAttachedCmdArgs('bash -i -c nvm --version');
169
+ // Must start with 'bash', not with '/bin/bash' (the outer shellToUse)
170
+ assert.strictEqual(args[0], 'bash');
171
+ // Must not wrap in outer bash -i -c
172
+ assert.ok(
173
+ args.filter((a) => a === '-c').length === 1,
174
+ `Must have exactly one -c flag, got: ${JSON.stringify(args)}`
175
+ );
176
+ // The script argument must be 'nvm --version' as one element
177
+ assert.deepStrictEqual(
178
+ args,
179
+ ['bash', '-i', '-c', 'nvm --version'],
180
+ `Expected ['bash', '-i', '-c', 'nvm --version'], got: ${JSON.stringify(args)}`
181
+ );
182
+ });
183
+
184
+ it('should pass "zsh -c nvm --version" directly without outer shell wrapper', () => {
185
+ const args = buildAttachedCmdArgs('zsh -c nvm --version', '/bin/zsh');
186
+ assert.deepStrictEqual(args, ['zsh', '-c', 'nvm --version']);
187
+ assert.ok(
188
+ args.filter((a) => a === '-c').length === 1,
189
+ 'Must have exactly one -c flag'
190
+ );
191
+ });
192
+
193
+ it('should pass "bash -c echo hello" directly', () => {
194
+ const args = buildAttachedCmdArgs('bash -c echo hello');
195
+ assert.deepStrictEqual(args, ['bash', '-c', 'echo hello']);
196
+ });
197
+
198
+ it('should not introduce a second bash layer (no shell-inside-shell)', () => {
199
+ const args = buildAttachedCmdArgs('bash -i -c nvm --version');
200
+ // The first element of the docker image command args must NOT be the outer shell
201
+ // i.e., must not be ['/bin/bash', '-i', '-c', 'bash ...']
202
+ assert.notStrictEqual(
203
+ args[0],
204
+ '/bin/bash',
205
+ 'Must not wrap in outer /bin/bash (shell-inside-shell)'
206
+ );
207
+ // Must not contain the original full command string as an argument
208
+ assert.ok(
209
+ !args.includes('bash -i -c nvm --version'),
210
+ 'Must not contain the full command string as a single argument (double-wrap)'
211
+ );
212
+ });
213
+
214
+ it('should still handle bare "bash" with -i (regression guard for issue #84)', () => {
215
+ const args = buildAttachedCmdArgs('bash');
216
+ assert.deepStrictEqual(args, ['bash', '-i']);
217
+ assert.ok(
218
+ !args.includes('-c'),
219
+ 'Bare shell must not use -c (issue #84 guard)'
220
+ );
221
+ });
222
+
223
+ it('should still wrap non-shell commands in outer shell -c (guard against over-broad fix)', () => {
224
+ const args = buildAttachedCmdArgs('nvm --version', '/bin/bash');
225
+ assert.deepStrictEqual(args, ['/bin/bash', '-i', '-c', 'nvm --version']);
226
+ assert.ok(
227
+ args.includes('-c'),
228
+ 'Non-shell commands must still use -c wrapper'
229
+ );
230
+ });
231
+
232
+ it('should still wrap "npm test" in outer shell -c', () => {
233
+ const args = buildAttachedCmdArgs('npm test', '/bin/bash');
234
+ assert.deepStrictEqual(args, ['/bin/bash', '-i', '-c', 'npm test']);
235
+ });
236
+ });
237
+
238
+ describe('isShellInvocationWithArgs is mutually exclusive with isInteractiveShellCommand', () => {
239
+ // A command cannot be both a bare shell AND a shell-with-args; they are disjoint.
240
+ const testCases = [
241
+ 'bash',
242
+ 'bash -i',
243
+ 'bash --norc',
244
+ 'bash -i -c nvm --version',
245
+ 'bash -c echo hi',
246
+ 'zsh -c ls',
247
+ 'nvm --version',
248
+ 'echo hello',
249
+ ];
250
+
251
+ for (const cmd of testCases) {
252
+ it(`"${cmd}" is not both bare-shell and shell-with-args`, () => {
253
+ const bare = isInteractiveShellCommand(cmd);
254
+ const withArgs = isShellInvocationWithArgs(cmd);
255
+ assert.ok(
256
+ !(bare && withArgs),
257
+ `"${cmd}" must not return true from both helpers simultaneously`
258
+ );
259
+ });
260
+ }
261
+ });
262
+
263
+ describe('buildDisplayCommand (issue #91 display fix)', () => {
264
+ it('should quote the -c script argument when it contains spaces', () => {
265
+ assert.strictEqual(
266
+ buildDisplayCommand('bash -i -c nvm --version'),
267
+ 'bash -i -c "nvm --version"'
268
+ );
269
+ });
270
+
271
+ it('should quote the -c script argument for plain "bash -c echo hello"', () => {
272
+ assert.strictEqual(
273
+ buildDisplayCommand('bash -c echo hello'),
274
+ 'bash -c "echo hello"'
275
+ );
276
+ });
277
+
278
+ it('should not double-quote if script has no spaces', () => {
279
+ assert.strictEqual(buildDisplayCommand('bash -c ls'), 'bash -c ls');
280
+ });
281
+
282
+ it('should return command unchanged for non-shell-with-args commands', () => {
283
+ assert.strictEqual(buildDisplayCommand('nvm --version'), 'nvm --version');
284
+ });
285
+
286
+ it('should return command unchanged for bare shell invocations', () => {
287
+ assert.strictEqual(buildDisplayCommand('bash -i'), 'bash -i');
288
+ });
289
+
290
+ it('should handle zsh -c with spaces', () => {
291
+ assert.strictEqual(
292
+ buildDisplayCommand('zsh -c echo hello world'),
293
+ 'zsh -c "echo hello world"'
294
+ );
295
+ });
296
+ });