mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 09:07:40 +00:00
* build: share one pnpm virtual store across worktrees, off under CI
Each worktree carried a full node_modules; the global virtual store links
one slot per package version instead. pnpm keeps an explicit setting on
under CI, so a pnpmfile hook turns it off there: CI packages from,
caches and verifies a project-local node_modules.
* fix(build): keep shared electron and windows-process-tree dirs untouched under GVS
Every worktree runs Electron from the one shared electron package, so the
per-repo dist cache must not swap that dist and a foreign-arch install must
not land in it. The relay addon builder now repairs and compiles a private
copy instead of the shared windows-process-tree dir.
* fix(build): accept CLI runtime imports reached through an in-artifact store link
Under the global virtual store node_modules/<pkg> realpaths into a store
outside the repo. The check still rejects anything not reached through a
node_modules link inside the artifact, such as a stray ~/node_modules.
* docs: explain the global virtual store layout and the ~/node_modules hazard
* test: compare CLI closure paths with POSIX separators so the suite passes on Windows
* build: keep Windows on the normal pnpm layout unless ORCA_PNPM_GLOBAL_VIRTUAL_STORE=1
Store links are true symlinks on Windows, and installs without admin or
Developer Mode are unverified; CI still forces the normal layout first.
* fix(build): close the remaining shared-slot gaps under the global virtual store
- verify-skills-cli-runtime resolves each import from the importer's realpath,
as Node does, so an artifact-local copy can't mask a ~/node_modules load.
- A concurrent sibling install of the shared Electron dist is kept, not swapped out.
- rebuild:node goes through ensure-native-runtime, which builds a shared
node-pty beside the slot instead of node-gyp cleaning its build/ dir.
* fix(build): keep pnpm rebuild:node a forced rebuild
It replaced pnpm rebuild node-pty, which always rebuilt; without --force it
only rebuilt a node-pty that failed to load.
* docs: note that the store switches must stay set, since pnpm run relinks
* build: move the pnpmfile under config/ to keep the repo root allowlisted
pnpm-workspace.yaml points pnpm at it; the lockfile checksum is unchanged
because the content is.
* fix(build): enable the global virtual store from the pnpmfile, not the workspace file
pnpm exec derives NODE_PATH from the file setting, so even with the store
forced off under CI every child process got the root node_modules on its
NODE_PATH; a Windows rebuild fixture then resolved the real
@orca/windows-registry it deliberately omits. The hook now sets the store
both ways, and ORCA_PNPM_GLOBAL_VIRTUAL_STORE=0/1 is the one switch.
* test(pty): keep F3's concurrent spawns on the node-pty mock
Two concurrent first import('node-pty') calls race vitest's mock registry
and the loser loads the real addon under the mocked linux platform. It
passed only where a platform-agnostic build/Release happened to exist,
so it failed on every shared-store worktree.
* ci: fail PRs whose packages need a dependency they never declared
A new dependency_isolation job installs with the global virtual store
forced on (ORCA_PNPM_FORCE_GLOBAL_VIRTUAL_STORE_IN_CI), HOME and the store
in an empty temp dir, then runs pnpm tc and the undeclared-dependency test.
Postinstall now warns when a node_modules above the shared store, such as
~/node_modules, would mask the same class of bug locally.
* ci: set the isolation job's HOME and store through GITHUB_ENV
Job-level env cannot read the runner context, which made pr.yml invalid.
492 lines
16 KiB
JavaScript
492 lines
16 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import { spawnSync } from 'node:child_process'
|
|
import { createRequire } from 'node:module'
|
|
import { existsSync, readFileSync, realpathSync } from 'node:fs'
|
|
import { basename, dirname, resolve } from 'node:path'
|
|
import {
|
|
ensureWindowsProcessTreeCommandLinePatch,
|
|
inspectWindowsProcessTreeAddon,
|
|
nodeGypRebuildInvocation,
|
|
nodeGypRebuildTimeoutMs,
|
|
stageWindowsProcessTreeNodeAddonApiHeaders,
|
|
windowsProcessTreeAddonPath
|
|
} from './windows-process-tree-gyp-rebuild.mjs'
|
|
import { runProcessSync } from '@orca/process-host'
|
|
import { describeProcessFailure } from './process-failure-message.mjs'
|
|
import {
|
|
isSharedNativePackage,
|
|
isSourceBuildDir,
|
|
nodePtyBuildArtifactPaths,
|
|
nodePtyNativeModuleName,
|
|
resolveNativePackageDir
|
|
} from './native-build-entry.cjs'
|
|
import {
|
|
checkNativeBuildDirLoads,
|
|
ensureSharedNativeBuild,
|
|
nativeBuildEnvironment
|
|
} from './shared-native-build.mjs'
|
|
|
|
const require = createRequire(import.meta.url)
|
|
const { assertNodePtyJobOwnership, nodePtyAddonPath } = require('./node-pty-job-ownership.cjs')
|
|
const { assertWindowsProcessTreeCreationTime } = require('./windows-process-tree-creation-time.cjs')
|
|
const scriptPath = import.meta.filename
|
|
const projectDir = resolve(import.meta.dirname, '../..')
|
|
const runtime = readRuntimeArg()
|
|
// Why: `pnpm rebuild:node` must rebuild even a loadable node-pty; a shared slot still reuses its usable entry.
|
|
const forceRebuild = process.argv.includes('--force')
|
|
|
|
const NATIVE_MODULES = [
|
|
'node-pty',
|
|
...(process.platform === 'win32'
|
|
? ['@orca/windows-registry', '@orca/windows-security', '@vscode/windows-process-tree']
|
|
: [])
|
|
]
|
|
const NODE_PTY_CONPTY_RUNTIME_FILES = ['conpty.dll', 'OpenConsole.exe']
|
|
const CHILD_CHECK_FLAG = '--check-only'
|
|
|
|
if (process.argv.includes(CHILD_CHECK_FLAG)) {
|
|
const failures = collectNativeModuleFailures()
|
|
if (failures.length > 0) {
|
|
for (const failure of failures) {
|
|
console.error(`${failure.moduleName}: ${failure.message}`)
|
|
}
|
|
process.exit(1)
|
|
}
|
|
process.exit(0)
|
|
}
|
|
|
|
if (runtime === 'node') {
|
|
ensureNodeRuntime()
|
|
} else if (runtime === 'electron') {
|
|
ensureElectronRuntime()
|
|
} else {
|
|
console.error('Usage: node config/scripts/ensure-native-runtime.mjs --runtime=node|electron')
|
|
process.exit(2)
|
|
}
|
|
|
|
function readRuntimeArg() {
|
|
const inline = process.argv.find((arg) => arg.startsWith('--runtime='))
|
|
if (inline) {
|
|
return inline.slice('--runtime='.length)
|
|
}
|
|
|
|
const runtimeIndex = process.argv.indexOf('--runtime')
|
|
if (runtimeIndex !== -1) {
|
|
return process.argv[runtimeIndex + 1]
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
function ensureNodeRuntime() {
|
|
const initial = runNodeCheck()
|
|
const patchedNodePtyRebuildReason = getPatchedNodePtyRebuildReason()
|
|
if (initial.ok && !patchedNodePtyRebuildReason && !forceRebuild) {
|
|
return
|
|
}
|
|
|
|
if (patchedNodePtyRebuildReason || forceRebuild) {
|
|
console.warn(
|
|
`[native-runtime] ${patchedNodePtyRebuildReason ?? 'Forced rebuild of node-pty for Node.'}`
|
|
)
|
|
if (!initial.ok) {
|
|
printCheckError(initial)
|
|
}
|
|
const failedModules = initial.failures.map((failure) => failure.moduleName)
|
|
const rebuildModules = [
|
|
'node-pty',
|
|
...failedModules.filter((moduleName) => moduleName !== 'node-pty')
|
|
]
|
|
rebuildNodeRuntimeModules(rebuildModules)
|
|
verifyNodeRuntimeAfterRebuild()
|
|
return
|
|
}
|
|
|
|
const failedModules = initial.failures.map((failure) => failure.moduleName)
|
|
console.warn(
|
|
`[native-runtime] ${formatRuntimeLabel('node')} cannot load native modules; rebuilding ${failedModules.join(', ')} for Node.`
|
|
)
|
|
printCheckError(initial)
|
|
rebuildNodeRuntimeModules(failedModules)
|
|
verifyNodeRuntimeAfterRebuild()
|
|
}
|
|
|
|
function verifyNodeRuntimeAfterRebuild() {
|
|
const final = runNodeCheck()
|
|
if (!final.ok) {
|
|
console.error(
|
|
`[native-runtime] Native modules still do not load for ${formatRuntimeLabel('node')}.`
|
|
)
|
|
printCheckError(final)
|
|
process.exit(1)
|
|
}
|
|
}
|
|
|
|
function ensureElectronRuntime() {
|
|
const initial = runElectronCheck()
|
|
const patchedNodePtyRebuildReason = getPatchedNodePtyRebuildReason()
|
|
if (initial.ok && !patchedNodePtyRebuildReason) {
|
|
return
|
|
}
|
|
|
|
if (patchedNodePtyRebuildReason) {
|
|
console.warn(`[native-runtime] ${patchedNodePtyRebuildReason}`)
|
|
if (!initial.ok) {
|
|
printCheckError(initial)
|
|
}
|
|
} else {
|
|
console.warn(
|
|
`[native-runtime] ${formatRuntimeLabel('electron')} cannot load native modules; rebuilding native deps for Electron.`
|
|
)
|
|
printCheckError(initial)
|
|
}
|
|
runNodeScript(['config/scripts/rebuild-native-deps.mjs'])
|
|
|
|
const final = runElectronCheck()
|
|
if (!final.ok) {
|
|
console.error(
|
|
`[native-runtime] Native modules still do not load for ${formatRuntimeLabel('electron')}.`
|
|
)
|
|
printCheckError(final)
|
|
process.exit(1)
|
|
}
|
|
}
|
|
|
|
function runNodeCheck() {
|
|
// Why: a failed native addon load can poison the current process, so the
|
|
// post-rebuild verification must happen in a fresh Node process.
|
|
const result = spawnSync(process.execPath, [scriptPath, CHILD_CHECK_FLAG], {
|
|
cwd: projectDir,
|
|
encoding: 'utf8',
|
|
stdio: ['ignore', 'pipe', 'pipe']
|
|
})
|
|
|
|
return parseChildCheckResult(result)
|
|
}
|
|
|
|
function runElectronCheck() {
|
|
const electronExecutable = resolveInstalledElectronExecutable()
|
|
if (!electronExecutable.ok) {
|
|
return { ok: false, error: electronExecutable.error }
|
|
}
|
|
|
|
const result = spawnSync(electronExecutable.path, [scriptPath, CHILD_CHECK_FLAG], {
|
|
cwd: projectDir,
|
|
env: {
|
|
...process.env,
|
|
ELECTRON_RUN_AS_NODE: '1'
|
|
},
|
|
encoding: 'utf8',
|
|
stdio: ['ignore', 'pipe', 'pipe']
|
|
})
|
|
|
|
return parseChildCheckResult(result)
|
|
}
|
|
|
|
function resolveInstalledElectronExecutable() {
|
|
const electronPackageDir = resolve(projectDir, 'node_modules/electron')
|
|
try {
|
|
const electronVersion = JSON.parse(
|
|
readFileSync(resolve(electronPackageDir, 'package.json'), 'utf8')
|
|
).version
|
|
const platformPath = getElectronPlatformPath()
|
|
const installedVersion = readFileSync(resolve(electronPackageDir, 'dist', 'version'), 'utf8')
|
|
.trim()
|
|
.replace(/^v/, '')
|
|
if (installedVersion !== electronVersion) {
|
|
return {
|
|
ok: false,
|
|
error: new Error(
|
|
`Electron package binary version ${installedVersion} does not match ${electronVersion}.`
|
|
)
|
|
}
|
|
}
|
|
const installedPlatformPath = readFileSync(resolve(electronPackageDir, 'path.txt'), 'utf8')
|
|
if (installedPlatformPath !== platformPath) {
|
|
return {
|
|
ok: false,
|
|
error: new Error(
|
|
`Electron package path.txt points at ${installedPlatformPath}, expected ${platformPath}.`
|
|
)
|
|
}
|
|
}
|
|
const electronPath = process.env.ELECTRON_OVERRIDE_DIST_PATH
|
|
? resolve(process.env.ELECTRON_OVERRIDE_DIST_PATH, platformPath)
|
|
: resolve(electronPackageDir, 'dist', platformPath)
|
|
if (!existsSync(electronPath)) {
|
|
return { ok: false, error: new Error(`Electron executable is missing at ${electronPath}.`) }
|
|
}
|
|
return { ok: true, path: electronPath }
|
|
} catch (error) {
|
|
return { ok: false, error }
|
|
}
|
|
}
|
|
|
|
function getElectronPlatformPath() {
|
|
const targetPlatform =
|
|
process.env.ELECTRON_INSTALL_PLATFORM || process.env.npm_config_platform || process.platform
|
|
switch (targetPlatform) {
|
|
case 'mas':
|
|
case 'darwin':
|
|
return 'Electron.app/Contents/MacOS/Electron'
|
|
case 'freebsd':
|
|
case 'openbsd':
|
|
case 'linux':
|
|
return 'electron'
|
|
case 'win32':
|
|
return 'electron.exe'
|
|
default:
|
|
throw new Error(`Electron builds are not available on platform: ${targetPlatform}`)
|
|
}
|
|
}
|
|
|
|
function parseChildCheckResult(result) {
|
|
const failures = parseCheckFailures(result.stderr)
|
|
|
|
return {
|
|
ok: result.status === 0,
|
|
status: result.status,
|
|
stdout: result.stdout,
|
|
stderr: result.stderr,
|
|
error: result.error,
|
|
failures
|
|
}
|
|
}
|
|
|
|
function parseCheckFailures(stderr) {
|
|
const failures = []
|
|
for (const line of (stderr ?? '').split(/\r?\n/)) {
|
|
const match = /^([^:]+):\s*(.*)$/.exec(line)
|
|
if (match && NATIVE_MODULES.includes(match[1])) {
|
|
failures.push({ moduleName: match[1], message: match[2] })
|
|
}
|
|
}
|
|
return failures
|
|
}
|
|
|
|
function collectNativeModuleFailures() {
|
|
const failures = []
|
|
for (const moduleName of NATIVE_MODULES) {
|
|
try {
|
|
loadNativeModule(moduleName)
|
|
} catch (cause) {
|
|
failures.push({ moduleName, message: formatError(cause), cause })
|
|
}
|
|
}
|
|
return failures
|
|
}
|
|
|
|
function loadNativeModule(moduleName) {
|
|
if (moduleName === '@vscode/windows-process-tree') {
|
|
// A bare require loads the .node addon on win32, so it catches an ABI
|
|
// mismatch on its own. What it cannot catch is *which* addon loaded: the
|
|
// published tarball ships a prebuilt built from unpatched source that is
|
|
// node-addon-api, so it requires cleanly, reads every process's command
|
|
// line out of its address space, and ignores the CreationTime flag. Check
|
|
// the binary on both counts, not the load.
|
|
assertWindowsProcessTreeCreationTime({ module: require(moduleName) })
|
|
if (inspectWindowsProcessTreeAddon(windowsProcessTreeAddonPath()) === 'unpatched') {
|
|
throw new Error(
|
|
'the loaded addon still calls ReadProcessMemory, so it was not built from the patched ' +
|
|
'source. Rebuild it (pnpm run rebuild:electron) rather than using the published prebuild.'
|
|
)
|
|
}
|
|
return
|
|
}
|
|
if (moduleName === '@orca/windows-registry') {
|
|
const registry = require(moduleName)
|
|
// Why: the package defers loading its .node addon until the first registry call.
|
|
registry.getRegistryKey(registry.HK.CU, 'Environment')
|
|
return
|
|
}
|
|
if (moduleName === '@orca/windows-security') {
|
|
// Why: the package defers loading its .node addon until the first call.
|
|
require(moduleName).currentUserSid()
|
|
return
|
|
}
|
|
if (moduleName === 'node-pty') {
|
|
loadNodePtyNativeModule()
|
|
return
|
|
}
|
|
|
|
require(moduleName)
|
|
}
|
|
|
|
function loadNodePtyNativeModule() {
|
|
require('node-pty')
|
|
|
|
const { loadNativeModule } = require('node-pty/lib/utils')
|
|
const nativeName = nodePtyNativeModuleName()
|
|
// Why: node-pty's Windows JS wrapper defers conpty.node/pty.node until a
|
|
// terminal is created, so require('node-pty') alone can miss ABI mismatches.
|
|
const native = loadNativeModule(nativeName)
|
|
const addonPath = nodePtyAddonPath(require.resolve('node-pty/lib/utils'), native, nativeName)
|
|
assertNodePtyWindowsConptyRuntime(native?.dir, dirname(addonPath))
|
|
assertNodePtyJobOwnership({ nativeName, native, addonPath })
|
|
if (requiresPatchedNodePtySourceBuild() && !isSourceBuildDir(native?.dir)) {
|
|
throw new Error(
|
|
`node-pty resolved to ${native.dir}; expected build/Release so Orca's node-pty patch is active`
|
|
)
|
|
}
|
|
}
|
|
|
|
function assertNodePtyWindowsConptyRuntime(nativeDir, loadedDir) {
|
|
if (process.platform !== 'win32' || !isSourceBuildDir(nativeDir)) {
|
|
return
|
|
}
|
|
const runtimeDir = resolve(loadedDir, 'conpty')
|
|
const missingFile = NODE_PTY_CONPTY_RUNTIME_FILES.find(
|
|
(filename) => !existsSync(resolve(runtimeDir, filename))
|
|
)
|
|
if (missingFile) {
|
|
throw new Error(`node-pty ConPTY runtime file is missing: ${resolve(runtimeDir, missingFile)}`)
|
|
}
|
|
}
|
|
|
|
function getPatchedNodePtyRebuildReason() {
|
|
if (!requiresPatchedNodePtySourceBuild()) {
|
|
return null
|
|
}
|
|
|
|
// Why: a loadable upstream node-pty prebuild is not enough; Orca's Unix and
|
|
// Windows patches only land in the source-built build/Release artifacts.
|
|
const nodePtyDir = resolve(projectDir, 'node_modules', 'node-pty')
|
|
const artifactPaths = nodePtyBuildArtifactPaths(nodePtyDir)
|
|
const missingArtifact = artifactPaths.find((artifactPath) => !existsSync(artifactPath))
|
|
|
|
if (!missingArtifact) {
|
|
return null
|
|
}
|
|
|
|
return 'Patched node-pty build artifacts are missing; rebuilding native deps.'
|
|
}
|
|
|
|
function requiresPatchedNodePtySourceBuild() {
|
|
const nodePtyPatchPath = resolve(projectDir, 'config', 'patches', 'node-pty@1.1.0.patch')
|
|
if (!existsSync(nodePtyPatchPath)) {
|
|
return false
|
|
}
|
|
|
|
return existsSync(resolve(projectDir, 'node_modules', 'node-pty'))
|
|
}
|
|
|
|
function rebuildNodeRuntimeModules(moduleNames) {
|
|
for (const moduleName of moduleNames) {
|
|
let moduleDir = dirname(require.resolve(`${moduleName}/package.json`))
|
|
if (isSharedNativePackage(moduleName, projectDir)) {
|
|
// Why: other worktrees load this very dir, so build beside it instead of over it.
|
|
buildSharedNodeRuntimeModule(moduleName, resolveNativePackageDir(moduleName, projectDir))
|
|
continue
|
|
}
|
|
if (moduleName === '@vscode/windows-process-tree') {
|
|
// Why before node-gyp: this module is rebuilt precisely because the
|
|
// binary was the unpatched one, and pnpm materializes it unpatched often
|
|
// enough that compiling the source as-is would just rebuild the same
|
|
// reader and fail the verify pass. The patched binding.gyp then includes
|
|
// deps/node-addon-api, which the tarball does not ship, and node-gyp must
|
|
// run from the physical dir -- both reasons live in
|
|
// windows-process-tree-gyp-rebuild.mjs.
|
|
ensureWindowsProcessTreeCommandLinePatch(moduleDir)
|
|
stageWindowsProcessTreeNodeAddonApiHeaders(moduleDir)
|
|
moduleDir = realpathSync(moduleDir)
|
|
}
|
|
console.warn(`[native-runtime] Rebuilding ${moduleName} with node-gyp.`)
|
|
// pnpm exec inside an installed addon cannot discover the root build tool.
|
|
runNodeGyp(
|
|
moduleName,
|
|
nodeGypRebuildInvocation(
|
|
process.arch,
|
|
moduleDir,
|
|
process.env.npm_config_node_gyp || undefined
|
|
)
|
|
)
|
|
if (moduleName === 'node-pty' && process.platform === 'win32') {
|
|
runNodeScript([resolve(moduleDir, 'scripts', 'post-install.js')])
|
|
}
|
|
}
|
|
}
|
|
|
|
function buildSharedNodeRuntimeModule(moduleName, packageDir) {
|
|
try {
|
|
ensureSharedNativeBuild({
|
|
moduleName,
|
|
packageDir,
|
|
checkBuildDir: (dir) => checkNativeBuildDirLoads(moduleName, dir),
|
|
nodeGypEntry: process.env.npm_config_node_gyp || undefined,
|
|
log: (message) => console.warn(`[native-runtime] ${message}`)
|
|
})
|
|
} catch (error) {
|
|
console.error(`[native-runtime] ${moduleName}: ${formatError(error)}`)
|
|
process.exit(1)
|
|
}
|
|
}
|
|
|
|
function runNodeGyp(moduleName, { args, cwd }) {
|
|
const env = nativeBuildEnvironment()
|
|
const result = runProcessSync({
|
|
program: process.execPath,
|
|
args,
|
|
cwd,
|
|
env,
|
|
stdio: 'inherit',
|
|
timeoutMs: nodeGypRebuildTimeoutMs(moduleName)
|
|
})
|
|
if (result.code !== 0) {
|
|
console.error(
|
|
`[native-runtime] node-gyp rebuild failed in ${cwd}: ${describeProcessFailure(result)}`
|
|
)
|
|
process.exit(result.code ?? 1)
|
|
}
|
|
}
|
|
|
|
function runNodeScript(args) {
|
|
const result = spawnSync(process.execPath, args, {
|
|
cwd: projectDir,
|
|
stdio: 'inherit'
|
|
})
|
|
|
|
if (result.error || result.status !== 0) {
|
|
console.error(`[native-runtime] ${basename(process.execPath)} ${args.join(' ')} failed.`)
|
|
if (result.error) {
|
|
console.error(formatError(result.error))
|
|
}
|
|
process.exit(result.status ?? 1)
|
|
}
|
|
}
|
|
|
|
function printCheckError(result) {
|
|
for (const failure of result.failures ?? []) {
|
|
console.warn(`[native-runtime] ${failure.moduleName}: ${failure.message}`)
|
|
}
|
|
if (result.error) {
|
|
console.warn(`[native-runtime] ${formatError(result.error)}`)
|
|
}
|
|
if (result.stderr?.trim()) {
|
|
console.warn(result.stderr.trim())
|
|
}
|
|
if (result.stdout?.trim()) {
|
|
console.warn(result.stdout.trim())
|
|
}
|
|
if (
|
|
result.status != null &&
|
|
!result.error &&
|
|
!result.stderr?.trim() &&
|
|
!result.stdout?.trim() &&
|
|
result.status !== 0
|
|
) {
|
|
console.warn(`[native-runtime] Native check exited with status ${result.status}.`)
|
|
}
|
|
}
|
|
|
|
function formatError(error) {
|
|
return error instanceof Error ? error.message : String(error)
|
|
}
|
|
|
|
function formatRuntimeLabel(value) {
|
|
if (value === 'electron') {
|
|
return `Electron ${process.env.npm_package_devDependencies_electron ?? ''}`.trim()
|
|
}
|
|
return `Node ${process.versions.node}`
|
|
}
|