@geonosis/release 1.1.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.
@@ -0,0 +1,941 @@
1
+ // src/config.ts
2
+ import { existsSync, readFileSync } from "fs";
3
+ import { resolve } from "path";
4
+ var CannotRun = class extends Error {
5
+ };
6
+ var DIALECTS = /* @__PURE__ */ new Set(["mikro-orm-ts", "postgres", "sqlite"]);
7
+ var isRecord = (value2) => typeof value2 === "object" && value2 !== null && !Array.isArray(value2);
8
+ var strings = (value2, where) => {
9
+ if (value2 === void 0) return [];
10
+ if (!Array.isArray(value2) || value2.some((one) => typeof one !== "string")) {
11
+ throw new CannotRun(`${where} must be a list of strings`);
12
+ }
13
+ return value2;
14
+ };
15
+ var oneDir = (value2, index) => {
16
+ if (!isRecord(value2)) throw new CannotRun(`release.migrations[${index}] must be an object`);
17
+ const dir = value2["dir"];
18
+ const dialect = value2["dialect"];
19
+ if (typeof dir !== "string" || dir === "") {
20
+ throw new CannotRun(`release.migrations[${index}].dir must name a directory`);
21
+ }
22
+ if (typeof dialect !== "string" || !DIALECTS.has(dialect)) {
23
+ throw new CannotRun(
24
+ `release.migrations[${index}].dialect must be one of ${[...DIALECTS].toSorted().join(", ")}`
25
+ );
26
+ }
27
+ const phases = strings(value2["phases"], `release.migrations[${index}].phases`);
28
+ for (const phase of phases) {
29
+ if (phase !== "up" && phase !== "down") {
30
+ throw new CannotRun(`release.migrations[${index}].phases may only hold "up" and "down"`);
31
+ }
32
+ }
33
+ const squawk = value2["squawk"];
34
+ const exclude = isRecord(squawk) ? strings(squawk["exclude"], `release.migrations[${index}].squawk.exclude`) : [];
35
+ return {
36
+ dialect,
37
+ dir,
38
+ // `down()` holds the drop in every MikroORM migration in the corpus this was built from, and a
39
+ // down that is never run against a live database cannot narrow a schema anyone is serving.
40
+ phases: phases.length === 0 ? ["up"] : phases,
41
+ squawk: { exclude }
42
+ };
43
+ };
44
+ var EMPTY = {
45
+ migrations: [],
46
+ proof: {},
47
+ secrets: [],
48
+ steps: [],
49
+ workers: [],
50
+ wrangler: []
51
+ };
52
+ var parseReleaseConfig = (raw) => {
53
+ if (!isRecord(raw)) return EMPTY;
54
+ const release = raw["release"];
55
+ if (!isRecord(release)) return EMPTY;
56
+ const migrations = release["migrations"];
57
+ if (migrations !== void 0 && !Array.isArray(migrations)) {
58
+ throw new CannotRun("release.migrations must be a list");
59
+ }
60
+ const proof = release["proof"];
61
+ return {
62
+ migrations: (migrations ?? []).map(oneDir),
63
+ proof: isRecord(proof) ? { mustAssertVersion: proof["mustAssertVersion"] !== false } : {},
64
+ secrets: strings(release["secrets"], "release.secrets"),
65
+ steps: strings(release["steps"], "release.steps"),
66
+ workers: strings(release["workers"], "release.workers"),
67
+ wrangler: strings(release["wrangler"], "release.wrangler"),
68
+ wranglerEnv: typeof release["wranglerEnv"] === "string" ? release["wranglerEnv"] : void 0
69
+ };
70
+ };
71
+ var readReleaseConfig = (root) => {
72
+ const path = resolve(root, "geonosis.json");
73
+ if (!existsSync(path)) return EMPTY;
74
+ try {
75
+ return parseReleaseConfig(JSON.parse(readFileSync(path, "utf8")));
76
+ } catch (error) {
77
+ if (error instanceof CannotRun) throw error;
78
+ throw new CannotRun(`${path} is not readable JSON: ${error.message}`);
79
+ }
80
+ };
81
+
82
+ // src/wrangler.ts
83
+ import { readFileSync as readFileSync2 } from "fs";
84
+ import { resolve as resolve2 } from "path";
85
+ var isRecord2 = (value2) => typeof value2 === "object" && value2 !== null && !Array.isArray(value2);
86
+ var parseJsonc = (source) => {
87
+ let out = "";
88
+ for (let index = 0; index < source.length; index += 1) {
89
+ const char = source[index] ?? "";
90
+ if (char === '"') {
91
+ const start = index;
92
+ index += 1;
93
+ for (; index < source.length; index += 1) {
94
+ if (source[index] === "\\") {
95
+ index += 1;
96
+ continue;
97
+ }
98
+ if (source[index] === '"') break;
99
+ }
100
+ out += source.slice(start, index + 1);
101
+ continue;
102
+ }
103
+ if (char === "/" && source[index + 1] === "/") {
104
+ const end = source.indexOf("\n", index);
105
+ index = end === -1 ? source.length : end - 1;
106
+ continue;
107
+ }
108
+ if (char === "/" && source[index + 1] === "*") {
109
+ const end = source.indexOf("*/", index + 2);
110
+ index = end === -1 ? source.length : end + 1;
111
+ continue;
112
+ }
113
+ out += char;
114
+ }
115
+ return JSON.parse(out.replaceAll(/,(\s*[\]}])/g, "$1"));
116
+ };
117
+ var literal = (text) => {
118
+ const value2 = text.trim();
119
+ if (value2.startsWith('"') || value2.startsWith("'")) return value2.slice(1, -1);
120
+ if (value2 === "true") return true;
121
+ if (value2 === "false") return false;
122
+ const number = Number(value2);
123
+ return Number.isNaN(number) ? value2 : number;
124
+ };
125
+ var split = (text, delimiter) => {
126
+ const parts = [];
127
+ let depth = 0;
128
+ let quote = "";
129
+ let current = "";
130
+ for (const char of text) {
131
+ if (quote !== "") {
132
+ current += char;
133
+ if (char === quote) quote = "";
134
+ continue;
135
+ }
136
+ if (char === '"' || char === "'") quote = char;
137
+ if (char === "[" || char === "{") depth += 1;
138
+ if (char === "]" || char === "}") depth -= 1;
139
+ if (char === delimiter && depth === 0) {
140
+ parts.push(current);
141
+ current = "";
142
+ continue;
143
+ }
144
+ current += char;
145
+ }
146
+ parts.push(current);
147
+ return parts.filter((one) => one.trim() !== "");
148
+ };
149
+ var value = (text) => {
150
+ const trimmed = text.trim();
151
+ if (trimmed.startsWith("[")) return split(trimmed.slice(1, -1), ",").map(value);
152
+ if (trimmed.startsWith("{")) {
153
+ return Object.fromEntries(
154
+ split(trimmed.slice(1, -1), ",").map((pair) => {
155
+ const at = pair.indexOf("=");
156
+ return [pair.slice(0, at).trim(), value(pair.slice(at + 1))];
157
+ })
158
+ );
159
+ }
160
+ return literal(trimmed);
161
+ };
162
+ var put = (into, path, leaf) => {
163
+ let here = into;
164
+ for (const key of path.slice(0, -1)) {
165
+ if (!isRecord2(here[key])) here[key] = {};
166
+ here = here[key];
167
+ }
168
+ here[path.at(-1) ?? ""] = leaf;
169
+ };
170
+ var table = (into, path) => {
171
+ let here = into;
172
+ for (const key of path) {
173
+ if (!isRecord2(here[key])) here[key] = {};
174
+ here = here[key];
175
+ }
176
+ return here;
177
+ };
178
+ var arrayTable = (into, path) => {
179
+ let here = into;
180
+ for (const key of path.slice(0, -1)) {
181
+ if (!isRecord2(here[key])) here[key] = {};
182
+ here = here[key];
183
+ }
184
+ const last = path.at(-1) ?? "";
185
+ if (!Array.isArray(here[last])) here[last] = [];
186
+ const list = here[last];
187
+ const entry = {};
188
+ list.push(entry);
189
+ return entry;
190
+ };
191
+ var parseToml = (source) => {
192
+ const out = {};
193
+ let here = out;
194
+ const lines = source.split("\n");
195
+ for (let index = 0; index < lines.length; index += 1) {
196
+ const line = (lines[index] ?? "").split("#")[0]?.trim() ?? "";
197
+ if (line === "") continue;
198
+ if (line.startsWith("[[") && line.endsWith("]]")) {
199
+ here = arrayTable(out, line.slice(2, -2).trim().split("."));
200
+ continue;
201
+ }
202
+ if (line.startsWith("[") && line.endsWith("]")) {
203
+ here = table(out, line.slice(1, -1).trim().split("."));
204
+ continue;
205
+ }
206
+ const at = line.indexOf("=");
207
+ if (at === -1) continue;
208
+ let text = line.slice(at + 1);
209
+ while ([...text].filter((one) => one === "[").length > [...text].filter((one) => one === "]").length) {
210
+ index += 1;
211
+ if (index >= lines.length) throw new CannotRun("an unterminated array in the TOML config");
212
+ text += `
213
+ ${(lines[index] ?? "").split("#")[0] ?? ""}`;
214
+ }
215
+ put(here, line.slice(0, at).trim().split("."), value(text));
216
+ }
217
+ return out;
218
+ };
219
+ var BINDING_LISTS = [
220
+ "ai",
221
+ "analytics_engine_datasets",
222
+ "browser",
223
+ "d1_databases",
224
+ "dispatch_namespaces",
225
+ "durable_objects",
226
+ "hyperdrive",
227
+ "kv_namespaces",
228
+ "mtls_certificates",
229
+ "queues",
230
+ "r2_buckets",
231
+ "send_email",
232
+ "services",
233
+ "vectorize",
234
+ "version_metadata",
235
+ "workflows"
236
+ ];
237
+ var bindingsOf = (found) => {
238
+ if (Array.isArray(found)) return found.flatMap(bindingsOf);
239
+ if (!isRecord2(found)) return [];
240
+ const named = found["binding"] ?? found["name"];
241
+ const here = typeof named === "string" ? [named] : [];
242
+ const nested = Object.entries(found).filter(([key]) => key === "bindings" || key === "producers" || key === "consumers").flatMap(([, one]) => bindingsOf(one));
243
+ return [...here, ...nested];
244
+ };
245
+ var declaredIn = (config) => {
246
+ const triggers = config["triggers"];
247
+ const crons = isRecord2(triggers) && Array.isArray(triggers["crons"]) ? triggers["crons"] : [];
248
+ const routes = config["routes"];
249
+ const one = config["route"];
250
+ const listed = [
251
+ ...Array.isArray(routes) ? routes : [],
252
+ ...typeof one === "string" ? [one] : []
253
+ ];
254
+ return {
255
+ bindings: BINDING_LISTS.flatMap((key) => bindingsOf(config[key])).toSorted(),
256
+ crons: crons.filter((cron) => typeof cron === "string").toSorted(),
257
+ routes: listed.map((route) => isRecord2(route) ? route["pattern"] : route).filter((pattern) => typeof pattern === "string").toSorted()
258
+ };
259
+ };
260
+ var readWrangler = (root, relative, env) => {
261
+ const path = resolve2(root, relative);
262
+ let parsed;
263
+ try {
264
+ const source = readFileSync2(path, "utf8");
265
+ parsed = relative.endsWith(".toml") ? parseToml(source) : parseJsonc(source);
266
+ } catch (error) {
267
+ throw new CannotRun(`${relative} could not be read: ${error.message}`);
268
+ }
269
+ if (!isRecord2(parsed)) throw new CannotRun(`${relative} is not a wrangler configuration`);
270
+ const environments = parsed["env"];
271
+ const block = env !== void 0 && isRecord2(environments) && isRecord2(environments[env]) ? environments[env] : parsed;
272
+ return declaredIn(block);
273
+ };
274
+
275
+ // src/deployed.ts
276
+ import { existsSync as existsSync2, readFileSync as readFileSync3 } from "fs";
277
+ import { resolve as resolve3 } from "path";
278
+ var DEPLOYED_FILE = ".geonosis/deployed.json";
279
+ var NOTHING_SAYS = "nothing here says what is deployed \u2014 .geonosis/deployed.json is written by the pipeline after promote, and its absence is not a pass";
280
+ var listOf = (found) => Array.isArray(found) ? found.filter((one) => typeof one === "string") : [];
281
+ var isRecord3 = (value2) => typeof value2 === "object" && value2 !== null && !Array.isArray(value2);
282
+ var readDeployed = (root) => {
283
+ const path = resolve3(root, DEPLOYED_FILE);
284
+ if (!existsSync2(path)) throw new CannotRun(NOTHING_SAYS);
285
+ let parsed;
286
+ try {
287
+ parsed = JSON.parse(readFileSync3(path, "utf8"));
288
+ } catch (error) {
289
+ throw new CannotRun(`${DEPLOYED_FILE} is not readable JSON: ${error.message}`);
290
+ }
291
+ if (!isRecord3(parsed)) throw new CannotRun(`${DEPLOYED_FILE} is not an object`);
292
+ const triggers = isRecord3(parsed["triggers"]) ? parsed["triggers"] : {};
293
+ return {
294
+ ...typeof parsed["at"] === "string" ? { at: parsed["at"] } : {},
295
+ deployed: {
296
+ bindings: listOf(parsed["bindings"]),
297
+ crons: listOf(triggers["crons"]),
298
+ routes: listOf(triggers["routes"]),
299
+ secrets: listOf(parsed["secrets"])
300
+ }
301
+ };
302
+ };
303
+ var missingBetween = (kind, declared, deployed) => {
304
+ const missing = declared.filter((one) => !deployed.includes(one));
305
+ const extra = deployed.filter((one) => !declared.includes(one));
306
+ return missing.length === 0 && extra.length === 0 ? [] : [{ extra, kind, missing }];
307
+ };
308
+ var driftBetween = (declared, deployed) => [
309
+ ...missingBetween("crons", declared.crons, deployed.crons),
310
+ ...missingBetween("routes", declared.routes, deployed.routes),
311
+ ...missingBetween("bindings", declared.bindings, deployed.bindings),
312
+ ...missingBetween("secrets", declared.secrets, deployed.secrets)
313
+ ];
314
+ var runDeployed = (input) => {
315
+ const config = readReleaseConfig(input.root);
316
+ const configs = config.wrangler ?? [];
317
+ const secrets = config.secrets ?? [];
318
+ if (configs.length === 0 && secrets.length === 0) {
319
+ throw new CannotRun(
320
+ "geonosis.json names no release.wrangler configs and no release.secrets \u2014 nothing here declares what a deployment is supposed to carry"
321
+ );
322
+ }
323
+ const found = configs.map((one) => readWrangler(input.root, one, config.wranglerEnv));
324
+ const declared = {
325
+ bindings: [...new Set(found.flatMap((one) => one.bindings))].toSorted(),
326
+ crons: [...new Set(found.flatMap((one) => one.crons))].toSorted(),
327
+ routes: [...new Set(found.flatMap((one) => one.routes))].toSorted(),
328
+ secrets: [...secrets].toSorted()
329
+ };
330
+ const { at, deployed } = readDeployed(input.root);
331
+ const drift = driftBetween(declared, deployed);
332
+ return { ...at === void 0 ? {} : { at }, drift, ok: drift.length === 0 };
333
+ };
334
+ var formatDeployed = (report) => {
335
+ if (report.ok) {
336
+ return `OK deployed: what the tree declares is what the pipeline reported${report.at === void 0 ? "" : ` at ${report.at}`}
337
+ `;
338
+ }
339
+ return report.drift.flatMap((one) => [
340
+ ...one.missing.length === 0 ? [] : [` declared but not deployed ${one.kind}: ${one.missing.join(", ")}
341
+ `],
342
+ ...one.extra.length === 0 ? [] : [` deployed but not declared ${one.kind}: ${one.extra.join(", ")}
343
+ `]
344
+ ]).join("");
345
+ };
346
+
347
+ // src/added.ts
348
+ import { execFileSync, spawnSync } from "child_process";
349
+ var git = (root, args) => {
350
+ try {
351
+ return execFileSync("git", [...args], { cwd: root, encoding: "utf8", stdio: "pipe" });
352
+ } catch (error) {
353
+ throw new CannotRun(`git ${args.join(" ")} failed: ${(error.message ?? "").trim()}`);
354
+ }
355
+ };
356
+ var addedSince = (root, since) => git(root, ["diff", "--name-only", "--diff-filter=A", `${since}..HEAD`]).split("\n").filter((line) => line !== "");
357
+ var gitOk = (root, args) => {
358
+ const run = spawnSync("git", [...args], { cwd: root, encoding: "utf8" });
359
+ return run.error === void 0 && run.status === 0;
360
+ };
361
+
362
+ // src/marker.ts
363
+ var MARKER = /^[^\S\n]*(?:--|\/\/)[^\S\n]*contract-migration:[^\S\n]*(.*)$/gim;
364
+ var SINCE = /(?:^|\s)since:(\S+)/;
365
+ var markersIn = (source) => {
366
+ const out = [];
367
+ MARKER.lastIndex = 0;
368
+ for (let found = MARKER.exec(source); found !== null; found = MARKER.exec(source)) {
369
+ const tail = found[1] ?? "";
370
+ const since = SINCE.exec(tail)?.[1];
371
+ out.push({
372
+ line: source.slice(0, found.index).split("\n").length,
373
+ reason: (since === void 0 ? tail : tail.replace(SINCE, " ")).trim(),
374
+ ...since === void 0 ? {} : { since }
375
+ });
376
+ }
377
+ return out;
378
+ };
379
+ var judge = (marker, root, base) => {
380
+ if (marker.reason === "") {
381
+ return "the marker gives no reason \u2014 a marker with nothing after the colon is a comment";
382
+ }
383
+ if (marker.since === void 0) {
384
+ return "the marker has no since:<ref> \u2014 an expand nobody can date excuses nothing";
385
+ }
386
+ if (!gitOk(root, ["rev-parse", "--verify", "--quiet", `${marker.since}^{commit}`])) {
387
+ return `since:${marker.since} is a ref git does not know`;
388
+ }
389
+ if (!gitOk(root, ["merge-base", "--is-ancestor", marker.since, base])) {
390
+ return `since:${marker.since} is not an ancestor of ${base} \u2014 the expand it names is inside this release, so nothing has drained`;
391
+ }
392
+ return void 0;
393
+ };
394
+
395
+ // src/mikro.ts
396
+ var Unreadable = class extends Error {
397
+ };
398
+ var isSpace = (char) => char === " " || char === " " || char === "\n" || char === "\r";
399
+ var advance = (source, at, to) => {
400
+ for (let index = at.index; index < to; index += 1) {
401
+ if (source[index] === "\n") at.line += 1;
402
+ }
403
+ at.index = to;
404
+ };
405
+ var skipTrivia = (source, at) => {
406
+ for (; ; ) {
407
+ while (at.index < source.length && isSpace(source[at.index] ?? "")) {
408
+ if (source[at.index] === "\n") at.line += 1;
409
+ at.index += 1;
410
+ }
411
+ if (source.startsWith("//", at.index)) {
412
+ const end = source.indexOf("\n", at.index);
413
+ advance(source, at, end === -1 ? source.length : end);
414
+ continue;
415
+ }
416
+ if (source.startsWith("/*", at.index)) {
417
+ const end = source.indexOf("*/", at.index + 2);
418
+ if (end === -1) throw new Unreadable("an unterminated block comment");
419
+ advance(source, at, end + 2);
420
+ continue;
421
+ }
422
+ return;
423
+ }
424
+ };
425
+ var readLiteral = (source, at) => {
426
+ const quote = source[at.index];
427
+ if (quote !== "`" && quote !== "'" && quote !== '"') {
428
+ throw new Unreadable(`an argument that is not a string literal: ${nameOf(source, at.index)}`);
429
+ }
430
+ let out = "";
431
+ let index = at.index + 1;
432
+ for (; index < source.length; index += 1) {
433
+ const char = source[index] ?? "";
434
+ if (char === "\\") {
435
+ out += source[index + 1] ?? "";
436
+ index += 1;
437
+ continue;
438
+ }
439
+ if (quote === "`" && char === "$" && source[index + 1] === "{") {
440
+ throw new Unreadable("a template literal with an interpolation");
441
+ }
442
+ if (char === quote) {
443
+ advance(source, at, index + 1);
444
+ return out;
445
+ }
446
+ out += char;
447
+ }
448
+ throw new Unreadable("an unterminated string literal");
449
+ };
450
+ var nameOf = (source, index) => {
451
+ const rest = source.slice(index);
452
+ const end = rest.search(/[),\n]/);
453
+ return (end === -1 ? rest : rest.slice(0, end)).trim();
454
+ };
455
+ var addSqlIn = (source, phases) => {
456
+ const out = [];
457
+ for (const phase of phases) {
458
+ const body = phaseBody(source, phase);
459
+ if (body === void 0) continue;
460
+ const at = { index: 0, line: body.line };
461
+ const calls = /(?:^|[^\w$.])(?:this\.)?addSql\s*\(/g;
462
+ for (let found = calls.exec(body.text); found !== null; found = calls.exec(body.text)) {
463
+ advance(body.text, at, found.index + found[0].length);
464
+ skipTrivia(body.text, at);
465
+ const line = at.line;
466
+ let sql = readLiteral(body.text, at);
467
+ for (; ; ) {
468
+ skipTrivia(body.text, at);
469
+ if (body.text[at.index] !== "+") break;
470
+ advance(body.text, at, at.index + 1);
471
+ skipTrivia(body.text, at);
472
+ sql += readLiteral(body.text, at);
473
+ }
474
+ out.push({ line, sql });
475
+ calls.lastIndex = at.index;
476
+ }
477
+ }
478
+ return out.toSorted((a, b) => a.line - b.line);
479
+ };
480
+ var phaseBody = (source, phase) => {
481
+ const blanked = blank(source);
482
+ const opener = new RegExp(`\\b${phase}\\s*\\([^)]*\\)[^{;]*\\{`);
483
+ const found = opener.exec(blanked);
484
+ if (found === null) return void 0;
485
+ const open = found.index + found[0].length - 1;
486
+ let depth = 0;
487
+ for (let index = open; index < blanked.length; index += 1) {
488
+ if (blanked[index] === "{") depth += 1;
489
+ if (blanked[index] === "}") {
490
+ depth -= 1;
491
+ if (depth === 0) {
492
+ return {
493
+ line: source.slice(0, open + 1).split("\n").length,
494
+ text: source.slice(open + 1, index)
495
+ };
496
+ }
497
+ }
498
+ }
499
+ throw new Unreadable(`an unbalanced ${phase}() body`);
500
+ };
501
+ var blank = (source) => {
502
+ const out = [...source];
503
+ const hide = (from, to) => {
504
+ for (let index = from; index < to && index < out.length; index += 1) {
505
+ if (out[index] !== "\n") out[index] = " ";
506
+ }
507
+ };
508
+ for (let index = 0; index < source.length; index += 1) {
509
+ const char = source[index] ?? "";
510
+ if (char === "/" && source[index + 1] === "/") {
511
+ const end2 = source.indexOf("\n", index);
512
+ const stop = end2 === -1 ? source.length : end2;
513
+ hide(index, stop);
514
+ index = stop;
515
+ continue;
516
+ }
517
+ if (char === "/" && source[index + 1] === "*") {
518
+ const end2 = source.indexOf("*/", index + 2);
519
+ const stop = end2 === -1 ? source.length : end2 + 2;
520
+ hide(index, stop);
521
+ index = stop - 1;
522
+ continue;
523
+ }
524
+ if (char !== "`" && char !== "'" && char !== '"') continue;
525
+ let end = index + 1;
526
+ for (; end < source.length; end += 1) {
527
+ if (source[end] === "\\") {
528
+ end += 1;
529
+ continue;
530
+ }
531
+ if (source[end] === char) break;
532
+ }
533
+ hide(index + 1, end);
534
+ index = end;
535
+ }
536
+ return out.join("");
537
+ };
538
+
539
+ // src/sql.ts
540
+ var NARROWING = [
541
+ ["DROP TABLE", /\bDROP\s+TABLE\b/i],
542
+ ["DROP COLUMN", /\bDROP\s+COLUMN\b/i],
543
+ ["RENAME", /\bRENAME\b/i],
544
+ ["ALTER COLUMN \u2026 TYPE", /\bALTER\s+COLUMN\b[^;]*\bTYPE\b/i],
545
+ ["SET NOT NULL", /\bSET\s+NOT\s+NULL\b/i]
546
+ ];
547
+ var statementsOf = (sql) => sql.replaceAll(/'[^']*'/g, (found) => `'${found.slice(1, -1).replaceAll(/[^\n]/g, " ")}'`);
548
+ var lineOf = (body, index) => body.slice(0, index).split("\n").length;
549
+ var refusalsIn = (path, body, firstLine = 1) => {
550
+ const stripped = statementsOf(body);
551
+ return NARROWING.flatMap(([verb, pattern]) => {
552
+ const found = pattern.exec(stripped);
553
+ return found === null ? [] : [{ line: lineOf(stripped, found.index) + firstLine - 1, path, verb }];
554
+ });
555
+ };
556
+
557
+ // src/migrations.ts
558
+ import { mkdtempSync, readFileSync as readFileSync4, rmSync, writeFileSync } from "fs";
559
+ import { tmpdir } from "os";
560
+ import { basename, join, resolve as resolve5 } from "path";
561
+
562
+ // src/squawk.ts
563
+ import { spawnSync as spawnSync2 } from "child_process";
564
+ import { existsSync as existsSync3 } from "fs";
565
+ import { dirname, resolve as resolve4 } from "path";
566
+ import { fileURLToPath } from "url";
567
+ var HERE = dirname(fileURLToPath(import.meta.url));
568
+ var findSquawk = (from = HERE) => {
569
+ for (let dir = from; ; dir = dirname(dir)) {
570
+ const candidate = resolve4(dir, "node_modules/.bin/squawk");
571
+ if (existsSync3(candidate)) return candidate;
572
+ if (dirname(dir) === dir) break;
573
+ }
574
+ throw new CannotRun(
575
+ "squawk-cli is not installed beside this package \u2014 the Postgres dialect cannot be measured, and a gate that cannot measure has not passed"
576
+ );
577
+ };
578
+ var squawkOn = (input) => {
579
+ const { cwd, exclude, files } = input;
580
+ if (files.length === 0) return { findings: "", ok: true };
581
+ const run = spawnSync2(
582
+ input.bin ?? findSquawk(),
583
+ [...exclude.length === 0 ? [] : [`--exclude=${exclude.join(",")}`], ...files],
584
+ { cwd, encoding: "utf8" }
585
+ );
586
+ if (run.error !== void 0) {
587
+ throw new CannotRun(`squawk could not be started: ${run.error.message}`);
588
+ }
589
+ const findings = `${run.stdout}${run.stderr}`;
590
+ if (run.status === 0) return { findings, ok: true };
591
+ if (run.status === 1) return { findings, ok: false };
592
+ throw new CannotRun(
593
+ `squawk exited ${String(run.status)}, which is neither clean nor findings:
594
+ ${findings}`
595
+ );
596
+ };
597
+
598
+ // src/migrations.ts
599
+ var EXTENSION = {
600
+ "mikro-orm-ts": ".ts",
601
+ postgres: ".sql",
602
+ sqlite: ".sql"
603
+ };
604
+ var under = (dir, file) => file === dir || file.startsWith(dir.endsWith("/") ? dir : `${dir}/`);
605
+ var fromTypeScript = (path, source, entry) => {
606
+ try {
607
+ return addSqlIn(source, entry.phases ?? ["up"]).flatMap(
608
+ (one) => refusalsIn(path, one.sql, one.line)
609
+ );
610
+ } catch (error) {
611
+ if (error instanceof Unreadable) {
612
+ throw new CannotRun(`${path} holds ${error.message} \u2014 this reader cannot read it`);
613
+ }
614
+ throw error;
615
+ }
616
+ };
617
+ var sqlFor = (path, source, entry) => entry.dialect === "mikro-orm-ts" ? addSqlIn(source, entry.phases ?? ["up"]).map((one) => one.sql.trim()).map((one) => one.endsWith(";") ? one : `${one};`).join("\n") : source;
618
+ var runMigrations = (input) => {
619
+ const config = readReleaseConfig(input.root);
620
+ const declared = config.migrations ?? [];
621
+ if (declared.length === 0) {
622
+ throw new CannotRun(
623
+ "geonosis.json names no release.migrations \u2014 nothing here says which directories hold migrations, or in which dialect"
624
+ );
625
+ }
626
+ const entries = input.dialect === void 0 ? declared : declared.filter((one) => one.dialect === input.dialect);
627
+ if (entries.length === 0) {
628
+ throw new CannotRun(`release.migrations names no directory with dialect "${input.dialect}"`);
629
+ }
630
+ const matched = addedSince(input.root, input.since).flatMap((file) => {
631
+ const entry = entries.find(
632
+ (one) => under(one.dir, file) && file.endsWith(EXTENSION[one.dialect])
633
+ );
634
+ return entry === void 0 ? [] : [{ entry, file }];
635
+ });
636
+ const refusals = [];
637
+ const markers = [];
638
+ const squawk = [];
639
+ const forSquawk = [];
640
+ for (const { entry, file } of matched) {
641
+ const source = readFileSync4(resolve5(input.root, file), "utf8");
642
+ const found = markersIn(source);
643
+ const bad = found.flatMap((one) => {
644
+ const why = judge(one, input.root, input.since);
645
+ return why === void 0 ? [] : [{ line: one.line, path: file, why }];
646
+ });
647
+ markers.push(...bad);
648
+ const excused = found.length > 0 && bad.length === 0;
649
+ if (!excused) {
650
+ refusals.push(
651
+ ...entry.dialect === "mikro-orm-ts" ? fromTypeScript(file, source, entry) : refusalsIn(file, source)
652
+ );
653
+ }
654
+ if (entry.dialect !== "sqlite")
655
+ forSquawk.push({ entry, file, sql: sqlFor(file, source, entry) });
656
+ }
657
+ let clean = true;
658
+ if (forSquawk.length > 0) {
659
+ const staging = mkdtempSync(join(tmpdir(), "geonosis-release-squawk-"));
660
+ try {
661
+ for (const one of forSquawk) {
662
+ const path = one.entry.dialect === "mikro-orm-ts" ? join(staging, `${basename(one.file, ".ts")}.sql`) : resolve5(input.root, one.file);
663
+ if (one.entry.dialect === "mikro-orm-ts") writeFileSync(path, `${one.sql}
664
+ `);
665
+ const found = squawkOn({
666
+ cwd: input.root,
667
+ exclude: one.entry.squawk?.exclude ?? [],
668
+ files: [path]
669
+ });
670
+ if (!found.ok) {
671
+ clean = false;
672
+ squawk.push({ findings: found.findings, path: one.file });
673
+ }
674
+ }
675
+ } finally {
676
+ rmSync(staging, { force: true, recursive: true });
677
+ }
678
+ }
679
+ return {
680
+ linted: forSquawk.length,
681
+ markers: markers.toSorted((a, b) => a.path.localeCompare(b.path) || a.line - b.line),
682
+ ok: refusals.length === 0 && markers.length === 0 && clean,
683
+ read: matched.length,
684
+ refusals: refusals.toSorted(
685
+ (a, b) => a.path.localeCompare(b.path) || a.line - b.line || a.verb.localeCompare(b.verb)
686
+ ),
687
+ squawk
688
+ };
689
+ };
690
+ var WHY = `
691
+ A release may only WIDEN the schema. Migrations run against live databases while the previous
692
+ version is still serving, and no rollback undoes them. Contract in a later release, or say why it
693
+ is safe now:
694
+
695
+ -- contract-migration: <what expanded it, and why nothing reads it any more> since:<ref>
696
+ `;
697
+ var formatMigrations = (report) => {
698
+ if (report.ok) {
699
+ return `OK migrations: expand-only (${String(report.read)} read, ${String(report.linted)} linted by squawk)
700
+ `;
701
+ }
702
+ const said = report.markers.map((one) => ` ${one.path}: ${one.why} (line ${String(one.line)})
703
+ `);
704
+ const lines = report.refusals.map(
705
+ (one) => ` ${one.path}: ${one.verb} (line ${String(one.line)})
706
+ `
707
+ );
708
+ const found = report.squawk.map((one) => ` ${one.path}:
709
+ ${one.findings}
710
+ `);
711
+ const why = report.markers.length + report.refusals.length === 0 ? "" : WHY;
712
+ return `${said.join("")}${lines.join("")}${why}${found.join("")}`;
713
+ };
714
+
715
+ // src/plan.ts
716
+ var STEPS = ["upload", "park", "prove", "promote"];
717
+ var WHY_VERSION_IDENTITY = "An override that was not applied routes by the configured percentages, to the OLD version \u2014 so a smoke that does not assert which version answered can pass by testing the code it was replacing.";
718
+ var runPlan = (input) => {
719
+ const config = readReleaseConfig(input.root);
720
+ const steps = config.steps ?? [];
721
+ if (steps.length === 0) {
722
+ throw new CannotRun(
723
+ "geonosis.json names no release.steps \u2014 nothing here says what this repo does to put a version in front of a user"
724
+ );
725
+ }
726
+ const problems = [];
727
+ const prove2 = steps.indexOf("prove");
728
+ const promote = steps.indexOf("promote");
729
+ if (prove2 === -1 || promote === -1 || prove2 > promote) {
730
+ problems.push(
731
+ "prove must come before promote \u2014 a promote that has not been proved serves code nothing answered for"
732
+ );
733
+ }
734
+ if (steps.length !== STEPS.length || steps.some((step, at) => step !== STEPS[at])) {
735
+ problems.push(`release.steps must be exactly ${STEPS.join(" \u2192 ")}, and is ${steps.join(" \u2192 ")}`);
736
+ }
737
+ const mustAssert = config.proof?.mustAssertVersion !== false;
738
+ if (!mustAssert && !input.accept) {
739
+ problems.push(
740
+ `release.proof.mustAssertVersion is false. ${WHY_VERSION_IDENTITY} Pass --i-accept-an-unproven-smoke to say so on purpose.`
741
+ );
742
+ }
743
+ return {
744
+ accepted: !mustAssert && input.accept,
745
+ ok: problems.length === 0,
746
+ problems,
747
+ steps: [...steps]
748
+ };
749
+ };
750
+ var formatPlan = (report) => {
751
+ if (!report.ok) return `${report.problems.map((one) => ` ${one}
752
+ `).join("")}`;
753
+ const note = report.accepted ? ` the smoke need not say which version answered. ${WHY_VERSION_IDENTITY}
754
+ ` : " the smoke asserts the version that answered it\n";
755
+ return `OK release: ${report.steps.join(" \u2192 ")}
756
+ ${note}`;
757
+ };
758
+
759
+ // src/runner.ts
760
+ import { spawn } from "child_process";
761
+ var Runner = class {
762
+ buffered = "";
763
+ closed;
764
+ child;
765
+ waiting = [];
766
+ constructor(command, args, cwd) {
767
+ this.child = spawn(command, [...args], { cwd, stdio: ["pipe", "pipe", "pipe"] });
768
+ this.child.stdout.setEncoding("utf8");
769
+ this.child.stdout.on("data", (chunk) => {
770
+ this.buffered += chunk;
771
+ for (let at = this.buffered.indexOf("\n"); at !== -1; at = this.buffered.indexOf("\n")) {
772
+ const line = this.buffered.slice(0, at);
773
+ this.buffered = this.buffered.slice(at + 1);
774
+ this.waiting.shift()?.(line);
775
+ }
776
+ });
777
+ this.child.on("error", (error) => {
778
+ this.closed = error.message;
779
+ });
780
+ this.child.on("close", () => {
781
+ this.closed ??= "the runner exited without answering";
782
+ });
783
+ }
784
+ async ask(request, timeoutMs = 6e4) {
785
+ if (this.closed !== void 0) {
786
+ throw new CannotRun(`the runner is gone before "${request.step}": ${this.closed}`);
787
+ }
788
+ const line = await new Promise((resolve6, reject) => {
789
+ const timer = setTimeout(() => {
790
+ reject(
791
+ new CannotRun(
792
+ `the runner did not answer "${request.step}" within ${String(timeoutMs)}ms`
793
+ )
794
+ );
795
+ }, timeoutMs);
796
+ this.waiting.push((answer) => {
797
+ clearTimeout(timer);
798
+ resolve6(answer);
799
+ });
800
+ this.child.on("close", () => {
801
+ clearTimeout(timer);
802
+ reject(new CannotRun(`the runner closed before answering "${request.step}"`));
803
+ });
804
+ this.child.stdin.write(`${JSON.stringify(request)}
805
+ `);
806
+ });
807
+ try {
808
+ return JSON.parse(line);
809
+ } catch {
810
+ throw new CannotRun(
811
+ `the runner answered "${request.step}" with something that is not JSON: ${line}`
812
+ );
813
+ }
814
+ }
815
+ close() {
816
+ this.child.stdin.end();
817
+ this.child.kill();
818
+ }
819
+ };
820
+
821
+ // src/prove.ts
822
+ import { existsSync as existsSync4 } from "fs";
823
+ import { fileURLToPath as fileURLToPath2 } from "url";
824
+ var stringAt = (reply, key) => typeof reply[key] === "string" ? reply[key] : void 0;
825
+ var proveOver = async (input) => {
826
+ const runner = new Runner(input.command, input.args ?? [], input.cwd);
827
+ const steps = [];
828
+ const refuse = (why) => ({ ok: false, steps, why });
829
+ try {
830
+ steps.push("upload");
831
+ const uploaded = await runner.ask({ step: "upload" }, input.timeoutMs);
832
+ const versionId = stringAt(uploaded, "versionId");
833
+ if (versionId === void 0) {
834
+ return refuse("the runner\u2019s upload answered no versionId, so there is nothing to override to");
835
+ }
836
+ steps.push("smoke");
837
+ const smoked = await runner.ask({ step: "smoke", versionId }, input.timeoutMs);
838
+ if (smoked["ok"] !== true) return refuse(`the smoke of ${versionId} did not pass`);
839
+ const answered = stringAt(smoked, "answeredVersionId");
840
+ if (answered === void 0) {
841
+ return refuse(`the smoke of ${versionId} named no version. ${WHY_VERSION_IDENTITY}`);
842
+ }
843
+ if (answered !== versionId) {
844
+ return refuse(
845
+ `the smoke overrode to ${versionId} and ${answered} answered. ${WHY_VERSION_IDENTITY}`
846
+ );
847
+ }
848
+ steps.push("promote");
849
+ const promoted = await runner.ask({ step: "promote", versionId }, input.timeoutMs);
850
+ if (promoted["ok"] !== true) return refuse(`the promote of ${versionId} did not pass`);
851
+ const went = stringAt(promoted, "promotedVersionId");
852
+ if (went !== void 0 && went !== versionId) {
853
+ return refuse(`${versionId} was proved and ${went} was promoted \u2014 nothing proved ${went}`);
854
+ }
855
+ return { ok: true, steps };
856
+ } finally {
857
+ runner.close();
858
+ }
859
+ };
860
+ var PLANTS = [
861
+ {
862
+ name: "answered",
863
+ says: "a smoke that answered a version other than the one it overrode to is refused"
864
+ },
865
+ { name: "failed", says: "a smoke that did not pass is refused" },
866
+ { name: "promoted", says: "a promote of a version nothing proved is refused" }
867
+ ];
868
+ var stubPath = () => {
869
+ const path = fileURLToPath2(new URL("./stub-runner.js", import.meta.url));
870
+ if (!existsSync4(path)) throw new CannotRun(`${path} is missing \u2014 run pnpm build first`);
871
+ return path;
872
+ };
873
+ var prove = async (cwd, stub = stubPath()) => {
874
+ const lines = [];
875
+ let ok = true;
876
+ for (const plant of PLANTS) {
877
+ const verdict = await proveOver({
878
+ args: [stub, `--plant=${plant.name}`],
879
+ command: process.execPath,
880
+ cwd,
881
+ timeoutMs: 3e4
882
+ });
883
+ if (verdict.ok) ok = false;
884
+ lines.push(` ${verdict.ok ? "MISSED" : "PROVEN"} ${plant.says}`);
885
+ if (plant.name !== "promoted" && verdict.steps.includes("promote")) {
886
+ ok = false;
887
+ lines.push(" MISSED a refused smoke was followed by a promote request");
888
+ }
889
+ }
890
+ const honest = await proveOver({
891
+ args: [stub, "--plant=good"],
892
+ command: process.execPath,
893
+ cwd,
894
+ timeoutMs: 3e4
895
+ });
896
+ if (!honest.ok) ok = false;
897
+ lines.push(
898
+ ` ${honest.ok ? "PROVEN" : "MISSED"} a smoke that answered the version it overrode to is not refused`
899
+ );
900
+ return { lines, ok };
901
+ };
902
+ var formatProve = (outcome) => `${outcome.lines.join("\n")}
903
+
904
+ prove ${outcome.ok ? "PASS" : "FAIL"} \u2014 ${outcome.ok ? "every plant was refused, and the honest runner was not" : "a plant went through"}.
905
+ `;
906
+ var formatVerdict = (verdict) => verdict.ok ? `OK prove: ${verdict.steps.join(" \u2192 ")}
907
+ ` : ` REFUSED after ${verdict.steps.join(" \u2192 ")}: ${verdict.why ?? ""}
908
+ `;
909
+
910
+ export {
911
+ CannotRun,
912
+ parseReleaseConfig,
913
+ readReleaseConfig,
914
+ parseJsonc,
915
+ parseToml,
916
+ declaredIn,
917
+ readWrangler,
918
+ DEPLOYED_FILE,
919
+ NOTHING_SAYS,
920
+ readDeployed,
921
+ driftBetween,
922
+ runDeployed,
923
+ formatDeployed,
924
+ markersIn,
925
+ addSqlIn,
926
+ NARROWING,
927
+ statementsOf,
928
+ refusalsIn,
929
+ runMigrations,
930
+ formatMigrations,
931
+ STEPS,
932
+ WHY_VERSION_IDENTITY,
933
+ runPlan,
934
+ formatPlan,
935
+ Runner,
936
+ proveOver,
937
+ PLANTS,
938
+ prove,
939
+ formatProve,
940
+ formatVerdict
941
+ };