@rubytech/create-realagent 1.0.616 → 1.0.618

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.
Files changed (28) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/config/brand.json +0 -4
  3. package/payload/platform/package-lock.json +1547 -1
  4. package/payload/platform/plugins/admin/PLUGIN.md +1 -0
  5. package/payload/platform/plugins/admin/hooks/webfetch-preflight.mjs +363 -0
  6. package/payload/platform/plugins/admin/skills/stream-log-review/SKILL.md +4 -1
  7. package/payload/platform/plugins/cloudflare/PLUGIN.md +2 -2
  8. package/payload/platform/plugins/cloudflare/mcp/__tests__/auth-binding.test.ts +0 -1
  9. package/payload/platform/plugins/cloudflare/mcp/dist/index.js +158 -99
  10. package/payload/platform/plugins/cloudflare/mcp/dist/index.js.map +1 -1
  11. package/payload/platform/plugins/cloudflare/mcp/dist/lib/cloudflared.d.ts +103 -70
  12. package/payload/platform/plugins/cloudflare/mcp/dist/lib/cloudflared.d.ts.map +1 -1
  13. package/payload/platform/plugins/cloudflare/mcp/dist/lib/cloudflared.js +300 -529
  14. package/payload/platform/plugins/cloudflare/mcp/dist/lib/cloudflared.js.map +1 -1
  15. package/payload/platform/plugins/cloudflare/references/setup-guide.md +10 -13
  16. package/payload/platform/plugins/cloudflare/skills/setup-tunnel/SKILL.md +18 -14
  17. package/payload/platform/plugins/docs/references/cloudflare.md +32 -49
  18. package/payload/platform/plugins/docs/references/plugins-guide.md +2 -0
  19. package/payload/platform/scripts/seed-neo4j.sh +12 -0
  20. package/payload/platform/templates/agents/admin/IDENTITY.md +2 -0
  21. package/payload/platform/templates/specialists/agents/personal-assistant.md +6 -6
  22. package/payload/server/public/assets/{admin-Df1liz4Y.js → admin-D7LRdkYB.js} +30 -30
  23. package/payload/server/public/index.html +1 -1
  24. package/payload/server/server.js +88 -23
  25. package/payload/platform/plugins/cloudflare/mcp/__tests__/brand-load.test.ts +0 -81
  26. package/payload/platform/plugins/cloudflare/mcp/__tests__/manifest-scope.test.ts +0 -65
  27. package/payload/platform/plugins/cloudflare/mcp/__tests__/verify-scenario-0.test.ts +0 -70
  28. package/payload/platform/plugins/cloudflare/mcp/__tests__/verify-scenario-B.test.ts +0 -124
@@ -17,25 +17,9 @@ export function loadBrand() {
17
17
  throw new Error(`brand.json not found at ${brandPath} — PLATFORM_ROOT may be incorrect`);
18
18
  }
19
19
  const parsed = JSON.parse(readFileSync(brandPath, "utf-8"));
20
- // configDir/productName fall back to Maxy values for forward compat with
21
- // older manifests; cloudflare.zones does NOT — its absence is an
22
- // authoritative-scope problem the bundler should have caught.
23
- if (!parsed.cloudflare ||
24
- !Array.isArray(parsed.cloudflare.zones) ||
25
- parsed.cloudflare.zones.length === 0) {
26
- throw new Error(`brand.json at ${brandPath} is missing a non-empty cloudflare.zones array. ` +
27
- `The Cloudflare plugin refuses to start without an explicit zone declaration. ` +
28
- `Add cloudflare.zones to brands/<name>/brand.json and republish the brand package.`);
29
- }
30
- for (const zone of parsed.cloudflare.zones) {
31
- if (typeof zone !== "string" || zone.length === 0) {
32
- throw new Error(`brand.json at ${brandPath} has invalid cloudflare.zones entry: ${JSON.stringify(zone)}`);
33
- }
34
- }
35
20
  cachedBrand = {
36
21
  configDir: parsed.configDir ?? ".maxy",
37
22
  productName: parsed.productName ?? "Maxy",
38
- cloudflare: { zones: parsed.cloudflare.zones.slice() },
39
23
  };
40
24
  return cachedBrand;
41
25
  }
@@ -155,14 +139,15 @@ function resetAccountBinding() {
155
139
  return false;
156
140
  }
157
141
  }
158
- export function matchManifestZone(hostname, declaredZones) {
142
+ export function matchAccountZone(hostname, accountZones) {
143
+ const active = accountZones.filter((z) => z.status === "active").map((z) => z.name);
159
144
  const lc = hostname.toLowerCase();
160
- const candidates = declaredZones.filter((z) => lc === z.toLowerCase() || lc.endsWith("." + z.toLowerCase()));
145
+ const candidates = active.filter((z) => lc === z.toLowerCase() || lc.endsWith("." + z.toLowerCase()));
161
146
  if (candidates.length === 0) {
162
- return { ok: false, matchedZone: null, declaredZones: declaredZones.slice() };
147
+ return { ok: false, matchedZone: null, accountZones: active };
163
148
  }
164
149
  candidates.sort((a, b) => b.length - a.length);
165
- return { ok: true, matchedZone: candidates[0], declaredZones: declaredZones.slice() };
150
+ return { ok: true, matchedZone: candidates[0], accountZones: active };
166
151
  }
167
152
  export function logRefuse(detail) {
168
153
  const brand = (() => {
@@ -173,30 +158,29 @@ export function logRefuse(detail) {
173
158
  return null;
174
159
  }
175
160
  })();
161
+ const f = detail.fields ?? {};
176
162
  const fields = {
177
163
  reason: detail.reason,
178
164
  brand: brand?.productName ?? "unknown",
179
- manifestZones: detail.fields.manifestZones ?? brand?.cloudflare.zones ?? null,
180
- boundAccountId: detail.fields.boundAccountId ?? null,
181
- certAccountId: detail.fields.certAccountId ?? null,
182
- requestedDomain: detail.fields.requestedDomain ?? null,
183
- requestedHostname: detail.fields.requestedHostname ?? null,
184
- actualFqdn: detail.fields.actualFqdn ?? null,
185
- tunnelId: detail.fields.tunnelId ?? null,
165
+ accountZones: f.accountZones ?? null,
166
+ boundAccountId: f.boundAccountId ?? null,
167
+ certAccountId: f.certAccountId ?? null,
168
+ requestedDomain: f.requestedDomain ?? null,
169
+ requestedHostname: f.requestedHostname ?? null,
170
+ actualFqdn: f.actualFqdn ?? null,
171
+ tunnelId: f.tunnelId ?? null,
186
172
  };
187
173
  console.error(`[cloudflare:refuse] ${JSON.stringify(fields)}`);
188
174
  }
189
175
  /**
190
- * Emit a single recovery instruction. Every refusal instructs the same path:
191
- * tunnel-login under the account that owns the brand's declared zones.
192
- * No alternative auth path exists.
176
+ * Single recovery instruction. Every refusal instructs the same path:
177
+ * tunnel-login under the Cloudflare account that owns the target zone.
193
178
  */
194
179
  export function recoveryMessage() {
195
- const brand = loadBrand();
196
180
  return (`Recovery: run tunnel-login while signed into the Cloudflare account that owns ` +
197
- `the declared zones for this brand: ${brand.cloudflare.zones.join(", ")}. ` +
198
- `If you recently rotated the cert under a different account, pass force=true ` +
199
- `to clear the existing cert.pem and account binding before re-authenticating.`);
181
+ `the target zone. If you recently rotated the cert under a different account, ` +
182
+ `pass force=true to clear the existing cert.pem and account binding before ` +
183
+ `re-authenticating.`);
200
184
  }
