start-command 0.3.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/.changeset/README.md +8 -0
- package/.changeset/config.json +11 -0
- package/.changeset/isolation-support.md +30 -0
- package/.github/workflows/release.yml +292 -0
- package/.husky/pre-commit +1 -0
- package/.prettierignore +6 -0
- package/.prettierrc +10 -0
- package/CHANGELOG.md +24 -0
- package/LICENSE +24 -0
- package/README.md +249 -0
- package/REQUIREMENTS.md +229 -0
- package/bun.lock +453 -0
- package/bunfig.toml +3 -0
- package/eslint.config.mjs +122 -0
- package/experiments/debug-regex.js +49 -0
- package/experiments/isolation-design.md +142 -0
- package/experiments/test-cli.sh +42 -0
- package/experiments/test-substitution.js +143 -0
- package/package.json +63 -0
- package/scripts/changeset-version.mjs +38 -0
- package/scripts/check-file-size.mjs +103 -0
- package/scripts/create-github-release.mjs +93 -0
- package/scripts/create-manual-changeset.mjs +89 -0
- package/scripts/format-github-release.mjs +83 -0
- package/scripts/format-release-notes.mjs +219 -0
- package/scripts/instant-version-bump.mjs +121 -0
- package/scripts/publish-to-npm.mjs +129 -0
- package/scripts/setup-npm.mjs +37 -0
- package/scripts/validate-changeset.mjs +107 -0
- package/scripts/version-and-commit.mjs +237 -0
- package/src/bin/cli.js +670 -0
- package/src/lib/args-parser.js +259 -0
- package/src/lib/isolation.js +419 -0
- package/src/lib/substitution.js +323 -0
- package/src/lib/substitutions.lino +308 -0
- package/test/args-parser.test.js +389 -0
- package/test/isolation.test.js +248 -0
- package/test/substitution.test.js +236 -0
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Argument Parser for start-command wrapper options
|
|
3
|
+
*
|
|
4
|
+
* Supports two syntax patterns:
|
|
5
|
+
* 1. $ [wrapper-options] -- [command-options]
|
|
6
|
+
* 2. $ [wrapper-options] command [command-options]
|
|
7
|
+
*
|
|
8
|
+
* Wrapper Options:
|
|
9
|
+
* --isolated, -i <backend> Run in isolated environment (screen, tmux, docker, zellij)
|
|
10
|
+
* --attached, -a Run in attached mode (foreground)
|
|
11
|
+
* --detached, -d Run in detached mode (background)
|
|
12
|
+
* --session, -s <name> Session name for isolation
|
|
13
|
+
* --image <image> Docker image (required for docker isolation)
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
// Debug mode from environment
|
|
17
|
+
const DEBUG =
|
|
18
|
+
process.env.START_DEBUG === '1' || process.env.START_DEBUG === 'true';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Valid isolation backends
|
|
22
|
+
*/
|
|
23
|
+
const VALID_BACKENDS = ['screen', 'tmux', 'docker', 'zellij'];
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Parse command line arguments into wrapper options and command
|
|
27
|
+
* @param {string[]} args - Array of command line arguments
|
|
28
|
+
* @returns {{wrapperOptions: object, command: string, rawCommand: string[]}}
|
|
29
|
+
*/
|
|
30
|
+
function parseArgs(args) {
|
|
31
|
+
const wrapperOptions = {
|
|
32
|
+
isolated: null, // Isolation backend: screen, tmux, docker, zellij
|
|
33
|
+
attached: false, // Run in attached mode
|
|
34
|
+
detached: false, // Run in detached mode
|
|
35
|
+
session: null, // Session name
|
|
36
|
+
image: null, // Docker image
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
let commandArgs = [];
|
|
40
|
+
let i = 0;
|
|
41
|
+
|
|
42
|
+
// Find the separator '--' or detect where command starts
|
|
43
|
+
const separatorIndex = args.indexOf('--');
|
|
44
|
+
|
|
45
|
+
if (separatorIndex !== -1) {
|
|
46
|
+
// Pattern 1: explicit separator
|
|
47
|
+
const wrapperArgs = args.slice(0, separatorIndex);
|
|
48
|
+
commandArgs = args.slice(separatorIndex + 1);
|
|
49
|
+
|
|
50
|
+
parseWrapperArgs(wrapperArgs, wrapperOptions);
|
|
51
|
+
} else {
|
|
52
|
+
// Pattern 2: parse until we hit a non-option argument
|
|
53
|
+
while (i < args.length) {
|
|
54
|
+
const arg = args[i];
|
|
55
|
+
|
|
56
|
+
if (arg.startsWith('-')) {
|
|
57
|
+
const consumed = parseOption(args, i, wrapperOptions);
|
|
58
|
+
if (consumed === 0) {
|
|
59
|
+
// Unknown option, treat rest as command
|
|
60
|
+
commandArgs = args.slice(i);
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
i += consumed;
|
|
64
|
+
} else {
|
|
65
|
+
// Non-option argument, rest is command
|
|
66
|
+
commandArgs = args.slice(i);
|
|
67
|
+
break;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Validate options
|
|
73
|
+
validateOptions(wrapperOptions);
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
wrapperOptions,
|
|
77
|
+
command: commandArgs.join(' '),
|
|
78
|
+
rawCommand: commandArgs,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Parse wrapper arguments
|
|
84
|
+
* @param {string[]} args - Wrapper arguments
|
|
85
|
+
* @param {object} options - Options object to populate
|
|
86
|
+
*/
|
|
87
|
+
function parseWrapperArgs(args, options) {
|
|
88
|
+
let i = 0;
|
|
89
|
+
while (i < args.length) {
|
|
90
|
+
const consumed = parseOption(args, i, options);
|
|
91
|
+
if (consumed === 0) {
|
|
92
|
+
if (DEBUG) {
|
|
93
|
+
console.warn(`Unknown wrapper option: ${args[i]}`);
|
|
94
|
+
}
|
|
95
|
+
i++;
|
|
96
|
+
} else {
|
|
97
|
+
i += consumed;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Parse a single option from args array
|
|
104
|
+
* @param {string[]} args - Arguments array
|
|
105
|
+
* @param {number} index - Current index
|
|
106
|
+
* @param {object} options - Options object to populate
|
|
107
|
+
* @returns {number} Number of arguments consumed (0 if not recognized)
|
|
108
|
+
*/
|
|
109
|
+
function parseOption(args, index, options) {
|
|
110
|
+
const arg = args[index];
|
|
111
|
+
|
|
112
|
+
// --isolated or -i
|
|
113
|
+
if (arg === '--isolated' || arg === '-i') {
|
|
114
|
+
if (index + 1 < args.length && !args[index + 1].startsWith('-')) {
|
|
115
|
+
options.isolated = args[index + 1].toLowerCase();
|
|
116
|
+
return 2;
|
|
117
|
+
} else {
|
|
118
|
+
throw new Error(
|
|
119
|
+
`Option ${arg} requires a backend argument (screen, tmux, docker, zellij)`
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// --isolated=<value>
|
|
125
|
+
if (arg.startsWith('--isolated=')) {
|
|
126
|
+
options.isolated = arg.split('=')[1].toLowerCase();
|
|
127
|
+
return 1;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// --attached or -a
|
|
131
|
+
if (arg === '--attached' || arg === '-a') {
|
|
132
|
+
options.attached = true;
|
|
133
|
+
return 1;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// --detached or -d
|
|
137
|
+
if (arg === '--detached' || arg === '-d') {
|
|
138
|
+
options.detached = true;
|
|
139
|
+
return 1;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// --session or -s
|
|
143
|
+
if (arg === '--session' || arg === '-s') {
|
|
144
|
+
if (index + 1 < args.length && !args[index + 1].startsWith('-')) {
|
|
145
|
+
options.session = args[index + 1];
|
|
146
|
+
return 2;
|
|
147
|
+
} else {
|
|
148
|
+
throw new Error(`Option ${arg} requires a session name argument`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// --session=<value>
|
|
153
|
+
if (arg.startsWith('--session=')) {
|
|
154
|
+
options.session = arg.split('=')[1];
|
|
155
|
+
return 1;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// --image (for docker)
|
|
159
|
+
if (arg === '--image') {
|
|
160
|
+
if (index + 1 < args.length && !args[index + 1].startsWith('-')) {
|
|
161
|
+
options.image = args[index + 1];
|
|
162
|
+
return 2;
|
|
163
|
+
} else {
|
|
164
|
+
throw new Error(`Option ${arg} requires an image name argument`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// --image=<value>
|
|
169
|
+
if (arg.startsWith('--image=')) {
|
|
170
|
+
options.image = arg.split('=')[1];
|
|
171
|
+
return 1;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Not a recognized wrapper option
|
|
175
|
+
return 0;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Validate parsed options
|
|
180
|
+
* @param {object} options - Parsed options
|
|
181
|
+
* @throws {Error} If options are invalid
|
|
182
|
+
*/
|
|
183
|
+
function validateOptions(options) {
|
|
184
|
+
// Check attached and detached conflict
|
|
185
|
+
if (options.attached && options.detached) {
|
|
186
|
+
throw new Error(
|
|
187
|
+
'Cannot use both --attached and --detached at the same time. Please choose only one mode.'
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Validate isolation backend
|
|
192
|
+
if (options.isolated !== null) {
|
|
193
|
+
if (!VALID_BACKENDS.includes(options.isolated)) {
|
|
194
|
+
throw new Error(
|
|
195
|
+
`Invalid isolation backend: "${options.isolated}". Valid options are: ${VALID_BACKENDS.join(', ')}`
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Docker requires --image
|
|
200
|
+
if (options.isolated === 'docker' && !options.image) {
|
|
201
|
+
throw new Error(
|
|
202
|
+
'Docker isolation requires --image option to specify the container image'
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Session name is only valid with isolation
|
|
208
|
+
if (options.session && !options.isolated) {
|
|
209
|
+
throw new Error('--session option is only valid with --isolated');
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Image is only valid with docker
|
|
213
|
+
if (options.image && options.isolated !== 'docker') {
|
|
214
|
+
throw new Error('--image option is only valid with --isolated docker');
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Generate a unique session name
|
|
220
|
+
* @param {string} [prefix='start'] - Prefix for the session name
|
|
221
|
+
* @returns {string} Generated session name
|
|
222
|
+
*/
|
|
223
|
+
function generateSessionName(prefix = 'start') {
|
|
224
|
+
const timestamp = Date.now();
|
|
225
|
+
const random = Math.random().toString(36).substring(2, 8);
|
|
226
|
+
return `${prefix}-${timestamp}-${random}`;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Check if any isolation options are present
|
|
231
|
+
* @param {object} options - Parsed wrapper options
|
|
232
|
+
* @returns {boolean} True if isolation is requested
|
|
233
|
+
*/
|
|
234
|
+
function hasIsolation(options) {
|
|
235
|
+
return options.isolated !== null;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Get the effective mode for isolation
|
|
240
|
+
* Multiplexers default to attached, docker defaults to attached
|
|
241
|
+
* @param {object} options - Parsed wrapper options
|
|
242
|
+
* @returns {'attached'|'detached'} The effective mode
|
|
243
|
+
*/
|
|
244
|
+
function getEffectiveMode(options) {
|
|
245
|
+
if (options.detached) {
|
|
246
|
+
return 'detached';
|
|
247
|
+
}
|
|
248
|
+
// Default to attached for all backends
|
|
249
|
+
return 'attached';
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
module.exports = {
|
|
253
|
+
parseArgs,
|
|
254
|
+
validateOptions,
|
|
255
|
+
generateSessionName,
|
|
256
|
+
hasIsolation,
|
|
257
|
+
getEffectiveMode,
|
|
258
|
+
VALID_BACKENDS,
|
|
259
|
+
};
|
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Isolation Runners for start-command
|
|
3
|
+
*
|
|
4
|
+
* Provides execution of commands in various isolated environments:
|
|
5
|
+
* - screen: GNU Screen terminal multiplexer
|
|
6
|
+
* - tmux: tmux terminal multiplexer
|
|
7
|
+
* - zellij: Modern terminal workspace
|
|
8
|
+
* - docker: Docker containers
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const { execSync, spawn } = require('child_process');
|
|
12
|
+
const { generateSessionName } = require('./args-parser');
|
|
13
|
+
|
|
14
|
+
// Debug mode from environment
|
|
15
|
+
const DEBUG =
|
|
16
|
+
process.env.START_DEBUG === '1' || process.env.START_DEBUG === 'true';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Check if a command is available on the system
|
|
20
|
+
* @param {string} command - Command to check
|
|
21
|
+
* @returns {boolean} True if command is available
|
|
22
|
+
*/
|
|
23
|
+
function isCommandAvailable(command) {
|
|
24
|
+
try {
|
|
25
|
+
const isWindows = process.platform === 'win32';
|
|
26
|
+
const checkCmd = isWindows ? 'where' : 'which';
|
|
27
|
+
execSync(`${checkCmd} ${command}`, { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
28
|
+
return true;
|
|
29
|
+
} catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Get the shell to use for command execution
|
|
36
|
+
* @returns {{shell: string, shellArgs: string[]}} Shell path and args
|
|
37
|
+
*/
|
|
38
|
+
function getShell() {
|
|
39
|
+
const isWindows = process.platform === 'win32';
|
|
40
|
+
const shell = isWindows ? 'cmd.exe' : process.env.SHELL || '/bin/sh';
|
|
41
|
+
const shellArg = isWindows ? '/c' : '-c';
|
|
42
|
+
return { shell, shellArg };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Run command in GNU Screen
|
|
47
|
+
* @param {string} command - Command to execute
|
|
48
|
+
* @param {object} options - Options (session, detached)
|
|
49
|
+
* @returns {Promise<{success: boolean, sessionName: string, message: string}>}
|
|
50
|
+
*/
|
|
51
|
+
function runInScreen(command, options = {}) {
|
|
52
|
+
if (!isCommandAvailable('screen')) {
|
|
53
|
+
return Promise.resolve({
|
|
54
|
+
success: false,
|
|
55
|
+
sessionName: null,
|
|
56
|
+
message:
|
|
57
|
+
'screen is not installed. Install it with: sudo apt-get install screen (Debian/Ubuntu) or brew install screen (macOS)',
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const sessionName = options.session || generateSessionName('screen');
|
|
62
|
+
const { shell, shellArg } = getShell();
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
if (options.detached) {
|
|
66
|
+
// Detached mode: screen -dmS <session> <shell> -c '<command>'
|
|
67
|
+
const screenArgs = ['-dmS', sessionName, shell, shellArg, command];
|
|
68
|
+
|
|
69
|
+
if (DEBUG) {
|
|
70
|
+
console.log(`[DEBUG] Running: screen ${screenArgs.join(' ')}`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
execSync(`screen ${screenArgs.map((a) => `"${a}"`).join(' ')}`, {
|
|
74
|
+
stdio: 'inherit',
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
return Promise.resolve({
|
|
78
|
+
success: true,
|
|
79
|
+
sessionName,
|
|
80
|
+
message: `Command started in detached screen session: ${sessionName}\nReattach with: screen -r ${sessionName}`,
|
|
81
|
+
});
|
|
82
|
+
} else {
|
|
83
|
+
// Attached mode: screen -S <session> <shell> -c '<command>'
|
|
84
|
+
const screenArgs = ['-S', sessionName, shell, shellArg, command];
|
|
85
|
+
|
|
86
|
+
if (DEBUG) {
|
|
87
|
+
console.log(`[DEBUG] Running: screen ${screenArgs.join(' ')}`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return new Promise((resolve) => {
|
|
91
|
+
const child = spawn('screen', screenArgs, {
|
|
92
|
+
stdio: 'inherit',
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
child.on('exit', (code) => {
|
|
96
|
+
resolve({
|
|
97
|
+
success: code === 0,
|
|
98
|
+
sessionName,
|
|
99
|
+
message: `Screen session "${sessionName}" exited with code ${code}`,
|
|
100
|
+
exitCode: code,
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
child.on('error', (err) => {
|
|
105
|
+
resolve({
|
|
106
|
+
success: false,
|
|
107
|
+
sessionName,
|
|
108
|
+
message: `Failed to start screen: ${err.message}`,
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
} catch (err) {
|
|
114
|
+
return Promise.resolve({
|
|
115
|
+
success: false,
|
|
116
|
+
sessionName,
|
|
117
|
+
message: `Failed to run in screen: ${err.message}`,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Run command in tmux
|
|
124
|
+
* @param {string} command - Command to execute
|
|
125
|
+
* @param {object} options - Options (session, detached)
|
|
126
|
+
* @returns {Promise<{success: boolean, sessionName: string, message: string}>}
|
|
127
|
+
*/
|
|
128
|
+
function runInTmux(command, options = {}) {
|
|
129
|
+
if (!isCommandAvailable('tmux')) {
|
|
130
|
+
return Promise.resolve({
|
|
131
|
+
success: false,
|
|
132
|
+
sessionName: null,
|
|
133
|
+
message:
|
|
134
|
+
'tmux is not installed. Install it with: sudo apt-get install tmux (Debian/Ubuntu) or brew install tmux (macOS)',
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const sessionName = options.session || generateSessionName('tmux');
|
|
139
|
+
|
|
140
|
+
try {
|
|
141
|
+
if (options.detached) {
|
|
142
|
+
// Detached mode: tmux new-session -d -s <session> '<command>'
|
|
143
|
+
if (DEBUG) {
|
|
144
|
+
console.log(
|
|
145
|
+
`[DEBUG] Running: tmux new-session -d -s "${sessionName}" "${command}"`
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
execSync(`tmux new-session -d -s "${sessionName}" "${command}"`, {
|
|
150
|
+
stdio: 'inherit',
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
return Promise.resolve({
|
|
154
|
+
success: true,
|
|
155
|
+
sessionName,
|
|
156
|
+
message: `Command started in detached tmux session: ${sessionName}\nReattach with: tmux attach -t ${sessionName}`,
|
|
157
|
+
});
|
|
158
|
+
} else {
|
|
159
|
+
// Attached mode: tmux new-session -s <session> '<command>'
|
|
160
|
+
if (DEBUG) {
|
|
161
|
+
console.log(
|
|
162
|
+
`[DEBUG] Running: tmux new-session -s "${sessionName}" "${command}"`
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return new Promise((resolve) => {
|
|
167
|
+
const child = spawn(
|
|
168
|
+
'tmux',
|
|
169
|
+
['new-session', '-s', sessionName, command],
|
|
170
|
+
{
|
|
171
|
+
stdio: 'inherit',
|
|
172
|
+
}
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
child.on('exit', (code) => {
|
|
176
|
+
resolve({
|
|
177
|
+
success: code === 0,
|
|
178
|
+
sessionName,
|
|
179
|
+
message: `Tmux session "${sessionName}" exited with code ${code}`,
|
|
180
|
+
exitCode: code,
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
child.on('error', (err) => {
|
|
185
|
+
resolve({
|
|
186
|
+
success: false,
|
|
187
|
+
sessionName,
|
|
188
|
+
message: `Failed to start tmux: ${err.message}`,
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
} catch (err) {
|
|
194
|
+
return Promise.resolve({
|
|
195
|
+
success: false,
|
|
196
|
+
sessionName,
|
|
197
|
+
message: `Failed to run in tmux: ${err.message}`,
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Run command in Zellij
|
|
204
|
+
* @param {string} command - Command to execute
|
|
205
|
+
* @param {object} options - Options (session, detached)
|
|
206
|
+
* @returns {Promise<{success: boolean, sessionName: string, message: string}>}
|
|
207
|
+
*/
|
|
208
|
+
function runInZellij(command, options = {}) {
|
|
209
|
+
if (!isCommandAvailable('zellij')) {
|
|
210
|
+
return Promise.resolve({
|
|
211
|
+
success: false,
|
|
212
|
+
sessionName: null,
|
|
213
|
+
message:
|
|
214
|
+
'zellij is not installed. Install it with: cargo install zellij or brew install zellij (macOS)',
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const sessionName = options.session || generateSessionName('zellij');
|
|
219
|
+
const { shell, shellArg } = getShell();
|
|
220
|
+
|
|
221
|
+
try {
|
|
222
|
+
if (options.detached) {
|
|
223
|
+
// Detached mode for zellij
|
|
224
|
+
if (DEBUG) {
|
|
225
|
+
console.log(`[DEBUG] Creating detached zellij session: ${sessionName}`);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Create the session in background
|
|
229
|
+
execSync(
|
|
230
|
+
`zellij -s "${sessionName}" action new-tab -- ${shell} ${shellArg} "${command}" &`,
|
|
231
|
+
{ stdio: 'inherit', shell: true }
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
return Promise.resolve({
|
|
235
|
+
success: true,
|
|
236
|
+
sessionName,
|
|
237
|
+
message: `Command started in detached zellij session: ${sessionName}\nReattach with: zellij attach ${sessionName}`,
|
|
238
|
+
});
|
|
239
|
+
} else {
|
|
240
|
+
// Attached mode: zellij -s <session> -- <shell> -c <command>
|
|
241
|
+
if (DEBUG) {
|
|
242
|
+
console.log(
|
|
243
|
+
`[DEBUG] Running: zellij -s "${sessionName}" -- ${shell} ${shellArg} "${command}"`
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return new Promise((resolve) => {
|
|
248
|
+
const child = spawn(
|
|
249
|
+
'zellij',
|
|
250
|
+
['-s', sessionName, '--', shell, shellArg, command],
|
|
251
|
+
{
|
|
252
|
+
stdio: 'inherit',
|
|
253
|
+
}
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
child.on('exit', (code) => {
|
|
257
|
+
resolve({
|
|
258
|
+
success: code === 0,
|
|
259
|
+
sessionName,
|
|
260
|
+
message: `Zellij session "${sessionName}" exited with code ${code}`,
|
|
261
|
+
exitCode: code,
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
child.on('error', (err) => {
|
|
266
|
+
resolve({
|
|
267
|
+
success: false,
|
|
268
|
+
sessionName,
|
|
269
|
+
message: `Failed to start zellij: ${err.message}`,
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
} catch (err) {
|
|
275
|
+
return Promise.resolve({
|
|
276
|
+
success: false,
|
|
277
|
+
sessionName,
|
|
278
|
+
message: `Failed to run in zellij: ${err.message}`,
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Run command in Docker container
|
|
285
|
+
* @param {string} command - Command to execute
|
|
286
|
+
* @param {object} options - Options (image, session/name, detached)
|
|
287
|
+
* @returns {Promise<{success: boolean, containerName: string, message: string}>}
|
|
288
|
+
*/
|
|
289
|
+
function runInDocker(command, options = {}) {
|
|
290
|
+
if (!isCommandAvailable('docker')) {
|
|
291
|
+
return Promise.resolve({
|
|
292
|
+
success: false,
|
|
293
|
+
containerName: null,
|
|
294
|
+
message:
|
|
295
|
+
'docker is not installed. Install Docker from https://docs.docker.com/get-docker/',
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (!options.image) {
|
|
300
|
+
return Promise.resolve({
|
|
301
|
+
success: false,
|
|
302
|
+
containerName: null,
|
|
303
|
+
message: 'Docker isolation requires --image option',
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const containerName = options.session || generateSessionName('docker');
|
|
308
|
+
|
|
309
|
+
try {
|
|
310
|
+
if (options.detached) {
|
|
311
|
+
// Detached mode: docker run -d --name <name> <image> <shell> -c '<command>'
|
|
312
|
+
const dockerArgs = [
|
|
313
|
+
'run',
|
|
314
|
+
'-d',
|
|
315
|
+
'--name',
|
|
316
|
+
containerName,
|
|
317
|
+
options.image,
|
|
318
|
+
'/bin/sh',
|
|
319
|
+
'-c',
|
|
320
|
+
command,
|
|
321
|
+
];
|
|
322
|
+
|
|
323
|
+
if (DEBUG) {
|
|
324
|
+
console.log(`[DEBUG] Running: docker ${dockerArgs.join(' ')}`);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const containerId = execSync(`docker ${dockerArgs.join(' ')}`, {
|
|
328
|
+
encoding: 'utf8',
|
|
329
|
+
}).trim();
|
|
330
|
+
|
|
331
|
+
return Promise.resolve({
|
|
332
|
+
success: true,
|
|
333
|
+
containerName,
|
|
334
|
+
containerId,
|
|
335
|
+
message: `Command started in detached docker container: ${containerName}\nContainer ID: ${containerId.substring(0, 12)}\nAttach with: docker attach ${containerName}\nView logs: docker logs ${containerName}`,
|
|
336
|
+
});
|
|
337
|
+
} else {
|
|
338
|
+
// Attached mode: docker run -it --name <name> <image> <shell> -c '<command>'
|
|
339
|
+
const dockerArgs = [
|
|
340
|
+
'run',
|
|
341
|
+
'-it',
|
|
342
|
+
'--rm',
|
|
343
|
+
'--name',
|
|
344
|
+
containerName,
|
|
345
|
+
options.image,
|
|
346
|
+
'/bin/sh',
|
|
347
|
+
'-c',
|
|
348
|
+
command,
|
|
349
|
+
];
|
|
350
|
+
|
|
351
|
+
if (DEBUG) {
|
|
352
|
+
console.log(`[DEBUG] Running: docker ${dockerArgs.join(' ')}`);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
return new Promise((resolve) => {
|
|
356
|
+
const child = spawn('docker', dockerArgs, {
|
|
357
|
+
stdio: 'inherit',
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
child.on('exit', (code) => {
|
|
361
|
+
resolve({
|
|
362
|
+
success: code === 0,
|
|
363
|
+
containerName,
|
|
364
|
+
message: `Docker container "${containerName}" exited with code ${code}`,
|
|
365
|
+
exitCode: code,
|
|
366
|
+
});
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
child.on('error', (err) => {
|
|
370
|
+
resolve({
|
|
371
|
+
success: false,
|
|
372
|
+
containerName,
|
|
373
|
+
message: `Failed to start docker: ${err.message}`,
|
|
374
|
+
});
|
|
375
|
+
});
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
} catch (err) {
|
|
379
|
+
return Promise.resolve({
|
|
380
|
+
success: false,
|
|
381
|
+
containerName,
|
|
382
|
+
message: `Failed to run in docker: ${err.message}`,
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Run command in the specified isolation backend
|
|
389
|
+
* @param {string} backend - Isolation backend (screen, tmux, docker, zellij)
|
|
390
|
+
* @param {string} command - Command to execute
|
|
391
|
+
* @param {object} options - Options
|
|
392
|
+
* @returns {Promise<{success: boolean, message: string}>}
|
|
393
|
+
*/
|
|
394
|
+
function runIsolated(backend, command, options = {}) {
|
|
395
|
+
switch (backend) {
|
|
396
|
+
case 'screen':
|
|
397
|
+
return runInScreen(command, options);
|
|
398
|
+
case 'tmux':
|
|
399
|
+
return runInTmux(command, options);
|
|
400
|
+
case 'zellij':
|
|
401
|
+
return runInZellij(command, options);
|
|
402
|
+
case 'docker':
|
|
403
|
+
return runInDocker(command, options);
|
|
404
|
+
default:
|
|
405
|
+
return Promise.resolve({
|
|
406
|
+
success: false,
|
|
407
|
+
message: `Unknown isolation backend: ${backend}`,
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
module.exports = {
|
|
413
|
+
isCommandAvailable,
|
|
414
|
+
runInScreen,
|
|
415
|
+
runInTmux,
|
|
416
|
+
runInZellij,
|
|
417
|
+
runInDocker,
|
|
418
|
+
runIsolated,
|
|
419
|
+
};
|