container-superposition 0.1.1 → 0.1.4

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 (178) hide show
  1. package/README.md +569 -8
  2. package/dist/scripts/init.js +436 -254
  3. package/dist/scripts/init.js.map +1 -1
  4. package/dist/tool/commands/doctor.d.ts +15 -0
  5. package/dist/tool/commands/doctor.d.ts.map +1 -0
  6. package/dist/tool/commands/doctor.js +862 -0
  7. package/dist/tool/commands/doctor.js.map +1 -0
  8. package/dist/tool/commands/explain.d.ts +13 -0
  9. package/dist/tool/commands/explain.d.ts.map +1 -0
  10. package/dist/tool/commands/explain.js +299 -0
  11. package/dist/tool/commands/explain.js.map +1 -0
  12. package/dist/tool/commands/list.d.ts +16 -0
  13. package/dist/tool/commands/list.d.ts.map +1 -0
  14. package/dist/tool/commands/list.js +121 -0
  15. package/dist/tool/commands/list.js.map +1 -0
  16. package/dist/tool/commands/plan.d.ts +67 -0
  17. package/dist/tool/commands/plan.d.ts.map +1 -0
  18. package/dist/tool/commands/plan.js +851 -0
  19. package/dist/tool/commands/plan.js.map +1 -0
  20. package/dist/tool/questionnaire/composer.d.ts +16 -2
  21. package/dist/tool/questionnaire/composer.d.ts.map +1 -1
  22. package/dist/tool/questionnaire/composer.js +411 -200
  23. package/dist/tool/questionnaire/composer.js.map +1 -1
  24. package/dist/tool/readme/markdown-parser.d.ts.map +1 -1
  25. package/dist/tool/readme/markdown-parser.js.map +1 -1
  26. package/dist/tool/readme/readme-generator.d.ts.map +1 -1
  27. package/dist/tool/readme/readme-generator.js +11 -6
  28. package/dist/tool/readme/readme-generator.js.map +1 -1
  29. package/dist/tool/schema/deployment-targets.d.ts +77 -0
  30. package/dist/tool/schema/deployment-targets.d.ts.map +1 -0
  31. package/dist/tool/schema/deployment-targets.js +91 -0
  32. package/dist/tool/schema/deployment-targets.js.map +1 -0
  33. package/dist/tool/schema/manifest-migrations.d.ts +51 -0
  34. package/dist/tool/schema/manifest-migrations.d.ts.map +1 -0
  35. package/dist/tool/schema/manifest-migrations.js +159 -0
  36. package/dist/tool/schema/manifest-migrations.js.map +1 -0
  37. package/dist/tool/schema/overlay-loader.d.ts +1 -1
  38. package/dist/tool/schema/overlay-loader.d.ts.map +1 -1
  39. package/dist/tool/schema/overlay-loader.js +42 -14
  40. package/dist/tool/schema/overlay-loader.js.map +1 -1
  41. package/dist/tool/schema/types.d.ts +62 -2
  42. package/dist/tool/schema/types.d.ts.map +1 -1
  43. package/dist/tool/utils/gitignore.d.ts +15 -0
  44. package/dist/tool/utils/gitignore.d.ts.map +1 -0
  45. package/dist/tool/utils/gitignore.js +41 -0
  46. package/dist/tool/utils/gitignore.js.map +1 -0
  47. package/dist/tool/utils/merge.d.ts +134 -0
  48. package/dist/tool/utils/merge.d.ts.map +1 -0
  49. package/dist/tool/utils/merge.js +277 -0
  50. package/dist/tool/utils/merge.js.map +1 -0
  51. package/dist/tool/utils/port-utils.d.ts +29 -0
  52. package/dist/tool/utils/port-utils.d.ts.map +1 -0
  53. package/dist/tool/utils/port-utils.js +128 -0
  54. package/dist/tool/utils/port-utils.js.map +1 -0
  55. package/dist/tool/utils/services-export.d.ts +14 -0
  56. package/dist/tool/utils/services-export.d.ts.map +1 -0
  57. package/dist/tool/utils/services-export.js +478 -0
  58. package/dist/tool/utils/services-export.js.map +1 -0
  59. package/dist/tool/utils/summary.d.ts +69 -0
  60. package/dist/tool/utils/summary.d.ts.map +1 -0
  61. package/dist/tool/utils/summary.js +260 -0
  62. package/dist/tool/utils/summary.js.map +1 -0
  63. package/dist/tool/utils/version.d.ts +9 -0
  64. package/dist/tool/utils/version.d.ts.map +1 -0
  65. package/dist/tool/utils/version.js +32 -0
  66. package/dist/tool/utils/version.js.map +1 -0
  67. package/docs/architecture.md +25 -21
  68. package/docs/deployment-targets.md +150 -0
  69. package/docs/discovery-commands.md +442 -0
  70. package/docs/merge-strategy.md +700 -0
  71. package/docs/minimal-and-editor.md +265 -0
  72. package/docs/overlay-imports.md +209 -0
  73. package/docs/overlay-manifest-refactoring.md +2 -2
  74. package/docs/overlay-metadata-archive.md +1 -1
  75. package/docs/overlays.md +139 -28
  76. package/docs/presets-architecture.md +3 -3
  77. package/docs/presets.md +1 -1
  78. package/docs/publishing.md +36 -35
  79. package/docs/team-workflow.md +540 -0
  80. package/overlays/.presets/data-engineering.yml +392 -0
  81. package/overlays/.presets/event-sourced-service.yml +262 -0
  82. package/overlays/.presets/frontend.yml +287 -0
  83. package/overlays/.presets/k8s-operator-dev.yml +462 -0
  84. package/overlays/{presets → .presets}/microservice.yml +32 -6
  85. package/overlays/.presets/web-api.yml +129 -0
  86. package/overlays/.registry/README.md +1 -1
  87. package/overlays/.registry/deployment-targets.yml +54 -0
  88. package/overlays/.shared/README.md +43 -0
  89. package/overlays/.shared/compose/common-healthchecks.yml +38 -0
  90. package/overlays/.shared/otel/instrumentation.env +20 -0
  91. package/overlays/.shared/otel/otel-base-config.yaml +30 -0
  92. package/overlays/.shared/vscode/recommended-extensions.json +14 -0
  93. package/overlays/README.md +1 -1
  94. package/overlays/cloudflared/README.md +190 -0
  95. package/overlays/cloudflared/devcontainer.patch.json +3 -0
  96. package/overlays/cloudflared/overlay.yml +15 -0
  97. package/overlays/cloudflared/setup.sh +49 -0
  98. package/overlays/cloudflared/verify.sh +21 -0
  99. package/overlays/codex/overlay.yml +1 -0
  100. package/overlays/direnv/README.md +6 -4
  101. package/overlays/direnv/setup.sh +0 -12
  102. package/overlays/duckdb/README.md +274 -0
  103. package/overlays/duckdb/devcontainer.patch.json +10 -0
  104. package/overlays/duckdb/overlay.yml +17 -0
  105. package/overlays/duckdb/setup.sh +45 -0
  106. package/overlays/duckdb/verify.sh +32 -0
  107. package/overlays/git-helpers/overlay.yml +1 -0
  108. package/overlays/grafana/README.md +5 -5
  109. package/overlays/grafana/dashboard-provider.yml +1 -1
  110. package/overlays/grafana/docker-compose.yml +2 -2
  111. package/overlays/grafana/overlay.yml +6 -1
  112. package/overlays/grpc-tools/README.md +242 -0
  113. package/overlays/grpc-tools/devcontainer.patch.json +14 -0
  114. package/overlays/grpc-tools/overlay.yml +14 -0
  115. package/overlays/grpc-tools/setup.sh +57 -0
  116. package/overlays/grpc-tools/verify.sh +47 -0
  117. package/overlays/jaeger/overlay.yml +16 -3
  118. package/overlays/jupyter/.env.example +6 -0
  119. package/overlays/jupyter/README.md +210 -0
  120. package/overlays/jupyter/devcontainer.patch.json +14 -0
  121. package/overlays/jupyter/docker-compose.yml +23 -0
  122. package/overlays/jupyter/overlay.yml +18 -0
  123. package/overlays/jupyter/verify.sh +35 -0
  124. package/overlays/keycloak/.env.example +5 -0
  125. package/overlays/keycloak/README.md +238 -0
  126. package/overlays/keycloak/devcontainer.patch.json +17 -0
  127. package/overlays/keycloak/docker-compose.yml +32 -0
  128. package/overlays/keycloak/overlay.yml +23 -0
  129. package/overlays/keycloak/verify.sh +54 -0
  130. package/overlays/kind/README.md +221 -0
  131. package/overlays/kind/devcontainer.patch.json +10 -0
  132. package/overlays/kind/overlay.yml +18 -0
  133. package/overlays/kind/setup.sh +43 -0
  134. package/overlays/kind/verify.sh +40 -0
  135. package/overlays/localstack/.env.example +6 -0
  136. package/overlays/localstack/README.md +188 -0
  137. package/overlays/localstack/devcontainer.patch.json +21 -0
  138. package/overlays/localstack/docker-compose.yml +25 -0
  139. package/overlays/localstack/overlay.yml +18 -0
  140. package/overlays/localstack/verify.sh +47 -0
  141. package/overlays/loki/overlay.yml +6 -1
  142. package/overlays/mailpit/.env.example +4 -0
  143. package/overlays/mailpit/README.md +191 -0
  144. package/overlays/mailpit/devcontainer.patch.json +20 -0
  145. package/overlays/mailpit/docker-compose.yml +17 -0
  146. package/overlays/mailpit/overlay.yml +26 -0
  147. package/overlays/mailpit/verify.sh +52 -0
  148. package/overlays/modern-cli-tools/overlay.yml +1 -0
  149. package/overlays/mongodb/overlay.yml +12 -2
  150. package/overlays/mysql/overlay.yml +12 -2
  151. package/overlays/nats/overlay.yml +12 -2
  152. package/overlays/ngrok/overlay.yml +2 -1
  153. package/overlays/openapi-tools/README.md +243 -0
  154. package/overlays/openapi-tools/devcontainer.patch.json +10 -0
  155. package/overlays/openapi-tools/overlay.yml +16 -0
  156. package/overlays/openapi-tools/setup.sh +45 -0
  157. package/overlays/openapi-tools/verify.sh +51 -0
  158. package/overlays/otel-collector/overlay.yml.example +26 -0
  159. package/overlays/postgres/overlay.yml +6 -1
  160. package/overlays/prometheus/overlay.yml +6 -1
  161. package/overlays/python/README.md +51 -35
  162. package/overlays/python/devcontainer.patch.json +7 -4
  163. package/overlays/python/setup.sh +50 -23
  164. package/overlays/python/verify.sh +29 -1
  165. package/overlays/rabbitmq/overlay.yml +12 -2
  166. package/overlays/redis/overlay.yml +6 -1
  167. package/overlays/tilt/README.md +259 -0
  168. package/overlays/tilt/devcontainer.patch.json +17 -0
  169. package/overlays/tilt/overlay.yml +19 -0
  170. package/overlays/tilt/setup.sh +25 -0
  171. package/overlays/tilt/verify.sh +24 -0
  172. package/package.json +8 -6
  173. package/tool/README.md +12 -16
  174. package/tool/schema/overlay-manifest.schema.json +64 -4
  175. package/tool/schema/superposition-manifest.schema.json +104 -0
  176. package/overlays/presets/web-api.yml +0 -109
  177. /package/overlays/{presets → .presets}/docs-site.yml +0 -0
  178. /package/overlays/{presets → .presets}/fullstack.yml +0 -0
