From 50e478cc17975dd5a589c84776090474536d6e65 Mon Sep 17 00:00:00 2001 From: Steve Calvert Date: Wed, 28 Oct 2026 17:45:17 -0700 Subject: [PATCH] feat(config): add a top-level partials field Partials were configured as source.partials, inside the source object that 0.11 marked legacy along with the 0.10 discovery fields (plugins, skills, rootPlugin). Partials are not part of that model and have no replacement, so the label told users a supported feature was going away. Add a top-level partials field with the same safe-path rule. source.partials keeps working as the older spelling. A config that sets both fails to load, so the two can't silently disagree. Errors and the outDir guard name whichever field the config used. Update the README config tables to mark only plugins, skills and rootPlugin as legacy, and correct the 0.11 migration guide, which said to keep source.partials only temporarily. --- MIGRATING_TO_0.11.md | 5 +-- README.md | 33 ++++++++++-------- src/config.ts | 34 +++++++++++++++---- src/partials.ts | 2 +- src/schema.ts | 25 ++++++++++++-- tests/core.test.ts | 46 ++++++++++++++++++++++++- tests/physical-path-safety.test.ts | 54 ++++++++++++++++++++++++++++++ 7 files changed, 171 insertions(+), 28 deletions(-) diff --git a/MIGRATING_TO_0.11.md b/MIGRATING_TO_0.11.md index 86e9f9f..3bbf753 100644 --- a/MIGRATING_TO_0.11.md +++ b/MIGRATING_TO_0.11.md @@ -139,8 +139,9 @@ Omit `overrides` when that directory does not exist. Prefer `exclude` when a target differs from pluginpack's defaults by only one or two content kinds. Delete `source.skills`, `source.rootPlugin`, and `source.plugins` after every -emitted plugin uses a direct `source`. Keep `source.partials` temporarily if the -repository uses partials. +emitted plugin uses a direct `source`. Keep `source.partials` if the repository +uses partials. It is not part of the legacy discovery model, and later versions +also accept it as the top-level `partials` field. ## 7. Update scripts and ignored paths diff --git a/README.md b/README.md index d01b507..f03a8ca 100644 --- a/README.md +++ b/README.md @@ -659,15 +659,17 @@ shared/acme/skills/release-notes/SKILL.md -> contains {{> auth}} shared/acme/skills/changelog/SKILL.md -> contains {{> auth}} ``` -Point `source.partials` at that directory in `pluginpack.config.ts`: +Point `partials` at that directory in `pluginpack.config.ts`: ```ts export default defineConfig({ - source: { partials: "partials" }, + partials: "partials", // ... }); ``` +`source: { partials: "partials" }` is the older spelling and still works. Set one or the other, not both. + Partials are project-level (shared across every source plugin, not scoped to one), and may reference other partials — nested composition resolves in one pass, though a circular reference (A includes B includes A) is a build-time error. A tag alone on its own line — the common case — leaves no blank line behind. Substitution runs on every `.md`/`.mdc`/`.markdown`/`.txt` file pluginpack emits, whatever the case of the extension (`NOTES.MD` counts) — skills, agents, commands, rules, declared shipping files, and a target's `repositoryFiles` — via the real [`mustache`](https://github.com/janl/mustache.js) library. @@ -728,22 +730,25 @@ Keys stripped by config parsing produce a warning that names the config file and **Top level** -| Field | Type | Required | Meaning | -| ---------- | ------ | -------- | ---------------------------------------------------------------------------------- | -| `name` | string | yes | Marketplace/source name written into generated manifests. | -| `version` | string | yes | Default version stamped into manifests (per-target/plugin overridable). | -| `source` | object | legacy | 0.10 discovery/partials config; direct plugin sources now live on emitted plugins. | -| `metadata` | object | no | Shared metadata merged into manifests (see **`metadata`**). | -| `targets` | object | yes | Per-target output config, keyed by target name (see **`targets.`**). | +| Field | Type | Required | Meaning | +| ---------- | ------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `name` | string | yes | Marketplace/source name written into generated manifests. | +| `version` | string | yes | Default version stamped into manifests (per-target/plugin overridable). | +| `partials` | string | no | Directory of reusable `{{> name}}` text fragments, shared across every source plugin (see **Partials**). Safe relative: it must sit inside the project. | +| `source` | object | no | 0.10 source discovery, plus the older spelling of `partials` (see **`source`**). Direct plugin sources now live on emitted plugins. | +| `metadata` | object | no | Shared metadata merged into manifests (see **`metadata`**). | +| `targets` | object | yes | Per-target output config, keyed by target name (see **`targets.`**). | + +**`source`** -**Legacy `source` (0.10 migration compatibility)** +`plugins`, `skills` and `rootPlugin` are legacy 0.10 fields, kept for migration. `partials` is not legacy. | Field | Type | Required | Meaning | | ------------ | ------ | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `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**). 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. | +| `plugins` | string | legacy | Directory to discover source plugins from. Defaults to `plugins`. | +| `skills` | string | legacy | Repo-level skills directory; creates a root source plugin from sibling component dirs. | +| `partials` | string | no | Older spelling of the top-level `partials` field. Still accepted; a config that sets both fails to load. | +| `rootPlugin` | object | legacy | 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/config.ts b/src/config.ts index 37a9048..fc1f25a 100644 --- a/src/config.ts +++ b/src/config.ts @@ -45,18 +45,37 @@ export async function loadConfig( } /** - * Loads the project-level `partials/` map if `source.partials` is - * configured, shared across every source plugin (unlike components, which - * are scoped to one plugin's own directory) — see `src/partials.ts`. + * The configured partials directory and the field that set it. `partials` is + * the current spelling; `source.partials` is the older one and still works. + * The schema rejects a config that sets both. + */ +function configuredPartials( + config: ResolvedPluginpackConfig, +): { dir: string; field: "partials" | "source.partials" } | undefined { + if (config.partials) { + return { dir: config.partials, field: "partials" }; + } + if (config.source?.partials) { + return { dir: config.source.partials, field: "source.partials" }; + } + return undefined; +} + +/** + * Loads the project-level `partials/` map if `partials` (or the older + * `source.partials`) is configured, shared across every source plugin (unlike + * components, which are scoped to one plugin's own directory) — see + * `src/partials.ts`. */ async function loadPartialsIfConfigured( rootDir: string, config: ResolvedPluginpackConfig, ): Promise> { - if (!config.source?.partials) { + const configured = configuredPartials(config); + if (!configured) { return new Map(); } - const partialsDir = path.resolve(rootDir, config.source.partials); + const partialsDir = path.resolve(rootDir, configured.dir); if (!(await exists(partialsDir))) { throw new Error(`Source partials directory is missing: ${partialsDir}`); } @@ -183,8 +202,9 @@ export async function listSourcePaths( if (config.source?.skills) { add(config.source.skills, "source.skills"); } - if (config.source?.partials) { - add(config.source.partials, "source.partials"); + const partials = configuredPartials(config); + if (partials) { + add(partials.dir, partials.field); } if (config.source?.plugins) { add(config.source.plugins, "source.plugins"); diff --git a/src/partials.ts b/src/partials.ts index fdb45e4..6e74cf1 100644 --- a/src/partials.ts +++ b/src/partials.ts @@ -301,7 +301,7 @@ function describe(error: unknown, partials: Map): string { } const names = [...partials.keys()]; if (names.length === 0) { - return `${error.message}, and no partials are configured — set \`source.partials\` in pluginpack.config.ts`; + return `${error.message}, and no partials are configured — set \`partials\` in pluginpack.config.ts`; } const suggestion = closestName(error.partialName, names); return [ diff --git a/src/schema.ts b/src/schema.ts index 079a75c..e102935 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -41,12 +41,19 @@ const rootPluginSchema = metadataSchema.extend({ description: 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. +const partialsPath = safeRelativePath; + +/** + * The 0.10 source-discovery fields. `plugins`, `skills` and `rootPlugin` are + * legacy: 0.11 emitted plugins name a direct `source` instead. `partials` is + * not legacy; it is the older spelling of the top-level `partials` field. + */ const sourceSchema = z.object({ plugins: z.string().optional(), skills: 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(), + partials: partialsPath.optional(), rootPlugin: rootPluginSchema.optional(), }); @@ -233,6 +240,7 @@ const configSchema = z .object({ name: z.string().min(1), version: z.string().min(1), + partials: partialsPath.optional(), source: sourceSchema.optional(), metadata: metadataSchema.optional(), targets: z.object({ @@ -245,6 +253,17 @@ const configSchema = z }), }) .superRefine((config, ctx) => { + if ( + config.partials !== undefined && + config.source?.partials !== undefined + ) { + ctx.addIssue({ + code: "custom", + path: ["source", "partials"], + message: + 'set either "partials" or "source.partials", not both; "partials" is the current spelling', + }); + } for (const [target, targetConfig] of Object.entries(config.targets)) { const problem = marketplaceDirProblem( target, diff --git a/tests/core.test.ts b/tests/core.test.ts index 50e6dc9..793c0d7 100644 --- a/tests/core.test.ts +++ b/tests/core.test.ts @@ -2992,6 +2992,50 @@ More instructions. `); }); + it("substitutes partials configured with the top-level partials field into a direct source", async () => { + const project = await fixtureProject({ + "pluginpack.config.ts": `import { defineConfig } from "${path.resolve("src/index.ts")}"; + +export default defineConfig({ + name: "top-level-partials", + version: "1.0.0", + metadata: { description: "Partials", author: { name: "X" }, license: "MIT" }, + partials: "partials", + targets: { + claude: { + outDir: "dist/claude", + plugins: { demo: { source: "shared/demo" } } + } + } +}); +`, + partials: { + "auth.md": "Authenticate via OAuth, falling back to a token.\n", + }, + shared: { + demo: { + skills: { + demo: { + "SKILL.md": skill("demo", "Demo skill.") + "\n{{> auth}}\n", + }, + }, + }, + }, + }); + const root = project.baseDir; + + await build({ cwd: root, target: "claude" }); + + const content = await readFile( + path.join(root, "dist/claude/plugins/demo/skills/demo/SKILL.md"), + "utf8", + ); + expect(content).toBe( + skill("demo", "Demo skill.") + + "\nAuthenticate via OAuth, falling back to a token.\n", + ); + }); + it("fails the build on a partial reference no partial satisfies", async () => { const project = await fixtureProject({ "pluginpack.config.ts": `import { defineConfig } from "${path.resolve("src/index.ts")}"; @@ -3063,7 +3107,7 @@ export default defineConfig({ const root = project.baseDir; await expect(build({ cwd: root, target: "claude" })).rejects.toThrow( - /no partials are configured — set `source\.partials`/, + /no partials are configured — set `partials`/, ); }); diff --git a/tests/physical-path-safety.test.ts b/tests/physical-path-safety.test.ts index ed982b3..951fef5 100644 --- a/tests/physical-path-safety.test.ts +++ b/tests/physical-path-safety.test.ts @@ -415,6 +415,29 @@ describe("source protection compares physical paths", () => { ); }); + it.each([ + ["partials", `partials: "partials",`], + ["source.partials", `source: { partials: "partials" },`], + ])( + "refuses to load a config whose outDir sits inside the %s directory", + async (field, sourceBlock) => { + const root = await makeProject( + ` antigravity: { + outDir: "partials/out", + plugins: { acme: { source: "shared/acme" } } + }`, + { partials: { "auth.md": "Authenticate first.\n" } }, + sourceBlock, + ); + + await expect(loadConfig(root)).rejects.toThrow( + new RegExp( + `targets\\.antigravity\\.outDir .* resolves inside .*partials \\(${field.replace(".", "\\.")}\\)`, + ), + ); + }, + ); + it("refuses a --out-dir override inside source", async () => { const root = await makeProject(` antigravity: { outDir: "dist/antigravity", @@ -1301,6 +1324,36 @@ describe("an external outDir keeps its version-control data", () => { }); describe("config path fields stay inside the project", () => { + it("rejects partials outside the project at config load", async () => { + const root = await makeProject( + ` antigravity: { + outDir: "out", + plugins: { acme: { source: "shared/acme" } } + }`, + {}, + `partials: "../outside",`, + ); + + await expect(loadConfig(root)).rejects.toThrow( + /Invalid pluginpack config in .*: partials: must be a safe relative path/, + ); + }); + + it("rejects a config that sets both partials and source.partials", async () => { + const root = await makeProject( + ` antigravity: { + outDir: "out", + plugins: { acme: { source: "shared/acme" } } + }`, + { partials: { "auth.md": "Authenticate first.\n" } }, + `partials: "partials",\n source: { partials: "partials" },`, + ); + + await expect(loadConfig(root)).rejects.toThrow( + /source\.partials: set either "partials" or "source\.partials", not both/, + ); + }); + it("rejects source.partials outside the project at config load", async () => { const root = await makeProject( ` antigravity: { @@ -1317,6 +1370,7 @@ describe("config path fields stay inside the project", () => { }); it.each([ + ["partials", `partials: "https://../../outside",`, ""], ["source.partials", `source: { partials: "https://../../outside" },`, ""], [ "targets.antigravity.plugins.acme.source",