start-command 0.22.0 → 0.24.0

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,47 @@
1
1
  # start-command
2
2
 
3
+ ## 0.24.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 2ea7f43: feat: Use interactive shell mode in isolation environments to source startup files
8
+
9
+ In docker and ssh isolation environments, bash and zsh are now invoked with
10
+ the `-i` (interactive) flag when executing commands. This ensures that startup
11
+ files like `.bashrc` and `.zshrc` are sourced, making environment-dependent
12
+ tools like `nvm`, `rbenv`, `pyenv`, and similar version managers available
13
+ in isolated commands.
14
+
15
+ Previously, even though bash was correctly detected and used over sh, running
16
+ `nvm --version` in a Docker container would fail with "command not found"
17
+ because bash was started in non-interactive mode and did not source `.bashrc`.
18
+
19
+ With this fix:
20
+ - Docker: `docker run <image> bash -i -c "nvm --version"` sources `.bashrc`
21
+ - SSH: `ssh <host> bash -i -c "nvm --version"` sources `.bashrc` on the remote host
22
+ - `zsh` also gets the `-i` flag for the same reason
23
+ - `sh` does not get `-i` as it is used as a fallback for minimal containers
24
+
25
+ Fixes #79
26
+
27
+ ## 0.23.0
28
+
29
+ ### Minor Changes
30
+
31
+ - b85d3cb: feat: Add shell auto-detection and --shell option for isolation environments
32
+
33
+ In docker and ssh isolation environments, the shell is now automatically
34
+ selected in order of preference: bash, zsh, sh (auto mode). A new `--shell`
35
+ option allows explicitly specifying the shell to use.
36
+ - Auto mode (default): probes the environment for the best available shell
37
+ - `--shell bash/zsh/sh`: forces a specific shell
38
+ - `--shell auto`: explicitly selects auto-detection mode
39
+
40
+ This enables tools like `nvm` to work correctly in Docker containers where
41
+ bash is available but sh does not source the necessary profile scripts.
42
+
43
+ Fixes #79
44
+
3
45
  ## 0.22.0
4
46
 
5
47
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "start-command",
3
- "version": "0.22.0",
3
+ "version": "0.24.0",
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": {
package/src/bin/cli.js CHANGED
@@ -331,6 +331,7 @@ Options:
331
331
  --keep-user Keep isolated user after command completes
332
332
  --keep-alive, -k Keep isolation environment alive after command exits
333
333
  --auto-remove-docker-container Auto-remove docker container after exit
334
+ --shell <shell> Shell to use in isolation environments: auto, bash, zsh, sh (default: auto)
334
335
  --use-command-stream Use command-stream library for execution (experimental)
335
336
  --status <uuid> Show status of execution by UUID (--output-format: links-notation|json|text)
336
337
  --cleanup Clean up stale "executing" records (crashed/killed processes)
@@ -575,6 +576,7 @@ async function runWithIsolation(
575
576
  user: createdUser,
576
577
  keepAlive: options.keepAlive,
577
578
  autoRemoveDockerContainer: options.autoRemoveDockerContainer,
579
+ shell: options.shell,
578
580
  });
