container-superposition 0.1.1 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (178) hide show
  1. package/README.md +569 -8
  2. package/dist/scripts/init.js +436 -254
  3. package/dist/scripts/init.js.map +1 -1
  4. package/dist/tool/commands/doctor.d.ts +15 -0
  5. package/dist/tool/commands/doctor.d.ts.map +1 -0
  6. package/dist/tool/commands/doctor.js +862 -0
  7. package/dist/tool/commands/doctor.js.map +1 -0
  8. package/dist/tool/commands/explain.d.ts +13 -0
  9. package/dist/tool/commands/explain.d.ts.map +1 -0
  10. package/dist/tool/commands/explain.js +299 -0
  11. package/dist/tool/commands/explain.js.map +1 -0
  12. package/dist/tool/commands/list.d.ts +16 -0
  13. package/dist/tool/commands/list.d.ts.map +1 -0
  14. package/dist/tool/commands/list.js +121 -0
  15. package/dist/tool/commands/list.js.map +1 -0
  16. package/dist/tool/commands/plan.d.ts +67 -0
  17. package/dist/tool/commands/plan.d.ts.map +1 -0
  18. package/dist/tool/commands/plan.js +851 -0
  19. package/dist/tool/commands/plan.js.map +1 -0
  20. package/dist/tool/questionnaire/composer.d.ts +16 -2
  21. package/dist/tool/questionnaire/composer.d.ts.map +1 -1
  22. package/dist/tool/questionnaire/composer.js +411 -200
  23. package/dist/tool/questionnaire/composer.js.map +1 -1
  24. package/dist/tool/readme/markdown-parser.d.ts.map +1 -1
  25. package/dist/tool/readme/markdown-parser.js.map +1 -1
  26. package/dist/tool/readme/readme-generator.d.ts.map +1 -1
  27. package/dist/tool/readme/readme-generator.js +11 -6
  28. package/dist/tool/readme/readme-generator.js.map +1 -1
  29. package/dist/tool/schema/deployment-targets.d.ts +77 -0
  30. package/dist/tool/schema/deployment-targets.d.ts.map +1 -0
  31. package/dist/tool/schema/deployment-targets.js +91 -0
  32. package/dist/tool/schema/deployment-targets.js.map +1 -0
  33. package/dist/tool/schema/manifest-migrations.d.ts +51 -0
  34. package/dist/tool/schema/manifest-migrations.d.ts.map +1 -0
  35. package/dist/tool/schema/manifest-migrations.js +159 -0
  36. package/dist/tool/schema/manifest-migrations.js.map +1 -0
  37. package/dist/tool/schema/overlay-loader.d.ts +1 -1
  38. package/dist/tool/schema/overlay-loader.d.ts.map +1 -1
  39. package/dist/tool/schema/overlay-loader.js +42 -14
  40. package/dist/tool/schema/overlay-loader.js.map +1 -1
  41. package/dist/tool/schema/types.d.ts +62 -2
  42. package/dist/tool/schema/types.d.ts.map +1 -1
  43. package/dist/tool/utils/gitignore.d.ts +15 -0
  44. package/dist/tool/utils/gitignore.d.ts.map +1 -0
  45. package/dist/tool/utils/gitignore.js +41 -0
  46. package/dist/tool/utils/gitignore.js.map +1 -0
  47. package/dist/tool/utils/merge.d.ts +134 -0
  48. package/dist/tool/utils/merge.d.ts.map +1 -0
  49. package/dist/tool/utils/merge.js +277 -0
  50. package/dist/tool/utils/merge.js.map +1 -0
  51. package/dist/tool/utils/port-utils.d.ts +29 -0
  52. package/dist/tool/utils/port-utils.d.ts.map +1 -0
  53. package/dist/tool/utils/port-utils.js +128 -0
  54. package/dist/tool/utils/port-utils.js.map +1 -0
  55. package/dist/tool/utils/services-export.d.ts +14 -0
  56. package/dist/tool/utils/services-export.d.ts.map +1 -0
  57. package/dist/tool/utils/services-export.js +478 -0
  58. package/dist/tool/utils/services-export.js.map +1 -0
  59. package/dist/tool/utils/summary.d.ts +69 -0
  60. package/dist/tool/utils/summary.d.ts.map +1 -0
  61. package/dist/tool/utils/summary.js +260 -0
  62. package/dist/tool/utils/summary.js.map +1 -0
  63. package/dist/tool/utils/version.d.ts +9 -0
  64. package/dist/tool/utils/version.d.ts.map +1 -0
  65. package/dist/tool/utils/version.js +32 -0
  66. package/dist/tool/utils/version.js.map +1 -0
  67. package/docs/architecture.md +25 -21
  68. package/docs/deployment-targets.md +150 -0
  69. package/docs/discovery-commands.md +442 -0
  70. package/docs/merge-strategy.md +700 -0
  71. package/docs/minimal-and-editor.md +265 -0
  72. package/docs/overlay-imports.md +209 -0
  73. package/docs/overlay-manifest-refactoring.md +2 -2
  74. package/docs/overlay-metadata-archive.md +1 -1
  75. package/docs/overlays.md +139 -28
  76. package/docs/presets-architecture.md +3 -3
  77. package/docs/presets.md +1 -1
  78. package/docs/publishing.md +36 -35
  79. package/docs/team-workflow.md +540 -0
  80. package/overlays/.presets/data-engineering.yml +392 -0
  81. package/overlays/.presets/event-sourced-service.yml +262 -0
  82. package/overlays/.presets/frontend.yml +287 -0
  83. package/overlays/.presets/k8s-operator-dev.yml +462 -0
  84. package/overlays/{presets → .presets}/microservice.yml +32 -6
  85. package/overlays/.presets/web-api.yml +129 -0
  86. package/overlays/.registry/README.md +1 -1
  87. package/overlays/.registry/deployment-targets.yml +54 -0
  88. package/overlays/.shared/README.md +43 -0
  89. package/overlays/.shared/compose/common-healthchecks.yml +38 -0
  90. package/overlays/.shared/otel/instrumentation.env +20 -0
  91. package/overlays/.shared/otel/otel-base-config.yaml +30 -0
  92. package/overlays/.shared/vscode/recommended-extensions.json +14 -0
  93. package/overlays/README.md +1 -1
  94. package/overlays/cloudflared/README.md +190 -0
  95. package/overlays/cloudflared/devcontainer.patch.json +3 -0
  96. package/overlays/cloudflared/overlay.yml +15 -0
  97. package/overlays/cloudflared/setup.sh +49 -0
  98. package/overlays/cloudflared/verify.sh +21 -0
  99. package/overlays/codex/overlay.yml +1 -0
  100. package/overlays/direnv/README.md +6 -4
  101. package/overlays/direnv/setup.sh +0 -12
  102. package/overlays/duckdb/README.md +274 -0
  103. package/overlays/duckdb/devcontainer.patch.json +10 -0
  104. package/overlays/duckdb/overlay.yml +17 -0
  105. package/overlays/duckdb/setup.sh +45 -0
  106. package/overlays/duckdb/verify.sh +32 -0
  107. package/overlays/git-helpers/overlay.yml +1 -0
  108. package/overlays/grafana/README.md +5 -5
  109. package/overlays/grafana/dashboard-provider.yml +1 -1
  110. package/overlays/grafana/docker-compose.yml +2 -2
  111. package/overlays/grafana/overlay.yml +6 -1
  112. package/overlays/grpc-tools/README.md +242 -0
  113. package/overlays/grpc-tools/devcontainer.patch.json +14 -0
  114. package/overlays/grpc-tools/overlay.yml +14 -0
  115. package/overlays/grpc-tools/setup.sh +57 -0
  116. package/overlays/grpc-tools/verify.sh +47 -0
  117. package/overlays/jaeger/overlay.yml +16 -3
  118. package/overlays/jupyter/.env.example +6 -0
  119. package/overlays/jupyter/README.md +210 -0
  120. package/overlays/jupyter/devcontainer.patch.json +14 -0
  121. package/overlays/jupyter/docker-compose.yml +23 -0
  122. package/overlays/jupyter/overlay.yml +18 -0
  123. package/overlays/jupyter/verify.sh +35 -0
  124. package/overlays/keycloak/.env.example +5 -0
  125. package/overlays/keycloak/README.md +238 -0
  126. package/overlays/keycloak/devcontainer.patch.json +17 -0
  127. package/overlays/keycloak/docker-compose.yml +32 -0
  128. package/overlays/keycloak/overlay.yml +23 -0
  129. package/overlays/keycloak/verify.sh +54 -0
  130. package/overlays/kind/README.md +221 -0
  131. package/overlays/kind/devcontainer.patch.json +10 -0
  132. package/overlays/kind/overlay.yml +18 -0
  133. package/overlays/kind/setup.sh +43 -0
  134. package/overlays/kind/verify.sh +40 -0
  135. package/overlays/localstack/.env.example +6 -0
  136. package/overlays/localstack/README.md +188 -0
  137. package/overlays/localstack/devcontainer.patch.json +21 -0
  138. package/overlays/localstack/docker-compose.yml +25 -0
  139. package/overlays/localstack/overlay.yml +18 -0
  140. package/overlays/localstack/verify.sh +47 -0
  141. package/overlays/loki/overlay.yml +6 -1
  142. package/overlays/mailpit/.env.example +4 -0
  143. package/overlays/mailpit/README.md +191 -0
  144. package/overlays/mailpit/devcontainer.patch.json +20 -0
  145. package/overlays/mailpit/docker-compose.yml +17 -0
  146. package/overlays/mailpit/overlay.yml +26 -0
  147. package/overlays/mailpit/verify.sh +52 -0
  148. package/overlays/modern-cli-tools/overlay.yml +1 -0
  149. package/overlays/mongodb/overlay.yml +12 -2
  150. package/overlays/mysql/overlay.yml +12 -2
  151. package/overlays/nats/overlay.yml +12 -2
  152. package/overlays/ngrok/overlay.yml +2 -1
  153. package/overlays/openapi-tools/README.md +243 -0
  154. package/overlays/openapi-tools/devcontainer.patch.json +10 -0
  155. package/overlays/openapi-tools/overlay.yml +16 -0
  156. package/overlays/openapi-tools/setup.sh +45 -0
  157. package/overlays/openapi-tools/verify.sh +51 -0
  158. package/overlays/otel-collector/overlay.yml.example +26 -0
  159. package/overlays/postgres/overlay.yml +6 -1
  160. package/overlays/prometheus/overlay.yml +6 -1
  161. package/overlays/python/README.md +51 -35
  162. package/overlays/python/devcontainer.patch.json +7 -4
  163. package/overlays/python/setup.sh +50 -23
  164. package/overlays/python/verify.sh +29 -1
  165. package/overlays/rabbitmq/overlay.yml +12 -2
  166. package/overlays/redis/overlay.yml +6 -1
  167. package/overlays/tilt/README.md +259 -0
  168. package/overlays/tilt/devcontainer.patch.json +17 -0
  169. package/overlays/tilt/overlay.yml +19 -0
  170. package/overlays/tilt/setup.sh +25 -0
  171. package/overlays/tilt/verify.sh +24 -0
  172. package/package.json +8 -6
  173. package/tool/README.md +12 -16
  174. package/tool/schema/overlay-manifest.schema.json +64 -4
  175. package/tool/schema/superposition-manifest.schema.json +104 -0
  176. package/overlays/presets/web-api.yml +0 -109
  177. /package/overlays/{presets → .presets}/docs-site.yml +0 -0
  178. /package/overlays/{presets → .presets}/fullstack.yml +0 -0
