diff --git a/scripts/lib/plugin-npm-package-manifest.d.mts b/scripts/lib/plugin-npm-package-manifest.d.mts index 2643afb937d5..8bdd1f845598 100644 --- a/scripts/lib/plugin-npm-package-manifest.d.mts +++ b/scripts/lib/plugin-npm-package-manifest.d.mts @@ -9,6 +9,12 @@ export function resolvePluginNpmCommand( shell: boolean; windowsVerbatimArguments?: boolean; }; +/** Run package-local npm ci with bounded retries for registry timeouts. */ +export function runPluginNpmCiWithRetry( + args: unknown, + options: Record, + params?: Record, +): unknown; /** Build the package.json that should be used while packaging a plugin for npm. */ export function resolveAugmentedPluginNpmPackageJson(params: unknown): | { diff --git a/scripts/lib/plugin-npm-package-manifest.mjs b/scripts/lib/plugin-npm-package-manifest.mjs index af9453917766..5ba7eeffcc5a 100644 --- a/scripts/lib/plugin-npm-package-manifest.mjs +++ b/scripts/lib/plugin-npm-package-manifest.mjs @@ -229,6 +229,36 @@ function spawnCommandSync(command, args, options) { return spawnSync(command, args, options); } +/** @internal Directly tested release-script implementation detail. */ +export function runPluginNpmCiWithRetry(args, options, params = {}) { + const attempts = params.attempts ?? 3; + const timeoutMs = params.timeoutMs ?? 180_000; + const spawn = params.spawn ?? spawnNpmSync; + const cleanupAttempt = params.cleanupAttempt ?? (() => {}); + const pluginDir = params.pluginDir ?? "plugin"; + + let result; + for (let attempt = 1; attempt <= attempts; attempt += 1) { + result = spawn(args, { ...options, timeout: timeoutMs }); + if (result.error?.code !== "ETIMEDOUT") { + return result; + } + + // A timed-out npm process can leave a partial tree that makes the next + // package attempt nondeterministic. Restore the staging invariant even + // when the retry budget is exhausted. + cleanupAttempt(); + if (attempt === attempts) { + return result; + } + console.error( + `[plugin-npm-publish] bundled dependency install timed out for ${pluginDir} ` + + `(attempt ${attempt}/${attempts}); retrying`, + ); + } + return result; +} + function resolveInstalledPackageDir(packageDir, packageName) { return path.join(packageDir, "node_modules", ...packageName.split("/")); } @@ -433,7 +463,7 @@ function installPackageLocalBundledDependencies(params) { generateNpmPackageLock(params.packageDir, { installStrategy: "shallow" }), "utf8", ); - const result = spawnNpmSync( + const result = runPluginNpmCiWithRetry( [ "ci", "--install-strategy=shallow", @@ -451,6 +481,10 @@ function installPackageLocalBundledDependencies(params) { env: process.env, stdio: ["ignore", "ignore", "inherit"], }, + { + cleanupAttempt: () => fs.rmSync(nodeModulesPath, { recursive: true, force: true }), + pluginDir: params.pluginDir, + }, ); if (result.error) { throw result.error; diff --git a/test/plugin-npm-package-manifest.test.ts b/test/plugin-npm-package-manifest.test.ts index 9ae7a26da300..b4f346cc8a7f 100644 --- a/test/plugin-npm-package-manifest.test.ts +++ b/test/plugin-npm-package-manifest.test.ts @@ -1,12 +1,13 @@ // Plugin npm manifest tests validate generated plugin package manifests. import { spawnSync } from "node:child_process"; -import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { dirname, join, win32 } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { resolveAugmentedPluginNpmPackageJson, resolveAugmentedPluginNpmManifest, resolvePluginNpmCommand, + runPluginNpmCiWithRetry, withAugmentedPluginNpmManifestForPackage, } from "../scripts/lib/plugin-npm-package-manifest.mjs"; import { cleanupTempDirs, makeTempRepoRoot, writeJsonFile } from "./helpers/temp-repo.js"; @@ -188,6 +189,99 @@ describe("plugin npm package manifest staging", () => { ).toThrow("OpenClaw refuses to shell out to bare npm on Windows"); }); + it("retries timed-out bundled dependency installs after cleaning partial output", () => { + const timeoutError = Object.assign(new Error("timed out"), { code: "ETIMEDOUT" }); + const spawnResults = [ + { error: timeoutError, status: null }, + { error: undefined, status: 0 }, + ]; + const spawnOptions: Array> = []; + let cleanupCalls = 0; + + const result = runPluginNpmCiWithRetry( + ["ci"], + { cwd: "/tmp/plugin" }, + { + cleanupAttempt: () => { + cleanupCalls += 1; + }, + pluginDir: "whatsapp", + spawn: (_args: string[], options: Record) => { + spawnOptions.push(options); + return spawnResults.shift(); + }, + timeoutMs: 1234, + }, + ) as { status: number | null }; + + expect(result.status).toBe(0); + expect(cleanupCalls).toBe(1); + expect(spawnOptions).toEqual([ + { cwd: "/tmp/plugin", timeout: 1234 }, + { cwd: "/tmp/plugin", timeout: 1234 }, + ]); + }); + + it("does not retry ordinary bundled dependency install failures", () => { + let spawnCalls = 0; + const result = runPluginNpmCiWithRetry( + ["ci"], + {}, + { + cleanupAttempt: () => { + throw new Error("cleanup should not run"); + }, + spawn: () => { + spawnCalls += 1; + return { error: undefined, status: 1 }; + }, + }, + ) as { status: number | null }; + + expect(result.status).toBe(1); + expect(spawnCalls).toBe(1); + }); + + it("cleans an exhausted timeout before reusing the same package directory", () => { + const repoDir = makeTempRepoRoot(tempDirs, "openclaw-plugin-npm-timeout-"); + const packageDir = join(repoDir, "extensions", "whatsapp"); + const nodeModulesPath = join(packageDir, "node_modules"); + const timeoutError = Object.assign(new Error("timed out"), { code: "ETIMEDOUT" }); + mkdirSync(packageDir, { recursive: true }); + + const firstResult = runPluginNpmCiWithRetry( + ["ci"], + { cwd: packageDir }, + { + attempts: 3, + cleanupAttempt: () => rmSync(nodeModulesPath, { recursive: true, force: true }), + pluginDir: "whatsapp", + spawn: () => { + mkdirSync(nodeModulesPath, { recursive: true }); + return { error: timeoutError, status: null }; + }, + }, + ) as { error?: NodeJS.ErrnoException }; + + expect(firstResult.error?.code).toBe("ETIMEDOUT"); + expect(existsSync(nodeModulesPath)).toBe(false); + + const secondResult = runPluginNpmCiWithRetry( + ["ci"], + { cwd: packageDir }, + { + cleanupAttempt: () => rmSync(nodeModulesPath, { recursive: true, force: true }), + pluginDir: "whatsapp", + spawn: () => { + expect(existsSync(nodeModulesPath)).toBe(false); + return { error: undefined, status: 0 }; + }, + }, + ) as { status: number | null }; + + expect(secondResult.status).toBe(0); + }); + it("overlays generated channel configs while packing and restores source manifest", () => { const repoDir = makeTempRepoRoot(tempDirs, "openclaw-plugin-npm-package-manifest-"); const packageDir = join(repoDir, "extensions", "twitch");