@astra-kit/rp 0.0.0-stage → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/CHANGELOG.md +196 -0
  2. package/LICENSE.md +21 -0
  3. package/README.md +352 -2
  4. package/bin/astra-rp.js +51 -0
  5. package/package.json +93 -4
  6. package/src/build-gate.js +157 -0
  7. package/src/changelog-blocks.js +53 -0
  8. package/src/changelog.js +108 -0
  9. package/src/constants/build-gate.js +30 -0
  10. package/src/constants/changelog-draft.js +180 -0
  11. package/src/constants/changelog.js +74 -0
  12. package/src/constants/ci-release.js +346 -0
  13. package/src/constants/cli.js +12 -0
  14. package/src/constants/create-version.js +523 -0
  15. package/src/constants/guard-publish.js +29 -0
  16. package/src/constants/index.js +18 -0
  17. package/src/constants/monorepo.js +57 -0
  18. package/src/constants/package-manager.js +56 -0
  19. package/src/constants/registry.js +72 -0
  20. package/src/constants/terminal-ui.js +93 -0
  21. package/src/constants/version-files.js +63 -0
  22. package/src/constants/versions.js +60 -0
  23. package/src/create-version/artifact.js +343 -0
  24. package/src/create-version/changelog-draft-workspace.js +124 -0
  25. package/src/create-version/changelog-draft.js +380 -0
  26. package/src/create-version/changelog.js +264 -0
  27. package/src/create-version/ci-config.js +54 -0
  28. package/src/create-version/ci-publication-history.js +113 -0
  29. package/src/create-version/ci-release-identity-step.js +58 -0
  30. package/src/create-version/ci-setup.js +794 -0
  31. package/src/create-version/ci.js +555 -0
  32. package/src/create-version/committed-tree.js +75 -0
  33. package/src/create-version/config.js +489 -0
  34. package/src/create-version/errors.js +46 -0
  35. package/src/create-version/github-workflow.js +199 -0
  36. package/src/create-version/index.js +54 -0
  37. package/src/create-version/json.js +32 -0
  38. package/src/create-version/jsonc.js +76 -0
  39. package/src/create-version/jsr.js +96 -0
  40. package/src/create-version/local-changes.js +178 -0
  41. package/src/create-version/npm-auth.js +142 -0
  42. package/src/create-version/npm.js +812 -0
  43. package/src/create-version/plan.js +1200 -0
  44. package/src/create-version/process.js +334 -0
  45. package/src/create-version/registry-config.js +121 -0
  46. package/src/create-version/registry-http.js +62 -0
  47. package/src/create-version/registry.js +220 -0
  48. package/src/create-version/release-execution.js +45 -0
  49. package/src/create-version/release-tag-config.js +76 -0
  50. package/src/create-version/run.js +2087 -0
  51. package/src/create-version/state.js +461 -0
  52. package/src/create-version/strict-utf8.js +11 -0
  53. package/src/create-version/templates/release.yml +52 -0
  54. package/src/create-version/templates/vercel-ignore-build.mjs +66 -0
  55. package/src/guard-publish.js +77 -0
  56. package/src/index.js +42 -0
  57. package/src/monorepo/plan.js +475 -0
  58. package/src/monorepo/release-commit.js +46 -0
  59. package/src/monorepo/run.js +927 -0
  60. package/src/monorepo/state.js +206 -0
  61. package/src/monorepo/workspaces.js +327 -0
  62. package/src/package-manager.js +107 -0
  63. package/src/terminal-ui.js +548 -0
  64. package/src/testing.js +70 -0
  65. package/src/version-files.js +142 -0
  66. package/src/versions.js +303 -0
  67. package/types/build-gate.d.ts +43 -0
  68. package/types/changelog-blocks.d.ts +33 -0
  69. package/types/changelog.d.ts +61 -0
  70. package/types/constants/build-gate.d.ts +23 -0
  71. package/types/constants/changelog-draft.d.ts +138 -0
  72. package/types/constants/changelog.d.ts +56 -0
  73. package/types/constants/ci-release.d.ts +284 -0
  74. package/types/constants/cli.d.ts +11 -0
  75. package/types/constants/create-version.d.ts +397 -0
  76. package/types/constants/guard-publish.d.ts +25 -0
  77. package/types/constants/index.d.ts +17 -0
  78. package/types/constants/monorepo.d.ts +45 -0
  79. package/types/constants/package-manager.d.ts +40 -0
  80. package/types/constants/registry.d.ts +79 -0
  81. package/types/constants/terminal-ui.d.ts +73 -0
  82. package/types/constants/version-files.d.ts +51 -0
  83. package/types/constants/versions.d.ts +47 -0
  84. package/types/create-version/artifact.d.ts +113 -0
  85. package/types/create-version/changelog-draft-workspace.d.ts +33 -0
  86. package/types/create-version/changelog-draft.d.ts +127 -0
  87. package/types/create-version/changelog.d.ts +141 -0
  88. package/types/create-version/ci-config.d.ts +33 -0
  89. package/types/create-version/ci-publication-history.d.ts +51 -0
  90. package/types/create-version/ci-release-identity-step.d.ts +12 -0
  91. package/types/create-version/ci-setup.d.ts +148 -0
  92. package/types/create-version/ci.d.ts +145 -0
  93. package/types/create-version/committed-tree.d.ts +55 -0
  94. package/types/create-version/config.d.ts +128 -0
  95. package/types/create-version/errors.d.ts +39 -0
  96. package/types/create-version/github-workflow.d.ts +41 -0
  97. package/types/create-version/index.d.ts +28 -0
  98. package/types/create-version/json.d.ts +21 -0
  99. package/types/create-version/jsonc.d.ts +21 -0
  100. package/types/create-version/jsr.d.ts +52 -0
  101. package/types/create-version/local-changes.d.ts +52 -0
  102. package/types/create-version/npm-auth.d.ts +60 -0
  103. package/types/create-version/npm.d.ts +331 -0
  104. package/types/create-version/plan.d.ts +369 -0
  105. package/types/create-version/process.d.ts +151 -0
  106. package/types/create-version/registry-config.d.ts +56 -0
  107. package/types/create-version/registry-http.d.ts +22 -0
  108. package/types/create-version/registry.d.ts +86 -0
  109. package/types/create-version/release-execution.d.ts +22 -0
  110. package/types/create-version/release-tag-config.d.ts +18 -0
  111. package/types/create-version/run.d.ts +407 -0
  112. package/types/create-version/state.d.ts +204 -0
  113. package/types/create-version/strict-utf8.d.ts +10 -0
  114. package/types/guard-publish.d.ts +49 -0
  115. package/types/index.d.ts +23 -0
  116. package/types/monorepo/plan.d.ts +68 -0
  117. package/types/monorepo/release-commit.d.ts +29 -0
  118. package/types/monorepo/run.d.ts +68 -0
  119. package/types/monorepo/state.d.ts +96 -0
  120. package/types/monorepo/workspaces.d.ts +86 -0
  121. package/types/package-manager.d.ts +58 -0
  122. package/types/terminal-ui.d.ts +196 -0
  123. package/types/testing.d.ts +31 -0
  124. package/types/version-files.d.ts +59 -0
  125. package/types/versions.d.ts +158 -0
