container-superposition 0.1.4 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +74 -1370
- package/dist/scripts/init.js +350 -185
- package/dist/scripts/init.js.map +1 -1
- package/dist/tool/commands/adopt.d.ts +63 -0
- package/dist/tool/commands/adopt.d.ts.map +1 -0
- package/dist/tool/commands/adopt.js +1104 -0
- package/dist/tool/commands/adopt.js.map +1 -0
- package/dist/tool/commands/hash.d.ts +36 -0
- package/dist/tool/commands/hash.d.ts.map +1 -0
- package/dist/tool/commands/hash.js +242 -0
- package/dist/tool/commands/hash.js.map +1 -0
- package/dist/tool/commands/plan.d.ts +2 -0
- package/dist/tool/commands/plan.d.ts.map +1 -1
- package/dist/tool/commands/plan.js +262 -42
- package/dist/tool/commands/plan.js.map +1 -1
- package/dist/tool/schema/project-config.d.ts +17 -0
- package/dist/tool/schema/project-config.d.ts.map +1 -0
- package/dist/tool/schema/project-config.js +441 -0
- package/dist/tool/schema/project-config.js.map +1 -0
- package/dist/tool/schema/types.d.ts +39 -1
- package/dist/tool/schema/types.d.ts.map +1 -1
- package/dist/tool/utils/backup.d.ts +23 -0
- package/dist/tool/utils/backup.d.ts.map +1 -0
- package/dist/tool/utils/backup.js +123 -0
- package/dist/tool/utils/backup.js.map +1 -0
- package/docs/README.md +12 -2
- package/docs/adopt.md +202 -0
- package/docs/custom-patches.md +1 -1
- package/docs/discovery-commands.md +55 -3
- package/docs/examples.md +40 -6
- package/docs/filesystem-contract.md +58 -0
- package/docs/hash.md +183 -0
- package/docs/minimal-and-editor.md +1 -1
- package/docs/overlays.md +70 -0
- package/docs/presets-architecture.md +1 -1
- package/docs/presets.md +1 -1
- package/docs/publishing.md +36 -23
- package/docs/security.md +43 -0
- package/docs/specs/001-verbose-plan-graph/checklists/requirements.md +36 -0
- package/docs/specs/001-verbose-plan-graph/contracts/plan-verbose-output.md +96 -0
- package/docs/specs/001-verbose-plan-graph/data-model.md +111 -0
- package/docs/specs/001-verbose-plan-graph/plan.md +127 -0
- package/docs/specs/001-verbose-plan-graph/quickstart.md +106 -0
- package/docs/specs/001-verbose-plan-graph/research.md +100 -0
- package/docs/specs/001-verbose-plan-graph/spec.md +128 -0
- package/docs/specs/001-verbose-plan-graph/tasks.md +223 -0
- package/docs/specs/002-superposition-config-file/checklists/requirements.md +36 -0
- package/docs/specs/002-superposition-config-file/contracts/init-project-config.md +98 -0
- package/docs/specs/002-superposition-config-file/data-model.md +126 -0
- package/docs/specs/002-superposition-config-file/plan.md +213 -0
- package/docs/specs/002-superposition-config-file/quickstart.md +140 -0
- package/docs/specs/002-superposition-config-file/research.md +144 -0
- package/docs/specs/002-superposition-config-file/spec.md +136 -0
- package/docs/specs/002-superposition-config-file/tasks.md +215 -0
- package/docs/team-workflow.md +33 -1
- package/docs/workflows.md +139 -0
- package/features/cross-distro-packages/README.md +18 -0
- package/features/cross-distro-packages/devcontainer-feature.json +3 -3
- package/features/cross-distro-packages/install.sh +49 -7
- package/overlays/.presets/sdd.yml +84 -0
- package/overlays/README.md +7 -1
- package/overlays/amp/README.md +70 -0
- package/overlays/amp/devcontainer.patch.json +3 -0
- package/overlays/amp/overlay.yml +15 -0
- package/overlays/amp/setup.sh +21 -0
- package/overlays/amp/verify.sh +21 -0
- package/overlays/claude-code/README.md +83 -0
- package/overlays/claude-code/devcontainer.patch.json +3 -0
- package/overlays/claude-code/overlay.yml +15 -0
- package/overlays/claude-code/setup.sh +21 -0
- package/overlays/claude-code/verify.sh +21 -0
- package/overlays/gemini-cli/README.md +77 -0
- package/overlays/gemini-cli/devcontainer.patch.json +3 -0
- package/overlays/gemini-cli/overlay.yml +15 -0
- package/overlays/gemini-cli/setup.sh +21 -0
- package/overlays/gemini-cli/verify.sh +21 -0
- package/overlays/opencode/README.md +76 -0
- package/overlays/opencode/devcontainer.patch.json +3 -0
- package/overlays/opencode/overlay.yml +14 -0
- package/overlays/opencode/setup.sh +21 -0
- package/overlays/opencode/verify.sh +21 -0
- package/overlays/pandoc/README.md +279 -0
- package/overlays/pandoc/devcontainer.patch.json +14 -0
- package/overlays/pandoc/overlay.yml +19 -0
- package/overlays/pandoc/setup.sh +94 -0
- package/overlays/pandoc/verify.sh +13 -0
- package/overlays/spec-kit/README.md +181 -0
- package/overlays/spec-kit/devcontainer.patch.json +6 -0
- package/overlays/spec-kit/overlay.yml +19 -0
- package/overlays/spec-kit/setup.sh +45 -0
- package/overlays/spec-kit/verify.sh +33 -0
- package/overlays/windsurf-cli/README.md +69 -0
- package/overlays/windsurf-cli/devcontainer.patch.json +3 -0
- package/overlays/windsurf-cli/overlay.yml +15 -0
- package/overlays/windsurf-cli/setup.sh +21 -0
- package/overlays/windsurf-cli/verify.sh +21 -0
- package/package.json +1 -1
- package/tool/schema/config.schema.json +138 -9
package/dist/scripts/init.js
CHANGED
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
import * as fs from 'fs';
|
|
3
3
|
import * as path from 'path';
|
|
4
4
|
import { fileURLToPath } from 'url';
|
|
5
|
-
import { execSync } from 'child_process';
|
|
6
5
|
import { Command } from 'commander';
|
|
7
6
|
import chalk from 'chalk';
|
|
8
7
|
import boxen from 'boxen';
|
|
@@ -15,11 +14,14 @@ import { listCommand } from '../tool/commands/list.js';
|
|
|
15
14
|
import { explainCommand } from '../tool/commands/explain.js';
|
|
16
15
|
import { planCommand } from '../tool/commands/plan.js';
|
|
17
16
|
import { doctorCommand } from '../tool/commands/doctor.js';
|
|
17
|
+
import { adoptCommand } from '../tool/commands/adopt.js';
|
|
18
|
+
import { hashCommand } from '../tool/commands/hash.js';
|
|
18
19
|
import { getIncompatibleOverlays, DEPLOYMENT_TARGETS } from '../tool/schema/deployment-targets.js';
|
|
19
20
|
import { migrateManifest, needsMigration, detectManifestVersion, isVersionSupported, CURRENT_MANIFEST_VERSION, SUPPORTED_MANIFEST_VERSIONS, } from '../tool/schema/manifest-migrations.js';
|
|
20
21
|
import { getToolVersion } from '../tool/utils/version.js';
|
|
21
22
|
import { printSummary } from '../tool/utils/summary.js';
|
|
22
|
-
import {
|
|
23
|
+
import { buildAnswersFromProjectConfig, loadProjectConfig, writeProjectConfigCustomizations, } from '../tool/schema/project-config.js';
|
|
24
|
+
import { isInsideGitRepo, createBackup, ensureBackupPatternsInGitignore, } from '../tool/utils/backup.js';
|
|
23
25
|
// Get __dirname equivalent in ESM
|
|
24
26
|
const __filename = fileURLToPath(import.meta.url);
|
|
25
27
|
const __dirname = path.dirname(__filename);
|
|
@@ -65,6 +67,100 @@ function loadPresetDefinition(presetId) {
|
|
|
65
67
|
const content = fs.readFileSync(presetPath, 'utf8');
|
|
66
68
|
return yaml.load(content);
|
|
67
69
|
}
|
|
70
|
+
function categorizeOverlayIds(overlayIds, config) {
|
|
71
|
+
const language = [];
|
|
72
|
+
const database = [];
|
|
73
|
+
const observability = [];
|
|
74
|
+
const cloudTools = [];
|
|
75
|
+
const devTools = [];
|
|
76
|
+
const overlayMap = new Map(config.overlays.map((o) => [o.id, o]));
|
|
77
|
+
for (const id of overlayIds) {
|
|
78
|
+
const overlay = overlayMap.get(id);
|
|
79
|
+
if (!overlay)
|
|
80
|
+
continue;
|
|
81
|
+
switch (overlay.category) {
|
|
82
|
+
case 'language':
|
|
83
|
+
language.push(id);
|
|
84
|
+
break;
|
|
85
|
+
case 'database':
|
|
86
|
+
database.push(id);
|
|
87
|
+
break;
|
|
88
|
+
case 'observability':
|
|
89
|
+
observability.push(id);
|
|
90
|
+
break;
|
|
91
|
+
case 'cloud':
|
|
92
|
+
cloudTools.push(id);
|
|
93
|
+
break;
|
|
94
|
+
case 'dev':
|
|
95
|
+
devTools.push(id);
|
|
96
|
+
break;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return { language, database, observability, cloudTools, devTools };
|
|
100
|
+
}
|
|
101
|
+
function mergeUnique(left, right) {
|
|
102
|
+
const merged = [...(left ?? []), ...(right ?? [])];
|
|
103
|
+
return merged.length > 0 ? [...new Set(merged)] : undefined;
|
|
104
|
+
}
|
|
105
|
+
function expandPresetWithDefaults(presetId, stack, providedChoices = {}) {
|
|
106
|
+
const preset = loadPresetDefinition(presetId);
|
|
107
|
+
if (!preset) {
|
|
108
|
+
throw new Error(`Preset definition not found for ${presetId}`);
|
|
109
|
+
}
|
|
110
|
+
const overlays = [...preset.selects.required];
|
|
111
|
+
const choices = {};
|
|
112
|
+
if (preset.selects.userChoice) {
|
|
113
|
+
for (const [key, choice] of Object.entries(preset.selects.userChoice)) {
|
|
114
|
+
const selectedOption = providedChoices[key] ?? choice.defaultOption;
|
|
115
|
+
if (!selectedOption || !choice.options.includes(selectedOption)) {
|
|
116
|
+
const valid = choice.options.join(', ');
|
|
117
|
+
throw new Error(`Preset choice '${key}' must be one of: ${valid}`);
|
|
118
|
+
}
|
|
119
|
+
overlays.push(selectedOption);
|
|
120
|
+
choices[key] = selectedOption;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (preset.parameters) {
|
|
124
|
+
for (const [key, param] of Object.entries(preset.parameters)) {
|
|
125
|
+
const selectedId = providedChoices[key] ?? param.default;
|
|
126
|
+
const selectedOption = param.options.find((option) => option.id === selectedId);
|
|
127
|
+
if (!selectedOption) {
|
|
128
|
+
const valid = param.options.map((option) => option.id).join(', ');
|
|
129
|
+
throw new Error(`Preset parameter '${key}' must be one of: ${valid}`);
|
|
130
|
+
}
|
|
131
|
+
overlays.push(...selectedOption.overlays);
|
|
132
|
+
choices[key] = selectedId;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
const uniqueOverlays = [...new Set(overlays)];
|
|
136
|
+
let resolvedGlueConfig = preset.glueConfig;
|
|
137
|
+
if (resolvedGlueConfig?.environment) {
|
|
138
|
+
const resolvedEnv = {};
|
|
139
|
+
for (const [envKey, envValue] of Object.entries(resolvedGlueConfig.environment)) {
|
|
140
|
+
resolvedEnv[envKey] = envValue.replace(/\{\{parameters\.(\w+)\.id\}\}/g, (_match, paramKey) => choices[paramKey] ?? _match);
|
|
141
|
+
}
|
|
142
|
+
resolvedGlueConfig = { ...resolvedGlueConfig, environment: resolvedEnv };
|
|
143
|
+
}
|
|
144
|
+
return { overlays: uniqueOverlays, choices, glueConfig: resolvedGlueConfig };
|
|
145
|
+
}
|
|
146
|
+
function applyPresetSelections(answers) {
|
|
147
|
+
if (!answers.preset) {
|
|
148
|
+
return answers;
|
|
149
|
+
}
|
|
150
|
+
const expansion = expandPresetWithDefaults(answers.preset, answers.stack ?? 'plain', answers.presetChoices ?? {});
|
|
151
|
+
const categories = categorizeOverlayIds(expansion.overlays, loadOverlaysConfigWrapper());
|
|
152
|
+
return {
|
|
153
|
+
...answers,
|
|
154
|
+
language: mergeUnique(categories.language, answers.language),
|
|
155
|
+
database: mergeUnique(categories.database, answers.database),
|
|
156
|
+
observability: mergeUnique(categories.observability, answers.observability),
|
|
157
|
+
cloudTools: mergeUnique(categories.cloudTools, answers.cloudTools),
|
|
158
|
+
devTools: mergeUnique(categories.devTools, answers.devTools),
|
|
159
|
+
playwright: answers.playwright ?? expansion.overlays.includes('playwright'),
|
|
160
|
+
presetChoices: Object.keys(expansion.choices).length > 0 ? expansion.choices : undefined,
|
|
161
|
+
presetGlueConfig: expansion.glueConfig,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
68
164
|
/**
|
|
69
165
|
* Expand a preset into a list of overlay IDs with user choices resolved
|
|
70
166
|
*/
|
|
@@ -141,7 +237,17 @@ async function expandPreset(presetId, stack, preProvidedChoices = {}) {
|
|
|
141
237
|
// Deduplicate overlays
|
|
142
238
|
const uniqueOverlays = [...new Set(overlays)];
|
|
143
239
|
console.log(chalk.dim(`✓ Preset will include: ${uniqueOverlays.join(', ')}\n`));
|
|
144
|
-
|
|
240
|
+
// Apply template substitution to glueConfig.environment values.
|
|
241
|
+
// Replaces {{parameters.<key>.id}} with the selected choice for <key>.
|
|
242
|
+
let resolvedGlueConfig = preset.glueConfig;
|
|
243
|
+
if (resolvedGlueConfig?.environment) {
|
|
244
|
+
const resolvedEnv = {};
|
|
245
|
+
for (const [envKey, envValue] of Object.entries(resolvedGlueConfig.environment)) {
|
|
246
|
+
resolvedEnv[envKey] = envValue.replace(/\{\{parameters\.(\w+)\.id\}\}/g, (_match, paramKey) => choices[paramKey] ?? _match);
|
|
247
|
+
}
|
|
248
|
+
resolvedGlueConfig = { ...resolvedGlueConfig, environment: resolvedEnv };
|
|
249
|
+
}
|
|
250
|
+
return { overlays: uniqueOverlays, choices, glueConfig: resolvedGlueConfig };
|
|
145
251
|
}
|
|
146
252
|
/**
|
|
147
253
|
* Search for manifest file in multiple locations
|
|
@@ -164,6 +270,16 @@ function findManifestFile(manifestPath) {
|
|
|
164
270
|
}
|
|
165
271
|
return null;
|
|
166
272
|
}
|
|
273
|
+
function findDefaultRegenManifest(outputPath = './.devcontainer') {
|
|
274
|
+
const manifestSearchPaths = ['superposition.json', path.join(outputPath, 'superposition.json')];
|
|
275
|
+
for (const searchPath of manifestSearchPaths) {
|
|
276
|
+
const resolvedPath = path.resolve(searchPath);
|
|
277
|
+
if (fs.existsSync(resolvedPath)) {
|
|
278
|
+
return resolvedPath;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
return null;
|
|
282
|
+
}
|
|
167
283
|
/**
|
|
168
284
|
* Load and validate manifest file
|
|
169
285
|
*/
|
|
@@ -210,127 +326,6 @@ function loadManifest(manifestPath) {
|
|
|
210
326
|
return null;
|
|
211
327
|
}
|
|
212
328
|
}
|
|
213
|
-
/**
|
|
214
|
-
* Detect whether a directory (or any of its parents) is inside a git repository.
|
|
215
|
-
* First tries `git rev-parse --git-dir`; if git is unavailable, falls back to
|
|
216
|
-
* walking up the directory tree looking for a `.git` entry.
|
|
217
|
-
*/
|
|
218
|
-
function isInsideGitRepo(dirPath) {
|
|
219
|
-
try {
|
|
220
|
-
execSync('git rev-parse --git-dir', { cwd: dirPath, stdio: 'ignore' });
|
|
221
|
-
return true;
|
|
222
|
-
}
|
|
223
|
-
catch {
|
|
224
|
-
// git command failed (not a repo) or git is not installed — walk up looking for .git
|
|
225
|
-
let current = path.resolve(dirPath);
|
|
226
|
-
while (true) {
|
|
227
|
-
if (fs.existsSync(path.join(current, '.git'))) {
|
|
228
|
-
return true;
|
|
229
|
-
}
|
|
230
|
-
const parent = path.dirname(current);
|
|
231
|
-
if (parent === current) {
|
|
232
|
-
break; // reached filesystem root
|
|
233
|
-
}
|
|
234
|
-
current = parent;
|
|
235
|
-
}
|
|
236
|
-
return false;
|
|
237
|
-
}
|
|
238
|
-
}
|
|
239
|
-
/**
|
|
240
|
-
* Create timestamped backup of existing devcontainer and manifest
|
|
241
|
-
*/
|
|
242
|
-
async function createBackup(outputPath, backupDir) {
|
|
243
|
-
// Check for devcontainer files to backup
|
|
244
|
-
const devcontainerJsonPath = path.join(outputPath, 'devcontainer.json');
|
|
245
|
-
const dockerComposePath = path.join(outputPath, 'docker-compose.yml');
|
|
246
|
-
const devcontainerSubdir = path.join(outputPath, '.devcontainer');
|
|
247
|
-
const manifestPath = path.join(outputPath, 'superposition.json');
|
|
248
|
-
// Determine what exists
|
|
249
|
-
const hasDevcontainerJson = fs.existsSync(devcontainerJsonPath);
|
|
250
|
-
const hasDockerCompose = fs.existsSync(dockerComposePath);
|
|
251
|
-
const hasDevcontainerSubdir = fs.existsSync(devcontainerSubdir) && fs.statSync(devcontainerSubdir).isDirectory();
|
|
252
|
-
const hasManifest = fs.existsSync(manifestPath);
|
|
253
|
-
if (!hasDevcontainerJson && !hasDockerCompose && !hasDevcontainerSubdir && !hasManifest) {
|
|
254
|
-
return null; // Nothing to backup
|
|
255
|
-
}
|
|
256
|
-
// Create timestamp
|
|
257
|
-
const timestamp = new Date()
|
|
258
|
-
.toISOString()
|
|
259
|
-
.replace(/:/g, '-')
|
|
260
|
-
.replace(/\..+/, '')
|
|
261
|
-
.replace('T', '-');
|
|
262
|
-
// Determine backup location - create next to outputPath, not inside it
|
|
263
|
-
const resolvedOutputPath = path.resolve(outputPath);
|
|
264
|
-
const outputParentDir = path.dirname(resolvedOutputPath);
|
|
265
|
-
const outputBaseName = path.basename(resolvedOutputPath);
|
|
266
|
-
const backupBaseName = outputBaseName === '.devcontainer' ? '.devcontainer' : outputBaseName;
|
|
267
|
-
const backupPath = backupDir
|
|
268
|
-
? path.resolve(backupDir)
|
|
269
|
-
: path.join(outputParentDir, `${backupBaseName}.backup-${timestamp}`);
|
|
270
|
-
// Create backup directory
|
|
271
|
-
fs.mkdirSync(backupPath, { recursive: true });
|
|
272
|
-
// Backup files and directories
|
|
273
|
-
if (hasDevcontainerJson) {
|
|
274
|
-
fs.copyFileSync(devcontainerJsonPath, path.join(backupPath, 'devcontainer.json'));
|
|
275
|
-
}
|
|
276
|
-
if (hasDockerCompose) {
|
|
277
|
-
fs.copyFileSync(dockerComposePath, path.join(backupPath, 'docker-compose.yml'));
|
|
278
|
-
}
|
|
279
|
-
if (hasDevcontainerSubdir) {
|
|
280
|
-
const destDir = path.join(backupPath, '.devcontainer');
|
|
281
|
-
await copyDirectory(devcontainerSubdir, destDir);
|
|
282
|
-
}
|
|
283
|
-
if (hasManifest) {
|
|
284
|
-
fs.copyFileSync(manifestPath, path.join(backupPath, 'superposition.json'));
|
|
285
|
-
}
|
|
286
|
-
// Also backup other common devcontainer files
|
|
287
|
-
const otherFiles = ['.env', '.env.example', '.gitignore', 'features', 'scripts'];
|
|
288
|
-
for (const file of otherFiles) {
|
|
289
|
-
const srcPath = path.join(outputPath, file);
|
|
290
|
-
if (fs.existsSync(srcPath)) {
|
|
291
|
-
const destPath = path.join(backupPath, file);
|
|
292
|
-
if (fs.statSync(srcPath).isDirectory()) {
|
|
293
|
-
await copyDirectory(srcPath, destPath);
|
|
294
|
-
}
|
|
295
|
-
else {
|
|
296
|
-
fs.copyFileSync(srcPath, destPath);
|
|
297
|
-
}
|
|
298
|
-
}
|
|
299
|
-
}
|
|
300
|
-
return backupPath;
|
|
301
|
-
}
|
|
302
|
-
/**
|
|
303
|
-
* Recursively copy directory
|
|
304
|
-
*/
|
|
305
|
-
async function copyDirectory(src, dest) {
|
|
306
|
-
fs.mkdirSync(dest, { recursive: true });
|
|
307
|
-
const entries = fs.readdirSync(src, { withFileTypes: true });
|
|
308
|
-
for (const entry of entries) {
|
|
309
|
-
const srcPath = path.join(src, entry.name);
|
|
310
|
-
const destPath = path.join(dest, entry.name);
|
|
311
|
-
if (entry.isDirectory()) {
|
|
312
|
-
await copyDirectory(srcPath, destPath);
|
|
313
|
-
}
|
|
314
|
-
else {
|
|
315
|
-
fs.copyFileSync(srcPath, destPath);
|
|
316
|
-
}
|
|
317
|
-
}
|
|
318
|
-
}
|
|
319
|
-
/**
|
|
320
|
-
* Ensure backup patterns are in .gitignore
|
|
321
|
-
*/
|
|
322
|
-
function ensureBackupPatternsInGitignore(outputPath) {
|
|
323
|
-
const projectRoot = path.dirname(path.resolve(outputPath));
|
|
324
|
-
const gitignorePath = path.join(projectRoot, '.gitignore');
|
|
325
|
-
const written = appendGitignoreSection(gitignorePath, 'container-superposition backups', [
|
|
326
|
-
'.devcontainer.backup-*/',
|
|
327
|
-
'*.backup-*',
|
|
328
|
-
'superposition.json.backup-*',
|
|
329
|
-
]);
|
|
330
|
-
if (written) {
|
|
331
|
-
console.log(chalk.dim(' 📝 Updated .gitignore with backup patterns'));
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
329
|
/**
|
|
335
330
|
* Build checkbox choices for overlay selection with optional pre-selection
|
|
336
331
|
*/
|
|
@@ -360,7 +355,7 @@ function buildOverlayChoices(config, stack, categoryList, preselected) {
|
|
|
360
355
|
/**
|
|
361
356
|
* Interactive questionnaire with modern checkbox selections
|
|
362
357
|
*/
|
|
363
|
-
async function runQuestionnaire(manifest, manifestDir, cliPresetId, cliPresetChoices) {
|
|
358
|
+
async function runQuestionnaire(manifest, manifestDir, cliPresetId, cliPresetChoices, defaultAnswers) {
|
|
364
359
|
const config = loadOverlaysConfigWrapper();
|
|
365
360
|
// Pretty banner
|
|
366
361
|
console.log('\n' +
|
|
@@ -448,7 +443,7 @@ async function runQuestionnaire(manifest, manifestDir, cliPresetId, cliPresetCho
|
|
|
448
443
|
value: t.id,
|
|
449
444
|
description: t.description,
|
|
450
445
|
})),
|
|
451
|
-
default: manifest?.baseTemplate,
|
|
446
|
+
default: manifest?.baseTemplate || defaultAnswers?.stack,
|
|
452
447
|
}));
|
|
453
448
|
// If using preset, expand it now (pass pre-provided choices to skip those prompts)
|
|
454
449
|
if (usePreset && selectedPresetId) {
|
|
@@ -473,7 +468,15 @@ async function runQuestionnaire(manifest, manifestDir, cliPresetId, cliPresetCho
|
|
|
473
468
|
// Check if manifest has a custom image or a known base image
|
|
474
469
|
const knownBaseImageIds = config.base_images.map((img) => img.id);
|
|
475
470
|
const manifestBaseImageIsKnown = manifest?.baseImage && knownBaseImageIds.includes(manifest.baseImage);
|
|
476
|
-
const manifestDefaultBaseImage = manifestBaseImageIsKnown
|
|
471
|
+
const manifestDefaultBaseImage = manifestBaseImageIsKnown
|
|
472
|
+
? manifest.baseImage
|
|
473
|
+
: manifest?.baseImage
|
|
474
|
+
? 'custom'
|
|
475
|
+
: undefined;
|
|
476
|
+
const defaultBaseImage = manifestDefaultBaseImage ||
|
|
477
|
+
(defaultAnswers?.baseImage === 'custom' && defaultAnswers.customImage
|
|
478
|
+
? 'custom'
|
|
479
|
+
: defaultAnswers?.baseImage);
|
|
477
480
|
const baseImage = (await select({
|
|
478
481
|
message: 'Select base image:',
|
|
479
482
|
choices: config.base_images.map((img) => ({
|
|
@@ -481,16 +484,17 @@ async function runQuestionnaire(manifest, manifestDir, cliPresetId, cliPresetCho
|
|
|
481
484
|
value: img.id,
|
|
482
485
|
description: img.description,
|
|
483
486
|
})),
|
|
484
|
-
default:
|
|
487
|
+
default: defaultBaseImage,
|
|
485
488
|
}));
|
|
486
489
|
// Question 2a: If custom, ask for image name
|
|
487
490
|
let customImage;
|
|
488
491
|
if (baseImage === 'custom') {
|
|
489
492
|
// If manifest has a custom image, use it as default
|
|
490
493
|
const manifestCustomImage = !manifestBaseImageIsKnown && manifest?.baseImage ? manifest.baseImage : undefined;
|
|
494
|
+
const defaultCustomImage = manifestCustomImage || defaultAnswers?.customImage;
|
|
491
495
|
customImage = await input({
|
|
492
496
|
message: 'Enter custom Docker image (e.g., ubuntu:22.04):',
|
|
493
|
-
default:
|
|
497
|
+
default: defaultCustomImage,
|
|
494
498
|
validate: (value) => {
|
|
495
499
|
if (!value || value.trim() === '') {
|
|
496
500
|
return 'Image name is required';
|
|
@@ -606,7 +610,15 @@ async function runQuestionnaire(manifest, manifestDir, cliPresetId, cliPresetCho
|
|
|
606
610
|
else {
|
|
607
611
|
// Custom mode: Normal overlay selection
|
|
608
612
|
console.log(chalk.dim('\n💡 Select overlays: Space to toggle, ↑/↓ to navigate, Enter to confirm\n'));
|
|
609
|
-
const
|
|
613
|
+
const preselectedDefaults = [
|
|
614
|
+
...(defaultAnswers?.language ?? []),
|
|
615
|
+
...(defaultAnswers?.database ?? []),
|
|
616
|
+
...(defaultAnswers?.observability ?? []),
|
|
617
|
+
...(defaultAnswers?.cloudTools ?? []),
|
|
618
|
+
...(defaultAnswers?.devTools ?? []),
|
|
619
|
+
...(defaultAnswers?.playwright ? ['playwright'] : []),
|
|
620
|
+
];
|
|
621
|
+
const choices = buildOverlayChoices(config, stack, categoryList, preselectedDefaults);
|
|
610
622
|
userSelection = await checkbox({
|
|
611
623
|
message: 'Select overlays to include:',
|
|
612
624
|
choices,
|
|
@@ -683,11 +695,11 @@ async function runQuestionnaire(manifest, manifestDir, cliPresetId, cliPresetCho
|
|
|
683
695
|
// Question 4: Container name
|
|
684
696
|
const containerName = await input({
|
|
685
697
|
message: 'Container/project name (optional):',
|
|
686
|
-
default: manifest?.containerName || '',
|
|
698
|
+
default: manifest?.containerName || defaultAnswers?.containerName || '',
|
|
687
699
|
});
|
|
688
700
|
// Question 5: Output path
|
|
689
701
|
// If manifest provided, default to its location; otherwise use ./.devcontainer
|
|
690
|
-
const defaultOutput = manifestDir || './.devcontainer';
|
|
702
|
+
const defaultOutput = manifestDir || defaultAnswers?.outputPath || './.devcontainer';
|
|
691
703
|
const outputPath = await input({
|
|
692
704
|
message: 'Output path:',
|
|
693
705
|
default: defaultOutput,
|
|
@@ -695,7 +707,11 @@ async function runQuestionnaire(manifest, manifestDir, cliPresetId, cliPresetCho
|
|
|
695
707
|
// Question 6: Port offset (optional, for running multiple instances)
|
|
696
708
|
const portOffsetInput = await input({
|
|
697
709
|
message: 'Port offset (leave empty for default ports, e.g., 100 to avoid conflicts):',
|
|
698
|
-
default: manifest?.portOffset
|
|
710
|
+
default: manifest?.portOffset !== undefined
|
|
711
|
+
? String(manifest.portOffset)
|
|
712
|
+
: defaultAnswers?.portOffset !== undefined
|
|
713
|
+
? String(defaultAnswers.portOffset)
|
|
714
|
+
: '',
|
|
699
715
|
});
|
|
700
716
|
const portOffset = portOffsetInput ? parseInt(portOffsetInput, 10) : undefined;
|
|
701
717
|
// Parse selected overlays into categories
|
|
@@ -790,7 +806,10 @@ async function runQuestionnaire(manifest, manifestDir, cliPresetId, cliPresetCho
|
|
|
790
806
|
observability,
|
|
791
807
|
outputPath,
|
|
792
808
|
portOffset,
|
|
793
|
-
target,
|
|
809
|
+
target: target ?? defaultAnswers?.target,
|
|
810
|
+
minimal: defaultAnswers?.minimal,
|
|
811
|
+
editor: defaultAnswers?.editor,
|
|
812
|
+
customizations: defaultAnswers?.customizations,
|
|
794
813
|
};
|
|
795
814
|
}
|
|
796
815
|
catch (error) {
|
|
@@ -960,8 +979,10 @@ async function parseCliArgs() {
|
|
|
960
979
|
program
|
|
961
980
|
.command('init', { isDefault: true })
|
|
962
981
|
.description('Initialize a new devcontainer configuration')
|
|
982
|
+
.option('--from-project', 'Load configuration from the repository project file')
|
|
983
|
+
.option('--project-root <path>', 'Run project-file and manifest discovery relative to a different repository root')
|
|
963
984
|
.option('--from-manifest <path>', 'Load configuration from existing superposition.json manifest')
|
|
964
|
-
.option('--no-interactive', 'Use
|
|
985
|
+
.option('--no-interactive', 'Use persisted input values directly without questionnaire')
|
|
965
986
|
.option('--backup', 'Force or suppress backup; default is --no-backup inside a git repo, --backup outside')
|
|
966
987
|
.option('--backup-dir <path>', 'Custom backup directory location')
|
|
967
988
|
.option('--stack <type>', 'Base template: plain, compose')
|
|
@@ -979,47 +1000,33 @@ async function parseCliArgs() {
|
|
|
979
1000
|
.option('--write-manifest-only', 'Generate only superposition.json manifest without creating .devcontainer/ files')
|
|
980
1001
|
.option('--preset <id>', 'Start from a preset (e.g., web-api, microservice)')
|
|
981
1002
|
.option('--preset-param <value>', 'Set a preset parameter value (format: key=value, can be repeated)', (value, previous) => previous.concat([value]), [])
|
|
982
|
-
.action((options) => {
|
|
1003
|
+
.action((options, command) => {
|
|
983
1004
|
// Store options for main() to process
|
|
984
|
-
initOptions =
|
|
1005
|
+
initOptions = {
|
|
1006
|
+
...options,
|
|
1007
|
+
commandName: 'init',
|
|
1008
|
+
_targetSource: command.getOptionValueSource('target'),
|
|
1009
|
+
_editorSource: command.getOptionValueSource('editor'),
|
|
1010
|
+
};
|
|
985
1011
|
});
|
|
986
1012
|
// Regen command
|
|
987
1013
|
program
|
|
988
1014
|
.command('regen')
|
|
989
|
-
.description('Regenerate devcontainer from existing superposition.json manifest')
|
|
1015
|
+
.description('Regenerate devcontainer from a project file or existing superposition.json manifest')
|
|
1016
|
+
.option('--from-project', 'Load configuration from the repository project file')
|
|
1017
|
+
.option('--project-root <path>', 'Run project-file and manifest discovery relative to a different repository root')
|
|
1018
|
+
.option('--from-manifest <path>', 'Load configuration from existing superposition.json manifest')
|
|
990
1019
|
.option('-o, --output <path>', 'Output path (default: ./.devcontainer)')
|
|
991
1020
|
.option('--backup', 'Force or suppress backup; default is --no-backup inside a git repo, --backup outside')
|
|
992
1021
|
.option('--backup-dir <path>', 'Custom backup directory location')
|
|
993
1022
|
.option('--minimal', 'Minimal mode - exclude optional/nice-to-have features and extensions')
|
|
994
1023
|
.option('--editor <profile>', 'Editor profile: vscode (default), jetbrains, none', 'vscode')
|
|
995
|
-
.action((options) => {
|
|
996
|
-
const outputPath = options.output || './.devcontainer';
|
|
997
|
-
// Look for manifest in multiple locations for team workflow:
|
|
998
|
-
// 1. Current directory (./superposition.json) - team workflow
|
|
999
|
-
// 2. Output directory (e.g., ./.devcontainer/superposition.json) - legacy
|
|
1000
|
-
const manifestSearchPaths = [
|
|
1001
|
-
'superposition.json',
|
|
1002
|
-
path.join(outputPath, 'superposition.json'),
|
|
1003
|
-
];
|
|
1004
|
-
let manifestPath = null;
|
|
1005
|
-
for (const searchPath of manifestSearchPaths) {
|
|
1006
|
-
if (fs.existsSync(searchPath)) {
|
|
1007
|
-
manifestPath = searchPath;
|
|
1008
|
-
break;
|
|
1009
|
-
}
|
|
1010
|
-
}
|
|
1011
|
-
if (!manifestPath) {
|
|
1012
|
-
console.error(chalk.red(`✗ Error: No manifest found`));
|
|
1013
|
-
console.error(chalk.gray(' Searched for: ./superposition.json, ' +
|
|
1014
|
-
path.join(outputPath, 'superposition.json')));
|
|
1015
|
-
console.error(chalk.gray(' Run "container-superposition init --write-manifest-only" to create a manifest'));
|
|
1016
|
-
process.exit(1);
|
|
1017
|
-
}
|
|
1018
|
-
// Store options for main() to process
|
|
1024
|
+
.action((options, command) => {
|
|
1019
1025
|
initOptions = {
|
|
1020
1026
|
...options,
|
|
1021
|
-
|
|
1027
|
+
commandName: 'regen',
|
|
1022
1028
|
interactive: false,
|
|
1029
|
+
_editorSource: command.getOptionValueSource('editor'),
|
|
1023
1030
|
};
|
|
1024
1031
|
});
|
|
1025
1032
|
// List command
|
|
@@ -1049,13 +1056,15 @@ async function parseCliArgs() {
|
|
|
1049
1056
|
program
|
|
1050
1057
|
.command('plan')
|
|
1051
1058
|
.description('Preview what will be generated before creating devcontainer')
|
|
1052
|
-
.option('--stack <type>', 'Base template: plain, compose'
|
|
1059
|
+
.option('--stack <type>', 'Base template: plain, compose')
|
|
1053
1060
|
.option('--overlays <list>', 'Comma-separated list of overlay IDs')
|
|
1061
|
+
.option('--from-manifest <path>', 'Load stack and overlays from an existing superposition.json manifest')
|
|
1054
1062
|
.option('--port-offset <number>', 'Add offset to all exposed ports', (val) => parseInt(val, 10), 0)
|
|
1055
1063
|
.option('-o, --output <path>', 'Compare against existing config at this path (default: ./.devcontainer)')
|
|
1056
1064
|
.option('--diff', 'Compare planned output vs existing configuration')
|
|
1057
1065
|
.option('--diff-format <format>', 'Diff output format: color (default), json', 'color')
|
|
1058
1066
|
.option('--diff-context <lines>', 'Context lines in diff output', (val) => parseInt(val, 10), 3)
|
|
1067
|
+
.option('--verbose', 'Explain why each overlay was included in the resolved plan')
|
|
1059
1068
|
.option('--json', 'Output as JSON for scripting')
|
|
1060
1069
|
.action(async (options) => {
|
|
1061
1070
|
const overlaysConfig = loadOverlaysConfigWrapper();
|
|
@@ -1073,6 +1082,40 @@ async function parseCliArgs() {
|
|
|
1073
1082
|
const overlaysConfig = loadOverlaysConfigWrapper();
|
|
1074
1083
|
await doctorCommand(overlaysConfig, OVERLAYS_DIR, options);
|
|
1075
1084
|
});
|
|
1085
|
+
// Adopt command
|
|
1086
|
+
program
|
|
1087
|
+
.command('adopt')
|
|
1088
|
+
.description('Analyse an existing .devcontainer/ and suggest an equivalent overlay-based configuration')
|
|
1089
|
+
.option('-d, --dir <path>', 'Path to the existing .devcontainer directory (default: ./.devcontainer)')
|
|
1090
|
+
.option('--dry-run', 'Print analysis and suggested command only; no files written')
|
|
1091
|
+
.option('--force', 'Overwrite existing superposition.json if present')
|
|
1092
|
+
.option('--backup', 'Force backup creation even when inside a git repo (default: backup only outside git repos)')
|
|
1093
|
+
.option('--no-backup', 'Disable backup creation even when it would normally be performed')
|
|
1094
|
+
.option('--backup-dir <path>', 'Custom backup directory location')
|
|
1095
|
+
.option('--project-file', 'Also write a repository-root project config (.superposition.yml or existing project file)')
|
|
1096
|
+
.option('--json', 'Output as JSON for scripting')
|
|
1097
|
+
.action(async (options) => {
|
|
1098
|
+
const overlaysConfig = loadOverlaysConfigWrapper();
|
|
1099
|
+
await adoptCommand(overlaysConfig, OVERLAYS_DIR, options);
|
|
1100
|
+
process.exit(0);
|
|
1101
|
+
});
|
|
1102
|
+
// Hash command
|
|
1103
|
+
program
|
|
1104
|
+
.command('hash')
|
|
1105
|
+
.description('Compute a deterministic fingerprint for a given configuration')
|
|
1106
|
+
.option('--stack <type>', 'Base template: plain, compose')
|
|
1107
|
+
.option('--overlays <list>', 'Comma-separated list of overlay IDs')
|
|
1108
|
+
.option('--preset <id>', 'Preset ID (optional)')
|
|
1109
|
+
.option('--base <image>', 'Base image / distro variant (e.g. bookworm, alpine)')
|
|
1110
|
+
.option('--manifest <path>', 'Path to superposition.json manifest')
|
|
1111
|
+
.option('-o, --output <path>', 'Directory to write hash file (used with --write)')
|
|
1112
|
+
.option('--write', 'Write hash to .devcontainer/superposition.hash')
|
|
1113
|
+
.option('--json', 'Output as JSON for scripting')
|
|
1114
|
+
.action(async (options) => {
|
|
1115
|
+
const overlaysConfig = loadOverlaysConfigWrapper();
|
|
1116
|
+
await hashCommand(overlaysConfig, OVERLAYS_DIR, options);
|
|
1117
|
+
process.exit(0);
|
|
1118
|
+
});
|
|
1076
1119
|
await program.parseAsync(process.argv);
|
|
1077
1120
|
// If init or regen command was run, return the options
|
|
1078
1121
|
if (!initOptions) {
|
|
@@ -1083,6 +1126,34 @@ async function parseCliArgs() {
|
|
|
1083
1126
|
if (Object.keys(initOptions).length === 0) {
|
|
1084
1127
|
return null;
|
|
1085
1128
|
}
|
|
1129
|
+
const hasSourceFlags = Number(Boolean(initOptions.fromProject)) + Number(Boolean(initOptions.fromManifest));
|
|
1130
|
+
if (hasSourceFlags > 1) {
|
|
1131
|
+
console.error(chalk.red('✗ Error: --from-project and --from-manifest cannot be used together'));
|
|
1132
|
+
process.exit(1);
|
|
1133
|
+
}
|
|
1134
|
+
const sourceSelectionConflicts = [
|
|
1135
|
+
'stack',
|
|
1136
|
+
'language',
|
|
1137
|
+
'database',
|
|
1138
|
+
'observability',
|
|
1139
|
+
'playwright',
|
|
1140
|
+
'cloudTools',
|
|
1141
|
+
'devTools',
|
|
1142
|
+
'portOffset',
|
|
1143
|
+
'preset',
|
|
1144
|
+
];
|
|
1145
|
+
const hasPresetParams = Array.isArray(initOptions.presetParam) && initOptions.presetParam.length > 0;
|
|
1146
|
+
const conflictingSelectionFlags = sourceSelectionConflicts.filter((key) => initOptions[key] !== undefined && initOptions[key] !== false);
|
|
1147
|
+
if ((initOptions.fromProject || initOptions.fromManifest) &&
|
|
1148
|
+
(conflictingSelectionFlags.length > 0 || hasPresetParams)) {
|
|
1149
|
+
const conflicts = [...conflictingSelectionFlags.map((key) => `--${key}`)];
|
|
1150
|
+
if (hasPresetParams) {
|
|
1151
|
+
conflicts.push('--preset-param');
|
|
1152
|
+
}
|
|
1153
|
+
console.error(chalk.red(`✗ Error: Persisted input sources cannot be combined with clean-generation selection flags: ${conflicts.join(', ')}`));
|
|
1154
|
+
console.error(chalk.dim(' Choose either a persisted input source (--from-project or --from-manifest) or direct selection flags for that run.'));
|
|
1155
|
+
process.exit(1);
|
|
1156
|
+
}
|
|
1086
1157
|
const config = {};
|
|
1087
1158
|
if (initOptions.stack)
|
|
1088
1159
|
config.stack = initOptions.stack;
|
|
@@ -1114,13 +1185,13 @@ async function parseCliArgs() {
|
|
|
1114
1185
|
if (initOptions.portOffset) {
|
|
1115
1186
|
config.portOffset = parseInt(initOptions.portOffset, 10);
|
|
1116
1187
|
}
|
|
1117
|
-
if (initOptions.target) {
|
|
1188
|
+
if (initOptions.target && initOptions._targetSource !== 'default') {
|
|
1118
1189
|
config.target = initOptions.target;
|
|
1119
1190
|
}
|
|
1120
1191
|
if (initOptions.minimal) {
|
|
1121
1192
|
config.minimal = true;
|
|
1122
1193
|
}
|
|
1123
|
-
if (initOptions.editor) {
|
|
1194
|
+
if (initOptions.editor && initOptions._editorSource !== 'default') {
|
|
1124
1195
|
const editorLower = initOptions.editor.toLowerCase();
|
|
1125
1196
|
if (['vscode', 'jetbrains', 'none'].includes(editorLower)) {
|
|
1126
1197
|
config.editor = editorLower;
|
|
@@ -1163,8 +1234,11 @@ async function parseCliArgs() {
|
|
|
1163
1234
|
}
|
|
1164
1235
|
}
|
|
1165
1236
|
return {
|
|
1237
|
+
commandName: initOptions.commandName,
|
|
1166
1238
|
config,
|
|
1167
1239
|
manifestPath: initOptions.fromManifest,
|
|
1240
|
+
fromProject: initOptions.fromProject === true,
|
|
1241
|
+
projectRoot: initOptions.projectRoot,
|
|
1168
1242
|
backupOverride: initOptions.backup, // undefined = auto-detect; true = --backup; false = --no-backup
|
|
1169
1243
|
backupDir: initOptions.backupDir,
|
|
1170
1244
|
noInteractive: initOptions.interactive === false, // Commander creates options.interactive = false for --no-interactive
|
|
@@ -1174,16 +1248,47 @@ async function parseCliArgs() {
|
|
|
1174
1248
|
async function main() {
|
|
1175
1249
|
try {
|
|
1176
1250
|
const cliArgs = await parseCliArgs();
|
|
1177
|
-
|
|
1178
|
-
if (cliArgs?.
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1251
|
+
const initialCwd = process.cwd();
|
|
1252
|
+
if (cliArgs?.projectRoot) {
|
|
1253
|
+
const resolvedProjectRoot = path.resolve(initialCwd, cliArgs.projectRoot);
|
|
1254
|
+
if (!fs.existsSync(resolvedProjectRoot)) {
|
|
1255
|
+
console.error(chalk.red(`✗ Project root not found: ${resolvedProjectRoot}`));
|
|
1256
|
+
process.exit(1);
|
|
1257
|
+
}
|
|
1258
|
+
if (!fs.statSync(resolvedProjectRoot).isDirectory()) {
|
|
1259
|
+
console.error(chalk.red(`✗ Project root is not a directory: ${resolvedProjectRoot}`));
|
|
1260
|
+
process.exit(1);
|
|
1261
|
+
}
|
|
1262
|
+
process.chdir(resolvedProjectRoot);
|
|
1263
|
+
}
|
|
1264
|
+
let projectConfig = undefined;
|
|
1265
|
+
let projectConfigAnswers;
|
|
1266
|
+
if (!cliArgs?.manifestPath) {
|
|
1267
|
+
projectConfig =
|
|
1268
|
+
loadProjectConfig(loadOverlaysConfigWrapper(), process.cwd()) ?? undefined;
|
|
1269
|
+
if (projectConfig) {
|
|
1270
|
+
projectConfigAnswers = applyPresetSelections(buildAnswersFromProjectConfig(projectConfig.selection));
|
|
1271
|
+
}
|
|
1182
1272
|
}
|
|
1183
1273
|
let manifest;
|
|
1184
1274
|
let manifestDir;
|
|
1185
1275
|
let backupDir;
|
|
1186
1276
|
let useManifestOnly = false;
|
|
1277
|
+
let useProjectOnly = false;
|
|
1278
|
+
if (cliArgs?.commandName === 'regen' && !cliArgs.manifestPath && !cliArgs.fromProject) {
|
|
1279
|
+
if (projectConfigAnswers) {
|
|
1280
|
+
useProjectOnly = true;
|
|
1281
|
+
}
|
|
1282
|
+
else {
|
|
1283
|
+
const discoveredManifestPath = findDefaultRegenManifest(cliArgs?.config?.outputPath || './.devcontainer');
|
|
1284
|
+
if (!discoveredManifestPath) {
|
|
1285
|
+
console.error(chalk.red('✗ Error: No project file or manifest found'));
|
|
1286
|
+
console.error(chalk.gray(' Looked for .superposition.yml or superposition.yml in the repository root, and superposition.json in common manifest locations.'));
|
|
1287
|
+
process.exit(1);
|
|
1288
|
+
}
|
|
1289
|
+
cliArgs.manifestPath = discoveredManifestPath;
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1187
1292
|
// Handle manifest loading
|
|
1188
1293
|
if (cliArgs?.manifestPath) {
|
|
1189
1294
|
const manifestPath = findManifestFile(cliArgs.manifestPath);
|
|
@@ -1206,12 +1311,30 @@ async function main() {
|
|
|
1206
1311
|
useManifestOnly = true;
|
|
1207
1312
|
}
|
|
1208
1313
|
}
|
|
1314
|
+
if (cliArgs?.fromProject) {
|
|
1315
|
+
if (!projectConfigAnswers || !projectConfig) {
|
|
1316
|
+
console.error(chalk.red('✗ Could not find project file'));
|
|
1317
|
+
console.error(chalk.red(' Searched for: .superposition.yml, superposition.yml'));
|
|
1318
|
+
process.exit(1);
|
|
1319
|
+
}
|
|
1320
|
+
useProjectOnly = cliArgs.noInteractive || cliArgs.commandName === 'regen';
|
|
1321
|
+
}
|
|
1322
|
+
// Validate --no-interactive requires a persisted input source
|
|
1323
|
+
if (cliArgs?.noInteractive && !cliArgs?.manifestPath && !projectConfigAnswers) {
|
|
1324
|
+
console.error(chalk.red('✗ Error: --no-interactive requires persisted input'));
|
|
1325
|
+
console.error(chalk.dim(' Use --from-project, --from-manifest <path>, or run from a repository with .superposition.yml or superposition.yml'));
|
|
1326
|
+
process.exit(1);
|
|
1327
|
+
}
|
|
1209
1328
|
// Determine whether to create a backup:
|
|
1210
1329
|
// --backup → always backup
|
|
1211
1330
|
// --no-backup → never backup
|
|
1212
1331
|
// (neither) → backup only when NOT inside a git repository
|
|
1213
1332
|
// (git already tracks history, so backups are redundant)
|
|
1214
|
-
const
|
|
1333
|
+
const resolvedOutputPath = cliArgs?.config?.outputPath ||
|
|
1334
|
+
projectConfigAnswers?.outputPath ||
|
|
1335
|
+
manifestDir ||
|
|
1336
|
+
'./.devcontainer';
|
|
1337
|
+
const backupCheckPath = path.resolve(resolvedOutputPath);
|
|
1215
1338
|
const inGitRepo = isInsideGitRepo(backupCheckPath);
|
|
1216
1339
|
let shouldBackup;
|
|
1217
1340
|
if (cliArgs?.backupOverride === true) {
|
|
@@ -1227,11 +1350,11 @@ async function main() {
|
|
|
1227
1350
|
console.log(chalk.dim('ℹ Skipping backup — git repo detected (use --backup to force one)\n'));
|
|
1228
1351
|
}
|
|
1229
1352
|
}
|
|
1353
|
+
const isReplayMode = cliArgs?.commandName === 'regen' || useManifestOnly || useProjectOnly;
|
|
1230
1354
|
// Create backup if needed
|
|
1231
1355
|
let actualBackupPath;
|
|
1232
|
-
if (shouldBackup &&
|
|
1233
|
-
|
|
1234
|
-
const outputPath = manifestDir || './.devcontainer';
|
|
1356
|
+
if (shouldBackup && isReplayMode) {
|
|
1357
|
+
const outputPath = resolvedOutputPath;
|
|
1235
1358
|
const backupPath = await createBackup(outputPath, backupDir);
|
|
1236
1359
|
if (backupPath) {
|
|
1237
1360
|
actualBackupPath = backupPath;
|
|
@@ -1241,14 +1364,19 @@ async function main() {
|
|
|
1241
1364
|
}
|
|
1242
1365
|
// Build answers based on mode
|
|
1243
1366
|
let answers;
|
|
1244
|
-
const isRegen = !!manifest;
|
|
1245
1367
|
// Check if there are CLI overrides beyond just output path and preset flags
|
|
1246
1368
|
// Preset/presetChoices alone don't constitute "CLI overrides" that bypass interactive mode
|
|
1247
1369
|
const hasCliOverrides = cliArgs &&
|
|
1248
1370
|
Object.keys(cliArgs.config).some((key) => key !== 'outputPath' &&
|
|
1249
1371
|
key !== 'preset' &&
|
|
1250
1372
|
key !== 'presetChoices' &&
|
|
1373
|
+
!(key === 'target' && cliArgs.config.target === 'local') &&
|
|
1374
|
+
!(key === 'editor' && cliArgs.config.editor === 'vscode') &&
|
|
1251
1375
|
cliArgs.config[key] !== undefined);
|
|
1376
|
+
const hasAnyCliConfig = cliArgs &&
|
|
1377
|
+
Object.entries(cliArgs.config).some(([key, value]) => value !== undefined &&
|
|
1378
|
+
!(key === 'target' && value === 'local') &&
|
|
1379
|
+
!(key === 'editor' && value === 'vscode'));
|
|
1252
1380
|
if (useManifestOnly && manifest && !hasCliOverrides) {
|
|
1253
1381
|
// Mode 1: Manifest-only (--from-manifest --no-interactive, no CLI overrides)
|
|
1254
1382
|
const manifestAnswers = buildAnswersFromManifest(manifest, manifestDir);
|
|
@@ -1268,19 +1396,44 @@ async function main() {
|
|
|
1268
1396
|
: '') +
|
|
1269
1397
|
chalk.gray(` Output: ${answers.outputPath}`), { padding: 1, borderColor: 'cyan', borderStyle: 'round', margin: 1 }));
|
|
1270
1398
|
}
|
|
1271
|
-
else if (
|
|
1399
|
+
else if (useProjectOnly && projectConfigAnswers && !hasCliOverrides) {
|
|
1400
|
+
const projectFileName = projectConfig?.file.fileName ?? '.superposition.yml';
|
|
1401
|
+
answers = mergeAnswers(projectConfigAnswers, {
|
|
1402
|
+
outputPath: cliArgs?.config?.outputPath ||
|
|
1403
|
+
projectConfigAnswers.outputPath ||
|
|
1404
|
+
'./.devcontainer',
|
|
1405
|
+
minimal: cliArgs?.config?.minimal,
|
|
1406
|
+
editor: cliArgs?.config?.editor,
|
|
1407
|
+
});
|
|
1408
|
+
console.log('\n' +
|
|
1409
|
+
boxen(chalk.bold.cyan('Regenerating from Project File (No Interactive)\n\n') +
|
|
1410
|
+
chalk.white('Configuration:\n') +
|
|
1411
|
+
chalk.gray(` Project file: ${projectFileName}\n`) +
|
|
1412
|
+
chalk.gray(` Output: ${answers.outputPath}`), {
|
|
1413
|
+
padding: 1,
|
|
1414
|
+
borderColor: 'cyan',
|
|
1415
|
+
borderStyle: 'round',
|
|
1416
|
+
margin: 1,
|
|
1417
|
+
}));
|
|
1418
|
+
}
|
|
1419
|
+
else if ((cliArgs && (cliArgs.config.stack || hasCliOverrides)) ||
|
|
1420
|
+
(projectConfigAnswers && (cliArgs?.noInteractive || hasAnyCliConfig))) {
|
|
1272
1421
|
// Mode 2: CLI-based (with optional manifest defaults)
|
|
1273
1422
|
// This includes regen with --minimal or --editor flags
|
|
1274
1423
|
const cliAnswers = buildAnswersFromCliArgs(cliArgs.config);
|
|
1275
1424
|
const manifestAnswers = manifest
|
|
1276
1425
|
? buildAnswersFromManifest(manifest, manifestDir)
|
|
1277
1426
|
: undefined;
|
|
1278
|
-
answers = mergeAnswers(manifestAnswers, cliAnswers, {
|
|
1279
|
-
outputPath: cliAnswers.outputPath || './.devcontainer',
|
|
1427
|
+
answers = mergeAnswers(projectConfigAnswers, manifestAnswers, cliAnswers, {
|
|
1428
|
+
outputPath: cliAnswers.outputPath || projectConfigAnswers?.outputPath || './.devcontainer',
|
|
1280
1429
|
});
|
|
1281
1430
|
const modeLabel = useManifestOnly && hasCliOverrides
|
|
1282
1431
|
? 'Regenerating from Manifest with Overrides'
|
|
1283
|
-
:
|
|
1432
|
+
: useProjectOnly && projectConfigAnswers
|
|
1433
|
+
? 'Regenerating from Project File with Overrides'
|
|
1434
|
+
: projectConfigAnswers && !manifest
|
|
1435
|
+
? 'Running from Project Config'
|
|
1436
|
+
: 'Running in CLI mode';
|
|
1284
1437
|
console.log('\n' +
|
|
1285
1438
|
boxen(chalk.bold(modeLabel), {
|
|
1286
1439
|
padding: 0.5,
|
|
@@ -1288,7 +1441,7 @@ async function main() {
|
|
|
1288
1441
|
borderStyle: 'round',
|
|
1289
1442
|
}));
|
|
1290
1443
|
// Show what's being overridden
|
|
1291
|
-
if (useManifestOnly && hasCliOverrides) {
|
|
1444
|
+
if ((useManifestOnly || useProjectOnly) && hasCliOverrides) {
|
|
1292
1445
|
const overrides = [];
|
|
1293
1446
|
if (cliAnswers.minimal)
|
|
1294
1447
|
overrides.push('minimal mode');
|
|
@@ -1301,8 +1454,15 @@ async function main() {
|
|
|
1301
1454
|
}
|
|
1302
1455
|
else {
|
|
1303
1456
|
// Mode 3: Interactive (with optional manifest pre-population and CLI preset pre-selection)
|
|
1304
|
-
const interactiveAnswers = await runQuestionnaire(manifest, manifestDir, cliArgs?.config.preset, cliArgs?.config.presetChoices);
|
|
1305
|
-
answers = mergeAnswers(interactiveAnswers);
|
|
1457
|
+
const interactiveAnswers = await runQuestionnaire(manifest, manifestDir, cliArgs?.config.preset || projectConfigAnswers?.preset, cliArgs?.config.presetChoices || projectConfigAnswers?.presetChoices, projectConfigAnswers);
|
|
1458
|
+
answers = mergeAnswers(projectConfigAnswers, interactiveAnswers);
|
|
1459
|
+
}
|
|
1460
|
+
if (!manifest && projectConfig?.selection.customizations) {
|
|
1461
|
+
const materializedOutputPath = path.resolve(answers.outputPath);
|
|
1462
|
+
if (!fs.existsSync(materializedOutputPath)) {
|
|
1463
|
+
fs.mkdirSync(materializedOutputPath, { recursive: true });
|
|
1464
|
+
}
|
|
1465
|
+
writeProjectConfigCustomizations(materializedOutputPath, projectConfig.selection.customizations);
|
|
1306
1466
|
}
|
|
1307
1467
|
// Show configuration summary
|
|
1308
1468
|
const summaryLines = [
|
|
@@ -1322,6 +1482,9 @@ async function main() {
|
|
|
1322
1482
|
if (answers.cloudTools && answers.cloudTools.length > 0) {
|
|
1323
1483
|
summaryLines.push(chalk.cyan('Cloud tools: ') + chalk.white(answers.cloudTools.join(', ')));
|
|
1324
1484
|
}
|
|
1485
|
+
if (projectConfig?.file && !manifest) {
|
|
1486
|
+
summaryLines.push(chalk.cyan('Project config: ') + chalk.white(projectConfig.file.fileName));
|
|
1487
|
+
}
|
|
1325
1488
|
summaryLines.push(chalk.cyan('Output: ') + chalk.white(answers.outputPath));
|
|
1326
1489
|
console.log('\n' +
|
|
1327
1490
|
boxen(summaryLines.join('\n'), {
|
|
@@ -1342,11 +1505,13 @@ async function main() {
|
|
|
1342
1505
|
try {
|
|
1343
1506
|
let summary;
|
|
1344
1507
|
if (isManifestOnly) {
|
|
1345
|
-
summary = await generateManifestOnly(answers, undefined, {
|
|
1508
|
+
summary = await generateManifestOnly(answers, undefined, {
|
|
1509
|
+
isRegen: isReplayMode,
|
|
1510
|
+
});
|
|
1346
1511
|
spinner.succeed(chalk.green('Manifest created successfully!'));
|
|
1347
1512
|
}
|
|
1348
1513
|
else {
|
|
1349
|
-
summary = await composeDevContainer(answers, undefined, { isRegen });
|
|
1514
|
+
summary = await composeDevContainer(answers, undefined, { isRegen: isReplayMode });
|
|
1350
1515
|
spinner.succeed(chalk.green('DevContainer created successfully!'));
|
|
1351
1516
|
}
|
|
1352
1517
|
// Update summary with backup path and regen status
|