@cat-factory/integrations 0.121.2 → 0.122.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 (24) hide show
  1. package/dist/modules/environments/provision-detect.compose.d.ts +66 -0
  2. package/dist/modules/environments/provision-detect.compose.d.ts.map +1 -0
  3. package/dist/modules/environments/provision-detect.compose.js +561 -0
  4. package/dist/modules/environments/provision-detect.compose.js.map +1 -0
  5. package/dist/modules/environments/provision-detect.contract.d.ts +64 -0
  6. package/dist/modules/environments/provision-detect.contract.d.ts.map +1 -0
  7. package/dist/modules/environments/provision-detect.contract.js +31 -0
  8. package/dist/modules/environments/provision-detect.contract.js.map +1 -0
  9. package/dist/modules/environments/provision-detect.logic.d.ts +2 -61
  10. package/dist/modules/environments/provision-detect.logic.d.ts.map +1 -1
  11. package/dist/modules/environments/provision-detect.logic.js +19 -572
  12. package/dist/modules/environments/provision-detect.logic.js.map +1 -1
  13. package/dist/modules/tasks/TaskLinkService.d.ts +31 -0
  14. package/dist/modules/tasks/TaskLinkService.d.ts.map +1 -1
  15. package/dist/modules/tasks/TaskLinkService.js +61 -4
  16. package/dist/modules/tasks/TaskLinkService.js.map +1 -1
  17. package/dist/modules/tasks/jira.logic.d.ts +2 -0
  18. package/dist/modules/tasks/jira.logic.d.ts.map +1 -1
  19. package/dist/modules/tasks/jira.logic.js +143 -41
  20. package/dist/modules/tasks/jira.logic.js.map +1 -1
  21. package/dist/modules/tasks/webhook/adapters.d.ts.map +1 -1
  22. package/dist/modules/tasks/webhook/adapters.js +13 -5
  23. package/dist/modules/tasks/webhook/adapters.js.map +1 -1
  24. package/package.json +4 -4
@@ -1,42 +1,25 @@
1
1
  import { BudgetedRepoScanner, joinRepoPath } from '@cat-factory/kernel';
2
- import { extractComposeProfiles, extractExternalNetworks, hasBuildDirective, } from '../compose/compose-environment.logic.js';
3
2
  import { RepoReadError } from './repo-read-error.js';
4
- import { asArray, asRecord, asString, isYamlFile, parseOne } from './provision-detect.yaml.js';
3
+ import { asArray, asString, isYamlFile, parseOne } from './provision-detect.yaml.js';
4
+ import { READ_BUDGET, withExtras, } from './provision-detect.contract.js';
5
+ import { buildComposeRecommendation, collectComposeFiles, collectEnvFileTemplates, findCompose, } from './provision-detect.compose.js';
5
6
  import { emptyScan, inferImageOverrides, inferHelmReleases, inferUrlSource, KUSTOMIZATION_FILES, parseManifestDocs, scanRawDir, walkKustomize, } from './provision-detect.kubernetes.js';
