@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/cf.ts
CHANGED
|
@@ -10,8 +10,10 @@ import { spawnSync } from "node:child_process";
|
|
|
10
10
|
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
11
11
|
import { createRequire } from "node:module";
|
|
12
12
|
import { tmpdir } from "node:os";
|
|
13
|
-
import { dirname, join } from "node:path";
|
|
13
|
+
import { basename, dirname, join } from "node:path";
|
|
14
14
|
import { fileURLToPath } from "node:url";
|
|
15
|
+
import { parseEnv } from "node:util";
|
|
16
|
+
import { jsoncToJson } from "./worker-config";
|
|
15
17
|
|
|
16
18
|
export type Row = Record<string, unknown>;
|
|
17
19
|
|
|
@@ -28,8 +30,8 @@ export interface WorkerSettings {
|
|
|
28
30
|
|
|
29
31
|
/** A call whose answer never came: the caller records it as an unknown outcome (§3.1). */
|
|
30
32
|
export class UnknownOutcome extends Error {
|
|
31
|
-
constructor(what: string) {
|
|
32
|
-
super(`${what}: no answer from
|
|
33
|
+
constructor(what: string, from = "Cloudflare") {
|
|
34
|
+
super(`${what}: no answer from ${from}; its outcome is unknown`);
|
|
33
35
|
this.name = "UnknownOutcome";
|
|
34
36
|
}
|
|
35
37
|
}
|
|
@@ -70,7 +72,10 @@ export class CloudflareError extends Error {
|
|
|
70
72
|
export interface Cloudflare {
|
|
71
73
|
readonly account: string;
|
|
72
74
|
findD1(name: string): Promise<string | null>;
|
|
73
|
-
|
|
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>;
|
|
74
79
|
deleteD1(id: string): Promise<void>;
|
|
75
80
|
queryD1(id: string, sql: string, params?: readonly unknown[]): Promise<Row[]>;
|
|
76
81
|
bookmark(id: string): Promise<string>;
|
|
@@ -100,14 +105,36 @@ export interface Cloudflare {
|
|
|
100
105
|
applyMigrations(input: { config: string; database: string }): Promise<void>;
|
|
101
106
|
/** One SQL file as one transaction (`wrangler d1 execute --file`): the CLI's audited batches (p0.5 §3.4). */
|
|
102
107
|
executeD1File(input: { database: string; databaseId: string; sql: string }): Promise<void>;
|
|
103
|
-
/** The account's workers.dev subdomain. */
|
|
108
|
+
/** The account's workers.dev subdomain; none is the person's choice to make (`NeedsPerson`). */
|
|
104
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>>;
|
|
105
112
|
/** The account's Logpush jobs, or null when the token can't read them. */
|
|
106
113
|
logpushJobs(): Promise<{ dataset: string; enabled: boolean; fields: string[] | null }[] | null>;
|
|
107
114
|
/** `wrangler versions secret put|delete`: a new version with one secret changed, not deployed. */
|
|
108
115
|
versionSecret(worker: string, name: string, value: string | null, message: string): Promise<string>;
|
|
109
116
|
}
|
|
110
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
|
+
|
|
111
138
|
export interface UploadInput {
|
|
112
139
|
/** A generated Wrangler config, every path in it absolute. */
|
|
113
140
|
config: string;
|
|
@@ -117,6 +144,196 @@ export interface UploadInput {
|
|
|
117
144
|
|
|
118
145
|
const API = "https://api.cloudflare.com/client/v4";
|
|
119
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
|
+
|
|
120
337
|
type Envelope<T> = {
|
|
121
338
|
success?: boolean;
|
|
122
339
|
result?: T;
|
|
@@ -130,24 +347,45 @@ export function wranglerBin(): string {
|
|
|
130
347
|
return join(dirname(require.resolve("wrangler/package.json")), "bin", "wrangler.js");
|
|
131
348
|
}
|
|
132
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
|
+
|
|
133
360
|
export function restCloudflare(
|
|
134
361
|
env: NodeJS.ProcessEnv,
|
|
135
362
|
fetchImpl: typeof fetch = fetch,
|
|
136
363
|
/** The Wrangler entry to run; tests give a stand-in. */
|
|
137
364
|
wranglerEntry: string = wranglerBin(),
|
|
365
|
+
/** Resolved credentials; without them, the environment's two variables. */
|
|
366
|
+
auth?: CloudflareAuth,
|
|
138
367
|
): Cloudflare {
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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}`;
|
|
143
379
|
|
|
144
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();
|
|
145
383
|
let res: Response;
|
|
146
384
|
try {
|
|
147
385
|
res = await fetchImpl(`${base}${path}`, {
|
|
148
386
|
method,
|
|
149
387
|
headers: {
|
|
150
|
-
authorization: `Bearer ${
|
|
388
|
+
authorization: `Bearer ${bearer}`,
|
|
151
389
|
...(body === undefined ? {} : { "content-type": "application/json" }),
|
|
152
390
|
},
|
|
153
391
|
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
@@ -196,7 +434,9 @@ export function restCloudflare(
|
|
|
196
434
|
): { out: string; outputs: Row[] } {
|
|
197
435
|
// A fresh directory of our own: Wrangler loads `.env` from its working
|
|
198
436
|
// directory and honours CLOUDFLARE_API_BASE_URL, so neither the store's
|
|
199
|
-
// files nor the caller's environment may reach it (§5.3).
|
|
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).
|
|
200
440
|
const dir = mkdtempSync(join(tmpdir(), "kedge-wrangler-"));
|
|
201
441
|
const envFile = join(dir, "empty.env");
|
|
202
442
|
const output = join(dir, "output.ndjson");
|
|
@@ -209,8 +449,9 @@ export function restCloudflare(
|
|
|
209
449
|
env: {
|
|
210
450
|
PATH: env.PATH ?? "",
|
|
211
451
|
HOME: dir,
|
|
212
|
-
CLOUDFLARE_API_TOKEN: token
|
|
213
|
-
CLOUDFLARE_ACCOUNT_ID: account
|
|
452
|
+
CLOUDFLARE_API_TOKEN: resolved.token(),
|
|
453
|
+
CLOUDFLARE_ACCOUNT_ID: account,
|
|
454
|
+
...(apiBase === API ? {} : { CLOUDFLARE_API_BASE_URL: apiBase }),
|
|
214
455
|
WRANGLER_SEND_METRICS: "false",
|
|
215
456
|
WRANGLER_OUTPUT_FILE_PATH: output,
|
|
216
457
|
CI: "1",
|
|
@@ -247,8 +488,12 @@ export function restCloudflare(
|
|
|
247
488
|
);
|
|
248
489
|
return r.find((d) => d.name === name)?.uuid ?? null;
|
|
249
490
|
},
|
|
250
|
-
async createD1(name) {
|
|
251
|
-
|
|
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
|
+
});
|
|
252
497
|
if (!r?.uuid) throw new UnknownOutcome(`create D1 ${name}`);
|
|
253
498
|
return r.uuid;
|
|
254
499
|
},
|
|
@@ -418,10 +663,43 @@ export function restCloudflare(
|
|
|
418
663
|
}
|
|
419
664
|
},
|
|
420
665
|
async workersSubdomain() {
|
|
421
|
-
|
|
422
|
-
|
|
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);
|
|
423
675
|
return r.subdomain;
|
|
424
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
|
+
},
|
|
425
703
|
async logpushJobs() {
|
|
426
704
|
try {
|
|
427
705
|
const r = await get<{ dataset: string; enabled: boolean; output_options?: { field_names?: string[] } }[]>(
|
package/src/check.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import { spawnSync } from "node:child_process";
|
|
6
6
|
import { existsSync } from "node:fs";
|
|
7
7
|
import { join } from "node:path";
|
|
8
|
+
import { childFailed, childStdio } from "./output";
|
|
8
9
|
|
|
9
10
|
export interface CheckInput {
|
|
10
11
|
cwd: string;
|
|
@@ -21,7 +22,9 @@ const runBin = (bin: string, args: string[], cwd: string): number => {
|
|
|
21
22
|
console.error(`${bin} isn't installed in this store: run pnpm install`);
|
|
22
23
|
return 1;
|
|
23
24
|
}
|
|
24
|
-
|
|
25
|
+
const r = spawnSync(path, args, { cwd, stdio: childStdio(), maxBuffer: 1 << 26 });
|
|
26
|
+
childFailed(r);
|
|
27
|
+
return r.status ?? 1;
|
|
25
28
|
};
|
|
26
29
|
|
|
27
30
|
export async function check(input: CheckInput): Promise<number> {
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// A CLI session for one command (docs/proposals/p0.10-agent-path.md §6.2):
|
|
2
|
+
// whoever holds the store's Cloudflare credentials writes the row, one audited
|
|
3
|
+
// `system:cli` batch, to the target's own database and no other, and the
|
|
4
|
+
// command sends the token to that target alone. The token lives in this
|
|
5
|
+
// process's memory only: never printed, logged or saved. The row is deleted
|
|
6
|
+
// when the command ends; one a crash leaves expires within ten minutes.
|
|
7
|
+
import { type CliBatch, cliSessionEndSql, cliSessionSql } from "@kedge/db";
|
|
8
|
+
import { type Cloudflare, UnknownOutcome } from "./cf";
|
|
9
|
+
import { DeployRefused } from "./deploy";
|
|
10
|
+
import type { StoreTarget } from "./target";
|
|
11
|
+
|
|
12
|
+
/** What an older Worker's or database's refusal of a CLI session means for the person running it. */
|
|
13
|
+
export const OLDER_KEDGE = "this store runs a Kedge older than this CLI, without CLI sessions: deploy first";
|
|
14
|
+
|
|
15
|
+
export interface SessionDeps {
|
|
16
|
+
cloudflare: () => Promise<Cloudflare>;
|
|
17
|
+
/** `wrangler d1 execute --local` in the store's directory: `kedge dev`'s database. */
|
|
18
|
+
executeLocal: (b: CliBatch) => number;
|
|
19
|
+
now?: () => number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function write(target: StoreTarget, b: CliBatch, d: SessionDeps): Promise<void> {
|
|
23
|
+
if (target.kind === "local") {
|
|
24
|
+
if (d.executeLocal(b) !== 0) throw new DeployRefused("writing the CLI session to kedge dev's database failed");
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
const cf = await d.cloudflare();
|
|
28
|
+
try {
|
|
29
|
+
await cf.executeD1File({ database: target.database.name, databaseId: target.database.id as string, sql: b.sql });
|
|
30
|
+
} catch (e) {
|
|
31
|
+
// A database from before P0.10 has no table to hold it.
|
|
32
|
+
if (e instanceof UnknownOutcome && /no such table: cli_session/.test(e.message))
|
|
33
|
+
throw new DeployRefused(OLDER_KEDGE);
|
|
34
|
+
throw e;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Runs `work` with a session holding `grants` on `target`, ending it after, whatever happened. */
|
|
39
|
+
export async function withCliSession<T>(
|
|
40
|
+
target: StoreTarget,
|
|
41
|
+
grants: readonly string[],
|
|
42
|
+
d: SessionDeps,
|
|
43
|
+
work: (auth: Record<string, string>) => Promise<T>,
|
|
44
|
+
): Promise<T> {
|
|
45
|
+
const now = d.now ?? Date.now;
|
|
46
|
+
const s = await cliSessionSql(grants, now());
|
|
47
|
+
await write(target, s, d);
|
|
48
|
+
try {
|
|
49
|
+
return await work({ authorization: `Bearer ${s.token}` });
|
|
50
|
+
} finally {
|
|
51
|
+
// Ending is housekeeping: a row left behind is useless without the token, and expires anyway.
|
|
52
|
+
await write(target, cliSessionEndSql(s.id, now()), d).catch(() => undefined);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Whether a request's credential was a CLI session, so a 401 can be read as an older Worker. */
|
|
57
|
+
export const isCliSession = (auth: Record<string, string>) => auth.authorization?.startsWith("Bearer kcs_") === true;
|