@cat-factory/integrations 0.121.1 → 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
@@ -0,0 +1,66 @@
1
+ import type { ProvisioningComposeFileCandidate, ProvisioningRecommendation, RecipeEnvFile } from '@cat-factory/contracts';
2
+ import { BudgetedRepoScanner } from '@cat-factory/kernel';
3
+ import { type DetectionConventions } from './provision-detect.contract.js';
4
+ export interface ComposeHit {
5
+ /** Repo-relative compose file path (the value `composePath` would take). */
6
+ path: string;
7
+ /** The directory the compose file was found in (repo-relative; `''` = the service/repo root). */
8
+ dir: string;
9
+ /** The matched file name (e.g. `dev.yml`), used to derive the compose stem for the override family. */
10
+ baseName: string;
11
+ /** The directory listing where the base was found — reused to collect the compose override family. */
12
+ entries: {
13
+ name: string;
14
+ type: string;
15
+ path: string;
16
+ }[];
17
+ /** The declared `services:` keys (empty when unparseable / none). */
18
+ services: string[];
19
+ /** True when any service declares a `build:` — the stack builds its images from source. */
20
+ hasBuild: boolean;
21
+ /** External networks the project expects to already exist (`external: true`) — resolved names. */
22
+ externalNetworks: string[];
23
+ /** `COMPOSE_PROFILES` labels declared across the file's services (deduped + sorted). */
24
+ profiles: string[];
25
+ }
26
+ /**
27
+ * Locate a Docker Compose file for the service, checking the service root AND the dirs it commonly
28
+ * nests under (`deploy/`, `docker/`, …). One `listDir` per candidate dir; the canonical file name
29
+ * wins (COMPOSE_FILES is canonical-first). Also parses the `services:` keys (for the service
30
+ * picker), external networks + profiles (for the recipe), and the containing dir's listing (for the
31
+ * `-f` override family).
32
+ */
33
+ export declare function findCompose(scanner: BudgetedRepoScanner, root: string, conventions?: DetectionConventions): Promise<ComposeHit | null>;
34
+ /**
35
+ * Assemble the compose-file layering from the base file's own directory listing. The primary base +
36
+ * any `<stem>.override.ya?ml` auto-merge sibling become ordered base layers (pre-selected into
37
+ * `recipe.composeFiles`); OS-specific overrides (`dev.<os>.override.yml`) are surfaced as opt-in
38
+ * candidates annotated with `os` and NOT auto-layered. A lone base file with no family ⇒ `{}` (the
39
+ * simple `composePath` suffices — no recipe layering needed).
40
+ */
41
+ export declare function collectComposeFiles(compose: ComposeHit): {
42
+ composeFiles?: string[];
43
+ composeFileCandidates?: ProvisioningComposeFileCandidate[];
44
+ };
45
+ /**
46
+ * Find committed env/config TEMPLATE files (`*-dist` / `*.example` / …) beside the compose file and
47
+ * in the service root's common config dirs, and pair each with its gitignored target. Deduped by
48
+ * target; bounded by `MAX_ENV_FILES`. These become `recipe.envFiles` — materialized before `up`.
49
+ *
50
+ * Scans, in order: the compose dir, the root-level config dirs (`ENV_TEMPLATE_DIR_CANDIDATES` +
51
+ * any deployment `conventions.envTemplateDirs`), then ONE LEVEL DOWN into the monorepo
52
+ * service-container dirs (`ENV_TEMPLATE_CONTAINER_DIRS` — `services/<svc>/`, `apps/<svc>/`), so a
53
+ * per-service template that lives outside the compose dir (the pilot's `services/app/.env.dev.local-dist`
54
+ * gap) is still surfaced. First template seen for a given target wins; the root-level dirs are scanned
55
+ * before the deeper container dirs so a root/compose-dir template takes precedence.
56
+ */
57
+ export declare function collectEnvFileTemplates(scanner: BudgetedRepoScanner, root: string, composeDir: string, conventions?: DetectionConventions): Promise<RecipeEnvFile[]>;
58
+ /**
59
+ * Build the `docker-compose` recommendation. Beyond the base `composePath` + build-mode detection,
60
+ * this reads the STACK RECIPE a complex compose repo implies (the acme-monolith pilot): multi-`-f`
61
+ * layering, external networks, env-file materialization → `recipe`; profiles + seed dumps →
62
+ * candidate arrays the wizard confirms; a repo-CLI hint → the analyst nudge. When NONE of those are
63
+ * present the output is exactly the simple single-file recommendation (no `recipe`, no extra notes).
64
+ */
65
+ export declare function buildComposeRecommendation(scanner: BudgetedRepoScanner, root: string, compose: ComposeHit, serviceBasename: string, kubernetesAlsoExists?: boolean, conventions?: DetectionConventions): Promise<ProvisioningRecommendation>;
66
+ //# sourceMappingURL=provision-detect.compose.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"provision-detect.compose.d.ts","sourceRoot":"","sources":["../../../src/modules/environments/provision-detect.compose.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,gCAAgC,EAIhC,0BAA0B,EAG1B,aAAa,EAGd,MAAM,wBAAwB,CAAA;AAE/B,OAAO,EAAE,mBAAmB,EAAgB,MAAM,qBAAqB,CAAA;AAOvE,OAAO,EAAE,KAAK,oBAAoB,EAAc,MAAM,gCAAgC,CAAA;AAuHtF,MAAM,WAAW,UAAU;IACzB,4EAA4E;IAC5E,IAAI,EAAE,MAAM,CAAA;IACZ,iGAAiG;IACjG,GAAG,EAAE,MAAM,CAAA;IACX,uGAAuG;IACvG,QAAQ,EAAE,MAAM,CAAA;IAChB,sGAAsG;IACtG,OAAO,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,EAAE,CAAA;IACvD,qEAAqE;IACrE,QAAQ,EAAE,MAAM,EAAE,CAAA;IAClB,2FAA2F;IAC3F,QAAQ,EAAE,OAAO,CAAA;IACjB,kGAAkG;IAClG,gBAAgB,EAAE,MAAM,EAAE,CAAA;IAC1B,wFAAwF;IACxF,QAAQ,EAAE,MAAM,EAAE,CAAA;CACnB;AAED;;;;;;GAMG;AACH,wBAAsB,WAAW,CAC/B,OAAO,EAAE,mBAAmB,EAC5B,IAAI,EAAE,MAAM,EACZ,WAAW,CAAC,EAAE,oBAAoB,GACjC,OAAO,CAAC,UAAU,GAAG,IAAI,CAAC,CAqC5B;AAkDD;;;;;;GAMG;AACH,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,UAAU,GAAG;IACxD,YAAY,CAAC,EAAE,MAAM,EAAE,CAAA;IACvB,qBAAqB,CAAC,EAAE,gCAAgC,EAAE,CAAA;CAC3D,CAyBA;AA+BD;;;;;;;;;;;GAWG;AACH,wBAAsB,uBAAuB,CAC3C,OAAO,EAAE,mBAAmB,EAC5B,IAAI,EAAE,MAAM,EACZ,UAAU,EAAE,MAAM,EAClB,WAAW,CAAC,EAAE,oBAAoB,GACjC,OAAO,CAAC,aAAa,EAAE,CAAC,CA0C1B;AAmHD;;;;;;GAMG;AACH,wBAAsB,0BAA0B,CAC9C,OAAO,EAAE,mBAAmB,EAC5B,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,UAAU,EACnB,eAAe,EAAE,MAAM,EACvB,oBAAoB,UAAQ,EAC5B,WAAW,CAAC,EAAE,oBAAoB,GACjC,OAAO,CAAC,0BAA0B,CAAC,CA8HrC"}
@@ -0,0 +1,561 @@
1
+ import { BudgetedRepoScanner, joinRepoPath } from '@cat-factory/kernel';
2
+ import { extractComposeProfiles, extractExternalNetworks, hasBuildDirective, } from '../compose/compose-environment.logic.js';
3
+ import { asRecord, parseOne } from './provision-detect.yaml.js';
4
+ import { withExtras } from './provision-detect.contract.js';
5
+ // The DOCKER-COMPOSE half of provisioning auto-detection: locating a compose file, and the
6
+ // STACK RECIPE a complex compose repo implies (the `-f` override family, external networks,
7
+ // env/config templates to materialize, profiles, SQL seed dumps, and the report-only repo-CLI
8
+ // hint), assembled into a `docker-compose` recommendation.
9
+ //
10
+ // Split out of `provision-detect.logic.ts`, which keeps the Kubernetes half and the two entry
11
+ // points and imports this module, so neither file carries the other's heuristics. Same seam
12
+ // `provision-detect.kubernetes.ts` was extracted along. Detection stays deterministic and
13
+ // checkout-free; nothing here is auto-applied beyond the pre-selected base layers.
14
+ // Compose file names, canonical-first: the officially-preferred `compose.yaml`, then the legacy
15
+ // `docker-compose.*`, then the auto-merged `*.override.*`, then the common env-variant names. The
16
+ // first present name wins as the recommended `composePath`, so the base names must precede the
17
+ // overrides/variants.
18
+ const COMPOSE_FILES = [
19
+ 'compose.yaml',
20
+ 'compose.yml',
21
+ 'docker-compose.yaml',
22
+ 'docker-compose.yml',
23
+ 'compose.override.yaml',
24
+ 'compose.override.yml',
25
+ 'docker-compose.override.yaml',
26
+ 'docker-compose.override.yml',
27
+ 'docker-compose.prod.yaml',
28
+ 'docker-compose.prod.yml',
29
+ 'docker-compose.dev.yaml',
30
+ 'docker-compose.dev.yml',
31
+ // A bare `dev.yml` base (the acme-monolith `docker/dev.yml` shape) — lowest priority so a
32
+ // canonical name still wins, but recognized so a complex multi-file compose repo is detected
33
+ // (its OS overrides `dev.<os>.override.yml` become recipe compose-file candidates).
34
+ 'dev.yaml',
35
+ 'dev.yml',
36
+ ];
37
+ // Bare `dev.ya?ml` is an AMBIGUOUS name — Ansible playbooks, tool/CLI config, and CI files all use
38
+ // it — so unlike the canonical `compose.*`/`docker-compose.*` names it is only accepted as a compose
39
+ // file when it actually declares a `services:` map (an empty/absent one ⇒ it isn't a compose file).
40
+ const AMBIGUOUS_COMPOSE_FILES = new Set(['dev.yaml', 'dev.yml']);
41
+ // The built-in (canonical) compose names, as a Set for a cheap membership test. A convention-added
42
+ // EXTRA name (not in here) is non-canonical, so — like the bare `dev.*` names — it is trusted as a
43
+ // compose file only when it actually declares `services:` (see `findCompose`).
44
+ const COMPOSE_FILE_SET = new Set(COMPOSE_FILES);
45
+ // Directories (relative to the service root) a compose file commonly nests under, in addition to
46
+ // the root itself. One `listDir` per entry (cheap membership test against COMPOSE_FILES).
47
+ const COMPOSE_DIR_CANDIDATES = ['', 'deploy', 'docker', '.docker', 'compose'];
48
+ // ---- Slice 2: stack-recipe detection (compose repos) -----------------------------------------
49
+ // All of the below feed a `docker-compose` recommendation's `recipe` + the recipe candidate arrays
50
+ // (compose-file layering / profiles / seed dumps) + the report-only repo-CLI hint. Detection stays
51
+ // deterministic + checkout-free; nothing is auto-applied beyond the pre-selected base layers.
52
+ // Template-file suffixes that materialize into a gitignored target (`.env.dev.local-dist` →
53
+ // `.env.dev.local`, `.split.yaml.dist` → `.split.yaml`, `.env.example` → `.env`). Longest/most
54
+ // specific first so a file is stripped by exactly one suffix. `strong` marks the config-template
55
+ // conventions (`-dist`/`.dist`, near-exclusively used for env/config) that accept any config-like
56
+ // target; the general `.example`/`.sample`/… suffixes accept only env-like targets so a non-env
57
+ // `values.yaml.example` (a Helm values sample) isn't scheduled to materialize `values.yaml`.
58
+ const ENV_TEMPLATE_SUFFIXES = [
59
+ { suffix: '-dist', strong: true },
60
+ { suffix: '.dist', strong: true },
61
+ { suffix: '.example', strong: false },
62
+ { suffix: '.sample', strong: false },
63
+ { suffix: '.template', strong: false },
64
+ { suffix: '.tmpl', strong: false },
65
+ ];
66
+ // Directories (relative to the service root) an env-template commonly sits in, beside the compose
67
+ // file's own dir. One `listDir` each; bounded by the read budget.
68
+ const ENV_TEMPLATE_DIR_CANDIDATES = ['', 'config', 'env', 'docker', '.docker'];
69
+ // Cap on materialization pairs surfaced, so a decoy-heavy repo can't produce an unbounded recipe.
70
+ const MAX_ENV_FILES = 20;
71
+ // Directories (relative to the service root) a SQL seed dump commonly lives under; each is scanned
72
+ // at its own level AND one level into immediate child dirs (acme's
73
+ // `deployment/acme-db-dummy/*.sql` shape).
74
+ const SEED_DIRS = [
75
+ 'deployment',
76
+ 'seed',
77
+ 'seeds',
78
+ 'db',
79
+ 'database',
80
+ 'sql',
81
+ 'docker-entrypoint-initdb.d',
82
+ 'fixtures',
83
+ 'dumps',
84
+ ];
85
+ // Cap on seed-dump candidates surfaced.
86
+ const MAX_SEED_DUMPS = 12;
87
+ // A `<stem>.<os>[.override].ya?ml` OS-specific compose override (`dev.wsl.override.yml`,
88
+ // `compose.mac.yml`). The OS token is normalized to the candidate schema's `os` picklist.
89
+ const OS_OVERRIDE_RE = /^(.+?)\.(wsl|mac|macos|osx|linux|windows|win)(?:\.override)?\.ya?ml$/i;
90
+ // Report-only repo-CLI hint (imperative bring-up the deterministic scan can't read — a nudge toward
91
+ // the slice-8 analyst). Detection NEVER parses these files; it only flags their presence.
92
+ const MAKEFILE_NAMES = ['Makefile', 'makefile', 'GNUmakefile'];
93
+ const JUSTFILE_NAMES = ['justfile', 'Justfile', '.justfile'];
94
+ const TASKFILE_NAMES = ['Taskfile.yml', 'Taskfile.yaml', 'taskfile.yml', 'taskfile.yaml'];
95
+ // Monorepo "service container" dirs an env/config template commonly lives ONE LEVEL DOWN in
96
+ // (`services/app/.env.dev.local-dist`, `apps/web/.env.example`). Scanned a single level deep by
97
+ // `collectEnvFileTemplates` in addition to the root-level dirs, so a per-service template outside
98
+ // the compose dir is still surfaced (the pilot's documented `services/app/` gap).
99
+ const ENV_TEMPLATE_CONTAINER_DIRS = ['services', 'apps', 'packages'];
100
+ /**
101
+ * The compose file names to try, canonical-first: the built-in {@link COMPOSE_FILES} then any
102
+ * deployment-supplied extras (lowest priority, so a canonical name still wins). The
103
+ * {@link AMBIGUOUS_COMPOSE_FILES} `services:`-required guard still applies to the bare `dev.*` names.
104
+ */
105
+ function resolveComposeFileNames(conventions) {
106
+ return withExtras(COMPOSE_FILES, conventions?.composeFiles);
107
+ }
108
+ function resolveComposeDirs(conventions) {
109
+ return withExtras(COMPOSE_DIR_CANDIDATES, conventions?.composeDirs);
110
+ }
111
+ function resolveSeedDirs(conventions) {
112
+ return withExtras(SEED_DIRS, conventions?.seedDirs);
113
+ }
114
+ function resolveEnvTemplateDirs(conventions) {
115
+ return withExtras(ENV_TEMPLATE_DIR_CANDIDATES, conventions?.envTemplateDirs);
116
+ }
117
+ /**
118
+ * Locate a Docker Compose file for the service, checking the service root AND the dirs it commonly
119
+ * nests under (`deploy/`, `docker/`, …). One `listDir` per candidate dir; the canonical file name
120
+ * wins (COMPOSE_FILES is canonical-first). Also parses the `services:` keys (for the service
121
+ * picker), external networks + profiles (for the recipe), and the containing dir's listing (for the
122
+ * `-f` override family).
123
+ */
124
+ export async function findCompose(scanner, root, conventions) {
125
+ const composeFileNames = resolveComposeFileNames(conventions);
126
+ for (const dir of resolveComposeDirs(conventions)) {
127
+ const dirPath = joinRepoPath(root, dir);
128
+ const entries = await scanner.listDir(dirPath);
129
+ if (entries.length === 0)
130
+ continue;
131
+ const names = new Set(entries.filter((e) => e.type !== 'dir').map((e) => e.name));
132
+ for (const candidate of composeFileNames) {
133
+ if (!names.has(candidate))
134
+ continue;
135
+ const path = joinRepoPath(dirPath, candidate);
136
+ const content = await scanner.getFile(path);
137
+ const doc = content ? parseOne(content) : null;
138
+ const servicesRecord = asRecord(doc?.services) ?? {};
139
+ const services = Object.keys(servicesRecord);
140
+ // An ambiguous bare `dev.ya?ml` — or ANY convention-added extra name, which is non-canonical
141
+ // by definition — is only a compose file when it declares services; otherwise it's some other
142
+ // YAML (CLI/CI/Ansible/app config) that merely matches the name and must not be detected as
143
+ // compose. Canonical `compose.*`/`docker-compose.*` names are trusted without this guard.
144
+ const requiresServices = AMBIGUOUS_COMPOSE_FILES.has(candidate) || !COMPOSE_FILE_SET.has(candidate);
145
+ if (requiresServices && services.length === 0)
146
+ continue;
147
+ // Single source of truth with the provider's build-mode rejection: any service with a
148
+ // `build:` means the stack builds from source, so build mode is required to provision it.
149
+ const hasBuild = Object.values(servicesRecord).some((s) => hasBuildDirective(s));
150
+ return {
151
+ path,
152
+ dir: dirPath,
153
+ baseName: candidate,
154
+ entries,
155
+ services,
156
+ hasBuild,
157
+ externalNetworks: doc ? extractExternalNetworks(doc) : [],
158
+ profiles: doc ? extractComposeProfiles(doc) : [],
159
+ };
160
+ }
161
+ }
162
+ return null;
163
+ }
164
+ /**
165
+ * Build the compose-service picker when a compose file declares MORE THAN ONE service. Pre-selects
166
+ * the service whose key matches the service directory's basename, else the first declared service.
167
+ * One/zero services ⇒ `undefined` (no picker).
168
+ */
169
+ function buildComposeServiceCandidates(compose, serviceBasename) {
170
+ if (compose.services.length <= 1)
171
+ return undefined;
172
+ const recommendedKey = compose.services.includes(serviceBasename)
173
+ ? serviceBasename
174
+ : compose.services[0];
175
+ return compose.services.map((service) => ({
176
+ composePath: compose.path,
177
+ service,
178
+ recommended: service === recommendedKey,
179
+ }));
180
+ }
181
+ /** The compose "stem" of a file name — the name with its `.yaml`/`.yml` extension stripped. */
182
+ function composeStem(baseName) {
183
+ return baseName.replace(/\.ya?ml$/i, '');
184
+ }
185
+ /** Normalize an OS token from an override file name onto the candidate schema's `os` picklist. */
186
+ function normalizeOs(token) {
187
+ const t = token.toLowerCase();
188
+ if (t === 'wsl')
189
+ return 'wsl';
190
+ if (t === 'mac' || t === 'macos' || t === 'osx')
191
+ return 'mac';
192
+ if (t === 'linux')
193
+ return 'linux';
194
+ return 'windows'; // windows | win
195
+ }
196
+ /** The OS an override file targets when it belongs to `stem`'s family (`dev.wsl.override.yml`), else null. */
197
+ function overrideOsFor(name, stem) {
198
+ const m = OS_OVERRIDE_RE.exec(name);
199
+ return m && m[1] === stem ? normalizeOs(m[2]) : null;
200
+ }
201
+ /** True when `name` is a NON-OS `<stem>.override.ya?ml` auto-merge override of the found base. */
202
+ function isBaseOverride(name, stem) {
203
+ const m = /^(.+?)\.override\.ya?ml$/i.exec(name);
204
+ return m !== null && m[1] === stem;
205
+ }
206
+ /**
207
+ * Assemble the compose-file layering from the base file's own directory listing. The primary base +
208
+ * any `<stem>.override.ya?ml` auto-merge sibling become ordered base layers (pre-selected into
209
+ * `recipe.composeFiles`); OS-specific overrides (`dev.<os>.override.yml`) are surfaced as opt-in
210
+ * candidates annotated with `os` and NOT auto-layered. A lone base file with no family ⇒ `{}` (the
211
+ * simple `composePath` suffices — no recipe layering needed).
212
+ */
213
+ export function collectComposeFiles(compose) {
214
+ const stem = composeStem(compose.baseName);
215
+ const baseFiles = [compose.path];
216
+ const baseOverrideNames = [];
217
+ const osOverrides = [];
218
+ for (const entry of compose.entries) {
219
+ if (entry.type === 'dir' || entry.name === compose.baseName)
220
+ continue;
221
+ const os = overrideOsFor(entry.name, stem);
222
+ if (os)
223
+ osOverrides.push({ path: joinRepoPath(compose.dir, entry.name), name: entry.name, os });
224
+ else if (isBaseOverride(entry.name, stem))
225
+ baseOverrideNames.push(entry.name);
226
+ }
227
+ // No override family beyond the single base file ⇒ nothing to layer.
228
+ if (osOverrides.length === 0 && baseOverrideNames.length === 0)
229
+ return {};
230
+ for (const name of baseOverrideNames.sort())
231
+ baseFiles.push(joinRepoPath(compose.dir, name));
232
+ osOverrides.sort((a, b) => a.name.localeCompare(b.name));
233
+ const composeFileCandidates = [
234
+ ...baseFiles.map((path) => ({ path, name: path.split('/').pop() ?? path, recommended: true })),
235
+ ...osOverrides.map((o) => ({ path: o.path, name: o.name, os: o.os, recommended: false })),
236
+ ];
237
+ return { composeFiles: baseFiles, composeFileCandidates };
238
+ }
239
+ /** Map a template file name to its materialization target (stripped suffix), or null when it isn't
240
+ * a config/env template (`README.dist` → null; `.env.dev.local-dist` → `.env.dev.local`;
241
+ * `values.yaml.example` → null — a Helm values sample, not env). A `strong` (`-dist`/`.dist`)
242
+ * suffix accepts any config-like target; the general suffixes accept only an env-like target. */
243
+ function deriveEnvTemplateTarget(name) {
244
+ for (const { suffix, strong } of ENV_TEMPLATE_SUFFIXES) {
245
+ if (name.length <= suffix.length || !name.endsWith(suffix))
246
+ continue;
247
+ const target = name.slice(0, -suffix.length);
248
+ const accepted = strong ? isConfigLikeName(target) : isEnvLikeName(target);
249
+ return accepted ? target : null;
250
+ }
251
+ return null;
252
+ }
253
+ /** True when a target is an env file per se — a dotfile or an `env`-bearing name (`.env`,
254
+ * `.env.dev.local`, `environment.local`). The bar the general (non-`dist`) template suffixes clear. */
255
+ function isEnvLikeName(target) {
256
+ return target.startsWith('.') || target.toLowerCase().includes('env');
257
+ }
258
+ /** True when a template's stripped target looks like an env/config file (so we don't materialize a
259
+ * `README.dist` or a `.tar.dist`). A dotfile, an `env`-bearing name, or a config extension. */
260
+ function isConfigLikeName(target) {
261
+ const lower = target.toLowerCase();
262
+ return (isEnvLikeName(target) || /\.(ya?ml|json|ini|conf|cfg|config|properties|toml|local)$/.test(lower));
263
+ }
264
+ /**
265
+ * Find committed env/config TEMPLATE files (`*-dist` / `*.example` / …) beside the compose file and
266
+ * in the service root's common config dirs, and pair each with its gitignored target. Deduped by
267
+ * target; bounded by `MAX_ENV_FILES`. These become `recipe.envFiles` — materialized before `up`.
268
+ *
269
+ * Scans, in order: the compose dir, the root-level config dirs (`ENV_TEMPLATE_DIR_CANDIDATES` +
270
+ * any deployment `conventions.envTemplateDirs`), then ONE LEVEL DOWN into the monorepo
271
+ * service-container dirs (`ENV_TEMPLATE_CONTAINER_DIRS` — `services/<svc>/`, `apps/<svc>/`), so a
272
+ * per-service template that lives outside the compose dir (the pilot's `services/app/.env.dev.local-dist`
273
+ * gap) is still surfaced. First template seen for a given target wins; the root-level dirs are scanned
274
+ * before the deeper container dirs so a root/compose-dir template takes precedence.
275
+ */
276
+ export async function collectEnvFileTemplates(scanner, root, composeDir, conventions) {
277
+ const pairs = [];
278
+ const seenTargets = new Set();
279
+ const sorted = () => pairs.sort((a, b) => a.template.localeCompare(b.template));
280
+ // Scan one flat directory; returns true once MAX_ENV_FILES is reached (caller stops).
281
+ const scanDir = async (dir) => {
282
+ // Sort by name so the dedup-by-target choice (first template seen wins) is deterministic
283
+ // regardless of the reader's directory-listing order.
284
+ const entries = [...(await scanner.listDir(dir))].sort((a, b) => a.name.localeCompare(b.name));
285
+ for (const entry of entries) {
286
+ if (entry.type === 'dir')
287
+ continue;
288
+ const target = deriveEnvTemplateTarget(entry.name);
289
+ if (!target)
290
+ continue;
291
+ const targetPath = joinRepoPath(dir, target);
292
+ if (seenTargets.has(targetPath))
293
+ continue;
294
+ seenTargets.add(targetPath);
295
+ pairs.push({ template: joinRepoPath(dir, entry.name), target: targetPath });
296
+ if (pairs.length >= MAX_ENV_FILES)
297
+ return true;
298
+ }
299
+ return false;
300
+ };
301
+ const rootDirs = [
302
+ ...new Set([
303
+ composeDir,
304
+ ...resolveEnvTemplateDirs(conventions).map((d) => joinRepoPath(root, d)),
305
+ ]),
306
+ ];
307
+ for (const dir of rootDirs) {
308
+ if (await scanDir(dir))
309
+ return sorted();
310
+ }
311
+ // One level into monorepo service containers (`services/app/…`), children sorted for determinism.
312
+ for (const container of ENV_TEMPLATE_CONTAINER_DIRS) {
313
+ const containerDir = joinRepoPath(root, container);
314
+ const children = [...(await scanner.listDir(containerDir))]
315
+ .filter((e) => e.type === 'dir')
316
+ .sort((a, b) => a.name.localeCompare(b.name));
317
+ for (const child of children) {
318
+ if (await scanDir(joinRepoPath(containerDir, child.name)))
319
+ return sorted();
320
+ }
321
+ }
322
+ return sorted();
323
+ }
324
+ // Whole-token matches (bounded by `^`/`$` or a non-letter — `-`, `_`, `.`, digits — so `pre` does
325
+ // NOT match inside `compressed` and `data` DOES match inside `add_data`) for the seed-dump ranking.
326
+ const SEED_DATA_TOKENS = /(^|[^a-z])(seed|dummy|data|dump|fixture|sample)([^a-z]|$)/;
327
+ const SEED_SCHEMA_TOKENS = /(^|[^a-z])(pre|schema|structure|ddl|migration|create|drop)([^a-z]|$)/;
328
+ /** Rank a SQL dump for the seed pre-selection: prefer full seed/dummy data, deprioritize
329
+ * schema/pre/structure-only dumps. Higher wins; ties break deterministically by path. */
330
+ function rankSeedDump(name) {
331
+ const lower = name.toLowerCase();
332
+ let score = 0;
333
+ if (SEED_DATA_TOKENS.test(lower))
334
+ score += 2;
335
+ if (SEED_SCHEMA_TOKENS.test(lower))
336
+ score -= 1;
337
+ return score;
338
+ }
339
+ /**
340
+ * Scan the seed-ish directories for `.sql` dumps (each dir + one level into its child dirs, the
341
+ * `deployment/<db>/*.sql` shape) and surface them as low-confidence candidates — the wizard confirms
342
+ * one into a `compose-exec` seed-import step (never auto-applied). The heuristically-fullest dump is
343
+ * pre-selected.
344
+ */
345
+ async function collectSeedDumps(scanner, root, conventions) {
346
+ const found = [];
347
+ const seen = new Set();
348
+ const addSql = (dir, name) => {
349
+ if (!name.toLowerCase().endsWith('.sql'))
350
+ return;
351
+ const path = joinRepoPath(dir, name);
352
+ if (seen.has(path))
353
+ return;
354
+ seen.add(path);
355
+ found.push({ path, name });
356
+ };
357
+ // Collect `.sql` dumps for one directory entry: a file is added directly; a dir is scanned
358
+ // one level in. Extracted so the child-dir loop doesn't nest under the two outer loops
359
+ // (keeps max-depth ≤ 4).
360
+ const scanEntry = async (dir, entry) => {
361
+ if (entry.type !== 'dir') {
362
+ addSql(dir, entry.name);
363
+ return;
364
+ }
365
+ // A `migrations`/`migration` child holds schema DDL, not seed data — never a seed dump.
366
+ if (/^migrations?$/i.test(entry.name))
367
+ return;
368
+ const childDir = joinRepoPath(dir, entry.name);
369
+ for (const child of await scanner.listDir(childDir)) {
370
+ if (child.type !== 'dir')
371
+ addSql(childDir, child.name);
372
+ if (found.length >= MAX_SEED_DUMPS)
373
+ break;
374
+ }
375
+ };
376
+ for (const rel of resolveSeedDirs(conventions)) {
377
+ if (found.length >= MAX_SEED_DUMPS)
378
+ break;
379
+ const dir = joinRepoPath(root, rel);
380
+ const entries = await scanner.listDir(dir);
381
+ for (const entry of entries) {
382
+ if (found.length >= MAX_SEED_DUMPS)
383
+ break;
384
+ await scanEntry(dir, entry);
385
+ }
386
+ }
387
+ if (found.length === 0)
388
+ return [];
389
+ // Sort by path so both the surfaced order and the pre-selection tie-break are deterministic
390
+ // regardless of the reader's directory-listing order.
391
+ found.sort((a, b) => a.path.localeCompare(b.path));
392
+ let bestIdx = 0;
393
+ let bestScore = rankSeedDump(found[0].name);
394
+ for (let i = 1; i < found.length; i++) {
395
+ const score = rankSeedDump(found[i].name);
396
+ if (score > bestScore) {
397
+ bestScore = score;
398
+ bestIdx = i;
399
+ }
400
+ }
401
+ return found.map((f, i) => ({ path: f.path, name: f.name, recommended: i === bestIdx }));
402
+ }
403
+ /**
404
+ * A REPORT-ONLY hint that the repo carries its own imperative bring-up — a `bin/*console*` repo CLI,
405
+ * a Makefile, a justfile, or a Taskfile. Detection NEVER parses these files; it only flags the first
406
+ * one found (repo-CLI first, then Makefile → justfile → Taskfile) so the wizard can nudge toward the
407
+ * slice-8 analyst. `rootEntries` is the already-read root listing (no extra read for the top-level files).
408
+ */
409
+ async function detectRepoCliHint(scanner, root, rootEntries) {
410
+ const fileNames = new Set(rootEntries.filter((e) => e.type !== 'dir').map((e) => e.name));
411
+ const hasBin = rootEntries.some((e) => e.type === 'dir' && e.name === 'bin');
412
+ if (hasBin) {
413
+ for (const entry of await scanner.listDir(joinRepoPath(root, 'bin'))) {
414
+ if (entry.type === 'dir')
415
+ continue;
416
+ const lower = entry.name.toLowerCase();
417
+ if (lower.includes('console') ||
418
+ lower.includes('cli') ||
419
+ lower === 'dev' ||
420
+ lower === 'setup') {
421
+ return { path: joinRepoPath(root, 'bin', entry.name), kind: 'repo-cli' };
422
+ }
423
+ }
424
+ }
425
+ for (const name of MAKEFILE_NAMES) {
426
+ if (fileNames.has(name))
427
+ return { path: joinRepoPath(root, name), kind: 'makefile' };
428
+ }
429
+ for (const name of JUSTFILE_NAMES) {
430
+ if (fileNames.has(name))
431
+ return { path: joinRepoPath(root, name), kind: 'justfile' };
432
+ }
433
+ for (const name of TASKFILE_NAMES) {
434
+ if (fileNames.has(name))
435
+ return { path: joinRepoPath(root, name), kind: 'taskfile' };
436
+ }
437
+ return undefined;
438
+ }
439
+ /**
440
+ * Build the `docker-compose` recommendation. Beyond the base `composePath` + build-mode detection,
441
+ * this reads the STACK RECIPE a complex compose repo implies (the acme-monolith pilot): multi-`-f`
442
+ * layering, external networks, env-file materialization → `recipe`; profiles + seed dumps →
443
+ * candidate arrays the wizard confirms; a repo-CLI hint → the analyst nudge. When NONE of those are
444
+ * present the output is exactly the simple single-file recommendation (no `recipe`, no extra notes).
445
+ */
446
+ export async function buildComposeRecommendation(scanner, root, compose, serviceBasename, kubernetesAlsoExists = false, conventions) {
447
+ const notes = [
448
+ {
449
+ field: 'provisionType',
450
+ confidence: 'high',
451
+ message: `Detected a Docker Compose file at ${compose.path}.`,
452
+ },
453
+ ];
454
+ // A service that declares `build:` can only run in build-from-source mode (the checkout-free
455
+ // image-pull path would reject it), so recommend build mode — a Docker-daemon capability, so
456
+ // only a local deployment can provision it.
457
+ if (compose.hasBuild) {
458
+ notes.push({
459
+ field: 'composeBuild',
460
+ confidence: 'high',
461
+ 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.',
462
+ });
463
+ }
464
+ // Symmetric to the kubernetes path's `compose` note: when we recommend compose because it's
465
+ // the selected tab but k8s manifests also exist, say so (the user can switch).
466
+ if (kubernetesAlsoExists) {
467
+ notes.push({
468
+ field: 'kubernetes',
469
+ confidence: 'low',
470
+ 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.',
471
+ });
472
+ }
473
+ const composeServiceCandidates = buildComposeServiceCandidates(compose, serviceBasename);
474
+ if (composeServiceCandidates) {
475
+ const rec = composeServiceCandidates.find((s) => s.recommended);
476
+ notes.push({
477
+ field: 'composeService',
478
+ confidence: 'low',
479
+ 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.`,
480
+ });
481
+ }
482
+ // --- Stack recipe detection (populated only when the repo is actually recipe-shaped) ----------
483
+ const recipe = {};
484
+ const rootEntries = await scanner.listDir(root);
485
+ const { composeFiles, composeFileCandidates } = collectComposeFiles(compose);
486
+ if (composeFiles) {
487
+ recipe.composeFiles = composeFiles;
488
+ const osCount = composeFileCandidates.filter((c) => c.os).length;
489
+ notes.push({
490
+ field: 'composeFiles',
491
+ confidence: 'high',
492
+ message: `Layered ${composeFiles.length} compose file(s): ${composeFiles.join(' → ')}.${osCount > 0 ? ` ${osCount} OS-specific override(s) surfaced — pick the one matching your machine.` : ''}`,
493
+ });
494
+ }
495
+ if (compose.externalNetworks.length > 0) {
496
+ recipe.externalNetworks = compose.externalNetworks;
497
+ notes.push({
498
+ field: 'externalNetworks',
499
+ confidence: 'high',
500
+ message: `This project expects external network(s) to already exist: ${compose.externalNetworks.join(', ')}. They must be created before it comes up.`,
501
+ });
502
+ notes.push({
503
+ field: 'sharedStackRefs',
504
+ confidence: 'low',
505
+ 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.`,
506
+ });
507
+ }
508
+ const envFiles = await collectEnvFileTemplates(scanner, root, compose.dir, conventions);
509
+ if (envFiles.length > 0) {
510
+ recipe.envFiles = envFiles;
511
+ notes.push({
512
+ field: 'envFiles',
513
+ confidence: 'low',
514
+ message: `Found ${envFiles.length} env/config template(s) to materialize before up: ${envFiles.map((e) => `${e.template} → ${e.target}`).join(', ')}. Confirm each pair.`,
515
+ });
516
+ }
517
+ const profileCandidates = compose.profiles.length > 0
518
+ ? compose.profiles.map((profile) => ({ profile, recommended: false }))
519
+ : undefined;
520
+ if (profileCandidates) {
521
+ notes.push({
522
+ field: 'composeProfiles',
523
+ confidence: 'low',
524
+ message: `The compose file declares ${profileCandidates.length} profile(s): ${compose.profiles.join(', ')}. All surfaced default-off — enable the optional service groups you need.`,
525
+ });
526
+ }
527
+ const seedDumpCandidates = await collectSeedDumps(scanner, root, conventions);
528
+ if (seedDumpCandidates.length > 0) {
529
+ const pick = seedDumpCandidates.find((s) => s.recommended);
530
+ notes.push({
531
+ field: 'seedDump',
532
+ confidence: 'low',
533
+ 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.`,
534
+ });
535
+ }
536
+ const repoCliHint = await detectRepoCliHint(scanner, root, rootEntries);
537
+ if (repoCliHint) {
538
+ notes.push({
539
+ field: 'repoCli',
540
+ confidence: 'low',
541
+ 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.`,
542
+ });
543
+ }
544
+ const provisioning = {
545
+ type: 'docker-compose',
546
+ composePath: compose.path,
547
+ ...(compose.hasBuild ? { composeBuild: true } : {}),
548
+ ...(Object.keys(recipe).length > 0 ? { recipe } : {}),
549
+ };
550
+ return {
551
+ detected: true,
552
+ provisioning,
553
+ ...(composeServiceCandidates ? { composeServiceCandidates } : {}),
554
+ ...(composeFileCandidates ? { composeFileCandidates } : {}),
555
+ ...(profileCandidates ? { profileCandidates } : {}),
556
+ ...(seedDumpCandidates.length > 0 ? { seedDumpCandidates } : {}),
557
+ ...(repoCliHint ? { repoCliHint } : {}),
558
+ notes,
559
+ };
560
+ }
561
+ //# sourceMappingURL=provision-detect.compose.js.map