Files
orca/config/scripts/build-orcad.mjs
T
Jinwoo Hong d3dbd9683f Update Orca's skills with Orca's own installer instead of npx skills (#31688)
* Add Orca's own bundled-skill planner and installer (not wired in yet)

One planner decides what installing Orca's bundled skills does to each
target folder; execute re-plans under per-path locks and runs only a plan
whose digest still matches; a shared summary derives badge, wording and
exit code. A copy counts as Orca's untouched one only when its SKILL.md
bytes match a version some build shipped (resources/skills/released-digests.json,
generated from git history and checked add-only).

Nothing calls this yet; the npx path is unchanged.

* Report left-alone folders as warnings and keep backups in the home

A leave is no longer a failure: the outcome line says what happened plus
"N left alone: <reason>", and the exit code is 1 only for a failed or
changed op. Backups for every scope go to <home>/.agents/.orca-skill-backups
on the executing host, so a project Replace never writes into the repo;
moving there falls back to copy, verify, then remove across filesystems.

* Update Orca's skills with Orca's own installer instead of npx skills

The app's Update skills dialog, badge, nudge and status pills now read the
bundled-skill plan and summary, and Update executes the reviewed plan's
digest. `orca skills install|update` runs the same planner in-process with
--dry-run, --force, --json and per-summary exit codes. Removes both npx
spawn sites, the freshness inventory modules and the npx round-trip CI.

* Update only where the skill already is; one provider-root rule for app and CLI

installedOnly now drops every location that holds nothing (keeping the shared slot
only when an existing agent link points at it), so Update never adds a skill for
another agent. The app and CLI resolve the Claude skills folder through the same
rule in localBundledSkillInput; a test asserts both plan identically.

* Compact Update skills dialog: one row per skill, locations named by agent

summarizeBundledSkillPlan now returns each skill's locations, labelled by
agent (or "Shared folder") with a ~-collapsed path; the dialog shows one row
per skill that needs something, collapses up-to-date skills into one line, and
lists every location behind "Show all locations". The result reads "Updated N
skills · M left alone". Under installedOnly a Replace with no shared copy
replaces in place instead of adding one.

* fix(skills): keep location paths readable by putting the state under the path

* fix(skills): hold the last plan in state, not a ref mutated during render

Split the update dialog's rows, headline and footer into components, move the Update label
next to the other plan copy, and use z.strictObject for the request schemas.

* Escape non-ASCII characters in bundled-skill regex and wording

The orcad bundle rejects a literal non-ASCII character in a regex.

* fix(cli): escape non-ASCII apostrophes in the skills install help text

* test(orcad): check the bundled CLI plans a skill install without npx

Drop the deleted roundtrip workflow's group from the closed-PR cancellation fixture.

* Make bundled-skill test fixtures link folders the way the product does on Windows

Directory links in fixtures are junctions on Windows, which need no
Developer Mode; the one file-link fixture is skipped there.

* Resolve legacy skill agent keys canonically

* Reject stale skill replacements when auxiliary content changes

* Keep ordinary bundled skill classification shallow

* Leave unreadable skill replacements alone without aborting plans

* Pass force-all through shallow planning and describe unreadable copies

* Clarify skill update statuses and folder details

* Keep selected skill replacements in place with one status

* Report completed skill replacements with their backup confirmation

* Clarify available skill updates and skipped copies

* Keep skill update results in preview order

* Show each skill skip reason once

* Count skipped skills instead of skipped folders

* Make installer input and link tests work on Windows

* Bound bundled skill classification reads and parse names linearly

* Keep workspace skill roots inside the resolved workspace

* Compare workspace skill roots with native path semantics

* Restrict bundled skill backups to their owner on POSIX

* Keep recovered skills needing attention until they are updated

* Count completed skill updates in the status tooltip

* Sync the runtime localization catalog
2026-10-22 22:35:14 -04:00

474 lines
19 KiB
JavaScript

#!/usr/bin/env node
// Package the server separately from its compatibility launcher.
import { fork, spawnSync } from 'node:child_process'
import { build } from 'esbuild'
import {
buildOrcadEntry,
buildOrcadCli,
buildOrcadLauncher,
externalNativeAddons,
ORCAD_EXTERNAL_MODULES,
ORCAD_CHILD_ENTRY_POINTS,
ORCAD_COMMONJS_MODULE_OPTIONS,
orcadChildOutputFilename
} from './orcad-entry-build.mjs'
import { createRequire } from 'node:module'
import {
chmodSync,
copyFileSync,
cpSync,
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync
} from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import process from 'node:process'
import { smokeProfileStateWorkers } from './profile-state-worker-smoke.mjs'
import { smokeForeignSqliteReaderWorker } from './foreign-sqlite-reader-worker-smoke.mjs'
import { smokeSessionScannerService } from './session-scanner-service-smoke.mjs'
import { materializeWatcherPackage } from './orcad-watcher-package.mjs'
import { stageOrcadWindowsProcessTree } from './orcad-windows-process-tree.mjs'
import { stageOrcadWindowsCliLauncher } from './orcad-windows-cli-launcher.mjs'
import { stageOrcadWindowsSecurity } from './orcad-windows-security.mjs'
import { stageOrcadLinearSdk } from './orcad-linear-sdk-package.mjs'
import { stageOrcadMobileWebBundle } from './orcad-mobile-web-bundle.mjs'
import {
ORCAD_EMOJI_SHORTCODE_DATASET,
ORCAD_CLI_ENTRY_FILENAME,
ORCAD_CLI_PACKAGE_FILENAME,
ORCAD_COMPUTER_USE_RUNTIME_ARTIFACTS,
ORCAD_LAUNCHER_FILENAME,
ORCAD_SERVER_ENTRY_FILENAME,
ORCAD_NODE_PTY_DIR,
ORCAD_NODE_PTY_JS_ARTIFACTS,
ORCAD_NODE_RUNTIME_MARKER_FILENAME,
ORCAD_PARCEL_WATCHER_ENTRY,
ORCAD_PARCEL_WATCHER_NATIVE,
ORCAD_SERVER_TARGET_FILENAME,
ORCAD_VERSION_FILENAME,
orcadNodePtySlotFiles,
orcadRipgrepArtifact
} from '../../src/shared/orcad-artifacts.ts'
import { computeOrcadFullVersion } from './orcad-artifact-version.mjs'
import { NODE_RUNTIME_ASSETS, NODE_RUNTIME_PIN } from '../../src/shared/node-runtime-pin.ts'
import { findSlotProblems, readManifest } from './orcad-prebuild-slot-contents.mjs'
import { orcadAgentBrowserNativeName } from '../../src/shared/orcad-agent-browser-name.ts'
import { runProcessSync } from '@orca/process-host'
import { describeProcessFailure } from './process-failure-message.mjs'
import { resolveBuildSourceCommit } from './build-source-commit.mjs'
const { darkBuildMarkers } = createRequire(import.meta.url)('../dark-build-switches.cjs')
const ROOT = join(import.meta.dirname, '..', '..')
const OUT_DIR = process.env.ORCAD_OUT_DIR
? resolve(process.env.ORCAD_OUT_DIR)
: join(ROOT, 'out', 'orcad')
// Why beside orcad.js: every forked child and worker resolves its entry in the app root, and a
// deployment has no desktop out/main to fall back to. A missing one only shows on a host as a
// feature that never starts (the daemon's absence makes every restart kill every terminal).
const childOutFile = (role) =>
join(OUT_DIR, orcadChildOutputFilename(ORCAD_CHILD_ENTRY_POINTS[role]))
const WATCHER_OUT_FILE = childOutFile('watcher')
const DAEMON_OUT_FILE = childOutFile('daemon')
const OUT_FILE = join(OUT_DIR, ORCAD_LAUNCHER_FILENAME)
const SERVER_OUT_FILE = join(OUT_DIR, ORCAD_SERVER_ENTRY_FILENAME)
const BUILD_TARGET = process.env.ORCAD_BUILD_TARGET
if (!BUILD_TARGET) {
throw new Error('ORCAD_BUILD_TARGET is required; run `pnpm build:orcad`')
}
const [targetPlatform, targetArch] = BUILD_TARGET.split('-')
const targetIsWindows = targetPlatform === 'win32'
const runtimeAsset = NODE_RUNTIME_ASSETS[BUILD_TARGET]
if (!runtimeAsset) {
throw new Error(`Unsupported ORCAD_BUILD_TARGET: ${BUILD_TARGET}`)
}
// Set only when the build host can run this target's runtime.
const nodeRuntimePath = process.env.ORCAD_NODE_RUNTIME_PATH || null
const PREBUILDS_DIR = process.env.ORCAD_PREBUILDS_DIR
if (!PREBUILDS_DIR) {
throw new Error('ORCAD_PREBUILDS_DIR is required; run `pnpm build:orcad`')
}
const AGENT_BROWSER_NAME = orcadAgentBrowserNativeName(
targetPlatform,
targetArch,
BUILD_TARGET.endsWith('-musl') ? 'musl' : 'glibc'
)
const AGENT_BROWSER_SOURCE = join(ROOT, 'node_modules', 'agent-browser', 'bin', AGENT_BROWSER_NAME)
const AGENT_BROWSER_OUTPUT = join(OUT_DIR, AGENT_BROWSER_NAME)
const WATCHER_MODULE_DIR = join(OUT_DIR, 'node_modules', '@parcel', 'watcher')
async function stageParcelWatcher(target) {
const requireFromWatcher = createRequire(
join(ROOT, 'node_modules', '@parcel', 'watcher', 'index.js')
)
const nativeSource = await materializeWatcherPackage(target)
const wrapperSource = requireFromWatcher.resolve('@parcel/watcher/wrapper.js')
mkdirSync(WATCHER_MODULE_DIR, { recursive: true })
await build({
...ORCAD_COMMONJS_MODULE_OPTIONS,
stdin: {
contents:
`const {createWrapper}=require(${JSON.stringify(wrapperSource)});` +
`module.exports=createWrapper(require('./watcher.node'));`,
resolveDir: ROOT,
sourcefile: 'orcad-parcel-watcher-entry.js'
},
bundle: true,
platform: 'node',
target: 'node18',
format: 'cjs',
outfile: join(OUT_DIR, ORCAD_PARCEL_WATCHER_ENTRY),
external: ['./watcher.node'],
minify: true,
sourcemap: false,
logLevel: 'error'
})
copyFileSync(nativeSource, join(OUT_DIR, ORCAD_PARCEL_WATCHER_NATIVE))
}
/** node-pty's JS plus only this target's prebuild slot, laid out the way node-pty's loader looks. */
function stageNodePty(target) {
const problems = findSlotProblems(readManifest(PREBUILDS_DIR), PREBUILDS_DIR, [target])
if (problems.length > 0) {
throw new Error(`[build-orcad] node-pty prebuild for ${target}: ${problems.join('; ')}`)
}
const slotFiles = Object.keys(readManifest(PREBUILDS_DIR).slots[target].files).sort()
const expected = orcadNodePtySlotFiles(target).sort()
if (slotFiles.join('\0') !== expected.join('\0')) {
throw new Error(
`[build-orcad] ${target} prebuild ships ${slotFiles.join(', ')}; orcad-artifacts.ts expects ${expected.join(', ')}`
)
}
const sourceDir = dirname(createRequire(import.meta.url).resolve('node-pty/package.json'))
for (const artifact of ORCAD_NODE_PTY_JS_ARTIFACTS) {
const destination = join(OUT_DIR, artifact)
mkdirSync(dirname(destination), { recursive: true })
copyFileSync(join(sourceDir, artifact.slice(ORCAD_NODE_PTY_DIR.length + 1)), destination)
}
for (const file of expected) {
const destination = join(OUT_DIR, ORCAD_NODE_PTY_DIR, 'build', 'Release', ...file.split('/'))
mkdirSync(dirname(destination), { recursive: true })
copyFileSync(join(PREBUILDS_DIR, target, ...file.split('/')), destination)
if (file === 'spawn-helper') {
chmodSync(destination, 0o755)
}
}
}
rmSync(OUT_DIR, { recursive: true, force: true })
mkdirSync(OUT_DIR, { recursive: true })
writeFileSync(join(OUT_DIR, ORCAD_SERVER_TARGET_FILENAME), `${BUILD_TARGET}\n`)
// Outside the artifact list, so the version hash and every deploy ignore it.
for (const marker of darkBuildMarkers()) {
writeFileSync(join(OUT_DIR, marker.fileName), marker.text)
}
writeFileSync(
join(OUT_DIR, ORCAD_NODE_RUNTIME_MARKER_FILENAME),
`${runtimeAsset.executableSha256}\n`
)
stageNodePty(BUILD_TARGET)
await stageParcelWatcher(BUILD_TARGET)
stageOrcadWindowsProcessTree(ROOT, OUT_DIR, BUILD_TARGET)
stageOrcadWindowsCliLauncher(ROOT, OUT_DIR, BUILD_TARGET)
stageOrcadWindowsSecurity(ROOT, OUT_DIR, BUILD_TARGET, undefined, undefined, {
enabled: process.env.ORCA_REUSE_PREPARED_WINDOWS_SECURITY === 'true',
runtimePath: nodeRuntimePath
})
const emojiDatasetOutput = join(OUT_DIR, ORCAD_EMOJI_SHORTCODE_DATASET)
mkdirSync(dirname(emojiDatasetOutput), { recursive: true })
copyFileSync(
createRequire(import.meta.url).resolve('emojibase-data/en/shortcodes/emojibase.json'),
emojiDatasetOutput
)
await stageOrcadLinearSdk(ROOT, OUT_DIR)
// The desktop template omits it: ~10 MB per target, and orcad already treats it as optional.
if (existsSync(AGENT_BROWSER_SOURCE) && process.env.ORCAD_OMIT_AGENT_BROWSER !== '1') {
copyFileSync(AGENT_BROWSER_SOURCE, AGENT_BROWSER_OUTPUT)
if (!targetIsWindows) {
chmodSync(AGENT_BROWSER_OUTPUT, 0o755)
}
}
{
const [, ripgrepPlatform, ripgrepName] = orcadRipgrepArtifact(BUILD_TARGET).split('/')
const outputDir = join(OUT_DIR, 'ripgrep', ripgrepPlatform)
mkdirSync(outputDir, { recursive: true })
const outputPath = join(outputDir, ripgrepName)
copyFileSync(
join(ROOT, 'node_modules', '@vscode', 'ripgrep-universal', 'bin', ripgrepPlatform, ripgrepName),
outputPath
)
if (!targetIsWindows) {
chmodSync(outputPath, 0o755)
}
}
cpSync(join(ROOT, 'resources', 'licenses', 'ripgrep'), join(OUT_DIR, 'ripgrep', 'licenses'), {
recursive: true
})
cpSync(join(ROOT, 'resources', 'native-chat-visuals'), join(OUT_DIR, 'native-chat-visuals'), {
recursive: true
})
for (const artifact of ORCAD_COMPUTER_USE_RUNTIME_ARTIFACTS) {
mkdirSync(dirname(join(OUT_DIR, artifact)), { recursive: true })
copyFileSync(join(ROOT, 'native', ...artifact.split('/')), join(OUT_DIR, ...artifact.split('/')))
}
stageOrcadMobileWebBundle(OUT_DIR)
/** Why one call per child and not one `outdir` build: esbuild mirrors each entry's source
* directory under `outdir`, and both children must land flat beside orcad.js — that is where
* their runtime resolvers look for them. */
function buildForkedChild(entryPoint, outfile) {
return build({
...ORCAD_COMMONJS_MODULE_OPTIONS,
entryPoints: [entryPoint],
bundle: true,
platform: 'node',
target: 'node18',
format: 'cjs',
outfile,
external: ORCAD_EXTERNAL_MODULES,
plugins: [externalNativeAddons],
metafile: true,
minify: true,
sourcemap: false,
define: {
...ORCAD_COMMONJS_MODULE_OPTIONS.define,
'process.env.NODE_ENV': '"production"'
},
logLevel: 'error'
})
}
const childResults = await Promise.all(
Object.values(ORCAD_CHILD_ENTRY_POINTS).map((entryPoint) =>
buildForkedChild(join(ROOT, entryPoint), join(OUT_DIR, orcadChildOutputFilename(entryPoint)))
)
)
const result = await buildOrcadEntry(SERVER_OUT_FILE)
const cliResult = await buildOrcadCli(join(OUT_DIR, ORCAD_CLI_ENTRY_FILENAME))
const { version: cliVersion } = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8'))
// Why commit: the stamp keys one serve-supervisor snapshot per build.
const cliPackage = {
type: 'commonjs',
private: true,
version: cliVersion,
commit: resolveBuildSourceCommit()
}
writeFileSync(join(OUT_DIR, ORCAD_CLI_PACKAGE_FILENAME), `${JSON.stringify(cliPackage)}\n`)
const launcherResult = await buildOrcadLauncher(OUT_FILE)
const output = Object.values(result.metafile.outputs).find(
(o) => o.entryPoint === 'src/main/orcad/main.ts'
)
// Why check `original` and not just `path`: when electron is bundleable, esbuild
// rewrites `path` to the resolved file under node_modules and the naive check passes
// while the package is very much in the bundle.
// Why both metafiles: the forked children ship in the same deployment and runtime. A
// daemon-entry that reached electron would fail at fork time, on the
// path whose whole point is that terminals survive.
function collectImporters(metafiles, matches) {
const importers = new Set()
for (const metafile of metafiles) {
for (const [file, info] of Object.entries(metafile.inputs)) {
for (const imported of info.imports ?? []) {
if (matches(imported.original ?? imported.path)) {
importers.add(file)
}
}
}
}
return importers
}
const metafiles = [
launcherResult.metafile,
result.metafile,
cliResult.metafile,
...childResults.map((child) => child.metafile)
]
const electronImporters = collectImporters(
metafiles,
(specifier) => specifier === 'electron' || specifier.startsWith('electron/')
)
const sqliteImporters = collectImporters(metafiles, (specifier) => specifier === 'node:sqlite')
const graphErrors = []
if (electronImporters.size > 0) {
graphErrors.push(
`${electronImporters.size} module(s) in the bundle import electron:\n${[...electronImporters]
.map((file) => ` - ${file}`)
.join('\n')}`
)
}
if (sqliteImporters.size > 0) {
graphErrors.push(
`${sqliteImporters.size} module(s) in the bundle import node:sqlite:\n${[...sqliteImporters]
.map((file) => ` - ${file}`)
.join('\n')}`
)
}
if (graphErrors.length > 0) {
console.error(`[build-orcad] ${graphErrors.join('\n')}`)
// Why this can exceed the ratchet baseline: the ratchet measures the graph reachable
// from orca-runtime + runtime-rpc, but this entry also imports ipc/pty directly to
// install the PTY controller. Once orcad ships, it should become a ratchet entry
// point so the two numbers cannot drift.
process.exitCode = 1
} else {
// Why smoke-load and not just read the metafile: the import scan proves no module
// *names* electron, but the rollback graph can still fail to resolve under plain Node — a
// dynamic require, a missing native, a top-level throw. The plain-node-entry-guard
// smoke-loads its entries for exactly this reason, and orcad cannot join that guard
// because it is an esbuild artifact rather than a rollup input.
// Why an exit code and not a message match: these bundles are minified onto one line, so
// Node's uncaught-exception report echoes that whole line — which contains every string
// literal in the bundle. A crash therefore "matches" any expected message, and a textual
// assertion passes against a bundle that never loaded.
const smoke = spawnSync(process.execPath, [SERVER_OUT_FILE, '--orcad-smoke-load-check'], {
encoding: 'utf8',
timeout: 60_000
})
const smokeOutput = `${smoke.stdout ?? ''}${smoke.stderr ?? ''}`
if (smoke.error || smoke.signal || smoke.status !== 0) {
console.error(
`[build-orcad] the bundle lost Node load compatibility.\n` +
`Expected a clean load-check exit, got status=${smoke.status ?? 'none'} ` +
`signal=${smoke.signal ?? 'none'} ` +
`error=${smoke.error?.message ?? 'none'}\n${smokeOutput.slice(0, 2000)}`
)
process.exitCode = 1
}
const providerSmoke = runProcessSync({
program: process.execPath,
args: [SERVER_OUT_FILE, '--orcad-structured-provider-load-check'],
timeoutMs: 60_000
})
if (providerSmoke.code !== 0 || providerSmoke.timedOut || providerSmoke.outputTruncated) {
console.error(
`[build-orcad] the packaged structured provider failed to load.\n${describeProcessFailure(providerSmoke)}`
)
process.exitCode = 1
}
// Why require + parseArgs and not a real daemon: requiring the bundle evaluates every
// top-level import, and calling its exported argv parser proves the entry's own code is
// there rather than a graph that merely resolved. Booting one would need a socket, a
// token and a PTY — `smoke:orcad-terminal` does that end to end, through orcad.
// The verdict is carried by the exit code for the same minification reason as above.
const daemonSmoke = spawnSync(
process.execPath,
[
'-e',
`const mod = require(${JSON.stringify(DAEMON_OUT_FILE)})\n` +
`if (typeof mod.parseArgs !== 'function') { process.exit(3) }\n` +
`try { mod.parseArgs([]); process.exit(4) } catch { process.exit(0) }`
],
{
encoding: 'utf8',
timeout: 60_000,
env: { ...process.env, ORCA_DAEMON_ENTRY_LOAD_CHECK: '1' }
}
)
const daemonSmokeOutput = `${daemonSmoke.stdout ?? ''}${daemonSmoke.stderr ?? ''}`
if (daemonSmoke.error || daemonSmoke.signal || daemonSmoke.status !== 0) {
console.error(
`[build-orcad] the daemon child lost Node load compatibility.\n` +
`Expected a clean load check, got status=${daemonSmoke.status ?? 'none'} ` +
`signal=${daemonSmoke.signal ?? 'none'} ` +
`error=${daemonSmoke.error?.message ?? 'none'}\n${daemonSmokeOutput.slice(0, 2000)}`
)
process.exitCode = 1
}
const watcherFailure = nodeRuntimePath ? await smokeLoadWatcherChild(nodeRuntimePath) : null
if (watcherFailure) {
console.error(
`[build-orcad] the watcher child failed under the bundled runtime.\n${watcherFailure}`
)
process.exitCode = 1
}
}
/** @type {{ label: string, smoke: (outDir: string, options?: { runtimePath?: string }) => unknown }[]} */
const workerSmokes = [
{ label: 'profile state worker', smoke: smokeProfileStateWorkers },
{ label: 'foreign SQLite reader worker', smoke: smokeForeignSqliteReaderWorker },
{ label: 'session scanner service', smoke: smokeSessionScannerService }
]
// Each shipped child is checked under the build's Node and, when set, the pinned runtime.
for (const { label, smoke } of workerSmokes) {
try {
await smoke(OUT_DIR)
if (nodeRuntimePath) {
await smoke(OUT_DIR, { runtimePath: nodeRuntimePath })
}
} catch (error) {
console.error(`[build-orcad] ${label} check failed:`, error)
process.exitCode = 1
}
}
// Why a content hash and not ORCAD_VERSION alone: the remote install directory is keyed on
// this string, so two different builds carrying one version would share a directory — and an
// already-`.install-complete` dir is never re-uploaded. The deploy would silently run stale
// bytes while reporting the new version.
if (process.exitCode !== 1) {
const fullVersion = computeOrcadFullVersion(OUT_DIR, {
target: BUILD_TARGET,
agentBrowserFilename: AGENT_BROWSER_NAME
})
writeFileSync(join(OUT_DIR, ORCAD_VERSION_FILENAME), fullVersion)
console.log(
`[build-orcad] ok — ${fullVersion}, ${(output.bytes / 1024 / 1024).toFixed(2)} MB, ${Object.keys(output.inputs).length} modules, zero electron and node:sqlite imports, Node ${NODE_RUNTIME_PIN.version} referenced.`
)
}
// Verify the shipped native watcher actually subscribes under the bundled runtime.
async function smokeLoadWatcherChild(runtimePath) {
const probeDir = mkdtempSync(join(tmpdir(), 'orcad-watcher-smoke-'))
const child = fork(WATCHER_OUT_FILE, [], {
execPath: runtimePath,
stdio: ['ignore', 'ignore', 'pipe', 'ipc'],
windowsHide: true
})
let stderr = ''
let subscribed = false
child.stderr?.on('data', (chunk) => {
stderr += String(chunk)
})
try {
return await new Promise((resolve) => {
const timer = setTimeout(() => {
child.kill('SIGKILL')
resolve(`Watcher did not complete its subscription within 30s.\n${stderr.slice(0, 2000)}`)
}, 30_000)
const settle = (failure) => {
clearTimeout(timer)
resolve(failure)
}
child.on('message', (message) => {
subscribed ||= message?.op === 'subscribed'
if (message?.op === 'subscribed' || message?.op === 'subscribe-failed') {
child.disconnect()
}
})
child.on('error', (error) => settle(`fork failed: ${error.message}`))
// Why exit and not disconnect: the child exits 0 on disconnect, so a non-zero code
// or a signal here is a load failure rather than a clean teardown.
child.on('exit', (code, signal) =>
settle(
code === 0 && subscribed
? null
: `subscribed=${subscribed} exit code=${code} signal=${signal}\n${stderr.slice(0, 2000)}`
)
)
child.send({ op: 'subscribe', id: 1, dir: probeDir, opts: {} })
})
} finally {
rmSync(probeDir, { recursive: true, force: true })
}
}