@getmonoceros/workbench 1.5.3 → 1.6.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
@@ -274,9 +274,8 @@ var SOLUTION_NAME_RE = /^[A-Za-z0-9._-]+$/;
274
274
  var APT_PACKAGE_NAME_RE = /^[a-z0-9][a-z0-9.+-]*$/;
275
275
  var FEATURE_REF_RE = /^[a-z0-9.-]+(\/[a-z0-9._-]+)+:[a-z0-9._-]+$/;
276
276
  var INSTALL_URL_RE = /^https:\/\/[A-Za-z0-9.\-_~/:?#[\]@!&'()*+,;=%]+$/;
277
- var REPO_URL_RE = /^[A-Za-z0-9@:/+_~.#=&?-]+$/;
278
- var REPO_NAME_RE = /^[A-Za-z0-9._-]+$/;
279
- var REPO_BRANCH_RE = /^[A-Za-z0-9._/-]+$/;
277
+ var REPO_URL_RE = /^https:\/\/[A-Za-z0-9@:/+_~.#=&?-]+$/;
278
+ var REPO_PATH_RE = /^[A-Za-z0-9._-]+(\/[A-Za-z0-9._-]+)*$/;
280
279
  var POSTGRES_URL_RE = /^postgres(ql)?:\/\//;
281
280
  var REGEX = {
282
281
  solutionName: SOLUTION_NAME_RE,
@@ -284,10 +283,20 @@ var REGEX = {
284
283
  featureRef: FEATURE_REF_RE,
285
284
  installUrl: INSTALL_URL_RE,
286
285
  repoUrl: REPO_URL_RE,
287
- repoName: REPO_NAME_RE,
288
- repoBranch: REPO_BRANCH_RE,
286
+ repoPath: REPO_PATH_RE,
289
287
  postgresUrl: POSTGRES_URL_RE
290
288
  };
289
+ var PROVIDER_VALUES = [
290
+ "github",
291
+ "gitlab",
292
+ "bitbucket",
293
+ "gitea"
294
+ ];
295
+ var KNOWN_PROVIDER_HOSTS = {
296
+ "github.com": "github",
297
+ "gitlab.com": "gitlab",
298
+ "bitbucket.org": "bitbucket"
299
+ };
291
300
  var CONFIG_SCHEMA_VERSION = 1;
292
301
  var FeatureOptionValueSchema = z.union([
293
302
  z.string(),
@@ -301,23 +310,39 @@ var FeatureEntrySchema = z.object({
301
310
  ),
302
311
  options: z.record(z.string(), FeatureOptionValueSchema).optional()
303
312
  });
313
+ var GitUserSchema = z.object({
314
+ name: z.string().min(1),
315
+ email: z.string().min(3).regex(/^[^\s@]+@[^\s@]+\.[^\s@]+$/, "Invalid email")
316
+ });
304
317
  var RepoEntrySchema = z.object({
305
318
  url: z.string().regex(
306
319
  REPO_URL_RE,
307
- "Invalid repo URL. Use HTTPS or SSH/git@ form; no shell metacharacters."
320
+ "Invalid repo URL. Only HTTPS URLs are supported (https://...). SSH-style URLs (git@host:..., ssh://...) are not in scope \u2014 see ADR 0006."
308
321
  ),
309
- name: z.string().regex(
310
- REPO_NAME_RE,
311
- "Invalid repo name. Folder name must match /^[A-Za-z0-9._-]+$/."
322
+ path: z.string().regex(
323
+ REPO_PATH_RE,
324
+ "Invalid repo path. Use letters/digits/'._-', forward slashes for nested folders, no leading or trailing slash."
325
+ ).refine(
326
+ (p) => !p.split("/").some((seg) => seg === ".." || seg === "."),
327
+ 'Repo path segments cannot be "." or "..".'
312
328
  ).optional(),
313
- branch: z.string().regex(
314
- REPO_BRANCH_RE,
315
- "Invalid branch name. Must match /^[A-Za-z0-9._/-]+$/."
316
- ).optional()
317
- });
318
- var GitUserSchema = z.object({
319
- name: z.string().min(1),
320
- email: z.string().min(3).regex(/^[^\s@]+@[^\s@]+\.[^\s@]+$/, "Invalid email")
329
+ // Per-repo git identity override. Falls back to the container-level
330
+ // `git.user` (which itself falls back to the host's
331
+ // `git config --global` at apply time). Useful when a single
332
+ // container clones multiple repos that need different committer
333
+ // identities — e.g. work GitHub org vs personal projects.
334
+ git: z.object({
335
+ user: GitUserSchema.optional()
336
+ }).optional(),
337
+ // Provider hint for the pre-flight credential check. For the three
338
+ // canonical hosts (github.com / gitlab.com / bitbucket.org) the
339
+ // provider is auto-detected and this field is unnecessary. For any
340
+ // other host (self-hosted GitLab on a custom domain, Gitea, …) the
341
+ // builder MUST declare the provider so apply can suggest the right
342
+ // CLI setup (`glab auth login --hostname <host>` etc.) when
343
+ // credentials are missing. Enforced at apply pre-flight, not at
344
+ // parse time — see ADR 0006.
345
+ provider: z.enum(PROVIDER_VALUES).optional()
321
346
  });
322
347
  var ExternalServicesSchema = z.object({
323
348
  postgres: z.string().regex(
@@ -567,8 +592,7 @@ var APT_PACKAGE_NAME_RE2 = /^[a-z0-9][a-z0-9.+-]*$/;
567
592
  var FEATURE_REF_RE2 = /^[a-z0-9.-]+(\/[a-z0-9._-]+)+:[a-z0-9._-]+$/;
568
593
  var INSTALL_URL_RE2 = /^https:\/\/[A-Za-z0-9.\-_~/:?#[\]@!&'()*+,;=%]+$/;
569
594
  var REPO_URL_RE2 = /^[A-Za-z0-9@:/+_~.#=&?-]+$/;
570
- var REPO_NAME_RE2 = /^[A-Za-z0-9._-]+$/;
571
- var REPO_BRANCH_RE2 = /^[A-Za-z0-9._/-]+$/;
595
+ var REPO_PATH_RE2 = /^[A-Za-z0-9._-]+(\/[A-Za-z0-9._-]+)*$/;
572
596
  function deriveRepoName(url) {
573
597
  const lastSep = Math.max(url.lastIndexOf("/"), url.lastIndexOf(":"));
574
598
  const tail = url.slice(lastSep + 1);
@@ -621,29 +645,29 @@ function validateOptions(opts) {
621
645
  );
622
646
  }
623
647
  }
624
- const seenRepoNames = /* @__PURE__ */ new Set();
648
+ const seenRepoPaths = /* @__PURE__ */ new Set();
625
649
  for (const repo of opts.repos ?? []) {
626
650
  if (!REPO_URL_RE2.test(repo.url)) {
627
651
  throw new Error(
628
652
  `Invalid repo URL: ${JSON.stringify(repo.url)}. Use HTTPS or SSH/git@ form; no shell metacharacters.`
629
653
  );
630
654
  }
631
- if (!REPO_NAME_RE2.test(repo.name)) {
655
+ if (!REPO_PATH_RE2.test(repo.path)) {
632
656
  throw new Error(
633
- `Invalid repo name: ${JSON.stringify(repo.name)}. Folder name must match ${REPO_NAME_RE2}.`
657
+ `Invalid repo path: ${JSON.stringify(repo.path)}. Use letters/digits/'._-', forward slashes for nested folders, no leading or trailing slash.`
634
658
  );
635
659
  }
636
- if (repo.branch !== void 0 && !REPO_BRANCH_RE2.test(repo.branch)) {
660
+ if (repo.path.split("/").some((seg) => seg === ".." || seg === ".")) {
637
661
  throw new Error(
638
- `Invalid branch name: ${JSON.stringify(repo.branch)}. Must match ${REPO_BRANCH_RE2}.`
662
+ `Invalid repo path: ${JSON.stringify(repo.path)}. Path segments cannot be "." or "..".`
639
663
  );
640
664
  }
641
- if (seenRepoNames.has(repo.name)) {
665
+ if (seenRepoPaths.has(repo.path)) {
642
666
  throw new Error(
643
- `Duplicate repo name: ${JSON.stringify(repo.name)}. Each projects/<name> folder must be unique \u2014 pass --name to disambiguate.`
667
+ `Duplicate repo path: ${JSON.stringify(repo.path)}. Each projects/<path> folder must be unique \u2014 pass --path to disambiguate.`
644
668
  );
645
669
  }
646
- seenRepoNames.add(repo.name);
670
+ seenRepoPaths.add(repo.path);
647
671
  }
648
672
  }
649
673
  function normalizeOptions(opts) {
@@ -658,9 +682,7 @@ function normalizeOptions(opts) {
658
682
  ) : void 0;
659
683
  const installUrls = opts.installUrls ? [...new Set(opts.installUrls)] : void 0;
660
684
  const repos = opts.repos ? Array.from(
661
- new Map(
662
- opts.repos.map((r) => [`${r.url}${r.name}${r.branch ?? ""}`, r])
663
- ).values()
685
+ new Map(opts.repos.map((r) => [`${r.url}${r.path}`, r])).values()
664
686
  ) : void 0;
665
687
  return {
666
688
  name: opts.name,
@@ -676,19 +698,6 @@ function normalizeOptions(opts) {
676
698
  function needsCompose(opts) {
677
699
  return opts.services.length > 0;
678
700
  }
679
- var SSH_AGENT_TARGET = "/ssh-agent";
680
- var GIT_SSH_COMMAND = "ssh -o StrictHostKeyChecking=accept-new";
681
- function buildRepoAuthMounts() {
682
- return [
683
- `source=\${localEnv:SSH_AUTH_SOCK},target=${SSH_AGENT_TARGET},type=bind`
684
- ];
685
- }
686
- function buildRepoAuthEnv() {
687
- return {
688
- SSH_AUTH_SOCK: SSH_AGENT_TARGET,
689
- GIT_SSH_COMMAND
690
- };
691
- }
692
701
  function resolveFeatures(opts) {
693
702
  const resolved = [];
694
703
  for (const langSpec of opts.languages) {
@@ -807,8 +816,6 @@ function buildDevcontainerJson(opts) {
807
816
  );
808
817
  }
809
818
  }
810
- const wantsRepoAuth = (opts.repos?.length ?? 0) > 0;
811
- const repoAuthEnv = wantsRepoAuth ? { containerEnv: buildRepoAuthEnv() } : {};
812
819
  if (needsCompose(opts)) {
813
820
  return {
814
821
  name: opts.name,
@@ -819,14 +826,10 @@ function buildDevcontainerJson(opts) {
819
826
  remoteUser: "node",
820
827
  forwardPorts: [3e3, 4e3],
821
828
  postCreateCommand: ".devcontainer/post-create.sh",
822
- ...featuresField ?? {},
823
- ...repoAuthEnv
829
+ ...featuresField ?? {}
824
830
  };
825
831
  }
826
- const mounts = [
827
- ...wantsRepoAuth ? buildRepoAuthMounts() : [],
828
- ...homeMounts
829
- ];
832
+ const mounts = [...homeMounts];
830
833
  const mountsField = mounts.length > 0 ? { mounts } : {};
831
834
  return {
832
835
  name: opts.name,
@@ -836,8 +839,7 @@ function buildDevcontainerJson(opts) {
836
839
  runArgs: ["--cap-add=NET_ADMIN"],
837
840
  forwardPorts: [3e3, 4e3],
838
841
  postCreateCommand: ".devcontainer/post-create.sh",
839
- ...featuresField ?? {},
840
- ...repoAuthEnv
842
+ ...featuresField ?? {}
841
843
  };
842
844
  }
843
845
  function buildComposeYaml(opts) {
@@ -859,13 +861,6 @@ function buildComposeYaml(opts) {
859
861
  lines.push(` - ../home/${sub}:/home/node/${sub}`);
860
862
  }
861
863
  }
862
- const wantsRepoAuth = (opts.repos?.length ?? 0) > 0;
863
- if (wantsRepoAuth) {
864
- lines.push(` - \${SSH_AUTH_SOCK:-/dev/null}:${SSH_AGENT_TARGET}`);
865
- lines.push(" environment:");
866
- lines.push(` SSH_AUTH_SOCK: ${SSH_AGENT_TARGET}`);
867
- lines.push(` GIT_SSH_COMMAND: "${GIT_SSH_COMMAND}"`);
868
- }
869
864
  for (const svcId of opts.services) {
870
865
  const def = SERVICE_CATALOG[svcId];
871
866
  if (!def) continue;
@@ -887,10 +882,11 @@ function buildComposeYaml(opts) {
887
882
  function buildCodeWorkspaceJson(opts) {
888
883
  const folders = [{ path: "." }];
889
884
  const sortedRepos = [...opts.repos ?? []].sort(
890
- (a, b) => a.name.localeCompare(b.name)
885
+ (a, b) => a.path.localeCompare(b.path)
891
886
  );
892
887
  for (const repo of sortedRepos) {
893
- folders.push({ path: `projects/${repo.name}`, name: repo.name });
888
+ const label = repo.path.split("/").pop() ?? repo.path;
889
+ folders.push({ path: `projects/${repo.path}`, name: label });
894
890
  }
895
891
  return { folders };
896
892
  }
@@ -957,22 +953,34 @@ function buildPostCreateScript(opts) {
957
953
  lines.push(
958
954
  "",
959
955
  "# Repos managed by `monoceros add-repo`. Each entry is cloned",
960
- "# into `projects/<name>/` if (and only if) the directory does",
956
+ "# into `projects/<path>/` if (and only if) the directory does",
961
957
  "# not exist yet. Existing project subfolders are left alone so",
962
- "# local changes survive `monoceros apply` rebuilds.",
958
+ "# local changes survive `monoceros apply` rebuilds. Nested",
959
+ "# `<path>` (e.g. apps/web) is created via `mkdir -p` before the",
960
+ "# clone so the parent directories exist.",
963
961
  "mkdir -p projects"
964
962
  );
965
963
  for (const repo of opts.repos) {
966
- const branchFlag = repo.branch ? ` --branch ${repo.branch}` : "";
967
- const branchLabel = repo.branch ? ` (branch: ${repo.branch})` : "";
964
+ const parent = repo.path.includes("/") ? repo.path.slice(0, repo.path.lastIndexOf("/")) : null;
965
+ if (parent) {
966
+ lines.push(`mkdir -p "projects/${parent}"`);
967
+ }
968
968
  lines.push(
969
- `if [ ! -d "projects/${repo.name}" ]; then`,
970
- ` echo "\u2192 Cloning ${repo.name} from ${repo.url}${branchLabel}\u2026"`,
971
- ` git clone${branchFlag} "${repo.url}" "projects/${repo.name}"`,
969
+ `if [ ! -d "projects/${repo.path}" ]; then`,
970
+ ` echo "\u2192 Cloning ${repo.path} from ${repo.url}\u2026"`,
971
+ ` git clone "${repo.url}" "projects/${repo.path}"`,
972
972
  `else`,
973
- ` echo "\u2192 projects/${repo.name} already exists, skipping clone"`,
973
+ ` echo "\u2192 projects/${repo.path} already exists, skipping clone"`,
974
974
  `fi`
975
975
  );
976
+ if (repo.gitUser) {
977
+ const safeName = repo.gitUser.name.replace(/"/g, '\\"');
978
+ const safeEmail = repo.gitUser.email.replace(/"/g, '\\"');
979
+ lines.push(
980
+ `git -C "projects/${repo.path}" config user.name "${safeName}"`,
981
+ `git -C "projects/${repo.path}" config user.email "${safeEmail}"`
982
+ );
983
+ }
976
984
  }
977
985
  }
978
986
  return lines.join("\n") + "\n";
@@ -1123,25 +1131,56 @@ function addFeatureToDoc(doc, ref, options = {}) {
1123
1131
  }
1124
1132
  function addRepoToDoc(doc, repo) {
1125
1133
  const seq = ensureSeq(doc, "repos");
1126
- const repoName = repo.name ?? deriveRepoName(repo.url);
1127
1134
  for (const item of seq.items) {
1128
1135
  if (!isMap(item)) continue;
1129
1136
  const url = item.get("url");
1130
1137
  if (url !== repo.url) continue;
1131
- const existingName = item.get("name");
1132
- const effectiveName = typeof existingName === "string" ? existingName : deriveRepoName(url);
1133
- const existingBranch = item.get("branch");
1134
- if (effectiveName === repoName && (existingBranch ?? void 0) === (repo.branch ?? void 0)) {
1138
+ const existingPath = item.get("path");
1139
+ const effectivePath = typeof existingPath === "string" ? existingPath : deriveRepoName(url);
1140
+ if (effectivePath !== repo.path) continue;
1141
+ const existingGit = item.get("git", true);
1142
+ const existingUser = existingGit && isMap(existingGit) ? existingGit.get("user", true) : null;
1143
+ const existingName = existingUser && isMap(existingUser) ? existingUser.get("name") : null;
1144
+ const existingEmail = existingUser && isMap(existingUser) ? existingUser.get("email") : null;
1145
+ const existingGitUser = typeof existingName === "string" && typeof existingEmail === "string" ? { name: existingName, email: existingEmail } : void 0;
1146
+ const sameGitUser = (existingGitUser?.name ?? null) === (repo.gitUser?.name ?? null) && (existingGitUser?.email ?? null) === (repo.gitUser?.email ?? null);
1147
+ const existingProvider = item.get("provider");
1148
+ const sameProvider = (typeof existingProvider === "string" ? existingProvider : null) === (repo.provider ?? null);
1149
+ if (sameGitUser && sameProvider) {
1135
1150
  return false;
1136
1151
  }
1152
+ if (repo.gitUser) {
1153
+ const gitMap = new YAMLMap();
1154
+ const userMap = new YAMLMap();
1155
+ userMap.set("name", repo.gitUser.name);
1156
+ userMap.set("email", repo.gitUser.email);
1157
+ gitMap.set("user", userMap);
1158
+ item.set("git", gitMap);
1159
+ } else {
1160
+ item.delete("git");
1161
+ }
1162
+ if (repo.provider) {
1163
+ item.set("provider", repo.provider);
1164
+ } else {
1165
+ item.delete("provider");
1166
+ }
1167
+ return true;
1137
1168
  }
1138
1169
  const entry2 = new YAMLMap();
1139
1170
  entry2.set("url", repo.url);
1140
- if (repo.name !== void 0 && repo.name !== deriveRepoName(repo.url)) {
1141
- entry2.set("name", repo.name);
1171
+ if (repo.path !== deriveRepoName(repo.url)) {
1172
+ entry2.set("path", repo.path);
1173
+ }
1174
+ if (repo.gitUser) {
1175
+ const gitMap = new YAMLMap();
1176
+ const userMap = new YAMLMap();
1177
+ userMap.set("name", repo.gitUser.name);
1178
+ userMap.set("email", repo.gitUser.email);
1179
+ gitMap.set("user", userMap);
1180
+ entry2.set("git", gitMap);
1142
1181
  }
1143
- if (repo.branch !== void 0) {
1144
- entry2.set("branch", repo.branch);
1182
+ if (repo.provider) {
1183
+ entry2.set("provider", repo.provider);
1145
1184
  }
1146
1185
  seq.add(entry2);
1147
1186
  return true;
@@ -1174,16 +1213,16 @@ function removeFeatureFromDoc(doc, ref) {
1174
1213
  pruneEmptySeq(doc, "features");
1175
1214
  return true;
1176
1215
  }
1177
- function removeRepoFromDoc(doc, urlOrName) {
1216
+ function removeRepoFromDoc(doc, urlOrPath) {
1178
1217
  const seq = doc.get("repos", true);
1179
1218
  if (!seq || !isSeq(seq)) return false;
1180
1219
  const idx = seq.items.findIndex((item) => {
1181
1220
  if (!isMap(item)) return false;
1182
1221
  const url = item.get("url");
1183
- if (url === urlOrName) return true;
1184
- const name = item.get("name");
1185
- const effectiveName = typeof name === "string" ? name : typeof url === "string" ? deriveRepoName(url) : void 0;
1186
- return effectiveName === urlOrName;
1222
+ if (url === urlOrPath) return true;
1223
+ const path13 = item.get("path");
1224
+ const effectivePath = typeof path13 === "string" ? path13 : typeof url === "string" ? deriveRepoName(url) : void 0;
1225
+ return effectivePath === urlOrPath;
1187
1226
  });
1188
1227
  if (idx < 0) return false;
1189
1228
  seq.items.splice(idx, 1);
@@ -1225,21 +1264,65 @@ function runAddAptPackages(input) {
1225
1264
  }
1226
1265
  return mutate(input, (doc) => addAptPackagesToDoc(doc, input.packages));
1227
1266
  }
1228
- function runAddRepo(input) {
1267
+ async function runAddRepo(input) {
1229
1268
  const url = input.url.trim();
1230
1269
  if (url.length === 0) {
1231
1270
  throw new Error(
1232
1271
  "Missing repo URL. Usage: monoceros add-repo <containername> <url>."
1233
1272
  );
1234
1273
  }
1235
- const name = (input.repoName ?? deriveRepoName(url)).trim();
1274
+ const path13 = (input.path ?? deriveRepoName(url)).trim();
1275
+ const hasName = typeof input.gitName === "string" && input.gitName.trim().length > 0;
1276
+ const hasEmail = typeof input.gitEmail === "string" && input.gitEmail.trim().length > 0;
1277
+ if (hasName !== hasEmail) {
1278
+ throw new Error(
1279
+ "--git-name and --git-email must be set together. Pass both, or neither."
1280
+ );
1281
+ }
1282
+ const explicitProvider = normalizeProvider(input.provider);
1283
+ let host;
1284
+ try {
1285
+ host = url.startsWith("https://") ? new URL(url).hostname : void 0;
1286
+ } catch {
1287
+ host = void 0;
1288
+ }
1289
+ const canonical = host ? KNOWN_PROVIDER_HOSTS[host.toLowerCase()] : void 0;
1290
+ if (host && !canonical && !explicitProvider) {
1291
+ throw new Error(
1292
+ `Host '${host}' is not a canonical Git provider Monoceros can auto-detect (github.com / gitlab.com / bitbucket.org). Pass --provider=github|gitlab|bitbucket so the credential-helper hints know which CLI to suggest.`
1293
+ );
1294
+ }
1295
+ if (canonical && explicitProvider && explicitProvider !== canonical) {
1296
+ throw new Error(
1297
+ `--provider=${explicitProvider} contradicts host '${host}' (auto-detected as ${canonical}). Drop --provider for canonical hosts, or fix the value.`
1298
+ );
1299
+ }
1300
+ const providerToWrite = !canonical && explicitProvider ? explicitProvider : void 0;
1236
1301
  const entry2 = {
1237
1302
  url,
1238
- name,
1239
- ...input.branch !== void 0 ? { branch: input.branch } : {}
1303
+ path: path13,
1304
+ ...hasName && hasEmail ? {
1305
+ gitUser: {
1306
+ name: input.gitName.trim(),
1307
+ email: input.gitEmail.trim()
1308
+ }
1309
+ } : {},
1310
+ ...providerToWrite ? { provider: providerToWrite } : {}
1240
1311
  };
1241
1312
  return mutate(input, (doc) => addRepoToDoc(doc, entry2));
1242
1313
  }
1314
+ function normalizeProvider(raw) {
1315
+ if (typeof raw !== "string") return void 0;
1316
+ const trimmed = raw.trim();
1317
+ if (trimmed.length === 0) return void 0;
1318
+ const lowered = trimmed.toLowerCase();
1319
+ if (!PROVIDER_VALUES.includes(lowered)) {
1320
+ throw new Error(
1321
+ `Invalid --provider value: ${JSON.stringify(raw)}. Allowed: ${PROVIDER_VALUES.join(", ")}.`
1322
+ );
1323
+ }
1324
+ return lowered;
1325
+ }
1243
1326
  function runAddFromUrl(input) {
1244
1327
  const url = input.url.trim();
1245
1328
  if (url.length === 0) {
@@ -1551,7 +1634,7 @@ var addRepoCommand = defineCommand4({
1551
1634
  meta: {
1552
1635
  name: "add-repo",
1553
1636
  group: "edit",
1554
- description: "Add a git repo to the container config. Cloned into projects/<folder>/ on container build. Idempotent \u2014 existing project subfolders are left alone. Folder name derived from URL by default; override with --as."
1637
+ 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."
1555
1638
  },
1556
1639
  args: {
1557
1640
  name: {
@@ -1564,13 +1647,21 @@ var addRepoCommand = defineCommand4({
1564
1647
  description: "Git URL (HTTPS or SSH/git@ form). E.g. https://github.com/foo/bar.git, git@github.com:foo/bar.git.",
1565
1648
  required: true
1566
1649
  },
1567
- as: {
1650
+ path: {
1651
+ type: "string",
1652
+ description: "Destination under projects/. Subfolders via `/` (e.g. apps/web). Default: URL-derived single segment (bar.git \u2192 bar)."
1653
+ },
1654
+ "git-name": {
1568
1655
  type: "string",
1569
- description: "Folder name under projects/. Default: derived from URL (e.g. bar.git \u2192 bar)."
1656
+ description: "Per-repo git committer name. Overrides the container-level git.user.name for this repo only. Pair with --git-email."
1570
1657
  },
1571
- branch: {
1658
+ "git-email": {
1572
1659
  type: "string",
1573
- description: "Specific branch to clone (default: repo default branch)."
1660
+ description: "Per-repo git committer email. Overrides the container-level git.user.email for this repo only. Pair with --git-name."
1661
+ },
1662
+ provider: {
1663
+ type: "string",
1664
+ 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."
1574
1665
  },
1575
1666
  yes: {
1576
1667
  type: "boolean",
@@ -1584,8 +1675,10 @@ var addRepoCommand = defineCommand4({
1584
1675
  const result = await runAddRepo({
1585
1676
  name: args.name,
1586
1677
  url: args.url,
1587
- ...typeof args.as === "string" ? { repoName: args.as } : {},
1588
- ...typeof args.branch === "string" ? { branch: args.branch } : {},
1678
+ ...typeof args.path === "string" ? { path: args.path } : {},
1679
+ ...typeof args["git-name"] === "string" ? { gitName: args["git-name"] } : {},
1680
+ ...typeof args["git-email"] === "string" ? { gitEmail: args["git-email"] } : {},
1681
+ ...typeof args.provider === "string" ? { provider: args.provider } : {},
1589
1682
  yes: args.yes
1590
1683
  });
1591
1684
  process.exit(result.status === "aborted" ? 1 : 0);
@@ -1804,11 +1897,13 @@ function solutionConfigToCreateOptions(config, featureDefaults = {}) {
1804
1897
  if (config.repos.length > 0) {
1805
1898
  result.repos = config.repos.map((r) => ({
1806
1899
  url: r.url,
1807
- // `name` is optional in the yml (derived from URL on apply),
1808
- // required in CreateOptions; the caller derives it via
1809
- // `deriveRepoName` when undefined.
1810
- name: r.name ?? deriveRepoName(r.url),
1811
- ...r.branch !== void 0 ? { branch: r.branch } : {}
1900
+ // `path` is optional in the yml; CreateOptions requires it.
1901
+ // When the yml omits `path`, fall back to the URL-derived
1902
+ // single-segment default (`https://.../foo.git` `foo`),
1903
+ // which lands the clone at `projects/foo/`.
1904
+ path: r.path ?? deriveRepoName(r.url),
1905
+ ...r.git?.user ? { gitUser: { name: r.git.user.name, email: r.git.user.email } } : {},
1906
+ ...r.provider ? { provider: r.provider } : {}
1812
1907
  }));
1813
1908
  }
1814
1909
  return result;
@@ -2020,7 +2115,10 @@ async function runStart(opts) {
2020
2115
  const logger = opts.logger ?? { info: (msg) => consola8.info(msg) };
2021
2116
  const spawnFn = opts.spawn ?? spawnDevcontainer;
2022
2117
  logger.info(`Bringing devcontainer up at ${opts.root}\u2026`);
2023
- return spawnFn(["up", "--workspace-folder", opts.root], opts.root);
2118
+ return spawnFn(
2119
+ ["up", "--workspace-folder", opts.root, "--mount-workspace-git-root=false"],
2120
+ opts.root
2121
+ );
2024
2122
  }
2025
2123
  async function runContainerCycle(root, opts) {
2026
2124
  const { hasCompose, logger } = opts;
@@ -2054,7 +2152,13 @@ async function runContainerCycle(root, opts) {
2054
2152
  logger.info(`Recreating image-mode devcontainer at ${root}\u2026`);
2055
2153
  const spawnFn = opts.devcontainerSpawn ?? spawnDevcontainer;
2056
2154
  return spawnFn(
2057
- ["up", "--workspace-folder", root, "--remove-existing-container"],
2155
+ [
2156
+ "up",
2157
+ "--workspace-folder",
2158
+ root,
2159
+ "--mount-workspace-git-root=false",
2160
+ "--remove-existing-container"
2161
+ ],
2058
2162
  root
2059
2163
  );
2060
2164
  }
@@ -2089,7 +2193,13 @@ import path6 from "path";
2089
2193
  var realGitCredentialFill = (input) => {
2090
2194
  return new Promise((resolve, reject) => {
2091
2195
  const child = spawn3("git", ["credential", "fill"], {
2092
- stdio: ["pipe", "pipe", "inherit"]
2196
+ stdio: ["pipe", "pipe", "inherit"],
2197
+ env: {
2198
+ ...process.env,
2199
+ GIT_TERMINAL_PROMPT: "0",
2200
+ GIT_ASKPASS: "",
2201
+ SSH_ASKPASS: ""
2202
+ }
2093
2203
  });
2094
2204
  let stdout = "";
2095
2205
  child.stdout.on("data", (chunk) => {
@@ -2101,16 +2211,135 @@ var realGitCredentialFill = (input) => {
2101
2211
  child.stdin.end();
2102
2212
  });
2103
2213
  };
2214
+ function resolveProvider(host, explicit) {
2215
+ const canonical = KNOWN_PROVIDER_HOSTS[host.toLowerCase()];
2216
+ if (canonical) return canonical;
2217
+ return explicit ?? "unknown";
2218
+ }
2104
2219
  function uniqueHttpsHosts(repos) {
2105
- const hosts = /* @__PURE__ */ new Set();
2220
+ const byHost = /* @__PURE__ */ new Map();
2106
2221
  for (const repo of repos) {
2107
2222
  if (!repo.url.startsWith("https://")) continue;
2223
+ let host;
2108
2224
  try {
2109
- hosts.add(new URL(repo.url).hostname);
2225
+ host = new URL(repo.url).hostname;
2110
2226
  } catch {
2227
+ continue;
2111
2228
  }
2229
+ if (byHost.has(host)) continue;
2230
+ byHost.set(host, { host, provider: resolveProvider(host, repo.provider) });
2231
+ }
2232
+ return [...byHost.values()];
2233
+ }
2234
+ function installCommandForOS(opts) {
2235
+ switch (process.platform) {
2236
+ case "darwin":
2237
+ return cyan2(opts.brew);
2238
+ case "win32":
2239
+ return cyan2(opts.winget);
2240
+ default:
2241
+ if (opts.linuxBrew) return cyan2(opts.linuxBrew);
2242
+ return `See ${opts.linuxDocsUrl} for package instructions.`;
2243
+ }
2244
+ }
2245
+ function providerSetupHint(host, provider) {
2246
+ if (provider === "github") {
2247
+ const isSaas = host.toLowerCase() === "github.com";
2248
+ const hostArg = isSaas ? "" : ` --hostname ${host}`;
2249
+ const install = installCommandForOS({
2250
+ brew: "brew install gh",
2251
+ winget: "winget install --id GitHub.cli",
2252
+ linuxDocsUrl: "https://github.com/cli/cli#installation"
2253
+ });
2254
+ return {
2255
+ title: `${host} \u2014 GitHub`,
2256
+ body: [
2257
+ "Install the GitHub CLI:",
2258
+ install,
2259
+ "",
2260
+ "Then run once:",
2261
+ cyan2(`gh auth login${hostArg}`),
2262
+ cyan2(`gh auth setup-git${hostArg}`),
2263
+ "",
2264
+ "`gh auth login` walks through OAuth in your browser.",
2265
+ "`gh auth setup-git` wires gh into git as a credential helper."
2266
+ ].join("\n")
2267
+ };
2112
2268
  }
2113
- return [...hosts];
2269
+ if (provider === "gitlab") {
2270
+ const isSaas = host.toLowerCase() === "gitlab.com";
2271
+ const hostArg = isSaas ? "" : ` --hostname ${host}`;
2272
+ const install = installCommandForOS({
2273
+ brew: "brew install glab",
2274
+ winget: "winget install --id GLab.GLab",
2275
+ linuxBrew: "brew install glab",
2276
+ linuxDocsUrl: "https://gitlab.com/gitlab-org/cli#installation"
2277
+ });
2278
+ return {
2279
+ title: `${host} \u2014 GitLab`,
2280
+ body: [
2281
+ "Install the GitLab CLI (glab):",
2282
+ install,
2283
+ "",
2284
+ "Then run once:",
2285
+ cyan2(`glab auth login${hostArg}`),
2286
+ "",
2287
+ "Choose `HTTPS` when asked for git-protocol, then accept",
2288
+ '"Authenticate Git with your GitLab credentials" \u2014 glab',
2289
+ "configures itself as the git credential helper."
2290
+ ].join("\n")
2291
+ };
2292
+ }
2293
+ if (provider === "bitbucket") {
2294
+ const isCloud = host.toLowerCase() === "bitbucket.org";
2295
+ if (isCloud) {
2296
+ return {
2297
+ title: `${host} \u2014 Bitbucket Cloud`,
2298
+ body: [
2299
+ "Bitbucket has no first-party CLI for git-credentials, so this",
2300
+ "is a manual one-time setup. Generate an Atlassian API token at",
2301
+ "https://id.atlassian.com/manage-profile/security/api-tokens",
2302
+ "",
2303
+ "Then store it via your OS credential helper:",
2304
+ cyan2(
2305
+ `git credential approve <<< $'protocol=https\\nhost=${host}\\nusername=<your-atlassian-email>\\npassword=<token>\\n'`
2306
+ )
2307
+ ].join("\n")
2308
+ };
2309
+ }
2310
+ return {
2311
+ title: `${host} \u2014 Bitbucket Data Center`,
2312
+ body: [
2313
+ "Bitbucket has no first-party CLI for git-credentials, so this",
2314
+ "is a manual one-time setup. Generate a personal HTTP access",
2315
+ `token in your Bitbucket UI: profile picture (top right on ${host})`,
2316
+ "\u2192 Manage account \u2192 HTTP access tokens \u2192 Create token. Give it",
2317
+ "at least repo-read + repo-write scopes for the repos you need.",
2318
+ "",
2319
+ "Then store it via your OS credential helper:",
2320
+ cyan2(
2321
+ `git credential approve <<< $'protocol=https\\nhost=${host}\\nusername=<your-bitbucket-username>\\npassword=<token>\\n'`
2322
+ )
2323
+ ].join("\n")
2324
+ };
2325
+ }
2326
+ return {
2327
+ title: `${host} \u2014 Gitea`,
2328
+ body: [
2329
+ "Gitea has no first-party CLI helper for git-credentials (the",
2330
+ "`tea` CLI logs into its own config, not into your git credential",
2331
+ "helper), so this is a manual one-time setup. Generate an access",
2332
+ `token in your Gitea UI: profile picture (top right on ${host}) \u2192`,
2333
+ 'Settings \u2192 Applications \u2192 "Generate New Token". Give it at',
2334
+ "least the `read:repository` scope (add `write:repository` if you",
2335
+ "need push from the container).",
2336
+ "",
2337
+ "Then store it via your OS credential helper:",
2338
+ cyan2(
2339
+ `git credential approve <<< $'protocol=https\\nhost=${host}\\nusername=<your-gitea-username>\\npassword=<token>\\n'`
2340
+ )
2341
+ ].join("\n")
2342
+ };
2114
2343
  }
2115
2344
  function parseCredentialFillOutput(output) {
2116
2345
  const result = {};
@@ -2129,17 +2358,26 @@ function formatCredentialLine(host, username, password) {
2129
2358
  const encPass = encodeURIComponent(password);
2130
2359
  return `https://${encUser}:${encPass}@${host}`;
2131
2360
  }
2132
- async function collectGitCredentials(devContainerRoot, repos, options = {}) {
2361
+ async function collectGitCredentials(devContainerRoot, hosts, options = {}) {
2133
2362
  const credsDir = path6.join(devContainerRoot, ".monoceros");
2134
2363
  const credentialsPath = path6.join(credsDir, "git-credentials");
2135
- const hosts = uniqueHttpsHosts(repos);
2136
2364
  const spawnFn = options.spawn ?? realGitCredentialFill;
2137
2365
  const logger = options.logger ?? { info: () => {
2138
2366
  }, warn: () => {
2139
2367
  } };
2140
2368
  const lines = [];
2141
- let hostsSkipped = 0;
2142
- for (const host of hosts) {
2369
+ const perHost = [];
2370
+ for (const { host, provider } of hosts) {
2371
+ if (provider === "unknown") {
2372
+ perHost.push({
2373
+ host,
2374
+ provider: "github",
2375
+ // placeholder — never rendered because pre-flight already bailed
2376
+ status: "no-credentials",
2377
+ detail: "provider not declared (internal: should not reach here)"
2378
+ });
2379
+ continue;
2380
+ }
2143
2381
  logger.info(`Fetching credentials for ${host} from host git\u2026`);
2144
2382
  const input = `protocol=https
2145
2383
  host=${host}
@@ -2149,28 +2387,31 @@ host=${host}
2149
2387
  try {
2150
2388
  result = await spawnFn(input);
2151
2389
  } catch (err) {
2152
- logger.warn(
2153
- `git credential fill not runnable for ${host} (${err instanceof Error ? err.message : String(err)}); skipping.`
2154
- );
2155
- hostsSkipped += 1;
2390
+ const detail = err instanceof Error ? err.message : String(err);
2391
+ perHost.push({ host, provider, status: "spawn-error", detail });
2156
2392
  continue;
2157
2393
  }
2158
2394
  if (result.exitCode !== 0) {
2159
- logger.warn(
2160
- `git credential fill exited ${result.exitCode} for ${host}; container clone will prompt.`
2161
- );
2162
- hostsSkipped += 1;
2395
+ perHost.push({
2396
+ host,
2397
+ provider,
2398
+ status: "non-zero-exit",
2399
+ detail: `exit code ${result.exitCode}`
2400
+ });
2163
2401
  continue;
2164
2402
  }
2165
2403
  const { username, password } = parseCredentialFillOutput(result.stdout);
2166
2404
  if (!username || !password) {
2167
- logger.warn(
2168
- `git credential fill returned no username/password for ${host}; container clone will prompt.`
2169
- );
2170
- hostsSkipped += 1;
2405
+ perHost.push({
2406
+ host,
2407
+ provider,
2408
+ status: "no-credentials",
2409
+ detail: "host credential helper returned no username/password"
2410
+ });
2171
2411
  continue;
2172
2412
  }
2173
2413
  lines.push(formatCredentialLine(host, username, password));
2414
+ perHost.push({ host, provider, status: "ok", detail: "" });
2174
2415
  }
2175
2416
  await fs6.mkdir(credsDir, { recursive: true });
2176
2417
  await fs6.writeFile(
@@ -2182,19 +2423,204 @@ host=${host}
2182
2423
  );
2183
2424
  return {
2184
2425
  hostsWritten: lines.length,
2185
- hostsSkipped,
2426
+ hostsSkipped: perHost.filter((p) => p.status !== "ok").length,
2427
+ perHost,
2186
2428
  credentialsPath
2187
2429
  };
2188
2430
  }
2431
+ function formatMissingCredentialsError(missing) {
2432
+ if (missing.length === 1) {
2433
+ const m = missing[0];
2434
+ const hint = providerSetupHint(m.host, m.provider);
2435
+ return [
2436
+ `Missing Git credentials: ${hint.title}`,
2437
+ "",
2438
+ hint.body,
2439
+ "",
2440
+ `Then re-run ${cyan2("monoceros apply")}.`
2441
+ ].join("\n");
2442
+ }
2443
+ const lines = [
2444
+ `Missing Git credentials for ${missing.length} hosts:`,
2445
+ ""
2446
+ ];
2447
+ for (const m of missing) {
2448
+ const hint = providerSetupHint(m.host, m.provider);
2449
+ lines.push(hint.title);
2450
+ lines.push("");
2451
+ lines.push(hint.body);
2452
+ lines.push("");
2453
+ }
2454
+ lines.push(`Then re-run ${cyan2("monoceros apply")}.`);
2455
+ return lines.join("\n");
2456
+ }
2457
+ function formatUnknownProviderError(hosts) {
2458
+ const sorted = [...new Set(hosts)].sort();
2459
+ const lines = [
2460
+ sorted.length === 1 ? `Unknown Git provider for host ${sorted[0]}.` : `Unknown Git provider for ${sorted.length} hosts: ${sorted.join(", ")}.`,
2461
+ "",
2462
+ "Monoceros auto-detects only github.com / gitlab.com / bitbucket.org.",
2463
+ "For any other host (self-hosted GitLab, Gitea, Bitbucket Server, \u2026)",
2464
+ "declare the provider explicitly in the yml. Edit the repo entry:",
2465
+ "",
2466
+ cyan2(" repos:"),
2467
+ cyan2(` - url: https://${sorted[0]}/\u2026`),
2468
+ cyan2(" provider: gitlab # or: github, bitbucket, gitea"),
2469
+ "",
2470
+ `Or re-add with ${cyan2("monoceros add-repo <name> <url> --provider=<github|gitlab|bitbucket|gitea>")}.`
2471
+ ];
2472
+ return lines.join("\n");
2473
+ }
2189
2474
 
2190
- // src/devcontainer/identity.ts
2475
+ // src/devcontainer/repo-reachability.ts
2191
2476
  import { spawn as spawn4 } from "child_process";
2477
+ var realGitLsRemote = (url) => {
2478
+ return new Promise((resolve, reject) => {
2479
+ const child = spawn4("git", ["ls-remote", "--heads", "--", url], {
2480
+ stdio: ["ignore", "pipe", "pipe"],
2481
+ env: {
2482
+ ...process.env,
2483
+ GIT_TERMINAL_PROMPT: "0",
2484
+ GIT_ASKPASS: "",
2485
+ SSH_ASKPASS: ""
2486
+ }
2487
+ });
2488
+ let stdout = "";
2489
+ let stderr = "";
2490
+ child.stdout.on("data", (chunk) => {
2491
+ stdout += chunk.toString();
2492
+ });
2493
+ child.stderr.on("data", (chunk) => {
2494
+ stderr += chunk.toString();
2495
+ });
2496
+ child.on("error", reject);
2497
+ child.on(
2498
+ "exit",
2499
+ (code) => resolve({ stdout, stderr, exitCode: code ?? 0 })
2500
+ );
2501
+ });
2502
+ };
2503
+ function classifyStderr(stderr) {
2504
+ const s = stderr.toLowerCase();
2505
+ if (s.includes("could not resolve host") || s.includes("name or service not known") || s.includes("temporary failure in name resolution") || s.includes("no address associated with hostname")) {
2506
+ return "dns";
2507
+ }
2508
+ if (s.includes("repository not found") || s.includes("may not have access") || s.includes("no longer exists") || s.includes("don't have permission") || s.includes("could not be found") || s.includes("the requested url returned error: 404")) {
2509
+ return "not-found-or-no-access";
2510
+ }
2511
+ if (s.includes("authentication failed") || s.includes("could not read username") || s.includes("incorrect username or password") || s.includes("the requested url returned error: 401") || s.includes("the requested url returned error: 403")) {
2512
+ return "auth-failed";
2513
+ }
2514
+ return "unknown";
2515
+ }
2516
+ async function checkRepoReachability(repos, options = {}) {
2517
+ const spawnFn = options.spawn ?? realGitLsRemote;
2518
+ const results = [];
2519
+ for (const repo of repos) {
2520
+ let result;
2521
+ try {
2522
+ result = await spawnFn(repo.url);
2523
+ } catch (err) {
2524
+ results.push({
2525
+ url: repo.url,
2526
+ ok: false,
2527
+ kind: "unknown",
2528
+ detail: err instanceof Error ? err.message : String(err)
2529
+ });
2530
+ continue;
2531
+ }
2532
+ if (result.exitCode === 0) {
2533
+ results.push({ url: repo.url, ok: true, detail: "" });
2534
+ continue;
2535
+ }
2536
+ results.push({
2537
+ url: repo.url,
2538
+ ok: false,
2539
+ kind: classifyStderr(result.stderr),
2540
+ detail: result.stderr.trim()
2541
+ });
2542
+ }
2543
+ return results;
2544
+ }
2545
+ function formatUnreachableReposError(failures) {
2546
+ const byKind = /* @__PURE__ */ new Map();
2547
+ for (const f of failures) {
2548
+ const kind = f.kind ?? "unknown";
2549
+ const list = byKind.get(kind) ?? [];
2550
+ list.push(f);
2551
+ byKind.set(kind, list);
2552
+ }
2553
+ const totalUrls = failures.length;
2554
+ const lines = [
2555
+ totalUrls === 1 ? `Cannot reach declared repo: ${failures[0].url}` : `Cannot reach ${totalUrls} declared repos:`,
2556
+ ""
2557
+ ];
2558
+ const sectionOrder = [
2559
+ "not-found-or-no-access",
2560
+ "auth-failed",
2561
+ "dns",
2562
+ "unknown"
2563
+ ];
2564
+ for (const kind of sectionOrder) {
2565
+ const entries = byKind.get(kind);
2566
+ if (!entries || entries.length === 0) continue;
2567
+ lines.push(headerForKind(kind));
2568
+ for (const e of entries) {
2569
+ lines.push(` \u2022 ${e.url}`);
2570
+ }
2571
+ for (const advice of adviceForKind(kind)) {
2572
+ lines.push(` - ${advice}`);
2573
+ }
2574
+ lines.push("");
2575
+ }
2576
+ lines.push(`Then re-run ${cyan2("monoceros apply")}.`);
2577
+ return lines.join("\n");
2578
+ }
2579
+ function headerForKind(kind) {
2580
+ switch (kind) {
2581
+ case "not-found-or-no-access":
2582
+ return "Repository not found (or your credentials don't grant access):";
2583
+ case "auth-failed":
2584
+ return "Authentication failed (credentials are present but rejected):";
2585
+ case "dns":
2586
+ return "Host unreachable (DNS / VPN / offline \u2014 git couldn't resolve the hostname):";
2587
+ case "unknown":
2588
+ return "Unrecognised git error:";
2589
+ }
2590
+ }
2591
+ function adviceForKind(kind) {
2592
+ switch (kind) {
2593
+ case "not-found-or-no-access":
2594
+ return [
2595
+ "Re-check the URL for typos (case-sensitive on most hosts).",
2596
+ "Verify the repo still exists / is not archived in a way that hides it.",
2597
+ "Ensure your token covers this org / workspace and has read scope (GitHub: `repo`; GitLab: `read_repository`; Bitbucket: repo read)."
2598
+ ];
2599
+ case "auth-failed":
2600
+ return [
2601
+ "Token may be expired or revoked \u2014 regenerate it from the provider UI.",
2602
+ "Re-run the provider CLI login (gh auth login / glab auth login) \u2014 Monoceros picks up the refreshed token on the next apply."
2603
+ ];
2604
+ case "dns":
2605
+ return [
2606
+ "Check your internet / VPN \u2014 corporate Git hosts often require VPN.",
2607
+ "Verify the hostname spelling in the yml."
2608
+ ];
2609
+ case "unknown":
2610
+ return [
2611
+ "Run `git ls-remote <url>` manually on the host to see the raw error."
2612
+ ];
2613
+ }
2614
+ }
2615
+
2616
+ // src/devcontainer/identity.ts
2617
+ import { spawn as spawn5 } from "child_process";
2192
2618
  import { promises as fs7 } from "fs";
2193
2619
  import path7 from "path";
2194
2620
  import { consola as consola9 } from "consola";
2195
2621
  var realGitConfigGet = (key) => {
2196
2622
  return new Promise((resolve, reject) => {
2197
- const child = spawn4("git", ["config", "--global", "--get", key], {
2623
+ const child = spawn5("git", ["config", "--global", "--get", key], {
2198
2624
  stdio: ["ignore", "pipe", "inherit"]
2199
2625
  });
2200
2626
  let stdout = "";
@@ -2352,11 +2778,30 @@ ${sectionLine(label)}
2352
2778
  ...globalConfig?.defaults?.git?.user ? { defaults: globalConfig.defaults.git.user } : {},
2353
2779
  logger: idLogger
2354
2780
  });
2355
- if (createOpts.repos && createOpts.repos.some((r) => r.url.startsWith("https://"))) {
2356
- await collectGitCredentials(targetDir, createOpts.repos, {
2781
+ const hostsToFetch = uniqueHttpsHosts(createOpts.repos ?? []);
2782
+ const unknownProviderHosts = hostsToFetch.filter((h) => h.provider === "unknown").map((h) => h.host);
2783
+ if (unknownProviderHosts.length > 0) {
2784
+ throw new Error(formatUnknownProviderError(unknownProviderHosts));
2785
+ }
2786
+ if (hostsToFetch.length > 0) {
2787
+ const credResult = await collectGitCredentials(targetDir, hostsToFetch, {
2357
2788
  ...opts.credentialsSpawn ? { spawn: opts.credentialsSpawn } : {},
2358
2789
  logger: idLogger
2359
2790
  });
2791
+ const missing = credResult.perHost.filter((p) => p.status !== "ok");
2792
+ if (missing.length > 0) {
2793
+ throw new Error(formatMissingCredentialsError(missing));
2794
+ }
2795
+ }
2796
+ const declaredRepos = createOpts.repos ?? [];
2797
+ if (declaredRepos.length > 0) {
2798
+ const reachability = await checkRepoReachability(declaredRepos, {
2799
+ ...opts.reachabilitySpawn ? { spawn: opts.reachabilitySpawn } : {}
2800
+ });
2801
+ const unreachable = reachability.filter((r) => !r.ok);
2802
+ if (unreachable.length > 0) {
2803
+ throw new Error(formatUnreachableReposError(unreachable));
2804
+ }
2360
2805
  }
2361
2806
  section("Scaffold");
2362
2807
  await fs8.mkdir(targetDir, { recursive: true });
@@ -2686,6 +3131,7 @@ import { consola as consola13 } from "consola";
2686
3131
  // src/init/index.ts
2687
3132
  import { existsSync as existsSync7, promises as fs10 } from "fs";
2688
3133
  import { consola as consola12 } from "consola";
3134
+ import { parseDocument as parseDocument3 } from "yaml";
2689
3135
 
2690
3136
  // src/init/components.ts
2691
3137
  import { existsSync as existsSync5, promises as fs9 } from "fs";
@@ -3183,6 +3629,37 @@ async function runInit(opts) {
3183
3629
  const components = resolveComponents(catalog, requested);
3184
3630
  text = generateComposedYml(opts.name, components, lookup);
3185
3631
  }
3632
+ const repos = (opts.withRepo ?? []).map((u) => u.trim()).filter((u) => u.length > 0);
3633
+ if (repos.length > 0) {
3634
+ const offending = [];
3635
+ for (const url of repos) {
3636
+ let host;
3637
+ try {
3638
+ host = url.startsWith("https://") ? new URL(url).hostname : void 0;
3639
+ } catch {
3640
+ host = void 0;
3641
+ }
3642
+ if (!host || !KNOWN_PROVIDER_HOSTS[host.toLowerCase()]) {
3643
+ offending.push(url);
3644
+ }
3645
+ }
3646
+ if (offending.length > 0) {
3647
+ throw new Error(
3648
+ [
3649
+ `--with-repo only supports github.com / gitlab.com / bitbucket.org URLs.`,
3650
+ `These are not canonical: ${offending.join(", ")}`,
3651
+ `For other hosts, run \`monoceros init <name>\` first, then`,
3652
+ `\`monoceros add-repo <name> <url> --provider=github|gitlab|bitbucket\`.`
3653
+ ].join("\n")
3654
+ );
3655
+ }
3656
+ const doc = parseDocument3(text);
3657
+ for (const url of repos) {
3658
+ const path13 = deriveRepoName(url);
3659
+ addRepoToDoc(doc, { url, path: path13 });
3660
+ }
3661
+ text = String(doc);
3662
+ }
3186
3663
  await fs10.mkdir(containerConfigsDir(home), { recursive: true });
3187
3664
  await fs10.writeFile(dest, text, "utf8");
3188
3665
  const documented = requested.length === 0;
@@ -3219,14 +3696,21 @@ var initCommand = defineCommand9({
3219
3696
  type: "string",
3220
3697
  description: "Comma-separated list of component names to compose, e.g. 'node,postgres,github,claude'. Sub-components use a slash, e.g. 'atlassian/twg'. When omitted, init writes a documented default with every catalog component commented out.",
3221
3698
  required: false
3699
+ },
3700
+ "with-repo": {
3701
+ type: "string",
3702
+ description: "Git URL of a repo to clone into projects/ on first apply. Repeatable: pass --with-repo=URL1 --with-repo=URL2 for multiple repos. Folder name derived from URL (foo.git \u2192 projects/foo/); use `monoceros add-repo --path=...` post-init for subfolder paths.",
3703
+ required: false
3222
3704
  }
3223
3705
  },
3224
3706
  async run({ args, rawArgs }) {
3225
3707
  try {
3226
3708
  const withList = collectWithList(args.with, rawArgs);
3709
+ const withRepoList = collectWithRepoList(rawArgs);
3227
3710
  await runInit({
3228
3711
  name: args.name,
3229
- ...withList ? { with: withList } : {}
3712
+ ...withList ? { with: withList } : {},
3713
+ ...withRepoList.length > 0 ? { withRepo: withRepoList } : {}
3230
3714
  });
3231
3715
  } catch (err) {
3232
3716
  consola13.error(err instanceof Error ? err.message : String(err));
@@ -3234,6 +3718,22 @@ var initCommand = defineCommand9({
3234
3718
  }
3235
3719
  }
3236
3720
  });
3721
+ function collectWithRepoList(rawArgs) {
3722
+ const urls = [];
3723
+ for (let i = 0; i < rawArgs.length; i += 1) {
3724
+ const t = rawArgs[i];
3725
+ if (t === "--with-repo") {
3726
+ const next = rawArgs[i + 1];
3727
+ if (typeof next === "string" && !next.startsWith("-")) {
3728
+ urls.push(next);
3729
+ i += 1;
3730
+ }
3731
+ } else if (t.startsWith("--with-repo=")) {
3732
+ urls.push(t.slice("--with-repo=".length));
3733
+ }
3734
+ }
3735
+ return urls;
3736
+ }
3237
3737
  function collectWithList(withArg, rawArgs) {
3238
3738
  if (typeof withArg !== "string" || withArg.trim().length === 0) {
3239
3739
  return void 0;
@@ -3881,14 +4381,22 @@ async function runShell(opts) {
3881
4381
  assertContainerExists(opts.root);
3882
4382
  const spawnFn = opts.spawn ?? spawnDevcontainer;
3883
4383
  const upCode = await spawnFn(
3884
- ["up", "--workspace-folder", opts.root],
4384
+ ["up", "--workspace-folder", opts.root, "--mount-workspace-git-root=false"],
3885
4385
  opts.root,
3886
4386
  { quiet: true }
3887
4387
  );
3888
4388
  if (upCode !== 0) return upCode;
3889
- return spawnFn(["exec", "--workspace-folder", opts.root, "bash"], opts.root, {
3890
- interactive: true
3891
- });
4389
+ return spawnFn(
4390
+ [
4391
+ "exec",
4392
+ "--workspace-folder",
4393
+ opts.root,
4394
+ "--mount-workspace-git-root=false",
4395
+ "bash"
4396
+ ],
4397
+ opts.root,
4398
+ { interactive: true }
4399
+ );
3892
4400
  }
3893
4401
  function assertContainerExists(root) {
3894
4402
  if (!existsSync10(path12.join(root, ".devcontainer"))) {
@@ -3908,13 +4416,19 @@ async function runInContainer(opts) {
3908
4416
  assertContainerExists(opts.root);
3909
4417
  const spawnFn = opts.spawn ?? spawnDevcontainer;
3910
4418
  const upCode = await spawnFn(
3911
- ["up", "--workspace-folder", opts.root],
4419
+ ["up", "--workspace-folder", opts.root, "--mount-workspace-git-root=false"],
3912
4420
  opts.root,
3913
4421
  { quiet: true }
3914
4422
  );
3915
4423
  if (upCode !== 0) return upCode;
3916
4424
  return spawnFn(
3917
- ["exec", "--workspace-folder", opts.root, ...opts.command],
4425
+ [
4426
+ "exec",
4427
+ "--workspace-folder",
4428
+ opts.root,
4429
+ "--mount-workspace-git-root=false",
4430
+ ...opts.command
4431
+ ],
3918
4432
  opts.root,
3919
4433
  { interactive: true }
3920
4434
  );