@getmonoceros/workbench 1.7.4 → 1.8.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.
package/dist/bin.js CHANGED
@@ -251,25 +251,25 @@ function detectHelpRequest(argv, main2) {
251
251
  const separatorIdx = argv.indexOf("--");
252
252
  if (helpIdx === -1) return null;
253
253
  if (separatorIdx !== -1 && separatorIdx < helpIdx) return null;
254
- const path15 = [];
254
+ const path16 = [];
255
255
  const tokens = argv.slice(
256
256
  0,
257
257
  separatorIdx === -1 ? argv.length : separatorIdx
258
258
  );
259
259
  let cursor = main2;
260
260
  const mainName = (main2.meta ?? {}).name ?? "monoceros";
261
- path15.push(mainName);
261
+ path16.push(mainName);
262
262
  for (const tok of tokens) {
263
263
  if (tok.startsWith("-")) continue;
264
264
  const subs = cursor.subCommands ?? {};
265
265
  if (tok in subs) {
266
266
  cursor = subs[tok];
267
- path15.push(tok);
267
+ path16.push(tok);
268
268
  continue;
269
269
  }
270
270
  break;
271
271
  }
272
- return { path: path15, cmd: cursor };
272
+ return { path: path16, cmd: cursor };
273
273
  }
274
274
  async function maybeRenderHelp(argv, main2) {
275
275
  const hit = detectHelpRequest(argv, main2);
@@ -308,9 +308,10 @@ import { defineCommand } from "citty";
308
308
  import { consola as consola2 } from "consola";
309
309
 
310
310
  // src/modify/index.ts
311
- import { promises as fs6 } from "fs";
311
+ import { promises as fs7 } from "fs";
312
312
  import { consola } from "consola";
313
313
  import { createPatch } from "diff";
314
+ import path6 from "path";
314
315
 
315
316
  // src/config/io.ts
316
317
  import { promises as fs } from "fs";
@@ -346,11 +347,7 @@ var KNOWN_PROVIDER_HOSTS = {
346
347
  "bitbucket.org": "bitbucket"
347
348
  };
348
349
  var CONFIG_SCHEMA_VERSION = 1;
349
- var FeatureOptionValueSchema = z.union([
350
- z.string(),
351
- z.number(),
352
- z.boolean()
353
- ]);
350
+ var FeatureOptionValueSchema = z.union([z.string(), z.number(), z.boolean(), z.null()]).transform((v) => v === null ? "" : v);
354
351
  var FeatureEntrySchema = z.object({
355
352
  ref: z.string().regex(
356
353
  FEATURE_REF_RE,
@@ -358,9 +355,14 @@ var FeatureEntrySchema = z.object({
358
355
  ),
359
356
  options: z.record(z.string(), FeatureOptionValueSchema).optional()
360
357
  });
358
+ var EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
361
359
  var GitUserSchema = z.object({
362
- name: z.string().min(1),
363
- email: z.string().min(3).regex(/^[^\s@]+@[^\s@]+\.[^\s@]+$/, "Invalid email")
360
+ 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
366
  });
365
367
  var RepoEntrySchema = z.object({
366
368
  url: z.string().regex(
@@ -567,222 +569,737 @@ function prettyPath(p) {
567
569
  return p;
568
570
  }
569
571
 
570
- // src/config/global.ts
572
+ // src/devcontainer/credentials.ts
573
+ import { spawn } from "child_process";
571
574
  import { promises as fs2 } from "fs";
572
- import { z as z2 } from "zod";
573
- import { parseDocument as parseDocument2 } from "yaml";
574
- var SCHEMA_VERSION = 1;
575
- var MonocerosConfigSchema = z2.object({
576
- schemaVersion: z2.literal(SCHEMA_VERSION),
577
- // .nullish() (= .optional().nullable()) on defaults so the shipped
578
- // sample yml — where `defaults:` is uncommented but every sub-block
579
- // is commented out — parses cleanly. YAML produces `defaults: null`
580
- // in that case; without .nullish() the schema would reject it and
581
- // we'd be back to forcing builders to comment-juggle three lines.
582
- defaults: z2.object({
583
- // .nullish() (not just .optional()) so the sample yml can leave
584
- // `git:` uncommented as a category marker — YAML produces
585
- // `git: null` for an empty mapping, which zod's plain
586
- // `.optional()` would reject.
587
- git: z2.object({
588
- user: GitUserSchema.optional()
589
- }).nullish(),
590
- // .nullish() for the same reason as `git` — the sample keeps
591
- // `features:` uncommented as a category marker.
592
- features: z2.record(
593
- z2.string().regex(
594
- REGEX.featureRef,
595
- "Invalid feature ref. Expected an OCI-image-style ref like 'ghcr.io/getmonoceros/monoceros-features/<name>:<tag>'."
596
- ),
597
- z2.record(z2.string(), FeatureOptionValueSchema)
598
- ).nullish()
599
- }).nullish(),
600
- // Machine-global routing settings — one Traefik per builder, so
601
- // host-port and similar live here rather than in any container yml.
602
- // See ADR 0007.
603
- routing: z2.object({
604
- hostPort: z2.number().int().min(1).max(65535).optional().describe(
605
- "Host port the Traefik singleton binds. Default 80. Set this when 80 is held by another service on your machine \u2014 URLs then become http://<name>.localhost:<port>/."
606
- )
607
- }).nullish()
608
- });
609
- async function readMonocerosConfig(opts = {}) {
610
- const home = opts.monocerosHome ?? monocerosHome();
611
- const filePath = monocerosConfigPath(home);
612
- let text;
613
- try {
614
- text = await fs2.readFile(filePath, "utf8");
615
- } catch {
616
- return void 0;
617
- }
618
- const doc = parseDocument2(text, { prettyErrors: true });
619
- if (doc.errors.length > 0) {
620
- throw new Error(
621
- `yaml parse error in ${filePath}: ${doc.errors[0].message}`
622
- );
623
- }
624
- const result = MonocerosConfigSchema.safeParse(doc.toJS());
625
- if (!result.success) {
626
- const issues = result.error.issues.map((issue) => {
627
- const where = issue.path.length > 0 ? issue.path.join(".") : "(root)";
628
- return ` - ${where}: ${issue.message}`;
629
- }).join("\n");
630
- throw new Error(
631
- `Invalid ${filePath}:
632
- ${issues}
575
+ import path2 from "path";
633
576
 
634
- See ${filePath.replace(
635
- /\.yml$/,
636
- ".sample.yml"
637
- )} for a valid example.`
638
- );
639
- }
640
- return result.data;
577
+ // src/util/format.ts
578
+ var ESC = "\x1B[";
579
+ var ANSI_BOLD2 = `${ESC}1m`;
580
+ var ANSI_UNDERLINE2 = `${ESC}4m`;
581
+ var ANSI_CYAN2 = `${ESC}36m`;
582
+ var ANSI_GREY2 = `${ESC}90m`;
583
+ var ANSI_RESET2 = `${ESC}0m`;
584
+ function makeWrap(isTty2) {
585
+ return (s, ...codes) => isTty2 ? codes.join("") + s + ANSI_RESET2 : s;
641
586
  }
642
- var DEFAULT_PROXY_HOST_PORT = 80;
643
- function proxyHostPort(config) {
644
- return config?.routing?.hostPort ?? DEFAULT_PROXY_HOST_PORT;
587
+ function makePalette(isTty2) {
588
+ const wrap = makeWrap(isTty2);
589
+ return {
590
+ bold: (s) => wrap(s, ANSI_BOLD2),
591
+ underline: (s) => wrap(s, ANSI_UNDERLINE2),
592
+ cyan: (s) => wrap(s, ANSI_CYAN2),
593
+ dim: (s) => wrap(s, ANSI_GREY2),
594
+ sectionLine: (label) => wrap(`\u25B8 ${label}`, ANSI_BOLD2, ANSI_UNDERLINE2)
595
+ };
596
+ }
597
+ function colorsFor(stream) {
598
+ return makePalette(stream.isTTY ?? false);
645
599
  }
600
+ var stderrPalette = makePalette(process.stderr.isTTY ?? false);
601
+ var bold2 = stderrPalette.bold;
602
+ var underline2 = stderrPalette.underline;
603
+ var cyan2 = stderrPalette.cyan;
604
+ var dim = stderrPalette.dim;
605
+ var sectionLine = stderrPalette.sectionLine;
646
606
 
647
- // src/proxy/index.ts
648
- import { spawn } from "child_process";
649
- import { promises as fs3 } from "fs";
650
- import path2 from "path";
651
- var PROXY_CONTAINER_NAME = "monoceros-proxy";
652
- var PROXY_NETWORK_NAME = "monoceros-proxy";
653
- var TRAEFIK_IMAGE = "traefik:v3.3";
654
- var defaultDockerExec = (args) => {
607
+ // src/devcontainer/credentials.ts
608
+ var realGitCredentialFill = (input) => {
655
609
  return new Promise((resolve, reject) => {
656
- const child = spawn("docker", args, {
657
- stdio: ["ignore", "pipe", "pipe"]
610
+ const child = spawn("git", ["credential", "fill"], {
611
+ stdio: ["pipe", "pipe", "inherit"],
612
+ env: {
613
+ ...process.env,
614
+ GIT_TERMINAL_PROMPT: "0",
615
+ GIT_ASKPASS: "",
616
+ SSH_ASKPASS: ""
617
+ }
658
618
  });
659
619
  let stdout = "";
660
- let stderr = "";
661
620
  child.stdout.on("data", (chunk) => {
662
621
  stdout += chunk.toString();
663
622
  });
664
- child.stderr.on("data", (chunk) => {
665
- stderr += chunk.toString();
666
- });
667
623
  child.on("error", reject);
668
- child.on(
669
- "exit",
670
- (code) => resolve({ stdout, stderr, exitCode: code ?? 0 })
671
- );
624
+ child.on("exit", (code) => resolve({ stdout, exitCode: code ?? 0 }));
625
+ child.stdin.write(input);
626
+ child.stdin.end();
672
627
  });
673
628
  };
674
- var realDocker = defaultDockerExec;
675
- function proxyDynamicDir(home) {
676
- return path2.join(home ?? monocerosHome(), "traefik", "dynamic");
629
+ function resolveProvider(host, explicit) {
630
+ const canonical = KNOWN_PROVIDER_HOSTS[host.toLowerCase()];
631
+ if (canonical) return canonical;
632
+ return explicit ?? "unknown";
677
633
  }
678
- async function ensureProxy(opts = {}) {
679
- const docker = opts.docker ?? realDocker;
680
- const dyn = proxyDynamicDir(opts.monocerosHome);
681
- await fs3.mkdir(dyn, { recursive: true });
682
- const netInspect = await docker(["network", "inspect", PROXY_NETWORK_NAME]);
683
- if (netInspect.exitCode !== 0) {
684
- const create = await docker(["network", "create", PROXY_NETWORK_NAME]);
685
- if (create.exitCode !== 0) {
686
- throw new Error(
687
- `Could not create docker network ${PROXY_NETWORK_NAME}: ${create.stderr.trim() || `exit ${create.exitCode}`}`
688
- );
689
- }
690
- }
691
- const state = await docker([
692
- "inspect",
693
- "--format",
694
- "{{.State.Running}}",
695
- PROXY_CONTAINER_NAME
696
- ]);
697
- if (state.exitCode === 0) {
698
- if (state.stdout.trim() === "true") return;
699
- const start = await docker(["start", PROXY_CONTAINER_NAME]);
700
- if (start.exitCode !== 0) {
701
- throw new Error(
702
- `Could not start existing ${PROXY_CONTAINER_NAME} container: ${start.stderr.trim() || `exit ${start.exitCode}`}`
703
- );
634
+ function uniqueHttpsHosts(repos) {
635
+ const byHost = /* @__PURE__ */ new Map();
636
+ for (const repo of repos) {
637
+ if (!repo.url.startsWith("https://")) continue;
638
+ let host;
639
+ try {
640
+ host = new URL(repo.url).hostname;
641
+ } catch {
642
+ continue;
704
643
  }
705
- return;
706
- }
707
- const hostPort = opts.hostPort ?? 80;
708
- const run = await docker([
709
- "run",
710
- "-d",
711
- "--name",
712
- PROXY_CONTAINER_NAME,
713
- "--network",
714
- PROXY_NETWORK_NAME,
715
- "-p",
716
- `${hostPort}:80`,
717
- "-v",
718
- `${dyn}:/etc/traefik/dynamic:ro`,
719
- "--label",
720
- "monoceros.role=proxy",
721
- TRAEFIK_IMAGE,
722
- "--entrypoints.web.address=:80",
723
- "--providers.file.directory=/etc/traefik/dynamic",
724
- "--providers.file.watch=true",
725
- "--providers.docker=false",
726
- "--api.dashboard=false",
727
- "--log.level=INFO"
728
- ]);
729
- if (run.exitCode !== 0) {
730
- throw new Error(
731
- `Could not start ${PROXY_CONTAINER_NAME}: ${run.stderr.trim() || `exit ${run.exitCode}`}`
732
- );
733
- }
734
- opts.logger?.info(
735
- `Started ${PROXY_CONTAINER_NAME} (Traefik on :${hostPort}).`
736
- );
737
- }
738
- async function maybeStopProxy(opts = {}) {
739
- const docker = opts.docker ?? realDocker;
740
- const logger = opts.logger;
741
- const inspect = await docker([
742
- "network",
743
- "inspect",
744
- PROXY_NETWORK_NAME,
745
- "--format",
746
- "{{range $k, $v := .Containers}}{{$v.Name}}\n{{end}}"
747
- ]);
748
- if (inspect.exitCode !== 0) {
749
- return;
750
- }
751
- const others = inspect.stdout.split("\n").map((n) => n.trim()).filter((n) => n.length > 0 && n !== PROXY_CONTAINER_NAME);
752
- if (others.length > 0) return;
753
- await docker(["rm", "-f", PROXY_CONTAINER_NAME]);
754
- const netRm = await docker(["network", "rm", PROXY_NETWORK_NAME]);
755
- if (netRm.exitCode !== 0) {
756
- logger?.warn?.(
757
- `Could not remove docker network ${PROXY_NETWORK_NAME}: ${netRm.stderr.trim() || `exit ${netRm.exitCode}`}`
758
- );
759
- return;
644
+ if (byHost.has(host)) continue;
645
+ byHost.set(host, { host, provider: resolveProvider(host, repo.provider) });
760
646
  }
761
- logger?.info(
762
- `Stopped ${PROXY_CONTAINER_NAME} (no dev-containers with ports left).`
763
- );
647
+ return [...byHost.values()];
764
648
  }
765
-
766
- // src/proxy/dynamic.ts
767
- import { promises as fs4 } from "fs";
768
- import path3 from "path";
769
- async function writeDynamicConfig(name, ports, opts = {}) {
770
- if (ports.length === 0) {
771
- throw new Error(
772
- `writeDynamicConfig requires at least one port. For empty port lists, call removeDynamicConfig(${JSON.stringify(name)}).`
773
- );
649
+ function installCommandForOS(opts) {
650
+ switch (process.platform) {
651
+ case "darwin":
652
+ return cyan2(opts.brew);
653
+ case "win32":
654
+ return cyan2(opts.winget);
655
+ default:
656
+ if (opts.linuxBrew) return cyan2(opts.linuxBrew);
657
+ return `See ${opts.linuxDocsUrl} for package instructions.`;
774
658
  }
775
- const dir = proxyDynamicDir(opts.monocerosHome);
776
- await fs4.mkdir(dir, { recursive: true });
777
- const file = path3.join(dir, `${name}.yml`);
778
- await fs4.writeFile(file, renderDynamicConfig(name, ports), "utf8");
779
- return file;
780
- }
781
- async function removeDynamicConfig(name, opts = {}) {
782
- const file = path3.join(proxyDynamicDir(opts.monocerosHome), `${name}.yml`);
783
- await fs4.rm(file, { force: true });
784
659
  }
785
- function renderDynamicConfig(name, ports) {
660
+ function providerSetupHint(host, provider) {
661
+ if (provider === "github") {
662
+ const isSaas = host.toLowerCase() === "github.com";
663
+ const hostArg = isSaas ? "" : ` --hostname ${host}`;
664
+ const install = installCommandForOS({
665
+ brew: "brew install gh",
666
+ winget: "winget install --id GitHub.cli",
667
+ linuxBrew: "brew install gh",
668
+ linuxDocsUrl: "https://github.com/cli/cli#installation"
669
+ });
670
+ return {
671
+ title: `${host} \u2014 GitHub`,
672
+ body: [
673
+ "Install the GitHub CLI:",
674
+ install,
675
+ "",
676
+ "Then run once:",
677
+ cyan2(`gh auth login${hostArg}`),
678
+ cyan2(`gh auth setup-git${hostArg}`),
679
+ "",
680
+ "`gh auth login` walks through OAuth in your browser.",
681
+ "`gh auth setup-git` wires gh into git as a credential helper."
682
+ ].join("\n")
683
+ };
684
+ }
685
+ if (provider === "gitlab") {
686
+ const isSaas = host.toLowerCase() === "gitlab.com";
687
+ const hostArg = isSaas ? "" : ` --hostname ${host}`;
688
+ const install = installCommandForOS({
689
+ brew: "brew install glab",
690
+ winget: "winget install --id GLab.GLab",
691
+ linuxBrew: "brew install glab",
692
+ linuxDocsUrl: "https://gitlab.com/gitlab-org/cli#installation"
693
+ });
694
+ return {
695
+ title: `${host} \u2014 GitLab`,
696
+ body: [
697
+ "Install the GitLab CLI (glab):",
698
+ install,
699
+ "",
700
+ "Then run once:",
701
+ cyan2(`glab auth login${hostArg}`),
702
+ "",
703
+ "Choose `HTTPS` when asked for git-protocol, then accept",
704
+ '"Authenticate Git with your GitLab credentials" \u2014 glab',
705
+ "configures itself as the git credential helper."
706
+ ].join("\n")
707
+ };
708
+ }
709
+ if (provider === "bitbucket") {
710
+ const isCloud = host.toLowerCase() === "bitbucket.org";
711
+ if (isCloud) {
712
+ return {
713
+ title: `${host} \u2014 Bitbucket Cloud`,
714
+ body: [
715
+ "Bitbucket has no first-party CLI for git-credentials, so this",
716
+ "is a manual one-time setup. Generate an Atlassian API token at",
717
+ "https://id.atlassian.com/manage-profile/security/api-tokens",
718
+ "",
719
+ "Then store it via your OS credential helper:",
720
+ cyan2(
721
+ `git credential approve <<< $'protocol=https\\nhost=${host}\\nusername=<your-atlassian-email>\\npassword=<token>\\n'`
722
+ )
723
+ ].join("\n")
724
+ };
725
+ }
726
+ return {
727
+ title: `${host} \u2014 Bitbucket Data Center`,
728
+ body: [
729
+ "Bitbucket has no first-party CLI for git-credentials, so this",
730
+ "is a manual one-time setup. Generate a personal HTTP access",
731
+ `token in your Bitbucket UI: profile picture (top right on ${host})`,
732
+ "\u2192 Manage account \u2192 HTTP access tokens \u2192 Create token. Give it",
733
+ "at least repo-read + repo-write scopes for the repos you need.",
734
+ "",
735
+ "Then store it via your OS credential helper:",
736
+ cyan2(
737
+ `git credential approve <<< $'protocol=https\\nhost=${host}\\nusername=<your-bitbucket-username>\\npassword=<token>\\n'`
738
+ )
739
+ ].join("\n")
740
+ };
741
+ }
742
+ return {
743
+ title: `${host} \u2014 Gitea`,
744
+ body: [
745
+ "Gitea has no first-party CLI helper for git-credentials (the",
746
+ "`tea` CLI logs into its own config, not into your git credential",
747
+ "helper), so this is a manual one-time setup. Generate an access",
748
+ `token in your Gitea UI: profile picture (top right on ${host}) \u2192`,
749
+ 'Settings \u2192 Applications \u2192 "Generate New Token". Give it at',
750
+ "least the `read:repository` scope (add `write:repository` if you",
751
+ "need push from the container).",
752
+ "",
753
+ "Then store it via your OS credential helper:",
754
+ cyan2(
755
+ `git credential approve <<< $'protocol=https\\nhost=${host}\\nusername=<your-gitea-username>\\npassword=<token>\\n'`
756
+ )
757
+ ].join("\n")
758
+ };
759
+ }
760
+ function parseCredentialFillOutput(output) {
761
+ const result = {};
762
+ for (const line of output.split("\n")) {
763
+ const eqIdx = line.indexOf("=");
764
+ if (eqIdx <= 0) continue;
765
+ const key = line.slice(0, eqIdx);
766
+ const value = line.slice(eqIdx + 1);
767
+ if (key === "username") result.username = value;
768
+ if (key === "password") result.password = value;
769
+ }
770
+ return result;
771
+ }
772
+ function formatCredentialLine(host, username, password) {
773
+ const encUser = encodeURIComponent(username);
774
+ const encPass = encodeURIComponent(password);
775
+ return `https://${encUser}:${encPass}@${host}`;
776
+ }
777
+ async function collectGitCredentials(devContainerRoot, hosts, options = {}) {
778
+ const credsDir = path2.join(devContainerRoot, ".monoceros");
779
+ const credentialsPath = path2.join(credsDir, "git-credentials");
780
+ const spawnFn = options.spawn ?? realGitCredentialFill;
781
+ const logger = options.logger ?? { info: () => {
782
+ }, warn: () => {
783
+ } };
784
+ const lines = [];
785
+ const perHost = [];
786
+ for (const { host, provider } of hosts) {
787
+ if (provider === "unknown") {
788
+ perHost.push({
789
+ host,
790
+ provider: "github",
791
+ // placeholder — never rendered because pre-flight already bailed
792
+ status: "no-credentials",
793
+ detail: "provider not declared (internal: should not reach here)"
794
+ });
795
+ continue;
796
+ }
797
+ logger.info(`Fetching credentials for ${host} from host git\u2026`);
798
+ const input = `protocol=https
799
+ host=${host}
800
+
801
+ `;
802
+ let result;
803
+ try {
804
+ result = await spawnFn(input);
805
+ } catch (err) {
806
+ const detail = err instanceof Error ? err.message : String(err);
807
+ perHost.push({ host, provider, status: "spawn-error", detail });
808
+ continue;
809
+ }
810
+ if (result.exitCode !== 0) {
811
+ perHost.push({
812
+ host,
813
+ provider,
814
+ status: "non-zero-exit",
815
+ detail: `exit code ${result.exitCode}`
816
+ });
817
+ continue;
818
+ }
819
+ const { username, password } = parseCredentialFillOutput(result.stdout);
820
+ if (!username || !password) {
821
+ perHost.push({
822
+ host,
823
+ provider,
824
+ status: "no-credentials",
825
+ detail: "host credential helper returned no username/password"
826
+ });
827
+ continue;
828
+ }
829
+ lines.push(formatCredentialLine(host, username, password));
830
+ perHost.push({ host, provider, status: "ok", detail: "" });
831
+ }
832
+ await fs2.mkdir(credsDir, { recursive: true });
833
+ await fs2.writeFile(
834
+ credentialsPath,
835
+ lines.join("\n") + (lines.length > 0 ? "\n" : ""),
836
+ {
837
+ mode: 384
838
+ }
839
+ );
840
+ return {
841
+ hostsWritten: lines.length,
842
+ hostsSkipped: perHost.filter((p) => p.status !== "ok").length,
843
+ perHost,
844
+ credentialsPath
845
+ };
846
+ }
847
+ function formatMissingCredentialsError(missing) {
848
+ if (missing.length === 1) {
849
+ const m = missing[0];
850
+ const hint = providerSetupHint(m.host, m.provider);
851
+ return [
852
+ `Missing Git credentials: ${hint.title}`,
853
+ "",
854
+ hint.body,
855
+ "",
856
+ `Then re-run ${cyan2("monoceros apply")}.`
857
+ ].join("\n");
858
+ }
859
+ const lines = [
860
+ `Missing Git credentials for ${missing.length} hosts:`,
861
+ ""
862
+ ];
863
+ for (const m of missing) {
864
+ const hint = providerSetupHint(m.host, m.provider);
865
+ lines.push(hint.title);
866
+ lines.push("");
867
+ lines.push(hint.body);
868
+ lines.push("");
869
+ }
870
+ lines.push(`Then re-run ${cyan2("monoceros apply")}.`);
871
+ return lines.join("\n");
872
+ }
873
+ function formatUnknownProviderError(hosts) {
874
+ const sorted = [...new Set(hosts)].sort();
875
+ const lines = [
876
+ sorted.length === 1 ? `Unknown Git provider for host ${sorted[0]}.` : `Unknown Git provider for ${sorted.length} hosts: ${sorted.join(", ")}.`,
877
+ "",
878
+ "Monoceros auto-detects only github.com / gitlab.com / bitbucket.org.",
879
+ "For any other host (self-hosted GitLab, Gitea, Bitbucket Server, \u2026)",
880
+ "declare the provider explicitly in the yml. Edit the repo entry:",
881
+ "",
882
+ cyan2(" repos:"),
883
+ cyan2(` - url: https://${sorted[0]}/\u2026`),
884
+ cyan2(" provider: gitlab # or: github, bitbucket, gitea"),
885
+ "",
886
+ `Or re-add with ${cyan2("monoceros add-repo <name> <url> --provider=<github|gitlab|bitbucket|gitea>")}.`
887
+ ];
888
+ return lines.join("\n");
889
+ }
890
+
891
+ // src/devcontainer/locate-running.ts
892
+ import { spawn as spawn2 } from "child_process";
893
+ var realDockerLookup = (args) => {
894
+ return new Promise((resolve, reject) => {
895
+ const child = spawn2("docker", args, {
896
+ stdio: ["ignore", "pipe", "pipe"]
897
+ });
898
+ let stdout = "";
899
+ let stderr = "";
900
+ child.stdout.on("data", (chunk) => {
901
+ stdout += chunk.toString();
902
+ });
903
+ child.stderr.on("data", (chunk) => {
904
+ stderr += chunk.toString();
905
+ });
906
+ child.on("error", reject);
907
+ child.on(
908
+ "exit",
909
+ (code) => resolve({ stdout, stderr, exitCode: code ?? 0 })
910
+ );
911
+ });
912
+ };
913
+ async function findRunningContainerByLocalFolder(containerPath, opts = {}) {
914
+ const docker = opts.docker ?? realDockerLookup;
915
+ const result = await docker([
916
+ "ps",
917
+ "-q",
918
+ "--filter",
919
+ `label=devcontainer.local_folder=${containerPath}`,
920
+ "--filter",
921
+ "status=running"
922
+ ]);
923
+ if (result.exitCode !== 0) return null;
924
+ const ids = result.stdout.split("\n").map((s) => s.trim()).filter((s) => s.length > 0);
925
+ return ids[0] ?? null;
926
+ }
927
+ var realContainerExec = (containerId, argv) => {
928
+ return new Promise((resolve, reject) => {
929
+ const child = spawn2("docker", ["exec", containerId, ...argv], {
930
+ // Inherit stdio so live git output reaches the user.
931
+ stdio: ["ignore", "inherit", "inherit"]
932
+ });
933
+ child.on("error", reject);
934
+ child.on("exit", (code) => resolve({ exitCode: code ?? 0 }));
935
+ });
936
+ };
937
+
938
+ // src/config/global.ts
939
+ import { promises as fs3 } from "fs";
940
+ import { z as z2 } from "zod";
941
+ import { isMap, Pair, parseDocument as parseDocument2, Scalar, YAMLMap } from "yaml";
942
+ var SCHEMA_VERSION = 1;
943
+ var MonocerosConfigSchema = z2.object({
944
+ schemaVersion: z2.literal(SCHEMA_VERSION),
945
+ // .nullish() (= .optional().nullable()) on defaults so the shipped
946
+ // sample yml — where `defaults:` is uncommented but every sub-block
947
+ // is commented out — parses cleanly. YAML produces `defaults: null`
948
+ // in that case; without .nullish() the schema would reject it and
949
+ // we'd be back to forcing builders to comment-juggle three lines.
950
+ defaults: z2.object({
951
+ // .nullish() (not just .optional()) so the sample yml can leave
952
+ // `git:` uncommented as a category marker — YAML produces
953
+ // `git: null` for an empty mapping, which zod's plain
954
+ // `.optional()` would reject.
955
+ git: z2.object({
956
+ user: GitUserSchema.optional()
957
+ }).nullish(),
958
+ // .nullish() for the same reason as `git` — the sample keeps
959
+ // `features:` uncommented as a category marker.
960
+ features: z2.record(
961
+ z2.string().regex(
962
+ REGEX.featureRef,
963
+ "Invalid feature ref. Expected an OCI-image-style ref like 'ghcr.io/getmonoceros/monoceros-features/<name>:<tag>'."
964
+ ),
965
+ z2.record(z2.string(), FeatureOptionValueSchema)
966
+ ).nullish()
967
+ }).nullish(),
968
+ // Machine-global routing settings — one Traefik per builder, so
969
+ // host-port and similar live here rather than in any container yml.
970
+ // See ADR 0007.
971
+ routing: z2.object({
972
+ hostPort: z2.number().int().min(1).max(65535).optional().describe(
973
+ "Host port the Traefik singleton binds. Default 80. Set this when 80 is held by another service on your machine \u2014 URLs then become http://<name>.localhost:<port>/."
974
+ )
975
+ }).nullish()
976
+ });
977
+ async function readMonocerosConfig(opts = {}) {
978
+ const home = opts.monocerosHome ?? monocerosHome();
979
+ const filePath = monocerosConfigPath(home);
980
+ let text;
981
+ try {
982
+ text = await fs3.readFile(filePath, "utf8");
983
+ } catch {
984
+ return void 0;
985
+ }
986
+ const doc = parseDocument2(text, { prettyErrors: true });
987
+ if (doc.errors.length > 0) {
988
+ throw new Error(
989
+ `yaml parse error in ${filePath}: ${doc.errors[0].message}`
990
+ );
991
+ }
992
+ const result = MonocerosConfigSchema.safeParse(doc.toJS());
993
+ if (!result.success) {
994
+ const issues = result.error.issues.map((issue) => {
995
+ const where = issue.path.length > 0 ? issue.path.join(".") : "(root)";
996
+ return ` - ${where}: ${issue.message}`;
997
+ }).join("\n");
998
+ throw new Error(
999
+ `Invalid ${filePath}:
1000
+ ${issues}
1001
+
1002
+ See ${filePath.replace(
1003
+ /\.yml$/,
1004
+ ".sample.yml"
1005
+ )} for a valid example.`
1006
+ );
1007
+ }
1008
+ return result.data;
1009
+ }
1010
+ var DEFAULT_PROXY_HOST_PORT = 80;
1011
+ function proxyHostPort(config) {
1012
+ return config?.routing?.hostPort ?? DEFAULT_PROXY_HOST_PORT;
1013
+ }
1014
+ async function writeGlobalDefaultGitUser(user, opts = {}) {
1015
+ const home = opts.monocerosHome ?? monocerosHome();
1016
+ const filePath = monocerosConfigPath(home);
1017
+ let text;
1018
+ try {
1019
+ text = await fs3.readFile(filePath, "utf8");
1020
+ } catch {
1021
+ text = void 0;
1022
+ }
1023
+ if (text === void 0) {
1024
+ const fresh = [
1025
+ "# Optional \u2014 global defaults for monoceros containers.",
1026
+ "",
1027
+ "schemaVersion: 1",
1028
+ "",
1029
+ "defaults:",
1030
+ " git:",
1031
+ " user:",
1032
+ ` name: ${user.name}`,
1033
+ ` email: ${user.email}`,
1034
+ ""
1035
+ ].join("\n");
1036
+ await fs3.mkdir(home, { recursive: true });
1037
+ await fs3.writeFile(filePath, fresh, "utf8");
1038
+ return { filePath, created: true, alreadySet: false };
1039
+ }
1040
+ const doc = parseDocument2(text, { prettyErrors: true });
1041
+ if (doc.errors.length > 0) {
1042
+ throw new Error(
1043
+ `yaml parse error in ${filePath}: ${doc.errors[0].message}`
1044
+ );
1045
+ }
1046
+ const defaultsMap = ensureMap(doc, "defaults");
1047
+ const gitMap = ensureSubMapAtTop(defaultsMap, "git");
1048
+ const userMap = ensureSubMap(gitMap, "user");
1049
+ const existingName = userMap.get("name");
1050
+ const existingEmail = userMap.get("email");
1051
+ if (typeof existingName === "string" && existingName.length > 0 && typeof existingEmail === "string" && existingEmail.length > 0) {
1052
+ return { filePath, created: false, alreadySet: true };
1053
+ }
1054
+ relocateLeakedLeafComments(userMap, defaultsMap, "git");
1055
+ userMap.set("name", user.name);
1056
+ userMap.set("email", user.email);
1057
+ const newText = String(doc);
1058
+ await fs3.writeFile(filePath, newText, "utf8");
1059
+ return { filePath, created: false, alreadySet: false };
1060
+ }
1061
+ function ensureMap(doc, key) {
1062
+ const node = doc.get(key, true);
1063
+ if (node && isMap(node)) return node;
1064
+ const m = new YAMLMap();
1065
+ doc.set(key, m);
1066
+ return m;
1067
+ }
1068
+ function ensureSubMap(parent, key) {
1069
+ const node = parent.get(key, true);
1070
+ if (node && isMap(node)) return node;
1071
+ const m = new YAMLMap();
1072
+ parent.set(key, m);
1073
+ return m;
1074
+ }
1075
+ function ensureSubMapAtTop(parent, key) {
1076
+ const node = parent.get(key, true);
1077
+ if (node && isMap(node)) return node;
1078
+ const parentMaybe = parent;
1079
+ const newKey = new Scalar(key);
1080
+ if (parent.items.length > 0 && typeof parentMaybe.commentBefore === "string" && parentMaybe.commentBefore.length > 0) {
1081
+ const cleaned = stripCommentedKeySkeleton(parentMaybe.commentBefore, key);
1082
+ const blankMatch = cleaned.match(/\n[ \t]*\n/);
1083
+ let head;
1084
+ let tail;
1085
+ if (blankMatch && blankMatch.index !== void 0) {
1086
+ head = cleaned.slice(0, blankMatch.index);
1087
+ tail = cleaned.slice(blankMatch.index + blankMatch[0].length);
1088
+ } else {
1089
+ head = cleaned;
1090
+ tail = "";
1091
+ }
1092
+ if (head.length > 0) {
1093
+ newKey.commentBefore = head;
1094
+ if (parentMaybe.spaceBefore) newKey.spaceBefore = true;
1095
+ }
1096
+ if (tail.length > 0) {
1097
+ const firstKey = parent.items[0].key;
1098
+ if (firstKey && typeof firstKey === "object") {
1099
+ const existing = firstKey.commentBefore ?? "";
1100
+ firstKey.commentBefore = existing ? `${tail}
1101
+ ${existing}` : tail;
1102
+ firstKey.spaceBefore = true;
1103
+ }
1104
+ }
1105
+ parentMaybe.commentBefore = null;
1106
+ parentMaybe.spaceBefore = false;
1107
+ }
1108
+ const m = new YAMLMap();
1109
+ const pair = new Pair(newKey, m);
1110
+ parent.items.unshift(pair);
1111
+ return m;
1112
+ }
1113
+ function stripCommentedKeySkeleton(commentBody, key) {
1114
+ const lines = commentBody.split("\n");
1115
+ const headRe = new RegExp(`^ ${escapeRegExp(key)}:\\s*$`);
1116
+ const out = [];
1117
+ let i = 0;
1118
+ while (i < lines.length) {
1119
+ const line = lines[i];
1120
+ if (headRe.test(line)) {
1121
+ i++;
1122
+ while (i < lines.length && /^ {2,}\S/.test(lines[i])) {
1123
+ i++;
1124
+ }
1125
+ continue;
1126
+ }
1127
+ out.push(line);
1128
+ i++;
1129
+ }
1130
+ return out.join("\n");
1131
+ }
1132
+ function escapeRegExp(s) {
1133
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1134
+ }
1135
+ function relocateLeakedLeafComments(leafMap, parent, ancestorKey) {
1136
+ const items = parent.items;
1137
+ const ancestorIdx = items.findIndex((p) => {
1138
+ const k = p.key;
1139
+ const v = typeof k === "string" ? k : k?.value ?? null;
1140
+ return v === ancestorKey;
1141
+ });
1142
+ if (ancestorIdx < 0 || ancestorIdx + 1 >= items.length) return;
1143
+ const target = items[ancestorIdx + 1];
1144
+ for (const pair of leafMap.items) {
1145
+ const value = pair.value;
1146
+ if (!value || typeof value !== "object") continue;
1147
+ const leakedComment = value.comment;
1148
+ const leakedSpace = value.spaceBefore;
1149
+ if (!leakedComment && !leakedSpace) continue;
1150
+ if (leakedComment) {
1151
+ const targetKey = target.key;
1152
+ if (targetKey && typeof targetKey === "object") {
1153
+ const existing = targetKey.commentBefore ?? "";
1154
+ targetKey.commentBefore = existing ? `${leakedComment}
1155
+ ${existing}` : leakedComment;
1156
+ if (leakedSpace) targetKey.spaceBefore = true;
1157
+ }
1158
+ value.comment = null;
1159
+ }
1160
+ if (leakedSpace) value.spaceBefore = false;
1161
+ }
1162
+ }
1163
+
1164
+ // src/proxy/index.ts
1165
+ import { spawn as spawn3 } from "child_process";
1166
+ import { promises as fs4 } from "fs";
1167
+ import path3 from "path";
1168
+ var PROXY_CONTAINER_NAME = "monoceros-proxy";
1169
+ var PROXY_NETWORK_NAME = "monoceros-proxy";
1170
+ var TRAEFIK_IMAGE = "traefik:v3.3";
1171
+ var defaultDockerExec = (args) => {
1172
+ return new Promise((resolve, reject) => {
1173
+ const child = spawn3("docker", args, {
1174
+ stdio: ["ignore", "pipe", "pipe"]
1175
+ });
1176
+ let stdout = "";
1177
+ let stderr = "";
1178
+ child.stdout.on("data", (chunk) => {
1179
+ stdout += chunk.toString();
1180
+ });
1181
+ child.stderr.on("data", (chunk) => {
1182
+ stderr += chunk.toString();
1183
+ });
1184
+ child.on("error", reject);
1185
+ child.on(
1186
+ "exit",
1187
+ (code) => resolve({ stdout, stderr, exitCode: code ?? 0 })
1188
+ );
1189
+ });
1190
+ };
1191
+ var realDocker = defaultDockerExec;
1192
+ function proxyDynamicDir(home) {
1193
+ return path3.join(home ?? monocerosHome(), "traefik", "dynamic");
1194
+ }
1195
+ async function ensureProxy(opts = {}) {
1196
+ const docker = opts.docker ?? realDocker;
1197
+ const dyn = proxyDynamicDir(opts.monocerosHome);
1198
+ await fs4.mkdir(dyn, { recursive: true });
1199
+ const netInspect = await docker(["network", "inspect", PROXY_NETWORK_NAME]);
1200
+ if (netInspect.exitCode !== 0) {
1201
+ const create = await docker(["network", "create", PROXY_NETWORK_NAME]);
1202
+ if (create.exitCode !== 0) {
1203
+ throw new Error(
1204
+ `Could not create docker network ${PROXY_NETWORK_NAME}: ${create.stderr.trim() || `exit ${create.exitCode}`}`
1205
+ );
1206
+ }
1207
+ }
1208
+ const state = await docker([
1209
+ "inspect",
1210
+ "--format",
1211
+ "{{.State.Running}}",
1212
+ PROXY_CONTAINER_NAME
1213
+ ]);
1214
+ if (state.exitCode === 0) {
1215
+ if (state.stdout.trim() === "true") return;
1216
+ const start = await docker(["start", PROXY_CONTAINER_NAME]);
1217
+ if (start.exitCode !== 0) {
1218
+ throw new Error(
1219
+ `Could not start existing ${PROXY_CONTAINER_NAME} container: ${start.stderr.trim() || `exit ${start.exitCode}`}`
1220
+ );
1221
+ }
1222
+ return;
1223
+ }
1224
+ const hostPort = opts.hostPort ?? 80;
1225
+ const run = await docker([
1226
+ "run",
1227
+ "-d",
1228
+ "--name",
1229
+ PROXY_CONTAINER_NAME,
1230
+ "--network",
1231
+ PROXY_NETWORK_NAME,
1232
+ "-p",
1233
+ `${hostPort}:80`,
1234
+ "-v",
1235
+ `${dyn}:/etc/traefik/dynamic:ro`,
1236
+ "--label",
1237
+ "monoceros.role=proxy",
1238
+ TRAEFIK_IMAGE,
1239
+ "--entrypoints.web.address=:80",
1240
+ "--providers.file.directory=/etc/traefik/dynamic",
1241
+ "--providers.file.watch=true",
1242
+ "--providers.docker=false",
1243
+ "--api.dashboard=false",
1244
+ "--log.level=INFO"
1245
+ ]);
1246
+ if (run.exitCode !== 0) {
1247
+ throw new Error(
1248
+ `Could not start ${PROXY_CONTAINER_NAME}: ${run.stderr.trim() || `exit ${run.exitCode}`}`
1249
+ );
1250
+ }
1251
+ opts.logger?.info(
1252
+ `Started ${PROXY_CONTAINER_NAME} (Traefik on :${hostPort}).`
1253
+ );
1254
+ }
1255
+ async function maybeStopProxy(opts = {}) {
1256
+ const docker = opts.docker ?? realDocker;
1257
+ const logger = opts.logger;
1258
+ const inspect = await docker([
1259
+ "network",
1260
+ "inspect",
1261
+ PROXY_NETWORK_NAME,
1262
+ "--format",
1263
+ "{{range $k, $v := .Containers}}{{$v.Name}}\n{{end}}"
1264
+ ]);
1265
+ if (inspect.exitCode !== 0) {
1266
+ return;
1267
+ }
1268
+ const others = inspect.stdout.split("\n").map((n) => n.trim()).filter((n) => n.length > 0 && n !== PROXY_CONTAINER_NAME);
1269
+ if (others.length > 0) return;
1270
+ await docker(["rm", "-f", PROXY_CONTAINER_NAME]);
1271
+ const netRm = await docker(["network", "rm", PROXY_NETWORK_NAME]);
1272
+ if (netRm.exitCode !== 0) {
1273
+ logger?.warn?.(
1274
+ `Could not remove docker network ${PROXY_NETWORK_NAME}: ${netRm.stderr.trim() || `exit ${netRm.exitCode}`}`
1275
+ );
1276
+ return;
1277
+ }
1278
+ logger?.info(
1279
+ `Stopped ${PROXY_CONTAINER_NAME} (no dev-containers with ports left).`
1280
+ );
1281
+ }
1282
+
1283
+ // src/proxy/dynamic.ts
1284
+ import { promises as fs5 } from "fs";
1285
+ import path4 from "path";
1286
+ async function writeDynamicConfig(name, ports, opts = {}) {
1287
+ if (ports.length === 0) {
1288
+ throw new Error(
1289
+ `writeDynamicConfig requires at least one port. For empty port lists, call removeDynamicConfig(${JSON.stringify(name)}).`
1290
+ );
1291
+ }
1292
+ const dir = proxyDynamicDir(opts.monocerosHome);
1293
+ await fs5.mkdir(dir, { recursive: true });
1294
+ const file = path4.join(dir, `${name}.yml`);
1295
+ await fs5.writeFile(file, renderDynamicConfig(name, ports), "utf8");
1296
+ return file;
1297
+ }
1298
+ async function removeDynamicConfig(name, opts = {}) {
1299
+ const file = path4.join(proxyDynamicDir(opts.monocerosHome), `${name}.yml`);
1300
+ await fs5.rm(file, { force: true });
1301
+ }
1302
+ function renderDynamicConfig(name, ports) {
786
1303
  const lines = [];
787
1304
  lines.push("# Generated by Monoceros \u2014 do not edit by hand.");
788
1305
  lines.push(`# Container: ${name}`);
@@ -981,8 +1498,8 @@ function knownServices() {
981
1498
  }
982
1499
 
983
1500
  // src/create/scaffold.ts
984
- import { existsSync as existsSync2, readFileSync, promises as fs5 } from "fs";
985
- import path4 from "path";
1501
+ import { existsSync as existsSync2, readFileSync, promises as fs6 } from "fs";
1502
+ import path5 from "path";
986
1503
 
987
1504
  // src/util/ref.ts
988
1505
  var FEATURE_NAME_CHARSET = "[a-z0-9._-]+";
@@ -1153,7 +1670,7 @@ function resolveFeatures(opts) {
1153
1670
  if (match) {
1154
1671
  const name = match.name;
1155
1672
  const checkout = workbenchCheckoutRoot();
1156
- const localSourceDir = checkout ? path4.join(checkout, "images", "features", name) : null;
1673
+ const localSourceDir = checkout ? path5.join(checkout, "images", "features", name) : null;
1157
1674
  if (localSourceDir && existsSync2(localSourceDir)) {
1158
1675
  const { paths, files } = readPersistentHomeEntries(localSourceDir);
1159
1676
  resolved.push({
@@ -1178,7 +1695,7 @@ function resolveFeatures(opts) {
1178
1695
  return resolved;
1179
1696
  }
1180
1697
  function readPersistentHomeEntries(localSourceDir) {
1181
- const manifestPath = path4.join(localSourceDir, "devcontainer-feature.json");
1698
+ const manifestPath = path5.join(localSourceDir, "devcontainer-feature.json");
1182
1699
  try {
1183
1700
  const text = readFileSync(manifestPath, "utf8");
1184
1701
  const parsed = JSON.parse(text);
@@ -1266,7 +1783,10 @@ function buildDevcontainerJson(opts, dockerMode = "rootful") {
1266
1783
  }
1267
1784
  const mounts = [...homeMounts];
1268
1785
  const mountsField = mounts.length > 0 ? { mounts } : {};
1269
- const workspaceMountField = {};
1786
+ const workspaceMountField = {
1787
+ workspaceMount: `source=\${localWorkspaceFolder},target=/workspaces/${opts.name},type=bind,consistency=cached`,
1788
+ workspaceFolder: `/workspaces/${opts.name}`
1789
+ };
1270
1790
  const runArgs = ["--cap-add=NET_ADMIN"];
1271
1791
  if (ports.length > 0) {
1272
1792
  runArgs.push("--network=monoceros-proxy");
@@ -1347,6 +1867,23 @@ function buildCodeWorkspaceJson(opts) {
1347
1867
  }
1348
1868
  return { folders };
1349
1869
  }
1870
+ function mergeCodeWorkspace(existing, generated) {
1871
+ if (!existing || typeof existing !== "object" || Array.isArray(existing) || !Array.isArray(existing.folders)) {
1872
+ return { ...generated };
1873
+ }
1874
+ const existingObj = existing;
1875
+ const existingFolders = existingObj.folders;
1876
+ const existingPaths = new Set(
1877
+ existingFolders.map((f) => f && typeof f === "object" ? f.path : void 0).filter((p) => typeof p === "string")
1878
+ );
1879
+ const merged = [...existingFolders];
1880
+ for (const g of generated.folders) {
1881
+ if (!existingPaths.has(g.path)) merged.push(g);
1882
+ }
1883
+ const out = { ...existingObj };
1884
+ out.folders = merged;
1885
+ return out;
1886
+ }
1350
1887
  function buildPostCreateScript(opts) {
1351
1888
  const lines = [
1352
1889
  "#!/usr/bin/env bash",
@@ -1443,83 +1980,98 @@ function buildPostCreateScript(opts) {
1443
1980
  return lines.join("\n") + "\n";
1444
1981
  }
1445
1982
  async function writePostCreateScript(devcontainerDir, opts) {
1446
- const dest = path4.join(devcontainerDir, "post-create.sh");
1447
- await fs5.writeFile(dest, buildPostCreateScript(opts));
1448
- await fs5.chmod(dest, 493);
1983
+ const dest = path5.join(devcontainerDir, "post-create.sh");
1984
+ await fs6.writeFile(dest, buildPostCreateScript(opts));
1985
+ await fs6.chmod(dest, 493);
1449
1986
  }
1450
1987
  async function writeScaffold(opts, targetDir, scaffoldOpts = {}) {
1451
1988
  const dockerMode = scaffoldOpts.dockerMode ?? "rootful";
1452
- const devcontainerDir = path4.join(targetDir, ".devcontainer");
1453
- const monocerosDir = path4.join(targetDir, ".monoceros");
1454
- const projectsDir = path4.join(targetDir, "projects");
1455
- const homeDir = path4.join(targetDir, "home");
1456
- const dataDir = path4.join(targetDir, "data");
1457
- await fs5.mkdir(devcontainerDir, { recursive: true });
1458
- await fs5.mkdir(monocerosDir, { recursive: true });
1459
- await fs5.mkdir(projectsDir, { recursive: true });
1460
- await fs5.mkdir(homeDir, { recursive: true });
1989
+ const devcontainerDir = path5.join(targetDir, ".devcontainer");
1990
+ const monocerosDir = path5.join(targetDir, ".monoceros");
1991
+ const projectsDir = path5.join(targetDir, "projects");
1992
+ const homeDir = path5.join(targetDir, "home");
1993
+ const dataDir = path5.join(targetDir, "data");
1994
+ await fs6.mkdir(devcontainerDir, { recursive: true });
1995
+ await fs6.mkdir(monocerosDir, { recursive: true });
1996
+ await fs6.mkdir(projectsDir, { recursive: true });
1997
+ await fs6.mkdir(homeDir, { recursive: true });
1461
1998
  if (needsCompose(opts)) {
1462
- await fs5.mkdir(dataDir, { recursive: true });
1999
+ await fs6.mkdir(dataDir, { recursive: true });
1463
2000
  for (const svcId of opts.services) {
1464
2001
  const def = SERVICE_CATALOG[svcId];
1465
2002
  if (def?.dataMount) {
1466
- await fs5.mkdir(path4.join(dataDir, def.id), { recursive: true });
2003
+ await fs6.mkdir(path5.join(dataDir, def.id), { recursive: true });
1467
2004
  }
1468
2005
  }
1469
2006
  }
1470
- const containerGitignore = path4.join(targetDir, ".gitignore");
1471
- await fs5.writeFile(containerGitignore, "/home/\n/.monoceros/\n/data/\n");
1472
- const gitkeep = path4.join(projectsDir, ".gitkeep");
2007
+ const containerGitignore = path5.join(targetDir, ".gitignore");
2008
+ await fs6.writeFile(containerGitignore, "/home/\n/.monoceros/\n/data/\n");
2009
+ const gitkeep = path5.join(projectsDir, ".gitkeep");
1473
2010
  if (!existsSync2(gitkeep)) {
1474
- await fs5.writeFile(gitkeep, "");
2011
+ await fs6.writeFile(gitkeep, "");
1475
2012
  }
1476
- await fs5.writeFile(
1477
- path4.join(monocerosDir, ".gitignore"),
2013
+ await fs6.writeFile(
2014
+ path5.join(monocerosDir, ".gitignore"),
1478
2015
  "git-credentials*\ngitconfig\n"
1479
2016
  );
1480
2017
  const devcontainerJson = buildDevcontainerJson(opts, dockerMode);
1481
- await fs5.writeFile(
1482
- path4.join(devcontainerDir, "devcontainer.json"),
2018
+ await fs6.writeFile(
2019
+ path5.join(devcontainerDir, "devcontainer.json"),
1483
2020
  JSON.stringify(devcontainerJson, null, 2) + "\n"
1484
2021
  );
1485
- const featuresDir = path4.join(devcontainerDir, "features");
2022
+ const featuresDir = path5.join(devcontainerDir, "features");
1486
2023
  if (existsSync2(featuresDir)) {
1487
- await fs5.rm(featuresDir, { recursive: true, force: true });
2024
+ await fs6.rm(featuresDir, { recursive: true, force: true });
1488
2025
  }
1489
2026
  const resolvedFeatures = resolveFeatures(opts);
1490
2027
  for (const f of resolvedFeatures) {
1491
2028
  if (!f.localSourceDir || !f.localName) continue;
1492
- const dest = path4.join(featuresDir, f.localName);
1493
- await fs5.mkdir(dest, { recursive: true });
1494
- await fs5.cp(f.localSourceDir, dest, { recursive: true });
2029
+ const dest = path5.join(featuresDir, f.localName);
2030
+ await fs6.mkdir(dest, { recursive: true });
2031
+ await fs6.cp(f.localSourceDir, dest, { recursive: true });
1495
2032
  }
1496
2033
  for (const f of resolvedFeatures) {
1497
2034
  for (const sub of f.persistentHomePaths) {
1498
- await fs5.mkdir(path4.join(homeDir, sub), { recursive: true });
2035
+ await fs6.mkdir(path5.join(homeDir, sub), { recursive: true });
1499
2036
  }
1500
2037
  for (const entry2 of f.persistentHomeFiles) {
1501
- const filePath = path4.join(homeDir, entry2.path);
1502
- await fs5.mkdir(path4.dirname(filePath), { recursive: true });
2038
+ const filePath = path5.join(homeDir, entry2.path);
2039
+ await fs6.mkdir(path5.dirname(filePath), { recursive: true });
1503
2040
  if (!existsSync2(filePath)) {
1504
- await fs5.writeFile(filePath, entry2.initialContent);
2041
+ await fs6.writeFile(filePath, entry2.initialContent);
1505
2042
  }
1506
2043
  }
1507
2044
  }
1508
2045
  await writePostCreateScript(devcontainerDir, opts);
1509
- const composePath = path4.join(devcontainerDir, "compose.yaml");
2046
+ const composePath = path5.join(devcontainerDir, "compose.yaml");
1510
2047
  if (needsCompose(opts)) {
1511
- await fs5.writeFile(composePath, buildComposeYaml(opts, dockerMode));
2048
+ await fs6.writeFile(composePath, buildComposeYaml(opts, dockerMode));
1512
2049
  } else if (existsSync2(composePath)) {
1513
- await fs5.rm(composePath);
2050
+ await fs6.rm(composePath);
1514
2051
  }
1515
- await fs5.writeFile(
1516
- path4.join(targetDir, `${opts.name}.code-workspace`),
1517
- JSON.stringify(buildCodeWorkspaceJson(opts), null, 2) + "\n"
1518
- );
2052
+ const workspacePath = path5.join(targetDir, `${opts.name}.code-workspace`);
2053
+ let existingWorkspace;
2054
+ try {
2055
+ const raw = await fs6.readFile(workspacePath, "utf8");
2056
+ existingWorkspace = JSON.parse(raw);
2057
+ } catch {
2058
+ existingWorkspace = void 0;
2059
+ }
2060
+ const generated = buildCodeWorkspaceJson(opts);
2061
+ const merged = mergeCodeWorkspace(existingWorkspace, generated);
2062
+ await fs6.writeFile(workspacePath, JSON.stringify(merged, null, 2) + "\n");
1519
2063
  }
1520
2064
 
1521
2065
  // src/modify/yml.ts
1522
- import { isMap, isScalar, isSeq, YAMLMap, YAMLSeq } from "yaml";
2066
+ import {
2067
+ isMap as isMap2,
2068
+ isScalar,
2069
+ isSeq,
2070
+ Pair as Pair2,
2071
+ Scalar as Scalar2,
2072
+ YAMLMap as YAMLMap2,
2073
+ YAMLSeq
2074
+ } from "yaml";
1523
2075
  function ensureSeq(doc, key) {
1524
2076
  const existing = doc.get(key, true);
1525
2077
  if (existing && isSeq(existing)) return existing;
@@ -1558,12 +2110,108 @@ function addAptPackagesToDoc(doc, packages) {
1558
2110
  }
1559
2111
  return changed;
1560
2112
  }
2113
+ function setContainerGitUserInDoc(doc, user) {
2114
+ const gitNode = doc.get("git", true);
2115
+ let gitMap;
2116
+ let createdNew = false;
2117
+ if (gitNode && isMap2(gitNode)) {
2118
+ gitMap = gitNode;
2119
+ } else {
2120
+ gitMap = new YAMLMap2();
2121
+ insertTopLevelAfterName(doc, "git", gitMap, GIT_USER_HEADER_COMMENT);
2122
+ createdNew = true;
2123
+ }
2124
+ const userNode = gitMap.get("user", true);
2125
+ let userMap;
2126
+ if (userNode && isMap2(userNode)) {
2127
+ userMap = userNode;
2128
+ } else {
2129
+ userMap = new YAMLMap2();
2130
+ gitMap.set("user", userMap);
2131
+ }
2132
+ const currentName = userMap.get("name");
2133
+ const currentEmail = userMap.get("email");
2134
+ if (!createdNew && currentName === user.name && currentEmail === user.email) {
2135
+ return false;
2136
+ }
2137
+ userMap.set("name", user.name);
2138
+ userMap.set("email", user.email);
2139
+ relocateLeakedSectionComments(doc);
2140
+ return true;
2141
+ }
2142
+ function relocateLeakedSectionComments(doc) {
2143
+ const root = doc.contents;
2144
+ if (!root || !isMap2(root)) return;
2145
+ const items = root.items;
2146
+ for (let i = 0; i < items.length - 1; i++) {
2147
+ const here = items[i];
2148
+ const next = items[i + 1];
2149
+ const leak = takeTrailingLeafComment(here.value);
2150
+ if (!leak) continue;
2151
+ const nextKey = next.key;
2152
+ if (!nextKey || typeof nextKey !== "object") continue;
2153
+ const existing = nextKey.commentBefore ?? "";
2154
+ nextKey.commentBefore = existing ? `${leak}
2155
+ ${existing}` : leak;
2156
+ nextKey.spaceBefore = true;
2157
+ }
2158
+ }
2159
+ function takeTrailingLeafComment(node) {
2160
+ if (!node) return null;
2161
+ const c = node;
2162
+ if (typeof c.comment === "string" && c.comment.length > 0) {
2163
+ const blankMatch = c.comment.match(/\n[ \t]*\n/);
2164
+ if (blankMatch && blankMatch.index !== void 0) {
2165
+ const tail = c.comment.slice(blankMatch.index + blankMatch[0].length);
2166
+ c.comment = c.comment.slice(0, blankMatch.index);
2167
+ if (tail.length > 0) return tail;
2168
+ }
2169
+ }
2170
+ if (isMap2(node) && node.items.length > 0) {
2171
+ for (let i = node.items.length - 1; i >= 0; i--) {
2172
+ const value = node.items[i].value;
2173
+ const found = takeTrailingLeafComment(value);
2174
+ if (found) return found;
2175
+ }
2176
+ }
2177
+ if (isSeq(node) && node.items.length > 0) {
2178
+ for (let i = node.items.length - 1; i >= 0; i--) {
2179
+ const found = takeTrailingLeafComment(node.items[i]);
2180
+ if (found) return found;
2181
+ }
2182
+ }
2183
+ return null;
2184
+ }
2185
+ function insertTopLevelAfterName(doc, key, value, comment) {
2186
+ const root = doc.contents;
2187
+ if (!root || !isMap2(root)) {
2188
+ doc.set(key, value);
2189
+ return;
2190
+ }
2191
+ const keyScalar = new Scalar2(key);
2192
+ if (comment) {
2193
+ keyScalar.commentBefore = comment;
2194
+ keyScalar.spaceBefore = true;
2195
+ }
2196
+ const pair = new Pair2(keyScalar, value);
2197
+ const nameIdx = root.items.findIndex((p) => {
2198
+ const k = p.key;
2199
+ return (typeof k === "string" ? k : k?.value ?? null) === "name";
2200
+ });
2201
+ const insertAt = nameIdx >= 0 ? nameIdx + 1 : Math.min(1, root.items.length);
2202
+ root.items.splice(insertAt, 0, pair);
2203
+ }
2204
+ var GIT_USER_HEADER_COMMENT = [
2205
+ " Git committer identity for this container. Overrides",
2206
+ " monoceros-config.yml's defaults.git.user. Applies to every repo",
2207
+ " below unless that repo declares its own `git.user` override."
2208
+ ].join("\n");
1561
2209
  function portOfItem(item) {
1562
2210
  const scalar = scalarValue(item);
1563
2211
  if (typeof scalar === "number" && Number.isInteger(scalar)) {
1564
2212
  return scalar;
1565
2213
  }
1566
- if (isMap(item)) {
2214
+ if (isMap2(item)) {
1567
2215
  const p = item.get("port");
1568
2216
  if (typeof p === "number" && Number.isInteger(p)) return p;
1569
2217
  }
@@ -1571,8 +2219,8 @@ function portOfItem(item) {
1571
2219
  }
1572
2220
  function ensureRoutingMap(doc) {
1573
2221
  const existing = doc.get("routing", true);
1574
- if (existing && isMap(existing)) return existing;
1575
- const map = new YAMLMap();
2222
+ if (existing && isMap2(existing)) return existing;
2223
+ const map = new YAMLMap2();
1576
2224
  doc.set("routing", map);
1577
2225
  return map;
1578
2226
  }
@@ -1616,7 +2264,7 @@ function addPortsToDoc(doc, ports) {
1616
2264
  }
1617
2265
  function removePortsFromDoc(doc, ports) {
1618
2266
  const routing = doc.get("routing", true);
1619
- if (!routing || !isMap(routing)) return false;
2267
+ if (!routing || !isMap2(routing)) return false;
1620
2268
  const seq = routing.get("ports", true);
1621
2269
  if (!seq || !isSeq(seq)) return false;
1622
2270
  const targets = new Set(ports);
@@ -1643,7 +2291,7 @@ function addInstallUrlToDoc(doc, url) {
1643
2291
  function addFeatureToDoc(doc, ref, options = {}) {
1644
2292
  const seq = ensureSeq(doc, "features");
1645
2293
  for (const item of seq.items) {
1646
- if (!isMap(item)) continue;
2294
+ if (!isMap2(item)) continue;
1647
2295
  const itemRef = item.get("ref");
1648
2296
  if (itemRef !== ref) continue;
1649
2297
  const itemJs = item.toJS(doc);
@@ -1655,7 +2303,7 @@ function addFeatureToDoc(doc, ref, options = {}) {
1655
2303
  `Feature ${ref} is already configured with different options. Remove it first (\`monoceros remove-feature ${ref}\`) before re-adding.`
1656
2304
  );
1657
2305
  }
1658
- const entry2 = new YAMLMap();
2306
+ const entry2 = new YAMLMap2();
1659
2307
  entry2.set("ref", ref);
1660
2308
  if (Object.keys(options).length > 0) {
1661
2309
  entry2.set("options", options);
@@ -1666,16 +2314,16 @@ function addFeatureToDoc(doc, ref, options = {}) {
1666
2314
  function addRepoToDoc(doc, repo) {
1667
2315
  const seq = ensureSeq(doc, "repos");
1668
2316
  for (const item of seq.items) {
1669
- if (!isMap(item)) continue;
2317
+ if (!isMap2(item)) continue;
1670
2318
  const url = item.get("url");
1671
2319
  if (url !== repo.url) continue;
1672
2320
  const existingPath = item.get("path");
1673
2321
  const effectivePath = typeof existingPath === "string" ? existingPath : deriveRepoName(url);
1674
2322
  if (effectivePath !== repo.path) continue;
1675
2323
  const existingGit = item.get("git", true);
1676
- const existingUser = existingGit && isMap(existingGit) ? existingGit.get("user", true) : null;
1677
- const existingName = existingUser && isMap(existingUser) ? existingUser.get("name") : null;
1678
- const existingEmail = existingUser && isMap(existingUser) ? existingUser.get("email") : null;
2324
+ const existingUser = existingGit && isMap2(existingGit) ? existingGit.get("user", true) : null;
2325
+ const existingName = existingUser && isMap2(existingUser) ? existingUser.get("name") : null;
2326
+ const existingEmail = existingUser && isMap2(existingUser) ? existingUser.get("email") : null;
1679
2327
  const existingGitUser = typeof existingName === "string" && typeof existingEmail === "string" ? { name: existingName, email: existingEmail } : void 0;
1680
2328
  const sameGitUser = (existingGitUser?.name ?? null) === (repo.gitUser?.name ?? null) && (existingGitUser?.email ?? null) === (repo.gitUser?.email ?? null);
1681
2329
  const existingProvider = item.get("provider");
@@ -1684,8 +2332,8 @@ function addRepoToDoc(doc, repo) {
1684
2332
  return false;
1685
2333
  }
1686
2334
  if (repo.gitUser) {
1687
- const gitMap = new YAMLMap();
1688
- const userMap = new YAMLMap();
2335
+ const gitMap = new YAMLMap2();
2336
+ const userMap = new YAMLMap2();
1689
2337
  userMap.set("name", repo.gitUser.name);
1690
2338
  userMap.set("email", repo.gitUser.email);
1691
2339
  gitMap.set("user", userMap);
@@ -1698,16 +2346,18 @@ function addRepoToDoc(doc, repo) {
1698
2346
  } else {
1699
2347
  item.delete("provider");
1700
2348
  }
2349
+ relocateLeakedSectionComments(doc);
1701
2350
  return true;
1702
2351
  }
1703
- const entry2 = new YAMLMap();
2352
+ const entry2 = new YAMLMap2();
1704
2353
  entry2.set("url", repo.url);
1705
- if (repo.path !== deriveRepoName(repo.url)) {
2354
+ const persistPath = repo.path !== deriveRepoName(repo.url);
2355
+ if (persistPath) {
1706
2356
  entry2.set("path", repo.path);
1707
2357
  }
1708
2358
  if (repo.gitUser) {
1709
- const gitMap = new YAMLMap();
1710
- const userMap = new YAMLMap();
2359
+ const gitMap = new YAMLMap2();
2360
+ const userMap = new YAMLMap2();
1711
2361
  userMap.set("name", repo.gitUser.name);
1712
2362
  userMap.set("email", repo.gitUser.email);
1713
2363
  gitMap.set("user", userMap);
@@ -1716,7 +2366,20 @@ function addRepoToDoc(doc, repo) {
1716
2366
  if (repo.provider) {
1717
2367
  entry2.set("provider", repo.provider);
1718
2368
  }
2369
+ const hintLines = [];
2370
+ if (!persistPath) hintLines.push(" path:");
2371
+ if (!repo.provider) hintLines.push(" provider:");
2372
+ if (!repo.gitUser) {
2373
+ hintLines.push(" git:");
2374
+ hintLines.push(" user:");
2375
+ hintLines.push(" name:");
2376
+ hintLines.push(" email:");
2377
+ }
2378
+ if (hintLines.length > 0) {
2379
+ entry2.comment = hintLines.join("\n");
2380
+ }
1719
2381
  seq.add(entry2);
2382
+ relocateLeakedSectionComments(doc);
1720
2383
  return true;
1721
2384
  }
1722
2385
  function removeLanguageFromDoc(doc, lang) {
@@ -1741,7 +2404,7 @@ function removeInstallUrlFromDoc(doc, url) {
1741
2404
  function removeFeatureFromDoc(doc, ref) {
1742
2405
  const seq = doc.get("features", true);
1743
2406
  if (!seq || !isSeq(seq)) return false;
1744
- const idx = seq.items.findIndex((i) => isMap(i) && i.get("ref") === ref);
2407
+ const idx = seq.items.findIndex((i) => isMap2(i) && i.get("ref") === ref);
1745
2408
  if (idx < 0) return false;
1746
2409
  seq.items.splice(idx, 1);
1747
2410
  pruneEmptySeq(doc, "features");
@@ -1751,11 +2414,11 @@ function removeRepoFromDoc(doc, urlOrPath) {
1751
2414
  const seq = doc.get("repos", true);
1752
2415
  if (!seq || !isSeq(seq)) return false;
1753
2416
  const idx = seq.items.findIndex((item) => {
1754
- if (!isMap(item)) return false;
2417
+ if (!isMap2(item)) return false;
1755
2418
  const url = item.get("url");
1756
2419
  if (url === urlOrPath) return true;
1757
- const path15 = item.get("path");
1758
- const effectivePath = typeof path15 === "string" ? path15 : typeof url === "string" ? deriveRepoName(url) : void 0;
2420
+ const path16 = item.get("path");
2421
+ const effectivePath = typeof path16 === "string" ? path16 : typeof url === "string" ? deriveRepoName(url) : void 0;
1759
2422
  return effectivePath === urlOrPath;
1760
2423
  });
1761
2424
  if (idx < 0) return false;
@@ -1805,7 +2468,7 @@ async function runAddRepo(input) {
1805
2468
  "Missing repo URL. Usage: monoceros add-repo <containername> <url>."
1806
2469
  );
1807
2470
  }
1808
- const path15 = (input.path ?? deriveRepoName(url)).trim();
2471
+ const path16 = (input.path ?? deriveRepoName(url)).trim();
1809
2472
  const hasName = typeof input.gitName === "string" && input.gitName.trim().length > 0;
1810
2473
  const hasEmail = typeof input.gitEmail === "string" && input.gitEmail.trim().length > 0;
1811
2474
  if (hasName !== hasEmail) {
@@ -1834,7 +2497,7 @@ async function runAddRepo(input) {
1834
2497
  const providerToWrite = !canonical && explicitProvider ? explicitProvider : void 0;
1835
2498
  const entry2 = {
1836
2499
  url,
1837
- path: path15,
2500
+ path: path16,
1838
2501
  ...hasName && hasEmail ? {
1839
2502
  gitUser: {
1840
2503
  name: input.gitName.trim(),
@@ -1843,7 +2506,117 @@ async function runAddRepo(input) {
1843
2506
  } : {},
1844
2507
  ...providerToWrite ? { provider: providerToWrite } : {}
1845
2508
  };
1846
- return mutate(input, (doc) => addRepoToDoc(doc, entry2));
2509
+ const result = await mutate(input, (doc) => addRepoToDoc(doc, entry2));
2510
+ if (result.status === "updated") {
2511
+ await tryCloneInRunningContainer(input, entry2);
2512
+ }
2513
+ return result;
2514
+ }
2515
+ async function tryCloneInRunningContainer(input, entry2) {
2516
+ const home = input.monocerosHome ?? monocerosHome();
2517
+ const root = containerDir(input.name, home);
2518
+ const logger = input.logger ?? defaultLogger();
2519
+ let containerId;
2520
+ try {
2521
+ containerId = await findRunningContainerByLocalFolder(root, {
2522
+ ...input.containerLookupDocker ? { docker: input.containerLookupDocker } : {}
2523
+ });
2524
+ } catch (err) {
2525
+ logger.warn(
2526
+ `Could not check whether the container is running: ${err instanceof Error ? err.message : String(err)}. The yml is updated \u2014 run \`monoceros apply ${input.name}\` to clone.`
2527
+ );
2528
+ return;
2529
+ }
2530
+ if (!containerId) {
2531
+ logger.info(
2532
+ `Container not running \u2014 yml updated only. Clone happens on \`monoceros apply ${input.name}\`.`
2533
+ );
2534
+ return;
2535
+ }
2536
+ let urlHost;
2537
+ try {
2538
+ urlHost = new URL(entry2.url).hostname;
2539
+ } catch {
2540
+ logger.warn(
2541
+ `Cannot parse URL host from ${entry2.url}. The yml is updated \u2014 clone manually inside the container or rerun with a fixed URL.`
2542
+ );
2543
+ return;
2544
+ }
2545
+ const provider = resolveProvider(urlHost, entry2.provider);
2546
+ if (provider === "unknown") {
2547
+ logger.warn(
2548
+ `Could not resolve provider for host ${urlHost}. The yml is updated; clone happens at the next \`monoceros apply\` if you set the provider.`
2549
+ );
2550
+ return;
2551
+ }
2552
+ try {
2553
+ const credsResult = await collectGitCredentials(
2554
+ root,
2555
+ [{ host: urlHost, provider }],
2556
+ {
2557
+ ...input.credentialsSpawn ? { spawn: input.credentialsSpawn } : {},
2558
+ logger: { info: () => {
2559
+ }, warn: (m) => logger.warn(m) }
2560
+ }
2561
+ );
2562
+ const status = credsResult.perHost.find((h) => h.host === urlHost);
2563
+ if (!status || status.status !== "ok") {
2564
+ const detail = status?.detail ? `: ${status.detail}` : "";
2565
+ logger.warn(
2566
+ `No HTTPS credentials available for ${urlHost}${detail}. The yml is updated; set up credentials (e.g. \`gh auth login\`) and re-run \`monoceros apply ${input.name}\` or rerun this add-repo.`
2567
+ );
2568
+ return;
2569
+ }
2570
+ } catch (err) {
2571
+ logger.warn(
2572
+ `Credential fetch for ${urlHost} failed: ${err instanceof Error ? err.message : String(err)}. The yml is updated.`
2573
+ );
2574
+ return;
2575
+ }
2576
+ const containerName = input.name;
2577
+ const targetRel = `projects/${entry2.path}`;
2578
+ const parentRel = entry2.path.includes("/") ? `projects/${entry2.path.split("/").slice(0, -1).join("/")}` : "projects";
2579
+ const credentialsFile = `/workspaces/${containerName}/.monoceros/git-credentials`;
2580
+ const credentialHelper = `store --file=${credentialsFile}`;
2581
+ const script = [
2582
+ `set -eu`,
2583
+ `cd /workspaces/${containerName}`,
2584
+ `if [ -d ${shquote(targetRel)} ]; then`,
2585
+ ` echo "[add-repo] ${targetRel} already exists \u2014 skipping clone."`,
2586
+ ` exit 0`,
2587
+ `fi`,
2588
+ `mkdir -p ${shquote(parentRel)}`,
2589
+ `git -c ${shquote(`credential.helper=${credentialHelper}`)} clone ${shquote(entry2.url)} ${shquote(targetRel)}`
2590
+ ];
2591
+ if (entry2.gitUser) {
2592
+ script.push(
2593
+ `git -C ${shquote(targetRel)} config user.name ${shquote(entry2.gitUser.name)}`,
2594
+ `git -C ${shquote(targetRel)} config user.email ${shquote(entry2.gitUser.email)}`
2595
+ );
2596
+ }
2597
+ const execFn = input.containerExec ?? realContainerExec;
2598
+ let exit;
2599
+ try {
2600
+ exit = await execFn(containerId, ["bash", "-c", script.join("\n")]);
2601
+ } catch (err) {
2602
+ logger.warn(
2603
+ `In-container clone for ${entry2.url} failed: ${err instanceof Error ? err.message : String(err)}. The yml is updated; \`monoceros apply ${input.name}\` retries.`
2604
+ );
2605
+ return;
2606
+ }
2607
+ if (exit.exitCode !== 0) {
2608
+ logger.warn(
2609
+ `In-container clone for ${entry2.url} exited ${exit.exitCode}. The yml is updated; \`monoceros apply ${input.name}\` retries.`
2610
+ );
2611
+ return;
2612
+ }
2613
+ logger.info(
2614
+ `Cloned ${entry2.url} into /workspaces/${containerName}/${targetRel} inside the running container.`
2615
+ );
2616
+ void path6;
2617
+ }
2618
+ function shquote(value) {
2619
+ return `'${value.replace(/'/g, `'\\''`)}'`;
1847
2620
  }
1848
2621
  function normalizeProvider(raw) {
1849
2622
  if (typeof raw !== "string") return void 0;
@@ -1979,7 +2752,7 @@ async function mutate(opts, apply) {
1979
2752
  const logger = opts.logger ?? defaultLogger();
1980
2753
  let oldText;
1981
2754
  try {
1982
- oldText = await fs6.readFile(ymlPath, "utf8");
2755
+ oldText = await fs7.readFile(ymlPath, "utf8");
1983
2756
  } catch {
1984
2757
  throw new Error(
1985
2758
  `No such config: ${ymlPath}. Run \`monoceros init <template> ${opts.name}\` first.`
@@ -2003,7 +2776,7 @@ async function mutate(opts, apply) {
2003
2776
  return { status: "aborted" };
2004
2777
  }
2005
2778
  }
2006
- await fs6.writeFile(ymlPath, newText, "utf8");
2779
+ await fs7.writeFile(ymlPath, newText, "utf8");
2007
2780
  logger.success(`Updated ${ymlPath}.`);
2008
2781
  logger.info(
2009
2782
  `Run \`monoceros apply ${opts.name}\` to rebuild the dev-container and pick up the change.`
@@ -2261,179 +3034,21 @@ function printSecurityWarning(url) {
2261
3034
  w(
2262
3035
  " 2. Verify the maintainer is who you think they are (HTTPS cert, repo)."
2263
3036
  );
2264
- w(" 3. Ideally, vendor the install steps as `add-apt-packages` or");
2265
- w(
2266
- " `add-feature` instead \u2014 those reference signed/versioned artifacts."
2267
- );
2268
- w("");
2269
- }
2270
-
2271
- // src/commands/add-repo.ts
2272
- import { defineCommand as defineCommand4 } from "citty";
2273
- import { consola as consola5 } from "consola";
2274
- var addRepoCommand = defineCommand4({
2275
- meta: {
2276
- name: "add-repo",
2277
- group: "edit",
2278
- description: "Add a git repo to the container config. Cloned into projects/<path>/ on container build. Idempotent \u2014 existing project subfolders are left alone. Destination path derived from URL by default; override with --path (supports nested subfolders like apps/web). Branches/PRs are git-level concerns: clone, then `git checkout` inside the container."
2279
- },
2280
- args: {
2281
- name: {
2282
- type: "positional",
2283
- description: "Container name (yml in $MONOCEROS_HOME/container-configs/).",
2284
- required: true
2285
- },
2286
- url: {
2287
- type: "positional",
2288
- description: "Git URL (HTTPS or SSH/git@ form). E.g. https://github.com/foo/bar.git, git@github.com:foo/bar.git.",
2289
- required: true
2290
- },
2291
- path: {
2292
- type: "string",
2293
- description: "Destination under projects/. Subfolders via `/` (e.g. apps/web). Default: URL-derived single segment (bar.git \u2192 bar)."
2294
- },
2295
- "git-name": {
2296
- type: "string",
2297
- description: "Per-repo git committer name. Overrides the container-level git.user.name for this repo only. Pair with --git-email."
2298
- },
2299
- "git-email": {
2300
- type: "string",
2301
- description: "Per-repo git committer email. Overrides the container-level git.user.email for this repo only. Pair with --git-name."
2302
- },
2303
- provider: {
2304
- type: "string",
2305
- description: "Git provider for credential-helper guidance: github | gitlab | bitbucket. Required when the URL host is not github.com, gitlab.com, or bitbucket.org \u2014 Monoceros uses this to suggest the right CLI (gh / glab / Atlassian token) on missing credentials."
2306
- },
2307
- yes: {
2308
- type: "boolean",
2309
- description: "Skip the interactive confirmation and apply the diff.",
2310
- alias: ["y"],
2311
- default: false
2312
- }
2313
- },
2314
- async run({ args }) {
2315
- try {
2316
- const result = await runAddRepo({
2317
- name: args.name,
2318
- url: args.url,
2319
- ...typeof args.path === "string" ? { path: args.path } : {},
2320
- ...typeof args["git-name"] === "string" ? { gitName: args["git-name"] } : {},
2321
- ...typeof args["git-email"] === "string" ? { gitEmail: args["git-email"] } : {},
2322
- ...typeof args.provider === "string" ? { provider: args.provider } : {},
2323
- yes: args.yes
2324
- });
2325
- process.exit(result.status === "aborted" ? 1 : 0);
2326
- } catch (err) {
2327
- consola5.error(err instanceof Error ? err.message : String(err));
2328
- process.exit(1);
2329
- }
2330
- }
2331
- });
2332
-
2333
- // src/commands/add-language.ts
2334
- import { defineCommand as defineCommand5 } from "citty";
2335
- import { consola as consola6 } from "consola";
2336
- var addLanguageCommand = defineCommand5({
2337
- meta: {
2338
- name: "add-language",
2339
- group: "edit",
2340
- description: "Add a language toolchain (devcontainer feature) to the container config. Idempotent, prints a diff before writing."
2341
- },
2342
- args: {
2343
- name: {
2344
- type: "positional",
2345
- description: "Container name (yml in $MONOCEROS_HOME/container-configs/).",
2346
- required: true
2347
- },
2348
- language: {
2349
- type: "positional",
2350
- description: "Language identifier from the feature whitelist (e.g. python, java, rust).",
2351
- required: true
2352
- },
2353
- yes: {
2354
- type: "boolean",
2355
- description: "Skip the interactive confirmation and apply the diff.",
2356
- alias: ["y"],
2357
- default: false
2358
- }
2359
- },
2360
- async run({ args }) {
2361
- try {
2362
- const result = await runAddLanguage({
2363
- name: args.name,
2364
- language: args.language,
2365
- yes: args.yes
2366
- });
2367
- process.exit(result.status === "aborted" ? 1 : 0);
2368
- } catch (err) {
2369
- consola6.error(err instanceof Error ? err.message : String(err));
2370
- process.exit(1);
2371
- }
2372
- }
2373
- });
2374
-
2375
- // src/commands/add-port.ts
2376
- import { defineCommand as defineCommand6 } from "citty";
2377
- import { consola as consola7 } from "consola";
2378
- var addPortCommand = defineCommand6({
2379
- meta: {
2380
- name: "add-port",
2381
- group: "edit",
2382
- description: "Add one or more ports to the container config so they become reachable from the host via Traefik (`<container>.localhost` / `<container>-<port>.localhost`). Pass port numbers after `--` (e.g. `monoceros add-port sandbox -- 3000 5173 6006`). Idempotent. Persisted in the yml so later `monoceros apply` runs restore the routes. Pass `--default` together with a single port to make it the bare `<container>.localhost` route \u2014 the port is inserted at position 0 (or moved there if it already exists)."
2383
- },
2384
- args: {
2385
- name: {
2386
- type: "positional",
2387
- description: "Container name (yml in $MONOCEROS_HOME/container-configs/).",
2388
- required: true
2389
- },
2390
- yes: {
2391
- type: "boolean",
2392
- description: "Skip the interactive confirmation and apply the diff.",
2393
- alias: ["y"],
2394
- default: false
2395
- },
2396
- default: {
2397
- type: "boolean",
2398
- description: "Make the (single) port the new default route at `<container>.localhost`. Inserts the port at position 0 of `routing.ports`, or moves it there if it already exists. Errors when more than one port is passed.",
2399
- default: false
2400
- }
2401
- },
2402
- async run({ args }) {
2403
- const tokens = [...getInnerArgs()];
2404
- if (tokens.length === 0) {
2405
- consola7.error(
2406
- "No ports given. Usage: `monoceros add-port <containername> [--yes] [--default] -- <port> [<port> \u2026]`."
2407
- );
2408
- process.exit(1);
2409
- }
2410
- try {
2411
- const result = await runAddPort({
2412
- name: args.name,
2413
- ports: tokens.map(coerceToken),
2414
- yes: args.yes,
2415
- asDefault: args.default
2416
- });
2417
- process.exit(result.status === "aborted" ? 1 : 0);
2418
- } catch (err) {
2419
- consola7.error(err instanceof Error ? err.message : String(err));
2420
- process.exit(1);
2421
- }
2422
- }
2423
- });
2424
- function coerceToken(raw) {
2425
- const n = Number(raw);
2426
- return Number.isFinite(n) ? n : raw;
3037
+ w(" 3. Ideally, vendor the install steps as `add-apt-packages` or");
3038
+ w(
3039
+ " `add-feature` instead \u2014 those reference signed/versioned artifacts."
3040
+ );
3041
+ w("");
2427
3042
  }
2428
3043
 
2429
- // src/commands/add-service.ts
2430
- import { defineCommand as defineCommand7 } from "citty";
2431
- import { consola as consola8 } from "consola";
2432
- var addServiceCommand = defineCommand7({
3044
+ // src/commands/add-repo.ts
3045
+ import { defineCommand as defineCommand4 } from "citty";
3046
+ import { consola as consola5 } from "consola";
3047
+ var addRepoCommand = defineCommand4({
2433
3048
  meta: {
2434
- name: "add-service",
3049
+ name: "add-repo",
2435
3050
  group: "edit",
2436
- description: "Add a compose service (postgres, mysql, redis, \u2026) to the container config. Idempotent, prints a diff before writing."
3051
+ description: "Add a git repo to the container config. Cloned into projects/<path>/ on container build. Idempotent \u2014 existing project subfolders are left alone. Destination path derived from URL by default; override with --path (supports nested subfolders like apps/web). Branches/PRs are git-level concerns: clone, then `git checkout` inside the container."
2437
3052
  },
2438
3053
  args: {
2439
3054
  name: {
@@ -2441,11 +3056,27 @@ var addServiceCommand = defineCommand7({
2441
3056
  description: "Container name (yml in $MONOCEROS_HOME/container-configs/).",
2442
3057
  required: true
2443
3058
  },
2444
- service: {
3059
+ url: {
2445
3060
  type: "positional",
2446
- description: "Service identifier (postgres, mysql, redis).",
3061
+ description: "Git URL (HTTPS or SSH/git@ form). E.g. https://github.com/foo/bar.git, git@github.com:foo/bar.git.",
2447
3062
  required: true
2448
3063
  },
3064
+ path: {
3065
+ type: "string",
3066
+ description: "Destination under projects/. Subfolders via `/` (e.g. apps/web). Default: URL-derived single segment (bar.git \u2192 bar)."
3067
+ },
3068
+ "git-name": {
3069
+ type: "string",
3070
+ description: "Per-repo git committer name. Overrides the container-level git.user.name for this repo only. Pair with --git-email."
3071
+ },
3072
+ "git-email": {
3073
+ type: "string",
3074
+ description: "Per-repo git committer email. Overrides the container-level git.user.email for this repo only. Pair with --git-name."
3075
+ },
3076
+ provider: {
3077
+ type: "string",
3078
+ description: "Git provider for credential-helper guidance: github | gitlab | bitbucket. Required when the URL host is not github.com, gitlab.com, or bitbucket.org \u2014 Monoceros uses this to suggest the right CLI (gh / glab / Atlassian token) on missing credentials."
3079
+ },
2449
3080
  yes: {
2450
3081
  type: "boolean",
2451
3082
  description: "Skip the interactive confirmation and apply the diff.",
@@ -2455,680 +3086,513 @@ var addServiceCommand = defineCommand7({
2455
3086
  },
2456
3087
  async run({ args }) {
2457
3088
  try {
2458
- const result = await runAddService({
3089
+ const result = await runAddRepo({
2459
3090
  name: args.name,
2460
- service: args.service,
2461
- yes: args.yes
2462
- });
2463
- process.exit(result.status === "aborted" ? 1 : 0);
2464
- } catch (err) {
2465
- consola8.error(err instanceof Error ? err.message : String(err));
2466
- process.exit(1);
2467
- }
2468
- }
2469
- });
2470
-
2471
- // src/commands/apply.ts
2472
- import { defineCommand as defineCommand8 } from "citty";
2473
-
2474
- // src/apply/index.ts
2475
- import { existsSync as existsSync4, promises as fs10 } from "fs";
2476
- import { consola as consola11 } from "consola";
2477
-
2478
- // src/config/state.ts
2479
- import { promises as fs7 } from "fs";
2480
- import path5 from "path";
2481
- function buildStateFile(opts) {
2482
- return {
2483
- schemaVersion: CONFIG_SCHEMA_VERSION,
2484
- origin: opts.origin,
2485
- monocerosCliVersion: opts.cliVersion,
2486
- materializedAt: (opts.now ?? /* @__PURE__ */ new Date()).toISOString()
2487
- };
2488
- }
2489
- function stateFilePath(targetDir) {
2490
- return path5.join(targetDir, ".monoceros", "state.json");
2491
- }
2492
- async function readStateFile(targetDir) {
2493
- try {
2494
- const content = await fs7.readFile(stateFilePath(targetDir), "utf8");
2495
- return JSON.parse(content);
2496
- } catch {
2497
- return void 0;
2498
- }
2499
- }
2500
- async function writeStateFile(targetDir, state) {
2501
- const monocerosDir = path5.join(targetDir, ".monoceros");
2502
- await fs7.mkdir(monocerosDir, { recursive: true });
2503
- await fs7.writeFile(
2504
- stateFilePath(targetDir),
2505
- JSON.stringify(state, null, 2) + "\n"
2506
- );
2507
- }
2508
-
2509
- // src/config/transform.ts
2510
- function solutionConfigToCreateOptions(config, featureDefaults = {}) {
2511
- const featureRecord = {};
2512
- for (const entry2 of config.features) {
2513
- const defaults = featureDefaults[entry2.ref] ?? {};
2514
- featureRecord[entry2.ref] = { ...defaults, ...entry2.options ?? {} };
2515
- }
2516
- const result = {
2517
- name: config.name,
2518
- languages: [...config.languages],
2519
- services: [...config.services]
2520
- };
2521
- if (config.externalServices.postgres !== void 0) {
2522
- result.postgresUrl = config.externalServices.postgres;
2523
- }
2524
- if (config.aptPackages.length > 0) {
2525
- result.aptPackages = [...config.aptPackages];
2526
- }
2527
- if (Object.keys(featureRecord).length > 0) {
2528
- result.features = featureRecord;
2529
- }
2530
- if (config.installUrls.length > 0) {
2531
- result.installUrls = [...config.installUrls];
2532
- }
2533
- if (config.repos.length > 0) {
2534
- result.repos = config.repos.map((r) => ({
2535
- url: r.url,
2536
- // `path` is optional in the yml; CreateOptions requires it.
2537
- // When the yml omits `path`, fall back to the URL-derived
2538
- // single-segment default (`https://.../foo.git` → `foo`),
2539
- // which lands the clone at `projects/foo/`.
2540
- path: r.path ?? deriveRepoName(r.url),
2541
- ...r.git?.user ? { gitUser: { name: r.git.user.name, email: r.git.user.email } } : {},
2542
- ...r.provider ? { provider: r.provider } : {}
2543
- }));
2544
- }
2545
- const routingPorts = config.routing?.ports ?? [];
2546
- if (routingPorts.length > 0) {
2547
- const seen = /* @__PURE__ */ new Set();
2548
- const ports = [];
2549
- for (const entry2 of routingPorts) {
2550
- const n = portNumber(entry2);
2551
- if (seen.has(n)) continue;
2552
- seen.add(n);
2553
- ports.push(n);
2554
- }
2555
- result.ports = ports;
2556
- }
2557
- if (config.routing?.vscodeAutoForward !== void 0) {
2558
- result.vscodeAutoForward = config.routing.vscodeAutoForward;
2559
- }
2560
- return result;
2561
- }
2562
-
2563
- // src/util/format.ts
2564
- var ESC = "\x1B[";
2565
- var ANSI_BOLD2 = `${ESC}1m`;
2566
- var ANSI_UNDERLINE2 = `${ESC}4m`;
2567
- var ANSI_CYAN2 = `${ESC}36m`;
2568
- var ANSI_GREY2 = `${ESC}90m`;
2569
- var ANSI_RESET2 = `${ESC}0m`;
2570
- function makeWrap(isTty2) {
2571
- return (s, ...codes) => isTty2 ? codes.join("") + s + ANSI_RESET2 : s;
2572
- }
2573
- function makePalette(isTty2) {
2574
- const wrap = makeWrap(isTty2);
2575
- return {
2576
- bold: (s) => wrap(s, ANSI_BOLD2),
2577
- underline: (s) => wrap(s, ANSI_UNDERLINE2),
2578
- cyan: (s) => wrap(s, ANSI_CYAN2),
2579
- dim: (s) => wrap(s, ANSI_GREY2),
2580
- sectionLine: (label) => wrap(`\u25B8 ${label}`, ANSI_BOLD2, ANSI_UNDERLINE2)
2581
- };
2582
- }
2583
- function colorsFor(stream) {
2584
- return makePalette(stream.isTTY ?? false);
2585
- }
2586
- var stderrPalette = makePalette(process.stderr.isTTY ?? false);
2587
- var bold2 = stderrPalette.bold;
2588
- var underline2 = stderrPalette.underline;
2589
- var cyan2 = stderrPalette.cyan;
2590
- var dim = stderrPalette.dim;
2591
- var sectionLine = stderrPalette.sectionLine;
2592
-
2593
- // src/devcontainer/compose.ts
2594
- import { spawn as spawn3 } from "child_process";
2595
- import { existsSync as existsSync3 } from "fs";
2596
- import path7 from "path";
2597
- import { consola as consola9 } from "consola";
2598
-
2599
- // src/util/mask-secrets.ts
2600
- import { Transform } from "stream";
2601
- var PATTERNS = [
2602
- // Atlassian Cloud API token. Starts with literal `ATATT3xFf` plus
2603
- // a long URL-safe-base64 tail. Tightened to that prefix to avoid
2604
- // matching unrelated all-caps words.
2605
- { name: "atlassian-api", re: /ATATT3xFf[A-Za-z0-9+/=_-]{20,}/g },
2606
- // Bitbucket Cloud app password.
2607
- { name: "bitbucket-app", re: /ATBB[A-Za-z0-9+/=_-]{20,}/g },
2608
- // GitHub PAT (classic), OAuth, user, server, refresh — all share
2609
- // the `gh<lower-letter>_<base62>` shape per GitHub's token format.
2610
- { name: "github-token", re: /gh[a-z]_[A-Za-z0-9]{20,}/g },
2611
- // GitHub fine-grained PAT.
2612
- { name: "github-pat", re: /github_pat_[A-Za-z0-9_]{20,}/g },
2613
- // Anthropic API key.
2614
- { name: "anthropic-api", re: /sk-ant-[A-Za-z0-9_-]{20,}/g }
2615
- ];
2616
- function maskSecrets(text) {
2617
- let result = text;
2618
- for (const { re } of PATTERNS) {
2619
- result = result.replace(re, maskOne);
2620
- }
2621
- return result;
2622
- }
2623
- function maskOne(token) {
2624
- if (token.length <= 12) return token;
2625
- return `${token.slice(0, 5)}\u2026${token.slice(-6)}`;
2626
- }
2627
- function createSecretMaskStream() {
2628
- let buffer = "";
2629
- return new Transform({
2630
- decodeStrings: true,
2631
- transform(chunk, _enc, cb) {
2632
- const text = typeof chunk === "string" ? chunk : chunk.toString("utf8");
2633
- buffer += text;
2634
- const lastNewline = buffer.lastIndexOf("\n");
2635
- if (lastNewline === -1) {
2636
- cb(null);
2637
- return;
2638
- }
2639
- const flushable = buffer.slice(0, lastNewline + 1);
2640
- buffer = buffer.slice(lastNewline + 1);
2641
- cb(null, maskSecrets(flushable));
2642
- },
2643
- flush(cb) {
2644
- if (buffer.length > 0) {
2645
- const tail = maskSecrets(buffer);
2646
- buffer = "";
2647
- cb(null, tail);
2648
- return;
2649
- }
2650
- cb(null);
2651
- }
2652
- });
2653
- }
2654
-
2655
- // src/devcontainer/cli.ts
2656
- import { spawn as spawn2 } from "child_process";
2657
- import { readFileSync as readFileSync2 } from "fs";
2658
- import { createRequire } from "module";
2659
- import path6 from "path";
2660
- var require_ = createRequire(import.meta.url);
2661
- var cachedBinaryPath = null;
2662
- function devcontainerCliPath() {
2663
- if (cachedBinaryPath) return cachedBinaryPath;
2664
- const pkgJsonPath = require_.resolve("@devcontainers/cli/package.json");
2665
- const pkg = JSON.parse(readFileSync2(pkgJsonPath, "utf8"));
2666
- const binEntry = typeof pkg.bin === "string" ? pkg.bin : pkg.bin?.devcontainer ?? "";
2667
- if (!binEntry) {
2668
- throw new Error("Could not resolve @devcontainers/cli bin entry.");
2669
- }
2670
- cachedBinaryPath = path6.resolve(path6.dirname(pkgJsonPath), binEntry);
2671
- return cachedBinaryPath;
2672
- }
2673
- var spawnDevcontainer = (args, cwd, options = {}) => {
2674
- const binPath = devcontainerCliPath();
2675
- return new Promise((resolve, reject) => {
2676
- if (options.interactive) {
2677
- const child2 = spawn2(process.execPath, [binPath, ...args], {
2678
- cwd,
2679
- stdio: "inherit"
3091
+ url: args.url,
3092
+ ...typeof args.path === "string" ? { path: args.path } : {},
3093
+ ...typeof args["git-name"] === "string" ? { gitName: args["git-name"] } : {},
3094
+ ...typeof args["git-email"] === "string" ? { gitEmail: args["git-email"] } : {},
3095
+ ...typeof args.provider === "string" ? { provider: args.provider } : {},
3096
+ yes: args.yes
2680
3097
  });
2681
- child2.on("error", reject);
2682
- child2.on("exit", (code) => resolve(code ?? 0));
2683
- return;
3098
+ process.exit(result.status === "aborted" ? 1 : 0);
3099
+ } catch (err) {
3100
+ consola5.error(err instanceof Error ? err.message : String(err));
3101
+ process.exit(1);
2684
3102
  }
2685
- const child = spawn2(process.execPath, [binPath, ...args], {
2686
- cwd,
2687
- stdio: ["ignore", "pipe", "pipe"]
2688
- });
2689
- if (options.quiet) {
2690
- const stdoutChunks = [];
2691
- const stderrChunks = [];
2692
- child.stdout?.on("data", (chunk) => stdoutChunks.push(chunk));
2693
- child.stderr?.on("data", (chunk) => stderrChunks.push(chunk));
2694
- child.on("error", reject);
2695
- child.on("exit", (code) => {
2696
- const exitCode = code ?? 0;
2697
- if (exitCode !== 0) {
2698
- process.stderr.write(
2699
- maskSecrets(Buffer.concat(stderrChunks).toString("utf8"))
2700
- );
2701
- process.stderr.write(
2702
- maskSecrets(Buffer.concat(stdoutChunks).toString("utf8"))
2703
- );
2704
- }
2705
- resolve(exitCode);
3103
+ }
3104
+ });
3105
+
3106
+ // src/commands/add-language.ts
3107
+ import { defineCommand as defineCommand5 } from "citty";
3108
+ import { consola as consola6 } from "consola";
3109
+ var addLanguageCommand = defineCommand5({
3110
+ meta: {
3111
+ name: "add-language",
3112
+ group: "edit",
3113
+ description: "Add a language toolchain (devcontainer feature) to the container config. Idempotent, prints a diff before writing."
3114
+ },
3115
+ args: {
3116
+ name: {
3117
+ type: "positional",
3118
+ description: "Container name (yml in $MONOCEROS_HOME/container-configs/).",
3119
+ required: true
3120
+ },
3121
+ language: {
3122
+ type: "positional",
3123
+ description: "Language identifier from the feature whitelist (e.g. python, java, rust).",
3124
+ required: true
3125
+ },
3126
+ yes: {
3127
+ type: "boolean",
3128
+ description: "Skip the interactive confirmation and apply the diff.",
3129
+ alias: ["y"],
3130
+ default: false
3131
+ }
3132
+ },
3133
+ async run({ args }) {
3134
+ try {
3135
+ const result = await runAddLanguage({
3136
+ name: args.name,
3137
+ language: args.language,
3138
+ yes: args.yes
2706
3139
  });
2707
- return;
3140
+ process.exit(result.status === "aborted" ? 1 : 0);
3141
+ } catch (err) {
3142
+ consola6.error(err instanceof Error ? err.message : String(err));
3143
+ process.exit(1);
2708
3144
  }
2709
- child.stdout?.pipe(createSecretMaskStream()).pipe(process.stdout);
2710
- child.stderr?.pipe(createSecretMaskStream()).pipe(process.stderr);
2711
- child.on("error", reject);
2712
- child.on("exit", (code) => resolve(code ?? 0));
2713
- });
2714
- };
2715
-
2716
- // src/devcontainer/compose.ts
2717
- var spawnDockerCompose = (args, cwd) => {
2718
- return new Promise((resolve, reject) => {
2719
- const child = spawn3("docker", ["compose", ...args], {
2720
- cwd,
2721
- stdio: ["inherit", "pipe", "pipe"]
2722
- });
2723
- child.stdout?.pipe(createSecretMaskStream()).pipe(process.stdout);
2724
- child.stderr?.pipe(createSecretMaskStream()).pipe(process.stderr);
2725
- child.on("error", reject);
2726
- child.on("exit", (code) => resolve(code ?? 0));
2727
- });
2728
- };
2729
- var spawnBash = (args, cwd) => {
2730
- return new Promise((resolve, reject) => {
2731
- const child = spawn3("bash", args, {
2732
- cwd,
2733
- stdio: ["inherit", "pipe", "pipe"]
2734
- });
2735
- child.stdout?.pipe(createSecretMaskStream()).pipe(process.stdout);
2736
- child.stderr?.pipe(createSecretMaskStream()).pipe(process.stderr);
2737
- child.on("error", reject);
2738
- child.on("exit", (code) => resolve(code ?? 0));
2739
- });
2740
- };
2741
- function composeProjectName(root) {
2742
- return `${path7.basename(root)}_devcontainer`;
2743
- }
2744
- function resolveCompose(root) {
2745
- if (!existsSync3(path7.join(root, ".devcontainer"))) {
2746
- throw new Error(
2747
- `No .devcontainer/ at ${root}. Run \`monoceros apply <name>\` first.`
2748
- );
2749
3145
  }
2750
- const composeFile = path7.join(root, ".devcontainer", "compose.yaml");
2751
- if (!existsSync3(composeFile)) {
2752
- throw new Error(
2753
- `No compose.yaml at ${composeFile}. \`start\` / \`stop\` / \`status\` / \`logs\` require services configured via \`monoceros add-service <name> <svc>\`. Use \`monoceros shell <name>\` to enter the container directly.`
2754
- );
3146
+ });
3147
+
3148
+ // src/commands/add-port.ts
3149
+ import { defineCommand as defineCommand6 } from "citty";
3150
+ import { consola as consola7 } from "consola";
3151
+ var addPortCommand = defineCommand6({
3152
+ meta: {
3153
+ name: "add-port",
3154
+ group: "edit",
3155
+ description: "Add one or more ports to the container config so they become reachable from the host via Traefik (`<container>.localhost` / `<container>-<port>.localhost`). Pass port numbers after `--` (e.g. `monoceros add-port sandbox -- 3000 5173 6006`). Idempotent. Persisted in the yml so later `monoceros apply` runs restore the routes. Pass `--default` together with a single port to make it the bare `<container>.localhost` route \u2014 the port is inserted at position 0 (or moved there if it already exists)."
3156
+ },
3157
+ args: {
3158
+ name: {
3159
+ type: "positional",
3160
+ description: "Container name (yml in $MONOCEROS_HOME/container-configs/).",
3161
+ required: true
3162
+ },
3163
+ yes: {
3164
+ type: "boolean",
3165
+ description: "Skip the interactive confirmation and apply the diff.",
3166
+ alias: ["y"],
3167
+ default: false
3168
+ },
3169
+ default: {
3170
+ type: "boolean",
3171
+ description: "Make the (single) port the new default route at `<container>.localhost`. Inserts the port at position 0 of `routing.ports`, or moves it there if it already exists. Errors when more than one port is passed.",
3172
+ default: false
3173
+ }
3174
+ },
3175
+ async run({ args }) {
3176
+ const tokens = [...getInnerArgs()];
3177
+ if (tokens.length === 0) {
3178
+ consola7.error(
3179
+ "No ports given. Usage: `monoceros add-port <containername> [--yes] [--default] -- <port> [<port> \u2026]`."
3180
+ );
3181
+ process.exit(1);
3182
+ }
3183
+ try {
3184
+ const result = await runAddPort({
3185
+ name: args.name,
3186
+ ports: tokens.map(coerceToken),
3187
+ yes: args.yes,
3188
+ asDefault: args.default
3189
+ });
3190
+ process.exit(result.status === "aborted" ? 1 : 0);
3191
+ } catch (err) {
3192
+ consola7.error(err instanceof Error ? err.message : String(err));
3193
+ process.exit(1);
3194
+ }
2755
3195
  }
2756
- return { composeFile, projectName: composeProjectName(root) };
2757
- }
2758
- async function runComposeAction(buildSubArgs, opts) {
2759
- const { composeFile, projectName } = resolveCompose(opts.root);
2760
- const spawnFn = opts.spawn ?? spawnDockerCompose;
2761
- const subArgs = buildSubArgs(opts.service);
2762
- return spawnFn(["-f", composeFile, "-p", projectName, ...subArgs], opts.root);
2763
- }
2764
- async function runStart(opts) {
2765
- resolveCompose(opts.root);
2766
- const logger = opts.logger ?? { info: (msg) => consola9.info(msg) };
2767
- const spawnFn = opts.spawn ?? spawnDevcontainer;
2768
- logger.info(`Bringing devcontainer up at ${opts.root}\u2026`);
2769
- return spawnFn(
2770
- ["up", "--workspace-folder", opts.root, "--mount-workspace-git-root=false"],
2771
- opts.root
2772
- );
3196
+ });
3197
+ function coerceToken(raw) {
3198
+ const n = Number(raw);
3199
+ return Number.isFinite(n) ? n : raw;
2773
3200
  }
2774
- async function runContainerCycle(root, opts) {
2775
- const { hasCompose, logger } = opts;
2776
- if (hasCompose) {
2777
- const projectName = composeProjectName(root);
2778
- logger.info(
2779
- `Force-removing existing ${projectName} containers (volumes preserved)\u2026`
2780
- );
2781
- const cleanupSpawn = opts.cleanupSpawn ?? spawnBash;
2782
- const script = [
2783
- `set -u`,
2784
- `echo "[cleanup] checking project ${projectName}\u2026"`,
2785
- `by_label=$(docker ps -aq --filter "label=com.docker.compose.project=${projectName}" 2>/dev/null || true)`,
2786
- `by_name=$(docker ps -aq --filter "name=^${projectName}-" 2>/dev/null || true)`,
2787
- `to_remove=$(printf "%s\\n%s\\n" "$by_label" "$by_name" | sort -u | grep -v "^$" || true)`,
2788
- `if [ -n "$to_remove" ]; then echo "[cleanup] removing: $(echo $to_remove | tr "\\n" " ")"; docker rm -f $to_remove >/dev/null || true; else echo "[cleanup] no containers to remove"; fi`,
2789
- `docker network rm ${projectName}_default 2>/dev/null && echo "[cleanup] network ${projectName}_default removed" || echo "[cleanup] network ${projectName}_default not present"`,
2790
- `remaining_label=$(docker ps -aq --filter "label=com.docker.compose.project=${projectName}" 2>/dev/null || true)`,
2791
- `remaining_name=$(docker ps -aq --filter "name=^${projectName}-" 2>/dev/null || true)`,
2792
- `if [ -n "$remaining_label" ] || [ -n "$remaining_name" ]; then echo "" >&2; echo "ERROR: containers under project ${projectName} reappeared after removal." >&2; echo "This typically means VS Code's Remote Containers extension is connected to" >&2; echo "this devcontainer and auto-recreated it. Close the dev container session" >&2; echo "in VS Code (Cmd+Shift+P \u2192 'Dev Containers: Close Remote Connection')" >&2; echo "and retry \\\`monoceros apply\\\`." >&2; exit 1; fi`,
2793
- `echo "[cleanup] done"`
2794
- ].join("; ");
2795
- const cleanupCode = await cleanupSpawn(["-c", script], root);
2796
- if (cleanupCode !== 0) return cleanupCode;
2797
- return runStart({
2798
- root,
2799
- ...opts.devcontainerSpawn ? { spawn: opts.devcontainerSpawn } : {},
2800
- logger
2801
- });
2802
- }
2803
- logger.info(`Recreating image-mode devcontainer at ${root}\u2026`);
2804
- const spawnFn = opts.devcontainerSpawn ?? spawnDevcontainer;
2805
- return spawnFn(
2806
- [
2807
- "up",
2808
- "--workspace-folder",
2809
- root,
2810
- "--mount-workspace-git-root=false",
2811
- "--remove-existing-container"
2812
- ],
2813
- root
2814
- );
3201
+
3202
+ // src/commands/add-service.ts
3203
+ import { defineCommand as defineCommand7 } from "citty";
3204
+ import { consola as consola8 } from "consola";
3205
+ var addServiceCommand = defineCommand7({
3206
+ meta: {
3207
+ name: "add-service",
3208
+ group: "edit",
3209
+ description: "Add a compose service (postgres, mysql, redis, \u2026) to the container config. Idempotent, prints a diff before writing."
3210
+ },
3211
+ args: {
3212
+ name: {
3213
+ type: "positional",
3214
+ description: "Container name (yml in $MONOCEROS_HOME/container-configs/).",
3215
+ required: true
3216
+ },
3217
+ service: {
3218
+ type: "positional",
3219
+ description: "Service identifier (postgres, mysql, redis).",
3220
+ required: true
3221
+ },
3222
+ yes: {
3223
+ type: "boolean",
3224
+ description: "Skip the interactive confirmation and apply the diff.",
3225
+ alias: ["y"],
3226
+ default: false
3227
+ }
3228
+ },
3229
+ async run({ args }) {
3230
+ try {
3231
+ const result = await runAddService({
3232
+ name: args.name,
3233
+ service: args.service,
3234
+ yes: args.yes
3235
+ });
3236
+ process.exit(result.status === "aborted" ? 1 : 0);
3237
+ } catch (err) {
3238
+ consola8.error(err instanceof Error ? err.message : String(err));
3239
+ process.exit(1);
3240
+ }
3241
+ }
3242
+ });
3243
+
3244
+ // src/commands/apply.ts
3245
+ import { defineCommand as defineCommand8 } from "citty";
3246
+
3247
+ // src/apply/index.ts
3248
+ import { existsSync as existsSync4, promises as fs10 } from "fs";
3249
+ import { consola as consola11 } from "consola";
3250
+
3251
+ // src/config/state.ts
3252
+ import { promises as fs8 } from "fs";
3253
+ import path7 from "path";
3254
+ function buildStateFile(opts) {
3255
+ return {
3256
+ schemaVersion: CONFIG_SCHEMA_VERSION,
3257
+ origin: opts.origin,
3258
+ monocerosCliVersion: opts.cliVersion,
3259
+ materializedAt: (opts.now ?? /* @__PURE__ */ new Date()).toISOString()
3260
+ };
2815
3261
  }
2816
- function runStop(opts) {
2817
- return runComposeAction(
2818
- (service) => ["stop", ...service ? [service] : []],
2819
- opts
2820
- );
3262
+ function stateFilePath(targetDir) {
3263
+ return path7.join(targetDir, ".monoceros", "state.json");
2821
3264
  }
2822
- function runStatus(opts) {
2823
- return runComposeAction(
2824
- (service) => ["ps", ...service ? [service] : []],
2825
- opts
2826
- );
3265
+ async function readStateFile(targetDir) {
3266
+ try {
3267
+ const content = await fs8.readFile(stateFilePath(targetDir), "utf8");
3268
+ return JSON.parse(content);
3269
+ } catch {
3270
+ return void 0;
3271
+ }
2827
3272
  }
2828
- function runLogs(opts) {
2829
- const follow = opts.follow ?? true;
2830
- return runComposeAction(
2831
- (service) => [
2832
- "logs",
2833
- ...follow ? ["-f"] : [],
2834
- ...service ? [service] : []
2835
- ],
2836
- opts
3273
+ async function writeStateFile(targetDir, state) {
3274
+ const monocerosDir = path7.join(targetDir, ".monoceros");
3275
+ await fs8.mkdir(monocerosDir, { recursive: true });
3276
+ await fs8.writeFile(
3277
+ stateFilePath(targetDir),
3278
+ JSON.stringify(state, null, 2) + "\n"
2837
3279
  );
2838
3280
  }
2839
3281
 
2840
- // src/devcontainer/credentials.ts
2841
- import { spawn as spawn4 } from "child_process";
2842
- import { promises as fs8 } from "fs";
2843
- import path8 from "path";
2844
- var realGitCredentialFill = (input) => {
2845
- return new Promise((resolve, reject) => {
2846
- const child = spawn4("git", ["credential", "fill"], {
2847
- stdio: ["pipe", "pipe", "inherit"],
2848
- env: {
2849
- ...process.env,
2850
- GIT_TERMINAL_PROMPT: "0",
2851
- GIT_ASKPASS: "",
2852
- SSH_ASKPASS: ""
2853
- }
2854
- });
2855
- let stdout = "";
2856
- child.stdout.on("data", (chunk) => {
2857
- stdout += chunk.toString();
2858
- });
2859
- child.on("error", reject);
2860
- child.on("exit", (code) => resolve({ stdout, exitCode: code ?? 0 }));
2861
- child.stdin.write(input);
2862
- child.stdin.end();
2863
- });
2864
- };
2865
- function resolveProvider(host, explicit) {
2866
- const canonical = KNOWN_PROVIDER_HOSTS[host.toLowerCase()];
2867
- if (canonical) return canonical;
2868
- return explicit ?? "unknown";
2869
- }
2870
- function uniqueHttpsHosts(repos) {
2871
- const byHost = /* @__PURE__ */ new Map();
2872
- for (const repo of repos) {
2873
- if (!repo.url.startsWith("https://")) continue;
2874
- let host;
2875
- try {
2876
- host = new URL(repo.url).hostname;
2877
- } catch {
2878
- continue;
2879
- }
2880
- if (byHost.has(host)) continue;
2881
- byHost.set(host, { host, provider: resolveProvider(host, repo.provider) });
3282
+ // src/config/transform.ts
3283
+ function solutionConfigToCreateOptions(config, featureDefaults = {}) {
3284
+ const featureRecord = {};
3285
+ for (const entry2 of config.features) {
3286
+ const defaults = featureDefaults[entry2.ref] ?? {};
3287
+ const containerOpts = Object.fromEntries(
3288
+ Object.entries(entry2.options ?? {}).filter(([, v]) => v !== "")
3289
+ );
3290
+ featureRecord[entry2.ref] = { ...defaults, ...containerOpts };
2882
3291
  }
2883
- return [...byHost.values()];
2884
- }
2885
- function installCommandForOS(opts) {
2886
- switch (process.platform) {
2887
- case "darwin":
2888
- return cyan2(opts.brew);
2889
- case "win32":
2890
- return cyan2(opts.winget);
2891
- default:
2892
- if (opts.linuxBrew) return cyan2(opts.linuxBrew);
2893
- return `See ${opts.linuxDocsUrl} for package instructions.`;
3292
+ const result = {
3293
+ name: config.name,
3294
+ languages: [...config.languages],
3295
+ services: [...config.services]
3296
+ };
3297
+ if (config.externalServices.postgres !== void 0) {
3298
+ result.postgresUrl = config.externalServices.postgres;
2894
3299
  }
2895
- }
2896
- function providerSetupHint(host, provider) {
2897
- if (provider === "github") {
2898
- const isSaas = host.toLowerCase() === "github.com";
2899
- const hostArg = isSaas ? "" : ` --hostname ${host}`;
2900
- const install = installCommandForOS({
2901
- brew: "brew install gh",
2902
- winget: "winget install --id GitHub.cli",
2903
- linuxBrew: "brew install gh",
2904
- linuxDocsUrl: "https://github.com/cli/cli#installation"
2905
- });
2906
- return {
2907
- title: `${host} \u2014 GitHub`,
2908
- body: [
2909
- "Install the GitHub CLI:",
2910
- install,
2911
- "",
2912
- "Then run once:",
2913
- cyan2(`gh auth login${hostArg}`),
2914
- cyan2(`gh auth setup-git${hostArg}`),
2915
- "",
2916
- "`gh auth login` walks through OAuth in your browser.",
2917
- "`gh auth setup-git` wires gh into git as a credential helper."
2918
- ].join("\n")
2919
- };
3300
+ if (config.aptPackages.length > 0) {
3301
+ result.aptPackages = [...config.aptPackages];
2920
3302
  }
2921
- if (provider === "gitlab") {
2922
- const isSaas = host.toLowerCase() === "gitlab.com";
2923
- const hostArg = isSaas ? "" : ` --hostname ${host}`;
2924
- const install = installCommandForOS({
2925
- brew: "brew install glab",
2926
- winget: "winget install --id GLab.GLab",
2927
- linuxBrew: "brew install glab",
2928
- linuxDocsUrl: "https://gitlab.com/gitlab-org/cli#installation"
2929
- });
2930
- return {
2931
- title: `${host} \u2014 GitLab`,
2932
- body: [
2933
- "Install the GitLab CLI (glab):",
2934
- install,
2935
- "",
2936
- "Then run once:",
2937
- cyan2(`glab auth login${hostArg}`),
2938
- "",
2939
- "Choose `HTTPS` when asked for git-protocol, then accept",
2940
- '"Authenticate Git with your GitLab credentials" \u2014 glab',
2941
- "configures itself as the git credential helper."
2942
- ].join("\n")
2943
- };
3303
+ if (Object.keys(featureRecord).length > 0) {
3304
+ result.features = featureRecord;
2944
3305
  }
2945
- if (provider === "bitbucket") {
2946
- const isCloud = host.toLowerCase() === "bitbucket.org";
2947
- if (isCloud) {
2948
- return {
2949
- title: `${host} \u2014 Bitbucket Cloud`,
2950
- body: [
2951
- "Bitbucket has no first-party CLI for git-credentials, so this",
2952
- "is a manual one-time setup. Generate an Atlassian API token at",
2953
- "https://id.atlassian.com/manage-profile/security/api-tokens",
2954
- "",
2955
- "Then store it via your OS credential helper:",
2956
- cyan2(
2957
- `git credential approve <<< $'protocol=https\\nhost=${host}\\nusername=<your-atlassian-email>\\npassword=<token>\\n'`
2958
- )
2959
- ].join("\n")
2960
- };
3306
+ if (config.installUrls.length > 0) {
3307
+ result.installUrls = [...config.installUrls];
3308
+ }
3309
+ if (config.repos.length > 0) {
3310
+ result.repos = config.repos.map((r) => ({
3311
+ url: r.url,
3312
+ // `path` is optional in the yml; CreateOptions requires it.
3313
+ // When the yml omits `path`, fall back to the URL-derived
3314
+ // single-segment default (`https://.../foo.git` → `foo`),
3315
+ // which lands the clone at `projects/foo/`.
3316
+ path: r.path ?? deriveRepoName(r.url),
3317
+ // gitUser is forwarded only when BOTH name + email are set.
3318
+ // The relaxed GitUserSchema accepts nullable / empty strings
3319
+ // (so a yml placeholder `name:` parses without error), so we
3320
+ // re-check here before downstream code, which expects both
3321
+ // values to be non-empty.
3322
+ ...r.git?.user?.name && r.git.user.email ? { gitUser: { name: r.git.user.name, email: r.git.user.email } } : {},
3323
+ ...r.provider ? { provider: r.provider } : {}
3324
+ }));
3325
+ }
3326
+ const routingPorts = config.routing?.ports ?? [];
3327
+ if (routingPorts.length > 0) {
3328
+ const seen = /* @__PURE__ */ new Set();
3329
+ const ports = [];
3330
+ for (const entry2 of routingPorts) {
3331
+ const n = portNumber(entry2);
3332
+ if (seen.has(n)) continue;
3333
+ seen.add(n);
3334
+ ports.push(n);
2961
3335
  }
2962
- return {
2963
- title: `${host} \u2014 Bitbucket Data Center`,
2964
- body: [
2965
- "Bitbucket has no first-party CLI for git-credentials, so this",
2966
- "is a manual one-time setup. Generate a personal HTTP access",
2967
- `token in your Bitbucket UI: profile picture (top right on ${host})`,
2968
- "\u2192 Manage account \u2192 HTTP access tokens \u2192 Create token. Give it",
2969
- "at least repo-read + repo-write scopes for the repos you need.",
2970
- "",
2971
- "Then store it via your OS credential helper:",
2972
- cyan2(
2973
- `git credential approve <<< $'protocol=https\\nhost=${host}\\nusername=<your-bitbucket-username>\\npassword=<token>\\n'`
2974
- )
2975
- ].join("\n")
2976
- };
3336
+ result.ports = ports;
2977
3337
  }
2978
- return {
2979
- title: `${host} \u2014 Gitea`,
2980
- body: [
2981
- "Gitea has no first-party CLI helper for git-credentials (the",
2982
- "`tea` CLI logs into its own config, not into your git credential",
2983
- "helper), so this is a manual one-time setup. Generate an access",
2984
- `token in your Gitea UI: profile picture (top right on ${host}) \u2192`,
2985
- 'Settings \u2192 Applications \u2192 "Generate New Token". Give it at',
2986
- "least the `read:repository` scope (add `write:repository` if you",
2987
- "need push from the container).",
2988
- "",
2989
- "Then store it via your OS credential helper:",
2990
- cyan2(
2991
- `git credential approve <<< $'protocol=https\\nhost=${host}\\nusername=<your-gitea-username>\\npassword=<token>\\n'`
2992
- )
2993
- ].join("\n")
2994
- };
3338
+ if (config.routing?.vscodeAutoForward !== void 0) {
3339
+ result.vscodeAutoForward = config.routing.vscodeAutoForward;
3340
+ }
3341
+ return result;
2995
3342
  }
2996
- function parseCredentialFillOutput(output) {
2997
- const result = {};
2998
- for (const line of output.split("\n")) {
2999
- const eqIdx = line.indexOf("=");
3000
- if (eqIdx <= 0) continue;
3001
- const key = line.slice(0, eqIdx);
3002
- const value = line.slice(eqIdx + 1);
3003
- if (key === "username") result.username = value;
3004
- if (key === "password") result.password = value;
3343
+
3344
+ // src/devcontainer/compose.ts
3345
+ import { spawn as spawn5 } from "child_process";
3346
+ import { existsSync as existsSync3 } from "fs";
3347
+ import path9 from "path";
3348
+ import { consola as consola9 } from "consola";
3349
+
3350
+ // src/util/mask-secrets.ts
3351
+ import { Transform } from "stream";
3352
+ var PATTERNS = [
3353
+ // Atlassian Cloud API token. Starts with literal `ATATT3xFf` plus
3354
+ // a long URL-safe-base64 tail. Tightened to that prefix to avoid
3355
+ // matching unrelated all-caps words.
3356
+ { name: "atlassian-api", re: /ATATT3xFf[A-Za-z0-9+/=_-]{20,}/g },
3357
+ // Bitbucket Cloud app password.
3358
+ { name: "bitbucket-app", re: /ATBB[A-Za-z0-9+/=_-]{20,}/g },
3359
+ // GitHub PAT (classic), OAuth, user, server, refresh — all share
3360
+ // the `gh<lower-letter>_<base62>` shape per GitHub's token format.
3361
+ { name: "github-token", re: /gh[a-z]_[A-Za-z0-9]{20,}/g },
3362
+ // GitHub fine-grained PAT.
3363
+ { name: "github-pat", re: /github_pat_[A-Za-z0-9_]{20,}/g },
3364
+ // Anthropic API key.
3365
+ { name: "anthropic-api", re: /sk-ant-[A-Za-z0-9_-]{20,}/g }
3366
+ ];
3367
+ function maskSecrets(text) {
3368
+ let result = text;
3369
+ for (const { re } of PATTERNS) {
3370
+ result = result.replace(re, maskOne);
3005
3371
  }
3006
3372
  return result;
3007
3373
  }
3008
- function formatCredentialLine(host, username, password) {
3009
- const encUser = encodeURIComponent(username);
3010
- const encPass = encodeURIComponent(password);
3011
- return `https://${encUser}:${encPass}@${host}`;
3374
+ function maskOne(token) {
3375
+ if (token.length <= 12) return token;
3376
+ return `${token.slice(0, 5)}\u2026${token.slice(-6)}`;
3012
3377
  }
3013
- async function collectGitCredentials(devContainerRoot, hosts, options = {}) {
3014
- const credsDir = path8.join(devContainerRoot, ".monoceros");
3015
- const credentialsPath = path8.join(credsDir, "git-credentials");
3016
- const spawnFn = options.spawn ?? realGitCredentialFill;
3017
- const logger = options.logger ?? { info: () => {
3018
- }, warn: () => {
3019
- } };
3020
- const lines = [];
3021
- const perHost = [];
3022
- for (const { host, provider } of hosts) {
3023
- if (provider === "unknown") {
3024
- perHost.push({
3025
- host,
3026
- provider: "github",
3027
- // placeholder — never rendered because pre-flight already bailed
3028
- status: "no-credentials",
3029
- detail: "provider not declared (internal: should not reach here)"
3030
- });
3031
- continue;
3378
+ function createSecretMaskStream() {
3379
+ let buffer = "";
3380
+ return new Transform({
3381
+ decodeStrings: true,
3382
+ transform(chunk, _enc, cb) {
3383
+ const text = typeof chunk === "string" ? chunk : chunk.toString("utf8");
3384
+ buffer += text;
3385
+ const lastNewline = buffer.lastIndexOf("\n");
3386
+ if (lastNewline === -1) {
3387
+ cb(null);
3388
+ return;
3389
+ }
3390
+ const flushable = buffer.slice(0, lastNewline + 1);
3391
+ buffer = buffer.slice(lastNewline + 1);
3392
+ cb(null, maskSecrets(flushable));
3393
+ },
3394
+ flush(cb) {
3395
+ if (buffer.length > 0) {
3396
+ const tail = maskSecrets(buffer);
3397
+ buffer = "";
3398
+ cb(null, tail);
3399
+ return;
3400
+ }
3401
+ cb(null);
3032
3402
  }
3033
- logger.info(`Fetching credentials for ${host} from host git\u2026`);
3034
- const input = `protocol=https
3035
- host=${host}
3403
+ });
3404
+ }
3036
3405
 
3037
- `;
3038
- let result;
3039
- try {
3040
- result = await spawnFn(input);
3041
- } catch (err) {
3042
- const detail = err instanceof Error ? err.message : String(err);
3043
- perHost.push({ host, provider, status: "spawn-error", detail });
3044
- continue;
3045
- }
3046
- if (result.exitCode !== 0) {
3047
- perHost.push({
3048
- host,
3049
- provider,
3050
- status: "non-zero-exit",
3051
- detail: `exit code ${result.exitCode}`
3406
+ // src/devcontainer/cli.ts
3407
+ import { spawn as spawn4 } from "child_process";
3408
+ import { readFileSync as readFileSync2 } from "fs";
3409
+ import { createRequire } from "module";
3410
+ import path8 from "path";
3411
+ var require_ = createRequire(import.meta.url);
3412
+ var cachedBinaryPath = null;
3413
+ function devcontainerCliPath() {
3414
+ if (cachedBinaryPath) return cachedBinaryPath;
3415
+ const pkgJsonPath = require_.resolve("@devcontainers/cli/package.json");
3416
+ const pkg = JSON.parse(readFileSync2(pkgJsonPath, "utf8"));
3417
+ const binEntry = typeof pkg.bin === "string" ? pkg.bin : pkg.bin?.devcontainer ?? "";
3418
+ if (!binEntry) {
3419
+ throw new Error("Could not resolve @devcontainers/cli bin entry.");
3420
+ }
3421
+ cachedBinaryPath = path8.resolve(path8.dirname(pkgJsonPath), binEntry);
3422
+ return cachedBinaryPath;
3423
+ }
3424
+ var spawnDevcontainer = (args, cwd, options = {}) => {
3425
+ const binPath = devcontainerCliPath();
3426
+ return new Promise((resolve, reject) => {
3427
+ if (options.interactive) {
3428
+ const child2 = spawn4(process.execPath, [binPath, ...args], {
3429
+ cwd,
3430
+ stdio: "inherit"
3052
3431
  });
3053
- continue;
3432
+ child2.on("error", reject);
3433
+ child2.on("exit", (code) => resolve(code ?? 0));
3434
+ return;
3054
3435
  }
3055
- const { username, password } = parseCredentialFillOutput(result.stdout);
3056
- if (!username || !password) {
3057
- perHost.push({
3058
- host,
3059
- provider,
3060
- status: "no-credentials",
3061
- detail: "host credential helper returned no username/password"
3436
+ const child = spawn4(process.execPath, [binPath, ...args], {
3437
+ cwd,
3438
+ stdio: ["ignore", "pipe", "pipe"]
3439
+ });
3440
+ if (options.quiet) {
3441
+ const stdoutChunks = [];
3442
+ const stderrChunks = [];
3443
+ child.stdout?.on("data", (chunk) => stdoutChunks.push(chunk));
3444
+ child.stderr?.on("data", (chunk) => stderrChunks.push(chunk));
3445
+ child.on("error", reject);
3446
+ child.on("exit", (code) => {
3447
+ const exitCode = code ?? 0;
3448
+ if (exitCode !== 0) {
3449
+ process.stderr.write(
3450
+ maskSecrets(Buffer.concat(stderrChunks).toString("utf8"))
3451
+ );
3452
+ process.stderr.write(
3453
+ maskSecrets(Buffer.concat(stdoutChunks).toString("utf8"))
3454
+ );
3455
+ }
3456
+ resolve(exitCode);
3062
3457
  });
3063
- continue;
3458
+ return;
3064
3459
  }
3065
- lines.push(formatCredentialLine(host, username, password));
3066
- perHost.push({ host, provider, status: "ok", detail: "" });
3460
+ child.stdout?.pipe(createSecretMaskStream()).pipe(process.stdout);
3461
+ child.stderr?.pipe(createSecretMaskStream()).pipe(process.stderr);
3462
+ child.on("error", reject);
3463
+ child.on("exit", (code) => resolve(code ?? 0));
3464
+ });
3465
+ };
3466
+
3467
+ // src/devcontainer/compose.ts
3468
+ var spawnDockerCompose = (args, cwd) => {
3469
+ return new Promise((resolve, reject) => {
3470
+ const child = spawn5("docker", ["compose", ...args], {
3471
+ cwd,
3472
+ stdio: ["inherit", "pipe", "pipe"]
3473
+ });
3474
+ child.stdout?.pipe(createSecretMaskStream()).pipe(process.stdout);
3475
+ child.stderr?.pipe(createSecretMaskStream()).pipe(process.stderr);
3476
+ child.on("error", reject);
3477
+ child.on("exit", (code) => resolve(code ?? 0));
3478
+ });
3479
+ };
3480
+ var spawnBash = (args, cwd) => {
3481
+ return new Promise((resolve, reject) => {
3482
+ const child = spawn5("bash", args, {
3483
+ cwd,
3484
+ stdio: ["inherit", "pipe", "pipe"]
3485
+ });
3486
+ child.stdout?.pipe(createSecretMaskStream()).pipe(process.stdout);
3487
+ child.stderr?.pipe(createSecretMaskStream()).pipe(process.stderr);
3488
+ child.on("error", reject);
3489
+ child.on("exit", (code) => resolve(code ?? 0));
3490
+ });
3491
+ };
3492
+ function composeProjectName(root) {
3493
+ return `${path9.basename(root)}_devcontainer`;
3494
+ }
3495
+ function resolveCompose(root) {
3496
+ if (!existsSync3(path9.join(root, ".devcontainer"))) {
3497
+ throw new Error(
3498
+ `No .devcontainer/ at ${root}. Run \`monoceros apply <name>\` first.`
3499
+ );
3067
3500
  }
3068
- await fs8.mkdir(credsDir, { recursive: true });
3069
- await fs8.writeFile(
3070
- credentialsPath,
3071
- lines.join("\n") + (lines.length > 0 ? "\n" : ""),
3072
- {
3073
- mode: 384
3074
- }
3501
+ const composeFile = path9.join(root, ".devcontainer", "compose.yaml");
3502
+ if (!existsSync3(composeFile)) {
3503
+ throw new Error(
3504
+ `No compose.yaml at ${composeFile}. \`start\` / \`stop\` / \`status\` / \`logs\` require services configured via \`monoceros add-service <name> <svc>\`. Use \`monoceros shell <name>\` to enter the container directly.`
3505
+ );
3506
+ }
3507
+ return { composeFile, projectName: composeProjectName(root) };
3508
+ }
3509
+ async function runComposeAction(buildSubArgs, opts) {
3510
+ const { composeFile, projectName } = resolveCompose(opts.root);
3511
+ const spawnFn = opts.spawn ?? spawnDockerCompose;
3512
+ const subArgs = buildSubArgs(opts.service);
3513
+ return spawnFn(["-f", composeFile, "-p", projectName, ...subArgs], opts.root);
3514
+ }
3515
+ async function runStart(opts) {
3516
+ resolveCompose(opts.root);
3517
+ const logger = opts.logger ?? { info: (msg) => consola9.info(msg) };
3518
+ const spawnFn = opts.spawn ?? spawnDevcontainer;
3519
+ logger.info(`Bringing devcontainer up at ${opts.root}\u2026`);
3520
+ return spawnFn(
3521
+ ["up", "--workspace-folder", opts.root, "--mount-workspace-git-root=false"],
3522
+ opts.root
3523
+ );
3524
+ }
3525
+ async function runContainerCycle(root, opts) {
3526
+ const { hasCompose, logger } = opts;
3527
+ if (hasCompose) {
3528
+ const projectName = composeProjectName(root);
3529
+ logger.info(
3530
+ `Force-removing existing ${projectName} containers (volumes preserved)\u2026`
3531
+ );
3532
+ const cleanupSpawn = opts.cleanupSpawn ?? spawnBash;
3533
+ const script = [
3534
+ `set -u`,
3535
+ `echo "[cleanup] checking project ${projectName}\u2026"`,
3536
+ `by_label=$(docker ps -aq --filter "label=com.docker.compose.project=${projectName}" 2>/dev/null || true)`,
3537
+ `by_name=$(docker ps -aq --filter "name=^${projectName}-" 2>/dev/null || true)`,
3538
+ `to_remove=$(printf "%s\\n%s\\n" "$by_label" "$by_name" | sort -u | grep -v "^$" || true)`,
3539
+ `if [ -n "$to_remove" ]; then echo "[cleanup] removing: $(echo $to_remove | tr "\\n" " ")"; docker rm -f $to_remove >/dev/null || true; else echo "[cleanup] no containers to remove"; fi`,
3540
+ `docker network rm ${projectName}_default 2>/dev/null && echo "[cleanup] network ${projectName}_default removed" || echo "[cleanup] network ${projectName}_default not present"`,
3541
+ `remaining_label=$(docker ps -aq --filter "label=com.docker.compose.project=${projectName}" 2>/dev/null || true)`,
3542
+ `remaining_name=$(docker ps -aq --filter "name=^${projectName}-" 2>/dev/null || true)`,
3543
+ `if [ -n "$remaining_label" ] || [ -n "$remaining_name" ]; then echo "" >&2; echo "ERROR: containers under project ${projectName} reappeared after removal." >&2; echo "This typically means VS Code's Remote Containers extension is connected to" >&2; echo "this devcontainer and auto-recreated it. Close the dev container session" >&2; echo "in VS Code (Cmd+Shift+P \u2192 'Dev Containers: Close Remote Connection')" >&2; echo "and retry \\\`monoceros apply\\\`." >&2; exit 1; fi`,
3544
+ `echo "[cleanup] done"`
3545
+ ].join("; ");
3546
+ const cleanupCode = await cleanupSpawn(["-c", script], root);
3547
+ if (cleanupCode !== 0) return cleanupCode;
3548
+ return runStart({
3549
+ root,
3550
+ ...opts.devcontainerSpawn ? { spawn: opts.devcontainerSpawn } : {},
3551
+ logger
3552
+ });
3553
+ }
3554
+ logger.info(`Recreating image-mode devcontainer at ${root}\u2026`);
3555
+ const spawnFn = opts.devcontainerSpawn ?? spawnDevcontainer;
3556
+ return spawnFn(
3557
+ [
3558
+ "up",
3559
+ "--workspace-folder",
3560
+ root,
3561
+ "--mount-workspace-git-root=false",
3562
+ "--remove-existing-container"
3563
+ ],
3564
+ root
3075
3565
  );
3076
- return {
3077
- hostsWritten: lines.length,
3078
- hostsSkipped: perHost.filter((p) => p.status !== "ok").length,
3079
- perHost,
3080
- credentialsPath
3081
- };
3082
3566
  }
3083
- function formatMissingCredentialsError(missing) {
3084
- if (missing.length === 1) {
3085
- const m = missing[0];
3086
- const hint = providerSetupHint(m.host, m.provider);
3087
- return [
3088
- `Missing Git credentials: ${hint.title}`,
3089
- "",
3090
- hint.body,
3091
- "",
3092
- `Then re-run ${cyan2("monoceros apply")}.`
3093
- ].join("\n");
3094
- }
3095
- const lines = [
3096
- `Missing Git credentials for ${missing.length} hosts:`,
3097
- ""
3098
- ];
3099
- for (const m of missing) {
3100
- const hint = providerSetupHint(m.host, m.provider);
3101
- lines.push(hint.title);
3102
- lines.push("");
3103
- lines.push(hint.body);
3104
- lines.push("");
3105
- }
3106
- lines.push(`Then re-run ${cyan2("monoceros apply")}.`);
3107
- return lines.join("\n");
3567
+ function runStop(opts) {
3568
+ return runComposeAction(
3569
+ (service) => ["stop", ...service ? [service] : []],
3570
+ opts
3571
+ );
3108
3572
  }
3109
- function formatUnknownProviderError(hosts) {
3110
- const sorted = [...new Set(hosts)].sort();
3111
- const lines = [
3112
- sorted.length === 1 ? `Unknown Git provider for host ${sorted[0]}.` : `Unknown Git provider for ${sorted.length} hosts: ${sorted.join(", ")}.`,
3113
- "",
3114
- "Monoceros auto-detects only github.com / gitlab.com / bitbucket.org.",
3115
- "For any other host (self-hosted GitLab, Gitea, Bitbucket Server, \u2026)",
3116
- "declare the provider explicitly in the yml. Edit the repo entry:",
3117
- "",
3118
- cyan2(" repos:"),
3119
- cyan2(` - url: https://${sorted[0]}/\u2026`),
3120
- cyan2(" provider: gitlab # or: github, bitbucket, gitea"),
3121
- "",
3122
- `Or re-add with ${cyan2("monoceros add-repo <name> <url> --provider=<github|gitlab|bitbucket|gitea>")}.`
3123
- ];
3124
- return lines.join("\n");
3573
+ function runStatus(opts) {
3574
+ return runComposeAction(
3575
+ (service) => ["ps", ...service ? [service] : []],
3576
+ opts
3577
+ );
3578
+ }
3579
+ function runLogs(opts) {
3580
+ const follow = opts.follow ?? true;
3581
+ return runComposeAction(
3582
+ (service) => [
3583
+ "logs",
3584
+ ...follow ? ["-f"] : [],
3585
+ ...service ? [service] : []
3586
+ ],
3587
+ opts
3588
+ );
3125
3589
  }
3126
3590
 
3127
3591
  // src/devcontainer/repo-reachability.ts
3128
- import { spawn as spawn5 } from "child_process";
3592
+ import { spawn as spawn6 } from "child_process";
3129
3593
  var realGitLsRemote = (url) => {
3130
3594
  return new Promise((resolve, reject) => {
3131
- const child = spawn5("git", ["ls-remote", "--heads", "--", url], {
3595
+ const child = spawn6("git", ["ls-remote", "--heads", "--", url], {
3132
3596
  stdio: ["ignore", "pipe", "pipe"],
3133
3597
  env: {
3134
3598
  ...process.env,
@@ -3266,10 +3730,10 @@ function adviceForKind(kind) {
3266
3730
  }
3267
3731
 
3268
3732
  // src/devcontainer/docker-mode.ts
3269
- import { spawn as spawn6 } from "child_process";
3733
+ import { spawn as spawn7 } from "child_process";
3270
3734
  var realDockerInfo = () => {
3271
3735
  return new Promise((resolve, reject) => {
3272
- const child = spawn6(
3736
+ const child = spawn7(
3273
3737
  "docker",
3274
3738
  ["info", "--format", "{{json .SecurityOptions}}"],
3275
3739
  {
@@ -3328,13 +3792,13 @@ function formatRootlessNotSupportedError() {
3328
3792
  }
3329
3793
 
3330
3794
  // src/devcontainer/identity.ts
3331
- import { spawn as spawn7 } from "child_process";
3795
+ import { spawn as spawn8 } from "child_process";
3332
3796
  import { promises as fs9 } from "fs";
3333
- import path9 from "path";
3797
+ import path10 from "path";
3334
3798
  import { consola as consola10 } from "consola";
3335
3799
  var realGitConfigGet = (key) => {
3336
3800
  return new Promise((resolve, reject) => {
3337
- const child = spawn7("git", ["config", "--global", "--get", key], {
3801
+ const child = spawn8("git", ["config", "--global", "--get", key], {
3338
3802
  stdio: ["ignore", "pipe", "inherit"]
3339
3803
  });
3340
3804
  let stdout = "";
@@ -3358,20 +3822,51 @@ var realIdentityPrompt = async (key) => {
3358
3822
  const trimmed = value.trim();
3359
3823
  return trimmed.length > 0 ? trimmed : void 0;
3360
3824
  };
3361
- async function collectGitIdentity(devContainerRoot, options = {}) {
3362
- const gitconfigDir = path9.join(devContainerRoot, ".monoceros");
3363
- const gitconfigPath = path9.join(gitconfigDir, "gitconfig");
3825
+ var realScopePrompt = async (ctx) => {
3826
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
3827
+ return ctx.reason === "prompt" ? "g" : "n";
3828
+ }
3829
+ const heading = ctx.reason === "persisted" ? `Found identity in .monoceros/gitconfig: ${ctx.name} <${ctx.email}>. Promote where?` : "Save this identity where?";
3830
+ const choice = await consola10.prompt(heading, {
3831
+ type: "select",
3832
+ options: [
3833
+ {
3834
+ label: "Globally \u2014 every container uses it as default",
3835
+ value: "g"
3836
+ },
3837
+ {
3838
+ label: "In this container only",
3839
+ value: "c"
3840
+ },
3841
+ {
3842
+ label: "Both \u2014 global default plus container-level entry",
3843
+ value: "b"
3844
+ },
3845
+ {
3846
+ label: "Keep as-is \u2014 do not write to monoceros-config or yml",
3847
+ value: "n"
3848
+ }
3849
+ ],
3850
+ initial: "g"
3851
+ });
3852
+ if (choice === "g" || choice === "c" || choice === "b" || choice === "n") {
3853
+ return choice;
3854
+ }
3855
+ return void 0;
3856
+ };
3857
+ async function resolveIdentityWithPrompt(options = {}) {
3364
3858
  const spawnFn = options.spawn ?? realGitConfigGet;
3365
3859
  const promptFn = options.prompt ?? realIdentityPrompt;
3860
+ const scopePromptFn = options.scopePrompt ?? realScopePrompt;
3366
3861
  const logger = options.logger ?? { info: () => {
3367
3862
  }, warn: () => {
3368
3863
  } };
3369
- const existing = await readExistingGitconfig(gitconfigPath);
3864
+ const persisted = options.persistedValues ?? {};
3370
3865
  const name = await resolveKey("user.name", {
3371
3866
  override: options.containerOverride?.name,
3372
3867
  defaultValue: options.defaults?.name,
3373
3868
  spawnFn,
3374
- persistedValue: existing.name,
3869
+ persistedValue: persisted.name,
3375
3870
  promptFn,
3376
3871
  logger
3377
3872
  });
@@ -3379,35 +3874,77 @@ async function collectGitIdentity(devContainerRoot, options = {}) {
3379
3874
  override: options.containerOverride?.email,
3380
3875
  defaultValue: options.defaults?.email,
3381
3876
  spawnFn,
3382
- persistedValue: existing.email,
3877
+ persistedValue: persisted.email,
3383
3878
  promptFn,
3384
3879
  logger
3385
3880
  });
3881
+ const alreadyCanonical = !!options.containerOverride?.name || !!options.containerOverride?.email || !!options.defaults?.name || !!options.defaults?.email;
3882
+ const promptableSources = [
3883
+ "prompt",
3884
+ "persisted"
3885
+ ];
3886
+ const bothPromotable = name?.source !== void 0 && email?.source !== void 0 && promptableSources.includes(name.source) && promptableSources.includes(email.source) && name.source === email.source;
3887
+ let promptedScope;
3888
+ if (!alreadyCanonical && bothPromotable && name?.value && email?.value) {
3889
+ promptedScope = await scopePromptFn({
3890
+ reason: name.source,
3891
+ name: name.value,
3892
+ email: email.value
3893
+ });
3894
+ }
3895
+ return {
3896
+ ...name?.value !== void 0 ? { name: name.value } : {},
3897
+ ...email?.value !== void 0 ? { email: email.value } : {},
3898
+ // Only surface `prompted` when the scope is a persistence target
3899
+ // (`g`/`c`/`b`). `'n'` means "do nothing" — no point passing it
3900
+ // to the caller as a "go persist" signal.
3901
+ ...promptedScope && promptedScope !== "n" && name?.value && email?.value ? {
3902
+ prompted: {
3903
+ name: name.value,
3904
+ email: email.value,
3905
+ scope: promptedScope
3906
+ }
3907
+ } : {}
3908
+ };
3909
+ }
3910
+ async function collectGitIdentity(devContainerRoot, options = {}) {
3911
+ const gitconfigDir = path10.join(devContainerRoot, ".monoceros");
3912
+ const gitconfigPath = path10.join(gitconfigDir, "gitconfig");
3913
+ const logger = options.logger ?? { info: () => {
3914
+ }, warn: () => {
3915
+ } };
3916
+ const existing = await readExistingGitconfig(gitconfigPath);
3917
+ const resolved = await resolveIdentityWithPrompt({
3918
+ ...options,
3919
+ persistedValues: existing,
3920
+ logger
3921
+ });
3386
3922
  const lines = ["[user]"];
3387
- if (name !== void 0) lines.push(` name = ${name}`);
3388
- if (email !== void 0) lines.push(` email = ${email}`);
3923
+ if (resolved.name !== void 0) lines.push(` name = ${resolved.name}`);
3924
+ if (resolved.email !== void 0) lines.push(` email = ${resolved.email}`);
3389
3925
  await fs9.mkdir(gitconfigDir, { recursive: true });
3390
3926
  await fs9.writeFile(gitconfigPath, lines.join("\n") + "\n");
3391
3927
  return {
3392
- ...name !== void 0 ? { name } : {},
3393
- ...email !== void 0 ? { email } : {},
3394
- gitconfigPath
3928
+ ...resolved.name !== void 0 ? { name: resolved.name } : {},
3929
+ ...resolved.email !== void 0 ? { email: resolved.email } : {},
3930
+ gitconfigPath,
3931
+ ...resolved.prompted ? { prompted: resolved.prompted } : {}
3395
3932
  };
3396
3933
  }
3397
3934
  async function resolveKey(key, opts) {
3398
3935
  if (opts.override !== void 0 && opts.override.length > 0) {
3399
- return opts.override;
3936
+ return { value: opts.override, source: "container" };
3400
3937
  }
3401
3938
  if (opts.defaultValue !== void 0 && opts.defaultValue.length > 0) {
3402
- return opts.defaultValue;
3939
+ return { value: opts.defaultValue, source: "defaults" };
3403
3940
  }
3404
3941
  const hostValue = await readKeyFromHost(opts.spawnFn, key, opts.logger);
3405
- if (hostValue !== void 0) return hostValue;
3942
+ if (hostValue !== void 0) return { value: hostValue, source: "host" };
3406
3943
  if (opts.persistedValue !== void 0 && opts.persistedValue.length > 0) {
3407
- return opts.persistedValue;
3944
+ return { value: opts.persistedValue, source: "persisted" };
3408
3945
  }
3409
3946
  const prompted = await opts.promptFn(key);
3410
- if (prompted !== void 0) return prompted;
3947
+ if (prompted !== void 0) return { value: prompted, source: "prompt" };
3411
3948
  opts.logger.warn(
3412
3949
  `No ${key} resolvable (yml override, monoceros-config.yml defaults, host \`git config --global\`, persisted .monoceros/gitconfig, prompt). Container git will have no ${key} until set explicitly.`
3413
3950
  );
@@ -3489,13 +4026,17 @@ ${sectionLine(label)}
3489
4026
  warn: logger.warn ?? logger.info
3490
4027
  };
3491
4028
  if (hasRepos || hasContainerGitUser || hasDefaultGitUser) {
3492
- await collectGitIdentity(targetDir, {
4029
+ const identity = await collectGitIdentity(targetDir, {
3493
4030
  ...opts.identitySpawn ? { spawn: opts.identitySpawn } : {},
3494
4031
  ...opts.identityPrompt ? { prompt: opts.identityPrompt } : {},
4032
+ ...opts.identityScopePrompt ? { scopePrompt: opts.identityScopePrompt } : {},
3495
4033
  ...parsed.config.git?.user ? { containerOverride: parsed.config.git.user } : {},
3496
4034
  ...globalConfig?.defaults?.git?.user ? { defaults: globalConfig.defaults.git.user } : {},
3497
4035
  logger: idLogger
3498
4036
  });
4037
+ if (identity.prompted) {
4038
+ await persistPromptedIdentity(identity.prompted, ymlPath, home, logger);
4039
+ }
3499
4040
  }
3500
4041
  const hostsToFetch = uniqueHttpsHosts(createOpts.repos ?? []);
3501
4042
  const unknownProviderHosts = hostsToFetch.filter((h) => h.provider === "unknown").map((h) => h.host);
@@ -3628,9 +4169,59 @@ function warnOnDeprecatedFeatureRefs(containerFeatures, globalConfig, logger) {
3628
4169
  }
3629
4170
  }
3630
4171
  }
4172
+ async function persistPromptedIdentity(prompted, ymlPath, home, logger) {
4173
+ const wantGlobal = prompted.scope === "g" || prompted.scope === "b";
4174
+ const wantContainer = prompted.scope === "c" || prompted.scope === "b";
4175
+ if (wantGlobal) {
4176
+ try {
4177
+ const result = await writeGlobalDefaultGitUser(
4178
+ { name: prompted.name, email: prompted.email },
4179
+ { monocerosHome: home }
4180
+ );
4181
+ if (result.alreadySet) {
4182
+ logger.warn?.(
4183
+ `monoceros-config.yml already has a defaults.git.user \u2014 left it alone. To replace, edit ${prettyPath(result.filePath)} by hand.`
4184
+ );
4185
+ } else if (result.created) {
4186
+ logger.info(
4187
+ `Saved identity globally \u2014 created ${prettyPath(result.filePath)} with defaults.git.user.`
4188
+ );
4189
+ } else {
4190
+ logger.info(
4191
+ `Saved identity globally \u2014 wrote defaults.git.user into ${prettyPath(result.filePath)}.`
4192
+ );
4193
+ }
4194
+ } catch (err) {
4195
+ logger.warn?.(
4196
+ `Could not persist identity to monoceros-config.yml: ${err instanceof Error ? err.message : String(err)}. The values are still active for this apply via .monoceros/gitconfig.`
4197
+ );
4198
+ }
4199
+ }
4200
+ if (wantContainer) {
4201
+ try {
4202
+ const text = await fs10.readFile(ymlPath, "utf8");
4203
+ const parsed = parseConfig(text, ymlPath);
4204
+ const changed = setContainerGitUserInDoc(parsed.doc, {
4205
+ name: prompted.name,
4206
+ email: prompted.email
4207
+ });
4208
+ if (changed) {
4209
+ const out = stringifyConfig(parsed.doc);
4210
+ await fs10.writeFile(ymlPath, out, "utf8");
4211
+ logger.info(
4212
+ `Saved identity in this container \u2014 wrote git.user into ${prettyPath(ymlPath)}.`
4213
+ );
4214
+ }
4215
+ } catch (err) {
4216
+ logger.warn?.(
4217
+ `Could not persist identity to ${prettyPath(ymlPath)}: ${err instanceof Error ? err.message : String(err)}. The values are still active for this apply via .monoceros/gitconfig.`
4218
+ );
4219
+ }
4220
+ }
4221
+ }
3631
4222
 
3632
4223
  // src/version.ts
3633
- var CLI_VERSION = true ? "1.7.4" : "dev";
4224
+ var CLI_VERSION = true ? "1.8.0" : "dev";
3634
4225
 
3635
4226
  // src/commands/_dispatch.ts
3636
4227
  import { consola as consola12 } from "consola";
@@ -3892,7 +4483,7 @@ import { consola as consola13 } from "consola";
3892
4483
 
3893
4484
  // src/init/components.ts
3894
4485
  import { existsSync as existsSync5, promises as fs11 } from "fs";
3895
- import path10 from "path";
4486
+ import path11 from "path";
3896
4487
  import { z as z3 } from "zod";
3897
4488
  import { parse as parseYaml } from "yaml";
3898
4489
  var CategorySchema = z3.enum(["language", "service", "feature"]);
@@ -3949,14 +4540,14 @@ async function loadComponentCatalog(rootDir = componentsDir()) {
3949
4540
  async function walk(baseDir, currentDir, out) {
3950
4541
  const entries = await fs11.readdir(currentDir, { withFileTypes: true });
3951
4542
  for (const entry2 of entries) {
3952
- const full = path10.join(currentDir, entry2.name);
4543
+ const full = path11.join(currentDir, entry2.name);
3953
4544
  if (entry2.isDirectory()) {
3954
4545
  await walk(baseDir, full, out);
3955
4546
  continue;
3956
4547
  }
3957
4548
  if (!entry2.isFile() || !entry2.name.endsWith(".yml")) continue;
3958
- const relative = path10.relative(baseDir, full);
3959
- const name = relative.replace(/\.yml$/, "").split(path10.sep).join("/");
4549
+ const relative = path11.relative(baseDir, full);
4550
+ const name = relative.replace(/\.yml$/, "").split(path11.sep).join("/");
3960
4551
  const text = await fs11.readFile(full, "utf8");
3961
4552
  let raw;
3962
4553
  try {
@@ -4056,35 +4647,49 @@ Available: ${available.join(", ")}.`
4056
4647
  }
4057
4648
 
4058
4649
  // src/init/generator.ts
4059
- var SCHEMA_HEADER = [
4060
- "# Monoceros solution-config. Edit freely, then run",
4061
- "# `monoceros apply <name>` to materialize a dev-container.",
4062
- "#",
4063
- "# Each section below carries inline comments for the options it",
4064
- "# accepts. Features expose additional knobs as commented hints",
4065
- "# under their `options:` block \u2014 un-comment what you need."
4066
- ];
4650
+ var SCHEMA_HEADER_ACTIVE = "# Solution-config \u2014 describes what should be inside your dev-container.\n# Edit any section, then run `monoceros apply <name>` to (re-)build.";
4651
+ var SCHEMA_HEADER_DOCUMENTED = "# Solution-config \u2014 describes what should be inside your dev-container.\n# Every section is commented out by default; un-comment what you need\n# (strip one `#` per line of the block), then run `monoceros apply <name>`.";
4652
+ var COMMENT_WIDTH = 76;
4067
4653
  function generateComposedYml(name, components, lookupManifest, repoUrls = [], ports = []) {
4068
4654
  const merged = mergeComponents(components);
4069
4655
  const lines = [];
4070
- for (const h of SCHEMA_HEADER) lines.push(h);
4656
+ pushHeader(lines, SCHEMA_HEADER_ACTIVE, name);
4071
4657
  lines.push("");
4072
4658
  lines.push("schemaVersion: 1");
4073
4659
  lines.push(`name: ${name}`);
4074
4660
  lines.push("");
4075
4661
  if (merged.languages.length > 0) {
4662
+ pushSectionHeader(
4663
+ lines,
4664
+ LANGUAGES_HEADER,
4665
+ /* commented */
4666
+ false
4667
+ );
4076
4668
  lines.push("languages:");
4077
4669
  for (const lang of merged.languages) lines.push(` - ${lang}`);
4078
4670
  lines.push("");
4079
4671
  }
4080
4672
  if (merged.services.length > 0) {
4673
+ pushSectionHeader(
4674
+ lines,
4675
+ SERVICES_HEADER,
4676
+ /* commented */
4677
+ false
4678
+ );
4081
4679
  lines.push("services:");
4082
4680
  for (const svc of merged.services) lines.push(` - ${svc}`);
4083
4681
  lines.push("");
4084
4682
  }
4085
4683
  if (merged.features.length > 0) {
4684
+ pushSectionHeader(
4685
+ lines,
4686
+ FEATURES_HEADER_ACTIVE,
4687
+ /* commented */
4688
+ false
4689
+ );
4086
4690
  lines.push("features:");
4087
4691
  for (const f of merged.features) {
4692
+ lines.push("");
4088
4693
  renderFeatureBlock(
4089
4694
  lines,
4090
4695
  f,
@@ -4096,87 +4701,86 @@ function generateComposedYml(name, components, lookupManifest, repoUrls = [], po
4096
4701
  lines.push("");
4097
4702
  }
4098
4703
  if (repoUrls.length > 0) {
4099
- renderReposBlock(
4704
+ pushSectionHeader(
4100
4705
  lines,
4101
- repoUrls,
4706
+ REPOS_HEADER,
4102
4707
  /* commented */
4103
4708
  false
4104
4709
  );
4710
+ lines.push("repos:");
4711
+ for (const url of repoUrls) {
4712
+ lines.push(` - url: ${url}`);
4713
+ lines.push(" # path:");
4714
+ lines.push(" # provider:");
4715
+ lines.push(" # git:");
4716
+ lines.push(" # user:");
4717
+ lines.push(" # name:");
4718
+ lines.push(" # email:");
4719
+ }
4720
+ lines.push("");
4105
4721
  }
4106
4722
  if (ports.length > 0) {
4107
- renderActiveRoutingBlock(lines, name, ports);
4723
+ pushSectionHeader(
4724
+ lines,
4725
+ routingHeader(name),
4726
+ /* commented */
4727
+ false
4728
+ );
4729
+ lines.push("routing:");
4730
+ lines.push(" ports:");
4731
+ for (const port of ports) {
4732
+ lines.push(` - ${port}`);
4733
+ }
4734
+ lines.push(" # vscodeAutoForward: false");
4735
+ lines.push("");
4108
4736
  }
4109
4737
  return ensureTrailingNewline(lines.join("\n"));
4110
4738
  }
4111
4739
  function generateDocumentedYml(name, catalog, lookupManifest, repoUrls = [], ports = []) {
4112
4740
  const byCategory = groupByCategory(catalog);
4113
4741
  const lines = [];
4114
- for (const h of SCHEMA_HEADER) lines.push(h);
4115
- lines.push("#");
4116
- lines.push("# Below is the full set of components shipped with this");
4117
- lines.push("# workbench, every one commented out. Un-comment the lines");
4118
- lines.push("# you want active. The same effect (and a cleaner yml) is");
4119
- lines.push("# achievable by running `monoceros init <name> --with=\u2026`");
4120
- lines.push("# with a comma-separated list of component names.");
4742
+ pushHeader(lines, SCHEMA_HEADER_DOCUMENTED, name);
4121
4743
  lines.push("");
4122
4744
  lines.push("schemaVersion: 1");
4123
4745
  lines.push(`name: ${name}`);
4124
4746
  lines.push("");
4125
4747
  if (byCategory.language.length > 0) {
4126
- const items = byCategory.language.flatMap(
4127
- (c) => (c.file.contributes.languages ?? []).map((lang) => ({
4128
- value: lang,
4129
- label: c.file.displayName
4130
- }))
4748
+ pushSectionHeader(
4749
+ lines,
4750
+ LANGUAGES_HEADER,
4751
+ /* commented */
4752
+ true
4131
4753
  );
4132
- const width = Math.max(...items.map((i) => i.value.length)) + 2;
4133
- lines.push("# Languages \u2014 runtime toolchains.");
4134
4754
  lines.push("# languages:");
4135
- for (const item of items) {
4136
- const pad = " ".repeat(width - item.value.length);
4137
- lines.push(`# - ${item.value}${pad}# ${item.label}`);
4755
+ for (const c of byCategory.language) {
4756
+ for (const lang of c.file.contributes.languages ?? []) {
4757
+ lines.push(`# - ${lang}`);
4758
+ }
4138
4759
  }
4139
4760
  lines.push("");
4140
4761
  }
4141
4762
  if (byCategory.service.length > 0) {
4142
- const items = byCategory.service.flatMap(
4143
- (c) => (c.file.contributes.services ?? []).map((svc) => ({
4144
- value: svc,
4145
- label: c.file.displayName
4146
- }))
4763
+ pushSectionHeader(
4764
+ lines,
4765
+ SERVICES_HEADER,
4766
+ /* commented */
4767
+ true
4147
4768
  );
4148
- const width = Math.max(...items.map((i) => i.value.length)) + 2;
4149
- lines.push("# Services \u2014 compose-mode siblings of the workspace");
4150
- lines.push("# container (compose mode kicks in as soon as at least");
4151
- lines.push("# one service is active).");
4152
4769
  lines.push("# services:");
4153
- for (const item of items) {
4154
- const pad = " ".repeat(width - item.value.length);
4155
- lines.push(`# - ${item.value}${pad}# ${item.label}`);
4770
+ for (const c of byCategory.service) {
4771
+ for (const svc of c.file.contributes.services ?? []) {
4772
+ lines.push(`# - ${svc}`);
4773
+ }
4156
4774
  }
4157
4775
  lines.push("");
4158
4776
  }
4159
4777
  if (byCategory.feature.length > 0) {
4160
- lines.push("# Features \u2014 devcontainer features installed inside the");
4161
- lines.push("# container. Each entry has an OCI-style `ref` plus an");
4162
- lines.push("# optional `options` map. Credentials/auth keys appear");
4163
- lines.push("# as commented hints; set them here per container, or");
4164
- lines.push("# globally in monoceros-config.yml under");
4165
- lines.push("# `defaults.features.<ref>`.");
4166
- lines.push("#");
4167
- lines.push("# Catalog:");
4168
- lines.push("#");
4169
- const nameColumnWidth = Math.max(...byCategory.feature.map((c) => c.name.length)) + 2;
4170
- for (const c of byCategory.feature) {
4171
- const pad = " ".repeat(nameColumnWidth - c.name.length);
4172
- lines.push(`# ${c.name}${pad}${c.file.displayName}`);
4173
- }
4174
- lines.push("#");
4175
- lines.push("# Below: one block per feature ref. Un-comment what");
4176
- lines.push("# you want active. Sub-components share their parent's");
4177
- lines.push("# block \u2014 pick the parent for the full preset, swap to");
4178
- lines.push("# a sub-component name for a partial install.");
4179
- lines.push("#");
4778
+ pushSectionHeader(
4779
+ lines,
4780
+ FEATURES_HEADER_DOCUMENTED,
4781
+ /* commented */
4782
+ true
4783
+ );
4180
4784
  lines.push("# features:");
4181
4785
  const renderedRefs = /* @__PURE__ */ new Set();
4182
4786
  const topLevel = byCategory.feature.filter((c) => !c.name.includes("/"));
@@ -4184,6 +4788,7 @@ function generateDocumentedYml(name, catalog, lookupManifest, repoUrls = [], por
4184
4788
  for (const f of c.file.contributes.features ?? []) {
4185
4789
  if (renderedRefs.has(f.ref)) continue;
4186
4790
  renderedRefs.add(f.ref);
4791
+ lines.push("#");
4187
4792
  renderFeatureBlock(
4188
4793
  lines,
4189
4794
  f,
@@ -4198,6 +4803,7 @@ function generateDocumentedYml(name, catalog, lookupManifest, repoUrls = [], por
4198
4803
  for (const f of c.file.contributes.features ?? []) {
4199
4804
  if (renderedRefs.has(f.ref)) continue;
4200
4805
  renderedRefs.add(f.ref);
4806
+ lines.push("#");
4201
4807
  renderFeatureBlock(
4202
4808
  lines,
4203
4809
  f,
@@ -4209,165 +4815,156 @@ function generateDocumentedYml(name, catalog, lookupManifest, repoUrls = [], por
4209
4815
  }
4210
4816
  lines.push("");
4211
4817
  }
4212
- renderReposBlock(
4213
- lines,
4214
- repoUrls,
4215
- /* commented */
4216
- repoUrls.length === 0
4217
- );
4818
+ if (repoUrls.length > 0) {
4819
+ pushSectionHeader(
4820
+ lines,
4821
+ REPOS_HEADER,
4822
+ /* commented */
4823
+ false
4824
+ );
4825
+ lines.push("repos:");
4826
+ for (const url of repoUrls) {
4827
+ lines.push(` - url: ${url}`);
4828
+ }
4829
+ lines.push("");
4830
+ } else {
4831
+ pushSectionHeader(
4832
+ lines,
4833
+ REPOS_HEADER,
4834
+ /* commented */
4835
+ true
4836
+ );
4837
+ lines.push("# repos:");
4838
+ lines.push("# - url: https://github.com/<org>/<repo>.git");
4839
+ lines.push("# path: <folder>");
4840
+ lines.push("# provider: github");
4841
+ lines.push("# git:");
4842
+ lines.push("# user:");
4843
+ lines.push("# name: Your Name");
4844
+ lines.push("# email: you@example.com");
4845
+ lines.push("");
4846
+ }
4218
4847
  if (ports.length > 0) {
4219
- renderActiveRoutingBlock(lines, name, ports);
4848
+ pushSectionHeader(
4849
+ lines,
4850
+ routingHeader(name),
4851
+ /* commented */
4852
+ false
4853
+ );
4854
+ lines.push("routing:");
4855
+ lines.push(" ports:");
4856
+ for (const port of ports) {
4857
+ lines.push(` - ${port}`);
4858
+ }
4859
+ lines.push(" # vscodeAutoForward: false");
4860
+ lines.push("");
4220
4861
  } else {
4221
- renderRoutingHintBlock(lines);
4862
+ pushSectionHeader(
4863
+ lines,
4864
+ routingHeader(name),
4865
+ /* commented */
4866
+ true
4867
+ );
4868
+ lines.push("# routing:");
4869
+ lines.push("# ports:");
4870
+ lines.push("# - 3000");
4871
+ lines.push("# - 5173");
4872
+ lines.push("# vscodeAutoForward: false");
4873
+ lines.push("");
4222
4874
  }
4223
4875
  return ensureTrailingNewline(lines.join("\n"));
4224
4876
  }
4225
- var COMMENT_WIDTH = 72;
4877
+ var LANGUAGES_HEADER = "Language runtimes installed inside the dev-container. Pick the ones your projects build against. The catalog of available runtimes is shown by `monoceros list-components`.";
4878
+ var SERVICES_HEADER = "Sibling containers that run alongside the dev-container (databases, caches, message queues, \u2026). Each service is reachable from inside the dev-container by its name as hostname (e.g. `postgres://postgres:5432`). Activating any service switches the container to docker-compose mode automatically.";
4879
+ 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`.";
4880
+ 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`.";
4881
+ 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`.";
4882
+ function routingHeader(name) {
4883
+ 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\`.`;
4884
+ }
4226
4885
  function renderFeatureBlock(out, feature, summary, commented) {
4227
- const c = commented ? "# " : " ";
4228
- const optionHints = summary?.optionHints ?? [];
4229
- const optionDescriptions = summary?.optionDescriptions ?? {};
4230
- const usageNotes = summary?.usageNotes ?? [];
4231
- for (let i = 0; i < usageNotes.length; i++) {
4232
- if (i > 0) out.push(`${c}#`);
4233
- for (const line of wrapToComment(
4234
- usageNotes[i],
4235
- COMMENT_WIDTH - c.length
4236
- )) {
4237
- out.push(`${c}# ${line}`);
4238
- }
4239
- }
4240
- out.push(`${c}- ref: ${feature.ref}`);
4886
+ const yamlPrefix = commented ? "# " : "";
4887
+ const headerLines = buildHeaderLines(summary);
4888
+ for (const line of headerLines) {
4889
+ const wrapped = wrapToComment(line, COMMENT_WIDTH - 2);
4890
+ for (const wl of wrapped) {
4891
+ out.push(`# ${wl}`.trimEnd());
4892
+ }
4893
+ }
4894
+ out.push(`${yamlPrefix} - ref: ${feature.ref}`);
4241
4895
  const options = feature.options ?? {};
4242
- const activeOptions = Object.entries(options);
4243
- const remainingHints = optionHints.filter((h) => !(h in options));
4244
- if (activeOptions.length > 0) {
4245
- out.push(`${c} options:`);
4246
- for (const [key, value] of activeOptions) {
4247
- out.push(`${c} ${key}: ${renderScalarValue(value)}`);
4248
- }
4249
- if (remainingHints.length > 0) {
4250
- out.push(
4251
- `${c} # Optional \u2014 override monoceros-config.yml defaults.features:`
4252
- );
4253
- for (const hint of remainingHints) {
4254
- emitHint(out, hint, optionDescriptions[hint], `${c} `);
4255
- }
4896
+ const activeKeys = Object.entries(options);
4897
+ const hintKeys = (summary?.optionHints ?? []).filter((h) => !(h in options));
4898
+ if (activeKeys.length === 0 && hintKeys.length === 0) return;
4899
+ if (commented) {
4900
+ out.push(`${yamlPrefix} options:`);
4901
+ for (const [key, value] of activeKeys) {
4902
+ out.push(`${yamlPrefix} ${key}: ${renderScalarValue(value)}`);
4256
4903
  }
4257
- } else if (remainingHints.length > 0) {
4258
- out.push(
4259
- `${c} # Optional \u2014 override monoceros-config.yml defaults.features:`
4260
- );
4261
- out.push(`${c} # options:`);
4262
- for (const hint of remainingHints) {
4263
- emitHint(out, hint, optionDescriptions[hint], `${c} # `);
4904
+ for (const key of hintKeys) {
4905
+ out.push(`${yamlPrefix} ${key}:`);
4906
+ }
4907
+ return;
4908
+ }
4909
+ if (activeKeys.length > 0) {
4910
+ out.push(` options:`);
4911
+ for (const [key, value] of activeKeys) {
4912
+ out.push(` ${key}: ${renderScalarValue(value)}`);
4264
4913
  }
4265
4914
  }
4266
- }
4267
- function emitHint(out, hint, description, linePrefix) {
4268
- if (description) {
4269
- for (const line of wrapToComment(
4270
- description,
4271
- COMMENT_WIDTH - linePrefix.length
4272
- )) {
4273
- out.push(`${linePrefix}# ${line}`);
4915
+ if (hintKeys.length > 0) {
4916
+ out.push(` # options:`);
4917
+ for (const key of hintKeys) {
4918
+ out.push(` # ${key}:`);
4274
4919
  }
4275
4920
  }
4276
- out.push(`${linePrefix}${hint}:`);
4277
4921
  }
4278
- function renderReposBlock(out, urls, commented) {
4279
- out.push("# Repos \u2014 git repositories cloned into projects/ during");
4280
- out.push("# post-create. HTTPS URLs only. Provider auto-detected");
4281
- out.push("# for github.com, gitlab.com, bitbucket.org; for any other host");
4282
- out.push("# (self-hosted GitLab, Bitbucket Data Center, Gitea/Forgejo,");
4283
- out.push("# GitHub Enterprise, \u2026) declare provider explicitly.");
4284
- out.push("#");
4285
- if (commented) {
4286
- out.push("# repos:");
4287
- out.push("# - url: https://github.com/<org>/<repo>.git");
4288
- out.push(
4289
- "# # path: <folder> # subfolder under projects/; default: URL-derived"
4290
- );
4291
- out.push(
4292
- "# # provider: github # github | gitlab | bitbucket | gitea"
4293
- );
4294
- out.push(
4295
- "# # git: # per-repo committer identity override"
4296
- );
4297
- out.push("# # user:");
4298
- out.push("# # name: Your Name");
4299
- out.push("# # email: you@example.com");
4300
- out.push("");
4301
- return;
4922
+ function buildHeaderLines(summary) {
4923
+ const out = [];
4924
+ if (!summary) {
4925
+ return out;
4926
+ }
4927
+ const tagline = summary.name?.trim();
4928
+ const description = summary.description?.trim();
4929
+ if (tagline && description) {
4930
+ out.push(`${tagline} \u2014 ${description}`);
4931
+ } else if (tagline) {
4932
+ out.push(tagline);
4933
+ } else if (description) {
4934
+ out.push(description);
4935
+ }
4936
+ for (const note of summary.usageNotes) {
4937
+ const trimmed = note.trim();
4938
+ if (trimmed.length > 0) out.push(trimmed);
4939
+ }
4940
+ if (summary.optionHints.length > 0) {
4941
+ const parts = summary.optionHints.map((key) => {
4942
+ const desc = summary.optionDescriptions[key];
4943
+ const short = desc ? shortenOptionDescription(desc) : void 0;
4944
+ return short ? `${key} (${short})` : key;
4945
+ });
4946
+ out.push(`Options: ${parts.join(", ")}.`);
4302
4947
  }
4303
- out.push("repos:");
4304
- for (const url of urls) {
4305
- const derivedPath = deriveDefaultPath(url);
4306
- out.push(` - url: ${url}`);
4307
- out.push(
4308
- ` # path: ${derivedPath} # subfolder under projects/; default: URL-derived (${derivedPath})`
4309
- );
4310
- out.push(
4311
- " # provider: github # github | gitlab | bitbucket | gitea"
4312
- );
4313
- out.push(
4314
- " # git: # per-repo committer identity override"
4315
- );
4316
- out.push(" # user:");
4317
- out.push(" # name: Your Name");
4318
- out.push(" # email: you@example.com");
4319
- }
4320
- out.push("");
4321
- }
4322
- function renderRoutingHintBlock(out) {
4323
- out.push("# Routing \u2014 expose container ports to the host through the");
4324
- out.push("# shared Traefik singleton. Once any port is declared the");
4325
- out.push("# container joins the monoceros-proxy network and the proxy");
4326
- out.push("# routes <name>.localhost (default port) and");
4327
- out.push("# <name>-<port>.localhost (explicit). `monoceros add-port`");
4328
- out.push("# manages the list; the block appears on first add. You can");
4329
- out.push("# also pre-seed at init time via `--with-ports=3000,5173,\u2026`.");
4330
- out.push("#");
4331
- out.push("# routing:");
4332
- out.push("# ports: # internal container ports");
4333
- out.push(
4334
- "# - 3000 # first entry doubles as <name>.localhost"
4335
- );
4336
- out.push("# - 5173");
4337
- out.push(
4338
- "# vscodeAutoForward: false # default: false. Traefik is the single"
4339
- );
4340
- out.push(
4341
- "# # source of truth \u2014 set true only if you"
4342
- );
4343
- out.push(
4344
- "# # want VS Code's port panel as primary."
4345
- );
4346
- out.push("");
4347
- }
4348
- function renderActiveRoutingBlock(out, name, ports) {
4349
- out.push("# Routing \u2014 expose these container ports to the host through");
4350
- out.push("# the shared Traefik singleton. First entry doubles as");
4351
- out.push(`# http://${name}.localhost (the default route).`);
4352
- out.push("routing:");
4353
- out.push(" ports:");
4354
- ports.forEach((port, idx) => {
4355
- if (idx === 0) {
4356
- out.push(` - ${port} # default \u2192 http://${name}.localhost`);
4357
- } else {
4358
- out.push(` - ${port}`);
4359
- }
4360
- });
4361
- out.push(" # vscodeAutoForward: false # set true to keep VS Code's");
4362
- out.push(" # # port-panel alongside Traefik");
4363
- out.push("");
4948
+ if (summary.documentationURL) {
4949
+ out.push(`See ${summary.documentationURL} for further information.`);
4950
+ }
4951
+ return out;
4364
4952
  }
4365
- function deriveDefaultPath(url) {
4366
- let last = url;
4367
- const slash = url.lastIndexOf("/");
4368
- if (slash >= 0) last = url.slice(slash + 1);
4369
- if (last.endsWith(".git")) last = last.slice(0, -4);
4370
- return last || "repo";
4953
+ function shortenOptionDescription(desc) {
4954
+ const firstSentence = desc.split(/(?<=[.!?])\s+/)[0]?.trim() ?? desc.trim();
4955
+ return firstSentence.replace(/[.!?]+$/, "").trim();
4956
+ }
4957
+ function pushHeader(out, header, name) {
4958
+ for (const line of header.replace(/<name>/g, name).split("\n")) {
4959
+ out.push(line);
4960
+ }
4961
+ }
4962
+ function pushSectionHeader(out, text, _commented) {
4963
+ void _commented;
4964
+ const wrapped = wrapToComment(text, COMMENT_WIDTH - 2);
4965
+ for (const wl of wrapped) {
4966
+ out.push(`# ${wl}`.trimEnd());
4967
+ }
4371
4968
  }
4372
4969
  function wrapToComment(text, width) {
4373
4970
  const words = text.split(/\s+/).filter((w) => w.length > 0);
@@ -4416,10 +5013,10 @@ function ensureTrailingNewline(s) {
4416
5013
 
4417
5014
  // src/init/manifest.ts
4418
5015
  import { existsSync as existsSync6, readFileSync as readFileSync3 } from "fs";
4419
- import path11 from "path";
5016
+ import path12 from "path";
4420
5017
  function resolveManifestPath(name, checkoutRoot) {
4421
5018
  if (checkoutRoot) {
4422
- const checkoutPath = path11.join(
5019
+ const checkoutPath = path12.join(
4423
5020
  checkoutRoot,
4424
5021
  "images",
4425
5022
  "features",
@@ -4428,7 +5025,7 @@ function resolveManifestPath(name, checkoutRoot) {
4428
5025
  );
4429
5026
  if (existsSync6(checkoutPath)) return checkoutPath;
4430
5027
  }
4431
- const bundlePath = path11.join(
5028
+ const bundlePath = path12.join(
4432
5029
  bundledFeaturesDir(),
4433
5030
  name,
4434
5031
  "devcontainer-feature.json"
@@ -4460,7 +5057,18 @@ function loadFeatureManifestSummary(ref, checkoutRoot = workbenchCheckoutRoot())
4460
5057
  }
4461
5058
  }
4462
5059
  }
4463
- return { optionHints, optionDescriptions, usageNotes };
5060
+ const name = typeof parsed.name === "string" ? parsed.name : "";
5061
+ const description = typeof parsed.description === "string" ? parsed.description : "";
5062
+ const rawUrl = typeof parsed.documentationURL === "string" ? parsed.documentationURL.trim() : "";
5063
+ const documentationURL = rawUrl.length > 0 && rawUrl.toLowerCase() !== "tbd" ? rawUrl : void 0;
5064
+ return {
5065
+ name,
5066
+ description,
5067
+ documentationURL,
5068
+ optionHints,
5069
+ optionDescriptions,
5070
+ usageNotes
5071
+ };
4464
5072
  } catch {
4465
5073
  return void 0;
4466
5074
  }
@@ -4538,6 +5146,17 @@ async function runInit(opts) {
4538
5146
  seenPorts.add(raw);
4539
5147
  ports.push(raw);
4540
5148
  }
5149
+ let promptedIdentity;
5150
+ if (repos.length > 0) {
5151
+ const globalConfig = await readMonocerosConfig({ monocerosHome: home });
5152
+ promptedIdentity = await resolveIdentityWithPrompt({
5153
+ ...opts.identitySpawn ? { spawn: opts.identitySpawn } : {},
5154
+ ...opts.identityPrompt ? { prompt: opts.identityPrompt } : {},
5155
+ ...opts.identityScopePrompt ? { scopePrompt: opts.identityScopePrompt } : {},
5156
+ ...globalConfig?.defaults?.git?.user ? { defaults: globalConfig.defaults.git.user } : {},
5157
+ logger: { info: logger.info, warn: logger.info }
5158
+ });
5159
+ }
4541
5160
  let text;
4542
5161
  const requested = opts.with ?? [];
4543
5162
  if (requested.length === 0) {
@@ -4548,6 +5167,51 @@ async function runInit(opts) {
4548
5167
  }
4549
5168
  await fs12.mkdir(containerConfigsDir(home), { recursive: true });
4550
5169
  await fs12.writeFile(dest, text, "utf8");
5170
+ if (promptedIdentity?.prompted) {
5171
+ const { name, email, scope } = promptedIdentity.prompted;
5172
+ if (scope === "g" || scope === "b") {
5173
+ try {
5174
+ const result = await writeGlobalDefaultGitUser(
5175
+ { name, email },
5176
+ { monocerosHome: home }
5177
+ );
5178
+ if (result.alreadySet) {
5179
+ logger.info(
5180
+ `monoceros-config.yml already had a defaults.git.user \u2014 left it alone.`
5181
+ );
5182
+ } else if (result.created) {
5183
+ logger.info(
5184
+ `Saved identity globally \u2014 created ${prettyPath(result.filePath)} with defaults.git.user.`
5185
+ );
5186
+ } else {
5187
+ logger.info(
5188
+ `Saved identity globally to ${prettyPath(result.filePath)}.`
5189
+ );
5190
+ }
5191
+ } catch (err) {
5192
+ logger.info(
5193
+ `Could not persist identity to monoceros-config.yml: ${err instanceof Error ? err.message : String(err)}. \`monoceros apply\` will re-prompt.`
5194
+ );
5195
+ }
5196
+ }
5197
+ if (scope === "c" || scope === "b") {
5198
+ try {
5199
+ const written = await fs12.readFile(dest, "utf8");
5200
+ const parsed = parseConfig(written, dest);
5201
+ const changed = setContainerGitUserInDoc(parsed.doc, { name, email });
5202
+ if (changed) {
5203
+ await fs12.writeFile(dest, stringifyConfig(parsed.doc), "utf8");
5204
+ logger.info(
5205
+ `Saved identity in ${prettyPath(dest)} (container-level git.user).`
5206
+ );
5207
+ }
5208
+ } catch (err) {
5209
+ logger.info(
5210
+ `Could not persist identity into ${prettyPath(dest)}: ${err instanceof Error ? err.message : String(err)}. \`monoceros apply\` will re-prompt.`
5211
+ );
5212
+ }
5213
+ }
5214
+ }
4551
5215
  const documented = requested.length === 0;
4552
5216
  const displayPath = prettyPath(dest);
4553
5217
  if (documented) {
@@ -4971,7 +5635,7 @@ import { createInterface } from "readline/promises";
4971
5635
 
4972
5636
  // src/remove/index.ts
4973
5637
  import { existsSync as existsSync8, promises as fs13 } from "fs";
4974
- import path12 from "path";
5638
+ import path13 from "path";
4975
5639
  import { consola as consola19 } from "consola";
4976
5640
  async function runRemove(opts) {
4977
5641
  const home = opts.monocerosHome ?? monocerosHome();
@@ -5022,13 +5686,13 @@ async function runRemove(opts) {
5022
5686
  let backupPath = null;
5023
5687
  if (!opts.noBackup && (hasYml || hasContainer)) {
5024
5688
  const ts = (opts.now ?? /* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
5025
- backupPath = path12.join(home, "container-backups", `${opts.name}-${ts}`);
5689
+ backupPath = path13.join(home, "container-backups", `${opts.name}-${ts}`);
5026
5690
  await fs13.mkdir(backupPath, { recursive: true });
5027
5691
  if (hasYml) {
5028
- await fs13.copyFile(ymlPath, path12.join(backupPath, `${opts.name}.yml`));
5692
+ await fs13.copyFile(ymlPath, path13.join(backupPath, `${opts.name}.yml`));
5029
5693
  }
5030
5694
  if (hasContainer) {
5031
- await fs13.cp(containerPath, path12.join(backupPath, "container"), {
5695
+ await fs13.cp(containerPath, path13.join(backupPath, "container"), {
5032
5696
  recursive: true
5033
5697
  });
5034
5698
  }
@@ -5141,7 +5805,7 @@ import { consola as consola22 } from "consola";
5141
5805
 
5142
5806
  // src/restore/index.ts
5143
5807
  import { existsSync as existsSync9, promises as fs14 } from "fs";
5144
- import path13 from "path";
5808
+ import path14 from "path";
5145
5809
  import { consola as consola21 } from "consola";
5146
5810
  async function runRestore(opts) {
5147
5811
  const home = opts.monocerosHome ?? monocerosHome();
@@ -5149,7 +5813,7 @@ async function runRestore(opts) {
5149
5813
  info: (msg) => consola21.info(msg),
5150
5814
  success: (msg) => consola21.success(msg)
5151
5815
  };
5152
- const backup = path13.resolve(opts.backupPath);
5816
+ const backup = path14.resolve(opts.backupPath);
5153
5817
  if (!existsSync9(backup)) {
5154
5818
  throw new Error(`Backup not found: ${backup}.`);
5155
5819
  }
@@ -5171,7 +5835,7 @@ async function runRestore(opts) {
5171
5835
  }
5172
5836
  const ymlFile = ymlFiles[0];
5173
5837
  const name = ymlFile.replace(/\.yml$/, "");
5174
- const containerInBackup = path13.join(backup, "container");
5838
+ const containerInBackup = path14.join(backup, "container");
5175
5839
  const hasContainer = existsSync9(containerInBackup);
5176
5840
  const destYml = containerConfigPath(name, home);
5177
5841
  const destContainer = containerDir(name, home);
@@ -5186,7 +5850,7 @@ async function runRestore(opts) {
5186
5850
  );
5187
5851
  }
5188
5852
  await fs14.mkdir(containerConfigsDir(home), { recursive: true });
5189
- await fs14.copyFile(path13.join(backup, ymlFile), destYml);
5853
+ await fs14.copyFile(path14.join(backup, ymlFile), destYml);
5190
5854
  if (hasContainer) {
5191
5855
  await fs14.cp(containerInBackup, destContainer, { recursive: true });
5192
5856
  }
@@ -5447,7 +6111,7 @@ import { consola as consola28 } from "consola";
5447
6111
 
5448
6112
  // src/devcontainer/shell.ts
5449
6113
  import { existsSync as existsSync10 } from "fs";
5450
- import path14 from "path";
6114
+ import path15 from "path";
5451
6115
  async function runShell(opts) {
5452
6116
  assertContainerExists(opts.root);
5453
6117
  const spawnFn = opts.spawn ?? spawnDevcontainer;
@@ -5470,7 +6134,7 @@ async function runShell(opts) {
5470
6134
  );
5471
6135
  }
5472
6136
  function assertContainerExists(root) {
5473
- if (!existsSync10(path14.join(root, ".devcontainer"))) {
6137
+ if (!existsSync10(path15.join(root, ".devcontainer"))) {
5474
6138
  throw new Error(
5475
6139
  `No .devcontainer/ at ${root}. Run \`monoceros apply <name>\` first.`
5476
6140
  );