228 lines
9.0 KiB
TypeScript
228 lines
9.0 KiB
TypeScript
/** Local release preparation only. Never authenticates to or publishes to a registry. */
|
|
import {
|
|
existsSync,
|
|
readdirSync,
|
|
readFileSync,
|
|
writeFileSync,
|
|
mkdirSync,
|
|
mkdtempSync,
|
|
rmSync,
|
|
} from "node:fs";
|
|
import { basename, dirname, join, resolve, sep } from "node:path";
|
|
import { tmpdir } from "node:os";
|
|
import { spawnSync } from "node:child_process";
|
|
import { validateAndHashStage } from "./lib/package-integrity.ts";
|
|
const root = resolve(import.meta.dir, "..");
|
|
const output = join(root, "output", "design-system-phase-7");
|
|
const stage = join(root, ".publish-design");
|
|
const mode = process.argv[2] ?? "plan";
|
|
const all = new Map(
|
|
readdirSync(join(root, "packages")).flatMap((name) => {
|
|
const file = join(root, "packages", name, "package.json");
|
|
if (!existsSync(file)) return [];
|
|
const manifest = JSON.parse(readFileSync(file, "utf8"));
|
|
return [[manifest.name, { directory: name, ...manifest }]];
|
|
}),
|
|
);
|
|
const names = new Set<string>();
|
|
function visit(name: string) {
|
|
if (names.has(name)) return;
|
|
const pkg = all.get(name);
|
|
if (!pkg) throw Error("Missing workspace package " + name);
|
|
names.add(name);
|
|
for (const dependency of Object.keys({
|
|
...pkg.dependencies,
|
|
...pkg.optionalDependencies,
|
|
...pkg.peerDependencies,
|
|
}))
|
|
if (dependency.startsWith("@wrnexus/")) visit(dependency);
|
|
}
|
|
for (const name of ["@wrnexus/design-system", "@wrnexus/design-studio", "@wrnexus/mcp"])
|
|
visit(name);
|
|
mkdirSync(output, { recursive: true });
|
|
const plan = {
|
|
formatVersion: 1,
|
|
stageDirectory: stage,
|
|
packages: [...names].map((name) => ({ name, version: all.get(name).version })),
|
|
schemaVersion: 1,
|
|
contractVersion: 1,
|
|
designPackageVersion: 1,
|
|
registryAvailability: "not-checked",
|
|
published: false,
|
|
notes: [
|
|
"Source versions remain unchanged. Confirm unused release versions before publication.",
|
|
"Use the existing version/release process for production; this command only stages and packs locally.",
|
|
"Production-wide API/visual/type gates must pass separately before publication.",
|
|
],
|
|
};
|
|
writeFileSync(join(output, "release-plan.json"), JSON.stringify(plan, null, 2));
|
|
function run(command: string, args: string[], cwd = root) {
|
|
const result = spawnSync(command, args, {
|
|
cwd,
|
|
encoding: "utf8",
|
|
maxBuffer: 20 * 1024 * 1024,
|
|
env: { ...process.env, WRNEXUS_DESIGN_REPORT: join(output, "consumer-report.json") },
|
|
});
|
|
if (result.status !== 0)
|
|
throw Error(
|
|
`${basename(command)} failed: ${result.error?.message ?? result.stderr ?? result.stdout}`,
|
|
);
|
|
return result.stdout;
|
|
}
|
|
if (mode === "plan") {
|
|
console.log(JSON.stringify(plan, null, 2));
|
|
} else if (mode === "stage") {
|
|
console.log(
|
|
run(process.execPath, [
|
|
"run",
|
|
"scripts/publish-packages.ts",
|
|
"design-system",
|
|
"design-studio",
|
|
"mcp",
|
|
"--with-dependencies",
|
|
"--stage-root=.publish-design",
|
|
]),
|
|
);
|
|
} else if (mode === "check") {
|
|
writeFileSync(
|
|
join(output, "consumer-report.json"),
|
|
JSON.stringify({ packagingVerified: false, published: false, status: "running" }, null, 2),
|
|
);
|
|
const integrity = JSON.parse(readFileSync(join(stage, "PACKAGE-INTEGRITY.json"), "utf8"));
|
|
if (
|
|
integrity.packages.length !== names.size ||
|
|
integrity.packages.some(
|
|
(pkg: { name: string; version: string }) =>
|
|
!names.has(pkg.name) || pkg.version !== all.get(pkg.name)?.version,
|
|
)
|
|
)
|
|
throw Error("Staged closure differs from release plan. Restage all design packages.");
|
|
const node = Bun.which("node");
|
|
if (!node) throw Error("Node.js with npm is required to verify tarballs.");
|
|
const npm = join(dirname(node), "node_modules", "npm", "bin", "npm-cli.js");
|
|
if (!existsSync(npm)) throw Error("Unable to locate npm CLI.");
|
|
const tarballs = join(output, "tarballs");
|
|
mkdirSync(tarballs, { recursive: true });
|
|
const dependencies: Record<string, string> = {};
|
|
const packed = [];
|
|
for (const pkg of integrity.packages) {
|
|
const folder = join(stage, pkg.name.slice("@wrnexus/".length));
|
|
const manifest = JSON.parse(readFileSync(join(folder, "package.json"), "utf8"));
|
|
const actual = validateAndHashStage(folder, manifest);
|
|
if (JSON.stringify(actual) !== JSON.stringify(pkg))
|
|
throw Error("Staged integrity mismatch for " + pkg.name);
|
|
const files = JSON.parse(
|
|
run(node, [
|
|
npm,
|
|
"pack",
|
|
folder,
|
|
"--pack-destination",
|
|
tarballs,
|
|
"--json",
|
|
"--ignore-scripts",
|
|
]),
|
|
);
|
|
dependencies[pkg.name] = "file:" + join(tarballs, files[0].filename).replace(/\\/g, "/");
|
|
packed.push({
|
|
name: pkg.name,
|
|
version: manifest.version,
|
|
filename: files[0].filename,
|
|
integrity: files[0].integrity,
|
|
});
|
|
}
|
|
writeFileSync(join(output, "tarballs.json"), JSON.stringify(packed, null, 2));
|
|
const consumer = mkdtempSync(join(tmpdir(), "wrnexus-design-release-"));
|
|
try {
|
|
writeFileSync(
|
|
join(consumer, "package.json"),
|
|
JSON.stringify({
|
|
name: "design-release-consumer",
|
|
private: true,
|
|
type: "module",
|
|
dependencies,
|
|
}),
|
|
);
|
|
console.log(
|
|
run(node, [npm, "install", "--ignore-scripts", "--no-audit", "--no-fund"], consumer),
|
|
);
|
|
writeFileSync(
|
|
join(consumer, "probe.ts"),
|
|
readFileSync(join(root, "scripts", "design-release-consumer.ts"), "utf8"),
|
|
);
|
|
console.log(run(process.execPath, ["probe.ts"], consumer));
|
|
writeFileSync(
|
|
join(consumer, "types.ts"),
|
|
'import {CORE_DESIGN_SYSTEMS} from "@wrnexus/design-system"; import {createDesignPackage} from "@wrnexus/design-system/package"; import {StudioSession} from "@wrnexus/design-studio"; import {planDesignUpgrade} from "@wrnexus/design-studio/upgrades"; import {createFrameworkMcpServer} from "@wrnexus/mcp"; const session = new StudioSession(CORE_DESIGN_SYSTEMS.square); const artifact = await createDesignPackage(session.valid); await planDesignUpgrade(session, JSON.stringify(artifact)); createFrameworkMcpServer(".", {design:true,designWrite:true}); const recovery: string | undefined = session.recoverySource; console.log(recovery);',
|
|
);
|
|
writeFileSync(
|
|
join(consumer, "tsconfig.json"),
|
|
JSON.stringify({
|
|
compilerOptions: {
|
|
noEmit: true,
|
|
module: "ESNext",
|
|
moduleResolution: "bundler",
|
|
target: "ESNext",
|
|
skipLibCheck: true,
|
|
types: [],
|
|
lib: ["ESNext", "DOM"],
|
|
},
|
|
files: ["types.ts"],
|
|
}),
|
|
);
|
|
run(
|
|
node,
|
|
[join(root, "node_modules", "typescript", "bin", "tsc"), "--project", "tsconfig.json"],
|
|
consumer,
|
|
);
|
|
console.log("Installed package declarations passed consumer type checking.");
|
|
} finally {
|
|
// The path comes exclusively from mkdtemp; verify its workspace-independent boundary before removal.
|
|
if (
|
|
!resolve(consumer).startsWith(resolve(tmpdir()) + sep) ||
|
|
!basename(consumer).startsWith("wrnexus-design-release-")
|
|
)
|
|
console.error("Skipped cleanup: invalid consumer boundary.");
|
|
else if (process.argv.includes("--keep-consumer")) {
|
|
writeFileSync(
|
|
join(output, "consumer-path.json"),
|
|
JSON.stringify({ directory: consumer }, null, 2),
|
|
);
|
|
console.log("Retained installed consumer for local QA: " + consumer);
|
|
} else rmSync(consumer, { recursive: true, force: true });
|
|
}
|
|
const solo = mkdtempSync(join(tmpdir(), "wrnexus-design-release-"));
|
|
try {
|
|
writeFileSync(
|
|
join(solo, "package.json"),
|
|
JSON.stringify({
|
|
name: "mcp-only-release-consumer",
|
|
private: true,
|
|
type: "module",
|
|
dependencies: { "@wrnexus/mcp": dependencies["@wrnexus/mcp"] },
|
|
}),
|
|
);
|
|
run(node, [npm, "install", "--ignore-scripts", "--no-audit", "--no-fund"], solo);
|
|
writeFileSync(
|
|
join(solo, "probe.ts"),
|
|
'import assert from "node:assert/strict"; import {createFrameworkMcpServer} from "@wrnexus/mcp"; import {existsSync} from "node:fs"; assert.equal(existsSync("node_modules/@wrnexus/design-studio"),false); assert.equal(createFrameworkMcpServer(".").tools().length,8); await assert.rejects(createFrameworkMcpServer(".",{design:true}).call("wrnexus_design_context"),/WRN-MCP-DESIGN-INSTALL/); console.log("MCP-only install keeps default APIs without requiring Studio.");',
|
|
);
|
|
console.log(run(process.execPath, ["probe.ts"], solo));
|
|
} finally {
|
|
if (
|
|
resolve(solo).startsWith(resolve(tmpdir()) + sep) &&
|
|
basename(solo).startsWith("wrnexus-design-release-")
|
|
)
|
|
rmSync(solo, { recursive: true, force: true });
|
|
}
|
|
const reportPath = join(output, "consumer-report.json");
|
|
const report = JSON.parse(readFileSync(reportPath, "utf8"));
|
|
report.packagingVerified = true;
|
|
report.checkedAt = new Date().toISOString();
|
|
report.checks.push(
|
|
"installed declaration consumer type check",
|
|
"MCP-only installation without optional Studio peer",
|
|
);
|
|
writeFileSync(reportPath, JSON.stringify(report, null, 2));
|
|
console.log("Local tarballs verified. No registry publication performed.");
|
|
} else throw Error("Usage: bun scripts/prepare-design-release.ts plan | stage | check");
|