@getmonoceros/workbench 1.13.0 → 1.13.2

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/dist/bin.js CHANGED
@@ -356,13 +356,12 @@ var FeatureEntrySchema = z.object({
356
356
  options: z.record(z.string(), FeatureOptionValueSchema).optional()
357
357
  });
358
358
  var EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
359
+ function isValidEmail(value) {
360
+ return EMAIL_RE.test(value);
361
+ }
359
362
  var GitUserSchema = z.object({
360
363
  name: z.union([z.literal(""), z.null(), z.string().min(1)]).nullish().transform((v) => typeof v === "string" && v.length > 0 ? v : void 0),
361
- email: z.union([
362
- z.literal(""),
363
- z.null(),
364
- z.string().regex(EMAIL_RE, "Invalid email")
365
- ]).nullish().transform((v) => typeof v === "string" && v.length > 0 ? v : void 0)
364
+ email: z.union([z.literal(""), z.null(), z.string().min(1)]).nullish().transform((v) => typeof v === "string" && v.length > 0 ? v : void 0)
366
365
  });
367
366
  var RepoEntrySchema = z.object({
368
367
  url: z.string().regex(
@@ -717,6 +716,23 @@ function interpolateServices(services, vars) {
717
716
  });
718
717
  return { services: resolved, missing };
719
718
  }
719
+ var GIT_IDENTITY_VAR = {
720
+ name: "GIT_USER_NAME",
721
+ email: "GIT_USER_EMAIL"
722
+ };
723
+ function hasVarPlaceholder(value) {
724
+ return /\$\{[A-Za-z_][A-Za-z0-9_]*\}/.test(value);
725
+ }
726
+ function resolveGitUserFields(user, vars) {
727
+ const resolve = (raw) => {
728
+ if (raw === void 0) return {};
729
+ const r = interpolate(raw, vars);
730
+ if (r.missing.length > 0) return {};
731
+ const trimmed = r.value.trim();
732
+ return trimmed.length > 0 ? { value: trimmed } : {};
733
+ };
734
+ return { name: resolve(user.name), email: resolve(user.email) };
735
+ }
720
736
  function interpolateFeatures(features, vars) {
721
737
  const missing = [];
722
738
  const out = {};
@@ -742,13 +758,20 @@ function buildEnvStub(name) {
742
758
  `;
743
759
  }
744
760
  async function ensureEnvVars(envPath, name, vars) {
761
+ const entries = Array.isArray(vars) ? vars.map((v) => [v, ""]) : Object.entries(vars);
745
762
  const exists = existsSync2(envPath);
746
763
  let content = exists ? readFileSync(envPath, "utf8") : buildEnvStub(name);
747
764
  const present = new Set(Object.keys(parseEnvFile(content)));
748
- const added = [...new Set(vars)].filter((v) => !present.has(v));
765
+ const seen = /* @__PURE__ */ new Set();
766
+ const toAdd = entries.filter(([k]) => {
767
+ if (present.has(k) || seen.has(k)) return false;
768
+ seen.add(k);
769
+ return true;
770
+ });
771
+ const added = toAdd.map(([k]) => k);
749
772
  if (!exists || added.length > 0) {
750
773
  if (content.length > 0 && !content.endsWith("\n")) content += "\n";
751
- for (const v of added) content += `${v}=
774
+ for (const [k, v] of toAdd) content += `${k}=${v}
752
775
  `;
753
776
  await fsp.mkdir(path2.dirname(envPath), { recursive: true });
754
777
  await fsp.writeFile(envPath, content);
@@ -1355,7 +1378,15 @@ var MonocerosConfigSchema = z2.object({
1355
1378
  // `git: null` for an empty mapping, which zod's plain
1356
1379
  // `.optional()` would reject.
1357
1380
  git: z2.object({
1358
- user: GitUserSchema.optional()
1381
+ // Strict email here: monoceros-config defaults are not tied to
1382
+ // any container `<name>.env`, so `${VAR}` placeholders make no
1383
+ // sense and the format can (and should) be validated at load
1384
+ // time — unlike the container/repo `git.user`, which defers to
1385
+ // apply after interpolation.
1386
+ user: GitUserSchema.optional().refine(
1387
+ (u) => u?.email === void 0 || isValidEmail(u.email),
1388
+ { message: "Invalid email in defaults.git.user", path: ["email"] }
1389
+ )
1359
1390
  }).nullish(),
1360
1391
  // .nullish() for the same reason as `git` — the sample keeps
1361
1392
  // `features:` uncommented as a category marker.
@@ -1972,6 +2003,19 @@ var SERVICE_CATALOG = {
1972
2003
  POSTGRES_PASSWORD: "monoceros",
1973
2004
  POSTGRES_DB: "monoceros"
1974
2005
  },
2006
+ healthcheck: {
2007
+ test: [
2008
+ "CMD",
2009
+ "pg_isready",
2010
+ "-U",
2011
+ "${POSTGRES_USER}",
2012
+ "-d",
2013
+ "${POSTGRES_DB}"
2014
+ ],
2015
+ interval: "10s",
2016
+ timeout: "5s",
2017
+ retries: 5
2018
+ },
1975
2019
  // Postgres 18+ stores data under /var/lib/postgresql/<major>/, so
1976
2020
  // the recommended mount is the parent directory; pre-18 used
1977
2021
  // /var/lib/postgresql/data directly. See
@@ -1986,12 +2030,33 @@ var SERVICE_CATALOG = {
1986
2030
  MYSQL_ROOT_PASSWORD: "monoceros",
1987
2031
  MYSQL_DATABASE: "monoceros"
1988
2032
  },
2033
+ healthcheck: {
2034
+ test: [
2035
+ "CMD",
2036
+ "mysqladmin",
2037
+ "ping",
2038
+ "-h",
2039
+ "127.0.0.1",
2040
+ "-u",
2041
+ "root",
2042
+ "-p${MYSQL_ROOT_PASSWORD}"
2043
+ ],
2044
+ interval: "10s",
2045
+ timeout: "5s",
2046
+ retries: 5
2047
+ },
1989
2048
  dataMount: "/var/lib/mysql",
1990
2049
  defaultPort: 3306
1991
2050
  },
1992
2051
  redis: {
1993
2052
  id: "redis",
1994
2053
  image: "redis:8",
2054
+ healthcheck: {
2055
+ test: ["CMD", "redis-cli", "ping"],
2056
+ interval: "10s",
2057
+ timeout: "5s",
2058
+ retries: 5
2059
+ },
1995
2060
  dataMount: "/data",
1996
2061
  defaultPort: 6379
1997
2062
  }
@@ -2028,10 +2093,20 @@ function expandCuratedService(name) {
2028
2093
  name: def.id,
2029
2094
  image: def.image,
2030
2095
  port: def.defaultPort,
2031
- ...def.env ? { env: { ...def.env } } : {},
2032
- ...def.dataMount ? { volumes: [`data:${def.dataMount}`] } : {}
2096
+ ...def.env ? {
2097
+ env: Object.fromEntries(
2098
+ Object.keys(def.env).map((k) => [k, `\${${k}}`])
2099
+ )
2100
+ } : {},
2101
+ ...def.dataMount ? { volumes: [`data:${def.dataMount}`] } : {},
2102
+ ...def.healthcheck ? { healthcheck: def.healthcheck } : {},
2103
+ restart: "unless-stopped"
2033
2104
  };
2034
2105
  }
2106
+ function curatedServiceEnvDefaults(name) {
2107
+ const def = SERVICE_CATALOG[name];
2108
+ return def?.env ? { ...def.env } : {};
2109
+ }
2035
2110
  function deriveServiceName(image) {
2036
2111
  const lastSegment = image.split("/").pop() ?? image;
2037
2112
  const noTag = lastSegment.split("@")[0].split(":")[0];
@@ -2772,6 +2847,17 @@ function setContainerGitUserInDoc(doc, user) {
2772
2847
  relocateLeakedSectionComments(doc);
2773
2848
  return true;
2774
2849
  }
2850
+ function ensureContainerGitUserPlaceholder(doc) {
2851
+ const gitNode = doc.get("git", true);
2852
+ if (gitNode && isMap2(gitNode)) {
2853
+ const userNode = gitNode.get("user", true);
2854
+ if (userNode && isMap2(userNode)) return false;
2855
+ }
2856
+ return setContainerGitUserInDoc(doc, {
2857
+ name: `\${${GIT_IDENTITY_VAR.name}}`,
2858
+ email: `\${${GIT_IDENTITY_VAR.email}}`
2859
+ });
2860
+ }
2775
2861
  function relocateLeakedSectionComments(doc) {
2776
2862
  const root = doc.contents;
2777
2863
  if (!root || !isMap2(root)) return;
@@ -3137,8 +3223,26 @@ async function runAddService(input) {
3137
3223
  }
3138
3224
  return r.outcome === "added";
3139
3225
  });
3140
- if (result.status === "updated" && !curated) {
3141
- (input.logger ?? defaultLogger()).info(customServiceHint(name));
3226
+ if (result.status === "updated") {
3227
+ if (curated) {
3228
+ const defaults = curatedServiceEnvDefaults(arg);
3229
+ if (Object.keys(defaults).length > 0) {
3230
+ const home = input.monocerosHome ?? monocerosHome();
3231
+ await ensureEnvGitignored(containerConfigsDir(home));
3232
+ const seeded = await ensureEnvVars(
3233
+ containerEnvPath(input.name, home),
3234
+ input.name,
3235
+ defaults
3236
+ );
3237
+ if (seeded.added.length > 0) {
3238
+ (input.logger ?? defaultLogger()).info(
3239
+ `Seeded ${seeded.added.join(", ")} into ${input.name}.env (dev-defaults \u2014 change them there if needed).`
3240
+ );
3241
+ }
3242
+ }
3243
+ } else {
3244
+ (input.logger ?? defaultLogger()).info(customServiceHint(name));
3245
+ }
3142
3246
  }
3143
3247
  return result;
3144
3248
  }
@@ -3165,6 +3269,14 @@ async function runAddRepo(input) {
3165
3269
  "--git-name and --git-email must be set together. Pass both, or neither."
3166
3270
  );
3167
3271
  }
3272
+ if (hasEmail) {
3273
+ const email = input.gitEmail.trim();
3274
+ if (!isValidEmail(email) && !hasVarPlaceholder(email)) {
3275
+ throw new Error(
3276
+ `Invalid --git-email '${email}': must be a valid email or a \${VAR} placeholder resolved from <name>.env.`
3277
+ );
3278
+ }
3279
+ }
3168
3280
  const explicitProvider = normalizeProvider(input.provider);
3169
3281
  let host;
3170
3282
  try {
@@ -3195,7 +3307,23 @@ async function runAddRepo(input) {
3195
3307
  } : {},
3196
3308
  ...providerToWrite ? { provider: providerToWrite } : {}
3197
3309
  };
3198
- const result = await mutate(input, (doc) => addRepoToDoc(doc, entry2));
3310
+ let gitUserScaffolded = false;
3311
+ const result = await mutate(input, (doc) => {
3312
+ const repoAdded = addRepoToDoc(doc, entry2);
3313
+ if (repoAdded) gitUserScaffolded = ensureContainerGitUserPlaceholder(doc);
3314
+ return repoAdded;
3315
+ });
3316
+ if (result.status === "updated" && gitUserScaffolded) {
3317
+ const home = input.monocerosHome ?? monocerosHome();
3318
+ await ensureEnvGitignored(containerConfigsDir(home));
3319
+ await ensureEnvVars(containerEnvPath(input.name, home), input.name, [
3320
+ GIT_IDENTITY_VAR.name,
3321
+ GIT_IDENTITY_VAR.email
3322
+ ]);
3323
+ (input.logger ?? defaultLogger()).info(
3324
+ `Added a container git.user with \${${GIT_IDENTITY_VAR.name}}/\${${GIT_IDENTITY_VAR.email}} placeholders and seeded ${input.name}.env \u2014 fill them or leave blank to use your global git identity.`
3325
+ );
3326
+ }
3199
3327
  if (result.status === "updated") {
3200
3328
  await tryCloneInRunningContainer(input, entry2);
3201
3329
  }
@@ -4971,6 +5099,44 @@ ${sectionLine(label)}
4971
5099
  }
4972
5100
  createOpts.services = interpServices.services;
4973
5101
  if (createOpts.features) createOpts.features = interpFeatures.features;
5102
+ const gitUserErrors = [];
5103
+ let containerGitOverride;
5104
+ if (parsed.config.git?.user) {
5105
+ const f = resolveGitUserFields(parsed.config.git.user, envVars);
5106
+ if (f.email.value !== void 0 && !isValidEmail(f.email.value)) {
5107
+ gitUserErrors.push(
5108
+ `git.user.email resolved to "${f.email.value}", which is not a valid email`
5109
+ );
5110
+ }
5111
+ const override2 = {
5112
+ ...f.name.value !== void 0 ? { name: f.name.value } : {},
5113
+ ...f.email.value !== void 0 ? { email: f.email.value } : {}
5114
+ };
5115
+ if (Object.keys(override2).length > 0) containerGitOverride = override2;
5116
+ }
5117
+ for (const repo of createOpts.repos ?? []) {
5118
+ if (!repo.gitUser) continue;
5119
+ const f = resolveGitUserFields(repo.gitUser, envVars);
5120
+ if (f.name.value === void 0 || f.email.value === void 0) {
5121
+ delete repo.gitUser;
5122
+ continue;
5123
+ }
5124
+ if (!isValidEmail(f.email.value)) {
5125
+ gitUserErrors.push(
5126
+ `repos[${repo.path}].git.user.email resolved to "${f.email.value}", which is not a valid email`
5127
+ );
5128
+ continue;
5129
+ }
5130
+ repo.gitUser = { name: f.name.value, email: f.email.value };
5131
+ }
5132
+ if (gitUserErrors.length > 0) {
5133
+ throw new Error(
5134
+ `Invalid git identity after resolving ${prettyPath(envPath)}:
5135
+ ` + gitUserErrors.map((e) => ` - ${e}`).join("\n") + `
5136
+
5137
+ Fix the value in the env file (or the yml).`
5138
+ );
5139
+ }
4974
5140
  validateOptions(createOpts);
4975
5141
  logger.success(`yml validated ${dim(`(${prettyPath(ymlPath)})`)}`);
4976
5142
  const hasRepos = (createOpts.repos ?? []).length > 0;
@@ -4985,7 +5151,7 @@ ${sectionLine(label)}
4985
5151
  ...opts.identitySpawn ? { spawn: opts.identitySpawn } : {},
4986
5152
  ...opts.identityPrompt ? { prompt: opts.identityPrompt } : {},
4987
5153
  ...opts.identityScopePrompt ? { scopePrompt: opts.identityScopePrompt } : {},
4988
- ...parsed.config.git?.user ? { containerOverride: parsed.config.git.user } : {},
5154
+ ...containerGitOverride ? { containerOverride: containerGitOverride } : {},
4989
5155
  ...globalConfig?.defaults?.git?.user ? { defaults: globalConfig.defaults.git.user } : {},
4990
5156
  logger: idLogger
4991
5157
  });
