start-command 0.24.0 → 0.24.2

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,40 @@
1
1
  # start-command
2
2
 
3
+ ## 0.24.2
4
+
5
+ ### Patch Changes
6
+
7
+ - f870146: perf: skip shell detection probe when command is a bare shell invocation
8
+
9
+ When running `$ --isolated docker -- bash`, the tool previously ran a probe
10
+ container (`docker run --rm image sh -c 'command -v bash'`) to detect which
11
+ shell to use, even though the result was unused for bare shell commands.
12
+
13
+ Now `isInteractiveShellCommand(command)` is evaluated first, and
14
+ `detectShellInEnvironment` is only called when the command is not a bare shell.
15
+ This avoids up to three unnecessary container starts per invocation and eliminates
16
+ spurious failures when the probe itself fails on images with complex entrypoints.
17
+
18
+ Also caches the `isInteractiveShellCommand(command)` result in `isBareShell` to
19
+ avoid redundant calls in both attached and detached code paths.
20
+
21
+ ## 0.24.1
22
+
23
+ ### Patch Changes
24
+
25
+ - 2cb0101: fix: avoid running shell inside shell when command is a bare shell invocation
26
+
27
+ When running `$ --isolated docker --image <image> -- bash`, the tool was
28
+ wrapping the bare `bash` command inside `bash -i -c bash`, causing `.bashrc`
29
+ to be sourced twice and printing errors twice. Same issue affected `zsh`, `sh`,
30
+ and other shells.
31
+
32
+ Added `isInteractiveShellCommand()` helper that detects when the user's command
33
+ is a bare interactive shell invocation (e.g. `bash`, `/bin/zsh`, `bash -l`).
34
+ When detected, the command is passed directly to the isolation environment
35
+ instead of being wrapped in another shell. Applies to Docker (attached and
36
+ detached), SSH (attached and detached), and Screen (attached and detached).
37
+
3
38
  ## 0.24.0
4
39
 
5
40
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "start-command",
3
- "version": "0.24.0",
3
+ "version": "0.24.2",
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": {
@@ -216,21 +216,19 @@ function getShellInteractiveFlag(shellPath) {
216
216
  const shellName = shellPath.split('/').pop();
217
217
  return shellName === 'bash' || shellName === 'zsh' ? '-i' : null;
218
218
  }
219
+ /** True if command is a bare shell invocation (no -c); avoids bash-inside-bash (issue #84). */
220
+ function isInteractiveShellCommand(command) {
221
+ 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');
224
+ }
219
225
 
220
- /**
221
- * Check if the current process has a TTY attached
222
- * @returns {boolean} True if TTY is available
223
- */
226
+ /** Returns true if the current process has a TTY attached. */
224
227
  function hasTTY() {
225
228
  return Boolean(process.stdin.isTTY && process.stdout.isTTY);
226
229
  }
227
230
 
