@rubytech/create-realagent 1.0.619 → 1.0.620

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.
@@ -3,21 +3,21 @@ initStderrTee("cloudflare");
3
3
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
4
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
5
  import { z } from "zod";
6
- import { join } from "node:path";
7
- import { hostname, homedir } from "node:os";
8
- import { existsSync } from "node:fs";
6
+ import { Resolver } from "node:dns/promises";
9
7
  import * as cloudflared from "./lib/cloudflared.js";
10
8
  const server = new McpServer({
11
9
  name: "cloudflare",
12
- version: "0.2.0",
10
+ version: "0.3.0",
13
11
  });
14
12
  // ===================================================================
15
- // Authentication — cert.pem only
13
+ // Authentication — OAuth cert.pem only
16
14
  //
17
- // The plugin recognises exactly one CF account identity: the one
18
- // cert.pem was bound to via OAuth. `tunnel-login` is the only auth
19
- // path; the binding written on success is the device's record of
20
- // which account it currently speaks to.
15
+ // The plugin recognises exactly one identity: the Cloudflare account
16
+ // cert.pem was bound to via `cloudflared tunnel login`. No API token
17
+ // handling, no SDK, no read of account state by any code path. The
18
+ // operator's logged-in Cloudflare dashboard session is the source of
19
+ // truth for which domains exist and who owns them; the agent's role
20
+ // is to instruct the operator where to click.
21
21
  // ===================================================================