@@ -6,6 +6,13 @@ import * as yaml from 'js-yaml';
6
6
  import { loadOverlaysConfig } from '../schema/overlay-loader.js';
7
7
  import { loadCustomPatches, hasCustomDirectory, getCustomScriptPaths, } from '../schema/custom-loader.js';
8
8
  import { generateReadme } from '../readme/readme-generator.js';
9
+ import { CURRENT_MANIFEST_VERSION } from '../schema/manifest-migrations.js';
10
+ import { getToolVersion } from '../utils/version.js';
11
+ import { deepMerge, mergePackages, filterDependsOn, applyPortOffsetToEnv, } from '../utils/merge.js';
12
+ import { generatePortsDocumentation } from '../utils/port-utils.js';
13
+ import { generateServicesMarkdown, generateEnvLocalExample } from '../utils/services-export.js';
14
+ import { appendGitignoreSection } from '../utils/gitignore.js';
15
+ import { detectWarnings, generateTips, generateNextSteps, overlaysToServices, portsToPortInfo, } from '../utils/summary.js';
9
16
  // Get __dirname equivalent in ESM
10
17
  const __filename = fileURLToPath(import.meta.url);
11
18
  const __dirname = path.dirname(__filename);
@@ -19,92 +26,6 @@ const REPO_ROOT_CANDIDATES = [
19
26
  const REPO_ROOT = REPO_ROOT_CANDIDATES.find((candidate) => fs.existsSync(path.join(candidate, 'templates')) &&
20
27
  fs.existsSync(path.join(candidate, 'overlays'))) ?? REPO_ROOT_CANDIDATES[0];
21
28
  const TEMPLATES_DIR = path.join(REPO_ROOT, 'templates');
22
- const OVERLAYS_DIR = path.join(REPO_ROOT, 'overlays');
23
- /**
24
- * Deep merge two objects, with special handling for arrays
25
- */
26
- function deepMerge(target, source) {
27
- const output = { ...target };
28
- for (const key in source) {
29
- if (source[key] instanceof Object && key in target) {
30
- if (Array.isArray(source[key])) {
31
- // For arrays, concatenate and deduplicate
32
- output[key] = Array.isArray(target[key])
33
- ? [...new Set([...target[key], ...source[key]])]
34
- : source[key];
35
- }
36
- else if (key === 'remoteEnv') {
37
- // Special handling for remoteEnv to merge PATH variables intelligently
38
- output[key] = mergeRemoteEnv(target[key], source[key]);
39
- }
40
- else {
41
- output[key] = deepMerge(target[key], source[key]);
42
- }
43
- }
44
- else {
45
- output[key] = source[key];
46
- }
47
- }
48
- return output;
49
- }
50
- /**
51
- * Split PATH string on colons, but preserve ${...} variable references
52
- * e.g., "${containerEnv:HOME}/bin:${containerEnv:PATH}" -> ["${containerEnv:HOME}/bin", "${containerEnv:PATH}"]
53
- */
54
- function splitPath(pathString) {
55
- const paths = [];
56
- let current = '';
57
- let braceDepth = 0;
58
- for (let i = 0; i < pathString.length; i++) {
59
- const char = pathString[i];
60
- const nextChar = pathString[i + 1];
61
- if (char === '$' && nextChar === '{') {
62
- current += char;
63
- braceDepth++;
64
- }
65
- else if (char === '}' && braceDepth > 0) {
66
- current += char;
67
- braceDepth--;
68
- }
69
- else if (char === ':' && braceDepth === 0) {
70
- // Split here - we're not inside ${...}
71
- if (current) {
72
- paths.push(current);
73
- }
74
- current = '';
75
- }
76
- else {
77
- current += char;
78
- }
79
- }
80
- // Add the last component
81
- if (current) {
82
- paths.push(current);
83
- }
84
- return paths;
85
- }
86
- /**
87
- * Merge remoteEnv objects, with special handling for PATH variables
88
- */
89
- function mergeRemoteEnv(target, source) {
90
- const output = { ...target };
91
- for (const key in source) {
92
- if (key === 'PATH' && target[key]) {
93
- // Collect PATH components from both target and source using smart split
94
- const targetPaths = splitPath(target[key]).filter((p) => p && p !== '${containerEnv:PATH}');
95
- const sourcePaths = splitPath(source[key]).filter((p) => p && p !== '${containerEnv:PATH}');
96
- // Combine and deduplicate paths, preserving order
97
- const allPaths = [...new Set([...targetPaths, ...sourcePaths])];
98
- // Rebuild PATH with original ${containerEnv:PATH} at the end
99
- output[key] = [...allPaths, '${containerEnv:PATH}'].join(':');
100
- }
101
- else {
102
- // For non-PATH variables, source overwrites target
103
- output[key] = source[key];
104
- }
105
- }
106
- return output;
107
- }
108
29
  /**
109
30
  * Merge packages from apt-get-packages feature
110
31
  */
@@ -118,10 +39,7 @@ function mergeAptPackages(baseConfig, packages) {
118
39
  }
119
40
  else {
120
41
  const existing = baseConfig.features[featureKey].packages || '';
121
- // Filter out empty tokens from split to avoid leading spaces
122
- const existingPackages = existing.split(' ').filter((p) => p);
123
- const newPackages = packages.split(' ').filter((p) => p);
124
- const merged = [...new Set([...existingPackages, ...newPackages])].join(' ');
42
+ const merged = mergePackages(existing, packages);
125
43
  baseConfig.features[featureKey].packages = merged;
126
44
  }
127
45
  return baseConfig;
@@ -140,17 +58,13 @@ function mergeCrossDistroPackages(baseConfig, apt, apk) {
140
58
  // Merge apt packages
141
59
  if (apt) {
142
60
  const existing = baseConfig.features[featureKey].apt || '';
143
- const existingPackages = existing.split(' ').filter((p) => p);
144
- const newPackages = apt.split(' ').filter((p) => p);
145
- const merged = [...new Set([...existingPackages, ...newPackages])].join(' ');
61
+ const merged = mergePackages(existing, apt);
146
62
  baseConfig.features[featureKey].apt = merged;
147
63
  }
148
64
  // Merge apk packages
149
65
  if (apk) {
150
66
  const existing = baseConfig.features[featureKey].apk || '';
151
- const existingPackages = existing.split(' ').filter((p) => p);
152
- const newPackages = apk.split(' ').filter((p) => p);
153
- const merged = [...new Set([...existingPackages, ...newPackages])].join(' ');
67
+ const merged = mergePackages(existing, apk);
154
68
  baseConfig.features[featureKey].apk = merged;
155
69
  }
156
70
  return baseConfig;
@@ -226,12 +140,108 @@ function resolveDependencies(requestedOverlays, allOverlayDefs) {
226
140
  },
227
141
  };
228
142
  }
