Files
WRNexusJS/scripts/publish-packages.ts
T
ClintchizandClaude Opus 5 eeef2d79df fix(dev-server,security): repair two defects that only appear in a published build
The dev server shipped two entries, index and serve-entry, bundled
independently because the publish build set splitting:false. They share
pipeline.ts, which holds mutable module state -- compileCacheDir, set once
at startup by the bootstrap, and browserArtifactPaths, populated during
compilation and read when serving /__wrnexus/client/*. Duplicating the
module duplicated the state, so the writer and the reader addressed
different copies: every component client module 404'd and .wrn compilation
wrote nothing. It works from source, where there is one module instance,
which is why it reached a release. Emitting a shared chunk fixes it for
every package at once.

resetDevCache also ran several hundred lines after the plugin virtual
modules were written into the same directory, deleting them at every boot.
An app with no plugins never noticed; an app with one lost them every time.

Separately, secureCookieOptions spread ...options after its path default,
and setSecureCookie always forwards an explicit path key -- so omitting
path emitted a cookie with no Path at all, which the browser then scoped to
the request's directory.

Verified end to end against a real app installing the published packages:
17 artifacts written, client modules 200, and the sign-in form submits from
the UI and reaches /dashboard.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 09:04:12 +05:30

357 lines
12 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";
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/<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";
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<string, string[]> = {
"@wrnexus/ui": [
"components",
"styles",
"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,
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<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", "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);
});