@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 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 { manifest, questions } = manifestFromDiscovery(found, {
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
- node: typeof flags.node === 'string' ? flags.node : undefined,
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] ?? ''));
@@ -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.9.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",