container-superposition 0.1.4 → 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.
Files changed (98) hide show
  1. package/README.md +74 -1370
  2. package/dist/scripts/init.js +350 -185
  3. package/dist/scripts/init.js.map +1 -1
  4. package/dist/tool/commands/adopt.d.ts +63 -0
  5. package/dist/tool/commands/adopt.d.ts.map +1 -0
  6. package/dist/tool/commands/adopt.js +1104 -0
  7. package/dist/tool/commands/adopt.js.map +1 -0
  8. package/dist/tool/commands/hash.d.ts +36 -0
  9. package/dist/tool/commands/hash.d.ts.map +1 -0
  10. package/dist/tool/commands/hash.js +242 -0
  11. package/dist/tool/commands/hash.js.map +1 -0
  12. package/dist/tool/commands/plan.d.ts +2 -0
  13. package/dist/tool/commands/plan.d.ts.map +1 -1
  14. package/dist/tool/commands/plan.js +262 -42
  15. package/dist/tool/commands/plan.js.map +1 -1
  16. package/dist/tool/schema/project-config.d.ts +17 -0
  17. package/dist/tool/schema/project-config.d.ts.map +1 -0
  18. package/dist/tool/schema/project-config.js +441 -0
  19. package/dist/tool/schema/project-config.js.map +1 -0
  20. package/dist/tool/schema/types.d.ts +39 -1
  21. package/dist/tool/schema/types.d.ts.map +1 -1
  22. package/dist/tool/utils/backup.d.ts +23 -0
  23. package/dist/tool/utils/backup.d.ts.map +1 -0
  24. package/dist/tool/utils/backup.js +123 -0
  25. package/dist/tool/utils/backup.js.map +1 -0
  26. package/docs/README.md +12 -2
  27. package/docs/adopt.md +202 -0
  28. package/docs/custom-patches.md +1 -1
  29. package/docs/discovery-commands.md +55 -3
  30. package/docs/examples.md +40 -6
  31. package/docs/filesystem-contract.md +58 -0
  32. package/docs/hash.md +183 -0
  33. package/docs/minimal-and-editor.md +1 -1
  34. package/docs/overlays.md +70 -0
  35. package/docs/presets-architecture.md +1 -1
  36. package/docs/presets.md +1 -1
  37. package/docs/publishing.md +36 -23
  38. package/docs/security.md +43 -0
  39. package/docs/specs/001-verbose-plan-graph/checklists/requirements.md +36 -0
  40. package/docs/specs/001-verbose-plan-graph/contracts/plan-verbose-output.md +96 -0
  41. package/docs/specs/001-verbose-plan-graph/data-model.md +111 -0
  42. package/docs/specs/001-verbose-plan-graph/plan.md +127 -0
  43. package/docs/specs/001-verbose-plan-graph/quickstart.md +106 -0
  44. package/docs/specs/001-verbose-plan-graph/research.md +100 -0
  45. package/docs/specs/001-verbose-plan-graph/spec.md +128 -0
  46. package/docs/specs/001-verbose-plan-graph/tasks.md +223 -0
  47. package/docs/specs/002-superposition-config-file/checklists/requirements.md +36 -0
  48. package/docs/specs/002-superposition-config-file/contracts/init-project-config.md +98 -0
  49. package/docs/specs/002-superposition-config-file/data-model.md +126 -0
  50. package/docs/specs/002-superposition-config-file/plan.md +213 -0
  51. package/docs/specs/002-superposition-config-file/quickstart.md +140 -0
  52. package/docs/specs/002-superposition-config-file/research.md +144 -0
  53. package/docs/specs/002-superposition-config-file/spec.md +136 -0
  54. package/docs/specs/002-superposition-config-file/tasks.md +215 -0
  55. package/docs/team-workflow.md +33 -1
  56. package/docs/workflows.md +139 -0
  57. package/features/cross-distro-packages/README.md +18 -0
  58. package/features/cross-distro-packages/devcontainer-feature.json +3 -3
  59. package/features/cross-distro-packages/install.sh +49 -7
  60. package/overlays/.presets/sdd.yml +84 -0
  61. package/overlays/README.md +7 -1
  62. package/overlays/amp/README.md +70 -0
  63. package/overlays/amp/devcontainer.patch.json +3 -0
  64. package/overlays/amp/overlay.yml +15 -0
  65. package/overlays/amp/setup.sh +21 -0
  66. package/overlays/amp/verify.sh +21 -0
  67. package/overlays/claude-code/README.md +83 -0
  68. package/overlays/claude-code/devcontainer.patch.json +3 -0
  69. package/overlays/claude-code/overlay.yml +15 -0
  70. package/overlays/claude-code/setup.sh +21 -0
  71. package/overlays/claude-code/verify.sh +21 -0
  72. package/overlays/gemini-cli/README.md +77 -0
  73. package/overlays/gemini-cli/devcontainer.patch.json +3 -0
  74. package/overlays/gemini-cli/overlay.yml +15 -0
  75. package/overlays/gemini-cli/setup.sh +21 -0
  76. package/overlays/gemini-cli/verify.sh +21 -0
  77. package/overlays/opencode/README.md +76 -0
  78. package/overlays/opencode/devcontainer.patch.json +3 -0
  79. package/overlays/opencode/overlay.yml +14 -0
  80. package/overlays/opencode/setup.sh +21 -0
  81. package/overlays/opencode/verify.sh +21 -0
  82. package/overlays/pandoc/README.md +279 -0
  83. package/overlays/pandoc/devcontainer.patch.json +14 -0
  84. package/overlays/pandoc/overlay.yml +19 -0
  85. package/overlays/pandoc/setup.sh +94 -0
  86. package/overlays/pandoc/verify.sh +13 -0
  87. package/overlays/spec-kit/README.md +181 -0
  88. package/overlays/spec-kit/devcontainer.patch.json +6 -0
  89. package/overlays/spec-kit/overlay.yml +19 -0
  90. package/overlays/spec-kit/setup.sh +45 -0
  91. package/overlays/spec-kit/verify.sh +33 -0
  92. package/overlays/windsurf-cli/README.md +69 -0
  93. package/overlays/windsurf-cli/devcontainer.patch.json +3 -0
  94. package/overlays/windsurf-cli/overlay.yml +15 -0
  95. package/overlays/windsurf-cli/setup.sh +21 -0
  96. package/overlays/windsurf-cli/verify.sh +21 -0
  97. package/package.json +1 -1
  98. package/tool/schema/config.schema.json +138 -9
