0cf92db43b
- relocate Bun normalization helpers under `packages/scripts` - update shared package build logic to use the new paths - remove obsolete top-level package wrappers
231 lines
6.6 KiB
TypeScript
231 lines
6.6 KiB
TypeScript
#!/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);
|
|
}
|