@vigilbase/cli 0.1.4 → 0.2.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 (142) hide show
  1. package/CLOUDFLARE.md +2 -0
  2. package/COMMANDS.md +43 -6
  3. package/LOCAL.md +731 -0
  4. package/README.md +58 -6
  5. package/dist/auth/device-auth.d.ts.map +1 -1
  6. package/dist/auth/device-auth.js +7 -3
  7. package/dist/auth/device-auth.js.map +1 -1
  8. package/dist/auth/index.d.ts +3 -0
  9. package/dist/auth/index.d.ts.map +1 -1
  10. package/dist/auth/index.js +2 -0
  11. package/dist/auth/index.js.map +1 -1
  12. package/dist/auth/provider.d.ts +18 -0
  13. package/dist/auth/provider.d.ts.map +1 -0
  14. package/dist/auth/provider.js +62 -0
  15. package/dist/auth/provider.js.map +1 -0
  16. package/dist/auth/refresh.d.ts +10 -0
  17. package/dist/auth/refresh.d.ts.map +1 -0
  18. package/dist/auth/refresh.js +143 -0
  19. package/dist/auth/refresh.js.map +1 -0
  20. package/dist/auth/store.d.ts +27 -2
  21. package/dist/auth/store.d.ts.map +1 -1
  22. package/dist/auth/store.js +23 -8
  23. package/dist/auth/store.js.map +1 -1
  24. package/dist/auth/token-metadata.d.ts +10 -0
  25. package/dist/auth/token-metadata.d.ts.map +1 -0
  26. package/dist/auth/token-metadata.js +22 -0
  27. package/dist/auth/token-metadata.js.map +1 -0
  28. package/dist/auth/types.d.ts +8 -1
  29. package/dist/auth/types.d.ts.map +1 -1
  30. package/dist/cli.d.ts.map +1 -1
  31. package/dist/cli.js +40 -9
  32. package/dist/cli.js.map +1 -1
  33. package/dist/commands/auth.d.ts.map +1 -1
  34. package/dist/commands/auth.js +20 -8
  35. package/dist/commands/auth.js.map +1 -1
  36. package/dist/commands/helpers.d.ts +15 -0
  37. package/dist/commands/helpers.d.ts.map +1 -1
  38. package/dist/commands/helpers.js +2 -2
  39. package/dist/commands/helpers.js.map +1 -1
  40. package/dist/commands/index.d.ts +3 -1
  41. package/dist/commands/index.d.ts.map +1 -1
  42. package/dist/commands/index.js +6 -1
  43. package/dist/commands/index.js.map +1 -1
  44. package/dist/commands/local.d.ts +30 -0
  45. package/dist/commands/local.d.ts.map +1 -0
  46. package/dist/commands/local.js +1520 -0
  47. package/dist/commands/local.js.map +1 -0
  48. package/dist/commands/public-tools.d.ts.map +1 -1
  49. package/dist/commands/public-tools.js +8 -4
  50. package/dist/commands/public-tools.js.map +1 -1
  51. package/dist/index.d.ts +2 -1
  52. package/dist/index.d.ts.map +1 -1
  53. package/dist/index.js +1 -1
  54. package/dist/index.js.map +1 -1
  55. package/dist/lib/config.d.ts +16 -1
  56. package/dist/lib/config.d.ts.map +1 -1
  57. package/dist/lib/config.js +36 -4
  58. package/dist/lib/config.js.map +1 -1
  59. package/dist/lib/format.d.ts.map +1 -1
  60. package/dist/lib/format.js +9 -0
  61. package/dist/lib/format.js.map +1 -1
  62. package/dist/lib/help.d.ts +1 -1
  63. package/dist/lib/help.d.ts.map +1 -1
  64. package/dist/lib/help.js +9 -0
  65. package/dist/lib/help.js.map +1 -1
  66. package/dist/lib/network.d.ts.map +1 -1
  67. package/dist/lib/network.js +2 -1
  68. package/dist/lib/network.js.map +1 -1
  69. package/dist/lib/one-client.d.ts +3 -2
  70. package/dist/lib/one-client.d.ts.map +1 -1
  71. package/dist/lib/one-client.js +46 -2
  72. package/dist/lib/one-client.js.map +1 -1
  73. package/dist/lib/security-client.d.ts +1 -1
  74. package/dist/lib/tui.d.ts +309 -0
  75. package/dist/lib/tui.d.ts.map +1 -0
  76. package/dist/lib/tui.js +847 -0
  77. package/dist/lib/tui.js.map +1 -0
  78. package/dist/local/cloudflare-checkup/cf/allowlist.d.ts +95 -0
  79. package/dist/local/cloudflare-checkup/cf/allowlist.d.ts.map +1 -0
  80. package/dist/local/cloudflare-checkup/cf/allowlist.js +184 -0
  81. package/dist/local/cloudflare-checkup/cf/allowlist.js.map +1 -0
  82. package/dist/local/cloudflare-checkup/cf/auth-lifecycle.d.ts +113 -0
  83. package/dist/local/cloudflare-checkup/cf/auth-lifecycle.d.ts.map +1 -0
  84. package/dist/local/cloudflare-checkup/cf/auth-lifecycle.js +313 -0
  85. package/dist/local/cloudflare-checkup/cf/auth-lifecycle.js.map +1 -0
  86. package/dist/local/cloudflare-checkup/cf/child-env.d.ts +32 -0
  87. package/dist/local/cloudflare-checkup/cf/child-env.d.ts.map +1 -0
  88. package/dist/local/cloudflare-checkup/cf/child-env.js +81 -0
  89. package/dist/local/cloudflare-checkup/cf/child-env.js.map +1 -0
  90. package/dist/local/cloudflare-checkup/cf/install.d.ts +106 -0
  91. package/dist/local/cloudflare-checkup/cf/install.d.ts.map +1 -0
  92. package/dist/local/cloudflare-checkup/cf/install.js +309 -0
  93. package/dist/local/cloudflare-checkup/cf/install.js.map +1 -0
  94. package/dist/local/cloudflare-checkup/cf/locate.d.ts +44 -0
  95. package/dist/local/cloudflare-checkup/cf/locate.d.ts.map +1 -0
  96. package/dist/local/cloudflare-checkup/cf/locate.js +228 -0
  97. package/dist/local/cloudflare-checkup/cf/locate.js.map +1 -0
  98. package/dist/local/cloudflare-checkup/cf/run-cf.d.ts +89 -0
  99. package/dist/local/cloudflare-checkup/cf/run-cf.d.ts.map +1 -0
  100. package/dist/local/cloudflare-checkup/cf/run-cf.js +275 -0
  101. package/dist/local/cloudflare-checkup/cf/run-cf.js.map +1 -0
  102. package/dist/local/cloudflare-checkup/collector.d.ts +48 -0
  103. package/dist/local/cloudflare-checkup/collector.d.ts.map +1 -0
  104. package/dist/local/cloudflare-checkup/collector.js +296 -0
  105. package/dist/local/cloudflare-checkup/collector.js.map +1 -0
  106. package/dist/local/cloudflare-checkup/contract.d.ts +2793 -0
  107. package/dist/local/cloudflare-checkup/contract.d.ts.map +1 -0
  108. package/dist/local/cloudflare-checkup/contract.js +1614 -0
  109. package/dist/local/cloudflare-checkup/contract.js.map +1 -0
  110. package/dist/local/cloudflare-checkup/email-verification.d.ts +125 -0
  111. package/dist/local/cloudflare-checkup/email-verification.d.ts.map +1 -0
  112. package/dist/local/cloudflare-checkup/email-verification.js +212 -0
  113. package/dist/local/cloudflare-checkup/email-verification.js.map +1 -0
  114. package/dist/local/cloudflare-checkup/errors.d.ts +28 -0
  115. package/dist/local/cloudflare-checkup/errors.d.ts.map +1 -0
  116. package/dist/local/cloudflare-checkup/errors.js +99 -0
  117. package/dist/local/cloudflare-checkup/errors.js.map +1 -0
  118. package/dist/local/cloudflare-checkup/output.d.ts +24 -0
  119. package/dist/local/cloudflare-checkup/output.d.ts.map +1 -0
  120. package/dist/local/cloudflare-checkup/output.js +89 -0
  121. package/dist/local/cloudflare-checkup/output.js.map +1 -0
  122. package/dist/local/cloudflare-checkup/prompts.d.ts +41 -0
  123. package/dist/local/cloudflare-checkup/prompts.d.ts.map +1 -0
  124. package/dist/local/cloudflare-checkup/prompts.js +140 -0
  125. package/dist/local/cloudflare-checkup/prompts.js.map +1 -0
  126. package/dist/local/cloudflare-checkup/rate-budget.d.ts +42 -0
  127. package/dist/local/cloudflare-checkup/rate-budget.d.ts.map +1 -0
  128. package/dist/local/cloudflare-checkup/rate-budget.js +80 -0
  129. package/dist/local/cloudflare-checkup/rate-budget.js.map +1 -0
  130. package/dist/local/cloudflare-checkup/snapshot-file.d.ts +53 -0
  131. package/dist/local/cloudflare-checkup/snapshot-file.d.ts.map +1 -0
  132. package/dist/local/cloudflare-checkup/snapshot-file.js +186 -0
  133. package/dist/local/cloudflare-checkup/snapshot-file.js.map +1 -0
  134. package/dist/local/cloudflare-checkup/submit-client.d.ts +98 -0
  135. package/dist/local/cloudflare-checkup/submit-client.d.ts.map +1 -0
  136. package/dist/local/cloudflare-checkup/submit-client.js +349 -0
  137. package/dist/local/cloudflare-checkup/submit-client.js.map +1 -0
  138. package/dist/local/cloudflare-checkup/terminal-ui.d.ts +171 -0
  139. package/dist/local/cloudflare-checkup/terminal-ui.d.ts.map +1 -0
  140. package/dist/local/cloudflare-checkup/terminal-ui.js +404 -0
  141. package/dist/local/cloudflare-checkup/terminal-ui.js.map +1 -0
  142. package/package.json +8 -4
