mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 17:53:39 +00:00
Since #160224 the root package depends on npm, whose v3 lock lists bundled packages under node_modules/npm/node_modules/* with inBundle and no resolved/integrity. The dependency release evidence job rejected them as unsupported lock entries. Bundled entries are now accepted only when their nearest non-bundled carrier has a verified registry tarball, and each report entry records them as bundledDependencies with that parent. Ordinary entries without resolved/integrity stay fatal.
276 lines
10 KiB
TypeScript
276 lines
10 KiB
TypeScript
#!/usr/bin/env node
|
|
|
|
import { execFileSync } from "node:child_process";
|
|
import { createHash } from "node:crypto";
|
|
import { readFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import {
|
|
generateNpmPackageLocks,
|
|
listManagedNpmLockPackageDirs,
|
|
parseLockPackagePath,
|
|
resolveNpmLockJobs,
|
|
} from "./generate-npm-package-lock.mts";
|
|
import { parseFlagArgs, stringFlag } from "./lib/arg-utils.mts";
|
|
import { isRecord } from "./lib/record-shared.mjs";
|
|
import { REPORT_CLI_PARSE_OPTIONS, writeReportArtifact } from "./lib/report-cli-helpers.mts";
|
|
|
|
function sha256(bytes: string | Buffer) {
|
|
return createHash("sha256").update(bytes).digest("hex");
|
|
}
|
|
|
|
async function readPackage(rootDir: string, packageDir: string) {
|
|
const manifest: unknown = JSON.parse(
|
|
await readFile(path.join(rootDir, packageDir, "package.json"), "utf8"),
|
|
);
|
|
if (
|
|
!isRecord(manifest) ||
|
|
typeof manifest.name !== "string" ||
|
|
!manifest.name ||
|
|
typeof manifest.version !== "string" ||
|
|
!manifest.version
|
|
) {
|
|
throw new Error(`${packageDir}: expected package name and version`);
|
|
}
|
|
const release = isRecord(manifest.openclaw) ? manifest.openclaw.release : undefined;
|
|
const dependencies = isRecord(manifest.dependencies) ? manifest.dependencies : {};
|
|
const optionalDependencies = isRecord(manifest.optionalDependencies)
|
|
? manifest.optionalDependencies
|
|
: {};
|
|
const omittedWorkspaceDependencies = [
|
|
...new Set(
|
|
[...Object.entries(dependencies), ...Object.entries(optionalDependencies)]
|
|
.filter(([, spec]) => typeof spec === "string" && spec.startsWith("workspace:"))
|
|
.map(([name]) => name),
|
|
),
|
|
].toSorted((left, right) => left.localeCompare(right));
|
|
return {
|
|
packageDir,
|
|
name: manifest.name,
|
|
version: manifest.version,
|
|
bundleRuntimeDependencies:
|
|
packageDir !== "." && !(isRecord(release) && release.bundleRuntimeDependencies === false),
|
|
dependencyCount: Object.keys(dependencies).length,
|
|
optionalDependencyCount: Object.keys(optionalDependencies).length,
|
|
omittedWorkspaceDependencies,
|
|
};
|
|
}
|
|
|
|
function isPortableLockEntry(metadata: unknown): metadata is Record<string, unknown> {
|
|
return (
|
|
isRecord(metadata) &&
|
|
metadata.dev !== true &&
|
|
metadata.link !== true &&
|
|
!(
|
|
typeof metadata.resolved === "string" &&
|
|
/^(?:file:|workspace:|git\+|git:|ssh:|https:\/\/github\.com\/)/iu.test(metadata.resolved)
|
|
)
|
|
);
|
|
}
|
|
|
|
function hasTarball(metadata: Record<string, unknown>) {
|
|
return (
|
|
typeof metadata.resolved === "string" &&
|
|
metadata.resolved.length > 0 &&
|
|
typeof metadata.integrity === "string" &&
|
|
metadata.integrity.length > 0
|
|
);
|
|
}
|
|
|
|
function validateLock(text: string, entry: Awaited<ReturnType<typeof readPackage>>) {
|
|
const lock: unknown = JSON.parse(text);
|
|
if (
|
|
!isRecord(lock) ||
|
|
lock.lockfileVersion !== 3 ||
|
|
lock.name !== entry.name ||
|
|
lock.version !== entry.version ||
|
|
!isRecord(lock.packages)
|
|
) {
|
|
throw new Error(
|
|
`${entry.packageDir}: expected a version 3 npm lock for ${entry.name}@${entry.version}`,
|
|
);
|
|
}
|
|
const root = lock.packages[""];
|
|
if (!isRecord(root) || root.name !== entry.name || root.version !== entry.version) {
|
|
throw new Error(`${entry.packageDir}: npm lock root identity mismatch`);
|
|
}
|
|
for (const name of entry.omittedWorkspaceDependencies) {
|
|
if (Object.hasOwn(lock.packages, `node_modules/${name}`)) {
|
|
throw new Error(
|
|
`${entry.packageDir}: npm lock contains omitted workspace dependency ${name}`,
|
|
);
|
|
}
|
|
}
|
|
const bundledDependencies: Array<{
|
|
path: string;
|
|
name: string;
|
|
version: string;
|
|
parent: string;
|
|
}> = [];
|
|
// Check carriers first so an incomplete parent names the affected bundle too.
|
|
for (const [lockPath, metadata] of Object.entries(lock.packages)) {
|
|
if (!isRecord(metadata) || metadata.inBundle !== true) {
|
|
continue;
|
|
}
|
|
const ancestors = parseLockPackagePath(lockPath);
|
|
const dependency = ancestors.pop();
|
|
if (
|
|
!dependency ||
|
|
dependency.path !== lockPath ||
|
|
!isPortableLockEntry(metadata) ||
|
|
typeof metadata.version !== "string" ||
|
|
!metadata.version
|
|
) {
|
|
throw new Error(`${entry.packageDir}: unsupported npm lock entry ${lockPath || "<root>"}`);
|
|
}
|
|
let parent: string;
|
|
while (true) {
|
|
parent = ancestors.pop()?.path ?? "";
|
|
const carrier = lock.packages[parent];
|
|
if (
|
|
!parent ||
|
|
!isPortableLockEntry(carrier) ||
|
|
(carrier.inBundle !== true && !hasTarball(carrier))
|
|
) {
|
|
throw new Error(
|
|
`${entry.packageDir}: bundled npm lock entry ${lockPath || "<root>"} has unverifiable carrier ${parent || "<root>"}`,
|
|
);
|
|
}
|
|
if (carrier.inBundle !== true) {
|
|
break;
|
|
}
|
|
}
|
|
bundledDependencies.push({
|
|
path: lockPath,
|
|
name: typeof metadata.name === "string" ? metadata.name : dependency.name,
|
|
version: metadata.version,
|
|
parent,
|
|
});
|
|
}
|
|
for (const [lockPath, metadata] of Object.entries(lock.packages)) {
|
|
if (
|
|
!isPortableLockEntry(metadata) ||
|
|
(lockPath !== "" && metadata.inBundle !== true && !hasTarball(metadata))
|
|
) {
|
|
throw new Error(`${entry.packageDir}: unsupported npm lock entry ${lockPath || "<root>"}`);
|
|
}
|
|
}
|
|
return {
|
|
lock,
|
|
bundledDependencies: bundledDependencies.toSorted((left, right) =>
|
|
left.path.localeCompare(right.path),
|
|
),
|
|
};
|
|
}
|
|
|
|
export async function generateNpmPackageLocksReport({
|
|
rootDir: sourceRoot = process.cwd(),
|
|
jobs = resolveNpmLockJobs(undefined),
|
|
}: { rootDir?: string; jobs?: number } = {}) {
|
|
const rootDir = path.resolve(sourceRoot);
|
|
const sourceSha = execFileSync("git", ["rev-parse", "HEAD"], {
|
|
cwd: rootDir,
|
|
encoding: "utf8",
|
|
}).trim();
|
|
if (!/^[a-f0-9]{40}$/u.test(sourceSha)) {
|
|
throw new Error("Expected the source root's full Git commit SHA");
|
|
}
|
|
const pnpmLockSha256 = sha256(await readFile(path.join(rootDir, "pnpm-lock.yaml")));
|
|
const manifests = await Promise.all(
|
|
[".", ...listManagedNpmLockPackageDirs(rootDir)].map((dir) => readPackage(rootDir, dir)),
|
|
);
|
|
const entries = manifests.filter(
|
|
(entry) =>
|
|
entry.packageDir === "." || entry.dependencyCount + entry.optionalDependencyCount > 0,
|
|
);
|
|
const locks = await generateNpmPackageLocks({
|
|
rootDir,
|
|
jobs: resolveNpmLockJobs(jobs),
|
|
packageDirs: entries.map((entry) => path.join(rootDir, entry.packageDir)),
|
|
});
|
|
return {
|
|
schemaVersion: 1,
|
|
generatedAt: new Date().toISOString(),
|
|
generator: "scripts/npm-package-locks-report.mts",
|
|
sourceSha,
|
|
packageVersion: manifests[0]!.version,
|
|
pnpmLockSha256,
|
|
lockfileVersion: 3,
|
|
packagesWithOmittedWorkspaceDependencies: entries.filter(
|
|
(entry) => entry.omittedWorkspaceDependencies.length > 0,
|
|
).length,
|
|
packages: entries.map((entry, index) => {
|
|
const { lock, bundledDependencies } = validateLock(locks[index]!, entry);
|
|
return Object.assign(entry, {
|
|
bundledDependencies,
|
|
lockSha256: sha256(`${JSON.stringify(lock, null, 2)}\n`),
|
|
lock,
|
|
});
|
|
}),
|
|
};
|
|
}
|
|
|
|
type NpmPackageLocksReport = Awaited<ReturnType<typeof generateNpmPackageLocksReport>>;
|
|
|
|
function renderMarkdown(report: NpmPackageLocksReport) {
|
|
return `${[
|
|
"# npm package-lock mirrors",
|
|
"",
|
|
`Source: \`${report.sourceSha}\` (OpenClaw ${report.packageVersion}).`,
|
|
`pnpm lock SHA-256: \`${report.pnpmLockSha256}\`.`,
|
|
"",
|
|
`Total packages: ${report.packages.length}`,
|
|
`Total bundled dependencies: ${report.packages.reduce((total, entry) => total + entry.bundledDependencies.length, 0)}`,
|
|
`Lockless packages (bundleRuntimeDependencies=false): ${report.packages.filter((entry) => !entry.bundleRuntimeDependencies).length}`,
|
|
`Partial locks (omitted workspace dependencies): ${report.packagesWithOmittedWorkspaceDependencies}`,
|
|
"",
|
|
"Entries with non-empty omittedWorkspaceDependencies are partial locks: sibling workspace packages publish in the same release and are omitted by the generator. Consumers must reject these entries instead of installing them.",
|
|
"Select the exact name and version in npm-package-locks.json, require an empty omittedWorkspaceDependencies array, and write entry.lock as package-lock.json.",
|
|
"Verify dependency-evidence-manifest.json releaseSha equals sourceSha and the OpenClaw commit you pin.",
|
|
"These generated locks are release evidence only and are never included in npm tarballs.",
|
|
"",
|
|
"| Package directory | Name | Version | Bundles runtime dependencies | Dependencies | Optional dependencies | Omitted workspace dependencies | Bundled dependencies |",
|
|
"| --- | --- | --- | --- | --- | --- | --- | --- |",
|
|
...report.packages.map(
|
|
(entry) =>
|
|
`| ${entry.packageDir} | ${entry.name} | ${entry.version} | ${entry.bundleRuntimeDependencies} | ${entry.dependencyCount} | ${entry.optionalDependencyCount} | ${entry.omittedWorkspaceDependencies.length > 0 ? `Partial: ${entry.omittedWorkspaceDependencies.join(", ")}` : "None"} | ${entry.bundledDependencies.length} |`,
|
|
),
|
|
].join("\n")}\n`;
|
|
}
|
|
|
|
async function main(argv = process.argv.slice(2)) {
|
|
const options: {
|
|
rootDir: string;
|
|
jsonPath: string | null;
|
|
markdownPath: string | null;
|
|
jobs: string | null;
|
|
} = { rootDir: process.cwd(), jsonPath: null, markdownPath: null, jobs: null };
|
|
const flags = [
|
|
["--root", "rootDir"],
|
|
["--json", "jsonPath"],
|
|
["--markdown", "markdownPath"],
|
|
["--jobs", "jobs"],
|
|
] satisfies Array<[string, keyof typeof options]>;
|
|
const parsed = parseFlagArgs(
|
|
argv,
|
|
options,
|
|
flags.map(([flag, key]) =>
|
|
stringFlag(flag, key, { allowInline: false, rejectShortOptions: true }),
|
|
),
|
|
REPORT_CLI_PARSE_OPTIONS,
|
|
);
|
|
const report = await generateNpmPackageLocksReport({
|
|
rootDir: parsed.rootDir,
|
|
jobs: resolveNpmLockJobs(parsed.jobs),
|
|
});
|
|
const markdown = renderMarkdown(report);
|
|
await writeReportArtifact(parsed.jsonPath, `${JSON.stringify(report, null, 2)}\n`);
|
|
await writeReportArtifact(parsed.markdownPath, markdown);
|
|
process.stdout.write(markdown);
|
|
}
|
|
|
|
if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(import.meta.filename)) {
|
|
main().catch((error: unknown) => {
|
|
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
process.exitCode = 1;
|
|
});
|
|
}
|