@waniwani/kit 0.1.1 → 0.1.4

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/src/server.ts CHANGED
@@ -11,9 +11,10 @@
11
11
  * hands them to `registerApp()`. Nothing else.
12
12
  */
13
13
 
14
+ import cors from "cors";
15
+ import express, { type ErrorRequestHandler, type RequestHandler } from "express";
14
16
  import { McpServer, type ViewName } from "skybridge/server";
15
- import { z } from "zod";
16
- import type { DocEntry, Shape, ToolHints, WidgetCsp } from "./index.js";
17
+ import type { EndpointDefinition, HttpMethod, Shape, ToolHints, WidgetCsp } from "./index.js";
17
18
 
18
19
  /**
19
20
  * The manifest holds definitions with unrelated schemas side by side, so the
@@ -52,7 +53,8 @@ export type Manifest = {
52
53
  tools: Array<{ name: string; def: AnyToolDefinition }>;
53
54
  widgets: Array<{ name: string; def: AnyWidgetDefinition }>;
54
55
  flows: CompiledFlow[];
55
- docs: DocEntry[];
56
+ /** HTTP endpoints, each with the path its file position produced. */
57
+ endpoints?: Array<{ path: string; def: EndpointDefinition }>;
56
58
  /**
57
59
  * Origins the template's Tailwind entry loads from, read off it at build
58
60
  * time. Every view imports that stylesheet, so every widget needs them.
@@ -127,61 +129,80 @@ function widgetError(name: string, error: unknown) {
127
129
  };
128
130
  }
129
131
 
130
- function registerDocsTool(server: McpServer, docs: DocEntry[]) {
131
- const corpus = docs.map((doc) => ({
132
- ...doc,
133
- haystack: `${doc.title}\n${doc.body}`.toLowerCase(),
134
- }));
132
+ /**
133
+ * Answer anything outside the declared methods with 405 rather than running the
134
+ * handler. An endpoint is mounted with `use()`, which matches every method, so
135
+ * without this a `GET /api/cal/book` would reach a handler written for a POST
136
+ * body and fail somewhere less obvious.
137
+ */
138
+ function methodGuard(allowed: string[]): RequestHandler {
139
+ // A preflight never carries the real method, and answering it 405 blocks the
140
+ // request it was asking about.
141
+ const pass = new Set([...allowed, "OPTIONS"]);
135
142
 
136
- server.registerTool(
137
- {
138
- name: "search_docs",
139
- title: "Search the documentation",
140
- description:
141
- "Search this product's documentation and answer general questions — pricing, eligibility, policies, how things work. Always search before answering, and answer only from what comes back. Never invent facts that are not in the results.",
142
- inputSchema: { question: z.string().describe("The user's question, in their own words.") },
143
- outputSchema: {
144
- results: z.array(z.object({ slug: z.string(), title: z.string(), body: z.string() })),
145
- },
146
- annotations: annotations("Search the documentation", undefined, { readOnly: true }),
147
- },
148
- async ({ question }) => {
149
- const terms = question
150
- .toLowerCase()
151
- .split(/[^a-z0-9]+/)
152
- .filter((term) => term.length > 2);
143
+ return (req, res, next) => {
144
+ if (pass.has(req.method)) return next();
145
+ res.setHeader("Allow", allowed.join(", "));
146
+ res.status(405).json({ error: `${req.method} not allowed` });
147
+ };
148
+ }
153
149
 
154
- const results = corpus
155
- .map((doc) => ({
156
- doc,
157
- score: terms.reduce((sum, term) => sum + (doc.haystack.includes(term) ? 1 : 0), 0),
158
- }))
159
- .filter(({ score }) => score > 0)
160
- .sort((a, b) => b.score - a.score)
161
- .slice(0, 3)
162
- .map(({ doc }) => ({ slug: doc.slug, title: doc.title, body: doc.body }));
150
+ /**
151
+ * The last link in every endpoint's chain: a 4-arity middleware, which is how
152
+ * Express recognises an error handler.
153
+ *
154
+ * Scoped to the endpoint's own mount path rather than the app, so it cannot
155
+ * catch anything the endpoint did not cause. Without it a rejected handler — or
156
+ * a malformed JSON body, which the parser reports the same way — reaches
157
+ * Express's default handler and answers an HTML error page to a `fetch()` that
158
+ * is waiting for JSON.
159
+ */
160
+ function endpointErrorHandler(path: string): ErrorRequestHandler {
161
+ return (error, _req, res, next) => {
162
+ console.error(`[waniwani] endpoint "${path}" failed:`, error);
163
+ if (res.headersSent) return next(error);
164
+ // `express.json()` rejects a malformed body with a 400 already on the error.
165
+ const status = typeof (error as { status?: unknown })?.status === "number"
166
+ ? (error as { status: number }).status
167
+ : 500;
168
+ res.status(status).json({
169
+ error: error instanceof Error ? error.message : "Internal server error",
170
+ });
171
+ };
172
+ }
163
173
 
164
- if (results.length === 0) {
165
- const text = "Nothing in the documentation covers that question.";
166
- return { structuredContent: { results: [] }, content: [{ type: "text" as const, text }] };
167
- }
174
+ /**
175
+ * Mount an app's HTTP endpoints on the server's Express app.
176
+ *
177
+ * Everything the framework does not do for us is done here, once, rather than
178
+ * left to each endpoint to remember: CORS (a widget calls from another origin),
179
+ * a JSON body parser (the framework installs none, so `req.body` would be
180
+ * `undefined`), the method guard, and the error envelope.
181
+ */
182
+ function registerEndpoints(
183
+ server: McpServer,
184
+ endpoints: NonNullable<Manifest["endpoints"]>,
185
+ ): void {
186
+ for (const { path, def } of endpoints) {
187
+ const methods = def.method ? [def.method].flat().map((m) => m.toUpperCase()) : undefined;
168
188
 
169
- return {
170
- structuredContent: { results },
171
- content: [
172
- {
173
- type: "text" as const,
174
- text: results.map((r) => `## ${r.title}\n${r.body}`).join("\n\n---\n\n"),
175
- },
176
- ],
177
- };
178
- },
179
- );
189
+ const chain: Array<RequestHandler | ErrorRequestHandler> = [];
190
+ // The preflight answer names the methods the guard below actually accepts.
191
+ // Browsers cache it, so advertising a method that then answers 405 is a
192
+ // contradiction the widget author has to debug twice.
193
+ if (def.cors !== false) chain.push(cors(methods ? { methods } : undefined));
194
+ if (def.json !== false) chain.push(express.json());
195
+ if (methods) chain.push(methodGuard(methods));
196
+ chain.push(def.handler, endpointErrorHandler(path));
197
+
198
+ // The error handler's arity is what makes Express treat it as one, and
199
+ // `use()` is typed for request handlers only.
200
+ server.use(path, ...(chain as RequestHandler[]));
201
+ }
180
202
  }
