@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 +648 -134
- package/dist/bin.js.map +1 -1
- package/package.json +1 -1
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
310
|
-
|
|
311
|
-
"Invalid repo
|
|
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
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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
|
|
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
|
|
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 (!
|
|
655
|
+
if (!REPO_PATH_RE2.test(repo.path)) {
|
|
632
656
|
throw new Error(
|
|
633
|
-
`Invalid repo
|
|
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.
|
|
660
|
+
if (repo.path.split("/").some((seg) => seg === ".." || seg === ".")) {
|
|
637
661
|
throw new Error(
|
|
638
|
-
`Invalid
|
|
662
|
+
`Invalid repo path: ${JSON.stringify(repo.path)}. Path segments cannot be "." or "..".`
|
|
639
663
|
);
|
|
640
664
|
}
|
|
641
|
-
if (
|
|
665
|
+
if (seenRepoPaths.has(repo.path)) {
|
|
642
666
|
throw new Error(
|
|
643
|
-
`Duplicate repo
|
|
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
|
-
|
|
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.
|
|
885
|
+
(a, b) => a.path.localeCompare(b.path)
|
|
891
886
|
);
|
|
892
887
|
for (const repo of sortedRepos) {
|
|
893
|
-
|
|
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/<
|
|
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
|
|
967
|
-
|
|
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.
|
|
970
|
-
` echo "\u2192 Cloning ${repo.
|
|
971
|
-
` git clone
|
|
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.
|
|
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
|
|
1132
|
-
const
|
|
1133
|
-
|
|
1134
|
-
|
|
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.
|
|
1141
|
-
entry2.set("
|
|
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.
|
|
1144
|
-
entry2.set("
|
|
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,
|
|
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 ===
|
|
1184
|
-
const
|
|
1185
|
-
const
|
|
1186
|
-
return
|
|
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
|
|
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
|
-
|
|
1239
|
-
...
|
|
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/<
|
|
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
|
-
|
|
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: "
|
|
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
|
-
|
|
1658
|
+
"git-email": {
|
|
1572
1659
|
type: "string",
|
|
1573
|
-
description: "
|
|
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.
|
|
1588
|
-
...typeof args
|
|
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
|
-
// `
|
|
1808
|
-
//
|
|
1809
|
-
// `
|
|
1810
|
-
|
|
1811
|
-
|
|
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(
|
|
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
|
-
[
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
2153
|
-
|
|
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
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
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
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
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/
|
|
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 =
|
|
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
|
-
|
|
2356
|
-
|
|
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(
|
|
3890
|
-
|
|
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
|
-
[
|
|
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
|
);
|