beez-rp 0.1.1
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/CHANGELOG.md +24 -0
- package/LICENSE.md +21 -0
- package/README.md +82 -0
- package/bin/beez-rp.js +36 -0
- package/package.json +77 -0
- package/src/build-gate.js +93 -0
- package/src/changelog-ai.js +52 -0
- package/src/changelog.js +109 -0
- package/src/constants/build-gate.js +24 -0
- package/src/constants/changelog-ai.js +14 -0
- package/src/constants/changelog.js +32 -0
- package/src/constants/cli.js +10 -0
- package/src/constants/index.js +12 -0
- package/src/constants/terminal-ui.js +93 -0
- package/src/constants/versions.js +39 -0
- package/src/index.js +37 -0
- package/src/terminal-ui.js +517 -0
- package/src/testing.js +70 -0
- package/src/versions.js +211 -0
- package/types/build-gate.d.ts +34 -0
- package/types/changelog-ai.d.ts +27 -0
- package/types/changelog.d.ts +52 -0
- package/types/constants/build-gate.d.ts +19 -0
- package/types/constants/changelog-ai.d.ts +11 -0
- package/types/constants/changelog.d.ts +23 -0
- package/types/constants/cli.d.ts +9 -0
- package/types/constants/index.d.ts +11 -0
- package/types/constants/terminal-ui.d.ts +73 -0
- package/types/constants/versions.d.ts +29 -0
- package/types/index.d.ts +18 -0
- package/types/terminal-ui.d.ts +180 -0
- package/types/testing.d.ts +31 -0
- package/types/versions.d.ts +102 -0
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Layout, ANSI and key contracts of the dependency-free terminal UI.
|
|
3
|
+
*
|
|
4
|
+
* @module constants/terminal-ui
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** Widest box drawn, so lines stay readable on large terminals. */
|
|
8
|
+
export const MAX_BOX_WIDTH = 84;
|
|
9
|
+
|
|
10
|
+
/** Width assumed when stdout does not report its columns. */
|
|
11
|
+
export const FALLBACK_TERMINAL_WIDTH = 80;
|
|
12
|
+
|
|
13
|
+
/** Columns kept free at the right of the terminal when sizing a box. */
|
|
14
|
+
export const TERMINAL_RIGHT_MARGIN = 2;
|
|
15
|
+
|
|
16
|
+
/** Horizontal padding inside a box, per side. */
|
|
17
|
+
export const BOX_PADDING = 1;
|
|
18
|
+
|
|
19
|
+
/** Width of the label column of status rows. */
|
|
20
|
+
export const ROW_LABEL_WIDTH = 16;
|
|
21
|
+
|
|
22
|
+
/** Spinner animation frames. */
|
|
23
|
+
export const SPINNER_FRAMES = Object.freeze(["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]);
|
|
24
|
+
|
|
25
|
+
/** Delay between spinner frames. */
|
|
26
|
+
export const SPINNER_INTERVAL_MS = 80;
|
|
27
|
+
|
|
28
|
+
/** Exit code conventionally used after Ctrl+C. */
|
|
29
|
+
export const INTERRUPTED_EXIT_CODE = 130;
|
|
30
|
+
|
|
31
|
+
/** Splits styled text into ANSI escape sequences and single code points. */
|
|
32
|
+
export const ANSI_TOKEN_PATTERN = /\x1b\[[0-9;]*m|[\s\S]/gu;
|
|
33
|
+
|
|
34
|
+
/** First character of every ANSI escape sequence. */
|
|
35
|
+
export const ANSI_ESCAPE = "\x1b";
|
|
36
|
+
|
|
37
|
+
/** ANSI control sequences used by boxes, spinners and prompts. */
|
|
38
|
+
export const ANSI_SEQUENCE = Object.freeze({
|
|
39
|
+
reset: "\x1b[0m",
|
|
40
|
+
hideCursor: "\x1b[?25l",
|
|
41
|
+
showCursor: "\x1b[?25h",
|
|
42
|
+
clearLine: "\r\x1b[2K",
|
|
43
|
+
clearBelow: "\x1b[0J",
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
/** Border color of each box tone. */
|
|
47
|
+
export const BOX_TONE = Object.freeze({
|
|
48
|
+
neutral: "gray",
|
|
49
|
+
info: "cyan",
|
|
50
|
+
success: "green",
|
|
51
|
+
warning: "yellow",
|
|
52
|
+
danger: "red",
|
|
53
|
+
accent: "magenta",
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
/** Nerd Font glyphs (Font Awesome and Octicons sets) used as status icons. */
|
|
57
|
+
export const ICON_GLYPH = Object.freeze({
|
|
58
|
+
success: "", // nf-fa-check
|
|
59
|
+
failure: "", // nf-fa-times
|
|
60
|
+
warning: "", // nf-fa-warning
|
|
61
|
+
info: "", // nf-fa-info_circle
|
|
62
|
+
pending: "", // nf-fa-circle_o
|
|
63
|
+
arrow: "", // nf-fa-chevron_right
|
|
64
|
+
bullet: "", // nf-oct-dot_fill
|
|
65
|
+
star: "", // nf-fa-star
|
|
66
|
+
rocket: "", // nf-fa-rocket
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
/** Label/value rows (`renderRow`): the value starts after a gap of two or more spaces. */
|
|
70
|
+
export const VALUE_COLUMN_PATTERN = /^\s*\S(?:.*?\S)?\s{2,}(?=\S)/u;
|
|
71
|
+
|
|
72
|
+
/** Leading marker (icon, arrow, bullet or `1.`, never a word) followed by a space, used as hanging indent. */
|
|
73
|
+
export const HANGING_MARKER_PATTERN = /^(\s*)((?:[^\p{L}\p{N}\s]{1,2}|\d{1,2}\.)\s+)?/u;
|
|
74
|
+
|
|
75
|
+
/** Options that can be picked with a single digit key (1-9). */
|
|
76
|
+
export const MAX_NUMBERED_OPTIONS = 9;
|
|
77
|
+
|
|
78
|
+
/** A single digit key that picks a numbered option. */
|
|
79
|
+
export const NUMBER_KEY_PATTERN = /^[1-9]$/u;
|
|
80
|
+
|
|
81
|
+
/** Seconds in a minute, used to format durations. */
|
|
82
|
+
export const SECONDS_PER_MINUTE = 60;
|
|
83
|
+
|
|
84
|
+
/** Milliseconds in a second, used to format durations. */
|
|
85
|
+
export const MILLISECONDS_PER_SECOND = 1000;
|
|
86
|
+
|
|
87
|
+
/** Key names (`readline` keypress events) handled by the select prompt. */
|
|
88
|
+
export const PROMPT_KEY = Object.freeze({
|
|
89
|
+
interrupt: "c",
|
|
90
|
+
previous: Object.freeze(["up", "k"]),
|
|
91
|
+
next: Object.freeze(["down", "j", "tab"]),
|
|
92
|
+
confirm: Object.freeze(["return", "enter"]),
|
|
93
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Release version rules shared by `create-version` and the Vercel build gate.
|
|
3
|
+
*
|
|
4
|
+
* @module constants/versions
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** Semver release types, from the smallest to the largest change. */
|
|
8
|
+
export const RELEASE_TYPE = Object.freeze({
|
|
9
|
+
patch: "patch",
|
|
10
|
+
minor: "minor",
|
|
11
|
+
major: "major",
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
/** Release types in the order they are offered: patch, minor, major. */
|
|
15
|
+
export const RELEASE_TYPE_ORDER = Object.freeze([RELEASE_TYPE.patch, RELEASE_TYPE.minor, RELEASE_TYPE.major]);
|
|
16
|
+
|
|
17
|
+
/** Stable `X.Y.Z` release version: no prerelease, build metadata, prefix or leading zeros. */
|
|
18
|
+
export const RELEASE_VERSION_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
|
|
19
|
+
|
|
20
|
+
/** Any semver version, capturing its `X.Y.Z` core and optional prerelease. */
|
|
21
|
+
export const SEMVER_PATTERN = /^((?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*))(-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
22
|
+
|
|
23
|
+
/** Prefix of the annotated Git tags that mark each release (`v0.93.0`). */
|
|
24
|
+
export const RELEASE_TAG_PREFIX = "v";
|
|
25
|
+
|
|
26
|
+
/** `git log --grep` pattern of release commit subjects (`0.93.0`). */
|
|
27
|
+
export const RELEASE_COMMIT_SUBJECT_GREP = String.raw`^[0-9]+\.[0-9]+\.[0-9]+$`;
|
|
28
|
+
|
|
29
|
+
/** Conventional commit header: `type(scope)!: subject`. */
|
|
30
|
+
export const CONVENTIONAL_HEADER_PATTERN = /^(?<type>[a-z]+)(?:\([^)]*\))?(?<breaking>!)?:\s/i;
|
|
31
|
+
|
|
32
|
+
/** Footer that marks a breaking change in a conventional commit body. */
|
|
33
|
+
export const BREAKING_CHANGE_FOOTER_PATTERN = /^BREAKING[ -]CHANGE:/m;
|
|
34
|
+
|
|
35
|
+
/** Conventional commit type that ships user-visible features. */
|
|
36
|
+
export const FEATURE_COMMIT_TYPE = "feat";
|
|
37
|
+
|
|
38
|
+
/** Imperative verbs used by legacy, non-conventional feature subjects. */
|
|
39
|
+
export const LEGACY_FEATURE_SUBJECT_PATTERN = /^(add|implement|introduce|support|enable|create|allow)\b/i;
|
package/src/index.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `beez-rp`: dependency-free release process shared by the Beez projects.
|
|
3
|
+
*
|
|
4
|
+
* - `beez-rp/versions`: stable `X.Y.Z` rules; only the next patch, minor or major is allowed.
|
|
5
|
+
* - `beez-rp/build-gate`: Vercel `ignoreCommand` decision built on those rules.
|
|
6
|
+
* - `beez-rp/changelog` and `beez-rp/changelog-ai`: Keep a Changelog release and Codex fill-in.
|
|
7
|
+
* - `beez-rp/terminal-ui`: boxes, spinners and prompts for release commands.
|
|
8
|
+
* - `beez-rp/testing`: shared version bump fixtures for consumer test suites.
|
|
9
|
+
* - `beez-rp/constants`: every constant above, grouped by domain.
|
|
10
|
+
*
|
|
11
|
+
* @module beez-rp
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export * from "./build-gate.js";
|
|
15
|
+
export * from "./changelog.js";
|
|
16
|
+
export * from "./changelog-ai.js";
|
|
17
|
+
export * from "./versions.js";
|
|
18
|
+
export {
|
|
19
|
+
ICON,
|
|
20
|
+
countTerminalRows,
|
|
21
|
+
formatDuration,
|
|
22
|
+
getPromptWaitMs,
|
|
23
|
+
measureActiveMs,
|
|
24
|
+
paint,
|
|
25
|
+
print,
|
|
26
|
+
renderBanner,
|
|
27
|
+
renderBox,
|
|
28
|
+
renderRow,
|
|
29
|
+
renderStepHeader,
|
|
30
|
+
resolveBoxWidth,
|
|
31
|
+
resolveNumberKey,
|
|
32
|
+
select,
|
|
33
|
+
startSpinner,
|
|
34
|
+
visibleWidth,
|
|
35
|
+
wrapStyledLine,
|
|
36
|
+
} from "./terminal-ui.js";
|
|
37
|
+
export * from "./constants/index.js";
|
|
@@ -0,0 +1,517 @@
|
|
|
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
|
+
return `${icon} ${paint("bold", label.padEnd(labelWidth))}${value}`;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Renders the one-line header shown when a release command starts: an
|
|
282
|
+
* inverted `RELEASE` label, the project name, the published version aligned
|
|
283
|
+
* to the right and a rule underneath.
|
|
284
|
+
*
|
|
285
|
+
* @param {{ projectName: string, publishedLabel: string | null }} options - Header content.
|
|
286
|
+
* @returns {string} Header.
|
|
287
|
+
*/
|
|
288
|
+
export function renderBanner({ projectName, publishedLabel }) {
|
|
289
|
+
const width = resolveBoxWidth();
|
|
290
|
+
const left = `${paint(["inverse", "bold", "magenta"], " RELEASE ")} ${paint("bold", projectName)}`;
|
|
291
|
+
const right = publishedLabel ? paint("gray", publishedLabel) : "";
|
|
292
|
+
const gap = " ".repeat(Math.max(width - visibleWidth(left) - visibleWidth(right), 2));
|
|
293
|
+
|
|
294
|
+
return ["", `${left}${gap}${right}`, paint("gray", "─".repeat(width)), ""].join("\n");
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Renders the header that introduces each executed step.
|
|
299
|
+
*
|
|
300
|
+
* @param {number} stepNumber - 1-based index.
|
|
301
|
+
* @param {number} stepCount - Total steps.
|
|
302
|
+
* @param {string} title - Step title.
|
|
303
|
+
* @returns {string} Header line.
|
|
304
|
+
*/
|
|
305
|
+
export function renderStepHeader(stepNumber, stepCount, title) {
|
|
306
|
+
const label = paint(["bold", "magenta"], ` PASO ${stepNumber}/${stepCount} `);
|
|
307
|
+
const text = ` ${paint("bold", title)} `;
|
|
308
|
+
const fill = Math.max(resolveBoxWidth() - visibleWidth(label) - visibleWidth(text) - 2, 2);
|
|
309
|
+
|
|
310
|
+
return `\n${paint("magenta", "━━")}${label}${paint("gray", "━")}${text}${paint("gray", "━".repeat(fill))}`;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Writes a line to stdout.
|
|
315
|
+
*
|
|
316
|
+
* @param {string} [text] - Line content.
|
|
317
|
+
*/
|
|
318
|
+
export function print(text = "") {
|
|
319
|
+
process.stdout.write(`${text}\n`);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Formats a duration in a compact Spanish form.
|
|
324
|
+
*
|
|
325
|
+
* @param {number} milliseconds - Duration.
|
|
326
|
+
* @returns {string} Such as `3.2 s` or `4 min 05 s`.
|
|
327
|
+
*/
|
|
328
|
+
export function formatDuration(milliseconds) {
|
|
329
|
+
const totalSeconds = milliseconds / MILLISECONDS_PER_SECOND;
|
|
330
|
+
|
|
331
|
+
if (totalSeconds < SECONDS_PER_MINUTE) {
|
|
332
|
+
return `${totalSeconds.toFixed(1)} s`;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const minutes = Math.floor(totalSeconds / SECONDS_PER_MINUTE);
|
|
336
|
+
const seconds = Math.round(totalSeconds % SECONDS_PER_MINUTE);
|
|
337
|
+
return `${minutes} min ${String(seconds).padStart(2, "0")} s`;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Starts a spinner; on non-TTY outputs it prints the label once instead.
|
|
342
|
+
*
|
|
343
|
+
* @param {string} label - Initial label.
|
|
344
|
+
* @returns {{ update: (label: string) => void, succeed: (label?: string) => void, fail: (label?: string) => void }} Controls.
|
|
345
|
+
*/
|
|
346
|
+
export function startSpinner(label) {
|
|
347
|
+
const isInteractive = Boolean(process.stdout.isTTY);
|
|
348
|
+
const startedAt = Date.now();
|
|
349
|
+
let currentLabel = label;
|
|
350
|
+
let frameIndex = 0;
|
|
351
|
+
/** @type {ReturnType<typeof setInterval> | null} */
|
|
352
|
+
let timer = null;
|
|
353
|
+
|
|
354
|
+
const render = () => {
|
|
355
|
+
const frame = paint("magenta", SPINNER_FRAMES[frameIndex % SPINNER_FRAMES.length]);
|
|
356
|
+
process.stdout.write(`${ANSI_SEQUENCE.clearLine}${frame} ${currentLabel}`);
|
|
357
|
+
frameIndex += 1;
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
if (isInteractive) {
|
|
361
|
+
process.stdout.write(ANSI_SEQUENCE.hideCursor);
|
|
362
|
+
render();
|
|
363
|
+
timer = setInterval(render, SPINNER_INTERVAL_MS);
|
|
364
|
+
} else {
|
|
365
|
+
print(`${ICON.pending} ${label}`);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* @param {string} icon - Final icon.
|
|
370
|
+
* @param {string} [finalLabel] - Final label; defaults to the current one.
|
|
371
|
+
*/
|
|
372
|
+
const stop = (icon, finalLabel) => {
|
|
373
|
+
if (timer) {
|
|
374
|
+
clearInterval(timer);
|
|
375
|
+
process.stdout.write(`${ANSI_SEQUENCE.clearLine}${ANSI_SEQUENCE.showCursor}`);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
print(`${icon} ${finalLabel ?? currentLabel} ${paint("gray", formatDuration(Date.now() - startedAt))}`);
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
return {
|
|
382
|
+
update(nextLabel) {
|
|
383
|
+
currentLabel = nextLabel;
|
|
384
|
+
if (!isInteractive) {
|
|
385
|
+
print(`${ICON.pending} ${nextLabel}`);
|
|
386
|
+
}
|
|
387
|
+
},
|
|
388
|
+
succeed: (finalLabel) => stop(ICON.success, finalLabel),
|
|
389
|
+
fail: (finalLabel) => stop(ICON.failure, finalLabel),
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Restores the terminal and exits after Ctrl+C.
|
|
395
|
+
*/
|
|
396
|
+
function exitOnInterrupt() {
|
|
397
|
+
process.stdout.write(`${ANSI_SEQUENCE.showCursor}\n`);
|
|
398
|
+
print(`${ICON.warning} ${paint("yellow", "Release cancelado por el usuario. No se tocó nada más.")}`);
|
|
399
|
+
process.exit(INTERRUPTED_EXIT_CODE);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Counts the terminal rows that lines occupy, including the extra rows of
|
|
404
|
+
* lines wider than the terminal, so a prompt can erase exactly what it drew.
|
|
405
|
+
*
|
|
406
|
+
* @param {string[]} lines - Rendered lines, possibly styled.
|
|
407
|
+
* @param {number} columns - Terminal width in columns.
|
|
408
|
+
* @returns {number} Physical rows.
|
|
409
|
+
*/
|
|
410
|
+
export function countTerminalRows(lines, columns) {
|
|
411
|
+
const safeColumns = Math.max(columns, 1);
|
|
412
|
+
return lines.reduce((rows, line) => rows + Math.max(1, Math.ceil(visibleWidth(line) / safeColumns)), 0);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Maps a typed character to the option it selects.
|
|
417
|
+
*
|
|
418
|
+
* @param {string | undefined} text - Character typed by the user.
|
|
419
|
+
* @param {number} optionCount - Number of options in the prompt.
|
|
420
|
+
* @returns {number} Zero-based option index, or -1 when the key does not pick an option.
|
|
421
|
+
*/
|
|
422
|
+
export function resolveNumberKey(text, optionCount) {
|
|
423
|
+
if (!text || !NUMBER_KEY_PATTERN.test(text)) {
|
|
424
|
+
return -1;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const index = Number(text) - 1;
|
|
428
|
+
return index < Math.min(optionCount, MAX_NUMBERED_OPTIONS) ? index : -1;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Asks the user to choose one option: its number picks it right away, or the
|
|
433
|
+
* arrow keys move the selection and Enter confirms it.
|
|
434
|
+
*
|
|
435
|
+
* @param {{ message: string, options: SelectOption[], defaultIndex?: number }} prompt - Prompt; `description` renders on its own line below the option.
|
|
436
|
+
* @returns {Promise<string>} Selected value (the default one when stdin is not a TTY).
|
|
437
|
+
*/
|
|
438
|
+
export function select({ message, options, defaultIndex = 0 }) {
|
|
439
|
+
const input = process.stdin;
|
|
440
|
+
const question = `${paint(["bold", "cyan"], "?")} ${paint("bold", message)}`;
|
|
441
|
+
|
|
442
|
+
if (!input.isTTY) {
|
|
443
|
+
print(`${question} ${paint("gray", `→ ${options[defaultIndex].label} (sin terminal interactiva)`)}`);
|
|
444
|
+
return Promise.resolve(options[defaultIndex].value);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
return new Promise((resolve) => {
|
|
448
|
+
const promptStartedAt = Date.now();
|
|
449
|
+
let selectedIndex = defaultIndex;
|
|
450
|
+
let renderedLineCount = 0;
|
|
451
|
+
|
|
452
|
+
const render = () => {
|
|
453
|
+
const lines = [
|
|
454
|
+
question,
|
|
455
|
+
...options.flatMap((option, index) => {
|
|
456
|
+
const isSelected = index === selectedIndex;
|
|
457
|
+
const pointer = isSelected ? ICON.arrow : " ";
|
|
458
|
+
const numberLabel = index < MAX_NUMBERED_OPTIONS ? `${index + 1}.` : " ";
|
|
459
|
+
const number = isSelected ? paint(["bold", "magentaBright"], numberLabel) : paint("gray", numberLabel);
|
|
460
|
+
const label = isSelected ? paint(["bold", "magentaBright"], option.label) : option.label;
|
|
461
|
+
const hint = option.hint ? ` ${paint("gray", option.hint)}` : "";
|
|
462
|
+
const optionLine = ` ${pointer} ${number} ${label}${hint}`;
|
|
463
|
+
return option.description ? [optionLine, ` ${paint("gray", option.description)}`] : [optionLine];
|
|
464
|
+
}),
|
|
465
|
+
paint("gray", ` 1-${Math.min(options.length, MAX_NUMBERED_OPTIONS)} para elegir · ↑/↓ y Enter para moverte y confirmar`),
|
|
466
|
+
];
|
|
467
|
+
process.stdout.write(`${cursorUp(renderedLineCount)}\r${ANSI_SEQUENCE.clearBelow}${lines.join("\n")}\n`);
|
|
468
|
+
// Long lines wrap in the terminal: count physical rows so the next redraw erases all of them.
|
|
469
|
+
renderedLineCount = countTerminalRows(lines, process.stdout.columns || FALLBACK_TERMINAL_WIDTH);
|
|
470
|
+
};
|
|
471
|
+
|
|
472
|
+
const finish = () => {
|
|
473
|
+
input.off("keypress", onKeypress);
|
|
474
|
+
input.setRawMode(false);
|
|
475
|
+
input.pause();
|
|
476
|
+
const chosen = options[selectedIndex];
|
|
477
|
+
process.stdout.write(`${cursorUp(renderedLineCount)}\r${ANSI_SEQUENCE.clearBelow}${ANSI_SEQUENCE.showCursor}`);
|
|
478
|
+
print(`${question} ${paint("magentaBright", chosen.label)}`);
|
|
479
|
+
promptWaitMs += Date.now() - promptStartedAt;
|
|
480
|
+
resolve(chosen.value);
|
|
481
|
+
};
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* @param {string | undefined} text - Typed character.
|
|
485
|
+
* @param {{ name?: string, ctrl?: boolean }} [key] - Parsed key.
|
|
486
|
+
*/
|
|
487
|
+
const onKeypress = (text, key = {}) => {
|
|
488
|
+
const numberedIndex = resolveNumberKey(text, options.length);
|
|
489
|
+
const keyName = key.name ?? "";
|
|
490
|
+
|
|
491
|
+
if (key.ctrl && keyName === PROMPT_KEY.interrupt) {
|
|
492
|
+
input.setRawMode(false);
|
|
493
|
+
exitOnInterrupt();
|
|
494
|
+
} else if (numberedIndex !== -1) {
|
|
495
|
+
selectedIndex = numberedIndex;
|
|
496
|
+
finish();
|
|
497
|
+
} else if (PROMPT_KEY.previous.includes(keyName)) {
|
|
498
|
+
selectedIndex = (selectedIndex - 1 + options.length) % options.length;
|
|
499
|
+
render();
|
|
500
|
+
} else if (PROMPT_KEY.next.includes(keyName)) {
|
|
501
|
+
selectedIndex = (selectedIndex + 1) % options.length;
|
|
502
|
+
render();
|
|
503
|
+
} else if (PROMPT_KEY.confirm.includes(keyName)) {
|
|
504
|
+
finish();
|
|
505
|
+
}
|
|
506
|
+
};
|
|
507
|
+
|
|
508
|
+
emitKeypressEvents(input);
|
|
509
|
+
input.setRawMode(true);
|
|
510
|
+
input.resume();
|
|
511
|
+
input.on("keypress", onKeypress);
|
|
512
|
+
process.stdout.write(ANSI_SEQUENCE.hideCursor);
|
|
513
|
+
render();
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
export { BOX_TONE, INTERRUPTED_EXIT_CODE };
|