paperclip/scripts/release-package-map.mjs

347 lines
10 KiB
JavaScript

#!/usr/bin/env node
import { readdirSync, readFileSync, writeFileSync, existsSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join, resolve } from "node:path";
const __dirname = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(__dirname, "..");
const manifestPath = join(repoRoot, "scripts", "release-package-manifest.json");
const roots = ["packages", "server", "ui", "cli"];
const CHANNEL_ENTRYPOINT_PACKAGE = "paperclipai";
function readJson(filePath) {
return JSON.parse(readFileSync(filePath, "utf8"));
}
function discoverPublicPackages() {
const packages = [];
function walk(relDir) {
const absDir = join(repoRoot, relDir);
if (!existsSync(absDir)) return;
const pkgPath = join(absDir, "package.json");
if (existsSync(pkgPath)) {
const pkg = readJson(pkgPath);
if (!pkg.private) {
packages.push({
dir: relDir,
pkgPath,
name: pkg.name,
version: pkg.version,
pkg,
});
}
return;
}
for (const entry of readdirSync(absDir, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
if (entry.name === "node_modules" || entry.name === "dist" || entry.name === ".git") continue;
walk(join(relDir, entry.name));
}
}
for (const rel of roots) {
walk(rel);
}
return packages;
}
function loadReleaseManifest() {
const manifest = readJson(manifestPath);
if (!Array.isArray(manifest)) {
throw new Error(`expected ${manifestPath} to contain an array.`);
}
return manifest.map((entry, index) => {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
throw new Error(`manifest entry ${index + 1} in ${manifestPath} must be an object.`);
}
if (typeof entry.dir !== "string" || entry.dir.length === 0) {
throw new Error(`manifest entry ${index + 1} in ${manifestPath} is missing a non-empty "dir".`);
}
if (typeof entry.name !== "string" || entry.name.length === 0) {
throw new Error(`manifest entry ${index + 1} in ${manifestPath} is missing a non-empty "name".`);
}
if (typeof entry.publishFromCi !== "boolean") {
throw new Error(
`manifest entry ${index + 1} (${entry.dir}) in ${manifestPath} must set boolean "publishFromCi".`,
);
}
return entry;
});
}
// Sections whose @paperclipai workspace deps are rewritten to the calver release
// version by replaceWorkspaceDeps() and that consumers resolve at install time.
const RESOLVED_DEP_SECTIONS = ["dependencies", "optionalDependencies", "peerDependencies"];
// A publishFromCi:true package gets republished at the unified calver version every
// release, and every @paperclipai/* workspace: dep it declares is rewritten to that
// same calver version. If the target is NOT publishFromCi:true it never gets a calver
// publish, so the rewritten spec points at a version that will never exist on npm and
// the package becomes uninstallable. Detect those edges so the release fails fast
// instead of shipping a broken canary (e.g. server -> skills-catalog from #8327).
function findUnpublishableWorkspaceEdges(packages) {
const publishFromCiByName = new Map(packages.map((pkg) => [pkg.name, pkg.publishFromCi]));
const problems = [];
for (const pkg of packages) {
if (!pkg.publishFromCi) continue;
for (const section of RESOLVED_DEP_SECTIONS) {
const deps = pkg.pkg[section];
if (!deps) continue;
for (const [depName, spec] of Object.entries(deps)) {
if (!depName.startsWith("@paperclipai/")) continue;
if (typeof spec !== "string" || !spec.startsWith("workspace:")) continue;
if (publishFromCiByName.get(depName) === true) continue;
problems.push(
`${pkg.name} (${pkg.dir}) is publishFromCi:true but declares a "${section}" workspace dependency on ${depName}, ` +
`which is not publishFromCi:true. The release version rewrite would point ${depName} at the calver version, ` +
`but that version is never published, so installs of ${pkg.name} would fail to resolve. ` +
`Enable publishFromCi for ${depName} (bootstrap its first npm publish if needed) or drop the workspace dependency.`,
);
}
}
}
return problems;
}
function buildReleasePackagePlan() {
const discoveredPackages = discoverPublicPackages();
const manifestEntries = loadReleaseManifest();
const packageByDir = new Map(discoveredPackages.map((pkg) => [pkg.dir, pkg]));
const manifestByDir = new Map();
const problems = [];
for (const entry of manifestEntries) {
if (manifestByDir.has(entry.dir)) {
problems.push(`duplicate manifest entry for ${entry.dir}`);
continue;
}
manifestByDir.set(entry.dir, entry);
const pkg = packageByDir.get(entry.dir);
if (!pkg) {
problems.push(`${entry.dir} is listed in ${manifestPath} but is not a public package in this repo`);
continue;
}
if (pkg.name !== entry.name) {
problems.push(
`${entry.dir} is listed as ${entry.name} in ${manifestPath}, but package.json declares ${pkg.name}`,
);
}
}
for (const pkg of discoveredPackages) {
if (!manifestByDir.has(pkg.dir)) {
problems.push(
`${pkg.dir} (${pkg.name}) is public but missing from ${manifestPath}; add it with publishFromCi true or false`,
);
}
}
if (problems.length > 0) {
throw new Error(`release package manifest validation failed:\n- ${problems.join("\n- ")}`);
}
const packages = discoveredPackages.map((pkg) => ({
...pkg,
publishFromCi: manifestByDir.get(pkg.dir).publishFromCi,
}));
const edgeProblems = findUnpublishableWorkspaceEdges(packages);
if (edgeProblems.length > 0) {
throw new Error(`release package manifest validation failed:\n- ${edgeProblems.join("\n- ")}`);
}
return packages;
}
function sortTopologically(packages) {
const byName = new Map(packages.map((pkg) => [pkg.name, pkg]));
const visited = new Set();
const visiting = new Set();
const ordered = [];
function visit(pkg) {
if (visited.has(pkg.name)) return;
if (visiting.has(pkg.name)) {
throw new Error(`cycle detected in release package graph at ${pkg.name}`);
}
visiting.add(pkg.name);
const dependencySections = [
pkg.pkg.dependencies ?? {},
pkg.pkg.optionalDependencies ?? {},
pkg.pkg.peerDependencies ?? {},
];
for (const deps of dependencySections) {
for (const depName of Object.keys(deps)) {
const dep = byName.get(depName);
if (dep) visit(dep);
}
}
visiting.delete(pkg.name);
visited.add(pkg.name);
ordered.push(pkg);
}
for (const pkg of [...packages].sort((a, b) => a.dir.localeCompare(b.dir))) {
visit(pkg);
}
return ordered;
}
function getReleasePackages() {
const ordered = sortTopologically(buildReleasePackagePlan().filter((pkg) => pkg.publishFromCi));
const entrypoint = ordered.find((pkg) => pkg.name === CHANNEL_ENTRYPOINT_PACKAGE);
if (!entrypoint) {
throw new Error(
`release package graph is missing channel entrypoint ${CHANNEL_ENTRYPOINT_PACKAGE}`,
);
}
// npm trusted publishing can authenticate `npm publish`, but not a later
// `npm dist-tag add`. Publish the user-facing CLI last so its channel tag
// cannot advance until every other release package has been accepted by npm.
return [
...ordered.filter((pkg) => pkg.name !== CHANNEL_ENTRYPOINT_PACKAGE),
entrypoint,
];
}
function replaceWorkspaceDeps(deps, version) {
if (!deps) return deps;
const next = { ...deps };
for (const [name, value] of Object.entries(next)) {
if (!name.startsWith("@paperclipai/")) continue;
if (typeof value !== "string" || !value.startsWith("workspace:")) continue;
next[name] = version;
}
return next;
}
function setVersion(version) {
const packages = getReleasePackages();
for (const pkg of packages) {
const nextPkg = {
...pkg.pkg,
version,
dependencies: replaceWorkspaceDeps(pkg.pkg.dependencies, version),
optionalDependencies: replaceWorkspaceDeps(pkg.pkg.optionalDependencies, version),
peerDependencies: replaceWorkspaceDeps(pkg.pkg.peerDependencies, version),
devDependencies: replaceWorkspaceDeps(pkg.pkg.devDependencies, version),
};
writeFileSync(pkg.pkgPath, `${JSON.stringify(nextPkg, null, 2)}\n`);
}
const cliEntryPath = join(repoRoot, "cli/src/index.ts");
const cliEntry = readFileSync(cliEntryPath, "utf8");
const nextCliEntry = cliEntry.replace(
/\.version\("([^"]+)"\)/,
`.version("${version}")`,
);
if (cliEntry !== nextCliEntry) {
writeFileSync(cliEntryPath, nextCliEntry);
return;
}
if (!cliEntry.includes(".version(cliVersion)")) {
throw new Error("failed to rewrite CLI version string in cli/src/index.ts");
}
}
function listPackages() {
const packages = getReleasePackages();
for (const pkg of packages) {
process.stdout.write(`${pkg.dir}\t${pkg.name}\t${pkg.version}\n`);
}
}
function checkConfiguration() {
const packages = buildReleasePackagePlan();
const enabledCount = packages.filter((pkg) => pkg.publishFromCi).length;
const disabledCount = packages.length - enabledCount;
if (enabledCount === 0) {
throw new Error(`no packages are enabled for CI publishing in ${manifestPath}`);
}
process.stdout.write(
`Release package manifest OK: ${enabledCount} enabled for CI publish, ${disabledCount} disabled pending bootstrap.\n`,
);
}
function usage() {
process.stderr.write(
[
"Usage:",
" node scripts/release-package-map.mjs list",
" node scripts/release-package-map.mjs check",
" node scripts/release-package-map.mjs set-version <version>",
"",
].join("\n"),
);
}
const [command, arg] = process.argv.slice(2);
const isDirectRun = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
if (isDirectRun) {
if (command === "list") {
listPackages();
process.exit(0);
}
if (command === "check") {
checkConfiguration();
process.exit(0);
}
if (command === "set-version") {
if (!arg) {
usage();
process.exit(1);
}
setVersion(arg);
process.exit(0);
}
usage();
process.exit(1);
}
export {
buildReleasePackagePlan,
checkConfiguration,
discoverPublicPackages,
findUnpublishableWorkspaceEdges,
getReleasePackages,
loadReleaseManifest,
};