start-command 0.23.0 → 0.24.1

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,46 @@
1
1
  # start-command
2
2
 
3
+ ## 0.24.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 2cb0101: fix: avoid running shell inside shell when command is a bare shell invocation
8
+
9
+ When running `$ --isolated docker --image <image> -- bash`, the tool was
10
+ wrapping the bare `bash` command inside `bash -i -c bash`, causing `.bashrc`
11
+ to be sourced twice and printing errors twice. Same issue affected `zsh`, `sh`,
12
+ and other shells.
13
+
14
+ Added `isInteractiveShellCommand()` helper that detects when the user's command
15
+ is a bare interactive shell invocation (e.g. `bash`, `/bin/zsh`, `bash -l`).
16
+ When detected, the command is passed directly to the isolation environment
17
+ instead of being wrapped in another shell. Applies to Docker (attached and
18
+ detached), SSH (attached and detached), and Screen (attached and detached).
19
+
20
+ ## 0.24.0
21
+
22
+ ### Minor Changes
23
+
24
+ - 2ea7f43: feat: Use interactive shell mode in isolation environments to source startup files
25
+
26
+ In docker and ssh isolation environments, bash and zsh are now invoked with
27
+ the `-i` (interactive) flag when executing commands. This ensures that startup
28
+ files like `.bashrc` and `.zshrc` are sourced, making environment-dependent
29
+ tools like `nvm`, `rbenv`, `pyenv`, and similar version managers available
30
+ in isolated commands.
31
+
32
+ Previously, even though bash was correctly detected and used over sh, running
33
+ `nvm --version` in a Docker container would fail with "command not found"
34
+ because bash was started in non-interactive mode and did not source `.bashrc`.
35
+
36
+ With this fix:
37
+ - Docker: `docker run <image> bash -i -c "nvm --version"` sources `.bashrc`
38
+ - SSH: `ssh <host> bash -i -c "nvm --version"` sources `.bashrc` on the remote host
39
+ - `zsh` also gets the `-i` flag for the same reason
40
+ - `sh` does not get `-i` as it is used as a fallback for minimal containers
41
+
42
+ Fixes #79
43
+
3
44
  ## 0.23.0
4
45
 
5
46
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "start-command",
3
- "version": "0.23.0",
3
+ "version": "0.24.1",
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": {
@@ -211,20 +211,24 @@ function detectShellInEnvironment(
211
211
  return 'sh';
212
212
  }
213
213
 
214
- /**
215
- * Check if the current process has a TTY attached
216
- * @returns {boolean} True if TTY is available
217
- */
214
+ /** Returns "-i" for bash/zsh (enables .bashrc sourcing), null otherwise. */
215
+ function getShellInteractiveFlag(shellPath) {
216
+ const shellName = shellPath.split('/').pop();
217
+ return shellName === 'bash' || shellName === 'zsh' ? '-i' : null;
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
+ }
225
+
226
+ /** Returns true if the current process has a TTY attached. */
218
227
  function hasTTY() {
219
228
  return Boolean(process.stdin.isTTY && process.stdout.isTTY);
220
229
  }
221
230
 
