container-superposition 0.1.3 → 0.1.5

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 (141) hide show
  1. package/README.md +72 -1014
  2. package/dist/scripts/init.js +512 -238
  3. package/dist/scripts/init.js.map +1 -1
  4. package/dist/tool/commands/adopt.d.ts +62 -0
  5. package/dist/tool/commands/adopt.d.ts.map +1 -0
  6. package/dist/tool/commands/adopt.js +767 -0
  7. package/dist/tool/commands/adopt.js.map +1 -0
  8. package/dist/tool/commands/doctor.js +2 -2
  9. package/dist/tool/commands/explain.d.ts.map +1 -1
  10. package/dist/tool/commands/explain.js +88 -0
  11. package/dist/tool/commands/explain.js.map +1 -1
  12. package/dist/tool/commands/hash.d.ts +36 -0
  13. package/dist/tool/commands/hash.d.ts.map +1 -0
  14. package/dist/tool/commands/hash.js +242 -0
  15. package/dist/tool/commands/hash.js.map +1 -0
  16. package/dist/tool/commands/plan.d.ts +53 -0
  17. package/dist/tool/commands/plan.d.ts.map +1 -1
  18. package/dist/tool/commands/plan.js +784 -42
  19. package/dist/tool/commands/plan.js.map +1 -1
  20. package/dist/tool/questionnaire/composer.d.ts +12 -3
  21. package/dist/tool/questionnaire/composer.d.ts.map +1 -1
  22. package/dist/tool/questionnaire/composer.js +133 -20
  23. package/dist/tool/questionnaire/composer.js.map +1 -1
  24. package/dist/tool/schema/project-config.d.ts +15 -0
  25. package/dist/tool/schema/project-config.d.ts.map +1 -0
  26. package/dist/tool/schema/project-config.js +359 -0
  27. package/dist/tool/schema/project-config.js.map +1 -0
  28. package/dist/tool/schema/types.d.ts +57 -1
  29. package/dist/tool/schema/types.d.ts.map +1 -1
  30. package/dist/tool/utils/backup.d.ts +23 -0
  31. package/dist/tool/utils/backup.d.ts.map +1 -0
  32. package/dist/tool/utils/backup.js +123 -0
  33. package/dist/tool/utils/backup.js.map +1 -0
  34. package/dist/tool/utils/gitignore.d.ts +15 -0
  35. package/dist/tool/utils/gitignore.d.ts.map +1 -0
  36. package/dist/tool/utils/gitignore.js +41 -0
  37. package/dist/tool/utils/gitignore.js.map +1 -0
  38. package/dist/tool/utils/services-export.d.ts +14 -0
  39. package/dist/tool/utils/services-export.d.ts.map +1 -0
  40. package/dist/tool/utils/services-export.js +478 -0
  41. package/dist/tool/utils/services-export.js.map +1 -0
  42. package/dist/tool/utils/summary.d.ts +69 -0
  43. package/dist/tool/utils/summary.d.ts.map +1 -0
  44. package/dist/tool/utils/summary.js +260 -0
  45. package/dist/tool/utils/summary.js.map +1 -0
  46. package/docs/README.md +12 -2
  47. package/docs/adopt.md +196 -0
  48. package/docs/custom-patches.md +1 -1
  49. package/docs/discovery-commands.md +55 -3
  50. package/docs/examples.md +40 -6
  51. package/docs/filesystem-contract.md +58 -0
  52. package/docs/hash.md +183 -0
  53. package/docs/minimal-and-editor.md +1 -1
  54. package/docs/overlays.md +108 -5
  55. package/docs/presets-architecture.md +1 -1
  56. package/docs/presets.md +1 -1
  57. package/docs/publishing.md +36 -23
  58. package/docs/security.md +43 -0
  59. package/docs/specs/001-verbose-plan-graph/checklists/requirements.md +36 -0
  60. package/docs/specs/001-verbose-plan-graph/contracts/plan-verbose-output.md +96 -0
  61. package/docs/specs/001-verbose-plan-graph/data-model.md +111 -0
  62. package/docs/specs/001-verbose-plan-graph/plan.md +127 -0
  63. package/docs/specs/001-verbose-plan-graph/quickstart.md +106 -0
  64. package/docs/specs/001-verbose-plan-graph/research.md +100 -0
  65. package/docs/specs/001-verbose-plan-graph/spec.md +128 -0
  66. package/docs/specs/001-verbose-plan-graph/tasks.md +223 -0
  67. package/docs/specs/002-superposition-config-file/checklists/requirements.md +36 -0
  68. package/docs/specs/002-superposition-config-file/contracts/init-project-config.md +98 -0
  69. package/docs/specs/002-superposition-config-file/data-model.md +126 -0
  70. package/docs/specs/002-superposition-config-file/plan.md +208 -0
  71. package/docs/specs/002-superposition-config-file/quickstart.md +140 -0
  72. package/docs/specs/002-superposition-config-file/research.md +144 -0
  73. package/docs/specs/002-superposition-config-file/spec.md +130 -0
  74. package/docs/specs/002-superposition-config-file/tasks.md +213 -0
  75. package/docs/team-workflow.md +27 -1
  76. package/docs/workflows.md +136 -0
  77. package/overlays/.presets/microservice.yml +32 -6
  78. package/overlays/.presets/sdd.yml +84 -0
  79. package/overlays/.presets/web-api.yml +76 -56
  80. package/overlays/README.md +7 -1
  81. package/overlays/amp/README.md +70 -0
  82. package/overlays/amp/devcontainer.patch.json +3 -0
  83. package/overlays/amp/overlay.yml +15 -0
  84. package/overlays/amp/setup.sh +21 -0
  85. package/overlays/amp/verify.sh +21 -0
  86. package/overlays/claude-code/README.md +83 -0
  87. package/overlays/claude-code/devcontainer.patch.json +3 -0
  88. package/overlays/claude-code/overlay.yml +15 -0
  89. package/overlays/claude-code/setup.sh +21 -0
  90. package/overlays/claude-code/verify.sh +21 -0
  91. package/overlays/cloudflared/README.md +190 -0
  92. package/overlays/cloudflared/devcontainer.patch.json +3 -0
  93. package/overlays/cloudflared/overlay.yml +15 -0
  94. package/overlays/cloudflared/setup.sh +49 -0
  95. package/overlays/cloudflared/verify.sh +21 -0
  96. package/overlays/direnv/README.md +6 -4
  97. package/overlays/direnv/setup.sh +0 -12
  98. package/overlays/gemini-cli/README.md +77 -0
  99. package/overlays/gemini-cli/devcontainer.patch.json +3 -0
  100. package/overlays/gemini-cli/overlay.yml +15 -0
  101. package/overlays/gemini-cli/setup.sh +21 -0
  102. package/overlays/gemini-cli/verify.sh +21 -0
  103. package/overlays/grpc-tools/README.md +242 -0
  104. package/overlays/grpc-tools/devcontainer.patch.json +14 -0
  105. package/overlays/grpc-tools/overlay.yml +14 -0
  106. package/overlays/grpc-tools/setup.sh +57 -0
  107. package/overlays/grpc-tools/verify.sh +47 -0
  108. package/overlays/keycloak/.env.example +5 -0
  109. package/overlays/keycloak/README.md +238 -0
  110. package/overlays/keycloak/devcontainer.patch.json +17 -0
  111. package/overlays/keycloak/docker-compose.yml +32 -0
  112. package/overlays/keycloak/overlay.yml +23 -0
  113. package/overlays/keycloak/verify.sh +54 -0
  114. package/overlays/mailpit/.env.example +4 -0
  115. package/overlays/mailpit/README.md +191 -0
  116. package/overlays/mailpit/devcontainer.patch.json +20 -0
  117. package/overlays/mailpit/docker-compose.yml +17 -0
  118. package/overlays/mailpit/overlay.yml +26 -0
  119. package/overlays/mailpit/verify.sh +52 -0
  120. package/overlays/ngrok/overlay.yml +2 -1
  121. package/overlays/opencode/README.md +76 -0
  122. package/overlays/opencode/devcontainer.patch.json +3 -0
  123. package/overlays/opencode/overlay.yml +14 -0
  124. package/overlays/opencode/setup.sh +21 -0
  125. package/overlays/opencode/verify.sh +21 -0
  126. package/overlays/python/README.md +51 -35
  127. package/overlays/python/devcontainer.patch.json +7 -4
  128. package/overlays/python/setup.sh +50 -23
  129. package/overlays/python/verify.sh +29 -1
  130. package/overlays/spec-kit/README.md +181 -0
  131. package/overlays/spec-kit/devcontainer.patch.json +6 -0
  132. package/overlays/spec-kit/overlay.yml +19 -0
  133. package/overlays/spec-kit/setup.sh +45 -0
  134. package/overlays/spec-kit/verify.sh +33 -0
  135. package/overlays/windsurf-cli/README.md +69 -0
  136. package/overlays/windsurf-cli/devcontainer.patch.json +3 -0
  137. package/overlays/windsurf-cli/overlay.yml +15 -0
  138. package/overlays/windsurf-cli/setup.sh +21 -0
  139. package/overlays/windsurf-cli/verify.sh +21 -0
  140. package/package.json +1 -1
  141. package/tool/schema/config.schema.json +138 -9
