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",