6
- // Compose file names, canonical-first: the officially-preferred `compose.yaml`, then the legacy
7
- // `docker-compose.*`, then the auto-merged `*.override.*`, then the common env-variant names. The
8
- // first present name wins as the recommended `composePath`, so the base names must precede the
9
- // overrides/variants.
10
- const COMPOSE_FILES = [
11
- 'compose.yaml',
12
- 'compose.yml',
13
- 'docker-compose.yaml',
14
- 'docker-compose.yml',
15
- 'compose.override.yaml',
16
- 'compose.override.yml',
17
- 'docker-compose.override.yaml',
18
- 'docker-compose.override.yml',
19
- 'docker-compose.prod.yaml',
20
- 'docker-compose.prod.yml',
21
- 'docker-compose.dev.yaml',
22
- 'docker-compose.dev.yml',
23
- // A bare `dev.yml` base (the acme-monolith `docker/dev.yml` shape) — lowest priority so a
24
- // canonical name still wins, but recognized so a complex multi-file compose repo is detected
25
- // (its OS overrides `dev.<os>.override.yml` become recipe compose-file candidates).
26
- 'dev.yaml',
27
- 'dev.yml',
28
- ];
29
- // Bare `dev.ya?ml` is an AMBIGUOUS name — Ansible playbooks, tool/CLI config, and CI files all use
30
- // it — so unlike the canonical `compose.*`/`docker-compose.*` names it is only accepted as a compose
31
- // file when it actually declares a `services:` map (an empty/absent one ⇒ it isn't a compose file).
32
- const AMBIGUOUS_COMPOSE_FILES = new Set(['dev.yaml', 'dev.yml']);
33
- // The built-in (canonical) compose names, as a Set for a cheap membership test. A convention-added
34
- // EXTRA name (not in here) is non-canonical, so — like the bare `dev.*` names — it is trusted as a
35
- // compose file only when it actually declares `services:` (see `findCompose`).
36
- const COMPOSE_FILE_SET = new Set(COMPOSE_FILES);
37
- // Directories (relative to the service root) a compose file commonly nests under, in addition to
38
- // the root itself. One `listDir` per entry (cheap membership test against COMPOSE_FILES).
39
- const COMPOSE_DIR_CANDIDATES = ['', 'deploy', 'docker', '.docker', 'compose'];
7
+ // ---------------------------------------------------------------------------
8
+ // Per-service provisioning AUTO-DETECTION (slice 11): a deterministic, pure-TS heuristic
9
+ // that proposes a NON-BINDING recommended `kubernetes` (or `docker-compose`) provisioning
10
+ // config from a service's repo, read CHECKOUT-FREE over a minimal RepoFiles-shaped reader.
11
+ // No LLM, no clone — just targeted directory listings + YAML parsing. The user always
12
+ // confirms/edits; nothing here is applied silently. Mirrors the spirit of the compose
13
+ // autodiscovery: high-confidence facts are inferred deterministically; ambiguous ones
14
+ // (which overlay is the ephemeral one, which helm releases) are surfaced as candidates with
15
+ // a hint rather than guessed. See docs/initiatives/per-service-provision-types.md (slice 11).
16
+ //
17
+ // This module owns the KUBERNETES half plus the two entry points that choose between the two
18
+ // provision types. The compose / stack-recipe half lives in `provision-detect.compose.ts` and
19
+ // the contract all the sibling detectors share in `provision-detect.contract.ts`; both are
20
+ // re-exported here so every existing importer reaches them unchanged.
21
+ // ---------------------------------------------------------------------------
22
+ export { READ_BUDGET, } from './provision-detect.contract.js';
40
23
  // Directories (relative to the service root) commonly holding the deploy manifests. Common names
41
24
  // FIRST so the read budget is spent on the likely layouts before the rare ones.
