@vigilbase/cli 0.1.4 → 0.2.1

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 (191) hide show
  1. package/CLOUDFLARE.md +3 -4
  2. package/COMMANDS.md +158 -83
  3. package/LOCAL.md +721 -0
  4. package/ONE.md +2 -4
  5. package/README.md +341 -123
  6. package/SECURITY.md +2 -2
  7. package/dist/auth/device-auth.d.ts +0 -1
  8. package/dist/auth/device-auth.js +13 -8
  9. package/dist/auth/index.d.ts +4 -2
  10. package/dist/auth/index.js +3 -2
  11. package/dist/auth/provider.d.ts +18 -0
  12. package/dist/auth/provider.js +71 -0
  13. package/dist/auth/refresh.d.ts +29 -0
  14. package/dist/auth/refresh.js +199 -0
  15. package/dist/auth/store.d.ts +103 -4
  16. package/dist/auth/store.js +400 -74
  17. package/dist/auth/token-metadata.d.ts +9 -0
  18. package/dist/auth/token-metadata.js +21 -0
  19. package/dist/auth/types.d.ts +8 -2
  20. package/dist/auth/types.js +0 -1
  21. package/dist/bin.d.ts +0 -1
  22. package/dist/bin.js +0 -1
  23. package/dist/cli.d.ts +4 -2
  24. package/dist/cli.js +45 -15
  25. package/dist/commands/account.d.ts +0 -1
  26. package/dist/commands/account.js +0 -1
  27. package/dist/commands/auth.d.ts +0 -1
  28. package/dist/commands/auth.js +82 -22
  29. package/dist/commands/billing.d.ts +0 -1
  30. package/dist/commands/billing.js +0 -1
  31. package/dist/commands/cloudflare.d.ts +1 -2
  32. package/dist/commands/cloudflare.js +6 -6
  33. package/dist/commands/helpers.d.ts +15 -1
  34. package/dist/commands/helpers.js +2 -3
  35. package/dist/commands/index.d.ts +3 -2
  36. package/dist/commands/index.js +6 -2
  37. package/dist/commands/legacy-validation.d.ts +0 -1
  38. package/dist/commands/legacy-validation.js +0 -1
  39. package/dist/commands/local.d.ts +35 -0
  40. package/dist/commands/local.js +1543 -0
  41. package/dist/commands/one.d.ts +0 -1
  42. package/dist/commands/one.js +6 -7
  43. package/dist/commands/public-tools.d.ts +0 -1
  44. package/dist/commands/public-tools.js +55 -17
  45. package/dist/commands/runs.d.ts +0 -1
  46. package/dist/commands/runs.js +0 -1
  47. package/dist/commands/security.d.ts +1 -2
  48. package/dist/commands/security.js +1 -2
  49. package/dist/commands/watchdog.d.ts +0 -1
  50. package/dist/commands/watchdog.js +0 -1
  51. package/dist/index.d.ts +3 -3
  52. package/dist/index.js +1 -2
  53. package/dist/lib/banner.d.ts +0 -1
  54. package/dist/lib/banner.js +0 -1
  55. package/dist/lib/client.d.ts +4 -2
  56. package/dist/lib/client.js +42 -18
  57. package/dist/lib/cloudflare-client.d.ts +0 -1
  58. package/dist/lib/cloudflare-client.js +0 -1
  59. package/dist/lib/config.d.ts +31 -3
  60. package/dist/lib/config.js +72 -13
  61. package/dist/lib/format.d.ts +2 -1
  62. package/dist/lib/format.js +27 -7
  63. package/dist/lib/help.d.ts +1 -2
  64. package/dist/lib/help.js +37 -17
  65. package/dist/lib/network.d.ts +10 -1
  66. package/dist/lib/network.js +33 -5
  67. package/dist/lib/one-client.d.ts +5 -3
  68. package/dist/lib/one-client.js +49 -3
  69. package/dist/lib/one-operations.d.ts +0 -1
  70. package/dist/lib/one-operations.js +0 -1
  71. package/dist/lib/open-url.d.ts +0 -1
  72. package/dist/lib/open-url.js +0 -1
  73. package/dist/lib/platform.d.ts +2 -0
  74. package/dist/lib/platform.js +4 -0
  75. package/dist/lib/progress.d.ts +0 -1
  76. package/dist/lib/progress.js +0 -1
  77. package/dist/lib/security-client.d.ts +1 -2
  78. package/dist/lib/security-client.js +0 -1
  79. package/dist/lib/skill-fetch.d.ts +0 -1
  80. package/dist/lib/skill-fetch.js +0 -1
  81. package/dist/lib/system-trust.d.ts +0 -1
  82. package/dist/lib/system-trust.js +0 -1
  83. package/dist/lib/tui.d.ts +308 -0
  84. package/dist/lib/tui.js +846 -0
  85. package/dist/local/cloudflare-checkup/cf/allowlist.d.ts +94 -0
  86. package/dist/local/cloudflare-checkup/cf/allowlist.js +183 -0
  87. package/dist/local/cloudflare-checkup/cf/auth-lifecycle.d.ts +112 -0
  88. package/dist/local/cloudflare-checkup/cf/auth-lifecycle.js +312 -0
  89. package/dist/local/cloudflare-checkup/cf/child-env.d.ts +31 -0
  90. package/dist/local/cloudflare-checkup/cf/child-env.js +80 -0
  91. package/dist/local/cloudflare-checkup/cf/install.d.ts +105 -0
  92. package/dist/local/cloudflare-checkup/cf/install.js +308 -0
  93. package/dist/local/cloudflare-checkup/cf/locate.d.ts +43 -0
  94. package/dist/local/cloudflare-checkup/cf/locate.js +227 -0
  95. package/dist/local/cloudflare-checkup/cf/run-cf.d.ts +88 -0
  96. package/dist/local/cloudflare-checkup/cf/run-cf.js +274 -0
  97. package/dist/local/cloudflare-checkup/collector.d.ts +47 -0
  98. package/dist/local/cloudflare-checkup/collector.js +295 -0
  99. package/dist/local/cloudflare-checkup/contract.d.ts +2804 -0
  100. package/dist/local/cloudflare-checkup/contract.js +1620 -0
  101. package/dist/local/cloudflare-checkup/email-verification.d.ts +124 -0
  102. package/dist/local/cloudflare-checkup/email-verification.js +211 -0
  103. package/dist/local/cloudflare-checkup/errors.d.ts +29 -0
  104. package/dist/local/cloudflare-checkup/errors.js +103 -0
  105. package/dist/local/cloudflare-checkup/output.d.ts +23 -0
  106. package/dist/local/cloudflare-checkup/output.js +88 -0
  107. package/dist/local/cloudflare-checkup/prompts.d.ts +40 -0
  108. package/dist/local/cloudflare-checkup/prompts.js +139 -0
  109. package/dist/local/cloudflare-checkup/rate-budget.d.ts +41 -0
  110. package/dist/local/cloudflare-checkup/rate-budget.js +79 -0
  111. package/dist/local/cloudflare-checkup/snapshot-file.d.ts +52 -0
  112. package/dist/local/cloudflare-checkup/snapshot-file.js +185 -0
  113. package/dist/local/cloudflare-checkup/submit-client.d.ts +112 -0
  114. package/dist/local/cloudflare-checkup/submit-client.js +395 -0
  115. package/dist/local/cloudflare-checkup/terminal-ui.d.ts +170 -0
  116. package/dist/local/cloudflare-checkup/terminal-ui.js +403 -0
  117. package/dist/types.d.ts +25 -3
  118. package/dist/types.js +16 -1
  119. package/dist/version.d.ts +0 -1
  120. package/dist/version.js +0 -1
  121. package/package.json +12 -12
  122. package/dist/auth/device-auth.d.ts.map +0 -1
  123. package/dist/auth/device-auth.js.map +0 -1
  124. package/dist/auth/index.d.ts.map +0 -1
  125. package/dist/auth/index.js.map +0 -1
  126. package/dist/auth/store.d.ts.map +0 -1
  127. package/dist/auth/store.js.map +0 -1
  128. package/dist/auth/types.d.ts.map +0 -1
  129. package/dist/auth/types.js.map +0 -1
  130. package/dist/bin.d.ts.map +0 -1
  131. package/dist/bin.js.map +0 -1
  132. package/dist/cli.d.ts.map +0 -1
  133. package/dist/cli.js.map +0 -1
  134. package/dist/commands/account.d.ts.map +0 -1
  135. package/dist/commands/account.js.map +0 -1
  136. package/dist/commands/auth.d.ts.map +0 -1
  137. package/dist/commands/auth.js.map +0 -1
  138. package/dist/commands/billing.d.ts.map +0 -1
  139. package/dist/commands/billing.js.map +0 -1
  140. package/dist/commands/cloudflare.d.ts.map +0 -1
  141. package/dist/commands/cloudflare.js.map +0 -1
  142. package/dist/commands/helpers.d.ts.map +0 -1
  143. package/dist/commands/helpers.js.map +0 -1
  144. package/dist/commands/index.d.ts.map +0 -1
  145. package/dist/commands/index.js.map +0 -1
  146. package/dist/commands/legacy-validation.d.ts.map +0 -1
  147. package/dist/commands/legacy-validation.js.map +0 -1
  148. package/dist/commands/one.d.ts.map +0 -1
  149. package/dist/commands/one.js.map +0 -1
  150. package/dist/commands/public-tools.d.ts.map +0 -1
  151. package/dist/commands/public-tools.js.map +0 -1
  152. package/dist/commands/runs.d.ts.map +0 -1
  153. package/dist/commands/runs.js.map +0 -1
  154. package/dist/commands/security.d.ts.map +0 -1
  155. package/dist/commands/security.js.map +0 -1
  156. package/dist/commands/watchdog.d.ts.map +0 -1
  157. package/dist/commands/watchdog.js.map +0 -1
  158. package/dist/index.d.ts.map +0 -1
  159. package/dist/index.js.map +0 -1
  160. package/dist/lib/banner.d.ts.map +0 -1
  161. package/dist/lib/banner.js.map +0 -1
  162. package/dist/lib/client.d.ts.map +0 -1
  163. package/dist/lib/client.js.map +0 -1
  164. package/dist/lib/cloudflare-client.d.ts.map +0 -1
  165. package/dist/lib/cloudflare-client.js.map +0 -1
  166. package/dist/lib/config.d.ts.map +0 -1
  167. package/dist/lib/config.js.map +0 -1
  168. package/dist/lib/format.d.ts.map +0 -1
  169. package/dist/lib/format.js.map +0 -1
  170. package/dist/lib/help.d.ts.map +0 -1
  171. package/dist/lib/help.js.map +0 -1
  172. package/dist/lib/network.d.ts.map +0 -1
  173. package/dist/lib/network.js.map +0 -1
  174. package/dist/lib/one-client.d.ts.map +0 -1
  175. package/dist/lib/one-client.js.map +0 -1
  176. package/dist/lib/one-operations.d.ts.map +0 -1
  177. package/dist/lib/one-operations.js.map +0 -1
  178. package/dist/lib/open-url.d.ts.map +0 -1
  179. package/dist/lib/open-url.js.map +0 -1
  180. package/dist/lib/progress.d.ts.map +0 -1
  181. package/dist/lib/progress.js.map +0 -1
  182. package/dist/lib/security-client.d.ts.map +0 -1
  183. package/dist/lib/security-client.js.map +0 -1
  184. package/dist/lib/skill-fetch.d.ts.map +0 -1
  185. package/dist/lib/skill-fetch.js.map +0 -1
  186. package/dist/lib/system-trust.d.ts.map +0 -1
  187. package/dist/lib/system-trust.js.map +0 -1
  188. package/dist/types.d.ts.map +0 -1
  189. package/dist/types.js.map +0 -1
  190. package/dist/version.d.ts.map +0 -1
  191. package/dist/version.js.map +0 -1
