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 +32 -0
- package/package.json +1 -1
- package/src/lib/args-parser.js +35 -0
- package/src/lib/command-builder.js +4 -0
- package/src/lib/isolation-log-utils.js +147 -0
- package/src/lib/isolation.js +135 -135
- package/test/args-parser-shell.test.js +165 -0
- package/test/args-parser.test.js +2 -0
- package/test/isolation.test.js +64 -0
- package/test/user-manager.test.js +1 -1
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
package/src/lib/args-parser.js
CHANGED
|
@@ -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
|
+
};
|
package/src/lib/isolation.js
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
583
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
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
|
+
});
|
package/test/args-parser.test.js
CHANGED
package/test/isolation.test.js
CHANGED
|
@@ -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', () => {
|