diff --git a/scripts/ocm-npm-workspace-deps.mjs b/scripts/ocm-npm-workspace-deps.mjs index 5a3ece81ca3..363cd1c196f 100755 --- a/scripts/ocm-npm-workspace-deps.mjs +++ b/scripts/ocm-npm-workspace-deps.mjs @@ -64,12 +64,28 @@ function runNpm(npm, args, options = {}) { return result; } +function runTar(args) { + const result = spawnSync("tar", args, { + env: process.env, + stdio: "inherit", + }); + if (result.error) { + throw result.error; + } + if (result.status !== 0) { + throw new Error(`tar failed with status ${result.status ?? 1}`); + } +} + function packWorkspaceDependencies(npm, workspaceDirs, outputDir) { return workspaceDirs.map((packageDir) => { const packageJson = JSON.parse(readFileSync(join(packageDir, "package.json"), "utf8")); if (typeof packageJson.name !== "string" || packageJson.name.trim() === "") { throw new Error(`workspace dependency has no package name: ${packageDir}`); } + if (typeof packageJson.version !== "string" || packageJson.version.trim() === "") { + throw new Error(`workspace dependency has no package version: ${packageDir}`); + } const before = new Set(readdirSync(outputDir)); const result = runNpm(npm, ["pack", packageDir, "--pack-destination", outputDir, "--silent"], { encoding: "utf8", @@ -88,11 +104,58 @@ function packWorkspaceDependencies(npm, workspaceDirs, outputDir) { } return { name: packageJson.name, + version: packageJson.version, tarball: join(outputDir, tarballs[0]), }; }); } +export function rewriteWorkspaceDependencyVersions(packageJson, workspacePackages) { + const workspaceVersions = new Map(workspacePackages.map(({ name, version }) => [name, version])); + let rewritten = 0; + for (const section of [ + "dependencies", + "optionalDependencies", + "peerDependencies", + "devDependencies", + ]) { + const dependencies = packageJson[section]; + if (!dependencies || typeof dependencies !== "object" || Array.isArray(dependencies)) { + continue; + } + for (const [name, spec] of Object.entries(dependencies)) { + if (typeof spec !== "string" || !spec.startsWith("workspace:")) { + continue; + } + const version = workspaceVersions.get(name); + if (!version) { + throw new Error(`root archive references unconfigured workspace dependency: ${name}`); + } + dependencies[name] = version; + rewritten += 1; + } + } + return rewritten; +} + +function patchRootArchiveWorkspaceDependencies(rootArchive, workspacePackages, outputDir) { + const unpackDir = join(outputDir, "root-archive"); + mkdirSync(unpackDir); + runTar(["-xzf", rootArchive, "-C", unpackDir]); + + const packageJsonPath = join(unpackDir, "package", "package.json"); + const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")); + const rewritten = rewriteWorkspaceDependencyVersions(packageJson, workspacePackages); + if (rewritten === 0) { + return rootArchive; + } + + writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`); + const patchedArchive = join(outputDir, "openclaw-root-patched.tgz"); + runTar(["-czf", patchedArchive, "-C", unpackDir, "package"]); + return patchedArchive; +} + function main() { const args = process.argv.slice(2); const npm = process.env[REAL_NPM_ENV]?.trim() || "npm"; @@ -106,10 +169,15 @@ function main() { const packDir = mkdtempSync(join(tmpdir(), "openclaw-ocm-workspace-deps-")); try { const workspacePackages = packWorkspaceDependencies(npm, workspaceDirs, packDir); + const rootArchive = patchRootArchiveWorkspaceDependencies( + plan.rootArchive, + workspacePackages, + packDir, + ); mkdirSync(plan.prefixDir, { recursive: true }); writeFileSync( join(plan.prefixDir, "package.json"), - `${JSON.stringify(buildInstallManifest(plan.rootArchive, workspacePackages), null, 2)}\n`, + `${JSON.stringify(buildInstallManifest(rootArchive, workspacePackages), null, 2)}\n`, ); const result = runNpm(npm, plan.installArgs, { stdio: "inherit" }); return result.status ?? 1; diff --git a/test/scripts/ocm-npm-workspace-deps.test.ts b/test/scripts/ocm-npm-workspace-deps.test.ts index 2ef491e4484..874f58f70c1 100644 --- a/test/scripts/ocm-npm-workspace-deps.test.ts +++ b/test/scripts/ocm-npm-workspace-deps.test.ts @@ -1,11 +1,20 @@ +import { execFileSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; import { delimiter, join } from "node:path"; +import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; import { buildInstallManifest, parseWorkspaceDependencyDirs, resolveWorkspaceInstallPlan, + rewriteWorkspaceDependencyVersions, } from "../../scripts/ocm-npm-workspace-deps.mjs"; +const adapterPath = fileURLToPath( + new URL("../../scripts/ocm-npm-workspace-deps.mjs", import.meta.url), +); + describe("OCM npm workspace dependency adapter", () => { it("resolves workspace package directories", () => { expect( @@ -60,4 +69,94 @@ describe("OCM npm workspace dependency adapter", () => { }, }); }); + + it("rewrites packed workspace protocols to the local package version", () => { + const packageJson = { + dependencies: { + "@openclaw/ai": "workspace:*", + chalk: "5.6.2", + }, + }; + + expect( + rewriteWorkspaceDependencyVersions(packageJson, [ + { + name: "@openclaw/ai", + version: "2026.7.1-beta.3", + tarball: "/tmp/openclaw-ai.tgz", + }, + ]), + ).toBe(1); + expect(packageJson.dependencies).toEqual({ + "@openclaw/ai": "2026.7.1-beta.3", + chalk: "5.6.2", + }); + }); + + it("installs a packed root with a local workspace dependency", () => { + const root = mkdtempSync(join(tmpdir(), "openclaw-ocm-adapter-test-")); + try { + const archiveRoot = join(root, "archive"); + const packagedRoot = join(archiveRoot, "package"); + const workspaceDir = join(root, "ai"); + const installDir = join(root, "install"); + const rootArchive = join(root, "openclaw.tgz"); + mkdirSync(packagedRoot, { recursive: true }); + mkdirSync(workspaceDir, { recursive: true }); + writeFileSync( + join(packagedRoot, "package.json"), + `${JSON.stringify({ + name: "openclaw", + version: "1.0.0", + dependencies: { "@openclaw/ai": "workspace:*" }, + })}\n`, + ); + writeFileSync( + join(workspaceDir, "package.json"), + `${JSON.stringify({ + name: "@openclaw/ai", + version: "1.0.0", + main: "index.js", + })}\n`, + ); + writeFileSync(join(workspaceDir, "index.js"), "export const ready = true;\n"); + execFileSync("tar", ["-czf", rootArchive, "-C", archiveRoot, "package"]); + + execFileSync( + process.execPath, + [ + adapterPath, + "install", + "--prefix", + installDir, + "--omit=dev", + "--no-save", + "--package-lock=false", + rootArchive, + ], + { + env: { + ...process.env, + OPENCLAW_OCM_REAL_NPM_BIN: process.platform === "win32" ? "npm.cmd" : "npm", + OPENCLAW_OCM_WORKSPACE_DEPENDENCY_DIRS: workspaceDir, + npm_config_audit: "false", + npm_config_cache: join(root, "npm-cache"), + npm_config_fund: "false", + }, + stdio: "pipe", + }, + ); + + expect( + JSON.parse(readFileSync(join(installDir, "node_modules/openclaw/package.json"), "utf8")) + .version, + ).toBe("1.0.0"); + expect( + JSON.parse(readFileSync(join(installDir, "node_modules/@openclaw/ai/package.json"), "utf8")) + .version, + ).toBe("1.0.0"); + } finally { + rmSync(root, { force: true, recursive: true }); + } + }); });