@@ -5193,7 +5359,7 @@ async function persistPromptedIdentity(prompted, ymlPath, home, logger) {
5193
5359
  }
5194
5360
 
5195
5361
  // src/version.ts
5196
- var CLI_VERSION = true ? "1.13.0" : "dev";
5362
+ var CLI_VERSION = true ? "1.13.2" : "dev";
5197
5363
 
5198
5364
  // src/commands/_dispatch.ts
5199
5365
  import { consola as consola12 } from "consola";
@@ -5828,6 +5994,7 @@ function generateComposedYml(name, composed, lookupManifest, repoUrls = [], port
5828
5994
  lines.push("");
5829
5995
  }
5830
5996
  if (repoUrls.length > 0) {
5997
+ pushGitIdentityBlock(lines);
5831
5998
  pushSectionHeader(
5832
5999
  lines,
5833
6000
  REPOS_HEADER,
@@ -5945,6 +6112,7 @@ function generateDocumentedYml(name, catalog, lookupManifest, repoUrls = [], por
5945
6112
  lines.push("");
5946
6113
  }
5947
6114
  if (repoUrls.length > 0) {
6115
+ pushGitIdentityBlock(lines);
5948
6116
  pushSectionHeader(
5949
6117
  lines,
5950
6118
  REPOS_HEADER,
@@ -6024,6 +6192,20 @@ function pushServiceEntry(out, svc) {
6024
6192
  var FEATURES_HEADER_ACTIVE = "A Monoceros dev-container is shaped by features \u2014 pluggable units that drop tooling (AI assistants, language CLIs, cloud SDKs, \u2026) into the container and bring their own options. The features active for this container are listed below; adjust their options as needed. Shared credentials used across containers belong in monoceros-config.yml under `defaults.features.<ref>` rather than here. Full catalog: `monoceros list-components`.";
6025
6193
  var FEATURES_HEADER_DOCUMENTED = "A Monoceros dev-container is shaped by features \u2014 pluggable units that drop tooling (AI assistants, language CLIs, cloud SDKs, \u2026) into the container and bring their own options. Un-comment the blocks below for the features you want active. Shared credentials used across containers belong in monoceros-config.yml under `defaults.features.<ref>` rather than here. Full catalog: `monoceros list-components`.";
6026
6194
  var REPOS_HEADER = "Git repositories cloned into `projects/` on container start-up. HTTPS URLs only. The provider is auto-detected for github.com / gitlab.com / bitbucket.org; for any other host (self-hosted GitLab, Gitea, \u2026) declare `provider:` explicitly. Add more later with `monoceros add-repo`.";
6195
+ var GIT_IDENTITY_HEADER = "Git committer identity for commits made inside the container. The ${VAR} values resolve from <name>.env at apply time \u2014 fill them there, or leave them blank to fall back to your global git config (or a one-time prompt). Override per repo under repos[].git.user.";
6196
+ function pushGitIdentityBlock(lines) {
6197
+ pushSectionHeader(
6198
+ lines,
6199
+ GIT_IDENTITY_HEADER,
6200
+ /* commented */
6201
+ false
6202
+ );
6203
+ lines.push("git:");
6204
+ lines.push(" user:");
6205
+ lines.push(` name: \${${GIT_IDENTITY_VAR.name}}`);
6206
+ lines.push(` email: \${${GIT_IDENTITY_VAR.email}}`);
6207
+ lines.push("");
6208
+ }
6027
6209
  function routingHeader(name) {
6028
6210
  return `Container ports exposed to the host through Traefik. Reach them in your browser as ${name}-<port>.localhost (e.g. ${name}-3000.localhost). The first entry is the default route and is also reachable as the bare ${name}.localhost. Manage the list with \`monoceros add-port\`.`;
6029
6211
  }
@@ -6168,17 +6350,6 @@ async function runInit(opts) {
6168
6350
  seenPorts.add(raw);
6169
6351
  ports.push(raw);
6170
6352
  }
6171
- let promptedIdentity;
6172
- if (repos.length > 0) {
6173
- const globalConfig = await readMonocerosConfig({ monocerosHome: home });
6174
- promptedIdentity = await resolveIdentityWithPrompt({
6175
- ...opts.identitySpawn ? { spawn: opts.identitySpawn } : {},
6176
- ...opts.identityPrompt ? { prompt: opts.identityPrompt } : {},
6177
- ...opts.identityScopePrompt ? { scopePrompt: opts.identityScopePrompt } : {},
6178
- ...globalConfig?.defaults?.git?.user ? { defaults: globalConfig.defaults.git.user } : {},
6179
- logger: { info: logger.info, warn: logger.info }
6180
- });
6181
- }
6182
6353
  let text;
6183
6354
  const composed = resolveComposedInit(catalog, {
6184
6355
  languages: opts.languages ?? [],
@@ -6196,57 +6367,26 @@ async function runInit(opts) {
6196
6367
  await ensureEnvGitignored(containerConfigsDir(home));
6197
6368
  await fs14.writeFile(dest, text, "utf8");
6198
6369
  const envPath = containerEnvPath(opts.name, home);
6199
- const featureVars = composed.features.flatMap(
6200
- (f) => featureOptionHints(lookup(f.ref), f.ref, Object.keys(f.options ?? {})).map(
6201
- (h) => h.envVar
6202
- )
6203
- );
6204
- await ensureEnvVars(envPath, opts.name, featureVars);
6205
- if (promptedIdentity?.prompted) {
6206
- const { name, email, scope } = promptedIdentity.prompted;
6207
- if (scope === "g" || scope === "b") {
6208
- try {
6209
- const result = await writeGlobalDefaultGitUser(
6210
- { name, email },
6211
- { monocerosHome: home }
6212
- );
6213
- if (result.alreadySet) {
6214
- logger.info(
6215
- `monoceros-config.yml already had a defaults.git.user \u2014 left it alone.`
6216
- );
6217
- } else if (result.created) {
6218
- logger.info(
6219
- `Saved identity globally \u2014 created ${prettyPath(result.filePath)} with defaults.git.user.`
6220
- );
6221
- } else {
6222
- logger.info(
6223
- `Saved identity globally to ${prettyPath(result.filePath)}.`
6224
- );
6225
- }
6226
- } catch (err) {
6227
- logger.info(
6228
- `Could not persist identity to monoceros-config.yml: ${err instanceof Error ? err.message : String(err)}. \`monoceros apply\` will re-prompt.`
6229
- );
6230
- }
6370
+ const seedVars = {};
6371
+ for (const f of composed.features) {
6372
+ for (const h of featureOptionHints(
6373
+ lookup(f.ref),
6374
+ f.ref,
6375
+ Object.keys(f.options ?? {})
6376
+ )) {
6377
+ if (!(h.envVar in seedVars)) seedVars[h.envVar] = "";
6231
6378
  }
6232
- if (scope === "c" || scope === "b") {
6233
- try {
6234
- const written = await fs14.readFile(dest, "utf8");
6235
- const parsed = parseConfig(written, dest);
6236
- const changed = setContainerGitUserInDoc(parsed.doc, { name, email });
6237
- if (changed) {
6238
- await fs14.writeFile(dest, stringifyConfig(parsed.doc), "utf8");
6239
- logger.info(
6240
- `Saved identity in ${prettyPath(dest)} (container-level git.user).`
6241
- );
6242
- }
6243
- } catch (err) {
6244
- logger.info(
6245
- `Could not persist identity into ${prettyPath(dest)}: ${err instanceof Error ? err.message : String(err)}. \`monoceros apply\` will re-prompt.`
6246
- );
6247
- }
6379
+ }
6380
+ for (const svc of composed.services) {
6381
+ if (svc.kind === "curated") {
6382
+ Object.assign(seedVars, curatedServiceEnvDefaults(svc.name));
6248
6383
  }
6249
6384
  }
6385
+ if (repos.length > 0) {
6386
+ seedVars[GIT_IDENTITY_VAR.name] = "";
6387
+ seedVars[GIT_IDENTITY_VAR.email] = "";
6388
+ }
6389
+ await ensureEnvVars(envPath, opts.name, seedVars);
6250
6390
  const documented = !anyComposed;
6251
6391
  const ymlRel = path16.relative(home, dest);
6252
6392
  const envRel = path16.relative(home, envPath);