container-superposition 0.1.5 → 0.1.7

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 (155) hide show
  1. package/README.md +3 -1
  2. package/dist/scripts/init.js +24 -4
  3. package/dist/scripts/init.js.map +1 -1
  4. package/dist/tool/commands/adopt.d.ts +3 -2
  5. package/dist/tool/commands/adopt.d.ts.map +1 -1
  6. package/dist/tool/commands/adopt.js +378 -67
  7. package/dist/tool/commands/adopt.js.map +1 -1
  8. package/dist/tool/commands/doctor.d.ts +3 -0
  9. package/dist/tool/commands/doctor.d.ts.map +1 -1
  10. package/dist/tool/commands/doctor.js +932 -69
  11. package/dist/tool/commands/doctor.js.map +1 -1
  12. package/dist/tool/commands/explain.d.ts.map +1 -1
  13. package/dist/tool/commands/explain.js +9 -0
  14. package/dist/tool/commands/explain.js.map +1 -1
  15. package/dist/tool/questionnaire/composer.d.ts.map +1 -1
  16. package/dist/tool/questionnaire/composer.js +212 -11
  17. package/dist/tool/questionnaire/composer.js.map +1 -1
  18. package/dist/tool/schema/overlay-loader.d.ts.map +1 -1
  19. package/dist/tool/schema/overlay-loader.js +1 -0
  20. package/dist/tool/schema/overlay-loader.js.map +1 -1
  21. package/dist/tool/schema/project-config.d.ts +3 -1
  22. package/dist/tool/schema/project-config.d.ts.map +1 -1
  23. package/dist/tool/schema/project-config.js +164 -13
  24. package/dist/tool/schema/project-config.js.map +1 -1
  25. package/dist/tool/schema/types.d.ts +85 -11
  26. package/dist/tool/schema/types.d.ts.map +1 -1
  27. package/dist/tool/utils/merge.d.ts.map +1 -1
  28. package/dist/tool/utils/merge.js +9 -0
  29. package/dist/tool/utils/merge.js.map +1 -1
  30. package/docs/adopt.md +20 -14
  31. package/docs/creating-overlays.md +151 -2
  32. package/docs/overlay-imports.md +125 -102
  33. package/docs/overlays.md +59 -6
  34. package/docs/quick-reference.md +99 -0
  35. package/docs/specs/002-superposition-config-file/plan.md +6 -1
  36. package/docs/specs/002-superposition-config-file/spec.md +6 -0
  37. package/docs/specs/002-superposition-config-file/tasks.md +2 -0
  38. package/docs/specs/003-mkdocs2-overlay/spec.md +114 -0
  39. package/docs/specs/004-doctor-fix/spec.md +70 -0
  40. package/docs/specs/005-cuda-overlay/spec.md +101 -0
  41. package/docs/specs/006-rocm-overlay/spec.md +109 -0
  42. package/docs/team-workflow.md +7 -1
  43. package/docs/workflows.md +3 -0
  44. package/features/cross-distro-packages/README.md +18 -0
  45. package/features/cross-distro-packages/devcontainer-feature.json +3 -3
  46. package/features/cross-distro-packages/install.sh +49 -7
  47. package/overlays/.shared/README.md +80 -21
  48. package/overlays/.shared/compose/common-healthchecks.md +60 -0
  49. package/overlays/.shared/vscode/recommended-extensions.json +15 -11
  50. package/overlays/alertmanager/setup.sh +4 -19
  51. package/overlays/alertmanager/verify.sh +8 -9
  52. package/overlays/all/README.md +43 -0
  53. package/overlays/all/devcontainer.patch.json +6 -0
  54. package/overlays/all/overlay.yml +14 -0
  55. package/overlays/amp/setup.sh +5 -0
  56. package/overlays/bun/setup.sh +10 -1
  57. package/overlays/bun/verify.sh +6 -1
  58. package/overlays/claude-code/setup.sh +5 -0
  59. package/overlays/cloudflared/setup.sh +9 -12
  60. package/overlays/codex/README.md +9 -6
  61. package/overlays/codex/devcontainer.patch.json +7 -1
  62. package/overlays/codex/setup.sh +5 -0
  63. package/overlays/codex/verify.sh +8 -0
  64. package/overlays/commitlint/setup.sh +5 -0
  65. package/overlays/cuda/README.md +179 -0
  66. package/overlays/cuda/devcontainer.patch.json +7 -0
  67. package/overlays/cuda/overlay.yml +17 -0
  68. package/overlays/cuda/setup.sh +32 -0
  69. package/overlays/cuda/verify.sh +38 -0
  70. package/overlays/devcontainer-cli/README.md +50 -0
  71. package/overlays/devcontainer-cli/devcontainer.patch.json +13 -0
  72. package/overlays/devcontainer-cli/overlay.yml +16 -0
  73. package/overlays/devcontainer-cli/setup.sh +14 -0
  74. package/overlays/direnv/devcontainer.patch.json +6 -0
  75. package/overlays/direnv/setup.sh +7 -6
  76. package/overlays/dotnet/setup.sh +14 -7
  77. package/overlays/duckdb/devcontainer.patch.json +1 -2
  78. package/overlays/gcloud/devcontainer.patch.json +0 -6
  79. package/overlays/gcloud/setup.sh +51 -0
  80. package/overlays/gemini-cli/setup.sh +5 -0
  81. package/overlays/git-helpers/devcontainer.patch.json +2 -1
  82. package/overlays/go/setup.sh +15 -14
  83. package/overlays/jaeger/overlay.yml +2 -0
  84. package/overlays/just/setup.sh +5 -17
  85. package/overlays/keycloak/docker-compose.yml +6 -4
  86. package/overlays/keycloak/verify.sh +4 -3
  87. package/overlays/kind/devcontainer.patch.json +1 -2
  88. package/overlays/kind/setup.sh +8 -17
  89. package/overlays/minio/setup.sh +10 -18
  90. package/overlays/mkdocs/overlay.yml +2 -1
  91. package/overlays/mkdocs2/README.md +135 -0
  92. package/overlays/mkdocs2/devcontainer.patch.json +19 -0
  93. package/overlays/mkdocs2/overlay.yml +17 -0
  94. package/overlays/mkdocs2/setup.sh +67 -0
  95. package/overlays/mkdocs2/verify.sh +35 -0
  96. package/overlays/modern-cli-tools/devcontainer.patch.json +7 -1
  97. package/overlays/modern-cli-tools/setup.sh +21 -71
  98. package/overlays/mongodb/devcontainer.patch.json +0 -6
  99. package/overlays/mongodb/setup.sh +59 -0
  100. package/overlays/mysql/verify.sh +4 -3
  101. package/overlays/nats/.env.example +1 -1
  102. package/overlays/nats/README.md +1 -1
  103. package/overlays/nats/docker-compose.yml +1 -1
  104. package/overlays/ngrok/setup.sh +9 -6
  105. package/overlays/nodejs/setup.sh +5 -0
  106. package/overlays/openapi-tools/devcontainer.patch.json +1 -2
  107. package/overlays/openapi-tools/setup.sh +9 -8
  108. package/overlays/opencode/setup.sh +5 -0
  109. package/overlays/otel-collector/overlay.yml +2 -0
  110. package/overlays/otel-collector/setup.sh +3 -16
  111. package/overlays/otel-demo-nodejs/verify.sh +8 -9
  112. package/overlays/otel-demo-python/verify.sh +16 -10
  113. package/overlays/pandoc/README.md +286 -0
  114. package/overlays/pandoc/devcontainer.patch.json +18 -0
  115. package/overlays/pandoc/overlay.yml +19 -0
  116. package/overlays/pandoc/setup.sh +293 -0
  117. package/overlays/pandoc/verify.sh +25 -0
  118. package/overlays/playwright/devcontainer.patch.json +3 -1
  119. package/overlays/playwright/setup.sh +37 -0
  120. package/overlays/postgres/docker-compose.yml +6 -0
  121. package/overlays/powershell/setup.sh +49 -13
  122. package/overlays/pre-commit/setup.sh +12 -3
  123. package/overlays/prometheus/overlay.yml +2 -0
  124. package/overlays/promtail/verify.sh +16 -10
  125. package/overlays/pulumi/devcontainer.patch.json +1 -1
  126. package/overlays/python/setup.sh +28 -9
  127. package/overlays/python/verify.sh +4 -2
  128. package/overlays/redpanda/docker-compose.yml +3 -5
  129. package/overlays/rocm/README.md +227 -0
  130. package/overlays/rocm/devcontainer.patch.json +4 -0
  131. package/overlays/rocm/overlay.yml +17 -0
  132. package/overlays/rocm/setup.sh +45 -0
  133. package/overlays/rocm/verify.sh +47 -0
  134. package/overlays/rust/setup.sh +11 -18
  135. package/overlays/spec-kit/setup.sh +7 -3
  136. package/overlays/sqlite/setup.sh +14 -14
  137. package/overlays/sqlserver/docker-compose.yml +3 -3
  138. package/overlays/sqlserver/verify.sh +22 -5
  139. package/overlays/tempo/verify.sh +16 -10
  140. package/overlays/tilt/devcontainer.patch.json +1 -2
  141. package/overlays/tilt/setup.sh +14 -4
  142. package/overlays/windsurf-cli/setup.sh +27 -4
  143. package/overlays/windsurf-cli/verify.sh +13 -3
  144. package/package.json +2 -1
  145. package/templates/scripts/setup-utils.sh +228 -0
  146. package/tool/schema/config.schema.json +110 -8
  147. package/tool/schema/overlay-manifest.schema.json +5 -0
  148. package/overlays/.shared/compose/common-healthchecks.yml +0 -38
  149. /package/overlays/otel-demo-nodejs/{Dockerfile-otel-demo-nodejs → Dockerfile} +0 -0
  150. /package/overlays/otel-demo-nodejs/{package-otel-demo-nodejs.json → package.json} +0 -0
  151. /package/overlays/otel-demo-nodejs/{server-otel-demo-nodejs.js → server.js} +0 -0
  152. /package/overlays/otel-demo-nodejs/{tracing-otel-demo-nodejs.js → tracing.js} +0 -0
  153. /package/overlays/otel-demo-python/{Dockerfile-otel-demo-python → Dockerfile} +0 -0
  154. /package/overlays/otel-demo-python/{app-otel-demo-python.py → app.py} +0 -0
  155. /package/overlays/otel-demo-python/{requirements-otel-demo-python.txt → requirements.txt} +0 -0
