From 1cc94eee6a46117f67f17d101b65fb50126e2a7d Mon Sep 17 00:00:00 2001 From: Steve Calvert Date: Tue, 28 Jul 2026 20:23:03 -0700 Subject: [PATCH 1/6] fix: delete guard and cross-target collision detection can silently destroy files Two live-reproduced data-loss bugs found during a 1.0-readiness review: - buildDeleteGuard only protected the source-plugins root when `source.plugins` was explicitly set in config; loadConfig applies the same default ("plugins") regardless, so an unforced `prune`/`clean` could silently delete real source files under the default, unconfigured path. - assertNoCrossTargetCollisions only compared artifacts built in a single `build()` invocation, so running `build --target X` after an earlier `build --target Y` with an overlapping outDir would silently overwrite Y's files, and a later `clean --target Y` would then delete what were now X's live files. Also makes build() write every target's new files before pruning any target's stale ones, so a mid-build failure can't leave one target with its stale files gone, new files partially written, and an inconsistent manifest. Co-Authored-By: Claude Sonnet 5 --- src/build.ts | 75 ++++- src/managed.ts | 41 ++- tests/core.test.ts | 691 ++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 798 insertions(+), 9 deletions(-) diff --git a/src/build.ts b/src/build.ts index 5228b15..d2bb1e6 100644 --- a/src/build.ts +++ b/src/build.ts @@ -3,11 +3,18 @@ import { loadConfig } from "./config.js"; import { writeArtifact } from "./fs.js"; import { buildDeleteGuard, + normalizeManagedPath, pruneManagedFiles, + readManagedManifest, writeManagedManifest, } from "./managed.js"; import { emitTarget, targetNames } from "./adapters.js"; -import type { Artifact, BuildOptions, TargetName } from "./types.js"; +import type { + Artifact, + BuildOptions, + ResolvedProject, + TargetName, +} from "./types.js"; /** Builds every configured (or explicitly selected) target and writes its output, unless `dryRun` is set. */ export async function build(options: BuildOptions = {}): Promise { @@ -24,11 +31,20 @@ export async function build(options: BuildOptions = {}): Promise { for (const target of targets) { artifacts.push(await emitTarget(project, target, options.outDir)); } - assertNoCrossTargetCollisions(artifacts); + const owner = assertNoCrossTargetCollisions(artifacts); + await assertNoCollisionsWithBuiltTargets(project, targets, owner); if (!options.dryRun) { + // Write every target's new files before pruning any target's stale ones. + // If a later target's write throws, no target has had files pruned yet — + // the previous build's output is still intact and a re-run starts from + // the same state, rather than leaving one target with its stale files + // already gone, its new files only partially written, and a manifest + // that no longer matches either state. for (const artifact of artifacts) { - await pruneManagedFiles(artifact, { guard }); await writeArtifact(artifact.outDir, artifact.files); + } + for (const artifact of artifacts) { + await pruneManagedFiles(artifact, { guard }); await writeManagedManifest(artifact); } } @@ -37,7 +53,11 @@ export async function build(options: BuildOptions = {}): Promise { // Two targets pointed at overlapping output paths would silently overwrite each // other (and one target's prune could delete the other's files). Catch it. -function assertNoCrossTargetCollisions(artifacts: Artifact[]): void { +// Returns the absolute-path -> owning-target map so +// `assertNoCollisionsWithBuiltTargets` can reuse it. +function assertNoCrossTargetCollisions( + artifacts: Artifact[], +): Map { const owner = new Map(); const collisions: string[] = []; for (const artifact of artifacts) { @@ -56,4 +76,51 @@ function assertNoCrossTargetCollisions(artifacts: Artifact[]): void { `Targets write overlapping output paths; give them distinct outDirs:\n${collisions.join("\n")}`, ); } + return owner; +} + +// assertNoCrossTargetCollisions only sees artifacts built in *this* +// invocation. Running `pluginpack build --target X` after an earlier +// `pluginpack build --target Y` wrote overlapping paths would otherwise slip +// through — X's build would silently overwrite Y's files, and a later +// `clean --target Y` would then delete what are now X's live files. Guard +// against that by also checking incoming paths against every other +// *configured* target's on-disk managed manifest, not just artifacts present +// in the current invocation. +async function assertNoCollisionsWithBuiltTargets( + project: ResolvedProject, + targets: TargetName[], + incoming: Map, +): Promise { + const building = new Set(targets); + const collisions: string[] = []; + for (const other of targetNames) { + if (building.has(other)) { + continue; + } + const otherConfig = project.config.targets[other]; + if (!otherConfig) { + continue; + } + const otherOutDir = path.resolve(project.rootDir, otherConfig.outDir); + const manifest = await readManagedManifest(otherOutDir, other); + if (!manifest) { + continue; + } + for (const file of manifest.files) { + const absolute = path.resolve(otherOutDir, normalizeManagedPath(file)); + const conflicting = incoming.get(absolute); + if (conflicting) { + collisions.push( + ` ${other} (previously built) and ${conflicting}: ${absolute}`, + ); + } + } + } + if (collisions.length > 0) { + throw new Error( + `Target output overlaps a previously built target's managed files; give them distinct outDirs:\n${collisions.join("\n")}\n` + + `Run a full \`build\` (no --target) to see every target's paths at once, or fix the outDir configuration.`, + ); + } } diff --git a/src/managed.ts b/src/managed.ts index 3d847d5..38c9bf1 100644 --- a/src/managed.ts +++ b/src/managed.ts @@ -139,9 +139,12 @@ export function buildDeleteGuard( if (config.source?.skills) { protectedRoots.push(path.resolve(rootDir, config.source.skills)); } - if (config.source?.plugins) { - protectedRoots.push(path.resolve(rootDir, config.source.plugins)); - } + // Mirrors loadConfig's default in src/config.ts so the source-plugin + // discovery root is always protected, whether or not it's written out + // explicitly in config. + protectedRoots.push( + path.resolve(rootDir, config.source?.plugins ?? "plugins"), + ); return { protectedRoots, configPath: path.resolve(configPath), force }; } @@ -189,7 +192,13 @@ export function normalizeManagedPath(value: string): string { !value || path.posix.isAbsolute(normalized) || normalized === ".." || - normalized.startsWith("../") + normalized.startsWith("../") || + // A Windows drive-letter path (e.g. "C:/Users/x") isn't caught by + // path.posix.isAbsolute, but path.resolve on an actual Windows host + // would treat it as absolute and escape `root` entirely. Reject it on + // every platform so a manifest built on one OS can't misbehave on + // another. + /^[a-zA-Z]:/.test(normalized) ) { throw new Error(`Unsafe managed path: ${value}`); } @@ -206,6 +215,30 @@ async function removeManagedPath( if (destination !== root && !destination.startsWith(`${root}${path.sep}`)) { throw new Error(`Managed path escapes output directory: ${relativePath}`); } + // Defense in depth: fs.rm on a symlink unlinks the symlink itself rather + // than following it, so this isn't currently exploitable — but refuse + // outright if the entry is a symlink pointing outside `root`, rather than + // relying on that fs.rm behavior remaining true forever. + let stats; + try { + stats = await fs.lstat(destination); + } catch (error) { + if (isNotFound(error)) { + return; + } + throw error; + } + if (stats.isSymbolicLink()) { + const target = path.resolve( + path.dirname(destination), + await fs.readlink(destination), + ); + if (target !== root && !target.startsWith(`${root}${path.sep}`)) { + throw new Error( + `Refusing to remove a symlink pointing outside the output directory: ${relativePath}`, + ); + } + } await fs.rm(destination, { force: true }); await removeEmptyParents(path.dirname(destination), root); } diff --git a/tests/core.test.ts b/tests/core.test.ts index c2f149e..8930d6c 100644 --- a/tests/core.test.ts +++ b/tests/core.test.ts @@ -1,4 +1,4 @@ -import { access, readFile, rm, writeFile } from "node:fs/promises"; +import { access, readFile, rm, symlink, writeFile } from "node:fs/promises"; import path from "node:path"; import { Project, type ProjectArgs } from "fixturify-project"; import { afterEach, describe, expect, it } from "vitest"; @@ -7,6 +7,7 @@ import { clean, prune } from "../src/cleanup.js"; import { loadConfig } from "../src/config.js"; import { diffTarget } from "../src/diff.js"; import { validateOutput } from "../src/adapters.js"; +import { normalizeManagedPath } from "../src/managed.js"; type DirJSON = NonNullable; @@ -171,6 +172,203 @@ export default defineConfig({ ).toBe(true); }); + it("flags missing required frontmatter fields per component kind", async () => { + const project = await fixtureProject({ + "pluginpack.config.ts": `import { defineConfig } from "${path.resolve("src/index.ts")}"; + +export default defineConfig({ + name: "frontmatter-plugins", + version: "1.0.0", + metadata: { description: "F", author: { name: "F" }, license: "MIT" }, + targets: { + claude: { + outDir: "dist/claude", + plugins: { + demo: { from: ["demo"], components: ["skills", "agents", "commands", "rules"] } + } + } + } +}); +`, + plugins: { + demo: { + skills: { demo: { "SKILL.md": skill("demo", "Demo skill.") } }, + agents: { + "helper.md": + "---\ndescription: Helps with tasks.\n---\n\n# helper\n", + }, + commands: { "run.md": "---\nname: run\n---\n\n# run\n" }, + rules: { "style.mdc": "---\nname: style\n---\n\n# style\n" }, + }, + }, + }); + const root = project.baseDir; + + await build({ cwd: root, target: "claude" }); + const result = await validateOutput( + "claude", + path.join(root, "dist/claude"), + ); + + expect(result.ok).toBe(false); + const messages = result.issues.map((issue) => issue.message); + expect(messages).toContainEqual( + expect.stringContaining( + 'agent frontmatter error in agents/helper.md: Missing required "name" field.', + ), + ); + expect(messages).toContainEqual( + expect.stringContaining( + 'command frontmatter error in commands/run.md: Missing required "description" field.', + ), + ); + expect(messages).toContainEqual( + expect.stringContaining( + 'rule frontmatter error in rules/style.mdc: Missing required "description" field.', + ), + ); + }); + + it("requires a command name only on cursor/antigravity/copilot, not claude/codex", async () => { + const project = await fixtureProject({ + "pluginpack.config.ts": `import { defineConfig } from "${path.resolve("src/index.ts")}"; + +export default defineConfig({ + name: "command-name-plugins", + version: "1.0.0", + metadata: { description: "C", author: { name: "C" }, license: "MIT" }, + targets: { + claude: { + outDir: "dist/claude", + plugins: { demo: { from: ["demo"], components: ["skills", "commands"] } } + }, + cursor: { + outDir: "dist/cursor", + plugins: { demo: { from: ["demo"], components: ["skills", "commands"] } } + } + } +}); +`, + plugins: { + demo: { + skills: { demo: { "SKILL.md": skill("demo", "Demo skill.") } }, + // Missing "name" — required for cursor, optional for claude. + commands: { + "run.md": "---\ndescription: Runs a thing.\n---\n\n# run\n", + }, + }, + }, + }); + const root = project.baseDir; + + await build({ cwd: root }); + + const claudeResult = await validateOutput( + "claude", + path.join(root, "dist/claude"), + ); + expect(claudeResult.ok).toBe(true); + + const cursorResult = await validateOutput( + "cursor", + path.join(root, "dist/cursor"), + ); + expect(cursorResult.ok).toBe(false); + expect( + cursorResult.issues.some((issue) => + issue.message.includes( + 'command frontmatter error in commands/run.md: Missing required "name" field.', + ), + ), + ).toBe(true); + }); + + it("accepts when_to_use in place of description for skills, except cursor which requires description", async () => { + const project = await fixtureProject({ + "pluginpack.config.ts": `import { defineConfig } from "${path.resolve("src/index.ts")}"; + +export default defineConfig({ + name: "skill-when-to-use-plugins", + version: "1.0.0", + metadata: { description: "S", author: { name: "S" }, license: "MIT" }, + targets: { + claude: { outDir: "dist/claude", plugins: { demo: { from: ["demo"] } } }, + cursor: { outDir: "dist/cursor", plugins: { demo: { from: ["demo"] } } } + } +}); +`, + plugins: { + demo: { + skills: { + demo: { + "SKILL.md": + "---\nname: demo\nwhen_to_use: Use when doing X.\n---\n\n# demo\n", + }, + }, + }, + }, + }); + const root = project.baseDir; + + await build({ cwd: root }); + + const claudeResult = await validateOutput( + "claude", + path.join(root, "dist/claude"), + ); + expect(claudeResult.ok).toBe(true); + + const cursorResult = await validateOutput( + "cursor", + path.join(root, "dist/cursor"), + ); + expect(cursorResult.ok).toBe(false); + expect( + cursorResult.issues.some((issue) => + issue.message.includes( + 'skill frontmatter error in skills/demo/SKILL.md: Missing required "description" field.', + ), + ), + ).toBe(true); + }); + + it("flags a skill file with no frontmatter at all", async () => { + const project = await fixtureProject({ + "pluginpack.config.ts": `import { defineConfig } from "${path.resolve("src/index.ts")}"; + +export default defineConfig({ + name: "no-frontmatter-plugins", + version: "1.0.0", + metadata: { description: "N", author: { name: "N" }, license: "MIT" }, + targets: { + claude: { outDir: "dist/claude", plugins: { demo: { from: ["demo"] } } } + } +}); +`, + plugins: { + demo: { + skills: { demo: { "SKILL.md": "# demo\n\nNo frontmatter here.\n" } }, + }, + }, + }); + const root = project.baseDir; + + await build({ cwd: root, target: "claude" }); + const result = await validateOutput( + "claude", + path.join(root, "dist/claude"), + ); + + expect(result.ok).toBe(false); + expect( + result.issues.some((issue) => + issue.message.includes( + "skill frontmatter error in skills/demo/SKILL.md: No frontmatter found", + ), + ), + ).toBe(true); + }); + it("catches a missing copilot plugin.json (.github/plugin copy) at validate time", async () => { const project = await fixture(); const root = project.baseDir; @@ -624,6 +822,117 @@ export default defineConfig({ expect(result.ok).toBe(true); }); + it("rejects a codex structured source with an unrecognized discriminator", async () => { + const project = await fixtureProject({ + "pluginpack.config.ts": `import { defineConfig } from "${path.resolve("src/index.ts")}"; + +export default defineConfig({ + name: "codex-plugins", + version: "1.0.0", + targets: { + codex: { + outDir: "dist/codex", + plugins: { demo: { from: ["demo"] } }, + manifest: { + plugins: [ + { + name: "remote-helper", + source: { source: "ftp", url: "ftp://example.com/plugin" }, + policy: { installation: "AVAILABLE", authentication: "ON_INSTALL" }, + category: "Developer Tools" + } + ] + } + } + } +}); +`, + plugins: { + demo: { + skills: { demo: { "SKILL.md": skill("demo", "Demo skill.") } }, + }, + }, + }); + const root = project.baseDir; + + await build({ cwd: root, target: "codex" }); + + const result = await validateOutput("codex", path.join(root, "dist/codex")); + expect(result.ok).toBe(false); + expect( + result.issues.some((issue) => + issue.message.includes("source.source must be one of"), + ), + ).toBe(true); + }); + + it("rejects a codex url/git-subdir source missing url, and an npm source missing package", async () => { + const project = await fixtureProject({ + "pluginpack.config.ts": `import { defineConfig } from "${path.resolve("src/index.ts")}"; + +export default defineConfig({ + name: "codex-plugins", + version: "1.0.0", + targets: { + codex: { + outDir: "dist/codex", + plugins: { demo: { from: ["demo"] } }, + manifest: { + plugins: [ + { + name: "no-url", + source: { source: "url" }, + policy: { installation: "AVAILABLE", authentication: "ON_INSTALL" }, + category: "Developer Tools" + }, + { + name: "no-package", + source: { source: "npm" }, + policy: { installation: "AVAILABLE", authentication: "ON_INSTALL" }, + category: "Developer Tools" + }, + { + name: "no-path", + source: { source: "local" }, + policy: { installation: "AVAILABLE", authentication: "ON_INSTALL" }, + category: "Developer Tools" + } + ] + } + } + } +}); +`, + plugins: { + demo: { + skills: { demo: { "SKILL.md": skill("demo", "Demo skill.") } }, + }, + }, + }); + const root = project.baseDir; + + await build({ cwd: root, target: "codex" }); + + const result = await validateOutput("codex", path.join(root, "dist/codex")); + expect(result.ok).toBe(false); + const messages = result.issues.map((issue) => issue.message); + expect( + messages.some((message) => + message.includes('a "url" source requires a "url"'), + ), + ).toBe(true); + expect( + messages.some((message) => + message.includes('an "npm" source requires a "package"'), + ), + ).toBe(true); + expect( + messages.some((message) => + message.includes('a "local" source requires a "path"'), + ), + ).toBe(true); + }); + it("points a codex plugin.json's hooks field at the bundled hooks file when hooks/ is present", async () => { const project = await fixtureProject({ "pluginpack.config.ts": `import { defineConfig } from "${path.resolve("src/index.ts")}"; @@ -814,6 +1123,45 @@ export default defineConfig({ expect(result.issues.some((issue) => issue.level === "error")).toBe(true); }); + it("flags a cursor plugin.json with an invalid name or an unknown field", async () => { + const project = await fixture(); + const root = project.baseDir; + await build({ cwd: root, target: "cursor" }); + const manifestPath = path.join( + root, + "dist/cursor/demo/.cursor-plugin/plugin.json", + ); + const manifest = JSON.parse(await readFile(manifestPath, "utf8")) as Record< + string, + unknown + >; + await writeFile( + manifestPath, + JSON.stringify( + { ...manifest, name: "Not Kebab Case", unknownField: "surprise" }, + null, + 2, + ), + ); + + const result = await validateOutput( + "cursor", + path.join(root, "dist/cursor"), + ); + expect(result.ok).toBe(false); + const messages = result.issues.map((issue) => issue.message); + expect( + messages.some((message) => + message.includes('must have a kebab-case "name"'), + ), + ).toBe(true); + expect( + messages.some((message) => + message.includes('unknown field "unknownField"'), + ), + ).toBe(true); + }); + it("applies target component defaults and explicit component overrides", async () => { const project = await fixtureProject({ "pluginpack.config.ts": `import { defineConfig } from "${path.resolve("src/index.ts")}"; @@ -1035,6 +1383,54 @@ export default defineConfig({ await expectMissing(path.join(root, "dist/cursor/.pluginpack/cursor.json")); }); + it("dryRun prune reports stale files without deleting them", async () => { + const project = await rootSkillsFixture(); + const root = project.baseDir; + await mergeFixture(project, { + skills: { "old-skill": { "SKILL.md": skill("old-skill", "Old skill.") } }, + }); + await build({ cwd: root, target: "cursor" }); + await rm(path.join(root, "skills/old-skill"), { + recursive: true, + force: true, + }); + + const pruneResult = await prune({ + cwd: root, + target: "cursor", + dryRun: true, + }); + + expect(pruneResult[0]?.entries).toContainEqual({ + type: "stale", + target: "cursor", + path: "demo/skills/old-skill/SKILL.md", + }); + // dryRun must report what would be pruned without actually deleting it. + await access(path.join(root, "dist/cursor/demo/skills/old-skill/SKILL.md")); + }); + + it("dryRun clean reports managed files without deleting them", async () => { + const project = await rootSkillsFixture(); + const root = project.baseDir; + await build({ cwd: root, target: "cursor" }); + + const cleanResult = await clean({ + cwd: root, + target: "cursor", + dryRun: true, + }); + + expect(cleanResult[0]?.entries).toContainEqual({ + type: "deleted", + target: "cursor", + path: "demo/skills/demo/SKILL.md", + }); + // dryRun must report what would be cleaned without actually deleting it. + await access(path.join(root, "dist/cursor/demo/skills/demo/SKILL.md")); + await access(path.join(root, "dist/cursor/.pluginpack/cursor.json")); + }); + it("rejects a corrupt managed manifest with a clear error", async () => { const project = await rootSkillsFixture(); const root = project.baseDir; @@ -1048,6 +1444,49 @@ export default defineConfig({ ); }); + it("rejects a Windows drive-letter path in normalizeManagedPath on every platform", () => { + expect(() => normalizeManagedPath("C:/Users/x/evil")).toThrow( + /Unsafe managed path/, + ); + expect(() => normalizeManagedPath("C:\\Users\\x\\evil")).toThrow( + /Unsafe managed path/, + ); + // A relative path that merely starts with a lowercase letter and colon + // is not a real-world managed path shape, but confirm the normal case + // still passes through unaffected. + expect(normalizeManagedPath("skills/demo/SKILL.md")).toBe( + "skills/demo/SKILL.md", + ); + }); + + it("refuses to delete a managed path that is a symlink escaping the output directory", async () => { + const project = await rootSkillsFixture(); + const root = project.baseDir; + await build({ cwd: root, target: "cursor" }); + + const outsideTarget = path.join(root, "outside-secret.txt"); + await writeFile(outsideTarget, "do not delete me\n"); + const linkPath = path.join(root, "dist/cursor/escape-link"); + await symlink(outsideTarget, linkPath); + await writeFile( + path.join(root, "dist/cursor/.pluginpack/cursor.json"), + JSON.stringify( + { + version: 1, + target: "cursor", + files: ["demo/skills/demo/SKILL.md", "escape-link"], + }, + null, + 2, + ), + ); + + await expect(clean({ cwd: root, target: "cursor" })).rejects.toThrow( + /Refusing to remove a symlink pointing outside the output directory/, + ); + await access(outsideTarget); + }); + it("refuses to prune source paths unless forced", async () => { const project = await fixtureProject({ "pluginpack.config.ts": `import { defineConfig } from "${path.resolve("src/index.ts")}"; @@ -1094,6 +1533,59 @@ export default defineConfig({ await expectMissing(path.join(root, "skills/demo/SKILL.md")); }); + it("refuses to prune the default source.plugins root even when unset in config", async () => { + const project = await fixtureProject({ + "pluginpack.config.ts": `import { defineConfig } from "${path.resolve("src/index.ts")}"; + +export default defineConfig({ + name: "demo-plugins", + version: "1.0.0", + metadata: { description: "Demo", author: { name: "Demo" }, license: "MIT" }, + targets: { + cursor: { + outDir: ".", + plugins: { + demo: { from: ["core"], components: ["skills"] } + } + } + } +}); +`, + plugins: { + core: { + skills: { + demo: { + "SKILL.md": skill("demo", "Demo skill."), + }, + }, + }, + }, + ".pluginpack": { + "cursor.json": `${JSON.stringify( + { + version: 1, + target: "cursor", + files: ["plugins/core/skills/demo/SKILL.md"], + }, + null, + 2, + )}\n`, + }, + }); + const root = project.baseDir; + // source.plugins is never set in this config — loadConfig still discovers + // the source plugin under the default "plugins" root, and the delete + // guard must protect that same default, not just an explicit value. + + await expect(prune({ cwd: root, target: "cursor" })).rejects.toThrow( + /Refusing to prune/, + ); + await access(path.join(root, "plugins/core/skills/demo/SKILL.md")); + + await prune({ cwd: root, target: "cursor", force: true }); + await expectMissing(path.join(root, "plugins/core/skills/demo/SKILL.md")); + }); + it("ignores configured diff paths", async () => { const project = await fixture(); const root = project.baseDir; @@ -1406,6 +1898,59 @@ export default defineConfig({ expect(claudeMcp.mcpServers).not.toHaveProperty("glean-local"); }); + it("prefers .mcp.json over plugin.pluginpack.json's mcpServers when a single plugin has both", async () => { + const project = await fixtureProject({ + "pluginpack.config.ts": `import { defineConfig } from "${path.resolve("src/index.ts")}"; + +export default defineConfig({ + name: "mcp-precedence-plugins", + version: "1.0.0", + metadata: { description: "MCP precedence", author: { name: "X" }, license: "MIT" }, + targets: { + claude: { outDir: "dist/claude", plugins: { both: { from: ["both"] } } } + } +}); +`, + plugins: { + both: { + "plugin.pluginpack.json": `${JSON.stringify( + { + mcpServers: { + "from-manifest": { command: "node", args: ["manifest.mjs"] }, + }, + }, + null, + 2, + )}\n`, + ".mcp.json": `${JSON.stringify( + { + mcpServers: { + "from-file": { command: "node", args: ["file.mjs"] }, + }, + }, + null, + 2, + )}\n`, + skills: { s1: { "SKILL.md": skill("s1", "S1.") } }, + }, + }, + }); + const root = project.baseDir; + + await build({ cwd: root, target: "claude" }); + + const mcp = JSON.parse( + await readFile( + path.join(root, "dist/claude/plugins/both/.mcp.json"), + "utf8", + ), + ) as { mcpServers: Record }; + expect(mcp.mcpServers).toMatchObject({ + "from-file": { command: "node", args: ["file.mjs"] }, + }); + expect(mcp.mcpServers).not.toHaveProperty("from-manifest"); + }); + it("emits arbitrary plugin-root files declared in plugin.pluginpack.json", async () => { const project = await fixtureProject({ "pluginpack.config.ts": `import { defineConfig } from "${path.resolve("src/index.ts")}"; @@ -1895,6 +2440,40 @@ export default defineConfig({ ); }); + it("detects collisions with a target built in a previous invocation", async () => { + const project = await fixtureProject({ + "pluginpack.config.ts": `import { defineConfig } from "${path.resolve("src/index.ts")}"; + +export default defineConfig({ + name: "collide-plugins", + version: "1.0.0", + source: { skills: "skills", rootPlugin: { id: "core" } }, + metadata: { description: "C", author: { name: "C" }, license: "MIT" }, + targets: { + claude: { outDir: ".", plugins: { demo: { from: ["core"] } } }, + copilot: { outDir: ".", plugins: { demo: { from: ["core"] } } } + } +}); +`, + skills: { + demo: { + "SKILL.md": skill("demo", "Demo skill."), + }, + }, + }); + const root = project.baseDir; + // Building claude and copilot together is caught by the in-process check + // above; building them one `--target` at a time, in separate `build()` + // calls, must be caught too — otherwise the second build silently + // overwrites the first's files, and a later `clean --target claude` + // would delete what are now copilot's live output files. + + await build({ cwd: root, target: "claude" }); + await expect(build({ cwd: root, target: "copilot" })).rejects.toThrow( + /overlaps a previously built target/, + ); + }); + it("applies per-target and per-plugin version overrides", async () => { const project = await fixtureProject({ "pluginpack.config.ts": `import { defineConfig } from "${path.resolve("src/index.ts")}"; @@ -2492,6 +3071,116 @@ export default defineConfig({ expect(content).toContain("Example Handlebars tag: \n"); expect(content).not.toContain("{{unrelated}}"); }); + + it("rejects a target that references an unknown source plugin in from:", async () => { + const project = await fixtureProject({ + "pluginpack.config.ts": `import { defineConfig } from "${path.resolve("src/index.ts")}"; + +export default defineConfig({ + name: "unknown-source-plugins", + version: "1.0.0", + metadata: { description: "U", author: { name: "U" }, license: "MIT" }, + targets: { + claude: { outDir: "dist/claude", plugins: { demo: { from: ["does-not-exist"] } } } + } +}); +`, + plugins: { + demo: { + skills: { demo: { "SKILL.md": skill("demo", "Demo skill.") } }, + }, + }, + }); + const root = project.baseDir; + + await expect(build({ cwd: root, target: "claude" })).rejects.toThrow( + /Target "claude" references unknown source plugin "does-not-exist"/, + ); + }); + + it("rejects malformed JSON in a source plugin's manifest", async () => { + const project = await fixtureProject({ + "pluginpack.config.ts": `import { defineConfig } from "${path.resolve("src/index.ts")}"; + +export default defineConfig({ + name: "bad-manifest-plugins", + version: "1.0.0", + metadata: { description: "B", author: { name: "B" }, license: "MIT" }, + targets: { + claude: { outDir: "dist/claude", plugins: { demo: { from: ["demo"] } } } + } +}); +`, + plugins: { + demo: { + "plugin.pluginpack.json": "{ not json", + skills: { demo: { "SKILL.md": skill("demo", "Demo skill.") } }, + }, + }, + }); + const root = project.baseDir; + + await expect(build({ cwd: root, target: "claude" })).rejects.toThrow( + /Invalid JSON in .*plugin\.pluginpack\.json/, + ); + }); + + it("rejects malformed JSON in a source plugin's .mcp.json", async () => { + const project = await fixtureProject({ + "pluginpack.config.ts": `import { defineConfig } from "${path.resolve("src/index.ts")}"; + +export default defineConfig({ + name: "bad-mcp-plugins", + version: "1.0.0", + metadata: { description: "B", author: { name: "B" }, license: "MIT" }, + targets: { + claude: { outDir: "dist/claude", plugins: { demo: { from: ["demo"] } } } + } +}); +`, + plugins: { + demo: { + ".mcp.json": "{ not json", + skills: { demo: { "SKILL.md": skill("demo", "Demo skill.") } }, + }, + }, + }); + const root = project.baseDir; + + await expect(build({ cwd: root, target: "claude" })).rejects.toThrow( + /Invalid JSON in .*\.mcp\.json/, + ); + }); + + it("rejects a config file that fails schema validation", async () => { + const project = await fixtureProject({ + "pluginpack.config.ts": `import { defineConfig } from "${path.resolve("src/index.ts")}"; + +export default defineConfig({ + name: "invalid-config", + // "version" is required by the schema and is missing here. + metadata: { description: "I", author: { name: "I" }, license: "MIT" }, + targets: {} +}); +`, + }); + const root = project.baseDir; + + await expect(loadConfig(root)).rejects.toThrow( + /Invalid pluginpack config in/, + ); + }); + + it("rejects a directory with no pluginpack config file", async () => { + const project = await fixtureProject({ + "README.md": "# No config here\n", + }); + const root = project.baseDir; + + await expect(loadConfig(root)).rejects.toThrow( + /No pluginpack config found/, + ); + }); }); async function fixtureProject(files: DirJSON): Promise { From c0926be5dfbdb22b08d9314c844d123eee4a35c0 Mon Sep 17 00:00:00 2001 From: Steve Calvert Date: Tue, 28 Jul 2026 20:23:19 -0700 Subject: [PATCH 2/6] fix: give cursor's shipped validator real field-level checks cursor.validateManifest was a no-op; the real structural check only existed in the dev-only ajv schema test, so a user running `pluginpack validate --target cursor` got materially weaker protection than every other target's shipped validator. Adds a name-pattern check and unknown-top-level-key rejection, mirroring the vendored schema's required/additionalProperties rules, plus a regression test. Co-Authored-By: Claude Sonnet 5 --- src/targets/cursor.ts | 48 +++++++++++++++++++++++++++++++++++++++---- 1 file changed, 44 insertions(+), 4 deletions(-) diff --git a/src/targets/cursor.ts b/src/targets/cursor.ts index b9493d9..2364058 100644 --- a/src/targets/cursor.ts +++ b/src/targets/cursor.ts @@ -13,6 +13,34 @@ import type { PluginTargetDefinition } from "./types.js"; const pluginNamePattern = /^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/; +/** + * Top-level `plugin.json` keys per the vendored Cursor plugin schema + * (`tests/fixtures/cursor/plugin.schema.json`, `additionalProperties: false`). + * Mirrored here so the shipped `validateOutput` catches a stray/misspelled + * key without requiring the dev-only ajv schema check. + */ +const KNOWN_MANIFEST_KEYS = new Set([ + "name", + "displayName", + "description", + "version", + "author", + "publisher", + "homepage", + "repository", + "license", + "logo", + "keywords", + "category", + "tags", + "commands", + "agents", + "skills", + "rules", + "hooks", + "mcpServers", +]); + /** * The manifest fields that point at a component, per the vendored Cursor * plugin schema (`tests/fixtures/cursor/plugin.schema.json`) — the single @@ -111,10 +139,21 @@ export const cursor: PluginTargetDefinition = { mcpConfigPath: (pluginPath) => path.join(pluginPath, ".mcp.json"), hooksPath: (pluginPath) => path.join(pluginPath, "hooks", "hooks.json"), - validateManifest: () => { - // Structural validation is delegated to the vendored JSON Schema in - // conformance tests (the stronger oracle); nothing target-specific to - // check here beyond what validateOutput already does below. + validateManifest: (manifest, pluginName, issues) => { + if ( + typeof manifest.name !== "string" || + !pluginNamePattern.test(manifest.name) + ) { + error( + issues, + `${pluginName}: plugin.json must have a kebab-case "name".`, + ); + } + for (const key of Object.keys(manifest)) { + if (!KNOWN_MANIFEST_KEYS.has(key)) { + error(issues, `${pluginName}: plugin.json has unknown field "${key}".`); + } + } }, validateMarketplaceEntry: (entry, index, root, issues) => validateBareStringSourceEntry( @@ -172,6 +211,7 @@ export const cursor: PluginTargetDefinition = { `${pluginName}: marketplace entry name does not match plugin.json name ("${manifest.name}").`, ); } + cursor.validateManifest(manifest, pluginName, issues); await validateReferencedManifestPaths( pluginDir, pluginName, From 50341a50beb576e69199ddf6485f8ce619e5ef0a Mon Sep 17 00:00:00 2001 From: Steve Calvert Date: Tue, 28 Jul 2026 20:24:00 -0700 Subject: [PATCH 3/6] ci: verify claude oracle, engines floor, pack contents, and PR labels - Installs the real claude CLI in CI so `claude plugin validate --strict` (the strongest documented conformance oracle for the claude target) runs on every PR instead of silently skipping. - Adds Node 22.12.0 to the CI matrix alongside 24, so package.json's stated `engines` floor is actually exercised, not just declared. Fixes CLAUDE.md's contradictory "Node >= 24" line to match the real floor. - Adds scripts/verify-pack-contents.mjs and verify-packed-install.mjs: the former asserts the npm tarball's file list (not just that `npm pack --dry-run` exits zero); the latter installs the real tarball into a scratch project and runs the installed binary, catching a broken `bin` shebang or `files` misconfig no existing test could see. - Adds a `labels` CI job requiring one of RELEASE.md's changelog labels on every PR. - Removes the now-redundant `undici` override (release-it now declares it directly); keeps the `esbuild` override, which is still load-bearing (GHSA-g7r4-m6w7-qqqr requires >=0.28.1, outside tsup's own declared range). - Adds @vitest/coverage-v8 and a `test:coverage` script (no threshold gate). Co-Authored-By: Claude Sonnet 5 --- .github/workflows/ci.yml | 34 ++++- .gitignore | 1 + CLAUDE.md | 5 +- eslint.config.js | 11 ++ package-lock.json | 202 ++++++++++++++++++++++++++++++ package.json | 6 +- scripts/verify-pack-contents.mjs | 35 ++++++ scripts/verify-packed-install.mjs | 53 ++++++++ 8 files changed, 342 insertions(+), 5 deletions(-) create mode 100755 scripts/verify-pack-contents.mjs create mode 100755 scripts/verify-packed-install.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4a2ed8f..50fb5be 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,7 +10,11 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - node-version: [24] + # 22.12.0 verifies package.json's stated `engines.node` floor; 24 is + # the version this repo is otherwise developed/tested against (see + # mise.toml). Keep both — the floor is meaningless if only the newer + # version is ever tested. + node-version: ["22.12.0", 24] steps: - uses: actions/checkout@v7 @@ -23,6 +27,12 @@ jobs: - name: Install dependencies run: npm ci + # Installs the real `claude` CLI so `claude plugin validate --strict` + # (the strongest available conformance oracle for the claude target, + # per CONFORMANCE.md) actually runs in CI instead of silently skipping. + - name: Install Claude Code CLI + run: npm install -g @anthropic-ai/claude-code + - name: Check run: npm run check @@ -30,4 +40,24 @@ jobs: run: npm run audit - name: Verify package contents - run: npm pack --dry-run + run: node scripts/verify-pack-contents.mjs + + - name: Verify packed install runs + run: node scripts/verify-packed-install.mjs + + labels: + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + steps: + - name: Require a lerna-changelog label + uses: actions/github-script@v8 + with: + script: | + const required = ["breaking", "enhancement", "bug", "documentation", "internal"]; + const labels = context.payload.pull_request.labels.map((label) => label.name); + const matched = labels.filter((label) => required.includes(label)); + if (matched.length === 0) { + core.setFailed( + `This PR needs one of the following labels for the changelog: ${required.join(", ")}.`, + ); + } diff --git a/.gitignore b/.gitignore index 1c07f1d..0e9416e 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ examples/glean/dist/ examples/basic/dist/ coverage/ .DS_Store +EVAL-CHECKLIST.md diff --git a/CLAUDE.md b/CLAUDE.md index ff7f2ba..42b7f74 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,7 +21,10 @@ manager or publisher. - After changing CLI commands/options, regenerate the README CLI reference with `node dist/cli.js docs` (the gate's `docs --check` fails if it is stale). -Node >= 24, ESM, `moduleResolution: nodenext`, `strict: true`. +Node >= 22.12.0 (the `engines` floor in `package.json`, verified in CI against +that exact version), ESM, `moduleResolution: nodenext`, `strict: true`. Local +dev tooling (`mise.toml`) pins Node 24, but that's a dev-environment choice, +not the supported floor. ## Architecture diff --git a/eslint.config.js b/eslint.config.js index 2ac4c63..b5660a4 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -18,4 +18,15 @@ export default tseslint.config( "@typescript-eslint/no-explicit-any": "off", }, }, + { + // Plain Node scripts run outside the TS/`@types/node` toolchain, so + // js.configs.recommended's no-undef doesn't otherwise know these globals. + files: ["scripts/**/*.mjs"], + languageOptions: { + globals: { + console: "readonly", + process: "readonly", + }, + }, + }, ); diff --git a/package-lock.json b/package-lock.json index 6e7b67e..487b7db 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "@release-it-plugins/lerna-changelog": "^9.0.0", "@types/mustache": "^4.2.6", "@types/node": "^26.0.1", + "@vitest/coverage-v8": "^4.1.10", "ajv": "^8.20.0", "ajv-formats": "^3.0.1", "bintastic": "^4.0.2", @@ -57,6 +58,16 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-validator-identifier": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", @@ -67,6 +78,46 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@embroider/shared-internals": { "version": "2.9.2", "resolved": "https://registry.npmjs.org/@embroider/shared-internals/-/shared-internals-2.9.2.tgz", @@ -167,6 +218,7 @@ "os": [ "aix" ], + "peer": true, "engines": { "node": ">=18" } @@ -184,6 +236,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -201,6 +254,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -218,6 +272,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -235,6 +290,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=18" } @@ -252,6 +308,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=18" } @@ -269,6 +326,7 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -286,6 +344,7 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -303,6 +362,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -320,6 +380,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -337,6 +398,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -354,6 +416,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -371,6 +434,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -388,6 +452,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -405,6 +470,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -422,6 +488,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -439,6 +506,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -456,6 +524,7 @@ "os": [ "netbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -473,6 +542,7 @@ "os": [ "netbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -490,6 +560,7 @@ "os": [ "openbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -507,6 +578,7 @@ "os": [ "openbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -524,6 +596,7 @@ "os": [ "openharmony" ], + "peer": true, "engines": { "node": ">=18" } @@ -541,6 +614,7 @@ "os": [ "sunos" ], + "peer": true, "engines": { "node": ">=18" } @@ -558,6 +632,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -575,6 +650,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -592,6 +668,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -3885,6 +3962,37 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.10.tgz", + "integrity": "sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.10", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.10", + "vitest": "4.1.10" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, "node_modules/@vitest/expect": { "version": "4.1.10", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", @@ -4278,6 +4386,25 @@ "node": ">=4" } }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.5.tgz", + "integrity": "sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/astral-regex": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", @@ -5714,6 +5841,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -6676,6 +6804,13 @@ "node": ">=10" } }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/http-cache-semantics": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", @@ -7092,6 +7227,45 @@ "node": "^18.17 || >=20.6.1" } }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/jiti": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", @@ -7955,6 +8129,34 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz", + "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/make-fetch-happen": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-9.1.0.tgz", diff --git a/package.json b/package.json index 7a1f82a..d0a17bb 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,8 @@ ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" - } + }, + "./package.json": "./package.json" }, "bin": { "pluginpack": "dist/cli.js" @@ -63,6 +64,7 @@ "pretest": "npm run build", "test": "vitest run", "test:all": "npm run format:check && npm run lint && npm run typecheck && npm run test && npm run build && npm run docs", + "test:coverage": "vitest run --coverage", "typecheck": "tsc --noEmit" }, "dependencies": { @@ -78,6 +80,7 @@ "@release-it-plugins/lerna-changelog": "^9.0.0", "@types/mustache": "^4.2.6", "@types/node": "^26.0.1", + "@vitest/coverage-v8": "^4.1.10", "ajv": "^8.20.0", "ajv-formats": "^3.0.1", "bintastic": "^4.0.2", @@ -101,7 +104,6 @@ }, "overrides": { "esbuild": "^0.28.1", - "undici": "^7.28.0", "tmp": "^0.2.7", "brace-expansion": "^5.0.8" }, diff --git a/scripts/verify-pack-contents.mjs b/scripts/verify-pack-contents.mjs new file mode 100755 index 0000000..dea1302 --- /dev/null +++ b/scripts/verify-pack-contents.mjs @@ -0,0 +1,35 @@ +#!/usr/bin/env node +// CI check: `npm pack --dry-run` only fails on a malformed `files` config, not +// on an accidentally-excluded runtime file (a broken tsup output path, a typo +// in package.json's `files` array). This inspects the actual tarball listing +// so a missing required file fails the build instead of just shipping broken. +import { execFileSync } from "node:child_process"; + +const REQUIRED_FILES = [ + "dist/cli.js", + "dist/index.js", + "dist/index.d.ts", + "package.json", + "README.md", + "LICENSE", +]; + +const raw = execFileSync("npm", ["pack", "--dry-run", "--json"], { + encoding: "utf8", +}); +const [pack] = JSON.parse(raw); +const files = new Set(pack.files.map((file) => file.path)); + +const missing = REQUIRED_FILES.filter((file) => !files.has(file)); +if (missing.length > 0) { + console.error( + `npm package is missing required file(s): ${missing.join(", ")}`, + ); + console.error(`Packed contents: ${[...files].sort().join(", ")}`); + process.exit(1); +} + +console.log(`Package contents OK (${files.size} files):`); +for (const file of REQUIRED_FILES) { + console.log(` ${file}`); +} diff --git a/scripts/verify-packed-install.mjs b/scripts/verify-packed-install.mjs new file mode 100755 index 0000000..a08837b --- /dev/null +++ b/scripts/verify-packed-install.mjs @@ -0,0 +1,53 @@ +#!/usr/bin/env node +// CI check: every existing test runs against the in-repo `dist/cli.js` or +// source, never a truly installed package. This packs the real tarball, +// installs it into a scratch project the way a real consumer would, and +// exercises the installed binary — catching a broken `bin` shebang or a +// `files`/tsup misconfig that `npm pack --dry-run` alone would miss. +import { execFileSync } from "node:child_process"; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +const repoRoot = process.cwd(); +const scratch = mkdtempSync(path.join(tmpdir(), "pluginpack-pack-smoke-")); + +try { + const packRaw = execFileSync( + "npm", + ["pack", "--json", "--pack-destination", scratch], + { cwd: repoRoot, encoding: "utf8" }, + ); + const [{ filename }] = JSON.parse(packRaw); + const tarballPath = path.join(scratch, filename); + + const installDir = path.join(scratch, "install"); + mkdirSync(installDir); + writeFileSync( + path.join(installDir, "package.json"), + JSON.stringify({ name: "pack-smoke-test", version: "0.0.0", private: true }), + ); + execFileSync("npm", ["install", tarballPath], { + cwd: installDir, + stdio: "inherit", + }); + + const bin = path.join(installDir, "node_modules", ".bin", "pluginpack"); + const version = execFileSync(bin, ["--version"], { encoding: "utf8" }).trim(); + if (!version) { + throw new Error("Installed CLI printed no version."); + } + console.log(`Installed CLI reports version: ${version}`); + + const initDir = path.join(installDir, "project"); + mkdirSync(initDir); + execFileSync(bin, ["init"], { cwd: initDir, stdio: "inherit" }); + if (!existsSync(path.join(initDir, "pluginpack.config.ts"))) { + throw new Error( + "pluginpack init did not create pluginpack.config.ts from the installed package.", + ); + } + console.log("Installed CLI's `init` command works."); +} finally { + rmSync(scratch, { recursive: true, force: true }); +} From 8cbb2cbbbcc62ee9060f20a40483799802be4e18 Mon Sep 17 00:00:00 2001 From: Steve Calvert Date: Tue, 28 Jul 2026 20:24:13 -0700 Subject: [PATCH 4/6] docs: bump stability badge to Prerelease, fix stale docs, document API caveats - README badge: Experimental -> Prerelease. Shipping a 1.0.0 tag under an "Experimental" badge (Glean's own stability doc: "not recommended for production use") directly contradicted the tag; Prerelease ("feature- complete, being validated before GA") matches where this actually stands. - skills/add-pluginpack-target/SKILL.md described the pre-registry architecture (src/targets.ts, src/validate.ts, ~5 touch points) that was deleted in the target-registry migration; rewritten to match CLAUDE.md's current architecture. - Added OpenAI Codex CLI to the three skill docs and the README Quick Start example that omitted it despite it being a fully supported 5th target. - Documented in README's Programmatic API section: the target set is closed (no third-party target-registration API), the package is deliberately ESM-only, and Artifact/ResolvedProject's Map fields don't survive JSON.stringify. Added the missing getInstallSnippetCitation/Citation entries to the same table. - Unexported SourceProvider from src/index.ts: nothing in build()/loadConfig() accepts a source override yet, so the exported type committed to public surface no one could actually use. - Documented the deliberate choice not to use zod's .strict() on config schemas, and what counts as a "breaking change" for a tool whose output is files written into a consumer's repo (RELEASE.md). - Re-verified the vendored Cursor schemas are still byte-identical to upstream and recorded that check in SOURCE.md. Co-Authored-By: Claude Sonnet 5 --- README.md | 20 +++++++++++-- RELEASE.md | 32 +++++++++++++++++++++ skills/add-pluginpack-target/SKILL.md | 29 ++++++++++--------- skills/authoring-pluginpack-config/SKILL.md | 6 +++- skills/migrate-to-pluginpack/SKILL.md | 5 ++-- snippets/readme/snippet-02.ts | 6 ++++ src/index.ts | 1 - src/schema.ts | 13 ++++++++- src/types.ts | 5 +++- tests/fixtures/cursor/SOURCE.md | 6 ++++ 10 files changed, 100 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 740e1b4..be71dbf 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # pluginpack -[![Experimental](https://img.shields.io/badge/-Experimental-D8FD49?style=flat-square&logo=data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIwIDAgMzIgMzIiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxwYXRoIGQ9Ik0yNC4zMDA2IDIuOTU0MjdMMjAuNzY1NiAwLjE5OTk1MUwxNy45MDI4IDMuOTk1MjdDMTMuNTY1MyAxLjkzNDk1IDguMjMwMTkgMy4wODQzOSA1LjE5Mzk0IDcuMDA5ODNDMS42NTg4OCAxMS41NjQyIDIuNDgzIDE4LjExMzggNy4wMzczOCAyMS42NDg5QzguNzcyMzggMjIuOTkzNSAxMC43ODkzIDIzLjcwOTIgMTIuODI3OSAyMy44MTc3QzE2LjE0NjEgMjQuMDEyOCAxOS41MDc3IDIyLjYyNDggMjEuNjc2NSAxOS44MDU1QzI0LjczNDQgMTUuODggMjQuNTE3NSAxMC40MTQ4IDIxLjQ1OTYgNi43Mjc4OUwyNC4zMDA2IDIuOTU0MjdaTTE4LjExOTcgMTcuMDUxMkMxNi4xMDI4IDE5LjYzMiAxMi4zNzI1IDIwLjEwOTEgOS43NzAwMSAxOC4wOTIyQzcuMTg5MTkgMTYuMDc1MiA2LjcxMjA3IDEyLjMyMzMgOC43MjkwMSA5Ljc0MjQ2QzkuNzA0OTQgOC40ODQ1OCAxMS4xMTQ2IDcuNjgyMTQgMTIuNjc2MSA3LjQ4Njk2QzEzLjA0NDggNy40NDM1OCAxMy40MTM1IDcuNDIxOSAxMy43ODIyIDcuNDQzNThDMTQuOTc1IDcuNTA4NjUgMTYuMTI0NCA3Ljk0MjM5IDE3LjA3ODcgOC42Nzk3N0MxOS42NTk1IDEwLjcxODQgMjAuMTM2NiAxNC40NzAzIDE4LjExOTcgMTcuMDUxMloiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGQ9Ik0yNC41MTc2IDIxLjY5MjJDMjMuOTMyIDIyLjQ1MTMgMjMuMjgxNCAyMy4xMjM2IDIyLjU2NTcgMjMuNzUyNUMyMS44NzE3IDI0LjMzODEgMjEuMTEyNyAyNC44ODAzIDIwLjMxMDIgMjUuMzM1N0MxOS41Mjk1IDI1Ljc2OTUgMTguNjgzNyAyNi4xMzgyIDE3LjgzNzggMjYuNDIwMUMxNi45OTIgMjYuNzAyIDE2LjEwMjggMjYuODk3MiAxNS4yMTM3IDI3LjAwNTdDMTQuMzI0NSAyNy4xMTQxIDEzLjQzNTMgMjcuMTU3NSAxMi41MjQ0IDI3LjA5MjRDMTEuNjEzNSAyNy4wMjczIDEwLjcyNDMgMjYuODc1NSA5Ljg1Njg0IDI2LjY1ODdMOS42NjE2NSAyNy4zNzQzTDguNzcyNDYgMzAuOTk2MkM5LjkwMDIxIDMxLjI5OTggMTEuMDQ5NyAzMS40NzMzIDEyLjIyMDggMzEuNTZDMTIuMjY0MiAzMS41NiAxMi4zMjkyIDMxLjU2IDEyLjM3MjYgMzEuNTZDMTMuNTAwMyAzMS42MjUxIDE0LjY0OTggMzEuNTgxNyAxNS43NTU4IDMxLjQ1MTZDMTYuOTI3IDMxLjI5OTggMTguMDk4MSAzMS4wMzk1IDE5LjIyNTggMzAuNjcwOEMyMC4zNTM2IDMwLjMwMjIgMjEuNDU5NyAyOS44MjUgMjIuNTAwNyAyOS4yMzk1QzIzLjU2MzQgMjguNjUzOSAyNC41NjEgMjcuOTM4MiAyNS40OTM1IDI3LjE1NzVDMjYuNDQ3OCAyNi4zNTUgMjcuMzE1MyAyNS40NDQyIDI4LjA3NDQgMjQuNDQ2NUMyOC4xODI4IDI0LjMxNjQgMjguMjY5NSAyNC4xNjQ2IDI4LjM3OCAyNC4wMTI4TDI0Ljc3NzkgMjEuMzQ1MkMyNC42Njk0IDIxLjQ1MzcgMjQuNjA0NCAyMS41ODM4IDI0LjUxNzYgMjEuNjkyMloiIGZpbGw9IndoaXRlIi8+Cjwvc3ZnPg==&labelColor=343CED)](https://github.com/gleanwork/.github/blob/main/docs/repository-stability.md#experimental) +[![Prerelease](https://img.shields.io/badge/-Prerelease-F6F3EB?style=flat-square&logo=data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIwIDAgMzIgMzIiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxwYXRoIGQ9Ik0yNC4zMDA2IDIuOTU0MjdMMjAuNzY1NiAwLjE5OTk1MUwxNy45MDI4IDMuOTk1MjdDMTMuNTY1MyAxLjkzNDk1IDguMjMwMTkgMy4wODQzOSA1LjE5Mzk0IDcuMDA5ODNDMS42NTg4OCAxMS41NjQyIDIuNDgzIDE4LjExMzggNy4wMzczOCAyMS42NDg5QzguNzcyMzggMjIuOTkzNSAxMC43ODkzIDIzLjcwOTIgMTIuODI3OSAyMy44MTc3QzE2LjE0NjEgMjQuMDEyOCAxOS41MDc3IDIyLjYyNDggMjEuNjc2NSAxOS44MDU1QzI0LjczNDQgMTUuODggMjQuNTE3NSAxMC40MTQ4IDIxLjQ1OTYgNi43Mjc4OUwyNC4zMDA2IDIuOTU0MjdaTTE4LjExOTcgMTcuMDUxMkMxNi4xMDI4IDE5LjYzMiAxMi4zNzI1IDIwLjEwOTEgOS43NzAwMSAxOC4wOTIyQzcuMTg5MTkgMTYuMDc1MiA2LjcxMjA3IDEyLjMyMzMgOC43MjkwMSA5Ljc0MjQ2QzkuNzA0OTQgOC40ODQ1OCAxMS4xMTQ2IDcuNjgyMTQgMTIuNjc2MSA3LjQ4Njk2QzEzLjA0NDggNy40NDM1OCAxMy40MTM1IDcuNDIxOSAxMy43ODIyIDcuNDQzNThDMTQuOTc1IDcuNTA4NjUgMTYuMTI0NCA3Ljk0MjM5IDE3LjA3ODcgOC42Nzk3N0MxOS42NTk1IDEwLjcxODQgMjAuMTM2NiAxNC40NzAzIDE4LjExOTcgMTcuMDUxMloiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGQ9Ik0yNC41MTc2IDIxLjY5MjJDMjMuOTMyIDIyLjQ1MTMgMjMuMjgxNCAyMy4xMjM2IDIyLjU2NTcgMjMuNzUyNUMyMS44NzE3IDI0LjMzODEgMjEuMTEyNyAyNC44ODAzIDIwLjMxMDIgMjUuMzM1N0MxOS41Mjk1IDI1Ljc2OTUgMTguNjgzNyAyNi4xMzgyIDE3LjgzNzggMjYuNDIwMUMxNi45OTIgMjYuNzAyIDE2LjEwMjggMjYuODk3MiAxNS4yMTM3IDI3LjAwNTdDMTQuMzI0NSAyNy4xMTQxIDEzLjQzNTMgMjcuMTU3NSAxMi41MjQ0IDI3LjA5MjRDMTEuNjEzNSAyNy4wMjczIDEwLjcyNDMgMjYuODc1NSA5Ljg1Njg0IDI2LjY1ODdMOS42NjE2NSAyNy4zNzQzTDguNzcyNDYgMzAuOTk2MkM5LjkwMDIxIDMxLjI5OTggMTEuMDQ5NyAzMS40NzMzIDEyLjIyMDggMzEuNTZDMTIuMjY0MiAzMS41NiAxMi4zMjkyIDMxLjU2IDEyLjM3MjYgMzEuNTZDMTMuNTAwMyAzMS42MjUxIDE0LjY0OTggMzEuNTgxNyAxNS43NTU4IDMxLjQ1MTZDMTYuOTI3IDMxLjI5OTggMTguMDk4MSAzMS4wMzk1IDE5LjIyNTggMzAuNjcwOEMyMC4zNTM2IDMwLjMwMjIgMjEuNDU5NyAyOS44MjUgMjIuNTAwNyAyOS4yMzk1QzIzLjU2MzQgMjguNjUzOSAyNC41NjEgMjcuOTM4MiAyNS40OTM1IDI3LjE1NzVDMjYuNDQ3OCAyNi4zNTUgMjcuMzE1MyAyNS40NDQyIDI4LjA3NDQgMjQuNDQ2NUMyOC4xODI4IDI0LjMxNjQgMjguMjY5NSAyNC4xNjQ2IDI4LjM3OCAyNC4wMTI4TDI0Ljc3NzkgMjEuMzQ1MkMyNC42Njk0IDIxLjQ1MzcgMjQuNjA0NCAyMS41ODM4IDI0LjUxNzYgMjEuNjkyMloiIGZpbGw9IndoaXRlIi8+Cjwvc3ZnPg==&labelColor=343CED)](https://github.com/gleanwork/.github/blob/main/docs/repository-stability.md#prerelease) [![npm version](https://img.shields.io/npm/v/@gleanwork/pluginpack.svg)](https://www.npmjs.com/package/@gleanwork/pluginpack) [![CI](https://github.com/gleanwork/pluginpack/actions/workflows/ci.yml/badge.svg)](https://github.com/gleanwork/pluginpack/actions/workflows/ci.yml) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) @@ -89,6 +89,12 @@ export default defineConfig({ acme: { from: ["core"] }, }, }, + codex: { + outDir: "plugins/codex", + plugins: { + acme: { from: ["core"] }, + }, + }, }, }); ``` @@ -100,7 +106,7 @@ npx pluginpack build npx pluginpack validate --target cursor ``` -Users who only want portable skills install from the `skills/` subpath, for example `npx skills add owner/repo/skills --skill '*'`. Claude, Cursor, Antigravity, and Copilot users install from the generated native layout that can include skills, agents, rules, hooks, assets, MCP config, and target-specific manifests. +Users who only want portable skills install from the `skills/` subpath, for example `npx skills add owner/repo/skills --skill '*'`. Claude, Cursor, Antigravity, Copilot, and Codex users install from the generated native layout that can include skills, agents, rules, hooks, assets, MCP config, and target-specific manifests. ## Mental Model @@ -491,6 +497,7 @@ import { prune, clean, buildInstallSnippet, + getInstallSnippetCitation, getSupportedInstallTargets, getUnsupportedInstallTargets, } from "@gleanwork/pluginpack"; @@ -506,6 +513,7 @@ import { | `prune(options?)` | `Promise` | Remove stale managed files no longer emitted by the config. | | `clean(options?)` | `Promise` | Remove all managed files for configured targets. | | `buildInstallSnippet(target, params)` | `InstallSnippet` | The install command/URL for one target (see **Install Snippet**). | +| `getInstallSnippetCitation(target)` | `Citation` | The documentation source backing that target's install snippet. | | `getSupportedInstallTargets()` | `TargetName[]` | Targets with a real install snippet today. | | `getUnsupportedInstallTargets()` | `TargetName[]` | Targets with none (empty today, kept for forward-compatibility). | @@ -515,7 +523,13 @@ Option objects: - `diffTarget` — `{ cwd?, configPath?, target, against }` - `prune` / `clean` — `{ cwd?, configPath?, target?, dryRun?, force? }` -The result and config types (`Artifact`, `DiffResult`/`DiffEntry`, `ValidationResult`/`ValidationIssue`, `CleanupResult`/`CleanupEntry`, `ResolvedProject`, `PluginpackConfig`, `TargetConfig`, `TargetName`, …) are all exported for use in TypeScript. +The result and config types (`Artifact`, `DiffResult`/`DiffEntry`, `ValidationResult`/`ValidationIssue`, `CleanupResult`/`CleanupEntry`, `ResolvedProject`, `PluginpackConfig`, `TargetConfig`, `TargetName`, `Citation`, `InstallSnippet`, …) are all exported for use in TypeScript. + +A few things worth knowing about this surface before depending on it: + +- **The target set is closed.** `TargetName` is `"claude" | "cursor" | "antigravity" | "copilot" | "codex"` today, with no public API for registering a sixth target — adding one means a PR to this repo (see `PluginTargetDefinition` in `src/targets/types.ts`, not exported). There is no supported third-party target-extension mechanism. +- **The package is ESM-only.** `package.json`'s `exports` map has no `require` condition; a CommonJS consumer needs dynamic `import()`. This is a deliberate choice, not a tsup default left unexamined. +- **`Artifact.files` and `ResolvedProject.plugins` are `Map`s, not plain objects.** `JSON.stringify()` on either silently produces `{}` — iterate with `for...of`/`Object.fromEntries()` instead of serializing directly if you need to log or transport a result. diff --git a/RELEASE.md b/RELEASE.md index df098f3..f452149 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -58,3 +58,35 @@ release process. It will prompt you to to choose the version number after which you will have the chance to hand tweak the changelog to be used (for the `CHANGELOG.md` and GitHub release), then `release-it` continues on to tagging, pushing the tag and commits, etc. + +## What counts as a breaking change + +pluginpack's public contract isn't just its TypeScript API (`src/index.ts`) +and CLI flags — it's also the _shape of the files it writes into a consumer's +repo_ (managed-file layout, manifest field names, default directory +conventions). A change that alters generated output for an existing config +with no code-level signature change (e.g. a different default `outDir` +convention, a renamed manifest field, a different `.pluginpack/.json` +shape) is breaking for the same reason a database migration is breaking: it +can affect a consumer's already-generated, already-committed repo on their +next build, even though nothing in `import { ... } from "@gleanwork/pluginpack"` +changed. Label PRs with `breaking` under that broader definition, not just for +API/CLI signature changes. + +## PR label enforcement + +CI (`.github/workflows/ci.yml`, `labels` job) requires every PR to carry one +of the five labels above before merge — this catches an unlabeled PR, but it +cannot judge whether the label chosen is the _correct_ one. Whether a given +diff is actually breaking under the definition above is still a human call at +review time. + +## Accepted risk: dev-only audit findings + +`npm audit` (unscoped) reports vulnerabilities in `@release-it-plugins/lerna-changelog`'s +dependency chain (`lerna-changelog` → `make-fetch-happen` → `cacache`/`tar`), +with no upstream fix available. `npm run audit` (what CI runs) is scoped to +`--omit=dev` and reports clean, per `CLAUDE.md`'s documented rationale: +devDependencies never ship in the published package. This is a tracked, +accepted gap, not an oversight — revisit if `lerna-changelog` is ever run +against untrusted input, or replace it if a maintained alternative appears. diff --git a/skills/add-pluginpack-target/SKILL.md b/skills/add-pluginpack-target/SKILL.md index f9e5fea..87e29b7 100644 --- a/skills/add-pluginpack-target/SKILL.md +++ b/skills/add-pluginpack-target/SKILL.md @@ -11,28 +11,29 @@ A target adapter owns one app's native layout, manifests, and validation. Find the app's actual plugin format from official docs or a real published plugin repo (the way the existing adapters were derived from `github/copilot-plugins`, -`gleanwork/cursor-plugins`, and the Claude plugins reference). Capture an +`gleanwork/cursor-plugins`, the Claude plugins reference, and the documented +OpenAI Codex CLI plugin format). Capture an external oracle you can validate against — a published JSON Schema, the app's own validator CLI, or a real repo to `diff` against. Record it in `CONFORMANCE.md`. ## Touch points -Adding a target currently means editing ~5 places: +Adding a target means: 1. `src/types.ts` — add the name to the `TargetName` union. -2. `src/cli.ts` — add it to the `targets` array and `parseTarget`. -3. `src/build.ts` — add it to `allTargets`. -4. `src/targets.ts` — add an `emitFoo` and register it in the `emitters` map, - plus a manifest builder. Reuse the `emitPlugins` engine if the target has a - per-plugin manifest + a marketplace (like cursor/claude); write a bespoke - emitter otherwise (like copilot). -5. `src/validate.ts` — add a branch and a `validateFoo`. - -> Because the touch points are spread across five files, consider introducing a -> single target registry (one object per target carrying its emitter, validator, -> and defaults) before or as part of adding the target. The cost of the registry -> is repaid immediately by the target you are adding. +2. `src/targets/.ts` — implement `PluginTargetDefinition` (see + `src/targets/types.ts`): emit logic, manifest/marketplace builder, + validation, install snippet, and defaults. +3. `src/targets/registry.ts` — add one entry mapping the name to that + definition. + +Everything else — the CLI's `--target` choices, `build()`'s target set, and +emit/validate dispatch — derives from the registry automatically via the +shared engine in `src/targets/engine.ts`. Reuse `src/targets/validation-shared.ts` +for checks whose shape genuinely matches the other targets (hooks, frontmatter); +write your own where the shape is genuinely different, the way codex's +structured `source` object does. ## Wire MCP diff --git a/skills/authoring-pluginpack-config/SKILL.md b/skills/authoring-pluginpack-config/SKILL.md index e9d08da..c9b328f 100644 --- a/skills/authoring-pluginpack-config/SKILL.md +++ b/skills/authoring-pluginpack-config/SKILL.md @@ -1,6 +1,6 @@ --- name: authoring-pluginpack-config -description: Use when someone wants to ship one set of agent skills/plugins to multiple AI apps (Claude Code, Cursor, Antigravity CLI, GitHub Copilot) from a single source, and needs to create or update a pluginpack.config.ts — choosing targets, source layout, MCP servers, and output directories. +description: Use when someone wants to ship one set of agent skills/plugins to multiple AI apps (Claude Code, Cursor, Antigravity CLI, GitHub Copilot, OpenAI Codex CLI) from a single source, and needs to create or update a pluginpack.config.ts — choosing targets, source layout, MCP servers, and output directories. --- # Authoring a pluginpack config @@ -59,6 +59,10 @@ export default defineConfig({ outDir: "plugins/copilot", plugins: { acme: { from: ["core"] } }, }, + codex: { + outDir: "plugins/codex", + plugins: { acme: { from: ["core"] } }, + }, }, }); ``` diff --git a/skills/migrate-to-pluginpack/SKILL.md b/skills/migrate-to-pluginpack/SKILL.md index 30c2da2..8c4534f 100644 --- a/skills/migrate-to-pluginpack/SKILL.md +++ b/skills/migrate-to-pluginpack/SKILL.md @@ -1,6 +1,6 @@ --- name: migrate-to-pluginpack -description: Use when someone has an existing native plugin repo (a Claude Code marketplace, a Cursor plugin, an Antigravity CLI plugin, or a GitHub Copilot plugin) and wants to manage it from one portable source via pluginpack — generating the source layout and config, then proving the output matches the original with diff. +description: Use when someone has an existing native plugin repo (a Claude Code marketplace, a Cursor plugin, an Antigravity CLI plugin, a GitHub Copilot plugin, or an OpenAI Codex CLI plugin) and wants to manage it from one portable source via pluginpack — generating the source layout and config, then proving the output matches the original with diff. --- # Migrating an existing plugin repo into pluginpack @@ -11,7 +11,8 @@ source, verified byte-for-byte against the original. ## Steps 1. **Inspect the existing repo.** Note its marketplace manifest - (`.claude-plugin/marketplace.json`, `.cursor-plugin/marketplace.json`, …), + (`.claude-plugin/marketplace.json`, `.cursor-plugin/marketplace.json`, + `.codex-plugin/plugin.json`, …), the per-plugin layout, and the skill/agent/command/hook/MCP content. 2. **Create portable source.** Put skills under a repo-level `skills/` directory diff --git a/snippets/readme/snippet-02.ts b/snippets/readme/snippet-02.ts index 69dcd51..9ca2cbc 100644 --- a/snippets/readme/snippet-02.ts +++ b/snippets/readme/snippet-02.ts @@ -44,5 +44,11 @@ export default defineConfig({ acme: { from: ["core"] }, }, }, + codex: { + outDir: "plugins/codex", + plugins: { + acme: { from: ["core"] }, + }, + }, }, }); diff --git a/src/index.ts b/src/index.ts index d0aa61e..9c51b51 100644 --- a/src/index.ts +++ b/src/index.ts @@ -29,7 +29,6 @@ export type { PluginpackConfig, ResolvedProject, SourcePlugin, - SourceProvider, TargetConfig, TargetName, ValidationIssue, diff --git a/src/schema.ts b/src/schema.ts index 492238c..14586c6 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -91,7 +91,18 @@ const targetSchema = z.object({ rootFiles: z.record(safeRelativePath, safeRelativePath).optional(), }); -/** The root `pluginpack.config.ts` schema. */ +/** + * The root `pluginpack.config.ts` schema. + * + * Deliberately not `.strict()`: zod's default `z.object()` silently drops + * unknown keys rather than erroring, which is the right tradeoff pre-1.0 — it + * lets a future minor version add an optional config field without that + * being a breaking change for existing configs. The cost is a typo'd key + * failing silently instead of loudly; that's an accepted tradeoff, not an + * oversight. Adding `.strict()` later would itself be a breaking change for + * anyone currently typo-ing successfully, so this needs to be a deliberate + * decision made once, not toggled casually. + */ const configSchema = z .object({ name: z.string().min(1), diff --git a/src/types.ts b/src/types.ts index 4446028..dcdf0af 100644 --- a/src/types.ts +++ b/src/types.ts @@ -36,7 +36,10 @@ export type SourcePlugin = { * The one surface pluginpack uses to acquire source. A filesystem provider * backs it today; an API-backed provider (e.g. a remote skills API) can * implement the same two methods without touching the emit/validate/diff - * pipeline. + * pipeline. Deliberately not exported from `src/index.ts`: nothing in + * `build()`/`loadConfig()` accepts an override yet, so exporting the type + * would commit to public surface no one can actually use. Wire a `source` + * override into `BuildOptions` before re-exporting this. */ export interface SourceProvider { readPluginFiles( diff --git a/tests/fixtures/cursor/SOURCE.md b/tests/fixtures/cursor/SOURCE.md index 2f7dce9..fe54d83 100644 --- a/tests/fixtures/cursor/SOURCE.md +++ b/tests/fixtures/cursor/SOURCE.md @@ -5,6 +5,8 @@ plugin/marketplace JSON Schemas, authored outside this repo. - Source: `gleanwork/cursor-plugins` → `schemas/` - Commit: `424c3485` (fetched 2026-05-29) +- Re-verified byte-identical against upstream `schemas/` (commit `6f3ad6e6`) on + 2026-07-28 — no re-fetch needed. Do not hand-edit. Re-fetch to update: @@ -14,3 +16,7 @@ gh api repos/gleanwork/cursor-plugins/contents/schemas/marketplace.schema.json \ gh api repos/gleanwork/cursor-plugins/contents/schemas/plugin.schema.json \ -H "Accept: application/vnd.github.raw" > plugin.schema.json ``` + +Staleness has no automated check today — re-run the fetch above and diff +against the vendored copies periodically, especially before a stability-tier +bump, since CONFORMANCE.md already flags this oracle as self-referential. From 3aef5e6208e58ab862ee9cad1316b551838b545b Mon Sep 17 00:00:00 2001 From: Steve Calvert Date: Tue, 28 Jul 2026 20:24:24 -0700 Subject: [PATCH 5/6] test: add CLI-binary-level smoke tests for init, validate, diff, prune, clean, docs Only `build` and `install-info` were ever exercised as the real built binary via bintastic; `validate`/`diff`/`prune`/`clean`/`init`/`docs` bypassed cli.ts's commander wiring entirely, so a broken option parse or wrong exit code in any of them had no test that could catch it. `init` in particular had zero coverage at any level, since it isn't a library function. Co-Authored-By: Claude Sonnet 5 --- tests/conformance.test.ts | 111 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) diff --git a/tests/conformance.test.ts b/tests/conformance.test.ts index 4ad13b0..c822ea0 100644 --- a/tests/conformance.test.ts +++ b/tests/conformance.test.ts @@ -418,4 +418,115 @@ describe("emitted output conforms to external target schemas", () => { expect(result.exitCode).toBe(1); expect(result.stderr).toContain("has no repository configured"); }); + + it("init scaffolds a starter config and source plugin in an empty project", async () => { + project = await setupProject(); + + const result = await runBin("init"); + expect(result.exitCode, String(result.stderr)).toBe(0); + expect(result.stdout).toContain( + "Created pluginpack.config.ts and plugins/example.", + ); + expect( + fs.existsSync(path.join(project.baseDir, "pluginpack.config.ts")), + ).toBe(true); + expect( + fs.existsSync( + path.join(project.baseDir, "plugins/example/skills/example/SKILL.md"), + ), + ).toBe(true); + }); + + it("init refuses to overwrite an existing pluginpack.config.ts", async () => { + const result = await runBin("init"); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("pluginpack.config.ts already exists"); + }); + + it("validate passes for a freshly built target and fails for a missing one", async () => { + const built = await runBin("build", "--target", "cursor"); + expect(built.exitCode, String(built.stderr)).toBe(0); + + const pass = await runBin("validate", "--target", "cursor"); + expect(pass.exitCode, String(pass.stderr)).toBe(0); + expect(pass.stdout).toContain("Validation passed."); + + const fail = await runBin( + "validate", + "--target", + "claude", + "--dir", + "nonexistent-dir", + ); + expect(fail.exitCode).toBe(1); + }); + + it("diff reports a match immediately after a build", async () => { + const built = await runBin("build", "--target", "cursor"); + expect(built.exitCode, String(built.stderr)).toBe(0); + + const diffed = await runBin( + "diff", + "--target", + "cursor", + "--against", + "out-cursor", + ); + expect(diffed.exitCode, String(diffed.stderr)).toBe(0); + expect(diffed.stdout).toContain("Managed files match."); + }); + + it("prune and clean remove stale and managed files via the built binary", async () => { + const built = await runBin("build", "--target", "cursor"); + expect(built.exitCode, String(built.stderr)).toBe(0); + const skillFile = path.join( + project.baseDir, + "out-cursor/glean/skills/example/SKILL.md", + ); + expect(fs.existsSync(skillFile)).toBe(true); + + // Removing the source skill makes the previously-generated file stale. + fs.rmSync(path.join(project.baseDir, "plugins/glean/skills/example"), { + recursive: true, + force: true, + }); + const pruned = await runBin("prune", "--target", "cursor"); + expect(pruned.exitCode, String(pruned.stderr)).toBe(0); + expect(pruned.stdout).toContain("Pruned"); + expect(fs.existsSync(skillFile)).toBe(false); + + const manifestFile = path.join( + project.baseDir, + "out-cursor/.pluginpack/cursor.json", + ); + expect(fs.existsSync(manifestFile)).toBe(true); + const cleaned = await runBin("clean", "--target", "cursor"); + expect(cleaned.exitCode, String(cleaned.stderr)).toBe(0); + expect(cleaned.stdout).toContain("Cleaned"); + expect(fs.existsSync(manifestFile)).toBe(false); + }); + + it("docs --check reports current vs. stale README CLI reference", async () => { + const readme = [ + "# pluginpack", + "", + "", + "", + "", + ].join("\n"); + await project.write({ "README.md": readme }); + + const sync = await runBin("docs"); + expect(sync.exitCode, String(sync.stderr)).toBe(0); + expect(sync.stdout).toContain("Updated README.md CLI reference."); + + const check = await runBin("docs", "--check"); + expect(check.exitCode, String(check.stderr)).toBe(0); + expect(check.stdout).toContain("README.md CLI reference is up to date."); + + await project.write({ "README.md": readme }); + const stale = await runBin("docs", "--check"); + expect(stale.exitCode).toBe(1); + expect(stale.stderr).toContain("README.md CLI reference is out of date"); + }); }); From 021dfe6a3d9fa893caf37fa925516eb9c98470f0 Mon Sep 17 00:00:00 2001 From: Steve Calvert Date: Tue, 28 Jul 2026 20:27:10 -0700 Subject: [PATCH 6/6] ci: fix label check to react to labels added after PR creation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The labels job only ran on the default pull_request activity types (opened/synchronize/reopened), so a label attached moments after creation (e.g. via `gh pr create --label`, or by a maintainer during review) never retriggered the check — observed on this PR's own first run. Split it into its own workflow triggered on labeled/unlabeled too, and dropped it from ci.yml's default trigger so the main test matrix doesn't needlessly rerun on every label change. Co-Authored-By: Claude Sonnet 5 --- .github/workflows/ci.yml | 17 ----------------- .github/workflows/pr-label.yml | 22 ++++++++++++++++++++++ 2 files changed, 22 insertions(+), 17 deletions(-) create mode 100644 .github/workflows/pr-label.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 50fb5be..5ff6178 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,20 +44,3 @@ jobs: - name: Verify packed install runs run: node scripts/verify-packed-install.mjs - - labels: - runs-on: ubuntu-latest - if: github.event_name == 'pull_request' - steps: - - name: Require a lerna-changelog label - uses: actions/github-script@v8 - with: - script: | - const required = ["breaking", "enhancement", "bug", "documentation", "internal"]; - const labels = context.payload.pull_request.labels.map((label) => label.name); - const matched = labels.filter((label) => required.includes(label)); - if (matched.length === 0) { - core.setFailed( - `This PR needs one of the following labels for the changelog: ${required.join(", ")}.`, - ); - } diff --git a/.github/workflows/pr-label.yml b/.github/workflows/pr-label.yml new file mode 100644 index 0000000..2358251 --- /dev/null +++ b/.github/workflows/pr-label.yml @@ -0,0 +1,22 @@ +name: PR label + +on: + pull_request: + types: [opened, reopened, synchronize, labeled, unlabeled] + +jobs: + labels: + runs-on: ubuntu-latest + steps: + - name: Require a lerna-changelog label + uses: actions/github-script@v8 + with: + script: | + const required = ["breaking", "enhancement", "bug", "documentation", "internal"]; + const labels = context.payload.pull_request.labels.map((label) => label.name); + const matched = labels.filter((label) => required.includes(label)); + if (matched.length === 0) { + core.setFailed( + `This PR needs one of the following labels for the changelog: ${required.join(", ")}.`, + ); + }