container-superposition 0.1.4 → 0.1.5

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 (90) hide show
  1. package/README.md +72 -1370
  2. package/dist/scripts/init.js +333 -185
  3. package/dist/scripts/init.js.map +1 -1
  4. package/dist/tool/commands/adopt.d.ts +62 -0
  5. package/dist/tool/commands/adopt.d.ts.map +1 -0
  6. package/dist/tool/commands/adopt.js +767 -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 +15 -0
  17. package/dist/tool/schema/project-config.d.ts.map +1 -0
  18. package/dist/tool/schema/project-config.js +359 -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 +196 -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 +60 -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 +208 -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 +130 -0
  54. package/docs/specs/002-superposition-config-file/tasks.md +213 -0
  55. package/docs/team-workflow.md +27 -1
  56. package/docs/workflows.md +136 -0
  57. package/overlays/.presets/sdd.yml +84 -0
  58. package/overlays/README.md +7 -1
  59. package/overlays/amp/README.md +70 -0
  60. package/overlays/amp/devcontainer.patch.json +3 -0
  61. package/overlays/amp/overlay.yml +15 -0
  62. package/overlays/amp/setup.sh +21 -0
  63. package/overlays/amp/verify.sh +21 -0
  64. package/overlays/claude-code/README.md +83 -0
  65. package/overlays/claude-code/devcontainer.patch.json +3 -0
  66. package/overlays/claude-code/overlay.yml +15 -0
  67. package/overlays/claude-code/setup.sh +21 -0
  68. package/overlays/claude-code/verify.sh +21 -0
  69. package/overlays/gemini-cli/README.md +77 -0
  70. package/overlays/gemini-cli/devcontainer.patch.json +3 -0
  71. package/overlays/gemini-cli/overlay.yml +15 -0
  72. package/overlays/gemini-cli/setup.sh +21 -0
  73. package/overlays/gemini-cli/verify.sh +21 -0
  74. package/overlays/opencode/README.md +76 -0
  75. package/overlays/opencode/devcontainer.patch.json +3 -0
  76. package/overlays/opencode/overlay.yml +14 -0
  77. package/overlays/opencode/setup.sh +21 -0
  78. package/overlays/opencode/verify.sh +21 -0
  79. package/overlays/spec-kit/README.md +181 -0
  80. package/overlays/spec-kit/devcontainer.patch.json +6 -0
  81. package/overlays/spec-kit/overlay.yml +19 -0
  82. package/overlays/spec-kit/setup.sh +45 -0
  83. package/overlays/spec-kit/verify.sh +33 -0
  84. package/overlays/windsurf-cli/README.md +69 -0
  85. package/overlays/windsurf-cli/devcontainer.patch.json +3 -0
  86. package/overlays/windsurf-cli/overlay.yml +15 -0
  87. package/overlays/windsurf-cli/setup.sh +21 -0
  88. package/overlays/windsurf-cli/verify.sh +21 -0
  89. package/package.json +1 -1
  90. package/tool/schema/config.schema.json +138 -9
