@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/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
@@ -130,10 +118,18 @@ async function inviteOrigin(
130
118
  ): Promise<string | null> {
131
119
  const url = flag(args, "--url");
132
120
  if (url) return new URL(url).origin;
133
- if (!remote) return null;
121
+ // Local: kedge dev's own origin, the port it binds (p0.10.1 §1, 5; §3).
122
+ if (!remote) {
123
+ try {
124
+ const { devPort } = await import("./target");
125
+ return `http://localhost:${devPort(cwd)}`;
126
+ } catch {
127
+ return null;
128
+ }
129
+ }
134
130
  try {
135
131
  const worker = storeNames(cwd).names.worker;
136
- return `https://${worker}.${await (cf ?? restCloudflare(env)).workersSubdomain()}.workers.dev`;
132
+ return `https://${worker}.${await (cf ?? (await cloudflareFor(env, cwd))).workersSubdomain()}.workers.dev`;
137
133
  } catch {
138
134
  return null;
139
135
  }
@@ -145,50 +141,87 @@ async function bootstrap(
145
141
  cwd: string,
146
142
  execute: Executor,
147
143
  env: NodeJS.ProcessEnv,
148
- cf?: Cloudflare,
144
+ o: { json: boolean; emit?: (result: Record<string, unknown>) => void; cf?: Cloudflare },
149
145
  ): Promise<number> {
146
+ const cf = o.cf;
147
+ const result = (r: Record<string, unknown>) => {
148
+ if (o.json) (o.emit ?? emit)(r);
149
+ };
150
+ // A usage error is an end too: under --json it's an object, never an empty stdout (codex code r8).
151
+ const usage = (message: string) => {
152
+ console.error(message);
153
+ result({ ok: false, error: message.split("\n")[0] });
154
+ return 2;
155
+ };
156
+ // Wrangler's own words are on stderr already; the object says only that the batch didn't finish.
157
+ const failed = (status: number) => {
158
+ result({ ok: false, error: `wrangler d1 execute exited ${status}` });
159
+ return status;
160
+ };
150
161
  const config = flag(args, "--config");
162
+ const remote = args.includes("--remote");
163
+ // Resolved before any write, as every remote command does (p0.10 §4): no credentials is the
164
+ // handoff, exit 3, never Wrangler's own failure; and the batch goes where the resolver says,
165
+ // salve's broker included (codex code r2).
166
+ const auth = remote ? await resolveCloudflare(env, cwd) : null;
167
+ // A --config of another file is what Wrangler reads, so its account_id must agree too.
168
+ if (auth && config) sameAccount(auth.account, resolve(cwd, config));
169
+ const base = cloudflareBase(env);
151
170
  const opts = {
152
171
  database: flag(args, "--database") ?? "DB",
153
- remote: args.includes("--remote"),
172
+ remote,
154
173
  cwd,
155
174
  ...(config ? { config } : {}),
175
+ ...(auth
176
+ ? {
177
+ env: {
178
+ CLOUDFLARE_API_TOKEN: auth.token(),
179
+ CLOUDFLARE_ACCOUNT_ID: auth.account,
180
+ // Always, the default included: Wrangler would otherwise take one from the store's
181
+ // dotenv files and send this token there (codex code r4).
182
+ CLOUDFLARE_API_BASE_URL: base,
183
+ },
184
+ }
185
+ : {}),
156
186
  };
157
187
  const now = Date.now();
158
188
  const [sub, target] = args;
159
189
  if (command === "staff" && (sub === "invite-owner" || sub === "recover") && target && !target.startsWith("--")) {
160
190
  const b = sub === "invite-owner" ? await ownerInviteSql(target, now) : await recoverSql(target, now);
161
191
  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()}`);
192
+ if (status !== 0) return failed(status);
193
+ const expires = new Date(b.expiresAt).toISOString();
194
+ console.log(`invite ${b.inviteId} for ${target}, valid until ${expires}`);
164
195
  console.log(`accept with POST /auth/admin/invites/accept and this token (shown once):\n${b.token}`);
165
196
  const origin = await inviteOrigin(args, opts.remote, cwd, env, cf);
166
197
  // 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}`);
198
+ const link = origin ? `${origin}/app/invite#${b.token}` : null;
199
+ if (link) console.log(`or open the admin's invite page:\n${link}`);
200
+ result({ ok: true, invite: b.inviteId, email: target, expires_at: expires, token: b.token, link });
168
201
  return 0;
169
202
  }
