container-superposition 0.1.3 → 0.1.5

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 (141) hide show
  1. package/README.md +72 -1014
  2. package/dist/scripts/init.js +512 -238
  3. package/dist/scripts/init.js.map +1 -1
  4. package/dist/tool/commands/adopt.d.ts +62 -0
  5. package/dist/tool/commands/adopt.d.ts.map +1 -0
  6. package/dist/tool/commands/adopt.js +767 -0
  7. package/dist/tool/commands/adopt.js.map +1 -0
  8. package/dist/tool/commands/doctor.js +2 -2
  9. package/dist/tool/commands/explain.d.ts.map +1 -1
  10. package/dist/tool/commands/explain.js +88 -0
  11. package/dist/tool/commands/explain.js.map +1 -1
  12. package/dist/tool/commands/hash.d.ts +36 -0
  13. package/dist/tool/commands/hash.d.ts.map +1 -0
  14. package/dist/tool/commands/hash.js +242 -0
  15. package/dist/tool/commands/hash.js.map +1 -0
  16. package/dist/tool/commands/plan.d.ts +53 -0
  17. package/dist/tool/commands/plan.d.ts.map +1 -1
  18. package/dist/tool/commands/plan.js +784 -42
  19. package/dist/tool/commands/plan.js.map +1 -1
  20. package/dist/tool/questionnaire/composer.d.ts +12 -3
  21. package/dist/tool/questionnaire/composer.d.ts.map +1 -1
  22. package/dist/tool/questionnaire/composer.js +133 -20
  23. package/dist/tool/questionnaire/composer.js.map +1 -1
  24. package/dist/tool/schema/project-config.d.ts +15 -0
  25. package/dist/tool/schema/project-config.d.ts.map +1 -0
  26. package/dist/tool/schema/project-config.js +359 -0
  27. package/dist/tool/schema/project-config.js.map +1 -0
  28. package/dist/tool/schema/types.d.ts +57 -1
  29. package/dist/tool/schema/types.d.ts.map +1 -1
  30. package/dist/tool/utils/backup.d.ts +23 -0
  31. package/dist/tool/utils/backup.d.ts.map +1 -0
  32. package/dist/tool/utils/backup.js +123 -0
  33. package/dist/tool/utils/backup.js.map +1 -0
  34. package/dist/tool/utils/gitignore.d.ts +15 -0
  35. package/dist/tool/utils/gitignore.d.ts.map +1 -0
  36. package/dist/tool/utils/gitignore.js +41 -0
  37. package/dist/tool/utils/gitignore.js.map +1 -0
  38. package/dist/tool/utils/services-export.d.ts +14 -0
  39. package/dist/tool/utils/services-export.d.ts.map +1 -0
  40. package/dist/tool/utils/services-export.js +478 -0
  41. package/dist/tool/utils/services-export.js.map +1 -0
  42. package/dist/tool/utils/summary.d.ts +69 -0
  43. package/dist/tool/utils/summary.d.ts.map +1 -0
  44. package/dist/tool/utils/summary.js +260 -0
  45. package/dist/tool/utils/summary.js.map +1 -0
  46. package/docs/README.md +12 -2
  47. package/docs/adopt.md +196 -0
  48. package/docs/custom-patches.md +1 -1
  49. package/docs/discovery-commands.md +55 -3
  50. package/docs/examples.md +40 -6
  51. package/docs/filesystem-contract.md +58 -0
  52. package/docs/hash.md +183 -0
  53. package/docs/minimal-and-editor.md +1 -1
  54. package/docs/overlays.md +108 -5
  55. package/docs/presets-architecture.md +1 -1
  56. package/docs/presets.md +1 -1
  57. package/docs/publishing.md +36 -23
  58. package/docs/security.md +43 -0
  59. package/docs/specs/001-verbose-plan-graph/checklists/requirements.md +36 -0
  60. package/docs/specs/001-verbose-plan-graph/contracts/plan-verbose-output.md +96 -0
  61. package/docs/specs/001-verbose-plan-graph/data-model.md +111 -0
  62. package/docs/specs/001-verbose-plan-graph/plan.md +127 -0
  63. package/docs/specs/001-verbose-plan-graph/quickstart.md +106 -0
  64. package/docs/specs/001-verbose-plan-graph/research.md +100 -0
  65. package/docs/specs/001-verbose-plan-graph/spec.md +128 -0
  66. package/docs/specs/001-verbose-plan-graph/tasks.md +223 -0
  67. package/docs/specs/002-superposition-config-file/checklists/requirements.md +36 -0
  68. package/docs/specs/002-superposition-config-file/contracts/init-project-config.md +98 -0
  69. package/docs/specs/002-superposition-config-file/data-model.md +126 -0
  70. package/docs/specs/002-superposition-config-file/plan.md +208 -0
  71. package/docs/specs/002-superposition-config-file/quickstart.md +140 -0
  72. package/docs/specs/002-superposition-config-file/research.md +144 -0
  73. package/docs/specs/002-superposition-config-file/spec.md +130 -0
  74. package/docs/specs/002-superposition-config-file/tasks.md +213 -0
  75. package/docs/team-workflow.md +27 -1
  76. package/docs/workflows.md +136 -0
  77. package/overlays/.presets/microservice.yml +32 -6
  78. package/overlays/.presets/sdd.yml +84 -0
  79. package/overlays/.presets/web-api.yml +76 -56
  80. package/overlays/README.md +7 -1
  81. package/overlays/amp/README.md +70 -0
  82. package/overlays/amp/devcontainer.patch.json +3 -0
  83. package/overlays/amp/overlay.yml +15 -0
  84. package/overlays/amp/setup.sh +21 -0
  85. package/overlays/amp/verify.sh +21 -0
  86. package/overlays/claude-code/README.md +83 -0
  87. package/overlays/claude-code/devcontainer.patch.json +3 -0
  88. package/overlays/claude-code/overlay.yml +15 -0
  89. package/overlays/claude-code/setup.sh +21 -0
  90. package/overlays/claude-code/verify.sh +21 -0
  91. package/overlays/cloudflared/README.md +190 -0
  92. package/overlays/cloudflared/devcontainer.patch.json +3 -0
  93. package/overlays/cloudflared/overlay.yml +15 -0
  94. package/overlays/cloudflared/setup.sh +49 -0
  95. package/overlays/cloudflared/verify.sh +21 -0
  96. package/overlays/direnv/README.md +6 -4
  97. package/overlays/direnv/setup.sh +0 -12
  98. package/overlays/gemini-cli/README.md +77 -0
  99. package/overlays/gemini-cli/devcontainer.patch.json +3 -0
  100. package/overlays/gemini-cli/overlay.yml +15 -0
  101. package/overlays/gemini-cli/setup.sh +21 -0
  102. package/overlays/gemini-cli/verify.sh +21 -0
  103. package/overlays/grpc-tools/README.md +242 -0
  104. package/overlays/grpc-tools/devcontainer.patch.json +14 -0
  105. package/overlays/grpc-tools/overlay.yml +14 -0
  106. package/overlays/grpc-tools/setup.sh +57 -0
  107. package/overlays/grpc-tools/verify.sh +47 -0
  108. package/overlays/keycloak/.env.example +5 -0
  109. package/overlays/keycloak/README.md +238 -0
  110. package/overlays/keycloak/devcontainer.patch.json +17 -0
  111. package/overlays/keycloak/docker-compose.yml +32 -0
  112. package/overlays/keycloak/overlay.yml +23 -0
  113. package/overlays/keycloak/verify.sh +54 -0
  114. package/overlays/mailpit/.env.example +4 -0
  115. package/overlays/mailpit/README.md +191 -0
  116. package/overlays/mailpit/devcontainer.patch.json +20 -0
  117. package/overlays/mailpit/docker-compose.yml +17 -0
  118. package/overlays/mailpit/overlay.yml +26 -0
  119. package/overlays/mailpit/verify.sh +52 -0
  120. package/overlays/ngrok/overlay.yml +2 -1
  121. package/overlays/opencode/README.md +76 -0
  122. package/overlays/opencode/devcontainer.patch.json +3 -0
  123. package/overlays/opencode/overlay.yml +14 -0
  124. package/overlays/opencode/setup.sh +21 -0
  125. package/overlays/opencode/verify.sh +21 -0
  126. package/overlays/python/README.md +51 -35
  127. package/overlays/python/devcontainer.patch.json +7 -4
  128. package/overlays/python/setup.sh +50 -23
  129. package/overlays/python/verify.sh +29 -1
  130. package/overlays/spec-kit/README.md +181 -0
  131. package/overlays/spec-kit/devcontainer.patch.json +6 -0
  132. package/overlays/spec-kit/overlay.yml +19 -0
  133. package/overlays/spec-kit/setup.sh +45 -0
  134. package/overlays/spec-kit/verify.sh +33 -0
  135. package/overlays/windsurf-cli/README.md +69 -0
  136. package/overlays/windsurf-cli/devcontainer.patch.json +3 -0
  137. package/overlays/windsurf-cli/overlay.yml +15 -0
  138. package/overlays/windsurf-cli/setup.sh +21 -0
  139. package/overlays/windsurf-cli/verify.sh +21 -0
  140. package/package.json +1 -1
  141. package/tool/schema/config.schema.json +138 -9
