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.
- package/.github/workflows/release.yml +18 -0
- package/CHANGELOG.md +14 -0
- package/bun.lock +5 -0
- package/experiments/test-command-stream-cjs.cjs +30 -0
- package/experiments/test-command-stream-wrapper.js +54 -0
- package/experiments/test-command-stream.mjs +56 -0
- package/package.json +4 -1
- package/src/bin/cli.js +167 -390
- package/src/lib/args-parser.js +8 -0
- package/src/lib/command-stream.js +258 -0
- package/src/lib/failure-handler.js +397 -0
- package/test/args-parser.test.js +33 -0
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
|
+
* --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
|
+
};
|