@kedge/cli 0.9.0 → 0.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -0
- package/kedge-manifest.json +93 -61
- package/package.json +8 -5
- package/src/cf.ts +295 -17
- package/src/check.ts +4 -1
- package/src/cli-session.ts +57 -0
- package/src/commands.ts +339 -90
- package/src/d1-batch.ts +68 -0
- package/src/deploy.ts +91 -19
- package/src/doctor.ts +68 -3
- package/src/keys-save.ts +343 -0
- package/src/local-state.ts +122 -0
- package/src/main.ts +181 -71
- package/src/ops.ts +18 -0
- package/src/output.ts +44 -0
- package/src/preview.ts +35 -6
- package/src/smoke.ts +226 -0
- package/src/store-api.ts +36 -0
- package/src/target.ts +81 -0
package/src/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
|
|
@@ -130,10 +118,18 @@ async function inviteOrigin(
|
|
|
130
118
|
): Promise<string | null> {
|
|
131
119
|
const url = flag(args, "--url");
|
|
132
120
|
if (url) return new URL(url).origin;
|
|
133
|
-
|
|
121
|
+
// Local: kedge dev's own origin, the port it binds (p0.10.1 §1, 5; §3).
|
|
122
|
+
if (!remote) {
|
|
123
|
+
try {
|
|
124
|
+
const { devPort } = await import("./target");
|
|
125
|
+
return `http://localhost:${devPort(cwd)}`;
|
|
126
|
+
} catch {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
134
130
|
try {
|
|
135
131
|
const worker = storeNames(cwd).names.worker;
|
|
136
|
-
return `https://${worker}.${await (cf ??
|
|
132
|
+
return `https://${worker}.${await (cf ?? (await cloudflareFor(env, cwd))).workersSubdomain()}.workers.dev`;
|
|
137
133
|
} catch {
|
|
138
134
|
return null;
|
|
139
135
|
}
|
|
@@ -145,50 +141,87 @@ async function bootstrap(
|
|
|
145
141
|
cwd: string,
|
|
146
142
|
execute: Executor,
|
|
147
143
|
env: NodeJS.ProcessEnv,
|
|
148
|
-
cf?: Cloudflare,
|
|
144
|
+
o: { json: boolean; emit?: (result: Record<string, unknown>) => void; cf?: Cloudflare },
|
|
149
145
|
): Promise<number> {
|
|
146
|
+
const cf = o.cf;
|
|
147
|
+
const result = (r: Record<string, unknown>) => {
|
|
148
|
+
if (o.json) (o.emit ?? emit)(r);
|
|
149
|
+
};
|
|
150
|
+
// A usage error is an end too: under --json it's an object, never an empty stdout (codex code r8).
|
|
151
|
+
const usage = (message: string) => {
|
|
152
|
+
console.error(message);
|
|
153
|
+
result({ ok: false, error: message.split("\n")[0] });
|
|
154
|
+
return 2;
|
|
155
|
+
};
|
|
156
|
+
// Wrangler's own words are on stderr already; the object says only that the batch didn't finish.
|
|
157
|
+
const failed = (status: number) => {
|
|
158
|
+
result({ ok: false, error: `wrangler d1 execute exited ${status}` });
|
|
159
|
+
return status;
|
|
160
|
+
};
|
|
150
161
|
const config = flag(args, "--config");
|
|
162
|
+
const remote = args.includes("--remote");
|
|
163
|
+
// Resolved before any write, as every remote command does (p0.10 §4): no credentials is the
|
|
164
|
+
// handoff, exit 3, never Wrangler's own failure; and the batch goes where the resolver says,
|
|
165
|
+
// salve's broker included (codex code r2).
|
|
166
|
+
const auth = remote ? await resolveCloudflare(env, cwd) : null;
|
|
167
|
+
// A --config of another file is what Wrangler reads, so its account_id must agree too.
|
|
168
|
+
if (auth && config) sameAccount(auth.account, resolve(cwd, config));
|
|
169
|
+
const base = cloudflareBase(env);
|
|
151
170
|
const opts = {
|
|
152
171
|
database: flag(args, "--database") ?? "DB",
|
|
153
|
-
remote
|
|
172
|
+
remote,
|
|
154
173
|
cwd,
|
|
155
174
|
...(config ? { config } : {}),
|
|
175
|
+
...(auth
|
|
176
|
+
? {
|
|
177
|
+
env: {
|
|
178
|
+
CLOUDFLARE_API_TOKEN: auth.token(),
|
|
179
|
+
CLOUDFLARE_ACCOUNT_ID: auth.account,
|
|
180
|
+
// Always, the default included: Wrangler would otherwise take one from the store's
|
|
181
|
+
// dotenv files and send this token there (codex code r4).
|
|
182
|
+
CLOUDFLARE_API_BASE_URL: base,
|
|
183
|
+
},
|
|
184
|
+
}
|
|
185
|
+
: {}),
|
|
156
186
|
};
|
|
157
187
|
const now = Date.now();
|
|
158
188
|
const [sub, target] = args;
|
|
159
189
|
if (command === "staff" && (sub === "invite-owner" || sub === "recover") && target && !target.startsWith("--")) {
|
|
160
190
|
const b = sub === "invite-owner" ? await ownerInviteSql(target, now) : await recoverSql(target, now);
|
|
161
191
|
const status = execute(b, opts);
|
|
162
|
-
if (status !== 0) return status;
|
|
163
|
-
|
|
192
|
+
if (status !== 0) return failed(status);
|
|
193
|
+
const expires = new Date(b.expiresAt).toISOString();
|
|
194
|
+
console.log(`invite ${b.inviteId} for ${target}, valid until ${expires}`);
|
|
164
195
|
console.log(`accept with POST /auth/admin/invites/accept and this token (shown once):\n${b.token}`);
|
|
165
196
|
const origin = await inviteOrigin(args, opts.remote, cwd, env, cf);
|
|
166
197
|
// The token rides in the fragment, which no server log or Referer carries (p0.9 §3.5).
|
|
167
|
-
|
|
198
|
+
const link = origin ? `${origin}/app/invite#${b.token}` : null;
|
|
199
|
+
if (link) console.log(`or open the admin's invite page:\n${link}`);
|
|
200
|
+
result({ ok: true, invite: b.inviteId, email: target, expires_at: expires, token: b.token, link });
|
|
168
201
|
return 0;
|
|
169
202
|
}
|
|
170
203
|
if (command === "keys" && sub === "create" && args.includes("--publishable")) {
|
|
171
204
|
const name = flag(args, "--name");
|
|
172
|
-
if (!name)
|
|
173
|
-
console.error("keys create needs --name");
|
|
174
|
-
return 2;
|
|
175
|
-
}
|
|
205
|
+
if (!name) return usage("keys create needs --name");
|
|
176
206
|
const origins = args.flatMap((a, i) => (a === "--origin" && args[i + 1] ? [args[i + 1] as string] : []));
|
|
177
207
|
const pepperVersion = Number(flag(args, "--pepper-version") ?? "1");
|
|
178
208
|
const pepper = env[`KEDGE_KEY_PEPPER_V${pepperVersion}`];
|
|
179
|
-
if (!Number.isSafeInteger(pepperVersion) || pepperVersion < 1
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
209
|
+
if (!Number.isSafeInteger(pepperVersion) || pepperVersion < 1)
|
|
210
|
+
return usage("--pepper-version takes a whole number from 1");
|
|
211
|
+
// The pepper lives in the merchant's own vault, or only in the Worker: theirs to hand over (p0.10 §5).
|
|
212
|
+
if (!pepper)
|
|
213
|
+
throw new NeedsPerson(
|
|
214
|
+
`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`,
|
|
215
|
+
);
|
|
183
216
|
const b = await publishableKeySql({ name, origins, pepperVersion, pepper }, now);
|
|
184
217
|
const status = execute(b, opts);
|
|
185
|
-
if (status !== 0) return status;
|
|
218
|
+
if (status !== 0) return failed(status);
|
|
186
219
|
console.log(`storefront ${b.storefrontId}, key ${b.keyId}`);
|
|
187
220
|
console.log(`publishable key (send as x-kedge-key; shown once):\n${b.key}`);
|
|
221
|
+
result({ ok: true, storefront: b.storefrontId, key_id: b.keyId, key: b.key });
|
|
188
222
|
return 0;
|
|
189
223
|
}
|
|
190
|
-
|
|
191
|
-
return 2;
|
|
224
|
+
return usage(USAGE);
|
|
192
225
|
}
|
|
193
226
|
|
|
194
227
|
const INDEX_START = "<!-- kedge:docs-index:start -->";
|
|
@@ -218,13 +251,18 @@ function agentsIndex(cwd: string): { path: string; text: string } | null {
|
|
|
218
251
|
return { path, text: `${current.slice(0, a + INDEX_START.length)}\n${lines.join("\n")}\n${current.slice(b)}` };
|
|
219
252
|
}
|
|
220
253
|
|
|
221
|
-
/**
|
|
254
|
+
/**
|
|
255
|
+
* Skills ship in @kedge/docs and are copied, gitignored, so they always match
|
|
256
|
+
* what's installed (§9.3): into `.claude/skills` for Claude Code, and into
|
|
257
|
+
* `.agents/skills`, where salve and other agents read them (p0.10 §2, §6.4).
|
|
258
|
+
*/
|
|
222
259
|
function copySkills(cwd: string): string[] {
|
|
223
260
|
const docs = installedPackages(cwd).get("@kedge/docs");
|
|
224
261
|
if (!docs || !existsSync(join(docs, "skills")) || !existsSync(join(cwd, ".claude"))) return [];
|
|
225
262
|
const out: string[] = [];
|
|
226
263
|
for (const s of readdirSync(join(docs, "skills"))) {
|
|
227
|
-
|
|
264
|
+
for (const home of [".claude", ".agents"])
|
|
265
|
+
cpSync(join(docs, "skills", s), join(cwd, home, "skills", s), { recursive: true });
|
|
228
266
|
out.push(s);
|
|
229
267
|
}
|
|
230
268
|
return out;
|
|
@@ -251,29 +289,46 @@ function coreMigrationsDir(): string {
|
|
|
251
289
|
return join(dirname(entry), "..", "migrations");
|
|
252
290
|
}
|
|
253
291
|
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
292
|
+
interface RunDeps {
|
|
293
|
+
execute?: Executor;
|
|
294
|
+
env?: NodeJS.ProcessEnv;
|
|
295
|
+
cf?: Cloudflare;
|
|
296
|
+
/** Where `--json`'s object goes; tests read it. */
|
|
297
|
+
emit?: (result: Record<string, unknown>) => void;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** The commands an agent reads the result of (p0.10 §5); doctor has its own `--json`. */
|
|
301
|
+
const JSON_COMMANDS = new Set(["deploy", "preview", "staff", "keys", "seed", "smoke"]);
|
|
302
|
+
|
|
303
|
+
async function run(argv: string[], cwd: string, deps: RunDeps & { json: boolean }): Promise<number> {
|
|
259
304
|
const [command, ...flags] = argv;
|
|
305
|
+
const out = {
|
|
306
|
+
json: deps.json,
|
|
307
|
+
...(deps.emit ? { emit: deps.emit } : {}),
|
|
308
|
+
...(deps.execute ? { execute: deps.execute } : {}),
|
|
309
|
+
};
|
|
260
310
|
if (command === "keys" && flag(flags, "--url")) {
|
|
261
|
-
const d0: CommandDeps = { cwd, env: deps.env ?? process.env, coreDir: coreMigrationsDir() };
|
|
311
|
+
const d0: CommandDeps = { cwd, env: deps.env ?? process.env, coreDir: coreMigrationsDir(), ...out };
|
|
262
312
|
return keysOverHttp(flags, d0);
|
|
263
313
|
}
|
|
264
314
|
if (command === "staff" || command === "keys")
|
|
265
|
-
return bootstrap(command, flags, cwd, deps.execute ?? wranglerExecute, deps.env ?? process.env,
|
|
315
|
+
return bootstrap(command, flags, cwd, deps.execute ?? wranglerExecute, deps.env ?? process.env, {
|
|
316
|
+
...out,
|
|
317
|
+
...(deps.cf ? { cf: deps.cf } : {}),
|
|
318
|
+
});
|
|
266
319
|
const d: CommandDeps = {
|
|
267
320
|
cwd,
|
|
268
321
|
env: deps.env ?? process.env,
|
|
269
322
|
coreDir: coreMigrationsDir(),
|
|
270
323
|
checkFast: () => main(["check", "--fast"], cwd),
|
|
324
|
+
...out,
|
|
271
325
|
};
|
|
272
326
|
if (command === "deploy") return deployCommand(flags, d);
|
|
273
327
|
if (command === "migrate") return migrateCommand(flags, d);
|
|
274
328
|
if (command === "restore") return restoreCommand(flags, d);
|
|
275
329
|
if (command === "secret") return secretCommand(flags, d);
|
|
276
330
|
if (command === "seed") return seedCommand(flags, d);
|
|
331
|
+
if (command === "smoke") return smokeCommand(flags, d);
|
|
277
332
|
if (command === "preview") return previewCommand(flags, d);
|
|
278
333
|
if (command === "eject") return ejectCommand(flags, d);
|
|
279
334
|
if (command === "upgrade")
|
|
@@ -302,11 +357,44 @@ export async function main(
|
|
|
302
357
|
if (command === "doctor") {
|
|
303
358
|
const results = await doctor({ cwd, coreDir: coreMigrationsDir() });
|
|
304
359
|
if (flags.includes("--remote")) {
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
360
|
+
let auth: Awaited<ReturnType<typeof resolveCloudflare>> | null = null;
|
|
361
|
+
try {
|
|
362
|
+
auth = await resolveCloudflare(d.env, cwd);
|
|
363
|
+
} catch (e) {
|
|
364
|
+
// No credentials is a check that failed and a handoff, with the offline report still printed (codex code r8).
|
|
365
|
+
if (!(e instanceof NeedsPerson)) throw e;
|
|
366
|
+
results.push({ name: "credentials", status: "fail", message: e.handoff, handoff: e.handoff });
|
|
367
|
+
}
|
|
368
|
+
if (auth) {
|
|
369
|
+
const cf = restCloudflare(d.env, fetch, wranglerBin(), auth);
|
|
370
|
+
const lock = JSON.parse(readFileSync(join(cwd, "kedge.lock.json"), "utf8")) as Lock;
|
|
371
|
+
const creds = await credentialsChecks({ cf, source: auth.source, base: cloudflareBase(d.env) });
|
|
372
|
+
results.push(...creds);
|
|
373
|
+
// Credentials that can't read the account would make every later check throw, not report (codex code r7).
|
|
374
|
+
if (creds.some((c) => c.status === "fail"))
|
|
375
|
+
results.push({
|
|
376
|
+
name: "remote",
|
|
377
|
+
status: "skip",
|
|
378
|
+
message: "the store's resources weren't read: fix the credentials first",
|
|
379
|
+
});
|
|
380
|
+
else results.push(...(await doctorRemote({ cwd, cf, lock, logpushJobs: () => cf.logpushJobs() })));
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
const handoff = results.find((r) => r.handoff)?.handoff;
|
|
384
|
+
// The same envelope as every --json command (p0.10.1 §1, 7): ok, the checks, and a handoff when there is one.
|
|
385
|
+
console.log(
|
|
386
|
+
flags.includes("--json")
|
|
387
|
+
? JSON.stringify({
|
|
388
|
+
ok: !results.some((r) => r.status === "fail"),
|
|
389
|
+
checks: results,
|
|
390
|
+
...(handoff ? { handoff } : {}),
|
|
391
|
+
})
|
|
392
|
+
: formatReport(results),
|
|
393
|
+
);
|
|
394
|
+
if (handoff) {
|
|
395
|
+
console.error(`for you: ${handoff}`);
|
|
396
|
+
return 3;
|
|
308
397
|
}
|
|
309
|
-
console.log(flags.includes("--json") ? JSON.stringify({ checks: results }, null, 2) : formatReport(results));
|
|
310
398
|
return results.some((r) => r.status === "fail") ? 1 : 0;
|
|
311
399
|
}
|
|
312
400
|
if (command === "check")
|
|
@@ -376,3 +464,25 @@ export async function main(
|
|
|
376
464
|
throw e;
|
|
377
465
|
}
|
|
378
466
|
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* The CLI. A handoff from any command ends it with exit 3 and one sentence for
|
|
470
|
+
* whoever runs it to pass on (p0.10 §5).
|
|
471
|
+
*/
|
|
472
|
+
export async function main(argv: string[], cwd: string, deps: RunDeps = {}): Promise<number> {
|
|
473
|
+
const [command, ...flags] = argv;
|
|
474
|
+
const json = flags.includes("--json") && JSON_COMMANDS.has(command ?? "");
|
|
475
|
+
return withJsonOutput(json, async () => {
|
|
476
|
+
try {
|
|
477
|
+
return await run(argv, cwd, { ...deps, json });
|
|
478
|
+
} catch (e) {
|
|
479
|
+
// What escapes a command's own handling (bootstrap, doctor) ends the same way (p0.10 §5).
|
|
480
|
+
const f = failureOf(e);
|
|
481
|
+
if (!f) throw e;
|
|
482
|
+
console.error(f.line);
|
|
483
|
+
// doctor keeps its own --json report, and a failure past it is still one object (p0.10.1, codex code r20).
|
|
484
|
+
if (json || (command === "doctor" && flags.includes("--json"))) (deps.emit ?? emit)(f.result);
|
|
485
|
+
return f.code;
|
|
486
|
+
}
|
|
487
|
+
});
|
|
488
|
+
}
|
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,44 @@
|
|
|
1
|
+
// `--json` (p0.10 §5): stdout carries one object when the command ends, and
|
|
2
|
+
// nothing else, so an agent parses it whole. stderr carries only what the
|
|
3
|
+
// agent must read: a refusal, a handoff's `for you:` line, or a child's failure.
|
|
4
|
+
// Progress is dropped, and every child the CLI starts (Wrangler, the store's
|
|
5
|
+
// checks and scenarios) is captured and shown, its last lines on stderr, only
|
|
6
|
+
// when it fails: an agent's tool shows both streams as one, so anything chatty
|
|
7
|
+
// there reads as part of the answer (p0.10.1 §1, 7).
|
|
8
|
+
import type { SpawnSyncReturns, StdioOptions } from "node:child_process";
|
|
9
|
+
|
|
10
|
+
let reserved = false;
|
|
11
|
+
|
|
12
|
+
/** Runs `work` with stdout reserved for `emit` when `on`; nested runs keep the outer setting. */
|
|
13
|
+
export async function withJsonOutput<T>(on: boolean, work: () => Promise<T>): Promise<T> {
|
|
14
|
+
if (!on || reserved) return work();
|
|
15
|
+
const log = console.log;
|
|
16
|
+
const info = console.info;
|
|
17
|
+
reserved = true;
|
|
18
|
+
console.log = () => {};
|
|
19
|
+
console.info = () => {};
|
|
20
|
+
try {
|
|
21
|
+
return await work();
|
|
22
|
+
} finally {
|
|
23
|
+
reserved = false;
|
|
24
|
+
console.log = log;
|
|
25
|
+
console.info = info;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The command's one result object. */
|
|
30
|
+
export function emit(result: Record<string, unknown>): void {
|
|
31
|
+
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** A child's stdio: inherited, or under `--json` captured, for `childFailed` to show if it fails. */
|
|
35
|
+
export function childStdio(): StdioOptions {
|
|
36
|
+
return reserved ? ["ignore", "pipe", "pipe"] : "inherit";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Under `--json`, a captured child that failed shows its last lines on stderr; inherited output was already seen. */
|
|
40
|
+
export function childFailed(r: Pick<SpawnSyncReturns<string | Buffer>, "status" | "stdout" | "stderr">): void {
|
|
41
|
+
if (!reserved || r.status === 0) return;
|
|
42
|
+
const out = `${r.stdout?.toString() ?? ""}${r.stderr?.toString() ?? ""}`.trim();
|
|
43
|
+
if (out) process.stderr.write(`${out.split("\n").slice(-40).join("\n")}\n`);
|
|
44
|
+
}
|
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 { childFailed, 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,9 +1016,11 @@ 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) },
|
|
1021
|
+
maxBuffer: 1 << 26,
|
|
994
1022
|
});
|
|
1023
|
+
childFailed(r);
|
|
995
1024
|
return r.status ?? 1;
|
|
996
1025
|
} finally {
|
|
997
1026
|
rmSync(tmp, { recursive: true, force: true });
|