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.
Files changed (238) hide show
  1. package/README.md +24 -15
  2. package/dist/scripts/init.js +1 -1534
  3. package/dist/scripts/init.js.map +1 -1
  4. package/dist/tool/cli/args.d.ts +20 -0
  5. package/dist/tool/cli/args.d.ts.map +1 -0
  6. package/dist/tool/cli/args.js +325 -0
  7. package/dist/tool/cli/args.js.map +1 -0
  8. package/dist/tool/cli/run.d.ts +2 -0
  9. package/dist/tool/cli/run.d.ts.map +1 -0
  10. package/dist/tool/cli/run.js +318 -0
  11. package/dist/tool/cli/run.js.map +1 -0
  12. package/dist/tool/commands/adopt.d.ts.map +1 -1
  13. package/dist/tool/commands/adopt.js +1 -27
  14. package/dist/tool/commands/adopt.js.map +1 -1
  15. package/dist/tool/commands/doctor.d.ts +3 -0
  16. package/dist/tool/commands/doctor.d.ts.map +1 -1
  17. package/dist/tool/commands/doctor.js +1068 -70
  18. package/dist/tool/commands/doctor.js.map +1 -1
  19. package/dist/tool/commands/explain.d.ts.map +1 -1
  20. package/dist/tool/commands/explain.js +18 -0
  21. package/dist/tool/commands/explain.js.map +1 -1
  22. package/dist/tool/commands/migrate.d.ts +7 -0
  23. package/dist/tool/commands/migrate.d.ts.map +1 -0
  24. package/dist/tool/commands/migrate.js +52 -0
  25. package/dist/tool/commands/migrate.js.map +1 -0
  26. package/dist/tool/questionnaire/answers.d.ts +16 -0
  27. package/dist/tool/questionnaire/answers.d.ts.map +1 -0
  28. package/dist/tool/questionnaire/answers.js +102 -0
  29. package/dist/tool/questionnaire/answers.js.map +1 -0
  30. package/dist/tool/questionnaire/composer.d.ts +3 -3
  31. package/dist/tool/questionnaire/composer.d.ts.map +1 -1
  32. package/dist/tool/questionnaire/composer.js +902 -37
  33. package/dist/tool/questionnaire/composer.js.map +1 -1
  34. package/dist/tool/questionnaire/presets.d.ts +60 -0
  35. package/dist/tool/questionnaire/presets.d.ts.map +1 -0
  36. package/dist/tool/questionnaire/presets.js +164 -0
  37. package/dist/tool/questionnaire/presets.js.map +1 -0
  38. package/dist/tool/questionnaire/questionnaire.d.ts +10 -0
  39. package/dist/tool/questionnaire/questionnaire.d.ts.map +1 -0
  40. package/dist/tool/questionnaire/questionnaire.js +580 -0
  41. package/dist/tool/questionnaire/questionnaire.js.map +1 -0
  42. package/dist/tool/schema/manifest-migrations.d.ts +5 -0
  43. package/dist/tool/schema/manifest-migrations.d.ts.map +1 -1
  44. package/dist/tool/schema/manifest-migrations.js +45 -0
  45. package/dist/tool/schema/manifest-migrations.js.map +1 -1
  46. package/dist/tool/schema/overlay-loader.d.ts.map +1 -1
  47. package/dist/tool/schema/overlay-loader.js +25 -0
  48. package/dist/tool/schema/overlay-loader.js.map +1 -1
  49. package/dist/tool/schema/project-config.d.ts +14 -2
  50. package/dist/tool/schema/project-config.d.ts.map +1 -1
  51. package/dist/tool/schema/project-config.js +277 -34
  52. package/dist/tool/schema/project-config.js.map +1 -1
  53. package/dist/tool/schema/target-rules.d.ts +78 -0
  54. package/dist/tool/schema/target-rules.d.ts.map +1 -0
  55. package/dist/tool/schema/target-rules.js +367 -0
  56. package/dist/tool/schema/target-rules.js.map +1 -0
  57. package/dist/tool/schema/types.d.ts +123 -12
  58. package/dist/tool/schema/types.d.ts.map +1 -1
  59. package/dist/tool/utils/merge.d.ts.map +1 -1
  60. package/dist/tool/utils/merge.js +9 -0
  61. package/dist/tool/utils/merge.js.map +1 -1
  62. package/dist/tool/utils/parameters.d.ts +76 -0
  63. package/dist/tool/utils/parameters.d.ts.map +1 -0
  64. package/dist/tool/utils/parameters.js +125 -0
  65. package/dist/tool/utils/parameters.js.map +1 -0
  66. package/dist/tool/utils/paths.d.ts +2 -0
  67. package/dist/tool/utils/paths.d.ts.map +1 -0
  68. package/dist/tool/utils/paths.js +31 -0
  69. package/dist/tool/utils/paths.js.map +1 -0
  70. package/docs/creating-overlays.md +151 -2
  71. package/docs/deployment-targets.md +88 -56
  72. package/docs/examples.md +20 -17
  73. package/docs/filesystem-contract.md +5 -0
  74. package/docs/minimal-and-editor.md +65 -5
  75. package/docs/overlay-imports.md +202 -101
  76. package/docs/overlays.md +162 -34
  77. package/docs/quick-reference.md +99 -0
  78. package/docs/specs/003-mkdocs2-overlay/spec.md +114 -0
  79. package/docs/specs/004-doctor-fix/spec.md +70 -0
  80. package/docs/specs/005-cuda-overlay/spec.md +101 -0
  81. package/docs/specs/006-rocm-overlay/spec.md +109 -0
  82. package/docs/specs/007-init-project-file/spec.md +66 -0
  83. package/docs/specs/007-target-aware-generation/spec.md +126 -0
  84. package/docs/specs/008-project-file-canonical/spec.md +83 -0
  85. package/docs/specs/009-project-env/spec.md +147 -0
  86. package/docs/specs/010-compose-env-materialization/spec.md +130 -0
  87. package/docs/specs/011-overlay-parameters/spec.md +235 -0
  88. package/overlays/.shared/README.md +105 -21
  89. package/overlays/.shared/compose/common-healthchecks.md +60 -0
  90. package/overlays/.shared/compose/nvidia-gpu-devcontainer.yml +22 -0
  91. package/overlays/.shared/vscode/recommended-extensions.json +15 -11
  92. package/overlays/alertmanager/setup.sh +4 -19
  93. package/overlays/alertmanager/verify.sh +8 -9
  94. package/overlays/all/README.md +43 -0
  95. package/overlays/all/devcontainer.patch.json +6 -0
  96. package/overlays/all/overlay.yml +14 -0
  97. package/overlays/amp/setup.sh +5 -0
  98. package/overlays/bun/setup.sh +10 -1
  99. package/overlays/bun/verify.sh +6 -1
  100. package/overlays/claude-code/setup.sh +5 -0
  101. package/overlays/cloudflared/setup.sh +9 -12
  102. package/overlays/codex/README.md +9 -6
  103. package/overlays/codex/devcontainer.patch.json +7 -1
  104. package/overlays/codex/setup.sh +5 -0
  105. package/overlays/codex/verify.sh +8 -0
  106. package/overlays/comfyui/.env.example +34 -0
  107. package/overlays/comfyui/README.md +342 -0
  108. package/overlays/comfyui/devcontainer.patch.json +15 -0
  109. package/overlays/comfyui/docker-compose.yml +39 -0
  110. package/overlays/comfyui/overlay.yml +20 -0
  111. package/overlays/comfyui/setup.sh +36 -0
  112. package/overlays/comfyui/verify.sh +103 -0
  113. package/overlays/commitlint/setup.sh +5 -0
  114. package/overlays/cuda/README.md +179 -0
  115. package/overlays/cuda/devcontainer.patch.json +7 -0
  116. package/overlays/cuda/overlay.yml +17 -0
  117. package/overlays/cuda/setup.sh +32 -0
  118. package/overlays/cuda/verify.sh +38 -0
  119. package/overlays/devcontainer-cli/README.md +50 -0
  120. package/overlays/devcontainer-cli/devcontainer.patch.json +13 -0
  121. package/overlays/devcontainer-cli/overlay.yml +16 -0
  122. package/overlays/devcontainer-cli/setup.sh +14 -0
  123. package/overlays/direnv/devcontainer.patch.json +6 -0
  124. package/overlays/direnv/setup.sh +7 -6
  125. package/overlays/dotnet/setup.sh +14 -7
  126. package/overlays/duckdb/devcontainer.patch.json +1 -2
  127. package/overlays/gcloud/devcontainer.patch.json +0 -6
  128. package/overlays/gcloud/setup.sh +51 -0
  129. package/overlays/gemini-cli/setup.sh +5 -0
  130. package/overlays/git-helpers/devcontainer.patch.json +2 -1
  131. package/overlays/go/setup.sh +15 -14
  132. package/overlays/jaeger/overlay.yml +2 -0
  133. package/overlays/just/setup.sh +5 -17
  134. package/overlays/k3d/README.md +201 -0
  135. package/overlays/k3d/devcontainer.patch.json +9 -0
  136. package/overlays/k3d/overlay.yml +19 -0
  137. package/overlays/k3d/setup.sh +34 -0
  138. package/overlays/k3d/verify.sh +38 -0
  139. package/overlays/keycloak/docker-compose.yml +6 -4
  140. package/overlays/keycloak/verify.sh +4 -3
  141. package/overlays/kind/devcontainer.patch.json +1 -2
  142. package/overlays/kind/setup.sh +8 -17
  143. package/overlays/minio/setup.sh +10 -18
  144. package/overlays/mkdocs/overlay.yml +2 -1
  145. package/overlays/mkdocs2/README.md +135 -0
  146. package/overlays/mkdocs2/devcontainer.patch.json +19 -0
  147. package/overlays/mkdocs2/overlay.yml +17 -0
  148. package/overlays/mkdocs2/setup.sh +67 -0
  149. package/overlays/mkdocs2/verify.sh +35 -0
  150. package/overlays/modern-cli-tools/devcontainer.patch.json +7 -1
  151. package/overlays/modern-cli-tools/setup.sh +21 -71
  152. package/overlays/mongodb/devcontainer.patch.json +0 -6
  153. package/overlays/mongodb/setup.sh +59 -0
  154. package/overlays/mysql/verify.sh +4 -3
  155. package/overlays/nats/.env.example +1 -1
  156. package/overlays/nats/README.md +1 -1
  157. package/overlays/nats/docker-compose.yml +1 -1
  158. package/overlays/ngrok/setup.sh +9 -6
  159. package/overlays/nodejs/setup.sh +5 -0
  160. package/overlays/ollama/.env.example +14 -0
  161. package/overlays/ollama/README.md +325 -0
  162. package/overlays/ollama/devcontainer.patch.json +14 -0
  163. package/overlays/ollama/docker-compose.yml +24 -0
  164. package/overlays/ollama/overlay.yml +22 -0
  165. package/overlays/ollama/setup.sh +106 -0
  166. package/overlays/ollama/verify.sh +99 -0
  167. package/overlays/open-webui/.env.example +5 -0
  168. package/overlays/open-webui/README.md +162 -0
  169. package/overlays/open-webui/devcontainer.patch.json +14 -0
  170. package/overlays/open-webui/docker-compose.yml +23 -0
  171. package/overlays/open-webui/overlay.yml +38 -0
  172. package/overlays/openapi-tools/devcontainer.patch.json +1 -2
  173. package/overlays/openapi-tools/setup.sh +9 -8
  174. package/overlays/opencode/setup.sh +5 -0
  175. package/overlays/otel-collector/overlay.yml +2 -0
  176. package/overlays/otel-collector/setup.sh +3 -16
  177. package/overlays/otel-demo-nodejs/verify.sh +8 -9
  178. package/overlays/otel-demo-python/verify.sh +16 -10
  179. package/overlays/pandoc/README.md +22 -15
  180. package/overlays/pandoc/devcontainer.patch.json +6 -2
  181. package/overlays/pandoc/setup.sh +217 -18
  182. package/overlays/pandoc/verify.sh +16 -4
  183. package/overlays/pgvector/.env.example +6 -0
  184. package/overlays/pgvector/README.md +215 -0
  185. package/overlays/pgvector/devcontainer.patch.json +23 -0
  186. package/overlays/pgvector/docker-compose.yml +32 -0
  187. package/overlays/pgvector/overlay.yml +44 -0
  188. package/overlays/playwright/devcontainer.patch.json +3 -1
  189. package/overlays/playwright/setup.sh +37 -0
  190. package/overlays/postgres/.env.example +5 -5
  191. package/overlays/postgres/devcontainer.patch.json +4 -4
  192. package/overlays/postgres/docker-compose.yml +15 -5
  193. package/overlays/postgres/overlay.yml +19 -1
  194. package/overlays/powershell/setup.sh +49 -13
  195. package/overlays/pre-commit/setup.sh +12 -3
  196. package/overlays/prometheus/overlay.yml +2 -0
  197. package/overlays/promtail/verify.sh +16 -10
  198. package/overlays/pulumi/devcontainer.patch.json +1 -1
  199. package/overlays/python/setup.sh +28 -9
  200. package/overlays/python/verify.sh +4 -2
  201. package/overlays/qdrant/.env.example +4 -0
  202. package/overlays/qdrant/README.md +216 -0
  203. package/overlays/qdrant/devcontainer.patch.json +20 -0
  204. package/overlays/qdrant/docker-compose.yml +25 -0
  205. package/overlays/qdrant/overlay.yml +40 -0
  206. package/overlays/redpanda/docker-compose.yml +3 -5
  207. package/overlays/rocm/README.md +227 -0
  208. package/overlays/rocm/devcontainer.patch.json +4 -0
  209. package/overlays/rocm/overlay.yml +17 -0
  210. package/overlays/rocm/setup.sh +45 -0
  211. package/overlays/rocm/verify.sh +47 -0
  212. package/overlays/rust/setup.sh +11 -18
  213. package/overlays/skaffold/README.md +256 -0
  214. package/overlays/skaffold/devcontainer.patch.json +9 -0
  215. package/overlays/skaffold/overlay.yml +20 -0
  216. package/overlays/skaffold/setup.sh +33 -0
  217. package/overlays/skaffold/verify.sh +24 -0
  218. package/overlays/spec-kit/setup.sh +7 -3
  219. package/overlays/sqlite/setup.sh +14 -14
  220. package/overlays/sqlserver/docker-compose.yml +3 -3
  221. package/overlays/sqlserver/verify.sh +22 -5
  222. package/overlays/tempo/verify.sh +16 -10
  223. package/overlays/tilt/devcontainer.patch.json +1 -2
  224. package/overlays/tilt/setup.sh +14 -4
  225. package/overlays/windsurf-cli/setup.sh +27 -4
  226. package/overlays/windsurf-cli/verify.sh +13 -3
  227. package/package.json +4 -2
  228. package/templates/scripts/setup-utils.sh +228 -0
  229. package/tool/schema/config.schema.json +141 -9
  230. package/tool/schema/overlay-manifest.schema.json +38 -0
  231. package/overlays/.shared/compose/common-healthchecks.yml +0 -38
  232. /package/overlays/otel-demo-nodejs/{Dockerfile-otel-demo-nodejs → Dockerfile} +0 -0
  233. /package/overlays/otel-demo-nodejs/{package-otel-demo-nodejs.json → package.json} +0 -0
  234. /package/overlays/otel-demo-nodejs/{server-otel-demo-nodejs.js → server.js} +0 -0
  235. /package/overlays/otel-demo-nodejs/{tracing-otel-demo-nodejs.js → tracing.js} +0 -0
  236. /package/overlays/otel-demo-python/{Dockerfile-otel-demo-python → Dockerfile} +0 -0
  237. /package/overlays/otel-demo-python/{app-otel-demo-python.py → app.py} +0 -0
  238. /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: ok ? 'pass' : 'fail',
