container-superposition 0.1.8 → 0.1.10

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 (184) hide show
  1. package/README.md +3 -0
  2. package/dist/tool/cli/args.d.ts.map +1 -1
  3. package/dist/tool/cli/args.js +1 -1
  4. package/dist/tool/cli/args.js.map +1 -1
  5. package/dist/tool/commands/adopt.d.ts.map +1 -1
  6. package/dist/tool/commands/adopt.js +15 -21
  7. package/dist/tool/commands/adopt.js.map +1 -1
  8. package/dist/tool/commands/doctor.d.ts +1 -0
  9. package/dist/tool/commands/doctor.d.ts.map +1 -1
  10. package/dist/tool/commands/doctor.js +1370 -73
  11. package/dist/tool/commands/doctor.js.map +1 -1
  12. package/dist/tool/questionnaire/composer.d.ts +3 -1
  13. package/dist/tool/questionnaire/composer.d.ts.map +1 -1
  14. package/dist/tool/questionnaire/composer.js +273 -20
  15. package/dist/tool/questionnaire/composer.js.map +1 -1
  16. package/dist/tool/questionnaire/presets.d.ts.map +1 -1
  17. package/dist/tool/questionnaire/presets.js +1 -0
  18. package/dist/tool/questionnaire/presets.js.map +1 -1
  19. package/dist/tool/questionnaire/questionnaire.d.ts.map +1 -1
  20. package/dist/tool/questionnaire/questionnaire.js +3 -1
  21. package/dist/tool/questionnaire/questionnaire.js.map +1 -1
  22. package/dist/tool/schema/project-config.d.ts.map +1 -1
  23. package/dist/tool/schema/project-config.js +174 -1
  24. package/dist/tool/schema/project-config.js.map +1 -1
  25. package/dist/tool/schema/types.d.ts +53 -2
  26. package/dist/tool/schema/types.d.ts.map +1 -1
  27. package/docs/README.md +1 -0
  28. package/docs/overlays.md +188 -147
  29. package/docs/specs/001-verbose-plan-graph/spec.md +5 -12
  30. package/docs/specs/002-superposition-config-file/spec.md +5 -12
  31. package/docs/specs/003-mkdocs2-overlay/spec.md +2 -9
  32. package/docs/specs/004-doctor-fix/spec.md +1 -8
  33. package/docs/specs/005-cuda-overlay/spec.md +2 -9
  34. package/docs/specs/006-rocm-overlay/spec.md +3 -10
  35. package/docs/specs/007-target-aware-generation/spec.md +4 -11
  36. package/docs/specs/008-project-file-canonical/spec.md +7 -8
  37. package/docs/specs/009-project-env/spec.md +3 -10
  38. package/docs/specs/010-compose-env-materialization/spec.md +3 -10
  39. package/docs/specs/011-overlay-parameters/spec.md +2 -9
  40. package/docs/specs/012-ollama-cli-overlay/spec.md +47 -0
  41. package/docs/specs/013-doctor-dependency-check/spec.md +250 -0
  42. package/docs/specs/014-doctor-compose-port-cross-validation/spec.md +276 -0
  43. package/docs/specs/015-doctor-env-example-drift/spec.md +248 -0
  44. package/docs/specs/016-doctor-reproducibility-check/spec.md +276 -0
  45. package/docs/specs/017-doctor-dry-run/spec.md +276 -0
  46. package/docs/specs/{007-init-project-file → 018-init-project-file}/spec.md +2 -9
  47. package/docs/specs/019-project-mounts/spec.md +176 -0
  48. package/docs/specs/taxonomy.md +186 -0
  49. package/docs/superposition-yml.md +467 -0
  50. package/overlays/.presets/full-observability.yml +113 -0
  51. package/overlays/.presets/k8s-dev.yml +174 -0
  52. package/overlays/.presets/local-llm.yml +105 -0
  53. package/overlays/.presets/vector-ai.yml +150 -0
  54. package/overlays/.shared/vscode/js-ts-settings.json +19 -0
  55. package/overlays/.shared/vscode/markdown-extensions.json +8 -0
  56. package/overlays/alertmanager/devcontainer.patch.json +0 -1
  57. package/overlays/alertmanager/docker-compose.yml +8 -0
  58. package/overlays/alertmanager/overlay.yml +1 -0
  59. package/overlays/amp/devcontainer.patch.json +4 -1
  60. package/overlays/ansible/README.md +163 -0
  61. package/overlays/ansible/devcontainer.patch.json +14 -0
  62. package/overlays/ansible/overlay.yml +18 -0
  63. package/overlays/argocd/README.md +158 -0
  64. package/overlays/argocd/devcontainer.patch.json +9 -0
  65. package/overlays/argocd/overlay.yml +17 -0
  66. package/overlays/argocd/setup.sh +29 -0
  67. package/overlays/argocd/verify.sh +14 -0
  68. package/overlays/bun/devcontainer.patch.json +1 -10
  69. package/overlays/bun/overlay.yml +8 -1
  70. package/overlays/claude-code/devcontainer.patch.json +6 -1
  71. package/overlays/codex/devcontainer.patch.json +5 -0
  72. package/overlays/comfyui/docker-compose.yml +1 -0
  73. package/overlays/comfyui/overlay.yml +4 -0
  74. package/overlays/commitlint/devcontainer.patch.json +1 -6
  75. package/overlays/docker-sock/overlay.yml +1 -0
  76. package/overlays/dotnet/overlay.yml +4 -1
  77. package/overlays/fuseki/.env.example +5 -0
  78. package/overlays/fuseki/README.md +173 -0
  79. package/overlays/fuseki/devcontainer.patch.json +18 -0
  80. package/overlays/fuseki/docker-compose.yml +29 -0
  81. package/overlays/fuseki/overlay.yml +42 -0
  82. package/overlays/fuseki/verify.sh +58 -0
  83. package/overlays/gemini-cli/devcontainer.patch.json +4 -1
  84. package/overlays/go/overlay.yml +6 -1
  85. package/overlays/grafana/devcontainer.patch.json +0 -1
  86. package/overlays/grafana/docker-compose.yml +8 -2
  87. package/overlays/grafana/overlay.yml +6 -1
  88. package/overlays/jaeger/.env.example +11 -0
  89. package/overlays/jaeger/README.md +33 -4
  90. package/overlays/jaeger/devcontainer.patch.json +9 -1
  91. package/overlays/jaeger/docker-compose.yml +17 -0
  92. package/overlays/jaeger/overlay.yml +1 -12
  93. package/overlays/java/overlay.yml +6 -1
  94. package/overlays/jupyter/docker-compose.yml +1 -0
  95. package/overlays/jupyter/overlay.yml +1 -0
  96. package/overlays/keycloak/devcontainer.patch.json +0 -1
  97. package/overlays/keycloak/docker-compose.yml +1 -0
  98. package/overlays/keycloak/overlay.yml +15 -0
  99. package/overlays/localstack/docker-compose.yml +1 -0
  100. package/overlays/localstack/overlay.yml +19 -1
  101. package/overlays/loki/devcontainer.patch.json +0 -1
  102. package/overlays/loki/docker-compose.yml +8 -0
  103. package/overlays/loki/overlay.yml +1 -0
  104. package/overlays/mailpit/docker-compose.yml +1 -0
  105. package/overlays/mailpit/overlay.yml +1 -0
  106. package/overlays/minio/devcontainer.patch.json +1 -1
  107. package/overlays/minio/docker-compose.yml +1 -0
  108. package/overlays/minio/overlay.yml +23 -2
  109. package/overlays/mkdocs/devcontainer.patch.json +1 -5
  110. package/overlays/mkdocs/overlay.yml +3 -1
  111. package/overlays/mkdocs2/devcontainer.patch.json +1 -5
  112. package/overlays/mkdocs2/overlay.yml +2 -0
  113. package/overlays/mongodb/docker-compose.yml +2 -0
  114. package/overlays/mongodb/overlay.yml +26 -2
  115. package/overlays/mysql/docker-compose.yml +2 -0
  116. package/overlays/mysql/overlay.yml +36 -2
  117. package/overlays/nats/docker-compose.yml +1 -0
  118. package/overlays/nats/overlay.yml +18 -2
  119. package/overlays/nodejs/devcontainer.patch.json +1 -12
  120. package/overlays/nodejs/overlay.yml +8 -1
  121. package/overlays/ollama/README.md +4 -3
  122. package/overlays/ollama/docker-compose.yml +1 -0
  123. package/overlays/ollama/overlay.yml +6 -1
  124. package/overlays/ollama/verify.sh +5 -28
  125. package/overlays/ollama-cli/README.md +90 -0
  126. package/overlays/ollama-cli/devcontainer.patch.json +3 -0
  127. package/overlays/ollama-cli/overlay.yml +19 -0
  128. package/overlays/{ollama → ollama-cli}/setup.sh +7 -10
  129. package/overlays/ollama-cli/verify.sh +49 -0
  130. package/overlays/open-webui/docker-compose.yml +1 -0
  131. package/overlays/open-webui/overlay.yml +8 -1
  132. package/overlays/opencode/devcontainer.patch.json +4 -1
  133. package/overlays/otel-collector/README.md +4 -0
  134. package/overlays/otel-collector/devcontainer.patch.json +4 -1
  135. package/overlays/otel-collector/docker-compose.yml +8 -4
  136. package/overlays/otel-collector/overlay.yml +1 -0
  137. package/overlays/otel-demo-nodejs/devcontainer.patch.json +0 -1
  138. package/overlays/otel-demo-nodejs/docker-compose.yml +1 -0
  139. package/overlays/otel-demo-nodejs/overlay.yml +9 -1
  140. package/overlays/otel-demo-python/devcontainer.patch.json +0 -1
  141. package/overlays/otel-demo-python/docker-compose.yml +1 -0
  142. package/overlays/otel-demo-python/overlay.yml +6 -1
  143. package/overlays/pandoc/README.md +10 -0
  144. package/overlays/pandoc/devcontainer.patch.json +0 -5
  145. package/overlays/pandoc/overlay.yml +2 -0
  146. package/overlays/pandoc/setup.sh +10 -0
  147. package/overlays/pgvector/devcontainer.patch.json +11 -5
  148. package/overlays/pgvector/docker-compose.yml +1 -0
  149. package/overlays/pgvector/overlay.yml +3 -0
  150. package/overlays/playwright/devcontainer.patch.json +0 -5
  151. package/overlays/playwright/overlay.yml +2 -1
  152. package/overlays/postgres/docker-compose.yml +1 -0
  153. package/overlays/postgres/overlay.yml +4 -1
  154. package/overlays/pre-commit/devcontainer.patch.json +1 -7
  155. package/overlays/prometheus/devcontainer.patch.json +0 -1
  156. package/overlays/prometheus/docker-compose.yml +8 -0
  157. package/overlays/prometheus/overlay.yml +1 -0
  158. package/overlays/promtail/devcontainer.patch.json +1 -2
  159. package/overlays/promtail/docker-compose.yml +8 -0
  160. package/overlays/promtail/overlay.yml +1 -0
  161. package/overlays/qdrant/docker-compose.yml +1 -0
  162. package/overlays/qdrant/overlay.yml +5 -1
  163. package/overlays/rabbitmq/docker-compose.yml +1 -0
  164. package/overlays/rabbitmq/overlay.yml +25 -2
  165. package/overlays/redis/docker-compose.yml +7 -0
  166. package/overlays/redis/overlay.yml +15 -1
  167. package/overlays/redpanda/docker-compose.yml +1 -0
  168. package/overlays/redpanda/overlay.yml +15 -3
  169. package/overlays/rocm/overlay.yml +2 -1
  170. package/overlays/rust/overlay.yml +3 -1
  171. package/overlays/sqlserver/docker-compose.yml +1 -0
  172. package/overlays/sqlserver/overlay.yml +17 -0
  173. package/overlays/task/README.md +47 -0
  174. package/overlays/task/devcontainer.patch.json +9 -0
  175. package/overlays/task/overlay.yml +16 -0
  176. package/overlays/task/setup.sh +29 -0
  177. package/overlays/task/verify.sh +14 -0
  178. package/overlays/tempo/devcontainer.patch.json +0 -1
  179. package/overlays/tempo/docker-compose.yml +8 -0
  180. package/overlays/tempo/overlay.yml +1 -0
  181. package/overlays/windsurf-cli/devcontainer.patch.json +4 -1
  182. package/package.json +1 -1
  183. package/tool/schema/config.schema.json +74 -1
  184. package/overlays/.shared/otel/otel-base-config.yaml +0 -30
