diff --git a/.github/workflows/plugin-npm-release.yml b/.github/workflows/plugin-npm-release.yml index 43f3be0f28a..7c402d55965 100644 --- a/.github/workflows/plugin-npm-release.yml +++ b/.github/workflows/plugin-npm-release.yml @@ -13,6 +13,7 @@ on: - "scripts/plugin-npm-publish.sh" - "scripts/plugin-npm-release-check.ts" - "scripts/plugin-npm-release-plan.ts" + - "scripts/verify-plugin-npm-published-runtime.mjs" workflow_dispatch: inputs: publish_scope: @@ -224,3 +225,9 @@ jobs: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} NPM_TOKEN: ${{ secrets.NPM_TOKEN }} run: bash scripts/plugin-npm-publish.sh --publish "${{ matrix.plugin.packageDir }}" + + - name: Verify published runtime + env: + PACKAGE_NAME: ${{ matrix.plugin.packageName }} + PACKAGE_VERSION: ${{ matrix.plugin.version }} + run: node scripts/verify-plugin-npm-published-runtime.mjs "${PACKAGE_NAME}@${PACKAGE_VERSION}" diff --git a/CHANGELOG.md b/CHANGELOG.md index 0446e9df7e3..561c4af8855 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Agents/tools: stop treating `tools.deny: ["write"]` as an implicit `apply_patch` deny; operators who want to block patch writes should deny `apply_patch` or `group:fs` explicitly. Fixes #76749. (#76795) Thanks @Nek-12 and @hclsys. +- Plugins/release: verify published plugin npm tarballs expose compiled runtime entries after publish, catching TS-only package artifacts before release closeout. Thanks @vincentkoc. - Gateway/update: recover an installed-but-unloaded macOS LaunchAgent after package updates, rerun Gateway health/version/channel readiness checks, and print restart, reinstall, and rollback guidance before reporting update failure. (#76790) Thanks @jonathanlindsay. - CLI/plugins: explain when a missing plugin command alias belongs to a bundled plugin that is disabled by default, including the `openclaw plugins enable ` repair command. (#76835) - Google Meet: route stateful CLI session commands through the gateway-owned runtime so joined realtime sessions survive after the starting CLI process exits. Fixes #76344. Thanks @coltonharris-wq. diff --git a/scripts/verify-plugin-npm-published-runtime.mjs b/scripts/verify-plugin-npm-published-runtime.mjs new file mode 100644 index 00000000000..64f9f02cc44 --- /dev/null +++ b/scripts/verify-plugin-npm-published-runtime.mjs @@ -0,0 +1,208 @@ +#!/usr/bin/env node + +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import * as tar from "tar"; + +function normalizeStringList(value) { + if (!Array.isArray(value)) { + return []; + } + return value.map((entry) => (typeof entry === "string" ? entry.trim() : "")).filter(Boolean); +} + +function normalizePackagePath(value) { + return value + .replace(/\\/g, "/") + .replace(/^package\//u, "") + .replace(/^\.\//u, ""); +} + +function isTypeScriptPackageEntry(entryPath) { + return [".ts", ".mts", ".cts"].includes(path.extname(entryPath).toLowerCase()); +} + +function listBuiltRuntimeEntryCandidates(entryPath) { + if (!isTypeScriptPackageEntry(entryPath)) { + return []; + } + const normalized = entryPath.replace(/\\/g, "/"); + const withoutExtension = normalized.replace(/\.[^.]+$/u, ""); + const normalizedRelative = normalized.replace(/^\.\//u, ""); + const distWithoutExtension = normalizedRelative.startsWith("src/") + ? `./dist/${normalizedRelative.slice("src/".length).replace(/\.[^.]+$/u, "")}` + : `./dist/${withoutExtension.replace(/^\.\//u, "")}`; + const withJavaScriptExtensions = (basePath) => [ + `${basePath}.js`, + `${basePath}.mjs`, + `${basePath}.cjs`, + ]; + return [ + ...new Set([ + ...withJavaScriptExtensions(distWithoutExtension), + ...withJavaScriptExtensions(withoutExtension), + ]), + ].filter((candidate) => candidate !== normalized); +} + +function formatPackageLabel(packageJson, fallbackSpec) { + const packageName = typeof packageJson.name === "string" ? packageJson.name.trim() : ""; + const packageVersion = typeof packageJson.version === "string" ? packageJson.version.trim() : ""; + if (packageName && packageVersion) { + return `${packageName}@${packageVersion}`; + } + return packageName || fallbackSpec || ""; +} + +export function collectPluginNpmPublishedRuntimeErrors(params) { + const packageJson = params.packageJson ?? {}; + const packageFiles = new Set([...params.files].map(normalizePackagePath)); + const packageLabel = formatPackageLabel(packageJson, params.spec); + const extensions = normalizeStringList(packageJson.openclaw?.extensions); + const runtimeExtensions = normalizeStringList(packageJson.openclaw?.runtimeExtensions); + const errors = []; + + if (extensions.length === 0) { + return errors; + } + + if (runtimeExtensions.length > 0 && runtimeExtensions.length !== extensions.length) { + errors.push( + `${packageLabel} package.json openclaw.runtimeExtensions length (${runtimeExtensions.length}) must match openclaw.extensions length (${extensions.length})`, + ); + return errors; + } + + for (const [index, entry] of extensions.entries()) { + const runtimeEntry = runtimeExtensions[index]; + if (runtimeEntry) { + if (!packageFiles.has(normalizePackagePath(runtimeEntry))) { + errors.push(`${packageLabel} runtime extension entry not found: ${runtimeEntry}`); + } + continue; + } + + if (!isTypeScriptPackageEntry(entry)) { + continue; + } + + const candidates = listBuiltRuntimeEntryCandidates(entry); + if (candidates.some((candidate) => packageFiles.has(normalizePackagePath(candidate)))) { + continue; + } + + errors.push( + `${packageLabel} requires compiled runtime output for TypeScript entry ${entry}: expected ${candidates.join(", ")}`, + ); + } + + return errors; +} + +function npmPack(spec, destinationDir) { + const output = execFileSync( + "npm", + ["pack", spec, "--json", "--ignore-scripts", "--pack-destination", destinationDir], + { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }, + ); + const rows = JSON.parse(output); + const filename = rows?.[0]?.filename; + if (typeof filename !== "string" || !filename) { + throw new Error(`npm pack ${spec} did not report a tarball filename`); + } + return path.isAbsolute(filename) ? filename : path.join(destinationDir, filename); +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function packPublishedPackage(spec, destinationDir) { + const attempts = Number.parseInt(process.env.OPENCLAW_PLUGIN_NPM_VERIFY_ATTEMPTS ?? "6", 10); + const delayMs = Number.parseInt(process.env.OPENCLAW_PLUGIN_NPM_VERIFY_DELAY_MS ?? "5000", 10); + let lastError; + for (let attempt = 1; attempt <= attempts; attempt += 1) { + try { + return npmPack(spec, destinationDir); + } catch (error) { + lastError = error; + if (attempt < attempts) { + await sleep(delayMs); + } + } + } + throw lastError; +} + +function listFiles(rootDir, prefix = "") { + const files = []; + for (const entry of fs.readdirSync(path.join(rootDir, prefix), { withFileTypes: true })) { + const relativePath = path.join(prefix, entry.name).replace(/\\/g, "/"); + if (entry.isDirectory()) { + files.push(...listFiles(rootDir, relativePath)); + } else if (entry.isFile()) { + files.push(relativePath); + } + } + return files; +} + +async function readPackedPackage(tarballPath, extractDir) { + await tar.x({ file: tarballPath, cwd: extractDir }); + const packageDir = path.join(extractDir, "package"); + const packageJson = JSON.parse(fs.readFileSync(path.join(packageDir, "package.json"), "utf8")); + return { + packageJson, + files: listFiles(packageDir), + }; +} + +export async function verifyPublishedPluginRuntime(spec) { + const workingDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-plugin-npm-runtime.")); + try { + const tarballPath = await packPublishedPackage(spec, workingDir); + const extractDir = path.join(workingDir, "extract"); + fs.mkdirSync(extractDir, { recursive: true }); + const packedPackage = await readPackedPackage(tarballPath, extractDir); + const errors = collectPluginNpmPublishedRuntimeErrors({ + ...packedPackage, + spec, + }); + if (errors.length > 0) { + throw new Error(errors.join("\n")); + } + return { + packageName: packedPackage.packageJson.name, + version: packedPackage.packageJson.version, + fileCount: packedPackage.files.length, + }; + } finally { + fs.rmSync(workingDir, { force: true, recursive: true }); + } +} + +async function main(argv) { + const spec = argv[0]?.trim(); + if (!spec) { + throw new Error("Usage: node scripts/verify-plugin-npm-published-runtime.mjs "); + } + const result = await verifyPublishedPluginRuntime(spec); + console.log( + `plugin-npm-published-runtime-check: ${result.packageName}@${result.version} OK (${result.fileCount} files)`, + ); +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { + main(process.argv.slice(2)).catch((error) => { + console.error( + `plugin-npm-published-runtime-check: ${error instanceof Error ? error.message : String(error)}`, + ); + process.exitCode = 1; + }); +} diff --git a/test/scripts/verify-plugin-npm-published-runtime.test.ts b/test/scripts/verify-plugin-npm-published-runtime.test.ts new file mode 100644 index 00000000000..d89f0ddb101 --- /dev/null +++ b/test/scripts/verify-plugin-npm-published-runtime.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; +import { collectPluginNpmPublishedRuntimeErrors } from "../../scripts/verify-plugin-npm-published-runtime.mjs"; + +describe("collectPluginNpmPublishedRuntimeErrors", () => { + it("flags published plugin packages with TypeScript entries and no compiled runtime output", () => { + expect( + collectPluginNpmPublishedRuntimeErrors({ + spec: "@openclaw/discord@2026.5.2", + packageJson: { + name: "@openclaw/discord", + version: "2026.5.2", + openclaw: { + extensions: ["./index.ts"], + }, + }, + files: ["package.json", "index.ts"], + }), + ).toEqual([ + "@openclaw/discord@2026.5.2 requires compiled runtime output for TypeScript entry ./index.ts: expected ./dist/index.js, ./dist/index.mjs, ./dist/index.cjs, ./index.js, ./index.mjs, ./index.cjs", + ]); + }); + + it("accepts published plugin packages with explicit runtimeExtensions", () => { + expect( + collectPluginNpmPublishedRuntimeErrors({ + packageJson: { + name: "@openclaw/zalo", + version: "2026.5.3", + openclaw: { + extensions: ["./index.ts"], + runtimeExtensions: ["./dist/index.js"], + }, + }, + files: ["package.json", "index.ts", "dist/index.js"], + }), + ).toEqual([]); + }); + + it("flags missing explicit runtimeExtensions outputs", () => { + expect( + collectPluginNpmPublishedRuntimeErrors({ + packageJson: { + name: "@openclaw/line", + version: "2026.5.3", + openclaw: { + extensions: ["./src/index.ts"], + runtimeExtensions: ["./dist/index.js"], + }, + }, + files: ["package.json", "src/index.ts"], + }), + ).toEqual(["@openclaw/line@2026.5.3 runtime extension entry not found: ./dist/index.js"]); + }); + + it("flags runtimeExtensions length mismatches", () => { + expect( + collectPluginNpmPublishedRuntimeErrors({ + packageJson: { + name: "@openclaw/acpx", + version: "2026.5.3", + openclaw: { + extensions: ["./index.ts", "./tools.ts"], + runtimeExtensions: ["./dist/index.js"], + }, + }, + files: ["package.json", "dist/index.js"], + }), + ).toEqual([ + "@openclaw/acpx@2026.5.3 package.json openclaw.runtimeExtensions length (1) must match openclaw.extensions length (2)", + ]); + }); +});