47
- message: ok
48
- ? `${nodeVersion} (>= ${requiredVersion} required)`
49
- : `${nodeVersion} - requires >= ${requiredVersion}`,
50
- details: ok
51
- ? undefined
52
- : [
53
- 'Update Node.js to version 20 or later',
54
- 'Visit https://nodejs.org/ to download the latest version',
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', { encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'] });
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
- * Generate doctor report
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 generateReport(environmentChecks, overlayChecks, manifestChecks, mergeChecks, portChecks) {
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
- * Apply automatic fixes
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
- async function applyFixes(report, outputPath) {
793
- console.log(chalk.bold('\nApplying fixes...\n'));
794
- const fixableChecks = [
795
- ...report.environment,
796
- ...report.overlays,
797
- ...report.manifest,
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
- for (const check of fixableChecks) {
806
- console.log(` ${chalk.cyan('→')} Fixing: ${check.name}...`);
807
- // Currently, we don't have any auto-fixable issues
808
- // This is a placeholder for future fix implementations
809
- console.log(` ${chalk.dim('Manual intervention required')}`);
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
- const outputPath = options.output || './.devcontainer';
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
- // Output results
835
- if (options.json) {
836
- console.log(JSON.stringify(report, null, 2));
837
- }
838
- else {
839
- console.log(formatAsText(report));
840
- }
841
- // Apply fixes if requested
842
- if (options.fix && !options.json) {
843
- await applyFixes(report, outputPath);
844
- }
845
- // Exit with appropriate code
846
- const hasErrors = report.summary.errors > 0;
847
- const hasWarnings = report.summary.warnings > 0;
848
- if (!options.json) {
849
- console.log(''); // Empty line at end
850
- }
851
- // Exit with error if there are critical failures
852
- if (hasErrors) {
853
- process.exit(1);
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
- process.exit(0);
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