@yadurajfleetos/cli 0.18.0 → 0.18.2

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/args.js CHANGED
@@ -46,7 +46,7 @@ export const KNOWN_FLAGS = new Set([
46
46
  'plan', 'dry-run', 'force', 'dir', 'file', 'manifest',
47
47
  // per command
48
48
  'ai', 'all', 'apply', 'channel', 'deploy', 'email', 'events', 'f', 'follow', 'limit',
49
- 'name', 'node', 'only', 'out', 'password', 'secret', 'service', 'sha',
49
+ 'name', 'node', 'only', 'out', 'password', 'project', 'secret', 'service', 'sha',
50
50
  'since', 'terminal', 'to', 'token', 'url',
51
51
  ]);
52
52
  /** The closest known flag to a mistyped one, or null when nothing is close. */
@@ -1,24 +1,20 @@
1
1
  import { createInterface } from 'node:readline/promises';
2
2
  import { createServer } from 'node:http';
3
- import { exec } from 'node:child_process';
4
- import { promisify } from 'node:util';
3
+ import { spawn } from 'node:child_process';
5
4
  import { request, CliError, EXIT } from '../api.js';
6
5
  import { loadProfile, saveProfile, configLocation } from '../config.js';
7
6
  import { c, keyValues } from '../render.js';
8
7
  import { banner } from '../mark.js';
9
8
  import { glyph, rule, task, spinner } from '../ui.js';
