@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 +59 -0
- package/dist/commands/services.js +110 -12
- package/dist/compose.js +23 -0
- package/dist/dburl.js +54 -0
- package/dist/index.js +12 -1
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
436
|
-
out = (await
|
|
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
|
-
|
|
458
|
-
|
|
459
|
-
|
|
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
|
-
|
|
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] ?? ''));
|