@moku-labs/ci 1.0.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,2497 @@
1
+ #!/usr/bin/env node
2
+ import { createInterface } from "node:readline";
3
+ import { existsSync, readFileSync } from "node:fs";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { execFile, spawn } from "node:child_process";
7
+ import { copyFile, mkdir, readFile, writeFile } from "node:fs/promises";
8
+ //#region node_modules/@moku-labs/common/dist/cli.mjs
9
+ /**
10
+ * @file `@moku-labs/common/cli` — TTY/`NO_COLOR`-aware ANSI color + box-drawing
11
+ * primitives: the shared "brand DNA" every Moku CLI renders through (the brand pink,
12
+ * the palette, the box/spinner/progress glyphs). Color and Unicode glyphs are emitted
13
+ * only on a real TTY with `NO_COLOR` unset; otherwise plain ASCII so CI logs and pipes
14
+ * stay readable. Pure: depends on nothing but `process.stdout`/`process.env` defaults,
15
+ * so a consuming framework can build its own panels on top without pulling in any UI lib.
16
+ */
17
+ /** The ANSI escape byte (ESC, `0x1b`), built so no literal control char is in source. */
18
+ const ESC = String.fromCodePoint(27);
19
+ /** ANSI SGR codes used by the brand renderer (each prefixed with the ESC byte). */
20
+ const ANSI = {
21
+ reset: `${ESC}[0m`,
22
+ bold: `${ESC}[1m`,
23
+ dim: `${ESC}[2m`,
24
+ red: `${ESC}[31m`,
25
+ green: `${ESC}[32m`,
26
+ yellow: `${ESC}[33m`,
27
+ blue: `${ESC}[34m`,
28
+ magenta: `${ESC}[35m`,
29
+ cyan: `${ESC}[36m`,
30
+ gray: `${ESC}[90m`
31
+ };
32
+ /**
33
+ * The Moku brand pink (`#FF1E6F`) as an RGB triple, used for 24-bit truecolor output.
34
+ * Degrades to {@link ANSI.magenta} on a 16-color TTY and to plain text off a TTY.
35
+ */
36
+ const BRAND_PINK = {
37
+ r: 255,
38
+ g: 30,
39
+ b: 111
40
+ };
41
+ /**
42
+ * Build a 24-bit (truecolor) SGR foreground escape for the given RGB triple.
43
+ *
44
+ * @param r - Red channel (0–255).
45
+ * @param g - Green channel (0–255).
46
+ * @param b - Blue channel (0–255).
47
+ * @returns The `ESC[38;2;r;g;bm` foreground sequence.
48
+ * @example
49
+ * fg24(255, 30, 111); // "\x1b[38;2;255;30;111m"
50
+ */
51
+ function fg24(r, g, b) {
52
+ return `${ESC}[38;2;${r};${g};${b}m`;
53
+ }
54
+ `${ESC}`;
55
+ `${ESC}`;
56
+ /** Unicode rounded box glyphs used when output is a color-capable TTY. */
57
+ const UNICODE_BOX = {
58
+ topLeft: "╭",
59
+ topRight: "╮",
60
+ bottomLeft: "╰",
61
+ bottomRight: "╯",
62
+ horizontal: "─",
63
+ vertical: "│"
64
+ };
65
+ /** ASCII box glyphs used when output is piped/CI (plain mode). */
66
+ const ASCII_BOX = {
67
+ topLeft: "+",
68
+ topRight: "+",
69
+ bottomLeft: "+",
70
+ bottomRight: "+",
71
+ horizontal: "-",
72
+ vertical: "|"
73
+ };
74
+ /**
75
+ * Matches every ANSI SGR escape sequence (used to measure visible width). Built from
76
+ * the {@link ESC} byte so no literal control character appears in the source regex.
77
+ */
78
+ const ANSI_PATTERN = new RegExp(String.raw`${ESC}\[[0-9;]*m`, "g");
79
+ /**
80
+ * Whether ANSI color/box glyphs should be emitted: a TTY stream with `NO_COLOR`
81
+ * unset. Reads `process.stdout.isTTY` and `process.env.NO_COLOR` by default so the
82
+ * renderer auto-degrades in CI and pipes.
83
+ *
84
+ * @param stream - Stream to probe for `isTTY` (defaults to `process.stdout`).
85
+ * @param noColor - The `NO_COLOR` value (defaults to `process.env.NO_COLOR`).
86
+ * @returns `true` when color should be used.
87
+ * @example
88
+ * supportsColor(); // true in an interactive terminal
89
+ */
90
+ function supportsColor(stream = process.stdout, noColor = process.env.NO_COLOR) {
91
+ return stream.isTTY === true && noColor === void 0;
92
+ }
93
+ /**
94
+ * Whether the terminal advertises 24-bit (truecolor) support via `COLORTERM`, so the
95
+ * renderer may emit the exact brand pink ({@link BRAND_PINK}) instead of the 16-color
96
+ * `magenta` approximation. Always layered on top of {@link supportsColor} — truecolor
97
+ * is never used when color itself is disabled.
98
+ *
99
+ * @param colorTerm - The `COLORTERM` value (defaults to `process.env.COLORTERM`).
100
+ * @returns `true` when `COLORTERM` is `truecolor` or `24bit`.
101
+ * @example
102
+ * supportsTruecolor("truecolor"); // true
103
+ */
104
+ function supportsTruecolor(colorTerm = process.env.COLORTERM) {
105
+ return colorTerm === "truecolor" || colorTerm === "24bit";
106
+ }
107
+ /**
108
+ * Select the box glyph set for the given color mode (Unicode on a TTY, ASCII off it).
109
+ *
110
+ * @param color - Whether color/Unicode output is enabled.
111
+ * @returns The matching {@link BoxGlyphs} set.
112
+ * @example
113
+ * const glyphs = boxGlyphs(supportsColor());
114
+ */
115
+ function boxGlyphs(color) {
116
+ return color ? UNICODE_BOX : ASCII_BOX;
117
+ }
118
+ /**
119
+ * The visible width of a string, ignoring any ANSI escape sequences it contains.
120
+ *
121
+ * @param text - The (possibly colorized) text to measure.
122
+ * @returns The number of visible characters.
123
+ * @example
124
+ * visibleWidth(`${ANSI.red}hi${ANSI.reset}`); // 2
125
+ */
126
+ function visibleWidth(text) {
127
+ return text.replaceAll(ANSI_PATTERN, "").length;
128
+ }
129
+ /**
130
+ * Build a {@link Palette} bound to a fixed color mode. When `color` is `false` every
131
+ * helper returns its input unchanged, so the same render code path produces plain
132
+ * output in CI/pipes.
133
+ *
134
+ * @param color - Whether color is enabled (typically `supportsColor()`).
135
+ * @param truecolor - Whether 24-bit output is enabled (typically `supportsTruecolor()`);
136
+ * only consulted by {@link Palette.pink}. Defaults to `false` (16-color magenta).
137
+ * @returns The bound color palette.
138
+ * @example
139
+ * const palette = makePalette(supportsColor(), supportsTruecolor());
140
+ * const line = palette.green("done");
141
+ */
142
+ function makePalette(color, truecolor = false) {
143
+ return {
144
+ enabled: color,
145
+ /**
146
+ * Wrap text in the given ANSI code (returns it unchanged when color is off).
147
+ *
148
+ * @param code - The ANSI SGR code to apply.
149
+ * @param text - The text to colorize.
150
+ * @returns The colorized (or unchanged) text.
151
+ * @example
152
+ * palette.paint(ANSI.green, "ok");
153
+ */
154
+ paint(code, text) {
155
+ return color ? `${code}${text}${ANSI.reset}` : text;
156
+ },
157
+ /**
158
+ * Bold the given text (no-op in plain mode).
159
+ *
160
+ * @param text - The text to embolden.
161
+ * @returns The bold (or unchanged) text.
162
+ * @example
163
+ * palette.bold("title");
164
+ */
165
+ bold(text) {
166
+ return this.paint(ANSI.bold, text);
167
+ },
168
+ /**
169
+ * Dim the given text (no-op in plain mode).
170
+ *
171
+ * @param text - The text to dim.
172
+ * @returns The dim (or unchanged) text.
173
+ * @example
174
+ * palette.dim("· 84ms");
175
+ */
176
+ dim(text) {
177
+ return this.paint(ANSI.dim, text);
178
+ },
179
+ /**
180
+ * Color the given text green (no-op in plain mode).
181
+ *
182
+ * @param text - The text to colorize.
183
+ * @returns The green (or unchanged) text.
184
+ * @example
185
+ * palette.green("✓");
186
+ */
187
+ green(text) {
188
+ return this.paint(ANSI.green, text);
189
+ },
190
+ /**
191
+ * Color the given text yellow (no-op in plain mode).
192
+ *
193
+ * @param text - The text to colorize.
194
+ * @returns The yellow (or unchanged) text.
195
+ * @example
196
+ * palette.yellow("~");
197
+ */
198
+ yellow(text) {
199
+ return this.paint(ANSI.yellow, text);
200
+ },
201
+ /**
202
+ * Color the given text red (no-op in plain mode).
203
+ *
204
+ * @param text - The text to colorize.
205
+ * @returns The red (or unchanged) text.
206
+ * @example
207
+ * palette.red("✗");
208
+ */
209
+ red(text) {
210
+ return this.paint(ANSI.red, text);
211
+ },
212
+ /**
213
+ * Color the given text cyan (no-op in plain mode).
214
+ *
215
+ * @param text - The text to colorize.
216
+ * @returns The cyan (or unchanged) text.
217
+ * @example
218
+ * palette.cyan("http://localhost:4173");
219
+ */
220
+ cyan(text) {
221
+ return this.paint(ANSI.cyan, text);
222
+ },
223
+ /**
224
+ * Color the given text the Moku brand pink: exact `#FF1E6F` (24-bit) when truecolor
225
+ * is enabled, the 16-color `magenta` approximation otherwise, unchanged in plain mode.
226
+ *
227
+ * @param text - The text to colorize.
228
+ * @returns The pink (or unchanged) text.
229
+ * @example
230
+ * palette.pink("▟▙ moku web");
231
+ */
232
+ pink(text) {
233
+ if (!color) return text;
234
+ if (truecolor) return `${fg24(BRAND_PINK.r, BRAND_PINK.g, BRAND_PINK.b)}${text}${ANSI.reset}`;
235
+ return this.paint(ANSI.magenta, text);
236
+ }
237
+ };
238
+ }
239
+ /**
240
+ * Frame a list of already-rendered content lines in a box, padding each line to the
241
+ * widest visible line (or `minInnerWidth`, whichever is larger — so several boxes can be
242
+ * forced to a shared width). Uses Unicode borders when `color` is enabled and ASCII
243
+ * otherwise. Visible width ignores embedded ANSI so colored lines align.
244
+ *
245
+ * @param lines - The content lines (may contain ANSI color codes).
246
+ * @param color - Whether to use Unicode borders (and assume color-capable output).
247
+ * @param minInnerWidth - Minimum inner (content) width to pad every row to. Defaults to `0`.
248
+ * @returns The boxed lines (top border, content rows, bottom border).
249
+ * @example
250
+ * box(["Local: http://localhost:4173"], true, 62);
251
+ */
252
+ function box(lines, color, minInnerWidth = 0) {
253
+ const glyphs = boxGlyphs(color);
254
+ const inner = Math.max(0, minInnerWidth, ...lines.map((line) => visibleWidth(line)));
255
+ const horizontal = glyphs.horizontal.repeat(inner + 2);
256
+ const top = `${glyphs.topLeft}${horizontal}${glyphs.topRight}`;
257
+ const bottom = `${glyphs.bottomLeft}${horizontal}${glyphs.bottomRight}`;
258
+ return [
259
+ top,
260
+ ...lines.map((line) => {
261
+ const pad = " ".repeat(inner - visibleWidth(line));
262
+ return `${glyphs.vertical} ${line}${pad} ${glyphs.vertical}`;
263
+ }),
264
+ bottom
265
+ ];
266
+ }
267
+ /**
268
+ * @file `@moku-labs/common/cli` — the branded console: the shared, **stateless** line
269
+ * vocabulary every Moku CLI prints through so the look never drifts between projects.
270
+ * It is the generic counterpart to a framework's own (stateful) panels: the `▟▙` lockup
271
+ * banner, section `heading`s, `info`/`warn`/`error` lines, `✓/✗ check` rows, plus the
272
+ * `railLine`/`box` builders a project composes its own panels from. Built entirely on the
273
+ * {@link makePalette} primitives, TTY/`NO_COLOR`-aware, every line routed through an
274
+ * injectable sink so tests capture output (and a non-CLI consumer can redirect it).
275
+ */
276
+ /** Default total visible width the lockup rule spans and `railLine` right-aligns to. */
277
+ const DEFAULT_WIDTH$1 = 66;
278
+ /**
279
+ * Create a {@link BrandConsole}. Output flows through the injected sink (default
280
+ * `console.log`/`console.error`) and is colorized only when color is enabled, so the
281
+ * identical render path yields branded color/Unicode on a TTY and plain ASCII in CI/pipes.
282
+ *
283
+ * @param options - Optional sinks, color/truecolor overrides, and width (see
284
+ * {@link BrandConsoleOptions}).
285
+ * @returns The branded console.
286
+ * @example
287
+ * const ui = createBrandConsole();
288
+ * ui.lockup({ wordmark: "moku tool", version: "v1.0.0" });
289
+ * ui.check(true, "config loaded");
290
+ */
291
+ function createBrandConsole(options = {}) {
292
+ const write = options.write ?? ((line) => console.log(line));
293
+ const writeError = options.writeError ?? ((line) => console.error(line));
294
+ const color = options.color ?? supportsColor();
295
+ const palette = makePalette(color, options.truecolor ?? (color && supportsTruecolor()));
296
+ const width = options.width ?? DEFAULT_WIDTH$1;
297
+ const cube = color ? "▟▙" : "*";
298
+ const rule = color ? "─" : "-";
299
+ /**
300
+ * Right-align `right` against `left` within `lineWidth`, measuring visible width so
301
+ * embedded ANSI never throws the alignment off.
302
+ *
303
+ * @param left - The left segment (may contain ANSI).
304
+ * @param right - The right segment (may contain ANSI).
305
+ * @param lineWidth - Total visible width to fill (defaults to the console width).
306
+ * @returns The padded line.
307
+ * @example
308
+ * railLine("left", "right", 20);
309
+ */
310
+ const railLine = (left, right, lineWidth = width) => {
311
+ const gap = Math.max(1, lineWidth - visibleWidth(left) - visibleWidth(right));
312
+ return `${left}${" ".repeat(gap)}${right}`;
313
+ };
314
+ return {
315
+ palette,
316
+ color,
317
+ width,
318
+ /**
319
+ * Write a pre-rendered line verbatim through the stdout sink.
320
+ *
321
+ * @param text - The line to write (defaults to an empty line).
322
+ * @example
323
+ * ui.line(" custom row");
324
+ */
325
+ line(text = "") {
326
+ write(text);
327
+ },
328
+ /**
329
+ * Render the `▟▙ <wordmark>` lockup (cube + bold-pink wordmark + optional label,
330
+ * version right-aligned), a dim hairline rule, and an optional dim facts line.
331
+ *
332
+ * @param opts - The lockup fields (see {@link LockupOptions}).
333
+ * @example
334
+ * ui.lockup({ wordmark: "moku web", label: "build", version: "v1.2.0" });
335
+ */
336
+ lockup(opts) {
337
+ const wordmark = palette.pink(palette.bold(opts.wordmark));
338
+ const label = opts.label ? ` ${palette.dim(opts.label)}` : "";
339
+ write(railLine(` ${palette.pink(cube)} ${wordmark}${label}`, opts.version ? palette.dim(opts.version) : ""));
340
+ write(` ${palette.dim(rule.repeat(width - 1))}`);
341
+ if (opts.facts !== void 0) write(` ${palette.dim(opts.facts)}`);
342
+ },
343
+ /**
344
+ * Render a section heading: a blank line followed by a bold brand-pink label.
345
+ *
346
+ * @param text - The heading label.
347
+ * @example
348
+ * ui.heading("Diagnostics");
349
+ */
350
+ heading(text) {
351
+ write("");
352
+ write(` ${palette.bold(palette.pink(text))}`);
353
+ },
354
+ /**
355
+ * Render a neutral informational line (`› message`), indenting continuation lines.
356
+ *
357
+ * @param message - The line to print.
358
+ * @example
359
+ * ui.info("watching for changes…");
360
+ */
361
+ info(message) {
362
+ const [first = "", ...rest] = message.split("\n");
363
+ write(` ${palette.cyan("›")} ${first}`);
364
+ for (const lineText of rest) write(` ${lineText}`);
365
+ },
366
+ /**
367
+ * Render a warning line (`⚠ message`, to stderr).
368
+ *
369
+ * @param message - The warning to print.
370
+ * @example
371
+ * ui.warn("deploy skipped");
372
+ */
373
+ warn(message) {
374
+ writeError(` ${palette.yellow("⚠")} ${message}`);
375
+ },
376
+ /**
377
+ * Render an error line (`✗ message`, to stderr), optionally with a cause beneath.
378
+ *
379
+ * @param message - The error summary to print.
380
+ * @param cause - Optional underlying error/value printed beneath the summary.
381
+ * @example
382
+ * ui.error("build failed", err);
383
+ */
384
+ error(message, cause) {
385
+ writeError(` ${palette.red("✗")} ${message}`);
386
+ if (cause !== void 0) writeError(String(cause));
387
+ },
388
+ /**
389
+ * Render a diagnostic line — green `✓` / red `✗` + label, with optional dim,
390
+ * indented detail beneath.
391
+ *
392
+ * @param ok - Whether the check passed.
393
+ * @param label - The check label.
394
+ * @param detail - Optional multi-line guidance shown indented under the line.
395
+ * @example
396
+ * ui.check(true, "config loaded");
397
+ */
398
+ check(ok, label, detail) {
399
+ write(` ${ok ? palette.green("✓") : palette.red("✗")} ${label}`);
400
+ if (detail !== void 0) for (const lineText of detail.split("\n")) write(` ${palette.dim(lineText)}`);
401
+ },
402
+ railLine,
403
+ /**
404
+ * Frame the given content lines in a brand box and write the result.
405
+ *
406
+ * @param lines - The content lines (may contain ANSI).
407
+ * @param minInnerWidth - Minimum inner width to pad every row to. Defaults to `0`.
408
+ * @example
409
+ * ui.box(["Local http://localhost:4173"]);
410
+ */
411
+ box(lines, minInnerWidth = 0) {
412
+ for (const lineText of box(lines, color, minInnerWidth)) write(lineText);
413
+ }
414
+ };
415
+ }
416
+ /**
417
+ * @file `@moku-labs/common/cli` — branded interactive prompts (`confirm` y/N and
418
+ * `select` one-of-N) styled with the brand `◆` marker, the dim hint, and the cyan `›`
419
+ * caret, so a guided flow in any Moku CLI looks the same. Built on `node:readline`; the
420
+ * input/output streams and the choices-block sink are injectable so tests drive prompts
421
+ * without a real TTY. Off a color TTY (CI/pipes) every prompt degrades to a plain form.
422
+ */
423
+ /** Default prompt rail width — matches the brand console so hints align with other rows. */
424
+ const DEFAULT_WIDTH = 66;
425
+ /** Matches an explicit affirmative answer (`y`/`yes`, case-insensitive). */
426
+ const YES_PATTERN = /^y(es)?$/i;
427
+ /**
428
+ * Create {@link BrandPrompts} bound to a color mode + streams. Styling matches the brand
429
+ * console (the `◆` marker, dim hints, cyan caret); off a color TTY every prompt uses the
430
+ * plain `question [y/N]` / `question [1-N]` form.
431
+ *
432
+ * @param options - Optional color/width overrides and injectable streams/sink (see
433
+ * {@link BrandPromptsOptions}).
434
+ * @returns The branded prompts.
435
+ * @example
436
+ * const prompts = createBrandPrompts();
437
+ * const i = await prompts.select("Workflow?", ["Auto", "Manual"]);
438
+ */
439
+ function createBrandPrompts(options = {}) {
440
+ const color = options.color ?? supportsColor();
441
+ const palette = makePalette(color, options.truecolor ?? (color && supportsTruecolor()));
442
+ const width = options.width ?? DEFAULT_WIDTH;
443
+ const input = options.input ?? process.stdin;
444
+ const output = options.output ?? process.stdout;
445
+ const write = options.write ?? ((block) => console.log(block));
446
+ /**
447
+ * Build the y/N prompt string: the styled `◆ question … y / N ›` rail on a color TTY,
448
+ * else the plain `question [y/N] ` form.
449
+ *
450
+ * @param question - The yes/no question to display.
451
+ * @returns The readline prompt string.
452
+ * @example
453
+ * confirmPrompt("Deploy?");
454
+ */
455
+ const confirmPrompt = (question) => {
456
+ if (!color) return `${question} [y/N] `;
457
+ const left = ` ${palette.pink("◆")} ${question}`;
458
+ const right = `${palette.dim("y / N")} ${palette.cyan("›")} `;
459
+ const gap = Math.max(1, width - visibleWidth(left) - visibleWidth(right));
460
+ return `${left}${" ".repeat(gap)}${right}`;
461
+ };
462
+ /**
463
+ * Build the select choices block: the styled `◆ question` head + dim-numbered rows on a
464
+ * color TTY, else the plain ` N) label` list.
465
+ *
466
+ * @param question - The prompt shown above the choices (styled mode only).
467
+ * @param choices - The selectable option labels.
468
+ * @returns The multi-line choices block.
469
+ * @example
470
+ * choicesBlock("Pick", ["a", "b"]);
471
+ */
472
+ const choicesBlock = (question, choices) => {
473
+ if (!color) return choices.map((choice, index) => ` ${index + 1}) ${choice}`).join("\n");
474
+ return [` ${palette.pink("◆")} ${question}`, ...choices.map((choice, index) => ` ${palette.dim(String(index + 1))} ${choice}`)].join("\n");
475
+ };
476
+ /**
477
+ * Build the select input prompt: the dim `pick 1–N ›` hint on a color TTY, else the
478
+ * plain `question [1-N] ` form.
479
+ *
480
+ * @param question - The prompt (used only by the plain fallback).
481
+ * @param count - The number of choices.
482
+ * @returns The readline prompt string.
483
+ * @example
484
+ * selectPrompt("Pick", 3);
485
+ */
486
+ const selectPrompt = (question, count) => {
487
+ if (!color) return `${question} [1-${count}] `;
488
+ return ` ${palette.dim(`pick 1–${count}`)} ${palette.cyan("›")} `;
489
+ };
490
+ return {
491
+ /**
492
+ * Ask a yes/no question; resolves `true` only on an explicit `y`/`yes`.
493
+ *
494
+ * @param question - The yes/no question to display.
495
+ * @returns Resolves `true` when the user answered yes.
496
+ * @example
497
+ * await prompts.confirm("Deploy?");
498
+ */
499
+ confirm(question) {
500
+ return new Promise((resolve) => {
501
+ const readline = createInterface({
502
+ input,
503
+ output
504
+ });
505
+ readline.question(confirmPrompt(question), (answer) => {
506
+ readline.close();
507
+ resolve(YES_PATTERN.test(answer.trim()));
508
+ });
509
+ });
510
+ },
511
+ /**
512
+ * Present `choices` numbered from 1 and resolve the chosen zero-based index.
513
+ *
514
+ * @param question - The prompt to display.
515
+ * @param choices - The selectable option labels.
516
+ * @returns Resolves the chosen zero-based index (`0` for empty/out-of-range).
517
+ * @example
518
+ * await prompts.select("Pick", ["a", "b"]);
519
+ */
520
+ select(question, choices) {
521
+ return new Promise((resolve) => {
522
+ const readline = createInterface({
523
+ input,
524
+ output
525
+ });
526
+ write(choicesBlock(question, choices));
527
+ readline.question(selectPrompt(question, choices.length), (answer) => {
528
+ readline.close();
529
+ const picked = Number.parseInt(answer.trim(), 10);
530
+ resolve(Number.isInteger(picked) && picked >= 1 && picked <= choices.length ? picked - 1 : 0);
531
+ });
532
+ });
533
+ }
534
+ };
535
+ }
536
+ //#endregion
537
+ //#region src/lib/git.ts
538
+ /**
539
+ * @file `moku-release` — git vocabulary as pure functions: remote-URL normalization, the
540
+ * `owner/repo` slug, and the latest `v*` tag.
541
+ *
542
+ * The one piece of git *invocation* that lives here is {@link LATEST_TAG_ARGS}: the tag
543
+ * listing must run with `versionsort.suffix=-` so `v1.0.0-rc.1` sorts BELOW `v1.0.0`
544
+ * instead of above it. Keeping the argv next to the parser stops the two drifting apart.
545
+ */
546
+ /** Argv for listing release tags newest-first with prereleases ordered correctly. */
547
+ const LATEST_TAG_ARGS = [
548
+ "-c",
549
+ "versionsort.suffix=-",
550
+ "tag",
551
+ "--list",
552
+ "v*",
553
+ "--sort=-v:refname"
554
+ ];
555
+ /** Matches the `owner/repo` pair in any GitHub remote form (ssh, https, with or without `.git`). */
556
+ const OWNER_REPO_PATTERN = /github\.com[:/]([^/]+)\/([^/]+?)(?:\.git)?$/;
557
+ /**
558
+ * Reduce any remote form to a comparable canonical one: `git+` prefix dropped, `.git`
559
+ * suffix dropped, `git@host:owner/repo` rewritten as `https://host/owner/repo`, trailing
560
+ * slash and surrounding whitespace removed.
561
+ *
562
+ * @param url - A remote URL from `package.json` or `git remote get-url`.
563
+ * @returns The canonical `https://host/owner/repo` form.
564
+ * @example
565
+ * normalizeRemoteUrl("git+https://github.com/moku-labs/common.git");
566
+ * // "https://github.com/moku-labs/common"
567
+ */
568
+ function normalizeRemoteUrl(url) {
569
+ return url.trim().replace(/^git\+/, "").replace(/\/$/, "").replace(/\.git$/, "").replace(/^(?:ssh:\/\/)?git@([^:/]+)[:/]/, "https://$1/");
570
+ }
571
+ /**
572
+ * Extract the `owner/repo` slug a remote points at.
573
+ *
574
+ * @param url - A GitHub remote URL in any form.
575
+ * @returns The `owner/repo` slug, or `undefined` when the URL is not a GitHub remote.
576
+ * @example
577
+ * ownerRepoFrom("git@github.com:moku-labs/common.git"); // "moku-labs/common"
578
+ */
579
+ function ownerRepoFrom(url) {
580
+ const match = OWNER_REPO_PATTERN.exec(normalizeRemoteUrl(url));
581
+ if (!match) return void 0;
582
+ return `${match[1]}/${match[2]}`;
583
+ }
584
+ /**
585
+ * Whether two remote URLs point at the same repository, comparing canonical forms so
586
+ * `git+https://…​.git` and `git@github.com:…` match.
587
+ *
588
+ * @param left - The first remote URL.
589
+ * @param right - The second remote URL.
590
+ * @returns `true` when both resolve to the same canonical URL.
591
+ * @example
592
+ * sameRemote("git@github.com:o/r.git", "https://github.com/o/r"); // true
593
+ */
594
+ function sameRemote(left, right) {
595
+ return normalizeRemoteUrl(left) === normalizeRemoteUrl(right);
596
+ }
597
+ /**
598
+ * The newest release tag from `git tag` output produced with {@link LATEST_TAG_ARGS}.
599
+ *
600
+ * @param stdout - The raw tag listing.
601
+ * @returns The first (newest) tag, or `undefined` when the repo has no release tags.
602
+ * @example
603
+ * latestVersionTag("v1.2.3\nv1.2.2\n"); // "v1.2.3"
604
+ */
605
+ function latestVersionTag(stdout) {
606
+ return stdout.split("\n").map((line) => line.trim()).find((line) => line !== "");
607
+ }
608
+ //#endregion
609
+ //#region src/lib/github.ts
610
+ /**
611
+ * @file `moku-release` — parsers for the `gh` JSON surfaces the CLI reads: branch
612
+ * rulesets, workflow runs, and the release permalink printed in the final summary.
613
+ *
614
+ * `gh` is invoked by the checks and commands; everything that *interprets* its output
615
+ * lives here so the interpretation is unit-testable against captured fixtures.
616
+ */
617
+ /** Ruleset target value GitHub uses for branch (as opposed to tag) rulesets. */
618
+ const BRANCH_TARGET = "branch";
619
+ /**
620
+ * Parse a JSON array `gh api` printed, tolerating error text and non-array payloads.
621
+ *
622
+ * @param stdout - The raw `gh api` output.
623
+ * @returns The parsed array, or an empty array when the payload is not one.
624
+ * @example
625
+ * parseJsonArray('[{"target":"branch"}]');
626
+ */
627
+ function parseJsonArray(stdout) {
628
+ try {
629
+ const parsed = JSON.parse(stdout);
630
+ return Array.isArray(parsed) ? parsed : [];
631
+ } catch {
632
+ return [];
633
+ }
634
+ }
635
+ /**
636
+ * Whether an active BRANCH ruleset protects the default branch. Tag rulesets are ignored
637
+ * on purpose — `moku-release` pushes tags, so restricting them would block every release.
638
+ *
639
+ * @param stdout - Output of `gh api repos/{owner}/{repo}/rulesets`.
640
+ * @returns `true` when at least one active branch ruleset is present.
641
+ * @example
642
+ * hasMainBranchRuleset('[{"target":"branch","enforcement":"active"}]'); // true
643
+ */
644
+ function hasMainBranchRuleset(stdout) {
645
+ return parseJsonArray(stdout).some((ruleset) => ruleset.target === BRANCH_TARGET && ruleset.enforcement !== "disabled");
646
+ }
647
+ /**
648
+ * The id of the newest workflow run from `gh run list --json databaseId`.
649
+ *
650
+ * @param stdout - The raw `gh run list` JSON.
651
+ * @returns The run id as a string, or `undefined` when no run was listed.
652
+ * @example
653
+ * latestRunId('[{"databaseId":42}]'); // "42"
654
+ */
655
+ function latestRunId(stdout) {
656
+ const [first] = parseJsonArray(stdout);
657
+ if (first?.databaseId === void 0) return void 0;
658
+ return String(first.databaseId);
659
+ }
660
+ /**
661
+ * The permalink of a GitHub release, for the final summary.
662
+ *
663
+ * @param ownerRepo - The `owner/repo` slug.
664
+ * @param tag - The release tag (`v1.2.3`).
665
+ * @returns The canonical release URL.
666
+ * @example
667
+ * releaseUrl("moku-labs/common", "v1.2.3");
668
+ */
669
+ function releaseUrl(ownerRepo, tag) {
670
+ return `https://github.com/${ownerRepo}/releases/tag/${tag}`;
671
+ }
672
+ //#endregion
673
+ //#region src/lib/package-json.ts
674
+ /** Repo-relative path of the manifest every command reads. */
675
+ const MANIFEST_PATH = "package.json";
676
+ /** Lowest Node major a releasable package may declare — the npm Trusted Publishing floor. */
677
+ const MINIMUM_NODE_MAJOR = 24;
678
+ /** Value `engines.node` is set to when the field is missing or too low. */
679
+ const NODE_ENGINE_RANGE = ">=24.0.0";
680
+ /** Default `files` entry when a package declares none — the build output. */
681
+ const DEFAULT_FILES = ["dist"];
682
+ /** The scripts a releasable package must expose, mapped to the value `setup` fills in. */
683
+ const REQUIRED_SCRIPTS = {
684
+ lint: "biome check . && eslint .",
685
+ typecheck: "tsc --noEmit",
686
+ test: "vitest run",
687
+ build: "tsdown",
688
+ validate: "publint && attw --pack . --profile node16",
689
+ "release:setup": "moku-release setup",
690
+ "release:doctor": "moku-release doctor",
691
+ release: "moku-release"
692
+ };
693
+ /**
694
+ * Parse `package.json` text, treating malformed or non-object content as absent.
695
+ *
696
+ * @param text - The raw file contents, or `undefined` when the file is missing.
697
+ * @returns The manifest, or `undefined` when it cannot be read as an object.
698
+ * @example
699
+ * parseManifest('{"name":"x"}');
700
+ */
701
+ function parseManifest(text) {
702
+ if (text === void 0) return void 0;
703
+ try {
704
+ const parsed = JSON.parse(text);
705
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return void 0;
706
+ return parsed;
707
+ } catch {
708
+ return;
709
+ }
710
+ }
711
+ /**
712
+ * Read and parse the package manifest from a file store.
713
+ *
714
+ * @param files - The file port to read through.
715
+ * @returns The manifest, or `undefined` when it is missing or malformed.
716
+ * @example
717
+ * const manifest = await readManifest(ctx.files);
718
+ */
719
+ async function readManifest(files) {
720
+ return parseManifest(await files.read(MANIFEST_PATH));
721
+ }
722
+ /**
723
+ * Render a manifest the way npm itself writes one: two-space JSON with a trailing newline.
724
+ *
725
+ * @param manifest - The manifest to serialize.
726
+ * @returns The file contents to write.
727
+ * @example
728
+ * await files.write("package.json", formatManifest(manifest));
729
+ */
730
+ function formatManifest(manifest) {
731
+ return `${JSON.stringify(manifest, void 0, 2)}\n`;
732
+ }
733
+ /**
734
+ * The repository URL a manifest declares, in either the object or shorthand string form.
735
+ *
736
+ * @param manifest - The manifest to read.
737
+ * @returns The declared URL, or `undefined` when the field is absent.
738
+ * @example
739
+ * repositoryUrlOf({ repository: { url: "git+https://github.com/o/r.git" } });
740
+ */
741
+ function repositoryUrlOf(manifest) {
742
+ const { repository } = manifest;
743
+ if (typeof repository === "string") return repository;
744
+ return repository?.url;
745
+ }
746
+ /**
747
+ * Whether an `engines.node` range admits Node 24 or newer. Only the first number in the
748
+ * range is read — enough to separate `>=24`/`^24` from `>=20`, and it never mistakes a
749
+ * richer range for a violation.
750
+ *
751
+ * @param range - The declared range, or `undefined`.
752
+ * @returns `true` when the range's floor is Node 24 or newer.
753
+ * @example
754
+ * satisfiesNodeFloor(">=24.0.0"); // true
755
+ */
756
+ function satisfiesNodeFloor(range) {
757
+ if (range === void 0) return false;
758
+ const match = /(\d+)/.exec(range);
759
+ if (!match?.[1]) return false;
760
+ return Number.parseInt(match[1], 10) >= MINIMUM_NODE_MAJOR;
761
+ }
762
+ /**
763
+ * Every way a manifest falls short of the release contract, one line per gap, each naming
764
+ * the exact script or field. An empty array means the manifest is on contract.
765
+ *
766
+ * @param manifest - The manifest to audit.
767
+ * @returns The list of gaps, in reporting order.
768
+ * @example
769
+ * contractIssues({ name: "x" }); // ["missing script `lint`", …]
770
+ */
771
+ function contractIssues(manifest) {
772
+ const issues = [];
773
+ const scripts = manifest.scripts ?? {};
774
+ for (const name of Object.keys(REQUIRED_SCRIPTS)) if (!scripts[name]) issues.push(`missing script \`${name}\``);
775
+ if (manifest.publishConfig?.access !== "public") issues.push("`publishConfig.access` is not `public`");
776
+ if (repositoryUrlOf(manifest) === void 0) issues.push("missing `repository.url`");
777
+ if (!manifest.files || manifest.files.length === 0) issues.push("missing `files`");
778
+ if (!satisfiesNodeFloor(manifest.engines?.node)) issues.push("`engines.node` is below `>=24`");
779
+ return issues;
780
+ }
781
+ /**
782
+ * Bring a manifest onto the contract without overwriting anything it already chose: fill
783
+ * missing scripts, force `publishConfig.access`, adopt the git remote as `repository.url`
784
+ * when absent, add a default `files` allowlist, and raise `engines.node` to the floor.
785
+ *
786
+ * Running it twice produces no further changes — `setup` relies on that to stay idempotent.
787
+ *
788
+ * @param manifest - The manifest to normalize (never mutated).
789
+ * @param remoteUrl - The `git remote get-url origin` value, when one is known.
790
+ * @returns The normalized copy and the list of applied changes.
791
+ * @example
792
+ * const { manifest: next, changes } = normalizeManifest(current, "git@github.com:o/r.git");
793
+ */
794
+ function normalizeManifest(manifest, remoteUrl) {
795
+ const next = structuredClone(manifest);
796
+ const changes = [];
797
+ const scripts = { ...next.scripts };
798
+ for (const [name, command] of Object.entries(REQUIRED_SCRIPTS)) {
799
+ if (scripts[name]) continue;
800
+ scripts[name] = command;
801
+ changes.push(`+ scripts.${name} = "${command}"`);
802
+ }
803
+ next.scripts = scripts;
804
+ if (next.publishConfig?.access !== "public") {
805
+ next.publishConfig = {
806
+ ...next.publishConfig,
807
+ access: "public"
808
+ };
809
+ changes.push("+ publishConfig.access = \"public\"");
810
+ }
811
+ if (repositoryUrlOf(next) === void 0 && remoteUrl !== void 0) {
812
+ next.repository = {
813
+ type: "git",
814
+ url: remoteUrl
815
+ };
816
+ changes.push(`+ repository.url = "${remoteUrl}"`);
817
+ }
818
+ if (!next.files || next.files.length === 0) {
819
+ next.files = [...DEFAULT_FILES];
820
+ changes.push(`+ files = ${JSON.stringify(DEFAULT_FILES)}`);
821
+ }
822
+ if (!satisfiesNodeFloor(next.engines?.node)) {
823
+ next.engines = {
824
+ ...next.engines,
825
+ node: NODE_ENGINE_RANGE
826
+ };
827
+ changes.push(`+ engines.node = "${NODE_ENGINE_RANGE}"`);
828
+ }
829
+ return {
830
+ manifest: next,
831
+ changes
832
+ };
833
+ }
834
+ //#endregion
835
+ //#region src/lib/result.ts
836
+ /**
837
+ * A passing result. A pass never carries a fix — there is nothing to fix.
838
+ *
839
+ * @param detail - What was observed, in one line.
840
+ * @returns The passing result.
841
+ * @example
842
+ * pass("npm 11.6.0");
843
+ */
844
+ function pass(detail) {
845
+ return {
846
+ status: "pass",
847
+ detail
848
+ };
849
+ }
850
+ /**
851
+ * A blocking result: the release cannot proceed until `fix` is applied.
852
+ *
853
+ * @param detail - What was observed, in one line.
854
+ * @param fix - The concrete command or edit that resolves it.
855
+ * @returns The failing result.
856
+ * @example
857
+ * fail("npm is not logged in", "npm login");
858
+ */
859
+ function fail(detail, fix) {
860
+ return {
861
+ status: "fail",
862
+ detail,
863
+ fix
864
+ };
865
+ }
866
+ /**
867
+ * An advisory result: worth knowing, not blocking.
868
+ *
869
+ * @param detail - What was observed, in one line.
870
+ * @param fix - The concrete command or edit that resolves it.
871
+ * @returns The warning result.
872
+ * @example
873
+ * warn("no branch ruleset on main", "moku-release setup");
874
+ */
875
+ function warn(detail, fix) {
876
+ return {
877
+ status: "warn",
878
+ detail,
879
+ fix
880
+ };
881
+ }
882
+ /**
883
+ * A skipped result: the check could not run because a prerequisite is missing.
884
+ *
885
+ * @param detail - Why the check was skipped.
886
+ * @returns The skipped result.
887
+ * @example
888
+ * skip("package.json is unreadable");
889
+ */
890
+ function skip(detail) {
891
+ return {
892
+ status: "skip",
893
+ detail
894
+ };
895
+ }
896
+ //#endregion
897
+ //#region src/checks/branch-ruleset.ts
898
+ /**
899
+ * @file `moku-release` — check: a branch ruleset protects the default branch.
900
+ *
901
+ * Advisory. The release pipeline works without it; what it buys is that main can only
902
+ * move through a PR, so a release always describes a reviewed state.
903
+ */
904
+ /** Verifies `gh api repos/{owner}/{repo}/rulesets` lists an active branch ruleset. */
905
+ const branchRulesetCheck = {
906
+ id: "branch-ruleset",
907
+ title: "branch ruleset on main",
908
+ /**
909
+ * List the repository's rulesets and look for an active branch-targeting one.
910
+ *
911
+ * @param ctx - The injected ports.
912
+ * @returns Pass when one exists, warn when none does.
913
+ * @example
914
+ * await branchRulesetCheck.run(ctx);
915
+ */
916
+ async run(ctx) {
917
+ const manifest = await readManifest(ctx.files);
918
+ const declared = manifest === void 0 ? void 0 : repositoryUrlOf(manifest);
919
+ const ownerRepo = declared === void 0 ? void 0 : ownerRepoFrom(declared);
920
+ if (ownerRepo === void 0) return skip("cannot derive owner/repo from repository.url");
921
+ const rulesets = await ctx.exec.capture("gh", ["api", `repos/${ownerRepo}/rulesets`]);
922
+ if (rulesets.code !== 0) return skip("`gh api` could not read the rulesets");
923
+ if (!hasMainBranchRuleset(rulesets.stdout)) return warn("main has no branch ruleset", "moku-release setup");
924
+ return pass("active branch ruleset present");
925
+ }
926
+ };
927
+ //#endregion
928
+ //#region src/checks/gh-auth.ts
929
+ /**
930
+ * @file `moku-release` — check: the GitHub CLI is installed and authenticated.
931
+ *
932
+ * A human-only prerequisite. The CLI never logs anyone in and never handles a token: it
933
+ * reports the exact command the operator must run and stops there.
934
+ */
935
+ /** Verifies `gh` exists and holds a live session. */
936
+ const ghAuthCheck = {
937
+ id: "gh-auth",
938
+ title: "gh installed and authenticated",
939
+ /**
940
+ * Probe `gh --version`, then `gh auth status`.
941
+ *
942
+ * @param ctx - The injected ports.
943
+ * @returns Pass when both succeed, otherwise the command the human must run.
944
+ * @example
945
+ * await ghAuthCheck.run(ctx);
946
+ */
947
+ async run(ctx) {
948
+ if ((await ctx.exec.capture("gh", ["--version"])).code !== 0) return fail("`gh` is not installed", "brew install gh");
949
+ if ((await ctx.exec.capture("gh", ["auth", "status"])).code !== 0) return fail("`gh` is not authenticated", "gh auth login");
950
+ return pass("authenticated");
951
+ }
952
+ };
953
+ //#endregion
954
+ //#region src/checks/npm-auth.ts
955
+ /**
956
+ * @file `moku-release` — check: npm holds a logged-in session.
957
+ *
958
+ * The second human-only prerequisite. Publishing itself runs through OIDC in CI, but the
959
+ * one-time `setup` (first publish, `npm trust`) needs a local session — and only the human
960
+ * can create one.
961
+ */
962
+ /** Verifies `npm whoami` resolves to a user. */
963
+ const npmAuthCheck = {
964
+ id: "npm-auth",
965
+ title: "npm is logged in",
966
+ /**
967
+ * Probe `npm whoami`.
968
+ *
969
+ * @param ctx - The injected ports.
970
+ * @returns Pass with the username, otherwise the login command.
971
+ * @example
972
+ * await npmAuthCheck.run(ctx);
973
+ */
974
+ async run(ctx) {
975
+ const session = await ctx.exec.capture("npm", ["whoami"]);
976
+ if (session.code !== 0) return fail("npm is not logged in", "npm login");
977
+ return pass(`logged in as ${session.stdout.trim()}`);
978
+ }
979
+ };
980
+ //#endregion
981
+ //#region src/checks/npm-package.ts
982
+ /**
983
+ * @file `moku-release` — check: the package exists on the registry.
984
+ *
985
+ * Advisory, not blocking: a brand-new package legitimately has no npm presence yet, and
986
+ * `setup` is what performs that first publish.
987
+ */
988
+ /** Verifies `npm view <name> version` resolves. */
989
+ const npmPackageCheck = {
990
+ id: "npm-package",
991
+ title: "package published on npm",
992
+ /**
993
+ * Ask the registry for the package's current version.
994
+ *
995
+ * @param ctx - The injected ports.
996
+ * @returns Pass with the published version, or a warning that the first publish is due.
997
+ * @example
998
+ * await npmPackageCheck.run(ctx);
999
+ */
1000
+ async run(ctx) {
1001
+ const manifest = await readManifest(ctx.files);
1002
+ if (!manifest?.name) return skip("package.json declares no `name`");
1003
+ const view = await ctx.exec.capture("npm", [
1004
+ "view",
1005
+ manifest.name,
1006
+ "version"
1007
+ ]);
1008
+ if (view.code !== 0) return warn("first publish not done yet", "moku-release setup");
1009
+ return pass(`${manifest.name}@${view.stdout.trim()}`);
1010
+ }
1011
+ };
1012
+ //#endregion
1013
+ //#region src/lib/npm.ts
1014
+ /**
1015
+ * @file `moku-release` — npm semantics as pure functions: semver comparison (including
1016
+ * prerelease ordering), the minimum-version gate, the dist-tag a version belongs under,
1017
+ * and the parsers for `npm view … --json` output.
1018
+ *
1019
+ * Nothing here runs a command. Checks capture npm's output and hand the text to these
1020
+ * functions, which is what makes version and tag logic testable without a registry.
1021
+ */
1022
+ /** Registry front-end the final summary links a published version to. */
1023
+ const NPM_BASE_URL = "https://www.npmjs.com/package";
1024
+ /** Dist-tag stable releases move. */
1025
+ const LATEST_TAG = "latest";
1026
+ /** Dist-tag prerelease versions are published under so they never clobber `latest`. */
1027
+ const NEXT_TAG = "next";
1028
+ /**
1029
+ * Split a version string into comparable parts. A leading `v` and any build metadata
1030
+ * (`+…`) are dropped — neither participates in precedence.
1031
+ *
1032
+ * @param version - The version or tag to parse (`v1.2.3-rc.1`).
1033
+ * @returns The numeric core and the prerelease identifiers.
1034
+ * @example
1035
+ * parseSemver("v1.2.3-rc.1"); // { core: [1, 2, 3], prerelease: ["rc", "1"] }
1036
+ */
1037
+ function parseSemver(version) {
1038
+ const [withoutBuild = ""] = version.replace(/^v/, "").split("+");
1039
+ const dash = withoutBuild.indexOf("-");
1040
+ const core = dash === -1 ? withoutBuild : withoutBuild.slice(0, dash);
1041
+ const prerelease = dash === -1 ? "" : withoutBuild.slice(dash + 1);
1042
+ return {
1043
+ core: core.split(".").map((part) => Number.parseInt(part, 10) || 0),
1044
+ prerelease: prerelease === "" ? [] : prerelease.split(".")
1045
+ };
1046
+ }
1047
+ /**
1048
+ * Compare two prerelease identifiers by semver rules: numeric identifiers compare
1049
+ * numerically and always sort below alphanumeric ones, which compare lexically.
1050
+ *
1051
+ * @param left - The left identifier.
1052
+ * @param right - The right identifier.
1053
+ * @returns Negative, zero or positive.
1054
+ * @example
1055
+ * compareIdentifier("2", "10"); // negative — numeric, not lexical
1056
+ */
1057
+ function compareIdentifier(left, right) {
1058
+ const leftNumeric = /^\d+$/.test(left);
1059
+ const rightNumeric = /^\d+$/.test(right);
1060
+ if (leftNumeric && !rightNumeric) return -1;
1061
+ if (!leftNumeric && rightNumeric) return 1;
1062
+ if (leftNumeric && rightNumeric) return Number(left) - Number(right);
1063
+ if (left === right) return 0;
1064
+ return left < right ? -1 : 1;
1065
+ }
1066
+ /**
1067
+ * Compare the prerelease segments of two versions. A stable release outranks any
1068
+ * prerelease; otherwise identifiers compare left to right and a shorter run loses.
1069
+ *
1070
+ * @param left - The left version's prerelease identifiers.
1071
+ * @param right - The right version's prerelease identifiers.
1072
+ * @returns Negative, zero or positive.
1073
+ * @example
1074
+ * comparePrerelease(["rc", "1"], []); // negative — 1.0.0-rc.1 < 1.0.0
1075
+ */
1076
+ function comparePrerelease(left, right) {
1077
+ if (left.length === 0 && right.length === 0) return 0;
1078
+ if (left.length === 0) return 1;
1079
+ if (right.length === 0) return -1;
1080
+ for (const [index, leftPart] of left.entries()) {
1081
+ const rightPart = right[index];
1082
+ if (rightPart === void 0) return 1;
1083
+ const verdict = compareIdentifier(leftPart, rightPart);
1084
+ if (verdict !== 0) return verdict;
1085
+ }
1086
+ return left.length === right.length ? 0 : -1;
1087
+ }
1088
+ /**
1089
+ * Compare two semver versions, prerelease ordering included.
1090
+ *
1091
+ * @param left - The left version or `v`-prefixed tag.
1092
+ * @param right - The right version or `v`-prefixed tag.
1093
+ * @returns Negative when `left` is older, `0` when equal, positive when newer.
1094
+ * @example
1095
+ * compareSemver("1.0.0-rc.2", "1.0.0"); // negative
1096
+ */
1097
+ function compareSemver(left, right) {
1098
+ const a = parseSemver(left);
1099
+ const b = parseSemver(right);
1100
+ for (let index = 0; index < 3; index += 1) {
1101
+ const verdict = (a.core[index] ?? 0) - (b.core[index] ?? 0);
1102
+ if (verdict !== 0) return verdict;
1103
+ }
1104
+ return comparePrerelease(a.prerelease, b.prerelease);
1105
+ }
1106
+ /**
1107
+ * Whether `version` satisfies a minimum floor (used for the npm Trusted Publishing gate).
1108
+ *
1109
+ * @param version - The observed version.
1110
+ * @param minimum - The lowest acceptable version.
1111
+ * @returns `true` when `version >= minimum`.
1112
+ * @example
1113
+ * isAtLeast("11.6.0", "11.5.1"); // true
1114
+ */
1115
+ function isAtLeast(version, minimum) {
1116
+ return compareSemver(version, minimum) >= 0;
1117
+ }
1118
+ /**
1119
+ * The dist-tag a version is published under: prereleases go to `next` so they never move
1120
+ * `latest`.
1121
+ *
1122
+ * @param version - The version being published.
1123
+ * @returns `"next"` for a prerelease, `"latest"` otherwise.
1124
+ * @example
1125
+ * distTagFor("1.0.0-rc.1"); // "next"
1126
+ */
1127
+ function distTagFor(version) {
1128
+ return parseSemver(version).prerelease.length > 0 ? NEXT_TAG : LATEST_TAG;
1129
+ }
1130
+ /**
1131
+ * Parse `npm view <pkg> dist-tags --json` output into a plain tag → version map.
1132
+ *
1133
+ * @param stdout - The raw JSON npm printed.
1134
+ * @returns The dist-tag map, or `undefined` when the output is not a JSON object.
1135
+ * @example
1136
+ * parseDistTags('{"latest":"1.2.3"}'); // { latest: "1.2.3" }
1137
+ */
1138
+ function parseDistTags(stdout) {
1139
+ try {
1140
+ const parsed = JSON.parse(stdout);
1141
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return void 0;
1142
+ return parsed;
1143
+ } catch {
1144
+ return;
1145
+ }
1146
+ }
1147
+ /**
1148
+ * The npm front-end URL for a package version, used in the final release summary.
1149
+ *
1150
+ * @param name - The package name.
1151
+ * @param version - The published version.
1152
+ * @returns The canonical npmjs.com URL.
1153
+ * @example
1154
+ * npmPackageUrl("@moku-labs/common", "1.2.3");
1155
+ */
1156
+ function npmPackageUrl(name, version) {
1157
+ return `${NPM_BASE_URL}/${name}/v/${version}`;
1158
+ }
1159
+ //#endregion
1160
+ //#region src/checks/npm-version.ts
1161
+ /**
1162
+ * @file `moku-release` — check: the local npm is new enough for Trusted Publishing.
1163
+ *
1164
+ * OIDC trusted publishing (and the `npm trust` command `setup` registers the publisher
1165
+ * with) landed in npm 11.5.1. Below that floor the whole token-free pipeline is
1166
+ * unavailable, so this is a hard fail rather than an advisory.
1167
+ */
1168
+ /** The npm version that introduced OIDC trusted publishing. */
1169
+ const NPM_TRUSTED_PUBLISHING_FLOOR = "11.5.1";
1170
+ /** Verifies `npm --version` is at or above the Trusted Publishing floor. */
1171
+ const npmVersionCheck = {
1172
+ id: "npm-version",
1173
+ title: `npm >= ${NPM_TRUSTED_PUBLISHING_FLOOR}`,
1174
+ /**
1175
+ * Read `npm --version` and compare it to the floor.
1176
+ *
1177
+ * @param ctx - The injected ports.
1178
+ * @returns Pass with the version, otherwise the upgrade command.
1179
+ * @example
1180
+ * await npmVersionCheck.run(ctx);
1181
+ */
1182
+ async run(ctx) {
1183
+ const probe = await ctx.exec.capture("npm", ["--version"]);
1184
+ if (probe.code !== 0) return fail("`npm` is not installed", "install Node 24 (bundles npm 11)");
1185
+ const version = probe.stdout.trim();
1186
+ if (!isAtLeast(version, "11.5.1")) return fail(`npm ${version} < ${NPM_TRUSTED_PUBLISHING_FLOOR}`, "npm install -g npm@latest");
1187
+ return pass(`npm ${version}`);
1188
+ }
1189
+ };
1190
+ //#endregion
1191
+ //#region src/checks/package-contract.ts
1192
+ /**
1193
+ * @file `moku-release` — check: package.json declares the release contract.
1194
+ *
1195
+ * Reports every gap by name (`missing script \`typecheck\``, not "scripts incomplete") so
1196
+ * the fix is mechanical, and points at the one command that closes all of them.
1197
+ */
1198
+ /** Verifies the required scripts and publish fields are present. */
1199
+ const packageContractCheck = {
1200
+ id: "package-contract",
1201
+ title: "package.json on contract",
1202
+ /**
1203
+ * Audit the manifest against the contract table.
1204
+ *
1205
+ * @param ctx - The injected ports.
1206
+ * @returns Pass when nothing is missing, otherwise every gap by name.
1207
+ * @example
1208
+ * await packageContractCheck.run(ctx);
1209
+ */
1210
+ async run(ctx) {
1211
+ const manifest = await readManifest(ctx.files);
1212
+ if (!manifest) return skip("package.json is missing or malformed");
1213
+ const issues = contractIssues(manifest);
1214
+ if (issues.length > 0) return fail(issues.join(", "), "moku-release setup");
1215
+ return pass("all scripts and publish fields present");
1216
+ }
1217
+ };
1218
+ //#endregion
1219
+ //#region src/checks/repository-url.ts
1220
+ /**
1221
+ * @file `moku-release` — check: `repository.url` points at the real origin.
1222
+ *
1223
+ * A manifest that names the wrong repository publishes provenance for the wrong
1224
+ * repository, and npm Trusted Publishing matches on that repository — so a mismatch here
1225
+ * surfaces later as an opaque OIDC rejection.
1226
+ */
1227
+ /** Verifies the declared repository URL matches `git remote get-url origin`. */
1228
+ const repositoryUrlCheck = {
1229
+ id: "repository-url",
1230
+ title: "repository.url matches origin",
1231
+ /**
1232
+ * Compare the manifest's repository URL with the git remote, in canonical form.
1233
+ *
1234
+ * @param ctx - The injected ports.
1235
+ * @returns Pass when both name the same repository.
1236
+ * @example
1237
+ * await repositoryUrlCheck.run(ctx);
1238
+ */
1239
+ async run(ctx) {
1240
+ const manifest = await readManifest(ctx.files);
1241
+ if (!manifest) return skip("package.json is missing or malformed");
1242
+ const origin = await ctx.exec.capture("git", [
1243
+ "remote",
1244
+ "get-url",
1245
+ "origin"
1246
+ ]);
1247
+ if (origin.code !== 0) return skip("no `origin` remote configured");
1248
+ const declared = repositoryUrlOf(manifest);
1249
+ if (declared === void 0) return fail("package.json declares no `repository.url`", "moku-release setup");
1250
+ if (!sameRemote(declared, origin.stdout)) return fail(`repository.url (${declared.trim()}) != origin (${origin.stdout.trim()})`, `set repository.url to "${origin.stdout.trim()}"`);
1251
+ return pass(declared.trim());
1252
+ }
1253
+ };
1254
+ //#endregion
1255
+ //#region src/checks/tag-sync.ts
1256
+ /**
1257
+ * @file `moku-release` — check: the newest git tag and npm's `latest` agree.
1258
+ *
1259
+ * The two drift in exactly two ways, and they mean opposite things: npm AHEAD of the tag
1260
+ * means a publish happened without its tag being pushed (history has lost the provenance
1261
+ * of a released version); npm BEHIND means a tag was cut whose publish never completed.
1262
+ * Both are advisory — neither blocks the next release, both want a human to look.
1263
+ */
1264
+ /** Fix line shared by both drift directions — the resolution is the same investigation. */
1265
+ const DRIFT_FIX = "reconcile: push the missing tag, or re-run the release that never published";
1266
+ /** Verifies the latest `v*` tag equals npm's `latest` dist-tag. */
1267
+ const tagSyncCheck = {
1268
+ id: "tag-sync",
1269
+ title: "latest git tag matches npm latest",
1270
+ /**
1271
+ * Compare the newest release tag with the registry's `latest` dist-tag.
1272
+ *
1273
+ * @param ctx - The injected ports.
1274
+ * @returns Pass when both name the same version, warn (with the direction) otherwise.
1275
+ * @example
1276
+ * await tagSyncCheck.run(ctx);
1277
+ */
1278
+ async run(ctx) {
1279
+ const manifest = await readManifest(ctx.files);
1280
+ if (!manifest?.name) return skip("package.json declares no `name`");
1281
+ const tags = await ctx.exec.capture("git", LATEST_TAG_ARGS);
1282
+ const tag = tags.code === 0 ? latestVersionTag(tags.stdout) : void 0;
1283
+ if (tag === void 0) return skip("no `v*` tags yet");
1284
+ const view = await ctx.exec.capture("npm", [
1285
+ "view",
1286
+ manifest.name,
1287
+ "dist-tags",
1288
+ "--json"
1289
+ ]);
1290
+ const published = view.code === 0 ? parseDistTags(view.stdout)?.latest : void 0;
1291
+ if (published === void 0) return skip("npm has no `latest` dist-tag yet");
1292
+ const drift = compareSemver(published, tag);
1293
+ if (drift > 0) return warn(`npm ${published} is AHEAD of tag ${tag}`, DRIFT_FIX);
1294
+ if (drift < 0) return warn(`npm ${published} is BEHIND tag ${tag}`, DRIFT_FIX);
1295
+ return pass(`${tag} == npm latest`);
1296
+ }
1297
+ };
1298
+ //#endregion
1299
+ //#region src/templates/central.ts
1300
+ /**
1301
+ * @file `moku-release` — read a central definition from this package's own files.
1302
+ *
1303
+ * The CLI ships in the same package as `examples/` and `rulesets/`, so a template is read
1304
+ * from disk instead of being copied into a string. One file, one version, nothing to drift.
1305
+ */
1306
+ /** A file that only exists at the package root, used to find it from `src/` and from `dist/`. */
1307
+ const ROOT_MARKER = path.join("rulesets", "main.json");
1308
+ /**
1309
+ * The package root: the closest parent directory that holds `rulesets/main.json`.
1310
+ *
1311
+ * @returns The absolute path of the package root.
1312
+ * @throws {Error} When no parent directory holds the marker, which means a broken install.
1313
+ * @example
1314
+ * path.join(packageRoot(), "examples");
1315
+ */
1316
+ function packageRoot() {
1317
+ let directory = path.dirname(fileURLToPath(import.meta.url));
1318
+ while (!existsSync(path.join(directory, ROOT_MARKER))) {
1319
+ const parent = path.dirname(directory);
1320
+ if (parent === directory) throw new Error(`moku-release: ${ROOT_MARKER} not found above ${import.meta.url}`);
1321
+ directory = parent;
1322
+ }
1323
+ return directory;
1324
+ }
1325
+ /**
1326
+ * Read one central definition, for example `examples/package/ci.yml`.
1327
+ *
1328
+ * @param relativePath - The path inside the package.
1329
+ * @returns The file contents.
1330
+ * @example
1331
+ * readCentral("rulesets/main.json");
1332
+ */
1333
+ function readCentral(relativePath) {
1334
+ return readFileSync(path.join(packageRoot(), relativePath), "utf8");
1335
+ }
1336
+ //#endregion
1337
+ //#region src/templates/publish.ts
1338
+ /**
1339
+ * @file `moku-release` — the `.github/workflows/publish.yml` template.
1340
+ *
1341
+ * Read from this package's own `examples/package/publish.yml`. The FILE NAME is the
1342
+ * contract: npm Trusted Publishing validates the CALLING workflow's filename, so this file
1343
+ * must stay `publish.yml` even though the publish itself happens inside the central
1344
+ * reusable workflow. There is no `NPM_TOKEN` anywhere — `id-token: write` is the whole
1345
+ * credential.
1346
+ */
1347
+ /** Repo-relative path this template is written to. Registered with npm — never rename it. */
1348
+ const PUBLISH_WORKFLOW_PATH = ".github/workflows/publish.yml";
1349
+ /**
1350
+ * The reusable workflow ref `doctor` recognizes a migrated `publish.yml` by. The
1351
+ * `publish.local-publish.yml` fallback variant calls the same ref, so it is recognized too.
1352
+ */
1353
+ const PUBLISH_WORKFLOW_REF = "moku-labs/ci/.github/workflows/package-release.yml@v1";
1354
+ /** The `publish.yml` body, read from `examples/package/publish.yml`. */
1355
+ const PUBLISH_WORKFLOW = readCentral("examples/package/publish.yml");
1356
+ //#endregion
1357
+ //#region src/checks/trusted-publisher.ts
1358
+ /**
1359
+ * @file `moku-release` — check: npm knows this repo's `publish.yml` as a trusted publisher.
1360
+ *
1361
+ * Without the registration the release workflow's OIDC token is rejected and the publish
1362
+ * step fails at the very end of a run, so this is fail-level. An npm too old to even have
1363
+ * `npm trust` warns instead — the answer there is an upgrade, not a registration.
1364
+ */
1365
+ /** Basename npm registers the publisher against — the workflow file's name, not its path. */
1366
+ const PUBLISH_WORKFLOW_FILE$1 = ".github/workflows/publish.yml".split("/").pop() ?? "publish.yml";
1367
+ /**
1368
+ * Whether npm's output says the `trust` command itself does not exist.
1369
+ *
1370
+ * @param output - The combined stdout/stderr npm produced.
1371
+ * @returns `true` when the installed npm has no `trust` command.
1372
+ * @example
1373
+ * isUnknownCommand("Unknown command: \"trust\"");
1374
+ */
1375
+ function isUnknownCommand(output) {
1376
+ return /unknown command|did you mean|not a recognized/i.test(output);
1377
+ }
1378
+ /**
1379
+ * Whether npm refused the listing because nobody is logged in.
1380
+ *
1381
+ * @param output - The combined stdout/stderr npm produced.
1382
+ * @returns `true` when the failure is an authentication failure, not a missing registration.
1383
+ * @example
1384
+ * isUnauthorized("npm error code E401");
1385
+ */
1386
+ function isUnauthorized(output) {
1387
+ return /E401|ENEEDAUTH|401 Unauthorized/i.test(output);
1388
+ }
1389
+ /**
1390
+ * The exact registration command for this package and repository.
1391
+ *
1392
+ * @param name - The package name.
1393
+ * @param ownerRepo - The `owner/repo` slug.
1394
+ * @returns The `npm trust github …` command line.
1395
+ * @example
1396
+ * trustCommand("@moku-labs/common", "moku-labs/common");
1397
+ */
1398
+ function trustCommand(name, ownerRepo) {
1399
+ return `npm trust github ${name} --file ${PUBLISH_WORKFLOW_FILE$1} --repo ${ownerRepo} --yes`;
1400
+ }
1401
+ /** Verifies a trusted publisher is registered for the package. */
1402
+ const trustedPublisherCheck = {
1403
+ id: "trusted-publisher",
1404
+ title: "npm trusted publisher registered",
1405
+ /**
1406
+ * List the package's trusted publishers and look for this repo's `publish.yml`.
1407
+ *
1408
+ * @param ctx - The injected ports.
1409
+ * @returns Pass when registered, warn when npm is too old, skip when logged out, otherwise the exact command.
1410
+ * @example
1411
+ * await trustedPublisherCheck.run(ctx);
1412
+ */
1413
+ async run(ctx) {
1414
+ const manifest = await readManifest(ctx.files);
1415
+ if (!manifest?.name) return skip("package.json declares no `name`");
1416
+ const declared = repositoryUrlOf(manifest);
1417
+ const ownerRepo = declared === void 0 ? void 0 : ownerRepoFrom(declared);
1418
+ if (ownerRepo === void 0) return skip("cannot derive owner/repo from repository.url");
1419
+ const listing = await ctx.exec.capture("npm", [
1420
+ "trust",
1421
+ "list",
1422
+ manifest.name
1423
+ ]);
1424
+ if (isUnknownCommand(`${listing.stdout}${listing.stderr}`)) return warn("this npm has no `trust` command", "upgrade npm");
1425
+ if (isUnauthorized(`${listing.stdout}${listing.stderr}`)) return skip("cannot list trusted publishers without `npm login`");
1426
+ if (listing.code !== 0 || !listing.stdout.includes(PUBLISH_WORKFLOW_FILE$1)) return fail("no trusted publisher registered", trustCommand(manifest.name, ownerRepo));
1427
+ return pass(`${ownerRepo} · ${PUBLISH_WORKFLOW_FILE$1}`);
1428
+ }
1429
+ };
1430
+ //#endregion
1431
+ //#region src/templates/ci.ts
1432
+ /**
1433
+ * @file `moku-release` — the `.github/workflows/ci.yml` template.
1434
+ *
1435
+ * Read from this package's own `examples/package/ci.yml`. It is a thin caller: the
1436
+ * whole check matrix lives once in the central reusable workflow, so a pipeline change is
1437
+ * one PR there instead of one per repository. Two details are load-bearing and must not be
1438
+ * "tidied": the caller job id is `ci` (GitHub prefixes the reused jobs with it, so the
1439
+ * required checks are `ci / lint`, `ci / types`, `ci / test`, `ci / build`), and there is
1440
+ * deliberately NO concurrency block — the called workflow already groups by
1441
+ * `github.workflow`, and a caller group with the same value deadlocks against its own child.
1442
+ */
1443
+ /** Repo-relative path this template is written to. */
1444
+ const CI_WORKFLOW_PATH = ".github/workflows/ci.yml";
1445
+ /** The reusable workflow ref `doctor` recognizes a migrated `ci.yml` by. */
1446
+ const CI_WORKFLOW_REF = "moku-labs/ci/.github/workflows/package-ci.yml@v1";
1447
+ /** The `ci.yml` body, read from `examples/package/ci.yml`. */
1448
+ const CI_WORKFLOW = readCentral("examples/package/ci.yml");
1449
+ //#endregion
1450
+ //#region src/templates/ruleset.ts
1451
+ /**
1452
+ * @file `moku-release` — the branch ruleset `setup` applies to `main`.
1453
+ *
1454
+ * Read from this package's own `rulesets/main.json`: PR-only main, no deletion, no
1455
+ * force-push, and the four required checks the thin `ci.yml` produces (`ci / lint`,
1456
+ * `ci / types`, `ci / test`, `ci / build`). Tags are deliberately NOT restricted — the
1457
+ * release workflow pushes `v*` tags, and a tag ruleset would block every release.
1458
+ */
1459
+ /** The ruleset payload for `gh api repos/{owner}/{repo}/rulesets --input -`, read from `rulesets/main.json`. */
1460
+ const MAIN_RULESET_JSON = readCentral("rulesets/main.json");
1461
+ //#endregion
1462
+ //#region src/lib/templates.ts
1463
+ /**
1464
+ * @file `moku-release` — the workflow template surface: what to write, where, and how to
1465
+ * tell a migrated workflow from a hand-written legacy one.
1466
+ *
1467
+ * `setup` writes what {@link workflowTemplates} lists; `doctor` reads the same list back
1468
+ * and asks {@link isThinWorkflow} whether the file on disk still calls the pinned central
1469
+ * workflow. One list, both directions — a template can never drift from its check.
1470
+ *
1471
+ * The bodies are read from this package's own `examples/package/*.yml` and
1472
+ * `rulesets/main.json`: this CLI distributes the central definitions, it does not copy them.
1473
+ */
1474
+ /** Both generated workflows, in the order `setup` writes and `doctor` reports them. */
1475
+ const workflowTemplates = [{
1476
+ path: CI_WORKFLOW_PATH,
1477
+ content: CI_WORKFLOW,
1478
+ ref: CI_WORKFLOW_REF
1479
+ }, {
1480
+ path: PUBLISH_WORKFLOW_PATH,
1481
+ content: PUBLISH_WORKFLOW,
1482
+ ref: PUBLISH_WORKFLOW_REF
1483
+ }];
1484
+ /**
1485
+ * Whether a workflow file on disk is the thin caller — i.e. it delegates to the pinned
1486
+ * central reusable workflow. A file that does not is a legacy hand-written pipeline.
1487
+ * Matching on the `@v1` ref rather than on the whole body is deliberate: the
1488
+ * `publish.local-publish.yml` fallback variant calls the same ref and must also pass.
1489
+ *
1490
+ * @param content - The file contents read from disk.
1491
+ * @param template - The template the file is expected to match.
1492
+ * @returns `true` when the file calls the pinned reusable workflow.
1493
+ * @example
1494
+ * isThinWorkflow(onDisk, workflowTemplates[0]);
1495
+ */
1496
+ function isThinWorkflow(content, template) {
1497
+ return content.includes(template.ref);
1498
+ }
1499
+ /**
1500
+ * The branch-ruleset payload `gh api … --input -` reads — the central definition verbatim.
1501
+ *
1502
+ * @returns The ruleset JSON.
1503
+ * @example
1504
+ * const body = renderMainRuleset();
1505
+ */
1506
+ function renderMainRuleset() {
1507
+ return MAIN_RULESET_JSON;
1508
+ }
1509
+ //#endregion
1510
+ //#region src/checks/workflows.ts
1511
+ /**
1512
+ * @file `moku-release` — check: both workflows exist and are the thin callers.
1513
+ *
1514
+ * Three outcomes, and the middle one matters most: a MISSING workflow fails, a
1515
+ * hand-written one warns ("legacy workflow, run release:setup to migrate") because the
1516
+ * repo still releases — just not through the shared pipeline — and a pinned thin caller
1517
+ * passes.
1518
+ */
1519
+ /** Advisory shown for a workflow that exists but does not call the central pipeline. */
1520
+ const LEGACY_FIX = "legacy workflow, run release:setup to migrate";
1521
+ /** Verifies `ci.yml` and `publish.yml` are the `@v1`-pinned thin callers. */
1522
+ const workflowsCheck = {
1523
+ id: "workflows",
1524
+ title: "workflows are thin callers pinned to @v1",
1525
+ /**
1526
+ * Read both workflow files and classify each as missing, legacy or thin.
1527
+ *
1528
+ * @param ctx - The injected ports.
1529
+ * @returns Fail for a missing file, warn for a legacy one, pass when both are thin.
1530
+ * @example
1531
+ * await workflowsCheck.run(ctx);
1532
+ */
1533
+ async run(ctx) {
1534
+ const missing = [];
1535
+ const legacy = [];
1536
+ for (const template of workflowTemplates) {
1537
+ const content = await ctx.files.read(template.path);
1538
+ if (content === void 0) missing.push(template.path);
1539
+ else if (!isThinWorkflow(content, template)) legacy.push(template.path);
1540
+ }
1541
+ if (missing.length > 0) return fail(`missing ${missing.join(", ")}`, "moku-release setup");
1542
+ if (legacy.length > 0) return warn(`${legacy.join(", ")} not pinned to @v1`, LEGACY_FIX);
1543
+ return pass("ci.yml + publish.yml pinned to @v1");
1544
+ }
1545
+ };
1546
+ //#endregion
1547
+ //#region src/checks/working-tree.ts
1548
+ /**
1549
+ * @file `moku-release` — check: the working tree is clean and HEAD is `origin/main`.
1550
+ *
1551
+ * The only check whose severity depends on the caller: in `doctor` a dirty tree is
1552
+ * information (`warn`), in the `release` preflight it is a stop condition (`fail`) —
1553
+ * releasing from a state the remote has never seen produces a tag nobody can reproduce.
1554
+ * `ctx.strict` is the switch.
1555
+ */
1556
+ /** Ref the release is always cut from. */
1557
+ const RELEASE_REF = "origin/main";
1558
+ /**
1559
+ * Report a problem at the severity the calling command asked for.
1560
+ *
1561
+ * @param ctx - The injected ports, carrying the `strict` flag.
1562
+ * @param detail - What was observed.
1563
+ * @param fixLine - The command that resolves it.
1564
+ * @returns A failing result under `strict`, otherwise a warning.
1565
+ * @example
1566
+ * atSeverity(ctx, "working tree is dirty", "git status");
1567
+ */
1568
+ function atSeverity(ctx, detail, fixLine) {
1569
+ return ctx.strict ? fail(detail, fixLine) : warn(detail, fixLine);
1570
+ }
1571
+ //#endregion
1572
+ //#region src/checks/index.ts
1573
+ /** Every diagnostic `doctor` runs, in report order. */
1574
+ const allChecks = [
1575
+ ghAuthCheck,
1576
+ npmAuthCheck,
1577
+ npmVersionCheck,
1578
+ packageContractCheck,
1579
+ repositoryUrlCheck,
1580
+ workflowsCheck,
1581
+ npmPackageCheck,
1582
+ trustedPublisherCheck,
1583
+ tagSyncCheck,
1584
+ branchRulesetCheck,
1585
+ {
1586
+ id: "working-tree",
1587
+ title: "clean tree on origin/main",
1588
+ /**
1589
+ * Compare the porcelain status and the HEAD / `origin/main` revisions.
1590
+ *
1591
+ * @param ctx - The injected ports.
1592
+ * @returns Pass when both agree; severity follows `ctx.strict` otherwise.
1593
+ * @example
1594
+ * await workingTreeCheck.run({ ...ctx, strict: true });
1595
+ */
1596
+ async run(ctx) {
1597
+ const status = await ctx.exec.capture("git", ["status", "--porcelain"]);
1598
+ if (status.code !== 0) return skip("not a git repository");
1599
+ if (status.stdout.trim() !== "") return atSeverity(ctx, "working tree has uncommitted changes", "commit or stash them");
1600
+ const head = await ctx.exec.capture("git", ["rev-parse", "HEAD"]);
1601
+ const remote = await ctx.exec.capture("git", ["rev-parse", RELEASE_REF]);
1602
+ if (head.code !== 0 || remote.code !== 0) return skip(`cannot resolve ${RELEASE_REF}`);
1603
+ if (head.stdout.trim() !== remote.stdout.trim()) return atSeverity(ctx, `HEAD is not ${RELEASE_REF}`, `git pull --ff-only origin main`);
1604
+ return pass(`clean, in sync with ${RELEASE_REF}`);
1605
+ }
1606
+ }
1607
+ ];
1608
+ //#endregion
1609
+ //#region src/commands/doctor.ts
1610
+ /** Glyphs for the two statuses the branded `check` row cannot express on its own. */
1611
+ const STATUS_GLYPH = {
1612
+ warn: "⚠",
1613
+ skip: "–"
1614
+ };
1615
+ /**
1616
+ * Print one entry: pass/fail through the branded check row, warn/skip as their own glyph
1617
+ * so a warning never reads as a failure.
1618
+ *
1619
+ * @param ui - The branded console.
1620
+ * @param entry - The check outcome to render.
1621
+ * @example
1622
+ * renderEntry(ui, { id: "npm-auth", title: "npm is logged in", status: "pass", detail: "ok" });
1623
+ */
1624
+ function renderEntry(ui, entry) {
1625
+ const label = `${entry.title} — ${entry.detail}`;
1626
+ const hint = entry.fix === void 0 ? void 0 : `fix: ${entry.fix}`;
1627
+ if (entry.status === "pass" || entry.status === "fail") {
1628
+ ui.check(entry.status === "pass", label, hint);
1629
+ return;
1630
+ }
1631
+ const glyph = STATUS_GLYPH[entry.status];
1632
+ ui.line(` ${ui.palette.yellow(glyph)} ${label}`);
1633
+ if (hint !== void 0) ui.line(` ${ui.palette.dim(hint)}`);
1634
+ }
1635
+ /**
1636
+ * Run one check, turning an unexpected throw into a failing entry rather than a crash —
1637
+ * a broken diagnostic must never hide the other ten.
1638
+ *
1639
+ * @param check - The check to run.
1640
+ * @param ctx - The ports and flags.
1641
+ * @returns The flattened entry.
1642
+ * @example
1643
+ * await evaluate(npmAuthCheck, ctx);
1644
+ */
1645
+ async function evaluate(check, ctx) {
1646
+ try {
1647
+ return {
1648
+ id: check.id,
1649
+ title: check.title,
1650
+ ...await check.run(ctx)
1651
+ };
1652
+ } catch (error) {
1653
+ return {
1654
+ id: check.id,
1655
+ title: check.title,
1656
+ status: "fail",
1657
+ detail: `check threw: ${String(error)}`,
1658
+ fix: "report this as a moku-release bug"
1659
+ };
1660
+ }
1661
+ }
1662
+ /**
1663
+ * Run every check in order and report. Checks run sequentially on purpose: the report is
1664
+ * read top to bottom, and the tools they shell out to are not all concurrency-safe.
1665
+ *
1666
+ * @param options - The ports, console, and output mode.
1667
+ * @returns The entries and whether anything failed.
1668
+ * @example
1669
+ * const report = await runDoctor({ ctx, ui });
1670
+ */
1671
+ async function runDoctor(options) {
1672
+ const { ctx, ui, json = false, checks = allChecks } = options;
1673
+ const entries = [];
1674
+ for (const check of checks) entries.push(await evaluate(check, ctx));
1675
+ const failed = entries.some((entry) => entry.status === "fail");
1676
+ if (json) {
1677
+ ui.line(JSON.stringify({
1678
+ failed,
1679
+ checks: entries
1680
+ }, void 0, 2));
1681
+ return {
1682
+ entries,
1683
+ failed
1684
+ };
1685
+ }
1686
+ for (const entry of entries) renderEntry(ui, entry);
1687
+ return {
1688
+ entries,
1689
+ failed
1690
+ };
1691
+ }
1692
+ //#endregion
1693
+ //#region src/commands/release.ts
1694
+ /** Workflow file dispatched by name — the same name npm's trusted publisher is bound to. */
1695
+ const PUBLISH_WORKFLOW_FILE = ".github/workflows/publish.yml".split("/").pop() ?? "publish.yml";
1696
+ /** How long to keep asking the registry for the new version before giving up. */
1697
+ const REGISTRY_POLL_ATTEMPTS = 24;
1698
+ /** Gap between registry polls — 24 × 5s ≈ two minutes of registry lag tolerated. */
1699
+ const REGISTRY_POLL_INTERVAL_MS = 5e3;
1700
+ /** How many times to look for the dispatched run before concluding it never started. */
1701
+ const RUN_LOOKUP_ATTEMPTS = 10;
1702
+ /** Gap between run lookups — GitHub takes a moment to materialize a dispatched run. */
1703
+ const RUN_LOOKUP_INTERVAL_MS = 3e3;
1704
+ /**
1705
+ * The default delay helper.
1706
+ *
1707
+ * @param ms - Milliseconds to wait.
1708
+ * @returns Resolves after the delay.
1709
+ * @example
1710
+ * await defaultSleep(500);
1711
+ */
1712
+ const defaultSleep = (ms) => new Promise((resolve) => {
1713
+ setTimeout(resolve, ms);
1714
+ });
1715
+ /**
1716
+ * Run the strict preflight and print it. Strict mode is what turns the advisory
1717
+ * working-tree check into a stop condition.
1718
+ *
1719
+ * @param ctx - The ports and flags.
1720
+ * @param ui - The branded console.
1721
+ * @returns `true` when nothing blocks the release.
1722
+ * @example
1723
+ * if (!(await preflight(ctx, ui))) return 1;
1724
+ */
1725
+ async function preflight(ctx, ui) {
1726
+ ui.heading("Preflight");
1727
+ const report = await runDoctor({
1728
+ ctx: {
1729
+ ...ctx,
1730
+ strict: true
1731
+ },
1732
+ ui,
1733
+ checks: allChecks
1734
+ });
1735
+ if (report.failed) ui.error("preflight failed — nothing was dispatched");
1736
+ return !report.failed;
1737
+ }
1738
+ /**
1739
+ * Find the run the dispatch just created, retrying while GitHub materializes it.
1740
+ *
1741
+ * @param ctx - The ports and flags.
1742
+ * @param sleep - The delay helper.
1743
+ * @returns The run id, or `undefined` when no run appeared.
1744
+ * @example
1745
+ * const runId = await findDispatchedRun(ctx, defaultSleep);
1746
+ */
1747
+ async function findDispatchedRun(ctx, sleep) {
1748
+ const args = [
1749
+ "run",
1750
+ "list",
1751
+ "--workflow",
1752
+ PUBLISH_WORKFLOW_FILE,
1753
+ "--branch",
1754
+ "main",
1755
+ "--limit",
1756
+ "1",
1757
+ "--json",
1758
+ "databaseId"
1759
+ ];
1760
+ for (let attempt = 0; attempt < RUN_LOOKUP_ATTEMPTS; attempt += 1) {
1761
+ const listing = await ctx.exec.capture("gh", args);
1762
+ const runId = listing.code === 0 ? latestRunId(listing.stdout) : void 0;
1763
+ if (runId !== void 0) return runId;
1764
+ await sleep(RUN_LOOKUP_INTERVAL_MS);
1765
+ }
1766
+ }
1767
+ /**
1768
+ * Poll the registry until the expected dist-tag moves off `before`. The registry lags
1769
+ * behind a successful publish, so "not there yet" is expected for a while and only a
1770
+ * timeout is an error.
1771
+ *
1772
+ * @param ctx - The ports and flags.
1773
+ * @param name - The package name.
1774
+ * @param tag - The dist-tag the new version lands under.
1775
+ * @param before - The version that tag pointed at before the release.
1776
+ * @param sleep - The delay helper.
1777
+ * @returns The new version, or `undefined` when the registry never moved.
1778
+ * @example
1779
+ * await awaitPublishedVersion(ctx, "@moku-labs/common", "latest", "1.2.2", defaultSleep);
1780
+ */
1781
+ async function awaitPublishedVersion(ctx, name, tag, before, sleep) {
1782
+ for (let attempt = 0; attempt < REGISTRY_POLL_ATTEMPTS; attempt += 1) {
1783
+ const view = await ctx.exec.capture("npm", [
1784
+ "view",
1785
+ name,
1786
+ "dist-tags",
1787
+ "--json"
1788
+ ]);
1789
+ const current = view.code === 0 ? parseDistTags(view.stdout)?.[tag] : void 0;
1790
+ if (current !== void 0 && current !== before) return current;
1791
+ await sleep(REGISTRY_POLL_INTERVAL_MS);
1792
+ }
1793
+ }
1794
+ /**
1795
+ * Print the closing summary: what shipped, where its tag is, and the two links a human
1796
+ * actually clicks.
1797
+ *
1798
+ * @param ui - The branded console.
1799
+ * @param name - The package name.
1800
+ * @param version - The published version.
1801
+ * @param ownerRepo - The `owner/repo` slug, when one is known.
1802
+ * @example
1803
+ * renderSummary(ui, "@moku-labs/common", "1.2.3", "moku-labs/common");
1804
+ */
1805
+ function renderSummary(ui, name, version, ownerRepo) {
1806
+ const tag = `v${version}`;
1807
+ const lines = [
1808
+ ui.railLine(`${name}`, version, 48),
1809
+ ui.railLine("tag", tag, 48),
1810
+ ui.railLine("npm", npmPackageUrl(name, version), 48)
1811
+ ];
1812
+ if (ownerRepo !== void 0) lines.push(ui.railLine("release", releaseUrl(ownerRepo, tag), 48));
1813
+ ui.heading("Released");
1814
+ ui.box(lines);
1815
+ }
1816
+ /**
1817
+ * Cut a release: preflight, dispatch `publish.yml`, watch the run, verify the artifact.
1818
+ *
1819
+ * @param options - The ports, console, bump type and flags.
1820
+ * @returns The process exit code.
1821
+ * @example
1822
+ * const code = await runRelease({ ctx, ui, releaseType: "patch" });
1823
+ */
1824
+ async function runRelease(options) {
1825
+ const { ctx, ui, releaseType, dryRun = false, sleep = defaultSleep } = options;
1826
+ ui.lockup({
1827
+ wordmark: "moku release",
1828
+ label: dryRun ? `${releaseType} · dry-run` : releaseType
1829
+ });
1830
+ const manifest = await readManifest(ctx.files);
1831
+ if (!manifest?.name) {
1832
+ ui.error("package.json is missing, malformed, or has no `name`");
1833
+ return 1;
1834
+ }
1835
+ await ctx.exec.capture("git", [
1836
+ "fetch",
1837
+ "--tags",
1838
+ "--prune"
1839
+ ]);
1840
+ if (!await preflight(ctx, ui)) return 1;
1841
+ const declared = repositoryUrlOf(manifest);
1842
+ const ownerRepo = declared === void 0 ? void 0 : ownerRepoFrom(declared);
1843
+ const tag = distTagFor(releaseType === "prerelease" ? "0.0.0-rc.0" : "0.0.0");
1844
+ const before = parseDistTags((await ctx.exec.capture("npm", [
1845
+ "view",
1846
+ manifest.name,
1847
+ "dist-tags",
1848
+ "--json"
1849
+ ])).stdout)?.[tag];
1850
+ if (dryRun) {
1851
+ ui.heading("Plan");
1852
+ ui.info(`gh workflow run ${PUBLISH_WORKFLOW_FILE} -f release_type=${releaseType} --ref main`);
1853
+ ui.info(`watch the run, then wait for npm dist-tag \`${tag}\` to move from ${before ?? "—"}`);
1854
+ return 0;
1855
+ }
1856
+ ui.heading("Dispatch");
1857
+ const dispatched = await ctx.exec.capture("gh", [
1858
+ "workflow",
1859
+ "run",
1860
+ PUBLISH_WORKFLOW_FILE,
1861
+ "-f",
1862
+ `release_type=${releaseType}`,
1863
+ "--ref",
1864
+ "main"
1865
+ ]);
1866
+ if (dispatched.code !== 0) {
1867
+ ui.error("could not dispatch the workflow", dispatched.stderr.trim());
1868
+ return 1;
1869
+ }
1870
+ ui.check(true, `${PUBLISH_WORKFLOW_FILE} dispatched (${releaseType})`);
1871
+ const runId = await findDispatchedRun(ctx, sleep);
1872
+ if (runId === void 0) {
1873
+ ui.error("the dispatched run never appeared — check GitHub Actions");
1874
+ return 1;
1875
+ }
1876
+ ui.heading("Run");
1877
+ if (await ctx.exec.inherit("gh", [
1878
+ "run",
1879
+ "watch",
1880
+ runId,
1881
+ "--exit-status"
1882
+ ]) !== 0) {
1883
+ ui.error(`run ${runId} did not succeed`);
1884
+ return 1;
1885
+ }
1886
+ ui.heading("Registry");
1887
+ const version = await awaitPublishedVersion(ctx, manifest.name, tag, before, sleep);
1888
+ if (version === void 0) {
1889
+ ui.error(`npm dist-tag \`${tag}\` did not move — the run passed but nothing was published`);
1890
+ return 1;
1891
+ }
1892
+ renderSummary(ui, manifest.name, version, ownerRepo);
1893
+ return 0;
1894
+ }
1895
+ //#endregion
1896
+ //#region src/commands/setup.ts
1897
+ /**
1898
+ * Announce a mutation under `--dry-run`. Every step calls this before acting; a `true`
1899
+ * answer means "already reported, do nothing".
1900
+ *
1901
+ * @param setup - The wizard state.
1902
+ * @param description - The mutation, phrased as an infinitive ("write .github/…").
1903
+ * @returns `true` when the run is a dry run and the caller must not act.
1904
+ * @example
1905
+ * if (deferred(setup, "write .github/workflows/ci.yml")) return;
1906
+ */
1907
+ function deferred(setup, description) {
1908
+ if (!setup.dryRun) return false;
1909
+ setup.ui.info(`would ${description}`);
1910
+ return true;
1911
+ }
1912
+ /**
1913
+ * Verify the two human-only prerequisites. Nothing else in the wizard can run without
1914
+ * them, and neither can be automated — the wizard prints the command and stops.
1915
+ *
1916
+ * @param setup - The wizard state.
1917
+ * @returns `true` when both `gh` and npm are authenticated.
1918
+ * @example
1919
+ * if (!(await ensurePrerequisites(setup))) return 1;
1920
+ */
1921
+ async function ensurePrerequisites(setup) {
1922
+ setup.ui.heading("Prerequisites");
1923
+ for (const check of [ghAuthCheck, npmAuthCheck]) {
1924
+ const result = await check.run(setup.ctx);
1925
+ setup.ui.check(result.status === "pass", `${check.title} — ${result.detail}`, result.fix);
1926
+ if (result.status === "fail") {
1927
+ setup.ui.error(`run this yourself, then re-run setup: ${result.fix}`);
1928
+ return false;
1929
+ }
1930
+ }
1931
+ return true;
1932
+ }
1933
+ /**
1934
+ * Write the two thin workflows. A file that already calls the pinned central workflow is
1935
+ * left alone; a differing file is only replaced after an explicit confirm, and a `.bak`
1936
+ * copy is kept.
1937
+ *
1938
+ * @param setup - The wizard state.
1939
+ * @returns Nothing.
1940
+ * @example
1941
+ * await writeWorkflows(setup);
1942
+ */
1943
+ async function writeWorkflows(setup) {
1944
+ setup.ui.heading("Workflows");
1945
+ for (const template of workflowTemplates) {
1946
+ const existing = await setup.ctx.files.read(template.path);
1947
+ if (existing === template.content) {
1948
+ setup.ui.check(true, `${template.path} up to date`);
1949
+ continue;
1950
+ }
1951
+ if (existing !== void 0) {
1952
+ const kind = isThinWorkflow(existing, template) ? "differs" : "is a legacy workflow";
1953
+ if (!await setup.prompts.confirm(`${template.path} ${kind}. Replace it (a .bak copy is kept)?`)) {
1954
+ setup.ui.check(false, `${template.path} left unchanged`);
1955
+ continue;
1956
+ }
1957
+ if (deferred(setup, `back up and replace ${template.path}`)) continue;
1958
+ await setup.ctx.files.backup(template.path);
1959
+ } else if (deferred(setup, `write ${template.path}`)) continue;
1960
+ await setup.ctx.files.write(template.path, template.content);
1961
+ setup.ui.check(true, `${template.path} written`);
1962
+ }
1963
+ }
1964
+ /**
1965
+ * Bring `package.json` onto the release contract. The change summary is printed first and
1966
+ * applied only after a confirm; an already-compliant manifest is a checkmark.
1967
+ *
1968
+ * @param setup - The wizard state.
1969
+ * @param manifest - The manifest read at the start of the run.
1970
+ * @returns Nothing.
1971
+ * @example
1972
+ * await normalizeContract(setup, manifest);
1973
+ */
1974
+ async function normalizeContract(setup, manifest) {
1975
+ setup.ui.heading("package.json");
1976
+ const origin = await setup.ctx.exec.capture("git", [
1977
+ "remote",
1978
+ "get-url",
1979
+ "origin"
1980
+ ]);
1981
+ const { manifest: next, changes } = normalizeManifest(manifest, origin.code === 0 ? origin.stdout.trim() : void 0);
1982
+ if (changes.length === 0) {
1983
+ setup.ui.check(true, "already on contract");
1984
+ return;
1985
+ }
1986
+ for (const change of changes) setup.ui.line(` ${setup.ui.palette.dim(change)}`);
1987
+ if (!await setup.prompts.confirm(`Apply ${changes.length} change(s) to package.json?`)) {
1988
+ setup.ui.check(false, "package.json left unchanged");
1989
+ return;
1990
+ }
1991
+ if (deferred(setup, `write package.json`)) return;
1992
+ await setup.ctx.files.write(MANIFEST_PATH, formatManifest(next));
1993
+ setup.ui.check(true, `${MANIFEST_PATH} normalized`);
1994
+ }
1995
+ /**
1996
+ * Perform the very first publish, if the package has no registry presence yet. Stdio is
1997
+ * inherited so npm — not this CLI — prompts for an OTP.
1998
+ *
1999
+ * @param setup - The wizard state.
2000
+ * @param name - The package name.
2001
+ * @param version - The version about to be published.
2002
+ * @returns `true` when the package exists on npm after this step.
2003
+ * @example
2004
+ * await firstPublish(setup, "@moku-labs/common", "0.2.0");
2005
+ */
2006
+ async function firstPublish(setup, name, version) {
2007
+ setup.ui.heading("First publish");
2008
+ const view = await setup.ctx.exec.capture("npm", [
2009
+ "view",
2010
+ name,
2011
+ "version"
2012
+ ]);
2013
+ if (view.code === 0) {
2014
+ setup.ui.check(true, `${name}@${view.stdout.trim()} already on npm`);
2015
+ return true;
2016
+ }
2017
+ if (!await setup.prompts.confirm(`Publish ${name}@${version} to npm now?`)) {
2018
+ setup.ui.check(false, "first publish skipped");
2019
+ return false;
2020
+ }
2021
+ if (deferred(setup, `run bun run build && npm publish --access public`)) return false;
2022
+ if (await setup.ctx.exec.inherit("bun", ["run", "build"]) !== 0) {
2023
+ setup.ui.error("build failed — not publishing");
2024
+ return false;
2025
+ }
2026
+ const published = await setup.ctx.exec.inherit("npm", [
2027
+ "publish",
2028
+ "--access",
2029
+ "public"
2030
+ ]);
2031
+ setup.ui.check(published === 0, `npm publish ${name}@${version}`);
2032
+ return published === 0;
2033
+ }
2034
+ /**
2035
+ * Tag the published version and push ONLY that tag — the branch is never written from
2036
+ * here.
2037
+ *
2038
+ * @param setup - The wizard state.
2039
+ * @param version - The version that was published.
2040
+ * @returns Nothing.
2041
+ * @example
2042
+ * await pushVersionTag(setup, "0.2.0");
2043
+ */
2044
+ async function pushVersionTag(setup, version) {
2045
+ setup.ui.heading("Tag");
2046
+ const tag = `v${version}`;
2047
+ if ((await setup.ctx.exec.capture("git", [
2048
+ "tag",
2049
+ "--list",
2050
+ tag
2051
+ ])).stdout.trim() !== "") {
2052
+ setup.ui.check(true, `${tag} already exists`);
2053
+ return;
2054
+ }
2055
+ if (deferred(setup, `tag ${tag} and push it`)) return;
2056
+ await setup.ctx.exec.capture("git", [
2057
+ "tag",
2058
+ "-a",
2059
+ tag,
2060
+ "-m",
2061
+ tag
2062
+ ]);
2063
+ const pushed = await setup.ctx.exec.capture("git", [
2064
+ "push",
2065
+ "origin",
2066
+ `refs/tags/${tag}`
2067
+ ]);
2068
+ setup.ui.check(pushed.code === 0, `${tag} pushed`, pushed.code === 0 ? void 0 : pushed.stderr);
2069
+ }
2070
+ /**
2071
+ * Register this repository's `publish.yml` as npm's trusted publisher, so the release
2072
+ * workflow publishes over OIDC and no `NPM_TOKEN` ever exists.
2073
+ *
2074
+ * @param setup - The wizard state.
2075
+ * @returns Nothing.
2076
+ * @example
2077
+ * await registerTrustedPublisher(setup);
2078
+ */
2079
+ async function registerTrustedPublisher(setup) {
2080
+ setup.ui.heading("Trusted publisher");
2081
+ const result = await trustedPublisherCheck.run(setup.ctx);
2082
+ if (result.status !== "fail" || result.fix === void 0) {
2083
+ setup.ui.check(result.status === "pass", `${result.detail}`, result.fix);
2084
+ return;
2085
+ }
2086
+ if (deferred(setup, result.fix)) return;
2087
+ const [command = "npm", ...args] = result.fix.split(" ");
2088
+ const code = await setup.ctx.exec.inherit(command, args);
2089
+ setup.ui.check(code === 0, "trusted publisher registered");
2090
+ }
2091
+ /**
2092
+ * Apply the PR-only branch ruleset to the default branch. Tags stay unrestricted — the
2093
+ * release workflow pushes them.
2094
+ *
2095
+ * @param setup - The wizard state.
2096
+ * @param ownerRepo - The `owner/repo` slug.
2097
+ * @returns Nothing.
2098
+ * @example
2099
+ * await applyBranchRuleset(setup, "moku-labs/common");
2100
+ */
2101
+ async function applyBranchRuleset(setup, ownerRepo) {
2102
+ setup.ui.heading("Branch ruleset");
2103
+ const result = await branchRulesetCheck.run(setup.ctx);
2104
+ if (result.status === "pass") {
2105
+ setup.ui.check(true, result.detail);
2106
+ return;
2107
+ }
2108
+ if (deferred(setup, `create the PR-only branch ruleset on ${ownerRepo}`)) return;
2109
+ const created = await setup.ctx.exec.capture("gh", [
2110
+ "api",
2111
+ `repos/${ownerRepo}/rulesets`,
2112
+ "--method",
2113
+ "POST",
2114
+ "--input",
2115
+ "-"
2116
+ ], { input: renderMainRuleset() });
2117
+ setup.ui.check(created.code === 0, "branch ruleset applied", created.stderr.trim() || void 0);
2118
+ }
2119
+ /**
2120
+ * Run the whole wizard. Every step is skippable, idempotent, and re-runnable; the last one
2121
+ * is always `doctor`, so the wizard's own verdict is the same report the operator gets
2122
+ * from `release:doctor`.
2123
+ *
2124
+ * @param options - The ports, console, prompts and dry-run flag.
2125
+ * @returns The process exit code (`0` when the final doctor run is clean).
2126
+ * @example
2127
+ * const code = await runSetup({ ctx, ui, prompts });
2128
+ */
2129
+ async function runSetup(options) {
2130
+ const setup = {
2131
+ dryRun: false,
2132
+ ...options
2133
+ };
2134
+ const { ui } = setup;
2135
+ ui.lockup({
2136
+ wordmark: "moku release",
2137
+ label: setup.dryRun ? "setup · dry-run" : "setup"
2138
+ });
2139
+ if (!await ensurePrerequisites(setup)) return 1;
2140
+ const manifest = await readManifest(setup.ctx.files);
2141
+ if (!manifest?.name || !manifest.version) {
2142
+ ui.error(`${MANIFEST_PATH} is missing, malformed, or has no name/version`);
2143
+ return 1;
2144
+ }
2145
+ await writeWorkflows(setup);
2146
+ await normalizeContract(setup, manifest);
2147
+ await firstPublish(setup, manifest.name, manifest.version);
2148
+ await pushVersionTag(setup, manifest.version);
2149
+ await registerTrustedPublisher(setup);
2150
+ const declared = repositoryUrlOf(manifest);
2151
+ const ownerRepo = declared === void 0 ? void 0 : ownerRepoFrom(declared);
2152
+ if (ownerRepo === void 0) ui.warn("no GitHub owner/repo — skipping the branch ruleset");
2153
+ else await applyBranchRuleset(setup, ownerRepo);
2154
+ ui.heading("Doctor");
2155
+ return (await runDoctor({
2156
+ ctx: setup.ctx,
2157
+ ui
2158
+ })).failed ? 1 : 0;
2159
+ }
2160
+ //#endregion
2161
+ //#region src/lib/argv.ts
2162
+ /**
2163
+ * @file `moku-release` — argv parsing, kept out of the entry so the entry stays a
2164
+ * dispatcher.
2165
+ *
2166
+ * The grammar is deliberately tiny: one positional (a command name, or a semver bump that
2167
+ * implies the `release` command) plus three flags. Anything unrecognized resolves to
2168
+ * `help` with an `error` set — the CLI never guesses what an operator meant.
2169
+ */
2170
+ /** The semver bumps `moku-release <type>` accepts, in menu order. */
2171
+ const RELEASE_TYPES = [
2172
+ "patch",
2173
+ "minor",
2174
+ "major",
2175
+ "prerelease"
2176
+ ];
2177
+ /**
2178
+ * Whether a positional argument is one of the semver bumps.
2179
+ *
2180
+ * @param value - The positional to test.
2181
+ * @returns `true` when it names a release type.
2182
+ * @example
2183
+ * isReleaseType("patch"); // true
2184
+ */
2185
+ function isReleaseType(value) {
2186
+ return RELEASE_TYPES.includes(value);
2187
+ }
2188
+ /**
2189
+ * Build a `help` result carrying the reason the invocation was rejected.
2190
+ *
2191
+ * @param error - The message shown above the usage block.
2192
+ * @returns The rejecting parse result.
2193
+ * @example
2194
+ * rejected("unknown flag `--force`");
2195
+ */
2196
+ function rejected(error) {
2197
+ return {
2198
+ command: "help",
2199
+ json: false,
2200
+ dryRun: false,
2201
+ error
2202
+ };
2203
+ }
2204
+ /**
2205
+ * Parse the argument vector (without `node` and the script path).
2206
+ *
2207
+ * @param argv - The raw arguments, e.g. `process.argv.slice(2)`.
2208
+ * @returns What to run, and with which flags.
2209
+ * @example
2210
+ * parseArgv(["patch", "--dry-run"]);
2211
+ * // { command: "release", releaseType: "patch", json: false, dryRun: true }
2212
+ */
2213
+ function parseArgv(argv) {
2214
+ const flags = argv.filter((argument) => argument.startsWith("-"));
2215
+ const positionals = argv.filter((argument) => !argument.startsWith("-"));
2216
+ if (flags.includes("--help") || flags.includes("-h")) return {
2217
+ command: "help",
2218
+ json: false,
2219
+ dryRun: false
2220
+ };
2221
+ const unknownFlag = flags.find((flag) => flag !== "--json" && flag !== "--dry-run");
2222
+ if (unknownFlag) return rejected(`unknown flag \`${unknownFlag}\``);
2223
+ if (positionals.length > 1) return rejected(`unexpected argument \`${positionals[1]}\``);
2224
+ const json = flags.includes("--json");
2225
+ const dryRun = flags.includes("--dry-run");
2226
+ const [first] = positionals;
2227
+ if (first === void 0) return {
2228
+ command: "help",
2229
+ json,
2230
+ dryRun
2231
+ };
2232
+ if (first === "doctor" || first === "setup") return {
2233
+ command: first,
2234
+ json,
2235
+ dryRun
2236
+ };
2237
+ if (first === "help") return {
2238
+ command: "help",
2239
+ json,
2240
+ dryRun
2241
+ };
2242
+ if (isReleaseType(first)) return {
2243
+ command: "release",
2244
+ releaseType: first,
2245
+ json,
2246
+ dryRun
2247
+ };
2248
+ return rejected(`unknown command \`${first}\``);
2249
+ }
2250
+ //#endregion
2251
+ //#region src/lib/exec.ts
2252
+ /**
2253
+ * @file `moku-release` — the injectable command runner, the CLI's only door to the shell.
2254
+ *
2255
+ * Two shapes, because release work needs both: `capture` reads a command's output (every
2256
+ * check), `inherit` hands the terminal to the child so `npm publish` can prompt for an OTP
2257
+ * and `gh run watch` can redraw. Arguments are always passed as an argv array through
2258
+ * `execFile`/`spawn` — never a shell string, so nothing interpolated can be re-parsed by a
2259
+ * shell. Tests inject a stub {@link Executor} and therefore never touch git, gh or npm.
2260
+ */
2261
+ /** Exit code POSIX shells use for "command not found" — what a missing binary reports. */
2262
+ const COMMAND_NOT_FOUND = 127;
2263
+ /**
2264
+ * Normalize whatever `execFile` rejected with into a {@link CommandOutput}. A non-zero exit
2265
+ * carries `code` plus both streams; a missing binary carries an `ENOENT`-style string code
2266
+ * and no exit status at all.
2267
+ *
2268
+ * @param error - The rejection value from `execFile`.
2269
+ * @returns The equivalent captured output.
2270
+ * @example
2271
+ * fromExecError({ code: 1, stdout: "", stderr: "not logged in" });
2272
+ */
2273
+ function fromExecError(error) {
2274
+ const shape = error;
2275
+ return {
2276
+ code: typeof shape.code === "number" ? shape.code : COMMAND_NOT_FOUND,
2277
+ stdout: shape.stdout ?? "",
2278
+ stderr: shape.stderr ?? String(error)
2279
+ };
2280
+ }
2281
+ /**
2282
+ * Create the real {@link Executor}, rooted at `cwd`.
2283
+ *
2284
+ * @param cwd - Default working directory for every child process.
2285
+ * @returns An executor bound to that directory.
2286
+ * @example
2287
+ * const exec = createExecutor(process.cwd());
2288
+ * const { stdout } = await exec.capture("git", ["rev-parse", "HEAD"]);
2289
+ */
2290
+ function createExecutor(cwd) {
2291
+ /**
2292
+ * Run a command and capture both streams, turning failures into data.
2293
+ *
2294
+ * @param command - The binary to run.
2295
+ * @param args - The argument vector.
2296
+ * @param options - Optional per-call overrides.
2297
+ * @returns The exit code and captured streams.
2298
+ * @example
2299
+ * await capture("npm", ["--version"]);
2300
+ */
2301
+ const capture = (command, args, options = {}) => new Promise((resolve) => {
2302
+ const child = execFile(command, [...args], { cwd: options.cwd ?? cwd }, (error, stdout, stderr) => {
2303
+ if (error) return resolve(fromExecError(error));
2304
+ resolve({
2305
+ code: 0,
2306
+ stdout,
2307
+ stderr
2308
+ });
2309
+ });
2310
+ if (options.input !== void 0) child.stdin?.end(options.input);
2311
+ });
2312
+ /**
2313
+ * Run a command attached to the parent's stdio so it can prompt and redraw.
2314
+ *
2315
+ * @param command - The binary to run.
2316
+ * @param args - The argument vector.
2317
+ * @param options - Optional per-call overrides.
2318
+ * @returns The exit code (`127` when the binary is missing).
2319
+ * @example
2320
+ * await inherit("npm", ["login"]);
2321
+ */
2322
+ const inherit = (command, args, options = {}) => new Promise((resolve) => {
2323
+ const child = spawn(command, [...args], {
2324
+ cwd: options.cwd ?? cwd,
2325
+ stdio: "inherit"
2326
+ });
2327
+ child.on("error", () => resolve(COMMAND_NOT_FOUND));
2328
+ child.on("close", (code) => resolve(code ?? 1));
2329
+ });
2330
+ return {
2331
+ capture,
2332
+ inherit
2333
+ };
2334
+ }
2335
+ //#endregion
2336
+ //#region src/lib/files.ts
2337
+ /**
2338
+ * @file `moku-release` — the injectable file port, rooted at the package directory.
2339
+ *
2340
+ * The second (and last) door to the outside world. Checks only ever `read`; `setup` also
2341
+ * `write`s and takes a `.bak` before it replaces anything it did not author. Paths are
2342
+ * always repo-relative POSIX paths, so a test can back the whole CLI with a plain object.
2343
+ */
2344
+ /** Suffix given to the copy `setup` keeps before overwriting a file it did not author. */
2345
+ const BACKUP_SUFFIX = ".bak";
2346
+ /**
2347
+ * Create the real {@link FileStore}, rooted at `root`.
2348
+ *
2349
+ * @param root - Absolute path every relative path resolves against.
2350
+ * @returns A file store bound to that directory.
2351
+ * @example
2352
+ * const files = createFileStore(process.cwd());
2353
+ * const manifest = await files.read("package.json");
2354
+ */
2355
+ function createFileStore(root) {
2356
+ /**
2357
+ * Resolve a repo-relative path against the store root.
2358
+ *
2359
+ * @param relative - Repo-relative path.
2360
+ * @returns The absolute path.
2361
+ * @example
2362
+ * absolute("package.json");
2363
+ */
2364
+ const absolute = (relative) => path.resolve(root, relative);
2365
+ /**
2366
+ * Read a repo-relative file, mapping "missing" to `undefined`.
2367
+ *
2368
+ * @param path - Repo-relative path.
2369
+ * @returns The contents, or `undefined`.
2370
+ * @example
2371
+ * await read("package.json");
2372
+ */
2373
+ const read = async (path) => {
2374
+ try {
2375
+ return await readFile(absolute(path), "utf8");
2376
+ } catch {
2377
+ return;
2378
+ }
2379
+ };
2380
+ /**
2381
+ * Write a repo-relative file, creating parent directories first.
2382
+ *
2383
+ * @param path - Repo-relative path.
2384
+ * @param content - The text to write.
2385
+ * @returns Nothing.
2386
+ * @example
2387
+ * await write("README.md", "# hi");
2388
+ */
2389
+ const write = async (path$1, content) => {
2390
+ const target = absolute(path$1);
2391
+ await mkdir(path.dirname(target), { recursive: true });
2392
+ await writeFile(target, content, "utf8");
2393
+ };
2394
+ /**
2395
+ * Copy a repo-relative file next to itself with a `.bak` suffix.
2396
+ *
2397
+ * @param path - Repo-relative path of the file to preserve.
2398
+ * @returns The repo-relative path of the backup.
2399
+ * @example
2400
+ * await backup(".github/workflows/ci.yml");
2401
+ */
2402
+ const backup = async (path) => {
2403
+ const target = `${path}${BACKUP_SUFFIX}`;
2404
+ await copyFile(absolute(path), absolute(target));
2405
+ return target;
2406
+ };
2407
+ return {
2408
+ read,
2409
+ write,
2410
+ backup
2411
+ };
2412
+ }
2413
+ //#endregion
2414
+ //#region src/index.ts
2415
+ /**
2416
+ * @file `moku-release` — the CLI entry: build the ports, parse argv, dispatch, exit.
2417
+ *
2418
+ * Deliberately thin. It owns exactly three things — the branded console, the two injected
2419
+ * ports ({@link createExecutor} / {@link createFileStore}), and the exit code — so that
2420
+ * every decision worth testing lives in a command, a check, or a pure lib function, none
2421
+ * of which know that a process exists.
2422
+ */
2423
+ /** The usage block, printed for `help`, for a bare invocation, and for a rejected one. */
2424
+ const USAGE = [
2425
+ " moku-release setup one-time wizard: workflows, contract, first publish",
2426
+ " moku-release doctor [--json] read-only diagnosis of the release setup",
2427
+ ` moku-release <${RELEASE_TYPES.join("|")}>`,
2428
+ "",
2429
+ " --dry-run print every action, mutate nothing",
2430
+ "",
2431
+ " Two steps are yours alone — this CLI never handles a credential:",
2432
+ " gh auth login",
2433
+ " npm login"
2434
+ ].join("\n");
2435
+ /**
2436
+ * Dispatch a parsed invocation to its command.
2437
+ *
2438
+ * @param parsed - The parsed argument vector.
2439
+ * @param ctx - The ports and flags every command runs against.
2440
+ * @param ui - The branded console.
2441
+ * @returns The process exit code.
2442
+ * @example
2443
+ * await dispatch(parseArgv(["doctor"]), ctx, ui);
2444
+ */
2445
+ async function dispatch(parsed, ctx, ui) {
2446
+ if (parsed.command === "doctor") {
2447
+ if (!parsed.json) ui.lockup({
2448
+ wordmark: "moku release",
2449
+ label: "doctor"
2450
+ });
2451
+ return (await runDoctor({
2452
+ ctx,
2453
+ ui,
2454
+ json: parsed.json
2455
+ })).failed ? 1 : 0;
2456
+ }
2457
+ if (parsed.command === "setup") return runSetup({
2458
+ ctx,
2459
+ ui,
2460
+ prompts: createBrandPrompts(),
2461
+ dryRun: parsed.dryRun
2462
+ });
2463
+ if (parsed.command === "release" && parsed.releaseType !== void 0) return runRelease({
2464
+ ctx,
2465
+ ui,
2466
+ releaseType: parsed.releaseType,
2467
+ dryRun: parsed.dryRun
2468
+ });
2469
+ if (parsed.error !== void 0) ui.error(parsed.error);
2470
+ ui.lockup({
2471
+ wordmark: "moku release",
2472
+ label: "usage"
2473
+ });
2474
+ ui.line(USAGE);
2475
+ return parsed.error === void 0 ? 0 : 1;
2476
+ }
2477
+ /**
2478
+ * Build the ports from the current working directory and run the requested command.
2479
+ *
2480
+ * @returns The process exit code.
2481
+ * @example
2482
+ * process.exitCode = await main();
2483
+ */
2484
+ async function main() {
2485
+ const cwd = process.cwd();
2486
+ const ui = createBrandConsole();
2487
+ const ctx = {
2488
+ cwd,
2489
+ exec: createExecutor(cwd),
2490
+ files: createFileStore(cwd),
2491
+ strict: false
2492
+ };
2493
+ return dispatch(parseArgv(process.argv.slice(2)), ctx, ui);
2494
+ }
2495
+ process.exitCode = await main();
2496
+ //#endregion
2497
+ export {};