@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/main.ts CHANGED
@@ -2,19 +2,28 @@
2
2
  // commands (`staff invite-owner`, `staff recover`, `keys create`); doctor,
3
3
  // check, preview and deploy arrive with P0.8 (docs/11).
4
4
  import { spawnSync } from "node:child_process";
5
- import { cpSync, existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
6
- import { tmpdir } from "node:os";
5
+ import { cpSync, existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
7
6
  import { dirname, join, relative, resolve } from "node:path";
8
7
  import { fileURLToPath, pathToFileURL } from "node:url";
9
8
  import type { KedgeConfig } from "@kedge/core";
10
- import { type CliBatch, ownerInviteSql, publishableKeySql, recoverSql } from "@kedge/db";
11
- import { type Cloudflare, restCloudflare } from "./cf";
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";
12
20
  import { check as runCheck } from "./check";
13
21
  import {
14
22
  type CommandDeps,
15
23
  deployCommand,
16
24
  devCommand,
17
25
  ejectCommand,
26
+ failureOf,
18
27
  flag,
19
28
  generateSupersede,
20
29
  keysOverHttp,
@@ -23,12 +32,15 @@ import {
23
32
  restoreCommand,
24
33
  secretCommand,
25
34
  seedCommand,
35
+ smokeCommand,
26
36
  upgradeCommand,
27
37
  } from "./commands";
28
38
  import { ComposeError, compose, type Lock } from "./compose";
29
- import { doctor, doctorRemote, formatReport } from "./doctor";
39
+ import { type Executor, wranglerExecute } from "./d1-batch";
40
+ import { credentialsChecks, doctor, doctorRemote, formatReport } from "./doctor";
30
41
  import { invariantsMarkdown, invariantsOf } from "./invariants";
31
42
  import { installedPackages } from "./manifest";
43
+ import { emit, withJsonOutput } from "./output";
32
44
  import { composedTexts, schemaOf } from "./schema";
33
45
  import { storeNames } from "./store";
34
46
  import { compileStoreZone } from "./tz";
@@ -51,9 +63,10 @@ const USAGE = `usage: kedge generate [--check] [--supersede <op-id>]
51
63
  kedge doctor --core-diff [--upstream <manifest>] [--strict]
52
64
  kedge secret put <NAME> | kedge secret delete <NAME>
53
65
  kedge restore --list
54
- kedge seed --sample apparel --url <url> (--key <sk_…> | --session <token>) [--live]
66
+ kedge seed --sample apparel --url <url> [--key <sk_…> | --session <token>] [--live]
67
+ kedge smoke --url <url> [--run <name>] [--live]
55
68
  kedge keys create (--publishable [--origin <url>]... | --secret --grant <permission>...)
56
- --url <url> (--session <token> | --sign-in <email>) [--name <name>]
69
+ --url <url> (--session <token> | --sign-in <email> [--answers-stdin] | --save) [--name <name>]
57
70
  kedge staff invite-owner <email> [--database DB] [--remote] [--url <url>] [--config wrangler.jsonc]
58
71
  kedge staff recover <email> [--database DB] [--remote] [--config wrangler.jsonc]
59
72
  kedge keys create --publishable --name <name> [--origin <origin>]... [--pepper-version N]
@@ -71,6 +84,8 @@ const USAGE = `usage: kedge generate [--check] [--supersede <op-id>]
71
84
  upgrade every @kedge package to a release one supported step on, its codemods,
72
85
  UPGRADE-TODO.md, then generate and check; nothing is committed
73
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
74
89
  check typecheck, generate --check, doctor, then the store's tests;
75
90
  --fast stops before the tests (the Stop hook's)
76
91
  doctor the store's offline checks: lock, installed core, error map,
@@ -81,40 +96,13 @@ const USAGE = `usage: kedge generate [--check] [--supersede <op-id>]
81
96
  KEDGE_KEY_PEPPER_V<N> in the environment (N defaults to 1)
82
97
 
83
98
  The staff and keys commands run one audited batch through wrangler d1 execute
84
- (--local unless --remote) and print the secret once.`;
85
-
86
- /** Runs one CLI batch against the store's D1; tests replace it. */
87
- export type Executor = (
88
- b: CliBatch,
89
- opts: { database: string; remote: boolean; cwd: string; config?: string },
90
- ) => number;
99
+ (--local unless --remote) and print the secret once.
91
100
 
92
- const wranglerExecute: Executor = (b, opts) => {
93
- const dir = mkdtempSync(join(tmpdir(), "kedge-"));
94
- const file = join(dir, "batch.sql");
95
- try {
96
- writeFileSync(file, b.sql, { mode: 0o600 });
97
- const r = spawnSync(
98
- "npx",
99
- [
100
- "wrangler",
101
- "d1",
102
- "execute",
103
- opts.database,
104
- "--file",
105
- file,
106
- opts.remote ? "--remote" : "--local",
107
- "--yes",
108
- ...(opts.config ? ["-c", opts.config] : []),
109
- ],
110
- { cwd: opts.cwd, stdio: "inherit" },
111
- );
112
- return r.status ?? 1;
113
- } finally {
114
- // The file holds no secret (only digests), but it needn't outlive the run.
115
- rmSync(dir, { recursive: true, force: true });
116
- }
117
- };
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.`;
118
106
 
119
107
  /**
120
108
  * The store's origin for the invite link (p0.9 §3.5): `--url`, or with
@@ -133,7 +121,7 @@ async function inviteOrigin(
133
121
  if (!remote) return null;
134
122
  try {
135
123
  const worker = storeNames(cwd).names.worker;
136
- return `https://${worker}.${await (cf ?? restCloudflare(env)).workersSubdomain()}.workers.dev`;
124
+ return `https://${worker}.${await (cf ?? (await cloudflareFor(env, cwd))).workersSubdomain()}.workers.dev`;
137
125
  } catch {
138
126
  return null;
139
127
  }
@@ -145,50 +133,87 @@ async function bootstrap(
145
133
  cwd: string,
146
134
  execute: Executor,
147
135
  env: NodeJS.ProcessEnv,
148
- cf?: Cloudflare,
136
+ o: { json: boolean; emit?: (result: Record<string, unknown>) => void; cf?: Cloudflare },
149
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
+ };
150
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);
151
162
  const opts = {
152
163
  database: flag(args, "--database") ?? "DB",
153
- remote: args.includes("--remote"),
164
+ remote,
154
165
  cwd,
155
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
+ : {}),
156
178
  };
157
179
  const now = Date.now();
158
180
  const [sub, target] = args;
159
181
  if (command === "staff" && (sub === "invite-owner" || sub === "recover") && target && !target.startsWith("--")) {
160
182
  const b = sub === "invite-owner" ? await ownerInviteSql(target, now) : await recoverSql(target, now);
161
183
  const status = execute(b, opts);
162
- if (status !== 0) return status;
163
- console.log(`invite ${b.inviteId} for ${target}, valid until ${new Date(b.expiresAt).toISOString()}`);
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}`);
164
187
  console.log(`accept with POST /auth/admin/invites/accept and this token (shown once):\n${b.token}`);
165
188
  const origin = await inviteOrigin(args, opts.remote, cwd, env, cf);
166
189
  // The token rides in the fragment, which no server log or Referer carries (p0.9 §3.5).
167
- if (origin) console.log(`or open the admin's invite page:\n${origin}/app/invite#${b.token}`);
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 });
168
193
  return 0;
