@harness-control/runner 0.1.0 → 0.3.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 CHANGED
@@ -5,16 +5,18 @@ Local runner for Harness Control Protocol, with Codex, Claude Code, OpenCode, an
5
5
  ```sh
6
6
  npm install --global @harness-control/runner
7
7
  hcp-runner version
8
- hcp-runner pair https://your-app.example/hcp/runner --out runner.json
8
+ hcp-runner connect https://your-app.example/hcp/runner
9
9
  ```
10
10
 
11
- Open the approval URL printed by the CLI. Once approved, configure `provider_instances`, `workspaces`, and optionally `workspace_management.allowed_roots` in `runner.json`, then:
11
+ Choose which detected agents to enable in the terminal, then approve this computer in the browser that opens. HCP saves its configuration and starts the connection. Keep the terminal open; run the same command to reconnect. Add existing project folders from your app after connecting.
12
12
 
13
- ```sh
14
- hcp-runner run --config runner.json
15
- ```
13
+ Use your application's actual runner URL. Guided setup allows folder registration under the local filesystem root (the home drive on Windows), but adds no folders automatically. Custom roots and provider settings can be changed in the printed configuration path. Existing settings are preserved on reconnect. Credentials stay in the local credentials file; do not commit it. The runner connects outward, so the local machine needs no inbound port.
14
+
15
+ `connect` stores one installation ID in `~/.hcp-runner/identity.json` and endpoint-specific configuration under `~/.hcp-runner/connections/`. On upgrade it adopts the existing runner ID. The ID survives reconnects, package upgrades, and credential replacement; it is not a hardware fingerprint. Do not copy this directory to another computer.
16
+
17
+ Use `--config <path>` to adopt an existing configuration on first setup. Its location is remembered, so the standard command reconnects it afterward. A second configuration for the same endpoint is rejected, and only one `connect` process can use that endpoint at a time. Use `--providers codex,claude` for explicit noninteractive selection, `--no-browser` to open the printed link manually, or `--pair` to replace revoked credentials with browser approval. Control planes should match the approved account and runner ID within their environment, rotate credentials on that existing machine record, and preserve its references. Hostnames are labels, not identity.
16
18
 
17
- Use your application's actual runner URL. Pairing does not automatically configure coding agents or authorize folders. Credentials stay in the local credentials file; do not commit it. The runner connects outward, so the local machine needs no inbound port.
19
+ Run `codex login` or `claude auth login` locally if needed, then restart HCP. The lower-level `pair --out runner.json` and `run --config runner.json` commands remain available for integrations that own their runner identity and configuration lifecycle.
18
20
 
