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.
- package/README.md +3 -1
- package/dist/scripts/init.js +24 -4
- 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 +378 -67
- package/dist/tool/commands/adopt.js.map +1 -1
- package/dist/tool/commands/doctor.d.ts +3 -0
- package/dist/tool/commands/doctor.d.ts.map +1 -1
- package/dist/tool/commands/doctor.js +932 -69
- package/dist/tool/commands/doctor.js.map +1 -1
- package/dist/tool/commands/explain.d.ts.map +1 -1
- package/dist/tool/commands/explain.js +9 -0
- package/dist/tool/commands/explain.js.map +1 -1
- package/dist/tool/questionnaire/composer.d.ts.map +1 -1
- package/dist/tool/questionnaire/composer.js +212 -11
- package/dist/tool/questionnaire/composer.js.map +1 -1
- package/dist/tool/schema/overlay-loader.d.ts.map +1 -1
- package/dist/tool/schema/overlay-loader.js +1 -0
- package/dist/tool/schema/overlay-loader.js.map +1 -1
- package/dist/tool/schema/project-config.d.ts +3 -1
- package/dist/tool/schema/project-config.d.ts.map +1 -1
- package/dist/tool/schema/project-config.js +164 -13
- package/dist/tool/schema/project-config.js.map +1 -1
- package/dist/tool/schema/types.d.ts +85 -11
- package/dist/tool/schema/types.d.ts.map +1 -1
- package/dist/tool/utils/merge.d.ts.map +1 -1
- package/dist/tool/utils/merge.js +9 -0
- package/dist/tool/utils/merge.js.map +1 -1
- package/docs/adopt.md +20 -14
- package/docs/creating-overlays.md +151 -2
- package/docs/overlay-imports.md +125 -102
- package/docs/overlays.md +59 -6
- package/docs/quick-reference.md +99 -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/specs/003-mkdocs2-overlay/spec.md +114 -0
- package/docs/specs/004-doctor-fix/spec.md +70 -0
- package/docs/specs/005-cuda-overlay/spec.md +101 -0
- package/docs/specs/006-rocm-overlay/spec.md +109 -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/.shared/README.md +80 -21
- package/overlays/.shared/compose/common-healthchecks.md +60 -0
- package/overlays/.shared/vscode/recommended-extensions.json +15 -11
- package/overlays/alertmanager/setup.sh +4 -19
- package/overlays/alertmanager/verify.sh +8 -9
- package/overlays/all/README.md +43 -0
- package/overlays/all/devcontainer.patch.json +6 -0
- package/overlays/all/overlay.yml +14 -0
- package/overlays/amp/setup.sh +5 -0
- package/overlays/bun/setup.sh +10 -1
- package/overlays/bun/verify.sh +6 -1
- package/overlays/claude-code/setup.sh +5 -0
- package/overlays/cloudflared/setup.sh +9 -12
- package/overlays/codex/README.md +9 -6
- package/overlays/codex/devcontainer.patch.json +7 -1
- package/overlays/codex/setup.sh +5 -0
- package/overlays/codex/verify.sh +8 -0
- package/overlays/commitlint/setup.sh +5 -0
- package/overlays/cuda/README.md +179 -0
- package/overlays/cuda/devcontainer.patch.json +7 -0
- package/overlays/cuda/overlay.yml +17 -0
- package/overlays/cuda/setup.sh +32 -0
- package/overlays/cuda/verify.sh +38 -0
- package/overlays/devcontainer-cli/README.md +50 -0
- package/overlays/devcontainer-cli/devcontainer.patch.json +13 -0
- package/overlays/devcontainer-cli/overlay.yml +16 -0
- package/overlays/devcontainer-cli/setup.sh +14 -0
- package/overlays/direnv/devcontainer.patch.json +6 -0
- package/overlays/direnv/setup.sh +7 -6
- package/overlays/dotnet/setup.sh +14 -7
- package/overlays/duckdb/devcontainer.patch.json +1 -2
- package/overlays/gcloud/devcontainer.patch.json +0 -6
- package/overlays/gcloud/setup.sh +51 -0
- package/overlays/gemini-cli/setup.sh +5 -0
- package/overlays/git-helpers/devcontainer.patch.json +2 -1
- package/overlays/go/setup.sh +15 -14
- package/overlays/jaeger/overlay.yml +2 -0
- package/overlays/just/setup.sh +5 -17
- package/overlays/keycloak/docker-compose.yml +6 -4
- package/overlays/keycloak/verify.sh +4 -3
- package/overlays/kind/devcontainer.patch.json +1 -2
- package/overlays/kind/setup.sh +8 -17
- package/overlays/minio/setup.sh +10 -18
- package/overlays/mkdocs/overlay.yml +2 -1
- package/overlays/mkdocs2/README.md +135 -0
- package/overlays/mkdocs2/devcontainer.patch.json +19 -0
- package/overlays/mkdocs2/overlay.yml +17 -0
- package/overlays/mkdocs2/setup.sh +67 -0
- package/overlays/mkdocs2/verify.sh +35 -0
- package/overlays/modern-cli-tools/devcontainer.patch.json +7 -1
- package/overlays/modern-cli-tools/setup.sh +21 -71
- package/overlays/mongodb/devcontainer.patch.json +0 -6
- package/overlays/mongodb/setup.sh +59 -0
- package/overlays/mysql/verify.sh +4 -3
- package/overlays/nats/.env.example +1 -1
- package/overlays/nats/README.md +1 -1
- package/overlays/nats/docker-compose.yml +1 -1
- package/overlays/ngrok/setup.sh +9 -6
- package/overlays/nodejs/setup.sh +5 -0
- package/overlays/openapi-tools/devcontainer.patch.json +1 -2
- package/overlays/openapi-tools/setup.sh +9 -8
- package/overlays/opencode/setup.sh +5 -0
- package/overlays/otel-collector/overlay.yml +2 -0
- package/overlays/otel-collector/setup.sh +3 -16
- package/overlays/otel-demo-nodejs/verify.sh +8 -9
- package/overlays/otel-demo-python/verify.sh +16 -10
- package/overlays/pandoc/README.md +286 -0
- package/overlays/pandoc/devcontainer.patch.json +18 -0
- package/overlays/pandoc/overlay.yml +19 -0
- package/overlays/pandoc/setup.sh +293 -0
- package/overlays/pandoc/verify.sh +25 -0
- package/overlays/playwright/devcontainer.patch.json +3 -1
- package/overlays/playwright/setup.sh +37 -0
- package/overlays/postgres/docker-compose.yml +6 -0
- package/overlays/powershell/setup.sh +49 -13
- package/overlays/pre-commit/setup.sh +12 -3
- package/overlays/prometheus/overlay.yml +2 -0
- package/overlays/promtail/verify.sh +16 -10
- package/overlays/pulumi/devcontainer.patch.json +1 -1
- package/overlays/python/setup.sh +28 -9
- package/overlays/python/verify.sh +4 -2
- package/overlays/redpanda/docker-compose.yml +3 -5
- package/overlays/rocm/README.md +227 -0
- package/overlays/rocm/devcontainer.patch.json +4 -0
- package/overlays/rocm/overlay.yml +17 -0
- package/overlays/rocm/setup.sh +45 -0
- package/overlays/rocm/verify.sh +47 -0
- package/overlays/rust/setup.sh +11 -18
- package/overlays/spec-kit/setup.sh +7 -3
- package/overlays/sqlite/setup.sh +14 -14
- package/overlays/sqlserver/docker-compose.yml +3 -3
- package/overlays/sqlserver/verify.sh +22 -5
- package/overlays/tempo/verify.sh +16 -10
- package/overlays/tilt/devcontainer.patch.json +1 -2
- package/overlays/tilt/setup.sh +14 -4
- package/overlays/windsurf-cli/setup.sh +27 -4
- package/overlays/windsurf-cli/verify.sh +13 -3
- package/package.json +2 -1
- package/templates/scripts/setup-utils.sh +228 -0
- package/tool/schema/config.schema.json +110 -8
- package/tool/schema/overlay-manifest.schema.json +5 -0
- package/overlays/.shared/compose/common-healthchecks.yml +0 -38
- /package/overlays/otel-demo-nodejs/{Dockerfile-otel-demo-nodejs → Dockerfile} +0 -0
- /package/overlays/otel-demo-nodejs/{package-otel-demo-nodejs.json → package.json} +0 -0
- /package/overlays/otel-demo-nodejs/{server-otel-demo-nodejs.js → server.js} +0 -0
- /package/overlays/otel-demo-nodejs/{tracing-otel-demo-nodejs.js → tracing.js} +0 -0
- /package/overlays/otel-demo-python/{Dockerfile-otel-demo-python → Dockerfile} +0 -0
- /package/overlays/otel-demo-python/{app-otel-demo-python.py → app.py} +0 -0
- /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
|
|
384
|
-
const
|
|
385
|
-
if (
|
|
386
|
-
|
|
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
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
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
|
-
|
|
654
|
+
candidatePatch.mounts = devcontainer.mounts;
|
|
418
655
|
}
|
|
419
|
-
if (devcontainer.remoteUser
|
|
420
|
-
|
|
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
|
-
|
|
660
|
+
candidatePatch.postCreateCommand = devcontainer.postCreateCommand;
|
|
426
661
|
}
|
|
427
662
|
if (devcontainer.postStartCommand) {
|
|
428
|
-
|
|
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
|
-
|
|
431
|
-
|
|
432
|
-
patch.remoteEnv = unmatchedRemoteEnv;
|
|
684
|
+
if (expectedConfig.postStartCommand) {
|
|
685
|
+
expectedPatch.postStartCommand = expectedConfig.postStartCommand;
|
|
433
686
|
}
|
|
434
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
742
|
+
for (const featureId of Object.keys(customDevcontainerPatch?.features ?? {})) {
|
|
475
743
|
unmatchedItems.push({
|
|
476
|
-
source:
|
|
744
|
+
source: featureId,
|
|
477
745
|
reason: 'No overlay covers this feature — preserve in custom/devcontainer.patch.json',
|
|
478
746
|
});
|
|
479
747
|
}
|
|
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) {
|
|
748
|
+
const preservedExtensions = customDevcontainerPatch?.customizations?.vscode?.extensions ?? [];
|
|
749
|
+
for (const extensionId of preservedExtensions) {
|
|
488
750
|
unmatchedItems.push({
|
|
489
|
-
source: `extension: ${
|
|
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(
|
|
755
|
+
if (Array.isArray(customDevcontainerPatch?.mounts) &&
|
|
756
|
+
customDevcontainerPatch.mounts.length > 0) {
|
|
494
757
|
unmatchedItems.push({
|
|
495
|
-
source: `mounts (${
|
|
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 (
|
|
762
|
+
if (customDevcontainerPatch?.remoteUser) {
|
|
500
763
|
unmatchedItems.push({
|
|
501
|
-
source: `remoteUser: ${
|
|
764
|
+
source: `remoteUser: ${customDevcontainerPatch.remoteUser}`,
|
|
502
765
|
reason: 'Custom remote user — preserve in custom/devcontainer.patch.json',
|
|
503
766
|
});
|
|
504
767
|
}
|
|
505
|
-
const
|
|
506
|
-
|
|
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
|
|
645
|
-
// can be committed alongside
|
|
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 {
|