@rubytech/create-realagent 1.0.612 → 1.0.614

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rubytech/create-realagent",
3
- "version": "1.0.612",
3
+ "version": "1.0.614",
4
4
  "description": "Install Real Agent — Built for agents. By agents.",
5
5
  "bin": {
6
6
  "create-realagent": "./dist/index.js"
@@ -18,7 +18,7 @@ tools:
18
18
 
19
19
  # Cloudflare Tunnel Setup
20
20
 
21
- Guides through setting up a Cloudflare Tunnel so customers can reach this assistant via a custom domain (e.g., `chat.mybusiness.com`). All operations are deterministic MCP tool callsauth via `tunnel-login` (one-click OAuth) or `cf-set-token` (paste token), zone/tunnel/DNS via the official Cloudflare SDK. Browser-specialist is navigation-only (screenshots + page state) — no `browser_evaluate`, no dashboard API scripts.
21
+ Guides through setting up a Cloudflare Tunnel so customers can reach this assistant via a custom domain (e.g., `chat.mybusiness.com`). `cloudflare-setup` is the single orchestrator — a UI-driven state machine that prompts the user for tunnel/zone selections and admin/public addresses via rendered components (`single-select`, `tunnel-route-picker`). Tunnel names are unique per customer (derived from the chosen admin label + domain) so multiple customers can coexist on a shared zone like `maxy.bot`. The agent never synthesises subdomains from free text every label comes from a component submission. Auth via `tunnel-login` (one-click OAuth) or `cf-set-token` (paste token). Browser-specialist is navigation-only — no dashboard forms.
22
22
 
23
23
  ## When to activate
24
24
 
@@ -3,7 +3,9 @@ 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 { hostname } from "node:os";
6
+ import { join } from "node:path";
7
+ import { hostname, homedir } from "node:os";
8
+ import { existsSync } from "node:fs";
7
9
  import * as cloudflared from "./lib/cloudflared.js";
8
10
  const server = new McpServer({
9
11
  name: "cloudflare",
@@ -933,23 +935,35 @@ server.tool("dns-lookup", "Resolve DNS records for a hostname. Replaces dig/nslo
933
935
  };
934
936
  }
935
937
  });