@@ -14,9 +14,14 @@ import { listCommand } from '../tool/commands/list.js';
14
14
  import { explainCommand } from '../tool/commands/explain.js';
15
15
  import { planCommand } from '../tool/commands/plan.js';
16
16
  import { doctorCommand } from '../tool/commands/doctor.js';
17
+ import { adoptCommand } from '../tool/commands/adopt.js';
18
+ import { hashCommand } from '../tool/commands/hash.js';
17
19
  import { getIncompatibleOverlays, DEPLOYMENT_TARGETS } from '../tool/schema/deployment-targets.js';
18
20
  import { migrateManifest, needsMigration, detectManifestVersion, isVersionSupported, CURRENT_MANIFEST_VERSION, SUPPORTED_MANIFEST_VERSIONS, } from '../tool/schema/manifest-migrations.js';
19
21
  import { getToolVersion } from '../tool/utils/version.js';
22
+ import { printSummary } from '../tool/utils/summary.js';
23
+ import { buildAnswersFromProjectConfig, loadProjectConfig, writeProjectConfigCustomizations, } from '../tool/schema/project-config.js';
24
+ import { isInsideGitRepo, createBackup, ensureBackupPatternsInGitignore, } from '../tool/utils/backup.js';
20
25
  // Get __dirname equivalent in ESM
21
26
  const __filename = fileURLToPath(import.meta.url);
22
27
  const __dirname = path.dirname(__filename);
@@ -62,10 +67,104 @@ function loadPresetDefinition(presetId) {
62
67
  const content = fs.readFileSync(presetPath, 'utf8');
63
68
  return yaml.load(content);
64
69
  }
70
+ function categorizeOverlayIds(overlayIds, config) {
71
+ const language = [];
72
+ const database = [];
73
+ const observability = [];
74
+ const cloudTools = [];
75
+ const devTools = [];
76
+ const overlayMap = new Map(config.overlays.map((o) => [o.id, o]));
77
+ for (const id of overlayIds) {
78
+ const overlay = overlayMap.get(id);
79
+ if (!overlay)
80
+ continue;
81
+ switch (overlay.category) {
82
+ case 'language':
83
+ language.push(id);
84
+ break;
85
+ case 'database':
86
+ database.push(id);
87
+ break;
88
+ case 'observability':
89
+ observability.push(id);
90
+ break;
91
+ case 'cloud':
92
+ cloudTools.push(id);
93
+ break;
94
+ case 'dev':
95
+ devTools.push(id);
96
+ break;
97
+ }
98
+ }
99
+ return { language, database, observability, cloudTools, devTools };
100
+ }
101
+ function mergeUnique(left, right) {
102
+ const merged = [...(left ?? []), ...(right ?? [])];
103
+ return merged.length > 0 ? [...new Set(merged)] : undefined;
104
+ }
105
+ function expandPresetWithDefaults(presetId, stack, providedChoices = {}) {
106
+ const preset = loadPresetDefinition(presetId);
107
+ if (!preset) {
108
+ throw new Error(`Preset definition not found for ${presetId}`);
109
+ }
110
+ const overlays = [...preset.selects.required];
111
+ const choices = {};
112
+ if (preset.selects.userChoice) {
113
+ for (const [key, choice] of Object.entries(preset.selects.userChoice)) {
114
+ const selectedOption = providedChoices[key] ?? choice.defaultOption;
115
+ if (!selectedOption || !choice.options.includes(selectedOption)) {
116
+ const valid = choice.options.join(', ');
117
+ throw new Error(`Preset choice '${key}' must be one of: ${valid}`);
118
+ }
119
+ overlays.push(selectedOption);
120
+ choices[key] = selectedOption;
121
+ }
122
+ }
123
+ if (preset.parameters) {
124
+ for (const [key, param] of Object.entries(preset.parameters)) {
125
+ const selectedId = providedChoices[key] ?? param.default;
126
+ const selectedOption = param.options.find((option) => option.id === selectedId);
127
+ if (!selectedOption) {
128
+ const valid = param.options.map((option) => option.id).join(', ');
129
+ throw new Error(`Preset parameter '${key}' must be one of: ${valid}`);
130
+ }
131
+ overlays.push(...selectedOption.overlays);
132
+ choices[key] = selectedId;
133
+ }
134
+ }
135
+ const uniqueOverlays = [...new Set(overlays)];
136
+ let resolvedGlueConfig = preset.glueConfig;
137
+ if (resolvedGlueConfig?.environment) {
138
+ const resolvedEnv = {};
139
+ for (const [envKey, envValue] of Object.entries(resolvedGlueConfig.environment)) {
140
+ resolvedEnv[envKey] = envValue.replace(/\{\{parameters\.(\w+)\.id\}\}/g, (_match, paramKey) => choices[paramKey] ?? _match);
141
+ }
142
+ resolvedGlueConfig = { ...resolvedGlueConfig, environment: resolvedEnv };
143
+ }
144
+ return { overlays: uniqueOverlays, choices, glueConfig: resolvedGlueConfig };
145
+ }
146
+ function applyPresetSelections(answers) {
147
+ if (!answers.preset) {
148
+ return answers;
149
+ }
150
+ const expansion = expandPresetWithDefaults(answers.preset, answers.stack ?? 'plain', answers.presetChoices ?? {});
151
+ const categories = categorizeOverlayIds(expansion.overlays, loadOverlaysConfigWrapper());
152
+ return {
153
+ ...answers,
154
+ language: mergeUnique(categories.language, answers.language),
155
+ database: mergeUnique(categories.database, answers.database),
156
+ observability: mergeUnique(categories.observability, answers.observability),
157
+ cloudTools: mergeUnique(categories.cloudTools, answers.cloudTools),
158
+ devTools: mergeUnique(categories.devTools, answers.devTools),
159
+ playwright: answers.playwright ?? expansion.overlays.includes('playwright'),
160
+ presetChoices: Object.keys(expansion.choices).length > 0 ? expansion.choices : undefined,
161
+ presetGlueConfig: expansion.glueConfig,
162
+ };
163
+ }
65
164
  /**
66
165
  * Expand a preset into a list of overlay IDs with user choices resolved
67
166
  */
