diff --git a/scripts/list-prod-store-packages.mjs b/scripts/list-prod-store-packages.mjs index 2b7e51110fe..e1e49772ffc 100644 --- a/scripts/list-prod-store-packages.mjs +++ b/scripts/list-prod-store-packages.mjs @@ -1,4 +1,4 @@ -// Lists production store packages from lockfile data. +// Lists current-target production packages for Docker's offline prune store seed. import fs from "node:fs"; import path from "node:path"; import { parse } from "yaml"; @@ -6,6 +6,11 @@ import { parse } from "yaml"; const parsed = JSON.parse(fs.readFileSync(0, "utf8")); const roots = Array.isArray(parsed) ? parsed : [parsed]; const specs = new Set(); +const target = { + cpu: process.arch, + libc: detectLibc(), + os: process.platform, +}; function packageSpec(name, version) { if (!name || !version || typeof version !== "string") { @@ -22,26 +27,72 @@ function packageSpec(name, version) { return `${name}@${normalizedVersion}`; } -function packageSpecFromLockfileKey(key) { +function detectLibc() { + if (process.platform !== "linux") { + return undefined; + } + const report = process.report?.getReport?.(); + return report?.header?.glibcVersionRuntime ? "glibc" : "musl"; +} + +function matchesTargetSelector(selector, value) { + if (!Array.isArray(selector) || !value) { + return true; + } + const blocked = selector.some((entry) => entry === `!${value}`); + if (blocked) { + return false; + } + const allowed = selector.filter((entry) => typeof entry === "string" && !entry.startsWith("!")); + return allowed.length === 0 || allowed.includes(value); +} + +function packageEntryForSpec(lockfile, spec) { + return lockfile?.packages?.[spec] ?? lockfile?.packages?.[`/${spec}`]; +} + +function normalizeLockfilePackageKey(key) { if (typeof key !== "string") { return undefined; } - const normalizedKey = (key.startsWith("/") ? key.slice(1) : key).replace(/\(.+\)$/, ""); - const separator = normalizedKey.lastIndexOf("@"); - if (separator <= 0) { - return undefined; - } - return packageSpec(normalizedKey.slice(0, separator), normalizedKey.slice(separator + 1)); + return (key.startsWith("/") ? key.slice(1) : key).replace(/\(.+\)$/, ""); } -function visitListNode(node) { +function snapshotForSpec(lockfile, spec) { + const snapshots = lockfile?.snapshots; + if (!snapshots) { + return undefined; + } + return ( + snapshots[spec] ?? + snapshots[`/${spec}`] ?? + Object.entries(snapshots).find(([key]) => normalizeLockfilePackageKey(key) === spec)?.[1] + ); +} + +function packageSupportsTarget(lockfile, spec) { + const entry = packageEntryForSpec(lockfile, spec); + return ( + matchesTargetSelector(entry?.os, target.os) && + matchesTargetSelector(entry?.cpu, target.cpu) && + matchesTargetSelector(entry?.libc, target.libc) + ); +} + +function addSpec(lockfile, spec) { + if (spec && packageSupportsTarget(lockfile, spec)) { + specs.add(spec); + } +} + +function visitListNode(lockfile, node) { for (const dep of Object.values(node.dependencies ?? {})) { const name = dep.from || dep.name; const spec = packageSpec(name, dep.version); if (spec && dep.resolved?.startsWith("https://registry.npmjs.org/")) { - specs.add(spec); + addSpec(lockfile, spec); } - visitListNode(dep); + visitListNode(lockfile, dep); } } @@ -53,15 +104,6 @@ function readLockfile() { return parse(fs.readFileSync(lockfilePath, "utf8")); } -function addLockfilePackages(lockfile) { - for (const key of Object.keys(lockfile?.packages ?? {})) { - const spec = packageSpecFromLockfileKey(key); - if (spec) { - specs.add(spec); - } - } -} - function addSnapshotClosure(lockfile) { const snapshots = lockfile?.snapshots; const packages = lockfile?.packages; @@ -76,26 +118,36 @@ function addSnapshotClosure(lockfile) { continue; } visited.add(spec); - const snapshot = snapshots[spec]; + const snapshot = snapshotForSpec(lockfile, spec); if (!snapshot) { continue; } - for (const [name, version] of Object.entries(snapshot.dependencies ?? {})) { + const addDependencySpec = (name, version) => { const depSpec = packageSpec(name, typeof version === "string" ? version : version?.version); - if (!depSpec || !packages[depSpec] || specs.has(depSpec)) { - continue; + if ( + !depSpec || + !packages[depSpec] || + specs.has(depSpec) || + !packageSupportsTarget(lockfile, depSpec) + ) { + return; } specs.add(depSpec); pending.push(depSpec); + }; + for (const [name, version] of Object.entries(snapshot.dependencies ?? {})) { + addDependencySpec(name, version); + } + for (const [name, version] of Object.entries(snapshot.optionalDependencies ?? {})) { + addDependencySpec(name, version); } } } -for (const root of roots) { - visitListNode(root); -} const lockfile = readLockfile(); +for (const root of roots) { + visitListNode(lockfile, root); +} addSnapshotClosure(lockfile); -addLockfilePackages(lockfile); process.stdout.write([...specs].toSorted((a, b) => a.localeCompare(b)).join("\n")); diff --git a/test/scripts/list-prod-store-packages.test.ts b/test/scripts/list-prod-store-packages.test.ts index bfc2271ffa5..63db5d6d93a 100644 --- a/test/scripts/list-prod-store-packages.test.ts +++ b/test/scripts/list-prod-store-packages.test.ts @@ -101,7 +101,66 @@ describe("list-prod-store-packages", () => { expect(result.stdout).toBe("source-map-support@0.5.21\nsource-map@0.6.1"); }); - it("adds lockfile packages missing from pnpm list output", () => { + it("adds target optional dependencies from peer-resolved lockfile snapshots", () => { + const cwd = makeTempRepoRoot(tempDirs, "openclaw-prod-store-packages-"); + const platformPackages = [ + ["darwin", "arm64"], + ["darwin", "x64"], + ["linux", "arm64"], + ["linux", "x64"], + ["win32", "arm64"], + ["win32", "x64"], + ] as const; + writeFileSync( + join(cwd, "pnpm-lock.yaml"), + [ + "lockfileVersion: '10.0'", + "", + "packages:", + " native-wrapper@1.0.0:", + " resolution: {integrity: sha512-test}", + ...platformPackages.flatMap(([os, cpu]) => [ + ` native-wrapper-${os}-${cpu}@1.0.0:`, + " resolution: {integrity: sha512-test}", + ` cpu: [${cpu}]`, + ` os: [${os}]`, + ]), + "", + "snapshots:", + " native-wrapper@1.0.0(peer@1.0.0):", + " optionalDependencies:", + ...platformPackages.map(([os, cpu]) => ` native-wrapper-${os}-${cpu}: 1.0.0`), + ...platformPackages.flatMap(([os, cpu]) => [ + ` native-wrapper-${os}-${cpu}@1.0.0:`, + " optional: true", + ]), + "", + ].join("\n"), + ); + const result = runListProdStorePackages( + { + dependencies: { + nativeWrapper: { + from: "native-wrapper", + resolved: "https://registry.npmjs.org/native-wrapper/-/native-wrapper-1.0.0.tgz", + version: "1.0.0(peer@1.0.0)", + }, + }, + }, + cwd, + ); + + expect(result.status).toBe(0); + const expectedPlatformPackage = [`native-wrapper-${process.platform}-${process.arch}@1.0.0`]; + const supportedPlatformPackage = ["linux", "darwin", "win32"].includes(process.platform) + ? expectedPlatformPackage + : []; + expect(result.stdout.split("\n").filter(Boolean)).toEqual( + ["native-wrapper@1.0.0", ...supportedPlatformPackage].toSorted((a, b) => a.localeCompare(b)), + ); + }); + + it("does not add unrelated lockfile packages missing from pnpm list output", () => { const cwd = makeTempRepoRoot(tempDirs, "openclaw-prod-store-packages-"); writeFileSync( join(cwd, "pnpm-lock.yaml"), @@ -119,6 +178,68 @@ describe("list-prod-store-packages", () => { const result = runListProdStorePackages({ dependencies: {} }, cwd); expect(result.status).toBe(0); - expect(result.stdout).toBe("recma-jsx@1.0.1"); + expect(result.stdout).toBe(""); + }); + + it("only adds optional platform packages matching the current target", () => { + const cwd = makeTempRepoRoot(tempDirs, "openclaw-prod-store-packages-"); + const platformPackages = [ + ["darwin", "arm64"], + ["darwin", "x64"], + ["linux", "arm64"], + ["linux", "x64"], + ["win32", "arm64"], + ["win32", "x64"], + ] as const; + const expectedPlatformPackage = platformPackages + .map(([os, cpu]) => `@zed-industries/codex-acp-${os}-${cpu}@0.15.0`) + .find( + (spec) => spec === `@zed-industries/codex-acp-${process.platform}-${process.arch}@0.15.0`, + ); + writeFileSync( + join(cwd, "pnpm-lock.yaml"), + [ + "lockfileVersion: '10.0'", + "", + "packages:", + " '@zed-industries/codex-acp@0.15.0':", + " resolution: {integrity: sha512-test}", + ...platformPackages.flatMap(([os, cpu]) => [ + ` '@zed-industries/codex-acp-${os}-${cpu}@0.15.0':`, + " resolution: {integrity: sha512-test}", + ` cpu: [${cpu}]`, + ` os: [${os}]`, + ]), + "", + "snapshots:", + " '@zed-industries/codex-acp@0.15.0':", + " optionalDependencies:", + ...platformPackages.map( + ([os, cpu]) => ` '@zed-industries/codex-acp-${os}-${cpu}': 0.15.0`, + ), + ...platformPackages.flatMap(([os, cpu]) => [ + ` '@zed-industries/codex-acp-${os}-${cpu}@0.15.0':`, + " optional: true", + ]), + "", + ].join("\n"), + ); + const result = runListProdStorePackages( + { + dependencies: { + codexAcp: { + from: "@zed-industries/codex-acp", + resolved: "https://registry.npmjs.org/@zed-industries/codex-acp/-/codex-acp-0.15.0.tgz", + version: "0.15.0", + }, + }, + }, + cwd, + ); + + expect(result.status).toBe(0); + expect(result.stdout.split("\n").filter(Boolean)).toEqual( + [expectedPlatformPackage, "@zed-industries/codex-acp@0.15.0"].filter(Boolean), + ); }); });