22
22
  server.tool("tunnel-login", "Authenticate with Cloudflare via `cloudflared tunnel login`. Three phases: (1) if cert.pem exists and matches the recorded account binding, reports status; (2) if cert.pem exists but no binding has been recorded, materializes the binding from the cert (migration path); (3) if no cert.pem, spawns login process and returns the auth URL. Detects existing login processes — safe to call repeatedly without spawning duplicates. Pass force=true to delete cert.pem (both brand-specific and legacy paths) and account-binding.json before re-authenticating — required when switching Cloudflare accounts.", {
23
23
  force: z
@@ -26,26 +26,22 @@ server.tool("tunnel-login", "Authenticate with Cloudflare via `cloudflared tunne
26
26
  .describe("Delete cert.pem (brand path + legacy ~/.cloudflared/cert.pem) and account-binding.json before re-authenticating. Required when switching Cloudflare accounts (e.g. after the prior account was scrubbed or the cert was rotated under a different account)."),
27
27
  }, async ({ force }) => {
28
28
  try {
29
- // Force: delete cert.pem (both paths) and binding before restarting
30
29
  if (force) {
31
30
  console.error("[tunnel-login] force=true — clearing cert.pem and account binding before re-auth");
32
31
  cloudflared.resetAuth();
33
32
  }
34
33
  const auth = cloudflared.validateAuth();
35
- // Phase: Already bound — cert + binding present and accountIds agree
36
34
  if (auth.bound) {
37
35
  console.error(`[tunnel-login] short-circuit: cert and binding agree on account ${auth.boundAccountId}`);
38
36
  return {
39
37
  content: [
40
38
  {
41
39
  type: "text",
42
- text: `Already authenticated. cert.pem and account binding agree on account ${auth.boundAccountId}.\n\nTo switch Cloudflare accounts, call tunnel-login with force=true. This deletes cert.pem (both paths) and the account binding, then restarts the authentication flow.`,
40
+ text: `Already signed into Cloudflare. If this is not the right account, ask me to run tunnel-login again with force=true that will sign this laptop out so you can sign in with the correct account.`,
43
41
  },
44
42
  ],
45
43
  };
46
44
  }
47
- // Phase: cert.pem exists — derive credentials and either materialize
48
- // or reconcile the binding.
49
45
  if (auth.hasCert) {
50
46
  const creds = cloudflared.parseCertPem();
51
47
  if (!creds) {
@@ -53,53 +49,46 @@ server.tool("tunnel-login", "Authenticate with Cloudflare via `cloudflared tunne
53
49
  content: [
54
50
  {
55
51
  type: "text",
56
- text: `cert.pem exists but could not extract API credentials from it. The file format may have changed.\n\nRecovery: run tunnel-login with force=true to clear cert.pem and re-authenticate.`,
52
+ text: `Sign-in file exists but its contents are unreadable. Ask me to run tunnel-login with force=true so the file is cleared and a fresh sign-in can start.`,
57
53
  },
58
54
  ],
59
55
  isError: true,
60
56
  };
61
57
  }
62
- // Migration path: cert exists, no binding yet — silently materialize
63
- // (the operator already trusted this cert by completing the prior
64
- // OAuth flow; we don't re-confirm).
65
58
  if (!auth.hasBinding) {
66
59
  const binding = cloudflared.materializeBinding("migration");
67
60
  return {
68
61
  content: [
69
62
  {
70
63
  type: "text",
71
- text: `Account binding materialized from existing cert.pem.\n\n Account: ${binding.accountId}\n Bound at: ${binding.boundAt}\n\nReady for tunnel operations.`,
64
+ text: `Sign-in recorded from the existing Cloudflare session on this laptop.\n\n Bound at: ${binding.boundAt}\n\nReady to create a tunnel.`,
72
65
  },
73
66
  ],
74
67
  };
75
68
  }
76
- // Drift: cert says account X, binding says account Y — refuse with
77
- // recovery instruction (force=true will clear both).
78
69
  if (auth.certAccountId !== auth.boundAccountId) {
79
70
  return {
80
71
  content: [
81
72
  {
82
73
  type: "text",
83
- text: `cert.pem is bound to account ${auth.certAccountId} but the recorded binding is account ${auth.boundAccountId}. The cert was rotated under a different Cloudflare account since the binding was established.\n\n${cloudflared.recoveryMessage()}`,
74
+ text: `The Cloudflare sign-in on this laptop has changed since it was first set up. ${cloudflared.recoveryMessage()}`,
84
75
  },
85
76
  ],
86
77
  isError: true,
87
78
  };
88
79
  }
89
80
  }
90
- // Phase: Login process already running — wait for authorization
91
81
  const activeLogin = cloudflared.getActiveLogin();
92
82
  if (activeLogin) {
93
83
  return {
94
84
  content: [
95
85
  {
96
86
  type: "text",
97
- text: `Authorization in progress — a login process is already running (PID ${activeLogin.pid}, started ${new Date(activeLogin.startedAt).toISOString()}).\n\nAuth URL: ${activeLogin.authUrl}\n\nAsk the user to complete authorization in the browser. Call tunnel-login again after the user has authorized — it will detect cert.pem and derive API credentials automatically.`,
87
+ text: `Sign-in in progress — a browser window is already open (started ${new Date(activeLogin.startedAt).toISOString()}).\n\nSign-in URL: ${activeLogin.authUrl}\n\nAsk the user to finish the sign-in in their browser and click Authorize. Call tunnel-login again once the user confirms — it will detect the completed sign-in automatically.`,
98
88
  },
99
89
  ],
100
90
  };
101
91
  }
102
- // Phase: No cert.pem, no active login — spawn cloudflared tunnel login
103
92
  if (!cloudflared.isInstalled()) {
104
93
  return {
105
94
  content: [
@@ -113,13 +102,13 @@ server.tool("tunnel-login", "Authenticate with Cloudflare via `cloudflared tunne
113
102
  }
114
103
  const result = await cloudflared.tunnelLogin();
115
104
  const alreadyNote = result.alreadyRunning
116
- ? " (reusing existing login process)"
105
+ ? " (reusing existing sign-in window)"
117
106
  : "";
118
107
  return {
119
108
  content: [
120
109
  {
121
110
  type: "text",
122
- text: `Cloudflare login started${alreadyNote}. Open this URL to authorize:\n\n${result.authUrl}\n\nThe user must open this URL and click Authorize. After authorization, call tunnel-login again — it will detect the authorization and derive API credentials automatically.`,
111
+ text: `Cloudflare sign-in started${alreadyNote}. Open this URL to authorize:\n\n${result.authUrl}\n\nAsk the user to open the URL, pick the Cloudflare account they want this laptop signed into, and click Authorize. Call tunnel-login again once they confirm — it will detect the completed sign-in automatically.`,
123
112
  },
124
113
  ],
125
114
  };
@@ -129,134 +118,46 @@ server.tool("tunnel-login", "Authenticate with Cloudflare via `cloudflared tunne
129
118
  content: [
130
119
  {
131
120
  type: "text",
132
- text: `Login failed: ${err instanceof Error ? err.message : String(err)}`,
121
+ text: `Sign-in failed: ${err instanceof Error ? err.message : String(err)}`,
133
122
  },
134
123
  ],
135
124
  isError: true,
136
125
  };
137
126
  }
138
127
  });
139
- server.tool("cf-zone-status", "List all zones (domains) on the Cloudflare account with their activation status and assigned nameservers.", {}, async () => {
140
- try {
141
- const zones = await cloudflared.listZones();
142
- if (zones.length === 0) {
143
- return {
144
- content: [
145
- {
146
- type: "text",
147
- text: "No zones found on this account. Add a domain to Cloudflare first.",
148
- },
149
- ],
150
- };
151
- }
152
- const lines = zones.map((z) => ` ${z.name} — ${z.status}${z.nameservers.length > 0 ? `\n Nameservers: ${z.nameservers.join(", ")}` : ""}`);
153
- return {
154
- content: [
155
- {
156
- type: "text",
157
- text: `Zones on this account:\n\n${lines.join("\n\n")}`,
158
- },
159
- ],
160
- };
161
- }
162
- catch (err) {
163
- return {
164
- content: [
165
- {
166
- type: "text",
167
- text: `Failed: ${err instanceof Error ? err.message : String(err)}`,
168
- },
169
- ],
170
- isError: true,
171
- };
172
- }
173
- });
174
- server.tool("cf-add-zone", "Add a domain to the bound Cloudflare account via the official API. Idempotent: returns existing zone info if already on the account. Requires a successful tunnel-login (cert.pem + account-binding.json present and matching). The bound account must have permission to create zones; if not, the operator must add the zone via the Cloudflare dashboard.", {
175
- domain: z
176
- .string()
177
- .describe("Registrable domain to add to the bound Cloudflare account (e.g. 'mybusiness.com')."),
178
- }, async ({ domain }) => {
179
- try {
180
- const start = Date.now();
181
- const result = await cloudflared.createZone(domain);
182
- const elapsed = Date.now() - start;
183
- const action = result.existing ? "already on this account" : "added";
184
- const nsLines = result.nameservers.length > 0
185
- ? `\n Nameservers: ${result.nameservers.join(", ")}`
186
- : "";
187
- console.error(`[cf-add-zone] ${domain} ${action} (${result.status}) in ${elapsed}ms`);
188
- return {
189
- content: [
190
- {
191
- type: "text",
192
- text: `Domain ${action}.\n\n Domain: ${result.name}\n Status: ${result.status}${nsLines}\n\n${result.status === "pending"
193
- ? "The domain is pending activation — update the nameservers at your registrar to the Cloudflare-assigned nameservers shown above. Once nameservers propagate (minutes to 24 hours), the status changes to Active."
194
- : result.status === "active"
195
- ? "The domain is active on Cloudflare."
196
- : `Current status: ${result.status}.`}`,
197
- },
198
- ],
199
- };
200
- }
201
- catch (err) {
202
- // Refusal-shaped errors thrown by getClient() carry structured detail —
203
- // surface them as agent-facing refusals with the same single recovery path.
204
- if (err instanceof cloudflared.CloudflareRefusalError) {
205
- return {
206
- content: [{ type: "text", text: err.refusal.message }],
207
- isError: true,
208
- };
209
- }
210
- const msg = err instanceof Error ? err.message : String(err);
211
- console.error(`[cf-add-zone] failed for ${domain}: ${msg}`);
212
- return {
213
- content: [
214
- {
215
- type: "text",
216
- text: `Zone creation failed: ${msg}\n\n` +
217
- `If this looks like a permissions error, the cert.pem account may not own ` +
218
- `this brand's zones. ${cloudflared.recoveryMessage()}`,
219
- },
220
- ],
221
- isError: true,
222
- };
223
- }
224
- });
225
- // ===================================================================
226
- // Cloudflare Tunnel tools
227
- // ===================================================================
228
- server.tool("tunnel-status", "Full Cloudflare Tunnel status: installed, version, token valid, running, domain, hostnames.", {
128
+ server.tool("tunnel-status", "End-to-end Cloudflare Tunnel status. Reads the tunnel process state, parses configured hostnames from config.yml, and probes each hostname (DNS + HTTPS via Cloudflare's edge) to confirm traffic actually reaches this laptop from the internet. `healthy: true` requires every configured hostname to probe `ok`. `boundAccountOwnsHostnames: false` means the laptop is running a tunnel that nothing on the internet can reach — almost always because it is signed into a Cloudflare account that does not own the domain.", {
229
129
  domain: z
230
130
  .string()
231
131
  .optional()
232
- .describe("The bare domain (e.g. 'maxy.bot'). If omitted, domain info is excluded."),
132
+ .describe("The bare domain (e.g. 'maxy.bot'). If omitted, derived from persisted state."),
233
133
  }, async ({ domain }) => {
234
134
  try {
235
135
  const status = await cloudflared.getStatus(domain);
236
- // Check if the actual hostnames resolve (from state, not hardcoded)
237
- let dnsWarning = "";
238
- if (status.hostnames.length > 0) {
239
- const dns = await import("node:dns/promises");
240
- const resolver = new dns.Resolver();
241
- resolver.setServers(["8.8.8.8", "1.1.1.1"]);
242
- const checks = [];
243
- for (const h of status.hostnames) {
244
- try {
245
- await resolver.resolve4(h);
246
- }
247
- catch {
248
- checks.push(h);
249
- }
250
- }
251
- if (checks.length > 0) {
252
- dnsWarning = `\n\nDNS NOT RESOLVING: ${checks.join(", ")} do not resolve (checked via Google DNS 8.8.8.8). Possible causes: (1) CNAME records not created — run tunnel-create to create them; (2) nameservers not pointing to Cloudflare — check cf-zone-status; (3) DNS propagation in progress — wait a few minutes and re-check.`;
253
- }
136
+ let guidance = "";
137
+ if (status.unhealthyReason === "bound-account-does-not-own-hostname") {
138
+ guidance =
139
+ `\n\nThe tunnel is running on this laptop but nothing from the internet is reaching it. ` +
140
+ `The Cloudflare account this laptop is signed into does not own the domain you are trying to use. ` +
141
+ `${cloudflared.DASHBOARD_SWITCH_ACCOUNTS_INSTRUCTION}`;
142
+ }
143
+ else if (status.unhealthyReason === "hostname-probes-failed") {
144
+ const failures = status.probes
145
+ .filter((p) => p.failureMode !== "ok")
146
+ .map((p) => ` ${p.hostname}: ${p.failureMode}`)
147
+ .join("\n");
148
+ guidance = `\n\nOne or more addresses are not reachable through Cloudflare:\n${failures}\n\nIf the tunnel was just created, wait 1-5 minutes for DNS to propagate. Otherwise ask the user to open Cloudflare in their browser and check that the relevant records exist.`;
149
+ }
150
+ else if (status.unhealthyReason === "not-running") {
151
+ guidance = `\n\nThe tunnel process is not running. Ask the user if they want me to enable it.`;
152
+ }
153
+ else if (status.unhealthyReason === "no-tunnel-configured") {
154
+ guidance = `\n\nNo tunnel is configured on this laptop yet. Run tunnel-create to set one up.`;
254
155
  }
255
156
  return {
256
157
  content: [
257
158
  {
258
159
  type: "text",
259
- text: JSON.stringify(status, null, 2) + dnsWarning,
160
+ text: JSON.stringify(status, null, 2) + guidance,
260
161
  },
261
162
  ],
262
163
  };
@@ -317,7 +218,7 @@ server.tool("tunnel-install", "Install the cloudflared binary if not already pre
317
218
  };
318
219
  }
319
220
  });
320
- server.tool("tunnel-create", "Create a Cloudflare Tunnel and route DNS for the specified subdomains. The admin subdomain is required (e.g. 'admin' → admin.{domain}, 'joel' → joel.{domain}). The public subdomain is optional — omit to skip the public endpoint. Collision guard: refuses to create DNS records that would overwrite another tunnel's hostnames. Uses cloudflared CLI with cert.pem requires tunnel-login first. Idempotent — reuses existing tunnel if name matches.", {
221
+ server.tool("tunnel-create", "Create a Cloudflare Tunnel and route DNS for the specified subdomains. The admin subdomain is required (e.g. 'admin' → admin.{domain}, 'joel' → joel.{domain}). The public subdomain is optional — omit to skip the public endpoint. Shells out to `cloudflared` which uses the signed-in cert.pem; refuses when the configured hostname's zone parent is not on Cloudflare. Idempotent — reuses existing tunnel if name matches.", {
321
222
  domain: z
322
223
  .string()
323
224
  .describe("The bare domain (e.g. 'maxy.bot')."),
@@ -335,7 +236,6 @@ server.tool("tunnel-create", "Create a Cloudflare Tunnel and route DNS for the s
335
236
  .optional()
336
237
  .describe("Human-readable tunnel name (default: derived from domain)"),
337
238
  }, async ({ domain, adminSubdomain, publicSubdomain, tunnelName }) => {
338
- // Validate: admin and public subdomains must differ
339
239
  if (publicSubdomain && adminSubdomain === publicSubdomain) {
340
240
  return {
341
241
  content: [
@@ -348,35 +248,27 @@ server.tool("tunnel-create", "Create a Cloudflare Tunnel and route DNS for the s
348
248
  };
349
249
  }
350
250
  try {
251
+ const auth = cloudflared.validateAuth();
252
+ if (!auth.bound) {
253
+ return {
254
+ content: [
255
+ {
256
+ type: "text",
257
+ text: `REFUSED: this laptop is not signed into Cloudflare yet. ${cloudflared.recoveryMessage()}`,
258
+ },
259
+ ],
260
+ isError: true,
261
+ };
262
+ }
351
263
  const name = tunnelName ?? domain.replace(/\./g, "-");
352
264
  const platformPort = parseInt(process.env.PLATFORM_PORT ?? "19200", 10);
353
- // Build hostname list from subdomains
354
265
  const adminHostname = `${adminSubdomain}.${domain}`;
355
266
  const publicHostname = publicSubdomain ? `${publicSubdomain}.${domain}` : null;
356
267
  const hostnames = publicHostname ? [adminHostname, publicHostname] : [adminHostname];
357
268
  console.error(`[tunnel-create] hostnames=${JSON.stringify(hostnames)} domain=${domain}`);
358
- // Live account-zone check happens inside routeDnsCli (Step 4 below).
359
- // getClient() inside routeDnsCli enforces auth pre-conditions
360
- // (cert + binding + accountId match).
361
269
  // Step 1: Create tunnel via CLI (idempotent — reuses if name exists)
362
270
  const tunnel = cloudflared.createTunnelCli(name);
363
- // Step 2: Collision guard check each hostname before creating DNS records
364
- for (const h of hostnames) {
365
- const check = await cloudflared.checkDnsCollision(h, tunnel.tunnelId);
366
- if (check.collision) {
367
- return {
368
- content: [
369
- {
370
- type: "text",
371
- text: `DNS collision: ${h} already points to a different tunnel (${check.existingTunnelId}). ` +
372
- `This hostname belongs to another device. Choose a different subdomain or remove the existing DNS record first.`,
373
- },
374
- ],
375
- isError: true,
376
- };
377
- }
378
- }
379
- // Step 3: Write local config.yml with ingress rules
271
+ // Step 2: Write local config.yml with ingress rules
380
272
  const configPath = cloudflared.writeLocalConfig(tunnel.tunnelId, tunnel.credentialsPath, hostnames, platformPort);
381
273
  // Persist tunnel identity with actual hostnames
382
274
  cloudflared.saveTunnelIdentity({
@@ -388,21 +280,19 @@ server.tool("tunnel-create", "Create a Cloudflare Tunnel and route DNS for the s
388
280
  adminHostname,
389
281
  publicHostname,
390
282
  });
391
- // Step 4: Route DNS via CLI for each hostname
283
+ // Step 3: Route DNS via CLI for each hostname (pre-flight + post-flight in routeDnsCli)
392
284
  const dnsResults = [];
393
285
  for (const h of hostnames) {
394
286
  const route = await cloudflared.routeDnsCli(tunnel.tunnelId, h);
395
287
  dnsResults.push({
396
288
  hostname: h,
397
289
  fqdn: route.fqdn,
398
- zone: route.zone,
399
290
  note: route.created ? "created" : "already existed",
400
291
  });
401
292
  }
402
- // Step 5: Verify hostnames resolve via Google DNS
403
- const dns = await import("node:dns/promises");
404
- const resolver = new dns.Resolver();
405
- resolver.setServers(["8.8.8.8", "1.1.1.1"]);
293
+ // Step 4: Verify hostnames resolve via public DNS
294
+ const resolver = new Resolver();
295
+ resolver.setServers(["1.1.1.1", "8.8.8.8"]);
406
296
  const dnsWarnings = [];
407
297
  for (const h of hostnames) {
408
298
  try {
@@ -414,16 +304,16 @@ server.tool("tunnel-create", "Create a Cloudflare Tunnel and route DNS for the s
414
304
  }
415
305
  let dnsNote = "";
416
306
  if (dnsWarnings.length > 0) {
417
- dnsNote = `\n\nDNS WARNING: ${dnsWarnings.join(", ")} did not resolve via Google DNS (8.8.8.8). This may be propagation delay (wait a few minutes and re-check) or the domain may not be Active on Cloudflare — run cf-zone-status to check.`;
307
+ dnsNote = `\n\nDNS WARNING: ${dnsWarnings.join(", ")} did not resolve via public DNS yet. DNS propagation typically takes 1-5 minutes. Re-run tunnel-status in a few minutes to confirm.`;
418
308
  }
419
309
  const dnsLines = dnsResults
420
- .map((r) => ` DNS: ${r.hostname} → ${tunnel.tunnelId}.cfargotunnel.com under zone ${r.zone} (${r.note})${r.fqdn !== r.hostname ? ` — WARNING: CNAME created as ${r.fqdn}` : ""}`)
310
+ .map((r) => ` ${r.hostname} → this tunnel (${r.note})`)
421
311
  .join("\n");
422
312
  return {
423
313
  content: [
424
314
  {
425
315
  type: "text",
426
- text: `Tunnel created.\n\n ID: ${tunnel.tunnelId}\n Name: ${tunnel.tunnelName}\n${dnsLines}\n Config: ${configPath}\n\nUse tunnel-enable to start the tunnel.${dnsNote}`,
316
+ text: `Tunnel created.\n\n Name: ${tunnel.tunnelName}\n${dnsLines}\n Config: ${configPath}\n\nUse tunnel-enable to start the tunnel.${dnsNote}`,
427
317
  },
428
318
  ],
429
319
  };
@@ -451,7 +341,6 @@ server.tool("tunnel-enable", "Start the Cloudflare Tunnel process. The tunnel mu
451
341
  domain: z.string().optional().describe("The bare domain (e.g. 'maxy.bot'). If omitted, reads from persisted tunnel state."),
452
342
  }, async ({ tunnelId: tunnelIdParam, domain: domainParam }) => {
453
343
  try {
454
- // Resolve tunnelId and domain from params or persisted state
455
344
  const tunnelId = tunnelIdParam ?? cloudflared.getPersistedTunnelId();
456
345
  const domain = domainParam ?? cloudflared.getPersistedDomain();
457
346
  if (!tunnelId) {
@@ -459,7 +348,7 @@ server.tool("tunnel-enable", "Start the Cloudflare Tunnel process. The tunnel mu
459
348
  content: [
460
349
  {
461
350
  type: "text",
462
- text: "No tunnel ID provided and none found in persisted state. Run tunnel-create first to create a tunnel.",
351
+ text: "No tunnel found on this laptop. Run tunnel-create first.",
463
352
  },
464
353
  ],
465
354
  isError: true,
@@ -470,30 +359,28 @@ server.tool("tunnel-enable", "Start the Cloudflare Tunnel process. The tunnel mu
470
359
  content: [
471
360
  {
472
361
  type: "text",
473
- text: "No domain provided and none found in persisted state. Run tunnel-create first to create a tunnel with a domain.",
362
+ text: "No domain recorded for this tunnel. Run tunnel-create first to set one up with a domain.",
474
363
  },
475
364
  ],
476
365
  isError: true,
477
366
  };
478
367
  }
479
- // Read actual hostnames from persisted state (backward compat: derives from domain)
480
368
  const hostnames = cloudflared.getPersistedHostnames();
481
369
  if (hostnames.length === 0) {
482
370
  return {
483
371
  content: [
484
372
  {
485
373
  type: "text",
486
- text: "No hostnames found in persisted state. Run tunnel-create first.",
374
+ text: "No addresses recorded for this tunnel. Run tunnel-create first.",
487
375
  },
488
376
  ],
489
377
  isError: true,
490
378
  };
491
379
  }
492
380
  console.error(`[tunnel-enable] checking hostnames=${JSON.stringify(hostnames)}`);
493
- // GATE 1: Check if actual hostnames resolve (via Google DNS)
494
- const dns = await import("node:dns/promises");
495
- const resolver = new dns.Resolver();
496
- resolver.setServers(["8.8.8.8", "1.1.1.1"]);
381
+ // GATE 1: Check if actual hostnames resolve via public DNS
382
+ const resolver = new Resolver();
383
+ resolver.setServers(["1.1.1.1", "8.8.8.8"]);
497
384
  const unresolvedSubs = [];
498
385
  for (const h of hostnames) {
499
386
  try {
@@ -508,7 +395,7 @@ server.tool("tunnel-enable", "Start the Cloudflare Tunnel process. The tunnel mu
508
395
  content: [
509
396
  {
510
397
  type: "text",
511
- text: `REFUSED: ${unresolvedSubs.join(", ")} do not resolve (checked via Google DNS). The tunnel URLs will not work.\n\nThis usually means the CNAME records have not been created. Run tunnel-create first it creates the CNAME records in Cloudflare.\n\nIf tunnel-create has already been run, this may be DNS propagation (wait a few minutes) or the domain may not be Active on Cloudflare. Run cf-zone-status to check.\n\nDo not call tunnel-enable until the hostnames resolve.`,
398
+ text: `REFUSED: ${unresolvedSubs.join(", ")} do not resolve via public DNS. The tunnel's URLs will not work.\n\nUsually this means the DNS records were never created or are still propagating. Run tunnel-create first. If that has already run, wait 1-5 minutes for propagation or ask the user to confirm in the Cloudflare dashboard that the addresses exist on the correct domain.\n\nDo not call tunnel-enable until the addresses resolve.`,
512
399
  },
513
400
  ],
514
401
  isError: true,
@@ -517,7 +404,7 @@ server.tool("tunnel-enable", "Start the Cloudflare Tunnel process. The tunnel mu
517
404
  const brand = cloudflared.loadBrand();
518
405
  const platformPort = parseInt(process.env.PLATFORM_PORT ?? "19200", 10);
519
406
  console.error(`[tunnel-enable] using PLATFORM_PORT=${platformPort}`);
520
- // GATE 2: Remote auth must be configured (verified via live API, not file check)
407
+ // GATE 2: Remote auth must be configured
521
408
  try {
522
409
  const res = await fetch(`http://127.0.0.1:${platformPort}/api/remote-auth/status`, {
523
410
  signal: AbortSignal.timeout(5000),
@@ -546,23 +433,20 @@ server.tool("tunnel-enable", "Start the Cloudflare Tunnel process. The tunnel mu
546
433
  isError: true,
547
434
  };
548
435
  }
549
- // GATE 3: auth pre-conditions — cert + binding + accountId match.
550
- // We use validateAuth (non-throwing) so we can produce a uniform
551
- // refusal message regardless of which condition failed.
436
+ // GATE 3: cert.pem + binding
552
437
  const enableAuth = cloudflared.validateAuth();
553
438
  if (!enableAuth.bound) {
554
- const missing = !enableAuth.hasCert ? "cert.pem" : !enableAuth.hasBinding ? "account binding" : "matching cert/binding";
555
439
  return {
556
440
  content: [
557
441
  {
558
442
  type: "text",
559
- text: `REFUSED: Cannot start tunnel — ${missing} is missing or mismatched. ${cloudflared.recoveryMessage()}`,
443
+ text: `REFUSED: Cannot start tunnel — this laptop is not signed into Cloudflare or the sign-in has drifted. ${cloudflared.recoveryMessage()}`,
560
444
  },
561
445
  ],
562
446
  isError: true,
563
447
  };
564
448
  }
565
- // GATE 4: config.yml must exist (written by tunnel-create)
449
+ // GATE 4: config.yml must exist
566
450
  const tunnelState = cloudflared.getPersistedState();
567
451
  const configPath = tunnelState?.configPath;
568
452
  const credentialsPath = tunnelState?.credentialsPath;
@@ -578,7 +462,6 @@ server.tool("tunnel-enable", "Start the Cloudflare Tunnel process. The tunnel mu
578
462
  isError: true,
579
463
  };
580
464
  }
581
- // Start the tunnel daemon — config.yml + cert.pem, deterministic
582
465
  cloudflared.startTunnel({
583
466
  tunnelId,
584
467
  tunnelName,
@@ -586,7 +469,6 @@ server.tool("tunnel-enable", "Start the Cloudflare Tunnel process. The tunnel mu
586
469
  configPath,
587
470
  credentialsPath: credentialsPath ?? "",
588
471
  });
589
- // Wait up to 5 seconds to verify the process didn't immediately crash
590
472
  const logHint = `~/${brand.configDir}/logs/cloudflared.log`;
591
473
  for (let i = 0; i < 10; i++) {
592
474
  await new Promise((r) => setTimeout(r, 500));
@@ -604,9 +486,7 @@ server.tool("tunnel-enable", "Start the Cloudflare Tunnel process. The tunnel mu
604
486
  }
605
487
  }
606
488
  const status = await cloudflared.getStatus(domain);
607
- // Programmatic verification — HTTP-check the admin URL (always present)
608
- // via Cloudflare's edge. If public URL exists, check that too.
609
- const verifyUrl = `https://${hostnames[0]}`; // admin hostname is always first
489
+ const verifyUrl = `https://${hostnames[0]}`;
610
490
  let verified = false;
611
491
  console.error(`[tunnel-enable] verifying url=${verifyUrl}`);
612
492
  for (let attempt = 0; attempt < 6; attempt++) {
@@ -632,20 +512,19 @@ server.tool("tunnel-enable", "Start the Cloudflare Tunnel process. The tunnel mu
632
512
  content: [
633
513
  {
634
514
  type: "text",
635
- text: `Tunnel process is running (PID ${status.pid}) but ${verifyUrl} returned HTTP 530 after 30s of retries. Cloudflare's edge cannot route traffic to this tunnel.\n\nCommon causes:\n 1. Stale DNS CNAME records pointing to a previous tunnel — run tunnel-create again to fix\n 2. UDP buffer too small for QUIC — check ${logHint}\n 3. Firewall blocking outbound QUIC to Cloudflare edge\n\nThe tunnel is NOT working. Do not report success.`,
515
+ text: `Tunnel process is running (PID ${status.pid}) but ${verifyUrl} returned 530 after 30s of retries. Cloudflare's edge cannot reach this tunnel.\n\nCommon causes:\n 1. Stale DNS records pointing to a previous tunnel — run tunnel-create again to fix\n 2. UDP buffer too small for QUIC — check ${logHint}\n 3. Firewall blocking outbound QUIC to Cloudflare's edge\n\nThe tunnel is NOT working. Do not report success.`,
636
516
  },
637
517
  ],
638
518
  isError: true,
639
519
  };
640
520
  }
641
- // Build URL list for output
642
521
  const urlLines = hostnames.map((h, i) => {
643
522
  const label = i === 0 ? "Admin" : "Public";
644
523
  return ` ${label}: https://${h}`;
645
524
  }).join("\n");
646
525
  const reachableNote = hostnames.length > 1
647
- ? "All URLs are reachable through Cloudflare's edge."
648
- : "The admin URL is reachable through Cloudflare's edge.";
526
+ ? "All URLs are reachable through Cloudflare."
527
+ : "The admin URL is reachable through Cloudflare.";
649
528
  return {
650
529
  content: [
651
530
  {
@@ -697,40 +576,25 @@ server.tool("tunnel-disable", "Stop the Cloudflare Tunnel process. Config is pre
697
576
  };
698
577
  }
699
578
  });
700
- server.tool("tunnel-add-hostname", "Add an alias hostname to an existing Cloudflare Tunnel for direct serving. Reads the current tunnel ingress config, appends the new hostname, creates a CNAME in the alias domain's zone pointing to the tunnel, and registers the alias so the web server treats it as a public hostname. The web server hot-reloads alias domains automatically — the new domain is active within seconds, no restart needed. Idempotent — safe to call again with the same hostname.", {
579
+ server.tool("tunnel-add-hostname", "Add an additional address to an existing Cloudflare Tunnel. Refuses pre-flight when the hostname's domain is not on Cloudflare, and refuses post-flight when `cloudflared` routes the address under a different domain (meaning the laptop is signed into the wrong Cloudflare account). Idempotent — safe to call again with the same hostname.", {
701
580
  hostname: z
702
581
  .string()
703
- .describe("The alias domain to add (e.g. 'maxy.chat'). Must be an Active zone on the Cloudflare account."),
582
+ .describe("The address to add (e.g. 'maxy.chat'). The domain must be on the Cloudflare account this laptop is signed into."),
704
583
  tunnelId: z
705
584
  .string()
706
585
  .describe("The tunnel UUID. Get this from tunnel-status."),
707
586
  }, async ({ hostname, tunnelId }) => {
708
587
  try {
709
- // getZoneId enforces auth pre-conditions and refuses if the alias
710
- // domain isn't a zone on the bound account.
711
- const zoneId = await cloudflared.getZoneId(hostname);
712
- // Step 2: Add hostname to tunnel ingress (read-then-append)
713
- const ingress = await cloudflared.addHostnameToIngress(tunnelId, hostname);
714
- // Step 3: Create CNAME via SDK. The cert-bound credential carries
715
- // Zone:DNS:Edit for zones owned by the bound account. On any error,
716
- // surface as failure — no fallback path remains.
717
- const dns = await cloudflared.createDnsRecord(zoneId, hostname, tunnelId);
718
- // Step 4: Persist alias domain for web server recognition
588
+ const route = await cloudflared.routeDnsCli(tunnelId, hostname);
719
589
  cloudflared.saveAliasDomain(hostname);
720
- // Build response
721
- const ingressNote = ingress.alreadyPresent
722
- ? "already in tunnel ingress"
723
- : "added to tunnel ingress";
724
- const dnsNote = dns.updated
725
- ? "updated — was pointing to wrong tunnel"
726
- : dns.existing
727
- ? "already existed"
728
- : "created";
590
+ const routeNote = route.created
591
+ ? "created"
592
+ : "already existed";
729
593
  return {
730
594
  content: [
731
595
  {
732
596
  type: "text",
733
- text: `Alias domain configured for direct serving.\n\n Hostname: ${hostname}\n Tunnel ingress: ${ingressNote}\n DNS CNAME: ${dnsNote} (→ ${tunnelId}.cfargotunnel.com)\n Web server: registered in alias-domains.json (hot-reloaded automatically)\n\nhttps://${hostname} is now active the web server picks up the new alias domain within seconds. It serves the public chat directly — no redirect, URL stays as ${hostname}. Verify the URL to confirm.`,
597
+ text: `Address configured.\n\n Address: ${hostname}\n Routing: ${routeNote}\n Web server: registered in alias-domains.json (hot-reloaded automatically)\n\nhttps://${hostname} will be active once DNS propagates (1-5 minutes). Run tunnel-status to confirm.`,
734
598
  },
735
599
  ],
736
600
  };
@@ -746,7 +610,7 @@ server.tool("tunnel-add-hostname", "Add an alias hostname to an existing Cloudfl
746
610
  content: [
747
611
  {
748
612
  type: "text",
749
- text: `Failed to add alias hostname: ${err instanceof Error ? err.message : String(err)}`,
613
+ text: `Failed to add address: ${err instanceof Error ? err.message : String(err)}`,
750
614
  },
751
615
  ],
752
616
  isError: true,
@@ -764,12 +628,11 @@ server.tool("dns-lookup", "Resolve DNS records for a hostname. Replaces dig/nslo
764
628
  nameserver: z
765
629
  .string()
766
630
  .optional()
767
- .describe("Custom nameserver IP (default: Google 8.8.8.8 + Cloudflare 1.1.1.1)"),
631
+ .describe("Custom nameserver IP (default: Cloudflare 1.1.1.1 + Google 8.8.8.8)"),
768
632
  }, async ({ hostname, type, nameserver }) => {
769
633
  try {
770
- const dns = await import("node:dns/promises");
771
- const resolver = new dns.Resolver();
772
- resolver.setServers(nameserver ? [nameserver] : ["8.8.8.8", "1.1.1.1"]);
634
+ const resolver = new Resolver();
635
+ resolver.setServers(nameserver ? [nameserver] : ["1.1.1.1", "8.8.8.8"]);
773
636
  const recordType = type ?? "A";
774
637
  const results = {
775
638
  hostname,
@@ -791,15 +654,13 @@ server.tool("dns-lookup", "Resolve DNS records for a hostname. Replaces dig/nslo
791
654
  catch (cnameErr) {
792
655
  const cnameCode = cnameErr.code;
793
656
  if (cnameCode === "ENODATA") {
794
- // Cloudflare-proxied CNAMEs are flattened to A records — resolveCname
795
- // returns ENODATA even though the hostname resolves. Fall back to A.
796
657
  try {
797
658
  const aRecords = await resolver.resolve4(hostname);
798
- results.note = "CNAME lookup returned ENODATA — record is likely Cloudflare-proxied (flattened to A)";
659
+ results.note = "CNAME lookup returned ENODATA — record is likely flattened to A";
799
660
  results.addresses = aRecords;
800
661
  }
801
662
  catch {
802
- throw cnameErr; // A also failed — rethrow original ENODATA
663
+ throw cnameErr;
803
664
  }
804
665
  }
805
666
  else {
@@ -845,809 +706,12 @@ server.tool("dns-lookup", "Resolve DNS records for a hostname. Replaces dig/nslo
845
706
  };
846
707
  }
847
708
  });
848
- // ===================================================================
849
- // cf-verify / cf-rebuild — account-state report + nuclear cleanup
850
- // ===================================================================
851
- server.tool("cf-verify", "Read the bound Cloudflare account and the device's local state, return a JSON snapshot. Account section: zones, tunnels, CNAMEs (grouped by zone). Device section: cert.pem, account-binding.json, tunnel.state, config.yml, alias-domains.json. Orphans section: account artefacts not referenced by the device's current intended state (tunnels, CNAMEs, zones not used by any intended hostname). Non-mutating. Runs in any state including fresh-install-before-login.", {}, async () => {
852
- try {
853
- const report = await cloudflared.cfVerifyCore();
854
- return {
855
- content: [{ type: "text", text: JSON.stringify(report, null, 2) }],
856
- };
857
- }
858
- catch (err) {
859
- return {
860
- content: [
861
- {
862
- type: "text",
863
- text: `cf-verify failed: ${err instanceof Error ? err.message : String(err)}`,
864
- },
865
- ],
866
- isError: true,
867
- };
868
- }
869
- });
870
- server.tool("cf-rebuild", "Nuclear cleanup of the bound Cloudflare account. Deletes every tunnel, CNAME, and (where the account permits) zone NOT in the `preserve` set. With no preserve args, infers what to keep from the device's `tunnel.state` and `alias-domains.json`. Pass `preserve.zones`, `preserve.tunnelIds`, or `preserve.cnames` to override. `dryRun=true` plans without mutating. The agent owns the bound account absolutely — anything not in preserve is treated as junk.", {
871
- dryRun: z
872
- .boolean()
873
- .optional()
874
- .describe("If true, report what cf-rebuild WOULD delete without performing any mutations."),
875
- preserve: z
876
- .object({
877
- zones: z.array(z.string()).optional().describe("Zone names to keep (whole zones; all CNAMEs under them survive unless preserve.cnames overrides)."),
878
- tunnelIds: z.array(z.string()).optional().describe("Tunnel UUIDs to keep."),
879
- cnames: z.array(z.object({ zone: z.string(), name: z.string() })).optional().describe("Per-CNAME preserve list. When set, ONLY these CNAMEs survive within preserved zones."),
880
- })
881
- .optional()
882
- .describe("Preservation set. Anything on the account not listed here is deleted. Defaults to whatever the device's tunnel.state and alias-domains.json reference."),
883
- }, async ({ dryRun, preserve }) => {
884
- try {
885
- const report = await cloudflared.cfRebuildCore({ dryRun: dryRun ?? false, preserve });
886
- return {
887
- content: [{ type: "text", text: JSON.stringify(report, null, 2) }],
888
- isError: report.halted,
889
- };
890
- }
891
- catch (err) {
892
- if (err instanceof cloudflared.CloudflareRefusalError) {
893
- return {
894
- content: [{ type: "text", text: err.refusal.message }],
895
- isError: true,
896
- };
897
- }
898
- return {
899
- content: [
900
- {
901
- type: "text",
902
- text: `cf-rebuild failed: ${err instanceof Error ? err.message : String(err)}`,
903
- },
904
- ],
905
- isError: true,
906
- };
907
- }
908
- });
909
- const LABEL_RE = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/;
910
- function sanitizedDomain(domain) {
911
- return domain.replace(/\./g, "-");
709
+ async function main() {
710
+ const transport = new StdioServerTransport();
711
+ await server.connect(transport);
912
712
  }
913
- function deriveTunnelName(adminLabel, domain) {
914
- return `${adminLabel}-${sanitizedDomain(domain)}`;
915
- }
916
- function hasBrandCredsFor(tunnelId) {
917
- const path = join(homedir(), cloudflared.loadBrand().configDir, "cloudflared", `${tunnelId}.json`);
918
- return existsSync(path);
919
- }
920
- server.tool("cloudflare-setup", "Deterministic, UI-driven state machine for Cloudflare Tunnel setup. Call with no parameters to begin; the tool evaluates current state and returns a structured result describing what to render next. When a returned status begins with 'awaiting_', read `data.render` and call render-component with its contents verbatim — do not paraphrase, do not prompt the user for values. When the user submits a component (single-select, tunnel-route-picker), the agent's sole job is to relay the submission's fields (selectedTunnelId, selectedZoneName, adminLabel, publicLabel) back into this tool unchanged. The agent never synthesises subdomains or domain fragments from free text.", {
921
- selectedTunnelId: z
922
- .string()
923
- .optional()
924
- .describe("Tunnel ID from the single-select submission. The literal string '__new__' means the user chose to create a new tunnel. Pass only when the last component rendered was the tunnel selection list."),
925
- selectedZoneName: z
926
- .string()
927
- .optional()
928
- .describe("Zone name from the single-select submission. Pass only when the last component rendered was the zone selection list."),
929
- adminLabel: z
930
- .string()
931
- .optional()
932
- .describe("Admin DNS label from the tunnel-route-picker submission. DNS-label-safe (a-z, 0-9, hyphen, no leading/trailing hyphen). Pass only when the last component rendered was tunnel-route-picker."),
933
- publicLabel: z
934
- .string()
935
- .optional()
936
- .describe("Public DNS label from the tunnel-route-picker submission. Optional; omit to skip the public endpoint. DNS-label-safe. Pass only when the last component rendered was tunnel-route-picker."),
937
- cleanupConfirmed: z
938
- .boolean()
939
- .optional()
940
- .describe("Operator confirmation to delete pollution on the bound Cloudflare account (zones, tunnels, and CNAMEs not matching the chosen domain). Pass only when the last component rendered was the cleanup confirmation — comes from the component's submitMessage verbatim. Never synthesise from free text."),
941
- }, async ({ selectedTunnelId, selectedZoneName, adminLabel, publicLabel, cleanupConfirmed }) => {
942
- const log = (msg) => console.error(`[cloudflare-setup] ${msg}`);
943
- try {
944
- // ── Step 1: cloudflared binary ──────────────────────────────────
945
- log("checking cloudflared installation");
946
- if (!cloudflared.isInstalled()) {
947
- log("cloudflared not installed — installing");
948
- const { execFileSync } = await import("node:child_process");
949
- const arch = process.arch === "arm64" ? "arm64" : "amd64";
950
- const debUrl = `https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-${arch}.deb`;
951
- try {
952
- execFileSync("curl", ["-fsSL", debUrl, "-o", "/tmp/cloudflared.deb"], { timeout: 60000 });
953
- execFileSync("sudo", ["dpkg", "-i", "/tmp/cloudflared.deb"], { timeout: 30000 });
954
- execFileSync("rm", ["-f", "/tmp/cloudflared.deb"]);
955
- log("cloudflared installed successfully");
956
- }
957
- catch (err) {
958
- const msg = err instanceof Error ? err.message : String(err);
959
- log(`installation failed: ${msg}`);
960
- return result({ status: "error", message: `Could not install cloudflared: ${msg}` });
961
- }
962
- }
963
- // ── Step 2: Authentication ──────────────────────────────────────
964
- log("checking authentication");
965
- const auth = cloudflared.validateAuth();
966
- if (auth.bound) {
967
- log(`authenticated — cert and binding agree on account ${auth.boundAccountId}`);
968
- }
969
- else if (auth.hasCert) {
970
- // cert.pem exists — materialize binding (migration path) or surface
971
- // drift if the binding disagrees with the cert.
972
- const creds = cloudflared.parseCertPem();
973
- if (!creds) {
974
- log("cert.pem exists but could not extract credentials");
975
- return result({
976
- status: "error",
977
- message: "Found a Cloudflare certificate but could not extract credentials from it. Sign in again — I'll clear the old certificate and start fresh.",
978
- });
979
- }
980
- if (!auth.hasBinding) {
981
- const binding = cloudflared.materializeBinding("migration");
982
- log(`materialized binding from existing cert — account ${binding.accountId}`);
983
- }
984
- else if (auth.certAccountId !== auth.boundAccountId) {
985
- log(`account drift: cert=${auth.certAccountId} binding=${auth.boundAccountId}`);
986
- return result({
987
- status: "error",
988
- message: `Your Cloudflare certificate is bound to a different account than this device's recorded binding. ${cloudflared.recoveryMessage()}`,
989
- });
990
- }
991
- }
992
- else {
993
- // No cert, no token — need login
994
- log("no authentication found — starting login");
995
- // Get auth URL from existing login process or spawn a fresh one
996
- let authUrl;
997
- const activeLogin = cloudflared.getActiveLogin();
998
- if (activeLogin) {
999
- log(`login process already running (PID ${activeLogin.pid})`);
1000
- authUrl = activeLogin.authUrl;
1001
- }
1002
- else {
1003
- try {
1004
- const loginResult = await cloudflared.tunnelLogin();
1005
- log(`login spawned — auth URL: ${loginResult.authUrl}`);
1006
- authUrl = loginResult.authUrl;
1007
- }
1008
- catch (err) {
1009
- const msg = err instanceof Error ? err.message : String(err);
1010
- log(`login failed: ${msg}`);
1011
- return result({
1012
- status: "error",
1013
- message: `Could not start Cloudflare sign-in: ${msg}`,
1014
- });
1015
- }
1016
- }
1017
- // Return immediately — the agent shows the URL, user authorizes,
1018
- // then re-calls cloudflare-setup. hasCert() at line 1082 handles re-entry.
1019
- log(`returning awaiting_auth — auth URL: ${authUrl}`);
1020
- return result({
1021
- status: "awaiting_auth",
1022
- message: "Open the authorization URL in your browser and click Authorize. Once done, let me know and I'll continue the setup.",
1023
- data: { authUrl },
1024
- });
1025
- }
1026
- // ── Step 3: Discover state — tunnel, zone, domain ───────────────
1027
- const existingState = cloudflared.getPersistedState();
1028
- const platformPort = parseInt(process.env.PLATFORM_PORT ?? "19200", 10);
1029
- // If state already has a tunnel identity with hostnames, labels are
1030
- // reconstructed from the persisted hostnames on every re-entry where
1031
- // the agent doesn't supply fresh labels. This is how the flow
1032
- // survives the awaiting_password round-trip.
1033
- const stateAdminLabel = existingState?.adminHostname && existingState.domain
1034
- ? existingState.adminHostname.replace(`.${existingState.domain}`, "")
1035
- : null;
1036
- const statePublicLabel = existingState?.publicHostname && existingState?.domain
1037
- ? existingState.publicHostname.replace(`.${existingState.domain}`, "")
1038
- : null;
1039
- let allTunnels = [];
1040
- let allZones = [];
1041
- try {
1042
- [allTunnels, allZones] = await Promise.all([
1043
- cloudflared.listTunnelsOnAccount(),
1044
- cloudflared.listZones(),
1045
- ]);
1046
- }
1047
- catch (err) {
1048
- const msg = err instanceof Error ? err.message : String(err);
1049
- log(`discovery failed: ${msg}`);
1050
- return result({
1051
- status: "error",
1052
- message: `Could not read your Cloudflare account: ${msg}`,
1053
- });
1054
- }
1055
- log(`entry tunnelsOnAccount=${allTunnels.length} zonesOnAccount=${allZones.length}`);
1056
- const activeZones = allZones.filter((z) => z.status === "active");
1057
- const pendingZones = allZones.filter((z) => z.status === "pending");
1058
- // Resolve domain — precedence: persisted state > tunnel selection > zone selection > auto-only-active
1059
- let domain = existingState?.domain ?? null;
1060
- if (!domain && selectedTunnelId && selectedTunnelId !== "__new__") {
1061
- const tunnel = allTunnels.find((t) => t.id === selectedTunnelId);
1062
- if (!tunnel) {
1063
- log(`selectedTunnelId ${selectedTunnelId} not found on account`);
1064
- return result({
1065
- status: "error",
1066
- message: "That tunnel is no longer on your account. Let me check again.",
1067
- });
1068
- }
1069
- const zoneName = await cloudflared.findZoneHostingTunnel(selectedTunnelId);
1070
- if (!zoneName) {
1071
- log(`selected tunnel ${selectedTunnelId} has no zone hosting it — falling back to zone selection`);
1072
- // Fall through to zone-selection branch below
1073
- }
1074
- else {
1075
- domain = zoneName;
1076
- log(`branch=existing-tunnel tunnelId=${selectedTunnelId} domain=${domain}`);
1077
- }
1078
- }
1079
- if (!domain && selectedZoneName) {
1080
- const zone = activeZones.find((z) => z.name === selectedZoneName);
1081
- if (!zone) {
1082
- log(`selectedZoneName ${selectedZoneName} not active on account`);
1083
- return result({
1084
- status: "error",
1085
- message: "That domain is no longer Active on your account. Let me check again.",
1086
- });
1087
- }
1088
- domain = selectedZoneName;
1089
- }
1090
- // No prior state, no selection — prompt the user for a tunnel
1091
- // (if account has any) or a zone (if account has >1 active zone).
1092
- if (!domain) {
1093
- if (allTunnels.length > 0 && !selectedTunnelId) {
1094
- log(`awaiting_tunnel_selection tunnelsOnAccount=${allTunnels.length}`);
1095
- const options = [
1096
- { value: "__new__", label: "Create a new tunnel (recommended)" },
1097
- ...allTunnels.map((t) => ({ value: t.id, label: t.name })),
1098
- ];
1099
- return result({
1100
- status: "awaiting_tunnel_selection",
1101
- message: "Pick a tunnel for this device. For a fresh setup, choose Create a new tunnel.",
1102
- data: {
1103
- tunnels: allTunnels.map((t) => ({ id: t.id, name: t.name })),
1104
- render: {
1105
- name: "single-select",
1106
- data: {
1107
- title: "Tunnel",
1108
- description: "Each device uses its own tunnel. Creating a new one is the normal choice.",
1109
- options,
1110
- submitLabel: "Use this tunnel",
1111
- submitMessage: '{"selectedTunnelId":"{{value}}"}',
1112
- },
1113
- },
1114
- },
1115
- });
1116
- }
1117
- // From here: selectedTunnelId is "__new__" or undefined (no tunnels on account).
1118
- if (activeZones.length === 0) {
1119
- if (pendingZones.length > 0) {
1120
- const z = pendingZones[0];
1121
- log(`awaiting_nameservers zone=${z.name}`);
1122
- return result({
1123
- status: "awaiting_nameservers",
1124
- message: `Your domain ${z.name} is on Cloudflare but not yet active. Update the nameservers at your registrar to:\n\n• ${z.nameservers[0]}\n• ${z.nameservers[1]}\n\nLet me know when you've updated them.`,
1125
- data: { nameservers: z.nameservers },
1126
- });
1127
- }
1128
- log(`branch=zone-add-fallback reason=noZones`);
1129
- return result({
1130
- status: "awaiting_zone_add_dashboard",
1131
- message: "No domain on your Cloudflare account yet. Sign in at cloudflare.com, click Add a site, and add your domain. When it appears on the account, let me know and I'll continue.",
1132
- data: { dashboardUrl: "https://dash.cloudflare.com" },
1133
- });
1134
- }
1135
- if (activeZones.length > 1) {
1136
- log(`awaiting_zone_selection activeZones=${activeZones.length}`);
1137
- return result({
1138
- status: "awaiting_zone_selection",
1139
- message: "Multiple domains on your Cloudflare account. Pick the one to use for this device.",
1140
- data: {
1141
- zones: activeZones.map((z) => ({ name: z.name, status: z.status })),
1142
- render: {
1143
- name: "single-select",
1144
- data: {
1145
- title: "Domain",
1146
- options: activeZones.map((z) => ({ value: z.name, label: z.name })),
1147
- submitLabel: "Use this domain",
1148
- submitMessage: '{"selectedZoneName":"{{value}}"}',
1149
- },
1150
- },
1151
- },
1152
- });
1153
- }
1154
- // Exactly one active zone — use it.
1155
- domain = activeZones[0].name;
1156
- log(`auto-selected only active zone: ${domain}`);
1157
- }
1158
- // Verify the resolved zone is active (guard against state with a since-pending zone)
1159
- const zone = allZones.find((z) => z.name === domain);
1160
- if (zone && zone.status === "pending") {
1161
- log(`zone ${domain} is pending — returning awaiting_nameservers`);
1162
- return result({
1163
- status: "awaiting_nameservers",
1164
- message: `Your domain ${domain} is not yet active on Cloudflare. Update the nameservers at your registrar to:\n\n• ${zone.nameservers[0]}\n• ${zone.nameservers[1]}\n\nLet me know when you've updated them.`,
1165
- data: { nameservers: zone.nameservers },
1166
- });
1167
- }
1168
- // ── Step 3b: Cleanup — bound account is the universe ────────────
1169
- // The chosen zone + any currently-persisted tunnel are intent;
1170
- // everything else on the account is pollution. Surface it, require
1171
- // explicit operator confirmation, then delete before creating the
1172
- // new tunnel (so the label-collision check runs against a clean
1173
- // zone, not one littered with stale `<deadTunnel>.cfargotunnel.com`
1174
- // pointers).
1175
- const preservedTunnelIds = new Set();
1176
- if (existingState?.tunnelId)
1177
- preservedTunnelIds.add(existingState.tunnelId);
1178
- if (selectedTunnelId && selectedTunnelId !== "__new__") {
1179
- preservedTunnelIds.add(selectedTunnelId);
1180
- }
1181
- const domainLc = domain.toLowerCase();
1182
- const pollutionZonesList = allZones.filter((z) => z.status === "active" && z.name.toLowerCase() !== domainLc);
1183
- const pollutionTunnelsList = allTunnels.filter((t) => !preservedTunnelIds.has(t.id));
1184
- // Only surface CNAMEs on the PRESERVED zone that point to deleted
1185
- // tunnels — those need explicit disclosure because the zone itself
1186
- // survives. CNAMEs under pollution zones are cascade-deleted when
1187
- // the zone is deleted; listing them separately would duplicate
1188
- // the same destruction in the confirm UI.
1189
- let pollutionCnamesList = [];
1190
- if (pollutionTunnelsList.length > 0) {
1191
- try {
1192
- const verify = await cloudflared.cfVerifyCore();
1193
- if (verify.account) {
1194
- const pollutionTunnelIdSet = new Set(pollutionTunnelsList.map((t) => t.id));
1195
- pollutionCnamesList = verify.account.cnames
1196
- .filter((c) => {
1197
- if (c.zone.toLowerCase() !== domainLc)
1198
- return false;
1199
- const m = c.content.toLowerCase().trim().match(/^([0-9a-f-]{36})\.cfargotunnel\.com\.?$/);
1200
- return Boolean(m && pollutionTunnelIdSet.has(m[1]));
1201
- })
1202
- .map((c) => ({ zone: c.zone, name: c.name, content: c.content }));
1203
- }
1204
- }
1205
- catch (err) {
1206
- const msg = err instanceof Error ? err.message : String(err);
1207
- log(`cfVerifyCore during cleanup check failed: ${msg}`);
1208
- // Continue with zone/tunnel pollution only — conservative.
1209
- }
1210
- }
1211
- const pollutionTotal = pollutionZonesList.length +
1212
- pollutionTunnelsList.length +
1213
- pollutionCnamesList.length;
1214
- if (pollutionTotal > 0) {
1215
- if (!cleanupConfirmed) {
1216
- log(`awaiting_cleanup_confirmation pollutionZones=${pollutionZonesList.length} pollutionTunnels=${pollutionTunnelsList.length} pollutionCnames=${pollutionCnamesList.length}`);
1217
- const items = [
1218
- ...pollutionZonesList.map((z) => ({ label: "Domain (zone)", value: z.name })),
1219
- ...pollutionTunnelsList.map((t) => ({ label: "Tunnel", value: t.name })),
1220
- ...pollutionCnamesList.map((c) => ({
1221
- label: "DNS record",
1222
- value: `${c.name} → ${c.content}`,
1223
- })),
1224
- ];
1225
- const confirmPayload = {
1226
- cleanupConfirmed: true,
1227
- selectedZoneName: domain,
1228
- };
1229
- if (selectedTunnelId)
1230
- confirmPayload.selectedTunnelId = selectedTunnelId;
1231
- return result({
1232
- status: "awaiting_cleanup_confirmation",
1233
- message: `Your Cloudflare account has artefacts from prior work that don't belong to the ${domain} setup. Confirm to clean them up before creating the tunnel.`,
1234
- data: {
1235
- domain,
1236
- pollution: {
1237
- zones: pollutionZonesList.map((z) => ({ name: z.name })),
1238
- tunnels: pollutionTunnelsList.map((t) => ({ id: t.id, name: t.name })),
1239
- cnames: pollutionCnamesList,
1240
- },
1241
- render: {
1242
- name: "confirm",
1243
- data: {
1244
- title: `Clean up before setting up ${domain}?`,
1245
- description: `Keeping ${domain}. Everything below will be deleted.`,
1246
- items,
1247
- confirmLabel: "Clean up and continue",
1248
- rejectLabel: "Cancel",
1249
- confirmMessage: JSON.stringify(confirmPayload),
1250
- rejectMessage: "I don't want to clean up.",
1251
- },
1252
- },
1253
- },
1254
- });
1255
- }
1256
- // Cleanup confirmed — run the rebuild with explicit intent.
1257
- log(`cleanup confirmed — cfRebuildCore preserve.zones=[${domain}] preserve.tunnelIds=[${[...preservedTunnelIds].join(",")}]`);
1258
- try {
1259
- const rebuild = await cloudflared.cfRebuildCore({
1260
- preserve: {
1261
- zones: [domain],
1262
- tunnelIds: [...preservedTunnelIds],
1263
- },
1264
- });
1265
- if (rebuild.halted) {
1266
- return result({
1267
- status: "error",
1268
- message: `Cleanup halted: ${rebuild.haltReason ?? "unknown reason"}. ${cloudflared.recoveryMessage()}`,
1269
- });
1270
- }
1271
- const failedActions = rebuild.actions.filter((a) => a.result === "failed");
1272
- if (failedActions.length > 0) {
1273
- return result({
1274
- status: "error",
1275
- message: `Cleanup completed with ${failedActions.length} failed action(s) of ${rebuild.actions.length}. First: ${failedActions[0].detail ?? "(no detail)"}. Run cf-verify to see current state.`,
1276
- });
1277
- }
1278
- log(`cleanup complete actions=${rebuild.actions.length}`);
1279
- // Refresh tunnels snapshot so label collision checks run against
1280
- // the post-cleanup account state.
1281
- try {
1282
- allTunnels = await cloudflared.listTunnelsOnAccount();
1283
- }
1284
- catch (err) {
1285
- const msg = err instanceof Error ? err.message : String(err);
1286
- log(`post-cleanup tunnel refresh failed: ${msg}`);
1287
- }
1288
- }
1289
- catch (err) {
1290
- const msg = err instanceof Error ? err.message : String(err);
1291
- log(`cleanup failed: ${msg}`);
1292
- return result({
1293
- status: "error",
1294
- message: `Could not clean up your Cloudflare account: ${msg}`,
1295
- });
1296
- }
1297
- }
1298
- // ── Step 4: Labels ──────────────────────────────────────────────
1299
- // Resolution precedence: args > persisted state hostnames.
1300
- const resolvedAdminLabel = adminLabel ?? stateAdminLabel;
1301
- const resolvedPublicLabel = publicLabel ?? statePublicLabel ?? null;
1302
- if (!resolvedAdminLabel) {
1303
- log(`ui=tunnel-route-picker rendered tunnelDomain=${domain}`);
1304
- return result({
1305
- status: "awaiting_labels",
1306
- message: `Pick a short name for your admin address on ${domain}. You can also pick one for a public address, or leave it empty.`,
1307
- data: {
1308
- domain,
1309
- render: {
1310
- name: "tunnel-route-picker",
1311
- data: {
1312
- domain,
1313
- title: "Pick your addresses",
1314
- description: "Your admin address is required. Public is optional and can be added later.",
1315
- submitLabel: "Set up addresses",
1316
- },
1317
- },
1318
- },
1319
- });
1320
- }
1321
- // Defense-in-depth label validation (UI already filters, but tool
1322
- // is authoritative — an agent misroute must fail loudly, not silently).
1323
- if (!LABEL_RE.test(resolvedAdminLabel)) {
1324
- log(`invalid admin label: ${resolvedAdminLabel}`);
1325
- return result({
1326
- status: "label-taken",
1327
- message: "Admin label is not a valid DNS label. Pick something that is lowercase letters, numbers, and hyphens.",
1328
- data: {
1329
- domain,
1330
- field: "admin",
1331
- render: {
1332
- name: "tunnel-route-picker",
1333
- data: {
1334
- domain,
1335
- title: "Pick your addresses",
1336
- defaultAdmin: "",
1337
- defaultPublic: resolvedPublicLabel ?? "",
1338
- error: { field: "admin", message: "Not a valid address — use lowercase letters, numbers, and hyphens." },
1339
- submitLabel: "Set up addresses",
1340
- },
1341
- },
1342
- },
1343
- });
1344
- }
1345
- if (resolvedPublicLabel !== null && resolvedPublicLabel !== "" && !LABEL_RE.test(resolvedPublicLabel)) {
1346
- log(`invalid public label: ${resolvedPublicLabel}`);
1347
- return result({
1348
- status: "label-taken",
1349
- message: "Public label is not a valid DNS label.",
1350
- data: {
1351
- domain,
1352
- field: "public",
1353
- render: {
1354
- name: "tunnel-route-picker",
1355
- data: {
1356
- domain,
1357
- defaultAdmin: resolvedAdminLabel,
1358
- defaultPublic: "",
1359
- error: { field: "public", message: "Not a valid address." },
1360
- submitLabel: "Set up addresses",
1361
- },
1362
- },
1363
- },
1364
- });
1365
- }
1366
- if (resolvedPublicLabel && resolvedAdminLabel === resolvedPublicLabel) {
1367
- log(`admin and public labels identical: ${resolvedAdminLabel}`);
1368
- return result({
1369
- status: "label-taken",
1370
- message: "Admin and public addresses must differ.",
1371
- data: {
1372
- domain,
1373
- field: "public",
1374
- render: {
1375
- name: "tunnel-route-picker",
1376
- data: {
1377
- domain,
1378
- defaultAdmin: resolvedAdminLabel,
1379
- defaultPublic: "",
1380
- error: { field: "public", message: "Public must differ from admin." },
1381
- submitLabel: "Set up addresses",
1382
- },
1383
- },
1384
- },
1385
- });
1386
- }
1387
- const adminHostname = `${resolvedAdminLabel}.${domain}`;
1388
- const publicHostname = resolvedPublicLabel ? `${resolvedPublicLabel}.${domain}` : null;
1389
- const hostnames = publicHostname ? [adminHostname, publicHostname] : [adminHostname];
1390
- // Account-zone scope check happens inside routeDnsCli (Step 6) — the
1391
- // domain was picked from the live account zone list, so by construction
1392
- // it is on the bound account. No pre-flight needed here.
1393
- // ── Step 5: Resolve or create tunnel (idempotent on re-entry) ───
1394
- let tunnelId = existingState?.tunnelId ?? null;
1395
- let tunnelName = existingState?.tunnelName ?? null;
1396
- let credentialsPath = existingState?.credentialsPath ?? null;
1397
- if (!tunnelId) {
1398
- if (selectedTunnelId && selectedTunnelId !== "__new__") {
1399
- // User picked an existing tunnel — reuse its identity.
1400
- const selected = allTunnels.find((t) => t.id === selectedTunnelId);
1401
- if (!selected) {
1402
- return result({
1403
- status: "error",
1404
- message: "Selected tunnel not found on account.",
1405
- });
1406
- }
1407
- if (!hasBrandCredsFor(selected.id)) {
1408
- // Cannot reuse without credentials — this tunnel belongs to
1409
- // another device on the same account. Surface structured error.
1410
- log(`collision type=tunnel-name label=${resolvedAdminLabel} existingTunnelId=${selected.id}`);
1411
- return result({
1412
- status: "tunnel-name-taken",
1413
- message: `The tunnel "${selected.name}" belongs to another device. Pick a different label for this one.`,
1414
- data: {
1415
- suggestedLabel: `${resolvedAdminLabel}2`,
1416
- existingTunnelId: selected.id,
1417
- render: {
1418
- name: "tunnel-route-picker",
1419
- data: {
1420
- domain,
1421
- defaultAdmin: "",
1422
- defaultPublic: resolvedPublicLabel ?? "",
1423
- error: { field: "admin", message: `That address is already taken. Pick a different one.` },
1424
- submitLabel: "Set up addresses",
1425
- },
1426
- },
1427
- },
1428
- });
1429
- }
1430
- tunnelId = selected.id;
1431
- tunnelName = selected.name;
1432
- }
1433
- else {
1434
- // Create new — tunnel name is derived from admin label + domain.
1435
- const derivedName = deriveTunnelName(resolvedAdminLabel, domain);
1436
- log(`branch=new-tunnel zoneName=${domain} tunnelName=${derivedName}`);
1437
- const accountExisting = allTunnels.find((t) => t.name === derivedName);
1438
- if (accountExisting && !hasBrandCredsFor(accountExisting.id)) {
1439
- log(`collision type=tunnel-name label=${resolvedAdminLabel} existingTunnelId=${accountExisting.id}`);
1440
- return result({
1441
- status: "tunnel-name-taken",
1442
- message: `The address ${resolvedAdminLabel}.${domain} is already in use by another device. Pick a different one.`,
1443
- data: {
1444
- domain,
1445
- suggestedLabel: `${resolvedAdminLabel}2`,
1446
- existingTunnelId: accountExisting.id,
1447
- field: "admin",
1448
- render: {
1449
- name: "tunnel-route-picker",
1450
- data: {
1451
- domain,
1452
- defaultAdmin: "",
1453
- defaultPublic: resolvedPublicLabel ?? "",
1454
- error: { field: "admin", message: `That address is already taken — pick a different one.` },
1455
- submitLabel: "Set up addresses",
1456
- },
1457
- },
1458
- },
1459
- });
1460
- }
1461
- try {
1462
- const created = cloudflared.createTunnelCli(derivedName);
1463
- tunnelId = created.tunnelId;
1464
- tunnelName = created.tunnelName;
1465
- credentialsPath = created.credentialsPath;
1466
- }
1467
- catch (err) {
1468
- const msg = err instanceof Error ? err.message : String(err);
1469
- log(`tunnel creation failed: ${msg}`);
1470
- return result({ status: "error", message: `Could not create the tunnel: ${msg}` });
1471
- }
1472
- }
1473
- }
1474
- if (!tunnelId || !tunnelName) {
1475
- return result({ status: "error", message: "Tunnel identity could not be resolved." });
1476
- }
1477
- // Fallback credentials path when reusing a tunnel picked from the UI:
1478
- // createTunnelCli already copies from ~/.cloudflared to the brand dir,
1479
- // but that runs only in the create branch. For the "reuse existing"
1480
- // branch, derive the same brand-dir path here.
1481
- if (!credentialsPath) {
1482
- credentialsPath = join(homedir(), cloudflared.loadBrand().configDir, "cloudflared", `${tunnelId}.json`);
1483
- }
1484
- // ── Step 6: DNS routing + config (idempotent every call) ────────
1485
- for (const h of hostnames) {
1486
- const check = await cloudflared.checkDnsCollision(h, tunnelId);
1487
- if (check.collision) {
1488
- const offendingField = h === adminHostname ? "admin" : "public";
1489
- log(`collision type=label hostname=${h} existingTunnelId=${check.existingTunnelId}`);
1490
- return result({
1491
- status: "label-taken",
1492
- message: `The address ${h} already points to a different tunnel. Pick a different ${offendingField} label.`,
1493
- data: {
1494
- domain,
1495
- field: offendingField,
1496
- existingTunnelId: check.existingTunnelId ?? undefined,
1497
- render: {
1498
- name: "tunnel-route-picker",
1499
- data: {
1500
- domain,
1501
- defaultAdmin: offendingField === "admin" ? "" : resolvedAdminLabel,
1502
- defaultPublic: offendingField === "public" ? "" : (resolvedPublicLabel ?? ""),
1503
- error: { field: offendingField, message: `That address is already in use by another device.` },
1504
- submitLabel: "Set up addresses",
1505
- },
1506
- },
1507
- },
1508
- });
1509
- }
1510
- }
1511
- const configPath = cloudflared.writeLocalConfig(tunnelId, credentialsPath, hostnames, platformPort);
1512
- cloudflared.saveTunnelIdentity({
1513
- tunnelId,
1514
- tunnelName,
1515
- domain,
1516
- configPath,
1517
- credentialsPath,
1518
- adminHostname,
1519
- publicHostname,
1520
- });
1521
- for (const h of hostnames) {
1522
- await cloudflared.routeDnsCli(tunnelId, h);
1523
- }
1524
- log(`ui=tunnel-route-picker submitted adminLabel=${resolvedAdminLabel} publicLabel=${resolvedPublicLabel ?? "null"}`);
1525
- // ── Step 7: Remote auth check ───────────────────────────────────
1526
- // scrypt hashing on Pi ARM takes 2-5s after form submission —
1527
- // wait before checking to avoid a false negative on every first attempt
1528
- log("waiting 3s for scrypt hash before checking remote auth");
1529
- await new Promise((r) => setTimeout(r, 3000));
1530
- log("checking remote auth");
1531
- try {
1532
- const res = await fetch(`http://127.0.0.1:${platformPort}/api/remote-auth/status`, {
1533
- signal: AbortSignal.timeout(5000),
1534
- });
1535
- const body = (await res.json());
1536
- if (!body.configured) {
1537
- const setupUrl = `http://${hostname()}.local:${platformPort}/__remote-auth/setup`;
1538
- log("remote auth not configured");
1539
- return result({
1540
- status: "awaiting_password",
1541
- message: `Before enabling remote access, you need to set a password that protects your admin interface over the internet.\n\nOpen this URL in your own browser (not the one in the chat) and set a password:\n\`${setupUrl}\`\n\nChoose something strong — at least 8 characters with a number and a special character. Let me know when you've submitted it.`,
1542
- data: { setupUrl },
1543
- });
1544
- }
1545
- log("remote auth configured");
1546
- }
1547
- catch {
1548
- log("remote auth check failed — platform may not be running");
1549
- return result({
1550
- status: "error",
1551
- message: "Could not verify remote access password — the platform may not be running. Make sure it's started and try again.",
1552
- });
1553
- }
1554
- // ── Step 8: Tunnel enable ───────────────────────────────────────
1555
- const tunnelState = cloudflared.getPersistedState();
1556
- if (!tunnelState) {
1557
- return result({ status: "error", message: "Tunnel state lost — please try again." });
1558
- }
1559
- const status = await cloudflared.getStatus(domain);
1560
- if (!status.running) {
1561
- log("tunnel not running — starting");
1562
- try {
1563
- cloudflared.startTunnel({
1564
- tunnelId: tunnelState.tunnelId,
1565
- tunnelName: tunnelState.tunnelName,
1566
- domain,
1567
- configPath: tunnelState.configPath,
1568
- credentialsPath: tunnelState.credentialsPath,
1569
- });
1570
- // Wait to confirm it didn't crash immediately
1571
- for (let i = 0; i < 10; i++) {
1572
- await new Promise((r) => setTimeout(r, 500));
1573
- const check = await cloudflared.getStatus(domain);
1574
- if (!check.running) {
1575
- const brand = cloudflared.loadBrand();
1576
- log("tunnel process exited immediately");
1577
- return result({
1578
- status: "error",
1579
- message: `The tunnel started but exited immediately. Check ~/${brand.configDir}/logs/cloudflared.log for details.`,
1580
- });
1581
- }
1582
- }
1583
- // Verify admin URL is reachable through Cloudflare edge
1584
- const verifyUrl = `https://${adminHostname}`;
1585
- let verified = false;
1586
- let lastStatus = null;
1587
- for (let attempt = 0; attempt < 6; attempt++) {
1588
- if (attempt > 0)
1589
- await new Promise((r) => setTimeout(r, 5000));
1590
- try {
1591
- const res = await fetch(verifyUrl, {
1592
- signal: AbortSignal.timeout(10000),
1593
- redirect: "manual",
1594
- });
1595
- lastStatus = res.status;
1596
- if (res.status !== 530) {
1597
- verified = true;
1598
- log(`verified url=${verifyUrl} status=${res.status}`);
1599
- break;
1600
- }
1601
- }
1602
- catch {
1603
- // retry
1604
- }
1605
- }
1606
- if (!verified) {
1607
- log(`verified url=${verifyUrl} status=${lastStatus ?? "timeout"} result=unreachable`);
1608
- return result({
1609
- status: "error",
1610
- message: `The tunnel is running but the admin URL is not reachable yet. This usually resolves in a few minutes as DNS propagates. Try again shortly.`,
1611
- });
1612
- }
1613
- log("tunnel started and verified");
1614
- }
1615
- catch (err) {
1616
- const msg = err instanceof Error ? err.message : String(err);
1617
- log(`tunnel start failed: ${msg}`);
1618
- return result({ status: "error", message: `Could not start the tunnel: ${msg}` });
1619
- }
1620
- }
1621
- else {
1622
- log(`tunnel already running (PID ${status.pid})`);
1623
- }
1624
- // ── Complete ────────────────────────────────────────────────────
1625
- const adminUrl = `https://${adminHostname}`;
1626
- const publicUrl = publicHostname ? `https://${publicHostname}` : undefined;
1627
- log(`complete — admin: ${adminUrl}${publicUrl ? `, public: ${publicUrl}` : ""}`);
1628
- const urlLines = publicUrl
1629
- ? `Admin: ${adminUrl}\nPublic: ${publicUrl}`
1630
- : `Admin: ${adminUrl}`;
1631
- const reachableNote = publicUrl
1632
- ? "Both URLs are live and reachable."
1633
- : "The admin URL is live and reachable.";
1634
- return result({
1635
- status: "complete",
1636
- message: `Remote access is set up and verified.\n\n${urlLines}\n\n${reachableNote} The tunnel auto-starts on device reboot.`,
1637
- data: { adminUrl, publicUrl },
1638
- });
1639
- }
1640
- catch (err) {
1641
- const msg = err instanceof Error ? err.message : String(err);
1642
- log(`unexpected error: ${msg}`);
1643
- return result({ status: "error", message: `Setup failed: ${msg}` });
1644
- }
713
+ main().catch((err) => {
714
+ console.error(`[cloudflare-mcp] fatal: ${err}`);
715
+ process.exit(1);
1645
716
  });
1646
- function result(r) {
1647
- return {
1648
- content: [{ type: "text", text: JSON.stringify(r, null, 2) }],
1649
- };
1650
- }
1651
- const transport = new StdioServerTransport();
1652
- await server.connect(transport);
1653
717
  //# sourceMappingURL=index.js.map