@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/main.ts
CHANGED
|
@@ -2,19 +2,28 @@
|
|
|
2
2
|
// commands (`staff invite-owner`, `staff recover`, `keys create`); doctor,
|
|
3
3
|
// check, preview and deploy arrive with P0.8 (docs/11).
|
|
4
4
|
import { spawnSync } from "node:child_process";
|
|
5
|
-
import { cpSync, existsSync,
|
|
6
|
-
import { tmpdir } from "node:os";
|
|
5
|
+
import { cpSync, existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
7
6
|
import { dirname, join, relative, resolve } from "node:path";
|
|
8
7
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
9
8
|
import type { KedgeConfig } from "@kedge/core";
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
9
|
+
import { ownerInviteSql, publishableKeySql, recoverSql } from "@kedge/db";
|
|
10
|
+
import {
|
|
11
|
+
type Cloudflare,
|
|
12
|
+
cloudflareBase,
|
|
13
|
+
cloudflareFor,
|
|
14
|
+
NeedsPerson,
|
|
15
|
+
resolveCloudflare,
|
|
16
|
+
restCloudflare,
|
|
17
|
+
sameAccount,
|
|
18
|
+
wranglerBin,
|
|
19
|
+
} from "./cf";
|
|
12
20
|
import { check as runCheck } from "./check";
|
|
13
21
|
import {
|
|
14
22
|
type CommandDeps,
|
|
15
23
|
deployCommand,
|
|
16
24
|
devCommand,
|
|
17
25
|
ejectCommand,
|
|
26
|
+
failureOf,
|
|
18
27
|
flag,
|
|
19
28
|
generateSupersede,
|
|
20
29
|
keysOverHttp,
|
|
@@ -23,12 +32,15 @@ import {
|
|
|
23
32
|
restoreCommand,
|
|
24
33
|
secretCommand,
|
|
25
34
|
seedCommand,
|
|
35
|
+
smokeCommand,
|
|
26
36
|
upgradeCommand,
|
|
27
37
|
} from "./commands";
|
|
28
38
|
import { ComposeError, compose, type Lock } from "./compose";
|
|
29
|
-
import {
|
|
39
|
+
import { type Executor, wranglerExecute } from "./d1-batch";
|
|
40
|
+
import { credentialsChecks, doctor, doctorRemote, formatReport } from "./doctor";
|
|
30
41
|
import { invariantsMarkdown, invariantsOf } from "./invariants";
|
|
31
42
|
import { installedPackages } from "./manifest";
|
|
43
|
+
import { emit, withJsonOutput } from "./output";
|
|
32
44
|
import { composedTexts, schemaOf } from "./schema";
|
|
33
45
|
import { storeNames } from "./store";
|
|
34
46
|
import { compileStoreZone } from "./tz";
|
|
@@ -51,9 +63,10 @@ const USAGE = `usage: kedge generate [--check] [--supersede <op-id>]
|
|
|
51
63
|
kedge doctor --core-diff [--upstream <manifest>] [--strict]
|
|
52
64
|
kedge secret put <NAME> | kedge secret delete <NAME>
|
|
53
65
|
kedge restore --list
|
|
54
|
-
kedge seed --sample apparel --url <url>
|
|
66
|
+
kedge seed --sample apparel --url <url> [--key <sk_…> | --session <token>] [--live]
|
|
67
|
+
kedge smoke --url <url> [--run <name>] [--live]
|
|
55
68
|
kedge keys create (--publishable [--origin <url>]... | --secret --grant <permission>...)
|
|
56
|
-
--url <url> (--session <token> | --sign-in <email>) [--name <name>]
|
|
69
|
+
--url <url> (--session <token> | --sign-in <email> [--answers-stdin] | --save) [--name <name>]
|
|
57
70
|
kedge staff invite-owner <email> [--database DB] [--remote] [--url <url>] [--config wrangler.jsonc]
|
|
58
71
|
kedge staff recover <email> [--database DB] [--remote] [--config wrangler.jsonc]
|
|
59
72
|
kedge keys create --publishable --name <name> [--origin <origin>]... [--pepper-version N]
|
|
@@ -71,6 +84,8 @@ const USAGE = `usage: kedge generate [--check] [--supersede <op-id>]
|
|
|
71
84
|
upgrade every @kedge package to a release one supported step on, its codemods,
|
|
72
85
|
UPGRADE-TODO.md, then generate and check; nothing is committed
|
|
73
86
|
restore --list the restore points; restoring waits for P1.18
|
|
87
|
+
smoke a test order on one of this store's own URLs: a cart, completion and
|
|
88
|
+
placement, resumed by its --run name until it has its number
|
|
74
89
|
check typecheck, generate --check, doctor, then the store's tests;
|
|
75
90
|
--fast stops before the tests (the Stop hook's)
|
|
76
91
|
doctor the store's offline checks: lock, installed core, error map,
|
|
@@ -81,40 +96,13 @@ const USAGE = `usage: kedge generate [--check] [--supersede <op-id>]
|
|
|
81
96
|
KEDGE_KEY_PEPPER_V<N> in the environment (N defaults to 1)
|
|
82
97
|
|
|
83
98
|
The staff and keys commands run one audited batch through wrangler d1 execute
|
|
84
|
-
(--local unless --remote) and print the secret once
|
|
85
|
-
|
|
86
|
-
/** Runs one CLI batch against the store's D1; tests replace it. */
|
|
87
|
-
export type Executor = (
|
|
88
|
-
b: CliBatch,
|
|
89
|
-
opts: { database: string; remote: boolean; cwd: string; config?: string },
|
|
90
|
-
) => number;
|
|
99
|
+
(--local unless --remote) and print the secret once.
|
|
91
100
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
const r = spawnSync(
|
|
98
|
-
"npx",
|
|
99
|
-
[
|
|
100
|
-
"wrangler",
|
|
101
|
-
"d1",
|
|
102
|
-
"execute",
|
|
103
|
-
opts.database,
|
|
104
|
-
"--file",
|
|
105
|
-
file,
|
|
106
|
-
opts.remote ? "--remote" : "--local",
|
|
107
|
-
"--yes",
|
|
108
|
-
...(opts.config ? ["-c", opts.config] : []),
|
|
109
|
-
],
|
|
110
|
-
{ cwd: opts.cwd, stdio: "inherit" },
|
|
111
|
-
);
|
|
112
|
-
return r.status ?? 1;
|
|
113
|
-
} finally {
|
|
114
|
-
// The file holds no secret (only digests), but it needn't outlive the run.
|
|
115
|
-
rmSync(dir, { recursive: true, force: true });
|
|
116
|
-
}
|
|
117
|
-
};
|
|
101
|
+
deploy, preview, seed, staff and keys take --json: one object on stdout when they
|
|
102
|
+
end ({"ok": true, ...}, or {"ok": false, "error"} / {"ok": false, "handoff"}),
|
|
103
|
+
everything else on stderr. Exit 3 means a person must act: the last line of stderr
|
|
104
|
+
starts "for you:". --sign-in asks only a terminal; --answers-stdin reads one line
|
|
105
|
+
per question from a pipe, waiting 30 seconds for each.`;
|
|
118
106
|
|
|
119
107
|
/**
|
|
120
108
|
* The store's origin for the invite link (p0.9 §3.5): `--url`, or with
|
|
@@ -133,7 +121,7 @@ async function inviteOrigin(
|
|
|
133
121
|
if (!remote) return null;
|
|
134
122
|
try {
|
|
135
123
|
const worker = storeNames(cwd).names.worker;
|
|
136
|
-
return `https://${worker}.${await (cf ??
|
|
124
|
+
return `https://${worker}.${await (cf ?? (await cloudflareFor(env, cwd))).workersSubdomain()}.workers.dev`;
|
|
137
125
|
} catch {
|
|
138
126
|
return null;
|
|
139
127
|
}
|
|
@@ -145,50 +133,87 @@ async function bootstrap(
|
|
|
145
133
|
cwd: string,
|
|
146
134
|
execute: Executor,
|
|
147
135
|
env: NodeJS.ProcessEnv,
|
|
148
|
-
cf?: Cloudflare,
|
|
136
|
+
o: { json: boolean; emit?: (result: Record<string, unknown>) => void; cf?: Cloudflare },
|
|
149
137
|
): Promise<number> {
|
|
138
|
+
const cf = o.cf;
|
|
139
|
+
const result = (r: Record<string, unknown>) => {
|
|
140
|
+
if (o.json) (o.emit ?? emit)(r);
|
|
141
|
+
};
|
|
142
|
+
// A usage error is an end too: under --json it's an object, never an empty stdout (codex code r8).
|
|
143
|
+
const usage = (message: string) => {
|
|
144
|
+
console.error(message);
|
|
145
|
+
result({ ok: false, error: message.split("\n")[0] });
|
|
146
|
+
return 2;
|
|
147
|
+
};
|
|
148
|
+
// Wrangler's own words are on stderr already; the object says only that the batch didn't finish.
|
|
149
|
+
const failed = (status: number) => {
|
|
150
|
+
result({ ok: false, error: `wrangler d1 execute exited ${status}` });
|
|
151
|
+
return status;
|
|
152
|
+
};
|
|
150
153
|
const config = flag(args, "--config");
|
|
154
|
+
const remote = args.includes("--remote");
|
|
155
|
+
// Resolved before any write, as every remote command does (p0.10 §4): no credentials is the
|
|
156
|
+
// handoff, exit 3, never Wrangler's own failure; and the batch goes where the resolver says,
|
|
157
|
+
// salve's broker included (codex code r2).
|
|
158
|
+
const auth = remote ? await resolveCloudflare(env, cwd) : null;
|
|
159
|
+
// A --config of another file is what Wrangler reads, so its account_id must agree too.
|
|
160
|
+
if (auth && config) sameAccount(auth.account, resolve(cwd, config));
|
|
161
|
+
const base = cloudflareBase(env);
|
|
151
162
|
const opts = {
|
|
152
163
|
database: flag(args, "--database") ?? "DB",
|
|
153
|
-
remote
|
|
164
|
+
remote,
|
|
154
165
|
cwd,
|
|
155
166
|
...(config ? { config } : {}),
|
|
167
|
+
...(auth
|
|
168
|
+
? {
|
|
169
|
+
env: {
|
|
170
|
+
CLOUDFLARE_API_TOKEN: auth.token(),
|
|
171
|
+
CLOUDFLARE_ACCOUNT_ID: auth.account,
|
|
172
|
+
// Always, the default included: Wrangler would otherwise take one from the store's
|
|
173
|
+
// dotenv files and send this token there (codex code r4).
|
|
174
|
+
CLOUDFLARE_API_BASE_URL: base,
|
|
175
|
+
},
|
|
176
|
+
}
|
|
177
|
+
: {}),
|
|
156
178
|
};
|
|
157
179
|
const now = Date.now();
|
|
158
180
|
const [sub, target] = args;
|
|
159
181
|
if (command === "staff" && (sub === "invite-owner" || sub === "recover") && target && !target.startsWith("--")) {
|
|
160
182
|
const b = sub === "invite-owner" ? await ownerInviteSql(target, now) : await recoverSql(target, now);
|
|
161
183
|
const status = execute(b, opts);
|
|
162
|
-
if (status !== 0) return status;
|
|
163
|
-
|
|
184
|
+
if (status !== 0) return failed(status);
|
|
185
|
+
const expires = new Date(b.expiresAt).toISOString();
|
|
186
|
+
console.log(`invite ${b.inviteId} for ${target}, valid until ${expires}`);
|
|
164
187
|
console.log(`accept with POST /auth/admin/invites/accept and this token (shown once):\n${b.token}`);
|
|
165
188
|
const origin = await inviteOrigin(args, opts.remote, cwd, env, cf);
|
|
166
189
|
// The token rides in the fragment, which no server log or Referer carries (p0.9 §3.5).
|
|
167
|
-
|
|
190
|
+
const link = origin ? `${origin}/app/invite#${b.token}` : null;
|
|
191
|
+
if (link) console.log(`or open the admin's invite page:\n${link}`);
|
|
192
|
+
result({ ok: true, invite: b.inviteId, email: target, expires_at: expires, token: b.token, link });
|
|
168
193
|
return 0;
|
|
169
194
|
}
|
|
170
195
|
if (command === "keys" && sub === "create" && args.includes("--publishable")) {
|
|
171
196
|
const name = flag(args, "--name");
|
|
172
|
-
if (!name)
|
|
173
|
-
console.error("keys create needs --name");
|
|
174
|
-
return 2;
|
|
175
|
-
}
|
|
197
|
+
if (!name) return usage("keys create needs --name");
|
|
176
198
|
const origins = args.flatMap((a, i) => (a === "--origin" && args[i + 1] ? [args[i + 1] as string] : []));
|
|
177
199
|
const pepperVersion = Number(flag(args, "--pepper-version") ?? "1");
|
|
178
200
|
const pepper = env[`KEDGE_KEY_PEPPER_V${pepperVersion}`];
|
|
179
|
-
if (!Number.isSafeInteger(pepperVersion) || pepperVersion < 1
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
201
|
+
if (!Number.isSafeInteger(pepperVersion) || pepperVersion < 1)
|
|
202
|
+
return usage("--pepper-version takes a whole number from 1");
|
|
203
|
+
// The pepper lives in the merchant's own vault, or only in the Worker: theirs to hand over (p0.10 §5).
|
|
204
|
+
if (!pepper)
|
|
205
|
+
throw new NeedsPerson(
|
|
206
|
+
`set KEDGE_KEY_PEPPER_V${pepperVersion} to the Worker's pepper secret, or create the key with --url and --sign-in, which keeps the pepper in the Worker`,
|
|
207
|
+
);
|
|
183
208
|
const b = await publishableKeySql({ name, origins, pepperVersion, pepper }, now);
|
|
184
209
|
const status = execute(b, opts);
|
|
185
|
-
if (status !== 0) return status;
|
|
210
|
+
if (status !== 0) return failed(status);
|
|
186
211
|
console.log(`storefront ${b.storefrontId}, key ${b.keyId}`);
|
|
187
212
|
console.log(`publishable key (send as x-kedge-key; shown once):\n${b.key}`);
|
|
213
|
+
result({ ok: true, storefront: b.storefrontId, key_id: b.keyId, key: b.key });
|
|
188
214
|
return 0;
|
|
189
215
|
}
|
|
190
|
-
|
|
191
|
-
return 2;
|
|
216
|
+
return usage(USAGE);
|
|
192
217
|
}
|
|
193
218
|
|
|
194
219
|
const INDEX_START = "<!-- kedge:docs-index:start -->";
|
|
@@ -218,13 +243,18 @@ function agentsIndex(cwd: string): { path: string; text: string } | null {
|
|
|
218
243
|
return { path, text: `${current.slice(0, a + INDEX_START.length)}\n${lines.join("\n")}\n${current.slice(b)}` };
|
|
219
244
|
}
|
|
220
245
|
|
|
221
|
-
/**
|
|
246
|
+
/**
|
|
247
|
+
* Skills ship in @kedge/docs and are copied, gitignored, so they always match
|
|
248
|
+
* what's installed (§9.3): into `.claude/skills` for Claude Code, and into
|
|
249
|
+
* `.agents/skills`, where salve and other agents read them (p0.10 §2, §6.4).
|
|
250
|
+
*/
|
|
222
251
|
function copySkills(cwd: string): string[] {
|
|
223
252
|
const docs = installedPackages(cwd).get("@kedge/docs");
|
|
224
253
|
if (!docs || !existsSync(join(docs, "skills")) || !existsSync(join(cwd, ".claude"))) return [];
|
|
225
254
|
const out: string[] = [];
|
|
226
255
|
for (const s of readdirSync(join(docs, "skills"))) {
|
|
227
|
-
|
|
256
|
+
for (const home of [".claude", ".agents"])
|
|
257
|
+
cpSync(join(docs, "skills", s), join(cwd, home, "skills", s), { recursive: true });
|
|
228
258
|
out.push(s);
|
|
229
259
|
}
|
|
230
260
|
return out;
|
|
@@ -251,29 +281,46 @@ function coreMigrationsDir(): string {
|
|
|
251
281
|
return join(dirname(entry), "..", "migrations");
|
|
252
282
|
}
|
|
253
283
|
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
284
|
+
interface RunDeps {
|
|
285
|
+
execute?: Executor;
|
|
286
|
+
env?: NodeJS.ProcessEnv;
|
|
287
|
+
cf?: Cloudflare;
|
|
288
|
+
/** Where `--json`'s object goes; tests read it. */
|
|
289
|
+
emit?: (result: Record<string, unknown>) => void;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** The commands an agent reads the result of (p0.10 §5); doctor has its own `--json`. */
|
|
293
|
+
const JSON_COMMANDS = new Set(["deploy", "preview", "staff", "keys", "seed", "smoke"]);
|
|
294
|
+
|
|
295
|
+
async function run(argv: string[], cwd: string, deps: RunDeps & { json: boolean }): Promise<number> {
|
|
259
296
|
const [command, ...flags] = argv;
|
|
297
|
+
const out = {
|
|
298
|
+
json: deps.json,
|
|
299
|
+
...(deps.emit ? { emit: deps.emit } : {}),
|
|
300
|
+
...(deps.execute ? { execute: deps.execute } : {}),
|
|
301
|
+
};
|
|
260
302
|
if (command === "keys" && flag(flags, "--url")) {
|
|
261
|
-
const d0: CommandDeps = { cwd, env: deps.env ?? process.env, coreDir: coreMigrationsDir() };
|
|
303
|
+
const d0: CommandDeps = { cwd, env: deps.env ?? process.env, coreDir: coreMigrationsDir(), ...out };
|
|
262
304
|
return keysOverHttp(flags, d0);
|
|
263
305
|
}
|
|
264
306
|
if (command === "staff" || command === "keys")
|
|
265
|
-
return bootstrap(command, flags, cwd, deps.execute ?? wranglerExecute, deps.env ?? process.env,
|
|
307
|
+
return bootstrap(command, flags, cwd, deps.execute ?? wranglerExecute, deps.env ?? process.env, {
|
|
308
|
+
...out,
|
|
309
|
+
...(deps.cf ? { cf: deps.cf } : {}),
|
|
310
|
+
});
|
|
266
311
|
const d: CommandDeps = {
|
|
267
312
|
cwd,
|
|
268
313
|
env: deps.env ?? process.env,
|
|
269
314
|
coreDir: coreMigrationsDir(),
|
|
270
315
|
checkFast: () => main(["check", "--fast"], cwd),
|
|
316
|
+
...out,
|
|
271
317
|
};
|
|
272
318
|
if (command === "deploy") return deployCommand(flags, d);
|
|
273
319
|
if (command === "migrate") return migrateCommand(flags, d);
|
|
274
320
|
if (command === "restore") return restoreCommand(flags, d);
|
|
275
321
|
if (command === "secret") return secretCommand(flags, d);
|
|
276
322
|
if (command === "seed") return seedCommand(flags, d);
|
|
323
|
+
if (command === "smoke") return smokeCommand(flags, d);
|
|
277
324
|
if (command === "preview") return previewCommand(flags, d);
|
|
278
325
|
if (command === "eject") return ejectCommand(flags, d);
|
|
279
326
|
if (command === "upgrade")
|
|
@@ -302,11 +349,35 @@ export async function main(
|
|
|
302
349
|
if (command === "doctor") {
|
|
303
350
|
const results = await doctor({ cwd, coreDir: coreMigrationsDir() });
|
|
304
351
|
if (flags.includes("--remote")) {
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
352
|
+
let auth: Awaited<ReturnType<typeof resolveCloudflare>> | null = null;
|
|
353
|
+
try {
|
|
354
|
+
auth = await resolveCloudflare(d.env, cwd);
|
|
355
|
+
} catch (e) {
|
|
356
|
+
// No credentials is a check that failed and a handoff, with the offline report still printed (codex code r8).
|
|
357
|
+
if (!(e instanceof NeedsPerson)) throw e;
|
|
358
|
+
results.push({ name: "credentials", status: "fail", message: e.handoff, handoff: e.handoff });
|
|
359
|
+
}
|
|
360
|
+
if (auth) {
|
|
361
|
+
const cf = restCloudflare(d.env, fetch, wranglerBin(), auth);
|
|
362
|
+
const lock = JSON.parse(readFileSync(join(cwd, "kedge.lock.json"), "utf8")) as Lock;
|
|
363
|
+
const creds = await credentialsChecks({ cf, source: auth.source, base: cloudflareBase(d.env) });
|
|
364
|
+
results.push(...creds);
|
|
365
|
+
// Credentials that can't read the account would make every later check throw, not report (codex code r7).
|
|
366
|
+
if (creds.some((c) => c.status === "fail"))
|
|
367
|
+
results.push({
|
|
368
|
+
name: "remote",
|
|
369
|
+
status: "skip",
|
|
370
|
+
message: "the store's resources weren't read: fix the credentials first",
|
|
371
|
+
});
|
|
372
|
+
else results.push(...(await doctorRemote({ cwd, cf, lock, logpushJobs: () => cf.logpushJobs() })));
|
|
373
|
+
}
|
|
308
374
|
}
|
|
309
375
|
console.log(flags.includes("--json") ? JSON.stringify({ checks: results }, null, 2) : formatReport(results));
|
|
376
|
+
const handoff = results.find((r) => r.handoff)?.handoff;
|
|
377
|
+
if (handoff) {
|
|
378
|
+
console.error(`for you: ${handoff}`);
|
|
379
|
+
return 3;
|
|
380
|
+
}
|
|
310
381
|
return results.some((r) => r.status === "fail") ? 1 : 0;
|
|
311
382
|
}
|
|
312
383
|
if (command === "check")
|
|
@@ -376,3 +447,24 @@ export async function main(
|
|
|
376
447
|
throw e;
|
|
377
448
|
}
|
|
378
449
|
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* The CLI. A handoff from any command ends it with exit 3 and one sentence for
|
|
453
|
+
* whoever runs it to pass on (p0.10 §5).
|
|
454
|
+
*/
|
|
455
|
+
export async function main(argv: string[], cwd: string, deps: RunDeps = {}): Promise<number> {
|
|
456
|
+
const [command, ...flags] = argv;
|
|
457
|
+
const json = flags.includes("--json") && JSON_COMMANDS.has(command ?? "");
|
|
458
|
+
return withJsonOutput(json, async () => {
|
|
459
|
+
try {
|
|
460
|
+
return await run(argv, cwd, { ...deps, json });
|
|
461
|
+
} catch (e) {
|
|
462
|
+
// What escapes a command's own handling (bootstrap, doctor) ends the same way (p0.10 §5).
|
|
463
|
+
const f = failureOf(e);
|
|
464
|
+
if (!f) throw e;
|
|
465
|
+
console.error(f.line);
|
|
466
|
+
if (json) (deps.emit ?? emit)(f.result);
|
|
467
|
+
return f.code;
|
|
468
|
+
}
|
|
469
|
+
});
|
|
470
|
+
}
|
package/src/ops.ts
CHANGED
|
@@ -18,6 +18,8 @@ export type Query = (sql: string, params?: readonly unknown[]) => Promise<Row[]>
|
|
|
18
18
|
|
|
19
19
|
export const OPS_SCHEMA = [
|
|
20
20
|
`CREATE TABLE IF NOT EXISTS kedge_ops_meta (id INTEGER PRIMARY KEY CHECK (id = 1), store TEXT NOT NULL, created_at INTEGER NOT NULL)`,
|
|
21
|
+
// The store's choices made once (p0.10 §7: its jurisdiction), each written once and never changed.
|
|
22
|
+
`CREATE TABLE IF NOT EXISTS ops_setting (name TEXT PRIMARY KEY, value TEXT NOT NULL, created_at INTEGER NOT NULL)`,
|
|
21
23
|
`CREATE TABLE IF NOT EXISTS operation (
|
|
22
24
|
scope TEXT PRIMARY KEY, op_id TEXT NOT NULL UNIQUE, kind TEXT NOT NULL, plan TEXT NOT NULL, step TEXT NOT NULL,
|
|
23
25
|
progress TEXT NOT NULL DEFAULT '{}',
|
|
@@ -136,6 +138,22 @@ export class OpsStore {
|
|
|
136
138
|
throw new Error(`this operations database belongs to ${String(meta?.store)}, not ${store}`);
|
|
137
139
|
}
|
|
138
140
|
|
|
141
|
+
/** A setting written once; the value already recorded wins, and is what this answers. */
|
|
142
|
+
async settle(name: string, value: string): Promise<string> {
|
|
143
|
+
await this.q("INSERT INTO ops_setting (name, value, created_at) VALUES (?, ?, ?) ON CONFLICT (name) DO NOTHING", [
|
|
144
|
+
name,
|
|
145
|
+
value,
|
|
146
|
+
this.now(),
|
|
147
|
+
]);
|
|
148
|
+
const [r] = await this.q("SELECT value FROM ops_setting WHERE name = ?", [name]);
|
|
149
|
+
return String(r?.value);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async setting(name: string): Promise<string | null> {
|
|
153
|
+
const [r] = await this.q("SELECT value FROM ops_setting WHERE name = ?", [name]);
|
|
154
|
+
return r ? String(r.value) : null;
|
|
155
|
+
}
|
|
156
|
+
|
|
139
157
|
// ---- operations --------------------------------------------------------------------------------------
|
|
140
158
|
async current(scope: string): Promise<OperationRow | null> {
|
|
141
159
|
return ((await this.q("SELECT * FROM operation WHERE scope = ?", [scope]))[0] as OperationRow | undefined) ?? null;
|
package/src/output.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// `--json` (p0.10 §5): stdout carries one object when the command ends, and
|
|
2
|
+
// nothing else, so an agent parses it whole. Progress still reaches a person,
|
|
3
|
+
// on stderr: console.log is pointed there for the command's run, and every
|
|
4
|
+
// child the CLI starts (Wrangler, the store's checks and scenarios) writes its
|
|
5
|
+
// stdout there too, since a child's line on stdout would break the object.
|
|
6
|
+
import type { StdioOptions } from "node:child_process";
|
|
7
|
+
|
|
8
|
+
let reserved = false;
|
|
9
|
+
|
|
10
|
+
/** Runs `work` with stdout reserved for `emit` when `on`; nested runs keep the outer setting. */
|
|
11
|
+
export async function withJsonOutput<T>(on: boolean, work: () => Promise<T>): Promise<T> {
|
|
12
|
+
if (!on || reserved) return work();
|
|
13
|
+
const log = console.log;
|
|
14
|
+
const info = console.info;
|
|
15
|
+
reserved = true;
|
|
16
|
+
console.log = console.error;
|
|
17
|
+
console.info = console.error;
|
|
18
|
+
try {
|
|
19
|
+
return await work();
|
|
20
|
+
} finally {
|
|
21
|
+
reserved = false;
|
|
22
|
+
console.log = log;
|
|
23
|
+
console.info = info;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The command's one result object. */
|
|
28
|
+
export function emit(result: Record<string, unknown>): void {
|
|
29
|
+
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A child's stdio: inherited, except that its stdout goes to stderr while stdout is reserved. */
|
|
33
|
+
export function childStdio(): StdioOptions {
|
|
34
|
+
return reserved ? ["inherit", 2, "inherit"] : "inherit";
|
|
35
|
+
}
|
package/src/preview.ts
CHANGED
|
@@ -27,12 +27,13 @@ import { tmpdir } from "node:os";
|
|
|
27
27
|
import { join } from "node:path";
|
|
28
28
|
import { ownerInviteSql } from "@kedge/db";
|
|
29
29
|
import { enrolOwner, type StoreCredentials } from "@kedge/testing/http";
|
|
30
|
-
import { type Cloudflare, UnknownOutcome } from "./cf";
|
|
30
|
+
import { type Cloudflare, type JurisdictionChoice, UnknownOutcome } from "./cf";
|
|
31
31
|
import { seed } from "./commands";
|
|
32
32
|
import type { Lock } from "./compose";
|
|
33
33
|
import { CORE_SECRETS, DeployRefused, openOps, stageAssets } from "./deploy";
|
|
34
34
|
import { assetsShape, declaredClasses, readWrangler } from "./doctor";
|
|
35
35
|
import { LostOwnership, OperationHeld, type OpsStore } from "./ops";
|
|
36
|
+
import { childStdio } from "./output";
|
|
36
37
|
import { hashTree, type StoreNames, sha256, type WranglerJson } from "./store";
|
|
37
38
|
import { readTar, type TarEntry, TarRefused, writeTar } from "./tar";
|
|
38
39
|
|
|
@@ -430,6 +431,8 @@ export interface ApplyInput {
|
|
|
430
431
|
name?: string;
|
|
431
432
|
/** Secrets the caller grants from its own environment (--secret-env). */
|
|
432
433
|
secretEnv?: readonly string[];
|
|
434
|
+
/** The person's choice, when this preview is the store's first resource (p0.10 §7). */
|
|
435
|
+
jurisdiction?: JurisdictionChoice;
|
|
433
436
|
env: NodeJS.ProcessEnv;
|
|
434
437
|
fresh?: boolean;
|
|
435
438
|
takeOver?: string;
|
|
@@ -466,7 +469,8 @@ export async function applyPlan(input: ApplyInput): Promise<Applied> {
|
|
|
466
469
|
const { dir: staging, plan } = stagePlan(input.stream);
|
|
467
470
|
const store = input.store ?? plan.store;
|
|
468
471
|
const name = input.name ?? plan.name;
|
|
469
|
-
|
|
472
|
+
// A preview before the first deploy makes <store>-ops, so it fixes the store's jurisdiction; it takes its store's.
|
|
473
|
+
const { ops, jurisdiction } = await openOps(input.cf, opsNames(store), input.now, input.jurisdiction ?? "ask");
|
|
470
474
|
const scope = `preview:${store}/${name}`;
|
|
471
475
|
const key = input.env.KEDGE_PREVIEW_KEY;
|
|
472
476
|
if (!key) throw new DeployRefused("set KEDGE_PREVIEW_KEY: a preview's credentials are sealed under it");
|
|
@@ -478,7 +482,19 @@ export async function applyPlan(input: ApplyInput): Promise<Applied> {
|
|
|
478
482
|
} else opId = await ops.start(scope, "preview", { modules: plan.modules, migrations: plan.migrations }, "start");
|
|
479
483
|
const release = (outcome: string) => ops.finish(scope, opId, outcome, [{ modules: plan.modules }]);
|
|
480
484
|
try {
|
|
481
|
-
const p = new PreviewRun(
|
|
485
|
+
const p = new PreviewRun(
|
|
486
|
+
{ ...input, jurisdiction },
|
|
487
|
+
ops,
|
|
488
|
+
scope,
|
|
489
|
+
opId,
|
|
490
|
+
staging,
|
|
491
|
+
plan,
|
|
492
|
+
`${store}/${name}`,
|
|
493
|
+
store,
|
|
494
|
+
name,
|
|
495
|
+
key,
|
|
496
|
+
log,
|
|
497
|
+
);
|
|
482
498
|
const out = await p.run();
|
|
483
499
|
return { ...out, release };
|
|
484
500
|
} catch (e) {
|
|
@@ -604,7 +620,10 @@ class PreviewRun {
|
|
|
604
620
|
if (found) return found;
|
|
605
621
|
const id = await this.ops.openIntent(this.scope, this.opId, "create", kind, row.worker, { name: nameOf });
|
|
606
622
|
try {
|
|
607
|
-
const
|
|
623
|
+
const j = this.input.jurisdiction ?? "none";
|
|
624
|
+
const out = await (kind === "d1"
|
|
625
|
+
? this.cf.createD1(nameOf, j === "none" ? null : j)
|
|
626
|
+
: this.cf.createQueue(nameOf));
|
|
608
627
|
await this.ops.closeIntent(id, "done", "answer");
|
|
609
628
|
return out;
|
|
610
629
|
} catch (e) {
|
|
@@ -620,6 +639,13 @@ class PreviewRun {
|
|
|
620
639
|
}
|
|
621
640
|
};
|
|
622
641
|
const databaseId = await create("d1", row.database_name, row.database_id);
|
|
642
|
+
// Recorded, found or made, the preview's database is accepted only where its store's data lives (p0.10 §7):
|
|
643
|
+
// one replaced outside Kedge would take the preview's writes elsewhere (codex code r13).
|
|
644
|
+
const where = await this.cf.d1Jurisdiction(databaseId);
|
|
645
|
+
if (where !== (this.input.jurisdiction ?? "none"))
|
|
646
|
+
throw new DeployRefused(
|
|
647
|
+
`${row.database_name} is in jurisdiction ${where}, and this store's is ${this.input.jurisdiction ?? "none"}`,
|
|
648
|
+
);
|
|
623
649
|
await this.write("database_id = ?", [databaseId]);
|
|
624
650
|
const queueId = await create("queue", row.queue_name, row.queue_id);
|
|
625
651
|
await this.write("queue_id = ?", [queueId]);
|
|
@@ -632,7 +658,8 @@ class PreviewRun {
|
|
|
632
658
|
databaseId,
|
|
633
659
|
migrationsDir: join(this.staging, "migrations"),
|
|
634
660
|
queue: row.queue_name,
|
|
635
|
-
|
|
661
|
+
// The store's jurisdiction, never the plan's: a preview's objects live where its store's do (p0.10 §7).
|
|
662
|
+
vars: { ...this.plan.vars, KEDGE_JURISDICTION: this.input.jurisdiction ?? "none" },
|
|
636
663
|
...(this.plan.assets ? { assetsDir: join(this.staging, "assets") } : {}),
|
|
637
664
|
});
|
|
638
665
|
const configFile = join(this.staging, "..", `${row.worker}.wrangler.json`);
|
|
@@ -989,7 +1016,7 @@ export function runScenarios(cwd: string, credentials: StoreCredentials, env: No
|
|
|
989
1016
|
);
|
|
990
1017
|
const r = spawnSync(join(cwd, "node_modules", ".bin", "vitest"), ["run", "--config", config], {
|
|
991
1018
|
cwd,
|
|
992
|
-
stdio:
|
|
1019
|
+
stdio: childStdio(),
|
|
993
1020
|
env: { ...env, KEDGE_PREVIEW_CREDENTIALS: JSON.stringify(credentials) },
|
|
994
1021
|
});
|
|
995
1022
|
return r.status ?? 1;
|