@kedge/cli 0.9.0 → 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/kedge-manifest.json +83 -61
- package/package.json +8 -5
- package/src/cf.ts +295 -17
- package/src/check.ts +2 -1
- package/src/cli-session.ts +57 -0
- package/src/commands.ts +278 -76
- package/src/d1-batch.ts +66 -0
- package/src/deploy.ts +91 -19
- package/src/doctor.ts +68 -3
- package/src/keys-save.ts +298 -0
- package/src/local-state.ts +110 -0
- package/src/main.ts +161 -69
- package/src/ops.ts +18 -0
- package/src/output.ts +35 -0
- package/src/preview.ts +33 -6
- package/src/smoke.ts +218 -0
- package/src/store-api.ts +36 -0
- package/src/target.ts +81 -0
package/src/smoke.ts
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
// `kedge smoke` (docs/proposals/p0.10-agent-path.md §6.3): a scripted checkout
|
|
2
|
+
// through the public API with the storefront's key, then Phase 0's staff
|
|
3
|
+
// placement, standing in for payment (D-035), with a CLI session holding
|
|
4
|
+
// `order:place`. It never places a second order by accident: each run has a
|
|
5
|
+
// name the caller keeps, recorded in `.kedge/state.db` before its cart exists,
|
|
6
|
+
// and three moments are each committed before the request they guard: the
|
|
7
|
+
// completion's exact body (a rerun sends it unchanged, never a fresh price),
|
|
8
|
+
// the order's id and token together, and the number once it's read back. A
|
|
9
|
+
// rerun resumes; a finished run answers its order again, writing nothing.
|
|
10
|
+
import type { DatabaseSync } from "node:sqlite";
|
|
11
|
+
import { NeedsPerson } from "./cf";
|
|
12
|
+
import { type SessionDeps, withCliSession } from "./cli-session";
|
|
13
|
+
import { DeployRefused } from "./deploy";
|
|
14
|
+
import { ENV, identityOf, keysSaveHeld } from "./keys-save";
|
|
15
|
+
import { stateDb, withStoreLock } from "./local-state";
|
|
16
|
+
import { storeRequest } from "./store-api";
|
|
17
|
+
import type { StoreTarget } from "./target";
|
|
18
|
+
|
|
19
|
+
/** The product `kedge seed --sample apparel` makes first, and the currency it's priced in. */
|
|
20
|
+
export const SMOKE_PRODUCT = { handle: "linen-shirt", currency: "USD" } as const;
|
|
21
|
+
|
|
22
|
+
interface Run {
|
|
23
|
+
name: string;
|
|
24
|
+
store: string;
|
|
25
|
+
storefront_id: string;
|
|
26
|
+
cart_id: string | null;
|
|
27
|
+
cart_token: string | null;
|
|
28
|
+
completion: string | null;
|
|
29
|
+
order_id: string | null;
|
|
30
|
+
order_token: string | null;
|
|
31
|
+
number: string | null;
|
|
32
|
+
abandoned: string | null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface SmokeDeps {
|
|
36
|
+
cwd: string;
|
|
37
|
+
fetch: typeof fetch;
|
|
38
|
+
session: SessionDeps;
|
|
39
|
+
log: (s: string) => void;
|
|
40
|
+
now?: () => number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface SmokeResult {
|
|
44
|
+
run: string;
|
|
45
|
+
order_id: string;
|
|
46
|
+
number: string;
|
|
47
|
+
status: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const table = (db: DatabaseSync) =>
|
|
51
|
+
db.exec(`CREATE TABLE IF NOT EXISTS smoke_run (
|
|
52
|
+
name TEXT PRIMARY KEY, store TEXT NOT NULL, storefront_id TEXT NOT NULL,
|
|
53
|
+
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)`);
|
|
55
|
+
|
|
56
|
+
/** The smoke run `name` on `target`: resumed, or answered again once it has its number. */
|
|
57
|
+
export async function smoke(target: StoreTarget, name: string, d: SmokeDeps): Promise<SmokeResult> {
|
|
58
|
+
return withStoreLock(d.cwd, "smoke", async () => {
|
|
59
|
+
const db = stateDb(d.cwd);
|
|
60
|
+
try {
|
|
61
|
+
table(db);
|
|
62
|
+
return await run(db, target, name, d);
|
|
63
|
+
} finally {
|
|
64
|
+
db.close();
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function run(db: DatabaseSync, target: StoreTarget, name: string, d: SmokeDeps): Promise<SmokeResult> {
|
|
70
|
+
const now = d.now ?? Date.now;
|
|
71
|
+
const store = identityOf(target);
|
|
72
|
+
const get = () => db.prepare("SELECT * FROM smoke_run WHERE name = ?").get(name) as unknown as Run | undefined;
|
|
73
|
+
const set = (a: Partial<Run>) => {
|
|
74
|
+
const cols = Object.keys(a);
|
|
75
|
+
db.prepare(`UPDATE smoke_run SET ${cols.map((c) => `${c} = ?`).join(", ")}, updated_at = ? WHERE name = ?`).run(
|
|
76
|
+
...cols.map((c) => (a as Record<string, string | null>)[c] ?? null),
|
|
77
|
+
now(),
|
|
78
|
+
name,
|
|
79
|
+
);
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
let r = get();
|
|
83
|
+
// A finished run answers its order again, sending nothing: a retried command can't place a second one.
|
|
84
|
+
if (r?.number && r.order_id) {
|
|
85
|
+
if (r.store !== store) throw new DeployRefused(`smoke run ${name} belongs to another store (${r.store})`);
|
|
86
|
+
return { run: name, order_id: r.order_id, number: r.number, status: "open" };
|
|
87
|
+
}
|
|
88
|
+
if (r?.abandoned)
|
|
89
|
+
throw new DeployRefused(`smoke run ${name} was abandoned: ${r.abandoned}. Start another with --run <new name>`);
|
|
90
|
+
|
|
91
|
+
// The storefront's key, through the same check `keys create --save` makes every time: a saved key that's
|
|
92
|
+
// this store's and still works, or a replacement on its recorded storefront (codex code r13).
|
|
93
|
+
const saved = await keysSaveHeld(
|
|
94
|
+
target,
|
|
95
|
+
{ kind: "publishable", name: "web", origins: [] },
|
|
96
|
+
{
|
|
97
|
+
cwd: d.cwd,
|
|
98
|
+
fetch: d.fetch,
|
|
99
|
+
session: d.session,
|
|
100
|
+
log: d.log,
|
|
101
|
+
},
|
|
102
|
+
);
|
|
103
|
+
const key = saved[ENV.publishable];
|
|
104
|
+
const storefront = saved[ENV.storefront];
|
|
105
|
+
if (!key || !storefront) throw new DeployRefused("no storefront key was saved for this store");
|
|
106
|
+
|
|
107
|
+
if (!r) {
|
|
108
|
+
const t = now();
|
|
109
|
+
db.prepare("INSERT INTO smoke_run (name, store, storefront_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?)").run(
|
|
110
|
+
name,
|
|
111
|
+
store,
|
|
112
|
+
storefront,
|
|
113
|
+
t,
|
|
114
|
+
t,
|
|
115
|
+
);
|
|
116
|
+
r = get() as Run;
|
|
117
|
+
}
|
|
118
|
+
// A cart admits only its own storefront's key: a record for another store or storefront is refused, not reused.
|
|
119
|
+
if (r.store !== store || r.storefront_id !== storefront)
|
|
120
|
+
throw new DeployRefused(
|
|
121
|
+
`smoke run ${name} was made for ${r.store === store ? `storefront ${r.storefront_id}` : r.store}: start another with --run <new name>`,
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
const shop = { "x-kedge-key": key };
|
|
125
|
+
const req = async (method: string, path: string, headers: Record<string, string>, body?: unknown) => {
|
|
126
|
+
const res = await storeRequest(d.fetch, new URL(path, target.url), {
|
|
127
|
+
method,
|
|
128
|
+
headers: { ...headers, ...(body === undefined ? {} : { "content-type": "application/json" }) },
|
|
129
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
130
|
+
});
|
|
131
|
+
return res;
|
|
132
|
+
};
|
|
133
|
+
const refused = (what: string, res: { status: number; body: Record<string, unknown> }) =>
|
|
134
|
+
new DeployRefused(
|
|
135
|
+
`${what} answered ${res.status} ${String((res.body.error as { code?: string } | undefined)?.code ?? "")}`.trim(),
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
if (!r.order_id) {
|
|
139
|
+
if (!r.completion) {
|
|
140
|
+
// Disposable until it's marked completing: a cart from a stopped run is abandoned with its lines.
|
|
141
|
+
const p = await req("GET", `/store/products/${SMOKE_PRODUCT.handle}?currency=${SMOKE_PRODUCT.currency}`, shop);
|
|
142
|
+
if (p.status === 404)
|
|
143
|
+
throw new DeployRefused(`the store has no ${SMOKE_PRODUCT.handle}: run kedge seed --sample apparel first`);
|
|
144
|
+
if (!p.ok) throw refused("reading the sample product", p);
|
|
145
|
+
const variant = (p.body.variants as { id?: string }[] | undefined)?.[0]?.id;
|
|
146
|
+
if (!variant) throw new DeployRefused(`${SMOKE_PRODUCT.handle} has no variant to buy`);
|
|
147
|
+
const cart = await req("POST", "/store/carts", shop, {
|
|
148
|
+
currency_code: SMOKE_PRODUCT.currency,
|
|
149
|
+
email: smokeEmail(target.url),
|
|
150
|
+
});
|
|
151
|
+
if (!cart.ok) throw refused("creating the cart", cart);
|
|
152
|
+
const id = String(cart.body.id);
|
|
153
|
+
const token = String(cart.body.token);
|
|
154
|
+
const withCart = { ...shop, "x-cart-token": token };
|
|
155
|
+
const line = await req("POST", `/store/carts/${id}/lines`, withCart, { variant_id: variant, quantity: 1 });
|
|
156
|
+
if (!line.ok) throw refused("adding the line", line);
|
|
157
|
+
const total = (line.body.totals as { total?: { amount?: number } } | null)?.total?.amount;
|
|
158
|
+
if (typeof total !== "number") throw new DeployRefused("the cart answered no total to complete it with");
|
|
159
|
+
// Moment 1: the exact completion, before it's sent. It's what every rerun sends.
|
|
160
|
+
set({ cart_id: id, cart_token: token, completion: JSON.stringify({ expected_total: total }) });
|
|
161
|
+
r = get() as Run;
|
|
162
|
+
}
|
|
163
|
+
// Moment 2: the recorded body, unchanged, with the same token; a completed cart answers the same order.
|
|
164
|
+
const done = await req(
|
|
165
|
+
"POST",
|
|
166
|
+
`/store/carts/${r.cart_id}/complete`,
|
|
167
|
+
{ ...shop, "x-cart-token": r.cart_token as string },
|
|
168
|
+
JSON.parse(r.completion as string),
|
|
169
|
+
);
|
|
170
|
+
const code = (done.body.error as { code?: string } | undefined)?.code;
|
|
171
|
+
if (code === "CART_TOTAL_CHANGED") {
|
|
172
|
+
// That refusal creates no order: this run ends, and another prices afresh.
|
|
173
|
+
set({ abandoned: "the cart's price changed before it completed (CART_TOTAL_CHANGED)" });
|
|
174
|
+
throw new DeployRefused(
|
|
175
|
+
`smoke run ${name}: the price changed before completion, so nothing was ordered. Start another with --run <new name>`,
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
if (!done.ok || typeof done.body.id !== "string" || typeof done.body.token !== "string")
|
|
179
|
+
throw refused("completing the cart", done);
|
|
180
|
+
// Moment 3: the order's id and token, together.
|
|
181
|
+
set({ order_id: done.body.id, order_token: done.body.token });
|
|
182
|
+
r = get() as Run;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Placement stands in for payment in Phase 0 (D-035); placing an open order again changes nothing.
|
|
186
|
+
const orderId = r.order_id as string;
|
|
187
|
+
await withCliSession(target, ["order:place"], d.session, async (auth) => {
|
|
188
|
+
const placed = await req("POST", `/admin/orders/${orderId}/place`, auth, {});
|
|
189
|
+
if (!placed.ok) throw refused("placing the order", placed);
|
|
190
|
+
});
|
|
191
|
+
const read = await req("GET", `/store/orders/${orderId}`, { ...shop, "x-order-token": r.order_token as string });
|
|
192
|
+
if (!read.ok) throw refused("reading the order back", read);
|
|
193
|
+
const status = String(read.body.status);
|
|
194
|
+
// The API answers the number as an integer (research/29's first run): kept as text, as shown.
|
|
195
|
+
const raw = read.body.number;
|
|
196
|
+
const number = typeof raw === "number" || (typeof raw === "string" && raw) ? String(raw) : null;
|
|
197
|
+
if (status !== "open" || !number)
|
|
198
|
+
throw new DeployRefused(
|
|
199
|
+
`the order is ${status}${number ? ` (${number})` : ", with no number"}, so it isn't placed`,
|
|
200
|
+
);
|
|
201
|
+
set({ number });
|
|
202
|
+
d.log(`order ${number} is open (smoke run ${name})`);
|
|
203
|
+
return { run: name, order_id: orderId, number, status };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** `smoke@<the store's host>`, which names it as a test in the admin; a bare host gets `.test` to be an address. */
|
|
207
|
+
export function smokeEmail(url: string): string {
|
|
208
|
+
const host = new URL(url).hostname;
|
|
209
|
+
return `smoke@${host.includes(".") ? host : `${host}.test`}`;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Whether `url` is a store a smoke run writes to only by someone's choice (§6.3). */
|
|
213
|
+
export function needsLive(target: StoreTarget, live: boolean): void {
|
|
214
|
+
if (target.kind === "production" && !live)
|
|
215
|
+
throw new NeedsPerson(
|
|
216
|
+
`${target.url} is the live store: a test order there is its owner's call, and stays as its first order until P1's order actions can cancel it. With their yes, run this again with --live`,
|
|
217
|
+
);
|
|
218
|
+
}
|
package/src/store-api.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Requests to a store's own API, as the CLI makes them (p0.10 §5): a write
|
|
2
|
+
// with no answer, or one that can't be read, is an unknown outcome.
|
|
3
|
+
import { UnknownOutcome } from "./cf";
|
|
4
|
+
import { DeployRefused } from "./deploy";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* One request to a store's API. A write sent with no answer back, or with an
|
|
8
|
+
* answer that can't be read, may have committed: its outcome is unknown, never
|
|
9
|
+
* a failure (p0.10 §5, codex code r8), and says so under `--json`. A readable
|
|
10
|
+
* refusal is the caller's to word.
|
|
11
|
+
*/
|
|
12
|
+
export async function storeRequest(
|
|
13
|
+
f: typeof fetch,
|
|
14
|
+
url: URL,
|
|
15
|
+
init: RequestInit,
|
|
16
|
+
): Promise<{ status: number; ok: boolean; body: Record<string, unknown> }> {
|
|
17
|
+
const what = `${init.method ?? "GET"} ${url.pathname}`;
|
|
18
|
+
const write = (init.method ?? "GET") !== "GET";
|
|
19
|
+
let r: Response;
|
|
20
|
+
try {
|
|
21
|
+
r = await f(url, init);
|
|
22
|
+
} catch {
|
|
23
|
+
if (write) throw new UnknownOutcome(what, "the store");
|
|
24
|
+
throw new DeployRefused(`${what}: the store didn't answer`);
|
|
25
|
+
}
|
|
26
|
+
let body: Record<string, unknown>;
|
|
27
|
+
try {
|
|
28
|
+
body = (await r.json()) as Record<string, unknown>;
|
|
29
|
+
} catch {
|
|
30
|
+
// A refusal Kedge words is JSON; an unreadable success, or a 5xx from in front of the Worker, isn't proof of either.
|
|
31
|
+
if (write && (r.ok || r.status >= 500))
|
|
32
|
+
throw new UnknownOutcome(`${what} (answered ${r.status}, unreadable)`, "the store");
|
|
33
|
+
throw new DeployRefused(`${what} answered ${r.status}, not JSON`);
|
|
34
|
+
}
|
|
35
|
+
return { status: r.status, ok: r.ok, body };
|
|
36
|
+
}
|
package/src/target.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// Which store a URL is (docs/proposals/p0.10-agent-path.md §4, §6.2). The CLI
|
|
2
|
+
// sends a session, a saved key or a run's record only to a target it can name
|
|
3
|
+
// by identity: production or a preview this store's `<store>-ops` records, or
|
|
4
|
+
// `kedge dev` on this machine. Anything else gets nothing, so an unknown
|
|
5
|
+
// endpoint never receives a token. A store is its account, Worker and
|
|
6
|
+
// database, not its URL: the account's owner can change the workers.dev
|
|
7
|
+
// subdomain, so the URL is computed from the current one on every run.
|
|
8
|
+
import type { Cloudflare } from "./cf";
|
|
9
|
+
import { openOps } from "./deploy";
|
|
10
|
+
import { storeNames } from "./store";
|
|
11
|
+
|
|
12
|
+
export interface StoreTarget {
|
|
13
|
+
kind: "production" | "preview" | "local";
|
|
14
|
+
/** The origin requests go to. */
|
|
15
|
+
url: string;
|
|
16
|
+
/** What records bind to: never the URL. A local target's account is `local`. */
|
|
17
|
+
identity: { account: string; worker: string; database: string };
|
|
18
|
+
/** Where a CLI session's row is written: that target's own database, and no other. */
|
|
19
|
+
database: { name: string; id: string | null };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* `kedge dev`'s port: the config's `dev.port`, else Wrangler's default 8787
|
|
24
|
+
* (`DEFAULT_LOCAL_PORT`, wrangler-dist/cli.js:212493, 4.152.0). When 8787 is
|
|
25
|
+
* taken Wrangler picks another (`memoizeGetPort`, :213241), which then isn't a
|
|
26
|
+
* target: the command asks for `--key` or `--session` instead.
|
|
27
|
+
*/
|
|
28
|
+
export function devPort(cwd: string): number {
|
|
29
|
+
const dev = storeNames(cwd).config.dev as { port?: unknown } | undefined;
|
|
30
|
+
return typeof dev?.port === "number" ? dev.port : 8787;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
34
|
+
|
|
35
|
+
/** The target `url` names, or null when it's none this checkout knows. */
|
|
36
|
+
export async function resolveTarget(
|
|
37
|
+
url: string,
|
|
38
|
+
cwd: string,
|
|
39
|
+
cloudflare: () => Promise<Cloudflare>,
|
|
40
|
+
): Promise<StoreTarget | null> {
|
|
41
|
+
const u = new URL(url);
|
|
42
|
+
const { names } = storeNames(cwd);
|
|
43
|
+
if (LOCAL_HOSTS.has(u.hostname)) {
|
|
44
|
+
if (u.protocol !== "http:" || Number(u.port || 80) !== devPort(cwd)) return null;
|
|
45
|
+
return {
|
|
46
|
+
kind: "local",
|
|
47
|
+
url: u.origin,
|
|
48
|
+
identity: { account: "local", worker: names.worker, database: "local" },
|
|
49
|
+
database: { name: names.database, id: null },
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
if (u.protocol !== "https:") return null;
|
|
53
|
+
const cf = await cloudflare();
|
|
54
|
+
// A lookup, never a create: a store never deployed has no target here.
|
|
55
|
+
if (!(await cf.findD1(names.ops))) return null;
|
|
56
|
+
const { ops } = await openOps(cf, names);
|
|
57
|
+
const host = (worker: string, subdomain: string) => `https://${worker}.${subdomain}.workers.dev`;
|
|
58
|
+
const subdomain = await cf.workersSubdomain();
|
|
59
|
+
if (u.origin === host(names.worker, subdomain)) {
|
|
60
|
+
const db = await ops.resource("d1", names.database);
|
|
61
|
+
if (!db?.id) return null;
|
|
62
|
+
return {
|
|
63
|
+
kind: "production",
|
|
64
|
+
url: u.origin,
|
|
65
|
+
identity: { account: cf.account, worker: names.worker, database: db.id },
|
|
66
|
+
database: { name: names.database, id: db.id },
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
// The whole origin, as a preview's is made: a host that only begins with a preview's name isn't it.
|
|
70
|
+
const rows = await ops.query(
|
|
71
|
+
"SELECT worker, database_name, database_id FROM preview WHERE state = 'bootstrapped' AND database_id IS NOT NULL",
|
|
72
|
+
);
|
|
73
|
+
const p = rows.find((r) => u.origin === host(String(r.worker), subdomain));
|
|
74
|
+
if (!p) return null;
|
|
75
|
+
return {
|
|
76
|
+
kind: "preview",
|
|
77
|
+
url: u.origin,
|
|
78
|
+
identity: { account: cf.account, worker: String(p.worker), database: String(p.database_id) },
|
|
79
|
+
database: { name: String(p.database_name), id: String(p.database_id) },
|
|
80
|
+
};
|
|
81
|
+
}
|