diff --git a/scripts/check-openclaw-package-tarball.mjs b/scripts/check-openclaw-package-tarball.mjs index 55516ba0741..9c5cb7e8d9d 100644 --- a/scripts/check-openclaw-package-tarball.mjs +++ b/scripts/check-openclaw-package-tarball.mjs @@ -39,6 +39,8 @@ const normalized = entries.map((entry) => entry.replace(/^package\//u, "")); const entrySet = new Set(normalized); const errors = []; const warnings = []; +const REQUIRED_TARBALL_ENTRIES = ["dist/control-ui/index.html"]; +const REQUIRED_TARBALL_ENTRY_PREFIXES = ["dist/control-ui/assets/"]; const LEGACY_PACKAGE_ACCEPTANCE_COMPAT_MAX = { year: 2026, month: 4, day: 25 }; const LEGACY_LOCAL_BUILD_METADATA_COMPAT_MAX = { year: 2026, month: 4, day: 26 }; const FORBIDDEN_LOCAL_BUILD_METADATA_FILES = new Set(LOCAL_BUILD_METADATA_DIST_PATHS); @@ -129,6 +131,16 @@ if (!entrySet.has("package.json")) { if (!normalized.some((entry) => entry.startsWith("dist/"))) { errors.push("missing dist/ entries"); } +for (const requiredEntry of REQUIRED_TARBALL_ENTRIES) { + if (!entrySet.has(requiredEntry)) { + errors.push(`missing required tar entry ${requiredEntry}`); + } +} +for (const requiredPrefix of REQUIRED_TARBALL_ENTRY_PREFIXES) { + if (!normalized.some((entry) => entry.startsWith(requiredPrefix))) { + errors.push(`missing required tar entries under ${requiredPrefix}`); + } +} let packageVersion = ""; if (entrySet.has("package.json")) { try { diff --git a/scripts/openclaw-cross-os-release-checks.ts b/scripts/openclaw-cross-os-release-checks.ts index f5d42a9b8d6..40b000ac71b 100644 --- a/scripts/openclaw-cross-os-release-checks.ts +++ b/scripts/openclaw-cross-os-release-checks.ts @@ -10,6 +10,7 @@ import { existsSync, mkdirSync, readFileSync, + realpathSync, rmSync, writeFileSync, } from "node:fs"; @@ -1302,7 +1303,9 @@ export function resolveInstalledPrefixDirFromCliPath(cliPath, platform = process } function readInstalledMetadataFromCliPath(cliPath, platform = process.platform) { - return readInstalledMetadata(resolveInstalledPrefixDirFromCliPath(cliPath, platform)); + return readInstalledMetadataFromPackageRoot( + resolveInstalledPackageRootFromCliPath(cliPath, platform), + ); } function resolveInstalledCliInvocation(cliPath, platform = process.platform) { @@ -2781,6 +2784,10 @@ async function runOpenClaw(params) { function readInstalledPackageManifest(prefixDir) { const packageRoot = installedPackageRoot(prefixDir); + return readInstalledPackageManifestFromPackageRoot(packageRoot); +} + +function readInstalledPackageManifestFromPackageRoot(packageRoot) { const packageJsonPath = join(packageRoot, "package.json"); if (!existsSync(packageJsonPath)) { throw new Error(`Installed package manifest missing: ${packageJsonPath}`); @@ -2798,6 +2805,15 @@ export function readInstalledVersion(prefixDir) { function readInstalledMetadata(prefixDir) { const { packageJson, packageRoot } = readInstalledPackageManifest(prefixDir); + return readInstalledMetadataFromManifest(packageJson, packageRoot); +} + +function readInstalledMetadataFromPackageRoot(packageRoot) { + const { packageJson } = readInstalledPackageManifestFromPackageRoot(packageRoot); + return readInstalledMetadataFromManifest(packageJson, packageRoot); +} + +function readInstalledMetadataFromManifest(packageJson, packageRoot) { const buildInfoPath = join(packageRoot, "dist", "build-info.json"); if (!existsSync(buildInfoPath)) { throw new Error(`Installed build info missing: ${buildInfoPath}`); @@ -2824,8 +2840,55 @@ function verifyInstalledCandidate(installed, build) { } } -function installedPackageRoot(prefixDir) { - return process.platform === "win32" +export function resolveInstalledPackageRootFromCliPath( + cliPath, + platform = process.platform, + env = process.env, +) { + const prefixDir = resolveInstalledPrefixDirFromCliPath(cliPath, platform); + const candidates = [installedPackageRoot(prefixDir, platform)]; + + if (platform !== "win32") { + const resolvedCliPath = String(cliPath ?? "").trim(); + if (resolvedCliPath) { + try { + const realCliPath = realpathSync(resolvedCliPath); + candidates.push(dirname(realCliPath)); + candidates.push(dirname(dirname(realCliPath))); + } catch { + // Some installer shims are shell wrappers, not symlinks. Fall through to + // common user-local npm prefixes below. + } + } + + for (const prefix of [ + env.NPM_CONFIG_PREFIX, + env.npm_config_prefix, + env.HOME && join(env.HOME, ".npm-global"), + env.HOME && join(env.HOME, ".local"), + ]) { + if (typeof prefix === "string" && prefix.trim()) { + candidates.push(installedPackageRoot(prefix, platform)); + } + } + } + + const checked: string[] = []; + for (const candidate of candidates) { + if (!candidate || checked.includes(candidate)) { + continue; + } + checked.push(candidate); + if (existsSync(join(candidate, "package.json"))) { + return candidate; + } + } + + throw new Error(`Installed package manifest missing. Checked: ${checked.join(", ")}`); +} + +function installedPackageRoot(prefixDir, platform = process.platform) { + return platform === "win32" ? join(prefixDir, "node_modules", "openclaw") : join(prefixDir, "lib", "node_modules", "openclaw"); } diff --git a/scripts/package-openclaw-for-docker.mjs b/scripts/package-openclaw-for-docker.mjs index 69226853f97..eda29144940 100644 --- a/scripts/package-openclaw-for-docker.mjs +++ b/scripts/package-openclaw-for-docker.mjs @@ -115,6 +115,8 @@ async function main() { if (!options.skipBuild) { console.error("==> Building OpenClaw package artifacts"); await run("pnpm", ["build"], sourceDir); + console.error("==> Building OpenClaw Control UI artifacts"); + await run("pnpm", ["ui:build"], sourceDir); } console.error("==> Writing OpenClaw package inventory"); diff --git a/test/scripts/check-openclaw-package-tarball.test.ts b/test/scripts/check-openclaw-package-tarball.test.ts index fa83805980d..b24936add7a 100644 --- a/test/scripts/check-openclaw-package-tarball.test.ts +++ b/test/scripts/check-openclaw-package-tarball.test.ts @@ -12,6 +12,7 @@ function withTarball( files: Record, testBody: (tarball: string) => void, version = "0.0.0", + options: { includeControlUi?: boolean } = {}, ) { const root = mkdtempSync(join(tmpdir(), "openclaw-package-tarball-test-")); try { @@ -22,7 +23,15 @@ function withTarball( join(packageRoot, "dist", "postinstall-inventory.json"), JSON.stringify(inventory), ); - for (const [relativePath, body] of Object.entries(files)) { + const tarFiles = + options.includeControlUi === false + ? files + : { + "dist/control-ui/index.html": "", + "dist/control-ui/assets/app.js": "console.log('ok');\n", + ...files, + }; + for (const [relativePath, body] of Object.entries(tarFiles)) { const filePath = join(packageRoot, relativePath); mkdirSync(dirname(filePath), { recursive: true }); writeFileSync(filePath, body); @@ -85,6 +94,24 @@ describe("check-openclaw-package-tarball", () => { ); }); + it("rejects missing Control UI assets", () => { + withTarball( + ["dist/index.js"], + { "dist/index.js": "export {};\n" }, + (tarball) => { + const result = spawnSync("node", [CHECK_SCRIPT, tarball], { encoding: "utf8" }); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("missing required tar entry dist/control-ui/index.html"); + expect(result.stderr).toContain( + "missing required tar entries under dist/control-ui/assets/", + ); + }, + "2026.4.27", + { includeControlUi: false }, + ); + }); + it("rejects local build metadata entries in package tarballs", () => { withTarball( ["dist/index.js", ...LOCAL_BUILD_METADATA_DIST_PATHS], diff --git a/test/scripts/openclaw-cross-os-release-checks.test.ts b/test/scripts/openclaw-cross-os-release-checks.test.ts index b44442bc871..96d6518ac6d 100644 --- a/test/scripts/openclaw-cross-os-release-checks.test.ts +++ b/test/scripts/openclaw-cross-os-release-checks.test.ts @@ -1,4 +1,12 @@ -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; import { createServer as createNetServer } from "node:net"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -31,6 +39,7 @@ import { readInstalledVersion, readRunnerOverrideEnv, resolveExplicitBaselineVersion, + resolveInstalledPackageRootFromCliPath, resolveDevUpdateVerificationRef, resolveInstalledPrefixDirFromCliPath, resolvePublishedInstallerUrl, @@ -398,6 +407,36 @@ describe("scripts/openclaw-cross-os-release-checks", () => { ).toBe("/Users/runner/.npm-global"); }); + it("resolves Linux npm package roots when the CLI is a user-local shim", () => { + const homeDir = mkdtempSync(join(tmpdir(), "openclaw-cross-os-linux-home-")); + try { + const packageRoot = join(homeDir, ".npm-global", "lib", "node_modules", "openclaw"); + const distDir = join(packageRoot, "dist"); + const cliDir = join(homeDir, ".local", "bin"); + mkdirSync(distDir, { recursive: true }); + mkdirSync(cliDir, { recursive: true }); + writeFileSync(join(packageRoot, "package.json"), JSON.stringify({ name: "openclaw" })); + writeFileSync(join(distDir, "entry.js"), "#!/usr/bin/env node\n"); + + expect( + resolveInstalledPackageRootFromCliPath(join(cliDir, "openclaw"), "linux", { + HOME: homeDir, + }), + ).toBe(packageRoot); + + rmSync(join(cliDir, "openclaw"), { force: true }); + symlinkSync(join(distDir, "entry.js"), join(cliDir, "openclaw")); + + expect( + resolveInstalledPackageRootFromCliPath(join(cliDir, "openclaw"), "linux", { + HOME: homeDir, + }), + ).toBe(realpathSync(packageRoot)); + } finally { + rmSync(homeDir, { recursive: true, force: true }); + } + }); + it("detects whether a managed gateway listener is still reachable on loopback", async () => { const server = createNetServer(); await new Promise((resolvePromise) => {