@yadurajfleetos/cli 0.8.0 → 0.9.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 +59 -0
- package/dist/commands/services.js +106 -11
- 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
|
+
}
|
|
@@ -415,6 +415,38 @@ export const logsCommand = {
|
|
|
415
415
|
}
|
|
416
416
|
},
|
|
417
417
|
};
|
|
418
|
+
/**
|
|
419
|
+
* The node to pin a database to, when there is only one it could be.
|
|
420
|
+
*
|
|
421
|
+
* A database has to name the node holding its data — that is the one decision
|
|
422
|
+
* Fleet will not make for you, because moving a database moves its disk. But
|
|
423
|
+
* on a fleet with a single node there is no decision to make, and writing
|
|
424
|
+
* CHANGE_ME there meant `init` produced a manifest whose next command fails:
|
|
425
|
+
*
|
|
426
|
+
* error The manifest names nodes that are not in this fleet
|
|
427
|
+
* services.db.node: no node named "CHANGE_ME" in this fleet
|
|
428
|
+
*
|
|
429
|
+
* Best effort, and quiet about it. `init` otherwise needs no control plane at
|
|
430
|
+
* all — it reads a directory — so a missing session, an unreachable server or
|
|
431
|
+
* a fleet with several nodes all fall back to the placeholder rather than
|
|
432
|
+
* turning a local command into one that requires the network.
|
|
433
|
+
*/
|
|
434
|
+
async function theOnlyNode(flags) {
|
|
435
|
+
try {
|
|
436
|
+
const fleetId = await requireFleet(typeof flags.fleet === 'string' ? flags.fleet : undefined);
|
|
437
|
+
const { body } = await request('GET', `/fleets/${fleetId}/nodes`);
|
|
438
|
+
// Offline is fine: a node that is down still holds its disk, and that is
|
|
439
|
+
// what pinning is about. Only an empty fleet has nothing to choose.
|
|
440
|
+
if (body.nodes.length !== 1)
|
|
441
|
+
return undefined;
|
|
442
|
+
const only = body.nodes[0].name;
|
|
443
|
+
console.log(c.dim(` · pinned the database to ${only}, the only node in this fleet`));
|
|
444
|
+
return only;
|
|
445
|
+
}
|
|
446
|
+
catch {
|
|
447
|
+
return undefined;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
418
450
|
/**
|
|
419
451
|
* A second opinion on the draft, when --ai is given.
|
|
420
452
|
*
|
|
@@ -430,12 +462,14 @@ export const logsCommand = {
|
|
|
430
462
|
async function reviewed(draft, flags) {
|
|
431
463
|
const { repoMap } = await import('../repomap.js');
|
|
432
464
|
const fleetId = await requireFleet(typeof flags.fleet === 'string' ? flags.fleet : undefined);
|
|
465
|
+
const review = (map, answers) => task(answers ? 'applying your answers' : 'reading the repository for a second opinion', async () => request('POST', `/fleets/${fleetId}/manifest/assist`, {
|
|
466
|
+
body: { draft, repoMap: map, ...(answers ? { answers } : {}) },
|
|
467
|
+
}), { done: () => (answers ? 'done' : 'reviewed') });
|
|
468
|
+
let map;
|
|
433
469
|
let out;
|
|
434
470
|
try {
|
|
435
|
-
|
|
436
|
-
out = (await
|
|
437
|
-
body: { draft, repoMap: map },
|
|
438
|
-
}), { done: () => 'reviewed' })).body;
|
|
471
|
+
map = await repoMap();
|
|
472
|
+
out = (await review(map)).body;
|
|
439
473
|
}
|
|
440
474
|
catch (err) {
|
|
441
475
|
console.log(`${glyph.warn} ${c.yellow('review skipped')} ${err instanceof Error ? err.message : 'the control plane could not be reached'}`);
|
|
@@ -454,16 +488,72 @@ async function reviewed(draft, flags) {
|
|
|
454
488
|
console.log(`${glyph.warn} ${c.yellow('kept the draft')} ${out.reason}`);
|
|
455
489
|
return draft;
|
|
456
490
|
}
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
console.log(`${glyph.ok} ${c.green('reviewed')} ${c.dim(out.model)}`);
|
|
491
|
+
// Questions are asked whether or not anything changed. A model that could
|
|
492
|
+
// not settle something leaves the draft exactly as it found it and asks —
|
|
493
|
+
// returning early on "nothing to change" swallowed precisely the case the
|
|
494
|
+
// questions exist for.
|
|
495
|
+
console.log(`${glyph.ok} ${c.green('reviewed')} ${c.dim(out.changed ? out.model : 'nothing to change')}`);
|
|
462
496
|
for (const note of out.notes)
|
|
463
497
|
console.log(c.dim(` · ${note}`));
|
|
498
|
+
// Anything the evidence could not settle is asked rather than guessed.
|
|
499
|
+
//
|
|
500
|
+
// Only when there is somebody to ask: piped into a script, or run with
|
|
501
|
+
// --yes, the questions are printed as what was assumed instead. A command
|
|
502
|
+
// that blocks on a prompt nobody can answer is worse than one that decides.
|
|
503
|
+
const answered = await answerQuestions(out.questions, flags);
|
|
504
|
+
if (answered) {
|
|
505
|
+
try {
|
|
506
|
+
const second = (await review(map, answered)).body;
|
|
507
|
+
if (second.status === 'ok') {
|
|
508
|
+
for (const note of second.notes)
|
|
509
|
+
console.log(c.dim(` · ${note}`));
|
|
510
|
+
console.log(c.dim(` ${second.usage.used}/${second.usage.limit} reviews used today`));
|
|
511
|
+
return second.manifest;
|
|
512
|
+
}
|
|
513
|
+
// The second pass failing is not a reason to lose the first one.
|
|
514
|
+
console.log(`${glyph.warn} ${c.yellow('kept the first answer')} ${'reason' in second ? second.reason : 'the follow-up did not come back'}`);
|
|
515
|
+
}
|
|
516
|
+
catch {
|
|
517
|
+
console.log(`${glyph.warn} ${c.yellow('kept the first answer')} the follow-up could not be sent`);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
464
520
|
console.log(c.dim(` ${out.usage.used}/${out.usage.limit} reviews used today`));
|
|
465
521
|
return out.manifest;
|
|
466
522
|
}
|
|
523
|
+
/**
|
|
524
|
+
* Put the model's open questions to the person running the command.
|
|
525
|
+
*
|
|
526
|
+
* Returns null when there is nothing to ask, or nobody to ask — the answers
|
|
527
|
+
* are then left to the manifest as it stands, and what was assumed is printed
|
|
528
|
+
* so the omission is visible rather than silent.
|
|
529
|
+
*/
|
|
530
|
+
async function answerQuestions(questions, flags) {
|
|
531
|
+
if (!questions.length)
|
|
532
|
+
return null;
|
|
533
|
+
const { canPrompt, select } = await import('../prompt.js');
|
|
534
|
+
if (flags.yes || !canPrompt()) {
|
|
535
|
+
console.log(c.dim(' · not asking (--yes or no terminal); left as generated:'));
|
|
536
|
+
for (const q of questions)
|
|
537
|
+
console.log(c.dim(` ? ${q.ask}`));
|
|
538
|
+
return null;
|
|
539
|
+
}
|
|
540
|
+
const answers = {};
|
|
541
|
+
for (const q of questions) {
|
|
542
|
+
console.log('');
|
|
543
|
+
if (q.why)
|
|
544
|
+
console.log(c.dim(` ${q.why}`));
|
|
545
|
+
answers[q.id] = await select(q.ask,
|
|
546
|
+
// "Leave it as generated" last and always present: a question with no
|
|
547
|
+
// way to decline is a demand, and the draft is a legitimate answer.
|
|
548
|
+
[
|
|
549
|
+
...q.options.map((o) => ({ label: o.label, value: o.value })),
|
|
550
|
+
{ label: 'leave it as generated', value: '' },
|
|
551
|
+
]);
|
|
552
|
+
if (!answers[q.id])
|
|
553
|
+
delete answers[q.id];
|
|
554
|
+
}
|
|
555
|
+
return Object.keys(answers).length ? answers : null;
|
|
556
|
+
}
|
|
467
557
|
export const initCommand = {
|
|
468
558
|
async run(args, flags) {
|
|
469
559
|
const { detect, manifestTemplate } = await import('../detect.js');
|
|
@@ -489,7 +579,8 @@ export const initCommand = {
|
|
|
489
579
|
if (found.services.length > 1 || found.databases.length) {
|
|
490
580
|
const drafted = manifestFromDiscovery(found, {
|
|
491
581
|
fleet: typeof flags.fleet === 'string' ? flags.fleet : undefined,
|
|
492
|
-
node: typeof flags.node === 'string' ? flags.node : undefined
|
|
582
|
+
node: (typeof flags.node === 'string' ? flags.node : undefined) ??
|
|
583
|
+
(found.databases.length ? await theOnlyNode(flags) : undefined),
|
|
493
584
|
});
|
|
494
585
|
const questions = drafted.questions;
|
|
495
586
|
const manifest = flags.ai ? await reviewed(drafted.manifest, flags) : drafted.manifest;
|
|
@@ -626,7 +717,11 @@ export const importCommand = {
|
|
|
626
717
|
try {
|
|
627
718
|
result = composeToFleet(text, {
|
|
628
719
|
fleet: typeof flags.fleet === 'string' ? flags.fleet : undefined,
|
|
629
|
-
|
|
720
|
+
// Same reasoning as init: a compose file that runs a database becomes
|
|
721
|
+
// a manifest that must name a node, and on a one-node fleet there is
|
|
722
|
+
// nothing to choose. Without this, import wrote a placeholder and the
|
|
723
|
+
// very next command failed on it.
|
|
724
|
+
node: (typeof flags.node === 'string' ? flags.node : undefined) ?? (await theOnlyNode(flags)),
|
|
630
725
|
});
|
|
631
726
|
}
|
|
632
727
|
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] ?? ''));
|