169
194
  }
170
195
  if (command === "keys" && sub === "create" && args.includes("--publishable")) {
171
196
  const name = flag(args, "--name");
172
- if (!name) {
173
- console.error("keys create needs --name");
174
- return 2;
175
- }
197
+ if (!name) return usage("keys create needs --name");
176
198
  const origins = args.flatMap((a, i) => (a === "--origin" && args[i + 1] ? [args[i + 1] as string] : []));
177
199
  const pepperVersion = Number(flag(args, "--pepper-version") ?? "1");
178
200
  const pepper = env[`KEDGE_KEY_PEPPER_V${pepperVersion}`];
179
- if (!Number.isSafeInteger(pepperVersion) || pepperVersion < 1 || !pepper) {
180
- console.error(`set KEDGE_KEY_PEPPER_V${pepperVersion} to the Worker's pepper secret first`);
181
- return 2;
182
- }
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
+ );
183
208
  const b = await publishableKeySql({ name, origins, pepperVersion, pepper }, now);
184
209
  const status = execute(b, opts);
185
- if (status !== 0) return status;
210
+ if (status !== 0) return failed(status);
186
211
  console.log(`storefront ${b.storefrontId}, key ${b.keyId}`);
187
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 });
188
214
  return 0;
189
215
  }
190
- console.error(USAGE);
191
- return 2;
216
+ return usage(USAGE);
192
217
  }
