diff --git a/scripts/lib/plugin-clawhub-release.ts b/scripts/lib/plugin-clawhub-release.ts index e5057541dd2..7a6a62109ba 100644 --- a/scripts/lib/plugin-clawhub-release.ts +++ b/scripts/lib/plugin-clawhub-release.ts @@ -1,15 +1,15 @@ import { execFileSync } from "node:child_process"; -import { readdirSync, readFileSync } from "node:fs"; -import { join, resolve } from "node:path"; +import { resolve } from "node:path"; import { validateExternalCodePluginPackageJson } from "../../packages/plugin-package-contract/src/index.ts"; -import { parseReleaseVersion } from "../openclaw-npm-release-check.ts"; import { + collectExtensionPackageJsonCandidates, collectChangedPathsFromGitRange, collectChangedExtensionIdsFromPaths, collectPublishablePluginPackageErrors, parsePluginReleaseArgs, parsePluginReleaseSelection, parsePluginReleaseSelectionMode, + resolvePublishablePluginVersion, resolveGitCommitSha, resolveChangedPublishablePluginPackages, resolveSelectedPublishablePluginPackages, @@ -90,10 +90,6 @@ const CLAWHUB_SHARED_RELEASE_INPUT_PATHS = [ "scripts/plugin-clawhub-release-plan.ts", ] as const; -function readPluginPackageJson(path: string): PluginPackageJson { - return JSON.parse(readFileSync(path, "utf8")) as PluginPackageJson; -} - function getRegistryBaseUrl(explicit?: string) { return ( explicit?.trim() || @@ -106,63 +102,50 @@ function getRegistryBaseUrl(explicit?: string) { export function collectClawHubPublishablePluginPackages( rootDir = resolve("."), ): PublishablePluginPackage[] { - const extensionsDir = join(rootDir, "extensions"); - const dirs = readdirSync(extensionsDir, { withFileTypes: true }).filter((entry) => - entry.isDirectory(), - ); - const publishable: PublishablePluginPackage[] = []; const validationErrors: string[] = []; - for (const dir of dirs) { - const packageDir = join("extensions", dir.name); - const absolutePackageDir = join(extensionsDir, dir.name); - const packageJsonPath = join(absolutePackageDir, "package.json"); - let packageJson: PluginPackageJson; - try { - packageJson = readPluginPackageJson(packageJsonPath); - } catch { - continue; - } - + for (const candidate of collectExtensionPackageJsonCandidates(rootDir)) { + const { extensionId, packageDir, packageJson } = candidate; if (packageJson.openclaw?.release?.publishToClawHub !== true) { continue; } - if (!SAFE_EXTENSION_ID_RE.test(dir.name)) { + if (!SAFE_EXTENSION_ID_RE.test(extensionId)) { validationErrors.push( - `${dir.name}: extension directory name must match ^[a-z0-9][a-z0-9._-]*$ for ClawHub publish.`, + `${extensionId}: extension directory name must match ^[a-z0-9][a-z0-9._-]*$ for ClawHub publish.`, ); continue; } const errors = collectPublishablePluginPackageErrors({ - extensionId: dir.name, + extensionId, packageDir, packageJson, }); if (errors.length > 0) { - validationErrors.push(...errors.map((error) => `${dir.name}: ${error}`)); + validationErrors.push(...errors.map((error) => `${extensionId}: ${error}`)); continue; } const contractValidation = validateExternalCodePluginPackageJson(packageJson); if (contractValidation.issues.length > 0) { validationErrors.push( - ...contractValidation.issues.map((issue) => `${dir.name}: ${issue.message}`), + ...contractValidation.issues.map((issue) => `${extensionId}: ${issue.message}`), ); continue; } - const version = packageJson.version!.trim(); - const parsedVersion = parseReleaseVersion(version); - if (parsedVersion === null) { - validationErrors.push( - `${dir.name}: package.json version must match YYYY.M.D, YYYY.M.D-N, or YYYY.M.D-beta.N; found "${version}".`, - ); + const resolvedVersion = resolvePublishablePluginVersion({ + extensionId, + packageJson, + validationErrors, + }); + if (!resolvedVersion) { continue; } + const { version, parsedVersion } = resolvedVersion; publishable.push({ - extensionId: dir.name, + extensionId, packageDir, packageName: packageJson.name!.trim(), version, diff --git a/scripts/lib/plugin-npm-release.ts b/scripts/lib/plugin-npm-release.ts index 449d0514888..4f646a40bae 100644 --- a/scripts/lib/plugin-npm-release.ts +++ b/scripts/lib/plugin-npm-release.ts @@ -55,14 +55,61 @@ export type ParsedPluginReleaseArgs = { headRef?: string; }; -type PublishablePluginPackageCandidate = { +export type PublishablePluginPackageCandidate< + TPackageJson extends PluginPackageJson = PluginPackageJson, +> = { extensionId: string; packageDir: string; - packageJson: PluginPackageJson; + packageJson: TPackageJson; }; -function readPluginPackageJson(path: string): PluginPackageJson { - return JSON.parse(readFileSync(path, "utf8")) as PluginPackageJson; +function readPluginPackageJson( + path: string, +): TPackageJson { + return JSON.parse(readFileSync(path, "utf8")) as TPackageJson; +} + +export function collectExtensionPackageJsonCandidates< + TPackageJson extends PluginPackageJson = PluginPackageJson, +>(rootDir = resolve(".")): PublishablePluginPackageCandidate[] { + const extensionsDir = join(rootDir, "extensions"); + const dirs = readdirSync(extensionsDir, { withFileTypes: true }).filter((entry) => + entry.isDirectory(), + ); + + const candidates: PublishablePluginPackageCandidate[] = []; + for (const dir of dirs) { + const packageDir = join("extensions", dir.name); + const absolutePackageDir = join(extensionsDir, dir.name); + const packageJsonPath = join(absolutePackageDir, "package.json"); + try { + candidates.push({ + extensionId: dir.name, + packageDir, + packageJson: readPluginPackageJson(packageJsonPath), + }); + } catch { + continue; + } + } + + return candidates; +} + +export function resolvePublishablePluginVersion(params: { + extensionId: string; + packageJson: Pick; + validationErrors: string[]; +}): { version: string; parsedVersion: NonNullable> } | null { + const version = params.packageJson.version?.trim() ?? ""; + const parsedVersion = parseReleaseVersion(version); + if (parsedVersion === null) { + params.validationErrors.push( + `${params.extensionId}: package.json version must match YYYY.M.D, YYYY.M.D-N, or YYYY.M.D-beta.N; found "${version}".`, + ); + return null; + } + return { version, parsedVersion }; } export function normalizeGitDiffPath(path: string): string { @@ -191,51 +238,33 @@ export function collectPublishablePluginPackageErrors( export function collectPublishablePluginPackages( rootDir = resolve("."), ): PublishablePluginPackage[] { - const extensionsDir = join(rootDir, "extensions"); - const dirs = readdirSync(extensionsDir, { withFileTypes: true }).filter((entry) => - entry.isDirectory(), - ); - const publishable: PublishablePluginPackage[] = []; const validationErrors: string[] = []; - for (const dir of dirs) { - const packageDir = join("extensions", dir.name); - const absolutePackageDir = join(extensionsDir, dir.name); - const packageJsonPath = join(absolutePackageDir, "package.json"); - let packageJson: PluginPackageJson; - try { - packageJson = readPluginPackageJson(packageJsonPath); - } catch { - continue; - } - + for (const candidate of collectExtensionPackageJsonCandidates(rootDir)) { + const { extensionId, packageDir, packageJson } = candidate; if (packageJson.openclaw?.release?.publishToNpm !== true) { continue; } - const candidate = { - extensionId: dir.name, - packageDir, - packageJson, - } satisfies PublishablePluginPackageCandidate; const errors = collectPublishablePluginPackageErrors(candidate); if (errors.length > 0) { - validationErrors.push(...errors.map((error) => `${dir.name}: ${error}`)); + validationErrors.push(...errors.map((error) => `${extensionId}: ${error}`)); continue; } - const version = packageJson.version!.trim(); - const parsedVersion = parseReleaseVersion(version); - if (parsedVersion === null) { - validationErrors.push( - `${dir.name}: package.json version must match YYYY.M.D, YYYY.M.D-N, or YYYY.M.D-beta.N; found "${version}".`, - ); + const resolvedVersion = resolvePublishablePluginVersion({ + extensionId, + packageJson, + validationErrors, + }); + if (!resolvedVersion) { continue; } + const { version, parsedVersion } = resolvedVersion; publishable.push({ - extensionId: dir.name, + extensionId, packageDir, packageName: packageJson.name!.trim(), version, diff --git a/test/plugin-npm-release.test.ts b/test/plugin-npm-release.test.ts index 7ffc034809a..5d28dfc7e1b 100644 --- a/test/plugin-npm-release.test.ts +++ b/test/plugin-npm-release.test.ts @@ -1,5 +1,8 @@ -import { describe, expect, it } from "vitest"; +import { mkdirSync } from "node:fs"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; import { + collectPublishablePluginPackages, collectChangedExtensionIdsFromPaths, collectPublishablePluginPackageErrors, parsePluginReleaseArgs, @@ -10,6 +13,13 @@ import { type PublishablePluginPackage, } from "../scripts/lib/plugin-npm-release.ts"; import { bundledPluginFile, bundledPluginRoot } from "./helpers/bundled-plugin-paths.js"; +import { cleanupTempDirs, makeTempRepoRoot, writeJsonFile } from "./helpers/temp-repo.js"; + +const tempDirs: string[] = []; + +afterEach(() => { + cleanupTempDirs(tempDirs); +}); describe("parsePluginReleaseSelection", () => { it("returns an empty list for blank input", () => { @@ -117,6 +127,38 @@ describe("collectPublishablePluginPackageErrors", () => { }); }); +describe("collectPublishablePluginPackages", () => { + it("collects publishable npm plugins from extension package manifests", () => { + const repoDir = makeTempRepoRoot(tempDirs, "openclaw-plugin-npm-release-"); + mkdirSync(join(repoDir, "extensions", "demo-plugin"), { recursive: true }); + writeJsonFile(join(repoDir, "extensions", "demo-plugin", "package.json"), { + name: "@openclaw/demo-plugin", + version: "2026.4.6", + openclaw: { + extensions: ["./index.ts"], + install: { + npmSpec: "@openclaw/demo-plugin", + }, + release: { + publishToNpm: true, + }, + }, + }); + + expect(collectPublishablePluginPackages(repoDir)).toEqual([ + { + extensionId: "demo-plugin", + packageDir: "extensions/demo-plugin", + packageName: "@openclaw/demo-plugin", + version: "2026.4.6", + channel: "stable", + publishTag: "latest", + installNpmSpec: "@openclaw/demo-plugin", + }, + ]); + }); +}); + describe("resolveSelectedPublishablePluginPackages", () => { const publishablePlugins: PublishablePluginPackage[] = [ {