Use npm packages for shared skill code (#136)

This commit is contained in:
Jim Liu 宝玉
2026-04-18 21:09:58 -05:00
committed by GitHub
parent 9977ff520c
commit 5b20f9a746
401 changed files with 157775 additions and 24357 deletions
+167
View File
@@ -0,0 +1,167 @@
#!/usr/bin/env node
import { spawnSync } from "node:child_process";
import fs from "node:fs/promises";
import path from "node:path";
import process from "node:process";
import { pathToFileURL } from "node:url";
async function main() {
const options = parseArgs(process.argv.slice(2));
const packageDir = path.resolve(options.packageDir);
const entryPath = path.resolve(packageDir, options.entry);
const outDir = path.resolve(packageDir, options.outDir);
await fs.rm(outDir, { recursive: true, force: true });
await fs.mkdir(outDir, { recursive: true });
const bun = resolveBunRuntime();
runBuild(bun, {
entryPath,
external: options.external,
format: "esm",
outfile: path.join(outDir, "index.js"),
});
const cjsOutfile = path.join(outDir, "index.cjs");
runBuild(bun, {
entryPath,
external: options.external,
format: "cjs",
outfile: cjsOutfile,
});
await scrubCommonJsSourceFileUrls(cjsOutfile, packageDir);
for (const asset of options.assets) {
const source = path.resolve(packageDir, asset.source);
const target = path.resolve(outDir, asset.target);
await fs.cp(source, target, { recursive: true });
}
}
function parseArgs(argv) {
const options = {
packageDir: process.cwd(),
entry: "src/index.ts",
outDir: "dist",
external: [],
assets: [],
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--package-dir") {
options.packageDir = readArgValue(argv, ++index, arg);
continue;
}
if (arg === "--entry") {
options.entry = readArgValue(argv, ++index, arg);
continue;
}
if (arg === "--out-dir") {
options.outDir = readArgValue(argv, ++index, arg);
continue;
}
if (arg === "--external") {
options.external.push(readArgValue(argv, ++index, arg));
continue;
}
if (arg === "--asset") {
options.assets.push(parseAssetArg(readArgValue(argv, ++index, arg)));
continue;
}
if (arg === "-h" || arg === "--help") {
printUsage();
process.exit(0);
}
throw new Error(`Unknown argument: ${arg}`);
}
return options;
}
function readArgValue(argv, index, flag) {
const value = argv[index];
if (!value) {
throw new Error(`${flag} requires a value`);
}
return value;
}
function parseAssetArg(value) {
const [source, target = path.basename(value)] = value.split(":");
if (!source) {
throw new Error(`Invalid --asset value: ${value}`);
}
return { source, target };
}
function resolveBunRuntime() {
if (process.versions.bun) {
return { command: process.execPath, args: [] };
}
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 runBuild(runtime, { entryPath, external, format, outfile }) {
const args = [
...runtime.args,
"build",
entryPath,
"--target=node",
`--format=${format}`,
...external.flatMap((name) => ["--external", name]),
"--outfile",
outfile,
];
const result = spawnSync(runtime.command, args, {
cwd: process.cwd(),
stdio: "inherit",
});
if (result.status !== 0) {
throw new Error(`Failed to build ${format.toUpperCase()} package output`);
}
}
async function scrubCommonJsSourceFileUrls(outfile, packageDir) {
const packageUrl = pathToFileURL(packageDir).href;
const packageFileUrlPattern = new RegExp(`"${escapeRegExp(packageUrl)}/[^"]+"`, "g");
const source = await fs.readFile(outfile, "utf8");
await fs.writeFile(outfile, source.replace(packageFileUrlPattern, "undefined"));
}
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function printUsage() {
console.log(`Usage: build-shared-package.mjs [options]
Options:
--package-dir <dir> Package root (default: current directory)
--entry <file> Entry file relative to package root (default: src/index.ts)
--out-dir <dir> Output directory relative to package root (default: dist)
--external <name> Leave a module external (can be repeated)
--asset <src[:dst]> Copy an asset directory/file into out-dir (can be repeated)
-h, --help Show help`);
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});
-364
View File
@@ -1,364 +0,0 @@
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", ".changeset", ".clawhub", ".clawdhub", "node_modules"]);
const SKIPPED_FILES = new Set([".DS_Store"]);
const TEST_DIR_NAMES = new Set(["__tests__", "test", "tests"]);
const TEST_FILE_PATTERN = /\.(test|spec)\.[^.]+$/;
const CHANGELOG_FILE_PATTERN = /^CHANGELOG(?:\..+)?\.md$/i;
const UNSUPPORTED_FILES_GLOB_PATTERN = /[*?[\]{}!]/;
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);
await copyPackageContents({ sourceDir, targetDir, packageJson });
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 (shouldSkipEntry(entry)) 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 copyPackageContents({ sourceDir, targetDir, packageJson }) {
const includedPaths = resolveIncludedPackagePaths(packageJson);
if (!includedPaths) {
const entries = await fs.readdir(sourceDir, { withFileTypes: true });
for (const entry of entries) {
if (shouldSkipEntry(entry)) 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);
}
return;
}
for (const relativePath of includedPaths) {
const sourcePath = path.join(sourceDir, relativePath);
const targetPath = path.join(targetDir, relativePath);
await copyPath(sourcePath, targetPath);
}
}
async function copyPath(sourcePath, targetPath) {
let stat;
try {
stat = await fs.lstat(sourcePath);
} catch (error) {
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") return;
throw error;
}
const name = path.basename(sourcePath);
if (stat.isDirectory()) {
if (shouldSkipName(name, { isDirectory: true })) return;
await copyDirectory(sourcePath, targetPath);
return;
}
if (!stat.isFile()) return;
if (name === "package.json" || shouldSkipName(name, { isFile: true })) return;
await fs.mkdir(path.dirname(targetPath), { recursive: true });
await fs.copyFile(sourcePath, targetPath);
}
function resolveIncludedPackagePaths(packageJson) {
if (!Array.isArray(packageJson.files)) return null;
const includedPaths = [];
for (const entry of packageJson.files) {
if (typeof entry !== "string") continue;
const normalized = normalizeIncludedPath(entry);
if (!normalized || normalized === "package.json") continue;
includedPaths.push(normalized);
}
return [...new Set(includedPaths)];
}
function normalizeIncludedPath(entry) {
const trimmed = entry.trim();
if (!trimmed) return null;
if (UNSUPPORTED_FILES_GLOB_PATTERN.test(trimmed)) {
throw new Error(`Unsupported package.json files entry: ${entry}`);
}
const normalized = path.posix.normalize(trimmed.replace(/\\/g, "/")).replace(/^(\.\/)+/, "");
if (!normalized || normalized === ".") return null;
if (path.posix.isAbsolute(normalized) || normalized.startsWith("../")) {
throw new Error(`Package file entry must stay within the package root: ${entry}`);
}
return normalized;
}
function shouldSkipEntry(entry) {
return shouldSkipName(entry.name, {
isDirectory: entry.isDirectory(),
isFile: entry.isFile(),
});
}
function shouldSkipName(name, { isDirectory = false, isFile = false } = {}) {
if (SKIPPED_DIRS.has(name) || SKIPPED_FILES.has(name)) return true;
if (isDirectory && TEST_DIR_NAMES.has(name)) return true;
if (isFile && (TEST_FILE_PATTERN.test(name) || CHANGELOG_FILE_PATTERN.test(name))) return true;
return false;
}
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}`);
}
}
-183
View File
@@ -1,183 +0,0 @@
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { syncSharedSkillPackages } from "./shared-skill-packages.mjs";
async function makeTempDir(prefix: string): Promise<string> {
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
}
async function writeFile(filePath: string, contents = ""): Promise<void> {
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, contents);
}
async function writeJson(filePath: string, value: unknown): Promise<void> {
await writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`);
}
test("syncSharedSkillPackages vendors workspace packages into skill scripts", async (t) => {
const root = await makeTempDir("baoyu-sync-shared-");
t.after(() => fs.rm(root, { recursive: true, force: true }));
await writeJson(path.join(root, "packages", "baoyu-md", "package.json"), {
name: "baoyu-md",
version: "1.0.0",
});
await writeFile(
path.join(root, "packages", "baoyu-md", "src", "index.ts"),
"export const markdown = true;\n",
);
await writeFile(
path.join(root, "packages", "baoyu-md", "src", "index.test.ts"),
"test('ignored', () => {});\n",
);
await writeFile(
path.join(root, "packages", "baoyu-md", "src", "__tests__", "helper.ts"),
"export const helper = true;\n",
);
await writeFile(
path.join(root, "packages", "baoyu-md", "tests", "setup.ts"),
"export const setup = true;\n",
);
await writeFile(
path.join(root, "packages", "baoyu-md", ".changeset", "demo.md"),
"---\n",
);
await writeFile(
path.join(root, "packages", "baoyu-md", "CHANGELOG.md"),
"# changelog\n",
);
const consumerDir = path.join(root, "skills", "demo-skill", "scripts");
await writeJson(path.join(consumerDir, "package.json"), {
name: "demo-skill-scripts",
version: "1.0.0",
dependencies: {
"baoyu-md": "^1.0.0",
kleur: "^4.1.5",
},
});
const result = await syncSharedSkillPackages(root, { install: false });
assert.deepEqual(result.packageDirs, [consumerDir]);
assert.deepEqual(result.managedPaths, [
"skills/demo-skill/scripts/bun.lock",
"skills/demo-skill/scripts/package.json",
"skills/demo-skill/scripts/vendor",
]);
const updatedPackageJson = JSON.parse(
await fs.readFile(path.join(consumerDir, "package.json"), "utf8"),
) as { dependencies: Record<string, string> };
assert.equal(updatedPackageJson.dependencies["baoyu-md"], "file:./vendor/baoyu-md");
assert.equal(updatedPackageJson.dependencies.kleur, "^4.1.5");
const vendoredPackageJson = JSON.parse(
await fs.readFile(path.join(consumerDir, "vendor", "baoyu-md", "package.json"), "utf8"),
) as { name: string };
assert.equal(vendoredPackageJson.name, "baoyu-md");
const vendoredFile = await fs.readFile(
path.join(consumerDir, "vendor", "baoyu-md", "src", "index.ts"),
"utf8",
);
assert.match(vendoredFile, /markdown = true/);
await assert.rejects(
fs.readFile(path.join(consumerDir, "vendor", "baoyu-md", "src", "index.test.ts"), "utf8"),
{ code: "ENOENT" },
);
await assert.rejects(
fs.readFile(path.join(consumerDir, "vendor", "baoyu-md", "src", "__tests__", "helper.ts"), "utf8"),
{ code: "ENOENT" },
);
await assert.rejects(
fs.readFile(path.join(consumerDir, "vendor", "baoyu-md", "tests", "setup.ts"), "utf8"),
{ code: "ENOENT" },
);
await assert.rejects(
fs.readFile(path.join(consumerDir, "vendor", "baoyu-md", ".changeset", "demo.md"), "utf8"),
{ code: "ENOENT" },
);
await assert.rejects(
fs.readFile(path.join(consumerDir, "vendor", "baoyu-md", "CHANGELOG.md"), "utf8"),
{ code: "ENOENT" },
);
});
test("syncSharedSkillPackages respects package.json files allowlists", async (t) => {
const root = await makeTempDir("baoyu-sync-files-");
t.after(() => fs.rm(root, { recursive: true, force: true }));
await writeJson(path.join(root, "packages", "demo-pkg", "package.json"), {
name: "demo-pkg",
version: "1.0.0",
files: ["README.md", "src", "CHANGELOG.md", ".changeset"],
});
await writeFile(
path.join(root, "packages", "demo-pkg", "README.md"),
"# Demo\n",
);
await writeFile(
path.join(root, "packages", "demo-pkg", "src", "index.ts"),
"export const demo = true;\n",
);
await writeFile(
path.join(root, "packages", "demo-pkg", "src", "index.test.ts"),
"test('ignored', () => {});\n",
);
await writeFile(
path.join(root, "packages", "demo-pkg", "docs", "private.md"),
"private\n",
);
await writeFile(
path.join(root, "packages", "demo-pkg", "CHANGELOG.md"),
"# changelog\n",
);
await writeFile(
path.join(root, "packages", "demo-pkg", ".changeset", "demo.md"),
"---\n",
);
const consumerDir = path.join(root, "skills", "demo-skill", "scripts");
await writeJson(path.join(consumerDir, "package.json"), {
name: "demo-skill-scripts",
version: "1.0.0",
dependencies: {
"demo-pkg": "^1.0.0",
},
});
await syncSharedSkillPackages(root, { install: false });
const readme = await fs.readFile(path.join(consumerDir, "vendor", "demo-pkg", "README.md"), "utf8");
assert.match(readme, /Demo/);
const vendoredSource = await fs.readFile(
path.join(consumerDir, "vendor", "demo-pkg", "src", "index.ts"),
"utf8",
);
assert.match(vendoredSource, /demo = true/);
await assert.rejects(
fs.readFile(path.join(consumerDir, "vendor", "demo-pkg", "docs", "private.md"), "utf8"),
{ code: "ENOENT" },
);
await assert.rejects(
fs.readFile(path.join(consumerDir, "vendor", "demo-pkg", "CHANGELOG.md"), "utf8"),
{ code: "ENOENT" },
);
await assert.rejects(
fs.readFile(path.join(consumerDir, "vendor", "demo-pkg", ".changeset", "demo.md"), "utf8"),
{ code: "ENOENT" },
);
await assert.rejects(
fs.readFile(path.join(consumerDir, "vendor", "demo-pkg", "src", "index.test.ts"), "utf8"),
{ code: "ENOENT" },
);
});
-70
View File
@@ -1,70 +0,0 @@
#!/usr/bin/env node
import path from "node:path";
import {
ensureManagedPathsClean,
syncSharedSkillPackages,
} from "./lib/shared-skill-packages.mjs";
async function main() {
const options = parseArgs(process.argv.slice(2));
const repoRoot = path.resolve(options.repoRoot);
const result = await syncSharedSkillPackages(repoRoot, {
targets: options.targets,
});
if (options.enforceClean) {
ensureManagedPathsClean(repoRoot, result.managedPaths);
}
console.log(`Synced shared workspace packages into ${result.packageDirs.length} skill script package(s).`);
}
function parseArgs(argv) {
const options = {
repoRoot: process.cwd(),
enforceClean: false,
targets: [],
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--repo-root") {
options.repoRoot = argv[index + 1] ?? options.repoRoot;
index += 1;
continue;
}
if (arg === "--enforce-clean") {
options.enforceClean = true;
continue;
}
if (arg === "--target") {
options.targets.push(argv[index + 1] ?? "");
index += 1;
continue;
}
if (arg === "-h" || arg === "--help") {
printUsage();
process.exit(0);
}
throw new Error(`Unknown argument: ${arg}`);
}
return options;
}
function printUsage() {
console.log(`Usage: sync-shared-skill-packages.mjs [options]
Options:
--repo-root <dir> Repository root (default: current directory)
--target <dir> Sync only one skill directory (can be repeated)
--enforce-clean Fail if managed files change after sync
-h, --help Show help`);
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});
+160
View File
@@ -0,0 +1,160 @@
#!/usr/bin/env node
import { existsSync } from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import process from "node:process";
const PACKAGE_DEPENDENCY_SECTIONS = [
"dependencies",
"optionalDependencies",
"peerDependencies",
"devDependencies",
];
async function main() {
const options = parseArgs(process.argv.slice(2));
const repoRoot = path.resolve(options.repoRoot);
const workspacePackages = await discoverWorkspacePackages(repoRoot);
const targets = normalizeTargetConsumerDirs(repoRoot, options.targets);
const consumers = await discoverSkillScriptPackages(repoRoot, targets);
const errors = [];
for (const consumer of consumers) {
const packageJson = JSON.parse(await fs.readFile(consumer.packageJsonPath, "utf8"));
const relativePackageJson = toPosix(path.relative(repoRoot, consumer.packageJsonPath));
for (const section of PACKAGE_DEPENDENCY_SECTIONS) {
const dependencies = packageJson[section];
if (!dependencies || typeof dependencies !== "object") continue;
for (const [name, workspacePackage] of workspacePackages) {
if (!(name in dependencies)) continue;
const spec = dependencies[name];
const expectedSpec = `^${workspacePackage.version}`;
if (typeof spec !== "string" || spec.startsWith("file:")) {
errors.push(`${relativePackageJson}: ${section}.${name} must use ${expectedSpec}, not ${spec}`);
continue;
}
if (spec !== expectedSpec) {
errors.push(`${relativePackageJson}: ${section}.${name} is ${spec}; expected ${expectedSpec}`);
}
}
}
for (const name of workspacePackages.keys()) {
const vendorDir = path.join(consumer.dir, "vendor", name);
if (existsSync(vendorDir)) {
errors.push(`${toPosix(path.relative(repoRoot, vendorDir))}: remove vendored workspace package`);
}
}
}
if (errors.length) {
throw new Error(["Shared package dependency check failed:", ...errors].join("\n"));
}
console.log(`Verified npm package dependencies for ${consumers.length} skill script package(s).`);
}
function parseArgs(argv) {
const options = {
repoRoot: process.cwd(),
targets: [],
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--repo-root") {
options.repoRoot = argv[index + 1] ?? options.repoRoot;
index += 1;
continue;
}
if (arg === "--target") {
options.targets.push(argv[index + 1] ?? "");
index += 1;
continue;
}
if (arg === "-h" || arg === "--help") {
printUsage();
process.exit(0);
}
throw new Error(`Unknown argument: ${arg}`);
}
return options;
}
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;
}
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 || !packageJson.version) continue;
map.set(packageJson.name, {
dir: path.join(packagesRoot, entry.name),
version: packageJson.version,
});
}
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 toPosix(value) {
return value.split(path.sep).join("/");
}
function printUsage() {
console.log(`Usage: verify-shared-package-deps.mjs [options]
Options:
--repo-root <dir> Repository root (default: current directory)
--target <dir> Check only one skill directory (can be repeated)
-h, --help Show help`);
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});
@@ -0,0 +1,75 @@
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import process from "node:process";
import test from "node:test";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
const SCRIPT_PATH = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"verify-shared-package-deps.mjs",
);
async function makeTempDir(prefix: string): Promise<string> {
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
}
async function writeFile(filePath: string, contents = ""): Promise<void> {
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, contents);
}
async function writeJson(filePath: string, value: unknown): Promise<void> {
await writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`);
}
async function runVerifier(root: string): Promise<void> {
await execFileAsync(process.execPath, [SCRIPT_PATH, "--repo-root", root]);
}
test("verify-shared-package-deps accepts npm semver dependencies", async (t) => {
const root = await makeTempDir("baoyu-verify-shared-ok-");
t.after(() => fs.rm(root, { recursive: true, force: true }));
await writeJson(path.join(root, "packages", "baoyu-md", "package.json"), {
name: "baoyu-md",
version: "1.2.3",
});
await writeJson(path.join(root, "skills", "demo", "scripts", "package.json"), {
dependencies: {
"baoyu-md": "^1.2.3",
},
});
await assert.doesNotReject(() => runVerifier(root));
});
test("verify-shared-package-deps rejects file dependencies and vendored workspace packages", async (t) => {
const root = await makeTempDir("baoyu-verify-shared-bad-");
t.after(() => fs.rm(root, { recursive: true, force: true }));
await writeJson(path.join(root, "packages", "baoyu-md", "package.json"), {
name: "baoyu-md",
version: "1.2.3",
});
await writeJson(path.join(root, "skills", "demo", "scripts", "package.json"), {
dependencies: {
"baoyu-md": "file:./vendor/baoyu-md",
},
});
await writeFile(path.join(root, "skills", "demo", "scripts", "vendor", "baoyu-md", "index.ts"));
await assert.rejects(
() => runVerifier(root),
(error: unknown) => {
assert(error instanceof Error);
assert.match(error.message, /must use \^1\.2\.3/);
assert.match(error.message, /remove vendored workspace package/);
return true;
},
);
});