container-superposition 0.1.7 → 0.1.8

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 (122) hide show
  1. package/README.md +24 -15
  2. package/dist/scripts/init.js +1 -1537
  3. package/dist/scripts/init.js.map +1 -1
  4. package/dist/tool/cli/args.d.ts +20 -0
  5. package/dist/tool/cli/args.d.ts.map +1 -0
  6. package/dist/tool/cli/args.js +325 -0
  7. package/dist/tool/cli/args.js.map +1 -0
  8. package/dist/tool/cli/run.d.ts +2 -0
  9. package/dist/tool/cli/run.d.ts.map +1 -0
  10. package/dist/tool/cli/run.js +318 -0
  11. package/dist/tool/cli/run.js.map +1 -0
  12. package/dist/tool/commands/doctor.d.ts.map +1 -1
  13. package/dist/tool/commands/doctor.js +141 -6
  14. package/dist/tool/commands/doctor.js.map +1 -1
  15. package/dist/tool/commands/explain.d.ts.map +1 -1
  16. package/dist/tool/commands/explain.js +9 -0
  17. package/dist/tool/commands/explain.js.map +1 -1
  18. package/dist/tool/commands/migrate.d.ts +7 -0
  19. package/dist/tool/commands/migrate.d.ts.map +1 -0
  20. package/dist/tool/commands/migrate.js +52 -0
  21. package/dist/tool/commands/migrate.js.map +1 -0
  22. package/dist/tool/questionnaire/answers.d.ts +16 -0
  23. package/dist/tool/questionnaire/answers.d.ts.map +1 -0
  24. package/dist/tool/questionnaire/answers.js +102 -0
  25. package/dist/tool/questionnaire/answers.js.map +1 -0
  26. package/dist/tool/questionnaire/composer.d.ts +3 -3
  27. package/dist/tool/questionnaire/composer.d.ts.map +1 -1
  28. package/dist/tool/questionnaire/composer.js +691 -27
  29. package/dist/tool/questionnaire/composer.js.map +1 -1
  30. package/dist/tool/questionnaire/presets.d.ts +60 -0
  31. package/dist/tool/questionnaire/presets.d.ts.map +1 -0
  32. package/dist/tool/questionnaire/presets.js +164 -0
  33. package/dist/tool/questionnaire/presets.js.map +1 -0
  34. package/dist/tool/questionnaire/questionnaire.d.ts +10 -0
  35. package/dist/tool/questionnaire/questionnaire.d.ts.map +1 -0
  36. package/dist/tool/questionnaire/questionnaire.js +580 -0
  37. package/dist/tool/questionnaire/questionnaire.js.map +1 -0
  38. package/dist/tool/schema/manifest-migrations.d.ts +5 -0
  39. package/dist/tool/schema/manifest-migrations.d.ts.map +1 -1
  40. package/dist/tool/schema/manifest-migrations.js +45 -0
  41. package/dist/tool/schema/manifest-migrations.js.map +1 -1
  42. package/dist/tool/schema/overlay-loader.d.ts.map +1 -1
  43. package/dist/tool/schema/overlay-loader.js +24 -0
  44. package/dist/tool/schema/overlay-loader.js.map +1 -1
  45. package/dist/tool/schema/project-config.d.ts +13 -1
  46. package/dist/tool/schema/project-config.d.ts.map +1 -1
  47. package/dist/tool/schema/project-config.js +183 -9
  48. package/dist/tool/schema/project-config.js.map +1 -1
  49. package/dist/tool/schema/target-rules.d.ts +78 -0
  50. package/dist/tool/schema/target-rules.d.ts.map +1 -0
  51. package/dist/tool/schema/target-rules.js +367 -0
  52. package/dist/tool/schema/target-rules.js.map +1 -0
  53. package/dist/tool/schema/types.d.ts +38 -1
  54. package/dist/tool/schema/types.d.ts.map +1 -1
  55. package/dist/tool/utils/parameters.d.ts +76 -0
  56. package/dist/tool/utils/parameters.d.ts.map +1 -0
  57. package/dist/tool/utils/parameters.js +125 -0
  58. package/dist/tool/utils/parameters.js.map +1 -0
  59. package/dist/tool/utils/paths.d.ts +2 -0
  60. package/dist/tool/utils/paths.d.ts.map +1 -0
  61. package/dist/tool/utils/paths.js +31 -0
  62. package/dist/tool/utils/paths.js.map +1 -0
  63. package/docs/deployment-targets.md +88 -56
  64. package/docs/examples.md +20 -17
  65. package/docs/filesystem-contract.md +5 -0
  66. package/docs/minimal-and-editor.md +65 -5
  67. package/docs/overlay-imports.md +92 -14
  68. package/docs/overlays.md +113 -28
  69. package/docs/specs/007-init-project-file/spec.md +66 -0
  70. package/docs/specs/007-target-aware-generation/spec.md +126 -0
  71. package/docs/specs/008-project-file-canonical/spec.md +83 -0
  72. package/docs/specs/009-project-env/spec.md +147 -0
  73. package/docs/specs/010-compose-env-materialization/spec.md +130 -0
  74. package/docs/specs/011-overlay-parameters/spec.md +235 -0
  75. package/overlays/.shared/README.md +27 -2
  76. package/overlays/.shared/compose/nvidia-gpu-devcontainer.yml +22 -0
  77. package/overlays/comfyui/.env.example +34 -0
  78. package/overlays/comfyui/README.md +342 -0
  79. package/overlays/comfyui/devcontainer.patch.json +15 -0
  80. package/overlays/comfyui/docker-compose.yml +39 -0
  81. package/overlays/comfyui/overlay.yml +20 -0
  82. package/overlays/comfyui/setup.sh +36 -0
  83. package/overlays/comfyui/verify.sh +103 -0
  84. package/overlays/k3d/README.md +201 -0
  85. package/overlays/k3d/devcontainer.patch.json +9 -0
  86. package/overlays/k3d/overlay.yml +19 -0
  87. package/overlays/k3d/setup.sh +34 -0
  88. package/overlays/k3d/verify.sh +38 -0
  89. package/overlays/ollama/.env.example +14 -0
  90. package/overlays/ollama/README.md +325 -0
  91. package/overlays/ollama/devcontainer.patch.json +14 -0
  92. package/overlays/ollama/docker-compose.yml +24 -0
  93. package/overlays/ollama/overlay.yml +22 -0
  94. package/overlays/ollama/setup.sh +106 -0
  95. package/overlays/ollama/verify.sh +99 -0
  96. package/overlays/open-webui/.env.example +5 -0
  97. package/overlays/open-webui/README.md +162 -0
  98. package/overlays/open-webui/devcontainer.patch.json +14 -0
  99. package/overlays/open-webui/docker-compose.yml +23 -0
  100. package/overlays/open-webui/overlay.yml +38 -0
  101. package/overlays/pgvector/.env.example +6 -0
  102. package/overlays/pgvector/README.md +215 -0
  103. package/overlays/pgvector/devcontainer.patch.json +23 -0
  104. package/overlays/pgvector/docker-compose.yml +32 -0
  105. package/overlays/pgvector/overlay.yml +44 -0
  106. package/overlays/postgres/.env.example +5 -5
  107. package/overlays/postgres/devcontainer.patch.json +4 -4
  108. package/overlays/postgres/docker-compose.yml +10 -6
  109. package/overlays/postgres/overlay.yml +19 -1
  110. package/overlays/qdrant/.env.example +4 -0
  111. package/overlays/qdrant/README.md +216 -0
  112. package/overlays/qdrant/devcontainer.patch.json +20 -0
  113. package/overlays/qdrant/docker-compose.yml +25 -0
  114. package/overlays/qdrant/overlay.yml +40 -0
  115. package/overlays/skaffold/README.md +256 -0
  116. package/overlays/skaffold/devcontainer.patch.json +9 -0
  117. package/overlays/skaffold/overlay.yml +20 -0
  118. package/overlays/skaffold/setup.sh +33 -0
  119. package/overlays/skaffold/verify.sh +24 -0
  120. package/package.json +3 -2
  121. package/tool/schema/config.schema.json +31 -1
  122. package/tool/schema/overlay-manifest.schema.json +33 -0
