@yadurajfleetos/cli 0.5.0 → 0.7.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,
@@ -403,6 +403,40 @@ export const initCommand = {
403
403
  const name = (typeof flags.name === 'string' ? flags.name : '') ||
404
404
  process.cwd().split('/').pop()?.toLowerCase().replace(/[^a-z0-9-]+/g, '-') ||
405
405
  'app';
406
+ // Read the whole repository first. A monorepo, an apps/ directory, or a
407
+ // service beside a database is the ordinary case, and describing only the
408
+ // current directory left every one of those to be written out by hand.
409
+ const { discover, manifestFromDiscovery } = await import('../discover.js');
410
+ const found = await discover();
411
+ if (found.services.length > 1 || found.databases.length) {
412
+ const { manifest, questions } = manifestFromDiscovery(found, {
413
+ fleet: typeof flags.fleet === 'string' ? flags.fleet : undefined,
414
+ node: typeof flags.node === 'string' ? flags.node : undefined,
415
+ });
416
+ await writeFile(path, manifest);
417
+ console.log(`${c.green('created')} ${path}`);
418
+ if (found.layout)
419
+ console.log(c.dim(` ${found.layout}`));
420
+ for (const s of found.services) {
421
+ // A manifest saying `build: ./web` against a directory with no
422
+ // Dockerfile is a deploy that fails at the first step. detect() has
423
+ // already worked out what the file should contain; writing it is the
424
+ // difference between a manifest and something that runs.
425
+ if (s.detection.dockerfile && !s.detection.hasDockerfile) {
426
+ const target = join(process.cwd(), s.dir, 'Dockerfile');
427
+ await writeFile(target, s.detection.dockerfile);
428
+ console.log(`${c.green('created')} ${s.dir}/Dockerfile ${c.dim(`(${s.detection.label}, port ${s.detection.port})`)}`);
429
+ }
430
+ console.log(c.dim(` · ${s.name} ${s.dir} ${s.detection.label}`));
431
+ }
432
+ for (const db of found.databases) {
433
+ console.log(c.dim(` · ${db.name} (${db.engine}) — ${db.because}`));
434
+ }
435
+ for (const q of questions)
436
+ console.log(` ${c.yellow('?')} ${q}`);
437
+ console.log(c.dim(`\n check it with: fleet apply --dry-run`));
438
+ return;
439
+ }
406
440
  const d = await detect();
407
441
  // Write a Dockerfile if we generated one and none exists.
408
442
  if (d.dockerfile) {
@@ -536,3 +570,78 @@ export const importCommand = {
536
570
  }
537
571
  },
538
572
  };