936
- server.tool("cloudflare-setup", "Deterministic state machine for full Cloudflare Tunnel setup. Checks current state, advances as far as possible, returns structured result. Call once, relay the message to the user. When the user confirms an action, call again — the tool picks up where it left off. Zero agent judgment required. adminSubdomain defaults to 'admin', publicSubdomain defaults to 'public'. Set publicSubdomain to skip the public endpoint.", {
937
- domain: z
938
+ const LABEL_RE = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/;
939
+ function sanitizedDomain(domain) {
940
+ return domain.replace(/\./g, "-");
941
+ }
942
+ function deriveTunnelName(adminLabel, domain) {
943
+ return `${adminLabel}-${sanitizedDomain(domain)}`;
944
+ }
945
+ function hasBrandCredsFor(tunnelId) {
946
+ const path = join(homedir(), cloudflared.loadBrand().configDir, "cloudflared", `${tunnelId}.json`);
947
+ return existsSync(path);
948
+ }
949
+ 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.", {
950
+ selectedTunnelId: z
938
951
  .string()
939
- .describe("The bare domain (e.g. 'maxy.bot')."),
940
- adminSubdomain: z
952
+ .optional()
953
+ .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."),
954
+ selectedZoneName: z
941
955
  .string()
942
- .min(1)
943
956
  .optional()
944
- .describe("Subdomain for admin interface (default: 'admin'). Creates {adminSubdomain}.{domain}."),
945
- publicSubdomain: z
957
+ .describe("Zone name from the single-select submission. Pass only when the last component rendered was the zone selection list."),
958
+ adminLabel: z
959
+ .string()
960
+ .optional()
961
+ .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."),
962
+ publicLabel: z
946
963
  .string()
947
- .min(1)
948
964
  .optional()
949
- .describe("Subdomain for public chat (default: 'public'). Creates {publicSubdomain}.{domain}. Omit or set explicitly to skip the public endpoint."),
950
- }, async ({ domain, adminSubdomain: adminSubParam, publicSubdomain: publicSubParam }) => {
951
- const adminSubdomain = adminSubParam ?? "admin";
952
- const publicSubdomain = publicSubParam ?? "public";
965
+ .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."),
966
+ }, async ({ selectedTunnelId, selectedZoneName, adminLabel, publicLabel }) => {
953
967
  const log = (msg) => console.error(`[cloudflare-setup] ${msg}`);
954
968
  try {
955
969
  // ── Step 1: cloudflared binary ──────────────────────────────────
@@ -1034,116 +1048,373 @@ server.tool("cloudflare-setup", "Deterministic state machine for full Cloudflare
1034
1048
  data: { authUrl },
1035
1049
  });
1036
1050
  }
1037
- // ── Step 3: Zone (domain) check ─────────────────────────────────
1038
- log(`checking zone for ${domain}`);
1039
- let zones;
1051
+ // ── Step 3: Discover state — tunnel, zone, domain ───────────────
1052
+ const existingState = cloudflared.getPersistedState();
1053
+ const platformPort = parseInt(process.env.PLATFORM_PORT ?? "19200", 10);
1054
+ // If state already has a tunnel identity with hostnames, labels are
1055
+ // reconstructed from the persisted hostnames on every re-entry where
1056
+ // the agent doesn't supply fresh labels. This is how the flow
1057
+ // survives the awaiting_password round-trip.
1058
+ const stateAdminLabel = existingState?.adminHostname && existingState.domain
1059
+ ? existingState.adminHostname.replace(`.${existingState.domain}`, "")
1060
+ : null;
1061
+ const statePublicLabel = existingState?.publicHostname && existingState?.domain
1062
+ ? existingState.publicHostname.replace(`.${existingState.domain}`, "")
1063
+ : null;
1064
+ let allTunnels = [];
1065
+ let allZones = [];
1040
1066
  try {
1041
- zones = await cloudflared.listZones();
1067
+ [allTunnels, allZones] = await Promise.all([
1068
+ cloudflared.listTunnelsOnAccount(),
1069
+ cloudflared.listZones(),
1070
+ ]);
1042
1071
  }
1043
1072
  catch (err) {
1044
1073
  const msg = err instanceof Error ? err.message : String(err);
1045
- log(`zone list failed: ${msg}`);
1046
- return result({ status: "error", message: `Could not check your domains: ${msg}` });
1074
+ log(`discovery failed: ${msg}`);
1075
+ return result({
1076
+ status: "error",
1077
+ message: `Could not read your Cloudflare account: ${msg}`,
1078
+ });
1047
1079
  }
1048
- const zone = zones.find((z) => z.name === domain);
1049
- if (!zone) {
1050
- // Zone doesn't exist try to add it
1051
- log(`zone ${domain} not found adding`);
1052
- try {
1053
- const created = await cloudflared.createZone(domain);
1054
- if (created.status === "pending") {
1055
- log(`zone ${domain} added but pending — nameservers: ${created.nameservers.join(", ")}`);
1080
+ log(`entry tunnelsOnAccount=${allTunnels.length} zonesOnAccount=${allZones.length}`);
1081
+ const activeZones = allZones.filter((z) => z.status === "active");
1082
+ const pendingZones = allZones.filter((z) => z.status === "pending");
1083
+ // Resolve domain precedence: persisted state > tunnel selection > zone selection > auto-only-active
1084
+ let domain = existingState?.domain ?? null;
1085
+ if (!domain && selectedTunnelId && selectedTunnelId !== "__new__") {
1086
+ const tunnel = allTunnels.find((t) => t.id === selectedTunnelId);
1087
+ if (!tunnel) {
1088
+ log(`selectedTunnelId ${selectedTunnelId} not found on account`);
1089
+ return result({
1090
+ status: "error",
1091
+ message: "That tunnel is no longer on your account. Let me check again.",
1092
+ });
1093
+ }
1094
+ const zoneName = await cloudflared.findZoneHostingTunnel(selectedTunnelId);
1095
+ if (!zoneName) {
1096
+ log(`selected tunnel ${selectedTunnelId} has no zone hosting it — falling back to zone selection`);
1097
+ // Fall through to zone-selection branch below
1098
+ }
1099
+ else {
1100
+ domain = zoneName;
1101
+ log(`branch=existing-tunnel tunnelId=${selectedTunnelId} domain=${domain}`);
1102
+ }
1103
+ }
1104
+ if (!domain && selectedZoneName) {
1105
+ const zone = activeZones.find((z) => z.name === selectedZoneName);
1106
+ if (!zone) {
1107
+ log(`selectedZoneName ${selectedZoneName} not active on account`);
1108
+ return result({
1109
+ status: "error",
1110
+ message: "That domain is no longer Active on your account. Let me check again.",
1111
+ });
1112
+ }
1113
+ domain = selectedZoneName;
1114
+ }
1115
+ // No prior state, no selection — prompt the user for a tunnel
1116
+ // (if account has any) or a zone (if account has >1 active zone).
1117
+ if (!domain) {
1118
+ if (allTunnels.length > 0 && !selectedTunnelId) {
1119
+ log(`awaiting_tunnel_selection tunnelsOnAccount=${allTunnels.length}`);
1120
+ const options = [
1121
+ { value: "__new__", label: "Create a new tunnel (recommended)" },
1122
+ ...allTunnels.map((t) => ({ value: t.id, label: t.name })),
1123
+ ];
1124
+ return result({
1125
+ status: "awaiting_tunnel_selection",
1126
+ message: "Pick a tunnel for this device. For a fresh setup, choose Create a new tunnel.",
1127
+ data: {
1128
+ tunnels: allTunnels.map((t) => ({ id: t.id, name: t.name })),
1129
+ render: {
1130
+ name: "single-select",
1131
+ data: {
1132
+ title: "Tunnel",
1133
+ description: "Each device uses its own tunnel. Creating a new one is the normal choice.",
1134
+ options,
1135
+ submitLabel: "Use this tunnel",
1136
+ submitMessage: '{"selectedTunnelId":"{{value}}"}',
1137
+ },
1138
+ },
1139
+ },
1140
+ });
1141
+ }
1142
+ // From here: selectedTunnelId is "__new__" or undefined (no tunnels on account).
1143
+ if (activeZones.length === 0) {
1144
+ if (pendingZones.length > 0) {
1145
+ const z = pendingZones[0];
1146
+ log(`awaiting_nameservers zone=${z.name}`);
1056
1147
  return result({
1057
1148
  status: "awaiting_nameservers",
1058
- message: `Your domain has been added to Cloudflare but needs to be activated. Update the nameservers at your registrar to:\n\n• ${created.nameservers[0]}\n• ${created.nameservers[1]}\n\nThis tells your domain to use Cloudflare. The change can take a few minutes to a few hours to take effect. Let me know when you've updated them.`,
1059
- data: { nameservers: created.nameservers },
1149
+ 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.`,
1150
+ data: { nameservers: z.nameservers },
1060
1151
  });
1061
1152
  }
1062
- // Active — continue
1063
- log(`zone ${domain} added and active`);
1153
+ log(`branch=zone-add-fallback reason=noZones`);
1154
+ return result({
1155
+ status: "awaiting_zone_add_dashboard",
1156
+ 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.",
1157
+ data: { dashboardUrl: "https://dash.cloudflare.com" },
1158
+ });
1064
1159
  }
1065
- catch (err) {
1066
- const msg = err instanceof Error ? err.message : String(err);
1067
- log(`zone creation failed: ${msg}`);
1160
+ if (activeZones.length > 1) {
1161
+ log(`awaiting_zone_selection activeZones=${activeZones.length}`);
1068
1162
  return result({
1069
- status: "error",
1070
- message: `Could not add your domain to Cloudflare: ${msg}. You may need to add it manually at cloudflare.com.`,
1163
+ status: "awaiting_zone_selection",
1164
+ message: "Multiple domains on your Cloudflare account. Pick the one to use for this device.",
1165
+ data: {
1166
+ zones: activeZones.map((z) => ({ name: z.name, status: z.status })),
1167
+ render: {
1168
+ name: "single-select",
1169
+ data: {
1170
+ title: "Domain",
1171
+ options: activeZones.map((z) => ({ value: z.name, label: z.name })),
1172
+ submitLabel: "Use this domain",
1173
+ submitMessage: '{"selectedZoneName":"{{value}}"}',
1174
+ },
1175
+ },
1176
+ },
1071
1177
  });
1072
1178
  }
1179
+ // Exactly one active zone — use it.
1180
+ domain = activeZones[0].name;
1181
+ log(`auto-selected only active zone: ${domain}`);
1073
1182
  }
1074
- else if (zone.status === "pending") {
1075
- log(`zone ${domain} is pending nameservers: ${zone.nameservers.join(", ")}`);
1183
+ // Verify the resolved zone is active (guard against state with a since-pending zone)
1184
+ const zone = allZones.find((z) => z.name === domain);
1185
+ if (zone && zone.status === "pending") {
1186
+ log(`zone ${domain} is pending — returning awaiting_nameservers`);
1076
1187
  return result({
1077
1188
  status: "awaiting_nameservers",
1078
- message: `Your domain is on Cloudflare but not yet active. 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.`,
1189
+ 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.`,
1079
1190
  data: { nameservers: zone.nameservers },
1080
1191
  });
1081
1192
  }
1082
- else {
1083
- log(`zone ${domain} is ${zone.status}`);
1193
+ // ── Step 4: Labels ──────────────────────────────────────────────
1194
+ // Resolution precedence: args > persisted state hostnames.
1195
+ const resolvedAdminLabel = adminLabel ?? stateAdminLabel;
1196
+ const resolvedPublicLabel = publicLabel ?? statePublicLabel ?? null;
1197
+ if (!resolvedAdminLabel) {
1198
+ log(`ui=tunnel-route-picker rendered tunnelDomain=${domain}`);
1199
+ return result({
1200
+ status: "awaiting_labels",
1201
+ message: `Pick a short name for your admin address on ${domain}. You can also pick one for a public address, or leave it empty.`,
1202
+ data: {
1203
+ domain,
1204
+ render: {
1205
+ name: "tunnel-route-picker",
1206
+ data: {
1207
+ domain,
1208
+ title: "Pick your addresses",
1209
+ description: "Your admin address is required. Public is optional and can be added later.",
1210
+ submitLabel: "Set up addresses",
1211
+ },
1212
+ },
1213
+ },
1214
+ });
1084
1215
  }
1085
- // ── Step 4: Tunnel creation ─────────────────────────────────────
1086
- log("checking tunnel");
1087
- const existingState = cloudflared.getPersistedState();
1088
- const platformPort = parseInt(process.env.PLATFORM_PORT ?? "19200", 10);
1089
- // Build hostname list from subdomains
1090
- const adminHostname = `${adminSubdomain}.${domain}`;
1091
- const publicHostname = publicSubdomain ? `${publicSubdomain}.${domain}` : null;
1216
+ // Defense-in-depth label validation (UI already filters, but tool
1217
+ // is authoritative — an agent misroute must fail loudly, not silently).
1218
+ if (!LABEL_RE.test(resolvedAdminLabel)) {
1219
+ log(`invalid admin label: ${resolvedAdminLabel}`);
1220
+ return result({
1221
+ status: "label-taken",
1222
+ message: "Admin label is not a valid DNS label. Pick something that is lowercase letters, numbers, and hyphens.",
1223
+ data: {
1224
+ domain,
1225
+ field: "admin",
1226
+ render: {
1227
+ name: "tunnel-route-picker",
1228
+ data: {
1229
+ domain,
1230
+ title: "Pick your addresses",
1231
+ defaultAdmin: "",
1232
+ defaultPublic: resolvedPublicLabel ?? "",
1233
+ error: { field: "admin", message: "Not a valid address — use lowercase letters, numbers, and hyphens." },
1234
+ submitLabel: "Set up addresses",
1235
+ },
1236
+ },
1237
+ },
1238
+ });
1239
+ }
1240
+ if (resolvedPublicLabel !== null && resolvedPublicLabel !== "" && !LABEL_RE.test(resolvedPublicLabel)) {
1241
+ log(`invalid public label: ${resolvedPublicLabel}`);
1242
+ return result({
1243
+ status: "label-taken",
1244
+ message: "Public label is not a valid DNS label.",
1245
+ data: {
1246
+ domain,
1247
+ field: "public",
1248
+ render: {
1249
+ name: "tunnel-route-picker",
1250
+ data: {
1251
+ domain,
1252
+ defaultAdmin: resolvedAdminLabel,
1253
+ defaultPublic: "",
1254
+ error: { field: "public", message: "Not a valid address." },
1255
+ submitLabel: "Set up addresses",
1256
+ },
1257
+ },
1258
+ },
1259
+ });
1260
+ }
1261
+ if (resolvedPublicLabel && resolvedAdminLabel === resolvedPublicLabel) {
1262
+ log(`admin and public labels identical: ${resolvedAdminLabel}`);
1263
+ return result({
1264
+ status: "label-taken",
1265
+ message: "Admin and public addresses must differ.",
1266
+ data: {
1267
+ domain,
1268
+ field: "public",
1269
+ render: {
1270
+ name: "tunnel-route-picker",
1271
+ data: {
1272
+ domain,
1273
+ defaultAdmin: resolvedAdminLabel,
1274
+ defaultPublic: "",
1275
+ error: { field: "public", message: "Public must differ from admin." },
1276
+ submitLabel: "Set up addresses",
1277
+ },
1278
+ },
1279
+ },
1280
+ });
1281
+ }
1282
+ const adminHostname = `${resolvedAdminLabel}.${domain}`;
1283
+ const publicHostname = resolvedPublicLabel ? `${resolvedPublicLabel}.${domain}` : null;
1092
1284
  const hostnames = publicHostname ? [adminHostname, publicHostname] : [adminHostname];
1093
- if (!existingState?.tunnelId) {
1094
- log(`no tunnel creating with hostnames=${JSON.stringify(hostnames)}`);
1095
- const tunnelName = domain.replace(/\./g, "-");
1096
- try {
1097
- const tunnel = cloudflared.createTunnelCli(tunnelName);
1098
- // Collision guard
1099
- for (const h of hostnames) {
1100
- const check = await cloudflared.checkDnsCollision(h, tunnel.tunnelId);
1101
- if (check.collision) {
1102
- log(`collision detected: ${h} → tunnel ${check.existingTunnelId}`);
1103
- return result({
1104
- status: "error",
1105
- message: `DNS collision: ${h} already points to a different tunnel. This hostname belongs to another device. Choose a different subdomain.`,
1106
- });
1107
- }
1285
+ // ── Step 5: Resolve or create tunnel (idempotent on re-entry) ───
1286
+ let tunnelId = existingState?.tunnelId ?? null;
1287
+ let tunnelName = existingState?.tunnelName ?? null;
1288
+ let credentialsPath = existingState?.credentialsPath ?? null;
1289
+ if (!tunnelId) {
1290
+ if (selectedTunnelId && selectedTunnelId !== "__new__") {
1291
+ // User picked an existing tunnel — reuse its identity.
1292
+ const selected = allTunnels.find((t) => t.id === selectedTunnelId);
1293
+ if (!selected) {
1294
+ return result({
1295
+ status: "error",
1296
+ message: "Selected tunnel not found on account.",
1297
+ });
1108
1298
  }
1109
- const configPath = cloudflared.writeLocalConfig(tunnel.tunnelId, tunnel.credentialsPath, hostnames, platformPort);
1110
- cloudflared.saveTunnelIdentity({
1111
- tunnelId: tunnel.tunnelId,
1112
- tunnelName: tunnel.tunnelName,
1113
- domain,
1114
- configPath,
1115
- credentialsPath: tunnel.credentialsPath,
1116
- adminHostname,
1117
- publicHostname,
1118
- });
1119
- // Route DNS for each hostname
1120
- for (const h of hostnames) {
1121
- cloudflared.routeDnsCli(tunnel.tunnelId, h);
1299
+ if (!hasBrandCredsFor(selected.id)) {
1300
+ // Cannot reuse without credentials — this tunnel belongs to
1301
+ // another device on the same account. Surface structured error.
1302
+ log(`collision type=tunnel-name label=${resolvedAdminLabel} existingTunnelId=${selected.id}`);
1303
+ return result({
1304
+ status: "tunnel-name-taken",
1305
+ message: `The tunnel "${selected.name}" belongs to another device. Pick a different label for this one.`,
1306
+ data: {
1307
+ suggestedLabel: `${resolvedAdminLabel}2`,
1308
+ existingTunnelId: selected.id,
1309
+ render: {
1310
+ name: "tunnel-route-picker",
1311
+ data: {
1312
+ domain,
1313
+ defaultAdmin: "",
1314
+ defaultPublic: resolvedPublicLabel ?? "",
1315
+ error: { field: "admin", message: `That address is already taken. Pick a different one.` },
1316
+ submitLabel: "Set up addresses",
1317
+ },
1318
+ },
1319
+ },
1320
+ });
1122
1321
  }
1123
- log(`tunnel ${tunnel.tunnelId} created, DNS routed for ${JSON.stringify(hostnames)}`);
1322
+ tunnelId = selected.id;
1323
+ tunnelName = selected.name;
1124
1324
  }
1125
- catch (err) {
1126
- const msg = err instanceof Error ? err.message : String(err);
1127
- log(`tunnel creation failed: ${msg}`);
1128
- return result({ status: "error", message: `Could not create the tunnel: ${msg}` });
1325
+ else {
1326
+ // Create new tunnel name is derived from admin label + domain.
1327
+ const derivedName = deriveTunnelName(resolvedAdminLabel, domain);
1328
+ log(`branch=new-tunnel zoneName=${domain} tunnelName=${derivedName}`);
1329
+ const accountExisting = allTunnels.find((t) => t.name === derivedName);
1330
+ if (accountExisting && !hasBrandCredsFor(accountExisting.id)) {
1331
+ log(`collision type=tunnel-name label=${resolvedAdminLabel} existingTunnelId=${accountExisting.id}`);
1332
+ return result({
1333
+ status: "tunnel-name-taken",
1334
+ message: `The address ${resolvedAdminLabel}.${domain} is already in use by another device. Pick a different one.`,
1335
+ data: {
1336
+ domain,
1337
+ suggestedLabel: `${resolvedAdminLabel}2`,
1338
+ existingTunnelId: accountExisting.id,
1339
+ field: "admin",
1340
+ render: {
1341
+ name: "tunnel-route-picker",
1342
+ data: {
1343
+ domain,
1344
+ defaultAdmin: "",
1345
+ defaultPublic: resolvedPublicLabel ?? "",
1346
+ error: { field: "admin", message: `That address is already taken — pick a different one.` },
1347
+ submitLabel: "Set up addresses",
1348
+ },
1349
+ },
1350
+ },
1351
+ });
1352
+ }
1353
+ try {
1354
+ const created = cloudflared.createTunnelCli(derivedName);
1355
+ tunnelId = created.tunnelId;
1356
+ tunnelName = created.tunnelName;
1357
+ credentialsPath = created.credentialsPath;
1358
+ }
1359
+ catch (err) {
1360
+ const msg = err instanceof Error ? err.message : String(err);
1361
+ log(`tunnel creation failed: ${msg}`);
1362
+ return result({ status: "error", message: `Could not create the tunnel: ${msg}` });
1363
+ }
1129
1364
  }
1130
1365
  }
1131
- else {
1132
- log(`tunnel exists: ${existingState.tunnelId}`);
1133
- // Ensure domain and hostnames are persisted in state (may be stale)
1134
- if (existingState.domain !== domain || existingState.adminHostname !== adminHostname) {
1135
- cloudflared.saveTunnelIdentity({
1136
- tunnelId: existingState.tunnelId,
1137
- tunnelName: existingState.tunnelName,
1138
- domain,
1139
- configPath: existingState.configPath,
1140
- credentialsPath: existingState.credentialsPath,
1141
- adminHostname,
1142
- publicHostname,
1366
+ if (!tunnelId || !tunnelName) {
1367
+ return result({ status: "error", message: "Tunnel identity could not be resolved." });
1368
+ }
1369
+ // Fallback credentials path when reusing a tunnel picked from the UI:
1370
+ // createTunnelCli already copies from ~/.cloudflared to the brand dir,
1371
+ // but that runs only in the create branch. For the "reuse existing"
1372
+ // branch, derive the same brand-dir path here.
1373
+ if (!credentialsPath) {
1374
+ credentialsPath = join(homedir(), cloudflared.loadBrand().configDir, "cloudflared", `${tunnelId}.json`);
1375
+ }
1376
+ // ── Step 6: DNS routing + config (idempotent every call) ────────
1377
+ for (const h of hostnames) {
1378
+ const check = await cloudflared.checkDnsCollision(h, tunnelId);
1379
+ if (check.collision) {
1380
+ const offendingField = h === adminHostname ? "admin" : "public";
1381
+ log(`collision type=label hostname=${h} existingTunnelId=${check.existingTunnelId}`);
1382
+ return result({
1383
+ status: "label-taken",
1384
+ message: `The address ${h} already points to a different tunnel. Pick a different ${offendingField} label.`,
1385
+ data: {
1386
+ domain,
1387
+ field: offendingField,
1388
+ existingTunnelId: check.existingTunnelId ?? undefined,
1389
+ render: {
1390
+ name: "tunnel-route-picker",
1391
+ data: {
1392
+ domain,
1393
+ defaultAdmin: offendingField === "admin" ? "" : resolvedAdminLabel,
1394
+ defaultPublic: offendingField === "public" ? "" : (resolvedPublicLabel ?? ""),
1395
+ error: { field: offendingField, message: `That address is already in use by another device.` },
1396
+ submitLabel: "Set up addresses",
1397
+ },
1398
+ },
1399
+ },
1143
1400
  });
1144
1401
  }
1145
1402
  }
1146
- // ── Step 5: Remote auth check ───────────────────────────────────
1403
+ const configPath = cloudflared.writeLocalConfig(tunnelId, credentialsPath, hostnames, platformPort);
1404
+ cloudflared.saveTunnelIdentity({
1405
+ tunnelId,
1406
+ tunnelName,
1407
+ domain,
1408
+ configPath,
1409
+ credentialsPath,
1410
+ adminHostname,
1411
+ publicHostname,
1412
+ });
1413
+ for (const h of hostnames) {
1414
+ cloudflared.routeDnsCli(tunnelId, h);
1415
+ }
1416
+ log(`ui=tunnel-route-picker submitted adminLabel=${resolvedAdminLabel} publicLabel=${resolvedPublicLabel ?? "null"}`);
1417
+ // ── Step 7: Remote auth check ───────────────────────────────────
1147
1418
  // scrypt hashing on Pi ARM takes 2-5s after form submission —
1148
1419
  // wait before checking to avoid a false negative on every first attempt
1149
1420
  log("waiting 3s for scrypt hash before checking remote auth");
@@ -1172,7 +1443,7 @@ server.tool("cloudflare-setup", "Deterministic state machine for full Cloudflare
1172
1443
  message: "Could not verify remote access password — the platform may not be running. Make sure it's started and try again.",
1173
1444
  });
1174
1445
  }
1175
- // ── Step 6: Tunnel enable ───────────────────────────────────────
1446
+ // ── Step 8: Tunnel enable ───────────────────────────────────────
1176
1447
  const tunnelState = cloudflared.getPersistedState();
1177
1448
  if (!tunnelState) {
1178
1449
  return result({ status: "error", message: "Tunnel state lost — please try again." });
@@ -1204,6 +1475,7 @@ server.tool("cloudflare-setup", "Deterministic state machine for full Cloudflare
1204
1475
  // Verify admin URL is reachable through Cloudflare edge
1205
1476
  const verifyUrl = `https://${adminHostname}`;
1206
1477
  let verified = false;
1478
+ let lastStatus = null;
1207
1479
  for (let attempt = 0; attempt < 6; attempt++) {
1208
1480
  if (attempt > 0)
1209
1481
  await new Promise((r) => setTimeout(r, 5000));
@@ -1212,8 +1484,10 @@ server.tool("cloudflare-setup", "Deterministic state machine for full Cloudflare
1212
1484
  signal: AbortSignal.timeout(10000),
1213
1485
  redirect: "manual",
1214
1486
  });
1487
+ lastStatus = res.status;
1215
1488
  if (res.status !== 530) {
1216
1489
  verified = true;
1490
+ log(`verified url=${verifyUrl} status=${res.status}`);
1217
1491
  break;
1218
1492
  }
1219
1493
  }
@@ -1222,7 +1496,7 @@ server.tool("cloudflare-setup", "Deterministic state machine for full Cloudflare
1222
1496
  }
1223
1497
  }
1224
1498
  if (!verified) {
1225
- log(`tunnel running but ${verifyUrl} not reachable (HTTP 530)`);
1499
+ log(`verified url=${verifyUrl} status=${lastStatus ?? "timeout"} result=unreachable`);
1226
1500
  return result({
1227
1501
  status: "error",
1228
1502
  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.`,