193
218
 
194
219
  const INDEX_START = "<!-- kedge:docs-index:start -->";
@@ -218,13 +243,18 @@ function agentsIndex(cwd: string): { path: string; text: string } | null {
218
243
  return { path, text: `${current.slice(0, a + INDEX_START.length)}\n${lines.join("\n")}\n${current.slice(b)}` };
219
244
  }
220
245
 
221
- /** Skills ship in @kedge/docs and are copied, gitignored, so they always match what's installed (§9.3). */
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
+ */
222
251
  function copySkills(cwd: string): string[] {
223
252
  const docs = installedPackages(cwd).get("@kedge/docs");
224
253
  if (!docs || !existsSync(join(docs, "skills")) || !existsSync(join(cwd, ".claude"))) return [];
225
254
  const out: string[] = [];
226
255
  for (const s of readdirSync(join(docs, "skills"))) {
227
- cpSync(join(docs, "skills", s), join(cwd, ".claude", "skills", s), { recursive: true });
256
+ for (const home of [".claude", ".agents"])
257
+ cpSync(join(docs, "skills", s), join(cwd, home, "skills", s), { recursive: true });
228
258
  out.push(s);
229
259
  }
230
260
  return out;
@@ -251,29 +281,46 @@ function coreMigrationsDir(): string {
251
281
  return join(dirname(entry), "..", "migrations");
252
282
  }
253
283
 
254
- export async function main(
255
- argv: string[],
256
- cwd: string,
257
- deps: { execute?: Executor; env?: NodeJS.ProcessEnv; cf?: Cloudflare } = {},
258
- ): Promise<number> {
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> {
259
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
+ };
260
302
  if (command === "keys" && flag(flags, "--url")) {
261
- const d0: CommandDeps = { cwd, env: deps.env ?? process.env, coreDir: coreMigrationsDir() };
303
+ const d0: CommandDeps = { cwd, env: deps.env ?? process.env, coreDir: coreMigrationsDir(), ...out };
262
304
  return keysOverHttp(flags, d0);
263
305
  }
264
306
  if (command === "staff" || command === "keys")
265
- return bootstrap(command, flags, cwd, deps.execute ?? wranglerExecute, deps.env ?? process.env, deps.cf);
307
+ return bootstrap(command, flags, cwd, deps.execute ?? wranglerExecute, deps.env ?? process.env, {
308
+ ...out,
309
+ ...(deps.cf ? { cf: deps.cf } : {}),
310
+ });
266
311
  const d: CommandDeps = {
267
312
  cwd,
268
313
  env: deps.env ?? process.env,
269
314
  coreDir: coreMigrationsDir(),
270
315
  checkFast: () => main(["check", "--fast"], cwd),
316
+ ...out,
271
317
  };
272
318
  if (command === "deploy") return deployCommand(flags, d);
273
319
  if (command === "migrate") return migrateCommand(flags, d);
274
320
  if (command === "restore") return restoreCommand(flags, d);
275
321
  if (command === "secret") return secretCommand(flags, d);
276
322
  if (command === "seed") return seedCommand(flags, d);
323
+ if (command === "smoke") return smokeCommand(flags, d);
277
324
  if (command === "preview") return previewCommand(flags, d);
278
325
  if (command === "eject") return ejectCommand(flags, d);
279
326
  if (command === "upgrade")
@@ -302,11 +349,35 @@ export async function main(
302
349
  if (command === "doctor") {
303
350
  const results = await doctor({ cwd, coreDir: coreMigrationsDir() });
304
351
  if (flags.includes("--remote")) {
305
- const cf = restCloudflare(d.env);
306
- const lock = JSON.parse(readFileSync(join(cwd, "kedge.lock.json"), "utf8")) as Lock;
307
- results.push(...(await doctorRemote({ cwd, cf, lock, logpushJobs: () => cf.logpushJobs() })));
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
+ }
308
374
  }
309
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
+ }
310
381
  return results.some((r) => r.status === "fail") ? 1 : 0;
311
382
  }
312
383
  if (command === "check")
@@ -376,3 +447,24 @@ export async function main(
376
447
  throw e;
377
448
  }
378
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
+ }
package/src/ops.ts CHANGED
@@ -18,6 +18,8 @@ export type Query = (sql: string, params?: readonly unknown[]) => Promise<Row[]>
18
18
 