573
+ /**
574
+ * Ask why a deployment failed.
575
+ *
576
+ * The wall of Docker output is still there underneath — this adds a reading of
577
+ * it, it does not replace the evidence. Printed at the moment of failure is the
578
+ * point; `fleet explain` exists for when you have come back to it later.
579
+ */
580
+ export const explainCommand = {
581
+ async run(args, flags) {
582
+ const id = await requireFleet(typeof flags.fleet === 'string' ? flags.fleet : undefined);
583
+ let deploymentId = typeof flags.deploy === 'string' ? flags.deploy : '';
584
+ if (!deploymentId) {
585
+ const name = args[0];
586
+ if (!name)
587
+ throw new CliError('name a service, or pass --deploy <id>', EXIT.usage);
588
+ const service = await findService(id, name);
589
+ const { body } = await request('GET', `/services/${service.id}/deployments`);
590
+ // The most recent failure, which is what someone asking "why did that
591
+ // fail" means — not the most recent deployment, which may since have
592
+ // succeeded.
593
+ const failed = body.deployments.find((d) => d.status === 'failed');
594
+ if (!failed) {
595
+ throw new CliError(`"${name}" has no failed deployment to explain.`, EXIT.usage);
596
+ }
597
+ deploymentId = failed.id;
598
+ }
599
+ const out = await task('reading the failure', () => request('POST', `/fleets/${id}/deployments/${deploymentId}/explain`));
600
+ printExplanation(out.body);
601
+ },
602
+ };
603
+ /** Shared by `fleet explain` and by a deploy that just failed. */
604
+ export function printExplanation(r) {
605
+ if (r.status === 'ok') {
606
+ console.log();
607
+ for (const line of wrapText(r.summary ?? '', 76))
608
+ console.log(` ${line}`);
609
+ if (r.steps?.length) {
610
+ console.log();
611
+ r.steps.forEach((step, i) => console.log(` ${c.dim(`${i + 1}.`)} ${step}`));
612
+ }
613
+ const seen = (r.hits ?? 1) > 1 ? `seen ${r.hits}× before` : 'first time this failure has been seen';
614
+ console.log(`\n ${c.dim(`${r.cached ? 'cached' : 'explained'} · ${seen}`)}`);
615
+ if (r.usage)
616
+ console.log(` ${c.dim(`${r.usage.used}/${r.usage.limit} explanations used today`)}`);
617
+ return;
618
+ }
619
+ if (r.status === 'rate_limited') {
620
+ const hours = Math.ceil((r.resetsInSec ?? 0) / 3600);
621
+ console.log(`\n ${c.yellow('daily limit reached')} ${c.dim(`— ${r.limit} explanations a day, resets in ${hours}h.`)}`);
622
+ console.log(c.dim(' Answers already generated are still free to read.'));
623
+ return;
624
+ }
625
+ // disabled / not_worth_it / failed all carry a reason worth printing as-is.
626
+ if (r.reason)
627
+ console.log(`\n ${c.dim(r.reason)}`);
628
+ }
629
+ /** Wrap to a width, on spaces, so a paragraph reads in a terminal. */
630
+ function wrapText(text, width) {
631
+ const out = [];
632
+ for (const paragraph of text.split('\n')) {
633
+ let line = '';
634
+ for (const word of paragraph.split(/\s+/).filter(Boolean)) {
635
+ if (line && line.length + word.length + 1 > width) {
636
+ out.push(line);
637
+ line = word;
638
+ }
639
+ else {
640
+ line = line ? `${line} ${word}` : word;
641
+ }
642
+ }
643
+ if (line)
644
+ out.push(line);
645
+ }
646
+ return out;
647
+ }
@@ -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
@@ -1,8 +1,17 @@
1
1
  import { parse as parseYaml } from 'yaml';
2
+ import { safeDatabaseName } from './dbnames.js';
2
3
  /** Images Fleet manages as databases rather than as plain containers. */