@@ -0,0 +1,767 @@
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 { getToolVersion } from '../utils/version.js';
16
+ import { isInsideGitRepo, createBackup, ensureBackupPatternsInGitignore } from '../utils/backup.js';
17
+ // Get __dirname equivalent in ESM
18
+ const __filename = fileURLToPath(import.meta.url);
19
+ const __dirname = path.dirname(__filename);
20
+ /**
21
+ * Strip the version suffix from a devcontainer feature URI so that prefix
22
+ * matching works regardless of pinned version.
23
+ * e.g. "ghcr.io/devcontainers/features/node:1" → "ghcr.io/devcontainers/features/node"
24
+ */
25
+ function stripFeatureVersion(featureId) {
26
+ return featureId.replace(/:\d+$/, '').replace(/@[^@]+$/, '');
27
+ }
28
+ /**
29
+ * Extract the image name prefix (registry + repo, without tag) from a docker
30
+ * image string that may contain variable substitutions.
31
+ * e.g. "postgres:${POSTGRES_VERSION:-16}-alpine" → "postgres"
32
+ * e.g. "grafana/grafana:${VERSION:-latest}" → "grafana/grafana"
33
+ */
34
+ function extractImagePrefix(image) {
35
+ // Strip all ${...} variable substitutions, then split on the first colon
36
+ return image
37
+ .replace(/\$\{[^}]+\}/g, '')
38
+ .split(':')[0]
39
+ .replace(/-$/, '')
40
+ .trim();
41
+ }
42
+ /**
43
+ * Build detection tables by scanning every overlay's devcontainer.patch.json
44
+ * and docker-compose.yml. This means adopt automatically supports any overlay
45
+ * that exists in the registry — no hardcoded lists.
46
+ */
47
+ /**
48
+ * Scoring weights for feature/extension match quality.
49
+ * Higher = better match. Used so that the overlay whose identity most closely
50
+ * matches a feature name wins when multiple overlays share the same feature.
51
+ */
52
+ const SCORE_EXACT = 100; // overlay ID === feature segment exactly
53
+ const SCORE_ID_STARTS_WITH_SEGMENT = 80; // e.g. "nodejs" starts with segment "node"
54
+ const SCORE_SEGMENT_STARTS_WITH_ID = 60; // segment starts with overlay ID
55
+ const SCORE_SEGMENT_CONTAINS_ID = 40; // segment contains overlay ID
56
+ const SCORE_ID_CONTAINS_SEGMENT = 20; // overlay ID contains segment
57
+ const SCORE_NO_MATCH = 0; // no textual relationship
58
+ /**
59
+ * Score how well an overlay ID matches a feature URI.
60
+ * Higher score = better match. Used to resolve conflicts when multiple overlays
61
+ * share the same devcontainer feature (e.g. `nodejs` and `bun` both include the
62
+ * `node` feature; `nodejs` should win because its ID starts with the feature's
63
+ * last path segment `node`).
64
+ */
65
+ function featureMatchScore(overlayId, strippedFeatureUri) {
66
+ // Take the last path segment of the feature URI (the feature name itself)
67
+ const segment = strippedFeatureUri.split('/').pop() ?? '';
68
+ if (overlayId === segment)
69
+ return SCORE_EXACT;
70
+ if (overlayId.startsWith(segment))
71
+ return SCORE_ID_STARTS_WITH_SEGMENT;
72
+ if (segment.startsWith(overlayId))
73
+ return SCORE_SEGMENT_STARTS_WITH_ID;
74
+ if (segment.includes(overlayId))
75
+ return SCORE_SEGMENT_CONTAINS_ID;
76
+ if (overlayId.includes(segment))
77
+ return SCORE_ID_CONTAINS_SEGMENT;
78
+ return SCORE_NO_MATCH;
79
+ }
80
+ /**
81
+ * Score how well an overlay ID matches a VS Code extension ID.
82
+ * Prevents, e.g., the `bun` overlay's eslint extension from claiming eslint
83
+ * over the `nodejs` overlay which owns it more naturally.
84
+ */
85
+ function extensionMatchScore(overlayId, extensionId) {
86
+ // Publisher.name → just use the name part for comparison
87
+ const name = extensionId.split('.').pop() ?? extensionId;
88
+ if (name.includes(overlayId))
89
+ return SCORE_ID_STARTS_WITH_SEGMENT;
90
+ if (overlayId.includes(name))
91
+ return SCORE_SEGMENT_STARTS_WITH_ID;
92
+ return SCORE_NO_MATCH;
93
+ }
94
+ export function buildDetectionTables(overlaysDir, overlaysConfig) {
95
+ const featureToOverlayScored = {};
96
+ const imagePrefixToOverlay = [];
97
+ const extensionToOverlayScored = {};
98
+ for (const overlay of overlaysConfig.overlays) {
99
+ const overlayDir = path.join(overlaysDir, overlay.id);
100
+ // ── devcontainer.patch.json ──────────────────────────────────────
101
+ const patchPath = path.join(overlayDir, 'devcontainer.patch.json');
102
+ if (fs.existsSync(patchPath)) {
103
+ try {
104
+ const patch = JSON.parse(fs.readFileSync(patchPath, 'utf8'));
105
+ // Features (skip local paths like ./features/...)
106
+ for (const featureId of Object.keys(patch.features ?? {})) {
107
+ if (featureId.startsWith('./') || featureId.startsWith('../'))
108
+ continue;
109
+ const stripped = stripFeatureVersion(featureId);
110
+ const score = featureMatchScore(overlay.id, stripped);
111
+ const existing = featureToOverlayScored[stripped];
112
+ if (!existing || score > existing.score) {
113
+ featureToOverlayScored[stripped] = { overlayId: overlay.id, score };
114
+ }
115
+ }
116
+ // VS Code extensions — also scored so the most specific overlay wins
117
+ for (const extId of (patch.customizations?.vscode?.extensions ?? [])) {
118
+ const lc = extId.toLowerCase();
119
+ const score = extensionMatchScore(overlay.id, lc);
120
+ const existing = extensionToOverlayScored[lc];
121
+ if (!existing || score > existing.score) {
122
+ extensionToOverlayScored[lc] = { overlayId: overlay.id, score };
123
+ }
124
+ }
125
+ }
126
+ catch {
127
+ // Skip malformed patch files
128
+ }
129
+ }
130
+ // ── docker-compose.yml ───────────────────────────────────────────
131
+ const composePath = path.join(overlayDir, 'docker-compose.yml');
132
+ if (fs.existsSync(composePath)) {
133
+ try {
134
+ const compose = yaml.load(fs.readFileSync(composePath, 'utf8'));
135
+ for (const serviceDef of Object.values(compose?.services ?? {})) {
136
+ const image = serviceDef?.image ?? '';
137
+ if (!image)
138
+ continue;
139
+ const prefix = extractImagePrefix(image);
140
+ if (prefix && !imagePrefixToOverlay.find((p) => p.prefix === prefix)) {
141
+ imagePrefixToOverlay.push({ prefix, overlayId: overlay.id });
142
+ }
143
+ }
144
+ }
145
+ catch {
146
+ // Skip malformed compose files
147
+ }
148
+ }
149
+ }
150
+ return {
151
+ featureToOverlay: Object.fromEntries(Object.entries(featureToOverlayScored).map(([k, v]) => [k, v.overlayId])),
152
+ imagePrefixToOverlay,
153
+ extensionToOverlay: Object.fromEntries(Object.entries(extensionToOverlayScored).map(([k, v]) => [k, v.overlayId])),
154
+ };
155
+ }
156
+ // ---------------------------------------------------------------------------
157
+ // Matching helpers (use dynamic tables)
158
+ // ---------------------------------------------------------------------------
159
+ function matchFeature(featureId, tables) {
160
+ const stripped = stripFeatureVersion(featureId);
161
+ // Exact match first
162
+ if (tables.featureToOverlay[stripped])
163
+ return tables.featureToOverlay[stripped];
164
+ // Prefix match (handles minor version variation within the same feature family)
165
+ for (const [uri, overlayId] of Object.entries(tables.featureToOverlay)) {
166
+ if (stripped.startsWith(uri))
167
+ return overlayId;
168
+ }
169
+ return null;
170
+ }
171
+ function matchImage(image, tables) {
172
+ const prefix = extractImagePrefix(image);
173
+ const entry = tables.imagePrefixToOverlay.find((p) => prefix === p.prefix || prefix.startsWith(p.prefix));
174
+ return entry?.overlayId ?? null;
175
+ }
176
+ function matchExtension(extensionId, tables) {
177
+ return tables.extensionToOverlay[extensionId.toLowerCase()] ?? null;
178
+ }
179
+ function withSchemaFirst(document) {
180
+ const { $schema, ...rest } = document;
181
+ return typeof $schema === 'string' && $schema.trim() !== '' ? { $schema, ...rest } : rest;
182
+ }
183
+ // ---------------------------------------------------------------------------
184
+ // Docker Compose path resolution
185
+ // ---------------------------------------------------------------------------
186
+ /**
187
+ * Resolve all docker-compose file paths referenced by a devcontainer.json.
188
+ *
189
+ * `dockerComposeFile` may be a string or an array of strings; each path is
190
+ * resolved relative to the devcontainer directory. Falls back to the
191
+ * conventional `docker-compose.yml` in the same directory.
192
+ */
193
+ export function resolveComposePaths(devcontainer, devcontainerDir) {
194
+ const field = devcontainer.dockerComposeFile;
195
+ if (field) {
196
+ // dockerComposeFile is explicitly set — use exactly those paths (no fallback).
197
+ // Deduplicate in case the array contains the same path more than once.
198
+ const rawPaths = Array.isArray(field) ? field : [field];
199
+ return [...new Set(rawPaths.map((raw) => path.resolve(devcontainerDir, raw)))].filter((p) => fs.existsSync(p));
200
+ }
201
+ // No dockerComposeFile field — fall back to the conventional location only
202
+ const conventional = path.join(devcontainerDir, 'docker-compose.yml');
203
+ return fs.existsSync(conventional) ? [conventional] : [];
204
+ }
205
+ // ---------------------------------------------------------------------------
206
+ // Analysis helpers
207
+ // ---------------------------------------------------------------------------
208
+ function analyseFeatures(devcontainer, tables) {
209
+ const detections = [];
210
+ const unmatchedFeatures = {};
211
+ const features = devcontainer.features ?? {};
212
+ for (const [featureId, featureConfig] of Object.entries(features)) {
213
+ if (featureId.startsWith('./') || featureId.startsWith('../'))
214
+ continue;
215
+ const overlayId = matchFeature(featureId, tables);
216
+ if (overlayId) {
217
+ detections.push({
218
+ source: featureId,
219
+ overlayId,
220
+ confidence: 'exact',
221
+ sourceType: 'feature',
222
+ });
223
+ }
224
+ else {
225
+ unmatchedFeatures[featureId] = featureConfig;
226
+ }
227
+ }
228
+ return { detections, unmatchedFeatures };
229
+ }
230
+ function analyseExtensions(devcontainer, tables) {
231
+ const detections = [];
232
+ const unmatchedExtensions = [];
233
+ const extensions = devcontainer.customizations?.vscode?.extensions ?? [];
234
+ for (const extId of extensions) {
235
+ const overlayId = matchExtension(extId, tables);
236
+ if (overlayId) {
237
+ detections.push({
238
+ source: `extension: ${extId}`,
239
+ overlayId,
240
+ confidence: 'heuristic',
241
+ sourceType: 'extension',
242
+ });
243
+ }
244
+ else {
245
+ unmatchedExtensions.push(extId);
246
+ }
247
+ }
248
+ return { detections, unmatchedExtensions };
249
+ }
250
+ function analyseDockerCompose(composePaths, tables) {
251
+ const detections = [];
252
+ const unmatchedServices = {};
253
+ for (const composePath of composePaths) {
254
+ let parsed;
255
+ try {
256
+ parsed = yaml.load(fs.readFileSync(composePath, 'utf8'));
257
+ }
258
+ catch {
259
+ continue;
260
+ }
261
+ for (const [serviceName, serviceDef] of Object.entries(parsed?.services ?? {})) {
262
+ const image = serviceDef?.image ?? '';
263
+ if (!image)
264
+ continue;
265
+ const overlayId = matchImage(image, tables);
266
+ if (overlayId) {
267
+ detections.push({
268
+ source: `service: ${serviceName} (image: ${image})`,
269
+ overlayId,
270
+ confidence: 'exact',
271
+ sourceType: 'service',
272
+ });
273
+ }
274
+ else {
275
+ unmatchedServices[serviceName] = serviceDef;
276
+ }
277
+ }
278
+ }
279
+ return { detections, unmatchedServices };
280
+ }
281
+ function analyseRemoteEnv(devcontainer, tables) {
282
+ const detections = [];
283
+ const unmatchedRemoteEnv = {};
284
+ const env = devcontainer.remoteEnv ?? {};
285
+ // Build env-var prefix patterns from overlay IDs that exist in the registry
286
+ // Supplement with a small set of well-known patterns that aren't derivable from overlay files
287
+ const ENV_PATTERNS = [
288
+ { pattern: /^POSTGRES_/, overlayId: 'postgres' },
289
+ { pattern: /^PG(HOST|PORT|USER|PASSWORD|DB)$/, overlayId: 'postgres' },
290
+ { pattern: /^REDIS_/, overlayId: 'redis' },
291
+ { pattern: /^MONGO(DB)?_/, overlayId: 'mongodb' },
292
+ { pattern: /^MYSQL_/, overlayId: 'mysql' },
293
+ { pattern: /^MSSQL_/, overlayId: 'sqlserver' },
294
+ { pattern: /^AWS_/, overlayId: 'aws-cli' },
295
+ { pattern: /^AZURE_/, overlayId: 'azure-cli' },
296
+ { pattern: /^GOOGLE_CLOUD_/, overlayId: 'gcloud' },
297
+ ];
298
+ for (const [key, value] of Object.entries(env)) {
299
+ let matched = false;
300
+ for (const { pattern, overlayId } of ENV_PATTERNS) {
301
+ if (pattern.test(key)) {
302
+ detections.push({
303
+ source: `remoteEnv: ${key}`,
304
+ overlayId,
305
+ confidence: 'heuristic',
306
+ sourceType: 'remoteenv',
307
+ });
308
+ matched = true;
309
+ break;
310
+ }
311
+ }
312
+ if (!matched) {
313
+ unmatchedRemoteEnv[key] = value;
314
+ }
315
+ }
316
+ return { detections, unmatchedRemoteEnv };
317
+ }
318
+ /**
319
+ * Deduplicate: keep one entry per overlayId, preferring `exact` over `heuristic`.
320
+ */
321
+ function deduplicateDetections(detections) {
322
+ const seen = new Map();
323
+ for (const d of detections) {
324
+ const existing = seen.get(d.overlayId);
325
+ if (!existing) {
326
+ seen.set(d.overlayId, d);
327
+ }
328
+ else if (d.confidence === 'exact' && existing.confidence !== 'exact') {
329
+ seen.set(d.overlayId, d);
330
+ }
331
+ }
332
+ return Array.from(seen.values());
333
+ }
334
+ /**
335
+ * Build the suggested `init` command using overlay categories from the registry.
336
+ */
337
+ function buildSuggestedCommand(overlayIds, stack, overlaysConfig) {
338
+ const language = [];
339
+ const database = [];
340
+ const observability = [];
341
+ const cloudTools = [];
342
+ const devTools = [];
343
+ const other = [];
344
+ for (const id of overlayIds) {
345
+ const overlay = overlaysConfig.overlays.find((o) => o.id === id);
346
+ if (!overlay)
347
+ continue; // Skip overlay IDs not in the registry
348
+ switch (overlay.category) {
349
+ case 'language':
350
+ language.push(id);
351
+ break;
352
+ case 'database':
353
+ database.push(id);
354
+ break;
355
+ case 'observability':
356
+ observability.push(id);
357
+ break;
358
+ case 'cloud':
359
+ cloudTools.push(id);
360
+ break;
361
+ case 'dev':
362
+ devTools.push(id);
363
+ break;
364
+ default:
365
+ other.push(id);
366
+ }
367
+ }
368
+ const parts = ['container-superposition init', `--stack ${stack}`];
369
+ if (language.length > 0)
370
+ parts.push(`--language ${language.join(',')}`);
371
+ if (database.length > 0)
372
+ parts.push(`--database ${database.join(',')}`);
373
+ if (observability.length > 0)
374
+ parts.push(`--observability ${observability.join(',')}`);
375
+ if (cloudTools.length > 0)
376
+ parts.push(`--cloud-tools ${cloudTools.join(',')}`);
377
+ if (devTools.length > 0)
378
+ parts.push(`--dev-tools ${devTools.join(',')}`);
379
+ if (other.length > 0)
380
+ parts.push(`--overlays ${other.join(',')}`);
381
+ return parts.join(' ');
382
+ }
383
+ function buildCustomDevcontainerPatch(devcontainer, unmatchedFeatures, unmatchedExtensions, unmatchedRemoteEnv) {
384
+ const patch = {};
385
+ if (Object.keys(unmatchedFeatures).length > 0) {
386
+ patch.features = unmatchedFeatures;
387
+ }
388
+ // Preserve all customizations, merging unmatched extensions into vscode.extensions.
389
+ // Other customizations fields (e.g. vscode.settings, jetbrains, ...) are carried through
390
+ // verbatim so the migration is lossless.
391
+ const originalCustomizations = devcontainer.customizations && typeof devcontainer.customizations === 'object'
392
+ ? devcontainer.customizations
393
+ : null;
394
+ if (unmatchedExtensions.length > 0 || originalCustomizations) {
395
+ const customizationsPatch = originalCustomizations
396
+ ? { ...originalCustomizations }
397
+ : {};
398
+ if (unmatchedExtensions.length > 0) {
399
+ const originalVscode = originalCustomizations?.vscode && typeof originalCustomizations.vscode === 'object'
400
+ ? { ...originalCustomizations.vscode }
401
+ : {};
402
+ const originalExtensions = Array.isArray(originalVscode.extensions)
403
+ ? originalVscode.extensions
404
+ : [];
405
+ // Order: existing extensions first so load order is preserved, then new unmatched ones
406
+ const mergedExtensions = Array.from(new Set([...originalExtensions, ...unmatchedExtensions]));
407
+ customizationsPatch.vscode = {
408
+ ...originalVscode,
409
+ extensions: mergedExtensions,
410
+ };
411
+ }
412
+ if (Object.keys(customizationsPatch).length > 0) {
413
+ patch.customizations = customizationsPatch;
414
+ }
415
+ }
416
+ if (Array.isArray(devcontainer.mounts) && devcontainer.mounts.length > 0) {
417
+ patch.mounts = devcontainer.mounts;
418
+ }
419
+ if (devcontainer.remoteUser && devcontainer.remoteUser !== 'vscode') {
420
+ patch.remoteUser = devcontainer.remoteUser;
421
+ }
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
+ if (devcontainer.postCreateCommand) {
425
+ patch.postCreateCommand = devcontainer.postCreateCommand;
426
+ }
427
+ if (devcontainer.postStartCommand) {
428
+ patch.postStartCommand = devcontainer.postStartCommand;
429
+ }
430
+ // Preserve remoteEnv keys not matched to any overlay so the migration is lossless.
431
+ if (Object.keys(unmatchedRemoteEnv).length > 0) {
432
+ patch.remoteEnv = unmatchedRemoteEnv;
433
+ }
434
+ return Object.keys(patch).length > 0 ? patch : null;
435
+ }
436
+ function buildCustomComposePatch(unmatchedServices) {
437
+ if (Object.keys(unmatchedServices).length === 0)
438
+ return null;
439
+ return { services: unmatchedServices };
440
+ }
441
+ // ---------------------------------------------------------------------------
442
+ // Full analysis
443
+ // ---------------------------------------------------------------------------
444
+ export function analyseDevcontainer(dir, overlaysConfig, tables) {
445
+ const devcontainerPath = path.join(dir, 'devcontainer.json');
446
+ let devcontainer = {};
447
+ if (fs.existsSync(devcontainerPath)) {
448
+ try {
449
+ devcontainer = JSON.parse(fs.readFileSync(devcontainerPath, 'utf8'));
450
+ }
451
+ catch {
452
+ devcontainer = {};
453
+ }
454
+ }
455
+ const composePaths = resolveComposePaths(devcontainer, dir);
456
+ const featureResult = analyseFeatures(devcontainer, tables);
457
+ const composeResult = analyseDockerCompose(composePaths, tables);
458
+ const extensionResult = analyseExtensions(devcontainer, tables);
459
+ const remoteEnvResult = analyseRemoteEnv(devcontainer, tables);
460
+ const detections = deduplicateDetections([
461
+ ...featureResult.detections,
462
+ ...composeResult.detections,
463
+ ...extensionResult.detections,
464
+ ...remoteEnvResult.detections,
465
+ ]);
466
+ const hasDockerCompose = composePaths.length > 0;
467
+ const hasServiceSignals = detections.some((d) => d.sourceType === 'service');
468
+ const suggestedStack = hasDockerCompose || hasServiceSignals ? 'compose' : 'plain';
469
+ const knownIds = new Set(overlaysConfig.overlays.map((o) => o.id));
470
+ const suggestedOverlays = [...new Set(detections.map((d) => d.overlayId))].filter((id) => knownIds.has(id));
471
+ const suggestedCommand = buildSuggestedCommand(suggestedOverlays, suggestedStack, overlaysConfig);
472
+ // Unmatched item descriptions for display / JSON
473
+ const unmatchedItems = [];
474
+ for (const fid of Object.keys(featureResult.unmatchedFeatures)) {
475
+ unmatchedItems.push({
476
+ source: fid,
477
+ reason: 'No overlay covers this feature — preserve in custom/devcontainer.patch.json',
478
+ });
479
+ }
480
+ for (const [name, def] of Object.entries(composeResult.unmatchedServices)) {
481
+ const image = def?.image ?? '(no image)';
482
+ unmatchedItems.push({
483
+ source: `service: ${name} (image: ${image})`,
484
+ reason: 'No overlay covers this service — preserve in custom/docker-compose.patch.yml',
485
+ });
486
+ }
487
+ for (const extId of extensionResult.unmatchedExtensions) {
488
+ unmatchedItems.push({
489
+ source: `extension: ${extId}`,
490
+ reason: 'No overlay installs this extension — preserve in custom/devcontainer.patch.json',
491
+ });
492
+ }
493
+ if (Array.isArray(devcontainer.mounts) && devcontainer.mounts.length > 0) {
494
+ unmatchedItems.push({
495
+ source: `mounts (${devcontainer.mounts.length} mount(s))`,
496
+ reason: 'Custom mounts are not managed by overlays — preserve in custom/devcontainer.patch.json',
497
+ });
498
+ }
499
+ if (devcontainer.remoteUser && devcontainer.remoteUser !== 'vscode') {
500
+ unmatchedItems.push({
501
+ source: `remoteUser: ${devcontainer.remoteUser}`,
502
+ reason: 'Custom remote user — preserve in custom/devcontainer.patch.json',
503
+ });
504
+ }
505
+ const customDevcontainerPatch = buildCustomDevcontainerPatch(devcontainer, featureResult.unmatchedFeatures, extensionResult.unmatchedExtensions, remoteEnvResult.unmatchedRemoteEnv);
506
+ const customComposePatch = buildCustomComposePatch(composeResult.unmatchedServices);
507
+ return {
508
+ detections,
509
+ unmatchedItems,
510
+ customDevcontainerPatch,
511
+ customComposePatch,
512
+ suggestedStack,
513
+ suggestedOverlays,
514
+ suggestedCommand,
515
+ hasDockerCompose,
516
+ };
517
+ }
518
+ // ---------------------------------------------------------------------------
519
+ // Output formatting
520
+ // ---------------------------------------------------------------------------
521
+ function formatConfidence(c) {
522
+ return c === 'exact' ? chalk.green('exact') : chalk.yellow('heuristic');
523
+ }
524
+ function formatAnalysisTable(detections, knownIds) {
525
+ if (detections.length === 0)
526
+ return chalk.dim(' (no recognisable patterns found)');
527
+ const sourceColWidth = 58;
528
+ const arrowColWidth = 4;
529
+ const overlayColWidth = 22;
530
+ const lines = [
531
+ chalk.bold('Source'.padEnd(sourceColWidth) +
532
+ '→'.padEnd(arrowColWidth) +
533
+ 'Overlay'.padEnd(overlayColWidth) +
534
+ 'Confidence'),
535
+ '─'.repeat(sourceColWidth + arrowColWidth + overlayColWidth + 12),
536
+ ];
537
+ for (const d of detections) {
538
+ const src = d.source.slice(0, sourceColWidth - 2).padEnd(sourceColWidth);
539
+ const overlay = knownIds.has(d.overlayId)
540
+ ? chalk.cyan(d.overlayId.padEnd(overlayColWidth))
541
+ : chalk.dim(`${d.overlayId} (unknown)`.padEnd(overlayColWidth));
542
+ lines.push(`${src}${chalk.dim('→'.padEnd(arrowColWidth))}${overlay}${formatConfidence(d.confidence)}`);
543
+ }
544
+ return lines.join('\n');
545
+ }
546
+ function formatUnmatchedTable(items) {
547
+ const s = 60;
548
+ const lines = [chalk.bold('Source'.padEnd(s) + 'Action'), '─'.repeat(s + 52)];
549
+ for (const item of items) {
550
+ lines.push(`${item.source.slice(0, s - 2).padEnd(s)}${chalk.dim(item.reason)}`);
551
+ }
552
+ return lines.join('\n');
553
+ }
554
+ // ---------------------------------------------------------------------------
555
+ // Main command
556
+ // ---------------------------------------------------------------------------
557
+ export async function adoptCommand(overlaysConfig, overlaysDir, options) {
558
+ const dir = options.dir ?? './.devcontainer';
559
+ const absoluteDir = path.resolve(dir);
560
+ if (!fs.existsSync(absoluteDir)) {
561
+ console.error(chalk.red(`✗ Directory not found: ${absoluteDir}`));
562
+ console.log(chalk.dim(`\n💡 Specify a different path with --dir, e.g. --dir path/to/.devcontainer\n`));
563
+ process.exit(1);
564
+ }
565
+ const devcontainerJsonPath = path.join(absoluteDir, 'devcontainer.json');
566
+ if (!fs.existsSync(devcontainerJsonPath)) {
567
+ console.error(chalk.red(`✗ No devcontainer.json found in ${absoluteDir}`));
568
+ process.exit(1);
569
+ }
570
+ // Build detection tables dynamically from the overlay registry
571
+ const tables = buildDetectionTables(overlaysDir, overlaysConfig);
572
+ // ── Analyse ────────────────────────────────────────────────────────────
573
+ const analysis = analyseDevcontainer(absoluteDir, overlaysConfig, tables);
574
+ // ── JSON output (no decoration) ────────────────────────────────────────
575
+ if (options.json) {
576
+ console.log(JSON.stringify({
577
+ dir: absoluteDir,
578
+ detections: analysis.detections,
579
+ unmatchedItems: analysis.unmatchedItems,
580
+ customDevcontainerPatch: analysis.customDevcontainerPatch,
581
+ customComposePatch: analysis.customComposePatch,
582
+ suggestedStack: analysis.suggestedStack,
583
+ suggestedOverlays: analysis.suggestedOverlays,
584
+ suggestedCommand: analysis.suggestedCommand,
585
+ }, null, 2));
586
+ return;
587
+ }
588
+ // ── Header ─────────────────────────────────────────────────────────────
589
+ console.log('\n' +
590
+ boxen(chalk.bold('🔍 Adopt Analysis'), {
591
+ padding: 0.5,
592
+ borderColor: 'cyan',
593
+ borderStyle: 'round',
594
+ }));
595
+ console.log(chalk.dim(`\nAnalysing ${path.relative(process.cwd(), devcontainerJsonPath)}...`));
596
+ let devcontainer;
597
+ try {
598
+ devcontainer = JSON.parse(fs.readFileSync(devcontainerJsonPath, 'utf8'));
599
+ }
600
+ catch (error) {
601
+ console.error(chalk.red(`\n✗ Failed to parse ${path.relative(process.cwd(), devcontainerJsonPath)}.` +
602
+ ' Please ensure it contains valid JSON.'));
603
+ if (error instanceof Error && error.message) {
604
+ console.error(chalk.red(` ${error.message}`));
605
+ }
606
+ process.exitCode = 1;
607
+ return;
608
+ }
609
+ for (const cp of resolveComposePaths(devcontainer, absoluteDir)) {
610
+ console.log(chalk.dim(`Analysing ${path.relative(process.cwd(), cp)}...`));
611
+ }
612
+ // ── Matched detections table ───────────────────────────────────────────
613
+ const knownIds = new Set(overlaysConfig.overlays.map((o) => o.id));
614
+ console.log('\n' + chalk.bold('Detected features / services → suggested overlays'));
615
+ console.log(chalk.dim('─'.repeat(80)));
616
+ console.log(formatAnalysisTable(analysis.detections, knownIds));
617
+ // ── Unmatched items table ──────────────────────────────────────────────
618
+ if (analysis.unmatchedItems.length > 0) {
619
+ console.log('\n' + chalk.bold('Items with no overlay equivalent → custom/'));
620
+ console.log(chalk.dim('─'.repeat(80)));
621
+ console.log(formatUnmatchedTable(analysis.unmatchedItems));
622
+ }
623
+ // ── No overlays found ──────────────────────────────────────────────────
624
+ if (analysis.suggestedOverlays.length === 0) {
625
+ console.log('\n' +
626
+ chalk.yellow('⚠ No recognisable overlay patterns detected.\n' +
627
+ ' Your devcontainer may use entirely custom configuration\n' +
628
+ ' that does not map to any available overlays.'));
629
+ console.log(chalk.dim('\n💡 You can still run:\n container-superposition init\n to create a new configuration interactively.\n'));
630
+ return;
631
+ }
632
+ // ── Suggested command ──────────────────────────────────────────────────
633
+ console.log('\n' + chalk.bold('Suggested command:'));
634
+ console.log(' ' + chalk.cyan(analysis.suggestedCommand));
635
+ if (analysis.customDevcontainerPatch || analysis.customComposePatch) {
636
+ console.log(chalk.dim('\n💡 Custom patches will be written to .devcontainer/custom/ to preserve\n' +
637
+ ' any configuration that has no overlay equivalent.'));
638
+ }
639
+ if (options.dryRun) {
640
+ console.log(chalk.dim('\n(--dry-run: no files written)\n'));
641
+ return;
642
+ }
643
+ // ── Guard: existing files ──────────────────────────────────────────────
644
+ // superposition.json goes to the project root (parent of .devcontainer/) so it
645
+ // can be committed alongside application code, matching the team workflow pattern.
646
+ const projectRoot = path.dirname(absoluteDir);
647
+ const manifestPath = path.join(projectRoot, 'superposition.json');
648
+ const customDir = path.join(absoluteDir, 'custom');
649
+ const customPatchPath = path.join(customDir, 'devcontainer.patch.json');
650
+ const customComposePath = path.join(customDir, 'docker-compose.patch.yml');
651
+ const existingFiles = [];
652
+ if (fs.existsSync(manifestPath))
653
+ existingFiles.push(path.relative(process.cwd(), manifestPath));
654
+ if (analysis.customDevcontainerPatch && fs.existsSync(customPatchPath))
655
+ existingFiles.push(path.relative(process.cwd(), customPatchPath));
656
+ if (analysis.customComposePatch && fs.existsSync(customComposePath))
657
+ existingFiles.push(path.relative(process.cwd(), customComposePath));
658
+ if (existingFiles.length > 0 && !options.force) {
659
+ console.log('\n' +
660
+ chalk.yellow('⚠ The following file(s) already exist:\n' +
661
+ existingFiles.map((f) => ` • ${f}`).join('\n') +
662
+ '\n Use --force to overwrite them.'));
663
+ return;
664
+ }
665
+ // ── Prompt ────────────────────────────────────────────────────────────
666
+ const hasCustomFiles = analysis.customDevcontainerPatch || analysis.customComposePatch;
667
+ let confirmed;
668
+ try {
669
+ confirmed = await confirm({
670
+ message: `Generate superposition.json${hasCustomFiles ? ' and custom/ patch files' : ''} from these suggestions?`,
671
+ default: true,
672
+ });
673
+ }
674
+ catch {
675
+ // AbortPromptError (Ctrl+C) or ExitPromptError (non-interactive) — treat as "no"
676
+ confirmed = false;
677
+ }
678
+ if (!confirmed) {
679
+ console.log(chalk.dim('\nAborted. No files written.\n'));
680
+ return;
681
+ }
682
+ // ── Backup (same logic as regen) ───────────────────────────────────────
683
+ // Backup happens AFTER confirmation and BEFORE writes so we only create
684
+ // backups when we're actually about to change things.
685
+ //
686
+ // --backup → force backup
687
+ // --no-backup → skip backup
688
+ // (neither) → skip when inside a git repo (git already tracks history)
689
+ const inGitRepo = isInsideGitRepo(absoluteDir);
690
+ let shouldBackup;
691
+ if (options.backup === true) {
692
+ shouldBackup = true;
693
+ }
694
+ else if (options.backup === false) {
695
+ shouldBackup = false;
696
+ }
697
+ else {
698
+ shouldBackup = !inGitRepo;
699
+ if (!shouldBackup) {
700
+ console.log(chalk.dim('\nℹ Skipping backup — git repo detected (use --backup to force one)'));
701
+ }
702
+ }
703
+ if (shouldBackup) {
704
+ const backupPath = await createBackup(absoluteDir, options.backupDir);
705
+ if (backupPath) {
706
+ console.log(chalk.dim(`\n💾 Backup created at ${path.relative(process.cwd(), backupPath)}`));
707
+ ensureBackupPatternsInGitignore(absoluteDir);
708
+ }
709
+ }
710
+ // ── Write superposition.json ───────────────────────────────────────────
711
+ const manifest = {
712
+ manifestVersion: CURRENT_MANIFEST_VERSION,
713
+ generatedBy: `container-superposition@${getToolVersion()} adopt`,
714
+ generated: new Date().toISOString(),
715
+ baseTemplate: analysis.suggestedStack,
716
+ baseImage: 'bookworm',
717
+ overlays: analysis.suggestedOverlays,
718
+ };
719
+ try {
720
+ fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n', 'utf8');
721
+ console.log('\n' + chalk.green(`✓ Written ${path.relative(process.cwd(), manifestPath)}`));
722
+ }
723
+ catch (err) {
724
+ console.error(chalk.red('✗ Failed to write superposition.json:'), err);
725
+ process.exit(1);
726
+ }
727
+ // ── Write custom patches ───────────────────────────────────────────────
728
+ if (hasCustomFiles) {
729
+ try {
730
+ fs.mkdirSync(customDir, { recursive: true });
731
+ }
732
+ catch (err) {
733
+ console.error(chalk.red('✗ Failed to create custom/ directory:'), err);
734
+ process.exit(1);
735
+ }
736
+ }
737
+ if (analysis.customDevcontainerPatch) {
738
+ try {
739
+ fs.writeFileSync(customPatchPath, JSON.stringify(withSchemaFirst(analysis.customDevcontainerPatch), null, 4) + '\n', 'utf8');
740
+ console.log(chalk.green(`✓ Written ${path.relative(process.cwd(), customPatchPath)}`));
741
+ }
742
+ catch (err) {
743
+ console.error(chalk.red('✗ Failed to write custom/devcontainer.patch.json:'), err);
744
+ process.exit(1);
745
+ }
746
+ }
747
+ if (analysis.customComposePatch) {
748
+ try {
749
+ const header = '# Custom Docker Compose services preserved from original configuration.\n' +
750
+ '# These services have no equivalent overlay and will be merged into\n' +
751
+ '# docker-compose.yml during regeneration.\n';
752
+ fs.writeFileSync(customComposePath, header + yaml.dump(analysis.customComposePatch), 'utf8');
753
+ console.log(chalk.green(`✓ Written ${path.relative(process.cwd(), customComposePath)}`));
754
+ }
755
+ catch (err) {
756
+ console.error(chalk.red('✗ Failed to write custom/docker-compose.patch.yml:'), err);
757
+ process.exit(1);
758
+ }
759
+ }
760
+ console.log(chalk.dim('\n💡 Next steps:\n' +
761
+ ' 1. Review and adjust superposition.json as needed\n' +
762
+ ' 2. Run: container-superposition regen\n' +
763
+ (hasCustomFiles
764
+ ? ' 3. Review custom/ patches — they will be merged automatically on every regen\n'
765
+ : '')));
766
+ }
767
+ //# sourceMappingURL=adopt.js.map