/** Independent private releases for changed @wrnexus packages. */ import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; import { join, resolve } from "node:path"; import { spawnSync } from "node:child_process"; const root = resolve(import.meta.dir, ".."); const npm = process.platform === "win32" ? "npm.cmd" : "npm"; const mode = process.argv[2] ?? "plan"; const requested = process.argv.slice(3).filter((arg) => !arg.startsWith("--")); interface PackageInfo { dir: string; name: string; file: string; manifest: Record; } function run(command: string, args: string[], capture = false, allowFailure = false) { const result = spawnSync(command, args, { cwd: root, encoding: capture ? "utf8" : undefined, stdio: capture ? "pipe" : "inherit", }); if (result.error) throw result.error; if (result.status !== 0 && !allowFailure) { throw new Error(`${command} ${args.join(" ")} failed with ${result.status}`); } return result; } function allPackages(): PackageInfo[] { return readdirSync(join(root, "packages"), { withFileTypes: true }).flatMap((entry) => { const file = join(root, "packages", entry.name, "package.json"); if (!entry.isDirectory() || !existsSync(file)) return []; const manifest = JSON.parse(readFileSync(file, "utf8")); return manifest.name?.startsWith("@wrnexus/") ? [{ dir: entry.name, name: manifest.name, file, manifest }] : []; }); } function changedPaths(): string[] { const tracked = String(run("git", ["diff", "HEAD", "--name-only"], true).stdout ?? ""); const untracked = String( run("git", ["ls-files", "--others", "--exclude-standard"], true).stdout ?? "", ); return `${tracked}\n${untracked}` .split(/\r?\n/) .filter(Boolean) .map((path) => path.replace(/\\/g, "/")); } function selectChanged(all: PackageInfo[]): PackageInfo[] { if (requested.length) { return all.filter((pkg) => requested.includes(pkg.dir) || requested.includes(pkg.name)); } const paths = changedPaths(); return all.filter((pkg) => paths.some((path) => path.startsWith(`packages/${pkg.dir}/`))); } function patch(version: string): string { const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version); if (!match) throw new Error(`Cannot patch-bump invalid package version ${version}`); return `${match[1]}.${match[2]}.${Number(match[3]) + 1}`; } function syncWorkspaceLock(pkgs: PackageInfo[]): void { const file = join(root, "bun.lock"); let source = readFileSync(file, "utf8"); for (const pkg of pkgs) { const escaped = pkg.dir.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const pattern = new RegExp( `("packages/${escaped}": \\{\\r?\\n\\s+"name": "[^"]+",\\r?\\n\\s+"version": ")[^"]+(")`, ); if (!pattern.test(source)) throw new Error(`bun.lock is missing workspace package ${pkg.name}`); source = source.replace(pattern, `$1${pkg.manifest.version}$2`); } writeFileSync(file, source); } function registryHas(pkg: PackageInfo): boolean { return ( run(npm, ["view", `${pkg.name}@${pkg.manifest.version}`, "version", "--json"], true, true) .status === 0 ); } function unpublished(all: PackageInfo[]): PackageInfo[] { return all.filter((pkg) => !registryHas(pkg)); } function print(pkgs: PackageInfo[], action: string): void { if (!pkgs.length) { console.log(`No packages need ${action}.`); return; } console.log(`Packages selected for ${action}:`); for (const pkg of pkgs) console.log(` ${pkg.name}@${pkg.manifest.version}`); } const all = allPackages(); if (mode === "plan") { const selected = selectChanged(all); print(selected, "a patch release"); for (const pkg of selected) console.log(` -> ${patch(pkg.manifest.version)}`); } else if (mode === "prepare") { const selected = selectChanged(all); if (!selected.length) throw new Error("No changed packages found. Pass package names explicitly if needed."); for (const pkg of selected) { pkg.manifest.version = patch(String(pkg.manifest.version)); writeFileSync(pkg.file, `${JSON.stringify(pkg.manifest, null, 2)}\n`); } syncWorkspaceLock(selected); // Bun currently leaves workspace manifest versions stale when only // --lockfile-only is used. The workspace entries are synchronized above; // this install refreshes dependency resolution without changing versions. run(process.execPath, ["install"]); run(process.execPath, ["run", "scripts/publish-packages.ts", ...selected.map((pkg) => pkg.name)]); print(selected, "release preparation"); console.log( "Review and commit the version and lockfile changes, then run bun run release:changed:publish.", ); } else if (mode === "publish") { const selected = requested.length ? selectChanged(all) : unpublished(all); if (!selected.length) throw new Error("Every local package version is already published."); run(process.execPath, ["run", "check:component-imports"]); run(process.execPath, ["test", ...selected.map((pkg) => `packages/${pkg.dir}`)]); run(process.execPath, ["run", "scripts/publish-packages.ts", ...selected.map((pkg) => pkg.name)]); run(npm, ["whoami"]); for (const pkg of selected) { const stage = join(root, ".publish", pkg.dir); if (!registryHas(pkg)) run(npm, ["publish", stage, "--access", "restricted"]); console.log(` ✓ ${pkg.name}@${pkg.manifest.version}`); } } else { throw new Error("Usage: bun scripts/release-changed.ts plan | prepare | publish [package ...]"); }