319 lines
11 KiB
TypeScript
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);
|
|
});
|