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
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
/** ACP Registry loading, normalization, and managed binary installation. */
|
|
2
|
+
|
|
3
|
+
import { createHash, randomUUID } from 'node:crypto'
|
|
4
|
+
import {
|
|
5
|
+
chmodSync,
|
|
6
|
+
createReadStream,
|
|
7
|
+
existsSync,
|
|
8
|
+
mkdirSync,
|
|
9
|
+
mkdtempSync,
|
|
10
|
+
renameSync,
|
|
11
|
+
rmSync,
|
|
12
|
+
statSync,
|
|
13
|
+
readFileSync,
|
|
14
|
+
writeFileSync,
|
|
15
|
+
} from 'node:fs'
|
|
16
|
+
import path from 'node:path'
|
|
17
|
+
import { spawn } from 'node:child_process'
|
|
18
|
+
import { downloadFile } from './download.js'
|
|
19
|
+
|
|
20
|
+
export const ACP_REGISTRY_URL = 'https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json'
|
|
21
|
+
|
|
22
|
+
function registryCachePath(options) {
|
|
23
|
+
return typeof options.settingsPath === 'string'
|
|
24
|
+
? path.join(path.dirname(options.settingsPath), 'cache', 'acp-registry.json') : undefined
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Last validated catalog, or the official bundled snapshot on first/offline launch. */
|
|
28
|
+
export function readAcpRegistrySnapshot(options = {}) {
|
|
29
|
+
const cachePath = registryCachePath(options)
|
|
30
|
+
if (cachePath !== undefined) {
|
|
31
|
+
try {
|
|
32
|
+
if (statSync(cachePath).size > 16 * 1024 * 1024) throw new Error('oversized cache')
|
|
33
|
+
const cached = JSON.parse(readFileSync(cachePath, 'utf8'))
|
|
34
|
+
if (cached.source === (options.registryUrl ?? ACP_REGISTRY_URL) && Array.isArray(cached.catalog?.agents)) {
|
|
35
|
+
return normalizeAcpRegistry(cached.catalog, options)
|
|
36
|
+
}
|
|
37
|
+
} catch { /* Missing/corrupt cache cannot block the bundled catalog. */ }
|
|
38
|
+
}
|
|
39
|
+
return normalizeAcpRegistry(JSON.parse(readFileSync(new URL('./acp-registry.snapshot.json', import.meta.url), 'utf8')), options)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function cacheRegistry(value, options) {
|
|
43
|
+
const cachePath = registryCachePath(options)
|
|
44
|
+
if (cachePath === undefined) return
|
|
45
|
+
const temporary = `${cachePath}.${randomUUID()}.tmp`
|
|
46
|
+
try {
|
|
47
|
+
mkdirSync(path.dirname(cachePath), { recursive: true })
|
|
48
|
+
writeFileSync(temporary, JSON.stringify({ source: options.registryUrl ?? ACP_REGISTRY_URL, catalog: value }))
|
|
49
|
+
renameSync(temporary, cachePath)
|
|
50
|
+
} catch { /* Read-only storage must not discard a successful network result. */ }
|
|
51
|
+
finally { try { rmSync(temporary, { force: true }) } catch { /* best effort */ } }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const MAX_ARCHIVE_BYTES = 512 * 1024 * 1024
|
|
55
|
+
const TARGETS = Object.freeze({
|
|
56
|
+
'darwin-arm64': 'darwin-aarch64',
|
|
57
|
+
'darwin-x64': 'darwin-x86_64',
|
|
58
|
+
'linux-arm64': 'linux-aarch64',
|
|
59
|
+
'linux-x64': 'linux-x86_64',
|
|
60
|
+
'win32-arm64': 'windows-aarch64',
|
|
61
|
+
'win32-x64': 'windows-x86_64',
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
function stringArray(value) {
|
|
65
|
+
return Array.isArray(value) && value.every((item) => typeof item === 'string')
|
|
66
|
+
? [...value]
|
|
67
|
+
: []
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function stringEnvironment(value) {
|
|
71
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) return {}
|
|
72
|
+
const entries = Object.entries(value)
|
|
73
|
+
.filter(([key, item]) => key.length > 0 && typeof item === 'string')
|
|
74
|
+
return Object.fromEntries(entries)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function registryPlatformKey(platform = process.platform, arch = process.arch) {
|
|
78
|
+
return TARGETS[`${platform}-${arch}`]
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Convert the public ACP Registry schema into Martty launch distributions.
|
|
83
|
+
* Unsupported platform-specific binaries are omitted; package distributions
|
|
84
|
+
* remain available on every platform.
|
|
85
|
+
*/
|
|
86
|
+
export function normalizeAcpRegistry(value, options = {}) {
|
|
87
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) return []
|
|
88
|
+
if (!Array.isArray(value.agents)) return []
|
|
89
|
+
const target = registryPlatformKey(options.platform, options.arch)
|
|
90
|
+
return value.agents.flatMap((agent) => {
|
|
91
|
+
if (agent === null || typeof agent !== 'object' || Array.isArray(agent)) return []
|
|
92
|
+
if (typeof agent.id !== 'string' || !/^[a-z][a-z0-9-]*$/.test(agent.id)) return []
|
|
93
|
+
if (typeof agent.name !== 'string' || agent.name.trim().length === 0) return []
|
|
94
|
+
if (typeof agent.version !== 'string' || agent.version.trim().length === 0) return []
|
|
95
|
+
const distribution = agent.distribution
|
|
96
|
+
if (distribution === null || typeof distribution !== 'object' || Array.isArray(distribution)) {
|
|
97
|
+
return []
|
|
98
|
+
}
|
|
99
|
+
const distributions = []
|
|
100
|
+
for (const type of ['npx', 'uvx']) {
|
|
101
|
+
const spec = distribution[type]
|
|
102
|
+
if (spec === null || typeof spec !== 'object' || Array.isArray(spec)) continue
|
|
103
|
+
if (typeof spec.package !== 'string' || spec.package.trim().length === 0) continue
|
|
104
|
+
distributions.push({
|
|
105
|
+
type,
|
|
106
|
+
command: type,
|
|
107
|
+
args: [spec.package, ...stringArray(spec.args)],
|
|
108
|
+
env: stringEnvironment(spec.env),
|
|
109
|
+
})
|
|
110
|
+
}
|
|
111
|
+
const binary = target === undefined ? undefined : distribution.binary?.[target]
|
|
112
|
+
if (binary !== null && typeof binary === 'object' && !Array.isArray(binary)
|
|
113
|
+
&& typeof binary.archive === 'string' && binary.archive.length > 0
|
|
114
|
+
&& typeof binary.cmd === 'string' && binary.cmd.length > 0) {
|
|
115
|
+
distributions.push({
|
|
116
|
+
type: 'binary',
|
|
117
|
+
target,
|
|
118
|
+
command: binary.cmd,
|
|
119
|
+
args: stringArray(binary.args),
|
|
120
|
+
env: stringEnvironment(binary.env),
|
|
121
|
+
archive: binary.archive,
|
|
122
|
+
...(typeof binary.sha256 === 'string' && binary.sha256.length > 0
|
|
123
|
+
? { sha256: binary.sha256.toLowerCase() }
|
|
124
|
+
: {}),
|
|
125
|
+
})
|
|
126
|
+
}
|
|
127
|
+
if (distributions.length === 0) return []
|
|
128
|
+
return [{
|
|
129
|
+
id: agent.id,
|
|
130
|
+
label: agent.name,
|
|
131
|
+
version: agent.version,
|
|
132
|
+
description: typeof agent.description === 'string' ? agent.description : '',
|
|
133
|
+
distributions,
|
|
134
|
+
}]
|
|
135
|
+
})
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function timeoutSignal(timeoutMs, externalSignal, operation) {
|
|
139
|
+
const controller = new AbortController()
|
|
140
|
+
const cancel = () => {
|
|
141
|
+
const error = new Error(`${operation} cancelled`)
|
|
142
|
+
error.name = 'AbortError'
|
|
143
|
+
controller.abort(error)
|
|
144
|
+
}
|
|
145
|
+
if (externalSignal?.aborted) cancel()
|
|
146
|
+
else externalSignal?.addEventListener('abort', cancel, { once: true })
|
|
147
|
+
let timer
|
|
148
|
+
const reset = (duration = timeoutMs) => {
|
|
149
|
+
clearTimeout(timer)
|
|
150
|
+
if (duration !== undefined && !controller.signal.aborted) {
|
|
151
|
+
timer = setTimeout(() => controller.abort(new Error(`${operation} timed out`)), duration)
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
reset()
|
|
155
|
+
return {
|
|
156
|
+
signal: controller.signal,
|
|
157
|
+
reset,
|
|
158
|
+
cancel() {
|
|
159
|
+
clearTimeout(timer)
|
|
160
|
+
externalSignal?.removeEventListener('abort', cancel)
|
|
161
|
+
},
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function abortable(promise, signal) {
|
|
166
|
+
if (signal.aborted) {
|
|
167
|
+
// The operation may synchronously abort its owner and return an already
|
|
168
|
+
// rejected promise. Consume it even though cancellation wins the race.
|
|
169
|
+
Promise.resolve(promise).catch(() => {})
|
|
170
|
+
return Promise.reject(signal.reason)
|
|
171
|
+
}
|
|
172
|
+
return new Promise((resolve, reject) => {
|
|
173
|
+
const onAbort = () => {
|
|
174
|
+
signal.removeEventListener('abort', onAbort)
|
|
175
|
+
reject(signal.reason)
|
|
176
|
+
}
|
|
177
|
+
signal.addEventListener('abort', onAbort, { once: true })
|
|
178
|
+
Promise.resolve(promise).then(resolve, reject).finally(() => {
|
|
179
|
+
signal.removeEventListener('abort', onAbort)
|
|
180
|
+
})
|
|
181
|
+
})
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function* responseChunks(response, signal) {
|
|
185
|
+
if (typeof response.body?.getReader !== 'function') {
|
|
186
|
+
signal.throwIfAborted()
|
|
187
|
+
yield Buffer.from(await abortable(response.arrayBuffer(), signal))
|
|
188
|
+
return
|
|
189
|
+
}
|
|
190
|
+
const reader = response.body.getReader()
|
|
191
|
+
try {
|
|
192
|
+
for (;;) {
|
|
193
|
+
signal.throwIfAborted()
|
|
194
|
+
const { done, value } = await abortable(reader.read(), signal)
|
|
195
|
+
if (done) return
|
|
196
|
+
yield Buffer.from(value)
|
|
197
|
+
}
|
|
198
|
+
} finally {
|
|
199
|
+
// Cancel the body as well as the request: injected fetch implementations may
|
|
200
|
+
// return a Response whose stream is not connected to the request signal.
|
|
201
|
+
await reader.cancel().catch(() => {})
|
|
202
|
+
reader.releaseLock()
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export async function fetchAcpRegistry(options = {}) {
|
|
207
|
+
const fetchImpl = options.fetchImpl ?? globalThis.fetch
|
|
208
|
+
if (typeof fetchImpl !== 'function') throw new Error('ACP Registry requires fetch support')
|
|
209
|
+
const timeout = timeoutSignal(options.timeoutMs ?? 8_000, options.signal, 'ACP Registry request')
|
|
210
|
+
try {
|
|
211
|
+
timeout.signal.throwIfAborted()
|
|
212
|
+
const response = await abortable(fetchImpl(options.registryUrl ?? ACP_REGISTRY_URL, {
|
|
213
|
+
headers: { accept: 'application/json' },
|
|
214
|
+
signal: timeout.signal,
|
|
215
|
+
}), timeout.signal)
|
|
216
|
+
if (response?.ok !== true) {
|
|
217
|
+
throw new Error(`ACP Registry returned HTTP ${response?.status ?? 'error'}`)
|
|
218
|
+
}
|
|
219
|
+
let value
|
|
220
|
+
try {
|
|
221
|
+
if (typeof response.body?.getReader === 'function') {
|
|
222
|
+
const chunks = []
|
|
223
|
+
let size = 0
|
|
224
|
+
for await (const chunk of responseChunks(response, timeout.signal)) {
|
|
225
|
+
size += chunk.length
|
|
226
|
+
if (size > 16 * 1024 * 1024) throw new Error('catalog is larger than 16 MiB')
|
|
227
|
+
chunks.push(chunk)
|
|
228
|
+
}
|
|
229
|
+
value = JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
|
230
|
+
} else {
|
|
231
|
+
value = await abortable(response.json(), timeout.signal)
|
|
232
|
+
}
|
|
233
|
+
} catch (error) {
|
|
234
|
+
throw new Error(`ACP Registry returned invalid JSON: ${error instanceof Error ? error.message : String(error)}`)
|
|
235
|
+
}
|
|
236
|
+
if (!Array.isArray(value?.agents)) throw new Error('ACP Registry returned an invalid catalog')
|
|
237
|
+
const records = normalizeAcpRegistry(value, options)
|
|
238
|
+
if (records.length === 0 && Array.isArray(value?.agents) && value.agents.length > 0) {
|
|
239
|
+
throw new Error('ACP Registry has no distributions for this platform')
|
|
240
|
+
}
|
|
241
|
+
cacheRegistry(value, options)
|
|
242
|
+
return records
|
|
243
|
+
} catch (error) {
|
|
244
|
+
if (timeout.signal.aborted) throw timeout.signal.reason
|
|
245
|
+
if (error instanceof Error && error.message.startsWith('ACP Registry')) throw error
|
|
246
|
+
throw new Error(`could not fetch ACP Registry: ${error instanceof Error ? error.message : String(error)}`)
|
|
247
|
+
} finally {
|
|
248
|
+
timeout.cancel()
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function safeComponent(value, label) {
|
|
253
|
+
if (typeof value !== 'string' || value.length === 0 || value === '.' || value === '..'
|
|
254
|
+
|| value.includes('/') || value.includes('\\')) {
|
|
255
|
+
throw new Error(`invalid ${label} in ACP Registry`)
|
|
256
|
+
}
|
|
257
|
+
return value.replace(/[^a-zA-Z0-9._+-]/g, '-')
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function relativeCommand(value) {
|
|
261
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
262
|
+
throw new Error('binary distribution has no command')
|
|
263
|
+
}
|
|
264
|
+
const normalized = value.replaceAll('\\', '/').replace(/^\.\//, '')
|
|
265
|
+
if (path.posix.isAbsolute(normalized)
|
|
266
|
+
|| normalized.split('/').some((part) => part === '..' || part.length === 0)) {
|
|
267
|
+
throw new Error('binary command must stay inside the installed archive')
|
|
268
|
+
}
|
|
269
|
+
return normalized.split('/')
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function containedPath(root, parts) {
|
|
273
|
+
const resolved = path.resolve(root, ...parts)
|
|
274
|
+
const prefix = `${path.resolve(root)}${path.sep}`
|
|
275
|
+
if (!resolved.startsWith(prefix)) throw new Error('binary command escapes the install directory')
|
|
276
|
+
return resolved
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export function managedBinaryPath(entry, options = {}) {
|
|
280
|
+
const distribution = entry?.distribution
|
|
281
|
+
if (distribution?.type !== 'binary') return undefined
|
|
282
|
+
if (typeof options.settingsPath !== 'string' || options.settingsPath.length === 0) return undefined
|
|
283
|
+
const id = safeComponent(entry.id, 'agent id')
|
|
284
|
+
const version = safeComponent(entry.version, 'agent version')
|
|
285
|
+
const target = safeComponent(distribution.target, 'binary target')
|
|
286
|
+
const commandParts = relativeCommand(distribution.command)
|
|
287
|
+
const installRoot = options.installRoot ?? path.join(path.dirname(options.settingsPath), 'bin')
|
|
288
|
+
return containedPath(path.join(installRoot, id, version, target), commandParts)
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function archiveSuffix(url) {
|
|
292
|
+
let pathname = ''
|
|
293
|
+
try {
|
|
294
|
+
pathname = new URL(url).pathname.toLowerCase()
|
|
295
|
+
} catch {
|
|
296
|
+
throw new Error('binary distribution has an invalid archive URL')
|
|
297
|
+
}
|
|
298
|
+
for (const suffix of ['.tar.gz', '.tar.bz2', '.tar.xz', '.tar.zst', '.tgz', '.zip', '.tar']) {
|
|
299
|
+
if (pathname.endsWith(suffix)) return suffix
|
|
300
|
+
}
|
|
301
|
+
throw new Error('binary distribution uses an unsupported archive format')
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function runArchiveTool(command, args, action, signal) {
|
|
305
|
+
signal.throwIfAborted()
|
|
306
|
+
return new Promise((resolve, reject) => {
|
|
307
|
+
const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true })
|
|
308
|
+
let stdout = ''
|
|
309
|
+
let stderr = ''
|
|
310
|
+
let failure
|
|
311
|
+
const stop = () => child.kill('SIGKILL')
|
|
312
|
+
signal.addEventListener('abort', stop, { once: true })
|
|
313
|
+
child.stdout.setEncoding('utf8').on('data', (chunk) => {
|
|
314
|
+
stdout += chunk
|
|
315
|
+
if (stdout.length > 4 * 1024 * 1024) {
|
|
316
|
+
failure = new Error('archive file listing is too large')
|
|
317
|
+
stop()
|
|
318
|
+
}
|
|
319
|
+
})
|
|
320
|
+
child.stderr.setEncoding('utf8').on('data', (chunk) => {
|
|
321
|
+
stderr = (stderr + chunk).slice(-65_536)
|
|
322
|
+
})
|
|
323
|
+
child.on('error', (error) => { failure = error })
|
|
324
|
+
child.on('close', (code) => {
|
|
325
|
+
signal.removeEventListener('abort', stop)
|
|
326
|
+
if (signal.aborted) reject(signal.reason)
|
|
327
|
+
else if (failure !== undefined || code !== 0) {
|
|
328
|
+
reject(new Error(`could not ${action} binary archive: ${failure?.message ?? (stderr.trim() || `exit ${code}`)}`))
|
|
329
|
+
} else resolve(stdout)
|
|
330
|
+
})
|
|
331
|
+
})
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function validateArchiveEntries(entries) {
|
|
335
|
+
for (const raw of entries.split(/\r?\n/).filter(Boolean)) {
|
|
336
|
+
const entry = raw.replaceAll('\\', '/')
|
|
337
|
+
if (entry.startsWith('/') || /^[a-zA-Z]:\//.test(entry)
|
|
338
|
+
|| entry.split('/').some((part) => part === '..')) {
|
|
339
|
+
throw new Error('binary archive contains a path outside its install directory')
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
async function defaultExtractArchive(archivePath, destination, archiveUrl, { signal }) {
|
|
345
|
+
const suffix = archiveSuffix(archiveUrl)
|
|
346
|
+
if (suffix === '.zip' && process.platform !== 'win32') {
|
|
347
|
+
validateArchiveEntries(await runArchiveTool('unzip', ['-Z1', archivePath], 'inspect', signal))
|
|
348
|
+
await runArchiveTool('unzip', ['-q', archivePath, '-d', destination], 'extract', signal)
|
|
349
|
+
return
|
|
350
|
+
}
|
|
351
|
+
const tar = process.platform === 'win32' ? 'tar.exe' : 'tar'
|
|
352
|
+
validateArchiveEntries(await runArchiveTool(tar, ['-tf', archivePath], 'inspect', signal))
|
|
353
|
+
await runArchiveTool(tar, ['-xf', archivePath, '-C', destination], 'extract', signal)
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function executable(command) {
|
|
357
|
+
try {
|
|
358
|
+
return statSync(command).isFile()
|
|
359
|
+
} catch {
|
|
360
|
+
return false
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Download an official binary distribution into Martty's own data directory.
|
|
366
|
+
* The settings path anchors the default at $MARTTY_HOME/bin.
|
|
367
|
+
*/
|
|
368
|
+
export async function installRegistryBinary(entry, options = {}) {
|
|
369
|
+
if (options.signal?.aborted) {
|
|
370
|
+
const error = new Error('binary installation cancelled')
|
|
371
|
+
error.name = 'AbortError'
|
|
372
|
+
throw error
|
|
373
|
+
}
|
|
374
|
+
const distribution = entry?.distribution
|
|
375
|
+
if (distribution?.type !== 'binary') throw new Error('registry entry is not a binary distribution')
|
|
376
|
+
if (typeof options.settingsPath !== 'string' || options.settingsPath.length === 0) {
|
|
377
|
+
throw new Error('binary installation needs a Martty settings path')
|
|
378
|
+
}
|
|
379
|
+
const id = safeComponent(entry.id, 'agent id')
|
|
380
|
+
const version = safeComponent(entry.version, 'agent version')
|
|
381
|
+
const target = safeComponent(distribution.target, 'binary target')
|
|
382
|
+
const commandParts = relativeCommand(distribution.command)
|
|
383
|
+
const installRoot = options.installRoot ?? path.join(path.dirname(options.settingsPath), 'bin')
|
|
384
|
+
const installDir = path.join(installRoot, id, version, target)
|
|
385
|
+
const installedCommand = containedPath(installDir, commandParts)
|
|
386
|
+
if (executable(installedCommand)) {
|
|
387
|
+
options.onProgress?.({ phase: 'complete' })
|
|
388
|
+
return {
|
|
389
|
+
id: entry.id,
|
|
390
|
+
label: entry.label,
|
|
391
|
+
command: installedCommand,
|
|
392
|
+
args: [...distribution.args],
|
|
393
|
+
env: { ...distribution.env },
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
if (existsSync(installDir)) {
|
|
397
|
+
throw new Error(`incomplete binary installation already exists at ${installDir}`)
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const suffix = archiveSuffix(distribution.archive)
|
|
401
|
+
const parent = path.dirname(installDir)
|
|
402
|
+
mkdirSync(parent, { recursive: true })
|
|
403
|
+
const temporary = mkdtempSync(path.join(parent, '.install-'))
|
|
404
|
+
try {
|
|
405
|
+
const archivePath = path.join(temporary, `download${suffix}`)
|
|
406
|
+
await (options.downloadFile ?? downloadFile)(distribution.archive, archivePath, {
|
|
407
|
+
signal: options.signal,
|
|
408
|
+
onProgress: options.onProgress,
|
|
409
|
+
connectTimeoutMs: options.connectTimeoutMs ?? options.timeoutMs ?? 30_000,
|
|
410
|
+
idleTimeoutMs: options.idleTimeoutMs ?? options.timeoutMs ?? 60_000,
|
|
411
|
+
maxBytes: MAX_ARCHIVE_BYTES,
|
|
412
|
+
})
|
|
413
|
+
options.signal?.throwIfAborted()
|
|
414
|
+
// Validate independently of the transport before trusting an archive.
|
|
415
|
+
if (statSync(archivePath).size > MAX_ARCHIVE_BYTES) throw new Error('binary archive is larger than 512 MiB')
|
|
416
|
+
if (typeof distribution.sha256 === 'string') {
|
|
417
|
+
const hash = createHash('sha256')
|
|
418
|
+
for await (const chunk of createReadStream(archivePath, { signal: options.signal })) hash.update(chunk)
|
|
419
|
+
const actual = hash.digest('hex')
|
|
420
|
+
if (actual !== distribution.sha256.toLowerCase()) {
|
|
421
|
+
throw new Error(`binary checksum mismatch: expected ${distribution.sha256}, received ${actual}`)
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const extracted = path.join(temporary, 'payload')
|
|
426
|
+
mkdirSync(extracted)
|
|
427
|
+
// Archive inspection and extraction share a ten-minute default deadline;
|
|
428
|
+
// callers can override it, and cancellation still interrupts the extractor.
|
|
429
|
+
const extraction = timeoutSignal(options.extractTimeoutMs ?? 10 * 60_000, options.signal, 'binary extraction')
|
|
430
|
+
try {
|
|
431
|
+
options.onProgress?.({ phase: 'extract' })
|
|
432
|
+
extraction.signal.throwIfAborted()
|
|
433
|
+
if (options.extractArchive !== undefined) {
|
|
434
|
+
await abortable(options.extractArchive(archivePath, extracted, distribution.archive, {
|
|
435
|
+
signal: extraction.signal,
|
|
436
|
+
}), extraction.signal)
|
|
437
|
+
} else {
|
|
438
|
+
// The native extractor waits for the terminated child to close before
|
|
439
|
+
// cleanup, including on Windows where open files cannot be removed.
|
|
440
|
+
await defaultExtractArchive(archivePath, extracted, distribution.archive, { signal: extraction.signal })
|
|
441
|
+
}
|
|
442
|
+
options.onProgress?.({ phase: 'verify' })
|
|
443
|
+
extraction.signal.throwIfAborted()
|
|
444
|
+
const extractedCommand = containedPath(extracted, commandParts)
|
|
445
|
+
if (!existsSync(extractedCommand)) {
|
|
446
|
+
throw new Error(`binary archive does not contain ${distribution.command}`)
|
|
447
|
+
}
|
|
448
|
+
if (process.platform !== 'win32') chmodSync(extractedCommand, 0o755)
|
|
449
|
+
if (!executable(extractedCommand)) throw new Error('binary command is not an executable file')
|
|
450
|
+
renameSync(extracted, installDir)
|
|
451
|
+
} catch (error) {
|
|
452
|
+
if (extraction.signal.aborted) throw extraction.signal.reason
|
|
453
|
+
throw error
|
|
454
|
+
} finally {
|
|
455
|
+
extraction.cancel()
|
|
456
|
+
}
|
|
457
|
+
} finally {
|
|
458
|
+
rmSync(temporary, { recursive: true, force: true })
|
|
459
|
+
}
|
|
460
|
+
options.onProgress?.({ phase: 'complete' })
|
|
461
|
+
return {
|
|
462
|
+
id: entry.id,
|
|
463
|
+
label: entry.label,
|
|
464
|
+
command: installedCommand,
|
|
465
|
+
args: [...distribution.args],
|
|
466
|
+
env: { ...distribution.env },
|
|
467
|
+
}
|
|
468
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/** Removal is intentionally limited to saved recipes and exact private binary installations. */
|
|
2
|
+
import { existsSync, lstatSync, readdirSync, realpathSync, renameSync, rmSync } from 'node:fs'
|
|
3
|
+
import { randomUUID } from 'node:crypto'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import { savedHarnesses, removeHarnessConfiguration } from './harnesses.js'
|
|
6
|
+
|
|
7
|
+
const inside = (root, value) => value === root || value.startsWith(root + path.sep)
|
|
8
|
+
const references = entry => [entry.command, ...(entry.args ?? []), ...Object.entries(entry.env ?? {})
|
|
9
|
+
.flatMap(([key, value]) => key.toUpperCase() === 'PATH' ? value.split(path.delimiter) : [value])]
|
|
10
|
+
.filter(value => typeof value === 'string')
|
|
11
|
+
.map(value => !path.isAbsolute(value) && value.startsWith('--') && value.includes('=') ? value.slice(value.indexOf('=') + 1) : value)
|
|
12
|
+
.filter(value => path.isAbsolute(value))
|
|
13
|
+
|
|
14
|
+
function hasLink(directory) {
|
|
15
|
+
if (lstatSync(directory).isSymbolicLink()) return true
|
|
16
|
+
if (!lstatSync(directory).isDirectory()) return false
|
|
17
|
+
return readdirSync(directory).some(name => hasLink(path.join(directory, name)))
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function privateInstallation(settingsPath, entry) {
|
|
21
|
+
const root = path.resolve(path.dirname(settingsPath), 'bin')
|
|
22
|
+
const canonicalRoot = existsSync(root) ? realpathSync(root) : root
|
|
23
|
+
const candidate = references(entry).find(value => inside(root, path.resolve(value)) || inside(canonicalRoot, path.resolve(value)))
|
|
24
|
+
if (!candidate) return { resources: [], cleanupReason: 'No private binary installation. External programs and shared npx/uvx caches are kept.' }
|
|
25
|
+
const parts = path.relative(inside(root, path.resolve(candidate)) ? root : canonicalRoot, path.resolve(candidate)).split(path.sep)
|
|
26
|
+
// The installer owns bin/<registry-id>/<version>/<platform>, never the whole bin or id directory.
|
|
27
|
+
if (parts.length < 4 || parts.slice(0, 3).some(part => !/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(part))) {
|
|
28
|
+
return { resources: [], cleanupReason: 'The path is not a recognized private binary installation.' }
|
|
29
|
+
}
|
|
30
|
+
const directory = path.join(root, ...parts.slice(0, 3))
|
|
31
|
+
let cursor = directory
|
|
32
|
+
while (true) {
|
|
33
|
+
try {
|
|
34
|
+
if (lstatSync(cursor).isSymbolicLink()) return { resources: [], cleanupReason: 'A symbolic link is present; resource cleanup is disabled.' }
|
|
35
|
+
} catch (error) { if (error.code !== 'ENOENT') throw error }
|
|
36
|
+
const parent = path.dirname(cursor)
|
|
37
|
+
if (cursor === path.dirname(root)) break
|
|
38
|
+
cursor = parent
|
|
39
|
+
}
|
|
40
|
+
if (!existsSync(directory)) return { resources: [], cleanupReason: 'Private installation is already absent.' }
|
|
41
|
+
if (hasLink(directory)) return { resources: [], cleanupReason: 'The installation contains a symbolic link; resource cleanup is disabled.' }
|
|
42
|
+
return { resources: [directory] }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function planHarnessRemoval(settingsPath, id, options = {}) {
|
|
46
|
+
const entries = savedHarnesses(settingsPath)
|
|
47
|
+
const entry = entries.find(entry => entry.id === id)
|
|
48
|
+
if (!entry) throw new Error('Only a saved Harness configuration can be removed')
|
|
49
|
+
if (options.isCurrent?.(entry)) throw new Error('Switch to another Harness before removing the current Harness')
|
|
50
|
+
const forced = [options.forcedHarness, ...(options.defaults ?? []).filter(entry => entry.source === 'forced')].filter(Boolean)
|
|
51
|
+
if (forced.some(value => value.id === id || value.command === entry.command)) throw new Error('This Harness is product-forced and cannot be removed here')
|
|
52
|
+
const installation = privateInstallation(settingsPath, entry)
|
|
53
|
+
if (installation.resources.length) {
|
|
54
|
+
const directory = installation.resources[0]
|
|
55
|
+
const shared = [...entries.filter(value => value.id !== id), ...(options.defaults ?? []), ...forced]
|
|
56
|
+
.some(value => references(value).some(ref => {
|
|
57
|
+
const resolved = existsSync(ref) ? realpathSync(ref) : path.resolve(ref)
|
|
58
|
+
return inside(directory, path.resolve(ref)) || inside(realpathSync(directory), resolved)
|
|
59
|
+
}))
|
|
60
|
+
if (shared) return { entry, resources: [], cleanupReason: 'The installation is shared by another Harness configuration; its resources must be kept.' }
|
|
61
|
+
}
|
|
62
|
+
return { entry, ...installation }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function removeHarness(settingsPath, expected, options = {}) {
|
|
66
|
+
const plan = planHarnessRemoval(settingsPath, expected.entry.id, options)
|
|
67
|
+
if (JSON.stringify(plan.entry) !== JSON.stringify(expected.entry)) throw new Error('Harness configuration changed; review removal again')
|
|
68
|
+
if (options.cleanup && plan.cleanupReason) throw new Error(plan.cleanupReason)
|
|
69
|
+
if (options.cleanup && JSON.stringify(plan.resources) !== JSON.stringify(expected.resources)) throw new Error('Installation paths changed; review removal again')
|
|
70
|
+
// Move the exact validated installation aside before changing settings. No external cache or ancestor is deleted.
|
|
71
|
+
const moved = []
|
|
72
|
+
try {
|
|
73
|
+
if (options.cleanup) for (const resource of plan.resources) {
|
|
74
|
+
const quarantine = path.join(path.dirname(resource), `.removed-${randomUUID()}`)
|
|
75
|
+
renameSync(resource, quarantine)
|
|
76
|
+
moved.push({ resource, quarantine })
|
|
77
|
+
}
|
|
78
|
+
removeHarnessConfiguration(settingsPath, plan.entry)
|
|
79
|
+
} catch (error) {
|
|
80
|
+
for (const { resource, quarantine } of moved.reverse()) renameSync(quarantine, resource)
|
|
81
|
+
throw error
|
|
82
|
+
}
|
|
83
|
+
for (const { quarantine } of moved) {
|
|
84
|
+
try { rmSync(quarantine, { recursive: true }) }
|
|
85
|
+
catch (error) { throw new Error(`Configuration removed, but cleanup failed at ${quarantine}: ${error.message}`) }
|
|
86
|
+
}
|
|
87
|
+
return { entry: plan.entry, removed: moved.map(({ resource }) => resource) }
|
|
88
|
+
}
|