19
19
  export const OPS_SCHEMA = [
20
20
  `CREATE TABLE IF NOT EXISTS kedge_ops_meta (id INTEGER PRIMARY KEY CHECK (id = 1), store TEXT NOT NULL, created_at INTEGER NOT NULL)`,
21
+ // The store's choices made once (p0.10 §7: its jurisdiction), each written once and never changed.
22
+ `CREATE TABLE IF NOT EXISTS ops_setting (name TEXT PRIMARY KEY, value TEXT NOT NULL, created_at INTEGER NOT NULL)`,
21
23
  `CREATE TABLE IF NOT EXISTS operation (
22
24
  scope TEXT PRIMARY KEY, op_id TEXT NOT NULL UNIQUE, kind TEXT NOT NULL, plan TEXT NOT NULL, step TEXT NOT NULL,
23
25
  progress TEXT NOT NULL DEFAULT '{}',
@@ -136,6 +138,22 @@ export class OpsStore {
136
138
  throw new Error(`this operations database belongs to ${String(meta?.store)}, not ${store}`);
137
139
  }
138
140
 
141
+ /** A setting written once; the value already recorded wins, and is what this answers. */
142
+ async settle(name: string, value: string): Promise<string> {
143
+ await this.q("INSERT INTO ops_setting (name, value, created_at) VALUES (?, ?, ?) ON CONFLICT (name) DO NOTHING", [
144
+ name,
145
+ value,
146
+ this.now(),
147
+ ]);
148
+ const [r] = await this.q("SELECT value FROM ops_setting WHERE name = ?", [name]);
149
+ return String(r?.value);
150
+ }
151
+
152
+ async setting(name: string): Promise<string | null> {
153
+ const [r] = await this.q("SELECT value FROM ops_setting WHERE name = ?", [name]);
154
+ return r ? String(r.value) : null;
155
+ }
156
+
139
157
  // ---- operations --------------------------------------------------------------------------------------
140
158
  async current(scope: string): Promise<OperationRow | null> {
141
159
  return ((await this.q("SELECT * FROM operation WHERE scope = ?", [scope]))[0] as OperationRow | undefined) ?? null;
package/src/output.ts ADDED
@@ -0,0 +1,35 @@
1
+ // `--json` (p0.10 §5): stdout carries one object when the command ends, and
2
+ // nothing else, so an agent parses it whole. Progress still reaches a person,
3
+ // on stderr: console.log is pointed there for the command's run, and every
4
+ // child the CLI starts (Wrangler, the store's checks and scenarios) writes its
5
+ // stdout there too, since a child's line on stdout would break the object.
6
+ import type { StdioOptions } from "node:child_process";
7
+
8
+ let reserved = false;
9
+
10
+ /** Runs `work` with stdout reserved for `emit` when `on`; nested runs keep the outer setting. */
11
+ export async function withJsonOutput<T>(on: boolean, work: () => Promise<T>): Promise<T> {
12
+ if (!on || reserved) return work();
13
+ const log = console.log;
14
+ const info = console.info;
15
+ reserved = true;
16
+ console.log = console.error;
17
+ console.info = console.error;
18
+ try {
19
+ return await work();
20
+ } finally {
21
+ reserved = false;
22
+ console.log = log;
23
+ console.info = info;
24
+ }
25
+ }
26
+
27
+ /** The command's one result object. */
28
+ export function emit(result: Record<string, unknown>): void {
29
+ process.stdout.write(`${JSON.stringify(result)}\n`);
30
+ }
31
+
32
+ /** A child's stdio: inherited, except that its stdout goes to stderr while stdout is reserved. */
33
+ export function childStdio(): StdioOptions {
34
+ return reserved ? ["inherit", 2, "inherit"] : "inherit";
35
+ }
package/src/preview.ts CHANGED
@@ -27,12 +27,13 @@ import { tmpdir } from "node:os";
27
27
  import { join } from "node:path";
28
28
  import { ownerInviteSql } from "@kedge/db";
29
29
  import { enrolOwner, type StoreCredentials } from "@kedge/testing/http";
30
- import { type Cloudflare, UnknownOutcome } from "./cf";
30
+ import { type Cloudflare, type JurisdictionChoice, UnknownOutcome } from "./cf";
31
31
  import { seed } from "./commands";
32
32
  import type { Lock } from "./compose";
33
33
  import { CORE_SECRETS, DeployRefused, openOps, stageAssets } from "./deploy";
34
34
  import { assetsShape, declaredClasses, readWrangler } from "./doctor";
35
35
  import { LostOwnership, OperationHeld, type OpsStore } from "./ops";
36
+ import { childStdio } from "./output";
36
37
  import { hashTree, type StoreNames, sha256, type WranglerJson } from "./store";
37
38
  import { readTar, type TarEntry, TarRefused, writeTar } from "./tar";
38
39
 
@@ -430,6 +431,8 @@ export interface ApplyInput {
430
431
  name?: string;
431
432
  /** Secrets the caller grants from its own environment (--secret-env). */
432
433
  secretEnv?: readonly string[];
434
+ /** The person's choice, when this preview is the store's first resource (p0.10 §7). */
435
+ jurisdiction?: JurisdictionChoice;
433
436
  env: NodeJS.ProcessEnv;
434
437
  fresh?: boolean;
435
438
  takeOver?: string;
@@ -466,7 +469,8 @@ export async function applyPlan(input: ApplyInput): Promise<Applied> {
466
469
  const { dir: staging, plan } = stagePlan(input.stream);
467
470
  const store = input.store ?? plan.store;
468
471
  const name = input.name ?? plan.name;
469
- const { ops } = await openOps(input.cf, opsNames(store), input.now);
472
+ // A preview before the first deploy makes <store>-ops, so it fixes the store's jurisdiction; it takes its store's.
473
+ const { ops, jurisdiction } = await openOps(input.cf, opsNames(store), input.now, input.jurisdiction ?? "ask");
470
474
  const scope = `preview:${store}/${name}`;
471
475
  const key = input.env.KEDGE_PREVIEW_KEY;
472
476
  if (!key) throw new DeployRefused("set KEDGE_PREVIEW_KEY: a preview's credentials are sealed under it");
@@ -478,7 +482,19 @@ export async function applyPlan(input: ApplyInput): Promise<Applied> {
478
482
  } else opId = await ops.start(scope, "preview", { modules: plan.modules, migrations: plan.migrations }, "start");
479
483
  const release = (outcome: string) => ops.finish(scope, opId, outcome, [{ modules: plan.modules }]);
480
484
  try {
481
- const p = new PreviewRun(input, ops, scope, opId, staging, plan, `${store}/${name}`, store, name, key, log);
485
+ const p = new PreviewRun(
486
+ { ...input, jurisdiction },
487
+ ops,
488
+ scope,
489
+ opId,
490
+ staging,
491
+ plan,
492
+ `${store}/${name}`,
493
+ store,
494
+ name,
495
+ key,
496
+ log,
497
+ );
482
498
  const out = await p.run();
483
499
  return { ...out, release };
484
500
  } catch (e) {
@@ -604,7 +620,10 @@ class PreviewRun {
604
620
  if (found) return found;
605
621
  const id = await this.ops.openIntent(this.scope, this.opId, "create", kind, row.worker, { name: nameOf });
606
622
  try {
607
- const out = await (kind === "d1" ? this.cf.createD1(nameOf) : this.cf.createQueue(nameOf));
623
+ const j = this.input.jurisdiction ?? "none";
624
+ const out = await (kind === "d1"
625
+ ? this.cf.createD1(nameOf, j === "none" ? null : j)
626
+ : this.cf.createQueue(nameOf));
608
627
  await this.ops.closeIntent(id, "done", "answer");
609
628
  return out;
610
629
  } catch (e) {
@@ -620,6 +639,13 @@ class PreviewRun {
620
639
  }
621
640
  };
622
641
  const databaseId = await create("d1", row.database_name, row.database_id);
642
+ // Recorded, found or made, the preview's database is accepted only where its store's data lives (p0.10 §7):
643
+ // one replaced outside Kedge would take the preview's writes elsewhere (codex code r13).
644
+ const where = await this.cf.d1Jurisdiction(databaseId);
645
+ if (where !== (this.input.jurisdiction ?? "none"))
646
+ throw new DeployRefused(
647
+ `${row.database_name} is in jurisdiction ${where}, and this store's is ${this.input.jurisdiction ?? "none"}`,
648
+ );
623
649
  await this.write("database_id = ?", [databaseId]);
