Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions MIGRATING_TO_0.11.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
33 changes: 19 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.<name>`**). |
| 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.<name>`**). |

**`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`)

Expand Down
34 changes: 27 additions & 7 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Map<string, string>> {
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}`);
}
Expand Down Expand Up @@ -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");
Expand Down
2 changes: 1 addition & 1 deletion src/partials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -301,7 +301,7 @@ function describe(error: unknown, partials: Map<string, string>): 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 [
Expand Down
25 changes: 22 additions & 3 deletions src/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
});

Expand Down Expand Up @@ -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({
Expand All @@ -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,
Expand Down
46 changes: 45 additions & 1 deletion tests/core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")}";
Expand Down Expand Up @@ -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`/,
);
});

Expand Down
54 changes: 54 additions & 0 deletions tests/physical-path-safety.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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: {
Expand All @@ -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",
Expand Down
Loading