201
185
  // ---------------------------------------------------------------------------
202
186
  // Reset auth — unlinks cert.pem (both paths) and the account binding
@@ -468,17 +452,6 @@ export async function listZones() {
468
452
  }
469
453
  return zones;
470
454
  }
471
- export function checkDeclaredZonesOnAccount(declaredZones, accountZones) {
472
- return declaredZones.map((zone) => {
473
- const lc = zone.toLowerCase();
474
- const match = accountZones.find((z) => z.name.toLowerCase() === lc);
475
- return {
476
- zone,
477
- presentOnAccount: !!match,
478
- activeOnAccount: match?.status === "active",
479
- };
480
- });
481
- }
482
455
  export async function getZoneId(domain) {
483
456
  const { client } = getClient();
484
457
  const zones = await client.zones.list({ name: domain });
@@ -902,47 +875,39 @@ export function writeLocalConfig(tunnelId, credentialsPath, hostnames, port) {
902
875
  return configPath;
903
876
  }
904
877
  // ---------------------------------------------------------------------------
905
- // CLI-based DNS routing — guarded by manifest scope + post-flight FQDN check
878
+ // CLI-based DNS routing — guarded by live account-zone check + post-flight
906
879
  //
907
880
  // `cloudflared tunnel route dns` resolves the target zone from cert.pem's
908
- // account. If the hostname's registrable parent zone is not on that
909
- // account, cloudflared silently writes a CNAME under a different zone on
910
- // the bound account (e.g. admin.example.com.othersite.example when the
911
- // account holds othersite.example but not example.com). Two layers stop
912
- // this:
881
+ // account. If the hostname's registrable parent zone is not on that account,
882
+ // cloudflared silently writes a CNAME under a different zone (the original
883
+ // joelsmalley.xyz wrong-routing bug). Defence in depth:
913
884
  //
914
- // (1) Manifest-scope pre-flight: refuse before invoking the CLI when the
915
- // hostname's registrable parent is not in `brand.cloudflare.zones`.
916
- // The brand declared the zones; nothing else is routable.
917
- // (2) Post-flight FQDN assertion: parse the actual FQDN from
918
- // `cloudflared`'s INF line and refuse with a reverse-and-cleanup if
919
- // it doesn't exactly equal the requested hostname — defence-in-depth
920
- // against cloudflared writing under a sibling zone the manifest also
921
- // declared but doesn't actually own on this account.
885
+ // (1) Live account-zone check: list the bound account's zones, refuse if
886
+ // hostname's registrable parent is not active on that account.
887
+ // (2) Post-flight FQDN assertion: the FQDN cloudflared wrote must equal
888
+ // the requested hostname. Mismatch reverse-and-refuse.
922
889
  //
923
- // `getClient()` is invoked first to enforce auth pre-conditions
924
- // (cert+binding+accountId match) so account-drift refusals fire before any
925
- // CLI process spawns.
890
+ // `getClient()` runs first to enforce auth pre-conditions.
926
891
  // ---------------------------------------------------------------------------
927
892
  export async function routeDnsCli(tunnelId, hostname) {
928
893
  const bin = findBinary();
929
894
  if (!bin)
930
895
  throw new Error("cloudflared is not installed");
931
- // Auth pre-condition: cert + binding + accountId match. Throws
932
- // CloudflareRefusalError on drift before any CLI call.
933
896
  const { client, accountId: boundAccountId } = getClient();
934
- // (1) Manifest-scope pre-flight against brand.cloudflare.zones.
935
- const brand = loadBrand();
936
- const scope = matchManifestZone(hostname, brand.cloudflare.zones);
897
+ // (1) Live account-zone check.
898
+ const accountZones = await listZones();
899
+ const scope = matchAccountZone(hostname, accountZones);
937
900
  if (!scope.ok) {
938
901
  const detail = {
939
902
  reason: "scope-mismatch",
940
- message: `Cannot route ${hostname} — its registrable parent is not in this brand's declared ` +
941
- `Cloudflare zones (${brand.cloudflare.zones.join(", ")}). ` +
942
- `Hostnames outside the declared scope are refused by design.`,
903
+ message: `Cannot route ${hostname} — no active zone on the bound Cloudflare account ` +
904
+ `owns this hostname's registrable parent. Active zones on this account: ` +
905
+ `${scope.accountZones.join(", ") || "none"}. Add the parent zone to the account ` +
906
+ `(cf-add-zone or via the Cloudflare dashboard), or pick a hostname under one ` +
907
+ `of the existing zones.`,
943
908
  fields: {
944
909
  requestedHostname: hostname,
945
- manifestZones: brand.cloudflare.zones,
910
+ accountZones: scope.accountZones,
946
911
  },
947
912
  };
948
913
  logRefuse(detail);
@@ -963,19 +928,10 @@ export async function routeDnsCli(tunnelId, hostname) {
963
928
  }
964
929
  throw new Error(`cloudflared tunnel route dns failed: ${msg}`);
965
930
  }
966
- // (2) Post-flight FQDN assertion. Parse the CNAME name cloudflared
967
- // actually created from its INF line:
968
- // "INF Added CNAME <fqdn> which will route to this tunnel ..."
931
+ // (2) Post-flight FQDN assertion.
969
932
  const fqdnMatch = output.match(/Added CNAME\s+(\S+?)\s+which will route/);
970
933
  if (!fqdnMatch) {
971
- // Format drift — log loudly. We treat the absence of the parse as an
972
- // observability gap rather than silent success because we can no
973
- // longer assert wrong-zone safety.
974
934
  console.error(`[tunnel-route-dns] WARNING: could not parse CNAME FQDN from cloudflared output — format may have changed. Output: ${output.trim()}`);
975
- // Surfacing as refusal would block correct flows when cloudflared
976
- // changes its log line; surface as unverified-success with the
977
- // requested hostname instead. Operators following [cloudflare:*]
978
- // grep cadence will see the WARNING line.
979
935
  return {
980
936
  created: true,
981
937
  output: output.trim(),
@@ -985,45 +941,34 @@ export async function routeDnsCli(tunnelId, hostname) {
985
941
  }
986
942
  const actualFqdn = fqdnMatch[1];
987
943
  if (actualFqdn !== hostname) {
988
- // Defence-in-depth: cloudflared wrote the CNAME under a different
989
- // FQDN than requested. Log raw evidence first, attempt cleanup ONLY
990
- // if the wrong zone is NOT in our declared scope (we never delete
991
- // in-scope records as "cleanup"), then refuse.
944
+ // cloudflared wrote a CNAME under a different FQDN than requested —
945
+ // somehow the live-zone check passed but the routing landed elsewhere.
946
+ // Log evidence, attempt to delete the wrong CNAME, refuse.
992
947
  console.error(`[cloudflare:post-flight-mismatch] ${JSON.stringify({
993
948
  requestedHostname: hostname,
994
949
  actualFqdn,
995
950
  tunnelId,
996
951
  boundAccountId,
997
952
  })}`);
998
- const actualScope = matchManifestZone(actualFqdn, brand.cloudflare.zones);
999
- let cleanupResult = "skipped-in-scope";
1000
- if (!actualScope.ok) {
1001
- try {
1002
- // Find zone for actualFqdn on the bound account; if found, delete
1003
- // the CNAME we just inadvertently created.
1004
- const allZones = await listZones();
1005
- const owningZone = allZones.find((z) => actualFqdn === z.name || actualFqdn.endsWith("." + z.name));
1006
- if (owningZone) {
1007
- const records = await client.dns.records.list({
1008
- zone_id: owningZone.id,
1009
- name: { exact: actualFqdn },
1010
- type: "CNAME",
1011
- });
1012
- for (const r of records.result ?? []) {
1013
- if (r.id)
1014
- await client.dns.records.delete(r.id, { zone_id: owningZone.id });
1015
- }
1016
- cleanupResult = "ok";
1017
- }
1018
- else {
1019
- cleanupResult = "failed"; // nothing to delete — surface as informational
953
+ let cleanupResult = "failed";
954
+ try {
955
+ const owningZone = accountZones.find((z) => actualFqdn === z.name || actualFqdn.endsWith("." + z.name));
956
+ if (owningZone) {
957
+ const records = await client.dns.records.list({
958
+ zone_id: owningZone.id,
959
+ name: { exact: actualFqdn },
960
+ type: "CNAME",
961
+ });
962
+ for (const r of records.result ?? []) {
963
+ if (r.id)
964
+ await client.dns.records.delete(r.id, { zone_id: owningZone.id });
1020
965
  }
1021
- }
1022
- catch (cleanupErr) {
1023
- cleanupResult = "failed";
1024
- console.error(`[cloudflare:post-flight-cleanup] error: ${cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr)}`);
966
+ cleanupResult = "ok";
1025
967
  }
1026
968
  }
969
+ catch (cleanupErr) {
970
+ console.error(`[cloudflare:post-flight-cleanup] error: ${cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr)}`);
971
+ }
1027
972
  console.error(`[cloudflare:post-flight-cleanup] ${JSON.stringify({
1028
973
  requestedHostname: hostname,
1029
974
  actualFqdn,
@@ -1032,24 +977,18 @@ export async function routeDnsCli(tunnelId, hostname) {
1032
977
  const detail = {
1033
978
  reason: "post-flight-fqdn-mismatch",
1034
979
  message: `cloudflared wrote the CNAME under ${actualFqdn} instead of the requested ${hostname}. ` +
1035
- `This indicates cert.pem's account does not own the zone for ${hostname} despite ` +
1036
- `passing manifest scope. ` +
1037
- recoveryMessage(),
980
+ `Cleanup ${cleanupResult === "ok" ? "succeeded" : "failed"}. Re-run cf-rebuild to reconcile.`,
1038
981
  fields: {
1039
982
  requestedHostname: hostname,
1040
983
  actualFqdn,
1041
984
  tunnelId,
1042
985
  boundAccountId,
1043
- manifestZones: brand.cloudflare.zones,
1044
986
  },
1045
987
  };
1046
988
  logRefuse(detail);
1047
989
  throw new CloudflareRefusalError(detail);
1048
990
  }
1049
991
  console.error(`[tunnel-route-dns] CLI: routed ${hostname} → tunnel ${tunnelId} (overwrite) fqdn=${actualFqdn} zone=${scope.matchedZone}`);
1050
- if (process.env.CLOUDFLARE_DEBUG === "1") {
1051
- console.error(`[cloudflare:guard-pass] ${JSON.stringify({ op: "routeDnsCli", requestedHostname: hostname, zone: scope.matchedZone })}`);
1052
- }
1053
992
  return { created: true, output: output.trim(), fqdn: actualFqdn, zone: scope.matchedZone };
1054
993
  }
1055
994
  // ---------------------------------------------------------------------------
@@ -1376,237 +1315,109 @@ export function stopTunnel() {
1376
1315
  writeState({ ...state, pid: null, startedAt: null });
1377
1316
  }
1378
1317
  /**
1379
- * Resolve the live ScopeContext from the brand manifest + binding +
1380
- * read-only SDK client. Used by tools; tests bypass with their own
1381
- * ScopeContext.
1318
+ * cf-verify: read everything, tag nothing as good or bad. The bound
1319
+ * Cloudflare account is the universe; the agent decides what is pollution
1320
+ * from the operator's stated intent in the current conversation.
1321
+ *
1322
+ * The `pollution` field returned here is the default no-intent view —
1323
+ * account artefacts the device's persisted `tunnel.state` + `alias-domains.json`
1324
+ * don't reference. The orchestrator recomputes against explicit intent
1325
+ * when the operator picks a zone.
1382
1326
  */
1383
- export function liveScopeContext() {
1327
+ export async function cfVerifyCore() {
1384
1328
  const brand = loadBrand();
1329
+ console.error(`[cloudflare:verify-start] ${JSON.stringify({ brand: brand.productName })}`);
1330
+ const device = readDeviceSnapshot();
1385
1331
  const ro = getReadOnlyClient();
1386
- return {
1387
- declaredZones: brand.cloudflare.zones,
1388
- binding: readAccountBinding(),
1389
- client: ro.client,
1390
- };
1391
- }
1392
- /**
1393
- * cf-verify implementation. Pure-ish: only reads (account state via SDK,
1394
- * device state via fs). Never mutates. Always returns a structured report
1395
- * even when cert/binding/account state is absent — fresh-install devices
1396
- * get a report with everything tagged MISSING.
1397
- */
1398
- export async function cfVerifyCore(ctx = liveScopeContext()) {
1399
- const brand = loadBrand();
1400
- const artefacts = [];
1401
- console.error(`[cloudflare:verify-start] ${JSON.stringify({
1402
- brand: brand.productName,
1403
- declaredZones: ctx.declaredZones,
1404
- bindingPresent: !!ctx.binding,
1405
- })}`);
1406
- // --- Local artefact: cert.pem ---
1407
- const certCreds = parseCertPem();
1408
- if (!certCreds) {
1409
- artefacts.push({ type: "cert.pem", id: certPath(), tag: "missing" });
1410
- }
1411
- else if (ctx.binding && certCreds.accountId !== ctx.binding.accountId) {
1412
- artefacts.push({
1413
- type: "cert.pem",
1414
- id: certPath(),
1415
- tag: "out-of-scope",
1416
- reason: `cert account ${certCreds.accountId} != binding account ${ctx.binding.accountId}`,
1417
- detail: { certAccountId: certCreds.accountId, boundAccountId: ctx.binding.accountId },
1418
- });
1419
- }
1420
- else {
1421
- artefacts.push({ type: "cert.pem", id: certPath(), tag: "in-scope" });
1422
- }
1423
- // --- Local artefact: binding ---
1424
- if (!ctx.binding) {
1425
- artefacts.push({ type: "binding", id: bindingFile(), tag: "missing" });
1426
- }
1427
- else {
1428
- artefacts.push({
1429
- type: "binding",
1430
- id: bindingFile(),
1431
- tag: "in-scope",
1432
- detail: { accountId: ctx.binding.accountId, boundAt: ctx.binding.boundAt },
1433
- });
1434
- }
1435
- // --- Account artefacts (only readable when bound and cert valid) ---
1436
- // Determine if we can call the SDK at all. cfVerify must never fail on
1437
- // unbound devices — it just reports everything as missing.
1438
- let accountSummary = null;
1439
- if (ctx.client && certCreds) {
1332
+ let account = null;
1333
+ if (ro.client && ro.certAccountId) {
1440
1334
  try {
1441
- const [tunnels, zones] = await Promise.all([
1442
- listTunnelsViaClient(ctx.client, certCreds.accountId),
1443
- listZonesViaClient(ctx.client),
1335
+ const [zones, tunnels] = await Promise.all([
1336
+ listZonesViaClient(ro.client),
1337
+ listTunnelsViaClient(ro.client, ro.certAccountId),
1444
1338
  ]);
1445
- accountSummary = { tunnels, zones };
1446
- }
1447
- catch (err) {
1448
- console.error(`[cloudflare:verify] account read failed: ${err instanceof Error ? err.message : String(err)}`);
1449
- }
1450
- }
1451
- // --- Declared zones: present + active on account? ---
1452
- if (accountSummary) {
1453
- const visibility = checkDeclaredZonesOnAccount(ctx.declaredZones, accountSummary.zones);
1454
- for (const v of visibility) {
1455
- if (!v.presentOnAccount) {
1456
- artefacts.push({
1457
- type: "declared-zone",
1458
- id: v.zone,
1459
- tag: "missing",
1460
- reason: "declared in brand.json but absent from bound account",
1461
- });
1462
- }
1463
- else if (!v.activeOnAccount) {
1464
- artefacts.push({
1465
- type: "declared-zone",
1466
- id: v.zone,
1467
- tag: "out-of-scope",
1468
- reason: "present on bound account but not active (likely awaiting nameservers)",
1469
- });
1470
- }
1471
- else {
1472
- artefacts.push({ type: "declared-zone", id: v.zone, tag: "in-scope" });
1473
- }
1474
- }
1475
- // Zones on the account NOT in the declared scope — informational tag,
1476
- // out-of-scope, no action required by rebuild (we never delete zones).
1477
- for (const z of accountSummary.zones) {
1478
- if (!ctx.declaredZones.some((d) => d.toLowerCase() === z.name.toLowerCase())) {
1479
- artefacts.push({
1480
- type: "declared-zone",
1481
- id: z.name,
1482
- tag: "out-of-scope",
1483
- reason: "zone on bound account but not in brand.cloudflare.zones",
1484
- });
1485
- }
1486
- }
1487
- // --- Tunnels on account ---
1488
- const persistedTunnelId = readState()?.tunnelId ?? null;
1489
- for (const t of accountSummary.tunnels) {
1490
- const isOurs = t.id === persistedTunnelId;
1491
- artefacts.push({
1492
- type: "tunnel",
1493
- id: `${t.name} (${t.id})`,
1494
- tag: isOurs ? "in-scope" : "out-of-scope",
1495
- reason: isOurs ? undefined : "tunnel on account but not in this brand's persisted state",
1496
- detail: { tunnelId: t.id, tunnelName: t.name },
1497
- });
1498
- }
1499
- // --- DNS CNAMEs under declared zones ---
1500
- if (ctx.client) {
1501
- for (const declared of ctx.declaredZones) {
1502
- const zone = accountSummary.zones.find((z) => z.name.toLowerCase() === declared.toLowerCase() && z.status === "active");
1503
- if (!zone)
1339
+ const cnames = [];
1340
+ for (const z of zones) {
1341
+ if (z.status !== "active")
1504
1342
  continue;
1505
1343
  try {
1506
- const records = await listCnamesUnderZone(ctx.client, zone.id);
1507
- for (const rec of records) {
1508
- const ourTunnelTarget = persistedTunnelId
1509
- ? `${persistedTunnelId}.cfargotunnel.com`
1510
- : null;
1511
- const isOurs = ourTunnelTarget !== null && rec.content === ourTunnelTarget;
1512
- artefacts.push({
1513
- type: "dns-cname",
1514
- id: rec.name,
1515
- tag: isOurs ? "in-scope" : "out-of-scope",
1516
- reason: isOurs ? undefined : `CNAME → ${rec.content} (not this brand's tunnel)`,
1517
- detail: { zoneId: zone.id, recordId: rec.id, content: rec.content },
1518
- });
1344
+ const recs = await listCnamesUnderZone(ro.client, z.id);
1345
+ for (const r of recs) {
1346
+ cnames.push({ zone: z.name, recordId: r.id, name: r.name, content: r.content });
1519
1347
  }
1520
1348
  }
1521
1349
  catch (err) {
1522
- console.error(`[cloudflare:verify] failed to list CNAMEs under ${declared}: ${err instanceof Error ? err.message : String(err)}`);
1350
+ console.error(`[cloudflare:verify] failed to list CNAMEs under ${z.name}: ${err instanceof Error ? err.message : String(err)}`);
1523
1351
  }
1524
1352
  }
1353
+ account = {
1354
+ accountId: ro.certAccountId,
1355
+ zones: zones.map((z) => ({ id: z.id, name: z.name, status: z.status })),
1356
+ tunnels: tunnels.map((t) => ({ id: t.id, name: t.name })),
1357
+ cnames,
1358
+ };
1525
1359
  }
1526
- }
1527
- else {
1528
- // Without an SDK client we cannot enumerate account state — surface
1529
- // each declared zone as MISSING.
1530
- for (const zone of ctx.declaredZones) {
1531
- artefacts.push({
1532
- type: "declared-zone",
1533
- id: zone,
1534
- tag: "missing",
1535
- reason: "cannot read bound account (no cert or no client)",
1536
- });
1537
- }
1538
- }
1539
- // --- Local artefacts: tunnel.state, config.yml, alias-domains.json ---
1540
- const persisted = readState();
1541
- if (!persisted) {
1542
- artefacts.push({ type: "tunnel.state", id: statePath(), tag: "missing" });
1543
- }
1544
- else {
1545
- // tunnel.state is in-scope when its tunnelId is present on the bound
1546
- // account AND its hostnames are all in declared scope.
1547
- const allHostnames = getPersistedHostnames();
1548
- const allInScope = allHostnames.every((h) => matchManifestZone(h, ctx.declaredZones).ok);
1549
- const tunnelOnAccount = accountSummary?.tunnels.some((t) => t.id === persisted.tunnelId) ?? null;
1550
- if (allInScope && tunnelOnAccount !== false) {
1551
- artefacts.push({ type: "tunnel.state", id: statePath(), tag: "in-scope" });
1552
- }
1553
- else {
1554
- const reasons = [];
1555
- if (!allInScope)
1556
- reasons.push("contains hostnames outside declared scope");
1557
- if (tunnelOnAccount === false)
1558
- reasons.push("references tunnel absent from bound account");
1559
- artefacts.push({
1560
- type: "tunnel.state",
1561
- id: statePath(),
1562
- tag: "out-of-scope",
1563
- reason: reasons.join("; "),
1564
- detail: { tunnelId: persisted.tunnelId, hostnames: JSON.stringify(allHostnames) },
1565
- });
1566
- }
1567
- // config.yml mirrors tunnel.state in scope assessment.
1568
- if (existsSync(persisted.configPath)) {
1569
- artefacts.push({
1570
- type: "config.yml",
1571
- id: persisted.configPath,
1572
- tag: allInScope ? "in-scope" : "out-of-scope",
1573
- });
1574
- }
1575
- else {
1576
- artefacts.push({ type: "config.yml", id: persisted.configPath, tag: "missing" });
1360
+ catch (err) {
1361
+ console.error(`[cloudflare:verify] account read failed: ${err instanceof Error ? err.message : String(err)}`);
1577
1362
  }
1578
1363
  }
1579
- const aliases = loadAliasDomains();
1580
- for (const alias of aliases) {
1581
- const inScope = matchManifestZone(alias, ctx.declaredZones).ok;
1582
- artefacts.push({
1583
- type: "alias-domain",
1584
- id: alias,
1585
- tag: inScope ? "in-scope" : "out-of-scope",
1586
- reason: inScope ? undefined : "alias hostname outside brand.cloudflare.zones",
1587
- });
1588
- }
1589
- const counts = {
1590
- inScope: artefacts.filter((a) => a.tag === "in-scope").length,
1591
- outOfScope: artefacts.filter((a) => a.tag === "out-of-scope").length,
1592
- missing: artefacts.filter((a) => a.tag === "missing").length,
1593
- };
1364
+ const pollution = computePollution(account, device);
1594
1365
  console.error(`[cloudflare:verify-complete] ${JSON.stringify({
1595
1366
  brand: brand.productName,
1596
- ...counts,
1367
+ accountZones: account?.zones.length ?? 0,
1368
+ accountTunnels: account?.tunnels.length ?? 0,
1369
+ accountCnames: account?.cnames.length ?? 0,
1370
+ pollutionTunnels: pollution.tunnels.length,
1371
+ pollutionCnames: pollution.cnames.length,
1372
+ pollutionZones: pollution.zones.length,
1597
1373
  })}`);
1374
+ return { brand: brand.productName, device, account, pollution };
1375
+ }
1376
+ function readDeviceSnapshot() {
1377
+ const auth = validateAuth();
1378
+ const state = readState();
1379
+ const aliases = [...loadAliasDomains()];
1598
1380
  return {
1599
- brand: brand.productName,
1600
- declaredZones: ctx.declaredZones,
1601
- bindingPresent: !!ctx.binding,
1602
- bindingMatchesCert: !!(certCreds && ctx.binding && certCreds.accountId === ctx.binding.accountId),
1603
- certPresent: !!certCreds,
1604
- artefacts,
1605
- counts,
1381
+ certPath: certPath(),
1382
+ certPresent: auth.hasCert,
1383
+ bindingPath: bindingFile(),
1384
+ bindingPresent: auth.hasBinding,
1385
+ bindingMatchesCert: auth.bound,
1386
+ certAccountId: auth.certAccountId,
1387
+ boundAccountId: auth.boundAccountId,
1388
+ tunnelStatePath: statePath(),
1389
+ tunnelState: state,
1390
+ configYmlPath: state?.configPath ?? null,
1391
+ configYmlPresent: !!(state?.configPath && existsSync(state.configPath)),
1392
+ aliasDomains: aliases,
1606
1393
  };
1607
1394
  }
1608
- // Internal helpers — wrap SDK calls so the verify/rebuild cores don't
1609
- // need to know about the SDK shape directly. Keeps mocks shallow.
1395
+ function computePollution(account, device) {
1396
+ if (!account)
1397
+ return { tunnels: [], cnames: [], zones: [] };
1398
+ const intendedTunnelId = device.tunnelState?.tunnelId ?? null;
1399
+ const intendedHostnames = new Set();
1400
+ if (device.tunnelState?.adminHostname)
1401
+ intendedHostnames.add(device.tunnelState.adminHostname.toLowerCase());
1402
+ if (device.tunnelState?.publicHostname)
1403
+ intendedHostnames.add(device.tunnelState.publicHostname.toLowerCase());
1404
+ for (const a of device.aliasDomains)
1405
+ intendedHostnames.add(a.toLowerCase());
1406
+ // Zones the intended hostnames live under — those are "in use".
1407
+ const inUseZones = new Set();
1408
+ for (const h of intendedHostnames) {
1409
+ for (const z of account.zones) {
1410
+ if (h === z.name.toLowerCase() || h.endsWith("." + z.name.toLowerCase())) {
1411
+ inUseZones.add(z.name.toLowerCase());
1412
+ break;
1413
+ }
1414
+ }
1415
+ }
1416
+ const pollutionTunnels = account.tunnels.filter((t) => t.id !== intendedTunnelId);
1417
+ const pollutionCnames = account.cnames.filter((c) => !intendedHostnames.has(c.name.toLowerCase()));
1418
+ const pollutionZones = account.zones.filter((z) => !inUseZones.has(z.name.toLowerCase()));
1419
+ return { tunnels: pollutionTunnels, cnames: pollutionCnames, zones: pollutionZones };
1420
+ }
1610
1421
  async function listZonesViaClient(client) {
1611
1422
  const zones = [];
1612
1423
  for await (const zone of client.zones.list()) {
@@ -1644,251 +1455,211 @@ async function listCnamesUnderZone(client, zoneId) {
1644
1455
  }
1645
1456
  export async function cfRebuildCore(opts = {}) {
1646
1457
  const dryRun = opts.dryRun ?? false;
1647
- const ctx = opts.ctx ?? liveScopeContext();
1648
1458
  const brand = loadBrand();
1649
- console.error(`[cloudflare:rebuild-start] ${JSON.stringify({
1650
- brand: brand.productName,
1651
- declaredZones: ctx.declaredZones,
1652
- dryRun,
1653
- })}`);
1654
- // Step 1: cert.pem account integrity. cf-rebuild refuses to delete a
1655
- // wrong-account cert.pem mid-flow — that would create a dead authoring
1656
- // window with no clear next step. Instead surface the discard intent
1657
- // and halt with an actionable instruction.
1658
- const certCreds = parseCertPem();
1659
- const actions = [];
1660
- if (!certCreds) {
1661
- const msg = `cf-rebuild requires cert.pem before it can reconstruct state. ` + recoveryMessage();
1459
+ console.error(`[cloudflare:rebuild-start] ${JSON.stringify({ brand: brand.productName, dryRun })}`);
1460
+ // Auth gate. We need a client to mutate the account.
1461
+ let client;
1462
+ let accountId;
1463
+ try {
1464
+ const c = getClient();
1465
+ client = c.client;
1466
+ accountId = c.accountId;
1467
+ }
1468
+ catch (err) {
1469
+ if (err instanceof CloudflareRefusalError) {
1470
+ return {
1471
+ brand: brand.productName,
1472
+ dryRun,
1473
+ preserve: { zones: [], tunnelIds: [], cnames: null },
1474
+ actions: [],
1475
+ halted: true,
1476
+ haltReason: err.refusal.message,
1477
+ };
1478
+ }
1479
+ throw err;
1480
+ }
1481
+ // Snapshot the account before mutating.
1482
+ const verify = await cfVerifyCore();
1483
+ if (!verify.account) {
1662
1484
  return {
1663
1485
  brand: brand.productName,
1664
- declaredZones: ctx.declaredZones,
1665
1486
  dryRun,
1666
- actions: [
1667
- {
1668
- op: "skip-needs-operator",
1669
- artefact: { type: "cert.pem", id: certPath(), tag: "missing" },
1670
- planned: dryRun,
1671
- resultDetail: msg,
1672
- },
1673
- ],
1487
+ preserve: { zones: [], tunnelIds: [], cnames: null },
1488
+ actions: [],
1674
1489
  halted: true,
1675
- haltReason: msg,
1490
+ haltReason: "Could not read account state (cf-verify returned no account snapshot).",
1676
1491
  };
1677
1492
  }
1678
- if (ctx.binding && certCreds.accountId !== ctx.binding.accountId) {
1679
- const msg = `cert.pem is bound to account ${certCreds.accountId} but the recorded binding is ` +
1680
- `account ${ctx.binding.accountId}. cf-rebuild will not silently delete cert.pem ` +
1681
- `re-run tunnel-login force=true to clear the wrong cert and binding, then re-run cf-rebuild. ` +
1682
- recoveryMessage();
1683
- actions.push({
1684
- op: "skip-needs-operator",
1685
- artefact: {
1686
- type: "cert.pem",
1687
- id: certPath(),
1688
- tag: "out-of-scope",
1689
- reason: "wrong-account",
1690
- detail: { certAccountId: certCreds.accountId, boundAccountId: ctx.binding.accountId },
1691
- },
1692
- planned: dryRun,
1693
- resultDetail: msg,
1493
+ // No-intent refusal. If the caller passed no preserve AND the device
1494
+ // has no persisted intent (no tunnel.state, no alias domains), refuse
1495
+ // rather than guess an empty preserve set would nuke the account.
1496
+ // An explicit `{ preserve: { zones: [], tunnelIds: [] } }` is valid
1497
+ // stated intent ("nuke everything") and proceeds.
1498
+ const deviceHasIntent = Boolean(verify.device.tunnelState || verify.device.aliasDomains.length > 0);
1499
+ if (opts.preserve === undefined && !deviceHasIntent) {
1500
+ logRefuse({
1501
+ reason: "no-intent",
1502
+ message: "cf-rebuild refused: no `preserve` was supplied and this device has no persisted tunnel.state or alias-domains. " +
1503
+ "Stating explicit intent is required before destructive operations — " +
1504
+ "pass `preserve: { zones: [...], tunnelIds: [...] }` (an empty array is a valid 'nuke everything' intent), " +
1505
+ "or use `cloudflare-setup` which derives intent from the conversation.",
1694
1506
  });
1695
- console.error(`[cloudflare:rebuild-discard] ${JSON.stringify({ type: "cert.pem", reason: "wrong-account", planned: true })}`);
1696
1507
  return {
1697
1508
  brand: brand.productName,
1698
- declaredZones: ctx.declaredZones,
1699
1509
  dryRun,
1700
- actions,
1510
+ preserve: { zones: [], tunnelIds: [], cnames: null },
1511
+ actions: [],
1701
1512
  halted: true,
1702
- haltReason: msg,
1513
+ haltReason: "no-intent",
1703
1514
  };
1704
1515
  }
1705
- // Step 2: ensure binding is materialized. The fresh-install case passes
1706
- // through here — binding is established from the cert. cf-rebuild does
1707
- // NOT do declared-zone-visibility validation here because the operator
1708
- // may be in the middle of adding zones; visibility shows in the final
1709
- // verify report instead.
1710
- //
1711
- // materializeBinding can throw if cert.pem becomes unreadable between
1712
- // the parseCertPem above and this call (e.g. a concurrent
1713
- // tunnel-login force=true). Wrap in try/catch so the throw becomes a
1714
- // structured halted result, not a raw JS Error escaping past the MCP
1715
- // boundary.
1716
- if (!ctx.binding) {
1516
+ // Resolve the preserve set. Defaults from the device's intended state.
1517
+ const intendedTunnelId = verify.device.tunnelState?.tunnelId ?? null;
1518
+ const intendedHostnames = [];
1519
+ if (verify.device.tunnelState?.adminHostname)
1520
+ intendedHostnames.push(verify.device.tunnelState.adminHostname);
1521
+ if (verify.device.tunnelState?.publicHostname)
1522
+ intendedHostnames.push(verify.device.tunnelState.publicHostname);
1523
+ for (const a of verify.device.aliasDomains)
1524
+ intendedHostnames.push(a);
1525
+ const inferredZones = new Set();
1526
+ for (const h of intendedHostnames) {
1527
+ const lc = h.toLowerCase();
1528
+ for (const z of verify.account.zones) {
1529
+ if (lc === z.name.toLowerCase() || lc.endsWith("." + z.name.toLowerCase())) {
1530
+ inferredZones.add(z.name);
1531
+ break;
1532
+ }
1533
+ }
1534
+ }
1535
+ const preserveZones = (opts.preserve?.zones ?? [...inferredZones]).map((s) => s.toLowerCase());
1536
+ const preserveTunnelIds = opts.preserve?.tunnelIds ?? (intendedTunnelId ? [intendedTunnelId] : []);
1537
+ const preserveCnames = opts.preserve?.cnames
1538
+ ? opts.preserve.cnames.map((c) => ({ zone: c.zone.toLowerCase(), name: c.name.toLowerCase() }))
1539
+ : null;
1540
+ // Identify tunnels that will be deleted — their `.cfargotunnel.com`
1541
+ // CNAMEs on preserved zones must also be scrubbed (else the zone
1542
+ // keeps a stale pointer to a dead tunnel).
1543
+ const deletedTunnelIds = new Set(verify.account.tunnels.filter((t) => !preserveTunnelIds.includes(t.id)).map((t) => t.id));
1544
+ const isStaleTunnelPointer = (content) => {
1545
+ const lc = content.toLowerCase().trim();
1546
+ const m = lc.match(/^([0-9a-f-]{36})\.cfargotunnel\.com\.?$/);
1547
+ return m ? deletedTunnelIds.has(m[1]) : false;
1548
+ };
1549
+ const actions = [];
1550
+ // (a) Delete tunnels not in preserve.
1551
+ for (const t of verify.account.tunnels) {
1552
+ if (preserveTunnelIds.includes(t.id))
1553
+ continue;
1554
+ const action = {
1555
+ op: "delete-tunnel",
1556
+ type: "tunnel",
1557
+ id: t.id,
1558
+ name: t.name,
1559
+ result: dryRun ? "planned" : "ok",
1560
+ };
1717
1561
  if (!dryRun) {
1718
1562
  try {
1719
- materializeBinding("migration");
1563
+ await client.zeroTrust.tunnels.cloudflared.delete(t.id, { account_id: accountId });
1720
1564
  }
1721
1565
  catch (err) {
1722
- const msg = err instanceof Error ? err.message : String(err);
1723
- const halt = `cf-rebuild could not establish account binding: ${msg}. ${recoveryMessage()}`;
1724
- actions.push({
1725
- op: "skip-needs-operator",
1726
- artefact: { type: "binding", id: bindingFile(), tag: "missing" },
1727
- planned: dryRun,
1728
- result: "failed",
1729
- resultDetail: halt,
1730
- });
1731
- return {
1732
- brand: brand.productName,
1733
- declaredZones: ctx.declaredZones,
1734
- dryRun,
1735
- actions,
1736
- halted: true,
1737
- haltReason: halt,
1738
- };
1566
+ action.result = "failed";
1567
+ action.detail = err instanceof Error ? err.message : String(err);
1739
1568
  }
1740
1569
  }
1741
- actions.push({
1742
- op: "recreate",
1743
- artefact: {
1744
- type: "binding",
1745
- id: bindingFile(),
1746
- tag: "missing",
1747
- },
1748
- planned: dryRun,
1749
- result: "ok",
1750
- });
1570
+ console.error(`[cloudflare:rebuild-discard] ${JSON.stringify({ type: "tunnel", id: t.id, name: t.name, planned: dryRun, result: action.result })}`);
1571
+ actions.push(action);
1751
1572
  }
1752
- // Step 3: gather verify report so we have a plan.
1753
- const verify = await cfVerifyCore(ctx);
1754
- // Step 4: discard out-of-scope artefacts. Order matters: DNS records
1755
- // first (so the tunnel can be deleted afterwards without orphaning
1756
- // routes), then alias-domain entries, then tunnel.state, then config.yml.
1757
- // We never delete cert.pem, never delete declared zones (out-of-scope
1758
- // declared-zone entries are operator-action items, not rebuild work),
1759
- // never delete tunnels owned by the bound account that don't match our
1760
- // persisted state (those belong to other devices on the same account
1761
- // Task 525's multi-device contention pattern).
1762
- const liveClient = ctx.client; // may be null in tests
1763
- for (const a of verify.artefacts) {
1764
- if (a.tag !== "out-of-scope")
1573
+ // (b) Delete CNAMEs not in preserve.
1574
+ // If preserve.cnames is set, ONLY those exact records survive.
1575
+ // Otherwise: CNAMEs on preserved zones survive, EXCEPT those pointing
1576
+ // to a `<tunnelId>.cfargotunnel.com` target where the tunnel is being
1577
+ // deleted (stale tunnel pointers on otherwise-preserved zones would
1578
+ // resolve to dead DNS always scrub).
1579
+ for (const c of verify.account.cnames) {
1580
+ const zoneLc = c.zone.toLowerCase();
1581
+ const nameLc = c.name.toLowerCase();
1582
+ let keep = false;
1583
+ if (preserveCnames) {
1584
+ keep = preserveCnames.some((p) => p.zone === zoneLc && p.name === nameLc);
1585
+ }
1586
+ else {
1587
+ keep = preserveZones.includes(zoneLc) && !isStaleTunnelPointer(c.content);
1588
+ }
1589
+ if (keep)
1765
1590
  continue;
1766
- const action = { op: "discard", artefact: a, planned: dryRun };
1767
- try {
1768
- switch (a.type) {
1769
- case "dns-cname": {
1770
- if (!dryRun && liveClient) {
1771
- const zoneId = String(a.detail?.zoneId ?? "");
1772
- const recordId = String(a.detail?.recordId ?? "");
1773
- if (zoneId && recordId) {
1774
- await liveClient.dns.records.delete(recordId, { zone_id: zoneId });
1775
- }
1776
- }
1777
- action.result = "ok";
1778
- break;
1779
- }
1780
- case "alias-domain": {
1781
- if (!dryRun) {
1782
- const aliases = loadAliasDomains();
1783
- aliases.delete(a.id);
1784
- const path = aliasDomainPath();
1785
- mkdirSync(join(homedir(), loadBrand().configDir), { recursive: true });
1786
- writeFileSync(path, JSON.stringify([...aliases], null, 2), "utf-8");
1787
- }
1788
- action.result = "ok";
1789
- break;
1790
- }
1791
- case "tunnel.state":
1792
- case "config.yml": {
1793
- if (!dryRun) {
1794
- try {
1795
- unlinkSync(a.id);
1796
- }
1797
- catch (err) {
1798
- const code = err.code;
1799
- if (code !== "ENOENT")
1800
- throw err;
1801
- }
1802
- }
1803
- action.result = "ok";
1804
- break;
1805
- }
1806
- case "tunnel": {
1807
- // Out-of-scope tunnels are siblings on the same account — leave
1808
- // them alone. Other devices may rely on them. Surface as
1809
- // discard intent but skip without mutating.
1810
- action.op = "skip-needs-operator";
1811
- action.result = "ok";
1812
- action.resultDetail = "tunnel on bound account but not this brand's — left untouched";
1813
- break;
1814
- }
1815
- case "declared-zone": {
1816
- // Out-of-scope declared-zones are zones on the bound account that
1817
- // aren't in the manifest. Informational; never deleted.
1818
- action.op = "skip-needs-operator";
1819
- action.result = "ok";
1820
- action.resultDetail = "zone on bound account outside brand scope — informational";
1821
- break;
1591
+ const action = {
1592
+ op: "delete-cname",
1593
+ type: "cname",
1594
+ id: c.recordId,
1595
+ name: c.name,
1596
+ result: dryRun ? "planned" : "ok",
1597
+ detail: `${c.name} ${c.content} under zone ${c.zone}`,
1598
+ };
1599
+ if (!dryRun) {
1600
+ const zoneRec = verify.account.zones.find((z) => z.name.toLowerCase() === zoneLc);
1601
+ if (!zoneRec) {
1602
+ action.result = "failed";
1603
+ action.detail = `zone ${c.zone} not found in snapshot`;
1604
+ }
1605
+ else {
1606
+ try {
1607
+ await client.dns.records.delete(c.recordId, { zone_id: zoneRec.id });
1822
1608
  }
1823
- case "cert.pem":
1824
- case "binding":
1825
- // Handled above (refuse-to-delete cert; binding mutated by force-reset path only)
1826
- action.op = "skip-needs-operator";
1827
- action.result = "ok";
1828
- break;
1829
- default:
1609
+ catch (err) {
1830
1610
  action.result = "failed";
1831
- action.resultDetail = `unknown artefact type ${a.type}`;
1611
+ action.detail = err instanceof Error ? err.message : String(err);
1612
+ }
1832
1613
  }
1833
1614
  }
1834
- catch (err) {
1835
- action.result = "failed";
1836
- action.resultDetail = err instanceof Error ? err.message : String(err);
1837
- }
1838
- if (action.op === "discard") {
1839
- console.error(`[cloudflare:rebuild-discard] ${JSON.stringify({ type: a.type, id: a.id, planned: dryRun, result: action.result ?? null })}`);
1840
- }
1615
+ console.error(`[cloudflare:rebuild-discard] ${JSON.stringify({ type: "cname", id: c.recordId, name: c.name, zone: c.zone, planned: dryRun, result: action.result })}`);
1841
1616
  actions.push(action);
1842
1617
  }
1843
- // Step 5: create missing in-scope artefacts. We cannot create CF-side
1844
- // missing zones (operator must add zones at cloudflare.com) — surface as
1845
- // skip-needs-operator. We can re-create local state if the tunnel
1846
- // exists on the account but local state is missing; that's a Task 521
1847
- // scenario (re-attaching a tunnel) and is out of scope here. v1 of
1848
- // cf-rebuild surfaces missing local artefacts as informational.
1849
- for (const a of verify.artefacts) {
1850
- if (a.tag !== "missing")
1618
+ // (c) Delete zones not in preserve.
1619
+ for (const z of verify.account.zones) {
1620
+ if (preserveZones.includes(z.name.toLowerCase()))
1851
1621
  continue;
1852
- if (a.type === "declared-zone") {
1853
- actions.push({
1854
- op: "skip-needs-operator",
1855
- artefact: a,
1856
- planned: dryRun,
1857
- result: "ok",
1858
- resultDetail: `declared zone "${a.id}" must be added to the bound Cloudflare account before tunnel-create can route to it`,
1859
- });
1860
- }
1861
- else if (a.type === "binding" || a.type === "cert.pem") {
1862
- // Already handled above
1863
- }
1864
- else {
1865
- actions.push({
1866
- op: "skip-needs-operator",
1867
- artefact: a,
1868
- planned: dryRun,
1869
- result: "ok",
1870
- resultDetail: `missing local artefact — run cloudflare-setup or tunnel-create to create`,
1871
- });
1622
+ const action = {
1623
+ op: "delete-zone",
1624
+ type: "zone",
1625
+ id: z.id,
1626
+ name: z.name,
1627
+ result: dryRun ? "planned" : "ok",
1628
+ };
1629
+ if (!dryRun) {
1630
+ try {
1631
+ await client.zones.delete({ zone_id: z.id });
1632
+ }
1633
+ catch (err) {
1634
+ // Zone deletion may fail (permissions, registrar lock). Surface but
1635
+ // do not halt — orphan zones are informational, not blocking.
1636
+ action.result = "failed";
1637
+ action.detail = err instanceof Error ? err.message : String(err);
1638
+ }
1872
1639
  }
1640
+ console.error(`[cloudflare:rebuild-discard] ${JSON.stringify({ type: "zone", id: z.id, name: z.name, planned: dryRun, result: action.result })}`);
1641
+ actions.push(action);
1873
1642
  }
1874
- // Step 6: rerun verify (unless dry-run) to capture final state.
1875
1643
  let finalVerify;
1876
1644
  if (!dryRun) {
1877
- finalVerify = await cfVerifyCore(ctx);
1645
+ finalVerify = await cfVerifyCore();
1878
1646
  }
1879
1647
  console.error(`[cloudflare:rebuild-complete] ${JSON.stringify({
1880
1648
  brand: brand.productName,
1881
1649
  dryRun,
1882
1650
  actionCount: actions.length,
1883
- finalCounts: finalVerify?.counts ?? null,
1884
1651
  })}`);
1885
1652
  return {
1886
1653
  brand: brand.productName,
1887
- declaredZones: ctx.declaredZones,
1888
1654
  dryRun,
1655
+ preserve: {
1656
+ zones: preserveZones,
1657
+ tunnelIds: preserveTunnelIds,
1658
+ cnames: preserveCnames,
1659
+ },
1889
1660
  actions,
1890
- halted: false,
1891
1661
  finalVerify,
1662
+ halted: false,
1892
1663
  };
1893
1664
  }
1894
1665
  //# sourceMappingURL=cloudflared.js.map