container-superposition 0.1.1 → 0.1.3

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 (136) hide show
  1. package/README.md +206 -1
  2. package/dist/scripts/init.js +235 -179
  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 +211 -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 +16 -0
  17. package/dist/tool/commands/plan.d.ts.map +1 -0
  18. package/dist/tool/commands/plan.js +329 -0
  19. package/dist/tool/commands/plan.js.map +1 -0
  20. package/dist/tool/questionnaire/composer.d.ts +6 -1
  21. package/dist/tool/questionnaire/composer.d.ts.map +1 -1
  22. package/dist/tool/questionnaire/composer.js +300 -202
  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 +44 -2
  42. package/dist/tool/schema/types.d.ts.map +1 -1
  43. package/dist/tool/utils/merge.d.ts +134 -0
  44. package/dist/tool/utils/merge.d.ts.map +1 -0
  45. package/dist/tool/utils/merge.js +277 -0
  46. package/dist/tool/utils/merge.js.map +1 -0
  47. package/dist/tool/utils/port-utils.d.ts +29 -0
  48. package/dist/tool/utils/port-utils.d.ts.map +1 -0
  49. package/dist/tool/utils/port-utils.js +128 -0
  50. package/dist/tool/utils/port-utils.js.map +1 -0
  51. package/dist/tool/utils/version.d.ts +9 -0
  52. package/dist/tool/utils/version.d.ts.map +1 -0
  53. package/dist/tool/utils/version.js +32 -0
  54. package/dist/tool/utils/version.js.map +1 -0
  55. package/docs/architecture.md +25 -21
  56. package/docs/deployment-targets.md +150 -0
  57. package/docs/discovery-commands.md +442 -0
  58. package/docs/merge-strategy.md +700 -0
  59. package/docs/minimal-and-editor.md +265 -0
  60. package/docs/overlay-imports.md +209 -0
  61. package/docs/overlay-manifest-refactoring.md +2 -2
  62. package/docs/overlay-metadata-archive.md +1 -1
  63. package/docs/overlays.md +91 -23
  64. package/docs/presets-architecture.md +3 -3
  65. package/docs/presets.md +1 -1
  66. package/docs/publishing.md +36 -35
  67. package/docs/team-workflow.md +540 -0
  68. package/overlays/.presets/data-engineering.yml +392 -0
  69. package/overlays/.presets/event-sourced-service.yml +262 -0
  70. package/overlays/.presets/frontend.yml +287 -0
  71. package/overlays/.presets/k8s-operator-dev.yml +462 -0
  72. package/overlays/.registry/README.md +1 -1
  73. package/overlays/.registry/deployment-targets.yml +54 -0
  74. package/overlays/.shared/README.md +43 -0
  75. package/overlays/.shared/compose/common-healthchecks.yml +38 -0
  76. package/overlays/.shared/otel/instrumentation.env +20 -0
  77. package/overlays/.shared/otel/otel-base-config.yaml +30 -0
  78. package/overlays/.shared/vscode/recommended-extensions.json +14 -0
  79. package/overlays/README.md +1 -1
  80. package/overlays/codex/overlay.yml +1 -0
  81. package/overlays/duckdb/README.md +274 -0
  82. package/overlays/duckdb/devcontainer.patch.json +10 -0
  83. package/overlays/duckdb/overlay.yml +17 -0
  84. package/overlays/duckdb/setup.sh +45 -0
  85. package/overlays/duckdb/verify.sh +32 -0
  86. package/overlays/git-helpers/overlay.yml +1 -0
  87. package/overlays/grafana/README.md +5 -5
  88. package/overlays/grafana/dashboard-provider.yml +1 -1
  89. package/overlays/grafana/docker-compose.yml +2 -2
  90. package/overlays/grafana/overlay.yml +6 -1
  91. package/overlays/jaeger/overlay.yml +16 -3
  92. package/overlays/jupyter/.env.example +6 -0
  93. package/overlays/jupyter/README.md +210 -0
  94. package/overlays/jupyter/devcontainer.patch.json +14 -0
  95. package/overlays/jupyter/docker-compose.yml +23 -0
  96. package/overlays/jupyter/overlay.yml +18 -0
  97. package/overlays/jupyter/verify.sh +35 -0
  98. package/overlays/kind/README.md +221 -0
  99. package/overlays/kind/devcontainer.patch.json +10 -0
  100. package/overlays/kind/overlay.yml +18 -0
  101. package/overlays/kind/setup.sh +43 -0
  102. package/overlays/kind/verify.sh +40 -0
  103. package/overlays/localstack/.env.example +6 -0
  104. package/overlays/localstack/README.md +188 -0
  105. package/overlays/localstack/devcontainer.patch.json +21 -0
  106. package/overlays/localstack/docker-compose.yml +25 -0
  107. package/overlays/localstack/overlay.yml +18 -0
  108. package/overlays/localstack/verify.sh +47 -0
  109. package/overlays/loki/overlay.yml +6 -1
  110. package/overlays/modern-cli-tools/overlay.yml +1 -0
  111. package/overlays/mongodb/overlay.yml +12 -2
  112. package/overlays/mysql/overlay.yml +12 -2
  113. package/overlays/nats/overlay.yml +12 -2
  114. package/overlays/openapi-tools/README.md +243 -0
  115. package/overlays/openapi-tools/devcontainer.patch.json +10 -0
  116. package/overlays/openapi-tools/overlay.yml +16 -0
  117. package/overlays/openapi-tools/setup.sh +45 -0
  118. package/overlays/openapi-tools/verify.sh +51 -0
  119. package/overlays/otel-collector/overlay.yml.example +26 -0
  120. package/overlays/postgres/overlay.yml +6 -1
  121. package/overlays/prometheus/overlay.yml +6 -1
  122. package/overlays/rabbitmq/overlay.yml +12 -2
  123. package/overlays/redis/overlay.yml +6 -1
  124. package/overlays/tilt/README.md +259 -0
  125. package/overlays/tilt/devcontainer.patch.json +17 -0
  126. package/overlays/tilt/overlay.yml +19 -0
  127. package/overlays/tilt/setup.sh +25 -0
  128. package/overlays/tilt/verify.sh +24 -0
  129. package/package.json +8 -6
  130. package/tool/README.md +12 -16
  131. package/tool/schema/overlay-manifest.schema.json +64 -4
  132. package/tool/schema/superposition-manifest.schema.json +104 -0
  133. /package/overlays/{presets → .presets}/docs-site.yml +0 -0
  134. /package/overlays/{presets → .presets}/fullstack.yml +0 -0
  135. /package/overlays/{presets → .presets}/microservice.yml +0 -0
  136. /package/overlays/{presets → .presets}/web-api.yml +0 -0
