@yadurajfleetos/cli 0.7.0 → 0.8.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/commands/services.js +81 -1
- package/dist/discover.js +11 -0
- package/dist/index.js +1 -0
- package/dist/repomap.js +121 -0
- package/package.json +1 -1
|
@@ -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,55 @@ export const logsCommand = {
|
|
|
386
415
|
}
|
|
387
416
|
},
|
|
388
417
|
};
|
|
418
|
+
/**
|
|
419
|
+
* A second opinion on the draft, when --ai is given.
|
|
420
|
+
*
|
|
421
|
+
* Opt-in, because it sends a description of the repository to whatever
|
|
422
|
+
* provider the control plane is configured with, and that should never be a
|
|
423
|
+
* surprise. Never fatal: the draft is what `init` produced without it, so any
|
|
424
|
+
* failure here leaves the user exactly where they would have been anyway.
|
|
425
|
+
*
|
|
426
|
+
* The changes are printed rather than applied silently. A manifest that
|
|
427
|
+
* appeared with different ports and no explanation is worse than one with a
|
|
428
|
+
* mistake in it -- at least the mistake is yours to find.
|
|
429
|
+
*/
|
|
430
|
+
async function reviewed(draft, flags) {
|
|
431
|
+
const { repoMap } = await import('../repomap.js');
|
|
432
|
+
const fleetId = await requireFleet(typeof flags.fleet === 'string' ? flags.fleet : undefined);
|
|
433
|
+
let out;
|
|
434
|
+
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;
|
|
439
|
+
}
|
|
440
|
+
catch (err) {
|
|
441
|
+
console.log(`${glyph.warn} ${c.yellow('review skipped')} ${err instanceof Error ? err.message : 'the control plane could not be reached'}`);
|
|
442
|
+
return draft;
|
|
443
|
+
}
|
|
444
|
+
if (out.status === 'disabled') {
|
|
445
|
+
console.log(`${glyph.warn} ${c.yellow('review skipped')} ${out.reason}`);
|
|
446
|
+
return draft;
|
|
447
|
+
}
|
|
448
|
+
if (out.status === 'rate_limited') {
|
|
449
|
+
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.`);
|
|
450
|
+
return draft;
|
|
451
|
+
}
|
|
452
|
+
if (out.status === 'kept_draft') {
|
|
453
|
+
// Worth saying out loud: silence here would read as "the review agreed".
|
|
454
|
+
console.log(`${glyph.warn} ${c.yellow('kept the draft')} ${out.reason}`);
|
|
455
|
+
return draft;
|
|
456
|
+
}
|
|
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)}`);
|
|
462
|
+
for (const note of out.notes)
|
|
463
|
+
console.log(c.dim(` · ${note}`));
|
|
464
|
+
console.log(c.dim(` ${out.usage.used}/${out.usage.limit} reviews used today`));
|
|
465
|
+
return out.manifest;
|
|
466
|
+
}
|
|
389
467
|
export const initCommand = {
|
|
390
468
|
async run(args, flags) {
|
|
391
469
|
const { detect, manifestTemplate } = await import('../detect.js');
|
|
@@ -409,10 +487,12 @@ export const initCommand = {
|
|
|
409
487
|
const { discover, manifestFromDiscovery } = await import('../discover.js');
|
|
410
488
|
const found = await discover();
|
|
411
489
|
if (found.services.length > 1 || found.databases.length) {
|
|
412
|
-
const
|
|
490
|
+
const drafted = manifestFromDiscovery(found, {
|
|
413
491
|
fleet: typeof flags.fleet === 'string' ? flags.fleet : undefined,
|
|
414
492
|
node: typeof flags.node === 'string' ? flags.node : undefined,
|
|
415
493
|
});
|
|
494
|
+
const questions = drafted.questions;
|
|
495
|
+
const manifest = flags.ai ? await reviewed(drafted.manifest, flags) : drafted.manifest;
|
|
416
496
|
await writeFile(path, manifest);
|
|
417
497
|
console.log(`${c.green('created')} ${path}`);
|
|
418
498
|
if (found.layout)
|
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
|
@@ -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'],
|
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
|
+
}
|