container-superposition 0.1.6 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/scripts/init.js +7 -4
- package/dist/scripts/init.js.map +1 -1
- package/dist/tool/commands/adopt.d.ts.map +1 -1
- package/dist/tool/commands/adopt.js +1 -27
- package/dist/tool/commands/adopt.js.map +1 -1
- package/dist/tool/commands/doctor.d.ts +3 -0
- package/dist/tool/commands/doctor.d.ts.map +1 -1
- package/dist/tool/commands/doctor.js +932 -69
- package/dist/tool/commands/doctor.js.map +1 -1
- package/dist/tool/commands/explain.d.ts.map +1 -1
- package/dist/tool/commands/explain.js +9 -0
- package/dist/tool/commands/explain.js.map +1 -1
- package/dist/tool/questionnaire/composer.d.ts.map +1 -1
- package/dist/tool/questionnaire/composer.js +212 -11
- package/dist/tool/questionnaire/composer.js.map +1 -1
- package/dist/tool/schema/overlay-loader.d.ts.map +1 -1
- package/dist/tool/schema/overlay-loader.js +1 -0
- package/dist/tool/schema/overlay-loader.js.map +1 -1
- package/dist/tool/schema/project-config.d.ts +1 -1
- package/dist/tool/schema/project-config.d.ts.map +1 -1
- package/dist/tool/schema/project-config.js +94 -25
- package/dist/tool/schema/project-config.js.map +1 -1
- package/dist/tool/schema/types.d.ts +85 -11
- package/dist/tool/schema/types.d.ts.map +1 -1
- package/dist/tool/utils/merge.d.ts.map +1 -1
- package/dist/tool/utils/merge.js +9 -0
- package/dist/tool/utils/merge.js.map +1 -1
- package/docs/creating-overlays.md +151 -2
- package/docs/overlay-imports.md +125 -102
- package/docs/overlays.md +49 -6
- package/docs/quick-reference.md +99 -0
- package/docs/specs/003-mkdocs2-overlay/spec.md +114 -0
- package/docs/specs/004-doctor-fix/spec.md +70 -0
- package/docs/specs/005-cuda-overlay/spec.md +101 -0
- package/docs/specs/006-rocm-overlay/spec.md +109 -0
- package/overlays/.shared/README.md +80 -21
- package/overlays/.shared/compose/common-healthchecks.md +60 -0
- package/overlays/.shared/vscode/recommended-extensions.json +15 -11
- package/overlays/alertmanager/setup.sh +4 -19
- package/overlays/alertmanager/verify.sh +8 -9
- package/overlays/all/README.md +43 -0
- package/overlays/all/devcontainer.patch.json +6 -0
- package/overlays/all/overlay.yml +14 -0
- package/overlays/amp/setup.sh +5 -0
- package/overlays/bun/setup.sh +10 -1
- package/overlays/bun/verify.sh +6 -1
- package/overlays/claude-code/setup.sh +5 -0
- package/overlays/cloudflared/setup.sh +9 -12
- package/overlays/codex/README.md +9 -6
- package/overlays/codex/devcontainer.patch.json +7 -1
- package/overlays/codex/setup.sh +5 -0
- package/overlays/codex/verify.sh +8 -0
- package/overlays/commitlint/setup.sh +5 -0
- package/overlays/cuda/README.md +179 -0
- package/overlays/cuda/devcontainer.patch.json +7 -0
- package/overlays/cuda/overlay.yml +17 -0
- package/overlays/cuda/setup.sh +32 -0
- package/overlays/cuda/verify.sh +38 -0
- package/overlays/devcontainer-cli/README.md +50 -0
- package/overlays/devcontainer-cli/devcontainer.patch.json +13 -0
- package/overlays/devcontainer-cli/overlay.yml +16 -0
- package/overlays/devcontainer-cli/setup.sh +14 -0
- package/overlays/direnv/devcontainer.patch.json +6 -0
- package/overlays/direnv/setup.sh +7 -6
- package/overlays/dotnet/setup.sh +14 -7
- package/overlays/duckdb/devcontainer.patch.json +1 -2
- package/overlays/gcloud/devcontainer.patch.json +0 -6
- package/overlays/gcloud/setup.sh +51 -0
- package/overlays/gemini-cli/setup.sh +5 -0
- package/overlays/git-helpers/devcontainer.patch.json +2 -1
- package/overlays/go/setup.sh +15 -14
- package/overlays/jaeger/overlay.yml +2 -0
- package/overlays/just/setup.sh +5 -17
- package/overlays/keycloak/docker-compose.yml +6 -4
- package/overlays/keycloak/verify.sh +4 -3
- package/overlays/kind/devcontainer.patch.json +1 -2
- package/overlays/kind/setup.sh +8 -17
- package/overlays/minio/setup.sh +10 -18
- package/overlays/mkdocs/overlay.yml +2 -1
- package/overlays/mkdocs2/README.md +135 -0
- package/overlays/mkdocs2/devcontainer.patch.json +19 -0
- package/overlays/mkdocs2/overlay.yml +17 -0
- package/overlays/mkdocs2/setup.sh +67 -0
- package/overlays/mkdocs2/verify.sh +35 -0
- package/overlays/modern-cli-tools/devcontainer.patch.json +7 -1
- package/overlays/modern-cli-tools/setup.sh +21 -71
- package/overlays/mongodb/devcontainer.patch.json +0 -6
- package/overlays/mongodb/setup.sh +59 -0
- package/overlays/mysql/verify.sh +4 -3
- package/overlays/nats/.env.example +1 -1
- package/overlays/nats/README.md +1 -1
- package/overlays/nats/docker-compose.yml +1 -1
- package/overlays/ngrok/setup.sh +9 -6
- package/overlays/nodejs/setup.sh +5 -0
- package/overlays/openapi-tools/devcontainer.patch.json +1 -2
- package/overlays/openapi-tools/setup.sh +9 -8
- package/overlays/opencode/setup.sh +5 -0
- package/overlays/otel-collector/overlay.yml +2 -0
- package/overlays/otel-collector/setup.sh +3 -16
- package/overlays/otel-demo-nodejs/verify.sh +8 -9
- package/overlays/otel-demo-python/verify.sh +16 -10
- package/overlays/pandoc/README.md +22 -15
- package/overlays/pandoc/devcontainer.patch.json +6 -2
- package/overlays/pandoc/setup.sh +217 -18
- package/overlays/pandoc/verify.sh +16 -4
- package/overlays/playwright/devcontainer.patch.json +3 -1
- package/overlays/playwright/setup.sh +37 -0
- package/overlays/postgres/docker-compose.yml +6 -0
- package/overlays/powershell/setup.sh +49 -13
- package/overlays/pre-commit/setup.sh +12 -3
- package/overlays/prometheus/overlay.yml +2 -0
- package/overlays/promtail/verify.sh +16 -10
- package/overlays/pulumi/devcontainer.patch.json +1 -1
- package/overlays/python/setup.sh +28 -9
- package/overlays/python/verify.sh +4 -2
- package/overlays/redpanda/docker-compose.yml +3 -5
- package/overlays/rocm/README.md +227 -0
- package/overlays/rocm/devcontainer.patch.json +4 -0
- package/overlays/rocm/overlay.yml +17 -0
- package/overlays/rocm/setup.sh +45 -0
- package/overlays/rocm/verify.sh +47 -0
- package/overlays/rust/setup.sh +11 -18
- package/overlays/spec-kit/setup.sh +7 -3
- package/overlays/sqlite/setup.sh +14 -14
- package/overlays/sqlserver/docker-compose.yml +3 -3
- package/overlays/sqlserver/verify.sh +22 -5
- package/overlays/tempo/verify.sh +16 -10
- package/overlays/tilt/devcontainer.patch.json +1 -2
- package/overlays/tilt/setup.sh +14 -4
- package/overlays/windsurf-cli/setup.sh +27 -4
- package/overlays/windsurf-cli/verify.sh +13 -3
- package/package.json +2 -1
- package/templates/scripts/setup-utils.sh +228 -0
- package/tool/schema/config.schema.json +110 -8
- package/tool/schema/overlay-manifest.schema.json +5 -0
- package/overlays/.shared/compose/common-healthchecks.yml +0 -38
- /package/overlays/otel-demo-nodejs/{Dockerfile-otel-demo-nodejs → Dockerfile} +0 -0
- /package/overlays/otel-demo-nodejs/{package-otel-demo-nodejs.json → package.json} +0 -0
- /package/overlays/otel-demo-nodejs/{server-otel-demo-nodejs.js → server.js} +0 -0
- /package/overlays/otel-demo-nodejs/{tracing-otel-demo-nodejs.js → tracing.js} +0 -0
- /package/overlays/otel-demo-python/{Dockerfile-otel-demo-python → Dockerfile} +0 -0
- /package/overlays/otel-demo-python/{app-otel-demo-python.py → app.py} +0 -0
- /package/overlays/otel-demo-python/{requirements-otel-demo-python.txt → requirements.txt} +0 -0
|
@@ -8,9 +8,76 @@ import { execSync } from 'child_process';
|
|
|
8
8
|
import chalk from 'chalk';
|
|
9
9
|
import boxen from 'boxen';
|
|
10
10
|
import { loadOverlayManifest } from '../schema/overlay-loader.js';
|
|
11
|
-
import { detectManifestVersion, isVersionSupported, needsMigration, CURRENT_MANIFEST_VERSION, } from '../schema/manifest-migrations.js';
|
|
11
|
+
import { detectManifestVersion, isVersionSupported, needsMigration, migrateManifest, CURRENT_MANIFEST_VERSION, } from '../schema/manifest-migrations.js';
|
|
12
12
|
import { MERGE_STRATEGY } from '../utils/merge.js';
|
|
13
13
|
import { extractPorts } from '../utils/port-utils.js';
|
|
14
|
+
import { composeDevContainer } from '../questionnaire/composer.js';
|
|
15
|
+
import { loadProjectConfig } from '../schema/project-config.js';
|
|
16
|
+
// ─── Remediation registry ─────────────────────────────────────────────────
|
|
17
|
+
const REMEDIATION_REGISTRY = new Map([
|
|
18
|
+
[
|
|
19
|
+
'manifest-migration',
|
|
20
|
+
{
|
|
21
|
+
key: 'manifest-migration',
|
|
22
|
+
findingId: 'manifest-version',
|
|
23
|
+
safetyClass: 'safe-unattended',
|
|
24
|
+
executionKind: 'manifest-migration',
|
|
25
|
+
preconditions: ['superposition.json must exist and be parseable'],
|
|
26
|
+
plannedChanges: [
|
|
27
|
+
'Migrate superposition.json to current schema version',
|
|
28
|
+
'Create timestamped backup of the original manifest',
|
|
29
|
+
],
|
|
30
|
+
manualFallback: [
|
|
31
|
+
'Run "container-superposition regen" to regenerate with the current schema',
|
|
32
|
+
],
|
|
33
|
+
},
|
|
34
|
+
],
|
|
35
|
+
[
|
|
36
|
+
'devcontainer-regeneration',
|
|
37
|
+
{
|
|
38
|
+
key: 'devcontainer-regeneration',
|
|
39
|
+
findingId: 'devcontainer-config',
|
|
40
|
+
safetyClass: 'safe-unattended',
|
|
41
|
+
executionKind: 'regeneration',
|
|
42
|
+
preconditions: ['Valid superposition.json manifest must be present'],
|
|
43
|
+
plannedChanges: ['Regenerate devcontainer.json from superposition.json'],
|
|
44
|
+
manualFallback: ['Run "container-superposition regen --output <path>" to regenerate'],
|
|
45
|
+
},
|
|
46
|
+
],
|
|
47
|
+
[
|
|
48
|
+
'node-version-fix',
|
|
49
|
+
{
|
|
50
|
+
key: 'node-version-fix',
|
|
51
|
+
findingId: 'nodejs-version',
|
|
52
|
+
safetyClass: 'safe-unattended',
|
|
53
|
+
executionKind: 'shell-command',
|
|
54
|
+
preconditions: ['nvm, fnm, or volta must be installed'],
|
|
55
|
+
plannedChanges: ['Use version manager to install and activate Node.js >= 20'],
|
|
56
|
+
manualFallback: [
|
|
57
|
+
'Install Node.js >= 20 from https://nodejs.org/',
|
|
58
|
+
'Or with nvm: nvm install 20 && nvm use 20',
|
|
59
|
+
'Or with fnm: fnm install 20 && fnm use 20',
|
|
60
|
+
'Or with volta: volta install node@20',
|
|
61
|
+
],
|
|
62
|
+
},
|
|
63
|
+
],
|
|
64
|
+
[
|
|
65
|
+
'docker-repair',
|
|
66
|
+
{
|
|
67
|
+
key: 'docker-repair',
|
|
68
|
+
findingId: 'docker-daemon',
|
|
69
|
+
safetyClass: 'requires-manual-action',
|
|
70
|
+
executionKind: 'no-op',
|
|
71
|
+
preconditions: [],
|
|
72
|
+
plannedChanges: [],
|
|
73
|
+
manualFallback: [
|
|
74
|
+
'Linux: sudo systemctl start docker',
|
|
75
|
+
'macOS: open -a Docker',
|
|
76
|
+
'Windows: Start Docker Desktop from the Start menu',
|
|
77
|
+
],
|
|
78
|
+
},
|
|
79
|
+
],
|
|
80
|
+
]);
|
|
14
81
|
/**
|
|
15
82
|
* Semantic version comparison helper
|
|
16
83
|
*/
|
|
@@ -41,36 +108,76 @@ function checkNodeVersion() {
|
|
|
41
108
|
const versionMatch = nodeVersion.match(/^v(\d+\.\d+\.\d+)/);
|
|
42
109
|
const currentVersion = versionMatch ? versionMatch[1] : '0.0.0';
|
|
43
110
|
const ok = isVersionAtLeast(currentVersion, requiredVersion);
|
|
111
|
+
if (ok) {
|
|
112
|
+
return {
|
|
113
|
+
name: 'Node.js version',
|
|
114
|
+
status: 'pass',
|
|
115
|
+
message: `${nodeVersion} (>= ${requiredVersion} required)`,
|
|
116
|
+
fixEligibility: 'not-applicable',
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
// Determine if a version manager is available for auto-fix
|
|
120
|
+
const hasVersionManager = detectVersionManager() !== null;
|
|
44
121
|
return {
|
|
45
122
|
name: 'Node.js version',
|
|
46
|
-
status:
|
|
47
|
-
message:
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
],
|
|
123
|
+
status: 'fail',
|
|
124
|
+
message: `${nodeVersion} - requires >= ${requiredVersion}`,
|
|
125
|
+
details: [
|
|
126
|
+
'Update Node.js to version 20 or later',
|
|
127
|
+
'Visit https://nodejs.org/ to download the latest version',
|
|
128
|
+
],
|
|
129
|
+
fixEligibility: hasVersionManager ? 'automatic' : 'manual-only',
|
|
130
|
+
remediationKey: hasVersionManager ? 'node-version-fix' : undefined,
|
|
131
|
+
fixable: hasVersionManager,
|
|
56
132
|
};
|
|
57
133
|
}
|
|
134
|
+
/**
|
|
135
|
+
* Detect which Node.js version manager is available.
|
|
136
|
+
* Returns the manager name or null if none found.
|
|
137
|
+
*/
|
|
138
|
+
function detectVersionManager() {
|
|
139
|
+
const home = process.env.HOME ?? process.env.USERPROFILE ?? '';
|
|
140
|
+
// nvm installs a shell function, not a binary — check the script file
|
|
141
|
+
const nvmScript = path.join(home, '.nvm', 'nvm.sh');
|
|
142
|
+
if (fs.existsSync(nvmScript)) {
|
|
143
|
+
return 'nvm';
|
|
144
|
+
}
|
|
145
|
+
for (const cmd of ['fnm', 'volta']) {
|
|
146
|
+
try {
|
|
147
|
+
execSync(`${cmd} --version`, {
|
|
148
|
+
stdio: 'ignore',
|
|
149
|
+
timeout: 3000,
|
|
150
|
+
});
|
|
151
|
+
return cmd;
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
// not available
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
58
159
|
/**
|
|
59
160
|
* Check if Docker daemon is accessible
|
|
60
161
|
*/
|
|
61
162
|
function checkDocker() {
|
|
62
163
|
try {
|
|
63
164
|
// Use 'docker info' to verify daemon connectivity, not just CLI presence
|
|
64
|
-
execSync('docker info', {
|
|
165
|
+
execSync('docker info', {
|
|
166
|
+
encoding: 'utf8',
|
|
167
|
+
stdio: ['pipe', 'pipe', 'ignore'],
|
|
168
|
+
timeout: 5000,
|
|
169
|
+
});
|
|
65
170
|
// Get version for display
|
|
66
171
|
const version = execSync('docker --version', {
|
|
67
172
|
encoding: 'utf8',
|
|
68
173
|
stdio: ['pipe', 'pipe', 'ignore'],
|
|
174
|
+
timeout: 5000,
|
|
69
175
|
});
|
|
70
176
|
return {
|
|
71
177
|
name: 'Docker daemon',
|
|
72
178
|
status: 'pass',
|
|
73
179
|
message: version.trim(),
|
|
180
|
+
fixEligibility: 'not-applicable',
|
|
74
181
|
};
|
|
75
182
|
}
|
|
76
183
|
catch {
|
|
@@ -83,6 +190,8 @@ function checkDocker() {
|
|
|
83
190
|
'Install Docker Desktop or Docker Engine',
|
|
84
191
|
'Ensure Docker daemon is running',
|
|
85
192
|
],
|
|
193
|
+
fixEligibility: 'manual-only',
|
|
194
|
+
remediationKey: 'docker-repair',
|
|
86
195
|
};
|
|
87
196
|
}
|
|
88
197
|
}
|
|
@@ -95,6 +204,7 @@ function checkDockerCompose() {
|
|
|
95
204
|
const version = execSync('docker compose version', {
|
|
96
205
|
encoding: 'utf8',
|
|
97
206
|
stdio: ['pipe', 'pipe', 'ignore'],
|
|
207
|
+
timeout: 5000,
|
|
98
208
|
});
|
|
99
209
|
const versionMatch = version.match(/v?(\d+\.\d+\.\d+)/);
|
|
100
210
|
const currentVersion = versionMatch ? versionMatch[1] : '0.0.0';
|
|
@@ -104,6 +214,7 @@ function checkDockerCompose() {
|
|
|
104
214
|
name: 'Docker Compose',
|
|
105
215
|
status: 'pass',
|
|
106
216
|
message: `v${currentVersion} (v2 required)`,
|
|
217
|
+
fixEligibility: 'not-applicable',
|
|
107
218
|
};
|
|
108
219
|
}
|
|
109
220
|
else {
|
|
@@ -115,6 +226,7 @@ function checkDockerCompose() {
|
|
|
115
226
|
'Docker Compose v2 is recommended for compose-based templates',
|
|
116
227
|
'Update Docker Desktop or install docker-compose-plugin',
|
|
117
228
|
],
|
|
229
|
+
fixEligibility: 'manual-only',
|
|
118
230
|
};
|
|
119
231
|
}
|
|
120
232
|
}
|
|
@@ -124,6 +236,7 @@ function checkDockerCompose() {
|
|
|
124
236
|
const version = execSync('docker-compose --version', {
|
|
125
237
|
encoding: 'utf8',
|
|
126
238
|
stdio: ['pipe', 'pipe', 'ignore'],
|
|
239
|
+
timeout: 5000,
|
|
127
240
|
});
|
|
128
241
|
return {
|
|
129
242
|
name: 'Docker Compose',
|
|
@@ -133,6 +246,7 @@ function checkDockerCompose() {
|
|
|
133
246
|
'Docker Compose v1 detected',
|
|
134
247
|
'Consider upgrading to v2: docker compose (not docker-compose)',
|
|
135
248
|
],
|
|
249
|
+
fixEligibility: 'manual-only',
|
|
136
250
|
};
|
|
137
251
|
}
|
|
138
252
|
catch {
|
|
@@ -145,6 +259,7 @@ function checkDockerCompose() {
|
|
|
145
259
|
'Install Docker Desktop (includes Compose v2)',
|
|
146
260
|
'Or install docker-compose-plugin',
|
|
147
261
|
],
|
|
262
|
+
fixEligibility: 'manual-only',
|
|
148
263
|
};
|
|
149
264
|
}
|
|
150
265
|
}
|
|
@@ -152,10 +267,10 @@ function checkDockerCompose() {
|
|
|
152
267
|
/**
|
|
153
268
|
* Run environment checks
|
|
154
269
|
*/
|
|
155
|
-
function checkEnvironment(outputPath) {
|
|
270
|
+
function checkEnvironment(outputPath, explicitManifestPath) {
|
|
156
271
|
const results = [checkNodeVersion(), checkDocker()];
|
|
157
272
|
// Only check Docker Compose if using compose stack
|
|
158
|
-
const baseTemplate = getBaseTemplateFromManifest(outputPath);
|
|
273
|
+
const baseTemplate = getBaseTemplateFromManifest(outputPath, explicitManifestPath);
|
|
159
274
|
if (baseTemplate === 'compose') {
|
|
160
275
|
results.push(checkDockerCompose());
|
|
161
276
|
}
|
|
@@ -164,8 +279,8 @@ function checkEnvironment(outputPath) {
|
|
|
164
279
|
/**
|
|
165
280
|
* Get base template from manifest if it exists
|
|
166
281
|
*/
|
|
167
|
-
function getBaseTemplateFromManifest(outputPath) {
|
|
168
|
-
const manifestPath = path.join(outputPath, 'superposition.json');
|
|
282
|
+
function getBaseTemplateFromManifest(outputPath, explicitManifestPath) {
|
|
283
|
+
const manifestPath = explicitManifestPath ?? path.join(outputPath, 'superposition.json');
|
|
169
284
|
if (!fs.existsSync(manifestPath)) {
|
|
170
285
|
return undefined;
|
|
171
286
|
}
|
|
@@ -258,9 +373,21 @@ function validateOverlayManifest(overlayDir, overlayId) {
|
|
|
258
373
|
// Validate imports if present
|
|
259
374
|
if (manifest.imports && manifest.imports.length > 0) {
|
|
260
375
|
const overlaysDir = path.dirname(overlayDir);
|
|
376
|
+
const sharedBase = path.resolve(overlaysDir, '.shared');
|
|
261
377
|
const missingImports = [];
|
|
262
378
|
const invalidImports = [];
|
|
379
|
+
const traversalImports = [];
|
|
263
380
|
for (const importPath of manifest.imports) {
|
|
381
|
+
// FR-006: Check for path traversal
|
|
382
|
+
if (!importPath.startsWith('.shared/')) {
|
|
383
|
+
traversalImports.push(`${importPath} (must begin with '.shared/')`);
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
const resolved = path.resolve(overlaysDir, importPath);
|
|
387
|
+
if (!resolved.startsWith(sharedBase + path.sep) && resolved !== sharedBase) {
|
|
388
|
+
traversalImports.push(`${importPath} (resolves outside '.shared/' directory)`);
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
264
391
|
const fullImportPath = path.join(overlaysDir, importPath);
|
|
265
392
|
if (!fs.existsSync(fullImportPath)) {
|
|
266
393
|
missingImports.push(importPath);
|
|
@@ -272,8 +399,11 @@ function validateOverlayManifest(overlayDir, overlayId) {
|
|
|
272
399
|
invalidImports.push(`${importPath} (unsupported type: ${ext})`);
|
|
273
400
|
}
|
|
274
401
|
}
|
|
275
|
-
if (missingImports.length > 0 || invalidImports.length > 0) {
|
|
402
|
+
if (traversalImports.length > 0 || missingImports.length > 0 || invalidImports.length > 0) {
|
|
276
403
|
const details = [];
|
|
404
|
+
if (traversalImports.length > 0) {
|
|
405
|
+
details.push(`Path traversal rejected: ${traversalImports.join(', ')}`);
|
|
406
|
+
}
|
|
277
407
|
if (missingImports.length > 0) {
|
|
278
408
|
details.push(`Missing imports: ${missingImports.join(', ')}`);
|
|
279
409
|
}
|
|
@@ -403,11 +533,11 @@ function checkPorts(overlaysConfig, manifestPath) {
|
|
|
403
533
|
/**
|
|
404
534
|
* Check manifest compatibility
|
|
405
535
|
*/
|
|
406
|
-
function checkManifest(outputPath) {
|
|
536
|
+
function checkManifest(outputPath, explicitManifestPath) {
|
|
407
537
|
const results = [];
|
|
408
|
-
const manifestPath = path.join(outputPath, 'superposition.json');
|
|
409
|
-
// Check if output path exists
|
|
410
|
-
if (!fs.existsSync(outputPath)) {
|
|
538
|
+
const manifestPath = explicitManifestPath ?? path.join(outputPath, 'superposition.json');
|
|
539
|
+
// Check if output path exists (skip when manifest is explicitly provided; the dir may not exist yet)
|
|
540
|
+
if (!explicitManifestPath && !fs.existsSync(outputPath)) {
|
|
411
541
|
return [
|
|
412
542
|
{
|
|
413
543
|
name: 'Devcontainer directory',
|
|
@@ -463,6 +593,9 @@ function checkManifest(outputPath) {
|
|
|
463
593
|
? `Schema version ${manifest.manifestVersion} (tool ${manifest.generatedBy || 'unknown'})`
|
|
464
594
|
: `Legacy format (tool ${manifest.version || 'unknown'})`,
|
|
465
595
|
details: versionDetails,
|
|
596
|
+
fixEligibility: needsUpdate && supported ? 'automatic' : 'not-applicable',
|
|
597
|
+
remediationKey: needsUpdate && supported ? 'manifest-migration' : undefined,
|
|
598
|
+
fixable: needsUpdate && supported,
|
|
466
599
|
});
|
|
467
600
|
// Check for required fields
|
|
468
601
|
if (!manifest.baseTemplate) {
|
|
@@ -470,6 +603,7 @@ function checkManifest(outputPath) {
|
|
|
470
603
|
name: 'Manifest base template',
|
|
471
604
|
status: 'fail',
|
|
472
605
|
message: 'Missing baseTemplate field',
|
|
606
|
+
fixEligibility: 'manual-only',
|
|
473
607
|
});
|
|
474
608
|
}
|
|
475
609
|
// Check devcontainer.json exists
|
|
@@ -480,6 +614,9 @@ function checkManifest(outputPath) {
|
|
|
480
614
|
status: 'fail',
|
|
481
615
|
message: 'devcontainer.json not found',
|
|
482
616
|
details: ['Devcontainer configuration file is missing or corrupted'],
|
|
617
|
+
fixEligibility: 'automatic',
|
|
618
|
+
remediationKey: 'devcontainer-regeneration',
|
|
619
|
+
fixable: true,
|
|
483
620
|
});
|
|
484
621
|
}
|
|
485
622
|
else {
|
|
@@ -491,6 +628,7 @@ function checkManifest(outputPath) {
|
|
|
491
628
|
name: 'DevContainer config',
|
|
492
629
|
status: 'pass',
|
|
493
630
|
message: 'devcontainer.json valid',
|
|
631
|
+
fixEligibility: 'not-applicable',
|
|
494
632
|
});
|
|
495
633
|
}
|
|
496
634
|
catch {
|
|
@@ -498,6 +636,9 @@ function checkManifest(outputPath) {
|
|
|
498
636
|
name: 'DevContainer config',
|
|
499
637
|
status: 'fail',
|
|
500
638
|
message: 'devcontainer.json has invalid JSON',
|
|
639
|
+
fixEligibility: 'automatic',
|
|
640
|
+
remediationKey: 'devcontainer-regeneration',
|
|
641
|
+
fixable: true,
|
|
501
642
|
});
|
|
502
643
|
}
|
|
503
644
|
}
|
|
@@ -508,6 +649,7 @@ function checkManifest(outputPath) {
|
|
|
508
649
|
status: 'fail',
|
|
509
650
|
message: 'Invalid JSON in superposition.json',
|
|
510
651
|
details: [`Parse error: ${error instanceof Error ? error.message : String(error)}`],
|
|
652
|
+
fixEligibility: 'manual-only',
|
|
511
653
|
});
|
|
512
654
|
}
|
|
513
655
|
return results;
|
|
@@ -787,33 +929,740 @@ function formatAsText(report) {
|
|
|
787
929
|
return lines.join('\n');
|
|
788
930
|
}
|
|
789
931
|
/**
|
|
790
|
-
*
|
|
791
|
-
*/
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
932
|
+
* Convert a report section + category into DiagnosticFinding objects.
|
|
933
|
+
*/
|
|
934
|
+
function checksToFindings(checks, category, recheckScope) {
|
|
935
|
+
return checks.map((c) => {
|
|
936
|
+
const id = c.name
|
|
937
|
+
.toLowerCase()
|
|
938
|
+
.replace(/\s+/g, '-')
|
|
939
|
+
.replace(/[^a-z0-9-]/g, '');
|
|
940
|
+
return {
|
|
941
|
+
id,
|
|
942
|
+
category,
|
|
943
|
+
name: c.name,
|
|
944
|
+
status: c.status,
|
|
945
|
+
message: c.message,
|
|
946
|
+
details: c.details,
|
|
947
|
+
fixEligibility: c.fixEligibility ?? 'not-applicable',
|
|
948
|
+
remediationKey: c.remediationKey,
|
|
949
|
+
recheckScope,
|
|
950
|
+
};
|
|
951
|
+
});
|
|
952
|
+
}
|
|
953
|
+
/**
|
|
954
|
+
* Convert a full DoctorReport into a flat DiagnosticFinding array.
|
|
955
|
+
*/
|
|
956
|
+
function reportToFindings(report) {
|
|
957
|
+
return [
|
|
958
|
+
...checksToFindings(report.environment, 'environment', 'environment'),
|
|
959
|
+
...checksToFindings(report.overlays, 'overlay', 'full'),
|
|
960
|
+
...checksToFindings(report.manifest, 'manifest', 'manifest'),
|
|
961
|
+
...checksToFindings(report.merge, 'merge', 'devcontainer'),
|
|
962
|
+
...checksToFindings(report.ports, 'ports', 'environment'),
|
|
963
|
+
];
|
|
964
|
+
}
|
|
965
|
+
/**
|
|
966
|
+
* Order findings for remediation: manifest migration must come before regeneration.
|
|
967
|
+
*/
|
|
968
|
+
function orderFindingsForRemediation(findings) {
|
|
969
|
+
const PRIORITY = {
|
|
970
|
+
'manifest-migration': 1,
|
|
971
|
+
'devcontainer-regeneration': 2,
|
|
972
|
+
'node-version-fix': 3,
|
|
973
|
+
'docker-repair': 4,
|
|
974
|
+
};
|
|
975
|
+
return [...findings].sort((a, b) => {
|
|
976
|
+
const pa = PRIORITY[a.remediationKey ?? ''] ?? 99;
|
|
977
|
+
const pb = PRIORITY[b.remediationKey ?? ''] ?? 99;
|
|
978
|
+
return pa - pb;
|
|
979
|
+
});
|
|
980
|
+
}
|
|
981
|
+
/**
|
|
982
|
+
* Atomically write a JSON file (write to .tmp then rename).
|
|
983
|
+
* On Windows, rename fails if the destination already exists; delete it first.
|
|
984
|
+
*/
|
|
985
|
+
function atomicWriteJson(filePath, data) {
|
|
986
|
+
const tmpPath = filePath + '.tmp';
|
|
987
|
+
fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2) + '\n', 'utf8');
|
|
988
|
+
// Windows requires the destination to be absent before renaming.
|
|
989
|
+
if (fs.existsSync(filePath)) {
|
|
990
|
+
fs.unlinkSync(filePath);
|
|
991
|
+
}
|
|
992
|
+
fs.renameSync(tmpPath, filePath);
|
|
993
|
+
}
|
|
994
|
+
/**
|
|
995
|
+
* Create a timestamped backup of a file and return the backup path.
|
|
996
|
+
*/
|
|
997
|
+
function backupFile(filePath) {
|
|
998
|
+
const timestamp = new Date()
|
|
999
|
+
.toISOString()
|
|
1000
|
+
.replace(/[:.]/g, '-')
|
|
1001
|
+
.replace('T', '-')
|
|
1002
|
+
.replace('Z', '');
|
|
1003
|
+
const backupPath = `${filePath}.backup-${timestamp}`;
|
|
1004
|
+
fs.copyFileSync(filePath, backupPath);
|
|
1005
|
+
return backupPath;
|
|
1006
|
+
}
|
|
1007
|
+
/**
|
|
1008
|
+
* Build QuestionnaireAnswers from a SuperpositionManifest using overlaysConfig
|
|
1009
|
+
* for category resolution. Used by the devcontainer-regeneration fix.
|
|
1010
|
+
*/
|
|
1011
|
+
function buildAnswersFromManifest(manifest, manifestDir, overlaysConfig) {
|
|
1012
|
+
const knownBaseImageIds = ['bookworm', 'trixie', 'alpine', 'ubuntu', 'custom'];
|
|
1013
|
+
const isKnownBaseImage = knownBaseImageIds.includes(manifest.baseImage);
|
|
1014
|
+
const language = [];
|
|
1015
|
+
const database = [];
|
|
1016
|
+
const observability = [];
|
|
1017
|
+
const cloudTools = [];
|
|
1018
|
+
const devTools = [];
|
|
1019
|
+
const overlayMap = new Map(overlaysConfig.overlays.map((o) => [o.id, o]));
|
|
1020
|
+
for (const id of manifest.overlays) {
|
|
1021
|
+
const overlay = overlayMap.get(id);
|
|
1022
|
+
if (!overlay)
|
|
1023
|
+
continue;
|
|
1024
|
+
switch (overlay.category) {
|
|
1025
|
+
case 'language':
|
|
1026
|
+
language.push(id);
|
|
1027
|
+
break;
|
|
1028
|
+
case 'database':
|
|
1029
|
+
database.push(id);
|
|
1030
|
+
break;
|
|
1031
|
+
case 'observability':
|
|
1032
|
+
observability.push(id);
|
|
1033
|
+
break;
|
|
1034
|
+
case 'cloud':
|
|
1035
|
+
cloudTools.push(id);
|
|
1036
|
+
break;
|
|
1037
|
+
case 'dev':
|
|
1038
|
+
devTools.push(id);
|
|
1039
|
+
break;
|
|
1040
|
+
}
|
|
810
1041
|
}
|
|
1042
|
+
return {
|
|
1043
|
+
stack: manifest.baseTemplate,
|
|
1044
|
+
baseImage: isKnownBaseImage ? manifest.baseImage : 'custom',
|
|
1045
|
+
customImage: isKnownBaseImage ? undefined : manifest.baseImage,
|
|
1046
|
+
containerName: manifest.containerName,
|
|
1047
|
+
preset: manifest.preset,
|
|
1048
|
+
presetChoices: manifest.presetChoices,
|
|
1049
|
+
language,
|
|
1050
|
+
database,
|
|
1051
|
+
observability,
|
|
1052
|
+
cloudTools,
|
|
1053
|
+
devTools,
|
|
1054
|
+
needsDocker: manifest.baseTemplate === 'compose',
|
|
1055
|
+
playwright: devTools.includes('playwright'),
|
|
1056
|
+
outputPath: manifestDir,
|
|
1057
|
+
portOffset: manifest.portOffset,
|
|
1058
|
+
};
|
|
1059
|
+
}
|
|
1060
|
+
/**
|
|
1061
|
+
* Execute manifest migration fix (Class 1).
|
|
1062
|
+
*/
|
|
1063
|
+
function executeManifestMigration(outputPath, explicitManifestPath) {
|
|
1064
|
+
const manifestPath = explicitManifestPath ?? path.join(outputPath, 'superposition.json');
|
|
1065
|
+
if (!fs.existsSync(manifestPath)) {
|
|
1066
|
+
return {
|
|
1067
|
+
findingId: 'manifest-version',
|
|
1068
|
+
remediationKey: 'manifest-migration',
|
|
1069
|
+
attempted: false,
|
|
1070
|
+
outcome: 'requires-manual-action',
|
|
1071
|
+
reason: 'superposition.json not found — cannot migrate',
|
|
1072
|
+
rechecked: false,
|
|
1073
|
+
};
|
|
1074
|
+
}
|
|
1075
|
+
let manifest;
|
|
1076
|
+
try {
|
|
1077
|
+
manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
1078
|
+
}
|
|
1079
|
+
catch (err) {
|
|
1080
|
+
return {
|
|
1081
|
+
findingId: 'manifest-version',
|
|
1082
|
+
remediationKey: 'manifest-migration',
|
|
1083
|
+
attempted: false,
|
|
1084
|
+
outcome: 'requires-manual-action',
|
|
1085
|
+
reason: `Cannot parse superposition.json: ${err instanceof Error ? err.message : String(err)}`,
|
|
1086
|
+
rechecked: false,
|
|
1087
|
+
};
|
|
1088
|
+
}
|
|
1089
|
+
if (!needsMigration(manifest)) {
|
|
1090
|
+
return {
|
|
1091
|
+
findingId: 'manifest-version',
|
|
1092
|
+
remediationKey: 'manifest-migration',
|
|
1093
|
+
attempted: false,
|
|
1094
|
+
outcome: 'already-compliant',
|
|
1095
|
+
reason: 'Manifest is already at the current schema version',
|
|
1096
|
+
rechecked: true,
|
|
1097
|
+
};
|
|
1098
|
+
}
|
|
1099
|
+
let backupPath;
|
|
1100
|
+
try {
|
|
1101
|
+
backupPath = backupFile(manifestPath);
|
|
1102
|
+
}
|
|
1103
|
+
catch (err) {
|
|
1104
|
+
return {
|
|
1105
|
+
findingId: 'manifest-version',
|
|
1106
|
+
remediationKey: 'manifest-migration',
|
|
1107
|
+
attempted: false,
|
|
1108
|
+
outcome: 'requires-manual-action',
|
|
1109
|
+
reason: `Failed to create backup: ${err instanceof Error ? err.message : String(err)}`,
|
|
1110
|
+
rechecked: false,
|
|
1111
|
+
};
|
|
1112
|
+
}
|
|
1113
|
+
try {
|
|
1114
|
+
const migrated = migrateManifest(manifest);
|
|
1115
|
+
atomicWriteJson(manifestPath, migrated);
|
|
1116
|
+
}
|
|
1117
|
+
catch (err) {
|
|
1118
|
+
// Restore backup on failure
|
|
1119
|
+
try {
|
|
1120
|
+
fs.copyFileSync(backupPath, manifestPath);
|
|
1121
|
+
}
|
|
1122
|
+
catch {
|
|
1123
|
+
// best effort
|
|
1124
|
+
}
|
|
1125
|
+
return {
|
|
1126
|
+
findingId: 'manifest-version',
|
|
1127
|
+
remediationKey: 'manifest-migration',
|
|
1128
|
+
attempted: true,
|
|
1129
|
+
outcome: 'requires-manual-action',
|
|
1130
|
+
reason: `Migration failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
1131
|
+
backupPath,
|
|
1132
|
+
rechecked: false,
|
|
1133
|
+
};
|
|
1134
|
+
}
|
|
1135
|
+
// Re-check
|
|
1136
|
+
try {
|
|
1137
|
+
const updated = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
1138
|
+
const stillNeeds = needsMigration(updated);
|
|
1139
|
+
return {
|
|
1140
|
+
findingId: 'manifest-version',
|
|
1141
|
+
remediationKey: 'manifest-migration',
|
|
1142
|
+
attempted: true,
|
|
1143
|
+
outcome: stillNeeds ? 'requires-manual-action' : 'fixed',
|
|
1144
|
+
reason: stillNeeds
|
|
1145
|
+
? 'Migration wrote file but schema still reports outdated'
|
|
1146
|
+
: 'Manifest migrated to current schema version',
|
|
1147
|
+
changedFiles: [manifestPath],
|
|
1148
|
+
backupPath,
|
|
1149
|
+
rechecked: true,
|
|
1150
|
+
};
|
|
1151
|
+
}
|
|
1152
|
+
catch {
|
|
1153
|
+
return {
|
|
1154
|
+
findingId: 'manifest-version',
|
|
1155
|
+
remediationKey: 'manifest-migration',
|
|
1156
|
+
attempted: true,
|
|
1157
|
+
outcome: 'fixed',
|
|
1158
|
+
reason: 'Manifest migrated (re-check skipped — parse error after write)',
|
|
1159
|
+
changedFiles: [manifestPath],
|
|
1160
|
+
backupPath,
|
|
1161
|
+
rechecked: false,
|
|
1162
|
+
};
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
/**
|
|
1166
|
+
* Execute devcontainer regeneration fix (Class 2).
|
|
1167
|
+
* @param silent When true, suppresses console output during regeneration (for --json mode).
|
|
1168
|
+
*/
|
|
1169
|
+
async function executeRegeneration(outputPath, overlaysConfig, overlaysDir, silent = false, explicitManifestPath) {
|
|
1170
|
+
const manifestPath = explicitManifestPath ?? path.join(outputPath, 'superposition.json');
|
|
1171
|
+
if (!fs.existsSync(manifestPath)) {
|
|
1172
|
+
return {
|
|
1173
|
+
findingId: 'devcontainer-config',
|
|
1174
|
+
remediationKey: 'devcontainer-regeneration',
|
|
1175
|
+
attempted: false,
|
|
1176
|
+
outcome: 'requires-manual-action',
|
|
1177
|
+
reason: 'No superposition.json found — run "container-superposition init" first',
|
|
1178
|
+
rechecked: false,
|
|
1179
|
+
};
|
|
1180
|
+
}
|
|
1181
|
+
let manifest;
|
|
1182
|
+
try {
|
|
1183
|
+
manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
1184
|
+
}
|
|
1185
|
+
catch (err) {
|
|
1186
|
+
return {
|
|
1187
|
+
findingId: 'devcontainer-config',
|
|
1188
|
+
remediationKey: 'devcontainer-regeneration',
|
|
1189
|
+
attempted: false,
|
|
1190
|
+
outcome: 'requires-manual-action',
|
|
1191
|
+
reason: `Cannot parse superposition.json: ${err instanceof Error ? err.message : String(err)}`,
|
|
1192
|
+
rechecked: false,
|
|
1193
|
+
};
|
|
1194
|
+
}
|
|
1195
|
+
if (!manifest.baseTemplate) {
|
|
1196
|
+
return {
|
|
1197
|
+
findingId: 'devcontainer-config',
|
|
1198
|
+
remediationKey: 'devcontainer-regeneration',
|
|
1199
|
+
attempted: false,
|
|
1200
|
+
outcome: 'requires-manual-action',
|
|
1201
|
+
reason: 'Manifest is missing required baseTemplate field — cannot regenerate',
|
|
1202
|
+
rechecked: false,
|
|
1203
|
+
};
|
|
1204
|
+
}
|
|
1205
|
+
const answers = buildAnswersFromManifest(manifest, outputPath, overlaysConfig);
|
|
1206
|
+
// Suppress console output during regeneration when in JSON mode
|
|
1207
|
+
const originalLog = console.log;
|
|
1208
|
+
if (silent) {
|
|
1209
|
+
console.log = () => { };
|
|
1210
|
+
}
|
|
1211
|
+
try {
|
|
1212
|
+
await composeDevContainer(answers, overlaysDir, { isRegen: true });
|
|
1213
|
+
}
|
|
1214
|
+
catch (err) {
|
|
1215
|
+
if (silent) {
|
|
1216
|
+
console.log = originalLog;
|
|
1217
|
+
}
|
|
1218
|
+
return {
|
|
1219
|
+
findingId: 'devcontainer-config',
|
|
1220
|
+
remediationKey: 'devcontainer-regeneration',
|
|
1221
|
+
attempted: true,
|
|
1222
|
+
outcome: 'requires-manual-action',
|
|
1223
|
+
reason: `Regeneration failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
1224
|
+
rechecked: false,
|
|
1225
|
+
};
|
|
1226
|
+
}
|
|
1227
|
+
finally {
|
|
1228
|
+
if (silent) {
|
|
1229
|
+
console.log = originalLog;
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
// Re-check
|
|
1233
|
+
const devcontainerPath = path.join(outputPath, 'devcontainer.json');
|
|
1234
|
+
const exists = fs.existsSync(devcontainerPath);
|
|
1235
|
+
let validJson = false;
|
|
1236
|
+
if (exists) {
|
|
1237
|
+
try {
|
|
1238
|
+
JSON.parse(fs.readFileSync(devcontainerPath, 'utf8'));
|
|
1239
|
+
validJson = true;
|
|
1240
|
+
}
|
|
1241
|
+
catch {
|
|
1242
|
+
// invalid JSON
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
return {
|
|
1246
|
+
findingId: 'devcontainer-config',
|
|
1247
|
+
remediationKey: 'devcontainer-regeneration',
|
|
1248
|
+
attempted: true,
|
|
1249
|
+
outcome: exists && validJson ? 'fixed' : 'requires-manual-action',
|
|
1250
|
+
reason: exists && validJson
|
|
1251
|
+
? 'devcontainer.json regenerated from superposition.json'
|
|
1252
|
+
: 'Regeneration ran but devcontainer.json is still missing or invalid',
|
|
1253
|
+
changedFiles: exists ? [devcontainerPath] : [],
|
|
1254
|
+
rechecked: true,
|
|
1255
|
+
};
|
|
1256
|
+
}
|
|
1257
|
+
/**
|
|
1258
|
+
* Execute Node.js version fix (Class 3).
|
|
1259
|
+
*/
|
|
1260
|
+
function executeNodeVersionFix() {
|
|
1261
|
+
const manager = detectVersionManager();
|
|
1262
|
+
if (!manager) {
|
|
1263
|
+
return {
|
|
1264
|
+
findingId: 'nodejs-version',
|
|
1265
|
+
remediationKey: 'node-version-fix',
|
|
1266
|
+
attempted: false,
|
|
1267
|
+
outcome: 'requires-manual-action',
|
|
1268
|
+
reason: 'No version manager (nvm, fnm, or volta) found',
|
|
1269
|
+
rechecked: false,
|
|
1270
|
+
};
|
|
1271
|
+
}
|
|
1272
|
+
let fixCmd;
|
|
1273
|
+
switch (manager) {
|
|
1274
|
+
case 'nvm': {
|
|
1275
|
+
const nvmScript = path.join(process.env.HOME ?? process.env.USERPROFILE ?? '', '.nvm', 'nvm.sh');
|
|
1276
|
+
fixCmd = `source "${nvmScript}" && nvm install 20 && nvm use 20`;
|
|
1277
|
+
break;
|
|
1278
|
+
}
|
|
1279
|
+
case 'fnm':
|
|
1280
|
+
fixCmd = 'fnm install 20 && fnm use 20';
|
|
1281
|
+
break;
|
|
1282
|
+
case 'volta':
|
|
1283
|
+
fixCmd = 'volta install node@20';
|
|
1284
|
+
break;
|
|
1285
|
+
}
|
|
1286
|
+
// nvm and fnm only update the child shell's PATH — `doctor` won't see the change.
|
|
1287
|
+
// Treat these as "installed; open a new shell" rather than attempting a re-check
|
|
1288
|
+
// that will always fail in the current process.
|
|
1289
|
+
// volta persists via its shim mechanism and can be verified immediately.
|
|
1290
|
+
if (manager === 'nvm' || manager === 'fnm') {
|
|
1291
|
+
const runCmd = manager === 'nvm' ? `bash -lc '${fixCmd}'` : `sh -lc '${fixCmd}'`;
|
|
1292
|
+
try {
|
|
1293
|
+
execSync(runCmd, { stdio: 'pipe', timeout: 60_000 });
|
|
1294
|
+
}
|
|
1295
|
+
catch (err) {
|
|
1296
|
+
return {
|
|
1297
|
+
findingId: 'nodejs-version',
|
|
1298
|
+
remediationKey: 'node-version-fix',
|
|
1299
|
+
attempted: true,
|
|
1300
|
+
outcome: 'requires-manual-action',
|
|
1301
|
+
reason: `Fix command failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
1302
|
+
commands: [fixCmd],
|
|
1303
|
+
rechecked: false,
|
|
1304
|
+
};
|
|
1305
|
+
}
|
|
1306
|
+
return {
|
|
1307
|
+
findingId: 'nodejs-version',
|
|
1308
|
+
remediationKey: 'node-version-fix',
|
|
1309
|
+
attempted: true,
|
|
1310
|
+
outcome: 'requires-manual-action',
|
|
1311
|
+
reason: `Node.js 20 installed via ${manager}. Open a new shell (or run \`${fixCmd}\`) to activate it — the current process cannot pick up the PATH change.`,
|
|
1312
|
+
commands: [fixCmd],
|
|
1313
|
+
rechecked: false,
|
|
1314
|
+
};
|
|
1315
|
+
}
|
|
1316
|
+
// volta: shim persists across processes — attempt + re-check is reliable.
|
|
1317
|
+
try {
|
|
1318
|
+
execSync(`sh -lc '${fixCmd}'`, { stdio: 'pipe', timeout: 60_000 });
|
|
1319
|
+
}
|
|
1320
|
+
catch (err) {
|
|
1321
|
+
return {
|
|
1322
|
+
findingId: 'nodejs-version',
|
|
1323
|
+
remediationKey: 'node-version-fix',
|
|
1324
|
+
attempted: true,
|
|
1325
|
+
outcome: 'requires-manual-action',
|
|
1326
|
+
reason: `Fix command failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
1327
|
+
commands: [fixCmd],
|
|
1328
|
+
rechecked: false,
|
|
1329
|
+
};
|
|
1330
|
+
}
|
|
1331
|
+
// Re-check (volta updates shim; new processes see the updated Node)
|
|
1332
|
+
try {
|
|
1333
|
+
const version = execSync('sh -lc "node --version"', {
|
|
1334
|
+
encoding: 'utf8',
|
|
1335
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
1336
|
+
timeout: 10_000,
|
|
1337
|
+
});
|
|
1338
|
+
const match = version.trim().match(/^v(\d+)/);
|
|
1339
|
+
const major = match ? parseInt(match[1], 10) : 0;
|
|
1340
|
+
if (major >= 20) {
|
|
1341
|
+
return {
|
|
1342
|
+
findingId: 'nodejs-version',
|
|
1343
|
+
remediationKey: 'node-version-fix',
|
|
1344
|
+
attempted: true,
|
|
1345
|
+
outcome: 'fixed',
|
|
1346
|
+
reason: `Node.js ${version.trim()} activated via volta`,
|
|
1347
|
+
commands: [fixCmd],
|
|
1348
|
+
rechecked: true,
|
|
1349
|
+
};
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
catch {
|
|
1353
|
+
// fall through
|
|
1354
|
+
}
|
|
1355
|
+
return {
|
|
1356
|
+
findingId: 'nodejs-version',
|
|
1357
|
+
remediationKey: 'node-version-fix',
|
|
1358
|
+
attempted: true,
|
|
1359
|
+
outcome: 'requires-manual-action',
|
|
1360
|
+
reason: `volta ran but node --version still reports < 20. Open a new shell and run: ${fixCmd}`,
|
|
1361
|
+
commands: [fixCmd],
|
|
1362
|
+
rechecked: true,
|
|
1363
|
+
};
|
|
1364
|
+
}
|
|
1365
|
+
/**
|
|
1366
|
+
* Execute a single remediation action and return its execution record.
|
|
1367
|
+
*/
|
|
1368
|
+
async function executeSingleFix(finding, outputPath, overlaysConfig, overlaysDir, silent = false, explicitManifestPath) {
|
|
1369
|
+
switch (finding.remediationKey) {
|
|
1370
|
+
case 'manifest-migration':
|
|
1371
|
+
return executeManifestMigration(outputPath, explicitManifestPath);
|
|
1372
|
+
case 'devcontainer-regeneration':
|
|
1373
|
+
return executeRegeneration(outputPath, overlaysConfig, overlaysDir, silent, explicitManifestPath);
|
|
1374
|
+
case 'node-version-fix':
|
|
1375
|
+
return executeNodeVersionFix();
|
|
1376
|
+
case 'docker-repair': {
|
|
1377
|
+
return {
|
|
1378
|
+
findingId: finding.id,
|
|
1379
|
+
remediationKey: 'docker-repair',
|
|
1380
|
+
attempted: false,
|
|
1381
|
+
outcome: 'requires-manual-action',
|
|
1382
|
+
reason: 'Docker daemon repair requires manual intervention',
|
|
1383
|
+
rechecked: false,
|
|
1384
|
+
};
|
|
1385
|
+
}
|
|
1386
|
+
default:
|
|
1387
|
+
return {
|
|
1388
|
+
findingId: finding.id,
|
|
1389
|
+
remediationKey: finding.remediationKey ?? 'unknown',
|
|
1390
|
+
attempted: false,
|
|
1391
|
+
outcome: 'requires-manual-action',
|
|
1392
|
+
reason: `No remediation handler registered for key "${finding.remediationKey}"`,
|
|
1393
|
+
rechecked: false,
|
|
1394
|
+
};
|
|
1395
|
+
}
|
|
1396
|
+
}
|
|
1397
|
+
/**
|
|
1398
|
+
* Build the FixOutcomeSummary from a list of executions.
|
|
1399
|
+
*/
|
|
1400
|
+
function buildOutcomeSummary(executions) {
|
|
1401
|
+
const counts = {
|
|
1402
|
+
fixed: 0,
|
|
1403
|
+
alreadyCompliant: 0,
|
|
1404
|
+
skipped: 0,
|
|
1405
|
+
requiresManualAction: 0,
|
|
1406
|
+
};
|
|
1407
|
+
for (const ex of executions) {
|
|
1408
|
+
switch (ex.outcome) {
|
|
1409
|
+
case 'fixed':
|
|
1410
|
+
counts.fixed++;
|
|
1411
|
+
break;
|
|
1412
|
+
case 'already-compliant':
|
|
1413
|
+
counts.alreadyCompliant++;
|
|
1414
|
+
break;
|
|
1415
|
+
case 'skipped':
|
|
1416
|
+
counts.skipped++;
|
|
1417
|
+
break;
|
|
1418
|
+
case 'requires-manual-action':
|
|
1419
|
+
counts.requiresManualAction++;
|
|
1420
|
+
break;
|
|
1421
|
+
}
|
|
1422
|
+
}
|
|
1423
|
+
return { ...counts, total: executions.length };
|
|
1424
|
+
}
|
|
1425
|
+
/**
|
|
1426
|
+
* Determine the exit disposition from summary and final findings.
|
|
1427
|
+
*/
|
|
1428
|
+
function determineExitDisposition(summary, finalFindings) {
|
|
1429
|
+
// Any failing finding (regardless of fix eligibility) is an unresolved failure.
|
|
1430
|
+
const unresolvedFailures = finalFindings.filter((f) => f.status === 'fail');
|
|
1431
|
+
if (unresolvedFailures.length > 0) {
|
|
1432
|
+
return 'unresolved-failures';
|
|
1433
|
+
}
|
|
1434
|
+
if (summary.requiresManualAction > 0 || summary.skipped > 0) {
|
|
1435
|
+
return 'repaired-with-warnings';
|
|
1436
|
+
}
|
|
1437
|
+
return 'success';
|
|
1438
|
+
}
|
|
1439
|
+
/**
|
|
1440
|
+
* Run the full fix flow: diagnose → narrate → remediate → re-check → summarise.
|
|
1441
|
+
*/
|
|
1442
|
+
async function executeFixRun(report, outputPath, overlaysConfig, overlaysDir, requestedJson, explicitManifestPath) {
|
|
1443
|
+
const initialFindings = reportToFindings(report);
|
|
1444
|
+
// Separate automatic and manual-only fixable findings
|
|
1445
|
+
const autoFixable = initialFindings.filter((f) => f.fixEligibility === 'automatic' && f.status !== 'pass');
|
|
1446
|
+
const manualOnly = initialFindings.filter((f) => f.fixEligibility === 'manual-only' && f.status !== 'pass');
|
|
1447
|
+
// Order automatic fixes: prerequisites before dependents
|
|
1448
|
+
const orderedAuto = orderFindingsForRemediation(autoFixable);
|
|
1449
|
+
const executions = [];
|
|
1450
|
+
let manifestMigrationFailed = false;
|
|
1451
|
+
for (const finding of orderedAuto) {
|
|
1452
|
+
// Dependency ordering: skip regeneration if manifest migration failed
|
|
1453
|
+
if (finding.remediationKey === 'devcontainer-regeneration' && manifestMigrationFailed) {
|
|
1454
|
+
executions.push({
|
|
1455
|
+
findingId: finding.id,
|
|
1456
|
+
remediationKey: 'devcontainer-regeneration',
|
|
1457
|
+
attempted: false,
|
|
1458
|
+
outcome: 'skipped',
|
|
1459
|
+
reason: 'Skipped because manifest migration did not succeed',
|
|
1460
|
+
rechecked: false,
|
|
1461
|
+
});
|
|
1462
|
+
continue;
|
|
1463
|
+
}
|
|
1464
|
+
// Narrate planned change (text mode)
|
|
1465
|
+
if (!requestedJson) {
|
|
1466
|
+
const action = REMEDIATION_REGISTRY.get(finding.remediationKey ?? '');
|
|
1467
|
+
console.log(`\n ${chalk.cyan('→')} Planning fix for: ${chalk.white(finding.name)}`);
|
|
1468
|
+
if (action) {
|
|
1469
|
+
for (const change of action.plannedChanges) {
|
|
1470
|
+
console.log(` ${chalk.dim('·')} ${chalk.dim(change)}`);
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
const execution = await executeSingleFix(finding, outputPath, overlaysConfig, overlaysDir, requestedJson, explicitManifestPath);
|
|
1475
|
+
executions.push(execution);
|
|
1476
|
+
if (finding.remediationKey === 'manifest-migration' &&
|
|
1477
|
+
execution.outcome !== 'fixed' &&
|
|
1478
|
+
execution.outcome !== 'already-compliant') {
|
|
1479
|
+
manifestMigrationFailed = true;
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1482
|
+
// Add manual-only findings as requires-manual-action
|
|
1483
|
+
for (const finding of manualOnly) {
|
|
1484
|
+
const action = REMEDIATION_REGISTRY.get(finding.remediationKey ?? '');
|
|
1485
|
+
executions.push({
|
|
1486
|
+
findingId: finding.id,
|
|
1487
|
+
remediationKey: finding.remediationKey ?? 'manual',
|
|
1488
|
+
attempted: false,
|
|
1489
|
+
outcome: 'requires-manual-action',
|
|
1490
|
+
reason: action
|
|
1491
|
+
? action.manualFallback.join(' | ')
|
|
1492
|
+
: 'No automatic fix available for this issue',
|
|
1493
|
+
rechecked: false,
|
|
1494
|
+
});
|
|
1495
|
+
}
|
|
1496
|
+
// Re-run checks to get final state
|
|
1497
|
+
const envChecks = checkEnvironment(outputPath, explicitManifestPath);
|
|
1498
|
+
const manifestChecks = checkManifest(outputPath, explicitManifestPath);
|
|
1499
|
+
const mergeChecks = checkMergeStrategy(outputPath);
|
|
1500
|
+
const overlayChecks = checkOverlays(overlaysDir);
|
|
1501
|
+
const finalManifestPath = explicitManifestPath ?? path.join(outputPath, 'superposition.json');
|
|
1502
|
+
const portChecks = checkPorts(overlaysConfig, finalManifestPath);
|
|
1503
|
+
const finalFindings = [
|
|
1504
|
+
...checksToFindings(envChecks, 'environment', 'environment'),
|
|
1505
|
+
...checksToFindings(manifestChecks, 'manifest', 'manifest'),
|
|
1506
|
+
...checksToFindings(mergeChecks, 'merge', 'devcontainer'),
|
|
1507
|
+
...checksToFindings(overlayChecks, 'overlay', 'full'),
|
|
1508
|
+
...checksToFindings(portChecks, 'ports', 'environment'),
|
|
1509
|
+
];
|
|
1510
|
+
const summary = buildOutcomeSummary(executions);
|
|
1511
|
+
const exitDisposition = determineExitDisposition(summary, finalFindings);
|
|
1512
|
+
return {
|
|
1513
|
+
outputPath,
|
|
1514
|
+
requestedJson,
|
|
1515
|
+
initialFindings,
|
|
1516
|
+
executions,
|
|
1517
|
+
finalFindings,
|
|
1518
|
+
summary,
|
|
1519
|
+
exitDisposition,
|
|
1520
|
+
};
|
|
1521
|
+
}
|
|
1522
|
+
/**
|
|
1523
|
+
* Format the fix run result as user-readable text.
|
|
1524
|
+
*/
|
|
1525
|
+
function formatFixRunText(fixRun) {
|
|
1526
|
+
const lines = [];
|
|
1527
|
+
const hasIssues = fixRun.finalFindings.some((f) => f.status === 'warn' || f.status === 'fail');
|
|
1528
|
+
if (fixRun.executions.length === 0 && !hasIssues) {
|
|
1529
|
+
lines.push(chalk.green('\n✓ No remediation needed — all checked items are already compliant.'));
|
|
1530
|
+
return lines.join('\n');
|
|
1531
|
+
}
|
|
1532
|
+
if (fixRun.executions.length === 0 && hasIssues) {
|
|
1533
|
+
lines.push(chalk.yellow('\n⚠ No automatic remediation available. Review the findings above for manual action.'));
|
|
1534
|
+
return lines.join('\n');
|
|
1535
|
+
}
|
|
1536
|
+
lines.push(chalk.bold('\nRemediation Summary:'));
|
|
1537
|
+
for (const ex of fixRun.executions) {
|
|
1538
|
+
const finding = fixRun.initialFindings.find((f) => f.id === ex.findingId);
|
|
1539
|
+
const name = finding?.name ?? ex.findingId;
|
|
1540
|
+
let icon;
|
|
1541
|
+
let outcomeLabel;
|
|
1542
|
+
switch (ex.outcome) {
|
|
1543
|
+
case 'fixed':
|
|
1544
|
+
icon = chalk.green('✓');
|
|
1545
|
+
outcomeLabel = chalk.green('fixed');
|
|
1546
|
+
break;
|
|
1547
|
+
case 'already-compliant':
|
|
1548
|
+
icon = chalk.green('✓');
|
|
1549
|
+
outcomeLabel = chalk.green('already compliant');
|
|
1550
|
+
break;
|
|
1551
|
+
case 'skipped':
|
|
1552
|
+
icon = chalk.yellow('→');
|
|
1553
|
+
outcomeLabel = chalk.yellow('skipped');
|
|
1554
|
+
break;
|
|
1555
|
+
default:
|
|
1556
|
+
icon = chalk.red('✗');
|
|
1557
|
+
outcomeLabel = chalk.red('requires manual action');
|
|
1558
|
+
}
|
|
1559
|
+
lines.push(` ${icon} ${chalk.white(name)}: ${outcomeLabel}`);
|
|
1560
|
+
lines.push(` ${chalk.dim('Reason:')} ${chalk.dim(ex.reason)}`);
|
|
1561
|
+
if (ex.changedFiles && ex.changedFiles.length > 0) {
|
|
1562
|
+
lines.push(` ${chalk.dim('Changed:')} ${chalk.dim(ex.changedFiles.join(', '))}`);
|
|
1563
|
+
}
|
|
1564
|
+
if (ex.backupPath) {
|
|
1565
|
+
lines.push(` ${chalk.dim('Backup:')} ${chalk.dim(ex.backupPath)}`);
|
|
1566
|
+
}
|
|
1567
|
+
// Show manual fallback for requires-manual-action
|
|
1568
|
+
if (ex.outcome === 'requires-manual-action') {
|
|
1569
|
+
const action = REMEDIATION_REGISTRY.get(ex.remediationKey);
|
|
1570
|
+
if (action && action.manualFallback.length > 0) {
|
|
1571
|
+
lines.push(` ${chalk.dim('Manual steps:')}`);
|
|
1572
|
+
for (const step of action.manualFallback) {
|
|
1573
|
+
lines.push(` ${chalk.dim('·')} ${chalk.dim(step)}`);
|
|
1574
|
+
}
|
|
1575
|
+
}
|
|
1576
|
+
}
|
|
1577
|
+
}
|
|
1578
|
+
// Overall disposition
|
|
1579
|
+
lines.push('');
|
|
1580
|
+
const { summary, exitDisposition } = fixRun;
|
|
1581
|
+
lines.push(chalk.bold('Fix Run Result:'));
|
|
1582
|
+
if (summary.fixed > 0) {
|
|
1583
|
+
lines.push(` ${chalk.green('✓')} ${summary.fixed} fixed`);
|
|
1584
|
+
}
|
|
1585
|
+
if (summary.alreadyCompliant > 0) {
|
|
1586
|
+
lines.push(` ${chalk.green('✓')} ${summary.alreadyCompliant} already compliant`);
|
|
1587
|
+
}
|
|
1588
|
+
if (summary.skipped > 0) {
|
|
1589
|
+
lines.push(` ${chalk.yellow('→')} ${summary.skipped} skipped`);
|
|
1590
|
+
}
|
|
1591
|
+
if (summary.requiresManualAction > 0) {
|
|
1592
|
+
lines.push(` ${chalk.red('✗')} ${summary.requiresManualAction} require manual action`);
|
|
1593
|
+
}
|
|
1594
|
+
const dispositionColour = exitDisposition === 'success'
|
|
1595
|
+
? chalk.green
|
|
1596
|
+
: exitDisposition === 'repaired-with-warnings'
|
|
1597
|
+
? chalk.yellow
|
|
1598
|
+
: chalk.red;
|
|
1599
|
+
lines.push(`\n ${dispositionColour('Exit status:')} ${dispositionColour(exitDisposition)}`);
|
|
1600
|
+
return lines.join('\n');
|
|
811
1601
|
}
|
|
812
1602
|
/**
|
|
813
1603
|
* Doctor command implementation
|
|
814
1604
|
*/
|
|
815
1605
|
export async function doctorCommand(overlaysConfig, overlaysDir, options) {
|
|
816
|
-
|
|
1606
|
+
// ── Validate mutually exclusive source flags ───────────────────────────
|
|
1607
|
+
if (options.fromManifest && options.fromProject) {
|
|
1608
|
+
console.error(chalk.red('✗ Error: --from-manifest and --from-project cannot be used together'));
|
|
1609
|
+
process.exit(1);
|
|
1610
|
+
}
|
|
1611
|
+
// ── Resolve working directory (--project-root) ────────────────────────
|
|
1612
|
+
const workingDir = options.projectRoot ? path.resolve(options.projectRoot) : process.cwd();
|
|
1613
|
+
if (options.projectRoot) {
|
|
1614
|
+
if (!fs.existsSync(workingDir)) {
|
|
1615
|
+
console.error(chalk.red(`✗ Project root not found: ${workingDir}`));
|
|
1616
|
+
process.exit(1);
|
|
1617
|
+
}
|
|
1618
|
+
if (!fs.statSync(workingDir).isDirectory()) {
|
|
1619
|
+
console.error(chalk.red(`✗ Project root is not a directory: ${workingDir}`));
|
|
1620
|
+
process.exit(1);
|
|
1621
|
+
}
|
|
1622
|
+
}
|
|
1623
|
+
// ── Resolve outputPath and optional explicit manifest path ─────────────
|
|
1624
|
+
let outputPath;
|
|
1625
|
+
let explicitManifestPath;
|
|
1626
|
+
if (options.fromManifest) {
|
|
1627
|
+
// Resolve manifest path (absolute or relative to workingDir)
|
|
1628
|
+
const resolvedManifest = path.resolve(workingDir, options.fromManifest);
|
|
1629
|
+
if (!fs.existsSync(resolvedManifest)) {
|
|
1630
|
+
console.error(chalk.red(`✗ Could not find manifest file: ${resolvedManifest}`));
|
|
1631
|
+
process.exit(1);
|
|
1632
|
+
}
|
|
1633
|
+
explicitManifestPath = resolvedManifest;
|
|
1634
|
+
// Derive outputPath from manifest's own outputPath field, relative to manifest's directory
|
|
1635
|
+
try {
|
|
1636
|
+
const raw = JSON.parse(fs.readFileSync(resolvedManifest, 'utf8'));
|
|
1637
|
+
const manifestOutputPath = typeof raw.outputPath === 'string' ? raw.outputPath : '.devcontainer';
|
|
1638
|
+
outputPath = path.resolve(path.dirname(resolvedManifest), manifestOutputPath);
|
|
1639
|
+
}
|
|
1640
|
+
catch {
|
|
1641
|
+
// If the manifest is unparseable, use its directory as outputPath
|
|
1642
|
+
outputPath = path.dirname(resolvedManifest);
|
|
1643
|
+
}
|
|
1644
|
+
}
|
|
1645
|
+
else if (options.fromProject) {
|
|
1646
|
+
// Load the repository project file (superposition.yml / .superposition.yml)
|
|
1647
|
+
let projectConfig;
|
|
1648
|
+
try {
|
|
1649
|
+
projectConfig = loadProjectConfig(overlaysConfig, workingDir);
|
|
1650
|
+
}
|
|
1651
|
+
catch (err) {
|
|
1652
|
+
console.error(chalk.red(`✗ Failed to load project config: ${err instanceof Error ? err.message : String(err)}`));
|
|
1653
|
+
process.exit(1);
|
|
1654
|
+
}
|
|
1655
|
+
if (!projectConfig) {
|
|
1656
|
+
console.error(chalk.red('✗ Could not find project file'));
|
|
1657
|
+
console.error(chalk.gray(' Searched for: .superposition.yml, superposition.yml'));
|
|
1658
|
+
console.error(chalk.gray(' Use --from-project in a repository that has a project config file, or use --from-manifest <path> instead'));
|
|
1659
|
+
process.exit(1);
|
|
1660
|
+
}
|
|
1661
|
+
outputPath = path.resolve(workingDir, projectConfig.selection.outputPath || '.devcontainer');
|
|
1662
|
+
}
|
|
1663
|
+
else {
|
|
1664
|
+
outputPath = path.resolve(workingDir, options.output || './.devcontainer');
|
|
1665
|
+
}
|
|
817
1666
|
if (!options.json) {
|
|
818
1667
|
console.log('\n' +
|
|
819
1668
|
boxen(chalk.bold('🔍 Running diagnostics...'), {
|
|
@@ -823,40 +1672,54 @@ export async function doctorCommand(overlaysConfig, overlaysDir, options) {
|
|
|
823
1672
|
}));
|
|
824
1673
|
}
|
|
825
1674
|
// Run all checks
|
|
826
|
-
const environmentChecks = checkEnvironment(outputPath);
|
|
1675
|
+
const environmentChecks = checkEnvironment(outputPath, explicitManifestPath);
|
|
827
1676
|
const overlayChecks = checkOverlays(overlaysDir);
|
|
828
|
-
const manifestChecks = checkManifest(outputPath);
|
|
1677
|
+
const manifestChecks = checkManifest(outputPath, explicitManifestPath);
|
|
829
1678
|
const mergeChecks = checkMergeStrategy(outputPath);
|
|
830
|
-
const manifestPath = path.join(outputPath, 'superposition.json');
|
|
1679
|
+
const manifestPath = explicitManifestPath ?? path.join(outputPath, 'superposition.json');
|
|
831
1680
|
const portChecks = checkPorts(overlaysConfig, manifestPath);
|
|
832
1681
|
// Generate report
|
|
833
1682
|
const report = generateReport(environmentChecks, overlayChecks, manifestChecks, mergeChecks, portChecks);
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
}
|
|
855
|
-
else if (hasWarnings && !options.json) {
|
|
856
|
-
process.exit(0); // Warnings don't fail the command
|
|
1683
|
+
if (options.fix) {
|
|
1684
|
+
// ── Fix flow ──────────────────────────────────────────────────────────
|
|
1685
|
+
if (!options.json) {
|
|
1686
|
+
// Print diagnostic findings first (as normal)
|
|
1687
|
+
console.log(formatAsText(report));
|
|
1688
|
+
}
|
|
1689
|
+
const fixRun = await executeFixRun(report, outputPath, overlaysConfig, overlaysDir, options.json ?? false, explicitManifestPath);
|
|
1690
|
+
if (options.json) {
|
|
1691
|
+
console.log(JSON.stringify(fixRun, null, 2));
|
|
1692
|
+
}
|
|
1693
|
+
else {
|
|
1694
|
+
console.log(formatFixRunText(fixRun));
|
|
1695
|
+
console.log('');
|
|
1696
|
+
}
|
|
1697
|
+
if (fixRun.exitDisposition === 'unresolved-failures') {
|
|
1698
|
+
process.exit(1);
|
|
1699
|
+
}
|
|
1700
|
+
else {
|
|
1701
|
+
process.exit(0);
|
|
1702
|
+
}
|
|
857
1703
|
}
|
|
858
1704
|
else {
|
|
859
|
-
|
|
1705
|
+
// ── Normal diagnostic output (unchanged) ─────────────────────────────
|
|
1706
|
+
if (options.json) {
|
|
1707
|
+
console.log(JSON.stringify(report, null, 2));
|
|
1708
|
+
}
|
|
1709
|
+
else {
|
|
1710
|
+
console.log(formatAsText(report));
|
|
1711
|
+
}
|
|
1712
|
+
// Exit with appropriate code
|
|
1713
|
+
const hasErrors = report.summary.errors > 0;
|
|
1714
|
+
if (!options.json) {
|
|
1715
|
+
console.log(''); // Empty line at end
|
|
1716
|
+
}
|
|
1717
|
+
if (hasErrors) {
|
|
1718
|
+
process.exit(1);
|
|
1719
|
+
}
|
|
1720
|
+
else {
|
|
1721
|
+
process.exit(0);
|
|
1722
|
+
}
|
|
860
1723
|
}
|
|
861
1724
|
}
|
|
862
1725
|
//# sourceMappingURL=doctor.js.map
|