@yadurajfleetos/cli 0.1.8 → 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.
- package/dist/api.js +6 -2
- package/dist/archive.js +115 -0
- package/dist/commands/index.js +2 -0
- package/dist/commands/secrets.js +117 -0
- package/dist/commands/services.js +49 -11
- package/dist/commands/up.js +108 -50
- package/dist/index.js +4 -1
- package/dist/ladder.js +290 -0
- package/dist/mark.js +30 -10
- package/dist/plan.js +69 -0
- package/dist/progress.js +188 -0
- package/dist/prompt.js +205 -0
- package/dist/render.js +57 -0
- package/dist/ui.js +115 -30
- package/package.json +2 -2
package/dist/prompt.js
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interactive prompts.
|
|
3
|
+
*
|
|
4
|
+
* One implementation each, because four hand-rolled versions of "are you sure"
|
|
5
|
+
* drifted into four different answers to the questions that actually matter: what
|
|
6
|
+
* happens when stdin is not a terminal, whether silence counts as consent, and
|
|
7
|
+
* whether a typed password can end up in the scrollback.
|
|
8
|
+
*
|
|
9
|
+
* Prompts write to stderr, like the rest of the progress UI, so a command that
|
|
10
|
+
* both asks a question and emits `--json` stays pipeable into jq.
|
|
11
|
+
*/
|
|
12
|
+
import { createInterface } from 'node:readline/promises';
|
|
13
|
+
import { c, cursor, glyphs, truncate, unicode } from './render.js';
|
|
14
|
+
import { CliError, EXIT } from './api.js';
|
|
15
|
+
import { activeLadder } from './ladder.js';
|
|
16
|
+
import { glyph, width } from './ui.js';
|
|
17
|
+
const err = process.stderr;
|
|
18
|
+
/** Asking a question needs somewhere to read the answer from and somewhere to show it. */
|
|
19
|
+
export const canPrompt = () => Boolean(process.stdin.isTTY && err.isTTY);
|
|
20
|
+
/**
|
|
21
|
+
* A ladder owns the cursor while it is live, so it has to stand down for the
|
|
22
|
+
* duration of a prompt rather than redraw over the question being asked.
|
|
23
|
+
*/
|
|
24
|
+
async function withTerminal(run) {
|
|
25
|
+
const live = activeLadder();
|
|
26
|
+
live?.suspend();
|
|
27
|
+
try {
|
|
28
|
+
return await run();
|
|
29
|
+
}
|
|
30
|
+
finally {
|
|
31
|
+
live?.resume();
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* A yes/no question.
|
|
36
|
+
*
|
|
37
|
+
* The two policies are deliberately separate knobs, because for the destructive
|
|
38
|
+
* commands they genuinely differ: `fleet deploy` and `fleet down` want Enter to
|
|
39
|
+
* mean *no* at a keyboard — an accidental return should not roll out a build —
|
|
40
|
+
* while a scripted CI step that pipes no stdin should still proceed without
|
|
41
|
+
* needing `--yes`. `ifNoTerminal` defaults to `default` when a caller has no such
|
|
42
|
+
* split, and `fleet rm` sets neither: it refuses both ways.
|
|
43
|
+
*/
|
|
44
|
+
export async function confirm(question, opts = {}) {
|
|
45
|
+
const fallback = opts.default ?? false;
|
|
46
|
+
if (!canPrompt())
|
|
47
|
+
return opts.ifNoTerminal ?? fallback;
|
|
48
|
+
return withTerminal(async () => {
|
|
49
|
+
const rl = createInterface({ input: process.stdin, output: err });
|
|
50
|
+
try {
|
|
51
|
+
const answer = (await rl.question(` ${question} ${c.dim(fallback ? '[Y/n]' : '[y/N]')} `))
|
|
52
|
+
.trim()
|
|
53
|
+
.toLowerCase();
|
|
54
|
+
if (!answer)
|
|
55
|
+
return fallback;
|
|
56
|
+
return answer === 'y' || answer === 'yes';
|
|
57
|
+
}
|
|
58
|
+
finally {
|
|
59
|
+
rl.close();
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
/** A free-text answer. Empty input is rejected rather than silently accepted. */
|
|
64
|
+
export async function ask(label, opts = {}) {
|
|
65
|
+
if (!canPrompt())
|
|
66
|
+
throw new CliError(`${label.trim()} is required, and there is no terminal to ask on.`, EXIT.usage);
|
|
67
|
+
return withTerminal(async () => {
|
|
68
|
+
if (opts.hint)
|
|
69
|
+
err.write(`${c.dim(` ${opts.hint}`)}\n`);
|
|
70
|
+
const rl = createInterface({ input: process.stdin, output: err });
|
|
71
|
+
try {
|
|
72
|
+
const value = (await rl.question(` ${c.dim(label.padEnd(18))}`)).trim();
|
|
73
|
+
if (!value)
|
|
74
|
+
throw new CliError(`${label.trim()} is required.`, EXIT.usage);
|
|
75
|
+
return value;
|
|
76
|
+
}
|
|
77
|
+
finally {
|
|
78
|
+
rl.close();
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* The same, with the echo suppressed. A password or token must not survive in the
|
|
84
|
+
* terminal scrollback or in a screen recording, which rules out letting readline
|
|
85
|
+
* echo it and clearing the line afterwards.
|
|
86
|
+
*/
|
|
87
|
+
export async function askSecret(label, opts = {}) {
|
|
88
|
+
if (!canPrompt())
|
|
89
|
+
throw new CliError(`${label.trim()} is required, and there is no terminal to ask on.`, EXIT.usage);
|
|
90
|
+
return withTerminal(async () => {
|
|
91
|
+
if (opts.hint)
|
|
92
|
+
err.write(`${c.dim(` ${opts.hint}`)}\n`);
|
|
93
|
+
const rl = createInterface({ input: process.stdin, output: err });
|
|
94
|
+
try {
|
|
95
|
+
err.write(` ${c.dim(label.padEnd(18))}`);
|
|
96
|
+
// The prompt is written directly, then readline's own echo is disabled, so
|
|
97
|
+
// nothing typed after this point reaches the terminal at all.
|
|
98
|
+
const internal = rl;
|
|
99
|
+
internal._writeToOutput = () => { };
|
|
100
|
+
const value = (await rl.question('')).trim();
|
|
101
|
+
err.write('\n');
|
|
102
|
+
if (!value)
|
|
103
|
+
throw new CliError(`${label.trim()} is required.`, EXIT.usage);
|
|
104
|
+
return value;
|
|
105
|
+
}
|
|
106
|
+
finally {
|
|
107
|
+
rl.close();
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
const KEY = {
|
|
112
|
+
up: ['\x1b[A', '\x1bOA', 'k'],
|
|
113
|
+
down: ['\x1b[B', '\x1bOB', 'j'],
|
|
114
|
+
enter: ['\r', '\n'],
|
|
115
|
+
cancel: ['\x03', 'q', '\x1b'],
|
|
116
|
+
};
|
|
117
|
+
/**
|
|
118
|
+
* An arrow-key picker. Callers must check `canPrompt()` first and raise their own
|
|
119
|
+
* error otherwise: the message a script sees when it forgets `--fleet` is part of
|
|
120
|
+
* that command's contract, and this function does not know what it should say.
|
|
121
|
+
*/
|
|
122
|
+
export async function select(title, choices) {
|
|
123
|
+
if (!choices.length)
|
|
124
|
+
throw new CliError('Nothing to choose from.', EXIT.usage);
|
|
125
|
+
if (!canPrompt())
|
|
126
|
+
throw new CliError('No interactive terminal to choose on.', EXIT.usage);
|
|
127
|
+
return withTerminal(() => new Promise((resolve) => {
|
|
128
|
+
let index = 0;
|
|
129
|
+
let painted = 0;
|
|
130
|
+
const pad = Math.max(...choices.map((choice) => choice.label.length));
|
|
131
|
+
const keys = unicode ? '↑↓ move · enter select · q cancel' : 'up/down move, enter select, q cancel';
|
|
132
|
+
const draw = () => {
|
|
133
|
+
const lines = [
|
|
134
|
+
` ${c.bold(title)}`,
|
|
135
|
+
...choices.map((choice, i) => {
|
|
136
|
+
const pointer = i === index ? c.signal(glyphs.pointer) : ' ';
|
|
137
|
+
const label = i === index ? c.signal(choice.label.padEnd(pad)) : choice.label.padEnd(pad);
|
|
138
|
+
return ` ${pointer} ${label}${choice.hint ? ` ${c.dim(choice.hint)}` : ''}`;
|
|
139
|
+
}),
|
|
140
|
+
c.dim(` ${keys}`),
|
|
141
|
+
].map((line) => truncate(line, width()));
|
|
142
|
+
err.write((painted ? cursor.up(painted) + '\r' + cursor.clearBelow() : '') +
|
|
143
|
+
lines.join('\n') +
|
|
144
|
+
'\n');
|
|
145
|
+
painted = lines.length;
|
|
146
|
+
};
|
|
147
|
+
const stdin = process.stdin;
|
|
148
|
+
const wasRaw = Boolean(stdin.isRaw);
|
|
149
|
+
const teardown = () => {
|
|
150
|
+
stdin.off('data', onData);
|
|
151
|
+
stdin.setRawMode?.(wasRaw);
|
|
152
|
+
stdin.pause();
|
|
153
|
+
// `ESC[0A` is read as up-one by most terminals, so an unpainted list
|
|
154
|
+
// must not try to move at all.
|
|
155
|
+
if (painted)
|
|
156
|
+
err.write(cursor.up(painted) + '\r' + cursor.clearBelow());
|
|
157
|
+
err.write(cursor.show());
|
|
158
|
+
painted = 0;
|
|
159
|
+
};
|
|
160
|
+
function onData(chunk) {
|
|
161
|
+
// A chunk carries a whole escape sequence, or several keys at once.
|
|
162
|
+
const key = chunk.toString();
|
|
163
|
+
if (KEY.cancel.includes(key)) {
|
|
164
|
+
teardown();
|
|
165
|
+
err.write(`${glyph.pending} ${c.dim('cancelled')}\n`);
|
|
166
|
+
// In raw mode ^C arrives as a byte, not a signal, so the exit code
|
|
167
|
+
// has to be produced here or the shell sees a clean exit.
|
|
168
|
+
process.exit(key === '\x03' ? 130 : EXIT.ok);
|
|
169
|
+
}
|
|
170
|
+
if (KEY.enter.includes(key)) {
|
|
171
|
+
const chosen = choices[index];
|
|
172
|
+
teardown();
|
|
173
|
+
err.write(`${glyph.ok} ${c.bold(title)} ${chosen.label}\n`);
|
|
174
|
+
resolve(chosen.value);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
if (KEY.up.includes(key))
|
|
178
|
+
index = (index - 1 + choices.length) % choices.length;
|
|
179
|
+
else if (KEY.down.includes(key))
|
|
180
|
+
index = (index + 1) % choices.length;
|
|
181
|
+
else if (/^[1-9]$/.test(key) && Number(key) <= choices.length)
|
|
182
|
+
index = Number(key) - 1;
|
|
183
|
+
else
|
|
184
|
+
return;
|
|
185
|
+
draw();
|
|
186
|
+
}
|
|
187
|
+
err.write(cursor.hide());
|
|
188
|
+
draw();
|
|
189
|
+
stdin.setEncoding('utf8');
|
|
190
|
+
stdin.setRawMode?.(true);
|
|
191
|
+
stdin.on('data', onData);
|
|
192
|
+
stdin.resume();
|
|
193
|
+
}));
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Offer a picker when a name did not match, and otherwise raise the error the
|
|
197
|
+
* command would have raised anyway. Used by the three lookups that previously
|
|
198
|
+
* only listed the valid names and left the operator to retype one.
|
|
199
|
+
*/
|
|
200
|
+
export async function selectOrThrow(title, choices, error) {
|
|
201
|
+
if (!canPrompt() || !choices.length)
|
|
202
|
+
throw error;
|
|
203
|
+
err.write(`${glyph.warn} ${error.message.split('\n')[0]}\n`);
|
|
204
|
+
return select(title, choices);
|
|
205
|
+
}
|
package/dist/render.js
CHANGED
|
@@ -10,6 +10,59 @@ export const colourDepth = !useColour
|
|
|
10
10
|
: /truecolor|24bit/i.test(process.env.COLORTERM ?? '')
|
|
11
11
|
? 2
|
|
12
12
|
: 1;
|
|
13
|
+
/**
|
|
14
|
+
* Whether the terminal can be trusted with braille, box-drawing and block
|
|
15
|
+
* glyphs. This is a separate question from colour: a terminal that renders
|
|
16
|
+
* `⠋` as a replacement box makes the CLI look broken, whereas plain ASCII only
|
|
17
|
+
* looks plain. So the guess errs toward ASCII and takes overrides in both
|
|
18
|
+
* directions — `FLEET_ASCII=1` forces the fallback, `FLEET_UNICODE=1` forces the
|
|
19
|
+
* glyphs on for the many Linux shells that simply never set a locale.
|
|
20
|
+
*/
|
|
21
|
+
export const unicode = process.env.FLEET_ASCII
|
|
22
|
+
? false
|
|
23
|
+
: process.env.FLEET_UNICODE
|
|
24
|
+
? true
|
|
25
|
+
: process.env.TERM === 'dumb'
|
|
26
|
+
? false
|
|
27
|
+
: /utf-?8/i.test(`${process.env.LC_ALL ?? ''} ${process.env.LC_CTYPE ?? ''} ${process.env.LANG ?? ''}`) ||
|
|
28
|
+
Boolean(process.env.WT_SESSION) ||
|
|
29
|
+
Boolean(process.env.TERM_PROGRAM);
|
|
30
|
+
/**
|
|
31
|
+
* One vocabulary, chosen once, so no caller ever branches on `unicode`. Every
|
|
32
|
+
* entry is a single terminal cell wide in both sets — the progress UI redraws in
|
|
33
|
+
* place and a two-cell glyph would shift everything after it.
|
|
34
|
+
*/
|
|
35
|
+
export const glyphs = unicode
|
|
36
|
+
? {
|
|
37
|
+
frames: ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'],
|
|
38
|
+
ok: '✔',
|
|
39
|
+
fail: '✖',
|
|
40
|
+
warn: '▲',
|
|
41
|
+
info: '›',
|
|
42
|
+
pending: '·',
|
|
43
|
+
stepActive: '◆',
|
|
44
|
+
stepTodo: '○',
|
|
45
|
+
pointer: '❯',
|
|
46
|
+
barFill: '█',
|
|
47
|
+
barEmpty: '░',
|
|
48
|
+
branch: '└',
|
|
49
|
+
rule: '─',
|
|
50
|
+
}
|
|
51
|
+
: {
|
|
52
|
+
frames: ['-', '\\', '|', '/'],
|
|
53
|
+
ok: 'v',
|
|
54
|
+
fail: 'x',
|
|
55
|
+
warn: '!',
|
|
56
|
+
info: '>',
|
|
57
|
+
pending: '.',
|
|
58
|
+
stepActive: '>',
|
|
59
|
+
stepTodo: '.',
|
|
60
|
+
pointer: '>',
|
|
61
|
+
barFill: '#',
|
|
62
|
+
barEmpty: '.',
|
|
63
|
+
branch: '\\',
|
|
64
|
+
rule: '-',
|
|
65
|
+
};
|
|
13
66
|
const ESC = '\x1b[';
|
|
14
67
|
const wrap = (code) => (s) => (useColour ? `${ESC}${code}m${s}${ESC}0m` : s);
|
|
15
68
|
/** Truecolour when the terminal has it, otherwise the supplied fallback. */
|
|
@@ -32,6 +85,10 @@ export const cursor = {
|
|
|
32
85
|
hide: () => `${ESC}?25l`,
|
|
33
86
|
show: () => `${ESC}?25h`,
|
|
34
87
|
up: (n) => `${ESC}${n}A`,
|
|
88
|
+
// Moving down with a control sequence rather than a newline matters at the
|
|
89
|
+
// bottom of the screen: `\n` there scrolls the region out from under the
|
|
90
|
+
// cursor arithmetic, `ESC[nB` cannot.
|
|
91
|
+
down: (n) => `${ESC}${n}B`,
|
|
35
92
|
clearLine: () => `\r${ESC}2K`,
|
|
36
93
|
clearBelow: () => `${ESC}0J`,
|
|
37
94
|
};
|
package/dist/ui.js
CHANGED
|
@@ -6,29 +6,86 @@
|
|
|
6
6
|
* fallback, so output captured by CI or a log file reads as a transcript rather
|
|
7
7
|
* than as a smear of cursor escapes.
|
|
8
8
|
*/
|
|
9
|
-
import { c, cursor, truncate, visibleLength } from './render.js';
|
|
9
|
+
import { c, cursor, glyphs, truncate, visibleLength } from './render.js';
|
|
10
10
|
import { MARK_HEIGHT, markFrame, PEER_COUNT } from './mark.js';
|
|
11
11
|
const err = process.stderr;
|
|
12
12
|
/** `columns` reads 0 on some pseudo-terminals, so `??` is not enough. */
|
|
13
|
-
const width = () => Math.max(1, (err.columns || process.stdout.columns || 80) - 1);
|
|
14
|
-
/**
|
|
15
|
-
|
|
16
|
-
|
|
13
|
+
export const width = () => Math.max(1, (err.columns || process.stdout.columns || 80) - 1);
|
|
14
|
+
/**
|
|
15
|
+
* `--quiet` drops progress entirely: no frames, no settled lines. Errors still
|
|
16
|
+
* print, because a command that failed silently is worse than a noisy one.
|
|
17
|
+
*/
|
|
18
|
+
let quiet = false;
|
|
19
|
+
export const setQuiet = (value) => {
|
|
20
|
+
quiet = value;
|
|
21
|
+
};
|
|
22
|
+
export const isQuiet = () => quiet;
|
|
23
|
+
/**
|
|
24
|
+
* Animate only where it can be erased again. `FLEET_ANIMATION` overrides the
|
|
25
|
+
* detection in both directions for recordings and for terminals the heuristics
|
|
26
|
+
* read wrongly; `FLEET_NO_ANIMATION` keeps working as it always has.
|
|
27
|
+
*/
|
|
28
|
+
export const animated = () => {
|
|
29
|
+
if (quiet)
|
|
30
|
+
return false;
|
|
31
|
+
if (process.env.FLEET_ANIMATION === '0')
|
|
32
|
+
return false;
|
|
33
|
+
if (process.env.FLEET_ANIMATION === '1')
|
|
34
|
+
return true;
|
|
35
|
+
return Boolean(err.isTTY) && !process.env.CI && !process.env.FLEET_NO_ANIMATION;
|
|
36
|
+
};
|
|
37
|
+
const FRAMES = glyphs.frames;
|
|
17
38
|
const TICK = 80;
|
|
39
|
+
/**
|
|
40
|
+
* Redrawing in place is slower to watch over a long link than locally, and a
|
|
41
|
+
* tall region costs proportionally more per frame. Neither is worth 12 frames a
|
|
42
|
+
* second.
|
|
43
|
+
*/
|
|
44
|
+
export const tickFor = (height) => process.env.SSH_CONNECTION || process.env.SSH_TTY || height > 10 ? 200 : TICK;
|
|
18
45
|
export const glyph = {
|
|
19
|
-
ok: c.signal(
|
|
20
|
-
fail: c.red(
|
|
21
|
-
warn: c.yellow(
|
|
22
|
-
info: c.cyan(
|
|
23
|
-
pending: c.dim(
|
|
46
|
+
ok: c.signal(glyphs.ok),
|
|
47
|
+
fail: c.red(glyphs.fail),
|
|
48
|
+
warn: c.yellow(glyphs.warn),
|
|
49
|
+
info: c.cyan(glyphs.info),
|
|
50
|
+
pending: c.dim(glyphs.pending),
|
|
24
51
|
};
|
|
25
|
-
/**
|
|
26
|
-
const
|
|
27
|
-
const seconds =
|
|
52
|
+
/** A duration, shown only once it is long enough to be worth knowing. */
|
|
53
|
+
export const duration = (ms) => {
|
|
54
|
+
const seconds = ms / 1000;
|
|
28
55
|
return seconds < 2 ? '' : c.dim(` ${seconds.toFixed(seconds < 10 ? 1 : 0)}s`);
|
|
29
56
|
};
|
|
57
|
+
/** Time since a start point. Ticks up while a step is in flight. */
|
|
58
|
+
export const elapsed = (startedAt) => duration(Date.now() - startedAt);
|
|
59
|
+
/**
|
|
60
|
+
* Exactly one thing may own an in-place redraw region at a time. Two writers
|
|
61
|
+
* moving the cursor relative to their own idea of where it is do not produce
|
|
62
|
+
* half-correct output, they produce shredded output — so the second writer stays
|
|
63
|
+
* quiet rather than fighting for the rows.
|
|
64
|
+
*/
|
|
65
|
+
let regionOwner = null;
|
|
66
|
+
export const claimRegion = (owner) => {
|
|
67
|
+
if (regionOwner)
|
|
68
|
+
return false;
|
|
69
|
+
regionOwner = owner;
|
|
70
|
+
return true;
|
|
71
|
+
};
|
|
72
|
+
export const releaseRegion = (owner) => {
|
|
73
|
+
if (regionOwner === owner)
|
|
74
|
+
regionOwner = null;
|
|
75
|
+
};
|
|
76
|
+
export const regionActive = () => regionOwner !== null;
|
|
77
|
+
/**
|
|
78
|
+
* Run while a ^C is being handled, before the process leaves. This is how the
|
|
79
|
+
* live region gets erased and how a command says what it left running — killing
|
|
80
|
+
* the CLI does not kill a build that is already underway on the control plane,
|
|
81
|
+
* and pretending otherwise is the misleading part.
|
|
82
|
+
*/
|
|
83
|
+
let onInterrupt = null;
|
|
84
|
+
export const setInterruptHandler = (fn) => {
|
|
85
|
+
onInterrupt = fn;
|
|
86
|
+
};
|
|
30
87
|
let restoreCursorHooked = false;
|
|
31
|
-
function hookCursorRestore() {
|
|
88
|
+
export function hookCursorRestore() {
|
|
32
89
|
if (restoreCursorHooked)
|
|
33
90
|
return;
|
|
34
91
|
restoreCursorHooked = true;
|
|
@@ -37,11 +94,33 @@ function hookCursorRestore() {
|
|
|
37
94
|
process.on('exit', restore);
|
|
38
95
|
for (const signal of ['SIGINT', 'SIGTERM']) {
|
|
39
96
|
process.on(signal, () => {
|
|
97
|
+
try {
|
|
98
|
+
onInterrupt?.();
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
// A failing teardown must not stop the cursor being restored.
|
|
102
|
+
}
|
|
40
103
|
restore();
|
|
41
104
|
process.exit(signal === 'SIGINT' ? 130 : 143);
|
|
42
105
|
});
|
|
43
106
|
}
|
|
44
107
|
}
|
|
108
|
+
/** A spinner that reports nothing: `--quiet`, or a region already owned. */
|
|
109
|
+
const silentSpinner = (label) => {
|
|
110
|
+
let text = label;
|
|
111
|
+
return {
|
|
112
|
+
update: (next) => {
|
|
113
|
+
text = next;
|
|
114
|
+
},
|
|
115
|
+
hints: () => { },
|
|
116
|
+
note: () => { },
|
|
117
|
+
succeed: () => { },
|
|
118
|
+
fail: () => { },
|
|
119
|
+
stop: () => {
|
|
120
|
+
void text;
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
};
|
|
45
124
|
export function spinner(label) {
|
|
46
125
|
const startedAt = Date.now();
|
|
47
126
|
let text = label;
|
|
@@ -49,6 +128,9 @@ export function spinner(label) {
|
|
|
49
128
|
let frame = 0;
|
|
50
129
|
let timer;
|
|
51
130
|
let done = false;
|
|
131
|
+
// A ladder already owns the cursor; a second writer would shred both.
|
|
132
|
+
if (quiet || regionActive())
|
|
133
|
+
return silentSpinner(label);
|
|
52
134
|
if (!animated()) {
|
|
53
135
|
err.write(`${label}…\n`);
|
|
54
136
|
return {
|
|
@@ -65,18 +147,21 @@ export function spinner(label) {
|
|
|
65
147
|
stop: () => { },
|
|
66
148
|
};
|
|
67
149
|
}
|
|
150
|
+
const owner = {};
|
|
151
|
+
claimRegion(owner);
|
|
68
152
|
hookCursorRestore();
|
|
69
153
|
err.write(cursor.hide());
|
|
70
|
-
const clear = () => err.write(cursor.clearLine());
|
|
71
154
|
const draw = () => {
|
|
72
155
|
// A hint every third of a spinner cycle: long enough to read, short enough
|
|
73
156
|
// that the line is visibly alive during a multi-minute build.
|
|
74
157
|
const hint = hintLines.length
|
|
75
158
|
? hintLines[Math.floor((Date.now() - startedAt) / 3200) % hintLines.length]
|
|
76
159
|
: undefined;
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
160
|
+
// One write per frame. Clearing and drawing separately doubles the syscalls
|
|
161
|
+
// and can be seen as a flicker on a slow link.
|
|
162
|
+
err.write(cursor.clearLine() +
|
|
163
|
+
truncate(`${c.signal(FRAMES[frame % FRAMES.length])} ${text}${elapsed(startedAt)}` +
|
|
164
|
+
(hint ? c.dim(` ${hint}`) : ''), width()));
|
|
80
165
|
frame++;
|
|
81
166
|
};
|
|
82
167
|
draw();
|
|
@@ -87,8 +172,8 @@ export function spinner(label) {
|
|
|
87
172
|
return;
|
|
88
173
|
done = true;
|
|
89
174
|
clearInterval(timer);
|
|
90
|
-
|
|
91
|
-
err.write(`${mark} ${final ?? text}${elapsed(startedAt)}\n
|
|
175
|
+
releaseRegion(owner);
|
|
176
|
+
err.write(cursor.clearLine() + `${mark} ${final ?? text}${elapsed(startedAt)}\n` + cursor.show());
|
|
92
177
|
};
|
|
93
178
|
return {
|
|
94
179
|
update: (next) => {
|
|
@@ -99,8 +184,7 @@ export function spinner(label) {
|
|
|
99
184
|
hintLines = lines;
|
|
100
185
|
},
|
|
101
186
|
note: (line) => {
|
|
102
|
-
|
|
103
|
-
err.write(`${line}\n`);
|
|
187
|
+
err.write(cursor.clearLine() + `${line}\n`);
|
|
104
188
|
draw();
|
|
105
189
|
},
|
|
106
190
|
succeed: (final) => settle(glyph.ok, final),
|
|
@@ -110,8 +194,8 @@ export function spinner(label) {
|
|
|
110
194
|
return;
|
|
111
195
|
done = true;
|
|
112
196
|
clearInterval(timer);
|
|
113
|
-
|
|
114
|
-
err.write(cursor.show());
|
|
197
|
+
releaseRegion(owner);
|
|
198
|
+
err.write(cursor.clearLine() + cursor.show());
|
|
115
199
|
},
|
|
116
200
|
};
|
|
117
201
|
}
|
|
@@ -198,13 +282,14 @@ export async function splash(label, run, opts = {}) {
|
|
|
198
282
|
export function rule(label) {
|
|
199
283
|
const width = Math.min(process.stdout.columns ?? 80, 72);
|
|
200
284
|
if (!label)
|
|
201
|
-
return c.dim(
|
|
202
|
-
const line =
|
|
203
|
-
return `${c.dim(
|
|
285
|
+
return c.dim(glyphs.rule.repeat(width));
|
|
286
|
+
const line = glyphs.rule.repeat(Math.max(0, width - visibleLength(label) - 3));
|
|
287
|
+
return `${c.dim(glyphs.rule.repeat(2))} ${c.bold(label)} ${c.dim(line)}`;
|
|
204
288
|
}
|
|
205
289
|
/** A horizontal meter. Used for headroom, where the shape matters more than the number. */
|
|
206
290
|
export function bar(fraction, width = 12) {
|
|
207
|
-
const
|
|
208
|
-
const
|
|
209
|
-
|
|
291
|
+
const clamped = Math.max(0, Math.min(1, fraction));
|
|
292
|
+
const filled = Math.round(clamped * width);
|
|
293
|
+
const colour = clamped > 0.85 ? c.red : clamped > 0.65 ? c.yellow : c.signal;
|
|
294
|
+
return colour(glyphs.barFill.repeat(filled)) + c.dim(glyphs.barEmpty.repeat(width - filled));
|
|
210
295
|
}
|
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yadurajfleetos/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Fleet OS command-line interface for deploying and orchestrating services on user-owned hardware",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"bin": {
|
|
8
|
-
"fleet": "
|
|
8
|
+
"fleet": "dist/index.js"
|
|
9
9
|
},
|
|
10
10
|
"files": [
|
|
11
11
|
"dist",
|