@@ -12,20 +12,311 @@ import { deepMerge, mergePackages, filterDependsOn, applyPortOffsetToEnv, } from
12
12
  import { generatePortsDocumentation } from '../utils/port-utils.js';
13
13
  import { generateServicesMarkdown, generateEnvLocalExample } from '../utils/services-export.js';
14
14
  import { appendGitignoreSection } from '../utils/gitignore.js';
15
+ import { getTargetRule, resolveTargetFilePath, removeStaleTargetArtifacts, } from '../schema/target-rules.js';
16
+ import { collectOverlayParameters, resolveParameters, substituteParameters, substituteParametersInObject, findUnresolvedTokens, redactSensitiveValues, } from '../utils/parameters.js';
15
17
  import { detectWarnings, generateTips, generateNextSteps, overlaysToServices, portsToPortInfo, } from '../utils/summary.js';
18
+ import { resolveRepoPath } from '../utils/paths.js';
16
19
  // Get __dirname equivalent in ESM
17
20
  const __filename = fileURLToPath(import.meta.url);
18
21
  const __dirname = path.dirname(__filename);
19
- // Resolve REPO_ROOT that works in both source and compiled output
20
- // When running from TypeScript sources (e.g. ts-node), __dirname is "<root>/tool/questionnaire"
21
- // When running from compiled JS in "dist/tool/questionnaire", __dirname is "<root>/dist/tool/questionnaire"
22
- const REPO_ROOT_CANDIDATES = [
23
- path.join(__dirname, '..', '..'), // From source: tool/questionnaire -> root
24
- path.join(__dirname, '..', '..', '..'), // From dist: dist/tool/questionnaire -> root
25
- ];
26
- const REPO_ROOT = REPO_ROOT_CANDIDATES.find((candidate) => fs.existsSync(path.join(candidate, 'templates')) &&
27
- fs.existsSync(path.join(candidate, 'overlays'))) ?? REPO_ROOT_CANDIDATES[0];
28
- const TEMPLATES_DIR = path.join(REPO_ROOT, 'templates');
22
+ // Anchor for resolving the top-level templates directory.
23
+ // In source layout: <repo>/tool/questionnaire -> anchor becomes <repo>/tool.
24
+ // path.basename('tool') === 'tool', so we go one level up to reach <repo>.
25
+ // In compiled layout: <repo>/dist/tool/questionnaire -> anchor becomes <repo>/dist/tool.
26
+ // path.basename('tool') === 'tool', so we go one level up to <repo>/dist,
27
+ // then resolveRepoPath walks further up to find templates/ at the repo root.
28
+ // NOTE: This check relies on the source directory being named 'tool'. If that changes,
29
+ // update this constant accordingly.
30
+ const TEMPLATES_ANCHOR_BASE = path.join(__dirname, '..', '..');
31
+ const TEMPLATES_ANCHOR = path.basename(TEMPLATES_ANCHOR_BASE) === 'tool'
32
+ ? path.dirname(TEMPLATES_ANCHOR_BASE)
33
+ : TEMPLATES_ANCHOR_BASE;
34
+ const TEMPLATES_DIR = resolveRepoPath('templates', TEMPLATES_ANCHOR);
35
+ const REPO_ROOT = path.dirname(TEMPLATES_DIR);
36
+ // ─── JetBrains support ────────────────────────────────────────────────────
37
+ /**
38
+ * Language overlays that have a defined JetBrains backend mapping.
39
+ * Used both in getJetBrainsBackend() and in the language filter for
40
+ * generateJetBrainsArtifacts() — kept in one place for consistency.
41
+ */
42
+ const JETBRAINS_SUPPORTED_LANGUAGES = new Set([
43
+ 'nodejs',
44
+ 'bun',
45
+ 'python',
46
+ 'mkdocs',
47
+ 'go',
48
+ 'dotnet',
49
+ 'java',
50
+ 'rust',
51
+ ]);
52
+ /**
53
+ * Map a language overlay ID to the appropriate JetBrains backend identifier.
54
+ * Falls back to 'IntelliJIdea' when the language is unknown or unspecified.
55
+ *
56
+ * When multiple language overlays are selected, the first match in the
57
+ * provided array determines the backend; the array order reflects the user's
58
+ * selection order.
59
+ */
60
+ function getJetBrainsBackend(languageOverlays) {
61
+ for (const lang of languageOverlays) {
62
+ switch (lang) {
63
+ case 'nodejs':
64
+ case 'bun':
65
+ return 'WebStorm';
66
+ case 'python':
67
+ case 'mkdocs':
68
+ return 'PyCharm';
69
+ case 'go':
70
+ return 'GoLand';
71
+ case 'dotnet':
72
+ return 'Rider';
73
+ case 'rust':
74
+ return 'RustRover';
75
+ case 'java':
76
+ return 'IntelliJIdea';
77
+ }
78
+ }
79
+ return 'IntelliJIdea';
80
+ }
81
+ /**
82
+ * Generate the content of .idea/.gitignore for a JetBrains project.
83
+ * Marks shared settings (run configurations, code style) as tracked and
84
+ * excludes user-local entries (workspace.xml, shelf/).
85
+ */
86
+ function generateIdeaGitignore() {
87
+ return `# Default ignored files
88
+ /shelf/
89
+ /workspace.xml
90
+
91
+ # Editor-based HTTP Client requests
92
+ /httpRequests/
93
+
94
+ # Datasource local storage
95
+ /dataSources/
96
+ /dataSources.local.xml
97
+ `;
98
+ }
99
+ /**
100
+ * Generate a JetBrains run configuration XML for the given language overlay.
101
+ * Returns an object with the filename and XML content, or null when no
102
+ * configuration is defined for the supplied language.
103
+ */
104
+ function generateRunConfiguration(lang) {
105
+ switch (lang) {
106
+ case 'nodejs':
107
+ case 'bun': {
108
+ const manager = lang === 'bun' ? 'bun' : 'npm';
109
+ const runScript = lang === 'bun' ? 'bun run dev' : 'npm run dev';
110
+ return {
111
+ filename: `${manager}_dev.xml`,
112
+ content: `<component name="ProjectRunConfigurationManager">
113
+ <configuration default="false" name="${runScript}" type="js.build_tools.npm" factoryName="npm">
114
+ <package-json value="$PROJECT_DIR$/package.json" />
115
+ <command value="run" />
116
+ <scripts>
117
+ <script value="dev" />
118
+ </scripts>
119
+ <node-interpreter value="project" />
120
+ <envs />
121
+ <method v="2" />
122
+ </configuration>
123
+ </component>
124
+ `,
125
+ };
126
+ }
127
+ case 'mkdocs': {
128
+ return {
129
+ filename: 'mkdocs_serve.xml',
130
+ content: `<component name="ProjectRunConfigurationManager">
131
+ <configuration default="false" name="MkDocs: mkdocs serve" type="PythonConfigurationType" factoryName="Python">
132
+ <module name="" />
133
+ <option name="INTERPRETER_OPTIONS" value="" />
134
+ <option name="PARENT_ENVS" value="true" />
135
+ <envs>
136
+ <env name="PYTHONUNBUFFERED" value="1" />
137
+ </envs>
138
+ <option name="SDK_HOME" value="" />
139
+ <option name="SDK_NAME" value="" />
140
+ <option name="WORKING_DIRECTORY" value="$PROJECT_DIR$" />
141
+ <option name="IS_MODULE_SDK" value="false" />
142
+ <option name="ADD_CONTENT_ROOTS" value="true" />
143
+ <option name="ADD_SOURCE_ROOTS" value="true" />
144
+ <EXTENSION ID="PythonCoverageRunConfigurationExtension" runner="coverage.py" />
145
+ <option name="SCRIPT_NAME" value="-m" />
146
+ <option name="MODULE_MODE" value="true" />
147
+ <option name="PARAMETERS" value="mkdocs serve" />
148
+ <option name="SHOW_COMMAND_LINE" value="false" />
149
+ <option name="EMULATE_TERMINAL" value="false" />
150
+ <option name="REDIRECT_INPUT" value="false" />
151
+ <option name="INPUT_FILE" value="" />
152
+ <method v="2" />
153
+ </configuration>
154
+ </component>
155
+ `,
156
+ };
157
+ }
158
+ case 'python': {
159
+ return {
160
+ filename: 'python_main.xml',
161
+ content: `<component name="ProjectRunConfigurationManager">
162
+ <configuration default="false" name="Python: main.py" type="PythonConfigurationType" factoryName="Python">
163
+ <module name="" />
164
+ <option name="INTERPRETER_OPTIONS" value="" />
165
+ <option name="PARENT_ENVS" value="true" />
166
+ <envs>
167
+ <env name="PYTHONUNBUFFERED" value="1" />
168
+ </envs>
169
+ <option name="SDK_HOME" value="" />
170
+ <option name="SDK_NAME" value="" />
171
+ <option name="WORKING_DIRECTORY" value="$PROJECT_DIR$" />
172
+ <option name="IS_MODULE_SDK" value="false" />
173
+ <option name="ADD_CONTENT_ROOTS" value="true" />
174
+ <option name="ADD_SOURCE_ROOTS" value="true" />
175
+ <EXTENSION ID="PythonCoverageRunConfigurationExtension" runner="coverage.py" />
176
+ <option name="SCRIPT_NAME" value="$PROJECT_DIR$/main.py" />
177
+ <option name="PARAMETERS" value="" />
178
+ <option name="SHOW_COMMAND_LINE" value="false" />
179
+ <option name="EMULATE_TERMINAL" value="false" />
180
+ <option name="MODULE_MODE" value="false" />
181
+ <option name="REDIRECT_INPUT" value="false" />
182
+ <option name="INPUT_FILE" value="" />
183
+ <method v="2" />
184
+ </configuration>
185
+ </component>
186
+ `,
187
+ };
188
+ }
189
+ case 'go': {
190
+ return {
191
+ filename: 'go_run.xml',
192
+ content: `<component name="ProjectRunConfigurationManager">
193
+ <configuration default="false" name="Go: run ./..." type="GoApplicationRunConfiguration" factoryName="Go Application">
194
+ <module name="" />
195
+ <working_directory value="$PROJECT_DIR$" />
196
+ <parameters value="" />
197
+ <kind value="PACKAGE" />
198
+ <package value="./..." />
199
+ <directory value="$PROJECT_DIR$" />
200
+ <filePath value="$PROJECT_DIR$" />
201
+ <method v="2" />
202
+ </configuration>
203
+ </component>
204
+ `,
205
+ };
206
+ }
207
+ case 'dotnet': {
208
+ return {
209
+ filename: 'dotnet_run.xml',
210
+ content: `<component name="ProjectRunConfigurationManager">
211
+ <configuration default="false" name=".NET: dotnet run" type="DotNetRunConfiguration" factoryName="Run">
212
+ <option name="EXE_PATH" value="" />
213
+ <option name="PROGRAM_PARAMETERS" value="" />
214
+ <option name="WORKING_DIRECTORY" value="$PROJECT_DIR$" />
215
+ <option name="PASS_PARENT_ENVS" value="1" />
216
+ <option name="USE_EXTERNAL_CONSOLE" value="0" />
217
+ <option name="RUNTIME_ARGUMENTS" value="" />
218
+ <option name="PROJECT_PATH" value="$PROJECT_DIR$" />
219
+ <option name="TARGET_FRAMEWORK_ID" value="" />
220
+ <option name="RUNTIME_ID" value="" />
221
+ <method v="2" />
222
+ </configuration>
223
+ </component>
224
+ `,
225
+ };
226
+ }
227
+ case 'java': {
228
+ return {
229
+ filename: 'java_run.xml',
230
+ content: `<component name="ProjectRunConfigurationManager">
231
+ <configuration default="false" name="Java: Application" type="Application" factoryName="Application">
232
+ <option name="MAIN_CLASS_NAME" value="Main" />
233
+ <module name="" />
234
+ <option name="VM_PARAMETERS" value="" />
235
+ <option name="PROGRAM_PARAMETERS" value="" />
236
+ <option name="WORKING_DIRECTORY" value="$PROJECT_DIR$" />
237
+ <option name="ALTERNATIVE_JRE_PATH_ENABLED" value="false" />
238
+ <option name="ENABLE_SWING_INSPECTOR" value="false" />
239
+ <option name="ENV_VARIABLES" />
240
+ <option name="PASS_PARENT_ENVS" value="true" />
241
+ <method v="2">
242
+ <option name="Make" enabled="true" />
243
+ </method>
244
+ </configuration>
245
+ </component>
246
+ `,
247
+ };
248
+ }
249
+ case 'rust': {
250
+ return {
251
+ filename: 'rust_run.xml',
252
+ content: `<component name="ProjectRunConfigurationManager">
253
+ <configuration default="false" name="Rust: cargo run" type="CargoCommandRunConfiguration" factoryName="Cargo Command">
254
+ <option name="command" value="run" />
255
+ <option name="workingDirectory" value="$PROJECT_DIR$" />
256
+ <envs />
257
+ <method v="2" />
258
+ </configuration>
259
+ </component>
260
+ `,
261
+ };
262
+ }
263
+ default:
264
+ return null;
265
+ }
266
+ }
267
+ /**
268
+ * Generate JetBrains IDE artifacts (.idea/.gitignore and run configurations)
269
+ * into the project root directory (parent of outputPath).
270
+ *
271
+ * Returns a list of project-root-relative paths that were written so the
272
+ * caller can register them and report what was generated.
273
+ */
274
+ function generateJetBrainsArtifacts(projectRoot, languageOverlays) {
275
+ const ideaDir = path.join(projectRoot, '.idea');
276
+ const runConfigsDir = path.join(ideaDir, 'runConfigurations');
277
+ const written = [];
278
+ // Ensure directories exist
279
+ if (!fs.existsSync(ideaDir)) {
280
+ fs.mkdirSync(ideaDir, { recursive: true });
281
+ }
282
+ if (!fs.existsSync(runConfigsDir)) {
283
+ fs.mkdirSync(runConfigsDir, { recursive: true });
284
+ }
285
+ // Write .idea/.gitignore (only if not already present)
286
+ const gitignorePath = path.join(ideaDir, '.gitignore');
287
+ if (!fs.existsSync(gitignorePath)) {
288
+ fs.writeFileSync(gitignorePath, generateIdeaGitignore());
289
+ written.push('.idea/.gitignore');
290
+ }
291
+ // Write run configurations for each recognised language overlay
292
+ const generated = [];
293
+ const skipped = [];
294
+ for (const lang of languageOverlays) {
295
+ const runConfig = generateRunConfiguration(lang);
296
+ if (!runConfig)
297
+ continue;
298
+ const xmlPath = path.join(runConfigsDir, runConfig.filename);
299
+ if (!fs.existsSync(xmlPath)) {
300
+ fs.writeFileSync(xmlPath, runConfig.content);
301
+ written.push(`.idea/runConfigurations/${runConfig.filename}`);
302
+ generated.push(lang);
303
+ }
304
+ else {
305
+ skipped.push(runConfig.filename);
306
+ }
307
+ }
308
+ if (generated.length > 0) {
309
+ console.log(chalk.dim(` šŸ’” Generated JetBrains run configuration(s) for: ${generated.join(', ')}`));
310
+ }
311
+ if (skipped.length > 0) {
312
+ console.log(chalk.dim(` ā­ļø Skipped existing JetBrains run configuration(s): ${skipped.join(', ')}`));
313
+ }
314
+ if (languageOverlays.length === 0) {
315
+ console.log(chalk.dim(` ā„¹ļø No language overlays selected — no JetBrains run configurations generated`));
316
+ }
317
+ return written;
318
+ }
319
+ // ─── End JetBrains support ────────────────────────────────────────────────
29
320
  /**
30
321
  * Merge packages from apt-get-packages feature
31
322
  */
