@yadurajfleetos/cli 0.1.8 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/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');
package/dist/plan.js ADDED
@@ -0,0 +1,69 @@
1
+ /**
2
+ * What to deploy, and in what order.
3
+ *
4
+ * `fleet up` used to deploy exactly one service, which meant a stack was four
5
+ * invocations typed in the right order — and getting the order wrong looked
6
+ * like a broken application rather than a sequencing mistake, because an API
7
+ * whose database is not up yet fails its health check like any other outage.
8
+ */
9
+ import { parse as parseYaml } from 'yaml';
10
+ /**
11
+ * Read the manifest the way the control plane will.
12
+ *
13
+ * Deliberately forgiving: the server validates, and this only needs enough
14
+ * structure to decide what to send and when. A manifest that is wrong will be
15
+ * rejected by apply with a proper message before any of this matters.
16
+ */
17
+ export function planFromManifest(source) {
18
+ const doc = parseYaml(source);
19
+ const services = doc?.services;
20
+ if (!services || typeof services !== 'object')
21
+ return [];
22
+ return Object.entries(services).map(([name, raw]) => {
23
+ const body = (raw ?? {});
24
+ return {
25
+ name,
26
+ build: typeof body.build === 'string' ? body.build : undefined,
27
+ affinity: Array.isArray(body.affinity) ? body.affinity.filter((a) => typeof a === 'string') : [],
28
+ };
29
+ });
30
+ }
31
+ /**
32
+ * Order services so a dependency is deployed before whatever depends on it.
33
+ *
34
+ * `affinity` is the signal. It means "co-locate", not "depends on", but in
35
+ * practice the two coincide: a service is pinned next to the database it talks
36
+ * to, and it cannot become healthy until that database answers. Using it for
37
+ * ordering is a heuristic, and the cost of it being wrong is one slow retry
38
+ * rather than a failure — the agent's restart policy brings up a container that
39
+ * started too early anyway.
40
+ *
41
+ * Ties keep manifest order, so the file stays the explanation for what happens.
42
+ * A cycle is not an error here: the services involved are emitted in manifest
43
+ * order rather than dropped, because refusing to deploy is a worse answer than
44
+ * deploying in an imperfect order.
45
+ */
46
+ export function deployOrder(services) {
47
+ const byName = new Map(services.map((s) => [s.name, s]));
48
+ const ordered = [];
49
+ const state = new Map();
50
+ const visit = (name) => {
51
+ if (state.get(name) === 'done')
52
+ return;
53
+ // Already on the stack: a cycle. Stop rather than recurse forever; the
54
+ // caller still gets every service, just not in a perfect order.
55
+ if (state.get(name) === 'visiting')
56
+ return;
57
+ const service = byName.get(name);
58
+ if (!service)
59
+ return; // affinity on something not in this manifest
60
+ state.set(name, 'visiting');
61
+ for (const dependency of service.affinity)
62
+ visit(dependency);
63
+ state.set(name, 'done');
64
+ ordered.push(name);
65
+ };
66
+ for (const service of services)
67
+ visit(service.name);
68
+ return ordered;
69
+ }
@@ -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
+ }