@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/tz.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Pinning the store's timezone rules (p0.4-dated-capacity.md §4): `kedge
|
|
2
|
+
// generate` compiles the configured zone from the generating Node's IANA
|
|
3
|
+
// data once, writes the table the Worker passes to createKedge, and composes
|
|
4
|
+
// a migration loading the same rows into tz_transition and pinning
|
|
5
|
+
// store_time. Generating again never changes a pinned version (until P2.3).
|
|
6
|
+
import { compileZone, intlOffsetSeconds, markedMigrationSql, type TzTable } from "@kedge/core";
|
|
7
|
+
|
|
8
|
+
/** Rows per INSERT: well under D1's statement size limit. */
|
|
9
|
+
const ROWS_PER_INSERT = 500;
|
|
10
|
+
|
|
11
|
+
/** The zone's transitions from 1970 to 9999 under this toolchain's IANA data. */
|
|
12
|
+
export function compileStoreZone(zone: string): TzTable {
|
|
13
|
+
const version = process.versions.tz;
|
|
14
|
+
if (!version) throw new Error("This Node has no IANA tz data version (process.versions.tz)");
|
|
15
|
+
try {
|
|
16
|
+
new Intl.DateTimeFormat("en-US", { timeZone: zone });
|
|
17
|
+
} catch {
|
|
18
|
+
throw new Error(`kedge.config.ts: "${zone}" isn't an IANA zone this toolchain knows`);
|
|
19
|
+
}
|
|
20
|
+
return compileZone(zone, version, intlOffsetSeconds(zone), {
|
|
21
|
+
from: 0,
|
|
22
|
+
to: Date.UTC(9999, 11, 31, 23, 59, 59),
|
|
23
|
+
// Historical rules can change twice within a week; future ones are seasonal.
|
|
24
|
+
denseUntil: Date.UTC(2050, 0, 1),
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const sqlText = (s: string) => `'${s.replaceAll("'", "''")}'`;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The migration that pins the zone: refused once any dated row recorded the
|
|
32
|
+
* previous rules (a booking's labels would change under it), then the rows,
|
|
33
|
+
* then store_time.
|
|
34
|
+
*/
|
|
35
|
+
export function tzMigrationSql(tz: TzTable): string {
|
|
36
|
+
const v = sqlText(tz.version);
|
|
37
|
+
const rows: [number, number][] = [
|
|
38
|
+
[0, tz.initialOffset],
|
|
39
|
+
...tz.at.map((t, i) => [t, tz.offsets[i] as number] as [number, number]),
|
|
40
|
+
];
|
|
41
|
+
const inserts: string[] = [];
|
|
42
|
+
for (let i = 0; i < rows.length; i += ROWS_PER_INSERT) {
|
|
43
|
+
const chunk = rows.slice(i, i + ROWS_PER_INSERT).map(([at, off]) => `(${v}, ${at}, ${off})`);
|
|
44
|
+
inserts.push(`INSERT INTO tz_transition (version, at_utc, offset_seconds) VALUES ${chunk.join(", ")};`);
|
|
45
|
+
}
|
|
46
|
+
return [
|
|
47
|
+
`-- ${tz.zone} at IANA ${tz.version}, pinned by kedge generate (p0.4 §4). A store's zone and
|
|
48
|
+
-- rules are fixed once any dated row exists: the guard refuses this migration then.
|
|
49
|
+
CREATE TABLE _kedge_tz_guard (dated integer NOT NULL CONSTRAINT kedge_tz_pinned CHECK (dated = 0));`,
|
|
50
|
+
`INSERT INTO _kedge_tz_guard (dated)
|
|
51
|
+
SELECT (SELECT count(*) FROM (SELECT 1 FROM capacity_pool LIMIT 1))
|
|
52
|
+
+ (SELECT count(*) FROM (SELECT 1 FROM capacity_adjustment LIMIT 1))
|
|
53
|
+
+ (SELECT count(*) FROM (SELECT 1 FROM order_line_period_provenance WHERE source = 'wall_time' LIMIT 1));`,
|
|
54
|
+
"DROP TABLE _kedge_tz_guard;",
|
|
55
|
+
// tz_transition and store_time are fenced core tables (p0.5 §7.1).
|
|
56
|
+
markedMigrationSql("migration:tz", [
|
|
57
|
+
...inserts,
|
|
58
|
+
`UPDATE store_time SET zone = ${sqlText(tz.zone)}, tz_rules_version = ${v} WHERE id = 1;`,
|
|
59
|
+
]),
|
|
60
|
+
].join("\n--> statement-breakpoint\n");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** src/kedge.tz.ts: the same table, for createKedge(config, { tz }). */
|
|
64
|
+
export function tzModule(tz: TzTable): string {
|
|
65
|
+
const what =
|
|
66
|
+
tz.version === "utc" ? "UTC, the store's default until a zone is configured" : `${tz.zone} at IANA ${tz.version}`;
|
|
67
|
+
return `// Generated by kedge generate: ${what}, pinned (p0.4 §4).
|
|
68
|
+
// Never edit: the database holds the same rows, and the checkout claim compares versions.
|
|
69
|
+
import type { TzTable } from "@kedge/runtime-cf";
|
|
70
|
+
|
|
71
|
+
export const tz: TzTable = {
|
|
72
|
+
zone: ${JSON.stringify(tz.zone)},
|
|
73
|
+
version: ${JSON.stringify(tz.version)},
|
|
74
|
+
initialOffset: ${tz.initialOffset},
|
|
75
|
+
at: [${tz.at.join(",")}],
|
|
76
|
+
offsets: [${tz.offsets.join(",")}],
|
|
77
|
+
};
|
|
78
|
+
`;
|
|
79
|
+
}
|
package/src/units.ts
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// `kedge eject` and doctor's `unit-drift` (docs/proposals/p0.8-cli-agent-kit.md §7):
|
|
2
|
+
// one unit of core copied into the store with the upstream's hash recorded,
|
|
3
|
+
// and, when an upgrade changes that upstream, a failing check that prints the
|
|
4
|
+
// diff. Merging is by hand, then `kedge eject --rebase`: overwriting a
|
|
5
|
+
// customised copy is how upstream changes get lost (research/14 §1).
|
|
6
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import type { KedgeConfig } from "@kedge/core";
|
|
9
|
+
import type { CheckResult } from "./doctor";
|
|
10
|
+
import { installedPackages, type Manifest } from "./manifest";
|
|
11
|
+
import { sha256 } from "./store";
|
|
12
|
+
|
|
13
|
+
export interface EjectHeader {
|
|
14
|
+
unit: string;
|
|
15
|
+
version: string;
|
|
16
|
+
upstreamSha256: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const HEADER = /^\/\/ kedge-eject: (\{.*\})$/m;
|
|
20
|
+
const overridesDir = (cwd: string) => join(cwd, "src", "overrides");
|
|
21
|
+
const origFile = (cwd: string, unit: string) => join(cwd, ".kedge", "eject", `${unit}.orig.ts`);
|
|
22
|
+
|
|
23
|
+
/** The installed upstream source of a unit, by the manifest's package and path. */
|
|
24
|
+
export function upstreamSource(cwd: string, manifest: Manifest, unit: string): string {
|
|
25
|
+
const u = manifest.units[unit];
|
|
26
|
+
if (!u)
|
|
27
|
+
throw new Error(
|
|
28
|
+
`"${unit}" isn't an ejectable unit of Kedge ${manifest.version}: ${Object.keys(manifest.units).join(", ") || "none"}`,
|
|
29
|
+
);
|
|
30
|
+
const dir = installedPackages(cwd).get(u.package);
|
|
31
|
+
if (!dir) throw new Error(`${u.package} isn't installed`);
|
|
32
|
+
return readFileSync(join(dir, u.path), "utf8");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function header(text: string): EjectHeader | null {
|
|
36
|
+
const m = HEADER.exec(text);
|
|
37
|
+
if (!m) return null;
|
|
38
|
+
try {
|
|
39
|
+
return JSON.parse(m[1] as string) as EjectHeader;
|
|
40
|
+
} catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const identifier = (unit: string) => unit.replace(/[.-](\w)/g, (_, c: string) => c.toUpperCase());
|
|
46
|
+
|
|
47
|
+
export function eject(cwd: string, manifest: Manifest, unit: string, reason: string): { file: string; wiring: string } {
|
|
48
|
+
if (!reason.trim())
|
|
49
|
+
throw new Error('kedge eject needs --reason "<why>": a higher rung couldn\'t do this, and the header says why');
|
|
50
|
+
const source = upstreamSource(cwd, manifest, unit);
|
|
51
|
+
const file = join(overridesDir(cwd), `${unit}.ts`);
|
|
52
|
+
if (existsSync(file))
|
|
53
|
+
throw new Error(`${unit} is already ejected; after merging an upstream change, kedge eject --rebase ${unit}`);
|
|
54
|
+
const h: EjectHeader = { unit, version: manifest.version, upstreamSha256: sha256(source) };
|
|
55
|
+
mkdirSync(overridesDir(cwd), { recursive: true });
|
|
56
|
+
writeFileSync(file, `// kedge-eject: ${JSON.stringify(h)}\n// Why: ${reason.replace(/\n/g, " ")}\n${source}`);
|
|
57
|
+
mkdirSync(join(cwd, ".kedge", "eject"), { recursive: true });
|
|
58
|
+
writeFileSync(origFile(cwd, unit), source);
|
|
59
|
+
const id = identifier(unit);
|
|
60
|
+
return {
|
|
61
|
+
file,
|
|
62
|
+
wiring: `import ${id} from "./src/overrides/${unit}";\n// in defineConfig({ … }):\n overrides: { "${unit}": ${id} },`,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** After a hand merge: the recorded upstream becomes the installed one. */
|
|
67
|
+
export function rebase(cwd: string, manifest: Manifest, unit: string): void {
|
|
68
|
+
const file = join(overridesDir(cwd), `${unit}.ts`);
|
|
69
|
+
if (!existsSync(file)) throw new Error(`${unit} isn't ejected`);
|
|
70
|
+
const text = readFileSync(file, "utf8");
|
|
71
|
+
const h = header(text);
|
|
72
|
+
if (!h) throw new Error(`${file} has no kedge-eject header`);
|
|
73
|
+
const source = upstreamSource(cwd, manifest, unit);
|
|
74
|
+
const next: EjectHeader = { unit, version: manifest.version, upstreamSha256: sha256(source) };
|
|
75
|
+
writeFileSync(file, text.replace(HEADER, `// kedge-eject: ${JSON.stringify(next)}`));
|
|
76
|
+
writeFileSync(origFile(cwd, unit), source);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** A unified diff of two texts, by longest common subsequence of lines. */
|
|
80
|
+
export function unifiedDiff(a: string, b: string, aName: string, bName: string, context = 3): string {
|
|
81
|
+
const x = a.split("\n");
|
|
82
|
+
const y = b.split("\n");
|
|
83
|
+
const n = x.length;
|
|
84
|
+
const m = y.length;
|
|
85
|
+
const lcs: number[][] = Array.from({ length: n + 1 }, () => new Array<number>(m + 1).fill(0));
|
|
86
|
+
for (let i = n - 1; i >= 0; i--)
|
|
87
|
+
for (let j = m - 1; j >= 0; j--)
|
|
88
|
+
(lcs[i] as number[])[j] =
|
|
89
|
+
x[i] === y[j]
|
|
90
|
+
? ((lcs[i + 1] as number[])[j + 1] as number) + 1
|
|
91
|
+
: Math.max((lcs[i + 1] as number[])[j] as number, (lcs[i] as number[])[j + 1] as number);
|
|
92
|
+
const ops: { t: " " | "-" | "+"; s: string; ai: number; bi: number }[] = [];
|
|
93
|
+
let i = 0;
|
|
94
|
+
let j = 0;
|
|
95
|
+
while (i < n || j < m) {
|
|
96
|
+
if (i < n && j < m && x[i] === y[j]) ops.push({ t: " ", s: x[i++] as string, ai: i, bi: ++j });
|
|
97
|
+
// A deletion before an addition, as diff -u prints them.
|
|
98
|
+
else if (i < n && (j >= m || ((lcs[i + 1] as number[])[j] as number) >= ((lcs[i] as number[])[j + 1] as number)))
|
|
99
|
+
ops.push({ t: "-", s: x[i++] as string, ai: i, bi: j });
|
|
100
|
+
else ops.push({ t: "+", s: y[j++] as string, ai: i, bi: j });
|
|
101
|
+
}
|
|
102
|
+
const out = [`--- ${aName}`, `+++ ${bName}`];
|
|
103
|
+
let k = 0;
|
|
104
|
+
while (k < ops.length) {
|
|
105
|
+
if ((ops[k] as { t: string }).t === " ") {
|
|
106
|
+
k++;
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
const start = Math.max(0, k - context);
|
|
110
|
+
let end = k;
|
|
111
|
+
while (end < ops.length) {
|
|
112
|
+
let next = end;
|
|
113
|
+
while (next < ops.length && (ops[next] as { t: string }).t === " ") next++;
|
|
114
|
+
if (next >= ops.length || next - end > context * 2) break;
|
|
115
|
+
end = next + 1;
|
|
116
|
+
}
|
|
117
|
+
end = Math.min(ops.length, end + context);
|
|
118
|
+
const hunk = ops.slice(start, end);
|
|
119
|
+
out.push(`@@ -${hunk[0]?.ai ?? 1} +${hunk[0]?.bi ?? 1} @@`);
|
|
120
|
+
for (const o of hunk) out.push(`${o.t}${o.s}`);
|
|
121
|
+
k = end;
|
|
122
|
+
}
|
|
123
|
+
return out.join("\n");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The upgrade notes' entries that name a unit, from the installed @kedge/docs. */
|
|
127
|
+
function notesFor(cwd: string, unit: string): string[] {
|
|
128
|
+
const docs = installedPackages(cwd).get("@kedge/docs");
|
|
129
|
+
if (!docs || !existsSync(join(docs, "upgrading"))) return [];
|
|
130
|
+
const out: string[] = [];
|
|
131
|
+
for (const f of readdirSync(join(docs, "upgrading")).filter((f) => f.endsWith(".md"))) {
|
|
132
|
+
const text = readFileSync(join(docs, "upgrading", f), "utf8");
|
|
133
|
+
if (text.includes(`unit:${unit}`)) out.push(`upgrading/${f} names ${unit}`);
|
|
134
|
+
}
|
|
135
|
+
return out;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Doctor's `unit-drift` (§7.3). */
|
|
139
|
+
export function unitDrift(
|
|
140
|
+
cwd: string,
|
|
141
|
+
manifest: Manifest | null,
|
|
142
|
+
config: KedgeConfig | null,
|
|
143
|
+
o: { ignoreWiring?: boolean } = {},
|
|
144
|
+
): CheckResult {
|
|
145
|
+
const dir = overridesDir(cwd);
|
|
146
|
+
const files = existsSync(dir) ? readdirSync(dir).filter((f) => f.endsWith(".ts")) : [];
|
|
147
|
+
const wired = Object.keys(config?.overrides ?? {});
|
|
148
|
+
if (files.length === 0 && wired.length === 0) return { name: "unit-drift", status: "pass" };
|
|
149
|
+
if (!manifest) return { name: "unit-drift", status: "skip", message: "no release manifest (a workspace install)" };
|
|
150
|
+
const problems: string[] = [];
|
|
151
|
+
const seen = new Set<string>();
|
|
152
|
+
for (const f of files) {
|
|
153
|
+
const unit = f.slice(0, -3);
|
|
154
|
+
seen.add(unit);
|
|
155
|
+
const h = header(readFileSync(join(dir, f), "utf8"));
|
|
156
|
+
if (!h || h.unit !== unit) {
|
|
157
|
+
problems.push(
|
|
158
|
+
`src/overrides/${f} has no kedge-eject header naming ${unit}: eject with kedge eject, never by hand`,
|
|
159
|
+
);
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
const u = manifest.units[unit];
|
|
163
|
+
if (!u) {
|
|
164
|
+
problems.push(`${unit} isn't an ejectable unit of Kedge ${manifest.version}`);
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
if (!o.ignoreWiring && !wired.includes(unit))
|
|
168
|
+
problems.push(`${unit} is ejected but not wired: add it to overrides in kedge.config.ts`);
|
|
169
|
+
if (u.sha256 !== h.upstreamSha256) {
|
|
170
|
+
const orig = existsSync(origFile(cwd, unit)) ? readFileSync(origFile(cwd, unit), "utf8") : "";
|
|
171
|
+
const now = upstreamSource(cwd, manifest, unit);
|
|
172
|
+
problems.push(
|
|
173
|
+
`${unit} was ejected from ${h.version}, and Kedge ${manifest.version} changed it upstream: merge the change below by hand, then kedge eject --rebase ${unit}`,
|
|
174
|
+
...notesFor(cwd, unit),
|
|
175
|
+
...unifiedDiff(orig, now, `${unit} (${h.version})`, `${unit} (${manifest.version})`).split("\n"),
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (!o.ignoreWiring)
|
|
180
|
+
for (const w of wired)
|
|
181
|
+
if (!seen.has(w)) problems.push(`overrides wires ${w}, but src/overrides/${w}.ts doesn't exist`);
|
|
182
|
+
return problems.length
|
|
183
|
+
? { name: "unit-drift", status: "fail", message: "an ejected unit drifted from its upstream", details: problems }
|
|
184
|
+
: { name: "unit-drift", status: "pass", message: `${files.length} ejected unit${files.length === 1 ? "" : "s"}` };
|
|
185
|
+
}
|
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