diff --git a/.claude/settings.json b/.claude/settings.json index 2a713398e901..a5df57ccf587 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -6,8 +6,8 @@ "hooks": [ { "type": "command", - "command": "bash -c 'r=\"$(git rev-parse --show-toplevel 2>/dev/null)\"; [ -n \"$r\" ] && cd \"$r\" || exit 0; command -v pnpm >/dev/null 2>&1 && [ -f pnpm-lock.yaml ] || exit 0; CI=true pnpm install --frozen-lockfile --ignore-scripts || echo \"[worktree-setup] pnpm install failed; run pnpm install manually in this worktree\"'", - "timeout": 600 + "command": "bash -c 'r=\"$(git rev-parse --show-toplevel 2>/dev/null)\"; [ -n \"$r\" ] && cd \"$r\" || exit 0; node scripts/worktree-setup.mjs source'", + "timeout": 10 } ] } diff --git a/scripts/bundled-plugin-assets.mts b/scripts/bundled-plugin-assets.mts index 0a56eb407d68..36ecc98b4825 100644 --- a/scripts/bundled-plugin-assets.mts +++ b/scripts/bundled-plugin-assets.mts @@ -5,9 +5,9 @@ import fs from "node:fs/promises"; import path from "node:path"; import { pathToFileURL } from "node:url"; // Discovers and runs bundled plugin package asset hooks. -import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { runManagedCommand } from "./lib/managed-child-process.mts"; import { assertRealOutputRoot } from "./lib/output-root-guard.mjs"; +import { isRecord } from "./lib/record-shared.mjs"; import { resolveRepoRoot } from "./lib/repo-root.mjs"; import { listGeneratedExtensionAssetSources } from "./lib/static-extension-assets.mts"; const rootDir = resolveRepoRoot(import.meta.url); diff --git a/scripts/worktree-setup.mjs b/scripts/worktree-setup.mjs new file mode 100644 index 000000000000..f5d159b40b67 --- /dev/null +++ b/scripts/worktree-setup.mjs @@ -0,0 +1,288 @@ +#!/usr/bin/env node +// Explicit repository preparation; source composition never selects a workload. +import { execFileSync, spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +const WORKLOADS = new Set(["source", "gateway", "full"]); +const SOURCE_GUIDANCE = + "[worktree-setup] Source-only: no dependencies installed or artifacts built. " + + "For gateway work run node scripts/worktree-setup.mjs gateway; " + + "for full preparation run node scripts/worktree-setup.mjs full."; +const FULL_TRANSITION = "Run git sparse-checkout disable, then retry preparation."; + +function git(rootDir, args, input) { + return execFileSync("git", args, { + cwd: rootDir, + encoding: "utf8", + input, + maxBuffer: 32 * 1024 * 1024, + stdio: ["pipe", "pipe", "pipe"], + }); +} + +function readSparseState(rootDir) { + const result = spawnSync("git", ["config", "--get", "--bool", "core.sparseCheckout"], { + cwd: rootDir, + encoding: "utf8", + }); + if (result.error) { + throw result.error; + } + if (result.status === 1) { + return false; + } + if (result.status !== 0) { + throw new Error(result.stderr.trim() || "Cannot read Git sparse-checkout state."); + } + return result.stdout.trim() === "true"; +} + +function validateInputs(rootDir, workload) { + const topLevel = git(rootDir, ["rev-parse", "--show-toplevel"]).trim(); + if (fs.realpathSync(topLevel) !== fs.realpathSync(rootDir)) { + throw new Error("Run preparation from the target repository root."); + } + const sparse = readSparseState(rootDir); + if (workload === "full" && sparse) { + throw new Error("Full preparation requires full source. " + FULL_TRANSITION); + } + const tracked = git(rootDir, ["ls-files", "-z"]).split("\0").filter(Boolean); + let required = tracked; + if (sparse) { + // Git interprets the repository-owned cone list. This helper has no profile + // parser and never changes checkout state. + const definition = path.join(rootDir, ".openclaw/worktree-profiles/gateway"); + if (!fs.existsSync(definition)) { + throw new Error( + "Sparse gateway preparation needs the repository gateway definition. " + FULL_TRANSITION, + ); + } + required = git( + rootDir, + ["sparse-checkout", "check-rules", "--cone", "--rules-file", definition, "-z"], + tracked.join("\0") + "\0", + ) + .split("\0") + .filter(Boolean); + const included = new Set( + git(rootDir, ["sparse-checkout", "check-rules", "-z"], required.join("\0") + "\0") + .split("\0") + .filter(Boolean), + ); + const excluded = required.filter((file) => !included.has(file)); + if (excluded.length > 0) { + throw new Error( + "Gateway inputs are excluded: " + excluded.slice(0, 10).join(", ") + ". " + FULL_TRANSITION, + ); + } + } + const missing = required.filter((file) => !fs.existsSync(path.join(rootDir, file))); + if (missing.length > 0) { + throw new Error( + "Tracked preparation inputs are missing: " + + missing.slice(0, 10).join(", ") + + ". " + + FULL_TRANSITION, + ); + } + // The package/build inventories below use the filesystem. Validate first so + // a missing tracked plugin cannot silently narrow the selected install. + for (const file of [ + "package.json", + "pnpm-workspace.yaml", + "pnpm-lock.yaml", + "scripts/build-all.mts", + ]) { + if (!fs.existsSync(path.join(rootDir, file))) { + throw new Error("Missing preparation input: " + file); + } + } + return { sparse, tracked, requiredCount: required.length }; +} + +export async function createWorktreeSetupPlan({ rootDir, workload = "source", env = process.env }) { + if (!WORKLOADS.has(workload)) { + throw new Error("Unknown preparation workload: " + workload); + } + if (workload === "source") { + return { workload, packageManager: null, install: null, build: null, requiredInputs: null }; + } + const inputs = validateInputs(rootDir, workload); + const pkg = JSON.parse(fs.readFileSync(path.join(rootDir, "package.json"), "utf8")); + if ( + typeof pkg.packageManager !== "string" || + !/^pnpm@\d+\.\d+\.\d+(?:[-+].*)?$/u.test(pkg.packageManager) + ) { + throw new Error("Target package.json must pin pnpm through packageManager."); + } + const filters = new Set(); + if (workload === "gateway") { + const { collectSourceCheckoutPluginBuildEntries } = + await import("./lib/bundled-plugin-build-entries.mjs"); + const { readBundledPluginAssetHooks } = await import("./bundled-plugin-assets.mts"); + if (typeof pkg.name !== "string" || !pkg.name) { + throw new Error("Target package.json is missing its package name."); + } + filters.add(pkg.name + "..."); + filters.add("./packages/*..."); + for (const entry of collectSourceCheckoutPluginBuildEntries({ cwd: rootDir, env })) { + if (entry.hasPackageJson) { + filters.add("./extensions/" + entry.id + "..."); + } + } + for (const phase of ["build", "copy"]) { + for (const hook of await readBundledPluginAssetHooks({ rootDir, phase })) { + filters.add( + "./" + path.relative(rootDir, hook.pluginDir).split(path.sep).join("/") + "...", + ); + } + } + } + const filterArgs = [...filters] + .toSorted((left, right) => (left < right ? -1 : left > right ? 1 : 0)) + .flatMap((filter) => ["--filter", filter]); + return { + workload, + packageManager: pkg.packageManager, + install: { command: "pnpm", args: [...filterArgs, "install", "--frozen-lockfile"] }, + build: + workload === "gateway" + ? { + command: "node", + args: ["--import", "./scripts/tsx.mjs", "scripts/build-all.mts", "qaRuntime"], + } + : { command: "pnpm", args: ["build"] }, + requiredInputs: { sparse: inputs.sparse, trackedFiles: inputs.requiredCount }, + }; +} + +function existingAncestor(target) { + let current = target; + while (!fs.existsSync(current)) { + const parent = path.dirname(current); + if (parent === current) { + throw new Error("Cannot inspect package store volume: " + target); + } + current = parent; + } + return current; +} + +async function executePreparation(rootDir, workload) { + const plan = await createWorktreeSetupPlan({ rootDir, workload }); + const { assertRealOutputRoot } = await import("./lib/output-root-guard.mjs"); + const { createPnpmRunnerSpawnSpec } = await import("./pnpm-runner.mts"); + const { runManagedCommand } = await import("./lib/managed-child-process.mts"); + const validateOutputRoots = () => { + const manifests = git(rootDir, ["ls-files", "-z"]) + .split("\0") + .filter((file) => file === "package.json" || file.endsWith("/package.json")); + for (const manifest of manifests) { + const packageRoot = path.join(rootDir, path.dirname(manifest)); + assertRealOutputRoot(path.join(packageRoot, "node_modules")); + } + assertRealOutputRoot(path.join(rootDir, "node_modules/.pnpm")); + assertRealOutputRoot(path.join(rootDir, "dist")); + }; + validateOutputRoots(); + const pnpm = (args) => createPnpmRunnerSpawnSpec({ cwd: rootDir, pnpmArgs: args }); + const capture = (args) => { + const spec = pnpm(args); + return execFileSync(spec.command, spec.args, { + ...spec.options, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }).trim(); + }; + const version = capture(["--version"]); + const pinnedVersion = plan.packageManager.slice("pnpm@".length).split("+")[0]; + if (version !== pinnedVersion) { + throw new Error( + "Expected repository pnpm " + + pinnedVersion + + ", got " + + version + + ". Run through the repository-pinned toolchain.", + ); + } + const storePath = capture(["store", "path"]); + if ( + !path.isAbsolute(storePath) || + fs.statSync(existingAncestor(storePath)).dev !== fs.statSync(rootDir).dev + ) { + throw new Error("Preparation requires a pnpm store on the same volume as the worktree."); + } + // Do not trust a previously displayed plan as permission to prepare changed inputs. + const current = await createWorktreeSetupPlan({ rootDir, workload }); + if (JSON.stringify(current) !== JSON.stringify(plan)) { + throw new Error("Preparation inputs changed while checking the toolchain; retry."); + } + // Toolchain probes can take time; reject output roots linked since the first check. + validateOutputRoots(); + for (const step of [current.install, current.build]) { + const spec = + step.command === "pnpm" + ? pnpm(step.args) + : { + command: process.execPath, + args: step.args, + options: { cwd: rootDir, shell: false, stdio: "inherit" }, + }; + const status = await runManagedCommand({ + bin: spec.command, + args: spec.args, + ...spec.options, + requireProcessTreeExit: process.platform !== "win32", + }); + if (status !== 0) { + return status; + } + } + return 0; +} + +export function parseWorktreeSetupArgs(argv) { + let workload; + let plan = false; + let help = false; + for (const arg of argv) { + if (arg === "--plan") { + plan = true; + } else if (arg === "--help" || arg === "-h") { + help = true; + } else if (WORKLOADS.has(arg) && workload === undefined) { + workload = arg; + } else { + throw new Error("Unexpected preparation argument: " + arg); + } + } + return { workload: workload ?? "source", plan, help }; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) { + try { + const args = parseWorktreeSetupArgs(process.argv.slice(2)); + if (args.help) { + console.log( + "Usage: node scripts/worktree-setup.mjs [source|gateway|full] [--plan]\n" + SOURCE_GUIDANCE, + ); + } else if (args.plan) { + console.log( + JSON.stringify( + await createWorktreeSetupPlan({ rootDir: process.cwd(), workload: args.workload }), + null, + 2, + ), + ); + } else if (args.workload === "source") { + console.log(SOURCE_GUIDANCE); + } else { + process.exitCode = await executePreparation(process.cwd(), args.workload); + } + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + } +} diff --git a/src/plugins/official-external-plugin-catalog.test.ts b/src/plugins/official-external-plugin-catalog.test.ts index 9c5914bf0d97..90a537789aee 100644 --- a/src/plugins/official-external-plugin-catalog.test.ts +++ b/src/plugins/official-external-plugin-catalog.test.ts @@ -407,19 +407,18 @@ describe("official external plugin catalog", () => { expect(gaps).toEqual([]); }); - it("declares each published ClawHub counterpart in its package and discovery catalog", () => { + it("declares each ClawHub publication target in its package", () => { const gaps = listPublishedPluginOwners().flatMap( ({ id, packageName, install, publishToClawHub }) => { if (!publishToClawHub) { return []; } const expected = `clawhub:${packageName}`; - const catalogSpec = resolveOfficialExternalPluginInstall( - expectCatalogEntry(id), - )?.clawhubSpec; - return install.clawhubSpec === expected && catalogSpec === expected + // Bundled packages may be publishable before external catalog publication. + // The preceding test checks catalog/install parity for external owners. + return install.clawhubSpec === expected ? [] - : [{ id, packageName, expected, packageSpec: install.clawhubSpec, catalogSpec }]; + : [{ id, packageName, expected, packageSpec: install.clawhubSpec }]; }, ); expect(gaps).toEqual([]); diff --git a/test/plugin-npm-release.test.ts b/test/plugin-npm-release.test.ts index 471d4a5d7603..221cae95c5a4 100644 --- a/test/plugin-npm-release.test.ts +++ b/test/plugin-npm-release.test.ts @@ -685,7 +685,14 @@ describe("collectPublishablePluginPackages", () => { }); it("keeps publishable plugin dist trees out of the core npm package unless bundled", () => { - const corePackageRuntimePluginIds = new Set(["discord"]); + // These publication targets intentionally remain in the core distribution. + // bundledDist: true would defer their separate npm/ClawHub publication. + const corePackageRuntimePluginIds = new Set([ + "discord", + "logbook", + "memory-wiki", + "onepassword", + ]); const rootPackage = JSON.parse(readFileSync("package.json", "utf8")) as { files?: unknown; }; diff --git a/test/scripts/release-plan-producer.test.ts b/test/scripts/release-plan-producer.test.ts index ad9073cbd1e0..44c9611ced82 100644 --- a/test/scripts/release-plan-producer.test.ts +++ b/test/scripts/release-plan-producer.test.ts @@ -1772,7 +1772,7 @@ mutateModule.syncBuiltinESMExports(); expect(existsSync(sentinel)).toBe(false); }); - it("matches the exact current publisher inventory: 95 npm and 91 ClawHub packages", () => { + it("matches the exact current publisher inventory: 98 npm and 94 ClawHub packages", () => { const root = tempDirs.make("openclaw-release-plan-current-"); const candidateSha = execFileSync("git", ["rev-parse", "HEAD"], { cwd: resolve("."), @@ -1825,8 +1825,8 @@ mutateModule.syncBuiltinESMExports(); const clawHubPackages = plan.inventory.packages.filter((entry) => entry.targets.includes("clawhub"), ); - expect(npmPackages).toHaveLength(95); - expect(clawHubPackages).toHaveLength(91); + expect(npmPackages).toHaveLength(98); + expect(clawHubPackages).toHaveLength(94); const coreNpmPackages = new Set([ "@openclaw/ai", "@openclaw/gateway-client", diff --git a/test/scripts/worktree-setup.test.ts b/test/scripts/worktree-setup.test.ts new file mode 100644 index 000000000000..b73e5ced105f --- /dev/null +++ b/test/scripts/worktree-setup.test.ts @@ -0,0 +1,659 @@ +import assert from "node:assert/strict"; +import { execFileSync, spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { afterEach, describe, expect, it } from "vitest"; +import { readBundledPluginAssetHooks } from "../../scripts/bundled-plugin-assets.mts"; +import { collectSourceCheckoutPluginBuildEntries } from "../../scripts/lib/bundled-plugin-build-entries.mjs"; +import { createWorktreeSetupPlan, parseWorktreeSetupArgs } from "../../scripts/worktree-setup.mjs"; +import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js"; + +const tempDirs = useAutoCleanupTempDirTracker(afterEach); +const repoRoot = fileURLToPath(new URL("../../", import.meta.url)); +const scriptPath = path.join(repoRoot, "scripts/worktree-setup.mjs"); + +// Copy real script owners, not replacement inventories or dependency symlinks. +// This bounded list deliberately fails if the plain-Node import closure grows. +const COLD_SCRIPT_INPUTS = [ + "scripts/worktree-setup.mjs", + "scripts/bundled-plugin-assets.mts", + "scripts/pnpm-runner.mts", + "scripts/windows-cmd-helpers.mjs", + "scripts/lib/bundled-plugin-build-entries.mjs", + "scripts/lib/bundled-plugin-paths.mjs", + "scripts/lib/managed-child-process.mts", + "scripts/lib/optional-bundled-clusters.mjs", + "scripts/lib/output-root-guard.mjs", + "scripts/lib/record-shared.mjs", + "scripts/lib/repo-root.mjs", + "scripts/lib/root-package-bundled-plugin-excludes.mjs", + "scripts/lib/static-extension-assets.mts", + "scripts/lib/vitest-resource-ownership.mts", + "scripts/lib/windows-taskkill.mjs", + "src/shared/non-packaged-plugin-dirs.ts", +]; + +type CommandCall = { kind: "pnpm" | "gateway-build"; args: string[]; cwd: string }; + +function makePreparationFixture() { + const directory = tempDirs.make("openclaw-preparation-"); + const rootDir = path.join(directory, "repo"); + const controls = path.join(directory, "controls"); + fs.mkdirSync(rootDir); + fs.mkdirSync(controls); + const write = (relative: string, value: string) => { + const target = path.join(rootDir, relative); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, value); + }; + const json = (relative: string, value: unknown) => write(relative, JSON.stringify(value)); + const env: NodeJS.ProcessEnv = { ...process.env, GIT_NO_LAZY_FETCH: "1" }; + // Cold execution must not inherit a loader or package path from the test runner. + for (const key of [ + "NODE_OPTIONS", + "NODE_PATH", + "npm_execpath", + "OPENCLAW_BUNDLED_PLUGIN_BUILD_IDS", + "OPENCLAW_INTERNAL_DOCKER_BUILD_PLUGIN_IDS", + "OPENCLAW_INCLUDE_OPTIONAL_BUNDLED", + "OPENCLAW_BUILD_PRIVATE_QA", + "GIT_DIR", + "GIT_WORK_TREE", + "GIT_INDEX_FILE", + "GIT_COMMON_DIR", + ]) { + delete env[key]; + } + const git = (...args: string[]) => + execFileSync("git", args, { + cwd: rootDir, + env, + encoding: "utf8", + }); + for (const input of COLD_SCRIPT_INPUTS) { + write(input, fs.readFileSync(path.join(repoRoot, input), "utf8")); + } + const pin: string = JSON.parse( + fs.readFileSync(path.join(repoRoot, "package.json"), "utf8"), + ).packageManager; + json("package.json", { name: "openclaw", type: "module", packageManager: pin }); + write("pnpm-workspace.yaml", "packages:\n - .\n - packages/*\n - extensions/*\n"); + write("pnpm-lock.yaml", "lockfileVersion: '9.0'\n"); + json("packages/support/package.json", { name: "@openclaw/support", version: "0.0.0" }); + json("extensions/isolated/package.json", { + name: "@openclaw/isolated", + dependencies: { "@openclaw/support": "workspace:*" }, + openclaw: { + extensions: ["./index.ts"], + build: { bundledDist: false }, + release: { publishToNpm: true }, + }, + }); + json("extensions/isolated/openclaw.plugin.json", { id: "isolated" }); + write("extensions/isolated/index.ts", "export {};\n"); + // No manifest or entrypoint: these owners exist only in the asset inventory. + for (const phase of ["build", "copy"]) { + json("extensions/asset-" + phase + "/package.json", { + name: "@example/asset-" + phase, + openclaw: { assetScripts: { [phase]: "node asset.mjs" } }, + }); + } + write(".openclaw/worktree-profiles/gateway", "scripts\npackages\nextensions\nsrc\n"); + write("src/gateway/input.ts", "export {};\n"); + write("apps/android/build.gradle.kts", "// Omitted by the synthetic gateway cone.\n"); + + // These two files are command recorders, NOT a gateway build implementation. + write("scripts/tsx.mjs", "export {};\n"); + write( + "scripts/build-all.mts", + [ + 'import fs from "node:fs";', + "fs.appendFileSync(process.env.SETUP_TEST_CALLS, JSON.stringify({", + ' kind: "gateway-build", args: process.argv.slice(2), cwd: process.cwd(),', + '}) + "\\n");', + 'process.exitCode = Number(process.env.SETUP_TEST_BUILD_EXIT ?? "0");', + ].join("\n"), + ); + git("init", "--quiet"); + git("add", "."); + git( + "-c", + "user.name=Setup Test", + "-c", + "user.email=setup@example.invalid", + "-c", + "commit.gpgsign=false", + "-c", + "core.hooksPath=" + controls, + "commit", + "--quiet", + "-m", + "fixture", + ); + + const callsPath = path.join(controls, "calls.jsonl"); + const pnpmPath = path.join(controls, "pnpm.cjs"); + const recorder = [ + 'const fs = require("node:fs");', + "const args = process.argv.slice(2);", + "fs.appendFileSync(process.env.SETUP_TEST_CALLS, JSON.stringify({", + ' kind: "pnpm", args, cwd: process.cwd(),', + '}) + "\\n");', + 'if (args.length === 1 && args[0] === "--version") {', + " console.log(process.env.SETUP_TEST_VERSION);", + '} else if (args.join(" ") === "store path") {', + " if (process.env.SETUP_TEST_STORE_HOOK) { require(process.env.SETUP_TEST_STORE_HOOK); }", + " console.log(process.env.SETUP_TEST_STORE);", + '} else if (args.includes("install")) {', + ' process.exitCode = Number(process.env.SETUP_TEST_INSTALL_EXIT ?? "0");', + '} else if (args.join(" ") === "build") {', + ' process.exitCode = Number(process.env.SETUP_TEST_BUILD_EXIT ?? "0");', + "} else {", + " process.exitCode = 97;", + "}", + ].join("\n"); + fs.writeFileSync(pnpmPath, recorder); + // Trap fallback resolution as well; no test may invoke the host's real pnpm. + fs.writeFileSync(path.join(controls, "pnpm"), "#!/usr/bin/env node\n" + recorder, { + mode: 0o755, + }); + fs.writeFileSync( + path.join(controls, "pnpm.cmd"), + '@"' + process.execPath + '" "' + pnpmPath + '" %*\r\n', + ); + Object.assign(env, { + npm_execpath: pnpmPath, + PATH: + controls + + path.delimiter + + path.dirname(process.execPath) + + path.delimiter + + (env.PATH ?? ""), + SETUP_TEST_CALLS: callsPath, + SETUP_TEST_VERSION: pin.slice("pnpm@".length).split("+")[0], + SETUP_TEST_STORE: path.join(controls, "store", "not-created"), + }); + + const run = (args: string[], extraEnv: NodeJS.ProcessEnv = {}, nodeArgs: string[] = []) => { + const result = spawnSync( + process.execPath, + [...nodeArgs, path.join(rootDir, "scripts/worktree-setup.mjs"), ...args], + { cwd: rootDir, env: { ...env, ...extraEnv }, encoding: "utf8" }, + ); + expect(result.error).toBeUndefined(); + return result; + }; + const calls = (): CommandCall[] => + fs.existsSync(callsPath) + ? fs + .readFileSync(callsPath, "utf8") + .trim() + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line)) + : []; + const assertNoOutputs = () => { + for (const relative of [ + "node_modules", + "dist", + "packages/support/node_modules", + "extensions/isolated/node_modules", + "apps/android/build", + ]) { + expect(fs.existsSync(path.join(rootDir, relative)), relative).toBe(false); + } + }; + return { rootDir, controls, env, run, git, write, calls, assertNoOutputs }; +} + +describe("source-only worktree preparation", () => { + it("runs source plans without Git, pnpm, repository imports, or generated output", () => { + const rootDir = tempDirs.make("openclaw-source-preparation-"); + const script = path.join(rootDir, "worktree-setup.mjs"); + fs.copyFileSync(scriptPath, script); + for (const args of [[], ["source"], ["--plan"]]) { + const output = execFileSync(process.execPath, [script, ...args], { + cwd: rootDir, + env: { ...process.env, PATH: "", NODE_OPTIONS: "", NODE_PATH: "" }, + encoding: "utf8", + }); + if (args.includes("--plan")) { + expect(JSON.parse(output)).toMatchObject({ + workload: "source", + install: null, + build: null, + }); + } else { + expect(output).toContain("Source-only"); + } + expect(fs.readdirSync(rootDir)).toEqual(["worktree-setup.mjs"]); + } + }); + + it("rejects invalid selectors before reading repository inputs", async () => { + expect(() => parseWorktreeSetupArgs(["gateway", "full"])).toThrow("Unexpected"); + expect(() => parseWorktreeSetupArgs(["--profile", "gateway"])).toThrow("Unexpected"); + expect(() => parseWorktreeSetupArgs(["--source-profile", "gateway"])).toThrow("Unexpected"); + await expect( + createWorktreeSetupPlan({ rootDir: "/missing-worktree", workload: "unknown" }), + ).rejects.toThrow("Unknown preparation workload"); + }); + + it.skipIf(process.platform === "win32")( + "keeps the actual Claude startup hook source-only", + () => { + const fixture = makePreparationFixture(); + const settings = JSON.parse( + fs.readFileSync(path.join(repoRoot, ".claude/settings.json"), "utf8"), + ) as { + hooks: { SessionStart: { matcher: string; hooks: { type: string; command: string }[] }[] }; + }; + const hooks = settings.hooks.SessionStart.filter((entry) => entry.matcher === "startup") + .flatMap((entry) => entry.hooks) + .filter((hook) => hook.type === "command"); + expect(hooks.length).toBeGreaterThan(0); + // Source startup must work even when the cone cannot prepare the gateway. + // Keep only scripts: the missing tracked plugin would fail input validation + // if startup accidentally inferred gateway preparation from sparse state. + for (const state of ["full", "sparse"]) { + if (state === "sparse") { + fixture.git("sparse-checkout", "set", "--cone", "scripts"); + expect(fs.existsSync(path.join(fixture.rootDir, "extensions/isolated/index.ts"))).toBe( + false, + ); + } + const before = fixture.git("status", "--porcelain", "--untracked-files=all"); + for (const hook of hooks) { + const output = execFileSync("/bin/sh", ["-c", hook.command], { + cwd: path.join(fixture.rootDir, "scripts"), + env: fixture.env, + encoding: "utf8", + }); + expect(output).toContain("Source-only"); + } + expect(fixture.git("status", "--porcelain", "--untracked-files=all")).toBe(before); + if (state === "sparse") { + expect(fixture.git("sparse-checkout", "list").trim()).toBe("scripts"); + } + expect(fixture.calls()).toEqual([]); + fixture.assertNoOutputs(); + } + }, + ); +}); + +describe("explicit preparation input closure", () => { + it("plans the actual checkout from canonical source and both asset inventories", async () => { + const pkg = JSON.parse(fs.readFileSync(path.join(repoRoot, "package.json"), "utf8")); + const entries = collectSourceCheckoutPluginBuildEntries({ cwd: repoRoot, env: {} }); + const buildHooks = await readBundledPluginAssetHooks({ rootDir: repoRoot, phase: "build" }); + const copyHooks = await readBundledPluginAssetHooks({ rootDir: repoRoot, phase: "copy" }); + expect(entries.length).toBeGreaterThan(0); + expect(buildHooks.length).toBeGreaterThan(0); + expect(copyHooks.length).toBeGreaterThan(0); + const expected = new Set([pkg.name + "...", "./packages/*..."]); + for (const entry of entries) { + if (entry.hasPackageJson) { + expected.add("./extensions/" + entry.id + "..."); + } + } + for (const hook of [...buildHooks, ...copyHooks]) { + expected.add( + "./" + path.relative(repoRoot, hook.pluginDir).split(path.sep).join("/") + "...", + ); + } + const plan = await createWorktreeSetupPlan({ rootDir: repoRoot, workload: "gateway", env: {} }); + expect(plan.install).toEqual({ + command: "pnpm", + args: [...expected] + .toSorted((left, right) => (left < right ? -1 : left > right ? 1 : 0)) + .flatMap((filter) => ["--filter", filter]) + .concat(["install", "--frozen-lockfile"]), + }); + expect(plan.build).toEqual({ + command: "node", + args: ["--import", "./scripts/tsx.mjs", "scripts/build-all.mts", "qaRuntime"], + }); + }); + + it("runs cold plain-Node gateway and full plans without resolving pnpm or producing outputs", () => { + const fixture = makePreparationFixture(); + const before = fixture.git("status", "--porcelain", "--untracked-files=all"); + const gateway = fixture.run(["gateway", "--plan"], { SETUP_TEST_VERSION: "wrong" }); + expect(gateway.status, gateway.stderr).toBe(0); + expect(JSON.parse(gateway.stdout).install).toEqual({ + command: "pnpm", + args: [ + "--filter", + "./extensions/asset-build...", + "--filter", + "./extensions/asset-copy...", + "--filter", + "./extensions/isolated...", + "--filter", + "./packages/*...", + "--filter", + "openclaw...", + "install", + "--frozen-lockfile", + ], + }); + const full = fixture.run(["full", "--plan"], { SETUP_TEST_VERSION: "wrong" }); + expect(full.status, full.stderr).toBe(0); + expect(JSON.parse(full.stdout)).toMatchObject({ + install: { command: "pnpm", args: ["install", "--frozen-lockfile"] }, + build: { command: "pnpm", args: ["build"] }, + }); + expect(fixture.calls()).toEqual([]); + expect(fixture.git("status", "--porcelain", "--untracked-files=all")).toBe(before); + fixture.assertNoOutputs(); + }); + + it("accepts complete gateway cones, rejects sparse full, and preserves explicit native expansion", () => { + const fixture = makePreparationFixture(); + fixture.git( + "sparse-checkout", + "set", + "--cone", + "scripts", + "packages", + "extensions", + "src", + ".openclaw/worktree-profiles", + ); + expect(fs.existsSync(path.join(fixture.rootDir, "apps/android/build.gradle.kts"))).toBe(false); + const gateway = fixture.run(["gateway", "--plan"]); + expect(gateway.status, gateway.stderr).toBe(0); + expect(JSON.parse(gateway.stdout).requiredInputs.sparse).toBe(true); + const patternsBefore = fixture.git("sparse-checkout", "list"); + for (const args of [["full"], ["full", "--plan"]]) { + const result = fixture.run(args); + expect(result.status).toBe(1); + expect(result.stderr).toContain("git sparse-checkout disable"); + } + expect(fixture.git("sparse-checkout", "list")).toBe(patternsBefore); + expect(fixture.run(["source"]).status).toBe(0); + expect(fixture.calls()).toEqual([]); + + fixture.git("sparse-checkout", "disable"); + expect(fs.existsSync(path.join(fixture.rootDir, "apps/android/build.gradle.kts"))).toBe(true); + const full = fixture.run(["full", "--plan"]); + expect(full.status, full.stderr).toBe(0); + expect(JSON.parse(full.stdout)).toMatchObject({ + install: { command: "pnpm", args: ["install", "--frozen-lockfile"] }, + build: { command: "pnpm", args: ["build"] }, + requiredInputs: { sparse: false }, + }); + expect(fixture.calls()).toEqual([]); + fixture.assertNoOutputs(); + }); + + it("rejects a gateway selection changed during toolchain checks before install", () => { + const fixture = makePreparationFixture(); + const hook = path.join(fixture.controls, "change-owner.cjs"); + fs.writeFileSync( + hook, + [ + 'const fs = require("node:fs");', + 'const file = "extensions/asset-copy/package.json";', + 'const pkg = JSON.parse(fs.readFileSync(file, "utf8"));', + "delete pkg.openclaw.assetScripts.copy;", + "fs.writeFileSync(file, JSON.stringify(pkg));", + ].join("\n"), + ); + const result = fixture.run(["gateway"], { SETUP_TEST_STORE_HOOK: hook }); + expect(result.status).toBe(1); + expect(result.stderr).toContain("Preparation inputs changed"); + expect(fixture.calls().map((call) => call.args)).toEqual([["--version"], ["store", "path"]]); + const pkg = JSON.parse( + fs.readFileSync(path.join(fixture.rootDir, "extensions/asset-copy/package.json"), "utf8"), + ); + expect(pkg.openclaw.assetScripts.copy).toBeUndefined(); + fixture.assertNoOutputs(); + }); + + it("rejects excluded tracked asset owners before pnpm, even when collectors would omit them", () => { + const fixture = makePreparationFixture(); + fixture.git( + "sparse-checkout", + "set", + "--cone", + "scripts", + "packages", + "extensions/isolated", + "src", + ".openclaw/worktree-profiles", + ); + expect(fs.existsSync(path.join(fixture.rootDir, "extensions/asset-build/package.json"))).toBe( + false, + ); + for (const args of [["gateway"], ["gateway", "--plan"]]) { + const result = fixture.run(args); + expect(result.status).toBe(1); + expect(result.stderr).toContain("Gateway inputs are excluded"); + expect(result.stderr).toContain("extensions/asset-build/package.json"); + expect(result.stderr).toContain("git sparse-checkout disable"); + } + expect(fixture.calls()).toEqual([]); + fixture.assertNoOutputs(); + }); + + it("resolves linked plugin inputs before inventory discovery", () => { + const fixture = makePreparationFixture(); + const manifest = path.join(fixture.rootDir, "extensions/isolated/package.json"); + const target = path.join(fixture.controls, "linked-package.json"); + fs.renameSync(manifest, target); + fs.symlinkSync(target, manifest, "file"); + + const valid = fixture.run(["gateway", "--plan"]); + expect(valid.status).toBe(0); + expect(JSON.parse(valid.stdout).install.args).toContain("./extensions/isolated..."); + expect(fixture.calls()).toEqual([]); + + fs.unlinkSync(target); + for (const args of [["gateway", "--plan"], ["gateway"]]) { + const result = fixture.run(args); + expect(result.status).toBe(1); + expect(result.stderr).toContain("Tracked preparation inputs are missing"); + expect(result.stderr).toContain("extensions/isolated/package.json"); + expect(fs.readlinkSync(manifest)).toBe(target); + expect(fs.existsSync(target)).toBe(false); + } + expect(fixture.calls()).toEqual([]); + fixture.assertNoOutputs(); + }); + + it.each([ + { workload: "gateway", missing: "extensions/isolated/index.ts" }, + { workload: "gateway", missing: "extensions/isolated" }, + { workload: "full", missing: "extensions/isolated/index.ts" }, + { workload: "full", missing: "extensions/isolated" }, + ])( + "rejects missing tracked $missing before inventory discovery or pnpm for $workload", + ({ workload, missing }) => { + const fixture = makePreparationFixture(); + // This deletes only a future test-owned fixture input, never retained source. + fs.rmSync(path.join(fixture.rootDir, missing), { recursive: true }); + for (const args of [[workload], [workload, "--plan"]]) { + const result = fixture.run(args); + expect(result.status).toBe(1); + expect(result.stderr).toContain("Tracked preparation inputs are missing"); + expect(result.stderr).toContain("extensions/isolated/index.ts"); + if (missing === "extensions/isolated") { + expect(result.stderr).toContain("extensions/isolated/package.json"); + expect(result.stderr).toContain("extensions/isolated/openclaw.plugin.json"); + } + } + expect(fixture.calls()).toEqual([]); + fixture.assertNoOutputs(); + }, + ); +}); + +// These are subprocess/ordering regressions. Recorders never install packages, +// prove pnpm closure, build the Gateway, or establish graph/volume isolation. +describe.each(["gateway", "full"])("%s preparation execution boundaries", (workload) => { + it("rejects the wrong target pnpm before querying its store or installing", () => { + const fixture = makePreparationFixture(); + const result = fixture.run([workload], { SETUP_TEST_VERSION: "0.0.0" }); + expect(result.status).toBe(1); + expect(result.stderr).toContain("Expected repository pnpm"); + expect(fixture.calls().map((call) => call.args)).toEqual([["--version"]]); + fixture.assertNoOutputs(); + }); + + it("rejects a nonabsolute store before install", () => { + const fixture = makePreparationFixture(); + const result = fixture.run([workload], { SETUP_TEST_STORE: "relative/store" }); + expect(result.status).toBe(1); + expect(result.stderr).toContain("same volume"); + expect(fixture.calls().map((call) => call.args)).toEqual([["--version"], ["store", "path"]]); + fixture.assertNoOutputs(); + }); + + it("rejects a simulated foreign-volume store before install", () => { + const fixture = makePreparationFixture(); + const store = path.join(fixture.controls, "foreign-store"); + fs.mkdirSync(store); + const preload = path.join(fixture.controls, "foreign-device.mjs"); + // Simulate only st_dev for this sentinel path; no mount or shared fs mutation. + fs.writeFileSync( + preload, + [ + 'import fs from "node:fs";', + "const original = fs.statSync;", + "fs.statSync = function (target, ...args) {", + " const stat = original.call(this, target, ...args);", + " if (target === " + JSON.stringify(store) + ") { stat.dev += 1; }", + " return stat;", + "};", + ].join("\n"), + ); + const result = fixture.run([workload], { SETUP_TEST_STORE: store }, [ + "--import", + pathToFileURL(preload).href, + ]); + expect(result.status).toBe(1); + expect(result.stderr).toContain("same volume"); + expect(fixture.calls().map((call) => call.args)).toEqual([["--version"], ["store", "path"]]); + fixture.assertNoOutputs(); + }); + + it.each(["node_modules", "node_modules/.pnpm", "packages/support/node_modules", "dist"])( + "rejects linked %s before invoking pnpm and preserves its target", + (relative) => { + const fixture = makePreparationFixture(); + const target = path.join(fixture.controls, "retained"); + fs.mkdirSync(target); + fs.writeFileSync(path.join(target, "sentinel"), "unchanged"); + const link = path.join(fixture.rootDir, relative); + fs.mkdirSync(path.dirname(link), { recursive: true }); + fs.symlinkSync(target, link, process.platform === "win32" ? "junction" : "dir"); + const result = fixture.run([workload]); + expect(result.status).toBe(1); + expect(result.stderr).toContain("symbolic link"); + expect(fixture.calls()).toEqual([]); + expect(fs.readFileSync(path.join(target, "sentinel"), "utf8")).toBe("unchanged"); + expect(fs.readdirSync(target)).toEqual(["sentinel"]); + expect(fs.lstatSync(link).isSymbolicLink()).toBe(true); + }, + ); + + it("revalidates missing inputs after store resolution and before install", () => { + const fixture = makePreparationFixture(); + const hook = path.join(fixture.controls, "remove-input.cjs"); + fs.writeFileSync(hook, 'require("node:fs").unlinkSync("extensions/isolated/index.ts");\n'); + const result = fixture.run([workload], { SETUP_TEST_STORE_HOOK: hook }); + expect(result.status).toBe(1); + expect(result.stderr).toContain("Tracked preparation inputs are missing"); + expect(result.stderr).toContain("extensions/isolated/index.ts"); + expect(fs.existsSync(path.join(fixture.rootDir, "extensions/isolated/index.ts"))).toBe(false); + expect(fixture.calls().map((call) => call.args)).toEqual([["--version"], ["store", "path"]]); + fixture.assertNoOutputs(); + }); + + it("revalidates output roots after store resolution and before install", () => { + const fixture = makePreparationFixture(); + const target = path.join(fixture.controls, "retained"); + fs.mkdirSync(target); + fs.writeFileSync(path.join(target, "sentinel"), "unchanged"); + const hook = path.join(fixture.controls, "link-output.cjs"); + fs.writeFileSync( + hook, + [ + 'const fs = require("node:fs");', + "fs.symlinkSync(" + + JSON.stringify(target) + + ', "dist", ' + + JSON.stringify(process.platform === "win32" ? "junction" : "dir") + + ");", + ].join("\n"), + ); + const result = fixture.run([workload], { SETUP_TEST_STORE_HOOK: hook }); + expect(result.status).toBe(1); + expect(result.stderr).toContain("symbolic link"); + expect(fixture.calls().map((call) => call.args)).toEqual([["--version"], ["store", "path"]]); + expect(fs.lstatSync(path.join(fixture.rootDir, "dist")).isSymbolicLink()).toBe(true); + expect(fs.readdirSync(target)).toEqual(["sentinel"]); + expect(fs.readFileSync(path.join(target, "sentinel"), "utf8")).toBe("unchanged"); + expect(fs.existsSync(path.join(fixture.rootDir, "node_modules"))).toBe(false); + }); + + it("propagates install failure without starting a build", () => { + const fixture = makePreparationFixture(); + const result = fixture.run([workload], { SETUP_TEST_INSTALL_EXIT: "23" }); + expect(result.status, result.stderr).toBe(23); + const calls = fixture.calls(); + expect(calls).toHaveLength(3); + expect(calls.map((call) => call.kind)).toEqual(["pnpm", "pnpm", "pnpm"]); + expect(calls.at(-1)?.args).toContain("install"); + expect(calls.at(-1)?.args).toContain("--frozen-lockfile"); + fixture.assertNoOutputs(); + }); + + it("propagates selected-build failure after a successful recorder install", () => { + const fixture = makePreparationFixture(); + const result = fixture.run([workload], { SETUP_TEST_BUILD_EXIT: "29" }); + expect(result.status, result.stderr).toBe(29); + const calls = fixture.calls(); + expect(calls).toHaveLength(4); + assert.ok(calls[2]); + expect(calls[2].args).toContain("install"); + expect(calls[3]).toMatchObject( + workload === "gateway" + ? { kind: "gateway-build", args: ["qaRuntime"] } + : { kind: "pnpm", args: ["build"] }, + ); + fixture.assertNoOutputs(); + }); + + it("reaches only the selected build after successful recorder install (positive control)", () => { + const fixture = makePreparationFixture(); + const result = fixture.run([workload]); + expect(result.status, result.stderr).toBe(0); + const calls = fixture.calls(); + expect(calls).toHaveLength(4); + assert.ok(calls[0]); + assert.ok(calls[1]); + assert.ok(calls[2]); + expect(calls[0].args).toEqual(["--version"]); + expect(calls[1].args).toEqual(["store", "path"]); + expect(calls[2].kind).toBe("pnpm"); + expect(calls[2].args).toContain("install"); + expect(calls[2].args).toContain("--frozen-lockfile"); + expect(calls[2].args).not.toContain("--ignore-scripts"); + expect(calls[3]).toMatchObject( + workload === "gateway" + ? { kind: "gateway-build", args: ["qaRuntime"] } + : { kind: "pnpm", args: ["build"] }, + ); + if (workload === "full") { + expect(calls[2].args).toEqual(["install", "--frozen-lockfile"]); + } + for (const call of calls) { + expect(fs.realpathSync(call.cwd)).toBe(fs.realpathSync(fixture.rootDir)); + } + fixture.assertNoOutputs(); + }); +});