3
4
  const ENGINE_IMAGES = {
4
5
  postgres: 'postgres',
5
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',
6
15
  'timescale/timescaledb': 'postgres',
7
16
  mysql: 'mysql',
8
17
  mariadb: 'mariadb',
@@ -184,13 +193,22 @@ export function composeToFleet(source, opts = {}) {
184
193
  const databases = [];
185
194
  /** Compose names that became databases, so `depends_on` can point at them. */
186
195
  const asDatabase = new Set();
196
+ /** Compose name -> the name the database is declared under, which differs
197
+ whenever the compose name would collide with its engine's own env vars. */
198
+ const dbNames = new Map();
199
+ const takenDbNames = new Set();
187
200
  // Databases first, so a service's `uses` can reference one by name.
188
201
  for (const [name, raw] of Object.entries(servicesRaw)) {
189
202
  const svc = (asRecord(raw) ?? {});
190
203
  const image = typeof svc.image === 'string' ? svc.image : '';
191
204
  const engine = image ? engineFor(image) : null;
192
- if (engine)
193
- asDatabase.add(name);
205
+ if (!engine)
206
+ continue;
207
+ asDatabase.add(name);
208
+ // Named here, in the pre-pass, not where the database is rendered: a
209
+ // service declared before it in the file still has to point at the name it
210
+ // ends up with, and the main loop would not know it yet.
211
+ dbNames.set(name, safeDatabaseName(name, engine, takenDbNames));
194
212
  }
195
213
  for (const [name, raw] of Object.entries(servicesRaw)) {
196
214
  const svc = (asRecord(raw) ?? {});
@@ -199,7 +217,11 @@ export function composeToFleet(source, opts = {}) {
199
217
  if (asDatabase.has(name)) {
200
218
  const engine = engineFor(image);
201
219
  const major = versionOf(image);
202
- const lines = [` ${name}:`];
220
+ const declared = dbNames.get(name);
221
+ if (declared !== name) {
222
+ notes.push(`${name} is declared as "${declared}": a database named after its own engine derives a password secret that collides with the engine's own environment variable, and the manifest is rejected.`);
223
+ }
224
+ const lines = [` ${declared}:`];
203
225
  lines.push(` engine: ${major ? `${engine}@${major}` : engine}`);
204
226
  if (dbNode)
205
227
  lines.push(` node: ${scalar(dbNode)}`);
@@ -295,7 +317,7 @@ export function composeToFleet(source, opts = {}) {
295
317
  const deps = Array.isArray(svc.depends_on)
296
318
  ? svc.depends_on.filter((d) => typeof d === 'string')
297
319
  : Object.keys(asRecord(svc.depends_on) ?? {});
298
- const dbDeps = deps.filter((d) => asDatabase.has(d));
320
+ const dbDeps = deps.filter((d) => asDatabase.has(d)).map((d) => dbNames.get(d) ?? d);
299
321
  const svcDeps = deps.filter((d) => !asDatabase.has(d));
300
322
  if (dbDeps.length)
301
323
  lines.push(` uses: [${dbDeps.join(', ')}]`);
@@ -0,0 +1,34 @@
1
+ /**
2
+ * What to call a managed database, when the obvious name breaks.
3
+ *
4
+ * The manifest derives a database's password secret from its name:
5
+ * `passwordRefFor("main")` is MAIN_PASSWORD. That is a good rule, and it has
6
+ * one collision. A database called `postgres` derives POSTGRES_PASSWORD, which
7
+ * is also the environment variable the Postgres image itself expects — so the
8
+ * generated service ends up with the same key in both `env` and `secrets`, and
9
+ * the parser rejects the whole manifest.
10
+ *
11
+ * It matters because `postgres:` is one of the most common service names in a
12
+ * docker-compose file, so the most ordinary input produced something the
13
+ * product refused. Renaming to `db` is also simply what a person writing this
14
+ * by hand would have done: `uses: [db, cache]` reads better than
15
+ * `uses: [postgres, redis]`.
16
+ */
17
+ /** What a person would call each engine, rather than what the image is called. */
18
+ const FRIENDLY = {
19
+ postgres: 'db',
20
+ mysql: 'db',
21
+ mariadb: 'db',
22
+ mongo: 'db',
23
+ redis: 'cache',
24
+ };
25
+ export function safeDatabaseName(preferred, engine, taken) {
26
+ const base = preferred.toLowerCase() === engine.toLowerCase() ? (FRIENDLY[engine] ?? 'db') : preferred;
27
+ // Two Postgres databases in one file both want to be `db`.
28
+ let name = base;
29
+ let n = 2;
30
+ while (taken.has(name))
31
+ name = `${base}${n++}`;
32
+ taken.add(name);
33
+ return name;
34
+ }
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
  };
@@ -0,0 +1,387 @@
1
+ import { readdir, readFile, access } from 'node:fs/promises';
2
+ import { basename, join, relative } from 'node:path';
3
+ import { detect } from './detect.js';
4
+ import { safeDatabaseName } from './dbnames.js';
5
+ const exists = async (p) => {
6
+ try {
7
+ await access(p);
8
+ return true;
9
+ }
10
+ catch {
11
+ return false;
12
+ }
13
+ };
14
+ const readText = async (p) => {
15
+ try {
16
+ return await readFile(p, 'utf8');
17
+ }
18
+ catch {
19
+ return '';
20
+ }
21
+ };
22
+ const readJson = async (p) => {
23
+ try {
24
+ return JSON.parse(await readFile(p, 'utf8'));
25
+ }
26
+ catch {
27
+ return null;
28
+ }
29
+ };
30
+ /** Directories that are never a service, whatever else they contain. */
31
+ const IGNORED = new Set([
32
+ 'node_modules', '.git', 'dist', 'build', 'target', 'vendor', '.next',
33
+ 'coverage', '__pycache__', '.venv', 'venv', 'tmp', '.turbo', '.cache',
34
+ ]);
35
+ /**
36
+ * Dependencies that mean "this service talks to a database".
37
+ *
38
+ * A driver in the dependency list is evidence, not a guess: nobody installs
39
+ * `pg` for a service that does not speak to Postgres.
40
+ */
41
+ const DB_HINTS = [
42
+ {
43
+ engine: 'postgres',
44
+ deps: [
45
+ 'pg', 'postgres', 'pg-promise', 'postgres.js', '@prisma/client', 'prisma',
46
+ 'typeorm', 'sequelize', 'drizzle-orm', 'knex', 'psycopg2', 'psycopg2-binary',
47
+ 'psycopg', 'asyncpg', 'sqlalchemy', 'django', 'lib/pq', 'jackc/pgx', 'sqlx',
48
+ 'tokio-postgres', 'diesel',
49
+ ],
50
+ },
51
+ { engine: 'redis', deps: ['redis', 'ioredis', 'redis-py', 'go-redis', 'bull', 'bullmq', 'celery'] },
52
+ { engine: 'mysql', deps: ['mysql', 'mysql2', 'mysqlclient', 'pymysql', 'go-sql-driver'] },
53
+ { engine: 'mongo', deps: ['mongodb', 'mongoose', 'pymongo', 'motor', 'mongo-driver'] },
54
+ ];
55
+ /** Dependencies that mean "this wants a GPU and a lot more memory". */
56
+ const GPU_HINTS = [
57
+ 'torch', 'pytorch', 'tensorflow', 'transformers', 'vllm', 'accelerate',
58
+ 'onnxruntime-gpu', 'jax', 'diffusers', 'sentence-transformers', 'llama-cpp-python',
59
+ ];
60
+ const SECRET_HINT = /(PASSWORD|SECRET|TOKEN|APIKEY|API_KEY|ACCESS_KEY|PRIVATE|CREDENTIAL|_DSN|SALT|CERT|_URL$)/i;
61
+ const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
62
+ /** Every dependency name a directory declares, across ecosystems. */
63
+ async function dependenciesOf(dir) {
64
+ const out = [];
65
+ const pkg = await readJson(join(dir, 'package.json'));
66
+ if (pkg) {
67
+ for (const field of ['dependencies', 'devDependencies', 'peerDependencies']) {
68
+ const deps = pkg[field];
69
+ if (deps && typeof deps === 'object')
70
+ out.push(...Object.keys(deps));
71
+ }
72
+ }
73
+ // Python: one name per line, before any version specifier.
74
+ for (const file of ['requirements.txt', 'requirements-prod.txt', 'pyproject.toml', 'Pipfile']) {
75
+ const text = await readText(join(dir, file));
76
+ for (const line of text.split('\n')) {
77
+ const name = line.trim().split(/[=<>!~\[;#"']/)[0]?.trim();
78
+ if (name && /^[A-Za-z][A-Za-z0-9._-]*$/.test(name))
79
+ out.push(name.toLowerCase());
80
+ }
81
+ }
82
+ // Go and Rust name their dependencies as paths and table keys respectively;
83
+ // a substring match on the whole file is enough to spot a driver.
84
+ for (const file of ['go.mod', 'go.sum', 'Cargo.toml']) {
85
+ const text = await readText(join(dir, file));
86
+ if (text)
87
+ out.push(...text.split('\n').map((l) => l.trim().toLowerCase()));
88
+ }
89
+ return out;
90
+ }
91
+ const mentions = (deps, needle) => deps.some((d) => d === needle || d.includes(needle));
92
+ /** Variable names a service declares it needs. */
93
+ async function envFrom(dir) {
94
+ const env = [];
95
+ const secrets = [];
96
+ for (const file of ['.env.example', '.env.sample', '.env.template']) {
97
+ const text = await readText(join(dir, file));
98
+ for (const line of text.split('\n')) {
99
+ const trimmed = line.trim();
100
+ if (!trimmed || trimmed.startsWith('#'))
101
+ continue;
102
+ const key = trimmed.split('=')[0]?.trim();
103
+ if (!key || !ENV_NAME.test(key))
104
+ continue;
105
+ // A credential and a setting are different things: one belongs in the
106
+ // manifest, the other must never appear in a file anyone commits.
107
+ if (SECRET_HINT.test(key))
108
+ secrets.push(key);
109
+ else
110
+ env.push(key);
111
+ }
112
+ }
113
+ return { env: [...new Set(env)], secrets: [...new Set(secrets)] };
114
+ }
115
+ /** Workspace globs, from whichever convention this repository uses. */
116
+ async function workspaceGlobs(root) {
117
+ const pkg = await readJson(join(root, 'package.json'));
118
+ const ws = pkg?.workspaces;
119
+ if (Array.isArray(ws))
120
+ return { layout: 'npm workspaces', globs: ws.filter((w) => typeof w === 'string') };
121
+ if (ws && typeof ws === 'object' && Array.isArray(ws.packages)) {
122
+ return { layout: 'yarn workspaces', globs: ws.packages };
123
+ }
124
+ const pnpm = await readText(join(root, 'pnpm-workspace.yaml'));
125
+ if (pnpm) {
126
+ const globs = pnpm
127
+ .split('\n')
128
+ .map((l) => l.trim())
129
+ .filter((l) => l.startsWith('- '))
130
+ .map((l) => l.slice(2).replace(/['"]/g, '').trim())
131
+ .filter(Boolean);
132
+ if (globs.length)
133
+ return { layout: 'pnpm workspace', globs };
134
+ }
135
+ const lerna = await readJson(join(root, 'lerna.json'));
136
+ if (Array.isArray(lerna?.packages))
137
+ return { layout: 'lerna', globs: lerna.packages };
138
+ const goWork = await readText(join(root, 'go.work'));
139
+ if (goWork) {
140
+ const globs = [...goWork.matchAll(/^\s*\.?\/?([\w./-]+)\s*$/gm)]
141
+ .map((m) => m[1])
142
+ .filter((p) => p !== 'go' && !p.includes('use') && p !== '.');
143
+ if (globs.length)
144
+ return { layout: 'go workspace', globs };
145
+ }
146
+ const cargo = await readText(join(root, 'Cargo.toml'));
147
+ const members = cargo.match(/members\s*=\s*\[([^\]]*)\]/);
148
+ if (members) {
149
+ const globs = [...members[1].matchAll(/["']([^"']+)["']/g)].map((m) => m[1]);
150
+ if (globs.length)
151
+ return { layout: 'cargo workspace', globs };
152
+ }
153
+ return null;
154
+ }
155
+ /**
156
+ * Expand the one glob shape workspaces actually use: a directory then `/*`.
157
+ *
158
+ * Deliberately not a glob library. `apps/*` and `packages/*` are what these
159
+ * files contain in practice, and a dependency to handle the rest is not worth
160
+ * carrying in a CLI whose whole install is one package.
161
+ */
162
+ async function expand(root, globs) {
163
+ const dirs = new Set();
164
+ for (const glob of globs) {
165
+ if (!glob.includes('*')) {
166
+ if (await exists(join(root, glob)))
167
+ dirs.add(glob);
168
+ continue;
169
+ }
170
+ const base = glob.slice(0, glob.indexOf('*')).replace(/\/$/, '');
171
+ try {
172
+ for (const entry of await readdir(join(root, base), { withFileTypes: true })) {
173
+ if (entry.isDirectory() && !IGNORED.has(entry.name))
174
+ dirs.add(join(base, entry.name));
175
+ }
176
+ }
177
+ catch {
178
+ /* a workspace glob pointing at nothing is the repository's problem */
179
+ }
180
+ }
181
+ return [...dirs];
182
+ }
183
+ /**
184
+ * Candidate directories for a repository that declares no workspaces.
185
+ *
186
+ * Two shapes, because both are common and neither is declared anywhere. The
187
+ * first is a parent holding many packages - apps/, services/, packages/. The
188
+ * second is simply a few directories at the top level: backend/ beside
189
+ * landing_page/, or api/ beside web/. Looking only for the first meant a
190
+ * perfectly ordinary two-app repository was read as one unrecognised project.
191
+ *
192
+ * Only immediate children are considered. Walking deeper finds vendored
193
+ * copies, fixtures and examples, and proposes deploying them.
194
+ */
195
+ async function conventionalDirs(root) {
196
+ const dirs = [];
197
+ for (const parent of ['apps', 'services', 'packages']) {
198
+ try {
199
+ for (const entry of await readdir(join(root, parent), { withFileTypes: true })) {
200
+ if (entry.isDirectory() && !IGNORED.has(entry.name))
201
+ dirs.push(join(parent, entry.name));
202
+ }
203
+ }
204
+ catch {
205
+ /* not this layout */
206
+ }
207
+ }
208
+ if (dirs.length)
209
+ return dirs;
210
+ try {
211
+ for (const entry of await readdir(root, { withFileTypes: true })) {
212
+ if (!entry.isDirectory() || IGNORED.has(entry.name) || entry.name.startsWith('.'))
213
+ continue;
214
+ dirs.push(entry.name);
215
+ }
216
+ }
217
+ catch {
218
+ /* unreadable root is the caller's problem */
219
+ }
220
+ return dirs;
221
+ }
222
+ /**
223
+ * Whether a directory is something to deploy.
224
+ *
225
+ * A monorepo is mostly libraries, and a manifest that tries to deploy a shared
226
+ * types package is noise the reader has to delete. Something to run says so:
227
+ * it has a Dockerfile, a start script, or a recognised framework.
228
+ */
229
+ async function isDeployable(dir, d) {
230
+ if (d.hasDockerfile)
231
+ return true;
232
+ if (d.framework !== 'unknown') {
233
+ const pkg = await readJson(join(dir, 'package.json'));
234
+ if (pkg) {
235
+ const scripts = (pkg.scripts ?? {});
236
+ // A package with no way to start is a library, whatever it depends on.
237
+ if (!scripts.start && !scripts.dev && !scripts.serve && d.framework !== 'vite')
238
+ return false;
239
+ }
240
+ return true;
241
+ }
242
+ return false;
243
+ }
244
+ const serviceName = (dir, root) => {
245
+ const raw = dir === '.' ? basename(root) : basename(dir);
246
+ return raw.toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '') || 'app';
247
+ };
248
+ export async function discover(root = process.cwd()) {
249
+ const notes = [];
250
+ const ws = await workspaceGlobs(root);
251
+ let candidates = [];
252
+ let layout = null;
253
+ if (ws) {
254
+ layout = ws.layout;
255
+ candidates = await expand(root, ws.globs);
256
+ notes.push(`${ws.layout} declares ${candidates.length} package${candidates.length === 1 ? '' : 's'}`);
257
+ }
258
+ else {
259
+ const conventional = await conventionalDirs(root);
260
+ if (conventional.length) {
261
+ layout = 'directories that look like services';
262
+ candidates = conventional;
263
+ notes.push(`no workspace file, but ${conventional.length} directories look like services`);
264
+ }
265
+ }
266
+ // Always consider the root itself: a repository can be a monorepo and still
267
+ // deploy something from its top level.
268
+ if (!candidates.includes('.'))
269
+ candidates.unshift('.');
270
+ const services = [];
271
+ const allDeps = new Set();
272
+ for (const dir of candidates) {
273
+ const abs = dir === '.' ? root : join(root, dir);
274
+ const d = await detect(abs);
275
+ const deps = await dependenciesOf(abs);
276
+ deps.forEach((x) => allDeps.add(x));
277
+ // Which engines THIS package depends on. Taking the union across the
278
+ // repository made a Next.js frontend declare it uses the database, which
279
+ // is a claim the manifest should not be making on its behalf.
280
+ const ownEngines = DB_HINTS.filter(({ deps: hints }) => hints.some((h) => mentions(deps, h))).map((x) => x.engine);
281
+ if (!(await isDeployable(abs, d)))
282
+ continue;
283
+ // A root that only exists to hold workspaces is not a service; without
284
+ // this a monorepo gains a phantom service named after the repository.
285
+ if (dir === '.' && ws && !d.hasDockerfile)
286
+ continue;
287
+ const { env, secrets } = await envFrom(abs);
288
+ const gpu = GPU_HINTS.find((h) => mentions(deps, h)) ?? null;
289
+ services.push({
290
+ name: serviceName(dir, root),
291
+ dir: dir === '.' ? '.' : `./${relative(root, abs)}`,
292
+ detection: d,
293
+ env,
294
+ secrets,
295
+ gpu: gpu ? `depends on ${gpu}` : null,
296
+ engines: ownEngines,
297
+ // A model needs room; everything else gets a modest default the reader
298
+ // can lower once they know what it actually uses.
299
+ ramMb: gpu ? 4096 : 512,
300
+ });
301
+ }
302
+ const deps = [...allDeps];
303
+ const databases = [];
304
+ const takenDbNames = new Set();
305
+ for (const { engine, deps: hints } of DB_HINTS) {
306
+ const hit = hints.find((h) => mentions(deps, h));
307
+ // Named `db` and `cache` rather than `postgres` and `redis`: a database
308
+ // named after its engine collides with that engine's own environment
309
+ // variables, and it is not what anyone would write by hand either.
310
+ if (hit) {
311
+ databases.push({
312
+ name: safeDatabaseName(engine, engine, takenDbNames),
313
+ engine,
314
+ because: `something depends on ${hit}`,
315
+ });
316
+ }
317
+ }
318
+ if (!services.length) {
319
+ notes.push('nothing deployable found — no Dockerfile, and no framework this recognises');
320
+ }
321
+ for (const db of databases)
322
+ notes.push(`${db.engine}: ${db.because}`);
323
+ return { root, layout, services, databases, notes };
324
+ }
325
+ /** Render a discovery as a manifest. */
326
+ export function manifestFromDiscovery(d, opts = {}) {
327
+ const questions = [];
328
+ const fleet = opts.fleet ?? 'homelab';
329
+ const lines = [`fleet: ${fleet}`, '', 'services:'];
330
+ d.services.forEach((s, i) => {
331
+ if (i)
332
+ lines.push('');
333
+ lines.push(` ${s.name}:`);
334
+ lines.push(` build: ${s.dir}`);
335
+ lines.push(' placement: flexible');
336
+ // Always written, never omitted as "the default". Leaving it out does not
337
+ // mean 80: an unset container port becomes 8080 on the node, so an nginx
338
+ // image serving 80 got its traffic forwarded to a closed port and answered
339
+ // 502 while every status in the system said running.
340
+ lines.push(` container_port: ${s.detection.port}`);
341
+ lines.push(` resources: { ram: ${s.ramMb}Mi, cpu: 0.5 }`);
342
+ // Only where the framework genuinely answers at the path. A guessed one
343
+ // that is wrong does not fall back to "no check" — it fails for ever and
344
+ // the deploy never leaves "deploying", while the service runs correctly.
345
+ if (s.detection.healthPath) {
346
+ lines.push(` health: { path: ${s.detection.healthPath} }`);
347
+ }
348
+ else {
349
+ lines.push(' # No health check: container state decides whether this');
350
+ lines.push(' # is up. Add one once you know a path that returns 2xx —');
351
+ lines.push(' # health: { path: /healthz }');
352
+ lines.push(' # Note the probe runs from the node, not inside the');
353
+ lines.push(' # container, so the image needs nothing installed for it.');
354
+ }
355
+ if (s.gpu) {
356
+ lines.push(' gpu: true');
357
+ questions.push(`${s.name}: ${s.gpu}, so it asks for a GPU — remove "gpu: true" if it runs on CPU.`);
358
+ }
359
+ if (s.env.length) {
360
+ lines.push(' env:');
361
+ for (const key of s.env)
362
+ lines.push(` ${key}: "" # from .env.example`);
363
+ }
364
+ if (s.secrets.length)
365
+ lines.push(` secrets: [${s.secrets.join(', ')}]`);
366
+ const mine = d.databases.filter((db) => s.engines.includes(db.engine));
367
+ if (mine.length)
368
+ lines.push(` uses: [${mine.map((db) => db.name).join(', ')}]`);
369
+ });
370
+ if (d.databases.length) {
371
+ lines.push('', 'databases:');
372
+ d.databases.forEach((db, i) => {
373
+ if (i)
374
+ lines.push('');
375
+ lines.push(` ${db.name}:`);
376
+ lines.push(` engine: ${db.engine}`);
377
+ if (opts.node)
378
+ lines.push(` node: ${opts.node}`);
379
+ else {
380
+ lines.push(' node: CHANGE_ME');
381
+ questions.push(`${db.name}: a database must name the node that holds its data — replace CHANGE_ME, or re-run with --node.`);
382
+ }
383
+ lines.push(' backup: daily');
384
+ });
385
+ }
386
+ return { manifest: lines.join('\n') + '\n', questions };
387
+ }
package/dist/index.js CHANGED
@@ -16,7 +16,7 @@ const GROUPS = [
16
16
  'getting started',
17
17
  [
18
18
  ['up [service]', 'Deploy the whole fleet.yaml, in dependency order'],
19
- ['init', 'Scaffold a fleet.yaml and Dockerfile from this repository'],
19
+ ['init', 'Read the repository — monorepo, databases, secrets — and write a fleet.yaml'],
20
20
  ['import [file]', 'Convert a docker-compose.yml into a fleet.yaml'],
21
21
  ['config show', 'Show the saved control plane and selected fleet'],
22
22
  ['use <fleet>', 'Select the default fleet for later commands'],
@@ -25,6 +25,7 @@ const GROUPS = [
25
25
  ['doctor', 'Check the control plane, nodes, services, ingress, and GitHub'],
26
26
  ['apply [file]', 'Apply a fleet.yaml to the fleet'],
27
27
  ['deploy <service>', 'Build, schedule, and roll out'],
28
+ ['explain <service>', 'Read a failed deploy and say what to do about it'],
28
29
  ],
29
30
  ],
30
31
  [
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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yadurajfleetos/cli",
3
- "version": "0.5.0",
3
+ "version": "0.7.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",