@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/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
- /** Animate only where it can be erased again. */
15
- export const animated = () => Boolean(err.isTTY) && !process.env.CI && !process.env.FLEET_NO_ANIMATION;
16
- const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
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
- /** Elapsed time, shown only once it is long enough to be worth knowing. */
26
- const elapsed = (startedAt) => {
27
- const seconds = (Date.now() - startedAt) / 1000;
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
- clear();
78
- err.write(truncate(`${c.signal(FRAMES[frame % FRAMES.length])} ${text}${elapsed(startedAt)}` +
79
- (hint ? c.dim(` ${hint}`) : ''), width()));
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
- clear();
91
- err.write(`${mark} ${final ?? text}${elapsed(startedAt)}\n${cursor.show()}`);
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
- clear();
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
- clear();
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('─'.repeat(width));
202
- const line = '─'.repeat(Math.max(0, width - visibleLength(label) - 3));
203
- return `${c.dim('──')} ${c.bold(label)} ${c.dim(line)}`;
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 filled = Math.round(Math.max(0, Math.min(1, fraction)) * width);
208
- const colour = fraction > 0.85 ? c.red : fraction > 0.65 ? c.yellow : c.signal;
209
- return colour('█'.repeat(filled)) + c.dim('░'.repeat(width - filled));
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.1.8",
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": "./dist/index.js"
8
+ "fleet": "dist/index.js"
9
9
  },
10
10
  "files": [
11
11
  "dist",