170
203
  if (command === "keys" && sub === "create" && args.includes("--publishable")) {
171
204
  const name = flag(args, "--name");
172
- if (!name) {
173
- console.error("keys create needs --name");
174
- return 2;
175
- }
205
+ if (!name) return usage("keys create needs --name");
176
206
  const origins = args.flatMap((a, i) => (a === "--origin" && args[i + 1] ? [args[i + 1] as string] : []));
177
207
  const pepperVersion = Number(flag(args, "--pepper-version") ?? "1");
178
208
  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
- }
209
+ if (!Number.isSafeInteger(pepperVersion) || pepperVersion < 1)
210
+ return usage("--pepper-version takes a whole number from 1");
211
+ // The pepper lives in the merchant's own vault, or only in the Worker: theirs to hand over (p0.10 §5).
212
+ if (!pepper)
213
+ throw new NeedsPerson(
214
+ `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`,
215
+ );
183
216
  const b = await publishableKeySql({ name, origins, pepperVersion, pepper }, now);
184
217
  const status = execute(b, opts);
185
- if (status !== 0) return status;
218
+ if (status !== 0) return failed(status);
186
219
  console.log(`storefront ${b.storefrontId}, key ${b.keyId}`);
187
220
  console.log(`publishable key (send as x-kedge-key; shown once):\n${b.key}`);
221
+ result({ ok: true, storefront: b.storefrontId, key_id: b.keyId, key: b.key });
188
222
  return 0;
189
223
  }
190
- console.error(USAGE);
191
- return 2;
224
+ return usage(USAGE);
192
225
  }
193
226
 
194
227
  const INDEX_START = "<!-- kedge:docs-index:start -->";
@@ -218,13 +251,18 @@ function agentsIndex(cwd: string): { path: string; text: string } | null {
218
251
  return { path, text: `${current.slice(0, a + INDEX_START.length)}\n${lines.join("\n")}\n${current.slice(b)}` };
219
252
  }
220
253
 
