@kedge/cli 0.0.0-stage → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/bin/kedge.mjs +8 -0
- package/kedge-manifest.json +396 -0
- package/package.json +40 -3
- package/src/cf.ts +451 -0
- package/src/check.ts +44 -0
- package/src/code-lint.ts +339 -0
- package/src/commands.ts +757 -0
- package/src/compose.ts +637 -0
- package/src/deploy.ts +1610 -0
- package/src/doctor.ts +636 -0
- package/src/fork.ts +67 -0
- package/src/index.ts +12 -0
- package/src/invariants.ts +168 -0
- package/src/main.ts +378 -0
- package/src/manifest.ts +163 -0
- package/src/n1.ts +217 -0
- package/src/ops.ts +355 -0
- package/src/order.ts +7 -0
- package/src/plugin-lint.ts +145 -0
- package/src/preview.ts +1011 -0
- package/src/schema.ts +72 -0
- package/src/sqlite-verify.ts +333 -0
- package/src/store.ts +132 -0
- package/src/tar.ts +125 -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/fork.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// `kedge doctor --core-diff` (docs/proposals/p0.8-cli-agent-kit.md §8.3): how far
|
|
2
|
+
// a fork's packages have drifted from a release, per package and file, the
|
|
3
|
+
// way Magento's core:code:changes turns "how far have we drifted" into a number
|
|
4
|
+
// before an upgrade (research/14 §1). It reports; only --strict fails on it.
|
|
5
|
+
// Each package.json is left out: a fork's names its siblings by workspace:*,
|
|
6
|
+
// a release's by version, and that difference isn't drift. Each package's
|
|
7
|
+
// LICENSE is the root one, copied in only while packing, so it's compared
|
|
8
|
+
// with the fork's root LICENSE.
|
|
9
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
10
|
+
import { join, relative } from "node:path";
|
|
11
|
+
import { fileHash, type Manifest } from "./manifest";
|
|
12
|
+
|
|
13
|
+
export interface PackageDiff {
|
|
14
|
+
package: string;
|
|
15
|
+
changed: string[];
|
|
16
|
+
added: string[];
|
|
17
|
+
removed: string[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function coreDiff(repo: string, manifest: Manifest): PackageDiff[] {
|
|
21
|
+
const out: PackageDiff[] = [];
|
|
22
|
+
for (const [name, files] of Object.entries(manifest.packages)) {
|
|
23
|
+
const dir = join(repo, "packages", name.replace(/^@kedge\//, ""));
|
|
24
|
+
const d: PackageDiff = { package: name, changed: [], added: [], removed: [] };
|
|
25
|
+
const tops = new Set(
|
|
26
|
+
Object.keys(files)
|
|
27
|
+
.map((f) => f.split("/")[0] as string)
|
|
28
|
+
.filter((t) => !t.endsWith(".json")),
|
|
29
|
+
);
|
|
30
|
+
for (const [f, want] of Object.entries(files)) {
|
|
31
|
+
if (f === "package.json") continue;
|
|
32
|
+
const p = f === "LICENSE" ? join(repo, "LICENSE") : join(dir, f);
|
|
33
|
+
if (!existsSync(p)) d.removed.push(f);
|
|
34
|
+
else if (fileHash(f, readFileSync(p)) !== want) d.changed.push(f);
|
|
35
|
+
}
|
|
36
|
+
const walk = (d0: string) => {
|
|
37
|
+
if (!existsSync(d0)) return;
|
|
38
|
+
for (const e of readdirSync(d0, { withFileTypes: true })) {
|
|
39
|
+
const p = join(d0, e.name);
|
|
40
|
+
if (e.isDirectory()) walk(p);
|
|
41
|
+
else {
|
|
42
|
+
const rel = relative(dir, p);
|
|
43
|
+
if (files[rel] === undefined && !(rel.startsWith("migrations/") && !rel.endsWith(".sql"))) d.added.push(rel);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
for (const t of tops) walk(join(dir, t));
|
|
48
|
+
if (d.changed.length || d.added.length || d.removed.length) out.push(d);
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function formatCoreDiff(diffs: readonly PackageDiff[], version: string): string {
|
|
54
|
+
if (!diffs.length) return `the packages match Kedge ${version}`;
|
|
55
|
+
const lines = [`drift from Kedge ${version}:`];
|
|
56
|
+
for (const d of diffs) {
|
|
57
|
+
lines.push(` ${d.package}: ${d.changed.length} changed, ${d.added.length} added, ${d.removed.length} removed`);
|
|
58
|
+
for (const f of d.changed) lines.push(` ~ ${f}`);
|
|
59
|
+
for (const f of d.added) lines.push(` + ${f}`);
|
|
60
|
+
for (const f of d.removed) lines.push(` - ${f}`);
|
|
61
|
+
}
|
|
62
|
+
const files = diffs.reduce((n, d) => n + d.changed.length + d.added.length + d.removed.length, 0);
|
|
63
|
+
lines.push(
|
|
64
|
+
` ${files} file${files === 1 ? "" : "s"} across ${diffs.length} package${diffs.length === 1 ? "" : "s"}: each is a merge at the next upgrade`,
|
|
65
|
+
);
|
|
66
|
+
return lines.join("\n");
|
|
67
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export {
|
|
2
|
+
CORE_TABLES,
|
|
3
|
+
errorSources,
|
|
4
|
+
type LintIssue,
|
|
5
|
+
lintSql,
|
|
6
|
+
type Owner,
|
|
7
|
+
PERMANENT_IDENTITIES,
|
|
8
|
+
statementTexts,
|
|
9
|
+
tokenize,
|
|
10
|
+
} from "@kedge/core";
|
|
11
|
+
export { ComposeError, type ComposeInput, type ComposeResult, compose, type Lock, type LockEntry } from "./compose";
|
|
12
|
+
export { lintPlugin } from "./plugin-lint";
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// INVARIANTS.md and invariants.json (docs/proposals/p0.8-cli-agent-kit.md §4.2):
|
|
2
|
+
// core's catalog checked against the composed schema, then each plugin's own
|
|
3
|
+
// constraints and RAISE codes with the codes they map to. `kedge generate`
|
|
4
|
+
// writes both; doctor's `invariants` check reports the catalog's problems.
|
|
5
|
+
import type { ErrorMapEntry } from "@kedge/core";
|
|
6
|
+
import {
|
|
7
|
+
CATALOG_EXEMPT,
|
|
8
|
+
type CatalogEntry,
|
|
9
|
+
CORE_RAISE_CODES,
|
|
10
|
+
catalogPattern,
|
|
11
|
+
ENTITY_RAISE_CODES,
|
|
12
|
+
INVARIANT_CATALOG,
|
|
13
|
+
} from "@kedge/db";
|
|
14
|
+
import { byCodePoint } from "./order";
|
|
15
|
+
import { raiseCodes, type Schema, type SchemaObject } from "./schema";
|
|
16
|
+
|
|
17
|
+
/** The merchant's own, from `local/`: theirs to name and keep, never core's catalog's (codex code r19). */
|
|
18
|
+
export const isLocal = (o: SchemaObject) => o.name.startsWith("local_") || o.table.startsWith("local_");
|
|
19
|
+
|
|
20
|
+
/** Objects the catalog speaks for: core's triggers and named constraints. Plugins' are listed per plugin. */
|
|
21
|
+
const claimable = (o: SchemaObject) =>
|
|
22
|
+
(o.type === "trigger" || o.type === "constraint") && !o.name.startsWith("plg_") && !isLocal(o);
|
|
23
|
+
|
|
24
|
+
/** Every code core's own SQL raises, and what each becomes. */
|
|
25
|
+
export const CORE_CODES: Readonly<Record<string, string>> = { ...CORE_RAISE_CODES, ...ENTITY_RAISE_CODES };
|
|
26
|
+
|
|
27
|
+
export interface CatalogProblem {
|
|
28
|
+
message: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function catalogProblems(
|
|
32
|
+
schema: Schema,
|
|
33
|
+
checks: ReadonlySet<string>,
|
|
34
|
+
catalog: readonly CatalogEntry[] = INVARIANT_CATALOG,
|
|
35
|
+
exempt: readonly { objects: readonly string[]; reason: string }[] = CATALOG_EXEMPT,
|
|
36
|
+
): CatalogProblem[] {
|
|
37
|
+
const problems: CatalogProblem[] = [];
|
|
38
|
+
const objects = schema.objects.filter(claimable);
|
|
39
|
+
const claimed = new Set<string>();
|
|
40
|
+
const claim = (where: string, pattern: string) => {
|
|
41
|
+
const re = catalogPattern(pattern);
|
|
42
|
+
const hits = objects.filter((o) => re.test(o.name));
|
|
43
|
+
if (hits.length === 0 && !generated.has(pattern))
|
|
44
|
+
problems.push({ message: `${where} names "${pattern}", which matches no core trigger or constraint` });
|
|
45
|
+
for (const h of hits) claimed.add(h.name);
|
|
46
|
+
};
|
|
47
|
+
const ids = new Set<string>();
|
|
48
|
+
const generated = new Set(catalog.filter((e) => e.generated).flatMap((e) => e.objects));
|
|
49
|
+
for (const e of catalog) {
|
|
50
|
+
if (ids.has(e.id)) problems.push({ message: `${e.id} is listed twice` });
|
|
51
|
+
ids.add(e.id);
|
|
52
|
+
for (const p of e.objects) claim(e.id, p);
|
|
53
|
+
if (e.check && !checks.has(e.check))
|
|
54
|
+
problems.push({ message: `${e.id}'s check "${e.check}" isn't in @kedge/testing's INVARIANTS` });
|
|
55
|
+
}
|
|
56
|
+
for (const x of exempt) for (const p of x.objects) claim("an exemption", p);
|
|
57
|
+
const unclaimed = [...new Set(objects.filter((o) => !claimed.has(o.name)).map((o) => o.name))];
|
|
58
|
+
if (unclaimed.length)
|
|
59
|
+
problems.push({
|
|
60
|
+
message: `core objects no catalog entry claims: ${unclaimed.join(", ")} (add them to packages/db/src/invariant-catalog.ts)`,
|
|
61
|
+
});
|
|
62
|
+
return problems;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface RenderedEntry {
|
|
66
|
+
id: string;
|
|
67
|
+
title: string;
|
|
68
|
+
rule: string;
|
|
69
|
+
check: string | null;
|
|
70
|
+
objects: string[];
|
|
71
|
+
raises: { code: string; error: string }[];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface PluginInvariants {
|
|
75
|
+
plugin: string;
|
|
76
|
+
constraints: { name: string; table: string; error: string | null }[];
|
|
77
|
+
raises: { code: string; object: string; error: string | null }[];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface Invariants {
|
|
81
|
+
version: 1;
|
|
82
|
+
core: RenderedEntry[];
|
|
83
|
+
plugins: PluginInvariants[];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const coreError = (code: string) => {
|
|
87
|
+
const e = CORE_CODES[code];
|
|
88
|
+
return e === undefined ? "UNMAPPED" : e === "defect" ? "INTERNAL (a core defect)" : e;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The plugin a `kedge_<plugin>_*` object belongs to: core generates them around a plugin's own SQL (its
|
|
93
|
+
* acquire statements, its entities' triggers), so their codes are the plugin's or core's. Longest name
|
|
94
|
+
* first, so `kedge_gift_card_*` is gift_card's beside a plugin named gift. Doctor and the catalog share
|
|
95
|
+
* it: a code doctor accepts as mapped is the one INVARIANTS.md shows (codex code r31).
|
|
96
|
+
*/
|
|
97
|
+
export function generatedFor<P extends { name: string }>(name: string, plugins: readonly P[]): P | undefined {
|
|
98
|
+
return [...plugins].sort((a, b) => b.name.length - a.name.length).find((p) => name.startsWith(`kedge_${p.name}_`));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The catalog and every plugin's constraints, as invariants.json holds them. */
|
|
102
|
+
export function invariantsOf(
|
|
103
|
+
schema: Schema,
|
|
104
|
+
plugins: readonly { name: string; errors: Readonly<Record<string, ErrorMapEntry>> }[],
|
|
105
|
+
): Invariants {
|
|
106
|
+
const objects = schema.objects.filter(claimable);
|
|
107
|
+
const core = INVARIANT_CATALOG.map((e) => {
|
|
108
|
+
const res = e.objects.map(catalogPattern);
|
|
109
|
+
const hits = objects.filter((o) => res.some((r) => r.test(o.name)));
|
|
110
|
+
const raises = new Map<string, { code: string; error: string }>();
|
|
111
|
+
for (const o of hits) {
|
|
112
|
+
const owner = generatedFor(o.name, plugins);
|
|
113
|
+
for (const code of raiseCodes(o.sql)) {
|
|
114
|
+
const error = owner?.errors[code]?.code ?? coreError(code);
|
|
115
|
+
raises.set(`${code} ${error}`, { code, error });
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return {
|
|
119
|
+
id: e.id,
|
|
120
|
+
title: e.title,
|
|
121
|
+
rule: e.rule,
|
|
122
|
+
check: e.check ?? null,
|
|
123
|
+
objects: [...new Set(hits.map((o) => o.name))],
|
|
124
|
+
raises: [...raises.values()].sort((a, b) => byCodePoint(a.code, b.code) || byCodePoint(a.error, b.error)),
|
|
125
|
+
};
|
|
126
|
+
});
|
|
127
|
+
const out: PluginInvariants[] = [];
|
|
128
|
+
for (const p of plugins) {
|
|
129
|
+
const prefix = `plg_${p.name}_`;
|
|
130
|
+
const mapped = (key: string) => p.errors[key]?.code ?? null;
|
|
131
|
+
const constraints = schema.objects
|
|
132
|
+
.filter((o) => o.type === "constraint" && o.table.startsWith(prefix))
|
|
133
|
+
.map((o) => ({ name: o.name, table: o.table, error: mapped(o.name) }));
|
|
134
|
+
const raises = schema.objects
|
|
135
|
+
.filter((o) => (o.type === "trigger" || o.type === "view") && o.name.startsWith(prefix))
|
|
136
|
+
.flatMap((o) => raiseCodes(o.sql).map((code) => ({ code, object: o.name, error: mapped(code) })));
|
|
137
|
+
if (constraints.length || raises.length) out.push({ plugin: p.name, constraints, raises });
|
|
138
|
+
}
|
|
139
|
+
return { version: 1, core, plugins: out };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function invariantsMarkdown(inv: Invariants): string {
|
|
143
|
+
const lines = [
|
|
144
|
+
"# Invariants",
|
|
145
|
+
"",
|
|
146
|
+
"Generated by `kedge generate` from the composed migrations; never edit it. Each",
|
|
147
|
+
"rule is enforced by D1 itself, in the batch, so code that breaks it is refused",
|
|
148
|
+
"with the error shown. Follow the rule and you never meet the error.",
|
|
149
|
+
"",
|
|
150
|
+
];
|
|
151
|
+
for (const e of inv.core) {
|
|
152
|
+
lines.push(`## ${e.id}: ${e.title}`, "", e.rule, "");
|
|
153
|
+
if (e.raises.length) lines.push(`- **Errors:** ${e.raises.map((r) => `\`${r.code}\` → ${r.error}`).join("; ")}`);
|
|
154
|
+
if (e.check) lines.push(`- **Checked after every test** by \`describeKedgeInvariants\` (\`${e.check}\`)`);
|
|
155
|
+
lines.push(`- **Enforced by:** ${e.objects.length} objects (\`invariants.json\` lists them)`, "");
|
|
156
|
+
}
|
|
157
|
+
if (inv.plugins.length) {
|
|
158
|
+
lines.push("## Plugins", "");
|
|
159
|
+
for (const p of inv.plugins) {
|
|
160
|
+
lines.push(`### ${p.plugin}`, "");
|
|
161
|
+
for (const c of p.constraints)
|
|
162
|
+
lines.push(`- constraint \`${c.name}\` on \`${c.table}\` → ${c.error ?? "UNMAPPED"}`);
|
|
163
|
+
for (const r of p.raises) lines.push(`- \`RAISE '${r.code}'\` in \`${r.object}\` → ${r.error ?? "UNMAPPED"}`);
|
|
164
|
+
lines.push("");
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return `${lines.join("\n").trimEnd()}\n`;
|
|
168
|
+
}
|
package/src/main.ts
ADDED
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
// The `kedge` command. P0.2 ships `generate`, P0.5 the deployment's bootstrap
|
|
2
|
+
// commands (`staff invite-owner`, `staff recover`, `keys create`); doctor,
|
|
3
|
+
// check, preview and deploy arrive with P0.8 (docs/11).
|
|
4
|
+
import { spawnSync } from "node:child_process";
|
|
5
|
+
import { cpSync, existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
8
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
9
|
+
import type { KedgeConfig } from "@kedge/core";
|
|
10
|
+
import { type CliBatch, ownerInviteSql, publishableKeySql, recoverSql } from "@kedge/db";
|
|
11
|
+
import { type Cloudflare, restCloudflare } from "./cf";
|
|
12
|
+
import { check as runCheck } from "./check";
|
|
13
|
+
import {
|
|
14
|
+
type CommandDeps,
|
|
15
|
+
deployCommand,
|
|
16
|
+
devCommand,
|
|
17
|
+
ejectCommand,
|
|
18
|
+
flag,
|
|
19
|
+
generateSupersede,
|
|
20
|
+
keysOverHttp,
|
|
21
|
+
migrateCommand,
|
|
22
|
+
previewCommand,
|
|
23
|
+
restoreCommand,
|
|
24
|
+
secretCommand,
|
|
25
|
+
seedCommand,
|
|
26
|
+
upgradeCommand,
|
|
27
|
+
} from "./commands";
|
|
28
|
+
import { ComposeError, compose, type Lock } from "./compose";
|
|
29
|
+
import { doctor, doctorRemote, formatReport } from "./doctor";
|
|
30
|
+
import { invariantsMarkdown, invariantsOf } from "./invariants";
|
|
31
|
+
import { installedPackages } from "./manifest";
|
|
32
|
+
import { composedTexts, schemaOf } from "./schema";
|
|
33
|
+
import { storeNames } from "./store";
|
|
34
|
+
import { compileStoreZone } from "./tz";
|
|
35
|
+
import { strictlyPublic } from "./worker-config";
|
|
36
|
+
|
|
37
|
+
const USAGE = `usage: kedge generate [--check] [--supersede <op-id>]
|
|
38
|
+
kedge check [--fast]
|
|
39
|
+
kedge doctor [--json]
|
|
40
|
+
kedge dev
|
|
41
|
+
kedge migrate [--remote]
|
|
42
|
+
kedge deploy [--maintenance-window <from>/<to>] [--var K=V]... [--adopt <name>]...
|
|
43
|
+
[--resume | --take-over <op-id> --confirm-stopped | --abort-cutover | --supersede <op-id>]
|
|
44
|
+
[--resolve-intent <id> --not-executed] [--record-active --reason <what>]
|
|
45
|
+
[--adopt-existing --from-release <version>] [--allow-older]
|
|
46
|
+
kedge preview [--name N] [--var K=V]... [--fresh] [--secret-env NAME]...
|
|
47
|
+
kedge preview build [--name N] | kedge preview apply <plan.tar|-> [--store S] [--name N]
|
|
48
|
+
kedge preview destroy [--name N] [--sweep]
|
|
49
|
+
kedge eject <unit> --reason "<why>" | kedge eject --rebase <unit>
|
|
50
|
+
kedge upgrade --to <version> [--from-packs <dir>]
|
|
51
|
+
kedge doctor --core-diff [--upstream <manifest>] [--strict]
|
|
52
|
+
kedge secret put <NAME> | kedge secret delete <NAME>
|
|
53
|
+
kedge restore --list
|
|
54
|
+
kedge seed --sample apparel --url <url> (--key <sk_…> | --session <token>) [--live]
|
|
55
|
+
kedge keys create (--publishable [--origin <url>]... | --secret --grant <permission>...)
|
|
56
|
+
--url <url> (--session <token> | --sign-in <email>) [--name <name>]
|
|
57
|
+
kedge staff invite-owner <email> [--database DB] [--remote] [--url <url>] [--config wrangler.jsonc]
|
|
58
|
+
kedge staff recover <email> [--database DB] [--remote] [--config wrangler.jsonc]
|
|
59
|
+
kedge keys create --publishable --name <name> [--origin <origin>]... [--pepper-version N]
|
|
60
|
+
[--database DB] [--remote] [--config wrangler.jsonc]
|
|
61
|
+
|
|
62
|
+
generate compose core, plugin and local migrations into migrations/
|
|
63
|
+
and update kedge.lock.json
|
|
64
|
+
generate --check fail if generate would write anything (for CI)
|
|
65
|
+
dev fresh local secrets once, local migrations, then wrangler dev
|
|
66
|
+
migrate local migrations; --remote applies N−1 files to production, refusing a cutover
|
|
67
|
+
deploy migrations before the Worker, under an operation in <store>-ops (§6)
|
|
68
|
+
preview a separate Worker with its own D1, queue and Durable Objects, capture
|
|
69
|
+
sinks for every effect, seeded, then test/scenarios/*.http.ts against it
|
|
70
|
+
eject copy one unit of core into src/overrides, its upstream hash recorded
|
|
71
|
+
upgrade every @kedge package to a release one supported step on, its codemods,
|
|
72
|
+
UPGRADE-TODO.md, then generate and check; nothing is committed
|
|
73
|
+
restore --list the restore points; restoring waits for P1.18
|
|
74
|
+
check typecheck, generate --check, doctor, then the store's tests;
|
|
75
|
+
--fast stops before the tests (the Stop hook's)
|
|
76
|
+
doctor the store's offline checks: lock, installed core, error map,
|
|
77
|
+
invariant catalog, code lint, wrangler.jsonc
|
|
78
|
+
staff invite-owner the first Owner's one-time invite (only while no active Owner exists)
|
|
79
|
+
staff recover reset a sole Owner's factors and print a reenrol invite
|
|
80
|
+
keys create a storefront and its first publishable key; reads the pepper from
|
|
81
|
+
KEDGE_KEY_PEPPER_V<N> in the environment (N defaults to 1)
|
|
82
|
+
|
|
83
|
+
The staff and keys commands run one audited batch through wrangler d1 execute
|
|
84
|
+
(--local unless --remote) and print the secret once.`;
|
|
85
|
+
|
|
86
|
+
/** Runs one CLI batch against the store's D1; tests replace it. */
|
|
87
|
+
export type Executor = (
|
|
88
|
+
b: CliBatch,
|
|
89
|
+
opts: { database: string; remote: boolean; cwd: string; config?: string },
|
|
90
|
+
) => number;
|
|
91
|
+
|
|
92
|
+
const wranglerExecute: Executor = (b, opts) => {
|
|
93
|
+
const dir = mkdtempSync(join(tmpdir(), "kedge-"));
|
|
94
|
+
const file = join(dir, "batch.sql");
|
|
95
|
+
try {
|
|
96
|
+
writeFileSync(file, b.sql, { mode: 0o600 });
|
|
97
|
+
const r = spawnSync(
|
|
98
|
+
"npx",
|
|
99
|
+
[
|
|
100
|
+
"wrangler",
|
|
101
|
+
"d1",
|
|
102
|
+
"execute",
|
|
103
|
+
opts.database,
|
|
104
|
+
"--file",
|
|
105
|
+
file,
|
|
106
|
+
opts.remote ? "--remote" : "--local",
|
|
107
|
+
"--yes",
|
|
108
|
+
...(opts.config ? ["-c", opts.config] : []),
|
|
109
|
+
],
|
|
110
|
+
{ cwd: opts.cwd, stdio: "inherit" },
|
|
111
|
+
);
|
|
112
|
+
return r.status ?? 1;
|
|
113
|
+
} finally {
|
|
114
|
+
// The file holds no secret (only digests), but it needn't outlive the run.
|
|
115
|
+
rmSync(dir, { recursive: true, force: true });
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The store's origin for the invite link (p0.9 §3.5): `--url`, or with
|
|
121
|
+
* `--remote` the Worker's workers.dev URL. Null when neither is known, and then
|
|
122
|
+
* only the token prints, as before: the link is a convenience.
|
|
123
|
+
*/
|
|
124
|
+
async function inviteOrigin(
|
|
125
|
+
args: readonly string[],
|
|
126
|
+
remote: boolean,
|
|
127
|
+
cwd: string,
|
|
128
|
+
env: NodeJS.ProcessEnv,
|
|
129
|
+
cf?: Cloudflare,
|
|
130
|
+
): Promise<string | null> {
|
|
131
|
+
const url = flag(args, "--url");
|
|
132
|
+
if (url) return new URL(url).origin;
|
|
133
|
+
if (!remote) return null;
|
|
134
|
+
try {
|
|
135
|
+
const worker = storeNames(cwd).names.worker;
|
|
136
|
+
return `https://${worker}.${await (cf ?? restCloudflare(env)).workersSubdomain()}.workers.dev`;
|
|
137
|
+
} catch {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function bootstrap(
|
|
143
|
+
command: string,
|
|
144
|
+
args: string[],
|
|
145
|
+
cwd: string,
|
|
146
|
+
execute: Executor,
|
|
147
|
+
env: NodeJS.ProcessEnv,
|
|
148
|
+
cf?: Cloudflare,
|
|
149
|
+
): Promise<number> {
|
|
150
|
+
const config = flag(args, "--config");
|
|
151
|
+
const opts = {
|
|
152
|
+
database: flag(args, "--database") ?? "DB",
|
|
153
|
+
remote: args.includes("--remote"),
|
|
154
|
+
cwd,
|
|
155
|
+
...(config ? { config } : {}),
|
|
156
|
+
};
|
|
157
|
+
const now = Date.now();
|
|
158
|
+
const [sub, target] = args;
|
|
159
|
+
if (command === "staff" && (sub === "invite-owner" || sub === "recover") && target && !target.startsWith("--")) {
|
|
160
|
+
const b = sub === "invite-owner" ? await ownerInviteSql(target, now) : await recoverSql(target, now);
|
|
161
|
+
const status = execute(b, opts);
|
|
162
|
+
if (status !== 0) return status;
|
|
163
|
+
console.log(`invite ${b.inviteId} for ${target}, valid until ${new Date(b.expiresAt).toISOString()}`);
|
|
164
|
+
console.log(`accept with POST /auth/admin/invites/accept and this token (shown once):\n${b.token}`);
|
|
165
|
+
const origin = await inviteOrigin(args, opts.remote, cwd, env, cf);
|
|
166
|
+
// The token rides in the fragment, which no server log or Referer carries (p0.9 §3.5).
|
|
167
|
+
if (origin) console.log(`or open the admin's invite page:\n${origin}/app/invite#${b.token}`);
|
|
168
|
+
return 0;
|
|
169
|
+
}
|
|
170
|
+
if (command === "keys" && sub === "create" && args.includes("--publishable")) {
|
|
171
|
+
const name = flag(args, "--name");
|
|
172
|
+
if (!name) {
|
|
173
|
+
console.error("keys create needs --name");
|
|
174
|
+
return 2;
|
|
175
|
+
}
|
|
176
|
+
const origins = args.flatMap((a, i) => (a === "--origin" && args[i + 1] ? [args[i + 1] as string] : []));
|
|
177
|
+
const pepperVersion = Number(flag(args, "--pepper-version") ?? "1");
|
|
178
|
+
const pepper = env[`KEDGE_KEY_PEPPER_V${pepperVersion}`];
|
|
179
|
+
if (!Number.isSafeInteger(pepperVersion) || pepperVersion < 1 || !pepper) {
|
|
180
|
+
console.error(`set KEDGE_KEY_PEPPER_V${pepperVersion} to the Worker's pepper secret first`);
|
|
181
|
+
return 2;
|
|
182
|
+
}
|
|
183
|
+
const b = await publishableKeySql({ name, origins, pepperVersion, pepper }, now);
|
|
184
|
+
const status = execute(b, opts);
|
|
185
|
+
if (status !== 0) return status;
|
|
186
|
+
console.log(`storefront ${b.storefrontId}, key ${b.keyId}`);
|
|
187
|
+
console.log(`publishable key (send as x-kedge-key; shown once):\n${b.key}`);
|
|
188
|
+
return 0;
|
|
189
|
+
}
|
|
190
|
+
console.error(USAGE);
|
|
191
|
+
return 2;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const INDEX_START = "<!-- kedge:docs-index:start -->";
|
|
195
|
+
const INDEX_END = "<!-- kedge:docs-index:end -->";
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* AGENTS.md's docs index (§9.3): a compressed list of the installed
|
|
199
|
+
* @kedge/docs, always in context, between markers so the rest of the file is
|
|
200
|
+
* the store's. Null when there's no AGENTS.md with the markers or no docs.
|
|
201
|
+
*/
|
|
202
|
+
function agentsIndex(cwd: string): { path: string; text: string } | null {
|
|
203
|
+
const path = join(cwd, "AGENTS.md");
|
|
204
|
+
const docs = installedPackages(cwd).get("@kedge/docs");
|
|
205
|
+
if (!existsSync(path) || !docs) return null;
|
|
206
|
+
const current = readFileSync(path, "utf8");
|
|
207
|
+
const a = current.indexOf(INDEX_START);
|
|
208
|
+
const b = current.indexOf(INDEX_END);
|
|
209
|
+
if (a < 0 || b < a) return null;
|
|
210
|
+
const version = (JSON.parse(readFileSync(join(docs, "package.json"), "utf8")) as { version: string }).version;
|
|
211
|
+
const index = JSON.parse(readFileSync(join(docs, "index.json"), "utf8")) as {
|
|
212
|
+
entries: { path: string; title: string; summary: string }[];
|
|
213
|
+
};
|
|
214
|
+
const lines = [
|
|
215
|
+
`Kedge ${version}. Read the guide before writing that kind of code; they match what's installed.`,
|
|
216
|
+
...index.entries.map((e) => `- [${e.title}](node_modules/@kedge/docs/${e.path}): ${e.summary}`),
|
|
217
|
+
];
|
|
218
|
+
return { path, text: `${current.slice(0, a + INDEX_START.length)}\n${lines.join("\n")}\n${current.slice(b)}` };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Skills ship in @kedge/docs and are copied, gitignored, so they always match what's installed (§9.3). */
|
|
222
|
+
function copySkills(cwd: string): string[] {
|
|
223
|
+
const docs = installedPackages(cwd).get("@kedge/docs");
|
|
224
|
+
if (!docs || !existsSync(join(docs, "skills")) || !existsSync(join(cwd, ".claude"))) return [];
|
|
225
|
+
const out: string[] = [];
|
|
226
|
+
for (const s of readdirSync(join(docs, "skills"))) {
|
|
227
|
+
cpSync(join(docs, "skills", s), join(cwd, ".claude", "skills", s), { recursive: true });
|
|
228
|
+
out.push(s);
|
|
229
|
+
}
|
|
230
|
+
return out;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** What generate writes beside the migrations: INVARIANTS.md, invariants.json and AGENTS.md's index. */
|
|
234
|
+
function generatedFiles(cwd: string, lock: Lock, config: KedgeConfig): { path: string; text: string }[] {
|
|
235
|
+
const schema = schemaOf(composedTexts(cwd, lock));
|
|
236
|
+
const inv = invariantsOf(
|
|
237
|
+
schema,
|
|
238
|
+
(config.plugins ?? []).map((p) => ({ name: p.definition.name, errors: p.definition.errors ?? {} })),
|
|
239
|
+
);
|
|
240
|
+
const index = agentsIndex(cwd);
|
|
241
|
+
return [
|
|
242
|
+
{ path: join(cwd, "INVARIANTS.md"), text: invariantsMarkdown(inv) },
|
|
243
|
+
{ path: join(cwd, "invariants.json"), text: `${JSON.stringify(inv, null, 2)}\n` },
|
|
244
|
+
...(index ? [index] : []),
|
|
245
|
+
];
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** @kedge/db's migrations, resolved from this package so the CLI and the runtime agree on core's version. */
|
|
249
|
+
function coreMigrationsDir(): string {
|
|
250
|
+
const entry = fileURLToPath(import.meta.resolve("@kedge/db"));
|
|
251
|
+
return join(dirname(entry), "..", "migrations");
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export async function main(
|
|
255
|
+
argv: string[],
|
|
256
|
+
cwd: string,
|
|
257
|
+
deps: { execute?: Executor; env?: NodeJS.ProcessEnv; cf?: Cloudflare } = {},
|
|
258
|
+
): Promise<number> {
|
|
259
|
+
const [command, ...flags] = argv;
|
|
260
|
+
if (command === "keys" && flag(flags, "--url")) {
|
|
261
|
+
const d0: CommandDeps = { cwd, env: deps.env ?? process.env, coreDir: coreMigrationsDir() };
|
|
262
|
+
return keysOverHttp(flags, d0);
|
|
263
|
+
}
|
|
264
|
+
if (command === "staff" || command === "keys")
|
|
265
|
+
return bootstrap(command, flags, cwd, deps.execute ?? wranglerExecute, deps.env ?? process.env, deps.cf);
|
|
266
|
+
const d: CommandDeps = {
|
|
267
|
+
cwd,
|
|
268
|
+
env: deps.env ?? process.env,
|
|
269
|
+
coreDir: coreMigrationsDir(),
|
|
270
|
+
checkFast: () => main(["check", "--fast"], cwd),
|
|
271
|
+
};
|
|
272
|
+
if (command === "deploy") return deployCommand(flags, d);
|
|
273
|
+
if (command === "migrate") return migrateCommand(flags, d);
|
|
274
|
+
if (command === "restore") return restoreCommand(flags, d);
|
|
275
|
+
if (command === "secret") return secretCommand(flags, d);
|
|
276
|
+
if (command === "seed") return seedCommand(flags, d);
|
|
277
|
+
if (command === "preview") return previewCommand(flags, d);
|
|
278
|
+
if (command === "eject") return ejectCommand(flags, d);
|
|
279
|
+
if (command === "upgrade")
|
|
280
|
+
return upgradeCommand(flags, {
|
|
281
|
+
...d,
|
|
282
|
+
// The store's own CLI after the install: this process is still the release being replaced.
|
|
283
|
+
kedge: async (argv) =>
|
|
284
|
+
spawnSync(join(cwd, "node_modules", ".bin", "kedge"), argv, { cwd, stdio: "inherit" }).status ?? 1,
|
|
285
|
+
});
|
|
286
|
+
if (command === "dev") return devCommand(d);
|
|
287
|
+
if (command === "generate" && flag(flags, "--supersede"))
|
|
288
|
+
return generateSupersede(flag(flags, "--supersede") as string, d);
|
|
289
|
+
if (command === "doctor" && flags.includes("--core-diff")) {
|
|
290
|
+
const { coreDiff, formatCoreDiff } = await import("./fork");
|
|
291
|
+
const { ownManifest } = await import("./manifest");
|
|
292
|
+
const upstream = flag(flags, "--upstream");
|
|
293
|
+
const manifest = upstream ? JSON.parse(readFileSync(upstream, "utf8")) : ownManifest();
|
|
294
|
+
if (!manifest) {
|
|
295
|
+
console.error("--core-diff needs a release's manifest: --upstream <dist-packs/<version>/kedge-manifest.json>");
|
|
296
|
+
return 2;
|
|
297
|
+
}
|
|
298
|
+
const diffs = coreDiff(cwd, manifest);
|
|
299
|
+
console.log(formatCoreDiff(diffs, manifest.version));
|
|
300
|
+
return flags.includes("--strict") && diffs.length ? 1 : 0;
|
|
301
|
+
}
|
|
302
|
+
if (command === "doctor") {
|
|
303
|
+
const results = await doctor({ cwd, coreDir: coreMigrationsDir() });
|
|
304
|
+
if (flags.includes("--remote")) {
|
|
305
|
+
const cf = restCloudflare(d.env);
|
|
306
|
+
const lock = JSON.parse(readFileSync(join(cwd, "kedge.lock.json"), "utf8")) as Lock;
|
|
307
|
+
results.push(...(await doctorRemote({ cwd, cf, lock, logpushJobs: () => cf.logpushJobs() })));
|
|
308
|
+
}
|
|
309
|
+
console.log(flags.includes("--json") ? JSON.stringify({ checks: results }, null, 2) : formatReport(results));
|
|
310
|
+
return results.some((r) => r.status === "fail") ? 1 : 0;
|
|
311
|
+
}
|
|
312
|
+
if (command === "check")
|
|
313
|
+
return runCheck({
|
|
314
|
+
cwd,
|
|
315
|
+
fast: flags.includes("--fast"),
|
|
316
|
+
generate: (c: string) => main(["generate", "--check"], c),
|
|
317
|
+
doctor: (c: string) => main(["doctor"], c),
|
|
318
|
+
});
|
|
319
|
+
if (command !== "generate") {
|
|
320
|
+
console.error(USAGE);
|
|
321
|
+
return 2;
|
|
322
|
+
}
|
|
323
|
+
const check = flags.includes("--check");
|
|
324
|
+
const configPath = resolve(cwd, "kedge.config.ts");
|
|
325
|
+
const config = ((await import(pathToFileURL(configPath).href)) as { default: KedgeConfig }).default;
|
|
326
|
+
try {
|
|
327
|
+
const result = compose({
|
|
328
|
+
coreDir: coreMigrationsDir(),
|
|
329
|
+
plugins: config.plugins ?? [],
|
|
330
|
+
// A preview's own ports are locked like any other's (p0.8 §5.2).
|
|
331
|
+
ports: [...(config.ports ?? []), ...(config.preview?.ports ?? [])],
|
|
332
|
+
localDir: join(cwd, "local"),
|
|
333
|
+
outDir: join(cwd, "migrations"),
|
|
334
|
+
lockPath: join(cwd, "kedge.lock.json"),
|
|
335
|
+
now: Date.now(),
|
|
336
|
+
dryRun: check,
|
|
337
|
+
...(config.timezone ? { timezone: config.timezone } : {}),
|
|
338
|
+
compileZone: compileStoreZone,
|
|
339
|
+
tzPath: join(cwd, "src", "kedge.tz.ts"),
|
|
340
|
+
});
|
|
341
|
+
for (const n of result.notes) console.log(`note: ${n}`);
|
|
342
|
+
// P0.6b (§2.8): the Worker's fetch takes the public route, webhooks included.
|
|
343
|
+
const flag = strictlyPublic(cwd);
|
|
344
|
+
if (flag.problem) {
|
|
345
|
+
console.error(flag.problem);
|
|
346
|
+
return 1;
|
|
347
|
+
}
|
|
348
|
+
if (check && (result.written.length > 0 || result.lockChanged)) {
|
|
349
|
+
const files = [...result.written, ...(result.lockChanged ? ["kedge.lock.json"] : [])];
|
|
350
|
+
console.error(`kedge generate would write:\n ${files.join("\n ")}\nRun kedge generate and commit the result.`);
|
|
351
|
+
return 1;
|
|
352
|
+
}
|
|
353
|
+
for (const f of result.written) console.log(f.endsWith(".ts") ? `wrote ${f}` : `wrote migrations/${f}`);
|
|
354
|
+
if (result.written.length === 0) console.log("migrations are up to date");
|
|
355
|
+
if (result.lockChanged && !check) console.log("wrote kedge.lock.json");
|
|
356
|
+
// The invariant catalog, from what's now composed (§4.2).
|
|
357
|
+
const generated = generatedFiles(cwd, result.lock, config);
|
|
358
|
+
const stale = generated.filter((g) => !existsSync(g.path) || readFileSync(g.path, "utf8") !== g.text);
|
|
359
|
+
if (check && stale.length) {
|
|
360
|
+
console.error(
|
|
361
|
+
`kedge generate would write:\n ${stale.map((g) => relative(cwd, g.path)).join("\n ")}\nRun kedge generate and commit the result.`,
|
|
362
|
+
);
|
|
363
|
+
return 1;
|
|
364
|
+
}
|
|
365
|
+
for (const g of stale) {
|
|
366
|
+
writeFileSync(g.path, g.text);
|
|
367
|
+
console.log(`wrote ${relative(cwd, g.path)}`);
|
|
368
|
+
}
|
|
369
|
+
if (!check) copySkills(cwd);
|
|
370
|
+
return 0;
|
|
371
|
+
} catch (e) {
|
|
372
|
+
if (e instanceof ComposeError) {
|
|
373
|
+
console.error(e.message);
|
|
374
|
+
return 1;
|
|
375
|
+
}
|
|
376
|
+
throw e;
|
|
377
|
+
}
|
|
378
|
+
}
|