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 +41 -0
- package/package.json +1 -1
- package/src/lib/isolation.js +72 -73
- package/test/isolation.test.js +56 -0
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
package/src/lib/isolation.js
CHANGED
|
@@ -211,20 +211,24 @@ function detectShellInEnvironment(
|
|
|
211
211
|
return 'sh';
|
|
212
212
|
}
|
|
213
213
|
|
|
214
|
-
/**
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
-
|
|
289
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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: ${
|
|
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:
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
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
|
-
|
|
784
|
-
|
|
785
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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 '<
|
|
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
|
-
|
|
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,
|
package/test/isolation.test.js
CHANGED
|
@@ -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
|
+
});
|