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.
@@ -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
+ }