@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.
@@ -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 { manifest, questions } = manifestFromDiscovery(found, {
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'],
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yadurajfleetos/cli",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
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",