From 94864cb5e414095c2d5aae43fcc656f8151257e5 Mon Sep 17 00:00:00 2001 From: unexplrd Date: Fri, 24 Apr 2026 23:14:57 +0300 Subject: [PATCH] Add reproducible Bun packaging for desktop and server - Add shared Nix node-modules derivation and Bun normalization scripts - Package the desktop and server apps from the normalized install tree --- nix/desktop-package.nix | 1 + nix/scripts/canonicalize-node-modules.ts | 104 ++++++++++ nix/scripts/normalize-bun-binaries.ts | 230 +++++++++++++++++++++++ nix/server-package.nix | 1 + packages/common.nix | 14 +- packages/desktop/default.nix | 3 +- packages/server/default.nix | 3 +- 7 files changed, 353 insertions(+), 3 deletions(-) create mode 100644 nix/desktop-package.nix create mode 100644 nix/scripts/canonicalize-node-modules.ts create mode 100644 nix/scripts/normalize-bun-binaries.ts create mode 100644 nix/server-package.nix diff --git a/nix/desktop-package.nix b/nix/desktop-package.nix new file mode 100644 index 0000000..aa82d79 --- /dev/null +++ b/nix/desktop-package.nix @@ -0,0 +1 @@ +import ../packages/desktop diff --git a/nix/scripts/canonicalize-node-modules.ts b/nix/scripts/canonicalize-node-modules.ts new file mode 100644 index 0000000..76fc11b --- /dev/null +++ b/nix/scripts/canonicalize-node-modules.ts @@ -0,0 +1,104 @@ +#!/usr/bin/env bun +// Canonicalize Bun-managed install indirection without modifying package contents. + +import { + lstat, + readdir, + readlink, + symlink, + unlink, +} from "node:fs/promises"; +import { dirname, join, relative, resolve } from "node:path"; + +const nodeModulesRoot = resolve(process.cwd(), process.argv[2] ?? "node_modules"); + +async function pathExists(path: string): Promise { + try { + await lstat(path); + return true; + } catch { + return false; + } +} + +function lexicalSort(values: Iterable): string[] { + return [...values].sort((left, right) => left.localeCompare(right, "en")); +} + +function normalizeRelativeTarget(fromDir: string, targetPath: string): string { + const target = relative(fromDir, targetPath) || "."; + return target.replaceAll(/\/+/g, "/"); +} + +async function rewriteSymlink(linkPath: string, targetPath: string): Promise { + const nextTarget = normalizeRelativeTarget(dirname(linkPath), targetPath); + const currentTarget = await readlink(linkPath).catch(() => null); + if (currentTarget === nextTarget) { + return; + } + + await unlink(linkPath); + await symlink(nextTarget, linkPath); +} + +async function canonicalizeManagedSymlink(linkPath: string): Promise { + const linkDir = dirname(linkPath); + const currentTarget = await readlink(linkPath).catch(() => null); + if (currentTarget === null || currentTarget.length === 0) { + return; + } + + const resolvedTarget = resolve(linkDir, currentTarget); + if (await pathExists(resolvedTarget)) { + await rewriteSymlink(linkPath, resolvedTarget); + return; + } + + const normalizedBrokenTarget = normalizeRelativeTarget(linkDir, resolvedTarget); + if (currentTarget !== normalizedBrokenTarget) { + await unlink(linkPath); + await symlink(normalizedBrokenTarget, linkPath); + } +} + +async function readSortedDir(path: string): Promise { + return lexicalSort(await readdir(path)); +} + +function isNodeModulesPackageEntry(path: string): boolean { + const parent = dirname(path); + const grandparent = dirname(parent); + return parent.endsWith("/node_modules") || grandparent.endsWith("/node_modules"); +} + +async function walkNodeModules(path: string): Promise { + const entries = await readSortedDir(path); + for (const entry of entries) { + const entryPath = join(path, entry); + const stats = await lstat(entryPath); + + if (stats.isSymbolicLink()) { + const relativePath = relative(nodeModulesRoot, entryPath); + const inBunStore = relativePath === ".bun" || relativePath.startsWith(".bun/"); + const isWorkspaceLink = isNodeModulesPackageEntry(entryPath); + if (inBunStore || isWorkspaceLink) { + await canonicalizeManagedSymlink(entryPath); + } + continue; + } + + if (!stats.isDirectory()) { + continue; + } + + if (entry === ".bin") { + continue; + } + + await walkNodeModules(entryPath); + } +} + +if (await pathExists(nodeModulesRoot)) { + await walkNodeModules(nodeModulesRoot); +} diff --git a/nix/scripts/normalize-bun-binaries.ts b/nix/scripts/normalize-bun-binaries.ts new file mode 100644 index 0000000..951bd4a --- /dev/null +++ b/nix/scripts/normalize-bun-binaries.ts @@ -0,0 +1,230 @@ +#!/usr/bin/env bun +// Normalize Bun-generated .bin entries to stable symlinks or byte-stable wrappers. + +import { + chmod, + lstat, + readdir, + readFile, + readlink, + symlink, + unlink, + writeFile, +} from "node:fs/promises"; +import { basename, dirname, join, relative, resolve } from "node:path"; + +const nodeModulesRoot = resolve(process.cwd(), process.argv[2] ?? "node_modules"); +async function pathExists(path: string): Promise { + try { + await lstat(path); + return true; + } catch { + return false; + } +} + +function lexicalSort(values: Iterable): string[] { + return [...values].sort((left, right) => left.localeCompare(right, "en")); +} + +function commandNameFromPackage(name: string): string { + const segments = name.split("/"); + return segments[segments.length - 1]!; +} + +function normalizeRelativeTarget(fromDir: string, targetPath: string): string { + const target = relative(fromDir, targetPath) || "."; + return target.replaceAll(/\/+/g, "/"); +} + +function isBinDirectory(path: string): boolean { + return basename(path) === ".bin" && dirname(path).endsWith("node_modules"); +} + +async function readSortedDir(path: string): Promise { + return lexicalSort(await readdir(path)); +} + +async function collectPackageDirectories(nodeModulesDir: string): Promise { + const packageDirs: string[] = []; + for (const entry of await readSortedDir(nodeModulesDir)) { + if (entry === ".bin") { + continue; + } + + const entryPath = join(nodeModulesDir, entry); + const stats = await lstat(entryPath); + if (!stats.isDirectory() && !stats.isSymbolicLink()) { + continue; + } + + if (entry.startsWith("@")) { + for (const scopedEntry of await readSortedDir(entryPath).catch(() => [])) { + packageDirs.push(join(entryPath, scopedEntry)); + } + continue; + } + + packageDirs.push(entryPath); + } + + return packageDirs; +} + +async function readPackageJson(path: string): Promise { + try { + return JSON.parse(await readFile(path, "utf8")); + } catch { + return null; + } +} + +async function collectBinCandidates(nodeModulesDir: string): Promise> { + const bins = new Map(); + for (const packageDir of await collectPackageDirectories(nodeModulesDir)) { + const packageJson = await readPackageJson(join(packageDir, "package.json")); + if (packageJson === null || packageJson.bin === undefined) { + continue; + } + + const register = (command: string, relativeTarget: string) => { + const targetPath = resolve(packageDir, relativeTarget); + const candidates = bins.get(command) ?? []; + candidates.push(targetPath); + bins.set(command, candidates); + }; + + if (typeof packageJson.bin === "string" && typeof packageJson.name === "string") { + register(commandNameFromPackage(packageJson.name), packageJson.bin); + continue; + } + + if (packageJson.bin && typeof packageJson.bin === "object") { + for (const command of lexicalSort(Object.keys(packageJson.bin))) { + const relativeTarget = packageJson.bin[command]; + if (typeof relativeTarget === "string") { + register(command, relativeTarget); + } + } + } + } + + for (const [command, targets] of bins) { + bins.set(command, lexicalSort(new Set(targets))); + } + return bins; +} + +async function resolveCurrentTarget(binPath: string): Promise { + const stats = await lstat(binPath); + if (stats.isSymbolicLink()) { + const currentTarget = await readlink(binPath); + return resolve(dirname(binPath), currentTarget); + } + + if (!stats.isFile()) { + return null; + } + + const content = await readFile(binPath, "utf8"); + const launchTargetMatch = + content.match(/(?:exec|run)\s+["']?([^"'$\s][^"'$\n]*)["']?/) ?? + content.match(/(?:^|\s)(\.\.\/[^\s"'`]+|\.\/[^\s"'`]+)(?:\s|$)/m); + if (launchTargetMatch) { + return resolve(dirname(binPath), launchTargetMatch[1]!); + } + + return null; +} + +async function rewriteSymlink(binPath: string, targetPath: string): Promise { + const nextTarget = normalizeRelativeTarget(dirname(binPath), targetPath); + const currentTarget = await readlink(binPath).catch(() => null); + if (currentTarget === nextTarget) { + return; + } + + await unlink(binPath).catch(() => undefined); + await symlink(nextTarget, binPath); +} + +async function writeWrapper(binPath: string, targetPath: string): Promise { + const launcherTarget = normalizeRelativeTarget(dirname(binPath), targetPath); + const wrapper = `#!/usr/bin/env sh +set -eu +script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +exec "$script_dir/${launcherTarget}" "$@" +`; + await writeFile(binPath, wrapper, "utf8"); + await chmod(binPath, 0o755); +} + +async function normalizeBinEntry(binDir: string, entry: string, candidates: Map): Promise { + const binPath = join(binDir, entry); + const stats = await lstat(binPath); + const candidateTargets = candidates.get(entry) ?? []; + const currentTarget = await resolveCurrentTarget(binPath); + + let selectedTarget: string | null = null; + if (candidateTargets.length === 1) { + selectedTarget = candidateTargets[0]!; + } else if (currentTarget !== null) { + selectedTarget = candidateTargets.find((candidate) => resolve(candidate) === resolve(currentTarget)) ?? null; + } + + if (stats.isSymbolicLink()) { + if (selectedTarget !== null && (await pathExists(selectedTarget))) { + await rewriteSymlink(binPath, selectedTarget); + } + return; + } + + if (!stats.isFile()) { + return; + } + + if (selectedTarget !== null && (await pathExists(selectedTarget))) { + await unlink(binPath); + await symlink(normalizeRelativeTarget(dirname(binPath), selectedTarget), binPath); + return; + } + + const content = await readFile(binPath, "utf8"); + const homePath = process.env.HOME ?? ""; + const hasMachinePath = + content.includes("/build/") || + content.includes("/tmp/") || + (homePath.length > 0 && content.includes(homePath)); + if (hasMachinePath) { + if (currentTarget !== null && (await pathExists(currentTarget))) { + await writeWrapper(binPath, currentTarget); + } + } + await chmod(binPath, 0o755); +} + +async function walk(path: string): Promise { + const stats = await lstat(path); + if (!stats.isDirectory()) { + return; + } + + if (isBinDirectory(path)) { + const candidates = await collectBinCandidates(dirname(path)); + for (const entry of await readSortedDir(path)) { + await normalizeBinEntry(path, entry, candidates); + } + return; + } + + for (const entry of await readSortedDir(path)) { + if (entry === "." || entry === "..") { + continue; + } + await walk(join(path, entry)); + } +} + +if (await pathExists(nodeModulesRoot)) { + await walk(nodeModulesRoot); +} diff --git a/nix/server-package.nix b/nix/server-package.nix new file mode 100644 index 0000000..f00e65a --- /dev/null +++ b/nix/server-package.nix @@ -0,0 +1 @@ +import ../packages/server diff --git a/packages/common.nix b/packages/common.nix index cc86953..ce1fc2c 100644 --- a/packages/common.nix +++ b/packages/common.nix @@ -20,6 +20,9 @@ in { inherit workspacePreparePatched; + # `bun.lock` pins dependency resolution, but Bun's materialized install tree is + # not stable enough to hash directly across machines. Normalize Bun-managed + # layout before the fixed-output copy, and keep native rebuilds outside the FOD. mkNodeModules = { pname, version, @@ -67,13 +70,22 @@ in { --no-progress \ ${renderBunFilters filters} + bun --bun ${../nix/scripts/canonicalize-node-modules.ts} + bun --bun ${../nix/scripts/normalize-bun-binaries.ts} + runHook postBuild ''; installPhase = '' runHook preInstall - cp -LR ./node_modules $out + mkdir -p "$out" + cp -RP ./node_modules/. "$out/" + + find "$out" -type d -exec chmod u+rwx,go+rx {} + + find "$out" -type f -exec chmod u+rw,go+r {} + + find "$out" -type f \( -path "$out/.bin/*" -o -path '*/node_modules/.bin/*' \) -exec chmod u+rwx,go+rx {} + + find "$out" -exec touch -h -d '@1' {} + runHook postInstall ''; diff --git a/packages/desktop/default.nix b/packages/desktop/default.nix index da6a199..e682c06 100644 --- a/packages/desktop/default.nix +++ b/packages/desktop/default.nix @@ -50,8 +50,9 @@ in nodeModules = common.mkNodeModules { inherit (finalAttrs) pname version src; - outputHash = "sha256-mzcaRKIymMQb934wrto/mBuKt0KzncbzUQ0rzkCLlC4="; + outputHash = "sha256-vOCDwW/t7CbqHyeDE6Nvnlq0c9NO5T/2h1NJKLERGSs="; filters = [ + "." "./apps/desktop" "./apps/server" "./apps/web" diff --git a/packages/server/default.nix b/packages/server/default.nix index e50de39..d89636d 100644 --- a/packages/server/default.nix +++ b/packages/server/default.nix @@ -31,8 +31,9 @@ in nodeModules = common.mkNodeModules { inherit (finalAttrs) pname version src; - outputHash = "sha256-l0BXsHRRFPyWjdxWedAdS8K7VdXSzAfw5c+0caqzT6M="; + outputHash = "sha256-eXNOHRuNv9XFhXmsFtkunZswtRPd8gzJB1Jdw2DxYZY="; filters = [ + "." "./apps/server" "./apps/web" "./packages/client-runtime"