/** * Capability plugin contract — Phase 2 (#559, epic #551). * * Promotes the Phase 1 capability registry convention * (`createCapabilityRegistry`/`STARTER_VERB_FAMILIES`, ./registry.ts) to a * first-class chant plugin contract, mirroring the lexicon plugin contract * (../lexicon.ts, ../cli/plugins.ts) so capabilities become typed, * discoverable packages resolved by `kind`, the same way lexicons are * discovered and resolved by resource type. * * Shape parity with `LexiconPlugin` (../lexicon.ts): * - `name`/`version` mirror `LexiconManifest.name`/`.version`. * - `capabilities()` is the required lifecycle method (like a lexicon's * `serializer`/`generate`/`validate`) every capability package must * implement — it returns the typed `Capability` instances this package * contributes, registered by `kind` the same way a lexicon's resource * types are registered by `resourceType`. * - `families()`, `init()` are optional extensions, mirroring * `LexiconPlugin`'s optional `lintRules`/`init`/etc. * - `isCapabilityPlugin` is the runtime type guard `loadCapabilityPlugin` * (./capability-plugin-loader.ts) uses to validate a dynamically-imported * package before trusting it — the same defensive shape check * `isLexiconPlugin` performs for lexicons. * * The starter verb set (./registry.ts) becomes the built-in, always-loaded * plugin (`starterCapabilityPlugin`, ./starter-plugin.ts) under this same * contract — see that module's docstring for the Phase 1 -> Phase 2 * migration path and the "no behavior change" guarantee. */ import { readFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import type { Capability } from "./capability"; /** * Manifest for a packaged capability plugin — the capability-side analogue * of `LexiconManifest` (../lexicon.ts). Kept as a plain data shape (rather * than folded into `CapabilityPlugin`) so it can be serialized alongside a * package (e.g. into a future tarball/`meta.json`, matching how lexicons * carry `LexiconManifest` in their `BundleSpec`) independent of the runtime * plugin object. */ export interface CapabilityManifest { /** Package/plugin name (e.g. "aws", "gcp"), not a capability `kind`. */ name: string; /** The plugin package's version (semver), read from its `package.json`. */ version: string; /** Minimum/compatible chant core version, checked the same way as `LexiconManifest.chantVersion` (see ../lexicon-manifest.ts's `checkVersionCompatibility`). */ chantVersion?: string; /** Every capability `kind` this plugin contributes — informational/validation aid; must match what `capabilities()` actually returns. */ kinds?: string[]; } /** * Plugin interface for capability packages — the capability-side analogue of * `LexiconPlugin` (../lexicon.ts). A capability plugin ships one or more * typed `Capability` implementations, discovered and loaded the same way a * lexicon plugin is: dynamically imported by a package-naming convention * (`@intentius/chant-capability-`, see ./capability-plugin-loader.ts), * validated with a runtime shape guard, then registered into a * `CapabilityRegistry` the driver resolves verbs through by `kind`. * * Required lifecycle method enforces consistency: every capability package * must be able to enumerate the capabilities it contributes. */ export interface CapabilityPlugin { // ── Required ────────────────────────────────────────────── /** Human-readable plugin/package name (e.g. "aws", "gcp"), not a capability `kind`. */ readonly name: string; /** * The plugin package's version (semver), read from its `package.json` * (chant #1505). Mirrors `LexiconManifest.version`. Informational — nothing * gates on it. The built-in plugins expose it as a lazy getter over * `ownPackageVersion`, so the read happens on first access and never at * module scope. */ readonly version: string; /** Return every `Capability` this plugin contributes, keyed for registration by its own `kind`. */ capabilities(): Array>; // ── Optional extensions ─────────────────────────────────── /** Minimum/compatible chant core version this plugin requires (checked via ../lexicon-manifest.ts's `checkVersionCompatibility`). */ readonly chantVersion?: string; /** Capability kinds grouped by family, informational — mirrors `STARTER_VERB_FAMILIES` (./registry.ts) shape for third-party plugins that want to publish the same grouping. */ families?(): Record; /** Optional initialization hook, called once after load (mirrors `LexiconPlugin.init`). */ init?(): void | Promise; } /** * Type guard to check if a value is a `CapabilityPlugin`. Checks for the * required `name`/`version`/`capabilities` shape, the same defensive check * `isLexiconPlugin` (../lexicon.ts) performs before a dynamically-imported * package is trusted and registered. This is also chant's detector for a * *malformed* capability package (#559 acceptance criteria): a package * missing any of these fails the guard and is rejected by the loader with a * descriptive error rather than registered half-working. */ export function isCapabilityPlugin(value: unknown): value is CapabilityPlugin { if ( typeof value !== "object" || value === null || !("name" in value) || typeof (value as Record).name !== "string" || !("version" in value) || typeof (value as Record).version !== "string" ) { return false; } const obj = value as Record; return typeof obj.capabilities === "function"; } /** * The calling module's own package version, read from the nearest * `package.json` above it (chant #1505). * * `CapabilityPlugin.version` documents itself as the plugin package's semver, * but chant's packages release in lockstep, so a hardcoded literal goes stale * on every `just release` — the aws plugin shipped `"1.0.0"` from the day it * was extracted from the starter set (#681), and the k8s plugin's authoring- * time `"0.41.0"` was stale one release later. Walking up from the module's * own URL survives both the `src/` (development condition) and `dist/` * layouts, and an npm install, without any build-time stamping. * * Returns `"0.0.0"` when no versioned `package.json` is found — a visible * sentinel rather than a guess; nothing gates on the field. * * Call it lazily (from a `get version()` accessor, as the built-in plugins * do), not at module scope: the read touches the filesystem, and a plugin * module must stay importable where `fs` is absent (workerd, chant #1081). * The result is cached per module URL, so the walk happens once. */ export function ownPackageVersion(moduleUrl: string): string { const cached = ownVersionCache.get(moduleUrl); if (cached !== undefined) return cached; const found = findPackageVersion(moduleUrl); ownVersionCache.set(moduleUrl, found); return found; } const ownVersionCache = new Map(); function findPackageVersion(moduleUrl: string): string { let dir = dirname(fileURLToPath(moduleUrl)); for (;;) { try { const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf-8")) as { version?: unknown }; if (typeof pkg.version === "string") return pkg.version; } catch { // No package.json here (or unreadable) — keep walking up. } const parent = dirname(dir); if (parent === dir) return "0.0.0"; dir = parent; } }