aios-dashboard 0.2.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/LICENSE ADDED
@@ -0,0 +1,25 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 AIOS Dashboard CLI contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this installer command-line software and associated documentation files
7
+ (the "Software"), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish, distribute,
9
+ sublicense, and/or sell copies of the Software, and to permit persons to whom
10
+ the Software is furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
23
+ This license applies only to the files distributed in the aios-dashboard npm
24
+ CLI package. It does not license the AIOS Dashboard application downloaded by
25
+ the CLI.
package/README.md ADDED
@@ -0,0 +1,91 @@
1
+ # aios-dashboard
2
+
3
+ Public, dependency-free installer and local process controller for the AIOS
4
+ Dashboard. Requires Node.js 22.22.0 or newer.
5
+
6
+ This package contains no Dashboard application source. Active members receive a
7
+ short-lived application URL and SHA-256 digest from the AIOS MCP. Claude runs:
8
+
9
+ ```bash
10
+ npx --yes --loglevel=error --package aios-dashboard@0.2.0 aios-dashboard init \
11
+ --dir /path/to/aios \
12
+ --source '<signed-MCP-URL>' \
13
+ --source-sha256 '<64-hex-digest>' \
14
+ --yes \
15
+ --start
16
+ ```
17
+
18
+ The CLI verifies the archive before extraction, builds in staging, preserves
19
+ the installed `.env`, `data/`, and `learnings.md` on update, atomically swaps
20
+ the app, starts the production server on loopback, and opens
21
+ `http://127.0.0.1:8080`. It redacts signed URL queries and does not forward them
22
+ through cross-origin redirects. The MCP command also lowers npm's log level so
23
+ npx does not echo the bearer URL in its command notice. Downloads are limited
24
+ to 250 MiB and five minutes; ZIP entry count and expanded size are bounded
25
+ before extraction.
26
+
27
+ Installation uses exactly pnpm 10.30.3 with `--frozen-lockfile --prod=false`.
28
+ For `node-pty`, Linux needs Python 3, make, and a C/C++ compiler; Windows uses
29
+ its prebuild, and the installer repairs the macOS spawn-helper execute bit. A
30
+ configured member database URL is retained even when it is temporarily
31
+ unreachable.
32
+
33
+ Bare `npx aios-dashboard init` does not fetch a private GitHub release; it tells
34
+ the member to ask Claude for an entitled install plan. `--ref` exists only for
35
+ maintainer development.
36
+
37
+ Manage an installed local Dashboard with:
38
+
39
+ ```bash
40
+ npx --yes --package aios-dashboard@0.2.0 aios-dashboard start --dir /path/to/aios
41
+ npx --yes --package aios-dashboard@0.2.0 aios-dashboard status --dir /path/to/aios
42
+ npx --yes --package aios-dashboard@0.2.0 aios-dashboard open --dir /path/to/aios
43
+ npx --yes --package aios-dashboard@0.2.0 aios-dashboard stop --dir /path/to/aios
44
+ ```
45
+
46
+ `AUTH_DISABLED=true` is written only for a local, single-user install and must
47
+ never be exposed publicly. Without a member database, the app opens a setup
48
+ experience and reports live data as not connected rather than as zero.
49
+ Lifecycle ownership is proven with a per-launch loopback runtime token plus OS
50
+ process identity before a live PID is signalled.
51
+
52
+ ## Hosted runner
53
+
54
+ Connect a member's local Claude Code to their hosted Dashboard:
55
+
56
+ ```bash
57
+ npx --yes --package aios-dashboard@0.2.0 aios-dashboard connect --dir /path/to/aios
58
+ ```
59
+
60
+ The command starts the installed app as a local runner on `127.0.0.1:8099`,
61
+ opens a Cloudflare quick tunnel, and prints a pairing block. Paste the whole
62
+ block into **Settings → Deploy → Connect your computer** in the hosted
63
+ Dashboard. Leave the command running; Ctrl+C disconnects.
64
+
65
+ Options:
66
+
67
+ - `--port <port>` — local runner port (default `8099`).
68
+ - `--tunnel-url <origin>` — use a tunnel you already run.
69
+ - `--tunnel-name <name>` and `--tunnel-hostname <host>` — use a stable named
70
+ Cloudflare tunnel.
71
+ - `--no-download` — do not download cloudflared automatically.
72
+ - `--rotate` — mint a new pairing code and invalidate the old one.
73
+ - `--daemon` — install a systemd (Linux) or launchd (macOS) user service.
74
+ - `--print-pairing` — print the current pairing block without starting.
75
+ - `--dry-run` — show the plan without changing anything.
76
+
77
+ cloudflared is taken from PATH, this CLI's data directory, or
78
+ `node_modules/.bin`. If absent, the official platform binary is downloaded once
79
+ into the CLI data directory. If that is unavailable, install it yourself or
80
+ pass `--tunnel-url`.
81
+
82
+ The pairing token is a credential with access to Claude Code in the workspace.
83
+ Do not post it or store it in a shared channel. Runner credentials live outside
84
+ the replaceable app directory with mode `0600`; `connect` forces
85
+ `AUTH_DISABLED=false` and a separate auth secret before exposing any tunnel.
86
+
87
+ ## License boundary
88
+
89
+ The files in this npm package are MIT-licensed. The private Dashboard application
90
+ downloaded for an entitled member is a separate artifact and is not licensed by
91
+ this package's `LICENSE`.
@@ -0,0 +1,17 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { readFile } from "node:fs/promises";
4
+ import { fileURLToPath } from "node:url";
5
+ import path from "node:path";
6
+
7
+ import { parseArgs, runInstaller } from "../lib/installer.mjs";
8
+
9
+ const packagePath = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "package.json");
10
+ const packageJson = JSON.parse(await readFile(packagePath, "utf8"));
11
+
12
+ try {
13
+ await runInstaller(parseArgs(process.argv.slice(2)), packageJson.version);
14
+ } catch (error) {
15
+ console.error(`aios-dashboard: ${error instanceof Error ? error.message : String(error)}`);
16
+ process.exitCode = 1;
17
+ }
@@ -0,0 +1,559 @@
1
+ import { spawn } from "node:child_process";
2
+ import { randomBytes } from "node:crypto";
3
+ import { existsSync } from "node:fs";
4
+ /**
5
+ * `aios-dashboard connect` — make the hosted dashboard reach this computer.
6
+ *
7
+ * Before this command, connecting was: start the app in local mode with a
8
+ * hand-made 48-character secret, start a tunnel in a second terminal, copy the
9
+ * tunnel origin and the secret into the host's environment variables, and
10
+ * redeploy. Four steps, two terminals, one redeploy, and a secret that
11
+ * inevitably ends up in shell history.
12
+ *
13
+ * Now it is one foreground command that starts the runner, opens the tunnel,
14
+ * and prints a pairing block to paste into Settings → Deploy. The block carries
15
+ * the *pairing code* — the same secret in a spelling that survives a copy and
16
+ * cannot be typo'd — and the hosted copy stores it in the member's own
17
+ * database, so there is nothing to redeploy.
18
+ *
19
+ * ## Why the runner keeps its login wall
20
+ *
21
+ * A local install writes `AUTH_DISABLED=true`, which is fine for an app
22
+ * reachable only from the machine it runs on. A tunnel makes it reachable from
23
+ * everywhere. This command therefore starts the runner with auth *enabled* and
24
+ * its own persistent `BETTER_AUTH_SECRET`, so the runner's UI stays behind
25
+ * sign-in while the bearer token opens exactly the three bridge paths and
26
+ * nothing else.
27
+ */
28
+ import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
29
+ import path from "node:path";
30
+
31
+ import { generatePairingSecret, pairingCodeFromToken } from "./pairing.mjs";
32
+ import {
33
+ dataDir,
34
+ dashboardPath,
35
+ portableCommand,
36
+ workspacePath,
37
+ } from "./paths.mjs";
38
+ import { choosePackageManager, supportsNode } from "./prerequisites.mjs";
39
+ import { ensureCloudflared, originFromOutput, startTunnel } from "./tunnel.mjs";
40
+
41
+ export const DEFAULT_RUNNER_PORT = 8099;
42
+ const RUNNER_READY_TIMEOUT_MS = 300_000;
43
+ /** Cloudflare drops quick tunnels without warning; come back, do not give up. */
44
+ const TUNNEL_RESTART_DELAYS_MS = [1_000, 2_000, 5_000, 10_000, 30_000];
45
+
46
+ export function connectHelpText() {
47
+ return `Connect this computer to a hosted AIOS Dashboard
48
+
49
+ Usage:
50
+ aios-dashboard connect [--dir <workspace>] [--port <port>]
51
+
52
+ Options:
53
+ --dir <workspace> AIOS workspace holding dashboard/ (default: current directory)
54
+ --port <port> Local port for the runner (default: ${DEFAULT_RUNNER_PORT})
55
+ --tunnel-url <origin> Use a tunnel you already run instead of cloudflared
56
+ --tunnel-name <name> Run a named Cloudflare tunnel instead of a quick one
57
+ --tunnel-hostname <h> The stable hostname of that named tunnel
58
+ --no-download Never fetch cloudflared; fail with instructions instead
59
+ --rotate Mint a new pairing code, invalidating the old one
60
+ --daemon Install a user service so this survives a reboot
61
+ --print-pairing Print the current pairing block and exit
62
+ --dry-run Show what would run without starting anything
63
+ --help, -h Show this help
64
+ `;
65
+ }
66
+
67
+ /* ------------------------------------------------------------------ *
68
+ * The credential, kept out of the app directory on purpose
69
+ * ------------------------------------------------------------------ */
70
+
71
+ export function credentialPath(platform = process.platform, env = process.env) {
72
+ return path.join(dataDir(platform, env), "runner.json");
73
+ }
74
+
75
+ /**
76
+ * Read the stored runner credential, or make one.
77
+ *
78
+ * It lives in the data dir rather than `dashboard/.env` because
79
+ * `aios-dashboard init` replaces the app directory on update — a pairing that
80
+ * lived there would silently die on the next upgrade. Keeping it here also
81
+ * means a restart does *not* invalidate the pairing: only `--rotate` does, and
82
+ * that is the documented way to revoke a runner.
83
+ */
84
+ export async function loadRunnerCredential({
85
+ rotate = false,
86
+ platform = process.platform,
87
+ env = process.env,
88
+ now = () => new Date().toISOString(),
89
+ random = randomBytes,
90
+ } = {}) {
91
+ const file = credentialPath(platform, env);
92
+ let stored = null;
93
+ if (!rotate && existsSync(file)) {
94
+ try {
95
+ stored = JSON.parse(await readFile(file, "utf8"));
96
+ } catch {
97
+ stored = null;
98
+ }
99
+ }
100
+
101
+ const token =
102
+ typeof stored?.token === "string" && pairingCodeFromToken(stored.token)
103
+ ? stored.token
104
+ : generatePairingSecret(random).token;
105
+ const authSecret =
106
+ typeof stored?.authSecret === "string" && stored.authSecret.length >= 32
107
+ ? stored.authSecret
108
+ : random(32).toString("hex");
109
+
110
+ const record = {
111
+ token,
112
+ authSecret,
113
+ createdAt: stored?.createdAt ?? now(),
114
+ rotatedAt:
115
+ rotate || !stored ? now() : (stored.rotatedAt ?? stored.createdAt),
116
+ };
117
+
118
+ await mkdir(path.dirname(file), { recursive: true });
119
+ await writeFile(file, `${JSON.stringify(record, null, 2)}\n`, {
120
+ mode: 0o600,
121
+ });
122
+ await chmod(file, 0o600).catch(() => undefined);
123
+
124
+ return {
125
+ token: record.token,
126
+ code: pairingCodeFromToken(record.token),
127
+ authSecret: record.authSecret,
128
+ file,
129
+ rotated: rotate,
130
+ };
131
+ }
132
+
133
+ /* ------------------------------------------------------------------ *
134
+ * What the member copies
135
+ * ------------------------------------------------------------------ */
136
+
137
+ /**
138
+ * The block printed to the terminal.
139
+ *
140
+ * Two spellings of the same thing: an `aios://connect` link that the pairing
141
+ * dialog parses in one paste, and the two fields underneath for anyone whose
142
+ * terminal mangles long lines. The code is a credential, and the block says so
143
+ * — the one line of security copy a person will actually read is the one next
144
+ * to the secret.
145
+ */
146
+ export function pairingBlock({ origin, code, hostname, workspace }) {
147
+ const link = `aios://connect?origin=${encodeURIComponent(origin)}&code=${code.replace(/-/g, "")}`;
148
+ return [
149
+ "",
150
+ " ┌─ Connect your dashboard ───────────────────────────────────",
151
+ " │",
152
+ " │ In the hosted dashboard, open Settings → Deploy →",
153
+ ' │ "Connect your computer" and paste this line:',
154
+ " │",
155
+ ` │ ${link}`,
156
+ " │",
157
+ " │ Or paste the two fields:",
158
+ ` │ Address ${origin}`,
159
+ ` │ Pairing code ${code}`,
160
+ " │",
161
+ ` │ Runner ${hostname}${workspace ? ` · ${workspace}` : ""}`,
162
+ " │ The pairing code is a password. It lets the dashboard run",
163
+ " │ Claude Code in your workspace. Do not paste it anywhere else.",
164
+ " │",
165
+ " └────────────────────────────────────────────────────────────",
166
+ "",
167
+ ].join("\n");
168
+ }
169
+
170
+ /* ------------------------------------------------------------------ *
171
+ * The runner process
172
+ * ------------------------------------------------------------------ */
173
+
174
+ /**
175
+ * The environment the runner child gets.
176
+ *
177
+ * `AUTH_DISABLED: "false"` is the important line and is set explicitly rather
178
+ * than deleted, because the value in `dashboard/.env` would otherwise win. See
179
+ * the file header.
180
+ */
181
+ export function runnerEnv({ workspace, port, token, authSecret, base = {} }) {
182
+ return {
183
+ ...base,
184
+ AIOS_MODE: "local",
185
+ AIOS_ROOT: workspace,
186
+ AIOS_AGENT_RUNNER_TOKEN: token,
187
+ // A runner must never be a hosted copy's remote target as well.
188
+ AIOS_AGENT_REMOTE_URL: "",
189
+ AIOS_AGENT_REMOTE_TOKEN: "",
190
+ AUTH_DISABLED: "false",
191
+ BETTER_AUTH_SECRET: authSecret,
192
+ HOST: "127.0.0.1",
193
+ PORT: String(port),
194
+ };
195
+ }
196
+
197
+ /** `agent-native dev …`, spelled the way each package manager wants it. */
198
+ export function runnerArgs(packageManagerName, port) {
199
+ const dev = [
200
+ "agent-native",
201
+ "dev",
202
+ "--host",
203
+ "127.0.0.1",
204
+ "--port",
205
+ String(port),
206
+ ];
207
+ return packageManagerName === "pnpm"
208
+ ? ["exec", ...dev]
209
+ : ["exec", "--", ...dev];
210
+ }
211
+
212
+ function startRunner({ target, packageManager, env, port, onLine }) {
213
+ const args = runnerArgs(packageManager.name, port);
214
+ const invocation = portableCommand(packageManager.command, [
215
+ ...(packageManager.commandArgs || []),
216
+ ...args,
217
+ ]);
218
+ const child = spawn(invocation.command, invocation.args, {
219
+ cwd: target,
220
+ env,
221
+ stdio: ["ignore", "pipe", "pipe"],
222
+ shell: false,
223
+ });
224
+ const read = (chunk) => onLine(chunk.toString("utf8"));
225
+ child.stdout?.on("data", read);
226
+ child.stderr?.on("data", read);
227
+ return child;
228
+ }
229
+
230
+ /**
231
+ * Wait until the runner answers its own `hello` with the token.
232
+ *
233
+ * This is a better readiness check than "the port is open": it proves the app
234
+ * booted in local mode, that bearer auth is wired, and that the token in the
235
+ * data dir is the one the process is actually using. If this never passes,
236
+ * nothing downstream would have worked anyway.
237
+ */
238
+ export async function waitForRunner({
239
+ port,
240
+ token,
241
+ timeoutMs = RUNNER_READY_TIMEOUT_MS,
242
+ fetchImpl = fetch,
243
+ sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
244
+ alive = () => true,
245
+ }) {
246
+ const deadline = Date.now() + timeoutMs;
247
+ let lastStatus = 0;
248
+ while (Date.now() < deadline) {
249
+ if (!alive()) throw new Error("The runner exited before it was ready.");
250
+ try {
251
+ const response = await fetchImpl(
252
+ `http://127.0.0.1:${port}/api/claude-code/hello`,
253
+ {
254
+ headers: { Authorization: `Bearer ${token}` },
255
+ signal: AbortSignal.timeout(20_000),
256
+ },
257
+ );
258
+ lastStatus = response.status;
259
+ if (response.ok) return await response.json();
260
+ // 401/403 mean the app is up but disagrees about the credential — no
261
+ // amount of waiting fixes that, so say it now.
262
+ if (response.status === 401 || response.status === 403) {
263
+ throw new Error(
264
+ `The runner rejected its own token (HTTP ${response.status}). Try \`aios-dashboard connect --rotate\`.`,
265
+ );
266
+ }
267
+ } catch (error) {
268
+ if (
269
+ error instanceof Error &&
270
+ error.message.startsWith("The runner rejected")
271
+ ) {
272
+ throw error;
273
+ }
274
+ // Still compiling; the first boot of a dev server takes a while.
275
+ }
276
+ await sleep(500);
277
+ }
278
+ throw new Error(
279
+ `The runner did not become ready within ${Math.round(timeoutMs / 1000)}s${lastStatus ? ` (last status ${lastStatus})` : ""}.`,
280
+ );
281
+ }
282
+
283
+ /* ------------------------------------------------------------------ *
284
+ * The command
285
+ * ------------------------------------------------------------------ */
286
+
287
+ /**
288
+ * The same origin rule the hosted dashboard applies, checked here so a typo
289
+ * fails in the terminal rather than as a rejected paste two steps later.
290
+ * Mirrors `normalizeRunnerOrigin` in `shared/agent-runner-pairing.ts`.
291
+ */
292
+ export function usableTunnelOrigin(raw) {
293
+ let url;
294
+ try {
295
+ url = new URL(String(raw ?? "").trim());
296
+ } catch {
297
+ return false;
298
+ }
299
+ const localHttp = url.protocol === "http:" && url.hostname === "localhost";
300
+ if (url.protocol !== "https:" && !localHttp) return false;
301
+ return !(
302
+ url.username ||
303
+ url.password ||
304
+ (url.pathname !== "" && url.pathname !== "/") ||
305
+ url.search ||
306
+ url.hash
307
+ );
308
+ }
309
+
310
+ /** Recognise the line the runner logs when a paired dashboard reaches it. */
311
+ export const RUNNER_AUTHENTICATED_MARKER =
312
+ "[aios] runner request authenticated";
313
+
314
+ /** cloudflared chatter that is not a problem the member can act on. */
315
+ export const TUNNEL_NOISE_PATTERN = /receive buffer size|quic-go/i;
316
+
317
+ /** Runner output worth interrupting a quiet terminal for. */
318
+ export const RUNNER_PROBLEM_PATTERN =
319
+ /\b(?:EADDRINUSE|ELIFECYCLE|ERR_[A-Z_]+|Cannot find|error:|Error:|failed)\b/;
320
+
321
+ export async function runConnect(
322
+ options,
323
+ { log = console.log, error = console.error } = {},
324
+ ) {
325
+ if (options.help) {
326
+ log(connectHelpText());
327
+ return;
328
+ }
329
+ if (!supportsNode()) throw new Error("Node.js 22 or newer is required.");
330
+
331
+ const workspace = workspacePath(options.dir);
332
+ const target = dashboardPath(workspace);
333
+ if (!existsSync(path.join(target, "package.json"))) {
334
+ throw new Error(
335
+ `No dashboard found at ${target}. Run \`aios-dashboard init\` in the workspace first.`,
336
+ );
337
+ }
338
+
339
+ const port = Number(options.port || DEFAULT_RUNNER_PORT);
340
+ if (!Number.isInteger(port) || port < 1 || port > 65_535) {
341
+ throw new Error(
342
+ `--port must be a number between 1 and 65535 (got ${options.port}).`,
343
+ );
344
+ }
345
+
346
+ if (options.tunnelUrl && !usableTunnelOrigin(options.tunnelUrl)) {
347
+ throw new Error(
348
+ `--tunnel-url must be an HTTPS origin with no path (got ${options.tunnelUrl}). Plain HTTP is accepted only for http://localhost:<port>.`,
349
+ );
350
+ }
351
+
352
+ const credential = await loadRunnerCredential({
353
+ rotate: options.rotate === true,
354
+ });
355
+ if (options.rotate) {
356
+ log(
357
+ "Minted a new pairing code. Any dashboard paired with the old one is now disconnected.",
358
+ );
359
+ }
360
+
361
+ if (options.printPairing) {
362
+ log(
363
+ pairingBlock({
364
+ origin: options.tunnelUrl || "https://<start connect to get one>",
365
+ code: credential.code,
366
+ hostname: (await import("node:os")).hostname(),
367
+ workspace: path.basename(workspace),
368
+ }),
369
+ );
370
+ return;
371
+ }
372
+
373
+ const packageManager = choosePackageManager();
374
+
375
+ if (options.dryRun) {
376
+ log("\naios-dashboard connect plan");
377
+ log(` Workspace: ${workspace}`);
378
+ log(` Runner: ${target} on http://127.0.0.1:${port}`);
379
+ log(` Auth: login wall enabled, secret from ${credential.file}`);
380
+ log(` Credential: ${credential.file} (mode 0600)`);
381
+ log(
382
+ ` Tunnel: ${
383
+ options.tunnelUrl
384
+ ? `supplied (${options.tunnelUrl})`
385
+ : options.tunnelName
386
+ ? `named cloudflared tunnel "${options.tunnelName}"`
387
+ : "cloudflared quick tunnel"
388
+ }`,
389
+ );
390
+ log("\nDry run complete; nothing was started.");
391
+ return;
392
+ }
393
+
394
+ if (options.daemon) {
395
+ const { installConnectService } = await import("./service.mjs");
396
+ await installConnectService({ workspace, port, options, log });
397
+ return;
398
+ }
399
+
400
+ /* ---- runner ---- */
401
+ // The readiness check below is itself an authenticated request, so the
402
+ // "Connected" line only means something once a pairing block exists to have
403
+ // been pasted. Announcing before that would greet the CLI's own probe.
404
+ let watchingForDashboard = false;
405
+ let reachedAtLeastOnce = false;
406
+ const runner = startRunner({
407
+ target,
408
+ packageManager,
409
+ port,
410
+ env: runnerEnv({
411
+ workspace,
412
+ port,
413
+ token: credential.token,
414
+ authSecret: credential.authSecret,
415
+ base: process.env,
416
+ }),
417
+ onLine: (text) => {
418
+ // The runner's own output is otherwise swallowed by this process, which
419
+ // would leave a member staring at a silent terminal while the app failed
420
+ // to boot. Anything that looks like a problem is surfaced verbatim.
421
+ if (RUNNER_PROBLEM_PATTERN.test(text)) error(text.trimEnd());
422
+ if (!watchingForDashboard || reachedAtLeastOnce) return;
423
+ if (!text.includes(RUNNER_AUTHENTICATED_MARKER)) return;
424
+ reachedAtLeastOnce = true;
425
+ log("Connected · hosted dashboard reached the runner");
426
+ },
427
+ });
428
+
429
+ let stopping = false;
430
+ let tunnel = null;
431
+ const stop = () => {
432
+ if (stopping) return;
433
+ stopping = true;
434
+ tunnel?.kill("SIGTERM");
435
+ runner.kill("SIGTERM");
436
+ };
437
+ process.once("SIGINT", () => {
438
+ log("\nStopping. The hosted dashboard will show its not-connected state.");
439
+ stop();
440
+ process.exitCode = 0;
441
+ });
442
+ process.once("SIGTERM", stop);
443
+ runner.once("exit", (code) => {
444
+ if (stopping) return;
445
+ error(`The runner exited with code ${code}.`);
446
+ stop();
447
+ process.exitCode = typeof code === "number" ? code : 1;
448
+ });
449
+
450
+ log(`Starting the runner on http://127.0.0.1:${port} …`);
451
+ const identity = await waitForRunner({
452
+ port,
453
+ token: credential.token,
454
+ alive: () => runner.exitCode === null,
455
+ }).catch((cause) => {
456
+ stop();
457
+ throw cause;
458
+ });
459
+ log(
460
+ `Runner ready · ${identity.hostname}${identity.workspace ? ` · ${identity.workspace}` : ""}${
461
+ identity.claude ? ` · claude ${identity.claude}` : " · claude not found"
462
+ }`,
463
+ );
464
+ if (!identity.claude) {
465
+ log(
466
+ "! The `claude` CLI was not found. Chat and commands will reach this computer and then fail honestly.",
467
+ );
468
+ }
469
+
470
+ /* ---- tunnel ---- */
471
+ let origin = options.tunnelUrl ?? null;
472
+ const openTunnel = async () => {
473
+ const binary = await ensureCloudflared({
474
+ allowDownload: options.noDownload !== true,
475
+ log,
476
+ });
477
+ const started = await startTunnel({
478
+ binary: binary.path,
479
+ port,
480
+ name: options.tunnelName ?? null,
481
+ hostname: options.tunnelHostname ?? null,
482
+ onLine: (text) => {
483
+ // Surface only what a person can act on; the rest is cloudflared's
484
+ // own connection bookkeeping. The UDP receive-buffer notice is loud,
485
+ // harmless, and not something a member can fix, so it stays quiet.
486
+ if (TUNNEL_NOISE_PATTERN.test(text)) return;
487
+ if (/ERR |error=|failed to/i.test(text)) error(text.trim());
488
+ },
489
+ });
490
+ tunnel = started.child;
491
+ return started.origin;
492
+ };
493
+
494
+ if (!origin) {
495
+ origin = await openTunnel().catch((cause) => {
496
+ stop();
497
+ throw cause;
498
+ });
499
+ } else {
500
+ log(`Using the tunnel you supplied: ${origin}`);
501
+ }
502
+
503
+ const printBlock = (currentOrigin, note) => {
504
+ if (note) log(note);
505
+ log(
506
+ pairingBlock({
507
+ origin: currentOrigin,
508
+ code: credential.code,
509
+ hostname: identity.hostname,
510
+ workspace: identity.workspace,
511
+ }),
512
+ );
513
+ };
514
+ printBlock(origin);
515
+ log("Leave this running. Press Ctrl+C to disconnect.");
516
+ watchingForDashboard = true;
517
+
518
+ /* ---- keep the tunnel alive ---- */
519
+ if (tunnel) {
520
+ let attempt = 0;
521
+ const watch = (child) => {
522
+ child.once("exit", async () => {
523
+ if (stopping) return;
524
+ const delay =
525
+ TUNNEL_RESTART_DELAYS_MS[
526
+ Math.min(attempt, TUNNEL_RESTART_DELAYS_MS.length - 1)
527
+ ];
528
+ attempt += 1;
529
+ error(
530
+ `The tunnel dropped. Reopening in ${Math.round(delay / 1000)}s …`,
531
+ );
532
+ await new Promise((resolve) => setTimeout(resolve, delay));
533
+ if (stopping) return;
534
+ try {
535
+ const next = await openTunnel();
536
+ attempt = 0;
537
+ if (next === origin) {
538
+ log("Tunnel reopened on the same address; nothing to re-paste.");
539
+ } else {
540
+ origin = next;
541
+ printBlock(
542
+ origin,
543
+ "The tunnel address changed. Paste this block again in Settings → Deploy:",
544
+ );
545
+ }
546
+ watch(tunnel);
547
+ } catch (cause) {
548
+ error(cause instanceof Error ? cause.message : String(cause));
549
+ watch(child);
550
+ }
551
+ });
552
+ };
553
+ watch(tunnel);
554
+ }
555
+
556
+ await new Promise((resolve) => runner.once("exit", resolve));
557
+ }
558
+
559
+ export { originFromOutput };