import { spawnSync } from "node:child_process"; import { existsSync } from "node:fs"; import fs from "node:fs/promises"; import path from "node:path"; const PACKAGE_DEPENDENCY_SECTIONS = [ "dependencies", "optionalDependencies", "peerDependencies", "devDependencies", ]; const SKIPPED_DIRS = new Set([".git", ".clawhub", ".clawdhub", "node_modules"]); const SKIPPED_FILES = new Set([".DS_Store"]); export async function syncSharedSkillPackages(repoRoot, options = {}) { const root = path.resolve(repoRoot); const workspacePackages = await discoverWorkspacePackages(root); const targetConsumerDirs = normalizeTargetConsumerDirs(root, options.targets ?? []); const consumers = await discoverSkillScriptPackages(root, targetConsumerDirs); const runtime = options.install === false ? null : resolveBunRuntime(); const managedPaths = new Set(); const packageDirs = []; for (const consumer of consumers) { const result = await syncConsumerPackage({ consumer, root, workspacePackages, runtime, }); if (!result) continue; packageDirs.push(consumer.dir); for (const managedPath of result.managedPaths) { managedPaths.add(managedPath); } } return { packageDirs, managedPaths: [...managedPaths].sort(), }; } function normalizeTargetConsumerDirs(repoRoot, targets) { if (!targets || targets.length === 0) return null; const consumerDirs = new Set(); for (const target of targets) { if (!target) continue; const resolvedTarget = path.resolve(repoRoot, target); if (path.basename(resolvedTarget) === "scripts") { consumerDirs.add(resolvedTarget); continue; } consumerDirs.add(path.join(resolvedTarget, "scripts")); } return consumerDirs; } export function ensureManagedPathsClean(repoRoot, managedPaths) { if (managedPaths.length === 0) return; const result = spawnSync("git", ["status", "--porcelain", "--", ...managedPaths], { cwd: repoRoot, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], }); if (result.status !== 0) { throw new Error(result.stderr.trim() || "Failed to inspect git status for managed paths"); } const output = result.stdout.trim(); if (!output) return; throw new Error( [ "Shared skill package sync produced uncommitted managed changes.", "Review and commit these files before pushing:", output, ].join("\n"), ); } async function syncConsumerPackage({ consumer, root, workspacePackages, runtime }) { const packageJsonPath = path.join(consumer.dir, "package.json"); const packageJson = JSON.parse(await fs.readFile(packageJsonPath, "utf8")); const localDeps = collectLocalDependencies(packageJson, workspacePackages); if (localDeps.length === 0) { return null; } const vendorRoot = path.join(consumer.dir, "vendor"); await fs.rm(vendorRoot, { recursive: true, force: true }); for (const name of localDeps) { const sourceDir = workspacePackages.get(name); if (!sourceDir) continue; await syncPackageTree({ sourceDir, targetDir: path.join(vendorRoot, name), workspacePackages, }); } rewriteLocalDependencySpecs(packageJson, localDeps); await writeJson(packageJsonPath, packageJson); if (runtime) { runInstall(runtime, consumer.dir); } const managedPaths = [ path.relative(root, packageJsonPath).split(path.sep).join("/"), path.relative(root, path.join(consumer.dir, "bun.lock")).split(path.sep).join("/"), path.relative(root, vendorRoot).split(path.sep).join("/"), ]; return { managedPaths }; } async function syncPackageTree({ sourceDir, targetDir, workspacePackages }) { await fs.rm(targetDir, { recursive: true, force: true }); await fs.mkdir(targetDir, { recursive: true }); const sourcePackageJsonPath = path.join(sourceDir, "package.json"); const packageJson = JSON.parse(await fs.readFile(sourcePackageJsonPath, "utf8")); const localDeps = collectLocalDependencies(packageJson, workspacePackages); const entries = await fs.readdir(sourceDir, { withFileTypes: true }); for (const entry of entries) { if (SKIPPED_DIRS.has(entry.name) || SKIPPED_FILES.has(entry.name)) continue; const sourcePath = path.join(sourceDir, entry.name); const targetPath = path.join(targetDir, entry.name); if (entry.isDirectory()) { await copyDirectory(sourcePath, targetPath); continue; } if (!entry.isFile() || entry.name === "package.json") continue; await fs.mkdir(path.dirname(targetPath), { recursive: true }); await fs.copyFile(sourcePath, targetPath); } for (const name of localDeps) { const nestedSourceDir = workspacePackages.get(name); if (!nestedSourceDir) continue; await syncPackageTree({ sourceDir: nestedSourceDir, targetDir: path.join(targetDir, "vendor", name), workspacePackages, }); } rewriteLocalDependencySpecs(packageJson, localDeps); await writeJson(path.join(targetDir, "package.json"), packageJson); } async function copyDirectory(sourceDir, targetDir) { await fs.mkdir(targetDir, { recursive: true }); const entries = await fs.readdir(sourceDir, { withFileTypes: true }); for (const entry of entries) { if (SKIPPED_DIRS.has(entry.name) || SKIPPED_FILES.has(entry.name)) continue; const sourcePath = path.join(sourceDir, entry.name); const targetPath = path.join(targetDir, entry.name); if (entry.isDirectory()) { await copyDirectory(sourcePath, targetPath); continue; } if (!entry.isFile()) continue; await fs.mkdir(path.dirname(targetPath), { recursive: true }); await fs.copyFile(sourcePath, targetPath); } } async function discoverWorkspacePackages(repoRoot) { const packagesRoot = path.join(repoRoot, "packages"); const map = new Map(); if (!existsSync(packagesRoot)) return map; const entries = await fs.readdir(packagesRoot, { withFileTypes: true }); for (const entry of entries) { if (!entry.isDirectory()) continue; const packageJsonPath = path.join(packagesRoot, entry.name, "package.json"); if (!existsSync(packageJsonPath)) continue; const packageJson = JSON.parse(await fs.readFile(packageJsonPath, "utf8")); if (!packageJson.name) continue; map.set(packageJson.name, path.join(packagesRoot, entry.name)); } return map; } async function discoverSkillScriptPackages(repoRoot, targetConsumerDirs = null) { const skillsRoot = path.join(repoRoot, "skills"); const consumers = []; const skillEntries = await fs.readdir(skillsRoot, { withFileTypes: true }); for (const entry of skillEntries) { if (!entry.isDirectory()) continue; const scriptsDir = path.join(skillsRoot, entry.name, "scripts"); if (targetConsumerDirs && !targetConsumerDirs.has(path.resolve(scriptsDir))) continue; const packageJsonPath = path.join(scriptsDir, "package.json"); if (!existsSync(packageJsonPath)) continue; consumers.push({ dir: scriptsDir, packageJsonPath }); } return consumers.sort((left, right) => left.dir.localeCompare(right.dir)); } function collectLocalDependencies(packageJson, workspacePackages) { const localDeps = []; for (const section of PACKAGE_DEPENDENCY_SECTIONS) { const dependencies = packageJson[section]; if (!dependencies || typeof dependencies !== "object") continue; for (const name of Object.keys(dependencies)) { if (!workspacePackages.has(name)) continue; localDeps.push(name); } } return [...new Set(localDeps)].sort(); } function rewriteLocalDependencySpecs(packageJson, localDeps) { for (const section of PACKAGE_DEPENDENCY_SECTIONS) { const dependencies = packageJson[section]; if (!dependencies || typeof dependencies !== "object") continue; for (const name of localDeps) { if (!(name in dependencies)) continue; dependencies[name] = `file:./vendor/${name}`; } } } async function writeJson(filePath, value) { await fs.mkdir(path.dirname(filePath), { recursive: true }); await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`); } function resolveBunRuntime() { if (commandExists("bun")) { return { command: "bun", args: [] }; } if (commandExists("npx")) { return { command: "npx", args: ["-y", "bun"] }; } throw new Error( "Neither bun nor npx is installed. Install bun with `brew install oven-sh/bun/bun` or `npm install -g bun`.", ); } function commandExists(command) { const result = spawnSync("sh", ["-lc", `command -v ${command}`], { stdio: "ignore", }); return result.status === 0; } function runInstall(runtime, cwd) { const result = spawnSync(runtime.command, [...runtime.args, "install"], { cwd, stdio: "inherit", }); if (result.status !== 0) { throw new Error(`Failed to refresh Bun dependencies in ${cwd}`); } }