@@ -2,14 +2,24 @@
2
2
  import * as fs from 'fs';
3
3
  import * as path from 'path';
4
4
  import { fileURLToPath } from 'url';
5
+ import { execSync } from 'child_process';
5
6
  import { Command } from 'commander';
6
7
  import chalk from 'chalk';
7
8
  import boxen from 'boxen';
8
9
  import ora from 'ora';
9
10
  import { select, checkbox, input } from '@inquirer/prompts';
10
11
  import yaml from 'js-yaml';
11
- import { composeDevContainer } from '../tool/questionnaire/composer.js';
12
+ import { composeDevContainer, generateManifestOnly } from '../tool/questionnaire/composer.js';
12
13
  import { loadOverlaysConfig } from '../tool/schema/overlay-loader.js';
14
+ import { listCommand } from '../tool/commands/list.js';
15
+ import { explainCommand } from '../tool/commands/explain.js';
16
+ import { planCommand } from '../tool/commands/plan.js';
17
+ import { doctorCommand } from '../tool/commands/doctor.js';
18
+ import { getIncompatibleOverlays, DEPLOYMENT_TARGETS } from '../tool/schema/deployment-targets.js';
19
+ import { migrateManifest, needsMigration, detectManifestVersion, isVersionSupported, CURRENT_MANIFEST_VERSION, SUPPORTED_MANIFEST_VERSIONS, } from '../tool/schema/manifest-migrations.js';
20
+ import { getToolVersion } from '../tool/utils/version.js';
21
+ import { printSummary } from '../tool/utils/summary.js';
22
+ import { appendGitignoreSection } from '../tool/utils/gitignore.js';
13
23
  // Get __dirname equivalent in ESM
14
24
  const __filename = fileURLToPath(import.meta.url);
15
25
  const __dirname = path.dirname(__filename);
