@cat-factory/integrations 0.77.3 → 0.77.5
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/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/modules/environments/EnvironmentConnectionService.d.ts +8 -0
- package/dist/modules/environments/EnvironmentConnectionService.d.ts.map +1 -1
- package/dist/modules/environments/EnvironmentConnectionService.js +2 -1
- package/dist/modules/environments/EnvironmentConnectionService.js.map +1 -1
- package/dist/modules/environments/frontend-detect.logic.d.ts +2 -2
- package/dist/modules/environments/frontend-detect.logic.d.ts.map +1 -1
- package/dist/modules/environments/frontend-detect.logic.js +8 -74
- package/dist/modules/environments/frontend-detect.logic.js.map +1 -1
- package/dist/modules/environments/provision-detect.logic.d.ts +26 -2
- package/dist/modules/environments/provision-detect.logic.d.ts.map +1 -1
- package/dist/modules/environments/provision-detect.logic.js +133 -162
- package/dist/modules/environments/provision-detect.logic.js.map +1 -1
- package/dist/modules/sharedStack/SharedStackService.d.ts +9 -0
- package/dist/modules/sharedStack/SharedStackService.d.ts.map +1 -1
- package/dist/modules/sharedStack/SharedStackService.js +4 -1
- package/dist/modules/sharedStack/SharedStackService.js.map +1 -1
- package/package.json +6 -4
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { BudgetedRepoScanner, joinRepoPath } from '@cat-factory/kernel';
|
|
1
2
|
import { parse as parseYaml, parseAllDocuments } from 'yaml';
|
|
2
3
|
import { extractComposeProfiles, extractExternalNetworks, hasBuildDirective, } from '../compose/compose-environment.logic.js';
|
|
3
4
|
import { RepoReadError } from './repo-read-error.js';
|
|
@@ -20,7 +21,7 @@ const COMPOSE_FILES = [
|
|
|
20
21
|
'docker-compose.prod.yml',
|
|
21
22
|
'docker-compose.dev.yaml',
|
|
22
23
|
'docker-compose.dev.yml',
|
|
23
|
-
// A bare `dev.yml` base (the acme-
|
|
24
|
+
// A bare `dev.yml` base (the acme-monolith `docker/dev.yml` shape) — lowest priority so a
|
|
24
25
|
// canonical name still wins, but recognized so a complex multi-file compose repo is detected
|
|
25
26
|
// (its OS overrides `dev.<os>.override.yml` become recipe compose-file candidates).
|
|
26
27
|
'dev.yaml',
|
|
@@ -30,6 +31,10 @@ const COMPOSE_FILES = [
|
|
|
30
31
|
// it — so unlike the canonical `compose.*`/`docker-compose.*` names it is only accepted as a compose
|
|
31
32
|
// file when it actually declares a `services:` map (an empty/absent one ⇒ it isn't a compose file).
|
|
32
33
|
const AMBIGUOUS_COMPOSE_FILES = new Set(['dev.yaml', 'dev.yml']);
|
|
34
|
+
// The built-in (canonical) compose names, as a Set for a cheap membership test. A convention-added
|
|
35
|
+
// EXTRA name (not in here) is non-canonical, so — like the bare `dev.*` names — it is trusted as a
|
|
36
|
+
// compose file only when it actually declares `services:` (see `findCompose`).
|
|
37
|
+
const COMPOSE_FILE_SET = new Set(COMPOSE_FILES);
|
|
33
38
|
// Directories (relative to the service root) a compose file commonly nests under, in addition to
|
|
34
39
|
// the root itself. One `listDir` per entry (cheap membership test against COMPOSE_FILES).
|
|
35
40
|
const COMPOSE_DIR_CANDIDATES = ['', 'deploy', 'docker', '.docker', 'compose'];
|
|
@@ -115,7 +120,7 @@ const ENV_EXAMPLE_FILES = ['.env.example', '.env.sample', '.env.template', '.env
|
|
|
115
120
|
// intentionally SEQUENTIAL (not batched/parallel): the budget short-circuit and the "first present
|
|
116
121
|
// name/dir wins" ordering both depend on deterministic, in-order accounting. In practice a real
|
|
117
122
|
// repo resolves in a handful of reads well before the cap; the cap only bites on decoy-heavy repos,
|
|
118
|
-
// where truncation is surfaced as a note (see `
|
|
123
|
+
// where truncation is surfaced as a note (see `BudgetedRepoScanner.exhausted`).
|
|
119
124
|
const READ_BUDGET = 200;
|
|
120
125
|
const MAX_IMAGES = 8;
|
|
121
126
|
// ---- Slice 2: stack-recipe detection (compose repos) -----------------------------------------
|
|
@@ -165,22 +170,42 @@ const OS_OVERRIDE_RE = /^(.+?)\.(wsl|mac|macos|osx|linux|windows|win)(?:\.overri
|
|
|
165
170
|
const MAKEFILE_NAMES = ['Makefile', 'makefile', 'GNUmakefile'];
|
|
166
171
|
const JUSTFILE_NAMES = ['justfile', 'Justfile', '.justfile'];
|
|
167
172
|
const TASKFILE_NAMES = ['Taskfile.yml', 'Taskfile.yaml', 'taskfile.yml', 'taskfile.yaml'];
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
173
|
+
// Monorepo "service container" dirs an env/config template commonly lives ONE LEVEL DOWN in
|
|
174
|
+
// (`services/app/.env.dev.local-dist`, `apps/web/.env.example`). Scanned a single level deep by
|
|
175
|
+
// `collectEnvFileTemplates` in addition to the root-level dirs, so a per-service template outside
|
|
176
|
+
// the compose dir is still surfaced (the pilot's documented `services/app/` gap).
|
|
177
|
+
const ENV_TEMPLATE_CONTAINER_DIRS = ['services', 'apps', 'packages'];
|
|
178
|
+
/** Append `extras` after `base`, dropping any already present in `base` (base wins / stays first). */
|
|
179
|
+
function withExtras(base, extras) {
|
|
180
|
+
if (!extras || extras.length === 0)
|
|
181
|
+
return [...base];
|
|
182
|
+
const seen = new Set(base);
|
|
183
|
+
const out = [...base];
|
|
184
|
+
for (const raw of extras) {
|
|
185
|
+
const value = raw.trim();
|
|
186
|
+
if (value && !seen.has(value)) {
|
|
187
|
+
seen.add(value);
|
|
188
|
+
out.push(value);
|
|
181
189
|
}
|
|
182
190
|
}
|
|
183
|
-
return
|
|
191
|
+
return out;
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* The compose file names to try, canonical-first: the built-in {@link COMPOSE_FILES} then any
|
|
195
|
+
* deployment-supplied extras (lowest priority, so a canonical name still wins). The
|
|
196
|
+
* {@link AMBIGUOUS_COMPOSE_FILES} `services:`-required guard still applies to the bare `dev.*` names.
|
|
197
|
+
*/
|
|
198
|
+
function resolveComposeFileNames(conventions) {
|
|
199
|
+
return withExtras(COMPOSE_FILES, conventions?.composeFiles);
|
|
200
|
+
}
|
|
201
|
+
function resolveComposeDirs(conventions) {
|
|
202
|
+
return withExtras(COMPOSE_DIR_CANDIDATES, conventions?.composeDirs);
|
|
203
|
+
}
|
|
204
|
+
function resolveSeedDirs(conventions) {
|
|
205
|
+
return withExtras(SEED_DIRS, conventions?.seedDirs);
|
|
206
|
+
}
|
|
207
|
+
function resolveEnvTemplateDirs(conventions) {
|
|
208
|
+
return withExtras(ENV_TEMPLATE_DIR_CANDIDATES, conventions?.envTemplateDirs);
|
|
184
209
|
}
|
|
185
210
|
function isYamlFile(name) {
|
|
186
211
|
return name.endsWith('.yaml') || name.endsWith('.yml');
|
|
@@ -215,92 +240,6 @@ function parseOne(content) {
|
|
|
215
240
|
return null;
|
|
216
241
|
}
|
|
217
242
|
}
|
|
218
|
-
/**
|
|
219
|
-
* Stateful repo reader with a hard read budget so detection can't fan out without bound. Reads are
|
|
220
|
-
* MEMOIZED per path: the compose + recipe passes list several dirs in common (the repo root is a
|
|
221
|
-
* candidate for k8s roots, compose dirs, env-template dirs, and the repo-CLI scan), so caching keeps
|
|
222
|
-
* each unique path to a single real round-trip and stops those overlaps from burning the budget. A
|
|
223
|
-
* cache hit is free (no budget spend) and deterministic, so the "first present name/dir wins"
|
|
224
|
-
* ordering and the budget short-circuit are unaffected.
|
|
225
|
-
*/
|
|
226
|
-
class Scanner {
|
|
227
|
-
reader;
|
|
228
|
-
gitRef;
|
|
229
|
-
reads = 0;
|
|
230
|
-
firstFault;
|
|
231
|
-
fileCache = new Map();
|
|
232
|
-
dirCache = new Map();
|
|
233
|
-
constructor(reader, gitRef) {
|
|
234
|
-
this.reader = reader;
|
|
235
|
-
this.gitRef = gitRef;
|
|
236
|
-
}
|
|
237
|
-
/** True once the read budget was hit — the scan may have stopped short of the full repo. */
|
|
238
|
-
get exhausted() {
|
|
239
|
-
return this.reads >= READ_BUDGET;
|
|
240
|
-
}
|
|
241
|
-
/**
|
|
242
|
-
* The message of the FIRST genuine read fault the reader threw (auth/permission revoked, rate
|
|
243
|
-
* limit, transport/token-mint error), else `undefined`. A miss (absent path) is NOT a fault. The
|
|
244
|
-
* caller raises {@link RepoReadError} when it detected nothing AND this is set — so a truly
|
|
245
|
-
* unreadable repo surfaces an actionable error instead of a misleading "nothing found".
|
|
246
|
-
*/
|
|
247
|
-
get readFault() {
|
|
248
|
-
return this.firstFault;
|
|
249
|
-
}
|
|
250
|
-
recordFault(err) {
|
|
251
|
-
if (this.firstFault === undefined) {
|
|
252
|
-
this.firstFault = err instanceof Error ? err.message : String(err);
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
async getFile(path) {
|
|
256
|
-
const cached = this.fileCache.get(path);
|
|
257
|
-
if (cached !== undefined)
|
|
258
|
-
return cached;
|
|
259
|
-
if (this.reads >= READ_BUDGET)
|
|
260
|
-
return null;
|
|
261
|
-
this.reads++;
|
|
262
|
-
let content = null;
|
|
263
|
-
try {
|
|
264
|
-
const file = await this.reader.getFile(path, this.gitRef);
|
|
265
|
-
content = file?.content ?? null;
|
|
266
|
-
}
|
|
267
|
-
catch (err) {
|
|
268
|
-
// A genuine read fault (non-404 — the reader turns 404 into null itself). Keep scanning
|
|
269
|
-
// best-effort (a transient fault mustn't lose a good result) but record it so an all-miss
|
|
270
|
-
// outcome can be reported as "couldn't read" rather than "nothing found".
|
|
271
|
-
this.recordFault(err);
|
|
272
|
-
}
|
|
273
|
-
this.fileCache.set(path, content);
|
|
274
|
-
return content;
|
|
275
|
-
}
|
|
276
|
-
/** Read the first present file among `names` in `dir`; returns its content + matched name. */
|
|
277
|
-
async getFirstFile(dir, names) {
|
|
278
|
-
for (const name of names) {
|
|
279
|
-
const content = await this.getFile(joinPath(dir, name));
|
|
280
|
-
if (content !== null)
|
|
281
|
-
return { name, content };
|
|
282
|
-
}
|
|
283
|
-
return null;
|
|
284
|
-
}
|
|
285
|
-
async listDir(path) {
|
|
286
|
-
const cached = this.dirCache.get(path);
|
|
287
|
-
if (cached !== undefined)
|
|
288
|
-
return cached;
|
|
289
|
-
if (this.reads >= READ_BUDGET)
|
|
290
|
-
return [];
|
|
291
|
-
this.reads++;
|
|
292
|
-
try {
|
|
293
|
-
const entries = await this.reader.listDirectory(path, this.gitRef);
|
|
294
|
-
this.dirCache.set(path, entries);
|
|
295
|
-
return entries;
|
|
296
|
-
}
|
|
297
|
-
catch (err) {
|
|
298
|
-
this.recordFault(err);
|
|
299
|
-
this.dirCache.set(path, []);
|
|
300
|
-
return [];
|
|
301
|
-
}
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
243
|
function emptyScan() {
|
|
305
244
|
return {
|
|
306
245
|
kinds: new Set(),
|
|
@@ -364,7 +303,7 @@ async function scanRawDir(scanner, dir, scan) {
|
|
|
364
303
|
const entries = await scanner.listDir(dir);
|
|
365
304
|
for (const entry of entries) {
|
|
366
305
|
if (entry.type !== 'dir' && isYamlFile(entry.name)) {
|
|
367
|
-
const content = await scanner.getFile(
|
|
306
|
+
const content = await scanner.getFile(joinRepoPath(dir, entry.name));
|
|
368
307
|
if (content)
|
|
369
308
|
for (const doc of parseDocs(content))
|
|
370
309
|
scanManifestDoc(doc, scan);
|
|
@@ -417,7 +356,7 @@ async function walkKustomize(scanner, dir, scan, depth) {
|
|
|
417
356
|
// Skip remote bases (URLs / git refs) — only local paths are checkout-free readable.
|
|
418
357
|
if (ref.includes('://') || ref.startsWith('git@'))
|
|
419
358
|
continue;
|
|
420
|
-
const refPath =
|
|
359
|
+
const refPath = joinRepoPath(dir, ref);
|
|
421
360
|
if (isYamlFile(ref)) {
|
|
422
361
|
const content = await scanner.getFile(refPath);
|
|
423
362
|
if (content)
|
|
@@ -462,7 +401,7 @@ async function evaluateK8sDir(scanner, dir, entries) {
|
|
|
462
401
|
for (const entry of entries) {
|
|
463
402
|
if (entry.type === 'dir' || !isYamlFile(entry.name))
|
|
464
403
|
continue;
|
|
465
|
-
const content = await scanner.getFile(
|
|
404
|
+
const content = await scanner.getFile(joinRepoPath(dir, entry.name));
|
|
466
405
|
const looksLikeManifest = content !== null && parseDocs(content).some((d) => asString(d.kind) && asString(d.apiVersion));
|
|
467
406
|
if (looksLikeManifest)
|
|
468
407
|
return { dir, hasOverlays: false, hasKustomization: false };
|
|
@@ -487,7 +426,7 @@ async function collectKubernetesRoots(scanner, root) {
|
|
|
487
426
|
for (const candidate of K8S_DIR_CANDIDATES) {
|
|
488
427
|
if (found.length >= MAX_MANIFEST_ROOTS)
|
|
489
428
|
break;
|
|
490
|
-
const dir =
|
|
429
|
+
const dir = joinRepoPath(root, candidate);
|
|
491
430
|
const entries = await scanner.listDir(dir);
|
|
492
431
|
if (entries.length === 0)
|
|
493
432
|
continue;
|
|
@@ -503,7 +442,7 @@ async function collectKubernetesRoots(scanner, root) {
|
|
|
503
442
|
break;
|
|
504
443
|
if (entry.type !== 'dir' || !K8S_NESTED_SUBDIRS.includes(entry.name))
|
|
505
444
|
continue;
|
|
506
|
-
const nestedDir =
|
|
445
|
+
const nestedDir = joinRepoPath(dir, entry.name);
|
|
507
446
|
const nested = await evaluateK8sDir(scanner, nestedDir, await scanner.listDir(nestedDir));
|
|
508
447
|
if (nested)
|
|
509
448
|
add(nested);
|
|
@@ -524,7 +463,7 @@ async function findServiceDeployCandidates(scanner, serviceBasename) {
|
|
|
524
463
|
for (const entry of await scanner.listDir(deployRoot)) {
|
|
525
464
|
if (entry.type !== 'dir')
|
|
526
465
|
continue;
|
|
527
|
-
const path =
|
|
466
|
+
const path = joinRepoPath(deployRoot, entry.name);
|
|
528
467
|
if (seen.has(path))
|
|
529
468
|
continue;
|
|
530
469
|
// Skip a child that is itself a shared-deploy root (e.g. `manifests/services`): it's a
|
|
@@ -551,24 +490,28 @@ async function findServiceDeployCandidates(scanner, serviceBasename) {
|
|
|
551
490
|
* picker), external networks + profiles (for the recipe), and the containing dir's listing (for the
|
|
552
491
|
* `-f` override family).
|
|
553
492
|
*/
|
|
554
|
-
async function findCompose(scanner, root) {
|
|
555
|
-
|
|
556
|
-
|
|
493
|
+
async function findCompose(scanner, root, conventions) {
|
|
494
|
+
const composeFileNames = resolveComposeFileNames(conventions);
|
|
495
|
+
for (const dir of resolveComposeDirs(conventions)) {
|
|
496
|
+
const dirPath = joinRepoPath(root, dir);
|
|
557
497
|
const entries = await scanner.listDir(dirPath);
|
|
558
498
|
if (entries.length === 0)
|
|
559
499
|
continue;
|
|
560
500
|
const names = new Set(entries.filter((e) => e.type !== 'dir').map((e) => e.name));
|
|
561
|
-
for (const candidate of
|
|
501
|
+
for (const candidate of composeFileNames) {
|
|
562
502
|
if (!names.has(candidate))
|
|
563
503
|
continue;
|
|
564
|
-
const path =
|
|
504
|
+
const path = joinRepoPath(dirPath, candidate);
|
|
565
505
|
const content = await scanner.getFile(path);
|
|
566
506
|
const doc = content ? parseOne(content) : null;
|
|
567
507
|
const servicesRecord = asRecord(doc?.services) ?? {};
|
|
568
508
|
const services = Object.keys(servicesRecord);
|
|
569
|
-
// An ambiguous bare `dev.ya?ml`
|
|
570
|
-
//
|
|
571
|
-
|
|
509
|
+
// An ambiguous bare `dev.ya?ml` — or ANY convention-added extra name, which is non-canonical
|
|
510
|
+
// by definition — is only a compose file when it declares services; otherwise it's some other
|
|
511
|
+
// YAML (CLI/CI/Ansible/app config) that merely matches the name and must not be detected as
|
|
512
|
+
// compose. Canonical `compose.*`/`docker-compose.*` names are trusted without this guard.
|
|
513
|
+
const requiresServices = AMBIGUOUS_COMPOSE_FILES.has(candidate) || !COMPOSE_FILE_SET.has(candidate);
|
|
514
|
+
if (requiresServices && services.length === 0)
|
|
572
515
|
continue;
|
|
573
516
|
// Single source of truth with the provider's build-mode rejection: any service with a
|
|
574
517
|
// `build:` means the stack builds from source, so build mode is required to provision it.
|
|
@@ -594,17 +537,17 @@ function rankOverlay(name) {
|
|
|
594
537
|
/** Resolve the manifest source path + renderer + (when several) the overlay candidates. */
|
|
595
538
|
async function resolveManifestSource(scanner, k8s) {
|
|
596
539
|
if (k8s.hasOverlays) {
|
|
597
|
-
const overlaysDir =
|
|
540
|
+
const overlaysDir = joinRepoPath(k8s.dir, 'overlays');
|
|
598
541
|
const overlays = (await scanner.listDir(overlaysDir)).filter((e) => e.type === 'dir');
|
|
599
542
|
if (overlays.length > 0) {
|
|
600
543
|
const ranked = [...overlays].sort((a, b) => rankOverlay(a.name) - rankOverlay(b.name));
|
|
601
544
|
const chosen = ranked[0];
|
|
602
545
|
const candidates = ranked.map((o) => ({
|
|
603
|
-
path:
|
|
546
|
+
path: joinRepoPath(overlaysDir, o.name),
|
|
604
547
|
name: o.name,
|
|
605
548
|
recommended: o.name === chosen.name,
|
|
606
549
|
}));
|
|
607
|
-
const chosenPath =
|
|
550
|
+
const chosenPath = joinRepoPath(overlaysDir, chosen.name);
|
|
608
551
|
const hasK = (await scanner.getFirstFile(chosenPath, KUSTOMIZATION_FILES)) !== null;
|
|
609
552
|
return {
|
|
610
553
|
path: chosenPath,
|
|
@@ -685,8 +628,8 @@ async function inferHelmReleases(scanner, root, k8sDir) {
|
|
|
685
628
|
field: 'helmReleases',
|
|
686
629
|
confidence: 'low',
|
|
687
630
|
message: releases.length > 0
|
|
688
|
-
? `Proposed ${releases.length} helm release(s) from ${
|
|
689
|
-
: `Found ${
|
|
631
|
+
? `Proposed ${releases.length} helm release(s) from ${joinRepoPath(dir, helmfile.name)}; review charts/versions before applying.${unpinned > 0 ? ` ${unpinned} release(s) had an unpinned version and were skipped.` : ''}`
|
|
632
|
+
: `Found ${joinRepoPath(dir, helmfile.name)} but its release versions aren't pinned — pin them to a semver to enable.`,
|
|
690
633
|
},
|
|
691
634
|
};
|
|
692
635
|
}
|
|
@@ -706,8 +649,8 @@ async function inferHelmReleases(scanner, root, k8sDir) {
|
|
|
706
649
|
field: 'helmReleases',
|
|
707
650
|
confidence: 'low',
|
|
708
651
|
message: releases.length > 0
|
|
709
|
-
? `Proposed ${releases.length} helm release(s) from ${
|
|
710
|
-
: `Found ${
|
|
652
|
+
? `Proposed ${releases.length} helm release(s) from ${joinRepoPath(dir, chart.name)} dependencies; review before applying.`
|
|
653
|
+
: `Found ${joinRepoPath(dir, chart.name)} dependencies but their versions aren't pinned to a semver.`,
|
|
711
654
|
},
|
|
712
655
|
};
|
|
713
656
|
}
|
|
@@ -778,7 +721,7 @@ function collectComposeFiles(compose) {
|
|
|
778
721
|
continue;
|
|
779
722
|
const os = overrideOsFor(entry.name, stem);
|
|
780
723
|
if (os)
|
|
781
|
-
osOverrides.push({ path:
|
|
724
|
+
osOverrides.push({ path: joinRepoPath(compose.dir, entry.name), name: entry.name, os });
|
|
782
725
|
else if (isBaseOverride(entry.name, stem))
|
|
783
726
|
baseOverrideNames.push(entry.name);
|
|
784
727
|
}
|
|
@@ -786,7 +729,7 @@ function collectComposeFiles(compose) {
|
|
|
786
729
|
if (osOverrides.length === 0 && baseOverrideNames.length === 0)
|
|
787
730
|
return {};
|
|
788
731
|
for (const name of baseOverrideNames.sort())
|
|
789
|
-
baseFiles.push(
|
|
732
|
+
baseFiles.push(joinRepoPath(compose.dir, name));
|
|
790
733
|
osOverrides.sort((a, b) => a.name.localeCompare(b.name));
|
|
791
734
|
const composeFileCandidates = [
|
|
792
735
|
...baseFiles.map((path) => ({ path, name: path.split('/').pop() ?? path, recommended: true })),
|
|
@@ -823,14 +766,20 @@ function isConfigLikeName(target) {
|
|
|
823
766
|
* Find committed env/config TEMPLATE files (`*-dist` / `*.example` / …) beside the compose file and
|
|
824
767
|
* in the service root's common config dirs, and pair each with its gitignored target. Deduped by
|
|
825
768
|
* target; bounded by `MAX_ENV_FILES`. These become `recipe.envFiles` — materialized before `up`.
|
|
769
|
+
*
|
|
770
|
+
* Scans, in order: the compose dir, the root-level config dirs (`ENV_TEMPLATE_DIR_CANDIDATES` +
|
|
771
|
+
* any deployment `conventions.envTemplateDirs`), then ONE LEVEL DOWN into the monorepo
|
|
772
|
+
* service-container dirs (`ENV_TEMPLATE_CONTAINER_DIRS` — `services/<svc>/`, `apps/<svc>/`), so a
|
|
773
|
+
* per-service template that lives outside the compose dir (the pilot's `services/app/.env.dev.local-dist`
|
|
774
|
+
* gap) is still surfaced. First template seen for a given target wins; the root-level dirs are scanned
|
|
775
|
+
* before the deeper container dirs so a root/compose-dir template takes precedence.
|
|
826
776
|
*/
|
|
827
|
-
async function collectEnvFileTemplates(scanner, root, composeDir) {
|
|
828
|
-
const dirs = [
|
|
829
|
-
...new Set([composeDir, ...ENV_TEMPLATE_DIR_CANDIDATES.map((d) => joinPath(root, d))]),
|
|
830
|
-
];
|
|
777
|
+
async function collectEnvFileTemplates(scanner, root, composeDir, conventions) {
|
|
831
778
|
const pairs = [];
|
|
832
779
|
const seenTargets = new Set();
|
|
833
|
-
|
|
780
|
+
const sorted = () => pairs.sort((a, b) => a.template.localeCompare(b.template));
|
|
781
|
+
// Scan one flat directory; returns true once MAX_ENV_FILES is reached (caller stops).
|
|
782
|
+
const scanDir = async (dir) => {
|
|
834
783
|
// Sort by name so the dedup-by-target choice (first template seen wins) is deterministic
|
|
835
784
|
// regardless of the reader's directory-listing order.
|
|
836
785
|
const entries = [...(await scanner.listDir(dir))].sort((a, b) => a.name.localeCompare(b.name));
|
|
@@ -840,16 +789,38 @@ async function collectEnvFileTemplates(scanner, root, composeDir) {
|
|
|
840
789
|
const target = deriveEnvTemplateTarget(entry.name);
|
|
841
790
|
if (!target)
|
|
842
791
|
continue;
|
|
843
|
-
const targetPath =
|
|
792
|
+
const targetPath = joinRepoPath(dir, target);
|
|
844
793
|
if (seenTargets.has(targetPath))
|
|
845
794
|
continue;
|
|
846
795
|
seenTargets.add(targetPath);
|
|
847
|
-
pairs.push({ template:
|
|
796
|
+
pairs.push({ template: joinRepoPath(dir, entry.name), target: targetPath });
|
|
848
797
|
if (pairs.length >= MAX_ENV_FILES)
|
|
849
|
-
return
|
|
798
|
+
return true;
|
|
799
|
+
}
|
|
800
|
+
return false;
|
|
801
|
+
};
|
|
802
|
+
const rootDirs = [
|
|
803
|
+
...new Set([
|
|
804
|
+
composeDir,
|
|
805
|
+
...resolveEnvTemplateDirs(conventions).map((d) => joinRepoPath(root, d)),
|
|
806
|
+
]),
|
|
807
|
+
];
|
|
808
|
+
for (const dir of rootDirs) {
|
|
809
|
+
if (await scanDir(dir))
|
|
810
|
+
return sorted();
|
|
811
|
+
}
|
|
812
|
+
// One level into monorepo service containers (`services/app/…`), children sorted for determinism.
|
|
813
|
+
for (const container of ENV_TEMPLATE_CONTAINER_DIRS) {
|
|
814
|
+
const containerDir = joinRepoPath(root, container);
|
|
815
|
+
const children = [...(await scanner.listDir(containerDir))]
|
|
816
|
+
.filter((e) => e.type === 'dir')
|
|
817
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
818
|
+
for (const child of children) {
|
|
819
|
+
if (await scanDir(joinRepoPath(containerDir, child.name)))
|
|
820
|
+
return sorted();
|
|
850
821
|
}
|
|
851
822
|
}
|
|
852
|
-
return
|
|
823
|
+
return sorted();
|
|
853
824
|
}
|
|
854
825
|
// Whole-token matches (bounded by `^`/`$` or a non-letter — `-`, `_`, `.`, digits — so `pre` does
|
|
855
826
|
// NOT match inside `compressed` and `data` DOES match inside `add_data`) for the seed-dump ranking.
|
|
@@ -872,22 +843,22 @@ function rankSeedDump(name) {
|
|
|
872
843
|
* one into a `compose-exec` seed-import step (never auto-applied). The heuristically-fullest dump is
|
|
873
844
|
* pre-selected.
|
|
874
845
|
*/
|
|
875
|
-
async function collectSeedDumps(scanner, root) {
|
|
846
|
+
async function collectSeedDumps(scanner, root, conventions) {
|
|
876
847
|
const found = [];
|
|
877
848
|
const seen = new Set();
|
|
878
849
|
const addSql = (dir, name) => {
|
|
879
850
|
if (!name.toLowerCase().endsWith('.sql'))
|
|
880
851
|
return;
|
|
881
|
-
const path =
|
|
852
|
+
const path = joinRepoPath(dir, name);
|
|
882
853
|
if (seen.has(path))
|
|
883
854
|
return;
|
|
884
855
|
seen.add(path);
|
|
885
856
|
found.push({ path, name });
|
|
886
857
|
};
|
|
887
|
-
for (const rel of
|
|
858
|
+
for (const rel of resolveSeedDirs(conventions)) {
|
|
888
859
|
if (found.length >= MAX_SEED_DUMPS)
|
|
889
860
|
break;
|
|
890
|
-
const dir =
|
|
861
|
+
const dir = joinRepoPath(root, rel);
|
|
891
862
|
const entries = await scanner.listDir(dir);
|
|
892
863
|
for (const entry of entries) {
|
|
893
864
|
if (found.length >= MAX_SEED_DUMPS)
|
|
@@ -896,7 +867,7 @@ async function collectSeedDumps(scanner, root) {
|
|
|
896
867
|
// A `migrations`/`migration` child holds schema DDL, not seed data — never a seed dump.
|
|
897
868
|
if (/^migrations?$/i.test(entry.name))
|
|
898
869
|
continue;
|
|
899
|
-
const childDir =
|
|
870
|
+
const childDir = joinRepoPath(dir, entry.name);
|
|
900
871
|
for (const child of await scanner.listDir(childDir)) {
|
|
901
872
|
if (child.type !== 'dir')
|
|
902
873
|
addSql(childDir, child.name);
|
|
@@ -935,7 +906,7 @@ async function detectRepoCliHint(scanner, root, rootEntries) {
|
|
|
935
906
|
const fileNames = new Set(rootEntries.filter((e) => e.type !== 'dir').map((e) => e.name));
|
|
936
907
|
const hasBin = rootEntries.some((e) => e.type === 'dir' && e.name === 'bin');
|
|
937
908
|
if (hasBin) {
|
|
938
|
-
for (const entry of await scanner.listDir(
|
|
909
|
+
for (const entry of await scanner.listDir(joinRepoPath(root, 'bin'))) {
|
|
939
910
|
if (entry.type === 'dir')
|
|
940
911
|
continue;
|
|
941
912
|
const lower = entry.name.toLowerCase();
|
|
@@ -943,32 +914,32 @@ async function detectRepoCliHint(scanner, root, rootEntries) {
|
|
|
943
914
|
lower.includes('cli') ||
|
|
944
915
|
lower === 'dev' ||
|
|
945
916
|
lower === 'setup') {
|
|
946
|
-
return { path:
|
|
917
|
+
return { path: joinRepoPath(root, 'bin', entry.name), kind: 'repo-cli' };
|
|
947
918
|
}
|
|
948
919
|
}
|
|
949
920
|
}
|
|
950
921
|
for (const name of MAKEFILE_NAMES) {
|
|
951
922
|
if (fileNames.has(name))
|
|
952
|
-
return { path:
|
|
923
|
+
return { path: joinRepoPath(root, name), kind: 'makefile' };
|
|
953
924
|
}
|
|
954
925
|
for (const name of JUSTFILE_NAMES) {
|
|
955
926
|
if (fileNames.has(name))
|
|
956
|
-
return { path:
|
|
927
|
+
return { path: joinRepoPath(root, name), kind: 'justfile' };
|
|
957
928
|
}
|
|
958
929
|
for (const name of TASKFILE_NAMES) {
|
|
959
930
|
if (fileNames.has(name))
|
|
960
|
-
return { path:
|
|
931
|
+
return { path: joinRepoPath(root, name), kind: 'taskfile' };
|
|
961
932
|
}
|
|
962
933
|
return undefined;
|
|
963
934
|
}
|
|
964
935
|
/**
|
|
965
936
|
* Build the `docker-compose` recommendation. Beyond the base `composePath` + build-mode detection,
|
|
966
|
-
* this reads the STACK RECIPE a complex compose repo implies (the acme-
|
|
937
|
+
* this reads the STACK RECIPE a complex compose repo implies (the acme-monolith pilot): multi-`-f`
|
|
967
938
|
* layering, external networks, env-file materialization → `recipe`; profiles + seed dumps →
|
|
968
939
|
* candidate arrays the wizard confirms; a repo-CLI hint → the analyst nudge. When NONE of those are
|
|
969
940
|
* present the output is exactly the simple single-file recommendation (no `recipe`, no extra notes).
|
|
970
941
|
*/
|
|
971
|
-
async function buildComposeRecommendation(scanner, root, compose, serviceBasename, kubernetesAlsoExists = false) {
|
|
942
|
+
async function buildComposeRecommendation(scanner, root, compose, serviceBasename, kubernetesAlsoExists = false, conventions) {
|
|
972
943
|
const notes = [
|
|
973
944
|
{
|
|
974
945
|
field: 'provisionType',
|
|
@@ -1030,7 +1001,7 @@ async function buildComposeRecommendation(scanner, root, compose, serviceBasenam
|
|
|
1030
1001
|
message: `Bind the external network(s) (${compose.externalNetworks.join(', ')}) to a shared stack so it is brought up first, or create them on the host manually.`,
|
|
1031
1002
|
});
|
|
1032
1003
|
}
|
|
1033
|
-
const envFiles = await collectEnvFileTemplates(scanner, root, compose.dir);
|
|
1004
|
+
const envFiles = await collectEnvFileTemplates(scanner, root, compose.dir, conventions);
|
|
1034
1005
|
if (envFiles.length > 0) {
|
|
1035
1006
|
recipe.envFiles = envFiles;
|
|
1036
1007
|
notes.push({
|
|
@@ -1049,7 +1020,7 @@ async function buildComposeRecommendation(scanner, root, compose, serviceBasenam
|
|
|
1049
1020
|
message: `The compose file declares ${profileCandidates.length} profile(s): ${compose.profiles.join(', ')}. All surfaced default-off — enable the optional service groups you need.`,
|
|
1050
1021
|
});
|
|
1051
1022
|
}
|
|
1052
|
-
const seedDumpCandidates = await collectSeedDumps(scanner, root);
|
|
1023
|
+
const seedDumpCandidates = await collectSeedDumps(scanner, root, conventions);
|
|
1053
1024
|
if (seedDumpCandidates.length > 0) {
|
|
1054
1025
|
const pick = seedDumpCandidates.find((s) => s.recommended);
|
|
1055
1026
|
notes.push({
|
|
@@ -1201,7 +1172,7 @@ async function buildKubernetesRecommendation(scanner, roots, lookupRoot, opts) {
|
|
|
1201
1172
|
}
|
|
1202
1173
|
const secretInjections = [];
|
|
1203
1174
|
if (scan.secretGenerator) {
|
|
1204
|
-
const envFilePath =
|
|
1175
|
+
const envFilePath = joinRepoPath(scan.secretGenerator.baseDir, scan.secretGenerator.envFile);
|
|
1205
1176
|
const exampleDirs = [...new Set([scan.secretGenerator.baseDir, path, lookupRoot])];
|
|
1206
1177
|
let keys = [];
|
|
1207
1178
|
for (const dir of exampleDirs) {
|
|
@@ -1276,18 +1247,18 @@ async function buildKubernetesRecommendation(scanner, roots, lookupRoot, opts) {
|
|
|
1276
1247
|
* candidates for the user to pick, never silently auto-applied beyond the pre-selected one.
|
|
1277
1248
|
*/
|
|
1278
1249
|
export async function detectKubernetesProvisioning(reader, options = {}) {
|
|
1279
|
-
const root =
|
|
1250
|
+
const root = joinRepoPath(options.directory ?? '');
|
|
1280
1251
|
const repoScanEnabled = root !== '';
|
|
1281
1252
|
const serviceBasename = root.split('/').pop() ?? '';
|
|
1282
|
-
const scanner = new
|
|
1253
|
+
const scanner = new BudgetedRepoScanner(reader, READ_BUDGET, options.gitRef);
|
|
1283
1254
|
const roots = await collectKubernetesRoots(scanner, root);
|
|
1284
|
-
const compose = await findCompose(scanner, root);
|
|
1255
|
+
const compose = await findCompose(scanner, root, options.conventions);
|
|
1285
1256
|
// Honor the selected tab: on docker-compose, recommend the compose file first (noting any
|
|
1286
1257
|
// co-existing k8s manifests). Falls through to kubernetes when the user is on compose but no
|
|
1287
1258
|
// compose file exists. With no preference (or any non-compose tab) we keep the historical
|
|
1288
1259
|
// kubernetes-first order.
|
|
1289
1260
|
if (options.prefer === 'docker-compose' && compose) {
|
|
1290
|
-
return buildComposeRecommendation(scanner, root, compose, serviceBasename, roots.length > 0);
|
|
1261
|
+
return buildComposeRecommendation(scanner, root, compose, serviceBasename, roots.length > 0, options.conventions);
|
|
1291
1262
|
}
|
|
1292
1263
|
// Colocated k8s manifests win (highest confidence). In a monorepo, ALSO surface a root-shared
|
|
1293
1264
|
// per-service slice as a low-confidence "this might be the deploy target instead" hint — but ONLY
|
|
@@ -1347,7 +1318,7 @@ export async function detectKubernetesProvisioning(reader, options = {}) {
|
|
|
1347
1318
|
}
|
|
1348
1319
|
}
|
|
1349
1320
|
if (compose)
|
|
1350
|
-
return buildComposeRecommendation(scanner, root, compose, serviceBasename);
|
|
1321
|
+
return buildComposeRecommendation(scanner, root, compose, serviceBasename, false, options.conventions);
|
|
1351
1322
|
// Nothing detected. If that "nothing" is really "the repo couldn't be read" (the scan hit a
|
|
1352
1323
|
// genuine read fault), raise it rather than falsely reporting an empty repo.
|
|
1353
1324
|
if (scanner.readFault)
|
|
@@ -1375,9 +1346,9 @@ export async function detectKubernetesProvisioning(reader, options = {}) {
|
|
|
1375
1346
|
* {@link RepoReadError}; a clean "no compose file here" returns `detected: false`.
|
|
1376
1347
|
*/
|
|
1377
1348
|
export async function detectSharedStack(reader, options = {}) {
|
|
1378
|
-
const root =
|
|
1379
|
-
const scanner = new
|
|
1380
|
-
const compose = await findCompose(scanner, root);
|
|
1349
|
+
const root = joinRepoPath(options.directory ?? '');
|
|
1350
|
+
const scanner = new BudgetedRepoScanner(reader, READ_BUDGET, options.gitRef);
|
|
1351
|
+
const compose = await findCompose(scanner, root, options.conventions);
|
|
1381
1352
|
if (!compose) {
|
|
1382
1353
|
// Nothing compose-shaped. Distinguish "couldn't read the repo" from "read it, no compose".
|
|
1383
1354
|
if (scanner.readFault)
|
|
@@ -1437,7 +1408,7 @@ export async function detectSharedStack(reader, options = {}) {
|
|
|
1437
1408
|
message: `The compose file declares ${compose.profiles.length} profile(s): ${compose.profiles.join(', ')}. Enable the optional service groups this stack should run.`,
|
|
1438
1409
|
});
|
|
1439
1410
|
}
|
|
1440
|
-
const envFiles = await collectEnvFileTemplates(scanner, root, compose.dir);
|
|
1411
|
+
const envFiles = await collectEnvFileTemplates(scanner, root, compose.dir, options.conventions);
|
|
1441
1412
|
if (envFiles.length > 0) {
|
|
1442
1413
|
notes.push({
|
|
1443
1414
|
field: 'envFiles',
|
|
@@ -1479,8 +1450,8 @@ export async function detectSharedStack(reader, options = {}) {
|
|
|
1479
1450
|
* Never throws / never persists; the SPA confirms the prefilled `manifestPath`.
|
|
1480
1451
|
*/
|
|
1481
1452
|
export async function detectCustomManifest(reader, options = {}) {
|
|
1482
|
-
const root =
|
|
1483
|
-
const scanner = new
|
|
1453
|
+
const root = joinRepoPath(options.directory ?? '');
|
|
1454
|
+
const scanner = new BudgetedRepoScanner(reader, READ_BUDGET, options.gitRef);
|
|
1484
1455
|
const manifestIdPart = options.manifestId ? { manifestId: options.manifestId } : {};
|
|
1485
1456
|
const rec = (detected, manifestPath, note) => ({
|
|
1486
1457
|
detected,
|
|
@@ -1509,7 +1480,7 @@ export async function detectCustomManifest(reader, options = {}) {
|
|
|
1509
1480
|
});
|
|
1510
1481
|
}
|
|
1511
1482
|
// 2a. Exact: the complete relative path (with filename) under the service subtree / repo root.
|
|
1512
|
-
const exact =
|
|
1483
|
+
const exact = joinRepoPath(root, defaultPath);
|
|
1513
1484
|
if ((await scanner.getFile(exact)) !== null) {
|
|
1514
1485
|
return rec(true, exact, {
|
|
1515
1486
|
field: 'manifestPath',
|
|
@@ -1522,7 +1493,7 @@ export async function detectCustomManifest(reader, options = {}) {
|
|
|
1522
1493
|
for (const entry of await scanner.listDir(root)) {
|
|
1523
1494
|
if (entry.type !== 'dir')
|
|
1524
1495
|
continue;
|
|
1525
|
-
const nested =
|
|
1496
|
+
const nested = joinRepoPath(entry.path, defaultPath);
|
|
1526
1497
|
if ((await scanner.getFile(nested)) !== null) {
|
|
1527
1498
|
return rec(true, nested, {
|
|
1528
1499
|
field: 'manifestPath',
|