container-superposition 0.1.4 → 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 (90) hide show
  1. package/README.md +72 -1370
  2. package/dist/scripts/init.js +333 -185
  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/hash.d.ts +36 -0
  9. package/dist/tool/commands/hash.d.ts.map +1 -0
  10. package/dist/tool/commands/hash.js +242 -0
  11. package/dist/tool/commands/hash.js.map +1 -0
  12. package/dist/tool/commands/plan.d.ts +2 -0
  13. package/dist/tool/commands/plan.d.ts.map +1 -1
  14. package/dist/tool/commands/plan.js +262 -42
  15. package/dist/tool/commands/plan.js.map +1 -1
  16. package/dist/tool/schema/project-config.d.ts +15 -0
  17. package/dist/tool/schema/project-config.d.ts.map +1 -0
  18. package/dist/tool/schema/project-config.js +359 -0
  19. package/dist/tool/schema/project-config.js.map +1 -0
  20. package/dist/tool/schema/types.d.ts +39 -1
  21. package/dist/tool/schema/types.d.ts.map +1 -1
  22. package/dist/tool/utils/backup.d.ts +23 -0
  23. package/dist/tool/utils/backup.d.ts.map +1 -0
  24. package/dist/tool/utils/backup.js +123 -0
  25. package/dist/tool/utils/backup.js.map +1 -0
  26. package/docs/README.md +12 -2
  27. package/docs/adopt.md +196 -0
  28. package/docs/custom-patches.md +1 -1
  29. package/docs/discovery-commands.md +55 -3
  30. package/docs/examples.md +40 -6
  31. package/docs/filesystem-contract.md +58 -0
  32. package/docs/hash.md +183 -0
  33. package/docs/minimal-and-editor.md +1 -1
  34. package/docs/overlays.md +60 -0
  35. package/docs/presets-architecture.md +1 -1
  36. package/docs/presets.md +1 -1
  37. package/docs/publishing.md +36 -23
  38. package/docs/security.md +43 -0
  39. package/docs/specs/001-verbose-plan-graph/checklists/requirements.md +36 -0
  40. package/docs/specs/001-verbose-plan-graph/contracts/plan-verbose-output.md +96 -0
  41. package/docs/specs/001-verbose-plan-graph/data-model.md +111 -0
  42. package/docs/specs/001-verbose-plan-graph/plan.md +127 -0
  43. package/docs/specs/001-verbose-plan-graph/quickstart.md +106 -0
  44. package/docs/specs/001-verbose-plan-graph/research.md +100 -0
  45. package/docs/specs/001-verbose-plan-graph/spec.md +128 -0
  46. package/docs/specs/001-verbose-plan-graph/tasks.md +223 -0
  47. package/docs/specs/002-superposition-config-file/checklists/requirements.md +36 -0
  48. package/docs/specs/002-superposition-config-file/contracts/init-project-config.md +98 -0
  49. package/docs/specs/002-superposition-config-file/data-model.md +126 -0
  50. package/docs/specs/002-superposition-config-file/plan.md +208 -0
  51. package/docs/specs/002-superposition-config-file/quickstart.md +140 -0
  52. package/docs/specs/002-superposition-config-file/research.md +144 -0
  53. package/docs/specs/002-superposition-config-file/spec.md +130 -0
  54. package/docs/specs/002-superposition-config-file/tasks.md +213 -0
  55. package/docs/team-workflow.md +27 -1
  56. package/docs/workflows.md +136 -0
  57. package/overlays/.presets/sdd.yml +84 -0
  58. package/overlays/README.md +7 -1
  59. package/overlays/amp/README.md +70 -0
  60. package/overlays/amp/devcontainer.patch.json +3 -0
  61. package/overlays/amp/overlay.yml +15 -0
  62. package/overlays/amp/setup.sh +21 -0
  63. package/overlays/amp/verify.sh +21 -0
  64. package/overlays/claude-code/README.md +83 -0
  65. package/overlays/claude-code/devcontainer.patch.json +3 -0
  66. package/overlays/claude-code/overlay.yml +15 -0
  67. package/overlays/claude-code/setup.sh +21 -0
  68. package/overlays/claude-code/verify.sh +21 -0
  69. package/overlays/gemini-cli/README.md +77 -0
  70. package/overlays/gemini-cli/devcontainer.patch.json +3 -0
  71. package/overlays/gemini-cli/overlay.yml +15 -0
  72. package/overlays/gemini-cli/setup.sh +21 -0
  73. package/overlays/gemini-cli/verify.sh +21 -0
  74. package/overlays/opencode/README.md +76 -0
  75. package/overlays/opencode/devcontainer.patch.json +3 -0
  76. package/overlays/opencode/overlay.yml +14 -0
  77. package/overlays/opencode/setup.sh +21 -0
  78. package/overlays/opencode/verify.sh +21 -0
  79. package/overlays/spec-kit/README.md +181 -0
  80. package/overlays/spec-kit/devcontainer.patch.json +6 -0
  81. package/overlays/spec-kit/overlay.yml +19 -0
  82. package/overlays/spec-kit/setup.sh +45 -0
  83. package/overlays/spec-kit/verify.sh +33 -0
  84. package/overlays/windsurf-cli/README.md +69 -0
  85. package/overlays/windsurf-cli/devcontainer.patch.json +3 -0
  86. package/overlays/windsurf-cli/overlay.yml +15 -0
  87. package/overlays/windsurf-cli/setup.sh +21 -0
  88. package/overlays/windsurf-cli/verify.sh +21 -0
  89. package/package.json +1 -1
  90. package/tool/schema/config.schema.json +138 -9
