@getmonoceros/workbench 1.21.2 → 1.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/bundled-components/features/atlassian/component.yml +52 -0
  2. package/bundled-components/features/atlassian/install.sh +176 -0
  3. package/bundled-components/features/claude-code/component.yml +32 -0
  4. package/bundled-components/features/claude-code/install.sh +56 -0
  5. package/bundled-components/features/github-cli/component.yml +18 -0
  6. package/bundled-components/features/github-cli/install.sh +53 -0
  7. package/bundled-components/languages/dotnet/component.yml +8 -0
  8. package/bundled-components/languages/go/component.yml +8 -0
  9. package/bundled-components/languages/java/component.yml +17 -0
  10. package/bundled-components/languages/node/component.yml +9 -0
  11. package/bundled-components/languages/python/component.yml +8 -0
  12. package/bundled-components/languages/rust/component.yml +8 -0
  13. package/bundled-components/services/mysql/component.yml +40 -0
  14. package/bundled-components/services/postgres/component.yml +34 -0
  15. package/bundled-components/services/redis/component.yml +16 -0
  16. package/dist/bin.js +791 -369
  17. package/dist/bin.js.map +1 -1
  18. package/package.json +5 -4
  19. package/features/atlassian/devcontainer-feature.json +0 -67
  20. package/features/claude-code/devcontainer-feature.json +0 -49
  21. package/features/github-cli/devcontainer-feature.json +0 -32
  22. package/templates/components/README.md +0 -95
  23. package/templates/components/atlassian/rovodev.yml +0 -14
  24. package/templates/components/atlassian/twg.yml +0 -14
  25. package/templates/components/atlassian.yml +0 -15
  26. package/templates/components/claude.yml +0 -16
  27. package/templates/components/dotnet.yml +0 -8
  28. package/templates/components/github.yml +0 -10
  29. package/templates/components/go.yml +0 -8
  30. package/templates/components/java.yml +0 -9
  31. package/templates/components/mysql.yml +0 -8
  32. package/templates/components/node.yml +0 -9
  33. package/templates/components/postgres.yml +0 -10
  34. package/templates/components/python.yml +0 -9
  35. package/templates/components/redis.yml +0 -7
  36. package/templates/components/rust.yml +0 -8