42
25
  const K8S_DIR_CANDIDATES = [
@@ -153,98 +136,6 @@ const OVERLAY_RANK = [
153
136
  'demo',
154
137
  ];
155
138
  const ENV_EXAMPLE_FILES = ['.env.example', '.env.sample', '.env.template', '.env.dist'];
156
- // Bounds the total reads so a pathological repo can't fan out unboundedly. Raised from 80 because
157
- // the candidate lists grew (more k8s dirs, compose dirs, shared-deploy roots) and manifest-root
158
- // collection no longer short-circuits on the first hit — still tiny versus a real API. Reads are
159
- // intentionally SEQUENTIAL (not batched/parallel): the budget short-circuit and the "first present
160
- // name/dir wins" ordering both depend on deterministic, in-order accounting. In practice a real
161
- // repo resolves in a handful of reads well before the cap; the cap only bites on decoy-heavy repos,
162
- // where truncation is surfaced as a note (see `BudgetedRepoScanner.exhausted`).
163
- export const READ_BUDGET = 200;
164
- // ---- Slice 2: stack-recipe detection (compose repos) -----------------------------------------
165
- // All of the below feed a `docker-compose` recommendation's `recipe` + the recipe candidate arrays
166
- // (compose-file layering / profiles / seed dumps) + the report-only repo-CLI hint. Detection stays
167
- // deterministic + checkout-free; nothing is auto-applied beyond the pre-selected base layers.
168
- // Template-file suffixes that materialize into a gitignored target (`.env.dev.local-dist` →
169
- // `.env.dev.local`, `.split.yaml.dist` → `.split.yaml`, `.env.example` → `.env`). Longest/most
170
- // specific first so a file is stripped by exactly one suffix. `strong` marks the config-template
171
- // conventions (`-dist`/`.dist`, near-exclusively used for env/config) that accept any config-like
172
- // target; the general `.example`/`.sample`/… suffixes accept only env-like targets so a non-env
173
- // `values.yaml.example` (a Helm values sample) isn't scheduled to materialize `values.yaml`.
174
- const ENV_TEMPLATE_SUFFIXES = [
175
- { suffix: '-dist', strong: true },
176
- { suffix: '.dist', strong: true },
177
- { suffix: '.example', strong: false },
178
- { suffix: '.sample', strong: false },
179
- { suffix: '.template', strong: false },
180
- { suffix: '.tmpl', strong: false },
181
- ];
182
- // Directories (relative to the service root) an env-template commonly sits in, beside the compose
183
- // file's own dir. One `listDir` each; bounded by the read budget.
184
- const ENV_TEMPLATE_DIR_CANDIDATES = ['', 'config', 'env', 'docker', '.docker'];
185
- // Cap on materialization pairs surfaced, so a decoy-heavy repo can't produce an unbounded recipe.
186
- const MAX_ENV_FILES = 20;
187
- // Directories (relative to the service root) a SQL seed dump commonly lives under; each is scanned
188
- // at its own level AND one level into immediate child dirs (acme's
189
- // `deployment/acme-db-dummy/*.sql` shape).
190
- const SEED_DIRS = [
191
- 'deployment',
192
- 'seed',
193
- 'seeds',
194
- 'db',
195
- 'database',
196
- 'sql',
197
- 'docker-entrypoint-initdb.d',
198
- 'fixtures',
199
- 'dumps',
200
- ];
201
- // Cap on seed-dump candidates surfaced.
202
- const MAX_SEED_DUMPS = 12;
203
- // A `<stem>.<os>[.override].ya?ml` OS-specific compose override (`dev.wsl.override.yml`,
204
- // `compose.mac.yml`). The OS token is normalized to the candidate schema's `os` picklist.
205
- const OS_OVERRIDE_RE = /^(.+?)\.(wsl|mac|macos|osx|linux|windows|win)(?:\.override)?\.ya?ml$/i;
206
- // Report-only repo-CLI hint (imperative bring-up the deterministic scan can't read — a nudge toward
207
- // the slice-8 analyst). Detection NEVER parses these files; it only flags their presence.
208
- const MAKEFILE_NAMES = ['Makefile', 'makefile', 'GNUmakefile'];
209
- const JUSTFILE_NAMES = ['justfile', 'Justfile', '.justfile'];
210
- const TASKFILE_NAMES = ['Taskfile.yml', 'Taskfile.yaml', 'taskfile.yml', 'taskfile.yaml'];
211
- // Monorepo "service container" dirs an env/config template commonly lives ONE LEVEL DOWN in
212
- // (`services/app/.env.dev.local-dist`, `apps/web/.env.example`). Scanned a single level deep by
213
- // `collectEnvFileTemplates` in addition to the root-level dirs, so a per-service template outside
214
- // the compose dir is still surfaced (the pilot's documented `services/app/` gap).
215
- const ENV_TEMPLATE_CONTAINER_DIRS = ['services', 'apps', 'packages'];
216
- /** Append `extras` after `base`, dropping any already present in `base` (base wins / stays first). */
217
- function withExtras(base, extras) {
218
- if (!extras || extras.length === 0)
219
- return [...base];
220
- const seen = new Set(base);
221
- const out = [...base];
222
- for (const raw of extras) {
223
- const value = raw.trim();
224
- if (value && !seen.has(value)) {
225
- seen.add(value);
226
- out.push(value);
227
- }
228
- }
229
- return out;
230
- }
231
- /**
232
- * The compose file names to try, canonical-first: the built-in {@link COMPOSE_FILES} then any
233
- * deployment-supplied extras (lowest priority, so a canonical name still wins). The
234
- * {@link AMBIGUOUS_COMPOSE_FILES} `services:`-required guard still applies to the bare `dev.*` names.
235
- */
236
- function resolveComposeFileNames(conventions) {
237
- return withExtras(COMPOSE_FILES, conventions?.composeFiles);
238
- }
239
- function resolveComposeDirs(conventions) {
240
- return withExtras(COMPOSE_DIR_CANDIDATES, conventions?.composeDirs);
241
- }
242
- function resolveSeedDirs(conventions) {
243
- return withExtras(SEED_DIRS, conventions?.seedDirs);
244
- }
245
- function resolveEnvTemplateDirs(conventions) {
246
- return withExtras(ENV_TEMPLATE_DIR_CANDIDATES, conventions?.envTemplateDirs);
247
- }
248
139
  /** Parse `KEY=...` lines of a dotenv example into its key names (values are the user's). */
249
140
  function parseEnvExampleKeys(content) {
250
141
  const keys = [];
@@ -556,53 +447,6 @@ async function resolveTemplatedManifestRoots(scanner, templates, serviceBasename
556
447
  }
557
448
  return null;
558
449
  }
559
- /**
560
- * Locate a Docker Compose file for the service, checking the service root AND the dirs it commonly
561
- * nests under (`deploy/`, `docker/`, …). One `listDir` per candidate dir; the canonical file name
562
- * wins (COMPOSE_FILES is canonical-first). Also parses the `services:` keys (for the service
563
- * picker), external networks + profiles (for the recipe), and the containing dir's listing (for the
564
- * `-f` override family).
565
- */
566
- async function findCompose(scanner, root, conventions) {
567
- const composeFileNames = resolveComposeFileNames(conventions);
568
- for (const dir of resolveComposeDirs(conventions)) {
569
- const dirPath = joinRepoPath(root, dir);
570
- const entries = await scanner.listDir(dirPath);
571
- if (entries.length === 0)
572
- continue;
573
- const names = new Set(entries.filter((e) => e.type !== 'dir').map((e) => e.name));
574
- for (const candidate of composeFileNames) {
575
- if (!names.has(candidate))
576
- continue;
577
- const path = joinRepoPath(dirPath, candidate);
578
- const content = await scanner.getFile(path);
579
- const doc = content ? parseOne(content) : null;
580
- const servicesRecord = asRecord(doc?.services) ?? {};
581
- const services = Object.keys(servicesRecord);
582
- // An ambiguous bare `dev.ya?ml` — or ANY convention-added extra name, which is non-canonical
583
- // by definition — is only a compose file when it declares services; otherwise it's some other
584
- // YAML (CLI/CI/Ansible/app config) that merely matches the name and must not be detected as
585
- // compose. Canonical `compose.*`/`docker-compose.*` names are trusted without this guard.
586
- const requiresServices = AMBIGUOUS_COMPOSE_FILES.has(candidate) || !COMPOSE_FILE_SET.has(candidate);
587
- if (requiresServices && services.length === 0)
588
- continue;
589
- // Single source of truth with the provider's build-mode rejection: any service with a
590
- // `build:` means the stack builds from source, so build mode is required to provision it.
591
- const hasBuild = Object.values(servicesRecord).some((s) => hasBuildDirective(s));
592
- return {
593
- path,
594
- dir: dirPath,
595
- baseName: candidate,
596
- entries,
597
- services,
598
- hasBuild,
599
- externalNetworks: doc ? extractExternalNetworks(doc) : [],
600
- profiles: doc ? extractComposeProfiles(doc) : [],
601
- };
602
- }
603
- }
604
- return null;
605
- }
606
450
  function rankOverlay(name) {
607
451
  const idx = OVERLAY_RANK.indexOf(name.toLowerCase());
608
452
  return idx === -1 ? OVERLAY_RANK.length : idx;
@@ -635,403 +479,6 @@ async function resolveManifestSource(scanner, k8s) {
635
479
  function dirLabel(dir) {
636
480
  return dir === '' ? '.' : (dir.split('/').pop() ?? dir);
637
481
  }
638
- /**
639
- * Build the compose-service picker when a compose file declares MORE THAN ONE service. Pre-selects
640
- * the service whose key matches the service directory's basename, else the first declared service.
641
- * One/zero services ⇒ `undefined` (no picker).
642
- */
643
- function buildComposeServiceCandidates(compose, serviceBasename) {
644
- if (compose.services.length <= 1)
645
- return undefined;
646
- const recommendedKey = compose.services.includes(serviceBasename)
647
- ? serviceBasename
648
- : compose.services[0];
649
- return compose.services.map((service) => ({
650
- composePath: compose.path,
651
- service,
652
- recommended: service === recommendedKey,
653
- }));
654
- }
655
- /** The compose "stem" of a file name — the name with its `.yaml`/`.yml` extension stripped. */
656
- function composeStem(baseName) {
657
- return baseName.replace(/\.ya?ml$/i, '');
658
- }
659
- /** Normalize an OS token from an override file name onto the candidate schema's `os` picklist. */
660
- function normalizeOs(token) {
661
- const t = token.toLowerCase();
662
- if (t === 'wsl')
663
- return 'wsl';
664
- if (t === 'mac' || t === 'macos' || t === 'osx')
665
- return 'mac';
666
- if (t === 'linux')
667
- return 'linux';
668
- return 'windows'; // windows | win
669
- }
670
- /** The OS an override file targets when it belongs to `stem`'s family (`dev.wsl.override.yml`), else null. */
671
- function overrideOsFor(name, stem) {
672
- const m = OS_OVERRIDE_RE.exec(name);
673
- return m && m[1] === stem ? normalizeOs(m[2]) : null;
674
- }
675
- /** True when `name` is a NON-OS `<stem>.override.ya?ml` auto-merge override of the found base. */
676
- function isBaseOverride(name, stem) {
677
- const m = /^(.+?)\.override\.ya?ml$/i.exec(name);
678
- return m !== null && m[1] === stem;
679
- }
680
- /**
681
- * Assemble the compose-file layering from the base file's own directory listing. The primary base +
682
- * any `<stem>.override.ya?ml` auto-merge sibling become ordered base layers (pre-selected into
683
- * `recipe.composeFiles`); OS-specific overrides (`dev.<os>.override.yml`) are surfaced as opt-in
684
- * candidates annotated with `os` and NOT auto-layered. A lone base file with no family ⇒ `{}` (the
685
- * simple `composePath` suffices — no recipe layering needed).
686
- */
687
- function collectComposeFiles(compose) {
688
- const stem = composeStem(compose.baseName);
689
- const baseFiles = [compose.path];
690
- const baseOverrideNames = [];
691
- const osOverrides = [];
692
- for (const entry of compose.entries) {
693
- if (entry.type === 'dir' || entry.name === compose.baseName)
694
- continue;
695
- const os = overrideOsFor(entry.name, stem);
696
- if (os)
697
- osOverrides.push({ path: joinRepoPath(compose.dir, entry.name), name: entry.name, os });
698
- else if (isBaseOverride(entry.name, stem))
699
- baseOverrideNames.push(entry.name);
700
- }
701
- // No override family beyond the single base file ⇒ nothing to layer.
702
- if (osOverrides.length === 0 && baseOverrideNames.length === 0)
703
- return {};
704
- for (const name of baseOverrideNames.sort())
705
- baseFiles.push(joinRepoPath(compose.dir, name));
706
- osOverrides.sort((a, b) => a.name.localeCompare(b.name));
707
- const composeFileCandidates = [
708
- ...baseFiles.map((path) => ({ path, name: path.split('/').pop() ?? path, recommended: true })),
709
- ...osOverrides.map((o) => ({ path: o.path, name: o.name, os: o.os, recommended: false })),
710
- ];
711
- return { composeFiles: baseFiles, composeFileCandidates };
712
- }
713
- /** Map a template file name to its materialization target (stripped suffix), or null when it isn't
714
- * a config/env template (`README.dist` → null; `.env.dev.local-dist` → `.env.dev.local`;
715
- * `values.yaml.example` → null — a Helm values sample, not env). A `strong` (`-dist`/`.dist`)
716
- * suffix accepts any config-like target; the general suffixes accept only an env-like target. */
717
- function deriveEnvTemplateTarget(name) {
718
- for (const { suffix, strong } of ENV_TEMPLATE_SUFFIXES) {
719
- if (name.length <= suffix.length || !name.endsWith(suffix))
720
- continue;
721
- const target = name.slice(0, -suffix.length);
722
- const accepted = strong ? isConfigLikeName(target) : isEnvLikeName(target);
723
- return accepted ? target : null;
724
- }
725
- return null;
726
- }
727
- /** True when a target is an env file per se — a dotfile or an `env`-bearing name (`.env`,
728
- * `.env.dev.local`, `environment.local`). The bar the general (non-`dist`) template suffixes clear. */
729
- function isEnvLikeName(target) {
730
- return target.startsWith('.') || target.toLowerCase().includes('env');
731
- }
732
- /** True when a template's stripped target looks like an env/config file (so we don't materialize a
733
- * `README.dist` or a `.tar.dist`). A dotfile, an `env`-bearing name, or a config extension. */
734
- function isConfigLikeName(target) {
735
- const lower = target.toLowerCase();
736
- return (isEnvLikeName(target) || /\.(ya?ml|json|ini|conf|cfg|config|properties|toml|local)$/.test(lower));
737
- }
738
- /**
739
- * Find committed env/config TEMPLATE files (`*-dist` / `*.example` / …) beside the compose file and
740
- * in the service root's common config dirs, and pair each with its gitignored target. Deduped by
741
- * target; bounded by `MAX_ENV_FILES`. These become `recipe.envFiles` — materialized before `up`.
742
- *
743
- * Scans, in order: the compose dir, the root-level config dirs (`ENV_TEMPLATE_DIR_CANDIDATES` +
744
- * any deployment `conventions.envTemplateDirs`), then ONE LEVEL DOWN into the monorepo
745
- * service-container dirs (`ENV_TEMPLATE_CONTAINER_DIRS` — `services/<svc>/`, `apps/<svc>/`), so a
746
- * per-service template that lives outside the compose dir (the pilot's `services/app/.env.dev.local-dist`
747
- * gap) is still surfaced. First template seen for a given target wins; the root-level dirs are scanned
748
- * before the deeper container dirs so a root/compose-dir template takes precedence.
749
- */
750
- async function collectEnvFileTemplates(scanner, root, composeDir, conventions) {
751
- const pairs = [];
752
- const seenTargets = new Set();
753
- const sorted = () => pairs.sort((a, b) => a.template.localeCompare(b.template));
754
- // Scan one flat directory; returns true once MAX_ENV_FILES is reached (caller stops).
755
- const scanDir = async (dir) => {
756
- // Sort by name so the dedup-by-target choice (first template seen wins) is deterministic
757
- // regardless of the reader's directory-listing order.
758
- const entries = [...(await scanner.listDir(dir))].sort((a, b) => a.name.localeCompare(b.name));
759
- for (const entry of entries) {
760
- if (entry.type === 'dir')
761
- continue;
762
- const target = deriveEnvTemplateTarget(entry.name);
763
- if (!target)
764
- continue;
765
- const targetPath = joinRepoPath(dir, target);
766
- if (seenTargets.has(targetPath))
767
- continue;
768
- seenTargets.add(targetPath);
769
- pairs.push({ template: joinRepoPath(dir, entry.name), target: targetPath });
770
- if (pairs.length >= MAX_ENV_FILES)
771
- return true;
772
- }
773
- return false;
774
- };
775
- const rootDirs = [
776
- ...new Set([
777
- composeDir,
778
- ...resolveEnvTemplateDirs(conventions).map((d) => joinRepoPath(root, d)),
779
- ]),
780
- ];
781
- for (const dir of rootDirs) {
782
- if (await scanDir(dir))
783
- return sorted();
784
- }
785
- // One level into monorepo service containers (`services/app/…`), children sorted for determinism.
786
- for (const container of ENV_TEMPLATE_CONTAINER_DIRS) {
787
- const containerDir = joinRepoPath(root, container);
788
- const children = [...(await scanner.listDir(containerDir))]
789
- .filter((e) => e.type === 'dir')
790
- .sort((a, b) => a.name.localeCompare(b.name));
791
- for (const child of children) {
792
- if (await scanDir(joinRepoPath(containerDir, child.name)))
793
- return sorted();
794
- }
795
- }
796
- return sorted();
797
- }
798
- // Whole-token matches (bounded by `^`/`$` or a non-letter — `-`, `_`, `.`, digits — so `pre` does
799
- // NOT match inside `compressed` and `data` DOES match inside `add_data`) for the seed-dump ranking.
800
- const SEED_DATA_TOKENS = /(^|[^a-z])(seed|dummy|data|dump|fixture|sample)([^a-z]|$)/;
801
- const SEED_SCHEMA_TOKENS = /(^|[^a-z])(pre|schema|structure|ddl|migration|create|drop)([^a-z]|$)/;
802
- /** Rank a SQL dump for the seed pre-selection: prefer full seed/dummy data, deprioritize
803
- * schema/pre/structure-only dumps. Higher wins; ties break deterministically by path. */
804
- function rankSeedDump(name) {
805
- const lower = name.toLowerCase();
806
- let score = 0;
807
- if (SEED_DATA_TOKENS.test(lower))
808
- score += 2;
809
- if (SEED_SCHEMA_TOKENS.test(lower))
810
- score -= 1;
811
- return score;
812
- }
813
- /**
814
- * Scan the seed-ish directories for `.sql` dumps (each dir + one level into its child dirs, the
815
- * `deployment/<db>/*.sql` shape) and surface them as low-confidence candidates — the wizard confirms
816
- * one into a `compose-exec` seed-import step (never auto-applied). The heuristically-fullest dump is
817
- * pre-selected.
818
- */
819
- async function collectSeedDumps(scanner, root, conventions) {
820
- const found = [];
821
- const seen = new Set();
822
- const addSql = (dir, name) => {
823
- if (!name.toLowerCase().endsWith('.sql'))
824
- return;
825
- const path = joinRepoPath(dir, name);
826
- if (seen.has(path))
827
- return;
828
- seen.add(path);
829
- found.push({ path, name });
830
- };
831
- // Collect `.sql` dumps for one directory entry: a file is added directly; a dir is scanned
832
- // one level in. Extracted so the child-dir loop doesn't nest under the two outer loops
833
- // (keeps max-depth ≤ 4).
834
- const scanEntry = async (dir, entry) => {
835
- if (entry.type !== 'dir') {
836
- addSql(dir, entry.name);
837
- return;
838
- }
839
- // A `migrations`/`migration` child holds schema DDL, not seed data — never a seed dump.
840
- if (/^migrations?$/i.test(entry.name))
841
- return;
842
- const childDir = joinRepoPath(dir, entry.name);
843
- for (const child of await scanner.listDir(childDir)) {
844
- if (child.type !== 'dir')
845
- addSql(childDir, child.name);
846
- if (found.length >= MAX_SEED_DUMPS)
847
- break;
848
- }
849
- };
850
- for (const rel of resolveSeedDirs(conventions)) {
851
- if (found.length >= MAX_SEED_DUMPS)
852
- break;
853
- const dir = joinRepoPath(root, rel);
854
- const entries = await scanner.listDir(dir);
855
- for (const entry of entries) {
856
- if (found.length >= MAX_SEED_DUMPS)
857
- break;
858
- await scanEntry(dir, entry);
859
- }
860
- }
861
- if (found.length === 0)
862
- return [];
863
- // Sort by path so both the surfaced order and the pre-selection tie-break are deterministic
864
- // regardless of the reader's directory-listing order.
865
- found.sort((a, b) => a.path.localeCompare(b.path));
866
- let bestIdx = 0;
867
- let bestScore = rankSeedDump(found[0].name);
868
- for (let i = 1; i < found.length; i++) {
869
- const score = rankSeedDump(found[i].name);
870
- if (score > bestScore) {
871
- bestScore = score;
872
- bestIdx = i;
873
- }
874
- }
875
- return found.map((f, i) => ({ path: f.path, name: f.name, recommended: i === bestIdx }));
876
- }
877
- /**
878
- * A REPORT-ONLY hint that the repo carries its own imperative bring-up — a `bin/*console*` repo CLI,
879
- * a Makefile, a justfile, or a Taskfile. Detection NEVER parses these files; it only flags the first
880
- * one found (repo-CLI first, then Makefile → justfile → Taskfile) so the wizard can nudge toward the
881
- * slice-8 analyst. `rootEntries` is the already-read root listing (no extra read for the top-level files).
882
- */
883
- async function detectRepoCliHint(scanner, root, rootEntries) {
884
- const fileNames = new Set(rootEntries.filter((e) => e.type !== 'dir').map((e) => e.name));
885
- const hasBin = rootEntries.some((e) => e.type === 'dir' && e.name === 'bin');
886
- if (hasBin) {
887
- for (const entry of await scanner.listDir(joinRepoPath(root, 'bin'))) {
888
- if (entry.type === 'dir')
889
- continue;
890
- const lower = entry.name.toLowerCase();
891
- if (lower.includes('console') ||
892
- lower.includes('cli') ||
893
- lower === 'dev' ||
894
- lower === 'setup') {
895
- return { path: joinRepoPath(root, 'bin', entry.name), kind: 'repo-cli' };
896
- }
897
- }
898
- }
899
- for (const name of MAKEFILE_NAMES) {
900
- if (fileNames.has(name))
901
- return { path: joinRepoPath(root, name), kind: 'makefile' };
902
- }
903
- for (const name of JUSTFILE_NAMES) {
904
- if (fileNames.has(name))
905
- return { path: joinRepoPath(root, name), kind: 'justfile' };
906
- }
907
- for (const name of TASKFILE_NAMES) {
908
- if (fileNames.has(name))
909
- return { path: joinRepoPath(root, name), kind: 'taskfile' };
910
- }
911
- return undefined;
912
- }
913
- /**
914
- * Build the `docker-compose` recommendation. Beyond the base `composePath` + build-mode detection,
915
- * this reads the STACK RECIPE a complex compose repo implies (the acme-monolith pilot): multi-`-f`
916
- * layering, external networks, env-file materialization → `recipe`; profiles + seed dumps →
917
- * candidate arrays the wizard confirms; a repo-CLI hint → the analyst nudge. When NONE of those are
918
- * present the output is exactly the simple single-file recommendation (no `recipe`, no extra notes).
919
- */
920
- async function buildComposeRecommendation(scanner, root, compose, serviceBasename, kubernetesAlsoExists = false, conventions) {
921
- const notes = [
922
- {
923
- field: 'provisionType',
924
- confidence: 'high',
925
- message: `Detected a Docker Compose file at ${compose.path}.`,
926
- },
927
- ];
928
- // A service that declares `build:` can only run in build-from-source mode (the checkout-free
929
- // image-pull path would reject it), so recommend build mode — a Docker-daemon capability, so
930
- // only a local deployment can provision it.
931
- if (compose.hasBuild) {
932
- notes.push({
933
- field: 'composeBuild',
934
- confidence: 'high',
935
- message: 'This compose stack builds its images from source (build:). Recommending build-from-source mode, which clones the PR head and runs `docker compose build` — available only on a local (Docker-capable) deployment.',
936
- });
937
- }
938
- // Symmetric to the kubernetes path's `compose` note: when we recommend compose because it's
939
- // the selected tab but k8s manifests also exist, say so (the user can switch).
940
- if (kubernetesAlsoExists) {
941
- notes.push({
942
- field: 'kubernetes',
943
- confidence: 'low',
944
- message: 'Kubernetes manifests also exist in this repo; recommending docker-compose because it is your selected provision type. Switch to kubernetes if that is the test target.',
945
- });
946
- }
947
- const composeServiceCandidates = buildComposeServiceCandidates(compose, serviceBasename);
948
- if (composeServiceCandidates) {
949
- const rec = composeServiceCandidates.find((s) => s.recommended);
950
- notes.push({
951
- field: 'composeService',
952
- confidence: 'low',
953
- 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.`,
954
- });
955
- }
956
- // --- Stack recipe detection (populated only when the repo is actually recipe-shaped) ----------
957
- const recipe = {};
958
- const rootEntries = await scanner.listDir(root);
959
- const { composeFiles, composeFileCandidates } = collectComposeFiles(compose);
960
- if (composeFiles) {
961
- recipe.composeFiles = composeFiles;
962
- const osCount = composeFileCandidates.filter((c) => c.os).length;
963
- notes.push({
964
- field: 'composeFiles',
965
- confidence: 'high',
966
- message: `Layered ${composeFiles.length} compose file(s): ${composeFiles.join(' → ')}.${osCount > 0 ? ` ${osCount} OS-specific override(s) surfaced — pick the one matching your machine.` : ''}`,
967
- });
968
- }
969
- if (compose.externalNetworks.length > 0) {
970
- recipe.externalNetworks = compose.externalNetworks;
971
- notes.push({
972
- field: 'externalNetworks',
973
- confidence: 'high',
974
- message: `This project expects external network(s) to already exist: ${compose.externalNetworks.join(', ')}. They must be created before it comes up.`,
975
- });
976
- notes.push({
977
- field: 'sharedStackRefs',
978
- confidence: 'low',
979
- 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.`,
980
- });
981
- }
982
- const envFiles = await collectEnvFileTemplates(scanner, root, compose.dir, conventions);
983
- if (envFiles.length > 0) {
984
- recipe.envFiles = envFiles;
985
- notes.push({
986
- field: 'envFiles',
987
- confidence: 'low',
988
- message: `Found ${envFiles.length} env/config template(s) to materialize before up: ${envFiles.map((e) => `${e.template} → ${e.target}`).join(', ')}. Confirm each pair.`,
989
- });
990
- }
991
- const profileCandidates = compose.profiles.length > 0
992
- ? compose.profiles.map((profile) => ({ profile, recommended: false }))
993
- : undefined;
994
- if (profileCandidates) {
995
- notes.push({
996
- field: 'composeProfiles',
997
- confidence: 'low',
998
- message: `The compose file declares ${profileCandidates.length} profile(s): ${compose.profiles.join(', ')}. All surfaced default-off — enable the optional service groups you need.`,
999
- });
1000
- }
1001
- const seedDumpCandidates = await collectSeedDumps(scanner, root, conventions);
1002
- if (seedDumpCandidates.length > 0) {
1003
- const pick = seedDumpCandidates.find((s) => s.recommended);
1004
- notes.push({
1005
- field: 'seedDump',
1006
- confidence: 'low',
1007
- 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.`,
1008
- });
1009
- }
1010
- const repoCliHint = await detectRepoCliHint(scanner, root, rootEntries);
1011
- if (repoCliHint) {
1012
- notes.push({
1013
- field: 'repoCli',
1014
- confidence: 'low',
1015
- 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.`,
1016
- });
1017
- }
1018
- const provisioning = {
1019
- type: 'docker-compose',
1020
- composePath: compose.path,
1021
- ...(compose.hasBuild ? { composeBuild: true } : {}),
1022
- ...(Object.keys(recipe).length > 0 ? { recipe } : {}),
1023
- };
1024
- return {
1025
- detected: true,
1026
- provisioning,
1027
- ...(composeServiceCandidates ? { composeServiceCandidates } : {}),
1028
- ...(composeFileCandidates ? { composeFileCandidates } : {}),
1029
- ...(profileCandidates ? { profileCandidates } : {}),
1030
- ...(seedDumpCandidates.length > 0 ? { seedDumpCandidates } : {}),
1031
- ...(repoCliHint ? { repoCliHint } : {}),
1032
- notes,
1033
- };
1034
- }
1035
482
  function noneRecommendation() {
1036
483
  return {
1037
484
  detected: false,