579
581
  } else if (createdUser) {
580
582
  // Run directly as the created user (no isolation environment)
@@ -211,6 +211,12 @@ function detectShellInEnvironment(
211
211
  return 'sh';
212
212
  }
213
213
 
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
+
214
220
  /**
215
221
  * Check if the current process has a TTY attached
216
222
  * @returns {boolean} True if TTY is available
@@ -648,30 +654,31 @@ function runInSsh(command, options = {}) {
648
654
  const sessionName = options.session || generateSessionName('ssh');
649
655
  const sshTarget = options.endpoint;
650
656
 
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
657
  const shellToUse = detectShellInEnvironment('ssh', options, options.shell);
655
- // Whether to wrap command with a shell (only when explicit shell is specified)
658
+ // In auto mode, SSH login shells already source startup files (.bashrc etc.);
659
+ // only wrap with an explicit shell when user requests one.
656
660
  const useExplicitShell =
657
661
  options.shell && options.shell !== 'auto' ? shellToUse : null;
662
+ const shellInteractiveFlag = useExplicitShell
663
+ ? getShellInteractiveFlag(useExplicitShell)
664
+ : null;
658
665
 
659
666
  try {
660
667
  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
668
+ // Detached mode: Run command in background via nohup; continues after SSH closes
663
669
  const remoteShell = useExplicitShell || shellToUse;
664
- const remoteCommand = `nohup ${remoteShell} -c ${JSON.stringify(command)} > /tmp/${sessionName}.log 2>&1 &`;
670
+ const shellInvocation = shellInteractiveFlag
671
+ ? `${remoteShell} ${shellInteractiveFlag}`
672
+ : remoteShell;
673
+ const remoteCommand = `nohup ${shellInvocation} -c ${JSON.stringify(command)} > /tmp/${sessionName}.log 2>&1 &`;
665
674
  const sshArgs = [sshTarget, remoteCommand];
666
675
 
667
676
  if (DEBUG) {
668
677
  console.log(`[DEBUG] Running: ssh ${sshArgs.join(' ')}`);
669
- console.log(`[DEBUG] shell: ${remoteShell}`);
678
+ console.log(`[DEBUG] shell: ${shellInvocation}`);
670
679
  }
671
680
 
672
- const result = spawnSync('ssh', sshArgs, {
673
- stdio: 'inherit',
674
- });
681
+ const result = spawnSync('ssh', sshArgs, { stdio: 'inherit' });
675
682
 
676
683
  if (result.error) {
677
684
  throw result.error;
@@ -683,11 +690,10 @@ function runInSsh(command, options = {}) {
683
690
  message: `Command started in detached SSH session on ${sshTarget}\nSession: ${sessionName}\nView logs: ssh ${sshTarget} "tail -f /tmp/${sessionName}.log"`,
684
691
  });
685
692
  } 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.
693
+ // Attached mode: pass command directly (auto) or wrap with explicit shell + -i flag (bash/zsh).
694
+ const extraFlags = shellInteractiveFlag ? [shellInteractiveFlag] : [];
689
695
  const sshArgs = useExplicitShell
690
- ? [sshTarget, useExplicitShell, '-c', command]
696
+ ? [sshTarget, useExplicitShell, ...extraFlags, '-c', command]
691
697
  : [sshTarget, command];
692
698
 
693
699
  if (DEBUG) {
@@ -769,8 +775,8 @@ function runInDocker(command, options = {}) {
769
775
  }
770
776
  }
771
777
 
772
- // Detect the shell to use in the container
773
778
  const shellToUse = detectShellInEnvironment('docker', options, options.shell);
779
+ const shellInteractiveFlag = getShellInteractiveFlag(shellToUse);
774
780
 
775
781
  // Print the user command (this appears after any virtual commands like docker pull)
776
782
  const { createCommandLine } = require('./output-blocks');
@@ -780,20 +786,12 @@ function runInDocker(command, options = {}) {
780
786
  try {
781
787
  if (options.detached) {
782
788
  // 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
-
789
+ const effectiveCommand = options.keepAlive
790
+ ? `${command}; exec ${shellToUse}`
791
+ : command;
793
792
  const dockerArgs = ['run', '-d', '--name', containerName];
794
793
 
795
- // Add --rm flag if autoRemoveDockerContainer is true
796
- // Note: --rm must come before the image name
794
+ // --rm must come before the image name
797
795
  if (options.autoRemoveDockerContainer) {
798
796
  dockerArgs.splice(2, 0, '--rm');
799
797
  }
@@ -803,7 +801,10 @@ function runInDocker(command, options = {}) {
803
801
  dockerArgs.push('--user', options.user);
804
802
  }
805
803
 
806
- dockerArgs.push(options.image, shellToUse, '-c', effectiveCommand);
804
+ const shellArgs = shellInteractiveFlag
805
+ ? [shellToUse, shellInteractiveFlag]
806
+ : [shellToUse];
807
+ dockerArgs.push(options.image, ...shellArgs, '-c', effectiveCommand);
807
808
 
808
809
  if (DEBUG) {
809
810
  console.log(`[DEBUG] Running: docker ${dockerArgs.join(' ')}`);
@@ -840,19 +841,19 @@ function runInDocker(command, options = {}) {
840
841
  message,
841
842
  });
842
843
  } else {
843
- // Attached mode: docker run -it --name <name> [--user <user>] <image> <shell> -c '<command>'
844
+ // Attached mode: docker run -it --rm --name <name> [--user <user>] <image> <shell> -c '<cmd>'
844
845
  const dockerArgs = ['run', '-it', '--rm', '--name', containerName];
845
-
846
- // Add --user flag if specified
847
846
  if (options.user) {
848
847
  dockerArgs.push('--user', options.user);
849
848
  }
850
-
851
849
  if (DEBUG) {
852
850
  console.log(`[DEBUG] shell: ${shellToUse}`);
853
851
  }
854
852
 
855
- dockerArgs.push(options.image, shellToUse, '-c', command);
853
+ const shellCmdArgs = shellInteractiveFlag
854
+ ? [shellToUse, shellInteractiveFlag]
855
+ : [shellToUse];
856
+ dockerArgs.push(options.image, ...shellCmdArgs, '-c', command);
856
857
 
857
858
  if (DEBUG) {
858
859
  console.log(`[DEBUG] Running: docker ${dockerArgs.join(' ')}`);
@@ -824,4 +824,75 @@ describe('detectShellInEnvironment', () => {
824
824
  );
825
825
  console.log(` Detected shell in alpine:latest: ${result}`);
826
826
  });
827
+
828
+ it('should read shell preference from options.shell', () => {
829
+ // Test that detectShellInEnvironment respects options.shell field
830
+ const result = detectShellInEnvironment(
831
+ 'docker',
832
+ { image: 'alpine:latest', shell: 'bash' },
833
+ 'bash'
834
+ );
835
+ assert.strictEqual(result, 'bash');
836
+ });
837
+ });
838
+
839
+ describe('Shell option forwarding to isolation runners', () => {
840
+ // These tests verify that the shell option is properly forwarded
841
+ // through the call chain: cli.js -> runIsolated -> runInDocker/runInSsh
842
+
843
+ const { runInDocker, runInSsh } = require('../src/lib/isolation');
844
+
845
+ describe('runInDocker shell option', () => {
846
+ it('should accept shell option in options object', async () => {
847
+ if (!isCommandAvailable('docker')) {
848
+ console.log(' Skipping: docker not installed');
849
+ return;
850
+ }
851
+
852
+ // Test with explicit shell option - just verify the option is accepted
853
+ // and the function runs (it may fail if image is not available)
854
+ const result = await runInDocker('echo test', {
855
+ image: 'alpine:latest',
856
+ detached: true,
857
+ shell: 'sh',
858
+ });
859
+ // If docker is available, verify the result
860
+ console.log(` runInDocker with --shell sh result: ${result.success}`);
861
+ // The result can be success or failure depending on docker availability
862
+ // The important thing is no crash due to missing shell option
863
+ assert.ok(typeof result.success === 'boolean');
864
+ });
865
+ });
866
+
867
+ describe('runInSsh shell option', () => {
868
+ it('should accept shell option in options object', async () => {
869
+ // SSH requires endpoint, so without it we get expected error
870
+ const result = await runInSsh('echo test', {
871
+ detached: true,
872
+ shell: 'bash',
873
+ });
874
+ assert.strictEqual(result.success, false);
875
+ // Should fail with endpoint error, not shell option error
876
+ assert.ok(
877
+ result.message.includes('endpoint') ||
878
+ result.message.includes('--endpoint') ||
879
+ result.message.includes('SSH isolation requires')
880
+ );
881
+ });
882
+
883
+ it('should use auto mode when shell option is auto', async () => {
884
+ // Verify auto mode doesn't crash
885
+ const result = await runInSsh('echo test', {
886
+ detached: true,
887
+ shell: 'auto',
888
+ });
889
+ assert.strictEqual(result.success, false);
890
+ // Should fail with endpoint error in auto mode
891
+ assert.ok(
892
+ result.message.includes('endpoint') ||
893
+ result.message.includes('--endpoint') ||
894
+ result.message.includes('SSH isolation requires')
895
+ );
896
+ });
897
+ });
827
898
  });