diff --git a/.github/workflows/openclaw-performance.yml b/.github/workflows/openclaw-performance.yml index 2dea8e70d73..dde34619051 100644 --- a/.github/workflows/openclaw-performance.yml +++ b/.github/workflows/openclaw-performance.yml @@ -246,6 +246,23 @@ jobs: chmod 0755 "$HOME/.local/bin/kova" echo "$HOME/.local/bin" >> "$GITHUB_PATH" + - name: Configure OCM local workspace dependencies + if: steps.lane.outputs.run == 'true' + shell: bash + run: | + set -euo pipefail + npm_wrapper="$PERFORMANCE_HELPER_DIR/scripts/ocm-npm-workspace-deps.mjs" + workspace_dependency_dirs="" + if [[ -f "${GITHUB_WORKSPACE}/packages/ai/package.json" ]]; then + workspace_dependency_dirs="${GITHUB_WORKSPACE}/packages/ai" + fi + chmod 0755 "$npm_wrapper" + { + echo "OCM_INTERNAL_NPM_BIN=$npm_wrapper" + echo "OPENCLAW_OCM_REAL_NPM_BIN=$(command -v npm)" + echo "OPENCLAW_OCM_WORKSPACE_DEPENDENCY_DIRS=$workspace_dependency_dirs" + } >> "$GITHUB_ENV" + - name: Pin Kova OpenAI model to GPT 5.5 if: steps.lane.outputs.run == 'true' shell: bash diff --git a/scripts/ocm-npm-workspace-deps.mjs b/scripts/ocm-npm-workspace-deps.mjs new file mode 100755 index 00000000000..5a3ece81ca3 --- /dev/null +++ b/scripts/ocm-npm-workspace-deps.mjs @@ -0,0 +1,123 @@ +#!/usr/bin/env node + +import { spawnSync } from "node:child_process"; +import { mkdtempSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { delimiter, join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +const WORKSPACE_DIRS_ENV = "OPENCLAW_OCM_WORKSPACE_DEPENDENCY_DIRS"; +const REAL_NPM_ENV = "OPENCLAW_OCM_REAL_NPM_BIN"; + +export function parseWorkspaceDependencyDirs( + raw = process.env[WORKSPACE_DIRS_ENV], + cwd = process.cwd(), +) { + return (raw ?? "") + .split(delimiter) + .map((entry) => entry.trim()) + .filter(Boolean) + .map((entry) => resolve(cwd, entry)); +} + +function optionValue(args, name) { + const index = args.indexOf(name); + return index >= 0 ? args[index + 1] : undefined; +} + +export function resolveWorkspaceInstallPlan(args, workspaceDirs, cwd = process.cwd()) { + if (args[0] !== "install" || workspaceDirs.length === 0) { + return null; + } + const prefixDir = optionValue(args, "--prefix"); + const rootArchive = args.at(-1); + if (!prefixDir || !rootArchive?.endsWith(".tgz")) { + throw new Error("OCM workspace dependency install requires --prefix and a root .tgz archive"); + } + return { + installArgs: args.slice(0, -1), + prefixDir: resolve(cwd, prefixDir), + rootArchive: resolve(cwd, rootArchive), + }; +} + +export function buildInstallManifest(rootArchive, workspacePackages) { + return { + private: true, + dependencies: { + openclaw: pathToFileURL(rootArchive).href, + ...Object.fromEntries( + workspacePackages.map(({ name, tarball }) => [name, pathToFileURL(tarball).href]), + ), + }, + }; +} + +function runNpm(npm, args, options = {}) { + const result = spawnSync(npm, args, { + ...options, + env: process.env, + }); + if (result.error) { + throw result.error; + } + return result; +} + +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}`); + } + const before = new Set(readdirSync(outputDir)); + const result = runNpm(npm, ["pack", packageDir, "--pack-destination", outputDir, "--silent"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "inherit"], + }); + if (result.status !== 0) { + throw new Error(`npm pack failed for ${packageJson.name} with status ${result.status ?? 1}`); + } + const tarballs = readdirSync(outputDir).filter( + (entry) => entry.endsWith(".tgz") && !before.has(entry), + ); + if (tarballs.length !== 1) { + throw new Error( + `expected npm pack to create one archive for ${packageJson.name}, found ${tarballs.length}`, + ); + } + return { + name: packageJson.name, + tarball: join(outputDir, tarballs[0]), + }; + }); +} + +function main() { + const args = process.argv.slice(2); + const npm = process.env[REAL_NPM_ENV]?.trim() || "npm"; + const workspaceDirs = parseWorkspaceDependencyDirs(); + const plan = resolveWorkspaceInstallPlan(args, workspaceDirs); + if (!plan) { + const result = runNpm(npm, args, { stdio: "inherit" }); + return result.status ?? 1; + } + + const packDir = mkdtempSync(join(tmpdir(), "openclaw-ocm-workspace-deps-")); + try { + const workspacePackages = packWorkspaceDependencies(npm, workspaceDirs, packDir); + mkdirSync(plan.prefixDir, { recursive: true }); + writeFileSync( + join(plan.prefixDir, "package.json"), + `${JSON.stringify(buildInstallManifest(plan.rootArchive, workspacePackages), null, 2)}\n`, + ); + const result = runNpm(npm, plan.installArgs, { stdio: "inherit" }); + return result.status ?? 1; + } finally { + rmSync(packDir, { force: true, recursive: true }); + } +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { + process.exitCode = main(); +} diff --git a/scripts/test-projects.test-support.mjs b/scripts/test-projects.test-support.mjs index 51ef67307dc..43e47622445 100644 --- a/scripts/test-projects.test-support.mjs +++ b/scripts/test-projects.test-support.mjs @@ -1418,6 +1418,7 @@ const TOOLING_SOURCE_TEST_TARGETS = new Map([ ["scripts/create-dmg.sh", ["test/scripts/create-dmg.test.ts"]], ["scripts/kova-ci-summary.mjs", ["test/scripts/kova-ci-summary.test.ts"]], ["scripts/make_appcast.sh", ["test/scripts/make-appcast.test.ts"]], + ["scripts/ocm-npm-workspace-deps.mjs", ["test/scripts/ocm-npm-workspace-deps.test.ts"]], ["scripts/openclaw-npm-prepublish-verify.ts", ["test/openclaw-npm-prepublish-verify.test.ts"]], ["scripts/openclaw-npm-postpublish-verify.ts", ["test/openclaw-npm-postpublish-verify.test.ts"]], ["scripts/openclaw-npm-release-check.ts", ["test/openclaw-npm-release-check.test.ts"]], diff --git a/test/scripts/ocm-npm-workspace-deps.test.ts b/test/scripts/ocm-npm-workspace-deps.test.ts new file mode 100644 index 00000000000..2ef491e4484 --- /dev/null +++ b/test/scripts/ocm-npm-workspace-deps.test.ts @@ -0,0 +1,63 @@ +import { delimiter, join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { + buildInstallManifest, + parseWorkspaceDependencyDirs, + resolveWorkspaceInstallPlan, +} from "../../scripts/ocm-npm-workspace-deps.mjs"; + +describe("OCM npm workspace dependency adapter", () => { + it("resolves workspace package directories", () => { + expect( + parseWorkspaceDependencyDirs(["packages/ai", "extensions/example"].join(delimiter), "/repo"), + ).toEqual(["/repo/packages/ai", "/repo/extensions/example"]); + }); + + it("replaces the root archive argument with a prepared install manifest", () => { + expect( + resolveWorkspaceInstallPlan( + [ + "install", + "--prefix", + "runtime", + "--omit=dev", + "--no-save", + "--package-lock=false", + "openclaw.tgz", + ], + ["/repo/packages/ai"], + "/repo", + ), + ).toEqual({ + installArgs: [ + "install", + "--prefix", + "runtime", + "--omit=dev", + "--no-save", + "--package-lock=false", + ], + prefixDir: "/repo/runtime", + rootArchive: "/repo/openclaw.tgz", + }); + }); + + it("keeps normal npm commands unchanged", () => { + expect(resolveWorkspaceInstallPlan(["pack", "--silent"], ["/repo/packages/ai"])).toBeNull(); + expect(resolveWorkspaceInstallPlan(["install", "openclaw.tgz"], [])).toBeNull(); + }); + + it("builds a manifest with the root and local workspace tarballs", () => { + expect( + buildInstallManifest("/tmp/openclaw.tgz", [ + { name: "@openclaw/ai", tarball: "/tmp/openclaw-ai.tgz" }, + ]), + ).toEqual({ + private: true, + dependencies: { + "@openclaw/ai": "file:///tmp/openclaw-ai.tgz", + openclaw: "file:///tmp/openclaw.tgz", + }, + }); + }); +}); diff --git a/test/scripts/openclaw-performance-workflow.test.ts b/test/scripts/openclaw-performance-workflow.test.ts index 2acfcdbcca6..73f6dc35956 100644 --- a/test/scripts/openclaw-performance-workflow.test.ts +++ b/test/scripts/openclaw-performance-workflow.test.ts @@ -104,6 +104,21 @@ describe("OpenClaw performance workflow", () => { expect(runKova.run).not.toContain("report.summary?.statuses ?? {}"); }); + it("installs local workspace packages beside the OCM root tarball", () => { + const configure = findStep("Configure OCM local workspace dependencies"); + + expect(configure.run).toContain( + 'npm_wrapper="$PERFORMANCE_HELPER_DIR/scripts/ocm-npm-workspace-deps.mjs"', + ); + expect(configure.run).toContain("OCM_INTERNAL_NPM_BIN=$npm_wrapper"); + expect(configure.run).toContain( + 'if [[ -f "${GITHUB_WORKSPACE}/packages/ai/package.json" ]]; then', + ); + expect(configure.run).toContain( + "OPENCLAW_OCM_WORKSPACE_DEPENDENCY_DIRS=$workspace_dependency_dirs", + ); + }); + it("fails selected live Kova lanes when live auth is missing", () => { const configureAuth = findStep("Configure live OpenAI auth"); const runKova = findStep("Run Kova");