@kedge/cli 0.0.0-stage → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/bin/kedge.mjs +8 -0
- package/kedge-manifest.json +418 -0
- package/package.json +43 -3
- package/src/cf.ts +729 -0
- package/src/check.ts +45 -0
- package/src/cli-session.ts +57 -0
- package/src/code-lint.ts +339 -0
- package/src/commands.ts +959 -0
- package/src/compose.ts +637 -0
- package/src/d1-batch.ts +66 -0
- package/src/deploy.ts +1682 -0
- package/src/doctor.ts +701 -0
- package/src/fork.ts +67 -0
- package/src/index.ts +12 -0
- package/src/invariants.ts +168 -0
- package/src/keys-save.ts +298 -0
- package/src/local-state.ts +110 -0
- package/src/main.ts +470 -0
- package/src/manifest.ts +163 -0
- package/src/n1.ts +217 -0
- package/src/ops.ts +373 -0
- package/src/order.ts +7 -0
- package/src/output.ts +35 -0
- package/src/plugin-lint.ts +145 -0
- package/src/preview.ts +1038 -0
- package/src/schema.ts +72 -0
- package/src/smoke.ts +218 -0
- package/src/sqlite-verify.ts +333 -0
- package/src/store-api.ts +36 -0
- package/src/store.ts +132 -0
- package/src/tar.ts +125 -0
- package/src/target.ts +81 -0
- package/src/tz.ts +79 -0
- package/src/units.ts +185 -0
- package/src/upgrade.ts +235 -0
- package/src/worker-config.ts +81 -0
- package/README.md +0 -3
package/src/commands.ts
ADDED
|
@@ -0,0 +1,959 @@
|
|
|
1
|
+
// P0.8's commands that touch Cloudflare or a running store
|
|
2
|
+
// (docs/proposals/p0.8-cli-agent-kit.md §3): deploy, migrate, restore --list,
|
|
3
|
+
// secret, seed, keys create --url, dev. Each command's target is fixed by what
|
|
4
|
+
// it is: deploy and preview only ever act on Cloudflare; migrate acts locally
|
|
5
|
+
// unless given --remote; nothing decides from the environment that it's
|
|
6
|
+
// acting on production.
|
|
7
|
+
import { spawnSync } from "node:child_process";
|
|
8
|
+
import { existsSync, readdirSync, readFileSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { join, resolve } from "node:path";
|
|
10
|
+
import { pathToFileURL } from "node:url";
|
|
11
|
+
import { isPermission, type KedgeConfig } from "@kedge/core";
|
|
12
|
+
import {
|
|
13
|
+
type Cloudflare,
|
|
14
|
+
CloudflareError,
|
|
15
|
+
cloudflareFor,
|
|
16
|
+
isJurisdictionChoice,
|
|
17
|
+
type JurisdictionChoice,
|
|
18
|
+
NeedsPerson,
|
|
19
|
+
preflight,
|
|
20
|
+
UnknownOutcome,
|
|
21
|
+
wranglerBin,
|
|
22
|
+
} from "./cf";
|
|
23
|
+
import { isCliSession, OLDER_KEDGE, type SessionDeps, withCliSession } from "./cli-session";
|
|
24
|
+
import { ComposeError, compose, type Lock } from "./compose";
|
|
25
|
+
import { type Executor, wranglerExecute } from "./d1-batch";
|
|
26
|
+
import {
|
|
27
|
+
adminBuildIn,
|
|
28
|
+
appliedNames,
|
|
29
|
+
Deployer,
|
|
30
|
+
DeployRefused,
|
|
31
|
+
openOps,
|
|
32
|
+
SCOPE,
|
|
33
|
+
supersededLock,
|
|
34
|
+
uncertainMigrations,
|
|
35
|
+
} from "./deploy";
|
|
36
|
+
import { readWrangler } from "./doctor";
|
|
37
|
+
import { badOrigins, ENV, keysSave, type Wanted } from "./keys-save";
|
|
38
|
+
import { installedPackages, ownManifest } from "./manifest";
|
|
39
|
+
import { LostOwnership, OperationHeld } from "./ops";
|
|
40
|
+
import { childStdio, emit } from "./output";
|
|
41
|
+
import { needsLive, smoke } from "./smoke";
|
|
42
|
+
import { git, storeNames } from "./store";
|
|
43
|
+
import { storeRequest } from "./store-api";
|
|
44
|
+
import { resolveTarget, type StoreTarget } from "./target";
|
|
45
|
+
import { compileStoreZone } from "./tz";
|
|
46
|
+
|
|
47
|
+
export interface CommandDeps {
|
|
48
|
+
cwd: string;
|
|
49
|
+
env: NodeJS.ProcessEnv;
|
|
50
|
+
coreDir: string;
|
|
51
|
+
cf?: Cloudflare;
|
|
52
|
+
fetch?: typeof fetch;
|
|
53
|
+
checkFast?: () => Promise<number>;
|
|
54
|
+
log?: (s: string) => void;
|
|
55
|
+
error?: (s: string) => void;
|
|
56
|
+
/** Asks the person at the terminal; `hidden` doesn't echo (a password). Tests answer it. */
|
|
57
|
+
ask?: (question: string, hidden: boolean) => Promise<string>;
|
|
58
|
+
/** Writes a CLI session's row to `kedge dev`'s database; tests replace it. */
|
|
59
|
+
execute?: Executor;
|
|
60
|
+
/** The sign-in's stdin; tests give a stream. */
|
|
61
|
+
stdin?: Stdin;
|
|
62
|
+
/** `--json`: the command's result as one object on stdout (p0.10 §5). */
|
|
63
|
+
json?: boolean;
|
|
64
|
+
/** Where that object goes; tests read it. */
|
|
65
|
+
emit?: (result: Record<string, unknown>) => void;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** How long `--answers-stdin` waits for each answer (p0.10 §5). */
|
|
69
|
+
export const ANSWER_WAIT_MS = 30_000;
|
|
70
|
+
|
|
71
|
+
type Stdin = NodeJS.ReadableStream & { isTTY?: boolean };
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The terminal's answers, one reader for every question of a command: a second
|
|
75
|
+
* readline would lose what the first had buffered, so piped answers would reach
|
|
76
|
+
* only the first question. A hidden answer isn't echoed, so a password stays
|
|
77
|
+
* off the screen.
|
|
78
|
+
*
|
|
79
|
+
* Only a terminal is asked unless `--answers-stdin` says otherwise, and that's
|
|
80
|
+
* decided before anything is sent (p0.10 §5): an agent's tool call can hold
|
|
81
|
+
* stdin open and write nothing, and a read would then wait forever. Piped
|
|
82
|
+
* answers wait at most `ANSWER_WAIT_MS` each, as `docker login --password-stdin`
|
|
83
|
+
* is the explicit way in.
|
|
84
|
+
*/
|
|
85
|
+
export async function terminalAsker(o: {
|
|
86
|
+
answersStdin: boolean;
|
|
87
|
+
input?: Stdin;
|
|
88
|
+
output?: NodeJS.WritableStream;
|
|
89
|
+
waitMs?: number;
|
|
90
|
+
}): Promise<{ ask: NonNullable<CommandDeps["ask"]>; close: () => void }> {
|
|
91
|
+
const input = o.input ?? process.stdin;
|
|
92
|
+
// Questions go to stderr, as git's and ssh's do: under --json stdout is the result's alone.
|
|
93
|
+
const output = o.output ?? process.stderr;
|
|
94
|
+
const tty = Boolean(input.isTTY);
|
|
95
|
+
if (!tty && !o.answersStdin)
|
|
96
|
+
throw new NeedsPerson(
|
|
97
|
+
"signing in asks for your password and authenticator code: run this command in your own terminal",
|
|
98
|
+
);
|
|
99
|
+
const { createInterface } = await import("node:readline");
|
|
100
|
+
const rl = createInterface({ input, output, terminal: tty });
|
|
101
|
+
const out = rl as unknown as { _writeToOutput: (s: string) => void; output: NodeJS.WriteStream };
|
|
102
|
+
let muted = false;
|
|
103
|
+
const write = out._writeToOutput.bind(rl);
|
|
104
|
+
out._writeToOutput = (s: string) => {
|
|
105
|
+
if (!muted) write(s);
|
|
106
|
+
};
|
|
107
|
+
const lines = rl[Symbol.asyncIterator]();
|
|
108
|
+
const waitMs = o.waitMs ?? ANSWER_WAIT_MS;
|
|
109
|
+
return {
|
|
110
|
+
async ask(question, hidden) {
|
|
111
|
+
output.write(question);
|
|
112
|
+
muted = hidden && tty;
|
|
113
|
+
let timer: NodeJS.Timeout | undefined;
|
|
114
|
+
try {
|
|
115
|
+
// A person at a terminal takes the time they need; a pipe that sends nothing is an end.
|
|
116
|
+
const silence = new Promise<never>((_, reject) => {
|
|
117
|
+
if (!tty)
|
|
118
|
+
timer = setTimeout(
|
|
119
|
+
() => reject(new DeployRefused(`no answer on stdin within ${waitMs / 1000} seconds`)),
|
|
120
|
+
waitMs,
|
|
121
|
+
);
|
|
122
|
+
});
|
|
123
|
+
const next = await Promise.race([lines.next(), silence]);
|
|
124
|
+
if (next.done) throw new DeployRefused("no answer on stdin");
|
|
125
|
+
return next.value;
|
|
126
|
+
} finally {
|
|
127
|
+
clearTimeout(timer);
|
|
128
|
+
if (muted) output.write("\n");
|
|
129
|
+
muted = false;
|
|
130
|
+
}
|
|
131
|
+
},
|
|
132
|
+
close: () => rl.close(),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function flag(args: readonly string[], name: string): string | undefined {
|
|
137
|
+
const i = args.indexOf(name);
|
|
138
|
+
return i >= 0 ? args[i + 1] : undefined;
|
|
139
|
+
}
|
|
140
|
+
export function flagsOf(args: readonly string[], name: string): string[] {
|
|
141
|
+
return args.flatMap((a, i) => (a === name && args[i + 1] ? [args[i + 1] as string] : []));
|
|
142
|
+
}
|
|
143
|
+
export function varsOf(args: readonly string[]): Record<string, string> {
|
|
144
|
+
const out: Record<string, string> = {};
|
|
145
|
+
for (const v of flagsOf(args, "--var")) {
|
|
146
|
+
const i = v.indexOf("=");
|
|
147
|
+
if (i < 1) throw new DeployRefused(`--var takes KEY=VALUE, not "${v}"`);
|
|
148
|
+
out[v.slice(0, i)] = v.slice(i + 1);
|
|
149
|
+
}
|
|
150
|
+
return out;
|
|
151
|
+
}
|
|
152
|
+
/** `<from>/<to>`, each an ISO instant. */
|
|
153
|
+
export function windowOf(arg: string | undefined): { from: number; to: number } | undefined {
|
|
154
|
+
if (!arg) return undefined;
|
|
155
|
+
const [a, b] = arg.split("/");
|
|
156
|
+
const from = Date.parse(a ?? "");
|
|
157
|
+
const to = Date.parse(b ?? "");
|
|
158
|
+
if (!Number.isFinite(from) || !Number.isFinite(to) || to <= from)
|
|
159
|
+
throw new DeployRefused("--maintenance-window takes <from>/<to>, two ISO instants, from before to");
|
|
160
|
+
return { from, to };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const loadConfig = async (cwd: string) =>
|
|
164
|
+
((await import(pathToFileURL(join(cwd, "kedge.config.ts")).href)) as { default: KedgeConfig }).default;
|
|
165
|
+
const lockOf = (cwd: string) => JSON.parse(readFileSync(join(cwd, "kedge.lock.json"), "utf8")) as Lock;
|
|
166
|
+
|
|
167
|
+
/** `wrangler deploy --dry-run --outdir`: bundles the store's code on this (trusted) machine. */
|
|
168
|
+
export function bundleWithWrangler(cwd: string) {
|
|
169
|
+
return (config: string, outdir: string) => {
|
|
170
|
+
const r = spawnSync(process.execPath, [wranglerBin(), "deploy", "--dry-run", "--outdir", outdir, "-c", config], {
|
|
171
|
+
cwd,
|
|
172
|
+
encoding: "utf8",
|
|
173
|
+
env: { ...process.env, WRANGLER_SEND_METRICS: "false" },
|
|
174
|
+
});
|
|
175
|
+
if (r.status !== 0)
|
|
176
|
+
throw new DeployRefused(
|
|
177
|
+
`bundling failed:\n${`${r.stdout}\n${r.stderr}`.trim().split("\n").slice(-15).join("\n")}`,
|
|
178
|
+
);
|
|
179
|
+
// Only what uploads stays: Wrangler's README.md and source maps name paths relative to the output
|
|
180
|
+
// directory, so a resume's rebuild elsewhere never reproduced the plan (research/26), and
|
|
181
|
+
// `versions upload --no-bundle` sends neither.
|
|
182
|
+
for (const f of readdirSync(outdir)) if (f === "README.md" || f.endsWith(".map")) rmSync(join(outdir, f));
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** The Cloudflare a command acts on: the given one (tests), else whatever source resolves. */
|
|
187
|
+
const cloudflareOf = async (d: CommandDeps) => d.cf ?? (await cloudflareFor(d.env, d.cwd, d.fetch));
|
|
188
|
+
|
|
189
|
+
async function deployer(d: CommandDeps, cf?: Cloudflare): Promise<Deployer> {
|
|
190
|
+
const config = await loadConfig(d.cwd);
|
|
191
|
+
return new Deployer({
|
|
192
|
+
cf: cf ?? (await cloudflareOf(d)),
|
|
193
|
+
cwd: d.cwd,
|
|
194
|
+
manifest: ownManifest(),
|
|
195
|
+
lock: lockOf(d.cwd),
|
|
196
|
+
git: git(d.cwd),
|
|
197
|
+
bundle: bundleWithWrangler(d.cwd),
|
|
198
|
+
checkFast: d.checkFast ?? (async () => 0),
|
|
199
|
+
plugins: config.plugins ?? [],
|
|
200
|
+
env: d.env,
|
|
201
|
+
...(d.log ? { log: d.log } : {}),
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Turns a refusal into its message and exit code; anything else is a bug and
|
|
207
|
+
* propagates. Under `--json` the outcome is also one object on stdout: what the
|
|
208
|
+
* command made, its handoff, or its refusal (p0.10 §5).
|
|
209
|
+
*/
|
|
210
|
+
async function refusals(d: CommandDeps, work: () => Promise<unknown>): Promise<number> {
|
|
211
|
+
const result = (r: Record<string, unknown>) => {
|
|
212
|
+
if (d.json) (d.emit ?? emit)(r);
|
|
213
|
+
};
|
|
214
|
+
try {
|
|
215
|
+
// What the command made, when it returns it; a void command's object is only `ok`.
|
|
216
|
+
const made = await work();
|
|
217
|
+
result({ ok: true, ...(made && typeof made === "object" ? made : {}) });
|
|
218
|
+
return 0;
|
|
219
|
+
} catch (e) {
|
|
220
|
+
const f = failureOf(e);
|
|
221
|
+
if (!f) throw e;
|
|
222
|
+
(d.error ?? console.error)(f.line);
|
|
223
|
+
result(f.result);
|
|
224
|
+
return f.code;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* How a command that didn't succeed ends, for a person and under `--json`
|
|
230
|
+
* (p0.10 §5): a handoff (exit 3, the last line `for you:`), or a refusal,
|
|
231
|
+
* Cloudflare refusing, or Cloudflare not answering (exit 1). An unanswered
|
|
232
|
+
* call says its outcome is unknown: the operation stays held, for
|
|
233
|
+
* `deploy --resume` or `doctor --remote` to settle. Null is a bug, which propagates.
|
|
234
|
+
*/
|
|
235
|
+
export function failureOf(e: unknown): { code: 1 | 3; line: string; result: Record<string, unknown> } | null {
|
|
236
|
+
if (e instanceof NeedsPerson)
|
|
237
|
+
return { code: 3, line: `for you: ${e.handoff}`, result: { ok: false, handoff: e.handoff } };
|
|
238
|
+
if (e instanceof UnknownOutcome)
|
|
239
|
+
return { code: 1, line: e.message, result: { ok: false, error: e.message, outcome: "unknown" } };
|
|
240
|
+
if (
|
|
241
|
+
e instanceof DeployRefused ||
|
|
242
|
+
e instanceof OperationHeld ||
|
|
243
|
+
e instanceof LostOwnership ||
|
|
244
|
+
e instanceof ComposeError ||
|
|
245
|
+
e instanceof CloudflareError
|
|
246
|
+
)
|
|
247
|
+
return { code: 1, line: e.message, result: { ok: false, error: e.message } };
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export async function deployCommand(args: readonly string[], d: CommandDeps): Promise<number> {
|
|
252
|
+
return refusals(d, async () => {
|
|
253
|
+
const cf = await cloudflareOf(d);
|
|
254
|
+
const dep = await deployer(d, cf);
|
|
255
|
+
(d.log ?? console.log)(`deploying ${dep.names.worker} on account ${cf.account}`);
|
|
256
|
+
const resolve = flag(args, "--resolve-intent");
|
|
257
|
+
if (resolve) return dep.resolveIntent(resolve, args.includes("--not-executed"));
|
|
258
|
+
if (args.includes("--record-active")) return dep.recordActive(flag(args, "--reason") ?? "");
|
|
259
|
+
if (args.includes("--abort-cutover")) return dep.abortCutover();
|
|
260
|
+
const supersede = flag(args, "--supersede");
|
|
261
|
+
if (supersede) return dep.supersede(supersede);
|
|
262
|
+
if (args.includes("--adopt-existing")) {
|
|
263
|
+
const release = flag(args, "--from-release");
|
|
264
|
+
if (!release)
|
|
265
|
+
throw new DeployRefused(
|
|
266
|
+
"--adopt-existing needs --from-release <version>: the release serving now, by your word",
|
|
267
|
+
);
|
|
268
|
+
// Adoption takes the store's jurisdiction from what exists, so it asks nothing; a choice is checked against it.
|
|
269
|
+
const choice = flag(args, "--jurisdiction");
|
|
270
|
+
await preflight(cf);
|
|
271
|
+
if (choice !== undefined && !isJurisdictionChoice(choice))
|
|
272
|
+
throw new DeployRefused(`--jurisdiction takes eu, us, fedramp or none, not "${choice}"`);
|
|
273
|
+
return dep.adoptExisting(
|
|
274
|
+
release,
|
|
275
|
+
(f) => readFileSync(join(d.cwd, "migrations", f), "utf8"),
|
|
276
|
+
choice as JurisdictionChoice | undefined,
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
const window = windowOf(flag(args, "--maintenance-window"));
|
|
280
|
+
const takeOver = flag(args, "--take-over");
|
|
281
|
+
const choice = flag(args, "--jurisdiction");
|
|
282
|
+
await preflight(cf, dep.names.ops, choice);
|
|
283
|
+
await dep.deploy({
|
|
284
|
+
...(choice ? { jurisdiction: choice as JurisdictionChoice } : {}),
|
|
285
|
+
vars: varsOf(args),
|
|
286
|
+
adopt: flagsOf(args, "--adopt"),
|
|
287
|
+
allowOlder: args.includes("--allow-older"),
|
|
288
|
+
resume: args.includes("--resume"),
|
|
289
|
+
confirmStopped: args.includes("--confirm-stopped"),
|
|
290
|
+
...(window ? { window } : {}),
|
|
291
|
+
...(takeOver ? { takeOver } : {}),
|
|
292
|
+
});
|
|
293
|
+
// From the account's subdomain now, never a recorded URL: its owner can change it (p0.10 §4).
|
|
294
|
+
const url = `https://${dep.names.worker}.${await cf.workersSubdomain()}.workers.dev`;
|
|
295
|
+
return { worker: dep.names.worker, account: cf.account, url, admin: adminUrl(d.cwd, url) };
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** The admin's address, when the store serves it (p0.9 §3.3); null when it serves none. */
|
|
300
|
+
function adminUrl(cwd: string, url: string): string | null {
|
|
301
|
+
return readWrangler(cwd)?.config.assets ? `${url}/app/` : null;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
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
|
+
}
|
|
313
|
+
return refusals(d, async () => (await deployer(d)).migrateRemote());
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* `kedge generate --supersede <op-id>` (§6.2): holding the stopped operation,
|
|
318
|
+
* the applied prefix kept byte for byte and the proven-unapplied suffix
|
|
319
|
+
* recomposed from the corrected sources.
|
|
320
|
+
*/
|
|
321
|
+
export async function generateSupersede(opId: string, d: CommandDeps): Promise<number> {
|
|
322
|
+
return refusals(d, async () => {
|
|
323
|
+
const cf = await cloudflareOf(d);
|
|
324
|
+
const names = storeNames(d.cwd).names;
|
|
325
|
+
const { ops } = await openOps(cf, names);
|
|
326
|
+
const held = await ops.current(SCOPE);
|
|
327
|
+
if (!held || held.op_id !== opId) throw new DeployRefused(`no operation ${opId} holds this store`);
|
|
328
|
+
await ops.resume(SCOPE, opId);
|
|
329
|
+
try {
|
|
330
|
+
const db = await ops.resource("d1", names.database);
|
|
331
|
+
if (!db?.id) throw new DeployRefused("no database is recorded for this store");
|
|
332
|
+
const applied = await appliedNames(cf, db.id);
|
|
333
|
+
const { lock, removed } = supersededLock(lockOf(d.cwd), applied);
|
|
334
|
+
// Before anything is deleted: a submitted file that could still land can't be recomposed (codex code r21).
|
|
335
|
+
const uncertain = (await uncertainMigrations(ops, opId)).flatMap((i) =>
|
|
336
|
+
(JSON.parse(i.detail) as { files: string[] }).files.filter((f) => removed.includes(f)),
|
|
337
|
+
);
|
|
338
|
+
if (uncertain.length)
|
|
339
|
+
throw new DeployRefused(
|
|
340
|
+
`${uncertain.join(", ")} ${uncertain.length === 1 ? "was" : "were"} submitted and could still land, so ${uncertain.length === 1 ? "it stays" : "they stay"} as ${uncertain.length === 1 ? "it is" : "they are"}: fix forward with a new migration, or a person who knows it never ran says so with kedge deploy --resolve-intent <id> --not-executed`,
|
|
341
|
+
);
|
|
342
|
+
const config = await loadConfig(d.cwd);
|
|
343
|
+
for (const f of removed) if (existsSync(join(d.cwd, "migrations", f))) unlinkSync(join(d.cwd, "migrations", f));
|
|
344
|
+
writeFileSync(join(d.cwd, "kedge.lock.json"), `${JSON.stringify(lock, null, 2)}\n`);
|
|
345
|
+
const result = compose({
|
|
346
|
+
coreDir: d.coreDir,
|
|
347
|
+
plugins: config.plugins ?? [],
|
|
348
|
+
// A preview's own ports are locked like any other's (p0.8 §5.2).
|
|
349
|
+
ports: [...(config.ports ?? []), ...(config.preview?.ports ?? [])],
|
|
350
|
+
localDir: join(d.cwd, "local"),
|
|
351
|
+
outDir: join(d.cwd, "migrations"),
|
|
352
|
+
lockPath: join(d.cwd, "kedge.lock.json"),
|
|
353
|
+
now: Date.now(),
|
|
354
|
+
...(config.timezone ? { timezone: config.timezone } : {}),
|
|
355
|
+
compileZone: compileStoreZone,
|
|
356
|
+
tzPath: join(d.cwd, "src", "kedge.tz.ts"),
|
|
357
|
+
});
|
|
358
|
+
(d.log ?? console.log)(
|
|
359
|
+
`kept ${applied.length} applied; recomposed ${result.written.filter((f) => f.endsWith(".sql")).join(", ") || "nothing"}. Commit, then kedge deploy --supersede ${opId}`,
|
|
360
|
+
);
|
|
361
|
+
} finally {
|
|
362
|
+
await ops.stop(SCOPE, opId);
|
|
363
|
+
}
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** `kedge restore --list` (§6.5): restore points. Restoring waits for P1.18's barrier. */
|
|
368
|
+
export async function restoreCommand(args: readonly string[], d: CommandDeps): Promise<number> {
|
|
369
|
+
if (!args.includes("--list")) {
|
|
370
|
+
(d.error ?? console.error)(
|
|
371
|
+
"kedge restore restores nothing yet: rewinding D1 under a live store needs P1.18's recovery barrier and reconciliation (docs/10). `kedge restore --list` prints the restore points; `wrangler d1 time-travel restore` exists for an emergency, outside Kedge's guarantees.",
|
|
372
|
+
);
|
|
373
|
+
return 1;
|
|
374
|
+
}
|
|
375
|
+
return refusals(d, async () => {
|
|
376
|
+
const cf = await cloudflareOf(d);
|
|
377
|
+
const names = storeNames(d.cwd).names;
|
|
378
|
+
// A lookup, never a create: no ops database is no preview, and seeding writes nothing to Cloudflare.
|
|
379
|
+
if (!(await cf.findD1(names.ops))) throw new DeployRefused("no ops database");
|
|
380
|
+
const { ops } = await openOps(cf, names);
|
|
381
|
+
const log = d.log ?? console.log;
|
|
382
|
+
for (const r of await ops.deployments()) {
|
|
383
|
+
const before = JSON.parse(r.before_half) as {
|
|
384
|
+
bookmark?: string;
|
|
385
|
+
versionId?: string | null;
|
|
386
|
+
release?: string | null;
|
|
387
|
+
};
|
|
388
|
+
const after = r.after_half ? (JSON.parse(r.after_half) as { versionId?: string; release?: string }) : null;
|
|
389
|
+
log(
|
|
390
|
+
`${new Date(r.created_at).toISOString()} ${r.mode.padEnd(8)} before ${before.bookmark ?? "(none)"} (${before.release ?? "nothing"}, ${before.versionId ?? "no Worker"}) → ${after ? `${after.release} ${after.versionId}` : "unfinished"}`,
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
log(
|
|
394
|
+
"These are D1 Time Travel bookmarks taken before each deploy's migrations. Restoring through Kedge arrives with P1.18.",
|
|
395
|
+
);
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
export async function secretCommand(args: readonly string[], d: CommandDeps): Promise<number> {
|
|
400
|
+
const [sub, name] = args;
|
|
401
|
+
if ((sub !== "put" && sub !== "delete") || !name) {
|
|
402
|
+
(d.error ?? console.error)("usage: kedge secret put <NAME> (value on stdin) | kedge secret delete <NAME>");
|
|
403
|
+
return 2;
|
|
404
|
+
}
|
|
405
|
+
return refusals(d, async () => {
|
|
406
|
+
const value = sub === "put" ? readFileSync(0, "utf8").replace(/\r?\n$/, "") : null;
|
|
407
|
+
if (value !== null && value.length < 32) throw new DeployRefused("a Kedge secret is at least 32 bytes");
|
|
408
|
+
await (await deployer(d)).secret(name, value);
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// ---- against a running store's HTTP API -------------------------------------------------------------------
|
|
413
|
+
|
|
414
|
+
const SAMPLE_APPAREL = [
|
|
415
|
+
{
|
|
416
|
+
title: "Linen shirt",
|
|
417
|
+
handle: "linen-shirt",
|
|
418
|
+
variants: [
|
|
419
|
+
["S", 4900],
|
|
420
|
+
["M", 4900],
|
|
421
|
+
["L", 5200],
|
|
422
|
+
],
|
|
423
|
+
},
|
|
424
|
+
{ title: "Canvas tote", handle: "canvas-tote", variants: [["One size", 1800]] },
|
|
425
|
+
{
|
|
426
|
+
title: "Wool beanie",
|
|
427
|
+
handle: "wool-beanie",
|
|
428
|
+
variants: [
|
|
429
|
+
["Grey", 2400],
|
|
430
|
+
["Navy", 2400],
|
|
431
|
+
],
|
|
432
|
+
},
|
|
433
|
+
{
|
|
434
|
+
title: "Selvedge jeans",
|
|
435
|
+
handle: "selvedge-jeans",
|
|
436
|
+
variants: [
|
|
437
|
+
["30", 12000],
|
|
438
|
+
["32", 12000],
|
|
439
|
+
["34", 12000],
|
|
440
|
+
],
|
|
441
|
+
},
|
|
442
|
+
] as const;
|
|
443
|
+
|
|
444
|
+
const isLocal = (url: string) => ["localhost", "127.0.0.1", "[::1]"].includes(new URL(url).hostname);
|
|
445
|
+
|
|
446
|
+
/** The store a URL is, for a CLI session or a check: what `resolveTarget` says, credentials resolved as for any remote command. */
|
|
447
|
+
const targetOf = (url: string, d: CommandDeps) => resolveTarget(url, d.cwd, () => cloudflareOf(d));
|
|
448
|
+
|
|
449
|
+
/** How a CLI session's row is written (p0.10 §6.2). */
|
|
450
|
+
const sessionDeps = (d: CommandDeps): SessionDeps => ({
|
|
451
|
+
cloudflare: () => cloudflareOf(d),
|
|
452
|
+
executeLocal: (b) => (d.execute ?? wranglerExecute)(b, { database: "DB", remote: false, cwd: d.cwd }),
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
export async function seed(
|
|
456
|
+
base: string,
|
|
457
|
+
auth: Record<string, string>,
|
|
458
|
+
fetchImpl: typeof fetch = fetch,
|
|
459
|
+
log: (s: string) => void = console.log,
|
|
460
|
+
): Promise<string[]> {
|
|
461
|
+
const variants: string[] = [];
|
|
462
|
+
for (const p of SAMPLE_APPAREL) {
|
|
463
|
+
const res = await storeRequest(fetchImpl, new URL("/admin/products", base), {
|
|
464
|
+
method: "POST",
|
|
465
|
+
headers: { ...auth, "content-type": "application/json", "idempotency-key": `kedge-seed-${p.handle}` },
|
|
466
|
+
body: JSON.stringify({
|
|
467
|
+
title: p.title,
|
|
468
|
+
handle: p.handle,
|
|
469
|
+
variants: p.variants.map(([title, price]) => ({
|
|
470
|
+
sku: `${p.handle}-${title}`.toUpperCase().replace(/[^A-Z0-9]+/g, "-"),
|
|
471
|
+
title,
|
|
472
|
+
prices: { USD: price },
|
|
473
|
+
stock: { loc_default: 50 },
|
|
474
|
+
})),
|
|
475
|
+
}),
|
|
476
|
+
});
|
|
477
|
+
const body = res.body as { variantIds?: string[]; error?: { code?: string } };
|
|
478
|
+
if (res.status === 409 && body.error?.code === "CONFLICT") continue; // already seeded: the handle exists
|
|
479
|
+
if (res.status === 401 && isCliSession(auth)) throw new DeployRefused(OLDER_KEDGE);
|
|
480
|
+
if (!res.ok) throw new DeployRefused(`seeding ${p.handle} failed: ${res.status} ${body.error?.code ?? ""}`);
|
|
481
|
+
variants.push(...(body.variantIds ?? []));
|
|
482
|
+
log(`seeded ${p.title}`);
|
|
483
|
+
}
|
|
484
|
+
return variants;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/** `--key` or `--session`, when given. */
|
|
488
|
+
function givenAuth(args: readonly string[]): Record<string, string> | null {
|
|
489
|
+
const token = flag(args, "--key") ?? flag(args, "--session");
|
|
490
|
+
return token ? { authorization: `Bearer ${token}` } : null;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function authOf(args: readonly string[]): Record<string, string> {
|
|
494
|
+
const auth = givenAuth(args);
|
|
495
|
+
if (!auth) throw new DeployRefused("give --key <sk_…> or --session <token>");
|
|
496
|
+
return auth;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/** Ends a session the CLI made; a failure leaves it to expire, as it would anyway. */
|
|
500
|
+
async function signOutBearer(url: string, token: string, d: CommandDeps): Promise<void> {
|
|
501
|
+
await (d.fetch ?? fetch)(new URL("/auth/admin/sign-out", url), {
|
|
502
|
+
method: "POST",
|
|
503
|
+
headers: { authorization: `Bearer ${token}` },
|
|
504
|
+
}).catch(() => undefined);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* `--sign-in <email>` (p0.9 §7.3): P0.5's bearer ceremonies run by the CLI, a
|
|
509
|
+
* password then a TOTP code, because an admin user's own session is an HttpOnly
|
|
510
|
+
* cookie they can't hand to `--session`. It sends the store's own origin, which
|
|
511
|
+
* the Worker counts as an admin origin (§4.1). The session serves one command.
|
|
512
|
+
*/
|
|
513
|
+
async function signInBearer(url: string, email: string, d: CommandDeps, answersStdin: boolean): Promise<string> {
|
|
514
|
+
const f = d.fetch ?? fetch;
|
|
515
|
+
const terminal = d.ask ? null : await terminalAsker({ answersStdin, ...(d.stdin ? { input: d.stdin } : {}) });
|
|
516
|
+
try {
|
|
517
|
+
return await ceremonies(url, email, d, f, d.ask ?? (terminal as NonNullable<typeof terminal>).ask);
|
|
518
|
+
} finally {
|
|
519
|
+
terminal?.close();
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
async function ceremonies(
|
|
524
|
+
url: string,
|
|
525
|
+
email: string,
|
|
526
|
+
d: CommandDeps,
|
|
527
|
+
f: typeof fetch,
|
|
528
|
+
ask: NonNullable<CommandDeps["ask"]>,
|
|
529
|
+
): Promise<string> {
|
|
530
|
+
const headers = {
|
|
531
|
+
origin: new URL(url).origin,
|
|
532
|
+
"x-kedge-csrf": "1",
|
|
533
|
+
"x-kedge-session-mode": "bearer",
|
|
534
|
+
"content-type": "application/json",
|
|
535
|
+
};
|
|
536
|
+
const post = async (path: string, body: unknown) => {
|
|
537
|
+
const r = await storeRequest(f, new URL(path, url), { method: "POST", headers, body: JSON.stringify(body) });
|
|
538
|
+
if (!r.ok)
|
|
539
|
+
throw new DeployRefused(
|
|
540
|
+
`${path} answered ${r.status} ${String((r.body.error as { code?: string } | undefined)?.code ?? "")}`,
|
|
541
|
+
);
|
|
542
|
+
return r.body;
|
|
543
|
+
};
|
|
544
|
+
const first = await post("/auth/admin/sign-in", { email, password: await ask(`password for ${email}: `, true) });
|
|
545
|
+
const challenge = (first.challenge as { token?: string } | undefined)?.token;
|
|
546
|
+
// Every staff account has a second factor once its invite completes, so a
|
|
547
|
+
// weak session here is one that couldn't create keys anyway; it's ended, not left behind.
|
|
548
|
+
if (!challenge) {
|
|
549
|
+
if (typeof first.session_token === "string") await signOutBearer(url, first.session_token, d);
|
|
550
|
+
throw new DeployRefused(`${email} has no second factor yet: finish its invite in the admin first`);
|
|
551
|
+
}
|
|
552
|
+
// A mistyped code is a 401 that keeps the challenge (p0.9 §11), so it asks
|
|
553
|
+
// again; each wrong code counts toward P0.5's lockout, so not forever.
|
|
554
|
+
for (let attempt = 1; ; attempt++) {
|
|
555
|
+
const code = (await ask("authenticator code: ", false)).trim();
|
|
556
|
+
try {
|
|
557
|
+
const strong = await post("/auth/admin/totp/challenge", { challenge_token: challenge, code });
|
|
558
|
+
if (typeof strong.session_token !== "string") throw new DeployRefused("the TOTP challenge answered no session");
|
|
559
|
+
return strong.session_token;
|
|
560
|
+
} catch (e) {
|
|
561
|
+
if (attempt >= 3 || !(e instanceof DeployRefused) || !/ 401 UNAUTHORIZED$/.test(e.message)) throw e;
|
|
562
|
+
(d.error ?? console.error)("that code wasn't accepted; try the next one");
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
export async function seedCommand(args: readonly string[], d: CommandDeps): Promise<number> {
|
|
568
|
+
return refusals(d, async () => {
|
|
569
|
+
if (flag(args, "--sample") !== "apparel")
|
|
570
|
+
throw new DeployRefused("kedge seed --sample apparel --url <url> --key <sk_…>");
|
|
571
|
+
const url = flag(args, "--url");
|
|
572
|
+
if (!url) throw new DeployRefused("kedge seed needs --url");
|
|
573
|
+
// Local, or a preview this store's ops knows by its whole origin (codex code r17); a live store's
|
|
574
|
+
// customers would see sample products, so it's the owner's call (p0.10 §5).
|
|
575
|
+
let target: StoreTarget | null = null;
|
|
576
|
+
if (!isLocal(url) && !args.includes("--live")) {
|
|
577
|
+
target = await targetOf(url, d);
|
|
578
|
+
if (target?.kind !== "preview")
|
|
579
|
+
throw new NeedsPerson(
|
|
580
|
+
`${url} isn't local or a preview this store knows: sample products on a live store are for its owner to allow; with their yes, run this again with --live`,
|
|
581
|
+
);
|
|
582
|
+
}
|
|
583
|
+
const run = (auth: Record<string, string>) => seed(url, auth, d.fetch ?? fetch, d.log ?? console.log);
|
|
584
|
+
const given = givenAuth(args);
|
|
585
|
+
if (given) return { variants: await run(given) };
|
|
586
|
+
// No key or session given: a CLI session on the store the URL is, and only there (p0.10 §6.2).
|
|
587
|
+
target ??= await targetOf(url, d);
|
|
588
|
+
if (!target)
|
|
589
|
+
throw new DeployRefused(
|
|
590
|
+
`${url} isn't a store this checkout knows, so it gets no CLI session: give --key <sk_…> or --session <token>`,
|
|
591
|
+
);
|
|
592
|
+
return { variants: await withCliSession(target, ["product:write"], sessionDeps(d), run) };
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/** `kedge smoke` (p0.10 §6.3): a named test order, resumed until it has its number. */
|
|
597
|
+
export async function smokeCommand(args: readonly string[], d: CommandDeps): Promise<number> {
|
|
598
|
+
return refusals(d, async () => {
|
|
599
|
+
const url = flag(args, "--url");
|
|
600
|
+
if (!url) throw new DeployRefused("kedge smoke --url <store> [--run <name>] [--live]");
|
|
601
|
+
const name = flag(args, "--run") ?? "first";
|
|
602
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(name))
|
|
603
|
+
throw new DeployRefused("--run takes a name of letters, digits, - and _, up to 64");
|
|
604
|
+
const target = await targetOf(url, d);
|
|
605
|
+
if (!target) throw new DeployRefused(`${url} isn't a store this checkout knows: smoke runs only on its own stores`);
|
|
606
|
+
needsLive(target, args.includes("--live"));
|
|
607
|
+
return smoke(target, name, {
|
|
608
|
+
cwd: d.cwd,
|
|
609
|
+
fetch: d.fetch ?? fetch,
|
|
610
|
+
session: sessionDeps(d),
|
|
611
|
+
log: d.log ?? console.log,
|
|
612
|
+
});
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/** `keys create --url`: through the Worker, so the pepper never leaves it (§6.1). */
|
|
617
|
+
export async function keysOverHttp(args: readonly string[], d: CommandDeps): Promise<number> {
|
|
618
|
+
return refusals(d, async () => {
|
|
619
|
+
const url = flag(args, "--url") as string;
|
|
620
|
+
if (args.includes("--save")) return saveKeys(url, args, d);
|
|
621
|
+
const email = flag(args, "--sign-in");
|
|
622
|
+
if (email) {
|
|
623
|
+
const token = await signInBearer(url, email, d, args.includes("--answers-stdin"));
|
|
624
|
+
try {
|
|
625
|
+
return await createKeys(url, args, { authorization: `Bearer ${token}` }, d);
|
|
626
|
+
} finally {
|
|
627
|
+
// The session was made for this one command: ending it keeps a bearer
|
|
628
|
+
// token that could create keys from outliving the terminal it was typed in.
|
|
629
|
+
await signOutBearer(url, token, d);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
return createKeys(url, args, authOf(args), d);
|
|
633
|
+
});
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/**
|
|
637
|
+
* `keys create --save` (p0.10 §6.2): through a credential reference with a
|
|
638
|
+
* CLI session, saved to `.env.local`. What it answers names what was saved,
|
|
639
|
+
* never the key: the key stays in the file, out of the agent's transcript.
|
|
640
|
+
*/
|
|
641
|
+
async function saveKeys(url: string, args: readonly string[], d: CommandDeps) {
|
|
642
|
+
if (flag(args, "--sign-in") || givenAuth(args))
|
|
643
|
+
throw new DeployRefused("--save makes its own CLI session: drop --sign-in, --key and --session");
|
|
644
|
+
const target = await targetOf(url, d);
|
|
645
|
+
if (!target)
|
|
646
|
+
throw new DeployRefused(`${url} isn't a store this checkout knows, so it gets no CLI session and no saved key`);
|
|
647
|
+
const name = flag(args, "--name") ?? "Storefront";
|
|
648
|
+
const wanted: Wanted = args.includes("--secret")
|
|
649
|
+
? { kind: "secret", name, grants: flagsOf(args, "--grant") }
|
|
650
|
+
: { kind: "publishable", name, origins: flagsOf(args, "--origin") };
|
|
651
|
+
if (wanted.kind === "secret" && wanted.grants.length === 0)
|
|
652
|
+
throw new DeployRefused("a secret key needs at least one --grant <permission>");
|
|
653
|
+
// Origins as the store accepts them, checked before anything is reused or written (codex code r16).
|
|
654
|
+
const wrong = wanted.kind === "publishable" ? badOrigins(wanted.origins) : [];
|
|
655
|
+
if (wrong.length)
|
|
656
|
+
throw new DeployRefused(
|
|
657
|
+
`not origins: ${wrong.join(", ")} (an origin is a scheme, host and optional port, like https://shop.example)`,
|
|
658
|
+
);
|
|
659
|
+
// Checked here, so a typo is one refusal under --json, before any session exists (codex code r15).
|
|
660
|
+
const bad = wanted.kind === "secret" ? wanted.grants.filter((g) => !isPermission(g)) : [];
|
|
661
|
+
if (bad.length) throw new DeployRefused(`not permissions: ${bad.join(", ")} (a permission is resource:action)`);
|
|
662
|
+
// The session that makes it holds the key's grants and api_key:write, and a session holds 16 at most.
|
|
663
|
+
if (wanted.kind === "secret" && new Set(wanted.grants).size > 15)
|
|
664
|
+
throw new DeployRefused("--save makes a secret key of at most 15 grants; make a wider one from the admin");
|
|
665
|
+
const saved = await keysSave(target, wanted, {
|
|
666
|
+
cwd: d.cwd,
|
|
667
|
+
fetch: d.fetch ?? fetch,
|
|
668
|
+
session: sessionDeps(d),
|
|
669
|
+
log: d.log ?? console.log,
|
|
670
|
+
});
|
|
671
|
+
return { saved: Object.keys(saved).sort(), storefront: saved[ENV.storefront] ?? null };
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/** The keys themselves, under whichever credential the caller has (P0.8 §6.1). */
|
|
675
|
+
async function createKeys(url: string, args: readonly string[], auth: Record<string, string>, d: CommandDeps) {
|
|
676
|
+
const f = d.fetch ?? fetch;
|
|
677
|
+
const post = async (path: string, body: unknown) => {
|
|
678
|
+
const r = await storeRequest(f, new URL(path, url), {
|
|
679
|
+
method: "POST",
|
|
680
|
+
headers: {
|
|
681
|
+
...auth,
|
|
682
|
+
"content-type": "application/json",
|
|
683
|
+
"idempotency-key": `kedge-keys-${crypto.randomUUID()}`,
|
|
684
|
+
},
|
|
685
|
+
body: JSON.stringify(body),
|
|
686
|
+
});
|
|
687
|
+
if (!r.ok)
|
|
688
|
+
throw new DeployRefused(
|
|
689
|
+
`${path} answered ${r.status} ${String((r.body.error as { code?: string } | undefined)?.code ?? "")}`,
|
|
690
|
+
);
|
|
691
|
+
return r.body;
|
|
692
|
+
};
|
|
693
|
+
const name = flag(args, "--name") ?? "Storefront";
|
|
694
|
+
const log = d.log ?? console.log;
|
|
695
|
+
if (args.includes("--secret")) {
|
|
696
|
+
const grants = flagsOf(args, "--grant").map((permission) => ({ permission, row_scope: "all" }));
|
|
697
|
+
if (!grants.length) throw new DeployRefused("a secret key needs at least one --grant <permission>");
|
|
698
|
+
const k = await post("/admin/api-keys", { name, grants });
|
|
699
|
+
log(`secret key ${String(k.id)} (shown once):\n${String(k.key)}`);
|
|
700
|
+
return { key_id: k.id, key: k.key };
|
|
701
|
+
}
|
|
702
|
+
const sf = await post("/admin/storefronts", { name, allowed_origins: flagsOf(args, "--origin") });
|
|
703
|
+
const k = await post(`/admin/storefronts/${String(sf.id)}/keys`, { name });
|
|
704
|
+
log(`storefront ${String(sf.id)}, key ${String(k.id)}`);
|
|
705
|
+
log(`publishable key (send as x-kedge-key; shown once):\n${String(k.key)}`);
|
|
706
|
+
return { storefront: sf.id, key_id: k.id, key: k.key };
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/**
|
|
710
|
+
* The environment with the preview key `kedge dev` wrote to .dev.vars when the environment holds none
|
|
711
|
+
* (codex code r16): that one key, never the rest of the local secrets.
|
|
712
|
+
*/
|
|
713
|
+
export function withPreviewKey(d: Pick<CommandDeps, "cwd" | "env">): NodeJS.ProcessEnv {
|
|
714
|
+
if (d.env.KEDGE_PREVIEW_KEY) return d.env;
|
|
715
|
+
const vars = join(d.cwd, ".dev.vars");
|
|
716
|
+
const key = existsSync(vars) ? /^KEDGE_PREVIEW_KEY=(.+)$/m.exec(readFileSync(vars, "utf8"))?.[1]?.trim() : undefined;
|
|
717
|
+
return key ? { ...d.env, KEDGE_PREVIEW_KEY: key } : d.env;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/** `kedge dev`: fresh local secrets once, local migrations, then wrangler dev (§3). */
|
|
721
|
+
/**
|
|
722
|
+
* What `kedge dev` adds to `wrangler dev` (p0.9 §3.5): the admin's build defined
|
|
723
|
+
* into the Worker's code, as deploy and preview define it. It refuses a config
|
|
724
|
+
* whose `routes` or `dev` host would make Wrangler rewrite the request's origin
|
|
725
|
+
* (`--local-upstream` "defaults to dev.host or route", `wrangler dev --help`,
|
|
726
|
+
* 4.152.0): sign-in relies on the request's URL being the browser's own origin.
|
|
727
|
+
*/
|
|
728
|
+
export function devArgs(cwd: string): string[] {
|
|
729
|
+
const w = readWrangler(cwd);
|
|
730
|
+
const c = (w?.config ?? {}) as Record<string, unknown> & { dev?: Record<string, unknown> };
|
|
731
|
+
const rewrites = [
|
|
732
|
+
...(c.routes !== undefined ? ["routes"] : []),
|
|
733
|
+
...(c.route !== undefined ? ["route"] : []),
|
|
734
|
+
...(c.dev?.host !== undefined ? ["dev.host"] : []),
|
|
735
|
+
...(c.dev?.local_upstream !== undefined ? ["dev.local_upstream"] : []),
|
|
736
|
+
];
|
|
737
|
+
if (rewrites.length)
|
|
738
|
+
throw new DeployRefused(
|
|
739
|
+
`wrangler.jsonc sets ${rewrites.join(", ")}, which makes wrangler dev give the Worker another origin than the browser's, and the admin's sign-in would be refused: remove ${rewrites.length === 1 ? "it" : "them"} (kedge deploy refuses routes too)`,
|
|
740
|
+
);
|
|
741
|
+
const dir = (c.assets as { directory?: string } | undefined)?.directory;
|
|
742
|
+
const id = dir ? adminBuildIn(resolve(cwd, dir)) : null;
|
|
743
|
+
return id ? ["--define", `KEDGE_ADMIN_BUILD:${JSON.stringify(id)}`] : [];
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
export function devCommand(d: CommandDeps): number {
|
|
747
|
+
const vars = join(d.cwd, ".dev.vars");
|
|
748
|
+
if (!existsSync(vars)) {
|
|
749
|
+
const lines = [
|
|
750
|
+
"CART_TOKEN_SECRET",
|
|
751
|
+
"ORDER_TOKEN_SECRET",
|
|
752
|
+
"KEDGE_AUTH_SECRET_V1",
|
|
753
|
+
"KEDGE_KEY_PEPPER_V1",
|
|
754
|
+
"KEDGE_TOTP_KEY_V1",
|
|
755
|
+
"KEDGE_PREVIEW_KEY",
|
|
756
|
+
].map((n) => `${n}=${Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("base64url")}`);
|
|
757
|
+
writeFileSync(vars, `# Local secrets for kedge dev, made once; never commit this file.\n${lines.join("\n")}\n`, {
|
|
758
|
+
mode: 0o600,
|
|
759
|
+
});
|
|
760
|
+
(d.log ?? console.log)("wrote .dev.vars with fresh local secrets");
|
|
761
|
+
}
|
|
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;
|
|
767
|
+
let extra: string[];
|
|
768
|
+
try {
|
|
769
|
+
extra = devArgs(d.cwd);
|
|
770
|
+
} catch (e) {
|
|
771
|
+
(d.log ?? console.log)((e as Error).message);
|
|
772
|
+
return 1;
|
|
773
|
+
}
|
|
774
|
+
return spawnSync(process.execPath, [wranglerBin(), "dev", ...extra], { cwd: d.cwd, stdio: "inherit" }).status ?? 0;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
export const removeIfExists = (p: string) => rmSync(p, { recursive: true, force: true });
|
|
778
|
+
|
|
779
|
+
// ---- kedge preview (§5) ------------------------------------------------------------------------------------
|
|
780
|
+
|
|
781
|
+
function branchName(cwd: string): string {
|
|
782
|
+
const r = spawnSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd, encoding: "utf8" });
|
|
783
|
+
const b = r.stdout.trim();
|
|
784
|
+
if (r.status !== 0 || !b || b === "HEAD") throw new DeployRefused("give --name: this checkout has no branch");
|
|
785
|
+
return b;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
export async function previewCommand(args: readonly string[], d: CommandDeps): Promise<number> {
|
|
789
|
+
const { applyPlan, buildPlan, cacheCredentials, destroyPreview, hasScenarios, runScenarios, sweepPreviews, tarPlan } =
|
|
790
|
+
await import("./preview");
|
|
791
|
+
const { signInOwner } = await import("@kedge/testing/http");
|
|
792
|
+
const sub = args[0] === "build" || args[0] === "apply" || args[0] === "destroy" ? args[0] : null;
|
|
793
|
+
const log = d.log ?? console.log;
|
|
794
|
+
return refusals(d, async () => {
|
|
795
|
+
const store = (): string => String(storeNames(d.cwd).config.name);
|
|
796
|
+
// A sweep names no preview, so it needs no branch (a detached CI checkout has none).
|
|
797
|
+
const sweep = sub === "destroy" && args.includes("--sweep");
|
|
798
|
+
const name = flag(args, "--name") ?? (sub === "apply" || sweep ? undefined : branchName(d.cwd));
|
|
799
|
+
if (sub === "destroy") {
|
|
800
|
+
const cf = await cloudflareOf(d);
|
|
801
|
+
if (args.includes("--sweep")) {
|
|
802
|
+
await sweepPreviews({ cf, store: store(), log });
|
|
803
|
+
return {};
|
|
804
|
+
}
|
|
805
|
+
await destroyPreview({
|
|
806
|
+
cf,
|
|
807
|
+
store: store(),
|
|
808
|
+
name: name as string,
|
|
809
|
+
log,
|
|
810
|
+
...(flag(args, "--take-over") ? { takeOver: flag(args, "--take-over") as string } : {}),
|
|
811
|
+
confirmStopped: args.includes("--confirm-stopped"),
|
|
812
|
+
});
|
|
813
|
+
return { destroyed: name };
|
|
814
|
+
}
|
|
815
|
+
const build = async () => {
|
|
816
|
+
if (d.checkFast && (await d.checkFast()) !== 0) throw new DeployRefused("kedge check --fast failed");
|
|
817
|
+
const config = await loadConfig(d.cwd);
|
|
818
|
+
return buildPlan({
|
|
819
|
+
cwd: d.cwd,
|
|
820
|
+
name: name as string,
|
|
821
|
+
lock: lockOf(d.cwd),
|
|
822
|
+
vars: varsOf(args),
|
|
823
|
+
secrets: config.preview?.secrets ?? [],
|
|
824
|
+
bundle: bundleWithWrangler(d.cwd),
|
|
825
|
+
});
|
|
826
|
+
};
|
|
827
|
+
if (sub === "build") {
|
|
828
|
+
const { dir } = await build();
|
|
829
|
+
log(`plan written to ${dir}`);
|
|
830
|
+
return { plan: dir };
|
|
831
|
+
}
|
|
832
|
+
const cf = await cloudflareOf(d);
|
|
833
|
+
const choice = flag(args, "--jurisdiction");
|
|
834
|
+
// A full preview builds from this checkout, so it knows its store; a plan applied from elsewhere names its own.
|
|
835
|
+
await preflight(cf, sub === "apply" ? undefined : `${store()}-ops`, choice);
|
|
836
|
+
const takeOver = flag(args, "--take-over");
|
|
837
|
+
const common = {
|
|
838
|
+
...(choice ? { jurisdiction: choice as JurisdictionChoice } : {}),
|
|
839
|
+
cf,
|
|
840
|
+
env: withPreviewKey(d),
|
|
841
|
+
secretEnv: flagsOf(args, "--secret-env"),
|
|
842
|
+
fresh: args.includes("--fresh"),
|
|
843
|
+
confirmStopped: args.includes("--confirm-stopped"),
|
|
844
|
+
log,
|
|
845
|
+
...(takeOver ? { takeOver } : {}),
|
|
846
|
+
...(d.fetch ? { fetch: d.fetch } : {}),
|
|
847
|
+
};
|
|
848
|
+
if (sub === "apply") {
|
|
849
|
+
const source = args[1];
|
|
850
|
+
if (!source) throw new DeployRefused("kedge preview apply <plan.tar | -> [--store <name>] [--name <name>]");
|
|
851
|
+
const stream = source === "-" ? readFileSync(0) : readFileSync(source);
|
|
852
|
+
const store = flag(args, "--store");
|
|
853
|
+
const a = await applyPlan({ ...common, stream, ...(store ? { store } : {}), ...(name ? { name } : {}) });
|
|
854
|
+
await a.release("applied");
|
|
855
|
+
log(`preview ${a.worker} serves ${a.url}`);
|
|
856
|
+
return { worker: a.worker, url: a.url };
|
|
857
|
+
}
|
|
858
|
+
// Both halves, then the store's scenarios, under the preview's operation to the last result (§5.3).
|
|
859
|
+
const { dir, plan } = await build();
|
|
860
|
+
const a = await applyPlan({ ...common, stream: tarPlan(dir) });
|
|
861
|
+
const identity = `${plan.store}/${plan.name}`;
|
|
862
|
+
// One Owner sign-in for every scenario file, before the cache, which then keeps its step: files signing
|
|
863
|
+
// in at once would send one step's code, and all but one be refused (codex code r29).
|
|
864
|
+
// Whatever fails after the deploy still ends the operation: left held, the next preview or destroy
|
|
865
|
+
// would need --take-over --confirm-stopped for a run that merely threw (codex code r30).
|
|
866
|
+
let code: number;
|
|
867
|
+
let cached: string;
|
|
868
|
+
try {
|
|
869
|
+
const owner = a.credentials.owner;
|
|
870
|
+
const ownerSession =
|
|
871
|
+
owner && hasScenarios(d.cwd)
|
|
872
|
+
? await signInOwner(a.url, owner, a.credentials.adminOrigin ?? "https://kedge-preview.invalid")
|
|
873
|
+
: undefined;
|
|
874
|
+
cached = cacheCredentials(d.cwd, identity, a.credentials);
|
|
875
|
+
log(`preview ${a.worker} serves ${a.url} (credentials in ${cached})`);
|
|
876
|
+
// The admin, when the store serves it (p0.9 §3.4): the preview's own origin signs in, with no ADMIN_ORIGINS.
|
|
877
|
+
if (plan.assets) log(`admin: ${a.url}/app/`);
|
|
878
|
+
code = runScenarios(d.cwd, { ...a.credentials, ...(ownerSession ? { ownerSession } : {}) }, d.env);
|
|
879
|
+
} catch (e) {
|
|
880
|
+
await a.release(`scenarios didn't run: ${(e as Error).message}`);
|
|
881
|
+
throw e;
|
|
882
|
+
}
|
|
883
|
+
await a.release(code === 0 ? "scenarios passed" : "scenarios failed");
|
|
884
|
+
if (code !== 0) throw new DeployRefused("the store's scenarios failed against the preview");
|
|
885
|
+
return {
|
|
886
|
+
worker: a.worker,
|
|
887
|
+
url: a.url,
|
|
888
|
+
admin: plan.assets ? `${a.url}/app/` : null,
|
|
889
|
+
credentials: cached,
|
|
890
|
+
scenarios: hasScenarios(d.cwd) ? "passed" : "none",
|
|
891
|
+
};
|
|
892
|
+
});
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
// ---- kedge eject, kedge upgrade (§7, §8) -------------------------------------------------------------------
|
|
896
|
+
|
|
897
|
+
export async function ejectCommand(args: readonly string[], d: CommandDeps): Promise<number> {
|
|
898
|
+
const { eject, rebase } = await import("./units");
|
|
899
|
+
const manifest = ownManifest();
|
|
900
|
+
const log = d.log ?? console.log;
|
|
901
|
+
const fail = (m: string) => {
|
|
902
|
+
(d.error ?? console.error)(m);
|
|
903
|
+
return 1;
|
|
904
|
+
};
|
|
905
|
+
if (!manifest) return fail("kedge eject needs an installed release (a workspace install has no manifest)");
|
|
906
|
+
try {
|
|
907
|
+
const r = flag(args, "--rebase");
|
|
908
|
+
if (r) {
|
|
909
|
+
rebase(d.cwd, manifest, r);
|
|
910
|
+
log(`${r} now records Kedge ${manifest.version}'s upstream`);
|
|
911
|
+
return 0;
|
|
912
|
+
}
|
|
913
|
+
const unit = args[0];
|
|
914
|
+
if (!unit || unit.startsWith("--"))
|
|
915
|
+
return fail(`usage: kedge eject <unit> --reason "<why>"; units: ${Object.keys(manifest.units).join(", ")}`);
|
|
916
|
+
const out = eject(d.cwd, manifest, unit, flag(args, "--reason") ?? "");
|
|
917
|
+
log(`wrote ${out.file}. Wire it in kedge.config.ts:\n${out.wiring}`);
|
|
918
|
+
return 0;
|
|
919
|
+
} catch (e) {
|
|
920
|
+
return fail((e as Error).message);
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
export async function upgradeCommand(
|
|
925
|
+
args: readonly string[],
|
|
926
|
+
d: CommandDeps & { kedge: (argv: string[]) => Promise<number> },
|
|
927
|
+
): Promise<number> {
|
|
928
|
+
const { upgrade, UpgradeRefused } = await import("./upgrade");
|
|
929
|
+
const to = flag(args, "--to");
|
|
930
|
+
if (!to) {
|
|
931
|
+
(d.error ?? console.error)("usage: kedge upgrade --to <version> [--from-packs <dir>]");
|
|
932
|
+
return 2;
|
|
933
|
+
}
|
|
934
|
+
try {
|
|
935
|
+
const fromPacks = flag(args, "--from-packs");
|
|
936
|
+
await upgrade({
|
|
937
|
+
cwd: d.cwd,
|
|
938
|
+
to,
|
|
939
|
+
...(fromPacks ? { fromPacks } : {}),
|
|
940
|
+
git: git(d.cwd),
|
|
941
|
+
manifest: () => {
|
|
942
|
+
// The CLI just installed is the target's: read its manifest from the store, not this process.
|
|
943
|
+
const dir = installedPackages(d.cwd).get("@kedge/cli");
|
|
944
|
+
return dir && existsSync(join(dir, "kedge-manifest.json"))
|
|
945
|
+
? JSON.parse(readFileSync(join(dir, "kedge-manifest.json"), "utf8"))
|
|
946
|
+
: null;
|
|
947
|
+
},
|
|
948
|
+
kedge: d.kedge,
|
|
949
|
+
...(d.log ? { log: d.log } : {}),
|
|
950
|
+
});
|
|
951
|
+
return 0;
|
|
952
|
+
} catch (e) {
|
|
953
|
+
if (e instanceof UpgradeRefused) {
|
|
954
|
+
(d.error ?? console.error)(e.message);
|
|
955
|
+
return 1;
|
|
956
|
+
}
|
|
957
|
+
throw e;
|
|
958
|
+
}
|
|
959
|
+
}
|