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.
- package/README.md +3 -1
- package/dist/scripts/init.js +17 -0
- package/dist/scripts/init.js.map +1 -1
- package/dist/tool/commands/adopt.d.ts +3 -2
- package/dist/tool/commands/adopt.d.ts.map +1 -1
- package/dist/tool/commands/adopt.js +404 -67
- package/dist/tool/commands/adopt.js.map +1 -1
- package/dist/tool/schema/project-config.d.ts +2 -0
- package/dist/tool/schema/project-config.d.ts.map +1 -1
- package/dist/tool/schema/project-config.js +82 -0
- package/dist/tool/schema/project-config.js.map +1 -1
- package/docs/adopt.md +20 -14
- package/docs/overlays.md +10 -0
- package/docs/specs/002-superposition-config-file/plan.md +6 -1
- package/docs/specs/002-superposition-config-file/spec.md +6 -0
- package/docs/specs/002-superposition-config-file/tasks.md +2 -0
- package/docs/team-workflow.md +7 -1
- package/docs/workflows.md +3 -0
- package/features/cross-distro-packages/README.md +18 -0
- package/features/cross-distro-packages/devcontainer-feature.json +3 -3
- package/features/cross-distro-packages/install.sh +49 -7
- package/overlays/pandoc/README.md +279 -0
- package/overlays/pandoc/devcontainer.patch.json +14 -0
- package/overlays/pandoc/overlay.yml +19 -0
- package/overlays/pandoc/setup.sh +94 -0
- package/overlays/pandoc/verify.sh +13 -0
- package/package.json +1 -1
|
@@ -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
|
|
384
|
-
const
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
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
|
-
|
|
680
|
+
candidatePatch.mounts = devcontainer.mounts;
|
|
418
681
|
}
|
|
419
|
-
if (devcontainer.remoteUser
|
|
420
|
-
|
|
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
|
-
|
|
686
|
+
candidatePatch.postCreateCommand = devcontainer.postCreateCommand;
|
|
426
687
|
}
|
|
427
688
|
if (devcontainer.postStartCommand) {
|
|
428
|
-
|
|
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
|
-
|
|
431
|
-
|
|
432
|
-
patch.remoteEnv = unmatchedRemoteEnv;
|
|
698
|
+
if (expectedConfig.customizations) {
|
|
699
|
+
expectedPatch.customizations = expectedConfig.customizations;
|
|
433
700
|
}
|
|
434
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
768
|
+
for (const featureId of Object.keys(customDevcontainerPatch?.features ?? {})) {
|
|
475
769
|
unmatchedItems.push({
|
|
476
|
-
source:
|
|
770
|
+
source: featureId,
|
|
477
771
|
reason: 'No overlay covers this feature — preserve in custom/devcontainer.patch.json',
|
|
478
772
|
});
|
|
479
773
|
}
|
|
480
|
-
|
|
481
|
-
|
|
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: ${
|
|
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(
|
|
781
|
+
if (Array.isArray(customDevcontainerPatch?.mounts) &&
|
|
782
|
+
customDevcontainerPatch.mounts.length > 0) {
|
|
494
783
|
unmatchedItems.push({
|
|
495
|
-
source: `mounts (${
|
|
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 (
|
|
788
|
+
if (customDevcontainerPatch?.remoteUser) {
|
|
500
789
|
unmatchedItems.push({
|
|
501
|
-
source: `remoteUser: ${
|
|
790
|
+
source: `remoteUser: ${customDevcontainerPatch.remoteUser}`,
|
|
502
791
|
reason: 'Custom remote user — preserve in custom/devcontainer.patch.json',
|
|
503
792
|
});
|
|
504
793
|
}
|
|
505
|
-
const
|
|
506
|
-
|
|
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
|
|
645
|
-
// can be committed alongside
|
|
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 {
|