@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.
- package/dist/commands/index.js +2 -0
- package/dist/commands/secrets.js +117 -0
- package/dist/commands/services.js +21 -11
- package/dist/commands/up.js +20 -9
- package/dist/index.js +3 -0
- package/dist/ladder.js +290 -0
- package/dist/mark.js +30 -10
- 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/commands/index.js
CHANGED
|
@@ -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,
|
|
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
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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));
|
package/dist/commands/up.js
CHANGED
|
@@ -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,
|
|
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
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
'
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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');
|
package/dist/progress.js
ADDED
|
@@ -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
|
-
/**
|
|
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.1.
|
|
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": "
|
|
8
|
+
"fleet": "dist/index.js"
|
|
9
9
|
},
|
|
10
10
|
"files": [
|
|
11
11
|
"dist",
|