package/dist/bin.js CHANGED
@@ -74,7 +74,7 @@ ${issues}`);
74
74
  }
75
75
  return result.data;
76
76
  }
77
- var SOLUTION_NAME_RE, APT_PACKAGE_NAME_RE, RUNTIME_VERSION_RE, FEATURE_REF_RE, INSTALL_URL_RE, REPO_URL_RE, REPO_PATH_RE, POSTGRES_URL_RE, REGEX, PROVIDER_VALUES, KNOWN_PROVIDER_HOSTS, CONFIG_SCHEMA_VERSION, FeatureOptionValueSchema, FeatureEntrySchema, EMAIL_RE, GitUserSchema, RepoEntrySchema, PortEntrySchema, RoutingSchema, SERVICE_NAME_RE, ServiceEnvValueSchema, ServiceHealthcheckSchema, SERVICE_RESTART_VALUES, ServiceObjectSchema, ExternalServicesSchema, SolutionConfigSchema;
77
+ var SOLUTION_NAME_RE, APT_PACKAGE_NAME_RE, RUNTIME_VERSION_RE, FEATURE_REF_RE, INSTALL_URL_RE, REPO_URL_RE, REPO_PATH_RE, POSTGRES_URL_RE, REGEX, PROVIDER_VALUES, KNOWN_PROVIDER_HOSTS, CONFIG_SCHEMA_VERSION, FeatureOptionValueSchema, FeatureEntrySchema, EMAIL_RE, GitUserSchema, RepoEntrySchema, PortEntrySchema, RoutingSchema, SERVICE_NAME_RE, ServiceEnvValueSchema, ServiceHealthcheckSchema, SERVICE_RESTART_VALUES, ServiceObjectSchema, ExternalServicesSchema, LanguageOptionValueSchema, LanguageEntrySchema, SolutionConfigSchema;
78
78
  var init_schema = __esm({
79
79
  "src/config/schema.ts"() {
80
80
  "use strict";
@@ -211,6 +211,20 @@ var init_schema = __esm({
211
211
  "Postgres URL must start with 'postgres://' or 'postgresql://'"
212
212
  ).optional()
213
213
  });
214
+ LanguageOptionValueSchema = z.union([
215
+ z.string(),
216
+ z.number(),
217
+ z.boolean()
218
+ ]);
219
+ LanguageEntrySchema = z.union([
220
+ z.string().min(1),
221
+ z.record(
222
+ z.string().min(1),
223
+ z.record(z.string().min(1), LanguageOptionValueSchema)
224
+ ).refine((obj) => Object.keys(obj).length === 1, {
225
+ message: "a language entry object must have exactly one language name as its key"
226
+ })
227
+ ]);
214
228
  SolutionConfigSchema = z.object({
215
229
  schemaVersion: z.literal(CONFIG_SCHEMA_VERSION),
216
230
  name: z.string().regex(
@@ -226,7 +240,7 @@ var init_schema = __esm({
226
240
  RUNTIME_VERSION_RE,
227
241
  "Invalid runtimeVersion. Expected an exact version like '1.1.0'."
228
242
  ).optional(),
229
- languages: z.array(z.string().min(1)).default([]),
243
+ languages: z.array(LanguageEntrySchema).default([]),
230
244
  aptPackages: z.array(
231
245
  z.string().regex(
232
246
  APT_PACKAGE_NAME_RE,
@@ -336,11 +350,13 @@ function workbenchCheckoutRoot() {
336
350
  dir = parent;
337
351
  }
338
352
  }
339
- function componentsDir(root = workbenchRoot()) {
340
- return path.join(root, "templates", "components");
341
- }
342
- function bundledFeaturesDir(root = workbenchRoot()) {
343
- return path.join(root, "features");
353
+ function componentsRootDir() {
354
+ const checkout = workbenchCheckoutRoot();
355
+ if (checkout) {
356
+ const inCheckout = path.join(checkout, "components");
357
+ if (existsSync(inCheckout)) return inCheckout;
358
+ }
359
+ return path.join(workbenchRoot(), "bundled-components");
344
360
  }
345
361
  function containerConfigsDir(home = monocerosHome()) {
346
362
  return path.join(home, "container-configs");
@@ -378,7 +394,7 @@ var init_paths = __esm({
378
394
  "src/config/paths.ts"() {
379
395
  "use strict";
380
396
  MONOCEROS_HOME_MARKER = "monoceros-config.sample.yml";
381
- WORKBENCH_MARKER = path.join("templates", "components", "README.md");
397
+ WORKBENCH_MARKER = path.join("templates", "monoceros-config.sample.yml");
382
398
  CHECKOUT_MARKER = "pnpm-workspace.yaml";
383
399
  cachedWorkbenchRoot = null;
384
400
  cachedMonocerosHome = null;
@@ -632,6 +648,304 @@ var init_feature_doc = __esm({
632
648
  }
633
649
  });
634
650
 
651
+ // src/catalog/descriptor.ts
652
+ import { z as z2 } from "zod";
653
+ var DESCRIPTOR_ID_RE, CategorySchema, OptionTypeSchema, SurfaceSchema, OptionValueSchema, OptionSpecSchema, BriefingLineSchema, HealthcheckSchema, LanguageBlockSchema, ServiceBlockSchema, PersistentHomeFileSchema, FeatureBlockSchema, DescriptorSchema;
654
+ var init_descriptor = __esm({
655
+ "src/catalog/descriptor.ts"() {
656
+ "use strict";
657
+ init_schema();
658
+ DESCRIPTOR_ID_RE = /^[a-z0-9][a-z0-9-]*$/;
659
+ CategorySchema = z2.enum(["language", "service", "feature"]);
660
+ OptionTypeSchema = z2.enum(["string", "boolean", "number"]);
661
+ SurfaceSchema = z2.enum(["yml", "silent", "env"]);
662
+ OptionValueSchema = z2.union([z2.string(), z2.boolean(), z2.number()]);
663
+ OptionSpecSchema = z2.object({
664
+ type: OptionTypeSchema,
665
+ default: OptionValueSchema.optional(),
666
+ description: z2.string().optional(),
667
+ surface: SurfaceSchema.default("silent"),
668
+ /** Suggested values (rendered as devcontainer `proposals`). */
669
+ proposals: z2.array(z2.string()).optional()
670
+ });
671
+ BriefingLineSchema = z2.object({
672
+ text: z2.string().min(1),
673
+ /**
674
+ * Option name; the line is emitted only when that option resolves
675
+ * truthy (after merging defaults + user options). Must reference an
676
+ * option declared on the same descriptor.
677
+ */
678
+ whenOption: z2.string().optional()
679
+ });
680
+ HealthcheckSchema = z2.object({
681
+ test: z2.array(z2.string()).min(1),
682
+ interval: z2.string().optional(),
683
+ timeout: z2.string().optional(),
684
+ retries: z2.number().int().positive().optional(),
685
+ startPeriod: z2.string().optional()
686
+ });
687
+ LanguageBlockSchema = z2.object({
688
+ /** Upstream OCI feature ref, e.g. `ghcr.io/devcontainers/features/java:1`. */
689
+ feature: z2.string().regex(REGEX.featureRef),
690
+ /** True when the toolchain is already in the base runtime image (node). */
691
+ builtin: z2.boolean().default(false),
692
+ /**
693
+ * Version shown inline in the generated yml (`name:<defaultVersion>`), so
694
+ * the builder sees where to edit it. Should equal the upstream feature's
695
+ * real default to stay behavior-neutral. For a `builtin` language it is the
696
+ * base-image version; pinning that exact version stays builtin (no feature
697
+ * install), only a different version triggers the upstream feature.
698
+ * Coerced to string so bare YAML numbers (`defaultVersion: 22`) work.
699
+ */
700
+ defaultVersion: z2.coerce.string().optional(),
701
+ /**
702
+ * Versions the upstream feature accepts (docs/UX only, not enforced).
703
+ * Coerced to string so authors can write bare YAML numbers
704
+ * (`versions: [latest, 21, 17]`) without quoting.
705
+ */
706
+ versions: z2.array(z2.coerce.string()).optional()
707
+ });
708
+ ServiceBlockSchema = z2.object({
709
+ image: z2.string().min(1),
710
+ defaultPort: z2.number().int().positive().optional(),
711
+ dataMount: z2.string().optional(),
712
+ healthcheck: HealthcheckSchema.optional(),
713
+ /**
714
+ * Connection env vars injected into the WORKSPACE container so the app /
715
+ * agent can reach this service without hardcoding anything. Keyed by env
716
+ * var name → a template value. Tokens: `${host}` (the service hostname),
717
+ * `${port}` (the service's port, falling back to `defaultPort`), and
718
+ * `${<OPTION>}` (any of the service's own option values, e.g.
719
+ * `${POSTGRES_USER}`). Example:
720
+ * DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${host}:${port}/${POSTGRES_DB}
721
+ */
722
+ connectionEnv: z2.record(z2.string(), z2.string()).optional(),
723
+ vscodeExtensions: z2.array(z2.string()).optional()
724
+ });
725
+ PersistentHomeFileSchema = z2.object({
726
+ path: z2.string().min(1),
727
+ initialContent: z2.string().optional()
728
+ });
729
+ FeatureBlockSchema = z2.object({
730
+ /** Publishable feature version (devcontainer-feature.json `version`). */
731
+ version: z2.string().min(1),
732
+ persistentHomePaths: z2.array(z2.string().min(1)).optional(),
733
+ persistentHomeFiles: z2.array(PersistentHomeFileSchema).optional(),
734
+ vscodeExtensions: z2.array(z2.string()).optional()
735
+ });
736
+ DescriptorSchema = z2.object({
737
+ id: z2.string().regex(DESCRIPTOR_ID_RE, "id must be lowercase letters/digits/hyphens"),
738
+ /**
739
+ * CLI/yml selector name (catalog key). Defaults to `id`. Lets a feature
740
+ * keep a short selector (`claude`) while its published manifest id stays
741
+ * canonical (`claude-code`).
742
+ */
743
+ name: z2.string().regex(DESCRIPTOR_ID_RE, "name must be lowercase letters/digits/hyphens").optional(),
744
+ category: CategorySchema,
745
+ displayName: z2.string().min(1),
746
+ description: z2.string().min(1),
747
+ documentationURL: z2.string().url().optional(),
748
+ options: z2.record(z2.string(), OptionSpecSchema).default({}),
749
+ /** Free-text notes rendered above the component block at `init`. */
750
+ usageNotes: z2.array(z2.string()).default([]),
751
+ briefing: z2.array(BriefingLineSchema).default([]),
752
+ language: LanguageBlockSchema.optional(),
753
+ service: ServiceBlockSchema.optional(),
754
+ feature: FeatureBlockSchema.optional(),
755
+ /**
756
+ * Named option-override presets. Each becomes a selectable
757
+ * `<name>/<presetKey>` component (e.g. `atlassian/twg`); the bare
758
+ * component keeps the descriptor's own option defaults. Feature-only.
759
+ */
760
+ presets: z2.record(
761
+ z2.string().regex(DESCRIPTOR_ID_RE),
762
+ z2.record(z2.string(), OptionValueSchema)
763
+ ).optional()
764
+ }).superRefine((data, ctx) => {
765
+ const present = [
766
+ data.language ? "language" : null,
767
+ data.service ? "service" : null,
768
+ data.feature ? "feature" : null
769
+ ].filter(Boolean).sort();
770
+ if (present.length === 0) {
771
+ ctx.addIssue({
772
+ code: z2.ZodIssueCode.custom,
773
+ message: `missing the '${data.category}' block required by category '${data.category}'`
774
+ });
775
+ } else if (present.length > 1) {
776
+ ctx.addIssue({
777
+ code: z2.ZodIssueCode.custom,
778
+ message: `exactly one of language/service/feature is allowed, got: ${present.join(", ")}`
779
+ });
780
+ } else if (present[0] !== data.category) {
781
+ ctx.addIssue({
782
+ code: z2.ZodIssueCode.custom,
783
+ message: `category '${data.category}' requires a '${data.category}' block, found '${present[0]}'`
784
+ });
785
+ }
786
+ const optionKeys = new Set(Object.keys(data.options));
787
+ data.briefing.forEach((line, i) => {
788
+ if (line.whenOption !== void 0 && !optionKeys.has(line.whenOption)) {
789
+ ctx.addIssue({
790
+ code: z2.ZodIssueCode.custom,
791
+ path: ["briefing", i, "whenOption"],
792
+ message: `whenOption '${line.whenOption}' is not a declared option`
793
+ });
794
+ }
795
+ });
796
+ if (data.presets) {
797
+ if (data.category !== "feature") {
798
+ ctx.addIssue({
799
+ code: z2.ZodIssueCode.custom,
800
+ path: ["presets"],
801
+ message: `presets are only allowed on features, not '${data.category}'`
802
+ });
803
+ }
804
+ for (const [presetKey, overrides] of Object.entries(data.presets)) {
805
+ for (const optKey of Object.keys(overrides)) {
806
+ if (!optionKeys.has(optKey)) {
807
+ ctx.addIssue({
808
+ code: z2.ZodIssueCode.custom,
809
+ path: ["presets", presetKey, optKey],
810
+ message: `preset '${presetKey}' overrides '${optKey}', which is not a declared option`
811
+ });
812
+ }
813
+ }
814
+ }
815
+ }
816
+ });
817
+ }
818
+ });
819
+
820
+ // src/catalog/load.ts
821
+ import { existsSync as existsSync3, promises as fs2 } from "fs";
822
+ import path3 from "path";
823
+ import { parse as parseYaml } from "yaml";
824
+ async function loadDescriptorCatalog(rootDir = componentsRootDir()) {
825
+ const out = /* @__PURE__ */ new Map();
826
+ if (!existsSync3(rootDir)) {
827
+ return out;
828
+ }
829
+ for (const [dirName, category] of Object.entries(CATEGORY_DIRS)) {
830
+ const categoryDir = path3.join(rootDir, dirName);
831
+ if (!existsSync3(categoryDir)) continue;
832
+ const entries = await fs2.readdir(categoryDir, { withFileTypes: true });
833
+ for (const entry2 of entries) {
834
+ if (!entry2.isDirectory()) continue;
835
+ const id = entry2.name;
836
+ const sourcePath = path3.join(categoryDir, id, "component.yml");
837
+ if (!existsSync3(sourcePath)) continue;
838
+ const component = await loadOne(sourcePath, id, category);
839
+ if (out.has(component.id)) {
840
+ const first = out.get(component.id);
841
+ throw new Error(
842
+ `Duplicate component id '${component.id}': ${first.sourcePath} and ${sourcePath}.`
843
+ );
844
+ }
845
+ out.set(component.id, component);
846
+ }
847
+ }
848
+ return out;
849
+ }
850
+ async function loadOne(sourcePath, folderId, expectedCategory) {
851
+ const text = await fs2.readFile(sourcePath, "utf8");
852
+ return parseDescriptorFile(text, sourcePath, folderId, expectedCategory);
853
+ }
854
+ function parseDescriptorFile(text, sourcePath, folderId, expectedCategory) {
855
+ let raw;
856
+ try {
857
+ raw = parseYaml(text);
858
+ } catch (err) {
859
+ throw new Error(
860
+ `Failed to parse descriptor (${sourcePath}): ${err.message}`
861
+ );
862
+ }
863
+ const parsed = DescriptorSchema.safeParse(raw);
864
+ if (!parsed.success) {
865
+ const issues = parsed.error.issues.map((issue) => {
866
+ const where = issue.path.length > 0 ? issue.path.join(".") : "(root)";
867
+ return ` - ${where}: ${issue.message}`;
868
+ }).join("\n");
869
+ throw new Error(`Invalid descriptor (${sourcePath}):
870
+ ${issues}`);
871
+ }
872
+ const descriptor = parsed.data;
873
+ if (descriptor.id !== folderId) {
874
+ throw new Error(
875
+ `Descriptor id '${descriptor.id}' must match its folder name '${folderId}' (${sourcePath}).`
876
+ );
877
+ }
878
+ if (descriptor.category !== expectedCategory) {
879
+ throw new Error(
880
+ `Descriptor '${descriptor.id}' has category '${descriptor.category}' but sits under '${expectedCategory}s/' (${sourcePath}).`
881
+ );
882
+ }
883
+ return {
884
+ id: descriptor.id,
885
+ category: descriptor.category,
886
+ sourcePath,
887
+ descriptor
888
+ };
889
+ }
890
+ var CATEGORY_DIRS;
891
+ var init_load = __esm({
892
+ "src/catalog/load.ts"() {
893
+ "use strict";
894
+ init_paths();
895
+ init_descriptor();
896
+ CATEGORY_DIRS = {
897
+ languages: "language",
898
+ services: "service",
899
+ features: "feature"
900
+ };
901
+ }
902
+ });
903
+
904
+ // src/catalog/load-sync.ts
905
+ import { existsSync as existsSync4, readFileSync as readFileSync2, readdirSync } from "fs";
906
+ import path4 from "path";
907
+ function loadDescriptorCatalogSync(rootDir = componentsRootDir()) {
908
+ const out = /* @__PURE__ */ new Map();
909
+ if (!existsSync4(rootDir)) return out;
910
+ for (const [dirName, category] of Object.entries(CATEGORY_DIRS2)) {
911
+ const categoryDir = path4.join(rootDir, dirName);
912
+ if (!existsSync4(categoryDir)) continue;
913
+ for (const entry2 of readdirSync(categoryDir, { withFileTypes: true })) {
914
+ if (!entry2.isDirectory()) continue;
915
+ const id = entry2.name;
916
+ const sourcePath = path4.join(categoryDir, id, "component.yml");
917
+ if (!existsSync4(sourcePath)) continue;
918
+ const component = parseDescriptorFile(
919
+ readFileSync2(sourcePath, "utf8"),
920
+ sourcePath,
921
+ id,
922
+ category
923
+ );
924
+ if (out.has(component.id)) {
925
+ const first = out.get(component.id);
926
+ throw new Error(
927
+ `Duplicate component id '${component.id}': ${first.sourcePath} and ${sourcePath}.`
928
+ );
929
+ }
930
+ out.set(component.id, component);
931
+ }
932
+ }
933
+ return out;
934
+ }
935
+ var CATEGORY_DIRS2;
936
+ var init_load_sync = __esm({
937
+ "src/catalog/load-sync.ts"() {
938
+ "use strict";
939
+ init_paths();
940
+ init_load();
941
+ CATEGORY_DIRS2 = {
942
+ languages: "language",
943
+ services: "service",
944
+ features: "feature"
945
+ };
946
+ }
947
+ });
948
+
635
949
  // src/util/ref.ts
636
950
  function matchMonocerosFeature(ref) {
637
951
  const match = MONOCEROS_FEATURE_RE.exec(ref);
@@ -661,108 +975,59 @@ var init_ref = __esm({
661
975
  });
662
976
 
663
977
  // src/init/manifest.ts
664
- import { existsSync as existsSync3, readFileSync as readFileSync2 } from "fs";
665
- import path3 from "path";
666
- function resolveManifestPath(name, checkoutRoot) {
667
- if (checkoutRoot) {
668
- const checkoutPath = path3.join(
669
- checkoutRoot,
670
- "images",
671
- "features",
672
- name,
673
- "devcontainer-feature.json"
674
- );
675
- if (existsSync3(checkoutPath)) return checkoutPath;
676
- }
677
- const bundlePath = path3.join(
678
- bundledFeaturesDir(),
679
- name,
680
- "devcontainer-feature.json"
681
- );
682
- if (existsSync3(bundlePath)) return bundlePath;
683
- return null;
684
- }
685
- function loadFeatureManifestSummary(ref, checkoutRoot = workbenchCheckoutRoot()) {
978
+ function loadFeatureManifestSummary(ref, componentsRoot) {
686
979
  const match = matchMonocerosFeature(ref);
687
980
  if (!match) return void 0;
688
- const manifestPath = resolveManifestPath(match.name, checkoutRoot);
689
- if (!manifestPath) return void 0;
981
+ let descriptor;
690
982
  try {
691
- const text = readFileSync2(manifestPath, "utf8");
692
- const parsed = JSON.parse(text);
693
- const rawHints = parsed["x-monoceros"]?.optionHints;
694
- const optionHints = Array.isArray(rawHints) ? rawHints.filter(
695
- (x) => typeof x === "string" && x.length > 0
696
- ) : [];
697
- const rawNotes = parsed["x-monoceros"]?.usageNotes;
698
- const usageNotes = Array.isArray(rawNotes) ? rawNotes.filter(
699
- (x) => typeof x === "string" && x.length > 0
700
- ) : [];
701
- const optionDescriptions = {};
702
- const optionTypes = {};
703
- const optionDefaults = {};
704
- const optionNames = [];
705
- if (parsed.options) {
706
- for (const [key, opt] of Object.entries(parsed.options)) {
707
- if (!opt || typeof opt !== "object") continue;
708
- optionNames.push(key);
709
- if (typeof opt.description === "string" && opt.description.length > 0) {
710
- optionDescriptions[key] = opt.description;
711
- }
712
- if (opt.type === "boolean") {
713
- optionTypes[key] = "boolean";
714
- } else if (opt.type === "string") {
715
- optionTypes[key] = "string";
716
- }
717
- if (typeof opt.default === "string" || typeof opt.default === "boolean") {
718
- optionDefaults[key] = opt.default;
719
- }
720
- }
721
- }
722
- const briefing = parseBriefing(parsed["x-monoceros"]?.briefing);
723
- const name = typeof parsed.name === "string" ? parsed.name : "";
724
- const description = typeof parsed.description === "string" ? parsed.description : "";
725
- const rawUrl = typeof parsed.documentationURL === "string" ? parsed.documentationURL.trim() : "";
726
- const documentationURL = rawUrl.length > 0 && rawUrl.toLowerCase() !== "tbd" ? rawUrl : void 0;
727
- return {
728
- name,
729
- description,
730
- documentationURL,
731
- optionHints,
732
- optionDescriptions,
733
- optionNames,
734
- optionTypes,
735
- optionDefaults,
736
- usageNotes,
737
- ...briefing ? { briefing } : {}
738
- };
983
+ descriptor = loadDescriptorCatalogSync(componentsRoot).get(
984
+ match.name
985
+ )?.descriptor;
739
986
  } catch {
740
987
  return void 0;
741
988
  }
742
- }
743
- function parseBriefing(raw) {
744
- if (!raw || typeof raw !== "object") return void 0;
745
- const rawLines = raw.lines;
746
- if (!Array.isArray(rawLines)) return void 0;
747
- const lines = [];
748
- for (const entry2 of rawLines) {
749
- if (!entry2 || typeof entry2 !== "object") continue;
750
- const text = entry2.text;
751
- if (typeof text !== "string" || text.length === 0) continue;
752
- const whenOption = entry2.whenOption;
753
- const line = { text };
754
- if (typeof whenOption === "string" && whenOption.length > 0) {
755
- line.whenOption = whenOption;
756
- }
757
- lines.push(line);
758
- }
759
- if (lines.length === 0) return void 0;
760
- return { lines };
989
+ if (!descriptor || descriptor.category !== "feature") return void 0;
990
+ const optionHints = [];
991
+ const optionDescriptions = {};
992
+ const optionTypes = {};
993
+ const optionDefaults = {};
994
+ const optionNames = [];
995
+ for (const [key, spec] of Object.entries(descriptor.options)) {
996
+ optionNames.push(key);
997
+ if (spec.surface === "env") optionHints.push(key);
998
+ if (spec.description !== void 0 && spec.description.length > 0) {
999
+ optionDescriptions[key] = spec.description;
1000
+ }
1001
+ optionTypes[key] = spec.type === "boolean" ? "boolean" : "string";
1002
+ if (typeof spec.default === "string" || typeof spec.default === "boolean") {
1003
+ optionDefaults[key] = spec.default;
1004
+ }
1005
+ }
1006
+ const rawUrl = descriptor.documentationURL?.trim() ?? "";
1007
+ const documentationURL = rawUrl.length > 0 && rawUrl.toLowerCase() !== "tbd" ? rawUrl : void 0;
1008
+ const briefing = descriptor.briefing.length > 0 ? {
1009
+ lines: descriptor.briefing.map((l) => ({
1010
+ text: l.text,
1011
+ ...l.whenOption !== void 0 ? { whenOption: l.whenOption } : {}
1012
+ }))
1013
+ } : void 0;
1014
+ return {
1015
+ name: descriptor.displayName,
1016
+ description: descriptor.description,
1017
+ documentationURL,
1018
+ optionHints,
1019
+ optionDescriptions,
1020
+ optionNames,
1021
+ optionTypes,
1022
+ optionDefaults,
1023
+ usageNotes: descriptor.usageNotes,
1024
+ ...briefing ? { briefing } : {}
1025
+ };
761
1026
  }
762
1027
  var init_manifest = __esm({
763
1028
  "src/init/manifest.ts"() {
764
1029
  "use strict";
765
- init_paths();
1030
+ init_load_sync();
766
1031
  init_ref();
767
1032
  }
768
1033
  });
@@ -809,8 +1074,8 @@ var init_format = __esm({
809
1074
 
810
1075
  // src/devcontainer/credentials.ts
811
1076
  import { spawn } from "child_process";
812
- import { promises as fs2 } from "fs";
813
- import path4 from "path";
1077
+ import { promises as fs3 } from "fs";
1078
+ import path5 from "path";
814
1079
  function resolveProvider(host, explicit) {
815
1080
  const canonical = KNOWN_PROVIDER_HOSTS[host.toLowerCase()];
816
1081
  if (canonical) return canonical;
@@ -959,8 +1224,8 @@ function formatCredentialLine(host, username, password) {
959
1224
  return `https://${encUser}:${encPass}@${host}`;
