@getmonoceros/workbench 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bin.js ADDED
@@ -0,0 +1,3640 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/bin.ts
4
+ import { runMain } from "citty";
5
+
6
+ // src/help.ts
7
+ var ANSI_BOLD = "\x1B[1m";
8
+ var ANSI_UNDERLINE = "\x1B[4m";
9
+ var ANSI_CYAN = "\x1B[36m";
10
+ var ANSI_GREY = "\x1B[90m";
11
+ var ANSI_RESET = "\x1B[0m";
12
+ function isTty() {
13
+ return process.stdout.isTTY ?? false;
14
+ }
15
+ function color(text, ...codes) {
16
+ if (!isTty()) return text;
17
+ return codes.join("") + text + ANSI_RESET;
18
+ }
19
+ var bold = (s) => color(s, ANSI_BOLD);
20
+ var underline = (s) => color(s, ANSI_UNDERLINE);
21
+ var cyan = (s) => color(s, ANSI_CYAN);
22
+ var grey = (s) => color(s, ANSI_GREY);
23
+ function resolveArgs(argsDef) {
24
+ if (!argsDef) return [];
25
+ const out = [];
26
+ for (const [name, defRaw] of Object.entries(argsDef)) {
27
+ const def = defRaw ?? {};
28
+ out.push({
29
+ name,
30
+ type: def.type ?? "string",
31
+ required: def.required,
32
+ description: def.description,
33
+ default: def.default,
34
+ alias: def.alias,
35
+ valueHint: def.valueHint
36
+ });
37
+ }
38
+ return out;
39
+ }
40
+ function renderValueHint(arg) {
41
+ if (arg.type === "boolean") return "";
42
+ const hint = arg.valueHint ?? arg.name;
43
+ return `=<${hint}>`;
44
+ }
45
+ function renderArgDescription(arg, isRequired) {
46
+ const parts = [];
47
+ if (arg.description) parts.push(arg.description);
48
+ if (isRequired) parts.push(grey("(Required)"));
49
+ if (arg.default !== void 0 && arg.type !== "boolean") {
50
+ parts.push(grey(`(Default: ${JSON.stringify(arg.default)})`));
51
+ }
52
+ return parts.join(" ");
53
+ }
54
+ var ANSI_RE = /\x1b\[[0-9;]*m/g;
55
+ function alignTable(rows, indent) {
56
+ if (rows.length === 0) return "";
57
+ const visibleLen = (s) => s.replace(ANSI_RE, "").length;
58
+ const width = Math.max(...rows.map((r) => visibleLen(r[0])));
59
+ return rows.map(([left, right]) => {
60
+ const pad = " ".repeat(width - visibleLen(left));
61
+ return `${indent}${left}${pad} ${right}`;
62
+ }).join("\n");
63
+ }
64
+ function renderUsageBlock(cmd, commandPath) {
65
+ const meta = cmd.meta ?? {};
66
+ const args = resolveArgs(cmd.args ?? {});
67
+ const subCommands = cmd.subCommands ?? {};
68
+ const fullName = commandPath.join(" ") || meta.name || "monoceros";
69
+ const positionals = args.filter((a) => a.type === "positional");
70
+ const flags = args.filter((a) => a.type !== "positional");
71
+ const usageTokens = [];
72
+ for (const p of positionals) {
73
+ const isRequired = p.required !== false && p.default === void 0;
74
+ const t = p.name.toUpperCase();
75
+ usageTokens.push(isRequired ? `<${t}>` : `[${t}]`);
76
+ }
77
+ const subCommandNames = Object.keys(subCommands).filter((n) => {
78
+ const sub = subCommands[n];
79
+ const subMeta = sub?.meta ?? {};
80
+ return !subMeta.hidden;
81
+ });
82
+ if (subCommandNames.length > 0) usageTokens.push(subCommandNames.join("|"));
83
+ if (flags.length > 0) usageTokens.push("[OPTIONS]");
84
+ const lines = [];
85
+ const version = meta.version;
86
+ const header = `${meta.description ?? ""} (${fullName}${version ? ` v${version}` : ""})`;
87
+ lines.push(grey(header));
88
+ lines.push("");
89
+ lines.push(
90
+ `${underline(bold("USAGE"))} ${cyan([fullName, ...usageTokens].join(" "))}`
91
+ );
92
+ lines.push("");
93
+ if (positionals.length > 0) {
94
+ lines.push(underline(bold("ARGUMENTS")));
95
+ lines.push("");
96
+ const rows = positionals.map((p) => {
97
+ const isRequired = p.required !== false && p.default === void 0;
98
+ return [cyan(p.name.toUpperCase()), renderArgDescription(p, isRequired)];
99
+ });
100
+ lines.push(alignTable(rows, " "));
101
+ lines.push("");
102
+ }
103
+ if (flags.length > 0) {
104
+ lines.push(underline(bold("OPTIONS")));
105
+ lines.push("");
106
+ const rows = flags.map((f) => {
107
+ const isRequired = f.required === true && f.default === void 0;
108
+ const aliases = (Array.isArray(f.alias) ? f.alias : f.alias ? [f.alias] : []).map((a) => `-${a}`);
109
+ const label = [...aliases, `--${f.name}`].join(", ") + renderValueHint(f);
110
+ return [cyan(label), renderArgDescription(f, isRequired)];
111
+ });
112
+ lines.push(alignTable(rows, " "));
113
+ lines.push("");
114
+ }
115
+ if (subCommandNames.length > 0) {
116
+ lines.push(underline(bold("COMMANDS")));
117
+ lines.push("");
118
+ const rows = [];
119
+ for (const n of subCommandNames) {
120
+ const sub = subCommands[n];
121
+ const subMeta = sub?.meta ?? {};
122
+ rows.push([cyan(n), subMeta.description ?? ""]);
123
+ }
124
+ lines.push(alignTable(rows, " "));
125
+ lines.push("");
126
+ lines.push(
127
+ `Use ${cyan(`${fullName} <command> --help`)} for more information about a command.`
128
+ );
129
+ lines.push("");
130
+ }
131
+ return lines.join("\n");
132
+ }
133
+ function detectHelpRequest(argv, main2) {
134
+ const helpIdx = argv.findIndex((a) => a === "--help" || a === "-h");
135
+ const separatorIdx = argv.indexOf("--");
136
+ if (helpIdx === -1) return null;
137
+ if (separatorIdx !== -1 && separatorIdx < helpIdx) return null;
138
+ const path14 = [];
139
+ const tokens = argv.slice(
140
+ 0,
141
+ separatorIdx === -1 ? argv.length : separatorIdx
142
+ );
143
+ let cursor = main2;
144
+ const mainName = (main2.meta ?? {}).name ?? "monoceros";
145
+ path14.push(mainName);
146
+ for (const tok of tokens) {
147
+ if (tok.startsWith("-")) continue;
148
+ const subs = cursor.subCommands ?? {};
149
+ if (tok in subs) {
150
+ cursor = subs[tok];
151
+ path14.push(tok);
152
+ continue;
153
+ }
154
+ break;
155
+ }
156
+ return { path: path14, cmd: cursor };
157
+ }
158
+ async function maybeRenderHelp(argv, main2) {
159
+ const hit = detectHelpRequest(argv, main2);
160
+ if (!hit) return false;
161
+ process.stdout.write(renderUsageBlock(hit.cmd, hit.path) + "\n");
162
+ return true;
163
+ }
164
+
165
+ // src/inner-args.ts
166
+ var innerArgs = [];
167
+ function splitInnerArgs(userArgs) {
168
+ const dashIdx = userArgs.indexOf("--");
169
+ if (dashIdx === -1) {
170
+ return { outerArgs: [...userArgs], innerArgs: [] };
171
+ }
172
+ return {
173
+ outerArgs: userArgs.slice(0, dashIdx),
174
+ innerArgs: userArgs.slice(dashIdx + 1)
175
+ };
176
+ }
177
+ function consumeInnerArgsFromProcessArgv() {
178
+ const userArgs = process.argv.slice(2);
179
+ const split = splitInnerArgs(userArgs);
180
+ process.argv = [...process.argv.slice(0, 2), ...split.outerArgs];
181
+ innerArgs = split.innerArgs;
182
+ }
183
+ function getInnerArgs() {
184
+ return innerArgs;
185
+ }
186
+
187
+ // src/main.ts
188
+ import { defineCommand as defineCommand24 } from "citty";
189
+
190
+ // src/commands/add-apt-packages.ts
191
+ import { defineCommand } from "citty";
192
+ import { consola as consola2 } from "consola";
193
+
194
+ // src/modify/index.ts
195
+ import { promises as fs3 } from "fs";
196
+ import { consola } from "consola";
197
+ import { createPatch } from "diff";
198
+
199
+ // src/config/io.ts
200
+ import { promises as fs } from "fs";
201
+ import { Document, parseDocument } from "yaml";
202
+
203
+ // src/config/schema.ts
204
+ import { z } from "zod";
205
+ var SOLUTION_NAME_RE = /^[A-Za-z0-9._-]+$/;
206
+ var APT_PACKAGE_NAME_RE = /^[a-z0-9][a-z0-9.+-]*$/;
207
+ var FEATURE_REF_RE = /^[a-z0-9.-]+(\/[a-z0-9._-]+)+:[a-z0-9._-]+$/;
208
+ var INSTALL_URL_RE = /^https:\/\/[A-Za-z0-9.\-_~/:?#[\]@!&'()*+,;=%]+$/;
209
+ var REPO_URL_RE = /^[A-Za-z0-9@:/+_~.#=&?-]+$/;
210
+ var REPO_NAME_RE = /^[A-Za-z0-9._-]+$/;
211
+ var REPO_BRANCH_RE = /^[A-Za-z0-9._/-]+$/;
212
+ var POSTGRES_URL_RE = /^postgres(ql)?:\/\//;
213
+ var REGEX = {
214
+ solutionName: SOLUTION_NAME_RE,
215
+ aptPackage: APT_PACKAGE_NAME_RE,
216
+ featureRef: FEATURE_REF_RE,
217
+ installUrl: INSTALL_URL_RE,
218
+ repoUrl: REPO_URL_RE,
219
+ repoName: REPO_NAME_RE,
220
+ repoBranch: REPO_BRANCH_RE,
221
+ postgresUrl: POSTGRES_URL_RE
222
+ };
223
+ var CONFIG_SCHEMA_VERSION = 1;
224
+ var FeatureOptionValueSchema = z.union([
225
+ z.string(),
226
+ z.number(),
227
+ z.boolean()
228
+ ]);
229
+ var FeatureEntrySchema = z.object({
230
+ ref: z.string().regex(
231
+ FEATURE_REF_RE,
232
+ "Invalid feature ref. Expected an OCI-image-style ref like 'ghcr.io/devcontainers/features/<name>:<tag>'."
233
+ ),
234
+ options: z.record(z.string(), FeatureOptionValueSchema).optional()
235
+ });
236
+ var RepoEntrySchema = z.object({
237
+ url: z.string().regex(
238
+ REPO_URL_RE,
239
+ "Invalid repo URL. Use HTTPS or SSH/git@ form; no shell metacharacters."
240
+ ),
241
+ name: z.string().regex(
242
+ REPO_NAME_RE,
243
+ "Invalid repo name. Folder name must match /^[A-Za-z0-9._-]+$/."
244
+ ).optional(),
245
+ branch: z.string().regex(
246
+ REPO_BRANCH_RE,
247
+ "Invalid branch name. Must match /^[A-Za-z0-9._/-]+$/."
248
+ ).optional()
249
+ });
250
+ var GitUserSchema = z.object({
251
+ name: z.string().min(1),
252
+ email: z.string().min(3).regex(/^[^\s@]+@[^\s@]+\.[^\s@]+$/, "Invalid email")
253
+ });
254
+ var ExternalServicesSchema = z.object({
255
+ postgres: z.string().regex(
256
+ POSTGRES_URL_RE,
257
+ "Postgres URL must start with 'postgres://' or 'postgresql://'"
258
+ ).optional()
259
+ });
260
+ var SolutionConfigSchema = z.object({
261
+ schemaVersion: z.literal(CONFIG_SCHEMA_VERSION),
262
+ name: z.string().regex(
263
+ SOLUTION_NAME_RE,
264
+ "Invalid solution name. Use letters, digits, '.', '_' or '-'."
265
+ ),
266
+ languages: z.array(z.string().min(1)).default([]),
267
+ aptPackages: z.array(
268
+ z.string().regex(
269
+ APT_PACKAGE_NAME_RE,
270
+ "Invalid apt package name. Expected lowercase alphanumeric plus '.+-'."
271
+ )
272
+ ).default([]),
273
+ features: z.array(FeatureEntrySchema).default([]),
274
+ installUrls: z.array(
275
+ z.string().regex(
276
+ INSTALL_URL_RE,
277
+ "Invalid install URL. Must start with 'https://' and contain only URL-safe characters (no shell metacharacters)."
278
+ )
279
+ ).default([]),
280
+ services: z.array(z.string().min(1)).default([]),
281
+ repos: z.array(RepoEntrySchema).default([]),
282
+ externalServices: ExternalServicesSchema.default({}),
283
+ git: z.object({
284
+ user: GitUserSchema.optional()
285
+ }).optional()
286
+ });
287
+ function validateConfig(input) {
288
+ const result = SolutionConfigSchema.safeParse(input);
289
+ if (!result.success) {
290
+ const issues = result.error.issues.map((issue) => {
291
+ const where = issue.path.length > 0 ? issue.path.join(".") : "(root)";
292
+ return ` - ${where}: ${issue.message}`;
293
+ }).join("\n");
294
+ throw new Error(`Invalid solution config:
295
+ ${issues}`);
296
+ }
297
+ return result.data;
298
+ }
299
+
300
+ // src/config/io.ts
301
+ function parseConfig(yamlText, source = "<inline>") {
302
+ const doc = parseDocument(yamlText, { prettyErrors: true });
303
+ if (doc.errors.length > 0) {
304
+ const first = doc.errors[0];
305
+ throw new Error(`yaml parse error in ${source}: ${first.message}`);
306
+ }
307
+ const config = validateConfig(doc.toJS());
308
+ return { config, doc, source };
309
+ }
310
+ async function readConfig(filePath) {
311
+ const text = await fs.readFile(filePath, "utf8");
312
+ return parseConfig(text, filePath);
313
+ }
314
+ function stringifyConfig(doc) {
315
+ return String(doc);
316
+ }
317
+
318
+ // src/config/paths.ts
319
+ import { existsSync } from "fs";
320
+ import path from "path";
321
+ import os from "os";
322
+ import { fileURLToPath } from "url";
323
+ var MONOCEROS_HOME_MARKER = "monoceros-config.sample.yml";
324
+ var WORKBENCH_MARKER = path.join("templates", "components", "README.md");
325
+ var CHECKOUT_MARKER = "pnpm-workspace.yaml";
326
+ var cachedWorkbenchRoot = null;
327
+ var cachedMonocerosHome = null;
328
+ var cachedCheckoutRoot = void 0;
329
+ function workbenchRoot() {
330
+ if (cachedWorkbenchRoot) return cachedWorkbenchRoot;
331
+ let dir = path.dirname(fileURLToPath(import.meta.url));
332
+ while (true) {
333
+ if (existsSync(path.join(dir, WORKBENCH_MARKER))) {
334
+ cachedWorkbenchRoot = dir;
335
+ return dir;
336
+ }
337
+ const parent = path.dirname(dir);
338
+ if (parent === dir) {
339
+ throw new Error(
340
+ `Could not locate the monoceros workbench checkout (no ${WORKBENCH_MARKER} found by walking up). Run the CLI from a workbench checkout.`
341
+ );
342
+ }
343
+ dir = parent;
344
+ }
345
+ }
346
+ function monocerosHome(opts = {}) {
347
+ if (!opts.force && cachedMonocerosHome) return cachedMonocerosHome;
348
+ const fromEnv = process.env.MONOCEROS_HOME;
349
+ if (fromEnv && fromEnv.length > 0) {
350
+ cachedMonocerosHome = path.resolve(fromEnv);
351
+ return cachedMonocerosHome;
352
+ }
353
+ let dir = path.dirname(fileURLToPath(import.meta.url));
354
+ while (true) {
355
+ const candidate = path.join(dir, ".local");
356
+ if (existsSync(path.join(candidate, MONOCEROS_HOME_MARKER))) {
357
+ cachedMonocerosHome = candidate;
358
+ return candidate;
359
+ }
360
+ const parent = path.dirname(dir);
361
+ if (parent === dir) break;
362
+ dir = parent;
363
+ }
364
+ cachedMonocerosHome = path.join(os.homedir(), ".monoceros");
365
+ return cachedMonocerosHome;
366
+ }
367
+ function workbenchCheckoutRoot() {
368
+ if (cachedCheckoutRoot !== void 0) return cachedCheckoutRoot;
369
+ let dir = path.dirname(fileURLToPath(import.meta.url));
370
+ while (true) {
371
+ if (existsSync(path.join(dir, CHECKOUT_MARKER))) {
372
+ cachedCheckoutRoot = dir;
373
+ return dir;
374
+ }
375
+ const parent = path.dirname(dir);
376
+ if (parent === dir) {
377
+ cachedCheckoutRoot = null;
378
+ return null;
379
+ }
380
+ dir = parent;
381
+ }
382
+ }
383
+ function componentsDir(root = workbenchRoot()) {
384
+ return path.join(root, "templates", "components");
385
+ }
386
+ function containerConfigsDir(home = monocerosHome()) {
387
+ return path.join(home, "container-configs");
388
+ }
389
+ function containerConfigPath(name, home = monocerosHome()) {
390
+ return path.join(containerConfigsDir(home), `${name}.yml`);
391
+ }
392
+ function containersDir(home = monocerosHome()) {
393
+ return path.join(home, "container");
394
+ }
395
+ function containerDir(name, home = monocerosHome()) {
396
+ return path.join(containersDir(home), name);
397
+ }
398
+ function monocerosConfigPath(home = monocerosHome()) {
399
+ return path.join(home, "monoceros-config.yml");
400
+ }
401
+
402
+ // src/create/catalog.ts
403
+ var DEFAULT_BASE_IMAGE = "ghcr.io/getmonoceros/monoceros-runtime:1";
404
+ var override = process.env.MONOCEROS_BASE_IMAGE_OVERRIDE?.trim();
405
+ var BASE_IMAGE = override && override.length > 0 ? override : DEFAULT_BASE_IMAGE;
406
+ var BUILTIN_LANGUAGES = /* @__PURE__ */ new Set(["node"]);
407
+ var LANGUAGE_CATALOG = {
408
+ node: { id: "node", feature: "ghcr.io/devcontainers/features/node:1" },
409
+ python: { id: "python", feature: "ghcr.io/devcontainers/features/python:1" },
410
+ java: { id: "java", feature: "ghcr.io/devcontainers/features/java:1" },
411
+ go: { id: "go", feature: "ghcr.io/devcontainers/features/go:1" },
412
+ rust: { id: "rust", feature: "ghcr.io/devcontainers/features/rust:1" },
413
+ dotnet: { id: "dotnet", feature: "ghcr.io/devcontainers/features/dotnet:2" }
414
+ };
415
+ var LANGUAGE_SPEC_RE = /^([a-z][a-z0-9-]*)(?::([A-Za-z0-9._-]+))?$/;
416
+ function parseLanguageSpec(spec) {
417
+ const m = LANGUAGE_SPEC_RE.exec(spec);
418
+ if (!m) return null;
419
+ return { name: m[1], ...m[2] !== void 0 ? { version: m[2] } : {} };
420
+ }
421
+ var SERVICE_CATALOG = {
422
+ postgres: {
423
+ id: "postgres",
424
+ image: "postgres:18",
425
+ env: {
426
+ POSTGRES_USER: "monoceros",
427
+ POSTGRES_PASSWORD: "monoceros",
428
+ POSTGRES_DB: "monoceros"
429
+ },
430
+ // Postgres 18+ stores data under /var/lib/postgresql/<major>/, so
431
+ // the recommended mount is the parent directory; pre-18 used
432
+ // /var/lib/postgresql/data directly. See
433
+ // https://github.com/docker-library/postgres/pull/1259.
434
+ dataMount: "/var/lib/postgresql"
435
+ },
436
+ mysql: {
437
+ id: "mysql",
438
+ image: "mysql:8",
439
+ env: {
440
+ MYSQL_ROOT_PASSWORD: "monoceros",
441
+ MYSQL_DATABASE: "monoceros"
442
+ },
443
+ dataMount: "/var/lib/mysql"
444
+ },
445
+ redis: {
446
+ id: "redis",
447
+ image: "redis:8",
448
+ dataMount: "/data"
449
+ }
450
+ };
451
+ function knownLanguages() {
452
+ return [...BUILTIN_LANGUAGES, ...Object.keys(LANGUAGE_CATALOG)].sort();
453
+ }
454
+ function knownServices() {
455
+ return Object.keys(SERVICE_CATALOG).sort();
456
+ }
457
+
458
+ // src/create/scaffold.ts
459
+ import { existsSync as existsSync2, readFileSync, promises as fs2 } from "fs";
460
+ import path2 from "path";
461
+
462
+ // src/util/ref.ts
463
+ var FEATURE_NAME_CHARSET = "[a-z0-9._-]+";
464
+ var FEATURE_TAG_CHARSET = "[a-z0-9._-]+";
465
+ var MONOCEROS_FEATURE_RE = new RegExp(
466
+ `^ghcr\\.io/getmonoceros/monoceros-features/(${FEATURE_NAME_CHARSET}):${FEATURE_TAG_CHARSET}$`
467
+ );
468
+ var DEPRECATED_MONOCEROS_FEATURE_RE = new RegExp(
469
+ `^ghcr\\.io/monoceros/features/(${FEATURE_NAME_CHARSET}):(${FEATURE_TAG_CHARSET})$`
470
+ );
471
+ function matchMonocerosFeature(ref) {
472
+ const match = MONOCEROS_FEATURE_RE.exec(ref);
473
+ if (!match) return null;
474
+ return { name: match[1] };
475
+ }
476
+ function migrateDeprecatedFeatureRef(ref) {
477
+ const match = DEPRECATED_MONOCEROS_FEATURE_RE.exec(ref);
478
+ if (!match) return null;
479
+ const name = match[1];
480
+ const tag = match[2];
481
+ return `ghcr.io/getmonoceros/monoceros-features/${name}:${tag}`;
482
+ }
483
+
484
+ // src/create/scaffold.ts
485
+ var APT_PACKAGE_NAME_RE2 = /^[a-z0-9][a-z0-9.+-]*$/;
486
+ var FEATURE_REF_RE2 = /^[a-z0-9.-]+(\/[a-z0-9._-]+)+:[a-z0-9._-]+$/;
487
+ var INSTALL_URL_RE2 = /^https:\/\/[A-Za-z0-9.\-_~/:?#[\]@!&'()*+,;=%]+$/;
488
+ var REPO_URL_RE2 = /^[A-Za-z0-9@:/+_~.#=&?-]+$/;
489
+ var REPO_NAME_RE2 = /^[A-Za-z0-9._-]+$/;
490
+ var REPO_BRANCH_RE2 = /^[A-Za-z0-9._/-]+$/;
491
+ function deriveRepoName(url) {
492
+ const lastSep = Math.max(url.lastIndexOf("/"), url.lastIndexOf(":"));
493
+ const tail = url.slice(lastSep + 1);
494
+ return tail.replace(/\.git$/, "");
495
+ }
496
+ function validateOptions(opts) {
497
+ if (!opts.name || !/^[a-zA-Z0-9._-]+$/.test(opts.name)) {
498
+ throw new Error(
499
+ `Invalid solution name: ${JSON.stringify(opts.name)}. Use letters, digits, '.', '_' or '-'.`
500
+ );
501
+ }
502
+ for (const langSpec of opts.languages) {
503
+ const parsed = parseLanguageSpec(langSpec);
504
+ if (!parsed) {
505
+ throw new Error(
506
+ `Invalid language spec: ${JSON.stringify(langSpec)}. Expected '<name>' or '<name>:<version>'.`
507
+ );
508
+ }
509
+ if (!BUILTIN_LANGUAGES.has(parsed.name) && !LANGUAGE_CATALOG[parsed.name]) {
510
+ throw new Error(
511
+ `Unknown language: ${parsed.name}. Known: ${knownLanguages().join(", ")}.`
512
+ );
513
+ }
514
+ }
515
+ for (const svc of opts.services) {
516
+ if (!SERVICE_CATALOG[svc]) {
517
+ throw new Error(
518
+ `Unknown service: ${svc}. Known: ${knownServices().join(", ")}.`
519
+ );
520
+ }
521
+ }
522
+ for (const pkg of opts.aptPackages ?? []) {
523
+ if (!APT_PACKAGE_NAME_RE2.test(pkg)) {
524
+ throw new Error(
525
+ `Invalid apt package name: ${JSON.stringify(pkg)}. Expected lowercase alphanumeric plus '.+-'.`
526
+ );
527
+ }
528
+ }
529
+ for (const ref of Object.keys(opts.features ?? {})) {
530
+ if (!FEATURE_REF_RE2.test(ref)) {
531
+ throw new Error(
532
+ `Invalid devcontainer feature ref: ${JSON.stringify(ref)}. Expected OCI-image-style ref like 'ghcr.io/devcontainers/features/<name>:<tag>'.`
533
+ );
534
+ }
535
+ }
536
+ for (const url of opts.installUrls ?? []) {
537
+ if (!INSTALL_URL_RE2.test(url)) {
538
+ throw new Error(
539
+ `Invalid install URL: ${JSON.stringify(url)}. Must start with 'https://' and contain only URL-safe characters (no shell metacharacters).`
540
+ );
541
+ }
542
+ }
543
+ const seenRepoNames = /* @__PURE__ */ new Set();
544
+ for (const repo of opts.repos ?? []) {
545
+ if (!REPO_URL_RE2.test(repo.url)) {
546
+ throw new Error(
547
+ `Invalid repo URL: ${JSON.stringify(repo.url)}. Use HTTPS or SSH/git@ form; no shell metacharacters.`
548
+ );
549
+ }
550
+ if (!REPO_NAME_RE2.test(repo.name)) {
551
+ throw new Error(
552
+ `Invalid repo name: ${JSON.stringify(repo.name)}. Folder name must match ${REPO_NAME_RE2}.`
553
+ );
554
+ }
555
+ if (repo.branch !== void 0 && !REPO_BRANCH_RE2.test(repo.branch)) {
556
+ throw new Error(
557
+ `Invalid branch name: ${JSON.stringify(repo.branch)}. Must match ${REPO_BRANCH_RE2}.`
558
+ );
559
+ }
560
+ if (seenRepoNames.has(repo.name)) {
561
+ throw new Error(
562
+ `Duplicate repo name: ${JSON.stringify(repo.name)}. Each projects/<name> folder must be unique \u2014 pass --name to disambiguate.`
563
+ );
564
+ }
565
+ seenRepoNames.add(repo.name);
566
+ }
567
+ }
568
+ function normalizeOptions(opts) {
569
+ const languages = [...new Set(opts.languages)].sort();
570
+ let services = [...new Set(opts.services)].sort();
571
+ if (opts.postgresUrl) {
572
+ services = services.filter((s) => s !== "postgres");
573
+ }
574
+ const aptPackages = [...new Set(opts.aptPackages ?? [])].sort();
575
+ const features = opts.features ? Object.fromEntries(
576
+ Object.entries(opts.features).sort(([a], [b]) => a.localeCompare(b))
577
+ ) : void 0;
578
+ const installUrls = opts.installUrls ? [...new Set(opts.installUrls)] : void 0;
579
+ const repos = opts.repos ? Array.from(
580
+ new Map(
581
+ opts.repos.map((r) => [`${r.url}${r.name}${r.branch ?? ""}`, r])
582
+ ).values()
583
+ ) : void 0;
584
+ return {
585
+ name: opts.name,
586
+ languages,
587
+ services,
588
+ postgresUrl: opts.postgresUrl,
589
+ ...aptPackages.length > 0 ? { aptPackages } : {},
590
+ ...features && Object.keys(features).length > 0 ? { features } : {},
591
+ ...installUrls && installUrls.length > 0 ? { installUrls } : {},
592
+ ...repos && repos.length > 0 ? { repos } : {}
593
+ };
594
+ }
595
+ function needsCompose(opts) {
596
+ return opts.services.length > 0;
597
+ }
598
+ var SSH_AGENT_TARGET = "/ssh-agent";
599
+ var GIT_SSH_COMMAND = "ssh -o StrictHostKeyChecking=accept-new";
600
+ function buildRepoAuthMounts() {
601
+ return [
602
+ `source=\${localEnv:SSH_AUTH_SOCK},target=${SSH_AGENT_TARGET},type=bind`
603
+ ];
604
+ }
605
+ function buildRepoAuthEnv() {
606
+ return {
607
+ SSH_AUTH_SOCK: SSH_AGENT_TARGET,
608
+ GIT_SSH_COMMAND
609
+ };
610
+ }
611
+ function resolveFeatures(opts) {
612
+ const resolved = [];
613
+ for (const langSpec of opts.languages) {
614
+ const parsed = parseLanguageSpec(langSpec);
615
+ if (!parsed) continue;
616
+ if (BUILTIN_LANGUAGES.has(parsed.name) && parsed.version === void 0) {
617
+ continue;
618
+ }
619
+ const entry2 = LANGUAGE_CATALOG[parsed.name];
620
+ if (!entry2) continue;
621
+ const options = {};
622
+ if (parsed.version !== void 0) options.version = parsed.version;
623
+ resolved.push({
624
+ devcontainerKey: entry2.feature,
625
+ options,
626
+ persistentHomePaths: [],
627
+ persistentHomeFiles: []
628
+ });
629
+ }
630
+ if (opts.aptPackages && opts.aptPackages.length > 0) {
631
+ resolved.push({
632
+ devcontainerKey: "ghcr.io/devcontainers-contrib/features/apt-packages:1",
633
+ options: { packages: opts.aptPackages.join(",") },
634
+ persistentHomePaths: [],
635
+ persistentHomeFiles: []
636
+ });
637
+ }
638
+ if (opts.features) {
639
+ for (const [rawRef, options] of Object.entries(opts.features)) {
640
+ const match = matchMonocerosFeature(rawRef);
641
+ if (match) {
642
+ const name = match.name;
643
+ const checkout = workbenchCheckoutRoot();
644
+ const localSourceDir = checkout ? path2.join(checkout, "images", "features", name) : null;
645
+ if (localSourceDir && existsSync2(localSourceDir)) {
646
+ const { paths, files } = readPersistentHomeEntries(localSourceDir);
647
+ resolved.push({
648
+ devcontainerKey: `./features/${name}`,
649
+ options,
650
+ localSourceDir,
651
+ localName: name,
652
+ persistentHomePaths: paths,
653
+ persistentHomeFiles: files
654
+ });
655
+ continue;
656
+ }
657
+ }
658
+ resolved.push({
659
+ devcontainerKey: rawRef,
660
+ options,
661
+ persistentHomePaths: [],
662
+ persistentHomeFiles: []
663
+ });
664
+ }
665
+ }
666
+ return resolved;
667
+ }
668
+ function readPersistentHomeEntries(localSourceDir) {
669
+ const manifestPath = path2.join(localSourceDir, "devcontainer-feature.json");
670
+ try {
671
+ const text = readFileSync(manifestPath, "utf8");
672
+ const parsed = JSON.parse(text);
673
+ return {
674
+ paths: filterSubpaths(parsed["x-monoceros"]?.persistentHomePaths),
675
+ files: filterFileEntries(parsed["x-monoceros"]?.persistentHomeFiles)
676
+ };
677
+ } catch {
678
+ return { paths: [], files: [] };
679
+ }
680
+ }
681
+ function filterSubpaths(raw) {
682
+ if (!Array.isArray(raw)) return [];
683
+ return raw.filter(
684
+ (p) => typeof p === "string" && p.length > 0 && !p.startsWith("/") && !p.includes("..") && HOME_SUBPATH_RE.test(p)
685
+ );
686
+ }
687
+ function filterFileEntries(raw) {
688
+ if (!Array.isArray(raw)) return [];
689
+ const result = [];
690
+ for (const entry2 of raw) {
691
+ if (typeof entry2 === "string") {
692
+ if (isValidHomeSubpath(entry2)) {
693
+ result.push({ path: entry2, initialContent: "" });
694
+ }
695
+ continue;
696
+ }
697
+ if (entry2 !== null && typeof entry2 === "object" && "path" in entry2 && typeof entry2.path === "string") {
698
+ const e = entry2;
699
+ if (!isValidHomeSubpath(e.path)) continue;
700
+ const initialContent = typeof e.initialContent === "string" ? e.initialContent : "";
701
+ result.push({ path: e.path, initialContent });
702
+ }
703
+ }
704
+ return result;
705
+ }
706
+ function isValidHomeSubpath(p) {
707
+ return p.length > 0 && !p.startsWith("/") && !p.includes("..") && HOME_SUBPATH_RE.test(p);
708
+ }
709
+ var HOME_SUBPATH_RE = /^[A-Za-z0-9._-]+(\/[A-Za-z0-9._-]+)*$/;
710
+ function buildDevcontainerJson(opts) {
711
+ const resolvedFeatures = resolveFeatures(opts);
712
+ const features = {};
713
+ for (const f of resolvedFeatures) {
714
+ features[f.devcontainerKey] = f.options;
715
+ }
716
+ const featuresField = Object.keys(features).length > 0 ? { features } : void 0;
717
+ const homeMounts = [];
718
+ for (const f of resolvedFeatures) {
719
+ const allSubs = [
720
+ ...f.persistentHomePaths,
721
+ ...f.persistentHomeFiles.map((entry2) => entry2.path)
722
+ ];
723
+ for (const sub of allSubs) {
724
+ homeMounts.push(
725
+ `source=\${localWorkspaceFolder}/home/${sub},target=/home/node/${sub},type=bind`
726
+ );
727
+ }
728
+ }
729
+ const wantsRepoAuth = (opts.repos?.length ?? 0) > 0;
730
+ const repoAuthEnv = wantsRepoAuth ? { containerEnv: buildRepoAuthEnv() } : {};
731
+ if (needsCompose(opts)) {
732
+ return {
733
+ name: opts.name,
734
+ dockerComposeFile: "compose.yaml",
735
+ service: "workspace",
736
+ ...opts.services.length > 0 ? { runServices: opts.services } : {},
737
+ workspaceFolder: `/workspaces/${opts.name}`,
738
+ remoteUser: "node",
739
+ forwardPorts: [3e3, 4e3],
740
+ postCreateCommand: ".devcontainer/post-create.sh",
741
+ ...featuresField ?? {},
742
+ ...repoAuthEnv
743
+ };
744
+ }
745
+ const mounts = [
746
+ ...wantsRepoAuth ? buildRepoAuthMounts() : [],
747
+ ...homeMounts
748
+ ];
749
+ const mountsField = mounts.length > 0 ? { mounts } : {};
750
+ return {
751
+ name: opts.name,
752
+ image: BASE_IMAGE,
753
+ remoteUser: "node",
754
+ ...mountsField,
755
+ runArgs: ["--cap-add=NET_ADMIN"],
756
+ forwardPorts: [3e3, 4e3],
757
+ postCreateCommand: ".devcontainer/post-create.sh",
758
+ ...featuresField ?? {},
759
+ ...repoAuthEnv
760
+ };
761
+ }
762
+ function buildComposeYaml(opts) {
763
+ const lines = ["services:"];
764
+ lines.push(" workspace:");
765
+ lines.push(` image: ${BASE_IMAGE}`);
766
+ lines.push(" command: 'sleep infinity'");
767
+ lines.push(" cap_add:");
768
+ lines.push(" - NET_ADMIN");
769
+ lines.push(" volumes:");
770
+ lines.push(` - ..:/workspaces/${opts.name}:cached`);
771
+ const resolvedFeatures = resolveFeatures(opts);
772
+ for (const f of resolvedFeatures) {
773
+ const allSubs = [
774
+ ...f.persistentHomePaths,
775
+ ...f.persistentHomeFiles.map((entry2) => entry2.path)
776
+ ];
777
+ for (const sub of allSubs) {
778
+ lines.push(` - ../home/${sub}:/home/node/${sub}`);
779
+ }
780
+ }
781
+ const wantsRepoAuth = (opts.repos?.length ?? 0) > 0;
782
+ if (wantsRepoAuth) {
783
+ lines.push(` - \${SSH_AUTH_SOCK:-/dev/null}:${SSH_AGENT_TARGET}`);
784
+ lines.push(" environment:");
785
+ lines.push(` SSH_AUTH_SOCK: ${SSH_AGENT_TARGET}`);
786
+ lines.push(` GIT_SSH_COMMAND: "${GIT_SSH_COMMAND}"`);
787
+ }
788
+ for (const svcId of opts.services) {
789
+ const def = SERVICE_CATALOG[svcId];
790
+ if (!def) continue;
791
+ lines.push(` ${def.id}:`);
792
+ lines.push(` image: ${def.image}`);
793
+ if (def.env) {
794
+ lines.push(" environment:");
795
+ for (const [k, v] of Object.entries(def.env)) {
796
+ lines.push(` ${k}: ${v}`);
797
+ }
798
+ }
799
+ if (def.dataMount) {
800
+ lines.push(" volumes:");
801
+ lines.push(` - ../data/${def.id}:${def.dataMount}`);
802
+ }
803
+ }
804
+ return lines.join("\n") + "\n";
805
+ }
806
+ function buildCodeWorkspaceJson(opts) {
807
+ const folders = [{ path: "." }];
808
+ const sortedRepos = [...opts.repos ?? []].sort(
809
+ (a, b) => a.name.localeCompare(b.name)
810
+ );
811
+ for (const repo of sortedRepos) {
812
+ folders.push({ path: `projects/${repo.name}`, name: repo.name });
813
+ }
814
+ return { folders };
815
+ }
816
+ function buildPostCreateScript(opts) {
817
+ const lines = [
818
+ "#!/usr/bin/env bash",
819
+ "set -euo pipefail",
820
+ "",
821
+ "# Inherit host-side git identity (user.name / user.email) captured",
822
+ "# into .monoceros/gitconfig by `monoceros apply`. Container-local",
823
+ "# git config loads first; the include below merges the host's",
824
+ "# identity values in.",
825
+ `git config --global include.path "/workspaces/${opts.name}/.monoceros/gitconfig"`,
826
+ "",
827
+ "# Per-feature post-create hooks. Each Monoceros-curated feature",
828
+ "# may drop a script into /usr/local/share/monoceros/post-create.d/",
829
+ "# during its install.sh \u2014 typical job is a non-interactive login",
830
+ "# against bind-mounted state under /home/node, using the option",
831
+ "# values the feature received as env vars at install time. Scripts",
832
+ "# run in lexicographic order, each in its own subshell, and a",
833
+ "# failure aborts post-create (set -e is in effect).",
834
+ "if [ -d /usr/local/share/monoceros/post-create.d ]; then",
835
+ " for hook in /usr/local/share/monoceros/post-create.d/*.sh; do",
836
+ ' [ -f "$hook" ] || continue',
837
+ ' echo "\u2192 post-create hook: $(basename "$hook")"',
838
+ ' bash "$hook"',
839
+ " done",
840
+ "fi",
841
+ "",
842
+ "# Bring up Node dependencies if the workspace has a package.json.",
843
+ "if [ -f package.json ]; then",
844
+ " pnpm install",
845
+ "fi"
846
+ ];
847
+ if (opts.installUrls && opts.installUrls.length > 0) {
848
+ lines.push(
849
+ "",
850
+ "# Custom install URLs added via `monoceros add-from-url`. Each is",
851
+ "# fetched and piped to `sh` on every container rebuild. URLs run",
852
+ "# in insertion order so later installs can build on earlier ones.",
853
+ "#",
854
+ "# Why `sh` (not `bash`): most install scripts target POSIX `sh`",
855
+ "# and some (starship, rustup, \u2026) explicitly refuse to run under",
856
+ "# `bash`. Outer `set -o pipefail` in this script makes a curl",
857
+ "# failure abort the post-create as expected.",
858
+ `echo "\u2192 Running ${opts.installUrls.length} install URL(s) added via add-from-url\u2026"`
859
+ );
860
+ for (const url of opts.installUrls) {
861
+ lines.push(`echo "\u2192 ${url}"`, `curl -fsSL "${url}" | sh`);
862
+ }
863
+ }
864
+ if (opts.repos && opts.repos.length > 0) {
865
+ const hasHttpsRepo = opts.repos.some((r) => r.url.startsWith("https://"));
866
+ if (hasHttpsRepo) {
867
+ lines.push(
868
+ "",
869
+ "# Wire git to the per-dev-container credentials file populated",
870
+ "# by `monoceros apply` (via `git credential fill` on the host).",
871
+ "# Path uses the workspace bind-mount so the file is reachable",
872
+ "# from inside the container.",
873
+ `git config --global credential.helper "store --file=/workspaces/${opts.name}/.monoceros/git-credentials"`
874
+ );
875
+ }
876
+ lines.push(
877
+ "",
878
+ "# Repos managed by `monoceros add-repo`. Each entry is cloned",
879
+ "# into `projects/<name>/` if (and only if) the directory does",
880
+ "# not exist yet. Existing project subfolders are left alone so",
881
+ "# local changes survive `monoceros apply` rebuilds.",
882
+ "mkdir -p projects"
883
+ );
884
+ for (const repo of opts.repos) {
885
+ const branchFlag = repo.branch ? ` --branch ${repo.branch}` : "";
886
+ const branchLabel = repo.branch ? ` (branch: ${repo.branch})` : "";
887
+ lines.push(
888
+ `if [ ! -d "projects/${repo.name}" ]; then`,
889
+ ` echo "\u2192 Cloning ${repo.name} from ${repo.url}${branchLabel}\u2026"`,
890
+ ` git clone${branchFlag} "${repo.url}" "projects/${repo.name}"`,
891
+ `else`,
892
+ ` echo "\u2192 projects/${repo.name} already exists, skipping clone"`,
893
+ `fi`
894
+ );
895
+ }
896
+ }
897
+ return lines.join("\n") + "\n";
898
+ }
899
+ async function writePostCreateScript(devcontainerDir, opts) {
900
+ const dest = path2.join(devcontainerDir, "post-create.sh");
901
+ await fs2.writeFile(dest, buildPostCreateScript(opts));
902
+ await fs2.chmod(dest, 493);
903
+ }
904
+ async function writeScaffold(opts, targetDir) {
905
+ const devcontainerDir = path2.join(targetDir, ".devcontainer");
906
+ const monocerosDir = path2.join(targetDir, ".monoceros");
907
+ const projectsDir = path2.join(targetDir, "projects");
908
+ const homeDir = path2.join(targetDir, "home");
909
+ const dataDir = path2.join(targetDir, "data");
910
+ await fs2.mkdir(devcontainerDir, { recursive: true });
911
+ await fs2.mkdir(monocerosDir, { recursive: true });
912
+ await fs2.mkdir(projectsDir, { recursive: true });
913
+ await fs2.mkdir(homeDir, { recursive: true });
914
+ if (needsCompose(opts)) {
915
+ await fs2.mkdir(dataDir, { recursive: true });
916
+ for (const svcId of opts.services) {
917
+ const def = SERVICE_CATALOG[svcId];
918
+ if (def?.dataMount) {
919
+ await fs2.mkdir(path2.join(dataDir, def.id), { recursive: true });
920
+ }
921
+ }
922
+ }
923
+ const containerGitignore = path2.join(targetDir, ".gitignore");
924
+ await fs2.writeFile(containerGitignore, "/home/\n/.monoceros/\n/data/\n");
925
+ const gitkeep = path2.join(projectsDir, ".gitkeep");
926
+ if (!existsSync2(gitkeep)) {
927
+ await fs2.writeFile(gitkeep, "");
928
+ }
929
+ await fs2.writeFile(
930
+ path2.join(monocerosDir, ".gitignore"),
931
+ "git-credentials*\ngitconfig\n"
932
+ );
933
+ const devcontainerJson = buildDevcontainerJson(opts);
934
+ await fs2.writeFile(
935
+ path2.join(devcontainerDir, "devcontainer.json"),
936
+ JSON.stringify(devcontainerJson, null, 2) + "\n"
937
+ );
938
+ const featuresDir = path2.join(devcontainerDir, "features");
939
+ if (existsSync2(featuresDir)) {
940
+ await fs2.rm(featuresDir, { recursive: true, force: true });
941
+ }
942
+ const resolvedFeatures = resolveFeatures(opts);
943
+ for (const f of resolvedFeatures) {
944
+ if (!f.localSourceDir || !f.localName) continue;
945
+ const dest = path2.join(featuresDir, f.localName);
946
+ await fs2.mkdir(dest, { recursive: true });
947
+ await fs2.cp(f.localSourceDir, dest, { recursive: true });
948
+ }
949
+ for (const f of resolvedFeatures) {
950
+ for (const sub of f.persistentHomePaths) {
951
+ await fs2.mkdir(path2.join(homeDir, sub), { recursive: true });
952
+ }
953
+ for (const entry2 of f.persistentHomeFiles) {
954
+ const filePath = path2.join(homeDir, entry2.path);
955
+ await fs2.mkdir(path2.dirname(filePath), { recursive: true });
956
+ if (!existsSync2(filePath)) {
957
+ await fs2.writeFile(filePath, entry2.initialContent);
958
+ }
959
+ }
960
+ }
961
+ await writePostCreateScript(devcontainerDir, opts);
962
+ const composePath = path2.join(devcontainerDir, "compose.yaml");
963
+ if (needsCompose(opts)) {
964
+ await fs2.writeFile(composePath, buildComposeYaml(opts));
965
+ } else if (existsSync2(composePath)) {
966
+ await fs2.rm(composePath);
967
+ }
968
+ await fs2.writeFile(
969
+ path2.join(targetDir, `${opts.name}.code-workspace`),
970
+ JSON.stringify(buildCodeWorkspaceJson(opts), null, 2) + "\n"
971
+ );
972
+ }
973
+
974
+ // src/modify/yml.ts
975
+ import { isMap, isScalar, isSeq, YAMLMap, YAMLSeq } from "yaml";
976
+ function ensureSeq(doc, key) {
977
+ const existing = doc.get(key, true);
978
+ if (existing && isSeq(existing)) return existing;
979
+ const seq = new YAMLSeq();
980
+ doc.set(key, seq);
981
+ return seq;
982
+ }
983
+ function pruneEmptySeq(doc, key) {
984
+ const node = doc.get(key, true);
985
+ if (node && isSeq(node) && node.items.length === 0) {
986
+ doc.delete(key);
987
+ }
988
+ }
989
+ function scalarValue(item) {
990
+ return isScalar(item) ? item.value : item;
991
+ }
992
+ function addLanguageToDoc(doc, lang) {
993
+ const seq = ensureSeq(doc, "languages");
994
+ if (seq.items.some((i) => scalarValue(i) === lang)) return false;
995
+ seq.add(lang);
996
+ return true;
997
+ }
998
+ function addServiceToDoc(doc, service) {
999
+ const seq = ensureSeq(doc, "services");
1000
+ if (seq.items.some((i) => scalarValue(i) === service)) return false;
1001
+ seq.add(service);
1002
+ return true;
1003
+ }
1004
+ function addAptPackagesToDoc(doc, packages) {
1005
+ const seq = ensureSeq(doc, "aptPackages");
1006
+ let changed = false;
1007
+ for (const pkg of packages) {
1008
+ if (seq.items.some((i) => scalarValue(i) === pkg)) continue;
1009
+ seq.add(pkg);
1010
+ changed = true;
1011
+ }
1012
+ return changed;
1013
+ }
1014
+ function addInstallUrlToDoc(doc, url) {
1015
+ const seq = ensureSeq(doc, "installUrls");
1016
+ if (seq.items.some((i) => scalarValue(i) === url)) return false;
1017
+ seq.add(url);
1018
+ return true;
1019
+ }
1020
+ function addFeatureToDoc(doc, ref, options = {}) {
1021
+ const seq = ensureSeq(doc, "features");
1022
+ for (const item of seq.items) {
1023
+ if (!isMap(item)) continue;
1024
+ const itemRef = item.get("ref");
1025
+ if (itemRef !== ref) continue;
1026
+ const itemJs = item.toJS(doc);
1027
+ const existingJs = itemJs.options ?? {};
1028
+ if (JSON.stringify(existingJs) === JSON.stringify(options)) {
1029
+ return false;
1030
+ }
1031
+ throw new Error(
1032
+ `Feature ${ref} is already configured with different options. Remove it first (\`monoceros remove-feature ${ref}\`) before re-adding.`
1033
+ );
1034
+ }
1035
+ const entry2 = new YAMLMap();
1036
+ entry2.set("ref", ref);
1037
+ if (Object.keys(options).length > 0) {
1038
+ entry2.set("options", options);
1039
+ }
1040
+ seq.add(entry2);
1041
+ return true;
1042
+ }
1043
+ function addRepoToDoc(doc, repo) {
1044
+ const seq = ensureSeq(doc, "repos");
1045
+ const repoName = repo.name ?? deriveRepoName(repo.url);
1046
+ for (const item of seq.items) {
1047
+ if (!isMap(item)) continue;
1048
+ const url = item.get("url");
1049
+ if (url !== repo.url) continue;
1050
+ const existingName = item.get("name");
1051
+ const effectiveName = typeof existingName === "string" ? existingName : deriveRepoName(url);
1052
+ const existingBranch = item.get("branch");
1053
+ if (effectiveName === repoName && (existingBranch ?? void 0) === (repo.branch ?? void 0)) {
1054
+ return false;
1055
+ }
1056
+ }
1057
+ const entry2 = new YAMLMap();
1058
+ entry2.set("url", repo.url);
1059
+ if (repo.name !== void 0 && repo.name !== deriveRepoName(repo.url)) {
1060
+ entry2.set("name", repo.name);
1061
+ }
1062
+ if (repo.branch !== void 0) {
1063
+ entry2.set("branch", repo.branch);
1064
+ }
1065
+ seq.add(entry2);
1066
+ return true;
1067
+ }
1068
+ function removeLanguageFromDoc(doc, lang) {
1069
+ return removeScalarFromSeq(doc, "languages", lang);
1070
+ }
1071
+ function removeServiceFromDoc(doc, service) {
1072
+ return removeScalarFromSeq(doc, "services", service);
1073
+ }
1074
+ function removeAptPackageFromDoc(doc, pkg) {
1075
+ return removeScalarFromSeq(doc, "aptPackages", pkg);
1076
+ }
1077
+ function removeAptPackagesFromDoc(doc, packages) {
1078
+ let changed = false;
1079
+ for (const pkg of packages) {
1080
+ if (removeAptPackageFromDoc(doc, pkg)) changed = true;
1081
+ }
1082
+ return changed;
1083
+ }
1084
+ function removeInstallUrlFromDoc(doc, url) {
1085
+ return removeScalarFromSeq(doc, "installUrls", url);
1086
+ }
1087
+ function removeFeatureFromDoc(doc, ref) {
1088
+ const seq = doc.get("features", true);
1089
+ if (!seq || !isSeq(seq)) return false;
1090
+ const idx = seq.items.findIndex((i) => isMap(i) && i.get("ref") === ref);
1091
+ if (idx < 0) return false;
1092
+ seq.items.splice(idx, 1);
1093
+ pruneEmptySeq(doc, "features");
1094
+ return true;
1095
+ }
1096
+ function removeRepoFromDoc(doc, urlOrName) {
1097
+ const seq = doc.get("repos", true);
1098
+ if (!seq || !isSeq(seq)) return false;
1099
+ const idx = seq.items.findIndex((item) => {
1100
+ if (!isMap(item)) return false;
1101
+ const url = item.get("url");
1102
+ if (url === urlOrName) return true;
1103
+ const name = item.get("name");
1104
+ const effectiveName = typeof name === "string" ? name : typeof url === "string" ? deriveRepoName(url) : void 0;
1105
+ return effectiveName === urlOrName;
1106
+ });
1107
+ if (idx < 0) return false;
1108
+ seq.items.splice(idx, 1);
1109
+ pruneEmptySeq(doc, "repos");
1110
+ return true;
1111
+ }
1112
+ function removeScalarFromSeq(doc, key, value) {
1113
+ const seq = doc.get(key, true);
1114
+ if (!seq || !isSeq(seq)) return false;
1115
+ const idx = seq.items.findIndex((i) => scalarValue(i) === value);
1116
+ if (idx < 0) return false;
1117
+ seq.items.splice(idx, 1);
1118
+ pruneEmptySeq(doc, key);
1119
+ return true;
1120
+ }
1121
+
1122
+ // src/modify/index.ts
1123
+ function runAddLanguage(input) {
1124
+ if (!BUILTIN_LANGUAGES.has(input.language) && !LANGUAGE_CATALOG[input.language]) {
1125
+ throw new Error(
1126
+ `Unknown language: ${input.language}. Known: ${knownLanguages().join(", ")}.`
1127
+ );
1128
+ }
1129
+ return mutate(input, (doc) => addLanguageToDoc(doc, input.language));
1130
+ }
1131
+ function runAddService(input) {
1132
+ if (!SERVICE_CATALOG[input.service]) {
1133
+ throw new Error(
1134
+ `Unknown service: ${input.service}. Known: ${knownServices().join(", ")}.`
1135
+ );
1136
+ }
1137
+ return mutate(input, (doc) => addServiceToDoc(doc, input.service));
1138
+ }
1139
+ function runAddAptPackages(input) {
1140
+ if (input.packages.length === 0) {
1141
+ throw new Error(
1142
+ "No package names given. Usage: monoceros add-apt-packages <containername> -- <pkg> [<pkg> \u2026]."
1143
+ );
1144
+ }
1145
+ return mutate(input, (doc) => addAptPackagesToDoc(doc, input.packages));
1146
+ }
1147
+ function runAddRepo(input) {
1148
+ const url = input.url.trim();
1149
+ if (url.length === 0) {
1150
+ throw new Error(
1151
+ "Missing repo URL. Usage: monoceros add-repo <containername> <url>."
1152
+ );
1153
+ }
1154
+ const name = (input.repoName ?? deriveRepoName(url)).trim();
1155
+ const entry2 = {
1156
+ url,
1157
+ name,
1158
+ ...input.branch !== void 0 ? { branch: input.branch } : {}
1159
+ };
1160
+ return mutate(input, (doc) => addRepoToDoc(doc, entry2));
1161
+ }
1162
+ function runAddFromUrl(input) {
1163
+ const url = input.url.trim();
1164
+ if (url.length === 0) {
1165
+ throw new Error(
1166
+ "Missing URL. Usage: monoceros add-from-url <containername> <url>."
1167
+ );
1168
+ }
1169
+ return mutate(input, (doc) => addInstallUrlToDoc(doc, url));
1170
+ }
1171
+ function runAddFeature(input) {
1172
+ const ref = input.ref.trim();
1173
+ if (ref.length === 0) {
1174
+ throw new Error(
1175
+ "Missing feature ref. Usage: monoceros add-feature <containername> <ref>."
1176
+ );
1177
+ }
1178
+ return mutate(input, (doc) => addFeatureToDoc(doc, ref, input.options ?? {}));
1179
+ }
1180
+ function runRemoveLanguage(input) {
1181
+ return mutate(input, (doc) => removeLanguageFromDoc(doc, input.language));
1182
+ }
1183
+ function runRemoveService(input) {
1184
+ return mutate(input, (doc) => removeServiceFromDoc(doc, input.service));
1185
+ }
1186
+ function runRemoveAptPackages(input) {
1187
+ if (input.packages.length === 0) {
1188
+ throw new Error(
1189
+ "No package names given. Usage: monoceros remove-apt-packages <containername> -- <pkg> [<pkg> \u2026]."
1190
+ );
1191
+ }
1192
+ return mutate(input, (doc) => removeAptPackagesFromDoc(doc, input.packages));
1193
+ }
1194
+ function runRemoveFeature(input) {
1195
+ const ref = input.ref.trim();
1196
+ if (ref.length === 0) {
1197
+ throw new Error(
1198
+ "Missing feature ref. Usage: monoceros remove-feature <containername> <ref>."
1199
+ );
1200
+ }
1201
+ return mutate(input, (doc) => removeFeatureFromDoc(doc, ref));
1202
+ }
1203
+ function runRemoveFromUrl(input) {
1204
+ const url = input.url.trim();
1205
+ if (url.length === 0) {
1206
+ throw new Error(
1207
+ "Missing URL. Usage: monoceros remove-from-url <containername> <url>."
1208
+ );
1209
+ }
1210
+ return mutate(input, (doc) => removeInstallUrlFromDoc(doc, url));
1211
+ }
1212
+ function runRemoveRepo(input) {
1213
+ const target = input.target.trim();
1214
+ if (target.length === 0) {
1215
+ throw new Error(
1216
+ "Missing repo identifier. Usage: monoceros remove-repo <containername> <url-or-name>."
1217
+ );
1218
+ }
1219
+ return mutate(input, (doc) => removeRepoFromDoc(doc, target));
1220
+ }
1221
+ async function mutate(opts, apply) {
1222
+ if (!REGEX.solutionName.test(opts.name)) {
1223
+ throw new Error(
1224
+ `Invalid container name: ${JSON.stringify(opts.name)}. Use letters, digits, '.', '_' or '-'.`
1225
+ );
1226
+ }
1227
+ const home = opts.monocerosHome ?? monocerosHome();
1228
+ const ymlPath = containerConfigPath(opts.name, home);
1229
+ const logger = opts.logger ?? defaultLogger();
1230
+ let oldText;
1231
+ try {
1232
+ oldText = await fs3.readFile(ymlPath, "utf8");
1233
+ } catch {
1234
+ throw new Error(
1235
+ `No such config: ${ymlPath}. Run \`monoceros init <template> ${opts.name}\` first.`
1236
+ );
1237
+ }
1238
+ const parsed = parseConfig(oldText, ymlPath);
1239
+ const changed = apply(parsed.doc);
1240
+ if (!changed) {
1241
+ logger.info("No changes \u2014 yml is already in the desired state.");
1242
+ return { status: "no-change" };
1243
+ }
1244
+ const newText = stringifyConfig(parsed.doc);
1245
+ parseConfig(newText, ymlPath);
1246
+ const out = opts.output ?? ((line) => process.stdout.write(line + "\n"));
1247
+ out(createPatch(ymlPath, oldText, newText, "before", "after"));
1248
+ if (!opts.yes) {
1249
+ const confirm = opts.confirm ?? defaultConfirm;
1250
+ const ok = await confirm("Apply these changes to the yml?");
1251
+ if (!ok) {
1252
+ logger.warn("Aborted by user. The yml was not modified.");
1253
+ return { status: "aborted" };
1254
+ }
1255
+ }
1256
+ await fs3.writeFile(ymlPath, newText, "utf8");
1257
+ logger.success(`Updated ${ymlPath}.`);
1258
+ logger.info(
1259
+ `Run \`monoceros apply ${opts.name}\` to rebuild the dev-container and pick up the change.`
1260
+ );
1261
+ return { status: "updated", changedPaths: [ymlPath] };
1262
+ }
1263
+ function defaultLogger() {
1264
+ return {
1265
+ info: (m) => consola.info(m),
1266
+ success: (m) => consola.success(m),
1267
+ warn: (m) => consola.warn(m)
1268
+ };
1269
+ }
1270
+ var defaultConfirm = async (message) => {
1271
+ const result = await consola.prompt(message, {
1272
+ type: "confirm",
1273
+ initial: false
1274
+ });
1275
+ return result === true;
1276
+ };
1277
+
1278
+ // src/commands/add-apt-packages.ts
1279
+ var addAptPackagesCommand = defineCommand({
1280
+ meta: {
1281
+ name: "add-apt-packages",
1282
+ description: "Add Debian/Ubuntu apt packages to the container config. Pass package names after `--` (e.g. `monoceros add-apt-packages sandbox -- make openssh-client jq`). Idempotent. No curated whitelist \u2014 invalid names surface as apt errors at container build time."
1283
+ },
1284
+ args: {
1285
+ name: {
1286
+ type: "positional",
1287
+ description: "Container name (yml in $MONOCEROS_HOME/container-configs/).",
1288
+ required: true
1289
+ },
1290
+ yes: {
1291
+ type: "boolean",
1292
+ description: "Skip the interactive confirmation and apply the diff.",
1293
+ alias: ["y"],
1294
+ default: false
1295
+ }
1296
+ },
1297
+ async run({ args }) {
1298
+ const packages = [...getInnerArgs()];
1299
+ if (packages.length === 0) {
1300
+ consola2.error(
1301
+ "No package names given. Usage: `monoceros add-apt-packages <containername> [--yes] -- <pkg> [<pkg> \u2026]`."
1302
+ );
1303
+ process.exit(1);
1304
+ }
1305
+ try {
1306
+ const result = await runAddAptPackages({
1307
+ name: args.name,
1308
+ packages,
1309
+ yes: args.yes
1310
+ });
1311
+ process.exit(result.status === "aborted" ? 1 : 0);
1312
+ } catch (err) {
1313
+ consola2.error(err instanceof Error ? err.message : String(err));
1314
+ process.exit(1);
1315
+ }
1316
+ }
1317
+ });
1318
+
1319
+ // src/commands/add-feature.ts
1320
+ import { defineCommand as defineCommand2 } from "citty";
1321
+ import { consola as consola3 } from "consola";
1322
+ var addFeatureCommand = defineCommand2({
1323
+ meta: {
1324
+ name: "add-feature",
1325
+ description: "Add a devcontainer feature by ref to the container config. Options follow `--` as `key=value` pairs. Idempotent (same ref + same options is a no-op). Adding the same ref with different options is an error."
1326
+ },
1327
+ args: {
1328
+ name: {
1329
+ type: "positional",
1330
+ description: "Container name (yml in $MONOCEROS_HOME/container-configs/).",
1331
+ required: true
1332
+ },
1333
+ ref: {
1334
+ type: "positional",
1335
+ description: "Devcontainer feature ref (OCI image style, e.g. `ghcr.io/devcontainers/features/docker-in-docker:2`).",
1336
+ required: true
1337
+ },
1338
+ yes: {
1339
+ type: "boolean",
1340
+ description: "Skip the interactive confirmation and apply the diff.",
1341
+ alias: ["y"],
1342
+ default: false
1343
+ }
1344
+ },
1345
+ async run({ args }) {
1346
+ let options;
1347
+ try {
1348
+ options = parseOptionsAfterDashes(getInnerArgs());
1349
+ } catch (err) {
1350
+ consola3.error(err instanceof Error ? err.message : String(err));
1351
+ process.exit(1);
1352
+ }
1353
+ try {
1354
+ const result = await runAddFeature({
1355
+ name: args.name,
1356
+ ref: args.ref,
1357
+ options,
1358
+ yes: args.yes
1359
+ });
1360
+ process.exit(result.status === "aborted" ? 1 : 0);
1361
+ } catch (err) {
1362
+ consola3.error(err instanceof Error ? err.message : String(err));
1363
+ process.exit(1);
1364
+ }
1365
+ }
1366
+ });
1367
+ function parseOptionsAfterDashes(tokens) {
1368
+ const result = {};
1369
+ for (const token of tokens) {
1370
+ const eqIdx = token.indexOf("=");
1371
+ if (eqIdx <= 0) {
1372
+ throw new Error(
1373
+ `Invalid option: ${JSON.stringify(token)}. Expected key=value (e.g. version=latest).`
1374
+ );
1375
+ }
1376
+ const key = token.slice(0, eqIdx);
1377
+ const raw = token.slice(eqIdx + 1);
1378
+ result[key] = coerce(raw);
1379
+ }
1380
+ return result;
1381
+ }
1382
+ function coerce(value) {
1383
+ if (value === "true") return true;
1384
+ if (value === "false") return false;
1385
+ if (/^-?\d+$/.test(value)) {
1386
+ const n = Number(value);
1387
+ if (Number.isSafeInteger(n)) return n;
1388
+ }
1389
+ return value;
1390
+ }
1391
+
1392
+ // src/commands/add-from-url.ts
1393
+ import { defineCommand as defineCommand3 } from "citty";
1394
+ import { consola as consola4 } from "consola";
1395
+ var addFromUrlCommand = defineCommand3({
1396
+ meta: {
1397
+ name: "add-from-url",
1398
+ description: "Add an https:// install URL to the container config. The URL gets piped to sh on every container rebuild. Loudly warns about remote-code execution before persisting. Idempotent."
1399
+ },
1400
+ args: {
1401
+ name: {
1402
+ type: "positional",
1403
+ description: "Container name (yml in $MONOCEROS_HOME/container-configs/).",
1404
+ required: true
1405
+ },
1406
+ url: {
1407
+ type: "positional",
1408
+ description: "https:// URL of an install script (e.g. https://starship.rs/install.sh).",
1409
+ required: true
1410
+ },
1411
+ yes: {
1412
+ type: "boolean",
1413
+ description: "Skip the security warning + diff confirm. Use only in scripts where you have already audited the URL.",
1414
+ alias: ["y"],
1415
+ default: false
1416
+ }
1417
+ },
1418
+ async run({ args }) {
1419
+ if (!args.yes) {
1420
+ printSecurityWarning(args.url);
1421
+ }
1422
+ try {
1423
+ const result = await runAddFromUrl({
1424
+ name: args.name,
1425
+ url: args.url,
1426
+ yes: args.yes
1427
+ });
1428
+ process.exit(result.status === "aborted" ? 1 : 0);
1429
+ } catch (err) {
1430
+ consola4.error(err instanceof Error ? err.message : String(err));
1431
+ process.exit(1);
1432
+ }
1433
+ }
1434
+ });
1435
+ function printSecurityWarning(url) {
1436
+ const w = (line) => process.stderr.write(line + "\n");
1437
+ w("");
1438
+ w("\u26A0\uFE0F SECURITY WARNING \u2014 `monoceros add-from-url`");
1439
+ w("");
1440
+ w(` URL: ${url}`);
1441
+ w("");
1442
+ w(" This URL will be fetched and piped to sh on every container rebuild.");
1443
+ w(
1444
+ " Remote-code execution against a URL you do not control is a supply-chain"
1445
+ );
1446
+ w(
1447
+ " risk: the maintainer could change the script tomorrow and your container"
1448
+ );
1449
+ w(" would silently run the new payload.");
1450
+ w("");
1451
+ w(" Before confirming below:");
1452
+ w(" 1. Open the URL in a browser, read what the script does.");
1453
+ w(
1454
+ " 2. Verify the maintainer is who you think they are (HTTPS cert, repo)."
1455
+ );
1456
+ w(" 3. Ideally, vendor the install steps as `add-apt-packages` or");
1457
+ w(
1458
+ " `add-feature` instead \u2014 those reference signed/versioned artifacts."
1459
+ );
1460
+ w("");
1461
+ }
1462
+
1463
+ // src/commands/add-repo.ts
1464
+ import { defineCommand as defineCommand4 } from "citty";
1465
+ import { consola as consola5 } from "consola";
1466
+ var addRepoCommand = defineCommand4({
1467
+ meta: {
1468
+ name: "add-repo",
1469
+ description: "Add a git repo to the container config. Cloned into projects/<folder>/ on container build. Idempotent \u2014 existing project subfolders are left alone. Folder name derived from URL by default; override with --as."
1470
+ },
1471
+ args: {
1472
+ name: {
1473
+ type: "positional",
1474
+ description: "Container name (yml in $MONOCEROS_HOME/container-configs/).",
1475
+ required: true
1476
+ },
1477
+ url: {
1478
+ type: "positional",
1479
+ description: "Git URL (HTTPS or SSH/git@ form). E.g. https://github.com/foo/bar.git, git@github.com:foo/bar.git.",
1480
+ required: true
1481
+ },
1482
+ as: {
1483
+ type: "string",
1484
+ description: "Folder name under projects/. Default: derived from URL (e.g. bar.git \u2192 bar)."
1485
+ },
1486
+ branch: {
1487
+ type: "string",
1488
+ description: "Specific branch to clone (default: repo default branch)."
1489
+ },
1490
+ yes: {
1491
+ type: "boolean",
1492
+ description: "Skip the interactive confirmation and apply the diff.",
1493
+ alias: ["y"],
1494
+ default: false
1495
+ }
1496
+ },
1497
+ async run({ args }) {
1498
+ try {
1499
+ const result = await runAddRepo({
1500
+ name: args.name,
1501
+ url: args.url,
1502
+ ...typeof args.as === "string" ? { repoName: args.as } : {},
1503
+ ...typeof args.branch === "string" ? { branch: args.branch } : {},
1504
+ yes: args.yes
1505
+ });
1506
+ process.exit(result.status === "aborted" ? 1 : 0);
1507
+ } catch (err) {
1508
+ consola5.error(err instanceof Error ? err.message : String(err));
1509
+ process.exit(1);
1510
+ }
1511
+ }
1512
+ });
1513
+
1514
+ // src/commands/add-language.ts
1515
+ import { defineCommand as defineCommand5 } from "citty";
1516
+ import { consola as consola6 } from "consola";
1517
+ var addLanguageCommand = defineCommand5({
1518
+ meta: {
1519
+ name: "add-language",
1520
+ description: "Add a language toolchain (devcontainer feature) to the container config. Idempotent, prints a diff before writing."
1521
+ },
1522
+ args: {
1523
+ name: {
1524
+ type: "positional",
1525
+ description: "Container name (yml in $MONOCEROS_HOME/container-configs/).",
1526
+ required: true
1527
+ },
1528
+ language: {
1529
+ type: "positional",
1530
+ description: "Language identifier from the feature whitelist (e.g. python, java, rust).",
1531
+ required: true
1532
+ },
1533
+ yes: {
1534
+ type: "boolean",
1535
+ description: "Skip the interactive confirmation and apply the diff.",
1536
+ alias: ["y"],
1537
+ default: false
1538
+ }
1539
+ },
1540
+ async run({ args }) {
1541
+ try {
1542
+ const result = await runAddLanguage({
1543
+ name: args.name,
1544
+ language: args.language,
1545
+ yes: args.yes
1546
+ });
1547
+ process.exit(result.status === "aborted" ? 1 : 0);
1548
+ } catch (err) {
1549
+ consola6.error(err instanceof Error ? err.message : String(err));
1550
+ process.exit(1);
1551
+ }
1552
+ }
1553
+ });
1554
+
1555
+ // src/commands/add-service.ts
1556
+ import { defineCommand as defineCommand6 } from "citty";
1557
+ import { consola as consola7 } from "consola";
1558
+ var addServiceCommand = defineCommand6({
1559
+ meta: {
1560
+ name: "add-service",
1561
+ description: "Add a compose service (postgres, mysql, redis, \u2026) to the container config. Idempotent, prints a diff before writing."
1562
+ },
1563
+ args: {
1564
+ name: {
1565
+ type: "positional",
1566
+ description: "Container name (yml in $MONOCEROS_HOME/container-configs/).",
1567
+ required: true
1568
+ },
1569
+ service: {
1570
+ type: "positional",
1571
+ description: "Service identifier (postgres, mysql, redis).",
1572
+ required: true
1573
+ },
1574
+ yes: {
1575
+ type: "boolean",
1576
+ description: "Skip the interactive confirmation and apply the diff.",
1577
+ alias: ["y"],
1578
+ default: false
1579
+ }
1580
+ },
1581
+ async run({ args }) {
1582
+ try {
1583
+ const result = await runAddService({
1584
+ name: args.name,
1585
+ service: args.service,
1586
+ yes: args.yes
1587
+ });
1588
+ process.exit(result.status === "aborted" ? 1 : 0);
1589
+ } catch (err) {
1590
+ consola7.error(err instanceof Error ? err.message : String(err));
1591
+ process.exit(1);
1592
+ }
1593
+ }
1594
+ });
1595
+
1596
+ // src/commands/apply.ts
1597
+ import { defineCommand as defineCommand7 } from "citty";
1598
+
1599
+ // src/apply/index.ts
1600
+ import { existsSync as existsSync4, promises as fs8 } from "fs";
1601
+ import { consola as consola10 } from "consola";
1602
+
1603
+ // src/config/global.ts
1604
+ import { promises as fs4 } from "fs";
1605
+ import { z as z2 } from "zod";
1606
+ import { parseDocument as parseDocument2 } from "yaml";
1607
+ var SCHEMA_VERSION = 1;
1608
+ var MonocerosConfigSchema = z2.object({
1609
+ schemaVersion: z2.literal(SCHEMA_VERSION),
1610
+ defaults: z2.object({
1611
+ git: z2.object({
1612
+ user: GitUserSchema.optional()
1613
+ }).optional(),
1614
+ features: z2.record(
1615
+ z2.string().regex(
1616
+ REGEX.featureRef,
1617
+ "Invalid feature ref. Expected an OCI-image-style ref like 'ghcr.io/getmonoceros/monoceros-features/<name>:<tag>'."
1618
+ ),
1619
+ z2.record(z2.string(), FeatureOptionValueSchema)
1620
+ ).optional()
1621
+ }).optional()
1622
+ });
1623
+ async function readMonocerosConfig(opts = {}) {
1624
+ const home = opts.monocerosHome ?? monocerosHome();
1625
+ const filePath = monocerosConfigPath(home);
1626
+ let text;
1627
+ try {
1628
+ text = await fs4.readFile(filePath, "utf8");
1629
+ } catch {
1630
+ return void 0;
1631
+ }
1632
+ const doc = parseDocument2(text, { prettyErrors: true });
1633
+ if (doc.errors.length > 0) {
1634
+ throw new Error(
1635
+ `yaml parse error in ${filePath}: ${doc.errors[0].message}`
1636
+ );
1637
+ }
1638
+ const result = MonocerosConfigSchema.safeParse(doc.toJS());
1639
+ if (!result.success) {
1640
+ const issues = result.error.issues.map((issue) => {
1641
+ const where = issue.path.length > 0 ? issue.path.join(".") : "(root)";
1642
+ return ` - ${where}: ${issue.message}`;
1643
+ }).join("\n");
1644
+ throw new Error(
1645
+ `Invalid ${filePath}:
1646
+ ${issues}
1647
+
1648
+ See ${filePath.replace(
1649
+ /\.yml$/,
1650
+ ".sample.yml"
1651
+ )} for a valid example.`
1652
+ );
1653
+ }
1654
+ return result.data;
1655
+ }
1656
+
1657
+ // src/config/state.ts
1658
+ import { promises as fs5 } from "fs";
1659
+ import path3 from "path";
1660
+ function buildStateFile(opts) {
1661
+ return {
1662
+ schemaVersion: CONFIG_SCHEMA_VERSION,
1663
+ origin: opts.origin,
1664
+ monocerosCliVersion: opts.cliVersion,
1665
+ materializedAt: (opts.now ?? /* @__PURE__ */ new Date()).toISOString()
1666
+ };
1667
+ }
1668
+ function stateFilePath(targetDir) {
1669
+ return path3.join(targetDir, ".monoceros", "state.json");
1670
+ }
1671
+ async function readStateFile(targetDir) {
1672
+ try {
1673
+ const content = await fs5.readFile(stateFilePath(targetDir), "utf8");
1674
+ return JSON.parse(content);
1675
+ } catch {
1676
+ return void 0;
1677
+ }
1678
+ }
1679
+ async function writeStateFile(targetDir, state) {
1680
+ const monocerosDir = path3.join(targetDir, ".monoceros");
1681
+ await fs5.mkdir(monocerosDir, { recursive: true });
1682
+ await fs5.writeFile(
1683
+ stateFilePath(targetDir),
1684
+ JSON.stringify(state, null, 2) + "\n"
1685
+ );
1686
+ }
1687
+
1688
+ // src/config/transform.ts
1689
+ function solutionConfigToCreateOptions(config, featureDefaults = {}) {
1690
+ const featureRecord = {};
1691
+ for (const entry2 of config.features) {
1692
+ const defaults = featureDefaults[entry2.ref] ?? {};
1693
+ featureRecord[entry2.ref] = { ...defaults, ...entry2.options ?? {} };
1694
+ }
1695
+ const result = {
1696
+ name: config.name,
1697
+ languages: [...config.languages],
1698
+ services: [...config.services]
1699
+ };
1700
+ if (config.externalServices.postgres !== void 0) {
1701
+ result.postgresUrl = config.externalServices.postgres;
1702
+ }
1703
+ if (config.aptPackages.length > 0) {
1704
+ result.aptPackages = [...config.aptPackages];
1705
+ }
1706
+ if (Object.keys(featureRecord).length > 0) {
1707
+ result.features = featureRecord;
1708
+ }
1709
+ if (config.installUrls.length > 0) {
1710
+ result.installUrls = [...config.installUrls];
1711
+ }
1712
+ if (config.repos.length > 0) {
1713
+ result.repos = config.repos.map((r) => ({
1714
+ url: r.url,
1715
+ // `name` is optional in the yml (derived from URL on apply),
1716
+ // required in CreateOptions; the caller derives it via
1717
+ // `deriveRepoName` when undefined.
1718
+ name: r.name ?? deriveRepoName(r.url),
1719
+ ...r.branch !== void 0 ? { branch: r.branch } : {}
1720
+ }));
1721
+ }
1722
+ return result;
1723
+ }
1724
+
1725
+ // src/devcontainer/compose.ts
1726
+ import { spawn as spawn2 } from "child_process";
1727
+ import { existsSync as existsSync3 } from "fs";
1728
+ import path5 from "path";
1729
+ import { consola as consola8 } from "consola";
1730
+
1731
+ // src/util/mask-secrets.ts
1732
+ import { Transform } from "stream";
1733
+ var PATTERNS = [
1734
+ // Atlassian Cloud API token. Starts with literal `ATATT3xFf` plus
1735
+ // a long URL-safe-base64 tail. Tightened to that prefix to avoid
1736
+ // matching unrelated all-caps words.
1737
+ { name: "atlassian-api", re: /ATATT3xFf[A-Za-z0-9+/=_-]{20,}/g },
1738
+ // Bitbucket Cloud app password.
1739
+ { name: "bitbucket-app", re: /ATBB[A-Za-z0-9+/=_-]{20,}/g },
1740
+ // GitHub PAT (classic), OAuth, user, server, refresh — all share
1741
+ // the `gh<lower-letter>_<base62>` shape per GitHub's token format.
1742
+ { name: "github-token", re: /gh[a-z]_[A-Za-z0-9]{20,}/g },
1743
+ // GitHub fine-grained PAT.
1744
+ { name: "github-pat", re: /github_pat_[A-Za-z0-9_]{20,}/g },
1745
+ // Anthropic API key.
1746
+ { name: "anthropic-api", re: /sk-ant-[A-Za-z0-9_-]{20,}/g }
1747
+ ];
1748
+ function maskSecrets(text) {
1749
+ let result = text;
1750
+ for (const { re } of PATTERNS) {
1751
+ result = result.replace(re, maskOne);
1752
+ }
1753
+ return result;
1754
+ }
1755
+ function maskOne(token) {
1756
+ if (token.length <= 12) return token;
1757
+ return `${token.slice(0, 5)}\u2026${token.slice(-6)}`;
1758
+ }
1759
+ function createSecretMaskStream() {
1760
+ let buffer = "";
1761
+ return new Transform({
1762
+ decodeStrings: true,
1763
+ transform(chunk, _enc, cb) {
1764
+ const text = typeof chunk === "string" ? chunk : chunk.toString("utf8");
1765
+ buffer += text;
1766
+ const lastNewline = buffer.lastIndexOf("\n");
1767
+ if (lastNewline === -1) {
1768
+ cb(null);
1769
+ return;
1770
+ }
1771
+ const flushable = buffer.slice(0, lastNewline + 1);
1772
+ buffer = buffer.slice(lastNewline + 1);
1773
+ cb(null, maskSecrets(flushable));
1774
+ },
1775
+ flush(cb) {
1776
+ if (buffer.length > 0) {
1777
+ const tail = maskSecrets(buffer);
1778
+ buffer = "";
1779
+ cb(null, tail);
1780
+ return;
1781
+ }
1782
+ cb(null);
1783
+ }
1784
+ });
1785
+ }
1786
+
1787
+ // src/devcontainer/cli.ts
1788
+ import { spawn } from "child_process";
1789
+ import { readFileSync as readFileSync2 } from "fs";
1790
+ import { createRequire } from "module";
1791
+ import path4 from "path";
1792
+ var require_ = createRequire(import.meta.url);
1793
+ var cachedBinaryPath = null;
1794
+ function devcontainerCliPath() {
1795
+ if (cachedBinaryPath) return cachedBinaryPath;
1796
+ const pkgJsonPath = require_.resolve("@devcontainers/cli/package.json");
1797
+ const pkg = JSON.parse(readFileSync2(pkgJsonPath, "utf8"));
1798
+ const binEntry = typeof pkg.bin === "string" ? pkg.bin : pkg.bin?.devcontainer ?? "";
1799
+ if (!binEntry) {
1800
+ throw new Error("Could not resolve @devcontainers/cli bin entry.");
1801
+ }
1802
+ cachedBinaryPath = path4.resolve(path4.dirname(pkgJsonPath), binEntry);
1803
+ return cachedBinaryPath;
1804
+ }
1805
+ var spawnDevcontainer = (args, cwd, options = {}) => {
1806
+ const binPath = devcontainerCliPath();
1807
+ return new Promise((resolve, reject) => {
1808
+ if (options.interactive) {
1809
+ const child2 = spawn(process.execPath, [binPath, ...args], {
1810
+ cwd,
1811
+ stdio: "inherit"
1812
+ });
1813
+ child2.on("error", reject);
1814
+ child2.on("exit", (code) => resolve(code ?? 0));
1815
+ return;
1816
+ }
1817
+ const child = spawn(process.execPath, [binPath, ...args], {
1818
+ cwd,
1819
+ stdio: ["ignore", "pipe", "pipe"]
1820
+ });
1821
+ if (options.quiet) {
1822
+ const stdoutChunks = [];
1823
+ const stderrChunks = [];
1824
+ child.stdout?.on("data", (chunk) => stdoutChunks.push(chunk));
1825
+ child.stderr?.on("data", (chunk) => stderrChunks.push(chunk));
1826
+ child.on("error", reject);
1827
+ child.on("exit", (code) => {
1828
+ const exitCode = code ?? 0;
1829
+ if (exitCode !== 0) {
1830
+ process.stderr.write(
1831
+ maskSecrets(Buffer.concat(stderrChunks).toString("utf8"))
1832
+ );
1833
+ process.stderr.write(
1834
+ maskSecrets(Buffer.concat(stdoutChunks).toString("utf8"))
1835
+ );
1836
+ }
1837
+ resolve(exitCode);
1838
+ });
1839
+ return;
1840
+ }
1841
+ child.stdout?.pipe(createSecretMaskStream()).pipe(process.stdout);
1842
+ child.stderr?.pipe(createSecretMaskStream()).pipe(process.stderr);
1843
+ child.on("error", reject);
1844
+ child.on("exit", (code) => resolve(code ?? 0));
1845
+ });
1846
+ };
1847
+
1848
+ // src/devcontainer/compose.ts
1849
+ var spawnDockerCompose = (args, cwd) => {
1850
+ return new Promise((resolve, reject) => {
1851
+ const child = spawn2("docker", ["compose", ...args], {
1852
+ cwd,
1853
+ stdio: ["inherit", "pipe", "pipe"]
1854
+ });
1855
+ child.stdout?.pipe(createSecretMaskStream()).pipe(process.stdout);
1856
+ child.stderr?.pipe(createSecretMaskStream()).pipe(process.stderr);
1857
+ child.on("error", reject);
1858
+ child.on("exit", (code) => resolve(code ?? 0));
1859
+ });
1860
+ };
1861
+ var spawnBash = (args, cwd) => {
1862
+ return new Promise((resolve, reject) => {
1863
+ const child = spawn2("bash", args, {
1864
+ cwd,
1865
+ stdio: ["inherit", "pipe", "pipe"]
1866
+ });
1867
+ child.stdout?.pipe(createSecretMaskStream()).pipe(process.stdout);
1868
+ child.stderr?.pipe(createSecretMaskStream()).pipe(process.stderr);
1869
+ child.on("error", reject);
1870
+ child.on("exit", (code) => resolve(code ?? 0));
1871
+ });
1872
+ };
1873
+ function composeProjectName(root) {
1874
+ return `${path5.basename(root)}_devcontainer`;
1875
+ }
1876
+ function resolveCompose(root) {
1877
+ if (!existsSync3(path5.join(root, ".devcontainer"))) {
1878
+ throw new Error(
1879
+ `No .devcontainer/ at ${root}. Run \`monoceros apply <name>\` first.`
1880
+ );
1881
+ }
1882
+ const composeFile = path5.join(root, ".devcontainer", "compose.yaml");
1883
+ if (!existsSync3(composeFile)) {
1884
+ throw new Error(
1885
+ `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.`
1886
+ );
1887
+ }
1888
+ return { composeFile, projectName: composeProjectName(root) };
1889
+ }
1890
+ async function runComposeAction(buildSubArgs, opts) {
1891
+ const { composeFile, projectName } = resolveCompose(opts.root);
1892
+ const spawnFn = opts.spawn ?? spawnDockerCompose;
1893
+ const subArgs = buildSubArgs(opts.service);
1894
+ return spawnFn(["-f", composeFile, "-p", projectName, ...subArgs], opts.root);
1895
+ }
1896
+ async function runStart(opts) {
1897
+ resolveCompose(opts.root);
1898
+ const logger = opts.logger ?? { info: (msg) => consola8.info(msg) };
1899
+ const spawnFn = opts.spawn ?? spawnDevcontainer;
1900
+ logger.info(`Bringing devcontainer up at ${opts.root}\u2026`);
1901
+ return spawnFn(["up", "--workspace-folder", opts.root], opts.root);
1902
+ }
1903
+ async function runContainerCycle(root, opts) {
1904
+ const { hasCompose, logger } = opts;
1905
+ if (hasCompose) {
1906
+ const projectName = composeProjectName(root);
1907
+ logger.info(
1908
+ `Force-removing existing ${projectName} containers (volumes preserved)\u2026`
1909
+ );
1910
+ const cleanupSpawn = opts.cleanupSpawn ?? spawnBash;
1911
+ const script = [
1912
+ `set -u`,
1913
+ `echo "[cleanup] checking project ${projectName}\u2026"`,
1914
+ `by_label=$(docker ps -aq --filter "label=com.docker.compose.project=${projectName}" 2>/dev/null || true)`,
1915
+ `by_name=$(docker ps -aq --filter "name=^${projectName}-" 2>/dev/null || true)`,
1916
+ `to_remove=$(printf "%s\\n%s\\n" "$by_label" "$by_name" | sort -u | grep -v "^$" || true)`,
1917
+ `if [ -n "$to_remove" ]; then echo "[cleanup] removing: $(echo $to_remove | tr "\\n" " ")"; docker rm -f $to_remove >/dev/null || true; else echo "[cleanup] no containers to remove"; fi`,
1918
+ `docker network rm ${projectName}_default 2>/dev/null && echo "[cleanup] network ${projectName}_default removed" || echo "[cleanup] network ${projectName}_default not present"`,
1919
+ `remaining_label=$(docker ps -aq --filter "label=com.docker.compose.project=${projectName}" 2>/dev/null || true)`,
1920
+ `remaining_name=$(docker ps -aq --filter "name=^${projectName}-" 2>/dev/null || true)`,
1921
+ `if [ -n "$remaining_label" ] || [ -n "$remaining_name" ]; then echo "" >&2; echo "ERROR: containers under project ${projectName} reappeared after removal." >&2; echo "This typically means VS Code's Remote Containers extension is connected to" >&2; echo "this devcontainer and auto-recreated it. Close the dev container session" >&2; echo "in VS Code (Cmd+Shift+P \u2192 'Dev Containers: Close Remote Connection')" >&2; echo "and retry \\\`monoceros apply\\\`." >&2; exit 1; fi`,
1922
+ `echo "[cleanup] done"`
1923
+ ].join("; ");
1924
+ const cleanupCode = await cleanupSpawn(["-c", script], root);
1925
+ if (cleanupCode !== 0) return cleanupCode;
1926
+ return runStart({
1927
+ root,
1928
+ ...opts.devcontainerSpawn ? { spawn: opts.devcontainerSpawn } : {},
1929
+ logger
1930
+ });
1931
+ }
1932
+ logger.info(`Recreating image-mode devcontainer at ${root}\u2026`);
1933
+ const spawnFn = opts.devcontainerSpawn ?? spawnDevcontainer;
1934
+ return spawnFn(
1935
+ ["up", "--workspace-folder", root, "--remove-existing-container"],
1936
+ root
1937
+ );
1938
+ }
1939
+ function runStop(opts) {
1940
+ return runComposeAction(
1941
+ (service) => ["stop", ...service ? [service] : []],
1942
+ opts
1943
+ );
1944
+ }
1945
+ function runStatus(opts) {
1946
+ return runComposeAction(
1947
+ (service) => ["ps", ...service ? [service] : []],
1948
+ opts
1949
+ );
1950
+ }
1951
+ function runLogs(opts) {
1952
+ const follow = opts.follow ?? true;
1953
+ return runComposeAction(
1954
+ (service) => [
1955
+ "logs",
1956
+ ...follow ? ["-f"] : [],
1957
+ ...service ? [service] : []
1958
+ ],
1959
+ opts
1960
+ );
1961
+ }
1962
+
1963
+ // src/devcontainer/credentials.ts
1964
+ import { spawn as spawn3 } from "child_process";
1965
+ import { promises as fs6 } from "fs";
1966
+ import path6 from "path";
1967
+ var realGitCredentialFill = (input) => {
1968
+ return new Promise((resolve, reject) => {
1969
+ const child = spawn3("git", ["credential", "fill"], {
1970
+ stdio: ["pipe", "pipe", "inherit"]
1971
+ });
1972
+ let stdout = "";
1973
+ child.stdout.on("data", (chunk) => {
1974
+ stdout += chunk.toString();
1975
+ });
1976
+ child.on("error", reject);
1977
+ child.on("exit", (code) => resolve({ stdout, exitCode: code ?? 0 }));
1978
+ child.stdin.write(input);
1979
+ child.stdin.end();
1980
+ });
1981
+ };
1982
+ function uniqueHttpsHosts(repos) {
1983
+ const hosts = /* @__PURE__ */ new Set();
1984
+ for (const repo of repos) {
1985
+ if (!repo.url.startsWith("https://")) continue;
1986
+ try {
1987
+ hosts.add(new URL(repo.url).hostname);
1988
+ } catch {
1989
+ }
1990
+ }
1991
+ return [...hosts];
1992
+ }
1993
+ function parseCredentialFillOutput(output) {
1994
+ const result = {};
1995
+ for (const line of output.split("\n")) {
1996
+ const eqIdx = line.indexOf("=");
1997
+ if (eqIdx <= 0) continue;
1998
+ const key = line.slice(0, eqIdx);
1999
+ const value = line.slice(eqIdx + 1);
2000
+ if (key === "username") result.username = value;
2001
+ if (key === "password") result.password = value;
2002
+ }
2003
+ return result;
2004
+ }
2005
+ function formatCredentialLine(host, username, password) {
2006
+ const encUser = encodeURIComponent(username);
2007
+ const encPass = encodeURIComponent(password);
2008
+ return `https://${encUser}:${encPass}@${host}`;
2009
+ }
2010
+ async function collectGitCredentials(devContainerRoot, repos, options = {}) {
2011
+ const credsDir = path6.join(devContainerRoot, ".monoceros");
2012
+ const credentialsPath = path6.join(credsDir, "git-credentials");
2013
+ const hosts = uniqueHttpsHosts(repos);
2014
+ const spawnFn = options.spawn ?? realGitCredentialFill;
2015
+ const logger = options.logger ?? { info: () => {
2016
+ }, warn: () => {
2017
+ } };
2018
+ const lines = [];
2019
+ let hostsSkipped = 0;
2020
+ for (const host of hosts) {
2021
+ logger.info(`Fetching credentials for ${host} from host git\u2026`);
2022
+ const input = `protocol=https
2023
+ host=${host}
2024
+
2025
+ `;
2026
+ let result;
2027
+ try {
2028
+ result = await spawnFn(input);
2029
+ } catch (err) {
2030
+ logger.warn(
2031
+ `git credential fill not runnable for ${host} (${err instanceof Error ? err.message : String(err)}); skipping.`
2032
+ );
2033
+ hostsSkipped += 1;
2034
+ continue;
2035
+ }
2036
+ if (result.exitCode !== 0) {
2037
+ logger.warn(
2038
+ `git credential fill exited ${result.exitCode} for ${host}; container clone will prompt.`
2039
+ );
2040
+ hostsSkipped += 1;
2041
+ continue;
2042
+ }
2043
+ const { username, password } = parseCredentialFillOutput(result.stdout);
2044
+ if (!username || !password) {
2045
+ logger.warn(
2046
+ `git credential fill returned no username/password for ${host}; container clone will prompt.`
2047
+ );
2048
+ hostsSkipped += 1;
2049
+ continue;
2050
+ }
2051
+ lines.push(formatCredentialLine(host, username, password));
2052
+ }
2053
+ await fs6.mkdir(credsDir, { recursive: true });
2054
+ await fs6.writeFile(
2055
+ credentialsPath,
2056
+ lines.join("\n") + (lines.length > 0 ? "\n" : ""),
2057
+ {
2058
+ mode: 384
2059
+ }
2060
+ );
2061
+ return {
2062
+ hostsWritten: lines.length,
2063
+ hostsSkipped,
2064
+ credentialsPath
2065
+ };
2066
+ }
2067
+
2068
+ // src/devcontainer/identity.ts
2069
+ import { spawn as spawn4 } from "child_process";
2070
+ import { promises as fs7 } from "fs";
2071
+ import path7 from "path";
2072
+ import { consola as consola9 } from "consola";
2073
+ var realGitConfigGet = (key) => {
2074
+ return new Promise((resolve, reject) => {
2075
+ const child = spawn4("git", ["config", "--global", "--get", key], {
2076
+ stdio: ["ignore", "pipe", "inherit"]
2077
+ });
2078
+ let stdout = "";
2079
+ child.stdout.on("data", (chunk) => {
2080
+ stdout += chunk.toString();
2081
+ });
2082
+ child.on("error", reject);
2083
+ child.on(
2084
+ "exit",
2085
+ (code) => resolve({ value: stdout.trim(), exitCode: code ?? 0 })
2086
+ );
2087
+ });
2088
+ };
2089
+ var realIdentityPrompt = async (key) => {
2090
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
2091
+ return void 0;
2092
+ }
2093
+ const label = key === "user.name" ? "Git user.name for this dev container (full name)" : "Git user.email for this dev container";
2094
+ const value = await consola9.prompt(`${label}:`, { type: "text" });
2095
+ if (typeof value !== "string") return void 0;
2096
+ const trimmed = value.trim();
2097
+ return trimmed.length > 0 ? trimmed : void 0;
2098
+ };
2099
+ async function collectGitIdentity(devContainerRoot, options = {}) {
2100
+ const gitconfigDir = path7.join(devContainerRoot, ".monoceros");
2101
+ const gitconfigPath = path7.join(gitconfigDir, "gitconfig");
2102
+ const spawnFn = options.spawn ?? realGitConfigGet;
2103
+ const promptFn = options.prompt ?? realIdentityPrompt;
2104
+ const logger = options.logger ?? { info: () => {
2105
+ }, warn: () => {
2106
+ } };
2107
+ const existing = await readExistingGitconfig(gitconfigPath);
2108
+ const name = await resolveKey("user.name", {
2109
+ override: options.containerOverride?.name,
2110
+ defaultValue: options.defaults?.name,
2111
+ spawnFn,
2112
+ persistedValue: existing.name,
2113
+ promptFn,
2114
+ logger
2115
+ });
2116
+ const email = await resolveKey("user.email", {
2117
+ override: options.containerOverride?.email,
2118
+ defaultValue: options.defaults?.email,
2119
+ spawnFn,
2120
+ persistedValue: existing.email,
2121
+ promptFn,
2122
+ logger
2123
+ });
2124
+ const lines = ["[user]"];
2125
+ if (name !== void 0) lines.push(` name = ${name}`);
2126
+ if (email !== void 0) lines.push(` email = ${email}`);
2127
+ await fs7.mkdir(gitconfigDir, { recursive: true });
2128
+ await fs7.writeFile(gitconfigPath, lines.join("\n") + "\n");
2129
+ return {
2130
+ ...name !== void 0 ? { name } : {},
2131
+ ...email !== void 0 ? { email } : {},
2132
+ gitconfigPath
2133
+ };
2134
+ }
2135
+ async function resolveKey(key, opts) {
2136
+ if (opts.override !== void 0 && opts.override.length > 0) {
2137
+ return opts.override;
2138
+ }
2139
+ if (opts.defaultValue !== void 0 && opts.defaultValue.length > 0) {
2140
+ return opts.defaultValue;
2141
+ }
2142
+ const hostValue = await readKeyFromHost(opts.spawnFn, key, opts.logger);
2143
+ if (hostValue !== void 0) return hostValue;
2144
+ if (opts.persistedValue !== void 0 && opts.persistedValue.length > 0) {
2145
+ return opts.persistedValue;
2146
+ }
2147
+ const prompted = await opts.promptFn(key);
2148
+ if (prompted !== void 0) return prompted;
2149
+ opts.logger.warn(
2150
+ `No ${key} resolvable (yml override, monoceros-config.yml defaults, host \`git config --global\`, persisted .monoceros/gitconfig, prompt). Container git will have no ${key} until set explicitly.`
2151
+ );
2152
+ return void 0;
2153
+ }
2154
+ async function readKeyFromHost(spawnFn, key, logger) {
2155
+ try {
2156
+ const result = await spawnFn(key);
2157
+ if (result.exitCode === 0 && result.value.length > 0) {
2158
+ return result.value;
2159
+ }
2160
+ return void 0;
2161
+ } catch (err) {
2162
+ logger.warn(
2163
+ `Host git not runnable (${err instanceof Error ? err.message : String(err)}); identity not captured.`
2164
+ );
2165
+ return void 0;
2166
+ }
2167
+ }
2168
+ async function readExistingGitconfig(filePath) {
2169
+ try {
2170
+ const content = await fs7.readFile(filePath, "utf8");
2171
+ const result = {};
2172
+ const nameMatch = /^\s*name\s*=\s*(.+?)\s*$/m.exec(content);
2173
+ const emailMatch = /^\s*email\s*=\s*(.+?)\s*$/m.exec(content);
2174
+ if (nameMatch?.[1]) result.name = nameMatch[1];
2175
+ if (emailMatch?.[1]) result.email = emailMatch[1];
2176
+ return result;
2177
+ } catch {
2178
+ return {};
2179
+ }
2180
+ }
2181
+
2182
+ // src/apply/index.ts
2183
+ async function runApply(opts) {
2184
+ const home = opts.monocerosHome ?? monocerosHome();
2185
+ const logger = opts.logger ?? {
2186
+ info: (msg) => consola10.info(msg),
2187
+ success: (msg) => consola10.success(msg),
2188
+ warn: (msg) => consola10.warn(msg)
2189
+ };
2190
+ if (!REGEX.solutionName.test(opts.name)) {
2191
+ throw new Error(
2192
+ `Invalid config name: ${JSON.stringify(opts.name)}. Use letters, digits, '.', '_' or '-'.`
2193
+ );
2194
+ }
2195
+ const ymlPath = containerConfigPath(opts.name, home);
2196
+ if (!existsSync4(ymlPath)) {
2197
+ throw new Error(
2198
+ `No such config: ${ymlPath}. Run \`monoceros init <template> ${opts.name}\` first.`
2199
+ );
2200
+ }
2201
+ const targetDir = containerDir(opts.name, home);
2202
+ await assertSafeTargetDir(targetDir, opts.name);
2203
+ const parsed = await readConfig(ymlPath);
2204
+ const globalConfig = await readMonocerosConfig({ monocerosHome: home });
2205
+ warnOnDeprecatedFeatureRefs(parsed.config.features, globalConfig, logger);
2206
+ const createOpts = normalizeOptions(
2207
+ solutionConfigToCreateOptions(
2208
+ parsed.config,
2209
+ globalConfig?.defaults?.features ?? {}
2210
+ )
2211
+ );
2212
+ validateOptions(createOpts);
2213
+ await fs8.mkdir(targetDir, { recursive: true });
2214
+ await writeScaffold(createOpts, targetDir);
2215
+ await writeStateFile(
2216
+ targetDir,
2217
+ buildStateFile({
2218
+ origin: opts.name,
2219
+ cliVersion: opts.cliVersion,
2220
+ ...opts.now ? { now: opts.now } : {}
2221
+ })
2222
+ );
2223
+ const idLogger = {
2224
+ info: logger.info,
2225
+ warn: logger.warn ?? logger.info
2226
+ };
2227
+ await collectGitIdentity(targetDir, {
2228
+ ...opts.identitySpawn ? { spawn: opts.identitySpawn } : {},
2229
+ ...opts.identityPrompt ? { prompt: opts.identityPrompt } : {},
2230
+ ...parsed.config.git?.user ? { containerOverride: parsed.config.git.user } : {},
2231
+ ...globalConfig?.defaults?.git?.user ? { defaults: globalConfig.defaults.git.user } : {},
2232
+ logger: idLogger
2233
+ });
2234
+ if (createOpts.repos && createOpts.repos.some((r) => r.url.startsWith("https://"))) {
2235
+ await collectGitCredentials(targetDir, createOpts.repos, {
2236
+ ...opts.credentialsSpawn ? { spawn: opts.credentialsSpawn } : {},
2237
+ logger: idLogger
2238
+ });
2239
+ }
2240
+ logger.success(
2241
+ `Materialized config '${opts.name}' into ${targetDir}. Starting container\u2026`
2242
+ );
2243
+ const exitCode = await runContainerCycle(targetDir, {
2244
+ hasCompose: needsCompose(createOpts),
2245
+ ...opts.cleanupSpawn !== void 0 ? { cleanupSpawn: opts.cleanupSpawn } : {},
2246
+ ...opts.devcontainerSpawn !== void 0 ? { devcontainerSpawn: opts.devcontainerSpawn } : {},
2247
+ logger
2248
+ });
2249
+ return { targetDir, configPath: ymlPath, containerExitCode: exitCode };
2250
+ }
2251
+ async function assertSafeTargetDir(targetDir, expectedOrigin) {
2252
+ if (!existsSync4(targetDir)) return;
2253
+ const entries = await fs8.readdir(targetDir);
2254
+ if (entries.length === 0) return;
2255
+ const state = await readStateFile(targetDir);
2256
+ if (state) {
2257
+ if (state.origin !== expectedOrigin) {
2258
+ throw new Error(
2259
+ `${targetDir} is already materialized from config '${state.origin}', not '${expectedOrigin}'. Delete the directory to re-target, or run \`monoceros apply ${state.origin}\`.`
2260
+ );
2261
+ }
2262
+ return;
2263
+ }
2264
+ throw new Error(
2265
+ `Refusing to materialize into non-empty directory ${targetDir} (no Monoceros state.json found). Delete the directory before re-running.`
2266
+ );
2267
+ }
2268
+ function warnOnDeprecatedFeatureRefs(containerFeatures, globalConfig, logger) {
2269
+ const warn = logger.warn ?? logger.info;
2270
+ const seen = /* @__PURE__ */ new Set();
2271
+ const emit = (oldRef, source) => {
2272
+ if (seen.has(oldRef)) return;
2273
+ seen.add(oldRef);
2274
+ const newRef = migrateDeprecatedFeatureRef(oldRef);
2275
+ if (!newRef) return;
2276
+ warn(
2277
+ `Deprecated feature ref in ${source}: '${oldRef}'. Replace with '${newRef}' \u2014 the old namespace is no longer published. See docs/MIGRATION-M4.md for a sed snippet.`
2278
+ );
2279
+ };
2280
+ for (const entry2 of containerFeatures) {
2281
+ emit(entry2.ref, "container yml");
2282
+ }
2283
+ const globalDefaults = globalConfig?.defaults?.features;
2284
+ if (globalDefaults) {
2285
+ for (const ref of Object.keys(globalDefaults)) {
2286
+ emit(ref, "monoceros-config.yml");
2287
+ }
2288
+ }
2289
+ }
2290
+
2291
+ // src/version.ts
2292
+ var CLI_VERSION = "1.0.0";
2293
+
2294
+ // src/commands/_dispatch.ts
2295
+ import { consola as consola11 } from "consola";
2296
+ async function dispatch(runner) {
2297
+ try {
2298
+ const exitCode = await runner();
2299
+ process.exit(exitCode);
2300
+ } catch (err) {
2301
+ consola11.error(err instanceof Error ? err.message : String(err));
2302
+ process.exit(1);
2303
+ }
2304
+ }
2305
+
2306
+ // src/commands/apply.ts
2307
+ var applyCommand = defineCommand7({
2308
+ meta: {
2309
+ name: "apply",
2310
+ description: "Materialize a container config into $MONOCEROS_HOME/container/<name>/ and bring the dev-container up. Close any VS Code Remote Containers session for the target first \u2014 the extension auto-recreates and races with apply."
2311
+ },
2312
+ args: {
2313
+ name: {
2314
+ type: "positional",
2315
+ description: "Config name. Resolves to $MONOCEROS_HOME/container-configs/<name>.yml.",
2316
+ required: true
2317
+ }
2318
+ },
2319
+ run({ args }) {
2320
+ return dispatch(async () => {
2321
+ const result = await runApply({
2322
+ name: args.name,
2323
+ cliVersion: CLI_VERSION
2324
+ });
2325
+ return result.containerExitCode;
2326
+ });
2327
+ }
2328
+ });
2329
+
2330
+ // src/commands/init.ts
2331
+ import { defineCommand as defineCommand8 } from "citty";
2332
+ import { consola as consola13 } from "consola";
2333
+
2334
+ // src/init/index.ts
2335
+ import { existsSync as existsSync7, promises as fs10 } from "fs";
2336
+ import path10 from "path";
2337
+ import { consola as consola12 } from "consola";
2338
+
2339
+ // src/init/components.ts
2340
+ import { existsSync as existsSync5, promises as fs9 } from "fs";
2341
+ import path8 from "path";
2342
+ import { z as z3 } from "zod";
2343
+ import { parse as parseYaml } from "yaml";
2344
+ var CategorySchema = z3.enum(["language", "service", "feature"]);
2345
+ var FeatureContributionSchema = z3.object({
2346
+ ref: z3.string().regex(REGEX.featureRef),
2347
+ options: z3.record(z3.string(), FeatureOptionValueSchema).optional()
2348
+ });
2349
+ var ComponentFileSchema = z3.object({
2350
+ displayName: z3.string().min(1),
2351
+ description: z3.string().min(1),
2352
+ category: CategorySchema,
2353
+ contributes: z3.object({
2354
+ languages: z3.array(z3.string().min(1)).optional(),
2355
+ services: z3.array(z3.string().min(1)).optional(),
2356
+ features: z3.array(FeatureContributionSchema).optional()
2357
+ })
2358
+ }).superRefine((data, ctx) => {
2359
+ const c = data.contributes;
2360
+ const filled = [
2361
+ c.languages && c.languages.length > 0 ? "languages" : null,
2362
+ c.services && c.services.length > 0 ? "services" : null,
2363
+ c.features && c.features.length > 0 ? "features" : null
2364
+ ].filter((x) => x !== null);
2365
+ if (filled.length === 0) {
2366
+ ctx.addIssue({
2367
+ code: z3.ZodIssueCode.custom,
2368
+ message: "contributes must set at least one of languages/services/features"
2369
+ });
2370
+ return;
2371
+ }
2372
+ if (filled.length > 1) {
2373
+ ctx.addIssue({
2374
+ code: z3.ZodIssueCode.custom,
2375
+ message: `contributes must set exactly one of languages/services/features, got: ${filled.join(", ")}`
2376
+ });
2377
+ return;
2378
+ }
2379
+ const expected = data.category === "language" ? "languages" : data.category === "service" ? "services" : "features";
2380
+ if (filled[0] !== expected) {
2381
+ ctx.addIssue({
2382
+ code: z3.ZodIssueCode.custom,
2383
+ message: `category '${data.category}' requires contributes.${expected}, got contributes.${filled[0]}`
2384
+ });
2385
+ }
2386
+ });
2387
+ async function loadComponentCatalog(rootDir = componentsDir()) {
2388
+ if (!existsSync5(rootDir)) {
2389
+ return /* @__PURE__ */ new Map();
2390
+ }
2391
+ const out = /* @__PURE__ */ new Map();
2392
+ await walk(rootDir, rootDir, out);
2393
+ return out;
2394
+ }
2395
+ async function walk(baseDir, currentDir, out) {
2396
+ const entries = await fs9.readdir(currentDir, { withFileTypes: true });
2397
+ for (const entry2 of entries) {
2398
+ const full = path8.join(currentDir, entry2.name);
2399
+ if (entry2.isDirectory()) {
2400
+ await walk(baseDir, full, out);
2401
+ continue;
2402
+ }
2403
+ if (!entry2.isFile() || !entry2.name.endsWith(".yml")) continue;
2404
+ const relative = path8.relative(baseDir, full);
2405
+ const name = relative.replace(/\.yml$/, "").split(path8.sep).join("/");
2406
+ const text = await fs9.readFile(full, "utf8");
2407
+ let raw;
2408
+ try {
2409
+ raw = parseYaml(text);
2410
+ } catch (err) {
2411
+ throw new Error(
2412
+ `Failed to parse component ${name} (${full}): ${err.message}`
2413
+ );
2414
+ }
2415
+ const parsed = ComponentFileSchema.safeParse(raw);
2416
+ if (!parsed.success) {
2417
+ const issues = parsed.error.issues.map((issue) => {
2418
+ const where = issue.path.length > 0 ? issue.path.join(".") : "(root)";
2419
+ return ` - ${where}: ${issue.message}`;
2420
+ }).join("\n");
2421
+ throw new Error(`Invalid component ${name} (${full}):
2422
+ ${issues}`);
2423
+ }
2424
+ out.set(name, { name, sourcePath: full, file: parsed.data });
2425
+ }
2426
+ }
2427
+ function mergeComponents(resolved) {
2428
+ const languages = [];
2429
+ const services = [];
2430
+ const featureByRef = /* @__PURE__ */ new Map();
2431
+ for (const entry2 of resolved) {
2432
+ const c = isResolvedComponent(entry2) ? entry2.component : entry2;
2433
+ const version = isResolvedComponent(entry2) ? entry2.version : void 0;
2434
+ const ct = c.file.contributes;
2435
+ for (const lang of ct.languages ?? []) {
2436
+ const value = version !== void 0 ? `${lang}:${version}` : lang;
2437
+ if (!languages.includes(value)) languages.push(value);
2438
+ }
2439
+ for (const svc of ct.services ?? []) {
2440
+ if (!services.includes(svc)) services.push(svc);
2441
+ }
2442
+ for (const f of ct.features ?? []) {
2443
+ const existing = featureByRef.get(f.ref);
2444
+ if (!existing) {
2445
+ featureByRef.set(f.ref, {
2446
+ ref: f.ref,
2447
+ options: { ...f.options ?? {} }
2448
+ });
2449
+ continue;
2450
+ }
2451
+ existing.options = mergeFeatureOptions(existing.options, f.options ?? {});
2452
+ }
2453
+ }
2454
+ return {
2455
+ languages,
2456
+ services,
2457
+ features: [...featureByRef.values()]
2458
+ };
2459
+ }
2460
+ function isResolvedComponent(x) {
2461
+ return "component" in x;
2462
+ }
2463
+ function mergeFeatureOptions(a, b) {
2464
+ const result = { ...a };
2465
+ for (const [key, valueB] of Object.entries(b)) {
2466
+ const valueA = result[key];
2467
+ if (typeof valueA === "boolean" && typeof valueB === "boolean") {
2468
+ result[key] = valueA || valueB;
2469
+ continue;
2470
+ }
2471
+ result[key] = valueB;
2472
+ }
2473
+ return result;
2474
+ }
2475
+ function resolveComponents(catalog, names) {
2476
+ const unknown = [];
2477
+ const out = [];
2478
+ for (const raw of names) {
2479
+ const colon = raw.indexOf(":");
2480
+ const name = colon === -1 ? raw : raw.slice(0, colon);
2481
+ const version = colon === -1 ? void 0 : raw.slice(colon + 1);
2482
+ const c = catalog.get(name);
2483
+ if (!c) {
2484
+ unknown.push(raw);
2485
+ continue;
2486
+ }
2487
+ if (version !== void 0 && c.file.category !== "language") {
2488
+ throw new Error(
2489
+ `Component '${name}' is a ${c.file.category}, not a language \u2014 a ':${version}' suffix has no meaning here.`
2490
+ );
2491
+ }
2492
+ out.push({ component: c, ...version !== void 0 ? { version } : {} });
2493
+ }
2494
+ if (unknown.length > 0) {
2495
+ const available = [...catalog.keys()].sort();
2496
+ throw new Error(
2497
+ `Unknown component${unknown.length > 1 ? "s" : ""}: ${unknown.join(", ")}.
2498
+ Available: ${available.join(", ")}.`
2499
+ );
2500
+ }
2501
+ return out;
2502
+ }
2503
+
2504
+ // src/init/generator.ts
2505
+ var SCHEMA_HEADER = [
2506
+ "# Monoceros solution-config. Edit freely, then run",
2507
+ "# `monoceros apply <name>` to materialize a dev-container.",
2508
+ "#",
2509
+ "# Schema reference: see the workbench `templates/components/README.md`",
2510
+ "# and `docs/konzept.md` for what each section does. Each feature",
2511
+ "# under `features:` also accepts options not shown here \u2014 check",
2512
+ "# the feature's `devcontainer-feature.json` for the full list."
2513
+ ];
2514
+ function generateComposedYml(name, components, lookupManifest) {
2515
+ const merged = mergeComponents(components);
2516
+ const lines = [];
2517
+ for (const h of SCHEMA_HEADER) lines.push(h);
2518
+ lines.push("");
2519
+ lines.push("schemaVersion: 1");
2520
+ lines.push(`name: ${name}`);
2521
+ lines.push("");
2522
+ if (merged.languages.length > 0) {
2523
+ lines.push("languages:");
2524
+ for (const lang of merged.languages) lines.push(` - ${lang}`);
2525
+ lines.push("");
2526
+ }
2527
+ if (merged.services.length > 0) {
2528
+ lines.push("services:");
2529
+ for (const svc of merged.services) lines.push(` - ${svc}`);
2530
+ lines.push("");
2531
+ }
2532
+ if (merged.features.length > 0) {
2533
+ lines.push("features:");
2534
+ for (const f of merged.features) {
2535
+ const hints = lookupManifest(f.ref)?.optionHints ?? [];
2536
+ renderFeatureBlock(
2537
+ lines,
2538
+ f,
2539
+ hints,
2540
+ /* commented */
2541
+ false
2542
+ );
2543
+ }
2544
+ lines.push("");
2545
+ }
2546
+ return ensureTrailingNewline(lines.join("\n"));
2547
+ }
2548
+ function generateDocumentedYml(name, catalog, lookupManifest) {
2549
+ const byCategory = groupByCategory(catalog);
2550
+ const lines = [];
2551
+ for (const h of SCHEMA_HEADER) lines.push(h);
2552
+ lines.push("#");
2553
+ lines.push("# Below is the full set of components shipped with this");
2554
+ lines.push("# workbench, every one commented out. Un-comment the lines");
2555
+ lines.push("# you want active. The same effect (and a cleaner yml) is");
2556
+ lines.push("# achievable by running `monoceros init <name> --with=\u2026`");
2557
+ lines.push("# with a comma-separated list of component names.");
2558
+ lines.push("");
2559
+ lines.push("schemaVersion: 1");
2560
+ lines.push(`name: ${name}`);
2561
+ lines.push("");
2562
+ if (byCategory.language.length > 0) {
2563
+ const items = byCategory.language.flatMap(
2564
+ (c) => (c.file.contributes.languages ?? []).map((lang) => ({
2565
+ value: lang,
2566
+ label: c.file.displayName
2567
+ }))
2568
+ );
2569
+ const width = Math.max(...items.map((i) => i.value.length)) + 2;
2570
+ lines.push("# Languages \u2014 runtime toolchains.");
2571
+ lines.push("# languages:");
2572
+ for (const item of items) {
2573
+ const pad = " ".repeat(width - item.value.length);
2574
+ lines.push(`# - ${item.value}${pad}# ${item.label}`);
2575
+ }
2576
+ lines.push("");
2577
+ }
2578
+ if (byCategory.service.length > 0) {
2579
+ const items = byCategory.service.flatMap(
2580
+ (c) => (c.file.contributes.services ?? []).map((svc) => ({
2581
+ value: svc,
2582
+ label: c.file.displayName
2583
+ }))
2584
+ );
2585
+ const width = Math.max(...items.map((i) => i.value.length)) + 2;
2586
+ lines.push("# Services \u2014 compose-mode siblings of the workspace");
2587
+ lines.push("# container (compose mode kicks in as soon as at least");
2588
+ lines.push("# one service is active).");
2589
+ lines.push("# services:");
2590
+ for (const item of items) {
2591
+ const pad = " ".repeat(width - item.value.length);
2592
+ lines.push(`# - ${item.value}${pad}# ${item.label}`);
2593
+ }
2594
+ lines.push("");
2595
+ }
2596
+ if (byCategory.feature.length > 0) {
2597
+ lines.push("# Features \u2014 devcontainer features installed inside the");
2598
+ lines.push("# container. Each entry has an OCI-style `ref` plus an");
2599
+ lines.push("# optional `options` map. Credentials/auth keys appear");
2600
+ lines.push("# as commented hints; set them here per container, or");
2601
+ lines.push("# globally in monoceros-config.yml under");
2602
+ lines.push("# `defaults.features.<ref>`.");
2603
+ lines.push("#");
2604
+ lines.push("# Catalog:");
2605
+ lines.push("#");
2606
+ const nameColumnWidth = Math.max(...byCategory.feature.map((c) => c.name.length)) + 2;
2607
+ for (const c of byCategory.feature) {
2608
+ const pad = " ".repeat(nameColumnWidth - c.name.length);
2609
+ lines.push(`# ${c.name}${pad}${c.file.displayName}`);
2610
+ }
2611
+ lines.push("#");
2612
+ lines.push("# Below: one block per feature ref. Un-comment what");
2613
+ lines.push("# you want active. Sub-components share their parent's");
2614
+ lines.push("# block \u2014 pick the parent for the full preset, swap to");
2615
+ lines.push("# a sub-component name for a partial install.");
2616
+ lines.push("#");
2617
+ lines.push("# features:");
2618
+ const renderedRefs = /* @__PURE__ */ new Set();
2619
+ const topLevel = byCategory.feature.filter((c) => !c.name.includes("/"));
2620
+ for (const c of topLevel) {
2621
+ for (const f of c.file.contributes.features ?? []) {
2622
+ if (renderedRefs.has(f.ref)) continue;
2623
+ renderedRefs.add(f.ref);
2624
+ const hints = lookupManifest(f.ref)?.optionHints ?? [];
2625
+ renderFeatureBlock(
2626
+ lines,
2627
+ f,
2628
+ hints,
2629
+ /* commented */
2630
+ true
2631
+ );
2632
+ }
2633
+ }
2634
+ for (const c of byCategory.feature) {
2635
+ if (!c.name.includes("/")) continue;
2636
+ for (const f of c.file.contributes.features ?? []) {
2637
+ if (renderedRefs.has(f.ref)) continue;
2638
+ renderedRefs.add(f.ref);
2639
+ const hints = lookupManifest(f.ref)?.optionHints ?? [];
2640
+ renderFeatureBlock(
2641
+ lines,
2642
+ f,
2643
+ hints,
2644
+ /* commented */
2645
+ true
2646
+ );
2647
+ }
2648
+ }
2649
+ lines.push("");
2650
+ }
2651
+ return ensureTrailingNewline(lines.join("\n"));
2652
+ }
2653
+ function renderFeatureBlock(out, feature, optionHints, commented) {
2654
+ const c = commented ? "# " : " ";
2655
+ out.push(`${c}- ref: ${feature.ref}`);
2656
+ const options = feature.options ?? {};
2657
+ const activeOptions = Object.entries(options);
2658
+ const remainingHints = optionHints.filter((h) => !(h in options));
2659
+ if (activeOptions.length > 0) {
2660
+ out.push(`${c} options:`);
2661
+ for (const [key, value] of activeOptions) {
2662
+ out.push(`${c} ${key}: ${renderScalarValue(value)}`);
2663
+ }
2664
+ if (remainingHints.length > 0) {
2665
+ out.push(
2666
+ `${c} # Optional \u2014 override monoceros-config.yml defaults.features:`
2667
+ );
2668
+ for (const hint of remainingHints) {
2669
+ out.push(`${c} # ${hint}:`);
2670
+ }
2671
+ }
2672
+ } else if (remainingHints.length > 0) {
2673
+ out.push(
2674
+ `${c} # Optional \u2014 override monoceros-config.yml defaults.features:`
2675
+ );
2676
+ out.push(`${c} # options:`);
2677
+ for (const hint of remainingHints) {
2678
+ out.push(`${c} # ${hint}:`);
2679
+ }
2680
+ }
2681
+ }
2682
+ function renderScalarValue(value) {
2683
+ if (typeof value === "string") {
2684
+ return /^[A-Za-z_][A-Za-z0-9._-]*$/.test(value) ? value : JSON.stringify(value);
2685
+ }
2686
+ return String(value);
2687
+ }
2688
+ function groupByCategory(catalog) {
2689
+ const out = {
2690
+ language: [],
2691
+ service: [],
2692
+ feature: []
2693
+ };
2694
+ const sorted = [...catalog.values()].sort(
2695
+ (a, b) => a.name.localeCompare(b.name)
2696
+ );
2697
+ for (const c of sorted) {
2698
+ out[c.file.category].push(c);
2699
+ }
2700
+ return out;
2701
+ }
2702
+ function ensureTrailingNewline(s) {
2703
+ return s.endsWith("\n") ? s : s + "\n";
2704
+ }
2705
+
2706
+ // src/init/manifest.ts
2707
+ import { existsSync as existsSync6, readFileSync as readFileSync3 } from "fs";
2708
+ import path9 from "path";
2709
+ function loadFeatureManifestSummary(ref, checkoutRoot = workbenchCheckoutRoot()) {
2710
+ if (!checkoutRoot) return void 0;
2711
+ const match = matchMonocerosFeature(ref);
2712
+ if (!match) return void 0;
2713
+ const name = match.name;
2714
+ const manifestPath = path9.join(
2715
+ checkoutRoot,
2716
+ "images",
2717
+ "features",
2718
+ name,
2719
+ "devcontainer-feature.json"
2720
+ );
2721
+ if (!existsSync6(manifestPath)) return void 0;
2722
+ try {
2723
+ const text = readFileSync3(manifestPath, "utf8");
2724
+ const parsed = JSON.parse(text);
2725
+ const raw = parsed["x-monoceros"]?.optionHints;
2726
+ if (!Array.isArray(raw)) return { optionHints: [] };
2727
+ const hints = raw.filter(
2728
+ (x) => typeof x === "string" && x.length > 0
2729
+ );
2730
+ return { optionHints: hints };
2731
+ } catch {
2732
+ return void 0;
2733
+ }
2734
+ }
2735
+
2736
+ // src/init/index.ts
2737
+ async function runInit(opts) {
2738
+ const workbench = opts.workbenchRoot ?? workbenchRoot();
2739
+ const home = opts.monocerosHome ?? monocerosHome();
2740
+ const logger = opts.logger ?? {
2741
+ success: (msg) => consola12.success(msg),
2742
+ info: (msg) => consola12.info(msg)
2743
+ };
2744
+ if (!REGEX.solutionName.test(opts.name)) {
2745
+ throw new Error(
2746
+ `Invalid config name: ${JSON.stringify(opts.name)}. Use letters, digits, '.', '_' or '-'.`
2747
+ );
2748
+ }
2749
+ const dest = containerConfigPath(opts.name, home);
2750
+ if (existsSync7(dest)) {
2751
+ throw new Error(
2752
+ `Config already exists: ${dest}. Delete it manually before re-running \`monoceros init\` \u2014 this protects any hand-edits.`
2753
+ );
2754
+ }
2755
+ const catalog = await loadComponentCatalog(componentsDir(workbench));
2756
+ if (catalog.size === 0) {
2757
+ throw new Error(
2758
+ `No components found under ${componentsDir(workbench)}. The workbench checkout is incomplete.`
2759
+ );
2760
+ }
2761
+ const checkoutRoot = opts.workbenchRoot ?? workbenchCheckoutRoot();
2762
+ const lookup = (ref) => loadFeatureManifestSummary(ref, checkoutRoot);
2763
+ let text;
2764
+ const requested = opts.with ?? [];
2765
+ if (requested.length === 0) {
2766
+ text = generateDocumentedYml(opts.name, catalog, lookup);
2767
+ } else {
2768
+ const components = resolveComponents(catalog, requested);
2769
+ text = generateComposedYml(opts.name, components, lookup);
2770
+ }
2771
+ await fs10.mkdir(containerConfigsDir(home), { recursive: true });
2772
+ await fs10.writeFile(dest, text, "utf8");
2773
+ const documented = requested.length === 0;
2774
+ const rel = path10.relative(home, dest) || dest;
2775
+ if (documented) {
2776
+ logger.success(
2777
+ `Wrote documented default to ${rel}. Un-comment what you need, then \`monoceros apply ${opts.name}\`.`
2778
+ );
2779
+ } else {
2780
+ logger.success(
2781
+ `Composed ${requested.length} component(s) into ${rel}: ${requested.join(", ")}`
2782
+ );
2783
+ logger.info(
2784
+ `Edit the file if you need to tweak, then \`monoceros apply ${opts.name}\`.`
2785
+ );
2786
+ }
2787
+ return { configPath: dest, documented };
2788
+ }
2789
+
2790
+ // src/commands/init.ts
2791
+ var initCommand = defineCommand8({
2792
+ meta: {
2793
+ name: "init",
2794
+ description: "Create a fresh container-config yml at .local/container-configs/<name>.yml. Without --with, the file is a documented default with every component commented out. With --with=<names>, the named components are composed into an active, immediately-applyable yml. Then run `monoceros apply <name>`."
2795
+ },
2796
+ args: {
2797
+ name: {
2798
+ type: "positional",
2799
+ description: "Config name. The yml lands at <MONOCEROS_HOME>/container-configs/<name>.yml and becomes the source-of-truth for `monoceros apply <name>`.",
2800
+ required: true
2801
+ },
2802
+ with: {
2803
+ type: "string",
2804
+ description: "Comma-separated list of component names to compose, e.g. 'node,postgres,github,claude'. Sub-components use a slash, e.g. 'atlassian/twg'. When omitted, init writes a documented default with every catalog component commented out.",
2805
+ required: false
2806
+ }
2807
+ },
2808
+ async run({ args, rawArgs }) {
2809
+ try {
2810
+ const withList = collectWithList(args.with, rawArgs);
2811
+ await runInit({
2812
+ name: args.name,
2813
+ ...withList ? { with: withList } : {}
2814
+ });
2815
+ } catch (err) {
2816
+ consola13.error(err instanceof Error ? err.message : String(err));
2817
+ process.exit(1);
2818
+ }
2819
+ }
2820
+ });
2821
+ function collectWithList(withArg, rawArgs) {
2822
+ if (typeof withArg !== "string" || withArg.trim().length === 0) {
2823
+ return void 0;
2824
+ }
2825
+ let combined = withArg.trim();
2826
+ const startIdx = rawArgs.findIndex(
2827
+ (t) => t === "--with" || t.startsWith("--with=")
2828
+ );
2829
+ if (startIdx >= 0) {
2830
+ let scanFrom = startIdx + 1;
2831
+ if (rawArgs[startIdx] === "--with") scanFrom += 1;
2832
+ for (let i = scanFrom; i < rawArgs.length; i += 1) {
2833
+ const t = rawArgs[i];
2834
+ if (t.startsWith("--") || t === "-h" || t === "--help") break;
2835
+ const sep = combined.endsWith(",") ? "" : ",";
2836
+ combined += sep + t;
2837
+ }
2838
+ }
2839
+ const pieces = combined.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
2840
+ return pieces.length > 0 ? pieces : void 0;
2841
+ }
2842
+
2843
+ // src/commands/list-components.ts
2844
+ import { defineCommand as defineCommand9 } from "citty";
2845
+ import { consola as consola14 } from "consola";
2846
+ var listComponentsCommand = defineCommand9({
2847
+ meta: {
2848
+ name: "list-components",
2849
+ description: "Print the components catalog used by `monoceros init --with=\u2026`. Each line is `name<TAB>category<TAB>displayName`, grouped by category for readability."
2850
+ },
2851
+ args: {},
2852
+ async run() {
2853
+ try {
2854
+ const catalog = await loadComponentCatalog();
2855
+ if (catalog.size === 0) {
2856
+ consola14.warn(
2857
+ "No components found. The workbench checkout looks incomplete."
2858
+ );
2859
+ process.exit(0);
2860
+ }
2861
+ const sorted = [...catalog.values()].sort((a, b) => {
2862
+ const order = { language: 0, service: 1, feature: 2 };
2863
+ const ca = order[a.file.category];
2864
+ const cb = order[b.file.category];
2865
+ if (ca !== cb) return ca - cb;
2866
+ return a.name.localeCompare(b.name);
2867
+ });
2868
+ let currentCategory = null;
2869
+ for (const c of sorted) {
2870
+ if (c.file.category !== currentCategory) {
2871
+ if (currentCategory !== null) process.stdout.write("\n");
2872
+ process.stdout.write(`# ${c.file.category}
2873
+ `);
2874
+ currentCategory = c.file.category;
2875
+ }
2876
+ process.stdout.write(`${c.name} ${c.file.displayName}
2877
+ `);
2878
+ }
2879
+ process.exit(0);
2880
+ } catch (err) {
2881
+ consola14.error(err instanceof Error ? err.message : String(err));
2882
+ process.exit(1);
2883
+ }
2884
+ }
2885
+ });
2886
+
2887
+ // src/commands/logs.ts
2888
+ import { defineCommand as defineCommand10 } from "citty";
2889
+ var logsCommand = defineCommand10({
2890
+ meta: {
2891
+ name: "logs",
2892
+ description: "Tail logs from the compose services of the named dev-container. Pass --no-follow for a one-shot dump."
2893
+ },
2894
+ args: {
2895
+ name: {
2896
+ type: "positional",
2897
+ description: "Container name (yml in $MONOCEROS_HOME/container-configs/).",
2898
+ required: true
2899
+ },
2900
+ service: {
2901
+ type: "string",
2902
+ description: "Restrict to a single compose service (e.g. postgres). Defaults to all."
2903
+ },
2904
+ follow: {
2905
+ type: "boolean",
2906
+ description: "Follow log output (default: true). Use --no-follow to disable.",
2907
+ alias: ["f"],
2908
+ default: true
2909
+ }
2910
+ },
2911
+ run({ args }) {
2912
+ return dispatch(
2913
+ () => runLogs({
2914
+ root: containerDir(args.name),
2915
+ ...typeof args.service === "string" ? { service: args.service } : {},
2916
+ follow: args.follow
2917
+ })
2918
+ );
2919
+ }
2920
+ });
2921
+
2922
+ // src/commands/remove-apt-packages.ts
2923
+ import { defineCommand as defineCommand11 } from "citty";
2924
+ import { consola as consola15 } from "consola";
2925
+ var removeAptPackagesCommand = defineCommand11({
2926
+ meta: {
2927
+ name: "remove-apt-packages",
2928
+ description: "Remove apt packages from the container config. Pass package names after `--` (e.g. `monoceros remove-apt-packages sandbox -- make jq`). Idempotent, prints a diff before writing."
2929
+ },
2930
+ args: {
2931
+ name: {
2932
+ type: "positional",
2933
+ description: "Container name (yml in $MONOCEROS_HOME/container-configs/).",
2934
+ required: true
2935
+ },
2936
+ yes: {
2937
+ type: "boolean",
2938
+ description: "Skip the interactive confirmation and apply the diff.",
2939
+ alias: ["y"],
2940
+ default: false
2941
+ }
2942
+ },
2943
+ async run({ args }) {
2944
+ const packages = [...getInnerArgs()];
2945
+ if (packages.length === 0) {
2946
+ consola15.error(
2947
+ "No package names given. Usage: `monoceros remove-apt-packages <containername> [--yes] -- <pkg> [<pkg> \u2026]`."
2948
+ );
2949
+ process.exit(1);
2950
+ }
2951
+ try {
2952
+ const result = await runRemoveAptPackages({
2953
+ name: args.name,
2954
+ packages,
2955
+ yes: args.yes
2956
+ });
2957
+ process.exit(result.status === "aborted" ? 1 : 0);
2958
+ } catch (err) {
2959
+ consola15.error(err instanceof Error ? err.message : String(err));
2960
+ process.exit(1);
2961
+ }
2962
+ }
2963
+ });
2964
+
2965
+ // src/commands/remove-feature.ts
2966
+ import { defineCommand as defineCommand12 } from "citty";
2967
+ import { consola as consola16 } from "consola";
2968
+ var removeFeatureCommand = defineCommand12({
2969
+ meta: {
2970
+ name: "remove-feature",
2971
+ description: "Remove a devcontainer feature from the container config (by its OCI ref). Idempotent, prints a diff before writing."
2972
+ },
2973
+ args: {
2974
+ name: {
2975
+ type: "positional",
2976
+ description: "Container name (yml in $MONOCEROS_HOME/container-configs/).",
2977
+ required: true
2978
+ },
2979
+ ref: {
2980
+ type: "positional",
2981
+ description: "Feature ref (e.g. ghcr.io/devcontainers/features/docker-in-docker:2).",
2982
+ required: true
2983
+ },
2984
+ yes: {
2985
+ type: "boolean",
2986
+ description: "Skip the interactive confirmation and apply the diff.",
2987
+ alias: ["y"],
2988
+ default: false
2989
+ }
2990
+ },
2991
+ async run({ args }) {
2992
+ try {
2993
+ const result = await runRemoveFeature({
2994
+ name: args.name,
2995
+ ref: args.ref,
2996
+ yes: args.yes
2997
+ });
2998
+ process.exit(result.status === "aborted" ? 1 : 0);
2999
+ } catch (err) {
3000
+ consola16.error(err instanceof Error ? err.message : String(err));
3001
+ process.exit(1);
3002
+ }
3003
+ }
3004
+ });
3005
+
3006
+ // src/commands/remove.ts
3007
+ import { defineCommand as defineCommand13 } from "citty";
3008
+ import { consola as consola18 } from "consola";
3009
+ import { createInterface } from "readline/promises";
3010
+
3011
+ // src/remove/index.ts
3012
+ import { existsSync as existsSync8, promises as fs11 } from "fs";
3013
+ import path11 from "path";
3014
+ import { consola as consola17 } from "consola";
3015
+ async function runRemove(opts) {
3016
+ const home = opts.monocerosHome ?? monocerosHome();
3017
+ const logger = opts.logger ?? {
3018
+ info: (msg) => consola17.info(msg),
3019
+ success: (msg) => consola17.success(msg),
3020
+ warn: (msg) => consola17.warn(msg)
3021
+ };
3022
+ if (!REGEX.solutionName.test(opts.name)) {
3023
+ throw new Error(
3024
+ `Invalid config name: ${JSON.stringify(opts.name)}. Use letters, digits, '.', '_' or '-'.`
3025
+ );
3026
+ }
3027
+ const ymlPath = containerConfigPath(opts.name, home);
3028
+ const containerPath = containerDir(opts.name, home);
3029
+ const hasYml = existsSync8(ymlPath);
3030
+ const hasContainer = existsSync8(containerPath);
3031
+ if (!hasYml && !hasContainer) {
3032
+ throw new Error(
3033
+ `Nothing to remove for '${opts.name}': neither ${ymlPath} nor ${containerPath} exists.`
3034
+ );
3035
+ }
3036
+ const projectName = composeProjectName(containerPath);
3037
+ const dockerSpawn = opts.dockerSpawn ?? spawnBash;
3038
+ const script = [
3039
+ `set -u`,
3040
+ `echo "[remove] tearing down docker project ${projectName}\u2026"`,
3041
+ // Compose-mode containers, identified by the project label
3042
+ `by_label=$(docker ps -aq --filter "label=com.docker.compose.project=${projectName}" 2>/dev/null || true)`,
3043
+ // Container-name prefix fallback (catches half-broken state)
3044
+ `by_compose_name=$(docker ps -aq --filter "name=^${projectName}-" 2>/dev/null || true)`,
3045
+ // Image-mode devcontainer-cli container
3046
+ `by_image_name=$(docker ps -aq --filter "name=^vsc-${opts.name}-" 2>/dev/null || true)`,
3047
+ `to_remove=$(printf "%s\\n%s\\n%s\\n" "$by_label" "$by_compose_name" "$by_image_name" | sort -u | grep -v "^$" || true)`,
3048
+ `if [ -n "$to_remove" ]; then echo "[remove] removing containers: $(echo $to_remove | tr "\\n" " ")"; docker rm -f $to_remove >/dev/null || true; else echo "[remove] no containers found"; fi`,
3049
+ `docker network rm ${projectName}_default 2>/dev/null && echo "[remove] network ${projectName}_default removed" || true`,
3050
+ `echo "[remove] docker cleanup done"`
3051
+ ].join("; ");
3052
+ const dockerExitCode = await dockerSpawn(["-c", script], home);
3053
+ let backupPath = null;
3054
+ if (!opts.noBackup && (hasYml || hasContainer)) {
3055
+ const ts = (opts.now ?? /* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
3056
+ backupPath = path11.join(home, "container-backups", `${opts.name}-${ts}`);
3057
+ await fs11.mkdir(backupPath, { recursive: true });
3058
+ if (hasYml) {
3059
+ await fs11.copyFile(ymlPath, path11.join(backupPath, `${opts.name}.yml`));
3060
+ }
3061
+ if (hasContainer) {
3062
+ await fs11.cp(containerPath, path11.join(backupPath, "container"), {
3063
+ recursive: true
3064
+ });
3065
+ }
3066
+ logger.info(
3067
+ `Backup written to ${path11.relative(home, backupPath) || backupPath}.`
3068
+ );
3069
+ }
3070
+ if (hasYml) {
3071
+ await fs11.rm(ymlPath, { force: true });
3072
+ }
3073
+ if (hasContainer) {
3074
+ await fs11.rm(containerPath, { recursive: true, force: true });
3075
+ }
3076
+ logger.success(
3077
+ `Removed '${opts.name}': docker objects gone, container-configs entry deleted, container directory deleted.`
3078
+ );
3079
+ if (!backupPath) {
3080
+ logger.warn?.(
3081
+ "No backup created (--no-backup). The host-side state is gone for good."
3082
+ );
3083
+ }
3084
+ return {
3085
+ configPath: hasYml ? ymlPath : null,
3086
+ containerPath: hasContainer ? containerPath : null,
3087
+ backupPath,
3088
+ dockerExitCode
3089
+ };
3090
+ }
3091
+
3092
+ // src/commands/remove.ts
3093
+ var removeCommand = defineCommand13({
3094
+ meta: {
3095
+ name: "remove",
3096
+ description: "Wipe everything belonging to a container: stop and remove the docker objects, back up the container-configs yml + container directory (incl. home/, projects/, data/), then delete them from disk. Shared docker images stay. By default the destructive step is confirmed interactively; pass -y to skip."
3097
+ },
3098
+ args: {
3099
+ name: {
3100
+ type: "positional",
3101
+ description: "Container name (yml in $MONOCEROS_HOME/container-configs/).",
3102
+ required: true
3103
+ },
3104
+ backup: {
3105
+ type: "boolean",
3106
+ // citty turns a default-true boolean automatically into a
3107
+ // `--no-X` flag for negation, so the builder gets the natural
3108
+ // `monoceros remove <name> --no-backup` form without us
3109
+ // needing to special-case the parsing. Defining the arg as
3110
+ // `no-backup` directly conflicts with citty's prefix logic
3111
+ // and silently fails to bind, so we always go through the
3112
+ // positive form.
3113
+ description: "Write a backup of <container-dir> and the yml under container-backups/ before deleting. Default on; use `--no-backup` to skip.",
3114
+ default: true
3115
+ },
3116
+ yes: {
3117
+ type: "boolean",
3118
+ alias: "y",
3119
+ description: "Skip the interactive confirmation prompt. Useful in scripts.",
3120
+ default: false
3121
+ }
3122
+ },
3123
+ async run({ args }) {
3124
+ try {
3125
+ const noBackup = args.backup === false;
3126
+ const skipPrompt = args.yes === true;
3127
+ if (!skipPrompt) {
3128
+ const warning = noBackup ? `About to remove '${args.name}' WITHOUT a backup. Docker objects, container-configs entry, and container directory will all be deleted.` : `About to remove '${args.name}'. A backup will be written to container-backups/ first, then docker objects, container-configs entry, and container directory will all be deleted.`;
3129
+ consola18.warn(warning);
3130
+ const rl = createInterface({
3131
+ input: process.stdin,
3132
+ output: process.stdout
3133
+ });
3134
+ const answer = await rl.question("Continue? [y/N] ");
3135
+ rl.close();
3136
+ if (!/^y(es)?$/i.test(answer.trim())) {
3137
+ consola18.info("Aborted. Nothing changed.");
3138
+ process.exit(0);
3139
+ }
3140
+ }
3141
+ await runRemove({
3142
+ name: args.name,
3143
+ ...noBackup ? { noBackup: true } : {}
3144
+ });
3145
+ } catch (err) {
3146
+ consola18.error(err instanceof Error ? err.message : String(err));
3147
+ process.exit(1);
3148
+ }
3149
+ }
3150
+ });
3151
+
3152
+ // src/commands/restore.ts
3153
+ import { defineCommand as defineCommand14 } from "citty";
3154
+ import { consola as consola20 } from "consola";
3155
+
3156
+ // src/restore/index.ts
3157
+ import { existsSync as existsSync9, promises as fs12 } from "fs";
3158
+ import path12 from "path";
3159
+ import { consola as consola19 } from "consola";
3160
+ async function runRestore(opts) {
3161
+ const home = opts.monocerosHome ?? monocerosHome();
3162
+ const logger = opts.logger ?? {
3163
+ info: (msg) => consola19.info(msg),
3164
+ success: (msg) => consola19.success(msg)
3165
+ };
3166
+ const backup = path12.resolve(opts.backupPath);
3167
+ if (!existsSync9(backup)) {
3168
+ throw new Error(`Backup not found: ${backup}.`);
3169
+ }
3170
+ const stat = await fs12.stat(backup);
3171
+ if (!stat.isDirectory()) {
3172
+ throw new Error(`Backup path is not a directory: ${backup}.`);
3173
+ }
3174
+ const entries = await fs12.readdir(backup);
3175
+ const ymlFiles = entries.filter((f) => f.endsWith(".yml"));
3176
+ if (ymlFiles.length === 0) {
3177
+ throw new Error(
3178
+ `Backup at ${backup} doesn't contain a *.yml \u2014 expected a single config file at the root.`
3179
+ );
3180
+ }
3181
+ if (ymlFiles.length > 1) {
3182
+ throw new Error(
3183
+ `Backup at ${backup} contains multiple .yml files (${ymlFiles.join(", ")}). Expected exactly one.`
3184
+ );
3185
+ }
3186
+ const ymlFile = ymlFiles[0];
3187
+ const name = ymlFile.replace(/\.yml$/, "");
3188
+ const containerInBackup = path12.join(backup, "container");
3189
+ const hasContainer = existsSync9(containerInBackup);
3190
+ const destYml = containerConfigPath(name, home);
3191
+ const destContainer = containerDir(name, home);
3192
+ if (existsSync9(destYml)) {
3193
+ throw new Error(
3194
+ `Refusing to restore: ${destYml} already exists. Remove the current container first (\`monoceros remove ${name}\`) or rename the existing config.`
3195
+ );
3196
+ }
3197
+ if (hasContainer && existsSync9(destContainer)) {
3198
+ throw new Error(
3199
+ `Refusing to restore: ${destContainer} already exists. Remove the current container first (\`monoceros remove ${name}\`).`
3200
+ );
3201
+ }
3202
+ await fs12.mkdir(containerConfigsDir(home), { recursive: true });
3203
+ await fs12.copyFile(path12.join(backup, ymlFile), destYml);
3204
+ if (hasContainer) {
3205
+ await fs12.cp(containerInBackup, destContainer, { recursive: true });
3206
+ }
3207
+ logger.success(
3208
+ `Restored '${name}' from ${path12.relative(home, backup) || backup}.`
3209
+ );
3210
+ logger.info(
3211
+ `Run \`monoceros apply ${name}\` to bring the container back up.`
3212
+ );
3213
+ return {
3214
+ name,
3215
+ configPath: destYml,
3216
+ containerPath: hasContainer ? destContainer : null
3217
+ };
3218
+ }
3219
+
3220
+ // src/commands/restore.ts
3221
+ var restoreCommand = defineCommand14({
3222
+ meta: {
3223
+ name: "restore",
3224
+ description: "Restore a container's host-side state from a backup written by `monoceros remove`. Copies the yml and the container directory back into $MONOCEROS_HOME. Refuses to overwrite an existing config or container \u2014 remove the in-place container first if you need to clobber. Run `monoceros apply <name>` afterwards to bring it back up."
3225
+ },
3226
+ args: {
3227
+ "backup-path": {
3228
+ type: "positional",
3229
+ description: "Path to a backup directory (typically `<MONOCEROS_HOME>/container-backups/<name>-<timestamp>/`).",
3230
+ required: true
3231
+ }
3232
+ },
3233
+ async run({ args }) {
3234
+ try {
3235
+ await runRestore({ backupPath: args["backup-path"] });
3236
+ } catch (err) {
3237
+ consola20.error(err instanceof Error ? err.message : String(err));
3238
+ process.exit(1);
3239
+ }
3240
+ }
3241
+ });
3242
+
3243
+ // src/commands/remove-from-url.ts
3244
+ import { defineCommand as defineCommand15 } from "citty";
3245
+ import { consola as consola21 } from "consola";
3246
+ var removeFromUrlCommand = defineCommand15({
3247
+ meta: {
3248
+ name: "remove-from-url",
3249
+ description: "Remove a previously-added install URL from the container config. Idempotent, prints a diff before writing. The URL is dropped from post-create.sh on the next `monoceros apply`."
3250
+ },
3251
+ args: {
3252
+ name: {
3253
+ type: "positional",
3254
+ description: "Container name (yml in $MONOCEROS_HOME/container-configs/).",
3255
+ required: true
3256
+ },
3257
+ url: {
3258
+ type: "positional",
3259
+ description: "Install URL to remove (must match the original exactly).",
3260
+ required: true
3261
+ },
3262
+ yes: {
3263
+ type: "boolean",
3264
+ description: "Skip the interactive confirmation and apply the diff.",
3265
+ alias: ["y"],
3266
+ default: false
3267
+ }
3268
+ },
3269
+ async run({ args }) {
3270
+ try {
3271
+ const result = await runRemoveFromUrl({
3272
+ name: args.name,
3273
+ url: args.url,
3274
+ yes: args.yes
3275
+ });
3276
+ process.exit(result.status === "aborted" ? 1 : 0);
3277
+ } catch (err) {
3278
+ consola21.error(err instanceof Error ? err.message : String(err));
3279
+ process.exit(1);
3280
+ }
3281
+ }
3282
+ });
3283
+
3284
+ // src/commands/remove-language.ts
3285
+ import { defineCommand as defineCommand16 } from "citty";
3286
+ import { consola as consola22 } from "consola";
3287
+ var removeLanguageCommand = defineCommand16({
3288
+ meta: {
3289
+ name: "remove-language",
3290
+ description: "Remove a language toolchain from the container config. Idempotent, prints a diff before writing."
3291
+ },
3292
+ args: {
3293
+ name: {
3294
+ type: "positional",
3295
+ description: "Container name (yml in $MONOCEROS_HOME/container-configs/).",
3296
+ required: true
3297
+ },
3298
+ language: {
3299
+ type: "positional",
3300
+ description: "Language identifier (e.g. python, java, rust).",
3301
+ required: true
3302
+ },
3303
+ yes: {
3304
+ type: "boolean",
3305
+ description: "Skip the interactive confirmation and apply the diff.",
3306
+ alias: ["y"],
3307
+ default: false
3308
+ }
3309
+ },
3310
+ async run({ args }) {
3311
+ try {
3312
+ const result = await runRemoveLanguage({
3313
+ name: args.name,
3314
+ language: args.language,
3315
+ yes: args.yes
3316
+ });
3317
+ process.exit(result.status === "aborted" ? 1 : 0);
3318
+ } catch (err) {
3319
+ consola22.error(err instanceof Error ? err.message : String(err));
3320
+ process.exit(1);
3321
+ }
3322
+ }
3323
+ });
3324
+
3325
+ // src/commands/remove-repo.ts
3326
+ import { defineCommand as defineCommand17 } from "citty";
3327
+ import { consola as consola23 } from "consola";
3328
+ var removeRepoCommand = defineCommand17({
3329
+ meta: {
3330
+ name: "remove-repo",
3331
+ description: "Remove a repo from the container config (matches by URL or by its projects/<folder> name). Does NOT delete the existing projects/<folder> directory \u2014 local edits are preserved; clean it up manually."
3332
+ },
3333
+ args: {
3334
+ name: {
3335
+ type: "positional",
3336
+ description: "Container name (yml in $MONOCEROS_HOME/container-configs/).",
3337
+ required: true
3338
+ },
3339
+ target: {
3340
+ type: "positional",
3341
+ description: "Repo URL or its projects/<folder> name. Either works.",
3342
+ required: true
3343
+ },
3344
+ yes: {
3345
+ type: "boolean",
3346
+ description: "Skip the interactive confirmation and apply the diff.",
3347
+ alias: ["y"],
3348
+ default: false
3349
+ }
3350
+ },
3351
+ async run({ args }) {
3352
+ try {
3353
+ const result = await runRemoveRepo({
3354
+ name: args.name,
3355
+ target: args.target,
3356
+ yes: args.yes
3357
+ });
3358
+ process.exit(result.status === "aborted" ? 1 : 0);
3359
+ } catch (err) {
3360
+ consola23.error(err instanceof Error ? err.message : String(err));
3361
+ process.exit(1);
3362
+ }
3363
+ }
3364
+ });
3365
+
3366
+ // src/commands/remove-service.ts
3367
+ import { defineCommand as defineCommand18 } from "citty";
3368
+ import { consola as consola24 } from "consola";
3369
+ var removeServiceCommand = defineCommand18({
3370
+ meta: {
3371
+ name: "remove-service",
3372
+ description: "Remove a compose service from the container config. Idempotent, prints a diff before writing. Note: data volumes (e.g. postgres-data) are NOT cleaned up automatically."
3373
+ },
3374
+ args: {
3375
+ name: {
3376
+ type: "positional",
3377
+ description: "Container name (yml in $MONOCEROS_HOME/container-configs/).",
3378
+ required: true
3379
+ },
3380
+ service: {
3381
+ type: "positional",
3382
+ description: "Service identifier (e.g. postgres, redis).",
3383
+ required: true
3384
+ },
3385
+ yes: {
3386
+ type: "boolean",
3387
+ description: "Skip the interactive confirmation and apply the diff.",
3388
+ alias: ["y"],
3389
+ default: false
3390
+ }
3391
+ },
3392
+ async run({ args }) {
3393
+ try {
3394
+ const result = await runRemoveService({
3395
+ name: args.name,
3396
+ service: args.service,
3397
+ yes: args.yes
3398
+ });
3399
+ process.exit(result.status === "aborted" ? 1 : 0);
3400
+ } catch (err) {
3401
+ consola24.error(err instanceof Error ? err.message : String(err));
3402
+ process.exit(1);
3403
+ }
3404
+ }
3405
+ });
3406
+
3407
+ // src/commands/run.ts
3408
+ import { defineCommand as defineCommand19 } from "citty";
3409
+ import { consola as consola25 } from "consola";
3410
+
3411
+ // src/devcontainer/shell.ts
3412
+ import { existsSync as existsSync10 } from "fs";
3413
+ import path13 from "path";
3414
+ async function runShell(opts) {
3415
+ assertContainerExists(opts.root);
3416
+ const spawnFn = opts.spawn ?? spawnDevcontainer;
3417
+ const upCode = await spawnFn(
3418
+ ["up", "--workspace-folder", opts.root],
3419
+ opts.root,
3420
+ { quiet: true }
3421
+ );
3422
+ if (upCode !== 0) return upCode;
3423
+ return spawnFn(["exec", "--workspace-folder", opts.root, "bash"], opts.root, {
3424
+ interactive: true
3425
+ });
3426
+ }
3427
+ function assertContainerExists(root) {
3428
+ if (!existsSync10(path13.join(root, ".devcontainer"))) {
3429
+ throw new Error(
3430
+ `No .devcontainer/ at ${root}. Run \`monoceros apply <name>\` first.`
3431
+ );
3432
+ }
3433
+ }
3434
+
3435
+ // src/devcontainer/run.ts
3436
+ async function runInContainer(opts) {
3437
+ if (opts.command.length === 0) {
3438
+ throw new Error(
3439
+ "No command provided. Usage: `monoceros run <containername> -- <cmd> [args\u2026]`."
3440
+ );
3441
+ }
3442
+ assertContainerExists(opts.root);
3443
+ const spawnFn = opts.spawn ?? spawnDevcontainer;
3444
+ const upCode = await spawnFn(
3445
+ ["up", "--workspace-folder", opts.root],
3446
+ opts.root,
3447
+ { quiet: true }
3448
+ );
3449
+ if (upCode !== 0) return upCode;
3450
+ return spawnFn(
3451
+ ["exec", "--workspace-folder", opts.root, ...opts.command],
3452
+ opts.root,
3453
+ { interactive: true }
3454
+ );
3455
+ }
3456
+
3457
+ // src/commands/run.ts
3458
+ var runCommand = defineCommand19({
3459
+ meta: {
3460
+ name: "run",
3461
+ description: "Run a one-off command inside the named dev-container. Use `--` to separate monoceros flags from the inner command."
3462
+ },
3463
+ args: {
3464
+ name: {
3465
+ type: "positional",
3466
+ description: "Container name (yml in $MONOCEROS_HOME/container-configs/).",
3467
+ required: true
3468
+ }
3469
+ },
3470
+ async run({ args }) {
3471
+ const command = [...getInnerArgs()];
3472
+ if (command.length === 0) {
3473
+ consola25.error(
3474
+ "No command provided. Usage: `monoceros run <containername> -- <cmd> [args\u2026]`."
3475
+ );
3476
+ process.exit(1);
3477
+ }
3478
+ try {
3479
+ const exitCode = await runInContainer({
3480
+ root: containerDir(args.name),
3481
+ command
3482
+ });
3483
+ process.exit(exitCode);
3484
+ } catch (err) {
3485
+ consola25.error(err instanceof Error ? err.message : String(err));
3486
+ process.exit(1);
3487
+ }
3488
+ }
3489
+ });
3490
+
3491
+ // src/commands/shell.ts
3492
+ import { defineCommand as defineCommand20 } from "citty";
3493
+ import { consola as consola26 } from "consola";
3494
+ var shellCommand = defineCommand20({
3495
+ meta: {
3496
+ name: "shell",
3497
+ description: "Open an interactive bash session inside the named dev-container."
3498
+ },
3499
+ args: {
3500
+ name: {
3501
+ type: "positional",
3502
+ description: "Container name (yml in $MONOCEROS_HOME/container-configs/).",
3503
+ required: true
3504
+ }
3505
+ },
3506
+ async run({ args }) {
3507
+ try {
3508
+ const exitCode = await runShell({ root: containerDir(args.name) });
3509
+ process.exit(exitCode);
3510
+ } catch (err) {
3511
+ consola26.error(err instanceof Error ? err.message : String(err));
3512
+ process.exit(1);
3513
+ }
3514
+ }
3515
+ });
3516
+
3517
+ // src/commands/start.ts
3518
+ import { defineCommand as defineCommand21 } from "citty";
3519
+ var startCommand = defineCommand21({
3520
+ meta: {
3521
+ name: "start",
3522
+ description: "Bring the named dev-container up via `devcontainer up` (workspace + runServices, postCreate, features)."
3523
+ },
3524
+ args: {
3525
+ name: {
3526
+ type: "positional",
3527
+ description: "Container name (yml in $MONOCEROS_HOME/container-configs/).",
3528
+ required: true
3529
+ }
3530
+ },
3531
+ run({ args }) {
3532
+ return dispatch(() => runStart({ root: containerDir(args.name) }));
3533
+ }
3534
+ });
3535
+
3536
+ // src/commands/status.ts
3537
+ import { defineCommand as defineCommand22 } from "citty";
3538
+ var statusCommand = defineCommand22({
3539
+ meta: {
3540
+ name: "status",
3541
+ description: "Show whether the compose services for the named dev-container are running."
3542
+ },
3543
+ args: {
3544
+ name: {
3545
+ type: "positional",
3546
+ description: "Container name (yml in $MONOCEROS_HOME/container-configs/).",
3547
+ required: true
3548
+ },
3549
+ service: {
3550
+ type: "string",
3551
+ description: "Restrict to a single compose service (e.g. postgres). Defaults to all."
3552
+ }
3553
+ },
3554
+ run({ args }) {
3555
+ return dispatch(
3556
+ () => runStatus({
3557
+ root: containerDir(args.name),
3558
+ ...typeof args.service === "string" ? { service: args.service } : {}
3559
+ })
3560
+ );
3561
+ }
3562
+ });
3563
+
3564
+ // src/commands/stop.ts
3565
+ import { defineCommand as defineCommand23 } from "citty";
3566
+ var stopCommand = defineCommand23({
3567
+ meta: {
3568
+ name: "stop",
3569
+ description: "Stop the compose services for the named dev-container. Volumes are preserved."
3570
+ },
3571
+ args: {
3572
+ name: {
3573
+ type: "positional",
3574
+ description: "Container name (yml in $MONOCEROS_HOME/container-configs/).",
3575
+ required: true
3576
+ },
3577
+ service: {
3578
+ type: "string",
3579
+ description: "Restrict to a single compose service (e.g. postgres). Defaults to all."
3580
+ }
3581
+ },
3582
+ run({ args }) {
3583
+ return dispatch(
3584
+ () => runStop({
3585
+ root: containerDir(args.name),
3586
+ ...typeof args.service === "string" ? { service: args.service } : {}
3587
+ })
3588
+ );
3589
+ }
3590
+ });
3591
+
3592
+ // src/main.ts
3593
+ var main = defineCommand24({
3594
+ meta: {
3595
+ name: "monoceros",
3596
+ version: CLI_VERSION,
3597
+ description: "Monoceros workbench \u2014 local, sandboxed AI-coding environment for solution builders."
3598
+ },
3599
+ subCommands: {
3600
+ init: initCommand,
3601
+ "list-components": listComponentsCommand,
3602
+ shell: shellCommand,
3603
+ run: runCommand,
3604
+ logs: logsCommand,
3605
+ start: startCommand,
3606
+ stop: stopCommand,
3607
+ status: statusCommand,
3608
+ apply: applyCommand,
3609
+ remove: removeCommand,
3610
+ restore: restoreCommand,
3611
+ "add-service": addServiceCommand,
3612
+ "add-language": addLanguageCommand,
3613
+ "add-apt-packages": addAptPackagesCommand,
3614
+ "add-feature": addFeatureCommand,
3615
+ "add-from-url": addFromUrlCommand,
3616
+ "add-repo": addRepoCommand,
3617
+ "remove-service": removeServiceCommand,
3618
+ "remove-language": removeLanguageCommand,
3619
+ "remove-apt-packages": removeAptPackagesCommand,
3620
+ "remove-feature": removeFeatureCommand,
3621
+ "remove-from-url": removeFromUrlCommand,
3622
+ "remove-repo": removeRepoCommand
3623
+ }
3624
+ });
3625
+
3626
+ // src/bin.ts
3627
+ consumeInnerArgsFromProcessArgv();
3628
+ async function entry() {
3629
+ if (await maybeRenderHelp(process.argv.slice(2), main)) {
3630
+ return;
3631
+ }
3632
+ await runMain(main);
3633
+ }
3634
+ entry().catch((err) => {
3635
+ console.error(
3636
+ err instanceof Error ? err.stack ?? err.message : String(err)
3637
+ );
3638
+ process.exit(1);
3639
+ });
3640
+ //# sourceMappingURL=bin.js.map