19
21
  For embedding, public modules are available at `/connection`, `/config`, `/harnesses`, `/mcp`, `/state`, and `/pairing`. Importing the package does not start a runner. See [configuration and examples](https://github.com/qazisaad/harness-control-protocol#runner-configuration), [workspace management](https://github.com/qazisaad/harness-control-protocol/blob/main/docs/workspace-management.md), and [provider support](https://github.com/qazisaad/harness-control-protocol/blob/main/docs/native-providers.md).
20
22
 
@@ -0,0 +1,13 @@
1
+ export type ConnectOptions = {
2
+ controlPlaneUrl: string;
3
+ configPath: string;
4
+ connectionDirectory: string;
5
+ identityPath: string;
6
+ providers?: string[];
7
+ pair: boolean;
8
+ openBrowser: boolean;
9
+ };
10
+ export declare function parseConnectOptions(args: string[], home?: string): ConnectOptions;
11
+ export declare function openApprovalBrowser(value: string): Promise<boolean>;
12
+ export declare function connectMachine(options: ConnectOptions, run: (path: string) => Promise<number>): Promise<number>;
13
+ //# sourceMappingURL=connect.d.ts.map
@@ -0,0 +1,187 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { existsSync, unlinkSync } from "node:fs";
3
+ import { link, mkdir, open, readFile, rename, rm, writeFile } from "node:fs/promises";
4
+ import { homedir, hostname } from "node:os";
5
+ import { dirname, join, parse, resolve } from "node:path";
6
+ import { spawn } from "node:child_process";
7
+ import { createInterface } from "node:readline/promises";
8
+ import { z } from "zod";
9
+ import { loadRunnerConfig, ProviderInstanceConfigSchema, RunnerConfigSchema } from "./config/index.js";
10
+ import { createDefaultHarnessAdapterRegistry } from "./harnesses/adapters/registry.js";
11
+ import { loadRunnerCredential, normalizeControlPlaneUrl, pairWithReferenceControlPlane, writeRunnerCredentials } from "./pairing/index.js";
12
+ export function parseConnectOptions(args, home = homedir()) {
13
+ if (!args[0])
14
+ throw new Error("Usage: hcp-runner connect <control-plane-url> [--config path] [--providers codex,claude,opencode] [--pair] [--no-browser]");
15
+ const controlPlaneUrl = normalizeControlPlaneUrl(args[0]);
16
+ const key = createHash("sha256").update(controlPlaneUrl).digest("hex").slice(0, 16);
17
+ const connectionDirectory = join(home, ".hcp-runner", "connections", key);
18
+ const options = { controlPlaneUrl, connectionDirectory, identityPath: join(home, ".hcp-runner", "identity.json"), configPath: join(connectionDirectory, "runner.json"), pair: false, openBrowser: true };
19
+ for (let index = 1; index < args.length; index++) {
20
+ const arg = args[index];
21
+ if (arg === "--pair")
22
+ options.pair = true;
23
+ else if (arg === "--no-browser")
24
+ options.openBrowser = false;
25
+ else if (arg === "--config" || arg === "--providers") {
26
+ const value = args[++index];
27
+ if (!value || value.startsWith("--"))
28
+ throw new Error(`${arg} requires a value.`);
29
+ if (arg === "--config")
30
+ options.configPath = resolve(value);
31
+ else
32
+ options.providers = parseProviders(value);
33
+ }
34
+ else
35
+ throw new Error(`Unknown connect argument: ${arg}`);
36
+ }
37
+ return options;
38
+ }
39
+ function parseProviders(value) {
40
+ const providers = value.split(",").map(item => item.trim()).filter(Boolean);
41
+ if (!providers.length || providers.some(item => !["codex", "claude", "opencode"].includes(item)) || new Set(providers).size !== providers.length) {
42
+ throw new Error("Choose one or more agents: codex,claude,opencode (comma-separated, without duplicates).");
43
+ }
44
+ return providers;
45
+ }
46
+ export async function openApprovalBrowser(value) {
47
+ const url = new URL(value);
48
+ if (!["http:", "https:"].includes(url.protocol) || url.username || url.password)
49
+ throw new Error("Invalid pairing approval URL.");
50
+ const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "rundll32" : "xdg-open";
51
+ const args = process.platform === "win32" ? ["url.dll,FileProtocolHandler", url.href] : [url.href];
52
+ return new Promise(resolveOpened => {
53
+ const child = spawn(command, args, { stdio: "ignore", timeout: 5_000 });
54
+ child.once("error", () => resolveOpened(false));
55
+ child.once("exit", code => resolveOpened(code === 0));
56
+ });
57
+ }
58
+ async function saveJson(path, config) {
59
+ const temp = `${path}.${randomUUID()}.tmp`;
60
+ try {
61
+ await writeFile(temp, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600, flag: "wx" });
62
+ await rename(temp, path);
63
+ }
64
+ finally {
65
+ await rm(temp, { force: true });
66
+ }
67
+ }
68
+ async function installationIdentity(path, existingRunnerId) {
69
+ const schema = z.object({ runner_id: z.string().min(1).max(200) }).strict();
70
+ if (!existsSync(path)) {
71
+ const temporary = `${path}.${randomUUID()}.tmp`;
72
+ try {
73
+ await writeFile(temporary, JSON.stringify({ runner_id: existingRunnerId ?? `runner-${randomUUID()}` }), { mode: 0o600, flag: "wx" });
74
+ try {
75
+ await link(temporary, path);
76
+ }
77
+ catch (error) {
78
+ if (error.code !== "EEXIST")
79
+ throw error;
80
+ }
81
+ }
82
+ finally {
83
+ await rm(temporary, { force: true });
84
+ }
85
+ }
86
+ return schema.parse(JSON.parse(await readFile(path, "utf8"))).runner_id;
87
+ }
88
+ export async function connectMachine(options, run) {
89
+ await mkdir(options.connectionDirectory, { recursive: true, mode: 0o700 });
90
+ const lockPath = join(options.connectionDirectory, "runner.json.lock");
91
+ let lock;
92
+ try {
93
+ lock = await open(lockPath, "wx", 0o600);
94
+ }
95
+ catch (error) {
96
+ if (error.code !== "EEXIST")
97
+ throw error;
98
+ throw new Error(`This connection is already in use. Stop its runner first. If it crashed, remove ${lockPath} after confirming it is stopped.`);
99
+ }
100
+ await lock.writeFile(String(process.pid));
101
+ await lock.close();
102
+ const release = () => { if (existsSync(lockPath))
103
+ unlinkSync(lockPath); };
104
+ process.once("exit", release);
105
+ const setupAbort = new AbortController();
106
+ const cancelSetup = () => setupAbort.abort(new Error("Setup cancelled. Run the command again when ready."));
107
+ process.once("SIGINT", cancelSetup);
108
+ process.once("SIGTERM", cancelSetup);
109
+ try {
110
+ const locationPath = join(options.connectionDirectory, "config-path.json");
111
+ const defaultPath = join(options.connectionDirectory, "runner.json");
112
+ const savedPath = existsSync(locationPath) ? z.string().min(1).parse(JSON.parse(await readFile(locationPath, "utf8"))) : existsSync(defaultPath) ? defaultPath : undefined;
113
+ if (savedPath && options.configPath !== defaultPath && options.configPath !== savedPath) {
114
+ throw new Error(`This machine already has a configuration for this server: ${savedPath}. Run the standard connect command to reuse it.`);
115
+ }
116
+ const configPath = savedPath ?? options.configPath;
117
+ await mkdir(dirname(configPath), { recursive: true, mode: 0o700 });
118
+ const existing = existsSync(configPath);
119
+ let config;
120
+ if (existing) {
121
+ config = await loadRunnerConfig(configPath);
122
+ if (normalizeControlPlaneUrl(config.control_plane_url) !== options.controlPlaneUrl)
123
+ throw new Error("This config belongs to another control plane. Use its URL or a different --config path.");
124
+ if (await installationIdentity(options.identityPath, config.runner_id) !== config.runner_id)
125
+ throw new Error("This configuration belongs to a different HCP installation. Use this installation’s saved configuration; do not copy machine credentials between installations.");
126
+ if (options.providers && options.providers.slice().sort().join(",") !== config.provider_instances.filter(provider => provider.enabled).map(provider => provider.driver_kind).sort().join(",")) {
127
+ throw new Error("This connection already has different agent settings. Edit its config to change them; reconnect preserves existing settings.");
128
+ }
129
+ }
130
+ else {
131
+ const providers = ["codex", "claude", "opencode"].map(driver => ProviderInstanceConfigSchema.parse({ id: `${driver}-local`, driver_kind: driver, display_name: driver === "claude" ? "Claude Code" : driver === "codex" ? "Codex" : "OpenCode" }));
132
+ console.log("Checking installed coding agents…");
133
+ const statuses = await createDefaultHarnessAdapterRegistry().probeProviders(providers);
134
+ for (const status of statuses)
135
+ console.log(` ${status.driver_kind}: ${!status.installed ? "not installed" : status.authStatus === "unauthenticated" ? "sign in required" : status.status ?? "unknown"}${status.message ? ` — ${status.message}` : ""}`);
136
+ const installed = statuses.filter(status => status.installed).map(status => status.driver_kind);
137
+ let selected = options.providers;
138
+ if (!selected && process.stdin.isTTY && process.stdout.isTTY && installed.length) {
139
+ const prompt = createInterface({ input: process.stdin, output: process.stdout });
140
+ prompt.once("SIGINT", cancelSetup);
141
+ try {
142
+ selected = parseProviders((await prompt.question(`Agents to enable [${installed.join(",")}]: `, { signal: setupAbort.signal })).trim() || installed.join(","));
143
+ }
144
+ finally {
145
+ prompt.close();
146
+ }
147
+ }
148
+ selected ??= installed;
149
+ if (!selected.length)
150
+ throw new Error("No coding agents found. Install and sign in to an agent, then run this command again.");
151
+ if (selected.some(driver => !installed.includes(driver)))
152
+ throw new Error("A selected agent is not installed. Install it before connecting.");
153
+ const runnerId = await installationIdentity(options.identityPath);
154
+ config = RunnerConfigSchema.parse({ runner_id: runnerId, host_id: hostname(), control_plane_url: options.controlPlaneUrl,
155
+ credentials_path: join(dirname(configPath), "credentials.json"), state_path: join(dirname(configPath), "state.json"),
156
+ workspaces: [], workspace_management: { allowed_roots: [parse(homedir()).root] }, provider_instances: providers.filter(provider => selected.includes(provider.driver_kind)) });
157
+ await saveJson(configPath, config);
158
+ }
159
+ await saveJson(locationPath, configPath);
160
+ setupAbort.signal.throwIfAborted();
161
+ const credential = await loadRunnerCredential(config);
162
+ if (!credential || options.pair) {
163
+ console.log("Approve this computer in your browser. You can then register existing folders in P2A; no folder is added automatically.");
164
+ const pairing = await pairWithReferenceControlPlane({ controlPlaneUrl: config.control_plane_url, runnerId: config.runner_id, hostId: config.host_id ?? config.runner_id, signal: setupAbort.signal,
165
+ onPairingCode: async (code) => {
166
+ console.log(`Approval link: ${code.pairing_url}\nFallback pairing code: ${code.pairing_code}`);
167
+ if (options.openBrowser && !await openApprovalBrowser(code.pairing_url))
168
+ console.log("Could not open a browser. Open the approval link yourself.");
169
+ console.log("Waiting for approval…");
170
+ } });
171
+ config.credentials_path ??= join(dirname(configPath), "credentials.json");
172
+ await writeRunnerCredentials(config.credentials_path, pairing.credential);
173
+ await saveJson(configPath, config);
174
+ }
175
+ process.removeListener("SIGINT", cancelSetup);
176
+ process.removeListener("SIGTERM", cancelSetup);
177
+ console.log(`Configuration: ${configPath}\nKeep this terminal open. Press Ctrl+C to disconnect; run the same command to reconnect.`);
178
+ return await run(configPath);
179
+ }
180
+ finally {
181
+ process.removeListener("SIGINT", cancelSetup);
182
+ process.removeListener("SIGTERM", cancelSetup);
183
+ process.removeListener("exit", release);
184
+ release();
185
+ }
186
+ }
187
+ //# sourceMappingURL=connect.js.map
package/dist/index.js CHANGED
@@ -9,12 +9,22 @@ import { loadRunnerConfig } from "./config/index.js";
9
9
  import { RunnerConnection } from "./connection/index.js";
