@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.
- package/LICENSE +21 -0
- package/README.md +184 -0
- package/dist/release.mjs +2497 -0
- package/examples/app/ci.yml +28 -0
- package/examples/package/ci.yml +22 -0
- package/examples/package/publish.local-publish.yml +84 -0
- package/examples/package/publish.yml +36 -0
- package/package.json +54 -0
- package/rulesets/main.json +33 -0
package/dist/release.mjs
ADDED
|
@@ -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 {};
|