@kedge/cli 0.9.0 → 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/commands.ts CHANGED
@@ -8,9 +8,21 @@ import { spawnSync } from "node:child_process";
8
8
  import { existsSync, readdirSync, readFileSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
9
9
  import { join, resolve } from "node:path";
10
10
  import { pathToFileURL } from "node:url";
11
- import type { KedgeConfig } from "@kedge/core";
12
- import { type Cloudflare, restCloudflare, wranglerBin } from "./cf";
11
+ import { isPermission, type KedgeConfig } from "@kedge/core";
12
+ import {
13
+ type Cloudflare,
14
+ CloudflareError,
15
+ cloudflareFor,
16
+ isJurisdictionChoice,
17
+ type JurisdictionChoice,
18
+ NeedsPerson,
19
+ preflight,
20
+ UnknownOutcome,
21
+ wranglerBin,
22
+ } from "./cf";
23
+ import { isCliSession, OLDER_KEDGE, type SessionDeps, withCliSession } from "./cli-session";
13
24
  import { ComposeError, compose, type Lock } from "./compose";
25
+ import { type Executor, wranglerExecute } from "./d1-batch";
14
26
  import {
15
27
  adminBuildIn,
16
28
  appliedNames,
@@ -22,9 +34,14 @@ import {
22
34
  uncertainMigrations,
23
35
  } from "./deploy";
24
36
  import { readWrangler } from "./doctor";
37
+ import { badOrigins, ENV, keysSave, type Wanted } from "./keys-save";
25
38
  import { installedPackages, ownManifest } from "./manifest";
26
- import { OperationHeld } from "./ops";
39
+ import { LostOwnership, OperationHeld } from "./ops";
40
+ import { childStdio, emit } from "./output";
41
+ import { needsLive, smoke } from "./smoke";
27
42
  import { git, storeNames } from "./store";
43
+ import { storeRequest } from "./store-api";
44
+ import { resolveTarget, type StoreTarget } from "./target";
28
45
  import { compileStoreZone } from "./tz";
29
46
 
30
47
  export interface CommandDeps {
@@ -38,18 +55,49 @@ export interface CommandDeps {
38
55
  error?: (s: string) => void;
39
56
  /** Asks the person at the terminal; `hidden` doesn't echo (a password). Tests answer it. */
40
57
  ask?: (question: string, hidden: boolean) => Promise<string>;
58
+ /** Writes a CLI session's row to `kedge dev`'s database; tests replace it. */
59
+ execute?: Executor;
60
+ /** The sign-in's stdin; tests give a stream. */
61
+ stdin?: Stdin;
62
+ /** `--json`: the command's result as one object on stdout (p0.10 §5). */
63
+ json?: boolean;
64
+ /** Where that object goes; tests read it. */
65
+ emit?: (result: Record<string, unknown>) => void;
41
66
  }
42
67
 
68
+ /** How long `--answers-stdin` waits for each answer (p0.10 §5). */
69
+ export const ANSWER_WAIT_MS = 30_000;
70
+
71
+ type Stdin = NodeJS.ReadableStream & { isTTY?: boolean };
72
+
43
73
  /**
44
74
  * 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.
75
+ * readline would lose what the first had buffered, so piped answers would reach
76
+ * only the first question. A hidden answer isn't echoed, so a password stays
77
+ * off the screen.
78
+ *
79
+ * Only a terminal is asked unless `--answers-stdin` says otherwise, and that's
80
+ * decided before anything is sent (p0.10 §5): an agent's tool call can hold
81
+ * stdin open and write nothing, and a read would then wait forever. Piped
82
+ * answers wait at most `ANSWER_WAIT_MS` each, as `docker login --password-stdin`
83
+ * is the explicit way in.
48
84
  */
49
- export async function terminalAsker(): Promise<{ ask: NonNullable<CommandDeps["ask"]>; close: () => void }> {
85
+ export async function terminalAsker(o: {
86
+ answersStdin: boolean;
87
+ input?: Stdin;
88
+ output?: NodeJS.WritableStream;
89
+ waitMs?: number;
90
+ }): Promise<{ ask: NonNullable<CommandDeps["ask"]>; close: () => void }> {
91
+ const input = o.input ?? process.stdin;
92
+ // Questions go to stderr, as git's and ssh's do: under --json stdout is the result's alone.
93
+ const output = o.output ?? process.stderr;
94
+ const tty = Boolean(input.isTTY);
95
+ if (!tty && !o.answersStdin)
96
+ throw new NeedsPerson(
97
+ "signing in asks for your password and authenticator code: run this command in your own terminal",
98
+ );
50
99
  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 });
100
+ const rl = createInterface({ input, output, terminal: tty });
53
101
  const out = rl as unknown as { _writeToOutput: (s: string) => void; output: NodeJS.WriteStream };
54
102
  let muted = false;
55
103
  const write = out._writeToOutput.bind(rl);
@@ -57,16 +105,27 @@ export async function terminalAsker(): Promise<{ ask: NonNullable<CommandDeps["a
57
105
  if (!muted) write(s);
58
106
  };
59
107
  const lines = rl[Symbol.asyncIterator]();
108
+ const waitMs = o.waitMs ?? ANSWER_WAIT_MS;
60
109
  return {
61
110
  async ask(question, hidden) {
62
- process.stdout.write(question);
111
+ output.write(question);
63
112
  muted = hidden && tty;
113
+ let timer: NodeJS.Timeout | undefined;
64
114
  try {
65
- const next = await lines.next();
115
+ // A person at a terminal takes the time they need; a pipe that sends nothing is an end.
116
+ const silence = new Promise<never>((_, reject) => {
117
+ if (!tty)
118
+ timer = setTimeout(
119
+ () => reject(new DeployRefused(`no answer on stdin within ${waitMs / 1000} seconds`)),
120
+ waitMs,
121
+ );
122
+ });
123
+ const next = await Promise.race([lines.next(), silence]);
66
124
  if (next.done) throw new DeployRefused("no answer on stdin");
67
125
  return next.value;
68
126
  } finally {
69
- if (muted) process.stdout.write("\n");
127
+ clearTimeout(timer);
128
+ if (muted) output.write("\n");
70
129
  muted = false;
71
130
  }
72
131
  },
@@ -124,10 +183,13 @@ export function bundleWithWrangler(cwd: string) {
124
183
  };
125
184
  }
126
185
 
127
- async function deployer(d: CommandDeps): Promise<Deployer> {
186
+ /** The Cloudflare a command acts on: the given one (tests), else whatever source resolves. */
187
+ const cloudflareOf = async (d: CommandDeps) => d.cf ?? (await cloudflareFor(d.env, d.cwd, d.fetch));
188
+
189
+ async function deployer(d: CommandDeps, cf?: Cloudflare): Promise<Deployer> {
128
190
  const config = await loadConfig(d.cwd);
129
191
  return new Deployer({
130
- cf: d.cf ?? restCloudflare(d.env, d.fetch),
192
+ cf: cf ?? (await cloudflareOf(d)),
131
193
  cwd: d.cwd,
132
194
  manifest: ownManifest(),
133
195
  lock: lockOf(d.cwd),
@@ -140,26 +202,57 @@ async function deployer(d: CommandDeps): Promise<Deployer> {
140
202
  });
141
203
  }
142
204
 
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> {
205
+ /**
206
+ * Turns a refusal into its message and exit code; anything else is a bug and
207
+ * propagates. Under `--json` the outcome is also one object on stdout: what the
208
+ * command made, its handoff, or its refusal (p0.10 §5).
209
+ */
210
+ async function refusals(d: CommandDeps, work: () => Promise<unknown>): Promise<number> {
211
+ const result = (r: Record<string, unknown>) => {
212
+ if (d.json) (d.emit ?? emit)(r);
213
+ };
145
214
  try {
146
- await work();
215
+ // What the command made, when it returns it; a void command's object is only `ok`.
216
+ const made = await work();
217
+ result({ ok: true, ...(made && typeof made === "object" ? made : {}) });
147
218
  return 0;
148
219
  } 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;
220
+ const f = failureOf(e);
221
+ if (!f) throw e;
222
+ (d.error ?? console.error)(f.line);
223
+ result(f.result);
224
+ return f.code;
154
225
  }
155
226
  }
156
227
 
228
+ /**
229
+ * How a command that didn't succeed ends, for a person and under `--json`
230
+ * (p0.10 §5): a handoff (exit 3, the last line `for you:`), or a refusal,
231
+ * Cloudflare refusing, or Cloudflare not answering (exit 1). An unanswered
232
+ * call says its outcome is unknown: the operation stays held, for
233
+ * `deploy --resume` or `doctor --remote` to settle. Null is a bug, which propagates.
234
+ */
235
+ export function failureOf(e: unknown): { code: 1 | 3; line: string; result: Record<string, unknown> } | null {
236
+ if (e instanceof NeedsPerson)
237
+ return { code: 3, line: `for you: ${e.handoff}`, result: { ok: false, handoff: e.handoff } };
238
+ if (e instanceof UnknownOutcome)
239
+ return { code: 1, line: e.message, result: { ok: false, error: e.message, outcome: "unknown" } };
240
+ if (
241
+ e instanceof DeployRefused ||
242
+ e instanceof OperationHeld ||
243
+ e instanceof LostOwnership ||
244
+ e instanceof ComposeError ||
245
+ e instanceof CloudflareError
246
+ )
247
+ return { code: 1, line: e.message, result: { ok: false, error: e.message } };
248
+ return null;
249
+ }
250
+
157
251
  export async function deployCommand(args: readonly string[], d: CommandDeps): Promise<number> {
158
252
  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
- );
253
+ const cf = await cloudflareOf(d);
254
+ const dep = await deployer(d, cf);
255
+ (d.log ?? console.log)(`deploying ${dep.names.worker} on account ${cf.account}`);
163
256
  const resolve = flag(args, "--resolve-intent");
164
257
  if (resolve) return dep.resolveIntent(resolve, args.includes("--not-executed"));
165
258
  if (args.includes("--record-active")) return dep.recordActive(flag(args, "--reason") ?? "");
@@ -172,11 +265,23 @@ export async function deployCommand(args: readonly string[], d: CommandDeps): Pr
172
265
  throw new DeployRefused(
173
266
  "--adopt-existing needs --from-release <version>: the release serving now, by your word",
174
267
  );
175
- return dep.adoptExisting(release, (f) => readFileSync(join(d.cwd, "migrations", f), "utf8"));
268
+ // Adoption takes the store's jurisdiction from what exists, so it asks nothing; a choice is checked against it.
269
+ const choice = flag(args, "--jurisdiction");
270
+ await preflight(cf);
271
+ if (choice !== undefined && !isJurisdictionChoice(choice))
272
+ throw new DeployRefused(`--jurisdiction takes eu, us, fedramp or none, not "${choice}"`);
273
+ return dep.adoptExisting(
274
+ release,
275
+ (f) => readFileSync(join(d.cwd, "migrations", f), "utf8"),
276
+ choice as JurisdictionChoice | undefined,
277
+ );
176
278
  }
177
279
  const window = windowOf(flag(args, "--maintenance-window"));
178
280
  const takeOver = flag(args, "--take-over");
281
+ const choice = flag(args, "--jurisdiction");
282
+ await preflight(cf, dep.names.ops, choice);
179
283
  await dep.deploy({
284
+ ...(choice ? { jurisdiction: choice as JurisdictionChoice } : {}),
180
285
  vars: varsOf(args),
181
286
  adopt: flagsOf(args, "--adopt"),
182
287
  allowOlder: args.includes("--allow-older"),
@@ -185,15 +290,23 @@ export async function deployCommand(args: readonly string[], d: CommandDeps): Pr
185
290
  ...(window ? { window } : {}),
186
291
  ...(takeOver ? { takeOver } : {}),
187
292
  });
293
+ // From the account's subdomain now, never a recorded URL: its owner can change it (p0.10 §4).
294
+ const url = `https://${dep.names.worker}.${await cf.workersSubdomain()}.workers.dev`;
295
+ return { worker: dep.names.worker, account: cf.account, url, admin: adminUrl(d.cwd, url) };
188
296
  });
189
297
  }
190
298
 
299
+ /** The admin's address, when the store serves it (p0.9 §3.3); null when it serves none. */
300
+ function adminUrl(cwd: string, url: string): string | null {
301
+ return readWrangler(cwd)?.config.assets ? `${url}/app/` : null;
302
+ }
303
+
191
304
  export async function migrateCommand(args: readonly string[], d: CommandDeps): Promise<number> {
192
305
  if (!args.includes("--remote")) {
193
306
  // Local: the dev database, through Wrangler's own bookkeeping.
194
307
  const r = spawnSync(process.execPath, [wranglerBin(), "d1", "migrations", "apply", "DB", "--local"], {
195
308
  cwd: d.cwd,
196
- stdio: "inherit",
309
+ stdio: childStdio(),
197
310
  });
198
311
  return r.status ?? 1;
199
312
  }
@@ -207,7 +320,7 @@ export async function migrateCommand(args: readonly string[], d: CommandDeps): P
207
320
  */
208
321
  export async function generateSupersede(opId: string, d: CommandDeps): Promise<number> {
209
322
  return refusals(d, async () => {
210
- const cf = d.cf ?? restCloudflare(d.env, d.fetch);
323
+ const cf = await cloudflareOf(d);
211
324
  const names = storeNames(d.cwd).names;
212
325
  const { ops } = await openOps(cf, names);
213
326
  const held = await ops.current(SCOPE);
@@ -260,8 +373,11 @@ export async function restoreCommand(args: readonly string[], d: CommandDeps): P
260
373
  return 1;
261
374
  }
262
375
  return refusals(d, async () => {
263
- const cf = d.cf ?? restCloudflare(d.env, d.fetch);
264
- const { ops } = await openOps(cf, storeNames(d.cwd).names);
376
+ const cf = await cloudflareOf(d);
377
+ const names = storeNames(d.cwd).names;
378
+ // A lookup, never a create: no ops database is no preview, and seeding writes nothing to Cloudflare.
379
+ if (!(await cf.findD1(names.ops))) throw new DeployRefused("no ops database");
380
+ const { ops } = await openOps(cf, names);
265
381
  const log = d.log ?? console.log;
266
382
  for (const r of await ops.deployments()) {
267
383
  const before = JSON.parse(r.before_half) as {
@@ -325,24 +441,16 @@ const SAMPLE_APPAREL = [
325
441
  },
326
442
  ] as const;
327
443
 
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
- }
444
+ const isLocal = (url: string) => ["localhost", "127.0.0.1", "[::1]"].includes(new URL(url).hostname);
445
+
446
+ /** The store a URL is, for a CLI session or a check: what `resolveTarget` says, credentials resolved as for any remote command. */
447
+ const targetOf = (url: string, d: CommandDeps) => resolveTarget(url, d.cwd, () => cloudflareOf(d));
448
+
449
+ /** How a CLI session's row is written (p0.10 §6.2). */
450
+ const sessionDeps = (d: CommandDeps): SessionDeps => ({
451
+ cloudflare: () => cloudflareOf(d),
452
+ executeLocal: (b) => (d.execute ?? wranglerExecute)(b, { database: "DB", remote: false, cwd: d.cwd }),
453
+ });
346
454
 
347
455
  export async function seed(
348
456
  base: string,
@@ -352,7 +460,7 @@ export async function seed(
352
460
  ): Promise<string[]> {
353
461
  const variants: string[] = [];
354
462
  for (const p of SAMPLE_APPAREL) {
355
- const res = await fetchImpl(new URL("/admin/products", base), {
463
+ const res = await storeRequest(fetchImpl, new URL("/admin/products", base), {
356
464
  method: "POST",
357
465
  headers: { ...auth, "content-type": "application/json", "idempotency-key": `kedge-seed-${p.handle}` },
358
466
  body: JSON.stringify({
@@ -366,8 +474,9 @@ export async function seed(
366
474
  })),
367
475
  }),
368
476
  });
369
- const body = (await res.json()) as { variantIds?: string[]; error?: { code?: string } };
477
+ const body = res.body as { variantIds?: string[]; error?: { code?: string } };
370
478
  if (res.status === 409 && body.error?.code === "CONFLICT") continue; // already seeded: the handle exists
479
+ if (res.status === 401 && isCliSession(auth)) throw new DeployRefused(OLDER_KEDGE);
371
480
  if (!res.ok) throw new DeployRefused(`seeding ${p.handle} failed: ${res.status} ${body.error?.code ?? ""}`);
372
481
  variants.push(...(body.variantIds ?? []));
373
482
  log(`seeded ${p.title}`);
@@ -375,10 +484,16 @@ export async function seed(
375
484
  return variants;
376
485
  }
377
486
 
378
- function authOf(args: readonly string[]): Record<string, string> {
487
+ /** `--key` or `--session`, when given. */
488
+ function givenAuth(args: readonly string[]): Record<string, string> | null {
379
489
  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}` };
490
+ return token ? { authorization: `Bearer ${token}` } : null;
491
+ }
492
+
493
+ function authOf(args: readonly string[]): Record<string, string> {
494
+ const auth = givenAuth(args);
495
+ if (!auth) throw new DeployRefused("give --key <sk_…> or --session <token>");
496
+ return auth;
382
497
  }
383
498
 
384
499
  /** Ends a session the CLI made; a failure leaves it to expire, as it would anyway. */
@@ -395,9 +510,9 @@ async function signOutBearer(url: string, token: string, d: CommandDeps): Promis
395
510
  * cookie they can't hand to `--session`. It sends the store's own origin, which
396
511
  * the Worker counts as an admin origin (§4.1). The session serves one command.
397
512
  */
398
- async function signInBearer(url: string, email: string, d: CommandDeps): Promise<string> {
513
+ async function signInBearer(url: string, email: string, d: CommandDeps, answersStdin: boolean): Promise<string> {
399
514
  const f = d.fetch ?? fetch;
400
- const terminal = d.ask ? null : await terminalAsker();
515
+ const terminal = d.ask ? null : await terminalAsker({ answersStdin, ...(d.stdin ? { input: d.stdin } : {}) });
401
516
  try {
402
517
  return await ceremonies(url, email, d, f, d.ask ?? (terminal as NonNullable<typeof terminal>).ask);
403
518
  } finally {
@@ -419,13 +534,12 @@ async function ceremonies(
419
534
  "content-type": "application/json",
420
535
  };
421
536
  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>;
537
+ const r = await storeRequest(f, new URL(path, url), { method: "POST", headers, body: JSON.stringify(body) });
424
538
  if (!r.ok)
425
539
  throw new DeployRefused(
426
- `${path} answered ${r.status} ${String((j.error as { code?: string } | undefined)?.code ?? "")}`,
540
+ `${path} answered ${r.status} ${String((r.body.error as { code?: string } | undefined)?.code ?? "")}`,
427
541
  );
428
- return j;
542
+ return r.body;
429
543
  };
430
544
  const first = await post("/auth/admin/sign-in", { email, password: await ask(`password for ${email}: `, true) });
431
545
  const challenge = (first.challenge as { token?: string } | undefined)?.token;
@@ -456,9 +570,46 @@ export async function seedCommand(args: readonly string[], d: CommandDeps): Prom
456
570
  throw new DeployRefused("kedge seed --sample apparel --url <url> --key <sk_…>");
457
571
  const url = flag(args, "--url");
458
572
  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);
573
+ // Local, or a preview this store's ops knows by its whole origin (codex code r17); a live store's
574
+ // customers would see sample products, so it's the owner's call (p0.10 §5).
575
+ let target: StoreTarget | null = null;
576
+ if (!isLocal(url) && !args.includes("--live")) {
577
+ target = await targetOf(url, d);
578
+ if (target?.kind !== "preview")
579
+ throw new NeedsPerson(
580
+ `${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`,
581
+ );
582
+ }
583
+ const run = (auth: Record<string, string>) => seed(url, auth, d.fetch ?? fetch, d.log ?? console.log);
584
+ const given = givenAuth(args);
585
+ if (given) return { variants: await run(given) };
586
+ // No key or session given: a CLI session on the store the URL is, and only there (p0.10 §6.2).
587
+ target ??= await targetOf(url, d);
588
+ if (!target)
589
+ throw new DeployRefused(
590
+ `${url} isn't a store this checkout knows, so it gets no CLI session: give --key <sk_…> or --session <token>`,
591
+ );
592
+ return { variants: await withCliSession(target, ["product:write"], sessionDeps(d), run) };
593
+ });
594
+ }
595
+
596
+ /** `kedge smoke` (p0.10 §6.3): a named test order, resumed until it has its number. */
597
+ export async function smokeCommand(args: readonly string[], d: CommandDeps): Promise<number> {
598
+ return refusals(d, async () => {
599
+ const url = flag(args, "--url");
600
+ if (!url) throw new DeployRefused("kedge smoke --url <store> [--run <name>] [--live]");
601
+ const name = flag(args, "--run") ?? "first";
602
+ if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(name))
603
+ throw new DeployRefused("--run takes a name of letters, digits, - and _, up to 64");
604
+ const target = await targetOf(url, d);
605
+ if (!target) throw new DeployRefused(`${url} isn't a store this checkout knows: smoke runs only on its own stores`);
606
+ needsLive(target, args.includes("--live"));
607
+ return smoke(target, name, {
608
+ cwd: d.cwd,
609
+ fetch: d.fetch ?? fetch,
610
+ session: sessionDeps(d),
611
+ log: d.log ?? console.log,
612
+ });
462
613
  });
463
614
  }
464
615
 
@@ -466,9 +617,10 @@ export async function seedCommand(args: readonly string[], d: CommandDeps): Prom
466
617
  export async function keysOverHttp(args: readonly string[], d: CommandDeps): Promise<number> {
467
618
  return refusals(d, async () => {
468
619
  const url = flag(args, "--url") as string;
620
+ if (args.includes("--save")) return saveKeys(url, args, d);
469
621
  const email = flag(args, "--sign-in");
470
622
  if (email) {
471
- const token = await signInBearer(url, email, d);
623
+ const token = await signInBearer(url, email, d, args.includes("--answers-stdin"));
472
624
  try {
473
625
  return await createKeys(url, args, { authorization: `Bearer ${token}` }, d);
474
626
  } finally {
@@ -481,11 +633,49 @@ export async function keysOverHttp(args: readonly string[], d: CommandDeps): Pro
481
633
  });
482
634
  }
483
635
 
636
+ /**
637
+ * `keys create --save` (p0.10 §6.2): through a credential reference with a
638
+ * CLI session, saved to `.env.local`. What it answers names what was saved,
639
+ * never the key: the key stays in the file, out of the agent's transcript.
640
+ */
641
+ async function saveKeys(url: string, args: readonly string[], d: CommandDeps) {
642
+ if (flag(args, "--sign-in") || givenAuth(args))
643
+ throw new DeployRefused("--save makes its own CLI session: drop --sign-in, --key and --session");
644
+ const target = await targetOf(url, d);
645
+ if (!target)
646
+ throw new DeployRefused(`${url} isn't a store this checkout knows, so it gets no CLI session and no saved key`);
647
+ const name = flag(args, "--name") ?? "Storefront";
648
+ const wanted: Wanted = args.includes("--secret")
649
+ ? { kind: "secret", name, grants: flagsOf(args, "--grant") }
650
+ : { kind: "publishable", name, origins: flagsOf(args, "--origin") };
651
+ if (wanted.kind === "secret" && wanted.grants.length === 0)
652
+ throw new DeployRefused("a secret key needs at least one --grant <permission>");
653
+ // Origins as the store accepts them, checked before anything is reused or written (codex code r16).
654
+ const wrong = wanted.kind === "publishable" ? badOrigins(wanted.origins) : [];
655
+ if (wrong.length)
656
+ throw new DeployRefused(
657
+ `not origins: ${wrong.join(", ")} (an origin is a scheme, host and optional port, like https://shop.example)`,
658
+ );
659
+ // Checked here, so a typo is one refusal under --json, before any session exists (codex code r15).
660
+ const bad = wanted.kind === "secret" ? wanted.grants.filter((g) => !isPermission(g)) : [];
661
+ if (bad.length) throw new DeployRefused(`not permissions: ${bad.join(", ")} (a permission is resource:action)`);
662
+ // The session that makes it holds the key's grants and api_key:write, and a session holds 16 at most.
663
+ if (wanted.kind === "secret" && new Set(wanted.grants).size > 15)
664
+ throw new DeployRefused("--save makes a secret key of at most 15 grants; make a wider one from the admin");
665
+ const saved = await keysSave(target, wanted, {
666
+ cwd: d.cwd,
667
+ fetch: d.fetch ?? fetch,
668
+ session: sessionDeps(d),
669
+ log: d.log ?? console.log,
670
+ });
671
+ return { saved: Object.keys(saved).sort(), storefront: saved[ENV.storefront] ?? null };
672
+ }
673
+
484
674
  /** The keys themselves, under whichever credential the caller has (P0.8 §6.1). */
485
675
  async function createKeys(url: string, args: readonly string[], auth: Record<string, string>, d: CommandDeps) {
486
676
  const f = d.fetch ?? fetch;
487
677
  const post = async (path: string, body: unknown) => {
488
- const r = await f(new URL(path, url), {
678
+ const r = await storeRequest(f, new URL(path, url), {
489
679
  method: "POST",
490
680
  headers: {
491
681
  ...auth,
@@ -494,12 +684,11 @@ async function createKeys(url: string, args: readonly string[], auth: Record<str
494
684
  },
495
685
  body: JSON.stringify(body),
496
686
  });
497
- const j = (await r.json()) as Record<string, unknown>;
498
687
  if (!r.ok)
499
688
  throw new DeployRefused(
500
- `${path} answered ${r.status} ${String((j.error as { code?: string } | undefined)?.code ?? "")}`,
689
+ `${path} answered ${r.status} ${String((r.body.error as { code?: string } | undefined)?.code ?? "")}`,
501
690
  );
502
- return j;
691
+ return r.body;
503
692
  };
504
693
  const name = flag(args, "--name") ?? "Storefront";
505
694
  const log = d.log ?? console.log;
@@ -508,12 +697,13 @@ async function createKeys(url: string, args: readonly string[], auth: Record<str
508
697
  if (!grants.length) throw new DeployRefused("a secret key needs at least one --grant <permission>");
509
698
  const k = await post("/admin/api-keys", { name, grants });
510
699
  log(`secret key ${String(k.id)} (shown once):\n${String(k.key)}`);
511
- return;
700
+ return { key_id: k.id, key: k.key };
512
701
  }
513
702
  const sf = await post("/admin/storefronts", { name, allowed_origins: flagsOf(args, "--origin") });
514
703
  const k = await post(`/admin/storefronts/${String(sf.id)}/keys`, { name });
515
704
  log(`storefront ${String(sf.id)}, key ${String(k.id)}`);
516
705
  log(`publishable key (send as x-kedge-key; shown once):\n${String(k.key)}`);
706
+ return { storefront: sf.id, key_id: k.id, key: k.key };
517
707
  }
518
708
 
519
709
  /**
@@ -607,10 +797,10 @@ export async function previewCommand(args: readonly string[], d: CommandDeps): P
607
797
  const sweep = sub === "destroy" && args.includes("--sweep");
608
798
  const name = flag(args, "--name") ?? (sub === "apply" || sweep ? undefined : branchName(d.cwd));
609
799
  if (sub === "destroy") {
610
- const cf = d.cf ?? restCloudflare(d.env, d.fetch);
800
+ const cf = await cloudflareOf(d);
611
801
  if (args.includes("--sweep")) {
612
802
  await sweepPreviews({ cf, store: store(), log });
613
- return;
803
+ return {};
614
804
  }
615
805
  await destroyPreview({
616
806
  cf,
@@ -620,7 +810,7 @@ export async function previewCommand(args: readonly string[], d: CommandDeps): P
620
810
  ...(flag(args, "--take-over") ? { takeOver: flag(args, "--take-over") as string } : {}),
621
811
  confirmStopped: args.includes("--confirm-stopped"),
622
812
  });
623
- return;
813
+ return { destroyed: name };
624
814
  }
625
815
  const build = async () => {
626
816
  if (d.checkFast && (await d.checkFast()) !== 0) throw new DeployRefused("kedge check --fast failed");
@@ -637,11 +827,15 @@ export async function previewCommand(args: readonly string[], d: CommandDeps): P
637
827
  if (sub === "build") {
638
828
  const { dir } = await build();
639
829
  log(`plan written to ${dir}`);
640
- return;
830
+ return { plan: dir };
641
831
  }
642
- const cf = d.cf ?? restCloudflare(d.env, d.fetch);
832
+ const cf = await cloudflareOf(d);
833
+ const choice = flag(args, "--jurisdiction");
834
+ // A full preview builds from this checkout, so it knows its store; a plan applied from elsewhere names its own.
835
+ await preflight(cf, sub === "apply" ? undefined : `${store()}-ops`, choice);
643
836
  const takeOver = flag(args, "--take-over");
644
837
  const common = {
838
+ ...(choice ? { jurisdiction: choice as JurisdictionChoice } : {}),
645
839
  cf,
646
840
  env: withPreviewKey(d),
647
841
  secretEnv: flagsOf(args, "--secret-env"),
@@ -659,7 +853,7 @@ export async function previewCommand(args: readonly string[], d: CommandDeps): P
659
853
  const a = await applyPlan({ ...common, stream, ...(store ? { store } : {}), ...(name ? { name } : {}) });
660
854
  await a.release("applied");
661
855
  log(`preview ${a.worker} serves ${a.url}`);
662
- return;
856
+ return { worker: a.worker, url: a.url };
663
857
  }
664
858
  // Both halves, then the store's scenarios, under the preview's operation to the last result (§5.3).
665
859
  const { dir, plan } = await build();
@@ -670,13 +864,14 @@ export async function previewCommand(args: readonly string[], d: CommandDeps): P
670
864
  // Whatever fails after the deploy still ends the operation: left held, the next preview or destroy
671
865
  // would need --take-over --confirm-stopped for a run that merely threw (codex code r30).
672
866
  let code: number;
867
+ let cached: string;
673
868
  try {
674
869
  const owner = a.credentials.owner;
675
870
  const ownerSession =
676
871
  owner && hasScenarios(d.cwd)
677
872
  ? await signInOwner(a.url, owner, a.credentials.adminOrigin ?? "https://kedge-preview.invalid")
678
873
  : undefined;
679
- const cached = cacheCredentials(d.cwd, identity, a.credentials);
874
+ cached = cacheCredentials(d.cwd, identity, a.credentials);
680
875
  log(`preview ${a.worker} serves ${a.url} (credentials in ${cached})`);
681
876
  // The admin, when the store serves it (p0.9 §3.4): the preview's own origin signs in, with no ADMIN_ORIGINS.
682
877
  if (plan.assets) log(`admin: ${a.url}/app/`);
@@ -687,6 +882,13 @@ export async function previewCommand(args: readonly string[], d: CommandDeps): P
687
882
  }
688
883
  await a.release(code === 0 ? "scenarios passed" : "scenarios failed");
689
884
  if (code !== 0) throw new DeployRefused("the store's scenarios failed against the preview");
885
+ return {
886
+ worker: a.worker,
887
+ url: a.url,
888
+ admin: plan.assets ? `${a.url}/app/` : null,
889
+ credentials: cached,
890
+ scenarios: hasScenarios(d.cwd) ? "passed" : "none",
891
+ };
690
892
  });
691
893
  }
692
894