@ricardochen1996/browser-use-mcp 0.0.0-stage → 0.1.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.
@@ -0,0 +1,585 @@
1
+ /**
2
+ * Installing the engine this server drives: one environment the package owns, built once.
3
+ *
4
+ * Nothing npm installs builds a Python environment, so the engine is treated as a runtime
5
+ * prerequisite: the package carries the engine as a wheel and its dependencies as hash-pinned
6
+ * requirements, and the first caller that finds no engine builds the environment from them with a
7
+ * Python the machine already has — or with uv, which can fetch the Python too. This module owns the
8
+ * environment directory, the install itself, and the record of what was installed; `./engine.js`
9
+ * decides when to call it.
10
+ *
11
+ * There is one environment per state root (`${BROWSER_USE_MCP_HOME}/env`), and there must be one
12
+ * install at a time: two server processes starting together would otherwise both build the same
13
+ * virtualenv. An in-process promise makes concurrent callers share one install, and an atomic lock
14
+ * directory (`<stateRoot>/install.lock`, with the owner's pid inside) does the same across
15
+ * processes. A lock whose owner is gone is reclaimed, so a killed install cannot wedge the next one.
16
+ *
17
+ * @module browser-use-mcp/provision
18
+ */
19
+
20
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'
21
+ import { homedir } from 'node:os'
22
+ import { dirname, join, resolve } from 'node:path'
23
+ import { fileURLToPath } from 'node:url'
24
+
25
+ import { createEnvironment, environmentPython, findUv, runCommand } from './python.js'
26
+
27
+ /** This package's own root: the sidecar, the bundled engine, and the install hints live here. */
28
+ export const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
29
+
30
+ /** The bundled engine wheel: parsed from its filename, so a version bump needs no code change. */
31
+ const VENDOR = join(PACKAGE_ROOT, 'vendor')
32
+
33
+ /** The requirements file the lock pins, one wheel per line with the hash of every artifact. */
34
+ const REQUIREMENTS = join(VENDOR, 'requirements.txt')
35
+
36
+ /** How long one install may take from lock to wheel, as the caller's hard cap. */
37
+ export const INSTALL_TIMEOUT_MS = 10 * 60 * 1000
38
+
39
+ /** How often a waiter looks at the lock again. A build takes seconds, so this is cheap enough. */
40
+ const LOCK_POLL_MS = 400
41
+
42
+ /**
43
+ * How long a lock directory without a readable owner is left alone.
44
+ *
45
+ * `mkdir` and writing `owner.json` are two steps: a waiter that read the directory in between would
46
+ * see no owner and, without this grace period, could delete a lock that was just created and then
47
+ * both processes would install. A few hundred milliseconds of patience is all it costs.
48
+ */
49
+ const LOCK_GRACE_MS = 3000
50
+
51
+ /** The installer's output kept for the failure message; more is noise, less loses the cause. */
52
+ const KEPT_LINES = 40
53
+
54
+ /** The status fields observers see; the install's raw output and command stay internal. */
55
+ const STATUS_FIELDS = [
56
+ 'state', 'step', 'startedAt', 'finishedAt', 'environment', 'strategy', 'pythonVersion', 'error',
57
+ 'code', 'fix',
58
+ ]
59
+
60
+ /** Environment dir -> the install running there in this process. */
61
+ const installs = new Map()
62
+
63
+ /** The in-process status of the last install this process ran, or `undefined` before one did. */
64
+ let status
65
+
66
+ /**
67
+ * The state root: where the managed environment, the browser profiles, and the install record live.
68
+ *
69
+ * The configuration always resolves this, but a bare object still has to answer, so the documented
70
+ * default is applied here too. The directory itself is created on demand by the install — a server
71
+ * that only reads a report should not create anything.
72
+ * @param {object} [config] - resolved server configuration.
73
+ * @returns {string} the absolute state root.
74
+ */
75
+ export function stateRoot(config = {}) {
76
+ const configured = String(config.browserUseMcpHome ?? '').trim()
77
+ return resolve(configured === '' ? join(homedir(), '.browser-use-mcp') : configured)
78
+ }
79
+
80
+ /**
81
+ * The environment this server builds and owns: `<stateRoot>/env`.
82
+ * @param {object} [config] - resolved server configuration.
83
+ * @returns {string}
84
+ */
85
+ export function ownEnvironment(config = {}) {
86
+ return join(stateRoot(config), 'env')
87
+ }
88
+
89
+ /**
90
+ * Where the install of the managed environment stands in this process, for observers such as the
91
+ * doctor or a waiter that joined a running install.
92
+ * @returns {{state: 'installing'|'installed'|'failed', step: string, startedAt: number,
93
+ * finishedAt?: number, environment: string, strategy?: string, pythonVersion?: string,
94
+ * error?: string, code?: string, fix?: string}|undefined} `undefined` before anything ran here.
95
+ */
96
+ export function installStatus() {
97
+ if (status === undefined) return undefined
98
+ const copy = {}
99
+ for (const key of STATUS_FIELDS) {
100
+ if (status[key] !== undefined) copy[key] = status[key]
101
+ }
102
+ return copy
103
+ }
104
+
105
+ /** The install record path that belongs to an environment, whether or not the record exists yet. */
106
+ function installRecordPath(environment) {
107
+ return join(dirname(environment), 'install.json')
108
+ }
109
+
110
+ /**
111
+ * The record of what built an environment, written by this module on success, or `undefined`.
112
+ *
113
+ * The record lives beside the environment (`<stateRoot>/install.json`) rather than inside it: a
114
+ * `python -m venv --clear` or a removed environment must not take the explanation of what happened
115
+ * with it, and a later doctor in another process reads it without importing anything.
116
+ * @param {string} environment - the managed virtualenv directory.
117
+ * @returns {object|undefined}
118
+ */
119
+ export function environmentRecord(environment) {
120
+ try {
121
+ const record = JSON.parse(readFileSync(installRecordPath(environment), 'utf8'))
122
+ return record !== null && typeof record === 'object' ? record : undefined
123
+ } catch {
124
+ return undefined
125
+ }
126
+ }
127
+
128
+ /** The hash-pinned requirements file for the sidecar's Python dependencies. */
129
+ export function requirementsPath() {
130
+ return REQUIREMENTS
131
+ }
132
+
133
+ /**
134
+ * The bundled engine wheel, its version read from the wheel's filename.
135
+ *
136
+ * Reading the version from the filename rather than a separate manifest means the record, the
137
+ * doctor, and a version bump cannot disagree: they all describe the one file that is installed.
138
+ * @param {string} [root] - package root to look in; the installed package by default.
139
+ * @returns {{version: string, path: string}|undefined} `undefined` when no wheel ships here.
140
+ */
141
+ export function bundledEngine(root = PACKAGE_ROOT) {
142
+ const vendor = join(root, 'vendor')
143
+ let names
144
+ try {
145
+ names = readdirSync(vendor)
146
+ } catch {
147
+ return undefined
148
+ }
149
+ const wheel = names.filter(name => /^jev_ultrafast-.+\.whl$/iu.test(name)).sort().at(-1)
150
+ if (wheel === undefined) return undefined
151
+ const version = wheel.split('-')[1]
152
+ if (version === undefined || version === '') return undefined
153
+ return { version, path: join(vendor, wheel) }
154
+ }
155
+
156
+ /** The version a requirement pins, so the install record can name browser-harness accurately. */
157
+ function requirementVersion(name) {
158
+ try {
159
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&')
160
+ const match = new RegExp(`^${escaped}==([^\\s\\\\]+)`, 'mu').exec(readFileSync(REQUIREMENTS, 'utf8'))
161
+ return match?.[1] ?? 'unknown'
162
+ } catch {
163
+ return 'unknown'
164
+ }
165
+ }
166
+
167
+ /**
168
+ * How to ask for the install in this checkout, from a shell: the CLI command the package ships.
169
+ * @param {object} [config] - resolved server configuration (accepted for call sites that have it).
170
+ * @returns {string}
171
+ */
172
+ export function installHint(config = {}) {
173
+ void config
174
+ return `node ${join(PACKAGE_ROOT, 'bin', 'browser-use-mcp.mjs')} install`
175
+ }
176
+
177
+ /** How to ask for the install again, appended to the end of a fix. */
178
+ function retryText(config) {
179
+ return `retry: ${installHint(config)}`
180
+ }
181
+
182
+ /** A duration as a person reads it: `42s`, `3m`, `3m 5s`. */
183
+ export function duration(ms) {
184
+ const seconds = Math.max(0, Math.round(ms / 1000))
185
+ if (seconds < 60) return `${seconds}s`
186
+ const rest = seconds % 60
187
+ return rest === 0 ? `${Math.floor(seconds / 60)}m` : `${Math.floor(seconds / 60)}m ${rest}s`
188
+ }
189
+
190
+ /** The fix for "the installer could not reach the index", whatever it was downloading. */
191
+ function networkFix(config) {
192
+ return `check the network (a proxy belongs in HTTPS_PROXY for the process that runs this server); then `
193
+ + retryText(config)
194
+ }
195
+
196
+ /** The diagnosis, shortened: the first error lines the installer printed, or its last words. */
197
+ function summarize(lines) {
198
+ const clipped = text => (text.length > 160 ? `${text.slice(0, 159)}\u2026` : text)
199
+ const errors = lines.map(line => line.trim()).filter(line => /^(?:ERROR|error):/u.test(line))
200
+ if (errors.length > 0) return [...new Set(errors.slice(-2))].map(clipped).join(' \u2014 ')
201
+ return lines.map(line => line.trim()).filter(Boolean).at(-1) ?? ''
202
+ }
203
+
204
+ /** The npm/uv output that means the network, not the environment, is the problem. */
205
+ const NETWORK_FAILURE = new RegExp([
206
+ 'files\\.pythonhosted\\.org', 'pypi\\.org', 'error sending request', 'tcp connect', 'dns error',
207
+ 'connection (?:refused|reset)', 'timed out', 'tunnel error', 'certificate', 'network',
208
+ ].join('|'), 'iu')
209
+
210
+ /** The fix for a failed install, decided by what the installer said. */
211
+ function fixFor(output, config) {
212
+ const retry = retryText(config)
213
+ if (/No matching distribution found|Could not find a version that satisfies/iu.test(output)) {
214
+ return `the index did not carry the pinned versions: check the network, or point PIP_INDEX_URL (or `
215
+ + `UV_INDEX_URL) at a mirror of PyPI; then ${retry}`
216
+ }
217
+ if (/THESE PACKAGES DO NOT MATCH THE HASHES|Expected sha256|hash mismatch/iu.test(output)) {
218
+ return `an index served an artifact that is not the one requirements.txt records: point PIP_INDEX_URL `
219
+ + `(or UV_INDEX_URL) at PyPI or a mirror of it; then ${retry}`
220
+ }
221
+ if (/No module named pip|ensurepip is not available|venv is not available/iu.test(output)) {
222
+ return `that Python cannot build a virtualenv with pip: install its venv package (for example `
223
+ + `python3.12-venv on Linux) or uv; then ${retry}`
224
+ }
225
+ if (/permission denied|read-only file system|operation not permitted/iu.test(output)) {
226
+ return 'the state root (BROWSER_USE_MCP_HOME) has to be writable by the user this server runs as'
227
+ }
228
+ if (NETWORK_FAILURE.test(output)) {
229
+ return networkFix(config)
230
+ }
231
+ return `fix what the installer reported; then ${retry}`
232
+ }
233
+
234
+ /** The environment pip and uv run in: the managed environment, and nothing that redirects them. */
235
+ function installEnvironment(environment) {
236
+ const env = { ...process.env, VIRTUAL_ENV: environment, NO_COLOR: '1' }
237
+ delete env.PIP_REQUIRE_VIRTUALENV
238
+ delete env.PYTHONHOME
239
+ return env
240
+ }
241
+
242
+ /**
243
+ * The two commands that install with uv: the pinned dependencies, then the engine wheel from disk.
244
+ *
245
+ * uv validates the `--hash` lines in requirements.txt and, unlike a resolve, installs exactly what
246
+ * the file lists. The wheel is installed `--no-deps` because its dependencies are already there,
247
+ * which is what makes a first install about 1.3 MB instead of a full resolve.
248
+ */
249
+ function uvInstallCommands(uv, environment, wheel) {
250
+ const python = environmentPython(environment)
251
+ return [
252
+ {
253
+ command: uv,
254
+ args: ['pip', 'install', '--python', python, '-r', REQUIREMENTS],
255
+ what: 'installing the pinned dependencies with uv',
256
+ },
257
+ {
258
+ command: uv,
259
+ args: ['pip', 'install', '--python', python, '--no-deps', wheel],
260
+ what: 'installing the bundled engine',
261
+ },
262
+ ]
263
+ }
264
+
265
+ /** The same install with pip, for a machine without uv: hashes required, no dependencies resolved. */
266
+ function pipInstallCommands(environment, wheel) {
267
+ const python = environmentPython(environment)
268
+ const quiet = ['--no-input', '--disable-pip-version-check', '--progress-bar', 'off']
269
+ return [
270
+ {
271
+ command: python,
272
+ args: ['-m', 'pip', 'install', ...quiet, '--require-hashes', '--no-deps', '--only-binary=:all:',
273
+ '-r', REQUIREMENTS],
274
+ what: 'installing the pinned dependencies with pip',
275
+ },
276
+ {
277
+ command: python,
278
+ args: ['-m', 'pip', 'install', ...quiet, '--no-index', '--no-deps', wheel],
279
+ what: 'installing the bundled engine',
280
+ },
281
+ ]
282
+ }
283
+
284
+ /** The lock directory that makes one install at a time a property of the state root. */
285
+ function lockPath(root) {
286
+ return join(root, 'install.lock')
287
+ }
288
+
289
+ function readLockOwner(lock) {
290
+ try {
291
+ const owner = JSON.parse(readFileSync(join(lock, 'owner.json'), 'utf8'))
292
+ return owner !== null && typeof owner === 'object' && Number.isInteger(owner.pid) ? owner : undefined
293
+ } catch {
294
+ return undefined
295
+ }
296
+ }
297
+
298
+ /** Whether a process id still names a live process; `EPERM` means alive but not ours. */
299
+ function pidAlive(pid) {
300
+ if (!Number.isInteger(pid) || pid <= 0) return false
301
+ try {
302
+ process.kill(pid, 0)
303
+ return true
304
+ } catch (error) {
305
+ return error?.code === 'EPERM'
306
+ }
307
+ }
308
+
309
+ /** Whether a lock may be removed: its owner is dead, or it never recorded an owner. */
310
+ function lockIsStale(lock) {
311
+ const owner = readLockOwner(lock)
312
+ // A readable owner is the fast path: a dead pid means the install was killed, and waiting for the
313
+ // grace period would only delay the next one. The grace period exists for the unreadable case
314
+ // below, where the directory may have been created a moment ago by a live installer.
315
+ if (owner !== undefined) return !pidAlive(owner.pid)
316
+ try {
317
+ return Date.now() - statSync(lock).mtimeMs > LOCK_GRACE_MS
318
+ } catch {
319
+ return true
320
+ }
321
+ }
322
+
323
+ /** One attempt to take the lock: `mkdir` is atomic, the owner file explains who holds it. */
324
+ function tryLock(lock) {
325
+ try {
326
+ mkdirSync(lock)
327
+ } catch (error) {
328
+ if (error?.code === 'EEXIST') return false
329
+ throw error
330
+ }
331
+ try {
332
+ const owner = JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() })
333
+ writeFileSync(join(lock, 'owner.json'), `${owner}\n`)
334
+ } catch {
335
+ // The lock is ours either way; an unreadable owner file only costs the reclaim grace period.
336
+ }
337
+ return true
338
+ }
339
+
340
+ function releaseLock(root) {
341
+ try {
342
+ rmSync(lockPath(root), { recursive: true, force: true })
343
+ } catch {
344
+ // A lock left behind is reclaimed by the next caller once this process is gone; never throw here.
345
+ }
346
+ }
347
+
348
+ function sleep(ms) {
349
+ return new Promise(resolve => setTimeout(resolve, ms))
350
+ }
351
+
352
+ /**
353
+ * Wait for the cross-process install lock, reclaiming a stale one, and fail after a hard cap.
354
+ * @throws {Error} when another live install holds the lock for the whole budget.
355
+ */
356
+ async function acquireInstallLock(root, config, onWait) {
357
+ const lock = lockPath(root)
358
+ const deadline = Date.now() + INSTALL_TIMEOUT_MS
359
+ for (;;) {
360
+ if (tryLock(lock)) return
361
+ if (lockIsStale(lock)) {
362
+ try {
363
+ rmSync(lock, { recursive: true, force: true })
364
+ continue
365
+ } catch {
366
+ // Another waiter may have reclaimed it first, or the directory refuses deletion; either way
367
+ // fall through to the wait so an undeletable lock cannot become a hot loop.
368
+ }
369
+ }
370
+ if (Date.now() >= deadline) {
371
+ const owner = readLockOwner(lock)
372
+ const who = owner === undefined ? 'another process' : `process ${owner.pid}`
373
+ throw new Error(`${who} has held ${lock} for over ${duration(INSTALL_TIMEOUT_MS)}`)
374
+ }
375
+ onWait?.(`waiting for another install (${lock})`)
376
+ await sleep(LOCK_POLL_MS)
377
+ }
378
+ }
379
+
380
+ /** Write the record that tells a later process what built the environment. */
381
+ function writeInstallRecord(root, record) {
382
+ try {
383
+ mkdirSync(root, { recursive: true })
384
+ writeFileSync(join(root, 'install.json'), `${JSON.stringify(record, null, 2)}\n`)
385
+ } catch {
386
+ // The environment works even when the convenience record cannot be written.
387
+ }
388
+ }
389
+
390
+ /** A record that still describes a usable environment: one exists and its interpreter is there. */
391
+ function readyEnvironment(environment) {
392
+ const record = environmentRecord(environment)
393
+ if (record === undefined) return undefined
394
+ if (!existsSync(environmentPython(environment))) return undefined
395
+ return record
396
+ }
397
+
398
+ /** What a caller gets when the environment was already built: the same shape a fresh install has. */
399
+ function reusedResult(environment, record) {
400
+ return {
401
+ ok: true,
402
+ environment: typeof record.environment === 'string' ? record.environment : environment,
403
+ strategy: record.strategy,
404
+ pythonVersion: record.pythonVersion,
405
+ engine: record.engine,
406
+ browserHarness: record.browserHarness,
407
+ }
408
+ }
409
+
410
+ /**
411
+ * Build the managed environment, or join the install already building it.
412
+ *
413
+ * The environment is reused when `install.json` records one and its interpreter exists — that is
414
+ * what makes a second call, and a server restart, free. Whether the engine actually *imports* is
415
+ * the caller's question (`inspectEngine` probes it); this module only builds and records. `force`
416
+ * is the user asking for a clean rebuild, and is what a caller passes when a recorded environment
417
+ * stopped working.
418
+ *
419
+ * Concurrent callers in this process share one promise and all receive the same result; another
420
+ * process waits on the lock, and when it gets it re-checks for a finished install before building.
421
+ * @param {object} [config] - resolved server configuration.
422
+ * @param {{force?: boolean, onOutput?: (line: string) => void}} [options] - `onOutput` receives the
423
+ * installer's lines from now until this install ends, whichever caller started it.
424
+ * @returns {Promise<{ok: boolean, environment: string, strategy?: string, pythonVersion?: string,
425
+ * engine?: string, browserHarness?: string, error?: string, code?: string, fix?: string,
426
+ * command?: string}>} the install's final state; it never rejects.
427
+ */
428
+ export function installEngine(config = {}, options = {}) {
429
+ const root = stateRoot(config)
430
+ const environment = ownEnvironment(config)
431
+ const force = options.force === true
432
+ const current = installs.get(environment)
433
+ if (current !== undefined) {
434
+ if (typeof options.onOutput === 'function') current.listeners.add(options.onOutput)
435
+ return current.promise
436
+ }
437
+ if (!force) {
438
+ const record = readyEnvironment(environment)
439
+ if (record !== undefined) return Promise.resolve(reusedResult(environment, record))
440
+ }
441
+ const listeners = new Set()
442
+ if (typeof options.onOutput === 'function') listeners.add(options.onOutput)
443
+ const entry = { listeners }
444
+ installs.set(environment, entry)
445
+ entry.promise = buildEnvironment({ config, root, environment, force, listeners })
446
+ .finally(() => {
447
+ if (installs.get(environment) === entry) installs.delete(environment)
448
+ })
449
+ return entry.promise
450
+ }
451
+
452
+ /** The actual install: status, lock, environment, two installs, record. */
453
+ async function buildEnvironment({ config, root, environment, force, listeners }) {
454
+ const startedAt = Date.now()
455
+ const output = []
456
+ status = { state: 'installing', step: 'preparing the install', startedAt, environment }
457
+ const say = text => {
458
+ const line = String(text)
459
+ if (line.trim() === '') return
460
+ output.push(line)
461
+ if (output.length > KEPT_LINES) output.shift()
462
+ status.step = line.trim().slice(0, 200)
463
+ for (const listener of listeners) {
464
+ try {
465
+ listener(line)
466
+ } catch {
467
+ // An observer that throws is its own problem; the install goes on for everyone else.
468
+ }
469
+ }
470
+ }
471
+ const failed = (code, error, fix) => {
472
+ Object.assign(status, { state: 'failed', finishedAt: Date.now(), code, error, fix })
473
+ return {
474
+ ok: false,
475
+ environment,
476
+ strategy: status.strategy,
477
+ pythonVersion: status.pythonVersion,
478
+ error,
479
+ code,
480
+ fix,
481
+ ...(status.command === undefined ? {} : { command: status.command }),
482
+ }
483
+ }
484
+ const installed = record => {
485
+ Object.assign(status, {
486
+ state: 'installed',
487
+ finishedAt: Date.now(),
488
+ code: undefined,
489
+ error: undefined,
490
+ fix: undefined,
491
+ // A waiter that reuses another process's install records that install's strategy, so the
492
+ // doctor says how the environment was built even when this process did not build it.
493
+ strategy: record.strategy,
494
+ pythonVersion: record.pythonVersion,
495
+ })
496
+ return {
497
+ ok: true,
498
+ environment,
499
+ strategy: record.strategy,
500
+ pythonVersion: record.pythonVersion,
501
+ engine: record.engine,
502
+ browserHarness: record.browserHarness,
503
+ }
504
+ }
505
+
506
+ try {
507
+ mkdirSync(root, { recursive: true })
508
+ } catch (error) {
509
+ return failed('state_root_failed', `the state directory ${root} could not be created: ${error.message}`,
510
+ `make ${root} writable by the user this server runs as, or set BROWSER_USE_MCP_HOME to a writable directory`)
511
+ }
512
+ try {
513
+ await acquireInstallLock(root, config, text => { status.step = text })
514
+ } catch (error) {
515
+ return failed('lock_timeout', `another install never finished: ${error.message}`,
516
+ `if no install is running, remove ${lockPath(root)} and retry: ${installHint(config)}`)
517
+ }
518
+
519
+ try {
520
+ // Another process may have finished while this one waited; that install is the answer.
521
+ if (!force) {
522
+ const record = readyEnvironment(environment)
523
+ if (record !== undefined) {
524
+ status.step = 'another process already installed the environment'
525
+ return installed(record)
526
+ }
527
+ }
528
+ status.step = 'creating the virtualenv'
529
+ const created = await createEnvironment({ config, envDir: environment, onOutput: say })
530
+ if (created.ok !== true) {
531
+ return failed(created.code, created.error, `${created.fix}; then ${retryText(config)}`)
532
+ }
533
+ status.strategy = created.strategy
534
+ status.pythonVersion = created.pythonVersion
535
+ const wheel = bundledEngine()
536
+ if (wheel === undefined || !existsSync(wheel.path)) {
537
+ return failed('no_wheel',
538
+ `the engine wheel this package carries is missing: ${wheel?.path ?? join(VENDOR, 'jev_ultrafast-*.whl')}`,
539
+ 'reinstall the package: its Python half ships in vendor/ beside lib/')
540
+ }
541
+ const uv = findUv()
542
+ const commands = uv === undefined
543
+ ? pipInstallCommands(environment, wheel.path)
544
+ : uvInstallCommands(uv, environment, wheel.path)
545
+ for (const step of commands) {
546
+ status.step = step.what
547
+ status.command = `${step.command} ${step.args.join(' ')}`
548
+ const remaining = Math.max(1000, INSTALL_TIMEOUT_MS - (Date.now() - startedAt))
549
+ const result = await runCommand(step.command, step.args, {
550
+ cwd: PACKAGE_ROOT,
551
+ env: installEnvironment(environment),
552
+ onLine: say,
553
+ timeoutMs: remaining,
554
+ })
555
+ if (result.timedOut) {
556
+ return failed('timeout', `${step.what} did not finish within ${duration(Date.now() - startedAt)}`,
557
+ networkFix(config))
558
+ }
559
+ if (result.spawnError !== undefined) {
560
+ return failed('install_failed', `${step.command} could not be started: ${result.spawnError}`,
561
+ `check ${step.command}; then ${retryText(config)}`)
562
+ }
563
+ if (result.code !== 0) {
564
+ return failed('install_failed',
565
+ summarize(output) || `${step.command} exited with ${result.code ?? result.signal}`,
566
+ fixFor(output.join('\n'), config))
567
+ }
568
+ }
569
+ const record = {
570
+ strategy: created.strategy,
571
+ environment,
572
+ pythonVersion: created.pythonVersion,
573
+ installedAt: new Date().toISOString(),
574
+ engine: wheel.version,
575
+ browserHarness: requirementVersion('browser-harness'),
576
+ }
577
+ writeInstallRecord(root, record)
578
+ return installed(record)
579
+ } catch (error) {
580
+ return failed('install_failed', `the install itself failed: ${error?.message ?? String(error)}`,
581
+ `report this; then ${retryText(config)}`)
582
+ } finally {
583
+ releaseLock(root)
584
+ }
585
+ }