@kedge/cli 0.10.0 → 0.11.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/README.md +28 -0
- package/kedge-manifest.json +68 -48
- package/package.json +7 -7
- package/src/cf.ts +216 -50
- package/src/check.ts +4 -2
- package/src/cli-session.ts +43 -4
- package/src/commands.ts +89 -25
- package/src/d1-batch.ts +3 -1
- package/src/deploy.ts +48 -13
- package/src/doctor.ts +34 -9
- package/src/keys-save.ts +49 -4
- package/src/local-state.ts +25 -13
- package/src/main.ts +30 -7
- package/src/ops.ts +26 -7
- package/src/output.ts +18 -9
- package/src/preview.ts +8 -1
- package/src/smoke.ts +29 -21
- package/src/store-api.ts +8 -1
- package/src/store.ts +27 -5
- package/src/target.ts +39 -11
package/src/main.ts
CHANGED
|
@@ -37,7 +37,7 @@ import {
|
|
|
37
37
|
} from "./commands";
|
|
38
38
|
import { ComposeError, compose, type Lock } from "./compose";
|
|
39
39
|
import { type Executor, wranglerExecute } from "./d1-batch";
|
|
40
|
-
import { credentialsChecks, doctor, doctorRemote, formatReport } from "./doctor";
|
|
40
|
+
import { credentialsChecks, doctor, doctorRemote, formatReport, readWrangler } from "./doctor";
|
|
41
41
|
import { invariantsMarkdown, invariantsOf } from "./invariants";
|
|
42
42
|
import { installedPackages } from "./manifest";
|
|
43
43
|
import { emit, withJsonOutput } from "./output";
|
|
@@ -118,10 +118,18 @@ async function inviteOrigin(
|
|
|
118
118
|
): Promise<string | null> {
|
|
119
119
|
const url = flag(args, "--url");
|
|
120
120
|
if (url) return new URL(url).origin;
|
|
121
|
-
|
|
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
|
+
}
|
|
122
130
|
try {
|
|
123
|
-
|
|
124
|
-
return
|
|
131
|
+
// Only where it's served: elsewhere (salve hosting) the link needs --url, the address its host gives.
|
|
132
|
+
return await (cf ?? (await cloudflareFor(env, cwd))).workersDevUrl(storeNames(cwd).names.worker);
|
|
125
133
|
} catch {
|
|
126
134
|
return null;
|
|
127
135
|
}
|
|
@@ -360,7 +368,12 @@ async function run(argv: string[], cwd: string, deps: RunDeps & { json: boolean
|
|
|
360
368
|
if (auth) {
|
|
361
369
|
const cf = restCloudflare(d.env, fetch, wranglerBin(), auth);
|
|
362
370
|
const lock = JSON.parse(readFileSync(join(cwd, "kedge.lock.json"), "utf8")) as Lock;
|
|
363
|
-
const creds = await credentialsChecks({
|
|
371
|
+
const creds = await credentialsChecks({
|
|
372
|
+
cf,
|
|
373
|
+
source: auth.source,
|
|
374
|
+
base: cloudflareBase(d.env),
|
|
375
|
+
workersDev: readWrangler(cwd)?.config.workers_dev !== false,
|
|
376
|
+
});
|
|
364
377
|
results.push(...creds);
|
|
365
378
|
// Credentials that can't read the account would make every later check throw, not report (codex code r7).
|
|
366
379
|
if (creds.some((c) => c.status === "fail"))
|
|
@@ -372,8 +385,17 @@ async function run(argv: string[], cwd: string, deps: RunDeps & { json: boolean
|
|
|
372
385
|
else results.push(...(await doctorRemote({ cwd, cf, lock, logpushJobs: () => cf.logpushJobs() })));
|
|
373
386
|
}
|
|
374
387
|
}
|
|
375
|
-
console.log(flags.includes("--json") ? JSON.stringify({ checks: results }, null, 2) : formatReport(results));
|
|
376
388
|
const handoff = results.find((r) => r.handoff)?.handoff;
|
|
389
|
+
// The same envelope as every --json command (p0.10.1 §1, 7): ok, the checks, and a handoff when there is one.
|
|
390
|
+
console.log(
|
|
391
|
+
flags.includes("--json")
|
|
392
|
+
? JSON.stringify({
|
|
393
|
+
ok: !results.some((r) => r.status === "fail"),
|
|
394
|
+
checks: results,
|
|
395
|
+
...(handoff ? { handoff } : {}),
|
|
396
|
+
})
|
|
397
|
+
: formatReport(results),
|
|
398
|
+
);
|
|
377
399
|
if (handoff) {
|
|
378
400
|
console.error(`for you: ${handoff}`);
|
|
379
401
|
return 3;
|
|
@@ -463,7 +485,8 @@ export async function main(argv: string[], cwd: string, deps: RunDeps = {}): Pro
|
|
|
463
485
|
const f = failureOf(e);
|
|
464
486
|
if (!f) throw e;
|
|
465
487
|
console.error(f.line);
|
|
466
|
-
|
|
488
|
+
// doctor keeps its own --json report, and a failure past it is still one object (p0.10.1, codex code r20).
|
|
489
|
+
if (json || (command === "doctor" && flags.includes("--json"))) (deps.emit ?? emit)(f.result);
|
|
467
490
|
return f.code;
|
|
468
491
|
}
|
|
469
492
|
});
|
package/src/ops.ts
CHANGED
|
@@ -53,6 +53,15 @@ export const OPS_SCHEMA = [
|
|
|
53
53
|
classes TEXT NOT NULL DEFAULT '[]', destroyed_at INTEGER NOT NULL)`,
|
|
54
54
|
];
|
|
55
55
|
|
|
56
|
+
/** Every table and index OPS_SCHEMA makes, by name. */
|
|
57
|
+
const OPS_OBJECTS = OPS_SCHEMA.map((s) => (/IF NOT EXISTS (\w+)/.exec(s) as RegExpExecArray)[1] as string);
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Rows per INSERT into `applied`: three bound parameters each, under D1's 100
|
|
61
|
+
* per query (https://developers.cloudflare.com/d1/platform/limits/).
|
|
62
|
+
*/
|
|
63
|
+
const APPLIED_ROWS = 33;
|
|
64
|
+
|
|
56
65
|
export class OperationHeld extends Error {
|
|
57
66
|
constructor(readonly held: OperationRow) {
|
|
58
67
|
super(
|
|
@@ -128,7 +137,13 @@ export class OpsStore {
|
|
|
128
137
|
) {}
|
|
129
138
|
|
|
130
139
|
async init(store: string): Promise<void> {
|
|
131
|
-
|
|
140
|
+
// Every object already there: the statements would change nothing, and a platform's rate limit counts
|
|
141
|
+
// each one (salve: 120 a minute per app, p0.11 row 16). The schema only ever gains objects, never columns.
|
|
142
|
+
const present = await this.q(
|
|
143
|
+
`SELECT name FROM sqlite_master WHERE name IN (${OPS_OBJECTS.map(() => "?").join(", ")})`,
|
|
144
|
+
OPS_OBJECTS,
|
|
145
|
+
);
|
|
146
|
+
if (present.length !== OPS_OBJECTS.length) for (const s of OPS_SCHEMA) await this.q(s);
|
|
132
147
|
await this.q("INSERT INTO kedge_ops_meta (id, store, created_at) VALUES (1, ?, ?) ON CONFLICT (id) DO NOTHING", [
|
|
133
148
|
store,
|
|
134
149
|
this.now(),
|
|
@@ -344,12 +359,16 @@ export class OpsStore {
|
|
|
344
359
|
return new Map((await this.q("SELECT file, sha256 FROM applied")).map((r) => [String(r.file), String(r.sha256)]));
|
|
345
360
|
}
|
|
346
361
|
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
362
|
+
/** Each applied file's hash, the first recorded kept: one statement per 33 files, not one per file (p0.11 row 16). */
|
|
363
|
+
async recordApplied(files: readonly (readonly [file: string, sha256: string])[]): Promise<void> {
|
|
364
|
+
const t = this.now();
|
|
365
|
+
for (let i = 0; i < files.length; i += APPLIED_ROWS) {
|
|
366
|
+
const chunk = files.slice(i, i + APPLIED_ROWS);
|
|
367
|
+
await this.q(
|
|
368
|
+
`INSERT INTO applied (file, sha256, applied_at) VALUES ${chunk.map(() => "(?, ?, ?)").join(", ")} ON CONFLICT (file) DO NOTHING`,
|
|
369
|
+
chunk.flatMap(([f, h]) => [f, h, t]),
|
|
370
|
+
);
|
|
371
|
+
}
|
|
353
372
|
}
|
|
354
373
|
|
|
355
374
|
// ---- the store's own resources (§6.1) ------------------------------------------------------------------
|
package/src/output.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
// `--json` (p0.10 §5): stdout carries one object when the command ends, and
|
|
2
|
-
// nothing else, so an agent parses it whole.
|
|
3
|
-
//
|
|
4
|
-
// child the CLI starts (Wrangler, the store's
|
|
5
|
-
//
|
|
6
|
-
|
|
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";
|
|
7
9
|
|
|
8
10
|
let reserved = false;
|
|
9
11
|
|
|
@@ -13,8 +15,8 @@ export async function withJsonOutput<T>(on: boolean, work: () => Promise<T>): Pr
|
|
|
13
15
|
const log = console.log;
|
|
14
16
|
const info = console.info;
|
|
15
17
|
reserved = true;
|
|
16
|
-
console.log =
|
|
17
|
-
console.info =
|
|
18
|
+
console.log = () => {};
|
|
19
|
+
console.info = () => {};
|
|
18
20
|
try {
|
|
19
21
|
return await work();
|
|
20
22
|
} finally {
|
|
@@ -29,7 +31,14 @@ export function emit(result: Record<string, unknown>): void {
|
|
|
29
31
|
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
30
32
|
}
|
|
31
33
|
|
|
32
|
-
/** A child's stdio: inherited,
|
|
34
|
+
/** A child's stdio: inherited, or under `--json` captured, for `childFailed` to show if it fails. */
|
|
33
35
|
export function childStdio(): StdioOptions {
|
|
34
|
-
return reserved ? ["
|
|
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`);
|
|
35
44
|
}
|
package/src/preview.ts
CHANGED
|
@@ -33,7 +33,7 @@ import type { Lock } from "./compose";
|
|
|
33
33
|
import { CORE_SECRETS, DeployRefused, openOps, stageAssets } from "./deploy";
|
|
34
34
|
import { assetsShape, declaredClasses, readWrangler } from "./doctor";
|
|
35
35
|
import { LostOwnership, OperationHeld, type OpsStore } from "./ops";
|
|
36
|
-
import { childStdio } from "./output";
|
|
36
|
+
import { childFailed, childStdio } from "./output";
|
|
37
37
|
import { hashTree, type StoreNames, sha256, type WranglerJson } from "./store";
|
|
38
38
|
import { readTar, type TarEntry, TarRefused, writeTar } from "./tar";
|
|
39
39
|
|
|
@@ -200,6 +200,7 @@ export function previewConfig(source: WranglerJson, p: PreviewWorker): Record<st
|
|
|
200
200
|
workers_dev: true,
|
|
201
201
|
observability: {
|
|
202
202
|
...observability,
|
|
203
|
+
redact_query_string: true,
|
|
203
204
|
logs: { ...((observability.logs as object | undefined) ?? {}), invocation_logs: false },
|
|
204
205
|
},
|
|
205
206
|
vars: { ...vars, ADMIN_ORIGINS: [...origins].join(","), KEDGE_ENVIRONMENT: "preview" },
|
|
@@ -720,6 +721,10 @@ class PreviewRun {
|
|
|
720
721
|
// recreates under a new nonce, where a late landing can't replace it, and the sweep removes
|
|
721
722
|
// what lands on the old name (codex code r4).
|
|
722
723
|
if (e instanceof UnknownOutcome) await this.write("state = 'destroying', lost_create = 1", []);
|
|
724
|
+
// A host that serves one Worker per store refuses this upload (salve hosting), after the preview's
|
|
725
|
+
// database and queue exist: the way out is named, never inferred from the platform's words (p0.11 row 8).
|
|
726
|
+
if (e instanceof Error)
|
|
727
|
+
e.message += `\nThe preview's database and queue exist: remove what it made with kedge preview destroy --name ${this.name}. A host that serves one Worker per store (salve hosting) has no previews: test on kedge dev instead.`;
|
|
723
728
|
throw e;
|
|
724
729
|
} finally {
|
|
725
730
|
if (secretsFile) rmSync(secretsFile, { force: true });
|
|
@@ -1018,7 +1023,9 @@ export function runScenarios(cwd: string, credentials: StoreCredentials, env: No
|
|
|
1018
1023
|
cwd,
|
|
1019
1024
|
stdio: childStdio(),
|
|
1020
1025
|
env: { ...env, KEDGE_PREVIEW_CREDENTIALS: JSON.stringify(credentials) },
|
|
1026
|
+
maxBuffer: 1 << 26,
|
|
1021
1027
|
});
|
|
1028
|
+
childFailed(r);
|
|
1022
1029
|
return r.status ?? 1;
|
|
1023
1030
|
} finally {
|
|
1024
1031
|
rmSync(tmp, { recursive: true, force: true });
|
package/src/smoke.ts
CHANGED
|
@@ -47,11 +47,25 @@ export interface SmokeResult {
|
|
|
47
47
|
status: string;
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
const
|
|
51
|
-
db.exec(`CREATE TABLE IF NOT EXISTS smoke_run (
|
|
52
|
-
name TEXT PRIMARY KEY, store TEXT NOT NULL, storefront_id TEXT NOT NULL,
|
|
50
|
+
const COLUMNS = `store TEXT NOT NULL, name TEXT NOT NULL, storefront_id TEXT NOT NULL,
|
|
53
51
|
cart_id TEXT, cart_token TEXT, completion TEXT, order_id TEXT, order_token TEXT, number TEXT, abandoned TEXT,
|
|
54
|
-
created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL
|
|
52
|
+
created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL`;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Runs keyed by store and name (p0.10.1 §5): `first` on kedge dev and `first` on
|
|
56
|
+
* Cloudflare are two runs. A 0.10.0 checkout's `smoke_run`, keyed by name alone,
|
|
57
|
+
* is copied in whole the first time, every recorded moment with it, so a run it
|
|
58
|
+
* stopped resumes; the old table is left as it was.
|
|
59
|
+
*/
|
|
60
|
+
function table(db: DatabaseSync) {
|
|
61
|
+
db.exec(`CREATE TABLE IF NOT EXISTS smoke_runs (${COLUMNS}, PRIMARY KEY (store, name))`);
|
|
62
|
+
const legacy = db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'smoke_run'").get();
|
|
63
|
+
if (legacy)
|
|
64
|
+
db.exec(`INSERT OR IGNORE INTO smoke_runs (store, name, storefront_id, cart_id, cart_token, completion, order_id,
|
|
65
|
+
order_token, number, abandoned, created_at, updated_at)
|
|
66
|
+
SELECT store, name, storefront_id, cart_id, cart_token, completion, order_id, order_token, number, abandoned,
|
|
67
|
+
created_at, updated_at FROM smoke_run`);
|
|
68
|
+
}
|
|
55
69
|
|
|
56
70
|
/** The smoke run `name` on `target`: resumed, or answered again once it has its number. */
|
|
57
71
|
export async function smoke(target: StoreTarget, name: string, d: SmokeDeps): Promise<SmokeResult> {
|
|
@@ -69,20 +83,18 @@ export async function smoke(target: StoreTarget, name: string, d: SmokeDeps): Pr
|
|
|
69
83
|
async function run(db: DatabaseSync, target: StoreTarget, name: string, d: SmokeDeps): Promise<SmokeResult> {
|
|
70
84
|
const now = d.now ?? Date.now;
|
|
71
85
|
const store = identityOf(target);
|
|
72
|
-
const get = () =>
|
|
86
|
+
const get = () =>
|
|
87
|
+
db.prepare("SELECT * FROM smoke_runs WHERE store = ? AND name = ?").get(store, name) as unknown as Run | undefined;
|
|
73
88
|
const set = (a: Partial<Run>) => {
|
|
74
89
|
const cols = Object.keys(a);
|
|
75
|
-
db.prepare(
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
name,
|
|
79
|
-
);
|
|
90
|
+
db.prepare(
|
|
91
|
+
`UPDATE smoke_runs SET ${cols.map((c) => `${c} = ?`).join(", ")}, updated_at = ? WHERE store = ? AND name = ?`,
|
|
92
|
+
).run(...cols.map((c) => (a as Record<string, string | null>)[c] ?? null), now(), store, name);
|
|
80
93
|
};
|
|
81
94
|
|
|
82
95
|
let r = get();
|
|
83
96
|
// A finished run answers its order again, sending nothing: a retried command can't place a second one.
|
|
84
97
|
if (r?.number && r.order_id) {
|
|
85
|
-
if (r.store !== store) throw new DeployRefused(`smoke run ${name} belongs to another store (${r.store})`);
|
|
86
98
|
return { run: name, order_id: r.order_id, number: r.number, status: "open" };
|
|
87
99
|
}
|
|
88
100
|
if (r?.abandoned)
|
|
@@ -106,19 +118,15 @@ async function run(db: DatabaseSync, target: StoreTarget, name: string, d: Smoke
|
|
|
106
118
|
|
|
107
119
|
if (!r) {
|
|
108
120
|
const t = now();
|
|
109
|
-
db.prepare(
|
|
110
|
-
name,
|
|
111
|
-
|
|
112
|
-
storefront,
|
|
113
|
-
t,
|
|
114
|
-
t,
|
|
115
|
-
);
|
|
121
|
+
db.prepare(
|
|
122
|
+
"INSERT INTO smoke_runs (store, name, storefront_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
|
|
123
|
+
).run(store, name, storefront, t, t);
|
|
116
124
|
r = get() as Run;
|
|
117
125
|
}
|
|
118
|
-
// A cart admits only its own storefront's key: a record for another
|
|
119
|
-
if (r.
|
|
126
|
+
// A cart admits only its own storefront's key: a record for another storefront is refused, not reused.
|
|
127
|
+
if (r.storefront_id !== storefront)
|
|
120
128
|
throw new DeployRefused(
|
|
121
|
-
`smoke run ${name} was made for
|
|
129
|
+
`smoke run ${name} was made for storefront ${r.storefront_id}: start another with --run <new name>`,
|
|
122
130
|
);
|
|
123
131
|
|
|
124
132
|
const shop = { "x-kedge-key": key };
|
package/src/store-api.ts
CHANGED
|
@@ -18,11 +18,18 @@ export async function storeRequest(
|
|
|
18
18
|
const write = (init.method ?? "GET") !== "GET";
|
|
19
19
|
let r: Response;
|
|
20
20
|
try {
|
|
21
|
-
|
|
21
|
+
// No redirect is followed (p0.11 §3): a session is bound to the origin it was sent to, and the
|
|
22
|
+
// person names the store's URL, not something in front of it that sends the request elsewhere.
|
|
23
|
+
r = await f(url, { ...init, redirect: "manual" });
|
|
22
24
|
} catch {
|
|
23
25
|
if (write) throw new UnknownOutcome(what, "the store");
|
|
24
26
|
throw new DeployRefused(`${what}: the store didn't answer`);
|
|
25
27
|
}
|
|
28
|
+
// Kedge's API never redirects, so this answer came from in front of the Worker, which ran nothing.
|
|
29
|
+
if (r.status >= 300 && r.status < 400)
|
|
30
|
+
throw new DeployRefused(
|
|
31
|
+
`${what} answered a redirect${r.headers.get("location") ? ` to ${r.headers.get("location")}` : ""}: the CLI follows none, so give the store's own URL`,
|
|
32
|
+
);
|
|
26
33
|
let body: Record<string, unknown>;
|
|
27
34
|
try {
|
|
28
35
|
body = (await r.json()) as Record<string, unknown>;
|
package/src/store.ts
CHANGED
|
@@ -13,6 +13,17 @@ import { byCodePoint } from "./order";
|
|
|
13
13
|
/** The starter's id until a first deploy writes the real one (§6.1). */
|
|
14
14
|
export const PLACEHOLDER_ID = "00000000-0000-0000-0000-000000000000";
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* wrangler.jsonc with `id` written over the placeholder `database_id`, and
|
|
18
|
+
* only there: `preview_database_id` keeps the zeros, because Wrangler keys the
|
|
19
|
+
* local database by it, and rewriting it would open an empty local store after
|
|
20
|
+
* the first deploy (p0.11 row 17). The opening quote keeps the pattern off
|
|
21
|
+
* `"preview_database_id"`, so the keys' order doesn't matter.
|
|
22
|
+
*/
|
|
23
|
+
export function withDatabaseId(text: string, id: string): string {
|
|
24
|
+
return text.replace(new RegExp(`("database_id"\\s*:\\s*")${PLACEHOLDER_ID}"`), `$1${id}"`);
|
|
25
|
+
}
|
|
26
|
+
|
|
16
27
|
export interface StoreNames {
|
|
17
28
|
worker: string;
|
|
18
29
|
database: string;
|
|
@@ -24,7 +35,13 @@ export interface StoreNames {
|
|
|
24
35
|
export type WranglerJson = Record<string, unknown> & {
|
|
25
36
|
name?: string;
|
|
26
37
|
main?: string;
|
|
27
|
-
d1_databases?: {
|
|
38
|
+
d1_databases?: {
|
|
39
|
+
binding?: string;
|
|
40
|
+
database_name?: string;
|
|
41
|
+
database_id?: string;
|
|
42
|
+
preview_database_id?: string;
|
|
43
|
+
migrations_dir?: string;
|
|
44
|
+
}[];
|
|
28
45
|
queues?: { producers?: { binding?: string; queue?: string }[]; consumers?: { queue?: string }[] };
|
|
29
46
|
migrations?: Record<string, unknown>[];
|
|
30
47
|
triggers?: { crons?: string[] };
|
|
@@ -75,14 +92,19 @@ export function git(cwd: string): Git {
|
|
|
75
92
|
if (r.status !== 0) throw new Error("kedge deploy needs a Git repository with a commit");
|
|
76
93
|
return r.stdout.trim();
|
|
77
94
|
},
|
|
95
|
+
// A store may sit below its repository's root (create-kedge reuses a workspace's repository, p0.11
|
|
96
|
+
// row 12): status covers the store's own directory, and its paths are the store's, as `show` reads
|
|
97
|
+
// them (codex code r5).
|
|
78
98
|
dirty() {
|
|
79
|
-
|
|
99
|
+
const prefix = run(["rev-parse", "--show-prefix"]).stdout.trim();
|
|
100
|
+
return run(["status", "--porcelain", "--", "."])
|
|
80
101
|
.stdout.split("\n")
|
|
81
102
|
.filter(Boolean)
|
|
82
|
-
.map((l) => l.slice(3))
|
|
103
|
+
.map((l) => l.slice(3))
|
|
104
|
+
.map((p) => (p.startsWith(prefix) ? p.slice(prefix.length) : p));
|
|
83
105
|
},
|
|
84
106
|
show(path) {
|
|
85
|
-
const r = run(["show", `HEAD
|
|
107
|
+
const r = run(["show", `HEAD:./${path}`]);
|
|
86
108
|
return r.status === 0 ? r.stdout : null;
|
|
87
109
|
},
|
|
88
110
|
isAncestor(older, newer) {
|
|
@@ -100,7 +122,7 @@ export function dirtyBeyondId(cwd: string, g: Git, databaseId: string | null): s
|
|
|
100
122
|
return g.dirty().filter((p) => {
|
|
101
123
|
if (p !== "wrangler.jsonc" || !databaseId) return true;
|
|
102
124
|
const head = g.show("wrangler.jsonc");
|
|
103
|
-
return head === null || head
|
|
125
|
+
return head === null || withDatabaseId(head, databaseId) !== readFileSync(join(cwd, p), "utf8");
|
|
104
126
|
});
|
|
105
127
|
}
|
|
106
128
|
|
package/src/target.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
// Which store a URL is (docs/proposals/p0.10-agent-path.md §4, §6.2). The CLI
|
|
2
2
|
// sends a session, a saved key or a run's record only to a target it can name
|
|
3
3
|
// by identity: production or a preview this store's `<store>-ops` records, or
|
|
4
|
-
// `kedge dev` on this machine.
|
|
5
|
-
//
|
|
4
|
+
// `kedge dev` on this machine. Any other https URL may be production under
|
|
5
|
+
// another name (salve hosting's, a custom domain), and is proved before it
|
|
6
|
+
// gets anything but the proof's own session, bound to that URL's origin and so
|
|
7
|
+
// useless anywhere else (p0.11 §3). A store is its account, Worker and
|
|
6
8
|
// database, not its URL: the account's owner can change the workers.dev
|
|
7
9
|
// subdomain, so the URL is computed from the current one on every run.
|
|
8
|
-
import type
|
|
10
|
+
import { type Cloudflare, NeedsPerson } from "./cf";
|
|
9
11
|
import { openOps } from "./deploy";
|
|
10
12
|
import { storeNames } from "./store";
|
|
11
13
|
|
|
@@ -17,6 +19,12 @@ export interface StoreTarget {
|
|
|
17
19
|
identity: { account: string; worker: string; database: string };
|
|
18
20
|
/** Where a CLI session's row is written: that target's own database, and no other. */
|
|
19
21
|
database: { name: string; id: string | null };
|
|
22
|
+
/**
|
|
23
|
+
* A URL the person gave rather than one the CLI derived (salve's, a custom
|
|
24
|
+
* domain): it's proved to be this store before any other request goes there
|
|
25
|
+
* (p0.11 §3).
|
|
26
|
+
*/
|
|
27
|
+
proof?: true;
|
|
20
28
|
}
|
|
21
29
|
|
|
22
30
|
/**
|
|
@@ -55,8 +63,7 @@ export async function resolveTarget(
|
|
|
55
63
|
if (!(await cf.findD1(names.ops))) return null;
|
|
56
64
|
const { ops } = await openOps(cf, names);
|
|
57
65
|
const host = (worker: string, subdomain: string) => `https://${worker}.${subdomain}.workers.dev`;
|
|
58
|
-
const
|
|
59
|
-
if (u.origin === host(names.worker, subdomain)) {
|
|
66
|
+
const production = async (proof: boolean): Promise<StoreTarget | null> => {
|
|
60
67
|
const db = await ops.resource("d1", names.database);
|
|
61
68
|
if (!db?.id) return null;
|
|
62
69
|
return {
|
|
@@ -64,14 +71,35 @@ export async function resolveTarget(
|
|
|
64
71
|
url: u.origin,
|
|
65
72
|
identity: { account: cf.account, worker: names.worker, database: db.id },
|
|
66
73
|
database: { name: names.database, id: db.id },
|
|
74
|
+
...(proof ? { proof: true as const } : {}),
|
|
67
75
|
};
|
|
76
|
+
};
|
|
77
|
+
// Derived only where the Worker is served at workers.dev: a platform's stand-in subdomain (salve's) names
|
|
78
|
+
// a host the store isn't on, which then gets the proof like any other URL (p0.11 row 2).
|
|
79
|
+
// A store served only at its own domain, on an account without a subdomain, still resolves (codex code r3).
|
|
80
|
+
const devUrl = await cf.workersDevUrl(names.worker).catch((e: unknown) => {
|
|
81
|
+
if (e instanceof NeedsPerson) return null;
|
|
82
|
+
throw e;
|
|
83
|
+
});
|
|
84
|
+
if (u.origin === devUrl) return production(false);
|
|
85
|
+
// A preview is only ever at workers.dev, so only such a host asks for the account's subdomain. The whole
|
|
86
|
+
// origin, as a preview's is made: a host that only begins with a preview's name isn't it.
|
|
87
|
+
let p: Record<string, unknown> | undefined;
|
|
88
|
+
if (u.hostname.endsWith(".workers.dev")) {
|
|
89
|
+
const subdomain = await cf.workersSubdomain().catch((e: unknown) => {
|
|
90
|
+
if (e instanceof NeedsPerson) return null;
|
|
91
|
+
throw e;
|
|
92
|
+
});
|
|
93
|
+
const rows = subdomain
|
|
94
|
+
? await ops.query(
|
|
95
|
+
"SELECT worker, database_name, database_id FROM preview WHERE state = 'bootstrapped' AND database_id IS NOT NULL",
|
|
96
|
+
)
|
|
97
|
+
: [];
|
|
98
|
+
p = rows.find((r) => u.origin === host(String(r.worker), subdomain as string));
|
|
68
99
|
}
|
|
69
|
-
//
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
);
|
|
73
|
-
const p = rows.find((r) => u.origin === host(String(r.worker), subdomain));
|
|
74
|
-
if (!p) return null;
|
|
100
|
+
// Any other https origin may be production under another name (salve hosting's, a custom domain):
|
|
101
|
+
// the proof decides, before a session's token goes there (p0.11 row 1).
|
|
102
|
+
if (!p) return production(true);
|
|
75
103
|
return {
|
|
76
104
|
kind: "preview",
|
|
77
105
|
url: u.origin,
|