container-superposition 0.1.6 → 0.1.7

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