@@ -12,11 +12,21 @@ import boxen from 'boxen';
12
12
  import yaml from 'js-yaml';
13
13
  import { confirm } from '@inquirer/prompts';
14
14
  import { CURRENT_MANIFEST_VERSION } from '../schema/manifest-migrations.js';
15
+ import { findProjectConfig, writeProjectConfig } from '../schema/project-config.js';
16
+ import { applyOverlay } from '../questionnaire/composer.js';
17
+ import { deepMerge } from '../utils/merge.js';
15
18
  import { getToolVersion } from '../utils/version.js';
16
19
  import { isInsideGitRepo, createBackup, ensureBackupPatternsInGitignore } from '../utils/backup.js';
17
20
  // Get __dirname equivalent in ESM
18
21
  const __filename = fileURLToPath(import.meta.url);
19
22
  const __dirname = path.dirname(__filename);
23
+ const REPO_ROOT_CANDIDATES = [
24
+ path.join(__dirname, '..', '..'),
25
+ path.join(__dirname, '..', '..', '..'),
26
+ ];
27
+ const REPO_ROOT = REPO_ROOT_CANDIDATES.find((candidate) => fs.existsSync(path.join(candidate, 'templates')) &&
28
+ fs.existsSync(path.join(candidate, 'overlays'))) ?? REPO_ROOT_CANDIDATES[0];
29
+ const TEMPLATES_DIR = path.join(REPO_ROOT, 'templates');
20
30
  /**
21
31
  * Strip the version suffix from a devcontainer feature URI so that prefix
22
32
  * matching works regardless of pinned version.
@@ -118,6 +128,16 @@ export function buildDetectionTables(overlaysDir, overlaysConfig) {
118
128
  const lc = extId.toLowerCase();
119
129
  const score = extensionMatchScore(overlay.id, lc);
120
130
  const existing = extensionToOverlayScored[lc];
131
+ if (score === SCORE_NO_MATCH) {
132
+ if (!existing) {
133
+ extensionToOverlayScored[lc] = { overlayId: overlay.id, score };
134
+ }
135
+ else if (existing.score === SCORE_NO_MATCH &&
136
+ existing.overlayId !== overlay.id) {
137
+ delete extensionToOverlayScored[lc];
138
+ }
139
+ continue;
140
+ }
121
141
  if (!existing || score > existing.score) {
122
142
  extensionToOverlayScored[lc] = { overlayId: overlay.id, score };
123
143
  }
@@ -260,6 +280,18 @@ function analyseDockerCompose(composePaths, tables) {
260
280
  }
261
281
  for (const [serviceName, serviceDef] of Object.entries(parsed?.services ?? {})) {
262
282
  const image = serviceDef?.image ?? '';
283
+ const volumes = Array.isArray(serviceDef?.volumes)
284
+ ? serviceDef.volumes
285
+ : [];
286
+ if (volumes.some((volume) => typeof volume === 'string' &&
287
+ volume.includes('/var/run/docker.sock:/var/run/docker-host.sock'))) {
288
+ detections.push({
289
+ source: `service: ${serviceName} (docker socket mount)`,
290
+ overlayId: 'docker-sock',
291
+ confidence: 'heuristic',
292
+ sourceType: 'service',
293
+ });
294
+ }
263
295
  if (!image)
264
296
  continue;
265
297
  const overlayId = matchImage(image, tables);
@@ -315,6 +347,205 @@ function analyseRemoteEnv(devcontainer, tables) {
315
347
  }
316
348
  return { detections, unmatchedRemoteEnv };
317
349
  }
350
+ function normalizeCommandMap(commands) {
351
+ if (typeof commands === 'string') {
352
+ return { entries: { default: commands }, isString: true };
353
+ }
354
+ if (!commands || typeof commands !== 'object' || Array.isArray(commands)) {
355
+ return null;
356
+ }
357
+ const entries = {};
358
+ for (const [key, value] of Object.entries(commands)) {
359
+ if (typeof value === 'string') {
360
+ entries[key] = value;
361
+ }
362
+ }
363
+ return Object.keys(entries).length > 0 ? { entries, isString: false } : null;
364
+ }
365
+ function findOverlayIdsInCommandMap(commands, overlaysConfig) {
366
+ const normalized = normalizeCommandMap(commands);
367
+ if (!normalized) {
368
+ return [];
369
+ }
370
+ const knownOverlays = new Set(overlaysConfig.overlays.map((overlay) => overlay.id));
371
+ const detections = new Map();
372
+ for (const [key, value] of Object.entries(normalized.entries)) {
373
+ const patterns = [
374
+ key.match(/^(?:setup|verify)-([a-z0-9-]+)$/i),
375
+ value.match(/(?:^|\/)(?:setup|verify)-([a-z0-9-]+)\.sh\b/i),
376
+ ];
377
+ for (const match of patterns) {
378
+ const overlayId = match?.[1];
379
+ if (!overlayId || !knownOverlays.has(overlayId) || detections.has(overlayId)) {
380
+ continue;
381
+ }
382
+ detections.set(overlayId, {
383
+ source: `command: ${key}`,
384
+ overlayId,
385
+ confidence: 'heuristic',
386
+ sourceType: 'script',
387
+ });
388
+ }
389
+ }
390
+ return Array.from(detections.values());
391
+ }
392
+ function analyseCommands(devcontainer, overlaysConfig) {
393
+ return {
394
+ detections: [
395
+ ...findOverlayIdsInCommandMap(devcontainer.postCreateCommand, overlaysConfig),
396
+ ...findOverlayIdsInCommandMap(devcontainer.postStartCommand, overlaysConfig),
397
+ ],
398
+ };
399
+ }
400
+ function loadJsonFile(filePath, fallback) {
401
+ if (!fs.existsSync(filePath)) {
402
+ return fallback;
403
+ }
404
+ try {
405
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
406
+ }
407
+ catch {
408
+ return fallback;
409
+ }
410
+ }
411
+ function loadYamlFile(filePath, fallback) {
412
+ if (!fs.existsSync(filePath)) {
413
+ return fallback;
414
+ }
415
+ try {
416
+ return yaml.load(fs.readFileSync(filePath, 'utf8')) ?? fallback;
417
+ }
418
+ catch {
419
+ return fallback;
420
+ }
421
+ }
422
+ function inferBaseImageSelection(devcontainer, composePaths, overlaysConfig, stack) {
423
+ let image;
424
+ if (stack === 'compose') {
425
+ for (const composePath of composePaths) {
426
+ const compose = loadYamlFile(composePath, {});
427
+ const composeImage = compose?.services?.devcontainer?.image;
428
+ if (typeof composeImage === 'string' && composeImage.trim() !== '') {
429
+ image = composeImage.trim();
430
+ break;
431
+ }
432
+ }
433
+ }
434
+ else if (typeof devcontainer?.image === 'string' && devcontainer.image.trim() !== '') {
435
+ image = devcontainer.image.trim();
436
+ }
437
+ if (!image) {
438
+ return { baseImage: 'bookworm' };
439
+ }
440
+ const matchedBaseImage = overlaysConfig.base_images.find((entry) => entry.image === image);
441
+ if (matchedBaseImage && matchedBaseImage.id !== 'custom') {
442
+ return { baseImage: matchedBaseImage.id };
443
+ }
444
+ return { baseImage: 'custom', customImage: image };
445
+ }
446
+ function addGeneratedOverlayCommands(config, overlayIds, overlaysDir) {
447
+ const nextConfig = { ...config };
448
+ const setupOverlays = overlayIds.filter((overlayId) => fs.existsSync(path.join(overlaysDir, overlayId, 'setup.sh')));
449
+ const verifyOverlays = overlayIds.filter((overlayId) => fs.existsSync(path.join(overlaysDir, overlayId, 'verify.sh')));
450
+ if (setupOverlays.length > 0) {
451
+ const postCreate = nextConfig.postCreateCommand &&
452
+ typeof nextConfig.postCreateCommand === 'object' &&
453
+ !Array.isArray(nextConfig.postCreateCommand)
454
+ ? { ...nextConfig.postCreateCommand }
455
+ : {};
456
+ for (const overlayId of setupOverlays) {
457
+ postCreate[`setup-${overlayId}`] = `bash .devcontainer/scripts/setup-${overlayId}.sh`;
458
+ }
459
+ nextConfig.postCreateCommand = postCreate;
460
+ }
461
+ if (verifyOverlays.length > 0) {
462
+ const postStart = nextConfig.postStartCommand &&
463
+ typeof nextConfig.postStartCommand === 'object' &&
464
+ !Array.isArray(nextConfig.postStartCommand)
465
+ ? { ...nextConfig.postStartCommand }
466
+ : {};
467
+ for (const overlayId of verifyOverlays) {
468
+ postStart[`verify-${overlayId}`] = `bash .devcontainer/scripts/verify-${overlayId}.sh`;
469
+ }
470
+ nextConfig.postStartCommand = postStart;
471
+ }
472
+ return nextConfig;
473
+ }
474
+ function buildExpectedDevcontainerConfig(stack, overlayIds, overlaysDir) {
475
+ const templatePath = path.join(TEMPLATES_DIR, stack, '.devcontainer', 'devcontainer.json');
476
+ let config = loadJsonFile(templatePath, {});
477
+ for (const overlayId of overlayIds) {
478
+ config = applyOverlay(config, overlayId, overlaysDir);
479
+ }
480
+ return addGeneratedOverlayCommands(config, overlayIds, overlaysDir);
481
+ }
482
+ function buildExpectedComposeConfig(overlayIds, overlaysDir, baseImageSelection, overlaysConfig) {
483
+ const templatePath = path.join(TEMPLATES_DIR, 'compose', '.devcontainer', 'docker-compose.yml');
484
+ let composeConfig = loadYamlFile(templatePath, {});
485
+ for (const overlayId of overlayIds) {
486
+ const overlayComposePath = path.join(overlaysDir, overlayId, 'docker-compose.yml');
487
+ if (!fs.existsSync(overlayComposePath)) {
488
+ continue;
489
+ }
490
+ composeConfig = deepMerge(composeConfig, loadYamlFile(overlayComposePath, {}));
491
+ }
492
+ const image = baseImageSelection.baseImage === 'custom'
493
+ ? baseImageSelection.customImage
494
+ : overlaysConfig.base_images.find((entry) => entry.id === baseImageSelection.baseImage)
495
+ ?.image;
496
+ if (image) {
497
+ composeConfig = {
498
+ ...composeConfig,
499
+ services: {
500
+ ...(composeConfig.services ?? {}),
501
+ devcontainer: {
502
+ ...(composeConfig.services?.devcontainer ?? {}),
503
+ image,
504
+ },
505
+ },
506
+ };
507
+ }
508
+ return composeConfig;
509
+ }
510
+ function isPlainObject(value) {
511
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
512
+ }
513
+ function deepClone(value) {
514
+ return JSON.parse(JSON.stringify(value));
515
+ }
516
+ function subtractDefaults(actual, expected) {
517
+ if (actual === undefined) {
518
+ return undefined;
519
+ }
520
+ if (expected === undefined) {
521
+ return deepClone(actual);
522
+ }
523
+ if (Array.isArray(actual)) {
524
+ if (!Array.isArray(expected)) {
525
+ return deepClone(actual);
526
+ }
527
+ const filtered = actual.filter((item) => !expected.some((expectedItem) => JSON.stringify(expectedItem) === JSON.stringify(item)));
528
+ return filtered.length > 0 ? filtered : undefined;
529
+ }
530
+ if (isPlainObject(actual)) {
531
+ if (!isPlainObject(expected)) {
532
+ return deepClone(actual);
533
+ }
534
+ const result = {};
535
+ for (const [key, value] of Object.entries(actual)) {
536
+ const diff = subtractDefaults(value, expected[key]);
537
+ if (diff !== undefined &&
538
+ (!isPlainObject(diff) ||
539
+ Object.keys(diff).length > 0 ||
540
+ expected[key] === undefined) &&
541
+ (!Array.isArray(diff) || diff.length > 0)) {
542
+ result[key] = diff;
543
+ }
544
+ }
545
+ return Object.keys(result).length > 0 ? result : undefined;
546
+ }
547
+ return actual === expected ? undefined : actual;
548
+ }
318
549
  /**
319
550
  * Deduplicate: keep one entry per overlayId, preferring `exact` over `heuristic`.
320
551
  */
