start-command 0.11.0 → 0.13.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.
@@ -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
+ * --use-command-stream Use command-stream library for command execution (experimental)
19
20
  */
20
21
 
21
22
  // Debug mode from environment
@@ -45,6 +46,7 @@ function parseArgs(args) {
45
46
  keepUser: false, // Keep isolated user after command completes (don't delete)
46
47
  keepAlive: false, // Keep environment alive after command exits
47
48
  autoRemoveDockerContainer: false, // Auto-remove docker container after exit
49
+ useCommandStream: false, // Use command-stream library for command execution
48
50
  };
49
51
 
50
52
  let commandArgs = [];
@@ -239,6 +241,12 @@ function parseOption(args, index, options) {
239
241
  return 1;
240
242
  }
241
243
 
244
+ // --use-command-stream
245
+ if (arg === '--use-command-stream') {
246
+ options.useCommandStream = true;
247
+ return 1;
248
+ }
249
+
242
250
  // Not a recognized wrapper option
243
251
  return 0;
244
252
  }
@@ -0,0 +1,258 @@
1
+ /**
2
+ * Command-Stream Wrapper for start-command
3
+ *
4
+ * This module provides a bridge to the command-stream library, which uses ESM,
5
+ * from the CommonJS-based start-command codebase.
6
+ *
7
+ * The command-stream library provides:
8
+ * - Shell command execution with streaming support
9
+ * - Synchronous and asynchronous execution modes
10
+ * - Built-in virtual commands (echo, ls, pwd, cd, etc.)
11
+ * - Real-time output capture
12
+ */
13
+
14
+ // Debug mode from environment
15
+ const DEBUG =
16
+ process.env.START_DEBUG === '1' || process.env.START_DEBUG === 'true';
17
+
18
+ // Cached command-stream module
19
+ let commandStream = null;
20
+
21
+ /**
22
+ * Get the command-stream module (lazy-loaded)
23
+ * @returns {Promise<object>} The command-stream module
24
+ */
25
+ async function getCommandStream() {
26
+ if (!commandStream) {
27
+ commandStream = await import('command-stream');
28
+ }
29
+ return commandStream;
30
+ }
31
+
32
+ /**
33
+ * Execute a shell command synchronously and return the result
34
+ * Uses command-stream's $ function with .sync() for blocking execution.
35
+ *
36
+ * @param {string} command - The shell command to execute
37
+ * @param {object} options - Options for command execution
38
+ * @param {boolean} options.silent - If true, don't mirror output to console (default: true)
39
+ * @param {boolean} options.captureOutput - If true, capture stdout/stderr (default: true)
40
+ * @returns {Promise<{stdout: string, stderr: string, code: number}>} Command result
41
+ */
42
+ async function execCommand(command, options = {}) {
43
+ const { $ } = await getCommandStream();
44
+
45
+ const silent = options.silent !== false;
46
+
47
+ // Create a configured $ instance
48
+ const $cmd = $({ mirror: !silent, capture: true });
49
+
50
+ try {
51
+ // Use sync() for synchronous execution
52
+ const result = $cmd`${command}`.sync();
53
+
54
+ return {
55
+ stdout: (result.stdout || '').trim(),
56
+ stderr: (result.stderr || '').trim(),
57
+ code: result.code || 0,
58
+ };
59
+ } catch (err) {
60
+ if (DEBUG) {
61
+ console.log(`[DEBUG] execCommand error: ${err.message}`);
62
+ }
63
+ return {
64
+ stdout: '',
65
+ stderr: err.message || '',
66
+ code: 1,
67
+ };
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Execute a shell command asynchronously and return the result
73
+ *
74
+ * @param {string} command - The shell command to execute
75
+ * @param {object} options - Options for command execution
76
+ * @param {boolean} options.silent - If true, don't mirror output to console (default: true)
77
+ * @param {boolean} options.captureOutput - If true, capture stdout/stderr (default: true)
78
+ * @returns {Promise<{stdout: string, stderr: string, code: number}>} Command result
79
+ */
80
+ async function execCommandAsync(command, options = {}) {
81
+ const { $ } = await getCommandStream();
82
+
83
+ const silent = options.silent !== false;
84
+
85
+ // Create a configured $ instance
86
+ const $cmd = $({ mirror: !silent, capture: true });
87
+
88
+ try {
89
+ const result = await $cmd`${command}`;
90
+
91
+ return {
92
+ stdout: (result.stdout || '').trim(),
93
+ stderr: (result.stderr || '').trim(),
94
+ code: result.code || 0,
95
+ };
96
+ } catch (err) {
97
+ if (DEBUG) {
98
+ console.log(`[DEBUG] execCommandAsync error: ${err.message}`);
99
+ }
100
+ return {
101
+ stdout: '',
102
+ stderr: err.message || '',
103
+ code: 1,
104
+ };
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Check if a command exists in the PATH
110
+ *
111
+ * @param {string} commandName - The command to check for
112
+ * @returns {Promise<boolean>} True if the command exists
113
+ */
114
+ async function commandExists(commandName) {
115
+ const isWindows = process.platform === 'win32';
116
+ const whichCmd = isWindows ? 'where' : 'which';
117
+
118
+ const result = await execCommand(`${whichCmd} ${commandName}`);
119
+ return result.code === 0;
120
+ }
121
+
122
+ /**
123
+ * Get the path to a command
124
+ *
125
+ * @param {string} commandName - The command to find
126
+ * @returns {Promise<string|null>} Path to the command or null if not found
127
+ */
128
+ async function getCommandPath(commandName) {
129
+ const isWindows = process.platform === 'win32';
130
+ const whichCmd = isWindows ? 'where' : 'which';
131
+
132
+ const result = await execCommand(`${whichCmd} ${commandName}`);
133
+ if (result.code === 0 && result.stdout) {
134
+ // On Windows, where returns multiple lines, take the first
135
+ return result.stdout.split('\n')[0].trim();
136
+ }
137
+ return null;
138
+ }
139
+
140
+ /**
141
+ * Get the version of a tool by running it with a version flag
142
+ *
143
+ * @param {string} toolName - Name of the tool
144
+ * @param {string} versionFlag - Flag to get version (e.g., '--version', '-V')
145
+ * @param {boolean} verbose - Whether to log verbose information
146
+ * @returns {Promise<string|null>} Version string or null if not installed
147
+ */
148
+ async function getToolVersion(toolName, versionFlag, verbose = false) {
149
+ // First check if the tool exists
150
+ const exists = await commandExists(toolName);
151
+ if (!exists) {
152
+ if (verbose) {
153
+ console.log(`[verbose] ${toolName}: not found in PATH`);
154
+ }
155
+ return null;
156
+ }
157
+
158
+ // Get the version - command-stream handles the output capture
159
+ const result = await execCommand(`${toolName} ${versionFlag}`);
160
+
161
+ // Combine stdout and stderr since some tools output version to stderr
162
+ const output = `${result.stdout}\n${result.stderr}`.trim();
163
+
164
+ if (verbose) {
165
+ console.log(
166
+ `[verbose] ${toolName} ${versionFlag}: exit=${result.code}, output="${output.substring(0, 100)}"`
167
+ );
168
+ }
169
+
170
+ if (!output) {
171
+ return null;
172
+ }
173
+
174
+ // Return the first line of output
175
+ const firstLine = output.split('\n')[0];
176
+ return firstLine || null;
177
+ }
178
+
179
+ /**
180
+ * Run a command with real-time output streaming
181
+ * This returns a ProcessRunner that can be used for advanced control.
182
+ *
183
+ * @param {string} command - The shell command to execute
184
+ * @param {object} options - Options for command execution
185
+ * @param {boolean} options.mirror - If true, mirror output to console (default: true)
186
+ * @param {boolean} options.capture - If true, capture output (default: true)
187
+ * @param {string} options.stdin - Input to pass to the command
188
+ * @param {string} options.cwd - Working directory
189
+ * @param {object} options.env - Environment variables
190
+ * @returns {Promise<ProcessRunner>} The process runner for the command
191
+ */
192
+ async function runCommand(command, options = {}) {
193
+ const { $ } = await getCommandStream();
194
+
195
+ const $cmd = $({
196
+ mirror: options.mirror !== false,
197
+ capture: options.capture !== false,
198
+ stdin: options.stdin,
199
+ cwd: options.cwd,
200
+ env: options.env,
201
+ });
202
+
203
+ // Return the process runner
204
+ return $cmd`${command}`;
205
+ }
206
+
207
+ /**
208
+ * Run a command with event handlers for stdout, stderr, and exit
209
+ *
210
+ * @param {string} command - The shell command to execute
211
+ * @param {object} handlers - Event handlers
212
+ * @param {function} handlers.onStdout - Called with stdout data chunks
213
+ * @param {function} handlers.onStderr - Called with stderr data chunks
214
+ * @param {function} handlers.onExit - Called when command exits with {code, stdout, stderr}
215
+ * @param {object} options - Additional options
216
+ * @param {boolean} options.mirror - If true, also mirror output to console
217
+ * @returns {Promise<void>}
218
+ */
219
+ async function runWithHandlers(command, handlers = {}, options = {}) {
220
+ const { $ } = await getCommandStream();
221
+
222
+ const { onStdout, onStderr, onExit } = handlers;
223
+
224
+ const $cmd = $({
225
+ mirror: options.mirror === true,
226
+ capture: true,
227
+ });
228
+
229
+ const runner = $cmd`${command}`;
230
+
231
+ // Set up event handlers
232
+ if (onStdout) {
233
+ runner.on('stdout', onStdout);
234
+ }
235
+ if (onStderr) {
236
+ runner.on('stderr', onStderr);
237
+ }
238
+ if (onExit) {
239
+ runner.on('end', onExit);
240
+ }
241
+
242
+ // Start the command
243
+ runner.start();
244
+
245
+ // Wait for completion
246
+ return await runner;
247
+ }
248
+
249
+ module.exports = {
250
+ getCommandStream,
251
+ execCommand,
252
+ execCommandAsync,
253
+ commandExists,
254
+ getCommandPath,
255
+ getToolVersion,
256
+ runCommand,
257
+ runWithHandlers,
258
+ };
@@ -0,0 +1,397 @@
1
+ /**
2
+ * Failure handler for start-command
3
+ *
4
+ * Handles command failures - detects repository, uploads logs, creates issues
5
+ */
6
+
7
+ const { execSync } = require('child_process');
8
+ const os = require('os');
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+ const { getTimestamp } = require('./isolation');
12
+
13
+ /**
14
+ * Handle command failure - detect repository, upload log, create issue
15
+ * @param {object} config - Configuration object
16
+ * @param {string} cmdName - Command name
17
+ * @param {string} fullCommand - Full command that was executed
18
+ * @param {number} exitCode - Exit code of the command
19
+ * @param {string} logPath - Path to the log file
20
+ */
21
+ function handleFailure(config, cmdName, fullCommand, exitCode, logPath) {
22
+ console.log('');
23
+
24
+ // Check if auto-issue is disabled
25
+ if (config.disableAutoIssue) {
26
+ if (config.verbose) {
27
+ console.log('Auto-issue creation disabled via START_DISABLE_AUTO_ISSUE');
28
+ }
29
+ return;
30
+ }
31
+
32
+ // Try to detect repository for the command
33
+ const repoInfo = detectRepository(cmdName);
34
+
35
+ if (!repoInfo) {
36
+ console.log('Repository not detected - automatic issue creation skipped');
37
+ return;
38
+ }
39
+
40
+ console.log(`Detected repository: ${repoInfo.url}`);
41
+
42
+ // Check if gh CLI is available and authenticated
43
+ if (!isGhAuthenticated()) {
44
+ console.log(
45
+ 'GitHub CLI not authenticated - automatic issue creation skipped'
46
+ );
47
+ console.log('Run "gh auth login" to enable automatic issue creation');
48
+ return;
49
+ }
50
+
51
+ // Try to upload log
52
+ let logUrl = null;
53
+ if (config.disableLogUpload) {
54
+ if (config.verbose) {
55
+ console.log('Log upload disabled via START_DISABLE_LOG_UPLOAD');
56
+ }
57
+ } else if (isGhUploadLogAvailable()) {
58
+ logUrl = uploadLog(logPath);
59
+ if (logUrl) {
60
+ console.log(`Log uploaded: ${logUrl}`);
61
+ }
62
+ } else {
63
+ console.log('gh-upload-log not installed - log upload skipped');
64
+ console.log('Install with: bun install -g gh-upload-log');
65
+ }
66
+
67
+ // Check if we can create issues in this repository
68
+ if (!canCreateIssue(repoInfo.owner, repoInfo.repo)) {
69
+ console.log('Cannot create issue in repository - skipping issue creation');
70
+ return;
71
+ }
72
+
73
+ // Create issue
74
+ const issueUrl = createIssue(
75
+ repoInfo,
76
+ fullCommand,
77
+ exitCode,
78
+ logUrl,
79
+ logPath
80
+ );
81
+ if (issueUrl) {
82
+ console.log(`Issue created: ${issueUrl}`);
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Detect repository URL for a command (currently supports NPM global packages)
88
+ * @param {string} cmdName - Name of the command
89
+ * @returns {object|null} Repository info or null
90
+ */
91
+ function detectRepository(cmdName) {
92
+ const isWindows = process.platform === 'win32';
93
+
94
+ try {
95
+ // Find command location
96
+ const whichCmd = isWindows ? 'where' : 'which';
97
+ let cmdPath;
98
+
99
+ try {
100
+ cmdPath = execSync(`${whichCmd} ${cmdName}`, {
101
+ encoding: 'utf8',
102
+ stdio: ['pipe', 'pipe', 'pipe'],
103
+ }).trim();
104
+ } catch {
105
+ return null;
106
+ }
107
+
108
+ if (!cmdPath) {
109
+ return null;
110
+ }
111
+
112
+ // Handle Windows where command that returns multiple lines
113
+ if (isWindows && cmdPath.includes('\n')) {
114
+ cmdPath = cmdPath.split('\n')[0].trim();
115
+ }
116
+
117
+ // Check if it's in npm global modules
118
+ let npmGlobalPath;
119
+ try {
120
+ npmGlobalPath = execSync('npm root -g', {
121
+ encoding: 'utf8',
122
+ stdio: ['pipe', 'pipe', 'pipe'],
123
+ }).trim();
124
+ } catch {
125
+ return null;
126
+ }
127
+
128
+ // Get the npm bin directory (parent of node_modules)
129
+ const npmBinPath = `${path.dirname(npmGlobalPath)}/bin`;
130
+
131
+ // Check if the command is located in the npm bin directory or node_modules
132
+ let packageName = null;
133
+ let isNpmPackage = false;
134
+
135
+ try {
136
+ // Try to resolve the symlink to find the actual path
137
+ const realPath = fs.realpathSync(cmdPath);
138
+
139
+ // Check if the real path is within node_modules
140
+ if (realPath.includes('node_modules')) {
141
+ isNpmPackage = true;
142
+ const npmPathMatch = realPath.match(/node_modules\/([^/]+)/);
143
+ if (npmPathMatch) {
144
+ packageName = npmPathMatch[1];
145
+ }
146
+ }
147
+
148
+ // Also check if the command path itself is in npm's bin directory
149
+ if (!isNpmPackage && cmdPath.includes(npmBinPath)) {
150
+ isNpmPackage = true;
151
+ }
152
+
153
+ // Try to read the bin script to extract package info
154
+ if (!packageName) {
155
+ const binContent = fs.readFileSync(cmdPath, 'utf8');
156
+
157
+ // Check if this is a Node.js script
158
+ if (
159
+ binContent.startsWith('#!/usr/bin/env node') ||
160
+ binContent.includes('node_modules')
161
+ ) {
162
+ isNpmPackage = true;
163
+
164
+ // Look for package path in the script
165
+ const packagePathMatch = binContent.match(/node_modules\/([^/'"]+)/);
166
+ if (packagePathMatch) {
167
+ packageName = packagePathMatch[1];
168
+ }
169
+ }
170
+ }
171
+ } catch {
172
+ // Could not read/resolve command - not an npm package
173
+ return null;
174
+ }
175
+
176
+ // If we couldn't confirm this is an npm package, don't proceed
177
+ if (!isNpmPackage) {
178
+ return null;
179
+ }
180
+
181
+ // If we couldn't find the package name from the path, use the command name
182
+ if (!packageName) {
183
+ packageName = cmdName;
184
+ }
185
+
186
+ // Try to get repository URL from npm
187
+ try {
188
+ const npmInfo = execSync(
189
+ `npm view ${packageName} repository.url 2>/dev/null`,
190
+ {
191
+ encoding: 'utf8',
192
+ stdio: ['pipe', 'pipe', 'pipe'],
193
+ }
194
+ ).trim();
195
+
196
+ if (npmInfo) {
197
+ // Parse git URL to extract owner and repo
198
+ const parsed = parseGitUrl(npmInfo);
199
+ if (parsed) {
200
+ return parsed;
201
+ }
202
+ }
203
+ } catch {
204
+ // npm view failed, package might not exist or have no repository
205
+ }
206
+
207
+ // Try to get homepage or bugs URL as fallback
208
+ try {
209
+ const bugsUrl = execSync(`npm view ${packageName} bugs.url 2>/dev/null`, {
210
+ encoding: 'utf8',
211
+ stdio: ['pipe', 'pipe', 'pipe'],
212
+ }).trim();
213
+
214
+ if (bugsUrl && bugsUrl.includes('github.com')) {
215
+ const parsed = parseGitUrl(bugsUrl);
216
+ if (parsed) {
217
+ return parsed;
218
+ }
219
+ }
220
+ } catch {
221
+ // Fallback also failed
222
+ }
223
+
224
+ return null;
225
+ } catch {
226
+ return null;
227
+ }
228
+ }
229
+
230
+ /**
231
+ * Parse a git URL to extract owner, repo, and normalized URL
232
+ * @param {string} url - Git URL to parse
233
+ * @returns {object|null} Parsed URL info or null
234
+ */
235
+ function parseGitUrl(url) {
236
+ if (!url) {
237
+ return null;
238
+ }
239
+
240
+ // Handle various git URL formats
241
+ const match = url.match(/github\.com[/:]([^/]+)\/([^/.]+)/);
242
+ if (match) {
243
+ const owner = match[1];
244
+ const repo = match[2].replace(/\.git$/, '');
245
+ return {
246
+ owner,
247
+ repo,
248
+ url: `https://github.com/${owner}/${repo}`,
249
+ };
250
+ }
251
+
252
+ return null;
253
+ }
254
+
255
+ /**
256
+ * Check if GitHub CLI is authenticated
257
+ * @returns {boolean} True if authenticated
258
+ */
259
+ function isGhAuthenticated() {
260
+ try {
261
+ execSync('gh auth status', { stdio: ['pipe', 'pipe', 'pipe'] });
262
+ return true;
263
+ } catch {
264
+ return false;
265
+ }
266
+ }
267
+
268
+ /**
269
+ * Check if gh-upload-log is available
270
+ * @returns {boolean} True if available
271
+ */
272
+ function isGhUploadLogAvailable() {
273
+ const isWindows = process.platform === 'win32';
274
+ try {
275
+ const whichCmd = isWindows ? 'where' : 'which';
276
+ execSync(`${whichCmd} gh-upload-log`, { stdio: ['pipe', 'pipe', 'pipe'] });
277
+ return true;
278
+ } catch {
279
+ return false;
280
+ }
281
+ }
282
+
283
+ /**
284
+ * Upload log file using gh-upload-log
285
+ * @param {string} logPath - Path to the log file
286
+ * @returns {string|null} URL of the uploaded log or null
287
+ */
288
+ function uploadLog(logPath) {
289
+ try {
290
+ const result = execSync(`gh-upload-log "${logPath}" --public`, {
291
+ encoding: 'utf8',
292
+ stdio: ['pipe', 'pipe', 'pipe'],
293
+ });
294
+
295
+ // Extract URL from output
296
+ const urlMatch = result.match(/https:\/\/gist\.github\.com\/[^\s]+/);
297
+ if (urlMatch) {
298
+ return urlMatch[0];
299
+ }
300
+
301
+ // Try other URL patterns
302
+ const repoUrlMatch = result.match(/https:\/\/github\.com\/[^\s]+/);
303
+ if (repoUrlMatch) {
304
+ return repoUrlMatch[0];
305
+ }
306
+
307
+ return null;
308
+ } catch (err) {
309
+ console.log(`Warning: Log upload failed - ${err.message}`);
310
+ return null;
311
+ }
312
+ }
313
+
314
+ /**
315
+ * Check if we can create an issue in a repository
316
+ * @param {string} owner - Repository owner
317
+ * @param {string} repo - Repository name
318
+ * @returns {boolean} True if we can create issues
319
+ */
320
+ function canCreateIssue(owner, repo) {
321
+ try {
322
+ // Check if the repository exists and we have access
323
+ execSync(`gh repo view ${owner}/${repo} --json name`, {
324
+ stdio: ['pipe', 'pipe', 'pipe'],
325
+ });
326
+ return true;
327
+ } catch {
328
+ return false;
329
+ }
330
+ }
331
+
332
+ /**
333
+ * Create an issue in the repository
334
+ * @param {object} repoInfo - Repository info
335
+ * @param {string} fullCommand - Full command that failed
336
+ * @param {number} exitCode - Exit code
337
+ * @param {string|null} logUrl - URL to the log or null
338
+ * @returns {string|null} Issue URL or null
339
+ */
340
+ function createIssue(repoInfo, fullCommand, exitCode, logUrl) {
341
+ try {
342
+ const title = `Command failed with exit code ${exitCode}: ${fullCommand.substring(0, 50)}${fullCommand.length > 50 ? '...' : ''}`;
343
+
344
+ // Get runtime information
345
+ const runtime = typeof Bun !== 'undefined' ? 'Bun' : 'Node.js';
346
+ const runtimeVersion =
347
+ typeof Bun !== 'undefined' ? Bun.version : process.version;
348
+
349
+ let body = `## Command Execution Failure Report\n\n`;
350
+ body += `**Command:** \`${fullCommand}\`\n\n`;
351
+ body += `**Exit Code:** ${exitCode}\n\n`;
352
+ body += `**Timestamp:** ${getTimestamp()}\n\n`;
353
+ body += `### System Information\n\n`;
354
+ body += `- **Platform:** ${process.platform}\n`;
355
+ body += `- **OS Release:** ${os.release()}\n`;
356
+ body += `- **${runtime} Version:** ${runtimeVersion}\n`;
357
+ body += `- **Architecture:** ${process.arch}\n\n`;
358
+
359
+ if (logUrl) {
360
+ body += `### Log File\n\n`;
361
+ body += `Full log available at: ${logUrl}\n\n`;
362
+ }
363
+
364
+ body += `---\n`;
365
+ body += `*This issue was automatically created by [start-command](https://github.com/link-foundation/start)*\n`;
366
+
367
+ const result = execSync(
368
+ `gh issue create --repo ${repoInfo.owner}/${repoInfo.repo} --title "${title.replace(/"/g, '\\"')}" --body "${body.replace(/"/g, '\\"').replace(/\n/g, '\\n')}"`,
369
+ {
370
+ encoding: 'utf8',
371
+ stdio: ['pipe', 'pipe', 'pipe'],
372
+ }
373
+ );
374
+
375
+ // Extract issue URL from output
376
+ const urlMatch = result.match(/https:\/\/github\.com\/[^\s]+/);
377
+ if (urlMatch) {
378
+ return urlMatch[0];
379
+ }
380
+
381
+ return null;
382
+ } catch (err) {
383
+ console.log(`Warning: Issue creation failed - ${err.message}`);
384
+ return null;
385
+ }
386
+ }
387
+
388
+ module.exports = {
389
+ handleFailure,
390
+ detectRepository,
391
+ parseGitUrl,
392
+ isGhAuthenticated,
393
+ isGhUploadLogAvailable,
394
+ uploadLog,
395
+ canCreateIssue,
396
+ createIssue,
397
+ };