From fbe2b60e63831a556ee829ed113ff246824a89f8 Mon Sep 17 00:00:00 2001 From: Steve Calvert Date: Wed, 21 Oct 2026 14:25:06 -0700 Subject: [PATCH 01/24] fix(fs): fail when a component directory is a file walkFiles treated ENOTDIR as "directory missing", so a source plugin whose skills (or any component) path was a regular file built successfully and shipped nothing from it. It now throws when the directory itself exists as a file, naming the path. A path below a file is still read as missing. --- src/fs.ts | 29 +++++++++- tests/physical-path-safety.test.ts | 89 ++++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+), 1 deletion(-) create mode 100644 tests/physical-path-safety.test.ts diff --git a/src/fs.ts b/src/fs.ts index 1b77464..73bdcd8 100644 --- a/src/fs.ts +++ b/src/fs.ts @@ -12,7 +12,9 @@ export function toPosix(value: string): string { * Dotfiles are included. Symlinks are followed: a link to a file is listed * as a file and a link to a directory is walked, except a link back to a * directory already being walked, which would loop. Broken links are - * skipped. A missing `dir` yields no files. + * skipped. A missing `dir` yields no files; a `dir` that is a file is an + * error, since reading it as an empty directory would silently drop content + * the author meant to ship. */ export async function walkFiles(dir: string): Promise { const files: string[] = []; @@ -23,6 +25,12 @@ export async function walkFiles(dir: string): Promise { real = await fs.realpath(current); entries = await fs.readdir(current, { withFileTypes: true }); } catch (err) { + if (await isExistingNonDirectory(err, current)) { + throw new Error( + `Expected a directory but found a file: ${current}. Move the file into a directory of that name, or remove it.`, + { cause: err }, + ); + } if (isNotFoundError(err)) { return; } @@ -193,3 +201,22 @@ export function isNotFoundError(error: unknown): boolean { const code = (error as NodeJS.ErrnoException).code; return code === "ENOENT" || code === "ENOTDIR"; } + +/** + * Whether `error` is an ENOTDIR raised because `dir` itself exists as a file, + * rather than because some ancestor of it is a file (which means `dir` is + * simply missing). + */ +async function isExistingNonDirectory( + error: unknown, + dir: string, +): Promise { + if ( + !(error instanceof Error) || + (error as NodeJS.ErrnoException).code !== "ENOTDIR" + ) { + return false; + } + const stats = await fs.stat(dir).catch(() => null); + return stats !== null && !stats.isDirectory(); +} diff --git a/tests/physical-path-safety.test.ts b/tests/physical-path-safety.test.ts new file mode 100644 index 0000000..b69681d --- /dev/null +++ b/tests/physical-path-safety.test.ts @@ -0,0 +1,89 @@ +import { rm, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { Project } from "fixturify-project"; +import { afterEach, describe, expect, it } from "vitest"; +import { build } from "../src/build.js"; +import { walkFiles } from "../src/fs.js"; + +let project: Project | undefined; + +afterEach(async () => { + await project?.dispose(); + project = undefined; +}); + +const skill = (name: string) => + `---\nname: ${name}\ndescription: ${name} skill.\n---\n\n${name} body.\n`; + +function configText(targetsBlock: string, sourceBlock = ""): string { + return `import { defineConfig } from "${path.resolve("src/index.ts")}"; + +export default defineConfig({ + name: "physical-demo", + version: "1.0.0", + ${sourceBlock} + metadata: { description: "P", author: { name: "P" }, license: "MIT" }, + targets: { +${targetsBlock} + } +}); +`; +} + +/** + * A project with one canonical source plugin at `shared/acme` holding the + * skills `alpha` and `beta`. `targetsBlock` and `sourceBlock` are spliced into + * the config verbatim. + */ +async function makeProject( + targetsBlock: string, + sourceBlock = "", +): Promise { + project = new Project("pluginpack-physical", "1.0.0", { + files: { + "pluginpack.config.ts": configText(targetsBlock, sourceBlock), + shared: { + acme: { + skills: { + alpha: { "SKILL.md": skill("alpha") }, + beta: { "SKILL.md": skill("beta") }, + }, + }, + }, + "UNMANAGED.txt": "USER FILE\n", + }, + }); + await project.write(); + return project.baseDir; +} + +describe("walkFiles distinguishes a missing directory from a file", () => { + it("throws when the directory is a file", async () => { + const root = await makeProject(` antigravity: { + outDir: "out", + plugins: { acme: { source: "shared/acme" } } + }`); + await writeFile(path.join(root, "not-a-dir"), "x\n"); + + await expect(walkFiles(path.join(root, "not-a-dir"))).rejects.toThrow( + `Expected a directory but found a file: ${path.join(root, "not-a-dir")}`, + ); + await expect(walkFiles(path.join(root, "missing"))).resolves.toEqual([]); + await expect( + walkFiles(path.join(root, "not-a-dir/below")), + ).resolves.toEqual([]); + }); + + it("fails a build whose source component directory is a file", async () => { + const root = await makeProject(` antigravity: { + outDir: "out", + plugins: { acme: { source: "shared/acme" } } + }`); + await rm(path.join(root, "shared/acme/skills"), { recursive: true }); + await writeFile(path.join(root, "shared/acme/skills"), "oops\n"); + + await expect(build({ cwd: root })).rejects.toThrow( + /Expected a directory but found a file: .*shared\/acme\/skills/, + ); + }); +}); From 886db5c3a0e1bc490aebcb422169309881fa647b Mon Sep 17 00:00:00 2001 From: Steve Calvert Date: Wed, 21 Oct 2026 14:26:58 -0700 Subject: [PATCH 02/24] fix(config): keep source.partials inside the project source.partials was a bare string, so "../outside" or "/etc" was accepted and its files were inlined into shipped skill files. Partials end up in published output, so the field now uses the same safe-relative-path check as the other source paths and is rejected at config load. --- README.md | 2 +- src/schema.ts | 4 +++- tests/physical-path-safety.test.ts | 17 +++++++++++++++++ 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 626eded..4d6c6f6 100644 --- a/README.md +++ b/README.md @@ -583,7 +583,7 @@ rest of the artifact. | ------------ | ------ | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `plugins` | string | no | Directory to discover source plugins from. Defaults to `plugins`. | | `skills` | string | no | Repo-level skills directory; creates a root source plugin from sibling component dirs. | -| `partials` | string | no | Directory of reusable `{{> name}}` text fragments, shared across every source plugin (see **Partials**). | +| `partials` | string | no | Directory of reusable `{{> name}}` text fragments, shared across every source plugin (see **Partials**). Safe relative: it must sit inside the project. | | `rootPlugin` | object | no | Metadata for that root skills plugin. Accepts all **`metadata`** fields plus `id`, `name`, `description`. `id` is the source-plugin name used in each target's `from` array. | **`metadata`** (and `source.rootPlugin`) diff --git a/src/schema.ts b/src/schema.ts index f6acb7d..fd2861e 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -41,7 +41,9 @@ const rootPluginSchema = metadataSchema.extend({ const sourceSchema = z.object({ plugins: z.string().optional(), skills: z.string().optional(), - partials: z.string().optional(), + // Partials are inlined into shipped output, so reading them from outside the + // project would publish files that are not part of it. + partials: safeRelativePath.optional(), rootPlugin: rootPluginSchema.optional(), }); diff --git a/tests/physical-path-safety.test.ts b/tests/physical-path-safety.test.ts index b69681d..8bb49e7 100644 --- a/tests/physical-path-safety.test.ts +++ b/tests/physical-path-safety.test.ts @@ -3,6 +3,7 @@ import path from "node:path"; import { Project } from "fixturify-project"; import { afterEach, describe, expect, it } from "vitest"; import { build } from "../src/build.js"; +import { loadConfig } from "../src/config.js"; import { walkFiles } from "../src/fs.js"; let project: Project | undefined; @@ -57,6 +58,22 @@ async function makeProject( return project.baseDir; } +describe("config path fields stay inside the project", () => { + it("rejects source.partials outside the project at config load", async () => { + const root = await makeProject( + ` antigravity: { + outDir: "out", + plugins: { acme: { source: "shared/acme" } } + }`, + `source: { partials: "../outside" },`, + ); + + await expect(loadConfig(root)).rejects.toThrow( + /Invalid pluginpack config in .*: source\.partials: must be a safe relative path/, + ); + }); +}); + describe("walkFiles distinguishes a missing directory from a file", () => { it("throws when the directory is a file", async () => { const root = await makeProject(` antigravity: { From 598f211b4cbd4c61ae0dac04e723d7871565d6e5 Mon Sep 17 00:00:00 2001 From: Steve Calvert Date: Wed, 21 Oct 2026 14:26:58 -0700 Subject: [PATCH 03/24] fix(managed): compare physical paths and check deletions before writing The delete guard, the cross-target collision checks and clean's manifest check all compared path.resolve strings. An alias walked past them: an outDir symlinked into a source plugin let build and clean delete authored skills, and "out" and "OUT" on a case-insensitive filesystem let two targets overwrite each other's files and clean delete the other target's live output. These checks now compare physical keys: the real path of the nearest existing ancestor, folded for case and Unicode normalization when that volume is case-insensitive. loadConfig also rejects an outDir that lands inside any source, overrides, partials, repositoryFiles or rootFiles path, so the alias case fails before anything is written. An outDir that is only a symlink to somewhere safe keeps working. The guard no longer guesses source plugins from component directories under the default plugins/ root. That guess read a generated package such as plugins/acme/skills/ as source and blocked its own prune whenever outDir was the repo root. It now protects what the config reads: an explicit source.plugins, the legacy plugins a target reads through "from", and any directory with a plugin.pluginpack.json. rootFiles sources and each outDir's .git are protected too, and a rootFiles entry that copies a file onto itself is rejected. build and prune now share one plan that runs every check before any write. That plan includes the delete guard over stale files, so --dry-run fails wherever the real command would, and a refused prune no longer leaves a half-written build. A stale path that another target produces in the same build, or that another configured target's manifest claims, has changed owner. It is left in place instead of being deleted from under the new owner. The managed manifest moves to version 2. It records a sha256 for each file. build, prune and clean warn before overwriting or deleting a managed file someone edited, and still go ahead. Version 1 manifests are read and upgraded on the next build. A manifest from a newer pluginpack gets its own error saying to upgrade, distinct from a corrupt one. Manifest writes go through the same containment check as artifact writes, so a symlinked .pluginpack directory cannot redirect them. A failed removal now names the manifest and the recovery step. --- README.md | 10 +- src/build.ts | 168 +++++-- src/cleanup.ts | 79 ++-- src/config.ts | 170 +++++++- src/fs.ts | 75 ++++ src/managed.ts | 423 +++++++++++++----- src/schema.ts | 4 + tests/destructive.test.ts | 12 +- tests/physical-path-safety.test.ts | 675 ++++++++++++++++++++++++++++- 9 files changed, 1416 insertions(+), 200 deletions(-) diff --git a/README.md b/README.md index 4d6c6f6..196e47a 100644 --- a/README.md +++ b/README.md @@ -188,7 +188,13 @@ Each emitted plugin names one `source`. Optional `overrides` are applied after the source, so it can add or replace files for that target. `repositoryFiles` copies a whole directory into the generated repository root. -`pluginpack` writes a `.pluginpack/.json` managed-file manifest for each built target. That manifest lets builds and cleanup commands remove stale generated files without touching source files or unmanaged repo content. +`pluginpack` writes a `.pluginpack/.json` managed-file manifest for each built target. That manifest lets builds and cleanup commands remove stale generated files without touching source files or unmanaged repo content. It also records a sha256 of each file as written. If you edit a generated file and a later `build`, `prune` or `clean` would overwrite or delete it, pluginpack prints a warning naming the file first. It still goes ahead, because the output belongs to pluginpack; move the edit into the source to keep it. Manifests from 0.11 and earlier have no hashes, so they produce no warnings until the next build rewrites them. + +Before it deletes anything, pluginpack compares real paths, with symlinks resolved and, on a case-insensitive filesystem, case folded. It refuses an `outDir` that lands inside a source, overrides, partials, `repositoryFiles` or `rootFiles` path, even through a symlink. It also refuses two targets that write the same file through different spellings, such as `out` and `OUT` on macOS. `--dry-run` runs the same checks, so it fails wherever the real command would. + +An `outDir` outside the project, such as a sibling output repository (`../cursor-plugins`), works. The guard there knows only that repository's `.git` directory and the files the manifest lists, so it cannot protect anything else in that repository from a manifest someone edited by hand. + +**Upgrading from 0.11 (managed files):** the first build rewrites each manifest as version 2. pluginpack 0.11 cannot read a version 2 manifest, so upgrade every machine and CI job that builds the same output together. A manifest from a newer pluginpack fails with a message saying to upgrade. Some configs that 0.11 accepted now fail at load or build: `source.partials` outside the project, an `outDir` inside a source path, and a `rootFiles` entry that copies a file onto itself (`"README.md": "README.md"` with `outDir: "."`). Each error names the field to change. ## Components @@ -606,7 +612,7 @@ rest of the artifact. | Field | Type | Required | Meaning | | ------------------ | ------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `outDir` | string | yes | Output directory for this target, relative to the config root. | +| `outDir` | string | yes | Output directory for this target, relative to the config root. May be outside the project; it may not sit inside a source path. | | `format` | `"agent-plugins"` \| `"legacy"` | no | `codex` only. `"legacy"` keeps the `.codex-plugin/plugin.json` layout; default `"agent-plugins"`. | | `plugins` | record | yes | Emitted plugins, keyed by emitted plugin name (see **`targets..plugins.`**). | | `marketplaceDir` | string (safe relative) | no | Override the marketplace dir (defaults: `.cursor-plugin` / `.claude-plugin`). | diff --git a/src/build.ts b/src/build.ts index 1ec3bdb..a56fb90 100644 --- a/src/build.ts +++ b/src/build.ts @@ -1,12 +1,16 @@ import path from "node:path"; -import { loadConfig } from "./config.js"; -import { writeArtifact } from "./fs.js"; +import { assertOutDirsOutsideSource, loadConfig } from "./config.js"; +import { physicalKey, writeArtifact } from "./fs.js"; import { + assertManagedManifestContained, buildDeleteGuard, + executePrune, normalizeManagedPath, - pruneManagedFiles, + planPrune, readManagedManifest, + warnOnEditedOverwrites, writeManagedManifest, + type PrunePlan, } from "./managed.js"; import { emitTarget, targetNames } from "./adapters.js"; import { @@ -22,18 +26,10 @@ import type { /** Builds every configured (or explicitly selected) target and writes its output, unless `dryRun` is set. */ export async function build(options: BuildOptions = {}): Promise { - const project = await loadConfig(options.cwd, options.configPath); - const targets = options.target - ? [options.target] - : targetNames.filter((target) => project.config.targets[target]); - const guard = await buildDeleteGuard(project); - const artifacts: Artifact[] = []; - for (const target of targets) { - artifacts.push(await emitTarget(project, target, options.outDir)); + const { artifacts, prunes } = await planBuild(options); + for (const plan of prunes) { + await warnOnEditedOverwrites(plan, options.dryRun); } - const owner = assertNoCrossTargetCollisions(artifacts); - await assertNoCollisionsWithBuiltTargets(project, targets, owner); - assertNoUnsubstitutedPartialTags(artifacts); 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 — @@ -44,14 +40,70 @@ export async function build(options: BuildOptions = {}): Promise { for (const artifact of artifacts) { await writeArtifact(artifact.outDir, artifact.files); } - for (const artifact of artifacts) { - await pruneManagedFiles(artifact, { guard }); - await writeManagedManifest(artifact); + for (const plan of prunes) { + await executePrune(plan); + await writeManagedManifest(plan.artifact); } } return artifacts; } +/** + * Emits every selected target and runs every check that could refuse the + * build, without touching disk: cross-target collisions, collisions with other + * targets' manifests, unsubstituted partials, self-referential rootFiles, + * manifest containment, and the delete guard over each target's stale files. + * + * `build` and `prune` both start here, and a dry run stops here, so a dry run + * fails exactly where the real command would, and a refused prune never + * leaves a half-written build behind. `force` relaxes only the delete guard. + */ +export async function planBuild( + options: BuildOptions, + force?: boolean, +): Promise<{ artifacts: Artifact[]; prunes: PrunePlan[] }> { + const project = await loadConfig(options.cwd, options.configPath); + const targets = options.target + ? [options.target] + : targetNames.filter((target) => project.config.targets[target]); + const guard = await buildDeleteGuard(project, force); + const artifacts: Artifact[] = []; + for (const target of targets) { + artifacts.push(await emitTarget(project, target, options.outDir)); + } + if (options.outDir) { + await assertOutDirsOutsideSource( + project, + artifacts.map((artifact) => ({ + target: artifact.target, + outDir: artifact.outDir, + })), + ); + } + const incoming = await assertNoCrossTargetCollisions(artifacts); + const claimedElsewhere = await assertNoCollisionsWithBuiltTargets( + project, + targets, + incoming, + ); + assertNoUnsubstitutedPartialTags(artifacts); + await assertNoSelfReferentialRootFiles(project, artifacts); + for (const artifact of artifacts) { + await assertManagedManifestContained(artifact.outDir, artifact.target); + } + const owners = new Map(claimedElsewhere); + for (const [key, claim] of incoming) { + owners.set(key, claim.target); + } + const prunes: PrunePlan[] = []; + for (const artifact of artifacts) { + prunes.push( + await planPrune(artifact, { dryRun: options.dryRun, guard, owners }), + ); + } + return { artifacts, prunes }; +} + /** * A `{{> name}}` tag that reaches output is broken content: whatever reads the * file gets a template marker instead of the text it was meant to inline. @@ -74,25 +126,69 @@ function assertNoUnsubstitutedPartialTags(artifacts: Artifact[]): void { } } +/** + * A `rootFiles` entry whose destination is its own source makes the authored + * file a managed output: the build copies it onto itself, and the next `clean` + * (or a prune after the entry is removed) deletes the only copy. Refuse it + * before writing anything. (A `repositoryFiles` directory that is the outDir + * is caught earlier, at config load, as an outDir inside a source path.) + */ +async function assertNoSelfReferentialRootFiles( + project: ResolvedProject, + artifacts: Artifact[], +): Promise { + const problems: string[] = []; + for (const artifact of artifacts) { + const targetConfig = project.config.targets[artifact.target]; + for (const [dest, source] of Object.entries( + targetConfig?.rootFiles ?? {}, + )) { + const [destKey, sourceKey] = await Promise.all([ + physicalKey(path.resolve(artifact.outDir, dest)), + physicalKey(path.resolve(project.rootDir, source)), + ]); + if (destKey === sourceKey) { + problems.push( + ` targets.${artifact.target}.rootFiles["${dest}"] copies ${path.resolve(project.rootDir, source)} onto itself`, + ); + } + } + } + if (problems.length > 0) { + throw new Error( + `Invalid pluginpack config: a generated file would replace its own source:\n${problems.join("\n")}\n` + + `pluginpack would then manage, and later delete, the authored file. ` + + `Map each entry to a different destination, or remove it if the file already lives in the output repository.`, + ); + } +} + +/** Which target claims a path, and the path as that target names it. */ +type Claim = { target: TargetName; absolute: string }; + /** * 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. - * Returns the absolute-path -> owning-target map so - * `assertNoCollisionsWithBuiltTargets` can reuse it. + * Keys on physical paths, so `out` and `OUT` on a case-insensitive filesystem, + * or an outDir reached through a symlink, are one path, as they are on disk. + * Returns the physical-key -> claim map so later checks can reuse it. */ -function assertNoCrossTargetCollisions( +async function assertNoCrossTargetCollisions( artifacts: Artifact[], -): Map { - const owner = new Map(); +): Promise> { + const owner = new Map(); const collisions: string[] = []; for (const artifact of artifacts) { for (const managedPath of artifact.managedPaths) { const absolute = path.resolve(artifact.outDir, managedPath); - const previous = owner.get(absolute); - if (previous && previous !== artifact.target) { - collisions.push(` ${previous} and ${artifact.target}: ${absolute}`); + const key = await physicalKey(absolute, { entry: true }); + const previous = owner.get(key); + if (previous && previous.target !== artifact.target) { + collisions.push( + ` ${previous.target} and ${artifact.target}: ${describeAlias(previous.absolute, absolute)}`, + ); } else { - owner.set(absolute, artifact.target); + owner.set(key, { target: artifact.target, absolute }); } } } @@ -113,13 +209,17 @@ function assertNoCrossTargetCollisions( * 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. + * + * Returns the physical key of every path those other manifests claim, so + * prune can leave a stale path alone when another target owns it now. */ async function assertNoCollisionsWithBuiltTargets( project: ResolvedProject, targets: TargetName[], - incoming: Map, -): Promise { + incoming: Map, +): Promise> { const building = new Set(targets); + const claimed = new Map(); const collisions: string[] = []; for (const other of targetNames) { if (building.has(other)) { @@ -136,10 +236,12 @@ async function assertNoCollisionsWithBuiltTargets( } for (const file of manifest.files) { const absolute = path.resolve(otherOutDir, normalizeManagedPath(file)); - const conflicting = incoming.get(absolute); + const key = await physicalKey(absolute, { entry: true }); + claimed.set(key, other); + const conflicting = incoming.get(key); if (conflicting) { collisions.push( - ` ${other} (previously built) and ${conflicting}: ${absolute}`, + ` ${other} (previously built) and ${conflicting.target}: ${describeAlias(absolute, conflicting.absolute)}`, ); } } @@ -150,4 +252,10 @@ async function assertNoCollisionsWithBuiltTargets( `Run a full \`build\` (no --target) to see every target's paths at once, or fix the outDir configuration.`, ); } + return claimed; +} + +/** One path, or both spellings when two different strings name the same file. */ +export function describeAlias(first: string, second: string): string { + return first === second ? first : `${first} (same file as ${second})`; } diff --git a/src/cleanup.ts b/src/cleanup.ts index 3037f14..7708340 100644 --- a/src/cleanup.ts +++ b/src/cleanup.ts @@ -1,12 +1,14 @@ import path from "node:path"; -import { build } from "./build.js"; +import { describeAlias, planBuild } from "./build.js"; import { targetNames } from "./adapters.js"; import { loadProjectConfig } from "./config.js"; +import { physicalKey } from "./fs.js"; import { buildDeleteGuard, cleanManagedFiles, + executePrune, normalizeManagedPath, - pruneManagedFiles, + pruneResult, readManagedManifest, } from "./managed.js"; import type { @@ -15,7 +17,12 @@ import type { TargetName, } from "./types.js"; -/** Deletes managed files no longer produced by the current build, without rewriting current output. */ +/** + * Deletes managed files no longer produced by the current build, without + * rewriting current output. Runs the same plan, and so the same checks, as + * `build`: the delete guard, cross-target ownership of stale paths, and every + * collision check. `--dry-run` runs them too and only skips the deletes. + */ export async function prune( options: { cwd?: string; @@ -25,21 +32,21 @@ export async function prune( force?: boolean; } = {}, ): Promise { - const project = await loadProjectConfig(options.cwd, options.configPath); - const guard = await buildDeleteGuard(project, options.force); - const artifacts = await build({ - cwd: options.cwd, - configPath: options.configPath, - target: options.target, - dryRun: true, - }); - const results: CleanupResult[] = []; - for (const artifact of artifacts) { - results.push( - await pruneManagedFiles(artifact, { dryRun: options.dryRun, guard }), - ); + const { prunes } = await planBuild( + { + cwd: options.cwd, + configPath: options.configPath, + target: options.target, + dryRun: options.dryRun, + }, + options.force, + ); + if (!options.dryRun) { + for (const plan of prunes) { + await executePrune(plan); + } } - return results; + return prunes.map(pruneResult); } /** Deletes every managed file for the given target(s), tearing the generated output down entirely. */ @@ -78,17 +85,20 @@ export async function clean( /** * Refuses to clean a path another configured target's manifest also claims. * - * `build()` rejects two targets writing overlapping output paths, and `prune()` - * inherits that check for free by running `build({ dryRun: true })` first. - * `clean()` deliberately never builds — teardown has to keep working when the - * source tree or config no longer does, which is often exactly why someone is - * cleaning — so it cannot inherit the check the same way. This is the - * standalone equivalent, working purely from the manifests on disk. + * `build()` and `prune()` share one plan that rejects overlapping output and + * leaves a stale path alone when another target owns it. `clean()` + * deliberately never builds — teardown has to keep working when the source + * tree or config no longer does, which is often exactly why someone is + * cleaning — so it cannot share that plan. This is the standalone check, + * working purely from the manifests on disk. * - * Overlapping manifests can only come from a pluginpack older than that build - * check, or from a manifest edited out of band; either way the file being - * deleted is another target's live output, so refuse rather than delete it. - * `--force` still wins, so nobody ends up unable to tear down their own repo. + * Paths are compared physically, so two outDirs that differ only in case on a + * case-insensitive filesystem, or that alias one directory through a symlink, + * collide here just as they do on disk. Overlapping manifests otherwise come + * from a pluginpack older than the build check, or from a manifest edited out + * of band; either way the file being deleted is another target's live output, + * so refuse rather than delete it. `--force` still wins, so nobody ends up + * unable to tear down their own repo. */ async function assertNoManifestCollisions( project: ResolvedProjectConfig, @@ -98,7 +108,7 @@ async function assertNoManifestCollisions( if (force) { return; } - const owners = new Map(); + const owners = new Map(); const collisions: string[] = []; for (const target of targetNames) { const targetConfig = project.config.targets[target]; @@ -112,14 +122,17 @@ async function assertNoManifestCollisions( } for (const file of manifest.files) { const absolute = path.resolve(outDir, normalizeManagedPath(file)); - const owner = owners.get(absolute); - if (owner && owner !== target) { - if (cleaning.includes(owner) || cleaning.includes(target)) { - collisions.push(` ${owner} and ${target}: ${absolute}`); + const key = await physicalKey(absolute, { entry: true }); + const owner = owners.get(key); + if (owner && owner.target !== target) { + if (cleaning.includes(owner.target) || cleaning.includes(target)) { + collisions.push( + ` ${owner.target} and ${target}: ${describeAlias(owner.absolute, absolute)}`, + ); } continue; } - owners.set(absolute, target); + owners.set(key, { target, absolute }); } } if (collisions.length > 0) { diff --git a/src/config.ts b/src/config.ts index 29322b0..0117963 100644 --- a/src/config.ts +++ b/src/config.ts @@ -4,7 +4,7 @@ import { pathToFileURL } from "node:url"; import { createJiti } from "jiti"; import { z } from "zod"; import { componentDirs } from "./components.js"; -import { exists } from "./fs.js"; +import { exists, isInside, physicalKey } from "./fs.js"; import { loadPartials } from "./partials.js"; import { configSchema, sourcePluginManifestSchema } from "./schema.js"; import { createFilesystemSourceProvider } from "./source.js"; @@ -27,6 +27,7 @@ export async function loadConfig( configPath?: string, ): Promise { const projectConfig = await loadProjectConfig(cwd, configPath); + await assertOutDirsOutsideSource(projectConfig); const { config, rootDir } = projectConfig; const sourceRoot = path.resolve(rootDir, config.source?.plugins ?? "plugins"); const plugins = await discoverSourcePlugins(sourceRoot); @@ -148,19 +149,166 @@ async function discoverSourcePlugins( return plugins; } +/** An authored path the delete guard protects, with the config field that names it. */ +export type SourcePath = { + path: string; + field: string; +}; + /** - * Directories under `sourceRoot` that are actually source plugins. + * Every authored path the config reads from: the paths prune and clean must + * never delete and a target's outDir must never sit inside. * - * The delete guard needs this to protect source without protecting generated - * output: the recommended layout writes every target's output under - * `plugins//`, which is also the default source-plugin root, so - * protecting that root wholesale refuses to prune the tool's own output. A - * shallow filesystem scan is deliberate — `clean` must keep working when the - * config or source tree no longer loads. + * Derived from the config, not from what is on disk, so `clean` keeps working + * when the source tree no longer loads. `source.plugins` is protected whole + * only when the config sets it. Under the default `plugins/` root — where the + * README's layouts also write generated output — only the legacy source + * plugins a target actually reads through `from`, and directories carrying a + * `plugin.pluginpack.json` (which generated output never contains), count as + * source. Guessing from component directories misreads a generated package + * (`plugins/acme/skills/`) as source and blocks its own prune. */ -export async function listSourcePluginDirs( - sourceRoot: string, -): Promise { +export async function listSourcePaths( + project: ResolvedProjectConfig, +): Promise { + const { rootDir, config } = project; + const paths: SourcePath[] = []; + const add = (value: string, field: string) => + paths.push({ path: path.resolve(rootDir, value), field }); + if (config.source?.skills) { + add(config.source.skills, "source.skills"); + } + if (config.source?.partials) { + add(config.source.partials, "source.partials"); + } + if (config.source?.plugins) { + add(config.source.plugins, "source.plugins"); + } else { + const defaultRoot = path.resolve(rootDir, "plugins"); + const rootPluginId = config.source?.skills + ? (config.source.rootPlugin?.id ?? "core") + : undefined; + const legacyIds = new Set(); + for (const target of Object.values(config.targets)) { + for (const plugin of Object.values(target?.plugins ?? {})) { + for (const id of plugin.from ?? []) { + // The root skills plugin reads only source.skills, protected above. + if (id !== rootPluginId) { + legacyIds.add(id); + } + } + } + } + for (const id of legacyIds) { + paths.push({ + path: path.join(defaultRoot, id), + field: `legacy source plugin "${id}" (from)`, + }); + } + for (const dir of await listManifestPluginDirs(defaultRoot)) { + if (!legacyIds.has(path.basename(dir))) { + paths.push({ path: dir, field: "plugin.pluginpack.json" }); + } + } + } + for (const [name, target] of Object.entries(config.targets)) { + if (!target) { + continue; + } + if (target.repositoryFiles) { + add(target.repositoryFiles, `targets.${name}.repositoryFiles`); + } + for (const [dest, source] of Object.entries(target.rootFiles ?? {})) { + add(source, `targets.${name}.rootFiles["${dest}"]`); + } + for (const [pluginName, plugin] of Object.entries(target.plugins)) { + if (plugin.source) { + add(plugin.source, `targets.${name}.plugins.${pluginName}.source`); + } + if (plugin.overrides) { + add( + plugin.overrides, + `targets.${name}.plugins.${pluginName}.overrides`, + ); + } + } + } + return paths; +} + +/** + * Rejects a config whose target outDir physically resolves inside an authored + * path: building there overwrites source, and prune/clean would then have to + * delete it. Compares physical paths, so an outDir that only reaches source + * through a symlink, or through a case variant the filesystem forgives, is + * caught too. An outDir that is merely a symlink to somewhere safe still works. + * + * Checks every configured outDir, or only `outDirs` when given (the absolute + * outDirs a `--out-dir` override produced). + */ +export async function assertOutDirsOutsideSource( + project: ResolvedProjectConfig, + outDirs?: Array<{ target: string; outDir: string }>, +): Promise { + const sources = await listSourcePaths(project); + const sourceKeys = await Promise.all( + sources.map(async (source) => ({ + ...source, + key: await physicalKey(source.path), + })), + ); + const checked = + outDirs ?? + Object.entries(project.config.targets).flatMap(([target, config]) => + config + ? [{ target, outDir: path.resolve(project.rootDir, config.outDir) }] + : [], + ); + const problems: string[] = []; + for (const { target: name, outDir } of checked) { + const outKey = await physicalKey(outDir); + for (const source of sourceKeys) { + if (isInside(source.key, outKey)) { + problems.push( + ` targets.${name}.outDir (${outDir}) resolves inside ${source.path} (${source.field})`, + ); + } + } + } + if (problems.length > 0) { + throw new Error( + `Invalid pluginpack config in ${project.configPath}: a target writes its output inside an authored source path:\n${problems.join("\n")}\n` + + `A build there overwrites source files, and prune or clean would delete them. ` + + `Point each outDir at a directory outside every source, overrides, partials, repositoryFiles and rootFiles path.`, + ); + } +} + +/** + * Directories directly under `sourceRoot` that carry a + * `plugin.pluginpack.json`. Generated output never contains that file, so + * these are authored source whether or not a target reads them. + */ +async function listManifestPluginDirs(sourceRoot: string): Promise { + if (!(await exists(sourceRoot))) { + return []; + } + const dirs: string[] = []; + const entries = await fs.readdir(sourceRoot, { withFileTypes: true }); + for (const entry of entries) { + const dir = path.join(sourceRoot, entry.name); + if ( + entry.isDirectory() && + (await exists(path.join(dir, "plugin.pluginpack.json"))) + ) { + dirs.push(dir); + } + } + return dirs; +} + +/** Directories under `sourceRoot` that legacy discovery reads as source plugins. */ +async function listSourcePluginDirs(sourceRoot: string): Promise { if (!(await exists(sourceRoot))) { return []; } diff --git a/src/fs.ts b/src/fs.ts index 73bdcd8..df5b273 100644 --- a/src/fs.ts +++ b/src/fs.ts @@ -81,6 +81,81 @@ function fold(value: string): string { return value.normalize("NFC").toLowerCase(); } +/** + * The key two paths share when they name the same file on this host: the + * physical path (symlinks resolved on the deepest existing ancestor), folded + * for case and Unicode normalization when that ancestor's volume compares + * names case-insensitively (APFS and NTFS by default). + * + * Every check that decides whether a delete or write is safe, or whether two + * targets claim the same file, compares these keys. Comparing `path.resolve` + * strings instead lets an alias walk past the check: `out` and `OUT` on APFS, + * or a symlink into a source tree, are different strings but one file. + * + * With `entry` set, only the parent is resolved and the final name is kept, + * which is the file `fs.rm` removes when the final entry is itself a symlink. + * Use it for deletions and ownership; leave it off for a root being protected. + */ +export async function physicalKey( + target: string, + options: { entry?: boolean } = {}, +): Promise { + const absolute = path.resolve(target); + const parent = path.dirname(absolute); + const real = + options.entry && parent !== absolute + ? path.join(await realpathDeep(parent), path.basename(absolute)) + : await realpathDeep(absolute); + return (await isCaseInsensitiveAt(real)) ? fold(real) : real; +} + +const caseInsensitiveDirs = new Map>(); + +/** + * Whether names under `real`'s volume compare case-insensitively. Probes the + * deepest existing component of `real` that has a cased letter: if the + * case-swapped name is the same inode, the directory holding it folds case. + * Falls back to the platform default (folding on macOS and Windows) when no + * component can be probed, since over-matching is the safe direction for a + * check that decides whether to refuse. + */ +async function isCaseInsensitiveAt(real: string): Promise { + let current = real; + for (;;) { + const parent = path.dirname(current); + if (parent === current) { + return process.platform === "darwin" || process.platform === "win32"; + } + const name = path.basename(current); + const swapped = swapCase(name); + // Skip names whose case swap does not round-trip ("ß" -> "SS"): the + // swapped name would be a different name, not a different case. + if (swapped !== name && swapCase(swapped) === name) { + const stats = await fs.lstat(current).catch(() => null); + if (stats) { + let cached = caseInsensitiveDirs.get(parent); + if (!cached) { + cached = fs.lstat(path.join(parent, swapped)).then( + (other) => other.ino === stats.ino && other.dev === stats.dev, + () => false, + ); + caseInsensitiveDirs.set(parent, cached); + } + return cached; + } + } + current = parent; + } +} + +function swapCase(value: string): string { + return [...value] + .map((char) => + char === char.toLowerCase() ? char.toUpperCase() : char.toLowerCase(), + ) + .join(""); +} + /** * Resolves `target` with symlinks followed, or returns `null` if the result * escapes `root`. diff --git a/src/managed.ts b/src/managed.ts index 86f86c4..2347639 100644 --- a/src/managed.ts +++ b/src/managed.ts @@ -1,26 +1,40 @@ +import { createHash } from "node:crypto"; import { promises as fs } from "node:fs"; import path from "node:path"; import { isInside, isInsideFolded, json, + physicalKey, resolveInside, toPosix, + writeArtifact, } from "./fs.js"; -import { listSourcePluginDirs } from "./config.js"; +import { listSourcePaths } from "./config.js"; import type { Artifact, CleanupEntry, CleanupResult, DeleteGuard, + FileValue, ResolvedProjectConfig, TargetName, } from "./types.js"; -type ManagedManifest = { - version: 1; +/** The newest managed-manifest version this pluginpack reads and the one it writes. */ +const MANIFEST_VERSION = 2; + +/** + * A target's managed-file manifest, as read. Version 2 records a sha256 of + * each file as written, so a later build, prune or clean can tell a file it + * wrote from one someone edited since. A version 1 manifest (paths only) reads + * as version 2 with no hashes and is upgraded by the next build. + */ +export type ManagedManifest = { + version: typeof MANIFEST_VERSION; target: TargetName; files: string[]; + sha256: Record; }; /** Output-relative path of a target's managed-file manifest. */ @@ -28,19 +42,57 @@ export function managedManifestPath(target: TargetName): string { return toPosix(path.join(".pluginpack", `${target}.json`)); } -/** Writes the list of files a build produced, for later prune/clean/diff to compare against. */ +/** The hex sha256 of a file's contents, as recorded in the managed manifest. */ +function contentHash(value: FileValue): string { + return createHash("sha256").update(value).digest("hex"); +} + +/** + * Writes the list of files a build produced, with their hashes, for later + * prune/clean/diff to compare against. Goes through the same containment check + * as the artifact itself, so a symlinked `.pluginpack/` cannot redirect the + * write outside the output directory. + */ export async function writeManagedManifest(artifact: Artifact): Promise { + const sha256: Record = {}; + for (const managedPath of artifact.managedPaths) { + const value = artifact.files.get(managedPath); + if (value !== undefined) { + sha256[managedPath] = contentHash(value); + } + } const manifest: ManagedManifest = { - version: 1, + version: MANIFEST_VERSION, target: artifact.target, files: artifact.managedPaths, + sha256, }; - const destination = path.join( + await writeArtifact( artifact.outDir, - managedManifestPath(artifact.target), + new Map([[managedManifestPath(artifact.target), json(manifest)]]), ); - await fs.mkdir(path.dirname(destination), { recursive: true }); - await fs.writeFile(destination, json(manifest)); +} + +/** + * Throws if writing the target's managed manifest would leave the output + * directory. Build runs this before writing anything, so a planted + * `.pluginpack` symlink fails the build up front instead of after the + * artifact is already on disk. + */ +export async function assertManagedManifestContained( + outDir: string, + target: TargetName, +): Promise { + const relative = managedManifestPath(target); + if ( + (await resolveInside(path.resolve(outDir), path.join(outDir, relative))) === + null + ) { + throw new Error( + `Refusing to write through a symlink that leaves the output directory: ${relative} (under ${outDir}). ` + + `Replace the .pluginpack symlink with a real directory.`, + ); + } } /** Reads a target's managed-file manifest, or `null` if none exists yet. */ @@ -58,60 +110,172 @@ export async function readManagedManifest( } throw error; } - let parsed: Partial | null; + const invalid = (reason: string) => + new Error( + `Invalid managed manifest: ${manifestPath} (${reason}). ` + + `It records which files pluginpack may delete. Restore it from version control, ` + + `or delete it and rebuild; pluginpack then leaves any files it listed in place.`, + ); + let parsed: unknown; try { - parsed = JSON.parse(raw) as Partial | null; + parsed = JSON.parse(raw); } catch { - throw new Error(`Invalid managed manifest: ${manifestPath}`); + throw invalid("not valid JSON"); + } + // `JSON.parse` happily returns null, a number, or a string for a truncated + // or hand-edited manifest; those must land on the same clear error as + // malformed JSON, not a TypeError from the field checks below. + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw invalid("not a JSON object"); + } + const record = parsed as Record; + const version = record.version; + if ( + typeof version === "number" && + Number.isInteger(version) && + version > MANIFEST_VERSION + ) { + throw new Error( + `Managed manifest ${manifestPath} has version ${version}, so a newer pluginpack wrote it. ` + + `This pluginpack reads versions 1 to ${MANIFEST_VERSION}. Upgrade pluginpack to build, prune or clean this output.`, + ); + } + if (version !== 1 && version !== 2) { + throw invalid(`unsupported version ${JSON.stringify(version)}`); + } + if (record.target !== target) { + throw invalid( + `target is ${JSON.stringify(record.target)}, expected "${target}"`, + ); } + const files = record.files; if ( - // `JSON.parse` happily returns null, a number, or a string for a - // truncated or hand-edited manifest; those must land on the same clear - // error as malformed JSON, not a TypeError from the field checks below. - !parsed || - typeof parsed !== "object" || - Array.isArray(parsed) || - parsed.version !== 1 || - parsed.target !== target || - !Array.isArray(parsed.files) || - !parsed.files.every((file) => typeof file === "string") + !Array.isArray(files) || + !files.every((file): file is string => typeof file === "string") ) { - throw new Error(`Invalid managed manifest: ${manifestPath}`); + throw invalid('"files" must be an array of paths'); } - return parsed as ManagedManifest; + let sha256: Record = {}; + if (version === 2) { + const hashes = record.sha256; + if ( + !hashes || + typeof hashes !== "object" || + Array.isArray(hashes) || + !Object.values(hashes).every((hash) => typeof hash === "string") + ) { + throw invalid('"sha256" must map each path to a hash'); + } + sha256 = hashes as Record; + } + return { version: MANIFEST_VERSION, target, files, sha256 }; } -/** Deletes files the previous build managed but the current one no longer produces. */ -export async function pruneManagedFiles( +/** What a prune will delete, decided before anything is written or removed. */ +export type PrunePlan = { + artifact: Artifact; + previous: ManagedManifest | null; + stale: string[]; +}; + +/** + * Decides which previously managed files are stale and checks it is safe to + * delete them, without touching disk. Runs on dry runs too, so `--dry-run` + * fails exactly where the real command would. + * + * `owners` maps the physical key of every path another target produces in + * this build, or claims in its own manifest, to that target. A stale path + * another target owns has changed hands (for example a marketplace file moved + * from one target to another sharing the outDir) and is left in place rather + * than deleted from under its new owner. This target's next manifest no + * longer lists it, so the ownership transfer completes on the next build. + */ +export async function planPrune( artifact: Artifact, - options: { dryRun?: boolean; guard?: DeleteGuard } = {}, -): Promise { + options: { + dryRun?: boolean; + guard?: DeleteGuard; + owners?: Map; + } = {}, +): Promise { const previous = await readManagedManifest(artifact.outDir, artifact.target); const current = new Set(artifact.managedPaths.map(normalizeManagedPath)); - const stale = (previous?.files ?? []) - .map(normalizeManagedPath) - .filter((file) => !current.has(file)); - if (!options.dryRun) { - assertNoProtectedDeletions(artifact.outDir, stale, options.guard, "prune"); - } - const entries: CleanupEntry[] = []; - for (const normalized of stale) { - entries.push({ - type: "stale", - target: artifact.target, - path: normalized, - }); - if (!options.dryRun) { - await removeManagedPath(artifact.outDir, normalized); + const stale: string[] = []; + for (const file of (previous?.files ?? []).map(normalizeManagedPath)) { + if (current.has(file)) { + continue; + } + if (options.owners) { + const key = await physicalKey(path.resolve(artifact.outDir, file), { + entry: true, + }); + const owner = options.owners.get(key); + if (owner && owner !== artifact.target) { + continue; + } } + stale.push(file); } + await assertNoProtectedDeletions( + artifact.outDir, + stale, + options.guard, + "prune", + ); + await warnOnEditedFiles( + artifact.outDir, + artifact.target, + stale, + previous, + options.dryRun ? "a real run would delete it" : "deleting it", + ); + return { artifact, previous, stale }; +} + +/** Deletes the stale files a `planPrune` decided on. */ +export async function executePrune(plan: PrunePlan): Promise { + for (const file of plan.stale) { + await removeManagedPath(plan.artifact.outDir, file, plan.artifact.target); + } +} + +/** The cleanup report for a prune plan. */ +export function pruneResult(plan: PrunePlan): CleanupResult { return { - target: artifact.target, - outDir: artifact.outDir, - entries, + target: plan.artifact.target, + outDir: plan.artifact.outDir, + entries: plan.stale.map((file): CleanupEntry => ({ + type: "stale", + target: plan.artifact.target, + path: file, + })), }; } +/** + * Warns before a build overwrites a managed file someone edited since the + * last build. The build still overwrites it: pluginpack owns its output, but + * the edit should not vanish without a word. + */ +export async function warnOnEditedOverwrites( + plan: PrunePlan, + dryRun?: boolean, +): Promise { + const { artifact, previous } = plan; + const incoming = new Map(); + for (const [file, value] of artifact.files) { + incoming.set(file, contentHash(value)); + } + await warnOnEditedFiles( + artifact.outDir, + artifact.target, + artifact.managedPaths, + previous, + dryRun ? "a real build would overwrite it" : "overwriting it", + incoming, + ); +} + /** Deletes every file the previous build managed for a target, including its manifest. */ export async function cleanManagedFiles( outDir: string, @@ -123,71 +287,88 @@ export async function cleanManagedFiles( if (!previous) { return { target, outDir, entries }; } - const files = (previous.files ?? []).map(normalizeManagedPath); - if (!options.dryRun) { - assertNoProtectedDeletions(outDir, files, options.guard, "clean"); - } + const files = previous.files.map(normalizeManagedPath); + await assertNoProtectedDeletions(outDir, files, options.guard, "clean"); + await warnOnEditedFiles( + outDir, + target, + files, + previous, + options.dryRun ? "a real run would delete it" : "deleting it", + ); for (const normalized of files) { entries.push({ type: "deleted", target, path: normalized }); if (!options.dryRun) { - await removeManagedPath(outDir, normalized); + await removeManagedPath(outDir, normalized, target); } } const manifestPath = managedManifestPath(target); entries.push({ type: "deleted", target, path: manifestPath }); if (!options.dryRun) { - await removeManagedPath(outDir, manifestPath); + await removeManagedPath(outDir, manifestPath, target); } return { target, outDir, entries }; } /** - * Builds the guard that stops prune/clean from deleting the config or a source tree. - * - * `source.plugins` gets different treatment depending on whether it was set: - * when the user names a directory as their source-plugin root, that whole - * directory is protected. When they don't, the default root is `plugins/` — - * which is also where the recommended layout writes every target's output, so - * protecting it wholesale would refuse to prune generated files. Under the - * default, protect only the directories that really are source plugins. + * Prints a warning for each of `files` whose on-disk content no longer + * matches the hash the manifest recorded when pluginpack wrote it. Files with + * no recorded hash (a version 1 manifest) and files already gone are skipped, + * as are files whose edit already matches what `replacements` will write. + */ +async function warnOnEditedFiles( + outDir: string, + target: TargetName, + files: string[], + previous: ManagedManifest | null, + action: string, + replacements?: Map, +): Promise { + if (!previous) { + return; + } + for (const file of files) { + const recorded = previous.sha256[file]; + if (recorded === undefined) { + continue; + } + let contents: Buffer; + try { + contents = await fs.readFile(path.resolve(outDir, file)); + } catch { + continue; + } + const actual = contentHash(contents); + if (actual !== recorded && actual !== replacements?.get(file)) { + console.warn( + `warning: ${target} managed file ${path.join(outDir, file)} was edited after pluginpack wrote it; ${action}. ` + + `Keep the edit by moving it into the source, or recover it from version control.`, + ); + } + } +} + +/** + * Builds the guard that stops prune/clean from deleting the config, a source + * tree, or an output repository's `.git` directory. * - * Takes the loaded config rather than plugin discovery results, so `clean` keeps - * working when the source tree no longer loads: the scan below is a shallow - * readdir, not a build. + * Takes the loaded config rather than plugin discovery results, so `clean` + * keeps working when the source tree no longer loads. See `listSourcePaths` + * for which authored paths count as source. */ export async function buildDeleteGuard( project: ResolvedProjectConfig, force?: boolean, ): Promise { - const { rootDir, config } = project; - const protectedRoots: string[] = []; - if (config.source?.skills) { - protectedRoots.push(path.resolve(rootDir, config.source.skills)); - } - if (config.source?.partials) { - protectedRoots.push(path.resolve(rootDir, config.source.partials)); - } - if (config.source?.plugins) { - protectedRoots.push(path.resolve(rootDir, config.source.plugins)); - } else { - protectedRoots.push( - ...(await listSourcePluginDirs(path.resolve(rootDir, "plugins"))), - ); - } - for (const target of Object.values(config.targets)) { - if (!target) { - continue; - } - if (target.repositoryFiles) { - protectedRoots.push(path.resolve(rootDir, target.repositoryFiles)); - } - for (const plugin of Object.values(target.plugins)) { - if (plugin.source) { - protectedRoots.push(path.resolve(rootDir, plugin.source)); - } - if (plugin.overrides) { - protectedRoots.push(path.resolve(rootDir, plugin.overrides)); - } + const protectedRoots = (await listSourcePaths(project)).map( + (source) => source.path, + ); + // An outDir can sit outside the source repo (a sibling output repo), where + // none of the source paths above apply. Its version-control data is the one + // thing there no manifest should ever name. + for (const target of Object.values(project.config.targets)) { + if (target) { + protectedRoots.push(path.resolve(project.rootDir, target.outDir, ".git")); } } return { @@ -197,20 +378,29 @@ export async function buildDeleteGuard( }; } -function assertNoProtectedDeletions( +async function assertNoProtectedDeletions( outDir: string, paths: string[], guard: DeleteGuard | undefined, command: string, -): void { - if (!guard || guard.force) { +): Promise { + if (!guard || guard.force || paths.length === 0) { return; } - const blocked = paths - .map((file) => ({ file, root: protectingRoot(outDir, file, guard) })) - .filter( - (entry): entry is { file: string; root: string } => entry.root !== null, - ); + const roots = [ + ...(guard.configPath ? [guard.configPath] : []), + ...guard.protectedRoots, + ]; + const rootKeys = await Promise.all( + roots.map(async (root) => ({ root, key: await physicalKey(root) })), + ); + const blocked: Array<{ file: string; root: string }> = []; + for (const file of paths) { + const root = await protectingRoot(outDir, file, rootKeys); + if (root !== null) { + blocked.push({ file, root }); + } + } if (blocked.length === 0) { return; } @@ -228,21 +418,25 @@ function assertNoProtectedDeletions( * name what it collided with — "resolves inside " is actionable in a way * that a bare list of refused paths is not. * - * Containment is compared with case and Unicode normalization folded, since the - * filesystem may treat paths differing only in case as the same file. + * Two comparisons, either of which refuses. The lexical one folds case and + * Unicode normalization on every host, so a manifest entry differing from a + * root only by case is refused even where the filesystem would not alias them. + * The physical one compares the entry `fs.rm` would remove against each root + * with symlinks resolved, so an outDir that reaches source through a symlink + * or a case variant is refused too. */ -function protectingRoot( +async function protectingRoot( outDir: string, relativePath: string, - guard: DeleteGuard, -): string | null { + roots: Array<{ root: string; key: string }>, +): Promise { const absolute = path.resolve(outDir, normalizeManagedPath(relativePath)); - if (guard.configPath && isInsideFolded(guard.configPath, absolute)) { - return guard.configPath; + const lexical = roots.find(({ root }) => isInsideFolded(root, absolute)); + if (lexical) { + return lexical.root; } - return ( - guard.protectedRoots.find((root) => isInsideFolded(root, absolute)) ?? null - ); + const key = await physicalKey(absolute, { entry: true }); + return roots.find((root) => isInside(root.key, key))?.root ?? null; } /** Normalizes a managed path to a safe, relative, forward-slash form, throwing if it escapes the output dir. */ @@ -268,6 +462,7 @@ export function normalizeManagedPath(value: string): string { async function removeManagedPath( outDir: string, relativePath: string, + target: TargetName, ): Promise { const root = path.resolve(outDir); const normalized = normalizeManagedPath(relativePath); @@ -285,11 +480,11 @@ async function removeManagedPath( throw error; } if (stats.isSymbolicLink()) { - const target = path.resolve( + const linkTarget = path.resolve( path.dirname(destination), await fs.readlink(destination), ); - if (!isInside(root, target)) { + if (!isInside(root, linkTarget)) { throw new Error( `Refusing to remove a symlink pointing outside the output directory: ${relativePath}`, ); @@ -307,9 +502,11 @@ async function removeManagedPath( try { await fs.rm(destination, { force: true }); } catch (error) { + const manifest = path.join(root, managedManifestPath(target)); throw new Error( `Failed to remove managed path "${relativePath}" under ${outDir}: ${(error as Error).message}. ` + - `The managed manifest still lists every path, so re-running is safe once the cause is fixed.`, + `The managed manifest ${manifest} still lists every remaining path, so fix the cause and re-run the same command. ` + + `To stop pluginpack managing these files instead, delete that manifest.`, { cause: error }, ); } diff --git a/src/schema.ts b/src/schema.ts index fd2861e..9c78b4f 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -162,6 +162,10 @@ export const CLIENT_ONLY_CONTENT_KINDS = [ /** One target's output configuration: where it's written, and which plugins it emits. */ const targetSchema = z.object({ + // Deliberately not safeRelativePath: an outDir may be a sibling output + // repository ("../cursor-plugins"). The delete guard protects this + // project's source and the outDir's .git; it knows nothing else about an + // external repo, which the README calls out. outDir: z.string().min(1), // codex only: "agent-plugins" (default) emits Agent Plugins packages; // "legacy" emits the .codex-plugin layout. See docs/adr/0001. diff --git a/tests/destructive.test.ts b/tests/destructive.test.ts index d5e2002..9196541 100644 --- a/tests/destructive.test.ts +++ b/tests/destructive.test.ts @@ -192,7 +192,12 @@ describe("writeArtifact refuses to write outside its output directory", () => { describe("readManagedManifest rejects a manifest it cannot trust", () => { const invalid: Array<[string, unknown]> = [ - ["a wrong version", { version: 2, target: "cursor", files: [] }], + ["an unknown older version", { version: 0, target: "cursor", files: [] }], + ["a non-numeric version", { version: "2", target: "cursor", files: [] }], + [ + "a version 2 manifest with no hashes", + { version: 2, target: "cursor", files: [] }, + ], ["a mismatched target", { version: 1, target: "claude", files: [] }], [ "a non-array files field", @@ -274,7 +279,10 @@ describe("the delete guard protects a source tree from clean, not just prune", ( await access(path.join(root, "pluginpack.config.ts")); }); - it("refuses to clean the default source.plugins root even when unset in config", async () => { + it("refuses to clean a legacy source plugin a target reads through from, even with source.plugins unset", async () => { + // Under the default plugins/ root only the source plugins a target reads + // are protected, not every directory there: generated output lives there + // too in the README layouts. const created = await makeProject(` cursor: { outDir: ".", plugins: { demo: { from: ["demo"], path: "out/demo", components: ["skills"] } } diff --git a/tests/physical-path-safety.test.ts b/tests/physical-path-safety.test.ts index 8bb49e7..c22183b 100644 --- a/tests/physical-path-safety.test.ts +++ b/tests/physical-path-safety.test.ts @@ -1,14 +1,28 @@ -import { rm, writeFile } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { + access, + mkdir, + readFile, + rm, + symlink, + writeFile, +} from "node:fs/promises"; import path from "node:path"; -import { Project } from "fixturify-project"; -import { afterEach, describe, expect, it } from "vitest"; +import { fileURLToPath } from "node:url"; +import { Project, type ProjectArgs } from "fixturify-project"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { build } from "../src/build.js"; +import { clean, prune } from "../src/cleanup.js"; import { loadConfig } from "../src/config.js"; -import { walkFiles } from "../src/fs.js"; +import { exists, json, walkFiles } from "../src/fs.js"; +import { managedManifestPath, readManagedManifest } from "../src/managed.js"; + +type DirJSON = NonNullable; let project: Project | undefined; afterEach(async () => { + vi.restoreAllMocks(); await project?.dispose(); project = undefined; }); @@ -16,6 +30,11 @@ afterEach(async () => { const skill = (name: string) => `---\nname: ${name}\ndescription: ${name} skill.\n---\n\n${name} body.\n`; +/** + * A project with one canonical source plugin at `shared/acme` holding the + * skills `alpha` and `beta`. `targetsBlock` and `sourceBlock` are spliced into + * the config verbatim. + */ function configText(targetsBlock: string, sourceBlock = ""): string { return `import { defineConfig } from "${path.resolve("src/index.ts")}"; @@ -31,13 +50,9 @@ ${targetsBlock} `; } -/** - * A project with one canonical source plugin at `shared/acme` holding the - * skills `alpha` and `beta`. `targetsBlock` and `sourceBlock` are spliced into - * the config verbatim. - */ async function makeProject( targetsBlock: string, + extra: DirJSON = {}, sourceBlock = "", ): Promise { project = new Project("pluginpack-physical", "1.0.0", { @@ -52,12 +67,653 @@ async function makeProject( }, }, "UNMANAGED.txt": "USER FILE\n", + ...extra, }, }); await project.write(); return project.baseDir; } +/** Rewrites a target's managed manifest as an older (version 1) pluginpack would. */ +async function writeManifest( + root: string, + outDir: string, + target: string, + files: string[], +): Promise { + const manifestPath = path.join( + root, + outDir, + managedManifestPath(target as never), + ); + await mkdir(path.dirname(manifestPath), { recursive: true }); + await writeFile(manifestPath, json({ version: 1, target, files })); +} + +/** Messages passed to console.warn while `fn` runs. */ +async function captureWarnings(fn: () => Promise): Promise { + const warnings: string[] = []; + vi.spyOn(console, "warn").mockImplementation((message: unknown) => { + warnings.push(String(message)); + }); + await fn(); + return warnings; +} + +const caseInsensitiveFs = existsSync( + path.join( + path.dirname(fileURLToPath(import.meta.url)), + "PHYSICAL-PATH-SAFETY.TEST.TS", + ), +); + +const sourceSurvives = async (root: string) => { + await access(path.join(root, "shared/acme/skills/alpha/SKILL.md")); + await access(path.join(root, "shared/acme/skills/beta/SKILL.md")); + await access(path.join(root, "pluginpack.config.ts")); + await access(path.join(root, "UNMANAGED.txt")); +}; + +describe("the delete guard protects source, not generated packages under plugins/", () => { + it("keeps outDir '.' with the default plugins/ package path operable through a full lifecycle", async () => { + // The agent-plugins default writes the package to plugins/acme/, which + // has a skills/ directory, so a guard that guesses source plugins from + // component directories under plugins/ refuses to prune its own output. + const root = await makeProject(` "agent-plugins": { + outDir: ".", + plugins: { acme: { source: "shared/acme" } } + }`); + await build({ cwd: root }); + await access(path.join(root, "plugins/acme/skills/beta/SKILL.md")); + await rm(path.join(root, "shared/acme/skills/beta"), { recursive: true }); + + await expect(build({ cwd: root })).resolves.toBeDefined(); + await expect( + exists(path.join(root, "plugins/acme/skills/beta/SKILL.md")), + ).resolves.toBe(false); + await expect(prune({ cwd: root })).resolves.toBeDefined(); + await expect(clean({ cwd: root })).resolves.toBeDefined(); + + await expect(exists(path.join(root, "plugins/acme"))).resolves.toBe(false); + await access(path.join(root, "shared/acme/skills/alpha/SKILL.md")); + await access(path.join(root, "UNMANAGED.txt")); + }); + + it("still protects a plugins/ directory that carries plugin.pluginpack.json", async () => { + const root = await makeProject( + ` "agent-plugins": { + outDir: ".", + plugins: { acme: { source: "shared/acme", path: "out/acme" } } + }`, + { + plugins: { + authored: { + "plugin.pluginpack.json": "{}\n", + skills: { gamma: { "SKILL.md": skill("gamma") } }, + }, + }, + }, + ); + await build({ cwd: root }); + await writeManifest(root, ".", "agent-plugins", [ + "plugins/authored/skills/gamma/SKILL.md", + ]); + + await expect(clean({ cwd: root })).rejects.toThrow( + /Refusing to clean 1 path\(s\)[\s\S]*resolves inside .*plugins\/authored/, + ); + await access(path.join(root, "plugins/authored/skills/gamma/SKILL.md")); + }); +}); + +describe("source protection compares physical paths", () => { + async function aliasedSource(): Promise { + const root = await makeProject(` "agent-plugins": { + outDir: "linked-out", + plugins: { acme: { source: "shared/acme", path: "." } } + }`); + await symlink( + path.join(root, "shared/acme"), + path.join(root, "linked-out"), + ); + return root; + } + + it("refuses to build when the outDir is a symlink into a source plugin", async () => { + const root = await aliasedSource(); + + await expect(build({ cwd: root })).rejects.toThrow( + /targets\.agent-plugins\.outDir .* resolves inside .*shared\/acme \(targets\.agent-plugins\.plugins\.acme\.source\)/, + ); + await sourceSurvives(root); + }); + + it("refuses to clean managed paths that reach source through a symlinked outDir", async () => { + // clean never loads the source tree, so this is the delete guard on its own. + const root = await aliasedSource(); + await writeManifest(root, "linked-out", "agent-plugins", [ + "skills/alpha/SKILL.md", + "skills/beta/SKILL.md", + ]); + + await expect(clean({ cwd: root })).rejects.toThrow( + /Refusing to clean 2 path\(s\)[\s\S]*skills\/alpha\/SKILL\.md -> resolves inside .*shared\/acme/, + ); + await sourceSurvives(root); + }); + + it("refuses to load a config whose outDir sits inside a source plugin", async () => { + const root = await makeProject(` antigravity: { + outDir: "shared/acme", + plugins: { acme: { source: "shared/acme" } } + }`); + + await expect(loadConfig(root)).rejects.toThrow( + /targets\.antigravity\.outDir .* resolves inside .*shared\/acme \(targets\.antigravity\.plugins\.acme\.source\)[\s\S]*Point each outDir at a directory outside/, + ); + }); + + it("refuses a --out-dir override inside source", async () => { + const root = await makeProject(` antigravity: { + outDir: "dist/antigravity", + plugins: { acme: { source: "shared/acme" } } + }`); + + await expect( + build({ cwd: root, target: "antigravity", outDir: "shared/acme/out" }), + ).rejects.toThrow(/resolves inside .*shared\/acme/); + await expect(exists(path.join(root, "shared/acme/out"))).resolves.toBe( + false, + ); + }); + + it("still builds and cleans through an outDir symlink that points somewhere safe", async () => { + const root = await makeProject(` antigravity: { + outDir: "linked-out", + plugins: { acme: { source: "shared/acme" } } + }`); + await mkdir(path.join(root, "real-out")); + await symlink(path.join(root, "real-out"), path.join(root, "linked-out")); + + await build({ cwd: root }); + await access(path.join(root, "real-out/acme/skills/alpha/SKILL.md")); + await clean({ cwd: root }); + await sourceSurvives(root); + }); +}); + +describe("cross-target collision checks compare physical paths", () => { + const codexAndAgentPlugins = ( + codexOut: string, + apOut: string, + ) => ` codex: { + outDir: "${codexOut}", + plugins: { acme: { source: "shared/acme" } } + }, + "agent-plugins": { + outDir: "${apOut}", + clients: { codex: {} }, + plugins: { acme: { source: "shared/acme" } } + }`; + + it("rejects two outDirs that alias one directory through a symlink", async () => { + const root = await makeProject(codexAndAgentPlugins("out", "alias-out")); + await mkdir(path.join(root, "out")); + await symlink(path.join(root, "out"), path.join(root, "alias-out")); + + await expect(build({ cwd: root })).rejects.toThrow( + /Targets write overlapping output paths[\s\S]*codex and agent-plugins: .*out\/\.agents\/plugins\/marketplace\.json \(same file as .*alias-out\/\.agents\/plugins\/marketplace\.json\)/, + ); + await expect(exists(path.join(root, "out/plugins"))).resolves.toBe(false); + }); + + it.skipIf(!caseInsensitiveFs)( + "rejects outDirs that differ only by case on a case-insensitive filesystem", + async () => { + const root = await makeProject(codexAndAgentPlugins("out", "OUT")); + + await expect(build({ cwd: root })).rejects.toThrow( + /Targets write overlapping output paths[\s\S]*codex and agent-plugins/, + ); + }, + ); + + it.skipIf(!caseInsensitiveFs)( + "rejects a --target build whose outDir aliases another target's built output by case", + async () => { + const root = await makeProject(codexAndAgentPlugins("out", "OUT")); + await build({ cwd: root, target: "codex" }); + + await expect( + build({ cwd: root, target: "agent-plugins" }), + ).rejects.toThrow( + /overlaps a previously built target's managed files[\s\S]*codex \(previously built\) and agent-plugins/, + ); + }, + ); + + it("refuses to clean one target's manifest entries that alias another's through a symlink", async () => { + const root = await makeProject(codexAndAgentPlugins("out", "alias-out")); + await mkdir(path.join(root, "out")); + await symlink(path.join(root, "out"), path.join(root, "alias-out")); + await mkdir(path.join(root, "out/plugins/acme"), { recursive: true }); + await writeFile(path.join(root, "out/plugins/acme/plugin.json"), "{}\n"); + // Two manifests an older pluginpack could have left behind. + await writeManifest(root, "out", "codex", ["plugins/acme/plugin.json"]); + await writeManifest(root, "alias-out", "agent-plugins", [ + "plugins/acme/plugin.json", + ]); + + await expect(clean({ cwd: root, target: "codex" })).rejects.toThrow( + /Refusing to clean paths claimed by more than one target's managed manifest:[\s\S]*codex and agent-plugins/, + ); + await access(path.join(root, "out/plugins/acme/plugin.json")); + }); +}); + +describe("a stale path another target now owns is left in place", () => { + const entry = `entry: { policy: { installation: "AVAILABLE", authentication: "ON_INSTALL" }, category: "Productivity" }`; + const agentPluginsWithCodex = ` "agent-plugins": { + outDir: "out", + clients: { codex: {} }, + plugins: { acme: { source: "shared/acme", clients: { codex: { ${entry} } } } } + }`; + const codexTarget = ` codex: { + outDir: "out", + plugins: { other: { source: "shared/acme", ${entry} } } + }`; + const agentPluginsWithoutCodex = ` "agent-plugins": { + outDir: "out", + plugins: { acme: { source: "shared/acme" } } + }`; + const marketplace = "out/.agents/plugins/marketplace.json"; + + /** + * Writes a later revision of the project config under its own name. A new + * file per revision, because the config loader caches modules by path. + */ + async function revision( + root: string, + name: string, + targets: string, + ): Promise { + const configPath = path.join(root, `${name}.config.ts`); + await writeFile(configPath, configText(targets)); + return configPath; + } + + it("does not delete a marketplace handed from agent-plugins to codex in one plain build", async () => { + const root = await makeProject(agentPluginsWithCodex); + await build({ cwd: root }); + const configPath = await revision( + root, + "handoff", + `${codexTarget},\n${agentPluginsWithoutCodex}`, + ); + + await build({ cwd: root, configPath }); + + const written = JSON.parse( + await readFile(path.join(root, marketplace), "utf8"), + ) as { plugins: Array<{ name: string }> }; + expect(written.plugins.map((plugin) => plugin.name)).toEqual(["other"]); + expect( + (await readManagedManifest(path.join(root, "out"), "agent-plugins")) + ?.files, + ).not.toContain(".agents/plugins/marketplace.json"); + }); + + it("does not prune a stale path another configured target's manifest claims", async () => { + const root = await makeProject(agentPluginsWithCodex); + await build({ cwd: root }); + // The project swaps targets; agent-plugins' old manifest stays on disk. + await build({ + cwd: root, + configPath: await revision(root, "codex-only", codexTarget), + }); + const configPath = await revision( + root, + "both", + `${codexTarget},\n${agentPluginsWithoutCodex}`, + ); + + const [result] = await prune({ + cwd: root, + configPath, + target: "agent-plugins", + }); + + expect(result.entries.map((entry) => entry.path)).not.toContain( + ".agents/plugins/marketplace.json", + ); + await access(path.join(root, marketplace)); + }); +}); + +describe("dry runs run the same destructive preflight as the real command", () => { + async function protectedStalePath(): Promise { + const root = await makeProject( + ` antigravity: { + outDir: ".", + plugins: { acme: { from: ["core"], path: "generated" } } + }`, + { skills: { gamma: { "SKILL.md": skill("gamma") } } }, + `source: { skills: "skills", rootPlugin: { id: "core" } },`, + ); + await build({ cwd: root }); + const manifest = await readManagedManifest(root, "antigravity"); + await writeManifest(root, ".", "antigravity", [ + ...(manifest?.files ?? []), + "skills/gamma/SKILL.md", + ]); + return root; + } + + it("clean --dry-run fails on a protected path, as clean does", async () => { + const root = await protectedStalePath(); + + await expect(clean({ cwd: root, dryRun: true })).rejects.toThrow( + /Refusing to clean 1 path\(s\)/, + ); + await expect(clean({ cwd: root })).rejects.toThrow( + /Refusing to clean 1 path\(s\)/, + ); + }); + + it("prune --dry-run fails on a protected path, as prune does", async () => { + const root = await protectedStalePath(); + + await expect(prune({ cwd: root, dryRun: true })).rejects.toThrow( + /Refusing to prune 1 path\(s\)/, + ); + await expect(prune({ cwd: root })).rejects.toThrow( + /Refusing to prune 1 path\(s\)/, + ); + await access(path.join(root, "skills/gamma/SKILL.md")); + }); + + it("build --dry-run fails where build would, and build refuses before writing", async () => { + const root = await protectedStalePath(); + await rm(path.join(root, "generated"), { recursive: true }); + + await expect(build({ cwd: root, dryRun: true })).rejects.toThrow( + /Refusing to prune 1 path\(s\)/, + ); + await expect(build({ cwd: root })).rejects.toThrow( + /Refusing to prune 1 path\(s\)/, + ); + await expect(exists(path.join(root, "generated"))).resolves.toBe(false); + await access(path.join(root, "skills/gamma/SKILL.md")); + }); +}); + +describe("rootFiles cannot make an authored file a managed output", () => { + const selfCopy = ` antigravity: { + outDir: ".", + rootFiles: { "README.md": "README.md" }, + plugins: { acme: { source: "shared/acme", path: "generated/acme" } } + }`; + + it("rejects a rootFiles entry that copies a file onto itself, before writing", async () => { + const root = await makeProject(selfCopy, { + "README.md": "HAND-WRITTEN README\n", + }); + + await expect(build({ cwd: root })).rejects.toThrow( + /targets\.antigravity\.rootFiles\["README\.md"\] copies .*README\.md onto itself/, + ); + expect(await readFile(path.join(root, "README.md"), "utf8")).toBe( + "HAND-WRITTEN README\n", + ); + await expect(exists(path.join(root, "generated"))).resolves.toBe(false); + }); + + it("refuses to clean a rootFiles source an older build left in the manifest", async () => { + const root = await makeProject(selfCopy, { + "README.md": "HAND-WRITTEN README\n", + }); + await writeManifest(root, ".", "antigravity", ["README.md"]); + + await expect(clean({ cwd: root })).rejects.toThrow( + /README\.md -> resolves inside .*README\.md/, + ); + await access(path.join(root, "README.md")); + }); + + it("rejects a repositoryFiles directory that is the target's own outDir", async () => { + const root = await makeProject( + ` antigravity: { + outDir: "repo-root", + repositoryFiles: "repo-root", + plugins: { acme: { source: "shared/acme" } } + }`, + { "repo-root": { "README.md": "HAND-WRITTEN README\n" } }, + ); + + // Caught at load, since the outDir is a protected repositoryFiles path. + await expect(build({ cwd: root })).rejects.toThrow( + /targets\.antigravity\.outDir .* resolves inside .*repo-root \(targets\.antigravity\.repositoryFiles\)/, + ); + await access(path.join(root, "repo-root/README.md")); + }); +}); + +describe("the managed manifest records hashes and warns before losing an edit", () => { + const target = ` antigravity: { + outDir: "out", + plugins: { acme: { source: "shared/acme" } } + }`; + + it("writes version 2 with a sha256 for every managed file", async () => { + const root = await makeProject(target); + await build({ cwd: root }); + + const raw = JSON.parse( + await readFile( + path.join(root, "out", managedManifestPath("antigravity")), + "utf8", + ), + ) as { version: number; files: string[]; sha256: Record }; + + expect(raw.version).toBe(2); + expect(Object.keys(raw.sha256).sort()).toEqual([...raw.files].sort()); + expect(raw.sha256["acme/skills/alpha/SKILL.md"]).toMatch(/^[0-9a-f]{64}$/); + }); + + it("reads a version 1 manifest and upgrades it on the next build", async () => { + const root = await makeProject(target); + await build({ cwd: root }); + await writeManifest(root, "out", "antigravity", [ + "acme/skills/alpha/SKILL.md", + ]); + + await expect( + readManagedManifest(path.join(root, "out"), "antigravity"), + ).resolves.toMatchObject({ version: 2, sha256: {} }); + await build({ cwd: root }); + await expect( + readManagedManifest(path.join(root, "out"), "antigravity"), + ).resolves.toMatchObject({ + sha256: { "acme/skills/alpha/SKILL.md": expect.any(String) }, + }); + }); + + it("warns, then still prunes, a stale managed file someone edited", async () => { + const root = await makeProject(target); + await build({ cwd: root }); + const generated = path.join(root, "out/acme/skills/beta/SKILL.md"); + await writeFile(generated, `${skill("beta")}HOTFIX\n`); + await rm(path.join(root, "shared/acme/skills/beta"), { recursive: true }); + + const warnings = await captureWarnings(() => build({ cwd: root })); + + expect(warnings).toEqual([ + expect.stringMatching( + /antigravity managed file .*out\/acme\/skills\/beta\/SKILL\.md was edited after pluginpack wrote it; deleting it/, + ), + ]); + await expect(exists(generated)).resolves.toBe(false); + }); + + it("reports an edited stale file from prune --dry-run without deleting it", async () => { + const root = await makeProject(target); + await build({ cwd: root }); + const generated = path.join(root, "out/acme/skills/beta/SKILL.md"); + await writeFile(generated, "EDITED\n"); + await rm(path.join(root, "shared/acme/skills/beta"), { recursive: true }); + + const warnings = await captureWarnings(() => + prune({ cwd: root, dryRun: true }), + ); + + expect(warnings).toEqual([ + expect.stringMatching( + /was edited after pluginpack wrote it; a real run would delete it/, + ), + ]); + await access(generated); + }); + + it("warns before a build overwrites an edited managed file, but not an unedited one", async () => { + const root = await makeProject(target); + await build({ cwd: root }); + await writeFile( + path.join(root, "out/acme/skills/alpha/SKILL.md"), + "EDITED\n", + ); + + const warnings = await captureWarnings(() => build({ cwd: root })); + + expect(warnings).toEqual([ + expect.stringMatching( + /acme\/skills\/alpha\/SKILL\.md was edited after pluginpack wrote it; overwriting it/, + ), + ]); + expect( + await readFile(path.join(root, "out/acme/skills/alpha/SKILL.md"), "utf8"), + ).toBe(skill("alpha")); + }); + + it("warns before clean deletes an edited managed file", async () => { + const root = await makeProject(target); + await build({ cwd: root }); + await writeFile( + path.join(root, "out/acme/skills/alpha/SKILL.md"), + "EDITED\n", + ); + + const warnings = await captureWarnings(() => clean({ cwd: root })); + + expect(warnings).toEqual([ + expect.stringMatching( + /alpha\/SKILL\.md was edited after pluginpack wrote it; deleting it/, + ), + ]); + }); + + it("tells a manifest from a newer pluginpack apart from a corrupt one", async () => { + const root = await makeProject(target); + const outDir = path.join(root, "out"); + const manifestPath = path.join(outDir, managedManifestPath("antigravity")); + await mkdir(path.dirname(manifestPath), { recursive: true }); + + await writeFile( + manifestPath, + json({ version: 3, target: "antigravity", files: [] }), + ); + await expect(readManagedManifest(outDir, "antigravity")).rejects.toThrow( + `Managed manifest ${manifestPath} has version 3, so a newer pluginpack wrote it. This pluginpack reads versions 1 to 2. Upgrade pluginpack`, + ); + + await writeFile(manifestPath, "{ truncated"); + await expect(readManagedManifest(outDir, "antigravity")).rejects.toThrow( + `Invalid managed manifest: ${manifestPath} (not valid JSON)`, + ); + }); +}); + +describe("managed-manifest writes stay inside the output directory", () => { + it("refuses to build through a symlinked .pluginpack directory", async () => { + const root = await makeProject(` antigravity: { + outDir: "out", + plugins: { acme: { source: "shared/acme" } } + }`); + const outside = path.join(root, "outside-state"); + await mkdir(outside); + const state = json({ + version: 1, + target: "antigravity", + files: [], + note: "USER STATE", + }); + await writeFile(path.join(outside, "antigravity.json"), state); + await mkdir(path.join(root, "out")); + await symlink(outside, path.join(root, "out/.pluginpack")); + + await expect(build({ cwd: root })).rejects.toThrow( + /Refusing to write through a symlink that leaves the output directory: \.pluginpack\/antigravity\.json/, + ); + expect(await readFile(path.join(outside, "antigravity.json"), "utf8")).toBe( + state, + ); + await expect(exists(path.join(root, "out/acme"))).resolves.toBe(false); + }); +}); + +describe("a failed clean says how to recover", () => { + it("names the managed manifest and the recovery step", async () => { + const root = await makeProject(` antigravity: { + outDir: "out", + plugins: { acme: { source: "shared/acme" } } + }`); + await build({ cwd: root }); + const manifest = await readManagedManifest( + path.join(root, "out"), + "antigravity", + ); + await mkdir(path.join(root, "out/blocked")); + await writeManifest(root, "out", "antigravity", [ + ...(manifest?.files ?? []), + "blocked", + ]); + + const manifestPath = path.join( + root, + "out", + managedManifestPath("antigravity"), + ); + await expect(clean({ cwd: root })).rejects.toThrow( + `The managed manifest ${manifestPath} still lists every remaining path, so fix the cause and re-run the same command.`, + ); + await access(manifestPath); + }); +}); + +describe("an external outDir keeps its version-control data", () => { + it("refuses to clean a manifest entry inside the outDir's .git", async () => { + const root = await makeProject( + ` antigravity: { + outDir: "../sibling", + plugins: { acme: { source: "shared/acme" } } + }`, + ); + const sibling = path.join(root, "../sibling"); + await mkdir(path.join(sibling, ".git"), { recursive: true }); + await writeFile(path.join(sibling, ".git/config"), "[core]\n"); + try { + await build({ cwd: root }); + await writeManifest(root, "../sibling", "antigravity", [".git/config"]); + + await expect(clean({ cwd: root })).rejects.toThrow( + /\.git\/config -> resolves inside .*sibling\/\.git/, + ); + await access(path.join(sibling, ".git/config")); + } finally { + await rm(sibling, { recursive: true, force: true }); + } + }); +}); + describe("config path fields stay inside the project", () => { it("rejects source.partials outside the project at config load", async () => { const root = await makeProject( @@ -65,6 +721,7 @@ describe("config path fields stay inside the project", () => { outDir: "out", plugins: { acme: { source: "shared/acme" } } }`, + {}, `source: { partials: "../outside" },`, ); From e2154f36a26dd6cd372c4da1af958f85c2e1f739 Mon Sep 17 00:00:00 2001 From: Steve Calvert Date: Wed, 21 Oct 2026 15:23:44 -0700 Subject: [PATCH 04/24] fix(config): reject URL-shaped traversal in config path fields The config schema checked path fields with the same helper that marketplace entry sources use, and that helper accepts any value starting with http:// or https://. Every schema field it guards is a filesystem path, though, and path.resolve reads "https://../../outside" as "../outside". So source.partials, a plugin source, or overrides could still read files from outside the project and ship them. Schema path fields now use a filesystem-only check that gives URLs no exemption. --- src/fs.ts | 27 ++++++++++++++++++++++++--- src/schema.ts | 8 +++++--- tests/physical-path-safety.test.ts | 29 +++++++++++++++++++++++++++++ 3 files changed, 58 insertions(+), 6 deletions(-) diff --git a/src/fs.ts b/src/fs.ts index df5b273..99c4db6 100644 --- a/src/fs.ts +++ b/src/fs.ts @@ -236,7 +236,12 @@ export function json(value: unknown): string { return `${JSON.stringify(value, null, 2)}\n`; } -/** Whether `value` is a URL, or a relative path that can't escape its base directory. */ +/** + * Whether `value` is a URL, or a relative path that can't escape its base + * directory. For a value that may be either, such as a marketplace entry's + * `source`. A value that is only ever read from or written to disk must use + * `isSafeRelativeFilePath` instead. + */ export function isSafeRelativePath(value: string): boolean { if (!value) { return false; @@ -244,11 +249,27 @@ export function isSafeRelativePath(value: string): boolean { if (value.startsWith("http://") || value.startsWith("https://")) { return true; } - if (path.isAbsolute(value)) { + return isSafeRelativeFilePath(value); +} + +/** + * Whether `value` is a relative filesystem path that can't escape its base + * directory. Unlike `isSafeRelativePath`, a URL gets no exemption: passed to + * `path.resolve`, `https://../../outside` is just a path with a `..` in it, + * and lands outside the base. + */ +export function isSafeRelativeFilePath(value: string): boolean { + if (!value || path.isAbsolute(value)) { return false; } const normalized = path.posix.normalize(value.replace(/\\/g, "/")); - return normalized !== ".." && !normalized.startsWith("../"); + return ( + normalized !== ".." && + !normalized.startsWith("../") && + !path.posix.isAbsolute(normalized) && + // A Windows drive-letter path resolves as absolute on Windows. + !/^[a-zA-Z]:/.test(normalized) + ); } /** Whether the file at `filePath` exists. */ diff --git a/src/schema.ts b/src/schema.ts index 9c78b4f..e6625aa 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -1,14 +1,16 @@ import { z } from "zod"; -import { isSafeRelativePath } from "./fs.js"; +import { isSafeRelativeFilePath } from "./fs.js"; /** * A path written to or read from disk relative to a root — rejects absolute - * paths and `..` escapes so a config can't write or read outside it. + * paths and `..` escapes so a config can't write or read outside it. Every + * field using it is a filesystem path, so a URL-shaped value gets no + * exemption: `path.resolve` would read `https://../x` as `../x`. */ const safeRelativePath = z .string() .refine( - isSafeRelativePath, + isSafeRelativeFilePath, 'must be a safe relative path (no absolute paths or ".." segments)', ); diff --git a/tests/physical-path-safety.test.ts b/tests/physical-path-safety.test.ts index c22183b..7afe916 100644 --- a/tests/physical-path-safety.test.ts +++ b/tests/physical-path-safety.test.ts @@ -729,6 +729,35 @@ describe("config path fields stay inside the project", () => { /Invalid pluginpack config in .*: source\.partials: must be a safe relative path/, ); }); + + it.each([ + ["source.partials", `source: { partials: "https://../../outside" },`, ""], + [ + "targets.antigravity.plugins.acme.source", + "", + `source: "https://../../outside"`, + ], + ])( + "rejects a URL-shaped %s that resolves outside the project", + async (field, sourceBlock, pluginSource) => { + const root = await makeProject( + ` antigravity: { + outDir: "out", + plugins: { acme: { ${pluginSource || `source: "shared/acme"`} } } + }`, + {}, + sourceBlock, + ); + // path.resolve reads the value as a plain path with ".." segments. + expect(path.resolve(root, "https://../../outside")).toBe( + path.resolve(root, "../outside"), + ); + + await expect(loadConfig(root)).rejects.toThrow( + `${field}: must be a safe relative path`, + ); + }, + ); }); describe("walkFiles distinguishes a missing directory from a file", () => { From bf0913eae9a205d1a19c1c53b606ec2c69c32631 Mon Sep 17 00:00:00 2001 From: Steve Calvert Date: Wed, 21 Oct 2026 15:24:51 -0700 Subject: [PATCH 05/24] fix(config): load the config file without jiti's module cache loadProjectConfig imported the config through jiti with its default module cache. A second build() in the same process after pluginpack.config.ts changed on disk built the first config again. The CLI starts a new process per command, but a watcher or test harness calling the programmatic API silently built stale config. The loader now disables the module cache. --- src/config.ts | 4 ++++ tests/physical-path-safety.test.ts | 34 ++++++++++++++++++++++++++---- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/src/config.ts b/src/config.ts index 0117963..cda22f0 100644 --- a/src/config.ts +++ b/src/config.ts @@ -69,8 +69,12 @@ export async function loadProjectConfig( const resolvedConfigPath = configPath ? path.resolve(cwd, configPath) : await findConfig(cwd); + // No module cache: a long-lived process (a watcher, a test harness) that + // calls build() again after the config file changed must get the new + // config, not the module jiti evaluated the first time. const jiti = createJiti(pathToFileURL(resolvedConfigPath).href, { interopDefault: true, + moduleCache: false, }); const loaded = await jiti.import(resolvedConfigPath, { default: true }); const config = parseWithContext( diff --git a/tests/physical-path-safety.test.ts b/tests/physical-path-safety.test.ts index 7afe916..4300318 100644 --- a/tests/physical-path-safety.test.ts +++ b/tests/physical-path-safety.test.ts @@ -328,10 +328,7 @@ describe("a stale path another target now owns is left in place", () => { }`; const marketplace = "out/.agents/plugins/marketplace.json"; - /** - * Writes a later revision of the project config under its own name. A new - * file per revision, because the config loader caches modules by path. - */ + /** Writes a later revision of the project config under its own name. */ async function revision( root: string, name: string, @@ -760,6 +757,35 @@ describe("config path fields stay inside the project", () => { ); }); +describe("the config loader reads the config file fresh each time", () => { + it.each(["pluginpack.config.ts", "pluginpack.config.mjs"])( + "builds the edited %s on a second build() in the same process", + async (name) => { + const antigravity = ` antigravity: { + outDir: "out", + plugins: { acme: { source: "shared/acme" } } + }`; + const root = await makeProject(antigravity, { + "pluginpack.config.ts": null as never, + [name]: configText(antigravity), + }); + expect(existsSync(path.join(root, "pluginpack.config.ts"))).toBe( + name === "pluginpack.config.ts", + ); + const [first] = await build({ cwd: root }); + expect(first.target).toBe("antigravity"); + + await writeFile( + path.join(root, name), + configText(antigravity.replace("antigravity", "cursor")), + ); + const [second] = await build({ cwd: root }); + + expect(second.target).toBe("cursor"); + }, + ); +}); + describe("walkFiles distinguishes a missing directory from a file", () => { it("throws when the directory is a file", async () => { const root = await makeProject(` antigravity: { From d61608486ff9b02a3e80ed1f4eb4e3d1599e6ca7 Mon Sep 17 00:00:00 2001 From: Steve Calvert Date: Wed, 21 Oct 2026 15:29:52 -0700 Subject: [PATCH 06/24] fix(fs): probe case sensitivity on the destination volume, once per build physicalKey decided whether to fold case by looking up the case-swapped name of the deepest existing path component in its parent directory. For an outDir on a case-sensitive volume mounted under a case-insensitive parent, that component is the mount point, and its parent answers for the wrong filesystem. Distinct out and OUT directories then got one key and a valid build was refused. The probe also compared inodes, so two hard links whose names differ only by case made a case-sensitive directory look case-insensitive. The probe now asks the volume that holds the nearest existing directory. It looks up names inside that directory, or inside an ancestor on the same device. A directory that lists both spellings is case-sensitive, so hard links no longer fool it. An empty volume gets a short-lived, uniquely named scratch file instead. The platform default remains the last resort. Build and prune plans now run inside a per-plan cache of real paths, nearest directories and per-volume answers. The physical checks had tripled build time on a 3,001-file package. That build now takes about as long as before them (about 1.0s fresh, 0.7s rebuild). --- src/build.ts | 12 +- src/fs.ts | 204 +++++++++++++++++++++++++---- tests/physical-path-safety.test.ts | 119 ++++++++++++++++- 3 files changed, 306 insertions(+), 29 deletions(-) diff --git a/src/build.ts b/src/build.ts index a56fb90..37283ec 100644 --- a/src/build.ts +++ b/src/build.ts @@ -1,6 +1,6 @@ import path from "node:path"; import { assertOutDirsOutsideSource, loadConfig } from "./config.js"; -import { physicalKey, writeArtifact } from "./fs.js"; +import { physicalKey, withPathCache, writeArtifact } from "./fs.js"; import { assertManagedManifestContained, buildDeleteGuard, @@ -58,7 +58,15 @@ export async function build(options: BuildOptions = {}): Promise { * fails exactly where the real command would, and a refused prune never * leaves a half-written build behind. `force` relaxes only the delete guard. */ -export async function planBuild( +export function planBuild( + options: BuildOptions, + force?: boolean, +): Promise<{ artifacts: Artifact[]; prunes: PrunePlan[] }> { + // Nothing in the plan writes, so it can resolve each directory once. + return withPathCache(() => planBuildChecked(options, force)); +} + +async function planBuildChecked( options: BuildOptions, force?: boolean, ): Promise<{ artifacts: Artifact[]; prunes: PrunePlan[] }> { diff --git a/src/fs.ts b/src/fs.ts index 99c4db6..bcf5112 100644 --- a/src/fs.ts +++ b/src/fs.ts @@ -1,3 +1,5 @@ +import { AsyncLocalStorage } from "node:async_hooks"; +import { randomBytes } from "node:crypto"; import { promises as fs } from "node:fs"; import path from "node:path"; import type { FileValue } from "./types.js"; @@ -104,48 +106,198 @@ export async function physicalKey( const parent = path.dirname(absolute); const real = options.entry && parent !== absolute - ? path.join(await realpathDeep(parent), path.basename(absolute)) - : await realpathDeep(absolute); + ? path.join(await cachedRealpathDeep(parent), path.basename(absolute)) + : await cachedRealpathDeep(absolute); return (await isCaseInsensitiveAt(real)) ? fold(real) : real; } -const caseInsensitiveDirs = new Map>(); +/** Lookups `physicalKey` memoizes inside `withPathCache`. */ +type PathCache = { + realpaths: Map>; + directories: Map>; + caseInsensitiveDevices: Map>; +}; + +const pathCache = new AsyncLocalStorage(); + +/** + * Runs `fn` with `physicalKey` memoizing real paths and per-volume case + * sensitivity, so a preflight over thousands of files resolves each + * directory and probes each volume once rather than once per file. + * + * The memo assumes the paths it has seen do not change while `fn` runs. Wrap + * read-only checks only, never code that writes or deletes. Nested calls share + * the outer cache. + */ +export function withPathCache(fn: () => Promise): Promise { + if (pathCache.getStore()) { + return fn(); + } + return pathCache.run( + { + realpaths: new Map(), + directories: new Map(), + caseInsensitiveDevices: new Map(), + }, + fn, + ); +} + +function memoize( + map: Map> | undefined, + key: K, + compute: () => Promise, +): Promise { + if (!map) { + return compute(); + } + let value = map.get(key); + if (!value) { + value = compute(); + map.set(key, value); + } + return value; +} + +/** `realpathDeep`, memoized per path (and so per ancestor) inside `withPathCache`. */ +function cachedRealpathDeep(target: string): Promise { + const absolute = path.resolve(target); + return memoize(pathCache.getStore()?.realpaths, absolute, async () => { + try { + return await fs.realpath(absolute); + } catch (error) { + if (!isNotFoundError(error)) { + throw error; + } + const parent = path.dirname(absolute); + if (parent === absolute) { + return absolute; + } + return path.join( + await cachedRealpathDeep(parent), + path.basename(absolute), + ); + } + }); +} /** - * Whether names under `real`'s volume compare case-insensitively. Probes the - * deepest existing component of `real` that has a cased letter: if the - * case-swapped name is the same inode, the directory holding it folds case. - * Falls back to the platform default (folding on macOS and Windows) when no - * component can be probed, since over-matching is the safe direction for a - * check that decides whether to refuse. + * Whether names on the volume that holds `real` compare case-insensitively. + * `real` may not exist yet; the volume is the one holding its nearest + * existing directory, since that is where the missing tail will be created. */ async function isCaseInsensitiveAt(real: string): Promise { - let current = real; + const nearest = await nearestDirectory(real); + if (!nearest) { + return platformFoldsCase(); + } + return memoize( + pathCache.getStore()?.caseInsensitiveDevices, + nearest.dev, + () => probeVolumeCase(nearest.dir, nearest.dev), + ); +} + +/** The nearest existing directory at or above `real`, and its device. */ +function nearestDirectory( + real: string, +): Promise<{ dir: string; dev: number } | null> { + return memoize(pathCache.getStore()?.directories, real, async () => { + // lstat: `real` is resolved except, for an entry key, its final name, + // which may be a symlink. That name lives in its parent's directory. + const stats = await fs.lstat(real).catch(() => null); + if (stats?.isDirectory()) { + return { dir: real, dev: stats.dev }; + } + const parent = path.dirname(real); + return parent === real ? null : nearestDirectory(parent); + }); +} + +/** + * Probes whether the volume `dir` lives on (device `dev`) folds case. + * + * A name lookup in a directory is answered by the directory's own + * filesystem, so the probe looks up names inside `dir`, or inside an + * ancestor on the same device, never a mount point's name in the parent + * volume. For an existing name with a cased letter: if its case-swapped + * spelling is also listed, the directory holds both and is case-sensitive; + * otherwise, the volume folds case exactly when looking the swapped spelling + * up finds something. (Comparing inodes instead would be fooled by two hard + * links whose names differ only by case.) + * + * When no directory on the volume has a usable name, as on an empty volume + * just mounted, the probe creates and removes one empty, uniquely named file + * in `dir`. Only when that also fails does it fall back to the platform + * default, folding on macOS and Windows: over-matching is the safe direction + * for a check that decides whether to refuse. + */ +async function probeVolumeCase(dir: string, dev: number): Promise { + let current = dir; for (;;) { + const answer = await probeDirectoryEntries(current); + if (answer !== undefined) { + return answer; + } const parent = path.dirname(current); if (parent === current) { - return process.platform === "darwin" || process.platform === "win32"; + break; + } + const parentStats = await fs.stat(parent).catch(() => null); + if (!parentStats || parentStats.dev !== dev) { + break; } - const name = path.basename(current); + current = parent; + } + return (await probeWithScratchFile(dir)) ?? platformFoldsCase(); +} + +async function probeDirectoryEntries( + dir: string, +): Promise { + const names = await fs.readdir(dir).catch(() => null); + if (!names) { + return undefined; + } + const listed = new Set(names); + for (const name of names) { const swapped = swapCase(name); // Skip names whose case swap does not round-trip ("ß" -> "SS"): the // swapped name would be a different name, not a different case. - if (swapped !== name && swapCase(swapped) === name) { - const stats = await fs.lstat(current).catch(() => null); - if (stats) { - let cached = caseInsensitiveDirs.get(parent); - if (!cached) { - cached = fs.lstat(path.join(parent, swapped)).then( - (other) => other.ino === stats.ino && other.dev === stats.dev, - () => false, - ); - caseInsensitiveDirs.set(parent, cached); - } - return cached; - } + if (swapped === name || swapCase(swapped) !== name) { + continue; } - current = parent; + if (listed.has(swapped)) { + return false; + } + return fs.lstat(path.join(dir, swapped)).then( + () => true, + () => false, + ); } + return undefined; +} + +async function probeWithScratchFile(dir: string): Promise { + const name = `.pluginpack-case-probe-${process.pid}-${randomBytes(6).toString("hex")}`; + const probe = path.join(dir, name); + try { + await fs.writeFile(probe, "", { flag: "wx" }); + } catch { + return null; + } + try { + return await fs.lstat(path.join(dir, swapCase(name))).then( + () => true, + () => false, + ); + } finally { + await fs.rm(probe, { force: true }); + } +} + +function platformFoldsCase(): boolean { + return process.platform === "darwin" || process.platform === "win32"; } function swapCase(value: string): string { diff --git a/tests/physical-path-safety.test.ts b/tests/physical-path-safety.test.ts index 4300318..aa847bd 100644 --- a/tests/physical-path-safety.test.ts +++ b/tests/physical-path-safety.test.ts @@ -1,12 +1,16 @@ +import { execFileSync } from "node:child_process"; import { existsSync } from "node:fs"; import { access, + link, mkdir, + mkdtemp, readFile, rm, symlink, writeFile, } from "node:fs/promises"; +import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { Project, type ProjectArgs } from "fixturify-project"; @@ -14,7 +18,13 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { build } from "../src/build.js"; import { clean, prune } from "../src/cleanup.js"; import { loadConfig } from "../src/config.js"; -import { exists, json, walkFiles } from "../src/fs.js"; +import { + exists, + json, + physicalKey, + walkFiles, + withPathCache, +} from "../src/fs.js"; import { managedManifestPath, readManagedManifest } from "../src/managed.js"; type DirJSON = NonNullable; @@ -107,6 +117,53 @@ const caseInsensitiveFs = existsSync( ), ); +/** + * Mounts an empty case-sensitive APFS volume at a directory under the + * (case-insensitive by default) system temp directory, or returns null where + * that is not possible: not macOS, or `hdiutil` unavailable or refused. + */ +async function mountCaseSensitiveVolume(): Promise { + if (process.platform !== "darwin") { + return null; + } + const base = await mkdtemp(path.join(os.tmpdir(), "pluginpack-casefs-")); + const image = path.join(base, "volume.sparseimage"); + const mountPoint = path.join(base, "mnt"); + try { + await mkdir(mountPoint); + execFileSync("hdiutil", [ + "create", + "-quiet", + "-size", + "16m", + "-fs", + "Case-sensitive APFS", + "-type", + "SPARSE", + "-volname", + "pluginpack-cs", + image, + ]); + execFileSync("hdiutil", [ + "attach", + "-quiet", + "-nobrowse", + "-mountpoint", + mountPoint, + image, + ]); + } catch { + await rm(base, { recursive: true, force: true }); + return null; + } + return mountPoint; +} + +function unmountVolume(mountPoint: string): void { + execFileSync("hdiutil", ["detach", "-quiet", "-force", mountPoint]); + execFileSync("rm", ["-rf", path.dirname(mountPoint)]); +} + const sourceSurvives = async (root: string) => { await access(path.join(root, "shared/acme/skills/alpha/SKILL.md")); await access(path.join(root, "shared/acme/skills/beta/SKILL.md")); @@ -242,6 +299,28 @@ describe("source protection compares physical paths", () => { }); }); +/** + * In a case-sensitive `dir`, two hard links whose names differ only by case + * are one inode: comparing inodes of `name` and `NAME` would conclude the + * directory folds case and alias the distinct files `other` and `OTHER`. + */ +async function expectHardLinksKeepCase(dir: string): Promise { + const links = path.join(dir, "links"); + await mkdir(links); + await writeFile(path.join(links, "name"), "x\n"); + await link(path.join(links, "name"), path.join(links, "NAME")); + await writeFile(path.join(links, "other"), "a\n"); + await writeFile(path.join(links, "OTHER"), "b\n"); + + const keys = await withPathCache(async () => [ + await physicalKey(path.join(links, "name"), { entry: true }), + await physicalKey(path.join(links, "other"), { entry: true }), + await physicalKey(path.join(links, "OTHER"), { entry: true }), + ]); + + expect(new Set(keys).size).toBe(3); +} + describe("cross-target collision checks compare physical paths", () => { const codexAndAgentPlugins = ( codexOut: string, @@ -292,6 +371,44 @@ describe("cross-target collision checks compare physical paths", () => { }, ); + it("builds outDirs that differ only by case on a case-sensitive volume mounted under a case-insensitive parent", async (context) => { + // The volume is the destination filesystem. Probing how its mount point's + // name compares in the parent directory asks the wrong filesystem. + const volume = await mountCaseSensitiveVolume(); + if (!volume) { + context.skip(); + return; + } + try { + const root = await makeProject( + codexAndAgentPlugins( + path.join(volume, "out"), + path.join(volume, "OUT"), + ), + ); + + await build({ cwd: root }); + await access(path.join(volume, "out/plugins/acme/plugin.json")); + await access(path.join(volume, "OUT/plugins/acme/plugin.json")); + await clean({ cwd: root }); + await expect(exists(path.join(volume, "out/plugins"))).resolves.toBe( + false, + ); + await expectHardLinksKeepCase(volume); + } finally { + unmountVolume(volume); + } + }); + + it.skipIf(caseInsensitiveFs)( + "does not read a hard link with a case-swapped name as a case-insensitive directory", + async () => { + const root = await makeProject(codexAndAgentPlugins("out", "OUT")); + await expectHardLinksKeepCase(root); + await build({ cwd: root }); + }, + ); + it("refuses to clean one target's manifest entries that alias another's through a symlink", async () => { const root = await makeProject(codexAndAgentPlugins("out", "alias-out")); await mkdir(path.join(root, "out")); From 1fd883d18e12346cfd07c6f2d872902f343b2953 Mon Sep 17 00:00:00 2001 From: Steve Calvert Date: Wed, 21 Oct 2026 15:31:33 -0700 Subject: [PATCH 07/24] fix(build): resolve final symlinks when checking write collisions The cross-target collision check keyed each output path with only its parent directory resolved. That is the right key for deletion, since unlinking a symlink removes the link. A write follows the link, though. A generated SKILL.md that was a symlink to another target's file let both targets build green, and the first target's skill shipped the second target's content. Write collisions now compare fully resolved paths, and ownership of stale paths still compares entries. --- src/build.ts | 48 ++++++++++++++++++++++-------- tests/physical-path-safety.test.ts | 40 +++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 13 deletions(-) diff --git a/src/build.ts b/src/build.ts index 37283ec..01c8d51 100644 --- a/src/build.ts +++ b/src/build.ts @@ -100,7 +100,7 @@ async function planBuildChecked( await assertManagedManifestContained(artifact.outDir, artifact.target); } const owners = new Map(claimedElsewhere); - for (const [key, claim] of incoming) { + for (const [key, claim] of incoming.byEntry) { owners.set(key, claim.target); } const prunes: PrunePlan[] = []; @@ -174,38 +174,58 @@ async function assertNoSelfReferentialRootFiles( /** Which target claims a path, and the path as that target names it. */ type Claim = { target: TargetName; absolute: string }; +/** + * Every path this build writes, keyed two ways. `byWrite` keys the file a + * write lands on, with every symlink resolved, the final entry included: + * writing through a symlinked `SKILL.md` changes the file it points at. Write + * collisions compare these. `byEntry` keys the entry itself (parent resolved, + * final name kept), which is what a prune unlinks; ownership compares these. + */ +type IncomingPaths = { + byWrite: Map; + byEntry: Map; +}; + /** * 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. * Keys on physical paths, so `out` and `OUT` on a case-insensitive filesystem, - * or an outDir reached through a symlink, are one path, as they are on disk. - * Returns the physical-key -> claim map so later checks can reuse it. + * an outDir reached through a symlink, or a generated file that is itself a + * symlink to another target's file, are one path, as they are on disk. + * Returns the keyed claims so later checks can reuse them. */ async function assertNoCrossTargetCollisions( artifacts: Artifact[], -): Promise> { - const owner = new Map(); +): Promise { + const byWrite = new Map(); + const byEntry = new Map(); const collisions: string[] = []; for (const artifact of artifacts) { for (const managedPath of artifact.managedPaths) { const absolute = path.resolve(artifact.outDir, managedPath); - const key = await physicalKey(absolute, { entry: true }); - const previous = owner.get(key); + const claim = { target: artifact.target, absolute }; + const writeKey = await physicalKey(absolute); + const previous = byWrite.get(writeKey); if (previous && previous.target !== artifact.target) { collisions.push( ` ${previous.target} and ${artifact.target}: ${describeAlias(previous.absolute, absolute)}`, ); - } else { - owner.set(key, { target: artifact.target, absolute }); + continue; + } + byWrite.set(writeKey, claim); + const entryKey = await physicalKey(absolute, { entry: true }); + if (!byEntry.has(entryKey)) { + byEntry.set(entryKey, claim); } } } if (collisions.length > 0) { throw new Error( - `Targets write overlapping output paths; give them distinct outDirs:\n${collisions.join("\n")}`, + `Targets write overlapping output paths; give them distinct outDirs:\n${collisions.join("\n")}\n` + + `If an output path is a symlink to another target's file, replace it with a regular file or remove it.`, ); } - return owner; + return { byWrite, byEntry }; } /** @@ -224,7 +244,7 @@ async function assertNoCrossTargetCollisions( async function assertNoCollisionsWithBuiltTargets( project: ResolvedProject, targets: TargetName[], - incoming: Map, + incoming: IncomingPaths, ): Promise> { const building = new Set(targets); const claimed = new Map(); @@ -246,7 +266,9 @@ async function assertNoCollisionsWithBuiltTargets( const absolute = path.resolve(otherOutDir, normalizeManagedPath(file)); const key = await physicalKey(absolute, { entry: true }); claimed.set(key, other); - const conflicting = incoming.get(key); + const conflicting = + incoming.byEntry.get(key) ?? + incoming.byWrite.get(await physicalKey(absolute)); if (conflicting) { collisions.push( ` ${other} (previously built) and ${conflicting.target}: ${describeAlias(absolute, conflicting.absolute)}`, diff --git a/tests/physical-path-safety.test.ts b/tests/physical-path-safety.test.ts index aa847bd..74600f6 100644 --- a/tests/physical-path-safety.test.ts +++ b/tests/physical-path-safety.test.ts @@ -409,6 +409,46 @@ describe("cross-target collision checks compare physical paths", () => { }, ); + it("rejects a generated file that is a symlink to another target's generated file", async () => { + const root = await makeProject( + ` antigravity: { + outDir: "out", + plugins: { first: { source: "shared/acme", path: "first" } } + }, + cursor: { + outDir: "out", + plugins: { second: { source: "shared/other", path: "second" } } + }`, + { + out: { + second: { skills: { alpha: { "SKILL.md": "OLD\n" } } }, + first: { skills: { alpha: {} } }, + }, + }, + ); + await mkdir(path.join(root, "shared/other/skills/alpha"), { + recursive: true, + }); + await writeFile( + path.join(root, "shared/other/skills/alpha/SKILL.md"), + skill("alpha").replace("body", "OTHER"), + ); + await symlink( + "../../../second/skills/alpha/SKILL.md", + path.join(root, "out/first/skills/alpha/SKILL.md"), + ); + + await expect(build({ cwd: root })).rejects.toThrow( + /Targets write overlapping output paths[\s\S]*antigravity and cursor: .*out\/first\/skills\/alpha\/SKILL\.md \(same file as .*out\/second\/skills\/alpha\/SKILL\.md\)/, + ); + expect( + await readFile( + path.join(root, "out/second/skills/alpha/SKILL.md"), + "utf8", + ), + ).toBe("OLD\n"); + }); + it("refuses to clean one target's manifest entries that alias another's through a symlink", async () => { const root = await makeProject(codexAndAgentPlugins("out", "alias-out")); await mkdir(path.join(root, "out")); From b46def66e8ea5d2ee2c03ccfdcf4ef8d46c5679d Mon Sep 17 00:00:00 2001 From: Steve Calvert Date: Wed, 21 Oct 2026 15:36:36 -0700 Subject: [PATCH 08/24] fix(managed): check deletion containment before any write or delete The checks that stop prune and clean from deleting through a symlink out of the output directory ran only inside the delete itself. A dry run skipped them and succeeded where the real command refused. A build wrote its new output and then refused at prune time. clean deleted one target's files and then refused for the next. The containment checks now run in the shared preflight for every stale or managed path, on dry runs too. clean now plans every target before deleting anything. A managed path below a directory someone replaced with a file made clean throw a raw ENOTDIR after it had already deleted other files. That managed file cannot exist, so it now counts as already gone, and the file in its way stays. Any other failure to inspect a managed path, such as EACCES on a locked directory, now names the managed manifest, says nothing was deleted, and gives the way out. --- src/build.ts | 7 +- src/cleanup.ts | 55 +++++--- src/managed.ts | 207 ++++++++++++++++++++++++----- tests/physical-path-safety.test.ts | 91 +++++++++++++ 4 files changed, 302 insertions(+), 58 deletions(-) diff --git a/src/build.ts b/src/build.ts index 01c8d51..8f02bd7 100644 --- a/src/build.ts +++ b/src/build.ts @@ -5,6 +5,7 @@ import { assertManagedManifestContained, buildDeleteGuard, executePrune, + managedPathKey, normalizeManagedPath, planPrune, readManagedManifest, @@ -264,11 +265,13 @@ async function assertNoCollisionsWithBuiltTargets( } for (const file of manifest.files) { const absolute = path.resolve(otherOutDir, normalizeManagedPath(file)); - const key = await physicalKey(absolute, { entry: true }); + const key = await managedPathKey(otherOutDir, other, file, { + entry: true, + }); claimed.set(key, other); const conflicting = incoming.byEntry.get(key) ?? - incoming.byWrite.get(await physicalKey(absolute)); + incoming.byWrite.get(await managedPathKey(otherOutDir, other, file)); if (conflicting) { collisions.push( ` ${other} (previously built) and ${conflicting.target}: ${describeAlias(absolute, conflicting.absolute)}`, diff --git a/src/cleanup.ts b/src/cleanup.ts index 7708340..d29806c 100644 --- a/src/cleanup.ts +++ b/src/cleanup.ts @@ -2,14 +2,18 @@ import path from "node:path"; import { describeAlias, planBuild } from "./build.js"; import { targetNames } from "./adapters.js"; import { loadProjectConfig } from "./config.js"; -import { physicalKey } from "./fs.js"; +import { withPathCache } from "./fs.js"; import { buildDeleteGuard, - cleanManagedFiles, + cleanResult, + executeClean, executePrune, + managedPathKey, + planClean, normalizeManagedPath, pruneResult, readManagedManifest, + type CleanPlan, } from "./managed.js"; import type { CleanupResult, @@ -59,27 +63,34 @@ export async function clean( force?: boolean; } = {}, ): Promise { - const project = await loadProjectConfig(options.cwd, options.configPath); - const guard = await buildDeleteGuard(project, options.force); - const targets = options.target - ? [options.target] - : (Object.keys(project.config.targets) as TargetName[]); - await assertNoManifestCollisions(project, targets, options.force); - const results: CleanupResult[] = []; - for (const target of targets) { - const targetConfig = project.config.targets[target]; - if (!targetConfig) { - throw new Error(`Target "${target}" is not configured.`); + // Check every target before deleting anything, so a refusal for one target + // does not leave another already torn down. + const plans = await withPathCache(async () => { + const project = await loadProjectConfig(options.cwd, options.configPath); + const guard = await buildDeleteGuard(project, options.force); + const targets = options.target + ? [options.target] + : (Object.keys(project.config.targets) as TargetName[]); + await assertNoManifestCollisions(project, targets, options.force); + const planned: CleanPlan[] = []; + for (const target of targets) { + const targetConfig = project.config.targets[target]; + if (!targetConfig) { + throw new Error(`Target "${target}" is not configured.`); + } + const outDir = path.resolve(project.rootDir, targetConfig.outDir); + planned.push( + await planClean(outDir, target, { dryRun: options.dryRun, guard }), + ); + } + return planned; + }); + if (!options.dryRun) { + for (const plan of plans) { + await executeClean(plan); } - const outDir = path.resolve(project.rootDir, targetConfig.outDir); - results.push( - await cleanManagedFiles(outDir, target, { - dryRun: options.dryRun, - guard, - }), - ); } - return results; + return plans.map(cleanResult); } /** @@ -122,7 +133,7 @@ async function assertNoManifestCollisions( } for (const file of manifest.files) { const absolute = path.resolve(outDir, normalizeManagedPath(file)); - const key = await physicalKey(absolute, { entry: true }); + const key = await managedPathKey(outDir, target, file, { entry: true }); const owner = owners.get(key); if (owner && owner.target !== target) { if (cleaning.includes(owner.target) || cleaning.includes(target)) { diff --git a/src/managed.ts b/src/managed.ts index 2347639..31261e7 100644 --- a/src/managed.ts +++ b/src/managed.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { isInside, isInsideFolded, + isNotFoundError, json, physicalKey, resolveInside, @@ -206,7 +207,7 @@ export async function planPrune( continue; } if (options.owners) { - const key = await physicalKey(path.resolve(artifact.outDir, file), { + const key = await managedPathKey(artifact.outDir, artifact.target, file, { entry: true, }); const owner = options.owners.get(key); @@ -218,10 +219,12 @@ export async function planPrune( } await assertNoProtectedDeletions( artifact.outDir, + artifact.target, stale, options.guard, "prune", ); + await assertRemovalsContained(artifact.outDir, artifact.target, stale); await warnOnEditedFiles( artifact.outDir, artifact.target, @@ -276,19 +279,37 @@ export async function warnOnEditedOverwrites( ); } -/** Deletes every file the previous build managed for a target, including its manifest. */ -export async function cleanManagedFiles( +/** What a clean will delete for one target, decided before anything is removed. */ +export type CleanPlan = { + target: TargetName; + outDir: string; + /** Every managed path, then the manifest itself; empty when no manifest exists. */ + paths: string[]; +}; + +/** + * Checks it is safe to delete every file the previous build managed for a + * target, without touching disk: the delete guard, containment of every path, + * and a warning for each edited file. Runs on dry runs too. + */ +export async function planClean( outDir: string, target: TargetName, options: { dryRun?: boolean; guard?: DeleteGuard } = {}, -): Promise { +): Promise { const previous = await readManagedManifest(outDir, target); - const entries: CleanupEntry[] = []; if (!previous) { - return { target, outDir, entries }; + return { target, outDir, paths: [] }; } const files = previous.files.map(normalizeManagedPath); - await assertNoProtectedDeletions(outDir, files, options.guard, "clean"); + await assertNoProtectedDeletions( + outDir, + target, + files, + options.guard, + "clean", + ); + await assertRemovalsContained(outDir, target, files); await warnOnEditedFiles( outDir, target, @@ -296,18 +317,27 @@ export async function cleanManagedFiles( previous, options.dryRun ? "a real run would delete it" : "deleting it", ); - for (const normalized of files) { - entries.push({ type: "deleted", target, path: normalized }); - if (!options.dryRun) { - await removeManagedPath(outDir, normalized, target); - } - } - const manifestPath = managedManifestPath(target); - entries.push({ type: "deleted", target, path: manifestPath }); - if (!options.dryRun) { - await removeManagedPath(outDir, manifestPath, target); + return { target, outDir, paths: [...files, managedManifestPath(target)] }; +} + +/** Deletes what a `planClean` decided on, the manifest last. */ +export async function executeClean(plan: CleanPlan): Promise { + for (const file of plan.paths) { + await removeManagedPath(plan.outDir, file, plan.target); } - return { target, outDir, entries }; +} + +/** The cleanup report for a clean plan. */ +export function cleanResult(plan: CleanPlan): CleanupResult { + return { + target: plan.target, + outDir: plan.outDir, + entries: plan.paths.map((file): CleanupEntry => ({ + type: "deleted", + target: plan.target, + path: file, + })), + }; } /** @@ -380,6 +410,7 @@ export async function buildDeleteGuard( async function assertNoProtectedDeletions( outDir: string, + target: TargetName, paths: string[], guard: DeleteGuard | undefined, command: string, @@ -396,7 +427,7 @@ async function assertNoProtectedDeletions( ); const blocked: Array<{ file: string; root: string }> = []; for (const file of paths) { - const root = await protectingRoot(outDir, file, rootKeys); + const root = await protectingRoot(outDir, target, file, rootKeys); if (root !== null) { blocked.push({ file, root }); } @@ -427,6 +458,7 @@ async function assertNoProtectedDeletions( */ async function protectingRoot( outDir: string, + target: TargetName, relativePath: string, roots: Array<{ root: string; key: string }>, ): Promise { @@ -435,10 +467,54 @@ async function protectingRoot( if (lexical) { return lexical.root; } - const key = await physicalKey(absolute, { entry: true }); + const key = await managedPathKey(outDir, target, relativePath, { + entry: true, + }); return roots.find((root) => isInside(root.key, key))?.root ?? null; } +/** + * `physicalKey` for a path a target's managed manifest lists. A failure to + * resolve it (EACCES on a locked directory, say) names the manifest and the + * way out, since every caller runs before anything is deleted. + */ +export async function managedPathKey( + outDir: string, + target: TargetName, + relativePath: string, + options: { entry?: boolean } = {}, +): Promise { + try { + return await physicalKey( + path.resolve(outDir, normalizeManagedPath(relativePath)), + options, + ); + } catch (error) { + throw uncheckedPathError(outDir, target, relativePath, error); + } +} + +function uncheckedPathError( + outDir: string, + target: TargetName, + relativePath: string, + error: unknown, +): Error { + return new Error( + `Cannot check managed path "${relativePath}" under ${outDir}: ${(error as Error).message}. ` + + nothingDeleted(outDir, target), + { cause: error }, + ); +} + +function nothingDeleted(outDir: string, target: TargetName): string { + const manifest = path.join(path.resolve(outDir), managedManifestPath(target)); + return ( + `Nothing was deleted. The managed manifest ${manifest} lists this path. ` + + `Fix the cause and re-run the same command, or remove the entry from that manifest to leave the path alone.` + ); +} + /** Normalizes a managed path to a safe, relative, forward-slash form, throwing if it escapes the output dir. */ export function normalizeManagedPath(value: string): string { const normalized = path.posix.normalize(value.replace(/\\/g, "/")); @@ -459,23 +535,34 @@ export function normalizeManagedPath(value: string): string { return normalized.startsWith("./") ? normalized.slice(2) : normalized; } -async function removeManagedPath( - outDir: string, +/** A managed path that cannot be removed safely. The message says why. */ +class UnsafeRemovalError extends Error {} + +/** + * The absolute path `relativePath` names under `root`, or `null` when there is + * nothing to remove: the entry is missing, or an ancestor is now a file, so + * the managed file cannot exist. Throws `UnsafeRemovalError` when removing the + * entry would reach outside `root`. + */ +async function inspectRemoval( + root: string, relativePath: string, - target: TargetName, -): Promise { - const root = path.resolve(outDir); +): Promise { const normalized = normalizeManagedPath(relativePath); const destination = path.resolve(root, normalized); if (!isInside(root, destination)) { - throw new Error(`Managed path escapes output directory: ${relativePath}`); + throw new UnsafeRemovalError( + `Managed path escapes output directory: ${relativePath}`, + ); } let stats; try { stats = await fs.lstat(destination); } catch (error) { - if (isNotFound(error)) { - return; + // ENOTDIR: an ancestor was replaced by a file, so the managed file is + // gone and the file in its way is not pluginpack's to delete. + if (isNotFoundError(error)) { + return null; } throw error; } @@ -485,7 +572,7 @@ async function removeManagedPath( await fs.readlink(destination), ); if (!isInside(root, linkTarget)) { - throw new Error( + throw new UnsafeRemovalError( `Refusing to remove a symlink pointing outside the output directory: ${relativePath}`, ); } @@ -495,18 +582,70 @@ async function removeManagedPath( // string test passes and the final entry is an ordinary file. Resolve the // real destination as the catch-all before deleting. if ((await resolveInside(root, destination)) === null) { - throw new Error( + throw new UnsafeRemovalError( `Refusing to remove a path that resolves outside the output directory: ${relativePath}`, ); } + return destination; +} + +/** + * Runs the containment checks `removeManagedPath` runs, for every path, before + * anything is written or deleted. Dry runs run it too, so `--dry-run` refuses + * a path that escapes the output directory exactly where the real command + * would, and a build refuses before writing new output. + */ +async function assertRemovalsContained( + outDir: string, + target: TargetName, + paths: string[], +): Promise { + const root = path.resolve(outDir); + for (const file of paths) { + try { + await inspectRemoval(root, file); + } catch (error) { + if (error instanceof UnsafeRemovalError) { + throw new Error( + `${error.message} (under ${outDir}). ${nothingDeleted(outDir, target)}`, + { cause: error }, + ); + } + throw uncheckedPathError(outDir, target, file, error); + } + } +} + +async function removeManagedPath( + outDir: string, + relativePath: string, + target: TargetName, +): Promise { + const root = path.resolve(outDir); + const manifest = path.join(root, managedManifestPath(target)); + const recovery = + `The managed manifest ${manifest} still lists every remaining path, so fix the cause and re-run the same command. ` + + `To stop pluginpack managing these files instead, delete that manifest.`; + let destination: string | null; + try { + destination = await inspectRemoval(root, relativePath); + } catch (error) { + if (error instanceof UnsafeRemovalError) { + throw new Error(`${error.message}. ${recovery}`, { cause: error }); + } + throw new Error( + `Failed to check managed path "${relativePath}" under ${outDir}: ${(error as Error).message}. ${recovery}`, + { cause: error }, + ); + } + if (destination === null) { + return; + } try { await fs.rm(destination, { force: true }); } catch (error) { - const manifest = path.join(root, managedManifestPath(target)); throw new Error( - `Failed to remove managed path "${relativePath}" under ${outDir}: ${(error as Error).message}. ` + - `The managed manifest ${manifest} still lists every remaining path, so fix the cause and re-run the same command. ` + - `To stop pluginpack managing these files instead, delete that manifest.`, + `Failed to remove managed path "${relativePath}" under ${outDir}: ${(error as Error).message}. ${recovery}`, { cause: error }, ); } diff --git a/tests/physical-path-safety.test.ts b/tests/physical-path-safety.test.ts index 74600f6..0c29063 100644 --- a/tests/physical-path-safety.test.ts +++ b/tests/physical-path-safety.test.ts @@ -2,6 +2,7 @@ import { execFileSync } from "node:child_process"; import { existsSync } from "node:fs"; import { access, + chmod, link, mkdir, mkdtemp, @@ -586,6 +587,44 @@ describe("dry runs run the same destructive preflight as the real command", () = await access(path.join(root, "skills/gamma/SKILL.md")); }); + describe("with a stale path that crosses a symlink out of the outDir", () => { + async function escapingStalePath(): Promise { + const root = await makeProject( + ` antigravity: { + outDir: "out", + plugins: { acme: { source: "shared/acme" } } + }`, + { outside: { "sentinel.txt": "UNMANAGED\n" }, out: {} }, + ); + await symlink("../outside", path.join(root, "out/link")); + await writeManifest(root, "out", "antigravity", ["link/sentinel.txt"]); + return root; + } + const refusal = + /Refusing to remove a path that resolves outside the output directory: link\/sentinel\.txt \(under .*out\)\. Nothing was deleted\. The managed manifest .*out\/\.pluginpack\/antigravity\.json lists this path/; + + it.each(["clean", "prune"] as const)( + "%s --dry-run refuses it, as the real command does", + async (command) => { + const root = await escapingStalePath(); + const run = command === "clean" ? clean : prune; + + await expect(run({ cwd: root, dryRun: true })).rejects.toThrow(refusal); + await expect(run({ cwd: root })).rejects.toThrow(refusal); + await access(path.join(root, "outside/sentinel.txt")); + }, + ); + + it("build refuses it before writing any output", async () => { + const root = await escapingStalePath(); + + await expect(build({ cwd: root, dryRun: true })).rejects.toThrow(refusal); + await expect(build({ cwd: root })).rejects.toThrow(refusal); + await expect(exists(path.join(root, "out/acme"))).resolves.toBe(false); + await access(path.join(root, "outside/sentinel.txt")); + }); + }); + it("build --dry-run fails where build would, and build refuses before writing", async () => { const root = await protectedStalePath(); await rm(path.join(root, "generated"), { recursive: true }); @@ -843,6 +882,58 @@ describe("a failed clean says how to recover", () => { }); }); +describe("clean when generated directories were replaced or locked", () => { + const target = ` antigravity: { + outDir: "out", + plugins: { acme: { source: "shared/acme" } } + }`; + + it("treats managed files below a directory now replaced by a file as gone", async () => { + const root = await makeProject(target); + await build({ cwd: root }); + await rm(path.join(root, "out/acme/skills"), { recursive: true }); + await writeFile(path.join(root, "out/acme/skills"), "USER FILE\n"); + + await clean({ cwd: root }); + + expect(await readFile(path.join(root, "out/acme/skills"), "utf8")).toBe( + "USER FILE\n", + ); + await expect(exists(path.join(root, "out/acme/plugin.json"))).resolves.toBe( + false, + ); + await expect( + exists(path.join(root, "out", managedManifestPath("antigravity"))), + ).resolves.toBe(false); + }); + + it.skipIf(process.getuid?.() === 0 || process.platform === "win32")( + "names the manifest and deletes nothing when a managed path cannot be inspected", + async () => { + const root = await makeProject(target); + await build({ cwd: root }); + const locked = path.join(root, "out/acme/skills"); + await chmod(locked, 0o000); + try { + const manifestPath = path.join( + root, + "out", + managedManifestPath("antigravity"), + ); + await expect(clean({ cwd: root })).rejects.toThrow( + new RegExp( + `Cannot check managed path "acme/skills/alpha/SKILL\\.md" under .*out: EACCES[\\s\\S]*Nothing was deleted\\. The managed manifest ${manifestPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")} lists this path`, + ), + ); + await access(path.join(root, "out/acme/plugin.json")); + await access(manifestPath); + } finally { + await chmod(locked, 0o755); + } + }, + ); +}); + describe("an external outDir keeps its version-control data", () => { it("refuses to clean a manifest entry inside the outDir's .git", async () => { const root = await makeProject( From 5f864e3e1074ded0c7d63f8ebac318d6202052ac Mon Sep 17 00:00:00 2001 From: Steve Calvert Date: Wed, 21 Oct 2026 15:39:16 -0700 Subject: [PATCH 09/24] fix(build): keep handed-over paths owned and warn before overwriting their edits When a path moves from one target to another in one build, the donor's new manifest drops it and the recipient's new manifest adds it. build wrote each target's manifest right after that target's prune. If the recipient's prune failed after the donor's manifest was written, neither manifest listed the path, and a later clean left the file behind. build now first writes every target's manifest with its new paths and its old ones, then prunes each target and writes its final manifest. A handed-over path is then always listed by at least one manifest. The hand-edit warning compared a file only against the overwriting target's own previous manifest. A handed-over file is new to that manifest, so an edit to it vanished without a warning. The plan now carries the hash the previous owner recorded, and the warning names the target it came from. --- src/build.ts | 11 ++++ src/managed.ts | 100 +++++++++++++++++++++++++---- tests/physical-path-safety.test.ts | 66 +++++++++++++++++++ 3 files changed, 163 insertions(+), 14 deletions(-) diff --git a/src/build.ts b/src/build.ts index 8f02bd7..4e6a61e 100644 --- a/src/build.ts +++ b/src/build.ts @@ -9,6 +9,7 @@ import { normalizeManagedPath, planPrune, readManagedManifest, + recordHandedOverHashes, warnOnEditedOverwrites, writeManagedManifest, type PrunePlan, @@ -41,6 +42,15 @@ export async function build(options: BuildOptions = {}): Promise { for (const artifact of artifacts) { await writeArtifact(artifact.outDir, artifact.files); } + // Before any target prunes or gives up a path, every target's manifest + // claims its new paths and still lists its old ones. A path handed from + // one target to another is then always listed by at least one manifest: + // if the recipient's prune fails after the donor's final manifest is + // written, the recipient's interim manifest still owns the path, and a + // later clean can remove it. + for (const plan of prunes) { + await writeManagedManifest(plan.artifact, plan.previous); + } for (const plan of prunes) { await executePrune(plan); await writeManagedManifest(plan.artifact); @@ -110,6 +120,7 @@ async function planBuildChecked( await planPrune(artifact, { dryRun: options.dryRun, guard, owners }), ); } + await recordHandedOverHashes(prunes); return { artifacts, prunes }; } diff --git a/src/managed.ts b/src/managed.ts index 31261e7..09cef43 100644 --- a/src/managed.ts +++ b/src/managed.ts @@ -54,7 +54,11 @@ function contentHash(value: FileValue): string { * as the artifact itself, so a symlinked `.pluginpack/` cannot redirect the * write outside the output directory. */ -export async function writeManagedManifest(artifact: Artifact): Promise { +export async function writeManagedManifest( + artifact: Artifact, + retained: ManagedManifest | null = null, +): Promise { + const files = [...artifact.managedPaths]; const sha256: Record = {}; for (const managedPath of artifact.managedPaths) { const value = artifact.files.get(managedPath); @@ -62,10 +66,20 @@ export async function writeManagedManifest(artifact: Artifact): Promise { sha256[managedPath] = contentHash(value); } } + const current = new Set(artifact.managedPaths.map(normalizeManagedPath)); + for (const file of retained?.files ?? []) { + if (!current.has(normalizeManagedPath(file))) { + files.push(file); + const hash = retained?.sha256[file]; + if (hash !== undefined) { + sha256[file] = hash; + } + } + } const manifest: ManagedManifest = { version: MANIFEST_VERSION, target: artifact.target, - files: artifact.managedPaths, + files, sha256, }; await writeArtifact( @@ -172,13 +186,58 @@ export async function readManagedManifest( return { version: MANIFEST_VERSION, target, files, sha256 }; } +/** A hash a target's manifest recorded for a file it wrote. */ +type RecordedHash = { target: TargetName; sha256: string }; + /** What a prune will delete, decided before anything is written or removed. */ export type PrunePlan = { artifact: Artifact; previous: ManagedManifest | null; stale: string[]; + /** + * For a path this target takes over from another target in this build, the + * hash the previous owner recorded, so an edit to it still warns. Filled + * in by `recordHandedOverHashes`. + */ + handedOver: Map; }; +/** + * Fills in each plan's `handedOver` hashes from the previous manifests of the + * other targets in the same build. A path that moves from one target to + * another (the agent-plugins Codex marketplace handed to the codex target, + * say) is new to the recipient's manifest, so without this an edit to it + * would be overwritten without a warning. + */ +export async function recordHandedOverHashes( + plans: PrunePlan[], +): Promise { + const recorded = new Map(); + for (const plan of plans) { + const { outDir, target } = plan.artifact; + for (const [file, sha256] of Object.entries(plan.previous?.sha256 ?? {})) { + const key = await managedPathKey(outDir, target, file, { entry: true }); + recorded.set(key, { target, sha256 }); + } + } + for (const plan of plans) { + const { outDir, target } = plan.artifact; + const own = new Set( + (plan.previous?.files ?? []).map((file) => normalizeManagedPath(file)), + ); + for (const file of plan.artifact.managedPaths) { + if (own.has(normalizeManagedPath(file))) { + continue; + } + const key = await managedPathKey(outDir, target, file, { entry: true }); + const previousOwner = recorded.get(key); + if (previousOwner && previousOwner.target !== target) { + plan.handedOver.set(file, previousOwner); + } + } + } +} + /** * Decides which previously managed files are stale and checks it is safe to * delete them, without touching disk. Runs on dry runs too, so `--dry-run` @@ -229,10 +288,10 @@ export async function planPrune( artifact.outDir, artifact.target, stale, - previous, + ownHashes(artifact.target, previous), options.dryRun ? "a real run would delete it" : "deleting it", ); - return { artifact, previous, stale }; + return { artifact, previous, stale, handedOver: new Map() }; } /** Deletes the stale files a `planPrune` decided on. */ @@ -264,16 +323,17 @@ export async function warnOnEditedOverwrites( plan: PrunePlan, dryRun?: boolean, ): Promise { - const { artifact, previous } = plan; + const { artifact, previous, handedOver } = plan; const incoming = new Map(); for (const [file, value] of artifact.files) { incoming.set(file, contentHash(value)); } + const own = ownHashes(artifact.target, previous); await warnOnEditedFiles( artifact.outDir, artifact.target, artifact.managedPaths, - previous, + (file) => own(file) ?? handedOver.get(file), dryRun ? "a real build would overwrite it" : "overwriting it", incoming, ); @@ -314,7 +374,7 @@ export async function planClean( outDir, target, files, - previous, + ownHashes(target, previous), options.dryRun ? "a real run would delete it" : "deleting it", ); return { target, outDir, paths: [...files, managedManifestPath(target)] }; @@ -350,15 +410,12 @@ async function warnOnEditedFiles( outDir: string, target: TargetName, files: string[], - previous: ManagedManifest | null, + recordedHash: (file: string) => RecordedHash | undefined, action: string, replacements?: Map, ): Promise { - if (!previous) { - return; - } for (const file of files) { - const recorded = previous.sha256[file]; + const recorded = recordedHash(file); if (recorded === undefined) { continue; } @@ -369,15 +426,30 @@ async function warnOnEditedFiles( continue; } const actual = contentHash(contents); - if (actual !== recorded && actual !== replacements?.get(file)) { + if (actual !== recorded.sha256 && actual !== replacements?.get(file)) { + const from = + recorded.target === target + ? "" + : ` (handed over from ${recorded.target})`; console.warn( - `warning: ${target} managed file ${path.join(outDir, file)} was edited after pluginpack wrote it; ${action}. ` + + `warning: ${target} managed file ${path.join(outDir, file)}${from} was edited after pluginpack wrote it; ${action}. ` + `Keep the edit by moving it into the source, or recover it from version control.`, ); } } } +/** Looks up the hashes a target's own previous manifest recorded. */ +function ownHashes( + target: TargetName, + previous: ManagedManifest | null, +): (file: string) => RecordedHash | undefined { + return (file) => { + const sha256 = previous?.sha256[file]; + return sha256 === undefined ? undefined : { target, sha256 }; + }; +} + /** * Builds the guard that stops prune/clean from deleting the config, a source * tree, or an output repository's `.git` directory. diff --git a/tests/physical-path-safety.test.ts b/tests/physical-path-safety.test.ts index 0c29063..967c671 100644 --- a/tests/physical-path-safety.test.ts +++ b/tests/physical-path-safety.test.ts @@ -518,6 +518,72 @@ describe("a stale path another target now owns is left in place", () => { ).not.toContain(".agents/plugins/marketplace.json"); }); + it("warns before overwriting an edited file handed over from another target", async () => { + const root = await makeProject(agentPluginsWithCodex); + await build({ cwd: root }); + await writeFile( + path.join(root, marketplace), + `${await readFile(path.join(root, marketplace), "utf8")}HAND EDIT\n`, + ); + const configPath = await revision( + root, + "handoff", + `${codexTarget},\n${agentPluginsWithoutCodex}`, + ); + + const warnings = await captureWarnings(() => + build({ cwd: root, configPath }), + ); + + expect(warnings).toEqual([ + expect.stringMatching( + /codex managed file .*out\/\.agents\/plugins\/marketplace\.json \(handed over from agent-plugins\) was edited after pluginpack wrote it; overwriting it/, + ), + ]); + }); + + it("keeps a handed-over path owned when the recipient's prune fails", async () => { + const one = `outDir: "out", plugins: { one: { source: "shared/acme" } }`; + const two = `outDir: "out", plugins: { two: { source: "shared/acme" } }`; + const root = await makeProject( + ` antigravity: { ${one}, rootFiles: { "transfer.txt": "authored.txt" } }, + cursor: { ${two}, rootFiles: { "blocked": "authored.txt" } }`, + { "authored.txt": "AUTHORED\n" }, + ); + await build({ cwd: root }); + // A directory where cursor's stale file was makes cursor's prune fail. + await rm(path.join(root, "out/blocked")); + await mkdir(path.join(root, "out/blocked")); + const configPath = await revision( + root, + "handoff", + ` antigravity: { ${one} }, + cursor: { ${two}, rootFiles: { "transfer.txt": "authored.txt" } }`, + ); + + await expect(build({ cwd: root, configPath })).rejects.toThrow( + /Failed to remove managed path "blocked"/, + ); + + const owners: string[] = []; + for (const target of ["antigravity", "cursor"] as const) { + const manifest = await readManagedManifest( + path.join(root, "out"), + target, + ); + if (manifest?.files.includes("transfer.txt")) { + owners.push(target); + } + } + expect(owners).toEqual(["cursor"]); + await rm(path.join(root, "out/blocked"), { recursive: true }); + await clean({ cwd: root, configPath }); + await expect(exists(path.join(root, "out/transfer.txt"))).resolves.toBe( + false, + ); + await access(path.join(root, "authored.txt")); + }); + it("does not prune a stale path another configured target's manifest claims", async () => { const root = await makeProject(agentPluginsWithCodex); await build({ cwd: root }); From c355903ca52283f66c67e50229f05e0dda744052 Mon Sep 17 00:00:00 2001 From: Steve Calvert Date: Wed, 21 Oct 2026 15:41:26 -0700 Subject: [PATCH 10/24] fix(managed): protect discovered legacy sources unless pluginpack wrote them The delete guard stopped treating every plugins/ directory with a component directory as source, because that guess blocked a generated package from pruning itself under outDir ".". It went too far: an authored legacy plugin that no target reads through "from" lost all protection. A managed manifest naming one of its files then let clean delete authored source. The guard now protects those directories again, unless pluginpack provably generated them. Proof means every file in the directory is listed, with a recorded hash, in a configured target's managed manifest. A version 1 manifest records paths but not what wrote them, so it does not count. A directory holding any file no manifest lists stays protected too. --- README.md | 2 +- src/config.ts | 71 +++++++++++++++++++++++------- src/managed.ts | 51 ++++++++++++++++++++- tests/physical-path-safety.test.ts | 50 +++++++++++++++++++++ 4 files changed, 157 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 196e47a..040e707 100644 --- a/README.md +++ b/README.md @@ -190,7 +190,7 @@ copies a whole directory into the generated repository root. `pluginpack` writes a `.pluginpack/.json` managed-file manifest for each built target. That manifest lets builds and cleanup commands remove stale generated files without touching source files or unmanaged repo content. It also records a sha256 of each file as written. If you edit a generated file and a later `build`, `prune` or `clean` would overwrite or delete it, pluginpack prints a warning naming the file first. It still goes ahead, because the output belongs to pluginpack; move the edit into the source to keep it. Manifests from 0.11 and earlier have no hashes, so they produce no warnings until the next build rewrites them. -Before it deletes anything, pluginpack compares real paths, with symlinks resolved and, on a case-insensitive filesystem, case folded. It refuses an `outDir` that lands inside a source, overrides, partials, `repositoryFiles` or `rootFiles` path, even through a symlink. It also refuses two targets that write the same file through different spellings, such as `out` and `OUT` on macOS. `--dry-run` runs the same checks, so it fails wherever the real command would. +Before it deletes anything, pluginpack compares real paths, with symlinks resolved and, on a case-insensitive filesystem, case folded. It refuses an `outDir` that lands inside a source, overrides, partials, `repositoryFiles` or `rootFiles` path, even through a symlink. It also refuses two targets that write the same file through different spellings, such as `out` and `OUT` on macOS, or through a generated file that is a symlink to another target's file. Under the default `plugins/` root, a directory with a component directory counts as legacy source and is never deleted, unless pluginpack recorded writing every file in it. `--dry-run` runs the same checks, so it fails wherever the real command would. An `outDir` outside the project, such as a sibling output repository (`../cursor-plugins`), works. The guard there knows only that repository's `.git` directory and the files the manifest lists, so it cannot protect anything else in that repository from a manifest someone edited by hand. diff --git a/src/config.ts b/src/config.ts index cda22f0..07006f8 100644 --- a/src/config.ts +++ b/src/config.ts @@ -169,7 +169,10 @@ export type SourcePath = { * README's layouts also write generated output — only the legacy source * plugins a target actually reads through `from`, and directories carrying a * `plugin.pluginpack.json` (which generated output never contains), count as - * source. Guessing from component directories misreads a generated package + * source here. Other directories legacy discovery would read as source plugins + * are listed by `listUnreferencedSourcePluginDirs`; the delete guard protects + * them unless pluginpack recorded writing every file in them, since guessing + * from component directories alone misreads a generated package * (`plugins/acme/skills/`) as source and blocks its own prune. */ export async function listSourcePaths( @@ -189,20 +192,7 @@ export async function listSourcePaths( add(config.source.plugins, "source.plugins"); } else { const defaultRoot = path.resolve(rootDir, "plugins"); - const rootPluginId = config.source?.skills - ? (config.source.rootPlugin?.id ?? "core") - : undefined; - const legacyIds = new Set(); - for (const target of Object.values(config.targets)) { - for (const plugin of Object.values(target?.plugins ?? {})) { - for (const id of plugin.from ?? []) { - // The root skills plugin reads only source.skills, protected above. - if (id !== rootPluginId) { - legacyIds.add(id); - } - } - } - } + const legacyIds = referencedLegacyIds(project); for (const id of legacyIds) { paths.push({ path: path.join(defaultRoot, id), @@ -240,6 +230,57 @@ export async function listSourcePaths( return paths; } +/** + * Legacy source plugin ids a target reads through `from`, except the root + * skills plugin, which reads only `source.skills`. + */ +function referencedLegacyIds(project: ResolvedProjectConfig): Set { + const { config } = project; + const rootPluginId = config.source?.skills + ? (config.source.rootPlugin?.id ?? "core") + : undefined; + const ids = new Set(); + for (const target of Object.values(config.targets)) { + for (const plugin of Object.values(target?.plugins ?? {})) { + for (const id of plugin.from ?? []) { + if (id !== rootPluginId) { + ids.add(id); + } + } + } + } + return ids; +} + +/** + * Directories under the default `plugins/` root that legacy discovery reads + * as source plugins (a component directory, no `plugin.pluginpack.json`) but + * that `listSourcePaths` does not already protect, because no target reads + * them through `from`. Such a directory is either authored source nobody + * references yet or a generated package (`plugins/acme/skills/` under + * `outDir: "."`); the delete guard tells them apart by provenance. Empty when + * the config sets `source.plugins`, which is then protected whole. + */ +export async function listUnreferencedSourcePluginDirs( + project: ResolvedProjectConfig, +): Promise { + if (project.config.source?.plugins) { + return []; + } + const defaultRoot = path.resolve(project.rootDir, "plugins"); + const legacyIds = referencedLegacyIds(project); + const dirs: string[] = []; + for (const dir of await listSourcePluginDirs(defaultRoot)) { + if ( + !legacyIds.has(path.basename(dir)) && + !(await exists(path.join(dir, "plugin.pluginpack.json"))) + ) { + dirs.push(dir); + } + } + return dirs; +} + /** * Rejects a config whose target outDir physically resolves inside an authored * path: building there overwrites source, and prune/clean would then have to diff --git a/src/managed.ts b/src/managed.ts index 09cef43..0979cfa 100644 --- a/src/managed.ts +++ b/src/managed.ts @@ -9,9 +9,10 @@ import { physicalKey, resolveInside, toPosix, + walkFiles, writeArtifact, } from "./fs.js"; -import { listSourcePaths } from "./config.js"; +import { listSourcePaths, listUnreferencedSourcePluginDirs } from "./config.js"; import type { Artifact, CleanupEntry, @@ -465,6 +466,7 @@ export async function buildDeleteGuard( const protectedRoots = (await listSourcePaths(project)).map( (source) => source.path, ); + protectedRoots.push(...(await listDiscoveredSourceDirs(project))); // An outDir can sit outside the source repo (a sibling output repo), where // none of the source paths above apply. Its version-control data is the one // thing there no manifest should ever name. @@ -480,6 +482,51 @@ export async function buildDeleteGuard( }; } +/** + * The directories legacy discovery reads as source plugins that nothing else + * protects, minus the ones pluginpack provably generated. + * + * A directory counts as generated only when every file in it (managed + * manifests aside) is listed, with a recorded hash, in a configured target's + * managed manifest: pluginpack wrote each of those files. Anything else is + * treated as authored and protected, including a directory a version 1 + * manifest names (it records paths, not what wrote them) and one holding any + * file no manifest lists. Absence of a `from` reference is not evidence: + * authored legacy source does not need one to exist. + */ +async function listDiscoveredSourceDirs( + project: ResolvedProjectConfig, +): Promise { + const dirs = await listUnreferencedSourcePluginDirs(project); + if (dirs.length === 0) { + return []; + } + const written = new Set(); + for (const [name, target] of Object.entries(project.config.targets)) { + if (!target) { + continue; + } + const outDir = path.resolve(project.rootDir, target.outDir); + const manifest = await readManagedManifest( + outDir, + name as TargetName, + ).catch(() => null); + for (const file of Object.keys(manifest?.sha256 ?? {})) { + written.add(path.resolve(outDir, file)); + } + } + const protectedDirs: string[] = []; + for (const dir of dirs) { + const files = (await walkFiles(dir).catch(() => null))?.filter( + (file) => !isInside(path.join(dir, ".pluginpack"), file), + ); + if (!files?.length || !files.every((file) => written.has(file))) { + protectedDirs.push(dir); + } + } + return protectedDirs; +} + async function assertNoProtectedDeletions( outDir: string, target: TargetName, @@ -511,6 +558,8 @@ async function assertNoProtectedDeletions( `Refusing to ${command} ${blocked.length} path(s) that resolve inside your source tree or config:\n` + `${blocked.map(({ file, root }) => ` ${file} -> resolves inside ${root}`).join("\n")}\n` + `This usually means a target outDir overlaps source, overrides, repositoryFiles, or legacy source config. ` + + `A plugins/ directory with a component directory counts as legacy source unless pluginpack recorded writing every file in it ` + + `(a version 2 managed manifest lists each file with a hash). ` + `Fix the config, or re-run with --force to delete anyway.`, ); } diff --git a/tests/physical-path-safety.test.ts b/tests/physical-path-safety.test.ts index 967c671..cae9f1c 100644 --- a/tests/physical-path-safety.test.ts +++ b/tests/physical-path-safety.test.ts @@ -224,6 +224,56 @@ describe("the delete guard protects source, not generated packages under plugins }); }); +describe("the delete guard protects discovered legacy sources nothing references", () => { + const generatedElsewhere = ` antigravity: { + outDir: ".", + plugins: { acme: { source: "shared/acme", path: "generated" } } + }`; + const authored = { + plugins: { + authored: { + skills: { + alpha: { "SKILL.md": skill("alpha") }, + beta: { "SKILL.md": skill("beta") }, + }, + }, + }, + }; + + it("refuses to clean one that a version 1 manifest names", async () => { + const root = await makeProject(generatedElsewhere, authored); + await writeManifest(root, ".", "antigravity", [ + "plugins/authored/skills/alpha/SKILL.md", + ]); + + await expect(clean({ cwd: root })).rejects.toThrow( + /Refusing to clean 1 path\(s\)[\s\S]*plugins\/authored\/skills\/alpha\/SKILL\.md -> resolves inside .*plugins\/authored/, + ); + await access(path.join(root, "plugins/authored/skills/alpha/SKILL.md")); + }); + + it("refuses to clean one holding a file no manifest records writing", async () => { + const root = await makeProject(generatedElsewhere, authored); + const manifestPath = path.join(root, managedManifestPath("antigravity")); + await mkdir(path.dirname(manifestPath), { recursive: true }); + await writeFile( + manifestPath, + json({ + version: 2, + target: "antigravity", + files: ["plugins/authored/skills/alpha/SKILL.md"], + sha256: { "plugins/authored/skills/alpha/SKILL.md": "0".repeat(64) }, + }), + ); + + await expect(clean({ cwd: root })).rejects.toThrow( + /Refusing to clean 1 path\(s\)[\s\S]*resolves inside .*plugins\/authored/, + ); + await access(path.join(root, "plugins/authored/skills/alpha/SKILL.md")); + await access(path.join(root, "plugins/authored/skills/beta/SKILL.md")); + }); +}); + describe("source protection compares physical paths", () => { async function aliasedSource(): Promise { const root = await makeProject(` "agent-plugins": { From 1d70dbc190a6871b4a57f5343c764385a1472c0e Mon Sep 17 00:00:00 2001 From: Steve Calvert Date: Wed, 21 Oct 2026 15:46:38 -0700 Subject: [PATCH 11/24] fix(build): refuse to leave a dropped target's output behind build, prune and clean read only the managed manifests of targets the current config lists. A config that dropped a target, or moved an outDir from one target to another (codex to agent-plugins, say), without cleaning first left the old target's files and its .pluginpack manifest in place forever. Some of those files, like a codex hooks file inside an agent-plugins package, made the new output invalid. build and prune now look for manifests in every configured outDir that belong to no target building there, and refuse with the manifest path and the command to run. Pruning those files automatically would delete them on the authority of a manifest alone. No config would describe that output any more, so a target dropped by mistake, or a second project sharing the output repository, would lose its files silently. clean --target now finds the old target's manifest in any configured outDir, even when that target is no longer configured. A plain clean includes those manifests too, and both run them through the same guard and collision checks. --- README.md | 8 +- src/build.ts | 98 +++++++++++++++++++++++ src/cleanup.ts | 77 ++++++++++++------ src/cli.ts | 6 +- tests/physical-path-safety.test.ts | 123 +++++++++++++++++++++++++++-- 5 files changed, 276 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index 040e707..1d2c573 100644 --- a/README.md +++ b/README.md @@ -192,9 +192,11 @@ copies a whole directory into the generated repository root. Before it deletes anything, pluginpack compares real paths, with symlinks resolved and, on a case-insensitive filesystem, case folded. It refuses an `outDir` that lands inside a source, overrides, partials, `repositoryFiles` or `rootFiles` path, even through a symlink. It also refuses two targets that write the same file through different spellings, such as `out` and `OUT` on macOS, or through a generated file that is a symlink to another target's file. Under the default `plugins/` root, a directory with a component directory counts as legacy source and is never deleted, unless pluginpack recorded writing every file in it. `--dry-run` runs the same checks, so it fails wherever the real command would. +If an `outDir` holds a manifest for a target the config no longer builds there, `build` and `prune` refuse to run. That happens when a config drops a target, or moves an outDir to another target, such as `codex` to `agent-plugins`, without cleaning first. No configured target would prune those files, so they would stay forever. Run `pluginpack clean --target ` to delete them. It finds the old target's manifest in any outDir the config uses, even when that target is no longer configured. A plain `clean` deletes them too. To keep the files unmanaged instead, delete the manifest. + An `outDir` outside the project, such as a sibling output repository (`../cursor-plugins`), works. The guard there knows only that repository's `.git` directory and the files the manifest lists, so it cannot protect anything else in that repository from a manifest someone edited by hand. -**Upgrading from 0.11 (managed files):** the first build rewrites each manifest as version 2. pluginpack 0.11 cannot read a version 2 manifest, so upgrade every machine and CI job that builds the same output together. A manifest from a newer pluginpack fails with a message saying to upgrade. Some configs that 0.11 accepted now fail at load or build: `source.partials` outside the project, an `outDir` inside a source path, and a `rootFiles` entry that copies a file onto itself (`"README.md": "README.md"` with `outDir: "."`). Each error names the field to change. +**Upgrading from 0.11 (managed files):** the first build rewrites each manifest as version 2. pluginpack 0.11 cannot read a version 2 manifest, so upgrade every machine and CI job that builds the same output together. A manifest from a newer pluginpack fails with a message saying to upgrade. Some configs that 0.11 accepted now fail at load or build: `source.partials` outside the project, an `outDir` inside a source path, and a `rootFiles` entry that copies a file onto itself (`"README.md": "README.md"` with `outDir: "."`). Each error names the field to change. A build into an outDir that still holds a dropped target's manifest also fails until you run `pluginpack clean --target `. ## Components @@ -809,7 +811,7 @@ Exit codes: ### `clean` -Remove all managed files for configured target outputs. +Remove all managed files in configured target outputs, including those of a target the config no longer builds there. ```bash pluginpack clean [--target copilot|antigravity|cursor|claude|codex|agent-plugins] [--dry-run] @@ -817,7 +819,7 @@ pluginpack clean [--target copilot|antigravity|cursor|claude|codex|agent-plugins Options: -- `--target `: Clean only one configured target. +- `--target `: Clean only one target. It may be one the config no longer builds, if its manifest is in a configured outDir. - `--dry-run`: Print managed files without deleting them. - `--force`: Delete even paths that resolve inside the source tree or config. diff --git a/src/build.ts b/src/build.ts index 4e6a61e..f3a6f33 100644 --- a/src/build.ts +++ b/src/build.ts @@ -1,3 +1,4 @@ +import { promises as fs } from "node:fs"; import path from "node:path"; import { assertOutDirsOutsideSource, loadConfig } from "./config.js"; import { physicalKey, withPathCache, writeArtifact } from "./fs.js"; @@ -5,6 +6,7 @@ import { assertManagedManifestContained, buildDeleteGuard, executePrune, + managedManifestPath, managedPathKey, normalizeManagedPath, planPrune, @@ -23,6 +25,7 @@ import type { Artifact, BuildOptions, ResolvedProject, + ResolvedProjectConfig, TargetName, } from "./types.js"; @@ -99,6 +102,10 @@ async function planBuildChecked( })), ); } + await assertNoOrphanManifests( + project, + artifacts.map(({ target, outDir }) => ({ target, outDir })), + ); const incoming = await assertNoCrossTargetCollisions(artifacts); const claimedElsewhere = await assertNoCollisionsWithBuiltTargets( project, @@ -299,6 +306,97 @@ async function assertNoCollisionsWithBuiltTargets( return claimed; } +/** Where a target's managed manifest lives: `/.pluginpack/.json`. */ +export type ManifestLocation = { target: TargetName; outDir: string }; + +/** + * Managed manifests in the outDirs this config uses that no target builds + * into: a target the config dropped, or one whose outDir moved. Switching an + * outDir from codex to agent-plugins, say, leaves `.pluginpack/codex.json` + * behind. No configured target prunes the files it lists, so they would stay + * forever, and some (a codex hooks file inside an agent-plugins package) + * make the new output invalid. + * + * Scans the outDir of every configured target, plus `building` (the outDirs + * of this invocation, which a `--out-dir` override moves). A manifest is + * expected where its target is configured to write or is writing now. + */ +export async function findOrphanManifests( + project: ResolvedProjectConfig, + building: ManifestLocation[] = [], +): Promise { + const expected = new Map>(); + const scanned = new Map(); + const add = async ({ target, outDir }: ManifestLocation) => { + const key = await physicalKey(outDir); + expected.set(target, (expected.get(target) ?? new Set()).add(key)); + if (!scanned.has(key)) { + scanned.set(key, outDir); + } + }; + for (const [target, config] of Object.entries(project.config.targets)) { + if (config) { + await add({ + target: target as TargetName, + outDir: path.resolve(project.rootDir, config.outDir), + }); + } + } + for (const location of building) { + await add(location); + } + const known = new Set(targetNames); + const orphans: ManifestLocation[] = []; + for (const [key, outDir] of scanned) { + const names = await fs + .readdir(path.join(outDir, ".pluginpack")) + .catch(() => [] as string[]); + for (const name of names.sort()) { + const target = name.endsWith(".json") ? name.slice(0, -5) : ""; + if (!known.has(target)) { + continue; + } + if (!expected.get(target as TargetName)?.has(key)) { + orphans.push({ target: target as TargetName, outDir }); + } + } + } + return orphans; +} + +/** + * Refuses to build or prune while an outDir holds a manifest no target builds + * there (see `findOrphanManifests`). + * + * Pruning those files instead would delete on the authority of a manifest + * alone, with no config left describing that output: a target dropped from + * the config by mistake, or a second project that shares the output + * repository, would lose its files without a word. Refusing costs one + * command, and `clean --target ` (which finds these manifests) deletes + * the files through the same guard and checks as any clean. + */ +async function assertNoOrphanManifests( + project: ResolvedProjectConfig, + building: ManifestLocation[], +): Promise { + const orphans = await findOrphanManifests(project, building); + if (orphans.length === 0) { + return; + } + const lines = orphans.map( + ({ target, outDir }) => + ` ${path.join(outDir, managedManifestPath(target))} (${target})`, + ); + const commands = [...new Set(orphans.map(({ target }) => target))] + .map((target) => `\`pluginpack clean --target ${target}\``) + .join(", then "); + throw new Error( + `Found managed output from a target this config does not build there:\n${lines.join("\n")}\n` + + `No configured target prunes the files these manifests list, so they would stay in the output. ` + + `Run ${commands} to delete them first. To keep the files unmanaged instead, delete the manifest.`, + ); +} + /** One path, or both spellings when two different strings name the same file. */ export function describeAlias(first: string, second: string): string { return first === second ? first : `${first} (same file as ${second})`; diff --git a/src/cleanup.ts b/src/cleanup.ts index d29806c..0ee3948 100644 --- a/src/cleanup.ts +++ b/src/cleanup.ts @@ -1,5 +1,10 @@ import path from "node:path"; -import { describeAlias, planBuild } from "./build.js"; +import { + describeAlias, + findOrphanManifests, + planBuild, + type ManifestLocation, +} from "./build.js"; import { targetNames } from "./adapters.js"; import { loadProjectConfig } from "./config.js"; import { withPathCache } from "./fs.js"; @@ -68,17 +73,26 @@ export async function clean( const plans = await withPathCache(async () => { const project = await loadProjectConfig(options.cwd, options.configPath); const guard = await buildDeleteGuard(project, options.force); - const targets = options.target - ? [options.target] - : (Object.keys(project.config.targets) as TargetName[]); - await assertNoManifestCollisions(project, targets, options.force); + const configured = configuredLocations(project); + const orphans = await findOrphanManifests(project); + const selected = (location: ManifestLocation) => + !options.target || location.target === options.target; + // A target the config dropped, or one whose outDir moved, left its + // manifest in an outDir this config uses; cleaning it is how a user + // removes that output, so it counts as that target's to clean. + const cleaning = [...configured, ...orphans].filter(selected); + if (options.target && cleaning.length === 0) { + throw new Error( + `Target "${options.target}" is not configured, and no outDir this config uses holds a .pluginpack/${options.target}.json manifest.`, + ); + } + await assertNoManifestCollisions( + [...configured, ...orphans], + cleaning, + options.force, + ); const planned: CleanPlan[] = []; - for (const target of targets) { - const targetConfig = project.config.targets[target]; - if (!targetConfig) { - throw new Error(`Target "${target}" is not configured.`); - } - const outDir = path.resolve(project.rootDir, targetConfig.outDir); + for (const { target, outDir } of cleaning) { planned.push( await planClean(outDir, target, { dryRun: options.dryRun, guard }), ); @@ -93,8 +107,20 @@ export async function clean( return plans.map(cleanResult); } +/** Where each configured target's managed manifest lives, in registry order. */ +function configuredLocations( + project: ResolvedProjectConfig, +): ManifestLocation[] { + return targetNames.flatMap((target) => { + const config = project.config.targets[target]; + return config + ? [{ target, outDir: path.resolve(project.rootDir, config.outDir) }] + : []; + }); +} + /** - * Refuses to clean a path another configured target's manifest also claims. + * Refuses to clean a path another target's manifest also claims. * * `build()` and `prune()` share one plan that rejects overlapping output and * leaves a stale path alone when another target owns it. `clean()` @@ -112,21 +138,22 @@ export async function clean( * unable to tear down their own repo. */ async function assertNoManifestCollisions( - project: ResolvedProjectConfig, - cleaning: TargetName[], + manifests: ManifestLocation[], + cleaning: ManifestLocation[], force?: boolean, ): Promise { if (force) { return; } - const owners = new Map(); + const isCleaning = (location: ManifestLocation) => + cleaning.includes(location); + const owners = new Map< + string, + { location: ManifestLocation; absolute: string } + >(); const collisions: string[] = []; - for (const target of targetNames) { - const targetConfig = project.config.targets[target]; - if (!targetConfig) { - continue; - } - const outDir = path.resolve(project.rootDir, targetConfig.outDir); + for (const location of manifests) { + const { target, outDir } = location; const manifest = await readManagedManifest(outDir, target); if (!manifest) { continue; @@ -135,15 +162,15 @@ async function assertNoManifestCollisions( const absolute = path.resolve(outDir, normalizeManagedPath(file)); const key = await managedPathKey(outDir, target, file, { entry: true }); const owner = owners.get(key); - if (owner && owner.target !== target) { - if (cleaning.includes(owner.target) || cleaning.includes(target)) { + if (owner && owner.location !== location) { + if (isCleaning(owner.location) || isCleaning(location)) { collisions.push( - ` ${owner.target} and ${target}: ${describeAlias(owner.absolute, absolute)}`, + ` ${owner.location.target} and ${target}: ${describeAlias(owner.absolute, absolute)}`, ); } continue; } - owners.set(key, { target, absolute }); + owners.set(key, { location, absolute }); } } if (collisions.length > 0) { diff --git a/src/cli.ts b/src/cli.ts index b18c24d..0840f14 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -173,12 +173,14 @@ function createProgram(): Command { program .command("clean") - .description("Remove all managed files for configured target outputs.") + .description( + "Remove all managed files in configured target outputs, including those of a target the config no longer builds there.", + ) .usage(`[--target ${targetList}] [--dry-run]`) .addOption( new Option( "--target ", - "Clean only one configured target.", + "Clean only one target. It may be one the config no longer builds, if its manifest is in a configured outDir.", ).choices([...targetNames]), ) .option("--dry-run", "Print managed files without deleting them.") diff --git a/tests/physical-path-safety.test.ts b/tests/physical-path-safety.test.ts index cae9f1c..d93fd66 100644 --- a/tests/physical-path-safety.test.ts +++ b/tests/physical-path-safety.test.ts @@ -637,11 +637,11 @@ describe("a stale path another target now owns is left in place", () => { it("does not prune a stale path another configured target's manifest claims", async () => { const root = await makeProject(agentPluginsWithCodex); await build({ cwd: root }); - // The project swaps targets; agent-plugins' old manifest stays on disk. - await build({ - cwd: root, - configPath: await revision(root, "codex-only", codexTarget), - }); + // A codex manifest an older pluginpack could have left claiming the + // marketplace agent-plugins still lists. + await writeManifest(root, "out", "codex", [ + ".agents/plugins/marketplace.json", + ]); const configPath = await revision( root, "both", @@ -661,6 +661,113 @@ describe("a stale path another target now owns is left in place", () => { }); }); +describe("managed output from a target the config no longer builds there", () => { + const entry = `entry: { policy: { installation: "AVAILABLE", authentication: "ON_INSTALL" }, category: "Productivity" }`; + const codexIn = (outDir: string) => ` codex: { + outDir: "${outDir}", + plugins: { acme: { source: "shared/acme", ${entry} } } + }`; + const agentPlugins = ` "agent-plugins": { + outDir: "out", + plugins: { acme: { source: "shared/acme" } } + }`; + const hooks = { + "hooks.json": `${JSON.stringify({ + hooks: { + SessionStart: [{ hooks: [{ type: "command", command: "echo hi" }] }], + }, + })}\n`, + }; + + /** Builds codex into out, then switches the config to agent-plugins there. */ + async function switchedWithoutCleaning(): Promise<{ + root: string; + configPath: string; + }> { + const root = await makeProject(codexIn("out")); + await mkdir(path.join(root, "shared/acme/hooks")); + await writeFile( + path.join(root, "shared/acme/hooks/hooks.json"), + hooks["hooks.json"], + ); + await build({ cwd: root }); + await access(path.join(root, "out/plugins/acme/hooks/hooks.json")); + const configPath = path.join(root, "switched.config.ts"); + await writeFile(configPath, configText(agentPlugins)); + return { root, configPath }; + } + + it("refuses to build or prune, naming the manifest and the clean command", async () => { + const { root, configPath } = await switchedWithoutCleaning(); + const manifest = path.join(root, "out", managedManifestPath("codex")); + const message = `Found managed output from a target this config does not build there:\n ${manifest} (codex)\n`; + + await expect(build({ cwd: root, configPath })).rejects.toThrow(message); + await expect(build({ cwd: root, configPath })).rejects.toThrow( + "Run `pluginpack clean --target codex` to delete them first.", + ); + await expect(prune({ cwd: root, configPath })).rejects.toThrow(message); + await expect( + exists(path.join(root, "out", managedManifestPath("agent-plugins"))), + ).resolves.toBe(false); + }); + + it("clean --target removes it, after which the new target builds clean output", async () => { + const { root, configPath } = await switchedWithoutCleaning(); + + const [result] = await clean({ cwd: root, configPath, target: "codex" }); + expect(result.target).toBe("codex"); + await expect(exists(path.join(root, "out/plugins"))).resolves.toBe(false); + await expect(exists(path.join(root, "out/.pluginpack"))).resolves.toBe( + false, + ); + + const [artifact] = await build({ cwd: root, configPath }); + expect(artifact.managedPaths).not.toContain( + "plugins/acme/hooks/hooks.json", + ); + await expect( + exists(path.join(root, "out/plugins/acme/hooks/hooks.json")), + ).resolves.toBe(false); + await clean({ cwd: root, configPath }); + await expect(walkFiles(path.join(root, "out"))).resolves.toEqual([]); + await sourceSurvives(root); + }); + + it("a plain clean removes it along with the configured targets' output", async () => { + const { root, configPath } = await switchedWithoutCleaning(); + + const results = await clean({ cwd: root, configPath }); + + expect(results.map((result) => result.target)).toEqual([ + "agent-plugins", + "codex", + ]); + await expect(walkFiles(path.join(root, "out"))).resolves.toEqual([]); + await sourceSurvives(root); + }); + + it("clean --target finds the manifest a target left in its old outDir", async () => { + const root = await makeProject(`${codexIn("out")},\n${agentPlugins}`); + await build({ cwd: root, target: "codex" }); + const configPath = path.join(root, "moved.config.ts"); + await writeFile( + configPath, + configText(`${codexIn("out2")},\n${agentPlugins}`), + ); + + await expect(build({ cwd: root, configPath })).rejects.toThrow( + /Found managed output[\s\S]*out\/\.pluginpack\/codex\.json \(codex\)/, + ); + await clean({ cwd: root, configPath, target: "codex" }); + + await expect( + exists(path.join(root, "out", managedManifestPath("codex"))), + ).resolves.toBe(false); + await expect(build({ cwd: root, configPath })).resolves.toHaveLength(2); + }); +}); + describe("dry runs run the same destructive preflight as the real command", () => { async function protectedStalePath(): Promise { const root = await makeProject( @@ -1141,7 +1248,11 @@ describe("the config loader reads the config file fresh each time", () => { await writeFile( path.join(root, name), - configText(antigravity.replace("antigravity", "cursor")), + configText( + antigravity + .replace("antigravity", "cursor") + .replace('"out"', '"out-cursor"'), + ), ); const [second] = await build({ cwd: root }); From f2a2cd9edaf194465608a482e7b29f3156bf1e3c Mon Sep 17 00:00:00 2001 From: Steve Calvert Date: Wed, 21 Oct 2026 15:48:18 -0700 Subject: [PATCH 12/24] fix(managed): keep a path renamed only by case when pruning On a case-insensitive volume, changing a plugin path from first to FIRST writes the new files into the existing first/ directory. The old spellings then count as stale, and prune deleted them. Those are the files the build had just written, so the build ended with no output for that plugin. A stale path whose physical key matches one of the target's own current paths is now kept, as a path another target owns already was. --- src/managed.ts | 11 ++++++++--- tests/physical-path-safety.test.ts | 24 ++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/src/managed.ts b/src/managed.ts index 0979cfa..724d0f9 100644 --- a/src/managed.ts +++ b/src/managed.ts @@ -249,7 +249,9 @@ export async function recordHandedOverHashes( * another target owns has changed hands (for example a marketplace file moved * from one target to another sharing the outDir) and is left in place rather * than deleted from under its new owner. This target's next manifest no - * longer lists it, so the ownership transfer completes on the next build. + * longer lists it, so the ownership transfer completes on the next build. A + * stale path that is physically one of this target's own current paths (a + * case-only rename on a case-insensitive volume) is kept for the same reason. */ export async function planPrune( artifact: Artifact, @@ -270,8 +272,11 @@ export async function planPrune( const key = await managedPathKey(artifact.outDir, artifact.target, file, { entry: true, }); - const owner = options.owners.get(key); - if (owner && owner !== artifact.target) { + // Another owner means the path changed hands. This target as the owner + // means the stale spelling is one of its current files under another + // name (`first/` renamed `FIRST/` on a case-insensitive volume), and + // deleting it would delete the file just written. + if (options.owners.has(key)) { continue; } } diff --git a/tests/physical-path-safety.test.ts b/tests/physical-path-safety.test.ts index d93fd66..ef9cbd5 100644 --- a/tests/physical-path-safety.test.ts +++ b/tests/physical-path-safety.test.ts @@ -634,6 +634,30 @@ describe("a stale path another target now owns is left in place", () => { await access(path.join(root, "authored.txt")); }); + it.skipIf(!caseInsensitiveFs)( + "does not prune a path renamed only by case, which is the file just written", + async () => { + const root = await makeProject(` antigravity: { + outDir: "out", + plugins: { acme: { source: "shared/acme", path: "first" } } + }`); + await build({ cwd: root }); + const configPath = await revision( + root, + "renamed", + ` antigravity: { + outDir: "out", + plugins: { acme: { source: "shared/acme", path: "FIRST" } } + }`, + ); + + await build({ cwd: root, configPath }); + + await access(path.join(root, "out/FIRST/skills/alpha/SKILL.md")); + await access(path.join(root, "out/FIRST/plugin.json")); + }, + ); + it("does not prune a stale path another configured target's manifest claims", async () => { const root = await makeProject(agentPluginsWithCodex); await build({ cwd: root }); From b88e62a75aa70d9d48453b2921620b1e179b136f Mon Sep 17 00:00:00 2001 From: Steve Calvert Date: Wed, 21 Oct 2026 16:03:45 -0700 Subject: [PATCH 13/24] fix(managed): let a 0.11 codex package at outDir "." prune itself after upgrade A version 1 manifest records paths but no hashes, so the delete guard treated every plugins/ directory it named as authored legacy source. For codex with outDir ".", the generated package lives at plugins/, and the codex format moved its manifest from .codex-plugin/plugin.json to the package root after 0.11. The first build after upgrading therefore had to prune a stale path inside a protected directory, refused, and never rewrote the manifest as version 2. Build, prune and clean all stayed refused without --force. A file listed without a hash now also counts as written by pluginpack when it sits inside a plugin directory that the same target's config places its output in. That is config-derived, so clean still works when the source tree no longer loads. A directory a version 1 manifest names but no target writes to stays protected, as does any directory holding a file no manifest lists. --- src/managed.ts | 43 +++++++++++--- tests/physical-path-safety.test.ts | 94 ++++++++++++++++++++++++++++++ 2 files changed, 128 insertions(+), 9 deletions(-) diff --git a/src/managed.ts b/src/managed.ts index 724d0f9..483eaa6 100644 --- a/src/managed.ts +++ b/src/managed.ts @@ -13,6 +13,7 @@ import { writeArtifact, } from "./fs.js"; import { listSourcePaths, listUnreferencedSourcePluginDirs } from "./config.js"; +import { targets as targetRegistry } from "./targets/registry.js"; import type { Artifact, CleanupEntry, @@ -492,12 +493,17 @@ export async function buildDeleteGuard( * protects, minus the ones pluginpack provably generated. * * A directory counts as generated only when every file in it (managed - * manifests aside) is listed, with a recorded hash, in a configured target's - * managed manifest: pluginpack wrote each of those files. Anything else is - * treated as authored and protected, including a directory a version 1 - * manifest names (it records paths, not what wrote them) and one holding any - * file no manifest lists. Absence of a `from` reference is not evidence: - * authored legacy source does not need one to exist. + * manifests aside) is one pluginpack recorded writing. A file counts when a + * configured target's managed manifest lists it with a hash, or lists it + * without one (a version 1 manifest from 0.11 or earlier) and the file sits + * inside a plugin directory that same target's config places its output in, + * such as `plugins/acme` for `codex` with `outDir: "."`. The second case + * keeps a project upgrading from 0.11 able to prune a path the new build no + * longer writes; without it the first build refuses and the manifest never + * gains hashes. Anything else is treated as authored and protected: a + * directory a version 1 manifest names but no target's config writes to, and + * one holding any file no manifest lists. Absence of a `from` reference is not + * evidence: authored legacy source does not need one to exist. */ async function listDiscoveredSourceDirs( project: ResolvedProjectConfig, @@ -516,8 +522,26 @@ async function listDiscoveredSourceDirs( outDir, name as TargetName, ).catch(() => null); - for (const file of Object.keys(manifest?.sha256 ?? {})) { - written.add(path.resolve(outDir, file)); + if (!manifest) { + continue; + } + const definition = targetRegistry[name as TargetName]; + const layout = definition.forConfig?.(target) ?? definition; + const pluginDirs = Object.entries(target.plugins).map( + ([pluginName, pluginConfig]) => + path.resolve( + outDir, + layout.resolvePluginPath(pluginName, pluginConfig, target), + ), + ); + for (const file of manifest.files) { + const absolute = path.resolve(outDir, file); + if ( + manifest.sha256[file] !== undefined || + pluginDirs.some((dir) => isInside(dir, absolute)) + ) { + written.add(absolute); + } } } const protectedDirs: string[] = []; @@ -564,7 +588,8 @@ async function assertNoProtectedDeletions( `${blocked.map(({ file, root }) => ` ${file} -> resolves inside ${root}`).join("\n")}\n` + `This usually means a target outDir overlaps source, overrides, repositoryFiles, or legacy source config. ` + `A plugins/ directory with a component directory counts as legacy source unless pluginpack recorded writing every file in it ` + - `(a version 2 managed manifest lists each file with a hash). ` + + `(a managed manifest lists each file with a hash, or lists it inside a plugin directory that target's config writes). ` + + `A file you added there that no manifest lists also makes it count as source; move it out. ` + `Fix the config, or re-run with --force to delete anyway.`, ); } diff --git a/tests/physical-path-safety.test.ts b/tests/physical-path-safety.test.ts index ef9cbd5..ed982b3 100644 --- a/tests/physical-path-safety.test.ts +++ b/tests/physical-path-safety.test.ts @@ -274,6 +274,100 @@ describe("the delete guard protects discovered legacy sources nothing references }); }); +describe("upgrading a codex outDir '.' build from 0.11 (version 1 manifest)", () => { + const codexAtRoot = ` codex: { + outDir: ".", + plugins: { acme: { source: "shared/acme" } } + }`; + + /** + * The files and version 1 manifest pluginpack 0.11.1 writes for `codexAtRoot` + * when the source also had a skill `gamma` (since deleted). 0.11 wrote the + * Codex manifest under `.codex-plugin/`; the package format now writes it at + * the package root, so the first build after upgrading must prune it. + */ + async function seed011(root: string) { + const files: Record = { + ".agents/plugins/marketplace.json": json({ + name: "physical-demo", + interface: { displayName: "physical-demo" }, + plugins: [ + { + name: "acme", + source: "./plugins/acme", + description: "P", + version: "1.0.0", + }, + ], + }), + "plugins/acme/.codex-plugin/plugin.json": json({ + name: "acme", + version: "1.0.0", + description: "P", + author: { name: "P" }, + license: "MIT", + skills: "./skills/", + }), + "plugins/acme/skills/alpha/SKILL.md": skill("alpha"), + "plugins/acme/skills/beta/SKILL.md": skill("beta"), + "plugins/acme/skills/gamma/SKILL.md": skill("gamma"), + }; + for (const [file, body] of Object.entries(files)) { + await mkdir(path.dirname(path.join(root, file)), { recursive: true }); + await writeFile(path.join(root, file), body); + } + await writeManifest(root, ".", "codex", Object.keys(files)); + } + + it("builds, prunes the stale 0.11 paths, and upgrades the manifest", async () => { + const root = await makeProject(codexAtRoot); + await seed011(root); + + await build({ cwd: root }); + + const manifest = await readManagedManifest(root, "codex"); + expect(manifest?.version).toBe(2); + expect( + await exists(path.join(root, "plugins/acme/.codex-plugin/plugin.json")), + ).toBe(false); + expect( + await exists(path.join(root, "plugins/acme/skills/gamma/SKILL.md")), + ).toBe(false); + await access(path.join(root, "plugins/acme/plugin.json")); + await sourceSurvives(root); + }); + + it("prunes and cleans straight off the version 1 manifest", async () => { + const root = await makeProject(codexAtRoot); + await seed011(root); + + await prune({ cwd: root }); + expect( + await exists(path.join(root, "plugins/acme/skills/gamma/SKILL.md")), + ).toBe(false); + await clean({ cwd: root }); + + expect(await exists(path.join(root, "plugins/acme"))).toBe(false); + expect(await exists(path.join(root, managedManifestPath("codex")))).toBe( + false, + ); + await sourceSurvives(root); + }); + + it("still refuses when the package directory holds a file the manifest does not list", async () => { + const root = await makeProject(codexAtRoot, { + plugins: { acme: { agents: { "mine.md": "authored\n" } } }, + }); + await seed011(root); + + await expect(clean({ cwd: root })).rejects.toThrow( + /Refusing to clean \d+ path\(s\)[\s\S]*resolves inside .*plugins\/acme/, + ); + await access(path.join(root, "plugins/acme/agents/mine.md")); + await access(path.join(root, "plugins/acme/skills/alpha/SKILL.md")); + }); +}); + describe("source protection compares physical paths", () => { async function aliasedSource(): Promise { const root = await makeProject(` "agent-plugins": { From a3387c25c7aafefbdda7cf2fb57488da744e5c64 Mon Sep 17 00:00:00 2001 From: Steve Calvert Date: Wed, 21 Oct 2026 15:15:52 -0700 Subject: [PATCH 14/24] test(lifecycle): walk the current authoring shape through a full lifecycle Component tests check each piece of a build, but no test ran the shape the README recommends (shared sources, overrides, partials and repository files) through build, edit, delete, add, rebuild, prune and clean. These journeys do, for all six targets at once, in two layouts: one outDir per target and the README recommended shape. After every step the whole project tree must equal the authored sources, the hand-written output files and each target's managed files, so a step that touches a source, deletes a hand-written file or leaves a stale file fails. They also cover the agent-plugins package for both the codex and copilot clients, and moving a plugin between the codex target and agent-plugins with clients.codex in one outDir, in both directions. The documented move (clean the old target first) passes. Two known gaps are kept as expected failures: switching the config without cleaning leaves the old target's manifest and files behind, and build() reuses a cached config module after the config file changes in the same process. --- tests/lifecycle-journeys.test.ts | 686 +++++++++++++++++++++++++++++++ 1 file changed, 686 insertions(+) create mode 100644 tests/lifecycle-journeys.test.ts diff --git a/tests/lifecycle-journeys.test.ts b/tests/lifecycle-journeys.test.ts new file mode 100644 index 0000000..c8c9ed6 --- /dev/null +++ b/tests/lifecycle-journeys.test.ts @@ -0,0 +1,686 @@ +/** + * Journey tests for the current authoring shape: canonical `shared/` + * sources, `overrides//`, project partials and + * `repositories/` files, walked through the whole lifecycle a user + * runs — build, edit a skill, delete a skill, add a skill, rebuild, prune, + * clean — for all six targets at once. + * + * After every step the whole project tree must be exactly: the authored + * sources, byte for byte as the author left them; every hand-written file in + * the output; and, for each target, the managed files the current config + * emits plus its managed manifest. So a step that touches a source, deletes a + * hand-written file, leaves a stale file behind or misses a file fails here. + * Component tests elsewhere check each piece; these check that the pieces + * still compose across rebuilds. + */ +import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { Project, type ProjectArgs } from "fixturify-project"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { build } from "../src/build.js"; +import { clean, prune } from "../src/cleanup.js"; +import { validateOutput } from "../src/adapters.js"; +import { exists, toPosix, walkFiles } from "../src/fs.js"; +import { managedManifestPath, readManagedManifest } from "../src/managed.js"; +import type { Artifact, TargetName } from "../src/types.js"; + +type DirJSON = NonNullable; + +const allTargets: TargetName[] = [ + "cursor", + "claude", + "antigravity", + "copilot", + "codex", + "agent-plugins", +]; + +const indexPath = path.resolve("src/index.ts"); + +let project: Project | undefined; +let warnings: string[] = []; + +beforeEach(() => { + warnings = []; + vi.spyOn(console, "warn").mockImplementation((...args: unknown[]) => { + warnings.push(args.map(String).join(" ")); + }); +}); + +afterEach(async () => { + vi.restoreAllMocks(); + await project?.dispose(); + project = undefined; +}); + +function skill(name: string, body: string): string { + return `---\nname: ${name}\ndescription: The ${name} skill.\n---\n\n${body}\n`; +} + +/** Where one target writes, and which optional pieces of the shape it uses. */ +type Placement = { + outDir: string; + /** Extra target-level config, such as `pluginRoot`. */ + target?: string; + /** Extra plugin-level config, such as `path`. */ + plugin?: string; + repositoryFiles: boolean; +}; + +const codexEntry = `entry: { policy: { installation: "AVAILABLE", authentication: "ON_INSTALL" }, category: "Productivity" },`; + +function targetBlock(target: TargetName, placement: Placement): string { + const pluginEntry = + target === "agent-plugins" + ? `clients: { codex: { ${codexEntry} } },` + : target === "codex" + ? codexEntry + : ""; + return `"${target}": { + outDir: "${placement.outDir}", + ${placement.repositoryFiles ? `repositoryFiles: "repositories/${target}",` : ""} + ${target === "agent-plugins" ? "clients: { codex: {}, copilot: {} }," : ""} + ${placement.target ?? ""} + plugins: { + acme: { + source: "shared/acme", + overrides: "overrides/${target}/acme", + ${placement.plugin ?? ""} + ${pluginEntry} + }, + }, + },`; +} + +function configSource(targets: string): string { + return `import { defineConfig } from "${indexPath}"; + +export default defineConfig({ + name: "acme-plugins", + version: "1.0.0", + source: { partials: "partials" }, + metadata: { + description: "Acme agent plugins.", + author: { name: "Acme" }, + owner: { name: "Acme" }, + license: "MIT", + repository: "https://github.com/acme/acme-plugins", + }, + targets: { + ${targets} + }, +}); +`; +} + +/** Files every journey project authors, apart from the config. */ +function authoredFiles(): DirJSON { + const overrides: DirJSON = {}; + const repositories: DirJSON = {}; + for (const target of allTargets) { + overrides[target] = { + acme: { + assets: { + // Replaces the shared icon, and adds a target-only file. + "icon.svg": `${target}\n`, + [`${target}-note.txt`]: `Only in ${target}.\n`, + }, + }, + }; + repositories[target] = { + "README.md": `# Acme for ${target}\n\n{{> auth}}\n`, + }; + } + return { + partials: { "auth.md": "Try OAuth first, then fall back to a token.\n" }, + shared: { + acme: { + skills: { + alpha: { "SKILL.md": skill("alpha", "{{> auth}}") }, + beta: { "SKILL.md": skill("beta", "Beta body.") }, + }, + agents: { + "helper.agent.md": + "---\nname: helper\ndescription: Find documents.\n---\n\nSearch and cite.\n", + }, + assets: { "icon.svg": "shared\n" }, + mcp: { + "config.json": JSON.stringify( + { + mcpServers: { + acme: { + type: "stdio", + command: "node", + args: ["./start.mjs"], + }, + }, + }, + null, + 2, + ), + "pluginpack.json": JSON.stringify( + { files: { "start.mjs": "start.mjs" } }, + null, + 2, + ), + "start.mjs": "console.log('acme');\n", + // MCP source that must not ship. + src: { "server.ts": "export {};\n" }, + }, + }, + }, + overrides, + repositories, + }; +} + +async function makeProject(config: string, files: DirJSON): Promise { + project = new Project("pluginpack-journey", "1.0.0", { + files: { "pluginpack.config.ts": config, ...files }, + }); + await project.write(); + return project.baseDir; +} + +/** + * Every file under `dir` (relative, posix) with its contents, sorted. + * Skips the `package.json` fixturify writes at the project root. + */ +async function readTree(dir: string): Promise> { + const tree = new Map(); + if (!(await exists(dir))) { + return tree; + } + for (const file of await walkFiles(dir)) { + const relative = toPosix(path.relative(dir, file)); + if (relative !== "package.json") { + tree.set(relative, await readFile(file, "utf8")); + } + } + return new Map([...tree].sort(([a], [b]) => (a < b ? -1 : 1))); +} + +async function writeFiles( + root: string, + files: Record, +): Promise { + for (const [relative, contents] of Object.entries(files)) { + await mkdir(path.dirname(path.join(root, relative)), { recursive: true }); + await writeFile(path.join(root, relative), contents); + } +} + +/** Asserts each hand-written file is still on disk with the bytes it was written with. */ +async function expectSurvives( + root: string, + files: Record, +): Promise { + for (const [relative, contents] of Object.entries(files)) { + expect( + await readFile(path.join(root, relative), "utf8"), + `${relative} should survive`, + ).toBe(contents); + } +} + +/** Root-relative path of a file inside a target's output. */ +function inOutput(root: string, artifact: Artifact, relative: string): string { + return toPosix(path.relative(root, path.join(artifact.outDir, relative))); +} + +/** + * Asserts the project tree is exactly `sources` plus `unmanaged` plus each + * artifact's managed files (with the bytes the artifact emitted) and managed + * manifest, and that each manifest lists exactly the artifact's paths. + */ +async function expectProjectTree( + root: string, + step: string, + sources: Map, + unmanaged: Record, + artifacts: Artifact[], +): Promise { + const expected = new Map([ + ...sources, + ...Object.entries(unmanaged), + ]); + for (const artifact of artifacts) { + for (const managedPath of artifact.managedPaths) { + const value = artifact.files.get(managedPath); + expected.set( + inOutput(root, artifact, managedPath), + typeof value === "string" ? value : value?.toString("utf8"), + ); + } + expected.set( + inOutput(root, artifact, managedManifestPath(artifact.target)), + undefined, + ); + const manifest = await readManagedManifest( + artifact.outDir, + artifact.target, + ); + expect(manifest?.files, `${step}: ${artifact.target} manifest`).toEqual( + artifact.managedPaths, + ); + } + const onDisk = await readTree(root); + expect([...onDisk.keys()], `${step}: project tree`).toEqual( + [...expected.keys()].sort(), + ); + for (const [relative, contents] of expected) { + if (contents !== undefined) { + expect(onDisk.get(relative), `${step}: ${relative}`).toBe(contents); + } + } +} + +async function expectValid(target: TargetName, outDir: string): Promise { + const result = await validateOutput(target, outDir); + expect( + result.issues.filter((issue) => issue.level === "error"), + `${target} validate errors`, + ).toEqual([]); + expect(result.ok).toBe(true); +} + +/** The managed path ending with `suffix`; throws if the artifact has none. */ +function findOutput(artifact: Artifact, suffix: string): string { + const found = artifact.managedPaths.find((managedPath) => + managedPath.endsWith(suffix), + ); + if (!found) { + throw new Error( + `${artifact.target} emitted no path ending with ${suffix}: ${artifact.managedPaths.join(", ")}`, + ); + } + return found; +} + +/** + * Runs the steps of an `it.fails` test that must succeed before the bug can + * show. `it.fails` passes on any failure, so a setup step that throws would + * hide a broken test; this returns undefined instead, the test then returns + * normally, and `it.fails` reports it. + */ +async function setupOnly(steps: () => Promise): Promise { + try { + return await steps(); + } catch (error) { + console.error("it.fails setup failed, so the test proves nothing:", error); + return undefined; + } +} + +const layouts: Record> = { + "one outDir per target": Object.fromEntries( + allTargets.map((target) => [ + target, + { outDir: `out/${target}`, repositoryFiles: true }, + ]), + ) as Record, + // README "Recommended Shape": cursor and claude share the repository root, + // so only cursor publishes repositories/ (both would write README.md). + "the README's recommended shape": { + cursor: { + outDir: ".", + plugin: `path: "plugins/cursor/acme",`, + repositoryFiles: true, + }, + claude: { + outDir: ".", + target: `pluginRoot: "plugins/claude",`, + repositoryFiles: false, + }, + antigravity: { outDir: "plugins/antigravity", repositoryFiles: true }, + copilot: { outDir: "plugins/copilot", repositoryFiles: true }, + codex: { outDir: "plugins/codex", repositoryFiles: true }, + "agent-plugins": { outDir: "plugins/agent-plugins", repositoryFiles: true }, + }, +}; + +describe.each(Object.entries(layouts))( + "current authoring shape across all six targets, %s", + (_name, layout) => { + it("survives build, edit, delete, add, rebuild, prune and clean", async () => { + const root = await makeProject( + configSource( + allTargets + .map((target) => targetBlock(target, layout[target])) + .join("\n"), + ), + authoredFiles(), + ); + // Everything on disk before the first build is authored. + const sources = await readTree(root); + const editSource = async ( + relative: string, + contents: string | undefined, + ): Promise => { + if (contents === undefined) { + await rm(path.join(root, relative)); + sources.delete(relative); + } else { + await writeFiles(root, { [relative]: contents }); + sources.set(relative, contents); + } + }; + + // --- build ------------------------------------------------------- + let artifacts = await build({ cwd: root }); + expect(artifacts.map((artifact) => artifact.target).sort()).toEqual( + [...allTargets].sort(), + ); + + // Hand-written files: one in each output root, one beside a skill + // that stays, and one inside the directory of the skill that will be + // deleted. + const unmanaged: Record = {}; + for (const artifact of artifacts) { + const alpha = findOutput(artifact, "skills/alpha/SKILL.md"); + const beta = findOutput(artifact, "skills/beta/SKILL.md"); + const files = { + [`NOTES-${artifact.target}.md`]: `Notes for ${artifact.target}.\n`, + [path.posix.join(path.posix.dirname(alpha), "LOCAL.md")]: + "Local alpha notes.\n", + [path.posix.join(path.posix.dirname(beta), "LOCAL.md")]: + "Local beta notes.\n", + }; + await writeFiles(artifact.outDir, files); + for (const [relative, contents] of Object.entries(files)) { + unmanaged[inOutput(root, artifact, relative)] = contents; + } + } + + const checkStep = async (step: string): Promise => { + await expectProjectTree(root, step, sources, unmanaged, artifacts); + for (const artifact of artifacts) { + await expectValid(artifact.target, artifact.outDir); + } + }; + await checkStep("build"); + + for (const artifact of artifacts) { + const alpha = String( + artifact.files.get(findOutput(artifact, "skills/alpha/SKILL.md")), + ); + // The partial resolved. + expect(alpha).toContain("Try OAuth first, then fall back to a token."); + expect(alpha).not.toContain("{{>"); + // The repository file was published, with its partial resolved. + if (layout[artifact.target].repositoryFiles) { + expect(String(artifact.files.get("README.md"))).toBe( + `# Acme for ${artifact.target}\n\nTry OAuth first, then fall back to a token.\n`, + ); + } + // The override replaced the shared icon and added a target-only file. + expect( + String(artifact.files.get(findOutput(artifact, "assets/icon.svg"))), + ).toBe(`${artifact.target}\n`); + findOutput(artifact, `assets/${artifact.target}-note.txt`); + // MCP shipped its declared file, not its source. + findOutput(artifact, "start.mjs"); + expect( + artifact.managedPaths.filter((managedPath) => + managedPath.includes("server.ts"), + ), + ).toEqual([]); + } + // One package serves both clients: a marketplace each, one package. + const multi = artifacts.find( + (artifact) => artifact.target === "agent-plugins", + )!; + expect(multi.managedPaths).toContain(".agents/plugins/marketplace.json"); + expect(multi.managedPaths).toContain(".github/plugin/marketplace.json"); + findOutput(multi, "com.github.copilot/agents/helper.agent.md"); + expect( + multi.managedPaths.filter((managedPath) => + managedPath.endsWith("plugin.json"), + ), + ).toHaveLength(1); + + // --- edit a skill, rebuild --------------------------------------- + await editSource( + "shared/acme/skills/alpha/SKILL.md", + skill("alpha", "Edited alpha.\n\n{{> auth}}"), + ); + artifacts = await build({ cwd: root }); + await checkStep("edit"); + for (const artifact of artifacts) { + expect( + String( + artifact.files.get(findOutput(artifact, "skills/alpha/SKILL.md")), + ), + ).toContain("Edited alpha."); + } + + // --- delete a skill, rebuild ------------------------------------- + await editSource("shared/acme/skills/beta/SKILL.md", undefined); + artifacts = await build({ cwd: root }); + await checkStep("delete"); + for (const artifact of artifacts) { + expect( + artifact.managedPaths.filter((managedPath) => + managedPath.includes("skills/beta/"), + ), + ).toEqual([]); + } + + // --- add a skill, rebuild ---------------------------------------- + await editSource( + "shared/acme/skills/gamma/SKILL.md", + skill("gamma", "Gamma body."), + ); + artifacts = await build({ cwd: root }); + await checkStep("add"); + const gammaPaths = new Map( + artifacts.map((artifact) => [ + artifact.target, + findOutput(artifact, "skills/gamma/SKILL.md"), + ]), + ); + + // --- delete a skill, prune without rebuilding -------------------- + await editSource("shared/acme/skills/gamma/SKILL.md", undefined); + const before = await readTree(root); + const dryRun = await prune({ cwd: root, dryRun: true }); + expect(await readTree(root)).toEqual(before); + for (const result of dryRun) { + expect( + result.entries.map((entry) => entry.path), + `${result.target} prune --dry-run`, + ).toEqual([gammaPaths.get(result.target)]); + } + const pruned = await prune({ cwd: root }); + expect( + Object.fromEntries( + pruned.map((result) => [ + result.target, + result.entries.map((entry) => entry.path), + ]), + ), + ).toEqual( + Object.fromEntries( + [...gammaPaths].map(([target, gamma]) => [target, [gamma]]), + ), + ); + for (const artifact of artifacts) { + expect( + await exists( + path.join(artifact.outDir, gammaPaths.get(artifact.target)!), + ), + ).toBe(false); + } + // Rebuild: nothing left to remove, and a second prune finds nothing. + artifacts = await build({ cwd: root }); + await checkStep("prune"); + const again = await prune({ cwd: root }); + expect(again.flatMap((result) => result.entries)).toEqual([]); + + // --- clean ------------------------------------------------------- + const cleaned = await clean({ cwd: root }); + expect(cleaned.map((result) => result.target).sort()).toEqual( + [...allTargets].sort(), + ); + await expectProjectTree(root, "clean", sources, unmanaged, []); + await expectSurvives(root, unmanaged); + + // Nothing in this journey edited generated output, so nothing should + // have warned about overwriting or deleting an edited file. + expect( + warnings.filter((warning) => /edited|changed since/i.test(warning)), + ).toEqual([]); + }); + }, +); + +/** + * Moving a plugin between the `codex` target and an `agent-plugins` package + * that lists `clients.codex`, in one output directory. Both targets write + * `.agents/plugins/marketplace.json` and `plugins//plugin.json`, so + * they cannot be configured over one outDir at the same time; the move is a + * config change between builds. + */ +describe("codex <-> agent-plugins clients.codex handoff in one outDir", () => { + const configs = { + codex: `codex: { + outDir: "out", + plugins: { acme: { source: "shared/acme", ${codexEntry} } }, + },`, + "agent-plugins": `"agent-plugins": { + outDir: "out", + clients: { codex: {}, copilot: {} }, + plugins: { acme: { source: "shared/acme", clients: { codex: { ${codexEntry} } } } }, + },`, + } as const; + type Side = keyof typeof configs; + const handoffs: [Side, Side][] = [ + ["codex", "agent-plugins"], + ["agent-plugins", "codex"], + ]; + const notes = { "out/NOTES.md": "Hand-written.\n" }; + + const handoffFiles = (): DirJSON => ({ + partials: { "auth.md": "Unused here.\n" }, + shared: { + acme: { + skills: { alpha: { "SKILL.md": skill("alpha", "Alpha body.") } }, + // Codex ships hooks; an agent-plugins package has nowhere to put them. + hooks: { + "hooks.json": `${JSON.stringify({ + hooks: { + SessionStart: [ + { hooks: [{ type: "command", command: "echo hi" }] }, + ], + }, + })}\n`, + }, + }, + }, + }); + + /** + * Writes the config for one side under its own file name. jiti caches a + * config module by path for the life of the process (see the `it.fails` + * at the end of this file), so rewriting `pluginpack.config.ts` in place + * would keep building the old config in-process; a CLI user gets a fresh + * process per command. + */ + async function useConfig(root: string, side: Side): Promise { + const configPath = `pluginpack.${side}.config.ts`; + await writeFiles(root, { [configPath]: configSource(configs[side]) }); + return configPath; + } + + it.each(handoffs)( + "%s -> %s: clean the old target first, then build the new one", + async (from, to) => { + const root = await makeProject(configSource(""), handoffFiles()); + const out = path.join(root, "out"); + const fromConfig = await useConfig(root, from); + const toConfig = await useConfig(root, to); + const sources = await readTree(root); + const [first] = await build({ cwd: root, configPath: fromConfig }); + await writeFiles(root, notes); + await expectProjectTree(root, "first build", sources, notes, [first]); + await expectValid(from, out); + + // The documented move: clean the old target while it is still + // configured, then build the new one. + await clean({ cwd: root, configPath: fromConfig, target: from }); + await expectProjectTree(root, "clean old", sources, notes, []); + + const [second] = await build({ cwd: root, configPath: toConfig }); + expect(second.target).toBe(to); + await expectProjectTree(root, "build new", sources, notes, [second]); + await expectValid(to, out); + expect( + second.managedPaths.includes("plugins/acme/hooks/hooks.json"), + ).toBe(to === "codex"); + + await clean({ cwd: root, configPath: toConfig }); + await expectProjectTree(root, "clean new", sources, notes, []); + }, + ); + + // BUG: build, prune and clean read only the managed manifests of targets + // the current config lists. After a config switches an outDir from one of + // these targets to the other without cleaning first, the old target's + // manifest and its files that the new target does not overwrite stay + // forever: codex -> agent-plugins leaves `plugins/acme/hooks/hooks.json` + // inside the package (so `validate --target agent-plugins` fails) and + // `.pluginpack/codex.json`; agent-plugins -> codex leaves the Copilot + // client's `.github/plugin/marketplace.json` and + // `.pluginpack/agent-plugins.json`, and both outlive `clean`. The README on + // fix/target-conformance documents cleaning first as the workaround. + it.fails.each(handoffs)( + "%s -> %s: switching the config without cleaning leaves no stale output", + async (from, to) => { + const ready = await setupOnly(async () => { + const root = await makeProject(configSource(""), handoffFiles()); + const toConfig = await useConfig(root, to); + const sources = await readTree(root); + await build({ cwd: root, configPath: await useConfig(root, from) }); + sources.set( + `pluginpack.${from}.config.ts`, + configSource(configs[from]), + ); + await writeFiles(root, notes); + const [second] = await build({ cwd: root, configPath: toConfig }); + return { root, toConfig, sources, second }; + }); + if (!ready) return; + const { root, toConfig, sources, second } = ready; + await expectProjectTree(root, "switch", sources, notes, [second]); + await expectValid(to, path.join(root, "out")); + await clean({ cwd: root, configPath: toConfig }); + await expectProjectTree(root, "clean", sources, notes, []); + }, + ); +}); + +// BUG: loadProjectConfig imports the config through jiti with its default +// module cache, so a second build() in the same process after the config file +// changes on disk still builds the first config. The CLI starts a new process +// per command and is unaffected; a long-lived script calling the programmatic +// API (a watcher, a test harness) silently builds stale config. +it.fails( + "build() reads pluginpack.config.ts again after it changes", + async () => { + const second = await setupOnly(async () => { + const placement = { outDir: "out", repositoryFiles: false }; + const root = await makeProject( + configSource(targetBlock("claude", placement)), + authoredFiles(), + ); + await build({ cwd: root }); + await writeFiles(root, { + "pluginpack.config.ts": configSource(targetBlock("cursor", placement)), + }); + const [artifact] = await build({ cwd: root }); + return artifact; + }); + if (!second) return; + expect(second.target).toBe("cursor"); + }, +); From ff65a70dd27467a420e55f68874866b00a5c6453 Mon Sep 17 00:00:00 2001 From: Steve Calvert Date: Wed, 21 Oct 2026 15:15:52 -0700 Subject: [PATCH 15/24] test(golden): pin every target's emitted output byte for byte Emitted output is a compatibility surface, but nothing stopped a renamed path, a reordered manifest field or a changed byte from shipping unannounced. The golden test builds two fixed configs (all six targets, plus legacy codex and standalone agent-plugins) and compares every emitted file, including the serialized .pluginpack/.json, against tests/golden. An intended change is regenerated with npm run test:golden:update and reviewed in the diff; the update refuses to run in CI. Prettier skips tests/golden so the files stay exactly as pluginpack writes them. --- .prettierignore | 2 + package.json | 1 + tests/golden.test.ts | 352 ++++++++++++++++++ .../.agents/plugins/marketplace.json | 19 + .../.github/plugin/marketplace.json | 18 + .../.pluginpack/agent-plugins.json | 36 ++ .../default/agent-plugins/CONTRIBUTING.md | 1 + tests/golden/default/agent-plugins/README.md | 3 + .../agent-plugins/plugins/acme/LICENSE | 1 + .../agent-plugins/plugins/acme/README.md | 3 + .../plugins/acme/assets/icon.png | Bin 0 -> 29 bytes .../agent-plugins/plugins/acme/bin/server | 2 + .../com.github.copilot/agents/helper.agent.md | 6 + .../agent-plugins/plugins/acme/mcp.json | 19 + .../agent-plugins/plugins/acme/plugin.json | 24 ++ .../agent-plugins/plugins/acme/scripts/run.sh | 2 + .../acme/skills/release-notes/SKILL.md | 10 + .../release-notes/references/template.txt | 3 + .../antigravity/.pluginpack/antigravity.json | 36 ++ .../default/antigravity/CONTRIBUTING.md | 1 + tests/golden/default/antigravity/README.md | 3 + tests/golden/default/antigravity/acme/LICENSE | 1 + .../golden/default/antigravity/acme/README.md | 3 + .../antigravity/acme/agents/helper.agent.md | 6 + .../default/antigravity/acme/assets/icon.png | Bin 0 -> 29 bytes .../default/antigravity/acme/bin/server | 2 + .../default/antigravity/acme/hooks.json | 14 + .../default/antigravity/acme/mcp_config.json | 18 + .../default/antigravity/acme/plugin.json | 4 + .../default/antigravity/acme/rules/style.mdc | 6 + .../default/antigravity/acme/scripts/run.sh | 2 + .../acme/skills/release-notes/SKILL.md | 10 + .../release-notes/references/template.txt | 3 + .../claude/.claude-plugin/marketplace.json | 16 + .../default/claude/.pluginpack/claude.json | 38 ++ tests/golden/default/claude/CONTRIBUTING.md | 1 + tests/golden/default/claude/README.md | 3 + .../plugins/acme/.claude-plugin/plugin.json | 16 + .../default/claude/plugins/acme/.mcp.json | 18 + .../default/claude/plugins/acme/LICENSE | 1 + .../default/claude/plugins/acme/README.md | 3 + .../plugins/acme/agents/helper.agent.md | 6 + .../claude/plugins/acme/assets/icon.png | Bin 0 -> 29 bytes .../default/claude/plugins/acme/bin/server | 2 + .../claude/plugins/acme/hooks/hooks.json | 24 ++ .../acme/scripts/pluginpack-update-check.sh | 49 +++ .../claude/plugins/acme/scripts/run.sh | 2 + .../acme/skills/release-notes/SKILL.md | 10 + .../release-notes/references/template.txt | 3 + .../codex/.agents/plugins/marketplace.json | 19 + .../default/codex/.pluginpack/codex.json | 34 ++ tests/golden/default/codex/CONTRIBUTING.md | 1 + tests/golden/default/codex/README.md | 3 + .../golden/default/codex/plugins/acme/LICENSE | 1 + .../default/codex/plugins/acme/README.md | 3 + .../codex/plugins/acme/assets/icon.png | Bin 0 -> 29 bytes .../default/codex/plugins/acme/bin/server | 2 + .../codex/plugins/acme/hooks/hooks.json | 14 + .../default/codex/plugins/acme/mcp.json | 19 + .../default/codex/plugins/acme/plugin.json | 25 ++ .../default/codex/plugins/acme/scripts/run.sh | 2 + .../acme/skills/release-notes/SKILL.md | 10 + .../release-notes/references/template.txt | 3 + .../copilot/.claude-plugin/marketplace.json | 26 ++ .../copilot/.github/plugin/marketplace.json | 26 ++ .../default/copilot/.pluginpack/copilot.json | 40 ++ tests/golden/default/copilot/CONTRIBUTING.md | 1 + tests/golden/default/copilot/README.md | 3 + .../plugins/acme/.github/plugin/plugin.json | 21 ++ .../default/copilot/plugins/acme/.mcp.json | 18 + .../default/copilot/plugins/acme/LICENSE | 1 + .../default/copilot/plugins/acme/README.md | 3 + .../plugins/acme/agents/helper.agent.md | 6 + .../copilot/plugins/acme/assets/icon.png | Bin 0 -> 29 bytes .../default/copilot/plugins/acme/bin/server | 2 + .../copilot/plugins/acme/hooks/hooks.json | 14 + .../default/copilot/plugins/acme/plugin.json | 21 ++ .../copilot/plugins/acme/scripts/run.sh | 2 + .../acme/skills/release-notes/SKILL.md | 10 + .../release-notes/references/template.txt | 3 + .../cursor/.cursor-plugin/marketplace.json | 21 ++ .../default/cursor/.pluginpack/cursor.json | 42 +++ tests/golden/default/cursor/CONTRIBUTING.md | 1 + tests/golden/default/cursor/README.md | 3 + .../plugins/acme/.cursor-plugin/plugin.json | 23 ++ .../default/cursor/plugins/acme/.mcp.json | 16 + .../default/cursor/plugins/acme/LICENSE | 1 + .../default/cursor/plugins/acme/README.md | 3 + .../plugins/acme/agents/helper.agent.md | 6 + .../cursor/plugins/acme/assets/icon.png | Bin 0 -> 29 bytes .../default/cursor/plugins/acme/bin/server | 2 + .../cursor/plugins/acme/hooks/hooks.json | 21 ++ .../cursor/plugins/acme/rules/cursor-only.mdc | 6 + .../cursor/plugins/acme/rules/style.mdc | 6 + .../acme/scripts/pluginpack-update-check.sh | 49 +++ .../cursor/plugins/acme/scripts/run.sh | 2 + .../acme/skills/release-notes/SKILL.md | 10 + .../release-notes/references/template.txt | 3 + .../.pluginpack/agent-plugins.json | 26 ++ .../agent-plugins/plugins/acme/LICENSE | 1 + .../agent-plugins/plugins/acme/README.md | 3 + .../plugins/acme/assets/icon.png | Bin 0 -> 29 bytes .../agent-plugins/plugins/acme/bin/server | 2 + .../agent-plugins/plugins/acme/mcp.json | 19 + .../agent-plugins/plugins/acme/plugin.json | 17 + .../agent-plugins/plugins/acme/scripts/run.sh | 2 + .../acme/skills/release-notes/SKILL.md | 10 + .../release-notes/references/template.txt | 3 + .../codex/.agents/plugins/marketplace.json | 19 + .../variants/codex/.pluginpack/codex.json | 30 ++ .../plugins/acme/.codex-plugin/plugin.json | 19 + .../variants/codex/plugins/acme/.mcp.json | 18 + .../variants/codex/plugins/acme/LICENSE | 1 + .../variants/codex/plugins/acme/README.md | 3 + .../codex/plugins/acme/assets/icon.png | Bin 0 -> 29 bytes .../variants/codex/plugins/acme/bin/server | 2 + .../codex/plugins/acme/hooks/hooks.json | 14 + .../codex/plugins/acme/scripts/run.sh | 2 + .../acme/skills/release-notes/SKILL.md | 10 + .../release-notes/references/template.txt | 3 + 120 files changed, 1555 insertions(+) create mode 100644 tests/golden.test.ts create mode 100644 tests/golden/default/agent-plugins/.agents/plugins/marketplace.json create mode 100644 tests/golden/default/agent-plugins/.github/plugin/marketplace.json create mode 100644 tests/golden/default/agent-plugins/.pluginpack/agent-plugins.json create mode 100644 tests/golden/default/agent-plugins/CONTRIBUTING.md create mode 100644 tests/golden/default/agent-plugins/README.md create mode 100644 tests/golden/default/agent-plugins/plugins/acme/LICENSE create mode 100644 tests/golden/default/agent-plugins/plugins/acme/README.md create mode 100644 tests/golden/default/agent-plugins/plugins/acme/assets/icon.png create mode 100644 tests/golden/default/agent-plugins/plugins/acme/bin/server create mode 100644 tests/golden/default/agent-plugins/plugins/acme/com.github.copilot/agents/helper.agent.md create mode 100644 tests/golden/default/agent-plugins/plugins/acme/mcp.json create mode 100644 tests/golden/default/agent-plugins/plugins/acme/plugin.json create mode 100644 tests/golden/default/agent-plugins/plugins/acme/scripts/run.sh create mode 100644 tests/golden/default/agent-plugins/plugins/acme/skills/release-notes/SKILL.md create mode 100644 tests/golden/default/agent-plugins/plugins/acme/skills/release-notes/references/template.txt create mode 100644 tests/golden/default/antigravity/.pluginpack/antigravity.json create mode 100644 tests/golden/default/antigravity/CONTRIBUTING.md create mode 100644 tests/golden/default/antigravity/README.md create mode 100644 tests/golden/default/antigravity/acme/LICENSE create mode 100644 tests/golden/default/antigravity/acme/README.md create mode 100644 tests/golden/default/antigravity/acme/agents/helper.agent.md create mode 100644 tests/golden/default/antigravity/acme/assets/icon.png create mode 100644 tests/golden/default/antigravity/acme/bin/server create mode 100644 tests/golden/default/antigravity/acme/hooks.json create mode 100644 tests/golden/default/antigravity/acme/mcp_config.json create mode 100644 tests/golden/default/antigravity/acme/plugin.json create mode 100644 tests/golden/default/antigravity/acme/rules/style.mdc create mode 100644 tests/golden/default/antigravity/acme/scripts/run.sh create mode 100644 tests/golden/default/antigravity/acme/skills/release-notes/SKILL.md create mode 100644 tests/golden/default/antigravity/acme/skills/release-notes/references/template.txt create mode 100644 tests/golden/default/claude/.claude-plugin/marketplace.json create mode 100644 tests/golden/default/claude/.pluginpack/claude.json create mode 100644 tests/golden/default/claude/CONTRIBUTING.md create mode 100644 tests/golden/default/claude/README.md create mode 100644 tests/golden/default/claude/plugins/acme/.claude-plugin/plugin.json create mode 100644 tests/golden/default/claude/plugins/acme/.mcp.json create mode 100644 tests/golden/default/claude/plugins/acme/LICENSE create mode 100644 tests/golden/default/claude/plugins/acme/README.md create mode 100644 tests/golden/default/claude/plugins/acme/agents/helper.agent.md create mode 100644 tests/golden/default/claude/plugins/acme/assets/icon.png create mode 100644 tests/golden/default/claude/plugins/acme/bin/server create mode 100644 tests/golden/default/claude/plugins/acme/hooks/hooks.json create mode 100644 tests/golden/default/claude/plugins/acme/scripts/pluginpack-update-check.sh create mode 100644 tests/golden/default/claude/plugins/acme/scripts/run.sh create mode 100644 tests/golden/default/claude/plugins/acme/skills/release-notes/SKILL.md create mode 100644 tests/golden/default/claude/plugins/acme/skills/release-notes/references/template.txt create mode 100644 tests/golden/default/codex/.agents/plugins/marketplace.json create mode 100644 tests/golden/default/codex/.pluginpack/codex.json create mode 100644 tests/golden/default/codex/CONTRIBUTING.md create mode 100644 tests/golden/default/codex/README.md create mode 100644 tests/golden/default/codex/plugins/acme/LICENSE create mode 100644 tests/golden/default/codex/plugins/acme/README.md create mode 100644 tests/golden/default/codex/plugins/acme/assets/icon.png create mode 100644 tests/golden/default/codex/plugins/acme/bin/server create mode 100644 tests/golden/default/codex/plugins/acme/hooks/hooks.json create mode 100644 tests/golden/default/codex/plugins/acme/mcp.json create mode 100644 tests/golden/default/codex/plugins/acme/plugin.json create mode 100644 tests/golden/default/codex/plugins/acme/scripts/run.sh create mode 100644 tests/golden/default/codex/plugins/acme/skills/release-notes/SKILL.md create mode 100644 tests/golden/default/codex/plugins/acme/skills/release-notes/references/template.txt create mode 100644 tests/golden/default/copilot/.claude-plugin/marketplace.json create mode 100644 tests/golden/default/copilot/.github/plugin/marketplace.json create mode 100644 tests/golden/default/copilot/.pluginpack/copilot.json create mode 100644 tests/golden/default/copilot/CONTRIBUTING.md create mode 100644 tests/golden/default/copilot/README.md create mode 100644 tests/golden/default/copilot/plugins/acme/.github/plugin/plugin.json create mode 100644 tests/golden/default/copilot/plugins/acme/.mcp.json create mode 100644 tests/golden/default/copilot/plugins/acme/LICENSE create mode 100644 tests/golden/default/copilot/plugins/acme/README.md create mode 100644 tests/golden/default/copilot/plugins/acme/agents/helper.agent.md create mode 100644 tests/golden/default/copilot/plugins/acme/assets/icon.png create mode 100644 tests/golden/default/copilot/plugins/acme/bin/server create mode 100644 tests/golden/default/copilot/plugins/acme/hooks/hooks.json create mode 100644 tests/golden/default/copilot/plugins/acme/plugin.json create mode 100644 tests/golden/default/copilot/plugins/acme/scripts/run.sh create mode 100644 tests/golden/default/copilot/plugins/acme/skills/release-notes/SKILL.md create mode 100644 tests/golden/default/copilot/plugins/acme/skills/release-notes/references/template.txt create mode 100644 tests/golden/default/cursor/.cursor-plugin/marketplace.json create mode 100644 tests/golden/default/cursor/.pluginpack/cursor.json create mode 100644 tests/golden/default/cursor/CONTRIBUTING.md create mode 100644 tests/golden/default/cursor/README.md create mode 100644 tests/golden/default/cursor/plugins/acme/.cursor-plugin/plugin.json create mode 100644 tests/golden/default/cursor/plugins/acme/.mcp.json create mode 100644 tests/golden/default/cursor/plugins/acme/LICENSE create mode 100644 tests/golden/default/cursor/plugins/acme/README.md create mode 100644 tests/golden/default/cursor/plugins/acme/agents/helper.agent.md create mode 100644 tests/golden/default/cursor/plugins/acme/assets/icon.png create mode 100644 tests/golden/default/cursor/plugins/acme/bin/server create mode 100644 tests/golden/default/cursor/plugins/acme/hooks/hooks.json create mode 100644 tests/golden/default/cursor/plugins/acme/rules/cursor-only.mdc create mode 100644 tests/golden/default/cursor/plugins/acme/rules/style.mdc create mode 100644 tests/golden/default/cursor/plugins/acme/scripts/pluginpack-update-check.sh create mode 100644 tests/golden/default/cursor/plugins/acme/scripts/run.sh create mode 100644 tests/golden/default/cursor/plugins/acme/skills/release-notes/SKILL.md create mode 100644 tests/golden/default/cursor/plugins/acme/skills/release-notes/references/template.txt create mode 100644 tests/golden/variants/agent-plugins/.pluginpack/agent-plugins.json create mode 100644 tests/golden/variants/agent-plugins/plugins/acme/LICENSE create mode 100644 tests/golden/variants/agent-plugins/plugins/acme/README.md create mode 100644 tests/golden/variants/agent-plugins/plugins/acme/assets/icon.png create mode 100644 tests/golden/variants/agent-plugins/plugins/acme/bin/server create mode 100644 tests/golden/variants/agent-plugins/plugins/acme/mcp.json create mode 100644 tests/golden/variants/agent-plugins/plugins/acme/plugin.json create mode 100644 tests/golden/variants/agent-plugins/plugins/acme/scripts/run.sh create mode 100644 tests/golden/variants/agent-plugins/plugins/acme/skills/release-notes/SKILL.md create mode 100644 tests/golden/variants/agent-plugins/plugins/acme/skills/release-notes/references/template.txt create mode 100644 tests/golden/variants/codex/.agents/plugins/marketplace.json create mode 100644 tests/golden/variants/codex/.pluginpack/codex.json create mode 100644 tests/golden/variants/codex/plugins/acme/.codex-plugin/plugin.json create mode 100644 tests/golden/variants/codex/plugins/acme/.mcp.json create mode 100644 tests/golden/variants/codex/plugins/acme/LICENSE create mode 100644 tests/golden/variants/codex/plugins/acme/README.md create mode 100644 tests/golden/variants/codex/plugins/acme/assets/icon.png create mode 100644 tests/golden/variants/codex/plugins/acme/bin/server create mode 100644 tests/golden/variants/codex/plugins/acme/hooks/hooks.json create mode 100644 tests/golden/variants/codex/plugins/acme/scripts/run.sh create mode 100644 tests/golden/variants/codex/plugins/acme/skills/release-notes/SKILL.md create mode 100644 tests/golden/variants/codex/plugins/acme/skills/release-notes/references/template.txt diff --git a/.prettierignore b/.prettierignore index 94e6de5..6cc72db 100644 --- a/.prettierignore +++ b/.prettierignore @@ -7,3 +7,5 @@ package-lock.json tests/fixtures/cursor/*.schema.json tests/fixtures/agent-plugins/*.schema.json CHANGELOG.md +# Golden output is pinned byte for byte as pluginpack emits it. +tests/golden/ diff --git a/package.json b/package.json index 683e624..ca48bb2 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,7 @@ "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", + "test:golden:update": "env UPDATE_GOLDEN=1 vitest run tests/golden.test.ts", "typecheck": "tsc --noEmit" }, "dependencies": { diff --git a/tests/golden.test.ts b/tests/golden.test.ts new file mode 100644 index 0000000..c52b706 --- /dev/null +++ b/tests/golden.test.ts @@ -0,0 +1,352 @@ +/** + * Golden output: builds fixed configs and compares every emitted file — its + * path and its exact bytes — against the tree checked in under + * `tests/golden///`, including the serialized managed manifest + * `.pluginpack/.json`. Emitted output is a compatibility surface: + * renaming a path, reordering a manifest field or changing a byte of a + * generated file changes what hosts and downstream repos see. This test makes + * such a change fail CI until someone regenerates the goldens on purpose: + * + * npm run test:golden:update + * + * then reviews `git diff tests/golden` and says in the PR why output changed. + */ +import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { Project, type ProjectArgs } from "fixturify-project"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { build } from "../src/build.js"; +import { validateOutput } from "../src/adapters.js"; +import { exists, toPosix, walkFiles } from "../src/fs.js"; +import { managedManifestPath } from "../src/managed.js"; +import type { TargetName } from "../src/types.js"; + +type DirJSON = NonNullable; + +const goldenRoot = fileURLToPath(new URL("./golden/", import.meta.url)); +const update = process.env.UPDATE_GOLDEN === "1"; +const updateCommand = "npm run test:golden:update"; + +if (update && process.env.CI) { + throw new Error( + "UPDATE_GOLDEN is set in CI. Golden output is regenerated locally and reviewed, never rewritten by CI.", + ); +} + +const indexPath = path.resolve("src/index.ts"); + +let project: Project | undefined; + +afterEach(async () => { + vi.restoreAllMocks(); + await project?.dispose(); + project = undefined; +}); + +const skillMd = `--- +name: release-notes +description: Draft release notes from a list of changes. +--- + +# Release notes + +{{> greeting}} + +Group the changes by feature and fix. +`; + +// A minimal valid PNG header and IHDR chunk, so binary passthrough is pinned too. +const iconPng = Buffer.from( + "89504e470d0a1a0a0000000d4948445200000001000000010806000000", + "hex", +); + +/** The authored text files every golden case builds, plus `iconPng`. Change them and the goldens change. */ +function sourceFiles(): DirJSON { + return { + partials: { "greeting.md": "Start by reading the changelog.\n" }, + repositories: { + all: { + "README.md": "# Golden plugins\n\n{{> greeting}}\n", + "CONTRIBUTING.md": "Edit shared/, not the generated output.\n", + }, + }, + shared: { + acme: { + "plugin.pluginpack.json": `${JSON.stringify({ description: "Acme from the source manifest." }, null, 2)}\n`, + "README.md": "# Acme\n\n{{> greeting}}\n", + LICENSE: "MIT\n", + skills: { + "release-notes": { + "SKILL.md": skillMd, + references: { "template.txt": "## Features\n\n## Fixes\n" }, + }, + }, + agents: { + "helper.agent.md": + "---\nname: helper\ndescription: Find documents that answer a question.\n---\n\nSearch and cite sources.\n", + }, + commands: { + "summarize.md": + "---\ndescription: Summarize the open file.\n---\n\nSummarize it.\n", + }, + rules: { + "style.mdc": + "---\ndescription: House style.\nalwaysApply: false\n---\n\nUse short sentences.\n", + }, + hooks: { + "hooks.json": `${JSON.stringify( + { + hooks: { + SessionStart: [ + { hooks: [{ type: "command", command: "echo ready" }] }, + ], + }, + }, + null, + 2, + )}\n`, + }, + scripts: { "run.sh": "#!/bin/sh\necho run\n" }, + mcp: { + "config.json": `${JSON.stringify( + { + mcpServers: { + acme: { + type: "stdio", + command: "./bin/server", + args: ["--stdio"], + env: { ACME_MODE: "plugin" }, + }, + docs: { + type: "streamable-http", + url: "https://mcp.example.com/mcp", + }, + }, + }, + null, + 2, + )}\n`, + "pluginpack.json": `${JSON.stringify({ files: { "bin/server": "bin/server" } }, null, 2)}\n`, + bin: { server: "#!/bin/sh\necho server\n" }, + src: { "server.ts": "export {};\n" }, + }, + }, + }, + overrides: { + cursor: { + acme: { + rules: { + "cursor-only.mdc": + "---\ndescription: Cursor only.\nalwaysApply: true\n---\n\nOnly in Cursor.\n", + }, + }, + }, + codex: { + acme: { + skills: { + "release-notes": { + "SKILL.md": skillMd.replace( + "Group the changes", + "In Codex, group the changes", + ), + }, + }, + }, + }, + }, + }; +} + +const codexEntry = `{ policy: { installation: "AVAILABLE", authentication: "ON_INSTALL" }, category: "Productivity" }`; + +/** Each case is one fixed config; its targets each write to `out/`. */ +const cases: Record = { + default: ` + cursor: { + outDir: "out/cursor", + repositoryFiles: "repositories/all", + updateCheck: {}, + plugins: { + acme: { + source: "shared/acme", + overrides: "overrides/cursor/acme", + path: "plugins/acme", + displayName: "Acme", + }, + }, + }, + claude: { + outDir: "out/claude", + pluginRoot: "plugins", + repositoryFiles: "repositories/all", + updateCheck: {}, + plugins: { acme: { source: "shared/acme", version: "1.2.4" } }, + }, + antigravity: { + outDir: "out/antigravity", + repositoryFiles: "repositories/all", + plugins: { acme: { source: "shared/acme" } }, + }, + copilot: { + outDir: "out/copilot", + repositoryFiles: "repositories/all", + plugins: { acme: { source: "shared/acme", description: "Acme for Copilot." } }, + }, + codex: { + outDir: "out/codex", + repositoryFiles: "repositories/all", + manifest: { interface: { displayName: "Golden plugins" } }, + plugins: { + acme: { + source: "shared/acme", + overrides: "overrides/codex/acme", + entry: ${codexEntry}, + manifest: { interface: { displayName: "Acme", category: "Productivity" } }, + }, + }, + }, + "agent-plugins": { + outDir: "out/agent-plugins", + repositoryFiles: "repositories/all", + clients: { + codex: { manifest: { interface: { displayName: "Golden plugins" } } }, + copilot: {}, + }, + plugins: { + acme: { + source: "shared/acme", + manifest: { extensions: { "com.openai": { interface: { displayName: "Acme" } } } }, + clients: { codex: { entry: ${codexEntry} } }, + }, + }, + },`, + variants: ` + codex: { + outDir: "out/codex", + format: "legacy", + plugins: { acme: { source: "shared/acme", entry: ${codexEntry} } }, + }, + "agent-plugins": { + outDir: "out/agent-plugins", + plugins: { acme: { source: "shared/acme" } }, + },`, +}; + +function configSource(targets: string): string { + return `import { defineConfig } from "${indexPath}"; + +export default defineConfig({ + name: "golden-plugins", + version: "1.2.3", + source: { partials: "partials" }, + metadata: { + displayName: "Golden Plugins", + description: "Plugins whose output is pinned byte for byte.", + author: { name: "Golden", email: "golden@example.com" }, + owner: { name: "Golden" }, + homepage: "https://example.com/golden", + repository: "https://github.com/example/golden-plugins", + license: "MIT", + keywords: ["golden", "test"], + category: "Productivity", + }, + targets: {${targets} + }, +}); +`; +} + +/** Every file under `dir`, keyed by posix path relative to it, sorted. */ +async function readTree(dir: string): Promise> { + const tree = new Map(); + if (!(await exists(dir))) { + return tree; + } + for (const file of await walkFiles(dir)) { + tree.set(toPosix(path.relative(dir, file)), await readFile(file)); + } + return new Map([...tree].sort(([a], [b]) => (a < b ? -1 : 1))); +} + +async function writeTree( + dir: string, + tree: Map, +): Promise { + await rm(dir, { recursive: true, force: true }); + for (const [relative, contents] of tree) { + await mkdir(path.dirname(path.join(dir, relative)), { recursive: true }); + await writeFile(path.join(dir, relative), contents); + } +} + +describe.each(Object.entries(cases))( + "golden output: %s config", + (name, targets) => { + it("matches tests/golden byte for byte", async () => { + project = new Project("pluginpack-golden", "1.0.0", { + files: { + "pluginpack.config.ts": configSource(targets), + ...sourceFiles(), + }, + }); + await project.write(); + const root = project.baseDir; + // fixturify writes only text, so the binary asset is written here. + await mkdir(path.join(root, "shared/acme/assets"), { recursive: true }); + await writeFile(path.join(root, "shared/acme/assets/icon.png"), iconPng); + // Build warnings (such as the legacy-format deprecation) are not output. + vi.spyOn(console, "warn").mockImplementation(() => {}); + const artifacts = await build({ cwd: root }); + + const caseDir = path.join(goldenRoot, name); + const builtTargets = artifacts.map((artifact) => artifact.target).sort(); + if (update) { + await rm(caseDir, { recursive: true, force: true }); + } + for (const artifact of artifacts) { + const actual = await readTree(artifact.outDir); + const goldenDir = path.join(caseDir, artifact.target); + if (update) { + await writeTree(goldenDir, actual); + continue; + } + const expected = await readTree(goldenDir); + // The managed manifest is pinned like any other output file. + expect(actual.has(managedManifestPath(artifact.target))).toBe(true); + expect( + [...actual.keys()], + `${name}/${artifact.target}: emitted paths changed. If that is intended, run \`${updateCommand}\` and review git diff tests/golden.`, + ).toEqual([...expected.keys()]); + for (const [relative, contents] of actual) { + expect( + contents.toString("utf8"), + `${name}/${artifact.target}/${relative}: bytes changed. If that is intended, run \`${updateCommand}\` and review git diff tests/golden.`, + ).toBe(expected.get(relative)?.toString("utf8")); + expect(contents.equals(expected.get(relative)!)).toBe(true); + } + } + // A golden directory for a target the case no longer builds is stale. + const goldenTargets = (await exists(caseDir)) + ? [ + ...new Set( + [...(await readTree(caseDir)).keys()].map( + (file) => file.split("/")[0], + ), + ), + ].sort() + : []; + expect(goldenTargets).toEqual(builtTargets as TargetName[]); + + // The pinned output is output a user could ship: every target validates. + for (const artifact of artifacts) { + const result = await validateOutput(artifact.target, artifact.outDir); + expect( + result.issues.filter((issue) => issue.level === "error"), + `${name}/${artifact.target} validate`, + ).toEqual([]); + } + }); + }, +); diff --git a/tests/golden/default/agent-plugins/.agents/plugins/marketplace.json b/tests/golden/default/agent-plugins/.agents/plugins/marketplace.json new file mode 100644 index 0000000..4eb2380 --- /dev/null +++ b/tests/golden/default/agent-plugins/.agents/plugins/marketplace.json @@ -0,0 +1,19 @@ +{ + "name": "golden-plugins", + "interface": { + "displayName": "Golden plugins" + }, + "plugins": [ + { + "name": "acme", + "source": "./plugins/acme", + "description": "Acme from the source manifest.", + "version": "1.2.3", + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Productivity" + } + ] +} diff --git a/tests/golden/default/agent-plugins/.github/plugin/marketplace.json b/tests/golden/default/agent-plugins/.github/plugin/marketplace.json new file mode 100644 index 0000000..c39e8d0 --- /dev/null +++ b/tests/golden/default/agent-plugins/.github/plugin/marketplace.json @@ -0,0 +1,18 @@ +{ + "name": "golden-plugins", + "owner": { + "name": "Golden" + }, + "metadata": { + "description": "Plugins whose output is pinned byte for byte.", + "version": "1.2.3" + }, + "plugins": [ + { + "name": "acme", + "source": "./plugins/acme", + "description": "Acme from the source manifest.", + "version": "1.2.3" + } + ] +} diff --git a/tests/golden/default/agent-plugins/.pluginpack/agent-plugins.json b/tests/golden/default/agent-plugins/.pluginpack/agent-plugins.json new file mode 100644 index 0000000..9468b53 --- /dev/null +++ b/tests/golden/default/agent-plugins/.pluginpack/agent-plugins.json @@ -0,0 +1,36 @@ +{ + "version": 2, + "target": "agent-plugins", + "files": [ + ".agents/plugins/marketplace.json", + ".github/plugin/marketplace.json", + "CONTRIBUTING.md", + "README.md", + "plugins/acme/LICENSE", + "plugins/acme/README.md", + "plugins/acme/assets/icon.png", + "plugins/acme/bin/server", + "plugins/acme/com.github.copilot/agents/helper.agent.md", + "plugins/acme/mcp.json", + "plugins/acme/plugin.json", + "plugins/acme/scripts/run.sh", + "plugins/acme/skills/release-notes/SKILL.md", + "plugins/acme/skills/release-notes/references/template.txt" + ], + "sha256": { + ".agents/plugins/marketplace.json": "c929e0fb6a0376199e20c75b96b7ad544cf1026351445ecd11d6b7e16a559c6d", + ".github/plugin/marketplace.json": "e115239188e2e21a9a31c727963030ce09c0adb321f22545f2fd3d203c12676f", + "CONTRIBUTING.md": "5d81cc62707c5592ad3fd46205b8b45d3aa25e2a1e6e18d59c14b3e801217bc7", + "README.md": "2e67a1226ce201bd5560b910ec05652241bf38ec364fc639d5173ae433980e5e", + "plugins/acme/LICENSE": "adc37366f403835c1470ab2df93d3837d4719372fc1ef8593d922e06f033f8b2", + "plugins/acme/README.md": "bd8dd9498117b96048242a906bd2112379227d573726b62ea4bbcba214a24469", + "plugins/acme/assets/icon.png": "71fde3856a298a052c6edb5155638d8422114e55bf0e28374d90dea8be99ef6c", + "plugins/acme/bin/server": "9210d306feecb440d7bd86c82b2ddfae0bb2b706571ed3962f566a7f3f3cf83d", + "plugins/acme/com.github.copilot/agents/helper.agent.md": "f98ea67be542b0a45b4b493c086c36fef4bb7b18c3a9ed957370083b1b0962f4", + "plugins/acme/mcp.json": "f6d7a3e17bd9f611b74858306a38f4904a0dba89da8e069a9e09ddadc6cd7b73", + "plugins/acme/plugin.json": "537dfa228c4c2898ea1eefca98986a8f1aa149786992402b7787f2eb63a7e8a7", + "plugins/acme/scripts/run.sh": "a4e0317eafab5cf1bc4a0041c7c8aeb6ece56fe72e7b2b3017a8a6574614cd35", + "plugins/acme/skills/release-notes/SKILL.md": "ab3912f7c2cd1515c291a59d91205364a66b41566095e4202ce8842c59d21b6d", + "plugins/acme/skills/release-notes/references/template.txt": "0528c3e23a7f67ba714dc08d17d0fa4d1d71584c3a52caa2d344d5f030db441a" + } +} diff --git a/tests/golden/default/agent-plugins/CONTRIBUTING.md b/tests/golden/default/agent-plugins/CONTRIBUTING.md new file mode 100644 index 0000000..d4cfe83 --- /dev/null +++ b/tests/golden/default/agent-plugins/CONTRIBUTING.md @@ -0,0 +1 @@ +Edit shared/, not the generated output. diff --git a/tests/golden/default/agent-plugins/README.md b/tests/golden/default/agent-plugins/README.md new file mode 100644 index 0000000..a459aab --- /dev/null +++ b/tests/golden/default/agent-plugins/README.md @@ -0,0 +1,3 @@ +# Golden plugins + +Start by reading the changelog. diff --git a/tests/golden/default/agent-plugins/plugins/acme/LICENSE b/tests/golden/default/agent-plugins/plugins/acme/LICENSE new file mode 100644 index 0000000..a22a2da --- /dev/null +++ b/tests/golden/default/agent-plugins/plugins/acme/LICENSE @@ -0,0 +1 @@ +MIT diff --git a/tests/golden/default/agent-plugins/plugins/acme/README.md b/tests/golden/default/agent-plugins/plugins/acme/README.md new file mode 100644 index 0000000..a417b90 --- /dev/null +++ b/tests/golden/default/agent-plugins/plugins/acme/README.md @@ -0,0 +1,3 @@ +# Acme + +Start by reading the changelog. diff --git a/tests/golden/default/agent-plugins/plugins/acme/assets/icon.png b/tests/golden/default/agent-plugins/plugins/acme/assets/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..d1ed0c8713020f5bda68b22792b0512681d2c0f9 GIT binary patch literal 29 ecmeAS@N?(olHy`uVBq!ia0vp^j3CUx1|$JML;~&r literal 0 HcmV?d00001 diff --git a/tests/golden/default/agent-plugins/plugins/acme/bin/server b/tests/golden/default/agent-plugins/plugins/acme/bin/server new file mode 100644 index 0000000..a43ea4e --- /dev/null +++ b/tests/golden/default/agent-plugins/plugins/acme/bin/server @@ -0,0 +1,2 @@ +#!/bin/sh +echo server diff --git a/tests/golden/default/agent-plugins/plugins/acme/com.github.copilot/agents/helper.agent.md b/tests/golden/default/agent-plugins/plugins/acme/com.github.copilot/agents/helper.agent.md new file mode 100644 index 0000000..8d32edd --- /dev/null +++ b/tests/golden/default/agent-plugins/plugins/acme/com.github.copilot/agents/helper.agent.md @@ -0,0 +1,6 @@ +--- +name: helper +description: Find documents that answer a question. +--- + +Search and cite sources. diff --git a/tests/golden/default/agent-plugins/plugins/acme/mcp.json b/tests/golden/default/agent-plugins/plugins/acme/mcp.json new file mode 100644 index 0000000..4417b24 --- /dev/null +++ b/tests/golden/default/agent-plugins/plugins/acme/mcp.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json", + "mcpServers": { + "acme": { + "type": "stdio", + "command": "./bin/server", + "args": [ + "--stdio" + ], + "env": { + "ACME_MODE": "plugin" + } + }, + "docs": { + "type": "streamable-http", + "url": "https://mcp.example.com/mcp" + } + } +} diff --git a/tests/golden/default/agent-plugins/plugins/acme/plugin.json b/tests/golden/default/agent-plugins/plugins/acme/plugin.json new file mode 100644 index 0000000..fde5c7e --- /dev/null +++ b/tests/golden/default/agent-plugins/plugins/acme/plugin.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + "name": "acme", + "version": "1.2.3", + "description": "Acme from the source manifest.", + "author": { + "name": "Golden", + "email": "golden@example.com" + }, + "homepage": "https://example.com/golden", + "repository": "https://github.com/example/golden-plugins", + "license": "MIT", + "keywords": [ + "golden", + "test" + ], + "extensions": { + "com.openai": { + "interface": { + "displayName": "Acme" + } + } + } +} diff --git a/tests/golden/default/agent-plugins/plugins/acme/scripts/run.sh b/tests/golden/default/agent-plugins/plugins/acme/scripts/run.sh new file mode 100644 index 0000000..85ba14d --- /dev/null +++ b/tests/golden/default/agent-plugins/plugins/acme/scripts/run.sh @@ -0,0 +1,2 @@ +#!/bin/sh +echo run diff --git a/tests/golden/default/agent-plugins/plugins/acme/skills/release-notes/SKILL.md b/tests/golden/default/agent-plugins/plugins/acme/skills/release-notes/SKILL.md new file mode 100644 index 0000000..ff888d6 --- /dev/null +++ b/tests/golden/default/agent-plugins/plugins/acme/skills/release-notes/SKILL.md @@ -0,0 +1,10 @@ +--- +name: release-notes +description: Draft release notes from a list of changes. +--- + +# Release notes + +Start by reading the changelog. + +Group the changes by feature and fix. diff --git a/tests/golden/default/agent-plugins/plugins/acme/skills/release-notes/references/template.txt b/tests/golden/default/agent-plugins/plugins/acme/skills/release-notes/references/template.txt new file mode 100644 index 0000000..937d151 --- /dev/null +++ b/tests/golden/default/agent-plugins/plugins/acme/skills/release-notes/references/template.txt @@ -0,0 +1,3 @@ +## Features + +## Fixes diff --git a/tests/golden/default/antigravity/.pluginpack/antigravity.json b/tests/golden/default/antigravity/.pluginpack/antigravity.json new file mode 100644 index 0000000..3c1185a --- /dev/null +++ b/tests/golden/default/antigravity/.pluginpack/antigravity.json @@ -0,0 +1,36 @@ +{ + "version": 2, + "target": "antigravity", + "files": [ + "CONTRIBUTING.md", + "README.md", + "acme/LICENSE", + "acme/README.md", + "acme/agents/helper.agent.md", + "acme/assets/icon.png", + "acme/bin/server", + "acme/hooks.json", + "acme/mcp_config.json", + "acme/plugin.json", + "acme/rules/style.mdc", + "acme/scripts/run.sh", + "acme/skills/release-notes/SKILL.md", + "acme/skills/release-notes/references/template.txt" + ], + "sha256": { + "CONTRIBUTING.md": "5d81cc62707c5592ad3fd46205b8b45d3aa25e2a1e6e18d59c14b3e801217bc7", + "README.md": "2e67a1226ce201bd5560b910ec05652241bf38ec364fc639d5173ae433980e5e", + "acme/LICENSE": "adc37366f403835c1470ab2df93d3837d4719372fc1ef8593d922e06f033f8b2", + "acme/README.md": "bd8dd9498117b96048242a906bd2112379227d573726b62ea4bbcba214a24469", + "acme/agents/helper.agent.md": "f98ea67be542b0a45b4b493c086c36fef4bb7b18c3a9ed957370083b1b0962f4", + "acme/assets/icon.png": "71fde3856a298a052c6edb5155638d8422114e55bf0e28374d90dea8be99ef6c", + "acme/bin/server": "9210d306feecb440d7bd86c82b2ddfae0bb2b706571ed3962f566a7f3f3cf83d", + "acme/hooks.json": "ffa7f7bd137c04c26f889c25e3ea9cad84596c619f55a2f6324369c9a413c94d", + "acme/mcp_config.json": "f3c91992c02ed1cb0767accf66bf8e7bca72408b401771c6c9ab5c5c55fc6aa1", + "acme/plugin.json": "af2e215200fc6427ae5630ff25de0d9db4d66d3a068fb07274f0e7ccd11fc5d1", + "acme/rules/style.mdc": "8d5532f408bbf1d24877ce3afb4cdf15114628ab98235787d1957bfe425546ee", + "acme/scripts/run.sh": "a4e0317eafab5cf1bc4a0041c7c8aeb6ece56fe72e7b2b3017a8a6574614cd35", + "acme/skills/release-notes/SKILL.md": "ab3912f7c2cd1515c291a59d91205364a66b41566095e4202ce8842c59d21b6d", + "acme/skills/release-notes/references/template.txt": "0528c3e23a7f67ba714dc08d17d0fa4d1d71584c3a52caa2d344d5f030db441a" + } +} diff --git a/tests/golden/default/antigravity/CONTRIBUTING.md b/tests/golden/default/antigravity/CONTRIBUTING.md new file mode 100644 index 0000000..d4cfe83 --- /dev/null +++ b/tests/golden/default/antigravity/CONTRIBUTING.md @@ -0,0 +1 @@ +Edit shared/, not the generated output. diff --git a/tests/golden/default/antigravity/README.md b/tests/golden/default/antigravity/README.md new file mode 100644 index 0000000..a459aab --- /dev/null +++ b/tests/golden/default/antigravity/README.md @@ -0,0 +1,3 @@ +# Golden plugins + +Start by reading the changelog. diff --git a/tests/golden/default/antigravity/acme/LICENSE b/tests/golden/default/antigravity/acme/LICENSE new file mode 100644 index 0000000..a22a2da --- /dev/null +++ b/tests/golden/default/antigravity/acme/LICENSE @@ -0,0 +1 @@ +MIT diff --git a/tests/golden/default/antigravity/acme/README.md b/tests/golden/default/antigravity/acme/README.md new file mode 100644 index 0000000..a417b90 --- /dev/null +++ b/tests/golden/default/antigravity/acme/README.md @@ -0,0 +1,3 @@ +# Acme + +Start by reading the changelog. diff --git a/tests/golden/default/antigravity/acme/agents/helper.agent.md b/tests/golden/default/antigravity/acme/agents/helper.agent.md new file mode 100644 index 0000000..8d32edd --- /dev/null +++ b/tests/golden/default/antigravity/acme/agents/helper.agent.md @@ -0,0 +1,6 @@ +--- +name: helper +description: Find documents that answer a question. +--- + +Search and cite sources. diff --git a/tests/golden/default/antigravity/acme/assets/icon.png b/tests/golden/default/antigravity/acme/assets/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..d1ed0c8713020f5bda68b22792b0512681d2c0f9 GIT binary patch literal 29 ecmeAS@N?(olHy`uVBq!ia0vp^j3CUx1|$JML;~&r literal 0 HcmV?d00001 diff --git a/tests/golden/default/antigravity/acme/bin/server b/tests/golden/default/antigravity/acme/bin/server new file mode 100644 index 0000000..a43ea4e --- /dev/null +++ b/tests/golden/default/antigravity/acme/bin/server @@ -0,0 +1,2 @@ +#!/bin/sh +echo server diff --git a/tests/golden/default/antigravity/acme/hooks.json b/tests/golden/default/antigravity/acme/hooks.json new file mode 100644 index 0000000..fd02484 --- /dev/null +++ b/tests/golden/default/antigravity/acme/hooks.json @@ -0,0 +1,14 @@ +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "echo ready" + } + ] + } + ] + } +} diff --git a/tests/golden/default/antigravity/acme/mcp_config.json b/tests/golden/default/antigravity/acme/mcp_config.json new file mode 100644 index 0000000..4ac170d --- /dev/null +++ b/tests/golden/default/antigravity/acme/mcp_config.json @@ -0,0 +1,18 @@ +{ + "mcpServers": { + "acme": { + "type": "stdio", + "command": "./bin/server", + "args": [ + "--stdio" + ], + "env": { + "ACME_MODE": "plugin" + } + }, + "docs": { + "type": "streamable-http", + "url": "https://mcp.example.com/mcp" + } + } +} diff --git a/tests/golden/default/antigravity/acme/plugin.json b/tests/golden/default/antigravity/acme/plugin.json new file mode 100644 index 0000000..a493fbf --- /dev/null +++ b/tests/golden/default/antigravity/acme/plugin.json @@ -0,0 +1,4 @@ +{ + "name": "acme", + "description": "Acme from the source manifest." +} diff --git a/tests/golden/default/antigravity/acme/rules/style.mdc b/tests/golden/default/antigravity/acme/rules/style.mdc new file mode 100644 index 0000000..47b1a62 --- /dev/null +++ b/tests/golden/default/antigravity/acme/rules/style.mdc @@ -0,0 +1,6 @@ +--- +description: House style. +alwaysApply: false +--- + +Use short sentences. diff --git a/tests/golden/default/antigravity/acme/scripts/run.sh b/tests/golden/default/antigravity/acme/scripts/run.sh new file mode 100644 index 0000000..85ba14d --- /dev/null +++ b/tests/golden/default/antigravity/acme/scripts/run.sh @@ -0,0 +1,2 @@ +#!/bin/sh +echo run diff --git a/tests/golden/default/antigravity/acme/skills/release-notes/SKILL.md b/tests/golden/default/antigravity/acme/skills/release-notes/SKILL.md new file mode 100644 index 0000000..ff888d6 --- /dev/null +++ b/tests/golden/default/antigravity/acme/skills/release-notes/SKILL.md @@ -0,0 +1,10 @@ +--- +name: release-notes +description: Draft release notes from a list of changes. +--- + +# Release notes + +Start by reading the changelog. + +Group the changes by feature and fix. diff --git a/tests/golden/default/antigravity/acme/skills/release-notes/references/template.txt b/tests/golden/default/antigravity/acme/skills/release-notes/references/template.txt new file mode 100644 index 0000000..937d151 --- /dev/null +++ b/tests/golden/default/antigravity/acme/skills/release-notes/references/template.txt @@ -0,0 +1,3 @@ +## Features + +## Fixes diff --git a/tests/golden/default/claude/.claude-plugin/marketplace.json b/tests/golden/default/claude/.claude-plugin/marketplace.json new file mode 100644 index 0000000..e0a38bb --- /dev/null +++ b/tests/golden/default/claude/.claude-plugin/marketplace.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://anthropic.com/claude-code/marketplace.schema.json", + "name": "golden-plugins", + "version": "1.2.3", + "description": "Plugins whose output is pinned byte for byte.", + "owner": { + "name": "Golden" + }, + "plugins": [ + { + "name": "acme", + "source": "./plugins/acme", + "description": "Acme from the source manifest." + } + ] +} diff --git a/tests/golden/default/claude/.pluginpack/claude.json b/tests/golden/default/claude/.pluginpack/claude.json new file mode 100644 index 0000000..762249b --- /dev/null +++ b/tests/golden/default/claude/.pluginpack/claude.json @@ -0,0 +1,38 @@ +{ + "version": 2, + "target": "claude", + "files": [ + ".claude-plugin/marketplace.json", + "CONTRIBUTING.md", + "README.md", + "plugins/acme/.claude-plugin/plugin.json", + "plugins/acme/.mcp.json", + "plugins/acme/LICENSE", + "plugins/acme/README.md", + "plugins/acme/agents/helper.agent.md", + "plugins/acme/assets/icon.png", + "plugins/acme/bin/server", + "plugins/acme/hooks/hooks.json", + "plugins/acme/scripts/pluginpack-update-check.sh", + "plugins/acme/scripts/run.sh", + "plugins/acme/skills/release-notes/SKILL.md", + "plugins/acme/skills/release-notes/references/template.txt" + ], + "sha256": { + ".claude-plugin/marketplace.json": "8bfbcee80b5ba0957a482e8f0e28c7c9917a69054cbfed51554047a8d474c71c", + "CONTRIBUTING.md": "5d81cc62707c5592ad3fd46205b8b45d3aa25e2a1e6e18d59c14b3e801217bc7", + "README.md": "2e67a1226ce201bd5560b910ec05652241bf38ec364fc639d5173ae433980e5e", + "plugins/acme/.claude-plugin/plugin.json": "9135121c5a0f3e97ad930f2c692472b1cc506abd3ec596da5fdb8103178bd16b", + "plugins/acme/.mcp.json": "856185b2681805ec57e9b3ef1edbad20dcc1d4a410a61f11ef9ba6b1fe337ca0", + "plugins/acme/LICENSE": "adc37366f403835c1470ab2df93d3837d4719372fc1ef8593d922e06f033f8b2", + "plugins/acme/README.md": "bd8dd9498117b96048242a906bd2112379227d573726b62ea4bbcba214a24469", + "plugins/acme/agents/helper.agent.md": "f98ea67be542b0a45b4b493c086c36fef4bb7b18c3a9ed957370083b1b0962f4", + "plugins/acme/assets/icon.png": "71fde3856a298a052c6edb5155638d8422114e55bf0e28374d90dea8be99ef6c", + "plugins/acme/bin/server": "9210d306feecb440d7bd86c82b2ddfae0bb2b706571ed3962f566a7f3f3cf83d", + "plugins/acme/hooks/hooks.json": "9bbe97ed7682f4e1e837309c1c3ba75a7d199931a1a21a0f58c18793b88e73ef", + "plugins/acme/scripts/pluginpack-update-check.sh": "4edae52013092d8ee9212299aefe80e4e91c9bf6fe923bc57daf3efcc4da507e", + "plugins/acme/scripts/run.sh": "a4e0317eafab5cf1bc4a0041c7c8aeb6ece56fe72e7b2b3017a8a6574614cd35", + "plugins/acme/skills/release-notes/SKILL.md": "ab3912f7c2cd1515c291a59d91205364a66b41566095e4202ce8842c59d21b6d", + "plugins/acme/skills/release-notes/references/template.txt": "0528c3e23a7f67ba714dc08d17d0fa4d1d71584c3a52caa2d344d5f030db441a" + } +} diff --git a/tests/golden/default/claude/CONTRIBUTING.md b/tests/golden/default/claude/CONTRIBUTING.md new file mode 100644 index 0000000..d4cfe83 --- /dev/null +++ b/tests/golden/default/claude/CONTRIBUTING.md @@ -0,0 +1 @@ +Edit shared/, not the generated output. diff --git a/tests/golden/default/claude/README.md b/tests/golden/default/claude/README.md new file mode 100644 index 0000000..a459aab --- /dev/null +++ b/tests/golden/default/claude/README.md @@ -0,0 +1,3 @@ +# Golden plugins + +Start by reading the changelog. diff --git a/tests/golden/default/claude/plugins/acme/.claude-plugin/plugin.json b/tests/golden/default/claude/plugins/acme/.claude-plugin/plugin.json new file mode 100644 index 0000000..d156fd2 --- /dev/null +++ b/tests/golden/default/claude/plugins/acme/.claude-plugin/plugin.json @@ -0,0 +1,16 @@ +{ + "name": "acme", + "version": "1.2.4", + "description": "Acme from the source manifest.", + "author": { + "name": "Golden", + "email": "golden@example.com" + }, + "homepage": "https://example.com/golden", + "repository": "https://github.com/example/golden-plugins", + "license": "MIT", + "keywords": [ + "golden", + "test" + ] +} diff --git a/tests/golden/default/claude/plugins/acme/.mcp.json b/tests/golden/default/claude/plugins/acme/.mcp.json new file mode 100644 index 0000000..d467c00 --- /dev/null +++ b/tests/golden/default/claude/plugins/acme/.mcp.json @@ -0,0 +1,18 @@ +{ + "mcpServers": { + "acme": { + "type": "stdio", + "command": "${CLAUDE_PLUGIN_ROOT}/bin/server", + "args": [ + "--stdio" + ], + "env": { + "ACME_MODE": "plugin" + } + }, + "docs": { + "type": "http", + "url": "https://mcp.example.com/mcp" + } + } +} diff --git a/tests/golden/default/claude/plugins/acme/LICENSE b/tests/golden/default/claude/plugins/acme/LICENSE new file mode 100644 index 0000000..a22a2da --- /dev/null +++ b/tests/golden/default/claude/plugins/acme/LICENSE @@ -0,0 +1 @@ +MIT diff --git a/tests/golden/default/claude/plugins/acme/README.md b/tests/golden/default/claude/plugins/acme/README.md new file mode 100644 index 0000000..a417b90 --- /dev/null +++ b/tests/golden/default/claude/plugins/acme/README.md @@ -0,0 +1,3 @@ +# Acme + +Start by reading the changelog. diff --git a/tests/golden/default/claude/plugins/acme/agents/helper.agent.md b/tests/golden/default/claude/plugins/acme/agents/helper.agent.md new file mode 100644 index 0000000..8d32edd --- /dev/null +++ b/tests/golden/default/claude/plugins/acme/agents/helper.agent.md @@ -0,0 +1,6 @@ +--- +name: helper +description: Find documents that answer a question. +--- + +Search and cite sources. diff --git a/tests/golden/default/claude/plugins/acme/assets/icon.png b/tests/golden/default/claude/plugins/acme/assets/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..d1ed0c8713020f5bda68b22792b0512681d2c0f9 GIT binary patch literal 29 ecmeAS@N?(olHy`uVBq!ia0vp^j3CUx1|$JML;~&r literal 0 HcmV?d00001 diff --git a/tests/golden/default/claude/plugins/acme/bin/server b/tests/golden/default/claude/plugins/acme/bin/server new file mode 100644 index 0000000..a43ea4e --- /dev/null +++ b/tests/golden/default/claude/plugins/acme/bin/server @@ -0,0 +1,2 @@ +#!/bin/sh +echo server diff --git a/tests/golden/default/claude/plugins/acme/hooks/hooks.json b/tests/golden/default/claude/plugins/acme/hooks/hooks.json new file mode 100644 index 0000000..89a22d9 --- /dev/null +++ b/tests/golden/default/claude/plugins/acme/hooks/hooks.json @@ -0,0 +1,24 @@ +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "echo ready" + } + ] + }, + { + "matcher": "startup|resume", + "hooks": [ + { + "type": "command", + "command": "sh \"${CLAUDE_PLUGIN_ROOT}/scripts/pluginpack-update-check.sh\"", + "timeout": 15 + } + ] + } + ] + } +} diff --git a/tests/golden/default/claude/plugins/acme/scripts/pluginpack-update-check.sh b/tests/golden/default/claude/plugins/acme/scripts/pluginpack-update-check.sh new file mode 100644 index 0000000..969a117 --- /dev/null +++ b/tests/golden/default/claude/plugins/acme/scripts/pluginpack-update-check.sh @@ -0,0 +1,49 @@ +#!/bin/sh +# Generated by pluginpack — session-start update check for "acme". +# Compares the installed version against the latest git tag of the plugin repo, +# throttled to one network check per 24h (cache shared across plugins from the +# same repo). Fail-open by design: any problem exits 0 with no output. +[ "${PLUGINPACK_NO_UPDATE_CHECK:-}" = "1" ] && exit 0 +[ -n "${CI:-}" ] && exit 0 +command -v git >/dev/null 2>&1 || exit 0 + +INSTALLED='1.2.4' +REPO_URL='https://github.com/example/golden-plugins' + +# Only plain dotted-numeric versions compare meaningfully with sort below. +case "$INSTALLED" in + "" | *[!0-9.]* | .* | *.) exit 0 ;; +esac + +CACHE_DIR="${XDG_CACHE_HOME:-$HOME/.cache}/pluginpack" +KEY=$(printf '%s' "$REPO_URL" | cksum | cut -d ' ' -f 1) || exit 0 +CACHE_FILE="$CACHE_DIR/latest-$KEY" +now=$(date +%s) || exit 0 +latest="" +cached_at=0 +if [ -f "$CACHE_FILE" ]; then + read -r cached_at latest <"$CACHE_FILE" || true +fi +case "$cached_at" in + "" | *[!0-9]*) cached_at=0 ;; +esac +if [ $((now - cached_at)) -ge 86400 ]; then + mkdir -p "$CACHE_DIR" 2>/dev/null || exit 0 + # Timestamp sentinel before fetching, so sibling plugins don't race-fetch. + printf '%s %s\n' "$now" "" >"$CACHE_FILE" 2>/dev/null || true + tags=$(GIT_TERMINAL_PROMPT=0 git -c http.connectTimeout=5 \ + -c http.lowSpeedLimit=1000 -c http.lowSpeedTime=10 \ + ls-remote --tags --refs "$REPO_URL" 2>/dev/null) || exit 0 + # Highest stable semver tag; "v" prefix tolerated, prereleases ignored. + latest=$(printf '%s\n' "$tags" | + sed -n 's#.*refs/tags/v\{0,1\}\([0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\)$#\1#p' | + sort -t . -k 1,1n -k 2,2n -k 3,3n | tail -n 1) + printf '%s %s\n' "$now" "$latest" >"$CACHE_FILE" 2>/dev/null || true +fi +[ -n "$latest" ] || exit 0 +[ "$latest" = "$INSTALLED" ] && exit 0 +newest=$(printf '%s\n%s\n' "$INSTALLED" "$latest" | + sort -t . -k 1,1n -k 2,2n -k 3,3n | tail -n 1) +[ "$newest" = "$latest" ] || exit 0 +printf '{"systemMessage":"Plugin acme v%s is available (installed v%s). Run /plugin update acme to upgrade."}\n' "$latest" "$INSTALLED" +exit 0 diff --git a/tests/golden/default/claude/plugins/acme/scripts/run.sh b/tests/golden/default/claude/plugins/acme/scripts/run.sh new file mode 100644 index 0000000..85ba14d --- /dev/null +++ b/tests/golden/default/claude/plugins/acme/scripts/run.sh @@ -0,0 +1,2 @@ +#!/bin/sh +echo run diff --git a/tests/golden/default/claude/plugins/acme/skills/release-notes/SKILL.md b/tests/golden/default/claude/plugins/acme/skills/release-notes/SKILL.md new file mode 100644 index 0000000..ff888d6 --- /dev/null +++ b/tests/golden/default/claude/plugins/acme/skills/release-notes/SKILL.md @@ -0,0 +1,10 @@ +--- +name: release-notes +description: Draft release notes from a list of changes. +--- + +# Release notes + +Start by reading the changelog. + +Group the changes by feature and fix. diff --git a/tests/golden/default/claude/plugins/acme/skills/release-notes/references/template.txt b/tests/golden/default/claude/plugins/acme/skills/release-notes/references/template.txt new file mode 100644 index 0000000..937d151 --- /dev/null +++ b/tests/golden/default/claude/plugins/acme/skills/release-notes/references/template.txt @@ -0,0 +1,3 @@ +## Features + +## Fixes diff --git a/tests/golden/default/codex/.agents/plugins/marketplace.json b/tests/golden/default/codex/.agents/plugins/marketplace.json new file mode 100644 index 0000000..4eb2380 --- /dev/null +++ b/tests/golden/default/codex/.agents/plugins/marketplace.json @@ -0,0 +1,19 @@ +{ + "name": "golden-plugins", + "interface": { + "displayName": "Golden plugins" + }, + "plugins": [ + { + "name": "acme", + "source": "./plugins/acme", + "description": "Acme from the source manifest.", + "version": "1.2.3", + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Productivity" + } + ] +} diff --git a/tests/golden/default/codex/.pluginpack/codex.json b/tests/golden/default/codex/.pluginpack/codex.json new file mode 100644 index 0000000..6d122fb --- /dev/null +++ b/tests/golden/default/codex/.pluginpack/codex.json @@ -0,0 +1,34 @@ +{ + "version": 2, + "target": "codex", + "files": [ + ".agents/plugins/marketplace.json", + "CONTRIBUTING.md", + "README.md", + "plugins/acme/LICENSE", + "plugins/acme/README.md", + "plugins/acme/assets/icon.png", + "plugins/acme/bin/server", + "plugins/acme/hooks/hooks.json", + "plugins/acme/mcp.json", + "plugins/acme/plugin.json", + "plugins/acme/scripts/run.sh", + "plugins/acme/skills/release-notes/SKILL.md", + "plugins/acme/skills/release-notes/references/template.txt" + ], + "sha256": { + ".agents/plugins/marketplace.json": "c929e0fb6a0376199e20c75b96b7ad544cf1026351445ecd11d6b7e16a559c6d", + "CONTRIBUTING.md": "5d81cc62707c5592ad3fd46205b8b45d3aa25e2a1e6e18d59c14b3e801217bc7", + "README.md": "2e67a1226ce201bd5560b910ec05652241bf38ec364fc639d5173ae433980e5e", + "plugins/acme/LICENSE": "adc37366f403835c1470ab2df93d3837d4719372fc1ef8593d922e06f033f8b2", + "plugins/acme/README.md": "bd8dd9498117b96048242a906bd2112379227d573726b62ea4bbcba214a24469", + "plugins/acme/assets/icon.png": "71fde3856a298a052c6edb5155638d8422114e55bf0e28374d90dea8be99ef6c", + "plugins/acme/bin/server": "9210d306feecb440d7bd86c82b2ddfae0bb2b706571ed3962f566a7f3f3cf83d", + "plugins/acme/hooks/hooks.json": "ffa7f7bd137c04c26f889c25e3ea9cad84596c619f55a2f6324369c9a413c94d", + "plugins/acme/mcp.json": "f6d7a3e17bd9f611b74858306a38f4904a0dba89da8e069a9e09ddadc6cd7b73", + "plugins/acme/plugin.json": "6399ba0f48ba6f10251c99950d900933d33a03a7cb158afaec0182f8499fd753", + "plugins/acme/scripts/run.sh": "a4e0317eafab5cf1bc4a0041c7c8aeb6ece56fe72e7b2b3017a8a6574614cd35", + "plugins/acme/skills/release-notes/SKILL.md": "91a5dad01d643b10c4f1e413bbab93e6f97d0aa213fa83ada8a26b0063670ddd", + "plugins/acme/skills/release-notes/references/template.txt": "0528c3e23a7f67ba714dc08d17d0fa4d1d71584c3a52caa2d344d5f030db441a" + } +} diff --git a/tests/golden/default/codex/CONTRIBUTING.md b/tests/golden/default/codex/CONTRIBUTING.md new file mode 100644 index 0000000..d4cfe83 --- /dev/null +++ b/tests/golden/default/codex/CONTRIBUTING.md @@ -0,0 +1 @@ +Edit shared/, not the generated output. diff --git a/tests/golden/default/codex/README.md b/tests/golden/default/codex/README.md new file mode 100644 index 0000000..a459aab --- /dev/null +++ b/tests/golden/default/codex/README.md @@ -0,0 +1,3 @@ +# Golden plugins + +Start by reading the changelog. diff --git a/tests/golden/default/codex/plugins/acme/LICENSE b/tests/golden/default/codex/plugins/acme/LICENSE new file mode 100644 index 0000000..a22a2da --- /dev/null +++ b/tests/golden/default/codex/plugins/acme/LICENSE @@ -0,0 +1 @@ +MIT diff --git a/tests/golden/default/codex/plugins/acme/README.md b/tests/golden/default/codex/plugins/acme/README.md new file mode 100644 index 0000000..a417b90 --- /dev/null +++ b/tests/golden/default/codex/plugins/acme/README.md @@ -0,0 +1,3 @@ +# Acme + +Start by reading the changelog. diff --git a/tests/golden/default/codex/plugins/acme/assets/icon.png b/tests/golden/default/codex/plugins/acme/assets/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..d1ed0c8713020f5bda68b22792b0512681d2c0f9 GIT binary patch literal 29 ecmeAS@N?(olHy`uVBq!ia0vp^j3CUx1|$JML;~&r literal 0 HcmV?d00001 diff --git a/tests/golden/default/codex/plugins/acme/bin/server b/tests/golden/default/codex/plugins/acme/bin/server new file mode 100644 index 0000000..a43ea4e --- /dev/null +++ b/tests/golden/default/codex/plugins/acme/bin/server @@ -0,0 +1,2 @@ +#!/bin/sh +echo server diff --git a/tests/golden/default/codex/plugins/acme/hooks/hooks.json b/tests/golden/default/codex/plugins/acme/hooks/hooks.json new file mode 100644 index 0000000..fd02484 --- /dev/null +++ b/tests/golden/default/codex/plugins/acme/hooks/hooks.json @@ -0,0 +1,14 @@ +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "echo ready" + } + ] + } + ] + } +} diff --git a/tests/golden/default/codex/plugins/acme/mcp.json b/tests/golden/default/codex/plugins/acme/mcp.json new file mode 100644 index 0000000..4417b24 --- /dev/null +++ b/tests/golden/default/codex/plugins/acme/mcp.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json", + "mcpServers": { + "acme": { + "type": "stdio", + "command": "./bin/server", + "args": [ + "--stdio" + ], + "env": { + "ACME_MODE": "plugin" + } + }, + "docs": { + "type": "streamable-http", + "url": "https://mcp.example.com/mcp" + } + } +} diff --git a/tests/golden/default/codex/plugins/acme/plugin.json b/tests/golden/default/codex/plugins/acme/plugin.json new file mode 100644 index 0000000..8074b81 --- /dev/null +++ b/tests/golden/default/codex/plugins/acme/plugin.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + "name": "acme", + "version": "1.2.3", + "description": "Acme from the source manifest.", + "author": { + "name": "Golden", + "email": "golden@example.com" + }, + "homepage": "https://example.com/golden", + "repository": "https://github.com/example/golden-plugins", + "license": "MIT", + "keywords": [ + "golden", + "test" + ], + "extensions": { + "com.openai": { + "interface": { + "displayName": "Acme", + "category": "Productivity" + } + } + } +} diff --git a/tests/golden/default/codex/plugins/acme/scripts/run.sh b/tests/golden/default/codex/plugins/acme/scripts/run.sh new file mode 100644 index 0000000..85ba14d --- /dev/null +++ b/tests/golden/default/codex/plugins/acme/scripts/run.sh @@ -0,0 +1,2 @@ +#!/bin/sh +echo run diff --git a/tests/golden/default/codex/plugins/acme/skills/release-notes/SKILL.md b/tests/golden/default/codex/plugins/acme/skills/release-notes/SKILL.md new file mode 100644 index 0000000..1078736 --- /dev/null +++ b/tests/golden/default/codex/plugins/acme/skills/release-notes/SKILL.md @@ -0,0 +1,10 @@ +--- +name: release-notes +description: Draft release notes from a list of changes. +--- + +# Release notes + +Start by reading the changelog. + +In Codex, group the changes by feature and fix. diff --git a/tests/golden/default/codex/plugins/acme/skills/release-notes/references/template.txt b/tests/golden/default/codex/plugins/acme/skills/release-notes/references/template.txt new file mode 100644 index 0000000..937d151 --- /dev/null +++ b/tests/golden/default/codex/plugins/acme/skills/release-notes/references/template.txt @@ -0,0 +1,3 @@ +## Features + +## Fixes diff --git a/tests/golden/default/copilot/.claude-plugin/marketplace.json b/tests/golden/default/copilot/.claude-plugin/marketplace.json new file mode 100644 index 0000000..b162ee8 --- /dev/null +++ b/tests/golden/default/copilot/.claude-plugin/marketplace.json @@ -0,0 +1,26 @@ +{ + "name": "golden-plugins", + "metadata": { + "description": "Plugins whose output is pinned byte for byte.", + "version": "1.2.3", + "keywords": [ + "golden", + "test" + ] + }, + "owner": { + "name": "Golden" + }, + "plugins": [ + { + "name": "acme", + "source": "./plugins/acme", + "description": "Acme for Copilot.", + "version": "1.2.3", + "skills": [ + "./skills/release-notes" + ], + "mcpServers": ".mcp.json" + } + ] +} diff --git a/tests/golden/default/copilot/.github/plugin/marketplace.json b/tests/golden/default/copilot/.github/plugin/marketplace.json new file mode 100644 index 0000000..b162ee8 --- /dev/null +++ b/tests/golden/default/copilot/.github/plugin/marketplace.json @@ -0,0 +1,26 @@ +{ + "name": "golden-plugins", + "metadata": { + "description": "Plugins whose output is pinned byte for byte.", + "version": "1.2.3", + "keywords": [ + "golden", + "test" + ] + }, + "owner": { + "name": "Golden" + }, + "plugins": [ + { + "name": "acme", + "source": "./plugins/acme", + "description": "Acme for Copilot.", + "version": "1.2.3", + "skills": [ + "./skills/release-notes" + ], + "mcpServers": ".mcp.json" + } + ] +} diff --git a/tests/golden/default/copilot/.pluginpack/copilot.json b/tests/golden/default/copilot/.pluginpack/copilot.json new file mode 100644 index 0000000..e427fdb --- /dev/null +++ b/tests/golden/default/copilot/.pluginpack/copilot.json @@ -0,0 +1,40 @@ +{ + "version": 2, + "target": "copilot", + "files": [ + ".claude-plugin/marketplace.json", + ".github/plugin/marketplace.json", + "CONTRIBUTING.md", + "README.md", + "plugins/acme/.github/plugin/plugin.json", + "plugins/acme/.mcp.json", + "plugins/acme/LICENSE", + "plugins/acme/README.md", + "plugins/acme/agents/helper.agent.md", + "plugins/acme/assets/icon.png", + "plugins/acme/bin/server", + "plugins/acme/hooks/hooks.json", + "plugins/acme/plugin.json", + "plugins/acme/scripts/run.sh", + "plugins/acme/skills/release-notes/SKILL.md", + "plugins/acme/skills/release-notes/references/template.txt" + ], + "sha256": { + ".claude-plugin/marketplace.json": "7959535c7d4f15dbbe875f2a68297900bb16daacacd7c4b33a861040dddde2ac", + ".github/plugin/marketplace.json": "7959535c7d4f15dbbe875f2a68297900bb16daacacd7c4b33a861040dddde2ac", + "CONTRIBUTING.md": "5d81cc62707c5592ad3fd46205b8b45d3aa25e2a1e6e18d59c14b3e801217bc7", + "README.md": "2e67a1226ce201bd5560b910ec05652241bf38ec364fc639d5173ae433980e5e", + "plugins/acme/.github/plugin/plugin.json": "82aa29a8d5cb896860bf6fd92cf42bd498dec846c93943d27681c7c52085ec2b", + "plugins/acme/.mcp.json": "ca303b1525ecca62ef783a67180f84ea8f58665bb132c0db2e8c78a9e9b02105", + "plugins/acme/LICENSE": "adc37366f403835c1470ab2df93d3837d4719372fc1ef8593d922e06f033f8b2", + "plugins/acme/README.md": "bd8dd9498117b96048242a906bd2112379227d573726b62ea4bbcba214a24469", + "plugins/acme/agents/helper.agent.md": "f98ea67be542b0a45b4b493c086c36fef4bb7b18c3a9ed957370083b1b0962f4", + "plugins/acme/assets/icon.png": "71fde3856a298a052c6edb5155638d8422114e55bf0e28374d90dea8be99ef6c", + "plugins/acme/bin/server": "9210d306feecb440d7bd86c82b2ddfae0bb2b706571ed3962f566a7f3f3cf83d", + "plugins/acme/hooks/hooks.json": "ffa7f7bd137c04c26f889c25e3ea9cad84596c619f55a2f6324369c9a413c94d", + "plugins/acme/plugin.json": "82aa29a8d5cb896860bf6fd92cf42bd498dec846c93943d27681c7c52085ec2b", + "plugins/acme/scripts/run.sh": "a4e0317eafab5cf1bc4a0041c7c8aeb6ece56fe72e7b2b3017a8a6574614cd35", + "plugins/acme/skills/release-notes/SKILL.md": "ab3912f7c2cd1515c291a59d91205364a66b41566095e4202ce8842c59d21b6d", + "plugins/acme/skills/release-notes/references/template.txt": "0528c3e23a7f67ba714dc08d17d0fa4d1d71584c3a52caa2d344d5f030db441a" + } +} diff --git a/tests/golden/default/copilot/CONTRIBUTING.md b/tests/golden/default/copilot/CONTRIBUTING.md new file mode 100644 index 0000000..d4cfe83 --- /dev/null +++ b/tests/golden/default/copilot/CONTRIBUTING.md @@ -0,0 +1 @@ +Edit shared/, not the generated output. diff --git a/tests/golden/default/copilot/README.md b/tests/golden/default/copilot/README.md new file mode 100644 index 0000000..a459aab --- /dev/null +++ b/tests/golden/default/copilot/README.md @@ -0,0 +1,3 @@ +# Golden plugins + +Start by reading the changelog. diff --git a/tests/golden/default/copilot/plugins/acme/.github/plugin/plugin.json b/tests/golden/default/copilot/plugins/acme/.github/plugin/plugin.json new file mode 100644 index 0000000..03aeddd --- /dev/null +++ b/tests/golden/default/copilot/plugins/acme/.github/plugin/plugin.json @@ -0,0 +1,21 @@ +{ + "name": "acme", + "version": "1.2.3", + "description": "Acme for Copilot.", + "author": { + "name": "Golden", + "email": "golden@example.com" + }, + "homepage": "https://example.com/golden", + "repository": "https://github.com/example/golden-plugins", + "license": "MIT", + "keywords": [ + "golden", + "test" + ], + "category": "Productivity", + "agents": "agents/", + "skills": "skills/", + "hooks": "hooks/hooks.json", + "mcpServers": ".mcp.json" +} diff --git a/tests/golden/default/copilot/plugins/acme/.mcp.json b/tests/golden/default/copilot/plugins/acme/.mcp.json new file mode 100644 index 0000000..730fed8 --- /dev/null +++ b/tests/golden/default/copilot/plugins/acme/.mcp.json @@ -0,0 +1,18 @@ +{ + "mcpServers": { + "acme": { + "type": "stdio", + "command": "${PLUGIN_ROOT}/bin/server", + "args": [ + "--stdio" + ], + "env": { + "ACME_MODE": "plugin" + } + }, + "docs": { + "type": "http", + "url": "https://mcp.example.com/mcp" + } + } +} diff --git a/tests/golden/default/copilot/plugins/acme/LICENSE b/tests/golden/default/copilot/plugins/acme/LICENSE new file mode 100644 index 0000000..a22a2da --- /dev/null +++ b/tests/golden/default/copilot/plugins/acme/LICENSE @@ -0,0 +1 @@ +MIT diff --git a/tests/golden/default/copilot/plugins/acme/README.md b/tests/golden/default/copilot/plugins/acme/README.md new file mode 100644 index 0000000..a417b90 --- /dev/null +++ b/tests/golden/default/copilot/plugins/acme/README.md @@ -0,0 +1,3 @@ +# Acme + +Start by reading the changelog. diff --git a/tests/golden/default/copilot/plugins/acme/agents/helper.agent.md b/tests/golden/default/copilot/plugins/acme/agents/helper.agent.md new file mode 100644 index 0000000..8d32edd --- /dev/null +++ b/tests/golden/default/copilot/plugins/acme/agents/helper.agent.md @@ -0,0 +1,6 @@ +--- +name: helper +description: Find documents that answer a question. +--- + +Search and cite sources. diff --git a/tests/golden/default/copilot/plugins/acme/assets/icon.png b/tests/golden/default/copilot/plugins/acme/assets/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..d1ed0c8713020f5bda68b22792b0512681d2c0f9 GIT binary patch literal 29 ecmeAS@N?(olHy`uVBq!ia0vp^j3CUx1|$JML;~&r literal 0 HcmV?d00001 diff --git a/tests/golden/default/copilot/plugins/acme/bin/server b/tests/golden/default/copilot/plugins/acme/bin/server new file mode 100644 index 0000000..a43ea4e --- /dev/null +++ b/tests/golden/default/copilot/plugins/acme/bin/server @@ -0,0 +1,2 @@ +#!/bin/sh +echo server diff --git a/tests/golden/default/copilot/plugins/acme/hooks/hooks.json b/tests/golden/default/copilot/plugins/acme/hooks/hooks.json new file mode 100644 index 0000000..fd02484 --- /dev/null +++ b/tests/golden/default/copilot/plugins/acme/hooks/hooks.json @@ -0,0 +1,14 @@ +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "echo ready" + } + ] + } + ] + } +} diff --git a/tests/golden/default/copilot/plugins/acme/plugin.json b/tests/golden/default/copilot/plugins/acme/plugin.json new file mode 100644 index 0000000..03aeddd --- /dev/null +++ b/tests/golden/default/copilot/plugins/acme/plugin.json @@ -0,0 +1,21 @@ +{ + "name": "acme", + "version": "1.2.3", + "description": "Acme for Copilot.", + "author": { + "name": "Golden", + "email": "golden@example.com" + }, + "homepage": "https://example.com/golden", + "repository": "https://github.com/example/golden-plugins", + "license": "MIT", + "keywords": [ + "golden", + "test" + ], + "category": "Productivity", + "agents": "agents/", + "skills": "skills/", + "hooks": "hooks/hooks.json", + "mcpServers": ".mcp.json" +} diff --git a/tests/golden/default/copilot/plugins/acme/scripts/run.sh b/tests/golden/default/copilot/plugins/acme/scripts/run.sh new file mode 100644 index 0000000..85ba14d --- /dev/null +++ b/tests/golden/default/copilot/plugins/acme/scripts/run.sh @@ -0,0 +1,2 @@ +#!/bin/sh +echo run diff --git a/tests/golden/default/copilot/plugins/acme/skills/release-notes/SKILL.md b/tests/golden/default/copilot/plugins/acme/skills/release-notes/SKILL.md new file mode 100644 index 0000000..ff888d6 --- /dev/null +++ b/tests/golden/default/copilot/plugins/acme/skills/release-notes/SKILL.md @@ -0,0 +1,10 @@ +--- +name: release-notes +description: Draft release notes from a list of changes. +--- + +# Release notes + +Start by reading the changelog. + +Group the changes by feature and fix. diff --git a/tests/golden/default/copilot/plugins/acme/skills/release-notes/references/template.txt b/tests/golden/default/copilot/plugins/acme/skills/release-notes/references/template.txt new file mode 100644 index 0000000..937d151 --- /dev/null +++ b/tests/golden/default/copilot/plugins/acme/skills/release-notes/references/template.txt @@ -0,0 +1,3 @@ +## Features + +## Fixes diff --git a/tests/golden/default/cursor/.cursor-plugin/marketplace.json b/tests/golden/default/cursor/.cursor-plugin/marketplace.json new file mode 100644 index 0000000..cd0481e --- /dev/null +++ b/tests/golden/default/cursor/.cursor-plugin/marketplace.json @@ -0,0 +1,21 @@ +{ + "name": "golden-plugins", + "owner": { + "name": "Golden" + }, + "metadata": { + "description": "Plugins whose output is pinned byte for byte.", + "keywords": [ + "golden", + "test" + ] + }, + "plugins": [ + { + "name": "acme", + "source": "plugins/acme", + "description": "Acme from the source manifest." + } + ], + "version": "1.2.3" +} diff --git a/tests/golden/default/cursor/.pluginpack/cursor.json b/tests/golden/default/cursor/.pluginpack/cursor.json new file mode 100644 index 0000000..0575863 --- /dev/null +++ b/tests/golden/default/cursor/.pluginpack/cursor.json @@ -0,0 +1,42 @@ +{ + "version": 2, + "target": "cursor", + "files": [ + ".cursor-plugin/marketplace.json", + "CONTRIBUTING.md", + "README.md", + "plugins/acme/.cursor-plugin/plugin.json", + "plugins/acme/.mcp.json", + "plugins/acme/LICENSE", + "plugins/acme/README.md", + "plugins/acme/agents/helper.agent.md", + "plugins/acme/assets/icon.png", + "plugins/acme/bin/server", + "plugins/acme/hooks/hooks.json", + "plugins/acme/rules/cursor-only.mdc", + "plugins/acme/rules/style.mdc", + "plugins/acme/scripts/pluginpack-update-check.sh", + "plugins/acme/scripts/run.sh", + "plugins/acme/skills/release-notes/SKILL.md", + "plugins/acme/skills/release-notes/references/template.txt" + ], + "sha256": { + ".cursor-plugin/marketplace.json": "b36379764a5a320c8c59cbec1194be620032d8929286ab3239e5f45222a04317", + "CONTRIBUTING.md": "5d81cc62707c5592ad3fd46205b8b45d3aa25e2a1e6e18d59c14b3e801217bc7", + "README.md": "2e67a1226ce201bd5560b910ec05652241bf38ec364fc639d5173ae433980e5e", + "plugins/acme/.cursor-plugin/plugin.json": "17110335b7450c7c195abf50702584def0eaf68c38629976b70600959cc5c6a2", + "plugins/acme/.mcp.json": "e5eb8a5785f1395b17759a0519533736ab96cdce9430a2be7eba63f0d806b98f", + "plugins/acme/LICENSE": "adc37366f403835c1470ab2df93d3837d4719372fc1ef8593d922e06f033f8b2", + "plugins/acme/README.md": "bd8dd9498117b96048242a906bd2112379227d573726b62ea4bbcba214a24469", + "plugins/acme/agents/helper.agent.md": "f98ea67be542b0a45b4b493c086c36fef4bb7b18c3a9ed957370083b1b0962f4", + "plugins/acme/assets/icon.png": "71fde3856a298a052c6edb5155638d8422114e55bf0e28374d90dea8be99ef6c", + "plugins/acme/bin/server": "9210d306feecb440d7bd86c82b2ddfae0bb2b706571ed3962f566a7f3f3cf83d", + "plugins/acme/hooks/hooks.json": "2964500e86d837c20982917061fdf2972946b893c682779f1677aafb1afd9b6d", + "plugins/acme/rules/cursor-only.mdc": "e735f55eb2c55458eeb055e07462474bb95d4a0052efecf687a4248938875821", + "plugins/acme/rules/style.mdc": "8d5532f408bbf1d24877ce3afb4cdf15114628ab98235787d1957bfe425546ee", + "plugins/acme/scripts/pluginpack-update-check.sh": "555083e54f172c755b824e825890ae87841ce27154717508ad18ed0274a84ed3", + "plugins/acme/scripts/run.sh": "a4e0317eafab5cf1bc4a0041c7c8aeb6ece56fe72e7b2b3017a8a6574614cd35", + "plugins/acme/skills/release-notes/SKILL.md": "ab3912f7c2cd1515c291a59d91205364a66b41566095e4202ce8842c59d21b6d", + "plugins/acme/skills/release-notes/references/template.txt": "0528c3e23a7f67ba714dc08d17d0fa4d1d71584c3a52caa2d344d5f030db441a" + } +} diff --git a/tests/golden/default/cursor/CONTRIBUTING.md b/tests/golden/default/cursor/CONTRIBUTING.md new file mode 100644 index 0000000..d4cfe83 --- /dev/null +++ b/tests/golden/default/cursor/CONTRIBUTING.md @@ -0,0 +1 @@ +Edit shared/, not the generated output. diff --git a/tests/golden/default/cursor/README.md b/tests/golden/default/cursor/README.md new file mode 100644 index 0000000..a459aab --- /dev/null +++ b/tests/golden/default/cursor/README.md @@ -0,0 +1,3 @@ +# Golden plugins + +Start by reading the changelog. diff --git a/tests/golden/default/cursor/plugins/acme/.cursor-plugin/plugin.json b/tests/golden/default/cursor/plugins/acme/.cursor-plugin/plugin.json new file mode 100644 index 0000000..1c601af --- /dev/null +++ b/tests/golden/default/cursor/plugins/acme/.cursor-plugin/plugin.json @@ -0,0 +1,23 @@ +{ + "name": "acme", + "displayName": "Acme", + "version": "1.2.3", + "description": "Acme from the source manifest.", + "author": { + "name": "Golden", + "email": "golden@example.com" + }, + "homepage": "https://example.com/golden", + "repository": "https://github.com/example/golden-plugins", + "license": "MIT", + "keywords": [ + "golden", + "test" + ], + "category": "Productivity", + "rules": "./rules/", + "agents": "./agents/", + "skills": "./skills/", + "hooks": "./hooks/", + "mcpServers": "./.mcp.json" +} diff --git a/tests/golden/default/cursor/plugins/acme/.mcp.json b/tests/golden/default/cursor/plugins/acme/.mcp.json new file mode 100644 index 0000000..dd3c805 --- /dev/null +++ b/tests/golden/default/cursor/plugins/acme/.mcp.json @@ -0,0 +1,16 @@ +{ + "mcpServers": { + "acme": { + "command": "${CURSOR_PLUGIN_ROOT}/bin/server", + "args": [ + "--stdio" + ], + "env": { + "ACME_MODE": "plugin" + } + }, + "docs": { + "url": "https://mcp.example.com/mcp" + } + } +} diff --git a/tests/golden/default/cursor/plugins/acme/LICENSE b/tests/golden/default/cursor/plugins/acme/LICENSE new file mode 100644 index 0000000..a22a2da --- /dev/null +++ b/tests/golden/default/cursor/plugins/acme/LICENSE @@ -0,0 +1 @@ +MIT diff --git a/tests/golden/default/cursor/plugins/acme/README.md b/tests/golden/default/cursor/plugins/acme/README.md new file mode 100644 index 0000000..a417b90 --- /dev/null +++ b/tests/golden/default/cursor/plugins/acme/README.md @@ -0,0 +1,3 @@ +# Acme + +Start by reading the changelog. diff --git a/tests/golden/default/cursor/plugins/acme/agents/helper.agent.md b/tests/golden/default/cursor/plugins/acme/agents/helper.agent.md new file mode 100644 index 0000000..8d32edd --- /dev/null +++ b/tests/golden/default/cursor/plugins/acme/agents/helper.agent.md @@ -0,0 +1,6 @@ +--- +name: helper +description: Find documents that answer a question. +--- + +Search and cite sources. diff --git a/tests/golden/default/cursor/plugins/acme/assets/icon.png b/tests/golden/default/cursor/plugins/acme/assets/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..d1ed0c8713020f5bda68b22792b0512681d2c0f9 GIT binary patch literal 29 ecmeAS@N?(olHy`uVBq!ia0vp^j3CUx1|$JML;~&r literal 0 HcmV?d00001 diff --git a/tests/golden/default/cursor/plugins/acme/bin/server b/tests/golden/default/cursor/plugins/acme/bin/server new file mode 100644 index 0000000..a43ea4e --- /dev/null +++ b/tests/golden/default/cursor/plugins/acme/bin/server @@ -0,0 +1,2 @@ +#!/bin/sh +echo server diff --git a/tests/golden/default/cursor/plugins/acme/hooks/hooks.json b/tests/golden/default/cursor/plugins/acme/hooks/hooks.json new file mode 100644 index 0000000..5468a83 --- /dev/null +++ b/tests/golden/default/cursor/plugins/acme/hooks/hooks.json @@ -0,0 +1,21 @@ +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "echo ready" + } + ] + } + ], + "sessionStart": [ + { + "command": "sh ./scripts/pluginpack-update-check.sh", + "timeout": 15 + } + ] + }, + "version": 1 +} diff --git a/tests/golden/default/cursor/plugins/acme/rules/cursor-only.mdc b/tests/golden/default/cursor/plugins/acme/rules/cursor-only.mdc new file mode 100644 index 0000000..8afa401 --- /dev/null +++ b/tests/golden/default/cursor/plugins/acme/rules/cursor-only.mdc @@ -0,0 +1,6 @@ +--- +description: Cursor only. +alwaysApply: true +--- + +Only in Cursor. diff --git a/tests/golden/default/cursor/plugins/acme/rules/style.mdc b/tests/golden/default/cursor/plugins/acme/rules/style.mdc new file mode 100644 index 0000000..47b1a62 --- /dev/null +++ b/tests/golden/default/cursor/plugins/acme/rules/style.mdc @@ -0,0 +1,6 @@ +--- +description: House style. +alwaysApply: false +--- + +Use short sentences. diff --git a/tests/golden/default/cursor/plugins/acme/scripts/pluginpack-update-check.sh b/tests/golden/default/cursor/plugins/acme/scripts/pluginpack-update-check.sh new file mode 100644 index 0000000..e095e34 --- /dev/null +++ b/tests/golden/default/cursor/plugins/acme/scripts/pluginpack-update-check.sh @@ -0,0 +1,49 @@ +#!/bin/sh +# Generated by pluginpack — session-start update check for "acme". +# Compares the installed version against the latest git tag of the plugin repo, +# throttled to one network check per 24h (cache shared across plugins from the +# same repo). Fail-open by design: any problem exits 0 with no output. +[ "${PLUGINPACK_NO_UPDATE_CHECK:-}" = "1" ] && exit 0 +[ -n "${CI:-}" ] && exit 0 +command -v git >/dev/null 2>&1 || exit 0 + +INSTALLED='1.2.3' +REPO_URL='https://github.com/example/golden-plugins' + +# Only plain dotted-numeric versions compare meaningfully with sort below. +case "$INSTALLED" in + "" | *[!0-9.]* | .* | *.) exit 0 ;; +esac + +CACHE_DIR="${XDG_CACHE_HOME:-$HOME/.cache}/pluginpack" +KEY=$(printf '%s' "$REPO_URL" | cksum | cut -d ' ' -f 1) || exit 0 +CACHE_FILE="$CACHE_DIR/latest-$KEY" +now=$(date +%s) || exit 0 +latest="" +cached_at=0 +if [ -f "$CACHE_FILE" ]; then + read -r cached_at latest <"$CACHE_FILE" || true +fi +case "$cached_at" in + "" | *[!0-9]*) cached_at=0 ;; +esac +if [ $((now - cached_at)) -ge 86400 ]; then + mkdir -p "$CACHE_DIR" 2>/dev/null || exit 0 + # Timestamp sentinel before fetching, so sibling plugins don't race-fetch. + printf '%s %s\n' "$now" "" >"$CACHE_FILE" 2>/dev/null || true + tags=$(GIT_TERMINAL_PROMPT=0 git -c http.connectTimeout=5 \ + -c http.lowSpeedLimit=1000 -c http.lowSpeedTime=10 \ + ls-remote --tags --refs "$REPO_URL" 2>/dev/null) || exit 0 + # Highest stable semver tag; "v" prefix tolerated, prereleases ignored. + latest=$(printf '%s\n' "$tags" | + sed -n 's#.*refs/tags/v\{0,1\}\([0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\)$#\1#p' | + sort -t . -k 1,1n -k 2,2n -k 3,3n | tail -n 1) + printf '%s %s\n' "$now" "$latest" >"$CACHE_FILE" 2>/dev/null || true +fi +[ -n "$latest" ] || exit 0 +[ "$latest" = "$INSTALLED" ] && exit 0 +newest=$(printf '%s\n%s\n' "$INSTALLED" "$latest" | + sort -t . -k 1,1n -k 2,2n -k 3,3n | tail -n 1) +[ "$newest" = "$latest" ] || exit 0 +printf '{"additional_context":"The installed Cursor plugin \\"acme\\" is outdated (installed v%s, latest v%s). Briefly let the user know they can update it from Cursor'\''s plugin settings."}\n' "$INSTALLED" "$latest" +exit 0 diff --git a/tests/golden/default/cursor/plugins/acme/scripts/run.sh b/tests/golden/default/cursor/plugins/acme/scripts/run.sh new file mode 100644 index 0000000..85ba14d --- /dev/null +++ b/tests/golden/default/cursor/plugins/acme/scripts/run.sh @@ -0,0 +1,2 @@ +#!/bin/sh +echo run diff --git a/tests/golden/default/cursor/plugins/acme/skills/release-notes/SKILL.md b/tests/golden/default/cursor/plugins/acme/skills/release-notes/SKILL.md new file mode 100644 index 0000000..ff888d6 --- /dev/null +++ b/tests/golden/default/cursor/plugins/acme/skills/release-notes/SKILL.md @@ -0,0 +1,10 @@ +--- +name: release-notes +description: Draft release notes from a list of changes. +--- + +# Release notes + +Start by reading the changelog. + +Group the changes by feature and fix. diff --git a/tests/golden/default/cursor/plugins/acme/skills/release-notes/references/template.txt b/tests/golden/default/cursor/plugins/acme/skills/release-notes/references/template.txt new file mode 100644 index 0000000..937d151 --- /dev/null +++ b/tests/golden/default/cursor/plugins/acme/skills/release-notes/references/template.txt @@ -0,0 +1,3 @@ +## Features + +## Fixes diff --git a/tests/golden/variants/agent-plugins/.pluginpack/agent-plugins.json b/tests/golden/variants/agent-plugins/.pluginpack/agent-plugins.json new file mode 100644 index 0000000..55ddb9d --- /dev/null +++ b/tests/golden/variants/agent-plugins/.pluginpack/agent-plugins.json @@ -0,0 +1,26 @@ +{ + "version": 2, + "target": "agent-plugins", + "files": [ + "plugins/acme/LICENSE", + "plugins/acme/README.md", + "plugins/acme/assets/icon.png", + "plugins/acme/bin/server", + "plugins/acme/mcp.json", + "plugins/acme/plugin.json", + "plugins/acme/scripts/run.sh", + "plugins/acme/skills/release-notes/SKILL.md", + "plugins/acme/skills/release-notes/references/template.txt" + ], + "sha256": { + "plugins/acme/LICENSE": "adc37366f403835c1470ab2df93d3837d4719372fc1ef8593d922e06f033f8b2", + "plugins/acme/README.md": "bd8dd9498117b96048242a906bd2112379227d573726b62ea4bbcba214a24469", + "plugins/acme/assets/icon.png": "71fde3856a298a052c6edb5155638d8422114e55bf0e28374d90dea8be99ef6c", + "plugins/acme/bin/server": "9210d306feecb440d7bd86c82b2ddfae0bb2b706571ed3962f566a7f3f3cf83d", + "plugins/acme/mcp.json": "f6d7a3e17bd9f611b74858306a38f4904a0dba89da8e069a9e09ddadc6cd7b73", + "plugins/acme/plugin.json": "656661b644afd8b9960fa5ee047a25a3304dc9cde25abf630448dffdf828f777", + "plugins/acme/scripts/run.sh": "a4e0317eafab5cf1bc4a0041c7c8aeb6ece56fe72e7b2b3017a8a6574614cd35", + "plugins/acme/skills/release-notes/SKILL.md": "ab3912f7c2cd1515c291a59d91205364a66b41566095e4202ce8842c59d21b6d", + "plugins/acme/skills/release-notes/references/template.txt": "0528c3e23a7f67ba714dc08d17d0fa4d1d71584c3a52caa2d344d5f030db441a" + } +} diff --git a/tests/golden/variants/agent-plugins/plugins/acme/LICENSE b/tests/golden/variants/agent-plugins/plugins/acme/LICENSE new file mode 100644 index 0000000..a22a2da --- /dev/null +++ b/tests/golden/variants/agent-plugins/plugins/acme/LICENSE @@ -0,0 +1 @@ +MIT diff --git a/tests/golden/variants/agent-plugins/plugins/acme/README.md b/tests/golden/variants/agent-plugins/plugins/acme/README.md new file mode 100644 index 0000000..a417b90 --- /dev/null +++ b/tests/golden/variants/agent-plugins/plugins/acme/README.md @@ -0,0 +1,3 @@ +# Acme + +Start by reading the changelog. diff --git a/tests/golden/variants/agent-plugins/plugins/acme/assets/icon.png b/tests/golden/variants/agent-plugins/plugins/acme/assets/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..d1ed0c8713020f5bda68b22792b0512681d2c0f9 GIT binary patch literal 29 ecmeAS@N?(olHy`uVBq!ia0vp^j3CUx1|$JML;~&r literal 0 HcmV?d00001 diff --git a/tests/golden/variants/agent-plugins/plugins/acme/bin/server b/tests/golden/variants/agent-plugins/plugins/acme/bin/server new file mode 100644 index 0000000..a43ea4e --- /dev/null +++ b/tests/golden/variants/agent-plugins/plugins/acme/bin/server @@ -0,0 +1,2 @@ +#!/bin/sh +echo server diff --git a/tests/golden/variants/agent-plugins/plugins/acme/mcp.json b/tests/golden/variants/agent-plugins/plugins/acme/mcp.json new file mode 100644 index 0000000..4417b24 --- /dev/null +++ b/tests/golden/variants/agent-plugins/plugins/acme/mcp.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json", + "mcpServers": { + "acme": { + "type": "stdio", + "command": "./bin/server", + "args": [ + "--stdio" + ], + "env": { + "ACME_MODE": "plugin" + } + }, + "docs": { + "type": "streamable-http", + "url": "https://mcp.example.com/mcp" + } + } +} diff --git a/tests/golden/variants/agent-plugins/plugins/acme/plugin.json b/tests/golden/variants/agent-plugins/plugins/acme/plugin.json new file mode 100644 index 0000000..8587c8b --- /dev/null +++ b/tests/golden/variants/agent-plugins/plugins/acme/plugin.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + "name": "acme", + "version": "1.2.3", + "description": "Acme from the source manifest.", + "author": { + "name": "Golden", + "email": "golden@example.com" + }, + "homepage": "https://example.com/golden", + "repository": "https://github.com/example/golden-plugins", + "license": "MIT", + "keywords": [ + "golden", + "test" + ] +} diff --git a/tests/golden/variants/agent-plugins/plugins/acme/scripts/run.sh b/tests/golden/variants/agent-plugins/plugins/acme/scripts/run.sh new file mode 100644 index 0000000..85ba14d --- /dev/null +++ b/tests/golden/variants/agent-plugins/plugins/acme/scripts/run.sh @@ -0,0 +1,2 @@ +#!/bin/sh +echo run diff --git a/tests/golden/variants/agent-plugins/plugins/acme/skills/release-notes/SKILL.md b/tests/golden/variants/agent-plugins/plugins/acme/skills/release-notes/SKILL.md new file mode 100644 index 0000000..ff888d6 --- /dev/null +++ b/tests/golden/variants/agent-plugins/plugins/acme/skills/release-notes/SKILL.md @@ -0,0 +1,10 @@ +--- +name: release-notes +description: Draft release notes from a list of changes. +--- + +# Release notes + +Start by reading the changelog. + +Group the changes by feature and fix. diff --git a/tests/golden/variants/agent-plugins/plugins/acme/skills/release-notes/references/template.txt b/tests/golden/variants/agent-plugins/plugins/acme/skills/release-notes/references/template.txt new file mode 100644 index 0000000..937d151 --- /dev/null +++ b/tests/golden/variants/agent-plugins/plugins/acme/skills/release-notes/references/template.txt @@ -0,0 +1,3 @@ +## Features + +## Fixes diff --git a/tests/golden/variants/codex/.agents/plugins/marketplace.json b/tests/golden/variants/codex/.agents/plugins/marketplace.json new file mode 100644 index 0000000..9b30483 --- /dev/null +++ b/tests/golden/variants/codex/.agents/plugins/marketplace.json @@ -0,0 +1,19 @@ +{ + "name": "golden-plugins", + "interface": { + "displayName": "Golden Plugins" + }, + "plugins": [ + { + "name": "acme", + "source": "./plugins/acme", + "description": "Acme from the source manifest.", + "version": "1.2.3", + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Productivity" + } + ] +} diff --git a/tests/golden/variants/codex/.pluginpack/codex.json b/tests/golden/variants/codex/.pluginpack/codex.json new file mode 100644 index 0000000..73f9aed --- /dev/null +++ b/tests/golden/variants/codex/.pluginpack/codex.json @@ -0,0 +1,30 @@ +{ + "version": 2, + "target": "codex", + "files": [ + ".agents/plugins/marketplace.json", + "plugins/acme/.codex-plugin/plugin.json", + "plugins/acme/.mcp.json", + "plugins/acme/LICENSE", + "plugins/acme/README.md", + "plugins/acme/assets/icon.png", + "plugins/acme/bin/server", + "plugins/acme/hooks/hooks.json", + "plugins/acme/scripts/run.sh", + "plugins/acme/skills/release-notes/SKILL.md", + "plugins/acme/skills/release-notes/references/template.txt" + ], + "sha256": { + ".agents/plugins/marketplace.json": "15c5be6a27be85175cf6b75a6aa357cd3244c4f049140e9283f05558714b7229", + "plugins/acme/.codex-plugin/plugin.json": "381feb8471c085bfdfd7a0085d24dbcfdda566228c59538c13c83cd62be1b26c", + "plugins/acme/.mcp.json": "f3c91992c02ed1cb0767accf66bf8e7bca72408b401771c6c9ab5c5c55fc6aa1", + "plugins/acme/LICENSE": "adc37366f403835c1470ab2df93d3837d4719372fc1ef8593d922e06f033f8b2", + "plugins/acme/README.md": "bd8dd9498117b96048242a906bd2112379227d573726b62ea4bbcba214a24469", + "plugins/acme/assets/icon.png": "71fde3856a298a052c6edb5155638d8422114e55bf0e28374d90dea8be99ef6c", + "plugins/acme/bin/server": "9210d306feecb440d7bd86c82b2ddfae0bb2b706571ed3962f566a7f3f3cf83d", + "plugins/acme/hooks/hooks.json": "ffa7f7bd137c04c26f889c25e3ea9cad84596c619f55a2f6324369c9a413c94d", + "plugins/acme/scripts/run.sh": "a4e0317eafab5cf1bc4a0041c7c8aeb6ece56fe72e7b2b3017a8a6574614cd35", + "plugins/acme/skills/release-notes/SKILL.md": "ab3912f7c2cd1515c291a59d91205364a66b41566095e4202ce8842c59d21b6d", + "plugins/acme/skills/release-notes/references/template.txt": "0528c3e23a7f67ba714dc08d17d0fa4d1d71584c3a52caa2d344d5f030db441a" + } +} diff --git a/tests/golden/variants/codex/plugins/acme/.codex-plugin/plugin.json b/tests/golden/variants/codex/plugins/acme/.codex-plugin/plugin.json new file mode 100644 index 0000000..f806271 --- /dev/null +++ b/tests/golden/variants/codex/plugins/acme/.codex-plugin/plugin.json @@ -0,0 +1,19 @@ +{ + "name": "acme", + "version": "1.2.3", + "description": "Acme from the source manifest.", + "author": { + "name": "Golden", + "email": "golden@example.com" + }, + "homepage": "https://example.com/golden", + "repository": "https://github.com/example/golden-plugins", + "license": "MIT", + "keywords": [ + "golden", + "test" + ], + "skills": "./skills/", + "hooks": "./hooks/hooks.json", + "mcpServers": "./.mcp.json" +} diff --git a/tests/golden/variants/codex/plugins/acme/.mcp.json b/tests/golden/variants/codex/plugins/acme/.mcp.json new file mode 100644 index 0000000..4ac170d --- /dev/null +++ b/tests/golden/variants/codex/plugins/acme/.mcp.json @@ -0,0 +1,18 @@ +{ + "mcpServers": { + "acme": { + "type": "stdio", + "command": "./bin/server", + "args": [ + "--stdio" + ], + "env": { + "ACME_MODE": "plugin" + } + }, + "docs": { + "type": "streamable-http", + "url": "https://mcp.example.com/mcp" + } + } +} diff --git a/tests/golden/variants/codex/plugins/acme/LICENSE b/tests/golden/variants/codex/plugins/acme/LICENSE new file mode 100644 index 0000000..a22a2da --- /dev/null +++ b/tests/golden/variants/codex/plugins/acme/LICENSE @@ -0,0 +1 @@ +MIT diff --git a/tests/golden/variants/codex/plugins/acme/README.md b/tests/golden/variants/codex/plugins/acme/README.md new file mode 100644 index 0000000..a417b90 --- /dev/null +++ b/tests/golden/variants/codex/plugins/acme/README.md @@ -0,0 +1,3 @@ +# Acme + +Start by reading the changelog. diff --git a/tests/golden/variants/codex/plugins/acme/assets/icon.png b/tests/golden/variants/codex/plugins/acme/assets/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..d1ed0c8713020f5bda68b22792b0512681d2c0f9 GIT binary patch literal 29 ecmeAS@N?(olHy`uVBq!ia0vp^j3CUx1|$JML;~&r literal 0 HcmV?d00001 diff --git a/tests/golden/variants/codex/plugins/acme/bin/server b/tests/golden/variants/codex/plugins/acme/bin/server new file mode 100644 index 0000000..a43ea4e --- /dev/null +++ b/tests/golden/variants/codex/plugins/acme/bin/server @@ -0,0 +1,2 @@ +#!/bin/sh +echo server diff --git a/tests/golden/variants/codex/plugins/acme/hooks/hooks.json b/tests/golden/variants/codex/plugins/acme/hooks/hooks.json new file mode 100644 index 0000000..fd02484 --- /dev/null +++ b/tests/golden/variants/codex/plugins/acme/hooks/hooks.json @@ -0,0 +1,14 @@ +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "echo ready" + } + ] + } + ] + } +} diff --git a/tests/golden/variants/codex/plugins/acme/scripts/run.sh b/tests/golden/variants/codex/plugins/acme/scripts/run.sh new file mode 100644 index 0000000..85ba14d --- /dev/null +++ b/tests/golden/variants/codex/plugins/acme/scripts/run.sh @@ -0,0 +1,2 @@ +#!/bin/sh +echo run diff --git a/tests/golden/variants/codex/plugins/acme/skills/release-notes/SKILL.md b/tests/golden/variants/codex/plugins/acme/skills/release-notes/SKILL.md new file mode 100644 index 0000000..ff888d6 --- /dev/null +++ b/tests/golden/variants/codex/plugins/acme/skills/release-notes/SKILL.md @@ -0,0 +1,10 @@ +--- +name: release-notes +description: Draft release notes from a list of changes. +--- + +# Release notes + +Start by reading the changelog. + +Group the changes by feature and fix. diff --git a/tests/golden/variants/codex/plugins/acme/skills/release-notes/references/template.txt b/tests/golden/variants/codex/plugins/acme/skills/release-notes/references/template.txt new file mode 100644 index 0000000..937d151 --- /dev/null +++ b/tests/golden/variants/codex/plugins/acme/skills/release-notes/references/template.txt @@ -0,0 +1,3 @@ +## Features + +## Fixes From 3d3ac5a9b68fc6f6849f89e77e0bec9f6a0e580d Mon Sep 17 00:00:00 2001 From: Steve Calvert Date: Wed, 21 Oct 2026 15:15:52 -0700 Subject: [PATCH 16/24] test(pack): walk the lifecycle with the installed tarball The packed-install check stopped after init, right before the steps that have broken for real users. It now adds a skill, builds, validates every target, deletes the skill, rebuilds and validates again, then cleans, using the binary installed from the packed tarball. It fails if the rebuild leaves the deleted skill behind, if clean leaves managed files or removes a hand-written one, or if any step changes an authored file. --- scripts/verify-packed-install.mjs | 144 +++++++++++++++++++++++++++--- 1 file changed, 130 insertions(+), 14 deletions(-) diff --git a/scripts/verify-packed-install.mjs b/scripts/verify-packed-install.mjs index a08837b..25698e9 100755 --- a/scripts/verify-packed-install.mjs +++ b/scripts/verify-packed-install.mjs @@ -2,16 +2,61 @@ // 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. +// walks the documented lifecycle with the installed binary: init, add a +// skill, build, validate, delete the skill, build, validate, clean. That +// catches a broken `bin` shebang or a `files`/tsup misconfig that +// `npm pack --dry-run` alone would miss, and a lifecycle step that breaks +// for a real install (such as a rebuild that cannot remove a deleted skill). import { execFileSync } from "node:child_process"; -import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + 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-")); +/** The targets `pluginpack init` configures, each writing to `dist/`. */ +const initTargets = ["cursor", "claude", "antigravity", "copilot"]; + +/** Every file under `dir`, as posix paths relative to it, sorted. */ +function listFiles(dir) { + if (!existsSync(dir)) { + return []; + } + return readdirSync(dir, { recursive: true, withFileTypes: true }) + .filter((entry) => entry.isFile()) + .map((entry) => + path + .relative(dir, path.join(entry.parentPath, entry.name)) + .split(path.sep) + .join("/"), + ) + .sort(); +} + +/** Every file under `dir` with its contents, skipping `skip` (relative to dir). */ +function snapshot(dir, skip) { + return Object.fromEntries( + listFiles(dir) + .filter((file) => !skip(file)) + .map((file) => [file, readFileSync(path.join(dir, file), "utf8")]), + ); +} + +function check(condition, message) { + if (!condition) { + throw new Error(message); + } +} + try { const packRaw = execFileSync( "npm", @@ -25,7 +70,11 @@ try { mkdirSync(installDir); writeFileSync( path.join(installDir, "package.json"), - JSON.stringify({ name: "pack-smoke-test", version: "0.0.0", private: true }), + JSON.stringify({ + name: "pack-smoke-test", + version: "0.0.0", + private: true, + }), ); execFileSync("npm", ["install", tarballPath], { cwd: installDir, @@ -34,20 +83,87 @@ try { 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."); - } + check(version, "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.", + const project = path.join(installDir, "project"); + mkdirSync(project); + const pluginpack = (...args) => { + console.log(`$ pluginpack ${args.join(" ")}`); + execFileSync(bin, args, { cwd: project, stdio: "inherit" }); + }; + const isOutput = (file) => file.startsWith("dist/"); + const outputs = () => + listFiles(path.join(project, "dist")).map((file) => `dist/${file}`); + const skillOutputs = (skill) => + outputs().filter((file) => file.includes(`/skills/${skill}/`)); + + // --- init -------------------------------------------------------------- + pluginpack("init"); + check( + existsSync(path.join(project, "pluginpack.config.ts")), + "pluginpack init did not create pluginpack.config.ts from the installed package.", + ); + + // --- add a skill, build, validate --------------------------------------- + const extraSkill = path.join(project, "shared/example/skills/extra"); + mkdirSync(extraSkill, { recursive: true }); + writeFileSync( + path.join(extraSkill, "SKILL.md"), + "---\nname: extra\ndescription: A second skill.\n---\n\nExtra.\n", + ); + const sources = snapshot(project, isOutput); + pluginpack("build"); + for (const target of initTargets) { + pluginpack("validate", "--target", target); + check( + existsSync( + path.join(project, "dist", target, ".pluginpack", `${target}.json`), + ), + `build wrote no managed manifest for ${target}.`, ); } - console.log("Installed CLI's `init` command works."); + check( + skillOutputs("extra").length === initTargets.length, + `Expected the extra skill in every target, got: ${skillOutputs("extra").join(", ")}`, + ); + // A hand-written file in the output, which no command may delete. + const notes = path.join(project, "dist/cursor/NOTES.md"); + writeFileSync(notes, "Hand-written.\n"); + + // --- delete the skill, build, validate ---------------------------------- + rmSync(extraSkill, { recursive: true }); + delete sources["shared/example/skills/extra/SKILL.md"]; + pluginpack("build"); + for (const target of initTargets) { + pluginpack("validate", "--target", target); + } + check( + skillOutputs("extra").length === 0, + `Rebuild left the deleted skill behind: ${skillOutputs("extra").join(", ")}`, + ); + check( + skillOutputs("example").length === initTargets.length, + `Rebuild lost the remaining skill: ${skillOutputs("example").join(", ")}`, + ); + + // --- clean -------------------------------------------------------------- + pluginpack("clean"); + check( + JSON.stringify(outputs()) === JSON.stringify(["dist/cursor/NOTES.md"]), + `clean should leave only the hand-written file, left: ${outputs().join(", ")}`, + ); + check( + readFileSync(notes, "utf8") === "Hand-written.\n", + "clean changed a hand-written file.", + ); + check( + JSON.stringify(snapshot(project, isOutput)) === JSON.stringify(sources), + "The lifecycle changed an authored source file.", + ); + console.log( + "Installed CLI walks init -> build -> validate -> delete a skill -> build -> clean.", + ); } finally { rmSync(scratch, { recursive: true, force: true }); } From cd39b78c5a863a3f8bb086d11791108cf16d863f Mon Sep 17 00:00:00 2001 From: Steve Calvert Date: Wed, 21 Oct 2026 15:15:52 -0700 Subject: [PATCH 17/24] test(readme): run the README shell and config examples markdown-code keeps four README blocks in sync with snippets/ but runs none of them, which is how a documented command that fails can ship. This test runs the CLI Reference examples, the Install Snippet and CI Change Detection commands, the Components and multi-client configs, the MCP Directory shipping map and the Programmatic API import list with the built CLI against a project that imports the local package. Every bash, sh, ts and json block must be listed in the test as run, run elsewhere or not runnable with a reason, so a new block fails until someone decides. The Quick Start is left to its own materialization test. --- tests/readme-examples.test.ts | 513 ++++++++++++++++++++++++++++++++++ 1 file changed, 513 insertions(+) create mode 100644 tests/readme-examples.test.ts diff --git a/tests/readme-examples.test.ts b/tests/readme-examples.test.ts new file mode 100644 index 0000000..f6c6e96 --- /dev/null +++ b/tests/readme-examples.test.ts @@ -0,0 +1,513 @@ +/** + * Runs the README's shell and config examples with the built CLI, so a + * documented command or config that stops working fails CI instead of + * shipping. `markdown-code check` only keeps four blocks in sync with + * `snippets/`; it runs nothing. + * + * Every fenced bash/sh/ts/json block in README.md must appear in `inventory` + * below, either as run here, run by another check, or not runnable with a + * reason. A new block fails the inventory test until someone decides which. + * + * Overlap: the Quick Start blocks are run by `tests/readme-quick-start.test.ts` + * (added on fix/target-conformance, which also makes the Quick Start + * complete), and its `npm install` by `scripts/verify-packed-install.mjs`. + * This file covers the rest: the CLI Reference examples, Install Snippet, CI + * Change Detection, Components, One package for several clients, MCP + * Directory and the Programmatic API import list. + */ +import { execFileSync } from "node:child_process"; +import { + cp, + mkdir, + mkdtemp, + readdir, + readFile, + rm, + symlink, + writeFile, +} from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { exists, toPosix } from "../src/fs.js"; + +const repoRoot = fileURLToPath(new URL("../", import.meta.url)); +const cli = path.join(repoRoot, "dist/cli.js"); + +type Block = { key: string; lang: string; body: string }; + +/** + * Fenced blocks by ` > #`, counting per heading + * and language. Only languages a reader might paste and run are listed. + */ +function readmeBlocks(readme: string): Block[] { + const blocks: Block[] = []; + const counts = new Map(); + let heading = ""; + let fence: { lang: string; lines: string[] } | undefined; + for (const line of readme.split("\n")) { + if (fence) { + if (line.startsWith("```")) { + if (["bash", "sh", "ts", "json"].includes(fence.lang)) { + const base = `${heading} > ${fence.lang}`; + const n = (counts.get(base) ?? 0) + 1; + counts.set(base, n); + blocks.push({ + key: `${base} #${n}`, + lang: fence.lang, + body: fence.lines.join("\n"), + }); + } + fence = undefined; + } else { + fence.lines.push(line); + } + } else if (line.startsWith("```")) { + fence = { lang: line.slice(3).split(/\s/)[0], lines: [] }; + } else if (/^#{1,6} /.test(line)) { + heading = line.replace(/^#+ /, ""); + } + } + return blocks; +} + +/** How each README block is covered. `run` blocks are executed in this file. */ +const inventory: Record = { + "Quick Start > bash #1": + "verify-packed-install.mjs installs the packed tarball", + "Quick Start > ts #1": "readme-quick-start.test.ts (fix/target-conformance)", + "Quick Start > bash #2": + "readme-quick-start.test.ts (fix/target-conformance)", + "Components > ts #1": "run", + "One package for several clients > ts #1": "run", + "Update Check (claude, cursor) > ts #1": + "not runnable: a fragment with `...` placeholders", + "Install Snippet > bash #1": "run", + "MCP Directory > json #1": "run", + "Legacy Additional Plugin-Root Files > json #1": + "not run: legacy 0.10 sources, covered by core.test.ts", + "Partials > ts #1": + "not runnable: a fragment; partials are run by lifecycle-journeys.test.ts", + "CI Change Detection > bash #1": "run", + "Programmatic API > ts #1": "run", + ...Object.fromEntries( + [ + "init", + "build", + "validate", + "diff", + "prune", + "clean", + "install-info", + "docs", + ].map((command) => [ + `\`${command}\` > bash #1`, + "not runnable: a usage synopsis; its Examples are run", + ]), + ), +}; + +let readme = ""; +let blocks: Block[] = []; +let workspace = ""; + +function block(key: string): string { + expect(inventory[key], key).toBe("run"); + const found = blocks.find((candidate) => candidate.key === key); + if (!found) { + throw new Error(`README.md has no block "${key}".`); + } + return found.body; +} + +/** Runs `pluginpack ` with the built CLI; throws with its output on a non-zero exit. */ +function pluginpack(cwd: string, args: string[]): string { + try { + return execFileSync(process.execPath, [cli, ...args], { + cwd, + encoding: "utf8", + stdio: "pipe", + env: { ...process.env, CI: "1" }, + }); + } catch (error) { + const failed = error as { stdout?: string; stderr?: string }; + throw new Error( + `pluginpack ${args.join(" ")} failed in ${cwd}:\n${failed.stdout ?? ""}${failed.stderr ?? ""}`, + { cause: error }, + ); + } +} + +/** `pluginpack a b c` from a README line, as argv. */ +function argv(command: string): string[] { + const trimmed = command.trim(); + expect(trimmed).toMatch(/^pluginpack /); + return trimmed.slice("pluginpack ".length).split(/\s+/); +} + +/** + * Every file under `dir` with its contents, keyed by posix relative path. + * Skips `node_modules`, which here is a symlink to this repository. + */ +async function readTree(dir: string): Promise> { + const tree: Record = {}; + const walk = async (current: string): Promise => { + for (const entry of await readdir(current, { withFileTypes: true })) { + const full = path.join(current, entry.name); + if (entry.isDirectory() && entry.name !== "node_modules") { + await walk(full); + } else if (entry.isFile()) { + tree[toPosix(path.relative(dir, full))] = await readFile(full, "utf8"); + } + } + }; + if (await exists(dir)) { + await walk(dir); + } + return Object.fromEntries(Object.entries(tree).sort()); +} + +async function writeFiles( + root: string, + files: Record, +): Promise { + for (const [relative, contents] of Object.entries(files)) { + await mkdir(path.dirname(path.join(root, relative)), { recursive: true }); + await writeFile(path.join(root, relative), contents); + } +} + +/** + * A project that imports the real local package the way a README config + * does (`from "@gleanwork/pluginpack"`), with its config and files. + */ +async function makeProject( + name: string, + files: Record, +): Promise { + const dir = path.join(workspace, name); + await writeFiles(dir, { "package.json": '{ "type": "module" }\n', ...files }); + await mkdir(path.join(dir, "node_modules/@gleanwork"), { recursive: true }); + await symlink( + repoRoot, + path.join(dir, "node_modules/@gleanwork/pluginpack"), + "dir", + ); + return dir; +} + +const sharedAcme = { + "shared/acme/skills/release-notes/SKILL.md": + "---\nname: release-notes\ndescription: Draft release notes.\n---\n\nGroup changes.\n", + "shared/acme/agents/search-assistant.agent.md": + "---\nname: search-assistant\ndescription: Find documents.\n---\n\nSearch and cite.\n", + "shared/acme/commands/summarize.md": + "---\nname: summarize\ndescription: Summarize the open file.\n---\n\nSummarize it.\n", +}; + +const codexEntry = `entry: { policy: { installation: "AVAILABLE", authentication: "ON_INSTALL" }, category: "Productivity" },`; + +/** One project configuring every target that `install-info` and the CLI examples name. */ +const cliConfig = `import { defineConfig } from "@gleanwork/pluginpack"; + +export default defineConfig({ + name: "acme-plugins", + version: "0.1.0", + metadata: { + description: "Acme agent plugins.", + author: { name: "Acme" }, + license: "MIT", + repository: "https://github.com/acme/acme-plugins", + }, + targets: { + cursor: { outDir: "out/cursor", plugins: { acme: { source: "shared/acme" } } }, + claude: { outDir: "out/claude", plugins: { acme: { source: "shared/acme" } } }, + antigravity: { outDir: "out/antigravity", plugins: { acme: { source: "shared/acme" } } }, + copilot: { outDir: "out/copilot", plugins: { acme: { source: "shared/acme" } } }, + codex: { outDir: "out/codex", plugins: { acme: { source: "shared/acme", ${codexEntry} } } }, + }, +}); +`; + +beforeAll(async () => { + readme = await readFile(path.join(repoRoot, "README.md"), "utf8"); + blocks = readmeBlocks(readme); + workspace = await mkdtemp(path.join(os.tmpdir(), "pluginpack-readme-")); +}); + +afterAll(async () => { + if (workspace) await rm(workspace, { recursive: true, force: true }); +}); + +it("classifies every README shell and config block", () => { + const keys = blocks.map((candidate) => candidate.key); + expect(new Set(keys).size).toBe(keys.length); + const unclassified = keys.filter((key) => !(key in inventory)); + expect( + unclassified, + "New README blocks: run each in tests/readme-examples.test.ts, or add it to `inventory` with the reason it is not run here.", + ).toEqual([]); + expect( + Object.keys(inventory).filter((key) => !keys.includes(key)), + "`inventory` names README blocks that no longer exist.", + ).toEqual([]); +}); + +describe("CLI Reference examples", () => { + /** Examples listed under each `### \`command\`` heading of the generated reference. */ + function cliExamples(): Map { + const reference = readme + .split("")[1] + ?.split("")[0]; + expect(reference).toBeDefined(); + const examples = new Map(); + for (const section of reference!.split(/^### /m).slice(1)) { + const command = /^`([^`]+)`/.exec(section)![1]; + const list = section.split("Examples:")[1]?.split("Exit codes:")[0] ?? ""; + examples.set( + command, + [...list.matchAll(/^- `(pluginpack [^`]+)`$/gm)].map( + (match) => match[1], + ), + ); + } + return examples; + } + + it("lists examples for every command", () => { + const examples = cliExamples(); + expect([...examples.keys()]).toEqual([ + "init", + "build", + "validate", + "diff", + "prune", + "clean", + "install-info", + "docs", + ]); + for (const [command, list] of examples) { + expect(list.length, command).toBeGreaterThan(0); + for (const example of list) { + expect(argv(example)[0]).toBe(command); + } + } + }); + + it("runs every example and each exits 0 in the state it documents", async () => { + const examples = cliExamples(); + const project = await makeProject("cli/project", { + "pluginpack.config.ts": cliConfig, + ...sharedAcme, + }); + // `validate --dir ../cursor-plugins` and `diff --against + // ../cursor-plugins` document a sibling output repository. + const prepare = (): void => { + pluginpack(project, ["build"]); + pluginpack(project, [ + "build", + "--target", + "cursor", + "--out-dir", + "../cursor-plugins", + ]); + }; + + let needsBuild = true; + for (const [command, list] of examples) { + for (const example of list) { + const args = argv(example); + if (command === "init") { + // init documents a fresh directory. + const fresh = path.join(workspace, "cli/init"); + await mkdir(fresh, { recursive: true }); + pluginpack(fresh, args); + expect(await exists(path.join(fresh, "pluginpack.config.ts"))).toBe( + true, + ); + continue; + } + if (command === "docs") { + // docs rewrites ./README.md; run it on a copy of the real one. + const docsDir = path.join(workspace, "cli/docs"); + await mkdir(docsDir, { recursive: true }); + await cp( + path.join(repoRoot, "README.md"), + path.join(docsDir, "README.md"), + ); + pluginpack(docsDir, args); + expect( + await readFile(path.join(docsDir, "README.md"), "utf8"), + example, + ).toBe(readme); + continue; + } + // Build the documented starting state when the last example tore it down. + if (needsBuild) { + prepare(); + needsBuild = false; + } + const before = await readTree(path.dirname(project)); + const output = pluginpack(project, args); + const after = await readTree(path.dirname(project)); + if (args.includes("--dry-run")) { + expect(after, `${example} must not write`).toEqual(before); + } + if (command === "install-info") { + expect(output, example).toContain("acme/acme-plugins"); + } + if (command === "clean" && !args.includes("--dry-run")) { + needsBuild = true; + // clean with no --target removes every configured target's output. + expect( + Object.keys(after).filter((file) => + file.startsWith("project/out/"), + ), + example, + ).toEqual([]); + } + } + } + }, 30_000); +}); + +it("runs the Install Snippet and CI Change Detection commands", async () => { + const project = await makeProject("snippets/project", { + "pluginpack.config.ts": cliConfig, + ...sharedAcme, + }); + for (const line of block("Install Snippet > bash #1").split("\n")) { + expect(pluginpack(project, argv(line))).toContain("acme/acme-plugins"); + } + const diffs = block("CI Change Detection > bash #1").split("\n"); + expect(diffs.length).toBeGreaterThan(0); + for (const line of diffs) { + const args = argv(line); + const target = args[args.indexOf("--target") + 1]; + const against = args[args.indexOf("--against") + 1]; + pluginpack(project, ["build", "--target", target, "--out-dir", against]); + pluginpack(project, args); + // And the documented point of the command: a stale repo fails. + await writeFile( + path.join(project, "shared/acme/skills/release-notes/SKILL.md"), + "---\nname: release-notes\ndescription: Changed.\n---\n\nChanged.\n", + ); + expect(() => pluginpack(project, args)).toThrow(/failed/); + await writeFile( + path.join(project, "shared/acme/skills/release-notes/SKILL.md"), + sharedAcme["shared/acme/skills/release-notes/SKILL.md"], + ); + } +}); + +it("builds and validates the Components include/exclude config", async () => { + const config = block("Components > ts #1"); + const project = await makeProject("components", { + "pluginpack.config.ts": config, + ...sharedAcme, + "shared/acme/README.md": "# Acme\n", + }); + pluginpack(project, ["build"]); + for (const target of ["antigravity", "claude"]) { + pluginpack(project, ["validate", "--target", target]); + } + const tree = Object.keys(await readTree(project)); + // antigravity includes commands and static files; claude excludes commands. + expect(tree).toContain("plugins/antigravity/acme/commands/summarize.md"); + expect(tree).toContain("plugins/antigravity/acme/README.md"); + expect(tree.filter((file) => file.startsWith("plugins/claude/"))).not.toEqual( + [], + ); + expect( + tree.filter( + (file) => file.startsWith("plugins/claude/") && file.includes("commands"), + ), + ).toEqual([]); +}); + +it("builds and validates the One package for several clients config", async () => { + const fragment = block("One package for several clients > ts #1"); + expect(fragment.trimStart()).toMatch(/^targets: \{/); + const project = await makeProject("clients", { + "pluginpack.config.ts": `import { defineConfig } from "@gleanwork/pluginpack"; + +export default defineConfig({ + name: "acme-plugins", + version: "0.1.0", + metadata: { description: "Acme agent plugins.", author: { name: "Acme" }, license: "MIT" }, + ${fragment} +}); +`, + ...sharedAcme, + }); + pluginpack(project, ["build"]); + pluginpack(project, ["validate", "--target", "agent-plugins"]); + const out = path.join(project, "dist/agent-plugins"); + const tree = Object.keys(await readTree(out)); + expect(tree).toContain(".agents/plugins/marketplace.json"); + expect(tree).toContain(".github/plugin/marketplace.json"); + expect(tree).toContain( + "plugins/acme/com.github.copilot/agents/search-assistant.agent.md", + ); + const codex = JSON.parse( + await readFile(path.join(out, ".agents/plugins/marketplace.json"), "utf8"), + ) as { interface?: unknown; plugins: { category?: string }[] }; + expect(codex.interface).toEqual({ displayName: "Acme" }); + expect(codex.plugins[0].category).toBe("Productivity"); +}); + +it("ships exactly the files the MCP Directory pluginpack.json declares", async () => { + const declared = JSON.parse(block("MCP Directory > json #1")) as { + files: Record; + }; + // The tree the section shows, with the declared map. + const project = await makeProject("mcp", { + "pluginpack.config.ts": cliConfig, + ...sharedAcme, + "shared/acme/mcp/config.json": JSON.stringify({ + mcpServers: { + acme: { type: "stdio", command: "node", args: ["./mcp/start.mjs"] }, + }, + }), + "shared/acme/mcp/pluginpack.json": JSON.stringify(declared), + "shared/acme/mcp/start.mjs": "import './dist/index.js';\n", + "shared/acme/mcp/dist/index.js": "export {};\n", + "shared/acme/mcp/src/index.ts": "export {};\n", + "shared/acme/mcp/tests/index.test.ts": "export {};\n", + }); + pluginpack(project, ["build"]); + const tree = Object.keys(await readTree(path.join(project, "out"))); + for (const target of [ + "cursor", + "claude", + "antigravity", + "copilot", + "codex", + ]) { + pluginpack(project, ["validate", "--target", target]); + const shipped = tree + .filter((file) => file.startsWith(`${target}/`) && file.includes("mcp/")) + .map((file) => file.slice(file.indexOf("mcp/"))) + .sort(); + expect(shipped, target).toEqual(Object.keys(declared.files).sort()); + } +}); + +it("exports every function the Programmatic API import list names", async () => { + const imports = block("Programmatic API > ts #1"); + const names = /import \{([\s\S]*?)\} from "@gleanwork\/pluginpack"/ + .exec(imports)![1] + .split(",") + .map((name) => name.trim()) + .filter(Boolean); + expect(names.length).toBeGreaterThan(5); + const project = await makeProject("api", { + "check.mjs": `${imports}\nconst api = { ${names.join(", ")} };\nconsole.log(JSON.stringify(Object.entries(api).filter(([, value]) => typeof value !== "function").map(([name]) => name)));\n`, + }); + const notFunctions = execFileSync(process.execPath, ["check.mjs"], { + cwd: project, + encoding: "utf8", + }); + expect(JSON.parse(notFunctions)).toEqual([]); +}); From 637f86f51ac8d757571851de21fb32b9a6d52bc8 Mon Sep 17 00:00:00 2001 From: Steve Calvert Date: Wed, 21 Oct 2026 16:12:50 -0700 Subject: [PATCH 18/24] test(lifecycle): assert the outDir handoff refusal and the config reload The base branch now loads the config without the module cache and refuses to build or prune while an outDir holds a manifest for a target no longer built there. The journey tests that expected those bugs now assert the fixed behavior. Switching an outDir between codex and agent-plugins without cleaning must make build and prune refuse, name the stale manifest and the clean command, and leave the tree untouched. Running that clean for the old target, which is no longer configured, must then let the new build finish with no stale files. The handoff tests now rewrite pluginpack.config.ts in place, as a user would, instead of keeping one config file per side to dodge the cache. --- tests/lifecycle-journeys.test.ts | 197 +++++++++++++++---------------- 1 file changed, 96 insertions(+), 101 deletions(-) diff --git a/tests/lifecycle-journeys.test.ts b/tests/lifecycle-journeys.test.ts index c8c9ed6..a4c0f43 100644 --- a/tests/lifecycle-journeys.test.ts +++ b/tests/lifecycle-journeys.test.ts @@ -210,6 +210,10 @@ async function writeFiles( } } +function escapeRegExp(text: string): string { + return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + /** Asserts each hand-written file is still on disk with the bytes it was written with. */ async function expectSurvives( root: string, @@ -297,21 +301,6 @@ function findOutput(artifact: Artifact, suffix: string): string { return found; } -/** - * Runs the steps of an `it.fails` test that must succeed before the bug can - * show. `it.fails` passes on any failure, so a setup step that throws would - * hide a broken test; this returns undefined instead, the test then returns - * normally, and `it.fails` reports it. - */ -async function setupOnly(steps: () => Promise): Promise { - try { - return await steps(); - } catch (error) { - console.error("it.fails setup failed, so the test proves nothing:", error); - return undefined; - } -} - const layouts: Record> = { "one outDir per target": Object.fromEntries( allTargets.map((target) => [ @@ -580,107 +569,113 @@ describe("codex <-> agent-plugins clients.codex handoff in one outDir", () => { }); /** - * Writes the config for one side under its own file name. jiti caches a - * config module by path for the life of the process (see the `it.fails` - * at the end of this file), so rewriting `pluginpack.config.ts` in place - * would keep building the old config in-process; a CLI user gets a fresh - * process per command. + * Rewrites `pluginpack.config.ts` in place to build one side, as a user + * editing the config would, and records the new bytes as authored. */ - async function useConfig(root: string, side: Side): Promise { - const configPath = `pluginpack.${side}.config.ts`; - await writeFiles(root, { [configPath]: configSource(configs[side]) }); - return configPath; + async function useConfig( + root: string, + sources: Map, + side: Side, + ): Promise { + const contents = configSource(configs[side]); + await writeFiles(root, { "pluginpack.config.ts": contents }); + sources.set("pluginpack.config.ts", contents); + } + + /** The project after building `from` and adding a hand-written file. */ + async function builtWith(from: Side) { + const root = await makeProject(configSource(""), handoffFiles()); + const sources = await readTree(root); + await useConfig(root, sources, from); + const [first] = await build({ cwd: root }); + expect(first.target).toBe(from); + await writeFiles(root, notes); + await expectProjectTree(root, "first build", sources, notes, [first]); + await expectValid(from, path.join(root, "out")); + return { root, sources }; + } + + /** Builds `to`, checks its output is complete and valid, then cleans it. */ + async function buildAndCleanNew( + root: string, + sources: Map, + to: Side, + ): Promise { + const [second] = await build({ cwd: root }); + expect(second.target).toBe(to); + await expectProjectTree(root, "build new", sources, notes, [second]); + await expectValid(to, path.join(root, "out")); + expect(second.managedPaths.includes("plugins/acme/hooks/hooks.json")).toBe( + to === "codex", + ); + + await clean({ cwd: root }); + await expectProjectTree(root, "clean new", sources, notes, []); } it.each(handoffs)( "%s -> %s: clean the old target first, then build the new one", async (from, to) => { - const root = await makeProject(configSource(""), handoffFiles()); - const out = path.join(root, "out"); - const fromConfig = await useConfig(root, from); - const toConfig = await useConfig(root, to); - const sources = await readTree(root); - const [first] = await build({ cwd: root, configPath: fromConfig }); - await writeFiles(root, notes); - await expectProjectTree(root, "first build", sources, notes, [first]); - await expectValid(from, out); + const { root, sources } = await builtWith(from); // The documented move: clean the old target while it is still - // configured, then build the new one. - await clean({ cwd: root, configPath: fromConfig, target: from }); + // configured, then switch the config and build the new one. + await clean({ cwd: root, target: from }); await expectProjectTree(root, "clean old", sources, notes, []); - const [second] = await build({ cwd: root, configPath: toConfig }); - expect(second.target).toBe(to); - await expectProjectTree(root, "build new", sources, notes, [second]); - await expectValid(to, out); - expect( - second.managedPaths.includes("plugins/acme/hooks/hooks.json"), - ).toBe(to === "codex"); - - await clean({ cwd: root, configPath: toConfig }); - await expectProjectTree(root, "clean new", sources, notes, []); + await useConfig(root, sources, to); + await buildAndCleanNew(root, sources, to); }, ); - // BUG: build, prune and clean read only the managed manifests of targets - // the current config lists. After a config switches an outDir from one of - // these targets to the other without cleaning first, the old target's - // manifest and its files that the new target does not overwrite stay - // forever: codex -> agent-plugins leaves `plugins/acme/hooks/hooks.json` - // inside the package (so `validate --target agent-plugins` fails) and - // `.pluginpack/codex.json`; agent-plugins -> codex leaves the Copilot - // client's `.github/plugin/marketplace.json` and - // `.pluginpack/agent-plugins.json`, and both outlive `clean`. The README on - // fix/target-conformance documents cleaning first as the workaround. - it.fails.each(handoffs)( - "%s -> %s: switching the config without cleaning leaves no stale output", + // Switching first leaves the old target's manifest in `out`, and no + // configured target would prune the files it lists (codex's hooks inside + // an agent-plugins package, say, which fail validation). build and prune + // refuse, name the manifest and say which clean to run; that clean finds + // the manifest even though the old target is no longer configured. + it.each(handoffs)( + "%s -> %s: switching the config without cleaning refuses until the old target is cleaned", async (from, to) => { - const ready = await setupOnly(async () => { - const root = await makeProject(configSource(""), handoffFiles()); - const toConfig = await useConfig(root, to); - const sources = await readTree(root); - await build({ cwd: root, configPath: await useConfig(root, from) }); - sources.set( - `pluginpack.${from}.config.ts`, - configSource(configs[from]), - ); - await writeFiles(root, notes); - const [second] = await build({ cwd: root, configPath: toConfig }); - return { root, toConfig, sources, second }; - }); - if (!ready) return; - const { root, toConfig, sources, second } = ready; - await expectProjectTree(root, "switch", sources, notes, [second]); - await expectValid(to, path.join(root, "out")); - await clean({ cwd: root, configPath: toConfig }); - await expectProjectTree(root, "clean", sources, notes, []); + const { root, sources } = await builtWith(from); + + await useConfig(root, sources, to); + const before = await readTree(root); + // Names the stale manifest, then the command that removes it. + const refusal = new RegExp( + `${escapeRegExp(`${from}.json (${from})`)}[\\s\\S]*${escapeRegExp(`pluginpack clean --target ${from}`)}`, + ); + await expect(build({ cwd: root }), "build refuses").rejects.toThrow( + refusal, + ); + await expect(prune({ cwd: root }), "prune refuses").rejects.toThrow( + refusal, + ); + // Refusing touched nothing. + expect(await readTree(root)).toEqual(before); + + const cleaned = await clean({ cwd: root, target: from }); + expect(cleaned.map((result) => result.target)).toEqual([from]); + await expectProjectTree(root, "clean old", sources, notes, []); + + await buildAndCleanNew(root, sources, to); }, ); }); -// BUG: loadProjectConfig imports the config through jiti with its default -// module cache, so a second build() in the same process after the config file -// changes on disk still builds the first config. The CLI starts a new process -// per command and is unaffected; a long-lived script calling the programmatic -// API (a watcher, a test harness) silently builds stale config. -it.fails( - "build() reads pluginpack.config.ts again after it changes", - async () => { - const second = await setupOnly(async () => { - const placement = { outDir: "out", repositoryFiles: false }; - const root = await makeProject( - configSource(targetBlock("claude", placement)), - authoredFiles(), - ); - await build({ cwd: root }); - await writeFiles(root, { - "pluginpack.config.ts": configSource(targetBlock("cursor", placement)), - }); - const [artifact] = await build({ cwd: root }); - return artifact; - }); - if (!second) return; - expect(second.target).toBe("cursor"); - }, -); +// A long-lived process calling the programmatic API (a watcher, a test +// harness) must build the config on disk, not a cached copy of an earlier one. +it("build() reads pluginpack.config.ts again after it changes", async () => { + const placement = { outDir: "out", repositoryFiles: false }; + const root = await makeProject( + configSource(targetBlock("claude", placement)), + authoredFiles(), + ); + const [first] = await build({ cwd: root }); + expect(first.target).toBe("claude"); + await clean({ cwd: root }); + await writeFiles(root, { + "pluginpack.config.ts": configSource(targetBlock("cursor", placement)), + }); + const [second] = await build({ cwd: root }); + expect(second.target).toBe("cursor"); +}); From 29d520c02e6b89bae4537f1316d744f0986362aa Mon Sep 17 00:00:00 2001 From: Steve Calvert Date: Wed, 21 Oct 2026 16:15:52 -0700 Subject: [PATCH 19/24] test(lifecycle): decode artifact bytes from any Uint8Array An artifact file value may be a plain Uint8Array, not only a Buffer, once the public FileValue type widens. Uint8Array#toString takes no encoding, so the journey decodes through Buffer.from, which works for both. --- tests/lifecycle-journeys.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/lifecycle-journeys.test.ts b/tests/lifecycle-journeys.test.ts index a4c0f43..753bb06 100644 --- a/tests/lifecycle-journeys.test.ts +++ b/tests/lifecycle-journeys.test.ts @@ -253,7 +253,9 @@ async function expectProjectTree( const value = artifact.files.get(managedPath); expected.set( inOutput(root, artifact, managedPath), - typeof value === "string" ? value : value?.toString("utf8"), + typeof value === "string" || value === undefined + ? value + : Buffer.from(value).toString("utf8"), ); } expected.set( From 2bf8f474c47440c903acd210daeb766edb0540dc Mon Sep 17 00:00:00 2001 From: Steve Calvert Date: Wed, 21 Oct 2026 16:24:11 -0700 Subject: [PATCH 20/24] test(pack): check every target init scaffolds Read the targets from the first build's output instead of a fixed list, so the packed-install walk keeps covering every target when init gains one. --- scripts/verify-packed-install.mjs | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/scripts/verify-packed-install.mjs b/scripts/verify-packed-install.mjs index 25698e9..18200d5 100755 --- a/scripts/verify-packed-install.mjs +++ b/scripts/verify-packed-install.mjs @@ -23,8 +23,12 @@ import path from "node:path"; const repoRoot = process.cwd(); const scratch = mkdtempSync(path.join(tmpdir(), "pluginpack-pack-smoke-")); -/** The targets `pluginpack init` configures, each writing to `dist/`. */ -const initTargets = ["cursor", "claude", "antigravity", "copilot"]; +/** + * The targets `pluginpack init` configures, each writing to `dist/`. + * Read from the first build's output rather than listed here, so the script + * keeps checking every scaffolded target when init gains one. + */ +let initTargets = []; /** Every file under `dir`, as posix paths relative to it, sorted. */ function listFiles(dir) { @@ -114,6 +118,11 @@ try { ); const sources = snapshot(project, isOutput); pluginpack("build"); + initTargets = readdirSync(path.join(project, "dist")).sort(); + check( + initTargets.length >= 4, + `Expected init to scaffold at least four targets, got: ${initTargets.join(", ")}`, + ); for (const target of initTargets) { pluginpack("validate", "--target", target); check( From a82580776dfc12e16003c4338c4580d9b87536f1 Mon Sep 17 00:00:00 2001 From: Steve Calvert Date: Wed, 21 Oct 2026 16:34:08 -0700 Subject: [PATCH 21/24] test(pack): drop the unused initial value --- scripts/verify-packed-install.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/verify-packed-install.mjs b/scripts/verify-packed-install.mjs index 18200d5..672decd 100755 --- a/scripts/verify-packed-install.mjs +++ b/scripts/verify-packed-install.mjs @@ -28,7 +28,7 @@ const scratch = mkdtempSync(path.join(tmpdir(), "pluginpack-pack-smoke-")); * Read from the first build's output rather than listed here, so the script * keeps checking every scaffolded target when init gains one. */ -let initTargets = []; +let initTargets; /** Every file under `dir`, as posix paths relative to it, sorted. */ function listFiles(dir) { From beadea6aed4d25fdc27afe8fd9b280324acc59a3 Mon Sep 17 00:00:00 2001 From: Steve Calvert Date: Wed, 21 Oct 2026 16:39:26 -0700 Subject: [PATCH 22/24] test(lifecycle): give the six-target journeys a CI-sized timeout --- tests/lifecycle-journeys.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/lifecycle-journeys.test.ts b/tests/lifecycle-journeys.test.ts index 753bb06..7cee503 100644 --- a/tests/lifecycle-journeys.test.ts +++ b/tests/lifecycle-journeys.test.ts @@ -521,7 +521,9 @@ describe.each(Object.entries(layouts))( expect( warnings.filter((warning) => /edited|changed since/i.test(warning)), ).toEqual([]); - }); + // Six targets through seven steps, each followed by six validations, + // takes 3-4s locally and more than vitest's 5s default on CI. + }, 60_000); }, ); From bf2348c8b8a7cf8d64d71da7770fc6297f150966 Mon Sep 17 00:00:00 2001 From: Steve Calvert Date: Wed, 21 Oct 2026 16:44:24 -0700 Subject: [PATCH 23/24] test(lifecycle): set one CI-sized timeout for the journey file --- tests/lifecycle-journeys.test.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/lifecycle-journeys.test.ts b/tests/lifecycle-journeys.test.ts index 7cee503..9cfab3a 100644 --- a/tests/lifecycle-journeys.test.ts +++ b/tests/lifecycle-journeys.test.ts @@ -26,6 +26,11 @@ import type { Artifact, TargetName } from "../src/types.js"; type DirJSON = NonNullable; +// Each journey runs several real builds and validations. The longest take +// 3-4s locally, and CI runners run the whole suite in parallel, so vitest's +// 5s default is too tight for this file. +vi.setConfig({ testTimeout: 60_000 }); + const allTargets: TargetName[] = [ "cursor", "claude", @@ -521,9 +526,7 @@ describe.each(Object.entries(layouts))( expect( warnings.filter((warning) => /edited|changed since/i.test(warning)), ).toEqual([]); - // Six targets through seven steps, each followed by six validations, - // takes 3-4s locally and more than vitest's 5s default on CI. - }, 60_000); + }); }, ); From 2b8cb108f7ccd012424631b2bb2bcd9a5050b3ff Mon Sep 17 00:00:00 2001 From: Steve Calvert Date: Wed, 21 Oct 2026 17:03:34 -0700 Subject: [PATCH 24/24] test(readme): classify the corrected install command blocks The conformance change replaced the Claude Code and Antigravity install examples with host CLI commands. install-snippet.test.ts checks install-info output for both. --- tests/readme-examples.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/readme-examples.test.ts b/tests/readme-examples.test.ts index f6c6e96..2132efb 100644 --- a/tests/readme-examples.test.ts +++ b/tests/readme-examples.test.ts @@ -91,6 +91,10 @@ const inventory: Record = { "not runnable: a fragment; partials are run by lifecycle-journeys.test.ts", "CI Change Detection > bash #1": "run", "Programmatic API > ts #1": "run", + "Claude Code > bash #1": + "not runnable: host CLI commands; install-snippet.test.ts checks install-info", + "Antigravity > bash #1": + "not runnable: host CLI commands; install-snippet.test.ts checks install-info", ...Object.fromEntries( [ "init",