@kedge/cli 0.0.0-stage → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/upgrade.ts ADDED
@@ -0,0 +1,235 @@
1
+ // `kedge upgrade --to <version>` (docs/proposals/p0.8-cli-agent-kit.md §8.2).
2
+ // Medusa's rule: cover every release in between and stop on the first failure
3
+ // rather than pile on speculative fixes (research/14 §1). Hydrogen's: write what
4
+ // a codemod can't do to a file. And Kedge's own: a deploy refuses a release not
5
+ // proven against the one serving, and composed migrations can't be un-composed,
6
+ // so a target more than one supported step away refuses before anything changes.
7
+ import { spawnSync } from "node:child_process";
8
+ import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
9
+ import { tmpdir } from "node:os";
10
+ import { join } from "node:path";
11
+ import { pathToFileURL } from "node:url";
12
+ import { isMap, parse, parseDocument } from "yaml";
13
+ import { installedPackages, type Manifest } from "./manifest";
14
+ import { byCodePoint } from "./order";
15
+ import type { Git } from "./store";
16
+ import { unitDrift } from "./units";
17
+
18
+ export interface NoteEntry {
19
+ id: string;
20
+ affects?: string[];
21
+ detect?: string;
22
+ codemod?: string | null;
23
+ manual?: string;
24
+ verify?: string;
25
+ }
26
+
27
+ export interface UpgradeNote {
28
+ version: string;
29
+ upgradesFrom: string | null;
30
+ /** Packages this release adds to a store, written in before the install (p0.9 §3.2). */
31
+ adds?: string[];
32
+ entries: NoteEntry[];
33
+ }
34
+
35
+ export class UpgradeRefused extends Error {
36
+ constructor(message: string) {
37
+ super(message);
38
+ this.name = "UpgradeRefused";
39
+ }
40
+ }
41
+
42
+ export function readNotes(dir: string): Map<string, UpgradeNote> {
43
+ const out = new Map<string, UpgradeNote>();
44
+ if (!existsSync(dir)) return out;
45
+ for (const f of readdirSync(dir).filter((f) => f.endsWith(".md"))) {
46
+ const m = /^---\n([\s\S]*?)\n---\n/.exec(readFileSync(join(dir, f), "utf8"));
47
+ if (!m) continue;
48
+ const fm = parse(m[1] as string) as Partial<UpgradeNote>;
49
+ if (typeof fm.version === "string")
50
+ out.set(fm.version, {
51
+ version: fm.version,
52
+ upgradesFrom: fm.upgradesFrom ?? null,
53
+ ...(Array.isArray(fm.adds)
54
+ ? { adds: fm.adds.filter((a) => typeof a === "string" && a.startsWith("@kedge/")) }
55
+ : {}),
56
+ entries: fm.entries ?? [],
57
+ });
58
+ }
59
+ return out;
60
+ }
61
+
62
+ /** The releases from `current` to `target`, each proven against the one before; null when there's no such chain. */
63
+ export function chain(notes: ReadonlyMap<string, UpgradeNote>, current: string, target: string): string[] | null {
64
+ const stages: string[] = [];
65
+ let at: string | null = target;
66
+ while (at && at !== current) {
67
+ stages.unshift(at);
68
+ if (stages.length > 50) return null;
69
+ at = notes.get(at)?.upgradesFrom ?? null;
70
+ }
71
+ return at === current ? stages : null;
72
+ }
73
+
74
+ export interface UpgradeInput {
75
+ cwd: string;
76
+ to: string;
77
+ fromPacks?: string;
78
+ git: Git;
79
+ manifest: (cwd: string) => Manifest | null;
80
+ /** Runs a command in the store; tests replace it. */
81
+ run?: (cmd: string, args: string[], cwd: string) => number;
82
+ /** `kedge generate` then `kedge check`, by the CLI the upgrade just installed, never this process's older one. */
83
+ kedge: (argv: string[]) => Promise<number>;
84
+ log?: (s: string) => void;
85
+ }
86
+
87
+ /** The target's @kedge/docs, fetched before anything is installed, so its notes decide. */
88
+ function fetchDocs(to: string, fromPacks: string | undefined): { dir: string; cleanup: () => void } {
89
+ const tmp = mkdtempSync(join(tmpdir(), "kedge-upgrade-"));
90
+ let tgz: string;
91
+ if (fromPacks) tgz = join(fromPacks, `kedge-docs-${to}.tgz`);
92
+ else {
93
+ const r = spawnSync("npm", ["pack", `@kedge/docs@${to}`, "--pack-destination", tmp], { encoding: "utf8" });
94
+ if (r.status !== 0)
95
+ throw new UpgradeRefused(`can't fetch @kedge/docs@${to}: ${r.stderr.trim().split("\n").at(-1)}`);
96
+ tgz = join(tmp, r.stdout.trim().split("\n").at(-1) as string);
97
+ }
98
+ if (!existsSync(tgz)) throw new UpgradeRefused(`no @kedge/docs ${to} at ${tgz}`);
99
+ const x = spawnSync("tar", ["-xzf", tgz, "-C", tmp]);
100
+ if (x.status !== 0) throw new UpgradeRefused(`can't unpack ${tgz}`);
101
+ return { dir: join(tmp, "package"), cleanup: () => rmSync(tmp, { recursive: true, force: true }) };
102
+ }
103
+
104
+ /**
105
+ * The workspace's Kedge overrides, made to name the requested source: each package's tarball in
106
+ * `fromPacks`, or none, for the registry. An override left from the last source wins at install, and
107
+ * the old release would stay installed under the new one's version (codex code r30). Other overrides stay.
108
+ */
109
+ export function reconcileOverrides(text: string, to: string, fromPacks?: string): string {
110
+ const doc = parseDocument(text);
111
+ const found = doc.get("overrides");
112
+ const overrides = isMap(found) ? found : doc.createNode({});
113
+ for (const item of [...overrides.items]) {
114
+ const key = String((item.key as { value?: unknown })?.value ?? item.key);
115
+ if (key.startsWith("@kedge/")) overrides.delete(key);
116
+ }
117
+ if (fromPacks) {
118
+ const tgz = new RegExp(`^kedge-(.+)-${to.replace(/\./g, "\\.")}\\.tgz$`);
119
+ const packs = readdirSync(fromPacks)
120
+ .map((f) => [f, tgz.exec(f)?.[1]] as const)
121
+ .filter((x): x is readonly [string, string] => Boolean(x[1]))
122
+ .sort(([, a], [, b]) => byCodePoint(a, b));
123
+ if (!packs.length) throw new UpgradeRefused(`no Kedge ${to} packs in ${fromPacks}`);
124
+ for (const [file, name] of packs) overrides.set(`@kedge/${name}`, `file:${join(fromPacks, file)}`);
125
+ }
126
+ if (overrides.items.length) doc.set("overrides", overrides);
127
+ else doc.delete("overrides");
128
+ return doc.toString();
129
+ }
130
+
131
+ /** Installed Kedge packages that aren't the target's version, which no codemod may run against. */
132
+ export function notAt(cwd: string, to: string): string[] {
133
+ return [...installedPackages(cwd)]
134
+ .map(([name, dir]) => [
135
+ name,
136
+ (JSON.parse(readFileSync(join(dir, "package.json"), "utf8")) as { version: string }).version,
137
+ ])
138
+ .filter(([, v]) => v !== to)
139
+ .map(([name, v]) => `${name} ${v}`);
140
+ }
141
+
142
+ export async function upgrade(input: UpgradeInput): Promise<void> {
143
+ const log = input.log ?? console.log;
144
+ const run = input.run ?? ((cmd, args, cwd) => spawnSync(cmd, args, { cwd, stdio: "inherit" }).status ?? 1);
145
+ if (input.git.dirty().length)
146
+ throw new UpgradeRefused("commit first: an upgrade's diff should be the upgrade's alone");
147
+ const cliDir = installedPackages(input.cwd).get("@kedge/cli");
148
+ if (!cliDir) throw new UpgradeRefused("@kedge/cli isn't installed here");
149
+ const current = (JSON.parse(readFileSync(join(cliDir, "package.json"), "utf8")) as { version: string }).version;
150
+ if (current === input.to) throw new UpgradeRefused(`already on ${current}`);
151
+ const docs = fetchDocs(input.to, input.fromPacks);
152
+ let notes: Map<string, UpgradeNote>;
153
+ try {
154
+ notes = readNotes(join(docs.dir, "upgrading"));
155
+ } finally {
156
+ docs.cleanup();
157
+ }
158
+ const stages = chain(notes, current, input.to);
159
+ if (!stages) throw new UpgradeRefused(`Kedge ${input.to}'s upgrade notes don't reach ${current}: no supported path`);
160
+ if (stages.length > 1)
161
+ throw new UpgradeRefused(
162
+ `${input.to} is ${stages.length} supported steps from ${current}; deploy each in turn:\n${stages.map((v) => ` kedge upgrade --to ${v}${input.fromPacks ? " --from-packs <dir>" : ""}, then kedge deploy`).join("\n")}`,
163
+ );
164
+
165
+ // Every @kedge package to the target, together.
166
+ const pkgFile = join(input.cwd, "package.json");
167
+ const pkg = JSON.parse(readFileSync(pkgFile, "utf8")) as Record<string, Record<string, string>>;
168
+ for (const field of ["dependencies", "devDependencies"])
169
+ for (const dep of Object.keys(pkg[field] ?? {}))
170
+ if (dep.startsWith("@kedge/")) (pkg[field] as Record<string, string>)[dep] = input.to;
171
+ // A package the release adds, before the one install, so the codemod that uses it finds it (p0.9 §3.2).
172
+ for (const added of notes.get(input.to)?.adds ?? [])
173
+ if (!pkg.dependencies?.[added] && !pkg.devDependencies?.[added]) {
174
+ pkg.dependencies = { ...(pkg.dependencies ?? {}), [added]: input.to };
175
+ log(`added ${added} ${input.to}, which this release brings`);
176
+ }
177
+ writeFileSync(pkgFile, `${JSON.stringify(pkg, null, 2)}\n`);
178
+ const ws = join(input.cwd, "pnpm-workspace.yaml");
179
+ if (existsSync(ws) || input.fromPacks)
180
+ writeFileSync(ws, reconcileOverrides(existsSync(ws) ? readFileSync(ws, "utf8") : "", input.to, input.fromPacks));
181
+ // An upgrade changes the lockfile by definition; CI would otherwise freeze it.
182
+ if (run("pnpm", ["install", "--no-frozen-lockfile"], input.cwd) !== 0)
183
+ throw new UpgradeRefused("pnpm install failed; nothing else ran");
184
+ const stale = notAt(input.cwd, input.to);
185
+ if (stale.length) throw new UpgradeRefused(`the install left ${stale.join(", ")}, not ${input.to}; no codemod ran`);
186
+
187
+ // Each entry's codemod, from the newly installed @kedge/docs; what's left goes to UPGRADE-TODO.md.
188
+ const installedDocs = installedPackages(input.cwd).get("@kedge/docs");
189
+ const note = notes.get(input.to) as UpgradeNote;
190
+ const todo: string[] = [
191
+ `# Upgrading to Kedge ${input.to}`,
192
+ "",
193
+ `From ${current}. Written by kedge upgrade; work through it, then delete it.`,
194
+ "",
195
+ ];
196
+ for (const e of note.entries) {
197
+ const left: string[] = [];
198
+ if (e.codemod) {
199
+ const file = installedDocs ? join(installedDocs, "upgrading", "codemods", `${e.codemod}.mjs`) : "";
200
+ if (!file || !existsSync(file)) left.push(`the codemod ${e.codemod} is missing from @kedge/docs`);
201
+ else {
202
+ const mod = (await import(pathToFileURL(file).href)) as {
203
+ default: (
204
+ dir: string,
205
+ ) => { changed: string[]; todo: string[] } | Promise<{ changed: string[]; todo: string[] }>;
206
+ };
207
+ const r = await mod.default(input.cwd);
208
+ for (const c of r.changed) log(`codemod ${e.codemod}: changed ${c}`);
209
+ left.push(...r.todo);
210
+ }
211
+ }
212
+ // Manual steps stand beside a codemod, not instead of one: what it can't do is why they're there (codex code r10).
213
+ if (e.manual) left.push(e.manual);
214
+ if (left.length)
215
+ todo.push(
216
+ `## ${e.id}`,
217
+ "",
218
+ ...(e.affects?.length ? [`Affects: ${e.affects.join(", ")}`, ""] : []),
219
+ ...left.map((l) => `- ${l}`),
220
+ ...(e.verify ? ["", `Verify: \`${e.verify}\``] : []),
221
+ "",
222
+ );
223
+ }
224
+ const drift = unitDrift(input.cwd, input.manifest(input.cwd), null, { ignoreWiring: true });
225
+ if (drift.status === "fail") todo.push("## Ejected units", "", "```diff", ...(drift.details ?? []), "```", "");
226
+ writeFileSync(join(input.cwd, "UPGRADE-TODO.md"), `${todo.join("\n").trimEnd()}\n`);
227
+ log("wrote UPGRADE-TODO.md");
228
+
229
+ // Then generate and check, stopping at the first failure with its output.
230
+ if ((await input.kedge(["generate"])) !== 0)
231
+ throw new UpgradeRefused("kedge generate failed after the upgrade; UPGRADE-TODO.md says what's left");
232
+ if ((await input.kedge(["check"])) !== 0)
233
+ throw new UpgradeRefused("kedge check failed after the upgrade; UPGRADE-TODO.md says what's left");
234
+ log(`upgraded to Kedge ${input.to}; nothing is committed`);
235
+ }
@@ -0,0 +1,81 @@
1
+ // The store's Worker config (docs/proposals/p0.6b-webhooks-calendar-preview.md
2
+ // §2.8): every Worker that sends webhooks needs `global_fetch_strictly_public`,
3
+ // or a `fetch` to a host in its own zone goes straight to that zone's origin,
4
+ // around Cloudflare's security settings for it
5
+ // (https://developers.cloudflare.com/workers/configuration/compatibility-flags/#global-fetch-strictly-public).
6
+ //
7
+ // `kedge generate` reads the config as wrangler does, to a value, and checks
8
+ // that value; it never edits the file. Editing JSONC text without a full
9
+ // parser failed three codex rounds (a trailing comma, a commented-out list,
10
+ // a string holding a bracket), so the merchant adds the flag, told exactly
11
+ // where. An `env.<name>` that sets its own list overrides the top-level one
12
+ // ("Inheritable keys … can be inherited (or overridden) by
13
+ // environment-specific configuration",
14
+ // https://developers.cloudflare.com/workers/wrangler/configuration/#inheritable-keys),
15
+ // so each such list needs the flag too.
16
+ import { existsSync, readFileSync } from "node:fs";
17
+ import { join } from "node:path";
18
+
19
+ export const STRICTLY_PUBLIC = "global_fetch_strictly_public";
20
+ const FILES = ["wrangler.jsonc", "wrangler.json"];
21
+
22
+ /**
23
+ * JSONC as JSON: comments and trailing commas dropped, every string kept
24
+ * byte for byte. One pass that knows when it is inside a string, so nothing
25
+ * a string holds is read as a comment, a comma or structure.
26
+ */
27
+ export function jsoncToJson(text: string): string {
28
+ let out = "";
29
+ let i = 0;
30
+ const skip = (from: number): number => {
31
+ let j = from;
32
+ for (;;) {
33
+ if (/\s/.test(text[j] ?? "")) j++;
34
+ else if (text.startsWith("//", j)) j = text.includes("\n", j) ? text.indexOf("\n", j) + 1 : text.length;
35
+ else if (text.startsWith("/*", j)) j = text.includes("*/", j + 2) ? text.indexOf("*/", j + 2) + 2 : text.length;
36
+ else return j;
37
+ }
38
+ };
39
+ while (i < text.length) {
40
+ const ch = text[i] as string;
41
+ if (ch === '"') {
42
+ let j = i + 1;
43
+ while (j < text.length && text[j] !== '"') j += text[j] === "\\" ? 2 : 1;
44
+ out += text.slice(i, j + 1);
45
+ i = j + 1;
46
+ } else if (text.startsWith("//", i) || text.startsWith("/*", i)) {
47
+ i = skip(i);
48
+ out += " ";
49
+ } else if (ch === ",") {
50
+ const next = skip(i + 1);
51
+ if (text[next] !== "]" && text[next] !== "}") out += ",";
52
+ i++;
53
+ } else {
54
+ out += ch;
55
+ i++;
56
+ }
57
+ }
58
+ return out;
59
+ }
60
+
61
+ type Flags = unknown;
62
+ const hasFlag = (flags: Flags) => Array.isArray(flags) && flags.includes(STRICTLY_PUBLIC);
63
+
64
+ /** The Worker config's refusal, or null when every list it deploys with holds the flag. */
65
+ export function strictlyPublic(cwd: string): { file: string | null; problem: string | null } {
66
+ const file = FILES.map((f) => join(cwd, f)).find((f) => existsSync(f));
67
+ if (!file) return { file: null, problem: null };
68
+ let config: { compatibility_flags?: Flags; env?: Record<string, { compatibility_flags?: Flags }> };
69
+ try {
70
+ config = JSON.parse(jsoncToJson(readFileSync(file, "utf8")));
71
+ } catch (e) {
72
+ return { file, problem: `${file} doesn't parse as JSONC: ${(e as Error).message}` };
73
+ }
74
+ const add = `add "${STRICTLY_PUBLIC}" to`;
75
+ if (!hasFlag(config.compatibility_flags))
76
+ return { file, problem: `${file}: ${add} the top-level "compatibility_flags" (webhooks need it, p0.6b §2.8)` };
77
+ for (const [name, env] of Object.entries(config.env ?? {}))
78
+ if (env && typeof env === "object" && "compatibility_flags" in env && !hasFlag(env.compatibility_flags))
79
+ return { file, problem: `${file}: env.${name} sets its own "compatibility_flags"; ${add} it (p0.6b §2.8)` };
80
+ return { file, problem: null };
81
+ }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.