container-superposition 0.1.6 ā 0.1.8
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 -1534
- 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.d.ts.map +1 -1
- package/dist/tool/commands/adopt.js +1 -27
- package/dist/tool/commands/adopt.js.map +1 -1
- package/dist/tool/commands/doctor.d.ts +3 -0
- package/dist/tool/commands/doctor.d.ts.map +1 -1
- package/dist/tool/commands/doctor.js +1068 -70
- 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 +18 -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 +3 -3
- package/dist/tool/questionnaire/composer.d.ts.map +1 -1
- package/dist/tool/questionnaire/composer.js +902 -37
- 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 +164 -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 +580 -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 +25 -0
- package/dist/tool/schema/overlay-loader.js.map +1 -1
- package/dist/tool/schema/project-config.d.ts +14 -2
- package/dist/tool/schema/project-config.d.ts.map +1 -1
- package/dist/tool/schema/project-config.js +277 -34
- 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 +123 -12
- package/dist/tool/schema/types.d.ts.map +1 -1
- package/dist/tool/utils/merge.d.ts.map +1 -1
- package/dist/tool/utils/merge.js +9 -0
- package/dist/tool/utils/merge.js.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/creating-overlays.md +151 -2
- 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 +202 -101
- package/docs/overlays.md +162 -34
- package/docs/quick-reference.md +99 -0
- package/docs/specs/003-mkdocs2-overlay/spec.md +114 -0
- package/docs/specs/004-doctor-fix/spec.md +70 -0
- package/docs/specs/005-cuda-overlay/spec.md +101 -0
- package/docs/specs/006-rocm-overlay/spec.md +109 -0
- package/docs/specs/007-init-project-file/spec.md +66 -0
- package/docs/specs/007-target-aware-generation/spec.md +126 -0
- package/docs/specs/008-project-file-canonical/spec.md +83 -0
- package/docs/specs/009-project-env/spec.md +147 -0
- package/docs/specs/010-compose-env-materialization/spec.md +130 -0
- package/docs/specs/011-overlay-parameters/spec.md +235 -0
- package/overlays/.shared/README.md +105 -21
- package/overlays/.shared/compose/common-healthchecks.md +60 -0
- package/overlays/.shared/compose/nvidia-gpu-devcontainer.yml +22 -0
- package/overlays/.shared/vscode/recommended-extensions.json +15 -11
- package/overlays/alertmanager/setup.sh +4 -19
- package/overlays/alertmanager/verify.sh +8 -9
- package/overlays/all/README.md +43 -0
- package/overlays/all/devcontainer.patch.json +6 -0
- package/overlays/all/overlay.yml +14 -0
- package/overlays/amp/setup.sh +5 -0
- package/overlays/bun/setup.sh +10 -1
- package/overlays/bun/verify.sh +6 -1
- package/overlays/claude-code/setup.sh +5 -0
- package/overlays/cloudflared/setup.sh +9 -12
- package/overlays/codex/README.md +9 -6
- package/overlays/codex/devcontainer.patch.json +7 -1
- package/overlays/codex/setup.sh +5 -0
- package/overlays/codex/verify.sh +8 -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 +39 -0
- package/overlays/comfyui/overlay.yml +20 -0
- package/overlays/comfyui/setup.sh +36 -0
- package/overlays/comfyui/verify.sh +103 -0
- package/overlays/commitlint/setup.sh +5 -0
- package/overlays/cuda/README.md +179 -0
- package/overlays/cuda/devcontainer.patch.json +7 -0
- package/overlays/cuda/overlay.yml +17 -0
- package/overlays/cuda/setup.sh +32 -0
- package/overlays/cuda/verify.sh +38 -0
- package/overlays/devcontainer-cli/README.md +50 -0
- package/overlays/devcontainer-cli/devcontainer.patch.json +13 -0
- package/overlays/devcontainer-cli/overlay.yml +16 -0
- package/overlays/devcontainer-cli/setup.sh +14 -0
- package/overlays/direnv/devcontainer.patch.json +6 -0
- package/overlays/direnv/setup.sh +7 -6
- package/overlays/dotnet/setup.sh +14 -7
- package/overlays/duckdb/devcontainer.patch.json +1 -2
- package/overlays/gcloud/devcontainer.patch.json +0 -6
- package/overlays/gcloud/setup.sh +51 -0
- package/overlays/gemini-cli/setup.sh +5 -0
- package/overlays/git-helpers/devcontainer.patch.json +2 -1
- package/overlays/go/setup.sh +15 -14
- package/overlays/jaeger/overlay.yml +2 -0
- package/overlays/just/setup.sh +5 -17
- 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/docker-compose.yml +6 -4
- package/overlays/keycloak/verify.sh +4 -3
- package/overlays/kind/devcontainer.patch.json +1 -2
- package/overlays/kind/setup.sh +8 -17
- package/overlays/minio/setup.sh +10 -18
- package/overlays/mkdocs/overlay.yml +2 -1
- package/overlays/mkdocs2/README.md +135 -0
- package/overlays/mkdocs2/devcontainer.patch.json +19 -0
- package/overlays/mkdocs2/overlay.yml +17 -0
- package/overlays/mkdocs2/setup.sh +67 -0
- package/overlays/mkdocs2/verify.sh +35 -0
- package/overlays/modern-cli-tools/devcontainer.patch.json +7 -1
- package/overlays/modern-cli-tools/setup.sh +21 -71
- package/overlays/mongodb/devcontainer.patch.json +0 -6
- package/overlays/mongodb/setup.sh +59 -0
- package/overlays/mysql/verify.sh +4 -3
- package/overlays/nats/.env.example +1 -1
- package/overlays/nats/README.md +1 -1
- package/overlays/nats/docker-compose.yml +1 -1
- package/overlays/ngrok/setup.sh +9 -6
- package/overlays/nodejs/setup.sh +5 -0
- package/overlays/ollama/.env.example +14 -0
- package/overlays/ollama/README.md +325 -0
- package/overlays/ollama/devcontainer.patch.json +14 -0
- package/overlays/ollama/docker-compose.yml +24 -0
- package/overlays/ollama/overlay.yml +22 -0
- package/overlays/ollama/setup.sh +106 -0
- package/overlays/ollama/verify.sh +99 -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 +23 -0
- package/overlays/open-webui/overlay.yml +38 -0
- package/overlays/openapi-tools/devcontainer.patch.json +1 -2
- package/overlays/openapi-tools/setup.sh +9 -8
- package/overlays/opencode/setup.sh +5 -0
- package/overlays/otel-collector/overlay.yml +2 -0
- package/overlays/otel-collector/setup.sh +3 -16
- package/overlays/otel-demo-nodejs/verify.sh +8 -9
- package/overlays/otel-demo-python/verify.sh +16 -10
- package/overlays/pandoc/README.md +22 -15
- package/overlays/pandoc/devcontainer.patch.json +6 -2
- package/overlays/pandoc/setup.sh +217 -18
- package/overlays/pandoc/verify.sh +16 -4
- package/overlays/pgvector/.env.example +6 -0
- package/overlays/pgvector/README.md +215 -0
- package/overlays/pgvector/devcontainer.patch.json +23 -0
- package/overlays/pgvector/docker-compose.yml +32 -0
- package/overlays/pgvector/overlay.yml +44 -0
- package/overlays/playwright/devcontainer.patch.json +3 -1
- package/overlays/playwright/setup.sh +37 -0
- package/overlays/postgres/.env.example +5 -5
- package/overlays/postgres/devcontainer.patch.json +4 -4
- package/overlays/postgres/docker-compose.yml +15 -5
- package/overlays/postgres/overlay.yml +19 -1
- package/overlays/powershell/setup.sh +49 -13
- package/overlays/pre-commit/setup.sh +12 -3
- package/overlays/prometheus/overlay.yml +2 -0
- package/overlays/promtail/verify.sh +16 -10
- package/overlays/pulumi/devcontainer.patch.json +1 -1
- package/overlays/python/setup.sh +28 -9
- package/overlays/python/verify.sh +4 -2
- 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 +25 -0
- package/overlays/qdrant/overlay.yml +40 -0
- package/overlays/redpanda/docker-compose.yml +3 -5
- package/overlays/rocm/README.md +227 -0
- package/overlays/rocm/devcontainer.patch.json +4 -0
- package/overlays/rocm/overlay.yml +17 -0
- package/overlays/rocm/setup.sh +45 -0
- package/overlays/rocm/verify.sh +47 -0
- package/overlays/rust/setup.sh +11 -18
- 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/spec-kit/setup.sh +7 -3
- package/overlays/sqlite/setup.sh +14 -14
- package/overlays/sqlserver/docker-compose.yml +3 -3
- package/overlays/sqlserver/verify.sh +22 -5
- package/overlays/tempo/verify.sh +16 -10
- package/overlays/tilt/devcontainer.patch.json +1 -2
- package/overlays/tilt/setup.sh +14 -4
- package/overlays/windsurf-cli/setup.sh +27 -4
- package/overlays/windsurf-cli/verify.sh +13 -3
- package/package.json +4 -2
- package/templates/scripts/setup-utils.sh +228 -0
- package/tool/schema/config.schema.json +141 -9
- package/tool/schema/overlay-manifest.schema.json +38 -0
- package/overlays/.shared/compose/common-healthchecks.yml +0 -38
- /package/overlays/otel-demo-nodejs/{Dockerfile-otel-demo-nodejs ā Dockerfile} +0 -0
- /package/overlays/otel-demo-nodejs/{package-otel-demo-nodejs.json ā package.json} +0 -0
- /package/overlays/otel-demo-nodejs/{server-otel-demo-nodejs.js ā server.js} +0 -0
- /package/overlays/otel-demo-nodejs/{tracing-otel-demo-nodejs.js ā tracing.js} +0 -0
- /package/overlays/otel-demo-python/{Dockerfile-otel-demo-python ā Dockerfile} +0 -0
- /package/overlays/otel-demo-python/{app-otel-demo-python.py ā app.py} +0 -0
- /package/overlays/otel-demo-python/{requirements-otel-demo-python.txt ā requirements.txt} +0 -0
|
@@ -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
|
*/
|
|
@@ -82,6 +373,8 @@ function loadJson(filePath) {
|
|
|
82
373
|
function getAllOverlayDefs(config) {
|
|
83
374
|
return config.overlays;
|
|
84
375
|
}
|
|
376
|
+
/** ID for the meta overlay that expands to all available overlays */
|
|
377
|
+
const META_OVERLAY_ID = 'all';
|
|
85
378
|
/**
|
|
86
379
|
* Resolve dependencies for a set of overlays
|
|
87
380
|
* Returns the expanded list with dependencies and metadata about what was added
|
|
@@ -89,11 +382,22 @@ function getAllOverlayDefs(config) {
|
|
|
89
382
|
function resolveDependencies(requestedOverlays, allOverlayDefs) {
|
|
90
383
|
const overlayMap = new Map();
|
|
91
384
|
allOverlayDefs.forEach((def) => overlayMap.set(def.id, def));
|
|
92
|
-
|
|
385
|
+
// Expand the meta overlay to all known non-meta overlay IDs
|
|
386
|
+
let expandedRequest = requestedOverlays;
|
|
387
|
+
if (requestedOverlays.includes(META_OVERLAY_ID)) {
|
|
388
|
+
const allIds = allOverlayDefs
|
|
389
|
+
.filter((def) => def.id !== META_OVERLAY_ID && def.category !== 'preset' && !def.hidden)
|
|
390
|
+
.map((def) => def.id);
|
|
391
|
+
expandedRequest = [
|
|
392
|
+
...requestedOverlays.filter((id) => id !== META_OVERLAY_ID),
|
|
393
|
+
...allIds.filter((id) => !requestedOverlays.includes(id)),
|
|
394
|
+
];
|
|
395
|
+
}
|
|
396
|
+
const resolved = new Set(expandedRequest);
|
|
93
397
|
const autoAdded = [];
|
|
94
398
|
const resolutionReasons = [];
|
|
95
399
|
// Resolve dependencies recursively
|
|
96
|
-
const toProcess = [...
|
|
400
|
+
const toProcess = [...expandedRequest];
|
|
97
401
|
const processed = new Set();
|
|
98
402
|
while (toProcess.length > 0) {
|
|
99
403
|
const current = toProcess.shift();
|
|
@@ -236,7 +540,7 @@ function prepareOverlaysForGeneration(answers, overlaysDir) {
|
|
|
236
540
|
/**
|
|
237
541
|
* Generate superposition.json manifest
|
|
238
542
|
*/
|
|
239
|
-
function generateManifest(outputPath, answers, overlays, autoResolved, containerName) {
|
|
543
|
+
function generateManifest(outputPath, answers, overlays, autoResolved, containerName, effectiveTarget) {
|
|
240
544
|
const toolVersion = getToolVersion();
|
|
241
545
|
const manifest = {
|
|
242
546
|
manifestVersion: CURRENT_MANIFEST_VERSION,
|
|
@@ -252,7 +556,14 @@ function generateManifest(outputPath, answers, overlays, autoResolved, container
|
|
|
252
556
|
preset: answers.preset,
|
|
253
557
|
presetChoices: answers.presetChoices,
|
|
254
558
|
containerName,
|
|
559
|
+
target: effectiveTarget ?? answers.target ?? 'local',
|
|
255
560
|
};
|
|
561
|
+
if (answers.minimal) {
|
|
562
|
+
manifest.minimal = true;
|
|
563
|
+
}
|
|
564
|
+
if (answers.editor && answers.editor !== 'vscode') {
|
|
565
|
+
manifest.editor = answers.editor;
|
|
566
|
+
}
|
|
256
567
|
if (autoResolved.added.length > 0) {
|
|
257
568
|
manifest.autoResolved = autoResolved;
|
|
258
569
|
}
|
|
@@ -275,6 +586,23 @@ function generateManifest(outputPath, answers, overlays, autoResolved, container
|
|
|
275
586
|
console.log(chalk.cyan(` ā¹ļø Used preset: ${answers.preset}`));
|
|
276
587
|
}
|
|
277
588
|
}
|
|
589
|
+
/**
|
|
590
|
+
* Validate that an import path is within the allowed .shared/ directory (path traversal prevention).
|
|
591
|
+
* Returns an error message if invalid, or null if the path is safe.
|
|
592
|
+
*/
|
|
593
|
+
function validateImportPath(importPath, overlaysDir) {
|
|
594
|
+
// FR-006: All imports must start with '.shared/'
|
|
595
|
+
if (!importPath.startsWith('.shared/')) {
|
|
596
|
+
return `Import path must begin with '.shared/': ${importPath}`;
|
|
597
|
+
}
|
|
598
|
+
// Normalize both the resolved path and the allowed base to detect traversal
|
|
599
|
+
const sharedBase = path.resolve(overlaysDir, '.shared');
|
|
600
|
+
const resolved = path.resolve(overlaysDir, importPath);
|
|
601
|
+
if (!resolved.startsWith(sharedBase + path.sep) && resolved !== sharedBase) {
|
|
602
|
+
return `Import path resolves outside '.shared/' directory (path traversal rejected): ${importPath}`;
|
|
603
|
+
}
|
|
604
|
+
return null;
|
|
605
|
+
}
|
|
278
606
|
/**
|
|
279
607
|
* Load and resolve imports from shared files for an overlay
|
|
280
608
|
*/
|
|
@@ -294,23 +622,30 @@ function loadImportsForOverlay(overlayName, overlaysDir) {
|
|
|
294
622
|
manifest.imports.length === 0) {
|
|
295
623
|
return importedConfig;
|
|
296
624
|
}
|
|
297
|
-
// Process each import
|
|
625
|
+
// Process each import in declaration order
|
|
298
626
|
for (const importPath of manifest.imports) {
|
|
627
|
+
// FR-006: Reject path traversal attempts
|
|
628
|
+
const traversalError = validateImportPath(importPath, overlaysDir);
|
|
629
|
+
if (traversalError) {
|
|
630
|
+
throw new Error(`Path traversal rejected in overlay '${overlayName}': ${traversalError}`);
|
|
631
|
+
}
|
|
299
632
|
const fullImportPath = path.join(overlaysDir, importPath);
|
|
300
633
|
if (!fs.existsSync(fullImportPath)) {
|
|
301
|
-
|
|
302
|
-
|
|
634
|
+
// FR-007: Missing imports are errors, not warnings
|
|
635
|
+
throw new Error(`Import not found: '${importPath}' (referenced by overlay: ${overlayName})`);
|
|
303
636
|
}
|
|
304
637
|
// Determine file type and merge appropriately
|
|
305
638
|
const ext = path.extname(importPath).toLowerCase();
|
|
306
639
|
if (ext === '.json') {
|
|
307
640
|
// JSON files are merged as devcontainer patches
|
|
641
|
+
console.log(chalk.dim(` š Applying shared import: ${importPath}`));
|
|
308
642
|
const importedPatch = loadJson(fullImportPath);
|
|
309
643
|
importedConfig = deepMerge(importedConfig, importedPatch);
|
|
310
644
|
}
|
|
311
645
|
else if (ext === '.yaml' || ext === '.yml') {
|
|
312
646
|
// YAML files are loaded and merged as devcontainer patches
|
|
313
647
|
try {
|
|
648
|
+
console.log(chalk.dim(` š Applying shared import: ${importPath}`));
|
|
314
649
|
const yamlContent = fs.readFileSync(fullImportPath, 'utf8');
|
|
315
650
|
const importedPatch = yaml.load(yamlContent);
|
|
316
651
|
if (importedPatch && typeof importedPatch === 'object') {
|
|
@@ -318,13 +653,25 @@ function loadImportsForOverlay(overlayName, overlaysDir) {
|
|
|
318
653
|
}
|
|
319
654
|
}
|
|
320
655
|
catch (error) {
|
|
321
|
-
|
|
656
|
+
throw new Error(`Failed to parse YAML import '${importPath}' (overlay: ${overlayName}): ${error instanceof Error ? error.message : String(error)}`);
|
|
322
657
|
}
|
|
323
658
|
}
|
|
324
|
-
|
|
659
|
+
else if (ext === '.env') {
|
|
660
|
+
// .env files are handled separately during env merging ā skip here
|
|
661
|
+
console.log(chalk.dim(` š Shared .env import noted: ${importPath}`));
|
|
662
|
+
}
|
|
663
|
+
else {
|
|
664
|
+
// FR-007: Unsupported file types are errors
|
|
665
|
+
throw new Error(`Unsupported import type '${ext}' for '${importPath}' (overlay: ${overlayName}). Supported types: .json, .yaml, .yml, .env`);
|
|
666
|
+
}
|
|
325
667
|
}
|
|
326
668
|
}
|
|
327
669
|
catch (error) {
|
|
670
|
+
if (error instanceof Error) {
|
|
671
|
+
// Fail fast on any error while loading imports so configuration issues are not silently ignored
|
|
672
|
+
throw error;
|
|
673
|
+
}
|
|
674
|
+
// Non-Error throwables are unexpected; log a warning but continue
|
|
328
675
|
console.warn(chalk.yellow(`ā ļø Failed to load imports for overlay: ${overlayName}`));
|
|
329
676
|
}
|
|
330
677
|
return importedConfig;
|
|
@@ -492,6 +839,164 @@ function copyOverlayFiles(outputPath, overlayName, registry, overlaysDir) {
|
|
|
492
839
|
console.log(chalk.dim(` š Copied ${copiedFiles} file(s) from ${chalk.cyan(overlayName)}`));
|
|
493
840
|
}
|
|
494
841
|
}
|
|
842
|
+
const PROJECT_ENV_REFERENCE_PATTERN = /\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-[^}]*)?\}/g;
|
|
843
|
+
function parseSimpleEnvFile(content) {
|
|
844
|
+
const env = {};
|
|
845
|
+
for (const line of content.split('\n')) {
|
|
846
|
+
const trimmed = line.trim();
|
|
847
|
+
if (!trimmed || trimmed.startsWith('#')) {
|
|
848
|
+
continue;
|
|
849
|
+
}
|
|
850
|
+
const match = trimmed.match(/^([^=]+)=(.*)$/);
|
|
851
|
+
if (!match) {
|
|
852
|
+
continue;
|
|
853
|
+
}
|
|
854
|
+
env[match[1].trim()] = match[2].trim();
|
|
855
|
+
}
|
|
856
|
+
return env;
|
|
857
|
+
}
|
|
858
|
+
function loadEnvFileIfExists(filePath) {
|
|
859
|
+
if (!fs.existsSync(filePath)) {
|
|
860
|
+
return {};
|
|
861
|
+
}
|
|
862
|
+
return parseSimpleEnvFile(fs.readFileSync(filePath, 'utf8'));
|
|
863
|
+
}
|
|
864
|
+
function resolveProjectEnvTarget(entry, stack) {
|
|
865
|
+
const target = entry.target ?? 'auto';
|
|
866
|
+
if (target === 'remoteEnv') {
|
|
867
|
+
return 'remoteEnv';
|
|
868
|
+
}
|
|
869
|
+
if (target === 'composeEnv') {
|
|
870
|
+
if (stack !== 'compose') {
|
|
871
|
+
throw new Error('Project env target "composeEnv" requires stack: compose because no docker-compose.yml is generated for plain stacks');
|
|
872
|
+
}
|
|
873
|
+
return 'composeEnv';
|
|
874
|
+
}
|
|
875
|
+
return stack === 'compose' ? 'composeEnv' : 'remoteEnv';
|
|
876
|
+
}
|
|
877
|
+
function resolveRootEnvReferences(value, rootEnv) {
|
|
878
|
+
return value.replace(PROJECT_ENV_REFERENCE_PATTERN, (match, name) => {
|
|
879
|
+
if (rootEnv[name] !== undefined) {
|
|
880
|
+
return rootEnv[name];
|
|
881
|
+
}
|
|
882
|
+
const defaultMatch = match.match(/^\$\{[A-Za-z_][A-Za-z0-9_]*:-([^}]*)\}$/);
|
|
883
|
+
return defaultMatch ? defaultMatch[1] : match;
|
|
884
|
+
});
|
|
885
|
+
}
|
|
886
|
+
function hasUnresolvedProjectEnvReference(value) {
|
|
887
|
+
PROJECT_ENV_REFERENCE_PATTERN.lastIndex = 0;
|
|
888
|
+
const result = PROJECT_ENV_REFERENCE_PATTERN.test(value);
|
|
889
|
+
PROJECT_ENV_REFERENCE_PATTERN.lastIndex = 0;
|
|
890
|
+
return result;
|
|
891
|
+
}
|
|
892
|
+
function buildResolvedProjectRemoteEnvEntries(projectEnv, stack, rootEnv) {
|
|
893
|
+
const entries = {};
|
|
894
|
+
for (const [key, entry] of Object.entries(projectEnv ?? {})) {
|
|
895
|
+
if (resolveProjectEnvTarget(entry, stack) !== 'remoteEnv') {
|
|
896
|
+
continue;
|
|
897
|
+
}
|
|
898
|
+
entries[key] = resolveRootEnvReferences(entry.value, rootEnv ?? {});
|
|
899
|
+
}
|
|
900
|
+
return entries;
|
|
901
|
+
}
|
|
902
|
+
function buildComposeProjectEnvInterpolationEntries(projectEnv, stack) {
|
|
903
|
+
const entries = {};
|
|
904
|
+
for (const [key, entry] of Object.entries(projectEnv ?? {})) {
|
|
905
|
+
if (resolveProjectEnvTarget(entry, stack) !== 'composeEnv') {
|
|
906
|
+
continue;
|
|
907
|
+
}
|
|
908
|
+
entries[key] = `\${${key}}`;
|
|
909
|
+
}
|
|
910
|
+
return entries;
|
|
911
|
+
}
|
|
912
|
+
function buildComposeProjectRemoteEnvRefs(projectEnv, stack) {
|
|
913
|
+
const entries = {};
|
|
914
|
+
for (const [key, entry] of Object.entries(projectEnv ?? {})) {
|
|
915
|
+
if (resolveProjectEnvTarget(entry, stack) !== 'composeEnv') {
|
|
916
|
+
continue;
|
|
917
|
+
}
|
|
918
|
+
entries[key] = `\${containerEnv:${key}}`;
|
|
919
|
+
}
|
|
920
|
+
return entries;
|
|
921
|
+
}
|
|
922
|
+
function materializeComposeProjectEnvValues(projectEnv, stack, rootEnv) {
|
|
923
|
+
const entries = {};
|
|
924
|
+
for (const [key, entry] of Object.entries(projectEnv ?? {})) {
|
|
925
|
+
if (resolveProjectEnvTarget(entry, stack) !== 'composeEnv') {
|
|
926
|
+
continue;
|
|
927
|
+
}
|
|
928
|
+
const resolvedValue = resolveRootEnvReferences(entry.value, rootEnv);
|
|
929
|
+
// Leave unresolved variables to shell/docker-compose fallback instead of
|
|
930
|
+
// persisting placeholder syntax into .devcontainer/.env.
|
|
931
|
+
if (hasUnresolvedProjectEnvReference(resolvedValue)) {
|
|
932
|
+
continue;
|
|
933
|
+
}
|
|
934
|
+
entries[key] = resolvedValue;
|
|
935
|
+
}
|
|
936
|
+
return entries;
|
|
937
|
+
}
|
|
938
|
+
function applyProjectEnvToDevcontainer(config, projectEnv, stack, rootEnv) {
|
|
939
|
+
const remoteEnv = {
|
|
940
|
+
...buildResolvedProjectRemoteEnvEntries(projectEnv, stack, rootEnv),
|
|
941
|
+
...buildComposeProjectRemoteEnvRefs(projectEnv, stack),
|
|
942
|
+
};
|
|
943
|
+
if (Object.keys(remoteEnv).length === 0) {
|
|
944
|
+
return config;
|
|
945
|
+
}
|
|
946
|
+
console.log(chalk.dim(` š± Applying project env to remoteEnv`));
|
|
947
|
+
return deepMerge(config, { remoteEnv });
|
|
948
|
+
}
|
|
949
|
+
function mergeComposeEnvFile(outputPath, entries) {
|
|
950
|
+
if (Object.keys(entries).length === 0) {
|
|
951
|
+
return false;
|
|
952
|
+
}
|
|
953
|
+
const envPath = path.join(outputPath, '.env');
|
|
954
|
+
const originalContent = fs.existsSync(envPath) ? fs.readFileSync(envPath, 'utf8') : '';
|
|
955
|
+
const lines = originalContent === '' ? [] : originalContent.replace(/\n$/, '').split('\n');
|
|
956
|
+
const indexByKey = new Map();
|
|
957
|
+
lines.forEach((line, index) => {
|
|
958
|
+
const match = line.match(/^([^#=\s][^=]*)=(.*)$/);
|
|
959
|
+
if (match) {
|
|
960
|
+
indexByKey.set(match[1].trim(), index);
|
|
961
|
+
}
|
|
962
|
+
});
|
|
963
|
+
let changed = false;
|
|
964
|
+
let insertedSpacer = false;
|
|
965
|
+
for (const [key, value] of Object.entries(entries)) {
|
|
966
|
+
const rendered = `${key}=${value}`;
|
|
967
|
+
const existingIndex = indexByKey.get(key);
|
|
968
|
+
if (existingIndex !== undefined) {
|
|
969
|
+
if (lines[existingIndex] !== rendered) {
|
|
970
|
+
lines[existingIndex] = rendered;
|
|
971
|
+
changed = true;
|
|
972
|
+
}
|
|
973
|
+
continue;
|
|
974
|
+
}
|
|
975
|
+
if (lines.length > 0 && !insertedSpacer && lines[lines.length - 1] !== '') {
|
|
976
|
+
lines.push('');
|
|
977
|
+
insertedSpacer = true;
|
|
978
|
+
}
|
|
979
|
+
lines.push(rendered);
|
|
980
|
+
indexByKey.set(key, lines.length - 1);
|
|
981
|
+
changed = true;
|
|
982
|
+
}
|
|
983
|
+
if (!changed && originalContent !== '') {
|
|
984
|
+
return false;
|
|
985
|
+
}
|
|
986
|
+
fs.writeFileSync(envPath, `${lines.join('\n')}\n`);
|
|
987
|
+
return true;
|
|
988
|
+
}
|
|
989
|
+
function materializeComposeProjectEnvFile(outputPath, projectEnv, stack, rootEnv) {
|
|
990
|
+
if (stack !== 'compose') {
|
|
991
|
+
return false;
|
|
992
|
+
}
|
|
993
|
+
const materializedEntries = materializeComposeProjectEnvValues(projectEnv, stack, rootEnv);
|
|
994
|
+
if (!mergeComposeEnvFile(outputPath, materializedEntries)) {
|
|
995
|
+
return false;
|
|
996
|
+
}
|
|
997
|
+
console.log(chalk.dim(` š Materialized ${Object.keys(materializedEntries).length} project env value(s) into .devcontainer/.env for docker-compose`));
|
|
998
|
+
return true;
|
|
999
|
+
}
|
|
495
1000
|
/**
|
|
496
1001
|
* Merge .env.example files from all selected overlays
|
|
497
1002
|
*/
|
|
@@ -510,21 +1015,33 @@ function mergeEnvExamples(outputPath, overlays, overlaysDir, portOffset, glueCon
|
|
|
510
1015
|
const manifest = yaml.load(manifestContent);
|
|
511
1016
|
if (manifest.imports && Array.isArray(manifest.imports)) {
|
|
512
1017
|
for (const importPath of manifest.imports) {
|
|
1018
|
+
// FR-006: Reject path traversal
|
|
1019
|
+
const traversalError = validateImportPath(importPath, overlaysDir);
|
|
1020
|
+
if (traversalError) {
|
|
1021
|
+
throw new Error(`Path traversal rejected in overlay '${overlay}': ${traversalError}`);
|
|
1022
|
+
}
|
|
513
1023
|
const ext = path.extname(importPath).toLowerCase();
|
|
514
1024
|
if (ext === '.env') {
|
|
515
1025
|
const fullImportPath = path.join(overlaysDir, importPath);
|
|
516
1026
|
if (fs.existsSync(fullImportPath)) {
|
|
1027
|
+
console.log(chalk.dim(` š Merging shared .env import: ${importPath}`));
|
|
517
1028
|
const content = fs.readFileSync(fullImportPath, 'utf-8').trim();
|
|
518
1029
|
if (content) {
|
|
519
|
-
envSections.push(`#
|
|
1030
|
+
envSections.push(`# from ${importPath}\n${content}`);
|
|
520
1031
|
}
|
|
521
1032
|
}
|
|
1033
|
+
else {
|
|
1034
|
+
throw new Error(`Import not found: '${importPath}' (referenced by overlay: ${overlay})`);
|
|
1035
|
+
}
|
|
522
1036
|
}
|
|
523
1037
|
}
|
|
524
1038
|
}
|
|
525
1039
|
}
|
|
526
1040
|
catch (error) {
|
|
527
|
-
|
|
1041
|
+
if (error instanceof Error) {
|
|
1042
|
+
// Fail fast on import errors so .env import violations are not silently ignored
|
|
1043
|
+
throw error;
|
|
1044
|
+
}
|
|
528
1045
|
}
|
|
529
1046
|
}
|
|
530
1047
|
// Then add the overlay's own .env.example
|
|
@@ -641,25 +1158,166 @@ function applyGlueConfig(outputPath, glueConfig, presetName, fileRegistry) {
|
|
|
641
1158
|
}
|
|
642
1159
|
console.log('');
|
|
643
1160
|
}
|
|
1161
|
+
/**
|
|
1162
|
+
* Parse the effective host port from any docker-compose port binding format:
|
|
1163
|
+
* "8081:8081", "127.0.0.1:8081:8081", "${VAR:-8081}:8081", 8081, {published:8081}
|
|
1164
|
+
*/
|
|
1165
|
+
function parseHostPortFromBinding(binding) {
|
|
1166
|
+
if (typeof binding === 'number') {
|
|
1167
|
+
// A bare number in docker-compose ports means container port ā random host port.
|
|
1168
|
+
// There is no deterministic host port to conflict on, so treat as "no host port".
|
|
1169
|
+
return null;
|
|
1170
|
+
}
|
|
1171
|
+
if (typeof binding === 'object' && binding !== null) {
|
|
1172
|
+
const pub = binding.published;
|
|
1173
|
+
if (pub == null)
|
|
1174
|
+
return null;
|
|
1175
|
+
if (typeof pub === 'number')
|
|
1176
|
+
return pub;
|
|
1177
|
+
const m = String(pub).match(/^(\d+)$/);
|
|
1178
|
+
return m ? parseInt(m[1], 10) : null;
|
|
1179
|
+
}
|
|
1180
|
+
if (typeof binding !== 'string')
|
|
1181
|
+
return null;
|
|
1182
|
+
// "${VAR:-8081}:..." ā extract default
|
|
1183
|
+
const envMatch = binding.match(/^\$\{[^}]+:-(\d+)\}/);
|
|
1184
|
+
if (envMatch)
|
|
1185
|
+
return parseInt(envMatch[1], 10);
|
|
1186
|
+
const parts = binding.split(':');
|
|
1187
|
+
if (parts.length === 3) {
|
|
1188
|
+
const n = parseInt(parts[1], 10);
|
|
1189
|
+
return isNaN(n) ? null : n;
|
|
1190
|
+
}
|
|
1191
|
+
if (parts.length === 2) {
|
|
1192
|
+
const n = parseInt(parts[0], 10);
|
|
1193
|
+
return isNaN(n) ? null : n;
|
|
1194
|
+
}
|
|
1195
|
+
// Single segment: "8081" string ā same as bare number, random host port.
|
|
1196
|
+
return null;
|
|
1197
|
+
}
|
|
1198
|
+
/**
|
|
1199
|
+
* Replace the host port in a binding, preserving its format.
|
|
1200
|
+
*/
|
|
1201
|
+
function replaceHostPortInBinding(binding, newPort) {
|
|
1202
|
+
if (typeof binding === 'number')
|
|
1203
|
+
return `${newPort}:${binding}`;
|
|
1204
|
+
if (typeof binding === 'object' && binding !== null)
|
|
1205
|
+
return { ...binding, published: newPort };
|
|
1206
|
+
if (typeof binding !== 'string')
|
|
1207
|
+
return binding;
|
|
1208
|
+
// "${VAR:-8081}:container"
|
|
1209
|
+
const replaced = binding.replace(/^(\$\{[^:}]+:-)(\d+)(\})/, `$1${newPort}$3`);
|
|
1210
|
+
if (replaced !== binding)
|
|
1211
|
+
return replaced;
|
|
1212
|
+
const parts = binding.split(':');
|
|
1213
|
+
if (parts.length === 3)
|
|
1214
|
+
return `${parts[0]}:${newPort}:${parts[2]}`;
|
|
1215
|
+
if (parts.length === 2)
|
|
1216
|
+
return `${newPort}:${parts[1]}`;
|
|
1217
|
+
return `${newPort}:${binding}`;
|
|
1218
|
+
}
|
|
1219
|
+
/**
|
|
1220
|
+
* Detect and auto-resolve host port conflicts in a merged services map.
|
|
1221
|
+
* The first service that claims a port keeps it; later ones are bumped to the
|
|
1222
|
+
* next free port. Returns a list of remappings made (for logging).
|
|
1223
|
+
*/
|
|
1224
|
+
function resolveDockerComposePortConflicts(services) {
|
|
1225
|
+
const remappings = [];
|
|
1226
|
+
// Build port ā [serviceNames] map
|
|
1227
|
+
const portOwners = new Map();
|
|
1228
|
+
const allocatedPorts = new Set();
|
|
1229
|
+
for (const [serviceName, service] of Object.entries(services)) {
|
|
1230
|
+
if (!Array.isArray(service?.ports))
|
|
1231
|
+
continue;
|
|
1232
|
+
for (const binding of service.ports) {
|
|
1233
|
+
const p = parseHostPortFromBinding(binding);
|
|
1234
|
+
if (p == null)
|
|
1235
|
+
continue;
|
|
1236
|
+
allocatedPorts.add(p);
|
|
1237
|
+
if (!portOwners.has(p))
|
|
1238
|
+
portOwners.set(p, serviceName);
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
function nextFreePort(start) {
|
|
1242
|
+
let p = start + 1;
|
|
1243
|
+
while (allocatedPorts.has(p))
|
|
1244
|
+
p++;
|
|
1245
|
+
return p;
|
|
1246
|
+
}
|
|
1247
|
+
for (const [serviceName, service] of Object.entries(services)) {
|
|
1248
|
+
if (!Array.isArray(service?.ports))
|
|
1249
|
+
continue;
|
|
1250
|
+
const newBindings = [];
|
|
1251
|
+
for (const binding of service.ports) {
|
|
1252
|
+
const p = parseHostPortFromBinding(binding);
|
|
1253
|
+
if (p != null && portOwners.get(p) !== serviceName) {
|
|
1254
|
+
// This service lost the port; remap it
|
|
1255
|
+
const newPort = nextFreePort(p);
|
|
1256
|
+
allocatedPorts.add(newPort);
|
|
1257
|
+
portOwners.set(newPort, serviceName);
|
|
1258
|
+
remappings.push({ service: serviceName, originalPort: p, newPort });
|
|
1259
|
+
newBindings.push(replaceHostPortInBinding(binding, newPort));
|
|
1260
|
+
}
|
|
1261
|
+
else {
|
|
1262
|
+
newBindings.push(binding);
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
service.ports = newBindings;
|
|
1266
|
+
}
|
|
1267
|
+
return remappings;
|
|
1268
|
+
}
|
|
644
1269
|
/**
|
|
645
1270
|
* Merge docker-compose.yml files from base and overlays into a single file
|
|
646
1271
|
*/
|
|
647
|
-
function mergeDockerComposeFiles(outputPath, baseStack, overlays, overlaysDir, portOffset, customImage) {
|
|
1272
|
+
function mergeDockerComposeFiles(outputPath, baseStack, overlays, overlaysDir, portOffset, customImage, projectEnv) {
|
|
648
1273
|
const composeFiles = [];
|
|
649
1274
|
// Add base docker-compose if exists
|
|
650
1275
|
const baseComposePath = path.join(TEMPLATES_DIR, baseStack, '.devcontainer', 'docker-compose.yml');
|
|
651
1276
|
if (fs.existsSync(baseComposePath)) {
|
|
652
1277
|
composeFiles.push(baseComposePath);
|
|
653
1278
|
}
|
|
654
|
-
// Add overlay docker-compose files
|
|
1279
|
+
// Add overlay docker-compose files, interleaving any compose_imports before each overlay's own file
|
|
655
1280
|
for (const overlay of overlays) {
|
|
1281
|
+
// First load any compose_imports for this overlay (shared fragments applied before own file)
|
|
1282
|
+
const manifestPath = path.join(overlaysDir, overlay, 'overlay.yml');
|
|
1283
|
+
if (fs.existsSync(manifestPath)) {
|
|
1284
|
+
try {
|
|
1285
|
+
const manifestContent = fs.readFileSync(manifestPath, 'utf8');
|
|
1286
|
+
const manifest = yaml.load(manifestContent);
|
|
1287
|
+
if (manifest.compose_imports && Array.isArray(manifest.compose_imports)) {
|
|
1288
|
+
for (const importPath of manifest.compose_imports) {
|
|
1289
|
+
const traversalError = validateImportPath(importPath, overlaysDir);
|
|
1290
|
+
if (traversalError) {
|
|
1291
|
+
throw new Error(`compose_import path traversal rejected in overlay '${overlay}': ${traversalError}`);
|
|
1292
|
+
}
|
|
1293
|
+
const fullImportPath = path.join(overlaysDir, importPath);
|
|
1294
|
+
if (!fs.existsSync(fullImportPath)) {
|
|
1295
|
+
throw new Error(`compose_import not found: '${importPath}' (referenced by overlay: ${overlay})`);
|
|
1296
|
+
}
|
|
1297
|
+
const ext = path.extname(importPath).toLowerCase();
|
|
1298
|
+
if (ext !== '.yml' && ext !== '.yaml') {
|
|
1299
|
+
throw new Error(`compose_import must be a .yml or .yaml file: '${importPath}' (overlay: ${overlay})`);
|
|
1300
|
+
}
|
|
1301
|
+
console.log(chalk.dim(` š Applying shared compose fragment: ${importPath}`));
|
|
1302
|
+
composeFiles.push(fullImportPath);
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
catch (error) {
|
|
1307
|
+
if (error instanceof Error) {
|
|
1308
|
+
throw error;
|
|
1309
|
+
}
|
|
1310
|
+
// Non-Error throwables are unexpected; wrap and re-throw so compose_imports failures always fail fast
|
|
1311
|
+
throw new Error(`Unexpected error loading compose_imports for overlay '${overlay}': ${String(error)}`);
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
656
1314
|
const overlayComposePath = path.join(overlaysDir, overlay, 'docker-compose.yml');
|
|
657
1315
|
if (fs.existsSync(overlayComposePath)) {
|
|
658
1316
|
composeFiles.push(overlayComposePath);
|
|
659
1317
|
}
|
|
660
1318
|
}
|
|
661
1319
|
if (composeFiles.length === 0) {
|
|
662
|
-
return; // No docker-compose files to merge
|
|
1320
|
+
return []; // No docker-compose files to merge
|
|
663
1321
|
}
|
|
664
1322
|
// Merge all compose files
|
|
665
1323
|
let merged = {
|
|
@@ -690,6 +1348,11 @@ function mergeDockerComposeFiles(outputPath, baseStack, overlays, overlaysDir, p
|
|
|
690
1348
|
}
|
|
691
1349
|
// Ensure devcontainer service has an image
|
|
692
1350
|
if (merged.services.devcontainer) {
|
|
1351
|
+
const composeEnv = buildComposeProjectEnvInterpolationEntries(projectEnv, baseStack);
|
|
1352
|
+
if (Object.keys(composeEnv).length > 0) {
|
|
1353
|
+
merged.services.devcontainer.environment = deepMerge(merged.services.devcontainer.environment ?? {}, composeEnv);
|
|
1354
|
+
console.log(chalk.dim(` š± Applying project env to docker-compose devcontainer service`));
|
|
1355
|
+
}
|
|
693
1356
|
if (customImage) {
|
|
694
1357
|
// Apply custom base image if specified
|
|
695
1358
|
merged.services.devcontainer.image = customImage;
|
|
@@ -719,6 +1382,15 @@ function mergeDockerComposeFiles(outputPath, baseStack, overlays, overlaysDir, p
|
|
|
719
1382
|
delete merged.volumes;
|
|
720
1383
|
if (Object.keys(merged.networks).length === 0)
|
|
721
1384
|
delete merged.networks;
|
|
1385
|
+
// Auto-resolve host port conflicts across composed services
|
|
1386
|
+
const portRemappings = resolveDockerComposePortConflicts(merged.services);
|
|
1387
|
+
if (portRemappings.length > 0) {
|
|
1388
|
+
console.log(chalk.yellow('\nā ļø Host port conflicts detected and auto-resolved:'));
|
|
1389
|
+
for (const { service, originalPort, newPort } of portRemappings) {
|
|
1390
|
+
console.log(chalk.yellow(` ⢠${service}: host port ${originalPort} ā ${newPort}`));
|
|
1391
|
+
}
|
|
1392
|
+
console.log();
|
|
1393
|
+
}
|
|
722
1394
|
// Write combined docker-compose.yml
|
|
723
1395
|
const outputComposePath = path.join(outputPath, 'docker-compose.yml');
|
|
724
1396
|
const yamlContent = yaml.dump(merged, {
|
|
@@ -728,6 +1400,7 @@ function mergeDockerComposeFiles(outputPath, baseStack, overlays, overlaysDir, p
|
|
|
728
1400
|
});
|
|
729
1401
|
fs.writeFileSync(outputComposePath, yamlContent);
|
|
730
1402
|
console.log(chalk.dim(` š³ Created combined docker-compose.yml with ${serviceNames.length} service(s)`));
|
|
1403
|
+
return portRemappings;
|
|
731
1404
|
}
|
|
732
1405
|
/**
|
|
733
1406
|
* Apply custom devcontainer patch from .devcontainer/custom/
|
|
@@ -1049,17 +1722,60 @@ export async function composeDevContainer(answers, overlaysDir, options = {}) {
|
|
|
1049
1722
|
}
|
|
1050
1723
|
}
|
|
1051
1724
|
const overlays = orderedOverlays;
|
|
1052
|
-
//
|
|
1725
|
+
// 5b. Resolve overlay parameters ({{cs.KEY}} substitution)
|
|
1726
|
+
// Collect parameter declarations from all selected overlays
|
|
1727
|
+
const declaredParams = collectOverlayParameters(overlays, allOverlayDefs);
|
|
1728
|
+
const { values: resolvedParams, missingRequired, unknownSupplied, } = resolveParameters(declaredParams, answers.overlayParameters ?? {});
|
|
1729
|
+
if (missingRequired.length > 0) {
|
|
1730
|
+
throw new Error(`Missing required overlay parameters: ${missingRequired.join(', ')}. ` +
|
|
1731
|
+
`Provide values in superposition.yml under the parameters: section, ` +
|
|
1732
|
+
`or via --param KEY=VALUE on the command line.`);
|
|
1733
|
+
}
|
|
1734
|
+
if (unknownSupplied.length > 0) {
|
|
1735
|
+
console.warn(chalk.yellow(` ā ļø Unknown overlay parameters (not declared by any selected overlay): ${unknownSupplied.join(', ')}`));
|
|
1736
|
+
}
|
|
1737
|
+
const hasResolvedParams = Object.keys(resolvedParams).length > 0;
|
|
1738
|
+
// Log resolved parameter values (sensitive values are redacted)
|
|
1739
|
+
if (hasResolvedParams) {
|
|
1740
|
+
const displayValues = redactSensitiveValues(resolvedParams, declaredParams);
|
|
1741
|
+
console.log(chalk.dim(` āļø Overlay parameters:`));
|
|
1742
|
+
for (const [k, v] of Object.entries(displayValues)) {
|
|
1743
|
+
console.log(chalk.dim(` ${k}=${v}`));
|
|
1744
|
+
}
|
|
1745
|
+
}
|
|
1053
1746
|
const outputPath = path.resolve(answers.outputPath);
|
|
1747
|
+
const projectRoot = path.dirname(outputPath);
|
|
1054
1748
|
const fileRegistry = new FileRegistry();
|
|
1749
|
+
const rootEnv = loadEnvFileIfExists(path.join(projectRoot, '.env'));
|
|
1055
1750
|
if (!fs.existsSync(outputPath)) {
|
|
1056
1751
|
fs.mkdirSync(outputPath, { recursive: true });
|
|
1057
1752
|
}
|
|
1753
|
+
// 5a. Remove stale project-root artifacts from a previous target run
|
|
1754
|
+
const manifestPath_existing = path.join(outputPath, 'superposition.json');
|
|
1755
|
+
let manifestTarget;
|
|
1756
|
+
if (fs.existsSync(manifestPath_existing)) {
|
|
1757
|
+
try {
|
|
1758
|
+
const existingManifest = JSON.parse(fs.readFileSync(manifestPath_existing, 'utf-8'));
|
|
1759
|
+
manifestTarget = existingManifest.target;
|
|
1760
|
+
}
|
|
1761
|
+
catch {
|
|
1762
|
+
// If manifest is unreadable, skip stale cleanup gracefully
|
|
1763
|
+
}
|
|
1764
|
+
}
|
|
1765
|
+
// When answers.target is undefined (e.g. regen without --target), fall back to the
|
|
1766
|
+
// target recorded in the existing manifest so the correct artifacts are reproduced.
|
|
1767
|
+
const activeTarget = answers.target ?? manifestTarget ?? 'local';
|
|
1768
|
+
const previousTarget = manifestTarget ?? 'local';
|
|
1769
|
+
if (previousTarget !== activeTarget) {
|
|
1770
|
+
removeStaleTargetArtifacts(previousTarget, activeTarget, projectRoot);
|
|
1771
|
+
console.log(chalk.dim(` š§¹ Removed stale target artifacts for previous target '${previousTarget}'`));
|
|
1772
|
+
}
|
|
1058
1773
|
// 6. Apply overlays
|
|
1059
1774
|
for (const overlay of overlays) {
|
|
1060
1775
|
console.log(chalk.dim(` š§ Applying overlay: ${chalk.cyan(overlay)}`));
|
|
1061
1776
|
config = applyOverlay(config, overlay, actualOverlaysDir);
|
|
1062
1777
|
}
|
|
1778
|
+
config = applyProjectEnvToDevcontainer(config, answers.projectEnv, answers.stack, rootEnv);
|
|
1063
1779
|
// 7. Copy template files (docker-compose, scripts, etc.)
|
|
1064
1780
|
const entries = fs.readdirSync(templatePath);
|
|
1065
1781
|
for (const entry of entries) {
|
|
@@ -1096,13 +1812,25 @@ export async function composeDevContainer(answers, overlaysDir, options = {}) {
|
|
|
1096
1812
|
// 9. Merge runServices array in correct order
|
|
1097
1813
|
mergeRunServices(config, overlays, actualOverlaysDir);
|
|
1098
1814
|
// 11. Merge docker-compose files into single combined file
|
|
1815
|
+
let composePortRemappings = [];
|
|
1099
1816
|
if (answers.stack === 'compose') {
|
|
1100
1817
|
const customImage = config._customImage;
|
|
1101
|
-
mergeDockerComposeFiles(outputPath, answers.stack, overlays, actualOverlaysDir, answers.portOffset, customImage);
|
|
1818
|
+
composePortRemappings = mergeDockerComposeFiles(outputPath, answers.stack, overlays, actualOverlaysDir, answers.portOffset, customImage, answers.projectEnv);
|
|
1102
1819
|
// Update devcontainer.json to reference the combined file
|
|
1103
1820
|
if (config.dockerComposeFile) {
|
|
1104
1821
|
config.dockerComposeFile = 'docker-compose.yml';
|
|
1105
1822
|
}
|
|
1823
|
+
// Apply parameter substitution to the merged docker-compose.yml
|
|
1824
|
+
if (hasResolvedParams) {
|
|
1825
|
+
const composePath = path.join(outputPath, 'docker-compose.yml');
|
|
1826
|
+
if (fs.existsSync(composePath)) {
|
|
1827
|
+
const original = fs.readFileSync(composePath, 'utf8');
|
|
1828
|
+
const substituted = substituteParameters(original, resolvedParams);
|
|
1829
|
+
if (substituted !== original) {
|
|
1830
|
+
fs.writeFileSync(composePath, substituted);
|
|
1831
|
+
}
|
|
1832
|
+
}
|
|
1833
|
+
}
|
|
1106
1834
|
}
|
|
1107
1835
|
// Apply port offset to devcontainer.json if specified
|
|
1108
1836
|
if (answers.portOffset) {
|
|
@@ -1131,24 +1859,62 @@ export async function composeDevContainer(answers, overlaysDir, options = {}) {
|
|
|
1131
1859
|
if (answers.editor === 'none' || answers.editor === 'jetbrains') {
|
|
1132
1860
|
// Remove VS Code customizations
|
|
1133
1861
|
if (config.customizations?.vscode) {
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
}
|
|
1138
|
-
else if (answers.editor === 'jetbrains') {
|
|
1139
|
-
// For JetBrains, remove VS Code customizations (future: could add JetBrains-specific settings)
|
|
1140
|
-
delete config.customizations.vscode;
|
|
1141
|
-
console.log(chalk.dim(` šØ Editor profile 'jetbrains': Removed VS Code customizations`));
|
|
1142
|
-
}
|
|
1862
|
+
delete config.customizations.vscode;
|
|
1863
|
+
const profileLabel = answers.editor === 'none' ? 'none' : 'jetbrains';
|
|
1864
|
+
console.log(chalk.dim(` šØ Editor profile '${profileLabel}': Removed VS Code customizations`));
|
|
1143
1865
|
// Clean up empty customizations object
|
|
1144
1866
|
if (config.customizations && Object.keys(config.customizations).length === 0) {
|
|
1145
1867
|
delete config.customizations;
|
|
1146
1868
|
}
|
|
1147
1869
|
}
|
|
1148
1870
|
}
|
|
1871
|
+
// Add JetBrains-specific devcontainer.json customizations and generate .idea/ artifacts
|
|
1872
|
+
if (answers.editor === 'jetbrains') {
|
|
1873
|
+
const selectedLanguages = answers.language ?? [];
|
|
1874
|
+
const languageOverlays = selectedLanguages.filter((lang) => JETBRAINS_SUPPORTED_LANGUAGES.has(lang));
|
|
1875
|
+
if (languageOverlays.length === 0 && selectedLanguages.length > 0) {
|
|
1876
|
+
const selectedLabel = selectedLanguages.join(', ');
|
|
1877
|
+
console.log(chalk.yellow(` ā ļø No supported JetBrains language overlays selected (selected: ${selectedLabel})`));
|
|
1878
|
+
}
|
|
1879
|
+
const backend = getJetBrainsBackend(languageOverlays);
|
|
1880
|
+
// Add customizations.jetbrains block to devcontainer.json
|
|
1881
|
+
if (!config.customizations) {
|
|
1882
|
+
config.customizations = {};
|
|
1883
|
+
}
|
|
1884
|
+
config.customizations.jetbrains = { backend };
|
|
1885
|
+
console.log(chalk.dim(` š§ Editor profile 'jetbrains': Set backend to '${backend}'`));
|
|
1886
|
+
// Generate .idea/ artifacts in the project root
|
|
1887
|
+
console.log(chalk.cyan('\nš” Generating JetBrains project artifacts...'));
|
|
1888
|
+
const jetbrainsFiles = generateJetBrainsArtifacts(projectRoot, languageOverlays);
|
|
1889
|
+
for (const relPath of jetbrainsFiles) {
|
|
1890
|
+
console.log(chalk.dim(` š Created ${relPath} at project root`));
|
|
1891
|
+
}
|
|
1892
|
+
}
|
|
1893
|
+
// 11b. Apply target-specific devcontainer.json patch
|
|
1894
|
+
const targetRule = getTargetRule(activeTarget);
|
|
1895
|
+
const overlayMetadataMapForTarget = new Map(allOverlayDefs.map((o) => [o.id, o]));
|
|
1896
|
+
const targetCtx = {
|
|
1897
|
+
overlays,
|
|
1898
|
+
overlayMetadata: overlayMetadataMapForTarget,
|
|
1899
|
+
portOffset: answers.portOffset ?? 0,
|
|
1900
|
+
stack: answers.stack,
|
|
1901
|
+
outputPath,
|
|
1902
|
+
projectRoot,
|
|
1903
|
+
};
|
|
1904
|
+
const targetPatch = targetRule.devcontainerPatch(targetCtx);
|
|
1905
|
+
if (Object.keys(targetPatch).length > 0) {
|
|
1906
|
+
config = deepMerge(config, targetPatch);
|
|
1907
|
+
console.log(chalk.dim(` šÆ Applied ${activeTarget} target patch to devcontainer.json`));
|
|
1908
|
+
}
|
|
1149
1909
|
// 12. Write merged devcontainer.json
|
|
1910
|
+
// Apply parameter substitution to the config object (before JSON.stringify) so that
|
|
1911
|
+
// any JSON-special characters in parameter values are properly escaped by JSON.stringify.
|
|
1150
1912
|
const configPath = path.join(outputPath, 'devcontainer.json');
|
|
1151
|
-
|
|
1913
|
+
const finalConfig = hasResolvedParams
|
|
1914
|
+
? substituteParametersInObject(config, resolvedParams)
|
|
1915
|
+
: config;
|
|
1916
|
+
const devcontainerContent = JSON.stringify(finalConfig, null, 2) + '\n';
|
|
1917
|
+
fs.writeFileSync(configPath, devcontainerContent);
|
|
1152
1918
|
fileRegistry.addFile('devcontainer.json');
|
|
1153
1919
|
console.log(chalk.dim(` š Wrote devcontainer.json`));
|
|
1154
1920
|
// Apply custom docker-compose patch (after writing base docker-compose.yml)
|
|
@@ -1156,13 +1922,39 @@ export async function composeDevContainer(answers, overlaysDir, options = {}) {
|
|
|
1156
1922
|
applyCustomDockerComposePatch(outputPath, customPatches);
|
|
1157
1923
|
}
|
|
1158
1924
|
// 13. Generate superposition.json manifest
|
|
1159
|
-
generateManifest(outputPath, answers, overlays, autoResolved, answers.containerName || config.name);
|
|
1925
|
+
generateManifest(outputPath, answers, overlays, autoResolved, answers.containerName || config.name, activeTarget);
|
|
1160
1926
|
fileRegistry.addFile('superposition.json');
|
|
1161
1927
|
// 14. Merge .env.example files from overlays and apply glue config environment variables
|
|
1162
1928
|
const envCreated = mergeEnvExamples(outputPath, overlays, actualOverlaysDir, answers.portOffset, answers.presetGlueConfig, answers.preset);
|
|
1163
1929
|
if (envCreated) {
|
|
1164
1930
|
fileRegistry.addFile('.env.example');
|
|
1165
1931
|
}
|
|
1932
|
+
// Apply parameter substitution to .env.example
|
|
1933
|
+
// This must happen after mergeEnvExamples but before any consumer of .env reads it,
|
|
1934
|
+
// because mergeEnvExamples may have written {{cs.*}} tokens into .env.example.
|
|
1935
|
+
// We also regenerate .env (the port-offset copy) from the substituted content so that
|
|
1936
|
+
// applyPortOffsetToEnv can correctly match numeric port values that were previously
|
|
1937
|
+
// hidden behind {{cs.POSTGRES_PORT}} tokens.
|
|
1938
|
+
if (hasResolvedParams) {
|
|
1939
|
+
const envExamplePath = path.join(outputPath, '.env.example');
|
|
1940
|
+
if (fs.existsSync(envExamplePath)) {
|
|
1941
|
+
const original = fs.readFileSync(envExamplePath, 'utf8');
|
|
1942
|
+
const substituted = substituteParameters(original, resolvedParams);
|
|
1943
|
+
if (substituted !== original) {
|
|
1944
|
+
fs.writeFileSync(envExamplePath, substituted);
|
|
1945
|
+
// Regenerate .env from the substituted content when a port offset is active.
|
|
1946
|
+
// mergeEnvExamples already wrote .env from the pre-substitution content, so
|
|
1947
|
+
// the port offset was applied to unresolved tokens (e.g. {{cs.POSTGRES_PORT}})
|
|
1948
|
+
// that had no numeric value to match ā we must regenerate .env now that the
|
|
1949
|
+
// tokens have been replaced with real numeric port values.
|
|
1950
|
+
if (answers.portOffset) {
|
|
1951
|
+
const envPath = path.join(outputPath, '.env');
|
|
1952
|
+
const offsetContent = applyPortOffsetToEnv(substituted, answers.portOffset);
|
|
1953
|
+
fs.writeFileSync(envPath, offsetContent);
|
|
1954
|
+
}
|
|
1955
|
+
}
|
|
1956
|
+
}
|
|
1957
|
+
}
|
|
1166
1958
|
// Apply custom environment variables (after .env.example is created)
|
|
1167
1959
|
if (customPatches) {
|
|
1168
1960
|
const customEnvCreated = applyCustomEnvironment(outputPath, customPatches);
|
|
@@ -1171,6 +1963,7 @@ export async function composeDevContainer(answers, overlaysDir, options = {}) {
|
|
|
1171
1963
|
fileRegistry.addFile('.env.example');
|
|
1172
1964
|
}
|
|
1173
1965
|
}
|
|
1966
|
+
materializeComposeProjectEnvFile(outputPath, answers.projectEnv, answers.stack, rootEnv);
|
|
1174
1967
|
// 14b. Merge .gitignore files from overlays into project root .gitignore
|
|
1175
1968
|
// Note: .gitignore lives at the project root (parent of outputPath), not inside outputPath,
|
|
1176
1969
|
// so it is intentionally NOT added to fileRegistry (cleanupStaleFiles must not touch it).
|
|
@@ -1209,6 +2002,19 @@ export async function composeDevContainer(answers, overlaysDir, options = {}) {
|
|
|
1209
2002
|
if (shouldGeneratePortsDocumentation) {
|
|
1210
2003
|
console.log(chalk.cyan('\nš” Generating ports documentation...'));
|
|
1211
2004
|
portsDoc = generatePortsDocumentation(selectedOverlayMetadata, portOffset, envVars);
|
|
2005
|
+
// Propagate host-port conflict remappings into the generated docs so that
|
|
2006
|
+
// ports.json and services.md reflect the actual ports in docker-compose.yml.
|
|
2007
|
+
if (composePortRemappings.length > 0) {
|
|
2008
|
+
// Build a lookup map: original (post-offset) host port ā remapped host port.
|
|
2009
|
+
const remapByOriginal = new Map(composePortRemappings.map(({ originalPort, newPort }) => [originalPort, newPort]));
|
|
2010
|
+
portsDoc = {
|
|
2011
|
+
...portsDoc,
|
|
2012
|
+
ports: portsDoc.ports.map((p) => {
|
|
2013
|
+
const remapped = remapByOriginal.get(p.actualPort);
|
|
2014
|
+
return remapped !== undefined ? { ...p, actualPort: remapped } : p;
|
|
2015
|
+
}),
|
|
2016
|
+
};
|
|
2017
|
+
}
|
|
1212
2018
|
const portsPath = path.join(outputPath, 'ports.json');
|
|
1213
2019
|
fs.writeFileSync(portsPath, JSON.stringify(portsDoc, null, 2) + '\n');
|
|
1214
2020
|
fileRegistry.addFile('ports.json');
|
|
@@ -1236,12 +2042,63 @@ export async function composeDevContainer(answers, overlaysDir, options = {}) {
|
|
|
1236
2042
|
const envLocalContent = generateEnvLocalExample(selectedOverlayMetadata, actualOverlaysDir, portOffset);
|
|
1237
2043
|
if (envLocalContent) {
|
|
1238
2044
|
const envLocalPath = path.join(outputPath, 'env.local.example');
|
|
1239
|
-
|
|
2045
|
+
const finalEnvLocalContent = hasResolvedParams
|
|
2046
|
+
? substituteParameters(envLocalContent, resolvedParams)
|
|
2047
|
+
: envLocalContent;
|
|
2048
|
+
fs.writeFileSync(envLocalPath, finalEnvLocalContent);
|
|
1240
2049
|
fileRegistry.addFile('env.local.example');
|
|
1241
2050
|
console.log(chalk.dim(` š Created env.local.example with optional overrides`));
|
|
1242
2051
|
}
|
|
2052
|
+
// 17d. Generate target-specific workspace artifacts and guidance
|
|
2053
|
+
if (activeTarget !== 'local') {
|
|
2054
|
+
console.log(chalk.cyan(`\nšÆ Generating ${activeTarget} target artifacts...`));
|
|
2055
|
+
const targetFiles = targetRule.generateFiles(targetCtx);
|
|
2056
|
+
for (const [key, content] of targetFiles) {
|
|
2057
|
+
const absPath = resolveTargetFilePath(key, outputPath, projectRoot);
|
|
2058
|
+
fs.writeFileSync(absPath, content);
|
|
2059
|
+
if (key.startsWith('../')) {
|
|
2060
|
+
// Project-root file: log but do NOT add to fileRegistry
|
|
2061
|
+
// (fileRegistry only tracks outputPath-relative files)
|
|
2062
|
+
console.log(chalk.dim(` š Created ${path.basename(absPath)} at project root`));
|
|
2063
|
+
}
|
|
2064
|
+
else {
|
|
2065
|
+
fileRegistry.addFile(key);
|
|
2066
|
+
console.log(chalk.dim(` š Created ${key} in .devcontainer/`));
|
|
2067
|
+
}
|
|
2068
|
+
}
|
|
2069
|
+
}
|
|
1243
2070
|
// 18. Clean up stale files from previous runs (preserves superposition.json and .env)
|
|
1244
2071
|
cleanupStaleFiles(outputPath, fileRegistry);
|
|
2072
|
+
// 18b. Validate that no unresolved {{cs.*}} tokens remain in any generated file.
|
|
2073
|
+
// Run unconditionally ā an overlay author could accidentally ship {{cs.*}} tokens
|
|
2074
|
+
// in files that don't have a matching parameters declaration, and we must catch those
|
|
2075
|
+
// regardless of whether any parameters were resolved in this run.
|
|
2076
|
+
// Only text-like files (not binaries) are scanned; skip missing files gracefully.
|
|
2077
|
+
const BINARY_EXTENSIONS = new Set(['.png', '.jpg', '.gif', '.ico', '.woff', '.woff2', '.ttf']);
|
|
2078
|
+
{
|
|
2079
|
+
const allGeneratedFiles = Array.from(fileRegistry.getFiles());
|
|
2080
|
+
const unresolvedByFile = {};
|
|
2081
|
+
for (const relFile of allGeneratedFiles) {
|
|
2082
|
+
const ext = path.extname(relFile).toLowerCase();
|
|
2083
|
+
if (BINARY_EXTENSIONS.has(ext))
|
|
2084
|
+
continue;
|
|
2085
|
+
const absPath = path.join(outputPath, relFile);
|
|
2086
|
+
if (!fs.existsSync(absPath))
|
|
2087
|
+
continue;
|
|
2088
|
+
const content = fs.readFileSync(absPath, 'utf8');
|
|
2089
|
+
const unresolved = findUnresolvedTokens(content);
|
|
2090
|
+
if (unresolved.length > 0) {
|
|
2091
|
+
unresolvedByFile[relFile] = [...new Set(unresolved)];
|
|
2092
|
+
}
|
|
2093
|
+
}
|
|
2094
|
+
if (Object.keys(unresolvedByFile).length > 0) {
|
|
2095
|
+
const details = Object.entries(unresolvedByFile)
|
|
2096
|
+
.map(([file, tokens]) => `${file}: ${tokens.join(', ')}`)
|
|
2097
|
+
.join('; ');
|
|
2098
|
+
throw new Error(`Unresolved {{cs.*}} parameter tokens remain in generated files: ${details}. ` +
|
|
2099
|
+
`Declare these parameters in the overlay's overlay.yml and provide values in your project file (superposition.yml).`);
|
|
2100
|
+
}
|
|
2101
|
+
}
|
|
1245
2102
|
// 19. Generate and return summary
|
|
1246
2103
|
const files = Array.from(fileRegistry.getFiles());
|
|
1247
2104
|
const services = overlaysToServices(selectedOverlayMetadata);
|
|
@@ -1308,6 +2165,14 @@ function mergeSetupScripts(config, overlays, outputPath, fileRegistry, overlaysD
|
|
|
1308
2165
|
fs.existsSync(path.join(overlaysDir, o, 'verify.sh')));
|
|
1309
2166
|
if (hasScripts) {
|
|
1310
2167
|
fileRegistry.addDirectory('scripts');
|
|
2168
|
+
// Emit shared setup utilities so overlay scripts can source them
|
|
2169
|
+
const setupUtilsSrc = path.join(TEMPLATES_DIR, 'scripts', 'setup-utils.sh');
|
|
2170
|
+
if (fs.existsSync(setupUtilsSrc)) {
|
|
2171
|
+
const setupUtilsDest = path.join(scriptsDir, 'setup-utils.sh');
|
|
2172
|
+
fs.copyFileSync(setupUtilsSrc, setupUtilsDest);
|
|
2173
|
+
fs.chmodSync(setupUtilsDest, 0o755);
|
|
2174
|
+
fileRegistry.addFile('scripts/setup-utils.sh');
|
|
2175
|
+
}
|
|
1311
2176
|
}
|
|
1312
2177
|
for (const overlay of overlays) {
|
|
1313
2178
|
// Handle setup scripts
|