143
+ /**
144
+ * Prepare overlays for generation by loading configuration, building requested overlay list,
145
+ * filtering for minimal mode, checking compatibility, and resolving dependencies.
146
+ * This shared logic is used by both generateManifestOnly and composeDevContainer.
147
+ */
148
+ function prepareOverlaysForGeneration(answers, overlaysDir) {
149
+ // 1. Load overlay configuration
150
+ const actualOverlaysDir = overlaysDir ?? path.join(REPO_ROOT, 'overlays');
151
+ const indexYmlPath = path.join(actualOverlaysDir, 'index.yml');
152
+ const overlaysConfig = loadOverlaysConfig(actualOverlaysDir, indexYmlPath);
153
+ // Collect all overlay definitions
154
+ const allOverlayDefs = getAllOverlayDefs(overlaysConfig);
155
+ // Build list of requested overlays
156
+ const requestedOverlays = [];
157
+ if (answers.language && answers.language.length > 0)
158
+ requestedOverlays.push(...answers.language);
159
+ if (answers.database && answers.database.length > 0)
160
+ requestedOverlays.push(...answers.database);
161
+ if (answers.observability)
162
+ requestedOverlays.push(...answers.observability);
163
+ if (answers.playwright)
164
+ requestedOverlays.push('playwright');
165
+ if (answers.cloudTools)
166
+ requestedOverlays.push(...answers.cloudTools);
167
+ if (answers.devTools)
168
+ requestedOverlays.push(...answers.devTools);
169
+ // Filter out "minimal" overlays if --minimal flag is set
170
+ let filteredRequestedOverlays = requestedOverlays;
171
+ if (answers.minimal) {
172
+ const minimalExcluded = [];
173
+ filteredRequestedOverlays = requestedOverlays.filter((overlayId) => {
174
+ const overlayDef = allOverlayDefs.find((o) => o.id === overlayId);
175
+ if (overlayDef?.minimal === true) {
176
+ minimalExcluded.push(overlayId);
177
+ return false;
178
+ }
179
+ return true;
180
+ });
181
+ if (minimalExcluded.length > 0) {
182
+ console.log(chalk.dim(` šŸ“¦ Minimal mode: Excluding ${minimalExcluded.length} optional overlay(s): ${minimalExcluded.join(', ')}`));
183
+ }
184
+ }
185
+ // Check compatibility
186
+ const incompatible = [];
187
+ for (const overlayId of filteredRequestedOverlays) {
188
+ const overlayDef = allOverlayDefs.find((o) => o.id === overlayId);
189
+ if (overlayDef?.supports && overlayDef.supports.length > 0) {
190
+ if (!overlayDef.supports.includes(answers.stack)) {
191
+ incompatible.push(`${overlayId} (requires: ${overlayDef.supports.join(', ')})`);
192
+ }
193
+ }
194
+ }
195
+ if (incompatible.length > 0) {
196
+ console.log(chalk.yellow(`\nāš ļø Warning: Some overlays are not compatible with '${answers.stack}' template:`));
197
+ incompatible.forEach((overlay) => {
198
+ console.log(chalk.yellow(` • ${overlay}`));
199
+ });
200
+ console.log(chalk.yellow(`\nThese overlays will be skipped.\n`));
201
+ // Filter out incompatible overlays
202
+ if (answers.database) {
203
+ answers.database = answers.database.filter((d) => !incompatible.some((i) => i.startsWith(d)));
204
+ }
205
+ if (answers.observability) {
206
+ answers.observability = answers.observability.filter((o) => !incompatible.some((i) => i.startsWith(o)));
207
+ }
208
+ // Update requestedOverlays after filtering
209
+ requestedOverlays.length = 0;
210
+ if (answers.language && answers.language.length > 0)
211
+ requestedOverlays.push(...answers.language);
212
+ if (answers.database && answers.database.length > 0)
213
+ requestedOverlays.push(...answers.database);
214
+ if (answers.observability)
215
+ requestedOverlays.push(...answers.observability);
216
+ if (answers.playwright)
217
+ requestedOverlays.push('playwright');
218
+ if (answers.cloudTools)
219
+ requestedOverlays.push(...answers.cloudTools);
220
+ if (answers.devTools)
221
+ requestedOverlays.push(...answers.devTools);
222
+ // Re-apply minimal filtering
223
+ filteredRequestedOverlays = requestedOverlays.filter((overlayId) => {
224
+ const overlayDef = allOverlayDefs.find((o) => o.id === overlayId);
225
+ return !answers.minimal || overlayDef?.minimal !== true;
226
+ });
227
+ }
228
+ // Resolve dependencies
229
+ const { overlays: resolvedOverlays, autoResolved } = resolveDependencies(filteredRequestedOverlays, allOverlayDefs);
230
+ return {
231
+ overlays: resolvedOverlays,
232
+ autoResolved,
233
+ overlaysConfig,
234
+ };
235
+ }
229
236
  /**
230
237
  * Generate superposition.json manifest
231
238
  */
