// Resolves changed bundled extension ids from git diff paths. import { execFileSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; import { BUNDLED_PLUGIN_PATH_PREFIX, BUNDLED_PLUGIN_ROOT_DIR } from "./bundled-plugin-paths.mjs"; const repoRoot = path.resolve(import.meta.dirname, "..", ".."); const EXTENSION_METADATA_FILES = ["package.json", "openclaw.plugin.json"]; /** Bundled plugins can declare their identity without an npm package. */ export function hasExtensionMetadata(directory: string): boolean { return EXTENSION_METADATA_FILES.some((file) => fs.existsSync(path.join(directory, file))); } type ChangedPathsBaseParams = Partial>; type ChangedExtensionParams = Partial> & { unavailableBaseBehavior?: "all" | "empty" | "error"; }; function runGit(args: string[], cwd = repoRoot) { return execFileSync("git", args, { cwd, stdio: ["ignore", "pipe", "pipe"], encoding: "utf8", }); } function hasGitCommit(ref: string | undefined) { if (!ref || /^0+$/.test(ref)) { return false; } try { runGit(["rev-parse", "--verify", `${ref}^{commit}`]); return true; } catch { return false; } } function resolveChangedPathsBase(params: ChangedPathsBaseParams = {}) { const base = params.base; const head = params.head ?? "HEAD"; const fallbackBaseRef = params.fallbackBaseRef; if (base && hasGitCommit(base)) { return base; } if (fallbackBaseRef) { const remoteBaseRef = fallbackBaseRef.startsWith("origin/") ? fallbackBaseRef : `origin/${fallbackBaseRef}`; if (hasGitCommit(remoteBaseRef)) { const mergeBase = runGit(["merge-base", remoteBaseRef, head]).trim(); if (hasGitCommit(mergeBase)) { return mergeBase; } } } if (!base) { throw new Error("A git base revision is required to list changed extensions."); } throw new Error(`Git base revision is unavailable locally: ${base}`); } function listChangedPaths(base: string, head = "HEAD") { if (!base) { throw new Error("A git base revision is required to list changed extensions."); } return runGit(["diff", "--name-only", "-z", base, head]).split("\0").filter(Boolean); } function listAvailableExtensionIdsFromGit(cwd: string) { const metadataFiles = runGit( [ "ls-files", "-z", "--", ...EXTENSION_METADATA_FILES.map((file) => `:(glob)${BUNDLED_PLUGIN_PATH_PREFIX}*/${file}`), ], cwd, ) .split("\0") .filter(Boolean); const extensionIds = metadataFiles.flatMap((file) => { const match = file.match(new RegExp(`^${BUNDLED_PLUGIN_PATH_PREFIX}([^/]+)/[^/]+$`)); return match?.[1] ? [match[1]] : []; }); return [...new Set(extensionIds)].toSorted((left, right) => left.localeCompare(right)); } function listAvailableExtensionIdsFromDirectory(cwd: string) { const extensionsDir = path.join(cwd, BUNDLED_PLUGIN_ROOT_DIR); if (!fs.existsSync(extensionsDir)) { return []; } return fs .readdirSync(extensionsDir, { withFileTypes: true }) .filter((entry) => entry.isDirectory()) .map((entry) => entry.name) .filter((extensionId) => hasExtensionMetadata(path.join(cwd, BUNDLED_PLUGIN_ROOT_DIR, extensionId)), ) .toSorted((left, right) => left.localeCompare(right)); } /** List bundled extension ids available in git or the local extensions directory. */ export function listAvailableExtensionIds(cwd = repoRoot) { try { return listAvailableExtensionIdsFromGit(cwd); } catch { return listAvailableExtensionIdsFromDirectory(cwd); } } /** Map changed paths to bundled extension ids, ignoring unknown extension-like paths. */ export function detectChangedExtensionIds(changedPaths: string[]) { const availableExtensionIds = new Set(listAvailableExtensionIds()); const extensionIds = new Set(); for (const relativePath of changedPaths) { if (!relativePath) { continue; } const extensionMatch = relativePath.match( new RegExp(`^${BUNDLED_PLUGIN_PATH_PREFIX.replace("/", "\\/")}([^/]+)(?:/|$)`), ); if (extensionMatch) { const extensionId = extensionMatch[1]; if (extensionId && availableExtensionIds.has(extensionId)) { extensionIds.add(extensionId); } continue; } const pairedCoreMatch = relativePath.match(/^src\/([^/]+)(?:\/|$)/); const pairedExtensionId = pairedCoreMatch?.[1]; if (pairedExtensionId && availableExtensionIds.has(pairedExtensionId)) { extensionIds.add(pairedExtensionId); } } return [...extensionIds].toSorted((left, right) => left.localeCompare(right)); } /** List changed bundled extension ids between a resolved base and head revision. */ export function listChangedExtensionIds(params: ChangedExtensionParams = {}) { const head = params.head ?? "HEAD"; const unavailableBaseBehavior = params.unavailableBaseBehavior ?? "error"; try { const base = resolveChangedPathsBase(params); return detectChangedExtensionIds(listChangedPaths(base, head)); } catch (error) { if (unavailableBaseBehavior === "all") { return listAvailableExtensionIds(); } if (unavailableBaseBehavior === "empty") { return []; } throw error; } }