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:
@@ -0,0 +1 @@
|
||||
import ../packages/desktop
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
import ../packages/server
|
||||
+13
-1
@@ -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
|
||||
'';
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user