@@ -0,0 +1,274 @@
1
+ /**
2
+ * Spawning cf (spec §F3, §F6).
3
+ *
4
+ * cf always runs as `process.execPath <cf entry> …argv` with no shell (and so
5
+ * no `.cmd` shim on Windows), from a private, empty 0700 working directory, so
6
+ * a project's `.env`, `.env.local`, `cloudflare.config.ts`, directory profile
7
+ * bindings and account cache never apply. Output is capped, stderr is never
8
+ * echoed, and every failure is reduced to a small classification.
9
+ */
10
+ import { spawn } from 'node:child_process';
11
+ import { chmod, mkdtemp, rm } from 'node:fs/promises';
12
+ import { tmpdir } from 'node:os';
13
+ import { join } from 'node:path';
14
+ import { stripVTControlCharacters } from 'node:util';
15
+ import { CF_TOKEN_ENV } from './child-env.js';
16
+ export const CF_CALL_TIMEOUT_MS = 45_000;
17
+ export const STDOUT_CAP_BYTES = 8 * 1024 * 1024;
18
+ /** `rulesets get` bodies can be large managed rulesets. */
19
+ export const RULESET_STDOUT_CAP_BYTES = 16 * 1024 * 1024;
20
+ export const STDERR_CAP_BYTES = 64 * 1024;
21
+ const KILL_GRACE_MS = 2_000;
22
+ /**
23
+ * Flags that skip confirmations, switch cf to local simulation or project
24
+ * config, or send a request body. No allowlisted argv contains them; the
25
+ * runner refuses to spawn one that does.
26
+ */
27
+ export const FORBIDDEN_CF_FLAGS = Object.freeze(['--force', '-f', '--yes', '-y', '--local', '--mode', '-m', '--body', '--persist-to']);
28
+ export function assertSafeCfArgv(argv) {
29
+ for (const arg of argv) {
30
+ const flag = arg.split('=')[0];
31
+ if (FORBIDDEN_CF_FLAGS.includes(flag))
32
+ throw new Error(`Refusing to run cf with ${flag}.`);
33
+ }
34
+ }
35
+ /** Owns the private working directory and every live cf child. */
36
+ export class CfProcessRunner {
37
+ entry;
38
+ privateDir;
39
+ active = new Set();
40
+ disposed = false;
41
+ /** Set once the runner kills its children (abort or shutdown), so those kills are not read as timeouts. */
42
+ terminating = false;
43
+ constructor(entry, privateDir) {
44
+ this.entry = entry;
45
+ this.privateDir = privateDir;
46
+ }
47
+ static async create(entry) {
48
+ const privateDir = await mkdtemp(join(tmpdir(), 'vigilbase-cf-'));
49
+ await chmod(privateDir, 0o700);
50
+ return new CfProcessRunner(entry, privateDir);
51
+ }
52
+ run(argv, options) {
53
+ assertSafeCfArgv(argv);
54
+ if (this.disposed)
55
+ return Promise.resolve(failedToStart());
56
+ const timeoutMs = options.timeoutMs ?? CF_CALL_TIMEOUT_MS;
57
+ const stdoutCap = options.stdoutCapBytes ?? STDOUT_CAP_BYTES;
58
+ return new Promise((resolve) => {
59
+ let child;
60
+ try {
61
+ child = spawn(process.execPath, [this.entry, ...argv], {
62
+ shell: false,
63
+ cwd: this.privateDir,
64
+ env: options.env,
65
+ stdio: ['ignore', 'pipe', 'pipe'],
66
+ windowsHide: true,
67
+ timeout: timeoutMs,
68
+ killSignal: 'SIGKILL',
69
+ });
70
+ }
71
+ catch {
72
+ resolve(failedToStart());
73
+ return;
74
+ }
75
+ this.active.add(child);
76
+ const stdout = [];
77
+ const stderr = [];
78
+ let stdoutBytes = 0;
79
+ let stderrBytes = 0;
80
+ let timedOut = false;
81
+ let stdoutTooLarge = false;
82
+ let settled = false;
83
+ // Node's own `timeout` option kills the child too; this timer only makes
84
+ // the cause observable when it wins the race.
85
+ const timer = setTimeout(() => {
86
+ timedOut = true;
87
+ child.kill('SIGKILL');
88
+ }, timeoutMs);
89
+ timer.unref?.();
90
+ child.stdout?.on('data', (chunk) => {
91
+ if (stdoutTooLarge)
92
+ return;
93
+ stdoutBytes += chunk.byteLength;
94
+ if (stdoutBytes > stdoutCap) {
95
+ stdoutTooLarge = true;
96
+ stdout.length = 0;
97
+ child.kill('SIGKILL');
98
+ return;
99
+ }
100
+ stdout.push(chunk);
101
+ });
102
+ child.stderr?.on('data', (chunk) => {
103
+ if (stderrBytes >= STDERR_CAP_BYTES)
104
+ return;
105
+ const room = STDERR_CAP_BYTES - stderrBytes;
106
+ stderr.push(chunk.byteLength > room ? chunk.subarray(0, room) : chunk);
107
+ stderrBytes += Math.min(chunk.byteLength, room);
108
+ });
109
+ const finish = (exitCode, signal, spawnFailed) => {
110
+ if (settled)
111
+ return;
112
+ settled = true;
113
+ clearTimeout(timer);
114
+ this.active.delete(child);
115
+ // A child killed by a signal we did not send for the stdout cap or a
116
+ // shutdown was killed by the spawn timeout.
117
+ if (signal !== null && !stdoutTooLarge && !this.terminating)
118
+ timedOut = true;
119
+ resolve({
120
+ exitCode,
121
+ stdout: Buffer.concat(stdout).toString('utf8'),
122
+ stderr: Buffer.concat(stderr).toString('utf8'),
123
+ timedOut,
124
+ stdoutTooLarge,
125
+ spawnFailed,
126
+ });
127
+ };
128
+ child.once('error', () => finish(null, null, true));
129
+ child.once('close', (code, signal) => finish(typeof code === 'number' ? code : null, signal ?? null, false));
130
+ });
131
+ }
132
+ /**
133
+ * The only interactive cf passthroughs: `cf auth create` (sign-in) and
134
+ * `cf auth delete` (logout). With `stdoutToStderr` (`--json`), cf's stdout
135
+ * is sent to stderr so the CLI's stdout stays a single JSON object.
136
+ */
137
+ runInteractive(argv, env, options = {}) {
138
+ assertSafeCfArgv(argv);
139
+ return new Promise((resolve, reject) => {
140
+ const child = spawn(process.execPath, [this.entry, ...argv], {
141
+ shell: false, cwd: this.privateDir, env, stdio: ['inherit', options.stdoutToStderr ? 2 : 'inherit', 'inherit'], windowsHide: true,
142
+ });
143
+ this.active.add(child);
144
+ child.once('error', (error) => {
145
+ this.active.delete(child);
146
+ reject(error);
147
+ });
148
+ child.once('close', (code) => {
149
+ this.active.delete(child);
150
+ resolve(typeof code === 'number' ? code : 1);
151
+ });
152
+ });
153
+ }
154
+ get activeCount() {
155
+ return this.active.size;
156
+ }
157
+ /** SIGTERM every live child, then SIGKILL whatever is still running after 2 s. */
158
+ async terminateAll() {
159
+ this.terminating = true;
160
+ if (this.active.size === 0)
161
+ return;
162
+ const children = [...this.active];
163
+ for (const child of children)
164
+ child.kill('SIGTERM');
165
+ await new Promise((resolve) => {
166
+ const timer = setTimeout(() => {
167
+ for (const child of children)
168
+ if (child.exitCode === null && child.signalCode === null)
169
+ child.kill('SIGKILL');
170
+ resolve();
171
+ }, KILL_GRACE_MS);
172
+ timer.unref?.();
173
+ Promise.all(children.map((child) => child.exitCode !== null || child.signalCode !== null
174
+ ? Promise.resolve()
175
+ : new Promise((done) => child.once('close', () => done())))).then(() => {
176
+ clearTimeout(timer);
177
+ resolve();
178
+ }, () => undefined);
179
+ });
180
+ }
181
+ async dispose() {
182
+ if (this.disposed)
183
+ return;
184
+ this.disposed = true;
185
+ await this.terminateAll();
186
+ await rm(this.privateDir, { recursive: true, force: true });
187
+ }
188
+ }
189
+ function failedToStart() {
190
+ return { exitCode: null, stdout: '', stderr: '', timedOut: false, stdoutTooLarge: false, spawnFailed: true };
191
+ }
192
+ /**
193
+ * Run `work` with a private cf runner; the directory is removed afterwards and
194
+ * on SIGINT, when live children get SIGTERM and then SIGKILL.
195
+ */
196
+ export async function withCfRunner(entry, work) {
197
+ const runner = await CfProcessRunner.create(entry);
198
+ const onSignal = () => {
199
+ void runner.dispose().finally(() => process.exit(130));
200
+ };
201
+ process.once('SIGINT', onSignal);
202
+ try {
203
+ return await work(runner);
204
+ }
205
+ finally {
206
+ process.removeListener('SIGINT', onSignal);
207
+ await runner.dispose();
208
+ }
209
+ }
210
+ const BOX_DRAWING = /[\u2500-\u257f]/g;
211
+ // cf's API error header: `${status} ${statusText}`, where statusText is empty
212
+ // outside 400-504, optionally followed by ` · HTTP <path>` (absent when the
213
+ // response URL is unknown). Body lines (`[code] message`, notes) never match:
214
+ // after the status only a known status text, whitespace or the `·` suffix may follow.
215
+ const STATUS_TEXT = '(?:Bad Request|Unauthorized|Forbidden|Not Found|Method Not Allowed|Conflict|Unprocessable Entity|' +
216
+ 'Too Many Requests|Internal Server Error|Bad Gateway|Service Unavailable|Gateway Timeout)';
217
+ const STATUS_LINE = new RegExp(`^\\s*([1-5]\\d\\d)(?: ${STATUS_TEXT})?\\s*(?:\u00b7.*)?$`, 'gm');
218
+ // Node/undici network failures and the cf SDK's own client timeout ("Timeout exceeded when calling …").
219
+ const NETWORK = /ENOTFOUND|ECONNRESET|ETIMEDOUT|ECONNREFUSED|EAI_AGAIN|UND_ERR|fetch failed|socket hang up|Timeout exceeded|Request timed out/i;
220
+ /** ANSI and box-drawing characters removed; lines kept. */
221
+ export function cleanCfStderr(stderr) {
222
+ return stripVTControlCharacters(stderr).replace(BOX_DRAWING, ' ').replace(/\r\n?/g, '\n');
223
+ }
224
+ // cf's non-interactive no-credential / expired-token message names the token variable.
225
+ const AUTH = new RegExp(`not logged in|cf auth (login|create)|expired|does not exist|necessary to set a ${CF_TOKEN_ENV}`, 'i');
226
+ /** The last status header line in cleaned stderr, if any. */
227
+ function statusFromLines(lines) {
228
+ let status = null;
229
+ for (const match of lines.matchAll(STATUS_LINE))
230
+ status = Number(match[1]);
231
+ return status;
232
+ }
233
+ /**
234
+ * Classify a failed cf call from its stderr, in order: Node version, unknown
235
+ * command, auth without an HTTP status, the HTTP status line
236
+ * (`${status} ${text}[ · HTTP <path>]`), network, anything else. cf wraps
237
+ * messages at the terminal width, so word patterns run over collapsed text.
238
+ */
239
+ export function classifyCfStderr(stderr) {
240
+ const lines = cleanCfStderr(stderr);
241
+ const flat = lines.replace(/\s+/g, ' ');
242
+ if (/cf requires Node\.js/.test(flat))
243
+ return { kind: 'node_unsupported' };
244
+ if (/Did you mean|Unknown (argument|command)/.test(flat))
245
+ return { kind: 'command_unavailable' };
246
+ const status = statusFromLines(lines);
247
+ if (status === null && AUTH.test(flat))
248
+ return { kind: 'auth' };
249
+ if (status !== null)
250
+ return { kind: 'http', status };
251
+ if (NETWORK.test(flat))
252
+ return { kind: 'network' };
253
+ return { kind: 'cf_error' };
254
+ }
255
+ /**
256
+ * Parse cf stdout: lists print a bare JSON array, gets a bare object. cf prints
257
+ * nothing for a `null` result, so an empty get is `null` (the projectors decide).
258
+ */
259
+ export function parseCfJson(stdout, shape) {
260
+ if (shape === 'object' && stdout.trim() === '')
261
+ return { ok: true, value: null };
262
+ let value;
263
+ try {
264
+ value = JSON.parse(stdout);
265
+ }
266
+ catch {
267
+ return { ok: false };
268
+ }
269
+ if (shape === 'array')
270
+ return Array.isArray(value) ? { ok: true, value } : { ok: false };
271
+ if (value === null || (typeof value === 'object' && !Array.isArray(value)))
272
+ return { ok: true, value };
273
+ return { ok: false };
274
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Collecting a Cloudflare account through cf (spec §F5, §D3).
3
+ *
4
+ * Every read goes through the frozen allowlist and the shared scheduler (four
5
+ * workers, the request budget, auth pauses and retries). Each response is
6
+ * projected immediately by the vendored contract and the raw text discarded.
7
+ * Lists are paged with `--page N` until an empty page, because cf strips
8
+ * `result_info`: a final page and a server-clamped page look the same.
9
+ */
10
+ import { type CollectedEntry, type CloudflareCheckupSnapshotV1, type SnapshotCollector, type ZoneData } from './contract.js';
11
+ import { type ProfileArgs } from './cf/allowlist.js';
12
+ import { type CfScheduler } from './cf/auth-lifecycle.js';
13
+ export declare const WORKER_COUNT = 4;
14
+ export type CollectEnv = {
15
+ scheduler: CfScheduler;
16
+ auth: ProfileArgs;
17
+ /** Template IDs, or `zone_setting:<id>`, whose dry-run mapping did not match. */
18
+ unavailable: ReadonlySet<string>;
19
+ concurrency?: number;
20
+ onProgress?: (progress: CollectProgress) => void;
21
+ };
22
+ export type CollectProgress = {
23
+ zonesDone: number;
24
+ zonesTotal: number;
25
+ requests: number;
26
+ };
27
+ export type AccountSummary = {
28
+ id: string;
29
+ name: string;
30
+ };
31
+ /** `accounts list --per-page 50 --page N` until an empty page. */
32
+ export declare function listAccounts(env: CollectEnv): Promise<AccountSummary[]>;
33
+ /** Requests for N zones: about 19 + extra pages + N × (33 + rulesets per zone). */
34
+ export declare function estimateRequests(zoneCount: number, rulesetsPerZone?: number): number;
35
+ /**
36
+ * Collect every snapshot entry for one account. Aborts (no file) when the
37
+ * account read fails other than 400/403/404, when the zone list fails, when it
38
+ * holds more than 100 zones, or when the account is invisible and has no zones.
39
+ */
40
+ export declare function collectAccount(env: CollectEnv, accountId: string, hooks?: {
41
+ onZones?: (zones: readonly ZoneData[]) => void;
42
+ }): Promise<CollectedEntry[]>;
43
+ /** Assemble, validate and serialise; the returned text is exactly what is written and uploaded. */
44
+ export declare function buildSnapshot(accountId: string, collector: SnapshotCollector, entries: readonly CollectedEntry[]): {
45
+ snapshot: CloudflareCheckupSnapshotV1;
46
+ text: string;
47
+ };
@@ -0,0 +1,295 @@
1
+ /**
2
+ * Collecting a Cloudflare account through cf (spec §F5, §D3).
3
+ *
4
+ * Every read goes through the frozen allowlist and the shared scheduler (four
5
+ * workers, the request budget, auth pauses and retries). Each response is
6
+ * projected immediately by the vendored contract and the raw text discarded.
7
+ * Lists are paged with `--page N` until an empty page, because cf strips
8
+ * `result_info`: a final page and a server-clamped page look the same.
9
+ */
10
+ import { ACCOUNT_KINDS, CloudflareCheckupSnapshotV1Schema, NOT_COLLECTABLE_ZONE_KINDS, NOT_COLLECTED_RESULT, PROJECTORS, SNAPSHOT_LIMITS, ZONE_SETTING_IDS, ZONE_SINGLE_KINDS, assembleSnapshot, collectionFailedResult, httpErrorResult, projectAccount, projectZones, projectionToResult, serializeSnapshot, } from './contract.js';
11
+ import { ENV_TOKEN_PERMISSION_HINT, LocalCheckupError } from './errors.js';
12
+ import { snapshotParseError } from './snapshot-file.js';
13
+ import { TEMPLATES, buildArgv } from './cf/allowlist.js';
14
+ import { authRequiredError } from './cf/auth-lifecycle.js';
15
+ import { RULESET_STDOUT_CAP_BYTES, parseCfJson } from './cf/run-cf.js';
16
+ export const WORKER_COUNT = 4;
17
+ /** Assumed ruleset details per zone for the up-front request estimate. */
18
+ const ESTIMATED_RULESETS_PER_ZONE = 6;
19
+ const failed = (detail, failure) => ({ ok: false, result: collectionFailedResult(detail), ...(failure ? { failure } : {}) });
20
+ function failureResult(failure) {
21
+ switch (failure.kind) {
22
+ case 'http':
23
+ return failure.status >= 400 && failure.status <= 599
24
+ ? { ok: false, result: httpErrorResult(failure.status), failure }
25
+ : failed('cf_error', failure);
26
+ case 'timeout': return failed('timeout', failure);
27
+ case 'output_too_large': return failed('output_too_large', failure);
28
+ case 'command_unavailable': return failed('command_unavailable', failure);
29
+ default: return failed('cf_error', failure);
30
+ }
31
+ }
32
+ function availabilityKey(id, settingId) {
33
+ return id === 'zone_setting' && settingId ? `zone_setting:${settingId}` : id;
34
+ }
35
+ /** Fetch one read: a single call, a one-call list, or every page of a paged list. */
36
+ async function fetchRaw(env, id, ids, options = {}) {
37
+ if (env.unavailable.has(availabilityKey(id, ids.settingId)))
38
+ return failed('command_unavailable');
39
+ const spec = TEMPLATES[id];
40
+ const callOptions = {
41
+ ...(ids.accountId !== undefined && id !== 'account_selection' ? { accountId: ids.accountId } : {}),
42
+ ...(id === 'zone_ruleset' ? { stdoutCapBytes: RULESET_STDOUT_CAP_BYTES } : {}),
43
+ };
44
+ const once = async (page) => {
45
+ const outcome = await env.scheduler.call(buildArgv(id, page === undefined ? ids : { ...ids, page }, env.auth), callOptions);
46
+ if (!outcome.ok)
47
+ return failureResult(outcome.failure);
48
+ const parsed = parseCfJson(outcome.stdout, spec.output);
49
+ return parsed.ok ? { ok: true, raw: parsed.value } : failed('invalid_output');
50
+ };
51
+ if (spec.paging.kind === 'single')
52
+ return once();
53
+ if (spec.paging.kind === 'one_call') {
54
+ const outcome = await once();
55
+ // The cursor is not printed, so a full page may hide more rulesets.
56
+ if (outcome.ok && outcome.raw.length >= SNAPSHOT_LIMITS.rulesetListTruncationAt)
57
+ return failed('possibly_truncated');
58
+ return outcome;
59
+ }
60
+ const items = [];
61
+ let previousFirst;
62
+ for (let page = 1;; page++) {
63
+ const outcome = await once(page);
64
+ if (!outcome.ok)
65
+ return outcome;
66
+ const pageItems = outcome.raw;
67
+ if (pageItems.length === 0)
68
+ return { ok: true, raw: items };
69
+ if (page > spec.paging.maxPages)
70
+ return failed('page_limit');
71
+ const first = JSON.stringify(pageItems[0]);
72
+ // cf ignoring --page would repeat the first page forever.
73
+ if (page > 1 && first === previousFirst)
74
+ return failed('invalid_output');
75
+ previousFirst = first;
76
+ items.push(...pageItems);
77
+ if (options.maxItems !== undefined && items.length > options.maxItems) {
78
+ return { ok: false, result: collectionFailedResult('output_too_large'), tooManyItems: true };
79
+ }
80
+ }
81
+ }
82
+ function isAuthFailure(failure) {
83
+ return failure?.kind === 'auth' || (failure?.kind === 'http' && failure.status === 401);
84
+ }
85
+ /** Abort errors for reads that must succeed (account selection, account, zones). */
86
+ function criticalError(env, outcome, what) {
87
+ if (isAuthFailure(outcome.failure))
88
+ return authRequiredError(env.auth.authMode);
89
+ if (outcome.result.reason === 'collection_failed' && outcome.result.detail === 'command_unavailable') {
90
+ return new LocalCheckupError('CF_ALLOWLIST_MISMATCH', `This cf release cannot run the ${what} command the checkup needs.`);
91
+ }
92
+ if (outcome.failure?.kind === 'http' && outcome.failure.status === 403) {
93
+ return new LocalCheckupError('CF_PERMISSION_DENIED', `Cloudflare denied the ${what} read (HTTP 403).`, { status: 403, ...(env.auth.authMode === 'env' ? { hint: ENV_TOKEN_PERMISSION_HINT } : {}) });
94
+ }
95
+ const status = outcome.failure?.kind === 'http' ? outcome.failure.status : undefined;
96
+ return new LocalCheckupError('CF_ERROR', `cf could not read the ${what}${status ? ` (HTTP ${status})` : outcome.result.reason === 'collection_failed' ? ` (${outcome.result.detail})` : ''}.`, status ? { status } : {});
97
+ }
98
+ /** `accounts list --per-page 50 --page N` until an empty page. */
99
+ export async function listAccounts(env) {
100
+ const outcome = await fetchRaw(env, 'account_selection', {});
101
+ if (!outcome.ok)
102
+ throw criticalError(env, outcome, 'account list');
103
+ const accounts = [];
104
+ for (const item of outcome.raw) {
105
+ if (!item || typeof item !== 'object')
106
+ continue;
107
+ const { id, name } = item;
108
+ if (typeof id !== 'string' || !/^[0-9a-f]{32}$/i.test(id))
109
+ continue;
110
+ accounts.push({ id: id.toLowerCase(), name: typeof name === 'string' ? name.slice(0, 256) : '' });
111
+ }
112
+ return accounts;
113
+ }
114
+ /** Requests for N zones: about 19 + extra pages + N × (33 + rulesets per zone). */
115
+ export function estimateRequests(zoneCount, rulesetsPerZone = ESTIMATED_RULESETS_PER_ZONE) {
116
+ return 19 + zoneCount * (33 + rulesetsPerZone);
117
+ }
118
+ /** A worker pool whose tasks may enqueue more tasks (ruleset details). */
119
+ async function runTasks(seed, concurrency, onError) {
120
+ const queue = [];
121
+ let active = 0;
122
+ let error = null;
123
+ let notify = () => undefined;
124
+ let changed = new Promise((resolve) => { notify = resolve; });
125
+ const signal = () => {
126
+ const resolve = notify;
127
+ changed = new Promise((next) => { notify = next; });
128
+ resolve();
129
+ };
130
+ const enqueue = (task, options) => {
131
+ if (options?.front)
132
+ queue.unshift(task);
133
+ else
134
+ queue.push(task);
135
+ signal();
136
+ };
137
+ seed(enqueue);
138
+ const worker = async () => {
139
+ for (;;) {
140
+ if (error)
141
+ return;
142
+ const task = queue.shift();
143
+ if (!task) {
144
+ if (active === 0)
145
+ return;
146
+ await changed;
147
+ continue;
148
+ }
149
+ active++;
150
+ try {
151
+ await task(enqueue);
152
+ }
153
+ catch (caught) {
154
+ if (!error) {
155
+ error = caught;
156
+ onError(caught);
157
+ }
158
+ }
159
+ finally {
160
+ active--;
161
+ signal();
162
+ }
163
+ }
164
+ };
165
+ await Promise.all(Array.from({ length: concurrency }, () => worker()));
166
+ if (error)
167
+ throw error;
168
+ }
169
+ /**
170
+ * Collect every snapshot entry for one account. Aborts (no file) when the
171
+ * account read fails other than 400/403/404, when the zone list fails, when it
172
+ * holds more than 100 zones, or when the account is invisible and has no zones.
173
+ */
174
+ export async function collectAccount(env, accountId, hooks = {}) {
175
+ const entries = [];
176
+ const ids = { accountId };
177
+ const account = await fetchRaw(env, 'account', ids);
178
+ if (account.ok) {
179
+ const projected = projectAccount(account.raw);
180
+ if (!projected.ok)
181
+ throw new LocalCheckupError('CF_ERROR', 'cf returned account details the checkup cannot read.');
182
+ if (projected.data.id !== accountId)
183
+ throw new LocalCheckupError('CF_ERROR', 'cf returned a different Cloudflare account than requested.');
184
+ entries.push({ kind: 'account', result: { ok: true, data: projected.data } });
185
+ }
186
+ else if (account.result.reason === 'http_error' && [400, 403, 404].includes(account.result.status)) {
187
+ // The scanner tolerates these (runInitPhase); 401 is never recorded.
188
+ entries.push({ kind: 'account', result: account.result });
189
+ }
190
+ else {
191
+ throw criticalError(env, account, 'account');
192
+ }
193
+ const zonesRaw = await fetchRaw(env, 'zones', ids, { maxItems: SNAPSHOT_LIMITS.maxZones });
194
+ if (!zonesRaw.ok) {
195
+ if (zonesRaw.tooManyItems) {
196
+ throw new LocalCheckupError('ACCOUNT_TOO_LARGE', `This account has more than ${SNAPSHOT_LIMITS.maxZones} zones; use the browser token mode.`);
197
+ }
198
+ throw criticalError(env, zonesRaw, 'zone list');
199
+ }
200
+ const zonesProjected = projectZones(zonesRaw.raw);
201
+ if (!zonesProjected.ok)
202
+ throw new LocalCheckupError('CF_ERROR', 'cf returned a zone list the checkup cannot read.');
203
+ const zones = zonesProjected.data;
204
+ if (zones.some((zone) => zone.account.id !== accountId))
205
+ throw new LocalCheckupError('CF_ERROR', 'cf returned zones from a different account.');
206
+ if (!entries[0].result.ok && zones.length === 0) {
207
+ throw new LocalCheckupError('ACCOUNT_NOT_VISIBLE', 'Cloudflare does not show this account or any of its zones to the signed-in cf user.');
208
+ }
209
+ entries.push({ kind: 'zones', result: { ok: true, data: zones } });
210
+ hooks.onZones?.(zones);
211
+ const zoneTasksLeft = new Map(zones.map((zone) => [zone.id, 0]));
212
+ let zonesDone = 0;
213
+ const report = () => env.onProgress?.({ zonesDone, zonesTotal: zones.length, requests: env.scheduler.invocations });
214
+ const finishZoneTask = (zoneId) => {
215
+ const left = (zoneTasksLeft.get(zoneId) ?? 1) - 1;
216
+ zoneTasksLeft.set(zoneId, left);
217
+ if (left === 0)
218
+ zonesDone++;
219
+ report();
220
+ };
221
+ const projectInto = async (kind, taskIds) => {
222
+ const outcome = await fetchRaw(env, kind, taskIds);
223
+ if (!outcome.ok)
224
+ return outcome.result;
225
+ const project = PROJECTORS[kind];
226
+ return projectionToResult(project(outcome.raw));
227
+ };
228
+ await runTasks((enqueue) => {
229
+ for (const kind of ACCOUNT_KINDS) {
230
+ if (kind === 'account' || kind === 'zones')
231
+ continue;
232
+ enqueue(async () => {
233
+ entries.push({ kind, result: await projectInto(kind, ids) });
234
+ report();
235
+ });
236
+ }
237
+ for (const zone of zones) {
238
+ const zoneIds = { accountId, zoneId: zone.id };
239
+ zoneTasksLeft.set(zone.id, ZONE_SETTING_IDS.length + ZONE_SINGLE_KINDS.length);
240
+ for (const settingId of ZONE_SETTING_IDS) {
241
+ enqueue(async () => {
242
+ entries.push({ kind: 'zone_setting', zoneId: zone.id, settingId, result: await projectInto('zone_setting', { ...zoneIds, settingId }) });
243
+ finishZoneTask(zone.id);
244
+ });
245
+ }
246
+ for (const kind of ZONE_SINGLE_KINDS) {
247
+ enqueue(async (more) => {
248
+ const result = await projectInto(kind, zoneIds);
249
+ entries.push({ kind, zoneId: zone.id, result });
250
+ if (kind === 'zone_rulesets' && result.ok) {
251
+ const rulesetIds = [...new Set(result.data.map((head) => head.id))];
252
+ zoneTasksLeft.set(zone.id, (zoneTasksLeft.get(zone.id) ?? 0) + rulesetIds.length);
253
+ for (const rulesetId of rulesetIds) {
254
+ more(async () => {
255
+ entries.push(await collectRulesetDetail(env, zoneIds, rulesetId));
256
+ finishZoneTask(zone.id);
257
+ }, { front: true });
258
+ }
259
+ }
260
+ finishZoneTask(zone.id);
261
+ });
262
+ }
263
+ for (const kind of NOT_COLLECTABLE_ZONE_KINDS)
264
+ entries.push({ kind, zoneId: zone.id, result: NOT_COLLECTED_RESULT });
265
+ }
266
+ }, env.concurrency ?? WORKER_COUNT, (error) => env.scheduler.abort(error instanceof Error ? error : new Error(String(error))));
267
+ env.scheduler.throwIfAborted();
268
+ return entries;
269
+ }
270
+ /** A ruleset detail keeps its projected rules; `assembleSnapshot` applies the failed-detail collector rule. */
271
+ async function collectRulesetDetail(env, ids, rulesetId) {
272
+ const outcome = await fetchRaw(env, 'zone_ruleset', { ...ids, rulesetId });
273
+ const base = { kind: 'zone_ruleset', zoneId: ids.zoneId, rulesetId };
274
+ if (!outcome.ok)
275
+ return { ...base, result: outcome.result };
276
+ const projected = PROJECTORS.zone_ruleset(outcome.raw);
277
+ if (!projected.ok)
278
+ return { ...base, result: collectionFailedResult(projected.detail) };
279
+ // A detail whose ID differs from the listed head cannot be attributed.
280
+ if (projected.data.id !== rulesetId)
281
+ return { ...base, result: collectionFailedResult('invalid_output') };
282
+ return { ...base, result: { ok: true, data: projected.data } };
283
+ }
284
+ /** Assemble, validate and serialise; the returned text is exactly what is written and uploaded. */
285
+ export function buildSnapshot(accountId, collector, entries) {
286
+ const assembled = assembleSnapshot({ accountId, collector, entries });
287
+ const parsed = CloudflareCheckupSnapshotV1Schema.safeParse(assembled);
288
+ if (!parsed.success)
289
+ throw snapshotParseError(parsed.error.issues);
290
+ const text = serializeSnapshot(parsed.data);
291
+ if (Buffer.byteLength(text) > SNAPSHOT_LIMITS.maxBytes) {
292
+ throw new LocalCheckupError('SNAPSHOT_TOO_LARGE', 'The collected snapshot is larger than the 8 MiB contract limit; no file was written.');
293
+ }
294
+ return { snapshot: parsed.data, text };
295
+ }