@getmonoceros/workbench 1.12.0 → 1.13.1
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 +1260 -515
- package/dist/bin.js.map +1 -1
- package/package.json +1 -1
package/dist/bin.js
CHANGED
|
@@ -251,25 +251,25 @@ function detectHelpRequest(argv, main2) {
|
|
|
251
251
|
const separatorIdx = argv.indexOf("--");
|
|
252
252
|
if (helpIdx === -1) return null;
|
|
253
253
|
if (separatorIdx !== -1 && separatorIdx < helpIdx) return null;
|
|
254
|
-
const
|
|
254
|
+
const path21 = [];
|
|
255
255
|
const tokens = argv.slice(
|
|
256
256
|
0,
|
|
257
257
|
separatorIdx === -1 ? argv.length : separatorIdx
|
|
258
258
|
);
|
|
259
259
|
let cursor = main2;
|
|
260
260
|
const mainName = (main2.meta ?? {}).name ?? "monoceros";
|
|
261
|
-
|
|
261
|
+
path21.push(mainName);
|
|
262
262
|
for (const tok of tokens) {
|
|
263
263
|
if (tok.startsWith("-")) continue;
|
|
264
264
|
const subs = cursor.subCommands ?? {};
|
|
265
265
|
if (tok in subs) {
|
|
266
266
|
cursor = subs[tok];
|
|
267
|
-
|
|
267
|
+
path21.push(tok);
|
|
268
268
|
continue;
|
|
269
269
|
}
|
|
270
270
|
break;
|
|
271
271
|
}
|
|
272
|
-
return { path:
|
|
272
|
+
return { path: path21, cmd: cursor };
|
|
273
273
|
}
|
|
274
274
|
async function maybeRenderHelp(argv, main2) {
|
|
275
275
|
const hit = detectHelpRequest(argv, main2);
|
|
@@ -311,7 +311,7 @@ import { consola as consola2 } from "consola";
|
|
|
311
311
|
import { promises as fs8 } from "fs";
|
|
312
312
|
import { consola } from "consola";
|
|
313
313
|
import { createPatch } from "diff";
|
|
314
|
-
import
|
|
314
|
+
import path9 from "path";
|
|
315
315
|
|
|
316
316
|
// src/config/io.ts
|
|
317
317
|
import { promises as fs } from "fs";
|
|
@@ -404,6 +404,68 @@ var RoutingSchema = z.object({
|
|
|
404
404
|
ports: z.array(PortEntrySchema).default([]),
|
|
405
405
|
vscodeAutoForward: z.boolean().optional()
|
|
406
406
|
});
|
|
407
|
+
var SERVICE_NAME_RE = /^[a-z0-9][a-z0-9_-]*$/;
|
|
408
|
+
var ServiceEnvValueSchema = z.union([z.string(), z.number(), z.boolean(), z.null()]).transform((v) => v === null ? "" : String(v));
|
|
409
|
+
var ServiceHealthcheckSchema = z.object({
|
|
410
|
+
// Compose accepts both forms and they differ semantically:
|
|
411
|
+
// - string → run via the shell (CMD-SHELL)
|
|
412
|
+
// - ["CMD", …] → exec the args directly, no shell
|
|
413
|
+
// - ["CMD-SHELL", …]
|
|
414
|
+
// We accept either and render it back faithfully.
|
|
415
|
+
test: z.union([
|
|
416
|
+
z.string().min(1, "Healthcheck test must not be empty."),
|
|
417
|
+
z.array(z.string().min(1)).min(1, "Healthcheck test array must not be empty.")
|
|
418
|
+
]),
|
|
419
|
+
interval: z.string().optional(),
|
|
420
|
+
timeout: z.string().optional(),
|
|
421
|
+
retries: z.number().int().min(1).optional(),
|
|
422
|
+
startPeriod: z.string().optional()
|
|
423
|
+
});
|
|
424
|
+
var SERVICE_RESTART_VALUES = [
|
|
425
|
+
"no",
|
|
426
|
+
"always",
|
|
427
|
+
"on-failure",
|
|
428
|
+
"unless-stopped"
|
|
429
|
+
];
|
|
430
|
+
function isValidServiceVolume(spec) {
|
|
431
|
+
const parts = spec.split(":");
|
|
432
|
+
if (parts.length < 2 || parts.length > 3) return false;
|
|
433
|
+
const [src, dest, mode] = parts;
|
|
434
|
+
if (!src || !dest) return false;
|
|
435
|
+
if (!dest.startsWith("/")) return false;
|
|
436
|
+
if (mode !== void 0 && !/^(ro|rw|cached|delegated|z|Z)$/.test(mode)) {
|
|
437
|
+
return false;
|
|
438
|
+
}
|
|
439
|
+
if (src === "data") return true;
|
|
440
|
+
if (src.startsWith("/")) return false;
|
|
441
|
+
const looksLikePath = src.startsWith("./") || src.includes("/");
|
|
442
|
+
if (!looksLikePath) return false;
|
|
443
|
+
const normalized = src.startsWith("./") ? src.slice(2) : src;
|
|
444
|
+
if (normalized.split("/").some((s) => s === ".." || s === ".")) return false;
|
|
445
|
+
return true;
|
|
446
|
+
}
|
|
447
|
+
var ServiceObjectSchema = z.object({
|
|
448
|
+
name: z.string().regex(
|
|
449
|
+
SERVICE_NAME_RE,
|
|
450
|
+
"Invalid service name. Use lowercase letters, digits, '_' or '-' (must start with a letter or digit)."
|
|
451
|
+
),
|
|
452
|
+
image: z.string().min(1, "Service image must not be empty."),
|
|
453
|
+
// In-container port the service listens on. Used by
|
|
454
|
+
// `monoceros tunnel <name> <service>` to forward without an explicit
|
|
455
|
+
// port argument. NOT a host port mapping — host exposure goes through
|
|
456
|
+
// routing.ports (Traefik) or `monoceros tunnel`.
|
|
457
|
+
port: z.number().int().min(1, "Port must be \u2265 1.").max(65535).optional(),
|
|
458
|
+
env: z.record(z.string(), ServiceEnvValueSchema).optional(),
|
|
459
|
+
volumes: z.array(
|
|
460
|
+
z.string().refine(
|
|
461
|
+
isValidServiceVolume,
|
|
462
|
+
"Invalid volume. Use 'data:/container/path' for the per-service persistent dir, or a relative host path ('projects/app/init.sql:/...:ro', './config:/...'). Docker named volumes (a bare name like 'rustfs_data') are not supported; absolute host paths and '..' are rejected."
|
|
463
|
+
)
|
|
464
|
+
).optional(),
|
|
465
|
+
healthcheck: ServiceHealthcheckSchema.optional(),
|
|
466
|
+
restart: z.enum(SERVICE_RESTART_VALUES).optional(),
|
|
467
|
+
command: z.string().optional()
|
|
468
|
+
});
|
|
407
469
|
var ExternalServicesSchema = z.object({
|
|
408
470
|
postgres: z.string().regex(
|
|
409
471
|
POSTGRES_URL_RE,
|
|
@@ -430,7 +492,7 @@ var SolutionConfigSchema = z.object({
|
|
|
430
492
|
"Invalid install URL. Must start with 'https://' and contain only URL-safe characters (no shell metacharacters)."
|
|
431
493
|
)
|
|
432
494
|
).default([]),
|
|
433
|
-
services: z.array(
|
|
495
|
+
services: z.array(ServiceObjectSchema).default([]),
|
|
434
496
|
repos: z.array(RepoEntrySchema).default([]),
|
|
435
497
|
routing: RoutingSchema.optional(),
|
|
436
498
|
externalServices: ExternalServicesSchema.default({}),
|
|
@@ -549,6 +611,9 @@ function containerConfigsDir(home = monocerosHome()) {
|
|
|
549
611
|
function containerConfigPath(name, home = monocerosHome()) {
|
|
550
612
|
return path.join(containerConfigsDir(home), `${name}.yml`);
|
|
551
613
|
}
|
|
614
|
+
function containerEnvPath(name, home = monocerosHome()) {
|
|
615
|
+
return path.join(containerConfigsDir(home), `${name}.env`);
|
|
616
|
+
}
|
|
552
617
|
function containersDir(home = monocerosHome()) {
|
|
553
618
|
return path.join(home, "container");
|
|
554
619
|
}
|
|
@@ -569,10 +634,330 @@ function prettyPath(p) {
|
|
|
569
634
|
return p;
|
|
570
635
|
}
|
|
571
636
|
|
|
637
|
+
// src/config/env-file.ts
|
|
638
|
+
import { existsSync as existsSync2, readFileSync, promises as fsp } from "fs";
|
|
639
|
+
import path2 from "path";
|
|
640
|
+
var ENV_LINE_RE = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=(.*)$/;
|
|
641
|
+
function parseEnvFile(content) {
|
|
642
|
+
const out = {};
|
|
643
|
+
for (const raw of content.split(/\r?\n/)) {
|
|
644
|
+
const trimmed = raw.trim();
|
|
645
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
646
|
+
const m = ENV_LINE_RE.exec(raw);
|
|
647
|
+
if (!m) continue;
|
|
648
|
+
const key = m[1];
|
|
649
|
+
let val = m[2].trim();
|
|
650
|
+
if (val.length >= 2 && (val.startsWith('"') && val.endsWith('"') || val.startsWith("'") && val.endsWith("'"))) {
|
|
651
|
+
val = val.slice(1, -1);
|
|
652
|
+
}
|
|
653
|
+
out[key] = val;
|
|
654
|
+
}
|
|
655
|
+
return out;
|
|
656
|
+
}
|
|
657
|
+
function readEnvFile(envPath) {
|
|
658
|
+
if (!existsSync2(envPath)) return {};
|
|
659
|
+
return parseEnvFile(readFileSync(envPath, "utf8"));
|
|
660
|
+
}
|
|
661
|
+
async function ensureEnvGitignored(configsDir) {
|
|
662
|
+
const gitignorePath = path2.join(configsDir, ".gitignore");
|
|
663
|
+
const pattern = "*.env";
|
|
664
|
+
let existing = "";
|
|
665
|
+
if (existsSync2(gitignorePath)) {
|
|
666
|
+
existing = readFileSync(gitignorePath, "utf8");
|
|
667
|
+
const lines = existing.split(/\r?\n/).map((l) => l.trim());
|
|
668
|
+
if (lines.includes(pattern)) return;
|
|
669
|
+
}
|
|
670
|
+
const prefix = existing.length > 0 && !existing.endsWith("\n") ? "\n" : "";
|
|
671
|
+
const header = existing.length === 0 ? "# Per-container env files hold the secrets behind the yml ${VAR}\n# references. Never commit them.\n" : "";
|
|
672
|
+
await fsp.appendFile(gitignorePath, `${prefix}${header}${pattern}
|
|
673
|
+
`);
|
|
674
|
+
}
|
|
675
|
+
var VAR_RE = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
|
|
676
|
+
function interpolate(value, vars) {
|
|
677
|
+
const missing = [];
|
|
678
|
+
const out = value.replace(VAR_RE, (_match, name) => {
|
|
679
|
+
if (Object.prototype.hasOwnProperty.call(vars, name)) return vars[name];
|
|
680
|
+
missing.push(name);
|
|
681
|
+
return _match;
|
|
682
|
+
});
|
|
683
|
+
return { value: out, missing };
|
|
684
|
+
}
|
|
685
|
+
function interpolateServices(services, vars) {
|
|
686
|
+
const missing = [];
|
|
687
|
+
const resolved = services.map((svc) => {
|
|
688
|
+
const interp = (raw, field) => {
|
|
689
|
+
const r = interpolate(raw, vars);
|
|
690
|
+
for (const name of r.missing) {
|
|
691
|
+
missing.push({ location: `services.${svc.name}.${field}`, name });
|
|
692
|
+
}
|
|
693
|
+
return r.value;
|
|
694
|
+
};
|
|
695
|
+
const next = {
|
|
696
|
+
...svc,
|
|
697
|
+
image: interp(svc.image, "image"),
|
|
698
|
+
env: Object.fromEntries(
|
|
699
|
+
Object.entries(svc.env).map(([k, v]) => [k, interp(v, `env.${k}`)])
|
|
700
|
+
),
|
|
701
|
+
volumes: svc.volumes.map((v, i) => interp(v, `volumes[${i}]`))
|
|
702
|
+
};
|
|
703
|
+
if (svc.command !== void 0) {
|
|
704
|
+
next.command = interp(svc.command, "command");
|
|
705
|
+
}
|
|
706
|
+
if (svc.healthcheck) {
|
|
707
|
+
const hc = svc.healthcheck;
|
|
708
|
+
next.healthcheck = {
|
|
709
|
+
...hc,
|
|
710
|
+
test: Array.isArray(hc.test) ? hc.test.map((t, i) => interp(t, `healthcheck.test[${i}]`)) : interp(hc.test, "healthcheck.test"),
|
|
711
|
+
...hc.interval !== void 0 ? { interval: interp(hc.interval, "healthcheck.interval") } : {},
|
|
712
|
+
...hc.timeout !== void 0 ? { timeout: interp(hc.timeout, "healthcheck.timeout") } : {},
|
|
713
|
+
...hc.startPeriod !== void 0 ? { startPeriod: interp(hc.startPeriod, "healthcheck.startPeriod") } : {}
|
|
714
|
+
};
|
|
715
|
+
}
|
|
716
|
+
return next;
|
|
717
|
+
});
|
|
718
|
+
return { services: resolved, missing };
|
|
719
|
+
}
|
|
720
|
+
function interpolateFeatures(features, vars) {
|
|
721
|
+
const missing = [];
|
|
722
|
+
const out = {};
|
|
723
|
+
for (const [ref, options] of Object.entries(features)) {
|
|
724
|
+
const next = {};
|
|
725
|
+
for (const [key, value] of Object.entries(options)) {
|
|
726
|
+
if (typeof value !== "string") {
|
|
727
|
+
next[key] = value;
|
|
728
|
+
continue;
|
|
729
|
+
}
|
|
730
|
+
const r = interpolate(value, vars);
|
|
731
|
+
for (const name of r.missing) {
|
|
732
|
+
missing.push({ location: `features.${ref}.${key}`, name });
|
|
733
|
+
}
|
|
734
|
+
next[key] = r.value;
|
|
735
|
+
}
|
|
736
|
+
out[ref] = next;
|
|
737
|
+
}
|
|
738
|
+
return { features: out, missing };
|
|
739
|
+
}
|
|
740
|
+
function buildEnvStub(name) {
|
|
741
|
+
return `# Secrets and values for \${VAR} references in ${name}.yml.
|
|
742
|
+
`;
|
|
743
|
+
}
|
|
744
|
+
async function ensureEnvVars(envPath, name, vars) {
|
|
745
|
+
const entries = Array.isArray(vars) ? vars.map((v) => [v, ""]) : Object.entries(vars);
|
|
746
|
+
const exists = existsSync2(envPath);
|
|
747
|
+
let content = exists ? readFileSync(envPath, "utf8") : buildEnvStub(name);
|
|
748
|
+
const present = new Set(Object.keys(parseEnvFile(content)));
|
|
749
|
+
const seen = /* @__PURE__ */ new Set();
|
|
750
|
+
const toAdd = entries.filter(([k]) => {
|
|
751
|
+
if (present.has(k) || seen.has(k)) return false;
|
|
752
|
+
seen.add(k);
|
|
753
|
+
return true;
|
|
754
|
+
});
|
|
755
|
+
const added = toAdd.map(([k]) => k);
|
|
756
|
+
if (!exists || added.length > 0) {
|
|
757
|
+
if (content.length > 0 && !content.endsWith("\n")) content += "\n";
|
|
758
|
+
for (const [k, v] of toAdd) content += `${k}=${v}
|
|
759
|
+
`;
|
|
760
|
+
await fsp.mkdir(path2.dirname(envPath), { recursive: true });
|
|
761
|
+
await fsp.writeFile(envPath, content);
|
|
762
|
+
}
|
|
763
|
+
return { created: !exists, added };
|
|
764
|
+
}
|
|
765
|
+
function formatMissingVarsError(missing, envPathPretty) {
|
|
766
|
+
const lines = missing.map((m) => ` - \${${m.name}} (${m.location})`);
|
|
767
|
+
const uniqueNames = [...new Set(missing.map((m) => m.name))];
|
|
768
|
+
return `Unresolved \${VAR} references in the container yml:
|
|
769
|
+
${lines.join("\n")}
|
|
770
|
+
|
|
771
|
+
Define them in ${envPathPretty}, e.g.
|
|
772
|
+
` + uniqueNames.map((n) => ` ${n}=<value>`).join("\n");
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
// src/init/feature-doc.ts
|
|
776
|
+
function buildFeatureHeaderLines(summary, width) {
|
|
777
|
+
const paragraphs = buildHeaderParagraphs(summary);
|
|
778
|
+
const wrapped = [];
|
|
779
|
+
for (const para of paragraphs) {
|
|
780
|
+
for (const line of wrapToComment(para, width)) {
|
|
781
|
+
wrapped.push(line);
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
return wrapped;
|
|
785
|
+
}
|
|
786
|
+
function buildFeatureHeaderCommentBefore(summary, width) {
|
|
787
|
+
const lines = buildFeatureHeaderLines(summary, width);
|
|
788
|
+
return lines.map((l) => ` ${l}`).join("\n");
|
|
789
|
+
}
|
|
790
|
+
function buildHeaderParagraphs(summary) {
|
|
791
|
+
if (!summary) return [];
|
|
792
|
+
const out = [];
|
|
793
|
+
const tagline = summary.name?.trim();
|
|
794
|
+
const description = summary.description?.trim();
|
|
795
|
+
if (tagline && description) {
|
|
796
|
+
out.push(`${tagline} \u2014 ${description}`);
|
|
797
|
+
} else if (tagline) {
|
|
798
|
+
out.push(tagline);
|
|
799
|
+
} else if (description) {
|
|
800
|
+
out.push(description);
|
|
801
|
+
}
|
|
802
|
+
for (const note of summary.usageNotes) {
|
|
803
|
+
const trimmed = note.trim();
|
|
804
|
+
if (trimmed.length > 0) out.push(trimmed);
|
|
805
|
+
}
|
|
806
|
+
if (summary.optionHints.length > 0) {
|
|
807
|
+
const parts = summary.optionHints.map((key) => {
|
|
808
|
+
const desc = summary.optionDescriptions[key];
|
|
809
|
+
const short = desc ? shortenOptionDescription(desc) : void 0;
|
|
810
|
+
return short ? `${key} (${short})` : key;
|
|
811
|
+
});
|
|
812
|
+
out.push(`Options: ${parts.join(", ")}.`);
|
|
813
|
+
}
|
|
814
|
+
if (summary.documentationURL) {
|
|
815
|
+
out.push(`See ${summary.documentationURL} for further information.`);
|
|
816
|
+
}
|
|
817
|
+
return out;
|
|
818
|
+
}
|
|
819
|
+
function shortenOptionDescription(desc) {
|
|
820
|
+
const firstSentence = desc.split(/(?<=[.!?])\s+/)[0]?.trim() ?? desc.trim();
|
|
821
|
+
return firstSentence.replace(/[.!?]+$/, "").trim();
|
|
822
|
+
}
|
|
823
|
+
function wrapToComment(text, width) {
|
|
824
|
+
const words = text.split(/\s+/).filter((w) => w.length > 0);
|
|
825
|
+
if (words.length === 0) return [""];
|
|
826
|
+
const usable = Math.max(width, 20);
|
|
827
|
+
const lines = [];
|
|
828
|
+
let current = "";
|
|
829
|
+
for (const w of words) {
|
|
830
|
+
if (current.length === 0) {
|
|
831
|
+
current = w;
|
|
832
|
+
continue;
|
|
833
|
+
}
|
|
834
|
+
if (current.length + 1 + w.length <= usable) {
|
|
835
|
+
current += " " + w;
|
|
836
|
+
} else {
|
|
837
|
+
lines.push(current);
|
|
838
|
+
current = w;
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
if (current.length > 0) lines.push(current);
|
|
842
|
+
return lines;
|
|
843
|
+
}
|
|
844
|
+
var FEATURE_HEADER_WIDTH = 76 - 2;
|
|
845
|
+
function featureOptionVarName(ref, optionKey) {
|
|
846
|
+
const leaf = ref.split("/").pop() ?? ref;
|
|
847
|
+
const id = leaf.split("@")[0].split(":")[0];
|
|
848
|
+
const idSnake = id.replace(/[^A-Za-z0-9]+/g, "_").toUpperCase();
|
|
849
|
+
const optSnake = optionKey.replace(/([a-z0-9])([A-Z])/g, "$1_$2").replace(/[^A-Za-z0-9]+/g, "_").toUpperCase();
|
|
850
|
+
return `${idSnake}_${optSnake}`;
|
|
851
|
+
}
|
|
852
|
+
function featureOptionHints(summary, ref, activeKeys = []) {
|
|
853
|
+
return (summary?.optionHints ?? []).filter((key) => !activeKeys.includes(key)).map((key) => {
|
|
854
|
+
const envVar = featureOptionVarName(ref, key);
|
|
855
|
+
return { key, envVar, placeholder: `\${${envVar}}` };
|
|
856
|
+
});
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
// src/init/manifest.ts
|
|
860
|
+
import { existsSync as existsSync3, readFileSync as readFileSync2 } from "fs";
|
|
861
|
+
import path3 from "path";
|
|
862
|
+
|
|
863
|
+
// src/util/ref.ts
|
|
864
|
+
var FEATURE_NAME_CHARSET = "[a-z0-9._-]+";
|
|
865
|
+
var FEATURE_TAG_CHARSET = "[a-z0-9._-]+";
|
|
866
|
+
var MONOCEROS_FEATURE_RE = new RegExp(
|
|
867
|
+
`^ghcr\\.io/getmonoceros/monoceros-features/(${FEATURE_NAME_CHARSET}):${FEATURE_TAG_CHARSET}$`
|
|
868
|
+
);
|
|
869
|
+
var DEPRECATED_MONOCEROS_FEATURE_RE = new RegExp(
|
|
870
|
+
`^ghcr\\.io/monoceros/features/(${FEATURE_NAME_CHARSET}):(${FEATURE_TAG_CHARSET})$`
|
|
871
|
+
);
|
|
872
|
+
function matchMonocerosFeature(ref) {
|
|
873
|
+
const match = MONOCEROS_FEATURE_RE.exec(ref);
|
|
874
|
+
if (!match) return null;
|
|
875
|
+
return { name: match[1] };
|
|
876
|
+
}
|
|
877
|
+
function migrateDeprecatedFeatureRef(ref) {
|
|
878
|
+
const match = DEPRECATED_MONOCEROS_FEATURE_RE.exec(ref);
|
|
879
|
+
if (!match) return null;
|
|
880
|
+
const name = match[1];
|
|
881
|
+
const tag = match[2];
|
|
882
|
+
return `ghcr.io/getmonoceros/monoceros-features/${name}:${tag}`;
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
// src/init/manifest.ts
|
|
886
|
+
function resolveManifestPath(name, checkoutRoot) {
|
|
887
|
+
if (checkoutRoot) {
|
|
888
|
+
const checkoutPath = path3.join(
|
|
889
|
+
checkoutRoot,
|
|
890
|
+
"images",
|
|
891
|
+
"features",
|
|
892
|
+
name,
|
|
893
|
+
"devcontainer-feature.json"
|
|
894
|
+
);
|
|
895
|
+
if (existsSync3(checkoutPath)) return checkoutPath;
|
|
896
|
+
}
|
|
897
|
+
const bundlePath = path3.join(
|
|
898
|
+
bundledFeaturesDir(),
|
|
899
|
+
name,
|
|
900
|
+
"devcontainer-feature.json"
|
|
901
|
+
);
|
|
902
|
+
if (existsSync3(bundlePath)) return bundlePath;
|
|
903
|
+
return null;
|
|
904
|
+
}
|
|
905
|
+
function loadFeatureManifestSummary(ref, checkoutRoot = workbenchCheckoutRoot()) {
|
|
906
|
+
const match = matchMonocerosFeature(ref);
|
|
907
|
+
if (!match) return void 0;
|
|
908
|
+
const manifestPath = resolveManifestPath(match.name, checkoutRoot);
|
|
909
|
+
if (!manifestPath) return void 0;
|
|
910
|
+
try {
|
|
911
|
+
const text = readFileSync2(manifestPath, "utf8");
|
|
912
|
+
const parsed = JSON.parse(text);
|
|
913
|
+
const rawHints = parsed["x-monoceros"]?.optionHints;
|
|
914
|
+
const optionHints = Array.isArray(rawHints) ? rawHints.filter(
|
|
915
|
+
(x) => typeof x === "string" && x.length > 0
|
|
916
|
+
) : [];
|
|
917
|
+
const rawNotes = parsed["x-monoceros"]?.usageNotes;
|
|
918
|
+
const usageNotes = Array.isArray(rawNotes) ? rawNotes.filter(
|
|
919
|
+
(x) => typeof x === "string" && x.length > 0
|
|
920
|
+
) : [];
|
|
921
|
+
const optionDescriptions = {};
|
|
922
|
+
const optionTypes = {};
|
|
923
|
+
const optionNames = [];
|
|
924
|
+
if (parsed.options) {
|
|
925
|
+
for (const [key, opt] of Object.entries(parsed.options)) {
|
|
926
|
+
if (!opt || typeof opt !== "object") continue;
|
|
927
|
+
optionNames.push(key);
|
|
928
|
+
if (typeof opt.description === "string" && opt.description.length > 0) {
|
|
929
|
+
optionDescriptions[key] = opt.description;
|
|
930
|
+
}
|
|
931
|
+
if (opt.type === "boolean") {
|
|
932
|
+
optionTypes[key] = "boolean";
|
|
933
|
+
} else if (opt.type === "string") {
|
|
934
|
+
optionTypes[key] = "string";
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
const name = typeof parsed.name === "string" ? parsed.name : "";
|
|
939
|
+
const description = typeof parsed.description === "string" ? parsed.description : "";
|
|
940
|
+
const rawUrl = typeof parsed.documentationURL === "string" ? parsed.documentationURL.trim() : "";
|
|
941
|
+
const documentationURL = rawUrl.length > 0 && rawUrl.toLowerCase() !== "tbd" ? rawUrl : void 0;
|
|
942
|
+
return {
|
|
943
|
+
name,
|
|
944
|
+
description,
|
|
945
|
+
documentationURL,
|
|
946
|
+
optionHints,
|
|
947
|
+
optionDescriptions,
|
|
948
|
+
optionNames,
|
|
949
|
+
optionTypes,
|
|
950
|
+
usageNotes
|
|
951
|
+
};
|
|
952
|
+
} catch {
|
|
953
|
+
return void 0;
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
|
|
572
957
|
// src/devcontainer/credentials.ts
|
|
573
958
|
import { spawn } from "child_process";
|
|
574
959
|
import { promises as fs2 } from "fs";
|
|
575
|
-
import
|
|
960
|
+
import path4 from "path";
|
|
576
961
|
|
|
577
962
|
// src/util/format.ts
|
|
578
963
|
var ESC = "\x1B[";
|
|
@@ -788,8 +1173,8 @@ function formatCredentialLine(host, username, password) {
|
|
|
788
1173
|
return `https://${encUser}:${encPass}@${host}`;
|
|
789
1174
|
}
|
|
790
1175
|
async function collectGitCredentials(devContainerRoot, hosts, options = {}) {
|
|
791
|
-
const credsDir =
|
|
792
|
-
const credentialsPath =
|
|
1176
|
+
const credsDir = path4.join(devContainerRoot, ".monoceros");
|
|
1177
|
+
const credentialsPath = path4.join(credsDir, "git-credentials");
|
|
793
1178
|
const spawnFn = options.spawn ?? realGitCredentialFill;
|
|
794
1179
|
const approveFn = options.approve ?? realGitCredentialApprove;
|
|
795
1180
|
const logger = options.logger ?? { info: () => {
|
|
@@ -1186,8 +1571,8 @@ ${existing}` : leakedComment;
|
|
|
1186
1571
|
}
|
|
1187
1572
|
|
|
1188
1573
|
// src/init/components.ts
|
|
1189
|
-
import { existsSync as
|
|
1190
|
-
import
|
|
1574
|
+
import { existsSync as existsSync4, promises as fs4 } from "fs";
|
|
1575
|
+
import path5 from "path";
|
|
1191
1576
|
import { z as z3 } from "zod";
|
|
1192
1577
|
import { parse as parseYaml } from "yaml";
|
|
1193
1578
|
var CategorySchema = z3.enum(["language", "service", "feature"]);
|
|
@@ -1234,7 +1619,7 @@ var ComponentFileSchema = z3.object({
|
|
|
1234
1619
|
}
|
|
1235
1620
|
});
|
|
1236
1621
|
async function loadComponentCatalog(rootDir = componentsDir()) {
|
|
1237
|
-
if (!
|
|
1622
|
+
if (!existsSync4(rootDir)) {
|
|
1238
1623
|
return /* @__PURE__ */ new Map();
|
|
1239
1624
|
}
|
|
1240
1625
|
const out = /* @__PURE__ */ new Map();
|
|
@@ -1244,14 +1629,14 @@ async function loadComponentCatalog(rootDir = componentsDir()) {
|
|
|
1244
1629
|
async function walk(baseDir, currentDir, out) {
|
|
1245
1630
|
const entries = await fs4.readdir(currentDir, { withFileTypes: true });
|
|
1246
1631
|
for (const entry2 of entries) {
|
|
1247
|
-
const full =
|
|
1632
|
+
const full = path5.join(currentDir, entry2.name);
|
|
1248
1633
|
if (entry2.isDirectory()) {
|
|
1249
1634
|
await walk(baseDir, full, out);
|
|
1250
1635
|
continue;
|
|
1251
1636
|
}
|
|
1252
1637
|
if (!entry2.isFile() || !entry2.name.endsWith(".yml")) continue;
|
|
1253
|
-
const relative =
|
|
1254
|
-
const name = relative.replace(/\.yml$/, "").split(
|
|
1638
|
+
const relative = path5.relative(baseDir, full);
|
|
1639
|
+
const name = relative.replace(/\.yml$/, "").split(path5.sep).join("/");
|
|
1255
1640
|
const text = await fs4.readFile(full, "utf8");
|
|
1256
1641
|
let raw;
|
|
1257
1642
|
try {
|
|
@@ -1273,42 +1658,6 @@ ${issues}`);
|
|
|
1273
1658
|
out.set(name, { name, sourcePath: full, file: parsed.data });
|
|
1274
1659
|
}
|
|
1275
1660
|
}
|
|
1276
|
-
function mergeComponents(resolved) {
|
|
1277
|
-
const languages = [];
|
|
1278
|
-
const services = [];
|
|
1279
|
-
const featureByRef = /* @__PURE__ */ new Map();
|
|
1280
|
-
for (const entry2 of resolved) {
|
|
1281
|
-
const c = isResolvedComponent(entry2) ? entry2.component : entry2;
|
|
1282
|
-
const version = isResolvedComponent(entry2) ? entry2.version : void 0;
|
|
1283
|
-
const ct = c.file.contributes;
|
|
1284
|
-
for (const lang of ct.languages ?? []) {
|
|
1285
|
-
const value = version !== void 0 ? `${lang}:${version}` : lang;
|
|
1286
|
-
if (!languages.includes(value)) languages.push(value);
|
|
1287
|
-
}
|
|
1288
|
-
for (const svc of ct.services ?? []) {
|
|
1289
|
-
if (!services.includes(svc)) services.push(svc);
|
|
1290
|
-
}
|
|
1291
|
-
for (const f of ct.features ?? []) {
|
|
1292
|
-
const existing = featureByRef.get(f.ref);
|
|
1293
|
-
if (!existing) {
|
|
1294
|
-
featureByRef.set(f.ref, {
|
|
1295
|
-
ref: f.ref,
|
|
1296
|
-
options: { ...f.options ?? {} }
|
|
1297
|
-
});
|
|
1298
|
-
continue;
|
|
1299
|
-
}
|
|
1300
|
-
existing.options = mergeFeatureOptions(existing.options, f.options ?? {});
|
|
1301
|
-
}
|
|
1302
|
-
}
|
|
1303
|
-
return {
|
|
1304
|
-
languages,
|
|
1305
|
-
services,
|
|
1306
|
-
features: [...featureByRef.values()]
|
|
1307
|
-
};
|
|
1308
|
-
}
|
|
1309
|
-
function isResolvedComponent(x) {
|
|
1310
|
-
return "component" in x;
|
|
1311
|
-
}
|
|
1312
1661
|
function mergeFeatureOptions(a, b) {
|
|
1313
1662
|
const result = { ...a };
|
|
1314
1663
|
for (const [key, valueB] of Object.entries(b)) {
|
|
@@ -1321,39 +1670,11 @@ function mergeFeatureOptions(a, b) {
|
|
|
1321
1670
|
}
|
|
1322
1671
|
return result;
|
|
1323
1672
|
}
|
|
1324
|
-
function resolveComponents(catalog, names) {
|
|
1325
|
-
const unknown = [];
|
|
1326
|
-
const out = [];
|
|
1327
|
-
for (const raw of names) {
|
|
1328
|
-
const colon = raw.indexOf(":");
|
|
1329
|
-
const name = colon === -1 ? raw : raw.slice(0, colon);
|
|
1330
|
-
const version = colon === -1 ? void 0 : raw.slice(colon + 1);
|
|
1331
|
-
const c = catalog.get(name);
|
|
1332
|
-
if (!c) {
|
|
1333
|
-
unknown.push(raw);
|
|
1334
|
-
continue;
|
|
1335
|
-
}
|
|
1336
|
-
if (version !== void 0 && c.file.category !== "language") {
|
|
1337
|
-
throw new Error(
|
|
1338
|
-
`Component '${name}' is a ${c.file.category}, not a language \u2014 a ':${version}' suffix has no meaning here.`
|
|
1339
|
-
);
|
|
1340
|
-
}
|
|
1341
|
-
out.push({ component: c, ...version !== void 0 ? { version } : {} });
|
|
1342
|
-
}
|
|
1343
|
-
if (unknown.length > 0) {
|
|
1344
|
-
const available = [...catalog.keys()].sort();
|
|
1345
|
-
throw new Error(
|
|
1346
|
-
`Unknown component${unknown.length > 1 ? "s" : ""}: ${unknown.join(", ")}.
|
|
1347
|
-
Available: ${available.join(", ")}.`
|
|
1348
|
-
);
|
|
1349
|
-
}
|
|
1350
|
-
return out;
|
|
1351
|
-
}
|
|
1352
1673
|
|
|
1353
1674
|
// src/proxy/index.ts
|
|
1354
1675
|
import { spawn as spawn3 } from "child_process";
|
|
1355
1676
|
import { promises as fs5 } from "fs";
|
|
1356
|
-
import
|
|
1677
|
+
import path6 from "path";
|
|
1357
1678
|
var PROXY_CONTAINER_NAME = "monoceros-proxy";
|
|
1358
1679
|
var PROXY_NETWORK_NAME = "monoceros-proxy";
|
|
1359
1680
|
var TRAEFIK_IMAGE = "traefik:v3.3";
|
|
@@ -1379,7 +1700,7 @@ var defaultDockerExec = (args) => {
|
|
|
1379
1700
|
};
|
|
1380
1701
|
var realDocker = defaultDockerExec;
|
|
1381
1702
|
function proxyDynamicDir(home) {
|
|
1382
|
-
return
|
|
1703
|
+
return path6.join(home ?? monocerosHome(), "traefik", "dynamic");
|
|
1383
1704
|
}
|
|
1384
1705
|
async function ensureProxy(opts = {}) {
|
|
1385
1706
|
const docker = opts.docker ?? realDocker;
|
|
@@ -1471,7 +1792,7 @@ async function maybeStopProxy(opts = {}) {
|
|
|
1471
1792
|
|
|
1472
1793
|
// src/proxy/dynamic.ts
|
|
1473
1794
|
import { promises as fs6 } from "fs";
|
|
1474
|
-
import
|
|
1795
|
+
import path7 from "path";
|
|
1475
1796
|
async function writeDynamicConfig(name, ports, opts = {}) {
|
|
1476
1797
|
if (ports.length === 0) {
|
|
1477
1798
|
throw new Error(
|
|
@@ -1480,12 +1801,12 @@ async function writeDynamicConfig(name, ports, opts = {}) {
|
|
|
1480
1801
|
}
|
|
1481
1802
|
const dir = proxyDynamicDir(opts.monocerosHome);
|
|
1482
1803
|
await fs6.mkdir(dir, { recursive: true });
|
|
1483
|
-
const file =
|
|
1804
|
+
const file = path7.join(dir, `${name}.yml`);
|
|
1484
1805
|
await fs6.writeFile(file, renderDynamicConfig(name, ports), "utf8");
|
|
1485
1806
|
return file;
|
|
1486
1807
|
}
|
|
1487
1808
|
async function removeDynamicConfig(name, opts = {}) {
|
|
1488
|
-
const file =
|
|
1809
|
+
const file = path7.join(proxyDynamicDir(opts.monocerosHome), `${name}.yml`);
|
|
1489
1810
|
await fs6.rm(file, { force: true });
|
|
1490
1811
|
}
|
|
1491
1812
|
function renderDynamicConfig(name, ports) {
|
|
@@ -1658,6 +1979,19 @@ var SERVICE_CATALOG = {
|
|
|
1658
1979
|
POSTGRES_PASSWORD: "monoceros",
|
|
1659
1980
|
POSTGRES_DB: "monoceros"
|
|
1660
1981
|
},
|
|
1982
|
+
healthcheck: {
|
|
1983
|
+
test: [
|
|
1984
|
+
"CMD",
|
|
1985
|
+
"pg_isready",
|
|
1986
|
+
"-U",
|
|
1987
|
+
"${POSTGRES_USER}",
|
|
1988
|
+
"-d",
|
|
1989
|
+
"${POSTGRES_DB}"
|
|
1990
|
+
],
|
|
1991
|
+
interval: "10s",
|
|
1992
|
+
timeout: "5s",
|
|
1993
|
+
retries: 5
|
|
1994
|
+
},
|
|
1661
1995
|
// Postgres 18+ stores data under /var/lib/postgresql/<major>/, so
|
|
1662
1996
|
// the recommended mount is the parent directory; pre-18 used
|
|
1663
1997
|
// /var/lib/postgresql/data directly. See
|
|
@@ -1672,12 +2006,33 @@ var SERVICE_CATALOG = {
|
|
|
1672
2006
|
MYSQL_ROOT_PASSWORD: "monoceros",
|
|
1673
2007
|
MYSQL_DATABASE: "monoceros"
|
|
1674
2008
|
},
|
|
2009
|
+
healthcheck: {
|
|
2010
|
+
test: [
|
|
2011
|
+
"CMD",
|
|
2012
|
+
"mysqladmin",
|
|
2013
|
+
"ping",
|
|
2014
|
+
"-h",
|
|
2015
|
+
"127.0.0.1",
|
|
2016
|
+
"-u",
|
|
2017
|
+
"root",
|
|
2018
|
+
"-p${MYSQL_ROOT_PASSWORD}"
|
|
2019
|
+
],
|
|
2020
|
+
interval: "10s",
|
|
2021
|
+
timeout: "5s",
|
|
2022
|
+
retries: 5
|
|
2023
|
+
},
|
|
1675
2024
|
dataMount: "/var/lib/mysql",
|
|
1676
2025
|
defaultPort: 3306
|
|
1677
2026
|
},
|
|
1678
2027
|
redis: {
|
|
1679
2028
|
id: "redis",
|
|
1680
2029
|
image: "redis:8",
|
|
2030
|
+
healthcheck: {
|
|
2031
|
+
test: ["CMD", "redis-cli", "ping"],
|
|
2032
|
+
interval: "10s",
|
|
2033
|
+
timeout: "5s",
|
|
2034
|
+
retries: 5
|
|
2035
|
+
},
|
|
1681
2036
|
dataMount: "/data",
|
|
1682
2037
|
defaultPort: 6379
|
|
1683
2038
|
}
|
|
@@ -1688,34 +2043,107 @@ function knownLanguages() {
|
|
|
1688
2043
|
function knownServices() {
|
|
1689
2044
|
return Object.keys(SERVICE_CATALOG).sort();
|
|
1690
2045
|
}
|
|
2046
|
+
function resolveService(entry2) {
|
|
2047
|
+
return {
|
|
2048
|
+
name: entry2.name,
|
|
2049
|
+
image: entry2.image,
|
|
2050
|
+
...entry2.port !== void 0 ? { port: entry2.port } : {},
|
|
2051
|
+
env: entry2.env ? { ...entry2.env } : {},
|
|
2052
|
+
volumes: entry2.volumes ? [...entry2.volumes] : [],
|
|
2053
|
+
...entry2.healthcheck ? { healthcheck: entry2.healthcheck } : {},
|
|
2054
|
+
...entry2.restart ? { restart: entry2.restart } : {},
|
|
2055
|
+
...entry2.command ? { command: entry2.command } : {}
|
|
2056
|
+
};
|
|
2057
|
+
}
|
|
2058
|
+
function isCuratedService(name) {
|
|
2059
|
+
return Object.prototype.hasOwnProperty.call(SERVICE_CATALOG, name);
|
|
2060
|
+
}
|
|
2061
|
+
function expandCuratedService(name) {
|
|
2062
|
+
const def = SERVICE_CATALOG[name];
|
|
2063
|
+
if (!def) {
|
|
2064
|
+
throw new Error(
|
|
2065
|
+
`Unknown service '${name}'. Known catalog services: ${knownServices().join(", ")}.`
|
|
2066
|
+
);
|
|
2067
|
+
}
|
|
2068
|
+
return {
|
|
2069
|
+
name: def.id,
|
|
2070
|
+
image: def.image,
|
|
2071
|
+
port: def.defaultPort,
|
|
2072
|
+
...def.env ? {
|
|
2073
|
+
env: Object.fromEntries(
|
|
2074
|
+
Object.keys(def.env).map((k) => [k, `\${${k}}`])
|
|
2075
|
+
)
|
|
2076
|
+
} : {},
|
|
2077
|
+
...def.dataMount ? { volumes: [`data:${def.dataMount}`] } : {},
|
|
2078
|
+
...def.healthcheck ? { healthcheck: def.healthcheck } : {},
|
|
2079
|
+
restart: "unless-stopped"
|
|
2080
|
+
};
|
|
2081
|
+
}
|
|
2082
|
+
function curatedServiceEnvDefaults(name) {
|
|
2083
|
+
const def = SERVICE_CATALOG[name];
|
|
2084
|
+
return def?.env ? { ...def.env } : {};
|
|
2085
|
+
}
|
|
2086
|
+
function deriveServiceName(image) {
|
|
2087
|
+
const lastSegment = image.split("/").pop() ?? image;
|
|
2088
|
+
const noTag = lastSegment.split("@")[0].split(":")[0];
|
|
2089
|
+
return noTag.toLowerCase().replace(/[^a-z0-9_-]/g, "-");
|
|
2090
|
+
}
|
|
1691
2091
|
|
|
1692
|
-
// src/
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
);
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
if (
|
|
1708
|
-
|
|
2092
|
+
// src/init/service-doc.ts
|
|
2093
|
+
function renderServiceObjectBody(svc) {
|
|
2094
|
+
const lines = [`name: ${svc.name}`, `image: ${svc.image}`];
|
|
2095
|
+
if (svc.port !== void 0) lines.push(`port: ${svc.port}`);
|
|
2096
|
+
if (svc.env && Object.keys(svc.env).length > 0) {
|
|
2097
|
+
lines.push("env:");
|
|
2098
|
+
for (const [k, v] of Object.entries(svc.env)) {
|
|
2099
|
+
lines.push(` ${k}: ${v}`);
|
|
2100
|
+
}
|
|
2101
|
+
}
|
|
2102
|
+
if (svc.volumes && svc.volumes.length > 0) {
|
|
2103
|
+
lines.push("volumes:");
|
|
2104
|
+
for (const vol of svc.volumes) lines.push(` - ${vol}`);
|
|
2105
|
+
}
|
|
2106
|
+
if (svc.restart) lines.push(`restart: ${svc.restart}`);
|
|
2107
|
+
if (svc.command !== void 0) lines.push(`command: ${svc.command}`);
|
|
2108
|
+
if (svc.healthcheck) {
|
|
2109
|
+
lines.push("healthcheck:");
|
|
2110
|
+
const test = svc.healthcheck.test;
|
|
2111
|
+
lines.push(
|
|
2112
|
+
Array.isArray(test) ? ` test: [${test.map((t) => JSON.stringify(t)).join(", ")}]` : ` test: ${test}`
|
|
2113
|
+
);
|
|
2114
|
+
if (svc.healthcheck.interval)
|
|
2115
|
+
lines.push(` interval: ${svc.healthcheck.interval}`);
|
|
2116
|
+
if (svc.healthcheck.timeout)
|
|
2117
|
+
lines.push(` timeout: ${svc.healthcheck.timeout}`);
|
|
2118
|
+
if (svc.healthcheck.retries !== void 0)
|
|
2119
|
+
lines.push(` retries: ${svc.healthcheck.retries}`);
|
|
2120
|
+
if (svc.healthcheck.startPeriod)
|
|
2121
|
+
lines.push(` startPeriod: ${svc.healthcheck.startPeriod}`);
|
|
2122
|
+
}
|
|
2123
|
+
return lines;
|
|
1709
2124
|
}
|
|
1710
|
-
function
|
|
1711
|
-
const
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
2125
|
+
function renderCustomService(name, image) {
|
|
2126
|
+
const bodyLines = [`name: ${name}`, `image: ${image}`];
|
|
2127
|
+
const comment = [
|
|
2128
|
+
" port: 8080 # in-container port \u2192 `monoceros tunnel`",
|
|
2129
|
+
" env: # values resolved from <name>.env",
|
|
2130
|
+
" KEY: ${SOME_VAR}",
|
|
2131
|
+
" volumes:",
|
|
2132
|
+
` - data:/data # persistent host bind-mount under data/${name}`,
|
|
2133
|
+
" - rel/host/path:/in/container:ro",
|
|
2134
|
+
" healthcheck:",
|
|
2135
|
+
" test: curl -f http://localhost:8080/health",
|
|
2136
|
+
" restart: unless-stopped"
|
|
2137
|
+
].join("\n");
|
|
2138
|
+
return { bodyLines, comment };
|
|
2139
|
+
}
|
|
2140
|
+
function customServiceHint(name) {
|
|
2141
|
+
return `'${name}' is a custom image \u2014 Monoceros doesn't know its env, ports or volumes. Review the commented block under services[].${name} in the yml and fill in what the image needs.`;
|
|
1716
2142
|
}
|
|
1717
2143
|
|
|
1718
2144
|
// src/create/scaffold.ts
|
|
2145
|
+
import { existsSync as existsSync5, readFileSync as readFileSync3, promises as fs7 } from "fs";
|
|
2146
|
+
import path8 from "path";
|
|
1719
2147
|
var APT_PACKAGE_NAME_RE2 = /^[a-z0-9][a-z0-9.+-]*$/;
|
|
1720
2148
|
var FEATURE_REF_RE2 = /^[a-z0-9.-]+(\/[a-z0-9._-]+)+:[a-z0-9._-]+$/;
|
|
1721
2149
|
var INSTALL_URL_RE2 = /^https:\/\/[A-Za-z0-9.\-_~/:?#[\]@!&'()*+,;=%]+$/;
|
|
@@ -1745,12 +2173,24 @@ function validateOptions(opts) {
|
|
|
1745
2173
|
);
|
|
1746
2174
|
}
|
|
1747
2175
|
}
|
|
2176
|
+
const seenServiceNames = /* @__PURE__ */ new Set();
|
|
1748
2177
|
for (const svc of opts.services) {
|
|
1749
|
-
if (!
|
|
2178
|
+
if (!svc.image) {
|
|
1750
2179
|
throw new Error(
|
|
1751
|
-
`
|
|
2180
|
+
`Service ${JSON.stringify(svc.name)} has no image. Every service needs an 'image:'.`
|
|
1752
2181
|
);
|
|
1753
2182
|
}
|
|
2183
|
+
if (svc.name === "workspace") {
|
|
2184
|
+
throw new Error(
|
|
2185
|
+
`Invalid service name 'workspace': it collides with the reserved devcontainer workspace service. Pick another name.`
|
|
2186
|
+
);
|
|
2187
|
+
}
|
|
2188
|
+
if (seenServiceNames.has(svc.name)) {
|
|
2189
|
+
throw new Error(
|
|
2190
|
+
`Duplicate service name: ${JSON.stringify(svc.name)}. Each services[] entry must have a unique name.`
|
|
2191
|
+
);
|
|
2192
|
+
}
|
|
2193
|
+
seenServiceNames.add(svc.name);
|
|
1754
2194
|
}
|
|
1755
2195
|
for (const pkg of opts.aptPackages ?? []) {
|
|
1756
2196
|
if (!APT_PACKAGE_NAME_RE2.test(pkg)) {
|
|
@@ -1800,10 +2240,14 @@ function validateOptions(opts) {
|
|
|
1800
2240
|
}
|
|
1801
2241
|
function normalizeOptions(opts) {
|
|
1802
2242
|
const languages = [...new Set(opts.languages)].sort();
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
2243
|
+
const serviceByName = /* @__PURE__ */ new Map();
|
|
2244
|
+
for (const svc of opts.services) {
|
|
2245
|
+
if (opts.postgresUrl && svc.name === "postgres") continue;
|
|
2246
|
+
serviceByName.set(svc.name, svc);
|
|
1806
2247
|
}
|
|
2248
|
+
const services = [...serviceByName.values()].sort(
|
|
2249
|
+
(a, b) => a.name.localeCompare(b.name)
|
|
2250
|
+
);
|
|
1807
2251
|
const aptPackages = [...new Set(opts.aptPackages ?? [])].sort();
|
|
1808
2252
|
const features = opts.features ? Object.fromEntries(
|
|
1809
2253
|
Object.entries(opts.features).sort(([a], [b]) => a.localeCompare(b))
|
|
@@ -1862,8 +2306,8 @@ function resolveFeatures(opts) {
|
|
|
1862
2306
|
if (match) {
|
|
1863
2307
|
const name = match.name;
|
|
1864
2308
|
const checkout = workbenchCheckoutRoot();
|
|
1865
|
-
const localSourceDir = checkout ?
|
|
1866
|
-
if (localSourceDir &&
|
|
2309
|
+
const localSourceDir = checkout ? path8.join(checkout, "images", "features", name) : null;
|
|
2310
|
+
if (localSourceDir && existsSync5(localSourceDir)) {
|
|
1867
2311
|
const { paths, files } = readPersistentHomeEntries(localSourceDir);
|
|
1868
2312
|
resolved.push({
|
|
1869
2313
|
devcontainerKey: `./features/${name}`,
|
|
@@ -1887,9 +2331,9 @@ function resolveFeatures(opts) {
|
|
|
1887
2331
|
return resolved;
|
|
1888
2332
|
}
|
|
1889
2333
|
function readPersistentHomeEntries(localSourceDir) {
|
|
1890
|
-
const manifestPath =
|
|
2334
|
+
const manifestPath = path8.join(localSourceDir, "devcontainer-feature.json");
|
|
1891
2335
|
try {
|
|
1892
|
-
const text =
|
|
2336
|
+
const text = readFileSync3(manifestPath, "utf8");
|
|
1893
2337
|
const parsed = JSON.parse(text);
|
|
1894
2338
|
return {
|
|
1895
2339
|
paths: filterSubpaths(parsed["x-monoceros"]?.persistentHomePaths),
|
|
@@ -1964,7 +2408,7 @@ function buildDevcontainerJson(opts, dockerMode = "rootful") {
|
|
|
1964
2408
|
name: opts.name,
|
|
1965
2409
|
dockerComposeFile: "compose.yaml",
|
|
1966
2410
|
service: "workspace",
|
|
1967
|
-
...opts.services.length > 0 ? { runServices: opts.services } : {},
|
|
2411
|
+
...opts.services.length > 0 ? { runServices: opts.services.map((s) => s.name) } : {},
|
|
1968
2412
|
workspaceFolder: `/workspaces/${opts.name}`,
|
|
1969
2413
|
remoteUser: "node",
|
|
1970
2414
|
forwardPorts: ports,
|
|
@@ -1997,6 +2441,18 @@ function buildDevcontainerJson(opts, dockerMode = "rootful") {
|
|
|
1997
2441
|
...customizationsField ?? {}
|
|
1998
2442
|
};
|
|
1999
2443
|
}
|
|
2444
|
+
function composeScalar(value) {
|
|
2445
|
+
const escaped = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\t/g, "\\t");
|
|
2446
|
+
return `"${escaped}"`;
|
|
2447
|
+
}
|
|
2448
|
+
function composeVolumeSource(spec, serviceName) {
|
|
2449
|
+
const parts = spec.split(":");
|
|
2450
|
+
const src = parts[0];
|
|
2451
|
+
const rest = parts.slice(1).join(":");
|
|
2452
|
+
if (src === "data") return `../data/${serviceName}:${rest}`;
|
|
2453
|
+
const relative = src.startsWith("./") ? src.slice(2) : src;
|
|
2454
|
+
return `../${relative}:${rest}`;
|
|
2455
|
+
}
|
|
2000
2456
|
function buildComposeYaml(opts, dockerMode = "rootful") {
|
|
2001
2457
|
void dockerMode;
|
|
2002
2458
|
const hasPorts = (opts.ports?.length ?? 0) > 0;
|
|
@@ -2025,20 +2481,42 @@ function buildComposeYaml(opts, dockerMode = "rootful") {
|
|
|
2025
2481
|
lines.push(` - ../home/${sub}:/home/node/${sub}`);
|
|
2026
2482
|
}
|
|
2027
2483
|
}
|
|
2028
|
-
for (const
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2484
|
+
for (const svc of opts.services) {
|
|
2485
|
+
lines.push(` ${svc.name}:`);
|
|
2486
|
+
lines.push(` image: ${svc.image}`);
|
|
2487
|
+
if (svc.restart) {
|
|
2488
|
+
lines.push(` restart: ${svc.restart}`);
|
|
2489
|
+
}
|
|
2490
|
+
if (svc.command !== void 0) {
|
|
2491
|
+
lines.push(` command: ${composeScalar(svc.command)}`);
|
|
2492
|
+
}
|
|
2493
|
+
const envKeys = Object.keys(svc.env);
|
|
2494
|
+
if (envKeys.length > 0) {
|
|
2034
2495
|
lines.push(" environment:");
|
|
2035
|
-
for (const
|
|
2036
|
-
lines.push(` ${k}: ${
|
|
2496
|
+
for (const k of envKeys) {
|
|
2497
|
+
lines.push(` ${k}: ${composeScalar(svc.env[k])}`);
|
|
2037
2498
|
}
|
|
2038
2499
|
}
|
|
2039
|
-
if (
|
|
2500
|
+
if (svc.volumes.length > 0) {
|
|
2040
2501
|
lines.push(" volumes:");
|
|
2041
|
-
|
|
2502
|
+
for (const vol of svc.volumes) {
|
|
2503
|
+
lines.push(` - ${composeVolumeSource(vol, svc.name)}`);
|
|
2504
|
+
}
|
|
2505
|
+
}
|
|
2506
|
+
if (svc.healthcheck) {
|
|
2507
|
+
const hc = svc.healthcheck;
|
|
2508
|
+
lines.push(" healthcheck:");
|
|
2509
|
+
if (Array.isArray(hc.test)) {
|
|
2510
|
+
lines.push(` test: [${hc.test.map(composeScalar).join(", ")}]`);
|
|
2511
|
+
} else {
|
|
2512
|
+
lines.push(` test: ${composeScalar(hc.test)}`);
|
|
2513
|
+
}
|
|
2514
|
+
if (hc.interval) lines.push(` interval: ${hc.interval}`);
|
|
2515
|
+
if (hc.timeout) lines.push(` timeout: ${hc.timeout}`);
|
|
2516
|
+
if (hc.retries !== void 0) lines.push(` retries: ${hc.retries}`);
|
|
2517
|
+
if (hc.startPeriod) {
|
|
2518
|
+
lines.push(` start_period: ${hc.startPeriod}`);
|
|
2519
|
+
}
|
|
2042
2520
|
}
|
|
2043
2521
|
}
|
|
2044
2522
|
if (hasPorts) {
|
|
@@ -2172,245 +2650,99 @@ function buildPostCreateScript(opts) {
|
|
|
2172
2650
|
return lines.join("\n") + "\n";
|
|
2173
2651
|
}
|
|
2174
2652
|
async function writePostCreateScript(devcontainerDir, opts) {
|
|
2175
|
-
const dest =
|
|
2653
|
+
const dest = path8.join(devcontainerDir, "post-create.sh");
|
|
2176
2654
|
await fs7.writeFile(dest, buildPostCreateScript(opts));
|
|
2177
2655
|
await fs7.chmod(dest, 493);
|
|
2178
2656
|
}
|
|
2179
2657
|
async function writeScaffold(opts, targetDir, scaffoldOpts = {}) {
|
|
2180
2658
|
const dockerMode = scaffoldOpts.dockerMode ?? "rootful";
|
|
2181
|
-
const devcontainerDir =
|
|
2182
|
-
const monocerosDir =
|
|
2183
|
-
const projectsDir =
|
|
2184
|
-
const homeDir =
|
|
2185
|
-
const dataDir =
|
|
2659
|
+
const devcontainerDir = path8.join(targetDir, ".devcontainer");
|
|
2660
|
+
const monocerosDir = path8.join(targetDir, ".monoceros");
|
|
2661
|
+
const projectsDir = path8.join(targetDir, "projects");
|
|
2662
|
+
const homeDir = path8.join(targetDir, "home");
|
|
2663
|
+
const dataDir = path8.join(targetDir, "data");
|
|
2186
2664
|
await fs7.mkdir(devcontainerDir, { recursive: true });
|
|
2187
2665
|
await fs7.mkdir(monocerosDir, { recursive: true });
|
|
2188
2666
|
await fs7.mkdir(projectsDir, { recursive: true });
|
|
2189
2667
|
await fs7.mkdir(homeDir, { recursive: true });
|
|
2190
2668
|
if (needsCompose(opts)) {
|
|
2191
2669
|
await fs7.mkdir(dataDir, { recursive: true });
|
|
2192
|
-
for (const
|
|
2193
|
-
const
|
|
2194
|
-
if (
|
|
2195
|
-
await fs7.mkdir(
|
|
2670
|
+
for (const svc of opts.services) {
|
|
2671
|
+
const hasDataVolume = svc.volumes.some((v) => v.split(":")[0] === "data");
|
|
2672
|
+
if (hasDataVolume) {
|
|
2673
|
+
await fs7.mkdir(path8.join(dataDir, svc.name), { recursive: true });
|
|
2196
2674
|
}
|
|
2197
2675
|
}
|
|
2198
2676
|
}
|
|
2199
|
-
const containerGitignore =
|
|
2677
|
+
const containerGitignore = path8.join(targetDir, ".gitignore");
|
|
2200
2678
|
await fs7.writeFile(containerGitignore, "/home/\n/.monoceros/\n/data/\n");
|
|
2201
|
-
const gitkeep =
|
|
2202
|
-
if (!
|
|
2679
|
+
const gitkeep = path8.join(projectsDir, ".gitkeep");
|
|
2680
|
+
if (!existsSync5(gitkeep)) {
|
|
2203
2681
|
await fs7.writeFile(gitkeep, "");
|
|
2204
2682
|
}
|
|
2205
2683
|
await fs7.writeFile(
|
|
2206
|
-
|
|
2684
|
+
path8.join(monocerosDir, ".gitignore"),
|
|
2207
2685
|
"git-credentials*\ngitconfig\n"
|
|
2208
2686
|
);
|
|
2209
2687
|
const devcontainerJson = buildDevcontainerJson(opts, dockerMode);
|
|
2210
2688
|
await fs7.writeFile(
|
|
2211
|
-
|
|
2689
|
+
path8.join(devcontainerDir, "devcontainer.json"),
|
|
2212
2690
|
JSON.stringify(devcontainerJson, null, 2) + "\n"
|
|
2213
2691
|
);
|
|
2214
|
-
const featuresDir =
|
|
2215
|
-
if (
|
|
2692
|
+
const featuresDir = path8.join(devcontainerDir, "features");
|
|
2693
|
+
if (existsSync5(featuresDir)) {
|
|
2216
2694
|
await fs7.rm(featuresDir, { recursive: true, force: true });
|
|
2217
2695
|
}
|
|
2218
2696
|
const resolvedFeatures = resolveFeatures(opts);
|
|
2219
2697
|
for (const f of resolvedFeatures) {
|
|
2220
2698
|
if (!f.localSourceDir || !f.localName) continue;
|
|
2221
|
-
const dest =
|
|
2699
|
+
const dest = path8.join(featuresDir, f.localName);
|
|
2222
2700
|
await fs7.mkdir(dest, { recursive: true });
|
|
2223
2701
|
await fs7.cp(f.localSourceDir, dest, { recursive: true });
|
|
2224
2702
|
}
|
|
2225
2703
|
for (const f of resolvedFeatures) {
|
|
2226
|
-
for (const sub of f.persistentHomePaths) {
|
|
2227
|
-
await fs7.mkdir(
|
|
2228
|
-
}
|
|
2229
|
-
for (const entry2 of f.persistentHomeFiles) {
|
|
2230
|
-
const filePath =
|
|
2231
|
-
await fs7.mkdir(
|
|
2232
|
-
if (!
|
|
2233
|
-
await fs7.writeFile(filePath, entry2.initialContent);
|
|
2234
|
-
}
|
|
2235
|
-
}
|
|
2236
|
-
}
|
|
2237
|
-
await writePostCreateScript(devcontainerDir, opts);
|
|
2238
|
-
const composePath = path6.join(devcontainerDir, "compose.yaml");
|
|
2239
|
-
if (needsCompose(opts)) {
|
|
2240
|
-
await fs7.writeFile(composePath, buildComposeYaml(opts, dockerMode));
|
|
2241
|
-
} else if (existsSync3(composePath)) {
|
|
2242
|
-
await fs7.rm(composePath);
|
|
2243
|
-
}
|
|
2244
|
-
const workspacePath = path6.join(targetDir, `${opts.name}.code-workspace`);
|
|
2245
|
-
let existingWorkspace;
|
|
2246
|
-
try {
|
|
2247
|
-
const raw = await fs7.readFile(workspacePath, "utf8");
|
|
2248
|
-
existingWorkspace = JSON.parse(raw);
|
|
2249
|
-
} catch {
|
|
2250
|
-
existingWorkspace = void 0;
|
|
2251
|
-
}
|
|
2252
|
-
const generated = buildCodeWorkspaceJson(opts);
|
|
2253
|
-
const merged = mergeCodeWorkspace(existingWorkspace, generated);
|
|
2254
|
-
await fs7.writeFile(workspacePath, JSON.stringify(merged, null, 2) + "\n");
|
|
2255
|
-
}
|
|
2256
|
-
|
|
2257
|
-
// src/modify/yml.ts
|
|
2258
|
-
import {
|
|
2259
|
-
isMap as isMap2,
|
|
2260
|
-
isScalar,
|
|
2261
|
-
isSeq,
|
|
2262
|
-
Pair as Pair2,
|
|
2263
|
-
Scalar as Scalar2,
|
|
2264
|
-
YAMLMap as YAMLMap2,
|
|
2265
|
-
YAMLSeq
|
|
2266
|
-
} from "yaml";
|
|
2267
|
-
|
|
2268
|
-
// src/init/manifest.ts
|
|
2269
|
-
import { existsSync as existsSync4, readFileSync as readFileSync2 } from "fs";
|
|
2270
|
-
import path7 from "path";
|
|
2271
|
-
function resolveManifestPath(name, checkoutRoot) {
|
|
2272
|
-
if (checkoutRoot) {
|
|
2273
|
-
const checkoutPath = path7.join(
|
|
2274
|
-
checkoutRoot,
|
|
2275
|
-
"images",
|
|
2276
|
-
"features",
|
|
2277
|
-
name,
|
|
2278
|
-
"devcontainer-feature.json"
|
|
2279
|
-
);
|
|
2280
|
-
if (existsSync4(checkoutPath)) return checkoutPath;
|
|
2281
|
-
}
|
|
2282
|
-
const bundlePath = path7.join(
|
|
2283
|
-
bundledFeaturesDir(),
|
|
2284
|
-
name,
|
|
2285
|
-
"devcontainer-feature.json"
|
|
2286
|
-
);
|
|
2287
|
-
if (existsSync4(bundlePath)) return bundlePath;
|
|
2288
|
-
return null;
|
|
2289
|
-
}
|
|
2290
|
-
function loadFeatureManifestSummary(ref, checkoutRoot = workbenchCheckoutRoot()) {
|
|
2291
|
-
const match = matchMonocerosFeature(ref);
|
|
2292
|
-
if (!match) return void 0;
|
|
2293
|
-
const manifestPath = resolveManifestPath(match.name, checkoutRoot);
|
|
2294
|
-
if (!manifestPath) return void 0;
|
|
2295
|
-
try {
|
|
2296
|
-
const text = readFileSync2(manifestPath, "utf8");
|
|
2297
|
-
const parsed = JSON.parse(text);
|
|
2298
|
-
const rawHints = parsed["x-monoceros"]?.optionHints;
|
|
2299
|
-
const optionHints = Array.isArray(rawHints) ? rawHints.filter(
|
|
2300
|
-
(x) => typeof x === "string" && x.length > 0
|
|
2301
|
-
) : [];
|
|
2302
|
-
const rawNotes = parsed["x-monoceros"]?.usageNotes;
|
|
2303
|
-
const usageNotes = Array.isArray(rawNotes) ? rawNotes.filter(
|
|
2304
|
-
(x) => typeof x === "string" && x.length > 0
|
|
2305
|
-
) : [];
|
|
2306
|
-
const optionDescriptions = {};
|
|
2307
|
-
const optionTypes = {};
|
|
2308
|
-
const optionNames = [];
|
|
2309
|
-
if (parsed.options) {
|
|
2310
|
-
for (const [key, opt] of Object.entries(parsed.options)) {
|
|
2311
|
-
if (!opt || typeof opt !== "object") continue;
|
|
2312
|
-
optionNames.push(key);
|
|
2313
|
-
if (typeof opt.description === "string" && opt.description.length > 0) {
|
|
2314
|
-
optionDescriptions[key] = opt.description;
|
|
2315
|
-
}
|
|
2316
|
-
if (opt.type === "boolean") {
|
|
2317
|
-
optionTypes[key] = "boolean";
|
|
2318
|
-
} else if (opt.type === "string") {
|
|
2319
|
-
optionTypes[key] = "string";
|
|
2320
|
-
}
|
|
2321
|
-
}
|
|
2322
|
-
}
|
|
2323
|
-
const name = typeof parsed.name === "string" ? parsed.name : "";
|
|
2324
|
-
const description = typeof parsed.description === "string" ? parsed.description : "";
|
|
2325
|
-
const rawUrl = typeof parsed.documentationURL === "string" ? parsed.documentationURL.trim() : "";
|
|
2326
|
-
const documentationURL = rawUrl.length > 0 && rawUrl.toLowerCase() !== "tbd" ? rawUrl : void 0;
|
|
2327
|
-
return {
|
|
2328
|
-
name,
|
|
2329
|
-
description,
|
|
2330
|
-
documentationURL,
|
|
2331
|
-
optionHints,
|
|
2332
|
-
optionDescriptions,
|
|
2333
|
-
optionNames,
|
|
2334
|
-
optionTypes,
|
|
2335
|
-
usageNotes
|
|
2336
|
-
};
|
|
2337
|
-
} catch {
|
|
2338
|
-
return void 0;
|
|
2339
|
-
}
|
|
2340
|
-
}
|
|
2341
|
-
|
|
2342
|
-
// src/init/feature-doc.ts
|
|
2343
|
-
function buildFeatureHeaderLines(summary, width) {
|
|
2344
|
-
const paragraphs = buildHeaderParagraphs(summary);
|
|
2345
|
-
const wrapped = [];
|
|
2346
|
-
for (const para of paragraphs) {
|
|
2347
|
-
for (const line of wrapToComment(para, width)) {
|
|
2348
|
-
wrapped.push(line);
|
|
2349
|
-
}
|
|
2350
|
-
}
|
|
2351
|
-
return wrapped;
|
|
2352
|
-
}
|
|
2353
|
-
function buildFeatureHeaderCommentBefore(summary, width) {
|
|
2354
|
-
const lines = buildFeatureHeaderLines(summary, width);
|
|
2355
|
-
return lines.map((l) => ` ${l}`).join("\n");
|
|
2356
|
-
}
|
|
2357
|
-
function buildHeaderParagraphs(summary) {
|
|
2358
|
-
if (!summary) return [];
|
|
2359
|
-
const out = [];
|
|
2360
|
-
const tagline = summary.name?.trim();
|
|
2361
|
-
const description = summary.description?.trim();
|
|
2362
|
-
if (tagline && description) {
|
|
2363
|
-
out.push(`${tagline} \u2014 ${description}`);
|
|
2364
|
-
} else if (tagline) {
|
|
2365
|
-
out.push(tagline);
|
|
2366
|
-
} else if (description) {
|
|
2367
|
-
out.push(description);
|
|
2368
|
-
}
|
|
2369
|
-
for (const note of summary.usageNotes) {
|
|
2370
|
-
const trimmed = note.trim();
|
|
2371
|
-
if (trimmed.length > 0) out.push(trimmed);
|
|
2372
|
-
}
|
|
2373
|
-
if (summary.optionHints.length > 0) {
|
|
2374
|
-
const parts = summary.optionHints.map((key) => {
|
|
2375
|
-
const desc = summary.optionDescriptions[key];
|
|
2376
|
-
const short = desc ? shortenOptionDescription(desc) : void 0;
|
|
2377
|
-
return short ? `${key} (${short})` : key;
|
|
2378
|
-
});
|
|
2379
|
-
out.push(`Options: ${parts.join(", ")}.`);
|
|
2380
|
-
}
|
|
2381
|
-
if (summary.documentationURL) {
|
|
2382
|
-
out.push(`See ${summary.documentationURL} for further information.`);
|
|
2383
|
-
}
|
|
2384
|
-
return out;
|
|
2385
|
-
}
|
|
2386
|
-
function shortenOptionDescription(desc) {
|
|
2387
|
-
const firstSentence = desc.split(/(?<=[.!?])\s+/)[0]?.trim() ?? desc.trim();
|
|
2388
|
-
return firstSentence.replace(/[.!?]+$/, "").trim();
|
|
2389
|
-
}
|
|
2390
|
-
function wrapToComment(text, width) {
|
|
2391
|
-
const words = text.split(/\s+/).filter((w) => w.length > 0);
|
|
2392
|
-
if (words.length === 0) return [""];
|
|
2393
|
-
const usable = Math.max(width, 20);
|
|
2394
|
-
const lines = [];
|
|
2395
|
-
let current = "";
|
|
2396
|
-
for (const w of words) {
|
|
2397
|
-
if (current.length === 0) {
|
|
2398
|
-
current = w;
|
|
2399
|
-
continue;
|
|
2400
|
-
}
|
|
2401
|
-
if (current.length + 1 + w.length <= usable) {
|
|
2402
|
-
current += " " + w;
|
|
2403
|
-
} else {
|
|
2404
|
-
lines.push(current);
|
|
2405
|
-
current = w;
|
|
2704
|
+
for (const sub of f.persistentHomePaths) {
|
|
2705
|
+
await fs7.mkdir(path8.join(homeDir, sub), { recursive: true });
|
|
2706
|
+
}
|
|
2707
|
+
for (const entry2 of f.persistentHomeFiles) {
|
|
2708
|
+
const filePath = path8.join(homeDir, entry2.path);
|
|
2709
|
+
await fs7.mkdir(path8.dirname(filePath), { recursive: true });
|
|
2710
|
+
if (!existsSync5(filePath)) {
|
|
2711
|
+
await fs7.writeFile(filePath, entry2.initialContent);
|
|
2712
|
+
}
|
|
2406
2713
|
}
|
|
2407
2714
|
}
|
|
2408
|
-
|
|
2409
|
-
|
|
2715
|
+
await writePostCreateScript(devcontainerDir, opts);
|
|
2716
|
+
const composePath = path8.join(devcontainerDir, "compose.yaml");
|
|
2717
|
+
if (needsCompose(opts)) {
|
|
2718
|
+
await fs7.writeFile(composePath, buildComposeYaml(opts, dockerMode));
|
|
2719
|
+
} else if (existsSync5(composePath)) {
|
|
2720
|
+
await fs7.rm(composePath);
|
|
2721
|
+
}
|
|
2722
|
+
const workspacePath = path8.join(targetDir, `${opts.name}.code-workspace`);
|
|
2723
|
+
let existingWorkspace;
|
|
2724
|
+
try {
|
|
2725
|
+
const raw = await fs7.readFile(workspacePath, "utf8");
|
|
2726
|
+
existingWorkspace = JSON.parse(raw);
|
|
2727
|
+
} catch {
|
|
2728
|
+
existingWorkspace = void 0;
|
|
2729
|
+
}
|
|
2730
|
+
const generated = buildCodeWorkspaceJson(opts);
|
|
2731
|
+
const merged = mergeCodeWorkspace(existingWorkspace, generated);
|
|
2732
|
+
await fs7.writeFile(workspacePath, JSON.stringify(merged, null, 2) + "\n");
|
|
2410
2733
|
}
|
|
2411
|
-
var FEATURE_HEADER_WIDTH = 76 - 2;
|
|
2412
2734
|
|
|
2413
2735
|
// src/modify/yml.ts
|
|
2736
|
+
import {
|
|
2737
|
+
isMap as isMap2,
|
|
2738
|
+
isScalar,
|
|
2739
|
+
isSeq,
|
|
2740
|
+
Pair as Pair2,
|
|
2741
|
+
parseDocument as parseDocument3,
|
|
2742
|
+
Scalar as Scalar2,
|
|
2743
|
+
YAMLMap as YAMLMap2,
|
|
2744
|
+
YAMLSeq
|
|
2745
|
+
} from "yaml";
|
|
2414
2746
|
function ensureSeq(doc, key) {
|
|
2415
2747
|
const existing = doc.get(key, true);
|
|
2416
2748
|
if (existing && isSeq(existing)) return existing;
|
|
@@ -2433,11 +2765,24 @@ function addLanguageToDoc(doc, lang) {
|
|
|
2433
2765
|
seq.add(lang);
|
|
2434
2766
|
return true;
|
|
2435
2767
|
}
|
|
2436
|
-
function
|
|
2768
|
+
function findServiceItem(seq, name) {
|
|
2769
|
+
for (const item of seq.items) {
|
|
2770
|
+
if (isMap2(item) && item.get("name") === name) return item;
|
|
2771
|
+
}
|
|
2772
|
+
return void 0;
|
|
2773
|
+
}
|
|
2774
|
+
function addServiceEntryToDoc(doc, name, image, bodyLines, scaffoldComment) {
|
|
2437
2775
|
const seq = ensureSeq(doc, "services");
|
|
2438
|
-
|
|
2439
|
-
|
|
2440
|
-
|
|
2776
|
+
const existing = findServiceItem(seq, name);
|
|
2777
|
+
if (existing) {
|
|
2778
|
+
const existingImage = existing.get("image");
|
|
2779
|
+
if (existingImage === image) return { outcome: "exists" };
|
|
2780
|
+
return { outcome: "conflict", existingImage: String(existingImage) };
|
|
2781
|
+
}
|
|
2782
|
+
const node = parseDocument3(bodyLines.join("\n")).contents;
|
|
2783
|
+
if (scaffoldComment) node.comment = scaffoldComment;
|
|
2784
|
+
seq.add(node);
|
|
2785
|
+
return { outcome: "added" };
|
|
2441
2786
|
}
|
|
2442
2787
|
function addAptPackagesToDoc(doc, packages) {
|
|
2443
2788
|
const seq = ensureSeq(doc, "aptPackages");
|
|
@@ -2657,6 +3002,12 @@ function addFeatureToDoc(doc, ref, options = {}, displayName) {
|
|
|
2657
3002
|
entry2.commentBefore = headerBefore;
|
|
2658
3003
|
entry2.spaceBefore = true;
|
|
2659
3004
|
}
|
|
3005
|
+
const hints = featureOptionHints(summary, ref, Object.keys(options));
|
|
3006
|
+
if (hints.length > 0) {
|
|
3007
|
+
const commentLines = [" options:"];
|
|
3008
|
+
for (const h of hints) commentLines.push(` ${h.key}: ${h.placeholder}`);
|
|
3009
|
+
entry2.comment = commentLines.join("\n");
|
|
3010
|
+
}
|
|
2660
3011
|
seq.add(entry2);
|
|
2661
3012
|
return true;
|
|
2662
3013
|
}
|
|
@@ -2733,7 +3084,15 @@ function removeLanguageFromDoc(doc, lang) {
|
|
|
2733
3084
|
return removeScalarFromSeq(doc, "languages", lang);
|
|
2734
3085
|
}
|
|
2735
3086
|
function removeServiceFromDoc(doc, service) {
|
|
2736
|
-
|
|
3087
|
+
const node = doc.get("services", true);
|
|
3088
|
+
if (!node || !isSeq(node)) return false;
|
|
3089
|
+
const idx = node.items.findIndex(
|
|
3090
|
+
(i) => isMap2(i) && i.get("name") === service
|
|
3091
|
+
);
|
|
3092
|
+
if (idx === -1) return false;
|
|
3093
|
+
node.items.splice(idx, 1);
|
|
3094
|
+
pruneEmptySeq(doc, "services");
|
|
3095
|
+
return true;
|
|
2737
3096
|
}
|
|
2738
3097
|
function removeAptPackageFromDoc(doc, pkg) {
|
|
2739
3098
|
return removeScalarFromSeq(doc, "aptPackages", pkg);
|
|
@@ -2773,8 +3132,8 @@ function removeRepoFromDoc(doc, urlOrPath) {
|
|
|
2773
3132
|
if (!isMap2(item)) return false;
|
|
2774
3133
|
const url = item.get("url");
|
|
2775
3134
|
if (url === urlOrPath) return true;
|
|
2776
|
-
const
|
|
2777
|
-
const effectivePath = typeof
|
|
3135
|
+
const path21 = item.get("path");
|
|
3136
|
+
const effectivePath = typeof path21 === "string" ? path21 : typeof url === "string" ? deriveRepoName(url) : void 0;
|
|
2778
3137
|
return effectivePath === urlOrPath;
|
|
2779
3138
|
});
|
|
2780
3139
|
if (idx < 0) return false;
|
|
@@ -2801,13 +3160,56 @@ function runAddLanguage(input) {
|
|
|
2801
3160
|
}
|
|
2802
3161
|
return mutate(input, (doc) => addLanguageToDoc(doc, input.language));
|
|
2803
3162
|
}
|
|
2804
|
-
function runAddService(input) {
|
|
2805
|
-
|
|
3163
|
+
async function runAddService(input) {
|
|
3164
|
+
const arg = input.service;
|
|
3165
|
+
const curated = isCuratedService(arg);
|
|
3166
|
+
if (input.as !== void 0 && !/^[a-z0-9][a-z0-9_-]*$/.test(input.as)) {
|
|
2806
3167
|
throw new Error(
|
|
2807
|
-
`
|
|
3168
|
+
`Invalid --as name ${JSON.stringify(input.as)}. Use lowercase letters, digits, '_' or '-' (must start with a letter or digit).`
|
|
3169
|
+
);
|
|
3170
|
+
}
|
|
3171
|
+
const name = input.as ?? (curated ? arg : deriveServiceName(arg));
|
|
3172
|
+
const image = curated ? expandCuratedService(arg).image : arg;
|
|
3173
|
+
const custom = curated ? null : renderCustomService(name, arg);
|
|
3174
|
+
const bodyLines = curated ? renderServiceObjectBody({ ...expandCuratedService(arg), name }) : custom.bodyLines;
|
|
3175
|
+
const scaffoldComment = curated ? void 0 : custom.comment;
|
|
3176
|
+
const result = await mutate(input, (doc) => {
|
|
3177
|
+
const r = addServiceEntryToDoc(
|
|
3178
|
+
doc,
|
|
3179
|
+
name,
|
|
3180
|
+
image,
|
|
3181
|
+
bodyLines,
|
|
3182
|
+
scaffoldComment
|
|
2808
3183
|
);
|
|
3184
|
+
if (r.outcome === "conflict") {
|
|
3185
|
+
throw new Error(
|
|
3186
|
+
`A service named '${name}' already exists with a different image (${r.existingImage}). Add it under a different name with \`--as <name>\`, or remove the existing one first (\`monoceros remove-service ${input.name} ${name}\`).`
|
|
3187
|
+
);
|
|
3188
|
+
}
|
|
3189
|
+
return r.outcome === "added";
|
|
3190
|
+
});
|
|
3191
|
+
if (result.status === "updated") {
|
|
3192
|
+
if (curated) {
|
|
3193
|
+
const defaults = curatedServiceEnvDefaults(arg);
|
|
3194
|
+
if (Object.keys(defaults).length > 0) {
|
|
3195
|
+
const home = input.monocerosHome ?? monocerosHome();
|
|
3196
|
+
await ensureEnvGitignored(containerConfigsDir(home));
|
|
3197
|
+
const seeded = await ensureEnvVars(
|
|
3198
|
+
containerEnvPath(input.name, home),
|
|
3199
|
+
input.name,
|
|
3200
|
+
defaults
|
|
3201
|
+
);
|
|
3202
|
+
if (seeded.added.length > 0) {
|
|
3203
|
+
(input.logger ?? defaultLogger()).info(
|
|
3204
|
+
`Seeded ${seeded.added.join(", ")} into ${input.name}.env (dev-defaults \u2014 change them there if needed).`
|
|
3205
|
+
);
|
|
3206
|
+
}
|
|
3207
|
+
}
|
|
3208
|
+
} else {
|
|
3209
|
+
(input.logger ?? defaultLogger()).info(customServiceHint(name));
|
|
3210
|
+
}
|
|
2809
3211
|
}
|
|
2810
|
-
return
|
|
3212
|
+
return result;
|
|
2811
3213
|
}
|
|
2812
3214
|
function runAddAptPackages(input) {
|
|
2813
3215
|
if (input.packages.length === 0) {
|
|
@@ -2824,7 +3226,7 @@ async function runAddRepo(input) {
|
|
|
2824
3226
|
"Missing repo URL. Usage: monoceros add-repo <containername> <url>."
|
|
2825
3227
|
);
|
|
2826
3228
|
}
|
|
2827
|
-
const
|
|
3229
|
+
const path21 = (input.path ?? deriveRepoName(url)).trim();
|
|
2828
3230
|
const hasName = typeof input.gitName === "string" && input.gitName.trim().length > 0;
|
|
2829
3231
|
const hasEmail = typeof input.gitEmail === "string" && input.gitEmail.trim().length > 0;
|
|
2830
3232
|
if (hasName !== hasEmail) {
|
|
@@ -2853,7 +3255,7 @@ async function runAddRepo(input) {
|
|
|
2853
3255
|
const providerToWrite = !canonical && explicitProvider ? explicitProvider : void 0;
|
|
2854
3256
|
const entry2 = {
|
|
2855
3257
|
url,
|
|
2856
|
-
path:
|
|
3258
|
+
path: path21,
|
|
2857
3259
|
...hasName && hasEmail ? {
|
|
2858
3260
|
gitUser: {
|
|
2859
3261
|
name: input.gitName.trim(),
|
|
@@ -2969,7 +3371,7 @@ async function tryCloneInRunningContainer(input, entry2) {
|
|
|
2969
3371
|
logger.info(
|
|
2970
3372
|
`Cloned ${entry2.url} into /workspaces/${containerName2}/${targetRel} inside the running container.`
|
|
2971
3373
|
);
|
|
2972
|
-
void
|
|
3374
|
+
void path9;
|
|
2973
3375
|
}
|
|
2974
3376
|
function shquote(value) {
|
|
2975
3377
|
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
@@ -3046,10 +3448,33 @@ async function runAddFeature(input) {
|
|
|
3046
3448
|
...resolved.defaultOptions,
|
|
3047
3449
|
...input.options ?? {}
|
|
3048
3450
|
};
|
|
3049
|
-
|
|
3451
|
+
const result = await mutate(
|
|
3050
3452
|
input,
|
|
3051
3453
|
(doc) => addFeatureToDoc(doc, resolved.ref, merged, raw)
|
|
3052
3454
|
);
|
|
3455
|
+
if (result.status === "updated") {
|
|
3456
|
+
const summary = loadFeatureManifestSummary(resolved.ref);
|
|
3457
|
+
const vars = featureOptionHints(
|
|
3458
|
+
summary,
|
|
3459
|
+
resolved.ref,
|
|
3460
|
+
Object.keys(merged)
|
|
3461
|
+
).map((h) => h.envVar);
|
|
3462
|
+
if (vars.length > 0) {
|
|
3463
|
+
const home = input.monocerosHome ?? monocerosHome();
|
|
3464
|
+
await ensureEnvGitignored(containerConfigsDir(home));
|
|
3465
|
+
const seeded = await ensureEnvVars(
|
|
3466
|
+
containerEnvPath(input.name, home),
|
|
3467
|
+
input.name,
|
|
3468
|
+
vars
|
|
3469
|
+
);
|
|
3470
|
+
if (seeded.added.length > 0) {
|
|
3471
|
+
(input.logger ?? defaultLogger()).info(
|
|
3472
|
+
`Seeded ${seeded.added.join(", ")} into ${input.name}.env \u2014 fill in the values.`
|
|
3473
|
+
);
|
|
3474
|
+
}
|
|
3475
|
+
}
|
|
3476
|
+
}
|
|
3477
|
+
return result;
|
|
3053
3478
|
}
|
|
3054
3479
|
async function resolveFeatureRefOrShortname(input) {
|
|
3055
3480
|
if (REGEX.featureRef.test(input)) {
|
|
@@ -3609,7 +4034,7 @@ var addServiceCommand = defineCommand7({
|
|
|
3609
4034
|
meta: {
|
|
3610
4035
|
name: "add-service",
|
|
3611
4036
|
group: "edit",
|
|
3612
|
-
description: "Add a
|
|
4037
|
+
description: "Add a backing service to the container config. A curated name (postgres, mysql, redis) expands to a full editable block; any other image (e.g. rustfs/rustfs:latest) drops in name + image plus a commented scaffold. Idempotent, prints a diff before writing."
|
|
3613
4038
|
},
|
|
3614
4039
|
args: {
|
|
3615
4040
|
name: {
|
|
@@ -3619,9 +4044,13 @@ var addServiceCommand = defineCommand7({
|
|
|
3619
4044
|
},
|
|
3620
4045
|
service: {
|
|
3621
4046
|
type: "positional",
|
|
3622
|
-
description: "
|
|
4047
|
+
description: "Curated name (postgres, mysql, redis) or any image ref (e.g. rustfs/rustfs:latest).",
|
|
3623
4048
|
required: true
|
|
3624
4049
|
},
|
|
4050
|
+
as: {
|
|
4051
|
+
type: "string",
|
|
4052
|
+
description: "Override the service name (the compose service / DNS name / data dir). Lets you add the same image more than once \u2014 e.g. two postgres servers as postgres-app and postgres-analytics."
|
|
4053
|
+
},
|
|
3625
4054
|
yes: {
|
|
3626
4055
|
type: "boolean",
|
|
3627
4056
|
description: "Skip the interactive confirmation and apply the diff.",
|
|
@@ -3634,6 +4063,7 @@ var addServiceCommand = defineCommand7({
|
|
|
3634
4063
|
const result = await runAddService({
|
|
3635
4064
|
name: args.name,
|
|
3636
4065
|
service: args.service,
|
|
4066
|
+
...args.as ? { as: args.as } : {},
|
|
3637
4067
|
yes: args.yes
|
|
3638
4068
|
});
|
|
3639
4069
|
process.exit(result.status === "aborted" ? 1 : 0);
|
|
@@ -3648,12 +4078,12 @@ var addServiceCommand = defineCommand7({
|
|
|
3648
4078
|
import { defineCommand as defineCommand8 } from "citty";
|
|
3649
4079
|
|
|
3650
4080
|
// src/apply/index.ts
|
|
3651
|
-
import { existsSync as
|
|
4081
|
+
import { existsSync as existsSync8, promises as fs12 } from "fs";
|
|
3652
4082
|
import { consola as consola11 } from "consola";
|
|
3653
4083
|
|
|
3654
4084
|
// src/config/state.ts
|
|
3655
4085
|
import { promises as fs9 } from "fs";
|
|
3656
|
-
import
|
|
4086
|
+
import path10 from "path";
|
|
3657
4087
|
function buildStateFile(opts) {
|
|
3658
4088
|
return {
|
|
3659
4089
|
schemaVersion: CONFIG_SCHEMA_VERSION,
|
|
@@ -3663,7 +4093,7 @@ function buildStateFile(opts) {
|
|
|
3663
4093
|
};
|
|
3664
4094
|
}
|
|
3665
4095
|
function stateFilePath(targetDir) {
|
|
3666
|
-
return
|
|
4096
|
+
return path10.join(targetDir, ".monoceros", "state.json");
|
|
3667
4097
|
}
|
|
3668
4098
|
async function readStateFile(targetDir) {
|
|
3669
4099
|
try {
|
|
@@ -3674,7 +4104,7 @@ async function readStateFile(targetDir) {
|
|
|
3674
4104
|
}
|
|
3675
4105
|
}
|
|
3676
4106
|
async function writeStateFile(targetDir, state) {
|
|
3677
|
-
const monocerosDir =
|
|
4107
|
+
const monocerosDir = path10.join(targetDir, ".monoceros");
|
|
3678
4108
|
await fs9.mkdir(monocerosDir, { recursive: true });
|
|
3679
4109
|
await fs9.writeFile(
|
|
3680
4110
|
stateFilePath(targetDir),
|
|
@@ -3695,7 +4125,11 @@ function solutionConfigToCreateOptions(config, featureDefaults = {}) {
|
|
|
3695
4125
|
const result = {
|
|
3696
4126
|
name: config.name,
|
|
3697
4127
|
languages: [...config.languages],
|
|
3698
|
-
services
|
|
4128
|
+
// Normalize every services[] entry (curated string or explicit
|
|
4129
|
+
// object) to the canonical ResolvedService shape. `${VAR}` values
|
|
4130
|
+
// survive untouched here — apply interpolates them against
|
|
4131
|
+
// <name>.env afterwards.
|
|
4132
|
+
services: config.services.map(resolveService)
|
|
3699
4133
|
};
|
|
3700
4134
|
if (config.externalServices.postgres !== void 0) {
|
|
3701
4135
|
result.postgresUrl = config.externalServices.postgres;
|
|
@@ -3746,8 +4180,8 @@ function solutionConfigToCreateOptions(config, featureDefaults = {}) {
|
|
|
3746
4180
|
|
|
3747
4181
|
// src/devcontainer/compose.ts
|
|
3748
4182
|
import { spawn as spawn5 } from "child_process";
|
|
3749
|
-
import { existsSync as
|
|
3750
|
-
import
|
|
4183
|
+
import { existsSync as existsSync6 } from "fs";
|
|
4184
|
+
import path12 from "path";
|
|
3751
4185
|
import { consola as consola9 } from "consola";
|
|
3752
4186
|
|
|
3753
4187
|
// src/util/mask-secrets.ts
|
|
@@ -3808,9 +4242,9 @@ function createSecretMaskStream() {
|
|
|
3808
4242
|
|
|
3809
4243
|
// src/devcontainer/cli.ts
|
|
3810
4244
|
import { spawn as spawn4 } from "child_process";
|
|
3811
|
-
import { readFileSync as
|
|
4245
|
+
import { readFileSync as readFileSync4 } from "fs";
|
|
3812
4246
|
import { createRequire } from "module";
|
|
3813
|
-
import
|
|
4247
|
+
import path11 from "path";
|
|
3814
4248
|
|
|
3815
4249
|
// src/devcontainer/runtime-pull-hint.ts
|
|
3816
4250
|
import { Transform as Transform2 } from "stream";
|
|
@@ -3856,12 +4290,12 @@ var cachedBinaryPath = null;
|
|
|
3856
4290
|
function devcontainerCliPath() {
|
|
3857
4291
|
if (cachedBinaryPath) return cachedBinaryPath;
|
|
3858
4292
|
const pkgJsonPath = require_.resolve("@devcontainers/cli/package.json");
|
|
3859
|
-
const pkg = JSON.parse(
|
|
4293
|
+
const pkg = JSON.parse(readFileSync4(pkgJsonPath, "utf8"));
|
|
3860
4294
|
const binEntry = typeof pkg.bin === "string" ? pkg.bin : pkg.bin?.devcontainer ?? "";
|
|
3861
4295
|
if (!binEntry) {
|
|
3862
4296
|
throw new Error("Could not resolve @devcontainers/cli bin entry.");
|
|
3863
4297
|
}
|
|
3864
|
-
cachedBinaryPath =
|
|
4298
|
+
cachedBinaryPath = path11.resolve(path11.dirname(pkgJsonPath), binEntry);
|
|
3865
4299
|
return cachedBinaryPath;
|
|
3866
4300
|
}
|
|
3867
4301
|
var spawnDevcontainer = (args, cwd, options = {}) => {
|
|
@@ -3979,16 +4413,16 @@ async function cleanupDockerObjects(opts) {
|
|
|
3979
4413
|
return { exitCode: rmExit, removedIds: ids };
|
|
3980
4414
|
}
|
|
3981
4415
|
function composeProjectName(root) {
|
|
3982
|
-
return `${
|
|
4416
|
+
return `${path12.basename(root)}_devcontainer`;
|
|
3983
4417
|
}
|
|
3984
4418
|
function resolveCompose(root) {
|
|
3985
|
-
if (!
|
|
4419
|
+
if (!existsSync6(path12.join(root, ".devcontainer"))) {
|
|
3986
4420
|
throw new Error(
|
|
3987
4421
|
`No .devcontainer/ at ${root}. Run \`monoceros apply <name>\` first.`
|
|
3988
4422
|
);
|
|
3989
4423
|
}
|
|
3990
|
-
const composeFile =
|
|
3991
|
-
if (!
|
|
4424
|
+
const composeFile = path12.join(root, ".devcontainer", "compose.yaml");
|
|
4425
|
+
if (!existsSync6(composeFile)) {
|
|
3992
4426
|
throw new Error(
|
|
3993
4427
|
`No compose.yaml at ${composeFile}. \`start\` / \`stop\` / \`status\` / \`logs\` require services configured via \`monoceros add-service <name> <svc>\`. Use \`monoceros shell <name>\` to enter the container directly.`
|
|
3994
4428
|
);
|
|
@@ -4179,6 +4613,12 @@ function formatUnreachableReposError(failures) {
|
|
|
4179
4613
|
lines.push(headerForKind(kind));
|
|
4180
4614
|
for (const e of entries) {
|
|
4181
4615
|
lines.push(` \u2022 ${e.url}`);
|
|
4616
|
+
if (e.detail) {
|
|
4617
|
+
for (const detailLine of e.detail.split("\n")) {
|
|
4618
|
+
const trimmed = detailLine.trim();
|
|
4619
|
+
if (trimmed) lines.push(` git: ${trimmed}`);
|
|
4620
|
+
}
|
|
4621
|
+
}
|
|
4182
4622
|
}
|
|
4183
4623
|
for (const advice of adviceForKind(kind)) {
|
|
4184
4624
|
lines.push(` - ${advice}`);
|
|
@@ -4225,11 +4665,85 @@ function adviceForKind(kind) {
|
|
|
4225
4665
|
}
|
|
4226
4666
|
}
|
|
4227
4667
|
|
|
4228
|
-
// src/devcontainer/
|
|
4668
|
+
// src/devcontainer/repo-clone.ts
|
|
4229
4669
|
import { spawn as spawn7 } from "child_process";
|
|
4670
|
+
import { existsSync as existsSync7, promises as fs10 } from "fs";
|
|
4671
|
+
import path13 from "path";
|
|
4672
|
+
var realGitClone = (url, dest) => {
|
|
4673
|
+
return new Promise((resolve, reject) => {
|
|
4674
|
+
const child = spawn7("git", ["clone", "--", url, dest], {
|
|
4675
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
4676
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: "0" }
|
|
4677
|
+
});
|
|
4678
|
+
let stdout = "";
|
|
4679
|
+
let stderr = "";
|
|
4680
|
+
child.stdout.on("data", (c) => {
|
|
4681
|
+
stdout += c.toString();
|
|
4682
|
+
});
|
|
4683
|
+
child.stderr.on("data", (c) => {
|
|
4684
|
+
stderr += c.toString();
|
|
4685
|
+
});
|
|
4686
|
+
child.on("error", reject);
|
|
4687
|
+
child.on(
|
|
4688
|
+
"exit",
|
|
4689
|
+
(code) => resolve({ stdout, stderr, exitCode: code ?? 0 })
|
|
4690
|
+
);
|
|
4691
|
+
});
|
|
4692
|
+
};
|
|
4693
|
+
async function cloneReposHostSide(containerRoot, repos, options = {}) {
|
|
4694
|
+
const spawnFn = options.spawn ?? realGitClone;
|
|
4695
|
+
const results = [];
|
|
4696
|
+
for (const repo of repos) {
|
|
4697
|
+
const dest = path13.join(containerRoot, "projects", repo.path);
|
|
4698
|
+
if (existsSync7(dest)) {
|
|
4699
|
+
results.push({ path: repo.path, url: repo.url, status: "skipped" });
|
|
4700
|
+
continue;
|
|
4701
|
+
}
|
|
4702
|
+
await fs10.mkdir(path13.dirname(dest), { recursive: true });
|
|
4703
|
+
let r;
|
|
4704
|
+
try {
|
|
4705
|
+
r = await spawnFn(repo.url, dest);
|
|
4706
|
+
} catch (err) {
|
|
4707
|
+
results.push({
|
|
4708
|
+
path: repo.path,
|
|
4709
|
+
url: repo.url,
|
|
4710
|
+
status: "failed",
|
|
4711
|
+
detail: err instanceof Error ? err.message : String(err)
|
|
4712
|
+
});
|
|
4713
|
+
continue;
|
|
4714
|
+
}
|
|
4715
|
+
results.push(
|
|
4716
|
+
r.exitCode === 0 ? { path: repo.path, url: repo.url, status: "cloned" } : {
|
|
4717
|
+
path: repo.path,
|
|
4718
|
+
url: repo.url,
|
|
4719
|
+
status: "failed",
|
|
4720
|
+
detail: r.stderr.trim()
|
|
4721
|
+
}
|
|
4722
|
+
);
|
|
4723
|
+
}
|
|
4724
|
+
return results;
|
|
4725
|
+
}
|
|
4726
|
+
function formatCloneFailuresError(failures) {
|
|
4727
|
+
const lines = failures.length === 1 ? [`Failed to clone declared repo: ${failures[0].url}`, ""] : [`Failed to clone ${failures.length} declared repos:`, ""];
|
|
4728
|
+
for (const f of failures) {
|
|
4729
|
+
lines.push(` \u2022 ${f.url} \u2192 projects/${f.path}`);
|
|
4730
|
+
if (f.detail) lines.push(` ${f.detail}`);
|
|
4731
|
+
}
|
|
4732
|
+
lines.push("");
|
|
4733
|
+
lines.push(
|
|
4734
|
+
"Reachability was confirmed earlier, so this is usually a local issue"
|
|
4735
|
+
);
|
|
4736
|
+
lines.push(
|
|
4737
|
+
"(disk space, a leftover non-empty target dir). Fix it and re-run " + cyan2("monoceros apply") + "."
|
|
4738
|
+
);
|
|
4739
|
+
return lines.join("\n");
|
|
4740
|
+
}
|
|
4741
|
+
|
|
4742
|
+
// src/devcontainer/docker-mode.ts
|
|
4743
|
+
import { spawn as spawn8 } from "child_process";
|
|
4230
4744
|
var realDockerInfo = () => {
|
|
4231
4745
|
return new Promise((resolve, reject) => {
|
|
4232
|
-
const child =
|
|
4746
|
+
const child = spawn8(
|
|
4233
4747
|
"docker",
|
|
4234
4748
|
["info", "--format", "{{json .SecurityOptions}}"],
|
|
4235
4749
|
{
|
|
@@ -4288,13 +4802,13 @@ function formatRootlessNotSupportedError() {
|
|
|
4288
4802
|
}
|
|
4289
4803
|
|
|
4290
4804
|
// src/devcontainer/identity.ts
|
|
4291
|
-
import { spawn as
|
|
4292
|
-
import { promises as
|
|
4293
|
-
import
|
|
4805
|
+
import { spawn as spawn9 } from "child_process";
|
|
4806
|
+
import { promises as fs11 } from "fs";
|
|
4807
|
+
import path14 from "path";
|
|
4294
4808
|
import { consola as consola10 } from "consola";
|
|
4295
4809
|
var realGitConfigGet = (key) => {
|
|
4296
4810
|
return new Promise((resolve, reject) => {
|
|
4297
|
-
const child =
|
|
4811
|
+
const child = spawn9("git", ["config", "--global", "--get", key], {
|
|
4298
4812
|
stdio: ["ignore", "pipe", "inherit"]
|
|
4299
4813
|
});
|
|
4300
4814
|
let stdout = "";
|
|
@@ -4404,8 +4918,8 @@ async function resolveIdentityWithPrompt(options = {}) {
|
|
|
4404
4918
|
};
|
|
4405
4919
|
}
|
|
4406
4920
|
async function collectGitIdentity(devContainerRoot, options = {}) {
|
|
4407
|
-
const gitconfigDir =
|
|
4408
|
-
const gitconfigPath =
|
|
4921
|
+
const gitconfigDir = path14.join(devContainerRoot, ".monoceros");
|
|
4922
|
+
const gitconfigPath = path14.join(gitconfigDir, "gitconfig");
|
|
4409
4923
|
const logger = options.logger ?? { info: () => {
|
|
4410
4924
|
}, warn: () => {
|
|
4411
4925
|
} };
|
|
@@ -4418,8 +4932,8 @@ async function collectGitIdentity(devContainerRoot, options = {}) {
|
|
|
4418
4932
|
const lines = ["[user]"];
|
|
4419
4933
|
if (resolved.name !== void 0) lines.push(` name = ${resolved.name}`);
|
|
4420
4934
|
if (resolved.email !== void 0) lines.push(` email = ${resolved.email}`);
|
|
4421
|
-
await
|
|
4422
|
-
await
|
|
4935
|
+
await fs11.mkdir(gitconfigDir, { recursive: true });
|
|
4936
|
+
await fs11.writeFile(gitconfigPath, lines.join("\n") + "\n");
|
|
4423
4937
|
return {
|
|
4424
4938
|
...resolved.name !== void 0 ? { name: resolved.name } : {},
|
|
4425
4939
|
...resolved.email !== void 0 ? { email: resolved.email } : {},
|
|
@@ -4462,7 +4976,7 @@ async function readKeyFromHost(spawnFn, key, logger) {
|
|
|
4462
4976
|
}
|
|
4463
4977
|
async function readExistingGitconfig(filePath) {
|
|
4464
4978
|
try {
|
|
4465
|
-
const content = await
|
|
4979
|
+
const content = await fs11.readFile(filePath, "utf8");
|
|
4466
4980
|
const result = {};
|
|
4467
4981
|
const nameMatch = /^\s*name\s*=\s*(.+?)\s*$/m.exec(content);
|
|
4468
4982
|
const emailMatch = /^\s*email\s*=\s*(.+?)\s*$/m.exec(content);
|
|
@@ -4495,7 +5009,7 @@ ${sectionLine(label)}
|
|
|
4495
5009
|
);
|
|
4496
5010
|
}
|
|
4497
5011
|
const ymlPath = containerConfigPath(opts.name, home);
|
|
4498
|
-
if (!
|
|
5012
|
+
if (!existsSync8(ymlPath)) {
|
|
4499
5013
|
throw new Error(
|
|
4500
5014
|
`No such config: ${ymlPath}. Run \`monoceros init <template> ${opts.name}\` first.`
|
|
4501
5015
|
);
|
|
@@ -4512,6 +5026,20 @@ ${sectionLine(label)}
|
|
|
4512
5026
|
globalConfig?.defaults?.features ?? {}
|
|
4513
5027
|
)
|
|
4514
5028
|
);
|
|
5029
|
+
const envPath = containerEnvPath(opts.name, home);
|
|
5030
|
+
await ensureEnvGitignored(containerConfigsDir(home));
|
|
5031
|
+
const envVars = readEnvFile(envPath);
|
|
5032
|
+
const interpServices = interpolateServices(createOpts.services, envVars);
|
|
5033
|
+
const interpFeatures = interpolateFeatures(
|
|
5034
|
+
createOpts.features ?? {},
|
|
5035
|
+
envVars
|
|
5036
|
+
);
|
|
5037
|
+
const missingVars = [...interpServices.missing, ...interpFeatures.missing];
|
|
5038
|
+
if (missingVars.length > 0) {
|
|
5039
|
+
throw new Error(formatMissingVarsError(missingVars, prettyPath(envPath)));
|
|
5040
|
+
}
|
|
5041
|
+
createOpts.services = interpServices.services;
|
|
5042
|
+
if (createOpts.features) createOpts.features = interpFeatures.features;
|
|
4515
5043
|
validateOptions(createOpts);
|
|
4516
5044
|
logger.success(`yml validated ${dim(`(${prettyPath(ymlPath)})`)}`);
|
|
4517
5045
|
const hasRepos = (createOpts.repos ?? []).length > 0;
|
|
@@ -4566,7 +5094,7 @@ ${sectionLine(label)}
|
|
|
4566
5094
|
if (dockerMode === "rootless") {
|
|
4567
5095
|
throw new Error(formatRootlessNotSupportedError());
|
|
4568
5096
|
}
|
|
4569
|
-
await
|
|
5097
|
+
await fs12.mkdir(targetDir, { recursive: true });
|
|
4570
5098
|
await writeScaffold(createOpts, targetDir, { dockerMode });
|
|
4571
5099
|
await writeStateFile(
|
|
4572
5100
|
targetDir,
|
|
@@ -4577,6 +5105,23 @@ ${sectionLine(label)}
|
|
|
4577
5105
|
})
|
|
4578
5106
|
);
|
|
4579
5107
|
logger.success(`materialized into ${prettyPath(targetDir)}`);
|
|
5108
|
+
const reposToClone = createOpts.repos ?? [];
|
|
5109
|
+
if (reposToClone.length > 0) {
|
|
5110
|
+
const cloneResults = await cloneReposHostSide(targetDir, reposToClone, {
|
|
5111
|
+
...opts.cloneSpawn ? { spawn: opts.cloneSpawn } : {}
|
|
5112
|
+
});
|
|
5113
|
+
for (const r of cloneResults) {
|
|
5114
|
+
if (r.status === "cloned") {
|
|
5115
|
+
logger.success(`cloned ${cyan2(r.path)} ${dim(`(${r.url})`)}`);
|
|
5116
|
+
} else if (r.status === "skipped") {
|
|
5117
|
+
logger.info(`projects/${r.path} already present \u2014 skipped clone`);
|
|
5118
|
+
}
|
|
5119
|
+
}
|
|
5120
|
+
const cloneFailures = cloneResults.filter((r) => r.status === "failed");
|
|
5121
|
+
if (cloneFailures.length > 0) {
|
|
5122
|
+
throw new Error(formatCloneFailuresError(cloneFailures));
|
|
5123
|
+
}
|
|
5124
|
+
}
|
|
4580
5125
|
section("Container");
|
|
4581
5126
|
const featureRefs = parsed.config.features.map((f) => f.ref);
|
|
4582
5127
|
if (featureRefs.length > 0) {
|
|
@@ -4624,8 +5169,8 @@ ${sectionLine(label)}
|
|
|
4624
5169
|
return { targetDir, configPath: ymlPath, containerExitCode: exitCode };
|
|
4625
5170
|
}
|
|
4626
5171
|
async function assertSafeTargetDir(targetDir, expectedOrigin) {
|
|
4627
|
-
if (!
|
|
4628
|
-
const entries = await
|
|
5172
|
+
if (!existsSync8(targetDir)) return;
|
|
5173
|
+
const entries = await fs12.readdir(targetDir);
|
|
4629
5174
|
if (entries.length === 0) return;
|
|
4630
5175
|
const state = await readStateFile(targetDir);
|
|
4631
5176
|
if (state) {
|
|
@@ -4695,7 +5240,7 @@ async function persistPromptedIdentity(prompted, ymlPath, home, logger) {
|
|
|
4695
5240
|
}
|
|
4696
5241
|
if (wantContainer) {
|
|
4697
5242
|
try {
|
|
4698
|
-
const text = await
|
|
5243
|
+
const text = await fs12.readFile(ymlPath, "utf8");
|
|
4699
5244
|
const parsed = parseConfig(text, ymlPath);
|
|
4700
5245
|
const changed = setContainerGitUserInDoc(parsed.doc, {
|
|
4701
5246
|
name: prompted.name,
|
|
@@ -4703,7 +5248,7 @@ async function persistPromptedIdentity(prompted, ymlPath, home, logger) {
|
|
|
4703
5248
|
});
|
|
4704
5249
|
if (changed) {
|
|
4705
5250
|
const out = stringifyConfig(parsed.doc);
|
|
4706
|
-
await
|
|
5251
|
+
await fs12.writeFile(ymlPath, out, "utf8");
|
|
4707
5252
|
logger.info(
|
|
4708
5253
|
`Saved identity in this container \u2014 wrote git.user into ${prettyPath(ymlPath)}.`
|
|
4709
5254
|
);
|
|
@@ -4717,7 +5262,7 @@ async function persistPromptedIdentity(prompted, ymlPath, home, logger) {
|
|
|
4717
5262
|
}
|
|
4718
5263
|
|
|
4719
5264
|
// src/version.ts
|
|
4720
|
-
var CLI_VERSION = true ? "1.
|
|
5265
|
+
var CLI_VERSION = true ? "1.13.1" : "dev";
|
|
4721
5266
|
|
|
4722
5267
|
// src/commands/_dispatch.ts
|
|
4723
5268
|
import { consola as consola12 } from "consola";
|
|
@@ -4877,8 +5422,8 @@ var completionCommand = defineCommand9({
|
|
|
4877
5422
|
import { defineCommand as defineCommand10 } from "citty";
|
|
4878
5423
|
|
|
4879
5424
|
// src/completion/resolve.ts
|
|
4880
|
-
import { existsSync as
|
|
4881
|
-
import
|
|
5425
|
+
import { existsSync as existsSync9, promises as fs13 } from "fs";
|
|
5426
|
+
import path15 from "path";
|
|
4882
5427
|
async function resolveCompletions(line, point, opts = {}) {
|
|
4883
5428
|
const { prev, current } = parseCompletionLine(line, point);
|
|
4884
5429
|
const ctx = { prev, current, opts };
|
|
@@ -5026,15 +5571,11 @@ function filterPrefix(values, fragment) {
|
|
|
5026
5571
|
}
|
|
5027
5572
|
async function listContainerNames(ctx) {
|
|
5028
5573
|
const home = ctx.opts.monocerosHome ?? monocerosHome();
|
|
5029
|
-
const dir =
|
|
5030
|
-
if (!
|
|
5031
|
-
const entries = await
|
|
5574
|
+
const dir = path15.join(home, "container-configs");
|
|
5575
|
+
if (!existsSync9(dir)) return [];
|
|
5576
|
+
const entries = await fs13.readdir(dir);
|
|
5032
5577
|
return entries.filter((e) => e.endsWith(".yml")).map((e) => e.slice(0, -".yml".length)).sort();
|
|
5033
5578
|
}
|
|
5034
|
-
async function listAllCatalogComponents() {
|
|
5035
|
-
const catalog = await loadComponentCatalog();
|
|
5036
|
-
return [...catalog.keys()].sort();
|
|
5037
|
-
}
|
|
5038
5579
|
async function listFeatureComponents() {
|
|
5039
5580
|
const catalog = await loadComponentCatalog();
|
|
5040
5581
|
return [...catalog.values()].filter((c) => c.file.category === "feature").map((c) => c.name).sort();
|
|
@@ -5136,8 +5677,14 @@ var COMMAND_SPECS = {
|
|
|
5136
5677
|
// flag suggestions.
|
|
5137
5678
|
positionalCount: 1,
|
|
5138
5679
|
flags: {
|
|
5139
|
-
"--with": { type: "value", values: () =>
|
|
5140
|
-
"--with-
|
|
5680
|
+
"--with-languages": { type: "value", values: () => listLanguageNames() },
|
|
5681
|
+
"--with-features": {
|
|
5682
|
+
type: "value",
|
|
5683
|
+
values: () => listFeatureComponents()
|
|
5684
|
+
},
|
|
5685
|
+
"--with-services": { type: "value", values: () => listServiceNames() },
|
|
5686
|
+
"--with-apt-packages": { type: "value" },
|
|
5687
|
+
"--with-repos": { type: "value" },
|
|
5141
5688
|
"--with-ports": { type: "value" }
|
|
5142
5689
|
}
|
|
5143
5690
|
},
|
|
@@ -5281,22 +5828,22 @@ import { defineCommand as defineCommand11 } from "citty";
|
|
|
5281
5828
|
import { consola as consola14 } from "consola";
|
|
5282
5829
|
|
|
5283
5830
|
// src/init/index.ts
|
|
5284
|
-
import { existsSync as
|
|
5831
|
+
import { existsSync as existsSync10, promises as fs14 } from "fs";
|
|
5832
|
+
import path16 from "path";
|
|
5285
5833
|
import { consola as consola13 } from "consola";
|
|
5286
5834
|
|
|
5287
5835
|
// src/init/generator.ts
|
|
5288
5836
|
var SCHEMA_HEADER_ACTIVE = "# Solution-config \u2014 describes what should be inside your dev-container.\n# Edit any section, then run `monoceros apply <name>` to (re-)build.";
|
|
5289
5837
|
var SCHEMA_HEADER_DOCUMENTED = "# Solution-config \u2014 describes what should be inside your dev-container.\n# Every section is commented out by default; un-comment what you need\n# (strip one `#` per line of the block), then run `monoceros apply <name>`.";
|
|
5290
5838
|
var COMMENT_WIDTH = 76;
|
|
5291
|
-
function generateComposedYml(name,
|
|
5292
|
-
const merged = mergeComponents(components);
|
|
5839
|
+
function generateComposedYml(name, composed, lookupManifest, repoUrls = [], ports = []) {
|
|
5293
5840
|
const lines = [];
|
|
5294
5841
|
pushHeader(lines, SCHEMA_HEADER_ACTIVE, name);
|
|
5295
5842
|
lines.push("");
|
|
5296
5843
|
lines.push("schemaVersion: 1");
|
|
5297
5844
|
lines.push(`name: ${name}`);
|
|
5298
5845
|
lines.push("");
|
|
5299
|
-
if (
|
|
5846
|
+
if (composed.languages.length > 0) {
|
|
5300
5847
|
pushSectionHeader(
|
|
5301
5848
|
lines,
|
|
5302
5849
|
LANGUAGES_HEADER,
|
|
@@ -5304,10 +5851,21 @@ function generateComposedYml(name, components, lookupManifest, repoUrls = [], po
|
|
|
5304
5851
|
false
|
|
5305
5852
|
);
|
|
5306
5853
|
lines.push("languages:");
|
|
5307
|
-
for (const lang of
|
|
5854
|
+
for (const lang of composed.languages) lines.push(` - ${lang}`);
|
|
5855
|
+
lines.push("");
|
|
5856
|
+
}
|
|
5857
|
+
if (composed.aptPackages.length > 0) {
|
|
5858
|
+
pushSectionHeader(
|
|
5859
|
+
lines,
|
|
5860
|
+
APT_PACKAGES_HEADER,
|
|
5861
|
+
/* commented */
|
|
5862
|
+
false
|
|
5863
|
+
);
|
|
5864
|
+
lines.push("aptPackages:");
|
|
5865
|
+
for (const pkg of composed.aptPackages) lines.push(` - ${pkg}`);
|
|
5308
5866
|
lines.push("");
|
|
5309
5867
|
}
|
|
5310
|
-
if (
|
|
5868
|
+
if (composed.services.length > 0) {
|
|
5311
5869
|
pushSectionHeader(
|
|
5312
5870
|
lines,
|
|
5313
5871
|
SERVICES_HEADER,
|
|
@@ -5315,10 +5873,10 @@ function generateComposedYml(name, components, lookupManifest, repoUrls = [], po
|
|
|
5315
5873
|
false
|
|
5316
5874
|
);
|
|
5317
5875
|
lines.push("services:");
|
|
5318
|
-
for (const svc of
|
|
5876
|
+
for (const svc of composed.services) pushServiceEntry(lines, svc);
|
|
5319
5877
|
lines.push("");
|
|
5320
5878
|
}
|
|
5321
|
-
if (
|
|
5879
|
+
if (composed.features.length > 0) {
|
|
5322
5880
|
pushSectionHeader(
|
|
5323
5881
|
lines,
|
|
5324
5882
|
FEATURES_HEADER_ACTIVE,
|
|
@@ -5326,7 +5884,7 @@ function generateComposedYml(name, components, lookupManifest, repoUrls = [], po
|
|
|
5326
5884
|
false
|
|
5327
5885
|
);
|
|
5328
5886
|
lines.push("features:");
|
|
5329
|
-
for (const f of
|
|
5887
|
+
for (const f of composed.features) {
|
|
5330
5888
|
lines.push("");
|
|
5331
5889
|
renderFeatureBlock(
|
|
5332
5890
|
lines,
|
|
@@ -5407,7 +5965,9 @@ function generateDocumentedYml(name, catalog, lookupManifest, repoUrls = [], por
|
|
|
5407
5965
|
lines.push("# services:");
|
|
5408
5966
|
for (const c of byCategory.service) {
|
|
5409
5967
|
for (const svc of c.file.contributes.services ?? []) {
|
|
5410
|
-
|
|
5968
|
+
const body = renderServiceObjectBody(expandCuratedService(svc));
|
|
5969
|
+
lines.push(`# - ${body[0]}`);
|
|
5970
|
+
for (const line of body.slice(1)) lines.push(`# ${line}`);
|
|
5411
5971
|
}
|
|
5412
5972
|
}
|
|
5413
5973
|
lines.push("");
|
|
@@ -5514,6 +6074,22 @@ function generateDocumentedYml(name, catalog, lookupManifest, repoUrls = [], por
|
|
|
5514
6074
|
}
|
|
5515
6075
|
var LANGUAGES_HEADER = "Language runtimes installed inside the dev-container. Pick the ones your projects build against. The catalog of available runtimes is shown by `monoceros list-components`.";
|
|
5516
6076
|
var SERVICES_HEADER = "Sibling containers that run alongside the dev-container (databases, caches, message queues, \u2026). Each service is reachable from inside the dev-container by its name as hostname (e.g. `postgres://postgres:5432`). Activating any service switches the container to docker-compose mode automatically.";
|
|
6077
|
+
var APT_PACKAGES_HEADER = "Debian/Ubuntu apt packages installed in the dev-container at build time. No curated list \u2014 any apt package name works; an invalid name surfaces as an apt error during build.";
|
|
6078
|
+
function pushServiceEntry(out, svc) {
|
|
6079
|
+
if (svc.kind === "custom") {
|
|
6080
|
+
const { bodyLines, comment } = renderCustomService(
|
|
6081
|
+
svc.name,
|
|
6082
|
+
svc.image ?? ""
|
|
6083
|
+
);
|
|
6084
|
+
out.push(` - ${bodyLines[0]}`);
|
|
6085
|
+
for (const line of bodyLines.slice(1)) out.push(` ${line}`);
|
|
6086
|
+
for (const cl of comment.split("\n")) out.push(` #${cl}`);
|
|
6087
|
+
return;
|
|
6088
|
+
}
|
|
6089
|
+
const body = renderServiceObjectBody(expandCuratedService(svc.name));
|
|
6090
|
+
out.push(` - ${body[0]}`);
|
|
6091
|
+
for (const line of body.slice(1)) out.push(` ${line}`);
|
|
6092
|
+
}
|
|
5517
6093
|
var FEATURES_HEADER_ACTIVE = "A Monoceros dev-container is shaped by features \u2014 pluggable units that drop tooling (AI assistants, language CLIs, cloud SDKs, \u2026) into the container and bring their own options. The features active for this container are listed below; adjust their options as needed. Shared credentials used across containers belong in monoceros-config.yml under `defaults.features.<ref>` rather than here. Full catalog: `monoceros list-components`.";
|
|
5518
6094
|
var FEATURES_HEADER_DOCUMENTED = "A Monoceros dev-container is shaped by features \u2014 pluggable units that drop tooling (AI assistants, language CLIs, cloud SDKs, \u2026) into the container and bring their own options. Un-comment the blocks below for the features you want active. Shared credentials used across containers belong in monoceros-config.yml under `defaults.features.<ref>` rather than here. Full catalog: `monoceros list-components`.";
|
|
5519
6095
|
var REPOS_HEADER = "Git repositories cloned into `projects/` on container start-up. HTTPS URLs only. The provider is auto-detected for github.com / gitlab.com / bitbucket.org; for any other host (self-hosted GitLab, Gitea, \u2026) declare `provider:` explicitly. Add more later with `monoceros add-repo`.";
|
|
@@ -5528,15 +6104,15 @@ function renderFeatureBlock(out, feature, summary, commented) {
|
|
|
5528
6104
|
out.push(`${yamlPrefix} - ref: ${feature.ref}`);
|
|
5529
6105
|
const options = feature.options ?? {};
|
|
5530
6106
|
const activeKeys = Object.entries(options);
|
|
5531
|
-
const
|
|
5532
|
-
if (activeKeys.length === 0 &&
|
|
6107
|
+
const hints = featureOptionHints(summary, feature.ref, Object.keys(options));
|
|
6108
|
+
if (activeKeys.length === 0 && hints.length === 0) return;
|
|
5533
6109
|
if (commented) {
|
|
5534
6110
|
out.push(`${yamlPrefix} options:`);
|
|
5535
6111
|
for (const [key, value] of activeKeys) {
|
|
5536
6112
|
out.push(`${yamlPrefix} ${key}: ${renderScalarValue(value)}`);
|
|
5537
6113
|
}
|
|
5538
|
-
for (const
|
|
5539
|
-
out.push(`${yamlPrefix} ${key}
|
|
6114
|
+
for (const hint of hints) {
|
|
6115
|
+
out.push(`${yamlPrefix} ${hint.key}: ${hint.placeholder}`);
|
|
5540
6116
|
}
|
|
5541
6117
|
return;
|
|
5542
6118
|
}
|
|
@@ -5546,10 +6122,10 @@ function renderFeatureBlock(out, feature, summary, commented) {
|
|
|
5546
6122
|
out.push(` ${key}: ${renderScalarValue(value)}`);
|
|
5547
6123
|
}
|
|
5548
6124
|
}
|
|
5549
|
-
if (
|
|
6125
|
+
if (hints.length > 0) {
|
|
5550
6126
|
out.push(` # options:`);
|
|
5551
|
-
for (const
|
|
5552
|
-
out.push(` # ${key}
|
|
6127
|
+
for (const hint of hints) {
|
|
6128
|
+
out.push(` # ${hint.key}: ${hint.placeholder}`);
|
|
5553
6129
|
}
|
|
5554
6130
|
}
|
|
5555
6131
|
}
|
|
@@ -5603,7 +6179,7 @@ async function runInit(opts) {
|
|
|
5603
6179
|
);
|
|
5604
6180
|
}
|
|
5605
6181
|
const dest = containerConfigPath(opts.name, home);
|
|
5606
|
-
if (
|
|
6182
|
+
if (existsSync10(dest)) {
|
|
5607
6183
|
throw new Error(
|
|
5608
6184
|
`Config already exists: ${dest}. Delete it manually before re-running \`monoceros init\` \u2014 this protects any hand-edits.`
|
|
5609
6185
|
);
|
|
@@ -5673,15 +6249,38 @@ async function runInit(opts) {
|
|
|
5673
6249
|
});
|
|
5674
6250
|
}
|
|
5675
6251
|
let text;
|
|
5676
|
-
const
|
|
5677
|
-
|
|
6252
|
+
const composed = resolveComposedInit(catalog, {
|
|
6253
|
+
languages: opts.languages ?? [],
|
|
6254
|
+
features: opts.features ?? [],
|
|
6255
|
+
services: opts.services ?? [],
|
|
6256
|
+
aptPackages: opts.aptPackages ?? []
|
|
6257
|
+
});
|
|
6258
|
+
const anyComposed = composed.languages.length > 0 || composed.features.length > 0 || composed.services.length > 0 || composed.aptPackages.length > 0;
|
|
6259
|
+
if (!anyComposed) {
|
|
5678
6260
|
text = generateDocumentedYml(opts.name, catalog, lookup, repos, ports);
|
|
5679
6261
|
} else {
|
|
5680
|
-
|
|
5681
|
-
|
|
6262
|
+
text = generateComposedYml(opts.name, composed, lookup, repos, ports);
|
|
6263
|
+
}
|
|
6264
|
+
await fs14.mkdir(containerConfigsDir(home), { recursive: true });
|
|
6265
|
+
await ensureEnvGitignored(containerConfigsDir(home));
|
|
6266
|
+
await fs14.writeFile(dest, text, "utf8");
|
|
6267
|
+
const envPath = containerEnvPath(opts.name, home);
|
|
6268
|
+
const seedVars = {};
|
|
6269
|
+
for (const f of composed.features) {
|
|
6270
|
+
for (const h of featureOptionHints(
|
|
6271
|
+
lookup(f.ref),
|
|
6272
|
+
f.ref,
|
|
6273
|
+
Object.keys(f.options ?? {})
|
|
6274
|
+
)) {
|
|
6275
|
+
if (!(h.envVar in seedVars)) seedVars[h.envVar] = "";
|
|
6276
|
+
}
|
|
6277
|
+
}
|
|
6278
|
+
for (const svc of composed.services) {
|
|
6279
|
+
if (svc.kind === "curated") {
|
|
6280
|
+
Object.assign(seedVars, curatedServiceEnvDefaults(svc.name));
|
|
6281
|
+
}
|
|
5682
6282
|
}
|
|
5683
|
-
await
|
|
5684
|
-
await fs13.writeFile(dest, text, "utf8");
|
|
6283
|
+
await ensureEnvVars(envPath, opts.name, seedVars);
|
|
5685
6284
|
if (promptedIdentity?.prompted) {
|
|
5686
6285
|
const { name, email, scope } = promptedIdentity.prompted;
|
|
5687
6286
|
if (scope === "g" || scope === "b") {
|
|
@@ -5711,11 +6310,11 @@ async function runInit(opts) {
|
|
|
5711
6310
|
}
|
|
5712
6311
|
if (scope === "c" || scope === "b") {
|
|
5713
6312
|
try {
|
|
5714
|
-
const written = await
|
|
6313
|
+
const written = await fs14.readFile(dest, "utf8");
|
|
5715
6314
|
const parsed = parseConfig(written, dest);
|
|
5716
6315
|
const changed = setContainerGitUserInDoc(parsed.doc, { name, email });
|
|
5717
6316
|
if (changed) {
|
|
5718
|
-
await
|
|
6317
|
+
await fs14.writeFile(dest, stringifyConfig(parsed.doc), "utf8");
|
|
5719
6318
|
logger.info(
|
|
5720
6319
|
`Saved identity in ${prettyPath(dest)} (container-level git.user).`
|
|
5721
6320
|
);
|
|
@@ -5727,29 +6326,136 @@ async function runInit(opts) {
|
|
|
5727
6326
|
}
|
|
5728
6327
|
}
|
|
5729
6328
|
}
|
|
5730
|
-
const documented =
|
|
5731
|
-
const
|
|
6329
|
+
const documented = !anyComposed;
|
|
6330
|
+
const ymlRel = path16.relative(home, dest);
|
|
6331
|
+
const envRel = path16.relative(home, envPath);
|
|
5732
6332
|
if (documented) {
|
|
5733
|
-
logger.success(
|
|
5734
|
-
|
|
6333
|
+
logger.success(`Wrote documented default to ${ymlRel} and ${envRel}.`);
|
|
6334
|
+
logger.info(
|
|
6335
|
+
`Un-comment what you need, then \`monoceros apply ${opts.name}\`.`
|
|
5735
6336
|
);
|
|
5736
6337
|
} else {
|
|
5737
|
-
logger.success(
|
|
5738
|
-
`Composed ${requested.length} component(s) into ${displayPath}: ${requested.join(", ")}`
|
|
5739
|
-
);
|
|
6338
|
+
logger.success(`Composed into ${ymlRel} and ${envRel}.`);
|
|
5740
6339
|
logger.info(
|
|
5741
|
-
`Edit the
|
|
6340
|
+
`Edit the files if you need to tweak, then \`monoceros apply ${opts.name}\`.`
|
|
5742
6341
|
);
|
|
5743
6342
|
}
|
|
5744
6343
|
return { configPath: dest, documented };
|
|
5745
6344
|
}
|
|
6345
|
+
function resolveComposedInit(catalog, raw) {
|
|
6346
|
+
return {
|
|
6347
|
+
languages: resolveInitLanguages(raw.languages),
|
|
6348
|
+
aptPackages: resolveInitAptPackages(raw.aptPackages),
|
|
6349
|
+
services: resolveInitServices(raw.services),
|
|
6350
|
+
features: resolveInitFeatures(catalog, raw.features)
|
|
6351
|
+
};
|
|
6352
|
+
}
|
|
6353
|
+
function resolveInitLanguages(entries) {
|
|
6354
|
+
const known = new Set(knownLanguages());
|
|
6355
|
+
const out = [];
|
|
6356
|
+
const seen = /* @__PURE__ */ new Set();
|
|
6357
|
+
const unknown = [];
|
|
6358
|
+
for (const raw of entries) {
|
|
6359
|
+
const e = raw.trim();
|
|
6360
|
+
if (!e || seen.has(e)) continue;
|
|
6361
|
+
const spec = parseLanguageSpec(e);
|
|
6362
|
+
if (!spec || !known.has(spec.name)) {
|
|
6363
|
+
unknown.push(e);
|
|
6364
|
+
continue;
|
|
6365
|
+
}
|
|
6366
|
+
seen.add(e);
|
|
6367
|
+
out.push(e);
|
|
6368
|
+
}
|
|
6369
|
+
if (unknown.length > 0) {
|
|
6370
|
+
throw new Error(
|
|
6371
|
+
`Unknown language${unknown.length > 1 ? "s" : ""}: ${unknown.join(", ")}. Known: ${knownLanguages().join(", ")}.`
|
|
6372
|
+
);
|
|
6373
|
+
}
|
|
6374
|
+
return out;
|
|
6375
|
+
}
|
|
6376
|
+
function resolveInitAptPackages(entries) {
|
|
6377
|
+
const out = [];
|
|
6378
|
+
const seen = /* @__PURE__ */ new Set();
|
|
6379
|
+
const bad = [];
|
|
6380
|
+
for (const raw of entries) {
|
|
6381
|
+
const e = raw.trim();
|
|
6382
|
+
if (!e || seen.has(e)) continue;
|
|
6383
|
+
if (!REGEX.aptPackage.test(e)) {
|
|
6384
|
+
bad.push(e);
|
|
6385
|
+
continue;
|
|
6386
|
+
}
|
|
6387
|
+
seen.add(e);
|
|
6388
|
+
out.push(e);
|
|
6389
|
+
}
|
|
6390
|
+
if (bad.length > 0) {
|
|
6391
|
+
throw new Error(
|
|
6392
|
+
`Invalid apt package name${bad.length > 1 ? "s" : ""}: ${bad.join(", ")}. Expected lowercase alphanumeric plus '.+-'.`
|
|
6393
|
+
);
|
|
6394
|
+
}
|
|
6395
|
+
return out;
|
|
6396
|
+
}
|
|
6397
|
+
function resolveInitServices(entries) {
|
|
6398
|
+
const out = [];
|
|
6399
|
+
const byName = /* @__PURE__ */ new Map();
|
|
6400
|
+
for (const raw of entries) {
|
|
6401
|
+
const e = raw.trim();
|
|
6402
|
+
if (!e) continue;
|
|
6403
|
+
const svc = isCuratedService(e) ? { kind: "curated", name: e } : { kind: "custom", name: deriveServiceName(e), image: e };
|
|
6404
|
+
const existing = byName.get(svc.name);
|
|
6405
|
+
if (existing) {
|
|
6406
|
+
if (existing.kind === svc.kind && existing.image === svc.image) continue;
|
|
6407
|
+
throw new Error(
|
|
6408
|
+
`Two --with-services entries resolve to the service name '${svc.name}'. Add one after init with \`monoceros add-service ${"<name>"} <image> --as=<other>\`.`
|
|
6409
|
+
);
|
|
6410
|
+
}
|
|
6411
|
+
byName.set(svc.name, svc);
|
|
6412
|
+
out.push(svc);
|
|
6413
|
+
}
|
|
6414
|
+
return out;
|
|
6415
|
+
}
|
|
6416
|
+
function resolveInitFeatures(catalog, entries) {
|
|
6417
|
+
const byRef = /* @__PURE__ */ new Map();
|
|
6418
|
+
const unknown = [];
|
|
6419
|
+
for (const raw of entries) {
|
|
6420
|
+
const e = raw.trim();
|
|
6421
|
+
if (!e) continue;
|
|
6422
|
+
if (REGEX.featureRef.test(e)) {
|
|
6423
|
+
if (!byRef.has(e)) byRef.set(e, { ref: e, options: {} });
|
|
6424
|
+
continue;
|
|
6425
|
+
}
|
|
6426
|
+
const c = catalog.get(e);
|
|
6427
|
+
if (!c || c.file.category !== "feature") {
|
|
6428
|
+
unknown.push(e);
|
|
6429
|
+
continue;
|
|
6430
|
+
}
|
|
6431
|
+
for (const f of c.file.contributes.features ?? []) {
|
|
6432
|
+
const existing = byRef.get(f.ref);
|
|
6433
|
+
if (!existing) {
|
|
6434
|
+
byRef.set(f.ref, { ref: f.ref, options: { ...f.options ?? {} } });
|
|
6435
|
+
} else {
|
|
6436
|
+
existing.options = mergeFeatureOptions(
|
|
6437
|
+
existing.options,
|
|
6438
|
+
f.options ?? {}
|
|
6439
|
+
);
|
|
6440
|
+
}
|
|
6441
|
+
}
|
|
6442
|
+
}
|
|
6443
|
+
if (unknown.length > 0) {
|
|
6444
|
+
const featureNames = [...catalog.values()].filter((c) => c.file.category === "feature").map((c) => c.name).sort();
|
|
6445
|
+
throw new Error(
|
|
6446
|
+
`Unknown feature${unknown.length > 1 ? "s" : ""}: ${unknown.join(", ")}.
|
|
6447
|
+
Use a catalog short name (${featureNames.join(", ")}) or a full OCI ref (ghcr.io/\u2026/<name>:<tag>).`
|
|
6448
|
+
);
|
|
6449
|
+
}
|
|
6450
|
+
return [...byRef.values()];
|
|
6451
|
+
}
|
|
5746
6452
|
|
|
5747
6453
|
// src/commands/init.ts
|
|
5748
6454
|
var initCommand = defineCommand11({
|
|
5749
6455
|
meta: {
|
|
5750
6456
|
name: "init",
|
|
5751
6457
|
group: "lifecycle",
|
|
5752
|
-
description: "Create a fresh container-config yml at
|
|
6458
|
+
description: "Create a fresh container-config yml at <MONOCEROS_HOME>/container-configs/<name>.yml. Without any --with-* flag, the file is a documented default with every component commented out. With --with-languages / --with-features / --with-services / --with-apt-packages, the named pieces are composed into an active, immediately-applyable yml. Then run `monoceros apply <name>`."
|
|
5753
6459
|
},
|
|
5754
6460
|
args: {
|
|
5755
6461
|
name: {
|
|
@@ -5757,14 +6463,29 @@ var initCommand = defineCommand11({
|
|
|
5757
6463
|
description: "Config name. The yml lands at <MONOCEROS_HOME>/container-configs/<name>.yml and becomes the source-of-truth for `monoceros apply <name>`.",
|
|
5758
6464
|
required: true
|
|
5759
6465
|
},
|
|
5760
|
-
with: {
|
|
6466
|
+
"with-languages": {
|
|
6467
|
+
type: "string",
|
|
6468
|
+
description: "Language runtimes to install, comma-separated or repeated, e.g. --with-languages=java,node. Optional :version (java:17). Curated catalog only \u2014 see `monoceros list-components`.",
|
|
6469
|
+
required: false
|
|
6470
|
+
},
|
|
6471
|
+
"with-features": {
|
|
6472
|
+
type: "string",
|
|
6473
|
+
description: "Features (AI tools, language CLIs, \u2026), comma-separated or repeated. Catalog short name (claude, atlassian/twg) or a full OCI ref (ghcr.io/foo/bar:1).",
|
|
6474
|
+
required: false
|
|
6475
|
+
},
|
|
6476
|
+
"with-services": {
|
|
6477
|
+
type: "string",
|
|
6478
|
+
description: "Backing services, comma-separated or repeated. Curated name (postgres, mysql, redis) \u2192 full editable block; any other image (rustfs/rustfs:latest) \u2192 name + image + commented scaffold.",
|
|
6479
|
+
required: false
|
|
6480
|
+
},
|
|
6481
|
+
"with-apt-packages": {
|
|
5761
6482
|
type: "string",
|
|
5762
|
-
description: "
|
|
6483
|
+
description: "Debian/Ubuntu apt packages to install, comma-separated or repeated, e.g. --with-apt-packages=openssl,make. No curated list.",
|
|
5763
6484
|
required: false
|
|
5764
6485
|
},
|
|
5765
|
-
"with-
|
|
6486
|
+
"with-repos": {
|
|
5766
6487
|
type: "string",
|
|
5767
|
-
description: "Git
|
|
6488
|
+
description: "Git URLs to clone into projects/ on first apply, comma-separated or repeated. Folder name derived from URL (foo.git \u2192 projects/foo/); use `monoceros add-repo --path=...` post-init for subfolder paths. Canonical hosts only (github.com / gitlab.com / bitbucket.org).",
|
|
5768
6489
|
required: false
|
|
5769
6490
|
},
|
|
5770
6491
|
"with-ports": {
|
|
@@ -5775,14 +6496,20 @@ var initCommand = defineCommand11({
|
|
|
5775
6496
|
},
|
|
5776
6497
|
async run({ args, rawArgs }) {
|
|
5777
6498
|
try {
|
|
5778
|
-
const
|
|
5779
|
-
const
|
|
5780
|
-
const
|
|
6499
|
+
const languages = collectListFlag("--with-languages", rawArgs);
|
|
6500
|
+
const features = collectListFlag("--with-features", rawArgs);
|
|
6501
|
+
const services = collectListFlag("--with-services", rawArgs);
|
|
6502
|
+
const aptPackages = collectListFlag("--with-apt-packages", rawArgs);
|
|
6503
|
+
const repos = collectListFlag("--with-repos", rawArgs);
|
|
6504
|
+
const ports = collectWithPortsList(args["with-ports"], rawArgs);
|
|
5781
6505
|
await runInit({
|
|
5782
6506
|
name: args.name,
|
|
5783
|
-
...
|
|
5784
|
-
...
|
|
5785
|
-
...
|
|
6507
|
+
...languages.length > 0 ? { languages } : {},
|
|
6508
|
+
...features.length > 0 ? { features } : {},
|
|
6509
|
+
...services.length > 0 ? { services } : {},
|
|
6510
|
+
...aptPackages.length > 0 ? { aptPackages } : {},
|
|
6511
|
+
...repos.length > 0 ? { withRepo: repos } : {},
|
|
6512
|
+
...ports && ports.length > 0 ? { withPorts: ports } : {}
|
|
5786
6513
|
});
|
|
5787
6514
|
} catch (err) {
|
|
5788
6515
|
consola14.error(err instanceof Error ? err.message : String(err));
|
|
@@ -5790,6 +6517,30 @@ var initCommand = defineCommand11({
|
|
|
5790
6517
|
}
|
|
5791
6518
|
}
|
|
5792
6519
|
});
|
|
6520
|
+
function collectListFlag(flag, rawArgs) {
|
|
6521
|
+
const eq = `${flag}=`;
|
|
6522
|
+
const pieces = [];
|
|
6523
|
+
for (let i = 0; i < rawArgs.length; i += 1) {
|
|
6524
|
+
const t = rawArgs[i];
|
|
6525
|
+
let scanStart = -1;
|
|
6526
|
+
if (t === flag) {
|
|
6527
|
+
scanStart = i + 1;
|
|
6528
|
+
} else if (t.startsWith(eq)) {
|
|
6529
|
+
pieces.push(t.slice(eq.length));
|
|
6530
|
+
scanStart = i + 1;
|
|
6531
|
+
}
|
|
6532
|
+
if (scanStart < 0) continue;
|
|
6533
|
+
let j = scanStart;
|
|
6534
|
+
while (j < rawArgs.length) {
|
|
6535
|
+
const u = rawArgs[j];
|
|
6536
|
+
if (u.startsWith("-")) break;
|
|
6537
|
+
pieces.push(u);
|
|
6538
|
+
j += 1;
|
|
6539
|
+
}
|
|
6540
|
+
i = j - 1;
|
|
6541
|
+
}
|
|
6542
|
+
return pieces.flatMap((s) => s.split(",")).map((s) => s.trim()).filter((s) => s.length > 0);
|
|
6543
|
+
}
|
|
5793
6544
|
function collectWithPortsList(_withPortsArg, rawArgs) {
|
|
5794
6545
|
const pieces = [];
|
|
5795
6546
|
for (let i = 0; i < rawArgs.length; i += 1) {
|
|
@@ -5825,43 +6576,6 @@ function collectWithPortsList(_withPortsArg, rawArgs) {
|
|
|
5825
6576
|
}
|
|
5826
6577
|
return out;
|
|
5827
6578
|
}
|
|
5828
|
-
function collectWithRepoList(rawArgs) {
|
|
5829
|
-
const urls = [];
|
|
5830
|
-
for (let i = 0; i < rawArgs.length; i += 1) {
|
|
5831
|
-
const t = rawArgs[i];
|
|
5832
|
-
if (t === "--with-repo") {
|
|
5833
|
-
const next = rawArgs[i + 1];
|
|
5834
|
-
if (typeof next === "string" && !next.startsWith("-")) {
|
|
5835
|
-
urls.push(next);
|
|
5836
|
-
i += 1;
|
|
5837
|
-
}
|
|
5838
|
-
} else if (t.startsWith("--with-repo=")) {
|
|
5839
|
-
urls.push(t.slice("--with-repo=".length));
|
|
5840
|
-
}
|
|
5841
|
-
}
|
|
5842
|
-
return urls;
|
|
5843
|
-
}
|
|
5844
|
-
function collectWithList(withArg, rawArgs) {
|
|
5845
|
-
if (typeof withArg !== "string" || withArg.trim().length === 0) {
|
|
5846
|
-
return void 0;
|
|
5847
|
-
}
|
|
5848
|
-
let combined = withArg.trim();
|
|
5849
|
-
const startIdx = rawArgs.findIndex(
|
|
5850
|
-
(t) => t === "--with" || t.startsWith("--with=")
|
|
5851
|
-
);
|
|
5852
|
-
if (startIdx >= 0) {
|
|
5853
|
-
let scanFrom = startIdx + 1;
|
|
5854
|
-
if (rawArgs[startIdx] === "--with") scanFrom += 1;
|
|
5855
|
-
for (let i = scanFrom; i < rawArgs.length; i += 1) {
|
|
5856
|
-
const t = rawArgs[i];
|
|
5857
|
-
if (t.startsWith("--") || t === "-h" || t === "--help") break;
|
|
5858
|
-
const sep = combined.endsWith(",") ? "" : ",";
|
|
5859
|
-
combined += sep + t;
|
|
5860
|
-
}
|
|
5861
|
-
}
|
|
5862
|
-
const pieces = combined.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
|
|
5863
|
-
return pieces.length > 0 ? pieces : void 0;
|
|
5864
|
-
}
|
|
5865
6579
|
|
|
5866
6580
|
// src/commands/list-components.ts
|
|
5867
6581
|
import { defineCommand as defineCommand12 } from "citty";
|
|
@@ -6149,8 +6863,8 @@ import { consola as consola20 } from "consola";
|
|
|
6149
6863
|
import { createInterface } from "readline/promises";
|
|
6150
6864
|
|
|
6151
6865
|
// src/remove/index.ts
|
|
6152
|
-
import { existsSync as
|
|
6153
|
-
import
|
|
6866
|
+
import { existsSync as existsSync11, promises as fs15 } from "fs";
|
|
6867
|
+
import path17 from "path";
|
|
6154
6868
|
import { consola as consola19 } from "consola";
|
|
6155
6869
|
async function runRemove(opts) {
|
|
6156
6870
|
const home = opts.monocerosHome ?? monocerosHome();
|
|
@@ -6165,9 +6879,11 @@ async function runRemove(opts) {
|
|
|
6165
6879
|
);
|
|
6166
6880
|
}
|
|
6167
6881
|
const ymlPath = containerConfigPath(opts.name, home);
|
|
6882
|
+
const envPath = containerEnvPath(opts.name, home);
|
|
6168
6883
|
const containerPath = containerDir(opts.name, home);
|
|
6169
|
-
const hasYml =
|
|
6170
|
-
const
|
|
6884
|
+
const hasYml = existsSync11(ymlPath);
|
|
6885
|
+
const hasEnv = existsSync11(envPath);
|
|
6886
|
+
const hasContainer = existsSync11(containerPath);
|
|
6171
6887
|
if (!hasYml && !hasContainer) {
|
|
6172
6888
|
throw new Error(
|
|
6173
6889
|
`Nothing to remove for '${opts.name}': neither ${ymlPath} nor ${containerPath} exists.`
|
|
@@ -6191,24 +6907,30 @@ async function runRemove(opts) {
|
|
|
6191
6907
|
let backupPath = null;
|
|
6192
6908
|
if (!opts.noBackup && (hasYml || hasContainer)) {
|
|
6193
6909
|
const ts = (opts.now ?? /* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
6194
|
-
backupPath =
|
|
6195
|
-
await
|
|
6910
|
+
backupPath = path17.join(home, "container-backups", `${opts.name}-${ts}`);
|
|
6911
|
+
await fs15.mkdir(backupPath, { recursive: true });
|
|
6196
6912
|
if (hasYml) {
|
|
6197
|
-
await
|
|
6913
|
+
await fs15.copyFile(ymlPath, path17.join(backupPath, `${opts.name}.yml`));
|
|
6914
|
+
}
|
|
6915
|
+
if (hasEnv) {
|
|
6916
|
+
await fs15.copyFile(envPath, path17.join(backupPath, `${opts.name}.env`));
|
|
6198
6917
|
}
|
|
6199
6918
|
if (hasContainer) {
|
|
6200
|
-
await
|
|
6919
|
+
await fs15.cp(containerPath, path17.join(backupPath, "container"), {
|
|
6201
6920
|
recursive: true
|
|
6202
6921
|
});
|
|
6203
6922
|
}
|
|
6204
6923
|
logger.info(`Backup written to ${prettyPath(backupPath)}.`);
|
|
6205
6924
|
}
|
|
6206
6925
|
if (hasYml) {
|
|
6207
|
-
await
|
|
6926
|
+
await fs15.rm(ymlPath, { force: true });
|
|
6927
|
+
}
|
|
6928
|
+
if (hasEnv) {
|
|
6929
|
+
await fs15.rm(envPath, { force: true });
|
|
6208
6930
|
}
|
|
6209
6931
|
if (hasContainer) {
|
|
6210
6932
|
try {
|
|
6211
|
-
await
|
|
6933
|
+
await fs15.rm(containerPath, { recursive: true, force: true });
|
|
6212
6934
|
} catch (err) {
|
|
6213
6935
|
const code = err.code;
|
|
6214
6936
|
if (code !== "EACCES" && code !== "EPERM") {
|
|
@@ -6234,7 +6956,7 @@ async function runRemove(opts) {
|
|
|
6234
6956
|
`docker-based cleanup of ${containerPath} exited ${exit}. Inspect with \`sudo ls -la ${containerPath}\` and clean manually.`
|
|
6235
6957
|
);
|
|
6236
6958
|
}
|
|
6237
|
-
await
|
|
6959
|
+
await fs15.rm(containerPath, { recursive: true, force: true });
|
|
6238
6960
|
}
|
|
6239
6961
|
}
|
|
6240
6962
|
logger.success(
|
|
@@ -6337,8 +7059,8 @@ import { defineCommand as defineCommand18 } from "citty";
|
|
|
6337
7059
|
import { consola as consola22 } from "consola";
|
|
6338
7060
|
|
|
6339
7061
|
// src/restore/index.ts
|
|
6340
|
-
import { existsSync as
|
|
6341
|
-
import
|
|
7062
|
+
import { existsSync as existsSync12, promises as fs16 } from "fs";
|
|
7063
|
+
import path18 from "path";
|
|
6342
7064
|
import { consola as consola21 } from "consola";
|
|
6343
7065
|
async function runRestore(opts) {
|
|
6344
7066
|
const home = opts.monocerosHome ?? monocerosHome();
|
|
@@ -6346,15 +7068,15 @@ async function runRestore(opts) {
|
|
|
6346
7068
|
info: (msg) => consola21.info(msg),
|
|
6347
7069
|
success: (msg) => consola21.success(msg)
|
|
6348
7070
|
};
|
|
6349
|
-
const backup =
|
|
6350
|
-
if (!
|
|
7071
|
+
const backup = path18.resolve(opts.backupPath);
|
|
7072
|
+
if (!existsSync12(backup)) {
|
|
6351
7073
|
throw new Error(`Backup not found: ${backup}.`);
|
|
6352
7074
|
}
|
|
6353
|
-
const stat = await
|
|
7075
|
+
const stat = await fs16.stat(backup);
|
|
6354
7076
|
if (!stat.isDirectory()) {
|
|
6355
7077
|
throw new Error(`Backup path is not a directory: ${backup}.`);
|
|
6356
7078
|
}
|
|
6357
|
-
const entries = await
|
|
7079
|
+
const entries = await fs16.readdir(backup);
|
|
6358
7080
|
const ymlFiles = entries.filter((f) => f.endsWith(".yml"));
|
|
6359
7081
|
if (ymlFiles.length === 0) {
|
|
6360
7082
|
throw new Error(
|
|
@@ -6368,24 +7090,29 @@ async function runRestore(opts) {
|
|
|
6368
7090
|
}
|
|
6369
7091
|
const ymlFile = ymlFiles[0];
|
|
6370
7092
|
const name = ymlFile.replace(/\.yml$/, "");
|
|
6371
|
-
const containerInBackup =
|
|
6372
|
-
const hasContainer =
|
|
7093
|
+
const containerInBackup = path18.join(backup, "container");
|
|
7094
|
+
const hasContainer = existsSync12(containerInBackup);
|
|
7095
|
+
const envInBackup = path18.join(backup, `${name}.env`);
|
|
7096
|
+
const hasEnv = existsSync12(envInBackup);
|
|
6373
7097
|
const destYml = containerConfigPath(name, home);
|
|
6374
7098
|
const destContainer = containerDir(name, home);
|
|
6375
|
-
if (
|
|
7099
|
+
if (existsSync12(destYml)) {
|
|
6376
7100
|
throw new Error(
|
|
6377
7101
|
`Refusing to restore: ${destYml} already exists. Remove the current container first (\`monoceros remove ${name}\`) or rename the existing config.`
|
|
6378
7102
|
);
|
|
6379
7103
|
}
|
|
6380
|
-
if (hasContainer &&
|
|
7104
|
+
if (hasContainer && existsSync12(destContainer)) {
|
|
6381
7105
|
throw new Error(
|
|
6382
7106
|
`Refusing to restore: ${destContainer} already exists. Remove the current container first (\`monoceros remove ${name}\`).`
|
|
6383
7107
|
);
|
|
6384
7108
|
}
|
|
6385
|
-
await
|
|
6386
|
-
await
|
|
7109
|
+
await fs16.mkdir(containerConfigsDir(home), { recursive: true });
|
|
7110
|
+
await fs16.copyFile(path18.join(backup, ymlFile), destYml);
|
|
7111
|
+
if (hasEnv) {
|
|
7112
|
+
await fs16.copyFile(envInBackup, containerEnvPath(name, home));
|
|
7113
|
+
}
|
|
6387
7114
|
if (hasContainer) {
|
|
6388
|
-
await
|
|
7115
|
+
await fs16.cp(containerInBackup, destContainer, { recursive: true });
|
|
6389
7116
|
}
|
|
6390
7117
|
logger.success(`Restored '${name}' from ${prettyPath(backup)}.`);
|
|
6391
7118
|
logger.info(
|
|
@@ -6643,8 +7370,8 @@ import { defineCommand as defineCommand24 } from "citty";
|
|
|
6643
7370
|
import { consola as consola28 } from "consola";
|
|
6644
7371
|
|
|
6645
7372
|
// src/devcontainer/shell.ts
|
|
6646
|
-
import { existsSync as
|
|
6647
|
-
import
|
|
7373
|
+
import { existsSync as existsSync13 } from "fs";
|
|
7374
|
+
import path19 from "path";
|
|
6648
7375
|
async function runShell(opts) {
|
|
6649
7376
|
assertContainerExists(opts.root);
|
|
6650
7377
|
const spawnFn = opts.spawn ?? spawnDevcontainer;
|
|
@@ -6667,7 +7394,7 @@ async function runShell(opts) {
|
|
|
6667
7394
|
);
|
|
6668
7395
|
}
|
|
6669
7396
|
function assertContainerExists(root) {
|
|
6670
|
-
if (!
|
|
7397
|
+
if (!existsSync13(path19.join(root, ".devcontainer"))) {
|
|
6671
7398
|
throw new Error(
|
|
6672
7399
|
`No .devcontainer/ at ${root}. Run \`monoceros apply <name>\` first.`
|
|
6673
7400
|
);
|
|
@@ -6879,15 +7606,15 @@ import { defineCommand as defineCommand29 } from "citty";
|
|
|
6879
7606
|
import { consola as consola33 } from "consola";
|
|
6880
7607
|
|
|
6881
7608
|
// src/tunnel/run.ts
|
|
6882
|
-
import { spawn as
|
|
7609
|
+
import { spawn as spawn10 } from "child_process";
|
|
6883
7610
|
import { consola as consola32 } from "consola";
|
|
6884
7611
|
|
|
6885
7612
|
// src/tunnel/resolve.ts
|
|
6886
|
-
import { existsSync as
|
|
6887
|
-
import
|
|
7613
|
+
import { existsSync as existsSync14 } from "fs";
|
|
7614
|
+
import path20 from "path";
|
|
6888
7615
|
async function resolveTunnelTarget(opts) {
|
|
6889
7616
|
const ymlPath = containerConfigPath(opts.name, opts.monocerosHome);
|
|
6890
|
-
if (!
|
|
7617
|
+
if (!existsSync14(ymlPath)) {
|
|
6891
7618
|
throw new Error(
|
|
6892
7619
|
`No yml profile for '${opts.name}' at ${ymlPath}. Run \`monoceros init ${opts.name}\` first.`
|
|
6893
7620
|
);
|
|
@@ -6895,13 +7622,13 @@ async function resolveTunnelTarget(opts) {
|
|
|
6895
7622
|
const parsed = await readConfig(ymlPath);
|
|
6896
7623
|
const config = parsed.config;
|
|
6897
7624
|
const containerRoot = containerDir(opts.name, opts.monocerosHome);
|
|
6898
|
-
if (!
|
|
7625
|
+
if (!existsSync14(containerRoot)) {
|
|
6899
7626
|
throw new Error(
|
|
6900
7627
|
`Container '${opts.name}' is not materialised at ${containerRoot}. Run \`monoceros apply ${opts.name}\` first.`
|
|
6901
7628
|
);
|
|
6902
7629
|
}
|
|
6903
|
-
const composePath =
|
|
6904
|
-
const isCompose =
|
|
7630
|
+
const composePath = path20.join(containerRoot, ".devcontainer", "compose.yaml");
|
|
7631
|
+
const isCompose = existsSync14(composePath);
|
|
6905
7632
|
const parsedTarget = parseTargetArg(opts.target, config);
|
|
6906
7633
|
const docker = opts.docker ?? defaultDockerExec;
|
|
6907
7634
|
if (isCompose) {
|
|
@@ -6920,23 +7647,41 @@ async function resolveTunnelTarget(opts) {
|
|
|
6920
7647
|
});
|
|
6921
7648
|
}
|
|
6922
7649
|
function parseTargetArg(raw, config) {
|
|
7650
|
+
const colon = raw.indexOf(":");
|
|
7651
|
+
if (colon > 0) {
|
|
7652
|
+
const name = raw.slice(0, colon);
|
|
7653
|
+
const port = Number(raw.slice(colon + 1));
|
|
7654
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
7655
|
+
throw new Error(
|
|
7656
|
+
`Invalid target '${raw}'. Use <service>:<port> with a numeric port (1\u201365535), a bare port number, or a configured service name.`
|
|
7657
|
+
);
|
|
7658
|
+
}
|
|
7659
|
+
findConfiguredService(config, name);
|
|
7660
|
+
return { kind: "service", service: name, port };
|
|
7661
|
+
}
|
|
6923
7662
|
const asNumber = Number(raw);
|
|
6924
7663
|
if (Number.isInteger(asNumber) && asNumber > 0 && asNumber < 65536) {
|
|
6925
7664
|
return { kind: "port", port: asNumber };
|
|
6926
7665
|
}
|
|
6927
|
-
const
|
|
6928
|
-
if (
|
|
6929
|
-
const candidates = knownServices().join(", ");
|
|
7666
|
+
const match = findConfiguredService(config, raw);
|
|
7667
|
+
if (match.port === void 0) {
|
|
6930
7668
|
throw new Error(
|
|
6931
|
-
`
|
|
7669
|
+
`Service '${raw}' declares no port, so tunnel can't know what to forward. Add \`port: <n>\` to the service in the yml and re-apply, or pass one explicitly: \`monoceros tunnel <name> ${raw}:<port>\`.`
|
|
6932
7670
|
);
|
|
6933
7671
|
}
|
|
6934
|
-
|
|
7672
|
+
return { kind: "service", service: raw, port: match.port };
|
|
7673
|
+
}
|
|
7674
|
+
function findConfiguredService(config, name) {
|
|
7675
|
+
const services = config.services.map(resolveService);
|
|
7676
|
+
const match = services.find((s) => s.name === name);
|
|
7677
|
+
if (!match) {
|
|
7678
|
+
const names = services.map((s) => s.name);
|
|
7679
|
+
const list = names.length > 0 ? names.join(", ") : "(none configured)";
|
|
6935
7680
|
throw new Error(
|
|
6936
|
-
`Service '${
|
|
7681
|
+
`Service '${name}' is not configured in this container's yml. Configured services: ${list}. Or pass a port number (e.g. \`monoceros tunnel <name> 8080\`).`
|
|
6937
7682
|
);
|
|
6938
7683
|
}
|
|
6939
|
-
return
|
|
7684
|
+
return match;
|
|
6940
7685
|
}
|
|
6941
7686
|
function resolveCompose2(args) {
|
|
6942
7687
|
const network = `${composeProjectName(args.containerRoot)}_default`;
|
|
@@ -6945,7 +7690,7 @@ function resolveCompose2(args) {
|
|
|
6945
7690
|
network,
|
|
6946
7691
|
targetHost: args.parsedTarget.service,
|
|
6947
7692
|
internalPort: args.parsedTarget.port,
|
|
6948
|
-
display: `${args.name}/${args.parsedTarget.service}`
|
|
7693
|
+
display: `${args.name}/${args.parsedTarget.service}:${args.parsedTarget.port}`
|
|
6949
7694
|
};
|
|
6950
7695
|
}
|
|
6951
7696
|
return {
|
|
@@ -7104,7 +7849,7 @@ function formatLocalPortHeldError(port, address, result) {
|
|
|
7104
7849
|
// src/tunnel/run.ts
|
|
7105
7850
|
var SOCAT_IMAGE = "alpine/socat:1.8.0.3";
|
|
7106
7851
|
var defaultDockerSpawn = (args) => {
|
|
7107
|
-
const child =
|
|
7852
|
+
const child = spawn10("docker", args, {
|
|
7108
7853
|
stdio: "inherit"
|
|
7109
7854
|
});
|
|
7110
7855
|
const exited = new Promise((resolve, reject) => {
|
|
@@ -7227,7 +7972,7 @@ var tunnelCommand = defineCommand29({
|
|
|
7227
7972
|
},
|
|
7228
7973
|
target: {
|
|
7229
7974
|
type: "positional",
|
|
7230
|
-
description: "Service name from the container yml (e.g. `postgres`)
|
|
7975
|
+
description: "Service name from the container yml (e.g. `postgres`), `service:port` for an explicit in-container port (e.g. `rustfs:9001`), or a bare in-container port number \u2192 workspace (e.g. `8080`).",
|
|
7231
7976
|
required: true
|
|
7232
7977
|
},
|
|
7233
7978
|
"local-port": {
|