container-superposition 0.1.6 → 0.1.8
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 +24 -15
- package/dist/scripts/init.js +1 -1534
- package/dist/scripts/init.js.map +1 -1
- package/dist/tool/cli/args.d.ts +20 -0
- package/dist/tool/cli/args.d.ts.map +1 -0
- package/dist/tool/cli/args.js +325 -0
- package/dist/tool/cli/args.js.map +1 -0
- package/dist/tool/cli/run.d.ts +2 -0
- package/dist/tool/cli/run.d.ts.map +1 -0
- package/dist/tool/cli/run.js +318 -0
- package/dist/tool/cli/run.js.map +1 -0
- 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 +1068 -70
- 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 +18 -0
- package/dist/tool/commands/explain.js.map +1 -1
- package/dist/tool/commands/migrate.d.ts +7 -0
- package/dist/tool/commands/migrate.d.ts.map +1 -0
- package/dist/tool/commands/migrate.js +52 -0
- package/dist/tool/commands/migrate.js.map +1 -0
- package/dist/tool/questionnaire/answers.d.ts +16 -0
- package/dist/tool/questionnaire/answers.d.ts.map +1 -0
- package/dist/tool/questionnaire/answers.js +102 -0
- package/dist/tool/questionnaire/answers.js.map +1 -0
- package/dist/tool/questionnaire/composer.d.ts +3 -3
- package/dist/tool/questionnaire/composer.d.ts.map +1 -1
- package/dist/tool/questionnaire/composer.js +902 -37
- package/dist/tool/questionnaire/composer.js.map +1 -1
- package/dist/tool/questionnaire/presets.d.ts +60 -0
- package/dist/tool/questionnaire/presets.d.ts.map +1 -0
- package/dist/tool/questionnaire/presets.js +164 -0
- package/dist/tool/questionnaire/presets.js.map +1 -0
- package/dist/tool/questionnaire/questionnaire.d.ts +10 -0
- package/dist/tool/questionnaire/questionnaire.d.ts.map +1 -0
- package/dist/tool/questionnaire/questionnaire.js +580 -0
- package/dist/tool/questionnaire/questionnaire.js.map +1 -0
- package/dist/tool/schema/manifest-migrations.d.ts +5 -0
- package/dist/tool/schema/manifest-migrations.d.ts.map +1 -1
- package/dist/tool/schema/manifest-migrations.js +45 -0
- package/dist/tool/schema/manifest-migrations.js.map +1 -1
- package/dist/tool/schema/overlay-loader.d.ts.map +1 -1
- package/dist/tool/schema/overlay-loader.js +25 -0
- package/dist/tool/schema/overlay-loader.js.map +1 -1
- package/dist/tool/schema/project-config.d.ts +14 -2
- package/dist/tool/schema/project-config.d.ts.map +1 -1
- package/dist/tool/schema/project-config.js +277 -34
- package/dist/tool/schema/project-config.js.map +1 -1
- package/dist/tool/schema/target-rules.d.ts +78 -0
- package/dist/tool/schema/target-rules.d.ts.map +1 -0
- package/dist/tool/schema/target-rules.js +367 -0
- package/dist/tool/schema/target-rules.js.map +1 -0
- package/dist/tool/schema/types.d.ts +123 -12
- 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/dist/tool/utils/parameters.d.ts +76 -0
- package/dist/tool/utils/parameters.d.ts.map +1 -0
- package/dist/tool/utils/parameters.js +125 -0
- package/dist/tool/utils/parameters.js.map +1 -0
- package/dist/tool/utils/paths.d.ts +2 -0
- package/dist/tool/utils/paths.d.ts.map +1 -0
- package/dist/tool/utils/paths.js +31 -0
- package/dist/tool/utils/paths.js.map +1 -0
- package/docs/creating-overlays.md +151 -2
- package/docs/deployment-targets.md +88 -56
- package/docs/examples.md +20 -17
- package/docs/filesystem-contract.md +5 -0
- package/docs/minimal-and-editor.md +65 -5
- package/docs/overlay-imports.md +202 -101
- package/docs/overlays.md +162 -34
- 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/docs/specs/007-init-project-file/spec.md +66 -0
- package/docs/specs/007-target-aware-generation/spec.md +126 -0
- package/docs/specs/008-project-file-canonical/spec.md +83 -0
- package/docs/specs/009-project-env/spec.md +147 -0
- package/docs/specs/010-compose-env-materialization/spec.md +130 -0
- package/docs/specs/011-overlay-parameters/spec.md +235 -0
- package/overlays/.shared/README.md +105 -21
- package/overlays/.shared/compose/common-healthchecks.md +60 -0
- package/overlays/.shared/compose/nvidia-gpu-devcontainer.yml +22 -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/comfyui/.env.example +34 -0
- package/overlays/comfyui/README.md +342 -0
- package/overlays/comfyui/devcontainer.patch.json +15 -0
- package/overlays/comfyui/docker-compose.yml +39 -0
- package/overlays/comfyui/overlay.yml +20 -0
- package/overlays/comfyui/setup.sh +36 -0
- package/overlays/comfyui/verify.sh +103 -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/k3d/README.md +201 -0
- package/overlays/k3d/devcontainer.patch.json +9 -0
- package/overlays/k3d/overlay.yml +19 -0
- package/overlays/k3d/setup.sh +34 -0
- package/overlays/k3d/verify.sh +38 -0
- 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/ollama/.env.example +14 -0
- package/overlays/ollama/README.md +325 -0
- package/overlays/ollama/devcontainer.patch.json +14 -0
- package/overlays/ollama/docker-compose.yml +24 -0
- package/overlays/ollama/overlay.yml +22 -0
- package/overlays/ollama/setup.sh +106 -0
- package/overlays/ollama/verify.sh +99 -0
- package/overlays/open-webui/.env.example +5 -0
- package/overlays/open-webui/README.md +162 -0
- package/overlays/open-webui/devcontainer.patch.json +14 -0
- package/overlays/open-webui/docker-compose.yml +23 -0
- package/overlays/open-webui/overlay.yml +38 -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/pgvector/.env.example +6 -0
- package/overlays/pgvector/README.md +215 -0
- package/overlays/pgvector/devcontainer.patch.json +23 -0
- package/overlays/pgvector/docker-compose.yml +32 -0
- package/overlays/pgvector/overlay.yml +44 -0
- package/overlays/playwright/devcontainer.patch.json +3 -1
- package/overlays/playwright/setup.sh +37 -0
- package/overlays/postgres/.env.example +5 -5
- package/overlays/postgres/devcontainer.patch.json +4 -4
- package/overlays/postgres/docker-compose.yml +15 -5
- package/overlays/postgres/overlay.yml +19 -1
- 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/qdrant/.env.example +4 -0
- package/overlays/qdrant/README.md +216 -0
- package/overlays/qdrant/devcontainer.patch.json +20 -0
- package/overlays/qdrant/docker-compose.yml +25 -0
- package/overlays/qdrant/overlay.yml +40 -0
- 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/skaffold/README.md +256 -0
- package/overlays/skaffold/devcontainer.patch.json +9 -0
- package/overlays/skaffold/overlay.yml +20 -0
- package/overlays/skaffold/setup.sh +33 -0
- package/overlays/skaffold/verify.sh +24 -0
- 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 +4 -2
- package/templates/scripts/setup-utils.sh +228 -0
- package/tool/schema/config.schema.json +141 -9
- package/tool/schema/overlay-manifest.schema.json +38 -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,77 @@ 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 { mergeAnswers } from '../questionnaire/answers.js';
|
|
16
|
+
import { loadProjectConfig } from '../schema/project-config.js';
|
|
17
|
+
// ─── Remediation registry ─────────────────────────────────────────────────
|
|
18
|
+
const REMEDIATION_REGISTRY = new Map([
|
|
19
|
+
[
|
|
20
|
+
'manifest-migration',
|
|
21
|
+
{
|
|
22
|
+
key: 'manifest-migration',
|
|
23
|
+
findingId: 'manifest-version',
|
|
24
|
+
safetyClass: 'safe-unattended',
|
|
25
|
+
executionKind: 'manifest-migration',
|
|
26
|
+
preconditions: ['superposition.json must exist and be parseable'],
|
|
27
|
+
plannedChanges: [
|
|
28
|
+
'Migrate superposition.json to current schema version',
|
|
29
|
+
'Create timestamped backup of the original manifest',
|
|
30
|
+
],
|
|
31
|
+
manualFallback: [
|
|
32
|
+
'Run "container-superposition regen" to regenerate with the current schema',
|
|
33
|
+
],
|
|
34
|
+
},
|
|
35
|
+
],
|
|
36
|
+
[
|
|
37
|
+
'devcontainer-regeneration',
|
|
38
|
+
{
|
|
39
|
+
key: 'devcontainer-regeneration',
|
|
40
|
+
findingId: 'devcontainer-config',
|
|
41
|
+
safetyClass: 'safe-unattended',
|
|
42
|
+
executionKind: 'regeneration',
|
|
43
|
+
preconditions: ['Valid superposition.json manifest must be present'],
|
|
44
|
+
plannedChanges: ['Regenerate devcontainer.json from superposition.json'],
|
|
45
|
+
manualFallback: ['Run "container-superposition regen --output <path>" to regenerate'],
|
|
46
|
+
},
|
|
47
|
+
],
|
|
48
|
+
[
|
|
49
|
+
'node-version-fix',
|
|
50
|
+
{
|
|
51
|
+
key: 'node-version-fix',
|
|
52
|
+
findingId: 'nodejs-version',
|
|
53
|
+
safetyClass: 'safe-unattended',
|
|
54
|
+
executionKind: 'shell-command',
|
|
55
|
+
preconditions: ['nvm, fnm, or volta must be installed'],
|
|
56
|
+
plannedChanges: ['Use version manager to install and activate Node.js >= 20'],
|
|
57
|
+
manualFallback: [
|
|
58
|
+
'Install Node.js >= 20 from https://nodejs.org/',
|
|
59
|
+
'Or with nvm: nvm install 20 && nvm use 20',
|
|
60
|
+
'Or with fnm: fnm install 20 && fnm use 20',
|
|
61
|
+
'Or with volta: volta install node@20',
|
|
62
|
+
],
|
|
63
|
+
},
|
|
64
|
+
],
|
|
65
|
+
[
|
|
66
|
+
'docker-repair',
|
|
67
|
+
{
|
|
68
|
+
key: 'docker-repair',
|
|
69
|
+
findingId: 'docker-daemon',
|
|
70
|
+
safetyClass: 'requires-manual-action',
|
|
71
|
+
executionKind: 'no-op',
|
|
72
|
+
preconditions: [],
|
|
73
|
+
plannedChanges: [],
|
|
74
|
+
manualFallback: [
|
|
75
|
+
'Linux: sudo systemctl start docker',
|
|
76
|
+
'macOS: open -a Docker',
|
|
77
|
+
'Windows: Start Docker Desktop from the Start menu',
|
|
78
|
+
],
|
|
79
|
+
},
|
|
80
|
+
],
|
|
81
|
+
]);
|
|
14
82
|
/**
|
|
15
83
|
* Semantic version comparison helper
|
|
16
84
|
*/
|
|
@@ -41,36 +109,76 @@ function checkNodeVersion() {
|
|
|
41
109
|
const versionMatch = nodeVersion.match(/^v(\d+\.\d+\.\d+)/);
|
|
42
110
|
const currentVersion = versionMatch ? versionMatch[1] : '0.0.0';
|
|
43
111
|
const ok = isVersionAtLeast(currentVersion, requiredVersion);
|
|
112
|
+
if (ok) {
|
|
113
|
+
return {
|
|
114
|
+
name: 'Node.js version',
|
|
115
|
+
status: 'pass',
|
|
116
|
+
message: `${nodeVersion} (>= ${requiredVersion} required)`,
|
|
117
|
+
fixEligibility: 'not-applicable',
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
// Determine if a version manager is available for auto-fix
|
|
121
|
+
const hasVersionManager = detectVersionManager() !== null;
|
|
44
122
|
return {
|
|
45
123
|
name: 'Node.js version',
|
|
46
|
-
status:
|
|
47
|
-
message:
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
],
|
|
124
|
+
status: 'fail',
|
|
125
|
+
message: `${nodeVersion} - requires >= ${requiredVersion}`,
|
|
126
|
+
details: [
|
|
127
|
+
'Update Node.js to version 20 or later',
|
|
128
|
+
'Visit https://nodejs.org/ to download the latest version',
|
|
129
|
+
],
|
|
130
|
+
fixEligibility: hasVersionManager ? 'automatic' : 'manual-only',
|
|
131
|
+
remediationKey: hasVersionManager ? 'node-version-fix' : undefined,
|
|
132
|
+
fixable: hasVersionManager,
|
|
56
133
|
};
|
|
57
134
|
}
|
|
135
|
+
/**
|
|
136
|
+
* Detect which Node.js version manager is available.
|
|
137
|
+
* Returns the manager name or null if none found.
|
|
138
|
+
*/
|
|
139
|
+
function detectVersionManager() {
|
|
140
|
+
const home = process.env.HOME ?? process.env.USERPROFILE ?? '';
|
|
141
|
+
// nvm installs a shell function, not a binary — check the script file
|
|
142
|
+
const nvmScript = path.join(home, '.nvm', 'nvm.sh');
|
|
143
|
+
if (fs.existsSync(nvmScript)) {
|
|
144
|
+
return 'nvm';
|
|
145
|
+
}
|
|
146
|
+
for (const cmd of ['fnm', 'volta']) {
|
|
147
|
+
try {
|
|
148
|
+
execSync(`${cmd} --version`, {
|
|
149
|
+
stdio: 'ignore',
|
|
150
|
+
timeout: 3000,
|
|
151
|
+
});
|
|
152
|
+
return cmd;
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
// not available
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
58
160
|
/**
|
|
59
161
|
* Check if Docker daemon is accessible
|
|
60
162
|
*/
|
|
61
163
|
function checkDocker() {
|
|
62
164
|
try {
|
|
63
165
|
// Use 'docker info' to verify daemon connectivity, not just CLI presence
|
|
64
|
-
execSync('docker info', {
|
|
166
|
+
execSync('docker info', {
|
|
167
|
+
encoding: 'utf8',
|
|
168
|
+
stdio: ['pipe', 'pipe', 'ignore'],
|
|
169
|
+
timeout: 5000,
|
|
170
|
+
});
|
|
65
171
|
// Get version for display
|
|
66
172
|
const version = execSync('docker --version', {
|
|
67
173
|
encoding: 'utf8',
|
|
68
174
|
stdio: ['pipe', 'pipe', 'ignore'],
|
|
175
|
+
timeout: 5000,
|
|
69
176
|
});
|
|
70
177
|
return {
|
|
71
178
|
name: 'Docker daemon',
|
|
72
179
|
status: 'pass',
|
|
73
180
|
message: version.trim(),
|
|
181
|
+
fixEligibility: 'not-applicable',
|
|
74
182
|
};
|
|
75
183
|
}
|
|
76
184
|
catch {
|
|
@@ -83,6 +191,8 @@ function checkDocker() {
|
|
|
83
191
|
'Install Docker Desktop or Docker Engine',
|
|
84
192
|
'Ensure Docker daemon is running',
|
|
85
193
|
],
|
|
194
|
+
fixEligibility: 'manual-only',
|
|
195
|
+
remediationKey: 'docker-repair',
|
|
86
196
|
};
|
|
87
197
|
}
|
|
88
198
|
}
|
|
@@ -95,6 +205,7 @@ function checkDockerCompose() {
|
|
|
95
205
|
const version = execSync('docker compose version', {
|
|
96
206
|
encoding: 'utf8',
|
|
97
207
|
stdio: ['pipe', 'pipe', 'ignore'],
|
|
208
|
+
timeout: 5000,
|
|
98
209
|
});
|
|
99
210
|
const versionMatch = version.match(/v?(\d+\.\d+\.\d+)/);
|
|
100
211
|
const currentVersion = versionMatch ? versionMatch[1] : '0.0.0';
|
|
@@ -104,6 +215,7 @@ function checkDockerCompose() {
|
|
|
104
215
|
name: 'Docker Compose',
|
|
105
216
|
status: 'pass',
|
|
106
217
|
message: `v${currentVersion} (v2 required)`,
|
|
218
|
+
fixEligibility: 'not-applicable',
|
|
107
219
|
};
|
|
108
220
|
}
|
|
109
221
|
else {
|
|
@@ -115,6 +227,7 @@ function checkDockerCompose() {
|
|
|
115
227
|
'Docker Compose v2 is recommended for compose-based templates',
|
|
116
228
|
'Update Docker Desktop or install docker-compose-plugin',
|
|
117
229
|
],
|
|
230
|
+
fixEligibility: 'manual-only',
|
|
118
231
|
};
|
|
119
232
|
}
|
|
120
233
|
}
|
|
@@ -124,6 +237,7 @@ function checkDockerCompose() {
|
|
|
124
237
|
const version = execSync('docker-compose --version', {
|
|
125
238
|
encoding: 'utf8',
|
|
126
239
|
stdio: ['pipe', 'pipe', 'ignore'],
|
|
240
|
+
timeout: 5000,
|
|
127
241
|
});
|
|
128
242
|
return {
|
|
129
243
|
name: 'Docker Compose',
|
|
@@ -133,6 +247,7 @@ function checkDockerCompose() {
|
|
|
133
247
|
'Docker Compose v1 detected',
|
|
134
248
|
'Consider upgrading to v2: docker compose (not docker-compose)',
|
|
135
249
|
],
|
|
250
|
+
fixEligibility: 'manual-only',
|
|
136
251
|
};
|
|
137
252
|
}
|
|
138
253
|
catch {
|
|
@@ -145,6 +260,7 @@ function checkDockerCompose() {
|
|
|
145
260
|
'Install Docker Desktop (includes Compose v2)',
|
|
146
261
|
'Or install docker-compose-plugin',
|
|
147
262
|
],
|
|
263
|
+
fixEligibility: 'manual-only',
|
|
148
264
|
};
|
|
149
265
|
}
|
|
150
266
|
}
|
|
@@ -152,10 +268,10 @@ function checkDockerCompose() {
|
|
|
152
268
|
/**
|
|
153
269
|
* Run environment checks
|
|
154
270
|
*/
|
|
155
|
-
function checkEnvironment(outputPath) {
|
|
271
|
+
function checkEnvironment(outputPath, explicitManifestPath) {
|
|
156
272
|
const results = [checkNodeVersion(), checkDocker()];
|
|
157
273
|
// Only check Docker Compose if using compose stack
|
|
158
|
-
const baseTemplate = getBaseTemplateFromManifest(outputPath);
|
|
274
|
+
const baseTemplate = getBaseTemplateFromManifest(outputPath, explicitManifestPath);
|
|
159
275
|
if (baseTemplate === 'compose') {
|
|
160
276
|
results.push(checkDockerCompose());
|
|
161
277
|
}
|
|
@@ -164,8 +280,8 @@ function checkEnvironment(outputPath) {
|
|
|
164
280
|
/**
|
|
165
281
|
* Get base template from manifest if it exists
|
|
166
282
|
*/
|
|
167
|
-
function getBaseTemplateFromManifest(outputPath) {
|
|
168
|
-
const manifestPath = path.join(outputPath, 'superposition.json');
|
|
283
|
+
function getBaseTemplateFromManifest(outputPath, explicitManifestPath) {
|
|
284
|
+
const manifestPath = explicitManifestPath ?? path.join(outputPath, 'superposition.json');
|
|
169
285
|
if (!fs.existsSync(manifestPath)) {
|
|
170
286
|
return undefined;
|
|
171
287
|
}
|
|
@@ -258,9 +374,21 @@ function validateOverlayManifest(overlayDir, overlayId) {
|
|
|
258
374
|
// Validate imports if present
|
|
259
375
|
if (manifest.imports && manifest.imports.length > 0) {
|
|
260
376
|
const overlaysDir = path.dirname(overlayDir);
|
|
377
|
+
const sharedBase = path.resolve(overlaysDir, '.shared');
|
|
261
378
|
const missingImports = [];
|
|
262
379
|
const invalidImports = [];
|
|
380
|
+
const traversalImports = [];
|
|
263
381
|
for (const importPath of manifest.imports) {
|
|
382
|
+
// FR-006: Check for path traversal
|
|
383
|
+
if (!importPath.startsWith('.shared/')) {
|
|
384
|
+
traversalImports.push(`${importPath} (must begin with '.shared/')`);
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
const resolved = path.resolve(overlaysDir, importPath);
|
|
388
|
+
if (!resolved.startsWith(sharedBase + path.sep) && resolved !== sharedBase) {
|
|
389
|
+
traversalImports.push(`${importPath} (resolves outside '.shared/' directory)`);
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
264
392
|
const fullImportPath = path.join(overlaysDir, importPath);
|
|
265
393
|
if (!fs.existsSync(fullImportPath)) {
|
|
266
394
|
missingImports.push(importPath);
|
|
@@ -272,8 +400,11 @@ function validateOverlayManifest(overlayDir, overlayId) {
|
|
|
272
400
|
invalidImports.push(`${importPath} (unsupported type: ${ext})`);
|
|
273
401
|
}
|
|
274
402
|
}
|
|
275
|
-
if (missingImports.length > 0 || invalidImports.length > 0) {
|
|
403
|
+
if (traversalImports.length > 0 || missingImports.length > 0 || invalidImports.length > 0) {
|
|
276
404
|
const details = [];
|
|
405
|
+
if (traversalImports.length > 0) {
|
|
406
|
+
details.push(`Path traversal rejected: ${traversalImports.join(', ')}`);
|
|
407
|
+
}
|
|
277
408
|
if (missingImports.length > 0) {
|
|
278
409
|
details.push(`Missing imports: ${missingImports.join(', ')}`);
|
|
279
410
|
}
|
|
@@ -288,6 +419,52 @@ function validateOverlayManifest(overlayDir, overlayId) {
|
|
|
288
419
|
};
|
|
289
420
|
}
|
|
290
421
|
}
|
|
422
|
+
// Validate compose_imports if present
|
|
423
|
+
if (manifest.compose_imports && manifest.compose_imports.length > 0) {
|
|
424
|
+
const overlaysDir = path.dirname(overlayDir);
|
|
425
|
+
const sharedBase = path.resolve(overlaysDir, '.shared');
|
|
426
|
+
const missingImports = [];
|
|
427
|
+
const invalidImports = [];
|
|
428
|
+
const traversalImports = [];
|
|
429
|
+
for (const importPath of manifest.compose_imports) {
|
|
430
|
+
if (!importPath.startsWith('.shared/')) {
|
|
431
|
+
traversalImports.push(`${importPath} (must begin with '.shared/')`);
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
const resolved = path.resolve(overlaysDir, importPath);
|
|
435
|
+
if (!resolved.startsWith(sharedBase + path.sep) && resolved !== sharedBase) {
|
|
436
|
+
traversalImports.push(`${importPath} (resolves outside '.shared/' directory)`);
|
|
437
|
+
continue;
|
|
438
|
+
}
|
|
439
|
+
const fullImportPath = path.join(overlaysDir, importPath);
|
|
440
|
+
if (!fs.existsSync(fullImportPath)) {
|
|
441
|
+
missingImports.push(importPath);
|
|
442
|
+
continue;
|
|
443
|
+
}
|
|
444
|
+
const ext = path.extname(importPath).toLowerCase();
|
|
445
|
+
if (!['.yaml', '.yml'].includes(ext)) {
|
|
446
|
+
invalidImports.push(`${importPath} (must be .yml or .yaml for compose_imports)`);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
if (traversalImports.length > 0 || missingImports.length > 0 || invalidImports.length > 0) {
|
|
450
|
+
const details = [];
|
|
451
|
+
if (traversalImports.length > 0) {
|
|
452
|
+
details.push(`Path traversal rejected: ${traversalImports.join(', ')}`);
|
|
453
|
+
}
|
|
454
|
+
if (missingImports.length > 0) {
|
|
455
|
+
details.push(`Missing compose_imports: ${missingImports.join(', ')}`);
|
|
456
|
+
}
|
|
457
|
+
if (invalidImports.length > 0) {
|
|
458
|
+
details.push(`Invalid compose_imports: ${invalidImports.join(', ')}`);
|
|
459
|
+
}
|
|
460
|
+
return {
|
|
461
|
+
name: `Overlay: ${overlayId}`,
|
|
462
|
+
status: 'warn',
|
|
463
|
+
message: 'compose_import validation issues',
|
|
464
|
+
details,
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
}
|
|
291
468
|
return {
|
|
292
469
|
name: `Overlay: ${overlayId}`,
|
|
293
470
|
status: 'pass',
|
|
@@ -403,11 +580,11 @@ function checkPorts(overlaysConfig, manifestPath) {
|
|
|
403
580
|
/**
|
|
404
581
|
* Check manifest compatibility
|
|
405
582
|
*/
|
|
406
|
-
function checkManifest(outputPath) {
|
|
583
|
+
function checkManifest(outputPath, explicitManifestPath) {
|
|
407
584
|
const results = [];
|
|
408
|
-
const manifestPath = path.join(outputPath, 'superposition.json');
|
|
409
|
-
// Check if output path exists
|
|
410
|
-
if (!fs.existsSync(outputPath)) {
|
|
585
|
+
const manifestPath = explicitManifestPath ?? path.join(outputPath, 'superposition.json');
|
|
586
|
+
// Check if output path exists (skip when manifest is explicitly provided; the dir may not exist yet)
|
|
587
|
+
if (!explicitManifestPath && !fs.existsSync(outputPath)) {
|
|
411
588
|
return [
|
|
412
589
|
{
|
|
413
590
|
name: 'Devcontainer directory',
|
|
@@ -463,6 +640,9 @@ function checkManifest(outputPath) {
|
|
|
463
640
|
? `Schema version ${manifest.manifestVersion} (tool ${manifest.generatedBy || 'unknown'})`
|
|
464
641
|
: `Legacy format (tool ${manifest.version || 'unknown'})`,
|
|
465
642
|
details: versionDetails,
|
|
643
|
+
fixEligibility: needsUpdate && supported ? 'automatic' : 'not-applicable',
|
|
644
|
+
remediationKey: needsUpdate && supported ? 'manifest-migration' : undefined,
|
|
645
|
+
fixable: needsUpdate && supported,
|
|
466
646
|
});
|
|
467
647
|
// Check for required fields
|
|
468
648
|
if (!manifest.baseTemplate) {
|
|
@@ -470,6 +650,7 @@ function checkManifest(outputPath) {
|
|
|
470
650
|
name: 'Manifest base template',
|
|
471
651
|
status: 'fail',
|
|
472
652
|
message: 'Missing baseTemplate field',
|
|
653
|
+
fixEligibility: 'manual-only',
|
|
473
654
|
});
|
|
474
655
|
}
|
|
475
656
|
// Check devcontainer.json exists
|
|
@@ -480,6 +661,9 @@ function checkManifest(outputPath) {
|
|
|
480
661
|
status: 'fail',
|
|
481
662
|
message: 'devcontainer.json not found',
|
|
482
663
|
details: ['Devcontainer configuration file is missing or corrupted'],
|
|
664
|
+
fixEligibility: 'automatic',
|
|
665
|
+
remediationKey: 'devcontainer-regeneration',
|
|
666
|
+
fixable: true,
|
|
483
667
|
});
|
|
484
668
|
}
|
|
485
669
|
else {
|
|
@@ -491,6 +675,7 @@ function checkManifest(outputPath) {
|
|
|
491
675
|
name: 'DevContainer config',
|
|
492
676
|
status: 'pass',
|
|
493
677
|
message: 'devcontainer.json valid',
|
|
678
|
+
fixEligibility: 'not-applicable',
|
|
494
679
|
});
|
|
495
680
|
}
|
|
496
681
|
catch {
|
|
@@ -498,6 +683,9 @@ function checkManifest(outputPath) {
|
|
|
498
683
|
name: 'DevContainer config',
|
|
499
684
|
status: 'fail',
|
|
500
685
|
message: 'devcontainer.json has invalid JSON',
|
|
686
|
+
fixEligibility: 'automatic',
|
|
687
|
+
remediationKey: 'devcontainer-regeneration',
|
|
688
|
+
fixable: true,
|
|
501
689
|
});
|
|
502
690
|
}
|
|
503
691
|
}
|
|
@@ -508,6 +696,7 @@ function checkManifest(outputPath) {
|
|
|
508
696
|
status: 'fail',
|
|
509
697
|
message: 'Invalid JSON in superposition.json',
|
|
510
698
|
details: [`Parse error: ${error instanceof Error ? error.message : String(error)}`],
|
|
699
|
+
fixEligibility: 'manual-only',
|
|
511
700
|
});
|
|
512
701
|
}
|
|
513
702
|
return results;
|
|
@@ -675,15 +864,84 @@ function checkMergeStrategy(outputPath) {
|
|
|
675
864
|
return results;
|
|
676
865
|
}
|
|
677
866
|
/**
|
|
678
|
-
*
|
|
867
|
+
* Check for drift between the project config file and the last generated manifest.
|
|
868
|
+
* Reports a warning when the overlay lists differ so users know to run `regen`.
|
|
679
869
|
*/
|
|
680
|
-
function
|
|
870
|
+
function checkProjectFileDrift(overlaysConfig, workingDir, manifestPath) {
|
|
871
|
+
// Load project config — skip silently if not present
|
|
872
|
+
let projectConfig;
|
|
873
|
+
try {
|
|
874
|
+
projectConfig = loadProjectConfig(overlaysConfig, workingDir);
|
|
875
|
+
}
|
|
876
|
+
catch {
|
|
877
|
+
return [];
|
|
878
|
+
}
|
|
879
|
+
if (!projectConfig) {
|
|
880
|
+
return [];
|
|
881
|
+
}
|
|
882
|
+
// Load manifest — if not present while the project file exists, report it informatively
|
|
883
|
+
if (!fs.existsSync(manifestPath)) {
|
|
884
|
+
return [
|
|
885
|
+
{
|
|
886
|
+
name: 'Project file drift',
|
|
887
|
+
status: 'warn',
|
|
888
|
+
message: 'Project file found but no generated manifest — run `cs regen` to generate',
|
|
889
|
+
fixEligibility: 'not-applicable',
|
|
890
|
+
},
|
|
891
|
+
];
|
|
892
|
+
}
|
|
893
|
+
let manifest;
|
|
894
|
+
try {
|
|
895
|
+
const raw = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
896
|
+
manifest = needsMigration(raw) ? migrateManifest(raw) : raw;
|
|
897
|
+
}
|
|
898
|
+
catch {
|
|
899
|
+
return [];
|
|
900
|
+
}
|
|
901
|
+
// Compare overlay sets (order-independent).
|
|
902
|
+
// Exclude auto-resolved dependencies from the manifest side — the project file only
|
|
903
|
+
// stores user-selected overlays; auto-resolved ones are re-calculated at generation time.
|
|
904
|
+
const autoResolvedAdded = new Set(manifest.autoResolved?.added ?? []);
|
|
905
|
+
const projectOverlays = new Set(projectConfig.selection.overlays ?? []);
|
|
906
|
+
const manifestBaseOverlays = new Set((manifest.overlays ?? []).filter((o) => !autoResolvedAdded.has(o)));
|
|
907
|
+
const inProjectNotManifest = [...projectOverlays].filter((o) => !manifestBaseOverlays.has(o));
|
|
908
|
+
const inManifestNotProject = [...manifestBaseOverlays].filter((o) => !projectOverlays.has(o));
|
|
909
|
+
if (inProjectNotManifest.length === 0 && inManifestNotProject.length === 0) {
|
|
910
|
+
return [
|
|
911
|
+
{
|
|
912
|
+
name: 'Project file drift',
|
|
913
|
+
status: 'pass',
|
|
914
|
+
message: 'Project file and generated manifest are consistent',
|
|
915
|
+
fixEligibility: 'not-applicable',
|
|
916
|
+
},
|
|
917
|
+
];
|
|
918
|
+
}
|
|
919
|
+
const details = [];
|
|
920
|
+
if (inProjectNotManifest.length > 0) {
|
|
921
|
+
details.push(`In project file but not in manifest: ${inProjectNotManifest.join(', ')}`);
|
|
922
|
+
}
|
|
923
|
+
if (inManifestNotProject.length > 0) {
|
|
924
|
+
details.push(`In manifest but not in project file: ${inManifestNotProject.join(', ')}`);
|
|
925
|
+
}
|
|
926
|
+
details.push('Run "cs regen" to regenerate with the current project file configuration');
|
|
927
|
+
return [
|
|
928
|
+
{
|
|
929
|
+
name: 'Project file drift',
|
|
930
|
+
status: 'warn',
|
|
931
|
+
message: 'Project file and generated manifest have diverged',
|
|
932
|
+
details,
|
|
933
|
+
fixEligibility: 'manual-only',
|
|
934
|
+
},
|
|
935
|
+
];
|
|
936
|
+
}
|
|
937
|
+
function generateReport(environmentChecks, overlayChecks, manifestChecks, mergeChecks, portChecks, driftChecks = []) {
|
|
681
938
|
const allChecks = [
|
|
682
939
|
...environmentChecks,
|
|
683
940
|
...overlayChecks,
|
|
684
941
|
...manifestChecks,
|
|
685
942
|
...mergeChecks,
|
|
686
943
|
...portChecks,
|
|
944
|
+
...driftChecks,
|
|
687
945
|
];
|
|
688
946
|
const passed = allChecks.filter((c) => c.status === 'pass').length;
|
|
689
947
|
const warnings = allChecks.filter((c) => c.status === 'warn').length;
|
|
@@ -695,6 +953,7 @@ function generateReport(environmentChecks, overlayChecks, manifestChecks, mergeC
|
|
|
695
953
|
manifest: manifestChecks,
|
|
696
954
|
merge: mergeChecks,
|
|
697
955
|
ports: portChecks,
|
|
956
|
+
drift: driftChecks,
|
|
698
957
|
summary: {
|
|
699
958
|
passed,
|
|
700
959
|
warnings,
|
|
@@ -771,6 +1030,20 @@ function formatAsText(report) {
|
|
|
771
1030
|
lines.push(formatCheckResult(check));
|
|
772
1031
|
}
|
|
773
1032
|
}
|
|
1033
|
+
// Drift section
|
|
1034
|
+
if (report.drift.length > 0) {
|
|
1035
|
+
const failedDrift = report.drift.filter((c) => c.status !== 'pass');
|
|
1036
|
+
if (failedDrift.length > 0) {
|
|
1037
|
+
lines.push(chalk.bold('\nProject File:'));
|
|
1038
|
+
for (const check of failedDrift) {
|
|
1039
|
+
lines.push(formatCheckResult(check));
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
else {
|
|
1043
|
+
lines.push(chalk.bold('\nProject File:'));
|
|
1044
|
+
lines.push(` ${chalk.green('✓')} ${chalk.white('Project file and manifest are consistent')}`);
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
774
1047
|
// Summary
|
|
775
1048
|
lines.push(chalk.bold('\nSummary:'));
|
|
776
1049
|
lines.push(` ${chalk.green('✓')} ${report.summary.passed} passed`);
|
|
@@ -787,33 +1060,743 @@ function formatAsText(report) {
|
|
|
787
1060
|
return lines.join('\n');
|
|
788
1061
|
}
|
|
789
1062
|
/**
|
|
790
|
-
*
|
|
1063
|
+
* Convert a report section + category into DiagnosticFinding objects.
|
|
1064
|
+
*/
|
|
1065
|
+
function checksToFindings(checks, category, recheckScope) {
|
|
1066
|
+
return checks.map((c) => {
|
|
1067
|
+
const id = c.name
|
|
1068
|
+
.toLowerCase()
|
|
1069
|
+
.replace(/\s+/g, '-')
|
|
1070
|
+
.replace(/[^a-z0-9-]/g, '');
|
|
1071
|
+
return {
|
|
1072
|
+
id,
|
|
1073
|
+
category,
|
|
1074
|
+
name: c.name,
|
|
1075
|
+
status: c.status,
|
|
1076
|
+
message: c.message,
|
|
1077
|
+
details: c.details,
|
|
1078
|
+
fixEligibility: c.fixEligibility ?? 'not-applicable',
|
|
1079
|
+
remediationKey: c.remediationKey,
|
|
1080
|
+
recheckScope,
|
|
1081
|
+
};
|
|
1082
|
+
});
|
|
1083
|
+
}
|
|
1084
|
+
/**
|
|
1085
|
+
* Convert a full DoctorReport into a flat DiagnosticFinding array.
|
|
1086
|
+
*/
|
|
1087
|
+
function reportToFindings(report) {
|
|
1088
|
+
return [
|
|
1089
|
+
...checksToFindings(report.environment, 'environment', 'environment'),
|
|
1090
|
+
...checksToFindings(report.overlays, 'overlay', 'full'),
|
|
1091
|
+
...checksToFindings(report.manifest, 'manifest', 'manifest'),
|
|
1092
|
+
...checksToFindings(report.merge, 'merge', 'devcontainer'),
|
|
1093
|
+
...checksToFindings(report.ports, 'ports', 'environment'),
|
|
1094
|
+
...checksToFindings(report.drift, 'manifest', 'manifest'),
|
|
1095
|
+
];
|
|
1096
|
+
}
|
|
1097
|
+
/**
|
|
1098
|
+
* Order findings for remediation: manifest migration must come before regeneration.
|
|
1099
|
+
*/
|
|
1100
|
+
function orderFindingsForRemediation(findings) {
|
|
1101
|
+
const PRIORITY = {
|
|
1102
|
+
'manifest-migration': 1,
|
|
1103
|
+
'devcontainer-regeneration': 2,
|
|
1104
|
+
'node-version-fix': 3,
|
|
1105
|
+
'docker-repair': 4,
|
|
1106
|
+
};
|
|
1107
|
+
return [...findings].sort((a, b) => {
|
|
1108
|
+
const pa = PRIORITY[a.remediationKey ?? ''] ?? 99;
|
|
1109
|
+
const pb = PRIORITY[b.remediationKey ?? ''] ?? 99;
|
|
1110
|
+
return pa - pb;
|
|
1111
|
+
});
|
|
1112
|
+
}
|
|
1113
|
+
/**
|
|
1114
|
+
* Atomically write a JSON file (write to .tmp then rename).
|
|
1115
|
+
* On Windows, rename fails if the destination already exists; delete it first.
|
|
791
1116
|
*/
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
...report.merge,
|
|
799
|
-
...report.ports,
|
|
800
|
-
].filter((c) => c.fixable);
|
|
801
|
-
if (fixableChecks.length === 0) {
|
|
802
|
-
console.log(chalk.yellow('No automatic fixes available.'));
|
|
803
|
-
return;
|
|
1117
|
+
function atomicWriteJson(filePath, data) {
|
|
1118
|
+
const tmpPath = filePath + '.tmp';
|
|
1119
|
+
fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2) + '\n', 'utf8');
|
|
1120
|
+
// Windows requires the destination to be absent before renaming.
|
|
1121
|
+
if (fs.existsSync(filePath)) {
|
|
1122
|
+
fs.unlinkSync(filePath);
|
|
804
1123
|
}
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
1124
|
+
fs.renameSync(tmpPath, filePath);
|
|
1125
|
+
}
|
|
1126
|
+
/**
|
|
1127
|
+
* Create a timestamped backup of a file and return the backup path.
|
|
1128
|
+
*/
|
|
1129
|
+
function backupFile(filePath) {
|
|
1130
|
+
const timestamp = new Date()
|
|
1131
|
+
.toISOString()
|
|
1132
|
+
.replace(/[:.]/g, '-')
|
|
1133
|
+
.replace('T', '-')
|
|
1134
|
+
.replace('Z', '');
|
|
1135
|
+
const backupPath = `${filePath}.backup-${timestamp}`;
|
|
1136
|
+
fs.copyFileSync(filePath, backupPath);
|
|
1137
|
+
return backupPath;
|
|
1138
|
+
}
|
|
1139
|
+
/**
|
|
1140
|
+
* Build QuestionnaireAnswers from a SuperpositionManifest using overlaysConfig
|
|
1141
|
+
* for category resolution. Used by the devcontainer-regeneration fix.
|
|
1142
|
+
*/
|
|
1143
|
+
function buildAnswersFromManifest(manifest, manifestDir, overlaysConfig) {
|
|
1144
|
+
const knownBaseImageIds = ['bookworm', 'trixie', 'alpine', 'ubuntu', 'custom'];
|
|
1145
|
+
const isKnownBaseImage = knownBaseImageIds.includes(manifest.baseImage);
|
|
1146
|
+
const language = [];
|
|
1147
|
+
const database = [];
|
|
1148
|
+
const observability = [];
|
|
1149
|
+
const cloudTools = [];
|
|
1150
|
+
const devTools = [];
|
|
1151
|
+
const overlayMap = new Map(overlaysConfig.overlays.map((o) => [o.id, o]));
|
|
1152
|
+
for (const id of manifest.overlays) {
|
|
1153
|
+
const overlay = overlayMap.get(id);
|
|
1154
|
+
if (!overlay)
|
|
1155
|
+
continue;
|
|
1156
|
+
switch (overlay.category) {
|
|
1157
|
+
case 'language':
|
|
1158
|
+
language.push(id);
|
|
1159
|
+
break;
|
|
1160
|
+
case 'database':
|
|
1161
|
+
database.push(id);
|
|
1162
|
+
break;
|
|
1163
|
+
case 'observability':
|
|
1164
|
+
observability.push(id);
|
|
1165
|
+
break;
|
|
1166
|
+
case 'cloud':
|
|
1167
|
+
cloudTools.push(id);
|
|
1168
|
+
break;
|
|
1169
|
+
case 'dev':
|
|
1170
|
+
devTools.push(id);
|
|
1171
|
+
break;
|
|
1172
|
+
}
|
|
810
1173
|
}
|
|
1174
|
+
return {
|
|
1175
|
+
stack: manifest.baseTemplate,
|
|
1176
|
+
baseImage: isKnownBaseImage ? manifest.baseImage : 'custom',
|
|
1177
|
+
customImage: isKnownBaseImage ? undefined : manifest.baseImage,
|
|
1178
|
+
containerName: manifest.containerName,
|
|
1179
|
+
preset: manifest.preset,
|
|
1180
|
+
presetChoices: manifest.presetChoices,
|
|
1181
|
+
language,
|
|
1182
|
+
database,
|
|
1183
|
+
observability,
|
|
1184
|
+
cloudTools,
|
|
1185
|
+
devTools,
|
|
1186
|
+
needsDocker: manifest.baseTemplate === 'compose',
|
|
1187
|
+
playwright: devTools.includes('playwright'),
|
|
1188
|
+
outputPath: manifestDir,
|
|
1189
|
+
portOffset: manifest.portOffset,
|
|
1190
|
+
};
|
|
1191
|
+
}
|
|
1192
|
+
/**
|
|
1193
|
+
* Execute manifest migration fix (Class 1).
|
|
1194
|
+
*/
|
|
1195
|
+
function executeManifestMigration(outputPath, explicitManifestPath) {
|
|
1196
|
+
const manifestPath = explicitManifestPath ?? path.join(outputPath, 'superposition.json');
|
|
1197
|
+
if (!fs.existsSync(manifestPath)) {
|
|
1198
|
+
return {
|
|
1199
|
+
findingId: 'manifest-version',
|
|
1200
|
+
remediationKey: 'manifest-migration',
|
|
1201
|
+
attempted: false,
|
|
1202
|
+
outcome: 'requires-manual-action',
|
|
1203
|
+
reason: 'superposition.json not found — cannot migrate',
|
|
1204
|
+
rechecked: false,
|
|
1205
|
+
};
|
|
1206
|
+
}
|
|
1207
|
+
let manifest;
|
|
1208
|
+
try {
|
|
1209
|
+
manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
1210
|
+
}
|
|
1211
|
+
catch (err) {
|
|
1212
|
+
return {
|
|
1213
|
+
findingId: 'manifest-version',
|
|
1214
|
+
remediationKey: 'manifest-migration',
|
|
1215
|
+
attempted: false,
|
|
1216
|
+
outcome: 'requires-manual-action',
|
|
1217
|
+
reason: `Cannot parse superposition.json: ${err instanceof Error ? err.message : String(err)}`,
|
|
1218
|
+
rechecked: false,
|
|
1219
|
+
};
|
|
1220
|
+
}
|
|
1221
|
+
if (!needsMigration(manifest)) {
|
|
1222
|
+
return {
|
|
1223
|
+
findingId: 'manifest-version',
|
|
1224
|
+
remediationKey: 'manifest-migration',
|
|
1225
|
+
attempted: false,
|
|
1226
|
+
outcome: 'already-compliant',
|
|
1227
|
+
reason: 'Manifest is already at the current schema version',
|
|
1228
|
+
rechecked: true,
|
|
1229
|
+
};
|
|
1230
|
+
}
|
|
1231
|
+
let backupPath;
|
|
1232
|
+
try {
|
|
1233
|
+
backupPath = backupFile(manifestPath);
|
|
1234
|
+
}
|
|
1235
|
+
catch (err) {
|
|
1236
|
+
return {
|
|
1237
|
+
findingId: 'manifest-version',
|
|
1238
|
+
remediationKey: 'manifest-migration',
|
|
1239
|
+
attempted: false,
|
|
1240
|
+
outcome: 'requires-manual-action',
|
|
1241
|
+
reason: `Failed to create backup: ${err instanceof Error ? err.message : String(err)}`,
|
|
1242
|
+
rechecked: false,
|
|
1243
|
+
};
|
|
1244
|
+
}
|
|
1245
|
+
try {
|
|
1246
|
+
const migrated = migrateManifest(manifest);
|
|
1247
|
+
atomicWriteJson(manifestPath, migrated);
|
|
1248
|
+
}
|
|
1249
|
+
catch (err) {
|
|
1250
|
+
// Restore backup on failure
|
|
1251
|
+
try {
|
|
1252
|
+
fs.copyFileSync(backupPath, manifestPath);
|
|
1253
|
+
}
|
|
1254
|
+
catch {
|
|
1255
|
+
// best effort
|
|
1256
|
+
}
|
|
1257
|
+
return {
|
|
1258
|
+
findingId: 'manifest-version',
|
|
1259
|
+
remediationKey: 'manifest-migration',
|
|
1260
|
+
attempted: true,
|
|
1261
|
+
outcome: 'requires-manual-action',
|
|
1262
|
+
reason: `Migration failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
1263
|
+
backupPath,
|
|
1264
|
+
rechecked: false,
|
|
1265
|
+
};
|
|
1266
|
+
}
|
|
1267
|
+
// Re-check
|
|
1268
|
+
try {
|
|
1269
|
+
const updated = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
1270
|
+
const stillNeeds = needsMigration(updated);
|
|
1271
|
+
return {
|
|
1272
|
+
findingId: 'manifest-version',
|
|
1273
|
+
remediationKey: 'manifest-migration',
|
|
1274
|
+
attempted: true,
|
|
1275
|
+
outcome: stillNeeds ? 'requires-manual-action' : 'fixed',
|
|
1276
|
+
reason: stillNeeds
|
|
1277
|
+
? 'Migration wrote file but schema still reports outdated'
|
|
1278
|
+
: 'Manifest migrated to current schema version',
|
|
1279
|
+
changedFiles: [manifestPath],
|
|
1280
|
+
backupPath,
|
|
1281
|
+
rechecked: true,
|
|
1282
|
+
};
|
|
1283
|
+
}
|
|
1284
|
+
catch {
|
|
1285
|
+
return {
|
|
1286
|
+
findingId: 'manifest-version',
|
|
1287
|
+
remediationKey: 'manifest-migration',
|
|
1288
|
+
attempted: true,
|
|
1289
|
+
outcome: 'fixed',
|
|
1290
|
+
reason: 'Manifest migrated (re-check skipped — parse error after write)',
|
|
1291
|
+
changedFiles: [manifestPath],
|
|
1292
|
+
backupPath,
|
|
1293
|
+
rechecked: false,
|
|
1294
|
+
};
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
/**
|
|
1298
|
+
* Execute devcontainer regeneration fix (Class 2).
|
|
1299
|
+
* @param silent When true, suppresses console output during regeneration (for --json mode).
|
|
1300
|
+
*/
|
|
1301
|
+
async function executeRegeneration(outputPath, overlaysConfig, overlaysDir, silent = false, explicitManifestPath) {
|
|
1302
|
+
const manifestPath = explicitManifestPath ?? path.join(outputPath, 'superposition.json');
|
|
1303
|
+
if (!fs.existsSync(manifestPath)) {
|
|
1304
|
+
return {
|
|
1305
|
+
findingId: 'devcontainer-config',
|
|
1306
|
+
remediationKey: 'devcontainer-regeneration',
|
|
1307
|
+
attempted: false,
|
|
1308
|
+
outcome: 'requires-manual-action',
|
|
1309
|
+
reason: 'No superposition.json found — run "container-superposition init" first',
|
|
1310
|
+
rechecked: false,
|
|
1311
|
+
};
|
|
1312
|
+
}
|
|
1313
|
+
let manifest;
|
|
1314
|
+
try {
|
|
1315
|
+
manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
1316
|
+
}
|
|
1317
|
+
catch (err) {
|
|
1318
|
+
return {
|
|
1319
|
+
findingId: 'devcontainer-config',
|
|
1320
|
+
remediationKey: 'devcontainer-regeneration',
|
|
1321
|
+
attempted: false,
|
|
1322
|
+
outcome: 'requires-manual-action',
|
|
1323
|
+
reason: `Cannot parse superposition.json: ${err instanceof Error ? err.message : String(err)}`,
|
|
1324
|
+
rechecked: false,
|
|
1325
|
+
};
|
|
1326
|
+
}
|
|
1327
|
+
if (!manifest.baseTemplate) {
|
|
1328
|
+
return {
|
|
1329
|
+
findingId: 'devcontainer-config',
|
|
1330
|
+
remediationKey: 'devcontainer-regeneration',
|
|
1331
|
+
attempted: false,
|
|
1332
|
+
outcome: 'requires-manual-action',
|
|
1333
|
+
reason: 'Manifest is missing required baseTemplate field — cannot regenerate',
|
|
1334
|
+
rechecked: false,
|
|
1335
|
+
};
|
|
1336
|
+
}
|
|
1337
|
+
const answers = mergeAnswers(buildAnswersFromManifest(manifest, outputPath, overlaysConfig));
|
|
1338
|
+
// Suppress console output during regeneration when in JSON mode
|
|
1339
|
+
const originalLog = console.log;
|
|
1340
|
+
if (silent) {
|
|
1341
|
+
console.log = () => { };
|
|
1342
|
+
}
|
|
1343
|
+
try {
|
|
1344
|
+
await composeDevContainer(answers, overlaysDir, { isRegen: true });
|
|
1345
|
+
}
|
|
1346
|
+
catch (err) {
|
|
1347
|
+
if (silent) {
|
|
1348
|
+
console.log = originalLog;
|
|
1349
|
+
}
|
|
1350
|
+
return {
|
|
1351
|
+
findingId: 'devcontainer-config',
|
|
1352
|
+
remediationKey: 'devcontainer-regeneration',
|
|
1353
|
+
attempted: true,
|
|
1354
|
+
outcome: 'requires-manual-action',
|
|
1355
|
+
reason: `Regeneration failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
1356
|
+
rechecked: false,
|
|
1357
|
+
};
|
|
1358
|
+
}
|
|
1359
|
+
finally {
|
|
1360
|
+
if (silent) {
|
|
1361
|
+
console.log = originalLog;
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
// Re-check
|
|
1365
|
+
const devcontainerPath = path.join(outputPath, 'devcontainer.json');
|
|
1366
|
+
const exists = fs.existsSync(devcontainerPath);
|
|
1367
|
+
let validJson = false;
|
|
1368
|
+
if (exists) {
|
|
1369
|
+
try {
|
|
1370
|
+
JSON.parse(fs.readFileSync(devcontainerPath, 'utf8'));
|
|
1371
|
+
validJson = true;
|
|
1372
|
+
}
|
|
1373
|
+
catch {
|
|
1374
|
+
// invalid JSON
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
return {
|
|
1378
|
+
findingId: 'devcontainer-config',
|
|
1379
|
+
remediationKey: 'devcontainer-regeneration',
|
|
1380
|
+
attempted: true,
|
|
1381
|
+
outcome: exists && validJson ? 'fixed' : 'requires-manual-action',
|
|
1382
|
+
reason: exists && validJson
|
|
1383
|
+
? 'devcontainer.json regenerated from superposition.json'
|
|
1384
|
+
: 'Regeneration ran but devcontainer.json is still missing or invalid',
|
|
1385
|
+
changedFiles: exists ? [devcontainerPath] : [],
|
|
1386
|
+
rechecked: true,
|
|
1387
|
+
};
|
|
1388
|
+
}
|
|
1389
|
+
/**
|
|
1390
|
+
* Execute Node.js version fix (Class 3).
|
|
1391
|
+
*/
|
|
1392
|
+
function executeNodeVersionFix() {
|
|
1393
|
+
const manager = detectVersionManager();
|
|
1394
|
+
if (!manager) {
|
|
1395
|
+
return {
|
|
1396
|
+
findingId: 'nodejs-version',
|
|
1397
|
+
remediationKey: 'node-version-fix',
|
|
1398
|
+
attempted: false,
|
|
1399
|
+
outcome: 'requires-manual-action',
|
|
1400
|
+
reason: 'No version manager (nvm, fnm, or volta) found',
|
|
1401
|
+
rechecked: false,
|
|
1402
|
+
};
|
|
1403
|
+
}
|
|
1404
|
+
let fixCmd;
|
|
1405
|
+
switch (manager) {
|
|
1406
|
+
case 'nvm': {
|
|
1407
|
+
const nvmScript = path.join(process.env.HOME ?? process.env.USERPROFILE ?? '', '.nvm', 'nvm.sh');
|
|
1408
|
+
fixCmd = `source "${nvmScript}" && nvm install 20 && nvm use 20`;
|
|
1409
|
+
break;
|
|
1410
|
+
}
|
|
1411
|
+
case 'fnm':
|
|
1412
|
+
fixCmd = 'fnm install 20 && fnm use 20';
|
|
1413
|
+
break;
|
|
1414
|
+
case 'volta':
|
|
1415
|
+
fixCmd = 'volta install node@20';
|
|
1416
|
+
break;
|
|
1417
|
+
}
|
|
1418
|
+
// nvm and fnm only update the child shell's PATH — `doctor` won't see the change.
|
|
1419
|
+
// Treat these as "installed; open a new shell" rather than attempting a re-check
|
|
1420
|
+
// that will always fail in the current process.
|
|
1421
|
+
// volta persists via its shim mechanism and can be verified immediately.
|
|
1422
|
+
if (manager === 'nvm' || manager === 'fnm') {
|
|
1423
|
+
const runCmd = manager === 'nvm' ? `bash -lc '${fixCmd}'` : `sh -lc '${fixCmd}'`;
|
|
1424
|
+
try {
|
|
1425
|
+
execSync(runCmd, { stdio: 'pipe', timeout: 60_000 });
|
|
1426
|
+
}
|
|
1427
|
+
catch (err) {
|
|
1428
|
+
return {
|
|
1429
|
+
findingId: 'nodejs-version',
|
|
1430
|
+
remediationKey: 'node-version-fix',
|
|
1431
|
+
attempted: true,
|
|
1432
|
+
outcome: 'requires-manual-action',
|
|
1433
|
+
reason: `Fix command failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
1434
|
+
commands: [fixCmd],
|
|
1435
|
+
rechecked: false,
|
|
1436
|
+
};
|
|
1437
|
+
}
|
|
1438
|
+
return {
|
|
1439
|
+
findingId: 'nodejs-version',
|
|
1440
|
+
remediationKey: 'node-version-fix',
|
|
1441
|
+
attempted: true,
|
|
1442
|
+
outcome: 'requires-manual-action',
|
|
1443
|
+
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.`,
|
|
1444
|
+
commands: [fixCmd],
|
|
1445
|
+
rechecked: false,
|
|
1446
|
+
};
|
|
1447
|
+
}
|
|
1448
|
+
// volta: shim persists across processes — attempt + re-check is reliable.
|
|
1449
|
+
try {
|
|
1450
|
+
execSync(`sh -lc '${fixCmd}'`, { stdio: 'pipe', timeout: 60_000 });
|
|
1451
|
+
}
|
|
1452
|
+
catch (err) {
|
|
1453
|
+
return {
|
|
1454
|
+
findingId: 'nodejs-version',
|
|
1455
|
+
remediationKey: 'node-version-fix',
|
|
1456
|
+
attempted: true,
|
|
1457
|
+
outcome: 'requires-manual-action',
|
|
1458
|
+
reason: `Fix command failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
1459
|
+
commands: [fixCmd],
|
|
1460
|
+
rechecked: false,
|
|
1461
|
+
};
|
|
1462
|
+
}
|
|
1463
|
+
// Re-check (volta updates shim; new processes see the updated Node)
|
|
1464
|
+
try {
|
|
1465
|
+
const version = execSync('sh -lc "node --version"', {
|
|
1466
|
+
encoding: 'utf8',
|
|
1467
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
1468
|
+
timeout: 10_000,
|
|
1469
|
+
});
|
|
1470
|
+
const match = version.trim().match(/^v(\d+)/);
|
|
1471
|
+
const major = match ? parseInt(match[1], 10) : 0;
|
|
1472
|
+
if (major >= 20) {
|
|
1473
|
+
return {
|
|
1474
|
+
findingId: 'nodejs-version',
|
|
1475
|
+
remediationKey: 'node-version-fix',
|
|
1476
|
+
attempted: true,
|
|
1477
|
+
outcome: 'fixed',
|
|
1478
|
+
reason: `Node.js ${version.trim()} activated via volta`,
|
|
1479
|
+
commands: [fixCmd],
|
|
1480
|
+
rechecked: true,
|
|
1481
|
+
};
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
catch {
|
|
1485
|
+
// fall through
|
|
1486
|
+
}
|
|
1487
|
+
return {
|
|
1488
|
+
findingId: 'nodejs-version',
|
|
1489
|
+
remediationKey: 'node-version-fix',
|
|
1490
|
+
attempted: true,
|
|
1491
|
+
outcome: 'requires-manual-action',
|
|
1492
|
+
reason: `volta ran but node --version still reports < 20. Open a new shell and run: ${fixCmd}`,
|
|
1493
|
+
commands: [fixCmd],
|
|
1494
|
+
rechecked: true,
|
|
1495
|
+
};
|
|
1496
|
+
}
|
|
1497
|
+
/**
|
|
1498
|
+
* Execute a single remediation action and return its execution record.
|
|
1499
|
+
*/
|
|
1500
|
+
async function executeSingleFix(finding, outputPath, overlaysConfig, overlaysDir, silent = false, explicitManifestPath) {
|
|
1501
|
+
switch (finding.remediationKey) {
|
|
1502
|
+
case 'manifest-migration':
|
|
1503
|
+
return executeManifestMigration(outputPath, explicitManifestPath);
|
|
1504
|
+
case 'devcontainer-regeneration':
|
|
1505
|
+
return executeRegeneration(outputPath, overlaysConfig, overlaysDir, silent, explicitManifestPath);
|
|
1506
|
+
case 'node-version-fix':
|
|
1507
|
+
return executeNodeVersionFix();
|
|
1508
|
+
case 'docker-repair': {
|
|
1509
|
+
return {
|
|
1510
|
+
findingId: finding.id,
|
|
1511
|
+
remediationKey: 'docker-repair',
|
|
1512
|
+
attempted: false,
|
|
1513
|
+
outcome: 'requires-manual-action',
|
|
1514
|
+
reason: 'Docker daemon repair requires manual intervention',
|
|
1515
|
+
rechecked: false,
|
|
1516
|
+
};
|
|
1517
|
+
}
|
|
1518
|
+
default:
|
|
1519
|
+
return {
|
|
1520
|
+
findingId: finding.id,
|
|
1521
|
+
remediationKey: finding.remediationKey ?? 'unknown',
|
|
1522
|
+
attempted: false,
|
|
1523
|
+
outcome: 'requires-manual-action',
|
|
1524
|
+
reason: `No remediation handler registered for key "${finding.remediationKey}"`,
|
|
1525
|
+
rechecked: false,
|
|
1526
|
+
};
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1529
|
+
/**
|
|
1530
|
+
* Build the FixOutcomeSummary from a list of executions.
|
|
1531
|
+
*/
|
|
1532
|
+
function buildOutcomeSummary(executions) {
|
|
1533
|
+
const counts = {
|
|
1534
|
+
fixed: 0,
|
|
1535
|
+
alreadyCompliant: 0,
|
|
1536
|
+
skipped: 0,
|
|
1537
|
+
requiresManualAction: 0,
|
|
1538
|
+
};
|
|
1539
|
+
for (const ex of executions) {
|
|
1540
|
+
switch (ex.outcome) {
|
|
1541
|
+
case 'fixed':
|
|
1542
|
+
counts.fixed++;
|
|
1543
|
+
break;
|
|
1544
|
+
case 'already-compliant':
|
|
1545
|
+
counts.alreadyCompliant++;
|
|
1546
|
+
break;
|
|
1547
|
+
case 'skipped':
|
|
1548
|
+
counts.skipped++;
|
|
1549
|
+
break;
|
|
1550
|
+
case 'requires-manual-action':
|
|
1551
|
+
counts.requiresManualAction++;
|
|
1552
|
+
break;
|
|
1553
|
+
}
|
|
1554
|
+
}
|
|
1555
|
+
return { ...counts, total: executions.length };
|
|
1556
|
+
}
|
|
1557
|
+
/**
|
|
1558
|
+
* Determine the exit disposition from summary and final findings.
|
|
1559
|
+
*/
|
|
1560
|
+
function determineExitDisposition(summary, finalFindings) {
|
|
1561
|
+
// Any failing finding (regardless of fix eligibility) is an unresolved failure.
|
|
1562
|
+
const unresolvedFailures = finalFindings.filter((f) => f.status === 'fail');
|
|
1563
|
+
if (unresolvedFailures.length > 0) {
|
|
1564
|
+
return 'unresolved-failures';
|
|
1565
|
+
}
|
|
1566
|
+
if (summary.requiresManualAction > 0 || summary.skipped > 0) {
|
|
1567
|
+
return 'repaired-with-warnings';
|
|
1568
|
+
}
|
|
1569
|
+
return 'success';
|
|
1570
|
+
}
|
|
1571
|
+
/**
|
|
1572
|
+
* Run the full fix flow: diagnose → narrate → remediate → re-check → summarise.
|
|
1573
|
+
*/
|
|
1574
|
+
async function executeFixRun(report, outputPath, overlaysConfig, overlaysDir, requestedJson, explicitManifestPath, workingDir = process.cwd()) {
|
|
1575
|
+
const initialFindings = reportToFindings(report);
|
|
1576
|
+
// Separate automatic and manual-only fixable findings
|
|
1577
|
+
const autoFixable = initialFindings.filter((f) => f.fixEligibility === 'automatic' && f.status !== 'pass');
|
|
1578
|
+
const manualOnly = initialFindings.filter((f) => f.fixEligibility === 'manual-only' && f.status !== 'pass');
|
|
1579
|
+
// Order automatic fixes: prerequisites before dependents
|
|
1580
|
+
const orderedAuto = orderFindingsForRemediation(autoFixable);
|
|
1581
|
+
const executions = [];
|
|
1582
|
+
let manifestMigrationFailed = false;
|
|
1583
|
+
for (const finding of orderedAuto) {
|
|
1584
|
+
// Dependency ordering: skip regeneration if manifest migration failed
|
|
1585
|
+
if (finding.remediationKey === 'devcontainer-regeneration' && manifestMigrationFailed) {
|
|
1586
|
+
executions.push({
|
|
1587
|
+
findingId: finding.id,
|
|
1588
|
+
remediationKey: 'devcontainer-regeneration',
|
|
1589
|
+
attempted: false,
|
|
1590
|
+
outcome: 'skipped',
|
|
1591
|
+
reason: 'Skipped because manifest migration did not succeed',
|
|
1592
|
+
rechecked: false,
|
|
1593
|
+
});
|
|
1594
|
+
continue;
|
|
1595
|
+
}
|
|
1596
|
+
// Narrate planned change (text mode)
|
|
1597
|
+
if (!requestedJson) {
|
|
1598
|
+
const action = REMEDIATION_REGISTRY.get(finding.remediationKey ?? '');
|
|
1599
|
+
console.log(`\n ${chalk.cyan('→')} Planning fix for: ${chalk.white(finding.name)}`);
|
|
1600
|
+
if (action) {
|
|
1601
|
+
for (const change of action.plannedChanges) {
|
|
1602
|
+
console.log(` ${chalk.dim('·')} ${chalk.dim(change)}`);
|
|
1603
|
+
}
|
|
1604
|
+
}
|
|
1605
|
+
}
|
|
1606
|
+
const execution = await executeSingleFix(finding, outputPath, overlaysConfig, overlaysDir, requestedJson, explicitManifestPath);
|
|
1607
|
+
executions.push(execution);
|
|
1608
|
+
if (finding.remediationKey === 'manifest-migration' &&
|
|
1609
|
+
execution.outcome !== 'fixed' &&
|
|
1610
|
+
execution.outcome !== 'already-compliant') {
|
|
1611
|
+
manifestMigrationFailed = true;
|
|
1612
|
+
}
|
|
1613
|
+
}
|
|
1614
|
+
// Add manual-only findings as requires-manual-action
|
|
1615
|
+
for (const finding of manualOnly) {
|
|
1616
|
+
const action = REMEDIATION_REGISTRY.get(finding.remediationKey ?? '');
|
|
1617
|
+
executions.push({
|
|
1618
|
+
findingId: finding.id,
|
|
1619
|
+
remediationKey: finding.remediationKey ?? 'manual',
|
|
1620
|
+
attempted: false,
|
|
1621
|
+
outcome: 'requires-manual-action',
|
|
1622
|
+
reason: action
|
|
1623
|
+
? action.manualFallback.join(' | ')
|
|
1624
|
+
: 'No automatic fix available for this issue',
|
|
1625
|
+
rechecked: false,
|
|
1626
|
+
});
|
|
1627
|
+
}
|
|
1628
|
+
// Re-run checks to get final state
|
|
1629
|
+
const envChecks = checkEnvironment(outputPath, explicitManifestPath);
|
|
1630
|
+
const manifestChecks = checkManifest(outputPath, explicitManifestPath);
|
|
1631
|
+
const mergeChecks = checkMergeStrategy(outputPath);
|
|
1632
|
+
const overlayChecks = checkOverlays(overlaysDir);
|
|
1633
|
+
const finalManifestPath = explicitManifestPath ?? path.join(outputPath, 'superposition.json');
|
|
1634
|
+
const portChecks = checkPorts(overlaysConfig, finalManifestPath);
|
|
1635
|
+
const finalDriftChecks = checkProjectFileDrift(overlaysConfig, workingDir, finalManifestPath);
|
|
1636
|
+
const finalFindings = [
|
|
1637
|
+
...checksToFindings(envChecks, 'environment', 'environment'),
|
|
1638
|
+
...checksToFindings(manifestChecks, 'manifest', 'manifest'),
|
|
1639
|
+
...checksToFindings(mergeChecks, 'merge', 'devcontainer'),
|
|
1640
|
+
...checksToFindings(overlayChecks, 'overlay', 'full'),
|
|
1641
|
+
...checksToFindings(portChecks, 'ports', 'environment'),
|
|
1642
|
+
...checksToFindings(finalDriftChecks, 'manifest', 'manifest'),
|
|
1643
|
+
];
|
|
1644
|
+
const summary = buildOutcomeSummary(executions);
|
|
1645
|
+
const exitDisposition = determineExitDisposition(summary, finalFindings);
|
|
1646
|
+
return {
|
|
1647
|
+
outputPath,
|
|
1648
|
+
requestedJson,
|
|
1649
|
+
initialFindings,
|
|
1650
|
+
executions,
|
|
1651
|
+
finalFindings,
|
|
1652
|
+
summary,
|
|
1653
|
+
exitDisposition,
|
|
1654
|
+
};
|
|
1655
|
+
}
|
|
1656
|
+
/**
|
|
1657
|
+
* Format the fix run result as user-readable text.
|
|
1658
|
+
*/
|
|
1659
|
+
function formatFixRunText(fixRun) {
|
|
1660
|
+
const lines = [];
|
|
1661
|
+
const hasIssues = fixRun.finalFindings.some((f) => f.status === 'warn' || f.status === 'fail');
|
|
1662
|
+
if (fixRun.executions.length === 0 && !hasIssues) {
|
|
1663
|
+
lines.push(chalk.green('\n✓ No remediation needed — all checked items are already compliant.'));
|
|
1664
|
+
return lines.join('\n');
|
|
1665
|
+
}
|
|
1666
|
+
if (fixRun.executions.length === 0 && hasIssues) {
|
|
1667
|
+
lines.push(chalk.yellow('\n⚠ No automatic remediation available. Review the findings above for manual action.'));
|
|
1668
|
+
return lines.join('\n');
|
|
1669
|
+
}
|
|
1670
|
+
lines.push(chalk.bold('\nRemediation Summary:'));
|
|
1671
|
+
for (const ex of fixRun.executions) {
|
|
1672
|
+
const finding = fixRun.initialFindings.find((f) => f.id === ex.findingId);
|
|
1673
|
+
const name = finding?.name ?? ex.findingId;
|
|
1674
|
+
let icon;
|
|
1675
|
+
let outcomeLabel;
|
|
1676
|
+
switch (ex.outcome) {
|
|
1677
|
+
case 'fixed':
|
|
1678
|
+
icon = chalk.green('✓');
|
|
1679
|
+
outcomeLabel = chalk.green('fixed');
|
|
1680
|
+
break;
|
|
1681
|
+
case 'already-compliant':
|
|
1682
|
+
icon = chalk.green('✓');
|
|
1683
|
+
outcomeLabel = chalk.green('already compliant');
|
|
1684
|
+
break;
|
|
1685
|
+
case 'skipped':
|
|
1686
|
+
icon = chalk.yellow('→');
|
|
1687
|
+
outcomeLabel = chalk.yellow('skipped');
|
|
1688
|
+
break;
|
|
1689
|
+
default:
|
|
1690
|
+
icon = chalk.red('✗');
|
|
1691
|
+
outcomeLabel = chalk.red('requires manual action');
|
|
1692
|
+
}
|
|
1693
|
+
lines.push(` ${icon} ${chalk.white(name)}: ${outcomeLabel}`);
|
|
1694
|
+
lines.push(` ${chalk.dim('Reason:')} ${chalk.dim(ex.reason)}`);
|
|
1695
|
+
if (ex.changedFiles && ex.changedFiles.length > 0) {
|
|
1696
|
+
lines.push(` ${chalk.dim('Changed:')} ${chalk.dim(ex.changedFiles.join(', '))}`);
|
|
1697
|
+
}
|
|
1698
|
+
if (ex.backupPath) {
|
|
1699
|
+
lines.push(` ${chalk.dim('Backup:')} ${chalk.dim(ex.backupPath)}`);
|
|
1700
|
+
}
|
|
1701
|
+
// Show manual fallback for requires-manual-action
|
|
1702
|
+
if (ex.outcome === 'requires-manual-action') {
|
|
1703
|
+
const action = REMEDIATION_REGISTRY.get(ex.remediationKey);
|
|
1704
|
+
if (action && action.manualFallback.length > 0) {
|
|
1705
|
+
lines.push(` ${chalk.dim('Manual steps:')}`);
|
|
1706
|
+
for (const step of action.manualFallback) {
|
|
1707
|
+
lines.push(` ${chalk.dim('·')} ${chalk.dim(step)}`);
|
|
1708
|
+
}
|
|
1709
|
+
}
|
|
1710
|
+
}
|
|
1711
|
+
}
|
|
1712
|
+
// Overall disposition
|
|
1713
|
+
lines.push('');
|
|
1714
|
+
const { summary, exitDisposition } = fixRun;
|
|
1715
|
+
lines.push(chalk.bold('Fix Run Result:'));
|
|
1716
|
+
if (summary.fixed > 0) {
|
|
1717
|
+
lines.push(` ${chalk.green('✓')} ${summary.fixed} fixed`);
|
|
1718
|
+
}
|
|
1719
|
+
if (summary.alreadyCompliant > 0) {
|
|
1720
|
+
lines.push(` ${chalk.green('✓')} ${summary.alreadyCompliant} already compliant`);
|
|
1721
|
+
}
|
|
1722
|
+
if (summary.skipped > 0) {
|
|
1723
|
+
lines.push(` ${chalk.yellow('→')} ${summary.skipped} skipped`);
|
|
1724
|
+
}
|
|
1725
|
+
if (summary.requiresManualAction > 0) {
|
|
1726
|
+
lines.push(` ${chalk.red('✗')} ${summary.requiresManualAction} require manual action`);
|
|
1727
|
+
}
|
|
1728
|
+
const dispositionColour = exitDisposition === 'success'
|
|
1729
|
+
? chalk.green
|
|
1730
|
+
: exitDisposition === 'repaired-with-warnings'
|
|
1731
|
+
? chalk.yellow
|
|
1732
|
+
: chalk.red;
|
|
1733
|
+
lines.push(`\n ${dispositionColour('Exit status:')} ${dispositionColour(exitDisposition)}`);
|
|
1734
|
+
return lines.join('\n');
|
|
811
1735
|
}
|
|
812
1736
|
/**
|
|
813
1737
|
* Doctor command implementation
|
|
814
1738
|
*/
|
|
815
1739
|
export async function doctorCommand(overlaysConfig, overlaysDir, options) {
|
|
816
|
-
|
|
1740
|
+
// ── Validate mutually exclusive source flags ───────────────────────────
|
|
1741
|
+
if (options.fromManifest && options.fromProject) {
|
|
1742
|
+
console.error(chalk.red('✗ Error: --from-manifest and --from-project cannot be used together'));
|
|
1743
|
+
process.exit(1);
|
|
1744
|
+
}
|
|
1745
|
+
// ── Resolve working directory (--project-root) ────────────────────────
|
|
1746
|
+
const workingDir = options.projectRoot ? path.resolve(options.projectRoot) : process.cwd();
|
|
1747
|
+
if (options.projectRoot) {
|
|
1748
|
+
if (!fs.existsSync(workingDir)) {
|
|
1749
|
+
console.error(chalk.red(`✗ Project root not found: ${workingDir}`));
|
|
1750
|
+
process.exit(1);
|
|
1751
|
+
}
|
|
1752
|
+
if (!fs.statSync(workingDir).isDirectory()) {
|
|
1753
|
+
console.error(chalk.red(`✗ Project root is not a directory: ${workingDir}`));
|
|
1754
|
+
process.exit(1);
|
|
1755
|
+
}
|
|
1756
|
+
}
|
|
1757
|
+
// ── Resolve outputPath and optional explicit manifest path ─────────────
|
|
1758
|
+
let outputPath;
|
|
1759
|
+
let explicitManifestPath;
|
|
1760
|
+
if (options.fromManifest) {
|
|
1761
|
+
// Resolve manifest path (absolute or relative to workingDir)
|
|
1762
|
+
const resolvedManifest = path.resolve(workingDir, options.fromManifest);
|
|
1763
|
+
if (!fs.existsSync(resolvedManifest)) {
|
|
1764
|
+
console.error(chalk.red(`✗ Could not find manifest file: ${resolvedManifest}`));
|
|
1765
|
+
process.exit(1);
|
|
1766
|
+
}
|
|
1767
|
+
explicitManifestPath = resolvedManifest;
|
|
1768
|
+
// Derive outputPath from manifest's own outputPath field, relative to manifest's directory
|
|
1769
|
+
try {
|
|
1770
|
+
const raw = JSON.parse(fs.readFileSync(resolvedManifest, 'utf8'));
|
|
1771
|
+
const manifestOutputPath = typeof raw.outputPath === 'string' ? raw.outputPath : '.devcontainer';
|
|
1772
|
+
outputPath = path.resolve(path.dirname(resolvedManifest), manifestOutputPath);
|
|
1773
|
+
}
|
|
1774
|
+
catch {
|
|
1775
|
+
// If the manifest is unparseable, use its directory as outputPath
|
|
1776
|
+
outputPath = path.dirname(resolvedManifest);
|
|
1777
|
+
}
|
|
1778
|
+
}
|
|
1779
|
+
else if (options.fromProject) {
|
|
1780
|
+
// Load the repository project file (superposition.yml / .superposition.yml)
|
|
1781
|
+
let projectConfig;
|
|
1782
|
+
try {
|
|
1783
|
+
projectConfig = loadProjectConfig(overlaysConfig, workingDir);
|
|
1784
|
+
}
|
|
1785
|
+
catch (err) {
|
|
1786
|
+
console.error(chalk.red(`✗ Failed to load project config: ${err instanceof Error ? err.message : String(err)}`));
|
|
1787
|
+
process.exit(1);
|
|
1788
|
+
}
|
|
1789
|
+
if (!projectConfig) {
|
|
1790
|
+
console.error(chalk.red('✗ Could not find project file'));
|
|
1791
|
+
console.error(chalk.gray(' Searched for: .superposition.yml, superposition.yml'));
|
|
1792
|
+
console.error(chalk.gray(' Use --from-project in a repository that has a project config file, or use --from-manifest <path> instead'));
|
|
1793
|
+
process.exit(1);
|
|
1794
|
+
}
|
|
1795
|
+
outputPath = path.resolve(workingDir, projectConfig.selection.outputPath || '.devcontainer');
|
|
1796
|
+
}
|
|
1797
|
+
else {
|
|
1798
|
+
outputPath = path.resolve(workingDir, options.output || './.devcontainer');
|
|
1799
|
+
}
|
|
817
1800
|
if (!options.json) {
|
|
818
1801
|
console.log('\n' +
|
|
819
1802
|
boxen(chalk.bold('🔍 Running diagnostics...'), {
|
|
@@ -823,40 +1806,55 @@ export async function doctorCommand(overlaysConfig, overlaysDir, options) {
|
|
|
823
1806
|
}));
|
|
824
1807
|
}
|
|
825
1808
|
// Run all checks
|
|
826
|
-
const environmentChecks = checkEnvironment(outputPath);
|
|
1809
|
+
const environmentChecks = checkEnvironment(outputPath, explicitManifestPath);
|
|
827
1810
|
const overlayChecks = checkOverlays(overlaysDir);
|
|
828
|
-
const manifestChecks = checkManifest(outputPath);
|
|
1811
|
+
const manifestChecks = checkManifest(outputPath, explicitManifestPath);
|
|
829
1812
|
const mergeChecks = checkMergeStrategy(outputPath);
|
|
830
|
-
const manifestPath = path.join(outputPath, 'superposition.json');
|
|
1813
|
+
const manifestPath = explicitManifestPath ?? path.join(outputPath, 'superposition.json');
|
|
831
1814
|
const portChecks = checkPorts(overlaysConfig, manifestPath);
|
|
1815
|
+
const driftChecks = checkProjectFileDrift(overlaysConfig, workingDir, manifestPath);
|
|
832
1816
|
// Generate report
|
|
833
|
-
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
|
|
1817
|
+
const report = generateReport(environmentChecks, overlayChecks, manifestChecks, mergeChecks, portChecks, driftChecks);
|
|
1818
|
+
if (options.fix) {
|
|
1819
|
+
// ── Fix flow ──────────────────────────────────────────────────────────
|
|
1820
|
+
if (!options.json) {
|
|
1821
|
+
// Print diagnostic findings first (as normal)
|
|
1822
|
+
console.log(formatAsText(report));
|
|
1823
|
+
}
|
|
1824
|
+
const fixRun = await executeFixRun(report, outputPath, overlaysConfig, overlaysDir, options.json ?? false, explicitManifestPath, workingDir);
|
|
1825
|
+
if (options.json) {
|
|
1826
|
+
console.log(JSON.stringify(fixRun, null, 2));
|
|
1827
|
+
}
|
|
1828
|
+
else {
|
|
1829
|
+
console.log(formatFixRunText(fixRun));
|
|
1830
|
+
console.log('');
|
|
1831
|
+
}
|
|
1832
|
+
if (fixRun.exitDisposition === 'unresolved-failures') {
|
|
1833
|
+
process.exit(1);
|
|
1834
|
+
}
|
|
1835
|
+
else {
|
|
1836
|
+
process.exit(0);
|
|
1837
|
+
}
|
|
857
1838
|
}
|
|
858
1839
|
else {
|
|
859
|
-
|
|
1840
|
+
// ── Normal diagnostic output (unchanged) ─────────────────────────────
|
|
1841
|
+
if (options.json) {
|
|
1842
|
+
console.log(JSON.stringify(report, null, 2));
|
|
1843
|
+
}
|
|
1844
|
+
else {
|
|
1845
|
+
console.log(formatAsText(report));
|
|
1846
|
+
}
|
|
1847
|
+
// Exit with appropriate code
|
|
1848
|
+
const hasErrors = report.summary.errors > 0;
|
|
1849
|
+
if (!options.json) {
|
|
1850
|
+
console.log(''); // Empty line at end
|
|
1851
|
+
}
|
|
1852
|
+
if (hasErrors) {
|
|
1853
|
+
process.exit(1);
|
|
1854
|
+
}
|
|
1855
|
+
else {
|
|
1856
|
+
process.exit(0);
|
|
1857
|
+
}
|
|
860
1858
|
}
|
|
861
1859
|
}
|
|
862
1860
|
//# sourceMappingURL=doctor.js.map
|