container-superposition 0.1.1 → 0.1.4

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 (178) hide show
  1. package/README.md +569 -8
  2. package/dist/scripts/init.js +436 -254
  3. package/dist/scripts/init.js.map +1 -1
  4. package/dist/tool/commands/doctor.d.ts +15 -0
  5. package/dist/tool/commands/doctor.d.ts.map +1 -0
  6. package/dist/tool/commands/doctor.js +862 -0
  7. package/dist/tool/commands/doctor.js.map +1 -0
  8. package/dist/tool/commands/explain.d.ts +13 -0
  9. package/dist/tool/commands/explain.d.ts.map +1 -0
  10. package/dist/tool/commands/explain.js +299 -0
  11. package/dist/tool/commands/explain.js.map +1 -0
  12. package/dist/tool/commands/list.d.ts +16 -0
  13. package/dist/tool/commands/list.d.ts.map +1 -0
  14. package/dist/tool/commands/list.js +121 -0
  15. package/dist/tool/commands/list.js.map +1 -0
  16. package/dist/tool/commands/plan.d.ts +67 -0
  17. package/dist/tool/commands/plan.d.ts.map +1 -0
  18. package/dist/tool/commands/plan.js +851 -0
  19. package/dist/tool/commands/plan.js.map +1 -0
  20. package/dist/tool/questionnaire/composer.d.ts +16 -2
  21. package/dist/tool/questionnaire/composer.d.ts.map +1 -1
  22. package/dist/tool/questionnaire/composer.js +411 -200
  23. package/dist/tool/questionnaire/composer.js.map +1 -1
  24. package/dist/tool/readme/markdown-parser.d.ts.map +1 -1
  25. package/dist/tool/readme/markdown-parser.js.map +1 -1
  26. package/dist/tool/readme/readme-generator.d.ts.map +1 -1
  27. package/dist/tool/readme/readme-generator.js +11 -6
  28. package/dist/tool/readme/readme-generator.js.map +1 -1
  29. package/dist/tool/schema/deployment-targets.d.ts +77 -0
  30. package/dist/tool/schema/deployment-targets.d.ts.map +1 -0
  31. package/dist/tool/schema/deployment-targets.js +91 -0
  32. package/dist/tool/schema/deployment-targets.js.map +1 -0
  33. package/dist/tool/schema/manifest-migrations.d.ts +51 -0
  34. package/dist/tool/schema/manifest-migrations.d.ts.map +1 -0
  35. package/dist/tool/schema/manifest-migrations.js +159 -0
  36. package/dist/tool/schema/manifest-migrations.js.map +1 -0
  37. package/dist/tool/schema/overlay-loader.d.ts +1 -1
  38. package/dist/tool/schema/overlay-loader.d.ts.map +1 -1
  39. package/dist/tool/schema/overlay-loader.js +42 -14
  40. package/dist/tool/schema/overlay-loader.js.map +1 -1
  41. package/dist/tool/schema/types.d.ts +62 -2
  42. package/dist/tool/schema/types.d.ts.map +1 -1
  43. package/dist/tool/utils/gitignore.d.ts +15 -0
  44. package/dist/tool/utils/gitignore.d.ts.map +1 -0
  45. package/dist/tool/utils/gitignore.js +41 -0
  46. package/dist/tool/utils/gitignore.js.map +1 -0
  47. package/dist/tool/utils/merge.d.ts +134 -0
  48. package/dist/tool/utils/merge.d.ts.map +1 -0
  49. package/dist/tool/utils/merge.js +277 -0
  50. package/dist/tool/utils/merge.js.map +1 -0
  51. package/dist/tool/utils/port-utils.d.ts +29 -0
  52. package/dist/tool/utils/port-utils.d.ts.map +1 -0
  53. package/dist/tool/utils/port-utils.js +128 -0
  54. package/dist/tool/utils/port-utils.js.map +1 -0
  55. package/dist/tool/utils/services-export.d.ts +14 -0
  56. package/dist/tool/utils/services-export.d.ts.map +1 -0
  57. package/dist/tool/utils/services-export.js +478 -0
  58. package/dist/tool/utils/services-export.js.map +1 -0
  59. package/dist/tool/utils/summary.d.ts +69 -0
  60. package/dist/tool/utils/summary.d.ts.map +1 -0
  61. package/dist/tool/utils/summary.js +260 -0
  62. package/dist/tool/utils/summary.js.map +1 -0
  63. package/dist/tool/utils/version.d.ts +9 -0
  64. package/dist/tool/utils/version.d.ts.map +1 -0
  65. package/dist/tool/utils/version.js +32 -0
  66. package/dist/tool/utils/version.js.map +1 -0
  67. package/docs/architecture.md +25 -21
  68. package/docs/deployment-targets.md +150 -0
  69. package/docs/discovery-commands.md +442 -0
  70. package/docs/merge-strategy.md +700 -0
  71. package/docs/minimal-and-editor.md +265 -0
  72. package/docs/overlay-imports.md +209 -0
  73. package/docs/overlay-manifest-refactoring.md +2 -2
  74. package/docs/overlay-metadata-archive.md +1 -1
  75. package/docs/overlays.md +139 -28
  76. package/docs/presets-architecture.md +3 -3
  77. package/docs/presets.md +1 -1
  78. package/docs/publishing.md +36 -35
  79. package/docs/team-workflow.md +540 -0
  80. package/overlays/.presets/data-engineering.yml +392 -0
  81. package/overlays/.presets/event-sourced-service.yml +262 -0
  82. package/overlays/.presets/frontend.yml +287 -0
  83. package/overlays/.presets/k8s-operator-dev.yml +462 -0
  84. package/overlays/{presets → .presets}/microservice.yml +32 -6
  85. package/overlays/.presets/web-api.yml +129 -0
  86. package/overlays/.registry/README.md +1 -1
  87. package/overlays/.registry/deployment-targets.yml +54 -0
  88. package/overlays/.shared/README.md +43 -0
  89. package/overlays/.shared/compose/common-healthchecks.yml +38 -0
  90. package/overlays/.shared/otel/instrumentation.env +20 -0
  91. package/overlays/.shared/otel/otel-base-config.yaml +30 -0
  92. package/overlays/.shared/vscode/recommended-extensions.json +14 -0
  93. package/overlays/README.md +1 -1
  94. package/overlays/cloudflared/README.md +190 -0
  95. package/overlays/cloudflared/devcontainer.patch.json +3 -0
  96. package/overlays/cloudflared/overlay.yml +15 -0
  97. package/overlays/cloudflared/setup.sh +49 -0
  98. package/overlays/cloudflared/verify.sh +21 -0
  99. package/overlays/codex/overlay.yml +1 -0
  100. package/overlays/direnv/README.md +6 -4
  101. package/overlays/direnv/setup.sh +0 -12
  102. package/overlays/duckdb/README.md +274 -0
  103. package/overlays/duckdb/devcontainer.patch.json +10 -0
  104. package/overlays/duckdb/overlay.yml +17 -0
  105. package/overlays/duckdb/setup.sh +45 -0
  106. package/overlays/duckdb/verify.sh +32 -0
  107. package/overlays/git-helpers/overlay.yml +1 -0
  108. package/overlays/grafana/README.md +5 -5
  109. package/overlays/grafana/dashboard-provider.yml +1 -1
  110. package/overlays/grafana/docker-compose.yml +2 -2
  111. package/overlays/grafana/overlay.yml +6 -1
  112. package/overlays/grpc-tools/README.md +242 -0
  113. package/overlays/grpc-tools/devcontainer.patch.json +14 -0
  114. package/overlays/grpc-tools/overlay.yml +14 -0
  115. package/overlays/grpc-tools/setup.sh +57 -0
  116. package/overlays/grpc-tools/verify.sh +47 -0
  117. package/overlays/jaeger/overlay.yml +16 -3
  118. package/overlays/jupyter/.env.example +6 -0
  119. package/overlays/jupyter/README.md +210 -0
  120. package/overlays/jupyter/devcontainer.patch.json +14 -0
  121. package/overlays/jupyter/docker-compose.yml +23 -0
  122. package/overlays/jupyter/overlay.yml +18 -0
  123. package/overlays/jupyter/verify.sh +35 -0
  124. package/overlays/keycloak/.env.example +5 -0
  125. package/overlays/keycloak/README.md +238 -0
  126. package/overlays/keycloak/devcontainer.patch.json +17 -0
  127. package/overlays/keycloak/docker-compose.yml +32 -0
  128. package/overlays/keycloak/overlay.yml +23 -0
  129. package/overlays/keycloak/verify.sh +54 -0
  130. package/overlays/kind/README.md +221 -0
  131. package/overlays/kind/devcontainer.patch.json +10 -0
  132. package/overlays/kind/overlay.yml +18 -0
  133. package/overlays/kind/setup.sh +43 -0
  134. package/overlays/kind/verify.sh +40 -0
  135. package/overlays/localstack/.env.example +6 -0
  136. package/overlays/localstack/README.md +188 -0
  137. package/overlays/localstack/devcontainer.patch.json +21 -0
  138. package/overlays/localstack/docker-compose.yml +25 -0
  139. package/overlays/localstack/overlay.yml +18 -0
  140. package/overlays/localstack/verify.sh +47 -0
  141. package/overlays/loki/overlay.yml +6 -1
  142. package/overlays/mailpit/.env.example +4 -0
  143. package/overlays/mailpit/README.md +191 -0
  144. package/overlays/mailpit/devcontainer.patch.json +20 -0
  145. package/overlays/mailpit/docker-compose.yml +17 -0
  146. package/overlays/mailpit/overlay.yml +26 -0
  147. package/overlays/mailpit/verify.sh +52 -0
  148. package/overlays/modern-cli-tools/overlay.yml +1 -0
  149. package/overlays/mongodb/overlay.yml +12 -2
  150. package/overlays/mysql/overlay.yml +12 -2
  151. package/overlays/nats/overlay.yml +12 -2
  152. package/overlays/ngrok/overlay.yml +2 -1
  153. package/overlays/openapi-tools/README.md +243 -0
  154. package/overlays/openapi-tools/devcontainer.patch.json +10 -0
  155. package/overlays/openapi-tools/overlay.yml +16 -0
  156. package/overlays/openapi-tools/setup.sh +45 -0
  157. package/overlays/openapi-tools/verify.sh +51 -0
  158. package/overlays/otel-collector/overlay.yml.example +26 -0
  159. package/overlays/postgres/overlay.yml +6 -1
  160. package/overlays/prometheus/overlay.yml +6 -1
  161. package/overlays/python/README.md +51 -35
  162. package/overlays/python/devcontainer.patch.json +7 -4
  163. package/overlays/python/setup.sh +50 -23
  164. package/overlays/python/verify.sh +29 -1
  165. package/overlays/rabbitmq/overlay.yml +12 -2
  166. package/overlays/redis/overlay.yml +6 -1
  167. package/overlays/tilt/README.md +259 -0
  168. package/overlays/tilt/devcontainer.patch.json +17 -0
  169. package/overlays/tilt/overlay.yml +19 -0
  170. package/overlays/tilt/setup.sh +25 -0
  171. package/overlays/tilt/verify.sh +24 -0
  172. package/package.json +8 -6
  173. package/tool/README.md +12 -16
  174. package/tool/schema/overlay-manifest.schema.json +64 -4
  175. package/tool/schema/superposition-manifest.schema.json +104 -0
  176. package/overlays/presets/web-api.yml +0 -109
  177. /package/overlays/{presets → .presets}/docs-site.yml +0 -0
  178. /package/overlays/{presets → .presets}/fullstack.yml +0 -0