@@ -606,7 +606,7 @@ function validateImportPath(importPath, overlaysDir) {
606
606
  /**
607
607
  * Load and resolve imports from shared files for an overlay
608
608
  */
609
- function loadImportsForOverlay(overlayName, overlaysDir) {
609
+ function loadImportsForOverlay(overlayName, overlaysDir, silent = false) {
610
610
  let importedConfig = {};
611
611
  // Load overlay manifest to get imports
612
612
  const overlayDir = path.join(overlaysDir, overlayName);
@@ -638,14 +638,16 @@ function loadImportsForOverlay(overlayName, overlaysDir) {
638
638
  const ext = path.extname(importPath).toLowerCase();
639
639
  if (ext === '.json') {
640
640
  // JSON files are merged as devcontainer patches
641
- console.log(chalk.dim(` 📎 Applying shared import: ${importPath}`));
641
+ if (!silent)
642
+ console.log(chalk.dim(` 📎 Applying shared import: ${importPath}`));
642
643
  const importedPatch = loadJson(fullImportPath);
643
644
  importedConfig = deepMerge(importedConfig, importedPatch);
644
645
  }
645
646
  else if (ext === '.yaml' || ext === '.yml') {
646
647
  // YAML files are loaded and merged as devcontainer patches
647
648
  try {
648
- console.log(chalk.dim(` 📎 Applying shared import: ${importPath}`));
649
+ if (!silent)
650
+ console.log(chalk.dim(` 📎 Applying shared import: ${importPath}`));
649
651
  const yamlContent = fs.readFileSync(fullImportPath, 'utf8');
650
652
  const importedPatch = yaml.load(yamlContent);
651
653
  if (importedPatch && typeof importedPatch === 'object') {
@@ -658,7 +660,8 @@ function loadImportsForOverlay(overlayName, overlaysDir) {
658
660
  }
659
661
  else if (ext === '.env') {
660
662
  // .env files are handled separately during env merging — skip here
661
- console.log(chalk.dim(` 📎 Shared .env import noted: ${importPath}`));
663
+ if (!silent)
664
+ console.log(chalk.dim(` 📎 Shared .env import noted: ${importPath}`));
662
665
  }
663
666
  else {
664
667
  // FR-007: Unsupported file types are errors
@@ -679,14 +682,16 @@ function loadImportsForOverlay(overlayName, overlaysDir) {
679
682
  /**
680
683
  * Apply an overlay to the base configuration
681
684
  */
682
- export function applyOverlay(baseConfig, overlayName, overlaysDir) {
685
+ export function applyOverlay(baseConfig, overlayName, overlaysDir, options = {}) {
686
+ const { silent = false } = options;
683
687
  const overlayPath = path.join(overlaysDir, overlayName, 'devcontainer.patch.json');
684
688
  if (!fs.existsSync(overlayPath)) {
685
- console.warn(chalk.yellow(`⚠️ Overlay not found: ${overlayName}`));
689
+ if (!silent)
690
+ console.warn(chalk.yellow(`⚠️ Overlay not found: ${overlayName}`));
686
691
  return baseConfig;
687
692
  }
688
693
  // First, load and apply any imports
689
- const importedConfig = loadImportsForOverlay(overlayName, overlaysDir);
694
+ const importedConfig = loadImportsForOverlay(overlayName, overlaysDir, silent);
690
695
  if (Object.keys(importedConfig).length > 0) {
691
696
  baseConfig = deepMerge(baseConfig, importedConfig);
692
697
  }
@@ -728,8 +733,36 @@ class FileRegistry {
728
733
  }
729
734
  }
730
735
  /**
731
- * Clean up stale files from previous runs
732
- * Removes anything not in the registry (except preserved files like superposition.json)
736
+ * Recursively remove stale files within a registered subdirectory.
737
+ * Called for directories that ARE in the registry but may contain files from
738
+ * a previous run that are no longer part of the current generation (e.g.
739
+ * scripts/setup-rabbitmq.sh after rabbitmq was removed from the project).
740
+ * Returns the number of files removed.
741
+ */
742
+ function cleanupStaleDirFiles(dirPath, prefix, expectedFiles) {
743
+ let removed = 0;
744
+ const entries = fs.readdirSync(dirPath);
745
+ for (const entry of entries) {
746
+ const entryPath = path.join(dirPath, entry);
747
+ const stat = fs.statSync(entryPath);
748
+ if (stat.isDirectory()) {
749
+ removed += cleanupStaleDirFiles(entryPath, `${prefix}${entry}/`, expectedFiles);
750
+ }
751
+ else {
752
+ const registryKey = `${prefix}${entry}`;
753
+ if (!expectedFiles.has(registryKey)) {
754
+ fs.unlinkSync(entryPath);
755
+ removed++;
756
+ }
757
+ }
758
+ }
759
+ return removed;
760
+ }
761
+ /**
762
+ * Clean up stale files from previous runs.
763
+ * Removes anything not in the registry (except preserved files like superposition.json).
764
+ * Also recurses into registered subdirectories to remove individual stale files within
765
+ * them — e.g. scripts/setup-rabbitmq.sh after rabbitmq is removed from the project.
733
766
  */
734
767
  function cleanupStaleFiles(outputPath, registry) {
735
768
  if (!fs.existsSync(outputPath)) {
@@ -753,11 +786,16 @@ function cleanupStaleFiles(outputPath, registry) {
753
786
  if (preservedDirs.has(entry)) {
754
787
  continue;
755
788
  }
756
- // Remove directory if not in registry
757
789
  if (!expectedDirs.has(entry)) {
790
+ // Remove directory entirely — nothing inside belongs to this run
758
791
  fs.rmSync(entryPath, { recursive: true, force: true });
759
792
  removedCount++;
760
793
  }
794
+ else {
795
+ // Directory is still expected, but individual files inside it may be stale
796
+ // (e.g. scripts/setup-rabbitmq.sh after rabbitmq was removed)
797
+ removedCount += cleanupStaleDirFiles(entryPath, `${entry}/`, expectedFiles);
798
+ }
761
799
  }
762
800
  else {
763
801
  // Remove file if not in registry
@@ -790,6 +828,23 @@ function copyDir(src, dest) {
790
828
  }
791
829
  }
792
830
  }
831
+ /**
832
+ * Recursively register every file inside a directory in the FileRegistry.
833
+ * Used after copyDir() to ensure cleanup logic doesn't delete the copied contents.
834
+ */
835
+ function registerDirContents(registry, dirPath, prefix) {
836
+ if (!fs.existsSync(dirPath))
837
+ return;
838
+ for (const entry of fs.readdirSync(dirPath, { withFileTypes: true })) {
839
+ const rel = `${prefix}${entry.name}`;
840
+ if (entry.isDirectory()) {
841
+ registerDirContents(registry, path.join(dirPath, entry.name), `${rel}/`);
842
+ }
843
+ else {
844
+ registry.addFile(rel);
845
+ }
846
+ }
847
+ }
793
848
  /**
794
849
  * Copy additional files from overlay to output directory
795
850
  * Excludes devcontainer.patch.json and .env.example (handled separately)
@@ -832,6 +887,9 @@ function copyOverlayFiles(outputPath, overlayName, registry, overlaysDir) {
832
887
  const destPath = path.join(outputPath, destDirName);
833
888
  copyDir(srcPath, destPath);
834
889
  registry.addDirectory(destDirName);
890
+ // Register every file inside the copied directory so that
891
+ // cleanupStaleDirFiles does not delete them during the same run.
892
+ registerDirContents(registry, destPath, `${destDirName}/`);
835
893
  copiedFiles++;
836
894
  }
837
895
  }
@@ -946,6 +1004,178 @@ function applyProjectEnvToDevcontainer(config, projectEnv, stack, rootEnv) {
946
1004
  console.log(chalk.dim(` 🌱 Applying project env to remoteEnv`));
947
1005
  return deepMerge(config, { remoteEnv });
948
1006
  }
1007
+ /**
1008
+ * Resolve the abstract `ProjectMountTarget` to a concrete destination.
1009
+ *
1010
+ * - `devcontainerMount` — always routes to `devcontainer.json mounts[]`
1011
+ * - `composeVolume` — always routes to `docker-compose.yml services.devcontainer.volumes[]`;
1012
+ * throws if the stack is not `compose` (no docker-compose.yml is generated for plain stacks)
1013
+ * - `auto` (default) — always routes to `devcontainer.json mounts[]` regardless of stack,
1014
+ * so that the same `superposition.yml` works unchanged when swapping `stack: plain` ↔ `stack: compose`
1015
+ *
1016
+ * @throws {Error} When `composeVolume` is requested on a non-compose stack
1017
+ */
1018
+ function resolveProjectMountTarget(mount, stack) {
1019
+ const target = mount.target ?? 'auto';
1020
+ if (target === 'composeVolume') {
1021
+ if (stack !== 'compose') {
1022
+ throw new Error('Project mount target "composeVolume" requires stack: compose because no docker-compose.yml is generated for plain stacks');
1023
+ }
1024
+ return 'composeVolume';
1025
+ }
1026
+ // Both 'auto' (default) and explicit 'devcontainerMount' are treated identically:
1027
+ // always route to devcontainer.json mounts[] regardless of stack
1028
+ return 'devcontainerMount';
1029
+ }
1030
+ function boolToReadonlyFlag(value) {
1031
+ return value ? ',readonly' : '';
1032
+ }
1033
+ function buildConsistencyValue(mount) {
1034
+ if (mount.cached) {
1035
+ return 'cached';
1036
+ }
1037
+ return mount.consistency;
1038
+ }
1039
+ function toDevcontainerMountSpec(mount) {
1040
+ if (mount.value) {
1041
+ return mount.value;
1042
+ }
1043
+ const source = mount.source?.trim();
1044
+ const destination = mount.destination?.trim();
1045
+ if (!source || !destination) {
1046
+ throw new Error('Structured project mounts require both "source" and "destination" when no raw "value" is provided');
1047
+ }
1048
+ const type = mount.type ?? 'bind';
1049
+ const consistency = buildConsistencyValue(mount);
1050
+ return `source=${source},target=${destination},type=${type}${consistency ? `,consistency=${consistency}` : ''}${boolToReadonlyFlag(mount.readOnly)}`;
1051
+ }
1052
+ function toComposeVolumeSpec(mount) {
1053
+ if (mount.value) {
1054
+ return mount.value;
1055
+ }
1056
+ const source = mount.source?.trim();
1057
+ const destination = mount.destination?.trim();
1058
+ if (!source || !destination) {
1059
+ throw new Error('Structured project mounts require both "source" and "destination" when no raw "value" is provided');
1060
+ }
1061
+ const options = [];
1062
+ if (mount.readOnly) {
1063
+ options.push('ro');
1064
+ }
1065
+ const consistency = buildConsistencyValue(mount);
1066
+ if (consistency) {
1067
+ options.push(consistency);
1068
+ }
1069
+ return options.length > 0
1070
+ ? `${source}:${destination}:${options.join(',')}`
1071
+ : `${source}:${destination}`;
1072
+ }
1073
+ /**
1074
+ * Merge project mounts destined for `devcontainer.json` into the devcontainer config.
1075
+ *
1076
+ * Filters the `projectMounts` list to entries whose resolved target is `devcontainerMount`
1077
+ * (i.e. explicit `target: devcontainerMount`, or `target: auto`), then
1078
+ * deepMerge-concatenates their values into `config.mounts[]`. Returns early unchanged if
1079
+ * no applicable mounts exist.
1080
+ */
1081
+ function applyProjectMountsToDevcontainer(config, projectMounts, stack) {
1082
+ if (!projectMounts?.length) {
1083
+ return config;
1084
+ }
1085
+ const devcontainerMounts = projectMounts
1086
+ .filter((m) => resolveProjectMountTarget(m, stack) === 'devcontainerMount')
1087
+ .map(toDevcontainerMountSpec);
1088
+ if (devcontainerMounts.length === 0) {
1089
+ return config;
1090
+ }
1091
+ console.log(chalk.dim(` 🗂️ Applying project mounts to devcontainer.json`));
1092
+ return deepMerge(config, { mounts: devcontainerMounts });
1093
+ }
1094
+ /**
1095
+ * Extract mount values destined for `docker-compose.yml services.devcontainer.volumes[]`.
1096
+ *
1097
+ * Returns the raw value strings for all mounts whose resolved target is `composeVolume`
1098
+ * (i.e. explicit `target: composeVolume` only). The caller is responsible for merging the
1099
+ * returned array into the compose service definition.
1100
+ */
1101
+ function buildComposeProjectMountVolumes(projectMounts, stack) {
1102
+ if (!projectMounts?.length) {
1103
+ return [];
1104
+ }
1105
+ return projectMounts
1106
+ .filter((m) => resolveProjectMountTarget(m, stack) === 'composeVolume')
1107
+ .map(toComposeVolumeSpec);
1108
+ }
1109
+ function quoteShellSingle(value) {
1110
+ return `'${value.replace(/'/g, `'\"'\"'`)}'`;
1111
+ }
1112
+ function applyProjectShellConfig(config, projectShell, outputPath, fileRegistry) {
1113
+ if (!projectShell) {
1114
+ return config;
1115
+ }
1116
+ const aliases = projectShell.aliases ?? {};
1117
+ const snippets = projectShell.snippets ?? [];
1118
+ if (Object.keys(aliases).length === 0 && snippets.length === 0) {
1119
+ return config;
1120
+ }
1121
+ const scriptsDir = path.join(outputPath, 'scripts');
1122
+ fs.mkdirSync(scriptsDir, { recursive: true });
1123
+ fileRegistry.addDirectory('scripts');
1124
+ const shellInitPath = path.join(scriptsDir, 'shell-init.sh');
1125
+ const aliasLines = Object.entries(aliases)
1126
+ .sort(([a], [b]) => a.localeCompare(b))
1127
+ .map(([name, cmd]) => `alias ${name}=${quoteShellSingle(cmd)}`);
1128
+ const shellInit = [
1129
+ '#!/usr/bin/env bash',
1130
+ '# Generated by container-superposition from superposition.yml:shell',
1131
+ ...aliasLines,
1132
+ ...(snippets.length > 0 ? ['', ...snippets] : []),
1133
+ '',
1134
+ ].join('\n');
1135
+ fs.writeFileSync(shellInitPath, shellInit, 'utf8');
1136
+ fileRegistry.addFile('scripts/shell-init.sh');
1137
+ const hookScriptPath = path.join(scriptsDir, 'setup-project-shell.sh');
1138
+ const hookScript = `#!/usr/bin/env bash
1139
+ set -e
1140
+ BEGIN_MARKER="# >>> container-superposition shell >>>"
1141
+ END_MARKER="# <<< container-superposition shell <<<"
1142
+ DEVCONTAINER_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")/.." && pwd)"
1143
+ SHELL_INIT_FILE="\${DEVCONTAINER_DIR}/scripts/shell-init.sh"
1144
+
1145
+ install_hook() {
1146
+ local rc_file="$1"
1147
+ touch "$rc_file"
1148
+ local tmp_file
1149
+ tmp_file="$(mktemp)"
1150
+ awk -v b="$BEGIN_MARKER" -v e="$END_MARKER" '
1151
+ $0==b {skip=1; next}
1152
+ $0==e {skip=0; next}
1153
+ !skip {print}
1154
+ ' "$rc_file" > "$tmp_file"
1155
+ cat >> "$tmp_file" <<EOF
1156
+ $BEGIN_MARKER
1157
+ [ -f "$SHELL_INIT_FILE" ] && source "$SHELL_INIT_FILE"
1158
+ $END_MARKER
1159
+ EOF
1160
+ mv "$tmp_file" "$rc_file"
1161
+ }
1162
+
1163
+ install_hook "$HOME/.bashrc"
1164
+ install_hook "$HOME/.zshrc"
1165
+ `;
1166
+ fs.writeFileSync(hookScriptPath, hookScript, { mode: 0o755 });
1167
+ fileRegistry.addFile('scripts/setup-project-shell.sh');
1168
+ if (!config.postCreateCommand) {
1169
+ config.postCreateCommand = {};
1170
+ }
1171
+ if (typeof config.postCreateCommand === 'string') {
1172
+ config.postCreateCommand = { default: config.postCreateCommand };
1173
+ }
1174
+ config.postCreateCommand['setup-project-shell'] =
1175
+ 'bash .devcontainer/scripts/setup-project-shell.sh';
1176
+ console.log(chalk.dim(` 🐚 Applying project shell aliases/snippets`));
1177
+ return config;
1178
+ }
949
1179
  function mergeComposeEnvFile(outputPath, entries) {
950
1180
  if (Object.keys(entries).length === 0) {
951
1181
  return false;
@@ -1269,7 +1499,7 @@ function resolveDockerComposePortConflicts(services) {
1269
1499
  /**
1270
1500
  * Merge docker-compose.yml files from base and overlays into a single file
1271
1501
  */
1272
- function mergeDockerComposeFiles(outputPath, baseStack, overlays, overlaysDir, portOffset, customImage, projectEnv) {
1502
+ function mergeDockerComposeFiles(outputPath, baseStack, overlays, overlaysDir, portOffset, customImage, projectEnv, projectMounts) {
1273
1503
  const composeFiles = [];
1274
1504
  // Add base docker-compose if exists
1275
1505
  const baseComposePath = path.join(TEMPLATES_DIR, baseStack, '.devcontainer', 'docker-compose.yml');
@@ -1353,6 +1583,16 @@ function mergeDockerComposeFiles(outputPath, baseStack, overlays, overlaysDir, p
1353
1583
  merged.services.devcontainer.environment = deepMerge(merged.services.devcontainer.environment ?? {}, composeEnv);
1354
1584
  console.log(chalk.dim(` 🌱 Applying project env to docker-compose devcontainer service`));
1355
1585
  }
1586
+ const composeMountVolumes = buildComposeProjectMountVolumes(projectMounts, baseStack);
1587
+ if (composeMountVolumes.length > 0) {
1588
+ const existing = Array.isArray(merged.services.devcontainer.volumes)
1589
+ ? merged.services.devcontainer.volumes
1590
+ : [];
1591
+ merged.services.devcontainer.volumes = [
1592
+ ...new Set([...existing, ...composeMountVolumes]),
1593
+ ];
1594
+ console.log(chalk.dim(` 🗂️ Applying project mounts to docker-compose devcontainer service`));
1595
+ }
1356
1596
  if (customImage) {
1357
1597
  // Apply custom base image if specified
1358
1598
  merged.services.devcontainer.image = customImage;
@@ -1776,6 +2016,7 @@ export async function composeDevContainer(answers, overlaysDir, options = {}) {
1776
2016
  config = applyOverlay(config, overlay, actualOverlaysDir);
1777
2017
  }
1778
2018
  config = applyProjectEnvToDevcontainer(config, answers.projectEnv, answers.stack, rootEnv);
2019
+ config = applyProjectMountsToDevcontainer(config, answers.projectMounts, answers.stack);
1779
2020
  // 7. Copy template files (docker-compose, scripts, etc.)
1780
2021
  const entries = fs.readdirSync(templatePath);
1781
2022
  for (const entry of entries) {
@@ -1799,11 +2040,19 @@ export async function composeDevContainer(answers, overlaysDir, options = {}) {
1799
2040
  }
1800
2041
  // 8.5. Copy cross-distro-packages feature if used
1801
2042
  if (config.features?.['./features/cross-distro-packages']) {
1802
- const featuresDir = path.join(outputPath, 'features', 'cross-distro-packages');
1803
- const sourceFeatureDir = path.join(REPO_ROOT, 'features', 'cross-distro-packages');
2043
+ const featureName = 'cross-distro-packages';
2044
+ const featuresDir = path.join(outputPath, 'features', featureName);
2045
+ const sourceFeatureDir = path.join(REPO_ROOT, 'features', featureName);
1804
2046
  if (fs.existsSync(sourceFeatureDir)) {
1805
2047
  copyDir(sourceFeatureDir, featuresDir);
1806
2048
  fileRegistry.addDirectory('features');
2049
+ // Register every file inside the feature so cleanupStaleDirFiles
2050
+ // does not remove them when it recurses into the 'features' directory.
2051
+ for (const f of fs.readdirSync(sourceFeatureDir)) {
2052
+ if (fs.statSync(path.join(sourceFeatureDir, f)).isFile()) {
2053
+ fileRegistry.addFile(`features/${featureName}/${f}`);
2054
+ }
2055
+ }
1807
2056
  console.log(chalk.dim(` 📦 Copied cross-distro-packages feature`));
1808
2057
  }
1809
2058
  }
@@ -1815,7 +2064,7 @@ export async function composeDevContainer(answers, overlaysDir, options = {}) {
1815
2064
  let composePortRemappings = [];
1816
2065
  if (answers.stack === 'compose') {
1817
2066
  const customImage = config._customImage;
1818
- composePortRemappings = mergeDockerComposeFiles(outputPath, answers.stack, overlays, actualOverlaysDir, answers.portOffset, customImage, answers.projectEnv);
2067
+ composePortRemappings = mergeDockerComposeFiles(outputPath, answers.stack, overlays, actualOverlaysDir, answers.portOffset, customImage, answers.projectEnv, answers.projectMounts);
1819
2068
  // Update devcontainer.json to reference the combined file
1820
2069
  if (config.dockerComposeFile) {
1821
2070
  config.dockerComposeFile = 'docker-compose.yml';
@@ -1838,6 +2087,7 @@ export async function composeDevContainer(answers, overlaysDir, options = {}) {
1838
2087
  }
1839
2088
  // Merge setup scripts from overlays into postCreateCommand
1840
2089
  mergeSetupScripts(config, overlays, outputPath, fileRegistry, actualOverlaysDir);
2090
+ config = applyProjectShellConfig(config, answers.projectShell, outputPath, fileRegistry);
1841
2091
  // 10. Apply custom patches from .devcontainer/custom/ (if present)
1842
2092
  const customPatches = loadCustomPatches(outputPath);
1843
2093
  if (customPatches) {
@@ -2155,15 +2405,14 @@ function applyPortOffsetToDevcontainer(config, offset) {
2155
2405
  function mergeSetupScripts(config, overlays, outputPath, fileRegistry, overlaysDir) {
2156
2406
  const setupScripts = [];
2157
2407
  const verifyScripts = [];
2158
- // Create scripts subfolder
2159
2408
  const scriptsDir = path.join(outputPath, 'scripts');
2160
- if (!fs.existsSync(scriptsDir)) {
2161
- fs.mkdirSync(scriptsDir, { recursive: true });
2162
- }
2163
- // Add scripts directory to registry if any scripts will be added
2409
+ // Only create the scripts directory (and register it) if at least one overlay needs it
2164
2410
  const hasScripts = overlays.some((o) => fs.existsSync(path.join(overlaysDir, o, 'setup.sh')) ||
2165
2411
  fs.existsSync(path.join(overlaysDir, o, 'verify.sh')));
2166
2412
  if (hasScripts) {
2413
+ if (!fs.existsSync(scriptsDir)) {
2414
+ fs.mkdirSync(scriptsDir, { recursive: true });
2415
+ }
2167
2416
  fileRegistry.addDirectory('scripts');
2168
2417
  // Emit shared setup utilities so overlay scripts can source them
2169
2418
  const setupUtilsSrc = path.join(TEMPLATES_DIR, 'scripts', 'setup-utils.sh');
@@ -2291,7 +2540,11 @@ function mergeRunServices(config, overlays, overlaysDir) {
2291
2540
  if (fs.existsSync(overlayPath)) {
2292
2541
  const overlayConfig = loadJson(overlayPath);
2293
2542
  if (overlayConfig.runServices) {
2294
- const order = overlayConfig._serviceOrder || 0;
2543
+ const manifestPath = path.join(overlaysDir, overlay, 'overlay.yml');
2544
+ const manifest = fs.existsSync(manifestPath)
2545
+ ? yaml.load(fs.readFileSync(manifestPath, 'utf8'))
2546
+ : null;
2547
+ const order = manifest?.serviceOrder ?? 0;
2295
2548
  for (const service of overlayConfig.runServices) {
2296
2549
  services.push({ name: service, order });
2297
2550
  }