mirror of
https://github.com/stablyai/orca.git
synced 2026-09-23 00:02:29 +00:00
* fix(ports): stop joining an undefined resourcesPath on a non-Electron host `resolveWorkerEntryPath` branched on `isPackaged` alone and joined `process.resourcesPath`. orcad reports `isPackaged` true — correctly, it is a production build, and ~15 consumers read it that way to gate HTTPS-only skill downloads and the real CLI name — but `process.resourcesPath` is Electron-only and `undefined` under plain Node. So the packaged branch threw `TypeError [ERR_INVALID_ARG_TYPE]: The "path" argument must be of type string` where a clean "worker unavailable" was the honest outcome. The type said `resourcesPath: string`, which is how it went unnoticed; it is now `string | undefined`, so the compiler carries the fact. A host with no Electron resources tree has no asar to look in, so it falls back to the module directory and lets the caller report a missing worker. Found by the item 1 agent while auditing the same `isPackaged` defect class in the watcher. Verified in both directions: reverting the guard reproduces the TypeError. * feat(orcad): prove node-pty loads before anything requires it Of the two ways node-pty fails, only one is catchable. A missing module throws MODULE_NOT_FOUND. A module built against the wrong libc or Node ABI is refused by the dynamic loader, and in the worst case takes the process down before any handler exists — that is #9902, which crashed the desktop app on Ubuntu 20.04 before a window appeared. There was no libc or ABI precondition anywhere in the tree. So orcad now proves the load in a CHILD process, from main.ts, before anything requires node-pty. Whatever the child does — throw, abort, die on a signal — is data rather than our own death, and the operator gets a sentence naming the host's libc, Node ABI and prebuild slot plus the command to run. Proven-unloadable exits 78 (EX_CONFIG), so a supervisor does not restart an unequippable host forever. A probe that never answered is unverifiable, not blocked: refusing to boot on an inconclusive signal would take down hosts that work. The child dlopens the file node-pty would have chosen, before requiring the package. node-pty's loader walks several directories and rethrows only the LAST error, so a refused binary reads as "Cannot find module ./prebuilds/..." — which sends the operator to install a module that is already there. It also reports through stdout: node echoes the whole -e source above a stack trace, and matching tokens against stderr made the probe's own source text answer for the verdict. Verdicts reach clients as a terminal_unavailable degradation alongside the existing browser_unavailable one, through the same cause-registry shape. degradations[].code is now an open vocabulary; clients already render only `message`. Prebuilds are compiled from PATCHED sources — the patch IS the glibc-floor fix, so an upstream tarball reproduces #9902 — into linux-{x64,arm64}-{glibc,musl} and darwin-{x64,arm64} slots. libc is in the slot name because node-pty's loader falls back to prebuilds/<platform>-<arch> and cannot tell glibc from musl. orcad installs the matching slot at boot, so a host with no compiler serves terminals. The relay's five pure toolchain-diagnosis functions moved to a transport-free module so the Node bundle can reuse them without dragging ssh2 in behind them; the relay keeps its API by re-export. macOS gets `xcode-select --install` rather than the cross-distro apt/dnf/pacman/apk menu, every line of which is wrong there. * test(orcad): pin the node-pty precondition to ground truth, not a prepared host CI's test shard runs `vitest` directly, so `ensure-native-runtime --runtime=node` never prepares node-pty for the Node ABI — `degraded` is the correct verdict there, and asserting 'ok' encoded an environment the shard does not have. Asserting whatever it returned would be vacuous, so the expectation is now derived from an independent require() of node-pty. Verified it still bites: forcing the precondition to always report 'ok' fails the suite. * feat(orcad): run the terminal daemon, and the ops contract around it orcad declared `canRecoverPersistentLocalPtys: () => false` because it did not run the terminal daemon, so every restart, update and rollback SIGKILLed every running terminal — on the host whose selling point is that work survives the client going away. That is the one property `ssh-execution-boundary.md` recommends the peer model for. Item 4 — the daemon: - Port the launch path off electron: `daemon-init.ts`, `daemon-host-relocation.ts` and `observability/logs-directory.ts` now read the `AppEnvironment` port. Relocation additionally asks whether the app root is an asar archive rather than whether the build is packaged, so a Node host answering `isPackaged() === true` no longer walks into an Electron-only NSIS-escape path (same precedent as `parcel-watcher-entry-path.ts`). - `build-orcad.mjs` emits `daemon-entry.js` beside `orcad.js`, scans the forked children's metafiles for electron/node:sqlite, and load-checks the child under plain Node. - orcad spawns and adopts the daemon; shutdown disconnects and never kills it. `canRecoverPersistentLocalPtys` now reads the live provider and is false under degraded routing, where fresh terminals would die with the process. Item 3 — the ops contract (docs/reference/orcad-operations.md): - Bind policy: `--bind`, default loopback, pinned so neither `orca serve`'s wide default nor the connected-device widen can override it, and so a paired client cannot rebind the listener from outside. - Instance lock on the data root before profile load, scoped to the runtime role so it never refuses a restart that a live daemon makes worthwhile. - Supervision: exit codes a supervisor can act on (78 = do not retry), second-signal escalation, a shutdown deadline, and crash-loop containment on daemon respawn. - Health in the readiness payload: build hash, Node ABI, and a PTY self-test that spans both processes — the daemon spawns a real PTY in its own process and the verdict crosses its socket. Both bundle load-checks now assert on exit codes: these bundles are minified onto one line, so Node's uncaught-exception report echoes every string literal in the bundle and the previous message match passed against a bundle that never loaded. * feat(orcad): deploy, activate and roll back a versioned orcad install Plan items 6 and 7 from docs/design/shipping-orcad.html. Install reuses the relay's transaction verbatim — per-version lock, staged SFTP write, .install-complete sentinel, stale-lock recovery — under a parameterized namespace, so orcad-<v>/ sits beside relay-<v>/ permanently (§06). Parameterizing GC is the trap that creates: each model now collects only its own directories, enforced twice (prefix-scoped remote listing plus a local ownership re-check), and a client picks its model from how the host is registered, never from what it finds on disk. Activation is separate from installation, because a versioned directory selects nothing. A candidate is launched, publishes orca_server_ready, and only becomes active if its cross-process health payload passes: right build hash, listening, daemon live, PTY self-test green. A rejected candidate is stopped and the incumbent restarted, so a careful deploy cannot cause the outage it was being careful about. Update and rollback are shaped by the daemon. An update restarts orcad, the daemon outlives it, and the surviving daemon was forked from the outgoing bundle — so live terminals defer the update rather than proceed, and GC pins the active version, the rollback target and the live daemon's bundle. Orca's persisted state carries no schema version, so rollback restores a pre-activation snapshot rather than trusting backward-readability; the point past which it is unsafe is the first terminal created after activation, which the snapshot cannot describe and the surviving daemon still owns. Running the generated shell for real found two bugs the text assertions missed: tar members re-quoted inside a shell variable captured nothing, and kill -0 reports a zombie as alive. * test(orcad): assert the precondition is self-consistent, not environment-shaped The real-host case cannot predict a status: CI's shard runs vitest directly, so node-pty is never built for the Node ABI and 'degraded' is correct there, while a prepared checkout gives 'ok'. The previous attempt used require('node-pty') as ground truth, which resolves the JS wrapper while the native binding loads lazily — it proved strictly less than the precondition checks, and failed CI for exactly that reason. What is invariant on a host with node-pty installed: never 'blocked', and never a degraded verdict carrying an unestablished reason. The injected-input tests keep the logic coverage. * fix(orcad): drop an eslint-disable the rule no longer needs * test(orcad): separate slot placement from the load verdict Both remaining CI failures were the same shape: tests reaching into node_modules for a pty.node that only exists after `ensure-native-runtime --runtime=node`, which CI's shard never runs because it invokes vitest directly. Slot *placement* is the logic worth checking on every host, so it now uses a synthetic payload and asserts the verdict stays honest about not loading. The three assertions that genuinely need a Node-ABI binding are gated on it existing. Verified: breaking slot installation fails both placement tests; with the real pty.node hidden the file is 17 passed / 3 skipped instead of ENOENT. * test(orcad): gate the load-dependent cases on a real load, not on the file existing CI ships a pty.node built for Electron's ABI, so existsSync was true while require still failed — the gate ran exactly the tests that host can never satisfy. It now probes the binding in a child process, so a bad one cannot take the runner down. The self-consistency assertion also allowed too little: 'blocked' is the honest verdict for a corrupt binding, alongside 'ok' on a prepared host and 'degraded' on an unprepared one. What stays invariant is that anything other than 'ok' names an established cause, so a terminal is never declined for a reason nobody worked out. Verified against all three host states: prepared (19 passed), unprepared, and a corrupt binding (17 passed / 3 skipped, no failures). * test(orcad): gate on the whole premise — binding AND spawn-helper CI has a loadable pty.node but no spawn-helper, and a slot without the helper is legitimately 'degraded'. So the previous gate let a test run whose premise ('a complete slot yields ok') that host cannot satisfy. Verified in both states: with the helper present 19 pass; with it removed the load-dependent cases skip (17 passed / 3 skipped) instead of failing. * fix(orcad): preserve degradation types after rebase
366 lines
14 KiB
TypeScript
366 lines
14 KiB
TypeScript
/**
|
|
* Prove `node-pty` can be loaded on this host BEFORE anything in the process requires it.
|
|
*
|
|
* Why this exists: of the two ways node-pty fails, only one is catchable. A missing
|
|
* module throws `MODULE_NOT_FOUND` and a caller can degrade. A module that is present but
|
|
* built against the wrong libc or Node ABI is refused by the dynamic loader, and in the
|
|
* worst case takes the process down before any handler exists — that is #9902, which
|
|
* crashed the desktop app on Ubuntu 20.04 before a window appeared
|
|
* (docs/reference/linux-glibc-compatibility.md).
|
|
*
|
|
* So the load happens in a CHILD process. Whatever the child does — throw, abort, die on
|
|
* a signal — is data to us rather than our own death, and the operator gets a sentence
|
|
* naming what to change instead of a loader stack trace.
|
|
*
|
|
* The cost is one short-lived `node -e` at startup. That is the price of turning an
|
|
* uncatchable failure into a catchable one, and it is paid once per boot.
|
|
*/
|
|
import { existsSync, accessSync, constants } from 'node:fs'
|
|
import { dirname, join } from 'node:path'
|
|
import process from 'node:process'
|
|
import { runProcessSync, type ProcessResult } from '../../shared/child-process/run-process'
|
|
import type { RuntimeTerminalUnavailableReason } from '../../shared/runtime-types'
|
|
import {
|
|
buildToolchainProbeCommand,
|
|
parseBuildToolchainProbe,
|
|
toolchainInstallHintLines
|
|
} from '../ssh/build-toolchain-diagnosis'
|
|
import {
|
|
detectNativeHostAbi,
|
|
nativeSlotName,
|
|
parseNodeAbiMismatch,
|
|
parseUnmetGlibcVersion,
|
|
type NativeHostAbi
|
|
} from './native-host-abi'
|
|
import { installPrebuiltSlot, type PrebuiltSlotOutcome } from './node-pty-prebuilt-slot'
|
|
|
|
// Why every verdict travels on STDOUT: node echoes the whole `-e` source into stderr
|
|
// before the stack trace, so any substring test against stderr also matches this file's
|
|
// own token strings. stdout carries only what the child chose to print.
|
|
const PROBE_OK_TOKEN = 'ORCA_NODE_PTY_LOAD_OK'
|
|
const NO_BINARY_TOKEN = 'ORCA_NODE_PTY_NO_BINARY'
|
|
const LOAD_ERROR_TOKEN = 'ORCA_NODE_PTY_LOAD_ERROR'
|
|
const PROBE_TIMEOUT_MS = 20_000
|
|
|
|
/**
|
|
* `ok` — proved loadable. `degraded` — loads, but something only spawn-time needs is
|
|
* broken, so the host should still serve everything else. `blocked` — proved unloadable,
|
|
* so nothing in this process may require it. `unverifiable` — the probe itself did not
|
|
* answer, which is not evidence either way.
|
|
*
|
|
* Why `unverifiable` is separate from `blocked`: a probe that times out or cannot spawn
|
|
* says nothing about node-pty, and refusing to boot on it would brick working hosts for
|
|
* a reason that was never established. Same verdict discipline as
|
|
* docs/reference/ssh-execution-boundary.md — loss of contact is not proof of death.
|
|
*/
|
|
export type NodePtyPreconditionStatus = 'ok' | 'degraded' | 'blocked' | 'unverifiable'
|
|
|
|
export type NodePtyPreconditionVerdict = {
|
|
status: NodePtyPreconditionStatus
|
|
slot: string
|
|
abi: NativeHostAbi
|
|
reason?: RuntimeTerminalUnavailableReason
|
|
detail?: string
|
|
/** What the slot install did, when one was attempted. */
|
|
prebuilt?: PrebuiltSlotOutcome
|
|
}
|
|
|
|
export type NodePtyProbeFailure = {
|
|
status: 'blocked' | 'unverifiable'
|
|
reason: RuntimeTerminalUnavailableReason
|
|
detail: string
|
|
}
|
|
|
|
/**
|
|
* Read the child's exit into a cause. Pure, so every failure shape is testable from a
|
|
* host that cannot reproduce it — the whole point, since the shapes that matter belong
|
|
* to Alpine and Ubuntu 20.04.
|
|
*/
|
|
export function classifyNodePtyProbeResult(
|
|
result: Pick<ProcessResult, 'code' | 'signal' | 'stdout' | 'stderr' | 'timedOut'>
|
|
): NodePtyProbeFailure | null {
|
|
const stdout = result.stdout
|
|
if (result.code === 0 && stdout.includes(PROBE_OK_TOKEN)) {
|
|
return null
|
|
}
|
|
if (result.timedOut) {
|
|
return {
|
|
status: 'unverifiable',
|
|
reason: 'unknown',
|
|
detail: 'the node-pty load probe did not finish in time, so nothing was established'
|
|
}
|
|
}
|
|
// Why signal before anything the child said: a binary that aborts or segfaults inside
|
|
// the loader never reaches the catch, and often prints nothing at all. That silence is
|
|
// exactly the uncatchable case this probe is a separate process for.
|
|
if (result.signal) {
|
|
return {
|
|
status: 'blocked',
|
|
reason: 'load_crashed',
|
|
detail: `the load probe was killed by ${result.signal}`
|
|
}
|
|
}
|
|
if (stdout.includes(NO_BINARY_TOKEN)) {
|
|
return {
|
|
status: 'blocked',
|
|
reason: 'dependency_missing',
|
|
detail: 'node-pty is installed but has no compiled binary for this platform'
|
|
}
|
|
}
|
|
const reported = readReportedLoadError(stdout)
|
|
if (reported !== null) {
|
|
return classifyLoaderMessage(reported)
|
|
}
|
|
return {
|
|
status: 'blocked',
|
|
reason: 'load_failed',
|
|
detail: firstLine(result.stderr) || `the load probe exited with code ${result.code}`
|
|
}
|
|
}
|
|
|
|
/** The message the child caught, or null when it never got that far. */
|
|
function readReportedLoadError(stdout: string): string | null {
|
|
const line = stdout.split('\n').find((candidate) => candidate.startsWith(LOAD_ERROR_TOKEN))
|
|
if (!line) {
|
|
return null
|
|
}
|
|
try {
|
|
return JSON.parse(line.slice(LOAD_ERROR_TOKEN.length).trim()) as string
|
|
} catch {
|
|
return line.slice(LOAD_ERROR_TOKEN.length).trim()
|
|
}
|
|
}
|
|
|
|
/** Read a dynamic-loader message. Pure, so shapes this host cannot reproduce are testable. */
|
|
export function classifyLoaderMessage(message: string): NodePtyProbeFailure {
|
|
const abiMismatch = parseNodeAbiMismatch(message)
|
|
if (abiMismatch) {
|
|
return {
|
|
status: 'blocked',
|
|
reason: 'abi_mismatch',
|
|
detail: `built for Node ABI ${abiMismatch.built}, this host runs ABI ${abiMismatch.host}`
|
|
}
|
|
}
|
|
const unmetGlibc = parseUnmetGlibcVersion(message)
|
|
if (unmetGlibc) {
|
|
return {
|
|
status: 'blocked',
|
|
reason: 'libc_floor',
|
|
detail: `the binary requires GLIBC_${unmetGlibc}`
|
|
}
|
|
}
|
|
if (/(GLIBCXX_|CXXABI_)[0-9.]+'? not found/.test(message)) {
|
|
return { status: 'blocked', reason: 'libc_floor', detail: firstLine(message) }
|
|
}
|
|
if (/MODULE_NOT_FOUND|Cannot find module/.test(message)) {
|
|
return { status: 'blocked', reason: 'dependency_missing', detail: firstLine(message) }
|
|
}
|
|
return { status: 'blocked', reason: 'load_failed', detail: firstLine(message) }
|
|
}
|
|
|
|
/**
|
|
* Why not simply the first non-empty line: when the child dies without catching, node
|
|
* prints the offending source line and a caret before the error, so line one is the
|
|
* script rather than the diagnosis. Prefer the first line that reads as an error.
|
|
*/
|
|
function firstLine(text: string): string {
|
|
const lines = text.split('\n').filter((candidate) => candidate.trim().length > 0)
|
|
const errorLine = lines.find((candidate) => /^[A-Za-z]*(Error|Exception):/.test(candidate.trim()))
|
|
return (errorLine ?? lines[0] ?? text).trim().slice(0, 400)
|
|
}
|
|
|
|
/**
|
|
* The script the child runs.
|
|
*
|
|
* Why it dlopens the file itself rather than trusting node-pty's loader: that loader
|
|
* tries several directories and rethrows only the LAST error, so a `pty.node` the
|
|
* dynamic loader refused is reported as `Cannot find module './prebuilds/...'`. Acting on
|
|
* that sends the operator to install a module that is already there. The dlopen has to
|
|
* come BEFORE `require(index.js)` for the same reason: node-pty's unixTerminal calls the
|
|
* loader at module scope, so requiring the package first re-wraps the error we came for.
|
|
*
|
|
* Why it catches and prints instead of throwing: a thrown error reaches us as a stack
|
|
* trace with the script source echoed above it, and the message we need is then one line
|
|
* inside a blob that also contains these very tokens. What the child cannot catch — a
|
|
* loader that aborts the process — still reaches us as a signal, which is the case this
|
|
* whole indirection exists for.
|
|
*/
|
|
export function buildNodePtyLoadProbeScript(nodePtyDir: string): string {
|
|
const entry = JSON.stringify(join(nodePtyDir, 'lib', 'index.js'))
|
|
const utils = JSON.stringify(join(nodePtyDir, 'lib', 'utils.js'))
|
|
const root = JSON.stringify(nodePtyDir)
|
|
// Same directory order node-pty's own loader walks, so the file opened here is the file
|
|
// it would load. Windows defers conpty.node to the first spawn, which is why the name is
|
|
// chosen the way node-pty chooses it rather than always being 'pty'.
|
|
return [
|
|
`const fs=require('fs'),p=require('path');`,
|
|
`const n=process.platform==='win32'&&Number(require('os').release().split('.')[2])>=18309?'conpty':'pty';`,
|
|
`let f=null;`,
|
|
`for(const d of ['build/Release','build/Debug','prebuilds/'+process.platform+'-'+process.arch]){`,
|
|
`for(const r of [${root},p.join(${root},'lib')]){`,
|
|
`const c=p.join(r,d,n+'.node');if(fs.existsSync(c)){f=c;break}}if(f)break}`,
|
|
`if(!f){console.log(${JSON.stringify(NO_BINARY_TOKEN)});process.exit(3)}`,
|
|
`try{`,
|
|
`process.dlopen({exports:{}},f);`,
|
|
`require(${entry});`,
|
|
`require(${utils}).loadNativeModule(n);`,
|
|
`console.log(${JSON.stringify(PROBE_OK_TOKEN)}+' '+p.dirname(f));`,
|
|
`}catch(e){`,
|
|
`console.log(${JSON.stringify(LOAD_ERROR_TOKEN)}+' '+JSON.stringify(String((e&&e.message)||e)));`,
|
|
`process.exit(4)}`
|
|
].join('')
|
|
}
|
|
|
|
function resolveNodePtyDir(): string | null {
|
|
try {
|
|
// Why require.resolve and not import: resolution only — the load itself happens in
|
|
// the child process, which is the whole point of the precondition.
|
|
return dirname(require.resolve('node-pty/package.json'))
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
/** Local equivalent of the relay's remote toolchain probe, reusing its pure half. */
|
|
export function probeLocalBuildToolchainHints(platform: NodeJS.Platform): string[] {
|
|
if (platform === 'win32') {
|
|
return []
|
|
}
|
|
// Why macOS is not routed through the relay's hints: that function answers with a
|
|
// cross-distro apt/dnf/pacman/apk menu when it finds no package manager, and none of
|
|
// those lines is the macOS answer. Printing them here would be confidently wrong.
|
|
if (platform === 'darwin') {
|
|
return [' xcode-select --install']
|
|
}
|
|
try {
|
|
const result = runProcessSync({
|
|
program: '/bin/sh',
|
|
args: ['-c', buildToolchainProbeCommand()],
|
|
timeoutMs: 10_000
|
|
})
|
|
return toolchainInstallHintLines(parseBuildToolchainProbe(result.stdout))
|
|
} catch {
|
|
return []
|
|
}
|
|
}
|
|
|
|
export function checkNodePtyPrecondition(
|
|
options: { nodePtyDir?: string | null; abi?: NativeHostAbi; prebuildsDir?: string | null } = {}
|
|
): NodePtyPreconditionVerdict {
|
|
const abi = options.abi ?? detectNativeHostAbi()
|
|
const slot = nativeSlotName(abi)
|
|
// Why `in` and not `??`: an explicit `null` means "this host cannot resolve node-pty",
|
|
// which is a case tests must be able to state. `??` would silently re-detect instead.
|
|
const nodePtyDir = 'nodePtyDir' in options ? options.nodePtyDir : resolveNodePtyDir()
|
|
if (!nodePtyDir) {
|
|
return {
|
|
status: 'blocked',
|
|
slot,
|
|
abi,
|
|
reason: 'dependency_missing',
|
|
detail: 'node-pty is not resolvable from this install'
|
|
}
|
|
}
|
|
|
|
// Why install before probing: on a toolchain-free deployment the compiled binary does
|
|
// not exist yet, and the shipped slot is the only thing that can make the probe pass.
|
|
let prebuilt: PrebuiltSlotOutcome | undefined
|
|
if (!existsSync(join(nodePtyDir, 'build', 'Release', 'pty.node'))) {
|
|
prebuilt = installPrebuiltSlot({
|
|
abi,
|
|
nodePtyDir,
|
|
...(options.prebuildsDir === undefined ? {} : { prebuildsDir: options.prebuildsDir })
|
|
})
|
|
}
|
|
|
|
let result: ProcessResult
|
|
try {
|
|
result = runProcessSync({
|
|
program: process.execPath,
|
|
args: ['-e', buildNodePtyLoadProbeScript(nodePtyDir)],
|
|
timeoutMs: PROBE_TIMEOUT_MS
|
|
})
|
|
} catch (error) {
|
|
return {
|
|
status: 'unverifiable',
|
|
slot,
|
|
abi,
|
|
reason: 'unknown',
|
|
detail: `the node-pty load probe could not be started: ${(error as Error).message}`,
|
|
...(prebuilt ? { prebuilt } : {})
|
|
}
|
|
}
|
|
const failure = classifyNodePtyProbeResult(result)
|
|
if (failure) {
|
|
return {
|
|
status: failure.status,
|
|
slot,
|
|
abi,
|
|
reason: failure.reason,
|
|
detail: failure.detail,
|
|
...(prebuilt ? { prebuilt } : {})
|
|
}
|
|
}
|
|
|
|
// Loaded. The remaining way terminals fail is spawn-time: node-pty posix_spawns
|
|
// build/Release/spawn-helper, and a missing one turns every terminal.create into ENOENT
|
|
// on a host that otherwise looks healthy. That is a degradation, not a boot blocker.
|
|
const loadedDir = result.stdout.split(PROBE_OK_TOKEN)[1]?.trim().split('\n')[0]?.trim()
|
|
if (abi.platform !== 'win32') {
|
|
const helper = join(loadedDir || join(nodePtyDir, 'build', 'Release'), 'spawn-helper')
|
|
if (!isExecutableFile(helper)) {
|
|
return {
|
|
status: 'degraded',
|
|
slot,
|
|
abi,
|
|
reason: 'spawn_helper_missing',
|
|
detail: `expected an executable at ${helper}`,
|
|
...(prebuilt ? { prebuilt } : {})
|
|
}
|
|
}
|
|
}
|
|
return { status: 'ok', slot, abi, ...(prebuilt ? { prebuilt } : {}) }
|
|
}
|
|
|
|
function isExecutableFile(path: string): boolean {
|
|
try {
|
|
accessSync(path, constants.X_OK)
|
|
return true
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
/** The operator-facing report. Names the host, the cause, and the next action. */
|
|
export function formatNodePtyPreconditionReport(
|
|
verdict: NodePtyPreconditionVerdict,
|
|
message: string,
|
|
toolchainHints: string[] = []
|
|
): string {
|
|
const { abi } = verdict
|
|
const host = [
|
|
`platform ${abi.platform}/${abi.arch}`,
|
|
abi.libc === 'none'
|
|
? null
|
|
: `libc ${abi.libc}${abi.glibcVersion ? ` ${abi.glibcVersion}` : ''}`,
|
|
`Node ABI ${abi.nodeAbi}`,
|
|
`prebuild slot ${verdict.slot}`
|
|
]
|
|
.filter((part): part is string => part !== null)
|
|
.join(', ')
|
|
const lines = [message, '', `Host: ${host}`]
|
|
if (verdict.prebuilt && !verdict.prebuilt.installed) {
|
|
lines.push(
|
|
verdict.prebuilt.why === 'no-slot'
|
|
? `No shipped prebuilt matches slot ${verdict.slot}.`
|
|
: verdict.prebuilt.why === 'no-prebuilds-dir'
|
|
? 'This install ships no prebuilds directory.'
|
|
: `Shipped prebuilds are unusable here: ${verdict.prebuilt.detail ?? 'ABI mismatch'}.`
|
|
)
|
|
}
|
|
if (toolchainHints.length > 0) {
|
|
lines.push('', 'To build node-pty on this host, install a C/C++ toolchain:', ...toolchainHints)
|
|
}
|
|
return lines.join('\n')
|
|
}
|