@rubytech/create-maxy-code 0.1.108 → 0.1.110

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 (124) hide show
  1. package/dist/index.js +11 -125
  2. package/dist/snap-chromium.js +1 -2
  3. package/dist/uninstall.js +10 -9
  4. package/package.json +1 -1
  5. package/payload/platform/plugins/admin/PLUGIN.md +1 -0
  6. package/payload/platform/plugins/admin/mcp/dist/index.js +1 -1
  7. package/payload/platform/plugins/admin/mcp/dist/index.js.map +1 -1
  8. package/payload/platform/plugins/admin/skills/upgrade/SKILL.md +34 -0
  9. package/payload/platform/plugins/cloudflare/.claude-plugin/plugin.json +1 -1
  10. package/payload/platform/plugins/cloudflare/PLUGIN.md +9 -16
  11. package/payload/platform/plugins/cloudflare/mcp/dist/index.js +7 -12
  12. package/payload/platform/plugins/cloudflare/mcp/dist/index.js.map +1 -1
  13. package/payload/platform/plugins/cloudflare/references/dashboard-guide.md +3 -3
  14. package/payload/platform/plugins/cloudflare/references/manual-setup.md +16 -51
  15. package/payload/platform/plugins/cloudflare/references/reset-guide.md +24 -25
  16. package/payload/platform/plugins/cloudflare/skills/setup-tunnel/SKILL.md +29 -144
  17. package/payload/platform/plugins/docs/references/admin-session.md +1 -1
  18. package/payload/platform/plugins/docs/references/admin-ui.md +1 -1
  19. package/payload/platform/plugins/docs/references/cloudflare.md +20 -29
  20. package/payload/platform/plugins/docs/references/platform.md +4 -22
  21. package/payload/platform/plugins/docs/references/plugins-guide.md +1 -1
  22. package/payload/platform/plugins/docs/references/troubleshooting.md +4 -316
  23. package/payload/platform/plugins/venture-studio/PLUGIN.md +35 -4
  24. package/payload/platform/plugins/venture-studio/bin/scaffold.sh +104 -0
  25. package/payload/platform/plugins/venture-studio/skills/investor-data-room/SKILL.md +3 -1
  26. package/payload/platform/plugins/work/mcp/dist/cli/project-create-cli.d.ts +2 -0
  27. package/payload/platform/plugins/work/mcp/dist/cli/project-create-cli.d.ts.map +1 -0
  28. package/payload/platform/plugins/work/mcp/dist/cli/project-create-cli.js +78 -0
  29. package/payload/platform/plugins/work/mcp/dist/cli/project-create-cli.js.map +1 -0
  30. package/payload/platform/scripts/check-no-task-id-leaks.mjs +1 -1
  31. package/payload/platform/scripts/vnc.sh +4 -3
  32. package/payload/platform/templates/agents/admin/IDENTITY.md +1 -1
  33. package/payload/premium-plugins/venture-studio/PLUGIN.md +35 -4
  34. package/payload/premium-plugins/venture-studio/bin/scaffold.sh +104 -0
  35. package/payload/premium-plugins/venture-studio/skills/investor-data-room/SKILL.md +3 -1
  36. package/payload/server/{chunk-AGFS3TVN.js → chunk-FSPPVVWM.js} +1303 -496
  37. package/payload/server/maxy-edge.js +21 -259
  38. package/payload/server/public/assets/{ChatInput-DJsqm_Gf.js → ChatInput-CsnIedhS.js} +1 -5
  39. package/payload/server/public/assets/{Checkbox-DGZG9BKc.js → Checkbox-CWugFyFT.js} +1 -1
  40. package/payload/server/public/assets/admin-D3gZuyUn.js +217 -0
  41. package/payload/server/public/assets/{architectureDiagram-Q4EWVU46-Dw16BhiX.js → architectureDiagram-Q4EWVU46-CeTRKWDb.js} +1 -1
  42. package/payload/server/public/assets/{blockDiagram-DXYQGD6D-DIOpmf5Y.js → blockDiagram-DXYQGD6D-DeeIX5U3.js} +1 -1
  43. package/payload/server/public/assets/{c4Diagram-AHTNJAMY-Tdb_HZeX.js → c4Diagram-AHTNJAMY-CFsqZuil.js} +1 -1
  44. package/payload/server/public/assets/channel-CrSx5mnG.js +1 -0
  45. package/payload/server/public/assets/{chunk-336JU56O-CebpwDDe.js → chunk-336JU56O-DpIXuFM0.js} +2 -2
  46. package/payload/server/public/assets/{chunk-426QAEUC-BtRCmfDU.js → chunk-426QAEUC-Qk8qqrvA.js} +1 -1
  47. package/payload/server/public/assets/{chunk-4TB4RGXK-BZ3GEWs3.js → chunk-4TB4RGXK-DtM8-CUn.js} +1 -1
  48. package/payload/server/public/assets/{chunk-5FUZZQ4R-iDI6Xu0U.js → chunk-5FUZZQ4R-CrSQ4ySU.js} +1 -1
  49. package/payload/server/public/assets/{chunk-5PVQY5BW-SQD_EpYa.js → chunk-5PVQY5BW-Bn2nQwdj.js} +1 -1
  50. package/payload/server/public/assets/{chunk-EDXVE4YY-CtCcA7_e.js → chunk-EDXVE4YY-CzCPnR0P.js} +1 -1
  51. package/payload/server/public/assets/{chunk-ENJZ2VHE-pXVGVCbb.js → chunk-ENJZ2VHE-CgZj9RoG.js} +1 -1
  52. package/payload/server/public/assets/{chunk-ICPOFSXX-Dkzg9o2N.js → chunk-ICPOFSXX-wy-eNjwW.js} +1 -1
  53. package/payload/server/public/assets/{chunk-OYMX7WX6-1ZZWzf9F.js → chunk-OYMX7WX6-CMmJtL8S.js} +1 -1
  54. package/payload/server/public/assets/{chunk-U2HBQHQK-CpQ3kzO0.js → chunk-U2HBQHQK-CFCW7OaT.js} +1 -1
  55. package/payload/server/public/assets/{chunk-X2U36JSP-C2LkxroC.js → chunk-X2U36JSP-Bgh-CJSN.js} +1 -1
  56. package/payload/server/public/assets/{chunk-YZCP3GAM-Bl5jBOt5.js → chunk-YZCP3GAM-BXKwZ4vN.js} +1 -1
  57. package/payload/server/public/assets/{chunk-ZZ45TVLE-CHtnptPS.js → chunk-ZZ45TVLE-BiOuK5NP.js} +1 -1
  58. package/payload/server/public/assets/classDiagram-6PBFFD2Q-C3IDJsqN.js +1 -0
  59. package/payload/server/public/assets/classDiagram-v2-HSJHXN6E-BOIrJ5Zb.js +1 -0
  60. package/payload/server/public/assets/clone-P8Fkz7JD.js +1 -0
  61. package/payload/server/public/assets/{dagre-BziN0Nkh.js → dagre-DyCxa9Q2.js} +1 -1
  62. package/payload/server/public/assets/{dagre-KV5264BT-B7OG1g1Y.js → dagre-KV5264BT-Cffo_hmf.js} +1 -1
  63. package/payload/server/public/assets/data-DRfwJPja.js +1 -0
  64. package/payload/server/public/assets/{diagram-5BDNPKRD-Bf31nIDs.js → diagram-5BDNPKRD-Bt93Du3V.js} +1 -1
  65. package/payload/server/public/assets/{diagram-G4DWMVQ6-DQu85hhH.js → diagram-G4DWMVQ6-v4r-tBsC.js} +1 -1
  66. package/payload/server/public/assets/{diagram-MMDJMWI5-5tstbs4y.js → diagram-MMDJMWI5-DKrVPOP-.js} +1 -1
  67. package/payload/server/public/assets/{diagram-TYMM5635--MOV1U4o.js → diagram-TYMM5635-B1PzWQb9.js} +1 -1
  68. package/payload/server/public/assets/{erDiagram-SMLLAGMA-BtAnOJmd.js → erDiagram-SMLLAGMA-ClQzsQAs.js} +1 -1
  69. package/payload/server/public/assets/{flowDiagram-DWJPFMVM-CybiCIih.js → flowDiagram-DWJPFMVM-h3IbpN_7.js} +1 -1
  70. package/payload/server/public/assets/{ganttDiagram-T4ZO3ILL-B44jN7Cy.js → ganttDiagram-T4ZO3ILL-ClaxOJi8.js} +1 -1
  71. package/payload/server/public/assets/{gitGraphDiagram-UUTBAWPF-CT7iYcwg.js → gitGraphDiagram-UUTBAWPF-pM-U4ClD.js} +1 -1
  72. package/payload/server/public/assets/graph-B4FFYwus.js +1 -0
  73. package/payload/server/public/assets/graph-labels-Cpk9Ktt0.js +1 -0
  74. package/payload/server/public/assets/{graphlib-DP4o0pYL.js → graphlib-CwimOv_M.js} +1 -1
  75. package/payload/server/public/assets/{infoDiagram-42DDH7IO-CvxFpFHN.js → infoDiagram-42DDH7IO-DcO3Giwe.js} +1 -1
  76. package/payload/server/public/assets/{ishikawaDiagram-UXIWVN3A-CUwWLPeR.js → ishikawaDiagram-UXIWVN3A-BcdSL5VS.js} +1 -1
  77. package/payload/server/public/assets/{journeyDiagram-VCZTEJTY-y-OnGgLi.js → journeyDiagram-VCZTEJTY-CYahHYzf.js} +1 -1
  78. package/payload/server/public/assets/{kanban-definition-6JOO6SKY-BhrSe8R4.js → kanban-definition-6JOO6SKY-Cl5KhoS4.js} +1 -1
  79. package/payload/server/public/assets/lib--yuBd0Xi.js +33 -0
  80. package/payload/server/public/assets/{line-C9uS7z4J.js → line-d_2oTxLp.js} +1 -1
  81. package/payload/server/public/assets/{mermaid-parser.core-Cg4ZdKp-.js → mermaid-parser.core-CMMDGv3x.js} +1 -1
  82. package/payload/server/public/assets/{mermaid.core-BHdKOsex.js → mermaid.core-DK9ENGbr.js} +3 -3
  83. package/payload/server/public/assets/{mindmap-definition-QFDTVHPH-NBYiXHo7.js → mindmap-definition-QFDTVHPH-mA3x3MFG.js} +1 -1
  84. package/payload/server/public/assets/page-CgDmg_fX.js +1 -0
  85. package/payload/server/public/assets/{page-D9YpwhIu.js → page-D9lluVl7.js} +2 -2
  86. package/payload/server/public/assets/{pieDiagram-DEJITSTG-CbojC64C.js → pieDiagram-DEJITSTG-CW1nGFlY.js} +1 -1
  87. package/payload/server/public/assets/public-DdLMkNdS.js +7 -0
  88. package/payload/server/public/assets/{quadrantDiagram-34T5L4WZ-CnnoZXcL.js → quadrantDiagram-34T5L4WZ-DI5igJhR.js} +1 -1
  89. package/payload/server/public/assets/{requirementDiagram-MS252O5E-DFRFRtyJ.js → requirementDiagram-MS252O5E-DyVeI4e_.js} +1 -1
  90. package/payload/server/public/assets/{sankeyDiagram-XADWPNL6-DU5gcpzO.js → sankeyDiagram-XADWPNL6-CCTTBYKY.js} +1 -1
  91. package/payload/server/public/assets/{sequenceDiagram-FGHM5R23-BN7HZ6Hq.js → sequenceDiagram-FGHM5R23-hVVf35ly.js} +1 -1
  92. package/payload/server/public/assets/{stateDiagram-FHFEXIEX-DG6cCjvg.js → stateDiagram-FHFEXIEX-CSLuQDUX.js} +1 -1
  93. package/payload/server/public/assets/stateDiagram-v2-QKLJ7IA2-C5JejhQU.js +1 -0
  94. package/payload/server/public/assets/{timeline-definition-GMOUNBTQ-qmV-8SEV.js → timeline-definition-GMOUNBTQ-sxC3W8Q4.js} +1 -1
  95. package/payload/server/public/assets/{vennDiagram-DHZGUBPP-BMXQ1s3n.js → vennDiagram-DHZGUBPP-Ro-ton6Z.js} +1 -1
  96. package/payload/server/public/assets/{wardleyDiagram-NUSXRM2D-CoAdP1Gw.js → wardleyDiagram-NUSXRM2D-T-dKD0Zx.js} +1 -1
  97. package/payload/server/public/assets/{xychartDiagram-5P7HB3ND-CZlfGTEn.js → xychartDiagram-5P7HB3ND-CfProPts.js} +1 -1
  98. package/payload/server/public/data.html +4 -4
  99. package/payload/server/public/graph.html +5 -5
  100. package/payload/server/public/index.html +7 -7
  101. package/payload/server/public/public.html +4 -4
  102. package/payload/server/server.js +299 -1392
  103. package/payload/platform/plugins/cloudflare/scripts/__tests__/tunnel-ingress.test.ts +0 -241
  104. package/payload/platform/plugins/cloudflare/scripts/_stream-log.sh +0 -154
  105. package/payload/platform/plugins/cloudflare/scripts/list-cf-domains.sh +0 -98
  106. package/payload/platform/plugins/cloudflare/scripts/list-cf-domains.ts +0 -749
  107. package/payload/platform/plugins/cloudflare/scripts/reset-tunnel.sh +0 -107
  108. package/payload/platform/plugins/cloudflare/scripts/setup-tunnel.sh +0 -854
  109. package/payload/platform/plugins/cloudflare/scripts/tunnel-ingress.ts +0 -291
  110. package/payload/server/chunk-BDFOTLPW.js +0 -759
  111. package/payload/server/chunk-JRBCOVA4.js +0 -1305
  112. package/payload/server/cloudflare-task-tracker-M5ONAGUT.js +0 -22
  113. package/payload/server/public/assets/admin-BvzMvMGo.js +0 -217
  114. package/payload/server/public/assets/channel-DThrH4QF.js +0 -1
  115. package/payload/server/public/assets/classDiagram-6PBFFD2Q-BC6oGTNX.js +0 -1
  116. package/payload/server/public/assets/classDiagram-v2-HSJHXN6E-COwC5Umh.js +0 -1
  117. package/payload/server/public/assets/clone-Chp7hvnA.js +0 -1
  118. package/payload/server/public/assets/data-BAgaPs4j.js +0 -1
  119. package/payload/server/public/assets/graph-Cma7EArf.js +0 -1
  120. package/payload/server/public/assets/graph-labels-DyKk6Sxf.js +0 -1
  121. package/payload/server/public/assets/lib-CpkYtEDz.js +0 -29
  122. package/payload/server/public/assets/page-B4IWl3aZ.js +0 -1
  123. package/payload/server/public/assets/public-BtOXjy3A.js +0 -8
  124. package/payload/server/public/assets/stateDiagram-v2-QKLJ7IA2-CvVd1Q_q.js +0 -1
