@yadurajfleetos/cli 0.1.8 → 0.1.9

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.
@@ -8,6 +8,7 @@ import { upCommand } from './up.js';
8
8
  import { openCommand } from './open.js';
9
9
  import { downCommand } from './down.js';
10
10
  import { unpairCommand, agentCommand } from './unpair.js';
11
+ import { secretsCommand } from './secrets.js';
11
12
  import { applyCommand, deployCommand, deploymentsCommand, initCommand, logsCommand, removeServiceCommand, restartCommand, rollbackCommand, rescheduleCommand, servicesCommand, validateCommand, whereCommand, } from './services.js';
12
13
  export const commands = {
13
14
  up: upCommand,
@@ -33,6 +34,7 @@ export const commands = {
33
34
  rollback: rollbackCommand,
34
35
  events: eventsCommand,
35
36
  alerts: alertsCommand,
37
+ secrets: secretsCommand,
36
38
  unpair: unpairCommand,
37
39
  agent: agentCommand,
38
40
  };
@@ -0,0 +1,117 @@
1
+ /**
2
+ * fleet secrets — the fleet credential store.
3
+ *
4
+ * The one rule this command exists to enforce: a secret value never appears in
5
+ * `argv`. Arguments land in shell history, in `ps` output for every user on the
6
+ * box, and in CI logs — so `fleet secrets set KEY hunter2` is deliberately not
7
+ * a supported spelling. The value comes from a pipe or from a prompt with the
8
+ * echo off, and nothing here ever prints one back.
9
+ */
10
+ import { request, requireFleet, CliError, EXIT } from '../api.js';
11
+ import { c, table, relativeTime } from '../render.js';
12
+ import { glyph } from '../ui.js';
13
+ import { askSecret, canPrompt } from '../prompt.js';
14
+ const KEY_PATTERN = /^[A-Z_][A-Z0-9_]{0,127}$/;
15
+ /**
16
+ * Read the value from a pipe when there is one, otherwise ask for it.
17
+ *
18
+ * The piped form is what a script or a password manager uses:
19
+ * pass show db/url | fleet secrets set DATABASE_URL
20
+ *
21
+ * Trailing newlines are stripped because every here-string and every `echo`
22
+ * adds one, and a credential with an invisible newline on the end fails
23
+ * authentication somewhere far away from here.
24
+ */
25
+ async function readValue(key) {
26
+ if (!process.stdin.isTTY) {
27
+ const chunks = [];
28
+ for await (const chunk of process.stdin)
29
+ chunks.push(Buffer.from(chunk));
30
+ const piped = Buffer.concat(chunks).toString('utf8').replace(/\r?\n$/, '');
31
+ if (piped)
32
+ return piped;
33
+ }
34
+ if (!canPrompt()) {
35
+ throw new CliError(`No value for ${key}. Pipe it in, or run this where there is a terminal to type into:\n` +
36
+ ` echo -n "value" | fleet secrets set ${key}`, EXIT.usage);
37
+ }
38
+ return askSecret(`${key}`, { hint: 'the value is not echoed and is not stored in shell history' });
39
+ }
40
+ async function resolveServiceId(fleetId, name) {
41
+ const { body } = await request('GET', `/fleets/${fleetId}/services`);
42
+ const match = body.services.find((s) => s.name === name || s.id === name);
43
+ if (!match) {
44
+ throw new CliError(`No service called "${name}". Known: ${body.services.map((s) => s.name).join(', ') || 'none'}`, EXIT.usage);
45
+ }
46
+ return match;
47
+ }
48
+ export const secretsCommand = {
49
+ async run(args, flags) {
50
+ const [sub, key] = args;
51
+ const service = typeof flags.service === 'string' ? flags.service : undefined;
52
+ /* ── list ──────────────────────────────────────────────────── */
53
+ if (!sub || sub === 'ls' || sub === 'list') {
54
+ const fleetId = await requireFleet(typeof flags.fleet === 'string' ? flags.fleet : undefined);
55
+ const { body } = await request('GET', `/fleets/${fleetId}/secrets`);
56
+ if (flags.json)
57
+ return console.log(JSON.stringify(body.secrets, null, 2));
58
+ if (!body.secrets.length) {
59
+ console.log('No secrets in this fleet.');
60
+ console.log(c.dim(' set one with `fleet secrets set DATABASE_URL`'));
61
+ return;
62
+ }
63
+ console.log(table(['key', 'scope', 'updated'], body.secrets.map((s) => [
64
+ s.key,
65
+ s.scope === 'service' ? `${c.dim('service:')}${s.service ?? '?'}` : c.dim('fleet'),
66
+ relativeTime(s.updatedAt),
67
+ ])));
68
+ console.log(c.dim(`\n ${body.secrets.length} stored. Values cannot be read back — only replaced.`));
69
+ return;
70
+ }
71
+ /* ── set ───────────────────────────────────────────────────── */
72
+ if (sub === 'set') {
73
+ if (!key)
74
+ throw new CliError('usage: fleet secrets set <KEY> [--service <name>]', EXIT.usage);
75
+ if (!KEY_PATTERN.test(key)) {
76
+ throw new CliError(`"${key}" is not a usable environment variable name.\n` +
77
+ ` Use upper snake case: A-Z, 0-9 and _, not starting with a digit.`, EXIT.usage);
78
+ }
79
+ // A third positional is almost always someone typing the value inline.
80
+ // Refuse it rather than accepting a credential into shell history.
81
+ if (args[2]) {
82
+ throw new CliError('Do not pass the value as an argument — it would be written to your shell history.\n' +
83
+ ` Pipe it: echo -n "value" | fleet secrets set ${key}\n` +
84
+ ` Or type it: fleet secrets set ${key}`, EXIT.usage);
85
+ }
86
+ const fleetId = await requireFleet(typeof flags.fleet === 'string' ? flags.fleet : undefined);
87
+ const target = service ? await resolveServiceId(fleetId, service) : null;
88
+ const value = await readValue(key);
89
+ const path = target
90
+ ? `/services/${target.id}/secrets/${encodeURIComponent(key)}`
91
+ : `/fleets/${fleetId}/secrets/${encodeURIComponent(key)}`;
92
+ const { body } = await request('PUT', path, { body: { value } });
93
+ const where = target ? ` for ${c.bold(target.name)}` : '';
94
+ console.log(`${glyph.ok} ${c.green(body.created ? 'stored' : 'replaced')} ${c.bold(key)}${where}`);
95
+ console.log(c.dim(' takes effect on the next deploy of any service that references it'));
96
+ return;
97
+ }
98
+ /* ── rm ────────────────────────────────────────────────────── */
99
+ if (sub === 'rm' || sub === 'remove' || sub === 'delete') {
100
+ if (!key)
101
+ throw new CliError('usage: fleet secrets rm <KEY> [--service <name>]', EXIT.usage);
102
+ const fleetId = await requireFleet(typeof flags.fleet === 'string' ? flags.fleet : undefined);
103
+ const target = service ? await resolveServiceId(fleetId, service) : null;
104
+ const path = target
105
+ ? `/services/${target.id}/secrets/${encodeURIComponent(key)}`
106
+ : `/fleets/${fleetId}/secrets/${encodeURIComponent(key)}`;
107
+ await request('DELETE', path);
108
+ const where = target ? ` override for ${c.bold(target.name)}` : '';
109
+ console.log(`${glyph.ok} ${c.yellow('removed')} ${c.bold(key)}${where}`);
110
+ console.log(c.dim(' services already running keep the value they started with until redeployed'));
111
+ return;
112
+ }
113
+ throw new CliError('usage: fleet secrets [ls]\n' +
114
+ ' fleet secrets set <KEY> [--service <name>]\n' +
115
+ ' fleet secrets rm <KEY> [--service <name>]', EXIT.usage);
116
+ },
117
+ };
@@ -2,7 +2,10 @@ import { readFile, writeFile, access } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
3
  import { request, requireFleet, CliError, EXIT } from '../api.js';
4
4
  import { c, table, statusColour, keyValues, relativeTime, mb } from '../render.js';
5
- import { task, splash, glyph } from '../ui.js';
5
+ import { task, glyph } from '../ui.js';
6
+ import { withLadder } from '../ladder.js';
7
+ import { ask, canPrompt, confirm, selectOrThrow } from '../prompt.js';
8
+ import { DEPLOY_STEPS, follow, phaseWalker, } from '../progress.js';
6
9
  const manifestPath = (given) => given ?? 'fleet.yaml';
7
10
  async function readManifest(path) {
8
11
  try {
@@ -179,16 +182,23 @@ export const deployCommand = {
179
182
  console.log(c.dim('Deployment cancelled. Re-run with --yes to skip confirmation.'));
180
183
  return;
181
184
  }
182
- const body = await splash(`deploying ${c.bold(service.name)}${gitSha ? c.dim(` at ${gitSha.slice(0, 7)}`) : ''}`, async () => (await request('POST', `/services/${service.id}/deploy`, { body: { gitSha } })).body, {
183
- // What the request involves, not a stage it has reached — the call is a
184
- // single synchronous POST and the CLI cannot see inside it.
185
- hints: [
186
- 'scoring every online node on headroom, reliability and load',
187
- 'building for every architecture an eligible node runs',
188
- 'the first multi-arch build is the slow one; layers cache after it',
189
- 'pushing the image to the fleet registry',
190
- ],
191
- done: (b) => `built and scheduled onto ${c.bold(b.placedOn.name)} ${c.dim(`score ${b.score?.toFixed(3)}`)}`,
185
+ const body = await withLadder(DEPLOY_STEPS, async (ladder) => {
186
+ const walker = phaseWalker(ladder);
187
+ const progress = follow(service.id, (p) => walker.apply(p), {
188
+ onUnavailable: () => ladder.note(c.dim('live progress unavailable; continuing with the deploy request')),
189
+ });
190
+ try {
191
+ const result = (await request('POST', `/services/${service.id}/deploy`, { body: { gitSha } })).body;
192
+ walker.finish(`scheduled onto ${result.placedOn.name}`);
193
+ return result;
194
+ }
195
+ finally {
196
+ await progress.stop();
197
+ }
198
+ }, {
199
+ mark: true,
200
+ title: `deploying ${service.name}${gitSha ? ` at ${gitSha.slice(0, 7)}` : ''}`,
201
+ onCancel: `deploy is still running on the control plane; inspect with fleet deployments ${service.name}`,
192
202
  });
193
203
  if (flags.json)
194
204
  return console.log(JSON.stringify(body, null, 2));
@@ -11,7 +11,9 @@ import { readFile, writeFile, access } from 'node:fs/promises';
11
11
  import { join } from 'node:path';
12
12
  import { request, requireFleet, CliError, EXIT } from '../api.js';
13
13
  import { c } from '../render.js';
14
- import { task, splash, glyph } from '../ui.js';
14
+ import { task, glyph } from '../ui.js';
15
+ import { withLadder } from '../ladder.js';
16
+ import { DEPLOY_STEPS, follow, phaseWalker } from '../progress.js';
15
17
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
16
18
  export const upCommand = {
17
19
  async run(args, flags) {
@@ -63,14 +65,23 @@ export const upCommand = {
63
65
  }
64
66
  // ── Step 4: deploy ────────────────────────────────────────────────
65
67
  const gitSha = typeof flags.sha === 'string' ? flags.sha : undefined;
66
- const deployResult = await splash(`deploying ${c.bold(service.name)}`, async () => (await request('POST', `/services/${service.id}/deploy`, { body: { gitSha } })).body, {
67
- hints: [
68
- 'scoring every online node on headroom, reliability and load',
69
- 'building for every architecture an eligible node runs',
70
- 'the first multi-arch build is the slow one; layers cache after it',
71
- 'pushing the image to the fleet registry',
72
- ],
73
- done: (b) => `built and scheduled onto ${c.bold(b.placedOn.name)} ${c.dim(`score ${b.score?.toFixed(3)}`)}`,
68
+ const deployResult = await withLadder(DEPLOY_STEPS, async (ladder) => {
69
+ const walker = phaseWalker(ladder);
70
+ const progress = follow(service.id, (p) => walker.apply(p), {
71
+ onUnavailable: () => ladder.note(c.dim('live progress unavailable; continuing with the deploy request')),
72
+ });
73
+ try {
74
+ const result = (await request('POST', `/services/${service.id}/deploy`, { body: { gitSha } })).body;
75
+ walker.finish(`scheduled onto ${result.placedOn.name}`);
76
+ return result;
77
+ }
78
+ finally {
79
+ await progress.stop();
80
+ }
81
+ }, {
82
+ mark: true,
83
+ title: `deploying ${service.name}`,
84
+ onCancel: `deploy is still running on the control plane; inspect with fleet deployments ${service.name}`,
74
85
  });
75
86
  for (const w of deployResult.warnings ?? []) {
76
87
  console.log(`${glyph.warn} ${c.yellow('warning')} ${w}`);
package/dist/index.js CHANGED
@@ -48,6 +48,9 @@ const GROUPS = [
48
48
  ['reschedule <service>', 'Force a service to move'],
49
49
  ['restart <service>', 'Replace the current release on its node'],
50
50
  ['rollback <service> [release]', 'Restore the previous or selected release'],
51
+ ['secrets', 'List the fleet secret store'],
52
+ ['secrets set <KEY>', 'Store a credential; the value is never echoed or logged'],
53
+ ['secrets rm <KEY>', 'Remove a stored credential'],
51
54
  ['nodes cordon <name>', 'Stop scheduling new work onto a node'],
52
55
  ['nodes uncordon <name>', 'Allow scheduling again'],
53
56
  ['nodes rm <name>', 'Revoke and remove a node'],
package/dist/ladder.js ADDED
@@ -0,0 +1,290 @@
1
+ /**
2
+ * A progress ladder: several named steps sharing one redraw region, each
3
+ * settling in place as it completes.
4
+ *
5
+ * This exists because a stack of independent spinners cannot answer the only
6
+ * question an operator has during a four-minute deploy — where am I. Completed
7
+ * steps stay on screen, the active step carries the clock, and the steps still to
8
+ * come are listed from the start, so the shape of the whole operation is legible
9
+ * before it has finished.
10
+ *
11
+ * The two rules from ui.ts still hold. Everything here goes to stderr, so `--json`
12
+ * on stdout stays pipeable into jq. And every animated form has a plain-line
13
+ * transcript equivalent, so a CI log reads as a sequence of events rather than as
14
+ * a smear of cursor escapes.
15
+ */
16
+ import { c, cursor, glyphs, truncate } from './render.js';
17
+ import { MARK_HEIGHT, markFrame, PEER_COUNT } from './mark.js';
18
+ import { animated, claimRegion, duration, elapsed, glyph, hookCursorRestore, isQuiet, releaseRegion, setInterruptHandler, tickFor, width, } from './ui.js';
19
+ const err = process.stderr;
20
+ /**
21
+ * The live ladder, if there is one. Prompts need to find it in order to step
22
+ * aside for it; threading it through every call site instead would mean every
23
+ * command that can prompt has to know whether it is inside a ladder.
24
+ */
25
+ let current = null;
26
+ export const activeLadder = () => current;
27
+ const screenRows = () => err.rows || process.stdout.rows || 24;
28
+ export function ladder(steps, opts = {}) {
29
+ const rows = steps.map((step) => ({ ...step, state: 'todo' }));
30
+ const at = (key) => rows.find((row) => row.key === key);
31
+ // The mark is the first thing to go when the terminal is short: the steps carry
32
+ // the information, the mesh only carries the brand.
33
+ const withMark = Boolean(opts.mark) && screenRows() >= MARK_HEIGHT + rows.length + 5;
34
+ // One spare row for the detail line that appears under the active step.
35
+ const height = (withMark ? MARK_HEIGHT + 1 : 0) + rows.length + 1;
36
+ const indent = withMark ? ' ' : '';
37
+ const owner = {};
38
+ // claimRegion mutates, so it stays last: a ladder that is too tall to redraw
39
+ // must not take ownership on its way to the transcript fallback.
40
+ const live = animated() && !isQuiet() && screenRows() >= height + 2 && claimRegion(owner);
41
+ const tick = tickFor(height);
42
+ let ticks = 0;
43
+ let phase = 0;
44
+ let painted = [];
45
+ let timer;
46
+ let closed = false;
47
+ let lastTranscriptDetail = 0;
48
+ const took = (row) => row.state === 'active' ? elapsed(row.startedAt ?? Date.now()) : duration(row.took ?? 0);
49
+ const render = (row, spin) => {
50
+ const marker = row.state === 'active'
51
+ ? c.signal(spin)
52
+ : row.state === 'done'
53
+ ? glyph.ok
54
+ : row.state === 'fail'
55
+ ? glyph.fail
56
+ : glyph.pending;
57
+ const dimmed = row.state === 'todo' || row.state === 'skip';
58
+ const label = dimmed ? c.dim(row.label) : row.label;
59
+ const summary = row.summary ? ` ${c.dim(row.summary)}` : '';
60
+ return `${marker} ${label}${summary}${took(row)}`;
61
+ };
62
+ const frame = () => {
63
+ const spin = glyphs.frames[ticks % glyphs.frames.length];
64
+ const body = rows.flatMap((row) => {
65
+ const out = [`${indent}${render(row, spin)}`];
66
+ if (row.state === 'active' && row.detail)
67
+ out.push(`${indent} ${c.dim(`${glyphs.branch} ${row.detail}`)}`);
68
+ return out;
69
+ });
70
+ const head = withMark
71
+ ? [
72
+ ...markFrame(phase).map((line, i) => ` ${line}${i === 2 && opts.title ? ` ${c.bold(opts.title)}` : ''}`),
73
+ '',
74
+ ]
75
+ : [];
76
+ return [...head, ...body].map((line) => truncate(line, width()));
77
+ };
78
+ /**
79
+ * Rewrite only the rows that moved, batched into a single write. A full repaint
80
+ * of a nine-row region twelve times a second is visible as flicker over SSH,
81
+ * and most frames change one row.
82
+ */
83
+ const paint = (lines) => {
84
+ if (!lines.length)
85
+ return;
86
+ if (painted.length !== lines.length) {
87
+ // The row count changed, so a partial rewrite would orphan rows below.
88
+ err.write((painted.length ? cursor.up(painted.length) + '\r' + cursor.clearBelow() : '') +
89
+ lines.join('\n') +
90
+ '\n');
91
+ painted = lines;
92
+ return;
93
+ }
94
+ let first = -1;
95
+ let last = -1;
96
+ for (let i = 0; i < lines.length; i++) {
97
+ if (lines[i] !== painted[i]) {
98
+ if (first < 0)
99
+ first = i;
100
+ last = i;
101
+ }
102
+ }
103
+ // Nothing moved: leave the terminal completely alone.
104
+ if (first < 0)
105
+ return;
106
+ let out = cursor.up(lines.length - first);
107
+ for (let i = first; i <= last; i++) {
108
+ if (lines[i] !== painted[i])
109
+ out += cursor.clearLine() + lines[i];
110
+ out += '\n';
111
+ }
112
+ // Back to where the region ends, without a newline that could scroll it.
113
+ const below = lines.length - 1 - last;
114
+ if (below > 0)
115
+ out += cursor.down(below);
116
+ err.write(out);
117
+ painted = lines;
118
+ };
119
+ const erase = () => {
120
+ if (!painted.length)
121
+ return;
122
+ err.write(cursor.up(painted.length) + '\r' + cursor.clearBelow());
123
+ painted = [];
124
+ };
125
+ const advance = () => {
126
+ ticks += 1;
127
+ // Scale the pulse by the tick so it travels at the same speed on a slow link.
128
+ phase = (phase + 0.25 * (tick / 80)) % PEER_COUNT;
129
+ paint(frame());
130
+ };
131
+ const start = () => {
132
+ timer = setInterval(advance, tick);
133
+ timer.unref?.();
134
+ };
135
+ /** One transcript line per transition — the same rule the spinner follows. */
136
+ const transcript = (line) => {
137
+ if (!isQuiet())
138
+ err.write(`${line}\n`);
139
+ };
140
+ const api = {
141
+ begin(key, label) {
142
+ const row = at(key);
143
+ if (!row)
144
+ return;
145
+ if (label)
146
+ row.label = label;
147
+ row.state = 'active';
148
+ row.startedAt = Date.now();
149
+ row.detail = undefined;
150
+ if (live)
151
+ paint(frame());
152
+ else
153
+ transcript(`${c.dim(glyphs.stepActive)} ${row.label}…`);
154
+ },
155
+ detail(key, text) {
156
+ const row = at(key);
157
+ if (!row || row.state !== 'active' || row.detail === text)
158
+ return;
159
+ row.detail = text;
160
+ if (live) {
161
+ paint(frame());
162
+ return;
163
+ }
164
+ // Without a redraw region every sub-step would be its own line, and a build
165
+ // emits hundreds. Keep a captured log alive without flooding it.
166
+ if (Date.now() - lastTranscriptDetail < 10_000)
167
+ return;
168
+ lastTranscriptDetail = Date.now();
169
+ transcript(` ${c.dim(`${glyphs.branch} ${text}`)}`);
170
+ },
171
+ done(key, summary) {
172
+ const row = at(key);
173
+ if (!row)
174
+ return;
175
+ row.took = Date.now() - (row.startedAt ?? Date.now());
176
+ row.state = 'done';
177
+ row.summary = summary;
178
+ row.detail = undefined;
179
+ if (live)
180
+ paint(frame());
181
+ else
182
+ transcript(`${glyph.ok} ${row.label}${summary ? ` — ${summary}` : ''}${took(row)}`);
183
+ },
184
+ skip(key, why) {
185
+ const row = at(key);
186
+ if (!row)
187
+ return;
188
+ row.state = 'skip';
189
+ row.summary = why ?? 'skipped';
190
+ row.detail = undefined;
191
+ if (live)
192
+ paint(frame());
193
+ else
194
+ transcript(`${glyph.pending} ${c.dim(`${row.label} — ${row.summary}`)}`);
195
+ },
196
+ fail(key, why) {
197
+ const row = at(key);
198
+ if (!row)
199
+ return;
200
+ row.took = Date.now() - (row.startedAt ?? Date.now());
201
+ row.state = 'fail';
202
+ row.summary = why;
203
+ row.detail = undefined;
204
+ if (live)
205
+ paint(frame());
206
+ else
207
+ transcript(`${glyph.fail} ${row.label}${why ? ` — ${why}` : ''}${took(row)}`);
208
+ },
209
+ failActive(why) {
210
+ for (const row of rows)
211
+ if (row.state === 'active')
212
+ api.fail(row.key, why);
213
+ },
214
+ note(line) {
215
+ if (!live) {
216
+ transcript(line);
217
+ return;
218
+ }
219
+ erase();
220
+ err.write(`${line}\n`);
221
+ paint(frame());
222
+ },
223
+ suspend() {
224
+ if (!live)
225
+ return;
226
+ clearInterval(timer);
227
+ erase();
228
+ err.write(cursor.show());
229
+ releaseRegion(owner);
230
+ },
231
+ resume() {
232
+ if (!live || closed || !claimRegion(owner))
233
+ return;
234
+ err.write(cursor.hide());
235
+ paint(frame());
236
+ start();
237
+ },
238
+ close() {
239
+ if (closed)
240
+ return;
241
+ closed = true;
242
+ clearInterval(timer);
243
+ setInterruptHandler(null);
244
+ releaseRegion(owner);
245
+ if (current === api)
246
+ current = null;
247
+ if (!live)
248
+ return;
249
+ erase();
250
+ // Reprint the settled steps as static lines so scrollback keeps the summary.
251
+ // The mesh does not survive: a frozen loading animation in scrollback says
252
+ // nothing that a settled step list does not say better.
253
+ const settled = rows
254
+ .filter((row) => row.state !== 'todo')
255
+ .map((row) => truncate(render(row, glyphs.stepActive), width()));
256
+ err.write((settled.length ? `${settled.join('\n')}\n` : '') + cursor.show());
257
+ },
258
+ };
259
+ if (live) {
260
+ current = api;
261
+ hookCursorRestore();
262
+ setInterruptHandler(() => {
263
+ erase();
264
+ if (opts.onCancel)
265
+ err.write(`${glyph.warn} ${opts.onCancel}\n`);
266
+ });
267
+ err.write(cursor.hide());
268
+ paint(frame());
269
+ start();
270
+ }
271
+ return api;
272
+ }
273
+ /**
274
+ * Run work under a ladder, closing it on either outcome. Whichever step was in
275
+ * flight when the work threw is marked failed, so the transcript shows where it
276
+ * stopped rather than only that it stopped.
277
+ */
278
+ export async function withLadder(steps, run, opts = {}) {
279
+ const l = ladder(steps, opts);
280
+ try {
281
+ return await run(l);
282
+ }
283
+ catch (error) {
284
+ l.failActive(error instanceof Error ? error.message : undefined);
285
+ throw error;
286
+ }
287
+ finally {
288
+ l.close();
289
+ }
290
+ }
package/dist/mark.js CHANGED
@@ -6,14 +6,28 @@
6
6
  * The mark is a fixed character grid rather than a string per state, because
7
7
  * the loading animation lights individual spokes and needs to address cells.
8
8
  */
9
- import { c, colourDepth, rgb } from './render.js';
10
- const GRID = [
11
- ' ○ ○ ○ ',
12
- ' ╲ │ ╱ ',
13
- '○───────◉───────○',
14
- ' ╱ │ ╲ ',
15
- ' ○ ○ ○ ',
16
- ];
9
+ import { c, colourDepth, rgb, unicode } from './render.js';
10
+ /**
11
+ * Both grids are deliberately the same 17×5 shape. Every cell coordinate below,
12
+ * and the cursor arithmetic in the progress UI above, is written against those
13
+ * dimensions — an ASCII fallback of a different size would silently break the
14
+ * redraw rather than merely look plainer.
15
+ */
16
+ const GRID = unicode
17
+ ? [
18
+ ' ○ ○ ○ ',
19
+ ' ╲ │ ╱ ',
20
+ '○───────◉───────○',
21
+ ' ╱ │ ╲ ',
22
+ ' ○ ○ ○ ',
23
+ ]
24
+ : [
25
+ ' o o o ',
26
+ ' \\ | / ',
27
+ 'o-------@-------o',
28
+ ' / | \\ ',
29
+ ' o o o ',
30
+ ];
17
31
  export const MARK_WIDTH = 17;
18
32
  export const MARK_HEIGHT = GRID.length;
19
33
  /** Where the hub sits. Always lit — a fleet with no control plane is not a fleet. */
@@ -82,6 +96,8 @@ export function markFrame(phase) {
82
96
  /** The resting mark: hub live, peers quiet. */
83
97
  export const mark = () => markFrame(-1);
84
98
  const WORDMARK = ['█▀▀ █ █▀▀ █▀▀ ▀█▀', '█▀ █ █▀ █▀ █ ', '▀ ▀▀▀ ▀▀▀ ▀▀▀ ▀ '];
99
+ /** The hub, read back out of the grid so the two glyph sets cannot drift. */
100
+ const hubGlyph = GRID[HUB[0]][HUB[1]];
85
101
  /**
86
102
  * Mark and wordmark side by side, with the tagline tucked under the wordmark so
87
103
  * the block stays rectangular. Falls back to a single line when the terminal is
@@ -91,11 +107,15 @@ export function banner(subtitle) {
91
107
  // `columns` is 0, not undefined, on some pseudo-terminals — `??` would miss it.
92
108
  const columns = process.stdout.columns || 80;
93
109
  if (columns < 46)
94
- return `${c.signal('◉')} ${c.bold('fleet')}${subtitle ? c.dim(` ${subtitle}`) : ''}`;
110
+ return `${c.signal(hubGlyph)} ${c.bold('fleet')}${subtitle ? c.dim(` ${subtitle}`) : ''}`;
95
111
  // The wordmark sits against the middle three rows of the mark; the tagline
96
112
  // takes the last. Every mark row is exactly MARK_WIDTH visible columns, so a
97
113
  // fixed gutter aligns them without measuring around the colour codes.
98
- const right = ['', ...WORDMARK.map(c.bold), subtitle ? c.dim(subtitle) : ''];
114
+ // Without block glyphs the drawn wordmark is unreadable, so ASCII terminals
115
+ // get the name set once against the hub row instead of a row of mojibake.
116
+ const right = unicode
117
+ ? ['', ...WORDMARK.map(c.bold), subtitle ? c.dim(subtitle) : '']
118
+ : ['', '', c.bold('F L E E T'), subtitle ? c.dim(subtitle) : '', ''];
99
119
  return mark()
100
120
  .map((line, i) => ` ${line}${right[i] ? ` ${right[i]}` : ''}`.trimEnd())
101
121
  .join('\n');
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Following a deploy.
3
+ *
4
+ * `POST /services/:id/deploy` is a single request that can take minutes: it builds
5
+ * for every architecture in the fleet, pushes to the registry, allocates a port
6
+ * and hands the container to an agent. The control plane now walks the deployment
7
+ * row through those phases and publishes the build's own sub-step alongside it
8
+ * (control-plane/src/api/deploy-progress.ts), so the CLI can poll for them while
9
+ * it waits on the request it already has in flight, and show where the work has
10
+ * actually got to instead of guessing.
11
+ *
12
+ * Progress is decoration, never the result: a poll that fails is swallowed and the
13
+ * deploy carries on. Only `awaitRunning`, where a poll *is* the mechanism, treats
14
+ * a persistently unreachable control plane as an error.
15
+ */
16
+ import { request, CliError, EXIT } from './api.js';
17
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
18
+ /** A failure reason can be a build log tail; a one-line error gets one line of it. */
19
+ export const firstLine = (text) => text.split('\n')[0].trim().slice(0, 200);
20
+ export async function fetchProgress(serviceId) {
21
+ const { body } = await request('GET', `/services/${serviceId}/progress`);
22
+ return body.progress;
23
+ }
24
+ /**
25
+ * The build's sub-step as one line. `linux/arm64` is shortened because the arch is
26
+ * the informative half and the redraw region is narrow.
27
+ */
28
+ export function progressLine(p) {
29
+ if (!p.detail)
30
+ return undefined;
31
+ const counter = p.step && p.ofSteps ? `${p.step}/${p.ofSteps} ` : '';
32
+ const platform = p.platform ? `${p.platform.replace(/^linux\//, '')} ` : '';
33
+ return `${counter}${platform}${p.detail}`;
34
+ }
35
+ /**
36
+ * Poll `/progress` in the background while something else is being awaited.
37
+ *
38
+ * The shape is the device-flow race in auth.ts: the answer comes from one promise
39
+ * while a second keeps the display honest until it lands. `stop()` interrupts the
40
+ * sleep rather than waiting it out, so the last frame is not held back by a poll
41
+ * interval that is no longer needed.
42
+ */
43
+ export function follow(serviceId, sink, opts = {}) {
44
+ const interval = opts.intervalMs ?? 800;
45
+ let stopped = false;
46
+ let misses = 0;
47
+ let wake = null;
48
+ const rest = (ms) => new Promise((resolve) => {
49
+ const timer = setTimeout(resolve, ms);
50
+ wake = () => {
51
+ clearTimeout(timer);
52
+ resolve();
53
+ };
54
+ });
55
+ const loop = (async () => {
56
+ while (!stopped) {
57
+ await rest(interval);
58
+ if (stopped)
59
+ return;
60
+ try {
61
+ const progress = await fetchProgress(serviceId);
62
+ misses = 0;
63
+ if (progress)
64
+ sink(progress);
65
+ }
66
+ catch {
67
+ // A control plane that predates the endpoint answers 404 every time, so
68
+ // give up rather than spend the whole build asking again.
69
+ if (++misses >= 3) {
70
+ opts.onUnavailable?.();
71
+ return;
72
+ }
73
+ }
74
+ }
75
+ })();
76
+ return {
77
+ stop: async () => {
78
+ stopped = true;
79
+ wake?.();
80
+ await loop.catch(() => { });
81
+ },
82
+ };
83
+ }
84
+ /** Ladder steps for a deploy, in the order the control plane reports them. */
85
+ export const DEPLOY_STEPS = [
86
+ { key: 'place', label: 'choosing a node' },
87
+ { key: 'build', label: 'building the image' },
88
+ { key: 'push', label: 'pushing to the fleet registry' },
89
+ { key: 'schedule', label: 'scheduling onto the node' },
90
+ { key: 'health', label: 'waiting for the container' },
91
+ ];
92
+ /** Which ladder step each server-reported phase corresponds to. */
93
+ const STEP_OF = {
94
+ queued: 0,
95
+ building: 1,
96
+ pushing: 2,
97
+ scheduling: 3,
98
+ deploying: 4,
99
+ running: 5,
100
+ };
101
+ /**
102
+ * Drive a ladder from the server's phases.
103
+ *
104
+ * Forward only: a poll can arrive out of order, and re-beginning a step would
105
+ * restart its clock. Steps the deploy never entered are marked skipped rather than
106
+ * done — a service deployed from a prebuilt image goes straight from `queued` to
107
+ * `scheduling`, and settling build and push as complete would claim work that
108
+ * never happened.
109
+ */
110
+ export function phaseWalker(l, steps = DEPLOY_STEPS) {
111
+ let at = 0;
112
+ l.begin(steps[0].key);
113
+ const advance = (target, summary) => {
114
+ if (target <= at)
115
+ return;
116
+ for (let i = at; i < target; i++) {
117
+ if (i === at)
118
+ l.done(steps[i].key, summary);
119
+ else
120
+ l.skip(steps[i].key, 'not needed');
121
+ }
122
+ at = target;
123
+ if (target < steps.length)
124
+ l.begin(steps[target].key);
125
+ };
126
+ return {
127
+ advance,
128
+ finish: (summary) => advance(steps.length, summary),
129
+ get at() {
130
+ return at;
131
+ },
132
+ apply(p) {
133
+ if (p.status === 'failed') {
134
+ l.failActive(p.failureReason ? firstLine(p.failureReason) : undefined);
135
+ return;
136
+ }
137
+ const target = STEP_OF[p.status];
138
+ if (target !== undefined) {
139
+ // Which node was chosen is the one summary worth carrying over from a
140
+ // poll, and it belongs on the step that decided it.
141
+ advance(target, at === 0 ? (p.nodeName ?? undefined) : undefined);
142
+ }
143
+ const line = progressLine(p);
144
+ if (line && at < steps.length)
145
+ l.detail(steps[at].key, line);
146
+ },
147
+ };
148
+ }
149
+ /**
150
+ * Follow a service to `running`.
151
+ *
152
+ * The deploy request returns once the image exists and a node has been chosen; the
153
+ * container actually starting is the agent's job and happens afterwards, so the
154
+ * CLI follows it to a conclusion rather than reporting "scheduled" and leaving the
155
+ * operator to guess. One indexed row per poll, not the whole service list.
156
+ */
157
+ export async function awaitRunning(service, opts = {}) {
158
+ const deadline = Date.now() + (opts.timeoutMs ?? 180_000);
159
+ let misses = 0;
160
+ while (Date.now() < deadline) {
161
+ let progress = null;
162
+ try {
163
+ progress = await fetchProgress(service.id);
164
+ misses = 0;
165
+ }
166
+ catch (err) {
167
+ // Here a poll is the mechanism, not decoration. Reporting a timeout when
168
+ // the control plane simply stopped answering would blame the wrong thing.
169
+ if (++misses >= 5)
170
+ throw err;
171
+ }
172
+ if (progress?.status === 'running')
173
+ return;
174
+ if (progress?.status === 'failed') {
175
+ const why = progress.failureReason ? `: ${firstLine(progress.failureReason)}` : '';
176
+ throw new CliError(`"${service.name}" did not start${why}. \`fleet deployments ${service.name}\` has the detail.`, EXIT.healthCheckFailed);
177
+ }
178
+ if (progress?.status === 'pinned_unavailable') {
179
+ throw new CliError(`"${service.name}" is pinned to a node that is not available. ` +
180
+ `\`fleet where ${service.name}\` explains why.`, EXIT.noEligibleNode);
181
+ }
182
+ if (progress)
183
+ opts.onProgress?.(progress);
184
+ await sleep(2000);
185
+ }
186
+ throw new CliError(`"${service.name}" was scheduled but has not reported running. ` +
187
+ `\`fleet deployments ${service.name}\` has the detail.`, EXIT.healthCheckFailed);
188
+ }
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.1.9",
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",