@yadurajfleetos/cli 0.6.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.
@@ -10,7 +10,7 @@ import { downCommand } from './down.js';
10
10
  import { unpairCommand, agentCommand } from './unpair.js';
11
11
  import { secretsCommand } from './secrets.js';
12
12
  import { backupCommand, backupsCommand, restoreCommand } from './backups.js';
13
- import { applyCommand, deployCommand, deploymentsCommand, initCommand, importCommand, logsCommand, removeServiceCommand, restartCommand, rollbackCommand, rescheduleCommand, servicesCommand, validateCommand, whereCommand, } from './services.js';
13
+ import { applyCommand, deployCommand, deploymentsCommand, initCommand, importCommand, explainCommand, logsCommand, removeServiceCommand, restartCommand, rollbackCommand, rescheduleCommand, servicesCommand, validateCommand, whereCommand, } from './services.js';
14
14
  export const commands = {
15
15
  up: upCommand,
16
16
  open: openCommand,
@@ -22,6 +22,7 @@ export const commands = {
22
22
  doctor: doctorCommand,
23
23
  init: initCommand,
24
24
  import: importCommand,
25
+ explain: explainCommand,
25
26
  validate: validateCommand,
26
27
  apply: applyCommand,
27
28
  status: statusCommand,
@@ -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,15 +487,26 @@ 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)
419
499
  console.log(c.dim(` ${found.layout}`));
420
500
  for (const s of found.services) {
501
+ // A manifest saying `build: ./web` against a directory with no
502
+ // Dockerfile is a deploy that fails at the first step. detect() has
503
+ // already worked out what the file should contain; writing it is the
504
+ // difference between a manifest and something that runs.
505
+ if (s.detection.dockerfile && !s.detection.hasDockerfile) {
506
+ const target = join(process.cwd(), s.dir, 'Dockerfile');
507
+ await writeFile(target, s.detection.dockerfile);
508
+ console.log(`${c.green('created')} ${s.dir}/Dockerfile ${c.dim(`(${s.detection.label}, port ${s.detection.port})`)}`);
509
+ }
421
510
  console.log(c.dim(` · ${s.name} ${s.dir} ${s.detection.label}`));
422
511
  }
423
512
  for (const db of found.databases) {
@@ -561,3 +650,78 @@ export const importCommand = {
561
650
  }
562
651
  },
563
652
  };
653
+ /**
654
+ * Ask why a deployment failed.
655
+ *
656
+ * The wall of Docker output is still there underneath — this adds a reading of
657
+ * it, it does not replace the evidence. Printed at the moment of failure is the
658
+ * point; `fleet explain` exists for when you have come back to it later.
659
+ */
660
+ export const explainCommand = {
661
+ async run(args, flags) {
662
+ const id = await requireFleet(typeof flags.fleet === 'string' ? flags.fleet : undefined);
663
+ let deploymentId = typeof flags.deploy === 'string' ? flags.deploy : '';
664
+ if (!deploymentId) {
665
+ const name = args[0];
666
+ if (!name)
667
+ throw new CliError('name a service, or pass --deploy <id>', EXIT.usage);
668
+ const service = await findService(id, name);
669
+ const { body } = await request('GET', `/services/${service.id}/deployments`);
670
+ // The most recent failure, which is what someone asking "why did that
671
+ // fail" means — not the most recent deployment, which may since have
672
+ // succeeded.
673
+ const failed = body.deployments.find((d) => d.status === 'failed');
674
+ if (!failed) {
675
+ throw new CliError(`"${name}" has no failed deployment to explain.`, EXIT.usage);
676
+ }
677
+ deploymentId = failed.id;
678
+ }
679
+ const out = await task('reading the failure', () => request('POST', `/fleets/${id}/deployments/${deploymentId}/explain`));
680
+ printExplanation(out.body);
681
+ },
682
+ };
683
+ /** Shared by `fleet explain` and by a deploy that just failed. */
684
+ export function printExplanation(r) {
685
+ if (r.status === 'ok') {
686
+ console.log();
687
+ for (const line of wrapText(r.summary ?? '', 76))
688
+ console.log(` ${line}`);
689
+ if (r.steps?.length) {
690
+ console.log();
691
+ r.steps.forEach((step, i) => console.log(` ${c.dim(`${i + 1}.`)} ${step}`));
692
+ }
693
+ const seen = (r.hits ?? 1) > 1 ? `seen ${r.hits}× before` : 'first time this failure has been seen';
694
+ console.log(`\n ${c.dim(`${r.cached ? 'cached' : 'explained'} · ${seen}`)}`);
695
+ if (r.usage)
696
+ console.log(` ${c.dim(`${r.usage.used}/${r.usage.limit} explanations used today`)}`);
697
+ return;
698
+ }
699
+ if (r.status === 'rate_limited') {
700
+ const hours = Math.ceil((r.resetsInSec ?? 0) / 3600);
701
+ console.log(`\n ${c.yellow('daily limit reached')} ${c.dim(`— ${r.limit} explanations a day, resets in ${hours}h.`)}`);
702
+ console.log(c.dim(' Answers already generated are still free to read.'));
703
+ return;
704
+ }
705
+ // disabled / not_worth_it / failed all carry a reason worth printing as-is.
706
+ if (r.reason)
707
+ console.log(`\n ${c.dim(r.reason)}`);
708
+ }
709
+ /** Wrap to a width, on spaces, so a paragraph reads in a terminal. */
710
+ function wrapText(text, width) {
711
+ const out = [];
712
+ for (const paragraph of text.split('\n')) {
713
+ let line = '';
714
+ for (const word of paragraph.split(/\s+/).filter(Boolean)) {
715
+ if (line && line.length + word.length + 1 > width) {
716
+ out.push(line);
717
+ line = word;
718
+ }
719
+ else {
720
+ line = line ? `${line} ${word}` : word;
721
+ }
722
+ }
723
+ if (line)
724
+ out.push(line);
725
+ }
726
+ return out;
727
+ }
@@ -63,6 +63,7 @@ export const upCommand = {
63
63
  // deploying one service of it and leaving the rest was never what anybody
64
64
  // wanted — it just meant typing the command again in the right order.
65
65
  const targets = args[0] ? [args[0]] : deployOrder(planned);
66
+ const isDatabase = new Set(planned.filter((p) => p.database).map((p) => p.name));
66
67
  if (!targets.length) {
67
68
  throw new CliError('The manifest declares no services to deploy.', EXIT.usage);
68
69
  }
@@ -74,12 +75,27 @@ export const upCommand = {
74
75
  }
75
76
  return service;
76
77
  });
