@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/src/cf.ts ADDED
@@ -0,0 +1,451 @@
1
+ // Every command that touches Cloudflare goes through here
2
+ // (docs/proposals/p0.8-cli-agent-kit.md §3): REST calls of one attempt each, so
3
+ // a lost response is an unknown outcome the caller records rather than a
4
+ // retry that might land late; and Wrangler, for what only it does (uploading a
5
+ // bundle, applying migrations with its own bookkeeping), run from a trusted
6
+ // directory with a sanitized environment (§5.3). A failed lookup throws:
7
+ // only a confirmed "not found" counts as absent, the rule every spike's
8
+ // cleanup learned.
9
+ import { spawnSync } from "node:child_process";
10
+ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
11
+ import { createRequire } from "node:module";
12
+ import { tmpdir } from "node:os";
13
+ import { dirname, join } from "node:path";
14
+ import { fileURLToPath } from "node:url";
15
+
16
+ export type Row = Record<string, unknown>;
17
+
18
+ export interface Deployment {
19
+ id: string;
20
+ versions: { version_id: string; percentage: number }[];
21
+ message: string | null;
22
+ }
23
+
24
+ export interface WorkerSettings {
25
+ logpush: boolean;
26
+ invocationLogs: boolean | null;
27
+ }
28
+
29
+ /** A call whose answer never came: the caller records it as an unknown outcome (§3.1). */
30
+ export class UnknownOutcome extends Error {
31
+ constructor(what: string) {
32
+ super(`${what}: no answer from Cloudflare; its outcome is unknown`);
33
+ this.name = "UnknownOutcome";
34
+ }
35
+ }
36
+
37
+ /**
38
+ * A Wrangler process that exited non-zero. It may have reached Cloudflare before failing, or
39
+ * lost the answer, so its outcome is unknown, never "failed": the intent stays for evidence
40
+ * (an activation's tag in the deployment list, d1_migrations) or a person (codex code r3).
41
+ */
42
+ export class WranglerFailed extends UnknownOutcome {
43
+ constructor(
44
+ what: string,
45
+ readonly tail: string,
46
+ readonly outputs: Row[],
47
+ ) {
48
+ super(what);
49
+ this.message = `${what} failed, and may have reached Cloudflare first:\n${tail}`;
50
+ this.name = "WranglerFailed";
51
+ }
52
+ }
53
+
54
+ export class CloudflareError extends Error {
55
+ constructor(
56
+ what: string,
57
+ readonly status: number,
58
+ readonly codes: number[],
59
+ /** Cloudflare's own words about its control plane, for the person reading the refusal; never decided on. */
60
+ readonly detail: string[] = [],
61
+ ) {
62
+ super(
63
+ `${what}: Cloudflare answered ${status}${codes.length ? ` (${codes.join(", ")})` : ""}${detail.length ? `: ${detail.join("; ").slice(0, 300)}` : ""}`,
64
+ );
65
+ this.name = "CloudflareError";
66
+ }
67
+ }
68
+
69
+ /** What the CLI needs of Cloudflare, by meaning. Tests replace it with a fake that can lose answers. */
70
+ export interface Cloudflare {
71
+ readonly account: string;
72
+ findD1(name: string): Promise<string | null>;
73
+ createD1(name: string): Promise<string>;
74
+ deleteD1(id: string): Promise<void>;
75
+ queryD1(id: string, sql: string, params?: readonly unknown[]): Promise<Row[]>;
76
+ bookmark(id: string): Promise<string>;
77
+ findQueue(name: string): Promise<string | null>;
78
+ createQueue(name: string): Promise<string>;
79
+ deleteQueue(id: string): Promise<void>;
80
+ consumers(queueId: string): Promise<{ id: string; script: string }[]>;
81
+ deleteConsumer(queueId: string, consumerId: string): Promise<void>;
82
+ findWorker(name: string): Promise<boolean>;
83
+ deleteWorker(name: string): Promise<void>;
84
+ /** Newest first. */
85
+ versions(name: string): Promise<{ id: string; created: string; message: string | null }[]>;
86
+ /** Newest first; the first is what serves. */
87
+ deployments(name: string): Promise<Deployment[]>;
88
+ /** One attempt: POST a deployment of one version at 100%, its message the intent's tag. */
89
+ activate(name: string, versionId: string, message: string): Promise<void>;
90
+ schedules(name: string): Promise<string[]>;
91
+ putSchedules(name: string, crons: readonly string[]): Promise<void>;
92
+ settings(name: string): Promise<WorkerSettings>;
93
+ patchSettings(name: string, s: WorkerSettings): Promise<void>;
94
+ secretNames(name: string): Promise<string[]>;
95
+ /** `wrangler versions upload --no-bundle` of a staged bundle; returns its version id. */
96
+ uploadVersion(input: UploadInput): Promise<string>;
97
+ /** `wrangler deploy --no-bundle`: only where Cloudflare requires it, a class change (§3.1). */
98
+ deployWithClasses(input: UploadInput): Promise<string>;
99
+ /** `wrangler d1 migrations apply --remote` from a staged directory. */
100
+ applyMigrations(input: { config: string; database: string }): Promise<void>;
101
+ /** One SQL file as one transaction (`wrangler d1 execute --file`): the CLI's audited batches (p0.5 §3.4). */
102
+ executeD1File(input: { database: string; databaseId: string; sql: string }): Promise<void>;
103
+ /** The account's workers.dev subdomain. */
104
+ workersSubdomain(): Promise<string>;
105
+ /** The account's Logpush jobs, or null when the token can't read them. */
106
+ logpushJobs(): Promise<{ dataset: string; enabled: boolean; fields: string[] | null }[] | null>;
107
+ /** `wrangler versions secret put|delete`: a new version with one secret changed, not deployed. */
108
+ versionSecret(worker: string, name: string, value: string | null, message: string): Promise<string>;
109
+ }
110
+
111
+ export interface UploadInput {
112
+ /** A generated Wrangler config, every path in it absolute. */
113
+ config: string;
114
+ message: string;
115
+ secretsFile?: string;
116
+ }
117
+
118
+ const API = "https://api.cloudflare.com/client/v4";
119
+
120
+ type Envelope<T> = {
121
+ success?: boolean;
122
+ result?: T;
123
+ errors?: { code: number; message?: string }[];
124
+ result_info?: { total_pages?: number };
125
+ };
126
+
127
+ /** The installed Wrangler's entry: the CLI's own dependency, never the store's code. */
128
+ export function wranglerBin(): string {
129
+ const require = createRequire(fileURLToPath(import.meta.url));
130
+ return join(dirname(require.resolve("wrangler/package.json")), "bin", "wrangler.js");
131
+ }
132
+
133
+ export function restCloudflare(
134
+ env: NodeJS.ProcessEnv,
135
+ fetchImpl: typeof fetch = fetch,
136
+ /** The Wrangler entry to run; tests give a stand-in. */
137
+ wranglerEntry: string = wranglerBin(),
138
+ ): Cloudflare {
139
+ const token = env.CLOUDFLARE_API_TOKEN;
140
+ const account = env.CLOUDFLARE_ACCOUNT_ID;
141
+ if (!token || !account) throw new Error("set CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID");
142
+ const base = `${API}/accounts/${account}`;
143
+
144
+ async function call<T>(what: string, method: string, path: string, body?: unknown, ok404 = false): Promise<T | null> {
145
+ let res: Response;
146
+ try {
147
+ res = await fetchImpl(`${base}${path}`, {
148
+ method,
149
+ headers: {
150
+ authorization: `Bearer ${token}`,
151
+ ...(body === undefined ? {} : { "content-type": "application/json" }),
152
+ },
153
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
154
+ });
155
+ } catch {
156
+ throw new UnknownOutcome(what);
157
+ }
158
+ if (ok404 && res.status === 404) return null;
159
+ let data: Envelope<T>;
160
+ try {
161
+ data = (await res.json()) as Envelope<T>;
162
+ } catch {
163
+ // A body we can't read isn't an answer: a lookup reading it as "absent" would forget a live resource.
164
+ if (method === "GET") throw new CloudflareError(what, res.status, []);
165
+ throw new UnknownOutcome(what);
166
+ }
167
+ if (!res.ok || data.success !== true) {
168
+ if (res.status >= 500 && method !== "GET") throw new UnknownOutcome(what);
169
+ throw new CloudflareError(
170
+ what,
171
+ res.status,
172
+ (data.errors ?? []).map((e) => e.code),
173
+ (data.errors ?? []).map((e) => e.message ?? "").filter(Boolean),
174
+ );
175
+ }
176
+ return (data.result ?? null) as T;
177
+ }
178
+ const get = <T>(what: string, path: string, ok404 = false) => call<T>(what, "GET", path, undefined, ok404);
179
+
180
+ async function all<T>(what: string, path: string): Promise<T[]> {
181
+ const out: T[] = [];
182
+ for (let page = 1; page <= 50; page++) {
183
+ const sep = path.includes("?") ? "&" : "?";
184
+ const r = await get<T[]>(what, `${path}${sep}page=${page}&per_page=100`);
185
+ if (!Array.isArray(r)) throw new CloudflareError(`${what}: no list`, 200, []);
186
+ out.push(...r);
187
+ if (r.length < 100) return out;
188
+ }
189
+ throw new CloudflareError(`${what}: more than 50 pages`, 200, []);
190
+ }
191
+
192
+ function wrangler(
193
+ args: string[],
194
+ extra: Record<string, string> = {},
195
+ input?: string,
196
+ ): { out: string; outputs: Row[] } {
197
+ // A fresh directory of our own: Wrangler loads `.env` from its working
198
+ // directory and honours CLOUDFLARE_API_BASE_URL, so neither the store's
199
+ // files nor the caller's environment may reach it (§5.3).
200
+ const dir = mkdtempSync(join(tmpdir(), "kedge-wrangler-"));
201
+ const envFile = join(dir, "empty.env");
202
+ const output = join(dir, "output.ndjson");
203
+ writeFileSync(envFile, "");
204
+ try {
205
+ const r = spawnSync(process.execPath, [wranglerEntry, ...args, "--env-file", envFile], {
206
+ cwd: dir,
207
+ encoding: "utf8",
208
+ ...(input === undefined ? {} : { input }),
209
+ env: {
210
+ PATH: env.PATH ?? "",
211
+ HOME: dir,
212
+ CLOUDFLARE_API_TOKEN: token as string,
213
+ CLOUDFLARE_ACCOUNT_ID: account as string,
214
+ WRANGLER_SEND_METRICS: "false",
215
+ WRANGLER_OUTPUT_FILE_PATH: output,
216
+ CI: "1",
217
+ ...extra,
218
+ },
219
+ });
220
+ const outputs = existsSync(output)
221
+ ? readFileSync(output, "utf8")
222
+ .split("\n")
223
+ .filter(Boolean)
224
+ .map((l) => JSON.parse(l) as Row)
225
+ : [];
226
+ if (r.status !== 0) {
227
+ const tail = `${r.stdout}\n${r.stderr}`.trim().split("\n").slice(-15).join("\n");
228
+ throw new WranglerFailed(`wrangler ${args[0]} ${args[1] ?? ""}`.trim(), tail, outputs);
229
+ }
230
+ return { out: r.stdout, outputs };
231
+ } finally {
232
+ rmSync(dir, { recursive: true, force: true });
233
+ }
234
+ }
235
+ const versionIdOf = (outputs: Row[], type: string) => {
236
+ const v = outputs.find((o) => o.type === type)?.version_id;
237
+ if (typeof v !== "string") throw new UnknownOutcome(`wrangler ${type} gave no version id`);
238
+ return v;
239
+ };
240
+
241
+ return {
242
+ account,
243
+ async findD1(name) {
244
+ const r = await all<{ uuid: string; name: string }>(
245
+ "list D1 databases",
246
+ `/d1/database?name=${encodeURIComponent(name)}`,
247
+ );
248
+ return r.find((d) => d.name === name)?.uuid ?? null;
249
+ },
250
+ async createD1(name) {
251
+ const r = await call<{ uuid: string }>(`create D1 ${name}`, "POST", "/d1/database", { name });
252
+ if (!r?.uuid) throw new UnknownOutcome(`create D1 ${name}`);
253
+ return r.uuid;
254
+ },
255
+ async deleteD1(id) {
256
+ await call(`delete D1 ${id}`, "DELETE", `/d1/database/${id}`, undefined, true);
257
+ },
258
+ async queryD1(id, sql, params = []) {
259
+ const r = await call<{ results?: Row[] }[]>(`query D1 ${id}`, "POST", `/d1/database/${id}/query`, {
260
+ sql,
261
+ params,
262
+ });
263
+ return r?.[0]?.results ?? [];
264
+ },
265
+ async bookmark(id) {
266
+ const r = await get<{ bookmark: string }>(`bookmark of ${id}`, `/d1/database/${id}/time_travel/bookmark`);
267
+ if (!r?.bookmark) throw new CloudflareError(`bookmark of ${id}`, 200, []);
268
+ return r.bookmark;
269
+ },
270
+ async findQueue(name) {
271
+ const r = await all<{ queue_id: string; queue_name: string }>("list queues", "/queues");
272
+ return r.find((q) => q.queue_name === name)?.queue_id ?? null;
273
+ },
274
+ async createQueue(name) {
275
+ const r = await call<{ queue_id: string }>(`create queue ${name}`, "POST", "/queues", { queue_name: name });
276
+ if (!r?.queue_id) throw new UnknownOutcome(`create queue ${name}`);
277
+ return r.queue_id;
278
+ },
279
+ async deleteQueue(id) {
280
+ await call(`delete queue ${id}`, "DELETE", `/queues/${id}`, undefined, true);
281
+ },
282
+ async consumers(queueId) {
283
+ const r = await get<{ consumer_id: string; script?: string; service?: string }[]>(
284
+ `consumers of ${queueId}`,
285
+ `/queues/${queueId}/consumers`,
286
+ );
287
+ return (r ?? []).map((c) => ({ id: c.consumer_id, script: c.script ?? c.service ?? "" }));
288
+ },
289
+ async deleteConsumer(queueId, consumerId) {
290
+ await call(
291
+ `detach consumer ${consumerId}`,
292
+ "DELETE",
293
+ `/queues/${queueId}/consumers/${consumerId}`,
294
+ undefined,
295
+ true,
296
+ );
297
+ },
298
+ async findWorker(name) {
299
+ return (await get(`Worker ${name}`, `/workers/scripts/${encodeURIComponent(name)}/settings`, true)) !== null;
300
+ },
301
+ async deleteWorker(name) {
302
+ await call(`delete Worker ${name}`, "DELETE", `/workers/scripts/${encodeURIComponent(name)}`, undefined, true);
303
+ },
304
+ async versions(name) {
305
+ const r = await get<{
306
+ items?: { id: string; metadata?: { created_on?: string }; annotations?: Record<string, string> }[];
307
+ }>(`versions of ${name}`, `/workers/scripts/${encodeURIComponent(name)}/versions?per_page=100`);
308
+ // A version's message is its `workers/message` annotation, as Wrangler reads it (4.152.0, cli.js:168553).
309
+ return (r?.items ?? []).map((v) => ({
310
+ id: v.id,
311
+ created: v.metadata?.created_on ?? "",
312
+ message: v.annotations?.["workers/message"] ?? null,
313
+ }));
314
+ },
315
+ async deployments(name) {
316
+ const r = await get<{
317
+ deployments?: { id: string; versions: Deployment["versions"]; annotations?: Record<string, string> }[];
318
+ }>(`deployments of ${name}`, `/workers/scripts/${encodeURIComponent(name)}/deployments`);
319
+ return (r?.deployments ?? []).map((d) => ({
320
+ id: d.id,
321
+ versions: d.versions,
322
+ message: d.annotations?.["workers/message"] ?? null,
323
+ }));
324
+ },
325
+ async activate(name, versionId, message) {
326
+ await call(
327
+ `activate ${versionId} on ${name}`,
328
+ "POST",
329
+ `/workers/scripts/${encodeURIComponent(name)}/deployments`,
330
+ {
331
+ strategy: "percentage",
332
+ versions: [{ version_id: versionId, percentage: 100 }],
333
+ // Only `workers/message`: Cloudflare refuses `workers/triggered_by` here (10210, research/26).
334
+ annotations: { "workers/message": message },
335
+ },
336
+ );
337
+ },
338
+ async schedules(name) {
339
+ const r = await get<{ schedules?: { cron: string }[] }>(
340
+ `schedules of ${name}`,
341
+ `/workers/scripts/${encodeURIComponent(name)}/schedules`,
342
+ );
343
+ return (r?.schedules ?? []).map((s) => s.cron).sort();
344
+ },
345
+ async putSchedules(name, crons) {
346
+ await call(
347
+ `schedules of ${name}`,
348
+ "PUT",
349
+ `/workers/scripts/${encodeURIComponent(name)}/schedules`,
350
+ crons.map((cron) => ({ cron })),
351
+ );
352
+ },
353
+ async settings(name) {
354
+ const r = await get<{ logpush?: boolean; observability?: { logs?: { invocation_logs?: boolean } } }>(
355
+ `settings of ${name}`,
356
+ `/workers/scripts/${encodeURIComponent(name)}/script-settings`,
357
+ );
358
+ return { logpush: r?.logpush ?? false, invocationLogs: r?.observability?.logs?.invocation_logs ?? null };
359
+ },
360
+ async patchSettings(name, s) {
361
+ await call(`settings of ${name}`, "PATCH", `/workers/scripts/${encodeURIComponent(name)}/script-settings`, {
362
+ logpush: s.logpush,
363
+ observability: { enabled: true, logs: { enabled: true, invocation_logs: s.invocationLogs ?? false } },
364
+ });
365
+ },
366
+ async secretNames(name) {
367
+ const r = await get<{ name: string }[]>(
368
+ `secrets of ${name}`,
369
+ `/workers/scripts/${encodeURIComponent(name)}/secrets`,
370
+ true,
371
+ );
372
+ return (r ?? []).map((s) => s.name).sort();
373
+ },
374
+ async uploadVersion(input) {
375
+ const { outputs } = wrangler([
376
+ "versions",
377
+ "upload",
378
+ "--no-bundle",
379
+ "-c",
380
+ input.config,
381
+ "--message",
382
+ input.message,
383
+ ...(input.secretsFile ? ["--secrets-file", input.secretsFile] : []),
384
+ ]);
385
+ return versionIdOf(outputs, "version-upload");
386
+ },
387
+ async deployWithClasses(input) {
388
+ const { outputs } = wrangler([
389
+ "deploy",
390
+ "--no-bundle",
391
+ "-c",
392
+ input.config,
393
+ "--message",
394
+ input.message,
395
+ ...(input.secretsFile ? ["--secrets-file", input.secretsFile] : []),
396
+ ]);
397
+ return versionIdOf(outputs, "deploy");
398
+ },
399
+ async applyMigrations(input) {
400
+ wrangler(["d1", "migrations", "apply", input.database, "--remote", "-c", input.config], {});
401
+ },
402
+ async executeD1File(input) {
403
+ const dir = mkdtempSync(join(tmpdir(), "kedge-sql-"));
404
+ try {
405
+ const file = join(dir, "batch.sql");
406
+ const config = join(dir, "wrangler.json");
407
+ writeFileSync(file, input.sql, { mode: 0o600 });
408
+ writeFileSync(
409
+ config,
410
+ JSON.stringify({
411
+ name: "kedge-cli",
412
+ d1_databases: [{ binding: "DB", database_name: input.database, database_id: input.databaseId }],
413
+ }),
414
+ );
415
+ wrangler(["d1", "execute", "DB", "--remote", "--file", file, "--yes", "-c", config]);
416
+ } finally {
417
+ rmSync(dir, { recursive: true, force: true });
418
+ }
419
+ },
420
+ async workersSubdomain() {
421
+ const r = await get<{ subdomain: string }>("workers.dev subdomain", "/workers/subdomain");
422
+ if (!r?.subdomain) throw new CloudflareError("no workers.dev subdomain", 200, []);
423
+ return r.subdomain;
424
+ },
425
+ async logpushJobs() {
426
+ try {
427
+ const r = await get<{ dataset: string; enabled: boolean; output_options?: { field_names?: string[] } }[]>(
428
+ "Logpush jobs",
429
+ "/logpush/jobs",
430
+ );
431
+ return (r ?? []).map((j) => ({
432
+ dataset: j.dataset,
433
+ enabled: j.enabled,
434
+ fields: j.output_options?.field_names ?? null,
435
+ }));
436
+ } catch (e) {
437
+ if (e instanceof CloudflareError && (e.status === 403 || e.status === 401)) return null;
438
+ throw e;
439
+ }
440
+ },
441
+ async versionSecret(worker, name, value, message) {
442
+ const { out } =
443
+ value === null
444
+ ? wrangler(["versions", "secret", "delete", name, "--name", worker, "--message", message])
445
+ : wrangler(["versions", "secret", "put", name, "--name", worker, "--message", message], {}, value);
446
+ const id = /Created version ([0-9a-f-]{36})/.exec(out)?.[1];
447
+ if (!id) throw new UnknownOutcome(`versions secret on ${worker} gave no version id`);
448
+ return id;
449
+ },
450
+ };
451
+ }
package/src/check.ts ADDED
@@ -0,0 +1,44 @@
1
+ // `kedge check` (docs/proposals/p0.8-cli-agent-kit.md §4.4): the guard an agent
2
+ // runs before it stops, in order, stopping at the first failure. `--fast`
3
+ // (the Stop hook's) needs no Cloudflare and finishes in seconds; the full one
4
+ // adds the store's tests in workerd, with the invariant suite after each.
5
+ import { spawnSync } from "node:child_process";
6
+ import { existsSync } from "node:fs";
7
+ import { join } from "node:path";
8
+
9
+ export interface CheckInput {
10
+ cwd: string;
11
+ fast: boolean;
12
+ generate: (cwd: string) => Promise<number>;
13
+ doctor: (cwd: string) => Promise<number>;
14
+ /** Runs a tool from the store's node_modules/.bin; tests replace it. */
15
+ run?: (bin: string, args: string[], cwd: string) => number;
16
+ }
17
+
18
+ const runBin = (bin: string, args: string[], cwd: string): number => {
19
+ const path = join(cwd, "node_modules", ".bin", bin);
20
+ if (!existsSync(path)) {
21
+ console.error(`${bin} isn't installed in this store: run pnpm install`);
22
+ return 1;
23
+ }
24
+ return spawnSync(path, args, { cwd, stdio: "inherit" }).status ?? 1;
25
+ };
26
+
27
+ export async function check(input: CheckInput): Promise<number> {
28
+ const run = input.run ?? runBin;
29
+ const steps: [string, () => Promise<number> | number][] = [
30
+ ["typecheck", () => run("tsc", ["--noEmit", "-p", "."], input.cwd)],
31
+ ["kedge generate --check", () => input.generate(input.cwd)],
32
+ ["kedge doctor", () => input.doctor(input.cwd)],
33
+ ...(input.fast ? [] : ([["tests", () => run("vitest", ["run"], input.cwd)]] as [string, () => number][])),
34
+ ];
35
+ for (const [name, step] of steps) {
36
+ const code = await step();
37
+ if (code !== 0) {
38
+ console.error(`\nkedge check: ${name} failed. Fix it and run kedge check${input.fast ? " --fast" : ""} again.`);
39
+ return code;
40
+ }
41
+ }
42
+ console.log(`kedge check${input.fast ? " --fast" : ""} passed`);
43
+ return 0;
44
+ }