221
- /** Skills ship in @kedge/docs and are copied, gitignored, so they always match what's installed (§9.3). */
254
+ /**
255
+ * Skills ship in @kedge/docs and are copied, gitignored, so they always match
256
+ * what's installed (§9.3): into `.claude/skills` for Claude Code, and into
257
+ * `.agents/skills`, where salve and other agents read them (p0.10 §2, §6.4).
258
+ */
222
259
  function copySkills(cwd: string): string[] {
223
260
  const docs = installedPackages(cwd).get("@kedge/docs");
224
261
  if (!docs || !existsSync(join(docs, "skills")) || !existsSync(join(cwd, ".claude"))) return [];
225
262
  const out: string[] = [];
226
263
  for (const s of readdirSync(join(docs, "skills"))) {
227
- cpSync(join(docs, "skills", s), join(cwd, ".claude", "skills", s), { recursive: true });
264
+ for (const home of [".claude", ".agents"])
265
+ cpSync(join(docs, "skills", s), join(cwd, home, "skills", s), { recursive: true });
228
266
  out.push(s);
229
267
  }
230
268
  return out;
@@ -251,29 +289,46 @@ function coreMigrationsDir(): string {
251
289
  return join(dirname(entry), "..", "migrations");
252
290
  }
253
291
 
254
- export async function main(
255
- argv: string[],
256
- cwd: string,
257
- deps: { execute?: Executor; env?: NodeJS.ProcessEnv; cf?: Cloudflare } = {},
258
- ): Promise<number> {
292
+ interface RunDeps {
293
+ execute?: Executor;
294
+ env?: NodeJS.ProcessEnv;
295
+ cf?: Cloudflare;
296
+ /** Where `--json`'s object goes; tests read it. */
297
+ emit?: (result: Record<string, unknown>) => void;
298
+ }
299
+
300
+ /** The commands an agent reads the result of (p0.10 §5); doctor has its own `--json`. */
301
+ const JSON_COMMANDS = new Set(["deploy", "preview", "staff", "keys", "seed", "smoke"]);
302
+
303
+ async function run(argv: string[], cwd: string, deps: RunDeps & { json: boolean }): Promise<number> {
259
304
  const [command, ...flags] = argv;
305
+ const out = {
306
+ json: deps.json,
307
+ ...(deps.emit ? { emit: deps.emit } : {}),
308
+ ...(deps.execute ? { execute: deps.execute } : {}),
309
+ };
260
310
  if (command === "keys" && flag(flags, "--url")) {
261
- const d0: CommandDeps = { cwd, env: deps.env ?? process.env, coreDir: coreMigrationsDir() };
311
+ const d0: CommandDeps = { cwd, env: deps.env ?? process.env, coreDir: coreMigrationsDir(), ...out };
262
312
  return keysOverHttp(flags, d0);
263
313
  }
264
314
  if (command === "staff" || command === "keys")
265
- return bootstrap(command, flags, cwd, deps.execute ?? wranglerExecute, deps.env ?? process.env, deps.cf);
315
+ return bootstrap(command, flags, cwd, deps.execute ?? wranglerExecute, deps.env ?? process.env, {
316
+ ...out,
317
+ ...(deps.cf ? { cf: deps.cf } : {}),
318
+ });
266
319
  const d: CommandDeps = {
267
320
  cwd,
268
321
  env: deps.env ?? process.env,
269
322
  coreDir: coreMigrationsDir(),
270
323
  checkFast: () => main(["check", "--fast"], cwd),
324
+ ...out,
271
325
  };
272
326
  if (command === "deploy") return deployCommand(flags, d);
273
327
  if (command === "migrate") return migrateCommand(flags, d);
274
328
  if (command === "restore") return restoreCommand(flags, d);
275
329
  if (command === "secret") return secretCommand(flags, d);
276
330
  if (command === "seed") return seedCommand(flags, d);
331
+ if (command === "smoke") return smokeCommand(flags, d);
277
332
  if (command === "preview") return previewCommand(flags, d);
278
333
  if (command === "eject") return ejectCommand(flags, d);
279
334
  if (command === "upgrade")
@@ -302,11 +357,44 @@ export async function main(
302
357
  if (command === "doctor") {
303
358
  const results = await doctor({ cwd, coreDir: coreMigrationsDir() });
304
359
  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() })));
360
+ let auth: Awaited<ReturnType<typeof resolveCloudflare>> | null = null;
361
+ try {
362
+ auth = await resolveCloudflare(d.env, cwd);
363
+ } catch (e) {
364
+ // No credentials is a check that failed and a handoff, with the offline report still printed (codex code r8).
365
+ if (!(e instanceof NeedsPerson)) throw e;
366
+ results.push({ name: "credentials", status: "fail", message: e.handoff, handoff: e.handoff });
367
+ }
368
+ if (auth) {
369
+ const cf = restCloudflare(d.env, fetch, wranglerBin(), auth);
370
+ const lock = JSON.parse(readFileSync(join(cwd, "kedge.lock.json"), "utf8")) as Lock;
371
+ const creds = await credentialsChecks({ cf, source: auth.source, base: cloudflareBase(d.env) });
372
+ results.push(...creds);
373
+ // Credentials that can't read the account would make every later check throw, not report (codex code r7).
374
+ if (creds.some((c) => c.status === "fail"))
375
+ results.push({
376
+ name: "remote",
377
+ status: "skip",
378
+ message: "the store's resources weren't read: fix the credentials first",
379
+ });
380
+ else results.push(...(await doctorRemote({ cwd, cf, lock, logpushJobs: () => cf.logpushJobs() })));
381
+ }
382
+ }
383
+ const handoff = results.find((r) => r.handoff)?.handoff;
384
+ // The same envelope as every --json command (p0.10.1 §1, 7): ok, the checks, and a handoff when there is one.
385
+ console.log(
386
+ flags.includes("--json")
387
+ ? JSON.stringify({
388
+ ok: !results.some((r) => r.status === "fail"),
389
+ checks: results,
390
+ ...(handoff ? { handoff } : {}),
391
+ })
392
+ : formatReport(results),
393
+ );
394
+ if (handoff) {
395
+ console.error(`for you: ${handoff}`);
396
+ return 3;
308
397
  }
