container-superposition 0.1.3 → 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.
- package/README.md +72 -1014
- package/dist/scripts/init.js +512 -238
- package/dist/scripts/init.js.map +1 -1
- package/dist/tool/commands/adopt.d.ts +62 -0
- package/dist/tool/commands/adopt.d.ts.map +1 -0
- package/dist/tool/commands/adopt.js +767 -0
- package/dist/tool/commands/adopt.js.map +1 -0
- package/dist/tool/commands/doctor.js +2 -2
- package/dist/tool/commands/explain.d.ts.map +1 -1
- package/dist/tool/commands/explain.js +88 -0
- package/dist/tool/commands/explain.js.map +1 -1
- package/dist/tool/commands/hash.d.ts +36 -0
- package/dist/tool/commands/hash.d.ts.map +1 -0
- package/dist/tool/commands/hash.js +242 -0
- package/dist/tool/commands/hash.js.map +1 -0
- package/dist/tool/commands/plan.d.ts +53 -0
- package/dist/tool/commands/plan.d.ts.map +1 -1
- package/dist/tool/commands/plan.js +784 -42
- package/dist/tool/commands/plan.js.map +1 -1
- package/dist/tool/questionnaire/composer.d.ts +12 -3
- package/dist/tool/questionnaire/composer.d.ts.map +1 -1
- package/dist/tool/questionnaire/composer.js +133 -20
- package/dist/tool/questionnaire/composer.js.map +1 -1
- package/dist/tool/schema/project-config.d.ts +15 -0
- package/dist/tool/schema/project-config.d.ts.map +1 -0
- package/dist/tool/schema/project-config.js +359 -0
- package/dist/tool/schema/project-config.js.map +1 -0
- package/dist/tool/schema/types.d.ts +57 -1
- package/dist/tool/schema/types.d.ts.map +1 -1
- package/dist/tool/utils/backup.d.ts +23 -0
- package/dist/tool/utils/backup.d.ts.map +1 -0
- package/dist/tool/utils/backup.js +123 -0
- package/dist/tool/utils/backup.js.map +1 -0
- package/dist/tool/utils/gitignore.d.ts +15 -0
- package/dist/tool/utils/gitignore.d.ts.map +1 -0
- package/dist/tool/utils/gitignore.js +41 -0
- package/dist/tool/utils/gitignore.js.map +1 -0
- package/dist/tool/utils/services-export.d.ts +14 -0
- package/dist/tool/utils/services-export.d.ts.map +1 -0
- package/dist/tool/utils/services-export.js +478 -0
- package/dist/tool/utils/services-export.js.map +1 -0
- package/dist/tool/utils/summary.d.ts +69 -0
- package/dist/tool/utils/summary.d.ts.map +1 -0
- package/dist/tool/utils/summary.js +260 -0
- package/dist/tool/utils/summary.js.map +1 -0
- package/docs/README.md +12 -2
- package/docs/adopt.md +196 -0
- package/docs/custom-patches.md +1 -1
- package/docs/discovery-commands.md +55 -3
- package/docs/examples.md +40 -6
- package/docs/filesystem-contract.md +58 -0
- package/docs/hash.md +183 -0
- package/docs/minimal-and-editor.md +1 -1
- package/docs/overlays.md +108 -5
- package/docs/presets-architecture.md +1 -1
- package/docs/presets.md +1 -1
- package/docs/publishing.md +36 -23
- package/docs/security.md +43 -0
- package/docs/specs/001-verbose-plan-graph/checklists/requirements.md +36 -0
- package/docs/specs/001-verbose-plan-graph/contracts/plan-verbose-output.md +96 -0
- package/docs/specs/001-verbose-plan-graph/data-model.md +111 -0
- package/docs/specs/001-verbose-plan-graph/plan.md +127 -0
- package/docs/specs/001-verbose-plan-graph/quickstart.md +106 -0
- package/docs/specs/001-verbose-plan-graph/research.md +100 -0
- package/docs/specs/001-verbose-plan-graph/spec.md +128 -0
- package/docs/specs/001-verbose-plan-graph/tasks.md +223 -0
- package/docs/specs/002-superposition-config-file/checklists/requirements.md +36 -0
- package/docs/specs/002-superposition-config-file/contracts/init-project-config.md +98 -0
- package/docs/specs/002-superposition-config-file/data-model.md +126 -0
- package/docs/specs/002-superposition-config-file/plan.md +208 -0
- package/docs/specs/002-superposition-config-file/quickstart.md +140 -0
- package/docs/specs/002-superposition-config-file/research.md +144 -0
- package/docs/specs/002-superposition-config-file/spec.md +130 -0
- package/docs/specs/002-superposition-config-file/tasks.md +213 -0
- package/docs/team-workflow.md +27 -1
- package/docs/workflows.md +136 -0
- package/overlays/.presets/microservice.yml +32 -6
- package/overlays/.presets/sdd.yml +84 -0
- package/overlays/.presets/web-api.yml +76 -56
- package/overlays/README.md +7 -1
- package/overlays/amp/README.md +70 -0
- package/overlays/amp/devcontainer.patch.json +3 -0
- package/overlays/amp/overlay.yml +15 -0
- package/overlays/amp/setup.sh +21 -0
- package/overlays/amp/verify.sh +21 -0
- package/overlays/claude-code/README.md +83 -0
- package/overlays/claude-code/devcontainer.patch.json +3 -0
- package/overlays/claude-code/overlay.yml +15 -0
- package/overlays/claude-code/setup.sh +21 -0
- package/overlays/claude-code/verify.sh +21 -0
- package/overlays/cloudflared/README.md +190 -0
- package/overlays/cloudflared/devcontainer.patch.json +3 -0
- package/overlays/cloudflared/overlay.yml +15 -0
- package/overlays/cloudflared/setup.sh +49 -0
- package/overlays/cloudflared/verify.sh +21 -0
- package/overlays/direnv/README.md +6 -4
- package/overlays/direnv/setup.sh +0 -12
- package/overlays/gemini-cli/README.md +77 -0
- package/overlays/gemini-cli/devcontainer.patch.json +3 -0
- package/overlays/gemini-cli/overlay.yml +15 -0
- package/overlays/gemini-cli/setup.sh +21 -0
- package/overlays/gemini-cli/verify.sh +21 -0
- package/overlays/grpc-tools/README.md +242 -0
- package/overlays/grpc-tools/devcontainer.patch.json +14 -0
- package/overlays/grpc-tools/overlay.yml +14 -0
- package/overlays/grpc-tools/setup.sh +57 -0
- package/overlays/grpc-tools/verify.sh +47 -0
- package/overlays/keycloak/.env.example +5 -0
- package/overlays/keycloak/README.md +238 -0
- package/overlays/keycloak/devcontainer.patch.json +17 -0
- package/overlays/keycloak/docker-compose.yml +32 -0
- package/overlays/keycloak/overlay.yml +23 -0
- package/overlays/keycloak/verify.sh +54 -0
- package/overlays/mailpit/.env.example +4 -0
- package/overlays/mailpit/README.md +191 -0
- package/overlays/mailpit/devcontainer.patch.json +20 -0
- package/overlays/mailpit/docker-compose.yml +17 -0
- package/overlays/mailpit/overlay.yml +26 -0
- package/overlays/mailpit/verify.sh +52 -0
- package/overlays/ngrok/overlay.yml +2 -1
- package/overlays/opencode/README.md +76 -0
- package/overlays/opencode/devcontainer.patch.json +3 -0
- package/overlays/opencode/overlay.yml +14 -0
- package/overlays/opencode/setup.sh +21 -0
- package/overlays/opencode/verify.sh +21 -0
- package/overlays/python/README.md +51 -35
- package/overlays/python/devcontainer.patch.json +7 -4
- package/overlays/python/setup.sh +50 -23
- package/overlays/python/verify.sh +29 -1
- package/overlays/spec-kit/README.md +181 -0
- package/overlays/spec-kit/devcontainer.patch.json +6 -0
- package/overlays/spec-kit/overlay.yml +19 -0
- package/overlays/spec-kit/setup.sh +45 -0
- package/overlays/spec-kit/verify.sh +33 -0
- package/overlays/windsurf-cli/README.md +69 -0
- package/overlays/windsurf-cli/devcontainer.patch.json +3 -0
- package/overlays/windsurf-cli/overlay.yml +15 -0
- package/overlays/windsurf-cli/setup.sh +21 -0
- package/overlays/windsurf-cli/verify.sh +21 -0
- package/package.json +1 -1
- 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
|