start-command 0.21.0 → 0.22.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,37 @@
1
1
  # start-command
2
2
 
3
+ ## 0.22.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 694d85e: feat: Add shell auto-detection and --shell option for isolation environments
8
+
9
+ In docker/ssh and other applicable isolation environments, the shell is now
10
+ automatically detected in order of preference: `bash` → `zsh` → `sh`.
11
+
12
+ Previously, `/bin/sh` was hardcoded in Docker and SSH isolation, which prevented
13
+ access to tools like `nvm` that require bash. Now, the most feature-complete
14
+ available shell is used automatically.
15
+
16
+ Key features:
17
+ - Auto-detect best available shell in Docker containers and SSH hosts (`bash > zsh > sh`)
18
+ - New `--shell` option to force a specific shell (`auto`, `bash`, `zsh`, `sh`)
19
+ - Default mode is `auto` — no need to specify `--shell` for automatic detection
20
+ - `--shell` is passed through in isolation stacking
21
+
22
+ Example usage:
23
+
24
+ ```bash
25
+ # Auto-detect best shell (default behavior, no option needed)
26
+ $ --isolated docker --image node:20 -- nvm use 20
27
+
28
+ # Force bash explicitly
29
+ $ --isolated docker --image ubuntu:22.04 --shell bash -- echo $BASH_VERSION
30
+
31
+ # Use sh specifically
32
+ $ --isolated ssh --endpoint user@host --shell sh -- echo hello
33
+ ```
34
+
3
35
  ## 0.21.0
4
36
 
