container-superposition 0.1.7 ā 0.1.9
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 +24 -15
- package/dist/scripts/init.js +1 -1537
- package/dist/scripts/init.js.map +1 -1
- package/dist/tool/cli/args.d.ts +20 -0
- package/dist/tool/cli/args.d.ts.map +1 -0
- package/dist/tool/cli/args.js +325 -0
- package/dist/tool/cli/args.js.map +1 -0
- package/dist/tool/cli/run.d.ts +2 -0
- package/dist/tool/cli/run.d.ts.map +1 -0
- package/dist/tool/cli/run.js +318 -0
- package/dist/tool/cli/run.js.map +1 -0
- package/dist/tool/commands/adopt.js +1 -1
- package/dist/tool/commands/adopt.js.map +1 -1
- package/dist/tool/commands/doctor.d.ts +1 -0
- package/dist/tool/commands/doctor.d.ts.map +1 -1
- package/dist/tool/commands/doctor.js +1510 -78
- package/dist/tool/commands/doctor.js.map +1 -1
- package/dist/tool/commands/explain.d.ts.map +1 -1
- package/dist/tool/commands/explain.js +9 -0
- package/dist/tool/commands/explain.js.map +1 -1
- package/dist/tool/commands/migrate.d.ts +7 -0
- package/dist/tool/commands/migrate.d.ts.map +1 -0
- package/dist/tool/commands/migrate.js +52 -0
- package/dist/tool/commands/migrate.js.map +1 -0
- package/dist/tool/questionnaire/answers.d.ts +16 -0
- package/dist/tool/questionnaire/answers.d.ts.map +1 -0
- package/dist/tool/questionnaire/answers.js +102 -0
- package/dist/tool/questionnaire/answers.js.map +1 -0
- package/dist/tool/questionnaire/composer.d.ts +6 -4
- package/dist/tool/questionnaire/composer.d.ts.map +1 -1
- package/dist/tool/questionnaire/composer.js +778 -45
- package/dist/tool/questionnaire/composer.js.map +1 -1
- package/dist/tool/questionnaire/presets.d.ts +60 -0
- package/dist/tool/questionnaire/presets.d.ts.map +1 -0
- package/dist/tool/questionnaire/presets.js +165 -0
- package/dist/tool/questionnaire/presets.js.map +1 -0
- package/dist/tool/questionnaire/questionnaire.d.ts +10 -0
- package/dist/tool/questionnaire/questionnaire.d.ts.map +1 -0
- package/dist/tool/questionnaire/questionnaire.js +582 -0
- package/dist/tool/questionnaire/questionnaire.js.map +1 -0
- package/dist/tool/schema/manifest-migrations.d.ts +5 -0
- package/dist/tool/schema/manifest-migrations.d.ts.map +1 -1
- package/dist/tool/schema/manifest-migrations.js +45 -0
- package/dist/tool/schema/manifest-migrations.js.map +1 -1
- package/dist/tool/schema/overlay-loader.d.ts.map +1 -1
- package/dist/tool/schema/overlay-loader.js +24 -0
- package/dist/tool/schema/overlay-loader.js.map +1 -1
- package/dist/tool/schema/project-config.d.ts +13 -1
- package/dist/tool/schema/project-config.d.ts.map +1 -1
- package/dist/tool/schema/project-config.js +188 -10
- package/dist/tool/schema/project-config.js.map +1 -1
- package/dist/tool/schema/target-rules.d.ts +78 -0
- package/dist/tool/schema/target-rules.d.ts.map +1 -0
- package/dist/tool/schema/target-rules.js +367 -0
- package/dist/tool/schema/target-rules.js.map +1 -0
- package/dist/tool/schema/types.d.ts +42 -3
- package/dist/tool/schema/types.d.ts.map +1 -1
- package/dist/tool/utils/parameters.d.ts +76 -0
- package/dist/tool/utils/parameters.d.ts.map +1 -0
- package/dist/tool/utils/parameters.js +125 -0
- package/dist/tool/utils/parameters.js.map +1 -0
- package/dist/tool/utils/paths.d.ts +2 -0
- package/dist/tool/utils/paths.d.ts.map +1 -0
- package/dist/tool/utils/paths.js +31 -0
- package/dist/tool/utils/paths.js.map +1 -0
- package/docs/deployment-targets.md +88 -56
- package/docs/examples.md +20 -17
- package/docs/filesystem-contract.md +5 -0
- package/docs/minimal-and-editor.md +65 -5
- package/docs/overlay-imports.md +92 -14
- package/docs/overlays.md +231 -135
- package/docs/specs/001-verbose-plan-graph/spec.md +5 -12
- package/docs/specs/002-superposition-config-file/spec.md +5 -12
- package/docs/specs/003-mkdocs2-overlay/spec.md +2 -9
- package/docs/specs/004-doctor-fix/spec.md +1 -8
- package/docs/specs/005-cuda-overlay/spec.md +2 -9
- package/docs/specs/006-rocm-overlay/spec.md +3 -10
- package/docs/specs/007-target-aware-generation/spec.md +119 -0
- package/docs/specs/008-project-file-canonical/spec.md +82 -0
- package/docs/specs/009-project-env/spec.md +140 -0
- package/docs/specs/010-compose-env-materialization/spec.md +123 -0
- package/docs/specs/011-overlay-parameters/spec.md +228 -0
- package/docs/specs/012-ollama-cli-overlay/spec.md +47 -0
- package/docs/specs/013-doctor-dependency-check/spec.md +250 -0
- package/docs/specs/014-doctor-compose-port-cross-validation/spec.md +276 -0
- package/docs/specs/015-doctor-env-example-drift/spec.md +248 -0
- package/docs/specs/016-doctor-reproducibility-check/spec.md +276 -0
- package/docs/specs/017-doctor-dry-run/spec.md +276 -0
- package/docs/specs/018-init-project-file/spec.md +59 -0
- package/docs/specs/taxonomy.md +186 -0
- package/overlays/.presets/full-observability.yml +113 -0
- package/overlays/.presets/k8s-dev.yml +174 -0
- package/overlays/.presets/local-llm.yml +105 -0
- package/overlays/.presets/vector-ai.yml +150 -0
- package/overlays/.shared/README.md +27 -2
- package/overlays/.shared/compose/nvidia-gpu-devcontainer.yml +22 -0
- package/overlays/.shared/vscode/js-ts-settings.json +19 -0
- package/overlays/.shared/vscode/markdown-extensions.json +8 -0
- package/overlays/alertmanager/devcontainer.patch.json +0 -1
- package/overlays/alertmanager/docker-compose.yml +8 -0
- package/overlays/alertmanager/overlay.yml +1 -0
- package/overlays/amp/devcontainer.patch.json +4 -1
- package/overlays/bun/devcontainer.patch.json +1 -10
- package/overlays/bun/overlay.yml +8 -1
- package/overlays/claude-code/devcontainer.patch.json +6 -1
- package/overlays/codex/devcontainer.patch.json +5 -0
- package/overlays/comfyui/.env.example +34 -0
- package/overlays/comfyui/README.md +342 -0
- package/overlays/comfyui/devcontainer.patch.json +15 -0
- package/overlays/comfyui/docker-compose.yml +40 -0
- package/overlays/comfyui/overlay.yml +24 -0
- package/overlays/comfyui/setup.sh +36 -0
- package/overlays/comfyui/verify.sh +103 -0
- package/overlays/commitlint/devcontainer.patch.json +1 -6
- package/overlays/docker-sock/overlay.yml +1 -0
- package/overlays/dotnet/overlay.yml +4 -1
- package/overlays/fuseki/.env.example +5 -0
- package/overlays/fuseki/README.md +173 -0
- package/overlays/fuseki/devcontainer.patch.json +18 -0
- package/overlays/fuseki/docker-compose.yml +29 -0
- package/overlays/fuseki/overlay.yml +42 -0
- package/overlays/fuseki/verify.sh +58 -0
- package/overlays/gemini-cli/devcontainer.patch.json +4 -1
- package/overlays/go/overlay.yml +6 -1
- package/overlays/grafana/devcontainer.patch.json +0 -1
- package/overlays/grafana/docker-compose.yml +8 -2
- package/overlays/grafana/overlay.yml +6 -1
- package/overlays/jaeger/.env.example +11 -0
- package/overlays/jaeger/README.md +33 -4
- package/overlays/jaeger/devcontainer.patch.json +9 -1
- package/overlays/jaeger/docker-compose.yml +17 -0
- package/overlays/jaeger/overlay.yml +1 -12
- package/overlays/java/overlay.yml +6 -1
- package/overlays/jupyter/docker-compose.yml +1 -0
- package/overlays/jupyter/overlay.yml +1 -0
- package/overlays/k3d/README.md +201 -0
- package/overlays/k3d/devcontainer.patch.json +9 -0
- package/overlays/k3d/overlay.yml +19 -0
- package/overlays/k3d/setup.sh +34 -0
- package/overlays/k3d/verify.sh +38 -0
- package/overlays/keycloak/devcontainer.patch.json +0 -1
- package/overlays/keycloak/docker-compose.yml +1 -0
- package/overlays/keycloak/overlay.yml +15 -0
- package/overlays/localstack/docker-compose.yml +1 -0
- package/overlays/localstack/overlay.yml +19 -1
- package/overlays/loki/devcontainer.patch.json +0 -1
- package/overlays/loki/docker-compose.yml +8 -0
- package/overlays/loki/overlay.yml +1 -0
- package/overlays/mailpit/docker-compose.yml +1 -0
- package/overlays/mailpit/overlay.yml +1 -0
- package/overlays/minio/devcontainer.patch.json +1 -1
- package/overlays/minio/docker-compose.yml +1 -0
- package/overlays/minio/overlay.yml +23 -2
- package/overlays/mkdocs/devcontainer.patch.json +1 -5
- package/overlays/mkdocs/overlay.yml +3 -1
- package/overlays/mkdocs2/devcontainer.patch.json +1 -5
- package/overlays/mkdocs2/overlay.yml +2 -0
- package/overlays/mongodb/docker-compose.yml +2 -0
- package/overlays/mongodb/overlay.yml +26 -2
- package/overlays/mysql/docker-compose.yml +2 -0
- package/overlays/mysql/overlay.yml +36 -2
- package/overlays/nats/docker-compose.yml +1 -0
- package/overlays/nats/overlay.yml +18 -2
- package/overlays/nodejs/devcontainer.patch.json +1 -12
- package/overlays/nodejs/overlay.yml +8 -1
- package/overlays/ollama/.env.example +14 -0
- package/overlays/ollama/README.md +326 -0
- package/overlays/ollama/devcontainer.patch.json +14 -0
- package/overlays/ollama/docker-compose.yml +25 -0
- package/overlays/ollama/overlay.yml +27 -0
- package/overlays/ollama/verify.sh +76 -0
- package/overlays/ollama-cli/README.md +90 -0
- package/overlays/ollama-cli/devcontainer.patch.json +3 -0
- package/overlays/ollama-cli/overlay.yml +19 -0
- package/overlays/ollama-cli/setup.sh +103 -0
- package/overlays/ollama-cli/verify.sh +49 -0
- package/overlays/open-webui/.env.example +5 -0
- package/overlays/open-webui/README.md +162 -0
- package/overlays/open-webui/devcontainer.patch.json +14 -0
- package/overlays/open-webui/docker-compose.yml +24 -0
- package/overlays/open-webui/overlay.yml +45 -0
- package/overlays/opencode/devcontainer.patch.json +4 -1
- package/overlays/otel-collector/README.md +4 -0
- package/overlays/otel-collector/devcontainer.patch.json +4 -1
- package/overlays/otel-collector/docker-compose.yml +8 -4
- package/overlays/otel-collector/overlay.yml +1 -0
- package/overlays/otel-demo-nodejs/devcontainer.patch.json +0 -1
- package/overlays/otel-demo-nodejs/docker-compose.yml +1 -0
- package/overlays/otel-demo-nodejs/overlay.yml +9 -1
- package/overlays/otel-demo-python/devcontainer.patch.json +0 -1
- package/overlays/otel-demo-python/docker-compose.yml +1 -0
- package/overlays/otel-demo-python/overlay.yml +6 -1
- package/overlays/pandoc/README.md +10 -0
- package/overlays/pandoc/devcontainer.patch.json +0 -5
- package/overlays/pandoc/overlay.yml +2 -0
- package/overlays/pandoc/setup.sh +10 -0
- package/overlays/pgvector/.env.example +6 -0
- package/overlays/pgvector/README.md +215 -0
- package/overlays/pgvector/devcontainer.patch.json +29 -0
- package/overlays/pgvector/docker-compose.yml +33 -0
- package/overlays/pgvector/overlay.yml +47 -0
- package/overlays/playwright/devcontainer.patch.json +0 -5
- package/overlays/playwright/overlay.yml +2 -1
- package/overlays/postgres/.env.example +5 -5
- package/overlays/postgres/devcontainer.patch.json +4 -4
- package/overlays/postgres/docker-compose.yml +11 -6
- package/overlays/postgres/overlay.yml +23 -2
- package/overlays/pre-commit/devcontainer.patch.json +1 -7
- package/overlays/prometheus/devcontainer.patch.json +0 -1
- package/overlays/prometheus/docker-compose.yml +8 -0
- package/overlays/prometheus/overlay.yml +1 -0
- package/overlays/promtail/devcontainer.patch.json +1 -2
- package/overlays/promtail/docker-compose.yml +8 -0
- package/overlays/promtail/overlay.yml +1 -0
- package/overlays/qdrant/.env.example +4 -0
- package/overlays/qdrant/README.md +216 -0
- package/overlays/qdrant/devcontainer.patch.json +20 -0
- package/overlays/qdrant/docker-compose.yml +26 -0
- package/overlays/qdrant/overlay.yml +44 -0
- package/overlays/rabbitmq/docker-compose.yml +1 -0
- package/overlays/rabbitmq/overlay.yml +25 -2
- package/overlays/redis/docker-compose.yml +7 -0
- package/overlays/redis/overlay.yml +15 -1
- package/overlays/redpanda/docker-compose.yml +1 -0
- package/overlays/redpanda/overlay.yml +15 -3
- package/overlays/rocm/overlay.yml +2 -1
- package/overlays/rust/overlay.yml +3 -1
- package/overlays/skaffold/README.md +256 -0
- package/overlays/skaffold/devcontainer.patch.json +9 -0
- package/overlays/skaffold/overlay.yml +20 -0
- package/overlays/skaffold/setup.sh +33 -0
- package/overlays/skaffold/verify.sh +24 -0
- package/overlays/sqlserver/docker-compose.yml +1 -0
- package/overlays/sqlserver/overlay.yml +17 -0
- package/overlays/tempo/devcontainer.patch.json +0 -1
- package/overlays/tempo/docker-compose.yml +8 -0
- package/overlays/tempo/overlay.yml +1 -0
- package/overlays/windsurf-cli/devcontainer.patch.json +4 -1
- package/package.json +3 -2
- package/tool/schema/config.schema.json +31 -1
- package/tool/schema/overlay-manifest.schema.json +33 -0
- package/overlays/.shared/otel/otel-base-config.yaml +0 -30
|
@@ -12,20 +12,311 @@ import { deepMerge, mergePackages, filterDependsOn, applyPortOffsetToEnv, } from
|
|
|
12
12
|
import { generatePortsDocumentation } from '../utils/port-utils.js';
|
|
13
13
|
import { generateServicesMarkdown, generateEnvLocalExample } from '../utils/services-export.js';
|
|
14
14
|
import { appendGitignoreSection } from '../utils/gitignore.js';
|
|
15
|
+
import { getTargetRule, resolveTargetFilePath, removeStaleTargetArtifacts, } from '../schema/target-rules.js';
|
|
16
|
+
import { collectOverlayParameters, resolveParameters, substituteParameters, substituteParametersInObject, findUnresolvedTokens, redactSensitiveValues, } from '../utils/parameters.js';
|
|
15
17
|
import { detectWarnings, generateTips, generateNextSteps, overlaysToServices, portsToPortInfo, } from '../utils/summary.js';
|
|
18
|
+
import { resolveRepoPath } from '../utils/paths.js';
|
|
16
19
|
// Get __dirname equivalent in ESM
|
|
17
20
|
const __filename = fileURLToPath(import.meta.url);
|
|
18
21
|
const __dirname = path.dirname(__filename);
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
const
|
|
22
|
+
// Anchor for resolving the top-level templates directory.
|
|
23
|
+
// In source layout: <repo>/tool/questionnaire -> anchor becomes <repo>/tool.
|
|
24
|
+
// path.basename('tool') === 'tool', so we go one level up to reach <repo>.
|
|
25
|
+
// In compiled layout: <repo>/dist/tool/questionnaire -> anchor becomes <repo>/dist/tool.
|
|
26
|
+
// path.basename('tool') === 'tool', so we go one level up to <repo>/dist,
|
|
27
|
+
// then resolveRepoPath walks further up to find templates/ at the repo root.
|
|
28
|
+
// NOTE: This check relies on the source directory being named 'tool'. If that changes,
|
|
29
|
+
// update this constant accordingly.
|
|
30
|
+
const TEMPLATES_ANCHOR_BASE = path.join(__dirname, '..', '..');
|
|
31
|
+
const TEMPLATES_ANCHOR = path.basename(TEMPLATES_ANCHOR_BASE) === 'tool'
|
|
32
|
+
? path.dirname(TEMPLATES_ANCHOR_BASE)
|
|
33
|
+
: TEMPLATES_ANCHOR_BASE;
|
|
34
|
+
const TEMPLATES_DIR = resolveRepoPath('templates', TEMPLATES_ANCHOR);
|
|
35
|
+
const REPO_ROOT = path.dirname(TEMPLATES_DIR);
|
|
36
|
+
// āāā JetBrains support āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
37
|
+
/**
|
|
38
|
+
* Language overlays that have a defined JetBrains backend mapping.
|
|
39
|
+
* Used both in getJetBrainsBackend() and in the language filter for
|
|
40
|
+
* generateJetBrainsArtifacts() ā kept in one place for consistency.
|
|
41
|
+
*/
|
|
42
|
+
const JETBRAINS_SUPPORTED_LANGUAGES = new Set([
|
|
43
|
+
'nodejs',
|
|
44
|
+
'bun',
|
|
45
|
+
'python',
|
|
46
|
+
'mkdocs',
|
|
47
|
+
'go',
|
|
48
|
+
'dotnet',
|
|
49
|
+
'java',
|
|
50
|
+
'rust',
|
|
51
|
+
]);
|
|
52
|
+
/**
|
|
53
|
+
* Map a language overlay ID to the appropriate JetBrains backend identifier.
|
|
54
|
+
* Falls back to 'IntelliJIdea' when the language is unknown or unspecified.
|
|
55
|
+
*
|
|
56
|
+
* When multiple language overlays are selected, the first match in the
|
|
57
|
+
* provided array determines the backend; the array order reflects the user's
|
|
58
|
+
* selection order.
|
|
59
|
+
*/
|
|
60
|
+
function getJetBrainsBackend(languageOverlays) {
|
|
61
|
+
for (const lang of languageOverlays) {
|
|
62
|
+
switch (lang) {
|
|
63
|
+
case 'nodejs':
|
|
64
|
+
case 'bun':
|
|
65
|
+
return 'WebStorm';
|
|
66
|
+
case 'python':
|
|
67
|
+
case 'mkdocs':
|
|
68
|
+
return 'PyCharm';
|
|
69
|
+
case 'go':
|
|
70
|
+
return 'GoLand';
|
|
71
|
+
case 'dotnet':
|
|
72
|
+
return 'Rider';
|
|
73
|
+
case 'rust':
|
|
74
|
+
return 'RustRover';
|
|
75
|
+
case 'java':
|
|
76
|
+
return 'IntelliJIdea';
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return 'IntelliJIdea';
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Generate the content of .idea/.gitignore for a JetBrains project.
|
|
83
|
+
* Marks shared settings (run configurations, code style) as tracked and
|
|
84
|
+
* excludes user-local entries (workspace.xml, shelf/).
|
|
85
|
+
*/
|
|
86
|
+
function generateIdeaGitignore() {
|
|
87
|
+
return `# Default ignored files
|
|
88
|
+
/shelf/
|
|
89
|
+
/workspace.xml
|
|
90
|
+
|
|
91
|
+
# Editor-based HTTP Client requests
|
|
92
|
+
/httpRequests/
|
|
93
|
+
|
|
94
|
+
# Datasource local storage
|
|
95
|
+
/dataSources/
|
|
96
|
+
/dataSources.local.xml
|
|
97
|
+
`;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Generate a JetBrains run configuration XML for the given language overlay.
|
|
101
|
+
* Returns an object with the filename and XML content, or null when no
|
|
102
|
+
* configuration is defined for the supplied language.
|
|
103
|
+
*/
|
|
104
|
+
function generateRunConfiguration(lang) {
|
|
105
|
+
switch (lang) {
|
|
106
|
+
case 'nodejs':
|
|
107
|
+
case 'bun': {
|
|
108
|
+
const manager = lang === 'bun' ? 'bun' : 'npm';
|
|
109
|
+
const runScript = lang === 'bun' ? 'bun run dev' : 'npm run dev';
|
|
110
|
+
return {
|
|
111
|
+
filename: `${manager}_dev.xml`,
|
|
112
|
+
content: `<component name="ProjectRunConfigurationManager">
|
|
113
|
+
<configuration default="false" name="${runScript}" type="js.build_tools.npm" factoryName="npm">
|
|
114
|
+
<package-json value="$PROJECT_DIR$/package.json" />
|
|
115
|
+
<command value="run" />
|
|
116
|
+
<scripts>
|
|
117
|
+
<script value="dev" />
|
|
118
|
+
</scripts>
|
|
119
|
+
<node-interpreter value="project" />
|
|
120
|
+
<envs />
|
|
121
|
+
<method v="2" />
|
|
122
|
+
</configuration>
|
|
123
|
+
</component>
|
|
124
|
+
`,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
case 'mkdocs': {
|
|
128
|
+
return {
|
|
129
|
+
filename: 'mkdocs_serve.xml',
|
|
130
|
+
content: `<component name="ProjectRunConfigurationManager">
|
|
131
|
+
<configuration default="false" name="MkDocs: mkdocs serve" type="PythonConfigurationType" factoryName="Python">
|
|
132
|
+
<module name="" />
|
|
133
|
+
<option name="INTERPRETER_OPTIONS" value="" />
|
|
134
|
+
<option name="PARENT_ENVS" value="true" />
|
|
135
|
+
<envs>
|
|
136
|
+
<env name="PYTHONUNBUFFERED" value="1" />
|
|
137
|
+
</envs>
|
|
138
|
+
<option name="SDK_HOME" value="" />
|
|
139
|
+
<option name="SDK_NAME" value="" />
|
|
140
|
+
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$" />
|
|
141
|
+
<option name="IS_MODULE_SDK" value="false" />
|
|
142
|
+
<option name="ADD_CONTENT_ROOTS" value="true" />
|
|
143
|
+
<option name="ADD_SOURCE_ROOTS" value="true" />
|
|
144
|
+
<EXTENSION ID="PythonCoverageRunConfigurationExtension" runner="coverage.py" />
|
|
145
|
+
<option name="SCRIPT_NAME" value="-m" />
|
|
146
|
+
<option name="MODULE_MODE" value="true" />
|
|
147
|
+
<option name="PARAMETERS" value="mkdocs serve" />
|
|
148
|
+
<option name="SHOW_COMMAND_LINE" value="false" />
|
|
149
|
+
<option name="EMULATE_TERMINAL" value="false" />
|
|
150
|
+
<option name="REDIRECT_INPUT" value="false" />
|
|
151
|
+
<option name="INPUT_FILE" value="" />
|
|
152
|
+
<method v="2" />
|
|
153
|
+
</configuration>
|
|
154
|
+
</component>
|
|
155
|
+
`,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
case 'python': {
|
|
159
|
+
return {
|
|
160
|
+
filename: 'python_main.xml',
|
|
161
|
+
content: `<component name="ProjectRunConfigurationManager">
|
|
162
|
+
<configuration default="false" name="Python: main.py" type="PythonConfigurationType" factoryName="Python">
|
|
163
|
+
<module name="" />
|
|
164
|
+
<option name="INTERPRETER_OPTIONS" value="" />
|
|
165
|
+
<option name="PARENT_ENVS" value="true" />
|
|
166
|
+
<envs>
|
|
167
|
+
<env name="PYTHONUNBUFFERED" value="1" />
|
|
168
|
+
</envs>
|
|
169
|
+
<option name="SDK_HOME" value="" />
|
|
170
|
+
<option name="SDK_NAME" value="" />
|
|
171
|
+
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$" />
|
|
172
|
+
<option name="IS_MODULE_SDK" value="false" />
|
|
173
|
+
<option name="ADD_CONTENT_ROOTS" value="true" />
|
|
174
|
+
<option name="ADD_SOURCE_ROOTS" value="true" />
|
|
175
|
+
<EXTENSION ID="PythonCoverageRunConfigurationExtension" runner="coverage.py" />
|
|
176
|
+
<option name="SCRIPT_NAME" value="$PROJECT_DIR$/main.py" />
|
|
177
|
+
<option name="PARAMETERS" value="" />
|
|
178
|
+
<option name="SHOW_COMMAND_LINE" value="false" />
|
|
179
|
+
<option name="EMULATE_TERMINAL" value="false" />
|
|
180
|
+
<option name="MODULE_MODE" value="false" />
|
|
181
|
+
<option name="REDIRECT_INPUT" value="false" />
|
|
182
|
+
<option name="INPUT_FILE" value="" />
|
|
183
|
+
<method v="2" />
|
|
184
|
+
</configuration>
|
|
185
|
+
</component>
|
|
186
|
+
`,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
case 'go': {
|
|
190
|
+
return {
|
|
191
|
+
filename: 'go_run.xml',
|
|
192
|
+
content: `<component name="ProjectRunConfigurationManager">
|
|
193
|
+
<configuration default="false" name="Go: run ./..." type="GoApplicationRunConfiguration" factoryName="Go Application">
|
|
194
|
+
<module name="" />
|
|
195
|
+
<working_directory value="$PROJECT_DIR$" />
|
|
196
|
+
<parameters value="" />
|
|
197
|
+
<kind value="PACKAGE" />
|
|
198
|
+
<package value="./..." />
|
|
199
|
+
<directory value="$PROJECT_DIR$" />
|
|
200
|
+
<filePath value="$PROJECT_DIR$" />
|
|
201
|
+
<method v="2" />
|
|
202
|
+
</configuration>
|
|
203
|
+
</component>
|
|
204
|
+
`,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
case 'dotnet': {
|
|
208
|
+
return {
|
|
209
|
+
filename: 'dotnet_run.xml',
|
|
210
|
+
content: `<component name="ProjectRunConfigurationManager">
|
|
211
|
+
<configuration default="false" name=".NET: dotnet run" type="DotNetRunConfiguration" factoryName="Run">
|
|
212
|
+
<option name="EXE_PATH" value="" />
|
|
213
|
+
<option name="PROGRAM_PARAMETERS" value="" />
|
|
214
|
+
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$" />
|
|
215
|
+
<option name="PASS_PARENT_ENVS" value="1" />
|
|
216
|
+
<option name="USE_EXTERNAL_CONSOLE" value="0" />
|
|
217
|
+
<option name="RUNTIME_ARGUMENTS" value="" />
|
|
218
|
+
<option name="PROJECT_PATH" value="$PROJECT_DIR$" />
|
|
219
|
+
<option name="TARGET_FRAMEWORK_ID" value="" />
|
|
220
|
+
<option name="RUNTIME_ID" value="" />
|
|
221
|
+
<method v="2" />
|
|
222
|
+
</configuration>
|
|
223
|
+
</component>
|
|
224
|
+
`,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
case 'java': {
|
|
228
|
+
return {
|
|
229
|
+
filename: 'java_run.xml',
|
|
230
|
+
content: `<component name="ProjectRunConfigurationManager">
|
|
231
|
+
<configuration default="false" name="Java: Application" type="Application" factoryName="Application">
|
|
232
|
+
<option name="MAIN_CLASS_NAME" value="Main" />
|
|
233
|
+
<module name="" />
|
|
234
|
+
<option name="VM_PARAMETERS" value="" />
|
|
235
|
+
<option name="PROGRAM_PARAMETERS" value="" />
|
|
236
|
+
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$" />
|
|
237
|
+
<option name="ALTERNATIVE_JRE_PATH_ENABLED" value="false" />
|
|
238
|
+
<option name="ENABLE_SWING_INSPECTOR" value="false" />
|
|
239
|
+
<option name="ENV_VARIABLES" />
|
|
240
|
+
<option name="PASS_PARENT_ENVS" value="true" />
|
|
241
|
+
<method v="2">
|
|
242
|
+
<option name="Make" enabled="true" />
|
|
243
|
+
</method>
|
|
244
|
+
</configuration>
|
|
245
|
+
</component>
|
|
246
|
+
`,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
case 'rust': {
|
|
250
|
+
return {
|
|
251
|
+
filename: 'rust_run.xml',
|
|
252
|
+
content: `<component name="ProjectRunConfigurationManager">
|
|
253
|
+
<configuration default="false" name="Rust: cargo run" type="CargoCommandRunConfiguration" factoryName="Cargo Command">
|
|
254
|
+
<option name="command" value="run" />
|
|
255
|
+
<option name="workingDirectory" value="$PROJECT_DIR$" />
|
|
256
|
+
<envs />
|
|
257
|
+
<method v="2" />
|
|
258
|
+
</configuration>
|
|
259
|
+
</component>
|
|
260
|
+
`,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
default:
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Generate JetBrains IDE artifacts (.idea/.gitignore and run configurations)
|
|
269
|
+
* into the project root directory (parent of outputPath).
|
|
270
|
+
*
|
|
271
|
+
* Returns a list of project-root-relative paths that were written so the
|
|
272
|
+
* caller can register them and report what was generated.
|
|
273
|
+
*/
|
|
274
|
+
function generateJetBrainsArtifacts(projectRoot, languageOverlays) {
|
|
275
|
+
const ideaDir = path.join(projectRoot, '.idea');
|
|
276
|
+
const runConfigsDir = path.join(ideaDir, 'runConfigurations');
|
|
277
|
+
const written = [];
|
|
278
|
+
// Ensure directories exist
|
|
279
|
+
if (!fs.existsSync(ideaDir)) {
|
|
280
|
+
fs.mkdirSync(ideaDir, { recursive: true });
|
|
281
|
+
}
|
|
282
|
+
if (!fs.existsSync(runConfigsDir)) {
|
|
283
|
+
fs.mkdirSync(runConfigsDir, { recursive: true });
|
|
284
|
+
}
|
|
285
|
+
// Write .idea/.gitignore (only if not already present)
|
|
286
|
+
const gitignorePath = path.join(ideaDir, '.gitignore');
|
|
287
|
+
if (!fs.existsSync(gitignorePath)) {
|
|
288
|
+
fs.writeFileSync(gitignorePath, generateIdeaGitignore());
|
|
289
|
+
written.push('.idea/.gitignore');
|
|
290
|
+
}
|
|
291
|
+
// Write run configurations for each recognised language overlay
|
|
292
|
+
const generated = [];
|
|
293
|
+
const skipped = [];
|
|
294
|
+
for (const lang of languageOverlays) {
|
|
295
|
+
const runConfig = generateRunConfiguration(lang);
|
|
296
|
+
if (!runConfig)
|
|
297
|
+
continue;
|
|
298
|
+
const xmlPath = path.join(runConfigsDir, runConfig.filename);
|
|
299
|
+
if (!fs.existsSync(xmlPath)) {
|
|
300
|
+
fs.writeFileSync(xmlPath, runConfig.content);
|
|
301
|
+
written.push(`.idea/runConfigurations/${runConfig.filename}`);
|
|
302
|
+
generated.push(lang);
|
|
303
|
+
}
|
|
304
|
+
else {
|
|
305
|
+
skipped.push(runConfig.filename);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
if (generated.length > 0) {
|
|
309
|
+
console.log(chalk.dim(` š” Generated JetBrains run configuration(s) for: ${generated.join(', ')}`));
|
|
310
|
+
}
|
|
311
|
+
if (skipped.length > 0) {
|
|
312
|
+
console.log(chalk.dim(` āļø Skipped existing JetBrains run configuration(s): ${skipped.join(', ')}`));
|
|
313
|
+
}
|
|
314
|
+
if (languageOverlays.length === 0) {
|
|
315
|
+
console.log(chalk.dim(` ā¹ļø No language overlays selected ā no JetBrains run configurations generated`));
|
|
316
|
+
}
|
|
317
|
+
return written;
|
|
318
|
+
}
|
|
319
|
+
// āāā End JetBrains support āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
29
320
|
/**
|
|
30
321
|
* Merge packages from apt-get-packages feature
|
|
31
322
|
*/
|
|
@@ -249,7 +540,7 @@ function prepareOverlaysForGeneration(answers, overlaysDir) {
|
|
|
249
540
|
/**
|
|
250
541
|
* Generate superposition.json manifest
|
|
251
542
|
*/
|
|
252
|
-
function generateManifest(outputPath, answers, overlays, autoResolved, containerName) {
|
|
543
|
+
function generateManifest(outputPath, answers, overlays, autoResolved, containerName, effectiveTarget) {
|
|
253
544
|
const toolVersion = getToolVersion();
|
|
254
545
|
const manifest = {
|
|
255
546
|
manifestVersion: CURRENT_MANIFEST_VERSION,
|
|
@@ -265,7 +556,14 @@ function generateManifest(outputPath, answers, overlays, autoResolved, container
|
|
|
265
556
|
preset: answers.preset,
|
|
266
557
|
presetChoices: answers.presetChoices,
|
|
267
558
|
containerName,
|
|
559
|
+
target: effectiveTarget ?? answers.target ?? 'local',
|
|
268
560
|
};
|
|
561
|
+
if (answers.minimal) {
|
|
562
|
+
manifest.minimal = true;
|
|
563
|
+
}
|
|
564
|
+
if (answers.editor && answers.editor !== 'vscode') {
|
|
565
|
+
manifest.editor = answers.editor;
|
|
566
|
+
}
|
|
269
567
|
if (autoResolved.added.length > 0) {
|
|
270
568
|
manifest.autoResolved = autoResolved;
|
|
271
569
|
}
|
|
@@ -308,7 +606,7 @@ function validateImportPath(importPath, overlaysDir) {
|
|
|
308
606
|
/**
|
|
309
607
|
* Load and resolve imports from shared files for an overlay
|
|
310
608
|
*/
|
|
311
|
-
function loadImportsForOverlay(overlayName, overlaysDir) {
|
|
609
|
+
function loadImportsForOverlay(overlayName, overlaysDir, silent = false) {
|
|
312
610
|
let importedConfig = {};
|
|
313
611
|
// Load overlay manifest to get imports
|
|
314
612
|
const overlayDir = path.join(overlaysDir, overlayName);
|
|
@@ -340,14 +638,16 @@ function loadImportsForOverlay(overlayName, overlaysDir) {
|
|
|
340
638
|
const ext = path.extname(importPath).toLowerCase();
|
|
341
639
|
if (ext === '.json') {
|
|
342
640
|
// JSON files are merged as devcontainer patches
|
|
343
|
-
|
|
641
|
+
if (!silent)
|
|
642
|
+
console.log(chalk.dim(` š Applying shared import: ${importPath}`));
|
|
344
643
|
const importedPatch = loadJson(fullImportPath);
|
|
345
644
|
importedConfig = deepMerge(importedConfig, importedPatch);
|
|
346
645
|
}
|
|
347
646
|
else if (ext === '.yaml' || ext === '.yml') {
|
|
348
647
|
// YAML files are loaded and merged as devcontainer patches
|
|
349
648
|
try {
|
|
350
|
-
|
|
649
|
+
if (!silent)
|
|
650
|
+
console.log(chalk.dim(` š Applying shared import: ${importPath}`));
|
|
351
651
|
const yamlContent = fs.readFileSync(fullImportPath, 'utf8');
|
|
352
652
|
const importedPatch = yaml.load(yamlContent);
|
|
353
653
|
if (importedPatch && typeof importedPatch === 'object') {
|
|
@@ -360,7 +660,8 @@ function loadImportsForOverlay(overlayName, overlaysDir) {
|
|
|
360
660
|
}
|
|
361
661
|
else if (ext === '.env') {
|
|
362
662
|
// .env files are handled separately during env merging ā skip here
|
|
363
|
-
|
|
663
|
+
if (!silent)
|
|
664
|
+
console.log(chalk.dim(` š Shared .env import noted: ${importPath}`));
|
|
364
665
|
}
|
|
365
666
|
else {
|
|
366
667
|
// FR-007: Unsupported file types are errors
|
|
@@ -381,14 +682,16 @@ function loadImportsForOverlay(overlayName, overlaysDir) {
|
|
|
381
682
|
/**
|
|
382
683
|
* Apply an overlay to the base configuration
|
|
383
684
|
*/
|
|
384
|
-
export function applyOverlay(baseConfig, overlayName, overlaysDir) {
|
|
685
|
+
export function applyOverlay(baseConfig, overlayName, overlaysDir, options = {}) {
|
|
686
|
+
const { silent = false } = options;
|
|
385
687
|
const overlayPath = path.join(overlaysDir, overlayName, 'devcontainer.patch.json');
|
|
386
688
|
if (!fs.existsSync(overlayPath)) {
|
|
387
|
-
|
|
689
|
+
if (!silent)
|
|
690
|
+
console.warn(chalk.yellow(`ā ļø Overlay not found: ${overlayName}`));
|
|
388
691
|
return baseConfig;
|
|
389
692
|
}
|
|
390
693
|
// First, load and apply any imports
|
|
391
|
-
const importedConfig = loadImportsForOverlay(overlayName, overlaysDir);
|
|
694
|
+
const importedConfig = loadImportsForOverlay(overlayName, overlaysDir, silent);
|
|
392
695
|
if (Object.keys(importedConfig).length > 0) {
|
|
393
696
|
baseConfig = deepMerge(baseConfig, importedConfig);
|
|
394
697
|
}
|
|
@@ -430,8 +733,36 @@ class FileRegistry {
|
|
|
430
733
|
}
|
|
431
734
|
}
|
|
432
735
|
/**
|
|
433
|
-
*
|
|
434
|
-
*
|
|
736
|
+
* Recursively remove stale files within a registered subdirectory.
|
|
737
|
+
* Called for directories that ARE in the registry but may contain files from
|
|
738
|
+
* a previous run that are no longer part of the current generation (e.g.
|
|
739
|
+
* scripts/setup-rabbitmq.sh after rabbitmq was removed from the project).
|
|
740
|
+
* Returns the number of files removed.
|
|
741
|
+
*/
|
|
742
|
+
function cleanupStaleDirFiles(dirPath, prefix, expectedFiles) {
|
|
743
|
+
let removed = 0;
|
|
744
|
+
const entries = fs.readdirSync(dirPath);
|
|
745
|
+
for (const entry of entries) {
|
|
746
|
+
const entryPath = path.join(dirPath, entry);
|
|
747
|
+
const stat = fs.statSync(entryPath);
|
|
748
|
+
if (stat.isDirectory()) {
|
|
749
|
+
removed += cleanupStaleDirFiles(entryPath, `${prefix}${entry}/`, expectedFiles);
|
|
750
|
+
}
|
|
751
|
+
else {
|
|
752
|
+
const registryKey = `${prefix}${entry}`;
|
|
753
|
+
if (!expectedFiles.has(registryKey)) {
|
|
754
|
+
fs.unlinkSync(entryPath);
|
|
755
|
+
removed++;
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
return removed;
|
|
760
|
+
}
|
|
761
|
+
/**
|
|
762
|
+
* Clean up stale files from previous runs.
|
|
763
|
+
* Removes anything not in the registry (except preserved files like superposition.json).
|
|
764
|
+
* Also recurses into registered subdirectories to remove individual stale files within
|
|
765
|
+
* them ā e.g. scripts/setup-rabbitmq.sh after rabbitmq is removed from the project.
|
|
435
766
|
*/
|
|
436
767
|
function cleanupStaleFiles(outputPath, registry) {
|
|
437
768
|
if (!fs.existsSync(outputPath)) {
|
|
@@ -455,11 +786,16 @@ function cleanupStaleFiles(outputPath, registry) {
|
|
|
455
786
|
if (preservedDirs.has(entry)) {
|
|
456
787
|
continue;
|
|
457
788
|
}
|
|
458
|
-
// Remove directory if not in registry
|
|
459
789
|
if (!expectedDirs.has(entry)) {
|
|
790
|
+
// Remove directory entirely ā nothing inside belongs to this run
|
|
460
791
|
fs.rmSync(entryPath, { recursive: true, force: true });
|
|
461
792
|
removedCount++;
|
|
462
793
|
}
|
|
794
|
+
else {
|
|
795
|
+
// Directory is still expected, but individual files inside it may be stale
|
|
796
|
+
// (e.g. scripts/setup-rabbitmq.sh after rabbitmq was removed)
|
|
797
|
+
removedCount += cleanupStaleDirFiles(entryPath, `${entry}/`, expectedFiles);
|
|
798
|
+
}
|
|
463
799
|
}
|
|
464
800
|
else {
|
|
465
801
|
// Remove file if not in registry
|
|
@@ -492,6 +828,23 @@ function copyDir(src, dest) {
|
|
|
492
828
|
}
|
|
493
829
|
}
|
|
494
830
|
}
|
|
831
|
+
/**
|
|
832
|
+
* Recursively register every file inside a directory in the FileRegistry.
|
|
833
|
+
* Used after copyDir() to ensure cleanup logic doesn't delete the copied contents.
|
|
834
|
+
*/
|
|
835
|
+
function registerDirContents(registry, dirPath, prefix) {
|
|
836
|
+
if (!fs.existsSync(dirPath))
|
|
837
|
+
return;
|
|
838
|
+
for (const entry of fs.readdirSync(dirPath, { withFileTypes: true })) {
|
|
839
|
+
const rel = `${prefix}${entry.name}`;
|
|
840
|
+
if (entry.isDirectory()) {
|
|
841
|
+
registerDirContents(registry, path.join(dirPath, entry.name), `${rel}/`);
|
|
842
|
+
}
|
|
843
|
+
else {
|
|
844
|
+
registry.addFile(rel);
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
}
|
|
495
848
|
/**
|
|
496
849
|
* Copy additional files from overlay to output directory
|
|
497
850
|
* Excludes devcontainer.patch.json and .env.example (handled separately)
|
|
@@ -534,6 +887,9 @@ function copyOverlayFiles(outputPath, overlayName, registry, overlaysDir) {
|
|
|
534
887
|
const destPath = path.join(outputPath, destDirName);
|
|
535
888
|
copyDir(srcPath, destPath);
|
|
536
889
|
registry.addDirectory(destDirName);
|
|
890
|
+
// Register every file inside the copied directory so that
|
|
891
|
+
// cleanupStaleDirFiles does not delete them during the same run.
|
|
892
|
+
registerDirContents(registry, destPath, `${destDirName}/`);
|
|
537
893
|
copiedFiles++;
|
|
538
894
|
}
|
|
539
895
|
}
|
|
@@ -541,6 +897,164 @@ function copyOverlayFiles(outputPath, overlayName, registry, overlaysDir) {
|
|
|
541
897
|
console.log(chalk.dim(` š Copied ${copiedFiles} file(s) from ${chalk.cyan(overlayName)}`));
|
|
542
898
|
}
|
|
543
899
|
}
|
|
900
|
+
const PROJECT_ENV_REFERENCE_PATTERN = /\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-[^}]*)?\}/g;
|
|
901
|
+
function parseSimpleEnvFile(content) {
|
|
902
|
+
const env = {};
|
|
903
|
+
for (const line of content.split('\n')) {
|
|
904
|
+
const trimmed = line.trim();
|
|
905
|
+
if (!trimmed || trimmed.startsWith('#')) {
|
|
906
|
+
continue;
|
|
907
|
+
}
|
|
908
|
+
const match = trimmed.match(/^([^=]+)=(.*)$/);
|
|
909
|
+
if (!match) {
|
|
910
|
+
continue;
|
|
911
|
+
}
|
|
912
|
+
env[match[1].trim()] = match[2].trim();
|
|
913
|
+
}
|
|
914
|
+
return env;
|
|
915
|
+
}
|
|
916
|
+
function loadEnvFileIfExists(filePath) {
|
|
917
|
+
if (!fs.existsSync(filePath)) {
|
|
918
|
+
return {};
|
|
919
|
+
}
|
|
920
|
+
return parseSimpleEnvFile(fs.readFileSync(filePath, 'utf8'));
|
|
921
|
+
}
|
|
922
|
+
function resolveProjectEnvTarget(entry, stack) {
|
|
923
|
+
const target = entry.target ?? 'auto';
|
|
924
|
+
if (target === 'remoteEnv') {
|
|
925
|
+
return 'remoteEnv';
|
|
926
|
+
}
|
|
927
|
+
if (target === 'composeEnv') {
|
|
928
|
+
if (stack !== 'compose') {
|
|
929
|
+
throw new Error('Project env target "composeEnv" requires stack: compose because no docker-compose.yml is generated for plain stacks');
|
|
930
|
+
}
|
|
931
|
+
return 'composeEnv';
|
|
932
|
+
}
|
|
933
|
+
return stack === 'compose' ? 'composeEnv' : 'remoteEnv';
|
|
934
|
+
}
|
|
935
|
+
function resolveRootEnvReferences(value, rootEnv) {
|
|
936
|
+
return value.replace(PROJECT_ENV_REFERENCE_PATTERN, (match, name) => {
|
|
937
|
+
if (rootEnv[name] !== undefined) {
|
|
938
|
+
return rootEnv[name];
|
|
939
|
+
}
|
|
940
|
+
const defaultMatch = match.match(/^\$\{[A-Za-z_][A-Za-z0-9_]*:-([^}]*)\}$/);
|
|
941
|
+
return defaultMatch ? defaultMatch[1] : match;
|
|
942
|
+
});
|
|
943
|
+
}
|
|
944
|
+
function hasUnresolvedProjectEnvReference(value) {
|
|
945
|
+
PROJECT_ENV_REFERENCE_PATTERN.lastIndex = 0;
|
|
946
|
+
const result = PROJECT_ENV_REFERENCE_PATTERN.test(value);
|
|
947
|
+
PROJECT_ENV_REFERENCE_PATTERN.lastIndex = 0;
|
|
948
|
+
return result;
|
|
949
|
+
}
|
|
950
|
+
function buildResolvedProjectRemoteEnvEntries(projectEnv, stack, rootEnv) {
|
|
951
|
+
const entries = {};
|
|
952
|
+
for (const [key, entry] of Object.entries(projectEnv ?? {})) {
|
|
953
|
+
if (resolveProjectEnvTarget(entry, stack) !== 'remoteEnv') {
|
|
954
|
+
continue;
|
|
955
|
+
}
|
|
956
|
+
entries[key] = resolveRootEnvReferences(entry.value, rootEnv ?? {});
|
|
957
|
+
}
|
|
958
|
+
return entries;
|
|
959
|
+
}
|
|
960
|
+
function buildComposeProjectEnvInterpolationEntries(projectEnv, stack) {
|
|
961
|
+
const entries = {};
|
|
962
|
+
for (const [key, entry] of Object.entries(projectEnv ?? {})) {
|
|
963
|
+
if (resolveProjectEnvTarget(entry, stack) !== 'composeEnv') {
|
|
964
|
+
continue;
|
|
965
|
+
}
|
|
966
|
+
entries[key] = `\${${key}}`;
|
|
967
|
+
}
|
|
968
|
+
return entries;
|
|
969
|
+
}
|
|
970
|
+
function buildComposeProjectRemoteEnvRefs(projectEnv, stack) {
|
|
971
|
+
const entries = {};
|
|
972
|
+
for (const [key, entry] of Object.entries(projectEnv ?? {})) {
|
|
973
|
+
if (resolveProjectEnvTarget(entry, stack) !== 'composeEnv') {
|
|
974
|
+
continue;
|
|
975
|
+
}
|
|
976
|
+
entries[key] = `\${containerEnv:${key}}`;
|
|
977
|
+
}
|
|
978
|
+
return entries;
|
|
979
|
+
}
|
|
980
|
+
function materializeComposeProjectEnvValues(projectEnv, stack, rootEnv) {
|
|
981
|
+
const entries = {};
|
|
982
|
+
for (const [key, entry] of Object.entries(projectEnv ?? {})) {
|
|
983
|
+
if (resolveProjectEnvTarget(entry, stack) !== 'composeEnv') {
|
|
984
|
+
continue;
|
|
985
|
+
}
|
|
986
|
+
const resolvedValue = resolveRootEnvReferences(entry.value, rootEnv);
|
|
987
|
+
// Leave unresolved variables to shell/docker-compose fallback instead of
|
|
988
|
+
// persisting placeholder syntax into .devcontainer/.env.
|
|
989
|
+
if (hasUnresolvedProjectEnvReference(resolvedValue)) {
|
|
990
|
+
continue;
|
|
991
|
+
}
|
|
992
|
+
entries[key] = resolvedValue;
|
|
993
|
+
}
|
|
994
|
+
return entries;
|
|
995
|
+
}
|
|
996
|
+
function applyProjectEnvToDevcontainer(config, projectEnv, stack, rootEnv) {
|
|
997
|
+
const remoteEnv = {
|
|
998
|
+
...buildResolvedProjectRemoteEnvEntries(projectEnv, stack, rootEnv),
|
|
999
|
+
...buildComposeProjectRemoteEnvRefs(projectEnv, stack),
|
|
1000
|
+
};
|
|
1001
|
+
if (Object.keys(remoteEnv).length === 0) {
|
|
1002
|
+
return config;
|
|
1003
|
+
}
|
|
1004
|
+
console.log(chalk.dim(` š± Applying project env to remoteEnv`));
|
|
1005
|
+
return deepMerge(config, { remoteEnv });
|
|
1006
|
+
}
|
|
1007
|
+
function mergeComposeEnvFile(outputPath, entries) {
|
|
1008
|
+
if (Object.keys(entries).length === 0) {
|
|
1009
|
+
return false;
|
|
1010
|
+
}
|
|
1011
|
+
const envPath = path.join(outputPath, '.env');
|
|
1012
|
+
const originalContent = fs.existsSync(envPath) ? fs.readFileSync(envPath, 'utf8') : '';
|
|
1013
|
+
const lines = originalContent === '' ? [] : originalContent.replace(/\n$/, '').split('\n');
|
|
1014
|
+
const indexByKey = new Map();
|
|
1015
|
+
lines.forEach((line, index) => {
|
|
1016
|
+
const match = line.match(/^([^#=\s][^=]*)=(.*)$/);
|
|
1017
|
+
if (match) {
|
|
1018
|
+
indexByKey.set(match[1].trim(), index);
|
|
1019
|
+
}
|
|
1020
|
+
});
|
|
1021
|
+
let changed = false;
|
|
1022
|
+
let insertedSpacer = false;
|
|
1023
|
+
for (const [key, value] of Object.entries(entries)) {
|
|
1024
|
+
const rendered = `${key}=${value}`;
|
|
1025
|
+
const existingIndex = indexByKey.get(key);
|
|
1026
|
+
if (existingIndex !== undefined) {
|
|
1027
|
+
if (lines[existingIndex] !== rendered) {
|
|
1028
|
+
lines[existingIndex] = rendered;
|
|
1029
|
+
changed = true;
|
|
1030
|
+
}
|
|
1031
|
+
continue;
|
|
1032
|
+
}
|
|
1033
|
+
if (lines.length > 0 && !insertedSpacer && lines[lines.length - 1] !== '') {
|
|
1034
|
+
lines.push('');
|
|
1035
|
+
insertedSpacer = true;
|
|
1036
|
+
}
|
|
1037
|
+
lines.push(rendered);
|
|
1038
|
+
indexByKey.set(key, lines.length - 1);
|
|
1039
|
+
changed = true;
|
|
1040
|
+
}
|
|
1041
|
+
if (!changed && originalContent !== '') {
|
|
1042
|
+
return false;
|
|
1043
|
+
}
|
|
1044
|
+
fs.writeFileSync(envPath, `${lines.join('\n')}\n`);
|
|
1045
|
+
return true;
|
|
1046
|
+
}
|
|
1047
|
+
function materializeComposeProjectEnvFile(outputPath, projectEnv, stack, rootEnv) {
|
|
1048
|
+
if (stack !== 'compose') {
|
|
1049
|
+
return false;
|
|
1050
|
+
}
|
|
1051
|
+
const materializedEntries = materializeComposeProjectEnvValues(projectEnv, stack, rootEnv);
|
|
1052
|
+
if (!mergeComposeEnvFile(outputPath, materializedEntries)) {
|
|
1053
|
+
return false;
|
|
1054
|
+
}
|
|
1055
|
+
console.log(chalk.dim(` š Materialized ${Object.keys(materializedEntries).length} project env value(s) into .devcontainer/.env for docker-compose`));
|
|
1056
|
+
return true;
|
|
1057
|
+
}
|
|
544
1058
|
/**
|
|
545
1059
|
* Merge .env.example files from all selected overlays
|
|
546
1060
|
*/
|
|
@@ -813,15 +1327,48 @@ function resolveDockerComposePortConflicts(services) {
|
|
|
813
1327
|
/**
|
|
814
1328
|
* Merge docker-compose.yml files from base and overlays into a single file
|
|
815
1329
|
*/
|
|
816
|
-
function mergeDockerComposeFiles(outputPath, baseStack, overlays, overlaysDir, portOffset, customImage) {
|
|
1330
|
+
function mergeDockerComposeFiles(outputPath, baseStack, overlays, overlaysDir, portOffset, customImage, projectEnv) {
|
|
817
1331
|
const composeFiles = [];
|
|
818
1332
|
// Add base docker-compose if exists
|
|
819
1333
|
const baseComposePath = path.join(TEMPLATES_DIR, baseStack, '.devcontainer', 'docker-compose.yml');
|
|
820
1334
|
if (fs.existsSync(baseComposePath)) {
|
|
821
1335
|
composeFiles.push(baseComposePath);
|
|
822
1336
|
}
|
|
823
|
-
// Add overlay docker-compose files
|
|
1337
|
+
// Add overlay docker-compose files, interleaving any compose_imports before each overlay's own file
|
|
824
1338
|
for (const overlay of overlays) {
|
|
1339
|
+
// First load any compose_imports for this overlay (shared fragments applied before own file)
|
|
1340
|
+
const manifestPath = path.join(overlaysDir, overlay, 'overlay.yml');
|
|
1341
|
+
if (fs.existsSync(manifestPath)) {
|
|
1342
|
+
try {
|
|
1343
|
+
const manifestContent = fs.readFileSync(manifestPath, 'utf8');
|
|
1344
|
+
const manifest = yaml.load(manifestContent);
|
|
1345
|
+
if (manifest.compose_imports && Array.isArray(manifest.compose_imports)) {
|
|
1346
|
+
for (const importPath of manifest.compose_imports) {
|
|
1347
|
+
const traversalError = validateImportPath(importPath, overlaysDir);
|
|
1348
|
+
if (traversalError) {
|
|
1349
|
+
throw new Error(`compose_import path traversal rejected in overlay '${overlay}': ${traversalError}`);
|
|
1350
|
+
}
|
|
1351
|
+
const fullImportPath = path.join(overlaysDir, importPath);
|
|
1352
|
+
if (!fs.existsSync(fullImportPath)) {
|
|
1353
|
+
throw new Error(`compose_import not found: '${importPath}' (referenced by overlay: ${overlay})`);
|
|
1354
|
+
}
|
|
1355
|
+
const ext = path.extname(importPath).toLowerCase();
|
|
1356
|
+
if (ext !== '.yml' && ext !== '.yaml') {
|
|
1357
|
+
throw new Error(`compose_import must be a .yml or .yaml file: '${importPath}' (overlay: ${overlay})`);
|
|
1358
|
+
}
|
|
1359
|
+
console.log(chalk.dim(` š Applying shared compose fragment: ${importPath}`));
|
|
1360
|
+
composeFiles.push(fullImportPath);
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
catch (error) {
|
|
1365
|
+
if (error instanceof Error) {
|
|
1366
|
+
throw error;
|
|
1367
|
+
}
|
|
1368
|
+
// Non-Error throwables are unexpected; wrap and re-throw so compose_imports failures always fail fast
|
|
1369
|
+
throw new Error(`Unexpected error loading compose_imports for overlay '${overlay}': ${String(error)}`);
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
825
1372
|
const overlayComposePath = path.join(overlaysDir, overlay, 'docker-compose.yml');
|
|
826
1373
|
if (fs.existsSync(overlayComposePath)) {
|
|
827
1374
|
composeFiles.push(overlayComposePath);
|
|
@@ -859,6 +1406,11 @@ function mergeDockerComposeFiles(outputPath, baseStack, overlays, overlaysDir, p
|
|
|
859
1406
|
}
|
|
860
1407
|
// Ensure devcontainer service has an image
|
|
861
1408
|
if (merged.services.devcontainer) {
|
|
1409
|
+
const composeEnv = buildComposeProjectEnvInterpolationEntries(projectEnv, baseStack);
|
|
1410
|
+
if (Object.keys(composeEnv).length > 0) {
|
|
1411
|
+
merged.services.devcontainer.environment = deepMerge(merged.services.devcontainer.environment ?? {}, composeEnv);
|
|
1412
|
+
console.log(chalk.dim(` š± Applying project env to docker-compose devcontainer service`));
|
|
1413
|
+
}
|
|
862
1414
|
if (customImage) {
|
|
863
1415
|
// Apply custom base image if specified
|
|
864
1416
|
merged.services.devcontainer.image = customImage;
|
|
@@ -1228,17 +1780,60 @@ export async function composeDevContainer(answers, overlaysDir, options = {}) {
|
|
|
1228
1780
|
}
|
|
1229
1781
|
}
|
|
1230
1782
|
const overlays = orderedOverlays;
|
|
1231
|
-
//
|
|
1783
|
+
// 5b. Resolve overlay parameters ({{cs.KEY}} substitution)
|
|
1784
|
+
// Collect parameter declarations from all selected overlays
|
|
1785
|
+
const declaredParams = collectOverlayParameters(overlays, allOverlayDefs);
|
|
1786
|
+
const { values: resolvedParams, missingRequired, unknownSupplied, } = resolveParameters(declaredParams, answers.overlayParameters ?? {});
|
|
1787
|
+
if (missingRequired.length > 0) {
|
|
1788
|
+
throw new Error(`Missing required overlay parameters: ${missingRequired.join(', ')}. ` +
|
|
1789
|
+
`Provide values in superposition.yml under the parameters: section, ` +
|
|
1790
|
+
`or via --param KEY=VALUE on the command line.`);
|
|
1791
|
+
}
|
|
1792
|
+
if (unknownSupplied.length > 0) {
|
|
1793
|
+
console.warn(chalk.yellow(` ā ļø Unknown overlay parameters (not declared by any selected overlay): ${unknownSupplied.join(', ')}`));
|
|
1794
|
+
}
|
|
1795
|
+
const hasResolvedParams = Object.keys(resolvedParams).length > 0;
|
|
1796
|
+
// Log resolved parameter values (sensitive values are redacted)
|
|
1797
|
+
if (hasResolvedParams) {
|
|
1798
|
+
const displayValues = redactSensitiveValues(resolvedParams, declaredParams);
|
|
1799
|
+
console.log(chalk.dim(` āļø Overlay parameters:`));
|
|
1800
|
+
for (const [k, v] of Object.entries(displayValues)) {
|
|
1801
|
+
console.log(chalk.dim(` ${k}=${v}`));
|
|
1802
|
+
}
|
|
1803
|
+
}
|
|
1232
1804
|
const outputPath = path.resolve(answers.outputPath);
|
|
1805
|
+
const projectRoot = path.dirname(outputPath);
|
|
1233
1806
|
const fileRegistry = new FileRegistry();
|
|
1807
|
+
const rootEnv = loadEnvFileIfExists(path.join(projectRoot, '.env'));
|
|
1234
1808
|
if (!fs.existsSync(outputPath)) {
|
|
1235
1809
|
fs.mkdirSync(outputPath, { recursive: true });
|
|
1236
1810
|
}
|
|
1811
|
+
// 5a. Remove stale project-root artifacts from a previous target run
|
|
1812
|
+
const manifestPath_existing = path.join(outputPath, 'superposition.json');
|
|
1813
|
+
let manifestTarget;
|
|
1814
|
+
if (fs.existsSync(manifestPath_existing)) {
|
|
1815
|
+
try {
|
|
1816
|
+
const existingManifest = JSON.parse(fs.readFileSync(manifestPath_existing, 'utf-8'));
|
|
1817
|
+
manifestTarget = existingManifest.target;
|
|
1818
|
+
}
|
|
1819
|
+
catch {
|
|
1820
|
+
// If manifest is unreadable, skip stale cleanup gracefully
|
|
1821
|
+
}
|
|
1822
|
+
}
|
|
1823
|
+
// When answers.target is undefined (e.g. regen without --target), fall back to the
|
|
1824
|
+
// target recorded in the existing manifest so the correct artifacts are reproduced.
|
|
1825
|
+
const activeTarget = answers.target ?? manifestTarget ?? 'local';
|
|
1826
|
+
const previousTarget = manifestTarget ?? 'local';
|
|
1827
|
+
if (previousTarget !== activeTarget) {
|
|
1828
|
+
removeStaleTargetArtifacts(previousTarget, activeTarget, projectRoot);
|
|
1829
|
+
console.log(chalk.dim(` š§¹ Removed stale target artifacts for previous target '${previousTarget}'`));
|
|
1830
|
+
}
|
|
1237
1831
|
// 6. Apply overlays
|
|
1238
1832
|
for (const overlay of overlays) {
|
|
1239
1833
|
console.log(chalk.dim(` š§ Applying overlay: ${chalk.cyan(overlay)}`));
|
|
1240
1834
|
config = applyOverlay(config, overlay, actualOverlaysDir);
|
|
1241
1835
|
}
|
|
1836
|
+
config = applyProjectEnvToDevcontainer(config, answers.projectEnv, answers.stack, rootEnv);
|
|
1242
1837
|
// 7. Copy template files (docker-compose, scripts, etc.)
|
|
1243
1838
|
const entries = fs.readdirSync(templatePath);
|
|
1244
1839
|
for (const entry of entries) {
|
|
@@ -1262,11 +1857,19 @@ export async function composeDevContainer(answers, overlaysDir, options = {}) {
|
|
|
1262
1857
|
}
|
|
1263
1858
|
// 8.5. Copy cross-distro-packages feature if used
|
|
1264
1859
|
if (config.features?.['./features/cross-distro-packages']) {
|
|
1265
|
-
const
|
|
1266
|
-
const
|
|
1860
|
+
const featureName = 'cross-distro-packages';
|
|
1861
|
+
const featuresDir = path.join(outputPath, 'features', featureName);
|
|
1862
|
+
const sourceFeatureDir = path.join(REPO_ROOT, 'features', featureName);
|
|
1267
1863
|
if (fs.existsSync(sourceFeatureDir)) {
|
|
1268
1864
|
copyDir(sourceFeatureDir, featuresDir);
|
|
1269
1865
|
fileRegistry.addDirectory('features');
|
|
1866
|
+
// Register every file inside the feature so cleanupStaleDirFiles
|
|
1867
|
+
// does not remove them when it recurses into the 'features' directory.
|
|
1868
|
+
for (const f of fs.readdirSync(sourceFeatureDir)) {
|
|
1869
|
+
if (fs.statSync(path.join(sourceFeatureDir, f)).isFile()) {
|
|
1870
|
+
fileRegistry.addFile(`features/${featureName}/${f}`);
|
|
1871
|
+
}
|
|
1872
|
+
}
|
|
1270
1873
|
console.log(chalk.dim(` š¦ Copied cross-distro-packages feature`));
|
|
1271
1874
|
}
|
|
1272
1875
|
}
|
|
@@ -1278,11 +1881,22 @@ export async function composeDevContainer(answers, overlaysDir, options = {}) {
|
|
|
1278
1881
|
let composePortRemappings = [];
|
|
1279
1882
|
if (answers.stack === 'compose') {
|
|
1280
1883
|
const customImage = config._customImage;
|
|
1281
|
-
composePortRemappings = mergeDockerComposeFiles(outputPath, answers.stack, overlays, actualOverlaysDir, answers.portOffset, customImage);
|
|
1884
|
+
composePortRemappings = mergeDockerComposeFiles(outputPath, answers.stack, overlays, actualOverlaysDir, answers.portOffset, customImage, answers.projectEnv);
|
|
1282
1885
|
// Update devcontainer.json to reference the combined file
|
|
1283
1886
|
if (config.dockerComposeFile) {
|
|
1284
1887
|
config.dockerComposeFile = 'docker-compose.yml';
|
|
1285
1888
|
}
|
|
1889
|
+
// Apply parameter substitution to the merged docker-compose.yml
|
|
1890
|
+
if (hasResolvedParams) {
|
|
1891
|
+
const composePath = path.join(outputPath, 'docker-compose.yml');
|
|
1892
|
+
if (fs.existsSync(composePath)) {
|
|
1893
|
+
const original = fs.readFileSync(composePath, 'utf8');
|
|
1894
|
+
const substituted = substituteParameters(original, resolvedParams);
|
|
1895
|
+
if (substituted !== original) {
|
|
1896
|
+
fs.writeFileSync(composePath, substituted);
|
|
1897
|
+
}
|
|
1898
|
+
}
|
|
1899
|
+
}
|
|
1286
1900
|
}
|
|
1287
1901
|
// Apply port offset to devcontainer.json if specified
|
|
1288
1902
|
if (answers.portOffset) {
|
|
@@ -1311,24 +1925,62 @@ export async function composeDevContainer(answers, overlaysDir, options = {}) {
|
|
|
1311
1925
|
if (answers.editor === 'none' || answers.editor === 'jetbrains') {
|
|
1312
1926
|
// Remove VS Code customizations
|
|
1313
1927
|
if (config.customizations?.vscode) {
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
}
|
|
1318
|
-
else if (answers.editor === 'jetbrains') {
|
|
1319
|
-
// For JetBrains, remove VS Code customizations (future: could add JetBrains-specific settings)
|
|
1320
|
-
delete config.customizations.vscode;
|
|
1321
|
-
console.log(chalk.dim(` šØ Editor profile 'jetbrains': Removed VS Code customizations`));
|
|
1322
|
-
}
|
|
1928
|
+
delete config.customizations.vscode;
|
|
1929
|
+
const profileLabel = answers.editor === 'none' ? 'none' : 'jetbrains';
|
|
1930
|
+
console.log(chalk.dim(` šØ Editor profile '${profileLabel}': Removed VS Code customizations`));
|
|
1323
1931
|
// Clean up empty customizations object
|
|
1324
1932
|
if (config.customizations && Object.keys(config.customizations).length === 0) {
|
|
1325
1933
|
delete config.customizations;
|
|
1326
1934
|
}
|
|
1327
1935
|
}
|
|
1328
1936
|
}
|
|
1937
|
+
// Add JetBrains-specific devcontainer.json customizations and generate .idea/ artifacts
|
|
1938
|
+
if (answers.editor === 'jetbrains') {
|
|
1939
|
+
const selectedLanguages = answers.language ?? [];
|
|
1940
|
+
const languageOverlays = selectedLanguages.filter((lang) => JETBRAINS_SUPPORTED_LANGUAGES.has(lang));
|
|
1941
|
+
if (languageOverlays.length === 0 && selectedLanguages.length > 0) {
|
|
1942
|
+
const selectedLabel = selectedLanguages.join(', ');
|
|
1943
|
+
console.log(chalk.yellow(` ā ļø No supported JetBrains language overlays selected (selected: ${selectedLabel})`));
|
|
1944
|
+
}
|
|
1945
|
+
const backend = getJetBrainsBackend(languageOverlays);
|
|
1946
|
+
// Add customizations.jetbrains block to devcontainer.json
|
|
1947
|
+
if (!config.customizations) {
|
|
1948
|
+
config.customizations = {};
|
|
1949
|
+
}
|
|
1950
|
+
config.customizations.jetbrains = { backend };
|
|
1951
|
+
console.log(chalk.dim(` š§ Editor profile 'jetbrains': Set backend to '${backend}'`));
|
|
1952
|
+
// Generate .idea/ artifacts in the project root
|
|
1953
|
+
console.log(chalk.cyan('\nš” Generating JetBrains project artifacts...'));
|
|
1954
|
+
const jetbrainsFiles = generateJetBrainsArtifacts(projectRoot, languageOverlays);
|
|
1955
|
+
for (const relPath of jetbrainsFiles) {
|
|
1956
|
+
console.log(chalk.dim(` š Created ${relPath} at project root`));
|
|
1957
|
+
}
|
|
1958
|
+
}
|
|
1959
|
+
// 11b. Apply target-specific devcontainer.json patch
|
|
1960
|
+
const targetRule = getTargetRule(activeTarget);
|
|
1961
|
+
const overlayMetadataMapForTarget = new Map(allOverlayDefs.map((o) => [o.id, o]));
|
|
1962
|
+
const targetCtx = {
|
|
1963
|
+
overlays,
|
|
1964
|
+
overlayMetadata: overlayMetadataMapForTarget,
|
|
1965
|
+
portOffset: answers.portOffset ?? 0,
|
|
1966
|
+
stack: answers.stack,
|
|
1967
|
+
outputPath,
|
|
1968
|
+
projectRoot,
|
|
1969
|
+
};
|
|
1970
|
+
const targetPatch = targetRule.devcontainerPatch(targetCtx);
|
|
1971
|
+
if (Object.keys(targetPatch).length > 0) {
|
|
1972
|
+
config = deepMerge(config, targetPatch);
|
|
1973
|
+
console.log(chalk.dim(` šÆ Applied ${activeTarget} target patch to devcontainer.json`));
|
|
1974
|
+
}
|
|
1329
1975
|
// 12. Write merged devcontainer.json
|
|
1976
|
+
// Apply parameter substitution to the config object (before JSON.stringify) so that
|
|
1977
|
+
// any JSON-special characters in parameter values are properly escaped by JSON.stringify.
|
|
1330
1978
|
const configPath = path.join(outputPath, 'devcontainer.json');
|
|
1331
|
-
|
|
1979
|
+
const finalConfig = hasResolvedParams
|
|
1980
|
+
? substituteParametersInObject(config, resolvedParams)
|
|
1981
|
+
: config;
|
|
1982
|
+
const devcontainerContent = JSON.stringify(finalConfig, null, 2) + '\n';
|
|
1983
|
+
fs.writeFileSync(configPath, devcontainerContent);
|
|
1332
1984
|
fileRegistry.addFile('devcontainer.json');
|
|
1333
1985
|
console.log(chalk.dim(` š Wrote devcontainer.json`));
|
|
1334
1986
|
// Apply custom docker-compose patch (after writing base docker-compose.yml)
|
|
@@ -1336,13 +1988,39 @@ export async function composeDevContainer(answers, overlaysDir, options = {}) {
|
|
|
1336
1988
|
applyCustomDockerComposePatch(outputPath, customPatches);
|
|
1337
1989
|
}
|
|
1338
1990
|
// 13. Generate superposition.json manifest
|
|
1339
|
-
generateManifest(outputPath, answers, overlays, autoResolved, answers.containerName || config.name);
|
|
1991
|
+
generateManifest(outputPath, answers, overlays, autoResolved, answers.containerName || config.name, activeTarget);
|
|
1340
1992
|
fileRegistry.addFile('superposition.json');
|
|
1341
1993
|
// 14. Merge .env.example files from overlays and apply glue config environment variables
|
|
1342
1994
|
const envCreated = mergeEnvExamples(outputPath, overlays, actualOverlaysDir, answers.portOffset, answers.presetGlueConfig, answers.preset);
|
|
1343
1995
|
if (envCreated) {
|
|
1344
1996
|
fileRegistry.addFile('.env.example');
|
|
1345
1997
|
}
|
|
1998
|
+
// Apply parameter substitution to .env.example
|
|
1999
|
+
// This must happen after mergeEnvExamples but before any consumer of .env reads it,
|
|
2000
|
+
// because mergeEnvExamples may have written {{cs.*}} tokens into .env.example.
|
|
2001
|
+
// We also regenerate .env (the port-offset copy) from the substituted content so that
|
|
2002
|
+
// applyPortOffsetToEnv can correctly match numeric port values that were previously
|
|
2003
|
+
// hidden behind {{cs.POSTGRES_PORT}} tokens.
|
|
2004
|
+
if (hasResolvedParams) {
|
|
2005
|
+
const envExamplePath = path.join(outputPath, '.env.example');
|
|
2006
|
+
if (fs.existsSync(envExamplePath)) {
|
|
2007
|
+
const original = fs.readFileSync(envExamplePath, 'utf8');
|
|
2008
|
+
const substituted = substituteParameters(original, resolvedParams);
|
|
2009
|
+
if (substituted !== original) {
|
|
2010
|
+
fs.writeFileSync(envExamplePath, substituted);
|
|
2011
|
+
// Regenerate .env from the substituted content when a port offset is active.
|
|
2012
|
+
// mergeEnvExamples already wrote .env from the pre-substitution content, so
|
|
2013
|
+
// the port offset was applied to unresolved tokens (e.g. {{cs.POSTGRES_PORT}})
|
|
2014
|
+
// that had no numeric value to match ā we must regenerate .env now that the
|
|
2015
|
+
// tokens have been replaced with real numeric port values.
|
|
2016
|
+
if (answers.portOffset) {
|
|
2017
|
+
const envPath = path.join(outputPath, '.env');
|
|
2018
|
+
const offsetContent = applyPortOffsetToEnv(substituted, answers.portOffset);
|
|
2019
|
+
fs.writeFileSync(envPath, offsetContent);
|
|
2020
|
+
}
|
|
2021
|
+
}
|
|
2022
|
+
}
|
|
2023
|
+
}
|
|
1346
2024
|
// Apply custom environment variables (after .env.example is created)
|
|
1347
2025
|
if (customPatches) {
|
|
1348
2026
|
const customEnvCreated = applyCustomEnvironment(outputPath, customPatches);
|
|
@@ -1351,6 +2029,7 @@ export async function composeDevContainer(answers, overlaysDir, options = {}) {
|
|
|
1351
2029
|
fileRegistry.addFile('.env.example');
|
|
1352
2030
|
}
|
|
1353
2031
|
}
|
|
2032
|
+
materializeComposeProjectEnvFile(outputPath, answers.projectEnv, answers.stack, rootEnv);
|
|
1354
2033
|
// 14b. Merge .gitignore files from overlays into project root .gitignore
|
|
1355
2034
|
// Note: .gitignore lives at the project root (parent of outputPath), not inside outputPath,
|
|
1356
2035
|
// so it is intentionally NOT added to fileRegistry (cleanupStaleFiles must not touch it).
|
|
@@ -1429,12 +2108,63 @@ export async function composeDevContainer(answers, overlaysDir, options = {}) {
|
|
|
1429
2108
|
const envLocalContent = generateEnvLocalExample(selectedOverlayMetadata, actualOverlaysDir, portOffset);
|
|
1430
2109
|
if (envLocalContent) {
|
|
1431
2110
|
const envLocalPath = path.join(outputPath, 'env.local.example');
|
|
1432
|
-
|
|
2111
|
+
const finalEnvLocalContent = hasResolvedParams
|
|
2112
|
+
? substituteParameters(envLocalContent, resolvedParams)
|
|
2113
|
+
: envLocalContent;
|
|
2114
|
+
fs.writeFileSync(envLocalPath, finalEnvLocalContent);
|
|
1433
2115
|
fileRegistry.addFile('env.local.example');
|
|
1434
2116
|
console.log(chalk.dim(` š Created env.local.example with optional overrides`));
|
|
1435
2117
|
}
|
|
2118
|
+
// 17d. Generate target-specific workspace artifacts and guidance
|
|
2119
|
+
if (activeTarget !== 'local') {
|
|
2120
|
+
console.log(chalk.cyan(`\nšÆ Generating ${activeTarget} target artifacts...`));
|
|
2121
|
+
const targetFiles = targetRule.generateFiles(targetCtx);
|
|
2122
|
+
for (const [key, content] of targetFiles) {
|
|
2123
|
+
const absPath = resolveTargetFilePath(key, outputPath, projectRoot);
|
|
2124
|
+
fs.writeFileSync(absPath, content);
|
|
2125
|
+
if (key.startsWith('../')) {
|
|
2126
|
+
// Project-root file: log but do NOT add to fileRegistry
|
|
2127
|
+
// (fileRegistry only tracks outputPath-relative files)
|
|
2128
|
+
console.log(chalk.dim(` š Created ${path.basename(absPath)} at project root`));
|
|
2129
|
+
}
|
|
2130
|
+
else {
|
|
2131
|
+
fileRegistry.addFile(key);
|
|
2132
|
+
console.log(chalk.dim(` š Created ${key} in .devcontainer/`));
|
|
2133
|
+
}
|
|
2134
|
+
}
|
|
2135
|
+
}
|
|
1436
2136
|
// 18. Clean up stale files from previous runs (preserves superposition.json and .env)
|
|
1437
2137
|
cleanupStaleFiles(outputPath, fileRegistry);
|
|
2138
|
+
// 18b. Validate that no unresolved {{cs.*}} tokens remain in any generated file.
|
|
2139
|
+
// Run unconditionally ā an overlay author could accidentally ship {{cs.*}} tokens
|
|
2140
|
+
// in files that don't have a matching parameters declaration, and we must catch those
|
|
2141
|
+
// regardless of whether any parameters were resolved in this run.
|
|
2142
|
+
// Only text-like files (not binaries) are scanned; skip missing files gracefully.
|
|
2143
|
+
const BINARY_EXTENSIONS = new Set(['.png', '.jpg', '.gif', '.ico', '.woff', '.woff2', '.ttf']);
|
|
2144
|
+
{
|
|
2145
|
+
const allGeneratedFiles = Array.from(fileRegistry.getFiles());
|
|
2146
|
+
const unresolvedByFile = {};
|
|
2147
|
+
for (const relFile of allGeneratedFiles) {
|
|
2148
|
+
const ext = path.extname(relFile).toLowerCase();
|
|
2149
|
+
if (BINARY_EXTENSIONS.has(ext))
|
|
2150
|
+
continue;
|
|
2151
|
+
const absPath = path.join(outputPath, relFile);
|
|
2152
|
+
if (!fs.existsSync(absPath))
|
|
2153
|
+
continue;
|
|
2154
|
+
const content = fs.readFileSync(absPath, 'utf8');
|
|
2155
|
+
const unresolved = findUnresolvedTokens(content);
|
|
2156
|
+
if (unresolved.length > 0) {
|
|
2157
|
+
unresolvedByFile[relFile] = [...new Set(unresolved)];
|
|
2158
|
+
}
|
|
2159
|
+
}
|
|
2160
|
+
if (Object.keys(unresolvedByFile).length > 0) {
|
|
2161
|
+
const details = Object.entries(unresolvedByFile)
|
|
2162
|
+
.map(([file, tokens]) => `${file}: ${tokens.join(', ')}`)
|
|
2163
|
+
.join('; ');
|
|
2164
|
+
throw new Error(`Unresolved {{cs.*}} parameter tokens remain in generated files: ${details}. ` +
|
|
2165
|
+
`Declare these parameters in the overlay's overlay.yml and provide values in your project file (superposition.yml).`);
|
|
2166
|
+
}
|
|
2167
|
+
}
|
|
1438
2168
|
// 19. Generate and return summary
|
|
1439
2169
|
const files = Array.from(fileRegistry.getFiles());
|
|
1440
2170
|
const services = overlaysToServices(selectedOverlayMetadata);
|
|
@@ -1491,15 +2221,14 @@ function applyPortOffsetToDevcontainer(config, offset) {
|
|
|
1491
2221
|
function mergeSetupScripts(config, overlays, outputPath, fileRegistry, overlaysDir) {
|
|
1492
2222
|
const setupScripts = [];
|
|
1493
2223
|
const verifyScripts = [];
|
|
1494
|
-
// Create scripts subfolder
|
|
1495
2224
|
const scriptsDir = path.join(outputPath, 'scripts');
|
|
1496
|
-
|
|
1497
|
-
fs.mkdirSync(scriptsDir, { recursive: true });
|
|
1498
|
-
}
|
|
1499
|
-
// Add scripts directory to registry if any scripts will be added
|
|
2225
|
+
// Only create the scripts directory (and register it) if at least one overlay needs it
|
|
1500
2226
|
const hasScripts = overlays.some((o) => fs.existsSync(path.join(overlaysDir, o, 'setup.sh')) ||
|
|
1501
2227
|
fs.existsSync(path.join(overlaysDir, o, 'verify.sh')));
|
|
1502
2228
|
if (hasScripts) {
|
|
2229
|
+
if (!fs.existsSync(scriptsDir)) {
|
|
2230
|
+
fs.mkdirSync(scriptsDir, { recursive: true });
|
|
2231
|
+
}
|
|
1503
2232
|
fileRegistry.addDirectory('scripts');
|
|
1504
2233
|
// Emit shared setup utilities so overlay scripts can source them
|
|
1505
2234
|
const setupUtilsSrc = path.join(TEMPLATES_DIR, 'scripts', 'setup-utils.sh');
|
|
@@ -1627,7 +2356,11 @@ function mergeRunServices(config, overlays, overlaysDir) {
|
|
|
1627
2356
|
if (fs.existsSync(overlayPath)) {
|
|
1628
2357
|
const overlayConfig = loadJson(overlayPath);
|
|
1629
2358
|
if (overlayConfig.runServices) {
|
|
1630
|
-
const
|
|
2359
|
+
const manifestPath = path.join(overlaysDir, overlay, 'overlay.yml');
|
|
2360
|
+
const manifest = fs.existsSync(manifestPath)
|
|
2361
|
+
? yaml.load(fs.readFileSync(manifestPath, 'utf8'))
|
|
2362
|
+
: null;
|
|
2363
|
+
const order = manifest?.serviceOrder ?? 0;
|
|
1631
2364
|
for (const service of overlayConfig.runServices) {
|
|
1632
2365
|
services.push({ name: service, order });
|
|
1633
2366
|
}
|