diff --git a/scripts/lib/workspace-bootstrap-smoke.mjs b/scripts/lib/workspace-bootstrap-smoke.mjs index 56739bc100fc..6ec84b5fc9d7 100644 --- a/scripts/lib/workspace-bootstrap-smoke.mjs +++ b/scripts/lib/workspace-bootstrap-smoke.mjs @@ -1,6 +1,6 @@ // Verifies installed packages can bootstrap the default OpenClaw workspace files. import { execFileSync, spawn } from "node:child_process"; -import { existsSync, mkdtempSync, mkdirSync, rmSync } from "node:fs"; +import { cpSync, existsSync, mkdtempSync, mkdirSync, rmSync } from "node:fs"; import { createServer } from "node:net"; import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; @@ -25,6 +25,7 @@ export const DIST_RUNTIME_ARTIFACT_BASE_PATHS = [ "src/agents/templates", "dist", "dist-runtime", + "node_modules", ]; const DIST_RUNTIME_ARTIFACT_PACKAGE_DIST_PATHS = [ @@ -43,6 +44,37 @@ function packageArtifactPath(sourcePath) { return sourcePath.replace(/^packages\//u, "node_modules/@openclaw/"); } +function copyDistRuntimeArtifactPath( + rootDir, + artifactRoot, + sourcePath, + destinationPath = sourcePath, +) { + const destination = join(artifactRoot, destinationPath); + mkdirSync(dirname(destination), { recursive: true }); + cpSync(join(rootDir, sourcePath), destination, { dereference: true, recursive: true }); +} + +function stageDistRuntimeArtifact(rootDir, artifactRoot) { + const deploymentRoot = join(artifactRoot, "deployment"); + execFileSync( + "pnpm", + ["--ignore-scripts", "--filter", "openclaw", "deploy", "--legacy", "--prod", deploymentRoot], + { cwd: rootDir, stdio: "inherit" }, + ); + copyDistRuntimeArtifactPath(deploymentRoot, artifactRoot, "node_modules"); + rmSync(deploymentRoot, { force: true, recursive: true }); + + for (const sourcePath of DIST_RUNTIME_ARTIFACT_BASE_PATHS) { + if (sourcePath !== "node_modules") { + copyDistRuntimeArtifactPath(rootDir, artifactRoot, sourcePath); + } + } + for (const sourcePath of DIST_RUNTIME_ARTIFACT_PACKAGE_SOURCE_PATHS) { + copyDistRuntimeArtifactPath(rootDir, artifactRoot, sourcePath, packageArtifactPath(sourcePath)); + } +} + // HEARTBEAT.md ships in the template pack for docs/doctor context but is no // longer seeded into new workspaces; heartbeat context lives in cron scratch. const REQUIRED_BOOTSTRAP_WORKSPACE_FILES = [ @@ -250,11 +282,11 @@ function validateDistRuntimeArtifactEntries(entries, expectedPaths) { entry === "docs/reference/templates" || entry.startsWith("docs/reference/templates/") || entry === "node_modules" || - entry === "node_modules/@openclaw" + entry.startsWith("node_modules/") ) { return false; } - return !/^node_modules\/@openclaw\/[^/]+\/(?:package\.json|dist(?:\/.*)?)$/u.test(entry); + return true; }); if (unexpectedEntries.length > 0) { throw new Error( @@ -367,105 +399,102 @@ export async function buildAndSmokeDistRuntimeArtifact(params) { const archivePath = resolve(params.archivePath); const compressor = params.compressor ?? "zstdmt"; const artifactPaths = collectDistRuntimeArtifactPaths(rootDir); + const artifactRoot = mkdtempSync(join(tmpdir(), "openclaw-dist-runtime-artifact-")); mkdirSync(dirname(archivePath), { recursive: true }); - execFileSync( - "tar", - [ - "--posix", - "-cf", - archivePath, - "--use-compress-program", - compressor, - // Workspace packages must resolve from the extracted artifact rather than - // the checkout that produced it; normal Node resolution then needs no hook. - "--transform=s,^packages/\\([^/\\]*\\)/,node_modules/@openclaw/\\1/,", - ...DIST_RUNTIME_ARTIFACT_BASE_PATHS, - ...DIST_RUNTIME_ARTIFACT_PACKAGE_SOURCE_PATHS, - ], - { cwd: rootDir, stdio: "inherit" }, - ); - - const entries = listDistRuntimeArtifactEntries(archivePath, compressor); - validateDistRuntimeArtifactEntries(entries, artifactPaths); - - const smokeRoot = mkdtempSync(join(tmpdir(), "openclaw-dist-runtime-artifact-smoke-")); - const packageRoot = join(smokeRoot, "package"); - const homeDir = join(smokeRoot, "home"); - const cwd = join(smokeRoot, "cwd"); - mkdirSync(packageRoot, { recursive: true }); - mkdirSync(homeDir, { recursive: true }); - mkdirSync(cwd, { recursive: true }); - - const artifactEnvOverrides = { - OPENCLAW_CONFIG_PATH: join(homeDir, "openclaw.json"), - OPENCLAW_STATE_DIR: join(homeDir, "state"), - }; - const smokeEnv = createWorkspaceBootstrapSmokeEnv(process.env, homeDir, artifactEnvOverrides); - - let gateway; - let gatewayOutput = ""; try { + stageDistRuntimeArtifact(rootDir, artifactRoot); execFileSync( "tar", - ["--use-compress-program", compressor, "-xf", archivePath, "-C", packageRoot], - { stdio: "inherit" }, + ["--posix", "-cf", archivePath, "--use-compress-program", compressor, ...artifactPaths], + { cwd: artifactRoot, stdio: "inherit" }, ); - runInstalledWorkspaceBootstrapSmoke({ - packageRoot, - envOverrides: artifactEnvOverrides, - }); - const acpHelp = execFileSync( - process.execPath, - [join(packageRoot, "openclaw.mjs"), "acp", "--help"], - { - cwd, - encoding: "utf8", - env: smokeEnv, - maxBuffer: DIST_RUNTIME_ARTIFACT_MAX_OUTPUT_BYTES, - timeout: DIST_RUNTIME_ARTIFACT_SMOKE_TIMEOUT_MS, - }, - ); - if (!acpHelp.includes("Usage: openclaw acp")) { - throw new Error("extracted ACP startup did not return the expected help output"); - } + const entries = listDistRuntimeArtifactEntries(archivePath, compressor); + validateDistRuntimeArtifactEntries(entries, artifactPaths); - const port = await reserveLoopbackPort(); - gateway = spawn( - process.execPath, - [ - join(packageRoot, "openclaw.mjs"), - "gateway", - "run", - "--allow-unconfigured", - "--auth", - "none", - "--bind", - "loopback", - "--port", - String(port), - ], - { - cwd, - detached: process.platform !== "win32", - env: smokeEnv, - stdio: ["ignore", "pipe", "pipe"], - }, - ); - const appendGatewayOutput = (chunk) => { - gatewayOutput += chunk.toString(); - if (Buffer.byteLength(gatewayOutput) > DIST_RUNTIME_ARTIFACT_MAX_OUTPUT_BYTES) { - gatewayOutput = gatewayOutput.slice(-DIST_RUNTIME_ARTIFACT_MAX_OUTPUT_BYTES); - } + const smokeRoot = mkdtempSync(join(tmpdir(), "openclaw-dist-runtime-artifact-smoke-")); + const packageRoot = join(smokeRoot, "package"); + const homeDir = join(smokeRoot, "home"); + const cwd = join(smokeRoot, "cwd"); + mkdirSync(packageRoot, { recursive: true }); + mkdirSync(homeDir, { recursive: true }); + mkdirSync(cwd, { recursive: true }); + + const artifactEnvOverrides = { + OPENCLAW_CONFIG_PATH: join(homeDir, "openclaw.json"), + OPENCLAW_STATE_DIR: join(homeDir, "state"), }; - gateway.stdout.on("data", appendGatewayOutput); - gateway.stderr.on("data", appendGatewayOutput); - await waitForGatewayReadiness({ child: gateway, port, readOutput: () => gatewayOutput }); - } finally { - if (gateway) { - await stopGatewaySmoke(gateway); + const smokeEnv = createWorkspaceBootstrapSmokeEnv(process.env, homeDir, artifactEnvOverrides); + const gatewayEnv = { ...smokeEnv }; + delete gatewayEnv.OPENCLAW_DISABLE_BUNDLED_PLUGINS; + + let gateway; + let gatewayOutput = ""; + try { + execFileSync( + "tar", + ["--use-compress-program", compressor, "-xf", archivePath, "-C", packageRoot], + { stdio: "inherit" }, + ); + runInstalledWorkspaceBootstrapSmoke({ + packageRoot, + envOverrides: artifactEnvOverrides, + }); + + const acpHelp = execFileSync( + process.execPath, + [join(packageRoot, "openclaw.mjs"), "acp", "--help"], + { + cwd, + encoding: "utf8", + env: smokeEnv, + maxBuffer: DIST_RUNTIME_ARTIFACT_MAX_OUTPUT_BYTES, + timeout: DIST_RUNTIME_ARTIFACT_SMOKE_TIMEOUT_MS, + }, + ); + if (!acpHelp.includes("Usage: openclaw acp")) { + throw new Error("extracted ACP startup did not return the expected help output"); + } + + const port = await reserveLoopbackPort(); + gateway = spawn( + process.execPath, + [ + join(packageRoot, "openclaw.mjs"), + "gateway", + "run", + "--allow-unconfigured", + "--auth", + "none", + "--bind", + "loopback", + "--port", + String(port), + ], + { + cwd, + detached: process.platform !== "win32", + env: gatewayEnv, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + const appendGatewayOutput = (chunk) => { + gatewayOutput += chunk.toString(); + if (Buffer.byteLength(gatewayOutput) > DIST_RUNTIME_ARTIFACT_MAX_OUTPUT_BYTES) { + gatewayOutput = gatewayOutput.slice(-DIST_RUNTIME_ARTIFACT_MAX_OUTPUT_BYTES); + } + }; + gateway.stdout.on("data", appendGatewayOutput); + gateway.stderr.on("data", appendGatewayOutput); + await waitForGatewayReadiness({ child: gateway, port, readOutput: () => gatewayOutput }); + } finally { + if (gateway) { + await stopGatewaySmoke(gateway); + } + rmSync(smokeRoot, { force: true, recursive: true }); } - rmSync(smokeRoot, { force: true, recursive: true }); + } finally { + rmSync(artifactRoot, { force: true, recursive: true }); } return { archivePath, artifactPaths }; diff --git a/test/scripts/ci-workflow-guards.test.ts b/test/scripts/ci-workflow-guards.test.ts index 4574f308ae73..468285b8feb9 100644 --- a/test/scripts/ci-workflow-guards.test.ts +++ b/test/scripts/ci-workflow-guards.test.ts @@ -4688,6 +4688,7 @@ printf '%s\n' "\${CURL_SUCCESS_IP:-203.0.113.7}" "TSDOWN_PACKAGE_OUTPUT_ROOTS", '"packages/plugin-sdk/dist"', '"node_modules/@openclaw/"', + '"node_modules"', ]) { expect(artifactBuilder).toContain(requiredPath); } @@ -4695,6 +4696,10 @@ printf '%s\n' "\${CURL_SUCCESS_IP:-203.0.113.7}" expect(artifactBuilder).not.toContain("--import"); expect(artifactBuilder).not.toContain("dist-runtime-artifact-resolver-hook"); expect(artifactBuilder).toContain("node_modules/@openclaw"); + expect(artifactBuilder).toContain( + '"--ignore-scripts", "--filter", "openclaw", "deploy", "--legacy", "--prod"', + ); + expect(artifactBuilder).toContain("delete gatewayEnv.OPENCLAW_DISABLE_BUNDLED_PLUGINS"); expect(artifactBuilder).toContain('detached: process.platform !== "win32"'); expect(artifactBuilder).toContain('process.kill(processGroupId, "SIGKILL")'); expect(artifactBuilder).toContain('"acp", "--help"');