Skip to content
Merged
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
51 changes: 46 additions & 5 deletions src/adapters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ import {
validateCopilot,
validateCursor,
} from "./validate.js";
import {
emitFromDefinition,
validateFromDefinition,
} from "./targets/engine.js";
import { targets as registry } from "./targets/registry.js";
import type {
Artifact,
ResolvedProject,
Expand All @@ -35,25 +40,60 @@ type TargetValidator = (
issues: ValidationIssue[],
) => Promise<void>;

/** The emit and validate functions for one target. */
export type TargetAdapter = {
emit: TargetEmitter;
validate: TargetValidator;
};

// The one place a target is wired. `Record<TargetName, …>` is exhaustive at
// compile time — a new TargetName won't build until it has an entry here — so
// emit dispatch, validate dispatch, the CLI `--target` choices, and the set
// build() iterates all derive from this single source instead of parallel maps.
export const adapters: Record<TargetName, TargetAdapter> = {
// Legacy per-target functions, used only for targets not yet migrated to
// src/targets/registry.ts. Delete this map (and ../targets.ts/../validate.ts's
// per-target functions) once every TargetName has a registry entry.
const legacyAdapters: Record<TargetName, TargetAdapter> = {
cursor: { emit: emitCursor, validate: validateCursor },
claude: { emit: emitClaude, validate: validateClaude },
antigravity: { emit: emitAntigravity, validate: validateAntigravity },
copilot: { emit: emitCopilot, validate: validateCopilot },
codex: { emit: emitCodex, validate: validateCodex },
};

/**
* The one place a target is wired. `Record<TargetName, …>` is exhaustive at
* compile time — a new `TargetName` won't build until it has an entry here —
* so emit dispatch, validate dispatch, the CLI `--target` choices, and the
* set `build()` iterates all derive from this single source instead of
* parallel maps.
*
* During migration, a target resolves to the new registry
* (`src/targets/*.ts`) if it has an entry there, otherwise falls back to the
* legacy function — this map's shape stays the same either way, so callers
* never notice.
*/
export const adapters: Record<TargetName, TargetAdapter> = Object.fromEntries(
(Object.keys(legacyAdapters) as TargetName[]).map((target) => {
const definition = registry[target];
const adapter: TargetAdapter = definition
? {
emit: (project, targetName, targetConfig, outDir) =>
emitFromDefinition(
project,
targetName,
targetConfig,
outDir,
definition,
),
validate: (root, issues) =>
validateFromDefinition(root, issues, definition),
}
: legacyAdapters[target];
return [target, adapter];
}),
) as Record<TargetName, TargetAdapter>;

/** Every target name with an adapter — the exhaustive list `build()` and the CLI derive from. */
export const targetNames = Object.keys(adapters) as TargetName[];

/** Emits one target's output and applies its `rootFiles`, resolving `outDir` from config if omitted. */
export async function emitTarget(
project: ResolvedProject,
target: TargetName,
Expand All @@ -76,6 +116,7 @@ export async function emitTarget(
return withRootFiles(project, targetConfig, result);
}

/** Validates an already-built target's output directory. */
export async function validateOutput(
target: TargetName,
dir: string,
Expand Down
1 change: 1 addition & 0 deletions src/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
import { emitTarget, targetNames } from "./adapters.js";
import type { Artifact, BuildOptions, TargetName } from "./types.js";

/** Builds every configured (or explicitly selected) target and writes its output, unless `dryRun` is set. */
export async function build(options: BuildOptions = {}): Promise<Artifact[]> {
const project = await loadConfig(options.cwd, options.configPath);
const targets = options.target
Expand Down
2 changes: 2 additions & 0 deletions src/cleanup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
} from "./managed.js";
import type { CleanupResult, TargetName } from "./types.js";

/** Deletes managed files no longer produced by the current build, without rewriting current output. */
export async function prune(
options: {
cwd?: string;
Expand Down Expand Up @@ -39,6 +40,7 @@ export async function prune(
return results;
}

/** Deletes every managed file for the given target(s), tearing the generated output down entirely. */
export async function clean(
options: {
cwd?: string;
Expand Down
18 changes: 18 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,13 @@ import { diffTarget } from "./diff.js";
import { targetNames, validateOutput } from "./adapters.js";
import type { TargetName } from "./types.js";

/** CLI entry point. */
async function main(): Promise<void> {
const program = createProgram();
await program.parseAsync(process.argv);
}

/** Builds the `pluginpack` commander program: init, build, validate, diff, prune, clean, docs. */
function createProgram(): Command {
const program = new Command();
const pkg = readPackageJson();
Expand Down Expand Up @@ -227,6 +229,7 @@ function createProgram(): Command {
return program;
}

/** Prints `prune`/`clean` results, one line per affected file. */
function printCleanupResults(
results: Awaited<ReturnType<typeof prune>>,
verb: string,
Expand All @@ -241,6 +244,7 @@ function printCleanupResults(
}
}

/** Writes a starter `pluginpack.config.ts` and example source plugin. */
async function init(): Promise<void> {
const configPath = path.resolve("pluginpack.config.ts");
if (await exists(configPath)) {
Expand All @@ -267,13 +271,15 @@ async function init(): Promise<void> {
console.log("Created pluginpack.config.ts and plugins/example.");
}

/** Commander option parser validating `--target` against the known target names. */
function parseTarget(value: string): TargetName {
if (!(targetNames as string[]).includes(value)) {
throw new Error(`Expected one of: ${targetNames.join(", ")}.`);
}
return value as TargetName;
}

/** Splices generated content between README.md's `pluginpack-cli` marker comments. */
function replaceGeneratedSection(readme: string, content: string): string {
const start = "<!-- pluginpack-cli:start -->";
const end = "<!-- pluginpack-cli:end -->";
Expand All @@ -285,6 +291,7 @@ function replaceGeneratedSection(readme: string, content: string): string {
return `${readme.slice(0, startIndex + start.length)}\n\n${content}\n${readme.slice(endIndex)}`;
}

/** Reads the package's own version and description, for `--version`/`--help`. */
function readPackageJson(): { version: string; description: string } {
const packageJson = JSON.parse(
readFileSync(new URL("../package.json", import.meta.url), "utf8"),
Expand All @@ -298,6 +305,7 @@ function readPackageJson(): { version: string; description: string } {
return { version: packageJson.version, description: packageJson.description };
}

/** Renders the README's "CLI Reference" section from the live commander program. */
function renderCliReference(program: Command): string {
const lines = ["## CLI Reference", ""];
for (const command of program.commands) {
Expand Down Expand Up @@ -344,13 +352,19 @@ function renderCliReference(program: Command): string {
return lines.join("\n").trimEnd();
}

/** A command's usage line, e.g. `pluginpack build [--target <t>]`. */
function commandUsage(command: Command): string {
const usage = command.usage();
return usage
? `pluginpack ${command.name()} ${usage}`
: `pluginpack ${command.name()}`;
}

/**
* Example invocations shown in the generated CLI reference, keyed by
* command name. Hand-maintained, not derived from the command definitions —
* a new command needs a case added here or its docs section omits examples.
*/
function commandExamples(commandName: string): string[] {
switch (commandName) {
case "init":
Expand All @@ -376,6 +390,7 @@ function commandExamples(commandName: string): string[] {
}
}

/** Exit-code documentation shown in the generated CLI reference, keyed by command name (hand-maintained, see `commandExamples`). */
function commandExitCodes(commandName: string): string[] {
switch (commandName) {
case "init":
Expand Down Expand Up @@ -415,6 +430,7 @@ function commandExitCodes(commandName: string): string[] {
}
}

/** Whether the file at `filePath` exists. */
async function exists(filePath: string): Promise<boolean> {
try {
await fs.access(filePath);
Expand All @@ -424,6 +440,7 @@ async function exists(filePath: string): Promise<boolean> {
}
}

/** Content of the `pluginpack.config.ts` written by `init`. */
function starterConfig(): string {
return `import { defineConfig } from "@gleanwork/pluginpack";

Expand Down Expand Up @@ -465,6 +482,7 @@ export default defineConfig({
`;
}

/** Content of the example `SKILL.md` written by `init`. */
function starterSkill(): string {
return `---
name: example
Expand Down
5 changes: 5 additions & 0 deletions src/components.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { TargetName } from "./types.js";

/** Every recognized component directory, across all targets. */
export const componentDirs = [
"skills",
"agents",
Expand All @@ -12,8 +13,10 @@ export const componentDirs = [
"themes",
];

/** Files copied verbatim to a target's output root rather than treated as components. */
export const staticFiles = ["README.md", "CHANGELOG.md", "LICENSE"];

/** Component directories emitted for a target when a plugin has no `components` override. */
export const targetDefaultComponents: Record<TargetName, readonly string[]> = {
claude: ["skills", "agents", "hooks", "scripts", "assets"],
copilot: ["skills", "agents", "hooks", "scripts", "assets"],
Expand All @@ -22,13 +25,15 @@ export const targetDefaultComponents: Record<TargetName, readonly string[]> = {
codex: ["skills", "hooks", "scripts", "assets"],
};

/** Resolves a plugin's component set from its own override, or the target's default. */
export function resolveTargetComponents(
target: TargetName,
pluginConfig: { components?: string[] },
): Set<string> {
return new Set(pluginConfig.components ?? targetDefaultComponents[target]);
}

/** Whether a relative path falls under a recognized component directory. */
export function isComponentPath(relativePath: string): boolean {
return componentDirs.includes(relativePath.split("/")[0]);
}
3 changes: 3 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,12 @@ import type {
SourcePluginManifest,
} from "./types.js";

/** Identity helper giving config authors type-checking and editor completion. */
export function defineConfig(config: PluginpackConfig): PluginpackConfig {
return config;
}

/** Loads the project config and discovers its source plugins, ready for `build()`. */
export async function loadConfig(
cwd = process.cwd(),
configPath?: string,
Expand All @@ -36,6 +38,7 @@ export async function loadConfig(
};
}

/** Loads and validates the config file itself, without discovering source plugins. */
export async function loadProjectConfig(
cwd = process.cwd(),
configPath?: string,
Expand Down
1 change: 1 addition & 0 deletions src/diff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { exists } from "./fs.js";
import { normalizeManagedPath, readManagedManifest } from "./managed.js";
import type { DiffEntry, DiffResult, TargetName } from "./types.js";

/** Builds a target into a temp dir and diffs its managed files against an existing output repo. */
export async function diffTarget(options: {
cwd?: string;
configPath?: string;
Expand Down
14 changes: 11 additions & 3 deletions src/fs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@ import path from "node:path";
import fastGlob from "fast-glob";
import type { FileValue } from "./types.js";

/** Converts OS-specific path separators to forward slashes. */
export function toPosix(value: string): string {
return value.split(path.sep).join("/");
}

/** Lists every file under `dir`, recursively, as absolute paths, sorted. */
export async function walkFiles(dir: string): Promise<string[]> {
const entries = await fastGlob("**/*", {
cwd: dir,
Expand All @@ -17,6 +19,7 @@ export async function walkFiles(dir: string): Promise<string[]> {
return entries.sort();
}

/** Writes every file in `files` under `outDir`, refusing to write outside it. */
export async function writeArtifact(
outDir: string,
files: Map<string, FileValue>,
Expand All @@ -37,10 +40,12 @@ export async function writeArtifact(
}
}

/** Serializes `value` as pretty-printed JSON with a trailing newline. */
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. */
export function isSafeRelativePath(value: string): boolean {
if (!value) {
return false;
Expand All @@ -55,6 +60,7 @@ export function isSafeRelativePath(value: string): boolean {
return normalized !== ".." && !normalized.startsWith("../");
}

/** Whether the file at `filePath` exists. */
export async function exists(filePath: string): Promise<boolean> {
try {
await fs.access(filePath);
Expand All @@ -67,9 +73,11 @@ export async function exists(filePath: string): Promise<boolean> {
}
}

// ENOENT/ENOTDIR mean the path simply isn't there; any other errno (EACCES,
// ELOOP, …) is a real IO/permission failure that should surface rather than be
// silently read as "does not exist".
/**
* Whether `error` is an ENOENT/ENOTDIR ("path doesn't exist") rather than a
* real IO/permission failure (EACCES, ELOOP, …) that should surface instead
* of being read as "does not exist".
*/
export function isNotFoundError(error: unknown): boolean {
if (!(error instanceof Error) || !("code" in error)) {
return false;
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/** Public API — everything the CLI itself uses is exported here for programmatic use. */
export { defineConfig, loadConfig } from "./config.js";
export { build } from "./build.js";
export { clean, prune } from "./cleanup.js";
Expand Down
Loading