build: commit vendored shared skill packages

This commit is contained in:
Jim Liu 宝玉
2026-03-11 20:45:25 -05:00
parent 069c5dc7d7
commit 3bba18c1fe
32 changed files with 2914 additions and 116 deletions
+259
View File
@@ -0,0 +1,259 @@
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 consumers = await discoverSkillScriptPackages(root);
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(),
};
}
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) {
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");
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}`);
}
}
+26 -96
View File
@@ -81,27 +81,8 @@ export async function listTextFiles(root) {
}
export async function collectReleaseFiles(root) {
const baseFiles = await listTextFiles(root);
const fileMap = new Map(baseFiles.map((file) => [file.relPath, file.bytes]));
const vendoredPackages = new Set();
for (const file of baseFiles.filter((entry) => path.posix.basename(entry.relPath) === "package.json")) {
const packageDirRel = normalizeDirRel(path.posix.dirname(file.relPath));
const rewritten = await rewritePackageJsonForRelease({
root,
packageDirRel,
bytes: file.bytes,
fileMap,
vendoredPackages,
});
if (rewritten) {
fileMap.set(file.relPath, rewritten);
}
}
return [...fileMap.entries()]
.map(([relPath, bytes]) => ({ relPath, bytes }))
.sort((left, right) => left.relPath.localeCompare(right.relPath));
await validateSelfContainedRelease(root);
return listTextFiles(root);
}
export async function materializeReleaseFiles(files, outDir) {
@@ -113,88 +94,37 @@ export async function materializeReleaseFiles(files, outDir) {
}
}
async function rewritePackageJsonForRelease({ root, packageDirRel, bytes, fileMap, vendoredPackages }) {
const packageJson = JSON.parse(bytes.toString("utf8"));
let changed = false;
export async function validateSelfContainedRelease(root) {
const files = await listTextFiles(root);
for (const file of files.filter((entry) => path.posix.basename(entry.relPath) === "package.json")) {
const packageDir = path.resolve(root, fromPosixRel(path.posix.dirname(file.relPath)));
const packageJson = JSON.parse(file.bytes.toString("utf8"));
for (const section of PACKAGE_DEPENDENCY_SECTIONS) {
const dependencies = packageJson[section];
if (!dependencies || typeof dependencies !== "object") continue;
for (const section of PACKAGE_DEPENDENCY_SECTIONS) {
const dependencies = packageJson[section];
if (!dependencies || typeof dependencies !== "object") continue;
for (const [name, spec] of Object.entries(dependencies)) {
if (typeof spec !== "string" || !spec.startsWith("file:")) continue;
const sourceDir = path.resolve(root, fromPosixRel(packageDirRel), spec.slice(5));
const vendorDirRel = normalizeDirRel(path.posix.join(packageDirRel, "vendor", name));
await vendorPackageTree({
sourceDir,
targetDirRel: vendorDirRel,
fileMap,
vendoredPackages,
});
dependencies[name] = toFileDependencySpec(packageDirRel, vendorDirRel);
changed = true;
for (const [name, spec] of Object.entries(dependencies)) {
if (typeof spec !== "string" || !spec.startsWith("file:")) continue;
const targetDir = path.resolve(packageDir, spec.slice(5));
if (!isWithinRoot(root, targetDir)) {
throw new Error(
`Release artifact is not self-contained: ${file.relPath} depends on ${name} via ${spec}`,
);
}
await fs.access(targetDir).catch(() => {
throw new Error(`Missing local dependency for release: ${file.relPath} -> ${spec}`);
});
}
}
}
if (!changed) return null;
return Buffer.from(`${JSON.stringify(packageJson, null, 2)}\n`);
}
async function vendorPackageTree({ sourceDir, targetDirRel, fileMap, vendoredPackages }) {
const dedupeKey = `${path.resolve(sourceDir)}=>${targetDirRel}`;
if (vendoredPackages.has(dedupeKey)) return;
vendoredPackages.add(dedupeKey);
const files = await listTextFiles(sourceDir);
if (files.length === 0) {
throw new Error(`Local package has no text files: ${sourceDir}`);
}
const packageJson = files.find((file) => file.relPath === "package.json");
if (!packageJson) {
throw new Error(`Local package is missing package.json: ${sourceDir}`);
}
for (const file of files) {
if (file.relPath === "package.json") continue;
fileMap.set(joinReleasePath(targetDirRel, file.relPath), file.bytes);
}
const rewrittenPackageJson = await rewritePackageJsonForRelease({
root: sourceDir,
packageDirRel: ".",
bytes: packageJson.bytes,
fileMap: {
set(relPath, outputBytes) {
fileMap.set(joinReleasePath(targetDirRel, relPath), outputBytes);
},
},
vendoredPackages,
});
fileMap.set(
joinReleasePath(targetDirRel, "package.json"),
rewrittenPackageJson ?? packageJson.bytes,
);
}
function normalizeDirRel(relPath) {
return relPath === "." ? "." : relPath.split(path.sep).join("/");
}
function fromPosixRel(relPath) {
return relPath === "." ? "." : relPath.split("/").join(path.sep);
}
function joinReleasePath(base, relPath) {
const joined = normalizeDirRel(path.posix.join(base === "." ? "" : base, relPath));
return joined.replace(/^\.\//, "");
}
function toFileDependencySpec(fromDirRel, targetDirRel) {
const fromDir = fromDirRel === "." ? "" : fromDirRel;
const relative = path.posix.relative(fromDir || ".", targetDirRel);
const normalized = relative === "" ? "." : relative;
return `file:${normalized.startsWith(".") ? normalized : `./${normalized}`}`;
function isWithinRoot(root, target) {
const resolvedRoot = path.resolve(root);
const relative = path.relative(resolvedRoot, path.resolve(target));
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
}