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
This commit is contained in:
2026-04-24 23:14:57 +03:00
parent 575ade4b81
commit 94864cb5e4
7 changed files with 353 additions and 3 deletions
+1
View File
@@ -0,0 +1 @@
import ../packages/desktop
+104
View File
@@ -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<boolean> {
try {
await lstat(path);
return true;
} catch {
return false;
}
}
function lexicalSort(values: Iterable<string>): 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<void> {
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<void> {
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<string[]> {
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<void> {
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);
}
+230
View File
@@ -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<boolean> {
try {
await lstat(path);
return true;
} catch {
return false;
}
}
function lexicalSort(values: Iterable<string>): 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<string[]> {
return lexicalSort(await readdir(path));
}
async function collectPackageDirectories(nodeModulesDir: string): Promise<string[]> {
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<any | null> {
try {
return JSON.parse(await readFile(path, "utf8"));
} catch {
return null;
}
}
async function collectBinCandidates(nodeModulesDir: string): Promise<Map<string, string[]>> {
const bins = new Map<string, string[]>();
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<string | null> {
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<void> {
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<void> {
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<string, string[]>): Promise<void> {
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<void> {
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);
}
+1
View File
@@ -0,0 +1 @@
import ../packages/server
+13 -1
View File
@@ -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
'';
+2 -1
View File
@@ -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"
+2 -1
View File
@@ -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"