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,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);
|
||||
}
|
||||
Reference in New Issue
Block a user