@getmonoceros/workbench 1.7.5 → 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);
@@ -1350,6 +1867,23 @@ function buildCodeWorkspaceJson(opts) {
1350
1867
  }
1351
1868
  return { folders };
1352
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
+ }
1353
1887
  function buildPostCreateScript(opts) {
1354
1888
  const lines = [
1355
1889
  "#!/usr/bin/env bash",
@@ -1446,83 +1980,98 @@ function buildPostCreateScript(opts) {
1446
1980
  return lines.join("\n") + "\n";
1447
1981
  }
1448
1982
  async function writePostCreateScript(devcontainerDir, opts) {
1449
- const dest = path4.join(devcontainerDir, "post-create.sh");
1450
- await fs5.writeFile(dest, buildPostCreateScript(opts));
1451
- 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);
1452
1986
  }
1453
1987
  async function writeScaffold(opts, targetDir, scaffoldOpts = {}) {
1454
1988
  const dockerMode = scaffoldOpts.dockerMode ?? "rootful";
1455
- const devcontainerDir = path4.join(targetDir, ".devcontainer");
1456
- const monocerosDir = path4.join(targetDir, ".monoceros");
1457
- const projectsDir = path4.join(targetDir, "projects");
1458
- const homeDir = path4.join(targetDir, "home");
1459
- const dataDir = path4.join(targetDir, "data");
1460
- await fs5.mkdir(devcontainerDir, { recursive: true });
1461
- await fs5.mkdir(monocerosDir, { recursive: true });
1462
- await fs5.mkdir(projectsDir, { recursive: true });
1463
- 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 });
1464
1998
  if (needsCompose(opts)) {
1465
- await fs5.mkdir(dataDir, { recursive: true });
1999
+ await fs6.mkdir(dataDir, { recursive: true });
1466
2000
  for (const svcId of opts.services) {
1467
2001
  const def = SERVICE_CATALOG[svcId];
1468
2002
  if (def?.dataMount) {
1469
- await fs5.mkdir(path4.join(dataDir, def.id), { recursive: true });
2003
+ await fs6.mkdir(path5.join(dataDir, def.id), { recursive: true });
1470
2004
  }
1471
2005
  }
1472
2006
  }
1473
- const containerGitignore = path4.join(targetDir, ".gitignore");
1474
- await fs5.writeFile(containerGitignore, "/home/\n/.monoceros/\n/data/\n");
1475
- 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");
1476
2010
  if (!existsSync2(gitkeep)) {
1477
- await fs5.writeFile(gitkeep, "");
2011
+ await fs6.writeFile(gitkeep, "");
1478
2012
  }
1479
- await fs5.writeFile(
1480
- path4.join(monocerosDir, ".gitignore"),
2013
+ await fs6.writeFile(
2014
+ path5.join(monocerosDir, ".gitignore"),
1481
2015
  "git-credentials*\ngitconfig\n"
1482
2016
  );
1483
2017
  const devcontainerJson = buildDevcontainerJson(opts, dockerMode);
1484
- await fs5.writeFile(
1485
- path4.join(devcontainerDir, "devcontainer.json"),
2018
+ await fs6.writeFile(
2019
+ path5.join(devcontainerDir, "devcontainer.json"),
1486
2020
  JSON.stringify(devcontainerJson, null, 2) + "\n"
1487
2021
  );
1488
- const featuresDir = path4.join(devcontainerDir, "features");
2022
+ const featuresDir = path5.join(devcontainerDir, "features");
1489
2023
  if (existsSync2(featuresDir)) {
1490
- await fs5.rm(featuresDir, { recursive: true, force: true });
2024
+ await fs6.rm(featuresDir, { recursive: true, force: true });
1491
2025
  }
1492
2026
  const resolvedFeatures = resolveFeatures(opts);
1493
2027
  for (const f of resolvedFeatures) {
1494
2028
  if (!f.localSourceDir || !f.localName) continue;
1495
- const dest = path4.join(featuresDir, f.localName);
1496
- await fs5.mkdir(dest, { recursive: true });
1497
- 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 });
1498
2032
  }
1499
2033
  for (const f of resolvedFeatures) {
1500
2034
  for (const sub of f.persistentHomePaths) {
1501
- await fs5.mkdir(path4.join(homeDir, sub), { recursive: true });
2035
+ await fs6.mkdir(path5.join(homeDir, sub), { recursive: true });
1502
2036
  }
1503
2037
  for (const entry2 of f.persistentHomeFiles) {
1504
- const filePath = path4.join(homeDir, entry2.path);
1505
- 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 });
1506
2040
  if (!existsSync2(filePath)) {
1507
- await fs5.writeFile(filePath, entry2.initialContent);
2041
+ await fs6.writeFile(filePath, entry2.initialContent);
1508
2042
  }
1509
2043
  }
1510
2044
  }
1511
2045
  await writePostCreateScript(devcontainerDir, opts);
1512
- const composePath = path4.join(devcontainerDir, "compose.yaml");
2046
+ const composePath = path5.join(devcontainerDir, "compose.yaml");
1513
2047
  if (needsCompose(opts)) {
1514
- await fs5.writeFile(composePath, buildComposeYaml(opts, dockerMode));
2048
+ await fs6.writeFile(composePath, buildComposeYaml(opts, dockerMode));
1515
2049
  } else if (existsSync2(composePath)) {
1516
- await fs5.rm(composePath);
2050
+ await fs6.rm(composePath);
1517
2051
  }
1518
- await fs5.writeFile(
1519
- path4.join(targetDir, `${opts.name}.code-workspace`),
1520
- JSON.stringify(buildCodeWorkspaceJson(opts), null, 2) + "\n"
1521
- );
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");
1522
2063
  }
1523
2064
 
1524
2065
  // src/modify/yml.ts
