start-command 0.24.1 → 0.24.3

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,45 @@
1
1
  # start-command
2
2
 
3
+ ## 0.24.3
4
+
5
+ ### Patch Changes
6
+
7
+ - c75983e: fix: hint users when a bare shell exits immediately due to startup file errors
8
+
9
+ When running `$ --isolated docker --image <image> -- bash` and the shell exits
10
+ with code 1 within 3 seconds, `start-command` now prints a helpful hint:
11
+
12
+ ```
13
+ Hint: The shell exited immediately — its startup file (.bashrc/.zshrc) may have errors.
14
+ Try skipping startup files: bash --norc
15
+ ```
16
+
17
+ This covers the post-fix regression from issue #84 where `konard/sandbox`
18
+ images with broken `.bashrc` files cause bash to exit immediately when run
19
+ directly. The hint also appears in the log file for later diagnosis.
20
+
21
+ The `bash --norc` (and `zsh --no-rcs`) workaround bypasses startup file
22
+ sourcing and is recognized as a bare shell invocation, so it is passed
23
+ directly to docker without any `-c` wrapping.
24
+
25
+ ## 0.24.2
26
+
27
+ ### Patch Changes
28
+
29
+ - f870146: perf: skip shell detection probe when command is a bare shell invocation
30
+
31
+ When running `$ --isolated docker -- bash`, the tool previously ran a probe
32
+ container (`docker run --rm image sh -c 'command -v bash'`) to detect which
33
+ shell to use, even though the result was unused for bare shell commands.
34
+
35
+ Now `isInteractiveShellCommand(command)` is evaluated first, and
36
+ `detectShellInEnvironment` is only called when the command is not a bare shell.
37
+ This avoids up to three unnecessary container starts per invocation and eliminates
38
+ spurious failures when the probe itself fails on images with complex entrypoints.
39
+
40
+ Also caches the `isInteractiveShellCommand(command)` result in `isBareShell` to
41
+ avoid redundant calls in both attached and detached code paths.
42
+
3
43
  ## 0.24.1
4
44
 
5
45
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "start-command",
3
- "version": "0.24.1",
3
+ "version": "0.24.3",
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": {
@@ -162,7 +162,7 @@ function detectShellInEnvironment(
162
162
  return result.stdout.trim();
163
163
  }
164
164
  } catch {
165
- // Continue to next shell
165
+ // ignore; try next shell
166
166
  }
167
167
  }
168
168
 
@@ -233,9 +233,7 @@ function wrapCommandWithUser(command, user) {
233
233
  if (!user) {
234
234
  return command;
235
235
  }
236
- // Use sudo -u to run command as specified user
237
- // -E preserves environment variables
238
- // -n ensures non-interactive (fails if password required)
236
+ // sudo -n: non-interactive (fails if password required); -u: run as user
239
237
  return `sudo -n -u ${user} sh -c '${command.replace(/'/g, "'\\''")}'`;
240
238
  }
241
239
 
@@ -292,10 +290,7 @@ function runScreenWithLogCapture(command, sessionName, shellInfo, user = null) {
292
290
  }
293
291
  }
294
292
 
295
- // Use spawnSync with array arguments to avoid shell quoting issues
296
- // This is critical for commands containing quotes (e.g., echo "hello")
297
- // Using execSync with a constructed string would break on nested quotes
298
- // See issue #25 for details
293
+ // Use spawnSync with array args (not execSync string) to avoid quoting issues (issue #25)
299
294
  const result = spawnSync('screen', screenArgs, {
300
295
  stdio: 'inherit',
301
296
  });