228
- /**
229
- * Wrap command with sudo -u if user option is specified
230
- * @param {string} command - Original command
231
- * @param {string|null} user - Username to run as (or null)
232
- * @returns {string} Wrapped command
233
- */
231
+ /** Wraps command with sudo -u if user is specified; returns command unchanged otherwise. */
234
232
  function wrapCommandWithUser(command, user) {
235
233
  if (!user) {
236
234
  return command;
@@ -242,13 +240,8 @@ function wrapCommandWithUser(command, user) {
242
240
  }
243
241
 
244
242
  /**
245
- * Run command in GNU Screen using detached mode with log capture
246
- * This is a workaround for environments without TTY
247
- *
248
- * Supports two methods based on screen version:
249
- * - screen >= 4.5.1: Uses -L -Logfile option for native log capture
250
- * - screen < 4.5.1: Uses tee command within the wrapped command for output capture
251
- *
243
+ * Run command in GNU Screen using detached mode with log capture.
244
+ * Supports screen >= 4.5.1 (native -Logfile) and older versions (tee fallback).
252
245
  * @param {string} command - Command to execute
253
246
  * @param {string} sessionName - Session name
254
247
  * @param {object} shellInfo - Shell info from getShell()
@@ -271,16 +264,10 @@ function runScreenWithLogCapture(command, sessionName, shellInfo, user = null) {
271
264
  if (useNativeLogging) {
272
265
  // Modern screen (>= 4.5.1): Use -L -Logfile option for native log capture
273
266
  // screen -dmS <session> -L -Logfile <logfile> <shell> -c '<command>'
274
- screenArgs = [
275
- '-dmS',
276
- sessionName,
277
- '-L',
278
- '-Logfile',
279
- logFile,
280
- shell,
281
- shellArg,
282
- effectiveCommand,
283
- ];
267
+ const logArgs = ['-dmS', sessionName, '-L', '-Logfile', logFile];
268
+ screenArgs = isInteractiveShellCommand(command)
269
+ ? [...logArgs, ...command.trim().split(/\s+/)]
270
+ : [...logArgs, shell, shellArg, effectiveCommand];
284
271
 
285
272
  if (DEBUG) {
286
273
  console.log(
@@ -289,10 +276,14 @@ function runScreenWithLogCapture(command, sessionName, shellInfo, user = null) {
289
276
  }
290
277
  } else {
291
278
  // Older screen (< 4.5.1, e.g., macOS bundled 4.0.3): Use tee fallback
292
- // Wrap the command to capture output using tee
293
279
  // The parentheses ensure proper grouping of the command and its stderr
294
- effectiveCommand = `(${effectiveCommand}) 2>&1 | tee "${logFile}"`;
295
- screenArgs = ['-dmS', sessionName, shell, shellArg, effectiveCommand];
280
+ const isBareShell = isInteractiveShellCommand(command);
281
+ if (!isBareShell) {
282
+ effectiveCommand = `(${effectiveCommand}) 2>&1 | tee "${logFile}"`;
283
+ }
284
+ screenArgs = isBareShell
285
+ ? ['-dmS', sessionName, ...command.trim().split(/\s+/)]
286
+ : ['-dmS', sessionName, shell, shellArg, effectiveCommand];
296
287
 
297
288
  if (DEBUG) {
298
289
  console.log(
@@ -451,13 +442,9 @@ function runInScreen(command, options = {}) {
451
442
  }
452
443
  // Without keep-alive: command runs and session exits naturally when done
453
444
 
454
- const screenArgs = [
455
- '-dmS',
456
- sessionName,
457
- shell,
458
- shellArg,
459
- effectiveCommand,
460
- ];
445
+ const screenArgs = isInteractiveShellCommand(command)
446
+ ? ['-dmS', sessionName, ...command.trim().split(/\s+/)]
447
+ : ['-dmS', sessionName, shell, shellArg, effectiveCommand];
461
448
 
462
449
  if (DEBUG) {
463
450
  console.log(`[DEBUG] Running: screen ${screenArgs.join(' ')}`);
@@ -670,7 +657,9 @@ function runInSsh(command, options = {}) {
670
657
  const shellInvocation = shellInteractiveFlag
671
658
  ? `${remoteShell} ${shellInteractiveFlag}`
672
659
  : remoteShell;
673
- const remoteCommand = `nohup ${shellInvocation} -c ${JSON.stringify(command)} > /tmp/${sessionName}.log 2>&1 &`;
660
+ const remoteCommand = isInteractiveShellCommand(command)
661
+ ? `nohup ${command} > /tmp/${sessionName}.log 2>&1 &`
662
+ : `nohup ${shellInvocation} -c ${JSON.stringify(command)} > /tmp/${sessionName}.log 2>&1 &`;
674
663
  const sshArgs = [sshTarget, remoteCommand];
675
664
 
676
665
  if (DEBUG) {
@@ -692,9 +681,11 @@ function runInSsh(command, options = {}) {
692
681
  } else {
693
682
  // Attached mode: pass command directly (auto) or wrap with explicit shell + -i flag (bash/zsh).
694
683
  const extraFlags = shellInteractiveFlag ? [shellInteractiveFlag] : [];
695
- const sshArgs = useExplicitShell
696
- ? [sshTarget, useExplicitShell, ...extraFlags, '-c', command]
697
- : [sshTarget, command];
684
+ const sshArgs = isInteractiveShellCommand(command)
685
+ ? [sshTarget, ...command.trim().split(/\s+/)]
686
+ : useExplicitShell
687
+ ? [sshTarget, useExplicitShell, ...extraFlags, '-c', command]
688
+ : [sshTarget, command];
698
689
 
699
690
  if (DEBUG) {
700
691
  console.log(`[DEBUG] Running: ssh ${sshArgs.join(' ')}`);
@@ -775,7 +766,10 @@ function runInDocker(command, options = {}) {
775
766
  }
776
767
  }
777
768
 
778
- const shellToUse = detectShellInEnvironment('docker', options, options.shell);
769
+ const isBareShell = isInteractiveShellCommand(command);
770
+ const shellToUse = isBareShell
771
+ ? 'sh'
772
+ : detectShellInEnvironment('docker', options, options.shell);
779
773
  const shellInteractiveFlag = getShellInteractiveFlag(shellToUse);
780
774
 
781
775
  // Print the user command (this appears after any virtual commands like docker pull)
@@ -786,9 +780,6 @@ function runInDocker(command, options = {}) {
786
780
  try {
787
781
  if (options.detached) {
788
782
  // Detached mode: docker run -d --name <name> [--user <user>] <image> <shell> -c '<command>'
789
- const effectiveCommand = options.keepAlive
790
- ? `${command}; exec ${shellToUse}`
791
- : command;
792
783
  const dockerArgs = ['run', '-d', '--name', containerName];
793
784
 
794
785
  // --rm must come before the image name
@@ -801,10 +792,16 @@ function runInDocker(command, options = {}) {
801
792
  dockerArgs.push('--user', options.user);
802
793
  }
803
794
 
795
+ const effectiveCommand = options.keepAlive
796
+ ? `${command}; exec ${shellToUse}`
797
+ : command;
804
798
  const shellArgs = shellInteractiveFlag
805
799
  ? [shellToUse, shellInteractiveFlag]
806
800
  : [shellToUse];
807
- dockerArgs.push(options.image, ...shellArgs, '-c', effectiveCommand);
801
+ const cmdArgs = isBareShell
802
+ ? command.trim().split(/\s+/)
803
+ : [...shellArgs, '-c', effectiveCommand];
804
+ dockerArgs.push(options.image, ...cmdArgs);
808
805
 
809
806
  if (DEBUG) {
810
807
  console.log(`[DEBUG] Running: docker ${dockerArgs.join(' ')}`);
@@ -853,7 +850,10 @@ function runInDocker(command, options = {}) {
853
850
  const shellCmdArgs = shellInteractiveFlag
854
851
  ? [shellToUse, shellInteractiveFlag]
855
852
  : [shellToUse];
856
- dockerArgs.push(options.image, ...shellCmdArgs, '-c', command);
853
+ const attachedCmdArgs = isBareShell
854
+ ? command.trim().split(/\s+/)
855
+ : [...shellCmdArgs, '-c', command];
856
+ dockerArgs.push(options.image, ...attachedCmdArgs);
857
857
 
858
858
  if (DEBUG) {
859
859
  console.log(`[DEBUG] Running: docker ${dockerArgs.join(' ')}`);
@@ -972,6 +972,7 @@ const {
972
972
  module.exports = {
973
973
  isCommandAvailable,
974
974
  hasTTY,
975
+ isInteractiveShellCommand,
975
976
  detectShellInEnvironment,
976
977
  runInScreen,
977
978
  runInTmux,
@@ -896,3 +896,59 @@ describe('Shell option forwarding to isolation runners', () => {
896
896
  });
897
897
  });
898
898
  });
899
+
900
+ describe('isInteractiveShellCommand (issue #84)', () => {
901
+ const { isInteractiveShellCommand } = require('../src/lib/isolation');
902
+
903
+ it('should return true for "bash"', () => {
904
+ assert.strictEqual(isInteractiveShellCommand('bash'), true);
905
+ });
906
+
907
+ it('should return true for "zsh"', () => {
908
+ assert.strictEqual(isInteractiveShellCommand('zsh'), true);
909
+ });
910
+
911
+ it('should return true for "sh"', () => {
912
+ assert.strictEqual(isInteractiveShellCommand('sh'), true);
913
+ });
914
+
915
+ it('should return true for "/bin/bash"', () => {
916
+ assert.strictEqual(isInteractiveShellCommand('/bin/bash'), true);
917
+ });
918
+
919
+ it('should return true for "/usr/bin/zsh"', () => {
920
+ assert.strictEqual(isInteractiveShellCommand('/usr/bin/zsh'), true);
921
+ });
922
+
923
+ it('should return true for "bash -l" (login flag, no -c)', () => {
924
+ assert.strictEqual(isInteractiveShellCommand('bash -l'), true);
925
+ });
926
+
927
+ it('should return false for "bash -c echo"', () => {
928
+ assert.strictEqual(isInteractiveShellCommand('bash -c echo'), false);
929
+ });
930
+
931
+ it('should return false for "bash -c \'echo hi\'"', () => {
932
+ assert.strictEqual(isInteractiveShellCommand("bash -c 'echo hi'"), false);
933
+ });
934
+
935
+ it('should return false for "npm test"', () => {
936
+ assert.strictEqual(isInteractiveShellCommand('npm test'), false);
937
+ });
938
+
939
+ it('should return false for "echo hello"', () => {
940
+ assert.strictEqual(isInteractiveShellCommand('echo hello'), false);
941
+ });
942
+
943
+ it('should return false for "ls -la"', () => {
944
+ assert.strictEqual(isInteractiveShellCommand('ls -la'), false);
945
+ });
946
+
947
+ it('should return false for empty string', () => {
948
+ assert.strictEqual(isInteractiveShellCommand(''), false);
949
+ });
950
+
951
+ it('should return false for " "', () => {
952
+ assert.strictEqual(isInteractiveShellCommand(' '), false);
953
+ });
954
+ });
@@ -0,0 +1,159 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Regression tests for issue #84: "We should not run bash inside bash"
4
+ *
5
+ * These tests guard against the shell-inside-shell regression where
6
+ * `$ --isolated docker -- bash` caused:
7
+ * docker run ... image /bin/bash -i -c bash (WRONG: bash inside bash)
8
+ * instead of:
9
+ * docker run ... image bash (CORRECT: bare shell)
10
+ *
11
+ * The same regression applies to zsh, sh, and all other isolation backends.
12
+ *
13
+ * Reference: https://github.com/link-foundation/start/issues/84
14
+ * Fixed in: PR #85 (v0.24.1) via isInteractiveShellCommand()
15
+ */
16
+
17
+ const { describe, it } = require('node:test');
18
+ const assert = require('assert');
19
+ const { isInteractiveShellCommand } = require('../src/lib/isolation');
20
+
21
+ // Helper: mirrors the command-args construction logic used in
22
+ // runInDocker (attached + detached), runInScreen, and runInSsh.
23
+ // If this helper returns args containing '-c' for a bare shell command,
24
+ // the shell-inside-shell bug is present.
25
+ function buildCmdArgs(command, shellToUse = '/bin/bash') {
26
+ const shellName = shellToUse.split('/').pop();
27
+ const shellInteractiveFlag =
28
+ shellName === 'bash' || shellName === 'zsh' ? '-i' : null;
29
+ const shellArgs = shellInteractiveFlag
30
+ ? [shellToUse, shellInteractiveFlag]
31
+ : [shellToUse];
32
+ return isInteractiveShellCommand(command)
33
+ ? command.trim().split(/\s+/)
34
+ : [...shellArgs, '-c', command];
35
+ }
36
+
37
+ describe('isInteractiveShellCommand additional cases (issue #84)', () => {
38
+ // These cover edge cases not in the base isInteractiveShellCommand test suite.
39
+
40
+ // Workaround: bash --norc skips .bashrc sourcing (post-fix regression workaround)
41
+ it('should return true for "bash --norc"', () => {
42
+ assert.strictEqual(isInteractiveShellCommand('bash --norc'), true);
43
+ });
44
+
45
+ it('should return true for "zsh --no-rcs"', () => {
46
+ assert.strictEqual(isInteractiveShellCommand('zsh --no-rcs'), true);
47
+ });
48
+
49
+ it('should return true for "bash -i" (interactive flag, no -c)', () => {
50
+ assert.strictEqual(isInteractiveShellCommand('bash -i'), true);
51
+ });
52
+
53
+ it('should return true for "fish"', () => {
54
+ assert.strictEqual(isInteractiveShellCommand('fish'), true);
55
+ });
56
+
57
+ it('should return true for "dash"', () => {
58
+ assert.strictEqual(isInteractiveShellCommand('dash'), true);
59
+ });
60
+
61
+ it('should return true for "/usr/local/bin/bash"', () => {
62
+ assert.strictEqual(isInteractiveShellCommand('/usr/local/bin/bash'), true);
63
+ });
64
+
65
+ it('should return false for \'bash -c "echo hello"\'', () => {
66
+ assert.strictEqual(
67
+ isInteractiveShellCommand('bash -c "echo hello"'),
68
+ false
69
+ );
70
+ });
71
+ });
72
+
73
+ describe('Regression: No Shell-Inside-Shell (issue #84)', () => {
74
+ // Each test verifies that the command-arg construction logic does NOT
75
+ // wrap a bare shell invocation inside another shell with `-c`.
76
+ //
77
+ // Before fix: buildCmdArgs('bash') → ['/bin/bash', '-i', '-c', 'bash']
78
+ // After fix: buildCmdArgs('bash') → ['bash']
79
+
80
+ it('should pass "bash" directly, not wrap in shell -c', () => {
81
+ const args = buildCmdArgs('bash');
82
+ assert.deepStrictEqual(
83
+ args,
84
+ ['bash'],
85
+ `Expected ["bash"], got: ${JSON.stringify(args)}`
86
+ );
87
+ assert.ok(
88
+ !args.includes('-c'),
89
+ 'Must not contain -c flag (shell-inside-shell)'
90
+ );
91
+ });
92
+
93
+ it('should pass "zsh" directly, not wrap in shell -c', () => {
94
+ const args = buildCmdArgs('zsh');
95
+ assert.deepStrictEqual(args, ['zsh']);
96
+ assert.ok(
97
+ !args.includes('-c'),
98
+ 'Must not contain -c flag (shell-inside-shell)'
99
+ );
100
+ });
101
+
102
+ it('should pass "sh" directly, not wrap in shell -c', () => {
103
+ const args = buildCmdArgs('sh', 'sh');
104
+ assert.deepStrictEqual(args, ['sh']);
105
+ assert.ok(
106
+ !args.includes('-c'),
107
+ 'Must not contain -c flag (shell-inside-shell)'
108
+ );
109
+ });
110
+
111
+ it('should pass "/bin/bash" directly, not wrap in shell -c', () => {
112
+ const args = buildCmdArgs('/bin/bash');
113
+ assert.deepStrictEqual(args, ['/bin/bash']);
114
+ assert.ok(
115
+ !args.includes('-c'),
116
+ 'Must not contain -c flag (shell-inside-shell)'
117
+ );
118
+ });
119
+
120
+ it('should pass "bash --norc" directly (workaround for broken .bashrc)', () => {
121
+ const args = buildCmdArgs('bash --norc');
122
+ assert.deepStrictEqual(args, ['bash', '--norc']);
123
+ assert.ok(!args.includes('-c'), 'Must not contain -c flag');
124
+ });
125
+
126
+ it('should pass "bash -l" directly (login shell)', () => {
127
+ const args = buildCmdArgs('bash -l');
128
+ assert.deepStrictEqual(args, ['bash', '-l']);
129
+ assert.ok(!args.includes('-c'), 'Must not contain -c flag');
130
+ });
131
+
132
+ it('should still wrap non-shell commands in shell -c (guard against over-broad fix)', () => {
133
+ const args = buildCmdArgs('echo hello', '/bin/bash');
134
+ assert.deepStrictEqual(args, ['/bin/bash', '-i', '-c', 'echo hello']);
135
+ assert.ok(
136
+ args.includes('-c'),
137
+ 'Non-shell commands must still use -c wrapper'
138
+ );
139
+ });
140
+
141
+ it('should still wrap "npm test" in shell -c (guard against over-broad fix)', () => {
142
+ const args = buildCmdArgs('npm test', '/bin/bash');
143
+ assert.deepStrictEqual(args, ['/bin/bash', '-i', '-c', 'npm test']);
144
+ assert.ok(
145
+ args.includes('-c'),
146
+ 'Non-shell commands must still use -c wrapper'
147
+ );
148
+ });
149
+
150
+ it('should not treat "bash -c something" as bare shell', () => {
151
+ // bash -c ... has -c, so isInteractiveShellCommand returns false
152
+ // The command gets wrapped: ['/bin/bash', '-i', '-c', 'bash -c "echo hi"']
153
+ const args = buildCmdArgs('bash -c "echo hi"', '/bin/bash');
154
+ assert.ok(
155
+ args.includes('-c'),
156
+ 'bash -c commands should be treated as regular commands'
157
+ );
158
+ });
159
+ });