@kedge/cli 0.10.0 → 0.11.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
@@ -6,6 +6,7 @@
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
12
  import { isPermission, type KedgeConfig } from "@kedge/core";
@@ -20,7 +21,7 @@ import {
20
21
  UnknownOutcome,
21
22
  wranglerBin,
22
23
  } from "./cf";
23
- import { isCliSession, OLDER_KEDGE, type SessionDeps, withCliSession } from "./cli-session";
24
+ import { isCliSession, OLDER_KEDGE, proveTarget, type SessionDeps, withCliSession } from "./cli-session";
24
25
  import { ComposeError, compose, type Lock } from "./compose";
25
26
  import { type Executor, wranglerExecute } from "./d1-batch";
26
27
  import {
@@ -41,7 +42,7 @@ import { childStdio, emit } from "./output";
41
42
  import { needsLive, smoke } from "./smoke";
42
43
  import { git, storeNames } from "./store";
43
44
  import { storeRequest } from "./store-api";
44
- import { resolveTarget, type StoreTarget } from "./target";
45
+ import { devPort, resolveTarget, type StoreTarget } from "./target";
45
46
  import { compileStoreZone } from "./tz";
46
47
 
47
48
  export interface CommandDeps {
@@ -267,7 +268,7 @@ export async function deployCommand(args: readonly string[], d: CommandDeps): Pr
267
268
  );
268
269
  // Adoption takes the store's jurisdiction from what exists, so it asks nothing; a choice is checked against it.
269
270
  const choice = flag(args, "--jurisdiction");
270
- await preflight(cf);
271
+ await preflight(cf, undefined, undefined, readWrangler(d.cwd)?.config.workers_dev !== false);
271
272
  if (choice !== undefined && !isJurisdictionChoice(choice))
272
273
  throw new DeployRefused(`--jurisdiction takes eu, us, fedramp or none, not "${choice}"`);
273
274
  return dep.adoptExisting(
@@ -279,7 +280,7 @@ export async function deployCommand(args: readonly string[], d: CommandDeps): Pr
279
280
  const window = windowOf(flag(args, "--maintenance-window"));
280
281
  const takeOver = flag(args, "--take-over");
281
282
  const choice = flag(args, "--jurisdiction");
282
- await preflight(cf, dep.names.ops, choice);
283
+ await preflight(cf, dep.names.ops, choice, readWrangler(d.cwd)?.config.workers_dev !== false);
283
284
  await dep.deploy({
284
285
  ...(choice ? { jurisdiction: choice as JurisdictionChoice } : {}),
285
286
  vars: varsOf(args),
@@ -290,26 +291,26 @@ export async function deployCommand(args: readonly string[], d: CommandDeps): Pr
290
291
  ...(window ? { window } : {}),
291
292
  ...(takeOver ? { takeOver } : {}),
292
293
  });
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) };
294
+ // From the account's subdomain now, never a recorded URL: its owner can change it (p0.10 §4). Only where the
295
+ // Worker is served there: a platform with its own domain answers an account subdomain too (p0.11 row 2).
296
+ const url = await cf.workersDevUrl(dep.names.worker);
297
+ if (!url) (d.log ?? console.log)(PLATFORM_URL);
298
+ return { worker: dep.names.worker, account: cf.account, url, admin: url ? adminUrl(d.cwd, url) : null };
296
299
  });
297
300
  }
298
301
 
302
+ /** What deploy says when the store isn't at workers.dev: its platform knows where it is. */
303
+ export const PLATFORM_URL =
304
+ "this store isn't served at workers.dev: its address is the one its host gives (salve hosting: hosting_status); use that https URL as --url";
305
+
299
306
  /** The admin's address, when the store serves it (p0.9 §3.3); null when it serves none. */
300
307
  function adminUrl(cwd: string, url: string): string | null {
301
308
  return readWrangler(cwd)?.config.assets ? `${url}/app/` : null;
302
309
  }
303
310
 
304
311
  export async function migrateCommand(args: readonly string[], d: CommandDeps): Promise<number> {
305
- if (!args.includes("--remote")) {
306
- // Local: the dev database, through Wrangler's own bookkeeping.
307
- const r = spawnSync(process.execPath, [wranglerBin(), "d1", "migrations", "apply", "DB", "--local"], {
308
- cwd: d.cwd,
309
- stdio: childStdio(),
310
- });
311
- return r.status ?? 1;
312
- }
312
+ // Local: the dev database, through Wrangler's own bookkeeping.
313
+ if (!args.includes("--remote")) return migrateLocal(d);
313
314
  return refusals(d, async () => (await deployer(d)).migrateRemote());
314
315
  }
315
316
 
