@polycore/runner 0.1.0

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 ADDED
@@ -0,0 +1,261 @@
1
+ # @polycore/runner
2
+
3
+ The Polycore **runner** executes governed, read-only-constrained connectors
4
+ against a customer's datastores. It runs in the **customer's own
5
+ infrastructure**, holds scoped credentials read from the customer's own secret
6
+ store, and connects **outbound** to the Polycore control plane (a persistent
7
+ WebSocket it dials out; no inbound port).
8
+
9
+ One runner belongs to **one organization** and serves **one or more projects**
10
+ (the customer's products/systems). Each project carries its own environments,
11
+ credentials, customer-authored actions, and optional agent-context document;
12
+ the runner advertises all of it per project when it joins the control plane.
13
+ Whether a customer runs one runner for all products or one per product is
14
+ purely a deployment choice; the config shape is the same.
15
+
16
+ It ships the **Firestore read connectors**, loads customer-authored **actions**,
17
+ and can be driven either by the control plane (`runner connect`) or by hand via
18
+ the local CLI harness. See `internal-docs/architecture.md` and
19
+ `internal-docs/mvp-plan.md`.
20
+
21
+ ## Connectors
22
+
23
+ A connector is a generic, read-only transport: the caller supplies the
24
+ query/path at request time. All connectors are `read` (Lane 1): they exist
25
+ because they are mutation-incapable, which is what lets them run with no
26
+ approval.
27
+
28
+ | Capability | Args | Returns |
29
+ | --------------------------- | ------------------------------------------ | ---------------------------------- |
30
+ | `firestore.query` | `{ collection, where?, orderBy?, limit? }` | array of `{ id, data }` |
31
+ | `firestore.get` | `{ collection, id }` | `{ id, data }` or `null` |
32
+ | `firestore.count` | `{ collection, where? }` | `number` (server-side aggregation) |
33
+ | `firestore.listCollections` | `{}` | array of collection ids |
34
+
35
+ `where` clauses are `{ field, op, value }` where `op` is a Firestore filter
36
+ operator (`==`, `!=`, `<`, `<=`, `>`, `>=`, `array-contains`,
37
+ `array-contains-any`, `in`, `not-in`).
38
+
39
+ ## Read-only safety (two layers)
40
+
41
+ - **Layer 1: IAM.** The credential carries `roles/datastore.viewer`; GCP
42
+ rejects any write at the API boundary. (The Admin SDK bypasses Firestore
43
+ Security Rules, so the IAM role, not rules, is what enforces read-only.)
44
+ This is true whether the credential is a downloaded key (dev) or the
45
+ runtime service account picked up via Application Default Credentials (prod).
46
+ - **Layer 2: this code.** The connectors only ever call read methods.
47
+
48
+ ## Setup: create a read-only service account (per environment)
49
+
50
+ With one Firebase project per environment, create a read-only SA in each and
51
+ download its key. Example for `dev` (replace `PROJECT_ID` with your own):
52
+
53
+ ```sh
54
+ PROJECT_ID=my-app-dev
55
+
56
+ gcloud iam service-accounts create polycore-readonly \
57
+ --project "$PROJECT_ID" \
58
+ --display-name "Polycore read-only runner"
59
+
60
+ gcloud projects add-iam-policy-binding "$PROJECT_ID" \
61
+ --member "serviceAccount:polycore-readonly@$PROJECT_ID.iam.gserviceaccount.com" \
62
+ --role "roles/datastore.viewer"
63
+
64
+ gcloud iam service-accounts keys create ./readonly-dev.json \
65
+ --project "$PROJECT_ID" \
66
+ --iam-account "polycore-readonly@$PROJECT_ID.iam.gserviceaccount.com"
67
+ ```
68
+
69
+ > Keep the key file out of git. When the runner later runs inside GCP, prefer
70
+ > workload identity over a downloaded key.
71
+
72
+ ## Config
73
+
74
+ Create a `runner.config.json` (path passed via `--config` or the
75
+ `POLYCORE_RUNNER_CONFIG` env var). The top level is a **projects map**: one
76
+ entry per product/system this runner serves. Environments are namespaced per
77
+ connector family (`firestore` today; SQL/HTTP families as they land), so one
78
+ environment can carry credentials for several backends:
79
+
80
+ ```json
81
+ {
82
+ "projects": {
83
+ "alpha": {
84
+ "defaultEnvironment": "dev",
85
+ "environments": {
86
+ "dev": {
87
+ "firestore": {
88
+ "projectId": "alpha-dev",
89
+ "serviceAccountKeyPath": "/absolute/path/to/alpha-readonly-dev.json"
90
+ }
91
+ },
92
+ "prod": {
93
+ "firestore": { "projectId": "alpha-prod" }
94
+ }
95
+ },
96
+ "actionsDir": "/absolute/path/to/alpha/polycore-actions",
97
+ "contextFile": "/absolute/path/to/alpha/schema.md"
98
+ },
99
+ "beta": {
100
+ "environments": {
101
+ "prod": {
102
+ "firestore": { "projectId": "beta-prod" }
103
+ }
104
+ }
105
+ }
106
+ },
107
+ "controlPlane": {
108
+ "url": "wss://cp.example.com/runner",
109
+ "runnerId": "<uuid from enrollment>",
110
+ "joinToken": "<from enrollment>",
111
+ "signingSecret": "<from enrollment>"
112
+ }
113
+ }
114
+ ```
115
+
116
+ Per-project extras:
117
+
118
+ - `actionsDir`: customer-authored actions for that project, loaded alongside
119
+ the built-in connectors.
120
+ - `secrets`: per-project action secret values (falls back to the runner-level
121
+ `secrets` map, then `process.env`). These never reach the control plane.
122
+ - `context` / `contextFile`: an agent-grounding document (e.g. the product's
123
+ database schema). Advertised at join and injected into the agent's system
124
+ prompt when this project is in scope. Re-read on every (re)connect.
125
+
126
+ The `controlPlane` block is optional; without it the local harness still works.
127
+
128
+ ## Run the harness
129
+
130
+ ```sh
131
+ # list available capabilities across all projects
132
+ pnpm --filter @polycore/runner runner list --config ./runner.config.json
133
+
134
+ # "how many users?" against alpha's dev
135
+ pnpm --filter @polycore/runner runner firestore.count \
136
+ --project alpha --env dev --args '{"collection":"users"}' \
137
+ --config ./runner.config.json
138
+
139
+ # the first five users (sole project → --project can be omitted)
140
+ pnpm --filter @polycore/runner runner firestore.query \
141
+ --env dev --args '{"collection":"users","limit":5}' \
142
+ --config ./runner.config.json
143
+
144
+ # hold the outbound control-plane link (production mode)
145
+ pnpm --filter @polycore/runner runner connect --config ./runner.config.json
146
+ ```
147
+
148
+ Logs go to stderr; the JSON result goes to stdout (pipeable).
149
+
150
+ > `serviceAccountKeyPath` is **optional**. Omit it to use Application Default
151
+ > Credentials: the idiomatic path when the runner runs inside GCP, where the
152
+ > runtime service account supplies the (read-only) credential and no key file
153
+ > ships. Provide a key path only for local/off-GCP use.
154
+
155
+ ## Deploy to Cloud Run
156
+
157
+ The runner ships as a container that dials the control plane outbound, so it
158
+ needs **no inbound port**: `min-instances=1` keeps the link warm. The image is
159
+ built from the repo root; config and secrets arrive entirely via env vars, so
160
+ nothing is baked in.
161
+
162
+ ### 1. Build and push the image
163
+
164
+ Cloud Run runs `linux/amd64`:
165
+
166
+ ```sh
167
+ docker build --platform linux/amd64 \
168
+ -f packages/runner/Dockerfile \
169
+ -t REGION-docker.pkg.dev/PROJECT_ID/polycore/runner:latest .
170
+
171
+ docker push REGION-docker.pkg.dev/PROJECT_ID/polycore/runner:latest
172
+ ```
173
+
174
+ ### 2. Give the runtime service account read-only datastore access
175
+
176
+ In prod there is **no key file**: the runner uses Application Default
177
+ Credentials, i.e. the Cloud Run runtime service account. Grant it the same
178
+ read-only role the key would have carried:
179
+
180
+ ```sh
181
+ gcloud projects add-iam-policy-binding PROJECT_ID \
182
+ --member "serviceAccount:RUNTIME_SA@PROJECT_ID.iam.gserviceaccount.com" \
183
+ --role "roles/datastore.viewer"
184
+ ```
185
+
186
+ ### 3. Store the outbound-link secrets in Secret Manager
187
+
188
+ The join token and signing secret are shared at enrollment and must never be
189
+ baked into the image:
190
+
191
+ ```sh
192
+ printf '%s' "$JOIN_TOKEN" | gcloud secrets create polycore-join-token --data-file=-
193
+ printf '%s' "$SIGNING_SECRET" | gcloud secrets create polycore-signing-secret --data-file=-
194
+ ```
195
+
196
+ ### 4. Deploy
197
+
198
+ Non-secret config is plain env vars; the two secrets come from Secret Manager.
199
+ `POLYCORE_PROJECTS` is the projects map (same shape as the config file's
200
+ `projects` field). With ADC each environment needs only its `projectId` (no
201
+ `serviceAccountKeyPath`):
202
+
203
+ ```sh
204
+ gcloud run deploy polycore-runner \
205
+ --image REGION-docker.pkg.dev/PROJECT_ID/polycore/runner:latest \
206
+ --no-allow-unauthenticated \
207
+ --min-instances 1 --max-instances 1 --no-cpu-throttling \
208
+ --service-account RUNTIME_SA@PROJECT_ID.iam.gserviceaccount.com \
209
+ --set-env-vars 'POLYCORE_CONTROL_PLANE_URL=wss://control.example.com/runner,POLYCORE_RUNNER_ID=<uuid-from-enrollment>' \
210
+ --set-env-vars 'POLYCORE_PROJECTS={"alpha":{"environments":{"prod":{"firestore":{"projectId":"PROJECT_ID"}}}}}' \
211
+ --set-secrets 'POLYCORE_JOIN_TOKEN=polycore-join-token:latest,POLYCORE_SIGNING_SECRET=polycore-signing-secret:latest'
212
+ ```
213
+
214
+ > **Cloud Run caveats (learned in prod).**
215
+ >
216
+ > - **The URL needs the `/runner` WS path**: `wss://<host>/runner`, not just the
217
+ > host. The control plane only upgrades WebSocket connections on that path.
218
+ > - **`--no-cpu-throttling` is mandatory.** The runner is an outbound-only worker
219
+ > that never serves requests, so by default Cloud Run would throttle its CPU to
220
+ > ~0 between (non-existent) requests and freeze its heartbeat/reconnect timers.
221
+ > CPU-always-allocated keeps the link and the self-healing alive. `max-instances 1`
222
+ > keeps the single WS pinned to one instance.
223
+ > - **A request-driven host needs the container to listen on `$PORT`.** The
224
+ > `connect` command starts a tiny liveness HTTP server when `PORT` is set (it is
225
+ > a no-op locally) purely so Cloud Run's startup probe passes; the real work is
226
+ > still the outbound WS. It opens the port **before** loading/compiling actions,
227
+ > so a cold boot is healthy in <1s. If the port only opened after the
228
+ > (esbuild-backed) action load, a slow boot would blow the startup-probe
229
+ > deadline and Cloud Run would kill + restart the instance in a ~4-minute loop.
230
+ > - **Redeploy ordering: control plane first, then bounce the runner.** A live
231
+ > runner's long-lived WS pins it to the control-plane instance it dialed, and
232
+ > that open connection keeps the old (draining) instance alive, so after a
233
+ > control-plane redeploy the runner keeps talking to the stale instance while
234
+ > new HTTP traffic hits the fresh revision's empty hub. Roll a new runner
235
+ > revision (any config change, e.g. bump a nonce env var) after deploying the
236
+ > control plane so it reconnects to the current revision.
237
+
238
+ | Env var | Required | Notes |
239
+ | ---------------------------- | :------: | ------------------------------------------------------------------------- |
240
+ | `POLYCORE_PROJECTS` | âś“ | JSON projects map (same shape as the config file's `projects`) |
241
+ | `POLYCORE_CONTROL_PLANE_URL` | ✓ | `wss://…` control-plane WebSocket |
242
+ | `POLYCORE_RUNNER_ID` | âś“ | This runner's enrolled id (a uuid) |
243
+ | `POLYCORE_JOIN_TOKEN` | âś“ | Enrollment secret (Secret Manager) |
244
+ | `POLYCORE_SIGNING_SECRET` | âś“ | HMAC secret to verify dispatch envelopes (Secret Manager) |
245
+ | `POLYCORE_SECRETS` | | JSON map of runner-level action secrets (per-project `secrets` still win) |
246
+
247
+ When `POLYCORE_PROJECTS` is set and no `--config` file is given, the runner
248
+ builds its config from these vars; a missing/invalid one fails loud at startup.
249
+ Per-project settings that reference local files (`actionsDir`, `contextFile`,
250
+ `serviceAccountKeyPath`) point at paths baked into or mounted on the container.
251
+
252
+ ## Tests
253
+
254
+ ```sh
255
+ pnpm --filter @polycore/runner test
256
+ ```
257
+
258
+ Unit tests cover the registry's validate-then-dispatch path, the read-only
259
+ invariant, and config parsing. Live-Firestore tests are intentionally not part
260
+ of the default run (they require real credentials); use the harness above to
261
+ verify against a live project.
@@ -0,0 +1,24 @@
1
+ #!/usr/bin/env node
2
+ // Launcher for the runner CLI. The runner is shipped as TypeScript source and
3
+ // executed by tsx (it must also load customer-authored `.ts` actions at
4
+ // runtime), so this thin bin re-execs Node with tsx registered as a loader and
5
+ // hands off to `src/cli.ts`. Consumers run `polycore-runner connect` (or drive
6
+ // the local harness, e.g. `polycore-runner list`).
7
+ import { spawn } from "node:child_process";
8
+ import { fileURLToPath } from "node:url";
9
+
10
+ const cli = fileURLToPath(new URL("../src/cli.ts", import.meta.url));
11
+
12
+ const child = spawn(
13
+ process.execPath,
14
+ ["--import", "tsx", cli, ...process.argv.slice(2)],
15
+ { stdio: "inherit" },
16
+ );
17
+
18
+ child.on("exit", (code, signal) => {
19
+ if (signal !== null) {
20
+ process.kill(process.pid, signal);
21
+ return;
22
+ }
23
+ process.exit(code ?? 0);
24
+ });
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@polycore/runner",
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "description": "Polycore runner: executes governed, read-only-constrained connectors against a customer's datastores. Runs in the customer's own infrastructure; holds scoped credentials and connects outbound to the control plane.",
6
+ "type": "module",
7
+ "main": "./src/index.ts",
8
+ "types": "./src/index.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./src/index.ts",
12
+ "import": "./src/index.ts",
13
+ "default": "./src/index.ts"
14
+ },
15
+ "./package.json": "./package.json"
16
+ },
17
+ "bin": {
18
+ "polycore-runner": "./bin/polycore-runner.mjs"
19
+ },
20
+ "files": [
21
+ "src",
22
+ "bin",
23
+ "README.md"
24
+ ],
25
+ "engines": {
26
+ "node": ">=22"
27
+ },
28
+ "dependencies": {
29
+ "firebase-admin": "^14.0.0",
30
+ "tsx": "4.21.0",
31
+ "ws": "8.18.3",
32
+ "zod": "4.4.3",
33
+ "@polycore/protocol": "^0.1.0"
34
+ },
35
+ "devDependencies": {
36
+ "@types/node": "25.6.2",
37
+ "@types/ws": "8.18.1",
38
+ "typescript": "6.0.3",
39
+ "vitest": "4.1.6"
40
+ },
41
+ "publishConfig": {
42
+ "access": "public"
43
+ },
44
+ "scripts": {
45
+ "runner": "tsx src/cli.ts",
46
+ "typecheck": "tsc -p tsconfig.test.json",
47
+ "test": "vitest run",
48
+ "test:watch": "vitest"
49
+ }
50
+ }
package/src/action.ts ADDED
@@ -0,0 +1,68 @@
1
+ import type { z } from "zod";
2
+
3
+ /**
4
+ * Blessed **actions** are the second execution lane: durable, customer-authored
5
+ * operations (e.g. "customer 360", "refund user") that live in the customer's
6
+ * own repo and are loaded by the runner. Unlike connectors — generic,
7
+ * read-only transports that ship inside the runner — an action is a named,
8
+ * parameterized unit of work with its own input/output contract.
9
+ *
10
+ * This is the same authoring shape as the desktop SDK's `defineAction`, minus
11
+ * the UI/page concern: a Slack/agent caller consumes the structured `output`,
12
+ * not a React page. An action reaches into the customer's own backend (their
13
+ * Firestore, Stripe, internal APIs) using `secrets` the runner resolves from
14
+ * its environment — the credential never leaves the runner's host.
15
+ */
16
+
17
+ export type ActionEffect = "read" | "mutate";
18
+
19
+ /** Human-facing progress sink, mirroring the desktop SDK's `ctx.log`. */
20
+ export interface ActionLogger {
21
+ info(message: string): void;
22
+ warn(message: string): void;
23
+ error(message: string): void;
24
+ success(message: string): void;
25
+ }
26
+
27
+ export interface ActionRunContext<Input> {
28
+ /** Caller-supplied, schema-validated input. */
29
+ readonly input: Input;
30
+ /** Declared secrets, resolved by the runner from its environment. */
31
+ readonly secrets: Readonly<Record<string, string>>;
32
+ /** The environment to run against (e.g. "dev"), from the dispatch. */
33
+ readonly environment: string;
34
+ readonly log: ActionLogger;
35
+ }
36
+
37
+ export interface Action<Input = unknown, Output = unknown> {
38
+ readonly title: string;
39
+ readonly description: string;
40
+ readonly tags?: readonly string[];
41
+ /**
42
+ * Hazard class. Defaults to `mutate` — the safe default, since an
43
+ * unclassified operation must be treated as dangerous and routed through
44
+ * the approval gate. Declare `read` for idempotent, side-effect-free work.
45
+ */
46
+ readonly effect?: ActionEffect;
47
+ readonly input: z.ZodType<Input>;
48
+ readonly output: z.ZodType<Output>;
49
+ /** Secret keys this action needs, mapped to a human description. */
50
+ readonly secrets?: Readonly<Record<string, string>>;
51
+ run(ctx: ActionRunContext<Input>): Promise<Output> | Output;
52
+ }
53
+
54
+ /**
55
+ * Identity helper that pins an action's I/O types. Authored modules
56
+ * `export default defineAction({ … })`; the loader injects the folder-path id.
57
+ */
58
+ export function defineAction<Input, Output>(
59
+ spec: Action<Input, Output>,
60
+ ): Action<Input, Output> {
61
+ return spec;
62
+ }
63
+
64
+ /** A loaded action plus the folder-path id the loader derived for it. */
65
+ export interface LoadedAction {
66
+ readonly id: string;
67
+ readonly action: Action;
68
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,198 @@
1
+ import { createServer } from "node:http";
2
+ import process from "node:process";
3
+
4
+ import {
5
+ buildRunnerConfigFromEnv,
6
+ hasEnvConfig,
7
+ loadRunnerConfig,
8
+ resolveEnvironment,
9
+ resolveProject,
10
+ RUNNER_CONFIG_ENV,
11
+ RUNNER_PROJECTS_ENV,
12
+ type RunnerConfig,
13
+ } from "./config.js";
14
+ import { RunnerClient } from "./connect.js";
15
+ import { buildProjectRegistries } from "./index.js";
16
+
17
+ /**
18
+ * Local harness for driving connectors by hand, the dogfood entry point
19
+ * before the control-plane connection exists. Logs go to stderr; the JSON
20
+ * result goes to stdout, so output is pipeable.
21
+ *
22
+ * pnpm --filter @polycore/runner runner list --config ./runner.config.json
23
+ * pnpm --filter @polycore/runner runner firestore.count \
24
+ * --project alpha --env dev --args '{"collection":"users"}' \
25
+ * --config ./runner.config.json
26
+ */
27
+
28
+ interface ParsedArgs {
29
+ readonly positional: string[];
30
+ readonly flags: Map<string, string>;
31
+ }
32
+
33
+ function parseArgs(argv: string[]): ParsedArgs {
34
+ const positional: string[] = [];
35
+ const flags = new Map<string, string>();
36
+ for (let i = 0; i < argv.length; i += 1) {
37
+ const token = argv[i];
38
+ if (token === undefined) continue;
39
+ if (!token.startsWith("--")) {
40
+ positional.push(token);
41
+ continue;
42
+ }
43
+ const eq = token.indexOf("=");
44
+ if (eq !== -1) {
45
+ flags.set(token.slice(2, eq), token.slice(eq + 1));
46
+ continue;
47
+ }
48
+ const key = token.slice(2);
49
+ const next = argv[i + 1];
50
+ if (next !== undefined && !next.startsWith("--")) {
51
+ flags.set(key, next);
52
+ i += 1;
53
+ } else {
54
+ flags.set(key, "true");
55
+ }
56
+ }
57
+ return { positional, flags };
58
+ }
59
+
60
+ function printUsage(): void {
61
+ process.stderr.write(
62
+ [
63
+ "polycore runner, local connector harness",
64
+ "",
65
+ "Usage:",
66
+ " runner list [--config <path>]",
67
+ " runner connect [--config <path>]",
68
+ " runner <capability> [--project <slug>] [--env <name>] [--args <json>] [--config <path>]",
69
+ "",
70
+ "Flags:",
71
+ " --project project to run against (default: the sole configured project)",
72
+ " --env environment to run against (default: project default or sole env)",
73
+ " --args JSON object of connector arguments (default: {})",
74
+ ` --config path to runner config (default: $${RUNNER_CONFIG_ENV})`,
75
+ "",
76
+ "Examples:",
77
+ ' runner firestore.count --project alpha --env dev --args \'{"collection":"users"}\'',
78
+ ' runner firestore.query --env dev --args \'{"collection":"users","limit":5}\'',
79
+ "",
80
+ ].join("\n"),
81
+ );
82
+ }
83
+
84
+ /**
85
+ * Pick the config source: an explicit file (flag or `POLYCORE_RUNNER_CONFIG`)
86
+ * for local use, else an env-driven config when the container path is set
87
+ * (`POLYCORE_PROJECTS` present).
88
+ */
89
+ function resolveConfig(configFlag: string | undefined): RunnerConfig {
90
+ const configPath = configFlag ?? process.env[RUNNER_CONFIG_ENV];
91
+ if (configPath !== undefined) return loadRunnerConfig(configPath);
92
+ if (hasEnvConfig(process.env)) return buildRunnerConfigFromEnv(process.env);
93
+ throw new Error(
94
+ `No runner config. Pass --config <path>, set ${RUNNER_CONFIG_ENV}, ` +
95
+ `or provide an env-driven config (${RUNNER_PROJECTS_ENV} + control-plane vars).`,
96
+ );
97
+ }
98
+
99
+ /**
100
+ * Request-driven hosts (Cloud Run, etc.) require the container to listen on
101
+ * `$PORT` and pass a startup probe before routing it traffic. The runner is an
102
+ * outbound-only WebSocket worker that never serves requests, so we run a tiny
103
+ * liveness server alongside the connection purely to satisfy the probe. No-op
104
+ * when `PORT` is unset, local CLI use is unaffected.
105
+ */
106
+ function maybeStartHealthServer(log: (message: string) => void): void {
107
+ const port = process.env["PORT"];
108
+ if (port === undefined || port.length === 0) return;
109
+ const server = createServer((_req, res) => {
110
+ res.writeHead(200, { "content-type": "application/json" });
111
+ res.end(JSON.stringify({ ok: true, role: "runner" }));
112
+ });
113
+ server.listen(Number(port), () => {
114
+ log(`Liveness server listening on :${port}`);
115
+ });
116
+ }
117
+
118
+ async function main(): Promise<void> {
119
+ const { positional, flags } = parseArgs(process.argv.slice(2));
120
+ const command = positional[0];
121
+
122
+ if (command === undefined || command === "help") {
123
+ printUsage();
124
+ return;
125
+ }
126
+
127
+ // For the long-running `connect` worker, open the liveness port *before* the
128
+ // (potentially slow) action compilation in `buildProjectRegistries`, so a
129
+ // request-driven host's startup probe passes in <1s regardless of how long
130
+ // loading takes. Otherwise a cold boot can exceed the probe deadline and get
131
+ // the instance killed + restarted in a loop.
132
+ if (command === "connect") {
133
+ maybeStartHealthServer((message) => process.stderr.write(`${message}\n`));
134
+ }
135
+
136
+ const config = resolveConfig(flags.get("config"));
137
+ const registries = await buildProjectRegistries(config);
138
+
139
+ if (command === "list") {
140
+ for (const [slug, registry] of registries) {
141
+ for (const capability of registry.list()) {
142
+ process.stdout.write(
143
+ `[${slug}] ${capability.name} [${capability.kind}/${capability.effect}] ${capability.description}\n`,
144
+ );
145
+ }
146
+ }
147
+ return;
148
+ }
149
+
150
+ if (command === "connect") {
151
+ const { controlPlane } = config;
152
+ if (controlPlane === undefined) {
153
+ throw new Error(
154
+ 'No "controlPlane" block in the runner config; cannot connect.',
155
+ );
156
+ }
157
+ const client = new RunnerClient({
158
+ controlPlane,
159
+ config,
160
+ registries,
161
+ log: (message) => process.stderr.write(`${message}\n`),
162
+ });
163
+ await client.connect();
164
+ process.stderr.write("Awaiting dispatches… (Ctrl+C to stop)\n");
165
+ await new Promise<never>(() => undefined);
166
+ return;
167
+ }
168
+
169
+ const { slug, project } = resolveProject(config, flags.get("project"));
170
+ const registry = registries.get(slug);
171
+ if (!registry) throw new Error(`No registry for project "${slug}".`);
172
+ const { name: environment, env } = resolveEnvironment(
173
+ project,
174
+ flags.get("env"),
175
+ );
176
+
177
+ const argsJson = flags.get("args");
178
+ const rawArgs: unknown = argsJson === undefined ? {} : JSON.parse(argsJson);
179
+
180
+ const dispatched = await registry.dispatch(command, rawArgs, {
181
+ project: slug,
182
+ environment,
183
+ env,
184
+ log: (message) => process.stderr.write(`${message}\n`),
185
+ });
186
+
187
+ process.stdout.write(`${JSON.stringify(dispatched.result, null, 2)}\n`);
188
+ }
189
+
190
+ void (async () => {
191
+ try {
192
+ await main();
193
+ } catch (error) {
194
+ const message = error instanceof Error ? error.message : String(error);
195
+ process.stderr.write(`Error: ${message}\n`);
196
+ process.exitCode = 1;
197
+ }
198
+ })();