@kedge/cli 0.9.0 → 0.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -0
- package/kedge-manifest.json +93 -61
- package/package.json +8 -5
- package/src/cf.ts +295 -17
- package/src/check.ts +4 -1
- package/src/cli-session.ts +57 -0
- package/src/commands.ts +339 -90
- package/src/d1-batch.ts +68 -0
- package/src/deploy.ts +91 -19
- package/src/doctor.ts +68 -3
- package/src/keys-save.ts +343 -0
- package/src/local-state.ts +122 -0
- package/src/main.ts +181 -71
- package/src/ops.ts +18 -0
- package/src/output.ts +44 -0
- package/src/preview.ts +35 -6
- package/src/smoke.ts +226 -0
- package/src/store-api.ts +36 -0
- package/src/target.ts +81 -0
package/src/commands.ts
CHANGED
|
@@ -6,11 +6,24 @@
|
|
|
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
|
-
import type
|
|
12
|
-
import {
|
|
12
|
+
import { isPermission, type KedgeConfig } from "@kedge/core";
|
|
13
|
+
import {
|
|
14
|
+
type Cloudflare,
|
|
15
|
+
CloudflareError,
|
|
16
|
+
cloudflareFor,
|
|
17
|
+
isJurisdictionChoice,
|
|
18
|
+
type JurisdictionChoice,
|
|
19
|
+
NeedsPerson,
|
|
20
|
+
preflight,
|
|
21
|
+
UnknownOutcome,
|
|
22
|
+
wranglerBin,
|
|
23
|
+
} from "./cf";
|
|
24
|
+
import { isCliSession, OLDER_KEDGE, type SessionDeps, withCliSession } from "./cli-session";
|
|
13
25
|
import { ComposeError, compose, type Lock } from "./compose";
|
|
26
|
+
import { type Executor, wranglerExecute } from "./d1-batch";
|
|
14
27
|
import {
|
|
15
28
|
adminBuildIn,
|
|
16
29
|
appliedNames,
|
|
@@ -22,9 +35,14 @@ import {
|
|
|
22
35
|
uncertainMigrations,
|
|
23
36
|
} from "./deploy";
|
|
24
37
|
import { readWrangler } from "./doctor";
|
|
38
|
+
import { badOrigins, ENV, keysSave, type Wanted } from "./keys-save";
|
|
25
39
|
import { installedPackages, ownManifest } from "./manifest";
|
|
26
|
-
import { OperationHeld } from "./ops";
|
|
40
|
+
import { LostOwnership, OperationHeld } from "./ops";
|
|
41
|
+
import { childStdio, emit } from "./output";
|
|
42
|
+
import { needsLive, smoke } from "./smoke";
|
|
27
43
|
import { git, storeNames } from "./store";
|
|
44
|
+
import { storeRequest } from "./store-api";
|
|
45
|
+
import { devPort, resolveTarget, type StoreTarget } from "./target";
|
|
28
46
|
import { compileStoreZone } from "./tz";
|
|
29
47
|
|
|
30
48
|
export interface CommandDeps {
|
|
@@ -38,18 +56,49 @@ export interface CommandDeps {
|
|
|
38
56
|
error?: (s: string) => void;
|
|
39
57
|
/** Asks the person at the terminal; `hidden` doesn't echo (a password). Tests answer it. */
|
|
40
58
|
ask?: (question: string, hidden: boolean) => Promise<string>;
|
|
59
|
+
/** Writes a CLI session's row to `kedge dev`'s database; tests replace it. */
|
|
60
|
+
execute?: Executor;
|
|
61
|
+
/** The sign-in's stdin; tests give a stream. */
|
|
62
|
+
stdin?: Stdin;
|
|
63
|
+
/** `--json`: the command's result as one object on stdout (p0.10 §5). */
|
|
64
|
+
json?: boolean;
|
|
65
|
+
/** Where that object goes; tests read it. */
|
|
66
|
+
emit?: (result: Record<string, unknown>) => void;
|
|
41
67
|
}
|
|
42
68
|
|
|
69
|
+
/** How long `--answers-stdin` waits for each answer (p0.10 §5). */
|
|
70
|
+
export const ANSWER_WAIT_MS = 30_000;
|
|
71
|
+
|
|
72
|
+
type Stdin = NodeJS.ReadableStream & { isTTY?: boolean };
|
|
73
|
+
|
|
43
74
|
/**
|
|
44
75
|
* The terminal's answers, one reader for every question of a command: a second
|
|
45
|
-
* readline would lose what the first had buffered, so piped answers
|
|
46
|
-
*
|
|
47
|
-
*
|
|
76
|
+
* readline would lose what the first had buffered, so piped answers would reach
|
|
77
|
+
* only the first question. A hidden answer isn't echoed, so a password stays
|
|
78
|
+
* off the screen.
|
|
79
|
+
*
|
|
80
|
+
* Only a terminal is asked unless `--answers-stdin` says otherwise, and that's
|
|
81
|
+
* decided before anything is sent (p0.10 §5): an agent's tool call can hold
|
|
82
|
+
* stdin open and write nothing, and a read would then wait forever. Piped
|
|
83
|
+
* answers wait at most `ANSWER_WAIT_MS` each, as `docker login --password-stdin`
|
|
84
|
+
* is the explicit way in.
|
|
48
85
|
*/
|
|
49
|
-
export async function terminalAsker(
|
|
86
|
+
export async function terminalAsker(o: {
|
|
87
|
+
answersStdin: boolean;
|
|
88
|
+
input?: Stdin;
|
|
89
|
+
output?: NodeJS.WritableStream;
|
|
90
|
+
waitMs?: number;
|
|
91
|
+
}): Promise<{ ask: NonNullable<CommandDeps["ask"]>; close: () => void }> {
|
|
92
|
+
const input = o.input ?? process.stdin;
|
|
93
|
+
// Questions go to stderr, as git's and ssh's do: under --json stdout is the result's alone.
|
|
94
|
+
const output = o.output ?? process.stderr;
|
|
95
|
+
const tty = Boolean(input.isTTY);
|
|
96
|
+
if (!tty && !o.answersStdin)
|
|
97
|
+
throw new NeedsPerson(
|
|
98
|
+
"signing in asks for your password and authenticator code: run this command in your own terminal",
|
|
99
|
+
);
|
|
50
100
|
const { createInterface } = await import("node:readline");
|
|
51
|
-
const
|
|
52
|
-
const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: tty });
|
|
101
|
+
const rl = createInterface({ input, output, terminal: tty });
|
|
53
102
|
const out = rl as unknown as { _writeToOutput: (s: string) => void; output: NodeJS.WriteStream };
|
|
54
103
|
let muted = false;
|
|
55
104
|
const write = out._writeToOutput.bind(rl);
|
|
@@ -57,16 +106,27 @@ export async function terminalAsker(): Promise<{ ask: NonNullable<CommandDeps["a
|
|
|
57
106
|
if (!muted) write(s);
|
|
58
107
|
};
|
|
59
108
|
const lines = rl[Symbol.asyncIterator]();
|
|
109
|
+
const waitMs = o.waitMs ?? ANSWER_WAIT_MS;
|
|
60
110
|
return {
|
|
61
111
|
async ask(question, hidden) {
|
|
62
|
-
|
|
112
|
+
output.write(question);
|
|
63
113
|
muted = hidden && tty;
|
|
114
|
+
let timer: NodeJS.Timeout | undefined;
|
|
64
115
|
try {
|
|
65
|
-
|
|
116
|
+
// A person at a terminal takes the time they need; a pipe that sends nothing is an end.
|
|
117
|
+
const silence = new Promise<never>((_, reject) => {
|
|
118
|
+
if (!tty)
|
|
119
|
+
timer = setTimeout(
|
|
120
|
+
() => reject(new DeployRefused(`no answer on stdin within ${waitMs / 1000} seconds`)),
|
|
121
|
+
waitMs,
|
|
122
|
+
);
|
|
123
|
+
});
|
|
124
|
+
const next = await Promise.race([lines.next(), silence]);
|
|
66
125
|
if (next.done) throw new DeployRefused("no answer on stdin");
|
|
67
126
|
return next.value;
|
|
68
127
|
} finally {
|
|
69
|
-
|
|
128
|
+
clearTimeout(timer);
|
|
129
|
+
if (muted) output.write("\n");
|
|
70
130
|
muted = false;
|
|
71
131
|
}
|
|
72
132
|
},
|
|
@@ -124,10 +184,13 @@ export function bundleWithWrangler(cwd: string) {
|
|
|
124
184
|
};
|
|
125
185
|
}
|
|
126
186
|
|
|
127
|
-
|
|
187
|
+
/** The Cloudflare a command acts on: the given one (tests), else whatever source resolves. */
|
|
188
|
+
const cloudflareOf = async (d: CommandDeps) => d.cf ?? (await cloudflareFor(d.env, d.cwd, d.fetch));
|
|
189
|
+
|
|
190
|
+
async function deployer(d: CommandDeps, cf?: Cloudflare): Promise<Deployer> {
|
|
128
191
|
const config = await loadConfig(d.cwd);
|
|
129
192
|
return new Deployer({
|
|
130
|
-
cf:
|
|
193
|
+
cf: cf ?? (await cloudflareOf(d)),
|
|
131
194
|
cwd: d.cwd,
|
|
132
195
|
manifest: ownManifest(),
|
|
133
196
|
lock: lockOf(d.cwd),
|
|
@@ -140,26 +203,57 @@ async function deployer(d: CommandDeps): Promise<Deployer> {
|
|
|
140
203
|
});
|
|
141
204
|
}
|
|
142
205
|
|
|
143
|
-
/**
|
|
144
|
-
|
|
206
|
+
/**
|
|
207
|
+
* Turns a refusal into its message and exit code; anything else is a bug and
|
|
208
|
+
* propagates. Under `--json` the outcome is also one object on stdout: what the
|
|
209
|
+
* command made, its handoff, or its refusal (p0.10 §5).
|
|
210
|
+
*/
|
|
211
|
+
async function refusals(d: CommandDeps, work: () => Promise<unknown>): Promise<number> {
|
|
212
|
+
const result = (r: Record<string, unknown>) => {
|
|
213
|
+
if (d.json) (d.emit ?? emit)(r);
|
|
214
|
+
};
|
|
145
215
|
try {
|
|
146
|
-
|
|
216
|
+
// What the command made, when it returns it; a void command's object is only `ok`.
|
|
217
|
+
const made = await work();
|
|
218
|
+
result({ ok: true, ...(made && typeof made === "object" ? made : {}) });
|
|
147
219
|
return 0;
|
|
148
220
|
} catch (e) {
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
221
|
+
const f = failureOf(e);
|
|
222
|
+
if (!f) throw e;
|
|
223
|
+
(d.error ?? console.error)(f.line);
|
|
224
|
+
result(f.result);
|
|
225
|
+
return f.code;
|
|
154
226
|
}
|
|
155
227
|
}
|
|
156
228
|
|
|
229
|
+
/**
|
|
230
|
+
* How a command that didn't succeed ends, for a person and under `--json`
|
|
231
|
+
* (p0.10 §5): a handoff (exit 3, the last line `for you:`), or a refusal,
|
|
232
|
+
* Cloudflare refusing, or Cloudflare not answering (exit 1). An unanswered
|
|
233
|
+
* call says its outcome is unknown: the operation stays held, for
|
|
234
|
+
* `deploy --resume` or `doctor --remote` to settle. Null is a bug, which propagates.
|
|
235
|
+
*/
|
|
236
|
+
export function failureOf(e: unknown): { code: 1 | 3; line: string; result: Record<string, unknown> } | null {
|
|
237
|
+
if (e instanceof NeedsPerson)
|
|
238
|
+
return { code: 3, line: `for you: ${e.handoff}`, result: { ok: false, handoff: e.handoff } };
|
|
239
|
+
if (e instanceof UnknownOutcome)
|
|
240
|
+
return { code: 1, line: e.message, result: { ok: false, error: e.message, outcome: "unknown" } };
|
|
241
|
+
if (
|
|
242
|
+
e instanceof DeployRefused ||
|
|
243
|
+
e instanceof OperationHeld ||
|
|
244
|
+
e instanceof LostOwnership ||
|
|
245
|
+
e instanceof ComposeError ||
|
|
246
|
+
e instanceof CloudflareError
|
|
247
|
+
)
|
|
248
|
+
return { code: 1, line: e.message, result: { ok: false, error: e.message } };
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
|
|
157
252
|
export async function deployCommand(args: readonly string[], d: CommandDeps): Promise<number> {
|
|
158
253
|
return refusals(d, async () => {
|
|
159
|
-
const
|
|
160
|
-
(d
|
|
161
|
-
|
|
162
|
-
);
|
|
254
|
+
const cf = await cloudflareOf(d);
|
|
255
|
+
const dep = await deployer(d, cf);
|
|
256
|
+
(d.log ?? console.log)(`deploying ${dep.names.worker} on account ${cf.account}`);
|
|
163
257
|
const resolve = flag(args, "--resolve-intent");
|
|
164
258
|
if (resolve) return dep.resolveIntent(resolve, args.includes("--not-executed"));
|
|
165
259
|
if (args.includes("--record-active")) return dep.recordActive(flag(args, "--reason") ?? "");
|
|
@@ -172,11 +266,23 @@ export async function deployCommand(args: readonly string[], d: CommandDeps): Pr
|
|
|
172
266
|
throw new DeployRefused(
|
|
173
267
|
"--adopt-existing needs --from-release <version>: the release serving now, by your word",
|
|
174
268
|
);
|
|
175
|
-
|
|
269
|
+
// Adoption takes the store's jurisdiction from what exists, so it asks nothing; a choice is checked against it.
|
|
270
|
+
const choice = flag(args, "--jurisdiction");
|
|
271
|
+
await preflight(cf);
|
|
272
|
+
if (choice !== undefined && !isJurisdictionChoice(choice))
|
|
273
|
+
throw new DeployRefused(`--jurisdiction takes eu, us, fedramp or none, not "${choice}"`);
|
|
274
|
+
return dep.adoptExisting(
|
|
275
|
+
release,
|
|
276
|
+
(f) => readFileSync(join(d.cwd, "migrations", f), "utf8"),
|
|
277
|
+
choice as JurisdictionChoice | undefined,
|
|
278
|
+
);
|
|
176
279
|
}
|
|
177
280
|
const window = windowOf(flag(args, "--maintenance-window"));
|
|
178
281
|
const takeOver = flag(args, "--take-over");
|
|
282
|
+
const choice = flag(args, "--jurisdiction");
|
|
283
|
+
await preflight(cf, dep.names.ops, choice);
|
|
179
284
|
await dep.deploy({
|
|
285
|
+
...(choice ? { jurisdiction: choice as JurisdictionChoice } : {}),
|
|
180
286
|
vars: varsOf(args),
|
|
181
287
|
adopt: flagsOf(args, "--adopt"),
|
|
182
288
|
allowOlder: args.includes("--allow-older"),
|
|
@@ -185,18 +291,20 @@ export async function deployCommand(args: readonly string[], d: CommandDeps): Pr
|
|
|
185
291
|
...(window ? { window } : {}),
|
|
186
292
|
...(takeOver ? { takeOver } : {}),
|
|
187
293
|
});
|
|
294
|
+
// From the account's subdomain now, never a recorded URL: its owner can change it (p0.10 §4).
|
|
295
|
+
const url = `https://${dep.names.worker}.${await cf.workersSubdomain()}.workers.dev`;
|
|
296
|
+
return { worker: dep.names.worker, account: cf.account, url, admin: adminUrl(d.cwd, url) };
|
|
188
297
|
});
|
|
189
298
|
}
|
|
190
299
|
|
|
300
|
+
/** The admin's address, when the store serves it (p0.9 §3.3); null when it serves none. */
|
|
301
|
+
function adminUrl(cwd: string, url: string): string | null {
|
|
302
|
+
return readWrangler(cwd)?.config.assets ? `${url}/app/` : null;
|
|
303
|
+
}
|
|
304
|
+
|
|
191
305
|
export async function migrateCommand(args: readonly string[], d: CommandDeps): Promise<number> {
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
const r = spawnSync(process.execPath, [wranglerBin(), "d1", "migrations", "apply", "DB", "--local"], {
|
|
195
|
-
cwd: d.cwd,
|
|
196
|
-
stdio: "inherit",
|
|
197
|
-
});
|
|
198
|
-
return r.status ?? 1;
|
|
199
|
-
}
|
|
306
|
+
// Local: the dev database, through Wrangler's own bookkeeping.
|
|
307
|
+
if (!args.includes("--remote")) return migrateLocal(d);
|
|
200
308
|
return refusals(d, async () => (await deployer(d)).migrateRemote());
|
|
201
309
|
}
|
|
202
310
|
|
|
@@ -207,7 +315,7 @@ export async function migrateCommand(args: readonly string[], d: CommandDeps): P
|
|
|
207
315
|
*/
|
|
208
316
|
export async function generateSupersede(opId: string, d: CommandDeps): Promise<number> {
|
|
209
317
|
return refusals(d, async () => {
|
|
210
|
-
const cf =
|
|
318
|
+
const cf = await cloudflareOf(d);
|
|
211
319
|
const names = storeNames(d.cwd).names;
|
|
212
320
|
const { ops } = await openOps(cf, names);
|
|
213
321
|
const held = await ops.current(SCOPE);
|
|
@@ -260,8 +368,11 @@ export async function restoreCommand(args: readonly string[], d: CommandDeps): P
|
|
|
260
368
|
return 1;
|
|
261
369
|
}
|
|
262
370
|
return refusals(d, async () => {
|
|
263
|
-
const cf =
|
|
264
|
-
const
|
|
371
|
+
const cf = await cloudflareOf(d);
|
|
372
|
+
const names = storeNames(d.cwd).names;
|
|
373
|
+
// A lookup, never a create: no ops database is no preview, and seeding writes nothing to Cloudflare.
|
|
374
|
+
if (!(await cf.findD1(names.ops))) throw new DeployRefused("no ops database");
|
|
375
|
+
const { ops } = await openOps(cf, names);
|
|
265
376
|
const log = d.log ?? console.log;
|
|
266
377
|
for (const r of await ops.deployments()) {
|
|
267
378
|
const before = JSON.parse(r.before_half) as {
|
|
@@ -325,24 +436,16 @@ const SAMPLE_APPAREL = [
|
|
|
325
436
|
},
|
|
326
437
|
] as const;
|
|
327
438
|
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
const subdomain = await cf.workersSubdomain();
|
|
339
|
-
const rows = await ops.query("SELECT worker FROM preview WHERE state = 'bootstrapped'");
|
|
340
|
-
if (rows.some((r) => origin === `https://${String(r.worker)}.${subdomain}.workers.dev`)) return null;
|
|
341
|
-
} catch {
|
|
342
|
-
// no ops database reachable: not a known preview
|
|
343
|
-
}
|
|
344
|
-
return `${url} isn't local or a preview this store knows: seeding a live store needs --live, someone's choice`;
|
|
345
|
-
}
|
|
439
|
+
const isLocal = (url: string) => ["localhost", "127.0.0.1", "[::1]"].includes(new URL(url).hostname);
|
|
440
|
+
|
|
441
|
+
/** The store a URL is, for a CLI session or a check: what `resolveTarget` says, credentials resolved as for any remote command. */
|
|
442
|
+
const targetOf = (url: string, d: CommandDeps) => resolveTarget(url, d.cwd, () => cloudflareOf(d));
|
|
443
|
+
|
|
444
|
+
/** How a CLI session's row is written (p0.10 §6.2). */
|
|
445
|
+
const sessionDeps = (d: CommandDeps): SessionDeps => ({
|
|
446
|
+
cloudflare: () => cloudflareOf(d),
|
|
447
|
+
executeLocal: (b) => (d.execute ?? wranglerExecute)(b, { database: "DB", remote: false, cwd: d.cwd }),
|
|
448
|
+
});
|
|
346
449
|
|
|
347
450
|
export async function seed(
|
|
348
451
|
base: string,
|
|
@@ -352,7 +455,7 @@ export async function seed(
|
|
|
352
455
|
): Promise<string[]> {
|
|
353
456
|
const variants: string[] = [];
|
|
354
457
|
for (const p of SAMPLE_APPAREL) {
|
|
355
|
-
const res = await fetchImpl
|
|
458
|
+
const res = await storeRequest(fetchImpl, new URL("/admin/products", base), {
|
|
356
459
|
method: "POST",
|
|
357
460
|
headers: { ...auth, "content-type": "application/json", "idempotency-key": `kedge-seed-${p.handle}` },
|
|
358
461
|
body: JSON.stringify({
|
|
@@ -366,8 +469,9 @@ export async function seed(
|
|
|
366
469
|
})),
|
|
367
470
|
}),
|
|
368
471
|
});
|
|
369
|
-
const body =
|
|
472
|
+
const body = res.body as { variantIds?: string[]; error?: { code?: string } };
|
|
370
473
|
if (res.status === 409 && body.error?.code === "CONFLICT") continue; // already seeded: the handle exists
|
|
474
|
+
if (res.status === 401 && isCliSession(auth)) throw new DeployRefused(OLDER_KEDGE);
|
|
371
475
|
if (!res.ok) throw new DeployRefused(`seeding ${p.handle} failed: ${res.status} ${body.error?.code ?? ""}`);
|
|
372
476
|
variants.push(...(body.variantIds ?? []));
|
|
373
477
|
log(`seeded ${p.title}`);
|
|
@@ -375,10 +479,16 @@ export async function seed(
|
|
|
375
479
|
return variants;
|
|
376
480
|
}
|
|
377
481
|
|
|
378
|
-
|
|
482
|
+
/** `--key` or `--session`, when given. */
|
|
483
|
+
function givenAuth(args: readonly string[]): Record<string, string> | null {
|
|
379
484
|
const token = flag(args, "--key") ?? flag(args, "--session");
|
|
380
|
-
|
|
381
|
-
|
|
485
|
+
return token ? { authorization: `Bearer ${token}` } : null;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function authOf(args: readonly string[]): Record<string, string> {
|
|
489
|
+
const auth = givenAuth(args);
|
|
490
|
+
if (!auth) throw new DeployRefused("give --key <sk_…> or --session <token>");
|
|
491
|
+
return auth;
|
|
382
492
|
}
|
|
383
493
|
|
|
384
494
|
/** Ends a session the CLI made; a failure leaves it to expire, as it would anyway. */
|
|
@@ -395,9 +505,9 @@ async function signOutBearer(url: string, token: string, d: CommandDeps): Promis
|
|
|
395
505
|
* cookie they can't hand to `--session`. It sends the store's own origin, which
|
|
396
506
|
* the Worker counts as an admin origin (§4.1). The session serves one command.
|
|
397
507
|
*/
|
|
398
|
-
async function signInBearer(url: string, email: string, d: CommandDeps): Promise<string> {
|
|
508
|
+
async function signInBearer(url: string, email: string, d: CommandDeps, answersStdin: boolean): Promise<string> {
|
|
399
509
|
const f = d.fetch ?? fetch;
|
|
400
|
-
const terminal = d.ask ? null : await terminalAsker();
|
|
510
|
+
const terminal = d.ask ? null : await terminalAsker({ answersStdin, ...(d.stdin ? { input: d.stdin } : {}) });
|
|
401
511
|
try {
|
|
402
512
|
return await ceremonies(url, email, d, f, d.ask ?? (terminal as NonNullable<typeof terminal>).ask);
|
|
403
513
|
} finally {
|
|
@@ -419,13 +529,12 @@ async function ceremonies(
|
|
|
419
529
|
"content-type": "application/json",
|
|
420
530
|
};
|
|
421
531
|
const post = async (path: string, body: unknown) => {
|
|
422
|
-
const r = await f
|
|
423
|
-
const j = (await r.json().catch(() => ({}))) as Record<string, unknown>;
|
|
532
|
+
const r = await storeRequest(f, new URL(path, url), { method: "POST", headers, body: JSON.stringify(body) });
|
|
424
533
|
if (!r.ok)
|
|
425
534
|
throw new DeployRefused(
|
|
426
|
-
`${path} answered ${r.status} ${String((
|
|
535
|
+
`${path} answered ${r.status} ${String((r.body.error as { code?: string } | undefined)?.code ?? "")}`,
|
|
427
536
|
);
|
|
428
|
-
return
|
|
537
|
+
return r.body;
|
|
429
538
|
};
|
|
430
539
|
const first = await post("/auth/admin/sign-in", { email, password: await ask(`password for ${email}: `, true) });
|
|
431
540
|
const challenge = (first.challenge as { token?: string } | undefined)?.token;
|
|
@@ -456,9 +565,46 @@ export async function seedCommand(args: readonly string[], d: CommandDeps): Prom
|
|
|
456
565
|
throw new DeployRefused("kedge seed --sample apparel --url <url> --key <sk_…>");
|
|
457
566
|
const url = flag(args, "--url");
|
|
458
567
|
if (!url) throw new DeployRefused("kedge seed needs --url");
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
568
|
+
// Local, or a preview this store's ops knows by its whole origin (codex code r17); a live store's
|
|
569
|
+
// customers would see sample products, so it's the owner's call (p0.10 §5).
|
|
570
|
+
let target: StoreTarget | null = null;
|
|
571
|
+
if (!isLocal(url) && !args.includes("--live")) {
|
|
572
|
+
target = await targetOf(url, d);
|
|
573
|
+
if (target?.kind !== "preview")
|
|
574
|
+
throw new NeedsPerson(
|
|
575
|
+
`${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`,
|
|
576
|
+
);
|
|
577
|
+
}
|
|
578
|
+
const run = (auth: Record<string, string>) => seed(url, auth, d.fetch ?? fetch, d.log ?? console.log);
|
|
579
|
+
const given = givenAuth(args);
|
|
580
|
+
if (given) return { variants: await run(given) };
|
|
581
|
+
// No key or session given: a CLI session on the store the URL is, and only there (p0.10 §6.2).
|
|
582
|
+
target ??= await targetOf(url, d);
|
|
583
|
+
if (!target)
|
|
584
|
+
throw new DeployRefused(
|
|
585
|
+
`${url} isn't a store this checkout knows, so it gets no CLI session: give --key <sk_…> or --session <token>`,
|
|
586
|
+
);
|
|
587
|
+
return { variants: await withCliSession(target, ["product:write"], sessionDeps(d), run) };
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/** `kedge smoke` (p0.10 §6.3): a named test order, resumed until it has its number. */
|
|
592
|
+
export async function smokeCommand(args: readonly string[], d: CommandDeps): Promise<number> {
|
|
593
|
+
return refusals(d, async () => {
|
|
594
|
+
const url = flag(args, "--url");
|
|
595
|
+
if (!url) throw new DeployRefused("kedge smoke --url <store> [--run <name>] [--live]");
|
|
596
|
+
const name = flag(args, "--run") ?? "first";
|
|
597
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(name))
|
|
598
|
+
throw new DeployRefused("--run takes a name of letters, digits, - and _, up to 64");
|
|
599
|
+
const target = await targetOf(url, d);
|
|
600
|
+
if (!target) throw new DeployRefused(`${url} isn't a store this checkout knows: smoke runs only on its own stores`);
|
|
601
|
+
needsLive(target, args.includes("--live"));
|
|
602
|
+
return smoke(target, name, {
|
|
603
|
+
cwd: d.cwd,
|
|
604
|
+
fetch: d.fetch ?? fetch,
|
|
605
|
+
session: sessionDeps(d),
|
|
606
|
+
log: d.log ?? console.log,
|
|
607
|
+
});
|
|
462
608
|
});
|
|
463
609
|
}
|
|
464
610
|
|
|
@@ -466,9 +612,10 @@ export async function seedCommand(args: readonly string[], d: CommandDeps): Prom
|
|
|
466
612
|
export async function keysOverHttp(args: readonly string[], d: CommandDeps): Promise<number> {
|
|
467
613
|
return refusals(d, async () => {
|
|
468
614
|
const url = flag(args, "--url") as string;
|
|
615
|
+
if (args.includes("--save")) return saveKeys(url, args, d);
|
|
469
616
|
const email = flag(args, "--sign-in");
|
|
470
617
|
if (email) {
|
|
471
|
-
const token = await signInBearer(url, email, d);
|
|
618
|
+
const token = await signInBearer(url, email, d, args.includes("--answers-stdin"));
|
|
472
619
|
try {
|
|
473
620
|
return await createKeys(url, args, { authorization: `Bearer ${token}` }, d);
|
|
474
621
|
} finally {
|
|
@@ -481,11 +628,49 @@ export async function keysOverHttp(args: readonly string[], d: CommandDeps): Pro
|
|
|
481
628
|
});
|
|
482
629
|
}
|
|
483
630
|
|
|
631
|
+
/**
|
|
632
|
+
* `keys create --save` (p0.10 §6.2): through a credential reference with a
|
|
633
|
+
* CLI session, saved to `.env.local`. What it answers names what was saved,
|
|
634
|
+
* never the key: the key stays in the file, out of the agent's transcript.
|
|
635
|
+
*/
|
|
636
|
+
async function saveKeys(url: string, args: readonly string[], d: CommandDeps) {
|
|
637
|
+
if (flag(args, "--sign-in") || givenAuth(args))
|
|
638
|
+
throw new DeployRefused("--save makes its own CLI session: drop --sign-in, --key and --session");
|
|
639
|
+
const target = await targetOf(url, d);
|
|
640
|
+
if (!target)
|
|
641
|
+
throw new DeployRefused(`${url} isn't a store this checkout knows, so it gets no CLI session and no saved key`);
|
|
642
|
+
const name = flag(args, "--name") ?? "Storefront";
|
|
643
|
+
const wanted: Wanted = args.includes("--secret")
|
|
644
|
+
? { kind: "secret", name, grants: flagsOf(args, "--grant") }
|
|
645
|
+
: { kind: "publishable", name, origins: flagsOf(args, "--origin") };
|
|
646
|
+
if (wanted.kind === "secret" && wanted.grants.length === 0)
|
|
647
|
+
throw new DeployRefused("a secret key needs at least one --grant <permission>");
|
|
648
|
+
// Origins as the store accepts them, checked before anything is reused or written (codex code r16).
|
|
649
|
+
const wrong = wanted.kind === "publishable" ? badOrigins(wanted.origins) : [];
|
|
650
|
+
if (wrong.length)
|
|
651
|
+
throw new DeployRefused(
|
|
652
|
+
`not origins: ${wrong.join(", ")} (an origin is a scheme, host and optional port, like https://shop.example)`,
|
|
653
|
+
);
|
|
654
|
+
// Checked here, so a typo is one refusal under --json, before any session exists (codex code r15).
|
|
655
|
+
const bad = wanted.kind === "secret" ? wanted.grants.filter((g) => !isPermission(g)) : [];
|
|
656
|
+
if (bad.length) throw new DeployRefused(`not permissions: ${bad.join(", ")} (a permission is resource:action)`);
|
|
657
|
+
// The session that makes it holds the key's grants and api_key:write, and a session holds 16 at most.
|
|
658
|
+
if (wanted.kind === "secret" && new Set(wanted.grants).size > 15)
|
|
659
|
+
throw new DeployRefused("--save makes a secret key of at most 15 grants; make a wider one from the admin");
|
|
660
|
+
const saved = await keysSave(target, wanted, {
|
|
661
|
+
cwd: d.cwd,
|
|
662
|
+
fetch: d.fetch ?? fetch,
|
|
663
|
+
session: sessionDeps(d),
|
|
664
|
+
log: d.log ?? console.log,
|
|
665
|
+
});
|
|
666
|
+
return { saved: Object.keys(saved).sort(), storefront: saved[ENV.storefront] ?? null };
|
|
667
|
+
}
|
|
668
|
+
|
|
484
669
|
/** The keys themselves, under whichever credential the caller has (P0.8 §6.1). */
|
|
485
670
|
async function createKeys(url: string, args: readonly string[], auth: Record<string, string>, d: CommandDeps) {
|
|
486
671
|
const f = d.fetch ?? fetch;
|
|
487
672
|
const post = async (path: string, body: unknown) => {
|
|
488
|
-
const r = await f
|
|
673
|
+
const r = await storeRequest(f, new URL(path, url), {
|
|
489
674
|
method: "POST",
|
|
490
675
|
headers: {
|
|
491
676
|
...auth,
|
|
@@ -494,12 +679,11 @@ async function createKeys(url: string, args: readonly string[], auth: Record<str
|
|
|
494
679
|
},
|
|
495
680
|
body: JSON.stringify(body),
|
|
496
681
|
});
|
|
497
|
-
const j = (await r.json()) as Record<string, unknown>;
|
|
498
682
|
if (!r.ok)
|
|
499
683
|
throw new DeployRefused(
|
|
500
|
-
`${path} answered ${r.status} ${String((
|
|
684
|
+
`${path} answered ${r.status} ${String((r.body.error as { code?: string } | undefined)?.code ?? "")}`,
|
|
501
685
|
);
|
|
502
|
-
return
|
|
686
|
+
return r.body;
|
|
503
687
|
};
|
|
504
688
|
const name = flag(args, "--name") ?? "Storefront";
|
|
505
689
|
const log = d.log ?? console.log;
|
|
@@ -508,12 +692,13 @@ async function createKeys(url: string, args: readonly string[], auth: Record<str
|
|
|
508
692
|
if (!grants.length) throw new DeployRefused("a secret key needs at least one --grant <permission>");
|
|
509
693
|
const k = await post("/admin/api-keys", { name, grants });
|
|
510
694
|
log(`secret key ${String(k.id)} (shown once):\n${String(k.key)}`);
|
|
511
|
-
return;
|
|
695
|
+
return { key_id: k.id, key: k.key };
|
|
512
696
|
}
|
|
513
697
|
const sf = await post("/admin/storefronts", { name, allowed_origins: flagsOf(args, "--origin") });
|
|
514
698
|
const k = await post(`/admin/storefronts/${String(sf.id)}/keys`, { name });
|
|
515
699
|
log(`storefront ${String(sf.id)}, key ${String(k.id)}`);
|
|
516
700
|
log(`publishable key (send as x-kedge-key; shown once):\n${String(k.key)}`);
|
|
701
|
+
return { storefront: sf.id, key_id: k.id, key: k.key };
|
|
517
702
|
}
|
|
518
703
|
|
|
519
704
|
/**
|
|
@@ -553,7 +738,41 @@ export function devArgs(cwd: string): string[] {
|
|
|
553
738
|
return id ? ["--define", `KEDGE_ADMIN_BUILD:${JSON.stringify(id)}`] : [];
|
|
554
739
|
}
|
|
555
740
|
|
|
556
|
-
|
|
741
|
+
/**
|
|
742
|
+
* The local database's migrations, through Wrangler's own bookkeeping, in one
|
|
743
|
+
* line: Wrangler prints its whole status table again after each migration,
|
|
744
|
+
* thousands of lines for a new store (p0.10.1 §1, 8). Its full output is shown
|
|
745
|
+
* only when it fails.
|
|
746
|
+
*/
|
|
747
|
+
export function migrateLocal(d: Pick<CommandDeps, "cwd" | "log" | "error">): number {
|
|
748
|
+
const r = spawnSync(process.execPath, [wranglerBin(), "d1", "migrations", "apply", "DB", "--local"], {
|
|
749
|
+
cwd: d.cwd,
|
|
750
|
+
encoding: "utf8",
|
|
751
|
+
env: { ...process.env, WRANGLER_SEND_METRICS: "false" },
|
|
752
|
+
maxBuffer: 1 << 26,
|
|
753
|
+
});
|
|
754
|
+
const out = `${r.stdout ?? ""}${r.stderr ?? ""}`;
|
|
755
|
+
if (r.status !== 0) {
|
|
756
|
+
(d.error ?? console.error)(out.trim().split("\n").slice(-40).join("\n"));
|
|
757
|
+
return r.status ?? 1;
|
|
758
|
+
}
|
|
759
|
+
const n = new Set(out.match(/\b\d{4}_[a-z0-9_]+\.sql\b/g) ?? []).size;
|
|
760
|
+
(d.log ?? console.log)(
|
|
761
|
+
n ? `applied ${n} migration${n === 1 ? "" : "s"} to the local database` : "the local database is up to date",
|
|
762
|
+
);
|
|
763
|
+
return 0;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
/** Whether something already listens on the port: Wrangler would otherwise move to another, silently. */
|
|
767
|
+
function portTaken(port: number): Promise<boolean> {
|
|
768
|
+
return new Promise((resolve) => {
|
|
769
|
+
const server = createServer();
|
|
770
|
+
server.once("error", () => resolve(true));
|
|
771
|
+
server.listen(port, "127.0.0.1", () => server.close(() => resolve(false)));
|
|
772
|
+
});
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
export async function devCommand(d: CommandDeps): Promise<number> {
|
|
557
776
|
const vars = join(d.cwd, ".dev.vars");
|
|
558
777
|
if (!existsSync(vars)) {
|
|
559
778
|
const lines = [
|
|
@@ -569,11 +788,16 @@ export function devCommand(d: CommandDeps): number {
|
|
|
569
788
|
});
|
|
570
789
|
(d.log ?? console.log)("wrote .dev.vars with fresh local secrets");
|
|
571
790
|
}
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
791
|
+
// One port for dev, its invite link and the CLI's local target (p0.10.1 §3): taken is a refusal, never a move.
|
|
792
|
+
const port = devPort(d.cwd);
|
|
793
|
+
if (await portTaken(port)) {
|
|
794
|
+
(d.error ?? console.error)(
|
|
795
|
+
`port ${port} is in use: stop what's on it, or set "dev": { "port": <another> } in wrangler.jsonc`,
|
|
796
|
+
);
|
|
797
|
+
return 1;
|
|
798
|
+
}
|
|
799
|
+
const migrated = migrateLocal(d);
|
|
800
|
+
if (migrated !== 0) return migrated;
|
|
577
801
|
let extra: string[];
|
|
578
802
|
try {
|
|
579
803
|
extra = devArgs(d.cwd);
|
|
@@ -581,7 +805,20 @@ export function devCommand(d: CommandDeps): number {
|
|
|
581
805
|
(d.log ?? console.log)((e as Error).message);
|
|
582
806
|
return 1;
|
|
583
807
|
}
|
|
584
|
-
|
|
808
|
+
const origin = `http://localhost:${port}`;
|
|
809
|
+
// Where the store and its admin are, and how a person signs in to a new store's admin (p0.10.1 §1, 5).
|
|
810
|
+
(d.log ?? console.log)(
|
|
811
|
+
[
|
|
812
|
+
`the store: ${origin} its admin: ${origin}/app/`,
|
|
813
|
+
"no one can sign in to the admin until there's an Owner: pnpm kedge staff invite-owner <your email>",
|
|
814
|
+
].join("\n"),
|
|
815
|
+
);
|
|
816
|
+
return (
|
|
817
|
+
spawnSync(process.execPath, [wranglerBin(), "dev", "--port", String(port), ...extra], {
|
|
818
|
+
cwd: d.cwd,
|
|
819
|
+
stdio: "inherit",
|
|
820
|
+
}).status ?? 0
|
|
821
|
+
);
|
|
585
822
|
}
|
|
586
823
|
|
|
587
824
|
export const removeIfExists = (p: string) => rmSync(p, { recursive: true, force: true });
|
|
@@ -607,10 +844,10 @@ export async function previewCommand(args: readonly string[], d: CommandDeps): P
|
|
|
607
844
|
const sweep = sub === "destroy" && args.includes("--sweep");
|
|
608
845
|
const name = flag(args, "--name") ?? (sub === "apply" || sweep ? undefined : branchName(d.cwd));
|
|
609
846
|
if (sub === "destroy") {
|
|
610
|
-
const cf =
|
|
847
|
+
const cf = await cloudflareOf(d);
|
|
611
848
|
if (args.includes("--sweep")) {
|
|
612
849
|
await sweepPreviews({ cf, store: store(), log });
|
|
613
|
-
return;
|
|
850
|
+
return {};
|
|
614
851
|
}
|
|
615
852
|
await destroyPreview({
|
|
616
853
|
cf,
|
|
@@ -620,7 +857,7 @@ export async function previewCommand(args: readonly string[], d: CommandDeps): P
|
|
|
620
857
|
...(flag(args, "--take-over") ? { takeOver: flag(args, "--take-over") as string } : {}),
|
|
621
858
|
confirmStopped: args.includes("--confirm-stopped"),
|
|
622
859
|
});
|
|
623
|
-
return;
|
|
860
|
+
return { destroyed: name };
|
|
624
861
|
}
|
|
625
862
|
const build = async () => {
|
|
626
863
|
if (d.checkFast && (await d.checkFast()) !== 0) throw new DeployRefused("kedge check --fast failed");
|
|
@@ -637,11 +874,15 @@ export async function previewCommand(args: readonly string[], d: CommandDeps): P
|
|
|
637
874
|
if (sub === "build") {
|
|
638
875
|
const { dir } = await build();
|
|
639
876
|
log(`plan written to ${dir}`);
|
|
640
|
-
return;
|
|
877
|
+
return { plan: dir };
|
|
641
878
|
}
|
|
642
|
-
const cf =
|
|
879
|
+
const cf = await cloudflareOf(d);
|
|
880
|
+
const choice = flag(args, "--jurisdiction");
|
|
881
|
+
// A full preview builds from this checkout, so it knows its store; a plan applied from elsewhere names its own.
|
|
882
|
+
await preflight(cf, sub === "apply" ? undefined : `${store()}-ops`, choice);
|
|
643
883
|
const takeOver = flag(args, "--take-over");
|
|
644
884
|
const common = {
|
|
885
|
+
...(choice ? { jurisdiction: choice as JurisdictionChoice } : {}),
|
|
645
886
|
cf,
|
|
646
887
|
env: withPreviewKey(d),
|
|
647
888
|
secretEnv: flagsOf(args, "--secret-env"),
|
|
@@ -659,7 +900,7 @@ export async function previewCommand(args: readonly string[], d: CommandDeps): P
|
|
|
659
900
|
const a = await applyPlan({ ...common, stream, ...(store ? { store } : {}), ...(name ? { name } : {}) });
|
|
660
901
|
await a.release("applied");
|
|
661
902
|
log(`preview ${a.worker} serves ${a.url}`);
|
|
662
|
-
return;
|
|
903
|
+
return { worker: a.worker, url: a.url };
|
|
663
904
|
}
|
|
664
905
|
// Both halves, then the store's scenarios, under the preview's operation to the last result (§5.3).
|
|
665
906
|
const { dir, plan } = await build();
|
|
@@ -670,13 +911,14 @@ export async function previewCommand(args: readonly string[], d: CommandDeps): P
|
|
|
670
911
|
// Whatever fails after the deploy still ends the operation: left held, the next preview or destroy
|
|
671
912
|
// would need --take-over --confirm-stopped for a run that merely threw (codex code r30).
|
|
672
913
|
let code: number;
|
|
914
|
+
let cached: string;
|
|
673
915
|
try {
|
|
674
916
|
const owner = a.credentials.owner;
|
|
675
917
|
const ownerSession =
|
|
676
918
|
owner && hasScenarios(d.cwd)
|
|
677
919
|
? await signInOwner(a.url, owner, a.credentials.adminOrigin ?? "https://kedge-preview.invalid")
|
|
678
920
|
: undefined;
|
|
679
|
-
|
|
921
|
+
cached = cacheCredentials(d.cwd, identity, a.credentials);
|
|
680
922
|
log(`preview ${a.worker} serves ${a.url} (credentials in ${cached})`);
|
|
681
923
|
// The admin, when the store serves it (p0.9 §3.4): the preview's own origin signs in, with no ADMIN_ORIGINS.
|
|
682
924
|
if (plan.assets) log(`admin: ${a.url}/app/`);
|
|
@@ -687,6 +929,13 @@ export async function previewCommand(args: readonly string[], d: CommandDeps): P
|
|
|
687
929
|
}
|
|
688
930
|
await a.release(code === 0 ? "scenarios passed" : "scenarios failed");
|
|
689
931
|
if (code !== 0) throw new DeployRefused("the store's scenarios failed against the preview");
|
|
932
|
+
return {
|
|
933
|
+
worker: a.worker,
|
|
934
|
+
url: a.url,
|
|
935
|
+
admin: plan.assets ? `${a.url}/app/` : null,
|
|
936
|
+
credentials: cached,
|
|
937
|
+
scenarios: hasScenarios(d.cwd) ? "passed" : "none",
|
|
938
|
+
};
|
|
690
939
|
});
|
|
691
940
|
}
|
|
692
941
|
|