/** * PlatformClient — the ONE port through which a plugin talks to the control * plane. A handler never calls the platform API directly; the SDK injects a * client and swaps the implementation by mode: * * - HttpPlatformClient real API calls (the dockerized agent / `np package run`) * - InMemoryPlatformClient records calls, prints locally, never networks * (`np package dev`, `np package test`) * * This is the pattern every comparable system converges on (Temporal activity * context, Crossplane ExternalClient, Terraform provider client, go-plugin host * services): local dev is isolated by construction, not by the accident of a * missing credential. */ import { cfg } from "../config"; import { patchActionStatus, patchActionMessages, getScopeCapabilities, patchScopeCapabilities, type ActionStatus, } from "../compat"; export type { ActionStatus }; export type LogStream = "stdout" | "stderr"; export interface PlatformClient { /** How this client is wired — "http" hits the real API, "memory" stays local. */ readonly mode: "http" | "memory"; /** Report an action lifecycle transition (in_progress → success/failed). */ transitionAction( serviceId: string, actionId: string, status: ActionStatus, results?: Record ): Promise; /** Append a log line to the action (shows live in the UI as it runs). */ emitLog( serviceId: string, actionId: string, line: string, stream: LogStream ): Promise; /** Read the scope's capabilities (the worker-managed `state` lives here). */ getScopeCapabilities(scopeId: string): Promise>; /** Replace the scope's capabilities (read-merge-write is the caller's job). */ patchScopeCapabilities( scopeId: string, capabilities: Record ): Promise; } /** Real client: talks to the nullplatform API. Used by the agent / `np package run`. */ export class HttpPlatformClient implements PlatformClient { readonly mode = "http" as const; async transitionAction( serviceId: string, actionId: string, status: ActionStatus, results?: Record ): Promise { await patchActionStatus(serviceId, actionId, status, results); } async emitLog( serviceId: string, actionId: string, line: string, stream: LogStream ): Promise { const message = line.trimEnd(); if (!message) return; // Local visibility: logs go to STDERR (stdout is reserved for the result // JSON the agent parses). This is the standard data-on-stdout / // logs-on-stderr split. The platform UI gets the same line as a properly // leveled action message below. process.stderr.write(message + "\n"); await patchActionMessages(serviceId, actionId, [ { level: stream === "stderr" ? "error" : "info", message }, ]); } async getScopeCapabilities( scopeId: string ): Promise> { return getScopeCapabilities(scopeId); } async patchScopeCapabilities( scopeId: string, capabilities: Record ): Promise { await patchScopeCapabilities(scopeId, capabilities); } } /** * Fake client: records every call and echoes logs to the terminal, but makes NO * network calls. Used for `np package dev` and tests so a plugin runs fully * local. Exposed calls are inspectable via `.calls` for assertions. */ export class InMemoryPlatformClient implements PlatformClient { readonly mode = "memory" as const; readonly calls: Array< | { kind: "transition"; actionId: string; status: ActionStatus; results?: Record; } | { kind: "log"; actionId: string; line: string; stream: LogStream } | { kind: "scopeRead"; scopeId: string } | { kind: "scopePatch"; scopeId: string; capabilities: Record; } > = []; private readonly scopeCapabilities = new Map< string, Record >(); constructor(private readonly opts: { echo?: boolean } = { echo: true }) {} async transitionAction( _serviceId: string, actionId: string, status: ActionStatus, results?: Record ): Promise { this.calls.push({ kind: "transition", actionId, status, results }); if (this.opts.echo) process.stderr.write(` • action ${status}\n`); } async emitLog( _serviceId: string, actionId: string, line: string, stream: LogStream ): Promise { const message = line.trimEnd(); if (!message) return; this.calls.push({ kind: "log", actionId, line: message, stream }); if (this.opts.echo) process.stderr.write(` → ${message}\n`); } async getScopeCapabilities( scopeId: string ): Promise> { this.calls.push({ kind: "scopeRead", scopeId }); return this.scopeCapabilities.get(scopeId) ?? {}; } async patchScopeCapabilities( scopeId: string, capabilities: Record ): Promise { this.calls.push({ kind: "scopePatch", scopeId, capabilities }); this.scopeCapabilities.set(scopeId, capabilities); } } /** * Pick the client for the current process. Explicit dev/test mode ALWAYS returns * the in-memory fake — even when NP_API_KEY is set — so local runs can never * mutate the real platform. Only a real agent run (no dev mode + a key present) * gets the HTTP client. */ export function resolvePlatformClient(): PlatformClient { const mode = cfg.mode(); if (mode === "dev" || mode === "test") return new InMemoryPlatformClient(); if (!cfg.apiKey()) return new InMemoryPlatformClient(); // no creds → stay local return new HttpPlatformClient(); }