77
- if (resolved.length > 1) {
78
- console.log(`\n ${c.dim('deploying')} ${resolved.map((s) => c.bold(s.name)).join(c.dim(' → '))}\n`);
78
+ // A database that is already serving is left alone.
79
+ //
80
+ // Redeploying one replaces a running container for no reason, and every
81
+ // service that talks to it loses its connections while it restarts. It is
82
+ // in the plan so that a database which is *not* running comes back — which
83
+ // is the case that used to need `fleet up db` by name — not so that every
84
+ // deploy of the stack restarts the database underneath it. Naming it
85
+ // explicitly still redeploys it.
86
+ const skipped = args[0]
87
+ ? []
88
+ : resolved.filter((s) => isDatabase.has(s.name) && s.current?.status === 'running');
89
+ const toDeploy = resolved.filter((s) => !skipped.includes(s));
90
+ for (const s of skipped) {
91
+ console.log(` ${c.dim('already running')} ${c.bold(s.name)}`);
92
+ }
93
+ if (toDeploy.length > 1) {
94
+ console.log(`\n ${c.dim('deploying')} ${toDeploy.map((s) => c.bold(s.name)).join(c.dim(' → '))}\n`);
79
95
  }
80
96
  const gitSha = typeof flags.sha === 'string' ? flags.sha : undefined;
81
97
  const deployed = [];
82
- for (const service of resolved) {
98
+ for (const service of toDeploy) {
83
99
  const url = await deployOne(service, {
84
100
  fleetId,
85
101
  gitSha,
@@ -148,11 +164,18 @@ async function deployOne(service, opts) {
148
164
  if (opts.wait) {
149
165
  await task(`waiting for ${c.bold(service.name)} to come up`, async (s) => {
150
166
  s.hints([
167
+ 'the image is built on the control plane, for every architecture in the fleet',
168
+ 'building for a different architecture than the control plane is emulated, and slow',
151
169
  'the agent picks up desired state on its next poll',
152
170
  "a cold image pull takes as long as the node's uplink does",
153
171
  'a service with a health check goes running once it passes, not before',
154
172
  ]);
155
- const deadline = Date.now() + 180_000;
173
+ // Long, because this now covers the build as well as the rollout.
174
+ // The control plane answers as soon as a node is chosen and keeps
175
+ // building afterwards, so this is the window in which a multi-arch
176
+ // build has to finish - and an arm64 build emulated on an amd64 host
177
+ // is measured in tens of minutes, not minutes.
178
+ const deadline = Date.now() + 45 * 60_000;
156
179
  while (Date.now() < deadline) {
157
180
  const { body } = await request('GET', `/fleets/${opts.fleetId}/services`);
158
181
  const current = body.services.find((s) => s.id === service.id)?.current;
package/dist/compose.js CHANGED
@@ -4,6 +4,14 @@ import { safeDatabaseName } from './dbnames.js';
4
4
  const ENGINE_IMAGES = {
5
5
  postgres: 'postgres',
6
6
  postgis: 'postgres',
7
+ // Postgres by another name. pgvector in particular is what anything doing
8
+ // embeddings runs, and leaving it unrecognised turned a managed database
9
+ // back into a container the reader has to look after themselves.
10
+ pgvector: 'postgres',
11
+ 'ankane/pgvector': 'postgres',
12
+ 'pgvector/pgvector': 'postgres',
13
+ 'supabase/postgres': 'postgres',
14
+ 'bitnami/postgresql': 'postgres',
7
15
  'timescale/timescaledb': 'postgres',
8
16
  mysql: 'mysql',
9
17
  mariadb: 'mariadb',
package/dist/detect.js CHANGED
@@ -135,7 +135,7 @@ export async function detect(cwd = process.cwd()) {
135
135
  framework: 'unknown',
136
136
  label: 'existing Dockerfile',
137
137
  port,
138
- healthPath: '/',
138
+ healthPath: null,
139
139
  dockerfile: null,
140
140
  hasDockerfile: true,
141
141
  };
@@ -175,7 +175,7 @@ export async function detect(cwd = process.cwd()) {
175
175
  framework: 'node',
176
176
  label: 'Node.js API',
177
177
  port: 3000,
178
- healthPath: '/health',
178
+ healthPath: null,
179
179
  dockerfile: NODE_DOCKERFILE,
180
180
  hasDockerfile: false,
181
181
  };
@@ -209,7 +209,7 @@ export async function detect(cwd = process.cwd()) {
209
209
  framework: 'python',
210
210
  label: isFastapi ? 'Python (FastAPI)' : isDjango ? 'Python (Django)' : isFlask ? 'Python (Flask)' : 'Python',
211
211
  port: 8000,
212
- healthPath: '/health',
212
+ healthPath: null,
213
213
  dockerfile: pythonDF,
214
214
  hasDockerfile: false,
215
215
  };
@@ -223,7 +223,7 @@ export async function detect(cwd = process.cwd()) {
223
223
  framework: 'go',
224
224
  label: 'Go',
225
225
  port: 8080,
226
- healthPath: '/healthz',
226
+ healthPath: null,
227
227
  dockerfile: GO_DOCKERFILE(moduleName),
228
228
  hasDockerfile: false,
229
229
  };
@@ -234,7 +234,7 @@ export async function detect(cwd = process.cwd()) {
234
234
  framework: 'rust',
235
235
  label: 'Rust',
236
236
  port: 8080,
237
- healthPath: '/healthz',
237
+ healthPath: null,
238
238
  dockerfile: RUST_DOCKERFILE,
239
239
  hasDockerfile: false,
240
240
  };
@@ -255,7 +255,7 @@ export async function detect(cwd = process.cwd()) {
255
255
  framework: 'unknown',
256
256
  label: 'unknown project',
257
257
  port: 3000,
258
- healthPath: '/',
258
+ healthPath: null,
259
259
  dockerfile: null,
260
260
  hasDockerfile: false,
261
261
  };
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".
@@ -180,7 +191,18 @@ async function expand(root, globs) {
180
191
  }
181
192
  return [...dirs];
182
193
  }
183
- /** Conventional layouts, for repositories that declare no workspaces. */
194
+ /**
195
+ * Candidate directories for a repository that declares no workspaces.
196
+ *
197
+ * Two shapes, because both are common and neither is declared anywhere. The
198
+ * first is a parent holding many packages - apps/, services/, packages/. The
199
+ * second is simply a few directories at the top level: backend/ beside
200
+ * landing_page/, or api/ beside web/. Looking only for the first meant a
201
+ * perfectly ordinary two-app repository was read as one unrecognised project.
202
+ *
203
+ * Only immediate children are considered. Walking deeper finds vendored
204
+ * copies, fixtures and examples, and proposes deploying them.
205
+ */
184
206
  async function conventionalDirs(root) {
185
207
  const dirs = [];
186
208
  for (const parent of ['apps', 'services', 'packages']) {
@@ -194,6 +216,18 @@ async function conventionalDirs(root) {
194
216
  /* not this layout */
195
217
  }
196
218
  }
219
+ if (dirs.length)
220
+ return dirs;
221
+ try {
222
+ for (const entry of await readdir(root, { withFileTypes: true })) {
223
+ if (!entry.isDirectory() || IGNORED.has(entry.name) || entry.name.startsWith('.'))
224
+ continue;
225
+ dirs.push(entry.name);
226
+ }
227
+ }
228
+ catch {
229
+ /* unreadable root is the caller's problem */
230
+ }
197
231
  return dirs;
198
232
  }
199
233
  /**
@@ -235,7 +269,7 @@ export async function discover(root = process.cwd()) {
235
269
  else {
236
270
  const conventional = await conventionalDirs(root);
237
271
  if (conventional.length) {
238
- layout = 'apps/ and services/ directories';
272
+ layout = 'directories that look like services';
239
273
  candidates = conventional;
240
274
  notes.push(`no workspace file, but ${conventional.length} directories look like services`);
241
275
  }
@@ -310,10 +344,25 @@ export function manifestFromDiscovery(d, opts = {}) {
310
344
  lines.push(` ${s.name}:`);
311
345
  lines.push(` build: ${s.dir}`);
312
346
  lines.push(' placement: flexible');
313
- if (s.detection.port !== 80)
314
- lines.push(` container_port: ${s.detection.port}`);
347
+ // Always written, never omitted as "the default". Leaving it out does not
348
+ // mean 80: an unset container port becomes 8080 on the node, so an nginx
349
+ // image serving 80 got its traffic forwarded to a closed port and answered
350
+ // 502 while every status in the system said running.
351
+ lines.push(` container_port: ${s.detection.port}`);
315
352
  lines.push(` resources: { ram: ${s.ramMb}Mi, cpu: 0.5 }`);
316
- lines.push(` health: { path: ${s.detection.healthPath} }`);
353
+ // Only where the framework genuinely answers at the path. A guessed one
354
+ // that is wrong does not fall back to "no check" — it fails for ever and
355
+ // the deploy never leaves "deploying", while the service runs correctly.
356
+ if (s.detection.healthPath) {
357
+ lines.push(` health: { path: ${s.detection.healthPath} }`);
358
+ }
359
+ else {
360
+ lines.push(' # No health check: container state decides whether this');
361
+ lines.push(' # is up. Add one once you know a path that returns 2xx —');
362
+ lines.push(' # health: { path: /healthz }');
363
+ lines.push(' # Note the probe runs from the node, not inside the');
364
+ lines.push(' # container, so the image needs nothing installed for it.');
365
+ }
317
366
  if (s.gpu) {
318
367
  lines.push(' gpu: true');
319
368
  questions.push(`${s.name}: ${s.gpu}, so it asks for a GPU — remove "gpu: true" if it runs on CPU.`);
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'],
@@ -25,6 +26,7 @@ const GROUPS = [
25
26
  ['doctor', 'Check the control plane, nodes, services, ingress, and GitHub'],
26
27
  ['apply [file]', 'Apply a fleet.yaml to the fleet'],
27
28
  ['deploy <service>', 'Build, schedule, and roll out'],
29
+ ['explain <service>', 'Read a failed deploy and say what to do about it'],
28
30
  ],
29
31
  ],
30
32
  [
package/dist/plan.js CHANGED
@@ -33,10 +33,7 @@ export function projectNameFor(dir) {
33
33
  */
34
34
  export function planFromManifest(source) {
35
35
  const doc = parseYaml(source);
36
- const services = doc?.services;
37
- if (!services || typeof services !== 'object')
38
- return [];
39
- return Object.entries(services).map(([name, raw]) => {
36
+ const services = Object.entries(doc?.services ?? {}).map(([name, raw]) => {
40
37
  const body = (raw ?? {});
41
38
  return {
42
39
  name,
@@ -44,6 +41,21 @@ export function planFromManifest(source) {
44
41
  affinity: Array.isArray(body.affinity) ? body.affinity.filter((a) => typeof a === 'string') : [],
45
42
  };
46
43
  });
44
+ // Databases are deployed too.
45
+ //
46
+ // They used to be left out entirely, on the reasoning that applying the
47
+ // manifest creates them. It does — once. After that a database whose
48
+ // deployment has failed is never revived, because nothing puts it back in
49
+ // the plan: `fleet up` walked the whole stack, skipped it, and reported
50
+ // success while the database it depended on stayed dead and every service
51
+ // using it crash-looped. Recovering needed `fleet up db` by name, which is
52
+ // knowledge the command should not require.
53
+ const databases = Object.keys(doc?.databases ?? {}).map((name) => ({
54
+ name,
55
+ affinity: [],
56
+ database: true,
57
+ }));
58
+ return [...databases, ...services];
47
59
  }
48
60
  /**
49
61
  * Which secrets the manifest says it needs, and which services want each.
@@ -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.6.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",