@yadurajfleetos/cli 0.8.0 → 0.9.1

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
@@ -25,3 +25,62 @@ export function parseArgs(argv) {
25
25
  }
26
26
  return { positional, flags };
27
27
  }
28
+ /**
29
+ * Every flag this CLI reads, anywhere.
30
+ *
31
+ * An unknown flag used to be accepted and thrown away, which is how three
32
+ * separate features looked broken in one day: `apply --dry-run` applied,
33
+ * `init --ai` skipped the review, and both read as "the feature does not
34
+ * work" rather than "this build does not have it". A CLI that ignores what
35
+ * you typed is worse than one that refuses it — the refusal is a sentence,
36
+ * the silence is an afternoon.
37
+ *
38
+ * One list rather than a spec per command. It catches the case that actually
39
+ * bites — a flag that does not exist in the installed version, or a typo —
40
+ * without threading a declaration through twenty commands. It does not catch
41
+ * a real flag used on the wrong command; that is a smaller wrong than this.
42
+ */
43
+ export const KNOWN_FLAGS = new Set([
44
+ // global
45
+ 'fleet', 'api', 'json', 'yes', 'y', 'help', 'h', 'version', 'v', 'no-wait',
46
+ 'plan', 'dry-run', 'force',
47
+ // per command
48
+ 'ai', 'all', 'channel', 'deploy', 'email', 'events', 'f', 'follow', 'limit',
49
+ 'name', 'node', 'only', 'out', 'password', 'secret', 'service', 'sha',
50
+ 'since', 'terminal', 'to', 'token', 'url',
51
+ ]);
52
+ /** The closest known flag to a mistyped one, or null when nothing is close. */
53
+ export function nearestFlag(name) {
54
+ if (KNOWN_FLAGS.has(name))
55
+ return name;
56
+ // A prefix relationship first, because the common mistakes are a flag with
57
+ // something stuck on the end and a flag typed short. `--ai-typo` should
58
+ // suggest `--ai`, which a pure edit distance rates as five changes away and
59
+ // therefore no relation at all.
60
+ let prefixBest = null;
61
+ for (const known of KNOWN_FLAGS) {
62
+ if (known.length < 2)
63
+ continue;
64
+ if (!name.startsWith(known) && !known.startsWith(name))
65
+ continue;
66
+ if (!prefixBest || Math.abs(known.length - name.length) < Math.abs(prefixBest.length - name.length)) {
67
+ prefixBest = known;
68
+ }
69
+ }
70
+ if (prefixBest)
71
+ return prefixBest;
72
+ // Otherwise a transposition or a wrong letter: same length, few differences.
73
+ let best = null;
74
+ for (const known of KNOWN_FLAGS) {
75
+ if (Math.abs(known.length - name.length) > 1)
76
+ continue;
77
+ let wrong = Math.abs(known.length - name.length);
78
+ for (let i = 0; i < Math.min(known.length, name.length); i++) {
79
+ if (known[i] !== name[i])
80
+ wrong++;
81
+ }
82
+ if (!best || wrong < best.wrong)
83
+ best = { flag: known, wrong };
84
+ }
85
+ return best && best.wrong <= 2 ? best.flag : null;
86
+ }
@@ -52,7 +52,10 @@ export const applyCommand = {
52
52
  return console.log(JSON.stringify(body, null, 2));
53
53
  if (!body.valid) {
54
54
  for (const issue of body.issues ?? []) {
55
- console.log(`${glyph.fail} ${c.red('invalid')} ${issue}`);
55
+ const text = typeof issue === 'string'
56
+ ? issue
57
+ : [issue.path, issue.message].filter(Boolean).join(': ');
58
+ console.log(`${glyph.fail} ${c.red('invalid')} ${text}`);
56
59
  }
57
60
  throw new CliError('The manifest was not applied.', EXIT.usage);
58
61
  }
@@ -415,6 +418,38 @@ export const logsCommand = {
415
418
  }
416
419
  },
417
420
  };