@@ -6,6 +6,10 @@ 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';
9
13
  // Get __dirname equivalent in ESM
10
14
  const __filename = fileURLToPath(import.meta.url);
11
15
  const __dirname = path.dirname(__filename);
@@ -19,92 +23,6 @@ const REPO_ROOT_CANDIDATES = [
19
23
  const REPO_ROOT = REPO_ROOT_CANDIDATES.find((candidate) => fs.existsSync(path.join(candidate, 'templates')) &&
20
24
  fs.existsSync(path.join(candidate, 'overlays'))) ?? REPO_ROOT_CANDIDATES[0];
21
25
  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
26
  /**
109
27
  * Merge packages from apt-get-packages feature
110
28
  */
@@ -118,10 +36,7 @@ function mergeAptPackages(baseConfig, packages) {
118
36
  }
119
37
  else {
120
38
  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(' ');
39
+ const merged = mergePackages(existing, packages);
125
40
  baseConfig.features[featureKey].packages = merged;
126
41
  }
127
42
  return baseConfig;
@@ -140,17 +55,13 @@ function mergeCrossDistroPackages(baseConfig, apt, apk) {
140
55
  // Merge apt packages
141
56
  if (apt) {
142
57
  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(' ');
58
+ const merged = mergePackages(existing, apt);
146
59
  baseConfig.features[featureKey].apt = merged;
147
60
  }
148
61
  // Merge apk packages
149
62
  if (apk) {
150
63
  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(' ');
64
+ const merged = mergePackages(existing, apk);
154
65
  baseConfig.features[featureKey].apk = merged;
155
66
  }
156
67
  return baseConfig;
@@ -226,12 +137,108 @@ function resolveDependencies(requestedOverlays, allOverlayDefs) {
226
137
  },
227
138
  };
228
139
  }