@@ -0,0 +1,851 @@
1
+ /**
2
+ * Plan command - Preview what will happen before generation
3
+ */
4
+ import * as fs from 'fs';
5
+ import * as path from 'path';
6
+ import { fileURLToPath } from 'url';
7
+ import chalk from 'chalk';
8
+ import boxen from 'boxen';
9
+ import { extractPorts } from '../utils/port-utils.js';
10
+ import { applyOverlay } from '../questionnaire/composer.js';
11
+ // Get __dirname equivalent in ESM
12
+ const __filename = fileURLToPath(import.meta.url);
13
+ const __dirname = path.dirname(__filename);
14
+ // Resolve TEMPLATES_DIR that works in both source and compiled output.
15
+ // Validate each candidate by checking for a known template file.
16
+ const EXPECTED_TEMPLATE_SUBPATH = path.join('compose', '.devcontainer', 'devcontainer.json');
17
+ const TEMPLATES_DIR_CANDIDATES = [
18
+ path.join(__dirname, '..', '..', 'templates'), // From source: tool/commands -> root/templates
19
+ path.join(__dirname, '..', '..', '..', 'templates'), // From dist: dist/tool/commands -> root/templates
20
+ ];
21
+ const TEMPLATES_DIR = TEMPLATES_DIR_CANDIDATES.find((candidate) => fs.existsSync(path.join(candidate, EXPECTED_TEMPLATE_SUBPATH))) ?? TEMPLATES_DIR_CANDIDATES[0];
22
+ /**
23
+ * Compute a line-level LCS diff between two string arrays.
24
+ * Returns an array of edits (equal / insert / delete).
25
+ */
26
+ function computeLineDiff(a, b) {
27
+ const m = a.length;
28
+ const n = b.length;
29
+ // dp[i][j] = length of LCS of a[0..i-1] and b[0..j-1]
30
+ const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
31
+ for (let i = 1; i <= m; i++) {
32
+ for (let j = 1; j <= n; j++) {
33
+ if (a[i - 1] === b[j - 1]) {
34
+ dp[i][j] = dp[i - 1][j - 1] + 1;
35
+ }
36
+ else {
37
+ dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
38
+ }
39
+ }
40
+ }
41
+ // Trace back to build edit list
42
+ const edits = [];
43
+ let i = m;
44
+ let j = n;
45
+ while (i > 0 || j > 0) {
46
+ if (i > 0 && j > 0 && a[i - 1] === b[j - 1]) {
47
+ edits.unshift({ type: 'equal', value: a[i - 1] });
48
+ i--;
49
+ j--;
50
+ }
51
+ else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
52
+ edits.unshift({ type: 'insert', value: b[j - 1] });
53
+ j--;
54
+ }
55
+ else {
56
+ edits.unshift({ type: 'delete', value: a[i - 1] });
57
+ i--;
58
+ }
59
+ }
60
+ return edits;
61
+ }
62
+ /**
63
+ * Format a list of edits as a unified diff string.
64
+ */
65
+ function formatUnifiedDiff(edits, fileNameA, fileNameB, contextLines = 3) {
66
+ const lines = [];
67
+ lines.push(`--- ${fileNameA}`);
68
+ lines.push(`+++ ${fileNameB}`);
69
+ // Identify hunk boundaries: positions with changes plus context
70
+ const changePositions = new Set();
71
+ for (let k = 0; k < edits.length; k++) {
72
+ if (edits[k].type !== 'equal') {
73
+ for (let ctx = Math.max(0, k - contextLines); ctx <= k + contextLines; ctx++) {
74
+ changePositions.add(ctx);
75
+ }
76
+ }
77
+ }
78
+ if (changePositions.size === 0) {
79
+ return ''; // No changes
80
+ }
81
+ let inHunk = false;
82
+ let hunkStart = -1;
83
+ let hunkLines = [];
84
+ let oldLine = 1;
85
+ let newLine = 1;
86
+ let hunkOldStart = 1;
87
+ let hunkNewStart = 1;
88
+ let hunkOldCount = 0;
89
+ let hunkNewCount = 0;
90
+ const flushHunk = () => {
91
+ if (hunkLines.length > 0) {
92
+ lines.push(`@@ -${hunkOldStart},${hunkOldCount} +${hunkNewStart},${hunkNewCount} @@`);
93
+ lines.push(...hunkLines);
94
+ }
95
+ hunkLines = [];
96
+ hunkOldCount = 0;
97
+ hunkNewCount = 0;
98
+ inHunk = false;
99
+ };
100
+ for (let k = 0; k < edits.length; k++) {
101
+ const edit = edits[k];
102
+ const inContext = changePositions.has(k);
103
+ if (inContext) {
104
+ if (!inHunk) {
105
+ hunkOldStart = oldLine;
106
+ hunkNewStart = newLine;
107
+ inHunk = true;
108
+ hunkStart = k;
109
+ }
110
+ if (edit.type === 'equal') {
111
+ hunkLines.push(` ${edit.value}`);
112
+ hunkOldCount++;
113
+ hunkNewCount++;
114
+ oldLine++;
115
+ newLine++;
116
+ }
117
+ else if (edit.type === 'delete') {
118
+ hunkLines.push(`-${edit.value}`);
119
+ hunkOldCount++;
120
+ oldLine++;
121
+ }
122
+ else {
123
+ hunkLines.push(`+${edit.value}`);
124
+ hunkNewCount++;
125
+ newLine++;
126
+ }
127
+ }
128
+ else {
129
+ if (inHunk) {
130
+ flushHunk();
131
+ }
132
+ if (edit.type === 'equal') {
133
+ oldLine++;
134
+ newLine++;
135
+ }
136
+ else if (edit.type === 'delete') {
137
+ oldLine++;
138
+ }
139
+ else {
140
+ newLine++;
141
+ }
142
+ }
143
+ }
144
+ if (inHunk) {
145
+ flushHunk();
146
+ }
147
+ return lines.join('\n');
148
+ }
149
+ /**
150
+ * Generate a unified diff between two text strings.
151
+ * Returns empty string if files are identical.
152
+ */
153
+ function generateUnifiedDiff(oldContent, newContent, filePath, contextLines = 3) {
154
+ if (oldContent === newContent)
155
+ return '';
156
+ const oldLines = oldContent.split('\n');
157
+ const newLines = newContent.split('\n');
158
+ const edits = computeLineDiff(oldLines, newLines);
159
+ return formatUnifiedDiff(edits, `a/${filePath}`, `b/${filePath}`, contextLines);
160
+ }
161
+ // ─── Planned content helpers ────────────────────────────────────────────────
162
+ /**
163
+ * Compute the approximate planned devcontainer.json content by loading the
164
+ * base template and applying each overlay using the same logic as the composer.
165
+ * This mirrors the core of composeDevContainer without writing to disk.
166
+ */
167
+ function computePlannedDevcontainerJson(stack, overlayIds, overlaysDir) {
168
+ try {
169
+ const basePath = path.join(TEMPLATES_DIR, stack, '.devcontainer', 'devcontainer.json');
170
+ if (!fs.existsSync(basePath))
171
+ return null;
172
+ let config = JSON.parse(fs.readFileSync(basePath, 'utf8'));
173
+ for (const id of overlayIds) {
174
+ config = applyOverlay(config, id, overlaysDir);
175
+ }
176
+ return JSON.stringify(config, null, 2);
177
+ }
178
+ catch {
179
+ return null;
180
+ }
181
+ }
182
+ // ─── Diff generation ─────────────────────────────────────────────────────────
183
+ /**
184
+ * Generate a PlanDiffResult comparing planned overlays/files against an existing
185
+ * .devcontainer/ directory.
186
+ */
187
+ export function generatePlanDiff(plan, overlaysConfig, overlaysDir, existingPath, contextLines = 3) {
188
+ const overlayMap = new Map(overlaysConfig.overlays.map((o) => [o.id, o]));
189
+ const allPlannedOverlays = [
190
+ ...plan.selectedOverlays,
191
+ ...plan.autoAddedOverlays.filter((id) => !plan.selectedOverlays.includes(id)),
192
+ ];
193
+ // ── Read existing superposition.json ──────────────────────────────────────
194
+ const existsDir = fs.existsSync(existingPath);
195
+ const manifestPath = path.join(existingPath, 'superposition.json');
196
+ let existingOverlays = [];
197
+ let existingPorts = [];
198
+ if (existsDir && fs.existsSync(manifestPath)) {
199
+ try {
200
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
201
+ existingOverlays = Array.isArray(manifest.overlays) ? manifest.overlays : [];
202
+ }
203
+ catch {
204
+ // ignore parse errors
205
+ }
206
+ }
207
+ // ── Overlay changes ───────────────────────────────────────────────────────
208
+ const plannedSet = new Set(allPlannedOverlays);
209
+ const existingSet = new Set(existingOverlays);
210
+ const addedOverlays = allPlannedOverlays
211
+ .filter((id) => !existingSet.has(id))
212
+ .map((id) => {
213
+ const meta = overlayMap.get(id);
214
+ return { id, name: meta?.name, category: meta?.category };
215
+ });
216
+ const removedOverlays = existingOverlays
217
+ .filter((id) => !plannedSet.has(id))
218
+ .map((id) => {
219
+ const meta = overlayMap.get(id);
220
+ return { id, name: meta?.name, category: meta?.category };
221
+ });
222
+ const unchangedOverlays = allPlannedOverlays.filter((id) => existingSet.has(id));
223
+ // ── Port changes ─────────────────────────────────────────────────────────
224
+ const existingPortSet = new Set();
225
+ for (const id of existingOverlays) {
226
+ const meta = overlayMap.get(id);
227
+ if (meta) {
228
+ for (const p of extractPorts([meta])) {
229
+ existingPortSet.add(`${id}:${p}`);
230
+ existingPorts.push({ overlay: id, port: p });
231
+ }
232
+ }
233
+ }
234
+ const addedPorts = [];
235
+ const removedPorts = [];
236
+ const plannedPortSet = new Set();
237
+ for (const mapping of plan.portMappings) {
238
+ for (const p of mapping.ports) {
239
+ plannedPortSet.add(`${mapping.overlay}:${p}`);
240
+ if (!existingPortSet.has(`${mapping.overlay}:${p}`)) {
241
+ addedPorts.push({ overlay: mapping.overlay, port: p });
242
+ }
243
+ }
244
+ }
245
+ for (const ep of existingPorts) {
246
+ if (!plannedPortSet.has(`${ep.overlay}:${ep.port}`)) {
247
+ removedPorts.push(ep);
248
+ }
249
+ }
250
+ // ── File status ──────────────────────────────────────────────────────────
251
+ const created = [];
252
+ const modified = [];
253
+ const overwritten = []; // files that exist but content was not compared
254
+ const unchanged = [];
255
+ // Helper: produce a display path preferring cwd-relative, falling back to
256
+ // paths relative to existingPath's parent (e.g., ".devcontainer/file.json").
257
+ const existingParent = path.dirname(path.resolve(existingPath));
258
+ const toDisplayPath = (abs) => {
259
+ const cwdRel = path.relative(process.cwd(), abs);
260
+ return cwdRel.startsWith('..') ? path.relative(existingParent, abs) : cwdRel;
261
+ };
262
+ const relFiles = plan.files.map((f) => toDisplayPath(path.isAbsolute(f) ? f : path.resolve(f)));
263
+ for (let idx = 0; idx < plan.files.length; idx++) {
264
+ const absFile = plan.files[idx];
265
+ const relFile = relFiles[idx];
266
+ if (!fs.existsSync(absFile)) {
267
+ created.push(relFile);
268
+ continue;
269
+ }
270
+ // File exists – compute a real diff only for devcontainer.json where we
271
+ // can reconstruct the planned content. All other existing files are marked
272
+ // as "overwritten" since we don't have their planned content.
273
+ const basename = path.basename(absFile);
274
+ if (basename === 'devcontainer.json') {
275
+ const existingContent = fs.readFileSync(absFile, 'utf8');
276
+ const plannedContent = computePlannedDevcontainerJson(plan.stack, allPlannedOverlays, overlaysDir);
277
+ if (plannedContent === null) {
278
+ overwritten.push(relFile);
279
+ }
280
+ else if (plannedContent.trimEnd() === existingContent.trimEnd()) {
281
+ unchanged.push(relFile);
282
+ }
283
+ else {
284
+ const diff = generateUnifiedDiff(existingContent.trimEnd(), plannedContent.trimEnd(), relFile, contextLines);
285
+ modified.push({ path: relFile, diff: diff || undefined });
286
+ }
287
+ }
288
+ else {
289
+ // Content not compared – will be overwritten on next generation
290
+ overwritten.push(relFile);
291
+ }
292
+ }
293
+ // ── Preserved custom files ────────────────────────────────────────────────
294
+ const preserved = [];
295
+ const customDir = path.join(existingPath, 'custom');
296
+ if (fs.existsSync(customDir)) {
297
+ try {
298
+ const entries = fs.readdirSync(customDir, { withFileTypes: true });
299
+ for (const entry of entries) {
300
+ if (entry.isFile()) {
301
+ preserved.push(toDisplayPath(path.join(customDir, entry.name)));
302
+ }
303
+ }
304
+ }
305
+ catch {
306
+ // ignore
307
+ }
308
+ }
309
+ // ── Removed files: walk the existing dir recursively and compare relative paths ──
310
+ const removed = [];
311
+ if (existsDir) {
312
+ try {
313
+ // Build a set of planned paths relative to existingPath for accurate comparison
314
+ const absExisting = path.resolve(existingPath);
315
+ const plannedRelPaths = new Set(plan.files.map((f) => path.normalize(path.relative(absExisting, path.isAbsolute(f) ? f : path.resolve(f)))));
316
+ // Files to skip regardless (user-managed or auto-generated docs)
317
+ const skipTopLevel = new Set(['superposition.json', '.env', 'ports.json']);
318
+ const walkExisting = (dir) => {
319
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
320
+ for (const entry of entries) {
321
+ const abs = path.join(dir, entry.name);
322
+ const relFromRoot = path.normalize(path.relative(absExisting, abs));
323
+ const segments = relFromRoot.split(path.sep);
324
+ // Skip the custom/ directory entirely (preserved separately)
325
+ if (segments[0] === 'custom')
326
+ continue;
327
+ // Skip specific top-level user-managed files
328
+ if (segments.length === 1 && skipTopLevel.has(entry.name))
329
+ continue;
330
+ if (entry.isDirectory()) {
331
+ walkExisting(abs);
332
+ }
333
+ else if (entry.isFile() && !plannedRelPaths.has(relFromRoot)) {
334
+ removed.push(toDisplayPath(abs));
335
+ }
336
+ }
337
+ };
338
+ walkExisting(absExisting);
339
+ }
340
+ catch {
341
+ // ignore
342
+ }
343
+ }
344
+ return {
345
+ existingPath,
346
+ hasExistingConfig: existsDir,
347
+ created,
348
+ modified,
349
+ overwritten,
350
+ unchanged,
351
+ preserved,
352
+ removed,
353
+ overlayChanges: {
354
+ added: addedOverlays,
355
+ removed: removedOverlays,
356
+ unchanged: unchangedOverlays,
357
+ },
358
+ portChanges: {
359
+ added: addedPorts,
360
+ removed: removedPorts,
361
+ },
362
+ };
363
+ }
364
+ // ─── Diff formatter ───────────────────────────────────────────────────────────
365
+ /**
366
+ * Format a PlanDiffResult as colored terminal text.
367
+ */
368
+ function formatDiffAsText(diff, contextLines = 3) {
369
+ const sep = chalk.dim('─'.repeat(57));
370
+ const lines = [];
371
+ lines.push('');
372
+ lines.push(boxen(chalk.bold('šŸ“‹ Plan Diff'), {
373
+ padding: 0.5,
374
+ borderColor: 'cyan',
375
+ borderStyle: 'round',
376
+ }));
377
+ if (!diff.hasExistingConfig) {
378
+ lines.push('');
379
+ lines.push(chalk.yellow(` ⚠ No existing configuration found at ${chalk.bold(diff.existingPath)}`));
380
+ lines.push(chalk.dim(' All files will be created fresh.'));
381
+ lines.push('');
382
+ }
383
+ else {
384
+ lines.push('');
385
+ lines.push(chalk.dim(` Comparing planned output vs ${chalk.bold(diff.existingPath)}`));
386
+ lines.push('');
387
+ }
388
+ // ── File summary ─────────────────────────────────────────────────────────
389
+ if (diff.created.length > 0) {
390
+ lines.push(chalk.bold.green('Files to be created:'));
391
+ for (const f of diff.created) {
392
+ lines.push(` ${chalk.green('+')} ${f} ${chalk.dim('(no existing file)')}`);
393
+ }
394
+ lines.push('');
395
+ }
396
+ if (diff.modified.length > 0) {
397
+ lines.push(chalk.bold.yellow('Files to be modified:'));
398
+ for (const f of diff.modified) {
399
+ lines.push(` ${chalk.yellow('~')} ${f.path}`);
400
+ }
401
+ lines.push('');
402
+ }
403
+ if (diff.overwritten.length > 0) {
404
+ lines.push(chalk.bold.yellow('Files to be overwritten:'));
405
+ for (const f of diff.overwritten) {
406
+ lines.push(` ${chalk.yellow('~')} ${f} ${chalk.dim('(content not compared)')}`);
407
+ }
408
+ lines.push('');
409
+ }
410
+ if (diff.unchanged.length > 0) {
411
+ lines.push(chalk.bold('Files unchanged:'));
412
+ for (const f of diff.unchanged) {
413
+ lines.push(` ${chalk.gray('=')} ${chalk.dim(f)}`);
414
+ }
415
+ lines.push('');
416
+ }
417
+ if (diff.preserved.length > 0) {
418
+ lines.push(chalk.bold('Files preserved (custom):'));
419
+ for (const f of diff.preserved) {
420
+ lines.push(` ${chalk.cyan('•')} ${f}`);
421
+ }
422
+ lines.push('');
423
+ }
424
+ if (diff.removed.length > 0) {
425
+ lines.push(chalk.bold.red('Files to be removed:'));
426
+ for (const f of diff.removed) {
427
+ lines.push(` ${chalk.red('-')} ${f}`);
428
+ }
429
+ lines.push('');
430
+ }
431
+ // ── File content diffs ────────────────────────────────────────────────────
432
+ const withDiff = diff.modified.filter((f) => f.diff);
433
+ if (withDiff.length > 0) {
434
+ lines.push(sep);
435
+ for (const f of withDiff) {
436
+ lines.push('');
437
+ lines.push(chalk.bold(`šŸ“„ ${path.basename(f.path)} diff`));
438
+ lines.push('');
439
+ for (const line of f.diff.split('\n')) {
440
+ if (line.startsWith('---') || line.startsWith('+++')) {
441
+ lines.push(chalk.dim(line));
442
+ }
443
+ else if (line.startsWith('@@')) {
444
+ lines.push(chalk.cyan(line));
445
+ }
446
+ else if (line.startsWith('+')) {
447
+ lines.push(chalk.green(line));
448
+ }
449
+ else if (line.startsWith('-')) {
450
+ lines.push(chalk.red(line));
451
+ }
452
+ else {
453
+ lines.push(chalk.dim(line));
454
+ }
455
+ }
456
+ lines.push('');
457
+ }
458
+ }
459
+ // ── Overlay changes ───────────────────────────────────────────────────────
460
+ const { overlayChanges, portChanges } = diff;
461
+ const hasOverlayChanges = overlayChanges.added.length > 0 || overlayChanges.removed.length > 0;
462
+ if (diff.hasExistingConfig && hasOverlayChanges) {
463
+ lines.push(sep);
464
+ lines.push('');
465
+ lines.push(chalk.bold('šŸ“¦ Overlays'));
466
+ lines.push('');
467
+ if (overlayChanges.added.length > 0) {
468
+ lines.push(chalk.bold('Added:'));
469
+ for (const o of overlayChanges.added) {
470
+ const cat = o.category ? chalk.dim(` (${o.category})`) : '';
471
+ lines.push(` ${chalk.green('+')} ${chalk.cyan(o.id)}${cat}`);
472
+ }
473
+ lines.push('');
474
+ }
475
+ if (overlayChanges.removed.length > 0) {
476
+ lines.push(chalk.bold('Removed:'));
477
+ for (const o of overlayChanges.removed) {
478
+ const cat = o.category ? chalk.dim(` (${o.category})`) : '';
479
+ lines.push(` ${chalk.red('-')} ${chalk.cyan(o.id)}${cat}`);
480
+ }
481
+ lines.push('');
482
+ }
483
+ }
484
+ // ── Port changes ──────────────────────────────────────────────────────────
485
+ const hasPortChanges = portChanges.added.length > 0 || portChanges.removed.length > 0;
486
+ if (diff.hasExistingConfig && hasPortChanges) {
487
+ lines.push(sep);
488
+ lines.push('');
489
+ lines.push(chalk.bold('🌐 Port changes'));
490
+ lines.push('');
491
+ if (portChanges.added.length > 0) {
492
+ lines.push(chalk.bold('Added:'));
493
+ for (const p of portChanges.added) {
494
+ lines.push(` ${chalk.green('+')} ${chalk.cyan(p.overlay)}: ${p.port}`);
495
+ }
496
+ lines.push('');
497
+ }
498
+ if (portChanges.removed.length > 0) {
499
+ lines.push(chalk.bold('Removed:'));
500
+ for (const p of portChanges.removed) {
501
+ lines.push(` ${chalk.red('-')} ${chalk.cyan(p.overlay)}: ${p.port}`);
502
+ }
503
+ lines.push('');
504
+ }
505
+ }
506
+ lines.push(sep);
507
+ return lines.join('\n');
508
+ }
509
+ /**
510
+ * Resolve dependencies recursively
511
+ */
512
+ function resolveDependencies(selectedIds, overlaysConfig) {
513
+ const overlayMap = new Map(overlaysConfig.overlays.map((o) => [o.id, o]));
514
+ const resolved = new Set(selectedIds);
515
+ const autoAdded = [];
516
+ const processDeps = (id) => {
517
+ const overlay = overlayMap.get(id);
518
+ if (!overlay || !overlay.requires)
519
+ return;
520
+ for (const reqId of overlay.requires) {
521
+ if (!resolved.has(reqId)) {
522
+ resolved.add(reqId);
523
+ autoAdded.push(reqId);
524
+ processDeps(reqId); // Recursive
525
+ }
526
+ }
527
+ };
528
+ for (const id of selectedIds) {
529
+ processDeps(id);
530
+ }
531
+ return {
532
+ resolved: Array.from(resolved),
533
+ autoAdded,
534
+ };
535
+ }
536
+ /**
537
+ * Detect conflicts in selected overlays
538
+ */
539
+ function detectConflicts(overlayIds, overlaysConfig) {
540
+ const overlayMap = new Map(overlaysConfig.overlays.map((o) => [o.id, o]));
541
+ const conflicts = [];
542
+ for (const id of overlayIds) {
543
+ const overlay = overlayMap.get(id);
544
+ if (!overlay || !overlay.conflicts || overlay.conflicts.length === 0)
545
+ continue;
546
+ const conflicting = overlay.conflicts.filter((c) => overlayIds.includes(c));
547
+ if (conflicting.length > 0) {
548
+ conflicts.push({
549
+ overlay: id,
550
+ conflictsWith: conflicting,
551
+ });
552
+ }
553
+ }
554
+ return conflicts;
555
+ }
556
+ /**
557
+ * Get all files that will be created/modified
558
+ */
559
+ function getFilesToCreate(overlayIds, overlaysDir, outputPath) {
560
+ const files = [];
561
+ // Base devcontainer files
562
+ files.push(path.join(outputPath, 'devcontainer.json'));
563
+ files.push(path.join(outputPath, 'superposition.json'));
564
+ files.push(path.join(outputPath, 'README.md'));
565
+ // Check if any overlay has .env.example
566
+ let hasEnvExample = false;
567
+ for (const id of overlayIds) {
568
+ const envPath = path.join(overlaysDir, id, '.env.example');
569
+ if (fs.existsSync(envPath)) {
570
+ hasEnvExample = true;
571
+ break;
572
+ }
573
+ }
574
+ if (hasEnvExample) {
575
+ files.push(path.join(outputPath, '.env.example'));
576
+ }
577
+ // Check for docker-compose
578
+ for (const id of overlayIds) {
579
+ const composePath = path.join(overlaysDir, id, 'docker-compose.yml');
580
+ if (fs.existsSync(composePath)) {
581
+ files.push(path.join(outputPath, 'docker-compose.yml'));
582
+ break;
583
+ }
584
+ }
585
+ // Check if we need scripts directory
586
+ const hasScripts = overlayIds.some((id) => fs.existsSync(path.join(overlaysDir, id, 'setup.sh')) ||
587
+ fs.existsSync(path.join(overlaysDir, id, 'verify.sh')));
588
+ // Overlay-specific files (mirroring composer behavior)
589
+ for (const id of overlayIds) {
590
+ const overlayDir = path.join(overlaysDir, id);
591
+ if (!fs.existsSync(overlayDir))
592
+ continue;
593
+ const overlayEntries = fs.readdirSync(overlayDir, { withFileTypes: true });
594
+ for (const entry of overlayEntries) {
595
+ const name = entry.name;
596
+ // Setup and verify scripts are copied into .devcontainer/scripts with overlay suffix
597
+ if (entry.isFile() && name.startsWith('setup') && name.endsWith('.sh')) {
598
+ files.push(path.join(outputPath, 'scripts', `setup-${id}.sh`));
599
+ }
600
+ if (entry.isFile() && name.startsWith('verify') && name.endsWith('.sh')) {
601
+ files.push(path.join(outputPath, 'scripts', `verify-${id}.sh`));
602
+ }
603
+ // Global packages/tools files and directories get an <overlay> suffix
604
+ if (name.startsWith('global-')) {
605
+ if (entry.isFile()) {
606
+ const ext = path.extname(name);
607
+ const base = ext.length > 0 ? name.slice(0, -ext.length) : name;
608
+ const targetName = `${base}-${id}${ext}`;
609
+ files.push(path.join(outputPath, targetName));
610
+ }
611
+ else if (entry.isDirectory()) {
612
+ const targetName = `${name}-${id}`;
613
+ files.push(path.join(outputPath, targetName));
614
+ }
615
+ }
616
+ }
617
+ }
618
+ // Deduplicate and sort
619
+ return Array.from(new Set(files)).sort();
620
+ }
621
+ /**
622
+ * Get port mappings with offset applied
623
+ */
624
+ function getPortMappings(overlayIds, overlaysConfig, portOffset) {
625
+ const overlayMap = new Map(overlaysConfig.overlays.map((o) => [o.id, o]));
626
+ const mappings = [];
627
+ for (const id of overlayIds) {
628
+ const overlay = overlayMap.get(id);
629
+ if (!overlay || !overlay.ports || overlay.ports.length === 0)
630
+ continue;
631
+ // Extract numeric ports from overlay
632
+ const ports = extractPorts([overlay]);
633
+ mappings.push({
634
+ overlay: id,
635
+ ports: ports,
636
+ offsetPorts: ports.map((p) => p + portOffset),
637
+ });
638
+ }
639
+ return mappings;
640
+ }
641
+ /**
642
+ * Format plan as text
643
+ */
644
+ function formatAsText(plan, overlaysConfig) {
645
+ const lines = [];
646
+ const overlayMap = new Map(overlaysConfig.overlays.map((o) => [o.id, o]));
647
+ lines.push(boxen(chalk.bold('Generation Plan'), {
648
+ padding: 0.5,
649
+ borderColor: 'cyan',
650
+ borderStyle: 'round',
651
+ }));
652
+ // Stack
653
+ lines.push('');
654
+ lines.push(chalk.bold('Stack:') + ` ${plan.stack}`);
655
+ // Overlays
656
+ lines.push('');
657
+ lines.push(chalk.bold('Overlays Selected:'));
658
+ for (const id of plan.selectedOverlays) {
659
+ const overlay = overlayMap.get(id);
660
+ const name = overlay ? ` (${overlay.name})` : '';
661
+ lines.push(` āœ“ ${chalk.cyan(id)}${chalk.gray(name)}`);
662
+ }
663
+ // Auto-added dependencies
664
+ if (plan.autoAddedOverlays.length > 0) {
665
+ lines.push('');
666
+ lines.push(chalk.bold('Auto-Added Dependencies:'));
667
+ for (const id of plan.autoAddedOverlays) {
668
+ const overlay = overlayMap.get(id);
669
+ const name = overlay ? ` (${overlay.name})` : '';
670
+ lines.push(` ${chalk.yellow('+')} ${chalk.cyan(id)}${chalk.gray(name)}`);
671
+ }
672
+ }
673
+ // Conflicts
674
+ if (plan.conflicts.length > 0) {
675
+ lines.push('');
676
+ lines.push(chalk.bold.red('⚠ Conflicts Detected:'));
677
+ for (const conflict of plan.conflicts) {
678
+ lines.push(` ${chalk.red('āœ—')} ${chalk.cyan(conflict.overlay)} conflicts with: ${conflict.conflictsWith.join(', ')}`);
679
+ }
680
+ lines.push('');
681
+ lines.push(chalk.yellow(' These conflicts must be resolved before generation.'));
682
+ }
683
+ // Port mappings
684
+ if (plan.portMappings.length > 0) {
685
+ lines.push('');
686
+ lines.push(chalk.bold('Port Mappings:'));
687
+ if (plan.portOffset > 0) {
688
+ lines.push(chalk.dim(` (Offset: +${plan.portOffset})`));
689
+ }
690
+ for (const mapping of plan.portMappings) {
691
+ for (let i = 0; i < mapping.ports.length; i++) {
692
+ const original = mapping.ports[i];
693
+ const offset = mapping.offsetPorts[i];
694
+ const arrow = plan.portOffset > 0 ? ` → ${offset}` : '';
695
+ lines.push(` ${chalk.cyan(mapping.overlay)}: ${original}${arrow}`);
696
+ }
697
+ }
698
+ }
699
+ // Files
700
+ lines.push('');
701
+ lines.push(chalk.bold('Files to Create/Modify:'));
702
+ const grouped = new Map();
703
+ for (const file of plan.files) {
704
+ const dir = path.dirname(file);
705
+ if (!grouped.has(dir)) {
706
+ grouped.set(dir, []);
707
+ }
708
+ grouped.get(dir).push(path.basename(file));
709
+ }
710
+ for (const [dir, files] of grouped) {
711
+ lines.push(` ${chalk.dim(dir)}/`);
712
+ for (const file of files) {
713
+ lines.push(` šŸ“„ ${file}`);
714
+ }
715
+ }
716
+ return lines.join('\n');
717
+ }
718
+ /**
719
+ * Execute plan command
720
+ */
721
+ export async function planCommand(overlaysConfig, overlaysDir, options) {
722
+ try {
723
+ // Validate required options
724
+ if (!options.stack) {
725
+ console.error(chalk.red('āœ— --stack is required for plan command'));
726
+ console.log(chalk.dim(' Example: container-superposition plan --stack compose --overlays postgres,grafana'));
727
+ process.exit(1);
728
+ }
729
+ // Validate stack value
730
+ const validStacks = ['plain', 'compose'];
731
+ if (!validStacks.includes(options.stack)) {
732
+ console.error(chalk.red(`āœ— Invalid --stack value: ${options.stack}`));
733
+ console.log(chalk.dim(` Valid values are: ${validStacks.join(', ')}\n` +
734
+ ' Example: container-superposition plan --stack compose --overlays postgres,grafana'));
735
+ process.exit(1);
736
+ }
737
+ if (!options.overlays) {
738
+ console.error(chalk.red('āœ— --overlays is required for plan command'));
739
+ console.log(chalk.dim(' Example: container-superposition plan --stack compose --overlays postgres,grafana'));
740
+ process.exit(1);
741
+ }
742
+ // Parse overlays - filter empty entries and deduplicate
743
+ const seenOverlayIds = new Set();
744
+ const selectedOverlays = options.overlays
745
+ .split(',')
746
+ .map((o) => o.trim())
747
+ .filter((id) => {
748
+ if (!id) {
749
+ return false;
750
+ }
751
+ if (seenOverlayIds.has(id)) {
752
+ return false;
753
+ }
754
+ seenOverlayIds.add(id);
755
+ return true;
756
+ });
757
+ const portOffset = options.portOffset || 0;
758
+ // Validate overlays exist
759
+ const overlayMap = new Map(overlaysConfig.overlays.map((o) => [o.id, o]));
760
+ for (const id of selectedOverlays) {
761
+ if (!overlayMap.has(id)) {
762
+ console.error(chalk.red(`āœ— Unknown overlay: ${id}`));
763
+ console.log(chalk.dim('\nšŸ’” Use "container-superposition list" to see available overlays\n'));
764
+ process.exit(1);
765
+ }
766
+ }
767
+ // Resolve dependencies
768
+ const { resolved, autoAdded } = resolveDependencies(selectedOverlays, overlaysConfig);
769
+ // Apply stack compatibility filtering (match composeDevContainer behavior)
770
+ let compatibleResolved = resolved;
771
+ const incompatible = [];
772
+ compatibleResolved = resolved.filter((id) => {
773
+ const overlay = overlayMap.get(id);
774
+ if (!overlay) {
775
+ return false;
776
+ }
777
+ // Check if overlay supports this stack
778
+ if (overlay.supports && overlay.supports.length > 0) {
779
+ const isCompatible = overlay.supports.includes(options.stack);
780
+ if (!isCompatible) {
781
+ incompatible.push(id);
782
+ }
783
+ return isCompatible;
784
+ }
785
+ // Empty supports array means supports all stacks
786
+ return true;
787
+ });
788
+ // Warn about incompatible overlays
789
+ for (const id of incompatible) {
790
+ console.warn(chalk.yellow(`⚠ Overlay "${id}" does not support stack "${options.stack}" and will be skipped.`));
791
+ }
792
+ // Detect conflicts
793
+ const conflicts = detectConflicts(compatibleResolved, overlaysConfig);
794
+ // Get port mappings
795
+ const portMappings = getPortMappings(compatibleResolved, overlaysConfig, portOffset);
796
+ // Determine output path for file comparison (used by both normal and diff modes)
797
+ const outputPath = options.output || '.devcontainer';
798
+ // Get files to create
799
+ const files = getFilesToCreate(compatibleResolved, overlaysDir, outputPath);
800
+ const plan = {
801
+ stack: options.stack,
802
+ selectedOverlays,
803
+ autoAddedOverlays: autoAdded,
804
+ conflicts,
805
+ portMappings,
806
+ files,
807
+ portOffset,
808
+ };
809
+ // ── Diff mode ─────────────────────────────────────────────────────────
810
+ if (options.diff) {
811
+ const contextLines = options.diffContext ?? 3;
812
+ const diffResult = generatePlanDiff(plan, overlaysConfig, overlaysDir, outputPath, contextLines);
813
+ if (options.diffFormat === 'json' || options.json) {
814
+ console.log(JSON.stringify(diffResult, null, 2));
815
+ return;
816
+ }
817
+ console.log(formatDiffAsText(diffResult, contextLines));
818
+ // Still show run hint if no conflicts
819
+ if (conflicts.length > 0) {
820
+ console.log(chalk.yellow('⚠ Cannot proceed with generation due to conflicts. Remove conflicting overlays.\n'));
821
+ process.exit(1);
822
+ }
823
+ else {
824
+ console.log(chalk.dim(` Run: container-superposition init --stack ${options.stack} --overlays ${options.overlays}${portOffset > 0 ? ` --port-offset ${portOffset}` : ''}\n`));
825
+ }
826
+ return;
827
+ }
828
+ // ── Normal mode ───────────────────────────────────────────────────────
829
+ // Output as JSON
830
+ if (options.json) {
831
+ console.log(JSON.stringify(plan, null, 2));
832
+ return;
833
+ }
834
+ // Output as formatted text
835
+ console.log('\n' + formatAsText(plan, overlaysConfig) + '\n');
836
+ // Summary
837
+ if (conflicts.length > 0) {
838
+ console.log(chalk.yellow('⚠ Cannot proceed with generation due to conflicts. Remove conflicting overlays.\n'));
839
+ process.exit(1);
840
+ }
841
+ else {
842
+ console.log(chalk.green('āœ“ No conflicts detected. Ready to generate!\n') +
843
+ chalk.dim(` Run: container-superposition init --stack ${options.stack} --overlays ${options.overlays}${portOffset > 0 ? ` --port-offset ${portOffset}` : ''}\n`));
844
+ }
845
+ }
846
+ catch (error) {
847
+ console.error(chalk.red('āœ— Error creating plan:'), error);
848
+ process.exit(1);
849
+ }
850
+ }
851
+ //# sourceMappingURL=plan.js.map