624
650
  const queueId = await create("queue", row.queue_name, row.queue_id);
625
651
  await this.write("queue_id = ?", [queueId]);
@@ -632,7 +658,8 @@ class PreviewRun {
632
658
  databaseId,
633
659
  migrationsDir: join(this.staging, "migrations"),
634
660
  queue: row.queue_name,
635
- vars: this.plan.vars,
661
+ // The store's jurisdiction, never the plan's: a preview's objects live where its store's do (p0.10 §7).
662
+ vars: { ...this.plan.vars, KEDGE_JURISDICTION: this.input.jurisdiction ?? "none" },
636
663
  ...(this.plan.assets ? { assetsDir: join(this.staging, "assets") } : {}),
637
664
  });
638
665
  const configFile = join(this.staging, "..", `${row.worker}.wrangler.json`);
@@ -989,7 +1016,7 @@ export function runScenarios(cwd: string, credentials: StoreCredentials, env: No
989
1016
  );
990
1017
  const r = spawnSync(join(cwd, "node_modules", ".bin", "vitest"), ["run", "--config", config], {
991
1018
  cwd,
992
- stdio: "inherit",
1019
+ stdio: childStdio(),
993
1020
  env: { ...env, KEDGE_PREVIEW_CREDENTIALS: JSON.stringify(credentials) },
994
1021
  });
995
1022
  return r.status ?? 1;