@kedge/cli 0.0.0-stage → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/doctor.ts ADDED
@@ -0,0 +1,701 @@
1
+ // `kedge doctor` (docs/proposals/p0.8-cli-agent-kit.md §4): named checks, each
2
+ // with a status and, when it fails, the failure and the fix, offline first.
3
+ // Vendure's doctor is the shape (.reference/vendure/packages/cli/src/commands/doctor/doctor.ts);
4
+ // the checks are what only Kedge has: a lock that owns migration order, an
5
+ // installed core nobody edits, an error map with no gaps, a catalog that
6
+ // claims every trigger, code that reaches the outside only through ports.
7
+ import { existsSync, readdirSync, readFileSync, realpathSync } from "node:fs";
8
+ import { join, resolve } from "node:path";
9
+ import { pathToFileURL } from "node:url";
10
+ import { parse } from "@babel/parser";
11
+ import { ExtensionConfigError, Extensions, type KedgeConfig } from "@kedge/core";
12
+ import { INVARIANTS } from "@kedge/testing/invariants";
13
+ import { lintStore } from "./code-lint";
14
+ import { ComposeError, compose, type Lock } from "./compose";
15
+ import { CORE_CODES, catalogProblems, generatedFor, isLocal } from "./invariants";
16
+ import { installedDrift, type Manifest, ownManifest } from "./manifest";
17
+ import { composedTexts, raiseCodes, type Schema, schemaOf } from "./schema";
18
+ import { compileStoreZone } from "./tz";
19
+ import { unitDrift } from "./units";
20
+ import { jsoncToJson, strictlyPublic } from "./worker-config";
21
+
22
+ export type Status = "pass" | "fail" | "warn" | "skip";
23
+ export interface CheckResult {
24
+ name: string;
25
+ status: Status;
26
+ message?: string;
27
+ /** Lines under the message: each problem, or each stated exception. */
28
+ details?: string[];
29
+ /** A failure only a person can fix (p0.10 §5): doctor then exits 3 with this as its `for you:` line. */
30
+ handoff?: string;
31
+ }
32
+
33
+ export interface DoctorInput {
34
+ cwd: string;
35
+ /** @kedge/db's migrations directory, where core's come from. */
36
+ coreDir: string;
37
+ /** The manifest to compare with; this CLI's own by default. */
38
+ manifest?: Manifest | null;
39
+ }
40
+
41
+ type WranglerConfig = {
42
+ main?: string;
43
+ d1_databases?: { binding?: string }[];
44
+ durable_objects?: { bindings?: { name?: string; class_name?: string }[] };
45
+ migrations?: {
46
+ tag?: string;
47
+ new_sqlite_classes?: string[];
48
+ new_classes?: string[];
49
+ deleted_classes?: string[];
50
+ renamed_classes?: { from: string; to: string }[];
51
+ }[];
52
+ queues?: { producers?: { binding?: string }[] };
53
+ observability?: { enabled?: boolean; logs?: { invocation_logs?: boolean } };
54
+ assets?: { directory?: string; binding?: string; not_found_handling?: string; run_worker_first?: unknown };
55
+ env?: Record<string, { observability?: WranglerConfig["observability"] }>;
56
+ };
57
+
58
+ export function readWrangler(cwd: string): { file: string; config: WranglerConfig } | null {
59
+ for (const f of ["wrangler.jsonc", "wrangler.json"]) {
60
+ const file = join(cwd, f);
61
+ if (existsSync(file))
62
+ return { file, config: JSON.parse(jsoncToJson(readFileSync(file, "utf8"))) as WranglerConfig };
63
+ }
64
+ return null;
65
+ }
66
+
67
+ export async function loadConfig(cwd: string): Promise<KedgeConfig> {
68
+ return ((await import(pathToFileURL(join(cwd, "kedge.config.ts")).href)) as { default: KedgeConfig }).default;
69
+ }
70
+
71
+ /** The classes a Worker's DO migrations leave declared, in order. */
72
+ export function declaredClasses(migrations: WranglerConfig["migrations"] = []): string[] {
73
+ const live = new Set<string>();
74
+ for (const m of migrations) {
75
+ for (const c of [...(m.new_sqlite_classes ?? []), ...(m.new_classes ?? [])]) live.add(c);
76
+ for (const r of m.renamed_classes ?? []) {
77
+ live.delete(r.from);
78
+ live.add(r.to);
79
+ }
80
+ for (const c of m.deleted_classes ?? []) live.delete(c);
81
+ }
82
+ return [...live];
83
+ }
84
+
85
+ /** What an entry module exports by name; null when an `export *` makes that unknowable statically. */
86
+ export function exportedNames(text: string): Set<string> | null {
87
+ const ast = parse(text, { sourceType: "module", plugins: ["typescript"] });
88
+ const names = new Set<string>();
89
+ for (const n of ast.program.body) {
90
+ if (n.type === "ExportAllDeclaration") return null;
91
+ if (n.type !== "ExportNamedDeclaration") continue;
92
+ for (const s of n.specifiers)
93
+ if (s.type === "ExportSpecifier")
94
+ names.add(s.exported.type === "Identifier" ? s.exported.name : s.exported.value);
95
+ const d = n.declaration;
96
+ if (d?.type === "ClassDeclaration" && d.id) names.add(d.id.name);
97
+ if (d?.type === "FunctionDeclaration" && d.id) names.add(d.id.name);
98
+ if (d?.type === "VariableDeclaration")
99
+ for (const v of d.declarations) {
100
+ if (v.id.type === "Identifier") names.add(v.id.name);
101
+ if (v.id.type === "ObjectPattern")
102
+ for (const p of v.id.properties)
103
+ if (p.type === "ObjectProperty" && p.value.type === "Identifier") names.add(p.value.name);
104
+ }
105
+ }
106
+ return names;
107
+ }
108
+
109
+ const pass = (name: string, message?: string): CheckResult => ({
110
+ name,
111
+ status: "pass",
112
+ ...(message ? { message } : {}),
113
+ });
114
+ const fail = (name: string, message: string, details?: string[]): CheckResult => ({
115
+ name,
116
+ status: "fail",
117
+ message,
118
+ ...(details?.length ? { details } : {}),
119
+ });
120
+ const skip = (name: string, message: string): CheckResult => ({ name, status: "skip", message });
121
+
122
+ /** The offline checks (§4.1), in order. */
123
+ export async function doctor(input: DoctorInput): Promise<CheckResult[]> {
124
+ const { cwd } = input;
125
+ const manifest = input.manifest === undefined ? ownManifest() : input.manifest;
126
+ const out: CheckResult[] = [];
127
+
128
+ // config: what a Worker would throw at boot.
129
+ let config: KedgeConfig | null = null;
130
+ try {
131
+ config = await loadConfig(cwd);
132
+ Extensions.resolve(config);
133
+ out.push(pass("config"));
134
+ } catch (e) {
135
+ out.push(
136
+ fail(
137
+ "config",
138
+ e instanceof ExtensionConfigError
139
+ ? `kedge.config.ts would fail at boot: ${e.message}`
140
+ : `kedge.config.ts doesn't load: ${(e as Error).message}`,
141
+ ),
142
+ );
143
+ }
144
+
145
+ // ports: what each effect does on a retry and which inputs are classified (the display P0.6a deferred).
146
+ if (config) {
147
+ const ports = [...(config.ports ?? []), ...(config.plugins ?? []).flatMap((p) => p.definition.ports ?? [])];
148
+ out.push({
149
+ ...pass("ports", ports.length ? `${ports.length} port${ports.length === 1 ? "" : "s"}` : "none"),
150
+ ...(ports.length
151
+ ? {
152
+ details: ports.flatMap((p) =>
153
+ Object.entries(p.operations).map(([name, op]) => {
154
+ const classified =
155
+ op.kind === "effect" ? (op.classified ?? []).map((c) => `${c.field}:${c.scope}`).join(", ") : "";
156
+ return `${p.name}.${name}: ${op.kind}${op.kind === "effect" ? `, resend ${op.resend.policy}, ${p.deliveryClass}` : ""}${classified ? `, classified ${classified}` : ""}`;
157
+ }),
158
+ ),
159
+ }
160
+ : {}),
161
+ });
162
+ }
163
+
164
+ // lock: every composed file is what the lock says, and the lock lists every file.
165
+ const lockPath = join(cwd, "kedge.lock.json");
166
+ const lock = existsSync(lockPath) ? (JSON.parse(readFileSync(lockPath, "utf8")) as Lock) : null;
167
+ if (!lock) out.push(fail("lock", "kedge.lock.json is missing: run kedge generate"));
168
+ else {
169
+ const { createHash } = await import("node:crypto");
170
+ const problems: string[] = [];
171
+ for (const e of lock.entries) {
172
+ const path = join(cwd, "migrations", e.file);
173
+ if (!existsSync(path)) problems.push(`${e.file} is in the lock but not in migrations/`);
174
+ else if (createHash("sha256").update(readFileSync(path, "utf8")).digest("hex") !== e.sha256)
175
+ problems.push(`${e.file} was edited: applied migrations are immutable; put a change in a new migration`);
176
+ }
177
+ const locked = new Set(lock.entries.map((e) => e.file));
178
+ const dir = join(cwd, "migrations");
179
+ for (const f of existsSync(dir) ? readdirSync(dir).filter((f) => f.endsWith(".sql")) : [])
180
+ if (!locked.has(f))
181
+ problems.push(
182
+ `migrations/${f} isn't in kedge.lock.json: migrations/ is written only by kedge generate; yours go in local/`,
183
+ );
184
+ out.push(problems.length ? fail("lock", "the composed migrations don't match the lock", problems) : pass("lock"));
185
+ }
186
+
187
+ // plugin-sql: the composer's own rules over every plugin source, as generate runs them.
188
+ if (config && lock) {
189
+ try {
190
+ compose({
191
+ coreDir: input.coreDir,
192
+ plugins: config.plugins ?? [],
193
+ // A preview's own ports are locked like any other's (p0.8 §5.2).
194
+ ports: [...(config.ports ?? []), ...(config.preview?.ports ?? [])],
195
+ localDir: join(cwd, "local"),
196
+ outDir: join(cwd, "migrations"),
197
+ lockPath,
198
+ now: Date.now(),
199
+ dryRun: true,
200
+ ...(config.timezone ? { timezone: config.timezone } : {}),
201
+ compileZone: compileStoreZone,
202
+ tzPath: join(cwd, "src", "kedge.tz.ts"),
203
+ });
204
+ out.push(pass("plugin-sql"));
205
+ } catch (e) {
206
+ if (!(e instanceof ComposeError)) throw e;
207
+ out.push(fail("plugin-sql", "kedge generate refuses this config", e.message.split("\n")));
208
+ }
209
+ } else out.push(skip("plugin-sql", "needs a config and a lock"));
210
+
211
+ // core-migrations: core's composed files are byte for byte what the release shipped.
212
+ if (!manifest) out.push(skip("core-migrations", "no release manifest (a workspace install)"));
213
+ else if (lock) {
214
+ const problems = lock.entries
215
+ .filter((e) => e.source.startsWith("core:0"))
216
+ .flatMap((e) => {
217
+ const f = e.source.slice("core:".length);
218
+ const want = manifest.migrations[f]?.sha256;
219
+ return want === undefined
220
+ ? [`${e.file}: ${f} isn't a migration of Kedge ${manifest.version}`]
221
+ : want !== e.sha256
222
+ ? [
223
+ `${e.file}: differs from Kedge ${manifest.version}'s ${f}; core migrations are never edited (a fork's go in their own namespace, FORKING.md)`,
224
+ ]
225
+ : [];
226
+ });
227
+ out.push(
228
+ problems.length
229
+ ? fail("core-migrations", "core migrations differ from the release", problems)
230
+ : pass("core-migrations"),
231
+ );
232
+ }
233
+
234
+ // installed: nothing under node_modules/@kedge was edited, added or removed.
235
+ if (!manifest) out.push(skip("installed", "no release manifest (a workspace install)"));
236
+ else {
237
+ const drift = installedDrift(cwd, manifest);
238
+ out.push(
239
+ drift.length
240
+ ? fail(
241
+ "installed",
242
+ `installed Kedge differs from ${manifest.version}: never edit node_modules/@kedge; reinstall, and extend through config, a plugin or kedge eject`,
243
+ drift.flatMap((d) => [
244
+ ...d.changed.map((f) => `${d.package}/${f} changed`),
245
+ ...d.added.map((f) => `${d.package}/${f} added`),
246
+ ...d.missing.map((f) => `${d.package}/${f} missing`),
247
+ ]),
248
+ )
249
+ : pass("installed"),
250
+ );
251
+ }
252
+
253
+ // versions: every @kedge package at one version.
254
+ const kedgeDir = join(cwd, "node_modules", "@kedge");
255
+ if (existsSync(kedgeDir)) {
256
+ const versions = new Map<string, string>();
257
+ for (const p of readdirSync(kedgeDir)) {
258
+ const pj = join(kedgeDir, p, "package.json");
259
+ if (existsSync(pj))
260
+ versions.set(`@kedge/${p}`, (JSON.parse(readFileSync(pj, "utf8")) as { version: string }).version);
261
+ }
262
+ const distinct = new Set(versions.values());
263
+ out.push(
264
+ distinct.size > 1
265
+ ? fail(
266
+ "versions",
267
+ "@kedge packages are at different versions: upgrade them together (kedge upgrade)",
268
+ [...versions].map(([n, v]) => `${n} ${v}`),
269
+ )
270
+ : pass("versions"),
271
+ );
272
+ } else out.push(skip("versions", "no node_modules/@kedge"));
273
+
274
+ // error-map, invariants: read from the composed schema.
275
+ let schema: Schema | null = null;
276
+ if (lock) {
277
+ try {
278
+ schema = schemaOf(composedTexts(cwd, lock));
279
+ } catch (e) {
280
+ out.push(fail("error-map", `the composed migrations don't apply to an empty database: ${(e as Error).message}`));
281
+ }
282
+ }
283
+ if (schema && config) {
284
+ const problems: string[] = [];
285
+ const plugins = (config.plugins ?? []).map((p) => p.definition);
286
+ const ownerOf = (name: string) => generatedFor(name, plugins);
287
+ const local: string[] = [];
288
+ for (const o of schema.objects) {
289
+ if (o.name.startsWith("plg_") || ownerOf(o.name)) continue;
290
+ if (isLocal(o)) {
291
+ // No map can name a local code, so it reaches shoppers as INTERNAL: said, not failed (codex code r19).
292
+ for (const c of raiseCodes(o.sql)) if (!(c in CORE_CODES)) local.push(`local RAISE '${c}' in ${o.name}`);
293
+ continue;
294
+ }
295
+ for (const c of raiseCodes(o.sql))
296
+ if (!(c in CORE_CODES)) problems.push(`core RAISE '${c}' in ${o.name} maps to no error code`);
297
+ }
298
+ for (const d of plugins) {
299
+ const name = d.name;
300
+ const mapped = new Set(Object.keys(d.errors ?? {}));
301
+ for (const o of schema.objects.filter(
302
+ (o) => o.name.startsWith(`plg_${name}_`) || o.table.startsWith(`plg_${name}_`) || ownerOf(o.name) === d,
303
+ )) {
304
+ if (o.type === "constraint" && !mapped.has(o.name))
305
+ problems.push(`${name}'s constraint ${o.name} has no entry in its errors map`);
306
+ if (o.type === "trigger" || o.type === "view")
307
+ for (const c of raiseCodes(o.sql))
308
+ if (!mapped.has(c) && !c.startsWith("KEDGE_") && !(c in CORE_CODES))
309
+ problems.push(`${name}'s RAISE '${c}' in ${o.name} has no entry in its errors map`);
310
+ }
311
+ }
312
+ out.push(
313
+ problems.length
314
+ ? fail("error-map", "a refusal maps to no error code, so a shopper would get INTERNAL", [...new Set(problems)])
315
+ : local.length
316
+ ? {
317
+ name: "error-map",
318
+ status: "warn",
319
+ message:
320
+ "local SQL raises codes no map names, which shoppers get as INTERNAL: a refusal they should read belongs in a plugin's errors map",
321
+ details: [...new Set(local)],
322
+ }
323
+ : pass("error-map"),
324
+ );
325
+ const cat = catalogProblems(schema, new Set(INVARIANTS.map((i) => i.name)));
326
+ out.push(
327
+ cat.length
328
+ ? fail(
329
+ "invariants",
330
+ "the invariant catalog doesn't match the schema",
331
+ cat.map((p) => p.message),
332
+ )
333
+ : pass("invariants"),
334
+ );
335
+ } else if (!out.some((r) => r.name === "error-map")) {
336
+ out.push(skip("error-map", "needs a config and a lock"));
337
+ out.push(skip("invariants", "needs a config and a lock"));
338
+ }
339
+
340
+ // code: the store's executable code, through its imports.
341
+ const w = readWrangler(cwd);
342
+ const entry = w?.config.main ?? "src/index.ts";
343
+ const issues = lintStore(cwd, ["kedge.config.ts", entry]);
344
+ const failing = issues.filter((i) => !i.allowed);
345
+ const allowed = issues.filter((i) => i.allowed);
346
+ out.push(
347
+ failing.length
348
+ ? fail(
349
+ "code",
350
+ "code the lint refuses (p0.8 §4.5)",
351
+ failing.map((i) => `${i.file}:${i.line} ${i.rule}: ${i.message}`),
352
+ )
353
+ : {
354
+ ...pass(
355
+ "code",
356
+ allowed.length ? `${allowed.length} stated exception${allowed.length === 1 ? "" : "s"}` : undefined,
357
+ ),
358
+ ...(allowed.length ? { details: allowed.map((i) => `${i.file}:${i.line} ${i.rule}: ${i.allowed}`) } : {}),
359
+ },
360
+ );
361
+
362
+ // wrangler: the bindings core needs, the webhook flag, every declared class exported.
363
+ if (!w) out.push(fail("wrangler", "no wrangler.jsonc"));
364
+ else {
365
+ const c = w.config;
366
+ const problems: string[] = [];
367
+ if (!c.d1_databases?.some((d) => d.binding === "DB")) problems.push("no D1 binding DB");
368
+ for (const b of ["CART", "CLOCK", "JOURNAL"])
369
+ if (!c.durable_objects?.bindings?.some((d) => d.name === b)) problems.push(`no Durable Object binding ${b}`);
370
+ if (!c.queues?.producers?.some((q) => q.binding === "EVENTS")) problems.push("no queue producer EVENTS");
371
+ const strict = strictlyPublic(cwd);
372
+ if (strict.problem) problems.push(strict.problem);
373
+ const entryFile = join(cwd, entry);
374
+ if (existsSync(entryFile)) {
375
+ const names = exportedNames(readFileSync(entryFile, "utf8"));
376
+ if (names)
377
+ for (const cls of declaredClasses(c.migrations))
378
+ if (!names.has(cls))
379
+ problems.push(`${entry} doesn't export ${cls}, which wrangler.jsonc's migrations declare`);
380
+ } else problems.push(`${entry} doesn't exist`);
381
+ out.push(
382
+ problems.length ? fail("wrangler", "wrangler.jsonc isn't what a Kedge Worker needs", problems) : pass("wrangler"),
383
+ );
384
+
385
+ // invocation-logs: Cloudflare's per-request log records the URL, which erasure can't reach.
386
+ const off = (o: WranglerConfig["observability"]) => o?.logs?.invocation_logs === false;
387
+ const where = [
388
+ ...(off(c.observability) ? [] : ["the top level"]),
389
+ ...Object.entries(c.env ?? {})
390
+ .filter(([, e]) => e?.observability !== undefined && !off(e.observability))
391
+ .map(([n]) => `env.${n}`),
392
+ ];
393
+ out.push(
394
+ where.length
395
+ ? fail(
396
+ "invocation-logs",
397
+ `set "observability": { "logs": { "invocation_logs": false } } at ${where.join(", ")}: invocation logs record each request's URL, which can carry a customer's data that erasure can't reach`,
398
+ )
399
+ : pass("invocation-logs"),
400
+ );
401
+ }
402
+
403
+ // admin: the one assets shape research/27 measured (p0.9 §3.2).
404
+ if (w) out.push(adminCheck(cwd, w.config));
405
+
406
+ // unit-drift: each ejected unit against the upstream it was copied from (§7.3).
407
+ out.push(unitDrift(cwd, manifest, config));
408
+
409
+ // tz: the compiled rules are the lock's.
410
+ const tzFile = join(cwd, "src", "kedge.tz.ts");
411
+ if (lock && existsSync(tzFile)) {
412
+ const have = /version:\s*"([^"]+)"/.exec(readFileSync(tzFile, "utf8"))?.[1];
413
+ const pinned =
414
+ lock.entries
415
+ .find((e) => e.source.startsWith("core:tz:"))
416
+ ?.source.split(":")
417
+ .at(-1) ?? "utc";
418
+ out.push(
419
+ have === pinned
420
+ ? pass("tz")
421
+ : fail(
422
+ "tz",
423
+ `src/kedge.tz.ts holds rules ${have ?? "(none)"}, but the store is pinned to ${pinned}: restore it from version control or run kedge generate`,
424
+ ),
425
+ );
426
+ } else out.push(skip("tz", "no src/kedge.tz.ts or lock"));
427
+
428
+ return out;
429
+ }
430
+
431
+ const MARK: Record<Status, string> = { pass: "✓", fail: "✗", warn: "!", skip: "-" };
432
+
433
+ export function formatReport(results: readonly CheckResult[]): string {
434
+ const lines: string[] = [];
435
+ for (const r of results) {
436
+ lines.push(`${MARK[r.status]} ${r.name}${r.message ? `: ${r.message}` : ""}`);
437
+ for (const d of r.details ?? []) lines.push(` ${d}`);
438
+ }
439
+ const failed = results.filter((r) => r.status === "fail").length;
440
+ lines.push(failed ? `\n${failed} check${failed === 1 ? "" : "s"} failed` : "\nall checks passed");
441
+ return lines.join("\n");
442
+ }
443
+
444
+ // ---- the remote checks (§4.3) --------------------------------------------------------------------------
445
+
446
+ const FAMILY_SECRET: Record<string, string> = {
447
+ auth: "KEDGE_AUTH_SECRET_V",
448
+ pepper: "KEDGE_KEY_PEPPER_V",
449
+ totp: "KEDGE_TOTP_KEY_V",
450
+ };
451
+
452
+ /**
453
+ * Whether the resolved credentials reach the store's account, claiming only
454
+ * what a read can show (p0.10 §4). A list succeeding proves the token answers
455
+ * for this account; it doesn't prove a write (Cloudflare's D1 list takes `D1
456
+ * Read` or `D1 Write`, creating a database only the latter:
457
+ * https://developers.cloudflare.com/api/resources/d1/subresources/database/methods/list/),
458
+ * so the writes are named as unverified, never passed. The token itself is never shown.
459
+ */
460
+ export async function credentialsChecks(input: {
461
+ cf: import("./cf").Cloudflare;
462
+ source: string;
463
+ base: string;
464
+ }): Promise<CheckResult[]> {
465
+ const { NeedsPerson } = await import("./cf");
466
+ const where = `from ${input.source}, sent to ${input.base}, account ${input.cf.account}`;
467
+ const read = await input.cf.readable();
468
+ const refused = Object.entries(read).filter(([, status]) => status !== null);
469
+ const out: CheckResult[] = [];
470
+ if (refused.length === Object.keys(read).length && refused.every(([, s]) => s === 401 || s === 403))
471
+ out.push(fail("credentials", `the token doesn't answer for this account (${where})`));
472
+ else if (refused.length)
473
+ out.push(
474
+ fail(
475
+ "credentials",
476
+ `the token can't list everything a store uses (${where})`,
477
+ refused.map(([kind, s]) => (s ? `${kind}: Cloudflare answered ${s}` : `${kind}: no answer`)),
478
+ ),
479
+ );
480
+ else
481
+ out.push(
482
+ pass(
483
+ "credentials",
484
+ `${where}; lists Workers, D1 and Queues. Writes are unverified until the first deploy: Workers Scripts Edit, D1 Edit, Queues Edit`,
485
+ ),
486
+ );
487
+ try {
488
+ out.push(pass("workers.dev", `${await input.cf.workersSubdomain()}.workers.dev`));
489
+ } catch (e) {
490
+ // Any answer is a report: a token that can't read the subdomain fails this check, never the run (codex code r7).
491
+ // An account without one is the owner's to choose: a handoff, not a technical failure (codex code r9).
492
+ if (e instanceof NeedsPerson) out.push({ ...fail("workers.dev", e.handoff), handoff: e.handoff });
493
+ else out.push(fail("workers.dev", (e as Error).message));
494
+ }
495
+ return out;
496
+ }
497
+
498
+ export interface RemoteDoctorInput {
499
+ cwd: string;
500
+ cf: import("./cf").Cloudflare;
501
+ lock: Lock;
502
+ /** Logpush jobs of the account, or null when the token can't read them. */
503
+ logpushJobs: () => Promise<{ dataset: string; enabled: boolean; fields: string[] | null }[] | null>;
504
+ now?: () => number;
505
+ }
506
+
507
+ /** Checks against the store's own Cloudflare resources; reads only. */
508
+ export async function doctorRemote(input: RemoteDoctorInput): Promise<CheckResult[]> {
509
+ const { appliedNames, appliedProblems, openOps, SCOPE } = await import("./deploy");
510
+ const { storeNames } = await import("./store");
511
+ const now = (input.now ?? Date.now)();
512
+ const out: CheckResult[] = [];
513
+ const names = storeNames(input.cwd).names;
514
+ const opsId = await input.cf.findD1(names.ops);
515
+ if (!opsId) return [skip("remote", `no ${names.ops}: this store hasn't been deployed with kedge deploy`)];
516
+ const { ops, jurisdiction } = await openOps(input.cf, names);
517
+ const db = await ops.resource("d1", names.database);
518
+
519
+ // jurisdiction: the store's database is where <store>-ops records the store's data lives (p0.10 §7).
520
+ if (db?.id) {
521
+ const actual = await input.cf.d1Jurisdiction(db.id);
522
+ out.push(
523
+ actual === jurisdiction
524
+ ? pass("jurisdiction", jurisdiction)
525
+ : fail("jurisdiction", `${names.database} is in ${actual}, but the store's jurisdiction is ${jurisdiction}`),
526
+ );
527
+ }
528
+
529
+ // applied: D1's record is a prefix of the lock, and what ops recorded of each file's bytes still holds.
530
+ if (db?.id) {
531
+ const problems = appliedProblems(input.lock, await appliedNames(input.cf, db.id), await ops.appliedHashes());
532
+ out.push(
533
+ problems.length ? fail("applied", "the store's migrations don't match the lock", problems) : pass("applied"),
534
+ );
535
+ } else out.push(skip("applied", "no database recorded"));
536
+
537
+ // serving: the active version, crons and settings are what the latest deployment recorded (§3.1's residue).
538
+ const rows = (await ops.deployments()).filter((r) => r.after_half);
539
+ const last = rows[0] ? (JSON.parse(rows[0].after_half as string) as import("./deploy").Half) : null;
540
+ if (last && (await input.cf.findWorker(names.worker))) {
541
+ const problems: string[] = [];
542
+ const active = (await input.cf.deployments(names.worker))[0];
543
+ const serving = active?.versions.length === 1 ? active.versions[0]?.version_id : null;
544
+ if (serving !== last.versionId)
545
+ problems.push(
546
+ `serves ${serving ?? `a split (${active?.versions.length ?? 0} versions)`}, but the last deployment recorded ${last.versionId}: a late call, or a change outside Kedge`,
547
+ );
548
+ const crons = await input.cf.schedules(names.worker);
549
+ if (last.crons && JSON.stringify(crons) !== JSON.stringify(last.crons))
550
+ problems.push(`crons are ${JSON.stringify(crons)}, recorded ${JSON.stringify(last.crons)}`);
551
+ const settings = await input.cf.settings(names.worker);
552
+ if (last.settings && JSON.stringify(settings) !== JSON.stringify(last.settings))
553
+ problems.push(`settings are ${JSON.stringify(settings)}, recorded ${JSON.stringify(last.settings)}`);
554
+ const held = await ops.current(SCOPE);
555
+ out.push(
556
+ problems.length
557
+ ? fail("serving", "what serves isn't what Kedge last deployed", problems)
558
+ : pass(
559
+ "serving",
560
+ held ? `operation ${held.op_id} (${held.kind}) holds the store at "${held.step}"` : undefined,
561
+ ),
562
+ );
563
+ // do-classes: rollback across a class change is blocked (a warning).
564
+ const planned = (readWrangler(input.cwd)?.config.migrations ?? []) as unknown[];
565
+ if (JSON.stringify(planned) !== JSON.stringify(last.doMigrations ?? []))
566
+ out.push({
567
+ name: "do-classes",
568
+ status: "warn",
569
+ message: "this checkout changes Durable Object classes: rollback across the next deploy is blocked",
570
+ });
571
+ } else if (last)
572
+ out.push(
573
+ fail("serving", `${names.worker} was deployed, and no Worker by that name exists now`, [
574
+ "an ordinary deploy refuses: it would make new secrets and Durable Objects over the surviving database",
575
+ ]),
576
+ );
577
+ else out.push(skip("serving", "nothing deployed yet"));
578
+
579
+ // secrets: core's two, and each keyring family's active and retiring versions, as the database says.
580
+ if (db?.id && (await input.cf.findWorker(names.worker))) {
581
+ const have = new Set(await input.cf.secretNames(names.worker));
582
+ const need = new Set(["CART_TOKEN_SECRET", "ORDER_TOKEN_SECRET"]);
583
+ for (const r of await input.cf.queryD1(db.id, "SELECT name, active, retiring FROM keyring_setting")) {
584
+ const prefix = FAMILY_SECRET[String(r.name)];
585
+ if (!prefix) continue;
586
+ need.add(`${prefix}${String(r.active)}`);
587
+ if (r.retiring !== null && r.retiring !== undefined) need.add(`${prefix}${String(r.retiring)}`);
588
+ }
589
+ const missing = [...need].filter((s) => !have.has(s));
590
+ out.push(
591
+ missing.length
592
+ ? fail("secrets", "the Worker lacks secrets its database uses: set each with kedge secret put", missing)
593
+ : pass("secrets"),
594
+ );
595
+ } else out.push(skip("secrets", "nothing deployed yet"));
596
+
597
+ // logpush: a job that ships request URLs or headers out of erasure's reach.
598
+ const jobs = await input.logpushJobs().catch(() => null);
599
+ if (jobs === null) out.push(skip("logpush", "this token can't read Logpush jobs; check them in the dashboard"));
600
+ else {
601
+ const settings = (await input.cf.findWorker(names.worker)) ? await input.cf.settings(names.worker) : null;
602
+ const leaking = jobs.filter(
603
+ (j) =>
604
+ j.enabled &&
605
+ j.dataset === "workers_trace_events" &&
606
+ (j.fields === null || j.fields.some((f) => /^(Event|Request|URL|Headers)/i.test(f))),
607
+ );
608
+ out.push(
609
+ settings?.logpush && leaking.length
610
+ ? fail(
611
+ "logpush",
612
+ "a Logpush job ships Workers' request URLs or headers, which erasure can't reach: remove those fields",
613
+ )
614
+ : pass("logpush"),
615
+ );
616
+ }
617
+
618
+ // previews: old ones, and lost creates whose names are re-checked for 7 days (§3.1).
619
+ const previews = await ops.query(
620
+ "SELECT identity, worker, database_name, database_id, queue_name, state, lost_create, updated_at FROM preview",
621
+ );
622
+ const old = previews.filter((p) => p.state !== "destroyed" && now - Number(p.updated_at) > 14 * 86_400_000);
623
+ const orphans: string[] = [];
624
+ // A reused identity's earlier generation is in preview_tombstone, re-checked the same 7 days (codex code r6).
625
+ const tombstones = await ops.query(
626
+ "SELECT worker, database_name, queue_name FROM preview_tombstone WHERE destroyed_at > ?",
627
+ [now - 7 * 86_400_000],
628
+ );
629
+ for (const p of [
630
+ ...previews.filter(
631
+ (p) => p.state === "destroyed" && Number(p.lost_create) === 1 && now - Number(p.updated_at) < 7 * 86_400_000,
632
+ ),
633
+ ...tombstones,
634
+ ]) {
635
+ if (await input.cf.findWorker(String(p.worker))) orphans.push(`Worker ${String(p.worker)}`);
636
+ if (await input.cf.findD1(String(p.database_name))) orphans.push(`database ${String(p.database_name)}`);
637
+ if (await input.cf.findQueue(String(p.queue_name))) orphans.push(`queue ${String(p.queue_name)}`);
638
+ }
639
+ // A live preview's database where its store's data lives (p0.10 §7, codex code r13).
640
+ const misplaced: string[] = [];
641
+ for (const p of previews.filter((p) => p.state === "bootstrapped" && p.database_id))
642
+ if ((await input.cf.d1Jurisdiction(String(p.database_id))) !== jurisdiction)
643
+ misplaced.push(`database ${String(p.database_name)}`);
644
+ if (misplaced.length)
645
+ out.push(fail("previews", `a preview's database isn't in the store's jurisdiction, ${jurisdiction}`, misplaced));
646
+ else if (orphans.length)
647
+ out.push(
648
+ fail("previews", "a destroyed preview's late create left resources: kedge preview destroy --sweep", orphans),
649
+ );
650
+ else if (old.length)
651
+ out.push({
652
+ name: "previews",
653
+ status: "warn",
654
+ message: `${old.length} preview${old.length === 1 ? "" : "s"} older than 14 days`,
655
+ details: old.map((p) => String(p.identity)),
656
+ });
657
+ else out.push(pass("previews"));
658
+ return out;
659
+ }
660
+
661
+ /**
662
+ * The admin's assets block (p0.9 §3.2), when present, is exactly the supported
663
+ * shape: the installed `@kedge/admin`'s dist, bound as `ASSETS`, with no SPA
664
+ * fallback and no `run_worker_first`. That's a restriction to the one shape
665
+ * research/27 measured: `single-page-application` answers unmatched requests,
666
+ * missing chunks included, with HTML; `404-page` and `run_worker_first` aren't
667
+ * tested under the admin's routes. No block serves no admin: allowed, said.
668
+ */
669
+ /**
670
+ * The one supported shape of an `assets` block (p0.9 §2.1), shared by doctor
671
+ * and preview so neither accepts what the other refuses: the `ASSETS` binding,
672
+ * no fallback (absent or "none") and no `run_worker_first`.
673
+ */
674
+ export function assetsShape(a: NonNullable<WranglerConfig["assets"]>): string[] {
675
+ const problems: string[] = [];
676
+ if (a.binding !== "ASSETS") problems.push('`assets.binding` must be "ASSETS"');
677
+ if (a.not_found_handling !== undefined && a.not_found_handling !== "none")
678
+ problems.push(
679
+ `\`assets.not_found_handling\` is "${a.not_found_handling}": only "none" (or absent) is supported, since a fallback can answer API paths and missing chunks with HTML`,
680
+ );
681
+ if (a.run_worker_first !== undefined)
682
+ problems.push("`assets.run_worker_first` isn't supported: the admin's routes aren't tested under it");
683
+ return problems;
684
+ }
685
+
686
+ export function adminCheck(cwd: string, c: WranglerConfig): CheckResult {
687
+ const a = c.assets;
688
+ if (!a) return { name: "admin", status: "warn", message: "no `assets` block: this store serves no admin" };
689
+ const problems: string[] = [];
690
+ const installed = join(cwd, "node_modules", "@kedge", "admin", "dist");
691
+ const dir = a.directory ? resolve(cwd, a.directory) : null;
692
+ // Compared after links resolve, as pnpm installs through them; a directory that isn't there is just wrong.
693
+ const same =
694
+ dir !== null &&
695
+ (existsSync(installed) && existsSync(dir) ? realpathSync(dir) === realpathSync(installed) : dir === installed);
696
+ if (!same) problems.push('`assets.directory` must be "./node_modules/@kedge/admin/dist", the installed admin');
697
+ if (existsSync(installed) && !existsSync(join(installed, "app", "boot.js")))
698
+ problems.push("the installed @kedge/admin has no built admin (dist/app/boot.js)");
699
+ problems.push(...assetsShape(a));
700
+ return problems.length ? fail("admin", "the admin isn't served the supported way", problems) : pass("admin");
701
+ }