diff --git a/.npmrc b/.npmrc index bdf24a6c276..49ef47513d9 100644 --- a/.npmrc +++ b/.npmrc @@ -1,4 +1,2 @@ -# pnpm build-script allowlist lives in package.json -> pnpm.onlyBuiltDependencies. -# TS 7 native-preview fails to resolve packages reliably from pnpm's isolated linker. -# Keep the workspace on a hoisted layout so pnpm check/build stay stable. -node-linker=hoisted +# pnpm v11 reads project settings from pnpm-workspace.yaml. +# Keep this file for registry/auth-only npmrc entries so Docker COPY steps stay stable. diff --git a/docs/ci.md b/docs/ci.md index 303334b9369..bb189dc7295 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -277,7 +277,7 @@ Package Acceptance has bounded legacy-compatibility windows for already-publishe - known private QA entries in `dist/postinstall-inventory.json` may point at tarball-omitted files; - `doctor-switch` may skip the `gateway install --wrapper` persistence subcase when the package does not expose that flag; -- `update-channel-switch` may prune missing `pnpm.patchedDependencies` from the tarball-derived fake git fixture and may log missing persisted `update.channel`; +- `update-channel-switch` may prune missing pnpm `patchedDependencies` from the tarball-derived fake git fixture and may log missing persisted `update.channel`; - plugin smokes may read legacy install-record locations or accept missing marketplace install-record persistence; - `plugin-update` may allow config metadata migration while still requiring the install record and no-reinstall behavior to stay unchanged. diff --git a/docs/cli/update.md b/docs/cli/update.md index 47780f80cb2..49022115b98 100644 --- a/docs/cli/update.md +++ b/docs/cli/update.md @@ -158,7 +158,7 @@ manually. Rebases onto the selected commit (dev only). - Uses the repo package manager. For pnpm checkouts, the updater bootstraps `pnpm` on demand (via `corepack` first, then a temporary `npm install pnpm@10` fallback) instead of running `npm run build` inside a pnpm workspace. + Uses the repo package manager. For pnpm checkouts, the updater bootstraps `pnpm` on demand (via `corepack` first, then a temporary `npm install pnpm@11` fallback) instead of running `npm run build` inside a pnpm workspace. Builds the gateway and the Control UI. diff --git a/extensions/acpx/AGENTS.md b/extensions/acpx/AGENTS.md index 84c291e258f..ca759c84738 100644 --- a/extensions/acpx/AGENTS.md +++ b/extensions/acpx/AGENTS.md @@ -18,14 +18,14 @@ Use this flow when OpenClaw needs unreleased ACPX changes before the ACPX versio 1. Make the ACPX code change in the `openclaw/acpx` repo first. 2. In OpenClaw, temporarily point `extensions/acpx/package.json` at the ACPX GitHub commit you need. -3. If pnpm blocks ACPX lifecycle/build scripts for that temporary GitHub-sourced package, temporarily add `acpx` to `onlyBuiltDependencies` in both `package.json` and `pnpm-workspace.yaml`. +3. If pnpm blocks ACPX lifecycle/build scripts for that temporary GitHub-sourced package, temporarily add `acpx: true` to `allowBuilds` in `pnpm-workspace.yaml`. 4. Refresh the root workspace lock: - `pnpm install --lockfile-only --filter ./extensions/acpx` 5. Refresh the extension-local npm lock for install metadata: - `cd extensions/acpx && npm install --package-lock-only --ignore-scripts` 6. Rebuild OpenClaw and restart the gateway before doing live ACP validation. 7. Once ACPX is released, switch `extensions/acpx/package.json` back to the published npm version and refresh the same lockfiles again. -8. Remove any temporary `acpx` build-script allowlist entries that were only needed for the GitHub-sourced development pin. +8. Remove any temporary `acpx` build-script allowlist entry that was only needed for the GitHub-sourced development pin. ## Lockfile Notes diff --git a/extensions/qa-lab/src/mantis/slack-desktop-smoke.runtime.ts b/extensions/qa-lab/src/mantis/slack-desktop-smoke.runtime.ts index 83c268b3f73..5efcd8c88eb 100644 --- a/extensions/qa-lab/src/mantis/slack-desktop-smoke.runtime.ts +++ b/extensions/qa-lab/src/mantis/slack-desktop-smoke.runtime.ts @@ -489,7 +489,7 @@ qa_status=0 { set -e echo "remote pwd: $(pwd)" - sudo corepack enable || sudo npm install -g pnpm@10.33.2 + sudo corepack enable || sudo npm install -g pnpm@11 if [ "$hydrate_mode" = "source" ]; then if ! command -v make >/dev/null 2>&1 || ! command -v python3 >/dev/null 2>&1; then sudo apt-get update -y >>"$out/apt.log" 2>&1 || true diff --git a/package.json b/package.json index bcc6fbed1b4..46597e62b57 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "CHANGELOG.md", "LICENSE", "openclaw.mjs", + "pnpm-workspace.yaml", "README.md", "dist/", "!dist/.buildstamp", @@ -1814,70 +1815,5 @@ "engines": { "node": ">=22.16.0" }, - "packageManager": "pnpm@10.33.2+sha512.a90faf6feeab71ad6c6e57f94e0fe1a12f5dcc22cd754db40ae9593eb6a3e0b6b12e3540218bb37ae083404b1f2ce6db2a4121e979829b4aff94b99f49da1cf8", - "pnpm": { - "overrides": { - "@anthropic-ai/sdk": "0.95.1", - "hono": "4.12.18", - "@hono/node-server": "1.19.14", - "@aws-sdk/client-bedrock-runtime": "3.1045.0", - "axios": "1.16.0", - "fast-uri": "3.1.2", - "follow-redirects": "1.16.0", - "defu": "6.1.5", - "fast-xml-parser": "5.7.0", - "request": "npm:@cypress/request@3.0.10", - "request-promise": "npm:@cypress/request-promise@5.0.0", - "basic-ftp": "6.0.1", - "file-type": "22.0.1", - "form-data": "2.5.4", - "ip-address": "10.2.0", - "minimatch": "10.2.5", - "path-to-regexp": "8.4.0", - "qs": "6.14.2", - "node-domexception": "npm:@nolyfill/domexception@1.0.28", - "typebox": "1.1.38", - "tar": "7.5.15", - "tough-cookie": "4.1.3", - "yauzl": "3.2.1", - "protobufjs": "7.5.5", - "uuid": "14.0.0" - }, - "onlyBuiltDependencies": [ - "@openclaw/fs-safe", - "@google/genai", - "@lydell/node-pty", - "@matrix-org/matrix-sdk-crypto-nodejs", - "@tloncorp/api", - "@tloncorp/tlon-skill", - "baileys", - "@whiskeysockets/libsignal-node", - "authenticate-pam", - "esbuild", - "node-llama-cpp", - "protobufjs", - "sharp" - ], - "ignoredBuiltDependencies": [ - "@discordjs/opus", - "koffi", - "tree-sitter-bash" - ], - "packageExtensions": { - "@mariozechner/pi-coding-agent": { - "dependencies": { - "strip-ansi": "^7.2.0" - } - } - }, - "peerDependencyRules": { - "allowedVersions": { - "prism-media>opusscript": "^0.0.8 || ^0.1.1" - } - }, - "patchedDependencies": { - "baileys@7.0.0-rc10": "patches/baileys@7.0.0-rc10.patch", - "@agentclientprotocol/claude-agent-acp@0.33.1": "patches/@agentclientprotocol__claude-agent-acp@0.33.1.patch" - } - } + "packageManager": "pnpm@11.0.8+sha512.4c4097e1dd2d42372c4e7fa5a791ff28fc75a484c7ac192e64b1df0fdef17594ba982f9b4fed9adfb3c757846f565b799b2763fb3733d1de1bcb82cf46684912" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 24c33340c8d..37715d4ee6c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3360,7 +3360,7 @@ packages: os: [win32] '@openclaw/fs-safe@https://codeload.github.com/openclaw/fs-safe/tar.gz/c7ccb99d3058f2acf2ad2758ad2470c7e113a53c': - resolution: {tarball: https://codeload.github.com/openclaw/fs-safe/tar.gz/c7ccb99d3058f2acf2ad2758ad2470c7e113a53c} + resolution: {gitHosted: true, tarball: https://codeload.github.com/openclaw/fs-safe/tar.gz/c7ccb99d3058f2acf2ad2758ad2470c7e113a53c} version: 0.2.0 engines: {node: '>=20.11'} @@ -4848,7 +4848,7 @@ packages: resolution: {integrity: sha512-58rWEqDGg+CKCyEeKm2KoxxSwTWtHh/NLTW9ObR4K8CGF6VwuuGudEI1CtniS/oSRmL1nJq/eh8MKARiluw4DQ==} '@whiskeysockets/libsignal-node@https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67': - resolution: {tarball: https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67} + resolution: {gitHosted: true, tarball: https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67} version: 2.0.1 '@zed-industries/codex-acp-darwin-arm64@0.14.0': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 94303c4d6db..ba001af66cc 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -32,22 +32,63 @@ minimumReleaseAgeExclude: - "sqlite-vec" - "sqlite-vec-*" -onlyBuiltDependencies: - - "@openclaw/fs-safe" - - "@google/genai" - - "@lydell/node-pty" - - "@matrix-org/matrix-sdk-crypto-nodejs" - - "@napi-rs/canvas" - - "@tloncorp/api" - - "baileys" - - "@whiskeysockets/libsignal-node" - - authenticate-pam - - esbuild - - node-llama-cpp - - protobufjs - - sharp +nodeLinker: hoisted -ignoredBuiltDependencies: - - "@discordjs/opus" - - koffi - - tree-sitter-bash +overrides: + "@anthropic-ai/sdk": 0.95.1 + hono: 4.12.18 + "@hono/node-server": 1.19.14 + "@aws-sdk/client-bedrock-runtime": 3.1045.0 + axios: 1.16.0 + fast-uri: 3.1.2 + follow-redirects: 1.16.0 + defu: 6.1.5 + fast-xml-parser: 5.7.0 + request: "npm:@cypress/request@3.0.10" + request-promise: "npm:@cypress/request-promise@5.0.0" + basic-ftp: 6.0.1 + file-type: 22.0.1 + form-data: 2.5.4 + ip-address: 10.2.0 + minimatch: 10.2.5 + path-to-regexp: 8.4.0 + qs: 6.14.2 + node-domexception: "npm:@nolyfill/domexception@1.0.28" + typebox: 1.1.38 + tar: 7.5.15 + tough-cookie: 4.1.3 + yauzl: 3.2.1 + protobufjs: 7.5.5 + uuid: 14.0.0 + +allowBuilds: + "@openclaw/fs-safe": true + "@google/genai": true + "@lydell/node-pty": true + "@matrix-org/matrix-sdk-crypto-nodejs": true + "@napi-rs/canvas": true + "@tloncorp/api": true + "@tloncorp/tlon-skill": true + baileys: true + "@whiskeysockets/libsignal-node": true + authenticate-pam: true + "@discordjs/opus": false + esbuild: true + koffi: false + node-llama-cpp: true + protobufjs: true + sharp: true + tree-sitter-bash: false + +packageExtensions: + "@mariozechner/pi-coding-agent": + dependencies: + strip-ansi: ^7.2.0 + +peerDependencyRules: + allowedVersions: + "prism-media>opusscript": "^0.0.8 || ^0.1.1" + +patchedDependencies: + "baileys@7.0.0-rc10": "patches/baileys@7.0.0-rc10.patch" + "@agentclientprotocol/claude-agent-acp@0.33.1": "patches/@agentclientprotocol__claude-agent-acp@0.33.1.patch" diff --git a/scripts/e2e/lib/update-channel-switch/assertions.mjs b/scripts/e2e/lib/update-channel-switch/assertions.mjs index 5e9c938fcce..611628e8d79 100644 --- a/scripts/e2e/lib/update-channel-switch/assertions.mjs +++ b/scripts/e2e/lib/update-channel-switch/assertions.mjs @@ -16,6 +16,80 @@ function readJson(file) { return JSON.parse(fs.readFileSync(file, "utf8")); } +// Runs inside the bare Docker E2E image, before package dependencies are installed. +// Keep this to the small pnpm-workspace.yaml surface the fixture mutates. +function findTopLevelBlock(lines, key) { + const start = lines.findIndex((line) => new RegExp(`^${key}:\\s*(?:#.*)?$`).test(line)); + if (start === -1) { + return null; + } + let end = start + 1; + while (end < lines.length && !/^[A-Za-z0-9_-]+:\s*/.test(lines[end])) { + end += 1; + } + return { start, end }; +} + +function parseYamlScalar(raw) { + const trimmed = raw.trim(); + const withoutComment = trimmed.replace(/\s+#.*$/, ""); + if (withoutComment.startsWith('"') && withoutComment.endsWith('"')) { + return withoutComment.slice(1, -1); + } + if (withoutComment.startsWith("'") && withoutComment.endsWith("'")) { + return withoutComment.slice(1, -1); + } + return withoutComment; +} + +function readWorkspacePatchedDependencies(file) { + const lines = fs.readFileSync(file, "utf8").split("\n"); + const block = findTopLevelBlock(lines, "patchedDependencies"); + if (!block) { + return { patches: undefined }; + } + + const patches = {}; + for (const line of lines.slice(block.start + 1, block.end)) { + const match = line.match(/^\s+(.+?):\s+(.+?)\s*$/); + if (!match) { + continue; + } + patches[parseYamlScalar(match[1])] = parseYamlScalar(match[2]); + } + return { patches }; +} + +function writeWorkspacePnpmConfig(file, keptPatches) { + const original = fs.readFileSync(file, "utf8"); + const hadTrailingNewline = original.endsWith("\n"); + const lines = original.replace(/\n$/, "").split("\n"); + const patchBlock = findTopLevelBlock(lines, "patchedDependencies"); + + if (patchBlock) { + const nextLines = []; + nextLines.push(...lines.slice(0, patchBlock.start)); + if (Object.keys(keptPatches).length > 0) { + nextLines.push("patchedDependencies:"); + for (const [dependency, patchFile] of Object.entries(keptPatches)) { + nextLines.push(` ${JSON.stringify(dependency)}: ${JSON.stringify(patchFile)}`); + } + } + nextLines.push(...lines.slice(patchBlock.end)); + lines.length = 0; + lines.push(...nextLines); + } + + const allowUnusedIndex = lines.findIndex((line) => /^allowUnusedPatches:\s*/.test(line)); + if (allowUnusedIndex === -1) { + lines.push("allowUnusedPatches: true"); + } else { + lines[allowUnusedIndex] = "allowUnusedPatches: true"; + } + + fs.writeFileSync(file, `${lines.join("\n")}${hadTrailingNewline ? "\n" : ""}`); +} + function writeControlUi(root) { const file = path.join(root, "dist", "control-ui", "index.html"); fs.mkdirSync(path.dirname(file), { recursive: true }); @@ -25,31 +99,41 @@ function writeControlUi(root) { function prepareGitFixture(root) { const packageJsonPath = path.join(root, "package.json"); const packageJson = readJson(packageJsonPath); - packageJson.pnpm = { ...packageJson.pnpm, allowUnusedPatches: true }; - const patches = packageJson.pnpm.patchedDependencies; + const pnpmWorkspacePath = path.join(root, "pnpm-workspace.yaml"); + const workspaceConfig = fs.existsSync(pnpmWorkspacePath) + ? readWorkspacePatchedDependencies(pnpmWorkspacePath) + : undefined; + const pnpmConfig = workspaceConfig ? {} : { ...packageJson.pnpm }; + const patches = workspaceConfig?.patches ?? pnpmConfig.patchedDependencies; + const keptPatches = {}; if (patches && typeof patches === "object" && !Array.isArray(patches)) { - const kept = {}; const missing = []; for (const [dependency, patchFile] of Object.entries(patches)) { const exists = typeof patchFile === "string" && fs.existsSync(path.resolve(path.dirname(packageJsonPath), patchFile)); if (exists) { - kept[dependency] = patchFile; + keptPatches[dependency] = patchFile; } else { missing.push(`${dependency} -> ${String(patchFile)}`); } } if (missing.length > 0 && !legacyPackageAcceptanceCompat(packageJson.version)) { throw new Error( - `package ${packageJson.version} has missing pnpm.patchedDependencies in package fixture: ${missing.join(", ")}`, + `package ${packageJson.version} has missing pnpm patchedDependencies in package fixture: ${missing.join(", ")}`, ); } - if (Object.keys(kept).length > 0) { - packageJson.pnpm.patchedDependencies = kept; + } + if (workspaceConfig) { + writeWorkspacePnpmConfig(pnpmWorkspacePath, keptPatches); + } else { + pnpmConfig.allowUnusedPatches = true; + if (Object.keys(keptPatches).length > 0) { + pnpmConfig.patchedDependencies = keptPatches; } else { - delete packageJson.pnpm.patchedDependencies; + delete pnpmConfig.patchedDependencies; } + packageJson.pnpm = pnpmConfig; } const fixtureUiBuildSource = `const fs=require("node:fs");fs.mkdirSync("dist/control-ui",{recursive:true});fs.writeFileSync("dist/control-ui/index.html",${JSON.stringify(controlUiHtml)})`; packageJson.scripts = { diff --git a/scripts/e2e/parallels/macos-smoke.ts b/scripts/e2e/parallels/macos-smoke.ts index 388cf0ebd55..532899952c4 100755 --- a/scripts/e2e/parallels/macos-smoke.ts +++ b/scripts/e2e/parallels/macos-smoke.ts @@ -851,7 +851,7 @@ fi echo "bootstrap-pnpm: install" rm -rf "$bootstrap_root" mkdir -p "$bootstrap_root" -/opt/homebrew/bin/node /opt/homebrew/bin/npm install --prefix "$bootstrap_root" --no-save pnpm@10 +/opt/homebrew/bin/node /opt/homebrew/bin/npm install --prefix "$bootstrap_root" --no-save pnpm@11 "$bootstrap_bin/pnpm" --version`); } diff --git a/scripts/install.sh b/scripts/install.sh index c8cefabe68f..4932b356c84 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1839,7 +1839,7 @@ ensure_pnpm() { if command -v corepack &> /dev/null; then ui_info "Configuring pnpm via Corepack" corepack enable >/dev/null 2>&1 || true - if ! run_quiet_step "Activating pnpm" corepack prepare pnpm@10 --activate; then + if ! run_quiet_step "Activating pnpm" corepack prepare pnpm@11 --activate; then ui_warn "Corepack pnpm activation failed; falling back" fi refresh_shell_command_cache @@ -1854,7 +1854,7 @@ ensure_pnpm() { ui_info "Installing pnpm via npm" fix_npm_permissions - run_quiet_step "Installing pnpm" npm install -g pnpm@10 + run_quiet_step "Installing pnpm" npm install -g pnpm@11 refresh_shell_command_cache if detect_pnpm_cmd && pnpm_cmd_is_ready; then ui_success "pnpm ready ($(pnpm_cmd_pretty))" @@ -1873,7 +1873,7 @@ ensure_pnpm_binary_for_scripts() { if command -v corepack >/dev/null 2>&1; then ui_info "Ensuring pnpm command is available" corepack enable >/dev/null 2>&1 || true - corepack prepare pnpm@10 --activate >/dev/null 2>&1 || true + corepack prepare pnpm@11 --activate >/dev/null 2>&1 || true refresh_shell_command_cache if command -v pnpm >/dev/null 2>&1; then ui_success "pnpm command enabled via Corepack" @@ -1899,7 +1899,7 @@ EOF fi ui_error "pnpm command not available on PATH" - ui_info "Install pnpm globally (npm install -g pnpm@10) and retry" + ui_info "Install pnpm globally (npm install -g pnpm@11) and retry" return 1 } diff --git a/src/commands/doctor-install.test.ts b/src/commands/doctor-install.test.ts new file mode 100644 index 00000000000..672158a67c5 --- /dev/null +++ b/src/commands/doctor-install.test.ts @@ -0,0 +1,48 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { note } from "../terminal/note.js"; +import { withTempDir } from "../test-helpers/temp-dir.js"; +import { noteSourceInstallIssues } from "./doctor-install.js"; + +vi.mock("../terminal/note.js", () => ({ + note: vi.fn(), +})); + +async function writeFile(root: string, relativePath: string, content = "") { + const file = path.join(root, relativePath); + await fs.mkdir(path.dirname(file), { recursive: true }); + await fs.writeFile(file, content, "utf8"); +} + +describe("noteSourceInstallIssues", () => { + beforeEach(() => { + vi.mocked(note).mockReset(); + }); + + it("does not treat a packaged workspace config as a source checkout", async () => { + await withTempDir({ prefix: "openclaw-doctor-install-" }, async (root) => { + await fs.mkdir(path.join(root, "node_modules"), { recursive: true }); + await writeFile(root, "pnpm-workspace.yaml", "packages:\n - .\n"); + + noteSourceInstallIssues(root); + + expect(note).not.toHaveBeenCalled(); + }); + }); + + it("warns source checkouts when node_modules was not installed by pnpm", async () => { + await withTempDir({ prefix: "openclaw-doctor-install-" }, async (root) => { + await fs.mkdir(path.join(root, "node_modules"), { recursive: true }); + await writeFile(root, "pnpm-workspace.yaml", "packages:\n - .\n"); + await writeFile(root, "src/entry.ts", "export {};\n"); + + noteSourceInstallIssues(root); + + expect(note).toHaveBeenCalledWith( + expect.stringContaining("node_modules was not installed by pnpm"), + "Install", + ); + }); + }); +}); diff --git a/src/commands/doctor-install.ts b/src/commands/doctor-install.ts index 2b2a3db301b..c01b4edcd3b 100644 --- a/src/commands/doctor-install.ts +++ b/src/commands/doctor-install.ts @@ -7,8 +7,9 @@ export function noteSourceInstallIssues(root: string | null) { return; } + const srcEntry = path.join(root, "src", "entry.ts"); const workspaceMarker = path.join(root, "pnpm-workspace.yaml"); - if (!fs.existsSync(workspaceMarker)) { + if (!fs.existsSync(workspaceMarker) || !fs.existsSync(srcEntry)) { return; } @@ -16,7 +17,6 @@ export function noteSourceInstallIssues(root: string | null) { const nodeModules = path.join(root, "node_modules"); const pnpmStore = path.join(nodeModules, ".pnpm"); const tsxBin = path.join(nodeModules, ".bin", "tsx"); - const srcEntry = path.join(root, "src", "entry.ts"); if (fs.existsSync(nodeModules) && !fs.existsSync(pnpmStore)) { warnings.push( diff --git a/src/dockerfile.test.ts b/src/dockerfile.test.ts index 2ef7dfa5a5a..39d98efa6ec 100644 --- a/src/dockerfile.test.ts +++ b/src/dockerfile.test.ts @@ -3,10 +3,11 @@ import { join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { BUNDLED_PLUGIN_ROOT_DIR } from "openclaw/plugin-sdk/test-fixtures"; import { describe, expect, it } from "vitest"; +import YAML from "yaml"; const repoRoot = resolve(fileURLToPath(new URL(".", import.meta.url)), ".."); const dockerfilePath = join(repoRoot, "Dockerfile"); -const packageJsonPath = join(repoRoot, "package.json"); +const pnpmWorkspacePath = join(repoRoot, "pnpm-workspace.yaml"); function collapseDockerContinuations(dockerfile: string): string { return dockerfile.replace(/\\\r?\n[ \t]*/g, " "); @@ -140,11 +141,11 @@ describe("Dockerfile", () => { it("keeps package manager patch files in runtime images", async () => { const dockerfile = await readFile(dockerfilePath, "utf8"); - const packageJson = JSON.parse(await readFile(packageJsonPath, "utf8")) as { - pnpm?: { patchedDependencies?: Record }; + const pnpmWorkspace = YAML.parse(await readFile(pnpmWorkspacePath, "utf8")) as { + patchedDependencies?: Record; }; - expect(Object.keys(packageJson.pnpm?.patchedDependencies ?? {})).not.toHaveLength(0); + expect(Object.keys(pnpmWorkspace.patchedDependencies ?? {})).not.toHaveLength(0); expect(dockerfile).toContain( "COPY --from=runtime-assets --chown=node:node /app/patches ./patches", ); diff --git a/src/infra/update-package-manager.test.ts b/src/infra/update-package-manager.test.ts index db0c97ccec9..82043cab1a3 100644 --- a/src/infra/update-package-manager.test.ts +++ b/src/infra/update-package-manager.test.ts @@ -13,7 +13,7 @@ describe("resolveUpdateBuildManager", () => { const envPath = options.env?.PATH ?? options.env?.Path ?? ""; if (envPath.includes("openclaw-update-pnpm-")) { paths.push(envPath); - return { stdout: "10.0.0", stderr: "", code: 0 }; + return { stdout: "11.0.0", stderr: "", code: 0 }; } throw new Error("spawn pnpm ENOENT"); } @@ -23,7 +23,7 @@ describe("resolveUpdateBuildManager", () => { if (key === "npm --version") { return { stdout: "10.0.0", stderr: "", code: 0 }; } - if (key.startsWith("npm install --prefix ") && key.endsWith(" pnpm@10")) { + if (key.startsWith("npm install --prefix ") && key.endsWith(" pnpm@11")) { return { stdout: "added 1 package", stderr: "", code: 0 }; } return { stdout: "", stderr: "", code: 0 }; @@ -53,7 +53,7 @@ describe("resolveUpdateBuildManager", () => { if (key === "npm --version") { return { stdout: "10.0.0", stderr: "", code: 0 }; } - if (key.startsWith("npm install --prefix ") && key.endsWith(" pnpm@10")) { + if (key.startsWith("npm install --prefix ") && key.endsWith(" pnpm@11")) { return { stdout: "", stderr: "network exploded", code: 1 }; } return { stdout: "", stderr: "", code: 0 }; diff --git a/src/infra/update-package-manager.ts b/src/infra/update-package-manager.ts index 5cfec4c7752..582ca37b0ca 100644 --- a/src/infra/update-package-manager.ts +++ b/src/infra/update-package-manager.ts @@ -34,6 +34,8 @@ type ResolvedBuildManager = reason: UpdatePackageManagerFailureReason; }; +const PNPM_NPM_FALLBACK_SPEC = "pnpm@11"; + async function detectBuildManager(root: string): Promise { return (await detectPackageManagerImpl(root)) ?? "npm"; } @@ -124,7 +126,7 @@ async function bootstrapPnpmViaNpm(params: { }; try { const installResult = await params.runCommand( - ["npm", "install", "--prefix", tempRoot, "pnpm@10"], + ["npm", "install", "--prefix", tempRoot, PNPM_NPM_FALLBACK_SPEC], { timeoutMs: params.timeoutMs, env: params.baseEnv, diff --git a/src/infra/update-runner.test.ts b/src/infra/update-runner.test.ts index 6ce864376f7..17a40c67084 100644 --- a/src/infra/update-runner.test.ts +++ b/src/infra/update-runner.test.ts @@ -438,7 +438,7 @@ describe("runGatewayUpdate", () => { if (key === "pnpm --version") { const envPath = options?.env?.PATH ?? options?.env?.Path ?? ""; if (envPath.includes("openclaw-update-pnpm-")) { - return { stdout: "10.0.0" }; + return { stdout: "11.0.0" }; } throw new Error("spawn pnpm ENOENT"); } @@ -448,7 +448,7 @@ describe("runGatewayUpdate", () => { if (key === "npm --version") { return { stdout: "10.0.0" }; } - if (key.startsWith("npm install --prefix ") && key.endsWith(" pnpm@10")) { + if (key.startsWith("npm install --prefix ") && key.endsWith(" pnpm@11")) { return { stdout: "added 1 package" }; } return undefined; @@ -550,7 +550,7 @@ describe("runGatewayUpdate", () => { const envPath = options?.env?.PATH ?? options?.env?.Path ?? ""; if (envPath.includes("openclaw-update-pnpm-")) { pnpmEnvPaths.push(envPath); - return { stdout: "10.0.0", stderr: "", code: 0 }; + return { stdout: "11.0.0", stderr: "", code: 0 }; } throw new Error("spawn pnpm ENOENT"); } @@ -560,7 +560,7 @@ describe("runGatewayUpdate", () => { if (key === "npm --version") { return { stdout: "10.0.0", stderr: "", code: 0 }; } - if (key.startsWith("npm install --prefix ") && key.endsWith(" pnpm@10")) { + if (key.startsWith("npm install --prefix ") && key.endsWith(" pnpm@11")) { return { stdout: "added 1 package", stderr: "", code: 0 }; } if ( @@ -1412,7 +1412,7 @@ describe("runGatewayUpdate", () => { if (key === "npm --version") { return { stdout: "10.0.0", stderr: "", code: 0 }; } - if (key.startsWith("npm install --prefix ") && key.endsWith(" pnpm@10")) { + if (key.startsWith("npm install --prefix ") && key.endsWith(" pnpm@11")) { return { stdout: "", stderr: "network exploded", code: 1 }; } return { stdout: "", stderr: "", code: 0 }; diff --git a/src/plugins/dependency-denylist.test.ts b/src/plugins/dependency-denylist.test.ts index 55420ab1003..971de20fcb4 100644 --- a/src/plugins/dependency-denylist.test.ts +++ b/src/plugins/dependency-denylist.test.ts @@ -1,6 +1,7 @@ import fs from "node:fs"; import path from "node:path"; import { describe, expect, it } from "vitest"; +import YAML from "yaml"; import { blockedInstallDependencyPackageNames, findBlockedPackageDirectoryInPath, @@ -15,9 +16,10 @@ type RootPackageManifest = { optionalDependencies?: Record; overrides?: Record>; peerDependencies?: Record; - pnpm?: { - overrides?: Record; - }; +}; + +type PnpmWorkspaceConfig = { + overrides?: Record; }; function readRootManifest(): RootPackageManifest { @@ -26,6 +28,12 @@ function readRootManifest(): RootPackageManifest { ) as RootPackageManifest; } +function readPnpmWorkspaceConfig(): PnpmWorkspaceConfig { + return YAML.parse( + fs.readFileSync(path.resolve(process.cwd(), "pnpm-workspace.yaml"), "utf8"), + ) as PnpmWorkspaceConfig; +} + function readRootLockfile(): string { return fs.readFileSync(path.resolve(process.cwd(), "pnpm-lock.yaml"), "utf8"); } @@ -84,8 +92,9 @@ describe("dependency denylist guardrails", () => { it("pins the axios override to an exact version", () => { const manifest = readRootManifest(); + const pnpmWorkspace = readPnpmWorkspaceConfig(); expect(manifest.overrides?.axios).toMatch(/^\d+\.\d+\.\d+$/); - expect(manifest.pnpm?.overrides?.axios).toMatch(/^\d+\.\d+\.\d+$/); + expect(pnpmWorkspace.overrides?.axios).toMatch(/^\d+\.\d+\.\d+$/); }); it("finds blocked package directories under node_modules regardless of node_modules casing", () => { diff --git a/src/plugins/pi-package-graph.test.ts b/src/plugins/pi-package-graph.test.ts index 256b06da5a6..d2228ad2067 100644 --- a/src/plugins/pi-package-graph.test.ts +++ b/src/plugins/pi-package-graph.test.ts @@ -1,12 +1,14 @@ import fs from "node:fs"; import path from "node:path"; import { describe, expect, it } from "vitest"; +import YAML from "yaml"; type RootPackageManifest = { dependencies?: Record; - pnpm?: { - overrides?: Record; - }; +}; + +type PnpmWorkspaceConfig = { + overrides?: Record; }; const PI_PACKAGE_NAMES = [ @@ -21,6 +23,11 @@ function readRootManifest(): RootPackageManifest { return JSON.parse(fs.readFileSync(manifestPath, "utf8")) as RootPackageManifest; } +function readPnpmWorkspaceConfig(): PnpmWorkspaceConfig { + const workspacePath = path.resolve(process.cwd(), "pnpm-workspace.yaml"); + return YAML.parse(fs.readFileSync(workspacePath, "utf8")) as PnpmWorkspaceConfig; +} + function isExactPinnedVersion(spec: string): boolean { return !spec.startsWith("^") && !spec.startsWith("~"); } @@ -76,8 +83,8 @@ describe("pi package graph guardrails", () => { }); it("forbids pnpm overrides that target Pi packages", () => { - const manifest = readRootManifest(); - const overrides = manifest.pnpm?.overrides ?? {}; + const pnpmWorkspace = readPnpmWorkspaceConfig(); + const overrides = pnpmWorkspace.overrides ?? {}; const piOverrides = Object.keys(overrides).filter(isPiOverrideKey); expectNoGraphViolations( diff --git a/test/scripts/docker-build-helper.test.ts b/test/scripts/docker-build-helper.test.ts index 1ea6785b20c..4140a6e6cb1 100644 --- a/test/scripts/docker-build-helper.test.ts +++ b/test/scripts/docker-build-helper.test.ts @@ -1,4 +1,7 @@ -import { readFileSync } from "node:fs"; +import { execFileSync } from "node:child_process"; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { describe, expect, it } from "vitest"; const HELPER_PATH = "scripts/lib/docker-build.sh"; @@ -225,6 +228,50 @@ describe("docker build helper", () => { expect(pluginsAssertions).toContain("expected modern installRecords in installed plugin index"); }); + it("prepares pnpm workspace package fixtures without package dependencies", () => { + const root = mkdtempSync(join(tmpdir(), "openclaw-update-channel-fixture-")); + try { + mkdirSync(join(root, "patches")); + writeFileSync( + join(root, "package.json"), + `${JSON.stringify({ name: "openclaw", version: "2026.5.6", scripts: {} }, null, 2)}\n`, + "utf8", + ); + writeFileSync( + join(root, "pnpm-workspace.yaml"), + [ + "packages:", + " - .", + "", + "patchedDependencies:", + ' "kept@1.0.0": "patches/kept.patch"', + "allowBuilds:", + " esbuild: true", + "", + ].join("\n"), + "utf8", + ); + writeFileSync(join(root, "patches", "kept.patch"), "", "utf8"); + + execFileSync(process.execPath, [ + UPDATE_CHANNEL_SWITCH_ASSERTIONS_PATH, + "prepare-git-fixture", + root, + ]); + + const workspace = readFileSync(join(root, "pnpm-workspace.yaml"), "utf8"); + const manifest = JSON.parse(readFileSync(join(root, "package.json"), "utf8")) as { + pnpm?: unknown; + }; + expect(workspace).toContain(' "kept@1.0.0": "patches/kept.patch"'); + expect(workspace).toContain("allowUnusedPatches: true"); + expect(workspace).toContain("allowBuilds:"); + expect(manifest.pnpm).toBeUndefined(); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + it("keeps bundled plugin install/uninstall sweep chunkable", () => { const runner = readFileSync(BUNDLED_PLUGIN_INSTALL_UNINSTALL_E2E_PATH, "utf8"); const sweep = readFileSync(BUNDLED_PLUGIN_INSTALL_UNINSTALL_SWEEP_PATH, "utf8"); diff --git a/test/scripts/root-package-overrides.test.ts b/test/scripts/root-package-overrides.test.ts index d6649287d83..f3cf4154341 100644 --- a/test/scripts/root-package-overrides.test.ts +++ b/test/scripts/root-package-overrides.test.ts @@ -1,13 +1,15 @@ import fs from "node:fs"; import path from "node:path"; import { describe, expect, it } from "vitest"; +import YAML from "yaml"; type RootPackageManifest = { dependencies?: Record; overrides?: Record; - pnpm?: { - overrides?: Record; - }; +}; + +type PnpmWorkspaceConfig = { + overrides?: Record; }; function readRootManifest(): RootPackageManifest { @@ -15,13 +17,19 @@ function readRootManifest(): RootPackageManifest { return JSON.parse(fs.readFileSync(manifestPath, "utf8")) as RootPackageManifest; } +function readPnpmWorkspaceConfig(): PnpmWorkspaceConfig { + const workspacePath = path.resolve(process.cwd(), "pnpm-workspace.yaml"); + return YAML.parse(fs.readFileSync(workspacePath, "utf8")) as PnpmWorkspaceConfig; +} + describe("root package override guardrails", () => { it("pins the Bedrock runtime below the Windows ARM Node 24 npm resolver failure", () => { const manifest = readRootManifest(); + const pnpmWorkspace = readPnpmWorkspaceConfig(); const packageName = "@aws-sdk/client-bedrock-runtime"; const dependencyVersion = manifest.dependencies?.[packageName]; const npmOverride = manifest.overrides?.[packageName]; - const pnpmOverride = manifest.pnpm?.overrides?.["@aws-sdk/client-bedrock-runtime"]; + const pnpmOverride = pnpmWorkspace.overrides?.["@aws-sdk/client-bedrock-runtime"]; expect(manifest.dependencies).toHaveProperty(packageName); expect(pnpmOverride).toBe(dependencyVersion); @@ -30,7 +38,8 @@ describe("root package override guardrails", () => { it("pins the node-domexception alias exactly in npm and pnpm overrides", () => { const manifest = readRootManifest(); - const pnpmOverride = manifest.pnpm?.overrides?.["node-domexception"]; + const pnpmWorkspace = readPnpmWorkspaceConfig(); + const pnpmOverride = pnpmWorkspace.overrides?.["node-domexception"]; const npmOverride = manifest.overrides?.["node-domexception"]; expect(pnpmOverride).toBe("npm:@nolyfill/domexception@1.0.28");