@@ -2,7 +2,6 @@
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';
6
5
  import { Command } from 'commander';
7
6
  import chalk from 'chalk';
8
7
  import boxen from 'boxen';
@@ -15,11 +14,14 @@ import { listCommand } from '../tool/commands/list.js';
15
14
  import { explainCommand } from '../tool/commands/explain.js';
16
15
  import { planCommand } from '../tool/commands/plan.js';
17
16
  import { doctorCommand } from '../tool/commands/doctor.js';
17
+ import { adoptCommand } from '../tool/commands/adopt.js';
18
+ import { hashCommand } from '../tool/commands/hash.js';
18
19
  import { getIncompatibleOverlays, DEPLOYMENT_TARGETS } from '../tool/schema/deployment-targets.js';
19
20
  import { migrateManifest, needsMigration, detectManifestVersion, isVersionSupported, CURRENT_MANIFEST_VERSION, SUPPORTED_MANIFEST_VERSIONS, } from '../tool/schema/manifest-migrations.js';
20
21
  import { getToolVersion } from '../tool/utils/version.js';
21
22
  import { printSummary } from '../tool/utils/summary.js';
22
- import { appendGitignoreSection } from '../tool/utils/gitignore.js';
23
+ import { buildAnswersFromProjectConfig, loadProjectConfig, writeProjectConfigCustomizations, } from '../tool/schema/project-config.js';
24
+ import { isInsideGitRepo, createBackup, ensureBackupPatternsInGitignore, } from '../tool/utils/backup.js';
23
25
  // Get __dirname equivalent in ESM
24
26
  const __filename = fileURLToPath(import.meta.url);
25
27
  const __dirname = path.dirname(__filename);
@@ -65,6 +67,100 @@ function loadPresetDefinition(presetId) {
65
67
  const content = fs.readFileSync(presetPath, 'utf8');
66
68
  return yaml.load(content);
67
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
+ }
68
164
  /**
69
165
  * Expand a preset into a list of overlay IDs with user choices resolved
70
166
  */
@@ -141,7 +237,17 @@ async function expandPreset(presetId, stack, preProvidedChoices = {}) {
141
237
  // Deduplicate overlays
142
238
  const uniqueOverlays = [...new Set(overlays)];
143
239
  console.log(chalk.dim(`✓ Preset will include: ${uniqueOverlays.join(', ')}\n`));
144
- return { overlays: uniqueOverlays, choices, glueConfig: preset.glueConfig };
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 };
145
251
  }
