@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.
Files changed (22) hide show
  1. package/dist/modules/compose/ComposeEnvironmentProvider.d.ts +33 -0
  2. package/dist/modules/compose/ComposeEnvironmentProvider.d.ts.map +1 -1
  3. package/dist/modules/compose/ComposeEnvironmentProvider.js +374 -17
  4. package/dist/modules/compose/ComposeEnvironmentProvider.js.map +1 -1
  5. package/dist/modules/compose/compose-environment.logic.d.ts +160 -1
  6. package/dist/modules/compose/compose-environment.logic.d.ts.map +1 -1
  7. package/dist/modules/compose/compose-environment.logic.js +257 -0
  8. package/dist/modules/compose/compose-environment.logic.js.map +1 -1
  9. package/dist/modules/environments/EnvironmentConnectionService.d.ts.map +1 -1
  10. package/dist/modules/environments/EnvironmentConnectionService.js +1 -0
  11. package/dist/modules/environments/EnvironmentConnectionService.js.map +1 -1
  12. package/dist/modules/environments/EnvironmentProvisioningService.d.ts.map +1 -1
  13. package/dist/modules/environments/EnvironmentProvisioningService.js +26 -0
  14. package/dist/modules/environments/EnvironmentProvisioningService.js.map +1 -1
  15. package/dist/modules/environments/infra-handler-build.d.ts +9 -5
  16. package/dist/modules/environments/infra-handler-build.d.ts.map +1 -1
  17. package/dist/modules/environments/infra-handler-build.js +16 -1
  18. package/dist/modules/environments/infra-handler-build.js.map +1 -1
  19. package/dist/modules/environments/provision-detect.logic.d.ts.map +1 -1
  20. package/dist/modules/environments/provision-detect.logic.js +407 -16
  21. package/dist/modules/environments/provision-detect.logic.js.map +1 -1
  22. 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
- /** Stateful repo reader with a hard read budget so detection can't fan out without bound. */
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
- return file?.content ?? null;
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
- return await this.reader.listDirectory(path, this.gitRef);
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 so callers can surface
449
- * a service picker when several are declared.
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 servicesRecord = content ? (asRecord(parseOne(content)?.services) ?? {}) : {};
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 { path, services, hasBuild };
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
- function composeRecommendation(compose, serviceBasename, kubernetesAlsoExists = false) {
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 composeRecommendation(compose, serviceBasename, roots.length > 0);
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 composeRecommendation(compose, serviceBasename);
1324
+ return buildComposeRecommendation(scanner, root, compose, serviceBasename);
934
1325
  return noneRecommendation();
935
1326
  }
936
1327
  /**