140
+ /**
141
+ * Prepare overlays for generation by loading configuration, building requested overlay list,
142
+ * filtering for minimal mode, checking compatibility, and resolving dependencies.
143
+ * This shared logic is used by both generateManifestOnly and composeDevContainer.
144
+ */
145
+ function prepareOverlaysForGeneration(answers, overlaysDir) {
146
+ // 1. Load overlay configuration
147
+ const actualOverlaysDir = overlaysDir ?? path.join(REPO_ROOT, 'overlays');
148
+ const indexYmlPath = path.join(actualOverlaysDir, 'index.yml');
149
+ const overlaysConfig = loadOverlaysConfig(actualOverlaysDir, indexYmlPath);
150
+ // Collect all overlay definitions
151
+ const allOverlayDefs = getAllOverlayDefs(overlaysConfig);
152
+ // Build list of requested overlays
153
+ const requestedOverlays = [];
154
+ if (answers.language && answers.language.length > 0)
155
+ requestedOverlays.push(...answers.language);
156
+ if (answers.database && answers.database.length > 0)
157
+ requestedOverlays.push(...answers.database);
158
+ if (answers.observability)
159
+ requestedOverlays.push(...answers.observability);
160
+ if (answers.playwright)
161
+ requestedOverlays.push('playwright');
162
+ if (answers.cloudTools)
163
+ requestedOverlays.push(...answers.cloudTools);
164
+ if (answers.devTools)
165
+ requestedOverlays.push(...answers.devTools);
166
+ // Filter out "minimal" overlays if --minimal flag is set
167
+ let filteredRequestedOverlays = requestedOverlays;
168
+ if (answers.minimal) {
169
+ const minimalExcluded = [];
170
+ filteredRequestedOverlays = requestedOverlays.filter((overlayId) => {
171
+ const overlayDef = allOverlayDefs.find((o) => o.id === overlayId);
172
+ if (overlayDef?.minimal === true) {
173
+ minimalExcluded.push(overlayId);
174
+ return false;
175
+ }
176
+ return true;
177
+ });
178
+ if (minimalExcluded.length > 0) {
179
+ console.log(chalk.dim(` šŸ“¦ Minimal mode: Excluding ${minimalExcluded.length} optional overlay(s): ${minimalExcluded.join(', ')}`));
180
+ }
181
+ }
182
+ // Check compatibility
183
+ const incompatible = [];
184
+ for (const overlayId of filteredRequestedOverlays) {
185
+ const overlayDef = allOverlayDefs.find((o) => o.id === overlayId);
186
+ if (overlayDef?.supports && overlayDef.supports.length > 0) {
187
+ if (!overlayDef.supports.includes(answers.stack)) {
188
+ incompatible.push(`${overlayId} (requires: ${overlayDef.supports.join(', ')})`);
189
+ }
190
+ }
191
+ }
192
+ if (incompatible.length > 0) {
193
+ console.log(chalk.yellow(`\nāš ļø Warning: Some overlays are not compatible with '${answers.stack}' template:`));
194
+ incompatible.forEach((overlay) => {
195
+ console.log(chalk.yellow(` • ${overlay}`));
196
+ });
197
+ console.log(chalk.yellow(`\nThese overlays will be skipped.\n`));
198
+ // Filter out incompatible overlays
199
+ if (answers.database) {
200
+ answers.database = answers.database.filter((d) => !incompatible.some((i) => i.startsWith(d)));
201
+ }
202
+ if (answers.observability) {
203
+ answers.observability = answers.observability.filter((o) => !incompatible.some((i) => i.startsWith(o)));
204
+ }
205
+ // Update requestedOverlays after filtering
206
+ requestedOverlays.length = 0;
207
+ if (answers.language && answers.language.length > 0)
208
+ requestedOverlays.push(...answers.language);
209
+ if (answers.database && answers.database.length > 0)
210
+ requestedOverlays.push(...answers.database);
211
+ if (answers.observability)
212
+ requestedOverlays.push(...answers.observability);
213
+ if (answers.playwright)
214
+ requestedOverlays.push('playwright');
215
+ if (answers.cloudTools)
216
+ requestedOverlays.push(...answers.cloudTools);
217
+ if (answers.devTools)
218
+ requestedOverlays.push(...answers.devTools);
219
+ // Re-apply minimal filtering
220
+ filteredRequestedOverlays = requestedOverlays.filter((overlayId) => {
221
+ const overlayDef = allOverlayDefs.find((o) => o.id === overlayId);
222
+ return !answers.minimal || overlayDef?.minimal !== true;
223
+ });
224
+ }
225
+ // Resolve dependencies
226
+ const { overlays: resolvedOverlays, autoResolved } = resolveDependencies(filteredRequestedOverlays, allOverlayDefs);
227
+ return {
228
+ overlays: resolvedOverlays,
229
+ autoResolved,
230
+ overlaysConfig,
231
+ };
232
+ }
229
233
  /**
230
234
  * Generate superposition.json manifest
231
235
  */
