@getmonoceros/workbench 1.24.1 → 1.25.0

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.
@@ -23,12 +23,12 @@ service:
23
23
  retries: 5
24
24
  vscodeExtensions: [cweijan.vscode-database-client2]
25
25
  connectionEnv:
26
- MYSQL_HOST: ${host}
27
- MYSQL_PORT: ${port}
28
- MYSQL_USER: root
29
- MYSQL_PASSWORD: ${MYSQL_ROOT_PASSWORD}
30
- MYSQL_DATABASE: ${MYSQL_DATABASE}
31
- DATABASE_URL: mysql://root:${MYSQL_ROOT_PASSWORD}@${host}:${port}/${MYSQL_DATABASE}
26
+ URL: mysql://root:${MYSQL_ROOT_PASSWORD}@${host}:${port}/${MYSQL_DATABASE}
27
+ HOST: ${host}
28
+ PORT: ${port}
29
+ USER: root
30
+ PASSWORD: ${MYSQL_ROOT_PASSWORD}
31
+ DB: ${MYSQL_DATABASE}
32
32
  options:
33
33
  MYSQL_ROOT_PASSWORD:
34
34
  type: string
@@ -13,12 +13,12 @@ service:
13
13
  retries: 5
14
14
  vscodeExtensions: [cweijan.vscode-database-client2]
15
15
  connectionEnv:
16
- PGHOST: ${host}
17
- PGPORT: ${port}
18
- PGUSER: ${POSTGRES_USER}
19
- PGPASSWORD: ${POSTGRES_PASSWORD}
20
- PGDATABASE: ${POSTGRES_DB}
21
- DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${host}:${port}/${POSTGRES_DB}
16
+ URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${host}:${port}/${POSTGRES_DB}
17
+ HOST: ${host}
18
+ PORT: ${port}
19
+ USER: ${POSTGRES_USER}
20
+ PASSWORD: ${POSTGRES_PASSWORD}
21
+ DB: ${POSTGRES_DB}
22
22
  options:
23
23
  POSTGRES_USER:
24
24
  type: string
@@ -13,4 +13,6 @@ service:
13
13
  retries: 5
14
14
  vscodeExtensions: [cweijan.vscode-database-client2]
15
15
  connectionEnv:
16
- REDIS_URL: redis://${host}:${port}
16
+ URL: redis://${host}:${port}
17
+ HOST: ${host}
18
+ PORT: ${port}
package/dist/bin.js CHANGED
@@ -203,7 +203,13 @@ var init_schema = __esm({
203
203
  ).optional(),
204
204
  healthcheck: ServiceHealthcheckSchema.optional(),
205
205
  restart: z.enum(SERVICE_RESTART_VALUES).optional(),
206
- command: z.string().optional()
206
+ command: z.string().optional(),
207
+ // Connection-env templates keyed by logical SUFFIX (URL/HOST/PORT/USER/
208
+ // PASSWORD/DB). Baked in by `expandCuratedService`; emitted into the
209
+ // workspace as `<UPPER(name)>_<SUFFIX>` at apply (ADR 0021). `${host}`/
210
+ // `${port}`/`${<OPTION>}` are render tokens, NOT `.env` placeholders, and
211
+ // are left untouched by `interpolateServices`.
212
+ connectionEnv: z.record(z.string(), z.string()).optional()
207
213
  });
208
214
  ExternalServicesSchema = z.object({
209
215
  postgres: z.string().regex(
@@ -261,6 +267,18 @@ var init_schema = __esm({
261
267
  git: z.object({
262
268
  user: GitUserSchema.optional()
263
269
  }).optional()
270
+ }).superRefine((cfg, ctx) => {
271
+ const seen = /* @__PURE__ */ new Set();
272
+ cfg.services.forEach((svc, i) => {
273
+ if (seen.has(svc.name)) {
274
+ ctx.addIssue({
275
+ code: z.ZodIssueCode.custom,
276
+ path: ["services", i, "name"],
277
+ message: `Duplicate service name '${svc.name}'. Service names must be unique (use \`add-service \u2026 --as <name>\` to add a second instance of the same image).`
278
+ });
279
+ }
280
+ seen.add(svc.name);
281
+ });
264
282
  });
265
283
  }
266
284
  });