@@ -444,11 +445,18 @@ const SAMPLE_APPAREL = [
444
445
  const isLocal = (url: string) => ["localhost", "127.0.0.1", "[::1]"].includes(new URL(url).hostname);
445
446
 
446
447
  /** 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
+ const unprovedOf = (url: string, d: CommandDeps) => resolveTarget(url, d.cwd, () => cloudflareOf(d));
449
+ const targetOf = async (url: string, d: CommandDeps) => {
450
+ const t = await unprovedOf(url, d);
451
+ // A URL the CLI didn't derive is proved before anything else goes there, a saved key included (p0.11 §3).
452
+ if (t?.proof) await proveTarget(t, sessionDeps(d));
453
+ return t;
454
+ };
448
455
 
449
456
  /** How a CLI session's row is written (p0.10 §6.2). */
450
457
  const sessionDeps = (d: CommandDeps): SessionDeps => ({
451
458
  cloudflare: () => cloudflareOf(d),
459
+ fetch: d.fetch ?? fetch,
452
460
  executeLocal: (b) => (d.execute ?? wranglerExecute)(b, { database: "DB", remote: false, cwd: d.cwd }),
453
461
  });
454
462
 
@@ -500,6 +508,7 @@ function authOf(args: readonly string[]): Record<string, string> {
500
508
  async function signOutBearer(url: string, token: string, d: CommandDeps): Promise<void> {
501
509
  await (d.fetch ?? fetch)(new URL("/auth/admin/sign-out", url), {
502
510
  method: "POST",
511
+ redirect: "manual",
503
512
  headers: { authorization: `Bearer ${token}` },
504
513
  }).catch(() => undefined);
505
514
  }
@@ -574,7 +583,8 @@ export async function seedCommand(args: readonly string[], d: CommandDeps): Prom
574
583
  // customers would see sample products, so it's the owner's call (p0.10 §5).
575
584
  let target: StoreTarget | null = null;
576
585
  if (!isLocal(url) && !args.includes("--live")) {
577
- target = await targetOf(url, d);
586
+ // Only a preview goes on from here, and a preview's origin is derived, never proved: anything else is a handoff.
587
+ target = await unprovedOf(url, d);
578
588
  if (target?.kind !== "preview")
579
589
  throw new NeedsPerson(
580
590
  `${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`,
@@ -601,9 +611,11 @@ export async function smokeCommand(args: readonly string[], d: CommandDeps): Pro
601
611
  const name = flag(args, "--run") ?? "first";
602
612
  if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(name))
603
613
  throw new DeployRefused("--run takes a name of letters, digits, - and _, up to 64");
604
- const target = await targetOf(url, d);
614
+ // The live store's handoff comes before any proof: nothing goes to a store its owner hasn't allowed.
615
+ const target = await unprovedOf(url, d);
605
616
  if (!target) throw new DeployRefused(`${url} isn't a store this checkout knows: smoke runs only on its own stores`);
606
617
  needsLive(target, args.includes("--live"));
618
+ if (target.proof) await proveTarget(target, sessionDeps(d));
607
619
  return smoke(target, name, {
608
620
  cwd: d.cwd,
609
621
  fetch: d.fetch ?? fetch,
@@ -743,7 +755,41 @@ export function devArgs(cwd: string): string[] {
743
755
  return id ? ["--define", `KEDGE_ADMIN_BUILD:${JSON.stringify(id)}`] : [];
744
756
  }
745
757
 
746
- export function devCommand(d: CommandDeps): number {
758
+ /**
759
+ * The local database's migrations, through Wrangler's own bookkeeping, in one
760
+ * line: Wrangler prints its whole status table again after each migration,
761
+ * thousands of lines for a new store (p0.10.1 §1, 8). Its full output is shown
762
+ * only when it fails.
763
+ */
764
+ export function migrateLocal(d: Pick<CommandDeps, "cwd" | "log" | "error">): number {
765
+ const r = spawnSync(process.execPath, [wranglerBin(), "d1", "migrations", "apply", "DB", "--local"], {
766
+ cwd: d.cwd,
767
+ encoding: "utf8",
768
+ env: { ...process.env, WRANGLER_SEND_METRICS: "false" },
769
+ maxBuffer: 1 << 26,
770
+ });
771
+ const out = `${r.stdout ?? ""}${r.stderr ?? ""}`;
772
+ if (r.status !== 0) {
773
+ (d.error ?? console.error)(out.trim().split("\n").slice(-40).join("\n"));
774
+ return r.status ?? 1;
775
+ }
776
+ const n = new Set(out.match(/\b\d{4}_[a-z0-9_]+\.sql\b/g) ?? []).size;
777
+ (d.log ?? console.log)(
778
+ n ? `applied ${n} migration${n === 1 ? "" : "s"} to the local database` : "the local database is up to date",
779
+ );
780
+ return 0;
781
+ }
782
+
783
+ /** Whether something already listens on the port: Wrangler would otherwise move to another, silently. */
784
+ function portTaken(port: number): Promise<boolean> {
785
+ return new Promise((resolve) => {
786
+ const server = createServer();
787
+ server.once("error", () => resolve(true));
788
+ server.listen(port, "127.0.0.1", () => server.close(() => resolve(false)));
789
+ });
790
+ }
791
+
792
+ export async function devCommand(d: CommandDeps): Promise<number> {
747
793
  const vars = join(d.cwd, ".dev.vars");
748
794
  if (!existsSync(vars)) {
749
795
  const lines = [
@@ -759,11 +805,16 @@ export function devCommand(d: CommandDeps): number {
759
805
  });
760
806
  (d.log ?? console.log)("wrote .dev.vars with fresh local secrets");
761
807
  }
762
- const migrated = spawnSync(process.execPath, [wranglerBin(), "d1", "migrations", "apply", "DB", "--local"], {
763
- cwd: d.cwd,
764
- stdio: "inherit",
765
- });
766
- if (migrated.status !== 0) return migrated.status ?? 1;
808
+ // One port for dev, its invite link and the CLI's local target (p0.10.1 §3): taken is a refusal, never a move.
809
+ const port = devPort(d.cwd);
810
+ if (await portTaken(port)) {
811
+ (d.error ?? console.error)(
812
+ `port ${port} is in use: stop what's on it, or set "dev": { "port": <another> } in wrangler.jsonc`,
813
+ );
814
+ return 1;
815
+ }
816
+ const migrated = migrateLocal(d);
817
+ if (migrated !== 0) return migrated;
767
818
  let extra: string[];
768
819
  try {
769
820
  extra = devArgs(d.cwd);
@@ -771,7 +822,20 @@ export function devCommand(d: CommandDeps): number {
771
822
  (d.log ?? console.log)((e as Error).message);
772
823
  return 1;
773
824
  }
774
- return spawnSync(process.execPath, [wranglerBin(), "dev", ...extra], { cwd: d.cwd, stdio: "inherit" }).status ?? 0;
825
+ const origin = `http://localhost:${port}`;
826
+ // Where the store and its admin are, and how a person signs in to a new store's admin (p0.10.1 §1, 5).
827
+ (d.log ?? console.log)(
828
+ [
829
+ `the store: ${origin} its admin: ${origin}/app/`,
830
+ "no one can sign in to the admin until there's an Owner: pnpm kedge staff invite-owner <your email>",
831
+ ].join("\n"),
832
+ );
833
+ return (
834
+ spawnSync(process.execPath, [wranglerBin(), "dev", "--port", String(port), ...extra], {
835
+ cwd: d.cwd,
836
+ stdio: "inherit",
837
+ }).status ?? 0
838
+ );
775
839
  }
776
840
 
777
841
  export const removeIfExists = (p: string) => rmSync(p, { recursive: true, force: true });
package/src/d1-batch.ts CHANGED
@@ -7,7 +7,7 @@ import { tmpdir } from "node:os";
7
7
  import { join } from "node:path";
8
8
  import type { CliBatch } from "@kedge/db";
9
9
  import { wranglerBin } from "./cf";
10
- import { childStdio } from "./output";
10
+ import { childFailed, childStdio } from "./output";
11
11
 
12
12
  /** Runs one CLI batch against the store's D1; tests replace it. */
13
13
  export type Executor = (
@@ -56,8 +56,10 @@ export const wranglerExecute: Executor = (b, opts) => {
56
56
  cwd: opts.cwd,
57
57
  stdio: childStdio(),
58
58
  env: bootstrapEnv(process.env, dir, opts.env),
59
+ maxBuffer: 1 << 26,
59
60
  },
60
61
  );
62
+ childFailed(r);
61
63
  return r.status ?? 1;
62
64
  } finally {
63
65
  // The file holds no secret (only digests), but it needn't outlive the run.
package/src/deploy.ts CHANGED
@@ -37,9 +37,16 @@ import {
37
37
  sha256,
38
38
  storeNames,
39
39
  type WranglerJson,
40
+ withDatabaseId,
40
41
  } from "./store";
41
42
 
42
43
  export const SCOPE = "production";
44
+ /**
45
+ * The settings a release leaves: no Logpush, no invocation logs, query strings
46
+ * redacted, because a logged or thrown request is recorded with its URL, which
47
+ * erasure can't reach (docs/10; p0.11 row 18).
48
+ */
49
+ const RELEASE_SETTINGS: WorkerSettings = { logpush: false, invocationLogs: false, redactQueryString: true };
43
50
  /** Core's secrets, generated only at a first deploy and only where missing (§6.1). */
44
51
  export const CORE_SECRETS = [
45
52
  "CART_TOKEN_SECRET",
@@ -714,6 +721,25 @@ export class Deployer {
714
721
  else blocking.push(i);
715
722
  continue;
716
723
  }
724
+ if (i.kind === "triggers") {
725
+ // A schedule PUT or a settings PATCH sets whole values, so a read-back showing them settles it,
726
+ // and a lost answer to one that landed doesn't block --resume (salve's codex r7). A mismatch
727
+ // stays unknown: replaying could race a late landing of the original.
728
+ const d = JSON.parse(i.detail) as { crons?: string[] } & Partial<WorkerSettings>;
729
+ const shows = d.crons
730
+ ? JSON.stringify(await this.d.cf.schedules(this.names.worker)) === JSON.stringify(d.crons)
731
+ : await this.d.cf
732
+ .settings(this.names.worker)
733
+ .then((s) =>
734
+ (["logpush", "invocationLogs", "redactQueryString"] as const).every(
735
+ (k) => d[k] === undefined || s[k] === d[k],
736
+ ),
737
+ );
738
+ if (shows) {
739
+ await this.ops.closeIntent(i.id, "done", "a read-back shows what it set");
740
+ continue;
741
+ }
742
+ }
717
743
  if (i.kind === "activate") {
718
744
  const deps = await this.d.cf.deployments(this.names.worker);
719
745
  const landed = deps.find((d) => d.message?.includes(i.tag));
@@ -918,8 +944,9 @@ export class Deployer {
918
944
  if (!existsSync(file)) return;
919
945
  const text = readFileSync(file, "utf8");
920
946
  if (this.names.databaseId === PLACEHOLDER_ID) {
921
- if (text.includes(PLACEHOLDER_ID)) {
922
- writeFileSync(file, text.replace(PLACEHOLDER_ID, id));
947
+ const written = withDatabaseId(text, id);
948
+ if (written !== text) {
949
+ writeFileSync(file, written);
923
950
  this.log(`wrangler.jsonc now names database ${id}: commit it`);
924
951
  }
925
952
  } else if (this.names.databaseId !== id)
@@ -1015,10 +1042,9 @@ export class Deployer {
1015
1042
  const cf = this.d.cf;
1016
1043
  const applied = new Set(await appliedNames(cf, p.databaseId as string));
1017
1044
  const pending = (p.pending ?? []).filter((f) => !applied.has(f));
1018
- for (const f of [...applied]) {
1019
- const e = this.d.lock.entries.find((x) => x.file === f);
1020
- if (e) await this.ops.recordApplied(f, e.sha256);
1021
- }
1045
+ await this.ops.recordApplied(
1046
+ this.d.lock.entries.filter((e) => applied.has(e.file)).map((e) => [e.file, e.sha256] as const),
1047
+ );
1022
1048
  if (pending.length === 0) return;
1023
1049
  await this.step("migrate");
1024
1050
  const dir = this.dir(this.opId);
@@ -1047,7 +1073,9 @@ export class Deployer {
1047
1073
  );
1048
1074
  } finally {
1049
1075
  const now = new Set(await appliedNames(cf, p.databaseId as string));
1050
- for (const f of pending) if (now.has(f)) await this.ops.recordApplied(f, this.plan.files[f] as string);
1076
+ await this.ops.recordApplied(
1077
+ pending.filter((f) => now.has(f)).map((f) => [f, this.plan.files[f] as string] as const),
1078
+ );
1051
1079
  }
1052
1080
  }
1053
1081
 
@@ -1116,7 +1144,7 @@ export class Deployer {
1116
1144
  }
1117
1145
  if (!classChange) await this.activate("activate", p.appVersion as string);
1118
1146
  await this.markReleased();
1119
- await this.triggers(this.plan.config.triggers?.crons ?? [], { logpush: false, invocationLogs: false });
1147
+ await this.triggers(this.plan.config.triggers?.crons ?? [], RELEASE_SETTINGS);
1120
1148
  }
1121
1149
 
1122
1150
  /** Crons and settings, each a single-attempt call read back before it counts (§3.1). */
@@ -1132,13 +1160,17 @@ export class Deployer {
1132
1160
  throw new DeployRefused("the cron schedules didn't read back as set");
1133
1161
  }
1134
1162
  const s = await cf.settings(this.names.worker);
1135
- if (s.logpush !== settings.logpush || s.invocationLogs !== settings.invocationLogs) {
1163
+ if (
1164
+ s.logpush !== settings.logpush ||
1165
+ s.invocationLogs !== settings.invocationLogs ||
1166
+ s.redactQueryString !== settings.redactQueryString
1167
+ ) {
1136
1168
  await this.step("settings");
1137
1169
  await this.intent("settings", "triggers", this.tag("settings"), settings, () =>
1138
1170
  cf.patchSettings(this.names.worker, settings),
1139
1171
  );
1140
1172
  const back = await cf.settings(this.names.worker);
1141
- if (back.invocationLogs !== settings.invocationLogs)
1173
+ if (back.invocationLogs !== settings.invocationLogs || back.redactQueryString !== settings.redactQueryString)
1142
1174
  throw new DeployRefused("the Worker's settings didn't read back as set");
1143
1175
  }
1144
1176
  }
@@ -1341,7 +1373,9 @@ export class Deployer {
1341
1373
  );
1342
1374
  await this.reconcile();
1343
1375
  await this.activate("abort", before.versionId);
1344
- await this.triggers(before.crons ?? [], before.settings ?? { logpush: false, invocationLogs: false });
1376
+ // Redaction stays on through a rollback: a "before" read under 0.10 never
1377
+ // recorded it, and turning it off would put query strings back in the logs.
1378
+ await this.triggers(before.crons ?? [], { ...(before.settings ?? RELEASE_SETTINGS), redactQueryString: true });
1345
1379
  await ops.completeDeployment(p.deploymentId as string, {
1346
1380
  ...before,
1347
1381
  commit: servingCommit((await ops.deployments()).filter((r) => r.id !== p.deploymentId)),
@@ -1612,8 +1646,9 @@ export class Deployer {
1612
1646
  const qid = await cf.findQueue(this.names.queue);
1613
1647
  if (qid) await ops.recordResource("queue", this.names.queue, qid, "ours");
1614
1648
  await ops.recordResource("worker", this.names.worker, null, "ours");
1615
- for (const f of applied)
1616
- await ops.recordApplied(f, (this.d.lock.entries.find((e) => e.file === f) as LockEntry).sha256);
1649
+ await ops.recordApplied(
1650
+ applied.map((f) => [f, (this.d.lock.entries.find((e) => e.file === f) as LockEntry).sha256] as const),
1651
+ );
1617
1652
  const half: Half = {
1618
1653
  lockSha: sha256(
1619
1654
  JSON.stringify(this.d.lock.entries.filter((e) => applied.includes(e.file)).map((e) => [e.file, e.sha256])),
package/src/doctor.ts CHANGED
@@ -50,7 +50,8 @@ type WranglerConfig = {
50
50
  renamed_classes?: { from: string; to: string }[];
51
51
  }[];
52
52
  queues?: { producers?: { binding?: string }[] };
53
- observability?: { enabled?: boolean; logs?: { invocation_logs?: boolean } };
53
+ observability?: { enabled?: boolean; redact_query_string?: boolean; logs?: { invocation_logs?: boolean } };
54
+ workers_dev?: boolean;
54
55
  assets?: { directory?: string; binding?: string; not_found_handling?: string; run_worker_first?: unknown };
55
56
  env?: Record<string, { observability?: WranglerConfig["observability"] }>;
56
57
  };
@@ -382,8 +383,11 @@ export async function doctor(input: DoctorInput): Promise<CheckResult[]> {
382
383
  problems.length ? fail("wrangler", "wrangler.jsonc isn't what a Kedge Worker needs", problems) : pass("wrangler"),
383
384
  );
384
385
 
385
- // invocation-logs: Cloudflare's per-request log records the URL, which erasure can't reach.
386
- const off = (o: WranglerConfig["observability"]) => o?.logs?.invocation_logs === false;
386
+ // invocation-logs: Cloudflare records a request with its URL, which erasure can't reach: every
387
+ // request with invocation logs on, and one that logs or throws with them off unless its query
388
+ // string is redacted (p0.11 row 18).
389
+ const off = (o: WranglerConfig["observability"]) =>
390
+ o?.logs?.invocation_logs === false && o.redact_query_string === true;
387
391
  const where = [
388
392
  ...(off(c.observability) ? [] : ["the top level"]),
389
393
  ...Object.entries(c.env ?? {})
@@ -394,7 +398,7 @@ export async function doctor(input: DoctorInput): Promise<CheckResult[]> {
394
398
  where.length
395
399
  ? fail(
396
400
  "invocation-logs",
397
- `set "observability": { "logs": { "invocation_logs": false } } at ${where.join(", ")}: invocation logs record each request's URL, which can carry a customer's data that erasure can't reach`,
401
+ `set "observability": { "redact_query_string": true, "logs": { "invocation_logs": false } } at ${where.join(", ")}: Cloudflare records a request with its URL, which can carry a customer's data that erasure can't reach`,
398
402
  )
399
403
  : pass("invocation-logs"),
400
404
  );
@@ -461,13 +465,21 @@ export async function credentialsChecks(input: {
461
465
  cf: import("./cf").Cloudflare;
462
466
  source: string;
463
467
  base: string;
468
+ /** Whether the store is served at workers.dev; when it isn't, a missing subdomain is no failure (codex code r3). */
469
+ workersDev?: boolean;
464
470
  }): Promise<CheckResult[]> {
465
471
  const { NeedsPerson } = await import("./cf");
466
472
  const where = `from ${input.source}, sent to ${input.base}, account ${input.cf.account}`;
467
473
  const read = await input.cf.readable();
468
- const refused = Object.entries(read).filter(([, status]) => status !== null);
474
+ const refused = Object.entries(read).filter(([, status]) => status !== null && status !== "not served");
475
+ // A list the platform doesn't serve (salve hosting's Workers) is reported, never a failed credential (p0.11 row 5).
476
+ const notServed = Object.entries(read)
477
+ .filter(([, s]) => s === "not served")
478
+ .map(([kind]) => kind);
479
+ const lists = (["Workers", "D1", "Queues"] as const).filter((k) => !notServed.includes(k));
480
+ const unserved = notServed.length ? `; ${notServed.join(" and ")} listing not available here` : "";
469
481
  const out: CheckResult[] = [];
470
- if (refused.length === Object.keys(read).length && refused.every(([, s]) => s === 401 || s === 403))
482
+ if (refused.length && refused.length === lists.length && refused.every(([, s]) => s === 401 || s === 403))
471
483
  out.push(fail("credentials", `the token doesn't answer for this account (${where})`));
472
484
  else if (refused.length)
473
485
  out.push(
@@ -481,7 +493,7 @@ export async function credentialsChecks(input: {
481
493
  out.push(
482
494
  pass(
483
495
  "credentials",
484
- `${where}; lists Workers, D1 and Queues. Writes are unverified until the first deploy: Workers Scripts Edit, D1 Edit, Queues Edit`,
496
+ `${where}; lists ${lists.join(", ").replace(/, ([^,]*)$/, " and $1")}${unserved}. Writes are unverified until the first deploy: Workers Scripts Edit, D1 Edit, Queues Edit`,
485
497
  ),
486
498
  );
487
499
  try {
@@ -489,7 +501,9 @@ export async function credentialsChecks(input: {
489
501
  } catch (e) {
490
502
  // Any answer is a report: a token that can't read the subdomain fails this check, never the run (codex code r7).
491
503
  // An account without one is the owner's to choose: a handoff, not a technical failure (codex code r9).
492
- if (e instanceof NeedsPerson) out.push({ ...fail("workers.dev", e.handoff), handoff: e.handoff });
504
+ if (e instanceof NeedsPerson && input.workersDev === false)
505
+ out.push(skip("workers.dev", 'none, and none needed: wrangler.jsonc says "workers_dev": false'));
506
+ else if (e instanceof NeedsPerson) out.push({ ...fail("workers.dev", e.handoff), handoff: e.handoff });
493
507
  else out.push(fail("workers.dev", (e as Error).message));
494
508
  }
495
509
  return out;
@@ -549,7 +563,9 @@ export async function doctorRemote(input: RemoteDoctorInput): Promise<CheckResul
549
563
  if (last.crons && JSON.stringify(crons) !== JSON.stringify(last.crons))
550
564
  problems.push(`crons are ${JSON.stringify(crons)}, recorded ${JSON.stringify(last.crons)}`);
551
565
  const settings = await input.cf.settings(names.worker);
552
- if (last.settings && JSON.stringify(settings) !== JSON.stringify(last.settings))
566
+ // Only what was recorded: a 0.10 deployment recorded no redaction, and isn't drift for lacking it.
567
+ const recorded = last.settings;
568
+ if (recorded && (Object.keys(recorded) as (keyof typeof recorded)[]).some((k) => settings[k] !== recorded[k]))
553
569
  problems.push(`settings are ${JSON.stringify(settings)}, recorded ${JSON.stringify(last.settings)}`);
554
570
  const held = await ops.current(SCOPE);
555
571
  out.push(
@@ -560,6 +576,15 @@ export async function doctorRemote(input: RemoteDoctorInput): Promise<CheckResul
560
576
  held ? `operation ${held.op_id} (${held.kind}) holds the store at "${held.step}"` : undefined,
561
577
  ),
562
578
  );
579
+ // workers-dev: Wrangler enables workers.dev after a deploy lands, and a platform serving the store elsewhere
580
+ // refuses that (salve hosting, 400/10400), so every `wrangler deploy` would exit non-zero after landing.
581
+ if (readWrangler(input.cwd)?.config.workers_dev !== false && !(await input.cf.workersDevUrl(names.worker)))
582
+ out.push(
583
+ fail(
584
+ "workers-dev",
585
+ `wrangler.jsonc has "workers_dev": true, but ${names.worker} isn't served at workers.dev: on a host that serves it at its own address (salve hosting), set "workers_dev": false and commit it`,
586
+ ),
587
+ );
563
588
  // do-classes: rollback across a class change is blocked (a warning).
564
589
  const planned = (readWrangler(input.cwd)?.config.migrations ?? []) as unknown[];
565
590
  if (JSON.stringify(planned) !== JSON.stringify(last.doMigrations ?? []))
package/src/keys-save.ts CHANGED
@@ -11,7 +11,7 @@ import type { DatabaseSync } from "node:sqlite";
11
11
  import { NeedsPerson } from "./cf";
12
12
  import { OLDER_KEDGE, type SessionDeps, withCliSession } from "./cli-session";
13
13
  import { DeployRefused } from "./deploy";
14
- import { type RefRecord, readEnvLocal, saveEnv, stateDb, withStoreLock } from "./local-state";
14
+ import { ENV_FILE, type RefRecord, readEnvLocal, saveEnv, stateDb, withStoreLock } from "./local-state";
15
15
  import { storeRequest } from "./store-api";
16
16
  import type { StoreTarget } from "./target";
17
17
 
@@ -85,7 +85,10 @@ async function save(db: DatabaseSync, target: StoreTarget, wanted: Wanted, d: Sa
85
85
  // digest, never by a label beside it: a key with no such ref, or one for another store, would be sent
86
86
  // to the wrong store or relabelled as this one's (codex code r11, r13). So any such key, of either
87
87
  // kind, is the person's to remove before anything is saved beside it.
88
- const env = readEnvLocal(d.cwd);
88
+ moveLocalBundle(db, d);
89
+ // Each store's keys in its own file (p0.10.1 §4): kedge dev's apart from Cloudflare's.
90
+ const envFile = target.kind === "local" ? ENV_FILE.local : ENV_FILE.production;
91
+ const env = readEnvLocal(d.cwd, envFile);
89
92
  const all = db.prepare("SELECT * FROM credential_ref").all() as unknown as RefRecord[];
90
93
  const foreign: string[] = [];
91
94
  for (const name of [ENV.publishable, ENV.secret]) {
@@ -104,7 +107,7 @@ async function save(db: DatabaseSync, target: StoreTarget, wanted: Wanted, d: Sa
104
107
  if (sf && !all.some((r) => r.store === store && r.storefront_id === sf)) foreign.push(ENV.storefront);
105
108
  if (foreign.length)
106
109
  throw new NeedsPerson(
107
- `.env.local holds ${foreign.join(", ")} that this checkout didn't make for this store: remove ${foreign.length === 1 ? "that line" : "those lines"}, or save this store's keys in its own checkout, then run this again`,
110
+ `${envFile} holds ${foreign.join(", ")} that this checkout didn't make for this store: remove ${foreign.length === 1 ? "that line" : "those lines"}, or save this store's keys in its own checkout, then run this again`,
108
111
  );
109
112
  const saved = env[envName];
110
113
  if (saved && (await works(target, wanted, saved, d))) {
@@ -172,7 +175,7 @@ async function save(db: DatabaseSync, target: StoreTarget, wanted: Wanted, d: Sa
172
175
  // 4. Saved before anything else happens, then marked saved.
173
176
  const values: Record<string, string> = { [ENV.store]: store, [envName]: made.key };
174
177
  if (made.storefront_id) values[ENV.storefront] = made.storefront_id;
175
- saveEnv(d.cwd, values);
178
+ saveEnv(d.cwd, values, envFile);
176
179
  set(ref, { state: "saved" });
177
180
  d.log(`saved ${Object.keys(values).join(", ")} to .env.local`);
178
181
  return values;
@@ -254,6 +257,48 @@ export function badOrigins(origins: readonly string[]): string[] {
254
257
  });
255
258
  }
256
259
 
260
+ /**
261
+ * A 0.10.0 checkout saved `kedge dev`'s keys in `.env.local`, where they'd block
262
+ * Cloudflare's (p0.10.1 §4). Under the lock, before anything else, the bundle
263
+ * proven local moves to `.env.development.local`: each key whose digest matches
264
+ * a ref recorded for a `local/…` store, the storefront a local ref recorded, and
265
+ * a `local/…` store marker. Nothing unproven moves. The destination is written
266
+ * first and read back, then only names it now holds are removed from
267
+ * `.env.local`; a run stopped between the two finishes the removal next time.
268
+ * A destination already holding one of those names with another value is a handoff.
269
+ */
270
+ function moveLocalBundle(db: DatabaseSync, d: SaveDeps): void {
271
+ const prod = readEnvLocal(d.cwd, ENV_FILE.production);
272
+ const refs = (db.prepare("SELECT * FROM credential_ref").all() as unknown as RefRecord[]).filter((r) =>
273
+ r.store.startsWith("local/"),
274
+ );
275
+ const moving: Record<string, string> = {};
276
+ for (const name of [ENV.publishable, ENV.secret]) {
277
+ const value = prod[name];
278
+ if (value && refs.some((r) => r.env_name === name && r.key_digest === keyDigest(value))) moving[name] = value;
279
+ }
280
+ const sf = prod[ENV.storefront];
281
+ if (sf && refs.some((r) => r.storefront_id === sf)) moving[ENV.storefront] = sf;
282
+ if (prod[ENV.store]?.startsWith("local/")) moving[ENV.store] = prod[ENV.store] as string;
283
+ const names = Object.keys(moving);
284
+ if (!names.length) return;
285
+ const dev = readEnvLocal(d.cwd, ENV_FILE.local);
286
+ const clash = names.filter((n) => dev[n] !== undefined && dev[n] !== moving[n]);
287
+ if (clash.length)
288
+ throw new NeedsPerson(
289
+ `${ENV_FILE.production} holds kedge dev's ${clash.join(", ")}, and ${ENV_FILE.local} holds others: keep one, remove the other line, then run this again`,
290
+ );
291
+ saveEnv(d.cwd, moving, ENV_FILE.local);
292
+ const landed = readEnvLocal(d.cwd, ENV_FILE.local);
293
+ saveEnv(
294
+ d.cwd,
295
+ {},
296
+ ENV_FILE.production,
297
+ names.filter((n) => landed[n] === moving[n]),
298
+ );
299
+ d.log(`moved kedge dev's ${names.join(", ")} from ${ENV_FILE.production} to ${ENV_FILE.local}`);
300
+ }
301
+
257
302
  /**
258
303
  * Whether a saved key still does its job (§6.2): a publishable key passes the
259
304
  * storefront gate; a secret key holds its grants. Only a definite answer
@@ -77,24 +77,36 @@ export function stateDb(cwd: string): DatabaseSync {
77
77
  return db;
78
78
  }
79
79
 
80
+ /** Where a store's saved keys go (p0.10.1 §4): `kedge dev`'s apart from Cloudflare's, so neither blocks the other. */
81
+ export const ENV_FILE = { production: ".env.local", local: ".env.development.local" } as const;
82
+
80
83
  /**
81
- * Writes `values` into `.env.local`, keeping every other line: a temporary
84
+ * Rewrites an env file, keeping every line it doesn't touch: `values` set
85
+ * (replaced in place, or added at the end) and `remove` dropped. A temporary
82
86
  * file in the same directory, mode 0600, renamed over it, so a crash leaves
83
87
  * the old file or the new one, never half of either.
84
88
  */
85
- export function saveEnv(cwd: string, values: Record<string, string>): void {
86
- const file = join(cwd, ".env.local");
89
+ export function saveEnv(
90
+ cwd: string,
91
+ values: Record<string, string>,
92
+ name: string = ENV_FILE.production,
93
+ remove: readonly string[] = [],
94
+ ): void {
95
+ const file = join(cwd, name);
87
96
  const lines = existsSync(file) ? readFileSync(file, "utf8").split("\n") : [];
88
97
  const left = new Set(Object.keys(values));
89
- const out = lines.map((line) => {
90
- const name = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/.exec(line)?.[1];
91
- if (!name || !(name in values)) return line;
92
- left.delete(name);
93
- return `${name}=${values[name]}`;
98
+ const drop = new Set(remove);
99
+ const out = lines.flatMap((line) => {
100
+ const key = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/.exec(line)?.[1];
101
+ if (!key) return [line];
102
+ if (drop.has(key)) return [];
103
+ if (!(key in values)) return [line];
104
+ left.delete(key);
105
+ return [`${key}=${values[key]}`];
94
106
  });
95
107
  while (out.length && out.at(-1) === "") out.pop();
96
- for (const name of left) out.push(`${name}=${values[name]}`);
97
- const tmp = join(cwd, `.env.local.${process.pid}.tmp`);
108
+ for (const key of left) out.push(`${key}=${values[key]}`);
109
+ const tmp = join(cwd, `${name}.${process.pid}.tmp`);
98
110
  try {
99
111
  writeFileSync(tmp, `${out.join("\n")}\n`, { mode: 0o600 });
100
112
  renameSync(tmp, file);
@@ -103,8 +115,8 @@ export function saveEnv(cwd: string, values: Record<string, string>): void {
103
115
  }
104
116
  }
105
117
 
106
- /** `.env.local`'s values, as the credential resolver reads them. */
107
- export function readEnvLocal(cwd: string): Record<string, string> {
108
- const file = join(cwd, ".env.local");
118
+ /** An env file's values (`.env.local` by default), as the credential resolver reads them. */
119
+ export function readEnvLocal(cwd: string, name: string = ENV_FILE.production): Record<string, string> {
120
+ const file = join(cwd, name);
109
121
  return existsSync(file) ? (parseEnv(readFileSync(file, "utf8")) as Record<string, string>) : {};
110
122
  }