@decohere/scaffold 0.0.1-dev.92 → 0.0.1-dev.93
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/commonjs/src/models/compose.model.js +111 -1
- package/commonjs/src/models/dev-container.model.js +53 -6
- package/commonjs/src/models/docker-image.model.js +24 -4
- package/commonjs/src/models/gitignore.model.js +6 -0
- package/commonjs/src/webapi/create-api.js +28 -8
- package/commonjs/src/webapi/features/database.feature.js +2 -1
- package/commonjs/src/webapi/models/production-dockerfile.model.js +5 -3
- package/es6/src/models/compose.model.js +113 -1
- package/es6/src/models/dev-container.model.js +53 -6
- package/es6/src/models/docker-image.model.js +24 -4
- package/es6/src/models/gitignore.model.js +6 -0
- package/es6/src/webapi/create-api.js +29 -9
- package/es6/src/webapi/features/database.feature.js +2 -1
- package/es6/src/webapi/models/production-dockerfile.model.js +5 -3
- package/package.json +5 -4
- package/types/src/models/compose.model.d.ts +9 -0
- package/types/src/models/dev-container.model.d.ts +8 -0
- package/types/src/models/docker-image.model.d.ts +6 -0
- package/types/src/models/gitignore.model.d.ts +2 -0
|
@@ -8,6 +8,8 @@ Object.defineProperty(exports, "DockerCompose", {
|
|
|
8
8
|
return DockerCompose;
|
|
9
9
|
}
|
|
10
10
|
});
|
|
11
|
+
const _nodefs = require("node:fs");
|
|
12
|
+
const _yaml = require("yaml");
|
|
11
13
|
const _const = require("./const.js");
|
|
12
14
|
class DockerCompose {
|
|
13
15
|
key = _const.WELL_KNOWN_FILES.DOCKER_COMPOSE;
|
|
@@ -16,6 +18,19 @@ class DockerCompose {
|
|
|
16
18
|
_services = new Map();
|
|
17
19
|
_volumes = new Set();
|
|
18
20
|
_networks = new Map();
|
|
21
|
+
_envFiles = [];
|
|
22
|
+
_existing;
|
|
23
|
+
/** Load an existing compose file so `decohere add api` can merge into it. */ static fromFile(absolutePath) {
|
|
24
|
+
const compose = new DockerCompose();
|
|
25
|
+
compose._existing = (0, _yaml.parseDocument)((0, _nodefs.readFileSync)(absolutePath, "utf8"));
|
|
26
|
+
return compose;
|
|
27
|
+
}
|
|
28
|
+
/** True when the service is defined (in the loaded file or added since). */ hasService(name) {
|
|
29
|
+
return this._services.has(name) || this._existing?.hasIn([
|
|
30
|
+
"services",
|
|
31
|
+
name
|
|
32
|
+
]) === true;
|
|
33
|
+
}
|
|
19
34
|
/** Compose project name (`name:` top-level) — isolates containers/volumes. */ setProjectName(name) {
|
|
20
35
|
this._projectName = name;
|
|
21
36
|
return this;
|
|
@@ -45,14 +60,23 @@ class DockerCompose {
|
|
|
45
60
|
return this;
|
|
46
61
|
}
|
|
47
62
|
/** Append an env_file path to an existing service. */ addEnvFile(serviceName, filePath) {
|
|
48
|
-
|
|
63
|
+
// A service already in the loaded file wins over an `addService` of the same name.
|
|
64
|
+
const svc = this._existing?.hasIn([
|
|
65
|
+
"services",
|
|
66
|
+
serviceName
|
|
67
|
+
]) ? undefined : this._services.get(serviceName);
|
|
49
68
|
if (svc) svc.env_file = [
|
|
50
69
|
...svc.env_file ?? [],
|
|
51
70
|
filePath
|
|
52
71
|
];
|
|
72
|
+
else this._envFiles.push([
|
|
73
|
+
serviceName,
|
|
74
|
+
filePath
|
|
75
|
+
]);
|
|
53
76
|
return this;
|
|
54
77
|
}
|
|
55
78
|
getContent() {
|
|
79
|
+
if (this._existing) return this.mergeIntoExisting(this._existing);
|
|
56
80
|
const lines = [];
|
|
57
81
|
for (const comment of this._headerComments){
|
|
58
82
|
lines.push(comment.startsWith("#") ? comment : `# ${comment}`);
|
|
@@ -145,4 +169,90 @@ class DockerCompose {
|
|
|
145
169
|
}
|
|
146
170
|
return `${lines.join("\n")}\n`;
|
|
147
171
|
}
|
|
172
|
+
mergeIntoExisting(doc) {
|
|
173
|
+
if (!(0, _yaml.isMap)(doc.contents)) throw new Error("docker-compose.yml is not a YAML mapping");
|
|
174
|
+
for (const line of this._headerComments){
|
|
175
|
+
const text = line.replace(/^#\s?/, "");
|
|
176
|
+
const before = doc.commentBefore ?? "";
|
|
177
|
+
if (!before.includes(text)) doc.commentBefore = before ? `${before}\n ${text}` : ` ${text}`;
|
|
178
|
+
}
|
|
179
|
+
for (const [name, svc] of this._services){
|
|
180
|
+
if (!doc.hasIn([
|
|
181
|
+
"services",
|
|
182
|
+
name
|
|
183
|
+
])) doc.setIn([
|
|
184
|
+
"services",
|
|
185
|
+
name
|
|
186
|
+
], doc.createNode(toComposeYaml(svc)));
|
|
187
|
+
}
|
|
188
|
+
for (const [service, path] of this._envFiles){
|
|
189
|
+
if (!doc.hasIn([
|
|
190
|
+
"services",
|
|
191
|
+
service
|
|
192
|
+
])) continue;
|
|
193
|
+
const envFiles = doc.getIn([
|
|
194
|
+
"services",
|
|
195
|
+
service,
|
|
196
|
+
"env_file"
|
|
197
|
+
]);
|
|
198
|
+
if ((0, _yaml.isSeq)(envFiles)) {
|
|
199
|
+
if (!envFiles.items.some((item)=>String(item) === path)) envFiles.add(path);
|
|
200
|
+
} else if (envFiles == null) {
|
|
201
|
+
doc.setIn([
|
|
202
|
+
"services",
|
|
203
|
+
service,
|
|
204
|
+
"env_file"
|
|
205
|
+
], doc.createNode([
|
|
206
|
+
path
|
|
207
|
+
]));
|
|
208
|
+
} else if (String(envFiles) !== path) {
|
|
209
|
+
doc.setIn([
|
|
210
|
+
"services",
|
|
211
|
+
service,
|
|
212
|
+
"env_file"
|
|
213
|
+
], doc.createNode([
|
|
214
|
+
String(envFiles),
|
|
215
|
+
path
|
|
216
|
+
]));
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
for (const [key, network] of this._networks){
|
|
220
|
+
if (!doc.hasIn([
|
|
221
|
+
"networks",
|
|
222
|
+
key
|
|
223
|
+
])) doc.setIn([
|
|
224
|
+
"networks",
|
|
225
|
+
key
|
|
226
|
+
], doc.createNode(network));
|
|
227
|
+
}
|
|
228
|
+
for (const volume of this._volumes){
|
|
229
|
+
if (!doc.hasIn([
|
|
230
|
+
"volumes",
|
|
231
|
+
volume
|
|
232
|
+
])) doc.setIn([
|
|
233
|
+
"volumes",
|
|
234
|
+
volume
|
|
235
|
+
], null);
|
|
236
|
+
}
|
|
237
|
+
return doc.toString({
|
|
238
|
+
lineWidth: 0
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
/** Plain compose YAML shape for a service (networks with aliases become a map). */ function toComposeYaml(svc) {
|
|
243
|
+
const { networks, ...rest } = svc;
|
|
244
|
+
if (!networks || networks.length === 0) return rest;
|
|
245
|
+
if (networks.every((n)=>typeof n === "string")) return {
|
|
246
|
+
...rest,
|
|
247
|
+
networks
|
|
248
|
+
};
|
|
249
|
+
const networkMap = {};
|
|
250
|
+
for (const network of networks){
|
|
251
|
+
if (typeof network === "string") networkMap[network] = null;
|
|
252
|
+
else Object.assign(networkMap, network);
|
|
253
|
+
}
|
|
254
|
+
return {
|
|
255
|
+
...rest,
|
|
256
|
+
networks: networkMap
|
|
257
|
+
};
|
|
148
258
|
}
|
|
@@ -8,6 +8,7 @@ Object.defineProperty(exports, "DevContainer", {
|
|
|
8
8
|
return DevContainer;
|
|
9
9
|
}
|
|
10
10
|
});
|
|
11
|
+
const _nodefs = require("node:fs");
|
|
11
12
|
const _const = require("./const.js");
|
|
12
13
|
class DevContainer {
|
|
13
14
|
key = _const.WELL_KNOWN_FILES.DEVCONTAINER_JSON;
|
|
@@ -22,6 +23,19 @@ class DevContainer {
|
|
|
22
23
|
_initializeCommand;
|
|
23
24
|
_remoteUser = "node";
|
|
24
25
|
_useCompose = false;
|
|
26
|
+
_existing;
|
|
27
|
+
/**
|
|
28
|
+
* Load an existing devcontainer.json so `decohere add api` can merge into it.
|
|
29
|
+
* Only extensions, forwarded ports and port attributes are merged; everything else is kept.
|
|
30
|
+
*/ static fromFile(absolutePath) {
|
|
31
|
+
const devContainer = new DevContainer();
|
|
32
|
+
try {
|
|
33
|
+
devContainer._existing = JSON.parse((0, _nodefs.readFileSync)(absolutePath, "utf8"));
|
|
34
|
+
} catch (error) {
|
|
35
|
+
throw new Error(`Can't update ${absolutePath}: it isn't plain JSON (${error.message}). Remove any comments or trailing commas and try again.`);
|
|
36
|
+
}
|
|
37
|
+
return devContainer;
|
|
38
|
+
}
|
|
25
39
|
constructor(){
|
|
26
40
|
this._mounts = [
|
|
27
41
|
this.bindMount(this._workspaceFolder)
|
|
@@ -74,6 +88,33 @@ class DevContainer {
|
|
|
74
88
|
return this;
|
|
75
89
|
}
|
|
76
90
|
files() {
|
|
91
|
+
const config = this._existing ? this.mergeIntoExisting(this._existing) : this.build();
|
|
92
|
+
return [
|
|
93
|
+
{
|
|
94
|
+
filePath: ".devcontainer/devcontainer.json",
|
|
95
|
+
content: JSON.stringify(config, null, 2)
|
|
96
|
+
}
|
|
97
|
+
];
|
|
98
|
+
}
|
|
99
|
+
mergeIntoExisting(existing) {
|
|
100
|
+
const config = structuredClone(existing);
|
|
101
|
+
if (this._extensions.length > 0) {
|
|
102
|
+
const customizations = objectAt(config, "customizations");
|
|
103
|
+
const vscode = objectAt(customizations, "vscode");
|
|
104
|
+
vscode.extensions = union(vscode.extensions, this._extensions);
|
|
105
|
+
}
|
|
106
|
+
if (this._ports.length > 0) {
|
|
107
|
+
config.forwardPorts = union(config.forwardPorts, this._ports);
|
|
108
|
+
}
|
|
109
|
+
if (this._portAttributes.size > 0) {
|
|
110
|
+
const attributes = objectAt(config, "portsAttributes");
|
|
111
|
+
for (const [port, attrs] of this._portAttributes){
|
|
112
|
+
attributes[String(port)] ??= attrs;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return config;
|
|
116
|
+
}
|
|
117
|
+
build() {
|
|
77
118
|
const config = {};
|
|
78
119
|
if (this._useCompose) {
|
|
79
120
|
config.dockerComposeFile = "docker-compose.yml";
|
|
@@ -115,11 +156,17 @@ class DevContainer {
|
|
|
115
156
|
if (this._remoteUser) {
|
|
116
157
|
config.remoteUser = this._remoteUser;
|
|
117
158
|
}
|
|
118
|
-
return
|
|
119
|
-
{
|
|
120
|
-
filePath: ".devcontainer/devcontainer.json",
|
|
121
|
-
content: JSON.stringify(config, null, 2)
|
|
122
|
-
}
|
|
123
|
-
];
|
|
159
|
+
return config;
|
|
124
160
|
}
|
|
125
161
|
}
|
|
162
|
+
/** The object at `parent[key]`, created when missing. */ function objectAt(parent, key) {
|
|
163
|
+
parent[key] ??= {};
|
|
164
|
+
return parent[key];
|
|
165
|
+
}
|
|
166
|
+
function union(existing, additions) {
|
|
167
|
+
const items = Array.isArray(existing) ? [
|
|
168
|
+
...existing
|
|
169
|
+
] : [];
|
|
170
|
+
for (const item of additions)if (!items.includes(item)) items.push(item);
|
|
171
|
+
return items;
|
|
172
|
+
}
|
|
@@ -46,6 +46,7 @@ _export(exports, {
|
|
|
46
46
|
return withWorkspaceNodeModulesOwnership;
|
|
47
47
|
}
|
|
48
48
|
});
|
|
49
|
+
const _nodefs = require("node:fs");
|
|
49
50
|
const _const = require("./const.js");
|
|
50
51
|
class DockerStep {
|
|
51
52
|
key;
|
|
@@ -168,10 +169,22 @@ class DockerfileModel {
|
|
|
168
169
|
_layerWriters = [];
|
|
169
170
|
_fromImage;
|
|
170
171
|
_layerCounter = 0;
|
|
172
|
+
_existing;
|
|
171
173
|
constructor(baseImage){
|
|
172
174
|
this._fromImage = baseImage;
|
|
173
175
|
this.registerBaseLayers();
|
|
174
176
|
}
|
|
177
|
+
/**
|
|
178
|
+
* Load an existing Dockerfile so `decohere add api` can append feature layers.
|
|
179
|
+
* The new steps are appended as one block, unless the file already contains it.
|
|
180
|
+
*/ static fromFile(absolutePath, filePath) {
|
|
181
|
+
const dockerfile = new DockerfileModel("");
|
|
182
|
+
dockerfile._existing = {
|
|
183
|
+
content: (0, _nodefs.readFileSync)(absolutePath, "utf8"),
|
|
184
|
+
filePath
|
|
185
|
+
};
|
|
186
|
+
return dockerfile;
|
|
187
|
+
}
|
|
175
188
|
from(baseImage) {
|
|
176
189
|
this._fromImage = baseImage;
|
|
177
190
|
return this;
|
|
@@ -207,9 +220,6 @@ class DockerfileModel {
|
|
|
207
220
|
for (const layerWriter of this._layerWriters){
|
|
208
221
|
layerWriter(this);
|
|
209
222
|
}
|
|
210
|
-
const lines = [
|
|
211
|
-
`FROM ${this._fromImage}`
|
|
212
|
-
];
|
|
213
223
|
const sortedSteps = [
|
|
214
224
|
...this._steps.values()
|
|
215
225
|
].sort((left, right)=>{
|
|
@@ -218,6 +228,16 @@ class DockerfileModel {
|
|
|
218
228
|
}
|
|
219
229
|
return left.key.localeCompare(right.key, "en");
|
|
220
230
|
});
|
|
231
|
+
if (this._existing) {
|
|
232
|
+
// All or nothing: skipping one step of a layer (e.g. its `USER root`) would break the rest.
|
|
233
|
+
const { content } = this._existing;
|
|
234
|
+
const block = sortedSteps.map((step)=>step.content).join("\n\n");
|
|
235
|
+
if (block === "" || content.includes(block)) return content;
|
|
236
|
+
return `${content.replace(/\n*$/, "\n")}\n${block}\n`;
|
|
237
|
+
}
|
|
238
|
+
const lines = [
|
|
239
|
+
`FROM ${this._fromImage}`
|
|
240
|
+
];
|
|
221
241
|
for (const step of sortedSteps){
|
|
222
242
|
lines.push("", step.content);
|
|
223
243
|
}
|
|
@@ -229,7 +249,7 @@ class DockerfileModel {
|
|
|
229
249
|
files() {
|
|
230
250
|
return [
|
|
231
251
|
{
|
|
232
|
-
filePath: "./Dockerfile",
|
|
252
|
+
filePath: this._existing?.filePath ?? "./Dockerfile",
|
|
233
253
|
content: this.build()
|
|
234
254
|
}
|
|
235
255
|
];
|
|
@@ -8,6 +8,7 @@ Object.defineProperty(exports, "Gitignore", {
|
|
|
8
8
|
return Gitignore;
|
|
9
9
|
}
|
|
10
10
|
});
|
|
11
|
+
const _nodefs = require("node:fs");
|
|
11
12
|
const _const = require("./const.js");
|
|
12
13
|
class Gitignore {
|
|
13
14
|
key = _const.WELL_KNOWN_FILES.GITIGNORE;
|
|
@@ -22,6 +23,11 @@ class Gitignore {
|
|
|
22
23
|
".DS_Store",
|
|
23
24
|
"logs"
|
|
24
25
|
];
|
|
26
|
+
/** Load an existing .gitignore so `decohere add api` appends instead of replacing. */ static fromFile(absolutePath) {
|
|
27
|
+
const gitignore = new Gitignore();
|
|
28
|
+
gitignore.ignoreList = (0, _nodefs.readFileSync)(absolutePath, "utf8").replace(/\n+$/, "").split("\n");
|
|
29
|
+
return gitignore;
|
|
30
|
+
}
|
|
25
31
|
add(...patterns) {
|
|
26
32
|
for (const pattern of patterns){
|
|
27
33
|
if (!this.ignoreList.includes(pattern)) {
|
|
@@ -100,14 +100,7 @@ function createWorkspace(targetDir, projectName, apiNames) {
|
|
|
100
100
|
}).addVolume("webapi-node-modules").addVolume(_dockerimagemodel.commandHistory.volumeName);
|
|
101
101
|
const installCmd = "bash .devcontainer/post-create.sh";
|
|
102
102
|
const devContainer = new _devcontainermodel.DevContainer().setWorkspaceFolder(workspaceRoot).addExtension("biomejs.biome", "vitest.explorer").setInitializeCommand("bash .devcontainer/initialize.sh").setPostCreateCommand(installCmd).setUpdateContentCommand(installCmd).setPostStartCommand("bash .devcontainer/post-start.sh").useCompose();
|
|
103
|
-
|
|
104
|
-
const port = (0, _const1.webapiPort)(index);
|
|
105
|
-
devContainer.forwardPort(port);
|
|
106
|
-
devContainer.setPortAttributes(port, {
|
|
107
|
-
label: apiName === _const1.DEFAULT_WEBAPI_NAME ? "API" : apiName,
|
|
108
|
-
requireLocalPort: true
|
|
109
|
-
});
|
|
110
|
-
}
|
|
103
|
+
forwardApiPorts(devContainer, apiNames, 0);
|
|
111
104
|
// Workspace-level models (repo root)
|
|
112
105
|
monoRepo.addModel(compose);
|
|
113
106
|
monoRepo.addModel(devContainer);
|
|
@@ -221,6 +214,19 @@ function createAddWorkspace(targetDir, projectName, apiNames) {
|
|
|
221
214
|
monoRepo.addModel((0, _nodefs.existsSync)((0, _nodepath.join)(targetDir, ".env")) ? _envfilemodel.EnvFile.fromFile((0, _nodepath.join)(targetDir, ".env")) : new _envfilemodel.EnvFile());
|
|
222
215
|
monoRepo.addModel((0, _nodefs.existsSync)((0, _nodepath.join)(targetDir, "config/config.yaml")) ? _configyamlmodel.ConfigYaml.fromFile(_const.WELL_KNOWN_FILES.CONFIG_YAML, "config/config.yaml", (0, _nodepath.join)(targetDir, "config/config.yaml")) : new _configyamlmodel.ConfigYaml(_const.WELL_KNOWN_FILES.CONFIG_YAML, "config/config.yaml", ""));
|
|
223
216
|
monoRepo.addModel((0, _nodefs.existsSync)((0, _nodepath.join)(targetDir, "config/config.dev.yaml")) ? _configyamlmodel.ConfigYaml.fromFile(_const.WELL_KNOWN_FILES.CONFIG_DEV_YAML, "config/config.dev.yaml", (0, _nodepath.join)(targetDir, "config/config.dev.yaml")) : new _configyamlmodel.ConfigYaml(_const.WELL_KNOWN_FILES.CONFIG_DEV_YAML, "config/config.dev.yaml", ""));
|
|
217
|
+
// Load the workspace files features merge into, so e.g. --database on a workspace
|
|
218
|
+
// created without one adds the Postgres service instead of only the db:* scripts.
|
|
219
|
+
const compose = loadIfExists(targetDir, ".devcontainer/docker-compose.yml", (path)=>_composemodel.DockerCompose.fromFile(path));
|
|
220
|
+
if (compose) monoRepo.addModel(compose);
|
|
221
|
+
const devContainer = loadIfExists(targetDir, ".devcontainer/devcontainer.json", (path)=>_devcontainermodel.DevContainer.fromFile(path));
|
|
222
|
+
if (devContainer) {
|
|
223
|
+
forwardApiPorts(devContainer, apiNames, existing.length);
|
|
224
|
+
monoRepo.addModel(devContainer);
|
|
225
|
+
}
|
|
226
|
+
const devImage = loadIfExists(targetDir, ".devcontainer/Dockerfile", (path)=>_dockerimagemodel.DockerfileModel.fromFile(path, ".devcontainer/Dockerfile"));
|
|
227
|
+
if (devImage) monoRepo.addModel(devImage);
|
|
228
|
+
const gitignore = loadIfExists(targetDir, ".gitignore", (path)=>_gitignoremodel.Gitignore.fromFile(path));
|
|
229
|
+
if (gitignore) monoRepo.addModel(gitignore);
|
|
224
230
|
for (const [offset, apiName] of apiNames.entries()){
|
|
225
231
|
const port = (0, _const1.webapiPort)(existing.length + offset);
|
|
226
232
|
pkg.addScript(`start:${apiName}`, `node ${(0, _const1.webapiReleaseEntry)(apiName)}`);
|
|
@@ -241,3 +247,17 @@ function addApiProject(monoRepo, workspaceName, apiName) {
|
|
|
241
247
|
apiProject.addFile(new _samplefilesmodel.SampleServiceModel());
|
|
242
248
|
monoRepo.addProject(apiProject);
|
|
243
249
|
}
|
|
250
|
+
function forwardApiPorts(devContainer, apiNames, firstIndex) {
|
|
251
|
+
for (const [offset, apiName] of apiNames.entries()){
|
|
252
|
+
const port = (0, _const1.webapiPort)(firstIndex + offset);
|
|
253
|
+
devContainer.forwardPort(port);
|
|
254
|
+
devContainer.setPortAttributes(port, {
|
|
255
|
+
label: apiName === _const1.DEFAULT_WEBAPI_NAME ? "API" : apiName,
|
|
256
|
+
requireLocalPort: true
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
function loadIfExists(targetDir, relativePath, load) {
|
|
261
|
+
const absolutePath = (0, _nodepath.join)(targetDir, relativePath);
|
|
262
|
+
return (0, _nodefs.existsSync)(absolutePath) ? load(absolutePath) : undefined;
|
|
263
|
+
}
|
|
@@ -289,7 +289,8 @@ function scaffold(workspace, options) {
|
|
|
289
289
|
const devContainer = workspace.getModel(_const.WELL_KNOWN_FILES.DEVCONTAINER_JSON);
|
|
290
290
|
const compose = workspace.getModel(_const.WELL_KNOWN_FILES.DOCKER_COMPOSE);
|
|
291
291
|
const networkKey = name;
|
|
292
|
-
|
|
292
|
+
// Skip when the workspace already runs Postgres (`decohere add api` on a database workspace).
|
|
293
|
+
if (compose && !compose.hasService("postgres")) {
|
|
293
294
|
// One shared instance. Each API owns a database on it (created at bootstrap).
|
|
294
295
|
// Point `{PREFIX}_DB_HOST` / `{PREFIX}_DB_PORT` at another server to split later.
|
|
295
296
|
// Custom image: sync bootstrap role password on every start (initdb.d is once-only).
|
|
@@ -81,7 +81,9 @@ COPY package.json ./
|
|
|
81
81
|
COPY pnpm-lock.yaml* ./
|
|
82
82
|
COPY tsconfig.json biome.json ./
|
|
83
83
|
COPY config ./config
|
|
84
|
-
|
|
84
|
+
# Every package under projects/ — the API imports workspace libraries (e.g. *.domain).
|
|
85
|
+
# Forge builds them all into dist/; only dist/ (not source) reaches the run image.
|
|
86
|
+
COPY projects ./projects
|
|
85
87
|
|
|
86
88
|
${renderPnpmInstall("all")}
|
|
87
89
|
|
|
@@ -136,8 +138,8 @@ RUN --mount=type=secret,id=npm_token,required=false \\
|
|
|
136
138
|
}
|
|
137
139
|
function dockerignoreContent() {
|
|
138
140
|
return [
|
|
139
|
-
"node_modules",
|
|
140
|
-
"dist",
|
|
141
|
+
"**/node_modules",
|
|
142
|
+
"**/dist",
|
|
141
143
|
".git",
|
|
142
144
|
".devcontainer",
|
|
143
145
|
".vscode",
|
|
@@ -1,8 +1,12 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { isMap, isSeq, parseDocument } from "yaml";
|
|
1
3
|
import { WELL_KNOWN_FILES } from "./const.js";
|
|
2
4
|
/**
|
|
3
5
|
* Live builder for `docker-compose.yml`.
|
|
4
6
|
*
|
|
5
7
|
* Features call `addService` / `addVolume` / `addNetwork` in their scaffold phases.
|
|
8
|
+
* Loaded with `fromFile` (`decohere add api`), additions merge into the existing file:
|
|
9
|
+
* services, networks and volumes already there are left untouched, comments kept.
|
|
6
10
|
*/ export class DockerCompose {
|
|
7
11
|
key = WELL_KNOWN_FILES.DOCKER_COMPOSE;
|
|
8
12
|
_projectName;
|
|
@@ -10,6 +14,19 @@ import { WELL_KNOWN_FILES } from "./const.js";
|
|
|
10
14
|
_services = new Map();
|
|
11
15
|
_volumes = new Set();
|
|
12
16
|
_networks = new Map();
|
|
17
|
+
_envFiles = [];
|
|
18
|
+
_existing;
|
|
19
|
+
/** Load an existing compose file so `decohere add api` can merge into it. */ static fromFile(absolutePath) {
|
|
20
|
+
const compose = new DockerCompose();
|
|
21
|
+
compose._existing = parseDocument(readFileSync(absolutePath, "utf8"));
|
|
22
|
+
return compose;
|
|
23
|
+
}
|
|
24
|
+
/** True when the service is defined (in the loaded file or added since). */ hasService(name) {
|
|
25
|
+
return this._services.has(name) || this._existing?.hasIn([
|
|
26
|
+
"services",
|
|
27
|
+
name
|
|
28
|
+
]) === true;
|
|
29
|
+
}
|
|
13
30
|
/** Compose project name (`name:` top-level) — isolates containers/volumes. */ setProjectName(name) {
|
|
14
31
|
this._projectName = name;
|
|
15
32
|
return this;
|
|
@@ -39,14 +56,23 @@ import { WELL_KNOWN_FILES } from "./const.js";
|
|
|
39
56
|
return this;
|
|
40
57
|
}
|
|
41
58
|
/** Append an env_file path to an existing service. */ addEnvFile(serviceName, filePath) {
|
|
42
|
-
|
|
59
|
+
// A service already in the loaded file wins over an `addService` of the same name.
|
|
60
|
+
const svc = this._existing?.hasIn([
|
|
61
|
+
"services",
|
|
62
|
+
serviceName
|
|
63
|
+
]) ? undefined : this._services.get(serviceName);
|
|
43
64
|
if (svc) svc.env_file = [
|
|
44
65
|
...svc.env_file ?? [],
|
|
45
66
|
filePath
|
|
46
67
|
];
|
|
68
|
+
else this._envFiles.push([
|
|
69
|
+
serviceName,
|
|
70
|
+
filePath
|
|
71
|
+
]);
|
|
47
72
|
return this;
|
|
48
73
|
}
|
|
49
74
|
getContent() {
|
|
75
|
+
if (this._existing) return this.mergeIntoExisting(this._existing);
|
|
50
76
|
const lines = [];
|
|
51
77
|
for (const comment of this._headerComments){
|
|
52
78
|
lines.push(comment.startsWith("#") ? comment : `# ${comment}`);
|
|
@@ -139,4 +165,90 @@ import { WELL_KNOWN_FILES } from "./const.js";
|
|
|
139
165
|
}
|
|
140
166
|
return `${lines.join("\n")}\n`;
|
|
141
167
|
}
|
|
168
|
+
mergeIntoExisting(doc) {
|
|
169
|
+
if (!isMap(doc.contents)) throw new Error("docker-compose.yml is not a YAML mapping");
|
|
170
|
+
for (const line of this._headerComments){
|
|
171
|
+
const text = line.replace(/^#\s?/, "");
|
|
172
|
+
const before = doc.commentBefore ?? "";
|
|
173
|
+
if (!before.includes(text)) doc.commentBefore = before ? `${before}\n ${text}` : ` ${text}`;
|
|
174
|
+
}
|
|
175
|
+
for (const [name, svc] of this._services){
|
|
176
|
+
if (!doc.hasIn([
|
|
177
|
+
"services",
|
|
178
|
+
name
|
|
179
|
+
])) doc.setIn([
|
|
180
|
+
"services",
|
|
181
|
+
name
|
|
182
|
+
], doc.createNode(toComposeYaml(svc)));
|
|
183
|
+
}
|
|
184
|
+
for (const [service, path] of this._envFiles){
|
|
185
|
+
if (!doc.hasIn([
|
|
186
|
+
"services",
|
|
187
|
+
service
|
|
188
|
+
])) continue;
|
|
189
|
+
const envFiles = doc.getIn([
|
|
190
|
+
"services",
|
|
191
|
+
service,
|
|
192
|
+
"env_file"
|
|
193
|
+
]);
|
|
194
|
+
if (isSeq(envFiles)) {
|
|
195
|
+
if (!envFiles.items.some((item)=>String(item) === path)) envFiles.add(path);
|
|
196
|
+
} else if (envFiles == null) {
|
|
197
|
+
doc.setIn([
|
|
198
|
+
"services",
|
|
199
|
+
service,
|
|
200
|
+
"env_file"
|
|
201
|
+
], doc.createNode([
|
|
202
|
+
path
|
|
203
|
+
]));
|
|
204
|
+
} else if (String(envFiles) !== path) {
|
|
205
|
+
doc.setIn([
|
|
206
|
+
"services",
|
|
207
|
+
service,
|
|
208
|
+
"env_file"
|
|
209
|
+
], doc.createNode([
|
|
210
|
+
String(envFiles),
|
|
211
|
+
path
|
|
212
|
+
]));
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
for (const [key, network] of this._networks){
|
|
216
|
+
if (!doc.hasIn([
|
|
217
|
+
"networks",
|
|
218
|
+
key
|
|
219
|
+
])) doc.setIn([
|
|
220
|
+
"networks",
|
|
221
|
+
key
|
|
222
|
+
], doc.createNode(network));
|
|
223
|
+
}
|
|
224
|
+
for (const volume of this._volumes){
|
|
225
|
+
if (!doc.hasIn([
|
|
226
|
+
"volumes",
|
|
227
|
+
volume
|
|
228
|
+
])) doc.setIn([
|
|
229
|
+
"volumes",
|
|
230
|
+
volume
|
|
231
|
+
], null);
|
|
232
|
+
}
|
|
233
|
+
return doc.toString({
|
|
234
|
+
lineWidth: 0
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
/** Plain compose YAML shape for a service (networks with aliases become a map). */ function toComposeYaml(svc) {
|
|
239
|
+
const { networks, ...rest } = svc;
|
|
240
|
+
if (!networks || networks.length === 0) return rest;
|
|
241
|
+
if (networks.every((n)=>typeof n === "string")) return {
|
|
242
|
+
...rest,
|
|
243
|
+
networks
|
|
244
|
+
};
|
|
245
|
+
const networkMap = {};
|
|
246
|
+
for (const network of networks){
|
|
247
|
+
if (typeof network === "string") networkMap[network] = null;
|
|
248
|
+
else Object.assign(networkMap, network);
|
|
249
|
+
}
|
|
250
|
+
return {
|
|
251
|
+
...rest,
|
|
252
|
+
networks: networkMap
|
|
253
|
+
};
|
|
142
254
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
1
2
|
import { WELL_KNOWN_FILES } from "./const.js";
|
|
2
3
|
export class DevContainer {
|
|
3
4
|
key = WELL_KNOWN_FILES.DEVCONTAINER_JSON;
|
|
@@ -12,6 +13,19 @@ export class DevContainer {
|
|
|
12
13
|
_initializeCommand;
|
|
13
14
|
_remoteUser = "node";
|
|
14
15
|
_useCompose = false;
|
|
16
|
+
_existing;
|
|
17
|
+
/**
|
|
18
|
+
* Load an existing devcontainer.json so `decohere add api` can merge into it.
|
|
19
|
+
* Only extensions, forwarded ports and port attributes are merged; everything else is kept.
|
|
20
|
+
*/ static fromFile(absolutePath) {
|
|
21
|
+
const devContainer = new DevContainer();
|
|
22
|
+
try {
|
|
23
|
+
devContainer._existing = JSON.parse(readFileSync(absolutePath, "utf8"));
|
|
24
|
+
} catch (error) {
|
|
25
|
+
throw new Error(`Can't update ${absolutePath}: it isn't plain JSON (${error.message}). Remove any comments or trailing commas and try again.`);
|
|
26
|
+
}
|
|
27
|
+
return devContainer;
|
|
28
|
+
}
|
|
15
29
|
constructor(){
|
|
16
30
|
this._mounts = [
|
|
17
31
|
this.bindMount(this._workspaceFolder)
|
|
@@ -64,6 +78,33 @@ export class DevContainer {
|
|
|
64
78
|
return this;
|
|
65
79
|
}
|
|
66
80
|
files() {
|
|
81
|
+
const config = this._existing ? this.mergeIntoExisting(this._existing) : this.build();
|
|
82
|
+
return [
|
|
83
|
+
{
|
|
84
|
+
filePath: ".devcontainer/devcontainer.json",
|
|
85
|
+
content: JSON.stringify(config, null, 2)
|
|
86
|
+
}
|
|
87
|
+
];
|
|
88
|
+
}
|
|
89
|
+
mergeIntoExisting(existing) {
|
|
90
|
+
const config = structuredClone(existing);
|
|
91
|
+
if (this._extensions.length > 0) {
|
|
92
|
+
const customizations = objectAt(config, "customizations");
|
|
93
|
+
const vscode = objectAt(customizations, "vscode");
|
|
94
|
+
vscode.extensions = union(vscode.extensions, this._extensions);
|
|
95
|
+
}
|
|
96
|
+
if (this._ports.length > 0) {
|
|
97
|
+
config.forwardPorts = union(config.forwardPorts, this._ports);
|
|
98
|
+
}
|
|
99
|
+
if (this._portAttributes.size > 0) {
|
|
100
|
+
const attributes = objectAt(config, "portsAttributes");
|
|
101
|
+
for (const [port, attrs] of this._portAttributes){
|
|
102
|
+
attributes[String(port)] ??= attrs;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return config;
|
|
106
|
+
}
|
|
107
|
+
build() {
|
|
67
108
|
const config = {};
|
|
68
109
|
if (this._useCompose) {
|
|
69
110
|
config.dockerComposeFile = "docker-compose.yml";
|
|
@@ -105,11 +146,17 @@ export class DevContainer {
|
|
|
105
146
|
if (this._remoteUser) {
|
|
106
147
|
config.remoteUser = this._remoteUser;
|
|
107
148
|
}
|
|
108
|
-
return
|
|
109
|
-
{
|
|
110
|
-
filePath: ".devcontainer/devcontainer.json",
|
|
111
|
-
content: JSON.stringify(config, null, 2)
|
|
112
|
-
}
|
|
113
|
-
];
|
|
149
|
+
return config;
|
|
114
150
|
}
|
|
115
151
|
}
|
|
152
|
+
/** The object at `parent[key]`, created when missing. */ function objectAt(parent, key) {
|
|
153
|
+
parent[key] ??= {};
|
|
154
|
+
return parent[key];
|
|
155
|
+
}
|
|
156
|
+
function union(existing, additions) {
|
|
157
|
+
const items = Array.isArray(existing) ? [
|
|
158
|
+
...existing
|
|
159
|
+
] : [];
|
|
160
|
+
for (const item of additions)if (!items.includes(item)) items.push(item);
|
|
161
|
+
return items;
|
|
162
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
1
2
|
import { WELL_KNOWN_FILES } from "./const.js";
|
|
2
3
|
export class DockerStep {
|
|
3
4
|
key;
|
|
@@ -123,10 +124,22 @@ export class DockerfileModel {
|
|
|
123
124
|
_layerWriters = [];
|
|
124
125
|
_fromImage;
|
|
125
126
|
_layerCounter = 0;
|
|
127
|
+
_existing;
|
|
126
128
|
constructor(baseImage){
|
|
127
129
|
this._fromImage = baseImage;
|
|
128
130
|
this.registerBaseLayers();
|
|
129
131
|
}
|
|
132
|
+
/**
|
|
133
|
+
* Load an existing Dockerfile so `decohere add api` can append feature layers.
|
|
134
|
+
* The new steps are appended as one block, unless the file already contains it.
|
|
135
|
+
*/ static fromFile(absolutePath, filePath) {
|
|
136
|
+
const dockerfile = new DockerfileModel("");
|
|
137
|
+
dockerfile._existing = {
|
|
138
|
+
content: readFileSync(absolutePath, "utf8"),
|
|
139
|
+
filePath
|
|
140
|
+
};
|
|
141
|
+
return dockerfile;
|
|
142
|
+
}
|
|
130
143
|
from(baseImage) {
|
|
131
144
|
this._fromImage = baseImage;
|
|
132
145
|
return this;
|
|
@@ -162,9 +175,6 @@ export class DockerfileModel {
|
|
|
162
175
|
for (const layerWriter of this._layerWriters){
|
|
163
176
|
layerWriter(this);
|
|
164
177
|
}
|
|
165
|
-
const lines = [
|
|
166
|
-
`FROM ${this._fromImage}`
|
|
167
|
-
];
|
|
168
178
|
const sortedSteps = [
|
|
169
179
|
...this._steps.values()
|
|
170
180
|
].sort((left, right)=>{
|
|
@@ -173,6 +183,16 @@ export class DockerfileModel {
|
|
|
173
183
|
}
|
|
174
184
|
return left.key.localeCompare(right.key, "en");
|
|
175
185
|
});
|
|
186
|
+
if (this._existing) {
|
|
187
|
+
// All or nothing: skipping one step of a layer (e.g. its `USER root`) would break the rest.
|
|
188
|
+
const { content } = this._existing;
|
|
189
|
+
const block = sortedSteps.map((step)=>step.content).join("\n\n");
|
|
190
|
+
if (block === "" || content.includes(block)) return content;
|
|
191
|
+
return `${content.replace(/\n*$/, "\n")}\n${block}\n`;
|
|
192
|
+
}
|
|
193
|
+
const lines = [
|
|
194
|
+
`FROM ${this._fromImage}`
|
|
195
|
+
];
|
|
176
196
|
for (const step of sortedSteps){
|
|
177
197
|
lines.push("", step.content);
|
|
178
198
|
}
|
|
@@ -184,7 +204,7 @@ export class DockerfileModel {
|
|
|
184
204
|
files() {
|
|
185
205
|
return [
|
|
186
206
|
{
|
|
187
|
-
filePath: "./Dockerfile",
|
|
207
|
+
filePath: this._existing?.filePath ?? "./Dockerfile",
|
|
188
208
|
content: this.build()
|
|
189
209
|
}
|
|
190
210
|
];
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
1
2
|
import { WELL_KNOWN_FILES } from "./const.js";
|
|
2
3
|
export class Gitignore {
|
|
3
4
|
key = WELL_KNOWN_FILES.GITIGNORE;
|
|
@@ -12,6 +13,11 @@ export class Gitignore {
|
|
|
12
13
|
".DS_Store",
|
|
13
14
|
"logs"
|
|
14
15
|
];
|
|
16
|
+
/** Load an existing .gitignore so `decohere add api` appends instead of replacing. */ static fromFile(absolutePath) {
|
|
17
|
+
const gitignore = new Gitignore();
|
|
18
|
+
gitignore.ignoreList = readFileSync(absolutePath, "utf8").replace(/\n+$/, "").split("\n");
|
|
19
|
+
return gitignore;
|
|
20
|
+
}
|
|
15
21
|
add(...patterns) {
|
|
16
22
|
for (const pattern of patterns){
|
|
17
23
|
if (!this.ignoreList.includes(pattern)) {
|
|
@@ -4,7 +4,7 @@ import { DockerCompose } from "../models/compose.model.js";
|
|
|
4
4
|
import { ConfigYaml } from "../models/config-yaml.model.js";
|
|
5
5
|
import { WELL_KNOWN_FILES } from "../models/const.js";
|
|
6
6
|
import { DevContainer } from "../models/dev-container.model.js";
|
|
7
|
-
import { commandHistory, withWorkspaceNodeModulesOwnership } from "../models/docker-image.model.js";
|
|
7
|
+
import { commandHistory, DockerfileModel, withWorkspaceNodeModulesOwnership } from "../models/docker-image.model.js";
|
|
8
8
|
import { EnvFile } from "../models/env-file.model.js";
|
|
9
9
|
import { Gitignore } from "../models/gitignore.model.js";
|
|
10
10
|
import { LaunchJson } from "../models/launch-json.model.js";
|
|
@@ -80,14 +80,7 @@ function createWorkspace(targetDir, projectName, apiNames) {
|
|
|
80
80
|
}).addVolume("webapi-node-modules").addVolume(commandHistory.volumeName);
|
|
81
81
|
const installCmd = "bash .devcontainer/post-create.sh";
|
|
82
82
|
const devContainer = new DevContainer().setWorkspaceFolder(workspaceRoot).addExtension("biomejs.biome", "vitest.explorer").setInitializeCommand("bash .devcontainer/initialize.sh").setPostCreateCommand(installCmd).setUpdateContentCommand(installCmd).setPostStartCommand("bash .devcontainer/post-start.sh").useCompose();
|
|
83
|
-
|
|
84
|
-
const port = webapiPort(index);
|
|
85
|
-
devContainer.forwardPort(port);
|
|
86
|
-
devContainer.setPortAttributes(port, {
|
|
87
|
-
label: apiName === DEFAULT_WEBAPI_NAME ? "API" : apiName,
|
|
88
|
-
requireLocalPort: true
|
|
89
|
-
});
|
|
90
|
-
}
|
|
83
|
+
forwardApiPorts(devContainer, apiNames, 0);
|
|
91
84
|
// Workspace-level models (repo root)
|
|
92
85
|
monoRepo.addModel(compose);
|
|
93
86
|
monoRepo.addModel(devContainer);
|
|
@@ -201,6 +194,19 @@ function createAddWorkspace(targetDir, projectName, apiNames) {
|
|
|
201
194
|
monoRepo.addModel(existsSync(join(targetDir, ".env")) ? EnvFile.fromFile(join(targetDir, ".env")) : new EnvFile());
|
|
202
195
|
monoRepo.addModel(existsSync(join(targetDir, "config/config.yaml")) ? ConfigYaml.fromFile(WELL_KNOWN_FILES.CONFIG_YAML, "config/config.yaml", join(targetDir, "config/config.yaml")) : new ConfigYaml(WELL_KNOWN_FILES.CONFIG_YAML, "config/config.yaml", ""));
|
|
203
196
|
monoRepo.addModel(existsSync(join(targetDir, "config/config.dev.yaml")) ? ConfigYaml.fromFile(WELL_KNOWN_FILES.CONFIG_DEV_YAML, "config/config.dev.yaml", join(targetDir, "config/config.dev.yaml")) : new ConfigYaml(WELL_KNOWN_FILES.CONFIG_DEV_YAML, "config/config.dev.yaml", ""));
|
|
197
|
+
// Load the workspace files features merge into, so e.g. --database on a workspace
|
|
198
|
+
// created without one adds the Postgres service instead of only the db:* scripts.
|
|
199
|
+
const compose = loadIfExists(targetDir, ".devcontainer/docker-compose.yml", (path)=>DockerCompose.fromFile(path));
|
|
200
|
+
if (compose) monoRepo.addModel(compose);
|
|
201
|
+
const devContainer = loadIfExists(targetDir, ".devcontainer/devcontainer.json", (path)=>DevContainer.fromFile(path));
|
|
202
|
+
if (devContainer) {
|
|
203
|
+
forwardApiPorts(devContainer, apiNames, existing.length);
|
|
204
|
+
monoRepo.addModel(devContainer);
|
|
205
|
+
}
|
|
206
|
+
const devImage = loadIfExists(targetDir, ".devcontainer/Dockerfile", (path)=>DockerfileModel.fromFile(path, ".devcontainer/Dockerfile"));
|
|
207
|
+
if (devImage) monoRepo.addModel(devImage);
|
|
208
|
+
const gitignore = loadIfExists(targetDir, ".gitignore", (path)=>Gitignore.fromFile(path));
|
|
209
|
+
if (gitignore) monoRepo.addModel(gitignore);
|
|
204
210
|
for (const [offset, apiName] of apiNames.entries()){
|
|
205
211
|
const port = webapiPort(existing.length + offset);
|
|
206
212
|
pkg.addScript(`start:${apiName}`, `node ${webapiReleaseEntry(apiName)}`);
|
|
@@ -221,3 +227,17 @@ function addApiProject(monoRepo, workspaceName, apiName) {
|
|
|
221
227
|
apiProject.addFile(new SampleServiceModel());
|
|
222
228
|
monoRepo.addProject(apiProject);
|
|
223
229
|
}
|
|
230
|
+
function forwardApiPorts(devContainer, apiNames, firstIndex) {
|
|
231
|
+
for (const [offset, apiName] of apiNames.entries()){
|
|
232
|
+
const port = webapiPort(firstIndex + offset);
|
|
233
|
+
devContainer.forwardPort(port);
|
|
234
|
+
devContainer.setPortAttributes(port, {
|
|
235
|
+
label: apiName === DEFAULT_WEBAPI_NAME ? "API" : apiName,
|
|
236
|
+
requireLocalPort: true
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
function loadIfExists(targetDir, relativePath, load) {
|
|
241
|
+
const absolutePath = join(targetDir, relativePath);
|
|
242
|
+
return existsSync(absolutePath) ? load(absolutePath) : undefined;
|
|
243
|
+
}
|
|
@@ -279,7 +279,8 @@ function scaffold(workspace, options) {
|
|
|
279
279
|
const devContainer = workspace.getModel(WELL_KNOWN_FILES.DEVCONTAINER_JSON);
|
|
280
280
|
const compose = workspace.getModel(WELL_KNOWN_FILES.DOCKER_COMPOSE);
|
|
281
281
|
const networkKey = name;
|
|
282
|
-
|
|
282
|
+
// Skip when the workspace already runs Postgres (`decohere add api` on a database workspace).
|
|
283
|
+
if (compose && !compose.hasService("postgres")) {
|
|
283
284
|
// One shared instance. Each API owns a database on it (created at bootstrap).
|
|
284
285
|
// Point `{PREFIX}_DB_HOST` / `{PREFIX}_DB_PORT` at another server to split later.
|
|
285
286
|
// Custom image: sync bootstrap role password on every start (initdb.d is once-only).
|
|
@@ -72,7 +72,9 @@ COPY package.json ./
|
|
|
72
72
|
COPY pnpm-lock.yaml* ./
|
|
73
73
|
COPY tsconfig.json biome.json ./
|
|
74
74
|
COPY config ./config
|
|
75
|
-
|
|
75
|
+
# Every package under projects/ — the API imports workspace libraries (e.g. *.domain).
|
|
76
|
+
# Forge builds them all into dist/; only dist/ (not source) reaches the run image.
|
|
77
|
+
COPY projects ./projects
|
|
76
78
|
|
|
77
79
|
${renderPnpmInstall("all")}
|
|
78
80
|
|
|
@@ -127,8 +129,8 @@ RUN --mount=type=secret,id=npm_token,required=false \\
|
|
|
127
129
|
}
|
|
128
130
|
function dockerignoreContent() {
|
|
129
131
|
return [
|
|
130
|
-
"node_modules",
|
|
131
|
-
"dist",
|
|
132
|
+
"**/node_modules",
|
|
133
|
+
"**/dist",
|
|
132
134
|
".git",
|
|
133
135
|
".devcontainer",
|
|
134
136
|
".vscode",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decohere/scaffold",
|
|
3
|
-
"version": "0.0.1-dev.
|
|
3
|
+
"version": "0.0.1-dev.93",
|
|
4
4
|
"description": "Decohere project scaffolder (runs inside ghcr.io/typesmiths/decohere/create)",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"decohere",
|
|
@@ -22,10 +22,11 @@
|
|
|
22
22
|
"test": "echo \"Error: no test specified\" && exit 1"
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
|
25
|
-
"@decohere/crucible": "0.0.1-dev.
|
|
26
|
-
"@decohere/nexus": "0.0.1-dev.
|
|
25
|
+
"@decohere/crucible": "0.0.1-dev.93",
|
|
26
|
+
"@decohere/nexus": "0.0.1-dev.93",
|
|
27
27
|
"commander": "^13.1.0",
|
|
28
|
-
"openapi-to-postmanv2": "^5.0.0"
|
|
28
|
+
"openapi-to-postmanv2": "^5.0.0",
|
|
29
|
+
"yaml": "^2.9.1"
|
|
29
30
|
},
|
|
30
31
|
"devDependencies": {},
|
|
31
32
|
"decohere": {
|
|
@@ -39,6 +39,8 @@ export type ComposeNetwork = {
|
|
|
39
39
|
* Live builder for `docker-compose.yml`.
|
|
40
40
|
*
|
|
41
41
|
* Features call `addService` / `addVolume` / `addNetwork` in their scaffold phases.
|
|
42
|
+
* Loaded with `fromFile` (`decohere add api`), additions merge into the existing file:
|
|
43
|
+
* services, networks and volumes already there are left untouched, comments kept.
|
|
42
44
|
*/
|
|
43
45
|
export declare class DockerCompose implements KeyedModel {
|
|
44
46
|
readonly key: string;
|
|
@@ -47,6 +49,12 @@ export declare class DockerCompose implements KeyedModel {
|
|
|
47
49
|
private readonly _services;
|
|
48
50
|
private readonly _volumes;
|
|
49
51
|
private readonly _networks;
|
|
52
|
+
private readonly _envFiles;
|
|
53
|
+
private _existing;
|
|
54
|
+
/** Load an existing compose file so `decohere add api` can merge into it. */
|
|
55
|
+
static fromFile(absolutePath: string): DockerCompose;
|
|
56
|
+
/** True when the service is defined (in the loaded file or added since). */
|
|
57
|
+
hasService(name: string): boolean;
|
|
50
58
|
/** Compose project name (`name:` top-level) — isolates containers/volumes. */
|
|
51
59
|
setProjectName(name: string): this;
|
|
52
60
|
/** File-level `#` comments rendered above `name:` / `services:`. */
|
|
@@ -61,4 +69,5 @@ export declare class DockerCompose implements KeyedModel {
|
|
|
61
69
|
/** Append an env_file path to an existing service. */
|
|
62
70
|
addEnvFile(serviceName: string, filePath: string): this;
|
|
63
71
|
getContent(): string;
|
|
72
|
+
private mergeIntoExisting;
|
|
64
73
|
}
|
|
@@ -24,6 +24,12 @@ export declare class DevContainer implements KeyedModel {
|
|
|
24
24
|
private _initializeCommand?;
|
|
25
25
|
private _remoteUser;
|
|
26
26
|
private _useCompose;
|
|
27
|
+
private _existing;
|
|
28
|
+
/**
|
|
29
|
+
* Load an existing devcontainer.json so `decohere add api` can merge into it.
|
|
30
|
+
* Only extensions, forwarded ports and port attributes are merged; everything else is kept.
|
|
31
|
+
*/
|
|
32
|
+
static fromFile(absolutePath: string): DevContainer;
|
|
27
33
|
constructor();
|
|
28
34
|
/** In-container path Cursor/VS Code opens (Agents window uses the basename). */
|
|
29
35
|
setWorkspaceFolder(folder: string): this;
|
|
@@ -38,4 +44,6 @@ export declare class DevContainer implements KeyedModel {
|
|
|
38
44
|
setRemoteUser(user: string): this;
|
|
39
45
|
useCompose(): this;
|
|
40
46
|
files(): SourceFile[];
|
|
47
|
+
private mergeIntoExisting;
|
|
48
|
+
private build;
|
|
41
49
|
}
|
|
@@ -59,7 +59,13 @@ export declare class DockerfileModel implements KeyedModel {
|
|
|
59
59
|
private readonly _layerWriters;
|
|
60
60
|
private _fromImage;
|
|
61
61
|
private _layerCounter;
|
|
62
|
+
private _existing;
|
|
62
63
|
constructor(baseImage: string);
|
|
64
|
+
/**
|
|
65
|
+
* Load an existing Dockerfile so `decohere add api` can append feature layers.
|
|
66
|
+
* The new steps are appended as one block, unless the file already contains it.
|
|
67
|
+
*/
|
|
68
|
+
static fromFile(absolutePath: string, filePath: string): DockerfileModel;
|
|
63
69
|
from(baseImage: string): this;
|
|
64
70
|
addLayer(layer: LayerWriter | string, order?: 5000): this;
|
|
65
71
|
addStep(step: DockerStep): this;
|
|
@@ -2,6 +2,8 @@ import type { KeyedModel, SourceFile } from "./source-file.js";
|
|
|
2
2
|
export declare class Gitignore implements KeyedModel {
|
|
3
3
|
readonly key: string;
|
|
4
4
|
private ignoreList;
|
|
5
|
+
/** Load an existing .gitignore so `decohere add api` appends instead of replacing. */
|
|
6
|
+
static fromFile(absolutePath: string): Gitignore;
|
|
5
7
|
add(...patterns: string[]): this;
|
|
6
8
|
files(): SourceFile[];
|
|
7
9
|
}
|