146
252
  /**
147
253
  * Search for manifest file in multiple locations
@@ -164,6 +270,16 @@ function findManifestFile(manifestPath) {
164
270
  }
165
271
  return null;
166
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
+ }
167
283
  /**
168
284
  * Load and validate manifest file
169
285
  */
@@ -210,127 +326,6 @@ function loadManifest(manifestPath) {
210
326
  return null;
211
327
  }
212
328
  }
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
- }
239
- /**
240
- * Create timestamped backup of existing devcontainer and manifest
241
- */
242
- async function createBackup(outputPath, backupDir) {
243
- // Check for devcontainer files to backup
244
- const devcontainerJsonPath = path.join(outputPath, 'devcontainer.json');
245
- const dockerComposePath = path.join(outputPath, 'docker-compose.yml');
246
- const devcontainerSubdir = path.join(outputPath, '.devcontainer');
247
- const manifestPath = path.join(outputPath, 'superposition.json');
248
- // Determine what exists
249
- const hasDevcontainerJson = fs.existsSync(devcontainerJsonPath);
250
- const hasDockerCompose = fs.existsSync(dockerComposePath);
251
- const hasDevcontainerSubdir = fs.existsSync(devcontainerSubdir) && fs.statSync(devcontainerSubdir).isDirectory();
252
- const hasManifest = fs.existsSync(manifestPath);
253
- if (!hasDevcontainerJson && !hasDockerCompose && !hasDevcontainerSubdir && !hasManifest) {
254
- return null; // Nothing to backup
255
- }
256
- // Create timestamp
257
- const timestamp = new Date()
258
- .toISOString()
259
- .replace(/:/g, '-')
260
- .replace(/\..+/, '')
261
- .replace('T', '-');
262
- // Determine backup location - create next to outputPath, not inside it
263
- const resolvedOutputPath = path.resolve(outputPath);
264
- const outputParentDir = path.dirname(resolvedOutputPath);
265
- const outputBaseName = path.basename(resolvedOutputPath);
266
- const backupBaseName = outputBaseName === '.devcontainer' ? '.devcontainer' : outputBaseName;
267
- const backupPath = backupDir
268
- ? path.resolve(backupDir)
269
- : path.join(outputParentDir, `${backupBaseName}.backup-${timestamp}`);
270
- // Create backup directory
271
- fs.mkdirSync(backupPath, { recursive: true });
272
- // Backup files and directories
273
- if (hasDevcontainerJson) {
274
- fs.copyFileSync(devcontainerJsonPath, path.join(backupPath, 'devcontainer.json'));
275
- }
276
- if (hasDockerCompose) {
277
- fs.copyFileSync(dockerComposePath, path.join(backupPath, 'docker-compose.yml'));
278
- }
279
- if (hasDevcontainerSubdir) {
280
- const destDir = path.join(backupPath, '.devcontainer');
281
- await copyDirectory(devcontainerSubdir, destDir);
282
- }
283
- if (hasManifest) {
284
- fs.copyFileSync(manifestPath, path.join(backupPath, 'superposition.json'));
285
- }
286
- // Also backup other common devcontainer files
287
- const otherFiles = ['.env', '.env.example', '.gitignore', 'features', 'scripts'];
288
- for (const file of otherFiles) {
289
- const srcPath = path.join(outputPath, file);
290
- if (fs.existsSync(srcPath)) {
291
- const destPath = path.join(backupPath, file);
292
- if (fs.statSync(srcPath).isDirectory()) {
293
- await copyDirectory(srcPath, destPath);
294
- }
295
- else {
296
- fs.copyFileSync(srcPath, destPath);
297
- }
298
- }
299
- }
300
- return backupPath;
301
- }
302
- /**
303
- * Recursively copy directory
304
- */
305
- async function copyDirectory(src, dest) {
306
- fs.mkdirSync(dest, { recursive: true });
307
- const entries = fs.readdirSync(src, { withFileTypes: true });
308
- for (const entry of entries) {
309
- const srcPath = path.join(src, entry.name);
310
- const destPath = path.join(dest, entry.name);
311
- if (entry.isDirectory()) {
312
- await copyDirectory(srcPath, destPath);
313
- }
314
- else {
315
- fs.copyFileSync(srcPath, destPath);
316
- }
317
- }
318
- }
319
- /**
320
- * Ensure backup patterns are in .gitignore
321
- */
322
- function ensureBackupPatternsInGitignore(outputPath) {
323
- const projectRoot = path.dirname(path.resolve(outputPath));
324
- const gitignorePath = path.join(projectRoot, '.gitignore');
325
- const written = appendGitignoreSection(gitignorePath, 'container-superposition backups', [
326
- '.devcontainer.backup-*/',
327
- '*.backup-*',
328
- 'superposition.json.backup-*',
329
- ]);
330
- if (written) {
331
- console.log(chalk.dim(' 📝 Updated .gitignore with backup patterns'));
332
- }
333
- }
334
329
  /**
335
330
  * Build checkbox choices for overlay selection with optional pre-selection
336
331
  */
