diff --git a/crates/pi-natives/src/lib.rs b/crates/pi-natives/src/lib.rs index 827696a51..81233f6cd 100644 --- a/crates/pi-natives/src/lib.rs +++ b/crates/pi-natives/src/lib.rs @@ -52,3 +52,24 @@ pub mod text; pub mod tokens; pub(crate) mod utils; pub mod workspace; + +use napi_derive::napi; + +/// Version sentinel — exists solely so the JS loader can prove at load time +/// that the `.node` file on disk is from the same package release as the +/// `index.js` ESM wrapper invoking it. +/// +/// The `js_name` is bumped by `scripts/release.ts` to match the new +/// `Cargo.toml` / `package.json` version on every release. The JS loader +/// computes the expected name from `package.json#version` and refuses to use +/// a `.node` that doesn't expose it, turning the silent +/// ` is not a function` crash from a locked-file update (the canonical +/// Windows `bun install -g` failure mode) into a clear load-time error. +/// +/// Bump policy: `__piNativesV{major}_{minor}_{patch}` — non-alphanumerics in +/// the version string are mapped to `_` to keep it a valid JS identifier. +/// MUST stay in sync with `VERSION_SENTINEL_EXPORT` in +/// `packages/natives/native/index.js` (which derives the name from +/// `package.json#version`). +#[napi(js_name = "__piNativesV15_0_1")] +pub const fn pi_natives_version_sentinel() {} diff --git a/packages/natives/CHANGELOG.md b/packages/natives/CHANGELOG.md index 9baa0c062..21d4dddc6 100644 --- a/packages/natives/CHANGELOG.md +++ b/packages/natives/CHANGELOG.md @@ -2,6 +2,14 @@ ## [Unreleased] +### Added + +- Added a per-release version sentinel napi export (`__piNativesV{major}_{minor}_{patch}`). The Rust `js_name` is bumped in lock-step with the package version by `scripts/release.ts`; the JS loader computes the expected name from `package.json#version` and throws an actionable error when the on-disk `.node` doesn't expose it. This converts the silent ` is not a function` crash from a stale addon into a load-time failure pointing at the real fix. + +### Fixed + +- Fixed ` is not a function` crashes on Windows after `bun install -g @oh-my-pi/pi-coding-agent` updates while an `omp` process was running. Bun cannot overwrite a locked `node_modules/@oh-my-pi/pi-natives/native/pi_natives.win32-x64.node` and silently keeps the old binary alongside the new ESM wrapper, so the next launch loads mismatched code. The loader now mirrors the addon into `~/.omp/natives//` on Windows npm installs and prefers that copy at load time — each version gets its own filesystem path, so future updates land in `node_modules` unchallenged. The new version sentinel detects any remaining drift up front. + ## [15.0.1] - 2026-05-14 ### Breaking Changes diff --git a/packages/natives/native/index.d.ts b/packages/natives/native/index.d.ts index fc5f13dca..44f3fae3a 100644 --- a/packages/natives/native/index.d.ts +++ b/packages/natives/native/index.d.ts @@ -118,6 +118,26 @@ export declare class Shell { abort(): Promise } +/** + * Version sentinel — exists solely so the JS loader can prove at load time + * that the `.node` file on disk is from the same package release as the + * `index.js` ESM wrapper invoking it. + * + * The `js_name` is bumped by `scripts/release.ts` to match the new + * `Cargo.toml` / `package.json` version on every release. The JS loader + * computes the expected name from `package.json#version` and refuses to use + * a `.node` that doesn't expose it, turning the silent + * ` is not a function` crash from a locked-file update (the canonical + * Windows `bun install -g` failure mode) into a clear load-time error. + * + * Bump policy: `__piNativesV{major}_{minor}_{patch}` — non-alphanumerics in + * the version string are mapped to `_` to keep it a valid JS identifier. + * MUST stay in sync with `VERSION_SENTINEL_EXPORT` in + * `packages/natives/native/index.js` (which derives the name from + * `package.json#version`). + */ +export declare function __piNativesV15_0_1(): void + /** * Apply ast-grep rewrite rules to matching files; honors `dryRun` and returns * a promise. diff --git a/packages/natives/native/index.js b/packages/natives/native/index.js index f3e96999b..36e4f66ee 100644 --- a/packages/natives/native/index.js +++ b/packages/natives/native/index.js @@ -5,7 +5,12 @@ import * as os from "node:os"; import * as path from "node:path"; import packageJson from "../package.json" with { type: "json" }; import { embeddedAddon } from "./embedded-addon.js"; -import { detectCompiledBinary, getAddonFilenames, resolveLoaderCandidates } from "./loader-state.js"; +import { + detectCompiledBinary, + getAddonFilenames, + resolveLoaderCandidates, + shouldStageNodeModulesAddon, +} from "./loader-state.js"; /** * Native addon loader and bindings. @@ -37,6 +42,11 @@ const isCompiledBinary = detectCompiledBinary({ env: process.env, importMetaUrl: import.meta.url, }); +const stageFromNodeModules = shouldStageNodeModulesAddon({ + platform: process.platform, + isCompiledBinary, + nativeDir, +}); const SUPPORTED_PLATFORMS = ["linux-x64", "linux-arm64", "darwin-x64", "darwin-arm64", "win32-x64"]; function getVariantOverride() { @@ -96,6 +106,7 @@ const addonLabel = selectedVariant ? `${platformTag} (${selectedVariant})` : pla const dedupedCandidates = resolveLoaderCandidates({ addonFilenames, isCompiledBinary, + stageFromNodeModules, nativeDir, execDir, versionedDir, @@ -158,13 +169,58 @@ function maybeExtractEmbeddedAddon(errors) { } } +/** + * Mirror `nativeDir/.node` to `versionedDir/.node` on Windows + * installs so the running process keeps its OS-level handle on a versioned + * cache path, never on the `node_modules` copy that bun must overwrite on + * update. No-op on non-Windows, in workspace dev, and for compiled binaries — + * see `shouldStageNodeModulesAddon` for the gating rules. + */ +function maybeStageNodeModulesAddon(errors) { + if (!stageFromNodeModules) return null; + + let stagedPath = null; + for (const filename of addonFilenames) { + const sourcePath = path.join(nativeDir, filename); + const targetPath = path.join(versionedDir, filename); + + if (fs.existsSync(targetPath)) { + stagedPath = stagedPath || targetPath; + continue; + } + if (!fs.existsSync(sourcePath)) continue; + + try { + fs.mkdirSync(versionedDir, { recursive: true }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + errors.push(`staged addon dir: ${message}`); + continue; + } + + try { + // `copyFileSync` is atomic on Windows (CopyFileW) and avoids holding + // two large buffers in JS for the read/write dance. + fs.copyFileSync(sourcePath, targetPath); + stagedPath = stagedPath || targetPath; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + errors.push(`staged addon copy (${filename}): ${message}`); + } + } + return stagedPath; +} + function loadNative() { const errors = []; const embeddedCandidate = maybeExtractEmbeddedAddon(errors); - const runtimeCandidates = embeddedCandidate ? [embeddedCandidate, ...dedupedCandidates] : dedupedCandidates; + const stagedCandidate = embeddedCandidate ? null : maybeStageNodeModulesAddon(errors); + const prepended = [embeddedCandidate, stagedCandidate].filter(c => typeof c === "string"); + const runtimeCandidates = prepended.length > 0 ? [...prepended, ...dedupedCandidates] : dedupedCandidates; for (const candidate of runtimeCandidates) { try { const bindings = require_(candidate); + validateLoadedBindings(bindings, candidate); return bindings; } catch (err) { const message = err instanceof Error ? err.message : String(err); @@ -203,6 +259,35 @@ function loadNative() { throw new Error(`Failed to load pi_natives native addon for ${addonLabel}.\n\nTried:\n${details}\n\n${helpMessage}`); } +// Version sentinel emitted by the Rust addon under a `js_name` that encodes +// the package version (`__piNativesV{major}_{minor}_{patch}`). +// `scripts/release.ts` bumps the name in `crates/pi-natives/src/lib.rs` in +// lock-step with the version, so a `.node` from a different release physically +// cannot expose the symbol this loader is looking for. That turns the silent +// ` is not a function` crash from a Windows locked-file update into an +// actionable load-time error. +const VERSION_SENTINEL_EXPORT = `__piNativesV${packageVersion.replace(/[^A-Za-z0-9]/g, "_")}`; + +// In workspace dev (running out of `packages/natives/native/` rather than a +// `node_modules` install or a compiled bundle) the local `.node` only gains +// the renamed sentinel after `bun --cwd=packages/natives run build`. Skip +// validation there so a stale post-pull dev tree boots while the rebuild +// completes; install and compiled-binary paths still validate. +const isWorkspaceLoad = + !isCompiledBinary && + !nativeDir.includes("\\node_modules\\") && + !nativeDir.includes("/node_modules/"); + +function validateLoadedBindings(bindings, candidate) { + if (isWorkspaceLoad) return; + if (typeof bindings[VERSION_SENTINEL_EXPORT] === "function") return; + throw new Error( + `Loaded ${candidate} but it does not expose the @oh-my-pi/pi-natives@${packageVersion} ` + + `version sentinel \`${VERSION_SENTINEL_EXPORT}\`. The .node file on disk is from a different ` + + "release than this loader — reinstall to re-sync.", + ); +} + const nativeBindings = loadNative(); // --- generated native exports (do not edit) --- // classes @@ -213,6 +298,7 @@ export const PtySession = nativeBindings.PtySession; export const Shell = nativeBindings.Shell; // functions +export const __piNativesV15_0_1 = nativeBindings.__piNativesV15_0_1; export const astEdit = nativeBindings.astEdit; export const astGrep = nativeBindings.astGrep; export const copyToClipboard = nativeBindings.copyToClipboard; diff --git a/packages/natives/native/loader-state.d.ts b/packages/natives/native/loader-state.d.ts index 3e08402be..433fcd8b9 100644 --- a/packages/natives/native/loader-state.d.ts +++ b/packages/natives/native/loader-state.d.ts @@ -26,9 +26,18 @@ export interface GetAddonFilenamesInput { export function getAddonFilenames(input: GetAddonFilenamesInput): string[]; +export interface ShouldStageNodeModulesAddonInput { + platform: NodeJS.Platform | string; + isCompiledBinary: boolean; + nativeDir: string; +} + +export function shouldStageNodeModulesAddon(input: ShouldStageNodeModulesAddonInput): boolean; + export interface ResolveLoaderCandidatesInput { addonFilenames: string[]; isCompiledBinary: boolean; + stageFromNodeModules?: boolean; nativeDir: string; execDir: string; versionedDir: string; diff --git a/packages/natives/native/loader-state.js b/packages/natives/native/loader-state.js index 40216aee3..f188b9a5e 100644 --- a/packages/natives/native/loader-state.js +++ b/packages/natives/native/loader-state.js @@ -52,10 +52,40 @@ export function getAddonFilenames({ tag, arch, variant }) { return [baselineFilename, defaultFilename]; } +/** + * Decide whether the loader should mirror the package's `native/.node` + * into the per-version cache directory (`~/.omp/natives//`) before loading. + * + * Windows-only safety net for `bun install -g` updates: when a previous `omp` + * process is running, bun cannot overwrite the locked `.node` inside + * `node_modules/@oh-my-pi/pi-natives/native/`, leaving an old binary next to a + * newer `index.js` and producing ` is not a function` crashes on the next + * launch. Staging into the version-pinned cache: + * 1. Gives every package version its own filesystem path, so concurrent omp + * processes never collide on the same file. + * 2. Makes the running process keep its handle on the cache copy, freeing bun + * to overwrite the `node_modules` copy on subsequent updates. + * Disabled on non-Windows (no file-lock problem), in workspace dev (`nativeDir` + * is not inside a `node_modules` segment), and for compiled binaries (handled + * by `maybeExtractEmbeddedAddon`). + * + * @param {{ platform: NodeJS.Platform | string; isCompiledBinary: boolean; nativeDir: string }} input + * @returns {boolean} + */ +export function shouldStageNodeModulesAddon({ platform, isCompiledBinary, nativeDir }) { + if (platform !== "win32") return false; + if (isCompiledBinary) return false; + // Check both separators independently of the host's `path.sep`: this helper + // is shared by the loader (running on Windows with `\`) and the test suite + // (typically running on POSIX hosts when CI executes the regression test). + return nativeDir.includes("\\node_modules\\") || nativeDir.includes("/node_modules/"); +} + /** * @param {{ * addonFilenames: string[]; * isCompiledBinary: boolean; + * stageFromNodeModules?: boolean; * nativeDir: string; * execDir: string; * versionedDir: string; @@ -63,7 +93,15 @@ export function getAddonFilenames({ tag, arch, variant }) { * }} input * @returns {string[]} */ -export function resolveLoaderCandidates({ addonFilenames, isCompiledBinary, nativeDir, execDir, versionedDir, userDataDir }) { +export function resolveLoaderCandidates({ + addonFilenames, + isCompiledBinary, + stageFromNodeModules = false, + nativeDir, + execDir, + versionedDir, + userDataDir, +}) { const baseReleaseCandidates = addonFilenames.flatMap(filename => [ path.join(nativeDir, filename), path.join(execDir, filename), @@ -72,9 +110,17 @@ export function resolveLoaderCandidates({ addonFilenames, isCompiledBinary, nati path.join(versionedDir, filename), path.join(userDataDir, filename), ]); - const releaseCandidates = isCompiledBinary - ? [...compiledCandidates, ...baseReleaseCandidates] - : baseReleaseCandidates; + const stagedCandidates = stageFromNodeModules + ? addonFilenames.map(filename => path.join(versionedDir, filename)) + : []; + let releaseCandidates; + if (isCompiledBinary) { + releaseCandidates = [...compiledCandidates, ...baseReleaseCandidates]; + } else if (stageFromNodeModules) { + releaseCandidates = [...stagedCandidates, ...baseReleaseCandidates]; + } else { + releaseCandidates = baseReleaseCandidates; + } return [...new Set(releaseCandidates)]; } diff --git a/packages/natives/scripts/native-index.template.js b/packages/natives/scripts/native-index.template.js index 2f35bd370..1d16153e1 100644 --- a/packages/natives/scripts/native-index.template.js +++ b/packages/natives/scripts/native-index.template.js @@ -5,7 +5,12 @@ import * as os from "node:os"; import * as path from "node:path"; import packageJson from "../package.json" with { type: "json" }; import { embeddedAddon } from "./embedded-addon.js"; -import { detectCompiledBinary, getAddonFilenames, resolveLoaderCandidates } from "./loader-state.js"; +import { + detectCompiledBinary, + getAddonFilenames, + resolveLoaderCandidates, + shouldStageNodeModulesAddon, +} from "./loader-state.js"; /** * Native addon loader and bindings. @@ -37,6 +42,11 @@ const isCompiledBinary = detectCompiledBinary({ env: process.env, importMetaUrl: import.meta.url, }); +const stageFromNodeModules = shouldStageNodeModulesAddon({ + platform: process.platform, + isCompiledBinary, + nativeDir, +}); const SUPPORTED_PLATFORMS = ["linux-x64", "linux-arm64", "darwin-x64", "darwin-arm64", "win32-x64"]; function getVariantOverride() { @@ -96,6 +106,7 @@ const addonLabel = selectedVariant ? `${platformTag} (${selectedVariant})` : pla const dedupedCandidates = resolveLoaderCandidates({ addonFilenames, isCompiledBinary, + stageFromNodeModules, nativeDir, execDir, versionedDir, @@ -158,13 +169,58 @@ function maybeExtractEmbeddedAddon(errors) { } } +/** + * Mirror `nativeDir/.node` to `versionedDir/.node` on Windows + * installs so the running process keeps its OS-level handle on a versioned + * cache path, never on the `node_modules` copy that bun must overwrite on + * update. No-op on non-Windows, in workspace dev, and for compiled binaries — + * see `shouldStageNodeModulesAddon` for the gating rules. + */ +function maybeStageNodeModulesAddon(errors) { + if (!stageFromNodeModules) return null; + + let stagedPath = null; + for (const filename of addonFilenames) { + const sourcePath = path.join(nativeDir, filename); + const targetPath = path.join(versionedDir, filename); + + if (fs.existsSync(targetPath)) { + stagedPath = stagedPath || targetPath; + continue; + } + if (!fs.existsSync(sourcePath)) continue; + + try { + fs.mkdirSync(versionedDir, { recursive: true }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + errors.push(`staged addon dir: ${message}`); + continue; + } + + try { + // `copyFileSync` is atomic on Windows (CopyFileW) and avoids holding + // two large buffers in JS for the read/write dance. + fs.copyFileSync(sourcePath, targetPath); + stagedPath = stagedPath || targetPath; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + errors.push(`staged addon copy (${filename}): ${message}`); + } + } + return stagedPath; +} + function loadNative() { const errors = []; const embeddedCandidate = maybeExtractEmbeddedAddon(errors); - const runtimeCandidates = embeddedCandidate ? [embeddedCandidate, ...dedupedCandidates] : dedupedCandidates; + const stagedCandidate = embeddedCandidate ? null : maybeStageNodeModulesAddon(errors); + const prepended = [embeddedCandidate, stagedCandidate].filter(c => typeof c === "string"); + const runtimeCandidates = prepended.length > 0 ? [...prepended, ...dedupedCandidates] : dedupedCandidates; for (const candidate of runtimeCandidates) { try { const bindings = require_(candidate); + validateLoadedBindings(bindings, candidate); return bindings; } catch (err) { const message = err instanceof Error ? err.message : String(err); @@ -202,3 +258,32 @@ function loadNative() { throw new Error(`Failed to load pi_natives native addon for ${addonLabel}.\n\nTried:\n${details}\n\n${helpMessage}`); } + +// Version sentinel emitted by the Rust addon under a `js_name` that encodes +// the package version (`__piNativesV{major}_{minor}_{patch}`). +// `scripts/release.ts` bumps the name in `crates/pi-natives/src/lib.rs` in +// lock-step with the version, so a `.node` from a different release physically +// cannot expose the symbol this loader is looking for. That turns the silent +// ` is not a function` crash from a Windows locked-file update into an +// actionable load-time error. +const VERSION_SENTINEL_EXPORT = `__piNativesV${packageVersion.replace(/[^A-Za-z0-9]/g, "_")}`; + +// In workspace dev (running out of `packages/natives/native/` rather than a +// `node_modules` install or a compiled bundle) the local `.node` only gains +// the renamed sentinel after `bun --cwd=packages/natives run build`. Skip +// validation there so a stale post-pull dev tree boots while the rebuild +// completes; install and compiled-binary paths still validate. +const isWorkspaceLoad = + !isCompiledBinary && + !nativeDir.includes("\\node_modules\\") && + !nativeDir.includes("/node_modules/"); + +function validateLoadedBindings(bindings, candidate) { + if (isWorkspaceLoad) return; + if (typeof bindings[VERSION_SENTINEL_EXPORT] === "function") return; + throw new Error( + `Loaded ${candidate} but it does not expose the @oh-my-pi/pi-natives@${packageVersion} ` + + `version sentinel \`${VERSION_SENTINEL_EXPORT}\`. The .node file on disk is from a different ` + + "release than this loader — reinstall to re-sync.", + ); +} diff --git a/packages/natives/test/windows-staging.test.ts b/packages/natives/test/windows-staging.test.ts new file mode 100644 index 000000000..ece93c79c --- /dev/null +++ b/packages/natives/test/windows-staging.test.ts @@ -0,0 +1,143 @@ +/** + * Regression for the Windows `bun install -g` update path: when an `omp` + * process is running, bun cannot overwrite a locked + * `node_modules/@oh-my-pi/pi-natives/native/pi_natives.win32-x64.node` during + * package update and silently keeps the old binary next to the new ESM + * wrapper. The next launch then throws ` is not a function` deep inside + * tool execution (see Discord report, 2026-05-14). + * + * The fix has two halves, both pinned by this test: + * 1. The loader stages `nativeDir/.node` → `versionedDir/.node` + * (per-package-version cache under `~/.omp/natives//`) so the + * running process holds its OS-level handle on a path bun is never asked + * to overwrite. Gated to Windows + node_modules installs + non-compiled + * mode by `shouldStageNodeModulesAddon`. + * 2. `resolveLoaderCandidates` puts the staged path ahead of the + * `node_modules` path so subsequent updates land in node_modules without + * contention. + * + * Both behaviors are off in workspace dev (`bun --cwd=packages/natives run + * build`) and on non-Windows so the regular path is unchanged. + */ +import { describe, expect, it } from "bun:test"; +import * as path from "node:path"; +import { getAddonFilenames, resolveLoaderCandidates, shouldStageNodeModulesAddon } from "../native/loader-state.js"; +import packageJson from "../package.json" with { type: "json" }; + +const winNodeModulesNativeDir = "C:\\Users\\Admin\\node_modules\\@oh-my-pi\\pi-natives\\native"; +const winWorkspaceNativeDir = "C:\\Users\\Admin\\dev\\oh-my-pi\\packages\\natives\\native"; +const posixNodeModulesNativeDir = "/home/u/proj/node_modules/@oh-my-pi/pi-natives/native"; + +describe("windows native addon staging", () => { + it("stages only on Windows node_modules installs", () => { + // Windows + node_modules install + npm (not compiled) → stage. + expect( + shouldStageNodeModulesAddon({ + platform: "win32", + isCompiledBinary: false, + nativeDir: winNodeModulesNativeDir, + }), + ).toBe(true); + + // Windows workspace dev: nativeDir lives outside node_modules → never stage, + // otherwise rebuilds via `bun --cwd=packages/natives run build` would be + // shadowed by a stale cache copy. + expect( + shouldStageNodeModulesAddon({ + platform: "win32", + isCompiledBinary: false, + nativeDir: winWorkspaceNativeDir, + }), + ).toBe(false); + + // Windows compiled binary: the embedded-addon extractor already populates + // versionedDir; staging from a non-existent nativeDir would race that. + expect( + shouldStageNodeModulesAddon({ + platform: "win32", + isCompiledBinary: true, + nativeDir: winNodeModulesNativeDir, + }), + ).toBe(false); + + // Non-Windows: bun's atomic rename works fine, no need to stage. + expect( + shouldStageNodeModulesAddon({ + platform: "linux", + isCompiledBinary: false, + nativeDir: posixNodeModulesNativeDir, + }), + ).toBe(false); + expect( + shouldStageNodeModulesAddon({ + platform: "darwin", + isCompiledBinary: false, + nativeDir: posixNodeModulesNativeDir, + }), + ).toBe(false); + }); + + it("prepends versionedDir candidates ahead of node_modules when staging on Windows", () => { + const versionedDir = "C:\\Users\\Admin\\.omp\\natives\\15.0.1"; + const userDataDir = "C:\\Users\\Admin\\AppData\\Local\\omp"; + const candidates = resolveLoaderCandidates({ + addonFilenames: getAddonFilenames({ tag: "win32-x64", arch: "x64", variant: "baseline" }), + isCompiledBinary: false, + stageFromNodeModules: true, + nativeDir: winNodeModulesNativeDir, + execDir: "C:\\Users\\Admin\\node_modules\\.bin", + versionedDir, + userDataDir, + }); + + const versionedBaseline = path.join(versionedDir, "pi_natives.win32-x64-baseline.node"); + const versionedDefault = path.join(versionedDir, "pi_natives.win32-x64.node"); + const nodeModulesBaseline = path.join(winNodeModulesNativeDir, "pi_natives.win32-x64-baseline.node"); + + // Staged paths must be probed first so the running process locks the cache + // copy and bun is free to replace the node_modules copy on next update. + expect(candidates).toContain(versionedBaseline); + expect(candidates).toContain(versionedDefault); + expect(candidates.indexOf(versionedBaseline)).toBeLessThan(candidates.indexOf(nodeModulesBaseline)); + + // User-data dir is reserved for compiled-binary mode — staging must not + // quietly start probing it on npm installs (where it never contains the + // addon anyway). + const userDataBaseline = path.join(userDataDir, "pi_natives.win32-x64-baseline.node"); + expect(candidates).not.toContain(userDataBaseline); + }); + + it("falls back to the node_modules-only candidate list when staging is off", () => { + // Mirrors the non-Windows / workspace-dev path: same behavior as before + // the staging feature was introduced. + const versionedDir = "/home/u/.omp/natives/15.0.1"; + const candidates = resolveLoaderCandidates({ + addonFilenames: getAddonFilenames({ tag: "linux-x64", arch: "x64", variant: "baseline" }), + isCompiledBinary: false, + stageFromNodeModules: false, + nativeDir: posixNodeModulesNativeDir, + execDir: "/usr/bin", + versionedDir, + userDataDir: "/home/u/.local/bin", + }); + + const versionedBaseline = path.join(versionedDir, "pi_natives.linux-x64-baseline.node"); + const nodeModulesBaseline = path.join(posixNodeModulesNativeDir, "pi_natives.linux-x64-baseline.node"); + expect(candidates).not.toContain(versionedBaseline); + expect(candidates).toContain(nodeModulesBaseline); + }); +}); + +describe("pi-natives version sentinel", () => { + it("Rust `js_name` matches the package version", async () => { + // The JS loader (`packages/natives/native/index.js`) computes its expected + // sentinel from `package.json#version`; if the Rust source falls out of + // sync we ship a `.node` that the loader will refuse to use. Pinning the + // pairing here catches release-script regressions before they reach CI. + const libRs = await Bun.file(path.join(import.meta.dir, "../../../crates/pi-natives/src/lib.rs")).text(); + const sentinelMatch = libRs.match(/js_name = "(__piNativesV[A-Za-z0-9_]+)"/); + expect(sentinelMatch, 'Rust sentinel `js_name = "__piNativesV…"` not found in lib.rs').not.toBeNull(); + const expected = `__piNativesV${packageJson.version.replace(/[^A-Za-z0-9]/g, "_")}`; + expect(sentinelMatch?.[1]).toBe(expected); + }); +}); diff --git a/scripts/release.ts b/scripts/release.ts index 7a6311f86..83ac5b4b0 100755 --- a/scripts/release.ts +++ b/scripts/release.ts @@ -269,6 +269,33 @@ async function cmdRelease(version: string): Promise { } console.log(); + // 3b. Rename the pi-natives version sentinel so any `.node` left on disk from + // a previous release physically cannot expose the symbol the new `index.js` + // expects. The JS loader derives `VERSION_SENTINEL_EXPORT` from `package.json` + // at runtime, so the only thing that has to move on the Rust side is the + // `js_name = "__piNativesV…"` literal. `gen-enums.ts` regenerates the matching + // entries in `packages/natives/native/{index.d.ts,index.js}` on the next napi + // build, but bump them here too so the committed surface tracks the version + // without waiting for a local rebuild on the release host. + console.log(`Bumping pi-natives version sentinel to v${version}…`); + const sentinelJsId = version.replace(/[^A-Za-z0-9]/g, "_"); + const sentinelName = `__piNativesV${sentinelJsId}`; + const sentinelFiles = [ + "crates/pi-natives/src/lib.rs", + "packages/natives/native/index.d.ts", + "packages/natives/native/index.js", + ]; + await $`sd '__piNativesV[A-Za-z0-9_]+' ${sentinelName} ${sentinelFiles}`; + const libRs = await Bun.file("crates/pi-natives/src/lib.rs").text(); + if (!libRs.includes(`js_name = "${sentinelName}"`)) { + console.error( + `Error: pi-natives version sentinel did not move to ${sentinelName} in crates/pi-natives/src/lib.rs. ` + + "The `__piNativesV…` literal may have been removed or renamed; restore it before releasing.", + ); + process.exit(1); + } + console.log(` sentinel: ${sentinelName}\n`); + // 4. Regenerate lockfiles console.log("Regenerating lockfiles..."); await $`rm -f bun.lock`;