@@ -711,13 +729,15 @@ var init_descriptor = __esm({
711
729
  dataMount: z2.string().optional(),
712
730
  healthcheck: HealthcheckSchema.optional(),
713
731
  /**
714
- * Connection env vars injected into the WORKSPACE container so the app /
715
- * agent can reach this service without hardcoding anything. Keyed by env
716
- * var name a template value. Tokens: `${host}` (the service hostname),
717
- * `${port}` (the service's port, falling back to `defaultPort`), and
718
- * `${<OPTION>}` (any of the service's own option values, e.g.
719
- * `${POSTGRES_USER}`). Example:
720
- * DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${host}:${port}/${POSTGRES_DB}
732
+ * Connection env injected into the WORKSPACE container so the app / agent
733
+ * can reach this service without hardcoding anything. Keyed by logical
734
+ * SUFFIX → template; emitted at apply as `<UPPER(name)>_<SUFFIX>` per
735
+ * service instance (ADR 0021), e.g. suffix `URL` on a service named
736
+ * `postgres` → `POSTGRES_URL`. Tokens: `${host}` (the service's instance
737
+ * name), `${port}` (its port, falling back to `defaultPort`), and
738
+ * `${<OPTION>}` (its own option values, e.g. `${POSTGRES_USER}`). Example:
739
+ * URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${host}:${port}/${POSTGRES_DB}
740
+ * HOST: ${host}
721
741
  */
722
742
  connectionEnv: z2.record(z2.string(), z2.string()).optional(),
723
743
  vscodeExtensions: z2.array(z2.string()).optional()
@@ -2149,7 +2169,8 @@ function resolveService(entry2) {
2149
2169
  volumes: entry2.volumes ? [...entry2.volumes] : [],
2150
2170
  ...entry2.healthcheck ? { healthcheck: entry2.healthcheck } : {},
2151
2171
  ...entry2.restart ? { restart: entry2.restart } : {},
2152
- ...entry2.command ? { command: entry2.command } : {}
2172
+ ...entry2.command ? { command: entry2.command } : {},
2173
+ ...entry2.connectionEnv ? { connectionEnv: { ...entry2.connectionEnv } } : {}
2153
2174
  };
2154
2175
  }
2155
2176
  function isCuratedService(name) {
@@ -2173,6 +2194,10 @@ function expandCuratedService(name) {
2173
2194
  } : {},
2174
2195
  ...def.dataMount ? { volumes: [`data:${def.dataMount}`] } : {},
2175
2196
  ...def.healthcheck ? { healthcheck: def.healthcheck } : {},
2197
+ // Bake the connection-env templates into the yml (suffix → template) so
2198
+ // they travel with the service: a renamed/duplicated instance keeps them,
2199
+ // and `serviceConnectionEnv` prefixes by the instance's current name.
2200
+ ...def.connectionEnv ? { connectionEnv: { ...def.connectionEnv } } : {},
2176
2201
  restart: "unless-stopped"
2177
2202
  };
2178
2203
  }
