/** * The manifest: what `np package publish` reads. One specification — the * shape `defineScope`'s `--describe` prints (name, version, schema, actions * by slug, agent routing) — plus `kind`, `displayName` and, for a dependency * service, `useDefaultActions`, `assignableTo` and `links`: every `link()` of * the service, each with its own actions. Older CLIs ignore the fields they * do not know. * * The manifest describes ONE service or scope per package. A package with * none (only `execute()` or `event()` components) describes its identity and * routing, nothing else. `event()` components have no place in it yet and are * left out, with a note on stderr. */ import { getActionDefaults } from "../scope/actions"; import type { EventComponent, ExecuteComponent } from "./components"; import type { ResolvedAgentConfig } from "./manifest"; import type { Resource } from "./resource"; import { isSpecification, type ActionSpecificationDef } from "./service"; export interface DescribeInput { slug: string; version: string; description?: string; agent: ResolvedAgentConfig; resources: Resource[]; events: EventComponent[]; execute?: ExecuteComponent; } export interface Described { manifest: Record; /** What the manifest leaves out, for stderr. */ notes: string[]; } interface SpecBody { name?: string; type?: string; useDefaultActions?: boolean; assignableTo?: string; selectors?: { category?: string; provider?: string; subCategory?: string }; attributes?: { schema?: Record & { uiSchema?: unknown; properties?: Record; }; }; actionSpecifications?: ActionSpecificationDef[]; telemetry?: unknown; } const isDefault = (slug: string) => slug === "create" || slug === "update" || slug === "delete"; /** * A specification's actions by slug: `name` (display), `type`, `retryable`, * `lifecycle`, `input` and `output`. A service's or link's default * create/update/delete are generated by the platform and left out; a scope * declares every action it answers. */ function describeActions( res: Resource, isScope: boolean ): Record { const body = res.body as SpecBody; const declared = new Map( (body.actionSpecifications ?? []).map((a) => [a.slug, a]) ); const actions: Record = {}; for (const slug of isSpecification(res) ? res.actions() : []) { if (!isScope && isDefault(slug)) continue; const a = declared.get(slug); const d = getActionDefaults(slug); actions[slug] = { name: a?.name ?? d.name, type: a?.type ?? d.type, retryable: a?.retryable ?? d.retryable, lifecycle: d.lifecycle, input: a?.parameters?.schema, output: a?.results?.schema, }; } return actions; } /** A link of the service, under `links` by its slug: the same shape as the service, minus the routing. */ function describeLink(res: Resource): Record { const body = res.body as SpecBody; const schema = body.attributes?.schema; return { name: res.slug, displayName: body.name, schema, uiSchema: schema?.uiSchema, useDefaultActions: body.useDefaultActions !== false, actions: describeActions(res, false), }; } export function describeManifest(input: DescribeInput): Described { const specs = input.resources.filter( (r) => r.kind === "service_specification" ); const notes: string[] = []; for (const e of input.events) notes.push( `event ${e.name}: an event has no channel in the manifest yet; register its channel by hand` ); if (specs.length === 0) { const manifest = { name: input.slug, version: input.version, description: input.description, agent: { ...input.agent, sources: input.agent.sources.length ? input.agent.sources : ["service"] }, }; return { manifest: JSON.parse(JSON.stringify(manifest)), notes }; } if (specs.length !== 1) throw new Error( `--describe: the manifest describes one service or scope per package; this package has ${specs.length}` ); const spec = specs[0]; // The manifest has one identity: `name` is the specification slug, and the // CLI derives the package slug, the channel's package, NP_PLUGIN and the // registry from it, while the agent selector and entrypoint come from the // package slug. They must be the same name, or routing splits in two. if (input.slug !== spec.slug) throw new Error( `--describe: the manifest names the package after its one specification, "${spec.slug}", but the package slug is "${input.slug}". ` + `Make them equal (package.json name, or definePackage({ slug })).` ); const body = spec.body as SpecBody; const isScope = body.type === "scope"; const bound = isSpecification(spec) ? spec.actions() : []; const links = input.resources.filter( (r) => r.kind === "link_specification" ); const schema = body.attributes?.schema; const scopeType = spec.children.find((c) => c.kind === "scope_type"); const telemetryActions = bound.some( (slug) => !getActionDefaults(slug).lifecycle ); const manifest: Record = { kind: isScope ? "scope" : "service", ...(isScope ? {} : { useDefaultActions: body.useDefaultActions !== false, assignableTo: body.assignableTo, }), name: spec.slug, displayName: body.name, version: input.version, description: (scopeType?.body as { description?: string } | undefined)?.description ?? input.description, category: body.selectors?.category, provider: body.selectors?.provider, subCategory: body.selectors?.subCategory, ...(isScope ? { assetType: schema?.properties?.asset_type?.default ?? "docker-image" } : {}), schema, uiSchema: schema?.uiSchema, actions: describeActions(spec, isScope), ...(isScope ? {} : { links: Object.fromEntries( links.map((l) => [l.slug, describeLink(l)]) ), }), agent: { ...input.agent, sources: input.agent.sources.length ? input.agent.sources : telemetryActions ? ["service", "telemetry"] : ["service"], }, ...(isScope && body.telemetry ? { telemetry: body.telemetry } : {}), }; if (isScope && links.length) notes.push( `a scope has no links in the manifest; left out: ${links .map((l) => l.slug) .join(", ")}` ); return { manifest: JSON.parse(JSON.stringify(manifest)), notes }; }