@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,308 @@
1
+ /**
2
+ * The Vigilbase-managed install of Cloudflare's cf (spec "one command", decision 5).
3
+ *
4
+ * When no usable cf is found, the full flow may install the pinned release
5
+ * into `<configDir>/cf/<version>/` with the npm that ships with this Node:
6
+ *
7
+ * - npm runs as `process.execPath <npm-cli.js> install --prefix <staging> …`
8
+ * with no shell, from the fresh 0700 staging directory, with the user's
9
+ * environment (npm needs their npmrc, proxy and CA settings) minus every
10
+ * `CF_*` / `CLOUDFLARE_*` variable and, defensively, any variable whose
11
+ * value holds a session code, a verification token or `--email`. (npx 10
12
+ * passes arguments only in argv, not in the environment:
13
+ * `npm_lifecycle_script` is just the bin name.)
14
+ * - `--ignore-scripts`: cf runs `--version`, `--dry-run` and API reads without
15
+ * lifecycle scripts. `--omit=optional`: the optional dependencies are the
16
+ * workerd and sharp platform binaries (about 150 MB). In cf 1.0.0-beta.13
17
+ * they are loaded only by `--local` mode and the `workers`, `deploy` and
18
+ * `previews` command trees (static and dynamic import graph of
19
+ * `dist/*.mjs`), none of which the checkup runs; see LOCAL.md.
20
+ * - Integrity is pinned: after npm exits, the staging lockfile must record
21
+ * `node_modules/cf` at exactly the pinned version and npm `dist.integrity`,
22
+ * and the installed package.json must agree; otherwise the staging
23
+ * directory is deleted (CF_INSTALL_FAILED). Only then is it renamed into
24
+ * place, so a half-finished install is never used. `locateCf` still applies
25
+ * its identity checks to the result.
26
+ * - Ctrl-C while npm runs kills npm and removes the staging directory. Staging
27
+ * directories left by a crash or a closed terminal are removed by the next
28
+ * install once they are older than the npm timeout (a concurrent install is
29
+ * always younger), and by `logout`.
30
+ */
31
+ import { spawn as nodeSpawn } from 'node:child_process';
32
+ import { existsSync } from 'node:fs';
33
+ import { chmod, lstat, mkdir, mkdtemp, readdir, readFile, realpath, rename, rm, stat } from 'node:fs/promises';
34
+ import path from 'node:path';
35
+ import { LocalCheckupError } from '../errors.js';
36
+ /** The cf release the CLI installs; `integrity` is npm's `dist.integrity` for it. */
37
+ export const MANAGED_CF = Object.freeze({
38
+ version: '1.0.0-beta.13',
39
+ integrity: 'sha512-dCjpzCJvjudIu8IQbXCJfy9diFoyHU84D/30K4lJp7jUaBgvjJypsMFzLqvUFOuwhF1ggR/igsLqXyehAOR2FQ==',
40
+ /** Measured `du` of the install without optional dependencies (darwin-arm64: 73 MB). */
41
+ approxSize: 'about 75 MB',
42
+ });
43
+ export const NPM_INSTALL_TIMEOUT_MS = 10 * 60_000;
44
+ /** A staging directory older than this is a leftover, never a running install. */
45
+ const STALE_STAGING_MS = NPM_INSTALL_TIMEOUT_MS + 60_000;
46
+ const STAGING_PREFIX = '.staging-';
47
+ const NPM_STDERR_KEEP_BYTES = 64 * 1024;
48
+ const MAX_LOCKFILE_BYTES = 16 * 1024 * 1024;
49
+ export const NPM_STDERR_TAIL_LINES = 20;
50
+ /** A Vigilbase session code or verification token, or a command line naming `--email` (were a launcher to export one). */
51
+ const VIGILBASE_SECRET_MARKER = /vbc[sv]1_|--email\b/;
52
+ /** `<configDir>/cf`: every managed version, plus any leftover staging directory. */
53
+ export function managedCfRoot(configDir) {
54
+ return path.join(configDir, 'cf');
55
+ }
56
+ export function managedCfInstallDir(configDir, version = MANAGED_CF.version) {
57
+ return path.join(managedCfRoot(configDir), version);
58
+ }
59
+ export function managedCfPackageDir(installDir) {
60
+ return path.join(installDir, 'node_modules', 'cf');
61
+ }
62
+ /** The npm arguments after `process.execPath`. */
63
+ export function npmInstallArgv(npmCli, prefix, version = MANAGED_CF.version) {
64
+ return [npmCli, 'install', '--prefix', prefix, '--no-audit', '--no-fund', '--ignore-scripts', '--omit=optional', `cf@${version}`];
65
+ }
66
+ /**
67
+ * The npm to run: `npm_execpath` when it names an existing `npm-cli.js` (or
68
+ * `npx-cli.js`, whose sibling is used), otherwise the npm bundled beside this
69
+ * Node. `null` when neither exists.
70
+ */
71
+ export function resolveNpmCli(options = { env: process.env }) {
72
+ const platform = options.platform ?? process.platform;
73
+ const p = platform === 'win32' ? path.win32 : path.posix;
74
+ const exists = options.exists ?? existsSync;
75
+ const fromEnv = options.env.npm_execpath;
76
+ if (fromEnv && p.isAbsolute(fromEnv)) {
77
+ const name = p.basename(fromEnv);
78
+ const candidate = name === 'npm-cli.js' ? fromEnv : name === 'npx-cli.js' ? p.join(p.dirname(fromEnv), 'npm-cli.js') : null;
79
+ if (candidate && exists(candidate))
80
+ return candidate;
81
+ }
82
+ const nodeDir = p.dirname(options.execPath ?? process.execPath);
83
+ const bundled = platform === 'win32'
84
+ ? p.join(nodeDir, 'node_modules', 'npm', 'bin', 'npm-cli.js')
85
+ : p.resolve(nodeDir, '..', 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js');
86
+ return exists(bundled) ? bundled : null;
87
+ }
88
+ /** The user's environment minus Cloudflare variables and anything carrying a session code, token or `--email`. */
89
+ export function npmChildEnv(parent) {
90
+ const env = {};
91
+ for (const [name, value] of Object.entries(parent)) {
92
+ if (typeof value !== 'string')
93
+ continue;
94
+ if (/^(?:CF_|CLOUDFLARE_)/i.test(name))
95
+ continue;
96
+ if (VIGILBASE_SECRET_MARKER.test(value))
97
+ continue;
98
+ env[name] = value;
99
+ }
100
+ env.npm_config_update_notifier = 'false';
101
+ return env;
102
+ }
103
+ async function readJsonCapped(file) {
104
+ const info = await stat(file).catch(() => null);
105
+ if (!info || !info.isFile() || info.size > MAX_LOCKFILE_BYTES)
106
+ return undefined;
107
+ try {
108
+ return JSON.parse(await readFile(file, 'utf8'));
109
+ }
110
+ catch {
111
+ return undefined;
112
+ }
113
+ }
114
+ /**
115
+ * True when every lockfile npm wrote records `node_modules/cf` at the pinned
116
+ * version and integrity (at least one must exist), and the installed
117
+ * package.json has that version.
118
+ */
119
+ export async function verifyManagedInstall(installDir, pin = MANAGED_CF) {
120
+ let lockfiles = 0;
121
+ for (const file of [path.join(installDir, 'package-lock.json'), path.join(installDir, 'node_modules', '.package-lock.json')]) {
122
+ if (!(await stat(file).catch(() => null)))
123
+ continue;
124
+ lockfiles++;
125
+ const lock = await readJsonCapped(file);
126
+ const entry = lock?.packages?.['node_modules/cf'];
127
+ if (!entry || entry.version !== pin.version || entry.integrity !== pin.integrity)
128
+ return false;
129
+ }
130
+ if (lockfiles === 0)
131
+ return false;
132
+ const manifest = await readJsonCapped(path.join(managedCfPackageDir(installDir), 'package.json'));
133
+ return manifest?.version === pin.version;
134
+ }
135
+ /** The managed cf package directory when a verified install exists, else null. */
136
+ export async function findManagedCf(configDir) {
137
+ const installDir = managedCfInstallDir(configDir);
138
+ const info = await lstat(installDir).catch(() => null);
139
+ if (!info || !info.isDirectory() || info.isSymbolicLink())
140
+ return null;
141
+ return (await verifyManagedInstall(installDir)) ? managedCfPackageDir(installDir) : null;
142
+ }
143
+ /** Create `dir` 0700 if missing; refuse a symlink or non-directory in its place. */
144
+ export async function ensurePrivateDir(dir) {
145
+ await mkdir(path.dirname(dir), { recursive: true, mode: 0o700 });
146
+ await mkdir(dir, { mode: 0o700 }).catch((error) => {
147
+ if (error.code !== 'EEXIST')
148
+ throw error;
149
+ });
150
+ const info = await lstat(dir);
151
+ if (!info.isDirectory() || info.isSymbolicLink()) {
152
+ throw new Error(`${dir} must be a directory, not a symbolic link or file.`);
153
+ }
154
+ if (process.platform !== 'win32' && (info.mode & 0o777) !== 0o700)
155
+ await chmod(dir, 0o700);
156
+ }
157
+ /** A CF_INSTALL_FAILED error that carries the redacted tail of npm's stderr. */
158
+ export class CfInstallError extends LocalCheckupError {
159
+ npmOutput;
160
+ constructor(message, npmOutput = []) {
161
+ super('CF_INSTALL_FAILED', message);
162
+ this.npmOutput = npmOutput;
163
+ }
164
+ }
165
+ const SECRET_LINE = /_auth|_password|authorization|bearer|npm_[A-Za-z0-9]{20,}|vbc[sv]1_|--email\b/i;
166
+ /** The last lines of npm's stderr, dropping any line that could carry a registry credential. */
167
+ export function npmStderrTail(stderr, lines = NPM_STDERR_TAIL_LINES) {
168
+ return stderr.replace(/\r\n?/g, '\n').split('\n')
169
+ .map((line) => line.trimEnd())
170
+ .filter((line) => line.trim() !== '' && !SECRET_LINE.test(line))
171
+ .slice(-lines);
172
+ }
173
+ function runNpm(spawn, args, options) {
174
+ return new Promise((resolve) => {
175
+ let child;
176
+ try {
177
+ child = spawn(process.execPath, args, { shell: false, cwd: options.cwd, env: options.env, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
178
+ }
179
+ catch {
180
+ resolve({ exitCode: null, stderr: '', timedOut: false, spawnFailed: true });
181
+ return;
182
+ }
183
+ options.onSpawn(child);
184
+ let stderr = '';
185
+ let timedOut = false;
186
+ let settled = false;
187
+ const timer = setTimeout(() => {
188
+ timedOut = true;
189
+ child.kill('SIGKILL');
190
+ }, options.timeoutMs);
191
+ timer.unref?.();
192
+ child.stdout?.on('data', () => undefined);
193
+ child.stderr?.on('data', (chunk) => {
194
+ stderr += chunk.toString();
195
+ if (stderr.length > NPM_STDERR_KEEP_BYTES)
196
+ stderr = stderr.slice(-NPM_STDERR_KEEP_BYTES);
197
+ });
198
+ const finish = (exitCode, spawnFailed) => {
199
+ if (settled)
200
+ return;
201
+ settled = true;
202
+ clearTimeout(timer);
203
+ resolve({ exitCode, stderr, timedOut, spawnFailed });
204
+ };
205
+ child.once('error', () => finish(null, true));
206
+ child.once('close', (code) => finish(typeof code === 'number' ? code : null, false));
207
+ });
208
+ }
209
+ /** Remove `.staging-*` leftovers older than `maxAgeMs`; a younger one may belong to a concurrent install. */
210
+ export async function removeStaleStaging(root, maxAgeMs = STALE_STAGING_MS, now = Date.now()) {
211
+ const names = await readdir(root).catch(() => []);
212
+ const removed = [];
213
+ for (const name of names.filter((candidate) => candidate.startsWith(STAGING_PREFIX)).sort()) {
214
+ const target = path.join(root, name);
215
+ const info = await lstat(target).catch(() => null);
216
+ if (!info || now - info.mtimeMs <= maxAgeMs)
217
+ continue;
218
+ await rm(target, { recursive: true, force: true }).then(() => removed.push(name), () => undefined);
219
+ }
220
+ return removed;
221
+ }
222
+ /**
223
+ * Ctrl-C while npm runs: kill npm, wait briefly for it to exit, remove the
224
+ * staging directory, then exit 130 (as `withCfRunner` does for cf).
225
+ */
226
+ function onInterrupt(staging, running, before) {
227
+ return () => {
228
+ before?.();
229
+ const child = running();
230
+ const closed = child && child.exitCode === null && child.signalCode === null
231
+ ? new Promise((resolve) => {
232
+ child.once('close', () => resolve());
233
+ setTimeout(resolve, 2_000).unref?.();
234
+ })
235
+ : Promise.resolve();
236
+ child?.kill('SIGKILL');
237
+ void closed
238
+ .then(() => rm(staging, { recursive: true, force: true, maxRetries: 3 }))
239
+ .catch(() => undefined)
240
+ .finally(() => process.exit(130));
241
+ };
242
+ }
243
+ /**
244
+ * Install the pinned cf into `<configDir>/cf/<version>/` and return its
245
+ * package directory. Throws CF_INSTALL_FAILED (a `CfInstallError`) when npm
246
+ * is missing or fails, or the result does not match the pin; the staging
247
+ * directory is always removed (on Ctrl-C too).
248
+ */
249
+ export async function installManagedCf(options) {
250
+ const npmCli = options.npmCli !== undefined ? options.npmCli : resolveNpmCli({ env: options.env });
251
+ if (!npmCli) {
252
+ throw new CfInstallError('npm was not found beside this Node.js, so cf cannot be installed automatically.');
253
+ }
254
+ const root = managedCfRoot(options.configDir);
255
+ await ensurePrivateDir(root);
256
+ await removeStaleStaging(root);
257
+ // realpath: npm keys its lockfile relative to the real prefix, so a symlinked path would not match `node_modules/cf`.
258
+ const staging = await realpath(await mkdtemp(path.join(root, `${STAGING_PREFIX}${MANAGED_CF.version}-`)));
259
+ let npmChild = null;
260
+ const interrupted = onInterrupt(staging, () => npmChild, options.onInterrupt);
261
+ process.once('SIGINT', interrupted);
262
+ try {
263
+ if (process.platform !== 'win32')
264
+ await chmod(staging, 0o700);
265
+ const result = await runNpm(options.spawn ?? nodeSpawn, npmInstallArgv(npmCli, staging), {
266
+ cwd: staging,
267
+ env: npmChildEnv(options.env),
268
+ timeoutMs: options.timeoutMs ?? NPM_INSTALL_TIMEOUT_MS,
269
+ onSpawn: (child) => { npmChild = child; },
270
+ });
271
+ if (result.spawnFailed)
272
+ throw new CfInstallError('npm could not be started, so cf was not installed.');
273
+ if (result.timedOut)
274
+ throw new CfInstallError(`npm did not finish installing cf within ${Math.round((options.timeoutMs ?? NPM_INSTALL_TIMEOUT_MS) / 60_000)} minutes.`, npmStderrTail(result.stderr));
275
+ if (result.exitCode !== 0)
276
+ throw new CfInstallError(`npm could not install cf@${MANAGED_CF.version} (exit ${result.exitCode ?? 'signal'}).`, npmStderrTail(result.stderr));
277
+ if (!(await verifyManagedInstall(staging))) {
278
+ throw new CfInstallError(`The installed cf does not match the pinned cf@${MANAGED_CF.version} integrity; it was deleted.`);
279
+ }
280
+ const installDir = managedCfInstallDir(options.configDir);
281
+ // A leftover install that failed verification is replaced; a verified one (a concurrent run) is kept unless it is being replaced.
282
+ if (!options.replace && await findManagedCf(options.configDir))
283
+ return managedCfPackageDir(installDir);
284
+ await rm(installDir, { recursive: true, force: true });
285
+ try {
286
+ await rename(staging, installDir);
287
+ }
288
+ catch {
289
+ if (await findManagedCf(options.configDir))
290
+ return managedCfPackageDir(installDir);
291
+ throw new CfInstallError('The installed cf could not be moved into place.');
292
+ }
293
+ return managedCfPackageDir(installDir);
294
+ }
295
+ finally {
296
+ process.removeListener('SIGINT', interrupted);
297
+ await rm(staging, { recursive: true, force: true }).catch(() => undefined);
298
+ }
299
+ }
300
+ /** `logout`: remove every managed cf version (and any staging leftovers). */
301
+ export async function removeManagedCf(configDir) {
302
+ const root = managedCfRoot(configDir);
303
+ const info = await lstat(root).catch(() => null);
304
+ if (!info)
305
+ return { path: root, removed: false };
306
+ await rm(root, { recursive: true, force: true });
307
+ return { path: root, removed: true };
308
+ }
@@ -0,0 +1,43 @@
1
+ export type LocateFs = {
2
+ stat(target: string): Promise<{
3
+ isFile(): boolean;
4
+ isDirectory(): boolean;
5
+ }>;
6
+ realpath(target: string): Promise<string>;
7
+ readFile(target: string): Promise<string>;
8
+ readdir(target: string): Promise<string[]>;
9
+ };
10
+ export type CfLocation = {
11
+ packageDir: string;
12
+ /** Absolute path of the `bin.cf` entry script. */
13
+ entry: string;
14
+ packageVersion: string;
15
+ source: 'cf-bin' | 'managed' | 'path' | 'pnpm' | 'volta';
16
+ /**
17
+ * The package lies inside the current working directory (warned about), which
18
+ * may be a project's own cf. Never set for the managed install, which the CLI
19
+ * installed and verified itself, nor when the working directory is the home
20
+ * directory, where every global install (nvm, Volta, pnpm) lives.
21
+ */
22
+ insideCwd: boolean;
23
+ };
24
+ export declare function isNodeSupported(version?: string): boolean;
25
+ /** cf runs under this Node via process.execPath; workerd ships no win32-arm64 build. */
26
+ export declare function assertLocalRuntimeSupported(version?: string, platform?: NodeJS.Platform, arch?: string): void;
27
+ export declare function locateCf(options: {
28
+ cfBin?: string;
29
+ /** The verified managed cf package directory (`findManagedCf`), tried after `--cf-bin`. */
30
+ managedDir?: string;
31
+ env: NodeJS.ProcessEnv;
32
+ platform?: NodeJS.Platform;
33
+ cwd?: string;
34
+ /** Defaults to HOME (USERPROFILE on Windows) from `env`, else the OS home directory. */
35
+ home?: string;
36
+ fs?: LocateFs;
37
+ }): Promise<CfLocation>;
38
+ export type CfVersionInfo = {
39
+ version: string;
40
+ tested: boolean;
41
+ };
42
+ /** Gate the version cf prints against its package.json and `CF_SUPPORTED`. */
43
+ export declare function checkCfVersion(printed: string, packageVersion: string): CfVersionInfo;
@@ -0,0 +1,227 @@
1
+ /**
2
+ * Locating Cloudflare's cf CLI and checking its identity (spec §F2).
3
+ *
4
+ * Candidates, in order: `--cf-bin`; the Vigilbase-managed install
5
+ * (`<configDir>/cf/<version>`, see install.ts); absolute PATH entries holding
6
+ * `cf`, then `cloudflare` (`cf.cmd` / `cloudflare.cmd` on Windows); then pnpm
7
+ * and Volta global package directories, probed without spawning a package
8
+ * manager. A
9
+ * candidate is accepted only if its package.json says it is Cloudflare's cf
10
+ * (`name`, `repository`, `bin.cf`); the Cloud Foundry CLI, which also installs
11
+ * `cf`, and unresolvable shims are skipped. cf is then run as
12
+ * `process.execPath <bin.cf entry>`, never through a shim or shell.
13
+ */
14
+ import { promises as fsPromises } from 'node:fs';
15
+ import { homedir } from 'node:os';
16
+ import path from 'node:path';
17
+ import { CF_SUPPORTED, isSupportedCfVersion } from '../contract.js';
18
+ import { LocalCheckupError } from '../errors.js';
19
+ const nodeFs = {
20
+ stat: (target) => fsPromises.stat(target),
21
+ realpath: (target) => fsPromises.realpath(target),
22
+ readFile: (target) => fsPromises.readFile(target, 'utf8'),
23
+ readdir: (target) => fsPromises.readdir(target),
24
+ };
25
+ const MIN_NODE = [22, 18];
26
+ const MAX_PACKAGE_JSON_BYTES = 1024 * 1024;
27
+ const MAX_WALK_UP = 4;
28
+ const MAX_PACKAGE_MANAGER_PROBES = 20;
29
+ const MAX_VOLTA_DEPTH = 4;
30
+ export function isNodeSupported(version = process.versions.node) {
31
+ const [major, minor] = version.split('.').map((part) => Number.parseInt(part, 10));
32
+ if (!Number.isInteger(major) || !Number.isInteger(minor))
33
+ return false;
34
+ return major > MIN_NODE[0] || (major === MIN_NODE[0] && minor >= MIN_NODE[1]);
35
+ }
36
+ /** cf runs under this Node via process.execPath; workerd ships no win32-arm64 build. */
37
+ export function assertLocalRuntimeSupported(version = process.versions.node, platform = process.platform, arch = process.arch) {
38
+ if (!isNodeSupported(version)) {
39
+ throw new LocalCheckupError('NODE_UNSUPPORTED', `Node.js ${version} is too old for cf; local checkups need Node.js 22.18 or newer.`);
40
+ }
41
+ if (!['darwin', 'linux', 'win32'].includes(platform) || (platform === 'win32' && arch === 'arm64')) {
42
+ throw new LocalCheckupError('NODE_UNSUPPORTED', `cf does not support ${platform}-${arch}. Use macOS, Linux or Windows x64.`);
43
+ }
44
+ }
45
+ function envLookup(env, platform, name) {
46
+ if (platform !== 'win32')
47
+ return env[name];
48
+ const key = Object.keys(env).find((candidate) => candidate.toUpperCase() === name.toUpperCase());
49
+ return key === undefined ? undefined : env[key];
50
+ }
51
+ function repositoryUrl(repository) {
52
+ if (typeof repository === 'string')
53
+ return repository;
54
+ if (repository && typeof repository === 'object' && typeof repository.url === 'string') {
55
+ return repository.url;
56
+ }
57
+ return '';
58
+ }
59
+ export async function locateCf(options) {
60
+ const platform = options.platform ?? process.platform;
61
+ const p = platform === 'win32' ? path.win32 : path.posix;
62
+ const fs = options.fs ?? nodeFs;
63
+ const cwd = options.cwd ?? process.cwd();
64
+ const home = options.home ?? (envLookup(options.env, platform, platform === 'win32' ? 'USERPROFILE' : 'HOME') || homedir());
65
+ const cwdIsHome = Boolean(home) && p.isAbsolute(home) && p.relative(home, cwd) === '';
66
+ let sawOther = false;
67
+ const isFile = async (target) => (await fs.stat(target).catch(() => null))?.isFile() === true;
68
+ const isDir = async (target) => (await fs.stat(target).catch(() => null))?.isDirectory() === true;
69
+ const verifyPackage = async (packageDir, expectedEntry) => {
70
+ const text = await fs.readFile(p.join(packageDir, 'package.json')).catch(() => null);
71
+ if (text === null)
72
+ return { ok: false, reason: 'missing' };
73
+ if (text.length > MAX_PACKAGE_JSON_BYTES)
74
+ return { ok: false, reason: 'impostor' };
75
+ let manifest;
76
+ try {
77
+ manifest = JSON.parse(text);
78
+ }
79
+ catch {
80
+ return { ok: false, reason: 'impostor' };
81
+ }
82
+ const binCf = manifest.bin && typeof manifest.bin === 'object' ? manifest.bin.cf : undefined;
83
+ if (manifest.name !== 'cf' || !repositoryUrl(manifest.repository).includes('github.com/cloudflare/cf') ||
84
+ typeof binCf !== 'string' || typeof manifest.version !== 'string') {
85
+ return { ok: false, reason: 'impostor' };
86
+ }
87
+ const entry = p.resolve(packageDir, binCf);
88
+ const relative = p.relative(packageDir, entry);
89
+ if (!relative || relative.startsWith('..') || p.isAbsolute(relative) || !(await isFile(entry))) {
90
+ return { ok: false, reason: 'impostor' };
91
+ }
92
+ if (expectedEntry !== undefined) {
93
+ const realEntry = await fs.realpath(entry).catch(() => entry);
94
+ if (realEntry !== expectedEntry)
95
+ return { ok: false, reason: 'impostor' };
96
+ }
97
+ return { ok: true, packageDir, entry, version: manifest.version };
98
+ };
99
+ /** A POSIX bin (symlink or file): resolve it and walk up to the nearest package.json. */
100
+ const fromPosixBin = async (bin) => {
101
+ const real = await fs.realpath(bin).catch(() => null);
102
+ if (real === null)
103
+ return { ok: false, reason: 'missing' };
104
+ let dir = p.dirname(real);
105
+ for (let depth = 0; depth < MAX_WALK_UP; depth++) {
106
+ if (await isFile(p.join(dir, 'package.json')))
107
+ return verifyPackage(dir, real);
108
+ const parent = p.dirname(dir);
109
+ if (parent === dir)
110
+ break;
111
+ dir = parent;
112
+ }
113
+ return { ok: false, reason: 'missing' };
114
+ };
115
+ const accept = (verified, source) => {
116
+ const relative = p.relative(cwd, verified.packageDir);
117
+ const inside = relative === '' || (!relative.startsWith('..') && !p.isAbsolute(relative));
118
+ return {
119
+ packageDir: verified.packageDir,
120
+ entry: verified.entry,
121
+ packageVersion: verified.version,
122
+ source,
123
+ insideCwd: inside && source !== 'managed' && !cwdIsHome,
124
+ };
125
+ };
126
+ // 1. --cf-bin: the package directory or its bin/cf. An explicit path is not
127
+ // silently replaced by another cf.
128
+ if (options.cfBin !== undefined) {
129
+ if (!p.isAbsolute(options.cfBin))
130
+ throw new Error('--cf-bin must be an absolute path.');
131
+ let verified;
132
+ if (await isDir(options.cfBin))
133
+ verified = await verifyPackage(options.cfBin);
134
+ else if (platform === 'win32')
135
+ verified = await verifyPackage(p.dirname(p.dirname(options.cfBin)));
136
+ else
137
+ verified = await fromPosixBin(options.cfBin);
138
+ if (verified.ok)
139
+ return accept(verified, 'cf-bin');
140
+ if (verified.reason === 'missing' && !(await fs.stat(options.cfBin).catch(() => null))) {
141
+ throw new LocalCheckupError('CF_NOT_FOUND', 'No file or directory exists at --cf-bin.');
142
+ }
143
+ throw new LocalCheckupError('CF_WRONG_BINARY', '--cf-bin does not point to Cloudflare\'s cf package (name "cf", repository github.com/cloudflare/cf).');
144
+ }
145
+ // 2. The Vigilbase-managed install; an unusable one falls through to the other candidates.
146
+ if (options.managedDir !== undefined && p.isAbsolute(options.managedDir)) {
147
+ const verified = await verifyPackage(options.managedDir);
148
+ if (verified.ok)
149
+ return accept(verified, 'managed');
150
+ }
151
+ // 3. Absolute PATH entries; `cf` everywhere first, then `cloudflare`.
152
+ const pathValue = envLookup(options.env, platform, 'PATH') ?? '';
153
+ const dirs = [...new Set(pathValue.split(platform === 'win32' ? ';' : ':'))]
154
+ .filter((dir) => dir !== '' && dir !== '.' && p.isAbsolute(dir));
155
+ const names = platform === 'win32' ? ['cf.cmd', 'cloudflare.cmd'] : ['cf', 'cloudflare'];
156
+ for (const name of names) {
157
+ for (const dir of dirs) {
158
+ const hit = p.join(dir, name);
159
+ if (!(await isFile(hit)))
160
+ continue;
161
+ // npm's Windows global layout keeps the package beside the shim.
162
+ const verified = platform === 'win32' ? await verifyPackage(p.join(dir, 'node_modules', 'cf')) : await fromPosixBin(hit);
163
+ if (verified.ok)
164
+ return accept(verified, 'path');
165
+ // Cloud Foundry's cf, or a shim (pnpm) without a resolvable package: keep searching.
166
+ sawOther = true;
167
+ }
168
+ }
169
+ // 4. Package-manager global directories, at most 20 filesystem probes.
170
+ let probes = 0;
171
+ const probe = async (work, fallback) => {
172
+ if (probes >= MAX_PACKAGE_MANAGER_PROBES)
173
+ return fallback;
174
+ probes++;
175
+ return work().catch(() => fallback);
176
+ };
177
+ const pnpmHome = envLookup(options.env, platform, 'PNPM_HOME');
178
+ if (pnpmHome && p.isAbsolute(pnpmHome)) {
179
+ const globalDir = p.join(pnpmHome, 'global');
180
+ const versions = await probe(() => fs.readdir(globalDir), []);
181
+ for (const version of versions.sort()) {
182
+ const packageDir = p.join(globalDir, version, 'node_modules', 'cf');
183
+ if (!(await probe(() => isFile(p.join(packageDir, 'package.json')), false)))
184
+ continue;
185
+ const verified = await verifyPackage(packageDir);
186
+ if (verified.ok)
187
+ return accept(verified, 'pnpm');
188
+ sawOther = true;
189
+ }
190
+ }
191
+ const voltaHome = envLookup(options.env, platform, 'VOLTA_HOME');
192
+ if (voltaHome && p.isAbsolute(voltaHome)) {
193
+ let frontier = [p.join(voltaHome, 'tools', 'image', 'packages', 'cf')];
194
+ for (let depth = 0; depth <= MAX_VOLTA_DEPTH && frontier.length > 0; depth++) {
195
+ const next = [];
196
+ for (const dir of frontier) {
197
+ if (p.basename(dir) === 'cf' && p.basename(p.dirname(dir)) === 'node_modules' &&
198
+ await probe(() => isFile(p.join(dir, 'package.json')), false)) {
199
+ const verified = await verifyPackage(dir);
200
+ if (verified.ok)
201
+ return accept(verified, 'volta');
202
+ sawOther = true;
203
+ continue;
204
+ }
205
+ const children = await probe(() => fs.readdir(dir), []);
206
+ for (const child of children.sort())
207
+ next.push(p.join(dir, child));
208
+ }
209
+ frontier = next;
210
+ }
211
+ }
212
+ if (sawOther) {
213
+ throw new LocalCheckupError('CF_WRONG_BINARY', 'A program named cf was found, but it is not Cloudflare\'s cf CLI (for example the Cloud Foundry CLI or an unresolvable shim).');
214
+ }
215
+ throw new LocalCheckupError('CF_NOT_FOUND', 'Cloudflare\'s cf CLI was not found on PATH or in the pnpm and Volta global directories.');
216
+ }
217
+ /** Gate the version cf prints against its package.json and `CF_SUPPORTED`. */
218
+ export function checkCfVersion(printed, packageVersion) {
219
+ const version = printed.trim();
220
+ if (version !== packageVersion) {
221
+ throw new LocalCheckupError('CF_WRONG_BINARY', 'cf --version does not match the version in its package.json.');
222
+ }
223
+ if (!isSupportedCfVersion(version)) {
224
+ throw new LocalCheckupError('CF_UNSUPPORTED_VERSION', `cf ${version.slice(0, 40)} is not supported; local checkups need cf >= ${CF_SUPPORTED.min} and < ${CF_SUPPORTED.maxExclusive}.`);
225
+ }
226
+ return { version, tested: CF_SUPPORTED.tested.includes(version) };
227
+ }
@@ -0,0 +1,88 @@
1
+ export declare const CF_CALL_TIMEOUT_MS = 45000;
2
+ export declare const STDOUT_CAP_BYTES: number;
3
+ /** `rulesets get` bodies can be large managed rulesets. */
4
+ export declare const RULESET_STDOUT_CAP_BYTES: number;
5
+ export declare const STDERR_CAP_BYTES: number;
6
+ /**
7
+ * Flags that skip confirmations, switch cf to local simulation or project
8
+ * config, or send a request body. No allowlisted argv contains them; the
9
+ * runner refuses to spawn one that does.
10
+ */
11
+ export declare const FORBIDDEN_CF_FLAGS: readonly string[];
12
+ export declare function assertSafeCfArgv(argv: readonly string[]): void;
13
+ export type CfProcessResult = {
14
+ exitCode: number | null;
15
+ stdout: string;
16
+ stderr: string;
17
+ timedOut: boolean;
18
+ stdoutTooLarge: boolean;
19
+ /** Set when the process could not be started at all. */
20
+ spawnFailed: boolean;
21
+ };
22
+ export type CfRunOptions = {
23
+ env: Record<string, string>;
24
+ timeoutMs?: number;
25
+ stdoutCapBytes?: number;
26
+ };
27
+ /** Owns the private working directory and every live cf child. */
28
+ export declare class CfProcessRunner {
29
+ readonly entry: string;
30
+ readonly privateDir: string;
31
+ private readonly active;
32
+ private disposed;
33
+ /** Set once the runner kills its children (abort or shutdown), so those kills are not read as timeouts. */
34
+ private terminating;
35
+ private constructor();
36
+ static create(entry: string): Promise<CfProcessRunner>;
37
+ run(argv: readonly string[], options: CfRunOptions): Promise<CfProcessResult>;
38
+ /**
39
+ * The only interactive cf passthroughs: `cf auth create` (sign-in) and
40
+ * `cf auth delete` (logout). With `stdoutToStderr` (`--json`), cf's stdout
41
+ * is sent to stderr so the CLI's stdout stays a single JSON object.
42
+ */
43
+ runInteractive(argv: readonly string[], env: Record<string, string>, options?: {
44
+ stdoutToStderr?: boolean;
45
+ }): Promise<number>;
46
+ get activeCount(): number;
47
+ /** SIGTERM every live child, then SIGKILL whatever is still running after 2 s. */
48
+ terminateAll(): Promise<void>;
49
+ dispose(): Promise<void>;
50
+ }
51
+ /**
52
+ * Run `work` with a private cf runner; the directory is removed afterwards and
53
+ * on SIGINT, when live children get SIGTERM and then SIGKILL.
54
+ */
55
+ export declare function withCfRunner<T>(entry: string, work: (runner: CfProcessRunner) => Promise<T>): Promise<T>;
56
+ export type CfFailure = {
57
+ kind: 'node_unsupported';
58
+ } | {
59
+ kind: 'command_unavailable';
60
+ } | {
61
+ kind: 'auth';
62
+ } | {
63
+ kind: 'http';
64
+ status: number;
65
+ } | {
66
+ kind: 'network';
67
+ } | {
68
+ kind: 'cf_error';
69
+ };
70
+ /** ANSI and box-drawing characters removed; lines kept. */
71
+ export declare function cleanCfStderr(stderr: string): string;
72
+ /**
73
+ * Classify a failed cf call from its stderr, in order: Node version, unknown
74
+ * command, auth without an HTTP status, the HTTP status line
75
+ * (`${status} ${text}[ · HTTP <path>]`), network, anything else. cf wraps
76
+ * messages at the terminal width, so word patterns run over collapsed text.
77
+ */
78
+ export declare function classifyCfStderr(stderr: string): CfFailure;
79
+ /**
80
+ * Parse cf stdout: lists print a bare JSON array, gets a bare object. cf prints
81
+ * nothing for a `null` result, so an empty get is `null` (the projectors decide).
82
+ */
83
+ export declare function parseCfJson(stdout: string, shape: 'array' | 'object'): {
84
+ ok: true;
85
+ value: unknown;
86
+ } | {
87
+ ok: false;
88
+ };