@kedge/cli 0.0.0-stage → 0.9.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 +396 -0
- package/package.json +40 -3
- package/src/cf.ts +451 -0
- package/src/check.ts +44 -0
- package/src/code-lint.ts +339 -0
- package/src/commands.ts +757 -0
- package/src/compose.ts +637 -0
- package/src/deploy.ts +1610 -0
- package/src/doctor.ts +636 -0
- package/src/fork.ts +67 -0
- package/src/index.ts +12 -0
- package/src/invariants.ts +168 -0
- package/src/main.ts +378 -0
- package/src/manifest.ts +163 -0
- package/src/n1.ts +217 -0
- package/src/ops.ts +355 -0
- package/src/order.ts +7 -0
- package/src/plugin-lint.ts +145 -0
- package/src/preview.ts +1011 -0
- package/src/schema.ts +72 -0
- package/src/sqlite-verify.ts +333 -0
- package/src/store.ts +132 -0
- package/src/tar.ts +125 -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,757 @@
|
|
|
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 type { KedgeConfig } from "@kedge/core";
|
|
12
|
+
import { type Cloudflare, restCloudflare, wranglerBin } from "./cf";
|
|
13
|
+
import { ComposeError, compose, type Lock } from "./compose";
|
|
14
|
+
import {
|
|
15
|
+
adminBuildIn,
|
|
16
|
+
appliedNames,
|
|
17
|
+
Deployer,
|
|
18
|
+
DeployRefused,
|
|
19
|
+
openOps,
|
|
20
|
+
SCOPE,
|
|
21
|
+
supersededLock,
|
|
22
|
+
uncertainMigrations,
|
|
23
|
+
} from "./deploy";
|
|
24
|
+
import { readWrangler } from "./doctor";
|
|
25
|
+
import { installedPackages, ownManifest } from "./manifest";
|
|
26
|
+
import { OperationHeld } from "./ops";
|
|
27
|
+
import { git, storeNames } from "./store";
|
|
28
|
+
import { compileStoreZone } from "./tz";
|
|
29
|
+
|
|
30
|
+
export interface CommandDeps {
|
|
31
|
+
cwd: string;
|
|
32
|
+
env: NodeJS.ProcessEnv;
|
|
33
|
+
coreDir: string;
|
|
34
|
+
cf?: Cloudflare;
|
|
35
|
+
fetch?: typeof fetch;
|
|
36
|
+
checkFast?: () => Promise<number>;
|
|
37
|
+
log?: (s: string) => void;
|
|
38
|
+
error?: (s: string) => void;
|
|
39
|
+
/** Asks the person at the terminal; `hidden` doesn't echo (a password). Tests answer it. */
|
|
40
|
+
ask?: (question: string, hidden: boolean) => Promise<string>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 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 (an agent
|
|
46
|
+
* writing the password and code to stdin) would reach only the first question.
|
|
47
|
+
* A hidden answer isn't echoed, so a password stays off the screen.
|
|
48
|
+
*/
|
|
49
|
+
export async function terminalAsker(): Promise<{ ask: NonNullable<CommandDeps["ask"]>; close: () => void }> {
|
|
50
|
+
const { createInterface } = await import("node:readline");
|
|
51
|
+
const tty = Boolean(process.stdin.isTTY);
|
|
52
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: tty });
|
|
53
|
+
const out = rl as unknown as { _writeToOutput: (s: string) => void; output: NodeJS.WriteStream };
|
|
54
|
+
let muted = false;
|
|
55
|
+
const write = out._writeToOutput.bind(rl);
|
|
56
|
+
out._writeToOutput = (s: string) => {
|
|
57
|
+
if (!muted) write(s);
|
|
58
|
+
};
|
|
59
|
+
const lines = rl[Symbol.asyncIterator]();
|
|
60
|
+
return {
|
|
61
|
+
async ask(question, hidden) {
|
|
62
|
+
process.stdout.write(question);
|
|
63
|
+
muted = hidden && tty;
|
|
64
|
+
try {
|
|
65
|
+
const next = await lines.next();
|
|
66
|
+
if (next.done) throw new DeployRefused("no answer on stdin");
|
|
67
|
+
return next.value;
|
|
68
|
+
} finally {
|
|
69
|
+
if (muted) process.stdout.write("\n");
|
|
70
|
+
muted = false;
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
close: () => rl.close(),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function flag(args: readonly string[], name: string): string | undefined {
|
|
78
|
+
const i = args.indexOf(name);
|
|
79
|
+
return i >= 0 ? args[i + 1] : undefined;
|
|
80
|
+
}
|
|
81
|
+
export function flagsOf(args: readonly string[], name: string): string[] {
|
|
82
|
+
return args.flatMap((a, i) => (a === name && args[i + 1] ? [args[i + 1] as string] : []));
|
|
83
|
+
}
|
|
84
|
+
export function varsOf(args: readonly string[]): Record<string, string> {
|
|
85
|
+
const out: Record<string, string> = {};
|
|
86
|
+
for (const v of flagsOf(args, "--var")) {
|
|
87
|
+
const i = v.indexOf("=");
|
|
88
|
+
if (i < 1) throw new DeployRefused(`--var takes KEY=VALUE, not "${v}"`);
|
|
89
|
+
out[v.slice(0, i)] = v.slice(i + 1);
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
/** `<from>/<to>`, each an ISO instant. */
|
|
94
|
+
export function windowOf(arg: string | undefined): { from: number; to: number } | undefined {
|
|
95
|
+
if (!arg) return undefined;
|
|
96
|
+
const [a, b] = arg.split("/");
|
|
97
|
+
const from = Date.parse(a ?? "");
|
|
98
|
+
const to = Date.parse(b ?? "");
|
|
99
|
+
if (!Number.isFinite(from) || !Number.isFinite(to) || to <= from)
|
|
100
|
+
throw new DeployRefused("--maintenance-window takes <from>/<to>, two ISO instants, from before to");
|
|
101
|
+
return { from, to };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const loadConfig = async (cwd: string) =>
|
|
105
|
+
((await import(pathToFileURL(join(cwd, "kedge.config.ts")).href)) as { default: KedgeConfig }).default;
|
|
106
|
+
const lockOf = (cwd: string) => JSON.parse(readFileSync(join(cwd, "kedge.lock.json"), "utf8")) as Lock;
|
|
107
|
+
|
|
108
|
+
/** `wrangler deploy --dry-run --outdir`: bundles the store's code on this (trusted) machine. */
|
|
109
|
+
export function bundleWithWrangler(cwd: string) {
|
|
110
|
+
return (config: string, outdir: string) => {
|
|
111
|
+
const r = spawnSync(process.execPath, [wranglerBin(), "deploy", "--dry-run", "--outdir", outdir, "-c", config], {
|
|
112
|
+
cwd,
|
|
113
|
+
encoding: "utf8",
|
|
114
|
+
env: { ...process.env, WRANGLER_SEND_METRICS: "false" },
|
|
115
|
+
});
|
|
116
|
+
if (r.status !== 0)
|
|
117
|
+
throw new DeployRefused(
|
|
118
|
+
`bundling failed:\n${`${r.stdout}\n${r.stderr}`.trim().split("\n").slice(-15).join("\n")}`,
|
|
119
|
+
);
|
|
120
|
+
// Only what uploads stays: Wrangler's README.md and source maps name paths relative to the output
|
|
121
|
+
// directory, so a resume's rebuild elsewhere never reproduced the plan (research/26), and
|
|
122
|
+
// `versions upload --no-bundle` sends neither.
|
|
123
|
+
for (const f of readdirSync(outdir)) if (f === "README.md" || f.endsWith(".map")) rmSync(join(outdir, f));
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function deployer(d: CommandDeps): Promise<Deployer> {
|
|
128
|
+
const config = await loadConfig(d.cwd);
|
|
129
|
+
return new Deployer({
|
|
130
|
+
cf: d.cf ?? restCloudflare(d.env, d.fetch),
|
|
131
|
+
cwd: d.cwd,
|
|
132
|
+
manifest: ownManifest(),
|
|
133
|
+
lock: lockOf(d.cwd),
|
|
134
|
+
git: git(d.cwd),
|
|
135
|
+
bundle: bundleWithWrangler(d.cwd),
|
|
136
|
+
checkFast: d.checkFast ?? (async () => 0),
|
|
137
|
+
plugins: config.plugins ?? [],
|
|
138
|
+
env: d.env,
|
|
139
|
+
...(d.log ? { log: d.log } : {}),
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Turns a refusal into its message and exit code; anything else is a bug and propagates. */
|
|
144
|
+
async function refusals(d: CommandDeps, work: () => Promise<void>): Promise<number> {
|
|
145
|
+
try {
|
|
146
|
+
await work();
|
|
147
|
+
return 0;
|
|
148
|
+
} catch (e) {
|
|
149
|
+
if (e instanceof DeployRefused || e instanceof OperationHeld || e instanceof ComposeError) {
|
|
150
|
+
(d.error ?? console.error)(e.message);
|
|
151
|
+
return 1;
|
|
152
|
+
}
|
|
153
|
+
throw e;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export async function deployCommand(args: readonly string[], d: CommandDeps): Promise<number> {
|
|
158
|
+
return refusals(d, async () => {
|
|
159
|
+
const dep = await deployer(d);
|
|
160
|
+
(d.log ?? console.log)(
|
|
161
|
+
`deploying ${dep.names.worker} on account ${(d.cf ?? restCloudflare(d.env, d.fetch)).account}`,
|
|
162
|
+
);
|
|
163
|
+
const resolve = flag(args, "--resolve-intent");
|
|
164
|
+
if (resolve) return dep.resolveIntent(resolve, args.includes("--not-executed"));
|
|
165
|
+
if (args.includes("--record-active")) return dep.recordActive(flag(args, "--reason") ?? "");
|
|
166
|
+
if (args.includes("--abort-cutover")) return dep.abortCutover();
|
|
167
|
+
const supersede = flag(args, "--supersede");
|
|
168
|
+
if (supersede) return dep.supersede(supersede);
|
|
169
|
+
if (args.includes("--adopt-existing")) {
|
|
170
|
+
const release = flag(args, "--from-release");
|
|
171
|
+
if (!release)
|
|
172
|
+
throw new DeployRefused(
|
|
173
|
+
"--adopt-existing needs --from-release <version>: the release serving now, by your word",
|
|
174
|
+
);
|
|
175
|
+
return dep.adoptExisting(release, (f) => readFileSync(join(d.cwd, "migrations", f), "utf8"));
|
|
176
|
+
}
|
|
177
|
+
const window = windowOf(flag(args, "--maintenance-window"));
|
|
178
|
+
const takeOver = flag(args, "--take-over");
|
|
179
|
+
await dep.deploy({
|
|
180
|
+
vars: varsOf(args),
|
|
181
|
+
adopt: flagsOf(args, "--adopt"),
|
|
182
|
+
allowOlder: args.includes("--allow-older"),
|
|
183
|
+
resume: args.includes("--resume"),
|
|
184
|
+
confirmStopped: args.includes("--confirm-stopped"),
|
|
185
|
+
...(window ? { window } : {}),
|
|
186
|
+
...(takeOver ? { takeOver } : {}),
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export async function migrateCommand(args: readonly string[], d: CommandDeps): Promise<number> {
|
|
192
|
+
if (!args.includes("--remote")) {
|
|
193
|
+
// Local: the dev database, through Wrangler's own bookkeeping.
|
|
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
|
+
}
|
|
200
|
+
return refusals(d, async () => (await deployer(d)).migrateRemote());
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* `kedge generate --supersede <op-id>` (§6.2): holding the stopped operation,
|
|
205
|
+
* the applied prefix kept byte for byte and the proven-unapplied suffix
|
|
206
|
+
* recomposed from the corrected sources.
|
|
207
|
+
*/
|
|
208
|
+
export async function generateSupersede(opId: string, d: CommandDeps): Promise<number> {
|
|
209
|
+
return refusals(d, async () => {
|
|
210
|
+
const cf = d.cf ?? restCloudflare(d.env, d.fetch);
|
|
211
|
+
const names = storeNames(d.cwd).names;
|
|
212
|
+
const { ops } = await openOps(cf, names);
|
|
213
|
+
const held = await ops.current(SCOPE);
|
|
214
|
+
if (!held || held.op_id !== opId) throw new DeployRefused(`no operation ${opId} holds this store`);
|
|
215
|
+
await ops.resume(SCOPE, opId);
|
|
216
|
+
try {
|
|
217
|
+
const db = await ops.resource("d1", names.database);
|
|
218
|
+
if (!db?.id) throw new DeployRefused("no database is recorded for this store");
|
|
219
|
+
const applied = await appliedNames(cf, db.id);
|
|
220
|
+
const { lock, removed } = supersededLock(lockOf(d.cwd), applied);
|
|
221
|
+
// Before anything is deleted: a submitted file that could still land can't be recomposed (codex code r21).
|
|
222
|
+
const uncertain = (await uncertainMigrations(ops, opId)).flatMap((i) =>
|
|
223
|
+
(JSON.parse(i.detail) as { files: string[] }).files.filter((f) => removed.includes(f)),
|
|
224
|
+
);
|
|
225
|
+
if (uncertain.length)
|
|
226
|
+
throw new DeployRefused(
|
|
227
|
+
`${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`,
|
|
228
|
+
);
|
|
229
|
+
const config = await loadConfig(d.cwd);
|
|
230
|
+
for (const f of removed) if (existsSync(join(d.cwd, "migrations", f))) unlinkSync(join(d.cwd, "migrations", f));
|
|
231
|
+
writeFileSync(join(d.cwd, "kedge.lock.json"), `${JSON.stringify(lock, null, 2)}\n`);
|
|
232
|
+
const result = compose({
|
|
233
|
+
coreDir: d.coreDir,
|
|
234
|
+
plugins: config.plugins ?? [],
|
|
235
|
+
// A preview's own ports are locked like any other's (p0.8 §5.2).
|
|
236
|
+
ports: [...(config.ports ?? []), ...(config.preview?.ports ?? [])],
|
|
237
|
+
localDir: join(d.cwd, "local"),
|
|
238
|
+
outDir: join(d.cwd, "migrations"),
|
|
239
|
+
lockPath: join(d.cwd, "kedge.lock.json"),
|
|
240
|
+
now: Date.now(),
|
|
241
|
+
...(config.timezone ? { timezone: config.timezone } : {}),
|
|
242
|
+
compileZone: compileStoreZone,
|
|
243
|
+
tzPath: join(d.cwd, "src", "kedge.tz.ts"),
|
|
244
|
+
});
|
|
245
|
+
(d.log ?? console.log)(
|
|
246
|
+
`kept ${applied.length} applied; recomposed ${result.written.filter((f) => f.endsWith(".sql")).join(", ") || "nothing"}. Commit, then kedge deploy --supersede ${opId}`,
|
|
247
|
+
);
|
|
248
|
+
} finally {
|
|
249
|
+
await ops.stop(SCOPE, opId);
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** `kedge restore --list` (§6.5): restore points. Restoring waits for P1.18's barrier. */
|
|
255
|
+
export async function restoreCommand(args: readonly string[], d: CommandDeps): Promise<number> {
|
|
256
|
+
if (!args.includes("--list")) {
|
|
257
|
+
(d.error ?? console.error)(
|
|
258
|
+
"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.",
|
|
259
|
+
);
|
|
260
|
+
return 1;
|
|
261
|
+
}
|
|
262
|
+
return refusals(d, async () => {
|
|
263
|
+
const cf = d.cf ?? restCloudflare(d.env, d.fetch);
|
|
264
|
+
const { ops } = await openOps(cf, storeNames(d.cwd).names);
|
|
265
|
+
const log = d.log ?? console.log;
|
|
266
|
+
for (const r of await ops.deployments()) {
|
|
267
|
+
const before = JSON.parse(r.before_half) as {
|
|
268
|
+
bookmark?: string;
|
|
269
|
+
versionId?: string | null;
|
|
270
|
+
release?: string | null;
|
|
271
|
+
};
|
|
272
|
+
const after = r.after_half ? (JSON.parse(r.after_half) as { versionId?: string; release?: string }) : null;
|
|
273
|
+
log(
|
|
274
|
+
`${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"}`,
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
log(
|
|
278
|
+
"These are D1 Time Travel bookmarks taken before each deploy's migrations. Restoring through Kedge arrives with P1.18.",
|
|
279
|
+
);
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export async function secretCommand(args: readonly string[], d: CommandDeps): Promise<number> {
|
|
284
|
+
const [sub, name] = args;
|
|
285
|
+
if ((sub !== "put" && sub !== "delete") || !name) {
|
|
286
|
+
(d.error ?? console.error)("usage: kedge secret put <NAME> (value on stdin) | kedge secret delete <NAME>");
|
|
287
|
+
return 2;
|
|
288
|
+
}
|
|
289
|
+
return refusals(d, async () => {
|
|
290
|
+
const value = sub === "put" ? readFileSync(0, "utf8").replace(/\r?\n$/, "") : null;
|
|
291
|
+
if (value !== null && value.length < 32) throw new DeployRefused("a Kedge secret is at least 32 bytes");
|
|
292
|
+
await (await deployer(d)).secret(name, value);
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// ---- against a running store's HTTP API -------------------------------------------------------------------
|
|
297
|
+
|
|
298
|
+
const SAMPLE_APPAREL = [
|
|
299
|
+
{
|
|
300
|
+
title: "Linen shirt",
|
|
301
|
+
handle: "linen-shirt",
|
|
302
|
+
variants: [
|
|
303
|
+
["S", 4900],
|
|
304
|
+
["M", 4900],
|
|
305
|
+
["L", 5200],
|
|
306
|
+
],
|
|
307
|
+
},
|
|
308
|
+
{ title: "Canvas tote", handle: "canvas-tote", variants: [["One size", 1800]] },
|
|
309
|
+
{
|
|
310
|
+
title: "Wool beanie",
|
|
311
|
+
handle: "wool-beanie",
|
|
312
|
+
variants: [
|
|
313
|
+
["Grey", 2400],
|
|
314
|
+
["Navy", 2400],
|
|
315
|
+
],
|
|
316
|
+
},
|
|
317
|
+
{
|
|
318
|
+
title: "Selvedge jeans",
|
|
319
|
+
handle: "selvedge-jeans",
|
|
320
|
+
variants: [
|
|
321
|
+
["30", 12000],
|
|
322
|
+
["32", 12000],
|
|
323
|
+
["34", 12000],
|
|
324
|
+
],
|
|
325
|
+
},
|
|
326
|
+
] as const;
|
|
327
|
+
|
|
328
|
+
/** Local, or a preview this store's ops knows; anything else only with --live (§3). */
|
|
329
|
+
async function seedTarget(url: string, args: readonly string[], d: CommandDeps): Promise<string | null> {
|
|
330
|
+
const host = new URL(url).hostname;
|
|
331
|
+
if (host === "localhost" || host === "127.0.0.1" || host === "[::1]") return null;
|
|
332
|
+
if (args.includes("--live")) return null;
|
|
333
|
+
try {
|
|
334
|
+
const cf = d.cf ?? restCloudflare(d.env, d.fetch);
|
|
335
|
+
const { ops } = await openOps(cf, storeNames(d.cwd).names);
|
|
336
|
+
// The whole origin, as a preview's is made: a host that only begins with a preview's name isn't it (codex code r17).
|
|
337
|
+
const origin = new URL(url).origin;
|
|
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
|
+
}
|
|
346
|
+
|
|
347
|
+
export async function seed(
|
|
348
|
+
base: string,
|
|
349
|
+
auth: Record<string, string>,
|
|
350
|
+
fetchImpl: typeof fetch = fetch,
|
|
351
|
+
log: (s: string) => void = console.log,
|
|
352
|
+
): Promise<string[]> {
|
|
353
|
+
const variants: string[] = [];
|
|
354
|
+
for (const p of SAMPLE_APPAREL) {
|
|
355
|
+
const res = await fetchImpl(new URL("/admin/products", base), {
|
|
356
|
+
method: "POST",
|
|
357
|
+
headers: { ...auth, "content-type": "application/json", "idempotency-key": `kedge-seed-${p.handle}` },
|
|
358
|
+
body: JSON.stringify({
|
|
359
|
+
title: p.title,
|
|
360
|
+
handle: p.handle,
|
|
361
|
+
variants: p.variants.map(([title, price]) => ({
|
|
362
|
+
sku: `${p.handle}-${title}`.toUpperCase().replace(/[^A-Z0-9]+/g, "-"),
|
|
363
|
+
title,
|
|
364
|
+
prices: { USD: price },
|
|
365
|
+
stock: { loc_default: 50 },
|
|
366
|
+
})),
|
|
367
|
+
}),
|
|
368
|
+
});
|
|
369
|
+
const body = (await res.json()) as { variantIds?: string[]; error?: { code?: string } };
|
|
370
|
+
if (res.status === 409 && body.error?.code === "CONFLICT") continue; // already seeded: the handle exists
|
|
371
|
+
if (!res.ok) throw new DeployRefused(`seeding ${p.handle} failed: ${res.status} ${body.error?.code ?? ""}`);
|
|
372
|
+
variants.push(...(body.variantIds ?? []));
|
|
373
|
+
log(`seeded ${p.title}`);
|
|
374
|
+
}
|
|
375
|
+
return variants;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function authOf(args: readonly string[]): Record<string, string> {
|
|
379
|
+
const token = flag(args, "--key") ?? flag(args, "--session");
|
|
380
|
+
if (!token) throw new DeployRefused("give --key <sk_…> or --session <token>");
|
|
381
|
+
return { authorization: `Bearer ${token}` };
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** Ends a session the CLI made; a failure leaves it to expire, as it would anyway. */
|
|
385
|
+
async function signOutBearer(url: string, token: string, d: CommandDeps): Promise<void> {
|
|
386
|
+
await (d.fetch ?? fetch)(new URL("/auth/admin/sign-out", url), {
|
|
387
|
+
method: "POST",
|
|
388
|
+
headers: { authorization: `Bearer ${token}` },
|
|
389
|
+
}).catch(() => undefined);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* `--sign-in <email>` (p0.9 §7.3): P0.5's bearer ceremonies run by the CLI, a
|
|
394
|
+
* password then a TOTP code, because an admin user's own session is an HttpOnly
|
|
395
|
+
* cookie they can't hand to `--session`. It sends the store's own origin, which
|
|
396
|
+
* the Worker counts as an admin origin (§4.1). The session serves one command.
|
|
397
|
+
*/
|
|
398
|
+
async function signInBearer(url: string, email: string, d: CommandDeps): Promise<string> {
|
|
399
|
+
const f = d.fetch ?? fetch;
|
|
400
|
+
const terminal = d.ask ? null : await terminalAsker();
|
|
401
|
+
try {
|
|
402
|
+
return await ceremonies(url, email, d, f, d.ask ?? (terminal as NonNullable<typeof terminal>).ask);
|
|
403
|
+
} finally {
|
|
404
|
+
terminal?.close();
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
async function ceremonies(
|
|
409
|
+
url: string,
|
|
410
|
+
email: string,
|
|
411
|
+
d: CommandDeps,
|
|
412
|
+
f: typeof fetch,
|
|
413
|
+
ask: NonNullable<CommandDeps["ask"]>,
|
|
414
|
+
): Promise<string> {
|
|
415
|
+
const headers = {
|
|
416
|
+
origin: new URL(url).origin,
|
|
417
|
+
"x-kedge-csrf": "1",
|
|
418
|
+
"x-kedge-session-mode": "bearer",
|
|
419
|
+
"content-type": "application/json",
|
|
420
|
+
};
|
|
421
|
+
const post = async (path: string, body: unknown) => {
|
|
422
|
+
const r = await f(new URL(path, url), { method: "POST", headers, body: JSON.stringify(body) });
|
|
423
|
+
const j = (await r.json().catch(() => ({}))) as Record<string, unknown>;
|
|
424
|
+
if (!r.ok)
|
|
425
|
+
throw new DeployRefused(
|
|
426
|
+
`${path} answered ${r.status} ${String((j.error as { code?: string } | undefined)?.code ?? "")}`,
|
|
427
|
+
);
|
|
428
|
+
return j;
|
|
429
|
+
};
|
|
430
|
+
const first = await post("/auth/admin/sign-in", { email, password: await ask(`password for ${email}: `, true) });
|
|
431
|
+
const challenge = (first.challenge as { token?: string } | undefined)?.token;
|
|
432
|
+
// Every staff account has a second factor once its invite completes, so a
|
|
433
|
+
// weak session here is one that couldn't create keys anyway; it's ended, not left behind.
|
|
434
|
+
if (!challenge) {
|
|
435
|
+
if (typeof first.session_token === "string") await signOutBearer(url, first.session_token, d);
|
|
436
|
+
throw new DeployRefused(`${email} has no second factor yet: finish its invite in the admin first`);
|
|
437
|
+
}
|
|
438
|
+
// A mistyped code is a 401 that keeps the challenge (p0.9 §11), so it asks
|
|
439
|
+
// again; each wrong code counts toward P0.5's lockout, so not forever.
|
|
440
|
+
for (let attempt = 1; ; attempt++) {
|
|
441
|
+
const code = (await ask("authenticator code: ", false)).trim();
|
|
442
|
+
try {
|
|
443
|
+
const strong = await post("/auth/admin/totp/challenge", { challenge_token: challenge, code });
|
|
444
|
+
if (typeof strong.session_token !== "string") throw new DeployRefused("the TOTP challenge answered no session");
|
|
445
|
+
return strong.session_token;
|
|
446
|
+
} catch (e) {
|
|
447
|
+
if (attempt >= 3 || !(e instanceof DeployRefused) || !/ 401 UNAUTHORIZED$/.test(e.message)) throw e;
|
|
448
|
+
(d.error ?? console.error)("that code wasn't accepted; try the next one");
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
export async function seedCommand(args: readonly string[], d: CommandDeps): Promise<number> {
|
|
454
|
+
return refusals(d, async () => {
|
|
455
|
+
if (flag(args, "--sample") !== "apparel")
|
|
456
|
+
throw new DeployRefused("kedge seed --sample apparel --url <url> --key <sk_…>");
|
|
457
|
+
const url = flag(args, "--url");
|
|
458
|
+
if (!url) throw new DeployRefused("kedge seed needs --url");
|
|
459
|
+
const refused = await seedTarget(url, args, d);
|
|
460
|
+
if (refused) throw new DeployRefused(refused);
|
|
461
|
+
await seed(url, authOf(args), d.fetch ?? fetch, d.log ?? console.log);
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** `keys create --url`: through the Worker, so the pepper never leaves it (§6.1). */
|
|
466
|
+
export async function keysOverHttp(args: readonly string[], d: CommandDeps): Promise<number> {
|
|
467
|
+
return refusals(d, async () => {
|
|
468
|
+
const url = flag(args, "--url") as string;
|
|
469
|
+
const email = flag(args, "--sign-in");
|
|
470
|
+
if (email) {
|
|
471
|
+
const token = await signInBearer(url, email, d);
|
|
472
|
+
try {
|
|
473
|
+
return await createKeys(url, args, { authorization: `Bearer ${token}` }, d);
|
|
474
|
+
} finally {
|
|
475
|
+
// The session was made for this one command: ending it keeps a bearer
|
|
476
|
+
// token that could create keys from outliving the terminal it was typed in.
|
|
477
|
+
await signOutBearer(url, token, d);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
return createKeys(url, args, authOf(args), d);
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/** The keys themselves, under whichever credential the caller has (P0.8 §6.1). */
|
|
485
|
+
async function createKeys(url: string, args: readonly string[], auth: Record<string, string>, d: CommandDeps) {
|
|
486
|
+
const f = d.fetch ?? fetch;
|
|
487
|
+
const post = async (path: string, body: unknown) => {
|
|
488
|
+
const r = await f(new URL(path, url), {
|
|
489
|
+
method: "POST",
|
|
490
|
+
headers: {
|
|
491
|
+
...auth,
|
|
492
|
+
"content-type": "application/json",
|
|
493
|
+
"idempotency-key": `kedge-keys-${crypto.randomUUID()}`,
|
|
494
|
+
},
|
|
495
|
+
body: JSON.stringify(body),
|
|
496
|
+
});
|
|
497
|
+
const j = (await r.json()) as Record<string, unknown>;
|
|
498
|
+
if (!r.ok)
|
|
499
|
+
throw new DeployRefused(
|
|
500
|
+
`${path} answered ${r.status} ${String((j.error as { code?: string } | undefined)?.code ?? "")}`,
|
|
501
|
+
);
|
|
502
|
+
return j;
|
|
503
|
+
};
|
|
504
|
+
const name = flag(args, "--name") ?? "Storefront";
|
|
505
|
+
const log = d.log ?? console.log;
|
|
506
|
+
if (args.includes("--secret")) {
|
|
507
|
+
const grants = flagsOf(args, "--grant").map((permission) => ({ permission, row_scope: "all" }));
|
|
508
|
+
if (!grants.length) throw new DeployRefused("a secret key needs at least one --grant <permission>");
|
|
509
|
+
const k = await post("/admin/api-keys", { name, grants });
|
|
510
|
+
log(`secret key ${String(k.id)} (shown once):\n${String(k.key)}`);
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
const sf = await post("/admin/storefronts", { name, allowed_origins: flagsOf(args, "--origin") });
|
|
514
|
+
const k = await post(`/admin/storefronts/${String(sf.id)}/keys`, { name });
|
|
515
|
+
log(`storefront ${String(sf.id)}, key ${String(k.id)}`);
|
|
516
|
+
log(`publishable key (send as x-kedge-key; shown once):\n${String(k.key)}`);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* The environment with the preview key `kedge dev` wrote to .dev.vars when the environment holds none
|
|
521
|
+
* (codex code r16): that one key, never the rest of the local secrets.
|
|
522
|
+
*/
|
|
523
|
+
export function withPreviewKey(d: Pick<CommandDeps, "cwd" | "env">): NodeJS.ProcessEnv {
|
|
524
|
+
if (d.env.KEDGE_PREVIEW_KEY) return d.env;
|
|
525
|
+
const vars = join(d.cwd, ".dev.vars");
|
|
526
|
+
const key = existsSync(vars) ? /^KEDGE_PREVIEW_KEY=(.+)$/m.exec(readFileSync(vars, "utf8"))?.[1]?.trim() : undefined;
|
|
527
|
+
return key ? { ...d.env, KEDGE_PREVIEW_KEY: key } : d.env;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/** `kedge dev`: fresh local secrets once, local migrations, then wrangler dev (§3). */
|
|
531
|
+
/**
|
|
532
|
+
* What `kedge dev` adds to `wrangler dev` (p0.9 §3.5): the admin's build defined
|
|
533
|
+
* into the Worker's code, as deploy and preview define it. It refuses a config
|
|
534
|
+
* whose `routes` or `dev` host would make Wrangler rewrite the request's origin
|
|
535
|
+
* (`--local-upstream` "defaults to dev.host or route", `wrangler dev --help`,
|
|
536
|
+
* 4.152.0): sign-in relies on the request's URL being the browser's own origin.
|
|
537
|
+
*/
|
|
538
|
+
export function devArgs(cwd: string): string[] {
|
|
539
|
+
const w = readWrangler(cwd);
|
|
540
|
+
const c = (w?.config ?? {}) as Record<string, unknown> & { dev?: Record<string, unknown> };
|
|
541
|
+
const rewrites = [
|
|
542
|
+
...(c.routes !== undefined ? ["routes"] : []),
|
|
543
|
+
...(c.route !== undefined ? ["route"] : []),
|
|
544
|
+
...(c.dev?.host !== undefined ? ["dev.host"] : []),
|
|
545
|
+
...(c.dev?.local_upstream !== undefined ? ["dev.local_upstream"] : []),
|
|
546
|
+
];
|
|
547
|
+
if (rewrites.length)
|
|
548
|
+
throw new DeployRefused(
|
|
549
|
+
`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)`,
|
|
550
|
+
);
|
|
551
|
+
const dir = (c.assets as { directory?: string } | undefined)?.directory;
|
|
552
|
+
const id = dir ? adminBuildIn(resolve(cwd, dir)) : null;
|
|
553
|
+
return id ? ["--define", `KEDGE_ADMIN_BUILD:${JSON.stringify(id)}`] : [];
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
export function devCommand(d: CommandDeps): number {
|
|
557
|
+
const vars = join(d.cwd, ".dev.vars");
|
|
558
|
+
if (!existsSync(vars)) {
|
|
559
|
+
const lines = [
|
|
560
|
+
"CART_TOKEN_SECRET",
|
|
561
|
+
"ORDER_TOKEN_SECRET",
|
|
562
|
+
"KEDGE_AUTH_SECRET_V1",
|
|
563
|
+
"KEDGE_KEY_PEPPER_V1",
|
|
564
|
+
"KEDGE_TOTP_KEY_V1",
|
|
565
|
+
"KEDGE_PREVIEW_KEY",
|
|
566
|
+
].map((n) => `${n}=${Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("base64url")}`);
|
|
567
|
+
writeFileSync(vars, `# Local secrets for kedge dev, made once; never commit this file.\n${lines.join("\n")}\n`, {
|
|
568
|
+
mode: 0o600,
|
|
569
|
+
});
|
|
570
|
+
(d.log ?? console.log)("wrote .dev.vars with fresh local secrets");
|
|
571
|
+
}
|
|
572
|
+
const migrated = spawnSync(process.execPath, [wranglerBin(), "d1", "migrations", "apply", "DB", "--local"], {
|
|
573
|
+
cwd: d.cwd,
|
|
574
|
+
stdio: "inherit",
|
|
575
|
+
});
|
|
576
|
+
if (migrated.status !== 0) return migrated.status ?? 1;
|
|
577
|
+
let extra: string[];
|
|
578
|
+
try {
|
|
579
|
+
extra = devArgs(d.cwd);
|
|
580
|
+
} catch (e) {
|
|
581
|
+
(d.log ?? console.log)((e as Error).message);
|
|
582
|
+
return 1;
|
|
583
|
+
}
|
|
584
|
+
return spawnSync(process.execPath, [wranglerBin(), "dev", ...extra], { cwd: d.cwd, stdio: "inherit" }).status ?? 0;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
export const removeIfExists = (p: string) => rmSync(p, { recursive: true, force: true });
|
|
588
|
+
|
|
589
|
+
// ---- kedge preview (§5) ------------------------------------------------------------------------------------
|
|
590
|
+
|
|
591
|
+
function branchName(cwd: string): string {
|
|
592
|
+
const r = spawnSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd, encoding: "utf8" });
|
|
593
|
+
const b = r.stdout.trim();
|
|
594
|
+
if (r.status !== 0 || !b || b === "HEAD") throw new DeployRefused("give --name: this checkout has no branch");
|
|
595
|
+
return b;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
export async function previewCommand(args: readonly string[], d: CommandDeps): Promise<number> {
|
|
599
|
+
const { applyPlan, buildPlan, cacheCredentials, destroyPreview, hasScenarios, runScenarios, sweepPreviews, tarPlan } =
|
|
600
|
+
await import("./preview");
|
|
601
|
+
const { signInOwner } = await import("@kedge/testing/http");
|
|
602
|
+
const sub = args[0] === "build" || args[0] === "apply" || args[0] === "destroy" ? args[0] : null;
|
|
603
|
+
const log = d.log ?? console.log;
|
|
604
|
+
return refusals(d, async () => {
|
|
605
|
+
const store = (): string => String(storeNames(d.cwd).config.name);
|
|
606
|
+
// A sweep names no preview, so it needs no branch (a detached CI checkout has none).
|
|
607
|
+
const sweep = sub === "destroy" && args.includes("--sweep");
|
|
608
|
+
const name = flag(args, "--name") ?? (sub === "apply" || sweep ? undefined : branchName(d.cwd));
|
|
609
|
+
if (sub === "destroy") {
|
|
610
|
+
const cf = d.cf ?? restCloudflare(d.env, d.fetch);
|
|
611
|
+
if (args.includes("--sweep")) {
|
|
612
|
+
await sweepPreviews({ cf, store: store(), log });
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
await destroyPreview({
|
|
616
|
+
cf,
|
|
617
|
+
store: store(),
|
|
618
|
+
name: name as string,
|
|
619
|
+
log,
|
|
620
|
+
...(flag(args, "--take-over") ? { takeOver: flag(args, "--take-over") as string } : {}),
|
|
621
|
+
confirmStopped: args.includes("--confirm-stopped"),
|
|
622
|
+
});
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
const build = async () => {
|
|
626
|
+
if (d.checkFast && (await d.checkFast()) !== 0) throw new DeployRefused("kedge check --fast failed");
|
|
627
|
+
const config = await loadConfig(d.cwd);
|
|
628
|
+
return buildPlan({
|
|
629
|
+
cwd: d.cwd,
|
|
630
|
+
name: name as string,
|
|
631
|
+
lock: lockOf(d.cwd),
|
|
632
|
+
vars: varsOf(args),
|
|
633
|
+
secrets: config.preview?.secrets ?? [],
|
|
634
|
+
bundle: bundleWithWrangler(d.cwd),
|
|
635
|
+
});
|
|
636
|
+
};
|
|
637
|
+
if (sub === "build") {
|
|
638
|
+
const { dir } = await build();
|
|
639
|
+
log(`plan written to ${dir}`);
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
const cf = d.cf ?? restCloudflare(d.env, d.fetch);
|
|
643
|
+
const takeOver = flag(args, "--take-over");
|
|
644
|
+
const common = {
|
|
645
|
+
cf,
|
|
646
|
+
env: withPreviewKey(d),
|
|
647
|
+
secretEnv: flagsOf(args, "--secret-env"),
|
|
648
|
+
fresh: args.includes("--fresh"),
|
|
649
|
+
confirmStopped: args.includes("--confirm-stopped"),
|
|
650
|
+
log,
|
|
651
|
+
...(takeOver ? { takeOver } : {}),
|
|
652
|
+
...(d.fetch ? { fetch: d.fetch } : {}),
|
|
653
|
+
};
|
|
654
|
+
if (sub === "apply") {
|
|
655
|
+
const source = args[1];
|
|
656
|
+
if (!source) throw new DeployRefused("kedge preview apply <plan.tar | -> [--store <name>] [--name <name>]");
|
|
657
|
+
const stream = source === "-" ? readFileSync(0) : readFileSync(source);
|
|
658
|
+
const store = flag(args, "--store");
|
|
659
|
+
const a = await applyPlan({ ...common, stream, ...(store ? { store } : {}), ...(name ? { name } : {}) });
|
|
660
|
+
await a.release("applied");
|
|
661
|
+
log(`preview ${a.worker} serves ${a.url}`);
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
// Both halves, then the store's scenarios, under the preview's operation to the last result (§5.3).
|
|
665
|
+
const { dir, plan } = await build();
|
|
666
|
+
const a = await applyPlan({ ...common, stream: tarPlan(dir) });
|
|
667
|
+
const identity = `${plan.store}/${plan.name}`;
|
|
668
|
+
// One Owner sign-in for every scenario file, before the cache, which then keeps its step: files signing
|
|
669
|
+
// in at once would send one step's code, and all but one be refused (codex code r29).
|
|
670
|
+
// Whatever fails after the deploy still ends the operation: left held, the next preview or destroy
|
|
671
|
+
// would need --take-over --confirm-stopped for a run that merely threw (codex code r30).
|
|
672
|
+
let code: number;
|
|
673
|
+
try {
|
|
674
|
+
const owner = a.credentials.owner;
|
|
675
|
+
const ownerSession =
|
|
676
|
+
owner && hasScenarios(d.cwd)
|
|
677
|
+
? await signInOwner(a.url, owner, a.credentials.adminOrigin ?? "https://kedge-preview.invalid")
|
|
678
|
+
: undefined;
|
|
679
|
+
const cached = cacheCredentials(d.cwd, identity, a.credentials);
|
|
680
|
+
log(`preview ${a.worker} serves ${a.url} (credentials in ${cached})`);
|
|
681
|
+
// The admin, when the store serves it (p0.9 §3.4): the preview's own origin signs in, with no ADMIN_ORIGINS.
|
|
682
|
+
if (plan.assets) log(`admin: ${a.url}/app/`);
|
|
683
|
+
code = runScenarios(d.cwd, { ...a.credentials, ...(ownerSession ? { ownerSession } : {}) }, d.env);
|
|
684
|
+
} catch (e) {
|
|
685
|
+
await a.release(`scenarios didn't run: ${(e as Error).message}`);
|
|
686
|
+
throw e;
|
|
687
|
+
}
|
|
688
|
+
await a.release(code === 0 ? "scenarios passed" : "scenarios failed");
|
|
689
|
+
if (code !== 0) throw new DeployRefused("the store's scenarios failed against the preview");
|
|
690
|
+
});
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// ---- kedge eject, kedge upgrade (§7, §8) -------------------------------------------------------------------
|
|
694
|
+
|
|
695
|
+
export async function ejectCommand(args: readonly string[], d: CommandDeps): Promise<number> {
|
|
696
|
+
const { eject, rebase } = await import("./units");
|
|
697
|
+
const manifest = ownManifest();
|
|
698
|
+
const log = d.log ?? console.log;
|
|
699
|
+
const fail = (m: string) => {
|
|
700
|
+
(d.error ?? console.error)(m);
|
|
701
|
+
return 1;
|
|
702
|
+
};
|
|
703
|
+
if (!manifest) return fail("kedge eject needs an installed release (a workspace install has no manifest)");
|
|
704
|
+
try {
|
|
705
|
+
const r = flag(args, "--rebase");
|
|
706
|
+
if (r) {
|
|
707
|
+
rebase(d.cwd, manifest, r);
|
|
708
|
+
log(`${r} now records Kedge ${manifest.version}'s upstream`);
|
|
709
|
+
return 0;
|
|
710
|
+
}
|
|
711
|
+
const unit = args[0];
|
|
712
|
+
if (!unit || unit.startsWith("--"))
|
|
713
|
+
return fail(`usage: kedge eject <unit> --reason "<why>"; units: ${Object.keys(manifest.units).join(", ")}`);
|
|
714
|
+
const out = eject(d.cwd, manifest, unit, flag(args, "--reason") ?? "");
|
|
715
|
+
log(`wrote ${out.file}. Wire it in kedge.config.ts:\n${out.wiring}`);
|
|
716
|
+
return 0;
|
|
717
|
+
} catch (e) {
|
|
718
|
+
return fail((e as Error).message);
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
export async function upgradeCommand(
|
|
723
|
+
args: readonly string[],
|
|
724
|
+
d: CommandDeps & { kedge: (argv: string[]) => Promise<number> },
|
|
725
|
+
): Promise<number> {
|
|
726
|
+
const { upgrade, UpgradeRefused } = await import("./upgrade");
|
|
727
|
+
const to = flag(args, "--to");
|
|
728
|
+
if (!to) {
|
|
729
|
+
(d.error ?? console.error)("usage: kedge upgrade --to <version> [--from-packs <dir>]");
|
|
730
|
+
return 2;
|
|
731
|
+
}
|
|
732
|
+
try {
|
|
733
|
+
const fromPacks = flag(args, "--from-packs");
|
|
734
|
+
await upgrade({
|
|
735
|
+
cwd: d.cwd,
|
|
736
|
+
to,
|
|
737
|
+
...(fromPacks ? { fromPacks } : {}),
|
|
738
|
+
git: git(d.cwd),
|
|
739
|
+
manifest: () => {
|
|
740
|
+
// The CLI just installed is the target's: read its manifest from the store, not this process.
|
|
741
|
+
const dir = installedPackages(d.cwd).get("@kedge/cli");
|
|
742
|
+
return dir && existsSync(join(dir, "kedge-manifest.json"))
|
|
743
|
+
? JSON.parse(readFileSync(join(dir, "kedge-manifest.json"), "utf8"))
|
|
744
|
+
: null;
|
|
745
|
+
},
|
|
746
|
+
kedge: d.kedge,
|
|
747
|
+
...(d.log ? { log: d.log } : {}),
|
|
748
|
+
});
|
|
749
|
+
return 0;
|
|
750
|
+
} catch (e) {
|
|
751
|
+
if (e instanceof UpgradeRefused) {
|
|
752
|
+
(d.error ?? console.error)(e.message);
|
|
753
|
+
return 1;
|
|
754
|
+
}
|
|
755
|
+
throw e;
|
|
756
|
+
}
|
|
757
|
+
}
|