@kedge/cli 0.9.0 → 0.10.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.
package/src/commands.ts CHANGED
@@ -6,11 +6,24 @@
6
6
  // acting on production.
7
7
  import { spawnSync } from "node:child_process";
8
8
  import { existsSync, readdirSync, readFileSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
9
+ import { createServer } from "node:net";
9
10
  import { join, resolve } from "node:path";
10
11
  import { pathToFileURL } from "node:url";
11
- import type { KedgeConfig } from "@kedge/core";
12
- import { type Cloudflare, restCloudflare, wranglerBin } from "./cf";
12
+ import { isPermission, type KedgeConfig } from "@kedge/core";
13
+ import {
14
+ type Cloudflare,
15
+ CloudflareError,
16
+ cloudflareFor,
17
+ isJurisdictionChoice,
18
+ type JurisdictionChoice,
19
+ NeedsPerson,
20
+ preflight,
21
+ UnknownOutcome,
22
+ wranglerBin,
23
+ } from "./cf";
24
+ import { isCliSession, OLDER_KEDGE, type SessionDeps, withCliSession } from "./cli-session";
13
25
  import { ComposeError, compose, type Lock } from "./compose";
26
+ import { type Executor, wranglerExecute } from "./d1-batch";
14
27
  import {
15
28
  adminBuildIn,
16
29
  appliedNames,
@@ -22,9 +35,14 @@ import {
22
35
  uncertainMigrations,
23
36
  } from "./deploy";
24
37
  import { readWrangler } from "./doctor";
38
+ import { badOrigins, ENV, keysSave, type Wanted } from "./keys-save";
25
39
  import { installedPackages, ownManifest } from "./manifest";
26
- import { OperationHeld } from "./ops";
40
+ import { LostOwnership, OperationHeld } from "./ops";
41
+ import { childStdio, emit } from "./output";
42
+ import { needsLive, smoke } from "./smoke";
27
43
  import { git, storeNames } from "./store";
44
+ import { storeRequest } from "./store-api";
45
+ import { devPort, resolveTarget, type StoreTarget } from "./target";
28
46
  import { compileStoreZone } from "./tz";
29
47
 
30
48
  export interface CommandDeps {
@@ -38,18 +56,49 @@ export interface CommandDeps {
38
56
  error?: (s: string) => void;
39
57
  /** Asks the person at the terminal; `hidden` doesn't echo (a password). Tests answer it. */
40
58
  ask?: (question: string, hidden: boolean) => Promise<string>;
59
+ /** Writes a CLI session's row to `kedge dev`'s database; tests replace it. */
60
+ execute?: Executor;
61
+ /** The sign-in's stdin; tests give a stream. */
62
+ stdin?: Stdin;
63
+ /** `--json`: the command's result as one object on stdout (p0.10 §5). */
64
+ json?: boolean;
65
+ /** Where that object goes; tests read it. */
66
+ emit?: (result: Record<string, unknown>) => void;
41
67
  }
42
68
 
69
+ /** How long `--answers-stdin` waits for each answer (p0.10 §5). */
70
+ export const ANSWER_WAIT_MS = 30_000;
71
+
72
+ type Stdin = NodeJS.ReadableStream & { isTTY?: boolean };
73
+
43
74
  /**
44
75
  * The terminal's answers, one reader for every question of a command: a second
45
- * readline would lose what the first had buffered, so piped answers (an agent
46
- * writing the password and code to stdin) would reach only the first question.
47
- * A hidden answer isn't echoed, so a password stays off the screen.
76
+ * readline would lose what the first had buffered, so piped answers would reach
77
+ * only the first question. A hidden answer isn't echoed, so a password stays
78
+ * off the screen.
79
+ *
80
+ * Only a terminal is asked unless `--answers-stdin` says otherwise, and that's
81
+ * decided before anything is sent (p0.10 §5): an agent's tool call can hold
82
+ * stdin open and write nothing, and a read would then wait forever. Piped
83
+ * answers wait at most `ANSWER_WAIT_MS` each, as `docker login --password-stdin`
84
+ * is the explicit way in.
48
85
  */
49
- export async function terminalAsker(): Promise<{ ask: NonNullable<CommandDeps["ask"]>; close: () => void }> {
86
+ export async function terminalAsker(o: {
87
+ answersStdin: boolean;
88
+ input?: Stdin;
89
+ output?: NodeJS.WritableStream;
90
+ waitMs?: number;
91
+ }): Promise<{ ask: NonNullable<CommandDeps["ask"]>; close: () => void }> {
92
+ const input = o.input ?? process.stdin;
93
+ // Questions go to stderr, as git's and ssh's do: under --json stdout is the result's alone.
94
+ const output = o.output ?? process.stderr;
95
+ const tty = Boolean(input.isTTY);
96
+ if (!tty && !o.answersStdin)
97
+ throw new NeedsPerson(
98
+ "signing in asks for your password and authenticator code: run this command in your own terminal",
99
+ );
50
100
  const { createInterface } = await import("node:readline");
51
- const tty = Boolean(process.stdin.isTTY);
52
- const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: tty });
101
+ const rl = createInterface({ input, output, terminal: tty });
53
102
  const out = rl as unknown as { _writeToOutput: (s: string) => void; output: NodeJS.WriteStream };
54
103
  let muted = false;
55
104
  const write = out._writeToOutput.bind(rl);
@@ -57,16 +106,27 @@ export async function terminalAsker(): Promise<{ ask: NonNullable<CommandDeps["a
57
106
  if (!muted) write(s);
58
107
  };
59
108
  const lines = rl[Symbol.asyncIterator]();
109
+ const waitMs = o.waitMs ?? ANSWER_WAIT_MS;
60
110
  return {
61
111
  async ask(question, hidden) {
62
- process.stdout.write(question);
112
+ output.write(question);
63
113
  muted = hidden && tty;
114
+ let timer: NodeJS.Timeout | undefined;
64
115
  try {
65
- const next = await lines.next();
116
+ // A person at a terminal takes the time they need; a pipe that sends nothing is an end.
117
+ const silence = new Promise<never>((_, reject) => {
118
+ if (!tty)
119
+ timer = setTimeout(
120
+ () => reject(new DeployRefused(`no answer on stdin within ${waitMs / 1000} seconds`)),
121
+ waitMs,
122
+ );
123
+ });
124
+ const next = await Promise.race([lines.next(), silence]);
66
125
  if (next.done) throw new DeployRefused("no answer on stdin");
67
126
  return next.value;
68
127
  } finally {
69
- if (muted) process.stdout.write("\n");
128
+ clearTimeout(timer);
129
+ if (muted) output.write("\n");
70
130
  muted = false;
71
131
  }
72
132
  },
@@ -124,10 +184,13 @@ export function bundleWithWrangler(cwd: string) {
124
184
  };
125
185
  }
126
186
 
127
- async function deployer(d: CommandDeps): Promise<Deployer> {
187
+ /** The Cloudflare a command acts on: the given one (tests), else whatever source resolves. */
188
+ const cloudflareOf = async (d: CommandDeps) => d.cf ?? (await cloudflareFor(d.env, d.cwd, d.fetch));
189
+
190
+ async function deployer(d: CommandDeps, cf?: Cloudflare): Promise<Deployer> {
128
191
  const config = await loadConfig(d.cwd);
129
192
  return new Deployer({
130
- cf: d.cf ?? restCloudflare(d.env, d.fetch),
193
+ cf: cf ?? (await cloudflareOf(d)),
131
194
  cwd: d.cwd,
132
195
  manifest: ownManifest(),
133
196
  lock: lockOf(d.cwd),
@@ -140,26 +203,57 @@ async function deployer(d: CommandDeps): Promise<Deployer> {
140
203
  });
141
204
  }
142
205
 
143
- /** Turns a refusal into its message and exit code; anything else is a bug and propagates. */
144
- async function refusals(d: CommandDeps, work: () => Promise<void>): Promise<number> {
206
+ /**
207
+ * Turns a refusal into its message and exit code; anything else is a bug and
208
+ * propagates. Under `--json` the outcome is also one object on stdout: what the
209
+ * command made, its handoff, or its refusal (p0.10 §5).
210
+ */
211
+ async function refusals(d: CommandDeps, work: () => Promise<unknown>): Promise<number> {
212
+ const result = (r: Record<string, unknown>) => {
213
+ if (d.json) (d.emit ?? emit)(r);
214
+ };
145
215
  try {
146
- await work();
216
+ // What the command made, when it returns it; a void command's object is only `ok`.
217
+ const made = await work();
218
+ result({ ok: true, ...(made && typeof made === "object" ? made : {}) });
147
219
  return 0;
148
220
  } catch (e) {
149
- if (e instanceof DeployRefused || e instanceof OperationHeld || e instanceof ComposeError) {
150
- (d.error ?? console.error)(e.message);
151
- return 1;
152
- }
153
- throw e;
221
+ const f = failureOf(e);
222
+ if (!f) throw e;
223
+ (d.error ?? console.error)(f.line);
224
+ result(f.result);
225
+ return f.code;
154
226
  }
155
227
  }
156
228
 
229
+ /**
230
+ * How a command that didn't succeed ends, for a person and under `--json`
231
+ * (p0.10 §5): a handoff (exit 3, the last line `for you:`), or a refusal,
232
+ * Cloudflare refusing, or Cloudflare not answering (exit 1). An unanswered
233
+ * call says its outcome is unknown: the operation stays held, for
234
+ * `deploy --resume` or `doctor --remote` to settle. Null is a bug, which propagates.
235
+ */
236
+ export function failureOf(e: unknown): { code: 1 | 3; line: string; result: Record<string, unknown> } | null {
237
+ if (e instanceof NeedsPerson)
238
+ return { code: 3, line: `for you: ${e.handoff}`, result: { ok: false, handoff: e.handoff } };
239
+ if (e instanceof UnknownOutcome)
240
+ return { code: 1, line: e.message, result: { ok: false, error: e.message, outcome: "unknown" } };
241
+ if (
242
+ e instanceof DeployRefused ||
243
+ e instanceof OperationHeld ||
244
+ e instanceof LostOwnership ||
245
+ e instanceof ComposeError ||
246
+ e instanceof CloudflareError
247
+ )
248
+ return { code: 1, line: e.message, result: { ok: false, error: e.message } };
249
+ return null;
250
+ }
251
+
157
252
  export async function deployCommand(args: readonly string[], d: CommandDeps): Promise<number> {
158
253
  return refusals(d, async () => {
159
- const dep = await deployer(d);
160
- (d.log ?? console.log)(
161
- `deploying ${dep.names.worker} on account ${(d.cf ?? restCloudflare(d.env, d.fetch)).account}`,
162
- );
254
+ const cf = await cloudflareOf(d);
255
+ const dep = await deployer(d, cf);
256
+ (d.log ?? console.log)(`deploying ${dep.names.worker} on account ${cf.account}`);
163
257
  const resolve = flag(args, "--resolve-intent");
164
258
  if (resolve) return dep.resolveIntent(resolve, args.includes("--not-executed"));
165
259
  if (args.includes("--record-active")) return dep.recordActive(flag(args, "--reason") ?? "");
@@ -172,11 +266,23 @@ export async function deployCommand(args: readonly string[], d: CommandDeps): Pr
172
266
  throw new DeployRefused(
173
267
  "--adopt-existing needs --from-release <version>: the release serving now, by your word",
174
268
  );
175
- return dep.adoptExisting(release, (f) => readFileSync(join(d.cwd, "migrations", f), "utf8"));
269
+ // Adoption takes the store's jurisdiction from what exists, so it asks nothing; a choice is checked against it.
270
+ const choice = flag(args, "--jurisdiction");
271
+ await preflight(cf);
272
+ if (choice !== undefined && !isJurisdictionChoice(choice))
273
+ throw new DeployRefused(`--jurisdiction takes eu, us, fedramp or none, not "${choice}"`);
274
+ return dep.adoptExisting(
275
+ release,
276
+ (f) => readFileSync(join(d.cwd, "migrations", f), "utf8"),
277
+ choice as JurisdictionChoice | undefined,
278
+ );
176
279
  }
177
280
  const window = windowOf(flag(args, "--maintenance-window"));
178
281
  const takeOver = flag(args, "--take-over");
282
+ const choice = flag(args, "--jurisdiction");
283
+ await preflight(cf, dep.names.ops, choice);
179
284
  await dep.deploy({
285
+ ...(choice ? { jurisdiction: choice as JurisdictionChoice } : {}),
180
286
  vars: varsOf(args),
181
287
  adopt: flagsOf(args, "--adopt"),
182
288
  allowOlder: args.includes("--allow-older"),
@@ -185,18 +291,20 @@ export async function deployCommand(args: readonly string[], d: CommandDeps): Pr
185
291
  ...(window ? { window } : {}),
186
292
  ...(takeOver ? { takeOver } : {}),
187
293
  });
294
+ // From the account's subdomain now, never a recorded URL: its owner can change it (p0.10 §4).
295
+ const url = `https://${dep.names.worker}.${await cf.workersSubdomain()}.workers.dev`;
296
+ return { worker: dep.names.worker, account: cf.account, url, admin: adminUrl(d.cwd, url) };
188
297
  });
189
298
  }
190
299
 
300
+ /** The admin's address, when the store serves it (p0.9 §3.3); null when it serves none. */
301
+ function adminUrl(cwd: string, url: string): string | null {
302
+ return readWrangler(cwd)?.config.assets ? `${url}/app/` : null;
303
+ }
304
+
191
305
  export async function migrateCommand(args: readonly string[], d: CommandDeps): Promise<number> {
192
- if (!args.includes("--remote")) {
193
- // Local: the dev database, through Wrangler's own bookkeeping.
194
- const r = spawnSync(process.execPath, [wranglerBin(), "d1", "migrations", "apply", "DB", "--local"], {
195
- cwd: d.cwd,
196
- stdio: "inherit",
197
- });
198
- return r.status ?? 1;
199
- }
306
+ // Local: the dev database, through Wrangler's own bookkeeping.
307
+ if (!args.includes("--remote")) return migrateLocal(d);
200
308
  return refusals(d, async () => (await deployer(d)).migrateRemote());
201
309
  }
202
310
 
@@ -207,7 +315,7 @@ export async function migrateCommand(args: readonly string[], d: CommandDeps): P
207
315
  */
208
316
  export async function generateSupersede(opId: string, d: CommandDeps): Promise<number> {
209
317
  return refusals(d, async () => {
210
- const cf = d.cf ?? restCloudflare(d.env, d.fetch);
318
+ const cf = await cloudflareOf(d);
211
319
  const names = storeNames(d.cwd).names;
212
320
  const { ops } = await openOps(cf, names);
213
321
  const held = await ops.current(SCOPE);
@@ -260,8 +368,11 @@ export async function restoreCommand(args: readonly string[], d: CommandDeps): P
260
368
  return 1;
261
369
  }
262
370
  return refusals(d, async () => {
263
- const cf = d.cf ?? restCloudflare(d.env, d.fetch);
264
- const { ops } = await openOps(cf, storeNames(d.cwd).names);
371
+ const cf = await cloudflareOf(d);
372
+ const names = storeNames(d.cwd).names;
373
+ // A lookup, never a create: no ops database is no preview, and seeding writes nothing to Cloudflare.
374
+ if (!(await cf.findD1(names.ops))) throw new DeployRefused("no ops database");
375
+ const { ops } = await openOps(cf, names);
265
376
  const log = d.log ?? console.log;
266
377
  for (const r of await ops.deployments()) {
267
378
  const before = JSON.parse(r.before_half) as {
@@ -325,24 +436,16 @@ const SAMPLE_APPAREL = [
325
436
  },
326
437
  ] as const;
327
438
 
328
- /** Local, or a preview this store's ops knows; anything else only with --live (§3). */
329
- async function seedTarget(url: string, args: readonly string[], d: CommandDeps): Promise<string | null> {
330
- const host = new URL(url).hostname;
331
- if (host === "localhost" || host === "127.0.0.1" || host === "[::1]") return null;
332
- if (args.includes("--live")) return null;
333
- try {
334
- const cf = d.cf ?? restCloudflare(d.env, d.fetch);
335
- const { ops } = await openOps(cf, storeNames(d.cwd).names);
336
- // The whole origin, as a preview's is made: a host that only begins with a preview's name isn't it (codex code r17).
337
- const origin = new URL(url).origin;
338
- const subdomain = await cf.workersSubdomain();
339
- const rows = await ops.query("SELECT worker FROM preview WHERE state = 'bootstrapped'");
340
- if (rows.some((r) => origin === `https://${String(r.worker)}.${subdomain}.workers.dev`)) return null;
341
- } catch {
342
- // no ops database reachable: not a known preview
343
- }
344
- return `${url} isn't local or a preview this store knows: seeding a live store needs --live, someone's choice`;
345
- }
439
+ const isLocal = (url: string) => ["localhost", "127.0.0.1", "[::1]"].includes(new URL(url).hostname);
440
+
441
+ /** The store a URL is, for a CLI session or a check: what `resolveTarget` says, credentials resolved as for any remote command. */
442
+ const targetOf = (url: string, d: CommandDeps) => resolveTarget(url, d.cwd, () => cloudflareOf(d));
443
+
444
+ /** How a CLI session's row is written (p0.10 §6.2). */
445
+ const sessionDeps = (d: CommandDeps): SessionDeps => ({
446
+ cloudflare: () => cloudflareOf(d),
447
+ executeLocal: (b) => (d.execute ?? wranglerExecute)(b, { database: "DB", remote: false, cwd: d.cwd }),
448
+ });
346
449
 
347
450
  export async function seed(
348
451
  base: string,
@@ -352,7 +455,7 @@ export async function seed(
352
455
  ): Promise<string[]> {
353
456
  const variants: string[] = [];
354
457
  for (const p of SAMPLE_APPAREL) {
355
- const res = await fetchImpl(new URL("/admin/products", base), {
458
+ const res = await storeRequest(fetchImpl, new URL("/admin/products", base), {
356
459
  method: "POST",
357
460
  headers: { ...auth, "content-type": "application/json", "idempotency-key": `kedge-seed-${p.handle}` },
358
461
  body: JSON.stringify({
@@ -366,8 +469,9 @@ export async function seed(
366
469
  })),
367
470
  }),
368
471
  });
369
- const body = (await res.json()) as { variantIds?: string[]; error?: { code?: string } };
472
+ const body = res.body as { variantIds?: string[]; error?: { code?: string } };
370
473
  if (res.status === 409 && body.error?.code === "CONFLICT") continue; // already seeded: the handle exists
474
+ if (res.status === 401 && isCliSession(auth)) throw new DeployRefused(OLDER_KEDGE);
371
475
  if (!res.ok) throw new DeployRefused(`seeding ${p.handle} failed: ${res.status} ${body.error?.code ?? ""}`);
372
476
  variants.push(...(body.variantIds ?? []));
373
477
  log(`seeded ${p.title}`);
@@ -375,10 +479,16 @@ export async function seed(
375
479
  return variants;
376
480
  }
377
481
 
378
- function authOf(args: readonly string[]): Record<string, string> {
482
+ /** `--key` or `--session`, when given. */
483
+ function givenAuth(args: readonly string[]): Record<string, string> | null {
379
484
  const token = flag(args, "--key") ?? flag(args, "--session");
380
- if (!token) throw new DeployRefused("give --key <sk_…> or --session <token>");
381
- return { authorization: `Bearer ${token}` };
485
+ return token ? { authorization: `Bearer ${token}` } : null;
486
+ }
487
+
488
+ function authOf(args: readonly string[]): Record<string, string> {
489
+ const auth = givenAuth(args);
490
+ if (!auth) throw new DeployRefused("give --key <sk_…> or --session <token>");
491
+ return auth;
382
492
  }
383
493
 
384
494
  /** Ends a session the CLI made; a failure leaves it to expire, as it would anyway. */
@@ -395,9 +505,9 @@ async function signOutBearer(url: string, token: string, d: CommandDeps): Promis
395
505
  * cookie they can't hand to `--session`. It sends the store's own origin, which
396
506
  * the Worker counts as an admin origin (§4.1). The session serves one command.
397
507
  */
398
- async function signInBearer(url: string, email: string, d: CommandDeps): Promise<string> {
508
+ async function signInBearer(url: string, email: string, d: CommandDeps, answersStdin: boolean): Promise<string> {
399
509
  const f = d.fetch ?? fetch;
400
- const terminal = d.ask ? null : await terminalAsker();
510
+ const terminal = d.ask ? null : await terminalAsker({ answersStdin, ...(d.stdin ? { input: d.stdin } : {}) });
401
511
  try {
402
512
  return await ceremonies(url, email, d, f, d.ask ?? (terminal as NonNullable<typeof terminal>).ask);
403
513
  } finally {
@@ -419,13 +529,12 @@ async function ceremonies(
419
529
  "content-type": "application/json",
420
530
  };
421
531
  const post = async (path: string, body: unknown) => {
422
- const r = await f(new URL(path, url), { method: "POST", headers, body: JSON.stringify(body) });
423
- const j = (await r.json().catch(() => ({}))) as Record<string, unknown>;
532
+ const r = await storeRequest(f, new URL(path, url), { method: "POST", headers, body: JSON.stringify(body) });
424
533
  if (!r.ok)
425
534
  throw new DeployRefused(
426
- `${path} answered ${r.status} ${String((j.error as { code?: string } | undefined)?.code ?? "")}`,
535
+ `${path} answered ${r.status} ${String((r.body.error as { code?: string } | undefined)?.code ?? "")}`,
427
536
  );
428
- return j;
537
+ return r.body;
429
538
  };
430
539
  const first = await post("/auth/admin/sign-in", { email, password: await ask(`password for ${email}: `, true) });
431
540
  const challenge = (first.challenge as { token?: string } | undefined)?.token;
@@ -456,9 +565,46 @@ export async function seedCommand(args: readonly string[], d: CommandDeps): Prom
456
565
  throw new DeployRefused("kedge seed --sample apparel --url <url> --key <sk_…>");
457
566
  const url = flag(args, "--url");
458
567
  if (!url) throw new DeployRefused("kedge seed needs --url");
459
- const refused = await seedTarget(url, args, d);
460
- if (refused) throw new DeployRefused(refused);
461
- await seed(url, authOf(args), d.fetch ?? fetch, d.log ?? console.log);
568
+ // Local, or a preview this store's ops knows by its whole origin (codex code r17); a live store's
569
+ // customers would see sample products, so it's the owner's call (p0.10 §5).
570
+ let target: StoreTarget | null = null;
571
+ if (!isLocal(url) && !args.includes("--live")) {
572
+ target = await targetOf(url, d);
573
+ if (target?.kind !== "preview")
574
+ throw new NeedsPerson(
575
+ `${url} isn't local or a preview this store knows: sample products on a live store are for its owner to allow; with their yes, run this again with --live`,
576
+ );
577
+ }
578
+ const run = (auth: Record<string, string>) => seed(url, auth, d.fetch ?? fetch, d.log ?? console.log);
579
+ const given = givenAuth(args);
580
+ if (given) return { variants: await run(given) };
581
+ // No key or session given: a CLI session on the store the URL is, and only there (p0.10 §6.2).
582
+ target ??= await targetOf(url, d);
583
+ if (!target)
584
+ throw new DeployRefused(
585
+ `${url} isn't a store this checkout knows, so it gets no CLI session: give --key <sk_…> or --session <token>`,
586
+ );
587
+ return { variants: await withCliSession(target, ["product:write"], sessionDeps(d), run) };
588
+ });
589
+ }
590
+
591
+ /** `kedge smoke` (p0.10 §6.3): a named test order, resumed until it has its number. */
592
+ export async function smokeCommand(args: readonly string[], d: CommandDeps): Promise<number> {
593
+ return refusals(d, async () => {
594
+ const url = flag(args, "--url");
595
+ if (!url) throw new DeployRefused("kedge smoke --url <store> [--run <name>] [--live]");
596
+ const name = flag(args, "--run") ?? "first";
597
+ if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(name))
598
+ throw new DeployRefused("--run takes a name of letters, digits, - and _, up to 64");
599
+ const target = await targetOf(url, d);
600
+ if (!target) throw new DeployRefused(`${url} isn't a store this checkout knows: smoke runs only on its own stores`);
601
+ needsLive(target, args.includes("--live"));
602
+ return smoke(target, name, {
603
+ cwd: d.cwd,
604
+ fetch: d.fetch ?? fetch,
605
+ session: sessionDeps(d),
606
+ log: d.log ?? console.log,
607
+ });
462
608
  });
463
609
  }
464
610
 
@@ -466,9 +612,10 @@ export async function seedCommand(args: readonly string[], d: CommandDeps): Prom
466
612
  export async function keysOverHttp(args: readonly string[], d: CommandDeps): Promise<number> {
467
613
  return refusals(d, async () => {
468
614
  const url = flag(args, "--url") as string;
615
+ if (args.includes("--save")) return saveKeys(url, args, d);
469
616
  const email = flag(args, "--sign-in");
470
617
  if (email) {
471
- const token = await signInBearer(url, email, d);
618
+ const token = await signInBearer(url, email, d, args.includes("--answers-stdin"));
472
619
  try {
473
620
  return await createKeys(url, args, { authorization: `Bearer ${token}` }, d);
474
621
  } finally {
@@ -481,11 +628,49 @@ export async function keysOverHttp(args: readonly string[], d: CommandDeps): Pro
481
628
  });
482
629
  }
483
630
 
631
+ /**
632
+ * `keys create --save` (p0.10 §6.2): through a credential reference with a
633
+ * CLI session, saved to `.env.local`. What it answers names what was saved,
634
+ * never the key: the key stays in the file, out of the agent's transcript.
635
+ */
636
+ async function saveKeys(url: string, args: readonly string[], d: CommandDeps) {
637
+ if (flag(args, "--sign-in") || givenAuth(args))
638
+ throw new DeployRefused("--save makes its own CLI session: drop --sign-in, --key and --session");
639
+ const target = await targetOf(url, d);
640
+ if (!target)
641
+ throw new DeployRefused(`${url} isn't a store this checkout knows, so it gets no CLI session and no saved key`);
642
+ const name = flag(args, "--name") ?? "Storefront";
643
+ const wanted: Wanted = args.includes("--secret")
644
+ ? { kind: "secret", name, grants: flagsOf(args, "--grant") }
645
+ : { kind: "publishable", name, origins: flagsOf(args, "--origin") };
646
+ if (wanted.kind === "secret" && wanted.grants.length === 0)
647
+ throw new DeployRefused("a secret key needs at least one --grant <permission>");
648
+ // Origins as the store accepts them, checked before anything is reused or written (codex code r16).
649
+ const wrong = wanted.kind === "publishable" ? badOrigins(wanted.origins) : [];
650
+ if (wrong.length)
651
+ throw new DeployRefused(
652
+ `not origins: ${wrong.join(", ")} (an origin is a scheme, host and optional port, like https://shop.example)`,
653
+ );
654
+ // Checked here, so a typo is one refusal under --json, before any session exists (codex code r15).
655
+ const bad = wanted.kind === "secret" ? wanted.grants.filter((g) => !isPermission(g)) : [];
656
+ if (bad.length) throw new DeployRefused(`not permissions: ${bad.join(", ")} (a permission is resource:action)`);
657
+ // The session that makes it holds the key's grants and api_key:write, and a session holds 16 at most.
658
+ if (wanted.kind === "secret" && new Set(wanted.grants).size > 15)
659
+ throw new DeployRefused("--save makes a secret key of at most 15 grants; make a wider one from the admin");
660
+ const saved = await keysSave(target, wanted, {
661
+ cwd: d.cwd,
662
+ fetch: d.fetch ?? fetch,
663
+ session: sessionDeps(d),
664
+ log: d.log ?? console.log,
665
+ });
666
+ return { saved: Object.keys(saved).sort(), storefront: saved[ENV.storefront] ?? null };
667
+ }
668
+
484
669
  /** The keys themselves, under whichever credential the caller has (P0.8 §6.1). */
485
670
  async function createKeys(url: string, args: readonly string[], auth: Record<string, string>, d: CommandDeps) {
486
671
  const f = d.fetch ?? fetch;
487
672
  const post = async (path: string, body: unknown) => {
488
- const r = await f(new URL(path, url), {
673
+ const r = await storeRequest(f, new URL(path, url), {
489
674
  method: "POST",
490
675
  headers: {
491
676
  ...auth,
@@ -494,12 +679,11 @@ async function createKeys(url: string, args: readonly string[], auth: Record<str
494
679
  },
495
680
  body: JSON.stringify(body),
496
681
  });
497
- const j = (await r.json()) as Record<string, unknown>;
498
682
  if (!r.ok)
499
683
  throw new DeployRefused(
500
- `${path} answered ${r.status} ${String((j.error as { code?: string } | undefined)?.code ?? "")}`,
684
+ `${path} answered ${r.status} ${String((r.body.error as { code?: string } | undefined)?.code ?? "")}`,
501
685
  );
502
- return j;
686
+ return r.body;
503
687
  };
504
688
  const name = flag(args, "--name") ?? "Storefront";
505
689
  const log = d.log ?? console.log;
@@ -508,12 +692,13 @@ async function createKeys(url: string, args: readonly string[], auth: Record<str
508
692
  if (!grants.length) throw new DeployRefused("a secret key needs at least one --grant <permission>");
509
693
  const k = await post("/admin/api-keys", { name, grants });
510
694
  log(`secret key ${String(k.id)} (shown once):\n${String(k.key)}`);
511
- return;
695
+ return { key_id: k.id, key: k.key };
512
696
  }
513
697
  const sf = await post("/admin/storefronts", { name, allowed_origins: flagsOf(args, "--origin") });
514
698
  const k = await post(`/admin/storefronts/${String(sf.id)}/keys`, { name });
515
699
  log(`storefront ${String(sf.id)}, key ${String(k.id)}`);
516
700
  log(`publishable key (send as x-kedge-key; shown once):\n${String(k.key)}`);
701
+ return { storefront: sf.id, key_id: k.id, key: k.key };
517
702
  }
518
703
 
519
704
  /**
@@ -553,7 +738,41 @@ export function devArgs(cwd: string): string[] {
553
738
  return id ? ["--define", `KEDGE_ADMIN_BUILD:${JSON.stringify(id)}`] : [];
554
739
  }
555
740
 
556
- export function devCommand(d: CommandDeps): number {
741
+ /**
742
+ * The local database's migrations, through Wrangler's own bookkeeping, in one
743
+ * line: Wrangler prints its whole status table again after each migration,
744
+ * thousands of lines for a new store (p0.10.1 §1, 8). Its full output is shown
745
+ * only when it fails.
746
+ */
747
+ export function migrateLocal(d: Pick<CommandDeps, "cwd" | "log" | "error">): number {
748
+ const r = spawnSync(process.execPath, [wranglerBin(), "d1", "migrations", "apply", "DB", "--local"], {
749
+ cwd: d.cwd,
750
+ encoding: "utf8",
751
+ env: { ...process.env, WRANGLER_SEND_METRICS: "false" },
752
+ maxBuffer: 1 << 26,
753
+ });
754
+ const out = `${r.stdout ?? ""}${r.stderr ?? ""}`;
755
+ if (r.status !== 0) {
756
+ (d.error ?? console.error)(out.trim().split("\n").slice(-40).join("\n"));
757
+ return r.status ?? 1;
758
+ }
759
+ const n = new Set(out.match(/\b\d{4}_[a-z0-9_]+\.sql\b/g) ?? []).size;
760
+ (d.log ?? console.log)(
761
+ n ? `applied ${n} migration${n === 1 ? "" : "s"} to the local database` : "the local database is up to date",
762
+ );
763
+ return 0;
764
+ }
765
+
766
+ /** Whether something already listens on the port: Wrangler would otherwise move to another, silently. */
767
+ function portTaken(port: number): Promise<boolean> {
768
+ return new Promise((resolve) => {
769
+ const server = createServer();
770
+ server.once("error", () => resolve(true));
771
+ server.listen(port, "127.0.0.1", () => server.close(() => resolve(false)));
772
+ });
773
+ }
774
+
775
+ export async function devCommand(d: CommandDeps): Promise<number> {
557
776
  const vars = join(d.cwd, ".dev.vars");
558
777
  if (!existsSync(vars)) {
559
778
  const lines = [
@@ -569,11 +788,16 @@ export function devCommand(d: CommandDeps): number {
569
788
  });
570
789
  (d.log ?? console.log)("wrote .dev.vars with fresh local secrets");
571
790
  }
572
- const migrated = spawnSync(process.execPath, [wranglerBin(), "d1", "migrations", "apply", "DB", "--local"], {
573
- cwd: d.cwd,
574
- stdio: "inherit",
575
- });
576
- if (migrated.status !== 0) return migrated.status ?? 1;
791
+ // One port for dev, its invite link and the CLI's local target (p0.10.1 §3): taken is a refusal, never a move.
792
+ const port = devPort(d.cwd);
793
+ if (await portTaken(port)) {
794
+ (d.error ?? console.error)(
795
+ `port ${port} is in use: stop what's on it, or set "dev": { "port": <another> } in wrangler.jsonc`,
796
+ );
797
+ return 1;
798
+ }
799
+ const migrated = migrateLocal(d);
800
+ if (migrated !== 0) return migrated;
577
801
  let extra: string[];
578
802
  try {
579
803
  extra = devArgs(d.cwd);
@@ -581,7 +805,20 @@ export function devCommand(d: CommandDeps): number {
581
805
  (d.log ?? console.log)((e as Error).message);
582
806
  return 1;
583
807
  }
584
- return spawnSync(process.execPath, [wranglerBin(), "dev", ...extra], { cwd: d.cwd, stdio: "inherit" }).status ?? 0;
808
+ const origin = `http://localhost:${port}`;
809
+ // Where the store and its admin are, and how a person signs in to a new store's admin (p0.10.1 §1, 5).
810
+ (d.log ?? console.log)(
811
+ [
812
+ `the store: ${origin} its admin: ${origin}/app/`,
813
+ "no one can sign in to the admin until there's an Owner: pnpm kedge staff invite-owner <your email>",
814
+ ].join("\n"),
815
+ );
816
+ return (
817
+ spawnSync(process.execPath, [wranglerBin(), "dev", "--port", String(port), ...extra], {
818
+ cwd: d.cwd,
819
+ stdio: "inherit",
820
+ }).status ?? 0
821
+ );
585
822
  }
586
823
 
587
824
  export const removeIfExists = (p: string) => rmSync(p, { recursive: true, force: true });
@@ -607,10 +844,10 @@ export async function previewCommand(args: readonly string[], d: CommandDeps): P
607
844
  const sweep = sub === "destroy" && args.includes("--sweep");
608
845
  const name = flag(args, "--name") ?? (sub === "apply" || sweep ? undefined : branchName(d.cwd));
609
846
  if (sub === "destroy") {
610
- const cf = d.cf ?? restCloudflare(d.env, d.fetch);
847
+ const cf = await cloudflareOf(d);
611
848
  if (args.includes("--sweep")) {
612
849
  await sweepPreviews({ cf, store: store(), log });
613
- return;
850
+ return {};
614
851
  }
615
852
  await destroyPreview({
616
853
  cf,
@@ -620,7 +857,7 @@ export async function previewCommand(args: readonly string[], d: CommandDeps): P
620
857
  ...(flag(args, "--take-over") ? { takeOver: flag(args, "--take-over") as string } : {}),
621
858
  confirmStopped: args.includes("--confirm-stopped"),
622
859
  });
623
- return;
860
+ return { destroyed: name };
624
861
  }
625
862
  const build = async () => {
626
863
  if (d.checkFast && (await d.checkFast()) !== 0) throw new DeployRefused("kedge check --fast failed");
@@ -637,11 +874,15 @@ export async function previewCommand(args: readonly string[], d: CommandDeps): P
637
874
  if (sub === "build") {
638
875
  const { dir } = await build();
639
876
  log(`plan written to ${dir}`);
640
- return;
877
+ return { plan: dir };
641
878
  }
642
- const cf = d.cf ?? restCloudflare(d.env, d.fetch);
879
+ const cf = await cloudflareOf(d);
880
+ const choice = flag(args, "--jurisdiction");
881
+ // A full preview builds from this checkout, so it knows its store; a plan applied from elsewhere names its own.
882
+ await preflight(cf, sub === "apply" ? undefined : `${store()}-ops`, choice);
643
883
  const takeOver = flag(args, "--take-over");
644
884
  const common = {
885
+ ...(choice ? { jurisdiction: choice as JurisdictionChoice } : {}),
645
886
  cf,
646
887
  env: withPreviewKey(d),
647
888
  secretEnv: flagsOf(args, "--secret-env"),
@@ -659,7 +900,7 @@ export async function previewCommand(args: readonly string[], d: CommandDeps): P
659
900
  const a = await applyPlan({ ...common, stream, ...(store ? { store } : {}), ...(name ? { name } : {}) });
660
901
  await a.release("applied");
661
902
  log(`preview ${a.worker} serves ${a.url}`);
662
- return;
903
+ return { worker: a.worker, url: a.url };
663
904
  }
664
905
  // Both halves, then the store's scenarios, under the preview's operation to the last result (§5.3).
665
906
  const { dir, plan } = await build();
@@ -670,13 +911,14 @@ export async function previewCommand(args: readonly string[], d: CommandDeps): P
670
911
  // Whatever fails after the deploy still ends the operation: left held, the next preview or destroy
671
912
  // would need --take-over --confirm-stopped for a run that merely threw (codex code r30).
672
913
  let code: number;
914
+ let cached: string;
673
915
  try {
674
916
  const owner = a.credentials.owner;
675
917
  const ownerSession =
676
918
  owner && hasScenarios(d.cwd)
677
919
  ? await signInOwner(a.url, owner, a.credentials.adminOrigin ?? "https://kedge-preview.invalid")
678
920
  : undefined;
679
- const cached = cacheCredentials(d.cwd, identity, a.credentials);
921
+ cached = cacheCredentials(d.cwd, identity, a.credentials);
680
922
  log(`preview ${a.worker} serves ${a.url} (credentials in ${cached})`);
681
923
  // The admin, when the store serves it (p0.9 §3.4): the preview's own origin signs in, with no ADMIN_ORIGINS.
682
924
  if (plan.assets) log(`admin: ${a.url}/app/`);
@@ -687,6 +929,13 @@ export async function previewCommand(args: readonly string[], d: CommandDeps): P
687
929
  }
688
930
  await a.release(code === 0 ? "scenarios passed" : "scenarios failed");
689
931
  if (code !== 0) throw new DeployRefused("the store's scenarios failed against the preview");
932
+ return {
933
+ worker: a.worker,
934
+ url: a.url,
935
+ admin: plan.assets ? `${a.url}/app/` : null,
936
+ credentials: cached,
937
+ scenarios: hasScenarios(d.cwd) ? "passed" : "none",
938
+ };
690
939
  });
691
940
  }
692
941