@yadurajfleetos/cli 0.7.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 +178 -3
- package/dist/compose.js +23 -0
- package/dist/dburl.js +54 -0
- package/dist/discover.js +11 -0
- package/dist/index.js +13 -1
- package/dist/repomap.js +121 -0
- 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
|
+
}
|
|
@@ -40,6 +40,35 @@ export const applyCommand = {
|
|
|
40
40
|
async run(args, flags) {
|
|
41
41
|
const fleetId = await requireFleet(typeof flags.fleet === 'string' ? flags.fleet : undefined);
|
|
42
42
|
const manifest = await readManifest(manifestPath(args[0]));
|
|
43
|
+
// --dry-run used to be accepted and ignored, so `fleet apply --dry-run`
|
|
44
|
+
// applied. `fleet init` prints that exact command as the safe way to check
|
|
45
|
+
// its output, which made the one command the tool recommends for looking
|
|
46
|
+
// before you leap the command that leapt. The control plane has always had
|
|
47
|
+
// an endpoint for this, labelled "validate without touching anything"; the
|
|
48
|
+
// CLI simply never called it.
|
|
49
|
+
if (flags['dry-run'] || flags.plan) {
|
|
50
|
+
const { body } = await request('POST', `/fleets/${fleetId}/services/validate`, { body: { manifest } });
|
|
51
|
+
if (flags.json)
|
|
52
|
+
return console.log(JSON.stringify(body, null, 2));
|
|
53
|
+
if (!body.valid) {
|
|
54
|
+
for (const issue of body.issues ?? []) {
|
|
55
|
+
console.log(`${glyph.fail} ${c.red('invalid')} ${issue}`);
|
|
56
|
+
}
|
|
57
|
+
throw new CliError('The manifest was not applied.', EXIT.usage);
|
|
58
|
+
}
|
|
59
|
+
console.log(`${glyph.ok} ${c.green('valid')} fleet ${c.bold(body.fleet ?? '?')}`);
|
|
60
|
+
for (const svc of body.services ?? []) {
|
|
61
|
+
// arch is empty when the manifest does not constrain it, which is the
|
|
62
|
+
// common case; printing a trailing separator for nothing reads like a
|
|
63
|
+
// value failed to load.
|
|
64
|
+
const facts = [svc.placement, `${svc.ramMb}Mi`, svc.arch?.join(', ')].filter(Boolean);
|
|
65
|
+
console.log(` ${c.bold(svc.name)} ${c.dim(facts.join(' · '))}`);
|
|
66
|
+
}
|
|
67
|
+
for (const w of body.warnings ?? []) {
|
|
68
|
+
console.log(`${glyph.warn} ${c.yellow('warning')} ${w}`);
|
|
69
|
+
}
|
|
70
|
+
return console.log(c.dim('\nnothing was changed. Drop --dry-run to apply.'));
|
|
71
|
+
}
|
|
43
72
|
const body = await task(`applying ${manifestPath(args[0])}`, async () => (await request('POST', `/fleets/${fleetId}/services`, {
|
|
44
73
|
body: { manifest, project: projectNameFor(process.cwd()) },
|
|
45
74
|
})).body, {
|
|
@@ -386,6 +415,145 @@ export const logsCommand = {
|
|
|
386
415
|
}
|
|
387
416
|
},
|
|
388
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
|
+
}
|
|
450
|
+
/**
|
|
451
|
+
* A second opinion on the draft, when --ai is given.
|
|
452
|
+
*
|
|
453
|
+
* Opt-in, because it sends a description of the repository to whatever
|
|
454
|
+
* provider the control plane is configured with, and that should never be a
|
|
455
|
+
* surprise. Never fatal: the draft is what `init` produced without it, so any
|
|
456
|
+
* failure here leaves the user exactly where they would have been anyway.
|
|
457
|
+
*
|
|
458
|
+
* The changes are printed rather than applied silently. A manifest that
|
|
459
|
+
* appeared with different ports and no explanation is worse than one with a
|
|
460
|
+
* mistake in it -- at least the mistake is yours to find.
|
|
461
|
+
*/
|
|
462
|
+
async function reviewed(draft, flags) {
|
|
463
|
+
const { repoMap } = await import('../repomap.js');
|
|
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;
|
|
469
|
+
let out;
|
|
470
|
+
try {
|
|
471
|
+
map = await repoMap();
|
|
472
|
+
out = (await review(map)).body;
|
|
473
|
+
}
|
|
474
|
+
catch (err) {
|
|
475
|
+
console.log(`${glyph.warn} ${c.yellow('review skipped')} ${err instanceof Error ? err.message : 'the control plane could not be reached'}`);
|
|
476
|
+
return draft;
|
|
477
|
+
}
|
|
478
|
+
if (out.status === 'disabled') {
|
|
479
|
+
console.log(`${glyph.warn} ${c.yellow('review skipped')} ${out.reason}`);
|
|
480
|
+
return draft;
|
|
481
|
+
}
|
|
482
|
+
if (out.status === 'rate_limited') {
|
|
483
|
+
console.log(`${glyph.warn} ${c.yellow('review skipped')} ${out.limit} reviews a day is the limit; it resets in ${Math.ceil(out.resetsInSec / 3600)}h.`);
|
|
484
|
+
return draft;
|
|
485
|
+
}
|
|
486
|
+
if (out.status === 'kept_draft') {
|
|
487
|
+
// Worth saying out loud: silence here would read as "the review agreed".
|
|
488
|
+
console.log(`${glyph.warn} ${c.yellow('kept the draft')} ${out.reason}`);
|
|
489
|
+
return draft;
|
|
490
|
+
}
|
|
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')}`);
|
|
496
|
+
for (const note of out.notes)
|
|
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
|
+
}
|
|
520
|
+
console.log(c.dim(` ${out.usage.used}/${out.usage.limit} reviews used today`));
|
|
521
|
+
return out.manifest;
|
|
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
|
+
}
|
|
389
557
|
export const initCommand = {
|
|
390
558
|
async run(args, flags) {
|
|
391
559
|
const { detect, manifestTemplate } = await import('../detect.js');
|
|
@@ -409,10 +577,13 @@ export const initCommand = {
|
|
|
409
577
|
const { discover, manifestFromDiscovery } = await import('../discover.js');
|
|
410
578
|
const found = await discover();
|
|
411
579
|
if (found.services.length > 1 || found.databases.length) {
|
|
412
|
-
const
|
|
580
|
+
const drafted = manifestFromDiscovery(found, {
|
|
413
581
|
fleet: typeof flags.fleet === 'string' ? flags.fleet : undefined,
|
|
414
|
-
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),
|
|
415
584
|
});
|
|
585
|
+
const questions = drafted.questions;
|
|
586
|
+
const manifest = flags.ai ? await reviewed(drafted.manifest, flags) : drafted.manifest;
|
|
416
587
|
await writeFile(path, manifest);
|
|
417
588
|
console.log(`${c.green('created')} ${path}`);
|
|
418
589
|
if (found.layout)
|
|
@@ -546,7 +717,11 @@ export const importCommand = {
|
|
|
546
717
|
try {
|
|
547
718
|
result = composeToFleet(text, {
|
|
548
719
|
fleet: typeof flags.fleet === 'string' ? flags.fleet : undefined,
|
|
549
|
-
|
|
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)),
|
|
550
725
|
});
|
|
551
726
|
}
|
|
552
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/discover.js
CHANGED
|
@@ -31,6 +31,17 @@ const readJson = async (p) => {
|
|
|
31
31
|
const IGNORED = new Set([
|
|
32
32
|
'node_modules', '.git', 'dist', 'build', 'target', 'vendor', '.next',
|
|
33
33
|
'coverage', '__pycache__', '.venv', 'venv', 'tmp', '.turbo', '.cache',
|
|
34
|
+
// Parts of a project rather than projects of their own.
|
|
35
|
+
//
|
|
36
|
+
// These are picked up by the immediate-children fallback, which exists for
|
|
37
|
+
// repositories that keep their services in plain top-level folders. A Vite
|
|
38
|
+
// app has src/ next to package.json, and the fallback happily proposed
|
|
39
|
+
// deploying it: a second service, built from the source directory of the
|
|
40
|
+
// first, serving raw .ts and .html instead of the built site — while the
|
|
41
|
+
// real build sat one level up. `src` is never a deployable unit on its own,
|
|
42
|
+
// and neither are the rest of these.
|
|
43
|
+
'src', 'public', 'static', 'assets', 'test', 'tests', '__tests__',
|
|
44
|
+
'migrations', 'fixtures', 'examples',
|
|
34
45
|
]);
|
|
35
46
|
/**
|
|
36
47
|
* Dependencies that mean "this service talks to a database".
|
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
|
|
@@ -17,6 +17,7 @@ const GROUPS = [
|
|
|
17
17
|
[
|
|
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
|
+
['init --ai', 'The same, then have the control plane review the draft against the repository'],
|
|
20
21
|
['import [file]', 'Convert a docker-compose.yml into a fleet.yaml'],
|
|
21
22
|
['config show', 'Show the saved control plane and selected fleet'],
|
|
22
23
|
['use <fleet>', 'Select the default fleet for later commands'],
|
|
@@ -110,6 +111,17 @@ async function main() {
|
|
|
110
111
|
// A bare `fleet` is someone asking what this is, not a malformed command.
|
|
111
112
|
process.exit(EXIT.ok);
|
|
112
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
|
+
}
|
|
113
125
|
const command = commands[name];
|
|
114
126
|
if (!command) {
|
|
115
127
|
const near = Object.keys(commands).filter((k) => k.startsWith(name[0] ?? ''));
|
package/dist/repomap.js
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { readdir, readFile, stat } from 'node:fs/promises';
|
|
2
|
+
import { join, relative } from 'node:path';
|
|
3
|
+
/**
|
|
4
|
+
* What a repository says about itself, small enough to read.
|
|
5
|
+
*
|
|
6
|
+
* `discover()` reads a repository with rules and produces a draft. This is the
|
|
7
|
+
* same repository as evidence: the tree, the files that declare dependencies
|
|
8
|
+
* and ports, and the first lines of anything already describing how to run it.
|
|
9
|
+
* The two go together — the draft says what was concluded, this says what it
|
|
10
|
+
* was concluded from, and a reviewer needs both.
|
|
11
|
+
*
|
|
12
|
+
* Deliberately not source code. A manifest is decided by package manifests,
|
|
13
|
+
* Dockerfiles, compose files and entry points; shipping the whole tree would
|
|
14
|
+
* cost tokens, leak more than anyone intended, and bury the three files that
|
|
15
|
+
* actually answer the question.
|
|
16
|
+
*/
|
|
17
|
+
/** Never read: build output, dependencies, and anything that is not evidence. */
|
|
18
|
+
const SKIP = new Set([
|
|
19
|
+
'node_modules', '.git', 'dist', 'build', 'target', 'vendor', '.next', '.nuxt',
|
|
20
|
+
'coverage', '__pycache__', '.venv', 'venv', '.turbo', '.cache', 'tmp', '.DS_Store',
|
|
21
|
+
]);
|
|
22
|
+
/**
|
|
23
|
+
* Files worth quoting, and how much of each.
|
|
24
|
+
*
|
|
25
|
+
* Entry points get a small window because the useful part — a listen() call, a
|
|
26
|
+
* route prefix, a PORT default — is near the top or in the last few lines, and
|
|
27
|
+
* the middle of a server file is business logic nobody needs to see.
|
|
28
|
+
*/
|
|
29
|
+
const EVIDENCE = [
|
|
30
|
+
{ name: /^package\.json$/, lines: 60 },
|
|
31
|
+
{ name: /^(requirements|requirements-prod)\.txt$/, lines: 40 },
|
|
32
|
+
{ name: /^(pyproject\.toml|Pipfile|go\.mod|Cargo\.toml|Gemfile)$/, lines: 40 },
|
|
33
|
+
{ name: /^Dockerfile(\..+)?$/, lines: 40 },
|
|
34
|
+
{ name: /^(docker-)?compose\.ya?ml$/, lines: 60 },
|
|
35
|
+
{ name: /^\.env\.(example|sample|template)$/, lines: 40 },
|
|
36
|
+
{ name: /^(main|server|app|index)\.(js|ts|mjs|py|go|rb)$/, lines: 40 },
|
|
37
|
+
{ name: /^(vite|next|nuxt|astro|svelte)\.config\.(js|ts|mjs)$/, lines: 25 },
|
|
38
|
+
{ name: /^README(\.md)?$/, lines: 20 },
|
|
39
|
+
];
|
|
40
|
+
const MAX_DEPTH = 3;
|
|
41
|
+
const MAX_TREE_ENTRIES = 300;
|
|
42
|
+
/** Comfortably inside the endpoint's limit, with room for the draft. */
|
|
43
|
+
const MAX_TOTAL_CHARS = 48_000;
|
|
44
|
+
function windowOf(text, lines) {
|
|
45
|
+
const all = text.split('\n');
|
|
46
|
+
if (all.length <= lines)
|
|
47
|
+
return text.trimEnd();
|
|
48
|
+
// Head and tail: a server file declares its framework at the top and starts
|
|
49
|
+
// listening at the bottom, and the port is usually in the second half.
|
|
50
|
+
const head = all.slice(0, Math.ceil(lines * 0.7)).join('\n');
|
|
51
|
+
const tail = all.slice(-Math.floor(lines * 0.3)).join('\n');
|
|
52
|
+
return `${head}\n…\n${tail}`.trimEnd();
|
|
53
|
+
}
|
|
54
|
+
/** The tree, breadth-first so the interesting top levels survive the cap. */
|
|
55
|
+
async function tree(root) {
|
|
56
|
+
const out = [];
|
|
57
|
+
let frontier = [{ dir: root, depth: 0 }];
|
|
58
|
+
while (frontier.length && out.length < MAX_TREE_ENTRIES) {
|
|
59
|
+
const next = [];
|
|
60
|
+
for (const { dir, depth } of frontier) {
|
|
61
|
+
let entries;
|
|
62
|
+
try {
|
|
63
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
for (const e of entries) {
|
|
69
|
+
if (e.name.startsWith('.') && e.name !== '.env.example')
|
|
70
|
+
continue;
|
|
71
|
+
if (SKIP.has(e.name))
|
|
72
|
+
continue;
|
|
73
|
+
const full = join(dir, e.name);
|
|
74
|
+
const rel = relative(root, full) || e.name;
|
|
75
|
+
if (out.length >= MAX_TREE_ENTRIES)
|
|
76
|
+
break;
|
|
77
|
+
out.push(e.isDirectory() ? `${rel}/` : rel);
|
|
78
|
+
if (e.isDirectory() && depth + 1 < MAX_DEPTH)
|
|
79
|
+
next.push({ dir: full, depth: depth + 1 });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
frontier = next;
|
|
83
|
+
}
|
|
84
|
+
return out.sort();
|
|
85
|
+
}
|
|
86
|
+
/** Build the evidence bundle for a repository root. */
|
|
87
|
+
export async function repoMap(root = process.cwd()) {
|
|
88
|
+
const paths = await tree(root);
|
|
89
|
+
const sections = [
|
|
90
|
+
'## Tree',
|
|
91
|
+
paths.join('\n'),
|
|
92
|
+
];
|
|
93
|
+
let budget = MAX_TOTAL_CHARS - sections.join('\n').length;
|
|
94
|
+
for (const rel of paths) {
|
|
95
|
+
if (rel.endsWith('/'))
|
|
96
|
+
continue;
|
|
97
|
+
const base = rel.split('/').pop() ?? rel;
|
|
98
|
+
const rule = EVIDENCE.find((e) => e.name.test(base));
|
|
99
|
+
if (!rule)
|
|
100
|
+
continue;
|
|
101
|
+
const full = join(root, rel);
|
|
102
|
+
try {
|
|
103
|
+
const info = await stat(full);
|
|
104
|
+
// A megabyte of lockfile-shaped JSON is not evidence.
|
|
105
|
+
if (info.size > 512 * 1024)
|
|
106
|
+
continue;
|
|
107
|
+
const text = await readFile(full, 'utf8');
|
|
108
|
+
const block = `\n## ${rel}\n${windowOf(text, rule.lines)}`;
|
|
109
|
+
// Stop cleanly at the budget rather than sending a truncated file that
|
|
110
|
+
// reads as though the repository itself is malformed.
|
|
111
|
+
if (block.length > budget)
|
|
112
|
+
break;
|
|
113
|
+
sections.push(block);
|
|
114
|
+
budget -= block.length;
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
// Unreadable is not fatal; it is simply not evidence.
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return sections.join('\n');
|
|
121
|
+
}
|