@yadurajfleetos/cli 0.17.1 → 0.18.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/args.js CHANGED
@@ -43,7 +43,7 @@ export function parseArgs(argv) {
43
43
  export const KNOWN_FLAGS = new Set([
44
44
  // global
45
45
  'fleet', 'api', 'json', 'yes', 'y', 'help', 'h', 'version', 'v', 'no-wait',
46
- 'plan', 'dry-run', 'force',
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
49
  'name', 'node', 'only', 'out', 'password', 'secret', 'service', 'sha',
@@ -1,11 +1,12 @@
1
1
  import { readFile, writeFile, access } from 'node:fs/promises';
2
- import { join } from 'node:path';
2
+ import { join, dirname } 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
5
  import { task, glyph } from '../ui.js';
6
6
  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
+ import { planFromDiscovery, renderPlan, toAssistPlan } from '../deployment-plan.js';
9
10
  import { planFromManifest, projectNameFor } from '../plan.js';
10
11
  import { uploadContext, humanBytes } from '../archive.js';
11
12
  import { localSource } from '../source.js';
@@ -213,10 +214,15 @@ async function waitUntilRunning(fleetId, name, timeoutMs = 180_000) {
213
214
  * repository is legitimate for a prebuilt `image:` service, and should not
214
215
  * become an error about a file the operator never needed.
215
216
  */
216
- async function buildContextFor(serviceName) {
217
+ async function buildContextFor(serviceName, manifestPath, baseDir) {
217
218
  try {
218
- const source = await readFile('fleet.yaml', 'utf8');
219
- return planFromManifest(source).find((s) => s.name === serviceName)?.build;
219
+ const file = manifestPath ?? (baseDir ? join(baseDir, 'fleet.yaml') : 'fleet.yaml');
220
+ const source = await readFile(file, 'utf8');
221
+ const buildRel = planFromManifest(source).find((s) => s.name === serviceName)?.build;
222
+ if (!buildRel)
223
+ return undefined;
224
+ const dir = baseDir ?? (manifestPath ? dirname(manifestPath) : process.cwd());
225
+ return join(dir, buildRel);
220
226
  }
221
227
  catch {
222
228
  return undefined;
@@ -227,7 +233,7 @@ export const deployCommand = {
227
233
  const fleetId = await requireFleet(typeof flags.fleet === 'string' ? flags.fleet : undefined);
228
234
  const [name] = args;
229
235
  if (!name)
230
- throw new CliError('usage: fleet deploy <service> [--sha <git-sha>] [--no-wait]', EXIT.usage);
236
+ throw new CliError('usage: fleet deploy <service> [--sha <git-sha>] [--no-wait] [--dir <path>]', EXIT.usage);
231
237
  const service = await findService(fleetId, name);
232
238
  const gitSha = typeof flags.sha === 'string' ? flags.sha : undefined;
233
239
  const plan = await task('checking deployment plan', async () => deployPlan(fleetId, service));
@@ -252,12 +258,16 @@ export const deployCommand = {
252
258
  }
253
259
  // A service that builds from source needs its directory sent, or the
254
260
  // control plane has nothing to build and says the context does not exist.
255
- // Read from the manifest here rather than from the service row, because
256
- // the build path is relative to the file the operator is standing in.
257
261
  let contextId;
258
- const buildContext = await buildContextFor(service.name);
259
- if (buildContext) {
260
- const uploaded = await task(`packaging ${c.bold(service.name)}`, async () => uploadContext(service.id, join(process.cwd(), buildContext)), { done: (r) => `uploaded ${humanBytes(r.bytes)} of build context` });
262
+ const baseDir = typeof flags.dir === 'string' ? flags.dir : undefined;
263
+ const manifestFile = typeof flags.file === 'string'
264
+ ? flags.file
265
+ : typeof flags.manifest === 'string'
266
+ ? flags.manifest
267
+ : undefined;
268
+ const buildPath = await buildContextFor(service.name, manifestFile, baseDir);
269
+ if (buildPath) {
270
+ const uploaded = await task(`packaging ${c.bold(service.name)}`, async () => uploadContext(service.id, buildPath), { done: (r) => `uploaded ${humanBytes(r.bytes)} of build context` });
261
271
  contextId = uploaded.contextId;
262
272
  }
263
273
  const body = await withLadder(DEPLOY_STEPS, async (ladder) => {
@@ -266,7 +276,16 @@ export const deployCommand = {
266
276
  onUnavailable: () => ladder.note(c.dim('live progress unavailable; continuing with the deploy request')),
267
277
  });
268
278
  try {
269
- const result = (await request('POST', `/services/${service.id}/deploy`, { body: { gitSha, contextId } })).body;
279
+ const result = (await request('POST', `/services/${service.id}/deploy`, {
280
+ body: {
281
+ gitSha,
282
+ contextId,
283
+ // Was in KNOWN_FLAGS and read by nothing, so `--node` was
284
+ // accepted and ignored and the scheduler picked whatever
285
+ // scored highest.
286
+ ...(typeof flags.node === 'string' ? { node: flags.node } : {}),
287
+ },
288
+ })).body;
270
289
  walker.finish(`scheduled onto ${result.placedOn.name}`);
271
290
  return result;
272
291
  }
@@ -440,36 +459,90 @@ export const logsCommand = {
440
459
  },
441
460
  };
442
461
  /**
443
- * The node to pin a database to, when there is only one it could be.
462
+ * The fleet's nodes, fetched once per process.
463
+ *
464
+ * `init` asks for a node for every database it found, and a repository with
465
+ * Postgres and Redis asked twice for a list that cannot change between the two
466
+ * questions.
467
+ */
468
+ let nodeCache = null;
469
+ async function fleetNodes(flags) {
470
+ if (nodeCache)
471
+ return nodeCache;
472
+ const fleetId = await requireFleet(typeof flags.fleet === 'string' ? flags.fleet : undefined);
473
+ const { body } = await request('GET', `/fleets/${fleetId}/nodes`);
474
+ nodeCache = body.nodes;
475
+ return nodeCache;
476
+ }
477
+ /** Free disk in MB, from whichever pair of numbers the agent reported. */
478
+ function freeDiskMb(n) {
479
+ const used = n.telemetry?.diskUsedMb;
480
+ const total = n.telemetry?.diskTotalMb;
481
+ if (typeof used === 'number' && typeof total === 'number' && total > 0)
482
+ return total - used;
483
+ // node.diskMb is free space already. Falling back to it rather than
484
+ // inventing a capacity: an unavailable metric stays unavailable.
485
+ return typeof n.diskMb === 'number' ? n.diskMb : undefined;
486
+ }
487
+ /**
488
+ * Which node should hold a database's data.
444
489
  *
445
- * A database has to name the node holding its data that is the one decision
446
- * Fleet will not make for you, because moving a database moves its disk. But
447
- * on a fleet with a single node there is no decision to make, and writing
448
- * CHANGE_ME there meant `init` produced a manifest whose next command fails:
490
+ * This replaces a function that answered only when the fleet had exactly one
491
+ * node and gave up otherwise writing `node: CHANGE_ME` into a file whose very
492
+ * next command then failed on it. A fleet with two nodes is the common case,
493
+ * not the exceptional one, so "I cannot choose" was the usual answer.
449
494
  *
450
- * error The manifest names nodes that are not in this fleet
451
- * services.db.node: no node named "CHANGE_ME" in this fleet
495
+ * Ranked on what a database actually needs from a machine: it must be able to
496
+ * hold the data, so free disk decides, and free RAM breaks ties. Metrics the
497
+ * agent did not report are left out of the comparison rather than defaulted —
498
+ * a node that reports no disk is not thereby a node with no disk.
452
499
  *
453
- * Best effort, and quiet about it. `init` otherwise needs no control plane at
454
- * all it reads a directory — so a missing session, an unreachable server or
455
- * a fleet with several nodes all fall back to the placeholder rather than
456
- * turning a local command into one that requires the network.
500
+ * Returns undefined only when the fleet has no nodes at all, which is the one
501
+ * case no amount of scoring can fix.
457
502
  */
458
- async function theOnlyNode(flags) {
503
+ export async function pickNodeForData(flags, what) {
504
+ let nodes;
459
505
  try {
460
- const fleetId = await requireFleet(typeof flags.fleet === 'string' ? flags.fleet : undefined);
461
- const { body } = await request('GET', `/fleets/${fleetId}/nodes`);
462
- // Offline is fine: a node that is down still holds its disk, and that is
463
- // what pinning is about. Only an empty fleet has nothing to choose.
464
- if (body.nodes.length !== 1)
465
- return undefined;
466
- const only = body.nodes[0].name;
467
- console.log(c.dim(` · pinned the database to ${only}, the only node in this fleet`));
468
- return only;
506
+ nodes = await fleetNodes(flags);
469
507
  }
470
508
  catch {
471
509
  return undefined;
472
510
  }
511
+ if (!nodes.length)
512
+ return undefined;
513
+ // An explicit --node is the operator's decision and outranks any scoring.
514
+ // It is still checked, because a typo silently ignored is how the wrong
515
+ // machine ends up holding the data.
516
+ const explicit = typeof flags.node === 'string' ? flags.node : undefined;
517
+ if (explicit) {
518
+ const match = nodes.find((n) => n.name === explicit);
519
+ if (!match) {
520
+ throw new CliError(`No node called "${explicit}" in this fleet. Known: ${nodes.map((n) => n.name).join(', ')}.`, EXIT.usage);
521
+ }
522
+ return { node: match.name, why: 'you named it with --node' };
523
+ }
524
+ if (nodes.length === 1) {
525
+ return { node: nodes[0].name, why: 'the only node in this fleet' };
526
+ }
527
+ // Offline is not disqualifying: a node that is down still holds its disk,
528
+ // and pinning is about where the data lives. It is a tie-breaker, not a gate.
529
+ const ranked = [...nodes].sort((a, b) => {
530
+ const live = Number(b.live ?? false) - Number(a.live ?? false);
531
+ if (live)
532
+ return live;
533
+ const disk = (freeDiskMb(b) ?? -1) - (freeDiskMb(a) ?? -1);
534
+ if (disk)
535
+ return disk;
536
+ return (b.ramMb ?? 0) - (a.ramMb ?? 0);
537
+ });
538
+ const best = ranked[0];
539
+ const disk = freeDiskMb(best);
540
+ const reasons = [
541
+ disk !== undefined ? `${Math.round(disk / 1024)}GB free` : 'free disk not reported',
542
+ best.live ? 'reporting' : 'not reporting',
543
+ ];
544
+ console.log(c.dim(` · ${what} pinned to ${best.name} — ${reasons.join(', ')}; change it before applying if that is wrong`));
545
+ return { node: best.name, why: reasons.join(', ') };
473
546
  }
474
547
  /**
475
548
  * A second opinion on the draft, when --ai is given.
@@ -483,7 +556,16 @@ async function theOnlyNode(flags) {
483
556
  * appeared with different ports and no explanation is worse than one with a
484
557
  * mistake in it -- at least the mistake is yours to find.
485
558
  */
486
- async function reviewed(draft, flags, services) {
559
+ async function reviewed(draft, flags, services,
560
+ /**
561
+ * What discovery already settled.
562
+ *
563
+ * Sent so the model is told the facts rather than left to read them back out
564
+ * of the draft it is being asked to correct — a reviewer shown only YAML
565
+ * treats every line as a proposal, including the ones deterministic code
566
+ * already decided and will re-check afterwards regardless.
567
+ */
568
+ plan) {
487
569
  const { repoMap } = await import('../repomap.js');
488
570
  const fleetId = await requireFleet(typeof flags.fleet === 'string' ? flags.fleet : undefined);
489
571
  // The second pass is a different, smaller question.
@@ -498,6 +580,7 @@ async function reviewed(draft, flags, services) {
498
580
  body: {
499
581
  draft: base,
500
582
  repoMap: map,
583
+ ...(plan ? { plan } : {}),
501
584
  ...(answers ? { answers } : {}),
502
585
  ...(parts ? { parts } : {}),
503
586
  },
@@ -677,14 +760,41 @@ export const initCommand = {
677
760
  const { discover, manifestFromDiscovery } = await import('../discover.js');
678
761
  const found = await discover();
679
762
  if (found.services.length > 1 || found.databases.length) {
763
+ // Resolved before the plan is built, so the plan can state where the
764
+ // data is going instead of describing a decision still to be made.
765
+ const chosen = found.databases.length
766
+ ? await pickNodeForData(flags, found.databases.map((d) => d.name).join(' and '))
767
+ : undefined;
680
768
  const drafted = manifestFromDiscovery(found, {
681
769
  fleet: typeof flags.fleet === 'string' ? flags.fleet : undefined,
682
- node: (typeof flags.node === 'string' ? flags.node : undefined) ??
683
- (found.databases.length ? await theOnlyNode(flags) : undefined),
770
+ // Resolved here, not deferred into the file. A database needs a node
771
+ // and `pickNodeForData` answers for any fleet that has one, so the
772
+ // placeholder is reachable only when the fleet has no nodes at all.
773
+ node: chosen?.node,
774
+ });
775
+ // Said before the file appears, not after. Everything here is a
776
+ // projection of what discovery already established — nothing is
777
+ // re-detected, and nothing the discovery could not settle is filled in.
778
+ const plan = planFromDiscovery(found, {
779
+ project: projectNameFor(process.cwd()),
780
+ ...(chosen ? { node: chosen.node, nodeWhy: chosen.why } : {}),
684
781
  });
782
+ for (const line of renderPlan(plan)) {
783
+ console.log(line ? c.dim(line) : '');
784
+ }
785
+ console.log('');
786
+ // Asked before anything is written, because writing is the side effect.
787
+ // `confirm` returns its `ifNoTerminal` answer when there is no tty, so a
788
+ // scripted `fleet init` keeps working exactly as it did.
789
+ if (!flags.yes &&
790
+ !flags.y &&
791
+ !(await confirm('Write this manifest?', { default: true, ifNoTerminal: true }))) {
792
+ console.log(c.dim('Nothing was written.'));
793
+ return;
794
+ }
685
795
  const questions = drafted.questions;
686
796
  const manifest = flags.ai
687
- ? await reviewed(drafted.manifest, flags, found.services.map((s) => ({ name: s.name, dir: s.dir })))
797
+ ? await reviewed(drafted.manifest, flags, found.services.map((s) => ({ name: s.name, dir: s.dir })), toAssistPlan(plan))
688
798
  : drafted.manifest;
689
799
  await writeFile(path, manifest);
690
800
  console.log(`${c.green('created')} ${path}`);
@@ -823,7 +933,7 @@ export const importCommand = {
823
933
  // a manifest that must name a node, and on a one-node fleet there is
824
934
  // nothing to choose. Without this, import wrote a placeholder and the
825
935
  // very next command failed on it.
826
- node: (typeof flags.node === 'string' ? flags.node : undefined) ?? (await theOnlyNode(flags)),
936
+ node: (await pickNodeForData(flags, 'the database'))?.node,
827
937
  });
828
938
  }
829
939
  catch (err) {
@@ -8,7 +8,7 @@
8
8
  * the operator from the loop between them.
9
9
  */
10
10
  import { readFile, writeFile, access } from 'node:fs/promises';
11
- import { join } from 'node:path';
11
+ import { join, dirname } from 'node:path';
12
12
  import { request, requireFleet, CliError, EXIT } from '../api.js';
13
13
  import { c } from '../render.js';
14
14
  import { task, glyph } from '../ui.js';
@@ -20,7 +20,12 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
20
20
  export const upCommand = {
21
21
  async run(args, flags) {
22
22
  const fleetId = await requireFleet(typeof flags.fleet === 'string' ? flags.fleet : undefined);
23
- const manifestPath = 'fleet.yaml';
23
+ const rootDir = typeof flags.dir === 'string' ? flags.dir : process.cwd();
24
+ const manifestPath = typeof flags.file === 'string'
25
+ ? flags.file
26
+ : typeof flags.manifest === 'string'
27
+ ? flags.manifest
28
+ : join(rootDir, 'fleet.yaml');
24
29
  // ── Step 1: scaffold if needed ────────────────────────────────────
25
30
  let needsApply = false;
26
31
  try {
@@ -29,14 +34,14 @@ export const upCommand = {
29
34
  catch {
30
35
  // No fleet.yaml — run the smart init inline.
31
36
  const { detect, manifestTemplate } = await import('../detect.js');
32
- const d = await task('detecting project framework', async () => detect());
37
+ const d = await task('detecting project framework', async () => detect(rootDir));
33
38
  const name = (typeof flags.name === 'string' ? flags.name : '') ||
34
39
  args[0] ||
35
- process.cwd().split('/').pop()?.toLowerCase().replace(/[^a-z0-9-]+/g, '-') ||
40
+ rootDir.split('/').pop()?.toLowerCase().replace(/[^a-z0-9-]+/g, '-') ||
36
41
  'app';
37
42
  // Write Dockerfile if generated
38
43
  if (d.dockerfile) {
39
- await writeFile(join(process.cwd(), 'Dockerfile'), d.dockerfile);
44
+ await writeFile(join(rootDir, 'Dockerfile'), d.dockerfile);
40
45
  console.log(`${glyph.ok} ${c.green('created')} Dockerfile ${c.dim(`(${d.label}, port ${d.port})`)}`);
41
46
  }
42
47
  // Write manifest
@@ -47,7 +52,7 @@ export const upCommand = {
47
52
  // ── Step 2: read and apply the manifest ───────────────────────────
48
53
  const manifest = await readFile(manifestPath, 'utf8');
49
54
  const applyResult = await task(`applying ${manifestPath}`, async () => (await request('POST', `/fleets/${fleetId}/services`, {
50
- body: { manifest, project: projectNameFor(process.cwd()) },
55
+ body: { manifest, project: projectNameFor(rootDir) },
51
56
  })).body, {
52
57
  done: (b) => b.created.length || b.updated.length
53
58
  ? `applied ${b.created.length + b.updated.length} service(s) to project ${b.project}`
@@ -76,13 +81,6 @@ export const upCommand = {
76
81
  return service;
77
82
  });
78
83
  // A database that is already serving is left alone.
79
- //
80
- // Redeploying one replaces a running container for no reason, and every
81
- // service that talks to it loses its connections while it restarts. It is
82
- // in the plan so that a database which is *not* running comes back — which
83
- // is the case that used to need `fleet up db` by name — not so that every
84
- // deploy of the stack restarts the database underneath it. Naming it
85
- // explicitly still redeploys it.
86
84
  const skipped = args[0]
87
85
  ? []
88
86
  : resolved.filter((s) => isDatabase.has(s.name) && s.current?.status === 'running');
@@ -101,6 +99,8 @@ export const upCommand = {
101
99
  gitSha,
102
100
  buildContext: buildContexts.get(service.name),
103
101
  wait: !flags['no-wait'],
102
+ ...(typeof flags.node === 'string' ? { node: flags.node } : {}),
103
+ rootDir: typeof flags.file === 'string' ? dirname(flags.file) : rootDir,
104
104
  });
105
105
  deployed.push({ service, url });
106
106
  }
@@ -123,18 +123,11 @@ export const upCommand = {
123
123
  /**
124
124
  * Deploy one service: upload its build context if it has one, run the deploy,
125
125
  * and wait for it to report running.
126
- *
127
- * Returns the URL the control plane handed back, or null for a service that
128
- * has none — an internal one, which is reached by name from its neighbours
129
- * rather than from outside.
130
126
  */
131
127
  async function deployOne(service, opts) {
132
- // A service that builds from source sends its directory first. The control
133
- // plane then builds it for every architecture the fleet has, which is the
134
- // part that is easy to get wrong by hand and silent when you do.
135
128
  let contextId;
136
129
  if (opts.buildContext) {
137
- const dir = join(process.cwd(), opts.buildContext);
130
+ const dir = join(opts.rootDir, opts.buildContext);
138
131
  const uploaded = await task(`packaging ${c.bold(service.name)}`, async () => uploadContext(service.id, dir), { done: (r) => `uploaded ${humanBytes(r.bytes)} of build context` });
139
132
  contextId = uploaded.contextId;
140
133
  }
@@ -145,7 +138,7 @@ async function deployOne(service, opts) {
145
138
  });
146
139
  try {
147
140
  const result = (await request('POST', `/services/${service.id}/deploy`, {
148
- body: { gitSha: opts.gitSha, contextId },
141
+ body: { gitSha: opts.gitSha, contextId, ...(opts.node ? { node: opts.node } : {}) },
149
142
  })).body;
150
143
  // Deliberately not walker.finish().
151
144
  //
@@ -197,8 +190,12 @@ async function deployOne(service, opts) {
197
190
  const line = await request('GET', `/services/${service.id}/progress`)
198
191
  .then((r) => r.body)
199
192
  .catch(() => null);
200
- if (line && ['queued', 'building', 'pushing'].includes(line.status)) {
201
- const parts = [line.status];
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.
198
+ const parts = [line.phase ?? line.status];
202
199
  if (line.step && line.ofSteps)
203
200
  parts.push(`${line.step}/${line.ofSteps}`);
204
201
  if (line.platform)
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Project the discovery model onto the plan.
3
+ *
4
+ * Pure, and takes an already-resolved node rather than looking one up: node
5
+ * selection talks to the control plane and belongs to the caller, which leaves
6
+ * this testable without a fleet.
7
+ */
8
+ export function planFromDiscovery(discovery, opts) {
9
+ const entries = [];
10
+ for (const svc of discovery.services) {
11
+ entries.push({
12
+ name: svc.name,
13
+ kind: 'service',
14
+ what: svc.detection.label,
15
+ ramMb: svc.ramMb,
16
+ // Discovery sizes a GPU service differently and gives everything else one
17
+ // default. That is a recommendation, not a measurement, and labelling it
18
+ // otherwise would be the invented precision this plan exists to avoid.
19
+ ramFrom: 'recommended',
20
+ placement: 'flexible',
21
+ // The engines this service's OWN dependencies imply. Discovery is already
22
+ // careful here — a frontend beside a backend must not claim to use the
23
+ // database — so this is a projection, not a second inference.
24
+ dependsOn: discovery.databases.filter((d) => svc.engines.includes(d.engine)).map((d) => d.name),
25
+ persistent: false,
26
+ });
27
+ }
28
+ for (const db of discovery.databases) {
29
+ entries.push({
30
+ name: db.name,
31
+ kind: 'database',
32
+ what: db.engine,
33
+ // Matches what `manifestFromDiscovery` writes, so the plan cannot
34
+ // describe a manifest different from the one produced.
35
+ ramMb: 512,
36
+ ramFrom: 'recommended',
37
+ placement: 'pinned',
38
+ ...(opts.node ? { node: opts.node } : {}),
39
+ ...(opts.nodeWhy ? { nodeWhy: opts.nodeWhy } : {}),
40
+ dependsOn: [],
41
+ // A database holds data by definition; that is why it is pinned at all.
42
+ persistent: true,
43
+ });
44
+ }
45
+ const limits = [];
46
+ if (discovery.databases.length) {
47
+ // Answered honestly rather than invented. `volume:` names a volume and
48
+ // `backup:` schedules a copy; neither carries a size, so a plan printing
49
+ // "20 GB" would describe a field the manifest cannot hold.
50
+ limits.push('storage size: unknown — a manifest can name a volume and a backup schedule, but has no field for its size');
51
+ }
52
+ return {
53
+ project: opts.project,
54
+ entries,
55
+ secrets: [...new Set(discovery.services.flatMap((s) => s.secrets))].sort(),
56
+ totalRamMb: entries.reduce((sum, e) => sum + e.ramMb, 0),
57
+ limits,
58
+ };
59
+ }
60
+ const size = (mb) => mb >= 1024 ? `${(mb / 1024).toFixed(mb % 1024 === 0 ? 0 : 2)} GiB` : `${mb} MiB`;
61
+ /**
62
+ * The plan as lines, for the terminal.
63
+ *
64
+ * Returns them rather than printing, so a test can read exactly what a user
65
+ * would see.
66
+ */
67
+ export function renderPlan(plan) {
68
+ const out = [`Deployment plan · ${plan.project}`];
69
+ const services = plan.entries.filter((e) => e.kind === 'service');
70
+ const databases = plan.entries.filter((e) => e.kind === 'database');
71
+ if (services.length) {
72
+ out.push('', 'Services');
73
+ for (const s of services) {
74
+ out.push(` ${s.name} ${s.what} · ${size(s.ramMb)} · ${s.placement}`);
75
+ if (s.dependsOn.length)
76
+ out.push(` uses ${s.dependsOn.join(', ')}`);
77
+ }
78
+ }
79
+ if (databases.length) {
80
+ out.push('', 'Databases');
81
+ for (const d of databases) {
82
+ // An unresolved node is stated, not hidden. It is the one thing that
83
+ // stops the manifest deploying, and the reader should meet it here
84
+ // rather than three commands later.
85
+ const where = d.node ? `→ ${d.node}` : '→ no node chosen yet';
86
+ out.push(` ${d.name} ${d.what} · ${size(d.ramMb)} · pinned ${where}`);
87
+ if (d.nodeWhy)
88
+ out.push(` ${d.nodeWhy}`);
89
+ }
90
+ }
91
+ if (plan.secrets.length) {
92
+ out.push('', `Secrets · ${plan.secrets.length} required`);
93
+ out.push(` ${plan.secrets.join(', ')}`);
94
+ out.push(' values are never read or written into the manifest');
95
+ }
96
+ out.push('', `Memory · ${size(plan.totalRamMb)} across ${plan.entries.length} containers`);
97
+ out.push(' every figure is a starting point, not a measurement');
98
+ for (const limit of plan.limits)
99
+ out.push(` ${limit}`);
100
+ return out;
101
+ }
102
+ export function toAssistPlan(plan) {
103
+ return {
104
+ project: plan.project,
105
+ entries: plan.entries.map((e) => ({
106
+ name: e.name,
107
+ kind: e.kind,
108
+ what: e.what,
109
+ ramMb: e.ramMb,
110
+ placement: e.placement,
111
+ ...(e.node ? { node: e.node } : {}),
112
+ dependsOn: e.dependsOn,
113
+ persistent: e.persistent,
114
+ })),
115
+ secrets: plan.secrets,
116
+ };
117
+ }
package/dist/index.js CHANGED
@@ -18,6 +18,7 @@ const GROUPS = [
18
18
  ['up [service]', 'Deploy the whole fleet.yaml, in dependency order'],
19
19
  ['init', 'Read the repository — monorepo, databases, secrets — and write a fleet.yaml'],
20
20
  ['init --ai', 'The same, then have the control plane review the draft against the repository'],
21
+ ['init --node <name>', 'Pin discovered databases to a specific node instead of the best-scoring one'],
21
22
  ['import [file]', 'Convert a docker-compose.yml into a fleet.yaml'],
22
23
  ['config show', 'Show the saved control plane and selected fleet'],
23
24
  ['use <fleet>', 'Select the default fleet for later commands'],
@@ -73,6 +74,9 @@ const GROUPS = [
73
74
  ];
74
75
  const OPTIONS = [
75
76
  ['--fleet <id>', 'Operate on a specific fleet'],
77
+ // Documented because it silently did nothing on `up` and `deploy` for a long
78
+ // time, and a flag that validates but is ignored is worse than one that errors.
79
+ ['--node <name>', 'Deploy onto this node, or say why the service cannot go there'],
76
80
  ['--api <url>', 'Control plane URL (default: saved profile)'],
77
81
  ['--json', 'Machine-readable output on stdout'],
78
82
  ['--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.17.1",
3
+ "version": "0.18.0",
4
4
  "description": "Fleet OS command-line interface for deploying and orchestrating services on user-owned hardware",
5
5
  "type": "module",
6
6
  "license": "MIT",