421
+ /**
422
+ * The node to pin a database to, when there is only one it could be.
423
+ *
424
+ * A database has to name the node holding its data — that is the one decision
425
+ * Fleet will not make for you, because moving a database moves its disk. But
426
+ * on a fleet with a single node there is no decision to make, and writing
427
+ * CHANGE_ME there meant `init` produced a manifest whose next command fails:
428
+ *
429
+ * error The manifest names nodes that are not in this fleet
430
+ * services.db.node: no node named "CHANGE_ME" in this fleet
431
+ *
432
+ * Best effort, and quiet about it. `init` otherwise needs no control plane at
433
+ * all — it reads a directory — so a missing session, an unreachable server or
434
+ * a fleet with several nodes all fall back to the placeholder rather than
435
+ * turning a local command into one that requires the network.
436
+ */
437
+ async function theOnlyNode(flags) {
438
+ try {
439
+ const fleetId = await requireFleet(typeof flags.fleet === 'string' ? flags.fleet : undefined);
440
+ const { body } = await request('GET', `/fleets/${fleetId}/nodes`);
441
+ // Offline is fine: a node that is down still holds its disk, and that is
442
+ // what pinning is about. Only an empty fleet has nothing to choose.
443
+ if (body.nodes.length !== 1)
444
+ return undefined;
445
+ const only = body.nodes[0].name;
446
+ console.log(c.dim(` · pinned the database to ${only}, the only node in this fleet`));
447
+ return only;
448
+ }
449
+ catch {
450
+ return undefined;
451
+ }
452
+ }
418
453
  /**
419
454
  * A second opinion on the draft, when --ai is given.
420
455
  *
@@ -430,12 +465,14 @@ export const logsCommand = {
430
465
  async function reviewed(draft, flags) {
431
466
  const { repoMap } = await import('../repomap.js');
432
467
  const fleetId = await requireFleet(typeof flags.fleet === 'string' ? flags.fleet : undefined);
468
+ const review = (map, answers) => task(answers ? 'applying your answers' : 'reading the repository for a second opinion', async () => request('POST', `/fleets/${fleetId}/manifest/assist`, {
469
+ body: { draft, repoMap: map, ...(answers ? { answers } : {}) },
470
+ }), { done: () => (answers ? 'done' : 'reviewed') });
471
+ let map;
433
472
  let out;
434
473
  try {
435
- const map = await repoMap();
436
- out = (await task('reading the repository for a second opinion', async () => request('POST', `/fleets/${fleetId}/manifest/assist`, {
437
- body: { draft, repoMap: map },
438
- }), { done: () => 'reviewed' })).body;
474
+ map = await repoMap();
475
+ out = (await review(map)).body;
439
476
  }
440
477
  catch (err) {
441
478
  console.log(`${glyph.warn} ${c.yellow('review skipped')} ${err instanceof Error ? err.message : 'the control plane could not be reached'}`);
@@ -454,16 +491,72 @@ async function reviewed(draft, flags) {
454
491
  console.log(`${glyph.warn} ${c.yellow('kept the draft')} ${out.reason}`);
455
492
  return draft;
456
493
  }
457
- if (!out.changed) {
458
- console.log(`${glyph.ok} ${c.green('reviewed')} ${c.dim('nothing to change')}`);
459
- return out.manifest;
460
- }
461
- console.log(`${glyph.ok} ${c.green('reviewed')} ${c.dim(out.model)}`);
494
+ // Questions are asked whether or not anything changed. A model that could
495
+ // not settle something leaves the draft exactly as it found it and asks —
496
+ // returning early on "nothing to change" swallowed precisely the case the
497
+ // questions exist for.
498
+ console.log(`${glyph.ok} ${c.green('reviewed')} ${c.dim(out.changed ? out.model : 'nothing to change')}`);
462
499
  for (const note of out.notes)
463
500
  console.log(c.dim(` · ${note}`));
501
+ // Anything the evidence could not settle is asked rather than guessed.
502
+ //
503
+ // Only when there is somebody to ask: piped into a script, or run with
504
+ // --yes, the questions are printed as what was assumed instead. A command
505
+ // that blocks on a prompt nobody can answer is worse than one that decides.
506
+ const answered = await answerQuestions(out.questions, flags);
507
+ if (answered) {
508
+ try {
509
+ const second = (await review(map, answered)).body;
510
+ if (second.status === 'ok') {
511
+ for (const note of second.notes)
512
+ console.log(c.dim(` · ${note}`));
513
+ console.log(c.dim(` ${second.usage.used}/${second.usage.limit} reviews used today`));
514
+ return second.manifest;
515
+ }
516
+ // The second pass failing is not a reason to lose the first one.
517
+ console.log(`${glyph.warn} ${c.yellow('kept the first answer')} ${'reason' in second ? second.reason : 'the follow-up did not come back'}`);
518
+ }
519
+ catch {
520
+ console.log(`${glyph.warn} ${c.yellow('kept the first answer')} the follow-up could not be sent`);
521
+ }
522
+ }
464
523
  console.log(c.dim(` ${out.usage.used}/${out.usage.limit} reviews used today`));
465
524
  return out.manifest;
466
525
  }
526
+ /**
527
+ * Put the model's open questions to the person running the command.
528
+ *
529
+ * Returns null when there is nothing to ask, or nobody to ask — the answers
530
+ * are then left to the manifest as it stands, and what was assumed is printed
531
+ * so the omission is visible rather than silent.
532
+ */
533
+ async function answerQuestions(questions, flags) {
534
+ if (!questions.length)
535
+ return null;
536
+ const { canPrompt, select } = await import('../prompt.js');
537
+ if (flags.yes || !canPrompt()) {
538
+ console.log(c.dim(' · not asking (--yes or no terminal); left as generated:'));
539
+ for (const q of questions)
540
+ console.log(c.dim(` ? ${q.ask}`));
541
+ return null;
542
+ }
543
+ const answers = {};
544
+ for (const q of questions) {
545
+ console.log('');
546
+ if (q.why)
547
+ console.log(c.dim(` ${q.why}`));
548
+ answers[q.id] = await select(q.ask,
549
+ // "Leave it as generated" last and always present: a question with no
550
+ // way to decline is a demand, and the draft is a legitimate answer.
551
+ [
552
+ ...q.options.map((o) => ({ label: o.label, value: o.value })),
553
+ { label: 'leave it as generated', value: '' },
554
+ ]);
555
+ if (!answers[q.id])
556
+ delete answers[q.id];
557
+ }
558
+ return Object.keys(answers).length ? answers : null;
559
+ }
467
560
  export const initCommand = {
468
561
  async run(args, flags) {
469
562
  const { detect, manifestTemplate } = await import('../detect.js');
@@ -489,7 +582,8 @@ export const initCommand = {
489
582
  if (found.services.length > 1 || found.databases.length) {
490
583
  const drafted = manifestFromDiscovery(found, {
491
584
  fleet: typeof flags.fleet === 'string' ? flags.fleet : undefined,
492
- node: typeof flags.node === 'string' ? flags.node : undefined,
585
+ node: (typeof flags.node === 'string' ? flags.node : undefined) ??
586
+ (found.databases.length ? await theOnlyNode(flags) : undefined),
493
587
  });
494
588
  const questions = drafted.questions;
495
589
  const manifest = flags.ai ? await reviewed(drafted.manifest, flags) : drafted.manifest;
@@ -626,7 +720,11 @@ export const importCommand = {
626
720
  try {
627
721
  result = composeToFleet(text, {
628
722
  fleet: typeof flags.fleet === 'string' ? flags.fleet : undefined,
629
- node: typeof flags.node === 'string' ? flags.node : undefined,
723
+ // Same reasoning as init: a compose file that runs a database becomes
724
+ // a manifest that must name a node, and on a one-node fleet there is
725
+ // nothing to choose. Without this, import wrote a placeholder and the
726
+ // very next command failed on it.
727
+ node: (typeof flags.node === 'string' ? flags.node : undefined) ?? (await theOnlyNode(flags)),
630
728
  });
631
729
  }
632
730
  catch (err) {
package/dist/compose.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { parse as parseYaml } from 'yaml';
2
2
  import { safeDatabaseName } from './dbnames.js';
3
+ import { injectedUrl, pointsAt } from './dburl.js';
3
4
  /** Images Fleet manages as databases rather than as plain containers. */
4
5
  const ENGINE_IMAGES = {
5
6
  postgres: 'postgres',
@@ -196,6 +197,8 @@ export function composeToFleet(source, opts = {}) {
196
197
  /** Compose name -> the name the database is declared under, which differs
197
198
  whenever the compose name would collide with its engine's own env vars. */
198
199
  const dbNames = new Map();
200
+ /** Compose name -> engine, so a connection string can be rewritten to it. */
201
+ const dbEngines = new Map();
199
202
  const takenDbNames = new Set();
200
203
  // Databases first, so a service's `uses` can reference one by name.
201
204
  for (const [name, raw] of Object.entries(servicesRaw)) {
@@ -209,6 +212,7 @@ export function composeToFleet(source, opts = {}) {
209
212
  // service declared before it in the file still has to point at the name it
210
213
  // ends up with, and the main loop would not know it yet.
211
214
  dbNames.set(name, safeDatabaseName(name, engine, takenDbNames));
215
+ dbEngines.set(name, engine);
212
216
  }
213
217
  for (const [name, raw] of Object.entries(servicesRaw)) {
214
218
  const svc = (asRecord(raw) ?? {});
@@ -294,6 +298,25 @@ export function composeToFleet(source, opts = {}) {
294
298
  notes.push(`${name}: dropped env key "${k}" — not a usable variable name.`);
295
299
  continue;
296
300
  }
301
+ // A connection string aimed at a service that just became a managed
302
+ // database is rewritten to the URL Fleet will actually inject.
303
+ //
304
+ // Left alone it goes one of two wrong ways: copied verbatim, so the app
305
+ // dials `mongo:27017` which no longer exists, or swept into `secrets`
306
+ // because the key matches _URI, so the user is asked to supply a value
307
+ // Fleet already knows. Both deploy cleanly and fail to connect, which is
308
+ // the worst kind of wrong — nothing in the manifest looks suspicious.
309
+ const target = [...dbNames.entries()].find(([composeName]) => pointsAt(v, composeName));
310
+ if (target) {
311
+ const [composeName, fleetName] = target;
312
+ const engine = dbEngines.get(composeName);
313
+ const url = engine ? injectedUrl(fleetName, engine) : null;
314
+ if (url) {
315
+ plain.push([k, url]);
316
+ notes.push(`${name}: ${k} now points at the managed ${engine} — it named the compose service "${composeName}", which Fleet runs as "${fleetName}" with a password it generates.`);
317
+ continue;
318
+ }
319
+ }
297
320
  const unresolved = v === '' || /^\$\{?[A-Za-z_]/.test(v);
298
321
  if (SECRET_HINT.test(k) || unresolved)
299
322
  secrets.push(k);
package/dist/dburl.js ADDED
@@ -0,0 +1,54 @@
1
+ /**
2
+ * The connection URL Fleet will inject for a managed database.
3
+ *
4
+ * `uses: [db]` gives a service DATABASE_URL and friends. An imported compose
5
+ * file usually has its own variable for the same thing — MONGODB_URI,
6
+ * DATABASE_URL, REDIS_URL — pointing at the compose service that just became a
7
+ * managed database. Left alone that variable is either copied verbatim, so the
8
+ * app dials a host that no longer exists, or moved to `secrets`, so the user is
9
+ * asked to supply a value Fleet already knows. Both end in an app that deploys
10
+ * and cannot reach its database.
11
+ *
12
+ * This is deliberately a copy of what the control plane computes, because the
13
+ * CLI cannot import it — they are separate packages, and the CLI has to work
14
+ * against a control plane it did not build. The copy is kept honest by a test
15
+ * in the control plane that reads this file and fails when the two disagree,
16
+ * which is the only thing that makes duplicating it acceptable.
17
+ */
18
+ /** Mirrors ENGINES in control-plane/src/manifest/databases.ts. */
19
+ export const ENGINE_WIRE = {
20
+ postgres: { scheme: 'postgres', port: 5432, defaultUser: 'postgres', usesPassword: true, usesDatabase: true },
21
+ mysql: { scheme: 'mysql', port: 3306, defaultUser: 'app', usesPassword: true, usesDatabase: true },
22
+ mariadb: { scheme: 'mysql', port: 3306, defaultUser: 'app', usesPassword: true, usesDatabase: true },
23
+ redis: { scheme: 'redis', port: 6379, defaultUser: '', usesPassword: false, usesDatabase: false },
24
+ mongo: { scheme: 'mongodb', port: 27017, defaultUser: 'app', usesPassword: true, usesDatabase: true },
25
+ };
26
+ /** Mirrors passwordRefFor: the secret name a database's password lives under. */
27
+ export const passwordRefFor = (name) => `${name.toUpperCase().replace(/[^A-Z0-9]+/g, '_')}_PASSWORD`;
28
+ /**
29
+ * The URL a service will see, written the way the manifest can carry it.
30
+ *
31
+ * The password is a `${secret:NAME}` reference rather than a value: it is
32
+ * generated by the control plane and never known here, and a manifest is a
33
+ * file people commit.
34
+ */
35
+ export function injectedUrl(dbName, engine) {
36
+ const spec = ENGINE_WIRE[engine];
37
+ if (!spec)
38
+ return null;
39
+ const auth = spec.usesPassword
40
+ ? `${encodeURIComponent(spec.defaultUser)}:\${secret:${passwordRefFor(dbName)}}@`
41
+ : '';
42
+ // `database` defaults to the declaration's own name, and the host is that
43
+ // same name: containers resolve each other by service name on the node.
44
+ const path = spec.usesDatabase ? `/${dbName}` : '';
45
+ return `${spec.scheme}://${auth}${dbName}:${spec.port}${path}`;
46
+ }
47
+ /** Does this value look like a connection URL aimed at `host`? */
48
+ export function pointsAt(value, host) {
49
+ // Scheme-relative on purpose: an app may hold a mongodb+srv:// or a
50
+ // postgresql:// where Fleet writes postgres://, and the host is what says
51
+ // this is the same database rather than an unrelated service.
52
+ const match = value.match(/^[a-z][a-z0-9+.-]*:\/\/(?:[^@/]*@)?([^:/?#]+)/i);
53
+ return match?.[1]?.toLowerCase() === host.toLowerCase();
54
+ }
package/dist/index.js CHANGED
@@ -5,7 +5,7 @@ import { CliError, EXIT } from './api.js';
5
5
  import { c } from './render.js';
6
6
  import { banner } from './mark.js';
7
7
  import { commands } from './commands/index.js';
8
- import { parseArgs } from './args.js';
8
+ import { parseArgs, KNOWN_FLAGS, nearestFlag } from './args.js';
9
9
  export { parseArgs };
10
10
  /**
11
11
  * Grouped by the order an operator meets them, not alphabetically: the first
@@ -111,6 +111,17 @@ async function main() {
111
111
  // A bare `fleet` is someone asking what this is, not a malformed command.
112
112
  process.exit(EXIT.ok);
113
113
  }
114
+ // A flag this build does not know is refused, not ignored. Checked after
115
+ // --help so `fleet --oops --help` still explains itself.
116
+ for (const flag of Object.keys(flags)) {
117
+ if (KNOWN_FLAGS.has(flag))
118
+ continue;
119
+ const near = nearestFlag(flag);
120
+ console.error(`${c.red('unknown option')} "--${flag}"` +
121
+ (near ? `\n did you mean: --${near}?` : '') +
122
+ `\n ${c.dim('if it is a newer flag, upgrade: npm i -g @yadurajfleetos/cli@latest')}`);
123
+ process.exit(EXIT.usage);
124
+ }
114
125
  const command = commands[name];
115
126
  if (!command) {
116
127
  const near = Object.keys(commands).filter((k) => k.startsWith(name[0] ?? ''));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yadurajfleetos/cli",
3
- "version": "0.8.0",
3
+ "version": "0.9.1",
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",