diff --git a/scripts/openclaw-prepack.ts b/scripts/openclaw-prepack.ts index c125d658779..508294fb69d 100644 --- a/scripts/openclaw-prepack.ts +++ b/scripts/openclaw-prepack.ts @@ -4,6 +4,7 @@ import { spawnSync } from "node:child_process"; import { existsSync, readdirSync } from "node:fs"; import { pathToFileURL } from "node:url"; import { formatErrorMessage } from "../src/infra/errors.ts"; +import { writePackageDistInventory } from "../src/infra/package-dist-inventory.ts"; const skipPrepackPreparedEnv = "OPENCLAW_PREPACK_PREPARED"; const requiredPreparedPathGroups = [ @@ -116,18 +117,24 @@ function runBuildSmoke(): void { run(process.execPath, ["scripts/test-built-bundled-channel-entry-smoke.mjs"]); } -function main(): void { +async function writeDistInventory(): Promise { + await writePackageDistInventory(process.cwd()); +} + +async function main(): Promise { const pnpmCommand = process.platform === "win32" ? "pnpm.cmd" : "pnpm"; if (shouldSkipPrepack()) { ensurePreparedArtifacts(); + await writeDistInventory(); runBuildSmoke(); return; } run(pnpmCommand, ["build"]); run(pnpmCommand, ["ui:build"]); + await writeDistInventory(); runBuildSmoke(); } if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { - main(); + await main(); } diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index 7bbf8e8be23..2666369ba9f 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -6,6 +6,7 @@ import { Command } from "commander"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { TEST_BUNDLED_RUNTIME_SIDECAR_PATHS } from "../../test/helpers/bundled-runtime-sidecars.js"; import type { OpenClawConfig, ConfigFileSnapshot } from "../config/types.openclaw.js"; +import { writePackageDistInventory } from "../infra/package-dist-inventory.js"; import type { UpdateRunResult } from "../infra/update-runner.js"; import { withEnvAsync } from "../test-utils/env.js"; import { createCliRuntimeCapture } from "./test-runtime-capture.js"; @@ -785,12 +786,8 @@ describe("update-cli", () => { await fs.mkdir(path.dirname(absolutePath), { recursive: true }); await fs.writeFile(absolutePath, "export {};\n", "utf-8"); } + await writePackageDistInventory(pkgRoot); readPackageVersion.mockResolvedValue("2026.3.23"); - pathExists.mockImplementation(async (candidate: string) => - TEST_BUNDLED_RUNTIME_SIDECAR_PATHS.some( - (relativePath) => candidate === path.join(pkgRoot, relativePath), - ), - ); vi.mocked(runCommandWithTimeout).mockImplementation(async (argv) => { if (Array.isArray(argv) && argv[0] === "npm" && argv[1] === "root" && argv[2] === "-g") { return { diff --git a/src/infra/package-dist-inventory.test.ts b/src/infra/package-dist-inventory.test.ts new file mode 100644 index 00000000000..b2beaf55395 --- /dev/null +++ b/src/infra/package-dist-inventory.test.ts @@ -0,0 +1,45 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { withTempDir } from "../test-helpers/temp-dir.js"; +import { + collectPackageDistInventoryErrors, + PACKAGE_DIST_INVENTORY_RELATIVE_PATH, + writePackageDistInventory, +} from "./package-dist-inventory.js"; + +describe("package dist inventory", () => { + it("tracks missing and stale dist files", async () => { + await withTempDir({ prefix: "openclaw-dist-inventory-" }, async (packageRoot) => { + const currentFile = path.join(packageRoot, "dist", "current-BR6xv1a1.js"); + await fs.mkdir(path.dirname(currentFile), { recursive: true }); + await fs.writeFile(currentFile, "export {};\n", "utf8"); + + await expect(writePackageDistInventory(packageRoot)).resolves.toEqual([ + "dist/current-BR6xv1a1.js", + ]); + await expect(collectPackageDistInventoryErrors(packageRoot)).resolves.toEqual([]); + + await fs.rm(currentFile); + await fs.writeFile( + path.join(packageRoot, "dist", "stale-CJUAgRQR.js"), + "export {};\n", + "utf8", + ); + + await expect(collectPackageDistInventoryErrors(packageRoot)).resolves.toEqual([ + "missing packaged dist file dist/current-BR6xv1a1.js", + "unexpected packaged dist file dist/stale-CJUAgRQR.js", + ]); + }); + }); + + it("fails closed when the inventory is missing", async () => { + await withTempDir({ prefix: "openclaw-dist-inventory-missing-" }, async (packageRoot) => { + await fs.mkdir(path.join(packageRoot, "dist"), { recursive: true }); + await expect(collectPackageDistInventoryErrors(packageRoot)).resolves.toEqual([ + `missing package dist inventory ${PACKAGE_DIST_INVENTORY_RELATIVE_PATH}`, + ]); + }); + }); +}); diff --git a/src/infra/package-dist-inventory.ts b/src/infra/package-dist-inventory.ts new file mode 100644 index 00000000000..8f7d245ef55 --- /dev/null +++ b/src/infra/package-dist-inventory.ts @@ -0,0 +1,86 @@ +import fs from "node:fs/promises"; +import path from "node:path"; + +export const PACKAGE_DIST_INVENTORY_RELATIVE_PATH = "dist/postinstall-inventory.json"; + +function normalizeRelativePath(value: string): string { + return value.replace(/\\/g, "/"); +} + +async function collectRelativeFiles(rootDir: string, baseDir: string): Promise { + try { + const entries = await fs.readdir(rootDir, { withFileTypes: true }); + const files = await Promise.all( + entries.map(async (entry) => { + const entryPath = path.join(rootDir, entry.name); + if (entry.isDirectory()) { + return await collectRelativeFiles(entryPath, baseDir); + } + if (entry.isFile() || entry.isSymbolicLink()) { + const relativePath = normalizeRelativePath(path.relative(baseDir, entryPath)); + return relativePath === PACKAGE_DIST_INVENTORY_RELATIVE_PATH ? [] : [relativePath]; + } + return []; + }), + ); + return files.flat().toSorted((left, right) => left.localeCompare(right)); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return []; + } + throw error; + } +} + +export async function collectPackageDistInventory(packageRoot: string): Promise { + return await collectRelativeFiles(path.join(packageRoot, "dist"), packageRoot); +} + +export async function writePackageDistInventory(packageRoot: string): Promise { + const inventory = await collectPackageDistInventory(packageRoot); + const inventoryPath = path.join(packageRoot, PACKAGE_DIST_INVENTORY_RELATIVE_PATH); + await fs.mkdir(path.dirname(inventoryPath), { recursive: true }); + await fs.writeFile(inventoryPath, `${JSON.stringify(inventory, null, 2)}\n`, "utf8"); + return inventory; +} + +export async function readPackageDistInventory(packageRoot: string): Promise { + const inventoryPath = path.join(packageRoot, PACKAGE_DIST_INVENTORY_RELATIVE_PATH); + const raw = await fs.readFile(inventoryPath, "utf8"); + const parsed = JSON.parse(raw) as unknown; + if (!Array.isArray(parsed) || parsed.some((entry) => typeof entry !== "string")) { + throw new Error(`Invalid package dist inventory at ${PACKAGE_DIST_INVENTORY_RELATIVE_PATH}`); + } + return [...new Set(parsed.map(normalizeRelativePath))].toSorted((left, right) => + left.localeCompare(right), + ); +} + +export async function collectPackageDistInventoryErrors(packageRoot: string): Promise { + let expectedFiles: string[]; + try { + expectedFiles = await readPackageDistInventory(packageRoot); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return [`missing package dist inventory ${PACKAGE_DIST_INVENTORY_RELATIVE_PATH}`]; + } + throw error; + } + + const actualFiles = await collectPackageDistInventory(packageRoot); + const expectedSet = new Set(expectedFiles); + const actualSet = new Set(actualFiles); + const errors: string[] = []; + + for (const relativePath of expectedFiles) { + if (!actualSet.has(relativePath)) { + errors.push(`missing packaged dist file ${relativePath}`); + } + } + for (const relativePath of actualFiles) { + if (!expectedSet.has(relativePath)) { + errors.push(`unexpected packaged dist file ${relativePath}`); + } + } + return errors; +} diff --git a/src/infra/update-global.test.ts b/src/infra/update-global.test.ts index 92c86205e7d..92237821770 100644 --- a/src/infra/update-global.test.ts +++ b/src/infra/update-global.test.ts @@ -5,6 +5,7 @@ import { bundledDistPluginFile } from "../../test/helpers/bundled-plugin-paths.j import { BUNDLED_RUNTIME_SIDECAR_PATHS } from "../plugins/runtime-sidecar-paths.js"; import { withTempDir } from "../test-helpers/temp-dir.js"; import { captureEnv } from "../test-utils/env.js"; +import { writePackageDistInventory } from "./package-dist-inventory.js"; import { canResolveRegistryVersionForPackageTarget, collectInstalledGlobalPackageErrors, @@ -365,7 +366,7 @@ describe("update global helpers", () => { }); }); - it("checks bundled runtime sidecars, including Matrix helper-api", async () => { + it("checks installed dist against the packaged inventory", async () => { await withTempDir({ prefix: "openclaw-update-global-pkg-" }, async (packageRoot) => { await fs.writeFile( path.join(packageRoot, "package.json"), @@ -377,12 +378,22 @@ describe("update global helpers", () => { await fs.mkdir(path.dirname(absolutePath), { recursive: true }); await fs.writeFile(absolutePath, "export {};\n", "utf-8"); } + await writePackageDistInventory(packageRoot); await expect(collectInstalledGlobalPackageErrors({ packageRoot })).resolves.toEqual([]); await fs.rm(path.join(packageRoot, MATRIX_HELPER_API)); await expect(collectInstalledGlobalPackageErrors({ packageRoot })).resolves.toContain( - `missing bundled runtime sidecar ${MATRIX_HELPER_API}`, + `missing packaged dist file ${MATRIX_HELPER_API}`, + ); + + await fs.writeFile( + path.join(packageRoot, "dist", "stale-CJUAgRQR.js"), + "export {};\n", + "utf8", + ); + await expect(collectInstalledGlobalPackageErrors({ packageRoot })).resolves.toContain( + "unexpected packaged dist file dist/stale-CJUAgRQR.js", ); }); }); diff --git a/src/infra/update-global.ts b/src/infra/update-global.ts index dd6debeee39..3761319fe9e 100644 --- a/src/infra/update-global.ts +++ b/src/infra/update-global.ts @@ -2,9 +2,9 @@ import fsSync from "node:fs"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { BUNDLED_RUNTIME_SIDECAR_PATHS } from "../plugins/runtime-sidecar-paths.js"; import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; import { pathExists } from "../utils.js"; +import { collectPackageDistInventoryErrors } from "./package-dist-inventory.js"; import { readPackageVersion } from "./package-json.js"; import { applyPathPrepend } from "./path-prepend.js"; @@ -89,11 +89,7 @@ export async function collectInstalledGlobalPackageErrors(params: { `expected installed version ${params.expectedVersion}, found ${installedVersion ?? ""}`, ); } - for (const relativePath of BUNDLED_RUNTIME_SIDECAR_PATHS) { - if (!(await pathExists(path.join(params.packageRoot, relativePath)))) { - errors.push(`missing bundled runtime sidecar ${relativePath}`); - } - } + errors.push(...(await collectPackageDistInventoryErrors(params.packageRoot))); return errors; } diff --git a/src/infra/update-runner.test.ts b/src/infra/update-runner.test.ts index 2119dfd904c..3d3c5f1c56d 100644 --- a/src/infra/update-runner.test.ts +++ b/src/infra/update-runner.test.ts @@ -6,6 +6,7 @@ import { BUNDLED_RUNTIME_SIDECAR_PATHS } from "../plugins/runtime-sidecar-paths. import { createSuiteTempRootTracker } from "../test-helpers/temp-dir.js"; import { withEnvAsync } from "../test-utils/env.js"; import { pathExists } from "../utils.js"; +import { writePackageDistInventory } from "./package-dist-inventory.js"; import { resolveStableNodePath } from "./stable-node-path.js"; import { runGatewayUpdate } from "./update-runner.js"; @@ -231,6 +232,7 @@ describe("runGatewayUpdate", () => { "utf-8", ); await writeBundledRuntimeSidecars(pkgRoot); + await writePackageDistInventory(pkgRoot); } async function writeGlobalPackageVersion(pkgRoot: string, version = "2.0.0") { @@ -240,6 +242,7 @@ describe("runGatewayUpdate", () => { "utf-8", ); await writeBundledRuntimeSidecars(pkgRoot); + await writePackageDistInventory(pkgRoot); } async function writeBundledRuntimeSidecars(pkgRoot: string) { @@ -1176,7 +1179,9 @@ describe("runGatewayUpdate", () => { JSON.stringify({ name: "openclaw", version: "2.0.0" }), "utf-8", ); - await fs.rm(path.join(pkgRoot, "dist"), { recursive: true, force: true }); + await writeBundledRuntimeSidecars(pkgRoot); + await writePackageDistInventory(pkgRoot); + await fs.rm(path.join(pkgRoot, WHATSAPP_LIGHT_RUNTIME_API), { force: true }); }, }); @@ -1185,7 +1190,7 @@ describe("runGatewayUpdate", () => { expect(result.status).toBe("error"); expect(result.reason).toBe("global install verify"); expect(result.steps.at(-1)?.stderrTail).toContain( - `missing bundled runtime sidecar ${WHATSAPP_LIGHT_RUNTIME_API}`, + `missing packaged dist file ${WHATSAPP_LIGHT_RUNTIME_API}`, ); });