@@ -0,0 +1,548 @@
1
+ /**
2
+ * Dependency-free terminal UI for release commands: colors, banner, rounded
3
+ * boxes, step headers, spinners and the interactive select prompt.
4
+ *
5
+ * Colors go through `util.styleText`, which drops ANSI codes automatically
6
+ * when stdout is not a TTY or `NO_COLOR` is set. Interactive prompts fall
7
+ * back to their default answer when stdin is not a TTY, so a command never
8
+ * hangs in a pipe.
9
+ *
10
+ * @module terminal-ui
11
+ */
12
+
13
+ import { emitKeypressEvents } from "node:readline";
14
+ import { stripVTControlCharacters, styleText } from "node:util";
15
+
16
+ import {
17
+ ANSI_ESCAPE,
18
+ ANSI_SEQUENCE,
19
+ ANSI_TOKEN_PATTERN,
20
+ BOX_PADDING,
21
+ BOX_TONE,
22
+ FALLBACK_TERMINAL_WIDTH,
23
+ HANGING_MARKER_PATTERN,
24
+ ICON_GLYPH,
25
+ INTERRUPTED_EXIT_CODE,
26
+ MAX_BOX_WIDTH,
27
+ MAX_NUMBERED_OPTIONS,
28
+ MILLISECONDS_PER_SECOND,
29
+ NUMBER_KEY_PATTERN,
30
+ PROMPT_KEY,
31
+ ROW_LABEL_WIDTH,
32
+ SECONDS_PER_MINUTE,
33
+ SPINNER_FRAMES,
34
+ SPINNER_INTERVAL_MS,
35
+ TERMINAL_RIGHT_MARGIN,
36
+ VALUE_COLUMN_PATTERN,
37
+ } from "./constants/terminal-ui.js";
38
+
39
+ /**
40
+ * @typedef {Parameters<typeof styleText>[0]} TextFormat
41
+ * @typedef {{ label: string, hint?: string, description?: string, value: string }} SelectOption
42
+ */
43
+
44
+ /** Time spent waiting for answers, excluded from the reported total. */
45
+ let promptWaitMs = 0;
46
+
47
+ /**
48
+ * Icons shared by status lines, colored once at import. They use Nerd Font
49
+ * glyphs, so the terminal needs a Nerd Font.
50
+ */
51
+ export const ICON = {
52
+ success: styleText("green", ICON_GLYPH.success),
53
+ failure: styleText("red", ICON_GLYPH.failure),
54
+ warning: styleText("yellow", ICON_GLYPH.warning),
55
+ info: styleText("cyan", ICON_GLYPH.info),
56
+ pending: styleText("gray", ICON_GLYPH.pending),
57
+ arrow: styleText("magenta", ICON_GLYPH.arrow),
58
+ bullet: styleText("gray", ICON_GLYPH.bullet),
59
+ star: styleText("yellow", ICON_GLYPH.star),
60
+ rocket: ICON_GLYPH.rocket,
61
+ };
62
+
63
+ /**
64
+ * Returns how long the command has waited for the user's answers.
65
+ *
66
+ * @returns {number} Milliseconds spent with a prompt open.
67
+ */
68
+ export function getPromptWaitMs() {
69
+ return promptWaitMs;
70
+ }
71
+
72
+ /**
73
+ * Measures the elapsed time since a start, without the time spent waiting for answers.
74
+ *
75
+ * @param {number} startedAt - `Date.now()` when the measured work started.
76
+ * @param {number} [promptWaitAtStart] - {@link getPromptWaitMs} at that moment.
77
+ * @returns {number} Active milliseconds.
78
+ */
79
+ export function measureActiveMs(startedAt, promptWaitAtStart = 0) {
80
+ return Date.now() - startedAt - (promptWaitMs - promptWaitAtStart);
81
+ }
82
+
83
+ /**
84
+ * Moves the cursor up a number of lines.
85
+ *
86
+ * @param {number} lineCount - Lines to move.
87
+ * @returns {string} ANSI sequence, empty when there is nothing to move.
88
+ */
89
+ function cursorUp(lineCount) {
90
+ return lineCount > 0 ? `${ANSI_ESCAPE}[${lineCount}A` : "";
91
+ }
92
+
93
+ /**
94
+ * Applies one or more `util.styleText` formats.
95
+ *
96
+ * @param {TextFormat} format - Format names such as `"bold"` or `["cyan", "bold"]`.
97
+ * @param {string} text - Text to style.
98
+ * @returns {string} Styled text (plain when colors are disabled).
99
+ */
100
+ export function paint(format, text) {
101
+ return styleText(format, text);
102
+ }
103
+
104
+ /**
105
+ * Measures the visible width of a string, ignoring ANSI codes.
106
+ *
107
+ * @param {string} text - Possibly styled text.
108
+ * @returns {number} Visible column count.
109
+ */
110
+ export function visibleWidth(text) {
111
+ return [...stripVTControlCharacters(text)].length;
112
+ }
113
+
114
+ /**
115
+ * Splits styled text into words (with their ANSI codes) and the spaces between them.
116
+ *
117
+ * @param {string} text - Possibly styled text.
118
+ * @returns {{ text: string, width: number, isSpace: boolean }[]} Segments in order.
119
+ */
120
+ function splitStyledSegments(text) {
121
+ /** @type {{ text: string, width: number, isSpace: boolean }[]} */
122
+ const segments = [];
123
+
124
+ for (const token of text.match(ANSI_TOKEN_PATTERN) ?? []) {
125
+ const isEscape = token.startsWith(ANSI_ESCAPE);
126
+ const isSpace = !isEscape && token === " ";
127
+ const last = segments.at(-1);
128
+
129
+ // An escape after a space starts the next word so it is never dropped with a line-start space.
130
+ if (last && ((isEscape && !last.isSpace) || (!isEscape && last.isSpace === isSpace))) {
131
+ last.text += token;
132
+ last.width += isEscape ? 0 : 1;
133
+ } else {
134
+ segments.push({ text: token, width: isEscape ? 0 : 1, isSpace });
135
+ }
136
+ }
137
+
138
+ return segments;
139
+ }
140
+
141
+ /**
142
+ * Returns the ANSI sequences seen so far, so a continuation line reopens the active styles.
143
+ *
144
+ * @param {string} text - Styled text already emitted.
145
+ * @returns {string} Concatenated escape sequences.
146
+ */
147
+ function collectEscapes(text) {
148
+ return (text.match(ANSI_TOKEN_PATTERN) ?? []).filter((token) => token.startsWith(ANSI_ESCAPE)).join("");
149
+ }
150
+
151
+ /**
152
+ * Word-wraps a styled line to a visible width without ever cutting it off:
153
+ * words move to the next line, continuation lines align after a leading
154
+ * marker (icon, arrow, bullet or `1.`), styles are closed at each break and
155
+ * reopened on the next line, and a word longer than the width is split.
156
+ *
157
+ * @param {string} text - Possibly styled line.
158
+ * @param {number} width - Maximum visible width.
159
+ * @returns {string[]} Lines that each fit in `width` columns.
160
+ */
161
+ export function wrapStyledLine(text, width) {
162
+ if (visibleWidth(text) <= width) {
163
+ return [text];
164
+ }
165
+
166
+ // Continuation lines align with the value column of a row, or after a leading marker.
167
+ const plainText = stripVTControlCharacters(text);
168
+ const indentPrefix = VALUE_COLUMN_PATTERN.exec(plainText)?.[0] ?? HANGING_MARKER_PATTERN.exec(plainText)?.[0] ?? "";
169
+ const indentWidth = [...indentPrefix].length;
170
+ const hangingIndent = indentWidth < width / 2 ? " ".repeat(indentWidth) : "";
171
+ /** @type {string[]} */
172
+ const lines = [];
173
+ let current = "";
174
+ let currentWidth = 0;
175
+ let emitted = "";
176
+
177
+ const closeLine = () => {
178
+ const hasStyles = current !== stripVTControlCharacters(current);
179
+ lines.push(`${current.trimEnd()}${hasStyles ? ANSI_SEQUENCE.reset : ""}`);
180
+ };
181
+
182
+ const breakLine = () => {
183
+ closeLine();
184
+ emitted += current;
185
+ current = `${hangingIndent}${collectEscapes(emitted)}`;
186
+ currentWidth = hangingIndent.length;
187
+ };
188
+
189
+ for (const segment of splitStyledSegments(text)) {
190
+ const isLineStart = currentWidth === hangingIndent.length && lines.length > 0;
191
+
192
+ if (segment.isSpace) {
193
+ if (!isLineStart) {
194
+ current += segment.text;
195
+ currentWidth += segment.width;
196
+ }
197
+ continue;
198
+ }
199
+
200
+ // Move the word to the next line only when it fits there; a longer word is split right here.
201
+ const fitsOnFreshLine = hangingIndent.length + segment.width <= width;
202
+ if (currentWidth + segment.width > width && currentWidth > hangingIndent.length && fitsOnFreshLine) {
203
+ breakLine();
204
+ }
205
+
206
+ // A single word wider than the line is split across lines instead of truncated.
207
+ for (const token of segment.text.match(ANSI_TOKEN_PATTERN) ?? []) {
208
+ const isEscape = token.startsWith(ANSI_ESCAPE);
209
+
210
+ if (!isEscape && currentWidth >= width) {
211
+ breakLine();
212
+ }
213
+
214
+ current += token;
215
+ currentWidth += isEscape ? 0 : 1;
216
+ }
217
+ }
218
+
219
+ if (stripVTControlCharacters(current).trim()) {
220
+ closeLine();
221
+ }
222
+
223
+ return lines;
224
+ }
225
+
226
+ /**
227
+ * Returns the box width for the current terminal.
228
+ *
229
+ * @returns {number} Outer width in columns.
230
+ */
231
+ export function resolveBoxWidth() {
232
+ const columns = process.stdout.columns || FALLBACK_TERMINAL_WIDTH;
233
+ return Math.min(columns - TERMINAL_RIGHT_MARGIN, MAX_BOX_WIDTH);
234
+ }
235
+
236
+ /**
237
+ * Renders a rounded box with an optional title in its top border. Long lines
238
+ * are word-wrapped, never truncated; a title that does not fit in the border
239
+ * moves inside the box as its first lines.
240
+ *
241
+ * @param {{ title?: string, lines: string[], tone?: TextFormat, width?: number }} options - Box content.
242
+ * @returns {string} Multi-line box.
243
+ */
244
+ export function renderBox({ title, lines, tone = BOX_TONE.neutral, width = resolveBoxWidth() }) {
245
+ /** @param {string} text */
246
+ const border = (text) => paint(tone, text);
247
+ const innerWidth = width - 2;
248
+ const contentWidth = innerWidth - BOX_PADDING * 2;
249
+ const titleText = title ? ` ${paint("bold", title)} ` : "";
250
+ const titleFits = visibleWidth(titleText) + 1 <= innerWidth;
251
+ const borderTitle = titleFits ? titleText : "";
252
+ const topFill = Math.max(innerWidth - visibleWidth(borderTitle) - 1, 0);
253
+ const top = `${border("╭─")}${borderTitle}${border(`${"─".repeat(topFill)}╮`)}`;
254
+ const padding = " ".repeat(BOX_PADDING);
255
+ const contentLines = titleFits || !title ? lines : [paint("bold", title), "", ...lines];
256
+ const body = contentLines
257
+ .flatMap((line) => wrapStyledLine(line, contentWidth))
258
+ .map((line) => {
259
+ const fill = " ".repeat(Math.max(contentWidth - visibleWidth(line), 0));
260
+ return `${border("│")}${padding}${line}${fill}${padding}${border("│")}`;
261
+ });
262
+ const bottom = border(`╰${"─".repeat(innerWidth)}╯`);
263
+
264
+ return [top, ...body, bottom].join("\n");
265
+ }
266
+
267
+ /**
268
+ * Renders a label/value row aligned for status panels.
269
+ *
270
+ * @param {string} icon - Leading icon.
271
+ * @param {string} label - Left column.
272
+ * @param {string} value - Right column.
273
+ * @param {number} [labelWidth] - Width of the label column.
274
+ * @returns {string} Row.
275
+ */
276
+ export function renderRow(icon, label, value, labelWidth = ROW_LABEL_WIDTH) {
277
+ // A label as wide as the column still keeps one space before its value.
278
+ return `${icon} ${paint("bold", label.padEnd(Math.max(labelWidth, label.length + 1)))}${value}`;
279
+ }
280
+
281
+ /**
282
+ * Renders the one-line header shown when a release command starts: an
283
+ * inverted `RELEASE` label, the project name, the published version aligned
284
+ * to the right and a rule underneath.
285
+ *
286
+ * @param {{ projectName: string, publishedLabel: string | null }} options - Header content.
287
+ * @returns {string} Header.
288
+ */
289
+ export function renderBanner({ projectName, publishedLabel }) {
290
+ const width = resolveBoxWidth();
291
+ const left = `${paint(["inverse", "bold", "magenta"], " RELEASE ")} ${paint("bold", projectName)}`;
292
+ const right = publishedLabel ? paint("gray", publishedLabel) : "";
293
+ const gap = " ".repeat(Math.max(width - visibleWidth(left) - visibleWidth(right), 2));
294
+
295
+ return ["", `${left}${gap}${right}`, paint("gray", "─".repeat(width)), ""].join("\n");
296
+ }
297
+
298
+ /**
299
+ * Renders the header that introduces each executed step: a blank line, the `━━ PASO N/M ━ title ━…`
300
+ * rule (only the step label in bold) and a blank line that separates it from the step output.
301
+ *
302
+ * @param {number} stepNumber - 1-based index.
303
+ * @param {number} stepCount - Total steps.
304
+ * @param {string} title - Step title, in regular weight.
305
+ * @returns {string} Header lines.
306
+ */
307
+ export function renderStepHeader(stepNumber, stepCount, title) {
308
+ const label = paint(["bold", "magenta"], ` PASO ${stepNumber}/${stepCount} `);
309
+ const text = ` ${title} `;
310
+ const fill = Math.max(resolveBoxWidth() - visibleWidth(label) - visibleWidth(text) - 2, 2);
311
+
312
+ return ["", `${paint("magenta", "━━")}${label}${paint("magenta", "━")}${text}${paint("magenta", "━".repeat(fill))}`, ""].join("\n");
313
+ }
314
+
315
+ /**
316
+ * Writes a line to stdout.
317
+ *
318
+ * @param {string} [text] - Line content.
319
+ */
320
+ export function print(text = "") {
321
+ process.stdout.write(`${text}\n`);
322
+ }
323
+
324
+ /**
325
+ * Formats a duration in a compact Spanish form.
326
+ *
327
+ * @param {number} milliseconds - Duration.
328
+ * @returns {string} Such as `3.2 s` or `4 min 05 s`.
329
+ */
330
+ export function formatDuration(milliseconds) {
331
+ const totalSeconds = milliseconds / MILLISECONDS_PER_SECOND;
332
+
333
+ if (totalSeconds < SECONDS_PER_MINUTE) {
334
+ return `${totalSeconds.toFixed(1)} s`;
335
+ }
336
+
337
+ const minutes = Math.floor(totalSeconds / SECONDS_PER_MINUTE);
338
+ const seconds = Math.round(totalSeconds % SECONDS_PER_MINUTE);
339
+ return `${minutes} min ${String(seconds).padStart(2, "0")} s`;
340
+ }
341
+
342
+ /**
343
+ * Starts a spinner; on non-TTY outputs it prints the label once instead.
344
+ *
345
+ * @param {string} label - Initial label.
346
+ * @returns {{ update: (label: string) => void, succeed: (label?: string) => void, fail: (label?: string) => void }} Controls.
347
+ */
348
+ export function startSpinner(label) {
349
+ const isInteractive = Boolean(process.stdout.isTTY);
350
+ const startedAt = Date.now();
351
+ let currentLabel = label;
352
+ let frameIndex = 0;
353
+ /** @type {ReturnType<typeof setInterval> | null} */
354
+ let timer = null;
355
+
356
+ const render = () => {
357
+ const frame = paint("magenta", SPINNER_FRAMES[frameIndex % SPINNER_FRAMES.length]);
358
+ process.stdout.write(`${ANSI_SEQUENCE.clearLine}${frame} ${currentLabel}`);
359
+ frameIndex += 1;
360
+ };
361
+
362
+ if (isInteractive) {
363
+ process.stdout.write(ANSI_SEQUENCE.hideCursor);
364
+ render();
365
+ timer = setInterval(render, SPINNER_INTERVAL_MS);
366
+ } else {
367
+ print(`${ICON.pending} ${label}`);
368
+ }
369
+
370
+ /**
371
+ * @param {string} icon - Final icon.
372
+ * @param {string} [finalLabel] - Final label; defaults to the current one.
373
+ */
374
+ const stop = (icon, finalLabel) => {
375
+ if (timer) {
376
+ clearInterval(timer);
377
+ process.stdout.write(`${ANSI_SEQUENCE.clearLine}${ANSI_SEQUENCE.showCursor}`);
378
+ }
379
+
380
+ print(`${icon} ${finalLabel ?? currentLabel} ${paint("gray", formatDuration(Date.now() - startedAt))}`);
381
+ };
382
+
383
+ return {
384
+ update(nextLabel) {
385
+ currentLabel = nextLabel;
386
+ if (!isInteractive) {
387
+ print(`${ICON.pending} ${nextLabel}`);
388
+ }
389
+ },
390
+ succeed: (finalLabel) => stop(ICON.success, finalLabel),
391
+ fail: (finalLabel) => stop(ICON.failure, finalLabel),
392
+ };
393
+ }
394
+
395
+ /**
396
+ * Restores the terminal and exits after Ctrl+C.
397
+ */
398
+ function exitOnInterrupt() {
399
+ process.stdout.write(`${ANSI_SEQUENCE.showCursor}\n`);
400
+ print(`${ICON.warning} ${paint("yellow", "Release cancelado por el usuario. No se tocó nada más.")}`);
401
+ process.exit(INTERRUPTED_EXIT_CODE);
402
+ }
403
+
404
+ /**
405
+ * Counts the terminal rows that lines occupy, including the extra rows of
406
+ * lines wider than the terminal, so a prompt can erase exactly what it drew.
407
+ *
408
+ * @param {string[]} lines - Rendered lines, possibly styled.
409
+ * @param {number} columns - Terminal width in columns.
410
+ * @returns {number} Physical rows.
411
+ */
412
+ export function countTerminalRows(lines, columns) {
413
+ const safeColumns = Math.max(columns, 1);
414
+ return lines.reduce((rows, line) => rows + Math.max(1, Math.ceil(visibleWidth(line) / safeColumns)), 0);
415
+ }
416
+
417
+ /**
418
+ * Maps a typed character to the option it selects.
419
+ *
420
+ * @param {string | undefined} text - Character typed by the user.
421
+ * @param {number} optionCount - Number of options in the prompt.
422
+ * @returns {number} Zero-based option index, or -1 when the key does not pick an option.
423
+ */
424
+ export function resolveNumberKey(text, optionCount) {
425
+ if (!text || !NUMBER_KEY_PATTERN.test(text)) {
426
+ return -1;
427
+ }
428
+
429
+ const index = Number(text) - 1;
430
+ return index < Math.min(optionCount, MAX_NUMBERED_OPTIONS) ? index : -1;
431
+ }
432
+
433
+ /**
434
+ * Moves the selection of a prompt for a navigation key. Up and `k` go to the previous option;
435
+ * down, `j` and Tab to the next one, wrapping around. With nothing selected yet, the
436
+ * first "next" selects the first option and the first "previous" the last one.
437
+ *
438
+ * @param {number | null} selectedIndex - Selected option, or `null` when none is.
439
+ * @param {{ name?: string }} key - Parsed key.
440
+ * @param {number} optionCount - Number of options in the prompt.
441
+ * @returns {number | null} New selection; the same one when the key does not navigate.
442
+ */
443
+ export function moveSelection(selectedIndex, key, optionCount) {
444
+ const keyName = key.name ?? "";
445
+
446
+ if (PROMPT_KEY.previous.includes(keyName)) {
447
+ return ((selectedIndex ?? 0) - 1 + optionCount) % optionCount;
448
+ }
449
+
450
+ if (PROMPT_KEY.next.includes(keyName)) {
451
+ return selectedIndex === null ? 0 : (selectedIndex + 1) % optionCount;
452
+ }
453
+
454
+ return selectedIndex;
455
+ }
456
+
457
+ /**
458
+ * Asks the user to choose one option: its number picks it right away, or the
459
+ * arrow keys move the selection and Enter confirms it. With `defaultIndex: null`
460
+ * nothing starts selected, so Enter does nothing until an option is picked.
461
+ *
462
+ * @param {{ message: string, options: SelectOption[], defaultIndex?: number | null }} prompt - Prompt; `description` renders on its own line below the option.
463
+ * @returns {Promise<string>} Selected value (the default one when stdin is not a TTY).
464
+ * @throws {Error} When stdin is not a TTY and there is no default option to answer with.
465
+ */
466
+ export function select({ message, options, defaultIndex = 0 }) {
467
+ const input = process.stdin;
468
+ const question = `${paint(["bold", "cyan"], "?")} ${paint("bold", message)}`;
469
+
470
+ if (!input.isTTY) {
471
+ if (defaultIndex === null) {
472
+ return Promise.reject(new Error(`astra-rp:select "${message}" needs an explicit choice and stdin is not an interactive terminal`));
473
+ }
474
+
475
+ print(`${question} ${paint("gray", `→ ${options[defaultIndex].label} (sin terminal interactiva)`)}`);
476
+ return Promise.resolve(options[defaultIndex].value);
477
+ }
478
+
479
+ return new Promise((resolve) => {
480
+ const promptStartedAt = Date.now();
481
+ /** @type {number | null} */
482
+ let selectedIndex = defaultIndex;
483
+ let renderedLineCount = 0;
484
+
485
+ const render = () => {
486
+ const lines = [
487
+ question,
488
+ ...options.flatMap((option, index) => {
489
+ const isSelected = index === selectedIndex;
490
+ const pointer = isSelected ? ICON.arrow : " ";
491
+ const numberLabel = index < MAX_NUMBERED_OPTIONS ? `${index + 1}.` : " ";
492
+ const number = isSelected ? paint(["bold", "magentaBright"], numberLabel) : paint("gray", numberLabel);
493
+ const label = isSelected ? paint(["bold", "magentaBright"], option.label) : option.label;
494
+ const hint = option.hint ? ` ${paint("gray", option.hint)}` : "";
495
+ const optionLine = ` ${pointer} ${number} ${label}${hint}`;
496
+ return option.description ? [optionLine, ` ${paint("gray", option.description)}`] : [optionLine];
497
+ }),
498
+ paint("gray", ` 1-${Math.min(options.length, MAX_NUMBERED_OPTIONS)} para elegir · ↑/↓ y Enter para moverte y confirmar`),
499
+ ];
500
+ process.stdout.write(`${cursorUp(renderedLineCount)}\r${ANSI_SEQUENCE.clearBelow}${lines.join("\n")}\n`);
501
+ // Long lines wrap in the terminal: count physical rows so the next redraw erases all of them.
502
+ renderedLineCount = countTerminalRows(lines, process.stdout.columns || FALLBACK_TERMINAL_WIDTH);
503
+ };
504
+
505
+ /** @param {number} chosenIndex - Option confirmed by the user. */
506
+ const finish = (chosenIndex) => {
507
+ input.off("keypress", onKeypress);
508
+ input.setRawMode(false);
509
+ input.pause();
510
+ const chosen = options[chosenIndex];
511
+ process.stdout.write(`${cursorUp(renderedLineCount)}\r${ANSI_SEQUENCE.clearBelow}${ANSI_SEQUENCE.showCursor}`);
512
+ print(`${question} ${paint("magentaBright", chosen.label)}`);
513
+ promptWaitMs += Date.now() - promptStartedAt;
514
+ resolve(chosen.value);
515
+ };
516
+
517
+ /**
518
+ * @param {string | undefined} text - Typed character.
519
+ * @param {{ name?: string, ctrl?: boolean }} [key] - Parsed key.
520
+ */
521
+ const onKeypress = (text, key = {}) => {
522
+ const numberedIndex = resolveNumberKey(text, options.length);
523
+ const keyName = key.name ?? "";
524
+ const movedIndex = moveSelection(selectedIndex, key, options.length);
525
+
526
+ if (key.ctrl && keyName === PROMPT_KEY.interrupt) {
527
+ input.setRawMode(false);
528
+ exitOnInterrupt();
529
+ } else if (numberedIndex !== -1) {
530
+ finish(numberedIndex);
531
+ } else if (movedIndex !== selectedIndex) {
532
+ selectedIndex = movedIndex;
533
+ render();
534
+ } else if (PROMPT_KEY.confirm.includes(keyName) && selectedIndex !== null) {
535
+ finish(selectedIndex);
536
+ }
537
+ };
538
+
539
+ emitKeypressEvents(input);
540
+ input.setRawMode(true);
541
+ input.resume();
542
+ input.on("keypress", onKeypress);
543
+ process.stdout.write(ANSI_SEQUENCE.hideCursor);
544
+ render();
545
+ });
546
+ }
547
+
548
+ export { BOX_TONE, INTERRUPTED_EXIT_CODE };
package/src/testing.js ADDED
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Version bump fixtures for the test suites of every repository that uses
3
+ * `astra-rp`, so `create-version`, the Vercel build gate and any custom release
4
+ * command are checked against the same rule: from a stable version only the
5
+ * next patch, minor or major is allowed.
6
+ *
7
+ * @module testing
8
+ */
9
+
10
+ /** Stable version every fixture bumps from. */
11
+ export const CURRENT_STABLE_VERSION = "1.2.3";
12
+
13
+ /** The only versions allowed after {@link CURRENT_STABLE_VERSION}: next patch, minor and major. */
14
+ export const ALLOWED_NEXT_VERSIONS = Object.freeze(["1.2.4", "1.3.0", "2.0.0"]);
15
+
16
+ /** Every rejected kind of bump from {@link CURRENT_STABLE_VERSION}, grouped by why it is rejected. */
17
+ export const REJECTED_VERSION_BUMPS = Object.freeze({
18
+ unchanged: Object.freeze(["1.2.3"]),
19
+ lower: Object.freeze(["1.2.2", "1.1.9", "0.9.9", "1.2.0", "0.0.1"]),
20
+ "skips patches": Object.freeze(["1.2.5", "1.2.10"]),
21
+ "skips minors": Object.freeze(["1.4.0", "1.10.0"]),
22
+ "skips majors": Object.freeze(["3.0.0", "10.0.0"]),
23
+ "minor bump without resetting the patch": Object.freeze(["1.3.3", "1.3.1"]),
24
+ "major bump without resetting minor and patch": Object.freeze(["2.2.3", "2.0.3", "2.3.0", "2.0.1"]),
25
+ prerelease: Object.freeze([
26
+ "1.2.4-alpha",
27
+ "1.2.4-alpha.1",
28
+ "1.3.0-beta.2",
29
+ "2.0.0-rc.1",
30
+ "1.2.4-canary.0",
31
+ "1.2.4-next.3",
32
+ "1.2.4-dev",
33
+ "1.2.4-snapshot",
34
+ "1.2.4-0",
35
+ "1.2.4-alpha.beta.1",
36
+ "1.2.4-SNAPSHOT-20260926",
37
+ ]),
38
+ "build metadata": Object.freeze(["1.2.4+build.5", "1.3.0+sha.8fc0245", "2.0.0-beta.1+exp"]),
39
+ "not a plain X.Y.Z": Object.freeze([
40
+ "1.3",
41
+ "2",
42
+ "1.2.4.0",
43
+ "01.2.4",
44
+ "1.02.4",
45
+ "1.3.00",
46
+ "x.y.z",
47
+ "1.2.x",
48
+ "latest",
49
+ "",
50
+ " 1.2.4",
51
+ "1.2.4 ",
52
+ "v1.2.4",
53
+ "V1.2.4",
54
+ "^1.2.4",
55
+ "~1.3.0",
56
+ ">=2.0.0",
57
+ "1.2.4 || 1.3.0",
58
+ ]),
59
+ });
60
+
61
+ /**
62
+ * All rejected bumps as `[reason, version]` pairs for table-driven tests.
63
+ *
64
+ * @type {ReadonlyArray<readonly [string, string]>}
65
+ */
66
+ export const REJECTED_VERSION_BUMP_CASES = Object.freeze(
67
+ Object.entries(REJECTED_VERSION_BUMPS).flatMap(([reason, versions]) =>
68
+ versions.map((version) => /** @type {const} */ ([reason, version]))
69
+ )
70
+ );