/** * --publish handler: registers (or updates) the scope type on the platform. * * Idempotent: for each resource, checks if it exists by name/slug and PATCHes it; * otherwise CREATEs a new one. * * Shells out to the `np` CLI (bundled OpenAPI specs) to manage: * 1. service_specification — scope definition + capabilities schema * 2. action_specification — one per action in the manifest * 3. scope_type — links service spec as provider * 4. notification/channel — dev-only channel with NP_PLUGIN routing * * Requires `np` CLI installed and logged in (`np login`). */ import { $ } from "bun"; import { getActionDefaults } from "./actions"; import { cfg } from "../config"; import { schemaWithAssetType, type ScopeDefinition } from "./index"; export async function handlePublish( definition: ScopeDefinition ): Promise { const nrn = extractNrn(); if (!nrn) { console.error('Missing --nrn. Usage: --publish --nrn "organization=123"'); process.exit(1); } await checkNpCli(); console.log(`\nPublishing scope type: ${definition.name}`); console.log(`NRN: ${nrn}\n`); // 1. Service specification (upsert by name) // uiSchema is a custom JSON Schema keyword — embed it inside the schema itself. // asset_type is a platform-mandatory property — same injection as --describe. const attributesSchema: Record = schemaWithAssetType(definition); if (definition.uiSchema) { attributesSchema.uiSchema = definition.uiSchema; } const serviceSpecBody = { name: definition.name, type: "scope", visible_to: [nrn], attributes: { schema: attributesSchema, values: {}, }, selectors: { category: definition.category ?? "any", sub_category: definition.subCategory ?? "any", provider: definition.provider ?? "any", imported: false, }, }; const existingSpec = await findFirst( [ "service", "specification", "list", "--nrn", nrn, "--type", "scope", "--limit", "200", ], (item: any) => item.name === definition.name ); let serviceSpecId: string; if (existingSpec) { await npPatch( ["service", "specification", "patch", "--id", String(existingSpec.id)], serviceSpecBody, `service specification "${definition.name}"` ); serviceSpecId = String(existingSpec.id); } else { serviceSpecId = await npCreate( ["service", "specification", "create"], serviceSpecBody, "service specification" ); } console.log( ` ${existingSpec ? "↻" : "✓"} Service specification: ${serviceSpecId}` ); // 2. Action specifications (upsert each by name) const existingActions = await listAll([ "service", "specification", "action", "specification", "list", "--serviceSpecificationId", serviceSpecId, "--limit", "200", ]); const actionByName = new Map(existingActions.map((a: any) => [a.name, a])); for (const [slug, action] of Object.entries(definition.actions)) { const defaults = getActionDefaults(slug); const actionBody = { name: slug, type: action.type ?? defaults.type, parameters: { schema: action.input, values: {}, }, results: { schema: action.output ?? { type: "object", properties: {} }, values: {}, }, }; const existing = actionByName.get(slug) as any; if (existing) { await npPatch( [ "service", "specification", "action", "specification", "patch", "--serviceSpecificationId", serviceSpecId, "--id", existing.id, ], actionBody, `action "${slug}"` ); console.log(` ↻ Action updated: ${slug} (${existing.id})`); } else { const actionId = await npCreate( [ "service", "specification", "action", "specification", "create", "--serviceSpecificationId", serviceSpecId, ], actionBody, `action "${slug}"` ); console.log(` ✓ Action created: ${slug} (${actionId})`); } } // 3. Scope type (upsert by name) const scopeTypeBody = { name: definition.name, type: "custom", status: "active", description: truncate(definition.description ?? definition.name, 60), nrn: nrn, provider_type: "service", provider_id: serviceSpecId, }; const existingScopeType = await findFirst( [ "scope", "type", "list", "--nrn", nrn, "--name", definition.name, "--limit", "200", ], (item: any) => item.name === definition.name ); let scopeTypeId: string; if (existingScopeType) { await npPatch( ["scope", "type", "patch", "--id", String(existingScopeType.id)], scopeTypeBody, `scope type "${definition.name}"` ); scopeTypeId = String(existingScopeType.id); } else { scopeTypeId = await npCreate( ["scope", "type", "create"], scopeTypeBody, "scope type" ); } console.log(` ${existingScopeType ? "↻" : "✓"} Scope type: ${scopeTypeId}`); // 4. Dev notification channel (upsert by description tag) const apiKey = cfg.apiKey(); if (!apiKey) { console.error("\n⚠ Skipping notification channel — NP_API_KEY not set"); console.error( " Set it and re-run, or create the channel manually via the UI" ); return; } const channelDescription = `[dev] ${definition.name}`; const channelBody = { nrn: nrn, source: ["service"], type: "agent", description: channelDescription, configuration: { api_key: apiKey, // Matches the tags the dev-agent sets on registration (see scripts/dev-agent.ts). // Only agents with matching tags will receive commands from this channel. selector: { env: "dev", plugin: definition.name, }, command: { type: "exec", data: { cmdline: "true", environment: { NP_ACTION_CONTEXT: "${NOTIFICATION_CONTEXT}", NP_PLUGIN: definition.name, }, }, }, }, // Filter: only route actions on services whose spec slug matches this scope type. // Uses mingo (MongoDB-style) query syntax via policyspecs-lib-js. filters: { "service.slug": definition.name, }, }; const existingChannel = await findFirst( [ "notification", "channel", "list", "--nrn", nrn, "--type", "agent", "--limit", "200", ], (item: any) => item.description === channelDescription ); let channelId: string; if (existingChannel) { await npPatch( ["notification", "channel", "patch", "--id", String(existingChannel.id)], channelBody, "dev notification channel" ); channelId = String(existingChannel.id); } else { channelId = await npCreate( ["notification", "channel", "create"], channelBody, "dev notification channel" ); } console.log( ` ${existingChannel ? "↻" : "✓"} Dev notification channel: ${channelId}` ); console.log(`\n✓ Published ${definition.name} → scope_type=${scopeTypeId}\n`); } // ─── Helpers ──────────────────────────────────────────────────────── function extractNrn(): string | undefined { const i = process.argv.indexOf("--nrn"); return i !== -1 ? process.argv[i + 1] : undefined; } function truncate(s: string, max: number): string { return s.length > max ? s.slice(0, max - 1) + "…" : s; } async function checkNpCli(): Promise { try { await $`np --version`.quiet(); } catch { console.error( "np CLI not found. Install it from https://github.com/nullplatform/cli" ); process.exit(1); } } async function runNp( args: string[] ): Promise<{ stdout: string; stderr: string; exitCode: number }> { const proc = Bun.spawn(["np", ...args], { stdout: "pipe", stderr: "pipe" }); const [stdout, stderr] = await Promise.all([ new Response(proc.stdout).text(), new Response(proc.stderr).text(), ]); const exitCode = await proc.exited; return { stdout, stderr, exitCode }; } async function npCreate( command: string[], body: unknown, label: string ): Promise { const { stdout, stderr, exitCode } = await runNp([ ...command, "--body", JSON.stringify(body), "--format", "json", "--query", ".id", ]); if (exitCode !== 0) { console.error(`\n✗ Failed to create ${label}`); console.error(` Command: np ${command.join(" ")}`); console.error(` Error: ${stderr.trim() || stdout.trim()}`); process.exit(1); } const id = stdout.trim().replace(/^"|"$/g, ""); if (!id || id === "null") { console.error(`\n✗ Empty response from create ${label}`); console.error(` Output: ${stdout}`); process.exit(1); } return id; } async function npPatch( command: string[], body: unknown, label: string ): Promise { const { stdout, stderr, exitCode } = await runNp([ ...command, "--body", JSON.stringify(body), "--format", "json", ]); if (exitCode !== 0) { console.error(`\n✗ Failed to update ${label}`); console.error(` Command: np ${command.join(" ")}`); console.error(` Error: ${stderr.trim() || stdout.trim()}`); process.exit(1); } } async function listAll(command: string[]): Promise { const { stdout, stderr, exitCode } = await runNp([ ...command, "--format", "json", "--query", ".results", ]); if (exitCode !== 0) { console.error(`\n✗ Failed to list`); console.error(` Command: np ${command.join(" ")}`); console.error(` Error: ${stderr.trim() || stdout.trim()}`); process.exit(1); } try { const parsed = JSON.parse(stdout || "[]"); return Array.isArray(parsed) ? parsed : []; } catch { return []; } } async function findFirst( command: string[], predicate: (item: any) => boolean ): Promise { const items = await listAll(command); return items.find(predicate) ?? null; }