@kedge/cli 0.9.0 → 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/kedge-manifest.json +83 -61
- package/package.json +8 -5
- package/src/cf.ts +295 -17
- package/src/check.ts +2 -1
- package/src/cli-session.ts +57 -0
- package/src/commands.ts +278 -76
- package/src/d1-batch.ts +66 -0
- package/src/deploy.ts +91 -19
- package/src/doctor.ts +68 -3
- package/src/keys-save.ts +298 -0
- package/src/local-state.ts +110 -0
- package/src/main.ts +161 -69
- package/src/ops.ts +18 -0
- package/src/output.ts +35 -0
- package/src/preview.ts +33 -6
- package/src/smoke.ts +218 -0
- package/src/store-api.ts +36 -0
- package/src/target.ts +81 -0
package/src/commands.ts
CHANGED
|
@@ -8,9 +8,21 @@ import { spawnSync } from "node:child_process";
|
|
|
8
8
|
import { existsSync, readdirSync, readFileSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
|
|
9
9
|
import { join, resolve } from "node:path";
|
|
10
10
|
import { pathToFileURL } from "node:url";
|
|
11
|
-
import type
|
|
12
|
-
import {
|
|
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";
|
|
13
24
|
import { ComposeError, compose, type Lock } from "./compose";
|
|
25
|
+
import { type Executor, wranglerExecute } from "./d1-batch";
|
|
14
26
|
import {
|
|
15
27
|
adminBuildIn,
|
|
16
28
|
appliedNames,
|
|
@@ -22,9 +34,14 @@ import {
|
|
|
22
34
|
uncertainMigrations,
|
|
23
35
|
} from "./deploy";
|
|
24
36
|
import { readWrangler } from "./doctor";
|
|
37
|
+
import { badOrigins, ENV, keysSave, type Wanted } from "./keys-save";
|
|
25
38
|
import { installedPackages, ownManifest } from "./manifest";
|
|
26
|
-
import { OperationHeld } from "./ops";
|
|
39
|
+
import { LostOwnership, OperationHeld } from "./ops";
|
|
40
|
+
import { childStdio, emit } from "./output";
|
|
41
|
+
import { needsLive, smoke } from "./smoke";
|
|
27
42
|
import { git, storeNames } from "./store";
|
|
43
|
+
import { storeRequest } from "./store-api";
|
|
44
|
+
import { resolveTarget, type StoreTarget } from "./target";
|
|
28
45
|
import { compileStoreZone } from "./tz";
|
|
29
46
|
|
|
30
47
|
export interface CommandDeps {
|
|
@@ -38,18 +55,49 @@ export interface CommandDeps {
|
|
|
38
55
|
error?: (s: string) => void;
|
|
39
56
|
/** Asks the person at the terminal; `hidden` doesn't echo (a password). Tests answer it. */
|
|
40
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;
|
|
41
66
|
}
|
|
42
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
|
+
|
|
43
73
|
/**
|
|
44
74
|
* 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
|
-
*
|
|
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.
|
|
48
84
|
*/
|
|
49
|
-
export async function terminalAsker(
|
|
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
|
+
);
|
|
50
99
|
const { createInterface } = await import("node:readline");
|
|
51
|
-
const
|
|
52
|
-
const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: tty });
|
|
100
|
+
const rl = createInterface({ input, output, terminal: tty });
|
|
53
101
|
const out = rl as unknown as { _writeToOutput: (s: string) => void; output: NodeJS.WriteStream };
|
|
54
102
|
let muted = false;
|
|
55
103
|
const write = out._writeToOutput.bind(rl);
|
|
@@ -57,16 +105,27 @@ export async function terminalAsker(): Promise<{ ask: NonNullable<CommandDeps["a
|
|
|
57
105
|
if (!muted) write(s);
|
|
58
106
|
};
|
|
59
107
|
const lines = rl[Symbol.asyncIterator]();
|
|
108
|
+
const waitMs = o.waitMs ?? ANSWER_WAIT_MS;
|
|
60
109
|
return {
|
|
61
110
|
async ask(question, hidden) {
|
|
62
|
-
|
|
111
|
+
output.write(question);
|
|
63
112
|
muted = hidden && tty;
|
|
113
|
+
let timer: NodeJS.Timeout | undefined;
|
|
64
114
|
try {
|
|
65
|
-
|
|
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]);
|
|
66
124
|
if (next.done) throw new DeployRefused("no answer on stdin");
|
|
67
125
|
return next.value;
|
|
68
126
|
} finally {
|
|
69
|
-
|
|
127
|
+
clearTimeout(timer);
|
|
128
|
+
if (muted) output.write("\n");
|
|
70
129
|
muted = false;
|
|
71
130
|
}
|
|
72
131
|
},
|
|
@@ -124,10 +183,13 @@ export function bundleWithWrangler(cwd: string) {
|
|
|
124
183
|
};
|
|
125
184
|
}
|
|
126
185
|
|
|
127
|
-
|
|
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> {
|
|
128
190
|
const config = await loadConfig(d.cwd);
|
|
129
191
|
return new Deployer({
|
|
130
|
-
cf:
|
|
192
|
+
cf: cf ?? (await cloudflareOf(d)),
|
|
131
193
|
cwd: d.cwd,
|
|
132
194
|
manifest: ownManifest(),
|
|
133
195
|
lock: lockOf(d.cwd),
|
|
@@ -140,26 +202,57 @@ async function deployer(d: CommandDeps): Promise<Deployer> {
|
|
|
140
202
|
});
|
|
141
203
|
}
|
|
142
204
|
|
|
143
|
-
/**
|
|
144
|
-
|
|
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
|
+
};
|
|
145
214
|
try {
|
|
146
|
-
|
|
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 : {}) });
|
|
147
218
|
return 0;
|
|
148
219
|
} catch (e) {
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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;
|
|
154
225
|
}
|
|
155
226
|
}
|
|
156
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
|
+
|
|
157
251
|
export async function deployCommand(args: readonly string[], d: CommandDeps): Promise<number> {
|
|
158
252
|
return refusals(d, async () => {
|
|
159
|
-
const
|
|
160
|
-
(d
|
|
161
|
-
|
|
162
|
-
);
|
|
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}`);
|
|
163
256
|
const resolve = flag(args, "--resolve-intent");
|
|
164
257
|
if (resolve) return dep.resolveIntent(resolve, args.includes("--not-executed"));
|
|
165
258
|
if (args.includes("--record-active")) return dep.recordActive(flag(args, "--reason") ?? "");
|
|
@@ -172,11 +265,23 @@ export async function deployCommand(args: readonly string[], d: CommandDeps): Pr
|
|
|
172
265
|
throw new DeployRefused(
|
|
173
266
|
"--adopt-existing needs --from-release <version>: the release serving now, by your word",
|
|
174
267
|
);
|
|
175
|
-
|
|
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
|
+
);
|
|
176
278
|
}
|
|
177
279
|
const window = windowOf(flag(args, "--maintenance-window"));
|
|
178
280
|
const takeOver = flag(args, "--take-over");
|
|
281
|
+
const choice = flag(args, "--jurisdiction");
|
|
282
|
+
await preflight(cf, dep.names.ops, choice);
|
|
179
283
|
await dep.deploy({
|
|
284
|
+
...(choice ? { jurisdiction: choice as JurisdictionChoice } : {}),
|
|
180
285
|
vars: varsOf(args),
|
|
181
286
|
adopt: flagsOf(args, "--adopt"),
|
|
182
287
|
allowOlder: args.includes("--allow-older"),
|
|
@@ -185,15 +290,23 @@ export async function deployCommand(args: readonly string[], d: CommandDeps): Pr
|
|
|
185
290
|
...(window ? { window } : {}),
|
|
186
291
|
...(takeOver ? { takeOver } : {}),
|
|
187
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) };
|
|
188
296
|
});
|
|
189
297
|
}
|
|
190
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
|
+
|
|
191
304
|
export async function migrateCommand(args: readonly string[], d: CommandDeps): Promise<number> {
|
|
192
305
|
if (!args.includes("--remote")) {
|
|
193
306
|
// Local: the dev database, through Wrangler's own bookkeeping.
|
|
194
307
|
const r = spawnSync(process.execPath, [wranglerBin(), "d1", "migrations", "apply", "DB", "--local"], {
|
|
195
308
|
cwd: d.cwd,
|
|
196
|
-
stdio:
|
|
309
|
+
stdio: childStdio(),
|
|
197
310
|
});
|
|
198
311
|
return r.status ?? 1;
|
|
199
312
|
}
|
|
@@ -207,7 +320,7 @@ export async function migrateCommand(args: readonly string[], d: CommandDeps): P
|
|
|
207
320
|
*/
|
|
208
321
|
export async function generateSupersede(opId: string, d: CommandDeps): Promise<number> {
|
|
209
322
|
return refusals(d, async () => {
|
|
210
|
-
const cf =
|
|
323
|
+
const cf = await cloudflareOf(d);
|
|
211
324
|
const names = storeNames(d.cwd).names;
|
|
212
325
|
const { ops } = await openOps(cf, names);
|
|
213
326
|
const held = await ops.current(SCOPE);
|
|
@@ -260,8 +373,11 @@ export async function restoreCommand(args: readonly string[], d: CommandDeps): P
|
|
|
260
373
|
return 1;
|
|
261
374
|
}
|
|
262
375
|
return refusals(d, async () => {
|
|
263
|
-
const cf =
|
|
264
|
-
const
|
|
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);
|
|
265
381
|
const log = d.log ?? console.log;
|
|
266
382
|
for (const r of await ops.deployments()) {
|
|
267
383
|
const before = JSON.parse(r.before_half) as {
|
|
@@ -325,24 +441,16 @@ const SAMPLE_APPAREL = [
|
|
|
325
441
|
},
|
|
326
442
|
] as const;
|
|
327
443
|
|
|
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
|
-
}
|
|
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
|
+
});
|
|
346
454
|
|
|
347
455
|
export async function seed(
|
|
348
456
|
base: string,
|
|
@@ -352,7 +460,7 @@ export async function seed(
|
|
|
352
460
|
): Promise<string[]> {
|
|
353
461
|
const variants: string[] = [];
|
|
354
462
|
for (const p of SAMPLE_APPAREL) {
|
|
355
|
-
const res = await fetchImpl
|
|
463
|
+
const res = await storeRequest(fetchImpl, new URL("/admin/products", base), {
|
|
356
464
|
method: "POST",
|
|
357
465
|
headers: { ...auth, "content-type": "application/json", "idempotency-key": `kedge-seed-${p.handle}` },
|
|
358
466
|
body: JSON.stringify({
|
|
@@ -366,8 +474,9 @@ export async function seed(
|
|
|
366
474
|
})),
|
|
367
475
|
}),
|
|
368
476
|
});
|
|
369
|
-
const body =
|
|
477
|
+
const body = res.body as { variantIds?: string[]; error?: { code?: string } };
|
|
370
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);
|
|
371
480
|
if (!res.ok) throw new DeployRefused(`seeding ${p.handle} failed: ${res.status} ${body.error?.code ?? ""}`);
|
|
372
481
|
variants.push(...(body.variantIds ?? []));
|
|
373
482
|
log(`seeded ${p.title}`);
|
|
@@ -375,10 +484,16 @@ export async function seed(
|
|
|
375
484
|
return variants;
|
|
376
485
|
}
|
|
377
486
|
|
|
378
|
-
|
|
487
|
+
/** `--key` or `--session`, when given. */
|
|
488
|
+
function givenAuth(args: readonly string[]): Record<string, string> | null {
|
|
379
489
|
const token = flag(args, "--key") ?? flag(args, "--session");
|
|
380
|
-
|
|
381
|
-
|
|
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;
|
|
382
497
|
}
|
|
383
498
|
|
|
384
499
|
/** Ends a session the CLI made; a failure leaves it to expire, as it would anyway. */
|
|
@@ -395,9 +510,9 @@ async function signOutBearer(url: string, token: string, d: CommandDeps): Promis
|
|
|
395
510
|
* cookie they can't hand to `--session`. It sends the store's own origin, which
|
|
396
511
|
* the Worker counts as an admin origin (§4.1). The session serves one command.
|
|
397
512
|
*/
|
|
398
|
-
async function signInBearer(url: string, email: string, d: CommandDeps): Promise<string> {
|
|
513
|
+
async function signInBearer(url: string, email: string, d: CommandDeps, answersStdin: boolean): Promise<string> {
|
|
399
514
|
const f = d.fetch ?? fetch;
|
|
400
|
-
const terminal = d.ask ? null : await terminalAsker();
|
|
515
|
+
const terminal = d.ask ? null : await terminalAsker({ answersStdin, ...(d.stdin ? { input: d.stdin } : {}) });
|
|
401
516
|
try {
|
|
402
517
|
return await ceremonies(url, email, d, f, d.ask ?? (terminal as NonNullable<typeof terminal>).ask);
|
|
403
518
|
} finally {
|
|
@@ -419,13 +534,12 @@ async function ceremonies(
|
|
|
419
534
|
"content-type": "application/json",
|
|
420
535
|
};
|
|
421
536
|
const post = async (path: string, body: unknown) => {
|
|
422
|
-
const r = await f
|
|
423
|
-
const j = (await r.json().catch(() => ({}))) as Record<string, unknown>;
|
|
537
|
+
const r = await storeRequest(f, new URL(path, url), { method: "POST", headers, body: JSON.stringify(body) });
|
|
424
538
|
if (!r.ok)
|
|
425
539
|
throw new DeployRefused(
|
|
426
|
-
`${path} answered ${r.status} ${String((
|
|
540
|
+
`${path} answered ${r.status} ${String((r.body.error as { code?: string } | undefined)?.code ?? "")}`,
|
|
427
541
|
);
|
|
428
|
-
return
|
|
542
|
+
return r.body;
|
|
429
543
|
};
|
|
430
544
|
const first = await post("/auth/admin/sign-in", { email, password: await ask(`password for ${email}: `, true) });
|
|
431
545
|
const challenge = (first.challenge as { token?: string } | undefined)?.token;
|
|
@@ -456,9 +570,46 @@ export async function seedCommand(args: readonly string[], d: CommandDeps): Prom
|
|
|
456
570
|
throw new DeployRefused("kedge seed --sample apparel --url <url> --key <sk_…>");
|
|
457
571
|
const url = flag(args, "--url");
|
|
458
572
|
if (!url) throw new DeployRefused("kedge seed needs --url");
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
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
|
+
});
|
|
462
613
|
});
|
|
463
614
|
}
|
|
464
615
|
|
|
@@ -466,9 +617,10 @@ export async function seedCommand(args: readonly string[], d: CommandDeps): Prom
|
|
|
466
617
|
export async function keysOverHttp(args: readonly string[], d: CommandDeps): Promise<number> {
|
|
467
618
|
return refusals(d, async () => {
|
|
468
619
|
const url = flag(args, "--url") as string;
|
|
620
|
+
if (args.includes("--save")) return saveKeys(url, args, d);
|
|
469
621
|
const email = flag(args, "--sign-in");
|
|
470
622
|
if (email) {
|
|
471
|
-
const token = await signInBearer(url, email, d);
|
|
623
|
+
const token = await signInBearer(url, email, d, args.includes("--answers-stdin"));
|
|
472
624
|
try {
|
|
473
625
|
return await createKeys(url, args, { authorization: `Bearer ${token}` }, d);
|
|
474
626
|
} finally {
|
|
@@ -481,11 +633,49 @@ export async function keysOverHttp(args: readonly string[], d: CommandDeps): Pro
|
|
|
481
633
|
});
|
|
482
634
|
}
|
|
483
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
|
+
|
|
484
674
|
/** The keys themselves, under whichever credential the caller has (P0.8 §6.1). */
|
|
485
675
|
async function createKeys(url: string, args: readonly string[], auth: Record<string, string>, d: CommandDeps) {
|
|
486
676
|
const f = d.fetch ?? fetch;
|
|
487
677
|
const post = async (path: string, body: unknown) => {
|
|
488
|
-
const r = await f
|
|
678
|
+
const r = await storeRequest(f, new URL(path, url), {
|
|
489
679
|
method: "POST",
|
|
490
680
|
headers: {
|
|
491
681
|
...auth,
|
|
@@ -494,12 +684,11 @@ async function createKeys(url: string, args: readonly string[], auth: Record<str
|
|
|
494
684
|
},
|
|
495
685
|
body: JSON.stringify(body),
|
|
496
686
|
});
|
|
497
|
-
const j = (await r.json()) as Record<string, unknown>;
|
|
498
687
|
if (!r.ok)
|
|
499
688
|
throw new DeployRefused(
|
|
500
|
-
`${path} answered ${r.status} ${String((
|
|
689
|
+
`${path} answered ${r.status} ${String((r.body.error as { code?: string } | undefined)?.code ?? "")}`,
|
|
501
690
|
);
|
|
502
|
-
return
|
|
691
|
+
return r.body;
|
|
503
692
|
};
|
|
504
693
|
const name = flag(args, "--name") ?? "Storefront";
|
|
505
694
|
const log = d.log ?? console.log;
|
|
@@ -508,12 +697,13 @@ async function createKeys(url: string, args: readonly string[], auth: Record<str
|
|
|
508
697
|
if (!grants.length) throw new DeployRefused("a secret key needs at least one --grant <permission>");
|
|
509
698
|
const k = await post("/admin/api-keys", { name, grants });
|
|
510
699
|
log(`secret key ${String(k.id)} (shown once):\n${String(k.key)}`);
|
|
511
|
-
return;
|
|
700
|
+
return { key_id: k.id, key: k.key };
|
|
512
701
|
}
|
|
513
702
|
const sf = await post("/admin/storefronts", { name, allowed_origins: flagsOf(args, "--origin") });
|
|
514
703
|
const k = await post(`/admin/storefronts/${String(sf.id)}/keys`, { name });
|
|
515
704
|
log(`storefront ${String(sf.id)}, key ${String(k.id)}`);
|
|
516
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 };
|
|
517
707
|
}
|
|
518
708
|
|
|
519
709
|
/**
|
|
@@ -607,10 +797,10 @@ export async function previewCommand(args: readonly string[], d: CommandDeps): P
|
|
|
607
797
|
const sweep = sub === "destroy" && args.includes("--sweep");
|
|
608
798
|
const name = flag(args, "--name") ?? (sub === "apply" || sweep ? undefined : branchName(d.cwd));
|
|
609
799
|
if (sub === "destroy") {
|
|
610
|
-
const cf =
|
|
800
|
+
const cf = await cloudflareOf(d);
|
|
611
801
|
if (args.includes("--sweep")) {
|
|
612
802
|
await sweepPreviews({ cf, store: store(), log });
|
|
613
|
-
return;
|
|
803
|
+
return {};
|
|
614
804
|
}
|
|
615
805
|
await destroyPreview({
|
|
616
806
|
cf,
|
|
@@ -620,7 +810,7 @@ export async function previewCommand(args: readonly string[], d: CommandDeps): P
|
|
|
620
810
|
...(flag(args, "--take-over") ? { takeOver: flag(args, "--take-over") as string } : {}),
|
|
621
811
|
confirmStopped: args.includes("--confirm-stopped"),
|
|
622
812
|
});
|
|
623
|
-
return;
|
|
813
|
+
return { destroyed: name };
|
|
624
814
|
}
|
|
625
815
|
const build = async () => {
|
|
626
816
|
if (d.checkFast && (await d.checkFast()) !== 0) throw new DeployRefused("kedge check --fast failed");
|
|
@@ -637,11 +827,15 @@ export async function previewCommand(args: readonly string[], d: CommandDeps): P
|
|
|
637
827
|
if (sub === "build") {
|
|
638
828
|
const { dir } = await build();
|
|
639
829
|
log(`plan written to ${dir}`);
|
|
640
|
-
return;
|
|
830
|
+
return { plan: dir };
|
|
641
831
|
}
|
|
642
|
-
const cf =
|
|
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);
|
|
643
836
|
const takeOver = flag(args, "--take-over");
|
|
644
837
|
const common = {
|
|
838
|
+
...(choice ? { jurisdiction: choice as JurisdictionChoice } : {}),
|
|
645
839
|
cf,
|
|
646
840
|
env: withPreviewKey(d),
|
|
647
841
|
secretEnv: flagsOf(args, "--secret-env"),
|
|
@@ -659,7 +853,7 @@ export async function previewCommand(args: readonly string[], d: CommandDeps): P
|
|
|
659
853
|
const a = await applyPlan({ ...common, stream, ...(store ? { store } : {}), ...(name ? { name } : {}) });
|
|
660
854
|
await a.release("applied");
|
|
661
855
|
log(`preview ${a.worker} serves ${a.url}`);
|
|
662
|
-
return;
|
|
856
|
+
return { worker: a.worker, url: a.url };
|
|
663
857
|
}
|
|
664
858
|
// Both halves, then the store's scenarios, under the preview's operation to the last result (§5.3).
|
|
665
859
|
const { dir, plan } = await build();
|
|
@@ -670,13 +864,14 @@ export async function previewCommand(args: readonly string[], d: CommandDeps): P
|
|
|
670
864
|
// Whatever fails after the deploy still ends the operation: left held, the next preview or destroy
|
|
671
865
|
// would need --take-over --confirm-stopped for a run that merely threw (codex code r30).
|
|
672
866
|
let code: number;
|
|
867
|
+
let cached: string;
|
|
673
868
|
try {
|
|
674
869
|
const owner = a.credentials.owner;
|
|
675
870
|
const ownerSession =
|
|
676
871
|
owner && hasScenarios(d.cwd)
|
|
677
872
|
? await signInOwner(a.url, owner, a.credentials.adminOrigin ?? "https://kedge-preview.invalid")
|
|
678
873
|
: undefined;
|
|
679
|
-
|
|
874
|
+
cached = cacheCredentials(d.cwd, identity, a.credentials);
|
|
680
875
|
log(`preview ${a.worker} serves ${a.url} (credentials in ${cached})`);
|
|
681
876
|
// The admin, when the store serves it (p0.9 §3.4): the preview's own origin signs in, with no ADMIN_ORIGINS.
|
|
682
877
|
if (plan.assets) log(`admin: ${a.url}/app/`);
|
|
@@ -687,6 +882,13 @@ export async function previewCommand(args: readonly string[], d: CommandDeps): P
|
|
|
687
882
|
}
|
|
688
883
|
await a.release(code === 0 ? "scenarios passed" : "scenarios failed");
|
|
689
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
|
+
};
|
|
690
892
|
});
|
|
691
893
|
}
|
|
692
894
|
|