fix(update): harden dist inventory handling

This commit is contained in:
Vincent Koc
2026-04-15 09:16:46 +01:00
parent df918c4de5
commit 7f35f76914
4 changed files with 222 additions and 12 deletions

View File

@@ -6,7 +6,10 @@ import { BUNDLED_RUNTIME_SIDECAR_PATHS } from "../plugins/runtime-sidecar-paths.
import { withTempDir } from "../test-helpers/temp-dir.js";
import { captureEnv } from "../test-utils/env.js";
import { NPM_UPDATE_COMPAT_SIDECAR_PATHS } from "./npm-update-compat-sidecars.js";
import { writePackageDistInventory } from "./package-dist-inventory.js";
import {
PACKAGE_DIST_INVENTORY_RELATIVE_PATH,
writePackageDistInventory,
} from "./package-dist-inventory.js";
import {
canResolveRegistryVersionForPackageTarget,
collectInstalledGlobalPackageErrors,
@@ -375,6 +378,11 @@ describe("update global helpers", () => {
JSON.stringify({ name: "openclaw", version: "1.0.0" }),
"utf-8",
);
for (const relativePath of NPM_UPDATE_COMPAT_SIDECAR_PATHS) {
const absolutePath = path.join(packageRoot, relativePath);
await fs.mkdir(path.dirname(absolutePath), { recursive: true });
await fs.writeFile(absolutePath, "export {};\n", "utf-8");
}
for (const relativePath of BUNDLED_RUNTIME_SIDECAR_PATHS) {
const absolutePath = path.join(packageRoot, relativePath);
await fs.mkdir(path.dirname(absolutePath), { recursive: true });
@@ -422,6 +430,51 @@ describe("update global helpers", () => {
});
});
it("fails closed on newer installs when the inventory is missing", async () => {
await withTempDir(
{ prefix: "openclaw-update-global-missing-inventory-new-" },
async (packageRoot) => {
await fs.writeFile(
path.join(packageRoot, "package.json"),
JSON.stringify({ name: "openclaw", version: "2026.4.15" }),
"utf-8",
);
for (const relativePath of NPM_UPDATE_COMPAT_SIDECAR_PATHS) {
const absolutePath = path.join(packageRoot, relativePath);
await fs.mkdir(path.dirname(absolutePath), { recursive: true });
await fs.writeFile(absolutePath, "export {};\n", "utf-8");
}
await expect(collectInstalledGlobalPackageErrors({ packageRoot })).resolves.toContain(
`missing package dist inventory ${PACKAGE_DIST_INVENTORY_RELATIVE_PATH}`,
);
},
);
});
it("rejects invalid inventory files during global verify", async () => {
await withTempDir(
{ prefix: "openclaw-update-global-invalid-inventory-" },
async (packageRoot) => {
await fs.writeFile(
path.join(packageRoot, "package.json"),
JSON.stringify({ name: "openclaw", version: "2026.4.15" }),
"utf-8",
);
await fs.mkdir(path.join(packageRoot, "dist"), { recursive: true });
await fs.writeFile(
path.join(packageRoot, PACKAGE_DIST_INVENTORY_RELATIVE_PATH),
"{not-json}\n",
"utf8",
);
await expect(collectInstalledGlobalPackageErrors({ packageRoot })).resolves.toContain(
`invalid package dist inventory ${PACKAGE_DIST_INVENTORY_RELATIVE_PATH}`,
);
},
);
});
it("verifies legacy sidecars for installed bundled plugins without inventory", async () => {
await withTempDir({ prefix: "openclaw-update-global-legacy-plugin-" }, async (packageRoot) => {
await fs.writeFile(
@@ -444,4 +497,40 @@ describe("update global helpers", () => {
);
});
});
it("still enforces critical sidecars when the inventory omits them", async () => {
await withTempDir(
{ prefix: "openclaw-update-global-critical-sidecars-" },
async (packageRoot) => {
await fs.writeFile(
path.join(packageRoot, "package.json"),
JSON.stringify({ name: "openclaw", version: "2026.4.15" }),
"utf-8",
);
for (const relativePath of NPM_UPDATE_COMPAT_SIDECAR_PATHS) {
const absolutePath = path.join(packageRoot, relativePath);
await fs.mkdir(path.dirname(absolutePath), { recursive: true });
await fs.writeFile(absolutePath, "export {};\n", "utf-8");
}
const matrixPackageJson = path.join(
packageRoot,
"dist",
"extensions",
"matrix",
"package.json",
);
await fs.mkdir(path.dirname(matrixPackageJson), { recursive: true });
await fs.writeFile(
matrixPackageJson,
JSON.stringify({ name: "@openclaw/matrix" }),
"utf-8",
);
await writePackageDistInventory(packageRoot);
await expect(collectInstalledGlobalPackageErrors({ packageRoot })).resolves.toContain(
`missing bundled runtime sidecar ${MATRIX_HELPER_API}`,
);
},
);
});
});

View File

@@ -2,12 +2,15 @@ import fsSync from "node:fs";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import semverGte from "semver/functions/gte.js";
import semverValid from "semver/functions/valid.js";
import { BUNDLED_RUNTIME_SIDECAR_PATHS } from "../plugins/runtime-sidecar-paths.js";
import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
import { pathExists } from "../utils.js";
import { NPM_UPDATE_COMPAT_SIDECAR_PATHS } from "./npm-update-compat-sidecars.js";
import {
collectPackageDistInventory,
PACKAGE_DIST_INVENTORY_RELATIVE_PATH,
readPackageDistInventoryIfPresent,
} from "./package-dist-inventory.js";
import { readPackageVersion } from "./package-json.js";
@@ -40,6 +43,7 @@ const NPM_GLOBAL_INSTALL_OMIT_OPTIONAL_FLAGS = [
"--omit=optional",
...NPM_GLOBAL_INSTALL_QUIET_FLAGS,
] as const;
const FIRST_PACKAGED_DIST_INVENTORY_VERSION = "2026.4.15";
function normalizePackageTarget(value: string): string {
return value.trim();
@@ -94,27 +98,82 @@ export async function collectInstalledGlobalPackageErrors(params: {
`expected installed version ${params.expectedVersion}, found ${installedVersion ?? "<missing>"}`,
);
}
errors.push(...(await collectInstalledPackageDistErrors(params.packageRoot)));
errors.push(
...(await collectInstalledPackageDistErrors({
packageRoot: params.packageRoot,
installedVersion,
expectedVersion: params.expectedVersion,
})),
);
return errors;
}
async function collectInstalledPackageDistErrors(packageRoot: string): Promise<string[]> {
const inventoryFiles = await readPackageDistInventoryIfPresent(packageRoot);
function shouldRequirePackagedDistInventory(version: string | null | undefined): boolean {
return typeof version === "string" && semverValid(version) !== null
? semverGte(version, FIRST_PACKAGED_DIST_INVENTORY_VERSION)
: false;
}
async function collectInstalledPackageDistErrors(params: {
packageRoot: string;
installedVersion: string | null;
expectedVersion?: string | null;
}): Promise<string[]> {
const criticalPaths = await collectLegacyInstalledPackageDistPaths(params.packageRoot);
let inventoryFiles: string[] | null = null;
let inventoryError: string | null = null;
try {
inventoryFiles = await readPackageDistInventoryIfPresent(params.packageRoot);
} catch {
inventoryError = `invalid package dist inventory ${PACKAGE_DIST_INVENTORY_RELATIVE_PATH}`;
}
if (inventoryFiles !== null) {
return await collectInstalledPathErrors({
packageRoot,
const actualFiles = await collectPackageDistInventory(params.packageRoot);
const inventoryErrors = await collectInstalledPathErrors({
packageRoot: params.packageRoot,
expectedFiles: inventoryFiles,
actualFiles: await collectPackageDistInventory(packageRoot),
actualFiles,
missingMessage: (relativePath) => `missing packaged dist file ${relativePath}`,
unexpectedMessage: (relativePath) => `unexpected packaged dist file ${relativePath}`,
});
const inventorySet = new Set(inventoryFiles);
const supplementalCriticalPaths = criticalPaths.filter(
(relativePath) => !inventorySet.has(relativePath),
);
if (supplementalCriticalPaths.length === 0) {
return inventoryErrors;
}
return [
...inventoryErrors,
...(await collectInstalledPathErrors({
packageRoot: params.packageRoot,
expectedFiles: supplementalCriticalPaths,
actualFiles,
missingMessage: (relativePath) => `missing bundled runtime sidecar ${relativePath}`,
})),
];
}
return await collectInstalledPathErrors({
packageRoot,
expectedFiles: await collectLegacyInstalledPackageDistPaths(packageRoot),
const criticalErrors = await collectInstalledPathErrors({
packageRoot: params.packageRoot,
expectedFiles: criticalPaths,
actualFiles: null,
missingMessage: (relativePath) => `missing bundled runtime sidecar ${relativePath}`,
});
if (inventoryError) {
return [inventoryError, ...criticalErrors];
}
if (
shouldRequirePackagedDistInventory(params.installedVersion) ||
shouldRequirePackagedDistInventory(params.expectedVersion)
) {
return [
`missing package dist inventory ${PACKAGE_DIST_INVENTORY_RELATIVE_PATH}`,
...criticalErrors,
];
}
return criticalErrors;
}
async function collectLegacyInstalledPackageDistPaths(packageRoot: string): Promise<string[]> {