From d8c3e9ed6d27f07a0aec2a065f1062f2cda5a282 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 2 May 2026 01:25:49 +0100 Subject: [PATCH] ci: stage generated plugin manifests for npm publish --- .github/workflows/plugin-npm-release.yml | 4 +- scripts/lib/plugin-npm-package-manifest.mjs | 208 ++++++++++++++++++++ scripts/plugin-npm-publish.sh | 61 ++++-- test/plugin-npm-package-manifest.test.ts | 93 +++++++++ 4 files changed, 345 insertions(+), 21 deletions(-) create mode 100644 scripts/lib/plugin-npm-package-manifest.mjs create mode 100644 test/plugin-npm-package-manifest.test.ts diff --git a/.github/workflows/plugin-npm-release.yml b/.github/workflows/plugin-npm-release.yml index 5d63e144e06..2e4f72a6f4a 100644 --- a/.github/workflows/plugin-npm-release.yml +++ b/.github/workflows/plugin-npm-release.yml @@ -8,6 +8,7 @@ on: - ".github/workflows/plugin-npm-release.yml" - "extensions/**" - "package.json" + - "scripts/lib/plugin-npm-package-manifest.mjs" - "scripts/lib/plugin-npm-release.ts" - "scripts/plugin-npm-publish.sh" - "scripts/plugin-npm-release-check.ts" @@ -168,8 +169,7 @@ jobs: run: bash scripts/plugin-npm-publish.sh --dry-run "${{ matrix.plugin.packageDir }}" - name: Preview npm pack contents - working-directory: ${{ matrix.plugin.packageDir }} - run: npm pack --dry-run --json --ignore-scripts + run: bash scripts/plugin-npm-publish.sh --pack-dry-run "${{ matrix.plugin.packageDir }}" publish_plugins_npm: needs: [preview_plugins_npm, preview_plugin_pack] diff --git a/scripts/lib/plugin-npm-package-manifest.mjs b/scripts/lib/plugin-npm-package-manifest.mjs new file mode 100644 index 00000000000..8f9a0181c0d --- /dev/null +++ b/scripts/lib/plugin-npm-package-manifest.mjs @@ -0,0 +1,208 @@ +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA_PATH = + "src/config/bundled-channel-config-metadata.generated.ts"; + +function readJsonFile(filePath) { + return JSON.parse(fs.readFileSync(filePath, "utf8")); +} + +function writeJsonFile(filePath, value) { + fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8"); +} + +function resolvePackageDir(repoRoot, packageDir) { + return path.isAbsolute(packageDir) ? packageDir : path.resolve(repoRoot, packageDir); +} + +function readGeneratedBundledChannelConfigs(repoRoot) { + const metadataPath = path.join(repoRoot, GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA_PATH); + if (!fs.existsSync(metadataPath)) { + return new Map(); + } + const source = fs.readFileSync(metadataPath, "utf8"); + const match = source.match( + /export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = ([\s\S]*?) as const;/u, + ); + if (!match?.[1]) { + return new Map(); + } + + let entries; + try { + entries = Function(`"use strict"; return (${match[1]});`)(); + } catch { + return new Map(); + } + if (!Array.isArray(entries)) { + return new Map(); + } + + const byPlugin = new Map(); + for (const entry of entries) { + if ( + !entry || + typeof entry !== "object" || + typeof entry.pluginId !== "string" || + typeof entry.channelId !== "string" || + !entry.schema || + typeof entry.schema !== "object" + ) { + continue; + } + const pluginConfigs = byPlugin.get(entry.pluginId) ?? {}; + pluginConfigs[entry.channelId] = { + schema: entry.schema, + ...(typeof entry.label === "string" && entry.label ? { label: entry.label } : {}), + ...(typeof entry.description === "string" && entry.description + ? { description: entry.description } + : {}), + ...(entry.uiHints && typeof entry.uiHints === "object" ? { uiHints: entry.uiHints } : {}), + }; + byPlugin.set(entry.pluginId, pluginConfigs); + } + return byPlugin; +} + +function mergeGeneratedChannelConfigs(manifest, generatedChannelConfigs) { + if (!generatedChannelConfigs || Object.keys(generatedChannelConfigs).length === 0) { + return manifest; + } + const existingChannelConfigs = + manifest.channelConfigs && typeof manifest.channelConfigs === "object" + ? manifest.channelConfigs + : {}; + const channelConfigs = { ...existingChannelConfigs }; + for (const [channelId, generated] of Object.entries(generatedChannelConfigs)) { + const existing = + existingChannelConfigs[channelId] && typeof existingChannelConfigs[channelId] === "object" + ? existingChannelConfigs[channelId] + : {}; + channelConfigs[channelId] = { + ...generated, + ...existing, + schema: generated.schema, + ...(generated.uiHints || existing.uiHints + ? { uiHints: { ...generated.uiHints, ...existing.uiHints } } + : {}), + ...(existing.label || generated.label ? { label: existing.label ?? generated.label } : {}), + ...(existing.description || generated.description + ? { description: existing.description ?? generated.description } + : {}), + }; + } + return { + ...manifest, + channelConfigs, + }; +} + +export function resolveAugmentedPluginNpmManifest(params) { + const repoRoot = path.resolve(params.repoRoot ?? "."); + const packageDir = resolvePackageDir(repoRoot, params.packageDir); + const manifestPath = path.join(packageDir, "openclaw.plugin.json"); + if (!fs.existsSync(manifestPath)) { + return { + manifestPath, + pluginId: path.basename(packageDir), + changed: false, + manifest: undefined, + reason: "missing-manifest", + }; + } + + const manifest = readJsonFile(manifestPath); + const pluginId = + typeof manifest.id === "string" && manifest.id ? manifest.id : path.basename(packageDir); + const generatedChannelConfigs = readGeneratedBundledChannelConfigs(repoRoot).get(pluginId); + const augmentedManifest = mergeGeneratedChannelConfigs(manifest, generatedChannelConfigs); + const changed = JSON.stringify(augmentedManifest) !== JSON.stringify(manifest); + return { + manifestPath, + pluginId, + changed, + manifest: augmentedManifest, + reason: changed ? "generated-channel-configs" : "unchanged", + }; +} + +export function withAugmentedPluginNpmManifestForPackage(params, callback) { + const repoRoot = path.resolve(params.repoRoot ?? "."); + const packageDir = resolvePackageDir(repoRoot, params.packageDir); + const resolved = resolveAugmentedPluginNpmManifest({ + repoRoot, + packageDir, + }); + + if (!resolved.changed || !resolved.manifest) { + return callback({ + ...resolved, + packageDir, + repoRoot, + applied: false, + }); + } + + const originalManifest = fs.readFileSync(resolved.manifestPath, "utf8"); + console.error( + `[plugin-npm-publish] overlaying generated channel config metadata for ${resolved.pluginId}`, + ); + writeJsonFile(resolved.manifestPath, resolved.manifest); + try { + return callback({ + ...resolved, + packageDir, + repoRoot, + applied: true, + }); + } finally { + fs.writeFileSync(resolved.manifestPath, originalManifest, "utf8"); + } +} + +function parseRunArgs(argv) { + if (argv[0] !== "--run") { + throw new Error( + "usage: node scripts/lib/plugin-npm-package-manifest.mjs --run -- [args...]", + ); + } + const packageDir = argv[1]; + const separatorIndex = argv.indexOf("--", 2); + if (!packageDir || separatorIndex === -1 || separatorIndex === argv.length - 1) { + throw new Error( + "usage: node scripts/lib/plugin-npm-package-manifest.mjs --run -- [args...]", + ); + } + return { + packageDir, + command: argv[separatorIndex + 1], + args: argv.slice(separatorIndex + 2), + }; +} + +export function main(argv = process.argv.slice(2)) { + const { packageDir, command, args } = parseRunArgs(argv); + return withAugmentedPluginNpmManifestForPackage({ packageDir }, ({ packageDir: cwd }) => { + const result = spawnSync(command, args, { + cwd, + env: process.env, + stdio: "inherit", + }); + if (result.error) { + throw result.error; + } + return result.status ?? 1; + }); +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { + try { + process.exitCode = main(); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + } +} diff --git a/scripts/plugin-npm-publish.sh b/scripts/plugin-npm-publish.sh index 436a9192361..93b418ad6db 100644 --- a/scripts/plugin-npm-publish.sh +++ b/scripts/plugin-npm-publish.sh @@ -5,8 +5,8 @@ set -euo pipefail mode="${1:-}" package_dir="${2:-}" -if [[ "${mode}" != "--dry-run" && "${mode}" != "--publish" ]]; then - echo "usage: bash scripts/plugin-npm-publish.sh [--dry-run|--publish] " >&2 +if [[ "${mode}" != "--dry-run" && "${mode}" != "--pack-dry-run" && "${mode}" != "--publish" ]]; then + echo "usage: bash scripts/plugin-npm-publish.sh [--dry-run|--pack-dry-run|--publish] " >&2 exit 2 fi @@ -18,6 +18,13 @@ fi package_name="$(node -e 'const pkg = require(require("node:path").resolve(process.argv[1], "package.json")); console.log(pkg.name)' "${package_dir}")" package_version="$(node -e 'const pkg = require(require("node:path").resolve(process.argv[1], "package.json")); console.log(pkg.version)' "${package_dir}")" current_beta_version="$(npm view "${package_name}" dist-tags.beta 2>/dev/null || true)" +log() { + if [[ "${mode}" == "--pack-dry-run" ]]; then + printf '%s\n' "$*" >&2 + else + printf '%s\n' "$*" + fi +} publish_plan_output="$( PACKAGE_VERSION="${package_version}" CURRENT_BETA_VERSION="${current_beta_version}" PUBLISH_MODE="${mode}" node --input-type=module <<'EOF' import { @@ -55,15 +62,15 @@ mirror_auth_source="${mirror_auth_source:-none}" mirror_auth_requirement="${mirror_auth_requirement:-optional}" publish_cmd=(npm publish --access public --tag "${publish_tag}" --provenance) -echo "Resolved package dir: ${package_dir}" -echo "Resolved package name: ${package_name}" -echo "Resolved package version: ${package_version}" -echo "Current beta dist-tag: ${current_beta_version:-}" -echo "Resolved release channel: ${release_channel}" -echo "Resolved publish tag: ${publish_tag}" -echo "Resolved mirror dist-tags: ${mirror_dist_tags_csv:-}" -echo "Mirror dist-tag auth source: ${mirror_auth_source}" -echo "Mirror dist-tag auth requirement: ${mirror_auth_requirement}" +log "Resolved package dir: ${package_dir}" +log "Resolved package name: ${package_name}" +log "Resolved package version: ${package_version}" +log "Current beta dist-tag: ${current_beta_version:-}" +log "Resolved release channel: ${release_channel}" +log "Resolved publish tag: ${publish_tag}" +log "Resolved mirror dist-tags: ${mirror_dist_tags_csv:-}" +log "Mirror dist-tag auth source: ${mirror_auth_source}" +log "Mirror dist-tag auth requirement: ${mirror_auth_requirement}" mirror_auth_token="" case "${mirror_auth_source}" in @@ -77,9 +84,9 @@ esac publish_auth_token="${mirror_auth_token}" publish_auth_source="${mirror_auth_source}" if [[ -n "${publish_auth_token}" ]]; then - echo "Publish auth: ${publish_auth_source} with provenance" + log "Publish auth: ${publish_auth_source} with provenance" else - echo "Publish auth: GitHub OIDC trusted publishing" + log "Publish auth: GitHub OIDC trusted publishing" fi if [[ "${mirror_auth_requirement}" == "required" && -z "${mirror_auth_token}" ]]; then @@ -88,27 +95,43 @@ if [[ "${mirror_auth_requirement}" == "required" && -z "${mirror_auth_token}" ]] exit 1 fi -printf 'Publish command:' -printf ' %q' "${publish_cmd[@]}" -printf '\n' +if [[ "${mode}" == "--pack-dry-run" ]]; then + { + printf 'Publish command:' + printf ' %q' "${publish_cmd[@]}" + printf '\n' + } >&2 +else + printf 'Publish command:' + printf ' %q' "${publish_cmd[@]}" + printf '\n' +fi if [[ "${mode}" == "--dry-run" ]]; then exit 0 fi +if [[ "${mode}" == "--pack-dry-run" ]]; then + node scripts/lib/plugin-npm-package-manifest.mjs --run "${package_dir}" -- \ + npm pack --dry-run --json --ignore-scripts + exit 0 +fi + ( - cd "${package_dir}" cleanup_files=() trap 'rm -f "${cleanup_files[@]}"' EXIT + run_with_manifest_overlay() { + node scripts/lib/plugin-npm-package-manifest.mjs --run "${package_dir}" -- "$@" + } publish_userconfig="" if [[ -n "${publish_auth_token}" ]]; then publish_userconfig="$(mktemp)" cleanup_files+=("${publish_userconfig}") chmod 0600 "${publish_userconfig}" printf '%s\n' "//registry.npmjs.org/:_authToken=${publish_auth_token}" > "${publish_userconfig}" - NPM_CONFIG_USERCONFIG="${publish_userconfig}" "${publish_cmd[@]}" + NPM_CONFIG_USERCONFIG="${publish_userconfig}" run_with_manifest_overlay "${publish_cmd[@]}" else - "${publish_cmd[@]}" + run_with_manifest_overlay "${publish_cmd[@]}" fi if [[ -n "${mirror_dist_tags_csv}" ]]; then diff --git a/test/plugin-npm-package-manifest.test.ts b/test/plugin-npm-package-manifest.test.ts new file mode 100644 index 00000000000..f91385ed530 --- /dev/null +++ b/test/plugin-npm-package-manifest.test.ts @@ -0,0 +1,93 @@ +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + resolveAugmentedPluginNpmManifest, + withAugmentedPluginNpmManifestForPackage, +} from "../scripts/lib/plugin-npm-package-manifest.mjs"; +import { cleanupTempDirs, makeTempRepoRoot, writeJsonFile } from "./helpers/temp-repo.js"; + +const tempDirs: string[] = []; + +afterEach(() => { + cleanupTempDirs(tempDirs); +}); + +function writeGeneratedChannelMetadata(repoDir: string): void { + const metadataPath = join( + repoDir, + "src", + "config", + "bundled-channel-config-metadata.generated.ts", + ); + mkdirSync(join(repoDir, "src", "config"), { recursive: true }); + writeFileText( + metadataPath, + `export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [ + { + pluginId: "twitch", + channelId: "twitch", + label: "Twitch", + description: "Twitch chat integration", + schema: { + type: "object", + required: ["channelName"], + properties: { + channelName: { type: "string" }, + }, + }, + }, +] as const; +`, + ); +} + +function writeFileText(filePath: string, text: string): void { + mkdirSync(dirname(filePath), { recursive: true }); + // writeJsonFile intentionally owns JSON formatting only. + writeFileSync(filePath, text, "utf8"); +} + +describe("plugin npm package manifest staging", () => { + 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"); + mkdirSync(packageDir, { recursive: true }); + const sourceManifest = { + id: "twitch", + channels: ["twitch"], + configSchema: { + type: "object", + additionalProperties: false, + properties: {}, + }, + }; + writeJsonFile(join(packageDir, "openclaw.plugin.json"), sourceManifest); + writeGeneratedChannelMetadata(repoDir); + + const resolved = resolveAugmentedPluginNpmManifest({ + repoRoot: repoDir, + packageDir, + }); + expect(resolved.changed).toBe(true); + expect(resolved.manifest).toMatchObject({ + channelConfigs: { + twitch: { + label: "Twitch", + schema: { + required: ["channelName"], + }, + }, + }, + }); + + const originalText = readFileSync(join(packageDir, "openclaw.plugin.json"), "utf8"); + withAugmentedPluginNpmManifestForPackage({ repoRoot: repoDir, packageDir }, () => { + const stagedManifest = JSON.parse( + readFileSync(join(packageDir, "openclaw.plugin.json"), "utf8"), + ); + expect(stagedManifest.channelConfigs.twitch.description).toBe("Twitch chat integration"); + }); + expect(readFileSync(join(packageDir, "openclaw.plugin.json"), "utf8")).toBe(originalText); + }); +});