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 {
|
in {
|
||||||
inherit workspacePreparePatched;
|
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 = {
|
mkNodeModules = {
|
||||||
pname,
|
pname,
|
||||||
version,
|
version,
|
||||||
@@ -67,13 +70,22 @@ in {
|
|||||||
--no-progress \
|
--no-progress \
|
||||||
${renderBunFilters filters}
|
${renderBunFilters filters}
|
||||||
|
|
||||||
|
bun --bun ${../nix/scripts/canonicalize-node-modules.ts}
|
||||||
|
bun --bun ${../nix/scripts/normalize-bun-binaries.ts}
|
||||||
|
|
||||||
runHook postBuild
|
runHook postBuild
|
||||||
'';
|
'';
|
||||||
|
|
||||||
installPhase = ''
|
installPhase = ''
|
||||||
runHook preInstall
|
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
|
runHook postInstall
|
||||||
'';
|
'';
|
||||||
|
|||||||
@@ -50,8 +50,9 @@ in
|
|||||||
|
|
||||||
nodeModules = common.mkNodeModules {
|
nodeModules = common.mkNodeModules {
|
||||||
inherit (finalAttrs) pname version src;
|
inherit (finalAttrs) pname version src;
|
||||||
outputHash = "sha256-mzcaRKIymMQb934wrto/mBuKt0KzncbzUQ0rzkCLlC4=";
|
outputHash = "sha256-vOCDwW/t7CbqHyeDE6Nvnlq0c9NO5T/2h1NJKLERGSs=";
|
||||||
filters = [
|
filters = [
|
||||||
|
"."
|
||||||
"./apps/desktop"
|
"./apps/desktop"
|
||||||
"./apps/server"
|
"./apps/server"
|
||||||
"./apps/web"
|
"./apps/web"
|
||||||
|
|||||||
@@ -31,8 +31,9 @@ in
|
|||||||
|
|
||||||
nodeModules = common.mkNodeModules {
|
nodeModules = common.mkNodeModules {
|
||||||
inherit (finalAttrs) pname version src;
|
inherit (finalAttrs) pname version src;
|
||||||
outputHash = "sha256-l0BXsHRRFPyWjdxWedAdS8K7VdXSzAfw5c+0caqzT6M=";
|
outputHash = "sha256-eXNOHRuNv9XFhXmsFtkunZswtRPd8gzJB1Jdw2DxYZY=";
|
||||||
filters = [
|
filters = [
|
||||||
|
"."
|
||||||
"./apps/server"
|
"./apps/server"
|
||||||
"./apps/web"
|
"./apps/web"
|
||||||
"./packages/client-runtime"
|
"./packages/client-runtime"
|
||||||
|
|||||||
Reference in New Issue
Block a user