@@ -0,0 +1,1104 @@
1
+ /**
2
+ * Adopt command - Analyse an existing .devcontainer/ and suggest overlay-based configuration.
3
+ *
4
+ * Detection tables (feature URIs, VS Code extensions, docker image prefixes) are built
5
+ * dynamically from the overlay registry — no hardcoded overlay IDs here.
6
+ */
7
+ import * as fs from 'fs';
8
+ import * as path from 'path';
9
+ import { fileURLToPath } from 'url';
10
+ import chalk from 'chalk';
11
+ import boxen from 'boxen';
12
+ import yaml from 'js-yaml';
13
+ import { confirm } from '@inquirer/prompts';
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';
18
+ import { getToolVersion } from '../utils/version.js';
19
+ import { isInsideGitRepo, createBackup, ensureBackupPatternsInGitignore } from '../utils/backup.js';
20
+ // Get __dirname equivalent in ESM
21
+ const __filename = fileURLToPath(import.meta.url);
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');
30
+ /**
31
+ * Strip the version suffix from a devcontainer feature URI so that prefix
32
+ * matching works regardless of pinned version.
33
+ * e.g. "ghcr.io/devcontainers/features/node:1" → "ghcr.io/devcontainers/features/node"
34
+ */
35
+ function stripFeatureVersion(featureId) {
36
+ return featureId.replace(/:\d+$/, '').replace(/@[^@]+$/, '');
37
+ }
38
+ /**
39
+ * Extract the image name prefix (registry + repo, without tag) from a docker
40
+ * image string that may contain variable substitutions.
41
+ * e.g. "postgres:${POSTGRES_VERSION:-16}-alpine" → "postgres"
42
+ * e.g. "grafana/grafana:${VERSION:-latest}" → "grafana/grafana"
43
+ */
44
+ function extractImagePrefix(image) {
45
+ // Strip all ${...} variable substitutions, then split on the first colon
46
+ return image
47
+ .replace(/\$\{[^}]+\}/g, '')
48
+ .split(':')[0]
49
+ .replace(/-$/, '')
50
+ .trim();
51
+ }
52
+ /**
53
+ * Build detection tables by scanning every overlay's devcontainer.patch.json
54
+ * and docker-compose.yml. This means adopt automatically supports any overlay
55
+ * that exists in the registry — no hardcoded lists.
56
+ */
57
+ /**
58
+ * Scoring weights for feature/extension match quality.
59
+ * Higher = better match. Used so that the overlay whose identity most closely
60
+ * matches a feature name wins when multiple overlays share the same feature.
61
+ */
62
+ const SCORE_EXACT = 100; // overlay ID === feature segment exactly
63
+ const SCORE_ID_STARTS_WITH_SEGMENT = 80; // e.g. "nodejs" starts with segment "node"
64
+ const SCORE_SEGMENT_STARTS_WITH_ID = 60; // segment starts with overlay ID
65
+ const SCORE_SEGMENT_CONTAINS_ID = 40; // segment contains overlay ID
66
+ const SCORE_ID_CONTAINS_SEGMENT = 20; // overlay ID contains segment
67
+ const SCORE_NO_MATCH = 0; // no textual relationship
68
+ /**
69
+ * Score how well an overlay ID matches a feature URI.
70
+ * Higher score = better match. Used to resolve conflicts when multiple overlays
71
+ * share the same devcontainer feature (e.g. `nodejs` and `bun` both include the
72
+ * `node` feature; `nodejs` should win because its ID starts with the feature's
73
+ * last path segment `node`).
74
+ */
75
+ function featureMatchScore(overlayId, strippedFeatureUri) {
76
+ // Take the last path segment of the feature URI (the feature name itself)
77
+ const segment = strippedFeatureUri.split('/').pop() ?? '';
78
+ if (overlayId === segment)
79
+ return SCORE_EXACT;
80
+ if (overlayId.startsWith(segment))
81
+ return SCORE_ID_STARTS_WITH_SEGMENT;
82
+ if (segment.startsWith(overlayId))
83
+ return SCORE_SEGMENT_STARTS_WITH_ID;
84
+ if (segment.includes(overlayId))
85
+ return SCORE_SEGMENT_CONTAINS_ID;
86
+ if (overlayId.includes(segment))
87
+ return SCORE_ID_CONTAINS_SEGMENT;
88
+ return SCORE_NO_MATCH;
89
+ }
90
+ /**
91
+ * Score how well an overlay ID matches a VS Code extension ID.
92
+ * Prevents, e.g., the `bun` overlay's eslint extension from claiming eslint
93
+ * over the `nodejs` overlay which owns it more naturally.
94
+ */
95
+ function extensionMatchScore(overlayId, extensionId) {
96
+ // Publisher.name → just use the name part for comparison
97
+ const name = extensionId.split('.').pop() ?? extensionId;
98
+ if (name.includes(overlayId))
99
+ return SCORE_ID_STARTS_WITH_SEGMENT;
100
+ if (overlayId.includes(name))
101
+ return SCORE_SEGMENT_STARTS_WITH_ID;
102
+ return SCORE_NO_MATCH;
103
+ }
104
+ export function buildDetectionTables(overlaysDir, overlaysConfig) {
105
+ const featureToOverlayScored = {};
106
+ const imagePrefixToOverlay = [];
107
+ const extensionToOverlayScored = {};
108
+ for (const overlay of overlaysConfig.overlays) {
109
+ const overlayDir = path.join(overlaysDir, overlay.id);
110
+ // ── devcontainer.patch.json ──────────────────────────────────────
111
+ const patchPath = path.join(overlayDir, 'devcontainer.patch.json');
112
+ if (fs.existsSync(patchPath)) {
113
+ try {
114
+ const patch = JSON.parse(fs.readFileSync(patchPath, 'utf8'));
115
+ // Features (skip local paths like ./features/...)
116
+ for (const featureId of Object.keys(patch.features ?? {})) {
117
+ if (featureId.startsWith('./') || featureId.startsWith('../'))
118
+ continue;
119
+ const stripped = stripFeatureVersion(featureId);
120
+ const score = featureMatchScore(overlay.id, stripped);
121
+ const existing = featureToOverlayScored[stripped];
122
+ if (!existing || score > existing.score) {
123
+ featureToOverlayScored[stripped] = { overlayId: overlay.id, score };
124
+ }
125
+ }
126
+ // VS Code extensions — also scored so the most specific overlay wins
127
+ for (const extId of (patch.customizations?.vscode?.extensions ?? [])) {
128
+ const lc = extId.toLowerCase();
129
+ const score = extensionMatchScore(overlay.id, lc);
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
+ }
141
+ if (!existing || score > existing.score) {
142
+ extensionToOverlayScored[lc] = { overlayId: overlay.id, score };
143
+ }
144
+ }
145
+ }
146
+ catch {
147
+ // Skip malformed patch files
148
+ }
149
+ }
150
+ // ── docker-compose.yml ───────────────────────────────────────────
151
+ const composePath = path.join(overlayDir, 'docker-compose.yml');
152
+ if (fs.existsSync(composePath)) {
153
+ try {
154
+ const compose = yaml.load(fs.readFileSync(composePath, 'utf8'));
155
+ for (const serviceDef of Object.values(compose?.services ?? {})) {
156
+ const image = serviceDef?.image ?? '';
157
+ if (!image)
158
+ continue;
159
+ const prefix = extractImagePrefix(image);
160
+ if (prefix && !imagePrefixToOverlay.find((p) => p.prefix === prefix)) {
161
+ imagePrefixToOverlay.push({ prefix, overlayId: overlay.id });
162
+ }
163
+ }
164
+ }
165
+ catch {
166
+ // Skip malformed compose files
167
+ }
168
+ }
169
+ }
170
+ return {
171
+ featureToOverlay: Object.fromEntries(Object.entries(featureToOverlayScored).map(([k, v]) => [k, v.overlayId])),
172
+ imagePrefixToOverlay,
173
+ extensionToOverlay: Object.fromEntries(Object.entries(extensionToOverlayScored).map(([k, v]) => [k, v.overlayId])),
174
+ };
175
+ }
176
+ // ---------------------------------------------------------------------------
177
+ // Matching helpers (use dynamic tables)
178
+ // ---------------------------------------------------------------------------
179
+ function matchFeature(featureId, tables) {
180
+ const stripped = stripFeatureVersion(featureId);
181
+ // Exact match first
182
+ if (tables.featureToOverlay[stripped])
183
+ return tables.featureToOverlay[stripped];
184
+ // Prefix match (handles minor version variation within the same feature family)
185
+ for (const [uri, overlayId] of Object.entries(tables.featureToOverlay)) {
186
+ if (stripped.startsWith(uri))
187
+ return overlayId;
188
+ }
189
+ return null;
190
+ }
191
+ function matchImage(image, tables) {
192
+ const prefix = extractImagePrefix(image);
193
+ const entry = tables.imagePrefixToOverlay.find((p) => prefix === p.prefix || prefix.startsWith(p.prefix));
194
+ return entry?.overlayId ?? null;
195
+ }
196
+ function matchExtension(extensionId, tables) {
197
+ return tables.extensionToOverlay[extensionId.toLowerCase()] ?? null;
198
+ }
199
+ function withSchemaFirst(document) {
200
+ const { $schema, ...rest } = document;
201
+ return typeof $schema === 'string' && $schema.trim() !== '' ? { $schema, ...rest } : rest;
202
+ }
203
+ // ---------------------------------------------------------------------------
204
+ // Docker Compose path resolution
205
+ // ---------------------------------------------------------------------------
206
+ /**
207
+ * Resolve all docker-compose file paths referenced by a devcontainer.json.
208
+ *
209
+ * `dockerComposeFile` may be a string or an array of strings; each path is
210
+ * resolved relative to the devcontainer directory. Falls back to the
211
+ * conventional `docker-compose.yml` in the same directory.
212
+ */
213
+ export function resolveComposePaths(devcontainer, devcontainerDir) {
214
+ const field = devcontainer.dockerComposeFile;
215
+ if (field) {
216
+ // dockerComposeFile is explicitly set — use exactly those paths (no fallback).
217
+ // Deduplicate in case the array contains the same path more than once.
218
+ const rawPaths = Array.isArray(field) ? field : [field];
219
+ return [...new Set(rawPaths.map((raw) => path.resolve(devcontainerDir, raw)))].filter((p) => fs.existsSync(p));
220
+ }
221
+ // No dockerComposeFile field — fall back to the conventional location only
222
+ const conventional = path.join(devcontainerDir, 'docker-compose.yml');
223
+ return fs.existsSync(conventional) ? [conventional] : [];
224
+ }
225
+ // ---------------------------------------------------------------------------
226
+ // Analysis helpers
227
+ // ---------------------------------------------------------------------------
228
+ function analyseFeatures(devcontainer, tables) {
229
+ const detections = [];
230
+ const unmatchedFeatures = {};
231
+ const features = devcontainer.features ?? {};
232
+ for (const [featureId, featureConfig] of Object.entries(features)) {
233
+ if (featureId.startsWith('./') || featureId.startsWith('../'))
234
+ continue;
235
+ const overlayId = matchFeature(featureId, tables);
236
+ if (overlayId) {
237
+ detections.push({
238
+ source: featureId,
239
+ overlayId,
240
+ confidence: 'exact',
241
+ sourceType: 'feature',
242
+ });
243
+ }
244
+ else {
245
+ unmatchedFeatures[featureId] = featureConfig;
246
+ }
247
+ }
248
+ return { detections, unmatchedFeatures };
249
+ }
250
+ function analyseExtensions(devcontainer, tables) {
251
+ const detections = [];
252
+ const unmatchedExtensions = [];
253
+ const extensions = devcontainer.customizations?.vscode?.extensions ?? [];
254
+ for (const extId of extensions) {
255
+ const overlayId = matchExtension(extId, tables);
256
+ if (overlayId) {
257
+ detections.push({
258
+ source: `extension: ${extId}`,
259
+ overlayId,
260
+ confidence: 'heuristic',
261
+ sourceType: 'extension',
262
+ });
263
+ }
264
+ else {
265
+ unmatchedExtensions.push(extId);
266
+ }
267
+ }
268
+ return { detections, unmatchedExtensions };
269
+ }
270
+ function analyseDockerCompose(composePaths, tables) {
271
+ const detections = [];
272
+ const unmatchedServices = {};
273
+ for (const composePath of composePaths) {
274
+ let parsed;
275
+ try {
276
+ parsed = yaml.load(fs.readFileSync(composePath, 'utf8'));
277
+ }
278
+ catch {
279
+ continue;
280
+ }
281
+ for (const [serviceName, serviceDef] of Object.entries(parsed?.services ?? {})) {
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
+ }
295
+ if (!image)
296
+ continue;
297
+ const overlayId = matchImage(image, tables);
298
+ if (overlayId) {
299
+ detections.push({
300
+ source: `service: ${serviceName} (image: ${image})`,
301
+ overlayId,
302
+ confidence: 'exact',
303
+ sourceType: 'service',
304
+ });
305
+ }
306
+ else {
307
+ unmatchedServices[serviceName] = serviceDef;
308
+ }
309
+ }
310
+ }
311
+ return { detections, unmatchedServices };
312
+ }
313
+ function analyseRemoteEnv(devcontainer, tables) {
314
+ const detections = [];
315
+ const unmatchedRemoteEnv = {};
316
+ const env = devcontainer.remoteEnv ?? {};
317
+ // Build env-var prefix patterns from overlay IDs that exist in the registry
318
+ // Supplement with a small set of well-known patterns that aren't derivable from overlay files
319
+ const ENV_PATTERNS = [
320
+ { pattern: /^POSTGRES_/, overlayId: 'postgres' },
321
+ { pattern: /^PG(HOST|PORT|USER|PASSWORD|DB)$/, overlayId: 'postgres' },
322
+ { pattern: /^REDIS_/, overlayId: 'redis' },
323
+ { pattern: /^MONGO(DB)?_/, overlayId: 'mongodb' },
324
+ { pattern: /^MYSQL_/, overlayId: 'mysql' },
325
+ { pattern: /^MSSQL_/, overlayId: 'sqlserver' },
326
+ { pattern: /^AWS_/, overlayId: 'aws-cli' },
327
+ { pattern: /^AZURE_/, overlayId: 'azure-cli' },
328
+ { pattern: /^GOOGLE_CLOUD_/, overlayId: 'gcloud' },
329
+ ];
330
+ for (const [key, value] of Object.entries(env)) {
331
+ let matched = false;
332
+ for (const { pattern, overlayId } of ENV_PATTERNS) {
333
+ if (pattern.test(key)) {
334
+ detections.push({
335
+ source: `remoteEnv: ${key}`,
336
+ overlayId,
337
+ confidence: 'heuristic',
338
+ sourceType: 'remoteenv',
339
+ });
340
+ matched = true;
341
+ break;
342
+ }
343
+ }
344
+ if (!matched) {
345
+ unmatchedRemoteEnv[key] = value;
346
+ }
347
+ }
348
+ return { detections, unmatchedRemoteEnv };
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
+ }
549
+ /**
550
+ * Deduplicate: keep one entry per overlayId, preferring `exact` over `heuristic`.
551
+ */
552
+ function deduplicateDetections(detections) {
553
+ const seen = new Map();
554
+ for (const d of detections) {
555
+ const existing = seen.get(d.overlayId);
556
+ if (!existing) {
557
+ seen.set(d.overlayId, d);
558
+ }
559
+ else if (d.confidence === 'exact' && existing.confidence !== 'exact') {
560
+ seen.set(d.overlayId, d);
561
+ }
562
+ }
563
+ return Array.from(seen.values());
564
+ }
565
+ /**
566
+ * Build the suggested `init` command using overlay categories from the registry.
567
+ */
568
+ function buildSuggestedCommand(overlayIds, stack, overlaysConfig) {
569
+ const language = [];
570
+ const database = [];
571
+ const observability = [];
572
+ const cloudTools = [];
573
+ const devTools = [];
574
+ const other = [];
575
+ for (const id of overlayIds) {
576
+ const overlay = overlaysConfig.overlays.find((o) => o.id === id);
577
+ if (!overlay)
578
+ continue; // Skip overlay IDs not in the registry
579
+ switch (overlay.category) {
580
+ case 'language':
581
+ language.push(id);
582
+ break;
583
+ case 'database':
584
+ database.push(id);
585
+ break;
586
+ case 'observability':
587
+ observability.push(id);
588
+ break;
589
+ case 'cloud':
590
+ cloudTools.push(id);
591
+ break;
592
+ case 'dev':
593
+ devTools.push(id);
594
+ break;
595
+ default:
596
+ other.push(id);
597
+ }
598
+ }
599
+ const parts = ['container-superposition init', `--stack ${stack}`];
600
+ if (language.length > 0)
601
+ parts.push(`--language ${language.join(',')}`);
602
+ if (database.length > 0)
603
+ parts.push(`--database ${database.join(',')}`);
604
+ if (observability.length > 0)
605
+ parts.push(`--observability ${observability.join(',')}`);
606
+ if (cloudTools.length > 0)
607
+ parts.push(`--cloud-tools ${cloudTools.join(',')}`);
608
+ if (devTools.length > 0)
609
+ parts.push(`--dev-tools ${devTools.join(',')}`);
610
+ if (other.length > 0)
611
+ parts.push(`--overlays ${other.join(',')}`);
612
+ return parts.join(' ');
613
+ }
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;
636
+ }
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
+ }
679
+ if (Array.isArray(devcontainer.mounts) && devcontainer.mounts.length > 0) {
680
+ candidatePatch.mounts = devcontainer.mounts;
681
+ }
682
+ if (devcontainer.remoteUser) {
683
+ candidatePatch.remoteUser = devcontainer.remoteUser;
684
+ }
685
+ if (devcontainer.postCreateCommand) {
686
+ candidatePatch.postCreateCommand = devcontainer.postCreateCommand;
687
+ }
688
+ if (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;
697
+ }
698
+ if (expectedConfig.customizations) {
699
+ expectedPatch.customizations = expectedConfig.customizations;
700
+ }
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;
718
+ }
719
+ function buildCustomComposePatch(unmatchedServices, expectedCompose) {
720
+ if (Object.keys(unmatchedServices).length === 0)
721
+ return null;
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;
726
+ }
727
+ // ---------------------------------------------------------------------------
728
+ // Full analysis
729
+ // ---------------------------------------------------------------------------
730
+ export function analyseDevcontainer(dir, overlaysConfig, tables, overlaysDir) {
731
+ const devcontainerPath = path.join(dir, 'devcontainer.json');
732
+ let devcontainer = {};
733
+ if (fs.existsSync(devcontainerPath)) {
734
+ try {
735
+ devcontainer = JSON.parse(fs.readFileSync(devcontainerPath, 'utf8'));
736
+ }
737
+ catch {
738
+ devcontainer = {};
739
+ }
740
+ }
741
+ const composePaths = resolveComposePaths(devcontainer, dir);
742
+ const featureResult = analyseFeatures(devcontainer, tables);
743
+ const composeResult = analyseDockerCompose(composePaths, tables);
744
+ const extensionResult = analyseExtensions(devcontainer, tables);
745
+ const remoteEnvResult = analyseRemoteEnv(devcontainer, tables);
746
+ const commandResult = analyseCommands(devcontainer, overlaysConfig);
747
+ const detections = deduplicateDetections([
748
+ ...featureResult.detections,
749
+ ...composeResult.detections,
750
+ ...extensionResult.detections,
751
+ ...remoteEnvResult.detections,
752
+ ...commandResult.detections,
753
+ ]);
754
+ const hasDockerCompose = composePaths.length > 0;
755
+ const hasServiceSignals = detections.some((d) => d.sourceType === 'service');
756
+ const suggestedStack = hasDockerCompose || hasServiceSignals ? 'compose' : 'plain';
757
+ const knownIds = new Set(overlaysConfig.overlays.map((o) => o.id));
758
+ const suggestedOverlays = [...new Set(detections.map((d) => d.overlayId))].filter((id) => knownIds.has(id));
759
+ const suggestedCommand = buildSuggestedCommand(suggestedOverlays, suggestedStack, overlaysConfig);
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);
767
+ const unmatchedItems = [];
768
+ for (const featureId of Object.keys(customDevcontainerPatch?.features ?? {})) {
769
+ unmatchedItems.push({
770
+ source: featureId,
771
+ reason: 'No overlay covers this feature — preserve in custom/devcontainer.patch.json',
772
+ });
773
+ }
774
+ const preservedExtensions = customDevcontainerPatch?.customizations?.vscode?.extensions ?? [];
775
+ for (const extensionId of preservedExtensions) {
776
+ unmatchedItems.push({
777
+ source: `extension: ${extensionId}`,
778
+ reason: 'No overlay installs this extension — preserve in custom/devcontainer.patch.json',
779
+ });
780
+ }
781
+ if (Array.isArray(customDevcontainerPatch?.mounts) &&
782
+ customDevcontainerPatch.mounts.length > 0) {
783
+ unmatchedItems.push({
784
+ source: `mounts (${customDevcontainerPatch.mounts.length} mount(s))`,
785
+ reason: 'Custom mounts are not managed by overlays — preserve in custom/devcontainer.patch.json',
786
+ });
787
+ }
788
+ if (customDevcontainerPatch?.remoteUser) {
789
+ unmatchedItems.push({
790
+ source: `remoteUser: ${customDevcontainerPatch.remoteUser}`,
791
+ reason: 'Custom remote user — preserve in custom/devcontainer.patch.json',
792
+ });
793
+ }
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
+ }
819
+ return {
820
+ detections,
821
+ unmatchedItems,
822
+ customDevcontainerPatch,
823
+ customComposePatch,
824
+ suggestedStack,
825
+ suggestedOverlays,
826
+ suggestedCommand,
827
+ hasDockerCompose,
828
+ };
829
+ }
830
+ // ---------------------------------------------------------------------------
831
+ // Output formatting
832
+ // ---------------------------------------------------------------------------
833
+ function formatConfidence(c) {
834
+ return c === 'exact' ? chalk.green('exact') : chalk.yellow('heuristic');
835
+ }
836
+ function formatAnalysisTable(detections, knownIds) {
837
+ if (detections.length === 0)
838
+ return chalk.dim(' (no recognisable patterns found)');
839
+ const sourceColWidth = 58;
840
+ const arrowColWidth = 4;
841
+ const overlayColWidth = 22;
842
+ const lines = [
843
+ chalk.bold('Source'.padEnd(sourceColWidth) +
844
+ '→'.padEnd(arrowColWidth) +
845
+ 'Overlay'.padEnd(overlayColWidth) +
846
+ 'Confidence'),
847
+ '─'.repeat(sourceColWidth + arrowColWidth + overlayColWidth + 12),
848
+ ];
849
+ for (const d of detections) {
850
+ const src = d.source.slice(0, sourceColWidth - 2).padEnd(sourceColWidth);
851
+ const overlay = knownIds.has(d.overlayId)
852
+ ? chalk.cyan(d.overlayId.padEnd(overlayColWidth))
853
+ : chalk.dim(`${d.overlayId} (unknown)`.padEnd(overlayColWidth));
854
+ lines.push(`${src}${chalk.dim('→'.padEnd(arrowColWidth))}${overlay}${formatConfidence(d.confidence)}`);
855
+ }
856
+ return lines.join('\n');
857
+ }
858
+ function formatUnmatchedTable(items) {
859
+ const s = 60;
860
+ const lines = [chalk.bold('Source'.padEnd(s) + 'Action'), '─'.repeat(s + 52)];
861
+ for (const item of items) {
862
+ lines.push(`${item.source.slice(0, s - 2).padEnd(s)}${chalk.dim(item.reason)}`);
863
+ }
864
+ return lines.join('\n');
865
+ }
866
+ // ---------------------------------------------------------------------------
867
+ // Main command
868
+ // ---------------------------------------------------------------------------
869
+ export async function adoptCommand(overlaysConfig, overlaysDir, options) {
870
+ const dir = options.dir ?? './.devcontainer';
871
+ const absoluteDir = path.resolve(dir);
872
+ if (!fs.existsSync(absoluteDir)) {
873
+ console.error(chalk.red(`✗ Directory not found: ${absoluteDir}`));
874
+ console.log(chalk.dim(`\n💡 Specify a different path with --dir, e.g. --dir path/to/.devcontainer\n`));
875
+ process.exit(1);
876
+ }
877
+ const devcontainerJsonPath = path.join(absoluteDir, 'devcontainer.json');
878
+ if (!fs.existsSync(devcontainerJsonPath)) {
879
+ console.error(chalk.red(`✗ No devcontainer.json found in ${absoluteDir}`));
880
+ process.exit(1);
881
+ }
882
+ // Build detection tables dynamically from the overlay registry
883
+ const tables = buildDetectionTables(overlaysDir, overlaysConfig);
884
+ // ── Analyse ────────────────────────────────────────────────────────────
885
+ const analysis = analyseDevcontainer(absoluteDir, overlaysConfig, tables, overlaysDir);
886
+ // ── JSON output (no decoration) ────────────────────────────────────────
887
+ if (options.json) {
888
+ console.log(JSON.stringify({
889
+ dir: absoluteDir,
890
+ detections: analysis.detections,
891
+ unmatchedItems: analysis.unmatchedItems,
892
+ customDevcontainerPatch: analysis.customDevcontainerPatch,
893
+ customComposePatch: analysis.customComposePatch,
894
+ suggestedStack: analysis.suggestedStack,
895
+ suggestedOverlays: analysis.suggestedOverlays,
896
+ suggestedCommand: analysis.suggestedCommand,
897
+ }, null, 2));
898
+ return;
899
+ }
900
+ // ── Header ─────────────────────────────────────────────────────────────
901
+ console.log('\n' +
902
+ boxen(chalk.bold('🔍 Adopt Analysis'), {
903
+ padding: 0.5,
904
+ borderColor: 'cyan',
905
+ borderStyle: 'round',
906
+ }));
907
+ console.log(chalk.dim(`\nAnalysing ${path.relative(process.cwd(), devcontainerJsonPath)}...`));
908
+ let devcontainer;
909
+ try {
910
+ devcontainer = JSON.parse(fs.readFileSync(devcontainerJsonPath, 'utf8'));
911
+ }
912
+ catch (error) {
913
+ console.error(chalk.red(`\n✗ Failed to parse ${path.relative(process.cwd(), devcontainerJsonPath)}.` +
914
+ ' Please ensure it contains valid JSON.'));
915
+ if (error instanceof Error && error.message) {
916
+ console.error(chalk.red(` ${error.message}`));
917
+ }
918
+ process.exitCode = 1;
919
+ return;
920
+ }
921
+ for (const cp of resolveComposePaths(devcontainer, absoluteDir)) {
922
+ console.log(chalk.dim(`Analysing ${path.relative(process.cwd(), cp)}...`));
923
+ }
924
+ // ── Matched detections table ───────────────────────────────────────────
925
+ const knownIds = new Set(overlaysConfig.overlays.map((o) => o.id));
926
+ console.log('\n' + chalk.bold('Detected features / services → suggested overlays'));
927
+ console.log(chalk.dim('─'.repeat(80)));
928
+ console.log(formatAnalysisTable(analysis.detections, knownIds));
929
+ // ── Unmatched items table ──────────────────────────────────────────────
930
+ if (analysis.unmatchedItems.length > 0) {
931
+ console.log('\n' + chalk.bold('Items with no overlay equivalent → custom/'));
932
+ console.log(chalk.dim('─'.repeat(80)));
933
+ console.log(formatUnmatchedTable(analysis.unmatchedItems));
934
+ }
935
+ // ── No overlays found ──────────────────────────────────────────────────
936
+ if (analysis.suggestedOverlays.length === 0) {
937
+ console.log('\n' +
938
+ chalk.yellow('⚠ No recognisable overlay patterns detected.\n' +
939
+ ' Your devcontainer may use entirely custom configuration\n' +
940
+ ' that does not map to any available overlays.'));
941
+ console.log(chalk.dim('\n💡 You can still run:\n container-superposition init\n to create a new configuration interactively.\n'));
942
+ return;
943
+ }
944
+ // ── Suggested command ──────────────────────────────────────────────────
945
+ console.log('\n' + chalk.bold('Suggested command:'));
946
+ console.log(' ' + chalk.cyan(analysis.suggestedCommand));
947
+ if (analysis.customDevcontainerPatch || analysis.customComposePatch) {
948
+ console.log(chalk.dim('\n💡 Custom patches will be written to .devcontainer/custom/ to preserve\n' +
949
+ ' any configuration that has no overlay equivalent.'));
950
+ }
951
+ if (options.dryRun) {
952
+ console.log(chalk.dim('\n(--dry-run: no files written)\n'));
953
+ return;
954
+ }
955
+ // ── Guard: existing files ──────────────────────────────────────────────
956
+ // superposition.json and optional project config go to the project root
957
+ // (parent of .devcontainer/) so they can be committed alongside app code.
958
+ const projectRoot = path.dirname(absoluteDir);
959
+ const manifestPath = path.join(projectRoot, 'superposition.json');
960
+ const customDir = path.join(absoluteDir, 'custom');
961
+ const customPatchPath = path.join(customDir, 'devcontainer.patch.json');
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;
971
+ const existingFiles = [];
972
+ if (fs.existsSync(manifestPath))
973
+ existingFiles.push(path.relative(process.cwd(), manifestPath));
974
+ if (projectFilePath && fs.existsSync(projectFilePath)) {
975
+ existingFiles.push(path.relative(process.cwd(), projectFilePath));
976
+ }
977
+ if (analysis.customDevcontainerPatch && fs.existsSync(customPatchPath))
978
+ existingFiles.push(path.relative(process.cwd(), customPatchPath));
979
+ if (analysis.customComposePatch && fs.existsSync(customComposePath))
980
+ existingFiles.push(path.relative(process.cwd(), customComposePath));
981
+ if (existingFiles.length > 0 && !options.force) {
982
+ console.log('\n' +
983
+ chalk.yellow('⚠ The following file(s) already exist:\n' +
984
+ existingFiles.map((f) => ` • ${f}`).join('\n') +
985
+ '\n Use --force to overwrite them.'));
986
+ return;
987
+ }
988
+ // ── Prompt ────────────────────────────────────────────────────────────
989
+ const hasCustomFiles = analysis.customDevcontainerPatch || analysis.customComposePatch;
990
+ const projectSelection = projectFilePath
991
+ ? buildProjectConfigSelection(analysis, overlaysConfig, projectRoot, absoluteDir, devcontainer)
992
+ : null;
993
+ let confirmed;
994
+ try {
995
+ confirmed = await confirm({
996
+ message: `Generate superposition.json${projectFilePath ? ', project config' : ''}${hasCustomFiles ? ', and custom/ patch files' : ''} from these suggestions?`,
997
+ default: true,
998
+ });
999
+ }
1000
+ catch {
1001
+ // AbortPromptError (Ctrl+C) or ExitPromptError (non-interactive) — treat as "no"
1002
+ confirmed = false;
1003
+ }
1004
+ if (!confirmed) {
1005
+ console.log(chalk.dim('\nAborted. No files written.\n'));
1006
+ return;
1007
+ }
1008
+ // ── Backup (same logic as regen) ───────────────────────────────────────
1009
+ // Backup happens AFTER confirmation and BEFORE writes so we only create
1010
+ // backups when we're actually about to change things.
1011
+ //
1012
+ // --backup → force backup
1013
+ // --no-backup → skip backup
1014
+ // (neither) → skip when inside a git repo (git already tracks history)
1015
+ const inGitRepo = isInsideGitRepo(absoluteDir);
1016
+ let shouldBackup;
1017
+ if (options.backup === true) {
1018
+ shouldBackup = true;
1019
+ }
1020
+ else if (options.backup === false) {
1021
+ shouldBackup = false;
1022
+ }
1023
+ else {
1024
+ shouldBackup = !inGitRepo;
1025
+ if (!shouldBackup) {
1026
+ console.log(chalk.dim('\nℹ Skipping backup — git repo detected (use --backup to force one)'));
1027
+ }
1028
+ }
1029
+ if (shouldBackup) {
1030
+ const backupPath = await createBackup(absoluteDir, options.backupDir);
1031
+ if (backupPath) {
1032
+ console.log(chalk.dim(`\n💾 Backup created at ${path.relative(process.cwd(), backupPath)}`));
1033
+ ensureBackupPatternsInGitignore(absoluteDir);
1034
+ }
1035
+ }
1036
+ // ── Write superposition.json ───────────────────────────────────────────
1037
+ const manifest = {
1038
+ manifestVersion: CURRENT_MANIFEST_VERSION,
1039
+ generatedBy: `container-superposition@${getToolVersion()} adopt`,
1040
+ generated: new Date().toISOString(),
1041
+ baseTemplate: analysis.suggestedStack,
1042
+ baseImage: 'bookworm',
1043
+ overlays: analysis.suggestedOverlays,
1044
+ containerName: projectSelection?.containerName,
1045
+ };
1046
+ try {
1047
+ fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n', 'utf8');
1048
+ console.log('\n' + chalk.green(`✓ Written ${path.relative(process.cwd(), manifestPath)}`));
1049
+ }
1050
+ catch (err) {
1051
+ console.error(chalk.red('✗ Failed to write superposition.json:'), err);
1052
+ process.exit(1);
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
+ }
1064
+ // ── Write custom patches ───────────────────────────────────────────────
1065
+ if (hasCustomFiles) {
1066
+ try {
1067
+ fs.mkdirSync(customDir, { recursive: true });
1068
+ }
1069
+ catch (err) {
1070
+ console.error(chalk.red('✗ Failed to create custom/ directory:'), err);
1071
+ process.exit(1);
1072
+ }
1073
+ }
1074
+ if (analysis.customDevcontainerPatch) {
1075
+ try {
1076
+ fs.writeFileSync(customPatchPath, JSON.stringify(withSchemaFirst(analysis.customDevcontainerPatch), null, 4) + '\n', 'utf8');
1077
+ console.log(chalk.green(`✓ Written ${path.relative(process.cwd(), customPatchPath)}`));
1078
+ }
1079
+ catch (err) {
1080
+ console.error(chalk.red('✗ Failed to write custom/devcontainer.patch.json:'), err);
1081
+ process.exit(1);
1082
+ }
1083
+ }
1084
+ if (analysis.customComposePatch) {
1085
+ try {
1086
+ const header = '# Custom Docker Compose services preserved from original configuration.\n' +
1087
+ '# These services have no equivalent overlay and will be merged into\n' +
1088
+ '# docker-compose.yml during regeneration.\n';
1089
+ fs.writeFileSync(customComposePath, header + yaml.dump(analysis.customComposePatch), 'utf8');
1090
+ console.log(chalk.green(`✓ Written ${path.relative(process.cwd(), customComposePath)}`));
1091
+ }
1092
+ catch (err) {
1093
+ console.error(chalk.red('✗ Failed to write custom/docker-compose.patch.yml:'), err);
1094
+ process.exit(1);
1095
+ }
1096
+ }
1097
+ console.log(chalk.dim('\n💡 Next steps:\n' +
1098
+ ' 1. Review and adjust superposition.json as needed\n' +
1099
+ ' 2. Run: container-superposition regen\n' +
1100
+ (hasCustomFiles
1101
+ ? ' 3. Review custom/ patches — they will be merged automatically on every regen\n'
1102
+ : '')));
1103
+ }
1104
+ //# sourceMappingURL=adopt.js.map