1525
- 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";
1526
2075
  function ensureSeq(doc, key) {
1527
2076
  const existing = doc.get(key, true);
1528
2077
  if (existing && isSeq(existing)) return existing;
@@ -1561,12 +2110,108 @@ function addAptPackagesToDoc(doc, packages) {
1561
2110
  }
1562
2111
  return changed;
1563
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");
1564
2209
  function portOfItem(item) {
1565
2210
  const scalar = scalarValue(item);
1566
2211
  if (typeof scalar === "number" && Number.isInteger(scalar)) {
1567
2212
  return scalar;
1568
2213
  }
1569
- if (isMap(item)) {
2214
+ if (isMap2(item)) {
1570
2215
  const p = item.get("port");
1571
2216
  if (typeof p === "number" && Number.isInteger(p)) return p;
1572
2217
  }
@@ -1574,8 +2219,8 @@ function portOfItem(item) {
1574
2219
  }
1575
2220
  function ensureRoutingMap(doc) {
1576
2221
  const existing = doc.get("routing", true);
1577
- if (existing && isMap(existing)) return existing;
1578
- const map = new YAMLMap();
2222
+ if (existing && isMap2(existing)) return existing;
2223
+ const map = new YAMLMap2();
1579
2224
  doc.set("routing", map);
1580
2225
  return map;
1581
2226
  }
@@ -1619,7 +2264,7 @@ function addPortsToDoc(doc, ports) {
1619
2264
  }
1620
2265
  function removePortsFromDoc(doc, ports) {
1621
2266
  const routing = doc.get("routing", true);
1622
- if (!routing || !isMap(routing)) return false;
2267
+ if (!routing || !isMap2(routing)) return false;
1623
2268
  const seq = routing.get("ports", true);
1624
2269
  if (!seq || !isSeq(seq)) return false;
1625
2270
  const targets = new Set(ports);
@@ -1646,7 +2291,7 @@ function addInstallUrlToDoc(doc, url) {
1646
2291
  function addFeatureToDoc(doc, ref, options = {}) {
1647
2292
  const seq = ensureSeq(doc, "features");
1648
2293
  for (const item of seq.items) {
1649
- if (!isMap(item)) continue;
2294
+ if (!isMap2(item)) continue;
1650
2295
  const itemRef = item.get("ref");
1651
2296
  if (itemRef !== ref) continue;
1652
2297
  const itemJs = item.toJS(doc);
@@ -1658,7 +2303,7 @@ function addFeatureToDoc(doc, ref, options = {}) {
1658
2303
  `Feature ${ref} is already configured with different options. Remove it first (\`monoceros remove-feature ${ref}\`) before re-adding.`
1659
2304
  );
1660
2305
  }
1661
- const entry2 = new YAMLMap();
2306
+ const entry2 = new YAMLMap2();
1662
2307
  entry2.set("ref", ref);
1663
2308
  if (Object.keys(options).length > 0) {
1664
2309
  entry2.set("options", options);
@@ -1669,16 +2314,16 @@ function addFeatureToDoc(doc, ref, options = {}) {
1669
2314
  function addRepoToDoc(doc, repo) {
1670
2315
  const seq = ensureSeq(doc, "repos");
1671
2316
  for (const item of seq.items) {
1672
- if (!isMap(item)) continue;
2317
+ if (!isMap2(item)) continue;
1673
2318
  const url = item.get("url");
1674
2319
  if (url !== repo.url) continue;
1675
2320
  const existingPath = item.get("path");
1676
2321
  const effectivePath = typeof existingPath === "string" ? existingPath : deriveRepoName(url);
1677
2322
  if (effectivePath !== repo.path) continue;
1678
2323
  const existingGit = item.get("git", true);
1679
- const existingUser = existingGit && isMap(existingGit) ? existingGit.get("user", true) : null;
1680
- const existingName = existingUser && isMap(existingUser) ? existingUser.get("name") : null;
1681
- 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;
1682
2327
  const existingGitUser = typeof existingName === "string" && typeof existingEmail === "string" ? { name: existingName, email: existingEmail } : void 0;
1683
2328
  const sameGitUser = (existingGitUser?.name ?? null) === (repo.gitUser?.name ?? null) && (existingGitUser?.email ?? null) === (repo.gitUser?.email ?? null);
1684
2329
  const existingProvider = item.get("provider");
@@ -1687,8 +2332,8 @@ function addRepoToDoc(doc, repo) {
1687
2332
  return false;
1688
2333
  }
1689
2334
  if (repo.gitUser) {
1690
- const gitMap = new YAMLMap();
1691
- const userMap = new YAMLMap();
2335
+ const gitMap = new YAMLMap2();
2336
+ const userMap = new YAMLMap2();
1692
2337
  userMap.set("name", repo.gitUser.name);
1693
2338
  userMap.set("email", repo.gitUser.email);
1694
2339
  gitMap.set("user", userMap);
@@ -1701,16 +2346,18 @@ function addRepoToDoc(doc, repo) {
1701
2346
  } else {
1702
2347
  item.delete("provider");
1703
2348
  }
2349
+ relocateLeakedSectionComments(doc);
1704
2350
  return true;
1705
2351
  }
1706
- const entry2 = new YAMLMap();
2352
+ const entry2 = new YAMLMap2();
1707
2353
  entry2.set("url", repo.url);
1708
- if (repo.path !== deriveRepoName(repo.url)) {
2354
+ const persistPath = repo.path !== deriveRepoName(repo.url);
2355
+ if (persistPath) {
1709
2356
  entry2.set("path", repo.path);
1710
2357
  }
1711
2358
  if (repo.gitUser) {
1712
- const gitMap = new YAMLMap();
1713
- const userMap = new YAMLMap();
2359
+ const gitMap = new YAMLMap2();
2360
+ const userMap = new YAMLMap2();
1714
2361
  userMap.set("name", repo.gitUser.name);
1715
2362
  userMap.set("email", repo.gitUser.email);
1716
2363
  gitMap.set("user", userMap);
@@ -1719,7 +2366,20 @@ function addRepoToDoc(doc, repo) {
1719
2366
  if (repo.provider) {
1720
2367
  entry2.set("provider", repo.provider);
1721
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
+ }
1722
2381
  seq.add(entry2);
2382
+ relocateLeakedSectionComments(doc);
1723
2383
  return true;
1724
2384
  }
1725
2385
  function removeLanguageFromDoc(doc, lang) {
@@ -1744,7 +2404,7 @@ function removeInstallUrlFromDoc(doc, url) {
1744
2404
  function removeFeatureFromDoc(doc, ref) {
1745
2405
  const seq = doc.get("features", true);
1746
2406
  if (!seq || !isSeq(seq)) return false;
1747
- 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);
1748
2408
  if (idx < 0) return false;
1749
2409
  seq.items.splice(idx, 1);
1750
2410
  pruneEmptySeq(doc, "features");
@@ -1754,11 +2414,11 @@ function removeRepoFromDoc(doc, urlOrPath) {
1754
2414
  const seq = doc.get("repos", true);
1755
2415
  if (!seq || !isSeq(seq)) return false;
1756
2416
  const idx = seq.items.findIndex((item) => {
1757
- if (!isMap(item)) return false;
2417
+ if (!isMap2(item)) return false;
1758
2418
  const url = item.get("url");
1759
2419
  if (url === urlOrPath) return true;
1760
- const path15 = item.get("path");
1761
- 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;
1762
2422
  return effectivePath === urlOrPath;
1763
2423
  });
1764
2424
  if (idx < 0) return false;
@@ -1808,7 +2468,7 @@ async function runAddRepo(input) {
1808
2468
  "Missing repo URL. Usage: monoceros add-repo <containername> <url>."
1809
2469
  );
1810
2470
  }
1811
- const path15 = (input.path ?? deriveRepoName(url)).trim();
2471
+ const path16 = (input.path ?? deriveRepoName(url)).trim();
1812
2472
  const hasName = typeof input.gitName === "string" && input.gitName.trim().length > 0;
1813
2473
  const hasEmail = typeof input.gitEmail === "string" && input.gitEmail.trim().length > 0;
1814
2474
  if (hasName !== hasEmail) {
@@ -1837,7 +2497,7 @@ async function runAddRepo(input) {
1837
2497
  const providerToWrite = !canonical && explicitProvider ? explicitProvider : void 0;
1838
2498
  const entry2 = {
1839
2499
  url,
1840
- path: path15,
2500
+ path: path16,
1841
2501
  ...hasName && hasEmail ? {
1842
2502
  gitUser: {
1843
2503
  name: input.gitName.trim(),
@@ -1846,7 +2506,117 @@ async function runAddRepo(input) {
1846
2506
  } : {},
1847
2507
  ...providerToWrite ? { provider: providerToWrite } : {}
1848
2508
  };
1849
- 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, `'\\''`)}'`;
1850
2620
  }
1851
2621
  function normalizeProvider(raw) {
1852
2622
  if (typeof raw !== "string") return void 0;
@@ -1982,7 +2752,7 @@ async function mutate(opts, apply) {
1982
2752
  const logger = opts.logger ?? defaultLogger();
1983
2753
  let oldText;
1984
2754
  try {
1985
- oldText = await fs6.readFile(ymlPath, "utf8");
2755
+ oldText = await fs7.readFile(ymlPath, "utf8");
1986
2756
  } catch {
1987
2757
  throw new Error(
1988
2758
  `No such config: ${ymlPath}. Run \`monoceros init <template> ${opts.name}\` first.`
@@ -2006,7 +2776,7 @@ async function mutate(opts, apply) {
2006
2776
  return { status: "aborted" };
2007
2777
  }
2008
2778
  }
2009
- await fs6.writeFile(ymlPath, newText, "utf8");
2779
+ await fs7.writeFile(ymlPath, newText, "utf8");
2010
2780
  logger.success(`Updated ${ymlPath}.`);
2011
2781
  logger.info(
2012
2782
  `Run \`monoceros apply ${opts.name}\` to rebuild the dev-container and pick up the change.`
@@ -2264,179 +3034,21 @@ function printSecurityWarning(url) {
2264
3034
  w(
2265
3035
  " 2. Verify the maintainer is who you think they are (HTTPS cert, repo)."
2266
3036
  );
2267
- w(" 3. Ideally, vendor the install steps as `add-apt-packages` or");
2268
- w(
2269
- " `add-feature` instead \u2014 those reference signed/versioned artifacts."
2270
- );
2271
- w("");
2272
- }
2273
-
2274
- // src/commands/add-repo.ts
2275
- import { defineCommand as defineCommand4 } from "citty";
2276
- import { consola as consola5 } from "consola";
2277
- var addRepoCommand = defineCommand4({
2278
- meta: {
2279
- name: "add-repo",
2280
- group: "edit",
2281
- 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."
2282
- },
2283
- args: {
2284
- name: {
2285
- type: "positional",
2286
- description: "Container name (yml in $MONOCEROS_HOME/container-configs/).",
2287
- required: true
2288
- },
2289
- url: {
2290
- type: "positional",
2291
- description: "Git URL (HTTPS or SSH/git@ form). E.g. https://github.com/foo/bar.git, git@github.com:foo/bar.git.",
2292
- required: true
2293
- },
2294
- path: {
2295
- type: "string",
2296
- description: "Destination under projects/. Subfolders via `/` (e.g. apps/web). Default: URL-derived single segment (bar.git \u2192 bar)."
2297
- },
2298
- "git-name": {
2299
- type: "string",
2300
- description: "Per-repo git committer name. Overrides the container-level git.user.name for this repo only. Pair with --git-email."
2301
- },
2302
- "git-email": {
2303
- type: "string",
2304
- description: "Per-repo git committer email. Overrides the container-level git.user.email for this repo only. Pair with --git-name."
2305
- },
2306
- provider: {
2307
- type: "string",
2308
- 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."
2309
- },
2310
- yes: {
2311
- type: "boolean",
2312
- description: "Skip the interactive confirmation and apply the diff.",
2313
- alias: ["y"],
2314
- default: false
2315
- }
2316
- },
2317
- async run({ args }) {
2318
- try {
2319
- const result = await runAddRepo({
2320
- name: args.name,
2321
- url: args.url,
2322
- ...typeof args.path === "string" ? { path: args.path } : {},
2323
- ...typeof args["git-name"] === "string" ? { gitName: args["git-name"] } : {},
2324
- ...typeof args["git-email"] === "string" ? { gitEmail: args["git-email"] } : {},
2325
- ...typeof args.provider === "string" ? { provider: args.provider } : {},
2326
- yes: args.yes
2327
- });
2328
- process.exit(result.status === "aborted" ? 1 : 0);
2329
- } catch (err) {
2330
- consola5.error(err instanceof Error ? err.message : String(err));
2331
- process.exit(1);
2332
- }
2333
- }
2334
- });
2335
-
2336
- // src/commands/add-language.ts
2337
- import { defineCommand as defineCommand5 } from "citty";
2338
- import { consola as consola6 } from "consola";
2339
- var addLanguageCommand = defineCommand5({
2340
- meta: {
2341
- name: "add-language",
2342
- group: "edit",
2343
- description: "Add a language toolchain (devcontainer feature) to the container config. Idempotent, prints a diff before writing."
2344
- },
2345
- args: {
2346
- name: {
2347
- type: "positional",
2348
- description: "Container name (yml in $MONOCEROS_HOME/container-configs/).",
2349
- required: true
2350
- },
2351
- language: {
2352
- type: "positional",
2353
- description: "Language identifier from the feature whitelist (e.g. python, java, rust).",
2354
- required: true
2355
- },
2356
- yes: {
2357
- type: "boolean",
2358
- description: "Skip the interactive confirmation and apply the diff.",
2359
- alias: ["y"],
2360
- default: false
2361
- }
2362
- },
2363
- async run({ args }) {
2364
- try {
2365
- const result = await runAddLanguage({
2366
- name: args.name,
2367
- language: args.language,
2368
- yes: args.yes
2369
- });
2370
- process.exit(result.status === "aborted" ? 1 : 0);
2371
- } catch (err) {
2372
- consola6.error(err instanceof Error ? err.message : String(err));
2373
- process.exit(1);
2374
- }
2375
- }
2376
- });
2377
-
2378
- // src/commands/add-port.ts
2379
- import { defineCommand as defineCommand6 } from "citty";
2380
- import { consola as consola7 } from "consola";
2381
- var addPortCommand = defineCommand6({
2382
- meta: {
2383
- name: "add-port",
2384
- group: "edit",
2385
- 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)."
2386
- },
2387
- args: {
2388
- name: {
2389
- type: "positional",
2390
- description: "Container name (yml in $MONOCEROS_HOME/container-configs/).",
2391
- required: true
2392
- },
2393
- yes: {
2394
- type: "boolean",
2395
- description: "Skip the interactive confirmation and apply the diff.",
2396
- alias: ["y"],
2397
- default: false
2398
- },
2399
- default: {
2400
- type: "boolean",
2401
- 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.",
2402
- default: false
2403
- }
2404
- },
2405
- async run({ args }) {
2406
- const tokens = [...getInnerArgs()];
2407
- if (tokens.length === 0) {
2408
- consola7.error(
2409
- "No ports given. Usage: `monoceros add-port <containername> [--yes] [--default] -- <port> [<port> \u2026]`."
2410
- );
2411
- process.exit(1);
2412
- }
2413
- try {
2414
- const result = await runAddPort({
2415
- name: args.name,
2416
- ports: tokens.map(coerceToken),
2417
- yes: args.yes,
2418
- asDefault: args.default
2419
- });
2420
- process.exit(result.status === "aborted" ? 1 : 0);
2421
- } catch (err) {
2422
- consola7.error(err instanceof Error ? err.message : String(err));
2423
- process.exit(1);
2424
- }
2425
- }
2426
- });
2427
- function coerceToken(raw) {
2428
- const n = Number(raw);
2429
- 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("");
2430
3042
  }
2431
3043
 
2432
- // src/commands/add-service.ts
2433
- import { defineCommand as defineCommand7 } from "citty";
2434
- import { consola as consola8 } from "consola";
2435
- 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({
2436
3048
  meta: {
2437
- name: "add-service",
3049
+ name: "add-repo",
2438
3050
  group: "edit",
2439
- 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."
2440
3052
  },
2441
3053
  args: {
2442
3054
  name: {
@@ -2444,11 +3056,27 @@ var addServiceCommand = defineCommand7({
2444
3056
  description: "Container name (yml in $MONOCEROS_HOME/container-configs/).",
2445
3057
  required: true
2446
3058
  },
2447
- service: {
3059
+ url: {
2448
3060
  type: "positional",
2449
- 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.",
2450
3062
  required: true
2451
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
+ },
2452
3080
  yes: {
2453
3081
  type: "boolean",
2454
3082
  description: "Skip the interactive confirmation and apply the diff.",
@@ -2458,680 +3086,513 @@ var addServiceCommand = defineCommand7({
2458
3086
  },
2459
3087
  async run({ args }) {
2460
3088
  try {
2461
- const result = await runAddService({
3089
+ const result = await runAddRepo({
2462
3090
  name: args.name,
2463
- service: args.service,
2464
- yes: args.yes
2465
- });
2466
- process.exit(result.status === "aborted" ? 1 : 0);
2467
- } catch (err) {
2468
- consola8.error(err instanceof Error ? err.message : String(err));
2469
- process.exit(1);
2470
- }
2471
- }
2472
- });
2473
-
2474
- // src/commands/apply.ts
2475
- import { defineCommand as defineCommand8 } from "citty";
2476
-
2477
- // src/apply/index.ts
2478
- import { existsSync as existsSync4, promises as fs10 } from "fs";
2479
- import { consola as consola11 } from "consola";
2480
-
2481
- // src/config/state.ts
2482
- import { promises as fs7 } from "fs";
2483
- import path5 from "path";
2484
- function buildStateFile(opts) {
2485
- return {
2486
- schemaVersion: CONFIG_SCHEMA_VERSION,
2487
- origin: opts.origin,
2488
- monocerosCliVersion: opts.cliVersion,
2489
- materializedAt: (opts.now ?? /* @__PURE__ */ new Date()).toISOString()
2490
- };
2491
- }
2492
- function stateFilePath(targetDir) {
2493
- return path5.join(targetDir, ".monoceros", "state.json");
2494
- }
2495
- async function readStateFile(targetDir) {
2496
- try {
2497
- const content = await fs7.readFile(stateFilePath(targetDir), "utf8");
2498
- return JSON.parse(content);
2499
- } catch {
2500
- return void 0;
2501
- }
2502
- }
2503
- async function writeStateFile(targetDir, state) {
2504
- const monocerosDir = path5.join(targetDir, ".monoceros");
2505
- await fs7.mkdir(monocerosDir, { recursive: true });
2506
- await fs7.writeFile(
2507
- stateFilePath(targetDir),
2508
- JSON.stringify(state, null, 2) + "\n"
2509
- );
2510
- }
2511
-
2512
- // src/config/transform.ts
2513
- function solutionConfigToCreateOptions(config, featureDefaults = {}) {
2514
- const featureRecord = {};
2515
- for (const entry2 of config.features) {
2516
- const defaults = featureDefaults[entry2.ref] ?? {};
2517
- featureRecord[entry2.ref] = { ...defaults, ...entry2.options ?? {} };
2518
- }
2519
- const result = {
2520
- name: config.name,
2521
- languages: [...config.languages],
2522
- services: [...config.services]
2523
- };
2524
- if (config.externalServices.postgres !== void 0) {
2525
- result.postgresUrl = config.externalServices.postgres;
2526
- }
2527
- if (config.aptPackages.length > 0) {
2528
- result.aptPackages = [...config.aptPackages];
2529
- }
2530
- if (Object.keys(featureRecord).length > 0) {
2531
- result.features = featureRecord;
2532
- }
2533
- if (config.installUrls.length > 0) {
2534
- result.installUrls = [...config.installUrls];
2535
- }
2536
- if (config.repos.length > 0) {
2537
- result.repos = config.repos.map((r) => ({
2538
- url: r.url,
2539
- // `path` is optional in the yml; CreateOptions requires it.
2540
- // When the yml omits `path`, fall back to the URL-derived
2541
- // single-segment default (`https://.../foo.git` → `foo`),
2542
- // which lands the clone at `projects/foo/`.
2543
- path: r.path ?? deriveRepoName(r.url),
2544
- ...r.git?.user ? { gitUser: { name: r.git.user.name, email: r.git.user.email } } : {},
2545
- ...r.provider ? { provider: r.provider } : {}
2546
- }));
2547
- }
2548
- const routingPorts = config.routing?.ports ?? [];
2549
- if (routingPorts.length > 0) {
2550
- const seen = /* @__PURE__ */ new Set();
2551
- const ports = [];
2552
- for (const entry2 of routingPorts) {
2553
- const n = portNumber(entry2);
2554
- if (seen.has(n)) continue;
2555
- seen.add(n);
2556
- ports.push(n);
2557
- }
2558
- result.ports = ports;
2559
- }
2560
- if (config.routing?.vscodeAutoForward !== void 0) {
2561
- result.vscodeAutoForward = config.routing.vscodeAutoForward;
2562
- }
2563
- return result;
2564
- }
2565
-
2566
- // src/util/format.ts
2567
- var ESC = "\x1B[";
2568
- var ANSI_BOLD2 = `${ESC}1m`;
2569
- var ANSI_UNDERLINE2 = `${ESC}4m`;
2570
- var ANSI_CYAN2 = `${ESC}36m`;
2571
- var ANSI_GREY2 = `${ESC}90m`;
2572
- var ANSI_RESET2 = `${ESC}0m`;
2573
- function makeWrap(isTty2) {
2574
- return (s, ...codes) => isTty2 ? codes.join("") + s + ANSI_RESET2 : s;
2575
- }
2576
- function makePalette(isTty2) {
2577
- const wrap = makeWrap(isTty2);
2578
- return {
2579
- bold: (s) => wrap(s, ANSI_BOLD2),
2580
- underline: (s) => wrap(s, ANSI_UNDERLINE2),
2581
- cyan: (s) => wrap(s, ANSI_CYAN2),
2582
- dim: (s) => wrap(s, ANSI_GREY2),
2583
- sectionLine: (label) => wrap(`\u25B8 ${label}`, ANSI_BOLD2, ANSI_UNDERLINE2)
2584
- };
2585
- }
2586
- function colorsFor(stream) {
2587
- return makePalette(stream.isTTY ?? false);
2588
- }
2589
- var stderrPalette = makePalette(process.stderr.isTTY ?? false);
2590
- var bold2 = stderrPalette.bold;
2591
- var underline2 = stderrPalette.underline;
2592
- var cyan2 = stderrPalette.cyan;
2593
- var dim = stderrPalette.dim;
2594
- var sectionLine = stderrPalette.sectionLine;
2595
-
2596
- // src/devcontainer/compose.ts
2597
- import { spawn as spawn3 } from "child_process";
2598
- import { existsSync as existsSync3 } from "fs";
2599
- import path7 from "path";
2600
- import { consola as consola9 } from "consola";
2601
-
2602
- // src/util/mask-secrets.ts
2603
- import { Transform } from "stream";
2604
- var PATTERNS = [
2605
- // Atlassian Cloud API token. Starts with literal `ATATT3xFf` plus
2606
- // a long URL-safe-base64 tail. Tightened to that prefix to avoid
2607
- // matching unrelated all-caps words.
2608
- { name: "atlassian-api", re: /ATATT3xFf[A-Za-z0-9+/=_-]{20,}/g },
2609
- // Bitbucket Cloud app password.
2610
- { name: "bitbucket-app", re: /ATBB[A-Za-z0-9+/=_-]{20,}/g },
2611
- // GitHub PAT (classic), OAuth, user, server, refresh — all share
2612
- // the `gh<lower-letter>_<base62>` shape per GitHub's token format.
2613
- { name: "github-token", re: /gh[a-z]_[A-Za-z0-9]{20,}/g },
2614
- // GitHub fine-grained PAT.
2615
- { name: "github-pat", re: /github_pat_[A-Za-z0-9_]{20,}/g },
2616
- // Anthropic API key.
2617
- { name: "anthropic-api", re: /sk-ant-[A-Za-z0-9_-]{20,}/g }
2618
- ];
2619
- function maskSecrets(text) {
2620
- let result = text;
2621
- for (const { re } of PATTERNS) {
2622
- result = result.replace(re, maskOne);
2623
- }
2624
- return result;
2625
- }
2626
- function maskOne(token) {
2627
- if (token.length <= 12) return token;
2628
- return `${token.slice(0, 5)}\u2026${token.slice(-6)}`;
2629
- }
2630
- function createSecretMaskStream() {
2631
- let buffer = "";
2632
- return new Transform({
2633
- decodeStrings: true,
2634
- transform(chunk, _enc, cb) {
2635
- const text = typeof chunk === "string" ? chunk : chunk.toString("utf8");
2636
- buffer += text;
2637
- const lastNewline = buffer.lastIndexOf("\n");
2638
- if (lastNewline === -1) {
2639
- cb(null);
2640
- return;
2641
- }
2642
- const flushable = buffer.slice(0, lastNewline + 1);
2643
- buffer = buffer.slice(lastNewline + 1);
2644
- cb(null, maskSecrets(flushable));
2645
- },
2646
- flush(cb) {
2647
- if (buffer.length > 0) {
2648
- const tail = maskSecrets(buffer);
2649
- buffer = "";
2650
- cb(null, tail);
2651
- return;
2652
- }
2653
- cb(null);
2654
- }
2655
- });
2656
- }
2657
-
2658
- // src/devcontainer/cli.ts
2659
- import { spawn as spawn2 } from "child_process";
2660
- import { readFileSync as readFileSync2 } from "fs";
2661
- import { createRequire } from "module";
2662
- import path6 from "path";
2663
- var require_ = createRequire(import.meta.url);
2664
- var cachedBinaryPath = null;
2665
- function devcontainerCliPath() {
2666
- if (cachedBinaryPath) return cachedBinaryPath;
2667
- const pkgJsonPath = require_.resolve("@devcontainers/cli/package.json");
2668
- const pkg = JSON.parse(readFileSync2(pkgJsonPath, "utf8"));
2669
- const binEntry = typeof pkg.bin === "string" ? pkg.bin : pkg.bin?.devcontainer ?? "";
2670
- if (!binEntry) {
2671
- throw new Error("Could not resolve @devcontainers/cli bin entry.");
2672
- }
2673
- cachedBinaryPath = path6.resolve(path6.dirname(pkgJsonPath), binEntry);
2674
- return cachedBinaryPath;
2675
- }
2676
- var spawnDevcontainer = (args, cwd, options = {}) => {
2677
- const binPath = devcontainerCliPath();
2678
- return new Promise((resolve, reject) => {
2679
- if (options.interactive) {
2680
- const child2 = spawn2(process.execPath, [binPath, ...args], {
2681
- cwd,
2682
- 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
2683
3097
  });
2684
- child2.on("error", reject);
2685
- child2.on("exit", (code) => resolve(code ?? 0));
2686
- 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);
2687
3102
  }
2688
- const child = spawn2(process.execPath, [binPath, ...args], {
2689
- cwd,
2690
- stdio: ["ignore", "pipe", "pipe"]
2691
- });
2692
- if (options.quiet) {
2693
- const stdoutChunks = [];
2694
- const stderrChunks = [];
2695
- child.stdout?.on("data", (chunk) => stdoutChunks.push(chunk));
2696
- child.stderr?.on("data", (chunk) => stderrChunks.push(chunk));
2697
- child.on("error", reject);
2698
- child.on("exit", (code) => {
2699
- const exitCode = code ?? 0;
2700
- if (exitCode !== 0) {
2701
- process.stderr.write(
2702
- maskSecrets(Buffer.concat(stderrChunks).toString("utf8"))
2703
- );
2704
- process.stderr.write(
2705
- maskSecrets(Buffer.concat(stdoutChunks).toString("utf8"))
2706
- );
2707
- }
2708
- 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
2709
3139
  });
2710
- 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);
2711
3144
  }
2712
- child.stdout?.pipe(createSecretMaskStream()).pipe(process.stdout);
2713
- child.stderr?.pipe(createSecretMaskStream()).pipe(process.stderr);
2714
- child.on("error", reject);
2715
- child.on("exit", (code) => resolve(code ?? 0));
2716
- });
2717
- };
2718
-
2719
- // src/devcontainer/compose.ts
2720
- var spawnDockerCompose = (args, cwd) => {
2721
- return new Promise((resolve, reject) => {
2722
- const child = spawn3("docker", ["compose", ...args], {
2723
- cwd,
2724
- stdio: ["inherit", "pipe", "pipe"]
2725
- });
2726
- child.stdout?.pipe(createSecretMaskStream()).pipe(process.stdout);
2727
- child.stderr?.pipe(createSecretMaskStream()).pipe(process.stderr);
2728
- child.on("error", reject);
2729
- child.on("exit", (code) => resolve(code ?? 0));
2730
- });
2731
- };
2732
- var spawnBash = (args, cwd) => {
2733
- return new Promise((resolve, reject) => {
2734
- const child = spawn3("bash", args, {
2735
- cwd,
2736
- stdio: ["inherit", "pipe", "pipe"]
2737
- });
2738
- child.stdout?.pipe(createSecretMaskStream()).pipe(process.stdout);
2739
- child.stderr?.pipe(createSecretMaskStream()).pipe(process.stderr);
2740
- child.on("error", reject);
2741
- child.on("exit", (code) => resolve(code ?? 0));
2742
- });
2743
- };
2744
- function composeProjectName(root) {
2745
- return `${path7.basename(root)}_devcontainer`;
2746
- }
2747
- function resolveCompose(root) {
2748
- if (!existsSync3(path7.join(root, ".devcontainer"))) {
2749
- throw new Error(
2750
- `No .devcontainer/ at ${root}. Run \`monoceros apply <name>\` first.`
2751
- );
2752
3145
  }
2753
- const composeFile = path7.join(root, ".devcontainer", "compose.yaml");
2754
- if (!existsSync3(composeFile)) {
2755
- throw new Error(
2756
- `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.`
2757
- );
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
+ }
2758
3195
  }
2759
- return { composeFile, projectName: composeProjectName(root) };
2760
- }
2761
- async function runComposeAction(buildSubArgs, opts) {
2762
- const { composeFile, projectName } = resolveCompose(opts.root);
2763
- const spawnFn = opts.spawn ?? spawnDockerCompose;
2764
- const subArgs = buildSubArgs(opts.service);
2765
- return spawnFn(["-f", composeFile, "-p", projectName, ...subArgs], opts.root);
2766
- }
2767
- async function runStart(opts) {
2768
- resolveCompose(opts.root);
2769
- const logger = opts.logger ?? { info: (msg) => consola9.info(msg) };
2770
- const spawnFn = opts.spawn ?? spawnDevcontainer;
2771
- logger.info(`Bringing devcontainer up at ${opts.root}\u2026`);
2772
- return spawnFn(
2773
- ["up", "--workspace-folder", opts.root, "--mount-workspace-git-root=false"],
2774
- opts.root
2775
- );
3196
+ });
3197
+ function coerceToken(raw) {
3198
+ const n = Number(raw);
3199
+ return Number.isFinite(n) ? n : raw;
2776
3200
  }
2777
- async function runContainerCycle(root, opts) {
2778
- const { hasCompose, logger } = opts;
2779
- if (hasCompose) {
2780
- const projectName = composeProjectName(root);
2781
- logger.info(
2782
- `Force-removing existing ${projectName} containers (volumes preserved)\u2026`
2783
- );
2784
- const cleanupSpawn = opts.cleanupSpawn ?? spawnBash;
2785
- const script = [
2786
- `set -u`,
2787
- `echo "[cleanup] checking project ${projectName}\u2026"`,
2788
- `by_label=$(docker ps -aq --filter "label=com.docker.compose.project=${projectName}" 2>/dev/null || true)`,
2789
- `by_name=$(docker ps -aq --filter "name=^${projectName}-" 2>/dev/null || true)`,
2790
- `to_remove=$(printf "%s\\n%s\\n" "$by_label" "$by_name" | sort -u | grep -v "^$" || true)`,
2791
- `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`,
2792
- `docker network rm ${projectName}_default 2>/dev/null && echo "[cleanup] network ${projectName}_default removed" || echo "[cleanup] network ${projectName}_default not present"`,
2793
- `remaining_label=$(docker ps -aq --filter "label=com.docker.compose.project=${projectName}" 2>/dev/null || true)`,
2794
- `remaining_name=$(docker ps -aq --filter "name=^${projectName}-" 2>/dev/null || true)`,
2795
- `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`,
2796
- `echo "[cleanup] done"`
2797
- ].join("; ");
2798
- const cleanupCode = await cleanupSpawn(["-c", script], root);
2799
- if (cleanupCode !== 0) return cleanupCode;
2800
- return runStart({
2801
- root,
2802
- ...opts.devcontainerSpawn ? { spawn: opts.devcontainerSpawn } : {},
2803
- logger
2804
- });
2805
- }
2806
- logger.info(`Recreating image-mode devcontainer at ${root}\u2026`);
2807
- const spawnFn = opts.devcontainerSpawn ?? spawnDevcontainer;
2808
- return spawnFn(
2809
- [
2810
- "up",
2811
- "--workspace-folder",
2812
- root,
2813
- "--mount-workspace-git-root=false",
2814
- "--remove-existing-container"
2815
- ],
2816
- root
2817
- );
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
+ };
2818
3261
  }
2819
- function runStop(opts) {
2820
- return runComposeAction(
2821
- (service) => ["stop", ...service ? [service] : []],
2822
- opts
2823
- );
3262
+ function stateFilePath(targetDir) {
3263
+ return path7.join(targetDir, ".monoceros", "state.json");
2824
3264
  }
2825
- function runStatus(opts) {
2826
- return runComposeAction(
2827
- (service) => ["ps", ...service ? [service] : []],
2828
- opts
2829
- );
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
+ }
2830
3272
  }
2831
- function runLogs(opts) {
2832
- const follow = opts.follow ?? true;
2833
- return runComposeAction(
2834
- (service) => [
2835
- "logs",
2836
- ...follow ? ["-f"] : [],
2837
- ...service ? [service] : []
2838
- ],
2839
- 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"
2840
3279
  );
2841
3280
  }
2842
3281
 
2843
- // src/devcontainer/credentials.ts
2844
- import { spawn as spawn4 } from "child_process";
2845
- import { promises as fs8 } from "fs";
2846
- import path8 from "path";
2847
- var realGitCredentialFill = (input) => {
2848
- return new Promise((resolve, reject) => {
2849
- const child = spawn4("git", ["credential", "fill"], {
2850
- stdio: ["pipe", "pipe", "inherit"],
2851
- env: {
2852
- ...process.env,
2853
- GIT_TERMINAL_PROMPT: "0",
2854
- GIT_ASKPASS: "",
2855
- SSH_ASKPASS: ""
2856
- }
2857
- });
2858
- let stdout = "";
2859
- child.stdout.on("data", (chunk) => {
2860
- stdout += chunk.toString();
2861
- });
2862
- child.on("error", reject);
2863
- child.on("exit", (code) => resolve({ stdout, exitCode: code ?? 0 }));
2864
- child.stdin.write(input);
2865
- child.stdin.end();
2866
- });
2867
- };
2868
- function resolveProvider(host, explicit) {
2869
- const canonical = KNOWN_PROVIDER_HOSTS[host.toLowerCase()];
2870
- if (canonical) return canonical;
2871
- return explicit ?? "unknown";
2872
- }
2873
- function uniqueHttpsHosts(repos) {
2874
- const byHost = /* @__PURE__ */ new Map();
2875
- for (const repo of repos) {
2876
- if (!repo.url.startsWith("https://")) continue;
2877
- let host;
2878
- try {
2879
- host = new URL(repo.url).hostname;
2880
- } catch {
2881
- continue;
2882
- }
2883
- if (byHost.has(host)) continue;
2884
- 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 };
2885
3291
  }
2886
- return [...byHost.values()];
2887
- }
2888
- function installCommandForOS(opts) {
2889
- switch (process.platform) {
2890
- case "darwin":
2891
- return cyan2(opts.brew);
2892
- case "win32":
2893
- return cyan2(opts.winget);
2894
- default:
2895
- if (opts.linuxBrew) return cyan2(opts.linuxBrew);
2896
- 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;
2897
3299
  }
2898
- }
2899
- function providerSetupHint(host, provider) {
2900
- if (provider === "github") {
2901
- const isSaas = host.toLowerCase() === "github.com";
2902
- const hostArg = isSaas ? "" : ` --hostname ${host}`;
2903
- const install = installCommandForOS({
2904
- brew: "brew install gh",
2905
- winget: "winget install --id GitHub.cli",
2906
- linuxBrew: "brew install gh",
2907
- linuxDocsUrl: "https://github.com/cli/cli#installation"
2908
- });
2909
- return {
2910
- title: `${host} \u2014 GitHub`,
2911
- body: [
2912
- "Install the GitHub CLI:",
2913
- install,
2914
- "",
2915
- "Then run once:",
2916
- cyan2(`gh auth login${hostArg}`),
2917
- cyan2(`gh auth setup-git${hostArg}`),
2918
- "",
2919
- "`gh auth login` walks through OAuth in your browser.",
2920
- "`gh auth setup-git` wires gh into git as a credential helper."
2921
- ].join("\n")
2922
- };
3300
+ if (config.aptPackages.length > 0) {
3301
+ result.aptPackages = [...config.aptPackages];
2923
3302
  }
2924
- if (provider === "gitlab") {
2925
- const isSaas = host.toLowerCase() === "gitlab.com";
2926
- const hostArg = isSaas ? "" : ` --hostname ${host}`;
2927
- const install = installCommandForOS({
2928
- brew: "brew install glab",
2929
- winget: "winget install --id GLab.GLab",
2930
- linuxBrew: "brew install glab",
2931
- linuxDocsUrl: "https://gitlab.com/gitlab-org/cli#installation"
2932
- });
2933
- return {
2934
- title: `${host} \u2014 GitLab`,
2935
- body: [
2936
- "Install the GitLab CLI (glab):",
2937
- install,
2938
- "",
2939
- "Then run once:",
2940
- cyan2(`glab auth login${hostArg}`),
2941
- "",
2942
- "Choose `HTTPS` when asked for git-protocol, then accept",
2943
- '"Authenticate Git with your GitLab credentials" \u2014 glab',
2944
- "configures itself as the git credential helper."
2945
- ].join("\n")
2946
- };
3303
+ if (Object.keys(featureRecord).length > 0) {
3304
+ result.features = featureRecord;
2947
3305
  }
2948
- if (provider === "bitbucket") {
2949
- const isCloud = host.toLowerCase() === "bitbucket.org";
2950
- if (isCloud) {
2951
- return {
2952
- title: `${host} \u2014 Bitbucket Cloud`,
2953
- body: [
2954
- "Bitbucket has no first-party CLI for git-credentials, so this",
2955
- "is a manual one-time setup. Generate an Atlassian API token at",
2956
- "https://id.atlassian.com/manage-profile/security/api-tokens",
2957
- "",
2958
- "Then store it via your OS credential helper:",
2959
- cyan2(
2960
- `git credential approve <<< $'protocol=https\\nhost=${host}\\nusername=<your-atlassian-email>\\npassword=<token>\\n'`
2961
- )
2962
- ].join("\n")
2963
- };
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);
2964
3335
  }
2965
- return {
2966
- title: `${host} \u2014 Bitbucket Data Center`,
2967
- body: [
2968
- "Bitbucket has no first-party CLI for git-credentials, so this",
2969
- "is a manual one-time setup. Generate a personal HTTP access",
2970
- `token in your Bitbucket UI: profile picture (top right on ${host})`,
2971
- "\u2192 Manage account \u2192 HTTP access tokens \u2192 Create token. Give it",
2972
- "at least repo-read + repo-write scopes for the repos you need.",
2973
- "",
2974
- "Then store it via your OS credential helper:",
2975
- cyan2(
2976
- `git credential approve <<< $'protocol=https\\nhost=${host}\\nusername=<your-bitbucket-username>\\npassword=<token>\\n'`
2977
- )
2978
- ].join("\n")
2979
- };
3336
+ result.ports = ports;
2980
3337
  }
2981
- return {
2982
- title: `${host} \u2014 Gitea`,
2983
- body: [
2984
- "Gitea has no first-party CLI helper for git-credentials (the",
2985
- "`tea` CLI logs into its own config, not into your git credential",
2986
- "helper), so this is a manual one-time setup. Generate an access",
2987
- `token in your Gitea UI: profile picture (top right on ${host}) \u2192`,
2988
- 'Settings \u2192 Applications \u2192 "Generate New Token". Give it at',
2989
- "least the `read:repository` scope (add `write:repository` if you",
2990
- "need push from the container).",
2991
- "",
2992
- "Then store it via your OS credential helper:",
2993
- cyan2(
2994
- `git credential approve <<< $'protocol=https\\nhost=${host}\\nusername=<your-gitea-username>\\npassword=<token>\\n'`
2995
- )
2996
- ].join("\n")
2997
- };
3338
+ if (config.routing?.vscodeAutoForward !== void 0) {
3339
+ result.vscodeAutoForward = config.routing.vscodeAutoForward;
3340
+ }
3341
+ return result;
2998
3342
  }
2999
- function parseCredentialFillOutput(output) {
3000
- const result = {};
3001
- for (const line of output.split("\n")) {
3002
- const eqIdx = line.indexOf("=");
3003
- if (eqIdx <= 0) continue;
3004
- const key = line.slice(0, eqIdx);
3005
- const value = line.slice(eqIdx + 1);
3006
- if (key === "username") result.username = value;
3007
- 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);
3008
3371
  }
3009
3372
  return result;
3010
3373
  }
3011
- function formatCredentialLine(host, username, password) {
3012
- const encUser = encodeURIComponent(username);
3013
- const encPass = encodeURIComponent(password);
3014
- 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)}`;
3015
3377
  }
3016
- async function collectGitCredentials(devContainerRoot, hosts, options = {}) {
3017
- const credsDir = path8.join(devContainerRoot, ".monoceros");
3018
- const credentialsPath = path8.join(credsDir, "git-credentials");
3019
- const spawnFn = options.spawn ?? realGitCredentialFill;
3020
- const logger = options.logger ?? { info: () => {
3021
- }, warn: () => {
3022
- } };
3023
- const lines = [];
3024
- const perHost = [];
3025
- for (const { host, provider } of hosts) {
3026
- if (provider === "unknown") {
3027
- perHost.push({
3028
- host,
3029
- provider: "github",
3030
- // placeholder — never rendered because pre-flight already bailed
3031
- status: "no-credentials",
3032
- detail: "provider not declared (internal: should not reach here)"
3033
- });
3034
- 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);
3035
3402
  }
3036
- logger.info(`Fetching credentials for ${host} from host git\u2026`);
3037
- const input = `protocol=https
3038
- host=${host}
3403
+ });
3404
+ }
3039
3405
 
3040
- `;
3041
- let result;
3042
- try {
3043
- result = await spawnFn(input);
3044
- } catch (err) {
3045
- const detail = err instanceof Error ? err.message : String(err);
3046
- perHost.push({ host, provider, status: "spawn-error", detail });
3047
- continue;
3048
- }
3049
- if (result.exitCode !== 0) {
3050
- perHost.push({
3051
- host,
3052
- provider,
3053
- status: "non-zero-exit",
3054
- 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"
3055
3431
  });
3056
- continue;
3432
+ child2.on("error", reject);
3433
+ child2.on("exit", (code) => resolve(code ?? 0));
3434
+ return;
3057
3435
  }
3058
- const { username, password } = parseCredentialFillOutput(result.stdout);
3059
- if (!username || !password) {
3060
- perHost.push({
3061
- host,
3062
- provider,
3063
- status: "no-credentials",
3064
- 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);
3065
3457
  });
3066
- continue;
3458
+ return;
3067
3459
  }
3068
- lines.push(formatCredentialLine(host, username, password));
3069
- 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
+ );
3070
3500
  }
3071
- await fs8.mkdir(credsDir, { recursive: true });
3072
- await fs8.writeFile(
3073
- credentialsPath,
3074
- lines.join("\n") + (lines.length > 0 ? "\n" : ""),
3075
- {
3076
- mode: 384
3077
- }
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
3078
3565
  );
3079
- return {
3080
- hostsWritten: lines.length,
3081
- hostsSkipped: perHost.filter((p) => p.status !== "ok").length,
3082
- perHost,
3083
- credentialsPath
3084
- };
3085
3566
  }
3086
- function formatMissingCredentialsError(missing) {
3087
- if (missing.length === 1) {
3088
- const m = missing[0];
3089
- const hint = providerSetupHint(m.host, m.provider);
3090
- return [
3091
- `Missing Git credentials: ${hint.title}`,
3092
- "",
3093
- hint.body,
3094
- "",
3095
- `Then re-run ${cyan2("monoceros apply")}.`
3096
- ].join("\n");
3097
- }
3098
- const lines = [
3099
- `Missing Git credentials for ${missing.length} hosts:`,
3100
- ""
3101
- ];
3102
- for (const m of missing) {
3103
- const hint = providerSetupHint(m.host, m.provider);
3104
- lines.push(hint.title);
3105
- lines.push("");
3106
- lines.push(hint.body);
3107
- lines.push("");
3108
- }
3109
- lines.push(`Then re-run ${cyan2("monoceros apply")}.`);
3110
- return lines.join("\n");
3567
+ function runStop(opts) {
3568
+ return runComposeAction(
3569
+ (service) => ["stop", ...service ? [service] : []],
3570
+ opts
3571
+ );
3111
3572
  }
3112
- function formatUnknownProviderError(hosts) {
3113
- const sorted = [...new Set(hosts)].sort();
3114
- const lines = [
3115
- sorted.length === 1 ? `Unknown Git provider for host ${sorted[0]}.` : `Unknown Git provider for ${sorted.length} hosts: ${sorted.join(", ")}.`,
3116
- "",
3117
- "Monoceros auto-detects only github.com / gitlab.com / bitbucket.org.",
3118
- "For any other host (self-hosted GitLab, Gitea, Bitbucket Server, \u2026)",
3119
- "declare the provider explicitly in the yml. Edit the repo entry:",
3120
- "",
3121
- cyan2(" repos:"),
3122
- cyan2(` - url: https://${sorted[0]}/\u2026`),
3123
- cyan2(" provider: gitlab # or: github, bitbucket, gitea"),
3124
- "",
3125
- `Or re-add with ${cyan2("monoceros add-repo <name> <url> --provider=<github|gitlab|bitbucket|gitea>")}.`
3126
- ];
3127
- 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
+ );
3128
3589
  }
3129
3590
 
3130
3591
  // src/devcontainer/repo-reachability.ts
3131
- import { spawn as spawn5 } from "child_process";
3592
+ import { spawn as spawn6 } from "child_process";
3132
3593
  var realGitLsRemote = (url) => {
3133
3594
  return new Promise((resolve, reject) => {
3134
- const child = spawn5("git", ["ls-remote", "--heads", "--", url], {
3595
+ const child = spawn6("git", ["ls-remote", "--heads", "--", url], {
3135
3596
  stdio: ["ignore", "pipe", "pipe"],
3136
3597
  env: {
3137
3598
  ...process.env,
@@ -3269,10 +3730,10 @@ function adviceForKind(kind) {
3269
3730
  }
3270
3731
 
3271
3732
  // src/devcontainer/docker-mode.ts
3272
- import { spawn as spawn6 } from "child_process";
3733
+ import { spawn as spawn7 } from "child_process";
3273
3734
  var realDockerInfo = () => {
3274
3735
  return new Promise((resolve, reject) => {
3275
- const child = spawn6(
3736
+ const child = spawn7(
3276
3737
  "docker",
3277
3738
  ["info", "--format", "{{json .SecurityOptions}}"],
3278
3739
  {
@@ -3331,13 +3792,13 @@ function formatRootlessNotSupportedError() {
3331
3792
  }
3332
3793
 
3333
3794
  // src/devcontainer/identity.ts
3334
- import { spawn as spawn7 } from "child_process";
3795
+ import { spawn as spawn8 } from "child_process";
3335
3796
  import { promises as fs9 } from "fs";
3336
- import path9 from "path";
3797
+ import path10 from "path";
3337
3798
  import { consola as consola10 } from "consola";
3338
3799
  var realGitConfigGet = (key) => {
3339
3800
  return new Promise((resolve, reject) => {
3340
- const child = spawn7("git", ["config", "--global", "--get", key], {
3801
+ const child = spawn8("git", ["config", "--global", "--get", key], {
3341
3802
  stdio: ["ignore", "pipe", "inherit"]
3342
3803
  });
3343
3804
  let stdout = "";
@@ -3361,20 +3822,51 @@ var realIdentityPrompt = async (key) => {
3361
3822
  const trimmed = value.trim();
3362
3823
  return trimmed.length > 0 ? trimmed : void 0;
3363
3824
  };
3364
- async function collectGitIdentity(devContainerRoot, options = {}) {
3365
- const gitconfigDir = path9.join(devContainerRoot, ".monoceros");
3366
- 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 = {}) {
3367
3858
  const spawnFn = options.spawn ?? realGitConfigGet;
3368
3859
  const promptFn = options.prompt ?? realIdentityPrompt;
3860
+ const scopePromptFn = options.scopePrompt ?? realScopePrompt;
3369
3861
  const logger = options.logger ?? { info: () => {
3370
3862
  }, warn: () => {
3371
3863
  } };
3372
- const existing = await readExistingGitconfig(gitconfigPath);
3864
+ const persisted = options.persistedValues ?? {};
3373
3865
  const name = await resolveKey("user.name", {
3374
3866
  override: options.containerOverride?.name,
3375
3867
  defaultValue: options.defaults?.name,
3376
3868
  spawnFn,
3377
- persistedValue: existing.name,
3869
+ persistedValue: persisted.name,
3378
3870
  promptFn,
3379
3871
  logger
3380
3872
  });
@@ -3382,35 +3874,77 @@ async function collectGitIdentity(devContainerRoot, options = {}) {
3382
3874
  override: options.containerOverride?.email,
3383
3875
  defaultValue: options.defaults?.email,
3384
3876
  spawnFn,
3385
- persistedValue: existing.email,
3877
+ persistedValue: persisted.email,
3386
3878
  promptFn,
3387
3879
  logger
3388
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
+ });
3389
3922
  const lines = ["[user]"];
3390
- if (name !== void 0) lines.push(` name = ${name}`);
3391
- 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}`);
3392
3925
  await fs9.mkdir(gitconfigDir, { recursive: true });
3393
3926
  await fs9.writeFile(gitconfigPath, lines.join("\n") + "\n");
3394
3927
  return {
3395
- ...name !== void 0 ? { name } : {},
3396
- ...email !== void 0 ? { email } : {},
3397
- 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 } : {}
3398
3932
  };
3399
3933
  }
3400
3934
  async function resolveKey(key, opts) {
3401
3935
  if (opts.override !== void 0 && opts.override.length > 0) {
3402
- return opts.override;
3936
+ return { value: opts.override, source: "container" };
3403
3937
  }
3404
3938
  if (opts.defaultValue !== void 0 && opts.defaultValue.length > 0) {
3405
- return opts.defaultValue;
3939
+ return { value: opts.defaultValue, source: "defaults" };
3406
3940
  }
3407
3941
  const hostValue = await readKeyFromHost(opts.spawnFn, key, opts.logger);
3408
- if (hostValue !== void 0) return hostValue;
3942
+ if (hostValue !== void 0) return { value: hostValue, source: "host" };
3409
3943
  if (opts.persistedValue !== void 0 && opts.persistedValue.length > 0) {
3410
- return opts.persistedValue;
3944
+ return { value: opts.persistedValue, source: "persisted" };
3411
3945
  }
3412
3946
  const prompted = await opts.promptFn(key);
3413
- if (prompted !== void 0) return prompted;
3947
+ if (prompted !== void 0) return { value: prompted, source: "prompt" };
3414
3948
  opts.logger.warn(
3415
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.`
3416
3950
  );
@@ -3492,13 +4026,17 @@ ${sectionLine(label)}
3492
4026
  warn: logger.warn ?? logger.info
3493
4027
  };
3494
4028
  if (hasRepos || hasContainerGitUser || hasDefaultGitUser) {
3495
- await collectGitIdentity(targetDir, {
4029
+ const identity = await collectGitIdentity(targetDir, {
3496
4030
  ...opts.identitySpawn ? { spawn: opts.identitySpawn } : {},
3497
4031
  ...opts.identityPrompt ? { prompt: opts.identityPrompt } : {},
4032
+ ...opts.identityScopePrompt ? { scopePrompt: opts.identityScopePrompt } : {},
3498
4033
  ...parsed.config.git?.user ? { containerOverride: parsed.config.git.user } : {},
3499
4034
  ...globalConfig?.defaults?.git?.user ? { defaults: globalConfig.defaults.git.user } : {},
3500
4035
  logger: idLogger
3501
4036
  });
4037
+ if (identity.prompted) {
4038
+ await persistPromptedIdentity(identity.prompted, ymlPath, home, logger);
4039
+ }
3502
4040
  }
3503
4041
  const hostsToFetch = uniqueHttpsHosts(createOpts.repos ?? []);
3504
4042
  const unknownProviderHosts = hostsToFetch.filter((h) => h.provider === "unknown").map((h) => h.host);
@@ -3631,9 +4169,59 @@ function warnOnDeprecatedFeatureRefs(containerFeatures, globalConfig, logger) {
3631
4169
  }
3632
4170
  }
3633
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
+ }
3634
4222
 
3635
4223
  // src/version.ts
3636
- var CLI_VERSION = true ? "1.7.5" : "dev";
4224
+ var CLI_VERSION = true ? "1.8.0" : "dev";
3637
4225
 
3638
4226
  // src/commands/_dispatch.ts
3639
4227
  import { consola as consola12 } from "consola";
@@ -3895,7 +4483,7 @@ import { consola as consola13 } from "consola";
3895
4483
 
3896
4484
  // src/init/components.ts
3897
4485
  import { existsSync as existsSync5, promises as fs11 } from "fs";
3898
- import path10 from "path";
4486
+ import path11 from "path";
3899
4487
  import { z as z3 } from "zod";
3900
4488
  import { parse as parseYaml } from "yaml";
3901
4489
  var CategorySchema = z3.enum(["language", "service", "feature"]);
@@ -3952,14 +4540,14 @@ async function loadComponentCatalog(rootDir = componentsDir()) {
3952
4540
  async function walk(baseDir, currentDir, out) {
3953
4541
  const entries = await fs11.readdir(currentDir, { withFileTypes: true });
3954
4542
  for (const entry2 of entries) {
3955
- const full = path10.join(currentDir, entry2.name);
4543
+ const full = path11.join(currentDir, entry2.name);
3956
4544
  if (entry2.isDirectory()) {
3957
4545
  await walk(baseDir, full, out);
3958
4546
  continue;
3959
4547
  }
3960
4548
  if (!entry2.isFile() || !entry2.name.endsWith(".yml")) continue;
3961
- const relative = path10.relative(baseDir, full);
3962
- 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("/");
3963
4551
  const text = await fs11.readFile(full, "utf8");
3964
4552
  let raw;
3965
4553
  try {
@@ -4059,35 +4647,49 @@ Available: ${available.join(", ")}.`
4059
4647
  }
4060
4648
 
4061
4649
  // src/init/generator.ts
4062
- var SCHEMA_HEADER = [
4063
- "# Monoceros solution-config. Edit freely, then run",
4064
- "# `monoceros apply <name>` to materialize a dev-container.",
4065
- "#",
4066
- "# Each section below carries inline comments for the options it",
4067
- "# accepts. Features expose additional knobs as commented hints",
4068
- "# under their `options:` block \u2014 un-comment what you need."
4069
- ];
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;
4070
4653
  function generateComposedYml(name, components, lookupManifest, repoUrls = [], ports = []) {
4071
4654
  const merged = mergeComponents(components);
4072
4655
  const lines = [];
4073
- for (const h of SCHEMA_HEADER) lines.push(h);
4656
+ pushHeader(lines, SCHEMA_HEADER_ACTIVE, name);
4074
4657
  lines.push("");
4075
4658
  lines.push("schemaVersion: 1");
4076
4659
  lines.push(`name: ${name}`);
4077
4660
  lines.push("");
4078
4661
  if (merged.languages.length > 0) {
4662
+ pushSectionHeader(
4663
+ lines,
4664
+ LANGUAGES_HEADER,
4665
+ /* commented */
4666
+ false
4667
+ );
4079
4668
  lines.push("languages:");
4080
4669
  for (const lang of merged.languages) lines.push(` - ${lang}`);
4081
4670
  lines.push("");
4082
4671
  }
4083
4672
  if (merged.services.length > 0) {
4673
+ pushSectionHeader(
4674
+ lines,
4675
+ SERVICES_HEADER,
4676
+ /* commented */
4677
+ false
4678
+ );
4084
4679
  lines.push("services:");
4085
4680
  for (const svc of merged.services) lines.push(` - ${svc}`);
4086
4681
  lines.push("");
4087
4682
  }
4088
4683
  if (merged.features.length > 0) {
4684
+ pushSectionHeader(
4685
+ lines,
4686
+ FEATURES_HEADER_ACTIVE,
4687
+ /* commented */
4688
+ false
4689
+ );
4089
4690
  lines.push("features:");
4090
4691
  for (const f of merged.features) {
4692
+ lines.push("");
4091
4693
  renderFeatureBlock(
4092
4694
  lines,
4093
4695
  f,
@@ -4099,87 +4701,86 @@ function generateComposedYml(name, components, lookupManifest, repoUrls = [], po
4099
4701
  lines.push("");
4100
4702
  }
4101
4703
  if (repoUrls.length > 0) {
4102
- renderReposBlock(
4704
+ pushSectionHeader(
4103
4705
  lines,
4104
- repoUrls,
4706
+ REPOS_HEADER,
4105
4707
  /* commented */
4106
4708
  false
4107
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("");
4108
4721
  }
4109
4722
  if (ports.length > 0) {
4110
- 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("");
4111
4736
  }
4112
4737
  return ensureTrailingNewline(lines.join("\n"));
4113
4738
  }
4114
4739
  function generateDocumentedYml(name, catalog, lookupManifest, repoUrls = [], ports = []) {
4115
4740
  const byCategory = groupByCategory(catalog);
4116
4741
  const lines = [];
4117
- for (const h of SCHEMA_HEADER) lines.push(h);
4118
- lines.push("#");
4119
- lines.push("# Below is the full set of components shipped with this");
4120
- lines.push("# workbench, every one commented out. Un-comment the lines");
4121
- lines.push("# you want active. The same effect (and a cleaner yml) is");
4122
- lines.push("# achievable by running `monoceros init <name> --with=\u2026`");
4123
- lines.push("# with a comma-separated list of component names.");
4742
+ pushHeader(lines, SCHEMA_HEADER_DOCUMENTED, name);
4124
4743
  lines.push("");
4125
4744
  lines.push("schemaVersion: 1");
4126
4745
  lines.push(`name: ${name}`);
4127
4746
  lines.push("");
4128
4747
  if (byCategory.language.length > 0) {
4129
- const items = byCategory.language.flatMap(
4130
- (c) => (c.file.contributes.languages ?? []).map((lang) => ({
4131
- value: lang,
4132
- label: c.file.displayName
4133
- }))
4748
+ pushSectionHeader(
4749
+ lines,
4750
+ LANGUAGES_HEADER,
4751
+ /* commented */
4752
+ true
4134
4753
  );
4135
- const width = Math.max(...items.map((i) => i.value.length)) + 2;
4136
- lines.push("# Languages \u2014 runtime toolchains.");
4137
4754
  lines.push("# languages:");
4138
- for (const item of items) {
4139
- const pad = " ".repeat(width - item.value.length);
4140
- 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
+ }
4141
4759
  }
4142
4760
  lines.push("");
4143
4761
  }
4144
4762
  if (byCategory.service.length > 0) {
4145
- const items = byCategory.service.flatMap(
4146
- (c) => (c.file.contributes.services ?? []).map((svc) => ({
4147
- value: svc,
4148
- label: c.file.displayName
4149
- }))
4763
+ pushSectionHeader(
4764
+ lines,
4765
+ SERVICES_HEADER,
4766
+ /* commented */
4767
+ true
4150
4768
  );
4151
- const width = Math.max(...items.map((i) => i.value.length)) + 2;
4152
- lines.push("# Services \u2014 compose-mode siblings of the workspace");
4153
- lines.push("# container (compose mode kicks in as soon as at least");
4154
- lines.push("# one service is active).");
4155
4769
  lines.push("# services:");
4156
- for (const item of items) {
4157
- const pad = " ".repeat(width - item.value.length);
4158
- 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
+ }
4159
4774
  }
4160
4775
  lines.push("");
4161
4776
  }
4162
4777
  if (byCategory.feature.length > 0) {
4163
- lines.push("# Features \u2014 devcontainer features installed inside the");
4164
- lines.push("# container. Each entry has an OCI-style `ref` plus an");
4165
- lines.push("# optional `options` map. Credentials/auth keys appear");
4166
- lines.push("# as commented hints; set them here per container, or");
4167
- lines.push("# globally in monoceros-config.yml under");
4168
- lines.push("# `defaults.features.<ref>`.");
4169
- lines.push("#");
4170
- lines.push("# Catalog:");
4171
- lines.push("#");
4172
- const nameColumnWidth = Math.max(...byCategory.feature.map((c) => c.name.length)) + 2;
4173
- for (const c of byCategory.feature) {
4174
- const pad = " ".repeat(nameColumnWidth - c.name.length);
4175
- lines.push(`# ${c.name}${pad}${c.file.displayName}`);
4176
- }
4177
- lines.push("#");
4178
- lines.push("# Below: one block per feature ref. Un-comment what");
4179
- lines.push("# you want active. Sub-components share their parent's");
4180
- lines.push("# block \u2014 pick the parent for the full preset, swap to");
4181
- lines.push("# a sub-component name for a partial install.");
4182
- lines.push("#");
4778
+ pushSectionHeader(
4779
+ lines,
4780
+ FEATURES_HEADER_DOCUMENTED,
4781
+ /* commented */
4782
+ true
4783
+ );
4183
4784
  lines.push("# features:");
4184
4785
  const renderedRefs = /* @__PURE__ */ new Set();
4185
4786
  const topLevel = byCategory.feature.filter((c) => !c.name.includes("/"));
@@ -4187,6 +4788,7 @@ function generateDocumentedYml(name, catalog, lookupManifest, repoUrls = [], por
4187
4788
  for (const f of c.file.contributes.features ?? []) {
4188
4789
  if (renderedRefs.has(f.ref)) continue;
4189
4790
  renderedRefs.add(f.ref);
4791
+ lines.push("#");
4190
4792
  renderFeatureBlock(
4191
4793
  lines,
4192
4794
  f,
@@ -4201,6 +4803,7 @@ function generateDocumentedYml(name, catalog, lookupManifest, repoUrls = [], por
4201
4803
  for (const f of c.file.contributes.features ?? []) {
4202
4804
  if (renderedRefs.has(f.ref)) continue;
4203
4805
  renderedRefs.add(f.ref);
4806
+ lines.push("#");
4204
4807
  renderFeatureBlock(
4205
4808
  lines,
4206
4809
  f,
@@ -4212,165 +4815,156 @@ function generateDocumentedYml(name, catalog, lookupManifest, repoUrls = [], por
4212
4815
  }
4213
4816
  lines.push("");
4214
4817
  }
4215
- renderReposBlock(
4216
- lines,
4217
- repoUrls,
4218
- /* commented */
4219
- repoUrls.length === 0
4220
- );
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
+ }
4221
4847
  if (ports.length > 0) {
4222
- 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("");
4223
4861
  } else {
4224
- 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("");
4225
4874
  }
4226
4875
  return ensureTrailingNewline(lines.join("\n"));
4227
4876
  }
4228
- 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
+ }
4229
4885
  function renderFeatureBlock(out, feature, summary, commented) {
4230
- const c = commented ? "# " : " ";
4231
- const optionHints = summary?.optionHints ?? [];
4232
- const optionDescriptions = summary?.optionDescriptions ?? {};
4233
- const usageNotes = summary?.usageNotes ?? [];
4234
- for (let i = 0; i < usageNotes.length; i++) {
4235
- if (i > 0) out.push(`${c}#`);
4236
- for (const line of wrapToComment(
4237
- usageNotes[i],
4238
- COMMENT_WIDTH - c.length
4239
- )) {
4240
- out.push(`${c}# ${line}`);
4241
- }
4242
- }
4243
- 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}`);
4244
4895
  const options = feature.options ?? {};
4245
- const activeOptions = Object.entries(options);
4246
- const remainingHints = optionHints.filter((h) => !(h in options));
4247
- if (activeOptions.length > 0) {
4248
- out.push(`${c} options:`);
4249
- for (const [key, value] of activeOptions) {
4250
- out.push(`${c} ${key}: ${renderScalarValue(value)}`);
4251
- }
4252
- if (remainingHints.length > 0) {
4253
- out.push(
4254
- `${c} # Optional \u2014 override monoceros-config.yml defaults.features:`
4255
- );
4256
- for (const hint of remainingHints) {
4257
- emitHint(out, hint, optionDescriptions[hint], `${c} `);
4258
- }
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)}`);
4259
4903
  }
4260
- } else if (remainingHints.length > 0) {
4261
- out.push(
4262
- `${c} # Optional \u2014 override monoceros-config.yml defaults.features:`
4263
- );
4264
- out.push(`${c} # options:`);
4265
- for (const hint of remainingHints) {
4266
- 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)}`);
4267
4913
  }
4268
4914
  }
4269
- }
4270
- function emitHint(out, hint, description, linePrefix) {
4271
- if (description) {
4272
- for (const line of wrapToComment(
4273
- description,
4274
- COMMENT_WIDTH - linePrefix.length
4275
- )) {
4276
- out.push(`${linePrefix}# ${line}`);
4915
+ if (hintKeys.length > 0) {
4916
+ out.push(` # options:`);
4917
+ for (const key of hintKeys) {
4918
+ out.push(` # ${key}:`);
4277
4919
  }
4278
4920
  }
4279
- out.push(`${linePrefix}${hint}:`);
4280
4921
  }
4281
- function renderReposBlock(out, urls, commented) {
4282
- out.push("# Repos \u2014 git repositories cloned into projects/ during");
4283
- out.push("# post-create. HTTPS URLs only. Provider auto-detected");
4284
- out.push("# for github.com, gitlab.com, bitbucket.org; for any other host");
4285
- out.push("# (self-hosted GitLab, Bitbucket Data Center, Gitea/Forgejo,");
4286
- out.push("# GitHub Enterprise, \u2026) declare provider explicitly.");
4287
- out.push("#");
4288
- if (commented) {
4289
- out.push("# repos:");
4290
- out.push("# - url: https://github.com/<org>/<repo>.git");
4291
- out.push(
4292
- "# # path: <folder> # subfolder under projects/; default: URL-derived"
4293
- );
4294
- out.push(
4295
- "# # provider: github # github | gitlab | bitbucket | gitea"
4296
- );
4297
- out.push(
4298
- "# # git: # per-repo committer identity override"
4299
- );
4300
- out.push("# # user:");
4301
- out.push("# # name: Your Name");
4302
- out.push("# # email: you@example.com");
4303
- out.push("");
4304
- 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(", ")}.`);
4305
4947
  }
4306
- out.push("repos:");
4307
- for (const url of urls) {
4308
- const derivedPath = deriveDefaultPath(url);
4309
- out.push(` - url: ${url}`);
4310
- out.push(
4311
- ` # path: ${derivedPath} # subfolder under projects/; default: URL-derived (${derivedPath})`
4312
- );
4313
- out.push(
4314
- " # provider: github # github | gitlab | bitbucket | gitea"
4315
- );
4316
- out.push(
4317
- " # git: # per-repo committer identity override"
4318
- );
4319
- out.push(" # user:");
4320
- out.push(" # name: Your Name");
4321
- out.push(" # email: you@example.com");
4322
- }
4323
- out.push("");
4324
- }
4325
- function renderRoutingHintBlock(out) {
4326
- out.push("# Routing \u2014 expose container ports to the host through the");
4327
- out.push("# shared Traefik singleton. Once any port is declared the");
4328
- out.push("# container joins the monoceros-proxy network and the proxy");
4329
- out.push("# routes <name>.localhost (default port) and");
4330
- out.push("# <name>-<port>.localhost (explicit). `monoceros add-port`");
4331
- out.push("# manages the list; the block appears on first add. You can");
4332
- out.push("# also pre-seed at init time via `--with-ports=3000,5173,\u2026`.");
4333
- out.push("#");
4334
- out.push("# routing:");
4335
- out.push("# ports: # internal container ports");
4336
- out.push(
4337
- "# - 3000 # first entry doubles as <name>.localhost"
4338
- );
4339
- out.push("# - 5173");
4340
- out.push(
4341
- "# vscodeAutoForward: false # default: false. Traefik is the single"
4342
- );
4343
- out.push(
4344
- "# # source of truth \u2014 set true only if you"
4345
- );
4346
- out.push(
4347
- "# # want VS Code's port panel as primary."
4348
- );
4349
- out.push("");
4350
- }
4351
- function renderActiveRoutingBlock(out, name, ports) {
4352
- out.push("# Routing \u2014 expose these container ports to the host through");
4353
- out.push("# the shared Traefik singleton. First entry doubles as");
4354
- out.push(`# http://${name}.localhost (the default route).`);
4355
- out.push("routing:");
4356
- out.push(" ports:");
4357
- ports.forEach((port, idx) => {
4358
- if (idx === 0) {
4359
- out.push(` - ${port} # default \u2192 http://${name}.localhost`);
4360
- } else {
4361
- out.push(` - ${port}`);
4362
- }
4363
- });
4364
- out.push(" # vscodeAutoForward: false # set true to keep VS Code's");
4365
- out.push(" # # port-panel alongside Traefik");
4366
- out.push("");
4948
+ if (summary.documentationURL) {
4949
+ out.push(`See ${summary.documentationURL} for further information.`);
4950
+ }
4951
+ return out;
4367
4952
  }
4368
- function deriveDefaultPath(url) {
4369
- let last = url;
4370
- const slash = url.lastIndexOf("/");
4371
- if (slash >= 0) last = url.slice(slash + 1);
4372
- if (last.endsWith(".git")) last = last.slice(0, -4);
4373
- 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
+ }
4374
4968
  }
4375
4969
  function wrapToComment(text, width) {
4376
4970
  const words = text.split(/\s+/).filter((w) => w.length > 0);
@@ -4419,10 +5013,10 @@ function ensureTrailingNewline(s) {
4419
5013
 
4420
5014
  // src/init/manifest.ts
4421
5015
  import { existsSync as existsSync6, readFileSync as readFileSync3 } from "fs";
4422
- import path11 from "path";
5016
+ import path12 from "path";
4423
5017
  function resolveManifestPath(name, checkoutRoot) {
4424
5018
  if (checkoutRoot) {
4425
- const checkoutPath = path11.join(
5019
+ const checkoutPath = path12.join(
4426
5020
  checkoutRoot,
4427
5021
  "images",
4428
5022
  "features",
@@ -4431,7 +5025,7 @@ function resolveManifestPath(name, checkoutRoot) {
4431
5025
  );
4432
5026
  if (existsSync6(checkoutPath)) return checkoutPath;
4433
5027
  }
4434
- const bundlePath = path11.join(
5028
+ const bundlePath = path12.join(
4435
5029
  bundledFeaturesDir(),
4436
5030
  name,
4437
5031
  "devcontainer-feature.json"
@@ -4463,7 +5057,18 @@ function loadFeatureManifestSummary(ref, checkoutRoot = workbenchCheckoutRoot())
4463
5057
  }
4464
5058
  }
4465
5059
  }
4466
- 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
+ };
4467
5072
  } catch {
4468
5073
  return void 0;
4469
5074
  }
@@ -4541,6 +5146,17 @@ async function runInit(opts) {
4541
5146
  seenPorts.add(raw);
4542
5147
  ports.push(raw);
4543
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
+ }
4544
5160
  let text;
4545
5161
  const requested = opts.with ?? [];
4546
5162
  if (requested.length === 0) {
@@ -4551,6 +5167,51 @@ async function runInit(opts) {
4551
5167
  }
4552
5168
  await fs12.mkdir(containerConfigsDir(home), { recursive: true });
4553
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
+ }
4554
5215
  const documented = requested.length === 0;
4555
5216
  const displayPath = prettyPath(dest);
4556
5217
  if (documented) {
@@ -4974,7 +5635,7 @@ import { createInterface } from "readline/promises";
4974
5635
 
4975
5636
  // src/remove/index.ts
4976
5637
  import { existsSync as existsSync8, promises as fs13 } from "fs";
4977
- import path12 from "path";
5638
+ import path13 from "path";
4978
5639
  import { consola as consola19 } from "consola";
4979
5640
  async function runRemove(opts) {
4980
5641
  const home = opts.monocerosHome ?? monocerosHome();
@@ -5025,13 +5686,13 @@ async function runRemove(opts) {
5025
5686
  let backupPath = null;
5026
5687
  if (!opts.noBackup && (hasYml || hasContainer)) {
5027
5688
  const ts = (opts.now ?? /* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
5028
- backupPath = path12.join(home, "container-backups", `${opts.name}-${ts}`);
5689
+ backupPath = path13.join(home, "container-backups", `${opts.name}-${ts}`);
5029
5690
  await fs13.mkdir(backupPath, { recursive: true });
5030
5691
  if (hasYml) {
5031
- await fs13.copyFile(ymlPath, path12.join(backupPath, `${opts.name}.yml`));
5692
+ await fs13.copyFile(ymlPath, path13.join(backupPath, `${opts.name}.yml`));
5032
5693
  }
5033
5694
  if (hasContainer) {
5034
- await fs13.cp(containerPath, path12.join(backupPath, "container"), {
5695
+ await fs13.cp(containerPath, path13.join(backupPath, "container"), {
5035
5696
  recursive: true
5036
5697
  });
5037
5698
  }
@@ -5144,7 +5805,7 @@ import { consola as consola22 } from "consola";
5144
5805
 
5145
5806
  // src/restore/index.ts
5146
5807
  import { existsSync as existsSync9, promises as fs14 } from "fs";
5147
- import path13 from "path";
5808
+ import path14 from "path";
5148
5809
  import { consola as consola21 } from "consola";
5149
5810
  async function runRestore(opts) {
5150
5811
  const home = opts.monocerosHome ?? monocerosHome();
@@ -5152,7 +5813,7 @@ async function runRestore(opts) {
5152
5813
  info: (msg) => consola21.info(msg),
5153
5814
  success: (msg) => consola21.success(msg)
5154
5815
  };
5155
- const backup = path13.resolve(opts.backupPath);
5816
+ const backup = path14.resolve(opts.backupPath);
5156
5817
  if (!existsSync9(backup)) {
5157
5818
  throw new Error(`Backup not found: ${backup}.`);
5158
5819
  }
@@ -5174,7 +5835,7 @@ async function runRestore(opts) {
5174
5835
  }
5175
5836
  const ymlFile = ymlFiles[0];
5176
5837
  const name = ymlFile.replace(/\.yml$/, "");
5177
- const containerInBackup = path13.join(backup, "container");
5838
+ const containerInBackup = path14.join(backup, "container");
5178
5839
  const hasContainer = existsSync9(containerInBackup);
5179
5840
  const destYml = containerConfigPath(name, home);
5180
5841
  const destContainer = containerDir(name, home);
@@ -5189,7 +5850,7 @@ async function runRestore(opts) {
5189
5850
  );
5190
5851
  }
5191
5852
  await fs14.mkdir(containerConfigsDir(home), { recursive: true });
5192
- await fs14.copyFile(path13.join(backup, ymlFile), destYml);
5853
+ await fs14.copyFile(path14.join(backup, ymlFile), destYml);
5193
5854
  if (hasContainer) {
5194
5855
  await fs14.cp(containerInBackup, destContainer, { recursive: true });
5195
5856
  }
@@ -5450,7 +6111,7 @@ import { consola as consola28 } from "consola";
5450
6111
 
5451
6112
  // src/devcontainer/shell.ts
5452
6113
  import { existsSync as existsSync10 } from "fs";
5453
- import path14 from "path";
6114
+ import path15 from "path";
5454
6115
  async function runShell(opts) {
5455
6116
  assertContainerExists(opts.root);
5456
6117
  const spawnFn = opts.spawn ?? spawnDevcontainer;
@@ -5473,7 +6134,7 @@ async function runShell(opts) {
5473
6134
  );
5474
6135
  }
5475
6136
  function assertContainerExists(root) {
5476
- if (!existsSync10(path14.join(root, ".devcontainer"))) {
6137
+ if (!existsSync10(path15.join(root, ".devcontainer"))) {
5477
6138
  throw new Error(
5478
6139
  `No .devcontainer/ at ${root}. Run \`monoceros apply <name>\` first.`
5479
6140
  );