@yadurajfleetos/cli 0.5.0 → 0.6.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.
@@ -403,6 +403,31 @@ 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
+ console.log(c.dim(` · ${s.name} ${s.dir} ${s.detection.label}`));
422
+ }
423
+ for (const db of found.databases) {
424
+ console.log(c.dim(` · ${db.name} (${db.engine}) — ${db.because}`));
425
+ }
426
+ for (const q of questions)
427
+ console.log(` ${c.yellow('?')} ${q}`);
428
+ console.log(c.dim(`\n check it with: fleet apply --dry-run`));
429
+ return;
430
+ }
406
431
  const d = await detect();
407
432
  // Write a Dockerfile if we generated one and none exists.
408
433
  if (d.dockerfile) {
package/dist/compose.js CHANGED
@@ -1,4 +1,5 @@
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',
@@ -184,13 +185,22 @@ export function composeToFleet(source, opts = {}) {
184
185
  const databases = [];
185
186
  /** Compose names that became databases, so `depends_on` can point at them. */
186
187
  const asDatabase = new Set();
188
+ /** Compose name -> the name the database is declared under, which differs
189
+ whenever the compose name would collide with its engine's own env vars. */
190
+ const dbNames = new Map();
191
+ const takenDbNames = new Set();
187
192
  // Databases first, so a service's `uses` can reference one by name.
188
193
  for (const [name, raw] of Object.entries(servicesRaw)) {
189
194
  const svc = (asRecord(raw) ?? {});
190
195
  const image = typeof svc.image === 'string' ? svc.image : '';
191
196
  const engine = image ? engineFor(image) : null;
192
- if (engine)
193
- asDatabase.add(name);
197
+ if (!engine)
198
+ continue;
199
+ asDatabase.add(name);
200
+ // Named here, in the pre-pass, not where the database is rendered: a
201
+ // service declared before it in the file still has to point at the name it
202
+ // ends up with, and the main loop would not know it yet.
203
+ dbNames.set(name, safeDatabaseName(name, engine, takenDbNames));
194
204
  }
195
205
  for (const [name, raw] of Object.entries(servicesRaw)) {
196
206
  const svc = (asRecord(raw) ?? {});
@@ -199,7 +209,11 @@ export function composeToFleet(source, opts = {}) {
199
209
  if (asDatabase.has(name)) {
200
210
  const engine = engineFor(image);
201
211
  const major = versionOf(image);
202
- const lines = [` ${name}:`];
212
+ const declared = dbNames.get(name);
213
+ if (declared !== name) {
214
+ 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.`);
215
+ }
216
+ const lines = [` ${declared}:`];
203
217
  lines.push(` engine: ${major ? `${engine}@${major}` : engine}`);
204
218
  if (dbNode)
205
219
  lines.push(` node: ${scalar(dbNode)}`);
@@ -295,7 +309,7 @@ export function composeToFleet(source, opts = {}) {
295
309
  const deps = Array.isArray(svc.depends_on)
296
310
  ? svc.depends_on.filter((d) => typeof d === 'string')
297
311
  : Object.keys(asRecord(svc.depends_on) ?? {});
298
- const dbDeps = deps.filter((d) => asDatabase.has(d));
312
+ const dbDeps = deps.filter((d) => asDatabase.has(d)).map((d) => dbNames.get(d) ?? d);
299
313
  const svcDeps = deps.filter((d) => !asDatabase.has(d));
300
314
  if (dbDeps.length)
301
315
  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
+ }
@@ -0,0 +1,349 @@
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
+ /** Conventional layouts, for repositories that declare no workspaces. */
184
+ async function conventionalDirs(root) {
185
+ const dirs = [];
186
+ for (const parent of ['apps', 'services', 'packages']) {
187
+ try {
188
+ for (const entry of await readdir(join(root, parent), { withFileTypes: true })) {
189
+ if (entry.isDirectory() && !IGNORED.has(entry.name))
190
+ dirs.push(join(parent, entry.name));
191
+ }
192
+ }
193
+ catch {
194
+ /* not this layout */
195
+ }
196
+ }
197
+ return dirs;
198
+ }
199
+ /**
200
+ * Whether a directory is something to deploy.
201
+ *
202
+ * A monorepo is mostly libraries, and a manifest that tries to deploy a shared
203
+ * types package is noise the reader has to delete. Something to run says so:
204
+ * it has a Dockerfile, a start script, or a recognised framework.
205
+ */
206
+ async function isDeployable(dir, d) {
207
+ if (d.hasDockerfile)
208
+ return true;
209
+ if (d.framework !== 'unknown') {
210
+ const pkg = await readJson(join(dir, 'package.json'));
211
+ if (pkg) {
212
+ const scripts = (pkg.scripts ?? {});
213
+ // A package with no way to start is a library, whatever it depends on.
214
+ if (!scripts.start && !scripts.dev && !scripts.serve && d.framework !== 'vite')
215
+ return false;
216
+ }
217
+ return true;
218
+ }
219
+ return false;
220
+ }
221
+ const serviceName = (dir, root) => {
222
+ const raw = dir === '.' ? basename(root) : basename(dir);
223
+ return raw.toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '') || 'app';
224
+ };
225
+ export async function discover(root = process.cwd()) {
226
+ const notes = [];
227
+ const ws = await workspaceGlobs(root);
228
+ let candidates = [];
229
+ let layout = null;
230
+ if (ws) {
231
+ layout = ws.layout;
232
+ candidates = await expand(root, ws.globs);
233
+ notes.push(`${ws.layout} declares ${candidates.length} package${candidates.length === 1 ? '' : 's'}`);
234
+ }
235
+ else {
236
+ const conventional = await conventionalDirs(root);
237
+ if (conventional.length) {
238
+ layout = 'apps/ and services/ directories';
239
+ candidates = conventional;
240
+ notes.push(`no workspace file, but ${conventional.length} directories look like services`);
241
+ }
242
+ }
243
+ // Always consider the root itself: a repository can be a monorepo and still
244
+ // deploy something from its top level.
245
+ if (!candidates.includes('.'))
246
+ candidates.unshift('.');
247
+ const services = [];
248
+ const allDeps = new Set();
249
+ for (const dir of candidates) {
250
+ const abs = dir === '.' ? root : join(root, dir);
251
+ const d = await detect(abs);
252
+ const deps = await dependenciesOf(abs);
253
+ deps.forEach((x) => allDeps.add(x));
254
+ // Which engines THIS package depends on. Taking the union across the
255
+ // repository made a Next.js frontend declare it uses the database, which
256
+ // is a claim the manifest should not be making on its behalf.
257
+ const ownEngines = DB_HINTS.filter(({ deps: hints }) => hints.some((h) => mentions(deps, h))).map((x) => x.engine);
258
+ if (!(await isDeployable(abs, d)))
259
+ continue;
260
+ // A root that only exists to hold workspaces is not a service; without
261
+ // this a monorepo gains a phantom service named after the repository.
262
+ if (dir === '.' && ws && !d.hasDockerfile)
263
+ continue;
264
+ const { env, secrets } = await envFrom(abs);
265
+ const gpu = GPU_HINTS.find((h) => mentions(deps, h)) ?? null;
266
+ services.push({
267
+ name: serviceName(dir, root),
268
+ dir: dir === '.' ? '.' : `./${relative(root, abs)}`,
269
+ detection: d,
270
+ env,
271
+ secrets,
272
+ gpu: gpu ? `depends on ${gpu}` : null,
273
+ engines: ownEngines,
274
+ // A model needs room; everything else gets a modest default the reader
275
+ // can lower once they know what it actually uses.
276
+ ramMb: gpu ? 4096 : 512,
277
+ });
278
+ }
279
+ const deps = [...allDeps];
280
+ const databases = [];
281
+ const takenDbNames = new Set();
282
+ for (const { engine, deps: hints } of DB_HINTS) {
283
+ const hit = hints.find((h) => mentions(deps, h));
284
+ // Named `db` and `cache` rather than `postgres` and `redis`: a database
285
+ // named after its engine collides with that engine's own environment
286
+ // variables, and it is not what anyone would write by hand either.
287
+ if (hit) {
288
+ databases.push({
289
+ name: safeDatabaseName(engine, engine, takenDbNames),
290
+ engine,
291
+ because: `something depends on ${hit}`,
292
+ });
293
+ }
294
+ }
295
+ if (!services.length) {
296
+ notes.push('nothing deployable found — no Dockerfile, and no framework this recognises');
297
+ }
298
+ for (const db of databases)
299
+ notes.push(`${db.engine}: ${db.because}`);
300
+ return { root, layout, services, databases, notes };
301
+ }
302
+ /** Render a discovery as a manifest. */
303
+ export function manifestFromDiscovery(d, opts = {}) {
304
+ const questions = [];
305
+ const fleet = opts.fleet ?? 'homelab';
306
+ const lines = [`fleet: ${fleet}`, '', 'services:'];
307
+ d.services.forEach((s, i) => {
308
+ if (i)
309
+ lines.push('');
310
+ lines.push(` ${s.name}:`);
311
+ lines.push(` build: ${s.dir}`);
312
+ lines.push(' placement: flexible');
313
+ if (s.detection.port !== 80)
314
+ lines.push(` container_port: ${s.detection.port}`);
315
+ lines.push(` resources: { ram: ${s.ramMb}Mi, cpu: 0.5 }`);
316
+ lines.push(` health: { path: ${s.detection.healthPath} }`);
317
+ if (s.gpu) {
318
+ lines.push(' gpu: true');
319
+ questions.push(`${s.name}: ${s.gpu}, so it asks for a GPU — remove "gpu: true" if it runs on CPU.`);
320
+ }
321
+ if (s.env.length) {
322
+ lines.push(' env:');
323
+ for (const key of s.env)
324
+ lines.push(` ${key}: "" # from .env.example`);
325
+ }
326
+ if (s.secrets.length)
327
+ lines.push(` secrets: [${s.secrets.join(', ')}]`);
328
+ const mine = d.databases.filter((db) => s.engines.includes(db.engine));
329
+ if (mine.length)
330
+ lines.push(` uses: [${mine.map((db) => db.name).join(', ')}]`);
331
+ });
332
+ if (d.databases.length) {
333
+ lines.push('', 'databases:');
334
+ d.databases.forEach((db, i) => {
335
+ if (i)
336
+ lines.push('');
337
+ lines.push(` ${db.name}:`);
338
+ lines.push(` engine: ${db.engine}`);
339
+ if (opts.node)
340
+ lines.push(` node: ${opts.node}`);
341
+ else {
342
+ lines.push(' node: CHANGE_ME');
343
+ questions.push(`${db.name}: a database must name the node that holds its data — replace CHANGE_ME, or re-run with --node.`);
344
+ }
345
+ lines.push(' backup: daily');
346
+ });
347
+ }
348
+ return { manifest: lines.join('\n') + '\n', questions };
349
+ }
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'],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yadurajfleetos/cli",
3
- "version": "0.5.0",
3
+ "version": "0.6.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",