@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.
@@ -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
- const svc = this._services.get(serviceName);
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
- for (const [index, apiName] of apiNames.entries()){
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
- if (compose) {
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
- COPY ${projectDir} ./${projectDir}
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
- const svc = this._services.get(serviceName);
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
- for (const [index, apiName] of apiNames.entries()){
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
- if (compose) {
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
- COPY ${projectDir} ./${projectDir}
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.92",
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.92",
26
- "@decohere/nexus": "0.0.1-dev.92",
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
  }