@@ -433,14 +428,10 @@ function runInScreen(command, options = {}) {
433
428
 
434
429
  if (options.detached) {
435
430
  // Detached mode: screen -dmS <session> <shell> -c '<command>'
436
- // By default (keepAlive=false), the session will exit after command completes
437
- // With keepAlive=true, we start a shell that runs the command but stays alive
438
-
439
431
  if (options.keepAlive) {
440
432
  // With keep-alive: run command, then keep shell open
441
433
  effectiveCommand = `${effectiveCommand}; exec ${shell}`;
442
434
  }
443
- // Without keep-alive: command runs and session exits naturally when done
444
435
 
445
436
  const screenArgs = isInteractiveShellCommand(command)
446
437
  ? ['-dmS', sessionName, ...command.trim().split(/\s+/)]
@@ -451,9 +442,7 @@ function runInScreen(command, options = {}) {
451
442
  console.log(`[DEBUG] keepAlive: ${options.keepAlive || false}`);
452
443
  }
453
444
 
454
- // Use spawnSync with array arguments to avoid shell quoting issues
455
- // This is critical for commands containing quotes (e.g., echo "hello")
456
- // See issue #25 for details
445
+ // Use spawnSync with array args (not execSync string) to avoid quoting issues (issue #25)
457
446
  const result = spawnSync('screen', screenArgs, {
458
447
  stdio: 'inherit',
459
448
  });
@@ -476,15 +465,8 @@ function runInScreen(command, options = {}) {
476
465
  message,
477
466
  });
478
467
  } else {
479
- // Attached mode: always use detached mode with log capture
480
- // This ensures output is captured and displayed correctly, even for quick commands
481
- // that would otherwise have their output lost in a rapidly-terminating screen session.
482
- // Direct screen invocation (screen -S session shell -c command) loses output because:
483
- // 1. Screen creates a virtual terminal for the session
484
- // 2. Command output goes to that virtual terminal
485
- // 3. When the command exits quickly, screen shows "[screen is terminating]"
486
- // 4. The virtual terminal is destroyed and output is lost
487
- // See issue #25 for details: https://github.com/link-foundation/start/issues/25
468
+ // Attached mode: use detached mode with log capture for reliable output
469
+ // (direct attached screen loses output for quick commands; see issue #25)
488
470
  if (DEBUG) {
489
471
  console.log(
490
472
  `[DEBUG] Using detached mode with log capture for reliable output`
@@ -533,14 +515,10 @@ function runInTmux(command, options = {}) {
533
515
  try {
534
516
  if (options.detached) {
535
517
  // Detached mode: tmux new-session -d -s <session> '<command>'
536
- // By default (keepAlive=false), the session will exit after command completes
537
- // With keepAlive=true, we keep the shell alive after the command
538
-
539
518
  if (options.keepAlive) {
540
519
  // With keep-alive: run command, then keep shell open
541
520
  effectiveCommand = `${effectiveCommand}; exec ${shell}`;
542
521
  }
543
- // Without keep-alive: command runs and session exits naturally when done
544
522
 
545
523
  if (DEBUG) {
546
524
  console.log(
@@ -724,8 +702,14 @@ function runInSsh(command, options = {}) {
724
702
  }
725
703
  }
726
704
 
727
- // Import docker image utilities from docker-utils
728
- const { dockerImageExists, dockerPullImage } = require('./docker-utils');
705
+ // Import docker utilities from docker-utils
706
+ const {
707
+ dockerImageExists,
708
+ dockerPullImage,
709
+ isDockerAvailable,
710
+ getDefaultDockerImage,
711
+ canRunLinuxDockerImages,
712
+ } = require('./docker-utils');
729
713
 
730
714
  /**
731
715
  * Run command in Docker container
@@ -743,6 +727,15 @@ function runInDocker(command, options = {}) {
743
727
  });
744
728
  }
745
729
 
730
+ if (!isDockerAvailable()) {
731
+ return Promise.resolve({
732
+ success: false,
733
+ containerName: null,
734
+ message:
735
+ 'Docker is installed but not running. Please start Docker Desktop or the Docker daemon, then try again.',
736
+ });
737
+ }
738
+
746
739
  if (!options.image) {
747
740
  return Promise.resolve({
748
741
  success: false,
@@ -766,10 +759,12 @@ function runInDocker(command, options = {}) {
766
759
  }
767
760
  }
768
761
 
769
- const shellToUse = detectShellInEnvironment('docker', options, options.shell);
762
+ const isBareShell = isInteractiveShellCommand(command);
763
+ const shellToUse = isBareShell
764
+ ? 'sh'
765
+ : detectShellInEnvironment('docker', options, options.shell);
770
766
  const shellInteractiveFlag = getShellInteractiveFlag(shellToUse);
771
767
 
772
- // Print the user command (this appears after any virtual commands like docker pull)
773
768
  const { createCommandLine } = require('./output-blocks');
774
769
  console.log(createCommandLine(command));
775
770
  console.log();
@@ -777,25 +772,23 @@ function runInDocker(command, options = {}) {
777
772
  try {
778
773
  if (options.detached) {
779
774
  // Detached mode: docker run -d --name <name> [--user <user>] <image> <shell> -c '<command>'
780
- const effectiveCommand = options.keepAlive
781
- ? `${command}; exec ${shellToUse}`
782
- : command;
783
775
  const dockerArgs = ['run', '-d', '--name', containerName];
784
-
785
- // --rm must come before the image name
776
+ // --rm must come before the image name in args
786
777
  if (options.autoRemoveDockerContainer) {
787
778
  dockerArgs.splice(2, 0, '--rm');
788
779
  }
789
780
 
790
- // Add --user flag if specified
791
781
  if (options.user) {
792
782
  dockerArgs.push('--user', options.user);
793
783
  }
794
784
 
785
+ const effectiveCommand = options.keepAlive
786
+ ? `${command}; exec ${shellToUse}`
787
+ : command;
795
788
  const shellArgs = shellInteractiveFlag
796
789
  ? [shellToUse, shellInteractiveFlag]
797
790
  : [shellToUse];
798
- const cmdArgs = isInteractiveShellCommand(command)
791
+ const cmdArgs = isBareShell
799
792
  ? command.trim().split(/\s+/)
800
793
  : [...shellArgs, '-c', effectiveCommand];
801
794
  dockerArgs.push(options.image, ...cmdArgs);
@@ -843,13 +836,21 @@ function runInDocker(command, options = {}) {
843
836
  if (DEBUG) {
844
837
  console.log(`[DEBUG] shell: ${shellToUse}`);
845
838
  }
846
-
847
839
  const shellCmdArgs = shellInteractiveFlag
848
840
  ? [shellToUse, shellInteractiveFlag]
849
841
  : [shellToUse];
850
- const attachedCmdArgs = isInteractiveShellCommand(command)
851
- ? command.trim().split(/\s+/)
852
- : [...shellCmdArgs, '-c', command];
842
+ // Bare shell: pass directly with -i (avoids bash-inside-bash, issue #84; -i ensures interactive).
843
+ let attachedCmdArgs;
844
+ if (isBareShell) {
845
+ const parts = command.trim().split(/\s+/);
846
+ const bareFlag = getShellInteractiveFlag(parts[0]);
847
+ attachedCmdArgs =
848
+ bareFlag && !parts.includes(bareFlag)
849
+ ? [parts[0], bareFlag, ...parts.slice(1)]
850
+ : parts;
851
+ } else {
852
+ attachedCmdArgs = [...shellCmdArgs, '-c', command];
853
+ }
853
854
  dockerArgs.push(options.image, ...attachedCmdArgs);
854
855
 
855
856
  if (DEBUG) {
@@ -857,15 +858,25 @@ function runInDocker(command, options = {}) {
857
858
  }
858
859
 
859
860
  return new Promise((resolve) => {
860
- const child = spawn('docker', dockerArgs, {
861
- stdio: 'inherit',
862
- });
861
+ const startTime = Date.now();
862
+ const child = spawn('docker', dockerArgs, { stdio: 'inherit' });
863
863
 
864
864
  child.on('exit', (code) => {
865
+ const durationMs = Date.now() - startTime;
866
+ let message = `Docker container "${containerName}" exited with code ${code}`;
867
+ // Bare shell exited non-zero quickly → startup file error; suggest --norc (issue #84).
868
+ if (isBareShell && code !== 0 && durationMs < 3000) {
869
+ const shell0 = command.trim().split(/\s+/)[0];
870
+ // prettier-ignore
871
+ const norc = path.basename(shell0) === 'zsh' ? '--no-rcs' : '--norc';
872
+ const hint = `Hint: The shell exited immediately — its startup file (.bashrc/.zshrc) may have errors.\nTry skipping startup files: ${shell0} ${norc}`;
873
+ console.log(hint);
874
+ message += `\n${hint}`;
875
+ }
865
876
  resolve({
866
877
  success: code === 0,
867
878
  containerName,
868
- message: `Docker container "${containerName}" exited with code ${code}`,
879
+ message,
869
880
  exitCode: code,
870
881
  });
871
882
  });
@@ -948,7 +959,6 @@ function resetScreenVersionCache() {
948
959
  screenVersionChecked = false;
949
960
  }
950
961
 
951
- // Log utilities and runAsIsolatedUser extracted to isolation-log-utils.js
952
962
  const {
953
963
  getTimestamp,
954
964
  generateLogFilename,
@@ -960,12 +970,6 @@ const {
960
970
  runAsIsolatedUser,
961
971
  } = require('./isolation-log-utils');
962
972
 
963
- // Re-export docker utilities from docker-utils for backwards compatibility
964
- const {
965
- getDefaultDockerImage,
966
- canRunLinuxDockerImages,
967
- } = require('./docker-utils');
968
-
969
973
  module.exports = {
970
974
  isCommandAvailable,
971
975
  hasTTY,
@@ -989,7 +993,6 @@ module.exports = {
989
993
  supportsLogfileOption,
990
994
  resetScreenVersionCache,
991
995
  canRunLinuxDockerImages,
992
- // Re-exported from docker-utils for backwards compatibility
993
996
  getDefaultDockerImage,
994
997
  dockerImageExists,
995
998
  dockerPullImage,
@@ -0,0 +1,292 @@
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 -i (CORRECT: bare shell with explicit -i)
10
+ *
11
+ * The -i flag is required so that bash reliably starts in interactive mode,
12
+ * since docker's PTY bridging through Node.js spawn may not set up a TTY that
13
+ * bash auto-detects. Without -i, bash may exit immediately (post-fix regression).
14
+ *
15
+ * The same regression applies to zsh, sh, and all other isolation backends.
16
+ *
17
+ * Also tests the post-fix regression hint: when a bare shell exits with code 1
18
+ * quickly (< 3s), a helpful hint suggests `bash --norc` as a workaround for
19
+ * containers whose .bashrc causes bash to exit non-zero (issue #84, second comment).
20
+ *
21
+ * Reference: https://github.com/link-foundation/start/issues/84
22
+ * Fixed in: PR #85 (v0.24.1) via isInteractiveShellCommand()
23
+ * Fixed in: PR #87 adding explicit -i for bare shells in docker attached mode
24
+ */
25
+
26
+ const { describe, it } = require('node:test');
27
+ const assert = require('assert');
28
+ const { isInteractiveShellCommand } = require('../src/lib/isolation');
29
+ const { isDockerAvailable } = require('../src/lib/docker-utils');
30
+
31
+ // Helper: mirrors the command-args construction logic used in
32
+ // runInDocker attached mode.
33
+ // If this helper returns args containing '-c' for a bare shell command,
34
+ // the shell-inside-shell bug is present.
35
+ function buildCmdArgs(command, shellToUse = '/bin/bash') {
36
+ const path = require('path');
37
+ const shellName = shellToUse.split('/').pop();
38
+ const shellInteractiveFlag =
39
+ shellName === 'bash' || shellName === 'zsh' ? '-i' : null;
40
+ const shellArgs = shellInteractiveFlag
41
+ ? [shellToUse, shellInteractiveFlag]
42
+ : [shellToUse];
43
+ if (isInteractiveShellCommand(command)) {
44
+ // Bare shell: pass directly with explicit -i for bash/zsh (issue #84 fix)
45
+ const parts = command.trim().split(/\s+/);
46
+ const bareShellFlag =
47
+ path.basename(parts[0]) === 'bash' || path.basename(parts[0]) === 'zsh'
48
+ ? '-i'
49
+ : null;
50
+ if (bareShellFlag && !parts.includes(bareShellFlag)) {
51
+ return [parts[0], bareShellFlag, ...parts.slice(1)];
52
+ }
53
+ return parts;
54
+ }
55
+ return [...shellArgs, '-c', command];
56
+ }
57
+
58
+ describe('isInteractiveShellCommand additional cases (issue #84)', () => {
59
+ // These cover edge cases not in the base isInteractiveShellCommand test suite.
60
+
61
+ // Workaround: bash --norc skips .bashrc sourcing (post-fix regression workaround)
62
+ it('should return true for "bash --norc"', () => {
63
+ assert.strictEqual(isInteractiveShellCommand('bash --norc'), true);
64
+ });
65
+
66
+ it('should return true for "zsh --no-rcs"', () => {
67
+ assert.strictEqual(isInteractiveShellCommand('zsh --no-rcs'), true);
68
+ });
69
+
70
+ it('should return true for "bash -i" (interactive flag, no -c)', () => {
71
+ assert.strictEqual(isInteractiveShellCommand('bash -i'), true);
72
+ });
73
+
74
+ it('should return true for "fish"', () => {
75
+ assert.strictEqual(isInteractiveShellCommand('fish'), true);
76
+ });
77
+
78
+ it('should return true for "dash"', () => {
79
+ assert.strictEqual(isInteractiveShellCommand('dash'), true);
80
+ });
81
+
82
+ it('should return true for "/usr/local/bin/bash"', () => {
83
+ assert.strictEqual(isInteractiveShellCommand('/usr/local/bin/bash'), true);
84
+ });
85
+
86
+ it('should return false for \'bash -c "echo hello"\'', () => {
87
+ assert.strictEqual(
88
+ isInteractiveShellCommand('bash -c "echo hello"'),
89
+ false
90
+ );
91
+ });
92
+ });
93
+
94
+ describe('Regression: No Shell-Inside-Shell (issue #84)', () => {
95
+ // Each test verifies that the command-arg construction logic does NOT
96
+ // wrap a bare shell invocation inside another shell with `-c`.
97
+ //
98
+ // Before fix (v0.24.0): buildCmdArgs('bash') → ['/bin/bash', '-i', '-c', 'bash']
99
+ // After fix (v0.24.1): buildCmdArgs('bash') → ['bash', '-i']
100
+ // (bare shell passed directly with explicit -i for reliable interactive mode)
101
+
102
+ it('should pass "bash" with -i flag, not wrap in shell -c', () => {
103
+ const args = buildCmdArgs('bash');
104
+ assert.deepStrictEqual(
105
+ args,
106
+ ['bash', '-i'],
107
+ `Expected ["bash", "-i"], got: ${JSON.stringify(args)}`
108
+ );
109
+ assert.ok(
110
+ !args.includes('-c'),
111
+ 'Must not contain -c flag (shell-inside-shell)'
112
+ );
113
+ });
114
+
115
+ it('should pass "zsh" with -i flag, not wrap in shell -c', () => {
116
+ const args = buildCmdArgs('zsh');
117
+ assert.deepStrictEqual(args, ['zsh', '-i']);
118
+ assert.ok(
119
+ !args.includes('-c'),
120
+ 'Must not contain -c flag (shell-inside-shell)'
121
+ );
122
+ });
123
+
124
+ it('should pass "sh" directly without -i (sh does not use -i flag), not wrap in shell -c', () => {
125
+ const args = buildCmdArgs('sh', 'sh');
126
+ assert.deepStrictEqual(args, ['sh']);
127
+ assert.ok(
128
+ !args.includes('-c'),
129
+ 'Must not contain -c flag (shell-inside-shell)'
130
+ );
131
+ });
132
+
133
+ it('should pass "/bin/bash" with -i flag, not wrap in shell -c', () => {
134
+ const args = buildCmdArgs('/bin/bash');
135
+ assert.deepStrictEqual(args, ['/bin/bash', '-i']);
136
+ assert.ok(
137
+ !args.includes('-c'),
138
+ 'Must not contain -c flag (shell-inside-shell)'
139
+ );
140
+ });
141
+
142
+ it('should pass "bash --norc" with -i flag (workaround for broken .bashrc)', () => {
143
+ const args = buildCmdArgs('bash --norc');
144
+ assert.deepStrictEqual(args, ['bash', '-i', '--norc']);
145
+ assert.ok(!args.includes('-c'), 'Must not contain -c flag');
146
+ });
147
+
148
+ it('should pass "bash -l" directly (login shell, already has its own init)', () => {
149
+ const args = buildCmdArgs('bash -l');
150
+ assert.deepStrictEqual(args, ['bash', '-i', '-l']);
151
+ assert.ok(!args.includes('-c'), 'Must not contain -c flag');
152
+ });
153
+
154
+ it('should still wrap non-shell commands in shell -c (guard against over-broad fix)', () => {
155
+ const args = buildCmdArgs('echo hello', '/bin/bash');
156
+ assert.deepStrictEqual(args, ['/bin/bash', '-i', '-c', 'echo hello']);
157
+ assert.ok(
158
+ args.includes('-c'),
159
+ 'Non-shell commands must still use -c wrapper'
160
+ );
161
+ });
162
+
163
+ it('should still wrap "npm test" in shell -c (guard against over-broad fix)', () => {
164
+ const args = buildCmdArgs('npm test', '/bin/bash');
165
+ assert.deepStrictEqual(args, ['/bin/bash', '-i', '-c', 'npm test']);
166
+ assert.ok(
167
+ args.includes('-c'),
168
+ 'Non-shell commands must still use -c wrapper'
169
+ );
170
+ });
171
+
172
+ it('should not treat "bash -c something" as bare shell', () => {
173
+ // bash -c ... has -c, so isInteractiveShellCommand returns false
174
+ // The command gets wrapped: ['/bin/bash', '-i', '-c', 'bash -c "echo hi"']
175
+ const args = buildCmdArgs('bash -c "echo hi"', '/bin/bash');
176
+ assert.ok(
177
+ args.includes('-c'),
178
+ 'bash -c commands should be treated as regular commands'
179
+ );
180
+ });
181
+
182
+ it('should not duplicate -i if user already passes "bash -i"', () => {
183
+ // If user explicitly passes -i, we should not add another -i
184
+ const args = buildCmdArgs('bash -i');
185
+ assert.deepStrictEqual(
186
+ args,
187
+ ['bash', '-i'],
188
+ 'Should not duplicate -i flag'
189
+ );
190
+ assert.ok(!args.includes('-c'), 'Must not contain -c flag');
191
+ });
192
+ });
193
+
194
+ describe('Docker daemon availability check (issue #84)', () => {
195
+ // Verifies that isDockerAvailable returns a boolean (not a crash),
196
+ // and that the error message for a non-running daemon is helpful.
197
+
198
+ it('isDockerAvailable should return a boolean', () => {
199
+ const result = isDockerAvailable();
200
+ assert.strictEqual(
201
+ typeof result,
202
+ 'boolean',
203
+ 'isDockerAvailable() must return a boolean'
204
+ );
205
+ });
206
+
207
+ it('runInDocker error message for non-running daemon should be actionable', () => {
208
+ // Mirrors the message in runInDocker when isDockerAvailable() returns false.
209
+ const message =
210
+ 'Docker is installed but not running. Please start Docker Desktop or the Docker daemon, then try again.';
211
+ assert.ok(
212
+ message.includes('not running'),
213
+ 'Message must indicate Docker is not running'
214
+ );
215
+ assert.ok(
216
+ message.includes('Docker Desktop'),
217
+ 'Message must mention Docker Desktop (common on macOS/Windows)'
218
+ );
219
+ assert.ok(
220
+ message.includes('try again'),
221
+ 'Message must tell user what to do next'
222
+ );
223
+ });
224
+ });
225
+
226
+ describe('Post-fix regression hint: --norc suggestion (issue #84)', () => {
227
+ // These tests verify the hint logic that recommends --norc when a bare shell
228
+ // exits quickly with code 1 (e.g., broken .bashrc in konard/sandbox image).
229
+ // The hint is shown in runInDocker attached mode when:
230
+ // isBareShell && code !== 0 && durationMs < 3000
231
+
232
+ // Helper mirrors the hint construction logic in isolation.js runInDocker.
233
+ const path = require('path');
234
+ function buildHint(command) {
235
+ const shellName = command.trim().split(/\s+/)[0];
236
+ const noRcFlag = path.basename(shellName) === 'zsh' ? '--no-rcs' : '--norc';
237
+ return (
238
+ `Hint: The shell exited immediately — its startup file (.bashrc/.zshrc) may have errors.\n` +
239
+ `Try skipping startup files: ${shellName} ${noRcFlag}`
240
+ );
241
+ }
242
+
243
+ it('should suggest --norc for bare bash that exits quickly', () => {
244
+ const hint = buildHint('bash');
245
+ assert.ok(hint.includes('--norc'), 'Hint must include --norc for bash');
246
+ assert.ok(
247
+ hint.includes('bash --norc'),
248
+ 'Hint must show the full corrected command'
249
+ );
250
+ assert.ok(
251
+ hint.includes('startup file'),
252
+ 'Hint must explain why the shell exited'
253
+ );
254
+ });
255
+
256
+ it('should suggest --no-rcs for bare zsh that exits quickly', () => {
257
+ const hint = buildHint('zsh');
258
+ assert.ok(hint.includes('--no-rcs'), 'Hint must include --no-rcs for zsh');
259
+ assert.ok(
260
+ hint.includes('zsh --no-rcs'),
261
+ 'Hint must show the full corrected command'
262
+ );
263
+ });
264
+
265
+ it('should suggest --norc for /bin/bash path that exits quickly', () => {
266
+ const hint = buildHint('/bin/bash');
267
+ assert.ok(
268
+ hint.includes('/bin/bash --norc'),
269
+ 'Hint must include full path with --norc'
270
+ );
271
+ });
272
+
273
+ it('should suggest --norc for bare sh that exits quickly', () => {
274
+ const hint = buildHint('sh');
275
+ assert.ok(hint.includes('sh --norc'), 'Hint must suggest --norc for sh');
276
+ });
277
+
278
+ it('should confirm workaround: bash --norc is detected as bare shell', () => {
279
+ // The workaround (bash --norc) must still be recognized as a bare shell
280
+ // so it gets passed directly to docker without -c wrapping
281
+ assert.strictEqual(
282
+ isInteractiveShellCommand('bash --norc'),
283
+ true,
284
+ 'bash --norc must be a bare shell (no -c wrapping)'
285
+ );
286
+ assert.strictEqual(
287
+ isInteractiveShellCommand('zsh --no-rcs'),
288
+ true,
289
+ 'zsh --no-rcs must be a bare shell (no -c wrapping)'
290
+ );
291
+ });
292
+ });