10
10
  import { HarnessSessionManager } from "./harnesses/index.js";
11
11
  import { consoleLogger } from "./logs/index.js";
12
+ import { connectMachine, parseConnectOptions } from "./connect.js";
12
13
  import { createDevelopmentHmacProofSigner } from "./mcp/McpAttachmentClient.js";
13
14
  import { JsonRunnerStateStore, defaultRunnerStatePath } from "./state/index.js";
14
15
  import { defaultCredentialsPath, loadRunnerCredential, normalizeControlPlaneUrl, pairWithReferenceControlPlane, requestConnectionToken, writeRunnerCredentials, } from "./pairing/index.js";
15
16
  const RUNNER_VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
16
17
  export async function main(argv = process.argv.slice(2)) {
17
18
  const command = argv[0];
19
+ if (command === "connect") {
20
+ try {
21
+ return await connectMachine(parseConnectOptions(argv.slice(1)), path => main(["run", "--config", path]));
22
+ }
23
+ catch (error) {
24
+ console.error(error instanceof Error ? error.message : String(error));
25
+ return 1;
26
+ }
27
+ }
18
28
  if (command === "version") {
19
29
  console.log(`hcp-runner ${RUNNER_VERSION} (${HCP_VERSION})`);
20
30
  return 0;
@@ -119,7 +129,7 @@ export async function main(argv = process.argv.slice(2)) {
119
129
  });
120
130
  return new Promise(() => undefined);
121
131
  }
