fix(build): preserve fresh startup metadata across rebuilds

This commit is contained in:
Vincent Koc
2026-05-31 07:00:44 +02:00
parent 51dee73a5d
commit ef9e9bf6b9
6 changed files with 112 additions and 6 deletions

View File

@@ -180,9 +180,15 @@ export const BUILD_ALL_PROFILES = {
};
export const BUILD_ALL_PROFILE_STEP_ENV = {
full: {
tsdown: {
OPENCLAW_PRESERVE_CLI_STARTUP_METADATA: "1",
},
},
ciArtifacts: {
tsdown: {
OPENCLAW_RUN_NODE_SKIP_DTS_BUILD: "1",
OPENCLAW_PRESERVE_CLI_STARTUP_METADATA: "1",
},
},
gatewayWatch: {
@@ -201,6 +207,7 @@ export const BUILD_ALL_PROFILE_STEP_ENV = {
cliStartup: {
tsdown: {
OPENCLAW_RUN_NODE_SKIP_DTS_BUILD: "1",
OPENCLAW_PRESERVE_CLI_STARTUP_METADATA: "1",
},
"runtime-postbuild": {
OPENCLAW_RUNTIME_POSTBUILD_STATIC_ASSETS: "0",

View File

@@ -30,6 +30,8 @@ const CGROUP_MEMORY_LIMIT_PATHS = [
const PROC_MEMINFO_PATH = "/proc/meminfo";
const TERMINATION_GRACE_MS = 5_000;
const ROOT_TSDOWN_OUTPUT_ROOTS = ["dist", "dist-runtime"];
const PRESERVED_TSDOWN_OUTPUT_FILES = ["dist/cli-startup-metadata.json"];
const PRESERVE_CLI_STARTUP_METADATA_ENV = "OPENCLAW_PRESERVE_CLI_STARTUP_METADATA";
const GENERATED_SOURCE_DECLARATION_PATHSPEC = ":(glob)extensions/**/*.d.ts";
const DECLARATION_EXTENSIONS = [".d.ts", ".d.mts", ".d.cts"];
const SOURCE_DECLARATION_SOURCE_EXTENSIONS = [".ts", ".tsx", ".mts", ".cts", ".js", ".mjs", ".cjs"];
@@ -78,11 +80,15 @@ export function cleanTsdownOutputRoots(params = {}) {
roots,
})
: new Set();
const protectedPaths = new Set([
...protectedDeclarationPaths,
...listExistingPreservedOutputPaths({ cwd, env, fs: fsImpl }),
]);
for (const root of roots) {
const rootPath = path.join(cwd, root);
try {
if (hasProtectedChild({ rootPath, protectedPaths: protectedDeclarationPaths })) {
cleanOutputRootExcept(rootPath, protectedDeclarationPaths, fsImpl);
if (hasProtectedChild({ rootPath, protectedPaths })) {
cleanOutputRootExcept(rootPath, protectedPaths, fsImpl);
} else {
fsImpl.rmSync(rootPath, { force: true, recursive: true });
}
@@ -137,6 +143,24 @@ function listExistingDeclarationOutputPaths({ cwd, fs: fsImpl, roots }) {
return protectedPaths;
}
function listExistingPreservedOutputPaths({ cwd, env, fs: fsImpl }) {
const protectedPaths = new Set();
if (env[PRESERVE_CLI_STARTUP_METADATA_ENV] !== "1") {
return protectedPaths;
}
for (const relativePath of PRESERVED_TSDOWN_OUTPUT_FILES) {
const absolutePath = path.resolve(cwd, relativePath);
try {
if (fsImpl.statSync(absolutePath).isFile()) {
protectedPaths.add(absolutePath);
}
} catch {
// Missing preserved outputs are normal on first build.
}
}
return protectedPaths;
}
function collectDeclarationOutputPaths(rootPath, protectedPaths, fsImpl) {
let entries = [];
try {

View File

@@ -39,6 +39,7 @@ const CORE_CHANNEL_ORDER = [
"signal",
"imessage",
] as const;
const generatorSignature = createHash("sha1").update(readFileSync(scriptPath)).digest("hex");
type ExtensionChannelEntry = {
id: string;
@@ -467,6 +468,7 @@ export async function writeCliStartupMetadata(options?: {
try {
const existing = JSON.parse(readFileSync(resolvedOutputPath, "utf8")) as {
rootHelpBundleSignature?: unknown;
generatorSignature?: unknown;
browserHelpSourceSignature?: unknown;
secretsHelpSourceSignature?: unknown;
nodesHelpSourceSignature?: unknown;
@@ -480,6 +482,7 @@ export async function writeCliStartupMetadata(options?: {
if (
bundleIdentity &&
existing.rootHelpBundleSignature === bundleIdentity.signature &&
existing.generatorSignature === generatorSignature &&
existing.browserHelpSourceSignature === browserHelpSourceSignature &&
existing.secretsHelpSourceSignature === secretsHelpSourceSignature &&
existing.nodesHelpSourceSignature === nodesHelpSourceSignature &&
@@ -527,6 +530,7 @@ export async function writeCliStartupMetadata(options?: {
`${JSON.stringify(
{
generatedBy: "scripts/write-cli-startup-metadata.ts",
generatorSignature,
channelOptions,
channelCatalogSignature: channelCatalog.signature,
rootHelpBundleSignature: bundleIdentity?.signature ?? null,

View File

@@ -214,7 +214,9 @@ describe("resolveBuildAllSteps", () => {
});
it("keeps the full profile aligned with the declared steps", () => {
expect(resolveBuildAllSteps("full")).toEqual(BUILD_ALL_STEPS);
expect(resolveBuildAllSteps("full").map((step) => step.label)).toEqual(
BUILD_ALL_STEPS.map((step) => step.label),
);
expect(BUILD_ALL_PROFILES.full).toEqual(BUILD_ALL_STEPS.map((step) => step.label));
});
@@ -246,7 +248,7 @@ describe("resolveBuildAllSteps", () => {
throw new Error(`Missing ${profile} tsdown step`);
}
expect(BUILD_ALL_PROFILE_STEP_ENV[profile].tsdown).toEqual({
expect(BUILD_ALL_PROFILE_STEP_ENV[profile].tsdown).toMatchObject({
OPENCLAW_RUN_NODE_SKIP_DTS_BUILD: "1",
});
expect(
@@ -257,6 +259,30 @@ describe("resolveBuildAllSteps", () => {
}
});
it("preserves startup metadata only for profiles that regenerate it", () => {
for (const profile of ["full", "ciArtifacts", "cliStartup"]) {
const tsdown = resolveBuildAllSteps(profile).find((step) => step.label === "tsdown");
if (!tsdown) {
throw new Error(`Missing ${profile} tsdown step`);
}
expect(resolveBuildAllStep(tsdown, { env: {} }).options.env).toMatchObject({
OPENCLAW_PRESERVE_CLI_STARTUP_METADATA: "1",
});
}
for (const profile of ["gatewayWatch", "qaRuntime"]) {
const tsdown = resolveBuildAllSteps(profile).find((step) => step.label === "tsdown");
if (!tsdown) {
throw new Error(`Missing ${profile} tsdown step`);
}
expect(resolveBuildAllStep(tsdown, { env: {} }).options.env).not.toHaveProperty(
"OPENCLAW_PRESERVE_CLI_STARTUP_METADATA",
);
}
});
it("uses a minimal built runtime profile for gateway watch regression", () => {
expect(resolveBuildAllSteps("gatewayWatch").map((step) => step.label)).toEqual([
"tsdown",

View File

@@ -320,6 +320,39 @@ describe("resolveTsdownBuildInvocation", () => {
await expect(fsPromises.readFile(unrelatedFile, "utf8")).resolves.toBe("keep\n");
});
it("removes CLI startup metadata during default tsdown clean", async () => {
const rootDir = createTempDir("openclaw-tsdown-clean-metadata-default-");
const metadataFile = path.join(rootDir, "dist", "cli-startup-metadata.json");
await fsPromises.mkdir(path.dirname(metadataFile), { recursive: true });
await fsPromises.writeFile(metadataFile, '{"generatedBy":"test"}\n');
cleanTsdownOutputRoots({ cwd: rootDir });
await expectPathMissing(metadataFile);
});
it("preserves CLI startup metadata across opted-in build-all tsdown clean", async () => {
const rootDir = createTempDir("openclaw-tsdown-clean-metadata-");
const metadataFile = path.join(rootDir, "dist", "cli-startup-metadata.json");
const staleFile = path.join(rootDir, "dist", "stale.js");
const nestedStaleFile = path.join(rootDir, "dist", "nested", "stale.js");
await fsPromises.mkdir(path.dirname(nestedStaleFile), { recursive: true });
await fsPromises.writeFile(metadataFile, '{"generatedBy":"test"}\n');
await fsPromises.writeFile(staleFile, "stale\n");
await fsPromises.writeFile(nestedStaleFile, "stale\n");
cleanTsdownOutputRoots({
cwd: rootDir,
env: { OPENCLAW_PRESERVE_CLI_STARTUP_METADATA: "1" },
});
await expect(fsPromises.readFile(metadataFile, "utf8")).resolves.toBe(
'{"generatedBy":"test"}\n',
);
await expectPathMissing(staleFile);
await expectPathMissing(nestedStaleFile);
});
it("preserves existing package declarations when tsdown DTS output is skipped", async () => {
const rootDir = createTempDir("openclaw-tsdown-clean-skip-dts-");
const declarationFile = path.join(

View File

@@ -93,6 +93,7 @@ describe("write-cli-startup-metadata", () => {
const written = JSON.parse(readFileSync(outputPath, "utf8")) as {
browserHelpText: string;
channelOptions: string[];
generatorSignature: string;
nodesHelpText: string;
rootHelpText: string;
secretsHelpText: string;
@@ -104,6 +105,7 @@ describe("write-cli-startup-metadata", () => {
};
};
expect(written.channelOptions).toContain("matrix");
expect(written.generatorSignature).toMatch(/^[a-f0-9]{40}$/u);
expect(written.browserHelpText).toContain("Usage:");
expect(written.browserHelpText).toContain("openclaw browser");
expect(written.secretsHelpText).toContain("Usage:");
@@ -154,6 +156,16 @@ describe("write-cli-startup-metadata", () => {
await writeMetadata();
expect(nodesRenderCount).toBe(1);
const staleGeneratorMetadata = JSON.parse(readFileSync(outputPath, "utf8")) as Record<
string,
unknown
>;
staleGeneratorMetadata.generatorSignature = "stale-generator";
writeFileSync(outputPath, `${JSON.stringify(staleGeneratorMetadata, null, 2)}\n`, "utf8");
await writeMetadata();
expect(nodesRenderCount).toBe(2);
writeFixtureFile(
tempRoot,
"extensions/canvas/src/cli.ts",
@@ -165,7 +177,7 @@ describe("write-cli-startup-metadata", () => {
const written = JSON.parse(readFileSync(outputPath, "utf8")) as {
nodesHelpText: string;
};
expect(nodesRenderCount).toBe(2);
expect(written.nodesHelpText).toContain("openclaw nodes 2");
expect(nodesRenderCount).toBe(3);
expect(written.nodesHelpText).toContain("openclaw nodes 3");
});
});