martty 0.2.33 → 0.2.35
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/README.md +108 -11
- package/bin/martty.js +52 -5
- package/lib/acp-client.js +296 -20
- package/lib/acp-registry.snapshot.json +1450 -0
- package/lib/acp-session-config.js +23 -0
- package/lib/acp-session-plan.js +14 -2
- package/lib/acp-session-stats.js +22 -5
- package/lib/acp-session-status.js +70 -25
- package/lib/agent.js +24 -4
- package/lib/boot.js +29 -5
- package/lib/command-args.js +28 -0
- package/lib/download.js +165 -0
- package/lib/harness-discovery.js +41 -0
- package/lib/harness-package.js +200 -0
- package/lib/harness-registry.js +468 -0
- package/lib/harness-removal.js +88 -0
- package/lib/harness-view.js +639 -46
- package/lib/harnesses.js +1053 -41
- package/lib/index.js +4 -0
- package/lib/mux.js +23 -0
- package/lib/plan-view.js +18 -3
- package/lib/status-view.js +2 -0
- package/lib/tui-commands.js +7 -1
- package/lib/tui-overlay.js +27 -5
- package/package.json +7 -4
- package/vendor/darwin-arm64/martty +0 -0
- package/vendor/darwin-x64/martty +0 -0
- package/vendor/linux-arm64/martty +0 -0
- package/vendor/linux-x64/martty +0 -0
- package/vendor/win32-x64/martty.exe +0 -0
package/lib/download.js
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { DownloaderHelper } from 'node-downloader-helper'
|
|
2
|
+
import { lstatSync, statSync } from 'node:fs'
|
|
3
|
+
import { rm } from 'node:fs/promises'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
|
|
6
|
+
function httpUrl(value, base) {
|
|
7
|
+
const parsed = new URL(value, base)
|
|
8
|
+
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
|
9
|
+
throw new Error('download requires an HTTP or HTTPS URL')
|
|
10
|
+
}
|
|
11
|
+
return parsed.href
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Compatibility boundary for the pinned SDK 2.1.11: its response callback can
|
|
15
|
+
// throw outside start()'s promise, and resolves relative redirects against the
|
|
16
|
+
// initial URL. Wrap that callback, not the transport or global HTTP modules.
|
|
17
|
+
// Re-run the redirect regressions when upgrading the SDK's private hook.
|
|
18
|
+
class ArchiveDownloader extends DownloaderHelper {
|
|
19
|
+
__downloadRequest(resolve, reject) {
|
|
20
|
+
const requestUrl = this.requestURL
|
|
21
|
+
const request = super.__downloadRequest(resolve, reject)
|
|
22
|
+
const [onResponse] = request.listeners('response')
|
|
23
|
+
request.removeListener('response', onResponse)
|
|
24
|
+
request.once('response', (response) => {
|
|
25
|
+
try {
|
|
26
|
+
if (response.statusCode >= 300 && response.statusCode < 400) {
|
|
27
|
+
if (![301, 302, 303, 307, 308].includes(response.statusCode) || !response.headers.location) {
|
|
28
|
+
throw new Error(`download returned HTTP ${response.statusCode} without a usable redirect`)
|
|
29
|
+
}
|
|
30
|
+
response.headers.location = httpUrl(response.headers.location, requestUrl)
|
|
31
|
+
}
|
|
32
|
+
onResponse.call(request, response)
|
|
33
|
+
} catch (error) {
|
|
34
|
+
response.destroy()
|
|
35
|
+
request.destroy()
|
|
36
|
+
this.emit('error', error)
|
|
37
|
+
reject(error)
|
|
38
|
+
}
|
|
39
|
+
})
|
|
40
|
+
return request
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Download into a caller-owned staging directory, without a total-time limit. */
|
|
45
|
+
export async function downloadFile(url, destination, options = {}) {
|
|
46
|
+
const label = options.label ?? 'binary download'
|
|
47
|
+
const cancelled = () => Object.assign(new Error(`${label} cancelled`), { name: 'AbortError' })
|
|
48
|
+
if (options.signal?.aborted) throw cancelled()
|
|
49
|
+
url = httpUrl(url)
|
|
50
|
+
const filePath = path.resolve(destination)
|
|
51
|
+
// This adapter owns only a new staging file, never a pre-existing user file.
|
|
52
|
+
try {
|
|
53
|
+
lstatSync(filePath)
|
|
54
|
+
throw new Error(`download destination already exists: ${filePath}`)
|
|
55
|
+
} catch (error) {
|
|
56
|
+
if (error.code !== 'ENOENT') throw error
|
|
57
|
+
}
|
|
58
|
+
const connectTimeoutMs = options.connectTimeoutMs ?? 30_000
|
|
59
|
+
const idleTimeoutMs = options.idleTimeoutMs ?? 60_000
|
|
60
|
+
const maxBytes = options.maxBytes ?? 512 * 1024 * 1024
|
|
61
|
+
const request = new AbortController()
|
|
62
|
+
const downloader = new ArchiveDownloader(url, path.dirname(filePath), {
|
|
63
|
+
fileName: path.basename(filePath),
|
|
64
|
+
override: true,
|
|
65
|
+
// The panel owns retry. SDK retry/resume can otherwise outlive cancellation.
|
|
66
|
+
retry: false,
|
|
67
|
+
resumeOnIncomplete: false,
|
|
68
|
+
resumeIfFileExists: false,
|
|
69
|
+
forceResume: false,
|
|
70
|
+
removeOnStop: false,
|
|
71
|
+
removeOnFail: false,
|
|
72
|
+
httpRequestOptions: { signal: request.signal },
|
|
73
|
+
httpsRequestOptions: { signal: request.signal },
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
return new Promise((resolve, reject) => {
|
|
77
|
+
let state = 'running'
|
|
78
|
+
let phase = 'connecting'
|
|
79
|
+
let receivedBytes = 0
|
|
80
|
+
let totalBytes
|
|
81
|
+
let timer
|
|
82
|
+
const cleanListeners = () => {
|
|
83
|
+
clearTimeout(timer)
|
|
84
|
+
options.signal?.removeEventListener('abort', onAbort)
|
|
85
|
+
}
|
|
86
|
+
const fail = (error) => {
|
|
87
|
+
if (state !== 'running') return
|
|
88
|
+
state = 'stopping'
|
|
89
|
+
cleanListeners()
|
|
90
|
+
request.abort(error)
|
|
91
|
+
// SDK emits "download" before finishing stream setup. Let that setup
|
|
92
|
+
// finish, then await closed handles before removing a partial file (Windows).
|
|
93
|
+
void Promise.resolve().then(async () => {
|
|
94
|
+
try {
|
|
95
|
+
await downloader.stop()
|
|
96
|
+
await rm(filePath, { force: true })
|
|
97
|
+
} catch (cleanupError) {
|
|
98
|
+
reject(new Error(`${error.message}; download cleanup failed: ${cleanupError.message}`, { cause: error }))
|
|
99
|
+
return
|
|
100
|
+
}
|
|
101
|
+
reject(error)
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
const onAbort = () => fail(cancelled())
|
|
105
|
+
const resetTimer = () => {
|
|
106
|
+
clearTimeout(timer)
|
|
107
|
+
if (state !== 'running') return
|
|
108
|
+
const duration = phase === 'connecting' ? connectTimeoutMs : idleTimeoutMs
|
|
109
|
+
timer = setTimeout(() => fail(new Error(
|
|
110
|
+
`${label} timed out (${phase} for ${duration}ms; received ${receivedBytes} bytes)`,
|
|
111
|
+
)), duration)
|
|
112
|
+
}
|
|
113
|
+
const progress = (detail) => {
|
|
114
|
+
if (state !== 'running') return
|
|
115
|
+
try {
|
|
116
|
+
options.onProgress?.({ phase: 'download', receivedBytes,
|
|
117
|
+
...(totalBytes === undefined ? {} : { totalBytes }),
|
|
118
|
+
...(detail === undefined ? {} : { detail }) })
|
|
119
|
+
} catch (error) { fail(error) }
|
|
120
|
+
}
|
|
121
|
+
const tooLarge = () => fail(new Error(`${label} is larger than ${maxBytes} bytes`))
|
|
122
|
+
downloader.on('download', (info) => {
|
|
123
|
+
if (state !== 'running') return
|
|
124
|
+
if (info.totalSize > maxBytes) { tooLarge(); return }
|
|
125
|
+
totalBytes = info.totalSize > 0 ? info.totalSize : undefined
|
|
126
|
+
phase = 'idle'
|
|
127
|
+
resetTimer()
|
|
128
|
+
progress('Connected; receiving archive data…')
|
|
129
|
+
})
|
|
130
|
+
downloader.on('progress', (info) => {
|
|
131
|
+
if (state !== 'running') return
|
|
132
|
+
if (info.downloaded > receivedBytes) {
|
|
133
|
+
receivedBytes = info.downloaded
|
|
134
|
+
resetTimer()
|
|
135
|
+
}
|
|
136
|
+
if (receivedBytes > maxBytes) { tooLarge(); return }
|
|
137
|
+
progress()
|
|
138
|
+
})
|
|
139
|
+
downloader.on('end', (info) => {
|
|
140
|
+
if (state !== 'running') return
|
|
141
|
+
try {
|
|
142
|
+
if (info.incomplete || path.resolve(info.filePath) !== filePath) {
|
|
143
|
+
throw new Error(`${label} is incomplete or has an unexpected destination`)
|
|
144
|
+
}
|
|
145
|
+
const size = statSync(filePath).size
|
|
146
|
+
if (size > maxBytes) { tooLarge(); return }
|
|
147
|
+
if (totalBytes !== undefined && size !== totalBytes) {
|
|
148
|
+
throw new Error(`${label} is incomplete: expected ${totalBytes} bytes, received ${size}`)
|
|
149
|
+
}
|
|
150
|
+
state = 'complete'
|
|
151
|
+
cleanListeners()
|
|
152
|
+
resolve({ filePath, receivedBytes: size })
|
|
153
|
+
} catch (error) { fail(error) }
|
|
154
|
+
})
|
|
155
|
+
// Keep an error listener through stop/cleanup; native abort can emit late.
|
|
156
|
+
downloader.on('error', (error) => fail(new Error(`${label} failed: ${error.message}`, { cause: error })))
|
|
157
|
+
downloader.on('stop', () => fail(new Error(`${label} stopped before completion`)))
|
|
158
|
+
options.signal?.addEventListener('abort', onAbort, { once: true })
|
|
159
|
+
resetTimer()
|
|
160
|
+
progress('Connecting to download server…')
|
|
161
|
+
if (state !== 'running') return
|
|
162
|
+
// start() resolves true on STOP as well as completion. Only "end" is success.
|
|
163
|
+
downloader.start().catch((error) => fail(new Error(`${label} failed: ${error.message}`, { cause: error })))
|
|
164
|
+
})
|
|
165
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/** Run filesystem probes off the Client event loop; never launch an agent. */
|
|
2
|
+
import { Worker, isMainThread, parentPort, workerData } from 'node:worker_threads'
|
|
3
|
+
import { discoverHarnessCandidates } from './harnesses.js'
|
|
4
|
+
|
|
5
|
+
export async function scanHarnessCandidates(settingsPath, options = {}, query = '') {
|
|
6
|
+
const { signal } = options
|
|
7
|
+
signal?.throwIfAborted()
|
|
8
|
+
// Pass only discovery data, not transport callbacks or unrelated plugin options.
|
|
9
|
+
const scoped = Object.fromEntries([
|
|
10
|
+
'registry', 'defaults', 'pathValue', 'pathExt', 'platform', 'arch', 'installRoot',
|
|
11
|
+
].filter((key) => options[key] !== undefined).map((key) => [key, options[key]]))
|
|
12
|
+
scoped.settingsPath = settingsPath
|
|
13
|
+
const worker = new Worker(new URL(import.meta.url), {
|
|
14
|
+
workerData: { harnessDiscovery: true, settingsPath, options: scoped, query },
|
|
15
|
+
// No CLI/test-runner flags apply to this plain filesystem worker.
|
|
16
|
+
execArgv: [],
|
|
17
|
+
})
|
|
18
|
+
return new Promise((resolve, reject) => {
|
|
19
|
+
let settled = false
|
|
20
|
+
const finish = (error, entries) => {
|
|
21
|
+
if (settled) return
|
|
22
|
+
settled = true
|
|
23
|
+
signal?.removeEventListener('abort', cancel)
|
|
24
|
+
void worker.terminate()
|
|
25
|
+
if (error) reject(error)
|
|
26
|
+
else resolve(entries)
|
|
27
|
+
}
|
|
28
|
+
const cancel = () => finish(signal.reason)
|
|
29
|
+
signal?.addEventListener('abort', cancel, { once: true })
|
|
30
|
+
worker.once('message', (entries) => finish(undefined, entries))
|
|
31
|
+
worker.once('error', (error) => finish(error))
|
|
32
|
+
worker.once('exit', (code) => {
|
|
33
|
+
if (!settled) finish(new Error(`Harness discovery worker exited before returning results (${code})`))
|
|
34
|
+
})
|
|
35
|
+
if (signal?.aborted) cancel()
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (!isMainThread && workerData?.harnessDiscovery === true) {
|
|
40
|
+
parentPort.postMessage(discoverHarnessCandidates(workerData.settingsPath, workerData.options, workerData.query))
|
|
41
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { StringDecoder } from 'node:string_decoder'
|
|
2
|
+
import spawn from 'cross-spawn'
|
|
3
|
+
|
|
4
|
+
const DEFAULT_TIMEOUT_MS = 300_000
|
|
5
|
+
const DETAIL_LIMIT = 240
|
|
6
|
+
const OUTPUT_LIMIT = 8192
|
|
7
|
+
|
|
8
|
+
// Keep parser state across chunks: an OSC clipboard/title payload or split CSI
|
|
9
|
+
// must never become visible text in a progress panel. Partial lines stay bounded.
|
|
10
|
+
function outputLines(onLine) {
|
|
11
|
+
const decoder = new StringDecoder('utf8')
|
|
12
|
+
let state = 'text', line = ''
|
|
13
|
+
const flush = () => {
|
|
14
|
+
const text = line.trim()
|
|
15
|
+
line = ''
|
|
16
|
+
if (text) onLine(text)
|
|
17
|
+
}
|
|
18
|
+
const consume = (text) => {
|
|
19
|
+
for (const char of text) {
|
|
20
|
+
const code = char.codePointAt(0)
|
|
21
|
+
if (state === 'escape') {
|
|
22
|
+
state = char === '[' ? 'csi' : char === ']' ? 'osc'
|
|
23
|
+
: ['P', 'X', '^', '_'].includes(char) ? 'string'
|
|
24
|
+
: code >= 0x20 && code <= 0x2f ? 'intermediate' : 'text'
|
|
25
|
+
} else if (state === 'intermediate') {
|
|
26
|
+
if (code >= 0x30 && code <= 0x7e) state = 'text'
|
|
27
|
+
} else if (state === 'csi') {
|
|
28
|
+
if (code >= 0x40 && code <= 0x7e) state = 'text'
|
|
29
|
+
else if (code === 0x1b) state = 'escape'
|
|
30
|
+
} else if (state === 'osc' || state === 'string') {
|
|
31
|
+
if (code === 0x07 || code === 0x9c) state = 'text'
|
|
32
|
+
else if (code === 0x1b) state = 'stringEscape'
|
|
33
|
+
} else if (state === 'stringEscape') {
|
|
34
|
+
state = char === '\\' ? 'text' : code === 0x1b ? 'stringEscape' : 'string'
|
|
35
|
+
} else if (code === 0x1b) state = 'escape'
|
|
36
|
+
else if (code === 0x9b) state = 'csi'
|
|
37
|
+
else if (code === 0x9d) state = 'osc'
|
|
38
|
+
else if ([0x90, 0x98, 0x9e, 0x9f].includes(code)) state = 'string'
|
|
39
|
+
else if (char === '\n' || char === '\r') flush()
|
|
40
|
+
else if (char === '\t') { if (line.length < OUTPUT_LIMIT) line += ' ' }
|
|
41
|
+
else if (code >= 0x20 && !(code >= 0x7f && code <= 0x9f)
|
|
42
|
+
&& !/[\u061c\u200e\u200f\u2028-\u202e\u2066-\u2069]/u.test(char)
|
|
43
|
+
&& line.length < OUTPUT_LIMIT) line += char
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
write(chunk) { consume(decoder.write(chunk)) },
|
|
48
|
+
end() { consume(decoder.end()); flush() },
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function safeText(value, limit = DETAIL_LIMIT) {
|
|
53
|
+
const lines = []
|
|
54
|
+
const output = outputLines((line) => lines.push(line))
|
|
55
|
+
output.write(Buffer.from(String(value ?? '').slice(0, OUTPUT_LIMIT)))
|
|
56
|
+
output.end()
|
|
57
|
+
return lines.join(' ').slice(0, limit)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function cancellationError() {
|
|
61
|
+
const error = new Error('Harness package preparation cancelled')
|
|
62
|
+
error.name = 'AbortError'
|
|
63
|
+
return error
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function signalProcessTree(child, signal) {
|
|
67
|
+
if (!child.pid) return
|
|
68
|
+
try { process.kill(-child.pid, signal) }
|
|
69
|
+
catch (error) {
|
|
70
|
+
if (error.code !== 'ESRCH') {
|
|
71
|
+
try { child.kill(signal) } catch { /* The process may already have exited. */ }
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function stopProcessTree(child) {
|
|
77
|
+
if (!child.pid) return
|
|
78
|
+
if (process.platform === 'win32') {
|
|
79
|
+
// Killing cmd.exe alone leaves npm/uv and their children running. taskkill
|
|
80
|
+
// accepts the numeric PID directly; no shell or user-controlled arguments.
|
|
81
|
+
await new Promise((resolve) => {
|
|
82
|
+
let killer
|
|
83
|
+
try {
|
|
84
|
+
killer = spawn('taskkill.exe', ['/PID', String(child.pid), '/T', '/F'], {
|
|
85
|
+
stdio: 'ignore', windowsHide: true,
|
|
86
|
+
})
|
|
87
|
+
} catch { resolve(); return }
|
|
88
|
+
const timer = setTimeout(() => { killer.kill(); resolve() }, 2000)
|
|
89
|
+
const done = () => { clearTimeout(timer); resolve() }
|
|
90
|
+
killer.once('error', done)
|
|
91
|
+
killer.once('close', done)
|
|
92
|
+
})
|
|
93
|
+
try { child.kill() } catch { /* Already reaped by taskkill. */ }
|
|
94
|
+
return
|
|
95
|
+
}
|
|
96
|
+
signalProcessTree(child, 'SIGTERM')
|
|
97
|
+
// Always finish killing the owned group, even when its leader exits first and
|
|
98
|
+
// descendants have closed their pipes. Do not return while installers survive.
|
|
99
|
+
await new Promise((resolve) => setTimeout(resolve, 200))
|
|
100
|
+
signalProcessTree(child, 'SIGKILL')
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Populate the package runner's cache without invoking the ACP entry point.
|
|
105
|
+
* npm 7+ hashes --package specs exactly as normal npx invocations; stdin is not
|
|
106
|
+
* a TTY, so first install needs no --yes flag. uvx --from builds the same package
|
|
107
|
+
* environment before running Python (including package@version / @latest).
|
|
108
|
+
* This never changes the saved recipe, selects a Harness, or inherits the TTY.
|
|
109
|
+
*/
|
|
110
|
+
export async function prepareHarnessPackage(entry, options = {}) {
|
|
111
|
+
const distribution = entry?.distribution
|
|
112
|
+
const spec = distribution?.args?.[0]
|
|
113
|
+
if (!['npx', 'uvx'].includes(distribution?.type) || typeof spec !== 'string'
|
|
114
|
+
|| !spec.trim() || spec.startsWith('-') || /[\x00-\x1f\x7f]/.test(spec)) {
|
|
115
|
+
throw new Error('Harness package recipe must specify an npx or uvx package')
|
|
116
|
+
}
|
|
117
|
+
if (options.signal?.aborted) throw cancellationError()
|
|
118
|
+
const runner = entry.runner ?? distribution.command ?? distribution.type
|
|
119
|
+
const args = distribution.type === 'npx'
|
|
120
|
+
? ['--package', spec, '--', process.execPath, '-e', '']
|
|
121
|
+
: ['--from', spec, '--', 'python', '-c', '']
|
|
122
|
+
const progress = (phase, detail) => options.onProgress?.({ phase, detail: safeText(detail) })
|
|
123
|
+
progress('preparing', `Preparing ${entry.label ?? entry.id ?? spec}`)
|
|
124
|
+
if (options.signal?.aborted) throw cancellationError()
|
|
125
|
+
|
|
126
|
+
await new Promise((resolve, reject) => {
|
|
127
|
+
let child, timer, failure, stopping, closed = false, settled = false
|
|
128
|
+
const output = { stdout: '', stderr: '' }
|
|
129
|
+
const cleanup = () => {
|
|
130
|
+
clearTimeout(timer)
|
|
131
|
+
options.signal?.removeEventListener('abort', abort)
|
|
132
|
+
}
|
|
133
|
+
const finish = () => {
|
|
134
|
+
if (!closed || stopping || settled) return
|
|
135
|
+
settled = true
|
|
136
|
+
cleanup()
|
|
137
|
+
if (failure) reject(failure)
|
|
138
|
+
else resolve()
|
|
139
|
+
}
|
|
140
|
+
const stop = (error) => {
|
|
141
|
+
if (settled || failure) return
|
|
142
|
+
failure = error
|
|
143
|
+
clearTimeout(timer)
|
|
144
|
+
stopping = stopProcessTree(child).finally(() => { stopping = undefined; finish() })
|
|
145
|
+
}
|
|
146
|
+
const abort = () => stop(cancellationError())
|
|
147
|
+
const collect = (stream, line) => {
|
|
148
|
+
output[stream] = `${output[stream]}${line}\n`.slice(-OUTPUT_LIMIT / 2)
|
|
149
|
+
if (failure) return
|
|
150
|
+
try { progress('download', line) }
|
|
151
|
+
catch (error) { stop(new Error(safeText(error?.message ?? error, OUTPUT_LIMIT))) }
|
|
152
|
+
}
|
|
153
|
+
const stdout = outputLines((line) => collect('stdout', line))
|
|
154
|
+
const stderr = outputLines((line) => collect('stderr', line))
|
|
155
|
+
try {
|
|
156
|
+
child = (options.spawnImpl ?? spawn)(runner, args, {
|
|
157
|
+
cwd: options.cwd,
|
|
158
|
+
env: { ...process.env, ...distribution.env, ...entry.env, ...options.env },
|
|
159
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
160
|
+
windowsHide: true,
|
|
161
|
+
detached: process.platform !== 'win32',
|
|
162
|
+
})
|
|
163
|
+
} catch (error) {
|
|
164
|
+
reject(new Error(`Could not start package runner: ${safeText(error?.message ?? error)}`))
|
|
165
|
+
return
|
|
166
|
+
}
|
|
167
|
+
child.stdout?.on('data', (chunk) => stdout.write(chunk))
|
|
168
|
+
child.stderr?.on('data', (chunk) => stderr.write(chunk))
|
|
169
|
+
child.stdout?.on('error', (error) => stop(new Error(safeText(error.message))))
|
|
170
|
+
child.stderr?.on('error', (error) => stop(new Error(safeText(error.message))))
|
|
171
|
+
child.once('error', (error) => {
|
|
172
|
+
stop(new Error(`Could not start package runner: ${safeText(error.message)}`))
|
|
173
|
+
})
|
|
174
|
+
child.once('close', (code, signal) => {
|
|
175
|
+
stdout.end(); stderr.end()
|
|
176
|
+
closed = true
|
|
177
|
+
if (!failure && code !== 0) {
|
|
178
|
+
const tail = `${output.stdout}${output.stderr}`.trim()
|
|
179
|
+
stop(new Error(`Harness package preparation ${signal ? `stopped by ${safeText(signal)}` : `exited with code ${code}`}${tail ? `\n${tail}` : ''}`))
|
|
180
|
+
}
|
|
181
|
+
if (!stopping && child.pid && process.platform !== 'win32') {
|
|
182
|
+
// An installer may spawn a worker with ignored stdio then exit first.
|
|
183
|
+
// A closed leader does not imply that its owned process group is empty.
|
|
184
|
+
try {
|
|
185
|
+
process.kill(-child.pid, 0)
|
|
186
|
+
stopping = stopProcessTree(child).finally(() => { stopping = undefined; finish() })
|
|
187
|
+
} catch { /* The owned process group has already exited. */ }
|
|
188
|
+
}
|
|
189
|
+
finish()
|
|
190
|
+
})
|
|
191
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS
|
|
192
|
+
timer = setTimeout(() => stop(new Error('Harness package preparation timed out')), timeoutMs)
|
|
193
|
+
options.signal?.addEventListener('abort', abort, { once: true })
|
|
194
|
+
// Cover cancellation from a synchronous spawn hook before the listener.
|
|
195
|
+
if (options.signal?.aborted) abort()
|
|
196
|
+
})
|
|
197
|
+
if (options.signal?.aborted) throw cancellationError()
|
|
198
|
+
progress('complete', 'Package ready')
|
|
199
|
+
return entry
|
|
200
|
+
}
|