@@ -360,7 +355,7 @@ function buildOverlayChoices(config, stack, categoryList, preselected) {
360
355
  /**
361
356
  * Interactive questionnaire with modern checkbox selections
362
357
  */
363
- async function runQuestionnaire(manifest, manifestDir, cliPresetId, cliPresetChoices) {
358
+ async function runQuestionnaire(manifest, manifestDir, cliPresetId, cliPresetChoices, defaultAnswers) {
364
359
  const config = loadOverlaysConfigWrapper();
365
360
  // Pretty banner
366
361
  console.log('\n' +
@@ -448,7 +443,7 @@ async function runQuestionnaire(manifest, manifestDir, cliPresetId, cliPresetCho
448
443
  value: t.id,
449
444
  description: t.description,
450
445
  })),
451
- default: manifest?.baseTemplate,
446
+ default: manifest?.baseTemplate || defaultAnswers?.stack,
452
447
  }));
453
448
  // If using preset, expand it now (pass pre-provided choices to skip those prompts)
454
449
  if (usePreset && selectedPresetId) {
@@ -473,7 +468,15 @@ async function runQuestionnaire(manifest, manifestDir, cliPresetId, cliPresetCho
473
468
  // Check if manifest has a custom image or a known base image
474
469
  const knownBaseImageIds = config.base_images.map((img) => img.id);
475
470
  const manifestBaseImageIsKnown = manifest?.baseImage && knownBaseImageIds.includes(manifest.baseImage);
476
- 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);
477
480
  const baseImage = (await select({
478
481
  message: 'Select base image:',
479
482
  choices: config.base_images.map((img) => ({
@@ -481,16 +484,17 @@ async function runQuestionnaire(manifest, manifestDir, cliPresetId, cliPresetCho
481
484
  value: img.id,
482
485
  description: img.description,
483
486
  })),
484
- default: manifestDefaultBaseImage,
487
+ default: defaultBaseImage,
485
488
  }));
486
489
  // Question 2a: If custom, ask for image name
487
490
  let customImage;
488
491
  if (baseImage === 'custom') {
489
492
  // If manifest has a custom image, use it as default
490
493
  const manifestCustomImage = !manifestBaseImageIsKnown && manifest?.baseImage ? manifest.baseImage : undefined;
494
+ const defaultCustomImage = manifestCustomImage || defaultAnswers?.customImage;
491
495
  customImage = await input({
492
496
  message: 'Enter custom Docker image (e.g., ubuntu:22.04):',
493
- default: manifestCustomImage,
497
+ default: defaultCustomImage,
494
498
  validate: (value) => {
495
499
  if (!value || value.trim() === '') {
496
500
  return 'Image name is required';
@@ -606,7 +610,15 @@ async function runQuestionnaire(manifest, manifestDir, cliPresetId, cliPresetCho
606
610
  else {
607
611
  // Custom mode: Normal overlay selection
608
612
  console.log(chalk.dim('\n💡 Select overlays: Space to toggle, ↑/↓ to navigate, Enter to confirm\n'));
609
- 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);
610
622
  userSelection = await checkbox({
611
623
  message: 'Select overlays to include:',
612
624
  choices,
@@ -683,11 +695,11 @@ async function runQuestionnaire(manifest, manifestDir, cliPresetId, cliPresetCho
683
695
  // Question 4: Container name
684
696
  const containerName = await input({
685
697
  message: 'Container/project name (optional):',
686
- default: manifest?.containerName || '',
698
+ default: manifest?.containerName || defaultAnswers?.containerName || '',
687
699
  });
688
700
  // Question 5: Output path
689
701
  // If manifest provided, default to its location; otherwise use ./.devcontainer
690
- const defaultOutput = manifestDir || './.devcontainer';
702
+ const defaultOutput = manifestDir || defaultAnswers?.outputPath || './.devcontainer';
691
703
  const outputPath = await input({
692
704
  message: 'Output path:',
693
705
  default: defaultOutput,
@@ -695,7 +707,11 @@ async function runQuestionnaire(manifest, manifestDir, cliPresetId, cliPresetCho
695
707
  // Question 6: Port offset (optional, for running multiple instances)
696
708
  const portOffsetInput = await input({
697
709
  message: 'Port offset (leave empty for default ports, e.g., 100 to avoid conflicts):',
698
- default: manifest?.portOffset ? String(manifest.portOffset) : '',
710
+ default: manifest?.portOffset !== undefined
711
+ ? String(manifest.portOffset)
712
+ : defaultAnswers?.portOffset !== undefined
713
+ ? String(defaultAnswers.portOffset)
714
+ : '',
699
715
  });
700
716
  const portOffset = portOffsetInput ? parseInt(portOffsetInput, 10) : undefined;
701
717
  // Parse selected overlays into categories
@@ -790,7 +806,10 @@ async function runQuestionnaire(manifest, manifestDir, cliPresetId, cliPresetCho
790
806
  observability,
791
807
  outputPath,
792
808
  portOffset,
793
- target,
809
+ target: target ?? defaultAnswers?.target,
810
+ minimal: defaultAnswers?.minimal,
811
+ editor: defaultAnswers?.editor,
812
+ customizations: defaultAnswers?.customizations,
794
813
  };
795
814
  }
796
815
  catch (error) {
@@ -960,8 +979,9 @@ async function parseCliArgs() {
960
979
  program
961
980
  .command('init', { isDefault: true })
962
981
  .description('Initialize a new devcontainer configuration')
982
+ .option('--from-project', 'Load configuration from the repository project file')
963
983
  .option('--from-manifest <path>', 'Load configuration from existing superposition.json manifest')
964
- .option('--no-interactive', 'Use manifest values directly without questionnaire (requires --from-manifest)')
984
+ .option('--no-interactive', 'Use persisted input values directly without questionnaire')
965
985
  .option('--backup', 'Force or suppress backup; default is --no-backup inside a git repo, --backup outside')
966
986
  .option('--backup-dir <path>', 'Custom backup directory location')
967
987
  .option('--stack <type>', 'Base template: plain, compose')
@@ -979,47 +999,32 @@ async function parseCliArgs() {
979
999
  .option('--write-manifest-only', 'Generate only superposition.json manifest without creating .devcontainer/ files')
980
1000
  .option('--preset <id>', 'Start from a preset (e.g., web-api, microservice)')
981
1001
  .option('--preset-param <value>', 'Set a preset parameter value (format: key=value, can be repeated)', (value, previous) => previous.concat([value]), [])
982
- .action((options) => {
1002
+ .action((options, command) => {
983
1003
  // Store options for main() to process
984
- initOptions = options;
1004
+ initOptions = {
1005
+ ...options,
1006
+ commandName: 'init',
1007
+ _targetSource: command.getOptionValueSource('target'),
1008
+ _editorSource: command.getOptionValueSource('editor'),
1009
+ };
985
1010
  });
986
1011
  // Regen command
987
1012
  program
988
1013
  .command('regen')
989
- .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')
990
1017
  .option('-o, --output <path>', 'Output path (default: ./.devcontainer)')
991
1018
  .option('--backup', 'Force or suppress backup; default is --no-backup inside a git repo, --backup outside')
992
1019
  .option('--backup-dir <path>', 'Custom backup directory location')
993
1020
  .option('--minimal', 'Minimal mode - exclude optional/nice-to-have features and extensions')
994
1021
  .option('--editor <profile>', 'Editor profile: vscode (default), jetbrains, none', 'vscode')
995
- .action((options) => {
996
- const outputPath = options.output || './.devcontainer';
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'));
1016
- process.exit(1);
1017
- }
1018
- // Store options for main() to process
1022
+ .action((options, command) => {
1019
1023
  initOptions = {
1020
1024
  ...options,
1021
- fromManifest: manifestPath,
1025
+ commandName: 'regen',
1022
1026
  interactive: false,
1027
+ _editorSource: command.getOptionValueSource('editor'),
1023
1028
  };
1024
1029
  });
1025
1030
  // List command
@@ -1049,13 +1054,15 @@ async function parseCliArgs() {
1049
1054
  program
1050
1055
  .command('plan')
1051
1056
  .description('Preview what will be generated before creating devcontainer')
1052
- .option('--stack <type>', 'Base template: plain, compose', 'compose')
1057
+ .option('--stack <type>', 'Base template: plain, compose')
1053
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')
1054
1060
  .option('--port-offset <number>', 'Add offset to all exposed ports', (val) => parseInt(val, 10), 0)
1055
1061
  .option('-o, --output <path>', 'Compare against existing config at this path (default: ./.devcontainer)')
1056
1062
  .option('--diff', 'Compare planned output vs existing configuration')
1057
1063
  .option('--diff-format <format>', 'Diff output format: color (default), json', 'color')
1058
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')
1059
1066
  .option('--json', 'Output as JSON for scripting')
1060
1067
  .action(async (options) => {
1061
1068
  const overlaysConfig = loadOverlaysConfigWrapper();
@@ -1073,6 +1080,39 @@ async function parseCliArgs() {
1073
1080
  const overlaysConfig = loadOverlaysConfigWrapper();
1074
1081
  await doctorCommand(overlaysConfig, OVERLAYS_DIR, options);
1075
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
+ });
1076
1116
  await program.parseAsync(process.argv);
1077
1117
  // If init or regen command was run, return the options
1078
1118
  if (!initOptions) {
@@ -1083,6 +1123,34 @@ async function parseCliArgs() {
1083
1123
  if (Object.keys(initOptions).length === 0) {
1084
1124
  return null;
1085
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
+ }
1086
1154
  const config = {};
1087
1155
  if (initOptions.stack)
1088
1156
  config.stack = initOptions.stack;
@@ -1114,13 +1182,13 @@ async function parseCliArgs() {
1114
1182
  if (initOptions.portOffset) {
1115
1183
  config.portOffset = parseInt(initOptions.portOffset, 10);
1116
1184
  }
1117
- if (initOptions.target) {
1185
+ if (initOptions.target && initOptions._targetSource !== 'default') {
1118
1186
  config.target = initOptions.target;
1119
1187
  }
1120
1188
  if (initOptions.minimal) {
1121
1189
  config.minimal = true;
1122
1190
  }
1123
- if (initOptions.editor) {
1191
+ if (initOptions.editor && initOptions._editorSource !== 'default') {
1124
1192
  const editorLower = initOptions.editor.toLowerCase();
1125
1193
  if (['vscode', 'jetbrains', 'none'].includes(editorLower)) {
1126
1194
  config.editor = editorLower;
@@ -1163,8 +1231,10 @@ async function parseCliArgs() {
1163
1231
  }
1164
1232
  }
1165
1233
  return {
1234
+ commandName: initOptions.commandName,
1166
1235
  config,
1167
1236
  manifestPath: initOptions.fromManifest,
1237
+ fromProject: initOptions.fromProject === true,
1168
1238
  backupOverride: initOptions.backup, // undefined = auto-detect; true = --backup; false = --no-backup
1169
1239
  backupDir: initOptions.backupDir,
1170
1240
  noInteractive: initOptions.interactive === false, // Commander creates options.interactive = false for --no-interactive
@@ -1174,16 +1244,34 @@ async function parseCliArgs() {
1174
1244
  async function main() {
1175
1245
  try {
1176
1246
  const cliArgs = await parseCliArgs();
1177
- // Validate --no-interactive requires --from-manifest
1178
- if (cliArgs?.noInteractive && !cliArgs?.manifestPath) {
1179
- console.error(chalk.red('✗ Error: --no-interactive requires --from-manifest'));
1180
- console.error(chalk.dim(' Use both flags together: --from-manifest <path> --no-interactive'));
1181
- 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
+ }
1182
1255
  }
1183
1256
  let manifest;
1184
1257
  let manifestDir;
1185
1258
  let backupDir;
1186
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
+ }
1187
1275
  // Handle manifest loading
1188
1276
  if (cliArgs?.manifestPath) {
1189
1277
  const manifestPath = findManifestFile(cliArgs.manifestPath);
@@ -1206,12 +1294,30 @@ async function main() {
1206
1294
  useManifestOnly = true;
1207
1295
  }
1208
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
+ }
1209
1311
  // Determine whether to create a backup:
1210
1312
  // --backup → always backup
1211
1313
  // --no-backup → never backup
1212
1314
  // (neither) → backup only when NOT inside a git repository
1213
1315
  // (git already tracks history, so backups are redundant)
1214
- const backupCheckPath = path.resolve(cliArgs?.config?.outputPath || manifestDir || './.devcontainer');
1316
+ const resolvedOutputPath = cliArgs?.config?.outputPath ||
1317
+ projectConfigAnswers?.outputPath ||
1318
+ manifestDir ||
1319
+ './.devcontainer';
1320
+ const backupCheckPath = path.resolve(resolvedOutputPath);
1215
1321
  const inGitRepo = isInsideGitRepo(backupCheckPath);
1216
1322
  let shouldBackup;
1217
1323
  if (cliArgs?.backupOverride === true) {
@@ -1227,11 +1333,11 @@ async function main() {
1227
1333
  console.log(chalk.dim('ℹ Skipping backup — git repo detected (use --backup to force one)\n'));
1228
1334
  }
1229
1335
  }
1336
+ const isReplayMode = cliArgs?.commandName === 'regen' || useManifestOnly || useProjectOnly;
1230
1337
  // Create backup if needed
1231
1338
  let actualBackupPath;
1232
- if (shouldBackup && manifest) {
1233
- // Output path is the directory containing the manifest
1234
- const outputPath = manifestDir || './.devcontainer';
1339
+ if (shouldBackup && isReplayMode) {
1340
+ const outputPath = resolvedOutputPath;
1235
1341
  const backupPath = await createBackup(outputPath, backupDir);
1236
1342
  if (backupPath) {
1237
1343
  actualBackupPath = backupPath;
@@ -1241,14 +1347,19 @@ async function main() {
1241
1347
  }
1242
1348
  // Build answers based on mode
1243
1349
  let answers;
1244
- const isRegen = !!manifest;
1245
1350
  // Check if there are CLI overrides beyond just output path and preset flags
1246
1351
  // Preset/presetChoices alone don't constitute "CLI overrides" that bypass interactive mode
1247
1352
  const hasCliOverrides = cliArgs &&
1248
1353
  Object.keys(cliArgs.config).some((key) => key !== 'outputPath' &&
1249
1354
  key !== 'preset' &&
1250
1355
  key !== 'presetChoices' &&
1356
+ !(key === 'target' && cliArgs.config.target === 'local') &&
1357
+ !(key === 'editor' && cliArgs.config.editor === 'vscode') &&
1251
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'));
1252
1363
  if (useManifestOnly && manifest && !hasCliOverrides) {
1253
1364
  // Mode 1: Manifest-only (--from-manifest --no-interactive, no CLI overrides)
1254
1365
  const manifestAnswers = buildAnswersFromManifest(manifest, manifestDir);
@@ -1268,19 +1379,44 @@ async function main() {
1268
1379
  : '') +
1269
1380
  chalk.gray(` Output: ${answers.outputPath}`), { padding: 1, borderColor: 'cyan', borderStyle: 'round', margin: 1 }));
1270
1381
  }
1271
- 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))) {
1272
1404
  // Mode 2: CLI-based (with optional manifest defaults)
1273
1405
  // This includes regen with --minimal or --editor flags
1274
1406
  const cliAnswers = buildAnswersFromCliArgs(cliArgs.config);
1275
1407
  const manifestAnswers = manifest
1276
1408
  ? buildAnswersFromManifest(manifest, manifestDir)
1277
1409
  : undefined;
1278
- answers = mergeAnswers(manifestAnswers, cliAnswers, {
1279
- outputPath: cliAnswers.outputPath || './.devcontainer',
1410
+ answers = mergeAnswers(projectConfigAnswers, manifestAnswers, cliAnswers, {
1411
+ outputPath: cliAnswers.outputPath || projectConfigAnswers?.outputPath || './.devcontainer',
1280
1412
  });
1281
1413
  const modeLabel = useManifestOnly && hasCliOverrides
1282
1414
  ? 'Regenerating from Manifest with Overrides'
1283
- : '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';
1284
1420
  console.log('\n' +
1285
1421
  boxen(chalk.bold(modeLabel), {
1286
1422
  padding: 0.5,
@@ -1288,7 +1424,7 @@ async function main() {
1288
1424
  borderStyle: 'round',
1289
1425
  }));
1290
1426
  // Show what's being overridden
1291
- if (useManifestOnly && hasCliOverrides) {
1427
+ if ((useManifestOnly || useProjectOnly) && hasCliOverrides) {
1292
1428
  const overrides = [];
1293
1429
  if (cliAnswers.minimal)
1294
1430
  overrides.push('minimal mode');
@@ -1301,8 +1437,15 @@ async function main() {
1301
1437
  }
1302
1438
  else {
1303
1439
  // 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);
1305
- answers = mergeAnswers(interactiveAnswers);
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);
1306
1449
  }
1307
1450
  // Show configuration summary
1308
1451
  const summaryLines = [
@@ -1322,6 +1465,9 @@ async function main() {
1322
1465
  if (answers.cloudTools && answers.cloudTools.length > 0) {
1323
1466
  summaryLines.push(chalk.cyan('Cloud tools: ') + chalk.white(answers.cloudTools.join(', ')));
1324
1467
  }
1468
+ if (projectConfig?.file && !manifest) {
1469
+ summaryLines.push(chalk.cyan('Project config: ') + chalk.white(projectConfig.file.fileName));
1470
+ }
1325
1471
  summaryLines.push(chalk.cyan('Output: ') + chalk.white(answers.outputPath));
1326
1472
  console.log('\n' +
1327
1473
  boxen(summaryLines.join('\n'), {
@@ -1342,11 +1488,13 @@ async function main() {
1342
1488
  try {
1343
1489
  let summary;
1344
1490
  if (isManifestOnly) {
1345
- summary = await generateManifestOnly(answers, undefined, { isRegen });
1491
+ summary = await generateManifestOnly(answers, undefined, {
1492
+ isRegen: isReplayMode,
1493
+ });
1346
1494
  spinner.succeed(chalk.green('Manifest created successfully!'));
1347
1495
  }
1348
1496
  else {
1349
- summary = await composeDevContainer(answers, undefined, { isRegen });
1497
+ summary = await composeDevContainer(answers, undefined, { isRegen: isReplayMode });
1350
1498
  spinner.succeed(chalk.green('DevContainer created successfully!'));
1351
1499
  }
1352
1500
  // Update summary with backup path and regen status