@@ -32,8 +42,8 @@ const OVERLAYS_CONFIG_CANDIDATES = [
32
42
  const OVERLAYS_CONFIG_PATH = OVERLAYS_CONFIG_CANDIDATES.find((candidate) => fs.existsSync(candidate)) ??
33
43
  OVERLAYS_CONFIG_CANDIDATES[0];
34
44
  const PRESETS_DIR_CANDIDATES = [
35
- path.join(__dirname, '..', 'overlays', 'presets'),
36
- path.join(__dirname, '..', '..', 'overlays', 'presets'),
45
+ path.join(__dirname, '..', 'overlays', '.presets'),
46
+ path.join(__dirname, '..', '..', 'overlays', '.presets'),
37
47
  ];
38
48
  const PRESETS_DIR = PRESETS_DIR_CANDIDATES.find((candidate) => fs.existsSync(candidate)) ??
39
49
  PRESETS_DIR_CANDIDATES[0];
@@ -58,7 +68,7 @@ function loadPresetDefinition(presetId) {
58
68
  /**
59
69
  * Expand a preset into a list of overlay IDs with user choices resolved
60
70
  */
61
- async function expandPreset(presetId, stack) {
71
+ async function expandPreset(presetId, stack, preProvidedChoices = {}) {
62
72
  const preset = loadPresetDefinition(presetId);
63
73
  if (!preset) {
64
74
  return { overlays: [], choices: {} };
@@ -66,23 +76,72 @@ async function expandPreset(presetId, stack) {
66
76
  console.log(chalk.cyan(`\n📦 Expanding preset: ${preset.name}\n`));
67
77
  const overlays = [...preset.selects.required];
68
78
  const choices = {};
69
- // Handle user choices
79
+ // Handle user choices (single overlay per option)
70
80
  if (preset.selects.userChoice) {
71
81
  for (const [key, choice] of Object.entries(preset.selects.userChoice)) {
72
- const selectedOption = (await select({
73
- message: choice.prompt,
74
- choices: choice.options.map((opt) => ({
75
- name: opt,
76
- value: opt,
77
- })),
78
- default: choice.defaultOption,
79
- }));
80
- overlays.push(selectedOption);
81
- choices[key] = selectedOption;
82
+ const preProvidedValue = preProvidedChoices[key];
83
+ if (preProvidedValue !== undefined) {
84
+ // Validate the pre-provided value
85
+ if (!choice.options.includes(preProvidedValue)) {
86
+ const valid = choice.options.join(', ');
87
+ throw new Error(`Invalid value '${preProvidedValue}' for preset choice '${key}'. Valid options: ${valid}`);
88
+ }
89
+ console.log(chalk.dim(`✓ ${key}: ${preProvidedValue} (from CLI)`));
90
+ overlays.push(preProvidedValue);
91
+ choices[key] = preProvidedValue;
92
+ }
93
+ else {
94
+ const selectedOption = (await select({
95
+ message: choice.prompt,
96
+ choices: choice.options.map((opt) => ({
97
+ name: opt,
98
+ value: opt,
99
+ })),
100
+ default: choice.defaultOption,
101
+ }));
102
+ overlays.push(selectedOption);
103
+ choices[key] = selectedOption;
104
+ }
82
105
  }
83
106
  }
84
- console.log(chalk.dim(`✓ Preset will include: ${overlays.join(', ')}\n`));
85
- return { overlays, choices, glueConfig: preset.glueConfig };
107
+ // Handle parameterized slots (multiple overlays per option)
108
+ if (preset.parameters) {
109
+ for (const [key, param] of Object.entries(preset.parameters)) {
110
+ const preProvidedValue = preProvidedChoices[key];
111
+ let selectedId;
112
+ if (preProvidedValue !== undefined) {
113
+ // Validate the pre-provided value
114
+ const validOption = param.options.find((o) => o.id === preProvidedValue);
115
+ if (!validOption) {
116
+ const valid = param.options.map((o) => o.id).join(', ');
117
+ throw new Error(`Invalid value '${preProvidedValue}' for preset parameter '${key}'. Valid options: ${valid}`);
118
+ }
119
+ console.log(chalk.dim(`✓ ${key}: ${preProvidedValue} (from CLI)`));
120
+ selectedId = preProvidedValue;
121
+ }
122
+ else {
123
+ const description = param.description || `Select ${key}`;
124
+ selectedId = (await select({
125
+ message: description,
126
+ choices: param.options.map((opt) => ({
127
+ name: opt.description ? `${opt.id} - ${opt.description}` : opt.id,
128
+ value: opt.id,
129
+ })),
130
+ default: param.default,
131
+ }));
132
+ }
133
+ // Add overlays for the selected option
134
+ const selectedOption = param.options.find((o) => o.id === selectedId);
135
+ if (selectedOption) {
136
+ overlays.push(...selectedOption.overlays);
137
+ }
138
+ choices[key] = selectedId;
139
+ }
140
+ }
141
+ // Deduplicate overlays
142
+ const uniqueOverlays = [...new Set(overlays)];
143
+ console.log(chalk.dim(`✓ Preset will include: ${uniqueOverlays.join(', ')}\n`));
144
+ return { overlays: uniqueOverlays, choices, glueConfig: preset.glueConfig };
86
145
  }
87
146
  /**
88
147
  * Search for manifest file in multiple locations
@@ -111,10 +170,29 @@ function findManifestFile(manifestPath) {
111
170
  function loadManifest(manifestPath) {
112
171
  try {
113
172
  const content = fs.readFileSync(manifestPath, 'utf-8');
114
- const manifest = JSON.parse(content);
173
+ const rawManifest = JSON.parse(content);
174
+ // Detect manifest version
175
+ const detectedVersion = detectManifestVersion(rawManifest);
176
+ // Check if version is supported
177
+ if (!isVersionSupported(detectedVersion)) {
178
+ console.error(chalk.red(`✗ Manifest version ${detectedVersion} is not supported.\n` +
179
+ ` This tool supports versions: ${SUPPORTED_MANIFEST_VERSIONS.join(', ')}\n` +
180
+ ` Please upgrade your tool or regenerate the manifest.`));
181
+ return null;
182
+ }
183
+ // Migrate if needed
184
+ let manifest;
185
+ if (needsMigration(rawManifest)) {
186
+ const oldVersion = rawManifest.manifestVersion || rawManifest.version || 'legacy';
187
+ console.log(chalk.cyan(`ℹ️ Migrating manifest from version ${oldVersion} to ${CURRENT_MANIFEST_VERSION}...`));
188
+ manifest = migrateManifest(rawManifest);
189
+ }
190
+ else {
191
+ manifest = rawManifest;
192
+ }
115
193
  // Basic validation
116
- if (!manifest.version || !manifest.baseTemplate) {
117
- console.error(chalk.red('✗ Invalid manifest format: missing required fields (version, baseTemplate)'));
194
+ if (!manifest.baseTemplate) {
195
+ console.error(chalk.red('✗ Invalid manifest format: missing required field "baseTemplate"'));
118
196
  return null;
119
197
  }
120
198
  if (!Array.isArray(manifest.overlays)) {
@@ -125,10 +203,6 @@ function loadManifest(manifestPath) {
125
203
  console.error(chalk.red('✗ Invalid manifest format: all "overlays" entries must be strings'));
126
204
  return null;
127
205
  }
128
- // Version check (warn if different, but continue)
129
- if (manifest.version !== '0.1.0') {
130
- console.warn(chalk.yellow(`⚠️ Manifest version ${manifest.version} may not be fully compatible with this tool`));
131
- }
132
206
  return manifest;
133
207
  }
134
208
  catch (error) {
@@ -136,6 +210,32 @@ function loadManifest(manifestPath) {
136
210
  return null;
137
211
  }
138
212
  }
213
+ /**
214
+ * Detect whether a directory (or any of its parents) is inside a git repository.
215
+ * First tries `git rev-parse --git-dir`; if git is unavailable, falls back to
216
+ * walking up the directory tree looking for a `.git` entry.
217
+ */
218
+ function isInsideGitRepo(dirPath) {
219
+ try {
220
+ execSync('git rev-parse --git-dir', { cwd: dirPath, stdio: 'ignore' });
221
+ return true;
222
+ }
223
+ catch {
224
+ // git command failed (not a repo) or git is not installed — walk up looking for .git
225
+ let current = path.resolve(dirPath);
226
+ while (true) {
227
+ if (fs.existsSync(path.join(current, '.git'))) {
228
+ return true;
229
+ }
230
+ const parent = path.dirname(current);
231
+ if (parent === current) {
232
+ break; // reached filesystem root
233
+ }
234
+ current = parent;
235
+ }
236
+ return false;
237
+ }
238
+ }
139
239
  /**
140
240
  * Create timestamped backup of existing devcontainer and manifest
141
241
  */
@@ -219,31 +319,16 @@ async function copyDirectory(src, dest) {
219
319
  /**
220
320
  * Ensure backup patterns are in .gitignore
221
321
  */
222
- async function ensureBackupPatternsInGitignore(outputPath) {
223
- // Write to the parent directory's .gitignore (project root), not inside outputPath
224
- const resolvedOutputPath = path.resolve(outputPath);
225
- const projectRoot = path.dirname(resolvedOutputPath);
322
+ function ensureBackupPatternsInGitignore(outputPath) {
323
+ const projectRoot = path.dirname(path.resolve(outputPath));
226
324
  const gitignorePath = path.join(projectRoot, '.gitignore');
227
- const backupPatterns = [
228
- '',
229
- '# Container Superposition backups',
325
+ const written = appendGitignoreSection(gitignorePath, 'container-superposition backups', [
230
326
  '.devcontainer.backup-*/',
231
327
  '*.backup-*',
232
328
  'superposition.json.backup-*',
233
- ].join('\n');
234
- if (!fs.existsSync(gitignorePath)) {
235
- // Create new .gitignore with backup patterns
236
- await fs.promises.writeFile(gitignorePath, backupPatterns + '\n');
237
- console.log(chalk.dim(' 📝 Created .gitignore with backup patterns'));
238
- }
239
- else {
240
- // Check if patterns already exist
241
- const content = await fs.promises.readFile(gitignorePath, 'utf-8');
242
- if (!content.includes('Container Superposition backups')) {
243
- // Append patterns
244
- await fs.promises.appendFile(gitignorePath, '\n' + backupPatterns + '\n');
245
- console.log(chalk.dim(' 📝 Updated .gitignore with backup patterns'));
246
- }
329
+ ]);
330
+ if (written) {
331
+ console.log(chalk.dim(' 📝 Updated .gitignore with backup patterns'));
247
332
  }
248
333
  }
249
334
  /**
@@ -275,7 +360,7 @@ function buildOverlayChoices(config, stack, categoryList, preselected) {
275
360
  /**
276
361
  * Interactive questionnaire with modern checkbox selections
277
362
  */
278
- async function runQuestionnaire(manifest, manifestDir) {
363
+ async function runQuestionnaire(manifest, manifestDir, cliPresetId, cliPresetChoices) {
279
364
  const config = loadOverlaysConfigWrapper();
280
365
  // Pretty banner
281
366
  console.log('\n' +
@@ -309,32 +394,50 @@ async function runQuestionnaire(manifest, manifestDir) {
309
394
  try {
310
395
  // Question 0: Optional preset selection
311
396
  let usePreset = false;
312
- let selectedPresetId = manifest?.preset;
313
- let presetChoices = manifest?.presetChoices || {};
397
+ // CLI preset takes precedence over manifest preset
398
+ let selectedPresetId = cliPresetId || manifest?.preset;
399
+ // CLI preset choices merged with manifest choices (CLI takes precedence)
400
+ let presetChoices = {
401
+ ...(manifest?.presetChoices || {}),
402
+ ...(cliPresetChoices || {}),
403
+ };
314
404
  let presetGlueConfig;
315
405
  const presetOverlaysFiltered = config.overlays.filter((o) => o.category === 'preset');
316
406
  let presetOverlays = [];
317
407
  if (presetOverlaysFiltered.length > 0) {
318
- const defaultPreset = manifest?.preset || 'custom';
319
- const presetChoice = (await select({
320
- message: 'Start from a preset or build custom?',
321
- choices: [
322
- {
323
- name: 'Custom (select overlays manually)',
324
- value: 'custom',
325
- description: 'Choose individual overlays yourself',
326
- },
327
- ...presetOverlaysFiltered.map((p) => ({
328
- name: p.name,
329
- value: p.id,
330
- description: p.description,
331
- })),
332
- ],
333
- default: defaultPreset,
334
- }));
335
- if (presetChoice !== 'custom') {
408
+ // If a preset was pre-selected via CLI or manifest, skip the prompt
409
+ if (selectedPresetId) {
336
410
  usePreset = true;
337
- selectedPresetId = presetChoice;
411
+ console.log(chalk.cyan(`\n📦 Using preset: ${selectedPresetId}\n`));
412
+ }
413
+ else {
414
+ const defaultPreset = 'custom';
415
+ const presetChoice = (await select({
416
+ message: 'Start from a preset or build custom?',
417
+ choices: [
418
+ {
419
+ name: 'Custom (select overlays manually)',
420
+ value: 'custom',
421
+ description: 'Choose individual overlays yourself',
422
+ },
423
+ ...presetOverlaysFiltered.map((p) => ({
424
+ name: p.name,
425
+ value: p.id,
426
+ description: p.description,
427
+ })),
428
+ ],
429
+ default: defaultPreset,
430
+ }));
431
+ if (presetChoice !== 'custom') {
432
+ usePreset = true;
433
+ selectedPresetId = presetChoice;
434
+ }
435
+ else {
436
+ // User chose custom - discard any pre-provided preset choices so the
437
+ // manifest cannot end up with presetChoices but no preset.
438
+ presetChoices = {};
439
+ selectedPresetId = undefined;
440
+ }
338
441
  }
339
442
  }
340
443
  // Question 1: Base template
@@ -347,9 +450,9 @@ async function runQuestionnaire(manifest, manifestDir) {
347
450
  })),
348
451
  default: manifest?.baseTemplate,
349
452
  }));
350
- // If using preset, expand it now
453
+ // If using preset, expand it now (pass pre-provided choices to skip those prompts)
351
454
  if (usePreset && selectedPresetId) {
352
- const expansion = await expandPreset(selectedPresetId, stack);
455
+ const expansion = await expandPreset(selectedPresetId, stack, presetChoices);
353
456
  if (!expansion.overlays || expansion.overlays.length === 0) {
354
457
  // Preset failed to expand (e.g., missing or invalid preset definition).
355
458
  // Treat this as "no preset" so the manifest does not incorrectly record one.
@@ -603,6 +706,73 @@ async function runQuestionnaire(manifest, manifestDir) {
603
706
  const devTools = selectedOverlays.filter((o) => overlayMap.get(o)?.category === 'dev');
604
707
  const database = selectedOverlays.filter((o) => overlayMap.get(o)?.category === 'database');
605
708
  const playwright = selectedOverlays.includes('playwright');
709
+ // Check for deployment target compatibility
710
+ let target;
711
+ // Check if any incompatible overlays selected
712
+ const incompatibleOverlays = getIncompatibleOverlays(selectedOverlays, undefined);
713
+ if (incompatibleOverlays.length > 0) {
714
+ console.log(chalk.yellow('\n⚠️ Deployment Target Compatibility Check:\n'));
715
+ console.log(chalk.gray('Some selected overlays may not work in all environments.'));
716
+ console.log();
717
+ // Show incompatibilities
718
+ for (const { overlay, alternatives } of incompatibleOverlays) {
719
+ const overlayMeta = allOverlaysMap.get(overlay);
720
+ console.log(chalk.yellow(` • ${overlayMeta?.name || overlay}`));
721
+ console.log(chalk.gray(` Not compatible with: ${DEPLOYMENT_TARGETS.codespaces.name}, ${DEPLOYMENT_TARGETS.gitpod.name}`));
722
+ if (alternatives.length > 0) {
723
+ const altNames = alternatives
724
+ .map((id) => allOverlaysMap.get(id)?.name || id)
725
+ .join(', ');
726
+ console.log(chalk.cyan(` Alternatives: ${altNames}`));
727
+ }
728
+ console.log();
729
+ }
730
+ const targetChoice = (await select({
731
+ message: 'Which environment are you targeting?',
732
+ choices: [
733
+ {
734
+ name: '🖥️ Local Development (Docker Desktop)',
735
+ value: 'local',
736
+ description: 'Running on your local machine - supports all overlays',
737
+ },
738
+ {
739
+ name: '☁️ GitHub Codespaces',
740
+ value: 'codespaces',
741
+ description: 'Cloud development - may require docker-in-docker',
742
+ },
743
+ {
744
+ name: '🌐 Gitpod',
745
+ value: 'gitpod',
746
+ description: 'Cloud development - may require docker-in-docker',
747
+ },
748
+ {
749
+ name: '📦 DevPod',
750
+ value: 'devpod',
751
+ description: 'Client-only dev environments',
752
+ },
753
+ ],
754
+ default: 'local',
755
+ }));
756
+ target = targetChoice;
757
+ // Show specific incompatibilities for selected target
758
+ if (target !== 'local') {
759
+ const targetIncompatible = getIncompatibleOverlays(selectedOverlays, target);
760
+ if (targetIncompatible.length > 0) {
761
+ console.log(chalk.yellow(`\n⚠️ Warning: Some overlays won't work in ${DEPLOYMENT_TARGETS[target].name}:\n`));
762
+ for (const { overlay, alternatives } of targetIncompatible) {
763
+ const overlayMeta = allOverlaysMap.get(overlay);
764
+ console.log(chalk.red(` ✗ ${overlayMeta?.name || overlay}`));
765
+ if (alternatives.length > 0) {
766
+ const altNames = alternatives
767
+ .map((id) => allOverlaysMap.get(id)?.name || id)
768
+ .join(', ');
769
+ console.log(chalk.cyan(` → Recommended: ${altNames}`));
770
+ }
771
+ }
772
+ console.log();
773
+ }
774
+ }
775
+ }
606
776
  return {
607
777
  stack,
608
778
  baseImage,
@@ -620,6 +790,7 @@ async function runQuestionnaire(manifest, manifestDir) {
620
790
  observability,
621
791
  outputPath,
622
792
  portOffset,
793
+ target,
623
794
  };
624
795
  }
625
796
  catch (error) {
@@ -724,6 +895,12 @@ function buildAnswersFromCliArgs(config) {
724
895
  answers.preset = config.preset;
725
896
  if (config.presetChoices)
726
897
  answers.presetChoices = config.presetChoices;
898
+ if (config.target)
899
+ answers.target = config.target;
900
+ if (config.minimal !== undefined)
901
+ answers.minimal = config.minimal;
902
+ if (config.editor)
903
+ answers.editor = config.editor;
727
904
  return answers;
728
905
  }
729
906
  /**
@@ -768,157 +945,6 @@ function mergeAnswers(...partials) {
768
945
  }
769
946
  return merged;
770
947
  }
771
- /**
772
- * List available overlays command
773
- */
774
- async function listOverlays(options) {
775
- try {
776
- const overlaysConfig = loadOverlaysConfigWrapper();
777
- const category = options.category?.toLowerCase();
778
- console.log('\n' +
779
- boxen(chalk.bold('Available Overlays'), {
780
- padding: 0.5,
781
- borderColor: 'cyan',
782
- borderStyle: 'round',
783
- }));
784
- const categories = [
785
- { name: 'language', title: '📚 Language & Framework' },
786
- { name: 'database', title: '🗄️ Database & Messaging' },
787
- { name: 'observability', title: '📊 Observability' },
788
- { name: 'cloud', title: '☁️ Cloud Tools' },
789
- { name: 'dev', title: '🔧 Dev Tools' },
790
- { name: 'preset', title: '🎯 Presets' },
791
- ];
792
- for (const cat of categories) {
793
- if (category && cat.name !== category)
794
- continue;
795
- const overlays = overlaysConfig.overlays.filter((o) => o.category === cat.name);
796
- if (overlays.length === 0)
797
- continue;
798
- console.log(`\n${chalk.bold(cat.title)}`);
799
- for (const overlay of overlays) {
800
- console.log(` ${chalk.cyan(overlay.id.padEnd(20))} ${chalk.gray(overlay.description)}`);
801
- }
802
- }
803
- console.log(chalk.dim(`\n💡 Use "container-superposition init --language nodejs,python --database postgres" to compose overlays\n`));
804
- process.exit(0);
805
- }
806
- catch (error) {
807
- console.error(chalk.red('✗ Error listing overlays:'), error);
808
- process.exit(1);
809
- }
810
- }
811
- /**
812
- * Doctor command - check environment and validate configuration
813
- */
814
- async function runDoctor(options) {
815
- try {
816
- const outputPath = options.output || './.devcontainer';
817
- console.log('\n' +
818
- boxen(chalk.bold('Environment Check'), {
819
- padding: 0.5,
820
- borderColor: 'cyan',
821
- borderStyle: 'round',
822
- }));
823
- const checks = [];
824
- // Helper function for semantic version comparison
825
- const isVersionAtLeast = (current, required) => {
826
- const parse = (v) => {
827
- const parts = v.split('.');
828
- const major = parseInt(parts[0] ?? '0', 10) || 0;
829
- const minor = parseInt(parts[1] ?? '0', 10) || 0;
830
- const patch = parseInt(parts[2] ?? '0', 10) || 0;
831
- return [major, minor, patch];
832
- };
833
- const [cMajor, cMinor, cPatch] = parse(current);
834
- const [rMajor, rMinor, rPatch] = parse(required);
835
- if (cMajor !== rMajor) {
836
- return cMajor > rMajor;
837
- }
838
- if (cMinor !== rMinor) {
839
- return cMinor > rMinor;
840
- }
841
- return cPatch >= rPatch;
842
- };
843
- // Check Node.js version
844
- const nodeVersion = process.version;
845
- const requiredVersion = '20.0.0';
846
- const versionMatch = nodeVersion.match(/^v(\d+\.\d+\.\d+)/);
847
- const currentVersion = versionMatch ? versionMatch[1] : '0.0.0';
848
- const nodeOk = isVersionAtLeast(currentVersion, requiredVersion);
849
- checks.push({
850
- name: 'Node.js version',
851
- status: nodeOk,
852
- message: nodeOk
853
- ? `${nodeVersion} ✓`
854
- : `${nodeVersion} (requires >= ${requiredVersion})`,
855
- });
856
- // Check if Docker is available
857
- let dockerOk = false;
858
- try {
859
- const { execSync } = await import('child_process');
860
- execSync('docker --version', { stdio: 'ignore' });
861
- dockerOk = true;
862
- }
863
- catch {
864
- dockerOk = false;
865
- }
866
- checks.push({
867
- name: 'Docker',
868
- status: dockerOk,
869
- message: dockerOk ? 'Available ✓' : 'Not found (required for devcontainers)',
870
- });
871
- // Check if devcontainer exists
872
- const devcontainerExists = fs.existsSync(outputPath);
873
- checks.push({
874
- name: 'Devcontainer path',
875
- status: devcontainerExists,
876
- message: devcontainerExists
877
- ? `${outputPath} exists ✓`
878
- : `${outputPath} not found (run init first)`,
879
- });
880
- // Check if manifest exists
881
- if (devcontainerExists) {
882
- const manifestPath = path.join(outputPath, 'superposition.json');
883
- const manifestExists = fs.existsSync(manifestPath);
884
- checks.push({
885
- name: 'Manifest',
886
- status: manifestExists,
887
- message: manifestExists
888
- ? 'superposition.json found ✓'
889
- : 'superposition.json missing (manual edit or old version)',
890
- });
891
- // Check devcontainer.json
892
- const devcontainerJsonPath = path.join(outputPath, 'devcontainer.json');
893
- const devcontainerJsonExists = fs.existsSync(devcontainerJsonPath);
894
- checks.push({
895
- name: 'DevContainer config',
896
- status: devcontainerJsonExists,
897
- message: devcontainerJsonExists
898
- ? 'devcontainer.json found ✓'
899
- : 'devcontainer.json missing',
900
- });
901
- }
902
- console.log('');
903
- for (const check of checks) {
904
- const icon = check.status ? chalk.green('✓') : chalk.red('✗');
905
- console.log(` ${icon} ${chalk.white(check.name)}: ${chalk.gray(check.message)}`);
906
- }
907
- const allPassed = checks.every((c) => c.status);
908
- if (allPassed) {
909
- console.log(chalk.green('\n✓ All checks passed!\n'));
910
- process.exit(0);
911
- }
912
- else {
913
- console.log(chalk.yellow('\n⚠ Some checks failed. See above for details.\n'));
914
- process.exit(1);
915
- }
916
- }
917
- catch (error) {
918
- console.error(chalk.red('✗ Error running doctor:'), error);
919
- process.exit(1);
920
- }
921
- }
922
948
  /**
923
949
  * Parse CLI arguments
924
950
  */
@@ -929,14 +955,14 @@ async function parseCliArgs() {
929
955
  program
930
956
  .name('container-superposition')
931
957
  .description('Composable devcontainer scaffolds')
932
- .version('0.1.0');
958
+ .version(getToolVersion());
933
959
  // Init command (default)
934
960
  program
935
961
  .command('init', { isDefault: true })
936
962
  .description('Initialize a new devcontainer configuration')
937
963
  .option('--from-manifest <path>', 'Load configuration from existing superposition.json manifest')
938
964
  .option('--no-interactive', 'Use manifest values directly without questionnaire (requires --from-manifest)')
939
- .option('--no-backup', 'Skip creating backup before regeneration')
965
+ .option('--backup', 'Force or suppress backup; default is --no-backup inside a git repo, --backup outside')
940
966
  .option('--backup-dir <path>', 'Custom backup directory location')
941
967
  .option('--stack <type>', 'Base template: plain, compose')
942
968
  .option('--language <list>', 'Comma-separated language overlays: dotnet, nodejs, python, mkdocs, java, go, rust, bun, powershell')
@@ -946,7 +972,13 @@ async function parseCliArgs() {
946
972
  .option('--cloud-tools <list>', 'Comma-separated: aws-cli, azure-cli, gcloud, kubectl-helm, terraform, pulumi')
947
973
  .option('--dev-tools <list>', 'Comma-separated: docker-in-docker, docker-sock, playwright, codex, git-helpers, pre-commit, commitlint, just, direnv, modern-cli-tools, ngrok')
948
974
  .option('--port-offset <number>', 'Add offset to all exposed ports (e.g., 100 makes Grafana 3100 instead of 3000)')
975
+ .option('--target <environment>', 'Deployment target: local, codespaces, gitpod, devpod (optimizes for environment)', 'local')
976
+ .option('--minimal', 'Minimal mode - exclude optional/nice-to-have features and extensions')
977
+ .option('--editor <profile>', 'Editor profile: vscode (default), jetbrains, none', 'vscode')
949
978
  .option('-o, --output <path>', 'Output path (default: ./.devcontainer)')
979
+ .option('--write-manifest-only', 'Generate only superposition.json manifest without creating .devcontainer/ files')
980
+ .option('--preset <id>', 'Start from a preset (e.g., web-api, microservice)')
981
+ .option('--preset-param <value>', 'Set a preset parameter value (format: key=value, can be repeated)', (value, previous) => previous.concat([value]), [])
950
982
  .action((options) => {
951
983
  // Store options for main() to process
952
984
  initOptions = options;
@@ -956,14 +988,31 @@ async function parseCliArgs() {
956
988
  .command('regen')
957
989
  .description('Regenerate devcontainer from existing superposition.json manifest')
958
990
  .option('-o, --output <path>', 'Output path (default: ./.devcontainer)')
959
- .option('--no-backup', 'Skip creating backup before regeneration')
991
+ .option('--backup', 'Force or suppress backup; default is --no-backup inside a git repo, --backup outside')
960
992
  .option('--backup-dir <path>', 'Custom backup directory location')
993
+ .option('--minimal', 'Minimal mode - exclude optional/nice-to-have features and extensions')
994
+ .option('--editor <profile>', 'Editor profile: vscode (default), jetbrains, none', 'vscode')
961
995
  .action((options) => {
962
996
  const outputPath = options.output || './.devcontainer';
963
- const manifestPath = path.join(outputPath, 'superposition.json');
964
- if (!fs.existsSync(manifestPath)) {
965
- console.error(chalk.red(`✗ Error: No manifest found at ${manifestPath}`));
966
- console.error(chalk.gray(' Run "container-superposition init" first to create a configuration'));
997
+ // Look for manifest in multiple locations for team workflow:
998
+ // 1. Current directory (./superposition.json) - team workflow
999
+ // 2. Output directory (e.g., ./.devcontainer/superposition.json) - legacy
1000
+ const manifestSearchPaths = [
1001
+ 'superposition.json',
1002
+ path.join(outputPath, 'superposition.json'),
1003
+ ];
1004
+ let manifestPath = null;
1005
+ for (const searchPath of manifestSearchPaths) {
1006
+ if (fs.existsSync(searchPath)) {
1007
+ manifestPath = searchPath;
1008
+ break;
1009
+ }
1010
+ }
1011
+ if (!manifestPath) {
1012
+ console.error(chalk.red(`✗ Error: No manifest found`));
1013
+ console.error(chalk.gray(' Searched for: ./superposition.json, ' +
1014
+ path.join(outputPath, 'superposition.json')));
1015
+ console.error(chalk.gray(' Run "container-superposition init --write-manifest-only" to create a manifest'));
967
1016
  process.exit(1);
968
1017
  }
969
1018
  // Store options for main() to process
@@ -978,16 +1027,51 @@ async function parseCliArgs() {
978
1027
  .command('list')
979
1028
  .description('List available overlays and presets')
980
1029
  .option('--category <type>', 'Filter by category: language, database, observability, cloud, dev, preset')
1030
+ .option('--tags <list>', 'Filter by tags (comma-separated)')
1031
+ .option('--supports <stack>', 'Filter by stack support: plain, compose')
1032
+ .option('--json', 'Output as JSON for scripting')
1033
+ .action(async (options) => {
1034
+ const overlaysConfig = loadOverlaysConfigWrapper();
1035
+ await listCommand(overlaysConfig, options);
1036
+ process.exit(0);
1037
+ });
1038
+ // Explain command
1039
+ program
1040
+ .command('explain <overlay>')
1041
+ .description('Show detailed information about an overlay')
1042
+ .option('--json', 'Output as JSON for scripting')
1043
+ .action(async (overlayId, options) => {
1044
+ const overlaysConfig = loadOverlaysConfigWrapper();
1045
+ await explainCommand(overlaysConfig, OVERLAYS_DIR, overlayId, options);
1046
+ process.exit(0);
1047
+ });
1048
+ // Plan command
1049
+ program
1050
+ .command('plan')
1051
+ .description('Preview what will be generated before creating devcontainer')
1052
+ .option('--stack <type>', 'Base template: plain, compose', 'compose')
1053
+ .option('--overlays <list>', 'Comma-separated list of overlay IDs')
1054
+ .option('--port-offset <number>', 'Add offset to all exposed ports', (val) => parseInt(val, 10), 0)
1055
+ .option('-o, --output <path>', 'Compare against existing config at this path (default: ./.devcontainer)')
1056
+ .option('--diff', 'Compare planned output vs existing configuration')
1057
+ .option('--diff-format <format>', 'Diff output format: color (default), json', 'color')
1058
+ .option('--diff-context <lines>', 'Context lines in diff output', (val) => parseInt(val, 10), 3)
1059
+ .option('--json', 'Output as JSON for scripting')
981
1060
  .action(async (options) => {
982
- await listOverlays(options);
1061
+ const overlaysConfig = loadOverlaysConfigWrapper();
1062
+ await planCommand(overlaysConfig, OVERLAYS_DIR, options);
1063
+ process.exit(0);
983
1064
  });
984
1065
  // Doctor command
985
1066
  program
986
1067
  .command('doctor')
987
1068
  .description('Check environment and validate configuration')
988
1069
  .option('-o, --output <path>', 'Devcontainer path to validate (default: ./.devcontainer)')
1070
+ .option('--fix', 'Apply automatic fixes where possible')
1071
+ .option('--json', 'Output as JSON for scripting')
989
1072
  .action(async (options) => {
990
- await runDoctor(options);
1073
+ const overlaysConfig = loadOverlaysConfigWrapper();
1074
+ await doctorCommand(overlaysConfig, OVERLAYS_DIR, options);
991
1075
  });
992
1076
  await program.parseAsync(process.argv);
993
1077
  // If init or regen command was run, return the options
@@ -1030,14 +1114,61 @@ async function parseCliArgs() {
1030
1114
  if (initOptions.portOffset) {
1031
1115
  config.portOffset = parseInt(initOptions.portOffset, 10);
1032
1116
  }
1117
+ if (initOptions.target) {
1118
+ config.target = initOptions.target;
1119
+ }
1120
+ if (initOptions.minimal) {
1121
+ config.minimal = true;
1122
+ }
1123
+ if (initOptions.editor) {
1124
+ const editorLower = initOptions.editor.toLowerCase();
1125
+ if (['vscode', 'jetbrains', 'none'].includes(editorLower)) {
1126
+ config.editor = editorLower;
1127
+ }
1128
+ else {
1129
+ console.warn(chalk.yellow(`⚠️ Invalid editor profile: ${initOptions.editor}, using default (vscode)`));
1130
+ config.editor = 'vscode';
1131
+ }
1132
+ }
1033
1133
  if (initOptions.output)
1034
1134
  config.outputPath = initOptions.output;
1135
+ // Handle --preset flag
1136
+ if (initOptions.preset) {
1137
+ config.preset = initOptions.preset;
1138
+ }
1139
+ // Handle --preset-param flags (can be repeated)
1140
+ if (initOptions.presetParam && initOptions.presetParam.length > 0) {
1141
+ if (!initOptions.preset) {
1142
+ console.warn(chalk.yellow('⚠️ Ignoring --preset-param because no --preset was provided. ' +
1143
+ 'Preset parameters only apply when a preset is selected (e.g., --preset web-api --preset-param broker=nats).'));
1144
+ }
1145
+ else {
1146
+ const presetChoices = {};
1147
+ for (const param of initOptions.presetParam) {
1148
+ const eqIdx = param.indexOf('=');
1149
+ if (eqIdx > 0) {
1150
+ const key = param.slice(0, eqIdx).trim();
1151
+ const value = param.slice(eqIdx + 1).trim();
1152
+ if (key) {
1153
+ presetChoices[key] = value;
1154
+ }
1155
+ }
1156
+ else {
1157
+ console.warn(chalk.yellow(`⚠️ Invalid --preset-param format: "${param}". Expected "key=value" (e.g., --preset-param broker=nats).`));
1158
+ }
1159
+ }
1160
+ if (Object.keys(presetChoices).length > 0) {
1161
+ config.presetChoices = presetChoices;
1162
+ }
1163
+ }
1164
+ }
1035
1165
  return {
1036
1166
  config,
1037
1167
  manifestPath: initOptions.fromManifest,
1038
- noBackup: initOptions.backup === false, // Commander creates options.backup = false for --no-backup
1168
+ backupOverride: initOptions.backup, // undefined = auto-detect; true = --backup; false = --no-backup
1039
1169
  backupDir: initOptions.backupDir,
1040
1170
  noInteractive: initOptions.interactive === false, // Commander creates options.interactive = false for --no-interactive
1171
+ writeManifestOnly: initOptions.writeManifestOnly === true,
1041
1172
  };
1042
1173
  }
1043
1174
  async function main() {
@@ -1051,7 +1182,6 @@ async function main() {
1051
1182
  }
1052
1183
  let manifest;
1053
1184
  let manifestDir;
1054
- let shouldBackup = true;
1055
1185
  let backupDir;
1056
1186
  let useManifestOnly = false;
1057
1187
  // Handle manifest loading
@@ -1068,10 +1198,7 @@ async function main() {
1068
1198
  process.exit(1);
1069
1199
  }
1070
1200
  manifest = loadedManifest;
1071
- // Check for backup and interaction options
1072
- if (cliArgs.noBackup) {
1073
- shouldBackup = false;
1074
- }
1201
+ // Check for interaction options
1075
1202
  if (cliArgs.backupDir) {
1076
1203
  backupDir = cliArgs.backupDir;
1077
1204
  }
@@ -1079,20 +1206,51 @@ async function main() {
1079
1206
  useManifestOnly = true;
1080
1207
  }
1081
1208
  }
1209
+ // Determine whether to create a backup:
1210
+ // --backup → always backup
1211
+ // --no-backup → never backup
1212
+ // (neither) → backup only when NOT inside a git repository
1213
+ // (git already tracks history, so backups are redundant)
1214
+ const backupCheckPath = path.resolve(cliArgs?.config?.outputPath || manifestDir || './.devcontainer');
1215
+ const inGitRepo = isInsideGitRepo(backupCheckPath);
1216
+ let shouldBackup;
1217
+ if (cliArgs?.backupOverride === true) {
1218
+ shouldBackup = true;
1219
+ }
1220
+ else if (cliArgs?.backupOverride === false) {
1221
+ shouldBackup = false;
1222
+ }
1223
+ else {
1224
+ // Auto-detect based on git presence
1225
+ shouldBackup = !inGitRepo;
1226
+ if (!shouldBackup) {
1227
+ console.log(chalk.dim('ℹ Skipping backup — git repo detected (use --backup to force one)\n'));
1228
+ }
1229
+ }
1082
1230
  // Create backup if needed
1231
+ let actualBackupPath;
1083
1232
  if (shouldBackup && manifest) {
1084
1233
  // Output path is the directory containing the manifest
1085
1234
  const outputPath = manifestDir || './.devcontainer';
1086
1235
  const backupPath = await createBackup(outputPath, backupDir);
1087
1236
  if (backupPath) {
1237
+ actualBackupPath = backupPath;
1088
1238
  console.log(chalk.green(`✓ Backup created: ${backupPath}\n`));
1089
- await ensureBackupPatternsInGitignore(outputPath);
1239
+ ensureBackupPatternsInGitignore(outputPath);
1090
1240
  }
1091
1241
  }
1092
1242
  // Build answers based on mode
1093
1243
  let answers;
1094
- if (useManifestOnly && manifest) {
1095
- // Mode 1: Manifest-only (--from-manifest --no-interactive)
1244
+ const isRegen = !!manifest;
1245
+ // Check if there are CLI overrides beyond just output path and preset flags
1246
+ // Preset/presetChoices alone don't constitute "CLI overrides" that bypass interactive mode
1247
+ const hasCliOverrides = cliArgs &&
1248
+ Object.keys(cliArgs.config).some((key) => key !== 'outputPath' &&
1249
+ key !== 'preset' &&
1250
+ key !== 'presetChoices' &&
1251
+ cliArgs.config[key] !== undefined);
1252
+ if (useManifestOnly && manifest && !hasCliOverrides) {
1253
+ // Mode 1: Manifest-only (--from-manifest --no-interactive, no CLI overrides)
1096
1254
  const manifestAnswers = buildAnswersFromManifest(manifest, manifestDir);
1097
1255
  answers = mergeAnswers(manifestAnswers);
1098
1256
  console.log('\n' +
@@ -1110,8 +1268,9 @@ async function main() {
1110
1268
  : '') +
1111
1269
  chalk.gray(` Output: ${answers.outputPath}`), { padding: 1, borderColor: 'cyan', borderStyle: 'round', margin: 1 }));
1112
1270
  }
1113
- else if (cliArgs && cliArgs.config.stack) {
1271
+ else if (cliArgs && (cliArgs.config.stack || hasCliOverrides)) {
1114
1272
  // Mode 2: CLI-based (with optional manifest defaults)
1273
+ // This includes regen with --minimal or --editor flags
1115
1274
  const cliAnswers = buildAnswersFromCliArgs(cliArgs.config);
1116
1275
  const manifestAnswers = manifest
1117
1276
  ? buildAnswersFromManifest(manifest, manifestDir)
@@ -1119,16 +1278,30 @@ async function main() {
1119
1278
  answers = mergeAnswers(manifestAnswers, cliAnswers, {
1120
1279
  outputPath: cliAnswers.outputPath || './.devcontainer',
1121
1280
  });
1281
+ const modeLabel = useManifestOnly && hasCliOverrides
1282
+ ? 'Regenerating from Manifest with Overrides'
1283
+ : 'Running in CLI mode';
1122
1284
  console.log('\n' +
1123
- boxen(chalk.bold('Running in CLI mode'), {
1285
+ boxen(chalk.bold(modeLabel), {
1124
1286
  padding: 0.5,
1125
1287
  borderColor: 'blue',
1126
1288
  borderStyle: 'round',
1127
1289
  }));
1290
+ // Show what's being overridden
1291
+ if (useManifestOnly && hasCliOverrides) {
1292
+ const overrides = [];
1293
+ if (cliAnswers.minimal)
1294
+ overrides.push('minimal mode');
1295
+ if (cliAnswers.editor)
1296
+ overrides.push(`editor: ${cliAnswers.editor}`);
1297
+ if (overrides.length > 0) {
1298
+ console.log(chalk.dim(` Overrides: ${overrides.join(', ')}`));
1299
+ }
1300
+ }
1128
1301
  }
1129
1302
  else {
1130
- // Mode 3: Interactive (with optional manifest pre-population)
1131
- const interactiveAnswers = await runQuestionnaire(manifest, manifestDir);
1303
+ // Mode 3: Interactive (with optional manifest pre-population and CLI preset pre-selection)
1304
+ const interactiveAnswers = await runQuestionnaire(manifest, manifestDir, cliArgs?.config.preset, cliArgs?.config.presetChoices);
1132
1305
  answers = mergeAnswers(interactiveAnswers);
1133
1306
  }
1134
1307
  // Show configuration summary
@@ -1157,27 +1330,36 @@ async function main() {
1157
1330
  borderStyle: 'round',
1158
1331
  margin: { top: 0, bottom: 1 },
1159
1332
  }));
1333
+ // Check if we're in manifest-only mode
1334
+ const isManifestOnly = cliArgs?.writeManifestOnly === true;
1160
1335
  // Generate with spinner
1161
1336
  const spinner = ora({
1162
- text: chalk.cyan('Generating devcontainer configuration...'),
1337
+ text: isManifestOnly
1338
+ ? chalk.cyan('Generating manifest file...')
1339
+ : chalk.cyan('Generating devcontainer configuration...'),
1163
1340
  color: 'cyan',
1164
1341
  }).start();
1165
1342
  try {
1166
- await composeDevContainer(answers);
1167
- spinner.succeed(chalk.green('DevContainer created successfully!'));
1343
+ let summary;
1344
+ if (isManifestOnly) {
1345
+ summary = await generateManifestOnly(answers, undefined, { isRegen });
1346
+ spinner.succeed(chalk.green('Manifest created successfully!'));
1347
+ }
1348
+ else {
1349
+ summary = await composeDevContainer(answers, undefined, { isRegen });
1350
+ spinner.succeed(chalk.green('DevContainer created successfully!'));
1351
+ }
1352
+ // Update summary with backup path and regen status
1353
+ if (actualBackupPath) {
1354
+ summary.backupPath = actualBackupPath;
1355
+ }
1356
+ // Print comprehensive summary
1357
+ printSummary(summary);
1168
1358
  }
1169
1359
  catch (error) {
1170
- spinner.fail(chalk.red('Failed to create devcontainer'));
1360
+ spinner.fail(chalk.red(isManifestOnly ? 'Failed to create manifest' : 'Failed to create devcontainer'));
1171
1361
  throw error;
1172
1362
  }
1173
- // Success message
1174
- console.log('\n' +
1175
- boxen(chalk.bold.green('✓ Setup Complete!\n\n') +
1176
- chalk.white('Next steps:\n') +
1177
- chalk.gray(' 1. Review the generated .devcontainer/ folder\n') +
1178
- chalk.gray(" 2. Customize as needed (it's just normal JSON!)\n") +
1179
- chalk.gray(' 3. Open in VS Code and rebuild container\n\n') +
1180
- chalk.dim('The generated configuration is fully editable and independent of this tool.'), { padding: 1, borderColor: 'green', borderStyle: 'double', margin: 1 }));
1181
1363
  }
1182
1364
  catch (error) {
1183
1365
  console.error('\n' +