Files
WRNexusJS/scripts/publish-packages.ts
T
2026-07-30 13:36:29 +05:30

319 lines
11 KiB
TypeScript

/**
* Build every @wrnexus/* package (JS + .d.ts via tsup) and STAGE a publishable
* copy under .publish/<name>/ with a rewritten manifest:
* - main/module/types/exports/bin point at ./dist/*
* - workspace:* deps → ^<version>
* - `private` removed, publishConfig.registry set
* - runtime assets (ui components/, ui.css) copied alongside dist
*
* The dev package.json (pointing at src/) is never modified, so in-repo dev and
* tests keep working.
*
* bun run scripts/publish-packages.ts # build + stage all
* bun run scripts/publish-packages.ts core db # build + stage only these
*
* Then publish from the staging dirs (see printed order), or dry-run with npm pack.
*/
import { build } from "tsup";
import {
cpSync,
existsSync,
mkdirSync,
readdirSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
const packagesDir = join(repoRoot, "packages");
const stageRoot = join(repoRoot, ".publish");
const REGISTRY = "https://registry.npmjs.org/";
// First release is published PRIVATE ("restricted") so we can test before the
// world sees it; flip to public with `npm access public @wrnexus/<name>` (or set
// WRNEXUS_NPM_ACCESS=public to stage public manifests). Requires the `wrnexus`
// npm org to exist and be on a paid plan (private scoped packages need Teams).
const ACCESS = process.env.WRNEXUS_NPM_ACCESS === "public" ? "public" : "restricted";
const LICENSE = "MIT";
/** Extra (non-code) files each package must ship, relative to the package root. */
const ASSETS: Record<string, string[]> = {
"@wrnexus/ui": [
"components",
"ui.css",
"component-catalog.json",
"component-migrations.json",
"component-reference.json",
"COMPONENTS.md",
],
};
function packageAssets(m: Record<string, any>): string[] {
const declared = Array.isArray(m.files)
? m.files.filter(
(entry: unknown): entry is string =>
typeof entry === "string" &&
entry !== "src" &&
!entry.startsWith("src/") &&
entry !== "dist" &&
entry !== "README.md",
)
: [];
return [...new Set([...(ASSETS[m.name] ?? []), ...declared])];
}
interface Pkg {
dir: string;
name: string;
manifest: Record<string, any>;
}
function readPackages(filter: string[]): Pkg[] {
const out: Pkg[] = [];
for (const entry of readdirSync(packagesDir)) {
const dir = join(packagesDir, entry);
const pj = join(dir, "package.json");
if (!existsSync(pj)) continue;
const manifest = JSON.parse(readFileSync(pj, "utf8"));
if (!manifest.name?.startsWith("@wrnexus/")) continue;
if (filter.length && !filter.includes(entry) && !filter.includes(manifest.name)) continue;
out.push({ dir, name: manifest.name, manifest });
}
return out;
}
/** Topologically sort by @wrnexus deps so dependencies publish first. */
function topoSort(pkgs: Pkg[]): Pkg[] {
const byName = new Map(pkgs.map((p) => [p.name, p]));
const sorted: Pkg[] = [];
const seen = new Set<string>();
const visit = (p: Pkg) => {
if (seen.has(p.name)) return;
seen.add(p.name);
for (const dep of Object.keys(p.manifest.dependencies ?? {})) {
const d = byName.get(dep);
if (d) visit(d);
}
sorted.push(p);
};
pkgs.forEach(visit);
return sorted;
}
/**
* Build a tsup entry map { outName: absSourcePath } from every `./x.ts` target
* referenced by main/bin/exports. Object entries (unlike arrays) are NOT glob-
* expanded by tsup, so absolute Windows paths work. `outName` is the source path
* relative to `src/` without extension, matching the js()/dts() manifest mapping.
*/
function entriesOf(dir: string, m: Record<string, any>): Record<string, string> {
const map: Record<string, string> = {};
const add = (v: unknown) => {
if (typeof v !== "string" || !v.endsWith(".ts")) return;
const rel = v.replace(/^\.?\/?src\//, "").replace(/\.ts$/, "");
map[rel] = join(dir, v.replace(/^\.\//, "")).replace(/\\/g, "/");
};
add(m.main);
if (m.bin) Object.values(m.bin).forEach(add);
if (m.exports) for (const v of Object.values(m.exports)) add(v);
if (m.wrnexus?.plugin) {
const routesDir = join(dir, "src", "routes");
if (existsSync(routesDir)) {
for (const file of readdirSync(routesDir, { recursive: true }) as string[]) {
if (typeof file !== "string" || !file.endsWith(".ts")) continue;
const source = join(routesDir, file);
const outName = join("routes", file).replace(/\\/g, "/").replace(/\.ts$/, "");
map[outName] = source.replace(/\\/g, "/");
}
}
}
return map;
}
function removeDirectoryWithRetry(path: string): void {
try {
rmSync(path, {
recursive: true,
force: true,
maxRetries: 10,
retryDelay: 300,
});
} catch (error) {
const code = error && typeof error === "object" && "code" in error ? String(error.code) : "";
if (code === "EBUSY" || code === "EPERM") {
throw new Error(
`Unable to clean ${path}. Close any Explorer window, editor, terminal, antivirus scan, or process using this directory, then retry.`,
{ cause: error },
);
}
throw error;
}
}
/** Rewrite the manifest for publishing. */
function publishManifest(
m: Record<string, any>,
version: string,
workspaceVersions: Map<string, string>,
): Record<string, any> {
const js = (ts: string) => "./dist/" + ts.replace(/^\.?\/?src\//, "").replace(/\.ts$/, ".js");
const dts = (ts: string) => "./dist/" + ts.replace(/^\.?\/?src\//, "").replace(/\.ts$/, ".d.ts");
const out: Record<string, any> = {
name: m.name,
version,
type: "module",
description: m.description ?? `${m.name} — part of the WrNexus framework.`,
license: LICENSE,
main: m.main ? js(m.main) : "./dist/index.js",
module: m.main ? js(m.main) : "./dist/index.js",
types: m.main ? dts(m.main) : "./dist/index.d.ts",
engines: { bun: ">=1.3.0" },
publishConfig: { registry: REGISTRY, access: ACCESS },
};
// exports: rewrite .ts targets to { types, import }; copy non-.ts (assets) as-is.
if (m.exports) {
const ex: Record<string, any> = {};
for (const [key, val] of Object.entries(m.exports)) {
if (typeof val === "string" && val.endsWith(".ts")) {
ex[key] = { types: dts(val), import: js(val) };
} else {
ex[key] = val; // e.g. "./ui.css"
}
}
out.exports = ex;
} else {
out.exports = { ".": { types: out.types, import: out.main } };
}
if (m.bin) {
out.bin = {};
for (const [k, v] of Object.entries(m.bin)) out.bin[k] = js(v as string);
}
if (m.dependencies) {
out.dependencies = {};
for (const [dep, range] of Object.entries(m.dependencies)) {
out.dependencies[dep] = String(range).startsWith("workspace:")
? `^${workspaceVersions.get(dep) ?? version}`
: range;
}
}
if (m.wrnexus) {
out.wrnexus = structuredClone(m.wrnexus);
const plugin = out.wrnexus?.plugin?.plugin;
if (typeof plugin === "string" && plugin.endsWith(".ts")) {
out.wrnexus.plugin.plugin = js(plugin);
}
}
const files = new Set(["dist"]);
for (const a of packageAssets(m)) files.add(a);
out.files = [...files];
return out;
}
async function main() {
const filter = process.argv.slice(2);
const allPackages = readPackages([]);
const workspaceVersions = new Map(
allPackages.map((pkg) => [pkg.name, String(pkg.manifest.version)]),
);
const selected = filter.length
? allPackages.filter(
(pkg) => filter.includes(pkg.dir.split(/[\\/]/).pop()!) || filter.includes(pkg.name),
)
: allPackages;
const pkgs = topoSort(selected);
if (!pkgs.length) {
console.error("No @wrnexus packages matched.");
process.exit(1);
}
removeDirectoryWithRetry(stageRoot);
mkdirSync(stageRoot, { recursive: true });
const order: string[] = [];
for (const p of pkgs) {
const entries = entriesOf(p.dir, p.manifest);
process.stdout.write(`▸ ${p.name} … `);
await build({
entry: entries,
outDir: join(p.dir, "dist"),
format: ["esm"],
dts: true,
external: [/^@wrnexus\//, /^bun:/, /^node:/],
tsconfig: join(repoRoot, "tsconfig.json"),
clean: true,
splitting: false,
sourcemap: false,
shims: false,
silent: true,
target: "esnext",
platform: "node",
});
// tsup's dts pass can occasionally leak .d.ts files next to source (the
// jsxImportSource files). Source is all .ts, so any .d.ts under src/ is a
// stray artifact — remove it so it never pollutes the tree.
for (const f of readdirSync(join(p.dir, "src"), { recursive: true }) as string[]) {
if (typeof f === "string" && f.endsWith(".d.ts")) {
rmSync(join(p.dir, "src", f), { force: true });
}
}
// Stage: dist + assets + manifest + README.
const stage = join(stageRoot, p.name.replace("@wrnexus/", ""));
mkdirSync(stage, { recursive: true });
cpSync(join(p.dir, "dist"), join(stage, "dist"), { recursive: true });
for (const a of packageAssets(p.manifest)) {
if (existsSync(join(p.dir, a))) cpSync(join(p.dir, a), join(stage, a), { recursive: true });
}
const manifest = publishManifest(p.manifest, p.manifest.version, workspaceVersions);
writeFileSync(join(stage, "package.json"), JSON.stringify(manifest, null, 2) + "\n");
if (p.manifest.wrnexus && !manifest.wrnexus?.plugin?.plugin) {
throw new Error(`${p.name} lost its wrnexus.plugin metadata while staging.`);
}
for (const asset of packageAssets(p.manifest)) {
if (!existsSync(join(stage, asset))) {
throw new Error(`${p.name} did not stage declared runtime asset ${asset}.`);
}
}
// Ship the package's own README.md if it has one; else a minimal placeholder.
const srcReadme = join(p.dir, "README.md");
if (existsSync(srcReadme)) {
cpSync(srcReadme, join(stage, "README.md"));
} else {
writeFileSync(
join(stage, "README.md"),
`# ${p.name}\n\n${manifest.description}\n\nPart of the WrNexus framework.\n`,
);
}
order.push(p.name);
console.log("staged");
}
console.log(`\n✓ Staged ${order.length} package(s) under .publish/ (access: ${ACCESS})`);
console.log("Publish order (deps first):");
for (const n of order) {
console.log(` npm publish .publish/${n.replace("@wrnexus/", "")} --access ${ACCESS}`);
}
if (ACCESS === "restricted") {
console.log("\nAfter testing, flip every package to public with:");
for (const n of order) console.log(` npm access public ${n}`);
}
}
main().catch((err) => {
console.error(err);
process.exit(1);
});