@@ -380,68 +611,97 @@ function buildSuggestedCommand(overlayIds, stack, overlaysConfig) {
380
611
  parts.push(`--overlays ${other.join(',')}`);
381
612
  return parts.join(' ');
382
613
  }
383
- function buildCustomDevcontainerPatch(devcontainer, unmatchedFeatures, unmatchedExtensions, unmatchedRemoteEnv) {
384
- const patch = {};
385
- if (Object.keys(unmatchedFeatures).length > 0) {
386
- patch.features = unmatchedFeatures;
614
+ function toProjectRelativePath(targetPath, projectRoot) {
615
+ const relativePath = path.relative(projectRoot, targetPath).split(path.sep).join('/');
616
+ if (relativePath === '' || relativePath === '.') {
617
+ return './';
387
618
  }
388
- // Preserve all customizations, merging unmatched extensions into vscode.extensions.
389
- // Other customizations fields (e.g. vscode.settings, jetbrains, ...) are carried through
390
- // verbatim so the migration is lossless.
391
- const originalCustomizations = devcontainer.customizations && typeof devcontainer.customizations === 'object'
392
- ? devcontainer.customizations
393
- : null;
394
- if (unmatchedExtensions.length > 0 || originalCustomizations) {
395
- const customizationsPatch = originalCustomizations
396
- ? { ...originalCustomizations }
397
- : {};
398
- if (unmatchedExtensions.length > 0) {
399
- const originalVscode = originalCustomizations?.vscode && typeof originalCustomizations.vscode === 'object'
400
- ? { ...originalCustomizations.vscode }
401
- : {};
402
- const originalExtensions = Array.isArray(originalVscode.extensions)
403
- ? originalVscode.extensions
404
- : [];
405
- // Order: existing extensions first so load order is preserved, then new unmatched ones
406
- const mergedExtensions = Array.from(new Set([...originalExtensions, ...unmatchedExtensions]));
407
- customizationsPatch.vscode = {
408
- ...originalVscode,
409
- extensions: mergedExtensions,
410
- };
411
- }
412
- if (Object.keys(customizationsPatch).length > 0) {
413
- patch.customizations = customizationsPatch;
414
- }
619
+ if (relativePath.startsWith('./') || relativePath.startsWith('../')) {
620
+ return relativePath;
621
+ }
622
+ return `./${relativePath}`;
623
+ }
624
+ function buildProjectConfigSelection(analysis, overlaysConfig, projectRoot, absoluteDir, devcontainer) {
625
+ const composePaths = resolveComposePaths(devcontainer, absoluteDir);
626
+ const baseImageSelection = inferBaseImageSelection(devcontainer, composePaths, overlaysConfig, analysis.suggestedStack);
627
+ const selection = {
628
+ stack: analysis.suggestedStack,
629
+ baseImage: baseImageSelection.baseImage,
630
+ customImage: baseImageSelection.customImage,
631
+ outputPath: toProjectRelativePath(absoluteDir, projectRoot),
632
+ containerName: typeof devcontainer?.name === 'string' && devcontainer.name.trim() !== ''
633
+ ? devcontainer.name.trim()
634
+ : undefined,
635
+ overlays: analysis.suggestedOverlays,
636
+ };
637
+ if (analysis.customDevcontainerPatch || analysis.customComposePatch) {
638
+ selection.customizations = {
639
+ devcontainerPatch: analysis.customDevcontainerPatch ?? undefined,
640
+ dockerComposePatch: analysis.customComposePatch ?? undefined,
641
+ };
642
+ }
643
+ return selection;
644
+ }
645
+ function buildCustomDevcontainerPatch(devcontainer, expectedConfig) {
646
+ const candidatePatch = {};
647
+ if (devcontainer.features) {
648
+ candidatePatch.features = devcontainer.features;
649
+ }
650
+ if (devcontainer.customizations) {
651
+ candidatePatch.customizations = devcontainer.customizations;
415
652
  }
416
653
  if (Array.isArray(devcontainer.mounts) && devcontainer.mounts.length > 0) {
417
- patch.mounts = devcontainer.mounts;
654
+ candidatePatch.mounts = devcontainer.mounts;
418
655
  }
419
- if (devcontainer.remoteUser && devcontainer.remoteUser !== 'vscode') {
420
- patch.remoteUser = devcontainer.remoteUser;
656
+ if (devcontainer.remoteUser) {
657
+ candidatePatch.remoteUser = devcontainer.remoteUser;
421
658
  }
422
- // Lifecycle commands — included as-is; the user should review the custom/
423
- // patch and remove anything that is already handled by overlay setup scripts.
424
659
  if (devcontainer.postCreateCommand) {
425
- patch.postCreateCommand = devcontainer.postCreateCommand;
660
+ candidatePatch.postCreateCommand = devcontainer.postCreateCommand;
426
661
  }
427
662
  if (devcontainer.postStartCommand) {
428
- patch.postStartCommand = devcontainer.postStartCommand;
663
+ candidatePatch.postStartCommand = devcontainer.postStartCommand;
664
+ }
665
+ if (devcontainer.remoteEnv) {
666
+ candidatePatch.remoteEnv = devcontainer.remoteEnv;
667
+ }
668
+ const expectedPatch = {};
669
+ if (expectedConfig.features) {
670
+ expectedPatch.features = expectedConfig.features;
671
+ }
672
+ if (expectedConfig.customizations) {
673
+ expectedPatch.customizations = expectedConfig.customizations;
674
+ }
675
+ if (expectedConfig.mounts) {
676
+ expectedPatch.mounts = expectedConfig.mounts;
677
+ }
678
+ if (expectedConfig.remoteUser) {
679
+ expectedPatch.remoteUser = expectedConfig.remoteUser;
680
+ }
681
+ if (expectedConfig.postCreateCommand) {
682
+ expectedPatch.postCreateCommand = expectedConfig.postCreateCommand;
429
683
  }
430
- // Preserve remoteEnv keys not matched to any overlay so the migration is lossless.
431
- if (Object.keys(unmatchedRemoteEnv).length > 0) {
432
- patch.remoteEnv = unmatchedRemoteEnv;
684
+ if (expectedConfig.postStartCommand) {
685
+ expectedPatch.postStartCommand = expectedConfig.postStartCommand;
433
686
  }
434
- return Object.keys(patch).length > 0 ? patch : null;
687
+ if (expectedConfig.remoteEnv) {
688
+ expectedPatch.remoteEnv = expectedConfig.remoteEnv;
689
+ }
690
+ const patch = subtractDefaults(candidatePatch, expectedPatch);
691
+ return patch && Object.keys(patch).length > 0 ? patch : null;
435
692
  }
436
- function buildCustomComposePatch(unmatchedServices) {
693
+ function buildCustomComposePatch(unmatchedServices, expectedCompose) {
437
694
  if (Object.keys(unmatchedServices).length === 0)
438
695
  return null;
439
- return { services: unmatchedServices };
696
+ const candidatePatch = { services: unmatchedServices };
697
+ const expectedPatch = { services: expectedCompose.services ?? {} };
698
+ const patch = subtractDefaults(candidatePatch, expectedPatch);
699
+ return patch?.services && Object.keys(patch.services).length > 0 ? patch : null;
440
700
  }
441
701
  // ---------------------------------------------------------------------------
442
702
  // Full analysis
443
703
  // ---------------------------------------------------------------------------
444
- export function analyseDevcontainer(dir, overlaysConfig, tables) {
704
+ export function analyseDevcontainer(dir, overlaysConfig, tables, overlaysDir) {
445
705
  const devcontainerPath = path.join(dir, 'devcontainer.json');
446
706
  let devcontainer = {};
447
707
  if (fs.existsSync(devcontainerPath)) {
@@ -457,11 +717,13 @@ export function analyseDevcontainer(dir, overlaysConfig, tables) {
457
717
  const composeResult = analyseDockerCompose(composePaths, tables);
458
718
  const extensionResult = analyseExtensions(devcontainer, tables);
459
719
  const remoteEnvResult = analyseRemoteEnv(devcontainer, tables);
720
+ const commandResult = analyseCommands(devcontainer, overlaysConfig);
460
721
  const detections = deduplicateDetections([
461
722
  ...featureResult.detections,
462
723
  ...composeResult.detections,
463
724
  ...extensionResult.detections,
464
725
  ...remoteEnvResult.detections,
726
+ ...commandResult.detections,
465
727
  ]);
466
728
  const hasDockerCompose = composePaths.length > 0;
467
729
  const hasServiceSignals = detections.some((d) => d.sourceType === 'service');
@@ -469,41 +731,65 @@ export function analyseDevcontainer(dir, overlaysConfig, tables) {
469
731
  const knownIds = new Set(overlaysConfig.overlays.map((o) => o.id));
470
732
  const suggestedOverlays = [...new Set(detections.map((d) => d.overlayId))].filter((id) => knownIds.has(id));
471
733
  const suggestedCommand = buildSuggestedCommand(suggestedOverlays, suggestedStack, overlaysConfig);
472
- // Unmatched item descriptions for display / JSON
734
+ const baseImageSelection = inferBaseImageSelection(devcontainer, composePaths, overlaysConfig, suggestedStack);
735
+ const expectedDevcontainerConfig = buildExpectedDevcontainerConfig(suggestedStack, suggestedOverlays, overlaysDir);
736
+ const expectedComposeConfig = suggestedStack === 'compose'
737
+ ? buildExpectedComposeConfig(suggestedOverlays, overlaysDir, baseImageSelection, overlaysConfig)
738
+ : {};
739
+ const customDevcontainerPatch = buildCustomDevcontainerPatch(devcontainer, expectedDevcontainerConfig);
740
+ const customComposePatch = buildCustomComposePatch(composeResult.unmatchedServices, expectedComposeConfig);
473
741
  const unmatchedItems = [];
474
- for (const fid of Object.keys(featureResult.unmatchedFeatures)) {
742
+ for (const featureId of Object.keys(customDevcontainerPatch?.features ?? {})) {
475
743
  unmatchedItems.push({
476
- source: fid,
744
+ source: featureId,
477
745
  reason: 'No overlay covers this feature — preserve in custom/devcontainer.patch.json',
478
746
  });
479
747
  }
480
- for (const [name, def] of Object.entries(composeResult.unmatchedServices)) {
481
- const image = def?.image ?? '(no image)';
482
- unmatchedItems.push({
483
- source: `service: ${name} (image: ${image})`,
484
- reason: 'No overlay covers this service — preserve in custom/docker-compose.patch.yml',
485
- });
486
- }
487
- for (const extId of extensionResult.unmatchedExtensions) {
748
+ const preservedExtensions = customDevcontainerPatch?.customizations?.vscode?.extensions ?? [];
749
+ for (const extensionId of preservedExtensions) {
488
750
  unmatchedItems.push({
489
- source: `extension: ${extId}`,
751
+ source: `extension: ${extensionId}`,
490
752
  reason: 'No overlay installs this extension — preserve in custom/devcontainer.patch.json',
491
753
  });
492
754
  }
493
- if (Array.isArray(devcontainer.mounts) && devcontainer.mounts.length > 0) {
755
+ if (Array.isArray(customDevcontainerPatch?.mounts) &&
756
+ customDevcontainerPatch.mounts.length > 0) {
494
757
  unmatchedItems.push({
495
- source: `mounts (${devcontainer.mounts.length} mount(s))`,
758
+ source: `mounts (${customDevcontainerPatch.mounts.length} mount(s))`,
496
759
  reason: 'Custom mounts are not managed by overlays — preserve in custom/devcontainer.patch.json',
497
760
  });
498
761
  }
499
- if (devcontainer.remoteUser && devcontainer.remoteUser !== 'vscode') {
762
+ if (customDevcontainerPatch?.remoteUser) {
500
763
  unmatchedItems.push({
501
- source: `remoteUser: ${devcontainer.remoteUser}`,
764
+ source: `remoteUser: ${customDevcontainerPatch.remoteUser}`,
502
765
  reason: 'Custom remote user — preserve in custom/devcontainer.patch.json',
503
766
  });
504
767
  }
505
- const customDevcontainerPatch = buildCustomDevcontainerPatch(devcontainer, featureResult.unmatchedFeatures, extensionResult.unmatchedExtensions, remoteEnvResult.unmatchedRemoteEnv);
506
- const customComposePatch = buildCustomComposePatch(composeResult.unmatchedServices);
768
+ for (const [key] of Object.entries(customDevcontainerPatch?.remoteEnv ?? {})) {
769
+ unmatchedItems.push({
770
+ source: `remoteEnv: ${key}`,
771
+ reason: 'Custom environment variable — preserve in custom/devcontainer.patch.json',
772
+ });
773
+ }
774
+ for (const [key] of Object.entries(normalizeCommandMap(customDevcontainerPatch?.postCreateCommand)?.entries ?? {})) {
775
+ unmatchedItems.push({
776
+ source: `postCreateCommand: ${key}`,
777
+ reason: 'Custom lifecycle command — preserve in custom/devcontainer.patch.json',
778
+ });
779
+ }
780
+ for (const [key] of Object.entries(normalizeCommandMap(customDevcontainerPatch?.postStartCommand)?.entries ?? {})) {
781
+ unmatchedItems.push({
782
+ source: `postStartCommand: ${key}`,
783
+ reason: 'Custom lifecycle command — preserve in custom/devcontainer.patch.json',
784
+ });
785
+ }
786
+ for (const [serviceName, serviceDef] of Object.entries(customComposePatch?.services ?? {})) {
787
+ const image = serviceDef?.image ?? '(no image)';
788
+ unmatchedItems.push({
789
+ source: `service: ${serviceName} (image: ${image})`,
790
+ reason: 'No overlay covers this service — preserve in custom/docker-compose.patch.yml',
791
+ });
792
+ }
507
793
  return {
508
794
  detections,
509
795
  unmatchedItems,
@@ -570,7 +856,7 @@ export async function adoptCommand(overlaysConfig, overlaysDir, options) {
570
856
  // Build detection tables dynamically from the overlay registry
571
857
  const tables = buildDetectionTables(overlaysDir, overlaysConfig);
572
858
  // ── Analyse ────────────────────────────────────────────────────────────
573
- const analysis = analyseDevcontainer(absoluteDir, overlaysConfig, tables);
859
+ const analysis = analyseDevcontainer(absoluteDir, overlaysConfig, tables, overlaysDir);
574
860
  // ── JSON output (no decoration) ────────────────────────────────────────
575
861
  if (options.json) {
576
862
  console.log(JSON.stringify({
@@ -641,16 +927,27 @@ export async function adoptCommand(overlaysConfig, overlaysDir, options) {
641
927
  return;
642
928
  }
643
929
  // ── Guard: existing files ──────────────────────────────────────────────
644
- // superposition.json goes to the project root (parent of .devcontainer/) so it
645
- // can be committed alongside application code, matching the team workflow pattern.
930
+ // superposition.json and optional project config go to the project root
931
+ // (parent of .devcontainer/) so they can be committed alongside app code.
646
932
  const projectRoot = path.dirname(absoluteDir);
647
933
  const manifestPath = path.join(projectRoot, 'superposition.json');
648
934
  const customDir = path.join(absoluteDir, 'custom');
649
935
  const customPatchPath = path.join(customDir, 'devcontainer.patch.json');
650
936
  const customComposePath = path.join(customDir, 'docker-compose.patch.yml');
937
+ const discoveredProjectFiles = options.projectFile ? findProjectConfig(projectRoot) : [];
938
+ if (discoveredProjectFiles.length > 1) {
939
+ console.error(chalk.red(`✗ Found both supported project config files in ${projectRoot}. Keep only one before using --project-file.`));
940
+ process.exit(1);
941
+ }
942
+ const projectFilePath = options.projectFile
943
+ ? (discoveredProjectFiles[0]?.path ?? path.join(projectRoot, '.superposition.yml'))
944
+ : null;
651
945
  const existingFiles = [];
652
946
  if (fs.existsSync(manifestPath))
653
947
  existingFiles.push(path.relative(process.cwd(), manifestPath));
948
+ if (projectFilePath && fs.existsSync(projectFilePath)) {
949
+ existingFiles.push(path.relative(process.cwd(), projectFilePath));
950
+ }
654
951
  if (analysis.customDevcontainerPatch && fs.existsSync(customPatchPath))
655
952
  existingFiles.push(path.relative(process.cwd(), customPatchPath));
656
953
  if (analysis.customComposePatch && fs.existsSync(customComposePath))
@@ -664,10 +961,13 @@ export async function adoptCommand(overlaysConfig, overlaysDir, options) {
664
961
  }
665
962
  // ── Prompt ────────────────────────────────────────────────────────────
666
963
  const hasCustomFiles = analysis.customDevcontainerPatch || analysis.customComposePatch;
964
+ const projectSelection = projectFilePath
965
+ ? buildProjectConfigSelection(analysis, overlaysConfig, projectRoot, absoluteDir, devcontainer)
966
+ : null;
667
967
  let confirmed;
668
968
  try {
669
969
  confirmed = await confirm({
670
- message: `Generate superposition.json${hasCustomFiles ? ' and custom/ patch files' : ''} from these suggestions?`,
970
+ message: `Generate superposition.json${projectFilePath ? ', project config' : ''}${hasCustomFiles ? ', and custom/ patch files' : ''} from these suggestions?`,
671
971
  default: true,
672
972
  });
673
973
  }
@@ -715,6 +1015,7 @@ export async function adoptCommand(overlaysConfig, overlaysDir, options) {
715
1015
  baseTemplate: analysis.suggestedStack,
716
1016
  baseImage: 'bookworm',
717
1017
  overlays: analysis.suggestedOverlays,
1018
+ containerName: projectSelection?.containerName,
718
1019
  };
719
1020
  try {
720
1021
  fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n', 'utf8');
@@ -724,6 +1025,16 @@ export async function adoptCommand(overlaysConfig, overlaysDir, options) {
724
1025
  console.error(chalk.red('✗ Failed to write superposition.json:'), err);
725
1026
  process.exit(1);
726
1027
  }
1028
+ if (projectFilePath && projectSelection) {
1029
+ try {
1030
+ writeProjectConfig(projectFilePath, projectSelection);
1031
+ console.log(chalk.green(`✓ Written ${path.relative(process.cwd(), projectFilePath)}`));
1032
+ }
1033
+ catch (err) {
1034
+ console.error(chalk.red('✗ Failed to write project config:'), err);
1035
+ process.exit(1);
1036
+ }
1037
+ }
727
1038
  // ── Write custom patches ───────────────────────────────────────────────
728
1039
  if (hasCustomFiles) {
729
1040
  try {