@@ -0,0 +1,847 @@
1
+ /**
2
+ * A small terminal UI on raw ANSI, with no dependencies: a step timeline
3
+ * (◆ active, ◇ done, │ rail, └ end, ▲ warning, ■ error), spinners, a progress
4
+ * bar, and raw-mode inputs (text, code, select, confirm) that re-render in
5
+ * place. It is used only when `terminalFeatures` finds an interactive
6
+ * terminal; everything else keeps the plain line output.
7
+ *
8
+ * While a session is open it owns the terminal: raw mode (typed keys are not
9
+ * echoed into the timeline, and Ctrl-C arrives as a key), a hidden cursor, and
10
+ * SIGINT, SIGTERM and exit listeners that always restore both. Ctrl-C prints
11
+ * the cancel message and is re-raised as SIGINT, so the other SIGINT listeners
12
+ * (the cf runner and the cf install clean up, then exit 130) still run; with
13
+ * none, the session exits 130 itself.
14
+ */
15
+ import { StringDecoder } from 'node:string_decoder';
16
+ import { shouldUseColor } from './banner.js';
17
+ /** Set, and not `0` or `false`. */
18
+ function isSet(value) {
19
+ return value !== undefined && value !== '' && value !== '0' && value.toLowerCase() !== 'false';
20
+ }
21
+ /**
22
+ * The rich UI's features, or null for the plain line output: `--json`, stdin
23
+ * or stderr not a terminal (pipes, files), TERM=dumb, or CI set.
24
+ */
25
+ export function terminalFeatures(options) {
26
+ const { mode, stdin, stderr, env } = options;
27
+ if (mode === 'json')
28
+ return null;
29
+ if (!stdin?.isTTY || typeof stdin.setRawMode !== 'function' || !stderr?.isTTY)
30
+ return null;
31
+ if ((env.TERM ?? '').toLowerCase() === 'dumb' || isSet(env.CI))
32
+ return null;
33
+ return {
34
+ color: shouldUseColor(stderr, env),
35
+ unicode: supportsUnicode(env, options.platform ?? process.platform),
36
+ hyperlinks: supportsHyperlinks(env),
37
+ };
38
+ }
39
+ /** Legacy Windows consoles (no Windows Terminal, no terminal program) and the Linux console get ASCII. */
40
+ export function supportsUnicode(env, platform = process.platform) {
41
+ if (env.TERM === 'linux')
42
+ return false;
43
+ return platform !== 'win32' || Boolean(env.WT_SESSION) || Boolean(env.TERM_PROGRAM);
44
+ }
45
+ const HYPERLINK_PROGRAMS = new Set(['iTerm.app', 'WezTerm', 'vscode', 'ghostty', 'Tabby', 'rio']);
46
+ /** Terminals known to render OSC 8 links; FORCE_HYPERLINK overrides. Multiplexers are left out. */
47
+ export function supportsHyperlinks(env) {
48
+ if (env.FORCE_HYPERLINK !== undefined && env.FORCE_HYPERLINK !== '')
49
+ return env.FORCE_HYPERLINK !== '0';
50
+ const term = env.TERM ?? '';
51
+ if (env.TMUX || term.startsWith('screen') || term.startsWith('tmux'))
52
+ return false;
53
+ if (env.WT_SESSION || env.KITTY_WINDOW_ID || env.WEZTERM_EXECUTABLE)
54
+ return true;
55
+ if (env.TERM_PROGRAM && HYPERLINK_PROGRAMS.has(env.TERM_PROGRAM))
56
+ return true;
57
+ if (['xterm-kitty', 'xterm-ghostty', 'alacritty', 'foot', 'foot-extra'].includes(term))
58
+ return true;
59
+ if (Number(env.VTE_VERSION) >= 5000)
60
+ return true;
61
+ return Number(env.KONSOLE_VERSION) >= 201200;
62
+ }
63
+ export const UNICODE_SYMBOLS = {
64
+ active: '◆', done: '◇', rail: '│', end: '└', warning: '▲', error: '■', check: '✓',
65
+ radioOn: '●', radioOff: '○', pointer: '›', ellipsis: '…', separator: '·', mask: '●', placeholder: '·',
66
+ barFull: '█', barEmpty: '░', up: '↑', down: '↓', spinner: ['◒', '◐', '◓', '◑'],
67
+ };
68
+ export const ASCII_SYMBOLS = {
69
+ active: '*', done: 'o', rail: '|', end: '+', warning: '!', error: 'x', check: 'v',
70
+ radioOn: '>', radioOff: ' ', pointer: '>', ellipsis: '...', separator: '-', mask: '*', placeholder: '.',
71
+ barFull: '#', barEmpty: '-', up: '^', down: 'v', spinner: ['|', '/', '-', '\\'],
72
+ };
73
+ const sgr = (open, close) => (text) => (text === '' ? '' : `\u001b[${open}m${text}\u001b[${close}m`);
74
+ const plain = (text) => text;
75
+ /** SGR styles; with colour off, every style is the identity (no escape sequences at all). */
76
+ export function createStyles(color) {
77
+ if (!color)
78
+ return { bold: plain, dim: plain, underline: plain, inverse: plain, green: plain, yellow: plain, red: plain, cyan: plain, brand: plain };
79
+ return {
80
+ bold: sgr('1', '22'),
81
+ dim: sgr('2', '22'),
82
+ underline: sgr('4', '24'),
83
+ inverse: sgr('7', '27'),
84
+ green: sgr('32', '39'),
85
+ yellow: sgr('33', '39'),
86
+ red: sgr('31', '39'),
87
+ cyan: sgr('36', '39'),
88
+ // Brand yellow, as in the banner.
89
+ brand: sgr('38;5;221', '39'),
90
+ };
91
+ }
92
+ // ============================================================================
93
+ // Measuring text
94
+ // ============================================================================
95
+ const ESCAPE_SEQUENCE = /\u001b\[[0-?]*[ -/]*[@-~]|\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)/g;
96
+ const ZERO_WIDTH = /[\p{Mn}\p{Me}\p{Cf}\u0000-\u001f\u007f-\u009f\ufe00-\ufe0f]/u;
97
+ const WIDE = /[\u1100-\u115f\u2e80-\u303e\u3041-\u33ff\u3400-\u4dbf\u4e00-\u9fff\ua000-\ua4cf\uac00-\ud7a3\uf900-\ufaff\ufe30-\ufe4f\uff00-\uff60\uffe0-\uffe6\u{20000}-\u{3fffd}]|\p{Emoji_Presentation}/u;
98
+ export function stripAnsi(text) {
99
+ return text.replace(ESCAPE_SEQUENCE, '');
100
+ }
101
+ /** Terminal columns of one character: 0 for marks and controls, 2 for CJK and emoji, else 1. */
102
+ export function charWidth(char) {
103
+ if (ZERO_WIDTH.test(char))
104
+ return 0;
105
+ return WIDE.test(char) ? 2 : 1;
106
+ }
107
+ /** Terminal columns of a line, ignoring escape sequences. */
108
+ export function displayWidth(text) {
109
+ let width = 0;
110
+ for (const char of stripAnsi(text))
111
+ width += charWidth(char);
112
+ return width;
113
+ }
114
+ /**
115
+ * At most `width` columns, ending in `…` when cut. Escape sequences are kept;
116
+ * styles and an open OSC 8 link are closed after a cut.
117
+ */
118
+ export function truncate(text, width, ellipsis = '…') {
119
+ if (displayWidth(text) <= width)
120
+ return text;
121
+ const room = Math.max(0, width - displayWidth(ellipsis));
122
+ let out = '';
123
+ let used = 0;
124
+ let styled = false;
125
+ let linkOpen = false;
126
+ const tokens = text.match(new RegExp(`${ESCAPE_SEQUENCE.source}|[\\s\\S]`, 'gu')) ?? [];
127
+ for (const token of tokens) {
128
+ if (token.startsWith('\u001b')) {
129
+ out += token;
130
+ if (token.startsWith('\u001b]8;'))
131
+ linkOpen = !/^\u001b\]8;[^;]*;(?:\u0007|\u001b\\)$/.test(token);
132
+ else
133
+ styled = true;
134
+ continue;
135
+ }
136
+ const w = charWidth(token);
137
+ if (used + w > room)
138
+ break;
139
+ out += token;
140
+ used += w;
141
+ }
142
+ return `${out}${linkOpen ? '\u001b]8;;\u001b\\' : ''}${styled ? '\u001b[0m' : ''}${width >= displayWidth(ellipsis) ? ellipsis : ''}`;
143
+ }
144
+ /**
145
+ * Plain `text` (no escape sequences) in at most `width` columns, keeping its
146
+ * start and its end around the ellipsis, so names that share a long prefix
147
+ * (`Acme … Production`, `Acme … Staging`) still differ once cut.
148
+ */
149
+ export function truncateMiddle(text, width, ellipsis = '…') {
150
+ if (displayWidth(text) <= width)
151
+ return text;
152
+ const room = width - displayWidth(ellipsis);
153
+ if (room < 2)
154
+ return truncate(text, width, ellipsis);
155
+ let head = '';
156
+ let used = 0;
157
+ for (const char of text) {
158
+ if (used + charWidth(char) > Math.ceil(room / 2))
159
+ break;
160
+ head += char;
161
+ used += charWidth(char);
162
+ }
163
+ return `${head}${ellipsis}${tail(text, room - used)}`;
164
+ }
165
+ /** `text` padded with spaces to `width` columns. */
166
+ export function padEnd(text, width) {
167
+ return text + ' '.repeat(Math.max(0, width - displayWidth(text)));
168
+ }
169
+ const ESCAPE_KEY = /^\u001b(?:\[(?:[0-9;]*)[A-Za-z~]|O[A-Za-z])/;
170
+ function escapeKey(sequence) {
171
+ const final = sequence.slice(-1);
172
+ if (final === '~') {
173
+ const code = sequence.slice(2, -1).split(';')[0];
174
+ if (code === '3')
175
+ return { name: 'delete' };
176
+ if (code === '1' || code === '7')
177
+ return { name: 'home' };
178
+ if (code === '4' || code === '8')
179
+ return { name: 'end' };
180
+ // Bracketed-paste markers (200~, 201~) and function keys.
181
+ return { name: 'other' };
182
+ }
183
+ switch (final) {
184
+ case 'A': return { name: 'up' };
185
+ case 'B': return { name: 'down' };
186
+ case 'C': return { name: 'right' };
187
+ case 'D': return { name: 'left' };
188
+ case 'H': return { name: 'home' };
189
+ case 'F': return { name: 'end' };
190
+ case 'Z': return { name: 'tab' };
191
+ default: return { name: 'other' };
192
+ }
193
+ }
194
+ /** Keys in a chunk of raw-mode input; a paste arrives as many `char` keys. */
195
+ export function parseKeys(input) {
196
+ const keys = [];
197
+ for (let index = 0; index < input.length;) {
198
+ if (input[index] === '\u001b') {
199
+ const match = ESCAPE_KEY.exec(input.slice(index));
200
+ keys.push(match ? escapeKey(match[0]) : { name: 'escape' });
201
+ index += match ? match[0].length : 1;
202
+ continue;
203
+ }
204
+ const char = String.fromCodePoint(input.codePointAt(index));
205
+ index += char.length;
206
+ switch (char) {
207
+ case '\r':
208
+ case '\n':
209
+ keys.push({ name: 'enter' });
210
+ if (char === '\r' && input[index] === '\n')
211
+ index++;
212
+ break;
213
+ case '\u007f':
214
+ case '\b':
215
+ keys.push({ name: 'backspace' });
216
+ break;
217
+ case '\u0003':
218
+ keys.push({ name: 'ctrl-c' });
219
+ break;
220
+ case '\u0004':
221
+ keys.push({ name: 'ctrl-d' });
222
+ break;
223
+ case '\u0015':
224
+ keys.push({ name: 'ctrl-u' });
225
+ break;
226
+ case '\u0017':
227
+ keys.push({ name: 'ctrl-w' });
228
+ break;
229
+ case '\t':
230
+ keys.push({ name: 'tab' });
231
+ break;
232
+ default: keys.push(charWidth(char) === 0 && /[\p{Cc}\p{Cf}]/u.test(char) ? { name: 'other' } : { name: 'char', char });
233
+ }
234
+ }
235
+ return keys;
236
+ }
237
+ // ============================================================================
238
+ // The live region
239
+ // ============================================================================
240
+ /**
241
+ * The lines at the bottom of the output that are redrawn in place: each
242
+ * update moves the cursor back to the region's first line, clears to the end
243
+ * of the screen and writes the new lines in a single write. Lines are cut to
244
+ * the terminal width, so each takes one row; after a resize, the previous
245
+ * lines are counted at the new width (terminals rewrap them).
246
+ */
247
+ export class LiveRegion {
248
+ out;
249
+ ellipsis;
250
+ lines = [];
251
+ constructor(out, ellipsis = '…') {
252
+ this.out = out;
253
+ this.ellipsis = ellipsis;
254
+ }
255
+ /** The terminal width; 80 when it reports none (0 or undefined, as some pseudo-terminals do). */
256
+ get columns() {
257
+ const columns = Math.floor(this.out.columns ?? 0);
258
+ return columns > 0 ? Math.max(2, columns) : 80;
259
+ }
260
+ get rows() {
261
+ const rows = Math.floor(this.out.rows ?? 0);
262
+ return rows > 0 ? Math.max(6, rows) : 24;
263
+ }
264
+ erase() {
265
+ if (this.lines.length === 0)
266
+ return '';
267
+ const rows = this.lines.reduce((sum, line) => sum + Math.max(1, Math.ceil(displayWidth(line) / this.columns)), 0);
268
+ return `\r${rows > 1 ? `\u001b[${rows - 1}A` : ''}\u001b[J`;
269
+ }
270
+ /** Lines that fit: one column short of the width (no pending wrap) and one row short of the height. */
271
+ fit(lines) {
272
+ const width = this.columns - 1;
273
+ const kept = lines.length > this.rows - 1 ? [...lines.slice(0, this.rows - 2), this.ellipsis] : lines;
274
+ return kept.map((line) => truncate(line, width, this.ellipsis));
275
+ }
276
+ draw(lines) {
277
+ const next = this.fit(lines);
278
+ this.out.write(this.erase() + next.join('\n'));
279
+ this.lines = next;
280
+ }
281
+ /** Permanent lines above the region (never cut; long ones wrap), then the region again. */
282
+ commit(lines) {
283
+ const live = this.lines;
284
+ this.out.write(this.erase() + lines.map((line) => `${line}\n`).join('') + live.join('\n'));
285
+ }
286
+ clear() {
287
+ if (this.lines.length === 0)
288
+ return;
289
+ this.out.write(this.erase());
290
+ this.lines = [];
291
+ }
292
+ get height() {
293
+ return this.lines.length;
294
+ }
295
+ }
296
+ const HIDE_CURSOR = '\u001b[?25l';
297
+ const SHOW_CURSOR = '\u001b[?25h';
298
+ export class Tui {
299
+ symbols;
300
+ style;
301
+ features;
302
+ /** What Ctrl-C (or SIGTERM) prints before the process exits. */
303
+ cancelMessage = 'Cancelled.';
304
+ stdin;
305
+ stderr;
306
+ region;
307
+ signals;
308
+ exitProcess;
309
+ frameMs;
310
+ decoder = new StringDecoder('utf8');
311
+ frame = null;
312
+ animated = false;
313
+ timer = null;
314
+ tick = 0;
315
+ keyHandler = null;
316
+ reading = false;
317
+ raw = false;
318
+ suspended = false;
319
+ closed = false;
320
+ interrupted = false;
321
+ constructor(options) {
322
+ this.stdin = options.stdin;
323
+ this.stderr = options.stderr;
324
+ this.features = options.features;
325
+ this.symbols = options.features.unicode ? UNICODE_SYMBOLS : ASCII_SYMBOLS;
326
+ this.style = createStyles(options.features.color);
327
+ this.region = new LiveRegion(options.stderr, this.symbols.ellipsis);
328
+ this.signals = options.signals ?? process;
329
+ this.exitProcess = options.exit ?? ((code) => process.exit(code));
330
+ this.frameMs = options.frameMs ?? 80;
331
+ this.signals.on('SIGINT', this.onSigint);
332
+ this.signals.on('SIGTERM', this.onSigterm);
333
+ this.signals.on('exit', this.onExit);
334
+ this.stderr.on?.('resize', this.onResize);
335
+ this.stderr.write(HIDE_CURSOR);
336
+ this.attachInput();
337
+ }
338
+ /** True once the user cancelled (Ctrl-C or SIGTERM); nothing is drawn after that. */
339
+ get isInterrupted() {
340
+ return this.interrupted;
341
+ }
342
+ get columns() {
343
+ return this.region.columns;
344
+ }
345
+ // --------------------------------------------------------------------------
346
+ // Output
347
+ // --------------------------------------------------------------------------
348
+ /** Permanent lines above the live frame. */
349
+ print(...lines) {
350
+ if (this.closed)
351
+ return;
352
+ if (this.suspended)
353
+ this.stderr.write(lines.map((line) => `${line}\n`).join(''));
354
+ else
355
+ this.region.commit(lines);
356
+ }
357
+ /** `│` */
358
+ rail() {
359
+ this.print(this.style.dim(this.symbols.rail));
360
+ }
361
+ /** `◇ title detail` */
362
+ step(title, detail) {
363
+ this.print(`${this.style.green(this.symbols.done)} ${title}${detail ? ` ${detail}` : ''}`);
364
+ }
365
+ /** `◇ title › value` (an answered question). */
366
+ answered(title, value) {
367
+ this.print(`${this.style.green(this.symbols.done)} ${title} ${this.style.dim(this.symbols.pointer)} ${value}`);
368
+ }
369
+ /** `│ text`, wrapped at word boundaries (`text` is plain). */
370
+ info(text) {
371
+ this.print(...this.wrap(text, 3).map((part) => `${this.style.dim(this.symbols.rail)} ${part}`));
372
+ }
373
+ /** `▲ text` */
374
+ warn(text) {
375
+ this.print(`${this.style.yellow(this.symbols.warning)} ${text}`);
376
+ }
377
+ /** `■ text`, then dim detail lines under it. */
378
+ error(text, ...details) {
379
+ this.print(`${this.style.red(this.symbols.error)} ${text}`, ...details.flatMap((line) => this.wrap(line, 3).map((part) => ` ${this.style.dim(part)}`)));
380
+ }
381
+ /** `│ text` (dim): a note inside the current step, wrapped at word boundaries. */
382
+ note(text) {
383
+ this.print(...this.wrap(text, 3).map((part) => `${this.style.dim(this.symbols.rail)} ${this.style.dim(part)}`));
384
+ }
385
+ /**
386
+ * A command the user may copy, indented and on a line of its own: never
387
+ * wrapped, cut or prefixed (a long one is soft-wrapped by the terminal and
388
+ * still copies as one line).
389
+ */
390
+ command(text) {
391
+ this.print(` ${text}`);
392
+ }
393
+ /** Dim lines under the end of the timeline (after `└`), indented and wrapped. */
394
+ footnote(text) {
395
+ this.print(...this.wrap(text, 3).map((part) => ` ${this.style.dim(part)}`));
396
+ }
397
+ /** Plain text in lines that fit after an `indent`-column prefix (a longer word, such as a path, stays whole). */
398
+ wrap(text, indent) {
399
+ return wrapWords(text, Math.max(20, this.columns - 1 - indent));
400
+ }
401
+ /** The current spinner frame. */
402
+ get spin() {
403
+ return this.style.cyan(this.symbols.spinner[this.tick % this.symbols.spinner.length]);
404
+ }
405
+ /** `███░░░` for a ratio between 0 and 1. */
406
+ bar(ratio, width) {
407
+ const filled = Math.round(Math.max(0, Math.min(1, ratio)) * width);
408
+ return this.style.cyan(this.symbols.barFull.repeat(filled)) + this.style.dim(this.symbols.barEmpty.repeat(width - filled));
409
+ }
410
+ /** The items of a summary line: `a · b · c`, or `a - b - c` in ASCII. */
411
+ join(parts) {
412
+ return parts.join(` ${this.symbols.separator} `);
413
+ }
414
+ /**
415
+ * Text written elsewhere with the Unicode symbols (an error message with a
416
+ * masked ID, a warning) in this session's: on an ASCII-only console, `…`
417
+ * becomes `...` and ` · ` becomes ` - `.
418
+ */
419
+ symbolText(text) {
420
+ if (this.features.unicode)
421
+ return text;
422
+ return text.replace(/…/g, this.symbols.ellipsis).replace(/ · /g, ` ${this.symbols.separator} `);
423
+ }
424
+ /** An OSC 8 link where the terminal supports it, else the text. The URL must be printable ASCII. */
425
+ link(url, text = url) {
426
+ return this.features.hyperlinks ? `\u001b]8;;${url}\u001b\\${text}\u001b]8;;\u001b\\` : text;
427
+ }
428
+ /** The live frame, replacing the previous one; animated frames are redrawn every frame (spinners). */
429
+ show(render, options = {}) {
430
+ this.frame = render;
431
+ this.animated = Boolean(options.animated);
432
+ this.syncTimer();
433
+ this.draw();
434
+ }
435
+ clearFrame() {
436
+ this.frame = null;
437
+ this.animated = false;
438
+ this.syncTimer();
439
+ if (!this.closed && !this.suspended)
440
+ this.region.clear();
441
+ }
442
+ /** Redraw the live frame now (after its state changed). */
443
+ draw() {
444
+ if (this.closed || this.suspended)
445
+ return;
446
+ if (this.frame)
447
+ this.region.draw(this.frame());
448
+ else
449
+ this.region.clear();
450
+ }
451
+ /** `◒ label` until stopped. */
452
+ spinner(label) {
453
+ let text = label;
454
+ let stopped = false;
455
+ const render = () => [`${this.spin} ${text}`];
456
+ this.show(render, { animated: true });
457
+ return {
458
+ update: (next) => {
459
+ text = next;
460
+ if (!stopped && this.frame === render)
461
+ this.draw();
462
+ },
463
+ stop: () => {
464
+ if (stopped)
465
+ return;
466
+ stopped = true;
467
+ if (this.frame === render)
468
+ this.clearFrame();
469
+ },
470
+ };
471
+ }
472
+ /** `work` with a spinner. */
473
+ async busy(label, work) {
474
+ const spinner = this.spinner(label);
475
+ try {
476
+ return await work();
477
+ }
478
+ finally {
479
+ spinner.stop();
480
+ }
481
+ }
482
+ syncTimer() {
483
+ const wanted = this.animated && !this.suspended && !this.closed;
484
+ if (wanted && !this.timer) {
485
+ this.timer = setInterval(() => {
486
+ this.tick++;
487
+ this.draw();
488
+ }, this.frameMs);
489
+ this.timer.unref?.();
490
+ }
491
+ else if (!wanted && this.timer) {
492
+ clearInterval(this.timer);
493
+ this.timer = null;
494
+ }
495
+ }
496
+ // --------------------------------------------------------------------------
497
+ // Inputs
498
+ // --------------------------------------------------------------------------
499
+ /** Run a prompt until a key ends it; its frame is cleared afterwards. Ctrl-C never ends it (it cancels the run). */
500
+ prompt(view) {
501
+ if (this.closed)
502
+ return new Promise(() => undefined);
503
+ return new Promise((resolve) => {
504
+ this.keyHandler = (key) => {
505
+ const outcome = view.key(key);
506
+ if (!outcome) {
507
+ this.draw();
508
+ return;
509
+ }
510
+ this.keyHandler = null;
511
+ this.clearFrame();
512
+ resolve(outcome.value);
513
+ };
514
+ this.show(() => view.render());
515
+ });
516
+ }
517
+ /** The fake cursor of inputs (the real one stays hidden). */
518
+ get cursor() {
519
+ return this.features.color ? this.style.inverse(' ') : '_';
520
+ }
521
+ head(title, value) {
522
+ return `${this.style.cyan(this.symbols.active)} ${title} ${this.style.dim(this.symbols.pointer)} ${value}`;
523
+ }
524
+ /** Columns an input's first line has for its title and value together (the frame's width less `◆ ` and ` › `). */
525
+ get headRoom() {
526
+ return this.columns - 1 - displayWidth(this.head('', ''));
527
+ }
528
+ /** A plain title cut (start and end kept) to at most `width` columns, so the value after it stays on screen. */
529
+ fitTitle(title, width) {
530
+ return truncateMiddle(title, Math.max(1, width), this.symbols.ellipsis);
531
+ }
532
+ noticeLines(notice) {
533
+ return notice ? [`${this.style.yellow(this.symbols.rail)} ${this.style.yellow(notice)}`] : [];
534
+ }
535
+ /**
536
+ * A one-line text input: printable characters, Backspace, Ctrl-U (clear) and
537
+ * Ctrl-W (last word). Null on Ctrl-D with no text (the input ended). On a
538
+ * narrow terminal the (plain) title is cut before the value, down to a third
539
+ * of the line; a value wider than the rest shows its end.
540
+ */
541
+ text(options) {
542
+ const max = options.maxLength ?? 256;
543
+ let value = '';
544
+ return this.prompt({
545
+ render: () => {
546
+ const available = this.headRoom;
547
+ const wanted = displayWidth(value) + 2;
548
+ const titleWidth = Math.min(displayWidth(options.title), Math.max(available - wanted, Math.floor(available / 3)));
549
+ const title = this.fitTitle(options.title, titleWidth);
550
+ const room = Math.max(8, available - displayWidth(title) - 2);
551
+ const shown = displayWidth(value) > room ? `${this.symbols.ellipsis}${tail(value, room - displayWidth(this.symbols.ellipsis))}` : value;
552
+ return [this.head(title, `${shown}${this.cursor}`), ...this.noticeLines(options.notice)];
553
+ },
554
+ key: (key) => {
555
+ switch (key.name) {
556
+ case 'enter': return { value };
557
+ case 'ctrl-d': return value === '' ? { value: null } : undefined;
558
+ case 'backspace':
559
+ value = [...value].slice(0, -1).join('');
560
+ return undefined;
561
+ case 'ctrl-u':
562
+ value = '';
563
+ return undefined;
564
+ case 'ctrl-w':
565
+ value = value.replace(/\S*\s*$/u, '');
566
+ return undefined;
567
+ case 'char':
568
+ if (charWidth(key.char) > 0 && [...value].length < max)
569
+ value += key.char;
570
+ return undefined;
571
+ default: return undefined;
572
+ }
573
+ },
574
+ });
575
+ }
576
+ /**
577
+ * A numeric code input: digits are shown as typed (spaces and dashes, typed
578
+ * or pasted, are skipped) and the input ends at the last digit. Enter with
579
+ * fewer digits keeps it open. Null on Ctrl-D (the input ended). On a narrow
580
+ * terminal the (plain) title is cut, never the digits.
581
+ */
582
+ code(options) {
583
+ let digits = '';
584
+ let short = false;
585
+ return this.prompt({
586
+ render: () => {
587
+ const rest = this.style.dim(this.symbols.placeholder.repeat(Math.max(0, options.length - digits.length - 1)));
588
+ const value = `${digits}${digits.length < options.length ? this.cursor : ''}${rest}`;
589
+ return [
590
+ this.head(this.fitTitle(options.title, this.headRoom - displayWidth(value)), value),
591
+ ...(options.hint ? [`${this.style.dim(this.symbols.rail)} ${this.style.dim(options.hint)}`] : []),
592
+ ...this.noticeLines(short ? `Enter all ${options.length} digits.` : options.notice),
593
+ ];
594
+ },
595
+ key: (key) => {
596
+ if (key.name === 'ctrl-d')
597
+ return { value: null };
598
+ if (key.name === 'backspace')
599
+ digits = digits.slice(0, -1);
600
+ else if (key.name === 'ctrl-u')
601
+ digits = '';
602
+ else if (key.name === 'enter')
603
+ short = digits.length < options.length;
604
+ else if (key.name === 'char' && /^\d$/.test(key.char)) {
605
+ digits += key.char;
606
+ short = false;
607
+ }
608
+ return digits.length === options.length ? { value: digits } : undefined;
609
+ },
610
+ });
611
+ }
612
+ /**
613
+ * An arrow-key list (↑/↓ or j/k, Home/End, Enter). Null on Ctrl-D. Labels
614
+ * are plain text, padded to one column; on a narrow terminal each is cut in
615
+ * the middle so its detail (a masked ID) always stays on screen.
616
+ */
617
+ select(options) {
618
+ const { items } = options;
619
+ let index = Math.max(0, Math.min(items.length - 1, options.initial ?? 0));
620
+ let top = 0;
621
+ const widest = Math.max(0, ...items.map((item) => displayWidth(item.label)));
622
+ const detailWidth = Math.max(0, ...items.map((item) => (item.detail ? displayWidth(item.detail) + 2 : 0)));
623
+ return this.prompt({
624
+ render: () => {
625
+ const visible = Math.max(3, Math.min(items.length, this.region.rows - 5, 10));
626
+ if (index < top)
627
+ top = index;
628
+ if (index >= top + visible)
629
+ top = index - visible + 1;
630
+ const rail = this.style.dim(this.symbols.rail);
631
+ const prefix = displayWidth(`${this.symbols.rail} ${this.symbols.radioOn} `);
632
+ const labelWidth = Math.min(widest, Math.max(4, this.columns - 1 - prefix - detailWidth));
633
+ const rows = items.slice(top, top + visible).map((item, offset) => {
634
+ const active = top + offset === index;
635
+ const mark = active ? this.style.green(this.symbols.radioOn) : this.style.dim(this.symbols.radioOff);
636
+ const fitted = padEnd(truncateMiddle(item.label, labelWidth, this.symbols.ellipsis), labelWidth);
637
+ const label = active ? this.style.bold(fitted) : fitted;
638
+ return `${rail} ${mark} ${label}${item.detail ? ` ${this.style.dim(item.detail)}` : ''}`;
639
+ });
640
+ const above = top > 0 ? [`${rail} ${this.style.dim(`${this.symbols.up} ${top} more`)}`] : [];
641
+ const below = top + visible < items.length ? [`${rail} ${this.style.dim(`${this.symbols.down} ${items.length - top - visible} more`)}`] : [];
642
+ return [`${this.style.cyan(this.symbols.active)} ${options.title}${options.hint ? ` ${this.style.dim(options.hint)}` : ''}`, ...above, ...rows, ...below];
643
+ },
644
+ key: (key) => {
645
+ if (key.name === 'enter')
646
+ return { value: items[index].value };
647
+ if (key.name === 'ctrl-d')
648
+ return { value: null };
649
+ if (key.name === 'up' || (key.name === 'char' && key.char === 'k'))
650
+ index = (index - 1 + items.length) % items.length;
651
+ else if (key.name === 'down' || key.name === 'tab' || (key.name === 'char' && key.char === 'j'))
652
+ index = (index + 1) % items.length;
653
+ else if (key.name === 'home')
654
+ index = 0;
655
+ else if (key.name === 'end')
656
+ index = items.length - 1;
657
+ return undefined;
658
+ },
659
+ });
660
+ }
661
+ /** Yes / No: ←/→ (or Tab, h/l) move, y or n answers at once, Enter answers the highlighted choice. Null on Ctrl-D. */
662
+ confirm(options) {
663
+ let yes = options.initial;
664
+ const choice = (label, on) => {
665
+ if (!this.features.color)
666
+ return on ? `[${label}]` : label;
667
+ return on ? this.style.underline(this.style.cyan(label)) : this.style.dim(label);
668
+ };
669
+ return this.prompt({
670
+ render: () => [
671
+ `${this.style.cyan(this.symbols.active)} ${options.title} ${this.style.dim(this.symbols.pointer)} ${choice('Yes', yes)} ${this.style.dim('/')} ${choice('No', !yes)}`,
672
+ ...(options.detail ? [`${this.style.dim(this.symbols.rail)} ${this.style.dim(options.detail)}`] : []),
673
+ ],
674
+ key: (key) => {
675
+ if (key.name === 'enter')
676
+ return { value: yes };
677
+ if (key.name === 'ctrl-d')
678
+ return { value: null };
679
+ if (key.name === 'char' && /^[yY]$/.test(key.char))
680
+ return { value: true };
681
+ if (key.name === 'char' && /^[nN]$/.test(key.char))
682
+ return { value: false };
683
+ if (['left', 'right', 'tab'].includes(key.name) || (key.name === 'char' && /^[hl]$/.test(key.char)))
684
+ yes = !yes;
685
+ return undefined;
686
+ },
687
+ });
688
+ }
689
+ // --------------------------------------------------------------------------
690
+ // The terminal
691
+ // --------------------------------------------------------------------------
692
+ onData = (chunk) => {
693
+ const text = typeof chunk === 'string' ? chunk : this.decoder.write(chunk);
694
+ for (const key of parseKeys(text)) {
695
+ if (key.name === 'ctrl-c') {
696
+ this.interrupt();
697
+ return;
698
+ }
699
+ // Keys typed while nothing asks are dropped (and were never echoed).
700
+ this.keyHandler?.(key);
701
+ }
702
+ };
703
+ /** Ctrl-C in raw mode sends no signal: raise SIGINT for every listener, this session's included. */
704
+ interrupt() {
705
+ this.signals.emit('SIGINT', 'SIGINT');
706
+ }
707
+ setRaw(on) {
708
+ if (this.raw === on)
709
+ return;
710
+ this.raw = on;
711
+ try {
712
+ this.stdin.setRawMode?.(on);
713
+ }
714
+ catch {
715
+ // The terminal is already gone.
716
+ }
717
+ }
718
+ attachInput() {
719
+ if (this.reading)
720
+ return;
721
+ this.reading = true;
722
+ this.setRaw(true);
723
+ this.stdin.on('data', this.onData);
724
+ this.stdin.resume();
725
+ }
726
+ detachInput() {
727
+ if (!this.reading)
728
+ return;
729
+ this.reading = false;
730
+ this.stdin.removeListener('data', this.onData);
731
+ this.stdin.pause();
732
+ this.setRaw(false);
733
+ }
734
+ /**
735
+ * Hand the terminal to another program (cf's interactive sign-in): the
736
+ * frame is cleared, raw mode is off, input is released and the cursor shown.
737
+ */
738
+ suspend() {
739
+ if (this.closed || this.suspended)
740
+ return;
741
+ this.region.clear();
742
+ this.suspended = true;
743
+ this.syncTimer();
744
+ this.detachInput();
745
+ this.stderr.write(SHOW_CURSOR);
746
+ }
747
+ resume() {
748
+ if (this.closed || !this.suspended)
749
+ return;
750
+ this.suspended = false;
751
+ this.stderr.write(HIDE_CURSOR);
752
+ this.attachInput();
753
+ this.syncTimer();
754
+ this.draw();
755
+ }
756
+ /** `work` with the terminal handed over (see `suspend`). */
757
+ async handOver(work) {
758
+ this.suspend();
759
+ try {
760
+ return await work();
761
+ }
762
+ finally {
763
+ this.resume();
764
+ }
765
+ }
766
+ onResize = () => {
767
+ this.draw();
768
+ };
769
+ /** Raw mode off, the frame cleared, the cursor shown; nothing more is drawn. */
770
+ restore() {
771
+ if (this.closed)
772
+ return;
773
+ this.closed = true;
774
+ if (this.timer)
775
+ clearInterval(this.timer);
776
+ this.timer = null;
777
+ this.keyHandler = null;
778
+ if (!this.suspended)
779
+ this.region.clear();
780
+ this.detachInput();
781
+ this.stderr.write(SHOW_CURSOR);
782
+ this.stderr.removeListener?.('resize', this.onResize);
783
+ }
784
+ detachSignals() {
785
+ this.signals.removeListener('SIGINT', this.onSigint);
786
+ this.signals.removeListener('SIGTERM', this.onSigterm);
787
+ this.signals.removeListener('exit', this.onExit);
788
+ }
789
+ /** Print the cancel message; exit with `code` unless another listener for `signal` will (it exits 130 after cleaning up). */
790
+ cancel(signal, code) {
791
+ if (this.interrupted || this.closed)
792
+ return;
793
+ this.interrupted = true;
794
+ this.restore();
795
+ this.stderr.write(`${this.style.red(this.symbols.error)} ${this.cancelMessage}\n`);
796
+ this.detachSignals();
797
+ if (this.signals.listenerCount(signal) === 0)
798
+ this.exitProcess(code);
799
+ }
800
+ onSigint = () => this.cancel('SIGINT', 130);
801
+ onSigterm = () => this.cancel('SIGTERM', 143);
802
+ onExit = () => this.restore();
803
+ /** End the session: the terminal is restored and the listeners removed. Safe to call twice. */
804
+ close() {
805
+ this.restore();
806
+ this.detachSignals();
807
+ }
808
+ }
809
+ const renderedErrors = new WeakSet();
810
+ /** The terminal UI has shown this error (■ and its hint); `runCli` does not print it again. */
811
+ export function markRendered(error) {
812
+ renderedErrors.add(error);
813
+ }
814
+ export function wasRendered(error) {
815
+ return typeof error === 'object' && error !== null && renderedErrors.has(error);
816
+ }
817
+ /**
818
+ * Words in lines of at most `width` columns; a longer word gets a line of its
819
+ * own. Leading spaces (an indented line) are kept on every line.
820
+ */
821
+ export function wrapWords(text, width) {
822
+ const indent = /^ */.exec(text)[0];
823
+ const lines = [];
824
+ let line = '';
825
+ for (const word of text.slice(indent.length).split(' ')) {
826
+ const next = line === '' ? word : `${line} ${word}`;
827
+ if (line !== '' && displayWidth(indent + next) > width) {
828
+ lines.push(indent + line);
829
+ line = word;
830
+ }
831
+ else {
832
+ line = next;
833
+ }
834
+ }
835
+ lines.push(indent + line);
836
+ return lines;
837
+ }
838
+ /** The last `width` columns of `text`. */
839
+ function tail(text, width) {
840
+ const chars = [...text];
841
+ let used = 0;
842
+ let start = chars.length;
843
+ while (start > 0 && used + charWidth(chars[start - 1]) <= width)
844
+ used += charWidth(chars[--start]);
845
+ return chars.slice(start).join('');
846
+ }
847
+ //# sourceMappingURL=tui.js.map