@kedge/cli 0.0.0-stage → 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/LICENSE +21 -0
- package/bin/kedge.mjs +8 -0
- package/kedge-manifest.json +418 -0
- package/package.json +43 -3
- package/src/cf.ts +729 -0
- package/src/check.ts +45 -0
- package/src/cli-session.ts +57 -0
- package/src/code-lint.ts +339 -0
- package/src/commands.ts +959 -0
- package/src/compose.ts +637 -0
- package/src/d1-batch.ts +66 -0
- package/src/deploy.ts +1682 -0
- package/src/doctor.ts +701 -0
- package/src/fork.ts +67 -0
- package/src/index.ts +12 -0
- package/src/invariants.ts +168 -0
- package/src/keys-save.ts +298 -0
- package/src/local-state.ts +110 -0
- package/src/main.ts +470 -0
- package/src/manifest.ts +163 -0
- package/src/n1.ts +217 -0
- package/src/ops.ts +373 -0
- package/src/order.ts +7 -0
- package/src/output.ts +35 -0
- package/src/plugin-lint.ts +145 -0
- package/src/preview.ts +1038 -0
- package/src/schema.ts +72 -0
- package/src/smoke.ts +218 -0
- package/src/sqlite-verify.ts +333 -0
- package/src/store-api.ts +36 -0
- package/src/store.ts +132 -0
- package/src/tar.ts +125 -0
- package/src/target.ts +81 -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/cf.ts
ADDED
|
@@ -0,0 +1,729 @@
|
|
|
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 { basename, dirname, join } from "node:path";
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
15
|
+
import { parseEnv } from "node:util";
|
|
16
|
+
import { jsoncToJson } from "./worker-config";
|
|
17
|
+
|
|
18
|
+
export type Row = Record<string, unknown>;
|
|
19
|
+
|
|
20
|
+
export interface Deployment {
|
|
21
|
+
id: string;
|
|
22
|
+
versions: { version_id: string; percentage: number }[];
|
|
23
|
+
message: string | null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface WorkerSettings {
|
|
27
|
+
logpush: boolean;
|
|
28
|
+
invocationLogs: boolean | null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** A call whose answer never came: the caller records it as an unknown outcome (§3.1). */
|
|
32
|
+
export class UnknownOutcome extends Error {
|
|
33
|
+
constructor(what: string, from = "Cloudflare") {
|
|
34
|
+
super(`${what}: no answer from ${from}; its outcome is unknown`);
|
|
35
|
+
this.name = "UnknownOutcome";
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A Wrangler process that exited non-zero. It may have reached Cloudflare before failing, or
|
|
41
|
+
* lost the answer, so its outcome is unknown, never "failed": the intent stays for evidence
|
|
42
|
+
* (an activation's tag in the deployment list, d1_migrations) or a person (codex code r3).
|
|
43
|
+
*/
|
|
44
|
+
export class WranglerFailed extends UnknownOutcome {
|
|
45
|
+
constructor(
|
|
46
|
+
what: string,
|
|
47
|
+
readonly tail: string,
|
|
48
|
+
readonly outputs: Row[],
|
|
49
|
+
) {
|
|
50
|
+
super(what);
|
|
51
|
+
this.message = `${what} failed, and may have reached Cloudflare first:\n${tail}`;
|
|
52
|
+
this.name = "WranglerFailed";
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export class CloudflareError extends Error {
|
|
57
|
+
constructor(
|
|
58
|
+
what: string,
|
|
59
|
+
readonly status: number,
|
|
60
|
+
readonly codes: number[],
|
|
61
|
+
/** Cloudflare's own words about its control plane, for the person reading the refusal; never decided on. */
|
|
62
|
+
readonly detail: string[] = [],
|
|
63
|
+
) {
|
|
64
|
+
super(
|
|
65
|
+
`${what}: Cloudflare answered ${status}${codes.length ? ` (${codes.join(", ")})` : ""}${detail.length ? `: ${detail.join("; ").slice(0, 300)}` : ""}`,
|
|
66
|
+
);
|
|
67
|
+
this.name = "CloudflareError";
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** What the CLI needs of Cloudflare, by meaning. Tests replace it with a fake that can lose answers. */
|
|
72
|
+
export interface Cloudflare {
|
|
73
|
+
readonly account: string;
|
|
74
|
+
findD1(name: string): Promise<string | null>;
|
|
75
|
+
/** Creates a database in `jurisdiction` (none when null): set only at creation, never after (p0.10 §7). */
|
|
76
|
+
createD1(name: string, jurisdiction?: Jurisdiction | null): Promise<string>;
|
|
77
|
+
/** The database's jurisdiction, as Cloudflare records it; `none` when it has none. */
|
|
78
|
+
d1Jurisdiction(id: string): Promise<JurisdictionChoice>;
|
|
79
|
+
deleteD1(id: string): Promise<void>;
|
|
80
|
+
queryD1(id: string, sql: string, params?: readonly unknown[]): Promise<Row[]>;
|
|
81
|
+
bookmark(id: string): Promise<string>;
|
|
82
|
+
findQueue(name: string): Promise<string | null>;
|
|
83
|
+
createQueue(name: string): Promise<string>;
|
|
84
|
+
deleteQueue(id: string): Promise<void>;
|
|
85
|
+
consumers(queueId: string): Promise<{ id: string; script: string }[]>;
|
|
86
|
+
deleteConsumer(queueId: string, consumerId: string): Promise<void>;
|
|
87
|
+
findWorker(name: string): Promise<boolean>;
|
|
88
|
+
deleteWorker(name: string): Promise<void>;
|
|
89
|
+
/** Newest first. */
|
|
90
|
+
versions(name: string): Promise<{ id: string; created: string; message: string | null }[]>;
|
|
91
|
+
/** Newest first; the first is what serves. */
|
|
92
|
+
deployments(name: string): Promise<Deployment[]>;
|
|
93
|
+
/** One attempt: POST a deployment of one version at 100%, its message the intent's tag. */
|
|
94
|
+
activate(name: string, versionId: string, message: string): Promise<void>;
|
|
95
|
+
schedules(name: string): Promise<string[]>;
|
|
96
|
+
putSchedules(name: string, crons: readonly string[]): Promise<void>;
|
|
97
|
+
settings(name: string): Promise<WorkerSettings>;
|
|
98
|
+
patchSettings(name: string, s: WorkerSettings): Promise<void>;
|
|
99
|
+
secretNames(name: string): Promise<string[]>;
|
|
100
|
+
/** `wrangler versions upload --no-bundle` of a staged bundle; returns its version id. */
|
|
101
|
+
uploadVersion(input: UploadInput): Promise<string>;
|
|
102
|
+
/** `wrangler deploy --no-bundle`: only where Cloudflare requires it, a class change (§3.1). */
|
|
103
|
+
deployWithClasses(input: UploadInput): Promise<string>;
|
|
104
|
+
/** `wrangler d1 migrations apply --remote` from a staged directory. */
|
|
105
|
+
applyMigrations(input: { config: string; database: string }): Promise<void>;
|
|
106
|
+
/** One SQL file as one transaction (`wrangler d1 execute --file`): the CLI's audited batches (p0.5 §3.4). */
|
|
107
|
+
executeD1File(input: { database: string; databaseId: string; sql: string }): Promise<void>;
|
|
108
|
+
/** The account's workers.dev subdomain; none is the person's choice to make (`NeedsPerson`). */
|
|
109
|
+
workersSubdomain(): Promise<string>;
|
|
110
|
+
/** One list of each resource type a store uses: null where it answered, else the refusing status (0: no answer). */
|
|
111
|
+
readable(): Promise<Record<ResourceKind, number | null>>;
|
|
112
|
+
/** The account's Logpush jobs, or null when the token can't read them. */
|
|
113
|
+
logpushJobs(): Promise<{ dataset: string; enabled: boolean; fields: string[] | null }[] | null>;
|
|
114
|
+
/** `wrangler versions secret put|delete`: a new version with one secret changed, not deployed. */
|
|
115
|
+
versionSecret(worker: string, name: string, value: string | null, message: string): Promise<string>;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export type ResourceKind = "Workers" | "D1" | "Queues";
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Where a store's data lives (p0.10 §7): D1 and Durable Object storage
|
|
122
|
+
* restricted to one jurisdiction, or `none`, which is a choice too. The values
|
|
123
|
+
* are Cloudflare's for both
|
|
124
|
+
* (https://developers.cloudflare.com/d1/configuration/data-location/,
|
|
125
|
+
* https://developers.cloudflare.com/durable-objects/reference/data-location/).
|
|
126
|
+
*/
|
|
127
|
+
export const JURISDICTIONS = ["eu", "us", "fedramp"] as const;
|
|
128
|
+
export type Jurisdiction = (typeof JURISDICTIONS)[number];
|
|
129
|
+
export type JurisdictionChoice = Jurisdiction | "none";
|
|
130
|
+
|
|
131
|
+
export const isJurisdictionChoice = (v: string): v is JurisdictionChoice =>
|
|
132
|
+
v === "none" || (JURISDICTIONS as readonly string[]).includes(v);
|
|
133
|
+
|
|
134
|
+
/** What the person is asked before a store's first resource exists. */
|
|
135
|
+
export const CHOOSE_JURISDICTION =
|
|
136
|
+
"choose where this store's data lives: eu, us, fedramp or none (it can't change later), then run this again with --jurisdiction <choice>";
|
|
137
|
+
|
|
138
|
+
export interface UploadInput {
|
|
139
|
+
/** A generated Wrangler config, every path in it absolute. */
|
|
140
|
+
config: string;
|
|
141
|
+
message: string;
|
|
142
|
+
secretsFile?: string;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const API = "https://api.cloudflare.com/client/v4";
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* What only a person can do next (p0.10 §5): the command stops with exit 3 and
|
|
149
|
+
* this one sentence, which an agent passes on as written.
|
|
150
|
+
*/
|
|
151
|
+
export class NeedsPerson extends Error {
|
|
152
|
+
constructor(readonly handoff: string) {
|
|
153
|
+
super(handoff);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Where every Cloudflare call goes: `CLOUDFLARE_API_BASE_URL` when set, as
|
|
159
|
+
* Wrangler's own calls do, else Cloudflare (p0.10 §4). salve's broker sets it
|
|
160
|
+
* with a token valid only there, so sending that token to Cloudflare itself
|
|
161
|
+
* would leak it and fail.
|
|
162
|
+
*/
|
|
163
|
+
export function cloudflareBase(env: NodeJS.ProcessEnv): string {
|
|
164
|
+
const raw = env.CLOUDFLARE_API_BASE_URL?.trim();
|
|
165
|
+
if (!raw) return API;
|
|
166
|
+
let u: URL;
|
|
167
|
+
try {
|
|
168
|
+
u = new URL(raw);
|
|
169
|
+
} catch {
|
|
170
|
+
throw new Error(`CLOUDFLARE_API_BASE_URL isn't a URL: ${raw}`);
|
|
171
|
+
}
|
|
172
|
+
const local = u.hostname === "localhost" || u.hostname === "127.0.0.1";
|
|
173
|
+
if (u.protocol !== "https:" && !(local && u.protocol === "http:"))
|
|
174
|
+
throw new Error(`CLOUDFLARE_API_BASE_URL must be https: ${raw}`);
|
|
175
|
+
return u.href.replace(/\/+$/, "");
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* An account without a workers.dev subdomain has nowhere to serve a store, and
|
|
180
|
+
* Kedge never registers one (p0.10 §4): it names every Worker on the account,
|
|
181
|
+
* so it's the account owner's choice, made where Wrangler sends people to make it
|
|
182
|
+
* (wrangler-dist/cli.js:165132, 4.152.0).
|
|
183
|
+
*/
|
|
184
|
+
export function noSubdomain(account: string): NeedsPerson {
|
|
185
|
+
return new NeedsPerson(
|
|
186
|
+
`this Cloudflare account has no workers.dev subdomain yet: choose one at https://dash.cloudflare.com/${account}/workers/subdomain, then run this again`,
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* What every command that can create a store resource checks before its first
|
|
192
|
+
* Cloudflare write (p0.10 §4), so a missing subdomain stops it with nothing
|
|
193
|
+
* made rather than after the database and Worker exist. Credentials have
|
|
194
|
+
* resolved by the time a `Cloudflare` exists. A store with no `<store>-ops`
|
|
195
|
+
* yet needs its jurisdiction chosen (§7); one that has it must not be given
|
|
196
|
+
* another. `ops` is that database's name; `preview apply` from a plan names
|
|
197
|
+
* its store only once the plan is read, and `openOps` checks the same there.
|
|
198
|
+
*/
|
|
199
|
+
export async function preflight(cf: Cloudflare, ops?: string, choice?: string): Promise<void> {
|
|
200
|
+
if (choice !== undefined && !isJurisdictionChoice(choice))
|
|
201
|
+
throw new NeedsPerson(
|
|
202
|
+
`--jurisdiction takes ${JURISDICTIONS.join(", ")} or none, not "${choice}": ${CHOOSE_JURISDICTION}`,
|
|
203
|
+
);
|
|
204
|
+
await cf.workersSubdomain();
|
|
205
|
+
if (ops === undefined) return;
|
|
206
|
+
const id = await cf.findD1(ops);
|
|
207
|
+
if (!id && choice === undefined) throw new NeedsPerson(CHOOSE_JURISDICTION);
|
|
208
|
+
if (id && choice !== undefined) {
|
|
209
|
+
const actual = await cf.d1Jurisdiction(id);
|
|
210
|
+
if (actual !== choice)
|
|
211
|
+
throw new NeedsPerson(
|
|
212
|
+
`this store's data lives in jurisdiction ${actual}, fixed when it was first deployed: run this again without --jurisdiction, or with --jurisdiction ${actual}`,
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** The resolved credentials: where they came from, the account, and a token fresh enough to send. */
|
|
218
|
+
export interface CloudflareAuth {
|
|
219
|
+
source: "environment" | ".env.local" | "wrangler login";
|
|
220
|
+
account: string;
|
|
221
|
+
token: () => string;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* The three sources, in order (p0.10 §4): the environment (salve, CI), the
|
|
226
|
+
* store's `.env.local`, then a `wrangler login` session read through Wrangler's
|
|
227
|
+
* own `wrangler auth token --json`, never from its files. A login's token is
|
|
228
|
+
* asked for again before every call: Wrangler refreshes an OAuth token only once
|
|
229
|
+
* it has expired, and says nothing of how long it has left, so a cached one
|
|
230
|
+
* could lapse in the middle of a cutover (codex code r1).
|
|
231
|
+
*/
|
|
232
|
+
export async function resolveCloudflare(
|
|
233
|
+
env: NodeJS.ProcessEnv,
|
|
234
|
+
cwd: string,
|
|
235
|
+
fetchImpl: typeof fetch = fetch,
|
|
236
|
+
wranglerEntry: string = wranglerBin(),
|
|
237
|
+
): Promise<CloudflareAuth> {
|
|
238
|
+
if (env.CLOUDFLARE_API_TOKEN) {
|
|
239
|
+
const token = env.CLOUDFLARE_API_TOKEN;
|
|
240
|
+
if (!env.CLOUDFLARE_ACCOUNT_ID) throw new NeedsPerson("set CLOUDFLARE_ACCOUNT_ID beside CLOUDFLARE_API_TOKEN");
|
|
241
|
+
sameAccount(env.CLOUDFLARE_ACCOUNT_ID, configIn(cwd));
|
|
242
|
+
return { source: "environment", account: env.CLOUDFLARE_ACCOUNT_ID, token: () => token };
|
|
243
|
+
}
|
|
244
|
+
const file = join(cwd, ".env.local");
|
|
245
|
+
const local = existsSync(file) ? parseEnv(readFileSync(file, "utf8")) : {};
|
|
246
|
+
if (local.CLOUDFLARE_API_TOKEN) {
|
|
247
|
+
const token = local.CLOUDFLARE_API_TOKEN;
|
|
248
|
+
const account = env.CLOUDFLARE_ACCOUNT_ID ?? local.CLOUDFLARE_ACCOUNT_ID;
|
|
249
|
+
if (!account) throw new NeedsPerson("add CLOUDFLARE_ACCOUNT_ID to .env.local beside CLOUDFLARE_API_TOKEN");
|
|
250
|
+
sameAccount(account, configIn(cwd));
|
|
251
|
+
return { source: ".env.local", account, token: () => token };
|
|
252
|
+
}
|
|
253
|
+
const token = () => {
|
|
254
|
+
const t = wranglerLoginToken(env, wranglerEntry);
|
|
255
|
+
if (!t)
|
|
256
|
+
throw new NeedsPerson(
|
|
257
|
+
"add CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID to .env.local, or run `wrangler login`, then run this again",
|
|
258
|
+
);
|
|
259
|
+
return t;
|
|
260
|
+
};
|
|
261
|
+
// Asked once here too, so a missing login is the handoff now, never a failed call later (codex code r1).
|
|
262
|
+
const first = token();
|
|
263
|
+
const account =
|
|
264
|
+
env.CLOUDFLARE_ACCOUNT_ID ?? local.CLOUDFLARE_ACCOUNT_ID ?? (await onlyAccount(env, first, fetchImpl));
|
|
265
|
+
sameAccount(account, configIn(cwd));
|
|
266
|
+
return { source: "wrangler login", account, token };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** The store's Wrangler config file, if it has one. */
|
|
270
|
+
function configIn(cwd: string): string | null {
|
|
271
|
+
for (const f of ["wrangler.jsonc", "wrangler.json"]) if (existsSync(join(cwd, f))) return join(cwd, f);
|
|
272
|
+
return null;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* A Wrangler config's `account_id` wins over `CLOUDFLARE_ACCOUNT_ID` in every
|
|
277
|
+
* Wrangler run (`getActiveAccountId`, wrangler-dist/cli.js:133664, 4.152.0),
|
|
278
|
+
* so a config naming another account than the credentials resolved would send
|
|
279
|
+
* Wrangler's writes there while Kedge's own calls go here (codex code r8).
|
|
280
|
+
* Which account is the store's is the person's to say.
|
|
281
|
+
*/
|
|
282
|
+
export function sameAccount(account: string, configFile: string | null): void {
|
|
283
|
+
if (!configFile || !existsSync(configFile)) return;
|
|
284
|
+
const named = (JSON.parse(jsoncToJson(readFileSync(configFile, "utf8"))) as { account_id?: unknown }).account_id;
|
|
285
|
+
if (typeof named === "string" && named !== "" && named !== account)
|
|
286
|
+
throw new NeedsPerson(
|
|
287
|
+
`${basename(configFile)} names Cloudflare account ${named}, but the credentials are for ${account}: remove its account_id, or use credentials for ${named}, then run this again`,
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const LEGACY_CREDENTIALS = new Set([
|
|
292
|
+
"CLOUDFLARE_API_TOKEN",
|
|
293
|
+
"CLOUDFLARE_API_KEY",
|
|
294
|
+
"CLOUDFLARE_EMAIL",
|
|
295
|
+
"CF_API_TOKEN",
|
|
296
|
+
"CF_API_KEY",
|
|
297
|
+
"CF_EMAIL",
|
|
298
|
+
]);
|
|
299
|
+
|
|
300
|
+
/** The login's token from Wrangler itself, in the person's own environment; null when there's none. */
|
|
301
|
+
function wranglerLoginToken(env: NodeJS.ProcessEnv, wranglerEntry: string): string | null {
|
|
302
|
+
// A directory of our own, so Wrangler reads no store's `.env`; HOME stays the person's, where the login is.
|
|
303
|
+
const dir = mkdtempSync(join(tmpdir(), "kedge-auth-"));
|
|
304
|
+
try {
|
|
305
|
+
// Every credential Wrangler would take from the environment goes, its legacy aliases too, so
|
|
306
|
+
// what answers is the login and nothing else (codex code r5).
|
|
307
|
+
const rest = Object.fromEntries(Object.entries(env).filter(([k]) => !LEGACY_CREDENTIALS.has(k)));
|
|
308
|
+
const r = spawnSync(process.execPath, [wranglerEntry, "auth", "token", "--json"], {
|
|
309
|
+
cwd: dir,
|
|
310
|
+
encoding: "utf8",
|
|
311
|
+
env: { ...rest, WRANGLER_SEND_METRICS: "false" },
|
|
312
|
+
});
|
|
313
|
+
if (r.status !== 0) return null;
|
|
314
|
+
const j = JSON.parse(r.stdout) as { type?: unknown; token?: unknown };
|
|
315
|
+
return j.type === "oauth" && typeof j.token === "string" && j.token ? j.token : null;
|
|
316
|
+
} catch {
|
|
317
|
+
return null;
|
|
318
|
+
} finally {
|
|
319
|
+
rmSync(dir, { recursive: true, force: true });
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** The one account a token can reach; more than one is the person's choice. */
|
|
324
|
+
async function onlyAccount(env: NodeJS.ProcessEnv, token: string, fetchImpl: typeof fetch): Promise<string> {
|
|
325
|
+
const res = await fetchImpl(`${cloudflareBase(env)}/memberships?per_page=50`, {
|
|
326
|
+
headers: { authorization: `Bearer ${token}` },
|
|
327
|
+
});
|
|
328
|
+
const data = (await res.json().catch(() => ({}))) as { result?: { account?: { id?: string; name?: string } }[] };
|
|
329
|
+
const accounts = (data.result ?? []).map((m) => m.account).filter((a): a is { id: string; name?: string } => !!a?.id);
|
|
330
|
+
if (accounts.length === 1) return (accounts[0] as { id: string }).id;
|
|
331
|
+
if (accounts.length === 0) throw new CloudflareError("the login's accounts", res.status, []);
|
|
332
|
+
throw new NeedsPerson(
|
|
333
|
+
`this login reaches ${accounts.length} Cloudflare accounts: set CLOUDFLARE_ACCOUNT_ID in .env.local to one of ${accounts.map((a) => `${a.id} (${a.name ?? "?"})`).join(", ")}`,
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
type Envelope<T> = {
|
|
338
|
+
success?: boolean;
|
|
339
|
+
result?: T;
|
|
340
|
+
errors?: { code: number; message?: string }[];
|
|
341
|
+
result_info?: { total_pages?: number };
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
/** The installed Wrangler's entry: the CLI's own dependency, never the store's code. */
|
|
345
|
+
export function wranglerBin(): string {
|
|
346
|
+
const require = createRequire(fileURLToPath(import.meta.url));
|
|
347
|
+
return join(dirname(require.resolve("wrangler/package.json")), "bin", "wrangler.js");
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** A store's Cloudflare, from whichever source resolves (p0.10 §4). */
|
|
351
|
+
export async function cloudflareFor(
|
|
352
|
+
env: NodeJS.ProcessEnv,
|
|
353
|
+
cwd: string,
|
|
354
|
+
fetchImpl: typeof fetch = fetch,
|
|
355
|
+
wranglerEntry: string = wranglerBin(),
|
|
356
|
+
): Promise<Cloudflare> {
|
|
357
|
+
return restCloudflare(env, fetchImpl, wranglerEntry, await resolveCloudflare(env, cwd, fetchImpl, wranglerEntry));
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
export function restCloudflare(
|
|
361
|
+
env: NodeJS.ProcessEnv,
|
|
362
|
+
fetchImpl: typeof fetch = fetch,
|
|
363
|
+
/** The Wrangler entry to run; tests give a stand-in. */
|
|
364
|
+
wranglerEntry: string = wranglerBin(),
|
|
365
|
+
/** Resolved credentials; without them, the environment's two variables. */
|
|
366
|
+
auth?: CloudflareAuth,
|
|
367
|
+
): Cloudflare {
|
|
368
|
+
const resolved =
|
|
369
|
+
auth ??
|
|
370
|
+
(() => {
|
|
371
|
+
const t = env.CLOUDFLARE_API_TOKEN;
|
|
372
|
+
const a = env.CLOUDFLARE_ACCOUNT_ID;
|
|
373
|
+
if (!t || !a) throw new Error("set CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID");
|
|
374
|
+
return { source: "environment" as const, account: a, token: () => t };
|
|
375
|
+
})();
|
|
376
|
+
const account = resolved.account;
|
|
377
|
+
const apiBase = cloudflareBase(env);
|
|
378
|
+
const base = `${apiBase}/accounts/${account}`;
|
|
379
|
+
|
|
380
|
+
async function call<T>(what: string, method: string, path: string, body?: unknown, ok404 = false): Promise<T | null> {
|
|
381
|
+
// Before the network's catch: a credential that's gone is a handoff, not an unknown outcome.
|
|
382
|
+
const bearer = resolved.token();
|
|
383
|
+
let res: Response;
|
|
384
|
+
try {
|
|
385
|
+
res = await fetchImpl(`${base}${path}`, {
|
|
386
|
+
method,
|
|
387
|
+
headers: {
|
|
388
|
+
authorization: `Bearer ${bearer}`,
|
|
389
|
+
...(body === undefined ? {} : { "content-type": "application/json" }),
|
|
390
|
+
},
|
|
391
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
392
|
+
});
|
|
393
|
+
} catch {
|
|
394
|
+
throw new UnknownOutcome(what);
|
|
395
|
+
}
|
|
396
|
+
if (ok404 && res.status === 404) return null;
|
|
397
|
+
let data: Envelope<T>;
|
|
398
|
+
try {
|
|
399
|
+
data = (await res.json()) as Envelope<T>;
|
|
400
|
+
} catch {
|
|
401
|
+
// A body we can't read isn't an answer: a lookup reading it as "absent" would forget a live resource.
|
|
402
|
+
if (method === "GET") throw new CloudflareError(what, res.status, []);
|
|
403
|
+
throw new UnknownOutcome(what);
|
|
404
|
+
}
|
|
405
|
+
if (!res.ok || data.success !== true) {
|
|
406
|
+
if (res.status >= 500 && method !== "GET") throw new UnknownOutcome(what);
|
|
407
|
+
throw new CloudflareError(
|
|
408
|
+
what,
|
|
409
|
+
res.status,
|
|
410
|
+
(data.errors ?? []).map((e) => e.code),
|
|
411
|
+
(data.errors ?? []).map((e) => e.message ?? "").filter(Boolean),
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
return (data.result ?? null) as T;
|
|
415
|
+
}
|
|
416
|
+
const get = <T>(what: string, path: string, ok404 = false) => call<T>(what, "GET", path, undefined, ok404);
|
|
417
|
+
|
|
418
|
+
async function all<T>(what: string, path: string): Promise<T[]> {
|
|
419
|
+
const out: T[] = [];
|
|
420
|
+
for (let page = 1; page <= 50; page++) {
|
|
421
|
+
const sep = path.includes("?") ? "&" : "?";
|
|
422
|
+
const r = await get<T[]>(what, `${path}${sep}page=${page}&per_page=100`);
|
|
423
|
+
if (!Array.isArray(r)) throw new CloudflareError(`${what}: no list`, 200, []);
|
|
424
|
+
out.push(...r);
|
|
425
|
+
if (r.length < 100) return out;
|
|
426
|
+
}
|
|
427
|
+
throw new CloudflareError(`${what}: more than 50 pages`, 200, []);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function wrangler(
|
|
431
|
+
args: string[],
|
|
432
|
+
extra: Record<string, string> = {},
|
|
433
|
+
input?: string,
|
|
434
|
+
): { out: string; outputs: Row[] } {
|
|
435
|
+
// A fresh directory of our own: Wrangler loads `.env` from its working
|
|
436
|
+
// directory and honours CLOUDFLARE_API_BASE_URL, so neither the store's
|
|
437
|
+
// files nor the caller's environment may reach it (§5.3). The base URL
|
|
438
|
+
// Kedge resolved is passed on explicitly: salve's broker token is valid
|
|
439
|
+
// only there (p0.10 §4).
|
|
440
|
+
const dir = mkdtempSync(join(tmpdir(), "kedge-wrangler-"));
|
|
441
|
+
const envFile = join(dir, "empty.env");
|
|
442
|
+
const output = join(dir, "output.ndjson");
|
|
443
|
+
writeFileSync(envFile, "");
|
|
444
|
+
try {
|
|
445
|
+
const r = spawnSync(process.execPath, [wranglerEntry, ...args, "--env-file", envFile], {
|
|
446
|
+
cwd: dir,
|
|
447
|
+
encoding: "utf8",
|
|
448
|
+
...(input === undefined ? {} : { input }),
|
|
449
|
+
env: {
|
|
450
|
+
PATH: env.PATH ?? "",
|
|
451
|
+
HOME: dir,
|
|
452
|
+
CLOUDFLARE_API_TOKEN: resolved.token(),
|
|
453
|
+
CLOUDFLARE_ACCOUNT_ID: account,
|
|
454
|
+
...(apiBase === API ? {} : { CLOUDFLARE_API_BASE_URL: apiBase }),
|
|
455
|
+
WRANGLER_SEND_METRICS: "false",
|
|
456
|
+
WRANGLER_OUTPUT_FILE_PATH: output,
|
|
457
|
+
CI: "1",
|
|
458
|
+
...extra,
|
|
459
|
+
},
|
|
460
|
+
});
|
|
461
|
+
const outputs = existsSync(output)
|
|
462
|
+
? readFileSync(output, "utf8")
|
|
463
|
+
.split("\n")
|
|
464
|
+
.filter(Boolean)
|
|
465
|
+
.map((l) => JSON.parse(l) as Row)
|
|
466
|
+
: [];
|
|
467
|
+
if (r.status !== 0) {
|
|
468
|
+
const tail = `${r.stdout}\n${r.stderr}`.trim().split("\n").slice(-15).join("\n");
|
|
469
|
+
throw new WranglerFailed(`wrangler ${args[0]} ${args[1] ?? ""}`.trim(), tail, outputs);
|
|
470
|
+
}
|
|
471
|
+
return { out: r.stdout, outputs };
|
|
472
|
+
} finally {
|
|
473
|
+
rmSync(dir, { recursive: true, force: true });
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
const versionIdOf = (outputs: Row[], type: string) => {
|
|
477
|
+
const v = outputs.find((o) => o.type === type)?.version_id;
|
|
478
|
+
if (typeof v !== "string") throw new UnknownOutcome(`wrangler ${type} gave no version id`);
|
|
479
|
+
return v;
|
|
480
|
+
};
|
|
481
|
+
|
|
482
|
+
return {
|
|
483
|
+
account,
|
|
484
|
+
async findD1(name) {
|
|
485
|
+
const r = await all<{ uuid: string; name: string }>(
|
|
486
|
+
"list D1 databases",
|
|
487
|
+
`/d1/database?name=${encodeURIComponent(name)}`,
|
|
488
|
+
);
|
|
489
|
+
return r.find((d) => d.name === name)?.uuid ?? null;
|
|
490
|
+
},
|
|
491
|
+
async createD1(name, jurisdiction) {
|
|
492
|
+
// `jurisdiction` in the create body, the only time it can be set (D1 data location, cited above).
|
|
493
|
+
const r = await call<{ uuid: string }>(`create D1 ${name}`, "POST", "/d1/database", {
|
|
494
|
+
name,
|
|
495
|
+
...(jurisdiction ? { jurisdiction } : {}),
|
|
496
|
+
});
|
|
497
|
+
if (!r?.uuid) throw new UnknownOutcome(`create D1 ${name}`);
|
|
498
|
+
return r.uuid;
|
|
499
|
+
},
|
|
500
|
+
async deleteD1(id) {
|
|
501
|
+
await call(`delete D1 ${id}`, "DELETE", `/d1/database/${id}`, undefined, true);
|
|
502
|
+
},
|
|
503
|
+
async queryD1(id, sql, params = []) {
|
|
504
|
+
const r = await call<{ results?: Row[] }[]>(`query D1 ${id}`, "POST", `/d1/database/${id}/query`, {
|
|
505
|
+
sql,
|
|
506
|
+
params,
|
|
507
|
+
});
|
|
508
|
+
return r?.[0]?.results ?? [];
|
|
509
|
+
},
|
|
510
|
+
async bookmark(id) {
|
|
511
|
+
const r = await get<{ bookmark: string }>(`bookmark of ${id}`, `/d1/database/${id}/time_travel/bookmark`);
|
|
512
|
+
if (!r?.bookmark) throw new CloudflareError(`bookmark of ${id}`, 200, []);
|
|
513
|
+
return r.bookmark;
|
|
514
|
+
},
|
|
515
|
+
async findQueue(name) {
|
|
516
|
+
const r = await all<{ queue_id: string; queue_name: string }>("list queues", "/queues");
|
|
517
|
+
return r.find((q) => q.queue_name === name)?.queue_id ?? null;
|
|
518
|
+
},
|
|
519
|
+
async createQueue(name) {
|
|
520
|
+
const r = await call<{ queue_id: string }>(`create queue ${name}`, "POST", "/queues", { queue_name: name });
|
|
521
|
+
if (!r?.queue_id) throw new UnknownOutcome(`create queue ${name}`);
|
|
522
|
+
return r.queue_id;
|
|
523
|
+
},
|
|
524
|
+
async deleteQueue(id) {
|
|
525
|
+
await call(`delete queue ${id}`, "DELETE", `/queues/${id}`, undefined, true);
|
|
526
|
+
},
|
|
527
|
+
async consumers(queueId) {
|
|
528
|
+
const r = await get<{ consumer_id: string; script?: string; service?: string }[]>(
|
|
529
|
+
`consumers of ${queueId}`,
|
|
530
|
+
`/queues/${queueId}/consumers`,
|
|
531
|
+
);
|
|
532
|
+
return (r ?? []).map((c) => ({ id: c.consumer_id, script: c.script ?? c.service ?? "" }));
|
|
533
|
+
},
|
|
534
|
+
async deleteConsumer(queueId, consumerId) {
|
|
535
|
+
await call(
|
|
536
|
+
`detach consumer ${consumerId}`,
|
|
537
|
+
"DELETE",
|
|
538
|
+
`/queues/${queueId}/consumers/${consumerId}`,
|
|
539
|
+
undefined,
|
|
540
|
+
true,
|
|
541
|
+
);
|
|
542
|
+
},
|
|
543
|
+
async findWorker(name) {
|
|
544
|
+
return (await get(`Worker ${name}`, `/workers/scripts/${encodeURIComponent(name)}/settings`, true)) !== null;
|
|
545
|
+
},
|
|
546
|
+
async deleteWorker(name) {
|
|
547
|
+
await call(`delete Worker ${name}`, "DELETE", `/workers/scripts/${encodeURIComponent(name)}`, undefined, true);
|
|
548
|
+
},
|
|
549
|
+
async versions(name) {
|
|
550
|
+
const r = await get<{
|
|
551
|
+
items?: { id: string; metadata?: { created_on?: string }; annotations?: Record<string, string> }[];
|
|
552
|
+
}>(`versions of ${name}`, `/workers/scripts/${encodeURIComponent(name)}/versions?per_page=100`);
|
|
553
|
+
// A version's message is its `workers/message` annotation, as Wrangler reads it (4.152.0, cli.js:168553).
|
|
554
|
+
return (r?.items ?? []).map((v) => ({
|
|
555
|
+
id: v.id,
|
|
556
|
+
created: v.metadata?.created_on ?? "",
|
|
557
|
+
message: v.annotations?.["workers/message"] ?? null,
|
|
558
|
+
}));
|
|
559
|
+
},
|
|
560
|
+
async deployments(name) {
|
|
561
|
+
const r = await get<{
|
|
562
|
+
deployments?: { id: string; versions: Deployment["versions"]; annotations?: Record<string, string> }[];
|
|
563
|
+
}>(`deployments of ${name}`, `/workers/scripts/${encodeURIComponent(name)}/deployments`);
|
|
564
|
+
return (r?.deployments ?? []).map((d) => ({
|
|
565
|
+
id: d.id,
|
|
566
|
+
versions: d.versions,
|
|
567
|
+
message: d.annotations?.["workers/message"] ?? null,
|
|
568
|
+
}));
|
|
569
|
+
},
|
|
570
|
+
async activate(name, versionId, message) {
|
|
571
|
+
await call(
|
|
572
|
+
`activate ${versionId} on ${name}`,
|
|
573
|
+
"POST",
|
|
574
|
+
`/workers/scripts/${encodeURIComponent(name)}/deployments`,
|
|
575
|
+
{
|
|
576
|
+
strategy: "percentage",
|
|
577
|
+
versions: [{ version_id: versionId, percentage: 100 }],
|
|
578
|
+
// Only `workers/message`: Cloudflare refuses `workers/triggered_by` here (10210, research/26).
|
|
579
|
+
annotations: { "workers/message": message },
|
|
580
|
+
},
|
|
581
|
+
);
|
|
582
|
+
},
|
|
583
|
+
async schedules(name) {
|
|
584
|
+
const r = await get<{ schedules?: { cron: string }[] }>(
|
|
585
|
+
`schedules of ${name}`,
|
|
586
|
+
`/workers/scripts/${encodeURIComponent(name)}/schedules`,
|
|
587
|
+
);
|
|
588
|
+
return (r?.schedules ?? []).map((s) => s.cron).sort();
|
|
589
|
+
},
|
|
590
|
+
async putSchedules(name, crons) {
|
|
591
|
+
await call(
|
|
592
|
+
`schedules of ${name}`,
|
|
593
|
+
"PUT",
|
|
594
|
+
`/workers/scripts/${encodeURIComponent(name)}/schedules`,
|
|
595
|
+
crons.map((cron) => ({ cron })),
|
|
596
|
+
);
|
|
597
|
+
},
|
|
598
|
+
async settings(name) {
|
|
599
|
+
const r = await get<{ logpush?: boolean; observability?: { logs?: { invocation_logs?: boolean } } }>(
|
|
600
|
+
`settings of ${name}`,
|
|
601
|
+
`/workers/scripts/${encodeURIComponent(name)}/script-settings`,
|
|
602
|
+
);
|
|
603
|
+
return { logpush: r?.logpush ?? false, invocationLogs: r?.observability?.logs?.invocation_logs ?? null };
|
|
604
|
+
},
|
|
605
|
+
async patchSettings(name, s) {
|
|
606
|
+
await call(`settings of ${name}`, "PATCH", `/workers/scripts/${encodeURIComponent(name)}/script-settings`, {
|
|
607
|
+
logpush: s.logpush,
|
|
608
|
+
observability: { enabled: true, logs: { enabled: true, invocation_logs: s.invocationLogs ?? false } },
|
|
609
|
+
});
|
|
610
|
+
},
|
|
611
|
+
async secretNames(name) {
|
|
612
|
+
const r = await get<{ name: string }[]>(
|
|
613
|
+
`secrets of ${name}`,
|
|
614
|
+
`/workers/scripts/${encodeURIComponent(name)}/secrets`,
|
|
615
|
+
true,
|
|
616
|
+
);
|
|
617
|
+
return (r ?? []).map((s) => s.name).sort();
|
|
618
|
+
},
|
|
619
|
+
async uploadVersion(input) {
|
|
620
|
+
const { outputs } = wrangler([
|
|
621
|
+
"versions",
|
|
622
|
+
"upload",
|
|
623
|
+
"--no-bundle",
|
|
624
|
+
"-c",
|
|
625
|
+
input.config,
|
|
626
|
+
"--message",
|
|
627
|
+
input.message,
|
|
628
|
+
...(input.secretsFile ? ["--secrets-file", input.secretsFile] : []),
|
|
629
|
+
]);
|
|
630
|
+
return versionIdOf(outputs, "version-upload");
|
|
631
|
+
},
|
|
632
|
+
async deployWithClasses(input) {
|
|
633
|
+
const { outputs } = wrangler([
|
|
634
|
+
"deploy",
|
|
635
|
+
"--no-bundle",
|
|
636
|
+
"-c",
|
|
637
|
+
input.config,
|
|
638
|
+
"--message",
|
|
639
|
+
input.message,
|
|
640
|
+
...(input.secretsFile ? ["--secrets-file", input.secretsFile] : []),
|
|
641
|
+
]);
|
|
642
|
+
return versionIdOf(outputs, "deploy");
|
|
643
|
+
},
|
|
644
|
+
async applyMigrations(input) {
|
|
645
|
+
wrangler(["d1", "migrations", "apply", input.database, "--remote", "-c", input.config], {});
|
|
646
|
+
},
|
|
647
|
+
async executeD1File(input) {
|
|
648
|
+
const dir = mkdtempSync(join(tmpdir(), "kedge-sql-"));
|
|
649
|
+
try {
|
|
650
|
+
const file = join(dir, "batch.sql");
|
|
651
|
+
const config = join(dir, "wrangler.json");
|
|
652
|
+
writeFileSync(file, input.sql, { mode: 0o600 });
|
|
653
|
+
writeFileSync(
|
|
654
|
+
config,
|
|
655
|
+
JSON.stringify({
|
|
656
|
+
name: "kedge-cli",
|
|
657
|
+
d1_databases: [{ binding: "DB", database_name: input.database, database_id: input.databaseId }],
|
|
658
|
+
}),
|
|
659
|
+
);
|
|
660
|
+
wrangler(["d1", "execute", "DB", "--remote", "--file", file, "--yes", "-c", config]);
|
|
661
|
+
} finally {
|
|
662
|
+
rmSync(dir, { recursive: true, force: true });
|
|
663
|
+
}
|
|
664
|
+
},
|
|
665
|
+
async workersSubdomain() {
|
|
666
|
+
let r: { subdomain: string } | null;
|
|
667
|
+
try {
|
|
668
|
+
r = await get<{ subdomain: string }>("workers.dev subdomain", "/workers/subdomain");
|
|
669
|
+
} catch (e) {
|
|
670
|
+
// 10007 is Cloudflare's "no subdomain", as Wrangler reads it (wrangler-dist/cli.js:164937, 4.152.0).
|
|
671
|
+
if (e instanceof CloudflareError && e.codes.includes(10007)) throw noSubdomain(account);
|
|
672
|
+
throw e;
|
|
673
|
+
}
|
|
674
|
+
if (!r?.subdomain) throw noSubdomain(account);
|
|
675
|
+
return r.subdomain;
|
|
676
|
+
},
|
|
677
|
+
async d1Jurisdiction(id) {
|
|
678
|
+
// The database's own record (https://developers.cloudflare.com/api/resources/d1/subresources/database/methods/get/);
|
|
679
|
+
// absent is none.
|
|
680
|
+
const r = await get<{ jurisdiction?: string | null }>(`D1 ${id}`, `/d1/database/${id}`);
|
|
681
|
+
const j = r?.jurisdiction ?? "none";
|
|
682
|
+
if (!isJurisdictionChoice(j)) throw new CloudflareError(`D1 ${id} answered jurisdiction "${j}"`, 200, []);
|
|
683
|
+
return j;
|
|
684
|
+
},
|
|
685
|
+
async readable() {
|
|
686
|
+
const out: Record<ResourceKind, number | null> = { Workers: null, D1: null, Queues: null };
|
|
687
|
+
const paths: [ResourceKind, string][] = [
|
|
688
|
+
["Workers", "/workers/scripts"],
|
|
689
|
+
["D1", "/d1/database"],
|
|
690
|
+
["Queues", "/queues"],
|
|
691
|
+
];
|
|
692
|
+
for (const [kind, path] of paths) {
|
|
693
|
+
try {
|
|
694
|
+
await get(`list ${kind}`, path);
|
|
695
|
+
} catch (e) {
|
|
696
|
+
if (e instanceof CloudflareError) out[kind] = e.status;
|
|
697
|
+
else if (e instanceof UnknownOutcome) out[kind] = 0;
|
|
698
|
+
else throw e;
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
return out;
|
|
702
|
+
},
|
|
703
|
+
async logpushJobs() {
|
|
704
|
+
try {
|
|
705
|
+
const r = await get<{ dataset: string; enabled: boolean; output_options?: { field_names?: string[] } }[]>(
|
|
706
|
+
"Logpush jobs",
|
|
707
|
+
"/logpush/jobs",
|
|
708
|
+
);
|
|
709
|
+
return (r ?? []).map((j) => ({
|
|
710
|
+
dataset: j.dataset,
|
|
711
|
+
enabled: j.enabled,
|
|
712
|
+
fields: j.output_options?.field_names ?? null,
|
|
713
|
+
}));
|
|
714
|
+
} catch (e) {
|
|
715
|
+
if (e instanceof CloudflareError && (e.status === 403 || e.status === 401)) return null;
|
|
716
|
+
throw e;
|
|
717
|
+
}
|
|
718
|
+
},
|
|
719
|
+
async versionSecret(worker, name, value, message) {
|
|
720
|
+
const { out } =
|
|
721
|
+
value === null
|
|
722
|
+
? wrangler(["versions", "secret", "delete", name, "--name", worker, "--message", message])
|
|
723
|
+
: wrangler(["versions", "secret", "put", name, "--name", worker, "--message", message], {}, value);
|
|
724
|
+
const id = /Created version ([0-9a-f-]{36})/.exec(out)?.[1];
|
|
725
|
+
if (!id) throw new UnknownOutcome(`versions secret on ${worker} gave no version id`);
|
|
726
|
+
return id;
|
|
727
|
+
},
|
|
728
|
+
};
|
|
729
|
+
}
|