181
203
 
182
204
  /**
183
- * Register an app's tools, widgets, flows, and docs onto a server the template
184
- * built.
205
+ * Register an app's tools, widgets and flows onto a server the template built.
185
206
  *
186
207
  * The template owns construction, its own tools, `withWaniwani`, and `run()`.
187
208
  * This adds to that server rather than replacing it, so a tool the template
@@ -189,7 +210,13 @@ function registerDocsTool(server: McpServer, docs: DocEntry[]) {
189
210
  * app's own tools sit alongside it.
190
211
  */
191
212
  export async function registerApp(server: McpServer, manifest: Manifest): Promise<McpServer> {
192
- const { tools, widgets, flows, docs, styleDomains = [] } = manifest;
213
+ const { tools, widgets, flows, endpoints = [], styleDomains = [] } = manifest;
214
+
215
+ // Before the tools, because Express matches in registration order and the
216
+ // framework mounts `/mcp` after this function returns. Nothing here can
217
+ // shadow it — `/api/...` and `/mcp` do not overlap — but the ordering is the
218
+ // reason an endpoint is reachable at all.
219
+ registerEndpoints(server, endpoints);
193
220
 
194
221
  // Widgets: one `data` schema drives the input schema, the structured output,
195
222
  // and the type the component receives.
@@ -274,10 +301,6 @@ export async function registerApp(server: McpServer, manifest: Manifest): Promis
274
301
  server.registerTool({ ...flow.config, name: flow.name }, flow.handler);
275
302
  }