222
- /**
223
- * Wrap command with sudo -u if user option is specified
224
- * @param {string} command - Original command
225
- * @param {string|null} user - Username to run as (or null)
226
- * @returns {string} Wrapped command
227
- */
231
+ /** Wraps command with sudo -u if user is specified; returns command unchanged otherwise. */
228
232
  function wrapCommandWithUser(command, user) {
229
233
  if (!user) {
230
234
  return command;
@@ -236,13 +240,8 @@ function wrapCommandWithUser(command, user) {
236
240
  }
237
241
 
238
242
  /**
239
- * Run command in GNU Screen using detached mode with log capture
240
- * This is a workaround for environments without TTY
241
- *
242
- * Supports two methods based on screen version:
243
- * - screen >= 4.5.1: Uses -L -Logfile option for native log capture
244
- * - screen < 4.5.1: Uses tee command within the wrapped command for output capture
245
- *
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).
246
245
  * @param {string} command - Command to execute
247
246
  * @param {string} sessionName - Session name
248
247
  * @param {object} shellInfo - Shell info from getShell()
@@ -265,16 +264,10 @@ function runScreenWithLogCapture(command, sessionName, shellInfo, user = null) {
265
264
  if (useNativeLogging) {
266
265
  // Modern screen (>= 4.5.1): Use -L -Logfile option for native log capture
267
266
  // screen -dmS <session> -L -Logfile <logfile> <shell> -c '<command>'
268
- screenArgs = [
269
- '-dmS',
270
- sessionName,
271
- '-L',
272
- '-Logfile',
273
- logFile,
274
- shell,
275
- shellArg,
276
- effectiveCommand,
277
- ];
267
+ const logArgs = ['-dmS', sessionName, '-L', '-Logfile', logFile];
268
+ screenArgs = isInteractiveShellCommand(command)
269
+ ? [...logArgs, ...command.trim().split(/\s+/)]
270
+ : [...logArgs, shell, shellArg, effectiveCommand];
278
271
 
279
272
  if (DEBUG) {
280
273
  console.log(
@@ -283,10 +276,14 @@ function runScreenWithLogCapture(command, sessionName, shellInfo, user = null) {
283
276
  }
284
277
  } else {
285
278
  // Older screen (< 4.5.1, e.g., macOS bundled 4.0.3): Use tee fallback
286
- // Wrap the command to capture output using tee
287
279
  // The parentheses ensure proper grouping of the command and its stderr
288
- effectiveCommand = `(${effectiveCommand}) 2>&1 | tee "${logFile}"`;
289
- 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];
290
287
 
291
288
  if (DEBUG) {
292
289
  console.log(
@@ -445,13 +442,9 @@ function runInScreen(command, options = {}) {
445
442
  }
446
443
  // Without keep-alive: command runs and session exits naturally when done
447
444
 
448
- const screenArgs = [
449
- '-dmS',
450
- sessionName,
451
- shell,
452
- shellArg,
453
- effectiveCommand,
454
- ];
445
+ const screenArgs = isInteractiveShellCommand(command)
446
+ ? ['-dmS', sessionName, ...command.trim().split(/\s+/)]
447
+ : ['-dmS', sessionName, shell, shellArg, effectiveCommand];
455
448
 
456
449
  if (DEBUG) {
457
450
  console.log(`[DEBUG] Running: screen ${screenArgs.join(' ')}`);
@@ -648,30 +641,33 @@ function runInSsh(command, options = {}) {
648
641
  const sessionName = options.session || generateSessionName('ssh');
649
642
  const sshTarget = options.endpoint;
650
643
 
651
- // Detect the shell to use on the remote host
652
- // In auto mode, detection may fall back to passing command directly to leverage
653
- // the remote user's default login shell (which may already be bash)
654
644
  const shellToUse = detectShellInEnvironment('ssh', options, options.shell);
655
- // Whether to wrap command with a shell (only when explicit shell is specified)
645
+ // In auto mode, SSH login shells already source startup files (.bashrc etc.);
646
+ // only wrap with an explicit shell when user requests one.
656
647
  const useExplicitShell =
657
648
  options.shell && options.shell !== 'auto' ? shellToUse : null;
649
+ const shellInteractiveFlag = useExplicitShell
650
+ ? getShellInteractiveFlag(useExplicitShell)
651
+ : null;
658
652
 
659
653
  try {
660
654
  if (options.detached) {
661
- // Detached mode: Run command in background on remote server using nohup
662
- // The command will continue running even after SSH connection closes
655
+ // Detached mode: Run command in background via nohup; continues after SSH closes
663
656
  const remoteShell = useExplicitShell || shellToUse;
664
- const remoteCommand = `nohup ${remoteShell} -c ${JSON.stringify(command)} > /tmp/${sessionName}.log 2>&1 &`;
657
+ const shellInvocation = shellInteractiveFlag
658
+ ? `${remoteShell} ${shellInteractiveFlag}`
659
+ : remoteShell;
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 &`;
665
663
  const sshArgs = [sshTarget, remoteCommand];
666
664
 
667
665
  if (DEBUG) {
668
666
  console.log(`[DEBUG] Running: ssh ${sshArgs.join(' ')}`);
669
- console.log(`[DEBUG] shell: ${remoteShell}`);
667
+ console.log(`[DEBUG] shell: ${shellInvocation}`);
670
668
  }
671
669
 
672
- const result = spawnSync('ssh', sshArgs, {
673
- stdio: 'inherit',
674
- });
670
+ const result = spawnSync('ssh', sshArgs, { stdio: 'inherit' });
675
671
 
676
672
  if (result.error) {
677
673
  throw result.error;
@@ -683,12 +679,13 @@ function runInSsh(command, options = {}) {
683
679
  message: `Command started in detached SSH session on ${sshTarget}\nSession: ${sessionName}\nView logs: ssh ${sshTarget} "tail -f /tmp/${sessionName}.log"`,
684
680
  });
685
681
  } else {
686
- // Attached mode: Run command interactively over SSH
687
- // When a specific shell is requested, wrap the command with that shell.
688
- // In auto mode, pass the command directly and let the remote's default shell handle it.
689
- const sshArgs = useExplicitShell
690
- ? [sshTarget, useExplicitShell, '-c', command]
691
- : [sshTarget, command];
682
+ // Attached mode: pass command directly (auto) or wrap with explicit shell + -i flag (bash/zsh).
683
+ const extraFlags = shellInteractiveFlag ? [shellInteractiveFlag] : [];
684
+ const sshArgs = isInteractiveShellCommand(command)
685
+ ? [sshTarget, ...command.trim().split(/\s+/)]
686
+ : useExplicitShell
687
+ ? [sshTarget, useExplicitShell, ...extraFlags, '-c', command]
688
+ : [sshTarget, command];
692
689
 
693
690
  if (DEBUG) {
694
691
  console.log(`[DEBUG] Running: ssh ${sshArgs.join(' ')}`);
@@ -769,8 +766,8 @@ function runInDocker(command, options = {}) {
769
766
  }
770
767
  }
771
768
 
772
- // Detect the shell to use in the container
773
769
  const shellToUse = detectShellInEnvironment('docker', options, options.shell);
770
+ const shellInteractiveFlag = getShellInteractiveFlag(shellToUse);
774
771
 
775
772
  // Print the user command (this appears after any virtual commands like docker pull)
776
773
  const { createCommandLine } = require('./output-blocks');
@@ -780,20 +777,12 @@ function runInDocker(command, options = {}) {
780
777
  try {
781
778
  if (options.detached) {
782
779
  // Detached mode: docker run -d --name <name> [--user <user>] <image> <shell> -c '<command>'
783
- // By default (keepAlive=false), the container exits after command completes
784
- // With keepAlive=true, we keep the container running with a shell
785
- let effectiveCommand = command;
786
-
787
- if (options.keepAlive) {
788
- // With keep-alive: run command, then keep shell alive
789
- effectiveCommand = `${command}; exec ${shellToUse}`;
790
- }
791
- // Without keep-alive: container exits naturally when command completes
792
-
780
+ const effectiveCommand = options.keepAlive
781
+ ? `${command}; exec ${shellToUse}`
782
+ : command;
793
783
  const dockerArgs = ['run', '-d', '--name', containerName];
794
784
 
795
- // Add --rm flag if autoRemoveDockerContainer is true
796
- // Note: --rm must come before the image name
785
+ // --rm must come before the image name
797
786
  if (options.autoRemoveDockerContainer) {
798
787
  dockerArgs.splice(2, 0, '--rm');
799
788
  }
@@ -803,7 +792,13 @@ function runInDocker(command, options = {}) {
803
792
  dockerArgs.push('--user', options.user);
804
793
  }
805
794
 
806
- dockerArgs.push(options.image, shellToUse, '-c', effectiveCommand);
795
+ const shellArgs = shellInteractiveFlag
796
+ ? [shellToUse, shellInteractiveFlag]
797
+ : [shellToUse];
798
+ const cmdArgs = isInteractiveShellCommand(command)
799
+ ? command.trim().split(/\s+/)
800
+ : [...shellArgs, '-c', effectiveCommand];
801
+ dockerArgs.push(options.image, ...cmdArgs);
807
802
 
808
803
  if (DEBUG) {
809
804
  console.log(`[DEBUG] Running: docker ${dockerArgs.join(' ')}`);
@@ -840,19 +835,22 @@ function runInDocker(command, options = {}) {
840
835
  message,
841
836
  });
842
837
  } else {
843
- // Attached mode: docker run -it --name <name> [--user <user>] <image> <shell> -c '<command>'
838
+ // Attached mode: docker run -it --rm --name <name> [--user <user>] <image> <shell> -c '<cmd>'
844
839
  const dockerArgs = ['run', '-it', '--rm', '--name', containerName];
845
-
846
- // Add --user flag if specified
847
840
  if (options.user) {
848
841
  dockerArgs.push('--user', options.user);
849
842
  }
850
-
851
843
  if (DEBUG) {
852
844
  console.log(`[DEBUG] shell: ${shellToUse}`);
853
845
  }
854
846
 
855
- dockerArgs.push(options.image, shellToUse, '-c', command);
847
+ const shellCmdArgs = shellInteractiveFlag
848
+ ? [shellToUse, shellInteractiveFlag]
849
+ : [shellToUse];
850
+ const attachedCmdArgs = isInteractiveShellCommand(command)
851
+ ? command.trim().split(/\s+/)
852
+ : [...shellCmdArgs, '-c', command];
853
+ dockerArgs.push(options.image, ...attachedCmdArgs);
856
854
 
857
855
  if (DEBUG) {
858
856
  console.log(`[DEBUG] Running: docker ${dockerArgs.join(' ')}`);
@@ -971,6 +969,7 @@ const {
971
969
  module.exports = {
972
970
  isCommandAvailable,
973
971
  hasTTY,
972
+ isInteractiveShellCommand,
974
973
  detectShellInEnvironment,
975
974
  runInScreen,
976
975
  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
+ });