10
- const execAsync = promisify(exec);
11
9
  async function openBrowserUrl(url) {
12
10
  const platform = process.platform;
13
- let cmd = '';
14
- if (platform === 'darwin')
15
- cmd = `open "${url}"`;
16
- else if (platform === 'win32')
17
- cmd = `start "" "${url}"`;
18
- else
19
- cmd = `xdg-open "${url}"`;
20
11
  try {
21
- await execAsync(cmd);
12
+ if (platform === 'darwin')
13
+ spawn('open', [url], { stdio: 'ignore' });
14
+ else if (platform === 'win32')
15
+ spawn('cmd', ['/c', 'start', '""', url], { stdio: 'ignore' });
16
+ else
17
+ spawn('xdg-open', [url], { stdio: 'ignore' });
22
18
  }
23
19
  catch {
24
20
  // Ignore browser opener errors; URL is printed on screen
@@ -4,30 +4,26 @@
4
4
  * Discovers the public HTTPS URL for the service and launches it via the
5
5
  * platform's native opener (open on macOS, xdg-open on Linux, start on Windows).
6
6
  */
7
- import { exec } from 'node:child_process';
8
- import { promisify } from 'node:util';
7
+ import { spawn } from 'node:child_process';
9
8
  import { request, requireFleet, CliError, EXIT } from '../api.js';
10
9
  import { c } from '../render.js';
11
10
  import { glyph } from '../ui.js';
12
- const execAsync = promisify(exec);
13
11
  async function openUrl(url) {
14
12
  const platform = process.platform;
15
- let cmd = '';
13
+ let child;
16
14
  if (platform === 'darwin') {
17
- cmd = `open "${url}"`;
15
+ child = spawn('open', [url], { stdio: 'ignore' });
18
16
  }
19
17
  else if (platform === 'win32') {
20
- cmd = `start "" "${url}"`;
18
+ child = spawn('cmd', ['/c', 'start', '""', url], { stdio: 'ignore' });
21
19
  }
22
20
  else {
23
- cmd = `xdg-open "${url}"`;
24
- }
25
- try {
26
- await execAsync(cmd);
27
- }
28
- catch (err) {
29
- throw new CliError(`Could not open browser automatically: ${String(err)}`, EXIT.failure);
21
+ child = spawn('xdg-open', [url], { stdio: 'ignore' });
30
22
  }
23
+ return new Promise((resolve, reject) => {
24
+ child.on('error', (err) => reject(new CliError(`Could not open browser automatically: ${err.message}`, EXIT.failure)));
25
+ child.on('close', () => resolve());
26
+ });
31
27
  }
32
28
  export const openCommand = {
33
29
  async run(args, flags) {
@@ -7,6 +7,7 @@ import { withLadder } from '../ladder.js';
7
7
  import { ask, canPrompt, confirm, selectOrThrow } from '../prompt.js';
8
8
  import { DEPLOY_STEPS, follow, phaseWalker, } from '../progress.js';
9
9
  import { planFromDiscovery, renderPlan, toAssistPlan } from '../deployment-plan.js';
10
+ import { requireRunning } from '../deploy-wait.js';
10
11
  import { planFromManifest, projectNameFor } from '../plan.js';
11
12
  import { uploadContext, humanBytes } from '../archive.js';
12
13
  import { localSource } from '../source.js';
@@ -132,13 +133,51 @@ export const servicesCommand = {
132
133
  }
133
134
  },
134
135
  };
135
- async function findService(fleetId, name) {
136
+ /**
137
+ * The service a name refers to, in a fleet where a name is no longer unique.
138
+ *
139
+ * Services are identified by (fleet, project, name), so two projects may both
140
+ * have a "backend". This used to take the first match, which meant a command
141
+ * could act on another project's service without saying so — the same class of
142
+ * mistake as the apply that silently took one over.
143
+ *
144
+ * Resolved in the order the operator's intent is clearest:
145
+ *
146
+ * --project an explicit answer, and an error if it does not match
147
+ * the directory the project a manifest here would apply as, which is how
148
+ * the service got its project in the first place
149
+ * otherwise refuse, and list the projects to choose between
150
+ *
151
+ * An id always wins: it identifies a row on its own and needs no project.
152
+ */
153
+ /** `--project`, when the operator gave one. */
154
+ const projectFlag = (flags) => typeof flags.project === 'string' ? flags.project : undefined;
155
+ async function findService(fleetId, name, project) {
136
156
  const { body } = await request('GET', `/fleets/${fleetId}/services`);
137
- const match = body.services.find((s) => s.name === name || s.id === name);
138
- if (!match) {
157
+ const byId = body.services.find((s) => s.id === name);
158
+ if (byId)
159
+ return byId;
160
+ const matches = body.services.filter((s) => s.name === name);
161
+ if (!matches.length) {
139
162
  throw new CliError(`No service called "${name}". Known: ${body.services.map((s) => s.name).join(', ') || 'none'}`, EXIT.usage);
140
163
  }
141
- return match;
164
+ if (matches.length === 1)
165
+ return matches[0];
166
+ if (project) {
167
+ const scoped = matches.find((s) => s.project === project);
168
+ if (scoped)
169
+ return scoped;
170
+ throw new CliError(`No service called "${name}" in project "${project}". ` +
171
+ `It exists in: ${matches.map((s) => s.project).join(', ')}.`, EXIT.usage);
172
+ }
173
+ // The project this directory would apply as. Not a guess: it is the same
174
+ // value `fleet apply` uses, so it names the service this directory owns.
175
+ const here = projectNameFor(process.cwd());
176
+ const local = matches.find((s) => s.project === here);
177
+ if (local)
178
+ return local;
179
+ throw new CliError(`"${name}" exists in more than one project: ${matches.map((s) => s.project).join(', ')}. ` +
180
+ `Say which with --project <name>.`, EXIT.usage);
142
181
  }
143
182
  async function deployPlan(fleetId, service) {
144
183
  return (await request('GET', `/services/${service.id}/placement-preview`)).body.decision;
@@ -185,27 +224,25 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
185
224
  * follows it to conclusion rather than reporting "scheduled" and leaving the
186
225
  * operator to guess.
187
226
  */
188
- async function waitUntilRunning(fleetId, name, timeoutMs = 180_000) {
227
+ async function waitUntilRunning(fleetId, name, project) {
228
+ const service = await findService(fleetId, name, project);
189
229
  await task(`waiting for ${c.bold(name)} to come up`, async (s) => {
190
230
  s.hints([
191
231
  'the agent picks up desired state on its next poll',
192
- 'a cold image pull takes as long as the node\'s uplink does',
232
+ "a cold image pull takes as long as the node's uplink does",
193
233
  'this clears once the agent reports the container running',
194
234
  ]);
195
- const deadline = Date.now() + timeoutMs;
196
- while (Date.now() < deadline) {
197
- const current = await findService(fleetId, name)
198
- .then((svc) => svc.current)
199
- .catch(() => null);
200
- if (current?.status === 'running')
201
- return;
202
- if (current?.status === 'failed') {
203
- throw new CliError(`"${name}" did not start. \`fleet deployments ${name}\` has the reason.`, EXIT.healthCheckFailed);
204
- }
205
- await sleep(2000);
206
- }
207
- throw new CliError(`"${name}" was scheduled but has not reported running. \`fleet deployments ${name}\` has the detail.`, EXIT.healthCheckFailed);
208
- }, { done: () => `${c.bold(name)} is running` });
235
+ // The shared waiter, on the shared deadline. This used to be its own
236
+ // loop with its own three-minute deadline, which failed a deploy that
237
+ // reported running three seconds later.
238
+ await requireRunning(fleetId, service.id, name, {
239
+ onPoll: (status, elapsedMs) => {
240
+ if (!status)
241
+ return;
242
+ s.update(`${c.bold(name)} · ${status} · ${Math.round(elapsedMs / 1000)}s`);
243
+ },
244
+ });
245
+ }, { done: () => 'running' });
209
246
  }
210
247
  /**
211
248
  * The build context a service declares, if any, read from the local manifest.
@@ -234,7 +271,7 @@ export const deployCommand = {
234
271
  const [name] = args;
235
272
  if (!name)
236
273
  throw new CliError('usage: fleet deploy <service> [--sha <git-sha>] [--no-wait] [--dir <path>]', EXIT.usage);
237
- const service = await findService(fleetId, name);
274
+ const service = await findService(fleetId, name, projectFlag(flags));
238
275
  const gitSha = typeof flags.sha === 'string' ? flags.sha : undefined;
239
276
  const plan = await task('checking deployment plan', async () => deployPlan(fleetId, service));
240
277
  const viable = plan.outcome === 'placed' && Boolean(plan.nodeName);
@@ -304,7 +341,7 @@ export const deployCommand = {
304
341
  if (body.url)
305
342
  console.log(`${glyph.info} ${c.cyan(body.url)}`);
306
343
  if (!flags['no-wait'])
307
- await waitUntilRunning(fleetId, service.name);
344
+ await waitUntilRunning(fleetId, service.name, service.project);
308
345
  },
309
346
  };
310
347
  export const whereCommand = {
@@ -313,7 +350,7 @@ export const whereCommand = {
313
350
  const [name] = args;
314
351
  if (!name)
315
352
  throw new CliError('usage: fleet where <service>', EXIT.usage);
316
- const service = await findService(fleetId, name);
353
+ const service = await findService(fleetId, name, projectFlag(flags));
317
354
  const { body } = await request('GET', `/services/${service.id}/placement-preview`);
318
355
  const d = body.decision;
319
356
  if (flags.json)
@@ -346,7 +383,7 @@ export const rescheduleCommand = {
346
383
  const [name] = args;
347
384
  if (!name)
348
385
  throw new CliError('usage: fleet reschedule <service>', EXIT.usage);
349
- const service = await findService(fleetId, name);
386
+ const service = await findService(fleetId, name, projectFlag(flags));
350
387
  const { body } = await request('POST', `/services/${service.id}/reschedule`);
351
388
  console.log(`${c.green('moved')} ${service.name} → ${c.bold(body.movedTo.name)}`);
352
389
  },
@@ -375,7 +412,7 @@ export const deploymentsCommand = {
375
412
  const [name] = args;
376
413
  if (!name)
377
414
  throw new CliError('usage: fleet deployments <service>', EXIT.usage);
378
- const service = await findService(fleetId, name);
415
+ const service = await findService(fleetId, name, projectFlag(flags));
379
416
  const { body } = await request('GET', `/services/${service.id}/deployments`);
380
417
  if (flags.json)
381
418
  return console.log(JSON.stringify(body.deployments, null, 2));
@@ -396,13 +433,13 @@ export const restartCommand = {
396
433
  const [name] = args;
397
434
  if (!name)
398
435
  throw new CliError('usage: fleet restart <service>', EXIT.usage);
399
- const service = await findService(fleetId, name);
436
+ const service = await findService(fleetId, name, projectFlag(flags));
400
437
  const { body } = await request('POST', `/services/${service.id}/restart`, { body: {} });
401
438
  if (flags.json)
402
439
  return console.log(JSON.stringify(body, null, 2));
403
440
  console.log(`${glyph.ok} ${c.green('restart scheduled')} ${service.name} ${c.dim(body.deployment.id.slice(0, 8))}`);
404
441
  if (!flags['no-wait'])
405
- await waitUntilRunning(fleetId, service.name);
442
+ await waitUntilRunning(fleetId, service.name, service.project);
406
443
  },
407
444
  };
408
445
  export const rollbackCommand = {
@@ -411,7 +448,7 @@ export const rollbackCommand = {
411
448
  const [name, deploymentId] = args;
412
449
  if (!name)
413
450
  throw new CliError('usage: fleet rollback <service> [deployment-id]', EXIT.usage);
414
- const service = await findService(fleetId, name);
451
+ const service = await findService(fleetId, name, projectFlag(flags));
415
452
  if (!flags.yes && !flags.y && !(await confirmDeploy())) {
416
453
  console.log(c.dim('Rollback cancelled.'));
417
454
  return;
@@ -421,7 +458,7 @@ export const rollbackCommand = {
421
458
  return console.log(JSON.stringify(body, null, 2));
422
459
  console.log(`${glyph.ok} ${c.green('rollback scheduled')} ${service.name} ← ${c.dim(body.rolledBackTo.slice(0, 8))}`);
423
460
  if (!flags['no-wait'])
424
- await waitUntilRunning(fleetId, service.name);
461
+ await waitUntilRunning(fleetId, service.name, service.project);
425
462
  },
426
463
  };
427
464
  export const logsCommand = {
@@ -430,7 +467,7 @@ export const logsCommand = {
430
467
  const [name] = args;
431
468
  if (!name)
432
469
  throw new CliError('usage: fleet logs <service> [--follow] [--since 1h]', EXIT.usage);
433
- const service = await findService(fleetId, name);
470
+ const service = await findService(fleetId, name, projectFlag(flags));
434
471
  if (flags.since)
435
472
  console.error(c.dim('note: agent log tails are live snapshots; --since is limited to the current retained tail.'));
436
473
  let previous = '';
@@ -859,7 +896,7 @@ export const removeServiceCommand = {
859
896
  if (!name)
860
897
  throw new CliError('usage: fleet rm <service> [--yes]', EXIT.usage);
861
898
  const fleetId = await requireFleet(typeof flags.fleet === 'string' ? flags.fleet : undefined);
862
- const service = await findService(fleetId, name);
899
+ const service = await findService(fleetId, name, projectFlag(flags));
863
900
  const confirmed = flags.yes === true || flags.y === true;
864
901
  if (!confirmed) {
865
902
  if (!process.stdin.isTTY) {
@@ -972,7 +1009,7 @@ export const explainCommand = {
972
1009
  const name = args[0];
973
1010
  if (!name)
974
1011
  throw new CliError('name a service, or pass --deploy <id>', EXIT.usage);
975
- const service = await findService(id, name);
1012
+ const service = await findService(id, name, projectFlag(flags));
976
1013
  const { body } = await request('GET', `/services/${service.id}/deployments`);
977
1014
  // The most recent failure, which is what someone asking "why did that
978
1015
  // fail" means — not the most recent deployment, which may since have
@@ -14,6 +14,7 @@ import { c } from '../render.js';
14
14
  import { task, glyph } from '../ui.js';
15
15
  import { withLadder } from '../ladder.js';
16
16
  import { DEPLOY_STEPS, follow, phaseWalker } from '../progress.js';
17
+ import { requireRunning } from '../deploy-wait.js';
17
18
  import { planFromManifest, deployOrder, projectNameFor } from '../plan.js';
18
19
  import { uploadContext, humanBytes } from '../archive.js';
19
20
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -179,22 +180,21 @@ async function deployOne(service, opts) {
179
180
  // building afterwards, so this is the window in which a multi-arch
180
181
  // build has to finish - and an arm64 build emulated on an amd64 host
181
182
  // is measured in tens of minutes, not minutes.
182
- const deadline = Date.now() + 45 * 60_000;
183
- while (Date.now() < deadline) {
184
- // What the builder is doing, rather than a hint about what it might
185
- // be doing. Every field here has been reaching /progress since the
186
- // phase writer was added and nothing asked for it, so this loop sat
187
- // cycling generic advice past a reader who could have been told the
188
- // step number. Failures are swallowed: this is a label, and losing it
189
- // must not end a deploy that is going fine.
190
- const line = await request('GET', `/services/${service.id}/progress`)
191
- .then((r) => r.body)
192
- .catch(() => null);
193
- if (line && ['queued', 'building', 'pushing', 'deploying'].includes(line.status)) {
194
- // The agent's own stage once the image leaves the control plane.
195
- // Before this, everything from "pull" to "health check passed" was
196
- // one line reading "waiting for the container", which is why a
197
- // 477-second deploy was indistinguishable from a hang.
183
+ // The same waiter `fleet deploy` uses, on the same deadline. Two
184
+ // commands watching the same thing in two loops is how one of them
185
+ // ended up giving up at three minutes.
186
+ await requireRunning(opts.fleetId, service.id, service.name, {
187
+ onPoll: async () => {
188
+ // What the builder is doing, rather than a hint about what it
189
+ // might be doing. Every field here has been reaching /progress
190
+ // since the phase writer was added and nothing asked for it.
191
+ // Failures are swallowed: this is a label, and losing it must not
192
+ // end a deploy that is going fine.
193
+ const line = await request('GET', `/services/${service.id}/progress`)
194
+ .then((r) => r.body)
195
+ .catch(() => null);
196
+ if (!line || !['queued', 'building', 'pushing', 'deploying'].includes(line.status))
197
+ return;
198
198
  const parts = [line.phase ?? line.status];
199
199
  if (line.step && line.ofSteps)
200
200
  parts.push(`${line.step}/${line.ofSteps}`);
@@ -205,17 +205,8 @@ async function deployOne(service, opts) {
205
205
  s.update(`${c.bold(service.name)} · ${parts.join(' · ')}`);
206
206
  if (line.detail)
207
207
  s.hints([line.detail]);
208
- }
209
- const { body } = await request('GET', `/fleets/${opts.fleetId}/services`);
210
- const current = body.services.find((s) => s.id === service.id)?.current;
211
- if (current?.status === 'running')
212
- return;
213
- if (current?.status === 'failed') {
214
- throw new CliError(`"${service.name}" did not start. \`fleet deployments ${service.name}\` has the reason.`, EXIT.healthCheckFailed);
215
- }
216
- await sleep(2000);
217
- }
218
- throw new CliError(`"${service.name}" was scheduled but has not reported running.`, EXIT.healthCheckFailed);
208
+ },
209
+ });
219
210
  }, { done: () => `${c.bold(service.name)} is running` });
220
211
  }
221
212
  return deployResult.url;
package/dist/config.js CHANGED
@@ -23,7 +23,7 @@ export async function loadProfile() {
23
23
  catch {
24
24
  // The public control plane is the useful default for a first-time install.
25
25
  // Self-hosters and CI can always override it with FLEET_API or --api.
26
- return { api: fromEnv.api || 'https://fleetapi.plastikworld.xyz', ...fromEnv };
26
+ return { ...fromEnv, api: fromEnv.api || 'https://fleetapi.plastikworld.xyz' };
27
27
  }
28
28
  }
29
29
  export async function saveProfile(profile) {
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Waiting for a deploy to reach a terminal state, once, for every command.
3
+ *
4
+ * `fleet up` and `fleet deploy` both watched a deploy and drifted apart while
5
+ * doing it. `up` was widened to 45 minutes when it took over the build, because
6
+ * an arm64 image emulated on an amd64 control plane is measured in tens of
7
+ * minutes. `deploy` kept a three-minute deadline nobody revisited.
8
+ *
9
+ * A real deploy of landing-page started at 15:44:54 and reported running at
10
+ * 15:47:57 — 183 seconds. `fleet deploy` gave up at 181 and told the operator
11
+ * the service "was scheduled but has not reported running", about a service
12
+ * that was running. Two seconds of drift produced a false failure, and the
13
+ * message sent somebody to debug a healthy container.
14
+ *
15
+ * So the deadline is one constant and the loop is one function. Two commands
16
+ * waiting for the same thing in two places is how the drift happened, and
17
+ * sharing the constant without sharing the loop would only slow it down.
18
+ */
19
+ import { request, CliError, EXIT } from './api.js';
20
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
21
+ /**
22
+ * How long a deploy may take before the CLI stops watching.
23
+ *
24
+ * Deliberately longer than the control plane's own BUILD_TIMEOUT_MS (20
25
+ * minutes): the client must not call a deploy dead while the server is still
26
+ * legitimately working on it. The server owns "this build is stuck"; this
27
+ * number only owns "nobody is watching any more".
28
+ */
29
+ export const DEPLOY_READY_TIMEOUT_MS = 45 * 60_000;
30
+ /** The service's current deployment status, or null when it cannot be read. */
31
+ const liveStatus = (fleetId, serviceId) => request('GET', `/fleets/${fleetId}/services`)
32
+ .then((r) => r.body.services.find((s) => s.id === serviceId)?.current ?? null)
33
+ // Unreadable is not the same as failed: a blip must not end a deploy that
34
+ // is going fine, so this reads as "no answer yet" and the loop continues.
35
+ .catch(() => null);
36
+ /**
37
+ * Wait until a service is running, has failed, or the deadline passes.
38
+ *
39
+ * Three behaviours the previous loops did not have:
40
+ *
41
+ * A service that is ALREADY running returns immediately. `fleet deploy` on an
42
+ * unchanged service creates no new deployment, so waiting for a transition
43
+ * meant waiting for something that could not happen.
44
+ *
45
+ * The deadline triggers one final authoritative read before failing. The bug
46
+ * this file exists for was a state change three seconds the wrong side of the
47
+ * deadline; a poll loop that gives up without looking one more time will always
48
+ * be able to miss by a second.
49
+ *
50
+ * And a timeout is reported as a timeout, carrying the last status seen —
51
+ * never as "has not reported running", which reads as a verdict on the service
52
+ * rather than on the waiting.
53
+ */
54
+ export async function waitForRunning(fleetId, serviceId, opts = {}) {
55
+ const timeoutMs = opts.timeoutMs ?? DEPLOY_READY_TIMEOUT_MS;
56
+ const pollMs = opts.pollMs ?? 2000;
57
+ const currentOf = opts.read ?? liveStatus;
58
+ const startedAt = Date.now();
59
+ const deadline = startedAt + timeoutMs;
60
+ let last = null;
61
+ // Before waiting at all. A deploy that changed nothing has nothing to wait
62
+ // for, and the desired state is already the current one.
63
+ const already = await currentOf(fleetId, serviceId);
64
+ if (already?.status === 'running')
65
+ return { state: 'running' };
66
+ while (Date.now() < deadline) {
67
+ await opts.onPoll?.(last, Date.now() - startedAt);
68
+ const current = await currentOf(fleetId, serviceId);
69
+ last = current?.status ?? last;
70
+ if (current?.status === 'running')
71
+ return { state: 'running' };
72
+ if (current?.status === 'failed') {
73
+ return { state: 'failed', reason: current.failureReason ?? null };
74
+ }
75
+ await sleep(pollMs);
76
+ }
77
+ // One last look. The deadline is a decision to stop waiting, not evidence
78
+ // about the service, and the two are only ever a poll interval apart.
79
+ const settled = await currentOf(fleetId, serviceId);
80
+ if (settled?.status === 'running')
81
+ return { state: 'running' };
82
+ if (settled?.status === 'failed') {
83
+ return { state: 'failed', reason: settled.failureReason ?? null };
84
+ }
85
+ return { state: 'timeout', last: settled?.status ?? last, elapsedMs: Date.now() - startedAt };
86
+ }
87
+ /**
88
+ * The same wait, as an exception for callers that want one.
89
+ *
90
+ * Keeps the outcome type available to anything that would rather branch than
91
+ * catch, while giving the two commands the single line they had before.
92
+ */
93
+ export async function requireRunning(fleetId, serviceId, name, opts = {}) {
94
+ const outcome = await waitForRunning(fleetId, serviceId, opts);
95
+ if (outcome.state === 'running')
96
+ return;
97
+ if (outcome.state === 'failed') {
98
+ throw new CliError(`"${name}" did not start${outcome.reason ? `: ${outcome.reason}` : '.'} ` +
99
+ `\`fleet deployments ${name}\` has the reason.`, EXIT.healthCheckFailed);
100
+ }
101
+ const minutes = Math.round(outcome.elapsedMs / 60_000);
102
+ throw new CliError(`Stopped watching "${name}" after ${minutes} minute${minutes === 1 ? '' : 's'}; ` +
103
+ `it was last ${outcome.last ?? 'not reporting a status'}. The deploy may still be running — ` +
104
+ `\`fleet deployments ${name}\` has the current state.`, EXIT.healthCheckFailed);
105
+ }
package/dist/index.js CHANGED
@@ -77,6 +77,10 @@ const OPTIONS = [
77
77
  // Documented because it silently did nothing on `up` and `deploy` for a long
78
78
  // time, and a flag that validates but is ignored is worse than one that errors.
79
79
  ['--node <name>', 'Deploy onto this node, or say why the service cannot go there'],
80
+ // Needed since a service became (fleet, project, name): two projects may
81
+ // both have a "backend", and picking the first match silently acts on
82
+ // somebody else's.
83
+ ['--project <name>', 'Disambiguate when two projects have a service of the same name'],
80
84
  ['--api <url>', 'Control plane URL (default: saved profile)'],
81
85
  ['--json', 'Machine-readable output on stdout'],
82
86
  ['--plan, --dry-run', 'Show the deploy placement plan without changing anything'],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yadurajfleetos/cli",
3
- "version": "0.18.0",
3
+ "version": "0.18.2",
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",