From ef9e9bf6b92865d61d981012afb33e0cb2fdb65d Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 31 May 2026 07:00:44 +0200 Subject: [PATCH] fix(build): preserve fresh startup metadata across rebuilds --- scripts/build-all.mjs | 7 ++++ scripts/tsdown-build.mjs | 28 ++++++++++++++-- scripts/write-cli-startup-metadata.ts | 4 +++ test/scripts/build-all.test.ts | 30 +++++++++++++++-- test/scripts/tsdown-build.test.ts | 33 +++++++++++++++++++ .../write-cli-startup-metadata.test.ts | 16 +++++++-- 6 files changed, 112 insertions(+), 6 deletions(-) diff --git a/scripts/build-all.mjs b/scripts/build-all.mjs index 084c08ea9f6..5aa74f387ad 100644 --- a/scripts/build-all.mjs +++ b/scripts/build-all.mjs @@ -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", diff --git a/scripts/tsdown-build.mjs b/scripts/tsdown-build.mjs index e20dd8eea2f..906a78657d6 100644 --- a/scripts/tsdown-build.mjs +++ b/scripts/tsdown-build.mjs @@ -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 { diff --git a/scripts/write-cli-startup-metadata.ts b/scripts/write-cli-startup-metadata.ts index 8e6404a64d0..d77e3ad6064 100644 --- a/scripts/write-cli-startup-metadata.ts +++ b/scripts/write-cli-startup-metadata.ts @@ -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, diff --git a/test/scripts/build-all.test.ts b/test/scripts/build-all.test.ts index 780bddbd135..c1da1b7ff0d 100644 --- a/test/scripts/build-all.test.ts +++ b/test/scripts/build-all.test.ts @@ -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", diff --git a/test/scripts/tsdown-build.test.ts b/test/scripts/tsdown-build.test.ts index 17489019e91..3c449fe0e6d 100644 --- a/test/scripts/tsdown-build.test.ts +++ b/test/scripts/tsdown-build.test.ts @@ -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( diff --git a/test/scripts/write-cli-startup-metadata.test.ts b/test/scripts/write-cli-startup-metadata.test.ts index ac5e8553b8b..ff53f8ebb30 100644 --- a/test/scripts/write-cli-startup-metadata.test.ts +++ b/test/scripts/write-cli-startup-metadata.test.ts @@ -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"); }); });