276
303
 
277
- if (docs.length > 0) {
278
- registerDocsTool(server, docs);
279
- }
280
-
281
304
  // `withWaniwani` is deliberately not called here. It wraps every registered
282
305
  // handler in place, so it has to run after the last registration — which is
283
306
  // the template's, not this function's. `src/server.ts` calls it.
package/cli/account.mjs DELETED
@@ -1,264 +0,0 @@
1
- /**
2
- * The WaniWani account boundary.
3
- *
4
- * `tunnel` is the one command in this CLI that talks to app.waniwani.ai, and it
5
- * needs two facts: who the developer is, and which agent this repo is bound to.
6
- * Both already have a home that `@waniwani/cli` writes and `@waniwani/sdk`
7
- * reads, so this file invents no third place to look:
8
- *
9
- * ~/.config/waniwani/settings.json credentials and the instance they were
10
- * issued for. One login per machine, mode
11
- * 0600, honours XDG_CONFIG_HOME.
12
- * ./waniwani.json orgId, projectId, apiUrl, devPort. The
13
- * file `waniwani connect` writes, and the
14
- * one the SDK loads at runtime.
15
- *
16
- * `.waniwani/` holds neither of them. In the kit that directory is build output:
17
- * every command regenerates it, the app's .gitignore covers it, and `eject` says
18
- * to delete it. A refresh token written there lasts until the next `waniwani
19
- * dev`. This is the same reasoning that moved the login CLI's own credentials
20
- * out of it.
21
- *
22
- * `WANIWANI_API_KEY` is ignored here on purpose. A kit app usually carries that
23
- * key for tracking, where it is scoped to the project's production environment,
24
- * while the tunnel and dev-session routes are about the human at the terminal.
25
- * Auth is therefore the OAuth token, refreshed in place when it has aged out.
26
- */
27
-
28
- import { spawn } from "node:child_process";
29
- import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
30
- import { homedir } from "node:os";
31
- import { dirname, join } from "node:path";
32
- import { fileURLToPath } from "node:url";
33
- import { dim } from "./log.mjs";
34
-
35
- const DEFAULT_API_URL = "https://app.waniwani.ai";
36
-
37
- const CONFIG_HOME = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
38
- const SETTINGS_FILE = join(CONFIG_HOME, "waniwani", "settings.json");
39
- const DIR_MODE = 0o700;
40
- const FILE_MODE = 0o600;
41
-
42
- /** A token this close to expiry is treated as expired, so a long run doesn't 401 mid-flight. */
43
- const EXPIRY_SKEW_MS = 5 * 60 * 1000;
44
-
45
- /**
46
- * The CLI that owns logging in and binding a repo to an agent.
47
- *
48
- * Those two flows are an OAuth2 PKCE round trip with a local callback server,
49
- * and a pair of pickers over the org's agents. Both live in `@waniwani/cli`
50
- * already, and both write exactly the files above, so this CLI drives it for
51
- * them instead of carrying a second copy that has to stay in step.
52
- */
53
- const LOGIN_CLI = "@waniwani/cli";
54
-
55
- function readSettings() {
56
- try {
57
- return JSON.parse(readFileSync(SETTINGS_FILE, "utf-8"));
58
- } catch {
59
- return {};
60
- }
61
- }
62
-
63
- function writeSettings(settings) {
64
- mkdirSync(dirname(SETTINGS_FILE), { recursive: true, mode: DIR_MODE });
65
- writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, "\t"));
66
- chmodSync(SETTINGS_FILE, FILE_MODE);
67
- }
68
-
69
- /**
70
- * The repo's `waniwani.json`, or null when it has never been connected.
71
- *
72
- * Only the JSON file counts. An app folder also has a `waniwani.config.ts`, and
73
- * in the kit that is the app itself (`defineApp`), which has nothing to say
74
- * about org or agent ids.
75
- */
76
- export function readProjectConfig(appRoot) {
77
- const file = join(appRoot, "waniwani.json");
78
- if (!existsSync(file)) return null;
79
- try {
80
- return JSON.parse(readFileSync(file, "utf-8"));
81
- } catch (error) {
82
- throw new Error(`${file} is not valid JSON: ${error.message}`);
83
- }
84
- }
85
-
86
- /** Which instance to talk to, most specific wins. Mirrors the login CLI's order. */
87
- function resolveApiUrl(project) {
88
- return process.env.WANIWANI_API_URL || project?.apiUrl || readSettings().apiUrl || DEFAULT_API_URL;
89
- }
90
-
91
- function isExpired(settings) {
92
- if (!settings.expiresAt) return true;
93
- return new Date(settings.expiresAt).getTime() - EXPIRY_SKEW_MS < Date.now();
94
- }
95
-
96
- /**
97
- * Trade the refresh token for a new access token and persist both.
98
- *
99
- * `resource` is RFC 8707 and load-bearing: without it the OAuth server issues an
100
- * opaque token, which then fails JWKS validation on every API call.
101
- */
102
- async function refreshTokens(apiUrl) {
103
- const settings = readSettings();
104
- if (!settings.refreshToken || !settings.clientId) return null;
105
-
106
- const response = await fetch(`${apiUrl}/api/auth/oauth2/token`, {
107
- method: "POST",
108
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
109
- body: new URLSearchParams({
110
- grant_type: "refresh_token",
111
- refresh_token: settings.refreshToken,
112
- client_id: settings.clientId,
113
- resource: apiUrl,
114
- }).toString(),
115
- });
116
- if (!response.ok) return null;
117
-
118
- const tokens = await response.json();
119
- writeSettings({
120
- ...settings,
121
- accessToken: tokens.access_token,
122
- refreshToken: tokens.refresh_token,
123
- expiresAt: new Date(Date.now() + tokens.expires_in * 1000).toISOString(),
124
- });
125
- return tokens.access_token;
126
- }
127
-
128
- /**
129
- * The stored token when it is usable against `apiUrl`, else null.
130
- *
131
- * A token issued for another instance counts as no token at all: the US and EU
132
- * deployments have separate identities, so carrying one over produces a 401 that
133
- * reads like a broken account.
134
- */
135
- async function usableToken(apiUrl) {
136
- const settings = readSettings();
137
- if (!settings.accessToken) return null;
138
- if (settings.apiUrl && settings.apiUrl !== apiUrl) return null;
139
- if (!isExpired(settings)) return settings.accessToken;
140
- return refreshTokens(apiUrl);
141
- }
142
-
143
- /** The login CLI's entry file when it is installed alongside us, else null. */
144
- function localLoginCli() {
145
- try {
146
- return fileURLToPath(import.meta.resolve(LOGIN_CLI));
147
- } catch {
148
- return null;
149
- }
150
- }
151
-
152
- /**
153
- * Hand the terminal to the login CLI for one subcommand.
154
- *
155
- * An installed copy is run through `node` by absolute path. Both packages
156
- * publish a `waniwani` binary, so going through `node_modules/.bin` would leave
157
- * it to install order which of the two answers.
158
- */
159
- async function runLoginCli(subcommand) {
160
- const entry = localLoginCli();
161
- const [command, args] = entry
162
- ? [process.execPath, [entry, subcommand]]
163
- : ["npx", ["-y", `${LOGIN_CLI}@latest`, subcommand]];
164
-
165
- console.log(dim(`[waniwani] running ${LOGIN_CLI} ${subcommand}…`));
166
- const code = await new Promise((resolve) => {
167
- const child = spawn(command, args, { stdio: "inherit" });
168
- child.on("close", (exit) => resolve(exit ?? 1));
169
- child.on("error", () => resolve(1));
170
- });
171
- if (code !== 0) {
172
- throw new Error(`\`waniwani ${subcommand}\` exited with code ${code}`);
173
- }
174
- }
175
-
176
- /**
177
- * Resolve the account this repo runs against, filling in whatever is missing.
178
- *
179
- * A machine with no credentials gets the login flow, and a repo with no
180
- * `waniwani.json` gets the connect flow, both in the terminal the user is
181
- * already sitting in. When they come back the files are on disk and every later
182
- * run of this command is silent.
183
- */
184
- export async function connectAccount(appRoot) {
185
- let project = readProjectConfig(appRoot);
186
- let apiUrl = resolveApiUrl(project);
187
-
188
- if (!(await usableToken(apiUrl))) {
189
- await runLoginCli("login");
190
- project = readProjectConfig(appRoot);
191
- apiUrl = resolveApiUrl(project);
192
- if (!(await usableToken(apiUrl))) {
193
- throw new Error(`no credentials at ${SETTINGS_FILE} after logging in`);
194
- }
195
- }
196
-
197
- if (!project?.projectId) {
198
- await runLoginCli("connect");
199
- project = readProjectConfig(appRoot);
200
- }
201
- if (!project?.projectId) {
202
- throw new Error(`no projectId in ${join(appRoot, "waniwani.json")}: run \`waniwani connect\` to bind this repo to an agent`);
203
- }
204
-
205
- return {
206
- apiUrl,
207
- projectId: project.projectId,
208
- devPort: project.devPort,
209
- playgroundUrl: `${apiUrl}/agents/${project.projectId}/playground?localMode=1`,
210
- };
211
- }
212
-
213
- /**
214
- * An authenticated client for one instance.
215
- *
216
- * Responses come back in the API's `{ success, data, error }` envelope, so the
217
- * payload is unwrapped here and a failure is raised as an ordinary Error the
218
- * CLI's top-level handler prints. A 401 buys one refresh and one retry, which
219
- * covers a token that aged out during a long dev session.
220
- */
221
- export function createClient(apiUrl) {
222
- const send = async (method, path, body, retry = true) => {
223
- const token = await usableToken(apiUrl);
224
- if (!token) {
225
- throw new Error("not logged in: run `waniwani login`");
226
- }
227
-
228
- const response = await fetch(`${apiUrl}${path}`, {
229
- method,
230
- headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
231
- body: body === undefined ? undefined : JSON.stringify(body),
232
- });
233
-
234
- if (response.status === 204) return undefined;
235
-
236
- const text = await response.text();
237
- let payload;
238
- try {
239
- payload = JSON.parse(text);
240
- } catch {
241
- throw new Error(`${method} ${path} failed with ${response.status}: ${text.slice(0, 200)}`);
242
- }
243
-
244
- if (response.ok && !payload.error) return payload.data;
245
-
246
- if (response.status === 401 && retry && (await refreshTokens(apiUrl))) {
247
- return send(method, path, body, false);
248
- }
249
-
250
- const error = payload.error;
251
- const message =
252
- (typeof error === "object" && error?.message) ||
253
- payload.message ||
254
- (typeof error === "string" ? error : null) ||
255
- `${method} ${path} failed with ${response.status}`;
256
- throw new Error(message);
257
- };
258
-
259
- return {
260
- post: (path, body) => send("POST", path, body),
261
- patch: (path) => send("PATCH", path),
262
- delete: (path) => send("DELETE", path),
263
- };
264
- }
package/cli/tunnel.mjs DELETED
@@ -1,140 +0,0 @@
1
- /**
2
- * A public hostname for a dev server on this machine.
3
- *
4
- * The MCP endpoint has to be reachable from the internet before anything else
5
- * can drive it: the WaniWani chat backend runs on Vercel and cannot see
6
- * `localhost`, and neither can Claude Desktop or ChatGPT. Cloudflare answers
7
- * that with a named tunnel per agent, provisioned server-side at agent creation,
8
- * so the hostname is `<slug>.waniwani.dev` and stays that way across runs and is
9
- * safe to paste into an MCP client's config.
10
- *
11
- * The API owns the pairing: it repoints the tunnel's ingress at the port passed
12
- * to it, then mints a connector token for that one tunnel. This file runs
13
- * cloudflared against the token and reports when the edge has the connection.
14
- */
15
-
16
- import { spawn } from "node:child_process";
17
- import { existsSync } from "node:fs";
18
- import { createServer } from "node:net";
19
-
20
- const TUNNEL_READY_TIMEOUT_MS = 30_000;
21
- const SERVER_READY_TIMEOUT_MS = 30_000;
22
- const SERVER_POLL_MS = 500;
23
-
24
- /**
25
- * Bind the wildcard, matching how a Node server binds, so a listener already on
26
- * `::` or `0.0.0.0` reads as a conflict. A check against 127.0.0.1 misses those.
27
- */
28
- export function isPortAvailable(port) {
29
- return new Promise((resolve) => {
30
- const server = createServer();
31
- server.once("error", () => resolve(false));
32
- server.once("listening", () => server.close(() => resolve(true)));
33
- server.listen(port);
34
- });
35
- }
36
-
37
- /** The first free port at or above `start`. */
38
- export async function findAvailablePort(start, attempts = 20) {
39
- for (let port = start; port < start + attempts; port++) {
40
- if (await isPortAvailable(port)) return port;
41
- }
42
- throw new Error(`no free port between ${start} and ${start + attempts - 1}`);
43
- }
44
-
45
- /**
46
- * Wait until something answers on `url`.
47
- *
48
- * Any response counts, including a 404: the question is whether the dev server
49
- * has the port, and the tunnel's ingress is pointed at it before it does.
50
- */
51
- export async function waitForLocalServer(url, timeoutMs = SERVER_READY_TIMEOUT_MS) {
52
- const deadline = Date.now() + timeoutMs;
53
- while (Date.now() < deadline) {
54
- try {
55
- await fetch(url);
56
- return;
57
- } catch {
58
- await new Promise((resolve) => setTimeout(resolve, SERVER_POLL_MS));
59
- }
60
- }
61
- throw new Error(`the dev server did not answer on ${url} within ${timeoutMs / 1000}s`);
62
- }
63
-
64
- /**
65
- * Resolve the cloudflared wrapper at call time.
66
- *
67
- * It is an optional dependency, and its install step fetches a platform binary
68
- * that a CI image or a container build has no use for. A static import would
69
- * make the whole CLI fail to load wherever that install was skipped, so the cost
70
- * lands on the one command that needs it.
71
- */
72
- async function loadCloudflared() {
73
- try {
74
- return await import("cloudflared");
75
- } catch {
76
- throw new Error(
77
- "the tunnel needs the `cloudflared` package, which is not installed.\n" +
78
- " It ships as an optional dependency, so an install run with --omit=optional skips it.\n" +
79
- " Add it with `npm install cloudflared`.",
80
- );
81
- }
82
- }
83
-
84
- /** The package ships a wrapper, so the binary itself is fetched on first use. */
85
- async function cloudflaredBinary() {
86
- const { bin, install } = await loadCloudflared();
87
- if (!existsSync(bin)) await install(bin);
88
- return bin;
89
- }
90
-
91
- /**
92
- * Run the agent's named tunnel under a connector token.
93
- *
94
- * The token encodes which tunnel to serve and the ingress was set when it was
95
- * issued, so there is no `--url` to pass. Resolution waits for cloudflared to
96
- * confirm an edge connection, since anything earlier races traffic against
97
- * connector readiness.
98
- */
99
- export async function startNamedTunnel({ hostname, token }) {
100
- const bin = await cloudflaredBinary();
101
- const child = spawn(bin, ["tunnel", "--no-autoupdate", "run", "--token", token], {
102
- stdio: ["ignore", "pipe", "pipe"],
103
- });
104
-
105
- return new Promise((resolve, reject) => {
106
- let settled = false;
107
- const settle = (fn, value) => {
108
- if (settled) return;
109
- settled = true;
110
- clearTimeout(timer);
111
- fn(value);
112
- };
113
-
114
- const timer = setTimeout(() => {
115
- child.kill("SIGTERM");
116
- settle(reject, new Error(`cloudflared did not connect within ${TUNNEL_READY_TIMEOUT_MS / 1000}s`));
117
- }, TUNNEL_READY_TIMEOUT_MS);
118
-
119
- // cloudflared logs this line on each successful edge handshake, and the
120
- // first one means the hostname is serving traffic.
121
- const onOutput = (chunk) => {
122
- if (chunk.toString().includes("Registered tunnel connection")) {
123
- settle(resolve, {
124
- hostname,
125
- publicUrl: `https://${hostname}`,
126
- stop: () => {
127
- if (child.exitCode === null) child.kill("SIGTERM");
128
- },
129
- });
130
- }
131
- };
132
-
133
- child.stdout?.on("data", onOutput);
134
- child.stderr?.on("data", onOutput);
135
- child.once("error", (error) => settle(reject, error));
136
- child.once("exit", (code) => {
137
- settle(reject, new Error(`cloudflared exited with code ${code ?? "unknown"} before connecting`));
138
- });
139
- });
140
- }