@@ -3,34 +3,588 @@
3
3
  */
4
4
  import * as fs from 'fs';
5
5
  import * as path from 'path';
6
+ import { fileURLToPath } from 'url';
6
7
  import chalk from 'chalk';
7
8
  import boxen from 'boxen';
8
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
+ }
9
509
  /**
10
510
  * Resolve dependencies recursively
11
511
  */
12
- function resolveDependencies(selectedIds, overlaysConfig) {
512
+ function resolveDependencies(selectedIds, overlaysConfig, origin) {
13
513
  const overlayMap = new Map(overlaysConfig.overlays.map((o) => [o.id, o]));
14
514
  const resolved = new Set(selectedIds);
15
515
  const autoAdded = [];
16
- const processDeps = (id) => {
516
+ const explanations = new Map();
517
+ const getExplanation = (id) => {
518
+ let explanation = explanations.get(id);
519
+ if (!explanation) {
520
+ explanation = {
521
+ id,
522
+ selectionKind: selectedIds.includes(id) ? 'direct' : 'dependency',
523
+ selectionSource: selectedIds.includes(id) ? origin : 'dependency',
524
+ reasons: [],
525
+ };
526
+ explanations.set(id, explanation);
527
+ }
528
+ return explanation;
529
+ };
530
+ const addReason = (id, reason) => {
531
+ const explanation = getExplanation(id);
532
+ if (selectedIds.includes(id)) {
533
+ explanation.selectionKind = 'direct';
534
+ explanation.selectionSource = origin;
535
+ }
536
+ const key = `${reason.kind}|${reason.rootOverlayId}|${reason.sourceOverlayId ?? ''}|${reason.path.join('>')}`;
537
+ const existing = explanation.reasons.some((entry) => `${entry.kind}|${entry.rootOverlayId}|${entry.sourceOverlayId ?? ''}|${entry.path.join('>')}` ===
538
+ key);
539
+ if (!existing) {
540
+ explanation.reasons.push(reason);
541
+ }
542
+ };
543
+ for (const id of selectedIds) {
544
+ addReason(id, {
545
+ kind: 'selected',
546
+ message: origin === 'manifest' ? 'selected from manifest' : 'selected directly by the user',
547
+ origin,
548
+ rootOverlayId: id,
549
+ path: [id],
550
+ depth: 0,
551
+ });
552
+ }
553
+ const processDeps = (id, rootOverlayId, currentPath) => {
17
554
  const overlay = overlayMap.get(id);
18
555
  if (!overlay || !overlay.requires)
19
556
  return;
20
557
  for (const reqId of overlay.requires) {
558
+ if (currentPath.includes(reqId)) {
559
+ continue;
560
+ }
561
+ const nextPath = [...currentPath, reqId];
562
+ const depth = nextPath.length - 1;
563
+ addReason(reqId, {
564
+ kind: depth === 1 ? 'required' : 'transitive',
565
+ message: depth === 1
566
+ ? `required by ${id}`
567
+ : `required transitively via ${currentPath.join(' -> ')}`,
568
+ origin,
569
+ rootOverlayId,
570
+ sourceOverlayId: id,
571
+ path: nextPath,
572
+ depth,
573
+ });
21
574
  if (!resolved.has(reqId)) {
22
575
  resolved.add(reqId);
23
576
  autoAdded.push(reqId);
24
- processDeps(reqId); // Recursive
25
577
  }
578
+ processDeps(reqId, rootOverlayId, nextPath);
26
579
  }
27
580
  };
28
581
  for (const id of selectedIds) {
29
- processDeps(id);
582
+ processDeps(id, id, [id]);
30
583
  }
31
584
  return {
32
585
  resolved: Array.from(resolved),
33
586
  autoAdded,
587
+ explanations,
34
588
  };
35
589
  }
36
590
  /**
@@ -53,6 +607,52 @@ function detectConflicts(overlayIds, overlaysConfig) {
53
607
  }
54
608
  return conflicts;
55
609
  }
610
+ function findManifest(manifestPath) {
611
+ const candidates = [manifestPath];
612
+ for (const candidate of candidates) {
613
+ const resolved = path.resolve(candidate);
614
+ if (fs.existsSync(resolved)) {
615
+ return resolved;
616
+ }
617
+ }
618
+ return null;
619
+ }
620
+ function loadPlanManifest(manifestPath) {
621
+ let rawManifest;
622
+ try {
623
+ rawManifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
624
+ }
625
+ catch (error) {
626
+ console.error(chalk.red(`āœ— Failed to read manifest: ${error instanceof Error ? error.message : String(error)}`));
627
+ process.exit(1);
628
+ }
629
+ if (typeof rawManifest !== 'object' || rawManifest === null) {
630
+ console.error(chalk.red('āœ— Invalid manifest: expected a JSON object'));
631
+ process.exit(1);
632
+ }
633
+ const manifest = rawManifest;
634
+ if (!manifest.baseTemplate || typeof manifest.baseTemplate !== 'string') {
635
+ console.error(chalk.red('āœ— Invalid manifest: missing or invalid "baseTemplate"'));
636
+ process.exit(1);
637
+ }
638
+ const validStacks = ['plain', 'compose'];
639
+ if (!validStacks.includes(manifest.baseTemplate)) {
640
+ console.error(chalk.red(`āœ— Invalid manifest: "baseTemplate" must be one of: ${validStacks.join(', ')}`));
641
+ process.exit(1);
642
+ }
643
+ if (!Array.isArray(manifest.overlays)) {
644
+ console.error(chalk.red('āœ— Invalid manifest: "overlays" must be an array'));
645
+ process.exit(1);
646
+ }
647
+ if (!manifest.overlays.every((overlay) => typeof overlay === 'string')) {
648
+ console.error(chalk.red('āœ— Invalid manifest: "overlays" must be an array of strings'));
649
+ process.exit(1);
650
+ }
651
+ return {
652
+ baseTemplate: manifest.baseTemplate,
653
+ overlays: manifest.overlays,
654
+ };
655
+ }
56
656
  /**
57
657
  * Get all files that will be created/modified
58
658
  */
@@ -154,7 +754,7 @@ function formatAsText(plan, overlaysConfig) {
154
754
  lines.push(chalk.bold('Stack:') + ` ${plan.stack}`);
155
755
  // Overlays
156
756
  lines.push('');
157
- lines.push(chalk.bold('Overlays Selected:'));
757
+ lines.push(chalk.bold(plan.inputMode === 'manifest' ? 'Overlays Loaded from Manifest:' : 'Overlays Selected:'));
158
758
  for (const id of plan.selectedOverlays) {
159
759
  const overlay = overlayMap.get(id);
160
760
  const name = overlay ? ` (${overlay.name})` : '';
@@ -170,6 +770,31 @@ function formatAsText(plan, overlaysConfig) {
170
770
  lines.push(` ${chalk.yellow('+')} ${chalk.cyan(id)}${chalk.gray(name)}`);
171
771
  }
172
772
  }
773
+ if (plan.verbose) {
774
+ lines.push('');
775
+ lines.push(chalk.bold('Dependency Resolution:'));
776
+ for (const explanation of plan.verbose.includedOverlays) {
777
+ const overlay = overlayMap.get(explanation.id);
778
+ const name = overlay ? ` (${overlay.name})` : '';
779
+ lines.push(` ${chalk.cyan(explanation.id)}${chalk.gray(name)}`);
780
+ for (const reason of explanation.reasons) {
781
+ lines.push(` - ${reason.message}`);
782
+ if (reason.path.length > 1) {
783
+ lines.push(` - path: ${reason.path.join(' -> ')}`);
784
+ }
785
+ }
786
+ }
787
+ if (plan.verbose.issues.length > 0) {
788
+ lines.push('');
789
+ lines.push(chalk.bold('Resolution Notes:'));
790
+ for (const issue of plan.verbose.issues) {
791
+ lines.push(` - ${issue.message}`);
792
+ if (issue.path && issue.path.length > 0) {
793
+ lines.push(` path: ${issue.path.join(' -> ')}`);
794
+ }
795
+ }
796
+ }
797
+ }
173
798
  // Conflicts
174
799
  if (plan.conflicts.length > 0) {
175
800
  lines.push('');
@@ -220,52 +845,91 @@ function formatAsText(plan, overlaysConfig) {
220
845
  */
221
846
  export async function planCommand(overlaysConfig, overlaysDir, options) {
222
847
  try {
223
- // Validate required options
224
- if (!options.stack) {
225
- console.error(chalk.red('āœ— --stack is required for plan command'));
226
- console.log(chalk.dim(' Example: container-superposition plan --stack compose --overlays postgres,grafana'));
227
- process.exit(1);
228
- }
229
- // Validate stack value
230
848
  const validStacks = ['plain', 'compose'];
231
- if (!validStacks.includes(options.stack)) {
232
- console.error(chalk.red(`āœ— Invalid --stack value: ${options.stack}`));
233
- console.log(chalk.dim(` Valid values are: ${validStacks.join(', ')}\n` +
234
- ' Example: container-superposition plan --stack compose --overlays postgres,grafana'));
235
- process.exit(1);
236
- }
237
- if (!options.overlays) {
238
- console.error(chalk.red('āœ— --overlays is required for plan command'));
239
- console.log(chalk.dim(' Example: container-superposition plan --stack compose --overlays postgres,grafana'));
240
- process.exit(1);
849
+ let stack;
850
+ let selectedOverlays;
851
+ let inputMode;
852
+ let selectionOrigin;
853
+ if (options.fromManifest) {
854
+ if (options.overlays) {
855
+ console.error(chalk.red('āœ— Use either --overlays or --from-manifest for plan command'));
856
+ process.exit(1);
857
+ }
858
+ const manifestPath = findManifest(options.fromManifest);
859
+ if (!manifestPath) {
860
+ console.error(chalk.red(`āœ— Could not find manifest file: ${options.fromManifest}`));
861
+ process.exit(1);
862
+ }
863
+ const manifest = loadPlanManifest(manifestPath);
864
+ if (options.stack && options.stack !== manifest.baseTemplate) {
865
+ console.error(chalk.red(`āœ— --stack ${options.stack} does not match manifest baseTemplate ${manifest.baseTemplate}`));
866
+ process.exit(1);
867
+ }
868
+ stack = manifest.baseTemplate;
869
+ inputMode = 'manifest';
870
+ selectionOrigin = 'manifest';
871
+ const seenOverlayIds = new Set();
872
+ selectedOverlays = manifest.overlays
873
+ .map((id) => id.trim())
874
+ .filter((id) => {
875
+ if (!id || seenOverlayIds.has(id)) {
876
+ return false;
877
+ }
878
+ seenOverlayIds.add(id);
879
+ return true;
880
+ });
241
881
  }
242
- // Parse overlays - filter empty entries and deduplicate
243
- const seenOverlayIds = new Set();
244
- const selectedOverlays = options.overlays
245
- .split(',')
246
- .map((o) => o.trim())
247
- .filter((id) => {
248
- if (!id) {
249
- return false;
882
+ else {
883
+ if (!options.stack) {
884
+ options.stack = 'compose';
250
885
  }
251
- if (seenOverlayIds.has(id)) {
252
- return false;
886
+ if (!validStacks.includes(options.stack)) {
887
+ console.error(chalk.red(`āœ— Invalid --stack value: ${options.stack}`));
888
+ console.log(chalk.dim(` Valid values are: ${validStacks.join(', ')}\n` +
889
+ ' Example: container-superposition plan --stack compose --overlays postgres,grafana'));
890
+ process.exit(1);
253
891
  }
254
- seenOverlayIds.add(id);
255
- return true;
256
- });
892
+ if (!options.overlays) {
893
+ console.error(chalk.red('āœ— --overlays is required for plan command'));
894
+ console.log(chalk.dim(' Example: container-superposition plan --stack compose --overlays postgres,grafana'));
895
+ process.exit(1);
896
+ }
897
+ stack = options.stack;
898
+ inputMode = 'overlay-list';
899
+ selectionOrigin = 'command-line';
900
+ const seenOverlayIds = new Set();
901
+ selectedOverlays = options.overlays
902
+ .split(',')
903
+ .map((o) => o.trim())
904
+ .filter((id) => {
905
+ if (!id) {
906
+ return false;
907
+ }
908
+ if (seenOverlayIds.has(id)) {
909
+ return false;
910
+ }
911
+ seenOverlayIds.add(id);
912
+ return true;
913
+ });
914
+ }
257
915
  const portOffset = options.portOffset || 0;
258
916
  // Validate overlays exist
259
917
  const overlayMap = new Map(overlaysConfig.overlays.map((o) => [o.id, o]));
260
918
  for (const id of selectedOverlays) {
261
919
  if (!overlayMap.has(id)) {
262
920
  console.error(chalk.red(`āœ— Unknown overlay: ${id}`));
921
+ if (options.verbose && inputMode === 'overlay-list') {
922
+ console.log(chalk.dim(` Dependency resolution did not start because "${id}" is not a known overlay.`));
923
+ }
924
+ if (inputMode === 'manifest') {
925
+ console.error(chalk.dim(` Manifest-driven planning cannot continue because "${id}" is not a known overlay.`));
926
+ }
263
927
  console.log(chalk.dim('\nšŸ’” Use "container-superposition list" to see available overlays\n'));
264
928
  process.exit(1);
265
929
  }
266
930
  }
267
931
  // Resolve dependencies
268
- const { resolved, autoAdded } = resolveDependencies(selectedOverlays, overlaysConfig);
932
+ const { resolved, autoAdded, explanations } = resolveDependencies(selectedOverlays, overlaysConfig, selectionOrigin);
269
933
  // Apply stack compatibility filtering (match composeDevContainer behavior)
270
934
  let compatibleResolved = resolved;
271
935
  const incompatible = [];
@@ -276,7 +940,7 @@ export async function planCommand(overlaysConfig, overlaysDir, options) {
276
940
  }
277
941
  // Check if overlay supports this stack
278
942
  if (overlay.supports && overlay.supports.length > 0) {
279
- const isCompatible = overlay.supports.includes(options.stack);
943
+ const isCompatible = overlay.supports.includes(stack);
280
944
  if (!isCompatible) {
281
945
  incompatible.push(id);
282
946
  }
@@ -285,25 +949,100 @@ export async function planCommand(overlaysConfig, overlaysDir, options) {
285
949
  // Empty supports array means supports all stacks
286
950
  return true;
287
951
  });
952
+ const issues = [];
288
953
  // Warn about incompatible overlays
289
954
  for (const id of incompatible) {
290
- console.warn(chalk.yellow(`⚠ Overlay "${id}" does not support stack "${options.stack}" and will be skipped.`));
955
+ console.warn(chalk.yellow(`⚠ Overlay "${id}" does not support stack "${stack}" and will be skipped.`));
956
+ const explanation = explanations.get(id);
957
+ issues.push({
958
+ kind: 'skipped',
959
+ overlayId: id,
960
+ message: `Overlay "${id}" was skipped because it does not support stack "${stack}".`,
961
+ path: explanation?.reasons[0]?.path,
962
+ });
291
963
  }
292
964
  // Detect conflicts
293
965
  const conflicts = detectConflicts(compatibleResolved, overlaysConfig);
966
+ for (const conflict of conflicts) {
967
+ const explanation = explanations.get(conflict.overlay);
968
+ issues.push({
969
+ kind: 'conflict',
970
+ overlayId: conflict.overlay,
971
+ relatedOverlayIds: conflict.conflictsWith,
972
+ message: `Overlay "${conflict.overlay}" conflicts with ${conflict.conflictsWith.join(', ')}.`,
973
+ path: explanation?.reasons[0]?.path,
974
+ });
975
+ }
294
976
  // Get port mappings
295
977
  const portMappings = getPortMappings(compatibleResolved, overlaysConfig, portOffset);
978
+ // Determine output path for file comparison (used by both normal and diff modes)
979
+ const outputPath = options.output || '.devcontainer';
296
980
  // Get files to create
297
- const files = getFilesToCreate(compatibleResolved, overlaysDir, '.devcontainer');
981
+ const files = getFilesToCreate(compatibleResolved, overlaysDir, outputPath);
982
+ const includedOverlays = compatibleResolved.map((id) => {
983
+ const explanation = explanations.get(id);
984
+ if (explanation) {
985
+ return explanation;
986
+ }
987
+ return {
988
+ id,
989
+ selectionKind: selectedOverlays.includes(id)
990
+ ? 'direct'
991
+ : 'dependency',
992
+ selectionSource: selectedOverlays.includes(id)
993
+ ? selectionOrigin
994
+ : 'dependency',
995
+ reasons: [],
996
+ };
997
+ });
998
+ const compatibleAutoAdded = autoAdded.filter((id) => compatibleResolved.includes(id));
298
999
  const plan = {
299
- stack: options.stack,
1000
+ stack,
300
1001
  selectedOverlays,
301
- autoAddedOverlays: autoAdded,
1002
+ autoAddedOverlays: compatibleAutoAdded,
302
1003
  conflicts,
303
1004
  portMappings,
304
1005
  files,
305
1006
  portOffset,
1007
+ inputMode,
1008
+ verbose: options.verbose
1009
+ ? {
1010
+ inputMode,
1011
+ includedOverlays,
1012
+ summary: {
1013
+ directSelections: selectedOverlays.length,
1014
+ autoAdded: compatibleAutoAdded.length,
1015
+ includedOverlays: compatibleResolved.length,
1016
+ skippedOverlays: incompatible.length,
1017
+ conflicts: conflicts.length,
1018
+ },
1019
+ issues,
1020
+ }
1021
+ : undefined,
306
1022
  };
1023
+ // ── Diff mode ─────────────────────────────────────────────────────────
1024
+ if (options.diff) {
1025
+ const contextLines = options.diffContext ?? 3;
1026
+ const diffResult = generatePlanDiff(plan, overlaysConfig, overlaysDir, outputPath, contextLines);
1027
+ if (options.diffFormat === 'json' || options.json) {
1028
+ console.log(JSON.stringify(diffResult, null, 2));
1029
+ return;
1030
+ }
1031
+ console.log(formatDiffAsText(diffResult, contextLines));
1032
+ // Still show run hint if no conflicts
1033
+ if (conflicts.length > 0) {
1034
+ console.log(chalk.yellow('⚠ Cannot proceed with generation due to conflicts. Remove conflicting overlays.\n'));
1035
+ process.exit(1);
1036
+ }
1037
+ else {
1038
+ const rerunHint = options.fromManifest
1039
+ ? `container-superposition init --from-manifest ${options.fromManifest} --no-interactive`
1040
+ : `container-superposition init --stack ${stack} --overlays ${options.overlays}${portOffset > 0 ? ` --port-offset ${portOffset}` : ''}`;
1041
+ console.log(chalk.dim(` Run: ${rerunHint}\n`));
1042
+ }
1043
+ return;
1044
+ }
1045
+ // ── Normal mode ───────────────────────────────────────────────────────
307
1046
  // Output as JSON
308
1047
  if (options.json) {
309
1048
  console.log(JSON.stringify(plan, null, 2));
@@ -317,8 +1056,11 @@ export async function planCommand(overlaysConfig, overlaysDir, options) {
317
1056
  process.exit(1);
318
1057
  }
319
1058
  else {
1059
+ const rerunHint = options.fromManifest
1060
+ ? `container-superposition init --from-manifest ${options.fromManifest} --no-interactive`
1061
+ : `container-superposition init --stack ${stack} --overlays ${options.overlays}${portOffset > 0 ? ` --port-offset ${portOffset}` : ''}`;
320
1062
  console.log(chalk.green('āœ“ No conflicts detected. Ready to generate!\n') +
321
- chalk.dim(` Run: container-superposition init --stack ${options.stack} --overlays ${options.overlays}${portOffset > 0 ? ` --port-offset ${portOffset}` : ''}\n`));
1063
+ chalk.dim(` Run: ${rerunHint}\n`));
322
1064
  }
323
1065
  }
324
1066
  catch (error) {