@@ -2183,17 +2208,18 @@ function curatedServiceEnvDefaults(name) {
2183
2208
  function serviceConnectionEnv(services) {
2184
2209
  const env = {};
2185
2210
  for (const svc of services) {
2186
- const def = SERVICE_CATALOG[svc.name];
2187
- if (!def?.connectionEnv) continue;
2211
+ const conn = svc.connectionEnv;
2212
+ if (!conn || Object.keys(conn).length === 0) continue;
2213
+ const prefix = svc.name.replace(/[^A-Za-z0-9]+/g, "_").toUpperCase();
2188
2214
  const host = svc.name;
2189
- const port = String(svc.port ?? def.defaultPort);
2215
+ const port = svc.port !== void 0 ? String(svc.port) : "";
2190
2216
  const fill = (template) => template.replace(/\$\{([A-Za-z0-9_]+)\}/g, (_, token) => {
2191
2217
  if (token === "host") return host;
2192
2218
  if (token === "port") return port;
2193
2219
  return svc.env[token] ?? "";
2194
2220
  });
2195
- for (const [key, template] of Object.entries(def.connectionEnv)) {
2196
- env[key] = fill(template);
2221
+ for (const [suffix, template] of Object.entries(conn)) {
2222
+ env[`${prefix}_${suffix}`] = fill(template);
2197
2223
  }
2198
2224
  }
2199
2225
  return env;
@@ -5405,29 +5431,30 @@ function generateAgentsMd(input) {
5405
5431
  }
5406
5432
  lines.push("");
5407
5433
  const connEnv = serviceConnectionEnv(input.services);
5408
- if (Object.keys(connEnv).length > 0) {
5434
+ const connUrlKeys = Object.keys(connEnv).filter((k) => k.endsWith("_URL"));
5435
+ if (connUrlKeys.length > 0) {
5409
5436
  lines.push(
5410
- "Connection details for the curated services above are already set as",
5411
- "environment variables in this container. Read them from the",
5412
- "environment \u2014 do not ask the user for credentials, and do not",
5413
- "hardcode them:"
5437
+ "Connection details for the curated services above are set as",
5438
+ "environment variables in this container \u2014 one set per service, prefixed",
5439
+ "with the (uppercased) service name. Read them from the environment; do",
5440
+ "not ask the user for credentials and do not hardcode them. The URLs:"
5414
5441
  );
5415
5442
  lines.push("");
5416
- if (connEnv.DATABASE_URL !== void 0) {
5417
- lines.push(
5418
- "- `DATABASE_URL` \u2014 the SQL database. Engine-specific variables are",
5419
- " set too (`PGHOST`/`PGPORT`/`PGUSER`/`PGPASSWORD`/`PGDATABASE` for",
5420
- " Postgres, `MYSQL_*` for MySQL)."
5421
- );
5422
- }
5423
- if (connEnv.REDIS_URL !== void 0) {
5424
- lines.push("- `REDIS_URL` \u2014 Redis.");
5443
+ for (const k of connUrlKeys) {
5444
+ lines.push(`- \`${k}\``);
5425
5445
  }
5426
5446
  lines.push("");
5427
5447
  lines.push(
5428
- "These are dev-only defaults for the local container, fine to use",
5429
- "directly. Prefer reading the variable (e.g. `process.env.DATABASE_URL`)",
5430
- "over copying its value into code."
5448
+ "Each service also exposes `<SERVICE>_HOST`, `<SERVICE>_PORT` and, for SQL",
5449
+ "databases, `<SERVICE>_USER` / `<SERVICE>_PASSWORD` / `<SERVICE>_DB`. These",
5450
+ "are dev-only defaults for the local container, fine to read directly."
5451
+ );
5452
+ lines.push("");
5453
+ lines.push(
5454
+ "There is deliberately **no** bare `DATABASE_URL` (multiple databases",
5455
+ "would collide on it). If a framework or tool defaults to `DATABASE_URL`,",
5456
+ "set it in the project's `.env` to the right service URL, e.g.",
5457
+ "`DATABASE_URL=$POSTGRES_URL`."
5431
5458
  );
5432
5459
  }
5433
5460
  const hasCustom = input.services.some((s) => !isCuratedService(s.name));
@@ -7233,7 +7260,7 @@ var CLI_VERSION;
7233
7260
  var init_version = __esm({
7234
7261
  "src/version.ts"() {
7235
7262
  "use strict";
7236
- CLI_VERSION = true ? "1.24.1" : "dev";
7263
+ CLI_VERSION = true ? "1.25.0" : "dev";
7237
7264
  }
7238
7265
  });
7239
7266
 
@@ -9610,6 +9637,24 @@ function nextRelayUrl(content, lastOpened) {
9610
9637
  const url = content.trim();
9611
9638
  return url && url !== lastOpened ? url : null;
9612
9639
  }
9640
+ function wrapExec(command, opts) {
9641
+ const leading = [];
9642
+ const stmts = [];
9643
+ if (opts.pathPrepend) {
9644
+ leading.push(opts.pathPrepend);
9645
+ const i = leading.length;
9646
+ stmts.push(`export PATH="$${i}:$PATH"`);
9647
+ stmts.push(`export BROWSER="$${i}/xdg-open"`);
9648
+ }
9649
+ if (opts.cwd) {
9650
+ leading.push(opts.cwd);
9651
+ stmts.push(`cd -- "$${leading.length}"`);
9652
+ }
9653
+ if (leading.length === 0) return command;
9654
+ const shift = leading.length === 1 ? "shift" : `shift ${leading.length}`;
9655
+ const script = `${stmts.join(" && ")} && ${shift} && exec "$@"`;
9656
+ return ["bash", "-lc", script, "bash", ...leading, ...command];
9657
+ }
9613
9658
  function openInBrowser(url) {
9614
9659
  const platform = process.platform;
9615
9660
  const [cmd, args] = platform === "darwin" ? ["open", [url]] : platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
@@ -9755,17 +9800,28 @@ async function runShell(opts) {
9755
9800
  { quiet: true }
9756
9801
  );
9757
9802
  if (upCode !== 0) return upCode;
9758
- return spawnFn(
9759
- [
9760
- "exec",
9761
- "--workspace-folder",
9803
+ const name = opts.name ?? path26.basename(opts.root);
9804
+ const isTty2 = opts.isTty ?? process.stdout.isTTY;
9805
+ const bridge = isTty2 ? await startBrowserBridge({ name, root: opts.root, spawn: spawnFn }) : null;
9806
+ try {
9807
+ const innerExec = wrapExec(
9808
+ ["bash"],
9809
+ bridge ? { pathPrepend: bridge.relayDirInContainer } : {}
9810
+ );
9811
+ return await spawnFn(
9812
+ [
9813
+ "exec",
9814
+ "--workspace-folder",
9815
+ opts.root,
9816
+ "--mount-workspace-git-root=false",
9817
+ ...innerExec
9818
+ ],
9762
9819
  opts.root,
9763
- "--mount-workspace-git-root=false",
9764
- "bash"
9765
- ],
9766
- opts.root,
9767
- { interactive: true }
9768
- );
9820
+ { interactive: true }
9821
+ );
9822
+ } finally {
9823
+ if (bridge) await bridge.dispose();
9824
+ }
9769
9825
  }
9770
9826
  function assertContainerExists(root) {
9771
9827
  if (!existsSync17(path26.join(root, ".devcontainer"))) {
@@ -9777,29 +9833,12 @@ function assertContainerExists(root) {
9777
9833
  var init_shell = __esm({
9778
9834
  "src/devcontainer/shell.ts"() {
9779
9835
  "use strict";
9836
+ init_browser_bridge();
9780
9837
  init_cli();
9781
9838
  }
9782
9839
  });
9783
9840
 
9784
9841
  // src/devcontainer/run.ts
9785
- function wrapExec(command, opts) {
9786
- const leading = [];
9787
- const stmts = [];
9788
- if (opts.pathPrepend) {
9789
- leading.push(opts.pathPrepend);
9790
- const i = leading.length;
9791
- stmts.push(`export PATH="$${i}:$PATH"`);
9792
- stmts.push(`export BROWSER="$${i}/xdg-open"`);
9793
- }
9794
- if (opts.cwd) {
9795
- leading.push(opts.cwd);
9796
- stmts.push(`cd -- "$${leading.length}"`);
9797
- }
9798
- if (leading.length === 0) return command;
9799
- const shift = leading.length === 1 ? "shift" : `shift ${leading.length}`;
9800
- const script = `${stmts.join(" && ")} && ${shift} && exec "$@"`;
9801
- return ["bash", "-lc", script, "bash", ...leading, ...command];
9802
- }
9803
9842
  async function runInContainer(opts) {
9804
9843
  if (opts.command.length === 0) {
9805
9844
  throw new Error(
@@ -9932,7 +9971,10 @@ var init_shell2 = __esm({
9932
9971
  },
9933
9972
  async run({ args }) {
9934
9973
  try {
9935
- const exitCode = await runShell({ root: containerDir(args.name) });
9974
+ const exitCode = await runShell({
9975
+ root: containerDir(args.name),
9976
+ name: args.name
9977
+ });
9936
9978
  process.exit(exitCode);
9937
9979
  } catch (err) {
9938
9980
  consola30.error(err instanceof Error ? err.message : String(err));