@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/main.ts ADDED
@@ -0,0 +1,470 @@
1
+ // The `kedge` command. P0.2 ships `generate`, P0.5 the deployment's bootstrap
2
+ // commands (`staff invite-owner`, `staff recover`, `keys create`); doctor,
3
+ // check, preview and deploy arrive with P0.8 (docs/11).
4
+ import { spawnSync } from "node:child_process";
5
+ import { cpSync, existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
6
+ import { dirname, join, relative, resolve } from "node:path";
7
+ import { fileURLToPath, pathToFileURL } from "node:url";
8
+ import type { KedgeConfig } from "@kedge/core";
9
+ import { ownerInviteSql, publishableKeySql, recoverSql } from "@kedge/db";
10
+ import {
11
+ type Cloudflare,
12
+ cloudflareBase,
13
+ cloudflareFor,
14
+ NeedsPerson,
15
+ resolveCloudflare,
16
+ restCloudflare,
17
+ sameAccount,
18
+ wranglerBin,
19
+ } from "./cf";
20
+ import { check as runCheck } from "./check";
21
+ import {
22
+ type CommandDeps,
23
+ deployCommand,
24
+ devCommand,
25
+ ejectCommand,
26
+ failureOf,
27
+ flag,
28
+ generateSupersede,
29
+ keysOverHttp,
30
+ migrateCommand,
31
+ previewCommand,
32
+ restoreCommand,
33
+ secretCommand,
34
+ seedCommand,
35
+ smokeCommand,
36
+ upgradeCommand,
37
+ } from "./commands";
38
+ import { ComposeError, compose, type Lock } from "./compose";
39
+ import { type Executor, wranglerExecute } from "./d1-batch";
40
+ import { credentialsChecks, doctor, doctorRemote, formatReport } from "./doctor";
41
+ import { invariantsMarkdown, invariantsOf } from "./invariants";
42
+ import { installedPackages } from "./manifest";
43
+ import { emit, withJsonOutput } from "./output";
44
+ import { composedTexts, schemaOf } from "./schema";
45
+ import { storeNames } from "./store";
46
+ import { compileStoreZone } from "./tz";
47
+ import { strictlyPublic } from "./worker-config";
48
+
49
+ const USAGE = `usage: kedge generate [--check] [--supersede <op-id>]
50
+ kedge check [--fast]
51
+ kedge doctor [--json]
52
+ kedge dev
53
+ kedge migrate [--remote]
54
+ kedge deploy [--maintenance-window <from>/<to>] [--var K=V]... [--adopt <name>]...
55
+ [--resume | --take-over <op-id> --confirm-stopped | --abort-cutover | --supersede <op-id>]
56
+ [--resolve-intent <id> --not-executed] [--record-active --reason <what>]
57
+ [--adopt-existing --from-release <version>] [--allow-older]
58
+ kedge preview [--name N] [--var K=V]... [--fresh] [--secret-env NAME]...
59
+ kedge preview build [--name N] | kedge preview apply <plan.tar|-> [--store S] [--name N]
60
+ kedge preview destroy [--name N] [--sweep]
61
+ kedge eject <unit> --reason "<why>" | kedge eject --rebase <unit>
62
+ kedge upgrade --to <version> [--from-packs <dir>]
63
+ kedge doctor --core-diff [--upstream <manifest>] [--strict]
64
+ kedge secret put <NAME> | kedge secret delete <NAME>
65
+ kedge restore --list
66
+ kedge seed --sample apparel --url <url> [--key <sk_…> | --session <token>] [--live]
67
+ kedge smoke --url <url> [--run <name>] [--live]
68
+ kedge keys create (--publishable [--origin <url>]... | --secret --grant <permission>...)
69
+ --url <url> (--session <token> | --sign-in <email> [--answers-stdin] | --save) [--name <name>]
70
+ kedge staff invite-owner <email> [--database DB] [--remote] [--url <url>] [--config wrangler.jsonc]
71
+ kedge staff recover <email> [--database DB] [--remote] [--config wrangler.jsonc]
72
+ kedge keys create --publishable --name <name> [--origin <origin>]... [--pepper-version N]
73
+ [--database DB] [--remote] [--config wrangler.jsonc]
74
+
75
+ generate compose core, plugin and local migrations into migrations/
76
+ and update kedge.lock.json
77
+ generate --check fail if generate would write anything (for CI)
78
+ dev fresh local secrets once, local migrations, then wrangler dev
79
+ migrate local migrations; --remote applies N−1 files to production, refusing a cutover
80
+ deploy migrations before the Worker, under an operation in <store>-ops (§6)
81
+ preview a separate Worker with its own D1, queue and Durable Objects, capture
82
+ sinks for every effect, seeded, then test/scenarios/*.http.ts against it
83
+ eject copy one unit of core into src/overrides, its upstream hash recorded
84
+ upgrade every @kedge package to a release one supported step on, its codemods,
85
+ UPGRADE-TODO.md, then generate and check; nothing is committed
86
+ restore --list the restore points; restoring waits for P1.18
87
+ smoke a test order on one of this store's own URLs: a cart, completion and
88
+ placement, resumed by its --run name until it has its number
89
+ check typecheck, generate --check, doctor, then the store's tests;
90
+ --fast stops before the tests (the Stop hook's)
91
+ doctor the store's offline checks: lock, installed core, error map,
92
+ invariant catalog, code lint, wrangler.jsonc
93
+ staff invite-owner the first Owner's one-time invite (only while no active Owner exists)
94
+ staff recover reset a sole Owner's factors and print a reenrol invite
95
+ keys create a storefront and its first publishable key; reads the pepper from
96
+ KEDGE_KEY_PEPPER_V<N> in the environment (N defaults to 1)
97
+
98
+ The staff and keys commands run one audited batch through wrangler d1 execute
99
+ (--local unless --remote) and print the secret once.
100
+
101
+ deploy, preview, seed, staff and keys take --json: one object on stdout when they
102
+ end ({"ok": true, ...}, or {"ok": false, "error"} / {"ok": false, "handoff"}),
103
+ everything else on stderr. Exit 3 means a person must act: the last line of stderr
104
+ starts "for you:". --sign-in asks only a terminal; --answers-stdin reads one line
105
+ per question from a pipe, waiting 30 seconds for each.`;
106
+
107
+ /**
108
+ * The store's origin for the invite link (p0.9 §3.5): `--url`, or with
109
+ * `--remote` the Worker's workers.dev URL. Null when neither is known, and then
110
+ * only the token prints, as before: the link is a convenience.
111
+ */
112
+ async function inviteOrigin(
113
+ args: readonly string[],
114
+ remote: boolean,
115
+ cwd: string,
116
+ env: NodeJS.ProcessEnv,
117
+ cf?: Cloudflare,
118
+ ): Promise<string | null> {
119
+ const url = flag(args, "--url");
120
+ if (url) return new URL(url).origin;
121
+ if (!remote) return null;
122
+ try {
123
+ const worker = storeNames(cwd).names.worker;
124
+ return `https://${worker}.${await (cf ?? (await cloudflareFor(env, cwd))).workersSubdomain()}.workers.dev`;
125
+ } catch {
126
+ return null;
127
+ }
128
+ }
129
+
130
+ async function bootstrap(
131
+ command: string,
132
+ args: string[],
133
+ cwd: string,
134
+ execute: Executor,
135
+ env: NodeJS.ProcessEnv,
136
+ o: { json: boolean; emit?: (result: Record<string, unknown>) => void; cf?: Cloudflare },
137
+ ): Promise<number> {
138
+ const cf = o.cf;
139
+ const result = (r: Record<string, unknown>) => {
140
+ if (o.json) (o.emit ?? emit)(r);
141
+ };
142
+ // A usage error is an end too: under --json it's an object, never an empty stdout (codex code r8).
143
+ const usage = (message: string) => {
144
+ console.error(message);
145
+ result({ ok: false, error: message.split("\n")[0] });
146
+ return 2;
147
+ };
148
+ // Wrangler's own words are on stderr already; the object says only that the batch didn't finish.
149
+ const failed = (status: number) => {
150
+ result({ ok: false, error: `wrangler d1 execute exited ${status}` });
151
+ return status;
152
+ };
153
+ const config = flag(args, "--config");
154
+ const remote = args.includes("--remote");
155
+ // Resolved before any write, as every remote command does (p0.10 §4): no credentials is the
156
+ // handoff, exit 3, never Wrangler's own failure; and the batch goes where the resolver says,
157
+ // salve's broker included (codex code r2).
158
+ const auth = remote ? await resolveCloudflare(env, cwd) : null;
159
+ // A --config of another file is what Wrangler reads, so its account_id must agree too.
160
+ if (auth && config) sameAccount(auth.account, resolve(cwd, config));
161
+ const base = cloudflareBase(env);
162
+ const opts = {
163
+ database: flag(args, "--database") ?? "DB",
164
+ remote,
165
+ cwd,
166
+ ...(config ? { config } : {}),
167
+ ...(auth
168
+ ? {
169
+ env: {
170
+ CLOUDFLARE_API_TOKEN: auth.token(),
171
+ CLOUDFLARE_ACCOUNT_ID: auth.account,
172
+ // Always, the default included: Wrangler would otherwise take one from the store's
173
+ // dotenv files and send this token there (codex code r4).
174
+ CLOUDFLARE_API_BASE_URL: base,
175
+ },
176
+ }
177
+ : {}),
178
+ };
179
+ const now = Date.now();
180
+ const [sub, target] = args;
181
+ if (command === "staff" && (sub === "invite-owner" || sub === "recover") && target && !target.startsWith("--")) {
182
+ const b = sub === "invite-owner" ? await ownerInviteSql(target, now) : await recoverSql(target, now);
183
+ const status = execute(b, opts);
184
+ if (status !== 0) return failed(status);
185
+ const expires = new Date(b.expiresAt).toISOString();
186
+ console.log(`invite ${b.inviteId} for ${target}, valid until ${expires}`);
187
+ console.log(`accept with POST /auth/admin/invites/accept and this token (shown once):\n${b.token}`);
188
+ const origin = await inviteOrigin(args, opts.remote, cwd, env, cf);
189
+ // The token rides in the fragment, which no server log or Referer carries (p0.9 §3.5).
190
+ const link = origin ? `${origin}/app/invite#${b.token}` : null;
191
+ if (link) console.log(`or open the admin's invite page:\n${link}`);
192
+ result({ ok: true, invite: b.inviteId, email: target, expires_at: expires, token: b.token, link });
193
+ return 0;
194
+ }
195
+ if (command === "keys" && sub === "create" && args.includes("--publishable")) {
196
+ const name = flag(args, "--name");
197
+ if (!name) return usage("keys create needs --name");
198
+ const origins = args.flatMap((a, i) => (a === "--origin" && args[i + 1] ? [args[i + 1] as string] : []));
199
+ const pepperVersion = Number(flag(args, "--pepper-version") ?? "1");
200
+ const pepper = env[`KEDGE_KEY_PEPPER_V${pepperVersion}`];
201
+ if (!Number.isSafeInteger(pepperVersion) || pepperVersion < 1)
202
+ return usage("--pepper-version takes a whole number from 1");
203
+ // The pepper lives in the merchant's own vault, or only in the Worker: theirs to hand over (p0.10 §5).
204
+ if (!pepper)
205
+ throw new NeedsPerson(
206
+ `set KEDGE_KEY_PEPPER_V${pepperVersion} to the Worker's pepper secret, or create the key with --url and --sign-in, which keeps the pepper in the Worker`,
207
+ );
208
+ const b = await publishableKeySql({ name, origins, pepperVersion, pepper }, now);
209
+ const status = execute(b, opts);
210
+ if (status !== 0) return failed(status);
211
+ console.log(`storefront ${b.storefrontId}, key ${b.keyId}`);
212
+ console.log(`publishable key (send as x-kedge-key; shown once):\n${b.key}`);
213
+ result({ ok: true, storefront: b.storefrontId, key_id: b.keyId, key: b.key });
214
+ return 0;
215
+ }
216
+ return usage(USAGE);
217
+ }
218
+
219
+ const INDEX_START = "<!-- kedge:docs-index:start -->";
220
+ const INDEX_END = "<!-- kedge:docs-index:end -->";
221
+
222
+ /**
223
+ * AGENTS.md's docs index (§9.3): a compressed list of the installed
224
+ * @kedge/docs, always in context, between markers so the rest of the file is
225
+ * the store's. Null when there's no AGENTS.md with the markers or no docs.
226
+ */
227
+ function agentsIndex(cwd: string): { path: string; text: string } | null {
228
+ const path = join(cwd, "AGENTS.md");
229
+ const docs = installedPackages(cwd).get("@kedge/docs");
230
+ if (!existsSync(path) || !docs) return null;
231
+ const current = readFileSync(path, "utf8");
232
+ const a = current.indexOf(INDEX_START);
233
+ const b = current.indexOf(INDEX_END);
234
+ if (a < 0 || b < a) return null;
235
+ const version = (JSON.parse(readFileSync(join(docs, "package.json"), "utf8")) as { version: string }).version;
236
+ const index = JSON.parse(readFileSync(join(docs, "index.json"), "utf8")) as {
237
+ entries: { path: string; title: string; summary: string }[];
238
+ };
239
+ const lines = [
240
+ `Kedge ${version}. Read the guide before writing that kind of code; they match what's installed.`,
241
+ ...index.entries.map((e) => `- [${e.title}](node_modules/@kedge/docs/${e.path}): ${e.summary}`),
242
+ ];
243
+ return { path, text: `${current.slice(0, a + INDEX_START.length)}\n${lines.join("\n")}\n${current.slice(b)}` };
244
+ }
245
+
246
+ /**
247
+ * Skills ship in @kedge/docs and are copied, gitignored, so they always match
248
+ * what's installed (§9.3): into `.claude/skills` for Claude Code, and into
249
+ * `.agents/skills`, where salve and other agents read them (p0.10 §2, §6.4).
250
+ */
251
+ function copySkills(cwd: string): string[] {
252
+ const docs = installedPackages(cwd).get("@kedge/docs");
253
+ if (!docs || !existsSync(join(docs, "skills")) || !existsSync(join(cwd, ".claude"))) return [];
254
+ const out: string[] = [];
255
+ for (const s of readdirSync(join(docs, "skills"))) {
256
+ for (const home of [".claude", ".agents"])
257
+ cpSync(join(docs, "skills", s), join(cwd, home, "skills", s), { recursive: true });
258
+ out.push(s);
259
+ }
260
+ return out;
261
+ }
262
+
263
+ /** What generate writes beside the migrations: INVARIANTS.md, invariants.json and AGENTS.md's index. */
264
+ function generatedFiles(cwd: string, lock: Lock, config: KedgeConfig): { path: string; text: string }[] {
265
+ const schema = schemaOf(composedTexts(cwd, lock));
266
+ const inv = invariantsOf(
267
+ schema,
268
+ (config.plugins ?? []).map((p) => ({ name: p.definition.name, errors: p.definition.errors ?? {} })),
269
+ );
270
+ const index = agentsIndex(cwd);
271
+ return [
272
+ { path: join(cwd, "INVARIANTS.md"), text: invariantsMarkdown(inv) },
273
+ { path: join(cwd, "invariants.json"), text: `${JSON.stringify(inv, null, 2)}\n` },
274
+ ...(index ? [index] : []),
275
+ ];
276
+ }
277
+
278
+ /** @kedge/db's migrations, resolved from this package so the CLI and the runtime agree on core's version. */
279
+ function coreMigrationsDir(): string {
280
+ const entry = fileURLToPath(import.meta.resolve("@kedge/db"));
281
+ return join(dirname(entry), "..", "migrations");
282
+ }
283
+
284
+ interface RunDeps {
285
+ execute?: Executor;
286
+ env?: NodeJS.ProcessEnv;
287
+ cf?: Cloudflare;
288
+ /** Where `--json`'s object goes; tests read it. */
289
+ emit?: (result: Record<string, unknown>) => void;
290
+ }
291
+
292
+ /** The commands an agent reads the result of (p0.10 §5); doctor has its own `--json`. */
293
+ const JSON_COMMANDS = new Set(["deploy", "preview", "staff", "keys", "seed", "smoke"]);
294
+
295
+ async function run(argv: string[], cwd: string, deps: RunDeps & { json: boolean }): Promise<number> {
296
+ const [command, ...flags] = argv;
297
+ const out = {
298
+ json: deps.json,
299
+ ...(deps.emit ? { emit: deps.emit } : {}),
300
+ ...(deps.execute ? { execute: deps.execute } : {}),
301
+ };
302
+ if (command === "keys" && flag(flags, "--url")) {
303
+ const d0: CommandDeps = { cwd, env: deps.env ?? process.env, coreDir: coreMigrationsDir(), ...out };
304
+ return keysOverHttp(flags, d0);
305
+ }
306
+ if (command === "staff" || command === "keys")
307
+ return bootstrap(command, flags, cwd, deps.execute ?? wranglerExecute, deps.env ?? process.env, {
308
+ ...out,
309
+ ...(deps.cf ? { cf: deps.cf } : {}),
310
+ });
311
+ const d: CommandDeps = {
312
+ cwd,
313
+ env: deps.env ?? process.env,
314
+ coreDir: coreMigrationsDir(),
315
+ checkFast: () => main(["check", "--fast"], cwd),
316
+ ...out,
317
+ };
318
+ if (command === "deploy") return deployCommand(flags, d);
319
+ if (command === "migrate") return migrateCommand(flags, d);
320
+ if (command === "restore") return restoreCommand(flags, d);
321
+ if (command === "secret") return secretCommand(flags, d);
322
+ if (command === "seed") return seedCommand(flags, d);
323
+ if (command === "smoke") return smokeCommand(flags, d);
324
+ if (command === "preview") return previewCommand(flags, d);
325
+ if (command === "eject") return ejectCommand(flags, d);
326
+ if (command === "upgrade")
327
+ return upgradeCommand(flags, {
328
+ ...d,
329
+ // The store's own CLI after the install: this process is still the release being replaced.
330
+ kedge: async (argv) =>
331
+ spawnSync(join(cwd, "node_modules", ".bin", "kedge"), argv, { cwd, stdio: "inherit" }).status ?? 1,
332
+ });
333
+ if (command === "dev") return devCommand(d);
334
+ if (command === "generate" && flag(flags, "--supersede"))
335
+ return generateSupersede(flag(flags, "--supersede") as string, d);
336
+ if (command === "doctor" && flags.includes("--core-diff")) {
337
+ const { coreDiff, formatCoreDiff } = await import("./fork");
338
+ const { ownManifest } = await import("./manifest");
339
+ const upstream = flag(flags, "--upstream");
340
+ const manifest = upstream ? JSON.parse(readFileSync(upstream, "utf8")) : ownManifest();
341
+ if (!manifest) {
342
+ console.error("--core-diff needs a release's manifest: --upstream <dist-packs/<version>/kedge-manifest.json>");
343
+ return 2;
344
+ }
345
+ const diffs = coreDiff(cwd, manifest);
346
+ console.log(formatCoreDiff(diffs, manifest.version));
347
+ return flags.includes("--strict") && diffs.length ? 1 : 0;
348
+ }
349
+ if (command === "doctor") {
350
+ const results = await doctor({ cwd, coreDir: coreMigrationsDir() });
351
+ if (flags.includes("--remote")) {
352
+ let auth: Awaited<ReturnType<typeof resolveCloudflare>> | null = null;
353
+ try {
354
+ auth = await resolveCloudflare(d.env, cwd);
355
+ } catch (e) {
356
+ // No credentials is a check that failed and a handoff, with the offline report still printed (codex code r8).
357
+ if (!(e instanceof NeedsPerson)) throw e;
358
+ results.push({ name: "credentials", status: "fail", message: e.handoff, handoff: e.handoff });
359
+ }
360
+ if (auth) {
361
+ const cf = restCloudflare(d.env, fetch, wranglerBin(), auth);
362
+ const lock = JSON.parse(readFileSync(join(cwd, "kedge.lock.json"), "utf8")) as Lock;
363
+ const creds = await credentialsChecks({ cf, source: auth.source, base: cloudflareBase(d.env) });
364
+ results.push(...creds);
365
+ // Credentials that can't read the account would make every later check throw, not report (codex code r7).
366
+ if (creds.some((c) => c.status === "fail"))
367
+ results.push({
368
+ name: "remote",
369
+ status: "skip",
370
+ message: "the store's resources weren't read: fix the credentials first",
371
+ });
372
+ else results.push(...(await doctorRemote({ cwd, cf, lock, logpushJobs: () => cf.logpushJobs() })));
373
+ }
374
+ }
375
+ console.log(flags.includes("--json") ? JSON.stringify({ checks: results }, null, 2) : formatReport(results));
376
+ const handoff = results.find((r) => r.handoff)?.handoff;
377
+ if (handoff) {
378
+ console.error(`for you: ${handoff}`);
379
+ return 3;
380
+ }
381
+ return results.some((r) => r.status === "fail") ? 1 : 0;
382
+ }
383
+ if (command === "check")
384
+ return runCheck({
385
+ cwd,
386
+ fast: flags.includes("--fast"),
387
+ generate: (c: string) => main(["generate", "--check"], c),
388
+ doctor: (c: string) => main(["doctor"], c),
389
+ });
390
+ if (command !== "generate") {
391
+ console.error(USAGE);
392
+ return 2;
393
+ }
394
+ const check = flags.includes("--check");
395
+ const configPath = resolve(cwd, "kedge.config.ts");
396
+ const config = ((await import(pathToFileURL(configPath).href)) as { default: KedgeConfig }).default;
397
+ try {
398
+ const result = compose({
399
+ coreDir: coreMigrationsDir(),
400
+ plugins: config.plugins ?? [],
401
+ // A preview's own ports are locked like any other's (p0.8 §5.2).
402
+ ports: [...(config.ports ?? []), ...(config.preview?.ports ?? [])],
403
+ localDir: join(cwd, "local"),
404
+ outDir: join(cwd, "migrations"),
405
+ lockPath: join(cwd, "kedge.lock.json"),
406
+ now: Date.now(),
407
+ dryRun: check,
408
+ ...(config.timezone ? { timezone: config.timezone } : {}),
409
+ compileZone: compileStoreZone,
410
+ tzPath: join(cwd, "src", "kedge.tz.ts"),
411
+ });
412
+ for (const n of result.notes) console.log(`note: ${n}`);
413
+ // P0.6b (§2.8): the Worker's fetch takes the public route, webhooks included.
414
+ const flag = strictlyPublic(cwd);
415
+ if (flag.problem) {
416
+ console.error(flag.problem);
417
+ return 1;
418
+ }
419
+ if (check && (result.written.length > 0 || result.lockChanged)) {
420
+ const files = [...result.written, ...(result.lockChanged ? ["kedge.lock.json"] : [])];
421
+ console.error(`kedge generate would write:\n ${files.join("\n ")}\nRun kedge generate and commit the result.`);
422
+ return 1;
423
+ }
424
+ for (const f of result.written) console.log(f.endsWith(".ts") ? `wrote ${f}` : `wrote migrations/${f}`);
425
+ if (result.written.length === 0) console.log("migrations are up to date");
426
+ if (result.lockChanged && !check) console.log("wrote kedge.lock.json");
427
+ // The invariant catalog, from what's now composed (§4.2).
428
+ const generated = generatedFiles(cwd, result.lock, config);
429
+ const stale = generated.filter((g) => !existsSync(g.path) || readFileSync(g.path, "utf8") !== g.text);
430
+ if (check && stale.length) {
431
+ console.error(
432
+ `kedge generate would write:\n ${stale.map((g) => relative(cwd, g.path)).join("\n ")}\nRun kedge generate and commit the result.`,
433
+ );
434
+ return 1;
435
+ }
436
+ for (const g of stale) {
437
+ writeFileSync(g.path, g.text);
438
+ console.log(`wrote ${relative(cwd, g.path)}`);
439
+ }
440
+ if (!check) copySkills(cwd);
441
+ return 0;
442
+ } catch (e) {
443
+ if (e instanceof ComposeError) {
444
+ console.error(e.message);
445
+ return 1;
446
+ }
447
+ throw e;
448
+ }
449
+ }
450
+
451
+ /**
452
+ * The CLI. A handoff from any command ends it with exit 3 and one sentence for
453
+ * whoever runs it to pass on (p0.10 §5).
454
+ */
455
+ export async function main(argv: string[], cwd: string, deps: RunDeps = {}): Promise<number> {
456
+ const [command, ...flags] = argv;
457
+ const json = flags.includes("--json") && JSON_COMMANDS.has(command ?? "");
458
+ return withJsonOutput(json, async () => {
459
+ try {
460
+ return await run(argv, cwd, { ...deps, json });
461
+ } catch (e) {
462
+ // What escapes a command's own handling (bootstrap, doctor) ends the same way (p0.10 §5).
463
+ const f = failureOf(e);
464
+ if (!f) throw e;
465
+ console.error(f.line);
466
+ if (json) (deps.emit ?? emit)(f.result);
467
+ return f.code;
468
+ }
469
+ });
470
+ }
@@ -0,0 +1,163 @@
1
+ // kedge-manifest.json (docs/proposals/p0.8-cli-agent-kit.md §2): what a release
2
+ // shipped, by package and file, written by scripts/pack-kedge.mjs into
3
+ // @kedge/cli. Doctor compares the store's installed packages, core migrations
4
+ // and ejected units with it. Hashing matches the pack script's, package.json
5
+ // included: its keys are sorted first, because pnpm pack orders the
6
+ // dependencies it rewrites differently from one run to the next.
7
+ import { createHash } from "node:crypto";
8
+ import { existsSync, readdirSync, readFileSync, realpathSync } from "node:fs";
9
+ import { basename, dirname, join, relative } from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+
12
+ export interface Manifest {
13
+ version: string;
14
+ upgradesFrom: string | null;
15
+ doStateFormat: number;
16
+ migrations: Record<string, { sha256: string; mode: "n1" | "cutover" }>;
17
+ units: Record<string, { package: string; path: string; sha256: string }>;
18
+ packages: Record<string, Record<string, string>>;
19
+ }
20
+
21
+ const sha256 = (buf: Buffer | string) => createHash("sha256").update(buf).digest("hex");
22
+ const sorted = (v: unknown): unknown =>
23
+ Array.isArray(v)
24
+ ? v.map(sorted)
25
+ : v && typeof v === "object"
26
+ ? Object.fromEntries(
27
+ Object.keys(v)
28
+ .sort()
29
+ .map((k) => [k, sorted((v as Record<string, unknown>)[k])]),
30
+ )
31
+ : v;
32
+
33
+ export function fileHash(path: string, buf: Buffer): string {
34
+ return path === "package.json" || path.endsWith("/package.json")
35
+ ? sha256(JSON.stringify(sorted(JSON.parse(buf.toString("utf8")))))
36
+ : sha256(buf);
37
+ }
38
+
39
+ /** This CLI's own manifest, or null where there is none (the monorepo's workspace links). */
40
+ export function ownManifest(): Manifest | null {
41
+ const file = join(dirname(fileURLToPath(import.meta.url)), "..", "kedge-manifest.json");
42
+ return existsSync(file) ? (JSON.parse(readFileSync(file, "utf8")) as Manifest) : null;
43
+ }
44
+
45
+ function filesUnder(dir: string): string[] {
46
+ const out: string[] = [];
47
+ const walk = (d: string) => {
48
+ for (const e of readdirSync(d, { withFileTypes: true })) {
49
+ const p = join(d, e.name);
50
+ if (e.isDirectory()) {
51
+ if (e.name !== "node_modules") walk(p);
52
+ } else if (e.isFile()) out.push(relative(dir, p));
53
+ }
54
+ };
55
+ walk(dir);
56
+ return out.sort();
57
+ }
58
+
59
+ export interface InstalledDrift {
60
+ package: string;
61
+ changed: string[];
62
+ added: string[];
63
+ missing: string[];
64
+ }
65
+
66
+ /** Where Node would find `name` from `fromDir`: each ancestor's node_modules, as the resolution algorithm walks. */
67
+ function locate(fromDir: string, name: string): string | null {
68
+ let d = fromDir;
69
+ for (;;) {
70
+ if (basename(d) !== "node_modules") {
71
+ const p = join(d, "node_modules", ...name.split("/"));
72
+ if (existsSync(p)) return realpathSync(p);
73
+ }
74
+ const up = dirname(d);
75
+ if (up === d) return null;
76
+ d = up;
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Every `@kedge/*` package the store loads, found as Node finds it: the
82
+ * store's own dependencies, then theirs. pnpm keeps a transitive package out
83
+ * of the store's node_modules (core, db and server sit beside runtime-cf in
84
+ * its virtual store), and an edit there would run just the same.
85
+ */
86
+ export function installedPackages(cwd: string): Map<string, string> {
87
+ const found = new Map<string, string>();
88
+ const queue: [string, string][] = [];
89
+ const pj = join(cwd, "package.json");
90
+ const own = existsSync(pj) ? (JSON.parse(readFileSync(pj, "utf8")) as Record<string, Record<string, string>>) : {};
91
+ for (const name of Object.keys({ ...own.dependencies, ...own.devDependencies }))
92
+ if (name.startsWith("@kedge/")) queue.push([cwd, name]);
93
+ while (queue.length) {
94
+ const [from, name] = queue.pop() as [string, string];
95
+ if (found.has(name)) continue;
96
+ const dir = locate(from, name);
97
+ if (!dir) continue;
98
+ found.set(name, dir);
99
+ const deps = (
100
+ JSON.parse(readFileSync(join(dir, "package.json"), "utf8")) as { dependencies?: Record<string, string> }
101
+ ).dependencies;
102
+ for (const d of Object.keys(deps ?? {})) if (d.startsWith("@kedge/")) queue.push([dir, d]);
103
+ }
104
+ return found;
105
+ }
106
+
107
+ /**
108
+ * Every installed copy of every `@kedge/*` package the store can load, by directory: two dependents
109
+ * can resolve the same name to different copies, and checking only the first would leave the one the
110
+ * runtime loads unchecked (codex code r13).
111
+ */
112
+ export function installedCopies(cwd: string): { name: string; dir: string }[] {
113
+ const seen = new Map<string, string>();
114
+ const queue: [string, string][] = [];
115
+ const pj = join(cwd, "package.json");
116
+ const own = existsSync(pj) ? (JSON.parse(readFileSync(pj, "utf8")) as Record<string, Record<string, string>>) : {};
117
+ for (const name of Object.keys({ ...own.dependencies, ...own.devDependencies }))
118
+ if (name.startsWith("@kedge/")) queue.push([cwd, name]);
119
+ while (queue.length) {
120
+ const [from, name] = queue.pop() as [string, string];
121
+ const dir = locate(from, name);
122
+ if (!dir || seen.has(dir)) continue;
123
+ seen.set(dir, name);
124
+ const deps = (
125
+ JSON.parse(readFileSync(join(dir, "package.json"), "utf8")) as { dependencies?: Record<string, string> }
126
+ ).dependencies;
127
+ for (const d of Object.keys(deps ?? {})) if (d.startsWith("@kedge/")) queue.push([dir, d]);
128
+ }
129
+ return [...seen].map(([dir, name]) => ({ name, dir }));
130
+ }
131
+
132
+ /** Each installed copy of each `@kedge/*` package, its files against what the release shipped. */
133
+ export function installedDrift(cwd: string, manifest: Manifest): InstalledDrift[] {
134
+ const out: InstalledDrift[] = [];
135
+ const copies = installedCopies(cwd);
136
+ const count = (n: string) => copies.filter((c) => c.name === n).length;
137
+ for (const { name: pkg, dir } of copies) {
138
+ const name = count(pkg) > 1 ? `${pkg} (${relative(cwd, dir)})` : pkg;
139
+ const files = manifest.packages[pkg];
140
+ if (!files) {
141
+ out.push({
142
+ package: name,
143
+ changed: [],
144
+ added: [`(the whole package: Kedge ${manifest.version} didn't ship it)`],
145
+ missing: [],
146
+ });
147
+ continue;
148
+ }
149
+ const have = filesUnder(dir);
150
+ const d: InstalledDrift = { package: name, changed: [], added: [], missing: [] };
151
+ for (const f of have) {
152
+ const want = files[f];
153
+ if (want === undefined) {
154
+ // The CLI carries the manifest that lists every other file.
155
+ if (!(pkg === "@kedge/cli" && f === "kedge-manifest.json")) d.added.push(f);
156
+ } else if (fileHash(f, readFileSync(join(dir, f))) !== want) d.changed.push(f);
157
+ }
158
+ const haveSet = new Set(have);
159
+ for (const f of Object.keys(files)) if (!haveSet.has(f)) d.missing.push(f);
160
+ if (d.changed.length || d.added.length || d.missing.length) out.push(d);
161
+ }
162
+ return out;
163
+ }