232
239
  function generateManifest(outputPath, answers, overlays, autoResolved, containerName) {
240
+ const toolVersion = getToolVersion();
233
241
  const manifest = {
234
- version: '0.1.0',
242
+ manifestVersion: CURRENT_MANIFEST_VERSION,
243
+ generatedBy: toolVersion,
244
+ version: '0.1.0', // Legacy field for backward compatibility
235
245
  generated: new Date().toISOString(),
236
246
  baseTemplate: answers.stack,
237
247
  baseImage: answers.baseImage === 'custom' && answers.customImage
@@ -265,15 +275,74 @@ function generateManifest(outputPath, answers, overlays, autoResolved, container
265
275
  console.log(chalk.cyan(` ā„¹ļø Used preset: ${answers.preset}`));
266
276
  }
267
277
  }
278
+ /**
279
+ * Load and resolve imports from shared files for an overlay
280
+ */
281
+ function loadImportsForOverlay(overlayName, overlaysDir) {
282
+ let importedConfig = {};
283
+ // Load overlay manifest to get imports
284
+ const overlayDir = path.join(overlaysDir, overlayName);
285
+ const manifestPath = path.join(overlayDir, 'overlay.yml');
286
+ if (!fs.existsSync(manifestPath)) {
287
+ return importedConfig;
288
+ }
289
+ try {
290
+ const manifestContent = fs.readFileSync(manifestPath, 'utf8');
291
+ const manifest = yaml.load(manifestContent);
292
+ if (!manifest.imports ||
293
+ !Array.isArray(manifest.imports) ||
294
+ manifest.imports.length === 0) {
295
+ return importedConfig;
296
+ }
297
+ // Process each import
298
+ for (const importPath of manifest.imports) {
299
+ const fullImportPath = path.join(overlaysDir, importPath);
300
+ if (!fs.existsSync(fullImportPath)) {
301
+ console.warn(chalk.yellow(`āš ļø Import not found: ${importPath} (for overlay: ${overlayName})`));
302
+ continue;
303
+ }
304
+ // Determine file type and merge appropriately
305
+ const ext = path.extname(importPath).toLowerCase();
306
+ if (ext === '.json') {
307
+ // JSON files are merged as devcontainer patches
308
+ const importedPatch = loadJson(fullImportPath);
309
+ importedConfig = deepMerge(importedConfig, importedPatch);
310
+ }
311
+ else if (ext === '.yaml' || ext === '.yml') {
312
+ // YAML files are loaded and merged as devcontainer patches
313
+ try {
314
+ const yamlContent = fs.readFileSync(fullImportPath, 'utf8');
315
+ const importedPatch = yaml.load(yamlContent);
316
+ if (importedPatch && typeof importedPatch === 'object') {
317
+ importedConfig = deepMerge(importedConfig, importedPatch);
318
+ }
319
+ }
320
+ catch (error) {
321
+ console.warn(chalk.yellow(`āš ļø Failed to parse YAML import: ${importPath}`));
322
+ }
323
+ }
324
+ // .env files are handled separately during env merging
325
+ }
326
+ }
327
+ catch (error) {
328
+ console.warn(chalk.yellow(`āš ļø Failed to load imports for overlay: ${overlayName}`));
329
+ }
330
+ return importedConfig;
331
+ }
268
332
  /**
269
333
  * Apply an overlay to the base configuration
270
334
  */
271
- function applyOverlay(baseConfig, overlayName) {
272
- const overlayPath = path.join(OVERLAYS_DIR, overlayName, 'devcontainer.patch.json');
335
+ export function applyOverlay(baseConfig, overlayName, overlaysDir) {
336
+ const overlayPath = path.join(overlaysDir, overlayName, 'devcontainer.patch.json');
273
337
  if (!fs.existsSync(overlayPath)) {
274
338
  console.warn(chalk.yellow(`āš ļø Overlay not found: ${overlayName}`));
275
339
  return baseConfig;
276
340
  }
341
+ // First, load and apply any imports
342
+ const importedConfig = loadImportsForOverlay(overlayName, overlaysDir);
343
+ if (Object.keys(importedConfig).length > 0) {
344
+ baseConfig = deepMerge(baseConfig, importedConfig);
345
+ }
277
346
  const overlay = loadJson(overlayPath);
278
347
  // Special handling for apt-get packages (legacy)
279
348
  if (overlay.features?.['ghcr.io/devcontainers-extra/features/apt-get-packages:1']?.packages) {
@@ -378,20 +447,21 @@ function copyDir(src, dest) {
378
447
  * Copy additional files from overlay to output directory
379
448
  * Excludes devcontainer.patch.json and .env.example (handled separately)
380
449
  */
381
- function copyOverlayFiles(outputPath, overlayName, registry) {
382
- const overlayPath = path.join(OVERLAYS_DIR, overlayName);
450
+ function copyOverlayFiles(outputPath, overlayName, registry, overlaysDir) {
451
+ const overlayPath = path.join(overlaysDir, overlayName);
383
452
  if (!fs.existsSync(overlayPath)) {
384
453
  return;
385
454
  }
386
455
  const entries = fs.readdirSync(overlayPath);
387
456
  let copiedFiles = 0;
388
457
  for (const entry of entries) {
389
- // Skip devcontainer.patch.json, .env.example, docker-compose.yml, setup.sh, verify.sh, and metadata files (handled separately)
458
+ // Skip devcontainer.patch.json, .env.example, docker-compose.yml, setup.sh, verify.sh, .gitignore, and metadata files (handled separately)
390
459
  if (entry === 'devcontainer.patch.json' ||
391
460
  entry === '.env.example' ||
392
461
  entry === 'docker-compose.yml' ||
393
462
  entry === 'setup.sh' ||
394
463
  entry === 'verify.sh' ||
464
+ entry === '.gitignore' ||
395
465
  entry === 'README.md' ||
396
466
  entry === 'overlay.yml') {
397
467
  continue;
@@ -428,10 +498,37 @@ function copyOverlayFiles(outputPath, overlayName, registry) {
428
498
  /**
429
499
  * Merge .env.example files from overlays and apply glue config
430
500
  */
431
- function mergeEnvExamples(outputPath, overlays, portOffset, glueConfig, presetName) {
501
+ function mergeEnvExamples(outputPath, overlays, overlaysDir, portOffset, glueConfig, presetName) {
432
502
  const envSections = [];
433
503
  for (const overlay of overlays) {
434
- const envPath = path.join(OVERLAYS_DIR, overlay, '.env.example');
504
+ // First, check for imports in the overlay and add any .env files from imports
505
+ const overlayDir = path.join(overlaysDir, overlay);
506
+ const manifestPath = path.join(overlayDir, 'overlay.yml');
507
+ if (fs.existsSync(manifestPath)) {
508
+ try {
509
+ const manifestContent = fs.readFileSync(manifestPath, 'utf8');
510
+ const manifest = yaml.load(manifestContent);
511
+ if (manifest.imports && Array.isArray(manifest.imports)) {
512
+ for (const importPath of manifest.imports) {
513
+ const ext = path.extname(importPath).toLowerCase();
514
+ if (ext === '.env') {
515
+ const fullImportPath = path.join(overlaysDir, importPath);
516
+ if (fs.existsSync(fullImportPath)) {
517
+ const content = fs.readFileSync(fullImportPath, 'utf-8').trim();
518
+ if (content) {
519
+ envSections.push(`# Imported from ${importPath}\n${content}`);
520
+ }
521
+ }
522
+ }
523
+ }
524
+ }
525
+ }
526
+ catch (error) {
527
+ // Ignore errors reading manifest
528
+ }
529
+ }
530
+ // Then add the overlay's own .env.example
531
+ const envPath = path.join(overlaysDir, overlay, '.env.example');
435
532
  if (fs.existsSync(envPath)) {
436
533
  const content = fs.readFileSync(envPath, 'utf-8').trim();
437
534
  if (content) {
@@ -479,21 +576,42 @@ function mergeEnvExamples(outputPath, overlays, portOffset, glueConfig, presetNa
479
576
  return true;
480
577
  }
481
578
  /**
482
- * Apply port offset to environment variables in .env content
579
+ * Merge .gitignore files from overlays into the project root .gitignore.
580
+ * Writes to path.dirname(outputPath) — the project root (parent of .devcontainer/).
581
+ * Only appends entries not already present; safe to run multiple times.
582
+ * Returns true if any entries were written.
483
583
  */
484
- function applyPortOffsetToEnv(envContent, offset) {
485
- const lines = envContent.split('\n');
486
- const portVarPattern = /^([A-Z_]*PORT[A-Z_]*)=(\d+)$/;
487
- const modifiedLines = lines.map((line) => {
488
- const match = line.match(portVarPattern);
489
- if (match) {
490
- const [, varName, portValue] = match;
491
- const newPort = parseInt(portValue, 10) + offset;
492
- return `${varName}=${newPort}`;
493
- }
494
- return line;
495
- });
496
- return modifiedLines.join('\n');
584
+ function mergeGitignoreFiles(outputPath, overlays, overlaysDir) {
585
+ const projectRoot = path.dirname(path.resolve(outputPath));
586
+ const destPath = path.join(projectRoot, '.gitignore');
587
+ let anyWritten = false;
588
+ let sectionsWritten = 0;
589
+ for (const overlay of overlays) {
590
+ const gitignorePath = path.join(overlaysDir, overlay, '.gitignore');
591
+ if (!fs.existsSync(gitignorePath))
592
+ continue;
593
+ const content = fs.readFileSync(gitignorePath, 'utf-8').trim();
594
+ if (!content)
595
+ continue;
596
+ const lines = content
597
+ .split('\n')
598
+ .map((l) => l.trim())
599
+ .filter((l) => l.length > 0 && !l.startsWith('#'));
600
+ if (lines.length === 0)
601
+ continue;
602
+ const written = appendGitignoreSection(destPath, `${overlay} (container-superposition)`, lines);
603
+ if (written) {
604
+ anyWritten = true;
605
+ sectionsWritten++;
606
+ }
607
+ }
608
+ if (anyWritten) {
609
+ console.log(chalk.dim(` šŸ“„ Updated .gitignore with entries from ${sectionsWritten} overlay(s)`));
610
+ }
611
+ else {
612
+ console.log(chalk.dim(` šŸ“„ .gitignore already up to date`));
613
+ }
614
+ return anyWritten;
497
615
  }
498
616
  /**
499
617
  * Apply preset glue configuration (README and port mappings)
@@ -526,7 +644,7 @@ function applyGlueConfig(outputPath, glueConfig, presetName, fileRegistry) {
526
644
  /**
527
645
  * Merge docker-compose.yml files from base and overlays into a single file
528
646
  */
529
- function mergeDockerComposeFiles(outputPath, baseStack, overlays, portOffset, customImage) {
647
+ function mergeDockerComposeFiles(outputPath, baseStack, overlays, overlaysDir, portOffset, customImage) {
530
648
  const composeFiles = [];
531
649
  // Add base docker-compose if exists
532
650
  const baseComposePath = path.join(TEMPLATES_DIR, baseStack, '.devcontainer', 'docker-compose.yml');
@@ -535,7 +653,7 @@ function mergeDockerComposeFiles(outputPath, baseStack, overlays, portOffset, cu
535
653
  }
536
654
  // Add overlay docker-compose files
537
655
  for (const overlay of overlays) {
538
- const overlayComposePath = path.join(OVERLAYS_DIR, overlay, 'docker-compose.yml');
656
+ const overlayComposePath = path.join(overlaysDir, overlay, 'docker-compose.yml');
539
657
  if (fs.existsSync(overlayComposePath)) {
540
658
  composeFiles.push(overlayComposePath);
541
659
  }
@@ -583,13 +701,17 @@ function mergeDockerComposeFiles(outputPath, baseStack, overlays, portOffset, cu
583
701
  }
584
702
  // Filter depends_on to only include services that exist
585
703
  const serviceNames = Object.keys(merged.services);
704
+ const serviceNameSet = new Set(serviceNames);
586
705
  for (const serviceName of serviceNames) {
587
706
  const service = merged.services[serviceName];
588
- if (service.depends_on && Array.isArray(service.depends_on)) {
589
- service.depends_on = service.depends_on.filter((dep) => serviceNames.includes(dep));
590
- if (service.depends_on.length === 0) {
707
+ if (service.depends_on !== undefined) {
708
+ const filteredDependsOn = filterDependsOn(service.depends_on, serviceNameSet);
709
+ if (filteredDependsOn === undefined) {
591
710
  delete service.depends_on;
592
711
  }
712
+ else {
713
+ service.depends_on = filteredDependsOn;
714
+ }
593
715
  }
594
716
  }
595
717
  // Remove empty sections
@@ -800,71 +922,61 @@ function copyCustomFiles(customConfig, outputPath, fileRegistry) {
800
922
  fileRegistry.addFile(relativeDest);
801
923
  }
802
924
  }
925
+ /**
926
+ * Generate only the superposition.json manifest without creating .devcontainer files
927
+ * Used for team collaboration workflow where manifest is committed but .devcontainer is gitignored
928
+ */
929
+ export async function generateManifestOnly(answers, overlaysDir, options = {}) {
930
+ // Prepare overlays using shared logic
931
+ const { overlays: resolvedOverlays, autoResolved } = prepareOverlaysForGeneration(answers, overlaysDir);
932
+ // Ensure output directory exists
933
+ const outputPath = answers.outputPath || '.';
934
+ if (!fs.existsSync(outputPath)) {
935
+ fs.mkdirSync(outputPath, { recursive: true });
936
+ }
937
+ // Generate manifest only
938
+ console.log(chalk.cyan('\nšŸ“‹ Generating manifest only (team collaboration mode)...\n'));
939
+ generateManifest(outputPath, answers, resolvedOverlays, autoResolved, answers.containerName);
940
+ console.log(chalk.green(`\nāœ“ Manifest created: ${path.join(outputPath, 'superposition.json')}`));
941
+ console.log(chalk.dim(' Ready for team collaboration workflow.'));
942
+ console.log(chalk.dim(' Commit this manifest to your repository and let team members run "npx container-superposition regen"'));
943
+ // Load overlay configs to get metadata
944
+ const actualOverlaysDir = overlaysDir ?? path.join(REPO_ROOT, 'overlays');
945
+ const indexYmlPath = path.join(actualOverlaysDir, 'index.yml');
946
+ const overlaysConfig = loadOverlaysConfig(actualOverlaysDir, indexYmlPath);
947
+ const allOverlayDefs = getAllOverlayDefs(overlaysConfig);
948
+ const overlayMetadataMap = new Map(allOverlayDefs.map((o) => [o.id, o]));
949
+ const selectedOverlayMetadata = resolvedOverlays
950
+ .map((id) => overlayMetadataMap.get(id))
951
+ .filter((m) => m !== undefined);
952
+ // Return summary for manifest-only mode
953
+ const services = overlaysToServices(selectedOverlayMetadata);
954
+ const warnings = detectWarnings(selectedOverlayMetadata, answers);
955
+ const tips = generateTips(selectedOverlayMetadata, answers);
956
+ const nextSteps = generateNextSteps(true, options.isRegen === true);
957
+ return {
958
+ files: ['superposition.json'],
959
+ services,
960
+ ports: [],
961
+ warnings,
962
+ tips,
963
+ nextSteps,
964
+ portOffset: answers.portOffset ?? 0,
965
+ target: answers.target || 'local',
966
+ isManifestOnly: true,
967
+ manifestPath: path.join(outputPath, 'superposition.json'),
968
+ };
969
+ }
803
970
  /**
804
971
  * Main composition logic
805
972
  */
806
- export async function composeDevContainer(answers) {
807
- // 1. Load overlay configuration
808
- const overlaysDir = path.join(REPO_ROOT, 'overlays');
809
- const indexYmlPath = path.join(REPO_ROOT, 'overlays', 'index.yml');
810
- const overlaysConfig = loadOverlaysConfig(overlaysDir, indexYmlPath);
811
- // Collect all overlay definitions
973
+ export async function composeDevContainer(answers, overlaysDir, options = {}) {
974
+ // Prepare overlays using shared logic
975
+ const actualOverlaysDir = overlaysDir ?? path.join(REPO_ROOT, 'overlays');
976
+ const { overlays: resolvedOverlays, autoResolved, overlaysConfig, } = prepareOverlaysForGeneration(answers, overlaysDir);
977
+ // Get all overlay definitions for later use
812
978
  const allOverlayDefs = getAllOverlayDefs(overlaysConfig);
813
- // Build list of requested overlays
814
- const requestedOverlays = [];
815
- if (answers.language && answers.language.length > 0)
816
- requestedOverlays.push(...answers.language);
817
- if (answers.database && answers.database.length > 0)
818
- requestedOverlays.push(...answers.database);
819
- if (answers.observability)
820
- requestedOverlays.push(...answers.observability);
821
- if (answers.playwright)
822
- requestedOverlays.push('playwright');
823
- if (answers.cloudTools)
824
- requestedOverlays.push(...answers.cloudTools);
825
- if (answers.devTools)
826
- requestedOverlays.push(...answers.devTools);
827
- // Check compatibility
828
- const incompatible = [];
829
- for (const overlayId of requestedOverlays) {
830
- const overlayDef = allOverlayDefs.find((o) => o.id === overlayId);
831
- if (overlayDef?.supports && overlayDef.supports.length > 0) {
832
- if (!overlayDef.supports.includes(answers.stack)) {
833
- incompatible.push(`${overlayId} (requires: ${overlayDef.supports.join(', ')})`);
834
- }
835
- }
836
- }
837
- if (incompatible.length > 0) {
838
- console.log(chalk.yellow(`\nāš ļø Warning: Some overlays are not compatible with '${answers.stack}' template:`));
839
- incompatible.forEach((overlay) => {
840
- console.log(chalk.yellow(` • ${overlay}`));
841
- });
842
- console.log(chalk.yellow(`\nThese overlays will be skipped.\n`));
843
- // Filter out incompatible overlays
844
- if (answers.database) {
845
- answers.database = answers.database.filter((d) => !incompatible.some((i) => i.startsWith(d)));
846
- }
847
- if (answers.observability) {
848
- answers.observability = answers.observability.filter((o) => !incompatible.some((i) => i.startsWith(o)));
849
- }
850
- // Update requestedOverlays after filtering
851
- requestedOverlays.length = 0;
852
- if (answers.language && answers.language.length > 0)
853
- requestedOverlays.push(...answers.language);
854
- if (answers.database && answers.database.length > 0)
855
- requestedOverlays.push(...answers.database);
856
- if (answers.observability)
857
- requestedOverlays.push(...answers.observability);
858
- if (answers.playwright)
859
- requestedOverlays.push('playwright');
860
- if (answers.cloudTools)
861
- requestedOverlays.push(...answers.cloudTools);
862
- if (answers.devTools)
863
- requestedOverlays.push(...answers.devTools);
864
- }
865
- // 2. Resolve dependencies
866
- const { overlays: resolvedOverlays, autoResolved } = resolveDependencies(requestedOverlays, allOverlayDefs);
867
- // 3. Determine base template path
979
+ // Determine base template path
868
980
  const templatePath = path.join(TEMPLATES_DIR, answers.stack, '.devcontainer');
869
981
  if (!fs.existsSync(templatePath)) {
870
982
  throw new Error(`Template not found: ${answers.stack}`);
@@ -946,7 +1058,7 @@ export async function composeDevContainer(answers) {
946
1058
  // 6. Apply overlays
947
1059
  for (const overlay of overlays) {
948
1060
  console.log(chalk.dim(` šŸ”§ Applying overlay: ${chalk.cyan(overlay)}`));
949
- config = applyOverlay(config, overlay);
1061
+ config = applyOverlay(config, overlay, actualOverlaysDir);
950
1062
  }
951
1063
  // 7. Copy template files (docker-compose, scripts, etc.)
952
1064
  const entries = fs.readdirSync(templatePath);
@@ -967,7 +1079,7 @@ export async function composeDevContainer(answers) {
967
1079
  }
968
1080
  // 8. Copy overlay files (docker-compose, configs, etc.)
969
1081
  for (const overlay of overlays) {
970
- copyOverlayFiles(outputPath, overlay, fileRegistry);
1082
+ copyOverlayFiles(outputPath, overlay, fileRegistry, actualOverlaysDir);
971
1083
  }
972
1084
  // 8.5. Copy cross-distro-packages feature if used
973
1085
  if (config.features?.['./features/cross-distro-packages']) {
@@ -982,11 +1094,11 @@ export async function composeDevContainer(answers) {
982
1094
  // 8. Filter docker-compose dependencies based on selected overlays
983
1095
  filterDockerComposeDependencies(outputPath, overlays);
984
1096
  // 9. Merge runServices array in correct order
985
- mergeRunServices(config, overlays);
1097
+ mergeRunServices(config, overlays, actualOverlaysDir);
986
1098
  // 11. Merge docker-compose files into single combined file
987
1099
  if (answers.stack === 'compose') {
988
1100
  const customImage = config._customImage;
989
- mergeDockerComposeFiles(outputPath, answers.stack, overlays, answers.portOffset, customImage);
1101
+ mergeDockerComposeFiles(outputPath, answers.stack, overlays, actualOverlaysDir, answers.portOffset, customImage);
990
1102
  // Update devcontainer.json to reference the combined file
991
1103
  if (config.dockerComposeFile) {
992
1104
  config.dockerComposeFile = 'docker-compose.yml';
@@ -997,7 +1109,7 @@ export async function composeDevContainer(answers) {
997
1109
  applyPortOffsetToDevcontainer(config, answers.portOffset);
998
1110
  }
999
1111
  // Merge setup scripts from overlays into postCreateCommand
1000
- mergeSetupScripts(config, overlays, outputPath, fileRegistry);
1112
+ mergeSetupScripts(config, overlays, outputPath, fileRegistry, actualOverlaysDir);
1001
1113
  // 10. Apply custom patches from .devcontainer/custom/ (if present)
1002
1114
  const customPatches = loadCustomPatches(outputPath);
1003
1115
  if (customPatches) {
@@ -1015,6 +1127,25 @@ export async function composeDevContainer(answers) {
1015
1127
  delete config[key];
1016
1128
  }
1017
1129
  });
1130
+ // Handle editor profile filtering
1131
+ if (answers.editor === 'none' || answers.editor === 'jetbrains') {
1132
+ // Remove VS Code customizations
1133
+ if (config.customizations?.vscode) {
1134
+ if (answers.editor === 'none') {
1135
+ delete config.customizations.vscode;
1136
+ console.log(chalk.dim(` šŸŽØ Editor profile 'none': Removed VS Code customizations`));
1137
+ }
1138
+ else if (answers.editor === 'jetbrains') {
1139
+ // For JetBrains, remove VS Code customizations (future: could add JetBrains-specific settings)
1140
+ delete config.customizations.vscode;
1141
+ console.log(chalk.dim(` šŸŽØ Editor profile 'jetbrains': Removed VS Code customizations`));
1142
+ }
1143
+ // Clean up empty customizations object
1144
+ if (config.customizations && Object.keys(config.customizations).length === 0) {
1145
+ delete config.customizations;
1146
+ }
1147
+ }
1148
+ }
1018
1149
  // 12. Write merged devcontainer.json
1019
1150
  const configPath = path.join(outputPath, 'devcontainer.json');
1020
1151
  fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
@@ -1028,7 +1159,7 @@ export async function composeDevContainer(answers) {
1028
1159
  generateManifest(outputPath, answers, overlays, autoResolved, answers.containerName || config.name);
1029
1160
  fileRegistry.addFile('superposition.json');
1030
1161
  // 14. Merge .env.example files from overlays and apply glue config environment variables
1031
- const envCreated = mergeEnvExamples(outputPath, overlays, answers.portOffset, answers.presetGlueConfig, answers.preset);
1162
+ const envCreated = mergeEnvExamples(outputPath, overlays, actualOverlaysDir, answers.portOffset, answers.presetGlueConfig, answers.preset);
1032
1163
  if (envCreated) {
1033
1164
  fileRegistry.addFile('.env.example');
1034
1165
  }
@@ -1040,6 +1171,10 @@ export async function composeDevContainer(answers) {
1040
1171
  fileRegistry.addFile('.env.example');
1041
1172
  }
1042
1173
  }
1174
+ // 14b. Merge .gitignore files from overlays into project root .gitignore
1175
+ // Note: .gitignore lives at the project root (parent of outputPath), not inside outputPath,
1176
+ // so it is intentionally NOT added to fileRegistry (cleanupStaleFiles must not touch it).
1177
+ mergeGitignoreFiles(outputPath, overlays, actualOverlaysDir);
1043
1178
  // 15. Apply preset glue configuration (README and port mappings) if present
1044
1179
  if (answers.presetGlueConfig) {
1045
1180
  applyGlueConfig(outputPath, answers.presetGlueConfig, answers.preset, fileRegistry);
@@ -1050,8 +1185,84 @@ export async function composeDevContainer(answers) {
1050
1185
  generateReadme(answers, overlays, overlayMetadataMap, outputPath);
1051
1186
  fileRegistry.addFile('README.md');
1052
1187
  console.log(chalk.dim(` šŸ“ Created README.md with documentation from ${overlays.length} overlay(s)`));
1053
- // 17. Clean up stale files from previous runs (preserves superposition.json and .env)
1188
+ // 17. Generate ports.json documentation
1189
+ const portOffset = answers.portOffset ?? 0;
1190
+ // Prepare overlay metadata for summary
1191
+ const selectedOverlayMetadata = overlays
1192
+ .map((id) => overlayMetadataMap.get(id))
1193
+ .filter((m) => m !== undefined);
1194
+ // Extract environment variables from .env.example for connection strings
1195
+ const envPath = path.join(outputPath, '.env.example');
1196
+ const envVars = {};
1197
+ if (fs.existsSync(envPath)) {
1198
+ const envContent = fs.readFileSync(envPath, 'utf8');
1199
+ for (const line of envContent.split('\n')) {
1200
+ const match = line.match(/^([A-Z_][A-Z0-9_]*)=(.*)$/);
1201
+ if (match) {
1202
+ envVars[match[1].toLowerCase()] = match[2];
1203
+ }
1204
+ }
1205
+ }
1206
+ const hasOverlayPorts = overlays.some((o) => overlayMetadataMap.get(o)?.ports?.length);
1207
+ const shouldGeneratePortsDocumentation = portOffset > 0 || hasOverlayPorts;
1208
+ let portsDoc = null;
1209
+ if (shouldGeneratePortsDocumentation) {
1210
+ console.log(chalk.cyan('\nšŸ“” Generating ports documentation...'));
1211
+ portsDoc = generatePortsDocumentation(selectedOverlayMetadata, portOffset, envVars);
1212
+ const portsPath = path.join(outputPath, 'ports.json');
1213
+ fs.writeFileSync(portsPath, JSON.stringify(portsDoc, null, 2) + '\n');
1214
+ fileRegistry.addFile('ports.json');
1215
+ console.log(chalk.dim(` šŸ“” Created ports.json with ${portsDoc.ports.length} port(s)`));
1216
+ // Log summary of ports
1217
+ if (portsDoc.ports.length > 0) {
1218
+ console.log(chalk.dim('\n Available services:'));
1219
+ for (const port of portsDoc.ports) {
1220
+ const serviceLabel = port.service || 'unknown';
1221
+ const desc = port.description ? ` - ${port.description}` : '';
1222
+ const proto = port.protocol ? ` (${port.protocol})` : '';
1223
+ console.log(chalk.dim(` • ${serviceLabel}: ${port.actualPort}${proto}${desc}`));
1224
+ }
1225
+ }
1226
+ }
1227
+ // 17b. Generate services.md reference document
1228
+ const servicesMdContent = generateServicesMarkdown(selectedOverlayMetadata, portOffset, envVars);
1229
+ if (servicesMdContent) {
1230
+ const servicesMdPath = path.join(outputPath, 'services.md');
1231
+ fs.writeFileSync(servicesMdPath, servicesMdContent);
1232
+ fileRegistry.addFile('services.md');
1233
+ console.log(chalk.dim(` šŸ“‹ Created services.md with service reference`));
1234
+ }
1235
+ // 17c. Generate env.local.example as an optional-overrides template
1236
+ const envLocalContent = generateEnvLocalExample(selectedOverlayMetadata, actualOverlaysDir, portOffset);
1237
+ if (envLocalContent) {
1238
+ const envLocalPath = path.join(outputPath, 'env.local.example');
1239
+ fs.writeFileSync(envLocalPath, envLocalContent);
1240
+ fileRegistry.addFile('env.local.example');
1241
+ console.log(chalk.dim(` šŸ“„ Created env.local.example with optional overrides`));
1242
+ }
1243
+ // 18. Clean up stale files from previous runs (preserves superposition.json and .env)
1054
1244
  cleanupStaleFiles(outputPath, fileRegistry);
1245
+ // 19. Generate and return summary
1246
+ const files = Array.from(fileRegistry.getFiles());
1247
+ const services = overlaysToServices(selectedOverlayMetadata);
1248
+ const portInfos = portsDoc
1249
+ ? portsToPortInfo(portsDoc.ports, portsDoc.connectionStrings || {})
1250
+ : [];
1251
+ const warnings = detectWarnings(selectedOverlayMetadata, answers);
1252
+ const tips = generateTips(selectedOverlayMetadata, answers);
1253
+ const nextSteps = generateNextSteps(false, options.isRegen === true);
1254
+ return {
1255
+ files,
1256
+ services,
1257
+ ports: portInfos,
1258
+ warnings,
1259
+ tips,
1260
+ nextSteps,
1261
+ portOffset: answers.portOffset ?? 0,
1262
+ target: answers.target || 'local',
1263
+ isManifestOnly: false,
1264
+ manifestPath: path.join(outputPath, 'superposition.json'),
1265
+ };
1055
1266
  }
1056
1267
  /**
1057
1268
  * Apply port offset to devcontainer.json forwardPorts and portsAttributes
@@ -1084,7 +1295,7 @@ function applyPortOffsetToDevcontainer(config, offset) {
1084
1295
  /**
1085
1296
  * Merge setup scripts from overlays into postCreateCommand
1086
1297
  */
1087
- function mergeSetupScripts(config, overlays, outputPath, fileRegistry) {
1298
+ function mergeSetupScripts(config, overlays, outputPath, fileRegistry, overlaysDir) {
1088
1299
  const setupScripts = [];
1089
1300
  const verifyScripts = [];
1090
1301
  // Create scripts subfolder
@@ -1093,14 +1304,14 @@ function mergeSetupScripts(config, overlays, outputPath, fileRegistry) {
1093
1304
  fs.mkdirSync(scriptsDir, { recursive: true });
1094
1305
  }
1095
1306
  // Add scripts directory to registry if any scripts will be added
1096
- const hasScripts = overlays.some((o) => fs.existsSync(path.join(OVERLAYS_DIR, o, 'setup.sh')) ||
1097
- fs.existsSync(path.join(OVERLAYS_DIR, o, 'verify.sh')));
1307
+ const hasScripts = overlays.some((o) => fs.existsSync(path.join(overlaysDir, o, 'setup.sh')) ||
1308
+ fs.existsSync(path.join(overlaysDir, o, 'verify.sh')));
1098
1309
  if (hasScripts) {
1099
1310
  fileRegistry.addDirectory('scripts');
1100
1311
  }
1101
1312
  for (const overlay of overlays) {
1102
1313
  // Handle setup scripts
1103
- const setupPath = path.join(OVERLAYS_DIR, overlay, 'setup.sh');
1314
+ const setupPath = path.join(overlaysDir, overlay, 'setup.sh');
1104
1315
  if (fs.existsSync(setupPath)) {
1105
1316
  // Copy setup script to scripts subdirectory
1106
1317
  const destPath = path.join(scriptsDir, `setup-${overlay}.sh`);
@@ -1111,7 +1322,7 @@ function mergeSetupScripts(config, overlays, outputPath, fileRegistry) {
1111
1322
  setupScripts.push(`bash .devcontainer/scripts/setup-${overlay}.sh`);
1112
1323
  }
1113
1324
  // Handle verify scripts
1114
- const verifyPath = path.join(OVERLAYS_DIR, overlay, 'verify.sh');
1325
+ const verifyPath = path.join(overlaysDir, overlay, 'verify.sh');
1115
1326
  if (fs.existsSync(verifyPath)) {
1116
1327
  // Copy verify script to scripts subdirectory
1117
1328
  const destPath = path.join(scriptsDir, `verify-${overlay}.sh`);
@@ -1134,7 +1345,7 @@ function mergeSetupScripts(config, overlays, outputPath, fileRegistry) {
1134
1345
  // Add setup scripts
1135
1346
  for (let i = 0; i < setupScripts.length; i++) {
1136
1347
  const overlay = overlays.filter((o) => {
1137
- const setupPath = path.join(OVERLAYS_DIR, o, 'setup.sh');
1348
+ const setupPath = path.join(overlaysDir, o, 'setup.sh');
1138
1349
  return fs.existsSync(setupPath);
1139
1350
  })[i];
1140
1351
  config.postCreateCommand[`setup-${overlay}`] = setupScripts[i];
@@ -1153,7 +1364,7 @@ function mergeSetupScripts(config, overlays, outputPath, fileRegistry) {
1153
1364
  // Add verify scripts
1154
1365
  for (let i = 0; i < verifyScripts.length; i++) {
1155
1366
  const overlay = overlays.filter((o) => {
1156
- const verifyPath = path.join(OVERLAYS_DIR, o, 'verify.sh');
1367
+ const verifyPath = path.join(overlaysDir, o, 'verify.sh');
1157
1368
  return fs.existsSync(verifyPath);
1158
1369
  })[i];
1159
1370
  config.postStartCommand[`verify-${overlay}`] = verifyScripts[i];
@@ -1208,10 +1419,10 @@ function filterDockerComposeDependencies(outputPath, selectedOverlays) {
1208
1419
  /**
1209
1420
  * Merge runServices from all overlays in correct order
1210
1421
  */
1211
- function mergeRunServices(config, overlays) {
1422
+ function mergeRunServices(config, overlays, overlaysDir) {
1212
1423
  const services = [];
1213
1424
  for (const overlay of overlays) {
1214
- const overlayPath = path.join(OVERLAYS_DIR, overlay, 'devcontainer.patch.json');
1425
+ const overlayPath = path.join(overlaysDir, overlay, 'devcontainer.patch.json');
1215
1426
  if (fs.existsSync(overlayPath)) {
1216
1427
  const overlayConfig = loadJson(overlayPath);
1217
1428
  if (overlayConfig.runServices) {