960
1225
  }
961
1226
  async function collectGitCredentials(devContainerRoot, hosts, options = {}) {
962
- const credsDir = path4.join(devContainerRoot, ".monoceros");
963
- const credentialsPath = path4.join(credsDir, "git-credentials");
1227
+ const credsDir = path5.join(devContainerRoot, ".monoceros");
1228
+ const credentialsPath = path5.join(credsDir, "git-credentials");
964
1229
  const spawnFn = options.spawn ?? realGitCredentialFill;
965
1230
  const approveFn = options.approve ?? realGitCredentialApprove;
966
1231
  const logger = options.logger ?? { info: () => {
@@ -1024,8 +1289,8 @@ password=${password}
1024
1289
  } catch {
1025
1290
  }
1026
1291
  }
1027
- await fs2.mkdir(credsDir, { recursive: true });
1028
- await fs2.writeFile(
1292
+ await fs3.mkdir(credsDir, { recursive: true });
1293
+ await fs3.writeFile(
1029
1294
  credentialsPath,
1030
1295
  lines.join("\n") + (lines.length > 0 ? "\n" : ""),
1031
1296
  {
@@ -1180,15 +1445,15 @@ var init_locate_running = __esm({
1180
1445
  });
1181
1446
 
1182
1447
  // src/config/global.ts
1183
- import { promises as fs3 } from "fs";
1184
- import { z as z2 } from "zod";
1448
+ import { promises as fs4 } from "fs";
1449
+ import { z as z3 } from "zod";
1185
1450
  import { isMap, Pair, parseDocument as parseDocument2, Scalar, YAMLMap } from "yaml";
1186
1451
  async function readMonocerosConfig(opts = {}) {
1187
1452
  const home = opts.monocerosHome ?? monocerosHome();
1188
1453
  const filePath = monocerosConfigPath(home);
1189
1454
  let text;
1190
1455
  try {
1191
- text = await fs3.readFile(filePath, "utf8");
1456
+ text = await fs4.readFile(filePath, "utf8");
1192
1457
  } catch {
1193
1458
  return void 0;
1194
1459
  }
@@ -1224,7 +1489,7 @@ async function writeGlobalDefaultGitUser(user, opts = {}) {
1224
1489
  const filePath = monocerosConfigPath(home);
1225
1490
  let text;
1226
1491
  try {
1227
- text = await fs3.readFile(filePath, "utf8");
1492
+ text = await fs4.readFile(filePath, "utf8");
1228
1493
  } catch {
1229
1494
  text = void 0;
1230
1495
  }
@@ -1241,8 +1506,8 @@ async function writeGlobalDefaultGitUser(user, opts = {}) {
1241
1506
  ` email: ${user.email}`,
1242
1507
  ""
1243
1508
  ].join("\n");
1244
- await fs3.mkdir(home, { recursive: true });
1245
- await fs3.writeFile(filePath, fresh, "utf8");
1509
+ await fs4.mkdir(home, { recursive: true });
1510
+ await fs4.writeFile(filePath, fresh, "utf8");
1246
1511
  return { filePath, created: true, alreadySet: false };
1247
1512
  }
1248
1513
  const doc = parseDocument2(text, { prettyErrors: true });
@@ -1263,7 +1528,7 @@ async function writeGlobalDefaultGitUser(user, opts = {}) {
1263
1528
  userMap.set("name", user.name);
1264
1529
  userMap.set("email", user.email);
1265
1530
  const newText = String(doc);
1266
- await fs3.writeFile(filePath, newText, "utf8");
1531
+ await fs4.writeFile(filePath, newText, "utf8");
1267
1532
  return { filePath, created: false, alreadySet: false };
1268
1533
  }
1269
1534
  function ensureMap(doc, key) {
@@ -1375,19 +1640,19 @@ var init_global = __esm({
1375
1640
  init_schema();
1376
1641
  init_paths();
1377
1642
  SCHEMA_VERSION = 1;
1378
- MonocerosConfigSchema = z2.object({
1379
- schemaVersion: z2.literal(SCHEMA_VERSION),
1643
+ MonocerosConfigSchema = z3.object({
1644
+ schemaVersion: z3.literal(SCHEMA_VERSION),
1380
1645
  // .nullish() (= .optional().nullable()) on defaults so the shipped
1381
1646
  // sample yml — where `defaults:` is uncommented but every sub-block
1382
1647
  // is commented out — parses cleanly. YAML produces `defaults: null`
1383
1648
  // in that case; without .nullish() the schema would reject it and
1384
1649
  // we'd be back to forcing builders to comment-juggle three lines.
1385
- defaults: z2.object({
1650
+ defaults: z3.object({
1386
1651
  // .nullish() (not just .optional()) so the sample yml can leave
1387
1652
  // `git:` uncommented as a category marker — YAML produces
1388
1653
  // `git: null` for an empty mapping, which zod's plain
1389
1654
  // `.optional()` would reject.
1390
- git: z2.object({
1655
+ git: z3.object({
1391
1656
  // Strict email here: monoceros-config defaults are not tied to
1392
1657
  // any container `<name>.env`, so `${VAR}` placeholders make no
1393
1658
  // sense and the format can (and should) be validated at load
@@ -1400,25 +1665,25 @@ var init_global = __esm({
1400
1665
  }).nullish(),
1401
1666
  // .nullish() for the same reason as `git` — the sample keeps
1402
1667
  // `features:` uncommented as a category marker.
1403
- features: z2.record(
1404
- z2.string().regex(
1668
+ features: z3.record(
1669
+ z3.string().regex(
1405
1670
  REGEX.featureRef,
1406
1671
  "Invalid feature ref. Expected an OCI-image-style ref like 'ghcr.io/getmonoceros/monoceros-features/<name>:<tag>'."
1407
1672
  ),
1408
- z2.record(z2.string(), FeatureOptionValueSchema)
1673
+ z3.record(z3.string(), FeatureOptionValueSchema)
1409
1674
  ).nullish()
1410
1675
  }).nullish(),
1411
1676
  // Machine-global routing settings — one Traefik per builder, so
1412
1677
  // host-port and similar live here rather than in any container yml.
1413
1678
  // See ADR 0007.
1414
- routing: z2.object({
1415
- hostPort: z2.number().int().min(1).max(65535).optional().describe(
1679
+ routing: z3.object({
1680
+ hostPort: z3.number().int().min(1).max(65535).optional().describe(
1416
1681
  "Host port the Traefik singleton binds. Default 80. Set this when 80 is held by another service on your machine \u2014 URLs then become http://<name>.localhost:<port>/."
1417
1682
  )
1418
1683
  }).nullish(),
1419
1684
  // Tool-freshness settings (ADR 0018). One machine-global knob.
1420
- upgrade: z2.object({
1421
- staleDays: z2.number().int().min(1).optional().describe(
1685
+ upgrade: z3.object({
1686
+ staleDays: z3.number().int().min(1).optional().describe(
1422
1687
  "Days after the last `monoceros upgrade` before `apply` nudges you to refresh tooling. Default 30."
1423
1688
  )
1424
1689
  }).nullish()
@@ -1428,49 +1693,81 @@ var init_global = __esm({
1428
1693
  });
1429
1694
 
1430
1695
  // src/init/components.ts
1431
- import { existsSync as existsSync4, promises as fs4 } from "fs";
1432
- import path5 from "path";
1433
- import { z as z3 } from "zod";
1434
- import { parse as parseYaml } from "yaml";
1435
- async function loadComponentCatalog(rootDir = componentsDir()) {
1436
- if (!existsSync4(rootDir)) {
1437
- return /* @__PURE__ */ new Map();
1696
+ function featureRef(d) {
1697
+ const major = (d.feature?.version ?? "1").split(".")[0];
1698
+ return `ghcr.io/getmonoceros/monoceros-features/${d.id}:${major}`;
1699
+ }
1700
+ function surfaceYmlDefaults(d) {
1701
+ const out = {};
1702
+ for (const [key, spec] of Object.entries(d.options)) {
1703
+ if (spec.surface === "yml" && spec.default !== void 0) {
1704
+ out[key] = spec.default;
1705
+ }
1438
1706
  }
1439
- const out = /* @__PURE__ */ new Map();
1440
- await walk(rootDir, rootDir, out);
1441
1707
  return out;
1442
1708
  }
1443
- async function walk(baseDir, currentDir, out) {
1444
- const entries = await fs4.readdir(currentDir, { withFileTypes: true });
1445
- for (const entry2 of entries) {
1446
- const full = path5.join(currentDir, entry2.name);
1447
- if (entry2.isDirectory()) {
1448
- await walk(baseDir, full, out);
1449
- continue;
1709
+ function baseComponentFile(d) {
1710
+ const selector = d.name ?? d.id;
1711
+ if (d.category === "language") {
1712
+ return {
1713
+ displayName: d.displayName,
1714
+ description: d.description,
1715
+ category: "language",
1716
+ contributes: { languages: [selector] }
1717
+ };
1718
+ }
1719
+ if (d.category === "service") {
1720
+ return {
1721
+ displayName: d.displayName,
1722
+ description: d.description,
1723
+ category: "service",
1724
+ contributes: { services: [selector] }
1725
+ };
1726
+ }
1727
+ return {
1728
+ displayName: d.displayName,
1729
+ description: d.description,
1730
+ category: "feature",
1731
+ contributes: {
1732
+ features: [{ ref: featureRef(d), options: surfaceYmlDefaults(d) }]
1450
1733
  }
1451
- if (!entry2.isFile() || !entry2.name.endsWith(".yml")) continue;
1452
- const relative = path5.relative(baseDir, full);
1453
- const name = relative.replace(/\.yml$/, "").split(path5.sep).join("/");
1454
- const text = await fs4.readFile(full, "utf8");
1455
- let raw;
1456
- try {
1457
- raw = parseYaml(text);
1458
- } catch (err) {
1459
- throw new Error(
1460
- `Failed to parse component ${name} (${full}): ${err.message}`
1461
- );
1734
+ };
1735
+ }
1736
+ async function loadComponentCatalog(rootDir) {
1737
+ return buildComponentCatalog(await loadDescriptorCatalog(rootDir));
1738
+ }
1739
+ function buildComponentCatalog(descriptors) {
1740
+ const out = /* @__PURE__ */ new Map();
1741
+ const add = (name, file, sourcePath) => {
1742
+ if (out.has(name)) {
1743
+ throw new Error(`Duplicate component name '${name}' (${sourcePath}).`);
1462
1744
  }
1463
- const parsed = ComponentFileSchema.safeParse(raw);
1464
- if (!parsed.success) {
1465
- const issues = parsed.error.issues.map((issue) => {
1466
- const where = issue.path.length > 0 ? issue.path.join(".") : "(root)";
1467
- return ` - ${where}: ${issue.message}`;
1468
- }).join("\n");
1469
- throw new Error(`Invalid component ${name} (${full}):
1470
- ${issues}`);
1745
+ out.set(name, { name, sourcePath, file });
1746
+ };
1747
+ for (const { descriptor: d, sourcePath } of descriptors.values()) {
1748
+ const selector = d.name ?? d.id;
1749
+ add(selector, baseComponentFile(d), sourcePath);
1750
+ for (const [presetKey, overrides] of Object.entries(d.presets ?? {})) {
1751
+ add(
1752
+ `${selector}/${presetKey}`,
1753
+ {
1754
+ displayName: `${d.displayName} (${presetKey})`,
1755
+ description: d.description,
1756
+ category: "feature",
1757
+ contributes: {
1758
+ features: [
1759
+ {
1760
+ ref: featureRef(d),
1761
+ options: { ...surfaceYmlDefaults(d), ...overrides }
1762
+ }
1763
+ ]
1764
+ }
1765
+ },
1766
+ sourcePath
1767
+ );
1471
1768
  }
1472
- out.set(name, { name, sourcePath: full, file: parsed.data });
1473
1769
  }
1770
+ return out;
1474
1771
  }
1475
1772
  function mergeFeatureOptions(a, b) {
1476
1773
  const result = { ...a };
@@ -1484,55 +1781,10 @@ function mergeFeatureOptions(a, b) {
1484
1781
  }
1485
1782
  return result;
1486
1783
  }
1487
- var CategorySchema, FeatureContributionSchema, ComponentFileSchema;
1488
1784
  var init_components = __esm({
1489
1785
  "src/init/components.ts"() {
1490
1786
  "use strict";
1491
- init_paths();
1492
- init_schema();
1493
- CategorySchema = z3.enum(["language", "service", "feature"]);
1494
- FeatureContributionSchema = z3.object({
1495
- ref: z3.string().regex(REGEX.featureRef),
1496
- options: z3.record(z3.string(), FeatureOptionValueSchema).optional()
1497
- });
1498
- ComponentFileSchema = z3.object({
1499
- displayName: z3.string().min(1),
1500
- description: z3.string().min(1),
1501
- category: CategorySchema,
1502
- contributes: z3.object({
1503
- languages: z3.array(z3.string().min(1)).optional(),
1504
- services: z3.array(z3.string().min(1)).optional(),
1505
- features: z3.array(FeatureContributionSchema).optional()
1506
- })
1507
- }).superRefine((data, ctx) => {
1508
- const c = data.contributes;
1509
- const filled = [
1510
- c.languages && c.languages.length > 0 ? "languages" : null,
1511
- c.services && c.services.length > 0 ? "services" : null,
1512
- c.features && c.features.length > 0 ? "features" : null
1513
- ].filter((x) => x !== null);
1514
- if (filled.length === 0) {
1515
- ctx.addIssue({
1516
- code: z3.ZodIssueCode.custom,
1517
- message: "contributes must set at least one of languages/services/features"
1518
- });
1519
- return;
1520
- }
1521
- if (filled.length > 1) {
1522
- ctx.addIssue({
1523
- code: z3.ZodIssueCode.custom,
1524
- message: `contributes must set exactly one of languages/services/features, got: ${filled.join(", ")}`
1525
- });
1526
- return;
1527
- }
1528
- const expected = data.category === "language" ? "languages" : data.category === "service" ? "services" : "features";
1529
- if (filled[0] !== expected) {
1530
- ctx.addIssue({
1531
- code: z3.ZodIssueCode.custom,
1532
- message: `category '${data.category}' requires contributes.${expected}, got contributes.${filled[0]}`
1533
- });
1534
- }
1535
- });
1787
+ init_load();
1536
1788
  }
1537
1789
  });
1538
1790
 
@@ -1858,6 +2110,25 @@ function runtimeSupportsIdeVolumes(version) {
1858
2110
  if (!version) return false;
1859
2111
  return compareRuntimeVersions(version, MIN_RUNTIME_FOR_IDE_VOLUMES) >= 0;
1860
2112
  }
2113
+ function descriptorOptionDefaults(options) {
2114
+ const out = {};
2115
+ for (const [key, spec] of Object.entries(options)) {
2116
+ if (spec.default !== void 0) out[key] = spec.default;
2117
+ }
2118
+ return out;
2119
+ }
2120
+ function descriptorYmlOptionDefaults(options) {
2121
+ const out = {};
2122
+ for (const [key, spec] of Object.entries(options)) {
2123
+ if (spec.surface === "yml" && spec.default !== void 0) {
2124
+ out[key] = spec.default;
2125
+ }
2126
+ }
2127
+ return out;
2128
+ }
2129
+ function descriptorSelector(c) {
2130
+ return c.descriptor.name ?? c.descriptor.id;
2131
+ }
1861
2132
  function parseLanguageSpec(spec) {
1862
2133
  const m = LANGUAGE_SPEC_RE.exec(spec);
1863
2134
  if (!m) return null;
@@ -1912,34 +2183,17 @@ function curatedServiceEnvDefaults(name) {
1912
2183
  function serviceConnectionEnv(services) {
1913
2184
  const env = {};
1914
2185
  for (const svc of services) {
1915
- if (!isCuratedService(svc.name)) continue;
2186
+ const def = SERVICE_CATALOG[svc.name];
2187
+ if (!def?.connectionEnv) continue;
1916
2188
  const host = svc.name;
1917
- if (svc.name === "postgres") {
1918
- const user = svc.env.POSTGRES_USER ?? "postgres";
1919
- const pass = svc.env.POSTGRES_PASSWORD ?? "";
1920
- const db = svc.env.POSTGRES_DB ?? user;
1921
- const port = svc.port ?? 5432;
1922
- env.PGHOST = host;
1923
- env.PGPORT = String(port);
1924
- env.PGUSER = user;
1925
- env.PGPASSWORD = pass;
1926
- env.PGDATABASE = db;
1927
- env.DATABASE_URL = `postgresql://${user}:${pass}@${host}:${port}/${db}`;
1928
- } else if (svc.name === "mysql") {
1929
- const pass = svc.env.MYSQL_ROOT_PASSWORD ?? "";
1930
- const db = svc.env.MYSQL_DATABASE ?? "";
1931
- const port = svc.port ?? 3306;
1932
- env.MYSQL_HOST = host;
1933
- env.MYSQL_PORT = String(port);
1934
- env.MYSQL_USER = "root";
1935
- env.MYSQL_PASSWORD = pass;
1936
- env.MYSQL_DATABASE = db;
1937
- if (env.DATABASE_URL === void 0) {
1938
- env.DATABASE_URL = `mysql://root:${pass}@${host}:${port}/${db}`;
1939
- }
1940
- } else if (svc.name === "redis") {
1941
- const port = svc.port ?? 6379;
1942
- env.REDIS_URL = `redis://${host}:${port}`;
2189
+ const port = String(svc.port ?? def.defaultPort);
2190
+ const fill = (template) => template.replace(/\$\{([A-Za-z0-9_]+)\}/g, (_, token) => {
2191
+ if (token === "host") return host;
2192
+ if (token === "port") return port;
2193
+ return svc.env[token] ?? "";
2194
+ });
2195
+ for (const [key, template] of Object.entries(def.connectionEnv)) {
2196
+ env[key] = fill(template);
1943
2197
  }
1944
2198
  }
1945
2199
  return env;
@@ -1949,10 +2203,11 @@ function deriveServiceName(image) {
1949
2203
  const noTag = lastSegment.split("@")[0].split(":")[0];
1950
2204
  return noTag.toLowerCase().replace(/[^a-z0-9_-]/g, "-");
1951
2205
  }
1952
- var DEFAULT_BASE_IMAGE, override, BASE_IMAGE, RUNTIME_IMAGE_REPO, DEFAULT_RUNTIME_VERSION, MIN_RUNTIME_FOR_IDE_VOLUMES, BUILTIN_LANGUAGES, LANGUAGE_CATALOG, LANGUAGE_SPEC_RE, SERVICE_CATALOG;
2206
+ var DEFAULT_BASE_IMAGE, override, BASE_IMAGE, RUNTIME_IMAGE_REPO, DEFAULT_RUNTIME_VERSION, MIN_RUNTIME_FOR_IDE_VOLUMES, DESCRIPTORS, BUILTIN_LANGUAGES, LANGUAGE_CATALOG, LANGUAGE_SPEC_RE, SERVICE_CATALOG;
1953
2207
  var init_catalog = __esm({
1954
2208
  "src/create/catalog.ts"() {
1955
2209
  "use strict";
2210
+ init_load_sync();
1956
2211
  DEFAULT_BASE_IMAGE = "ghcr.io/getmonoceros/monoceros-runtime:1";
1957
2212
  override = process.env.MONOCEROS_BASE_IMAGE_OVERRIDE?.trim();
1958
2213
  BASE_IMAGE = override && override.length > 0 ? override : DEFAULT_BASE_IMAGE;
@@ -1964,86 +2219,49 @@ var init_catalog = __esm({
1964
2219
  "utf8"
1965
2220
  ).trim();
1966
2221
  MIN_RUNTIME_FOR_IDE_VOLUMES = "1.1.0";
1967
- BUILTIN_LANGUAGES = /* @__PURE__ */ new Set(["node"]);
1968
- LANGUAGE_CATALOG = {
1969
- node: { id: "node", feature: "ghcr.io/devcontainers/features/node:1" },
1970
- python: { id: "python", feature: "ghcr.io/devcontainers/features/python:1" },
1971
- java: { id: "java", feature: "ghcr.io/devcontainers/features/java:1" },
1972
- go: { id: "go", feature: "ghcr.io/devcontainers/features/go:1" },
1973
- rust: { id: "rust", feature: "ghcr.io/devcontainers/features/rust:1" },
1974
- dotnet: { id: "dotnet", feature: "ghcr.io/devcontainers/features/dotnet:2" }
1975
- };
2222
+ DESCRIPTORS = loadDescriptorCatalogSync();
2223
+ BUILTIN_LANGUAGES = new Set(
2224
+ [...DESCRIPTORS.values()].filter((c) => c.category === "language" && c.descriptor.language?.builtin).map(descriptorSelector)
2225
+ );
2226
+ LANGUAGE_CATALOG = Object.fromEntries(
2227
+ [...DESCRIPTORS.values()].filter((c) => c.category === "language").map((c) => {
2228
+ const key = descriptorSelector(c);
2229
+ const defaults = descriptorOptionDefaults(c.descriptor.options);
2230
+ const ymlOptions = descriptorYmlOptionDefaults(c.descriptor.options);
2231
+ const entry2 = {
2232
+ id: key,
2233
+ feature: c.descriptor.language.feature,
2234
+ ...Object.keys(defaults).length > 0 ? { defaultOptions: defaults } : {},
2235
+ ...Object.keys(ymlOptions).length > 0 ? { ymlOptions } : {},
2236
+ ...c.descriptor.language.defaultVersion !== void 0 ? { defaultVersion: c.descriptor.language.defaultVersion } : {}
2237
+ };
2238
+ return [key, entry2];
2239
+ })
2240
+ );
1976
2241
  LANGUAGE_SPEC_RE = /^([a-z][a-z0-9-]*)(?::([A-Za-z0-9._-]+))?$/;
1977
- SERVICE_CATALOG = {
1978
- postgres: {
1979
- id: "postgres",
1980
- image: "postgres:18",
1981
- env: {
1982
- POSTGRES_USER: "monoceros",
1983
- POSTGRES_PASSWORD: "monoceros",
1984
- POSTGRES_DB: "monoceros"
1985
- },
1986
- healthcheck: {
1987
- test: [
1988
- "CMD",
1989
- "pg_isready",
1990
- "-U",
1991
- "${POSTGRES_USER}",
1992
- "-d",
1993
- "${POSTGRES_DB}"
1994
- ],
1995
- interval: "10s",
1996
- timeout: "5s",
1997
- retries: 5
1998
- },
1999
- // Postgres 18+ stores data under /var/lib/postgresql/<major>/, so
2000
- // the recommended mount is the parent directory; pre-18 used
2001
- // /var/lib/postgresql/data directly. See
2002
- // https://github.com/docker-library/postgres/pull/1259.
2003
- dataMount: "/var/lib/postgresql",
2004
- defaultPort: 5432,
2005
- vscodeExtensions: ["cweijan.vscode-database-client2"]
2006
- },
2007
- mysql: {
2008
- id: "mysql",
2009
- image: "mysql:8",
2010
- env: {
2011
- MYSQL_ROOT_PASSWORD: "monoceros",
2012
- MYSQL_DATABASE: "monoceros"
2013
- },
2014
- healthcheck: {
2015
- test: [
2016
- "CMD",
2017
- "mysqladmin",
2018
- "ping",
2019
- "-h",
2020
- "127.0.0.1",
2021
- "-u",
2022
- "root",
2023
- "-p${MYSQL_ROOT_PASSWORD}"
2024
- ],
2025
- interval: "10s",
2026
- timeout: "5s",
2027
- retries: 5
2028
- },
2029
- dataMount: "/var/lib/mysql",
2030
- defaultPort: 3306,
2031
- vscodeExtensions: ["cweijan.vscode-database-client2"]
2032
- },
2033
- redis: {
2034
- id: "redis",
2035
- image: "redis:8",
2036
- healthcheck: {
2037
- test: ["CMD", "redis-cli", "ping"],
2038
- interval: "10s",
2039
- timeout: "5s",
2040
- retries: 5
2041
- },
2042
- dataMount: "/data",
2043
- defaultPort: 6379,
2044
- vscodeExtensions: ["cweijan.vscode-database-client2"]
2045
- }
2046
- };
2242
+ SERVICE_CATALOG = Object.fromEntries(
2243
+ [...DESCRIPTORS.values()].filter((c) => c.category === "service").map((c) => {
2244
+ const key = descriptorSelector(c);
2245
+ const svc = c.descriptor.service;
2246
+ if (svc.defaultPort === void 0) {
2247
+ throw new Error(
2248
+ `Service descriptor '${key}' is missing service.defaultPort.`
2249
+ );
2250
+ }
2251
+ const env = descriptorOptionDefaults(c.descriptor.options);
2252
+ const entry2 = {
2253
+ id: key,
2254
+ image: svc.image,
2255
+ ...Object.keys(env).length > 0 ? { env } : {},
2256
+ ...svc.healthcheck ? { healthcheck: svc.healthcheck } : {},
2257
+ ...svc.dataMount ? { dataMount: svc.dataMount } : {},
2258
+ defaultPort: svc.defaultPort,
2259
+ ...svc.vscodeExtensions ? { vscodeExtensions: svc.vscodeExtensions } : {},
2260
+ ...svc.connectionEnv ? { connectionEnv: svc.connectionEnv } : {}
2261
+ };
2262
+ return [key, entry2];
2263
+ })
2264
+ );
2047
2265
  }
2048
2266
  });
2049
2267
 
@@ -2104,6 +2322,66 @@ var init_service_doc = __esm({
2104
2322
  }
2105
2323
  });
2106
2324
 
2325
+ // src/catalog/generate-manifest.ts
2326
+ function descriptorToFeatureManifest(descriptor) {
2327
+ if (descriptor.category !== "feature" || !descriptor.feature) {
2328
+ throw new Error(
2329
+ `descriptorToFeatureManifest: '${descriptor.id}' is a ${descriptor.category}, not a feature.`
2330
+ );
2331
+ }
2332
+ const feat = descriptor.feature;
2333
+ const options = {};
2334
+ const optionHints = [];
2335
+ for (const [key, spec] of Object.entries(descriptor.options)) {
2336
+ const option = { type: spec.type };
2337
+ if (spec.proposals !== void 0) option.proposals = spec.proposals;
2338
+ if (spec.default !== void 0) option.default = spec.default;
2339
+ if (spec.description !== void 0) option.description = spec.description;
2340
+ options[key] = option;
2341
+ if (spec.surface === "env") optionHints.push(key);
2342
+ }
2343
+ const xMonoceros = {};
2344
+ if (feat.persistentHomePaths && feat.persistentHomePaths.length > 0) {
2345
+ xMonoceros.persistentHomePaths = feat.persistentHomePaths;
2346
+ }
2347
+ if (feat.persistentHomeFiles && feat.persistentHomeFiles.length > 0) {
2348
+ xMonoceros.persistentHomeFiles = feat.persistentHomeFiles;
2349
+ }
2350
+ xMonoceros.optionHints = optionHints;
2351
+ xMonoceros.usageNotes = descriptor.usageNotes;
2352
+ xMonoceros.briefing = {
2353
+ lines: descriptor.briefing.map((line) => ({
2354
+ ...line.whenOption !== void 0 ? { whenOption: line.whenOption } : {},
2355
+ text: line.text
2356
+ }))
2357
+ };
2358
+ const manifest = {
2359
+ $schema: DEVCONTAINER_FEATURE_SCHEMA,
2360
+ id: descriptor.id,
2361
+ name: descriptor.displayName,
2362
+ version: feat.version,
2363
+ description: descriptor.description
2364
+ };
2365
+ if (descriptor.documentationURL !== void 0) {
2366
+ manifest.documentationURL = descriptor.documentationURL;
2367
+ }
2368
+ if (Object.keys(options).length > 0) {
2369
+ manifest.options = options;
2370
+ }
2371
+ if (feat.vscodeExtensions && feat.vscodeExtensions.length > 0) {
2372
+ manifest.customizations = { vscode: { extensions: feat.vscodeExtensions } };
2373
+ }
2374
+ manifest["x-monoceros"] = xMonoceros;
2375
+ return manifest;
2376
+ }
2377
+ var DEVCONTAINER_FEATURE_SCHEMA;
2378
+ var init_generate_manifest = __esm({
2379
+ "src/catalog/generate-manifest.ts"() {
2380
+ "use strict";
2381
+ DEVCONTAINER_FEATURE_SCHEMA = "https://raw.githubusercontent.com/devcontainers/spec/main/schemas/devContainerFeature.schema.json";
2382
+ }
2383
+ });
2384
+
2107
2385
  // src/create/claude-settings.ts
2108
2386
  import { existsSync as existsSync5, promises as fsp2 } from "fs";
2109
2387
  import path8 from "path";
@@ -2180,7 +2458,7 @@ var init_claude_settings = __esm({
2180
2458
  });
2181
2459
 
2182
2460
  // src/create/scaffold.ts
2183
- import { existsSync as existsSync6, readFileSync as readFileSync4, promises as fs7 } from "fs";
2461
+ import { existsSync as existsSync6, promises as fs7 } from "fs";
2184
2462
  import path9 from "path";
2185
2463
  function deriveRepoName(url) {
2186
2464
  const lastSep = Math.max(url.lastIndexOf("/"), url.lastIndexOf(":"));
@@ -2294,6 +2572,7 @@ function normalizeOptions(opts) {
2294
2572
  name: opts.name,
2295
2573
  ...opts.runtimeVersion !== void 0 ? { runtimeVersion: opts.runtimeVersion } : {},
2296
2574
  languages,
2575
+ ...opts.languageOptions && Object.keys(opts.languageOptions).length > 0 ? { languageOptions: opts.languageOptions } : {},
2297
2576
  services,
2298
2577
  postgresUrl: opts.postgresUrl,
2299
2578
  ...aptPackages.length > 0 ? { aptPackages } : {},
@@ -2311,19 +2590,22 @@ function featuresSourceRoot() {
2311
2590
  const override2 = process.env.MONOCEROS_FEATURES_DIR_OVERRIDE?.trim();
2312
2591
  if (override2 && override2.length > 0) return override2;
2313
2592
  const checkout = workbenchCheckoutRoot();
2314
- return checkout ? path9.join(checkout, "images", "features") : null;
2593
+ return checkout ? path9.join(checkout, "components", "features") : null;
2315
2594
  }
2316
2595
  function resolveFeatures(opts) {
2317
2596
  const resolved = [];
2318
2597
  for (const langSpec of opts.languages) {
2319
2598
  const parsed = parseLanguageSpec(langSpec);
2320
2599
  if (!parsed) continue;
2321
- if (BUILTIN_LANGUAGES.has(parsed.name) && parsed.version === void 0) {
2322
- continue;
2323
- }
2324
2600
  const entry2 = LANGUAGE_CATALOG[parsed.name];
2325
2601
  if (!entry2) continue;
2326
- const options = {};
2602
+ if (BUILTIN_LANGUAGES.has(parsed.name) && (parsed.version === void 0 || parsed.version === entry2.defaultVersion)) {
2603
+ continue;
2604
+ }
2605
+ const options = {
2606
+ ...entry2.defaultOptions ?? {},
2607
+ ...opts.languageOptions?.[parsed.name] ?? {}
2608
+ };
2327
2609
  if (parsed.version !== void 0) options.version = parsed.version;
2328
2610
  resolved.push({
2329
2611
  devcontainerKey: entry2.feature,
@@ -2345,21 +2627,22 @@ function resolveFeatures(opts) {
2345
2627
  const match = matchMonocerosFeature(rawRef);
2346
2628
  if (match) {
2347
2629
  const name = match.name;
2630
+ const descriptor = featureDescriptor(name);
2631
+ const { paths, files } = descriptorPersistentHome(descriptor);
2348
2632
  const sourceRoot = featuresSourceRoot();
2349
2633
  const localSourceDir = sourceRoot ? path9.join(sourceRoot, name) : null;
2350
- if (localSourceDir && existsSync6(localSourceDir)) {
2351
- const { paths: paths2, files: files2 } = readPersistentHomeEntries(localSourceDir);
2634
+ if (descriptor && localSourceDir && existsSync6(localSourceDir)) {
2352
2635
  resolved.push({
2353
2636
  devcontainerKey: `./features/${name}`,
2354
2637
  options,
2355
2638
  localSourceDir,
2356
2639
  localName: name,
2357
- persistentHomePaths: paths2,
2358
- persistentHomeFiles: files2
2640
+ generatedManifest: descriptorToFeatureManifest(descriptor),
2641
+ persistentHomePaths: paths,
2642
+ persistentHomeFiles: files
2359
2643
  });
2360
2644
  continue;
2361
2645
  }
2362
- const { paths, files } = readBundledPersistentHomeEntries(name);
2363
2646
  resolved.push({
2364
2647
  devcontainerKey: rawRef,
2365
2648
  options,
@@ -2378,25 +2661,19 @@ function resolveFeatures(opts) {
2378
2661
  }
2379
2662
  return resolved;
2380
2663
  }
2381
- function readPersistentHomeEntries(localSourceDir) {
2382
- const manifestPath = path9.join(localSourceDir, "devcontainer-feature.json");
2664
+ function featureDescriptor(name) {
2383
2665
  try {
2384
- const text = readFileSync4(manifestPath, "utf8");
2385
- const parsed = JSON.parse(text);
2386
- return {
2387
- paths: filterSubpaths(parsed["x-monoceros"]?.persistentHomePaths),
2388
- files: filterFileEntries(parsed["x-monoceros"]?.persistentHomeFiles)
2389
- };
2666
+ const c = loadDescriptorCatalogSync().get(name);
2667
+ return c?.category === "feature" ? c.descriptor : void 0;
2390
2668
  } catch {
2391
- return { paths: [], files: [] };
2669
+ return void 0;
2392
2670
  }
2393
2671
  }
2394
- function readBundledPersistentHomeEntries(name) {
2395
- try {
2396
- return readPersistentHomeEntries(path9.join(bundledFeaturesDir(), name));
2397
- } catch {
2398
- return { paths: [], files: [] };
2399
- }
2672
+ function descriptorPersistentHome(descriptor) {
2673
+ return {
2674
+ paths: filterSubpaths(descriptor?.feature?.persistentHomePaths),
2675
+ files: filterFileEntries(descriptor?.feature?.persistentHomeFiles)
2676
+ };
2400
2677
  }
2401
2678
  function filterSubpaths(raw) {
2402
2679
  if (!Array.isArray(raw)) return [];
@@ -2858,7 +3135,23 @@ async function writeScaffold(opts, targetDir, scaffoldOpts = {}) {
2858
3135
  if (!f.localSourceDir || !f.localName) continue;
2859
3136
  const dest = path9.join(featuresDir, f.localName);
2860
3137
  await fs7.mkdir(dest, { recursive: true });
2861
- await fs7.cp(f.localSourceDir, dest, { recursive: true });
3138
+ const entries = await fs7.readdir(f.localSourceDir, { withFileTypes: true });
3139
+ for (const entry2 of entries) {
3140
+ if (!entry2.isFile()) continue;
3141
+ if (entry2.name === "component.yml" || entry2.name === "devcontainer-feature.json") {
3142
+ continue;
3143
+ }
3144
+ await fs7.cp(
3145
+ path9.join(f.localSourceDir, entry2.name),
3146
+ path9.join(dest, entry2.name)
3147
+ );
3148
+ }
3149
+ if (f.generatedManifest) {
3150
+ await fs7.writeFile(
3151
+ path9.join(dest, "devcontainer-feature.json"),
3152
+ JSON.stringify(f.generatedManifest, null, 2) + "\n"
3153
+ );
3154
+ }
2862
3155
  }
2863
3156
  for (const f of resolvedFeatures) {
2864
3157
  for (const sub of f.persistentHomePaths) {
@@ -2911,6 +3204,8 @@ var init_scaffold = __esm({
2911
3204
  "use strict";
2912
3205
  init_paths();
2913
3206
  init_ref();
3207
+ init_load_sync();
3208
+ init_generate_manifest();
2914
3209
  init_claude_settings();
2915
3210
  init_catalog();
2916
3211
  APT_PACKAGE_NAME_RE2 = /^[a-z0-9][a-z0-9.+-]*$/;
@@ -2966,10 +3261,33 @@ function pruneEmptySeq(doc, key) {
2966
3261
  function scalarValue(item) {
2967
3262
  return isScalar(item) ? item.value : item;
2968
3263
  }
2969
- function addLanguageToDoc(doc, lang) {
3264
+ function languageEntryName(item) {
3265
+ const v = scalarValue(item);
3266
+ if (typeof v === "string") {
3267
+ const colon = v.indexOf(":");
3268
+ return colon === -1 ? v : v.slice(0, colon);
3269
+ }
3270
+ if (isMap2(item)) {
3271
+ const firstKey = item.items[0]?.key;
3272
+ const k = isScalar(firstKey) ? firstKey.value : firstKey;
3273
+ return typeof k === "string" ? k : void 0;
3274
+ }
3275
+ return void 0;
3276
+ }
3277
+ function addLanguageToDoc(doc, name, opts = {}) {
2970
3278
  const seq = ensureSeq(doc, "languages");
2971
- if (seq.items.some((i) => scalarValue(i) === lang)) return false;
2972
- seq.add(lang);
3279
+ if (seq.items.some((i) => languageEntryName(i) === name)) return false;
3280
+ const { version, options } = opts;
3281
+ if (options && Object.keys(options).length > 0) {
3282
+ const inner = new YAMLMap2();
3283
+ if (version !== void 0) inner.set("version", version);
3284
+ for (const [key, value] of Object.entries(options)) inner.set(key, value);
3285
+ const outer = new YAMLMap2();
3286
+ outer.set(name, inner);
3287
+ seq.add(outer);
3288
+ } else {
3289
+ seq.add(version !== void 0 ? `${name}:${version}` : name);
3290
+ }
2973
3291
  return true;
2974
3292
  }
2975
3293
  function findServiceItem(seq, name) {
@@ -3382,12 +3700,22 @@ import { consola } from "consola";
3382
3700
  import { createPatch } from "diff";
3383
3701
  import path10 from "path";
3384
3702
  function runAddLanguage(input) {
3385
- if (!BUILTIN_LANGUAGES.has(input.language) && !LANGUAGE_CATALOG[input.language]) {
3703
+ const spec = parseLanguageSpec(input.language);
3704
+ if (!spec || !BUILTIN_LANGUAGES.has(spec.name) && !LANGUAGE_CATALOG[spec.name]) {
3386
3705
  throw new Error(
3387
3706
  `Unknown language: ${input.language}. Known: ${knownLanguages().join(", ")}.`
3388
3707
  );
3389
3708
  }
3390
- return mutate(input, (doc) => addLanguageToDoc(doc, input.language));
3709
+ const entry2 = LANGUAGE_CATALOG[spec.name];
3710
+ const version = spec.version ?? entry2?.defaultVersion;
3711
+ const options = entry2?.ymlOptions;
3712
+ return mutate(
3713
+ input,
3714
+ (doc) => addLanguageToDoc(doc, spec.name, {
3715
+ ...version !== void 0 ? { version } : {},
3716
+ ...options && Object.keys(options).length > 0 ? { options } : {}
3717
+ })
3718
+ );
3391
3719
  }
3392
3720
  async function runAddService(input) {
3393
3721
  const arg = input.service;
@@ -4443,6 +4771,23 @@ var init_state = __esm({
4443
4771
  });
4444
4772
 
4445
4773
  // src/config/transform.ts
4774
+ function normalizeLanguages(entries) {
4775
+ const languages = [];
4776
+ const languageOptions = {};
4777
+ for (const entry2 of entries) {
4778
+ if (typeof entry2 === "string") {
4779
+ languages.push(entry2);
4780
+ continue;
4781
+ }
4782
+ const name = Object.keys(entry2)[0];
4783
+ const opts = { ...entry2[name] };
4784
+ const version = opts.version;
4785
+ delete opts.version;
4786
+ languages.push(version !== void 0 ? `${name}:${String(version)}` : name);
4787
+ if (Object.keys(opts).length > 0) languageOptions[name] = opts;
4788
+ }
4789
+ return { languages, languageOptions };
4790
+ }
4446
4791
  function solutionConfigToCreateOptions(config, featureDefaults = {}) {
4447
4792
  const featureRecord = {};
4448
4793
  for (const entry2 of config.features) {
@@ -4452,16 +4797,20 @@ function solutionConfigToCreateOptions(config, featureDefaults = {}) {
4452
4797
  );
4453
4798
  featureRecord[entry2.ref] = { ...defaults, ...containerOpts };
4454
4799
  }
4800
+ const { languages, languageOptions } = normalizeLanguages(config.languages);
4455
4801
  const result = {
4456
4802
  name: config.name,
4457
4803
  ...config.runtimeVersion !== void 0 ? { runtimeVersion: config.runtimeVersion } : {},
4458
- languages: [...config.languages],
4804
+ languages,
4459
4805
  // Normalize every services[] entry (curated string or explicit
4460
4806
  // object) to the canonical ResolvedService shape. `${VAR}` values
4461
4807
  // survive untouched here — apply interpolates them against
4462
4808
  // <name>.env afterwards.
4463
4809
  services: config.services.map(resolveService)
4464
4810
  };
4811
+ if (Object.keys(languageOptions).length > 0) {
4812
+ result.languageOptions = languageOptions;
4813
+ }
4465
4814
  if (config.externalServices.postgres !== void 0) {
4466
4815
  result.postgresUrl = config.externalServices.postgres;
4467
4816
  }
@@ -4967,6 +5316,13 @@ function generateAgentsMd(input) {
4967
5316
  }
4968
5317
  }
4969
5318
  lines.push("");
5319
+ lines.push(
5320
+ "To show the user a running app, open it in their host browser with",
5321
+ `\`xdg-open http://${input.containerName}.localhost\` \u2014 Monoceros relays`,
5322
+ "browser-opens from the container to the host machine. Also tell the user",
5323
+ "the URL, so they can open it themselves if no bridge is active."
5324
+ );
5325
+ lines.push("");
4970
5326
  }
4971
5327
  lines.push("## How to extend this container");
4972
5328
  lines.push("");
@@ -5548,13 +5904,13 @@ var init_runtime_pull_hint = __esm({
5548
5904
 
5549
5905
  // src/devcontainer/cli.ts
5550
5906
  import { spawn as spawn4 } from "child_process";
5551
- import { readFileSync as readFileSync5 } from "fs";
5907
+ import { readFileSync as readFileSync4 } from "fs";
5552
5908
  import { createRequire } from "module";
5553
5909
  import path14 from "path";
5554
5910
  function devcontainerCliPath() {
5555
5911
  if (cachedBinaryPath) return cachedBinaryPath;
5556
5912
  const pkgJsonPath = require_.resolve("@devcontainers/cli/package.json");
5557
- const pkg = JSON.parse(readFileSync5(pkgJsonPath, "utf8"));
5913
+ const pkg = JSON.parse(readFileSync4(pkgJsonPath, "utf8"));
5558
5914
  const binEntry = typeof pkg.bin === "string" ? pkg.bin : pkg.bin?.devcontainer ?? "";
5559
5915
  if (!binEntry) {
5560
5916
  throw new Error("Could not resolve @devcontainers/cli bin entry.");
@@ -6699,7 +7055,7 @@ var CLI_VERSION;
6699
7055
  var init_version = __esm({
6700
7056
  "src/version.ts"() {
6701
7057
  "use strict";
6702
- CLI_VERSION = true ? "1.21.2" : "dev";
7058
+ CLI_VERSION = true ? "1.22.0" : "dev";
6703
7059
  }
6704
7060
  });
6705
7061
 
@@ -7346,7 +7702,21 @@ function generateComposedYml(name, composed, lookupManifest, repoUrls = [], port
7346
7702
  false
7347
7703
  );
7348
7704
  lines.push("languages:");
7349
- for (const lang of composed.languages) lines.push(` - ${lang}`);
7705
+ for (const lang of composed.languages) {
7706
+ const opts = lang.options ?? {};
7707
+ if (Object.keys(opts).length === 0) {
7708
+ lines.push(` - ${lang.spec}`);
7709
+ continue;
7710
+ }
7711
+ const colon = lang.spec.indexOf(":");
7712
+ const langName = colon === -1 ? lang.spec : lang.spec.slice(0, colon);
7713
+ const version = colon === -1 ? void 0 : lang.spec.slice(colon + 1);
7714
+ lines.push(` - ${langName}:`);
7715
+ if (version !== void 0) lines.push(` version: ${version}`);
7716
+ for (const [key, value] of Object.entries(opts)) {
7717
+ lines.push(` ${key}: ${String(value)}`);
7718
+ }
7719
+ }
7350
7720
  lines.push("");
7351
7721
  }
7352
7722
  if (composed.aptPackages.length > 0) {
@@ -7454,7 +7824,8 @@ function generateDocumentedYml(name, catalog, lookupManifest, repoUrls = [], por
7454
7824
  lines.push("# languages:");
7455
7825
  for (const c of byCategory.language) {
7456
7826
  for (const lang of c.file.contributes.languages ?? []) {
7457
- lines.push(`# - ${lang}`);
7827
+ const ver = LANGUAGE_CATALOG[lang]?.defaultVersion;
7828
+ lines.push(`# - ${ver ? `${lang}:${ver}` : lang}`);
7458
7829
  }
7459
7830
  }
7460
7831
  lines.push("");
@@ -7697,7 +8068,6 @@ import { existsSync as existsSync10, promises as fs14 } from "fs";
7697
8068
  import path19 from "path";
7698
8069
  import { consola as consola13 } from "consola";
7699
8070
  async function runInit(opts) {
7700
- const workbench = opts.workbenchRoot ?? workbenchRoot();
7701
8071
  const home = opts.monocerosHome ?? monocerosHome();
7702
8072
  const logger = opts.logger ?? {
7703
8073
  success: (msg) => consola13.success(msg),
@@ -7714,14 +8084,14 @@ async function runInit(opts) {
7714
8084
  `Config already exists: ${dest}. Delete it manually before re-running \`monoceros init\` \u2014 this protects any hand-edits.`
7715
8085
  );
7716
8086
  }
7717
- const catalog = await loadComponentCatalog(componentsDir(workbench));
8087
+ const componentsRoot = opts.workbenchRoot ? path19.join(opts.workbenchRoot, "components") : componentsRootDir();
8088
+ const catalog = await loadComponentCatalog(componentsRoot);
7718
8089
  if (catalog.size === 0) {
7719
8090
  throw new Error(
7720
- `No components found under ${componentsDir(workbench)}. The workbench checkout is incomplete.`
8091
+ `No components found under ${componentsRoot}. The workbench checkout is incomplete.`
7721
8092
  );
7722
8093
  }
7723
- const checkoutRoot = opts.workbenchRoot ?? workbenchCheckoutRoot();
7724
- const lookup = (ref) => loadFeatureManifestSummary(ref, checkoutRoot);
8094
+ const lookup = (ref) => loadFeatureManifestSummary(ref, componentsRoot);
7725
8095
  const reposRaw = (opts.withRepo ?? []).map((u) => u.trim()).filter((u) => u.length > 0);
7726
8096
  const repos = [];
7727
8097
  const seenRepoUrls = /* @__PURE__ */ new Set();
@@ -7842,7 +8212,14 @@ function resolveInitLanguages(entries) {
7842
8212
  continue;
7843
8213
  }
7844
8214
  seen.add(e);
7845
- out.push(e);
8215
+ const entry2 = LANGUAGE_CATALOG[spec.name];
8216
+ const renderedSpec = spec.version === void 0 && entry2?.defaultVersion ? `${spec.name}:${entry2.defaultVersion}` : e;
8217
+ if (out.some((o) => o.spec === renderedSpec)) continue;
8218
+ const ymlOptions = entry2?.ymlOptions;
8219
+ out.push({
8220
+ spec: renderedSpec,
8221
+ ...ymlOptions && Object.keys(ymlOptions).length > 0 ? { options: ymlOptions } : {}
8222
+ });
7846
8223
  }
7847
8224
  if (unknown.length > 0) {
7848
8225
  throw new Error(
@@ -8077,6 +8454,45 @@ var init_init2 = __esm({
8077
8454
  }
8078
8455
  });
8079
8456
 
8457
+ // src/catalog/expand.ts
8458
+ function expandSelectable(catalog) {
8459
+ const out = /* @__PURE__ */ new Map();
8460
+ const add = (entry2) => {
8461
+ if (out.has(entry2.name)) {
8462
+ throw new Error(
8463
+ `Duplicate selectable component name '${entry2.name}' (from descriptor '${entry2.componentId}').`
8464
+ );
8465
+ }
8466
+ out.set(entry2.name, entry2);
8467
+ };
8468
+ for (const { descriptor: d } of catalog.values()) {
8469
+ const selector = d.name ?? d.id;
8470
+ add({
8471
+ name: selector,
8472
+ category: d.category,
8473
+ displayName: d.displayName,
8474
+ description: d.description,
8475
+ componentId: d.id
8476
+ });
8477
+ for (const [presetKey, overrides] of Object.entries(d.presets ?? {})) {
8478
+ add({
8479
+ name: `${selector}/${presetKey}`,
8480
+ category: d.category,
8481
+ displayName: `${d.displayName} (${presetKey})`,
8482
+ description: d.description,
8483
+ componentId: d.id,
8484
+ presetOptions: overrides
8485
+ });
8486
+ }
8487
+ }
8488
+ return out;
8489
+ }
8490
+ var init_expand = __esm({
8491
+ "src/catalog/expand.ts"() {
8492
+ "use strict";
8493
+ }
8494
+ });
8495
+
8080
8496
  // src/commands/list-components.ts
8081
8497
  import { defineCommand as defineCommand12 } from "citty";
8082
8498
  import { consola as consola15 } from "consola";
@@ -8084,7 +8500,8 @@ var CATEGORY_LABELS, CATEGORY_ORDER, listComponentsCommand;
8084
8500
  var init_list_components = __esm({
8085
8501
  "src/commands/list-components.ts"() {
8086
8502
  "use strict";
8087
- init_components();
8503
+ init_load();
8504
+ init_expand();
8088
8505
  init_format();
8089
8506
  CATEGORY_LABELS = {
8090
8507
  language: "Languages",
@@ -8105,7 +8522,7 @@ var init_list_components = __esm({
8105
8522
  args: {},
8106
8523
  async run() {
8107
8524
  try {
8108
- const catalog = await loadComponentCatalog();
8525
+ const catalog = expandSelectable(await loadDescriptorCatalog());
8109
8526
  if (catalog.size === 0) {
8110
8527
  consola15.warn(
8111
8528
  "No components found. The workbench checkout looks incomplete."
@@ -8116,9 +8533,9 @@ var init_list_components = __esm({
8116
8533
  const isTty2 = process.stdout.isTTY ?? false;
8117
8534
  const byCategory = /* @__PURE__ */ new Map();
8118
8535
  for (const c of catalog.values()) {
8119
- const list = byCategory.get(c.file.category) ?? [];
8120
- list.push({ name: c.name, desc: c.file.displayName });
8121
- byCategory.set(c.file.category, list);
8536
+ const list = byCategory.get(c.category) ?? [];
8537
+ list.push({ name: c.name, desc: c.displayName });
8538
+ byCategory.set(c.category, list);
8122
8539
  }
8123
8540
  for (const list of byCategory.values()) {
8124
8541
  list.sort((a, b) => a.name.localeCompare(b.name));
@@ -8994,7 +9411,7 @@ var init_remove_service = __esm({
8994
9411
 
8995
9412
  // src/devcontainer/browser-bridge.ts
8996
9413
  import { spawn as spawn9 } from "child_process";
8997
- import { existsSync as existsSync14, promises as fsp4, readFileSync as readFileSync6 } from "fs";
9414
+ import { existsSync as existsSync14, promises as fsp4, readFileSync as readFileSync5 } from "fs";
8998
9415
  import http from "http";
8999
9416
  import path23 from "path";
9000
9417
  function parseCallbackTarget(authUrl) {
@@ -9011,6 +9428,10 @@ function parseCallbackTarget(authUrl) {
9011
9428
  return null;
9012
9429
  }
9013
9430
  }
9431
+ function nextRelayUrl(content, lastOpened) {
9432
+ const url = content.trim();
9433
+ return url && url !== lastOpened ? url : null;
9434
+ }
9014
9435
  function openInBrowser(url) {
9015
9436
  const platform = process.platform;
9016
9437
  const [cmd, args] = platform === "darwin" ? ["open", [url]] : platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
@@ -9041,7 +9462,7 @@ exit 0
9041
9462
  );
9042
9463
  await fsp4.chmod(relayScript, 493);
9043
9464
  const servers = [];
9044
- let handled = false;
9465
+ let lastOpened = null;
9045
9466
  const onUrl = (url) => {
9046
9467
  openInBrowser(url);
9047
9468
  const target = parseCallbackTarget(url);
@@ -9072,16 +9493,17 @@ exit 0
9072
9493
  servers.push(server);
9073
9494
  };
9074
9495
  const poll = setInterval(() => {
9075
- if (handled || !existsSync14(urlFile)) return;
9496
+ if (!existsSync14(urlFile)) return;
9076
9497
  let content = "";
9077
9498
  try {
9078
- content = readFileSync6(urlFile, "utf8");
9499
+ content = readFileSync5(urlFile, "utf8");
9079
9500
  } catch {
9080
9501
  return;
9081
9502
  }
9082
- if (!content.trim()) return;
9083
- handled = true;
9084
- onUrl(content.trim());
9503
+ const url = nextRelayUrl(content, lastOpened);
9504
+ if (!url) return;
9505
+ lastOpened = url;
9506
+ onUrl(url);
9085
9507
  }, 250);
9086
9508
  return {
9087
9509
  relayDirInContainer: `/workspaces/${opts.name}/${RELAY_DIRNAME}`,