@@ -249,7 +540,7 @@ function prepareOverlaysForGeneration(answers, overlaysDir) {
249
540
  /**
250
541
  * Generate superposition.json manifest
251
542
  */
252
- function generateManifest(outputPath, answers, overlays, autoResolved, containerName) {
543
+ function generateManifest(outputPath, answers, overlays, autoResolved, containerName, effectiveTarget) {
253
544
  const toolVersion = getToolVersion();
254
545
  const manifest = {
255
546
  manifestVersion: CURRENT_MANIFEST_VERSION,
@@ -265,7 +556,14 @@ function generateManifest(outputPath, answers, overlays, autoResolved, container
265
556
  preset: answers.preset,
266
557
  presetChoices: answers.presetChoices,
267
558
  containerName,
559
+ target: effectiveTarget ?? answers.target ?? 'local',
268
560
  };
561
+ if (answers.minimal) {
562
+ manifest.minimal = true;
563
+ }
564
+ if (answers.editor && answers.editor !== 'vscode') {
565
+ manifest.editor = answers.editor;
566
+ }
269
567
  if (autoResolved.added.length > 0) {
270
568
  manifest.autoResolved = autoResolved;
271
569
  }
@@ -541,6 +839,164 @@ function copyOverlayFiles(outputPath, overlayName, registry, overlaysDir) {
541
839
  console.log(chalk.dim(` šŸ“‹ Copied ${copiedFiles} file(s) from ${chalk.cyan(overlayName)}`));
542
840
  }
543
841
  }
842
+ const PROJECT_ENV_REFERENCE_PATTERN = /\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-[^}]*)?\}/g;
843
+ function parseSimpleEnvFile(content) {
844
+ const env = {};
845
+ for (const line of content.split('\n')) {
846
+ const trimmed = line.trim();
847
+ if (!trimmed || trimmed.startsWith('#')) {
848
+ continue;
849
+ }
850
+ const match = trimmed.match(/^([^=]+)=(.*)$/);
851
+ if (!match) {
852
+ continue;
853
+ }
854
+ env[match[1].trim()] = match[2].trim();
855
+ }
856
+ return env;
857
+ }
858
+ function loadEnvFileIfExists(filePath) {
859
+ if (!fs.existsSync(filePath)) {
860
+ return {};
861
+ }
862
+ return parseSimpleEnvFile(fs.readFileSync(filePath, 'utf8'));
863
+ }
864
+ function resolveProjectEnvTarget(entry, stack) {
865
+ const target = entry.target ?? 'auto';
866
+ if (target === 'remoteEnv') {
867
+ return 'remoteEnv';
868
+ }
869
+ if (target === 'composeEnv') {
870
+ if (stack !== 'compose') {
871
+ throw new Error('Project env target "composeEnv" requires stack: compose because no docker-compose.yml is generated for plain stacks');
872
+ }
873
+ return 'composeEnv';
874
+ }
875
+ return stack === 'compose' ? 'composeEnv' : 'remoteEnv';
876
+ }
877
+ function resolveRootEnvReferences(value, rootEnv) {
878
+ return value.replace(PROJECT_ENV_REFERENCE_PATTERN, (match, name) => {
879
+ if (rootEnv[name] !== undefined) {
880
+ return rootEnv[name];
881
+ }
882
+ const defaultMatch = match.match(/^\$\{[A-Za-z_][A-Za-z0-9_]*:-([^}]*)\}$/);
883
+ return defaultMatch ? defaultMatch[1] : match;
884
+ });
885
+ }
886
+ function hasUnresolvedProjectEnvReference(value) {
887
+ PROJECT_ENV_REFERENCE_PATTERN.lastIndex = 0;
888
+ const result = PROJECT_ENV_REFERENCE_PATTERN.test(value);
889
+ PROJECT_ENV_REFERENCE_PATTERN.lastIndex = 0;
890
+ return result;
891
+ }
892
+ function buildResolvedProjectRemoteEnvEntries(projectEnv, stack, rootEnv) {
893
+ const entries = {};
894
+ for (const [key, entry] of Object.entries(projectEnv ?? {})) {
895
+ if (resolveProjectEnvTarget(entry, stack) !== 'remoteEnv') {
896
+ continue;
897
+ }
898
+ entries[key] = resolveRootEnvReferences(entry.value, rootEnv ?? {});
899
+ }
900
+ return entries;
901
+ }
902
+ function buildComposeProjectEnvInterpolationEntries(projectEnv, stack) {
903
+ const entries = {};
904
+ for (const [key, entry] of Object.entries(projectEnv ?? {})) {
905
+ if (resolveProjectEnvTarget(entry, stack) !== 'composeEnv') {
906
+ continue;
907
+ }
908
+ entries[key] = `\${${key}}`;
909
+ }
910
+ return entries;
911
+ }
912
+ function buildComposeProjectRemoteEnvRefs(projectEnv, stack) {
913
+ const entries = {};
914
+ for (const [key, entry] of Object.entries(projectEnv ?? {})) {
915
+ if (resolveProjectEnvTarget(entry, stack) !== 'composeEnv') {
916
+ continue;
917
+ }
918
+ entries[key] = `\${containerEnv:${key}}`;
919
+ }
920
+ return entries;
921
+ }
922
+ function materializeComposeProjectEnvValues(projectEnv, stack, rootEnv) {
923
+ const entries = {};
924
+ for (const [key, entry] of Object.entries(projectEnv ?? {})) {
925
+ if (resolveProjectEnvTarget(entry, stack) !== 'composeEnv') {
926
+ continue;
927
+ }
928
+ const resolvedValue = resolveRootEnvReferences(entry.value, rootEnv);
929
+ // Leave unresolved variables to shell/docker-compose fallback instead of
930
+ // persisting placeholder syntax into .devcontainer/.env.
931
+ if (hasUnresolvedProjectEnvReference(resolvedValue)) {
932
+ continue;
933
+ }
934
+ entries[key] = resolvedValue;
935
+ }
936
+ return entries;
937
+ }
938
+ function applyProjectEnvToDevcontainer(config, projectEnv, stack, rootEnv) {
939
+ const remoteEnv = {
940
+ ...buildResolvedProjectRemoteEnvEntries(projectEnv, stack, rootEnv),
941
+ ...buildComposeProjectRemoteEnvRefs(projectEnv, stack),
942
+ };
943
+ if (Object.keys(remoteEnv).length === 0) {
944
+ return config;
945
+ }
946
+ console.log(chalk.dim(` 🌱 Applying project env to remoteEnv`));
947
+ return deepMerge(config, { remoteEnv });
948
+ }
949
+ function mergeComposeEnvFile(outputPath, entries) {
950
+ if (Object.keys(entries).length === 0) {
951
+ return false;
952
+ }
953
+ const envPath = path.join(outputPath, '.env');
954
+ const originalContent = fs.existsSync(envPath) ? fs.readFileSync(envPath, 'utf8') : '';
955
+ const lines = originalContent === '' ? [] : originalContent.replace(/\n$/, '').split('\n');
956
+ const indexByKey = new Map();
957
+ lines.forEach((line, index) => {
958
+ const match = line.match(/^([^#=\s][^=]*)=(.*)$/);
959
+ if (match) {
960
+ indexByKey.set(match[1].trim(), index);
961
+ }
962
+ });
963
+ let changed = false;
964
+ let insertedSpacer = false;
965
+ for (const [key, value] of Object.entries(entries)) {
966
+ const rendered = `${key}=${value}`;
967
+ const existingIndex = indexByKey.get(key);
968
+ if (existingIndex !== undefined) {
969
+ if (lines[existingIndex] !== rendered) {
970
+ lines[existingIndex] = rendered;
971
+ changed = true;
972
+ }
973
+ continue;
974
+ }
975
+ if (lines.length > 0 && !insertedSpacer && lines[lines.length - 1] !== '') {
976
+ lines.push('');
977
+ insertedSpacer = true;
978
+ }
979
+ lines.push(rendered);
980
+ indexByKey.set(key, lines.length - 1);
981
+ changed = true;
982
+ }
983
+ if (!changed && originalContent !== '') {
984
+ return false;
985
+ }
986
+ fs.writeFileSync(envPath, `${lines.join('\n')}\n`);
987
+ return true;
988
+ }
989
+ function materializeComposeProjectEnvFile(outputPath, projectEnv, stack, rootEnv) {
990
+ if (stack !== 'compose') {
991
+ return false;
992
+ }
993
+ const materializedEntries = materializeComposeProjectEnvValues(projectEnv, stack, rootEnv);
994
+ if (!mergeComposeEnvFile(outputPath, materializedEntries)) {
995
+ return false;
996
+ }
997
+ console.log(chalk.dim(` šŸ” Materialized ${Object.keys(materializedEntries).length} project env value(s) into .devcontainer/.env for docker-compose`));
998
+ return true;
999
+ }
544
1000
  /**
545
1001
  * Merge .env.example files from all selected overlays
546
1002
  */
@@ -813,15 +1269,48 @@ function resolveDockerComposePortConflicts(services) {
813
1269
  /**
814
1270
  * Merge docker-compose.yml files from base and overlays into a single file
815
1271
  */
816
- function mergeDockerComposeFiles(outputPath, baseStack, overlays, overlaysDir, portOffset, customImage) {
1272
+ function mergeDockerComposeFiles(outputPath, baseStack, overlays, overlaysDir, portOffset, customImage, projectEnv) {
817
1273
  const composeFiles = [];
818
1274
  // Add base docker-compose if exists
819
1275
  const baseComposePath = path.join(TEMPLATES_DIR, baseStack, '.devcontainer', 'docker-compose.yml');
820
1276
  if (fs.existsSync(baseComposePath)) {
821
1277
  composeFiles.push(baseComposePath);
822
1278
  }
823
- // Add overlay docker-compose files
1279
+ // Add overlay docker-compose files, interleaving any compose_imports before each overlay's own file
824
1280
  for (const overlay of overlays) {
1281
+ // First load any compose_imports for this overlay (shared fragments applied before own file)
1282
+ const manifestPath = path.join(overlaysDir, overlay, 'overlay.yml');
1283
+ if (fs.existsSync(manifestPath)) {
1284
+ try {
1285
+ const manifestContent = fs.readFileSync(manifestPath, 'utf8');
1286
+ const manifest = yaml.load(manifestContent);
1287
+ if (manifest.compose_imports && Array.isArray(manifest.compose_imports)) {
1288
+ for (const importPath of manifest.compose_imports) {
1289
+ const traversalError = validateImportPath(importPath, overlaysDir);
1290
+ if (traversalError) {
1291
+ throw new Error(`compose_import path traversal rejected in overlay '${overlay}': ${traversalError}`);
1292
+ }
1293
+ const fullImportPath = path.join(overlaysDir, importPath);
1294
+ if (!fs.existsSync(fullImportPath)) {
1295
+ throw new Error(`compose_import not found: '${importPath}' (referenced by overlay: ${overlay})`);
1296
+ }
1297
+ const ext = path.extname(importPath).toLowerCase();
1298
+ if (ext !== '.yml' && ext !== '.yaml') {
1299
+ throw new Error(`compose_import must be a .yml or .yaml file: '${importPath}' (overlay: ${overlay})`);
1300
+ }
1301
+ console.log(chalk.dim(` šŸ“Ž Applying shared compose fragment: ${importPath}`));
1302
+ composeFiles.push(fullImportPath);
1303
+ }
1304
+ }
1305
+ }
1306
+ catch (error) {
1307
+ if (error instanceof Error) {
1308
+ throw error;
1309
+ }
1310
+ // Non-Error throwables are unexpected; wrap and re-throw so compose_imports failures always fail fast
1311
+ throw new Error(`Unexpected error loading compose_imports for overlay '${overlay}': ${String(error)}`);
1312
+ }
1313
+ }
825
1314
  const overlayComposePath = path.join(overlaysDir, overlay, 'docker-compose.yml');
826
1315
  if (fs.existsSync(overlayComposePath)) {
827
1316
  composeFiles.push(overlayComposePath);
@@ -859,6 +1348,11 @@ function mergeDockerComposeFiles(outputPath, baseStack, overlays, overlaysDir, p
859
1348
  }
860
1349
  // Ensure devcontainer service has an image
861
1350
  if (merged.services.devcontainer) {
1351
+ const composeEnv = buildComposeProjectEnvInterpolationEntries(projectEnv, baseStack);
1352
+ if (Object.keys(composeEnv).length > 0) {
1353
+ merged.services.devcontainer.environment = deepMerge(merged.services.devcontainer.environment ?? {}, composeEnv);
1354
+ console.log(chalk.dim(` 🌱 Applying project env to docker-compose devcontainer service`));
1355
+ }
862
1356
  if (customImage) {
863
1357
  // Apply custom base image if specified
864
1358
  merged.services.devcontainer.image = customImage;
@@ -1228,17 +1722,60 @@ export async function composeDevContainer(answers, overlaysDir, options = {}) {
1228
1722
  }
1229
1723
  }
1230
1724
  const overlays = orderedOverlays;
1231
- // 5. Create output directory and file registry for cleanup
1725
+ // 5b. Resolve overlay parameters ({{cs.KEY}} substitution)
1726
+ // Collect parameter declarations from all selected overlays
1727
+ const declaredParams = collectOverlayParameters(overlays, allOverlayDefs);
1728
+ const { values: resolvedParams, missingRequired, unknownSupplied, } = resolveParameters(declaredParams, answers.overlayParameters ?? {});
1729
+ if (missingRequired.length > 0) {
1730
+ throw new Error(`Missing required overlay parameters: ${missingRequired.join(', ')}. ` +
1731
+ `Provide values in superposition.yml under the parameters: section, ` +
1732
+ `or via --param KEY=VALUE on the command line.`);
1733
+ }
1734
+ if (unknownSupplied.length > 0) {
1735
+ console.warn(chalk.yellow(` āš ļø Unknown overlay parameters (not declared by any selected overlay): ${unknownSupplied.join(', ')}`));
1736
+ }
1737
+ const hasResolvedParams = Object.keys(resolvedParams).length > 0;
1738
+ // Log resolved parameter values (sensitive values are redacted)
1739
+ if (hasResolvedParams) {
1740
+ const displayValues = redactSensitiveValues(resolvedParams, declaredParams);
1741
+ console.log(chalk.dim(` āš™ļø Overlay parameters:`));
1742
+ for (const [k, v] of Object.entries(displayValues)) {
1743
+ console.log(chalk.dim(` ${k}=${v}`));
1744
+ }
1745
+ }
1232
1746
  const outputPath = path.resolve(answers.outputPath);
1747
+ const projectRoot = path.dirname(outputPath);
1233
1748
  const fileRegistry = new FileRegistry();
1749
+ const rootEnv = loadEnvFileIfExists(path.join(projectRoot, '.env'));
1234
1750
  if (!fs.existsSync(outputPath)) {
1235
1751
  fs.mkdirSync(outputPath, { recursive: true });
1236
1752
  }
1753
+ // 5a. Remove stale project-root artifacts from a previous target run
1754
+ const manifestPath_existing = path.join(outputPath, 'superposition.json');
1755
+ let manifestTarget;
1756
+ if (fs.existsSync(manifestPath_existing)) {
1757
+ try {
1758
+ const existingManifest = JSON.parse(fs.readFileSync(manifestPath_existing, 'utf-8'));
1759
+ manifestTarget = existingManifest.target;
1760
+ }
1761
+ catch {
1762
+ // If manifest is unreadable, skip stale cleanup gracefully
1763
+ }
1764
+ }
1765
+ // When answers.target is undefined (e.g. regen without --target), fall back to the
1766
+ // target recorded in the existing manifest so the correct artifacts are reproduced.
1767
+ const activeTarget = answers.target ?? manifestTarget ?? 'local';
1768
+ const previousTarget = manifestTarget ?? 'local';
1769
+ if (previousTarget !== activeTarget) {
1770
+ removeStaleTargetArtifacts(previousTarget, activeTarget, projectRoot);
1771
+ console.log(chalk.dim(` 🧹 Removed stale target artifacts for previous target '${previousTarget}'`));
1772
+ }
1237
1773
  // 6. Apply overlays
1238
1774
  for (const overlay of overlays) {
1239
1775
  console.log(chalk.dim(` šŸ”§ Applying overlay: ${chalk.cyan(overlay)}`));
1240
1776
  config = applyOverlay(config, overlay, actualOverlaysDir);
1241
1777
  }
1778
+ config = applyProjectEnvToDevcontainer(config, answers.projectEnv, answers.stack, rootEnv);
1242
1779
  // 7. Copy template files (docker-compose, scripts, etc.)
1243
1780
  const entries = fs.readdirSync(templatePath);
1244
1781
  for (const entry of entries) {
@@ -1278,11 +1815,22 @@ export async function composeDevContainer(answers, overlaysDir, options = {}) {
1278
1815
  let composePortRemappings = [];
1279
1816
  if (answers.stack === 'compose') {
1280
1817
  const customImage = config._customImage;
1281
- composePortRemappings = mergeDockerComposeFiles(outputPath, answers.stack, overlays, actualOverlaysDir, answers.portOffset, customImage);
1818
+ composePortRemappings = mergeDockerComposeFiles(outputPath, answers.stack, overlays, actualOverlaysDir, answers.portOffset, customImage, answers.projectEnv);
1282
1819
  // Update devcontainer.json to reference the combined file
1283
1820
  if (config.dockerComposeFile) {
1284
1821
  config.dockerComposeFile = 'docker-compose.yml';
1285
1822
  }
1823
+ // Apply parameter substitution to the merged docker-compose.yml
1824
+ if (hasResolvedParams) {
1825
+ const composePath = path.join(outputPath, 'docker-compose.yml');
1826
+ if (fs.existsSync(composePath)) {
1827
+ const original = fs.readFileSync(composePath, 'utf8');
1828
+ const substituted = substituteParameters(original, resolvedParams);
1829
+ if (substituted !== original) {
1830
+ fs.writeFileSync(composePath, substituted);
1831
+ }
1832
+ }
1833
+ }
1286
1834
  }
1287
1835
  // Apply port offset to devcontainer.json if specified
1288
1836
  if (answers.portOffset) {
@@ -1311,24 +1859,62 @@ export async function composeDevContainer(answers, overlaysDir, options = {}) {
1311
1859
  if (answers.editor === 'none' || answers.editor === 'jetbrains') {
1312
1860
  // Remove VS Code customizations
1313
1861
  if (config.customizations?.vscode) {
1314
- if (answers.editor === 'none') {
1315
- delete config.customizations.vscode;
1316
- console.log(chalk.dim(` šŸŽØ Editor profile 'none': Removed VS Code customizations`));
1317
- }
1318
- else if (answers.editor === 'jetbrains') {
1319
- // For JetBrains, remove VS Code customizations (future: could add JetBrains-specific settings)
1320
- delete config.customizations.vscode;
1321
- console.log(chalk.dim(` šŸŽØ Editor profile 'jetbrains': Removed VS Code customizations`));
1322
- }
1862
+ delete config.customizations.vscode;
1863
+ const profileLabel = answers.editor === 'none' ? 'none' : 'jetbrains';
1864
+ console.log(chalk.dim(` šŸŽØ Editor profile '${profileLabel}': Removed VS Code customizations`));
1323
1865
  // Clean up empty customizations object
1324
1866
  if (config.customizations && Object.keys(config.customizations).length === 0) {
1325
1867
  delete config.customizations;
1326
1868
  }
1327
1869
  }
1328
1870
  }
1871
+ // Add JetBrains-specific devcontainer.json customizations and generate .idea/ artifacts
1872
+ if (answers.editor === 'jetbrains') {
1873
+ const selectedLanguages = answers.language ?? [];
1874
+ const languageOverlays = selectedLanguages.filter((lang) => JETBRAINS_SUPPORTED_LANGUAGES.has(lang));
1875
+ if (languageOverlays.length === 0 && selectedLanguages.length > 0) {
1876
+ const selectedLabel = selectedLanguages.join(', ');
1877
+ console.log(chalk.yellow(` āš ļø No supported JetBrains language overlays selected (selected: ${selectedLabel})`));
1878
+ }
1879
+ const backend = getJetBrainsBackend(languageOverlays);
1880
+ // Add customizations.jetbrains block to devcontainer.json
1881
+ if (!config.customizations) {
1882
+ config.customizations = {};
1883
+ }
1884
+ config.customizations.jetbrains = { backend };
1885
+ console.log(chalk.dim(` 🧠 Editor profile 'jetbrains': Set backend to '${backend}'`));
1886
+ // Generate .idea/ artifacts in the project root
1887
+ console.log(chalk.cyan('\nšŸ’” Generating JetBrains project artifacts...'));
1888
+ const jetbrainsFiles = generateJetBrainsArtifacts(projectRoot, languageOverlays);
1889
+ for (const relPath of jetbrainsFiles) {
1890
+ console.log(chalk.dim(` šŸ“„ Created ${relPath} at project root`));
1891
+ }
1892
+ }
1893
+ // 11b. Apply target-specific devcontainer.json patch
1894
+ const targetRule = getTargetRule(activeTarget);
1895
+ const overlayMetadataMapForTarget = new Map(allOverlayDefs.map((o) => [o.id, o]));
1896
+ const targetCtx = {
1897
+ overlays,
1898
+ overlayMetadata: overlayMetadataMapForTarget,
1899
+ portOffset: answers.portOffset ?? 0,
1900
+ stack: answers.stack,
1901
+ outputPath,
1902
+ projectRoot,
1903
+ };
1904
+ const targetPatch = targetRule.devcontainerPatch(targetCtx);
1905
+ if (Object.keys(targetPatch).length > 0) {
1906
+ config = deepMerge(config, targetPatch);
1907
+ console.log(chalk.dim(` šŸŽÆ Applied ${activeTarget} target patch to devcontainer.json`));
1908
+ }
1329
1909
  // 12. Write merged devcontainer.json
1910
+ // Apply parameter substitution to the config object (before JSON.stringify) so that
1911
+ // any JSON-special characters in parameter values are properly escaped by JSON.stringify.
1330
1912
  const configPath = path.join(outputPath, 'devcontainer.json');
1331
- fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
1913
+ const finalConfig = hasResolvedParams
1914
+ ? substituteParametersInObject(config, resolvedParams)
1915
+ : config;
1916
+ const devcontainerContent = JSON.stringify(finalConfig, null, 2) + '\n';
1917
+ fs.writeFileSync(configPath, devcontainerContent);
1332
1918
  fileRegistry.addFile('devcontainer.json');
1333
1919
  console.log(chalk.dim(` šŸ“ Wrote devcontainer.json`));
1334
1920
  // Apply custom docker-compose patch (after writing base docker-compose.yml)
@@ -1336,13 +1922,39 @@ export async function composeDevContainer(answers, overlaysDir, options = {}) {
1336
1922
  applyCustomDockerComposePatch(outputPath, customPatches);
1337
1923
  }
1338
1924
  // 13. Generate superposition.json manifest
1339
- generateManifest(outputPath, answers, overlays, autoResolved, answers.containerName || config.name);
1925
+ generateManifest(outputPath, answers, overlays, autoResolved, answers.containerName || config.name, activeTarget);
1340
1926
  fileRegistry.addFile('superposition.json');
1341
1927
  // 14. Merge .env.example files from overlays and apply glue config environment variables
1342
1928
  const envCreated = mergeEnvExamples(outputPath, overlays, actualOverlaysDir, answers.portOffset, answers.presetGlueConfig, answers.preset);
1343
1929
  if (envCreated) {
1344
1930
  fileRegistry.addFile('.env.example');
1345
1931
  }
1932
+ // Apply parameter substitution to .env.example
1933
+ // This must happen after mergeEnvExamples but before any consumer of .env reads it,
1934
+ // because mergeEnvExamples may have written {{cs.*}} tokens into .env.example.
1935
+ // We also regenerate .env (the port-offset copy) from the substituted content so that
1936
+ // applyPortOffsetToEnv can correctly match numeric port values that were previously
1937
+ // hidden behind {{cs.POSTGRES_PORT}} tokens.
1938
+ if (hasResolvedParams) {
1939
+ const envExamplePath = path.join(outputPath, '.env.example');
1940
+ if (fs.existsSync(envExamplePath)) {
1941
+ const original = fs.readFileSync(envExamplePath, 'utf8');
1942
+ const substituted = substituteParameters(original, resolvedParams);
1943
+ if (substituted !== original) {
1944
+ fs.writeFileSync(envExamplePath, substituted);
1945
+ // Regenerate .env from the substituted content when a port offset is active.
1946
+ // mergeEnvExamples already wrote .env from the pre-substitution content, so
1947
+ // the port offset was applied to unresolved tokens (e.g. {{cs.POSTGRES_PORT}})
1948
+ // that had no numeric value to match — we must regenerate .env now that the
1949
+ // tokens have been replaced with real numeric port values.
1950
+ if (answers.portOffset) {
1951
+ const envPath = path.join(outputPath, '.env');
1952
+ const offsetContent = applyPortOffsetToEnv(substituted, answers.portOffset);
1953
+ fs.writeFileSync(envPath, offsetContent);
1954
+ }
1955
+ }
1956
+ }
1957
+ }
1346
1958
  // Apply custom environment variables (after .env.example is created)
1347
1959
  if (customPatches) {
1348
1960
  const customEnvCreated = applyCustomEnvironment(outputPath, customPatches);
@@ -1351,6 +1963,7 @@ export async function composeDevContainer(answers, overlaysDir, options = {}) {
1351
1963
  fileRegistry.addFile('.env.example');
1352
1964
  }
1353
1965
  }
1966
+ materializeComposeProjectEnvFile(outputPath, answers.projectEnv, answers.stack, rootEnv);
1354
1967
  // 14b. Merge .gitignore files from overlays into project root .gitignore
1355
1968
  // Note: .gitignore lives at the project root (parent of outputPath), not inside outputPath,
1356
1969
  // so it is intentionally NOT added to fileRegistry (cleanupStaleFiles must not touch it).
@@ -1429,12 +2042,63 @@ export async function composeDevContainer(answers, overlaysDir, options = {}) {
1429
2042
  const envLocalContent = generateEnvLocalExample(selectedOverlayMetadata, actualOverlaysDir, portOffset);
1430
2043
  if (envLocalContent) {
1431
2044
  const envLocalPath = path.join(outputPath, 'env.local.example');
1432
- fs.writeFileSync(envLocalPath, envLocalContent);
2045
+ const finalEnvLocalContent = hasResolvedParams
2046
+ ? substituteParameters(envLocalContent, resolvedParams)
2047
+ : envLocalContent;
2048
+ fs.writeFileSync(envLocalPath, finalEnvLocalContent);
1433
2049
  fileRegistry.addFile('env.local.example');
1434
2050
  console.log(chalk.dim(` šŸ“„ Created env.local.example with optional overrides`));
1435
2051
  }
2052
+ // 17d. Generate target-specific workspace artifacts and guidance
2053
+ if (activeTarget !== 'local') {
2054
+ console.log(chalk.cyan(`\nšŸŽÆ Generating ${activeTarget} target artifacts...`));
2055
+ const targetFiles = targetRule.generateFiles(targetCtx);
2056
+ for (const [key, content] of targetFiles) {
2057
+ const absPath = resolveTargetFilePath(key, outputPath, projectRoot);
2058
+ fs.writeFileSync(absPath, content);
2059
+ if (key.startsWith('../')) {
2060
+ // Project-root file: log but do NOT add to fileRegistry
2061
+ // (fileRegistry only tracks outputPath-relative files)
2062
+ console.log(chalk.dim(` šŸ“„ Created ${path.basename(absPath)} at project root`));
2063
+ }
2064
+ else {
2065
+ fileRegistry.addFile(key);
2066
+ console.log(chalk.dim(` šŸ“„ Created ${key} in .devcontainer/`));
2067
+ }
2068
+ }
2069
+ }
1436
2070
  // 18. Clean up stale files from previous runs (preserves superposition.json and .env)
1437
2071
  cleanupStaleFiles(outputPath, fileRegistry);
2072
+ // 18b. Validate that no unresolved {{cs.*}} tokens remain in any generated file.
2073
+ // Run unconditionally — an overlay author could accidentally ship {{cs.*}} tokens
2074
+ // in files that don't have a matching parameters declaration, and we must catch those
2075
+ // regardless of whether any parameters were resolved in this run.
2076
+ // Only text-like files (not binaries) are scanned; skip missing files gracefully.
2077
+ const BINARY_EXTENSIONS = new Set(['.png', '.jpg', '.gif', '.ico', '.woff', '.woff2', '.ttf']);
2078
+ {
2079
+ const allGeneratedFiles = Array.from(fileRegistry.getFiles());
2080
+ const unresolvedByFile = {};
2081
+ for (const relFile of allGeneratedFiles) {
2082
+ const ext = path.extname(relFile).toLowerCase();
2083
+ if (BINARY_EXTENSIONS.has(ext))
2084
+ continue;
2085
+ const absPath = path.join(outputPath, relFile);
2086
+ if (!fs.existsSync(absPath))
2087
+ continue;
2088
+ const content = fs.readFileSync(absPath, 'utf8');
2089
+ const unresolved = findUnresolvedTokens(content);
2090
+ if (unresolved.length > 0) {
2091
+ unresolvedByFile[relFile] = [...new Set(unresolved)];
2092
+ }
2093
+ }
2094
+ if (Object.keys(unresolvedByFile).length > 0) {
2095
+ const details = Object.entries(unresolvedByFile)
2096
+ .map(([file, tokens]) => `${file}: ${tokens.join(', ')}`)
2097
+ .join('; ');
2098
+ throw new Error(`Unresolved {{cs.*}} parameter tokens remain in generated files: ${details}. ` +
2099
+ `Declare these parameters in the overlay's overlay.yml and provide values in your project file (superposition.yml).`);
2100
+ }
2101
+ }
1438
2102
  // 19. Generate and return summary
1439
2103
  const files = Array.from(fileRegistry.getFiles());
1440
2104
  const services = overlaysToServices(selectedOverlayMetadata);