container-superposition 0.1.5 → 0.1.6

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.
@@ -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,123 @@ 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;
387
- }
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;
614
+ function categorizeOverlayIds(overlayIds, overlaysConfig) {
615
+ const selection = {};
616
+ for (const id of overlayIds) {
617
+ const overlay = overlaysConfig.overlays.find((entry) => entry.id === id);
618
+ if (!overlay)
619
+ continue;
620
+ switch (overlay.category) {
621
+ case 'language':
622
+ selection.language = [...(selection.language ?? []), id];
623
+ break;
624
+ case 'database':
625
+ selection.database = [...(selection.database ?? []), id];
626
+ break;
627
+ case 'observability':
628
+ selection.observability = [...(selection.observability ?? []), id];
629
+ break;
630
+ case 'cloud':
631
+ selection.cloudTools = [...(selection.cloudTools ?? []), id];
632
+ break;
633
+ case 'dev':
634
+ selection.devTools = [...(selection.devTools ?? []), id];
635
+ break;
414
636
  }
415
637
  }
638
+ return selection;
639
+ }
640
+ function toProjectRelativePath(targetPath, projectRoot) {
641
+ const relativePath = path.relative(projectRoot, targetPath).split(path.sep).join('/');
642
+ if (relativePath === '' || relativePath === '.') {
643
+ return './';
644
+ }
645
+ if (relativePath.startsWith('./') || relativePath.startsWith('../')) {
646
+ return relativePath;
647
+ }
648
+ return `./${relativePath}`;
649
+ }
650
+ function buildProjectConfigSelection(analysis, overlaysConfig, projectRoot, absoluteDir, devcontainer) {
651
+ const composePaths = resolveComposePaths(devcontainer, absoluteDir);
652
+ const baseImageSelection = inferBaseImageSelection(devcontainer, composePaths, overlaysConfig, analysis.suggestedStack);
653
+ const selection = {
654
+ stack: analysis.suggestedStack,
655
+ baseImage: baseImageSelection.baseImage,
656
+ customImage: baseImageSelection.customImage,
657
+ outputPath: toProjectRelativePath(absoluteDir, projectRoot),
658
+ containerName: typeof devcontainer?.name === 'string' && devcontainer.name.trim() !== ''
659
+ ? devcontainer.name.trim()
660
+ : undefined,
661
+ ...categorizeOverlayIds(analysis.suggestedOverlays, overlaysConfig),
662
+ };
663
+ if (analysis.customDevcontainerPatch || analysis.customComposePatch) {
664
+ selection.customizations = {
665
+ devcontainerPatch: analysis.customDevcontainerPatch ?? undefined,
666
+ dockerComposePatch: analysis.customComposePatch ?? undefined,
667
+ };
668
+ }
669
+ return selection;
670
+ }
671
+ function buildCustomDevcontainerPatch(devcontainer, expectedConfig) {
672
+ const candidatePatch = {};
673
+ if (devcontainer.features) {
674
+ candidatePatch.features = devcontainer.features;
675
+ }
676
+ if (devcontainer.customizations) {
677
+ candidatePatch.customizations = devcontainer.customizations;
678
+ }
416
679
  if (Array.isArray(devcontainer.mounts) && devcontainer.mounts.length > 0) {
417
- patch.mounts = devcontainer.mounts;
680
+ candidatePatch.mounts = devcontainer.mounts;
418
681
  }
419
- if (devcontainer.remoteUser && devcontainer.remoteUser !== 'vscode') {
420
- patch.remoteUser = devcontainer.remoteUser;
682
+ if (devcontainer.remoteUser) {
683
+ candidatePatch.remoteUser = devcontainer.remoteUser;
421
684
  }
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
685
  if (devcontainer.postCreateCommand) {
425
- patch.postCreateCommand = devcontainer.postCreateCommand;
686
+ candidatePatch.postCreateCommand = devcontainer.postCreateCommand;
426
687
  }
427
688
  if (devcontainer.postStartCommand) {
428
- patch.postStartCommand = devcontainer.postStartCommand;
689
+ candidatePatch.postStartCommand = devcontainer.postStartCommand;
690
+ }
691
+ if (devcontainer.remoteEnv) {
692
+ candidatePatch.remoteEnv = devcontainer.remoteEnv;
693
+ }
694
+ const expectedPatch = {};
695
+ if (expectedConfig.features) {
696
+ expectedPatch.features = expectedConfig.features;
429
697
  }
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;
698
+ if (expectedConfig.customizations) {
699
+ expectedPatch.customizations = expectedConfig.customizations;
433
700
  }
434
- return Object.keys(patch).length > 0 ? patch : null;
701
+ if (expectedConfig.mounts) {
702
+ expectedPatch.mounts = expectedConfig.mounts;
703
+ }
704
+ if (expectedConfig.remoteUser) {
705
+ expectedPatch.remoteUser = expectedConfig.remoteUser;
706
+ }
707
+ if (expectedConfig.postCreateCommand) {
708
+ expectedPatch.postCreateCommand = expectedConfig.postCreateCommand;
709
+ }
710
+ if (expectedConfig.postStartCommand) {
711
+ expectedPatch.postStartCommand = expectedConfig.postStartCommand;
712
+ }
713
+ if (expectedConfig.remoteEnv) {
714
+ expectedPatch.remoteEnv = expectedConfig.remoteEnv;
715
+ }
716
+ const patch = subtractDefaults(candidatePatch, expectedPatch);
717
+ return patch && Object.keys(patch).length > 0 ? patch : null;
435
718
  }
436
- function buildCustomComposePatch(unmatchedServices) {
719
+ function buildCustomComposePatch(unmatchedServices, expectedCompose) {
437
720
  if (Object.keys(unmatchedServices).length === 0)
438
721
  return null;
439
- return { services: unmatchedServices };
722
+ const candidatePatch = { services: unmatchedServices };
723
+ const expectedPatch = { services: expectedCompose.services ?? {} };
724
+ const patch = subtractDefaults(candidatePatch, expectedPatch);
725
+ return patch?.services && Object.keys(patch.services).length > 0 ? patch : null;
440
726
  }
441
727
  // ---------------------------------------------------------------------------
442
728
  // Full analysis
443
729
  // ---------------------------------------------------------------------------
444
- export function analyseDevcontainer(dir, overlaysConfig, tables) {
730
+ export function analyseDevcontainer(dir, overlaysConfig, tables, overlaysDir) {
445
731
  const devcontainerPath = path.join(dir, 'devcontainer.json');
446
732
  let devcontainer = {};
447
733
  if (fs.existsSync(devcontainerPath)) {
@@ -457,11 +743,13 @@ export function analyseDevcontainer(dir, overlaysConfig, tables) {
457
743
  const composeResult = analyseDockerCompose(composePaths, tables);
458
744
  const extensionResult = analyseExtensions(devcontainer, tables);
459
745
  const remoteEnvResult = analyseRemoteEnv(devcontainer, tables);
746
+ const commandResult = analyseCommands(devcontainer, overlaysConfig);
460
747
  const detections = deduplicateDetections([
461
748
  ...featureResult.detections,
462
749
  ...composeResult.detections,
463
750
  ...extensionResult.detections,
464
751
  ...remoteEnvResult.detections,
752
+ ...commandResult.detections,
465
753
  ]);
466
754
  const hasDockerCompose = composePaths.length > 0;
467
755
  const hasServiceSignals = detections.some((d) => d.sourceType === 'service');
@@ -469,41 +757,65 @@ export function analyseDevcontainer(dir, overlaysConfig, tables) {
469
757
  const knownIds = new Set(overlaysConfig.overlays.map((o) => o.id));
470
758
  const suggestedOverlays = [...new Set(detections.map((d) => d.overlayId))].filter((id) => knownIds.has(id));
471
759
  const suggestedCommand = buildSuggestedCommand(suggestedOverlays, suggestedStack, overlaysConfig);
472
- // Unmatched item descriptions for display / JSON
760
+ const baseImageSelection = inferBaseImageSelection(devcontainer, composePaths, overlaysConfig, suggestedStack);
761
+ const expectedDevcontainerConfig = buildExpectedDevcontainerConfig(suggestedStack, suggestedOverlays, overlaysDir);
762
+ const expectedComposeConfig = suggestedStack === 'compose'
763
+ ? buildExpectedComposeConfig(suggestedOverlays, overlaysDir, baseImageSelection, overlaysConfig)
764
+ : {};
765
+ const customDevcontainerPatch = buildCustomDevcontainerPatch(devcontainer, expectedDevcontainerConfig);
766
+ const customComposePatch = buildCustomComposePatch(composeResult.unmatchedServices, expectedComposeConfig);
473
767
  const unmatchedItems = [];
474
- for (const fid of Object.keys(featureResult.unmatchedFeatures)) {
768
+ for (const featureId of Object.keys(customDevcontainerPatch?.features ?? {})) {
475
769
  unmatchedItems.push({
476
- source: fid,
770
+ source: featureId,
477
771
  reason: 'No overlay covers this feature — preserve in custom/devcontainer.patch.json',
478
772
  });
479
773
  }
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) {
774
+ const preservedExtensions = customDevcontainerPatch?.customizations?.vscode?.extensions ?? [];
775
+ for (const extensionId of preservedExtensions) {
488
776
  unmatchedItems.push({
489
- source: `extension: ${extId}`,
777
+ source: `extension: ${extensionId}`,
490
778
  reason: 'No overlay installs this extension — preserve in custom/devcontainer.patch.json',
491
779
  });
492
780
  }
493
- if (Array.isArray(devcontainer.mounts) && devcontainer.mounts.length > 0) {
781
+ if (Array.isArray(customDevcontainerPatch?.mounts) &&
782
+ customDevcontainerPatch.mounts.length > 0) {
494
783
  unmatchedItems.push({
495
- source: `mounts (${devcontainer.mounts.length} mount(s))`,
784
+ source: `mounts (${customDevcontainerPatch.mounts.length} mount(s))`,
496
785
  reason: 'Custom mounts are not managed by overlays — preserve in custom/devcontainer.patch.json',
497
786
  });
498
787
  }
499
- if (devcontainer.remoteUser && devcontainer.remoteUser !== 'vscode') {
788
+ if (customDevcontainerPatch?.remoteUser) {
500
789
  unmatchedItems.push({
501
- source: `remoteUser: ${devcontainer.remoteUser}`,
790
+ source: `remoteUser: ${customDevcontainerPatch.remoteUser}`,
502
791
  reason: 'Custom remote user — preserve in custom/devcontainer.patch.json',
503
792
  });
504
793
  }
505
- const customDevcontainerPatch = buildCustomDevcontainerPatch(devcontainer, featureResult.unmatchedFeatures, extensionResult.unmatchedExtensions, remoteEnvResult.unmatchedRemoteEnv);
506
- const customComposePatch = buildCustomComposePatch(composeResult.unmatchedServices);
794
+ for (const [key] of Object.entries(customDevcontainerPatch?.remoteEnv ?? {})) {
795
+ unmatchedItems.push({
796
+ source: `remoteEnv: ${key}`,
797
+ reason: 'Custom environment variable — preserve in custom/devcontainer.patch.json',
798
+ });
799
+ }
800
+ for (const [key] of Object.entries(normalizeCommandMap(customDevcontainerPatch?.postCreateCommand)?.entries ?? {})) {
801
+ unmatchedItems.push({
802
+ source: `postCreateCommand: ${key}`,
803
+ reason: 'Custom lifecycle command — preserve in custom/devcontainer.patch.json',
804
+ });
805
+ }
806
+ for (const [key] of Object.entries(normalizeCommandMap(customDevcontainerPatch?.postStartCommand)?.entries ?? {})) {
807
+ unmatchedItems.push({
808
+ source: `postStartCommand: ${key}`,
809
+ reason: 'Custom lifecycle command — preserve in custom/devcontainer.patch.json',
810
+ });
811
+ }
812
+ for (const [serviceName, serviceDef] of Object.entries(customComposePatch?.services ?? {})) {
813
+ const image = serviceDef?.image ?? '(no image)';
814
+ unmatchedItems.push({
815
+ source: `service: ${serviceName} (image: ${image})`,
816
+ reason: 'No overlay covers this service — preserve in custom/docker-compose.patch.yml',
817
+ });
818
+ }
507
819
  return {
508
820
  detections,
509
821
  unmatchedItems,
@@ -570,7 +882,7 @@ export async function adoptCommand(overlaysConfig, overlaysDir, options) {
570
882
  // Build detection tables dynamically from the overlay registry
571
883
  const tables = buildDetectionTables(overlaysDir, overlaysConfig);
572
884
  // ── Analyse ────────────────────────────────────────────────────────────
573
- const analysis = analyseDevcontainer(absoluteDir, overlaysConfig, tables);
885
+ const analysis = analyseDevcontainer(absoluteDir, overlaysConfig, tables, overlaysDir);
574
886
  // ── JSON output (no decoration) ────────────────────────────────────────
575
887
  if (options.json) {
576
888
  console.log(JSON.stringify({
@@ -641,16 +953,27 @@ export async function adoptCommand(overlaysConfig, overlaysDir, options) {
641
953
  return;
642
954
  }
643
955
  // ── 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.
956
+ // superposition.json and optional project config go to the project root
957
+ // (parent of .devcontainer/) so they can be committed alongside app code.
646
958
  const projectRoot = path.dirname(absoluteDir);
647
959
  const manifestPath = path.join(projectRoot, 'superposition.json');
648
960
  const customDir = path.join(absoluteDir, 'custom');
649
961
  const customPatchPath = path.join(customDir, 'devcontainer.patch.json');
650
962
  const customComposePath = path.join(customDir, 'docker-compose.patch.yml');
963
+ const discoveredProjectFiles = options.projectFile ? findProjectConfig(projectRoot) : [];
964
+ if (discoveredProjectFiles.length > 1) {
965
+ console.error(chalk.red(`✗ Found both supported project config files in ${projectRoot}. Keep only one before using --project-file.`));
966
+ process.exit(1);
967
+ }
968
+ const projectFilePath = options.projectFile
969
+ ? (discoveredProjectFiles[0]?.path ?? path.join(projectRoot, '.superposition.yml'))
970
+ : null;
651
971
  const existingFiles = [];
652
972
  if (fs.existsSync(manifestPath))
653
973
  existingFiles.push(path.relative(process.cwd(), manifestPath));
974
+ if (projectFilePath && fs.existsSync(projectFilePath)) {
975
+ existingFiles.push(path.relative(process.cwd(), projectFilePath));
976
+ }
654
977
  if (analysis.customDevcontainerPatch && fs.existsSync(customPatchPath))
655
978
  existingFiles.push(path.relative(process.cwd(), customPatchPath));
656
979
  if (analysis.customComposePatch && fs.existsSync(customComposePath))
@@ -664,10 +987,13 @@ export async function adoptCommand(overlaysConfig, overlaysDir, options) {
664
987
  }
665
988
  // ── Prompt ────────────────────────────────────────────────────────────
666
989
  const hasCustomFiles = analysis.customDevcontainerPatch || analysis.customComposePatch;
990
+ const projectSelection = projectFilePath
991
+ ? buildProjectConfigSelection(analysis, overlaysConfig, projectRoot, absoluteDir, devcontainer)
992
+ : null;
667
993
  let confirmed;
668
994
  try {
669
995
  confirmed = await confirm({
670
- message: `Generate superposition.json${hasCustomFiles ? ' and custom/ patch files' : ''} from these suggestions?`,
996
+ message: `Generate superposition.json${projectFilePath ? ', project config' : ''}${hasCustomFiles ? ', and custom/ patch files' : ''} from these suggestions?`,
671
997
  default: true,
672
998
  });
673
999
  }
@@ -715,6 +1041,7 @@ export async function adoptCommand(overlaysConfig, overlaysDir, options) {
715
1041
  baseTemplate: analysis.suggestedStack,
716
1042
  baseImage: 'bookworm',
717
1043
  overlays: analysis.suggestedOverlays,
1044
+ containerName: projectSelection?.containerName,
718
1045
  };
719
1046
  try {
720
1047
  fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n', 'utf8');
@@ -724,6 +1051,16 @@ export async function adoptCommand(overlaysConfig, overlaysDir, options) {
724
1051
  console.error(chalk.red('✗ Failed to write superposition.json:'), err);
725
1052
  process.exit(1);
726
1053
  }
1054
+ if (projectFilePath && projectSelection) {
1055
+ try {
1056
+ writeProjectConfig(projectFilePath, projectSelection);
1057
+ console.log(chalk.green(`✓ Written ${path.relative(process.cwd(), projectFilePath)}`));
1058
+ }
1059
+ catch (err) {
1060
+ console.error(chalk.red('✗ Failed to write project config:'), err);
1061
+ process.exit(1);
1062
+ }
1063
+ }
727
1064
  // ── Write custom patches ───────────────────────────────────────────────
728
1065
  if (hasCustomFiles) {
729
1066
  try {