@waniwani/kit 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/cli/log.mjs ADDED
@@ -0,0 +1,177 @@
1
+ /** Build output formatting. Errors point at a file and say how to fix it. */
2
+
3
+ /** ESC by char code: a raw control byte in source is invisible and fragile. */
4
+ const ESC = String.fromCharCode(27);
5
+
6
+ const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
7
+ const wrap = (code) => (text) => (useColor ? `${ESC}[${code}m${text}${ESC}[0m` : text);
8
+
9
+ export const red = wrap("31");
10
+ export const green = wrap("32");
11
+ export const yellow = wrap("33");
12
+ export const dim = wrap("2");
13
+ export const bold = wrap("1");
14
+
15
+ /**
16
+ * The wordmark, at half the height of the art `waniwani login` prints.
17
+ *
18
+ * Same letterforms: the tall version is six rows of full blocks with a box-glyph
19
+ * drop shadow, and each row here folds two of those together — `▀` where only
20
+ * the upper row had ink, `▄` where only the lower did, `█` where both. Three
21
+ * rows instead of six, at the same width, and nothing about the shapes changes.
22
+ *
23
+ * One colour per row rather than per glyph: a vertical ramp needs three escape
24
+ * sequences instead of two hundred, and reads the same.
25
+ */
26
+ const LOGO = [
27
+ "██ ██ ▄█▀▀▀█▄ ███▄ ██ ██ ██ ██ ▄█▀▀▀█▄ ███▄ ██ ██",
28
+ "██ ▄█▄ ██ ██▀▀▀██ ██ ▀█▄ ██ ██ ██ ▄█▄ ██ ██▀▀▀██ ██ ▀█▄ ██ ██",
29
+ " ▀▀▀ ▀▀▀ ▀▀ ▀▀ ▀▀ ▀▀▀▀ ▀▀ ▀▀▀ ▀▀▀ ▀▀ ▀▀ ▀▀ ▀▀▀▀ ▀▀",
30
+ ];
31
+
32
+ const LOGO_WIDTH = 65;
33
+
34
+ /** The brand accent, `#04d916`. */
35
+ const ACCENT = [4, 217, 22];
36
+
37
+ /** How far the first and last rows are mixed toward white and black. */
38
+ const LIGHTEN = 0.42;
39
+ const DARKEN = 0.35;
40
+
41
+ /**
42
+ * The ramp stop for one row: a tint of the accent at the top, the accent itself
43
+ * in the middle, a shade of it at the bottom — so the wordmark reads as one
44
+ * object lit from above.
45
+ *
46
+ * Interpolated from the row count rather than written out, so the art and the
47
+ * ramp cannot drift apart when either changes.
48
+ */
49
+ function rampStop(index, rows) {
50
+ // -1 on the first row, 0 in the middle, +1 on the last.
51
+ const position = rows === 1 ? 0 : (index / (rows - 1)) * 2 - 1;
52
+ if (position <= 0) {
53
+ const mix = LIGHTEN * -position;
54
+ return ACCENT.map((channel) => Math.round(channel + (255 - channel) * mix));
55
+ }
56
+ return ACCENT.map((channel) => Math.round(channel * (1 - DARKEN * position)));
57
+ }
58
+
59
+ /** 24-bit colour is used only where the terminal says it has it. */
60
+ const truecolor = ["truecolor", "24bit"].includes(process.env.COLORTERM ?? "");
61
+
62
+ /** The six levels each channel can take in the xterm 256-colour cube. */
63
+ const CUBE_LEVELS = [0, 95, 135, 175, 215, 255];
64
+
65
+ /** The nearest cube entry, for a terminal that does not announce 24-bit colour. */
66
+ function cube([r, g, b]) {
67
+ const nearest = (channel) =>
68
+ CUBE_LEVELS.reduce(
69
+ (best, _, index) =>
70
+ Math.abs(CUBE_LEVELS[index] - channel) < Math.abs(CUBE_LEVELS[best] - channel) ? index : best,
71
+ 0,
72
+ );
73
+ return 16 + 36 * nearest(r) + 6 * nearest(g) + nearest(b);
74
+ }
75
+
76
+ /** The foreground escape for one row of the ramp. */
77
+ function ramp(index, rows) {
78
+ const rgb = rampStop(index, rows);
79
+ if (!truecolor) {
80
+ return `${ESC}[38;5;${cube(rgb)}m`;
81
+ }
82
+ return `${ESC}[38;2;${rgb.join(";")}m`;
83
+ }
84
+
85
+ /**
86
+ * Print the banner a command opens with.
87
+ *
88
+ * The art needs 65 columns and a terminal that wants colour. A narrower one, a
89
+ * pipe, a CI log or `NO_COLOR` gets the wordmark on one line instead — the same
90
+ * information, and nothing that turns into wrapped garbage in a build log.
91
+ */
92
+ export function banner(version) {
93
+ // `||`, not `??`: a pty whose window size was never set reports 0 columns,
94
+ // which is unknown rather than narrow.
95
+ const columns = process.stdout.columns || 80;
96
+ if (!useColor || columns < LOGO_WIDTH) {
97
+ // The wordmark still carries the accent — it is the same banner, narrowed.
98
+ const mark = useColor ? `${ramp(0, 1)}${ESC}[1mwaniwani${ESC}[0m` : "waniwani";
99
+ console.log(`\n${mark} ${dim(`v${version}`)}`);
100
+ return;
101
+ }
102
+ console.log("");
103
+ for (const [index, line] of LOGO.entries()) {
104
+ console.log(`${ramp(index, LOGO.length)}${line}${ESC}[0m`);
105
+ }
106
+ console.log(dim(`v${version}`.padStart(LOGO_WIDTH)));
107
+ console.log("");
108
+ }
109
+
110
+ /**
111
+ * One URL a command hands the developer, in the shape every command uses for
112
+ * them. The label is padded so a list of endpoints aligns whether the framework
113
+ * printed the line or this CLI did.
114
+ */
115
+ export function endpoint(label, url) {
116
+ return ` ${dim(label.padEnd(8))} ${green(url)}`;
117
+ }
118
+
119
+ function printGroup(entries, marker, color) {
120
+ const byFile = new Map();
121
+ for (const entry of entries) {
122
+ const list = byFile.get(entry.where) ?? [];
123
+ list.push(entry);
124
+ byFile.set(entry.where, list);
125
+ }
126
+
127
+ for (const [where, list] of byFile) {
128
+ console.log(` ${bold(where)}`);
129
+ for (const entry of list) {
130
+ console.log(` ${color(marker)} ${entry.message}`);
131
+ if (entry.hint) {
132
+ console.log(` ${dim(entry.hint)}`);
133
+ }
134
+ }
135
+ console.log("");
136
+ }
137
+ }
138
+
139
+ export function printReport(app, report) {
140
+ if (!report.ok) {
141
+ console.log(`\n${red("✗")} ${bold("Build check failed")}\n`);
142
+ printGroup(report.errors, "└", red);
143
+ if (report.warnings.length > 0) {
144
+ printGroup(report.warnings, "└", yellow);
145
+ }
146
+ return;
147
+ }
148
+
149
+ const counts = [
150
+ [app.widgets.length, "widget"],
151
+ [app.tools.length, "tool"],
152
+ [app.flows.length, "flow"],
153
+ [app.docs.length, "doc"],
154
+ ]
155
+ .filter(([count]) => count > 0)
156
+ .map(([count, label]) => `${count} ${label}${count === 1 ? "" : "s"}`);
157
+
158
+ console.log(`${green("✓")} ${bold("Build check passed")} ${dim(`— ${counts.join(", ")}`)}`);
159
+
160
+ for (const widget of app.widgets) {
161
+ console.log(` ${dim("widget")} ${widget.name}`);
162
+ }
163
+ for (const tool of app.tools) {
164
+ console.log(` ${dim("tool ")} ${tool.name}`);
165
+ }
166
+ for (const flow of app.flows) {
167
+ console.log(` ${dim("flow ")} ${flow.name}`);
168
+ }
169
+ if (app.docs.length > 0) {
170
+ console.log(` ${dim("tool ")} search_docs ${dim(`(${app.docs.length} pages)`)}`);
171
+ }
172
+
173
+ if (report.warnings.length > 0) {
174
+ console.log("");
175
+ printGroup(report.warnings, "└", yellow);
176
+ }
177
+ }
package/cli/scan.mjs ADDED
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Discover an app by walking its folders. Convention, not configuration.
3
+ *
4
+ * waniwani.config.ts
5
+ * tools/<name>.ts
6
+ * widgets/<name>/{widget.ts,ui.tsx}
7
+ * flows/<name>.ts
8
+ * docs/<slug>.md
9
+ *
10
+ * There is no CSS in that list. Styling is Tailwind, from the distribution
11
+ * template's `src/index.css` — its `@theme` tokens and its `dark` variant — and
12
+ * a widget writes utility classes in `ui.tsx`. Stray `styles.css` files are
13
+ * collected only so the build check can tell an author they are dead.
14
+ */
15
+
16
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
17
+ import { basename, extname, join } from "node:path";
18
+
19
+ const CODE_EXT = new Set([".ts", ".tsx", ".mts"]);
20
+
21
+ function listFiles(dir) {
22
+ if (!existsSync(dir)) return [];
23
+ return readdirSync(dir)
24
+ .filter((entry) => !entry.startsWith(".") && !entry.startsWith("_"))
25
+ .map((entry) => join(dir, entry))
26
+ .filter((path) => statSync(path).isFile());
27
+ }
28
+
29
+ function listDirs(dir) {
30
+ if (!existsSync(dir)) return [];
31
+ return readdirSync(dir)
32
+ .filter((entry) => !entry.startsWith(".") && !entry.startsWith("_"))
33
+ .map((entry) => join(dir, entry))
34
+ .filter((path) => statSync(path).isDirectory());
35
+ }
36
+
37
+ function stripExt(path) {
38
+ return basename(path, extname(path));
39
+ }
40
+
41
+ /** `# Title` on the first heading line, falling back to the slug. */
42
+ function docTitle(body, slug) {
43
+ const heading = body.split("\n").find((line) => line.startsWith("# "));
44
+ return heading ? heading.slice(2).trim() : slug;
45
+ }
46
+
47
+ export function scanApp(root) {
48
+ const configFile = [join(root, "waniwani.config.ts"), join(root, "waniwani.config.js")].find(
49
+ existsSync,
50
+ );
51
+
52
+ const tools = listFiles(join(root, "tools"))
53
+ .filter((file) => CODE_EXT.has(extname(file)))
54
+ .map((file) => ({ name: stripExt(file), file }));
55
+
56
+ const widgets = listDirs(join(root, "widgets")).map((dir) => {
57
+ const name = basename(dir);
58
+ const contract = [join(dir, "widget.ts"), join(dir, "widget.tsx")].find(existsSync);
59
+ const ui = [join(dir, "ui.tsx"), join(dir, "ui.jsx")].find(existsSync);
60
+ return { name, dir, contract, ui };
61
+ });
62
+
63
+ const flows = listFiles(join(root, "flows"))
64
+ .filter((file) => CODE_EXT.has(extname(file)))
65
+ .map((file) => ({ name: stripExt(file), file }));
66
+
67
+ const docs = listFiles(join(root, "docs"))
68
+ .filter((file) => extname(file) === ".md")
69
+ .map((file) => {
70
+ const slug = stripExt(file);
71
+ const body = readFileSync(file, "utf-8").trim();
72
+ return { slug, file, title: docTitle(body, slug), body };
73
+ });
74
+
75
+ // The two paths an author is most likely to expect the kit to pick up. It
76
+ // imports neither, so a file at either one is styling that never reaches the
77
+ // browser — the kind of silent no-op the build check exists to name.
78
+ const strayStyles = [
79
+ join(root, "styles.css"),
80
+ ...widgets.map((widget) => join(widget.dir, "styles.css")),
81
+ ].filter(existsSync);
82
+
83
+ return { root, configFile, tools, widgets, flows, docs, strayStyles };
84
+ }
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Resolve the distribution template.
3
+ *
4
+ * The template is a separate public repo, consumed as-is. Nothing is forked
5
+ * into this package: the generator downloads the repo at a pinned commit,
6
+ * caches it, and copies the plumbing out of it. What ships to customers is the
7
+ * same tree anyone can read on GitHub, clone, and deploy by hand.
8
+ *
9
+ * Sources:
10
+ * github:OWNER/REPO#REF a GitHub repo at a branch, tag, or SHA (default)
11
+ * /path/to/checkout a local clone, for working on the template itself
12
+ *
13
+ * Override per command with `--template <source>` or `WANIWANI_TEMPLATE`.
14
+ */
15
+
16
+ import { spawnSync } from "node:child_process";
17
+ import { existsSync, mkdirSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
18
+ import { homedir, tmpdir } from "node:os";
19
+ import { join, resolve } from "node:path";
20
+
21
+ /**
22
+ * `beta`, not `main`. The generator is written against the template's current
23
+ * layout — `vite.config.ts`, `src/server.ts`, `src/views/` — and `main` is still
24
+ * the older `server/` + `web/` + `api/` split, which it cannot absorb. When beta
25
+ * merges down, this goes back to a ref on `main` (a tag, ideally — see the
26
+ * README's known gaps).
27
+ */
28
+ export const DEFAULT_TEMPLATE = "github:WaniWani-AI/mcp-distribution-template#beta";
29
+
30
+ const CACHE_ROOT = join(homedir(), ".cache", "waniwani", "templates");
31
+
32
+ /** `github:owner/repo#ref` -> its parts. */
33
+ function parseGithub(source) {
34
+ const match = /^github:([^/]+)\/([^#]+)(?:#(.+))?$/.exec(source);
35
+ if (!match) return null;
36
+ return { owner: match[1], repo: match[2], ref: match[3] ?? "main" };
37
+ }
38
+
39
+ /**
40
+ * Resolve a ref to a commit SHA, so a cache entry is content-addressed and two
41
+ * builds of the same ref cannot silently differ.
42
+ */
43
+ async function resolveSha({ owner, repo, ref }) {
44
+ let response;
45
+ try {
46
+ response = await fetch(`https://api.github.com/repos/${owner}/${repo}/commits/${ref}`, {
47
+ headers: { Accept: "application/vnd.github.sha" },
48
+ });
49
+ } catch (cause) {
50
+ // Unreachable network. A cached template is a reasonable answer.
51
+ throw Object.assign(new Error(`cannot reach GitHub: ${cause.message}`), { offline: true });
52
+ }
53
+
54
+ // A bad ref is the caller's mistake, not a network problem — falling back
55
+ // to a cached template here would hide the typo.
56
+ if (!response.ok) {
57
+ throw new Error(
58
+ `GitHub returned ${response.status} for ${owner}/${repo}@${ref}` +
59
+ (response.status === 404 ? " — check the repo name and ref, or that it is public" : ""),
60
+ );
61
+ }
62
+ return (await response.text()).trim();
63
+ }
64
+
65
+ async function download({ owner, repo, sha }, destination) {
66
+ const response = await fetch(
67
+ `https://codeload.github.com/${owner}/${repo}/tar.gz/${sha}`,
68
+ );
69
+ if (!response.ok) {
70
+ throw new Error(`could not download ${owner}/${repo}@${sha}: ${response.status}`);
71
+ }
72
+
73
+ const archive = join(tmpdir(), `waniwani-template-${sha}.tar.gz`);
74
+ writeFileSync(archive, Buffer.from(await response.arrayBuffer()));
75
+
76
+ // Extract into a staging directory first, so an interrupted run cannot
77
+ // leave a half-populated cache entry that later builds would trust.
78
+ const staging = `${destination}.partial`;
79
+ rmSync(staging, { recursive: true, force: true });
80
+ mkdirSync(staging, { recursive: true });
81
+
82
+ const result = spawnSync("tar", ["-xzf", archive, "-C", staging, "--strip-components=1"]);
83
+ rmSync(archive, { force: true });
84
+ if (result.status !== 0) {
85
+ rmSync(staging, { recursive: true, force: true });
86
+ throw new Error(`could not extract the template archive: ${result.stderr?.toString().trim()}`);
87
+ }
88
+
89
+ rmSync(destination, { recursive: true, force: true });
90
+ spawnSync("mv", [staging, destination]);
91
+ }
92
+
93
+ /** The newest cache entry for a repo, used when the network is unavailable. */
94
+ function newestCached(owner, repo) {
95
+ if (!existsSync(CACHE_ROOT)) return null;
96
+ const prefix = `${owner}-${repo}-`;
97
+ const entries = readdirSync(CACHE_ROOT)
98
+ .filter((name) => name.startsWith(prefix))
99
+ .map((name) => ({ name, mtime: statSync(join(CACHE_ROOT, name)).mtimeMs }))
100
+ .sort((a, b) => b.mtime - a.mtime);
101
+ return entries[0] ? join(CACHE_ROOT, entries[0].name) : null;
102
+ }
103
+
104
+ /**
105
+ * @param source `github:owner/repo#ref` or a local path
106
+ * @returns `{ dir, source, ref, sha, cached, local }`
107
+ */
108
+ export async function resolveTemplate(source = DEFAULT_TEMPLATE) {
109
+ const github = parseGithub(source);
110
+
111
+ if (!github) {
112
+ const dir = resolve(source);
113
+ if (!existsSync(dir)) {
114
+ throw new Error(`template not found: ${dir}`);
115
+ }
116
+ return { dir, source, local: true };
117
+ }
118
+
119
+ const { owner, repo, ref } = github;
120
+ mkdirSync(CACHE_ROOT, { recursive: true });
121
+
122
+ let sha;
123
+ try {
124
+ sha = await resolveSha(github);
125
+ } catch (error) {
126
+ const fallback = error.offline && newestCached(owner, repo);
127
+ if (!fallback) throw error;
128
+ return {
129
+ dir: fallback,
130
+ source,
131
+ ref,
132
+ sha: fallback.split("-").pop(),
133
+ cached: true,
134
+ offline: true,
135
+ };
136
+ }
137
+
138
+ const dir = join(CACHE_ROOT, `${owner}-${repo}-${sha}`);
139
+ const cached = existsSync(dir);
140
+ if (!cached) {
141
+ await download({ owner, repo, sha }, dir);
142
+ }
143
+
144
+ return { dir, source, ref, sha, cached };
145
+ }
146
+
147
+ /** One-line description of what a build used, for logs and provenance files. */
148
+ export function describeTemplate(template) {
149
+ if (template.local) return `${template.dir} (local)`;
150
+ const state = template.offline ? "offline, cached" : template.cached ? "cached" : "downloaded";
151
+ return `${template.source} @ ${template.sha?.slice(0, 7)} (${state})`;
152
+ }
package/cli/tunnel.mjs ADDED
@@ -0,0 +1,140 @@
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
+ }