68
- async function expandPreset(presetId, stack) {
167
+ async function expandPreset(presetId, stack, preProvidedChoices = {}) {
69
168
  const preset = loadPresetDefinition(presetId);
70
169
  if (!preset) {
71
170
  return { overlays: [], choices: {} };
@@ -73,23 +172,82 @@ async function expandPreset(presetId, stack) {
73
172
  console.log(chalk.cyan(`\n📦 Expanding preset: ${preset.name}\n`));
74
173
  const overlays = [...preset.selects.required];
75
174
  const choices = {};
76
- // Handle user choices
175
+ // Handle user choices (single overlay per option)
77
176
  if (preset.selects.userChoice) {
78
177
  for (const [key, choice] of Object.entries(preset.selects.userChoice)) {
79
- const selectedOption = (await select({
80
- message: choice.prompt,
81
- choices: choice.options.map((opt) => ({
82
- name: opt,
83
- value: opt,
84
- })),
85
- default: choice.defaultOption,
86
- }));
87
- overlays.push(selectedOption);
88
- choices[key] = selectedOption;
178
+ const preProvidedValue = preProvidedChoices[key];
179
+ if (preProvidedValue !== undefined) {
180
+ // Validate the pre-provided value
181
+ if (!choice.options.includes(preProvidedValue)) {
182
+ const valid = choice.options.join(', ');
183
+ throw new Error(`Invalid value '${preProvidedValue}' for preset choice '${key}'. Valid options: ${valid}`);
184
+ }
185
+ console.log(chalk.dim(`✓ ${key}: ${preProvidedValue} (from CLI)`));
186
+ overlays.push(preProvidedValue);
187
+ choices[key] = preProvidedValue;
188
+ }
189
+ else {
190
+ const selectedOption = (await select({
191
+ message: choice.prompt,
192
+ choices: choice.options.map((opt) => ({
193
+ name: opt,
194
+ value: opt,
195
+ })),
196
+ default: choice.defaultOption,
197
+ }));
198
+ overlays.push(selectedOption);
199
+ choices[key] = selectedOption;
200
+ }
201
+ }
202
+ }
203
+ // Handle parameterized slots (multiple overlays per option)
204
+ if (preset.parameters) {
205
+ for (const [key, param] of Object.entries(preset.parameters)) {
206
+ const preProvidedValue = preProvidedChoices[key];
207
+ let selectedId;
208
+ if (preProvidedValue !== undefined) {
209
+ // Validate the pre-provided value
210
+ const validOption = param.options.find((o) => o.id === preProvidedValue);
211
+ if (!validOption) {
212
+ const valid = param.options.map((o) => o.id).join(', ');
213
+ throw new Error(`Invalid value '${preProvidedValue}' for preset parameter '${key}'. Valid options: ${valid}`);
214
+ }
215
+ console.log(chalk.dim(`✓ ${key}: ${preProvidedValue} (from CLI)`));
216
+ selectedId = preProvidedValue;
217
+ }
218
+ else {
219
+ const description = param.description || `Select ${key}`;
220
+ selectedId = (await select({
221
+ message: description,
222
+ choices: param.options.map((opt) => ({
223
+ name: opt.description ? `${opt.id} - ${opt.description}` : opt.id,
224
+ value: opt.id,
225
+ })),
226
+ default: param.default,
227
+ }));
228
+ }
229
+ // Add overlays for the selected option
230
+ const selectedOption = param.options.find((o) => o.id === selectedId);
231
+ if (selectedOption) {
232
+ overlays.push(...selectedOption.overlays);
233
+ }
234
+ choices[key] = selectedId;
89
235
  }
90
236
  }
91
- console.log(chalk.dim(`✓ Preset will include: ${overlays.join(', ')}\n`));
92
- return { overlays, choices, glueConfig: preset.glueConfig };
237
+ // Deduplicate overlays
238
+ const uniqueOverlays = [...new Set(overlays)];
239
+ console.log(chalk.dim(`✓ Preset will include: ${uniqueOverlays.join(', ')}\n`));
240
+ // Apply template substitution to glueConfig.environment values.
241
+ // Replaces {{parameters.<key>.id}} with the selected choice for <key>.
242
+ let resolvedGlueConfig = preset.glueConfig;
243
+ if (resolvedGlueConfig?.environment) {
244
+ const resolvedEnv = {};
245
+ for (const [envKey, envValue] of Object.entries(resolvedGlueConfig.environment)) {
246
+ resolvedEnv[envKey] = envValue.replace(/\{\{parameters\.(\w+)\.id\}\}/g, (_match, paramKey) => choices[paramKey] ?? _match);
247
+ }
248
+ resolvedGlueConfig = { ...resolvedGlueConfig, environment: resolvedEnv };
249
+ }
250
+ return { overlays: uniqueOverlays, choices, glueConfig: resolvedGlueConfig };
93
251
  }
94
252
  /**
95
253
  * Search for manifest file in multiple locations
@@ -112,6 +270,16 @@ function findManifestFile(manifestPath) {
112
270
  }
113
271
  return null;
114
272
  }
273
+ function findDefaultRegenManifest(outputPath = './.devcontainer') {
274
+ const manifestSearchPaths = ['superposition.json', path.join(outputPath, 'superposition.json')];
275
+ for (const searchPath of manifestSearchPaths) {
276
+ const resolvedPath = path.resolve(searchPath);
277
+ if (fs.existsSync(resolvedPath)) {
278
+ return resolvedPath;
279
+ }
280
+ }
281
+ return null;
282
+ }
115
283
  /**
116
284
  * Load and validate manifest file
117
285
  */
@@ -158,116 +326,6 @@ function loadManifest(manifestPath) {
158
326
  return null;
159
327
  }
160
328
  }
161
- /**
162
- * Create timestamped backup of existing devcontainer and manifest
163
- */
164
- async function createBackup(outputPath, backupDir) {
165
- // Check for devcontainer files to backup
166
- const devcontainerJsonPath = path.join(outputPath, 'devcontainer.json');
167
- const dockerComposePath = path.join(outputPath, 'docker-compose.yml');
168
- const devcontainerSubdir = path.join(outputPath, '.devcontainer');
169
- const manifestPath = path.join(outputPath, 'superposition.json');
170
- // Determine what exists
171
- const hasDevcontainerJson = fs.existsSync(devcontainerJsonPath);
172
- const hasDockerCompose = fs.existsSync(dockerComposePath);
173
- const hasDevcontainerSubdir = fs.existsSync(devcontainerSubdir) && fs.statSync(devcontainerSubdir).isDirectory();
174
- const hasManifest = fs.existsSync(manifestPath);
175
- if (!hasDevcontainerJson && !hasDockerCompose && !hasDevcontainerSubdir && !hasManifest) {
176
- return null; // Nothing to backup
177
- }
178
- // Create timestamp
179
- const timestamp = new Date()
180
- .toISOString()
181
- .replace(/:/g, '-')
182
- .replace(/\..+/, '')
183
- .replace('T', '-');
184
- // Determine backup location - create next to outputPath, not inside it
185
- const resolvedOutputPath = path.resolve(outputPath);
186
- const outputParentDir = path.dirname(resolvedOutputPath);
187
- const outputBaseName = path.basename(resolvedOutputPath);
188
- const backupBaseName = outputBaseName === '.devcontainer' ? '.devcontainer' : outputBaseName;
189
- const backupPath = backupDir
190
- ? path.resolve(backupDir)
191
- : path.join(outputParentDir, `${backupBaseName}.backup-${timestamp}`);
192
- // Create backup directory
193
- fs.mkdirSync(backupPath, { recursive: true });
194
- // Backup files and directories
195
- if (hasDevcontainerJson) {
196
- fs.copyFileSync(devcontainerJsonPath, path.join(backupPath, 'devcontainer.json'));
197
- }
198
- if (hasDockerCompose) {
199
- fs.copyFileSync(dockerComposePath, path.join(backupPath, 'docker-compose.yml'));
200
- }
201
- if (hasDevcontainerSubdir) {
202
- const destDir = path.join(backupPath, '.devcontainer');
203
- await copyDirectory(devcontainerSubdir, destDir);
204
- }
205
- if (hasManifest) {
206
- fs.copyFileSync(manifestPath, path.join(backupPath, 'superposition.json'));
207
- }
208
- // Also backup other common devcontainer files
209
- const otherFiles = ['.env', '.env.example', '.gitignore', 'features', 'scripts'];
210
- for (const file of otherFiles) {
211
- const srcPath = path.join(outputPath, file);
212
- if (fs.existsSync(srcPath)) {
213
- const destPath = path.join(backupPath, file);
214
- if (fs.statSync(srcPath).isDirectory()) {
215
- await copyDirectory(srcPath, destPath);
216
- }
217
- else {
218
- fs.copyFileSync(srcPath, destPath);
219
- }
220
- }
221
- }
222
- return backupPath;
223
- }
224
- /**
225
- * Recursively copy directory
226
- */
227
- async function copyDirectory(src, dest) {
228
- fs.mkdirSync(dest, { recursive: true });
229
- const entries = fs.readdirSync(src, { withFileTypes: true });
230
- for (const entry of entries) {
231
- const srcPath = path.join(src, entry.name);
232
- const destPath = path.join(dest, entry.name);
233
- if (entry.isDirectory()) {
234
- await copyDirectory(srcPath, destPath);
235
- }
236
- else {
237
- fs.copyFileSync(srcPath, destPath);
238
- }
239
- }
240
- }
241
- /**
242
- * Ensure backup patterns are in .gitignore
243
- */
244
- async function ensureBackupPatternsInGitignore(outputPath) {
245
- // Write to the parent directory's .gitignore (project root), not inside outputPath
246
- const resolvedOutputPath = path.resolve(outputPath);
247
- const projectRoot = path.dirname(resolvedOutputPath);
248
- const gitignorePath = path.join(projectRoot, '.gitignore');
249
- const backupPatterns = [
250
- '',
251
- '# Container Superposition backups',
252
- '.devcontainer.backup-*/',
253
- '*.backup-*',
254
- 'superposition.json.backup-*',
255
- ].join('\n');
256
- if (!fs.existsSync(gitignorePath)) {
257
- // Create new .gitignore with backup patterns
258
- await fs.promises.writeFile(gitignorePath, backupPatterns + '\n');
259
- console.log(chalk.dim(' 📝 Created .gitignore with backup patterns'));
260
- }
261
- else {
262
- // Check if patterns already exist
263
- const content = await fs.promises.readFile(gitignorePath, 'utf-8');
264
- if (!content.includes('Container Superposition backups')) {
265
- // Append patterns
266
- await fs.promises.appendFile(gitignorePath, '\n' + backupPatterns + '\n');
267
- console.log(chalk.dim(' 📝 Updated .gitignore with backup patterns'));
268
- }
269
- }
270
- }
271
329
  /**
272
330
  * Build checkbox choices for overlay selection with optional pre-selection
273
331
  */
@@ -297,7 +355,7 @@ function buildOverlayChoices(config, stack, categoryList, preselected) {
297
355
  /**
298
356
  * Interactive questionnaire with modern checkbox selections
299
357
  */
300
- async function runQuestionnaire(manifest, manifestDir) {
358
+ async function runQuestionnaire(manifest, manifestDir, cliPresetId, cliPresetChoices, defaultAnswers) {
301
359
  const config = loadOverlaysConfigWrapper();
302
360
  // Pretty banner
303
361
  console.log('\n' +
@@ -331,32 +389,50 @@ async function runQuestionnaire(manifest, manifestDir) {
331
389
  try {
332
390
  // Question 0: Optional preset selection
333
391
  let usePreset = false;
334
- let selectedPresetId = manifest?.preset;
335
- let presetChoices = manifest?.presetChoices || {};
392
+ // CLI preset takes precedence over manifest preset
393
+ let selectedPresetId = cliPresetId || manifest?.preset;
394
+ // CLI preset choices merged with manifest choices (CLI takes precedence)
395
+ let presetChoices = {
396
+ ...(manifest?.presetChoices || {}),
397
+ ...(cliPresetChoices || {}),
398
+ };
336
399
  let presetGlueConfig;
337
400
  const presetOverlaysFiltered = config.overlays.filter((o) => o.category === 'preset');
338
401
  let presetOverlays = [];
339
402
  if (presetOverlaysFiltered.length > 0) {
340
- const defaultPreset = manifest?.preset || 'custom';
341
- const presetChoice = (await select({
342
- message: 'Start from a preset or build custom?',
343
- choices: [
344
- {
345
- name: 'Custom (select overlays manually)',
346
- value: 'custom',
347
- description: 'Choose individual overlays yourself',
348
- },
349
- ...presetOverlaysFiltered.map((p) => ({
350
- name: p.name,
351
- value: p.id,
352
- description: p.description,
353
- })),
354
- ],
355
- default: defaultPreset,
356
- }));
357
- if (presetChoice !== 'custom') {
403
+ // If a preset was pre-selected via CLI or manifest, skip the prompt
404
+ if (selectedPresetId) {
358
405
  usePreset = true;
359
- selectedPresetId = presetChoice;
406
+ console.log(chalk.cyan(`\n📦 Using preset: ${selectedPresetId}\n`));
407
+ }
408
+ else {
409
+ const defaultPreset = 'custom';
410
+ const presetChoice = (await select({
411
+ message: 'Start from a preset or build custom?',
412
+ choices: [
413
+ {
414
+ name: 'Custom (select overlays manually)',
415
+ value: 'custom',
416
+ description: 'Choose individual overlays yourself',
417
+ },
418
+ ...presetOverlaysFiltered.map((p) => ({
419
+ name: p.name,
420
+ value: p.id,
421
+ description: p.description,
422
+ })),
423
+ ],
424
+ default: defaultPreset,
425
+ }));
426
+ if (presetChoice !== 'custom') {
427
+ usePreset = true;
428
+ selectedPresetId = presetChoice;
429
+ }
430
+ else {
431
+ // User chose custom - discard any pre-provided preset choices so the
432
+ // manifest cannot end up with presetChoices but no preset.
433
+ presetChoices = {};
434
+ selectedPresetId = undefined;
435
+ }
360
436
  }
361
437
  }
362
438
  // Question 1: Base template
@@ -367,11 +443,11 @@ async function runQuestionnaire(manifest, manifestDir) {
367
443
  value: t.id,
368
444
  description: t.description,
369
445
  })),
370
- default: manifest?.baseTemplate,
446
+ default: manifest?.baseTemplate || defaultAnswers?.stack,
371
447
  }));
372
- // If using preset, expand it now
448
+ // If using preset, expand it now (pass pre-provided choices to skip those prompts)
373
449
  if (usePreset && selectedPresetId) {
374
- const expansion = await expandPreset(selectedPresetId, stack);
450
+ const expansion = await expandPreset(selectedPresetId, stack, presetChoices);
375
451
  if (!expansion.overlays || expansion.overlays.length === 0) {
376
452
  // Preset failed to expand (e.g., missing or invalid preset definition).
377
453
  // Treat this as "no preset" so the manifest does not incorrectly record one.
@@ -392,7 +468,15 @@ async function runQuestionnaire(manifest, manifestDir) {
392
468
  // Check if manifest has a custom image or a known base image
393
469
  const knownBaseImageIds = config.base_images.map((img) => img.id);
394
470
  const manifestBaseImageIsKnown = manifest?.baseImage && knownBaseImageIds.includes(manifest.baseImage);
395
- const manifestDefaultBaseImage = manifestBaseImageIsKnown ? manifest.baseImage : 'custom';
471
+ const manifestDefaultBaseImage = manifestBaseImageIsKnown
472
+ ? manifest.baseImage
473
+ : manifest?.baseImage
474
+ ? 'custom'
475
+ : undefined;
476
+ const defaultBaseImage = manifestDefaultBaseImage ||
477
+ (defaultAnswers?.baseImage === 'custom' && defaultAnswers.customImage
478
+ ? 'custom'
479
+ : defaultAnswers?.baseImage);
396
480
  const baseImage = (await select({
397
481
  message: 'Select base image:',
398
482
  choices: config.base_images.map((img) => ({
@@ -400,16 +484,17 @@ async function runQuestionnaire(manifest, manifestDir) {
400
484
  value: img.id,
401
485
  description: img.description,
402
486
  })),
403
- default: manifestDefaultBaseImage,
487
+ default: defaultBaseImage,
404
488
  }));
405
489
  // Question 2a: If custom, ask for image name
406
490
  let customImage;
407
491
  if (baseImage === 'custom') {
408
492
  // If manifest has a custom image, use it as default
409
493
  const manifestCustomImage = !manifestBaseImageIsKnown && manifest?.baseImage ? manifest.baseImage : undefined;
494
+ const defaultCustomImage = manifestCustomImage || defaultAnswers?.customImage;
410
495
  customImage = await input({
411
496
  message: 'Enter custom Docker image (e.g., ubuntu:22.04):',
412
- default: manifestCustomImage,
497
+ default: defaultCustomImage,
413
498
  validate: (value) => {
414
499
  if (!value || value.trim() === '') {
415
500
  return 'Image name is required';
@@ -525,7 +610,15 @@ async function runQuestionnaire(manifest, manifestDir) {
525
610
  else {
526
611
  // Custom mode: Normal overlay selection
527
612
  console.log(chalk.dim('\n💡 Select overlays: Space to toggle, ↑/↓ to navigate, Enter to confirm\n'));
528
- const choices = buildOverlayChoices(config, stack, categoryList, []);
613
+ const preselectedDefaults = [
614
+ ...(defaultAnswers?.language ?? []),
615
+ ...(defaultAnswers?.database ?? []),
616
+ ...(defaultAnswers?.observability ?? []),
617
+ ...(defaultAnswers?.cloudTools ?? []),
618
+ ...(defaultAnswers?.devTools ?? []),
619
+ ...(defaultAnswers?.playwright ? ['playwright'] : []),
620
+ ];
621
+ const choices = buildOverlayChoices(config, stack, categoryList, preselectedDefaults);
529
622
  userSelection = await checkbox({
530
623
  message: 'Select overlays to include:',
531
624
  choices,
@@ -602,11 +695,11 @@ async function runQuestionnaire(manifest, manifestDir) {
602
695
  // Question 4: Container name
603
696
  const containerName = await input({
604
697
  message: 'Container/project name (optional):',
605
- default: manifest?.containerName || '',
698
+ default: manifest?.containerName || defaultAnswers?.containerName || '',
606
699
  });
607
700
  // Question 5: Output path
608
701
  // If manifest provided, default to its location; otherwise use ./.devcontainer
609
- const defaultOutput = manifestDir || './.devcontainer';
702
+ const defaultOutput = manifestDir || defaultAnswers?.outputPath || './.devcontainer';
610
703
  const outputPath = await input({
611
704
  message: 'Output path:',
612
705
  default: defaultOutput,
@@ -614,7 +707,11 @@ async function runQuestionnaire(manifest, manifestDir) {
614
707
  // Question 6: Port offset (optional, for running multiple instances)
615
708
  const portOffsetInput = await input({
616
709
  message: 'Port offset (leave empty for default ports, e.g., 100 to avoid conflicts):',
617
- default: manifest?.portOffset ? String(manifest.portOffset) : '',
710
+ default: manifest?.portOffset !== undefined
711
+ ? String(manifest.portOffset)
712
+ : defaultAnswers?.portOffset !== undefined
713
+ ? String(defaultAnswers.portOffset)
714
+ : '',
618
715
  });
619
716
  const portOffset = portOffsetInput ? parseInt(portOffsetInput, 10) : undefined;
620
717
  // Parse selected overlays into categories
@@ -709,7 +806,10 @@ async function runQuestionnaire(manifest, manifestDir) {
709
806
  observability,
710
807
  outputPath,
711
808
  portOffset,
712
- target,
809
+ target: target ?? defaultAnswers?.target,
810
+ minimal: defaultAnswers?.minimal,
811
+ editor: defaultAnswers?.editor,
812
+ customizations: defaultAnswers?.customizations,
713
813
  };
714
814
  }
715
815
  catch (error) {
@@ -879,9 +979,10 @@ async function parseCliArgs() {
879
979
  program
880
980
  .command('init', { isDefault: true })
881
981
  .description('Initialize a new devcontainer configuration')
982
+ .option('--from-project', 'Load configuration from the repository project file')
882
983
  .option('--from-manifest <path>', 'Load configuration from existing superposition.json manifest')
883
- .option('--no-interactive', 'Use manifest values directly without questionnaire (requires --from-manifest)')
884
- .option('--no-backup', 'Skip creating backup before regeneration')
984
+ .option('--no-interactive', 'Use persisted input values directly without questionnaire')
985
+ .option('--backup', 'Force or suppress backup; default is --no-backup inside a git repo, --backup outside')
885
986
  .option('--backup-dir <path>', 'Custom backup directory location')
886
987
  .option('--stack <type>', 'Base template: plain, compose')
887
988
  .option('--language <list>', 'Comma-separated language overlays: dotnet, nodejs, python, mkdocs, java, go, rust, bun, powershell')
@@ -896,47 +997,34 @@ async function parseCliArgs() {
896
997
  .option('--editor <profile>', 'Editor profile: vscode (default), jetbrains, none', 'vscode')
897
998
  .option('-o, --output <path>', 'Output path (default: ./.devcontainer)')
898
999
  .option('--write-manifest-only', 'Generate only superposition.json manifest without creating .devcontainer/ files')
899
- .action((options) => {
1000
+ .option('--preset <id>', 'Start from a preset (e.g., web-api, microservice)')
1001
+ .option('--preset-param <value>', 'Set a preset parameter value (format: key=value, can be repeated)', (value, previous) => previous.concat([value]), [])
1002
+ .action((options, command) => {
900
1003
  // Store options for main() to process
901
- initOptions = options;
1004
+ initOptions = {
1005
+ ...options,
1006
+ commandName: 'init',
1007
+ _targetSource: command.getOptionValueSource('target'),
1008
+ _editorSource: command.getOptionValueSource('editor'),
1009
+ };
902
1010
  });
903
1011
  // Regen command
904
1012
  program
905
1013
  .command('regen')
906
- .description('Regenerate devcontainer from existing superposition.json manifest')
1014
+ .description('Regenerate devcontainer from a project file or existing superposition.json manifest')
1015
+ .option('--from-project', 'Load configuration from the repository project file')
1016
+ .option('--from-manifest <path>', 'Load configuration from existing superposition.json manifest')
907
1017
  .option('-o, --output <path>', 'Output path (default: ./.devcontainer)')
908
- .option('--no-backup', 'Skip creating backup before regeneration')
1018
+ .option('--backup', 'Force or suppress backup; default is --no-backup inside a git repo, --backup outside')
909
1019
  .option('--backup-dir <path>', 'Custom backup directory location')
910
1020
  .option('--minimal', 'Minimal mode - exclude optional/nice-to-have features and extensions')
911
1021
  .option('--editor <profile>', 'Editor profile: vscode (default), jetbrains, none', 'vscode')
912
- .action((options) => {
913
- const outputPath = options.output || './.devcontainer';
914
- // Look for manifest in multiple locations for team workflow:
915
- // 1. Current directory (./superposition.json) - team workflow
916
- // 2. Output directory (e.g., ./.devcontainer/superposition.json) - legacy
917
- const manifestSearchPaths = [
918
- 'superposition.json',
919
- path.join(outputPath, 'superposition.json'),
920
- ];
921
- let manifestPath = null;
922
- for (const searchPath of manifestSearchPaths) {
923
- if (fs.existsSync(searchPath)) {
924
- manifestPath = searchPath;
925
- break;
926
- }
927
- }
928
- if (!manifestPath) {
929
- console.error(chalk.red(`✗ Error: No manifest found`));
930
- console.error(chalk.gray(' Searched for: ./superposition.json, ' +
931
- path.join(outputPath, 'superposition.json')));
932
- console.error(chalk.gray(' Run "container-superposition init --write-manifest-only" to create a manifest'));
933
- process.exit(1);
934
- }
935
- // Store options for main() to process
1022
+ .action((options, command) => {
936
1023
  initOptions = {
937
1024
  ...options,
938
- fromManifest: manifestPath,
1025
+ commandName: 'regen',
939
1026
  interactive: false,
1027
+ _editorSource: command.getOptionValueSource('editor'),
940
1028
  };
941
1029
  });
942
1030
  // List command
@@ -966,9 +1054,15 @@ async function parseCliArgs() {
966
1054
  program
967
1055
  .command('plan')
968
1056
  .description('Preview what will be generated before creating devcontainer')
969
- .option('--stack <type>', 'Base template: plain, compose', 'compose')
1057
+ .option('--stack <type>', 'Base template: plain, compose')
970
1058
  .option('--overlays <list>', 'Comma-separated list of overlay IDs')
1059
+ .option('--from-manifest <path>', 'Load stack and overlays from an existing superposition.json manifest')
971
1060
  .option('--port-offset <number>', 'Add offset to all exposed ports', (val) => parseInt(val, 10), 0)
1061
+ .option('-o, --output <path>', 'Compare against existing config at this path (default: ./.devcontainer)')
1062
+ .option('--diff', 'Compare planned output vs existing configuration')
1063
+ .option('--diff-format <format>', 'Diff output format: color (default), json', 'color')
1064
+ .option('--diff-context <lines>', 'Context lines in diff output', (val) => parseInt(val, 10), 3)
1065
+ .option('--verbose', 'Explain why each overlay was included in the resolved plan')
972
1066
  .option('--json', 'Output as JSON for scripting')
973
1067
  .action(async (options) => {
974
1068
  const overlaysConfig = loadOverlaysConfigWrapper();
@@ -986,6 +1080,39 @@ async function parseCliArgs() {
986
1080
  const overlaysConfig = loadOverlaysConfigWrapper();
987
1081
  await doctorCommand(overlaysConfig, OVERLAYS_DIR, options);
988
1082
  });
1083
+ // Adopt command
1084
+ program
1085
+ .command('adopt')
1086
+ .description('Analyse an existing .devcontainer/ and suggest an equivalent overlay-based configuration')
1087
+ .option('-d, --dir <path>', 'Path to the existing .devcontainer directory (default: ./.devcontainer)')
1088
+ .option('--dry-run', 'Print analysis and suggested command only; no files written')
1089
+ .option('--force', 'Overwrite existing superposition.json if present')
1090
+ .option('--backup', 'Force backup creation even when inside a git repo (default: backup only outside git repos)')
1091
+ .option('--no-backup', 'Disable backup creation even when it would normally be performed')
1092
+ .option('--backup-dir <path>', 'Custom backup directory location')
1093
+ .option('--json', 'Output as JSON for scripting')
1094
+ .action(async (options) => {
1095
+ const overlaysConfig = loadOverlaysConfigWrapper();
1096
+ await adoptCommand(overlaysConfig, OVERLAYS_DIR, options);
1097
+ process.exit(0);
1098
+ });
1099
+ // Hash command
1100
+ program
1101
+ .command('hash')
1102
+ .description('Compute a deterministic fingerprint for a given configuration')
1103
+ .option('--stack <type>', 'Base template: plain, compose')
1104
+ .option('--overlays <list>', 'Comma-separated list of overlay IDs')
1105
+ .option('--preset <id>', 'Preset ID (optional)')
1106
+ .option('--base <image>', 'Base image / distro variant (e.g. bookworm, alpine)')
1107
+ .option('--manifest <path>', 'Path to superposition.json manifest')
1108
+ .option('-o, --output <path>', 'Directory to write hash file (used with --write)')
1109
+ .option('--write', 'Write hash to .devcontainer/superposition.hash')
1110
+ .option('--json', 'Output as JSON for scripting')
1111
+ .action(async (options) => {
1112
+ const overlaysConfig = loadOverlaysConfigWrapper();
1113
+ await hashCommand(overlaysConfig, OVERLAYS_DIR, options);
1114
+ process.exit(0);
1115
+ });
989
1116
  await program.parseAsync(process.argv);
990
1117
  // If init or regen command was run, return the options
991
1118
  if (!initOptions) {
@@ -996,6 +1123,34 @@ async function parseCliArgs() {
996
1123
  if (Object.keys(initOptions).length === 0) {
997
1124
  return null;
998
1125
  }
1126
+ const hasSourceFlags = Number(Boolean(initOptions.fromProject)) + Number(Boolean(initOptions.fromManifest));
1127
+ if (hasSourceFlags > 1) {
1128
+ console.error(chalk.red('✗ Error: --from-project and --from-manifest cannot be used together'));
1129
+ process.exit(1);
1130
+ }
1131
+ const sourceSelectionConflicts = [
1132
+ 'stack',
1133
+ 'language',
1134
+ 'database',
1135
+ 'observability',
1136
+ 'playwright',
1137
+ 'cloudTools',
1138
+ 'devTools',
1139
+ 'portOffset',
1140
+ 'preset',
1141
+ ];
1142
+ const hasPresetParams = Array.isArray(initOptions.presetParam) && initOptions.presetParam.length > 0;
1143
+ const conflictingSelectionFlags = sourceSelectionConflicts.filter((key) => initOptions[key] !== undefined && initOptions[key] !== false);
1144
+ if ((initOptions.fromProject || initOptions.fromManifest) &&
1145
+ (conflictingSelectionFlags.length > 0 || hasPresetParams)) {
1146
+ const conflicts = [...conflictingSelectionFlags.map((key) => `--${key}`)];
1147
+ if (hasPresetParams) {
1148
+ conflicts.push('--preset-param');
1149
+ }
1150
+ console.error(chalk.red(`✗ Error: Persisted input sources cannot be combined with clean-generation selection flags: ${conflicts.join(', ')}`));
1151
+ console.error(chalk.dim(' Choose either a persisted input source (--from-project or --from-manifest) or direct selection flags for that run.'));
1152
+ process.exit(1);
1153
+ }
999
1154
  const config = {};
1000
1155
  if (initOptions.stack)
1001
1156
  config.stack = initOptions.stack;
@@ -1027,13 +1182,13 @@ async function parseCliArgs() {
1027
1182
  if (initOptions.portOffset) {
1028
1183
  config.portOffset = parseInt(initOptions.portOffset, 10);
1029
1184
  }
1030
- if (initOptions.target) {
1185
+ if (initOptions.target && initOptions._targetSource !== 'default') {
1031
1186
  config.target = initOptions.target;
1032
1187
  }
1033
1188
  if (initOptions.minimal) {
1034
1189
  config.minimal = true;
1035
1190
  }
1036
- if (initOptions.editor) {
1191
+ if (initOptions.editor && initOptions._editorSource !== 'default') {
1037
1192
  const editorLower = initOptions.editor.toLowerCase();
1038
1193
  if (['vscode', 'jetbrains', 'none'].includes(editorLower)) {
1039
1194
  config.editor = editorLower;
@@ -1045,10 +1200,42 @@ async function parseCliArgs() {
1045
1200
  }
1046
1201
  if (initOptions.output)
1047
1202
  config.outputPath = initOptions.output;
1203
+ // Handle --preset flag
1204
+ if (initOptions.preset) {
1205
+ config.preset = initOptions.preset;
1206
+ }
1207
+ // Handle --preset-param flags (can be repeated)
1208
+ if (initOptions.presetParam && initOptions.presetParam.length > 0) {
1209
+ if (!initOptions.preset) {
1210
+ console.warn(chalk.yellow('⚠️ Ignoring --preset-param because no --preset was provided. ' +
1211
+ 'Preset parameters only apply when a preset is selected (e.g., --preset web-api --preset-param broker=nats).'));
1212
+ }
1213
+ else {
1214
+ const presetChoices = {};
1215
+ for (const param of initOptions.presetParam) {
1216
+ const eqIdx = param.indexOf('=');
1217
+ if (eqIdx > 0) {
1218
+ const key = param.slice(0, eqIdx).trim();
1219
+ const value = param.slice(eqIdx + 1).trim();
1220
+ if (key) {
1221
+ presetChoices[key] = value;
1222
+ }
1223
+ }
1224
+ else {
1225
+ console.warn(chalk.yellow(`⚠️ Invalid --preset-param format: "${param}". Expected "key=value" (e.g., --preset-param broker=nats).`));
1226
+ }
1227
+ }
1228
+ if (Object.keys(presetChoices).length > 0) {
1229
+ config.presetChoices = presetChoices;
1230
+ }
1231
+ }
1232
+ }
1048
1233
  return {
1234
+ commandName: initOptions.commandName,
1049
1235
  config,
1050
1236
  manifestPath: initOptions.fromManifest,
1051
- noBackup: initOptions.backup === false, // Commander creates options.backup = false for --no-backup
1237
+ fromProject: initOptions.fromProject === true,
1238
+ backupOverride: initOptions.backup, // undefined = auto-detect; true = --backup; false = --no-backup
1052
1239
  backupDir: initOptions.backupDir,
1053
1240
  noInteractive: initOptions.interactive === false, // Commander creates options.interactive = false for --no-interactive
1054
1241
  writeManifestOnly: initOptions.writeManifestOnly === true,
@@ -1057,17 +1244,34 @@ async function parseCliArgs() {
1057
1244
  async function main() {
1058
1245
  try {
1059
1246
  const cliArgs = await parseCliArgs();
1060
- // Validate --no-interactive requires --from-manifest
1061
- if (cliArgs?.noInteractive && !cliArgs?.manifestPath) {
1062
- console.error(chalk.red('✗ Error: --no-interactive requires --from-manifest'));
1063
- console.error(chalk.dim(' Use both flags together: --from-manifest <path> --no-interactive'));
1064
- process.exit(1);
1247
+ let projectConfig = undefined;
1248
+ let projectConfigAnswers;
1249
+ if (!cliArgs?.manifestPath) {
1250
+ projectConfig =
1251
+ loadProjectConfig(loadOverlaysConfigWrapper(), process.cwd()) ?? undefined;
1252
+ if (projectConfig) {
1253
+ projectConfigAnswers = applyPresetSelections(buildAnswersFromProjectConfig(projectConfig.selection));
1254
+ }
1065
1255
  }
1066
1256
  let manifest;
1067
1257
  let manifestDir;
1068
- let shouldBackup = true;
1069
1258
  let backupDir;
1070
1259
  let useManifestOnly = false;
1260
+ let useProjectOnly = false;
1261
+ if (cliArgs?.commandName === 'regen' && !cliArgs.manifestPath && !cliArgs.fromProject) {
1262
+ if (projectConfigAnswers) {
1263
+ useProjectOnly = true;
1264
+ }
1265
+ else {
1266
+ const discoveredManifestPath = findDefaultRegenManifest(cliArgs?.config?.outputPath || './.devcontainer');
1267
+ if (!discoveredManifestPath) {
1268
+ console.error(chalk.red('✗ Error: No project file or manifest found'));
1269
+ console.error(chalk.gray(' Looked for .superposition.yml or superposition.yml in the repository root, and superposition.json in common manifest locations.'));
1270
+ process.exit(1);
1271
+ }
1272
+ cliArgs.manifestPath = discoveredManifestPath;
1273
+ }
1274
+ }
1071
1275
  // Handle manifest loading
1072
1276
  if (cliArgs?.manifestPath) {
1073
1277
  const manifestPath = findManifestFile(cliArgs.manifestPath);
@@ -1082,10 +1286,7 @@ async function main() {
1082
1286
  process.exit(1);
1083
1287
  }
1084
1288
  manifest = loadedManifest;
1085
- // Check for backup and interaction options
1086
- if (cliArgs.noBackup) {
1087
- shouldBackup = false;
1088
- }
1289
+ // Check for interaction options
1089
1290
  if (cliArgs.backupDir) {
1090
1291
  backupDir = cliArgs.backupDir;
1091
1292
  }
@@ -1093,22 +1294,72 @@ async function main() {
1093
1294
  useManifestOnly = true;
1094
1295
  }
1095
1296
  }
1297
+ if (cliArgs?.fromProject) {
1298
+ if (!projectConfigAnswers || !projectConfig) {
1299
+ console.error(chalk.red('✗ Could not find project file'));
1300
+ console.error(chalk.red(' Searched for: .superposition.yml, superposition.yml'));
1301
+ process.exit(1);
1302
+ }
1303
+ useProjectOnly = cliArgs.noInteractive || cliArgs.commandName === 'regen';
1304
+ }
1305
+ // Validate --no-interactive requires a persisted input source
1306
+ if (cliArgs?.noInteractive && !cliArgs?.manifestPath && !projectConfigAnswers) {
1307
+ console.error(chalk.red('✗ Error: --no-interactive requires persisted input'));
1308
+ console.error(chalk.dim(' Use --from-project, --from-manifest <path>, or run from a repository with .superposition.yml or superposition.yml'));
1309
+ process.exit(1);
1310
+ }
1311
+ // Determine whether to create a backup:
1312
+ // --backup → always backup
1313
+ // --no-backup → never backup
1314
+ // (neither) → backup only when NOT inside a git repository
1315
+ // (git already tracks history, so backups are redundant)
1316
+ const resolvedOutputPath = cliArgs?.config?.outputPath ||
1317
+ projectConfigAnswers?.outputPath ||
1318
+ manifestDir ||
1319
+ './.devcontainer';
1320
+ const backupCheckPath = path.resolve(resolvedOutputPath);
1321
+ const inGitRepo = isInsideGitRepo(backupCheckPath);
1322
+ let shouldBackup;
1323
+ if (cliArgs?.backupOverride === true) {
1324
+ shouldBackup = true;
1325
+ }
1326
+ else if (cliArgs?.backupOverride === false) {
1327
+ shouldBackup = false;
1328
+ }
1329
+ else {
1330
+ // Auto-detect based on git presence
1331
+ shouldBackup = !inGitRepo;
1332
+ if (!shouldBackup) {
1333
+ console.log(chalk.dim('ℹ Skipping backup — git repo detected (use --backup to force one)\n'));
1334
+ }
1335
+ }
1336
+ const isReplayMode = cliArgs?.commandName === 'regen' || useManifestOnly || useProjectOnly;
1096
1337
  // Create backup if needed
1097
- if (shouldBackup && manifest) {
1098
- // Output path is the directory containing the manifest
1099
- const outputPath = manifestDir || './.devcontainer';
1338
+ let actualBackupPath;
1339
+ if (shouldBackup && isReplayMode) {
1340
+ const outputPath = resolvedOutputPath;
1100
1341
  const backupPath = await createBackup(outputPath, backupDir);
1101
1342
  if (backupPath) {
1343
+ actualBackupPath = backupPath;
1102
1344
  console.log(chalk.green(`✓ Backup created: ${backupPath}\n`));
1103
- await ensureBackupPatternsInGitignore(outputPath);
1345
+ ensureBackupPatternsInGitignore(outputPath);
1104
1346
  }
1105
1347
  }
1106
1348
  // Build answers based on mode
1107
1349
  let answers;
1108
- // Check if there are CLI overrides beyond just output path
1350
+ // Check if there are CLI overrides beyond just output path and preset flags
1351
+ // Preset/presetChoices alone don't constitute "CLI overrides" that bypass interactive mode
1109
1352
  const hasCliOverrides = cliArgs &&
1110
1353
  Object.keys(cliArgs.config).some((key) => key !== 'outputPath' &&
1354
+ key !== 'preset' &&
1355
+ key !== 'presetChoices' &&
1356
+ !(key === 'target' && cliArgs.config.target === 'local') &&
1357
+ !(key === 'editor' && cliArgs.config.editor === 'vscode') &&
1111
1358
  cliArgs.config[key] !== undefined);
1359
+ const hasAnyCliConfig = cliArgs &&
1360
+ Object.entries(cliArgs.config).some(([key, value]) => value !== undefined &&
1361
+ !(key === 'target' && value === 'local') &&
1362
+ !(key === 'editor' && value === 'vscode'));
1112
1363
  if (useManifestOnly && manifest && !hasCliOverrides) {
1113
1364
  // Mode 1: Manifest-only (--from-manifest --no-interactive, no CLI overrides)
1114
1365
  const manifestAnswers = buildAnswersFromManifest(manifest, manifestDir);
@@ -1128,19 +1379,44 @@ async function main() {
1128
1379
  : '') +
1129
1380
  chalk.gray(` Output: ${answers.outputPath}`), { padding: 1, borderColor: 'cyan', borderStyle: 'round', margin: 1 }));
1130
1381
  }
1131
- else if (cliArgs && (cliArgs.config.stack || hasCliOverrides)) {
1382
+ else if (useProjectOnly && projectConfigAnswers && !hasCliOverrides) {
1383
+ const projectFileName = projectConfig?.file.fileName ?? '.superposition.yml';
1384
+ answers = mergeAnswers(projectConfigAnswers, {
1385
+ outputPath: cliArgs?.config?.outputPath ||
1386
+ projectConfigAnswers.outputPath ||
1387
+ './.devcontainer',
1388
+ minimal: cliArgs?.config?.minimal,
1389
+ editor: cliArgs?.config?.editor,
1390
+ });
1391
+ console.log('\n' +
1392
+ boxen(chalk.bold.cyan('Regenerating from Project File (No Interactive)\n\n') +
1393
+ chalk.white('Configuration:\n') +
1394
+ chalk.gray(` Project file: ${projectFileName}\n`) +
1395
+ chalk.gray(` Output: ${answers.outputPath}`), {
1396
+ padding: 1,
1397
+ borderColor: 'cyan',
1398
+ borderStyle: 'round',
1399
+ margin: 1,
1400
+ }));
1401
+ }
1402
+ else if ((cliArgs && (cliArgs.config.stack || hasCliOverrides)) ||
1403
+ (projectConfigAnswers && (cliArgs?.noInteractive || hasAnyCliConfig))) {
1132
1404
  // Mode 2: CLI-based (with optional manifest defaults)
1133
1405
  // This includes regen with --minimal or --editor flags
1134
1406
  const cliAnswers = buildAnswersFromCliArgs(cliArgs.config);
1135
1407
  const manifestAnswers = manifest
1136
1408
  ? buildAnswersFromManifest(manifest, manifestDir)
1137
1409
  : undefined;
1138
- answers = mergeAnswers(manifestAnswers, cliAnswers, {
1139
- outputPath: cliAnswers.outputPath || './.devcontainer',
1410
+ answers = mergeAnswers(projectConfigAnswers, manifestAnswers, cliAnswers, {
1411
+ outputPath: cliAnswers.outputPath || projectConfigAnswers?.outputPath || './.devcontainer',
1140
1412
  });
1141
1413
  const modeLabel = useManifestOnly && hasCliOverrides
1142
1414
  ? 'Regenerating from Manifest with Overrides'
1143
- : 'Running in CLI mode';
1415
+ : useProjectOnly && projectConfigAnswers
1416
+ ? 'Regenerating from Project File with Overrides'
1417
+ : projectConfigAnswers && !manifest
1418
+ ? 'Running from Project Config'
1419
+ : 'Running in CLI mode';
1144
1420
  console.log('\n' +
1145
1421
  boxen(chalk.bold(modeLabel), {
1146
1422
  padding: 0.5,
@@ -1148,7 +1424,7 @@ async function main() {
1148
1424
  borderStyle: 'round',
1149
1425
  }));
1150
1426
  // Show what's being overridden
1151
- if (useManifestOnly && hasCliOverrides) {
1427
+ if ((useManifestOnly || useProjectOnly) && hasCliOverrides) {
1152
1428
  const overrides = [];
1153
1429
  if (cliAnswers.minimal)
1154
1430
  overrides.push('minimal mode');
@@ -1160,9 +1436,16 @@ async function main() {
1160
1436
  }
1161
1437
  }
1162
1438
  else {
1163
- // Mode 3: Interactive (with optional manifest pre-population)
1164
- const interactiveAnswers = await runQuestionnaire(manifest, manifestDir);
1165
- answers = mergeAnswers(interactiveAnswers);
1439
+ // Mode 3: Interactive (with optional manifest pre-population and CLI preset pre-selection)
1440
+ const interactiveAnswers = await runQuestionnaire(manifest, manifestDir, cliArgs?.config.preset || projectConfigAnswers?.preset, cliArgs?.config.presetChoices || projectConfigAnswers?.presetChoices, projectConfigAnswers);
1441
+ answers = mergeAnswers(projectConfigAnswers, interactiveAnswers);
1442
+ }
1443
+ if (!manifest && projectConfig?.selection.customizations) {
1444
+ const materializedOutputPath = path.resolve(answers.outputPath);
1445
+ if (!fs.existsSync(materializedOutputPath)) {
1446
+ fs.mkdirSync(materializedOutputPath, { recursive: true });
1447
+ }
1448
+ writeProjectConfigCustomizations(materializedOutputPath, projectConfig.selection.customizations);
1166
1449
  }
1167
1450
  // Show configuration summary
1168
1451
  const summaryLines = [
@@ -1182,6 +1465,9 @@ async function main() {
1182
1465
  if (answers.cloudTools && answers.cloudTools.length > 0) {
1183
1466
  summaryLines.push(chalk.cyan('Cloud tools: ') + chalk.white(answers.cloudTools.join(', ')));
1184
1467
  }
1468
+ if (projectConfig?.file && !manifest) {
1469
+ summaryLines.push(chalk.cyan('Project config: ') + chalk.white(projectConfig.file.fileName));
1470
+ }
1185
1471
  summaryLines.push(chalk.cyan('Output: ') + chalk.white(answers.outputPath));
1186
1472
  console.log('\n' +
1187
1473
  boxen(summaryLines.join('\n'), {
@@ -1200,40 +1486,28 @@ async function main() {
1200
1486
  color: 'cyan',
1201
1487
  }).start();
1202
1488
  try {
1489
+ let summary;
1203
1490
  if (isManifestOnly) {
1204
- await generateManifestOnly(answers);
1491
+ summary = await generateManifestOnly(answers, undefined, {
1492
+ isRegen: isReplayMode,
1493
+ });
1205
1494
  spinner.succeed(chalk.green('Manifest created successfully!'));
1206
1495
  }
1207
1496
  else {
1208
- await composeDevContainer(answers);
1497
+ summary = await composeDevContainer(answers, undefined, { isRegen: isReplayMode });
1209
1498
  spinner.succeed(chalk.green('DevContainer created successfully!'));
1210
1499
  }
1500
+ // Update summary with backup path and regen status
1501
+ if (actualBackupPath) {
1502
+ summary.backupPath = actualBackupPath;
1503
+ }
1504
+ // Print comprehensive summary
1505
+ printSummary(summary);
1211
1506
  }
1212
1507
  catch (error) {
1213
1508
  spinner.fail(chalk.red(isManifestOnly ? 'Failed to create manifest' : 'Failed to create devcontainer'));
1214
1509
  throw error;
1215
1510
  }
1216
- // Success message
1217
- const successMessage = isManifestOnly
1218
- ? chalk.bold.green('✓ Manifest Created!\n\n') +
1219
- chalk.white('Next steps:\n') +
1220
- chalk.gray(' 1. Review the generated superposition.json file\n') +
1221
- chalk.gray(' 2. Commit it to your repository\n') +
1222
- chalk.gray(' 3. Team members can run "npx container-superposition regen"\n\n') +
1223
- chalk.dim('Team workflow: commit manifest, .gitignore .devcontainer/, customize with .devcontainer/custom/')
1224
- : chalk.bold.green('✓ Setup Complete!\n\n') +
1225
- chalk.white('Next steps:\n') +
1226
- chalk.gray(' 1. Review the generated .devcontainer/ folder\n') +
1227
- chalk.gray(" 2. Customize as needed (it's just normal JSON!)\n") +
1228
- chalk.gray(' 3. Open in VS Code and rebuild container\n\n') +
1229
- chalk.dim('The generated configuration is fully editable and independent of this tool.');
1230
- console.log('\n' +
1231
- boxen(successMessage, {
1232
- padding: 1,
1233
- borderColor: 'green',
1234
- borderStyle: 'double',
1235
- margin: 1,
1236
- }));
1237
1511
  }
1238
1512
  catch (error) {
1239
1513
  console.error('\n' +