@nullplatform/plugin 0.0.3 → 0.0.4-alpha.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -8,7 +8,9 @@
8
8
  <br>
9
9
  </h2>
10
10
 
11
- TypeScript SDK for building nullplatform plugins. Handles gRPC transport (HashiCorp go-plugin protocol), action routing, `--describe`, and `--publish`.
11
+ TypeScript SDK for building nullplatform plugins. Handles transport (gRPC
12
+ go-plugin **and** subprocess-exec), action routing, action lifecycle reporting,
13
+ `--describe`, and `--publish`.
12
14
 
13
15
  ## Install
14
16
 
@@ -16,6 +18,17 @@ TypeScript SDK for building nullplatform plugins. Handles gRPC transport (HashiC
16
18
  bun add @nullplatform/plugin
17
19
  ```
18
20
 
21
+ ## Layers
22
+
23
+ The SDK is layered so the stable core is insulated from today's rough platform
24
+ APIs:
25
+
26
+ - **base** — the plugin runtime, transport (gRPC + subprocess-exec), and the
27
+ action-lifecycle *protocol*. Stable.
28
+ - **compat** (`src/compat/`) — temporary shims for current APIs: token exchange,
29
+ action `PATCH` shapes. Each is deletable in isolation once the real API lands.
30
+ - **goal layers** — `defineScope` today; `defineService`, `defineHook` next.
31
+
19
32
  ## Usage
20
33
 
21
34
  ### Scope plugin
@@ -36,18 +49,51 @@ defineScope({
36
49
  },
37
50
  },
38
51
 
52
+ // Optional — how the platform routes actions to your worker. Defaults to
53
+ // selector { package: <name> } and entrypoint /app/packages/<name>/entrypoint.
54
+ agent: {
55
+ selector: { package: "kubernetes-k3d" },
56
+ },
57
+
39
58
  actions: {
40
59
  "create-scope": {
41
60
  input: { type: "object" },
42
61
  handler: async (notification, emit) => {
62
+ emit({ stdout: "Provisioning…" }); // live message on the action + worker logs
43
63
  // provision infrastructure
44
- return { domain: "app.k3d.local" };
64
+ return { domain: "app.k3d.local" }; // becomes the action results
45
65
  },
46
66
  },
47
67
  },
48
68
  });
49
69
  ```
50
70
 
71
+ #### Actions
72
+
73
+ `handler` is a plain `async (notification, emit) => result` (or a workflow
74
+ chain). The SDK reports the action lifecycle automatically: `in_progress` on
75
+ start, `success` with your returned object, or `failed` on throw. `emit({ stdout
76
+ })` streams a live message onto the action in the UI.
77
+
78
+ Well-known action slugs get their metadata (name, type, retryable, lifecycle)
79
+ for free — `create-scope`, `delete-scope`, `update-scope`, `start-initial`,
80
+ `start-blue-green`, `switch-traffic`, `finalize-blue-green`,
81
+ `rollback-deployment`, `delete-deployment`, `diagnose-scope`,
82
+ `diagnose-deployment`, `kill-instances`, `restart-pods`, `pause-autoscaling`,
83
+ `resume-autoscaling`, `set-desired-instance-count`. For a **custom** action, use
84
+ any slug and declare `name`/`type` inline:
85
+
86
+ ```ts
87
+ "say-hello": { name: "Say Hello", type: "custom", input, handler },
88
+ ```
89
+
90
+ #### Transport
91
+
92
+ Agents run the compiled plugin directly (subprocess-exec): the action arrives in
93
+ `NP_ACTION_CONTEXT`, the plugin runs it, reports status, prints the result, and
94
+ exits. The gRPC go-plugin server is also supported for hosts that use it. You
95
+ write handlers; the SDK picks the transport.
96
+
51
97
  ### Simple plugin (custom command)
52
98
 
53
99
  ```typescript
@@ -74,12 +120,17 @@ createPlugin({
74
120
 
75
121
  ## CLI integration
76
122
 
77
- Plugins are scaffolded with `np plugin init` and managed via mise tasks:
123
+ Packages are scaffolded and driven with the `np package` commands, which are
124
+ thin wrappers over the template's own tasks:
78
125
 
79
126
  ```bash
80
- mise run dev # local dev with hot reload
81
- mise run dev:agent # dev with np-agent attached
82
- mise run test # run tests
83
- mise run build # compile to standalone binary
84
- mise run publish # publish to platform
127
+ np package init # scaffold from a git template
128
+ np package dev # local dev UI + hot reload (→ mise/bun run dev)
129
+ np package test # run tests (→ mise/bun run test)
130
+ np package build --image # build the worker image
131
+ np package run # run the worker as a real local agent (docker)
132
+ np package publish # register on the platform (specs + channel + artifact)
85
133
  ```
134
+
135
+ `publish` reads the manifest from `--describe`, so the SDK owns the manifest
136
+ shape and the CLI stays decoupled from it. See the CLI's `docs/packages.md`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nullplatform/plugin",
3
- "version": "0.0.3",
3
+ "version": "0.0.4-alpha.2",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -9,11 +9,14 @@
9
9
  "./schema": "./src/schema.ts",
10
10
  "./scope": "./src/scope/index.ts",
11
11
  "./testing": "./src/testing/index.ts",
12
- "./workflow": "./src/workflow.ts"
12
+ "./workflow": "./src/workflow.ts",
13
+ "./service": "./src/service/index.ts",
14
+ "./platform": "./src/platform/index.ts"
13
15
  },
14
16
  "dependencies": {
15
17
  "@grpc/grpc-js": "^1.13.0",
16
18
  "@grpc/proto-loader": "^0.7.0",
19
+ "config": "^4.4.2",
17
20
  "yaml": "^2.7.0"
18
21
  },
19
22
  "files": [
@@ -46,6 +49,7 @@
46
49
  },
47
50
  "devDependencies": {
48
51
  "@types/bun": "^1.2.0",
52
+ "@types/config": "^4.4.0",
49
53
  "json-schema-to-ts": "^3.1.1"
50
54
  }
51
55
  }
package/src/api.ts CHANGED
@@ -14,13 +14,15 @@
14
14
 
15
15
  // ─── Configuration ───────────────────────────────────────────────────
16
16
 
17
+ import { cfg } from "./config";
18
+
17
19
  const TIMEOUT_MS = 30_000;
18
20
 
19
21
  function getConfig() {
20
- const apiUrl = process.env.NP_API_URL ?? "https://api.nullplatform.com";
21
- const apiKey = process.env.NULLPLATFORM_APIKEY ?? "";
22
+ const apiUrl = cfg.apiUrl();
23
+ const apiKey = cfg.apiKey();
22
24
  if (!apiKey) {
23
- throw new Error("NULLPLATFORM_APIKEY environment variable is not set");
25
+ throw new Error("NP_API_KEY is not set");
24
26
  }
25
27
  return { apiUrl, apiKey };
26
28
  }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * COMPAT LAYER — temporary shims for today's platform APIs.
3
+ *
4
+ * Anything in here exists only because a platform API is missing or awkward
5
+ * right now. Each shim should be deletable in isolation once the real API
6
+ * lands. The stable base + goal layers import from here; nothing here should
7
+ * depend on them.
8
+ */
9
+ export {
10
+ patchActionStatus,
11
+ patchActionMessages,
12
+ type ActionStatus,
13
+ type ActionMessage,
14
+ } from "./lifecycle";
@@ -0,0 +1,84 @@
1
+ /**
2
+ * COMPAT LAYER — temporary. Delete as the platform APIs improve.
3
+ *
4
+ * Action lifecycle management, working around today's API shapes:
5
+ * - PATCH /service/{serviceId}/action/{actionId} { status, results } for
6
+ * in_progress / success / failed (no dedicated action-status endpoint yet).
7
+ * - The API expects a Bearer *token*, so NP_API_KEY is exchanged for
8
+ * an access token (POST /token) and cached (no direct api-key auth yet).
9
+ *
10
+ * Everything here is a shim for a rough edge. When the platform exposes a
11
+ * first-class action-lifecycle API, this whole file goes away and the base
12
+ * layer talks to it directly. Keep base-layer code OUT of here.
13
+ */
14
+
15
+ import { cfg } from "../config";
16
+
17
+ const API_URL = () => cfg.apiUrl();
18
+ const API_KEY = () => cfg.apiKey();
19
+
20
+ let cachedToken: string | undefined;
21
+
22
+ async function getToken(): Promise<string> {
23
+ if (cachedToken) return cachedToken;
24
+ const res = await fetch(`${API_URL()}/token`, {
25
+ method: "POST",
26
+ headers: { "Content-Type": "application/json" },
27
+ body: JSON.stringify({ apikey: API_KEY() }),
28
+ });
29
+ if (!res.ok) throw new Error(`Token exchange failed: ${res.status} ${res.statusText}`);
30
+ const data = (await res.json()) as { access_token?: string };
31
+ if (!data.access_token) throw new Error("Token exchange returned no access_token");
32
+ cachedToken = data.access_token;
33
+ return cachedToken;
34
+ }
35
+
36
+ export type ActionStatus = "in_progress" | "success" | "failed";
37
+ export interface ActionMessage {
38
+ level: "info" | "warning" | "error";
39
+ message: string;
40
+ }
41
+
42
+ // patchAction PATCHes the action with any of status / results / messages. It's
43
+ // the single entry point for reporting; status/messages helpers wrap it.
44
+ async function patchAction(serviceId: string, actionId: string, body: Record<string, unknown>): Promise<void> {
45
+ if (!API_KEY()) return; // skip lifecycle if no API key (local dev / testing)
46
+ if (!serviceId || !actionId) return;
47
+
48
+ const token = await getToken();
49
+ const res = await fetch(`${API_URL()}/service/${serviceId}/action/${actionId}`, {
50
+ method: "PATCH",
51
+ headers: {
52
+ Authorization: `Bearer ${token}`,
53
+ "Content-Type": "application/json",
54
+ },
55
+ body: JSON.stringify(body),
56
+ });
57
+
58
+ if (!res.ok) {
59
+ const text = await res.text().catch(() => "");
60
+ throw new Error(`Action PATCH failed: ${res.status} ${res.statusText}${text ? ` — ${text}` : ""}`);
61
+ }
62
+ }
63
+
64
+ export async function patchActionStatus(
65
+ serviceId: string,
66
+ actionId: string,
67
+ status: ActionStatus,
68
+ results?: Record<string, unknown>,
69
+ ): Promise<void> {
70
+ const body: Record<string, unknown> = { status };
71
+ if (results) body.results = results;
72
+ return patchAction(serviceId, actionId, body);
73
+ }
74
+
75
+ // patchActionMessages appends log lines to the action (what shows in the UI as
76
+ // the action runs) — the SDK equivalent of `np service-action exec --live-report`.
77
+ export async function patchActionMessages(
78
+ serviceId: string,
79
+ actionId: string,
80
+ messages: ActionMessage[],
81
+ ): Promise<void> {
82
+ if (messages.length === 0) return;
83
+ return patchAction(serviceId, actionId, { messages });
84
+ }
package/src/config.ts ADDED
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Central configuration — the ONE place the SDK reads environment.
3
+ *
4
+ * Values come from node-config (a package's `config/` dir: `default.json` for
5
+ * defaults + `custom-environment-variables.json` mapping env vars → keys), which
6
+ * is the single declarative place every env var is documented. If a package
7
+ * ships no config/ (or a key isn't mapped), we fall back to the raw env var and
8
+ * then a built-in default — so the SDK works with or without node-config.
9
+ *
10
+ * Nothing else in the SDK touches `process.env`. Import { cfg } from here.
11
+ */
12
+
13
+ import config from "config";
14
+
15
+ // read resolves a value in priority order: node-config → raw env var → default.
16
+ // This module is the only place `process.env` is read.
17
+ function read(key: string, envVar: string, fallback = ""): string {
18
+ try {
19
+ if (config.has(key)) {
20
+ const v = config.get(key);
21
+ if (v != null && v !== "") return String(v);
22
+ }
23
+ } catch {
24
+ // node-config not configured for this process (no config/ dir) — fall through.
25
+ }
26
+ return process.env[envVar] ?? fallback;
27
+ }
28
+
29
+ export const cfg = {
30
+ /** Platform API base URL. env: NP_API_URL */
31
+ apiUrl: () => read("api.url", "NP_API_URL", "https://api.nullplatform.com"),
32
+
33
+ /** Platform API key. env: NP_API_KEY */
34
+ apiKey: () => read("api.key", "NP_API_KEY"),
35
+
36
+ /** Executions/streaming API URL. env: NP_EXECUTIONS_API_URL */
37
+ executionsApiUrl: () => read("api.executionsUrl", "NP_EXECUTIONS_API_URL"),
38
+
39
+ /** Agent transport mode marker. env: NP_AGENT_PLUGIN */
40
+ agentMode: () => read("agent.mode", "NP_AGENT_PLUGIN"),
41
+
42
+ /**
43
+ * Execution mode: "dev" | "test" | "" (production/agent). Set explicitly by
44
+ * `np package dev` / `np package test`. When "dev" or "test" the SDK uses the
45
+ * in-memory platform client and NEVER touches the real API — even if an API
46
+ * key is present in the environment. env: NP_MODE
47
+ */
48
+ mode: () => read("mode", "NP_MODE"),
49
+
50
+ /** The action payload injected in subprocess-exec mode. env: NP_ACTION_CONTEXT */
51
+ actionContext: () => read("agent.actionContext", "NP_ACTION_CONTEXT"),
52
+
53
+ /** Path to a plugin manifest override. env: NP_PLUGIN_MANIFEST */
54
+ pluginManifest: () => read("plugin.manifest", "NP_PLUGIN_MANIFEST"),
55
+ } as const;
@@ -22,11 +22,13 @@
22
22
 
23
23
  import * as grpc from "@grpc/grpc-js";
24
24
  import * as protoLoader from "@grpc/proto-loader";
25
- import { unlinkSync } from "fs";
25
+ import { unlinkSync, writeFileSync } from "fs";
26
26
  import { tmpdir } from "os";
27
- import { join, resolve, dirname } from "path";
28
- import { fileURLToPath } from "url";
27
+ import { join } from "path";
29
28
  import type { PluginHandler, ExecuteRequest, PluginManifest } from "../types";
29
+ // Embed the proto as text so it survives `bun build --compile` (a compiled
30
+ // binary has no plugin.proto on disk next to the source). Works from source too.
31
+ import protoSource from "./plugin.proto" with { type: "text" };
30
32
 
31
33
  const MAGIC_COOKIE_KEY = "NP_AGENT_PLUGIN";
32
34
  const MAGIC_COOKIE_VALUE = "np-agent-v1";
@@ -158,12 +160,11 @@ export function startGrpcServer(
158
160
  handler,
159
161
  manifest,
160
162
  };
161
- // Resolve proto path import.meta.dir is Bun-only; fall back to
162
- // import.meta.url (standard ESM) for Node.js compatibility.
163
- const protoDir = typeof import.meta.dir === "string"
164
- ? import.meta.dir
165
- : dirname(fileURLToPath(import.meta.url));
166
- const protoPath = resolve(protoDir, "plugin.proto");
163
+ // @grpc/proto-loader loads from a file path, so materialize the embedded proto
164
+ // text to a temp file. This is the one path that works both from source and
165
+ // from a compiled single-file binary.
166
+ const protoPath = join(tmpdir(), `np-plugin-${process.pid}.proto`);
167
+ writeFileSync(protoPath, protoSource);
167
168
  const packageDefinition = protoLoader.loadSync(protoPath, {
168
169
  keepCase: true,
169
170
  longs: String,
@@ -8,6 +8,7 @@
8
8
  import { readFileSync, existsSync } from "fs";
9
9
  import { resolve } from "path";
10
10
  import YAML from "yaml";
11
+ import { cfg } from "../config";
11
12
  import type { PluginManifest } from "../types";
12
13
 
13
14
  let registeredManifest: PluginManifest | null = null;
@@ -18,14 +19,19 @@ export function registerManifest(manifest: PluginManifest): void {
18
19
  }
19
20
 
20
21
  export function loadManifest(): PluginManifest {
21
- // If a manifest was registered in-memory (by defineScope), use it
22
+ // Two ways a plugin declares its manifest (name/version/command_types):
23
+ // 1. defineScope() registers it in-memory at import time — the common path;
24
+ // scope plugins never need a plugin.yaml.
25
+ // 2. Raw createPlugin() plugins (no defineScope) fall back to a plugin.yaml
26
+ // file on disk. Override its path with NP_PLUGIN_MANIFEST.
22
27
  if (registeredManifest) {
23
28
  return registeredManifest;
24
29
  }
25
30
 
31
+ const manifestOverride = cfg.pluginManifest();
26
32
  const manifestPath = resolve(
27
- process.env.NP_PLUGIN_MANIFEST || process.cwd(),
28
- process.env.NP_PLUGIN_MANIFEST ? "" : "plugin.yaml",
33
+ manifestOverride || process.cwd(),
34
+ manifestOverride ? "" : "plugin.yaml",
29
35
  );
30
36
 
31
37
  if (!existsSync(manifestPath)) {
@@ -0,0 +1,109 @@
1
+ /**
2
+ * PlatformClient — the ONE port through which a plugin talks to the control
3
+ * plane. A handler never calls the platform API directly; the SDK injects a
4
+ * client and swaps the implementation by mode:
5
+ *
6
+ * - HttpPlatformClient real API calls (the dockerized agent / `np package run`)
7
+ * - InMemoryPlatformClient records calls, prints locally, never networks
8
+ * (`np package dev`, `np package test`)
9
+ *
10
+ * This is the pattern every comparable system converges on (Temporal activity
11
+ * context, Crossplane ExternalClient, Terraform provider client, go-plugin host
12
+ * services): local dev is isolated by construction, not by the accident of a
13
+ * missing credential.
14
+ */
15
+
16
+ import { cfg } from "../config";
17
+ import { patchActionStatus, patchActionMessages, type ActionStatus } from "../compat";
18
+
19
+ export type { ActionStatus };
20
+ export type LogStream = "stdout" | "stderr";
21
+
22
+ export interface PlatformClient {
23
+ /** How this client is wired — "http" hits the real API, "memory" stays local. */
24
+ readonly mode: "http" | "memory";
25
+
26
+ /** Report an action lifecycle transition (in_progress → success/failed). */
27
+ transitionAction(
28
+ serviceId: string,
29
+ actionId: string,
30
+ status: ActionStatus,
31
+ results?: Record<string, unknown>,
32
+ ): Promise<void>;
33
+
34
+ /** Append a log line to the action (shows live in the UI as it runs). */
35
+ emitLog(serviceId: string, actionId: string, line: string, stream: LogStream): Promise<void>;
36
+ }
37
+
38
+ /** Real client: talks to the nullplatform API. Used by the agent / `np package run`. */
39
+ export class HttpPlatformClient implements PlatformClient {
40
+ readonly mode = "http" as const;
41
+
42
+ async transitionAction(
43
+ serviceId: string,
44
+ actionId: string,
45
+ status: ActionStatus,
46
+ results?: Record<string, unknown>,
47
+ ): Promise<void> {
48
+ await patchActionStatus(serviceId, actionId, status, results);
49
+ }
50
+
51
+ async emitLog(serviceId: string, actionId: string, line: string, stream: LogStream): Promise<void> {
52
+ const message = line.trimEnd();
53
+ if (!message) return;
54
+ // Local visibility: logs go to STDERR (stdout is reserved for the result
55
+ // JSON the agent parses). This is the standard data-on-stdout /
56
+ // logs-on-stderr split. The platform UI gets the same line as a properly
57
+ // leveled action message below.
58
+ process.stderr.write(message + "\n");
59
+ await patchActionMessages(serviceId, actionId, [
60
+ { level: stream === "stderr" ? "error" : "info", message },
61
+ ]);
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Fake client: records every call and echoes logs to the terminal, but makes NO
67
+ * network calls. Used for `np package dev` and tests so a plugin runs fully
68
+ * local. Exposed calls are inspectable via `.calls` for assertions.
69
+ */
70
+ export class InMemoryPlatformClient implements PlatformClient {
71
+ readonly mode = "memory" as const;
72
+
73
+ readonly calls: Array<
74
+ | { kind: "transition"; actionId: string; status: ActionStatus; results?: Record<string, unknown> }
75
+ | { kind: "log"; actionId: string; line: string; stream: LogStream }
76
+ > = [];
77
+
78
+ constructor(private readonly opts: { echo?: boolean } = { echo: true }) {}
79
+
80
+ async transitionAction(
81
+ _serviceId: string,
82
+ actionId: string,
83
+ status: ActionStatus,
84
+ results?: Record<string, unknown>,
85
+ ): Promise<void> {
86
+ this.calls.push({ kind: "transition", actionId, status, results });
87
+ if (this.opts.echo) process.stderr.write(` • action ${status}\n`);
88
+ }
89
+
90
+ async emitLog(_serviceId: string, actionId: string, line: string, stream: LogStream): Promise<void> {
91
+ const message = line.trimEnd();
92
+ if (!message) return;
93
+ this.calls.push({ kind: "log", actionId, line: message, stream });
94
+ if (this.opts.echo) process.stderr.write(` → ${message}\n`);
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Pick the client for the current process. Explicit dev/test mode ALWAYS returns
100
+ * the in-memory fake — even when NP_API_KEY is set — so local runs can never
101
+ * mutate the real platform. Only a real agent run (no dev mode + a key present)
102
+ * gets the HTTP client.
103
+ */
104
+ export function resolvePlatformClient(): PlatformClient {
105
+ const mode = cfg.mode();
106
+ if (mode === "dev" || mode === "test") return new InMemoryPlatformClient();
107
+ if (!cfg.apiKey()) return new InMemoryPlatformClient(); // no creds → stay local
108
+ return new HttpPlatformClient();
109
+ }
@@ -0,0 +1,8 @@
1
+ export {
2
+ type PlatformClient,
3
+ type ActionStatus,
4
+ type LogStream,
5
+ HttpPlatformClient,
6
+ InMemoryPlatformClient,
7
+ resolvePlatformClient,
8
+ } from "./client";
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  import { getActionDefaults } from "./actions";
9
- import type { ScopeDefinition } from "./index";
9
+ import { resolveAgentConfig, type ScopeDefinition } from "./index";
10
10
 
11
11
  export function handleDescribe(definition: ScopeDefinition): void {
12
12
  const actionSpecs: Record<string, unknown> = {};
@@ -33,6 +33,9 @@ export function handleDescribe(definition: ScopeDefinition): void {
33
33
  schema: definition.schema,
34
34
  actions: actionSpecs,
35
35
  uiSchema: definition.uiSchema,
36
+ // Routing config with defaults resolved from the name, so the CLI applies
37
+ // exactly what the scope declares instead of re-deriving conventions.
38
+ agent: resolveAgentConfig(definition),
36
39
  };
37
40
 
38
41
  const cleaned = JSON.parse(JSON.stringify(output));
@@ -30,7 +30,8 @@ import { InMemoryEngine } from "@nullplatform/workflow-engine-in-memory";
30
30
  import { getActionDefaults } from "./actions";
31
31
  import { handleDescribe } from "./describe";
32
32
  import { handlePublish } from "./publish";
33
- import { patchActionStatus } from "./lifecycle";
33
+ import { resolvePlatformClient } from "../platform";
34
+ import { cfg } from "../config";
34
35
  import type { NpJSONSchema } from "../schema";
35
36
 
36
37
  // ─── Types ───────────────────────────────────────────────────────────
@@ -84,10 +85,35 @@ export interface ScopeDefinition {
84
85
  /** JSONForms UISchema for rendering the capabilities form. */
85
86
  uiSchema?: object;
86
87
 
88
+ /**
89
+ * How the platform routes actions to the worker. Optional — sensible defaults
90
+ * are derived from `name`, so most scopes never set this. Override only for a
91
+ * custom selector or a non-standard worker entrypoint.
92
+ */
93
+ agent?: ScopeAgentConfig;
94
+
87
95
  /** Actions: schema (input/output) + handler, keyed by action slug. */
88
96
  actions: Record<string, ActionDefinition>;
89
97
  }
90
98
 
99
+ /** Agent routing config for a scope. See {@link ScopeDefinition.agent}. */
100
+ export interface ScopeAgentConfig {
101
+ /** Selector tags an agent must carry to handle this scope. Default: `{ package: <name> }`. */
102
+ selector?: Record<string, string>;
103
+ /** Path the agent execs in the worker image. Default: `/app/packages/<name>/entrypoint`. */
104
+ entrypoint?: string;
105
+ }
106
+
107
+ /** Fill agent config defaults from the scope name (the package slug). */
108
+ export function resolveAgentConfig(definition: ScopeDefinition): Required<ScopeAgentConfig> {
109
+ const slug = definition.name;
110
+ const selector = definition.agent?.selector;
111
+ return {
112
+ selector: selector && Object.keys(selector).length > 0 ? selector : { package: slug },
113
+ entrypoint: definition.agent?.entrypoint || `/app/packages/${slug}/entrypoint`,
114
+ };
115
+ }
116
+
91
117
  // ─── Exec payload parsing ────────────────────────────────────────────
92
118
  // The platform only supports "exec" channel type today. np-agent reroutes
93
119
  // exec commands to this plugin when the channel config includes NP_PLUGIN=scope
@@ -178,6 +204,66 @@ export function defineScope(definition: ScopeDefinition): void {
178
204
  command_types: ["scope"],
179
205
  });
180
206
 
207
+ // Subprocess exec mode: agents that run this binary directly (no gRPC host)
208
+ // pass the action in the NP_ACTION_CONTEXT env var. Run the one action, report
209
+ // its lifecycle status, print the result, and exit.
210
+ const execCtx = cfg.actionContext();
211
+ if (execCtx && cfg.agentMode() !== "np-agent-v1") {
212
+ void (async () => {
213
+ const platform = resolvePlatformClient();
214
+ let out: ExecuteResult;
215
+ let serviceId = "";
216
+ let actionId = "";
217
+ let lifecycle = false;
218
+ try {
219
+ const raw = JSON.parse(execCtx);
220
+ const notification: Notification = raw.notification ?? raw;
221
+ serviceId = notification.service?.id ?? "";
222
+ actionId = notification.id ?? "";
223
+ // emit forwards handler output to the platform as action messages (shown
224
+ // live on the action). It routes through the injected client, so in dev
225
+ // it's recorded locally and never touches the API.
226
+ const emit = (o: { stdout?: string; stderr?: string }) => {
227
+ if (o?.stdout) void platform.emitLog(serviceId, actionId, o.stdout, "stdout").catch(() => {});
228
+ if (o?.stderr) void platform.emitLog(serviceId, actionId, o.stderr, "stderr").catch(() => {});
229
+ };
230
+ const actionSlug = resolveActionSlug(notification, notification.type ?? "");
231
+ const actionDef = definition.actions[actionSlug];
232
+ if (!actionDef) throw new Error(`Unknown action: ${actionSlug}`);
233
+ lifecycle = getActionDefaults(actionSlug).lifecycle;
234
+ if (lifecycle && actionId) {
235
+ await platform.transitionAction(serviceId, actionId, "in_progress").catch(() => {});
236
+ }
237
+ let data: unknown;
238
+ if (isWorkflowChain(actionDef.handler)) {
239
+ const observer = new StreamingExecutionObserver(emit, { apiUrl: cfg.executionsApiUrl() });
240
+ observer.emitPlan(actionDef.handler.toGraph());
241
+ const engine = new InMemoryEngine({ observer });
242
+ const wf = await actionDef.handler.run(notification, { engine, timeoutMs: 10 * 60 * 1000 });
243
+ if (wf.status !== "completed") throw new Error(wf.error?.message ?? `Workflow ${wf.status}`);
244
+ data = wf.output;
245
+ } else {
246
+ data = await actionDef.handler(notification, emit);
247
+ }
248
+ if (lifecycle && actionId) {
249
+ const results = data && typeof data === "object" ? (data as Record<string, unknown>) : undefined;
250
+ await platform.transitionAction(serviceId, actionId, "success", results).catch(() => {});
251
+ }
252
+ out = { success: true, data };
253
+ } catch (error) {
254
+ const message = error instanceof Error ? error.message : String(error);
255
+ if (lifecycle && actionId) {
256
+ await platform.transitionAction(serviceId, actionId, "failed", { error: message }).catch(() => {});
257
+ }
258
+ out = { success: false, error: message, errorCode: "EXECUTION_ERROR" };
259
+ }
260
+ process.stdout.write(JSON.stringify(out));
261
+ process.exit(out.success ? 0 : 1);
262
+ })();
263
+ return;
264
+ }
265
+
266
+ const platform = resolvePlatformClient();
181
267
  const plugin = createPlugin({
182
268
  async execute(req: ExecuteRequest): Promise<ExecuteResult> {
183
269
  // Handle both native "scope" and legacy "exec" command types
@@ -205,9 +291,10 @@ export function defineScope(definition: ScopeDefinition): void {
205
291
 
206
292
  const defaults = getActionDefaults(actionSlug);
207
293
  const actionId = notification.id;
294
+ const serviceId = notification.service?.id ?? "";
208
295
 
209
296
  if (defaults.lifecycle && actionId) {
210
- await patchActionStatus(actionId, "in_progress").catch((err) => {
297
+ await platform.transitionAction(serviceId, actionId, "in_progress").catch((err) => {
211
298
  console.error(`[defineScope] Failed to patch action status: ${err.message}`);
212
299
  });
213
300
  }
@@ -218,7 +305,7 @@ export function defineScope(definition: ScopeDefinition): void {
218
305
  if (isWorkflowChain(actionDef.handler)) {
219
306
  // Workflow chain: emit plan, run with engine
220
307
  const observer = new StreamingExecutionObserver(req.emit, {
221
- apiUrl: process.env.NP_EXECUTIONS_API_URL,
308
+ apiUrl: cfg.executionsApiUrl(),
222
309
  });
223
310
  observer.emitPlan(actionDef.handler.toGraph());
224
311
 
@@ -235,7 +322,8 @@ export function defineScope(definition: ScopeDefinition): void {
235
322
  }
236
323
 
237
324
  if (defaults.lifecycle && actionId) {
238
- await patchActionStatus(actionId, "completed").catch((err) => {
325
+ const results = result && typeof result === "object" ? (result as Record<string, unknown>) : undefined;
326
+ await platform.transitionAction(serviceId, actionId, "success", results).catch((err) => {
239
327
  console.error(`[defineScope] Failed to patch action status: ${err.message}`);
240
328
  });
241
329
  }
@@ -245,7 +333,7 @@ export function defineScope(definition: ScopeDefinition): void {
245
333
  const message = error instanceof Error ? error.message : String(error);
246
334
 
247
335
  if (defaults.lifecycle && actionId) {
248
- await patchActionStatus(actionId, "failed", message).catch((err) => {
336
+ await platform.transitionAction(serviceId, actionId, "failed", { error: message }).catch((err) => {
249
337
  console.error(`[defineScope] Failed to patch action status: ${err.message}`);
250
338
  });
251
339
  }
@@ -15,6 +15,7 @@
15
15
 
16
16
  import { $ } from "bun";
17
17
  import { getActionDefaults } from "./actions";
18
+ import { cfg } from "../config";
18
19
  import type { ScopeDefinition } from "./index";
19
20
 
20
21
  export async function handlePublish(definition: ScopeDefinition): Promise<void> {
@@ -161,7 +162,7 @@ export async function handlePublish(definition: ScopeDefinition): Promise<void>
161
162
  console.log(` ${existingScopeType ? "↻" : "✓"} Scope type: ${scopeTypeId}`);
162
163
 
163
164
  // 4. Dev notification channel (upsert by description tag)
164
- const apiKey = process.env.NP_API_KEY;
165
+ const apiKey = cfg.apiKey();
165
166
  if (!apiKey) {
166
167
  console.error("\n⚠ Skipping notification channel — NP_API_KEY not set");
167
168
  console.error(" Set it and re-run, or create the channel manually via the UI");
@@ -0,0 +1,43 @@
1
+ /**
2
+ * --describe for a service package. Prints the manifest as JSON. The `kind:
3
+ * "service"` marker tells the CLI to register a dependency service_specification
4
+ * (with use_default_actions) instead of a scope + scope type.
5
+ */
6
+
7
+ import { resolveAgentConfig } from "../scope";
8
+ import type { ServiceDefinition } from "./index";
9
+
10
+ export function handleServiceDescribe(definition: ServiceDefinition): void {
11
+ // Only CUSTOM actions are described — the platform generates create/update/
12
+ // delete when useDefaultActions is on, so we don't ship specs for them.
13
+ const defaultTypes = new Set(["create", "update", "delete"]);
14
+ const actionSpecs: Record<string, unknown> = {};
15
+ for (const [slug, action] of Object.entries(definition.actions)) {
16
+ if (definition.useDefaultActions !== false && defaultTypes.has(slug)) continue;
17
+ actionSpecs[slug] = {
18
+ name: action.name ?? slug,
19
+ type: action.type ?? "custom",
20
+ retryable: action.retryable ?? false,
21
+ lifecycle: true,
22
+ input: action.input,
23
+ output: action.output,
24
+ };
25
+ }
26
+
27
+ const output = {
28
+ kind: "service",
29
+ name: definition.name,
30
+ version: definition.version,
31
+ description: definition.description,
32
+ category: definition.category,
33
+ provider: definition.provider,
34
+ schema: definition.schema,
35
+ uiSchema: definition.uiSchema,
36
+ useDefaultActions: definition.useDefaultActions ?? true,
37
+ assignableTo: definition.assignableTo ?? "any",
38
+ actions: actionSpecs,
39
+ agent: resolveAgentConfig(definition as any),
40
+ };
41
+
42
+ console.log(JSON.stringify(JSON.parse(JSON.stringify(output)), null, 2));
43
+ }
@@ -0,0 +1,173 @@
1
+ /**
2
+ * defineService() — define a self-describing SERVICE package.
3
+ *
4
+ * A service is an application *dependency* (a database, cache, queue, …), not a
5
+ * deployment target. It differs from a scope:
6
+ * - the service_specification is `type: "dependency"` (no scope type),
7
+ * - with `useDefaultActions` the platform auto-generates create/update/delete
8
+ * action specs from the schema — you only write handlers, not specs,
9
+ * - action slugs are dynamic (`create-<name>`), so handlers are keyed by the
10
+ * action TYPE (create/update/delete) or a custom slug.
11
+ *
12
+ * The runtime (subprocess-exec + gRPC, lifecycle reporting) mirrors defineScope.
13
+ */
14
+
15
+ import { createPlugin } from "../create-plugin";
16
+ import type { ExecuteRequest, ExecuteResult } from "../types";
17
+ import { registerManifest } from "../internal/manifest";
18
+ import { StreamingExecutionObserver } from "../workflow";
19
+ import { InMemoryEngine } from "@nullplatform/workflow-engine-in-memory";
20
+ import { resolvePlatformClient } from "../platform";
21
+ import { cfg } from "../config";
22
+ import { resolveAgentConfig, type ScopeAgentConfig, type ActionDefinition, type Notification } from "../scope";
23
+ import { handleServiceDescribe } from "./describe";
24
+ import type { NpJSONSchema } from "../schema";
25
+
26
+ export interface ServiceDefinition {
27
+ name: string;
28
+ version: string;
29
+ description?: string;
30
+ category?: string;
31
+ provider?: string;
32
+
33
+ /** JSON Schema for the service's attributes (its configurable properties). */
34
+ schema: NpJSONSchema;
35
+
36
+ /** JSONForms UISchema for the attributes form. */
37
+ uiSchema?: object;
38
+
39
+ /**
40
+ * Let the platform auto-generate create/update/delete action specs from the
41
+ * schema (default true). Set false to declare all action specs yourself.
42
+ */
43
+ useDefaultActions?: boolean;
44
+
45
+ /** Where the service can be assigned. Default "any". */
46
+ assignableTo?: "any" | "dimension" | "scope";
47
+
48
+ /** How the platform routes actions to the worker. Defaults from `name`. */
49
+ agent?: ScopeAgentConfig;
50
+
51
+ /**
52
+ * Handlers, keyed by action type (`create`/`update`/`delete`) for the default
53
+ * actions, and by slug for any custom action.
54
+ */
55
+ actions: Record<string, ActionDefinition>;
56
+ }
57
+
58
+ function isWorkflowChain(handler: unknown): handler is { run: Function; toGraph: Function } {
59
+ return !!handler && typeof (handler as any).run === "function" && typeof (handler as any).toGraph === "function";
60
+ }
61
+
62
+ // resolveServiceHandler maps a notification to a handler. Default actions arrive
63
+ // with a dynamic slug (`create-<name>`) but a stable `type` (create/update/
64
+ // delete), so we try: exact slug → action type → the type-prefixed slug.
65
+ function resolveServiceHandler(def: ServiceDefinition, notification: Notification): { key: string; action?: ActionDefinition } {
66
+ const slug = notification.slug || notification.specification?.slug || "";
67
+ const type = String((notification as any).type ?? "");
68
+ if (slug && def.actions[slug]) return { key: slug, action: def.actions[slug] };
69
+ if (type && def.actions[type]) return { key: type, action: def.actions[type] };
70
+ // "create-postgres" → "create"
71
+ const prefix = slug.split("-")[0];
72
+ if (prefix && def.actions[prefix]) return { key: prefix, action: def.actions[prefix] };
73
+ return { key: slug || type };
74
+ }
75
+
76
+ export function defineService(definition: ServiceDefinition): void {
77
+ if (process.argv.includes("--describe")) {
78
+ handleServiceDescribe(definition);
79
+ process.exit(0);
80
+ }
81
+
82
+ registerManifest({
83
+ name: definition.name,
84
+ version: definition.version,
85
+ command_types: ["service"],
86
+ });
87
+
88
+ // Subprocess-exec mode (how the agent runs the worker): the action arrives in
89
+ // NP_ACTION_CONTEXT; run it, report lifecycle, print the result, exit.
90
+ const execCtx = cfg.actionContext();
91
+ if (execCtx && cfg.agentMode() !== "np-agent-v1") {
92
+ void (async () => {
93
+ const platform = resolvePlatformClient();
94
+ let out: ExecuteResult;
95
+ let serviceId = "";
96
+ let actionId = "";
97
+ try {
98
+ const raw = JSON.parse(execCtx);
99
+ const notification: Notification = raw.notification ?? raw;
100
+ serviceId = notification.service?.id ?? "";
101
+ actionId = notification.id ?? "";
102
+ const emit = (o: { stdout?: string; stderr?: string }) => {
103
+ if (o?.stdout) void platform.emitLog(serviceId, actionId, o.stdout, "stdout").catch(() => {});
104
+ if (o?.stderr) void platform.emitLog(serviceId, actionId, o.stderr, "stderr").catch(() => {});
105
+ };
106
+ const { key, action } = resolveServiceHandler(definition, notification);
107
+ if (!action) throw new Error(`Unknown service action: ${key}`);
108
+ if (actionId) await platform.transitionAction(serviceId, actionId, "in_progress").catch(() => {});
109
+ let data: unknown;
110
+ if (isWorkflowChain(action.handler)) {
111
+ const observer = new StreamingExecutionObserver(emit, { apiUrl: cfg.executionsApiUrl() });
112
+ observer.emitPlan((action.handler as any).toGraph());
113
+ const engine = new InMemoryEngine({ observer });
114
+ const wf = await (action.handler as any).run(notification, { engine, timeoutMs: 10 * 60 * 1000 });
115
+ if (wf.status !== "completed") throw new Error(wf.error?.message ?? `Workflow ${wf.status}`);
116
+ data = wf.output;
117
+ } else {
118
+ data = await (action.handler as any)(notification, emit);
119
+ }
120
+ if (actionId) {
121
+ const results = data && typeof data === "object" ? (data as Record<string, unknown>) : undefined;
122
+ await platform.transitionAction(serviceId, actionId, "success", results).catch(() => {});
123
+ }
124
+ out = { success: true, data };
125
+ } catch (error) {
126
+ const message = error instanceof Error ? error.message : String(error);
127
+ if (actionId) await platform.transitionAction(serviceId, actionId, "failed", { error: message }).catch(() => {});
128
+ out = { success: false, error: message, errorCode: "EXECUTION_ERROR" };
129
+ }
130
+ process.stdout.write(JSON.stringify(out));
131
+ process.exit(out.success ? 0 : 1);
132
+ })();
133
+ return;
134
+ }
135
+
136
+ // gRPC host path (dev / np-agent-v1).
137
+ const platform = resolvePlatformClient();
138
+ const plugin = createPlugin({
139
+ async execute(req: ExecuteRequest): Promise<ExecuteResult> {
140
+ let notification: Notification;
141
+ try {
142
+ const raw = JSON.parse(req.payload.toString("utf-8"));
143
+ notification = raw.notification ?? raw;
144
+ } catch {
145
+ return { success: false, error: "invalid payload", errorCode: "MISSING_CONTEXT" };
146
+ }
147
+ const { key, action } = resolveServiceHandler(definition, notification);
148
+ if (!action) return { success: false, error: `Unknown service action: ${key}`, errorCode: "UNKNOWN_ACTION" };
149
+ const actionId = notification.id;
150
+ const serviceId = notification.service?.id ?? "";
151
+ if (actionId) await platform.transitionAction(serviceId, actionId, "in_progress").catch(() => {});
152
+ try {
153
+ const result = await (action.handler as any)(notification, req.emit);
154
+ if (actionId) {
155
+ const results = result && typeof result === "object" ? (result as Record<string, unknown>) : undefined;
156
+ await platform.transitionAction(serviceId, actionId, "success", results).catch(() => {});
157
+ }
158
+ return { success: true, data: result };
159
+ } catch (error) {
160
+ const message = error instanceof Error ? error.message : String(error);
161
+ if (actionId) await platform.transitionAction(serviceId, actionId, "failed", { error: message }).catch(() => {});
162
+ return { success: false, error: message, errorCode: "EXECUTION_ERROR" };
163
+ }
164
+ },
165
+ });
166
+ plugin.start();
167
+ }
168
+
169
+ export { resolveAgentConfig };
170
+ // Author-facing types: a service handler receives the action notification and an
171
+ // `emit` callback. `ServiceActionInput` is the notification shape (aliased from
172
+ // the shared Notification type) so action files read as service code.
173
+ export type { Notification as ServiceActionInput, ActionDefinition, ScopeAgentConfig } from "../scope";
@@ -53,6 +53,10 @@ export async function startPluginProcess(
53
53
  ...process.env,
54
54
  ...opts?.env,
55
55
  NP_AGENT_PLUGIN: "np-agent-v1",
56
+ // This helper only ever runs a plugin for local dev/test (the real agent
57
+ // execs the binary directly). Force in-memory platform mode so a dev/test
58
+ // run can never hit the real API, even if NP_API_KEY is in the env.
59
+ NP_MODE: process.env.NP_MODE || "dev",
56
60
  },
57
61
  stdout: "pipe",
58
62
  stderr: "pipe",
@@ -1,39 +0,0 @@
1
- /**
2
- * Action lifecycle management.
3
- *
4
- * PATCHes service action status via the platform API.
5
- * Mirrors what `np service-action exec` does:
6
- * - PATCH status: "in_progress" on start
7
- * - PATCH status: "completed" or "failed" on end
8
- */
9
-
10
- const API_URL = () => process.env.NP_API_URL ?? "https://api.nullplatform.com";
11
- const API_KEY = () => process.env.NULLPLATFORM_APIKEY ?? "";
12
-
13
- export async function patchActionStatus(
14
- actionId: string,
15
- status: "in_progress" | "completed" | "failed",
16
- error?: string,
17
- ): Promise<void> {
18
- const apiKey = API_KEY();
19
- if (!apiKey) return; // skip lifecycle if no API key (local dev / testing)
20
-
21
- const body: Record<string, unknown> = { status };
22
- if (error) {
23
- body.results = { error };
24
- }
25
-
26
- const res = await fetch(`${API_URL()}/service/action/${actionId}`, {
27
- method: "PATCH",
28
- headers: {
29
- Authorization: `Bearer ${apiKey}`,
30
- "Content-Type": "application/json",
31
- },
32
- body: JSON.stringify(body),
33
- });
34
-
35
- if (!res.ok) {
36
- const text = await res.text().catch(() => "");
37
- throw new Error(`Lifecycle PATCH ${status} failed: ${res.status} ${res.statusText}${text ? ` — ${text}` : ""}`);
38
- }
39
- }