232
236
  function generateManifest(outputPath, answers, overlays, autoResolved, containerName) {
237
+ const toolVersion = getToolVersion();
233
238
  const manifest = {
234
- version: '0.1.0',
239
+ manifestVersion: CURRENT_MANIFEST_VERSION,
240
+ generatedBy: toolVersion,
241
+ version: '0.1.0', // Legacy field for backward compatibility
235
242
  generated: new Date().toISOString(),
236
243
  baseTemplate: answers.stack,
237
244
  baseImage: answers.baseImage === 'custom' && answers.customImage
@@ -265,15 +272,74 @@ function generateManifest(outputPath, answers, overlays, autoResolved, container
265
272
  console.log(chalk.cyan(` ā„¹ļø Used preset: ${answers.preset}`));
266
273
  }
267
274
  }
275
+ /**
276
+ * Load and resolve imports from shared files for an overlay
277
+ */
278
+ function loadImportsForOverlay(overlayName, overlaysDir) {
279
+ let importedConfig = {};
280
+ // Load overlay manifest to get imports
281
+ const overlayDir = path.join(overlaysDir, overlayName);
282
+ const manifestPath = path.join(overlayDir, 'overlay.yml');
283
+ if (!fs.existsSync(manifestPath)) {
284
+ return importedConfig;
285
+ }
286
+ try {
287
+ const manifestContent = fs.readFileSync(manifestPath, 'utf8');
288
+ const manifest = yaml.load(manifestContent);
289
+ if (!manifest.imports ||
290
+ !Array.isArray(manifest.imports) ||
291
+ manifest.imports.length === 0) {
292
+ return importedConfig;
293
+ }
294
+ // Process each import
295
+ for (const importPath of manifest.imports) {
296
+ const fullImportPath = path.join(overlaysDir, importPath);
297
+ if (!fs.existsSync(fullImportPath)) {
298
+ console.warn(chalk.yellow(`āš ļø Import not found: ${importPath} (for overlay: ${overlayName})`));
299
+ continue;
300
+ }
301
+ // Determine file type and merge appropriately
302
+ const ext = path.extname(importPath).toLowerCase();
303
+ if (ext === '.json') {
304
+ // JSON files are merged as devcontainer patches
305
+ const importedPatch = loadJson(fullImportPath);
306
+ importedConfig = deepMerge(importedConfig, importedPatch);
307
+ }
308
+ else if (ext === '.yaml' || ext === '.yml') {
309
+ // YAML files are loaded and merged as devcontainer patches
310
+ try {
311
+ const yamlContent = fs.readFileSync(fullImportPath, 'utf8');
312
+ const importedPatch = yaml.load(yamlContent);
313
+ if (importedPatch && typeof importedPatch === 'object') {
314
+ importedConfig = deepMerge(importedConfig, importedPatch);
315
+ }
316
+ }
317
+ catch (error) {
318
+ console.warn(chalk.yellow(`āš ļø Failed to parse YAML import: ${importPath}`));
319
+ }
320
+ }
321
+ // .env files are handled separately during env merging
322
+ }
323
+ }
324
+ catch (error) {
325
+ console.warn(chalk.yellow(`āš ļø Failed to load imports for overlay: ${overlayName}`));
326
+ }
327
+ return importedConfig;
328
+ }
268
329
  /**
269
330
  * Apply an overlay to the base configuration
270
331
  */
271
- function applyOverlay(baseConfig, overlayName) {
272
- const overlayPath = path.join(OVERLAYS_DIR, overlayName, 'devcontainer.patch.json');
332
+ function applyOverlay(baseConfig, overlayName, overlaysDir) {
333
+ const overlayPath = path.join(overlaysDir, overlayName, 'devcontainer.patch.json');
273
334
  if (!fs.existsSync(overlayPath)) {
274
335
  console.warn(chalk.yellow(`āš ļø Overlay not found: ${overlayName}`));
275
336
  return baseConfig;
276
337
  }
338
+ // First, load and apply any imports
339
+ const importedConfig = loadImportsForOverlay(overlayName, overlaysDir);
340
+ if (Object.keys(importedConfig).length > 0) {
341
+ baseConfig = deepMerge(baseConfig, importedConfig);
342
+ }
277
343
  const overlay = loadJson(overlayPath);
278
344
  // Special handling for apt-get packages (legacy)
279
345
  if (overlay.features?.['ghcr.io/devcontainers-extra/features/apt-get-packages:1']?.packages) {
@@ -378,8 +444,8 @@ function copyDir(src, dest) {
378
444
  * Copy additional files from overlay to output directory
379
445
  * Excludes devcontainer.patch.json and .env.example (handled separately)
380
446
  */
381
- function copyOverlayFiles(outputPath, overlayName, registry) {
382
- const overlayPath = path.join(OVERLAYS_DIR, overlayName);
447
+ function copyOverlayFiles(outputPath, overlayName, registry, overlaysDir) {
448
+ const overlayPath = path.join(overlaysDir, overlayName);
383
449
  if (!fs.existsSync(overlayPath)) {
384
450
  return;
385
451
  }
@@ -428,10 +494,37 @@ function copyOverlayFiles(outputPath, overlayName, registry) {
428
494
  /**
429
495
  * Merge .env.example files from overlays and apply glue config
430
496
  */
431
- function mergeEnvExamples(outputPath, overlays, portOffset, glueConfig, presetName) {
497
+ function mergeEnvExamples(outputPath, overlays, overlaysDir, portOffset, glueConfig, presetName) {
432
498
  const envSections = [];
433
499
  for (const overlay of overlays) {
434
- const envPath = path.join(OVERLAYS_DIR, overlay, '.env.example');
500
+ // First, check for imports in the overlay and add any .env files from imports
501
+ const overlayDir = path.join(overlaysDir, overlay);
502
+ const manifestPath = path.join(overlayDir, 'overlay.yml');
503
+ if (fs.existsSync(manifestPath)) {
504
+ try {
505
+ const manifestContent = fs.readFileSync(manifestPath, 'utf8');
506
+ const manifest = yaml.load(manifestContent);
507
+ if (manifest.imports && Array.isArray(manifest.imports)) {
508
+ for (const importPath of manifest.imports) {
509
+ const ext = path.extname(importPath).toLowerCase();
510
+ if (ext === '.env') {
511
+ const fullImportPath = path.join(overlaysDir, importPath);
512
+ if (fs.existsSync(fullImportPath)) {
513
+ const content = fs.readFileSync(fullImportPath, 'utf-8').trim();
514
+ if (content) {
515
+ envSections.push(`# Imported from ${importPath}\n${content}`);
516
+ }
517
+ }
518
+ }
519
+ }
520
+ }
521
+ }
522
+ catch (error) {
523
+ // Ignore errors reading manifest
524
+ }
525
+ }
526
+ // Then add the overlay's own .env.example
527
+ const envPath = path.join(overlaysDir, overlay, '.env.example');
435
528
  if (fs.existsSync(envPath)) {
436
529
  const content = fs.readFileSync(envPath, 'utf-8').trim();
437
530
  if (content) {
@@ -478,23 +571,6 @@ function mergeEnvExamples(outputPath, overlays, portOffset, glueConfig, presetNa
478
571
  }
479
572
  return true;
480
573
  }
481
- /**
482
- * Apply port offset to environment variables in .env content
483
- */
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');
497
- }
498
574
  /**
499
575
  * Apply preset glue configuration (README and port mappings)
500
576
  * Note: Environment variables are handled in mergeEnvExamples to ensure proper port offset application
@@ -526,7 +602,7 @@ function applyGlueConfig(outputPath, glueConfig, presetName, fileRegistry) {
526
602
  /**
527
603
  * Merge docker-compose.yml files from base and overlays into a single file
528
604
  */
529
- function mergeDockerComposeFiles(outputPath, baseStack, overlays, portOffset, customImage) {
605
+ function mergeDockerComposeFiles(outputPath, baseStack, overlays, overlaysDir, portOffset, customImage) {
530
606
  const composeFiles = [];
531
607
  // Add base docker-compose if exists
532
608
  const baseComposePath = path.join(TEMPLATES_DIR, baseStack, '.devcontainer', 'docker-compose.yml');
@@ -535,7 +611,7 @@ function mergeDockerComposeFiles(outputPath, baseStack, overlays, portOffset, cu
535
611
  }
536
612
  // Add overlay docker-compose files
537
613
  for (const overlay of overlays) {
538
- const overlayComposePath = path.join(OVERLAYS_DIR, overlay, 'docker-compose.yml');
614
+ const overlayComposePath = path.join(overlaysDir, overlay, 'docker-compose.yml');
539
615
  if (fs.existsSync(overlayComposePath)) {
540
616
  composeFiles.push(overlayComposePath);
541
617
  }
@@ -583,13 +659,17 @@ function mergeDockerComposeFiles(outputPath, baseStack, overlays, portOffset, cu
583
659
  }
584
660
  // Filter depends_on to only include services that exist
585
661
  const serviceNames = Object.keys(merged.services);
662
+ const serviceNameSet = new Set(serviceNames);
586
663
  for (const serviceName of serviceNames) {
587
664
  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) {
665
+ if (service.depends_on !== undefined) {
666
+ const filteredDependsOn = filterDependsOn(service.depends_on, serviceNameSet);
667
+ if (filteredDependsOn === undefined) {
591
668
  delete service.depends_on;
592
669
  }
670
+ else {
671
+ service.depends_on = filteredDependsOn;
672
+ }
593
673
  }
594
674
  }
595
675
  // Remove empty sections
@@ -800,71 +880,35 @@ function copyCustomFiles(customConfig, outputPath, fileRegistry) {
800
880
  fileRegistry.addFile(relativeDest);
801
881
  }
802
882
  }
883
+ /**
884
+ * Generate only the superposition.json manifest without creating .devcontainer files
885
+ * Used for team collaboration workflow where manifest is committed but .devcontainer is gitignored
886
+ */
887
+ export async function generateManifestOnly(answers, overlaysDir) {
888
+ // Prepare overlays using shared logic
889
+ const { overlays: resolvedOverlays, autoResolved } = prepareOverlaysForGeneration(answers, overlaysDir);
890
+ // Ensure output directory exists
891
+ const outputPath = answers.outputPath || '.';
892
+ if (!fs.existsSync(outputPath)) {
893
+ fs.mkdirSync(outputPath, { recursive: true });
894
+ }
895
+ // Generate manifest only
896
+ console.log(chalk.cyan('\nšŸ“‹ Generating manifest only (team collaboration mode)...\n'));
897
+ generateManifest(outputPath, answers, resolvedOverlays, autoResolved, answers.containerName);
898
+ console.log(chalk.green(`\nāœ“ Manifest created: ${path.join(outputPath, 'superposition.json')}`));
899
+ console.log(chalk.dim(' Ready for team collaboration workflow.'));
900
+ console.log(chalk.dim(' Commit this manifest to your repository and let team members run "npx container-superposition regen"'));
901
+ }
803
902
  /**
804
903
  * Main composition logic
805
904
  */
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
905
+ export async function composeDevContainer(answers, overlaysDir) {
906
+ // Prepare overlays using shared logic
907
+ const actualOverlaysDir = overlaysDir ?? path.join(REPO_ROOT, 'overlays');
908
+ const { overlays: resolvedOverlays, autoResolved, overlaysConfig, } = prepareOverlaysForGeneration(answers, overlaysDir);
909
+ // Get all overlay definitions for later use
812
910
  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
911
+ // Determine base template path
868
912
  const templatePath = path.join(TEMPLATES_DIR, answers.stack, '.devcontainer');
869
913
  if (!fs.existsSync(templatePath)) {
870
914
  throw new Error(`Template not found: ${answers.stack}`);
@@ -946,7 +990,7 @@ export async function composeDevContainer(answers) {
946
990
  // 6. Apply overlays
947
991
  for (const overlay of overlays) {
948
992
  console.log(chalk.dim(` šŸ”§ Applying overlay: ${chalk.cyan(overlay)}`));
949
- config = applyOverlay(config, overlay);
993
+ config = applyOverlay(config, overlay, actualOverlaysDir);
950
994
  }
951
995
  // 7. Copy template files (docker-compose, scripts, etc.)
952
996
  const entries = fs.readdirSync(templatePath);
@@ -967,7 +1011,7 @@ export async function composeDevContainer(answers) {
967
1011
  }
968
1012
  // 8. Copy overlay files (docker-compose, configs, etc.)
969
1013
  for (const overlay of overlays) {
970
- copyOverlayFiles(outputPath, overlay, fileRegistry);
1014
+ copyOverlayFiles(outputPath, overlay, fileRegistry, actualOverlaysDir);
971
1015
  }
972
1016
  // 8.5. Copy cross-distro-packages feature if used
973
1017
  if (config.features?.['./features/cross-distro-packages']) {
@@ -982,11 +1026,11 @@ export async function composeDevContainer(answers) {
982
1026
  // 8. Filter docker-compose dependencies based on selected overlays
983
1027
  filterDockerComposeDependencies(outputPath, overlays);
984
1028
  // 9. Merge runServices array in correct order
985
- mergeRunServices(config, overlays);
1029
+ mergeRunServices(config, overlays, actualOverlaysDir);
986
1030
  // 11. Merge docker-compose files into single combined file
987
1031
  if (answers.stack === 'compose') {
988
1032
  const customImage = config._customImage;
989
- mergeDockerComposeFiles(outputPath, answers.stack, overlays, answers.portOffset, customImage);
1033
+ mergeDockerComposeFiles(outputPath, answers.stack, overlays, actualOverlaysDir, answers.portOffset, customImage);
990
1034
  // Update devcontainer.json to reference the combined file
991
1035
  if (config.dockerComposeFile) {
992
1036
  config.dockerComposeFile = 'docker-compose.yml';
@@ -997,7 +1041,7 @@ export async function composeDevContainer(answers) {
997
1041
  applyPortOffsetToDevcontainer(config, answers.portOffset);
998
1042
  }
999
1043
  // Merge setup scripts from overlays into postCreateCommand
1000
- mergeSetupScripts(config, overlays, outputPath, fileRegistry);
1044
+ mergeSetupScripts(config, overlays, outputPath, fileRegistry, actualOverlaysDir);
1001
1045
  // 10. Apply custom patches from .devcontainer/custom/ (if present)
1002
1046
  const customPatches = loadCustomPatches(outputPath);
1003
1047
  if (customPatches) {
@@ -1015,6 +1059,25 @@ export async function composeDevContainer(answers) {
1015
1059
  delete config[key];
1016
1060
  }
1017
1061
  });
1062
+ // Handle editor profile filtering
1063
+ if (answers.editor === 'none' || answers.editor === 'jetbrains') {
1064
+ // Remove VS Code customizations
1065
+ if (config.customizations?.vscode) {
1066
+ if (answers.editor === 'none') {
1067
+ delete config.customizations.vscode;
1068
+ console.log(chalk.dim(` šŸŽØ Editor profile 'none': Removed VS Code customizations`));
1069
+ }
1070
+ else if (answers.editor === 'jetbrains') {
1071
+ // For JetBrains, remove VS Code customizations (future: could add JetBrains-specific settings)
1072
+ delete config.customizations.vscode;
1073
+ console.log(chalk.dim(` šŸŽØ Editor profile 'jetbrains': Removed VS Code customizations`));
1074
+ }
1075
+ // Clean up empty customizations object
1076
+ if (config.customizations && Object.keys(config.customizations).length === 0) {
1077
+ delete config.customizations;
1078
+ }
1079
+ }
1080
+ }
1018
1081
  // 12. Write merged devcontainer.json
1019
1082
  const configPath = path.join(outputPath, 'devcontainer.json');
1020
1083
  fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
@@ -1028,7 +1091,7 @@ export async function composeDevContainer(answers) {
1028
1091
  generateManifest(outputPath, answers, overlays, autoResolved, answers.containerName || config.name);
1029
1092
  fileRegistry.addFile('superposition.json');
1030
1093
  // 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);
1094
+ const envCreated = mergeEnvExamples(outputPath, overlays, actualOverlaysDir, answers.portOffset, answers.presetGlueConfig, answers.preset);
1032
1095
  if (envCreated) {
1033
1096
  fileRegistry.addFile('.env.example');
1034
1097
  }
@@ -1050,7 +1113,42 @@ export async function composeDevContainer(answers) {
1050
1113
  generateReadme(answers, overlays, overlayMetadataMap, outputPath);
1051
1114
  fileRegistry.addFile('README.md');
1052
1115
  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)
1116
+ // 17. Generate ports.json documentation
1117
+ const portOffset = answers.portOffset ?? 0;
1118
+ if (portOffset > 0 || overlays.some((o) => overlayMetadataMap.get(o)?.ports?.length)) {
1119
+ console.log(chalk.cyan('\nšŸ“” Generating ports documentation...'));
1120
+ const selectedOverlayMetadata = overlays
1121
+ .map((id) => overlayMetadataMap.get(id))
1122
+ .filter((m) => m !== undefined);
1123
+ // Extract environment variables from .env.example for connection strings
1124
+ const envPath = path.join(outputPath, '.env.example');
1125
+ const envVars = {};
1126
+ if (fs.existsSync(envPath)) {
1127
+ const envContent = fs.readFileSync(envPath, 'utf8');
1128
+ for (const line of envContent.split('\n')) {
1129
+ const match = line.match(/^([A-Z_][A-Z0-9_]*)=(.*)$/);
1130
+ if (match) {
1131
+ envVars[match[1].toLowerCase()] = match[2];
1132
+ }
1133
+ }
1134
+ }
1135
+ const portsDoc = generatePortsDocumentation(selectedOverlayMetadata, portOffset, envVars);
1136
+ const portsPath = path.join(outputPath, 'ports.json');
1137
+ fs.writeFileSync(portsPath, JSON.stringify(portsDoc, null, 2) + '\n');
1138
+ fileRegistry.addFile('ports.json');
1139
+ console.log(chalk.dim(` šŸ“” Created ports.json with ${portsDoc.ports.length} port(s)`));
1140
+ // Log summary of ports
1141
+ if (portsDoc.ports.length > 0) {
1142
+ console.log(chalk.dim('\n Available services:'));
1143
+ for (const port of portsDoc.ports) {
1144
+ const serviceLabel = port.service || 'unknown';
1145
+ const desc = port.description ? ` - ${port.description}` : '';
1146
+ const proto = port.protocol ? ` (${port.protocol})` : '';
1147
+ console.log(chalk.dim(` • ${serviceLabel}: ${port.actualPort}${proto}${desc}`));
1148
+ }
1149
+ }
1150
+ }
1151
+ // 18. Clean up stale files from previous runs (preserves superposition.json and .env)
1054
1152
  cleanupStaleFiles(outputPath, fileRegistry);
1055
1153
  }
1056
1154
  /**
@@ -1084,7 +1182,7 @@ function applyPortOffsetToDevcontainer(config, offset) {
1084
1182
  /**
1085
1183
  * Merge setup scripts from overlays into postCreateCommand
1086
1184
  */
1087
- function mergeSetupScripts(config, overlays, outputPath, fileRegistry) {
1185
+ function mergeSetupScripts(config, overlays, outputPath, fileRegistry, overlaysDir) {
1088
1186
  const setupScripts = [];
1089
1187
  const verifyScripts = [];
1090
1188
  // Create scripts subfolder
@@ -1093,14 +1191,14 @@ function mergeSetupScripts(config, overlays, outputPath, fileRegistry) {
1093
1191
  fs.mkdirSync(scriptsDir, { recursive: true });
1094
1192
  }
1095
1193
  // 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')));
1194
+ const hasScripts = overlays.some((o) => fs.existsSync(path.join(overlaysDir, o, 'setup.sh')) ||
1195
+ fs.existsSync(path.join(overlaysDir, o, 'verify.sh')));
1098
1196
  if (hasScripts) {
1099
1197
  fileRegistry.addDirectory('scripts');
1100
1198
  }
1101
1199
  for (const overlay of overlays) {
1102
1200
  // Handle setup scripts
1103
- const setupPath = path.join(OVERLAYS_DIR, overlay, 'setup.sh');
1201
+ const setupPath = path.join(overlaysDir, overlay, 'setup.sh');
1104
1202
  if (fs.existsSync(setupPath)) {
1105
1203
  // Copy setup script to scripts subdirectory
1106
1204
  const destPath = path.join(scriptsDir, `setup-${overlay}.sh`);
@@ -1111,7 +1209,7 @@ function mergeSetupScripts(config, overlays, outputPath, fileRegistry) {
1111
1209
  setupScripts.push(`bash .devcontainer/scripts/setup-${overlay}.sh`);
1112
1210
  }
1113
1211
  // Handle verify scripts
1114
- const verifyPath = path.join(OVERLAYS_DIR, overlay, 'verify.sh');
1212
+ const verifyPath = path.join(overlaysDir, overlay, 'verify.sh');
1115
1213
  if (fs.existsSync(verifyPath)) {
1116
1214
  // Copy verify script to scripts subdirectory
1117
1215
  const destPath = path.join(scriptsDir, `verify-${overlay}.sh`);
@@ -1134,7 +1232,7 @@ function mergeSetupScripts(config, overlays, outputPath, fileRegistry) {
1134
1232
  // Add setup scripts
1135
1233
  for (let i = 0; i < setupScripts.length; i++) {
1136
1234
  const overlay = overlays.filter((o) => {
1137
- const setupPath = path.join(OVERLAYS_DIR, o, 'setup.sh');
1235
+ const setupPath = path.join(overlaysDir, o, 'setup.sh');
1138
1236
  return fs.existsSync(setupPath);
1139
1237
  })[i];
1140
1238
  config.postCreateCommand[`setup-${overlay}`] = setupScripts[i];
@@ -1153,7 +1251,7 @@ function mergeSetupScripts(config, overlays, outputPath, fileRegistry) {
1153
1251
  // Add verify scripts
1154
1252
  for (let i = 0; i < verifyScripts.length; i++) {
1155
1253
  const overlay = overlays.filter((o) => {
1156
- const verifyPath = path.join(OVERLAYS_DIR, o, 'verify.sh');
1254
+ const verifyPath = path.join(overlaysDir, o, 'verify.sh');
1157
1255
  return fs.existsSync(verifyPath);
1158
1256
  })[i];
1159
1257
  config.postStartCommand[`verify-${overlay}`] = verifyScripts[i];
@@ -1208,10 +1306,10 @@ function filterDockerComposeDependencies(outputPath, selectedOverlays) {
1208
1306
  /**
1209
1307
  * Merge runServices from all overlays in correct order
1210
1308
  */
1211
- function mergeRunServices(config, overlays) {
1309
+ function mergeRunServices(config, overlays, overlaysDir) {
1212
1310
  const services = [];
1213
1311
  for (const overlay of overlays) {
1214
- const overlayPath = path.join(OVERLAYS_DIR, overlay, 'devcontainer.patch.json');
1312
+ const overlayPath = path.join(overlaysDir, overlay, 'devcontainer.patch.json');
1215
1313
  if (fs.existsSync(overlayPath)) {
1216
1314
  const overlayConfig = loadJson(overlayPath);
1217
1315
  if (overlayConfig.runServices) {