146 lines
6.6 KiB
JavaScript
146 lines
6.6 KiB
JavaScript
// Verify every npm package the vendored paperclip-runner actually imports
|
|
// at runtime is also declared as a direct dependency of server/package.json.
|
|
//
|
|
// packages/paperclip-runner is private and never published, so the server
|
|
// build vendors its compiled dist/ tree wholesale with `cp -R` (see the
|
|
// `build` script's `... dist/vendor/paperclip-runner/` step) -- code only,
|
|
// no node_modules alongside it. Every npm package the vendored code
|
|
// actually imports is therefore also a runtime dependency of server once
|
|
// vendored, and has to be declared there too (see acpx, ajv). That mirror
|
|
// step is easy to forget -- it silently missed smol-toml in #13110, and CI
|
|
// stayed green while production crash-looped 3 seconds into every start
|
|
// (#13116) -- because nothing enforced it.
|
|
//
|
|
// This derives the required set from esbuild's own module-resolution scan
|
|
// of the two entry points server actually imports (index.js, testing.js),
|
|
// with `write: false` so nothing is written to disk and `packages:
|
|
// "external"` so npm imports are reported, not inlined. That is precise:
|
|
// paperclip-runner declares dependencies (react-markdown, the codex/opencode
|
|
// CLI packages, ...) that only its unrelated ./react and ./browser export
|
|
// subpaths use, which server never imports, so requiring *every* declared
|
|
// dependency to be mirrored would be over-broad and demand dependencies
|
|
// server does not actually need.
|
|
//
|
|
// This intentionally only analyzes the module graph; it does not bundle or
|
|
// rewrite anything on disk. Several runner modules resolve sibling build
|
|
// artifacts -- the native runnerd binary, the ACPX/OpenCode CJS sidecar
|
|
// scripts in dist/cli, JSON replay fixtures under a top-level protocol/
|
|
// directory -- via `import.meta.url`-relative filesystem paths rather than
|
|
// JS imports, each at whatever nesting depth its source file happens to
|
|
// sit at. Actually bundling those entry points (an earlier version of this
|
|
// fix did) collapses and rearranges that layout, silently breaking those
|
|
// lookups. Preserving the original `cp -R` tree 1:1 is what keeps all of
|
|
// them working, so this script only verifies; it never restructures.
|
|
|
|
import { build } from "esbuild";
|
|
import { existsSync, readFileSync } from "node:fs";
|
|
import { builtinModules } from "node:module";
|
|
import { dirname, resolve } from "node:path";
|
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
|
|
const serverRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
const runnerRoot = resolve(serverRoot, "../packages/paperclip-runner");
|
|
const runnerDist = resolve(runnerRoot, "dist");
|
|
|
|
// The only entry points server/src actually imports from the vendored
|
|
// runner (server/src/**/*.ts import "../vendor/paperclip-runner/index.js"
|
|
// or ".../testing.js").
|
|
const ENTRY_POINT_NAMES = ["index.js", "testing.js"];
|
|
|
|
const NODE_BUILTINS = new Set([
|
|
...builtinModules,
|
|
...builtinModules.map((name) => `node:${name}`),
|
|
]);
|
|
|
|
/** The npm package name a bare import specifier resolves to, honoring scoped packages and subpaths. */
|
|
function packageNameFromSpecifier(specifier) {
|
|
const segments = specifier.split("/");
|
|
return specifier.startsWith("@") ? segments.slice(0, 2).join("/") : segments[0];
|
|
}
|
|
|
|
export function findMissingVendorDependencies(importedPackageNames, declaredDependencyNames) {
|
|
return [...importedPackageNames]
|
|
.filter((name) => !declaredDependencyNames.has(name))
|
|
.sort();
|
|
}
|
|
|
|
export async function findRunnerExternalPackages(entryPoints) {
|
|
for (const entryPoint of entryPoints) {
|
|
if (!existsSync(entryPoint)) {
|
|
throw new Error(
|
|
`paperclip-runner vendor check: expected build output at ${entryPoint}. ` +
|
|
`Run "pnpm --filter @paperclipai/paperclip-runner build" first.`,
|
|
);
|
|
}
|
|
}
|
|
|
|
// write: false means this never touches disk -- it's a module-graph scan,
|
|
// not a real bundle. Vendoring itself still happens via `cp -R` elsewhere
|
|
// in the build script. `outdir` is required by esbuild for multiple entry
|
|
// points but nothing is ever written there, so any sibling path will do.
|
|
const result = await build({
|
|
entryPoints,
|
|
outdir: resolve(dirname(entryPoints[0]), ".vendor-dependency-scan"),
|
|
bundle: true,
|
|
write: false,
|
|
platform: "node",
|
|
format: "esm",
|
|
packages: "external",
|
|
metafile: true,
|
|
logLevel: "silent",
|
|
});
|
|
|
|
const externalPackageNames = new Set();
|
|
for (const output of Object.values(result.metafile.outputs)) {
|
|
for (const imported of output.imports) {
|
|
if (!imported.external) continue;
|
|
if (imported.path.startsWith(".") || NODE_BUILTINS.has(imported.path)) continue;
|
|
externalPackageNames.add(packageNameFromSpecifier(imported.path));
|
|
}
|
|
}
|
|
return externalPackageNames;
|
|
}
|
|
|
|
function readDependencyNames(packageJsonPath) {
|
|
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8"));
|
|
return new Map(Object.entries(packageJson.dependencies ?? {}));
|
|
}
|
|
|
|
function explainMissingDependencies(missing, runnerDependencyNames) {
|
|
const lines = missing.map((name) => {
|
|
const range = runnerDependencyNames.get(name);
|
|
return range
|
|
? ` "${name}": "${range}" (matches packages/paperclip-runner/package.json)`
|
|
: ` "${name}" (not declared as a paperclip-runner dependency either -- check for a missing or mistyped dependency there first)`;
|
|
});
|
|
return (
|
|
`paperclip-runner vendor check: server/package.json is missing the runtime ` +
|
|
`${missing.length === 1 ? "dependency" : "dependencies"} the vendored runner ` +
|
|
`imports at runtime:\n${lines.join("\n")}\n\n` +
|
|
`packages/paperclip-runner/dist is copied into server's own published package ` +
|
|
`without its node_modules, so every npm package the runner imports must also be ` +
|
|
`a direct dependency of server so it resolves once vendored. Add ` +
|
|
`${missing.length === 1 ? "it" : "them"} to server/package.json's "dependencies".`
|
|
);
|
|
}
|
|
|
|
async function main() {
|
|
const entryPoints = ENTRY_POINT_NAMES.map((name) => resolve(runnerDist, name));
|
|
const externalPackageNames = await findRunnerExternalPackages(entryPoints);
|
|
const serverDependencyNames = readDependencyNames(resolve(serverRoot, "package.json"));
|
|
|
|
const missing = findMissingVendorDependencies(
|
|
externalPackageNames,
|
|
new Set(serverDependencyNames.keys()),
|
|
);
|
|
if (missing.length > 0) {
|
|
const runnerDependencyNames = readDependencyNames(resolve(runnerRoot, "package.json"));
|
|
throw new Error(explainMissingDependencies(missing, runnerDependencyNames));
|
|
}
|
|
}
|
|
|
|
// Only run when invoked directly (`node scripts/verify-runner-vendor-dependencies.mjs`),
|
|
// not when the vitest suite imports findMissingVendorDependencies for a unit test.
|
|
const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
if (isMain) await main();
|