@cat-factory/integrations 0.67.1 → 0.69.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/modules/compose/ComposeEnvironmentProvider.d.ts +33 -0
- package/dist/modules/compose/ComposeEnvironmentProvider.d.ts.map +1 -1
- package/dist/modules/compose/ComposeEnvironmentProvider.js +374 -17
- package/dist/modules/compose/ComposeEnvironmentProvider.js.map +1 -1
- package/dist/modules/compose/compose-environment.logic.d.ts +160 -1
- package/dist/modules/compose/compose-environment.logic.d.ts.map +1 -1
- package/dist/modules/compose/compose-environment.logic.js +257 -0
- package/dist/modules/compose/compose-environment.logic.js.map +1 -1
- package/dist/modules/environments/EnvironmentConnectionService.d.ts.map +1 -1
- package/dist/modules/environments/EnvironmentConnectionService.js +1 -0
- package/dist/modules/environments/EnvironmentConnectionService.js.map +1 -1
- package/dist/modules/environments/EnvironmentProvisioningService.d.ts.map +1 -1
- package/dist/modules/environments/EnvironmentProvisioningService.js +26 -0
- package/dist/modules/environments/EnvironmentProvisioningService.js.map +1 -1
- package/dist/modules/environments/infra-handler-build.d.ts +9 -5
- package/dist/modules/environments/infra-handler-build.d.ts.map +1 -1
- package/dist/modules/environments/infra-handler-build.js +16 -1
- package/dist/modules/environments/infra-handler-build.js.map +1 -1
- package/dist/modules/environments/provision-detect.logic.d.ts.map +1 -1
- package/dist/modules/environments/provision-detect.logic.js +407 -16
- package/dist/modules/environments/provision-detect.logic.js.map +1 -1
- package/package.json +3 -3
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { parse as parseYaml, parseAllDocuments } from 'yaml';
|
|
2
|
-
import { hasBuildDirective } from '../compose/compose-environment.logic.js';
|
|
2
|
+
import { extractComposeProfiles, extractExternalNetworks, hasBuildDirective, } from '../compose/compose-environment.logic.js';
|
|
3
3
|
const PINNED_SEMVER = /^v?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
4
4
|
const KUSTOMIZATION_FILES = ['kustomization.yaml', 'kustomization.yml', 'Kustomization'];
|
|
5
5
|
// Compose file names, canonical-first: the officially-preferred `compose.yaml`, then the legacy
|
|
@@ -19,7 +19,16 @@ const COMPOSE_FILES = [
|
|
|
19
19
|
'docker-compose.prod.yml',
|
|
20
20
|
'docker-compose.dev.yaml',
|
|
21
21
|
'docker-compose.dev.yml',
|
|
22
|
+
// A bare `dev.yml` base (the acme-main `docker/dev.yml` shape) — lowest priority so a
|
|
23
|
+
// canonical name still wins, but recognized so a complex multi-file compose repo is detected
|
|
24
|
+
// (its OS overrides `dev.<os>.override.yml` become recipe compose-file candidates).
|
|
25
|
+
'dev.yaml',
|
|
26
|
+
'dev.yml',
|
|
22
27
|
];
|
|
28
|
+
// Bare `dev.ya?ml` is an AMBIGUOUS name — Ansible playbooks, tool/CLI config, and CI files all use
|
|
29
|
+
// it — so unlike the canonical `compose.*`/`docker-compose.*` names it is only accepted as a compose
|
|
30
|
+
// file when it actually declares a `services:` map (an empty/absent one ⇒ it isn't a compose file).
|
|
31
|
+
const AMBIGUOUS_COMPOSE_FILES = new Set(['dev.yaml', 'dev.yml']);
|
|
23
32
|
// Directories (relative to the service root) a compose file commonly nests under, in addition to
|
|
24
33
|
// the root itself. One `listDir` per entry (cheap membership test against COMPOSE_FILES).
|
|
25
34
|
const COMPOSE_DIR_CANDIDATES = ['', 'deploy', 'docker', '.docker', 'compose'];
|
|
@@ -108,6 +117,53 @@ const ENV_EXAMPLE_FILES = ['.env.example', '.env.sample', '.env.template', '.env
|
|
|
108
117
|
// where truncation is surfaced as a note (see `Scanner.exhausted`).
|
|
109
118
|
const READ_BUDGET = 200;
|
|
110
119
|
const MAX_IMAGES = 8;
|
|
120
|
+
// ---- Slice 2: stack-recipe detection (compose repos) -----------------------------------------
|
|
121
|
+
// All of the below feed a `docker-compose` recommendation's `recipe` + the recipe candidate arrays
|
|
122
|
+
// (compose-file layering / profiles / seed dumps) + the report-only repo-CLI hint. Detection stays
|
|
123
|
+
// deterministic + checkout-free; nothing is auto-applied beyond the pre-selected base layers.
|
|
124
|
+
// Template-file suffixes that materialize into a gitignored target (`.env.dev.local-dist` →
|
|
125
|
+
// `.env.dev.local`, `.split.yaml.dist` → `.split.yaml`, `.env.example` → `.env`). Longest/most
|
|
126
|
+
// specific first so a file is stripped by exactly one suffix. `strong` marks the config-template
|
|
127
|
+
// conventions (`-dist`/`.dist`, near-exclusively used for env/config) that accept any config-like
|
|
128
|
+
// target; the general `.example`/`.sample`/… suffixes accept only env-like targets so a non-env
|
|
129
|
+
// `values.yaml.example` (a Helm values sample) isn't scheduled to materialize `values.yaml`.
|
|
130
|
+
const ENV_TEMPLATE_SUFFIXES = [
|
|
131
|
+
{ suffix: '-dist', strong: true },
|
|
132
|
+
{ suffix: '.dist', strong: true },
|
|
133
|
+
{ suffix: '.example', strong: false },
|
|
134
|
+
{ suffix: '.sample', strong: false },
|
|
135
|
+
{ suffix: '.template', strong: false },
|
|
136
|
+
{ suffix: '.tmpl', strong: false },
|
|
137
|
+
];
|
|
138
|
+
// Directories (relative to the service root) an env-template commonly sits in, beside the compose
|
|
139
|
+
// file's own dir. One `listDir` each; bounded by the read budget.
|
|
140
|
+
const ENV_TEMPLATE_DIR_CANDIDATES = ['', 'config', 'env', 'docker', '.docker'];
|
|
141
|
+
// Cap on materialization pairs surfaced, so a decoy-heavy repo can't produce an unbounded recipe.
|
|
142
|
+
const MAX_ENV_FILES = 20;
|
|
143
|
+
// Directories (relative to the service root) a SQL seed dump commonly lives under; each is scanned
|
|
144
|
+
// at its own level AND one level into immediate child dirs (acme's
|
|
145
|
+
// `deployment/acme-db-dummy/*.sql` shape).
|
|
146
|
+
const SEED_DIRS = [
|
|
147
|
+
'deployment',
|
|
148
|
+
'seed',
|
|
149
|
+
'seeds',
|
|
150
|
+
'db',
|
|
151
|
+
'database',
|
|
152
|
+
'sql',
|
|
153
|
+
'docker-entrypoint-initdb.d',
|
|
154
|
+
'fixtures',
|
|
155
|
+
'dumps',
|
|
156
|
+
];
|
|
157
|
+
// Cap on seed-dump candidates surfaced.
|
|
158
|
+
const MAX_SEED_DUMPS = 12;
|
|
159
|
+
// A `<stem>.<os>[.override].ya?ml` OS-specific compose override (`dev.wsl.override.yml`,
|
|
160
|
+
// `compose.mac.yml`). The OS token is normalized to the candidate schema's `os` picklist.
|
|
161
|
+
const OS_OVERRIDE_RE = /^(.+?)\.(wsl|mac|macos|osx|linux|windows|win)(?:\.override)?\.ya?ml$/i;
|
|
162
|
+
// Report-only repo-CLI hint (imperative bring-up the deterministic scan can't read — a nudge toward
|
|
163
|
+
// the slice-8 analyst). Detection NEVER parses these files; it only flags their presence.
|
|
164
|
+
const MAKEFILE_NAMES = ['Makefile', 'makefile', 'GNUmakefile'];
|
|
165
|
+
const JUSTFILE_NAMES = ['justfile', 'Justfile', '.justfile'];
|
|
166
|
+
const TASKFILE_NAMES = ['Taskfile.yml', 'Taskfile.yaml', 'taskfile.yml', 'taskfile.yaml'];
|
|
111
167
|
/** Join + normalize repo-relative path segments, collapsing `.`/`..` (resolves `../base` refs). */
|
|
112
168
|
function joinPath(...parts) {
|
|
113
169
|
const segs = [];
|
|
@@ -158,11 +214,20 @@ function parseOne(content) {
|
|
|
158
214
|
return null;
|
|
159
215
|
}
|
|
160
216
|
}
|
|
161
|
-
/**
|
|
217
|
+
/**
|
|
218
|
+
* Stateful repo reader with a hard read budget so detection can't fan out without bound. Reads are
|
|
219
|
+
* MEMOIZED per path: the compose + recipe passes list several dirs in common (the repo root is a
|
|
220
|
+
* candidate for k8s roots, compose dirs, env-template dirs, and the repo-CLI scan), so caching keeps
|
|
221
|
+
* each unique path to a single real round-trip and stops those overlaps from burning the budget. A
|
|
222
|
+
* cache hit is free (no budget spend) and deterministic, so the "first present name/dir wins"
|
|
223
|
+
* ordering and the budget short-circuit are unaffected.
|
|
224
|
+
*/
|
|
162
225
|
class Scanner {
|
|
163
226
|
reader;
|
|
164
227
|
gitRef;
|
|
165
228
|
reads = 0;
|
|
229
|
+
fileCache = new Map();
|
|
230
|
+
dirCache = new Map();
|
|
166
231
|
constructor(reader, gitRef) {
|
|
167
232
|
this.reader = reader;
|
|
168
233
|
this.gitRef = gitRef;
|
|
@@ -172,11 +237,16 @@ class Scanner {
|
|
|
172
237
|
return this.reads >= READ_BUDGET;
|
|
173
238
|
}
|
|
174
239
|
async getFile(path) {
|
|
240
|
+
const cached = this.fileCache.get(path);
|
|
241
|
+
if (cached !== undefined)
|
|
242
|
+
return cached;
|
|
175
243
|
if (this.reads >= READ_BUDGET)
|
|
176
244
|
return null;
|
|
177
245
|
this.reads++;
|
|
178
246
|
const file = await this.reader.getFile(path, this.gitRef);
|
|
179
|
-
|
|
247
|
+
const content = file?.content ?? null;
|
|
248
|
+
this.fileCache.set(path, content);
|
|
249
|
+
return content;
|
|
180
250
|
}
|
|
181
251
|
/** Read the first present file among `names` in `dir`; returns its content + matched name. */
|
|
182
252
|
async getFirstFile(dir, names) {
|
|
@@ -188,13 +258,19 @@ class Scanner {
|
|
|
188
258
|
return null;
|
|
189
259
|
}
|
|
190
260
|
async listDir(path) {
|
|
261
|
+
const cached = this.dirCache.get(path);
|
|
262
|
+
if (cached !== undefined)
|
|
263
|
+
return cached;
|
|
191
264
|
if (this.reads >= READ_BUDGET)
|
|
192
265
|
return [];
|
|
193
266
|
this.reads++;
|
|
194
267
|
try {
|
|
195
|
-
|
|
268
|
+
const entries = await this.reader.listDirectory(path, this.gitRef);
|
|
269
|
+
this.dirCache.set(path, entries);
|
|
270
|
+
return entries;
|
|
196
271
|
}
|
|
197
272
|
catch {
|
|
273
|
+
this.dirCache.set(path, []);
|
|
198
274
|
return [];
|
|
199
275
|
}
|
|
200
276
|
}
|
|
@@ -445,8 +521,9 @@ async function findServiceDeployCandidates(scanner, serviceBasename) {
|
|
|
445
521
|
/**
|
|
446
522
|
* Locate a Docker Compose file for the service, checking the service root AND the dirs it commonly
|
|
447
523
|
* nests under (`deploy/`, `docker/`, …). One `listDir` per candidate dir; the canonical file name
|
|
448
|
-
* wins (COMPOSE_FILES is canonical-first). Also parses the `services:` keys
|
|
449
|
-
*
|
|
524
|
+
* wins (COMPOSE_FILES is canonical-first). Also parses the `services:` keys (for the service
|
|
525
|
+
* picker), external networks + profiles (for the recipe), and the containing dir's listing (for the
|
|
526
|
+
* `-f` override family).
|
|
450
527
|
*/
|
|
451
528
|
async function findCompose(scanner, root) {
|
|
452
529
|
for (const dir of COMPOSE_DIR_CANDIDATES) {
|
|
@@ -460,12 +537,26 @@ async function findCompose(scanner, root) {
|
|
|
460
537
|
continue;
|
|
461
538
|
const path = joinPath(dirPath, candidate);
|
|
462
539
|
const content = await scanner.getFile(path);
|
|
463
|
-
const
|
|
540
|
+
const doc = content ? parseOne(content) : null;
|
|
541
|
+
const servicesRecord = asRecord(doc?.services) ?? {};
|
|
464
542
|
const services = Object.keys(servicesRecord);
|
|
543
|
+
// An ambiguous bare `dev.ya?ml` is only a compose file when it declares services; otherwise
|
|
544
|
+
// it's some other `dev.yml` (CLI/CI/Ansible config) and must not be detected as compose.
|
|
545
|
+
if (AMBIGUOUS_COMPOSE_FILES.has(candidate) && services.length === 0)
|
|
546
|
+
continue;
|
|
465
547
|
// Single source of truth with the provider's build-mode rejection: any service with a
|
|
466
548
|
// `build:` means the stack builds from source, so build mode is required to provision it.
|
|
467
549
|
const hasBuild = Object.values(servicesRecord).some((s) => hasBuildDirective(s));
|
|
468
|
-
return {
|
|
550
|
+
return {
|
|
551
|
+
path,
|
|
552
|
+
dir: dirPath,
|
|
553
|
+
baseName: candidate,
|
|
554
|
+
entries,
|
|
555
|
+
services,
|
|
556
|
+
hasBuild,
|
|
557
|
+
externalNetworks: doc ? extractExternalNetworks(doc) : [],
|
|
558
|
+
profiles: doc ? extractComposeProfiles(doc) : [],
|
|
559
|
+
};
|
|
469
560
|
}
|
|
470
561
|
}
|
|
471
562
|
return null;
|
|
@@ -619,7 +710,239 @@ function buildComposeServiceCandidates(compose, serviceBasename) {
|
|
|
619
710
|
recommended: service === recommendedKey,
|
|
620
711
|
}));
|
|
621
712
|
}
|
|
622
|
-
|
|
713
|
+
/** The compose "stem" of a file name — the name with its `.yaml`/`.yml` extension stripped. */
|
|
714
|
+
function composeStem(baseName) {
|
|
715
|
+
return baseName.replace(/\.ya?ml$/i, '');
|
|
716
|
+
}
|
|
717
|
+
/** Normalize an OS token from an override file name onto the candidate schema's `os` picklist. */
|
|
718
|
+
function normalizeOs(token) {
|
|
719
|
+
const t = token.toLowerCase();
|
|
720
|
+
if (t === 'wsl')
|
|
721
|
+
return 'wsl';
|
|
722
|
+
if (t === 'mac' || t === 'macos' || t === 'osx')
|
|
723
|
+
return 'mac';
|
|
724
|
+
if (t === 'linux')
|
|
725
|
+
return 'linux';
|
|
726
|
+
return 'windows'; // windows | win
|
|
727
|
+
}
|
|
728
|
+
/** The OS an override file targets when it belongs to `stem`'s family (`dev.wsl.override.yml`), else null. */
|
|
729
|
+
function overrideOsFor(name, stem) {
|
|
730
|
+
const m = OS_OVERRIDE_RE.exec(name);
|
|
731
|
+
return m && m[1] === stem ? normalizeOs(m[2]) : null;
|
|
732
|
+
}
|
|
733
|
+
/** True when `name` is a NON-OS `<stem>.override.ya?ml` auto-merge override of the found base. */
|
|
734
|
+
function isBaseOverride(name, stem) {
|
|
735
|
+
const m = /^(.+?)\.override\.ya?ml$/i.exec(name);
|
|
736
|
+
return m !== null && m[1] === stem;
|
|
737
|
+
}
|
|
738
|
+
/**
|
|
739
|
+
* Assemble the compose-file layering from the base file's own directory listing. The primary base +
|
|
740
|
+
* any `<stem>.override.ya?ml` auto-merge sibling become ordered base layers (pre-selected into
|
|
741
|
+
* `recipe.composeFiles`); OS-specific overrides (`dev.<os>.override.yml`) are surfaced as opt-in
|
|
742
|
+
* candidates annotated with `os` and NOT auto-layered. A lone base file with no family ⇒ `{}` (the
|
|
743
|
+
* simple `composePath` suffices — no recipe layering needed).
|
|
744
|
+
*/
|
|
745
|
+
function collectComposeFiles(compose) {
|
|
746
|
+
const stem = composeStem(compose.baseName);
|
|
747
|
+
const baseFiles = [compose.path];
|
|
748
|
+
const baseOverrideNames = [];
|
|
749
|
+
const osOverrides = [];
|
|
750
|
+
for (const entry of compose.entries) {
|
|
751
|
+
if (entry.type === 'dir' || entry.name === compose.baseName)
|
|
752
|
+
continue;
|
|
753
|
+
const os = overrideOsFor(entry.name, stem);
|
|
754
|
+
if (os)
|
|
755
|
+
osOverrides.push({ path: joinPath(compose.dir, entry.name), name: entry.name, os });
|
|
756
|
+
else if (isBaseOverride(entry.name, stem))
|
|
757
|
+
baseOverrideNames.push(entry.name);
|
|
758
|
+
}
|
|
759
|
+
// No override family beyond the single base file ⇒ nothing to layer.
|
|
760
|
+
if (osOverrides.length === 0 && baseOverrideNames.length === 0)
|
|
761
|
+
return {};
|
|
762
|
+
for (const name of baseOverrideNames.sort())
|
|
763
|
+
baseFiles.push(joinPath(compose.dir, name));
|
|
764
|
+
osOverrides.sort((a, b) => a.name.localeCompare(b.name));
|
|
765
|
+
const composeFileCandidates = [
|
|
766
|
+
...baseFiles.map((path) => ({ path, name: path.split('/').pop() ?? path, recommended: true })),
|
|
767
|
+
...osOverrides.map((o) => ({ path: o.path, name: o.name, os: o.os, recommended: false })),
|
|
768
|
+
];
|
|
769
|
+
return { composeFiles: baseFiles, composeFileCandidates };
|
|
770
|
+
}
|
|
771
|
+
/** Map a template file name to its materialization target (stripped suffix), or null when it isn't
|
|
772
|
+
* a config/env template (`README.dist` → null; `.env.dev.local-dist` → `.env.dev.local`;
|
|
773
|
+
* `values.yaml.example` → null — a Helm values sample, not env). A `strong` (`-dist`/`.dist`)
|
|
774
|
+
* suffix accepts any config-like target; the general suffixes accept only an env-like target. */
|
|
775
|
+
function deriveEnvTemplateTarget(name) {
|
|
776
|
+
for (const { suffix, strong } of ENV_TEMPLATE_SUFFIXES) {
|
|
777
|
+
if (name.length <= suffix.length || !name.endsWith(suffix))
|
|
778
|
+
continue;
|
|
779
|
+
const target = name.slice(0, -suffix.length);
|
|
780
|
+
const accepted = strong ? isConfigLikeName(target) : isEnvLikeName(target);
|
|
781
|
+
return accepted ? target : null;
|
|
782
|
+
}
|
|
783
|
+
return null;
|
|
784
|
+
}
|
|
785
|
+
/** True when a target is an env file per se — a dotfile or an `env`-bearing name (`.env`,
|
|
786
|
+
* `.env.dev.local`, `environment.local`). The bar the general (non-`dist`) template suffixes clear. */
|
|
787
|
+
function isEnvLikeName(target) {
|
|
788
|
+
return target.startsWith('.') || target.toLowerCase().includes('env');
|
|
789
|
+
}
|
|
790
|
+
/** True when a template's stripped target looks like an env/config file (so we don't materialize a
|
|
791
|
+
* `README.dist` or a `.tar.dist`). A dotfile, an `env`-bearing name, or a config extension. */
|
|
792
|
+
function isConfigLikeName(target) {
|
|
793
|
+
const lower = target.toLowerCase();
|
|
794
|
+
return (isEnvLikeName(target) || /\.(ya?ml|json|ini|conf|cfg|config|properties|toml|local)$/.test(lower));
|
|
795
|
+
}
|
|
796
|
+
/**
|
|
797
|
+
* Find committed env/config TEMPLATE files (`*-dist` / `*.example` / …) beside the compose file and
|
|
798
|
+
* in the service root's common config dirs, and pair each with its gitignored target. Deduped by
|
|
799
|
+
* target; bounded by `MAX_ENV_FILES`. These become `recipe.envFiles` — materialized before `up`.
|
|
800
|
+
*/
|
|
801
|
+
async function collectEnvFileTemplates(scanner, root, composeDir) {
|
|
802
|
+
const dirs = [
|
|
803
|
+
...new Set([composeDir, ...ENV_TEMPLATE_DIR_CANDIDATES.map((d) => joinPath(root, d))]),
|
|
804
|
+
];
|
|
805
|
+
const pairs = [];
|
|
806
|
+
const seenTargets = new Set();
|
|
807
|
+
for (const dir of dirs) {
|
|
808
|
+
// Sort by name so the dedup-by-target choice (first template seen wins) is deterministic
|
|
809
|
+
// regardless of the reader's directory-listing order.
|
|
810
|
+
const entries = [...(await scanner.listDir(dir))].sort((a, b) => a.name.localeCompare(b.name));
|
|
811
|
+
for (const entry of entries) {
|
|
812
|
+
if (entry.type === 'dir')
|
|
813
|
+
continue;
|
|
814
|
+
const target = deriveEnvTemplateTarget(entry.name);
|
|
815
|
+
if (!target)
|
|
816
|
+
continue;
|
|
817
|
+
const targetPath = joinPath(dir, target);
|
|
818
|
+
if (seenTargets.has(targetPath))
|
|
819
|
+
continue;
|
|
820
|
+
seenTargets.add(targetPath);
|
|
821
|
+
pairs.push({ template: joinPath(dir, entry.name), target: targetPath });
|
|
822
|
+
if (pairs.length >= MAX_ENV_FILES)
|
|
823
|
+
return pairs.sort((a, b) => a.template.localeCompare(b.template));
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
return pairs.sort((a, b) => a.template.localeCompare(b.template));
|
|
827
|
+
}
|
|
828
|
+
// Whole-token matches (bounded by `^`/`$` or a non-letter — `-`, `_`, `.`, digits — so `pre` does
|
|
829
|
+
// NOT match inside `compressed` and `data` DOES match inside `add_data`) for the seed-dump ranking.
|
|
830
|
+
const SEED_DATA_TOKENS = /(^|[^a-z])(seed|dummy|data|dump|fixture|sample)([^a-z]|$)/;
|
|
831
|
+
const SEED_SCHEMA_TOKENS = /(^|[^a-z])(pre|schema|structure|ddl|migration|create|drop)([^a-z]|$)/;
|
|
832
|
+
/** Rank a SQL dump for the seed pre-selection: prefer full seed/dummy data, deprioritize
|
|
833
|
+
* schema/pre/structure-only dumps. Higher wins; ties break deterministically by path. */
|
|
834
|
+
function rankSeedDump(name) {
|
|
835
|
+
const lower = name.toLowerCase();
|
|
836
|
+
let score = 0;
|
|
837
|
+
if (SEED_DATA_TOKENS.test(lower))
|
|
838
|
+
score += 2;
|
|
839
|
+
if (SEED_SCHEMA_TOKENS.test(lower))
|
|
840
|
+
score -= 1;
|
|
841
|
+
return score;
|
|
842
|
+
}
|
|
843
|
+
/**
|
|
844
|
+
* Scan the seed-ish directories for `.sql` dumps (each dir + one level into its child dirs, the
|
|
845
|
+
* `deployment/<db>/*.sql` shape) and surface them as low-confidence candidates — the wizard confirms
|
|
846
|
+
* one into a `compose-exec` seed-import step (never auto-applied). The heuristically-fullest dump is
|
|
847
|
+
* pre-selected.
|
|
848
|
+
*/
|
|
849
|
+
async function collectSeedDumps(scanner, root) {
|
|
850
|
+
const found = [];
|
|
851
|
+
const seen = new Set();
|
|
852
|
+
const addSql = (dir, name) => {
|
|
853
|
+
if (!name.toLowerCase().endsWith('.sql'))
|
|
854
|
+
return;
|
|
855
|
+
const path = joinPath(dir, name);
|
|
856
|
+
if (seen.has(path))
|
|
857
|
+
return;
|
|
858
|
+
seen.add(path);
|
|
859
|
+
found.push({ path, name });
|
|
860
|
+
};
|
|
861
|
+
for (const rel of SEED_DIRS) {
|
|
862
|
+
if (found.length >= MAX_SEED_DUMPS)
|
|
863
|
+
break;
|
|
864
|
+
const dir = joinPath(root, rel);
|
|
865
|
+
const entries = await scanner.listDir(dir);
|
|
866
|
+
for (const entry of entries) {
|
|
867
|
+
if (found.length >= MAX_SEED_DUMPS)
|
|
868
|
+
break;
|
|
869
|
+
if (entry.type === 'dir') {
|
|
870
|
+
// A `migrations`/`migration` child holds schema DDL, not seed data — never a seed dump.
|
|
871
|
+
if (/^migrations?$/i.test(entry.name))
|
|
872
|
+
continue;
|
|
873
|
+
const childDir = joinPath(dir, entry.name);
|
|
874
|
+
for (const child of await scanner.listDir(childDir)) {
|
|
875
|
+
if (child.type !== 'dir')
|
|
876
|
+
addSql(childDir, child.name);
|
|
877
|
+
if (found.length >= MAX_SEED_DUMPS)
|
|
878
|
+
break;
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
else {
|
|
882
|
+
addSql(dir, entry.name);
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
if (found.length === 0)
|
|
887
|
+
return [];
|
|
888
|
+
// Sort by path so both the surfaced order and the pre-selection tie-break are deterministic
|
|
889
|
+
// regardless of the reader's directory-listing order.
|
|
890
|
+
found.sort((a, b) => a.path.localeCompare(b.path));
|
|
891
|
+
let bestIdx = 0;
|
|
892
|
+
let bestScore = rankSeedDump(found[0].name);
|
|
893
|
+
for (let i = 1; i < found.length; i++) {
|
|
894
|
+
const score = rankSeedDump(found[i].name);
|
|
895
|
+
if (score > bestScore) {
|
|
896
|
+
bestScore = score;
|
|
897
|
+
bestIdx = i;
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
return found.map((f, i) => ({ path: f.path, name: f.name, recommended: i === bestIdx }));
|
|
901
|
+
}
|
|
902
|
+
/**
|
|
903
|
+
* A REPORT-ONLY hint that the repo carries its own imperative bring-up — a `bin/*console*` repo CLI,
|
|
904
|
+
* a Makefile, a justfile, or a Taskfile. Detection NEVER parses these files; it only flags the first
|
|
905
|
+
* one found (repo-CLI first, then Makefile → justfile → Taskfile) so the wizard can nudge toward the
|
|
906
|
+
* slice-8 analyst. `rootEntries` is the already-read root listing (no extra read for the top-level files).
|
|
907
|
+
*/
|
|
908
|
+
async function detectRepoCliHint(scanner, root, rootEntries) {
|
|
909
|
+
const fileNames = new Set(rootEntries.filter((e) => e.type !== 'dir').map((e) => e.name));
|
|
910
|
+
const hasBin = rootEntries.some((e) => e.type === 'dir' && e.name === 'bin');
|
|
911
|
+
if (hasBin) {
|
|
912
|
+
for (const entry of await scanner.listDir(joinPath(root, 'bin'))) {
|
|
913
|
+
if (entry.type === 'dir')
|
|
914
|
+
continue;
|
|
915
|
+
const lower = entry.name.toLowerCase();
|
|
916
|
+
if (lower.includes('console') ||
|
|
917
|
+
lower.includes('cli') ||
|
|
918
|
+
lower === 'dev' ||
|
|
919
|
+
lower === 'setup') {
|
|
920
|
+
return { path: joinPath(root, 'bin', entry.name), kind: 'repo-cli' };
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
for (const name of MAKEFILE_NAMES) {
|
|
925
|
+
if (fileNames.has(name))
|
|
926
|
+
return { path: joinPath(root, name), kind: 'makefile' };
|
|
927
|
+
}
|
|
928
|
+
for (const name of JUSTFILE_NAMES) {
|
|
929
|
+
if (fileNames.has(name))
|
|
930
|
+
return { path: joinPath(root, name), kind: 'justfile' };
|
|
931
|
+
}
|
|
932
|
+
for (const name of TASKFILE_NAMES) {
|
|
933
|
+
if (fileNames.has(name))
|
|
934
|
+
return { path: joinPath(root, name), kind: 'taskfile' };
|
|
935
|
+
}
|
|
936
|
+
return undefined;
|
|
937
|
+
}
|
|
938
|
+
/**
|
|
939
|
+
* Build the `docker-compose` recommendation. Beyond the base `composePath` + build-mode detection,
|
|
940
|
+
* this reads the STACK RECIPE a complex compose repo implies (the acme-main pilot): multi-`-f`
|
|
941
|
+
* layering, external networks, env-file materialization → `recipe`; profiles + seed dumps →
|
|
942
|
+
* candidate arrays the wizard confirms; a repo-CLI hint → the analyst nudge. When NONE of those are
|
|
943
|
+
* present the output is exactly the simple single-file recommendation (no `recipe`, no extra notes).
|
|
944
|
+
*/
|
|
945
|
+
async function buildComposeRecommendation(scanner, root, compose, serviceBasename, kubernetesAlsoExists = false) {
|
|
623
946
|
const notes = [
|
|
624
947
|
{
|
|
625
948
|
field: 'provisionType',
|
|
@@ -655,14 +978,82 @@ function composeRecommendation(compose, serviceBasename, kubernetesAlsoExists =
|
|
|
655
978
|
message: `The compose file declares ${composeServiceCandidates.length} services; pre-selected "${rec?.service ?? composeServiceCandidates[0].service}" for this block. The file is the deploy target — the service choice is advisory; pick another if that's wrong.`,
|
|
656
979
|
});
|
|
657
980
|
}
|
|
981
|
+
// --- Stack recipe detection (populated only when the repo is actually recipe-shaped) ----------
|
|
982
|
+
const recipe = {};
|
|
983
|
+
const rootEntries = await scanner.listDir(root);
|
|
984
|
+
const { composeFiles, composeFileCandidates } = collectComposeFiles(compose);
|
|
985
|
+
if (composeFiles) {
|
|
986
|
+
recipe.composeFiles = composeFiles;
|
|
987
|
+
const osCount = composeFileCandidates.filter((c) => c.os).length;
|
|
988
|
+
notes.push({
|
|
989
|
+
field: 'composeFiles',
|
|
990
|
+
confidence: 'high',
|
|
991
|
+
message: `Layered ${composeFiles.length} compose file(s): ${composeFiles.join(' → ')}.${osCount > 0 ? ` ${osCount} OS-specific override(s) surfaced — pick the one matching your machine.` : ''}`,
|
|
992
|
+
});
|
|
993
|
+
}
|
|
994
|
+
if (compose.externalNetworks.length > 0) {
|
|
995
|
+
recipe.externalNetworks = compose.externalNetworks;
|
|
996
|
+
notes.push({
|
|
997
|
+
field: 'externalNetworks',
|
|
998
|
+
confidence: 'high',
|
|
999
|
+
message: `This project expects external network(s) to already exist: ${compose.externalNetworks.join(', ')}. They must be created before it comes up.`,
|
|
1000
|
+
});
|
|
1001
|
+
notes.push({
|
|
1002
|
+
field: 'sharedStackRefs',
|
|
1003
|
+
confidence: 'low',
|
|
1004
|
+
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.`,
|
|
1005
|
+
});
|
|
1006
|
+
}
|
|
1007
|
+
const envFiles = await collectEnvFileTemplates(scanner, root, compose.dir);
|
|
1008
|
+
if (envFiles.length > 0) {
|
|
1009
|
+
recipe.envFiles = envFiles;
|
|
1010
|
+
notes.push({
|
|
1011
|
+
field: 'envFiles',
|
|
1012
|
+
confidence: 'low',
|
|
1013
|
+
message: `Found ${envFiles.length} env/config template(s) to materialize before up: ${envFiles.map((e) => `${e.template} → ${e.target}`).join(', ')}. Confirm each pair.`,
|
|
1014
|
+
});
|
|
1015
|
+
}
|
|
1016
|
+
const profileCandidates = compose.profiles.length > 0
|
|
1017
|
+
? compose.profiles.map((profile) => ({ profile, recommended: false }))
|
|
1018
|
+
: undefined;
|
|
1019
|
+
if (profileCandidates) {
|
|
1020
|
+
notes.push({
|
|
1021
|
+
field: 'composeProfiles',
|
|
1022
|
+
confidence: 'low',
|
|
1023
|
+
message: `The compose file declares ${profileCandidates.length} profile(s): ${compose.profiles.join(', ')}. All surfaced default-off — enable the optional service groups you need.`,
|
|
1024
|
+
});
|
|
1025
|
+
}
|
|
1026
|
+
const seedDumpCandidates = await collectSeedDumps(scanner, root);
|
|
1027
|
+
if (seedDumpCandidates.length > 0) {
|
|
1028
|
+
const pick = seedDumpCandidates.find((s) => s.recommended);
|
|
1029
|
+
notes.push({
|
|
1030
|
+
field: 'seedDump',
|
|
1031
|
+
confidence: 'low',
|
|
1032
|
+
message: `Found ${seedDumpCandidates.length} SQL seed dump(s)${pick ? ` (pre-selected ${pick.path})` : ''}. Confirm one to import as a seed step; none is applied automatically.`,
|
|
1033
|
+
});
|
|
1034
|
+
}
|
|
1035
|
+
const repoCliHint = await detectRepoCliHint(scanner, root, rootEntries);
|
|
1036
|
+
if (repoCliHint) {
|
|
1037
|
+
notes.push({
|
|
1038
|
+
field: 'repoCli',
|
|
1039
|
+
confidence: 'low',
|
|
1040
|
+
message: `This repo has its own imperative bring-up (${repoCliHint.kind} at ${repoCliHint.path}); the deterministic scan can't read it. Consider running deep analysis to translate its setup into recipe steps.`,
|
|
1041
|
+
});
|
|
1042
|
+
}
|
|
1043
|
+
const provisioning = {
|
|
1044
|
+
type: 'docker-compose',
|
|
1045
|
+
composePath: compose.path,
|
|
1046
|
+
...(compose.hasBuild ? { composeBuild: true } : {}),
|
|
1047
|
+
...(Object.keys(recipe).length > 0 ? { recipe } : {}),
|
|
1048
|
+
};
|
|
658
1049
|
return {
|
|
659
1050
|
detected: true,
|
|
660
|
-
provisioning
|
|
661
|
-
type: 'docker-compose',
|
|
662
|
-
composePath: compose.path,
|
|
663
|
-
...(compose.hasBuild ? { composeBuild: true } : {}),
|
|
664
|
-
},
|
|
1051
|
+
provisioning,
|
|
665
1052
|
...(composeServiceCandidates ? { composeServiceCandidates } : {}),
|
|
1053
|
+
...(composeFileCandidates ? { composeFileCandidates } : {}),
|
|
1054
|
+
...(profileCandidates ? { profileCandidates } : {}),
|
|
1055
|
+
...(seedDumpCandidates.length > 0 ? { seedDumpCandidates } : {}),
|
|
1056
|
+
...(repoCliHint ? { repoCliHint } : {}),
|
|
666
1057
|
notes,
|
|
667
1058
|
};
|
|
668
1059
|
}
|
|
@@ -870,7 +1261,7 @@ export async function detectKubernetesProvisioning(reader, options = {}) {
|
|
|
870
1261
|
// compose file exists. With no preference (or any non-compose tab) we keep the historical
|
|
871
1262
|
// kubernetes-first order.
|
|
872
1263
|
if (options.prefer === 'docker-compose' && compose) {
|
|
873
|
-
return
|
|
1264
|
+
return buildComposeRecommendation(scanner, root, compose, serviceBasename, roots.length > 0);
|
|
874
1265
|
}
|
|
875
1266
|
// Colocated k8s manifests win (highest confidence). In a monorepo, ALSO surface a root-shared
|
|
876
1267
|
// per-service slice as a low-confidence "this might be the deploy target instead" hint — but ONLY
|
|
@@ -930,7 +1321,7 @@ export async function detectKubernetesProvisioning(reader, options = {}) {
|
|
|
930
1321
|
}
|
|
931
1322
|
}
|
|
932
1323
|
if (compose)
|
|
933
|
-
return
|
|
1324
|
+
return buildComposeRecommendation(scanner, root, compose, serviceBasename);
|
|
934
1325
|
return noneRecommendation();
|
|
935
1326
|
}
|
|
936
1327
|
/**
|