5
37
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "start-command",
3
- "version": "0.21.0",
3
+ "version": "0.22.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": {
@@ -16,6 +16,7 @@
16
16
  * --keep-user Keep isolated user after command completes (don't delete)
17
17
  * --keep-alive, -k Keep isolation environment alive after command exits
18
18
  * --auto-remove-docker-container Automatically remove docker container after exit (disabled by default)
19
+ * --shell <shell> Shell to use in isolation environments: auto, bash, zsh, sh (default: auto)
19
20
  * --use-command-stream Use command-stream library for command execution (experimental)
20
21
  * --status <uuid> Show status of a previous command execution by UUID
21
22
  * --output-format <format> Output format for status (links-notation, json, text)
@@ -35,6 +36,11 @@ const DEBUG =
35
36
  */
36
37
  const VALID_BACKENDS = ['screen', 'tmux', 'docker', 'ssh'];
37
38
 
39
+ /**
40
+ * Valid shell options for --shell
41
+ */
42
+ const VALID_SHELLS = ['auto', 'bash', 'zsh', 'sh'];
43
+
38
44
  /**
39
45
  * Maximum depth for isolation stacking
40
46
  */
@@ -159,6 +165,7 @@ function parseArgs(args) {
159
165
  keepUser: false, // Keep isolated user after command completes (don't delete)
160
166
  keepAlive: false, // Keep environment alive after command exits
161
167
  autoRemoveDockerContainer: false, // Auto-remove docker container after exit
168
+ shell: 'auto', // Shell to use in isolation environments: auto, bash, zsh, sh
162
169
  useCommandStream: false, // Use command-stream library for command execution
163
170
  status: null, // UUID to show status for
164
171
  outputFormat: null, // Output format for status (links-notation, json, text)
@@ -364,6 +371,24 @@ function parseOption(args, index, options) {
364
371
  return 1;
365
372
  }
366
373
 
374
+ // --shell <shell>
375
+ if (arg === '--shell') {
376
+ if (index + 1 < args.length && !args[index + 1].startsWith('-')) {
377
+ options.shell = args[index + 1].toLowerCase();
378
+ return 2;
379
+ } else {
380
+ throw new Error(
381
+ `Option ${arg} requires a shell argument (auto, bash, zsh, sh)`
382
+ );
383
+ }
384
+ }
385
+
386
+ // --shell=<value>
387
+ if (arg.startsWith('--shell=')) {
388
+ options.shell = arg.split('=')[1].toLowerCase();
389
+ return 1;
390
+ }
391
+
367
392
  // --use-command-stream
368
393
  if (arg === '--use-command-stream') {
369
394
  options.useCommandStream = true;
@@ -629,6 +654,15 @@ function validateOptions(options) {
629
654
  throw new Error('--output-format option is only valid with --status');
630
655
  }
631
656
 
657
+ // Validate shell option
658
+ if (options.shell !== null && options.shell !== undefined) {
659
+ if (!VALID_SHELLS.includes(options.shell)) {
660
+ throw new Error(
661
+ `Invalid shell: "${options.shell}". Valid options are: ${VALID_SHELLS.join(', ')}`
662
+ );
663
+ }
664
+ }
665
+
632
666
  // Validate session ID is a valid UUID if provided
633
667
  if (options.sessionId !== null && options.sessionId !== undefined) {
634
668
  if (!isValidUUID(options.sessionId)) {
@@ -693,5 +727,6 @@ module.exports = {
693
727
  generateUUID,
694
728
  VALID_BACKENDS,
695
729
  VALID_OUTPUT_FORMATS,
730
+ VALID_SHELLS,
696
731
  MAX_ISOLATION_DEPTH,
697
732
  };
@@ -71,6 +71,10 @@ function buildNextLevelCommand(options, command) {
71
71
  parts.push('--auto-remove-docker-container');
72
72
  }
73
73
 
74
+ if (options.shell && options.shell !== 'auto') {
75
+ parts.push(`--shell ${options.shell}`);
76
+ }
77
+
74
78
  // Separator and command
75
79
  parts.push('--');
76
80
  parts.push(command);
@@ -0,0 +1,147 @@
1
+ /** Log utility functions for isolation runners */
2
+
3
+ const { spawn } = require('child_process');
4
+ const fs = require('fs');
5
+ const os = require('os');
6
+ const path = require('path');
7
+
8
+ /**
9
+ * Generate timestamp for logging
10
+ * @returns {string} ISO timestamp without 'T' and 'Z'
11
+ */
12
+ function getTimestamp() {
13
+ return new Date().toISOString().replace('T', ' ').replace('Z', '');
14
+ }
15
+
16
+ /**
17
+ * Generate unique log filename
18
+ * @param {string} environment - The isolation environment name
19
+ * @returns {string} Log filename
20
+ */
21
+ function generateLogFilename(environment) {
22
+ const timestamp = Date.now();
23
+ const random = Math.random().toString(36).substring(2, 8);
24
+ return `start-command-${environment}-${timestamp}-${random}.log`;
25
+ }
26
+
27
+ /**
28
+ * Create log content header
29
+ * @param {object} params - Log parameters
30
+ * @param {string} params.command - The command being executed
31
+ * @param {string} params.environment - The isolation environment
32
+ * @param {string} params.mode - attached or detached
33
+ * @param {string} params.sessionName - Session/container name
34
+ * @param {string} [params.image] - Docker image (for docker environment)
35
+ * @param {string} [params.user] - User to run command as (optional)
36
+ * @param {string} params.startTime - Start timestamp
37
+ * @returns {string} Log header content
38
+ */
39
+ function createLogHeader(params) {
40
+ let content = `=== Start Command Log ===\n`;
41
+ content += `Timestamp: ${params.startTime}\n`;
42
+ content += `Command: ${params.command}\n`;
43
+ content += `Environment: ${params.environment}\n`;
44
+ content += `Mode: ${params.mode}\n`;
45
+ content += `Session: ${params.sessionName}\n`;
46
+ if (params.image) {
47
+ content += `Image: ${params.image}\n`;
48
+ }
49
+ if (params.user) {
50
+ content += `User: ${params.user}\n`;
51
+ }
52
+ content += `Platform: ${process.platform}\n`;
53
+ content += `Node Version: ${process.version}\n`;
54
+ content += `Working Directory: ${process.cwd()}\n`;
55
+ content += `${'='.repeat(50)}\n\n`;
56
+ return content;
57
+ }
58
+
59
+ /**
60
+ * Create log content footer
61
+ * @param {string} endTime - End timestamp
62
+ * @param {number} exitCode - Exit code
63
+ * @returns {string} Log footer content
64
+ */
65
+ function createLogFooter(endTime, exitCode) {
66
+ let content = `\n${'='.repeat(50)}\n`;
67
+ content += `Finished: ${endTime}\n`;
68
+ content += `Exit Code: ${exitCode}\n`;
69
+ return content;
70
+ }
71
+
72
+ /**
73
+ * Write log file
74
+ * @param {string} logPath - Path to log file
75
+ * @param {string} content - Log content
76
+ * @returns {boolean} Success status
77
+ */
78
+ function writeLogFile(logPath, content) {
79
+ try {
80
+ fs.writeFileSync(logPath, content, 'utf8');
81
+ return true;
82
+ } catch (err) {
83
+ console.error(`\nWarning: Could not save log file: ${err.message}`);
84
+ return false;
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Get log directory from environment or use system temp
90
+ * @returns {string} Log directory path
91
+ */
92
+ function getLogDir() {
93
+ return process.env.START_LOG_DIR || os.tmpdir();
94
+ }
95
+
96
+ /**
97
+ * Create log file path
98
+ * @param {string} environment - The isolation environment
99
+ * @returns {string} Full path to log file
100
+ */
101
+ function createLogPath(environment) {
102
+ const logDir = getLogDir();
103
+ const logFilename = generateLogFilename(environment);
104
+ return path.join(logDir, logFilename);
105
+ }
106
+
107
+ /**
108
+ * Run command as an isolated user (without isolation environment)
109
+ * Uses sudo -u to switch users
110
+ * @param {string} cmd - Command to execute
111
+ * @param {string} username - User to run as
112
+ * @returns {Promise<{success: boolean, message: string, exitCode: number}>}
113
+ */
114
+ function runAsIsolatedUser(cmd, username) {
115
+ return new Promise((resolve) => {
116
+ const child = spawn('sudo', ['-n', '-u', username, 'sh', '-c', cmd], {
117
+ stdio: 'inherit',
118
+ });
119
+
120
+ child.on('exit', (code) => {
121
+ resolve({
122
+ success: code === 0,
123
+ message: `Command completed as user "${username}" with exit code ${code}`,
124
+ exitCode: code || 0,
125
+ });
126
+ });
127
+
128
+ child.on('error', (err) => {
129
+ resolve({
130
+ success: false,
131
+ message: `Failed to run as user "${username}": ${err.message}`,
132
+ exitCode: 1,
133
+ });
134
+ });
135
+ });
136
+ }
137
+
138
+ module.exports = {
139
+ getTimestamp,
140
+ generateLogFilename,
141
+ createLogHeader,
142
+ createLogFooter,
143
+ writeLogFile,
144
+ getLogDir,
145
+ createLogPath,
146
+ runAsIsolatedUser,
147
+ };
@@ -116,6 +116,101 @@ function getShell() {
116
116
  return { shell, shellArg };
117
117
  }
118
118
 
119
+ /**
120
+ * Detect the best available shell in an isolation environment (docker/ssh)
121
+ * Tries shells in order: bash, zsh, sh
122
+ * @param {'docker'|'ssh'} environment - Isolation environment type
123
+ * @param {object} options - Options for the isolation environment
124
+ * @param {string} [shellPreference='auto'] - Shell preference: 'auto', 'bash', 'zsh', 'sh'
125
+ * @returns {string} Shell path to use
126
+ */
127
+ function detectShellInEnvironment(
128
+ environment,
129
+ options,
130
+ shellPreference = 'auto'
131
+ ) {
132
+ // If a specific shell is requested (not auto), use it directly
133
+ if (shellPreference && shellPreference !== 'auto') {
134
+ if (DEBUG) {
135
+ console.log(`[DEBUG] Using forced shell: ${shellPreference}`);
136
+ }
137
+ return shellPreference;
138
+ }
139
+
140
+ // In auto mode, try shells in order of preference
141
+ const shellsToTry = ['bash', 'zsh', 'sh'];
142
+
143
+ if (environment === 'docker') {
144
+ const image = options.image;
145
+ if (!image) {
146
+ return 'sh';
147
+ }
148
+
149
+ for (const shell of shellsToTry) {
150
+ try {
151
+ const result = spawnSync(
152
+ 'docker',
153
+ ['run', '--rm', image, 'sh', '-c', `command -v ${shell}`],
154
+ { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }
155
+ );
156
+ if (result.status === 0 && result.stdout.trim()) {
157
+ if (DEBUG) {
158
+ console.log(
159
+ `[DEBUG] Detected shell in docker image ${image}: ${result.stdout.trim()}`
160
+ );
161
+ }
162
+ return result.stdout.trim();
163
+ }
164
+ } catch {
165
+ // Continue to next shell
166
+ }
167
+ }
168
+
169
+ if (DEBUG) {
170
+ console.log(
171
+ `[DEBUG] Could not detect shell in docker image ${image}, falling back to sh`
172
+ );
173
+ }
174
+ return 'sh';
175
+ }
176
+
177
+ if (environment === 'ssh') {
178
+ const endpoint = options.endpoint;
179
+ if (!endpoint) {
180
+ return 'sh';
181
+ }
182
+
183
+ try {
184
+ // Run a single SSH command to check for available shells in order
185
+ const checkCmd = shellsToTry.map((s) => `command -v ${s}`).join(' || ');
186
+ const result = spawnSync('ssh', [endpoint, checkCmd], {
187
+ encoding: 'utf8',
188
+ stdio: ['pipe', 'pipe', 'pipe'],
189
+ });
190
+ if (result.status === 0 && result.stdout.trim()) {
191
+ const detected = result.stdout.trim();
192
+ if (DEBUG) {
193
+ console.log(
194
+ `[DEBUG] Detected shell on SSH host ${endpoint}: ${detected}`
195
+ );
196
+ }
197
+ return detected;
198
+ }
199
+ } catch {
200
+ // Fall through to default
201
+ }
202
+
203
+ if (DEBUG) {
204
+ console.log(
205
+ `[DEBUG] Could not detect shell on SSH host ${endpoint}, falling back to sh`
206
+ );
207
+ }
208
+ return 'sh';
209
+ }
210
+
211
+ return 'sh';
212
+ }
213
+
119
214
  /**
120
215
  * Check if the current process has a TTY attached
121
216
  * @returns {boolean} True if TTY is available
@@ -553,15 +648,25 @@ function runInSsh(command, options = {}) {
553
648
  const sessionName = options.session || generateSessionName('ssh');
554
649
  const sshTarget = options.endpoint;
555
650
 
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
+ const shellToUse = detectShellInEnvironment('ssh', options, options.shell);
655
+ // Whether to wrap command with a shell (only when explicit shell is specified)
656
+ const useExplicitShell =
657
+ options.shell && options.shell !== 'auto' ? shellToUse : null;
658
+
556
659
  try {
557
660
  if (options.detached) {
558
661
  // Detached mode: Run command in background on remote server using nohup
559
662
  // The command will continue running even after SSH connection closes
560
- const remoteCommand = `nohup ${command} > /tmp/${sessionName}.log 2>&1 &`;
663
+ const remoteShell = useExplicitShell || shellToUse;
664
+ const remoteCommand = `nohup ${remoteShell} -c ${JSON.stringify(command)} > /tmp/${sessionName}.log 2>&1 &`;
561
665
  const sshArgs = [sshTarget, remoteCommand];
562
666
 
563
667
  if (DEBUG) {
564
668
  console.log(`[DEBUG] Running: ssh ${sshArgs.join(' ')}`);
669
+ console.log(`[DEBUG] shell: ${remoteShell}`);
565
670
  }
566
671
 
567
672
  const result = spawnSync('ssh', sshArgs, {
@@ -579,11 +684,15 @@ function runInSsh(command, options = {}) {
579
684
  });
580
685
  } else {
581
686
  // Attached mode: Run command interactively over SSH
582
- // This creates a direct SSH connection and runs the command
583
- const sshArgs = [sshTarget, command];
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];
584
692
 
585
693
  if (DEBUG) {
586
694
  console.log(`[DEBUG] Running: ssh ${sshArgs.join(' ')}`);
695
+ console.log(`[DEBUG] shell: ${shellToUse}`);
587
696
  }
588
697
 
589
698
  return new Promise((resolve) => {
@@ -660,6 +769,9 @@ function runInDocker(command, options = {}) {
660
769
  }
661
770
  }
662
771
 
772
+ // Detect the shell to use in the container
773
+ const shellToUse = detectShellInEnvironment('docker', options, options.shell);
774
+
663
775
  // Print the user command (this appears after any virtual commands like docker pull)
664
776
  const { createCommandLine } = require('./output-blocks');
665
777
  console.log(createCommandLine(command));
@@ -674,7 +786,7 @@ function runInDocker(command, options = {}) {
674
786
 
675
787
  if (options.keepAlive) {
676
788
  // With keep-alive: run command, then keep shell alive
677
- effectiveCommand = `${command}; exec /bin/sh`;
789
+ effectiveCommand = `${command}; exec ${shellToUse}`;
678
790
  }
679
791
  // Without keep-alive: container exits naturally when command completes
680
792
 
@@ -691,10 +803,11 @@ function runInDocker(command, options = {}) {
691
803
  dockerArgs.push('--user', options.user);
692
804
  }
693
805
 
694
- dockerArgs.push(options.image, '/bin/sh', '-c', effectiveCommand);
806
+ dockerArgs.push(options.image, shellToUse, '-c', effectiveCommand);
695
807
 
696
808
  if (DEBUG) {
697
809
  console.log(`[DEBUG] Running: docker ${dockerArgs.join(' ')}`);
810
+ console.log(`[DEBUG] shell: ${shellToUse}`);
698
811
  console.log(`[DEBUG] keepAlive: ${options.keepAlive || false}`);
699
812
  console.log(
700
813
  `[DEBUG] autoRemoveDockerContainer: ${options.autoRemoveDockerContainer || false}`
@@ -735,7 +848,11 @@ function runInDocker(command, options = {}) {
735
848
  dockerArgs.push('--user', options.user);
736
849
  }
737
850
 
738
- dockerArgs.push(options.image, '/bin/sh', '-c', command);
851
+ if (DEBUG) {
852
+ console.log(`[DEBUG] shell: ${shellToUse}`);
853
+ }
854
+
855
+ dockerArgs.push(options.image, shellToUse, '-c', command);
739
856
 
740
857
  if (DEBUG) {
741
858
  console.log(`[DEBUG] Running: docker ${dockerArgs.join(' ')}`);
@@ -825,105 +942,6 @@ function runIsolated(backend, command, options = {}) {
825
942
  }
826
943
  }
827
944
 
828
- /**
829
- * Generate timestamp for logging
830
- * @returns {string} ISO timestamp without 'T' and 'Z'
831
- */
832
- function getTimestamp() {
833
- return new Date().toISOString().replace('T', ' ').replace('Z', '');
834
- }
835
-
836
- /**
837
- * Generate unique log filename
838
- * @param {string} environment - The isolation environment name
839
- * @returns {string} Log filename
840
- */
841
- function generateLogFilename(environment) {
842
- const timestamp = Date.now();
843
- const random = Math.random().toString(36).substring(2, 8);
844
- return `start-command-${environment}-${timestamp}-${random}.log`;
845
- }
846
-
847
- /**
848
- * Create log content header
849
- * @param {object} params - Log parameters
850
- * @param {string} params.command - The command being executed
851
- * @param {string} params.environment - The isolation environment
852
- * @param {string} params.mode - attached or detached
853
- * @param {string} params.sessionName - Session/container name
854
- * @param {string} [params.image] - Docker image (for docker environment)
855
- * @param {string} [params.user] - User to run command as (optional)
856
- * @param {string} params.startTime - Start timestamp
857
- * @returns {string} Log header content
858
- */
859
- function createLogHeader(params) {
860
- let content = `=== Start Command Log ===\n`;
861
- content += `Timestamp: ${params.startTime}\n`;
862
- content += `Command: ${params.command}\n`;
863
- content += `Environment: ${params.environment}\n`;
864
- content += `Mode: ${params.mode}\n`;
865
- content += `Session: ${params.sessionName}\n`;
866
- if (params.image) {
867
- content += `Image: ${params.image}\n`;
868
- }
869
- if (params.user) {
870
- content += `User: ${params.user}\n`;
871
- }
872
- content += `Platform: ${process.platform}\n`;
873
- content += `Node Version: ${process.version}\n`;
874
- content += `Working Directory: ${process.cwd()}\n`;
875
- content += `${'='.repeat(50)}\n\n`;
876
- return content;
877
- }
878
-
879
- /**
880
- * Create log content footer
881
- * @param {string} endTime - End timestamp
882
- * @param {number} exitCode - Exit code
883
- * @returns {string} Log footer content
884
- */
885
- function createLogFooter(endTime, exitCode) {
886
- let content = `\n${'='.repeat(50)}\n`;
887
- content += `Finished: ${endTime}\n`;
888
- content += `Exit Code: ${exitCode}\n`;
889
- return content;
890
- }
891
-
892
- /**
893
- * Write log file
894
- * @param {string} logPath - Path to log file
895
- * @param {string} content - Log content
896
- * @returns {boolean} Success status
897
- */
898
- function writeLogFile(logPath, content) {
899
- try {
900
- fs.writeFileSync(logPath, content, 'utf8');
901
- return true;
902
- } catch (err) {
903
- console.error(`\nWarning: Could not save log file: ${err.message}`);
904
- return false;
905
- }
906
- }
907
-
908
- /**
909
- * Get log directory from environment or use system temp
910
- * @returns {string} Log directory path
911
- */
912
- function getLogDir() {
913
- return process.env.START_LOG_DIR || os.tmpdir();
914
- }
915
-
916
- /**
917
- * Create log file path
918
- * @param {string} environment - The isolation environment
919
- * @returns {string} Full path to log file
920
- */
921
- function createLogPath(environment) {
922
- const logDir = getLogDir();
923
- const logFilename = generateLogFilename(environment);
924
- return path.join(logDir, logFilename);
925
- }
926
-
927
945
  /**
928
946
  * Reset screen version cache (useful for testing)
929
947
  */
@@ -932,36 +950,17 @@ function resetScreenVersionCache() {
932
950
  screenVersionChecked = false;
933
951
  }
934
952
 
935
- /**
936
- * Run command as an isolated user (without isolation environment)
937
- * Uses sudo -u to switch users
938
- * @param {string} cmd - Command to execute
939
- * @param {string} username - User to run as
940
- * @returns {Promise<{success: boolean, message: string, exitCode: number}>}
941
- */
942
- function runAsIsolatedUser(cmd, username) {
943
- return new Promise((resolve) => {
944
- const child = spawn('sudo', ['-n', '-u', username, 'sh', '-c', cmd], {
945
- stdio: 'inherit',
946
- });
947
-
948
- child.on('exit', (code) => {
949
- resolve({
950
- success: code === 0,
951
- message: `Command completed as user "${username}" with exit code ${code}`,
952
- exitCode: code || 0,
953
- });
954
- });
955
-
956
- child.on('error', (err) => {
957
- resolve({
958
- success: false,
959
- message: `Failed to run as user "${username}": ${err.message}`,
960
- exitCode: 1,
961
- });
962
- });
963
- });
964
- }
953
+ // Log utilities and runAsIsolatedUser extracted to isolation-log-utils.js
954
+ const {
955
+ getTimestamp,
956
+ generateLogFilename,
957
+ createLogHeader,
958
+ createLogFooter,
959
+ writeLogFile,
960
+ getLogDir,
961
+ createLogPath,
962
+ runAsIsolatedUser,
963
+ } = require('./isolation-log-utils');
965
964
 
966
965
  // Re-export docker utilities from docker-utils for backwards compatibility
967
966
  const {
@@ -972,6 +971,7 @@ const {
972
971
  module.exports = {
973
972
  isCommandAvailable,
974
973
  hasTTY,
974
+ detectShellInEnvironment,
975
975
  runInScreen,
976
976
  runInTmux,
977
977
  runInDocker,
@@ -0,0 +1,165 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Unit tests for shell option in the argument parser
4
+ */
5
+
6
+ const { describe, it } = require('node:test');
7
+ const assert = require('assert');
8
+ const { parseArgs, VALID_SHELLS } = require('../src/lib/args-parser');
9
+
10
+ describe('shell option', () => {
11
+ it('should default shell to auto', () => {
12
+ const result = parseArgs(['echo', 'hello']);
13
+ assert.strictEqual(result.wrapperOptions.shell, 'auto');
14
+ });
15
+
16
+ it('should parse --shell bash', () => {
17
+ const result = parseArgs([
18
+ '--isolated',
19
+ 'docker',
20
+ '--shell',
21
+ 'bash',
22
+ '--',
23
+ 'npm',
24
+ 'test',
25
+ ]);
26
+ assert.strictEqual(result.wrapperOptions.shell, 'bash');
27
+ });
28
+
29
+ it('should parse --shell zsh', () => {
30
+ const result = parseArgs([
31
+ '--isolated',
32
+ 'docker',
33
+ '--shell',
34
+ 'zsh',
35
+ '--',
36
+ 'npm',
37
+ 'test',
38
+ ]);
39
+ assert.strictEqual(result.wrapperOptions.shell, 'zsh');
40
+ });
41
+
42
+ it('should parse --shell sh', () => {
43
+ const result = parseArgs([
44
+ '--isolated',
45
+ 'docker',
46
+ '--shell',
47
+ 'sh',
48
+ '--',
49
+ 'npm',
50
+ 'test',
51
+ ]);
52
+ assert.strictEqual(result.wrapperOptions.shell, 'sh');
53
+ });
54
+
55
+ it('should parse --shell auto', () => {
56
+ const result = parseArgs([
57
+ '--isolated',
58
+ 'docker',
59
+ '--shell',
60
+ 'auto',
61
+ '--',
62
+ 'npm',
63
+ 'test',
64
+ ]);
65
+ assert.strictEqual(result.wrapperOptions.shell, 'auto');
66
+ });
67
+
68
+ it('should parse --shell=value format', () => {
69
+ const result = parseArgs([
70
+ '--isolated',
71
+ 'docker',
72
+ '--shell=bash',
73
+ '--',
74
+ 'npm',
75
+ 'test',
76
+ ]);
77
+ assert.strictEqual(result.wrapperOptions.shell, 'bash');
78
+ });
79
+
80
+ it('should normalize shell to lowercase', () => {
81
+ const result = parseArgs([
82
+ '--isolated',
83
+ 'docker',
84
+ '--shell',
85
+ 'BASH',
86
+ '--',
87
+ 'npm',
88
+ 'test',
89
+ ]);
90
+ assert.strictEqual(result.wrapperOptions.shell, 'bash');
91
+ });
92
+
93
+ it('should throw error for missing shell argument', () => {
94
+ assert.throws(() => {
95
+ parseArgs(['--isolated', 'docker', '--shell']);
96
+ }, /requires a shell argument/);
97
+ });
98
+
99
+ it('should throw error for invalid shell', () => {
100
+ assert.throws(() => {
101
+ parseArgs([
102
+ '--isolated',
103
+ 'docker',
104
+ '--shell',
105
+ 'fish',
106
+ '--',
107
+ 'echo',
108
+ 'hi',
109
+ ]);
110
+ }, /Invalid shell/);
111
+ });
112
+
113
+ it('should list valid shells in error message', () => {
114
+ try {
115
+ parseArgs([
116
+ '--isolated',
117
+ 'docker',
118
+ '--shell',
119
+ 'invalid',
120
+ '--',
121
+ 'echo',
122
+ 'test',
123
+ ]);
124
+ assert.fail('Should have thrown an error');
125
+ } catch (err) {
126
+ for (const shell of VALID_SHELLS) {
127
+ assert.ok(err.message.includes(shell), `Error should mention ${shell}`);
128
+ }
129
+ }
130
+ });
131
+
132
+ it('should work with ssh isolation', () => {
133
+ const result = parseArgs([
134
+ '--isolated',
135
+ 'ssh',
136
+ '--endpoint',
137
+ 'user@host',
138
+ '--shell',
139
+ 'bash',
140
+ '--',
141
+ 'echo',
142
+ 'hi',
143
+ ]);
144
+ assert.strictEqual(result.wrapperOptions.shell, 'bash');
145
+ assert.strictEqual(result.wrapperOptions.isolated, 'ssh');
146
+ });
147
+ });
148
+
149
+ describe('VALID_SHELLS', () => {
150
+ it('should include bash', () => {
151
+ assert.ok(VALID_SHELLS.includes('bash'));
152
+ });
153
+
154
+ it('should include zsh', () => {
155
+ assert.ok(VALID_SHELLS.includes('zsh'));
156
+ });
157
+
158
+ it('should include sh', () => {
159
+ assert.ok(VALID_SHELLS.includes('sh'));
160
+ });
161
+
162
+ it('should include auto', () => {
163
+ assert.ok(VALID_SHELLS.includes('auto'));
164
+ });
165
+ });
@@ -927,3 +927,5 @@ describe('cleanup options', () => {
927
927
  assert.strictEqual(result.wrapperOptions.cleanupDryRun, false);
928
928
  });
929
929
  });
930
+
931
+ // Shell option tests moved to args-parser-shell.test.js
@@ -10,6 +10,7 @@ const assert = require('assert');
10
10
  const {
11
11
  isCommandAvailable,
12
12
  hasTTY,
13
+ detectShellInEnvironment,
13
14
  getScreenVersion,
14
15
  supportsLogfileOption,
15
16
  resetScreenVersionCache,
@@ -761,3 +762,66 @@ describe('Default Docker Image Detection', () => {
761
762
  });
762
763
  });
763
764
  });
765
+
766
+ describe('detectShellInEnvironment', () => {
767
+ it('should return the forced shell when shellPreference is not auto', () => {
768
+ const result = detectShellInEnvironment(
769
+ 'docker',
770
+ { image: 'alpine:latest' },
771
+ 'bash'
772
+ );
773
+ assert.strictEqual(result, 'bash');
774
+ });
775
+
776
+ it('should return zsh when shellPreference is zsh', () => {
777
+ const result = detectShellInEnvironment(
778
+ 'ssh',
779
+ { endpoint: 'user@host' },
780
+ 'zsh'
781
+ );
782
+ assert.strictEqual(result, 'zsh');
783
+ });
784
+
785
+ it('should return sh when shellPreference is sh', () => {
786
+ const result = detectShellInEnvironment(
787
+ 'docker',
788
+ { image: 'alpine:latest' },
789
+ 'sh'
790
+ );
791
+ assert.strictEqual(result, 'sh');
792
+ });
793
+
794
+ it('should return sh fallback when docker image is not provided', () => {
795
+ const result = detectShellInEnvironment('docker', {}, 'auto');
796
+ assert.strictEqual(result, 'sh');
797
+ });
798
+
799
+ it('should return sh fallback when ssh endpoint is not provided', () => {
800
+ const result = detectShellInEnvironment('ssh', {}, 'auto');
801
+ assert.strictEqual(result, 'sh');
802
+ });
803
+
804
+ it('should return sh fallback for unknown environment', () => {
805
+ const result = detectShellInEnvironment('screen', {}, 'auto');
806
+ assert.strictEqual(result, 'sh');
807
+ });
808
+
809
+ it('should auto-detect shell in docker if docker is available', () => {
810
+ if (!isCommandAvailable('docker')) {
811
+ console.log(' Skipping: docker not installed');
812
+ return;
813
+ }
814
+ // Use alpine:latest which is commonly available and has sh
815
+ // This test just verifies we get a valid shell back
816
+ const result = detectShellInEnvironment(
817
+ 'docker',
818
+ { image: 'alpine:latest' },
819
+ 'auto'
820
+ );
821
+ assert.ok(
822
+ ['bash', 'zsh', 'sh'].includes(result),
823
+ `Expected a valid shell (bash/zsh/sh), got: ${result}`
824
+ );
825
+ console.log(` Detected shell in alpine:latest: ${result}`);
826
+ });
827
+ });
@@ -217,7 +217,7 @@ describe('args-parser user isolation options', () => {
217
217
  'npm',
218
218
  'test',
219
219
  ]);
220
- }, /--isolated-user is not supported with Docker isolation/);
220
+ }, /--isolated-user is not supported with Docker as the first isolation level/);
221
221
  });
222
222
 
223
223
  it('should validate custom username format', () => {