@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/README.md +28 -0
- package/kedge-manifest.json +68 -48
- package/package.json +7 -7
- package/src/cf.ts +216 -50
- package/src/check.ts +4 -2
- package/src/cli-session.ts +43 -4
- package/src/commands.ts +89 -25
- package/src/d1-batch.ts +3 -1
- package/src/deploy.ts +48 -13
- package/src/doctor.ts +34 -9
- package/src/keys-save.ts +49 -4
- package/src/local-state.ts +25 -13
- package/src/main.ts +30 -7
- package/src/ops.ts +26 -7
- package/src/output.ts +18 -9
- package/src/preview.ts +8 -1
- package/src/smoke.ts +29 -21
- package/src/store-api.ts +8 -1
- package/src/store.ts +27 -5
- package/src/target.ts +39 -11
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
|
-
|
|
295
|
-
|
|
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
|
-
|
|
306
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
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
|
-
|
|
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
|
-
|
|
922
|
-
|
|
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
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
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
|
-
|
|
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 ?? [],
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
1616
|
-
|
|
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
|
|
386
|
-
|
|
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(", ")}:
|
|
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 ===
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/src/local-state.ts
CHANGED
|
@@ -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
|
-
*
|
|
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(
|
|
86
|
-
|
|
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
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
return
|
|
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
|
|
97
|
-
const tmp = join(cwd,
|
|
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`
|
|
107
|
-
export function readEnvLocal(cwd: string): Record<string, string> {
|
|
108
|
-
const file = join(cwd,
|
|
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
|
}
|