309
- console.log(flags.includes("--json") ? JSON.stringify({ checks: results }, null, 2) : formatReport(results));
310
398
  return results.some((r) => r.status === "fail") ? 1 : 0;
311
399
  }
312
400
  if (command === "check")
@@ -376,3 +464,25 @@ export async function main(
376
464
  throw e;
377
465
  }
378
466
  }
467
+
468
+ /**
469
+ * The CLI. A handoff from any command ends it with exit 3 and one sentence for
470
+ * whoever runs it to pass on (p0.10 §5).
471
+ */
472
+ export async function main(argv: string[], cwd: string, deps: RunDeps = {}): Promise<number> {
473
+ const [command, ...flags] = argv;
474
+ const json = flags.includes("--json") && JSON_COMMANDS.has(command ?? "");
475
+ return withJsonOutput(json, async () => {
476
+ try {
477
+ return await run(argv, cwd, { ...deps, json });
478
+ } catch (e) {
479
+ // What escapes a command's own handling (bootstrap, doctor) ends the same way (p0.10 §5).
480
+ const f = failureOf(e);
481
+ if (!f) throw e;
482
+ console.error(f.line);
483
+ // doctor keeps its own --json report, and a failure past it is still one object (p0.10.1, codex code r20).
484
+ if (json || (command === "doctor" && flags.includes("--json"))) (deps.emit ?? emit)(f.result);
485
+ return f.code;
486
+ }
487
+ });
488
+ }
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,44 @@
1
+ // `--json` (p0.10 §5): stdout carries one object when the command ends, and
2
+ // nothing else, so an agent parses it whole. stderr carries only what the
3
+ // agent must read: a refusal, a handoff's `for you:` line, or a child's failure.
4
+ // Progress is dropped, and every child the CLI starts (Wrangler, the store's
5
+ // checks and scenarios) is captured and shown, its last lines on stderr, only
6
+ // when it fails: an agent's tool shows both streams as one, so anything chatty
7
+ // there reads as part of the answer (p0.10.1 §1, 7).
8
+ import type { SpawnSyncReturns, StdioOptions } from "node:child_process";
9
+
10
+ let reserved = false;
11
+
12
+ /** Runs `work` with stdout reserved for `emit` when `on`; nested runs keep the outer setting. */
13
+ export async function withJsonOutput<T>(on: boolean, work: () => Promise<T>): Promise<T> {
14
+ if (!on || reserved) return work();
15
+ const log = console.log;
16
+ const info = console.info;
17
+ reserved = true;
18
+ console.log = () => {};
19
+ console.info = () => {};
20
+ try {
21
+ return await work();
22
+ } finally {
23
+ reserved = false;
24
+ console.log = log;
25
+ console.info = info;
26
+ }
27
+ }
28
+
29
+ /** The command's one result object. */
30
+ export function emit(result: Record<string, unknown>): void {
31
+ process.stdout.write(`${JSON.stringify(result)}\n`);
32
+ }
33
+
34
+ /** A child's stdio: inherited, or under `--json` captured, for `childFailed` to show if it fails. */
35
+ export function childStdio(): StdioOptions {
36
+ return reserved ? ["ignore", "pipe", "pipe"] : "inherit";
37
+ }
38
+
39
+ /** Under `--json`, a captured child that failed shows its last lines on stderr; inherited output was already seen. */
40
+ export function childFailed(r: Pick<SpawnSyncReturns<string | Buffer>, "status" | "stdout" | "stderr">): void {
41
+ if (!reserved || r.status === 0) return;
42
+ const out = `${r.stdout?.toString() ?? ""}${r.stderr?.toString() ?? ""}`.trim();
43
+ if (out) process.stderr.write(`${out.split("\n").slice(-40).join("\n")}\n`);
44
+ }
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 { childFailed, 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,9 +1016,11 @@ 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) },
1021
+ maxBuffer: 1 << 26,
994
1022
  });
1023
+ childFailed(r);
995
1024
  return r.status ?? 1;
996
1025
  } finally {
997
1026
  rmSync(tmp, { recursive: true, force: true });