122
- console.log("Usage: hcp-runner <version|pair|run>");
132
+ console.log("Usage: hcp-runner <version|connect|pair|run>");
123
133
  return command ? 1 : 0;
124
134
  }
125
135
  function parseConfigPath(args) {
@@ -8,6 +8,9 @@ export declare class WorkspaceManager {
8
8
  constructor(config: RunnerConfig, configPath: string | undefined, sessions: Pick<HarnessSessionManager, "updateWorkspaceConfiguration">);
9
9
  snapshot(): HcpWorkspaceManagement;
10
10
  execute(requestId: string, request: HcpWorkspacesRequestPayload): Promise<HcpWorkspacesResultPayload>;
11
+ private canonicalRoots;
12
+ private allowedDirectory;
13
+ private browse;
11
14
  private change;
12
15
  }
13
16
  //# sourceMappingURL=index.d.ts.map
@@ -1,6 +1,7 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
- import { open, readFile, realpath, rename, rm, stat } from "node:fs/promises";
3
- import { isAbsolute, relative, sep } from "node:path";
2
+ import { open, readdir, readFile, realpath, rename, rm, stat } from "node:fs/promises";
3
+ import { dirname, isAbsolute, join, relative, sep } from "node:path";
4
+ import { homedir } from "node:os";
4
5
  import { isDeepStrictEqual } from "node:util";
5
6
  import { z } from "zod";
6
7
  import { RunnerConfigSchema } from "../config/index.js";
@@ -17,25 +18,88 @@ export class WorkspaceManager {
17
18
  return {
18
19
  revision: this.config.workspace_revision ?? createHash("sha256").update(JSON.stringify(this.config.workspaces)).digest("hex"),
19
20
  allowed_roots: this.configPath ? this.config.workspace_management?.allowed_roots ?? [] : [],
21
+ directory_browsing: true,
20
22
  };
21
23
  }
22
24
  async execute(requestId, request) {
23
25
  let outcome;
24
26
  try {
25
- if (request.operation.kind === "list") {
26
- if (Date.parse(request.expires_at) <= Date.now())
27
- throw new Error("Request expired. Refresh workspaces.");
27
+ if (request.operation.kind === "browse") {
28
+ outcome = await this.browse(request);
28
29
  }
29
30
  else {
30
- await this.sessions.updateWorkspaceConfiguration(() => this.change(request));
31
+ if (request.operation.kind === "list") {
32
+ if (Date.parse(request.expires_at) <= Date.now())
33
+ throw new Error("Request expired. Refresh workspaces.");
34
+ }
35
+ else {
36
+ await this.sessions.updateWorkspaceConfiguration(() => this.change(request));
37
+ }
38
+ outcome = { kind: "success" };
31
39
  }
32
- outcome = { kind: "success" };
33
40
  }
34
41
  catch (error) {
35
42
  outcome = { kind: "error", message: error instanceof Error ? error.message.slice(0, 1000) : "Workspace update failed." };
36
43
  }
37
44
  return { request_id: requestId, outcome, management: this.snapshot(), workspaces: this.config.workspaces.map(workspace => ({ ...workspace })) };
38
45
  }
46
+ async canonicalRoots() {
47
+ const roots = this.snapshot().allowed_roots;
48
+ if (roots.length === 0)
49
+ throw new Error("Folder browsing is disabled. Configure workspace_management.allowed_roots locally, then restart the runner.");
50
+ return Promise.all(roots.map(root => realpath(root)));
51
+ }
52
+ async allowedDirectory(path, roots) {
53
+ if (!isAbsolute(path))
54
+ throw new Error("Use an absolute folder path on this machine.");
55
+ const canonical = await realpath(path);
56
+ if (!roots.some(root => contained(root, canonical)))
57
+ throw new Error("The folder is outside this machine’s allowed roots.");
58
+ if (!(await stat(canonical)).isDirectory())
59
+ throw new Error("Choose an existing folder.");
60
+ return canonical;
61
+ }
62
+ async browse(request) {
63
+ if (request.operation.kind !== "browse")
64
+ throw new Error("Expected a folder browsing request.");
65
+ const operation = request.operation;
66
+ if (Date.parse(request.expires_at) <= Date.now())
67
+ throw new Error("Folder request expired. Try again.");
68
+ const roots = await this.canonicalRoots();
69
+ const home = homedir();
70
+ const path = await this.allowedDirectory(operation.path ?? (roots.some(root => contained(root, home)) ? home : roots[0]), roots);
71
+ const candidates = (await readdir(path, { withFileTypes: true }))
72
+ .filter(entry => (entry.isDirectory() || entry.isSymbolicLink()) && (!operation.cursor || entry.name > operation.cursor))
73
+ .sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
74
+ const entries = [];
75
+ let pageBytes = 0;
76
+ let hasMore = false;
77
+ for (const entry of candidates) {
78
+ if (Date.parse(request.expires_at) <= Date.now())
79
+ throw new Error("Folder request expired. Try again.");
80
+ const candidate = join(path, entry.name);
81
+ try {
82
+ const canonical = await realpath(candidate);
83
+ if (roots.some(root => contained(root, canonical)) && (await stat(canonical)).isDirectory()) {
84
+ const item = { name: entry.name, path: canonical };
85
+ const bytes = Buffer.byteLength(JSON.stringify(item));
86
+ if (entries.length === 200 || pageBytes + bytes > 64 * 1024) {
87
+ hasMore = true;
88
+ break;
89
+ }
90
+ entries.push(item);
91
+ pageBytes += bytes;
92
+ }
93
+ }
94
+ catch (error) {
95
+ if (!(error instanceof Error && "code" in error && ["ENOENT", "EACCES", "EPERM", "ELOOP"].includes(String(error.code))))
96
+ throw error;
97
+ }
98
+ }
99
+ const parent = dirname(path);
100
+ return { kind: "directory", path, ...(parent !== path && roots.some(root => contained(root, parent)) ? { parent } : {}),
101
+ entries, ...(hasMore ? { next_cursor: entries.at(-1).name } : {}) };
102
+ }
39
103
  async change(request) {
40
104
  if (Date.parse(request.expires_at) <= Date.now())
41
105
  throw new Error("Request expired. Refresh workspaces.");
@@ -48,14 +112,7 @@ export class WorkspaceManager {
48
112
  const operation = request.operation;
49
113
  let workspaces = this.config.workspaces.map(workspace => ({ ...workspace }));
50
114
  if (operation.kind === "add") {
51
- if (!isAbsolute(operation.path))
52
- throw new Error("Use an absolute folder path on this machine.");
53
- const path = await realpath(operation.path);
54
- if (!(await stat(path)).isDirectory())
55
- throw new Error("The workspace must be an existing folder.");
56
- const canonicalRoots = await Promise.all(roots.map(root => realpath(root)));
57
- if (!canonicalRoots.some(root => contained(root, path)))
58
- throw new Error("The folder is outside this machine’s allowed roots.");
115
+ const path = await this.allowedDirectory(operation.path, await this.canonicalRoots());
59
116
  const existingPaths = await Promise.all(workspaces.map(async (workspace) => {
60
117
  try {
61
118
  return await realpath(workspace.path);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@harness-control/runner",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "bin": {
@@ -41,11 +41,11 @@
41
41
  "scripts": {
42
42
  "build": "tsc -b",
43
43
  "check": "tsc -b --pretty false",
44
- "test": "node --import tsx --test --test-concurrency=1 src/**/*.test.ts"
44
+ "test": "node --import tsx --test --test-concurrency=1 'src/**/*.test.ts'"
45
45
  },
46
46
  "dependencies": {
47
47
  "@anthropic-ai/claude-agent-sdk": "0.3.267",
48
- "@harness-control/protocol": "0.1.0",
48
+ "@harness-control/protocol": "0.3.0",
49
49
  "@modelcontextprotocol/sdk": "^1.29.0",
50
50
  "ws": "^8.21.0",
51
51
  "zod": "^4.4.3"