/** * Build every @wrnexus/* package (JS + .d.ts via tsup) and STAGE a publishable * copy under .publish// with a rewritten manifest: * - main/module/types/exports/bin point at ./dist/* * - workspace:* deps → ^ * - `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"; import process from "node:process"; import { validateAndHashStage, type StagedPackageIntegrity } from "./lib/package-integrity.ts"; const repoRoot = join(dirname(fileURLToPath(import.meta.url)), ".."); const packagesDir = join(repoRoot, "packages"); const stageRoot = join(repoRoot, ".publish"); const REGISTRY = process.env.WRNEXUS_NPM_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/` (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"; const REPOSITORY = "https://git.workroot.in/WorkRoot/WRNexusJS.git"; const HOMEPAGE = "https://wrnexusjs.dev"; /** Extra (non-code) files each package must ship, relative to the package root. */ const ASSETS: Record = { "@wrnexus/ui": [ "components", "styles", "ui.css", "component-catalog.json", "component-migrations.json", "component-reference.json", "COMPONENTS.md", ], }; function packageAssets(m: Record): 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; } 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(); 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): Record { const map: Record = {}; 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, version: string, workspaceVersions: Map, ): Record { 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 = { name: m.name, version, type: "module", description: m.description ?? `${m.name} — part of the WrNexus framework.`, license: LICENSE, repository: { type: "git", url: REPOSITORY, directory: `packages/${String(m.name).replace("@wrnexus/", "")}`, }, homepage: `${HOMEPAGE}/packages/${String(m.name).replace("@wrnexus/", "")}`, bugs: { url: `${REPOSITORY.replace(/\.git$/, "")}/issues` }, keywords: ["wrnexus", "bun", "typescript", String(m.name).replace("@wrnexus/", "")], sideEffects: m.sideEffects ?? false, 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 = {}; 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", "README.md"]); 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[] = []; const packageIntegrity: StagedPackageIntegrity[] = []; 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, // Shared modules must be emitted ONCE as a chunk both entries import, // not inlined into each. Several packages hold mutable module state -- // dev-server's compileCacheDir and browserArtifactPaths are set by one // entry and read by another -- and duplicating the module duplicates the // state, so the writer and the reader silently address different copies. // This only manifests in a published build; from source there is one // module instance and everything works. splitting: true, 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`, ); } packageIntegrity.push(validateAndHashStage(stage, manifest)); order.push(p.name); console.log("staged + verified"); } const integrityDocument = { schemaVersion: 1, frameworkVersion: String( JSON.parse(readFileSync(join(repoRoot, "package.json"), "utf8")).version, ), access: ACCESS, packages: packageIntegrity, }; writeFileSync( join(stageRoot, "PACKAGE-INTEGRITY.json"), `${JSON.stringify(integrityDocument, null, 2)}\n`, ); console.log( `\n✓ Staged and verified ${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); });