@@ -1,749 +0,0 @@
1
- // Raw CDP scrape of the operator's Cloudflare dashboard to discover the
2
- // domains attached to the logged-in account. Output on stdout is a JSON
3
- // `string[]`, sorted and deduped. Every failure mode exits non-zero with
4
- // a `reason=<enum>` token on stderr tagged [list-cf-domains].
5
- //
6
- // Why raw CDP, not playwright:
7
- // - `/json/new?about:blank` + WebSocket to the returned `webSocketDebuggerUrl`
8
- // is ~120 LOC including retries and state machine.
9
- // - Adds zero npm deps (Node 22 ships global `WebSocket`; no `ws`/`playwright`).
10
- // - CDP is already bound to 127.0.0.1 by vnc.sh; the runtime surface is
11
- // the same one setup-tunnel.sh uses for browser drive.
12
- //
13
- // Why URL-pattern extraction, not CSS selectors:
14
- // - Cloudflare's SPA uses hashed, version-churning class names — a selector
15
- // written today drifts within a dashboard redesign window.
16
- // - The `/<accountId>/<domain>` URL shape is load-bearing for CF's own
17
- // routing and cannot drift without breaking their entire dashboard; it
18
- // is the most stable surface to key off.
19
- //
20
- // Why href-only (no page-text fallback): an earlier
21
- // <main>-wide FQDN text walker ("Source B") was removed after a reproduction returned
22
- // `count=1` with a bogus string (`leg.interest` — a static footer FQDN-shape
23
- // that hit the regex). The walker's non-empty result short-circuited the
24
- // poll-to-success branch before zones hydrated 500 ms later, silently
25
- // returning wrong data with `reason=ok`. The walker's intended role —
26
- // "defend against CF redesign removing href links" — is already served by
27
- // the existing `empty-or-drift` body-dump branch when href scraping returns
28
- // nothing; a silent fallback that produces wrong answers is strictly worse
29
- // than a loud drift signal with a snapshotted HTML dump for diagnosis.
30
- //
31
- // Why poll-to-stable: the CF dashboard lazy-loads its zone list after the
32
- // initial document-ready signal. A naive "return on first non-empty poll"
33
- // can race into a partial result (e.g. first zone rendered at t=500 ms, all
34
- // zones at t=1000 ms). The `scrapeDomains` poll waits for the observed count
35
- // to stabilise across two consecutive iterations before returning.
36
- //
37
- // Contract with the caller (list-cf-domains.sh → route → form):
38
- // stdout on exit 0: JSON `string[]` (empty array means signed-in-but-empty)
39
- // stderr on any path: phase_line-formatted lines, `[list-cf-domains] …`
40
- // exit 1: `reason=<enum>` on stderr; scrape-empty-but-no-error is exit 0
41
- // with body dump (the enum exists so the caller can distinguish
42
- // "selector drift" from "empty account" — both shapes arrive via
43
- // stdout).
44
-
45
- import { appendFileSync, readFileSync } from "node:fs";
46
- import { writeFile } from "node:fs/promises";
47
- import { resolve } from "node:path";
48
- import { homedir } from "node:os";
49
- import { fileURLToPath } from "node:url";
50
-
51
- // CDP endpoint is resolved lazily inside `main()` so importing this module
52
- // for in-process tests (`scrapeCurrentPage` / `scrapeDomains` under JSDOM)
53
- // does not require an installed brand on disk.
54
- //
55
- // source-of-truth contract:
56
- // Runtime: brand.json `cdpPort` at `${MAXY_PLATFORM_ROOT}/config/brand.json`
57
- // is authoritative. Wrapper exports MAXY_PLATFORM_ROOT and BRAND. Missing
58
- // env / file / field → loud-fail with one of three named reasons. A
59
- // silent CDP-port default would make every non-default brand fail
60
- // `cdp-unreachable`; runtime config never falls back silently
61
- // (NEO4J_URI sets the same precedent).
62
- //
63
- // Test overrides: when BOTH `LIST_CF_DOMAINS_CDP_HOST` and
64
- // `LIST_CF_DOMAINS_CDP_PORT` are set, they win over brand.json. The
65
- // existing ECONNREFUSED vitest forces `127.0.0.1:1` (RFC 6335-reserved)
66
- // this way without an installed brand. Production never sets these.
67
-
68
- // Phase budgets total 30s (enforced by the shell wrapper's `timeout`).
69
- // Individual steps get sub-budgets so an early stall does not eat the
70
- // whole envelope silently.
71
- const CONNECT_TIMEOUT_MS = 2_000;
72
- const NAVIGATE_TIMEOUT_MS = 15_000;
73
- const SIGNED_IN_POLL_MS = 10_000;
74
- const SCRAPE_POLL_MS = 10_000;
75
- const POLL_INTERVAL_MS = 500;
76
-
77
- type Reason =
78
- | "cdp-unreachable"
79
- | "target-create-failed"
80
- | "ws-connect-failed"
81
- | "not-signed-in"
82
- | "navigate-failed"
83
- | "table-wait-timeout"
84
- | "runtime-error"
85
- // config-class failures. The first three short-circuit before
86
- // any CDP work begins; the route maps them to `field='config'` so the
87
- // form renders a config-card naming the brand + path instead of a Retry
88
- // button (retrying re-fires the same wrongly-resolved spawn
89
- // pattern).
90
- | "brand-config-missing"
91
- | "cdp-port-unresolved";
92
-
93
- // Sticky flag: once a stream-log write fails (EACCES, ENOENT, …), skip
94
- // subsequent file writes for the rest of this process. Repeated appends to an
95
- // unwritable path would spam stderr with identical `phase=stream-log-write-failed`
96
- // lines and crowd result.stderr that the route handler regex reads. Losing a
97
- // log line is strictly better than aborting the scrape — observability failure
98
- // must not mask the scrape's real signal.
99
- let streamLogWriteFailed = false;
100
-
101
- function logPhase(line: string): void {
102
- // Stderr: preserved for direct-SSH invocation parity AND for runFormSpawn's
103
- // in-memory `result.stderr`, which the route handler greps for `reason=<enum>`
104
- // on non-zero exit. Removing this write would break the form's error-card path.
105
- process.stderr.write(`[list-cf-domains] ${line}\n`);
106
-
107
- // Stream log: direct write by the TS helper (not via wrapper tee) so phase
108
- // lines reach the per-conversation file the tailer reads, even on
109
- // exit 0 where runFormSpawn discards `result.stderr`. Format parity with
110
- // `_stream-log.sh phase_line`: `[<ISO-ts>] [script:<scope>] <content>\n` —
111
- // the `script:` prefix is the chat-surface namespace so the line
112
- // passes the tightened tailer regex (`[mcp:*]` / `[init]` / `[api-wait-*]`
113
- // lines in the same file are excluded by construction). Asserted by the
114
- // vitest regression under platform/ui/__tests__. Sync append so the
115
- // terminal `phase=script-exit code=0 count=N` lands before process exit.
116
- const streamLogPath = process.env.STREAM_LOG_PATH;
117
- if (!streamLogPath || streamLogWriteFailed) return;
118
- try {
119
- appendFileSync(
120
- streamLogPath,
121
- `[${new Date().toISOString()}] [script:list-cf-domains] ${line}\n`,
122
- );
123
- } catch (err) {
124
- streamLogWriteFailed = true;
125
- const detail = (err instanceof Error ? err.message : String(err))
126
- .slice(0, 200)
127
- .replace(/"/g, "'");
128
- process.stderr.write(
129
- `[list-cf-domains] phase=stream-log-write-failed detail="${detail}"\n`,
130
- );
131
- }
132
- }
133
-
134
- function die(reason: Reason, detail = ""): never {
135
- logPhase(`phase=error reason=${reason}${detail ? ` detail="${detail.replace(/"/g, "'").slice(0, 300)}"` : ""}`);
136
- process.exit(1);
137
- }
138
-
139
- interface CdpEndpoint {
140
- host: string;
141
- port: number;
142
- source: "env-override" | "brand.json";
143
- }
144
-
145
- // single source of truth for the CDP endpoint. Called once from
146
- // `main()` so the JSDOM scrape test (which imports `scrapeCurrentPage`
147
- // directly without invoking `main`) does not need an installed brand.
148
- function resolveCdpEndpoint(): CdpEndpoint {
149
- const envHost = process.env.LIST_CF_DOMAINS_CDP_HOST;
150
- const envPortRaw = process.env.LIST_CF_DOMAINS_CDP_PORT;
151
- if (envHost !== undefined && envPortRaw !== undefined) {
152
- const envPort = Number(envPortRaw);
153
- if (!Number.isInteger(envPort) || envPort <= 0 || envPort > 65535) {
154
- die(
155
- "cdp-port-unresolved",
156
- `LIST_CF_DOMAINS_CDP_PORT="${envPortRaw}" is not a valid integer port`,
157
- );
158
- }
159
- return { host: envHost, port: envPort, source: "env-override" };
160
- }
161
-
162
- const brand = process.env.BRAND;
163
- if (!brand) {
164
- die(
165
- "brand-config-missing",
166
- "BRAND env var not set by wrapper — refusing to guess brand identity",
167
- );
168
- }
169
- const platformRoot = process.env.MAXY_PLATFORM_ROOT;
170
- if (!platformRoot) {
171
- die(
172
- "brand-config-missing",
173
- "MAXY_PLATFORM_ROOT env var not set — wrapper must export it before spawn",
174
- );
175
- }
176
- const brandPath = resolve(platformRoot, "config", "brand.json");
177
- let raw: string;
178
- try {
179
- raw = readFileSync(brandPath, "utf-8");
180
- } catch (err) {
181
- // Distinct emission (not via `die`) so the path is named on the same line
182
- // — config-card rendering on the route side keys off it.
183
- logPhase(
184
- `phase=error reason=brand-config-missing path=${brandPath} detail="${(err instanceof Error ? err.message : String(err)).replace(/"/g, "'").slice(0, 200)}"`,
185
- );
186
- process.exit(1);
187
- }
188
- let parsed: Record<string, unknown>;
189
- try {
190
- parsed = JSON.parse(raw) as Record<string, unknown>;
191
- } catch (err) {
192
- logPhase(
193
- `phase=error reason=brand-config-missing path=${brandPath} detail="parse failed: ${(err instanceof Error ? err.message : String(err)).replace(/"/g, "'").slice(0, 160)}"`,
194
- );
195
- process.exit(1);
196
- }
197
- const cdpPort = parsed.cdpPort;
198
- if (typeof cdpPort !== "number" || !Number.isInteger(cdpPort) || cdpPort <= 0 || cdpPort > 65535) {
199
- // Names the keys actually present so a schema-drift incident surfaces
200
- // immediately ("oh, brand.json renamed `cdpPort` to `cdp_port` in r123").
201
- const keys = Object.keys(parsed).join(",");
202
- logPhase(
203
- `phase=error reason=cdp-port-unresolved brand=${brand} json_keys=${keys}`,
204
- );
205
- process.exit(1);
206
- }
207
- // Host stays 127.0.0.1 on the runtime path — vnc.sh binds Chromium's CDP
208
- // server to localhost only, by construction. brand.json carries the port,
209
- // not the host, because the host has never varied across brands.
210
- return { host: "127.0.0.1", port: cdpPort, source: "brand.json" };
211
- }
212
-
213
- async function cdpVersion(host: string, port: number): Promise<void> {
214
- const controller = new AbortController();
215
- const timer = setTimeout(() => controller.abort(), CONNECT_TIMEOUT_MS);
216
- try {
217
- const res = await fetch(`http://${host}:${port}/json/version`, { signal: controller.signal });
218
- if (!res.ok) die("cdp-unreachable", `HTTP ${res.status}`);
219
- } catch (err) {
220
- die("cdp-unreachable", err instanceof Error ? err.message : String(err));
221
- } finally {
222
- clearTimeout(timer);
223
- }
224
- }
225
-
226
- interface CdpTarget {
227
- id: string;
228
- webSocketDebuggerUrl: string;
229
- }
230
-
231
- async function createTarget(host: string, port: number): Promise<CdpTarget> {
232
- // `PUT /json/new?<url>` returns JSON describing the target. The URL goes
233
- // as a query-string argument (not a ?url= key), matching Chromium's CDP
234
- // server. about:blank avoids a wasted navigation — the real navigate
235
- // happens over WS after we attach.
236
- const endpoint = `http://${host}:${port}/json/new?about:blank`;
237
- let res: Response;
238
- try {
239
- res = await fetch(endpoint, { method: "PUT", signal: AbortSignal.timeout(CONNECT_TIMEOUT_MS) });
240
- } catch (err) {
241
- die("target-create-failed", err instanceof Error ? err.message : String(err));
242
- }
243
- if (!res.ok) die("target-create-failed", `HTTP ${res.status}`);
244
- const target = (await res.json()) as Partial<CdpTarget>;
245
- if (!target.id || !target.webSocketDebuggerUrl) {
246
- die("target-create-failed", "response missing id or webSocketDebuggerUrl");
247
- }
248
- return target as CdpTarget;
249
- }
250
-
251
- async function closeTarget(host: string, port: number, id: string): Promise<void> {
252
- try {
253
- await fetch(`http://${host}:${port}/json/close/${id}`, {
254
- signal: AbortSignal.timeout(CONNECT_TIMEOUT_MS),
255
- });
256
- } catch {
257
- // Best-effort on cleanup — a leaked target is recovered by the next vnc.sh
258
- // restart; surfacing it as a failure would mask the real scrape error.
259
- }
260
- }
261
-
262
- // Minimal CDP WebSocket client. We only need three commands: Page.enable,
263
- // Runtime.enable, Page.navigate, Runtime.evaluate. A full client would
264
- // manage sessions / targets / events — we just need request/response with
265
- // per-id correlation and an optional event stream for Page.frameNavigated
266
- // (which we do not actually need because Runtime.evaluate on location.href
267
- // is the authoritative signal).
268
- class CdpClient {
269
- private ws: WebSocket;
270
- private nextId = 1;
271
- private pending = new Map<number, { resolve: (v: unknown) => void; reject: (e: Error) => void }>();
272
-
273
- private constructor(ws: WebSocket) {
274
- this.ws = ws;
275
- this.ws.addEventListener("message", (ev) => this.onMessage(ev.data as string));
276
- this.ws.addEventListener("error", () => {
277
- for (const { reject } of this.pending.values()) reject(new Error("WebSocket error"));
278
- this.pending.clear();
279
- });
280
- this.ws.addEventListener("close", () => {
281
- for (const { reject } of this.pending.values()) reject(new Error("WebSocket closed"));
282
- this.pending.clear();
283
- });
284
- }
285
-
286
- static async connect(url: string): Promise<CdpClient> {
287
- const ws = new WebSocket(url);
288
- return await new Promise((resolvePromise, rejectPromise) => {
289
- const timer = setTimeout(() => {
290
- ws.close();
291
- rejectPromise(new Error(`WebSocket connect timeout (${CONNECT_TIMEOUT_MS}ms)`));
292
- }, CONNECT_TIMEOUT_MS);
293
- ws.addEventListener("open", () => {
294
- clearTimeout(timer);
295
- resolvePromise(new CdpClient(ws));
296
- }, { once: true });
297
- ws.addEventListener("error", (ev) => {
298
- clearTimeout(timer);
299
- rejectPromise(new Error(`WebSocket error: ${(ev as ErrorEvent).message ?? "unknown"}`));
300
- }, { once: true });
301
- });
302
- }
303
-
304
- private onMessage(raw: string): void {
305
- let msg: { id?: number; result?: unknown; error?: { message?: string } };
306
- try {
307
- msg = JSON.parse(raw);
308
- } catch {
309
- return;
310
- }
311
- if (typeof msg.id !== "number") return;
312
- const waiter = this.pending.get(msg.id);
313
- if (!waiter) return;
314
- this.pending.delete(msg.id);
315
- if (msg.error) {
316
- waiter.reject(new Error(msg.error.message ?? "CDP error"));
317
- } else {
318
- waiter.resolve(msg.result);
319
- }
320
- }
321
-
322
- send<T = unknown>(method: string, params: Record<string, unknown> = {}): Promise<T> {
323
- const id = this.nextId++;
324
- return new Promise<T>((resolvePromise, rejectPromise) => {
325
- this.pending.set(id, {
326
- resolve: resolvePromise as (v: unknown) => void,
327
- reject: rejectPromise,
328
- });
329
- this.ws.send(JSON.stringify({ id, method, params }));
330
- });
331
- }
332
-
333
- close(): void {
334
- try { this.ws.close(); } catch { /* ws may already be closing */ }
335
- }
336
- }
337
-
338
- interface EvaluateResult {
339
- result: { type: string; value?: unknown; description?: string };
340
- exceptionDetails?: { text?: string; exception?: { description?: string } };
341
- }
342
-
343
- async function evaluate<T = unknown>(cdp: CdpClient, expression: string): Promise<T> {
344
- const res = await cdp.send<EvaluateResult>("Runtime.evaluate", {
345
- expression,
346
- returnByValue: true,
347
- awaitPromise: false,
348
- });
349
- if (res.exceptionDetails) {
350
- throw new Error(res.exceptionDetails.exception?.description ?? res.exceptionDetails.text ?? "evaluate threw");
351
- }
352
- return res.result.value as T;
353
- }
354
-
355
- // Detect whether the dashboard has redirected to a signed-in account path
356
- // (URL like `/<32-hex-accountId>/…`) or the unsigned-in `/login` flow.
357
- // Returns the accountId if signed in, null if still loading, false if
358
- // definitively signed-out.
359
- const ACCOUNT_ID_REGEX = /^\/([a-f0-9]{32})(?:\/|$)/;
360
-
361
- async function readAccountId(cdp: CdpClient): Promise<string | null | false> {
362
- const pathname = await evaluate<string>(cdp, "location.pathname");
363
- if (typeof pathname !== "string") return null;
364
- if (pathname.startsWith("/login") || pathname.startsWith("/sign-in")) return false;
365
- const match = pathname.match(ACCOUNT_ID_REGEX);
366
- return match ? match[1] : null;
367
- }
368
-
369
- async function waitForSignedIn(cdp: CdpClient): Promise<string> {
370
- const deadline = Date.now() + SIGNED_IN_POLL_MS;
371
- while (Date.now() < deadline) {
372
- const state = await readAccountId(cdp);
373
- if (state === false) die("not-signed-in", "dashboard redirected to login");
374
- if (typeof state === "string") {
375
- logPhase(`phase=dashboard-nav-complete accountId=${state.slice(0, 8)}…`);
376
- return state;
377
- }
378
- await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
379
- }
380
- // Timed out without seeing either pattern — treat as not-signed-in to
381
- // prompt `tunnel-login`; a genuinely-slow dashboard that later resolves
382
- // would be a false-negative we accept in exchange for deterministic UX.
383
- die("not-signed-in", "no /<accountId>/… path reached within budget");
384
- }
385
-
386
- export interface ScrapeOutcome {
387
- reason: "ok" | "no-account-id";
388
- domains: string[];
389
- }
390
-
391
- // Pure scrape logic — extracts the zones on the current Cloudflare dashboard
392
- // page by matching `<a href="/<accountId>/<zone>…">` links. Exported for
393
- // direct in-process testing under JSDOM (see `list-cf-domains-scrape.test.ts`),
394
- // and serialised into `SCRAPE_EXPRESSION` below for execution inside the
395
- // operator's VNC Chromium via CDP `Runtime.evaluate`. The function takes its
396
- // document and location as arguments (rather than reading globals) so the
397
- // JSDOM test can pass in its window's document+location directly — test and
398
- // prod share the same implementation, with no duplication and no eval.
399
- //
400
- // Note: this function body is serialised via `Function.prototype.toString()`,
401
- // so it must be fully self-contained — no imports, no captures from outer
402
- // scope. Node-side helpers belong outside.
403
- export function scrapeCurrentPage(
404
- document: Document,
405
- location: Location,
406
- ): ScrapeOutcome {
407
- const accountIdMatch = location.pathname.match(/^\/([a-f0-9]{32})/);
408
- if (!accountIdMatch) return { reason: "no-account-id", domains: [] };
409
- const accountId = accountIdMatch[1];
410
-
411
- const out = new Set<string>();
412
- const pushIfDomain = (s: unknown): void => {
413
- if (typeof s !== "string") return;
414
- const t = s.trim().toLowerCase();
415
- if (
416
- !/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$/.test(
417
- t,
418
- )
419
- )
420
- return;
421
- if (t.endsWith(".cloudflare.com") || t === "cloudflare.com") return;
422
- out.add(t);
423
- };
424
-
425
- // The only scrape source: `/<accountId>/<host>` hrefs — the canonical CF
426
- // routing pattern the overview page uses to link each zone to its
427
- // management screens. The full-<main> FQDN text walker was removed
428
- // after it returned a bogus FQDN-shaped string from static page copy
429
- // (see module header). When this source returns empty, the caller's
430
- // `empty-or-drift` branch snapshots the page HTML for diagnosis — loud
431
- // drift signal, not silent wrong data.
432
- const hrefPrefix = "/" + accountId + "/";
433
- for (const a of document.querySelectorAll("a[href]")) {
434
- const href = a.getAttribute("href") || "";
435
- if (!href.startsWith(hrefPrefix)) continue;
436
- const tail = href.slice(hrefPrefix.length).split("?")[0].split("#")[0];
437
- const firstSegment = tail.split("/")[0];
438
- pushIfDomain(firstSegment);
439
- }
440
-
441
- return { reason: "ok", domains: Array.from(out).sort() };
442
- }
443
-
444
- // The serialised form the CDP Runtime.evaluate executes inside the operator's
445
- // VNC Chromium. Deriving it from `scrapeCurrentPage.toString()` means the
446
- // browser-side and the test-side share one implementation — drift between
447
- // them is impossible by construction.
448
- export const SCRAPE_EXPRESSION = `(${scrapeCurrentPage.toString()})(document, location)`;
449
-
450
- // Minimum surface `scrapeDomains` needs from the CDP client: execute a JS
451
- // expression in the target page and return the serialised value. Typing the
452
- // dependency at this width lets the vitest regression inject a mock without
453
- // constructing a full CdpClient + WebSocket.
454
- export type CdpEvaluator = (expression: string) => Promise<unknown>;
455
-
456
- // Trailing-run length (consecutive polls at `max_count`) the complete-line
457
- // asserts before it declares `unstable=false`. Two iterations at 500 ms each
458
- // = 1 s of continuous observation at the high-water mark. removed
459
- // the early-exit on this threshold (it shortcuts multi-zone accounts where
460
- // the first zone hydrates one poll ahead of the second — the plateau at the
461
- // intermediate count trips the threshold before the final zone arrives);
462
- // the threshold now only labels the exit, it does not gate it.
463
- const STABLE_POLL_THRESHOLD = 2;
464
-
465
- // Cap on `document.documentElement.outerHTML` captured into the dump file.
466
- // The observed Cloudflare dashboard outerHTML is ~1 MB; 5 MB is 5× headroom
467
- // and still fits comfortably in one `writeFile` syscall on the Pi. An earlier
468
- // 100 KB cap truncated the dump mid-`<head>` CSS on every scrape,
469
- // rendering the forensic artifact silently broken: the
470
- // zone table lived past the cutoff and `grep <zone-host> <dump>` always
471
- // returned nothing, forcing re-scrapes to answer post-hoc questions.
472
- const OUTER_HTML_CAPTURE_MAX_CHARS = 5_000_000;
473
-
474
- // Cap the `domains=[…]` payload on per-poll and complete phase lines. The
475
- // extractor already implicitly bounds the list via the FQDN regex + Set
476
- // dedupe, but a pathological CF redesign could emit thousands of href
477
- // matches; the cap keeps the stream log readable without masking the real
478
- // capture size (the `count=` field carries the exact n).
479
- const DOMAINS_PAYLOAD_MAX_CHARS = 1000;
480
-
481
- function formatDomains(domains: string[]): string {
482
- const joined = domains.join(",");
483
- return joined.length > DOMAINS_PAYLOAD_MAX_CHARS
484
- ? joined.slice(0, DOMAINS_PAYLOAD_MAX_CHARS - 3) + "..."
485
- : joined;
486
- }
487
-
488
- type DumpMode = "stable" | "unstable" | "empty-or-drift";
489
- type DumpResult = { path: string; truncated: boolean } | { err: string };
490
-
491
- // Snapshot the operator's current dashboard HTML so post-hoc diagnosis of a
492
- // partial, unstable, or empty scrape has the exact DOM the scrape observed
493
- // at exit. Called on all three `scrapeDomains` exit paths — the filename's
494
- // `mode` field names which path fired, and the `pid<pid>` suffix prevents
495
- // collision if two invocations land in the same millisecond. Loud-fail
496
- // preserved: on any throw (evaluator rejected, CONFIG_DIR unset, writeFile
497
- // ENOENT), return `{err}` so the caller emits a separate
498
- // `phase=dump-write-failed` line and the complete line carries
499
- // `dump=failed`. The scrape's real signal must never be masked.
500
- async function dumpHtml(
501
- evaluator: CdpEvaluator,
502
- count: number,
503
- mode: DumpMode,
504
- ): Promise<DumpResult> {
505
- try {
506
- const html = (await evaluator(
507
- `document.documentElement.outerHTML.slice(0, ${OUTER_HTML_CAPTURE_MAX_CHARS})`,
508
- )) as string;
509
- // CONFIG_DIR is set by list-cf-domains.sh before the spawn. A silent
510
- // fallback would dump logs into the wrong brand's directory on a Real
511
- // Agent install — a silent-miswrite masking the wrapper-side break it
512
- // came from. doctrine: loud-fail on absent runtime-derived
513
- // values.
514
- const configDir = process.env.CONFIG_DIR;
515
- if (!configDir) {
516
- throw new Error(
517
- "CONFIG_DIR env var not set by wrapper — refusing to guess brand log directory",
518
- );
519
- }
520
- const logDir = resolve(homedir(), configDir, "logs");
521
- const ts = new Date().toISOString().replace(/[:.]/g, "-");
522
- const dumpPath = resolve(
523
- logDir,
524
- `list-cf-domains-${ts}-count${count}-${mode}-pid${process.pid}.html`,
525
- );
526
- const htmlStr = typeof html === "string" ? html : String(html);
527
- await writeFile(dumpPath, htmlStr, "utf-8");
528
- // Heuristic: if the slice returned exactly OUTER_HTML_CAPTURE_MAX_CHARS,
529
- // the source outerHTML almost certainly exceeded the cap and we
530
- // truncated mid-document. The false positive (outerHTML exactly at the
531
- // ceiling) is a rounding coincidence on the order of 1-in-5M and is
532
- // acceptable — a loud truncation signal with a ~10^-7 false-positive
533
- // rate is strictly better than silent truncation. A downstream
534
- // investigator seeing `truncated=true` can re-scrape with a larger
535
- // cap or `outerHTML` unsliced.
536
- const truncated = htmlStr.length >= OUTER_HTML_CAPTURE_MAX_CHARS;
537
- return { path: dumpPath, truncated };
538
- } catch (err) {
539
- return {
540
- err: (err instanceof Error ? err.message : String(err)).slice(0, 120),
541
- };
542
- }
543
- }
544
-
545
- // Render the `dump=<path>` field for a complete-line, and emit a separate
546
- // loud-fail `phase=dump-write-failed` line on failure. Keeps the on-success
547
- // complete line terse and routes every failure through the same observation
548
- // primitive so investigators have one grep pattern for all dump write errors.
549
- function dumpField(result: DumpResult, mode: DumpMode): string {
550
- if ("path" in result) {
551
- if (result.truncated) {
552
- // Loud separate phase line — an investigator grepping for truncation
553
- // incidents across the stream log has one exact pattern to match.
554
- logPhase(
555
- `phase=dump-truncated mode=${mode} path=${result.path} captured_bytes=${OUTER_HTML_CAPTURE_MAX_CHARS}`,
556
- );
557
- return `dump=${result.path} dump_truncated=true`;
558
- }
559
- return `dump=${result.path}`;
560
- }
561
- logPhase(
562
- `phase=dump-write-failed mode=${mode} detail="${result.err.replace(/"/g, "'")}"`,
563
- );
564
- return "dump=failed";
565
- }
566
-
567
- export async function scrapeDomains(evaluator: CdpEvaluator): Promise<string[]> {
568
- const deadline = Date.now() + SCRAPE_POLL_MS;
569
- // pure poll-to-deadline. The earlier "first N stable polls wins"
570
- // heuristic short-circuited on the first plateau a multi-zone dashboard
571
- // rendered — a 2-zone account whose second zone hydrates one poll later
572
- // than the first would lock `count=1` before the second zone arrived.
573
- // Removing early-exit entirely is the only formulation that eliminates the
574
- // class: any "N-consec-at-max wins" variant still fires on the intermediate
575
- // plateau (`[] [] [a] [a] [a,b] …` → threshold met at poll 4 before zone B
576
- // observed). The cost is ~9 s of happy-path latency under the 10 s budget
577
- // (still within onboarding tolerance). `max_count` tracks the high-water
578
- // count; `consecutiveAtMax` tracks the trailing run at that water-mark;
579
- // `seenDecrease` tracks any mid-trajectory count drop. `unstable=false`
580
- // requires reaching max, ending at max, at least STABLE_POLL_THRESHOLD
581
- // consecutive trailing polls at max, and no mid-trajectory decrease — so a
582
- // trajectory like `[a][a][a][a,b]{never reaches 2 consecutive}` correctly
583
- // reports `unstable=true` even though `final_count === max_count`.
584
- let lastOutcome: ScrapeOutcome | null = null;
585
- let maxCount = 0;
586
- // Snapshot of the domains array at the most-recent observation of
587
- // `maxCount`. Updated on every `>=` poll so it always reflects the latest
588
- // capture at the high-water mark; returned at deadline when `maxCount > 0`.
589
- let maxDomains: string[] = [];
590
- let finalCount = 0;
591
- let consecutiveAtMax = 0;
592
- let seenDecrease = false;
593
- let polls = 0;
594
-
595
- while (Date.now() < deadline) {
596
- polls += 1;
597
- try {
598
- const outcome = (await evaluator(SCRAPE_EXPRESSION)) as ScrapeOutcome;
599
- lastOutcome = outcome;
600
- const count = outcome.domains.length;
601
-
602
- logPhase(
603
- `phase=dom-scrape-poll n=${polls} count=${count} domains=[${formatDomains(outcome.domains)}]`,
604
- );
605
-
606
- if (outcome.reason === "ok" && count > 0) {
607
- if (count > maxCount) {
608
- // New high-water mark. Post-stable increases reset the run counter —
609
- // the new stable window must also satisfy the threshold to qualify
610
- // as `unstable=false`.
611
- maxCount = count;
612
- maxDomains = outcome.domains;
613
- consecutiveAtMax = 1;
614
- } else if (count === maxCount) {
615
- maxDomains = outcome.domains;
616
- consecutiveAtMax += 1;
617
- } else {
618
- // count > 0 && count < maxCount — the page shrunk mid-poll
619
- // (virtual-scroll pagination, zone deletion concurrent with scrape,
620
- // SPA re-render collapsing rows). Preserve the high-water snapshot;
621
- // flag the trajectory as unstable.
622
- seenDecrease = true;
623
- consecutiveAtMax = 0;
624
- }
625
- finalCount = count;
626
- } else {
627
- // Empty poll or non-ok reason (no-account-id — SPA mid-navigation):
628
- // the page momentarily showed no zones, breaking any trailing at-max
629
- // run. Don't flip `seenDecrease` — treating a hydration gap as a
630
- // "shrink" would be a false positive on the leading empty polls of
631
- // every scrape. `finalCount` reflects the last poll's observed count
632
- // (always 0 in this branch, since `scrapeCurrentPage` returns empty
633
- // domains on any non-ok reason), so the stream log's `final_count`
634
- // field accurately names what the last poll saw, not a stale value
635
- // from an earlier successful observation.
636
- consecutiveAtMax = 0;
637
- finalCount = count;
638
- }
639
- } catch (err) {
640
- logPhase(
641
- `phase=scrape-retry n=${polls} err="${(err instanceof Error ? err.message : String(err)).slice(0, 120)}"`,
642
- );
643
- }
644
- await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
645
- }
646
-
647
- // Deadline reached. Two branches:
648
- //
649
- // (i) We observed at least one non-empty poll. Return the max-count
650
- // snapshot (which, when `finalCount === maxCount`, is also the last
651
- // non-empty observation). `unstable=true` whenever any signal suggests
652
- // the page wasn't settled: shrinkage, end-below-max, or fewer than
653
- // STABLE_POLL_THRESHOLD consecutive trailing polls at max.
654
- //
655
- // (ii) Every poll was empty — either a genuinely empty account OR CF
656
- // drifted the href shape. The `empty-or-drift` dump is the operator's
657
- // single-artifact signal for distinguishing the two.
658
- if (maxCount > 0) {
659
- const unstable =
660
- seenDecrease ||
661
- finalCount !== maxCount ||
662
- consecutiveAtMax < STABLE_POLL_THRESHOLD;
663
- const mode: DumpMode = unstable ? "unstable" : "stable";
664
- const dump = await dumpHtml(evaluator, maxCount, mode);
665
- logPhase(
666
- `phase=dom-scrape-complete result=ok count=${maxCount} total_polls=${polls} max_count=${maxCount} final_count=${finalCount} stable_polls=${consecutiveAtMax} unstable=${unstable} domains=[${formatDomains(maxDomains)}] ${dumpField(dump, mode)}`,
667
- );
668
- return maxDomains;
669
- }
670
-
671
- const dump = await dumpHtml(evaluator, 0, "empty-or-drift");
672
- logPhase(
673
- `phase=dom-scrape-complete result=empty-or-drift count=0 total_polls=${polls} max_count=0 final_count=0 stable_polls=0 lastReason=${lastOutcome?.reason ?? "unknown"} ${dumpField(dump, "empty-or-drift")}`,
674
- );
675
- return [];
676
- }
677
-
678
- async function navigate(cdp: CdpClient, url: string): Promise<void> {
679
- const result = await cdp.send<{ errorText?: string }>("Page.navigate", { url });
680
- if (result?.errorText) die("navigate-failed", `Page.navigate errorText="${result.errorText}"`);
681
- }
682
-
683
- async function waitForDocumentReady(cdp: CdpClient): Promise<void> {
684
- const deadline = Date.now() + NAVIGATE_TIMEOUT_MS;
685
- while (Date.now() < deadline) {
686
- const state = await evaluate<string>(cdp, "document.readyState");
687
- if (state === "complete" || state === "interactive") return;
688
- await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
689
- }
690
- die("navigate-failed", "document.readyState did not reach complete/interactive");
691
- }
692
-
693
- async function main(): Promise<void> {
694
- const endpoint = resolveCdpEndpoint();
695
- logPhase(`phase=script-start cdp=${endpoint.host}:${endpoint.port} source=${endpoint.source}`);
696
-
697
- await cdpVersion(endpoint.host, endpoint.port);
698
- logPhase(`phase=cdp-connect result=ok`);
699
-
700
- const target = await createTarget(endpoint.host, endpoint.port);
701
- logPhase(`phase=target-created targetId=${target.id.slice(0, 8)}…`);
702
-
703
- let cdp: CdpClient;
704
- try {
705
- cdp = await CdpClient.connect(target.webSocketDebuggerUrl);
706
- } catch (err) {
707
- await closeTarget(endpoint.host, endpoint.port, target.id);
708
- die("ws-connect-failed", err instanceof Error ? err.message : String(err));
709
- }
710
-
711
- try {
712
- await cdp.send("Page.enable");
713
- await cdp.send("Runtime.enable");
714
-
715
- logPhase(`phase=dashboard-nav-start url=https://dash.cloudflare.com/`);
716
- await navigate(cdp, "https://dash.cloudflare.com/");
717
- await waitForDocumentReady(cdp);
718
-
719
- const accountId = await waitForSignedIn(cdp);
720
-
721
- const overviewUrl = `https://dash.cloudflare.com/${accountId}/domains/overview`;
722
- logPhase(`phase=table-wait url=${overviewUrl}`);
723
- await navigate(cdp, overviewUrl);
724
- await waitForDocumentReady(cdp);
725
-
726
- const domains = await scrapeDomains((expr) => evaluate(cdp, expr));
727
-
728
- logPhase(`phase=target-closed`);
729
- process.stdout.write(JSON.stringify(domains) + "\n");
730
- logPhase(`phase=script-exit code=0 count=${domains.length}`);
731
- } catch (err) {
732
- // Only runtime errors that escaped the typed-reason paths land here.
733
- die("runtime-error", err instanceof Error ? err.message : String(err));
734
- } finally {
735
- cdp.close();
736
- await closeTarget(endpoint.host, endpoint.port, target.id);
737
- }
738
- }
739
-
740
- // Entry-point gate: only run `main()` when this module is the script Node was
741
- // invoked with, not when imported by the vitest regressions that exercise
742
- // `scrapeCurrentPage` / `scrapeDomains` directly. Without the gate, importing
743
- // this module would trigger a live CDP connection attempt and process.exit(1)
744
- // inside the test process.
745
- if (process.argv[1] && process.argv[1] === fileURLToPath(import.meta.url)) {
746
- main().catch((err: unknown) => {
747
- die("runtime-error", err instanceof Error ? err.message : String(err));
748
- });
749
- }