@gamecrate/cli 0.1.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.
@@ -0,0 +1,4365 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { existsSync as existsSync7 } from "node:fs";
5
+ import { chown, cp, mkdir as mkdir4, readdir as readdir8, readFile as readFile5, rm as rm2, rmdir, stat as stat4, writeFile as writeFile4 } from "node:fs/promises";
6
+ import { homedir as homedir5 } from "node:os";
7
+ import { basename as basename7, dirname as dirname5, join as join14 } from "node:path";
8
+ import { setTimeout as sleep4 } from "node:timers/promises";
9
+
10
+ // src/cli/args.ts
11
+ import { Command, CommanderError, Option } from "commander";
12
+
13
+ // src/types.ts
14
+ var Exit = {
15
+ Ok: 0,
16
+ GameFailed: 1,
17
+ Usage: 2,
18
+ Config: 3,
19
+ Resolution: 4,
20
+ Environment: 5,
21
+ MarkerTimeout: 6,
22
+ Refused: 7,
23
+ Stale: 8,
24
+ Interrupted: 130
25
+ };
26
+
27
+ class GamecrateError extends Error {
28
+ code;
29
+ detail;
30
+ constructor(message, code, detail) {
31
+ super(message);
32
+ this.code = code;
33
+ this.detail = detail;
34
+ this.name = "GamecrateError";
35
+ }
36
+ }
37
+ var RESERVED_NAMES = [
38
+ "run",
39
+ "list",
40
+ "mods",
41
+ "doctor",
42
+ "clean",
43
+ "clone",
44
+ "logs",
45
+ "build",
46
+ "shell",
47
+ "config",
48
+ "fix-perms",
49
+ "verify",
50
+ "help",
51
+ "version",
52
+ "modless"
53
+ ];
54
+ var NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
55
+ function own(bag, key) {
56
+ return bag !== undefined && Object.hasOwn(bag, key) ? bag[key] : undefined;
57
+ }
58
+
59
+ // src/cli/args.ts
60
+ var RUN_FLAGS = [
61
+ "--mod",
62
+ "--without",
63
+ "--only",
64
+ "--mode",
65
+ "--marker",
66
+ "--timeout",
67
+ "--render-wait",
68
+ "--resolution",
69
+ "--network",
70
+ "--log",
71
+ "--pull",
72
+ "--build",
73
+ "--no-build",
74
+ "--no-stale-check",
75
+ "--replace",
76
+ "--no-replace",
77
+ "--sort",
78
+ "--docker-arg",
79
+ "--dry-run",
80
+ "--print-plan",
81
+ "--root",
82
+ "--worktree",
83
+ "--no-worktree",
84
+ "--instance",
85
+ "--use"
86
+ ];
87
+ var SUBCOMMANDS = [
88
+ {
89
+ name: "run",
90
+ summary: "resolve, stage, launch (implied when the first word is a game)",
91
+ usage: "<game> [profile]",
92
+ positionals: ["game", "profile", "rest"],
93
+ flags: RUN_FLAGS
94
+ },
95
+ {
96
+ name: "list",
97
+ summary: "games, profiles, and each profile's provenance",
98
+ usage: "[game]",
99
+ positionals: ["game"],
100
+ flags: []
101
+ },
102
+ {
103
+ name: "mods",
104
+ summary: "resolved mod set with source kind and absolute path",
105
+ usage: "<game> [profile]",
106
+ positionals: ["game", "profile"],
107
+ flags: ["--mod", "--without", "--only", "--sort"]
108
+ },
109
+ {
110
+ name: "doctor",
111
+ summary: "preflight: docker, CDI, registry auth, game dirs, scan roots, perms",
112
+ usage: "",
113
+ positionals: [],
114
+ flags: []
115
+ },
116
+ {
117
+ name: "clean",
118
+ summary: "tiered wipe of a profile",
119
+ usage: "<game> <profile>",
120
+ positionals: ["game", "profile"],
121
+ flags: ["--staging", "--logs", "--all", "--yes", "--instance", "--worktree", "--no-worktree"]
122
+ },
123
+ {
124
+ name: "clone",
125
+ summary: "reflink-copy a profile's precious tier",
126
+ usage: "<game> <src> <dst>",
127
+ positionals: ["game", "rest"],
128
+ flags: ["--yes"]
129
+ },
130
+ {
131
+ name: "logs",
132
+ summary: "tail or open the last run's captured logs",
133
+ usage: "<game> <profile>",
134
+ positionals: ["game", "profile"],
135
+ flags: ["--instance", "--worktree", "--no-worktree"]
136
+ },
137
+ {
138
+ name: "build",
139
+ summary: "build or pull the runtime image, no launch",
140
+ usage: "<game>",
141
+ positionals: ["game"],
142
+ flags: ["--pull"]
143
+ },
144
+ {
145
+ name: "shell",
146
+ summary: "same mounts, bash instead of the game",
147
+ usage: "<game> [profile]",
148
+ positionals: ["game", "profile"],
149
+ flags: [
150
+ "--mod",
151
+ "--without",
152
+ "--only",
153
+ "--docker-arg",
154
+ "--root",
155
+ "--worktree",
156
+ "--no-worktree",
157
+ "--instance",
158
+ "--use",
159
+ "--replace",
160
+ "--log"
161
+ ]
162
+ },
163
+ {
164
+ name: "verify",
165
+ summary: "what the running container actually bound, and whether it looks current",
166
+ usage: "<game> [profile]",
167
+ positionals: ["game", "profile"],
168
+ flags: ["--instance", "--worktree", "--no-worktree"]
169
+ },
170
+ {
171
+ name: "config",
172
+ summary: "open profiles.json in $EDITOR, validate on save",
173
+ usage: "edit",
174
+ positionals: ["rest"],
175
+ flags: []
176
+ },
177
+ {
178
+ name: "fix-perms",
179
+ summary: "chown foreign-owned files back to the caller",
180
+ usage: "<game> [profile]",
181
+ positionals: ["game", "profile"],
182
+ flags: ["--yes", "--dry-run"]
183
+ },
184
+ {
185
+ name: "help",
186
+ summary: "help for a subcommand or a game",
187
+ usage: "[topic]",
188
+ positionals: ["rest"],
189
+ flags: []
190
+ },
191
+ {
192
+ name: "version",
193
+ summary: "print the version",
194
+ usage: "",
195
+ positionals: [],
196
+ flags: []
197
+ }
198
+ ];
199
+ var GLOBAL_FLAGS = ["--json", "--help"];
200
+ var MODES = ["headed", "headless", "screenshot"];
201
+ var PULL_POLICIES = ["always", "missing", "never"];
202
+ var NETWORK_POLICIES = ["none", "bridge", "host"];
203
+ var BUILD_POLICIES = ["auto", "always", "never"];
204
+ var SORTS = ["topo", "none"];
205
+ var FLAG_ENV = {
206
+ "--instance": "GAMECRATE_INSTANCE",
207
+ "--mode": "GAMECRATE_MODE",
208
+ "--marker": "GAMECRATE_MARKER",
209
+ "--timeout": "GAMECRATE_TIMEOUT",
210
+ "--render-wait": "GAMECRATE_RENDER_WAIT",
211
+ "--network": "GAMECRATE_NETWORK",
212
+ "--pull": "GAMECRATE_PULL",
213
+ "--build": "GAMECRATE_BUILD",
214
+ "--sort": "GAMECRATE_SORT",
215
+ "--root": "GAMECRATE_ROOT"
216
+ };
217
+ function collect(value, previous) {
218
+ return [...previous, value];
219
+ }
220
+ function choice(flag, values) {
221
+ return (raw) => {
222
+ if (!values.includes(raw)) {
223
+ throw usage(`${flag} must be one of ${values.join(", ")}, got ${raw}`);
224
+ }
225
+ return raw;
226
+ };
227
+ }
228
+ function enumOption(flags, summary, values) {
229
+ const long = flags.split(/[ ,]+/).find((token) => token.startsWith("--"));
230
+ return new Option(flags, summary).choices([...values]).argParser(choice(long, values));
231
+ }
232
+ function buildProgram() {
233
+ const program = new Command;
234
+ program.name("gamecrate").exitOverride().helpOption(false).allowExcessArguments(true).showSuggestionAfterError(false).configureOutput({ writeOut: () => {}, writeErr: () => {} }).argument("[args...]").option("--mod <id>", "add a mod to the profile set", collect, []).option("--without <id>", "drop a mod from the resolved set", collect, []).option("--only <id>", "restrict the resolved set to these mods", collect, []).option("--worktree <path>", "promote mods from this git worktree, in its own instance ($GAMECRATE_WORKTREE)", collect, []).option("--use <packageId>=<path>", "force one mod to load from this directory, whatever the profile pins", collect, []).option("--no-worktree", "ignore the current worktree and $GAMECRATE_WORKTREE").option("--instance <name>", "run under a named sub-profile with its own saves, logs and container").addOption(enumOption(`--mode <${MODES.join("|")}>`, "how the game is displayed", MODES)).option("--marker <str>", "exit 0 as soon as this string appears in the log").option("--timeout <seconds>", "kill the container after this long", (v) => seconds("--timeout", v)).option("--render-wait <seconds>", "settle time before a screenshot is taken", (v) => seconds("--render-wait", v)).option("--resolution <width>x<height>", "override the game resolution", parseResolution).addOption(enumOption(`--network <${NETWORK_POLICIES.join("|")}>`, "the container's network mode", NETWORK_POLICIES)).option("--log <path>", "route launch stdout and stderr to one file").addOption(enumOption(`--pull <${PULL_POLICIES.join("|")}>`, "when to pull the runtime image", PULL_POLICIES)).option("--build", "build local C# mods before launching").option("--no-build", "never build, even when an assembly is stale").option("--no-stale-check", "do not warn when a mod's sources are newer than its assemblies").option("--replace", "stop whatever is holding this profile and instance, then launch").option("--no-replace", "refuse when this profile and instance are already running").addOption(enumOption(`--sort <${SORTS.join("|")}>`, "load order: the profile order, or a topological sort", SORTS)).option("--docker-arg <arg>", "one extra argv element for docker run", collect, []).option("--dry-run", "resolve and validate fully, write nothing").option("--print-plan", "print the resolved launch plan instead of launching").option("--json", "machine-readable output").option("--root", "run as root instead of mapping the host uid").option("--staging", "clean: wipe .stage only (the default)").option("--logs", "clean: wipe the captured run logs").option("--all", "clean: wipe the whole profile, saves included (needs --yes)").option("-y, --yes", "skip destructive-action confirmation").option("-h, --help", "this help");
235
+ return program;
236
+ }
237
+ function checkValueTokens(program, head) {
238
+ const valued = (name) => program.options.find((o) => (o.long === name || o.short === name) && (o.required || o.optional));
239
+ for (let i = 0;i < head.length; i++) {
240
+ const token = head[i];
241
+ if (!token.startsWith("-") || token === "-")
242
+ continue;
243
+ const eq = token.indexOf("=");
244
+ if (token.startsWith("--") && eq !== -1) {
245
+ const name = token.slice(0, eq);
246
+ if (eq === token.length - 1 && valued(name) !== undefined)
247
+ throw usage(`${name} needs a value`);
248
+ continue;
249
+ }
250
+ const option = valued(token);
251
+ if (option === undefined)
252
+ continue;
253
+ const value = head[++i];
254
+ if (value === undefined)
255
+ return;
256
+ if (option.long === "--docker-arg")
257
+ continue;
258
+ if (value.startsWith("-") && value.length > 1) {
259
+ throw usage(`${option.long ?? token} needs a value, got the flag ${value}`);
260
+ }
261
+ }
262
+ }
263
+ function parseArgs(argv, opts = {}) {
264
+ const env = opts.env ?? process.env;
265
+ const sep = argv.indexOf("--");
266
+ const head = sep === -1 ? argv : argv.slice(0, sep);
267
+ const program = buildProgram();
268
+ const seen = new Set;
269
+ const counts = new Map;
270
+ const worktree = [];
271
+ let cleanTier;
272
+ for (const option of program.options) {
273
+ const long = option.long ?? option.flags;
274
+ program.on(`option:${option.name()}`, (value) => {
275
+ seen.add(long);
276
+ counts.set(long, (counts.get(long) ?? 0) + 1);
277
+ if (long === "--worktree" && value !== undefined)
278
+ worktree.push(value);
279
+ if (long === "--staging" || long === "--logs" || long === "--all") {
280
+ cleanTier = long.slice(2);
281
+ }
282
+ });
283
+ }
284
+ checkValueTokens(program, head);
285
+ try {
286
+ program.parse(head, { from: "user" });
287
+ } catch (error) {
288
+ throw translate(error, program);
289
+ }
290
+ for (const option of program.options) {
291
+ const long = option.long ?? option.flags;
292
+ const repeatable = Array.isArray(option.defaultValue);
293
+ if (option.required && !repeatable && (counts.get(long) ?? 0) > 1) {
294
+ throw usage(`${long} given more than once`);
295
+ }
296
+ }
297
+ if (seen.has("--build") && seen.has("--no-build"))
298
+ throw usage("--build and --no-build contradict");
299
+ if (seen.has("--replace") && seen.has("--no-replace"))
300
+ throw usage("--replace and --no-replace contradict");
301
+ const values = program.opts();
302
+ const envBuild = applyEnv(program, seen, env, values);
303
+ const out = {
304
+ subcommand: "run",
305
+ mods: values["mod"],
306
+ without: values["without"],
307
+ only: values["only"],
308
+ dockerArgs: values["dockerArg"],
309
+ gameArgs: sep === -1 ? [] : argv.slice(sep + 1),
310
+ dryRun: values["dryRun"] === true,
311
+ printPlan: values["printPlan"] === true,
312
+ json: values["json"] === true,
313
+ root: values["root"] === true,
314
+ yes: values["yes"] === true,
315
+ help: values["help"] === true,
316
+ worktree,
317
+ noWorktree: seen.has("--no-worktree"),
318
+ noStaleCheck: seen.has("--no-stale-check"),
319
+ replace: values["replace"] === true,
320
+ use: values["use"],
321
+ rest: []
322
+ };
323
+ out.mode = values["mode"];
324
+ out.marker = values["marker"];
325
+ out.timeout = values["timeout"];
326
+ out.renderWait = values["renderWait"];
327
+ out.resolution = values["resolution"];
328
+ out.network = values["network"];
329
+ out.log = values["log"];
330
+ out.pull = values["pull"];
331
+ out.sort = values["sort"];
332
+ out.instance = values["instance"];
333
+ out.build = envBuild ?? policy(values["build"]);
334
+ out.cleanTier = cleanTier;
335
+ if (opts.defaults?.game !== undefined)
336
+ out.game = opts.defaults.game;
337
+ if (opts.defaults?.profile !== undefined)
338
+ out.profile = opts.defaults.profile;
339
+ applyPositionals(out, program.args, opts.games);
340
+ if (opts.defaults !== undefined)
341
+ applyDefaults(out, seen, opts.defaults, sep !== -1);
342
+ return out;
343
+ }
344
+ function policy(value) {
345
+ if (value === true)
346
+ return "always";
347
+ if (value === false)
348
+ return "never";
349
+ return;
350
+ }
351
+ function translate(error, program) {
352
+ if (!(error instanceof CommanderError))
353
+ return error;
354
+ const token = /'([^']+)'/.exec(error.message)?.[1] ?? "";
355
+ if (error.code === "commander.unknownOption") {
356
+ const name = token.split("=")[0];
357
+ const known = program.options.find((o) => o.long === name || o.short === name);
358
+ if (known !== undefined)
359
+ return usage(`${name} takes no value`);
360
+ return usage(`unknown flag ${name}`, suggest(name, flagNames(program)));
361
+ }
362
+ if (error.code === "commander.optionMissingArgument") {
363
+ return usage(`${token.split(" ")[0]} needs a value`);
364
+ }
365
+ return new GamecrateError(error.message.replace(/^error: /, ""), Exit.Usage);
366
+ }
367
+ function applyPositionals(out, positional, games) {
368
+ const first = positional[0];
369
+ if (first === undefined) {
370
+ if (out.game !== undefined)
371
+ return;
372
+ out.subcommand = "help";
373
+ out.help = true;
374
+ return;
375
+ }
376
+ const sub = SUBCOMMANDS.find((s) => s.name === first);
377
+ let slots;
378
+ let rest;
379
+ if (sub) {
380
+ out.subcommand = sub.name;
381
+ slots = [...sub.positionals];
382
+ rest = positional.slice(1);
383
+ } else {
384
+ const known = !NAME_PATTERN.test(first) ? false : games === undefined || games.includes(first);
385
+ if (!known) {
386
+ const candidates = [...SUBCOMMANDS.map((s) => s.name), ...games ?? []];
387
+ throw usage(`${first} is not a game or a subcommand`, suggest(first, candidates));
388
+ }
389
+ out.subcommand = "run";
390
+ out.game = first;
391
+ slots = ["profile", "rest"];
392
+ rest = positional.slice(1);
393
+ }
394
+ for (const slot of slots) {
395
+ if (slot === "rest") {
396
+ out.rest = rest;
397
+ rest = [];
398
+ break;
399
+ }
400
+ const value = rest.shift();
401
+ if (value === undefined)
402
+ break;
403
+ if (!NAME_PATTERN.test(value))
404
+ throw usage(`${value} is not a valid ${slot} name`);
405
+ if (slot === "game")
406
+ out.game = value;
407
+ else
408
+ out.profile = value;
409
+ }
410
+ if (rest.length > 0) {
411
+ const shape = sub ? `${sub.name} ${sub.usage}`.trim() : `${out.game} [profile]`;
412
+ throw usage(`unexpected argument ${rest[0]}`, `gamecrate ${shape}`);
413
+ }
414
+ }
415
+ function applyEnv(program, seen, env, values) {
416
+ let build;
417
+ for (const [flag, name] of Object.entries(FLAG_ENV)) {
418
+ if (seen.has(flag))
419
+ continue;
420
+ if (flag === "--build" && seen.has("--no-build"))
421
+ continue;
422
+ const raw = env[name];
423
+ if (raw === undefined || raw === "")
424
+ continue;
425
+ seen.add(flag);
426
+ if (flag === "--build") {
427
+ if (!BUILD_POLICIES.includes(raw)) {
428
+ throw usage(`${name} must be one of ${BUILD_POLICIES.join(", ")}, got ${raw}`);
429
+ }
430
+ build = raw;
431
+ continue;
432
+ }
433
+ const option = program.options.find((o) => o.long === flag);
434
+ const key = option.attributeName();
435
+ if (!option.required) {
436
+ if (truthy(raw))
437
+ values[key] = true;
438
+ continue;
439
+ }
440
+ if (option.argChoices && !option.argChoices.includes(raw)) {
441
+ throw usage(`${name} must be one of ${option.argChoices.join(", ")}, got ${raw}`);
442
+ }
443
+ values[key] = option.parseArg === undefined ? raw : option.parseArg(raw, values[key]);
444
+ }
445
+ return build;
446
+ }
447
+ function applyDefaults(out, seen, defaults, hasGameArgs) {
448
+ if (!seen.has("--mod") && defaults.mods !== undefined)
449
+ out.mods = [...defaults.mods];
450
+ if (!seen.has("--without") && defaults.without !== undefined)
451
+ out.without = [...defaults.without];
452
+ if (!seen.has("--only") && defaults.only !== undefined)
453
+ out.only = [...defaults.only];
454
+ if (!seen.has("--docker-arg") && defaults.dockerArgs !== undefined)
455
+ out.dockerArgs = [...defaults.dockerArgs];
456
+ if (!seen.has("--worktree") && !seen.has("--no-worktree") && defaults.worktree !== undefined) {
457
+ out.worktree = [...defaults.worktree];
458
+ }
459
+ if (!seen.has("--use") && defaults.use !== undefined)
460
+ out.use = [...defaults.use];
461
+ if (!hasGameArgs && defaults.gameArgs !== undefined)
462
+ out.gameArgs = [...defaults.gameArgs];
463
+ out.mode ??= defaults.mode;
464
+ out.marker ??= defaults.marker;
465
+ out.timeout ??= defaults.timeout;
466
+ out.renderWait ??= defaults.renderWait;
467
+ out.resolution ??= defaults.resolution;
468
+ out.network ??= defaults.network;
469
+ out.log ??= defaults.log;
470
+ out.pull ??= defaults.pull;
471
+ out.build ??= defaults.build;
472
+ out.sort ??= defaults.sort;
473
+ out.instance ??= defaults.instance;
474
+ if (!seen.has("--dry-run"))
475
+ out.dryRun = defaults.dryRun ?? out.dryRun;
476
+ if (!seen.has("--print-plan"))
477
+ out.printPlan = defaults.printPlan ?? out.printPlan;
478
+ if (!seen.has("--json"))
479
+ out.json = defaults.json ?? out.json;
480
+ if (!seen.has("--root"))
481
+ out.root = defaults.root ?? out.root;
482
+ if (!seen.has("--no-worktree") && !seen.has("--worktree")) {
483
+ out.noWorktree = defaults.noWorktree ?? out.noWorktree;
484
+ }
485
+ if (!seen.has("--no-stale-check"))
486
+ out.noStaleCheck = defaults.noStaleCheck ?? out.noStaleCheck;
487
+ if (!seen.has("--replace") && !seen.has("--no-replace"))
488
+ out.replace = defaults.replace ?? out.replace;
489
+ }
490
+ function truthy(value) {
491
+ return value === "1" || value.toLowerCase() === "true" || value.toLowerCase() === "yes";
492
+ }
493
+ function flagNames(program) {
494
+ return program.options.flatMap((o) => [o.long, o.short].filter((f) => f !== undefined));
495
+ }
496
+ function seconds(flag, value) {
497
+ const n = Number(value);
498
+ if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0) {
499
+ throw usage(`${flag} takes a whole number of seconds, got ${value}`);
500
+ }
501
+ return n;
502
+ }
503
+ function parseResolution(value) {
504
+ const match = /^(\d+)x(\d+)$/i.exec(value);
505
+ const width = Number(match?.[1]);
506
+ const height = Number(match?.[2]);
507
+ if (!Number.isSafeInteger(width) || width <= 0 || !Number.isSafeInteger(height) || height <= 0) {
508
+ throw usage(`--resolution takes positive dimensions like 1920x1080, got ${value}`);
509
+ }
510
+ return { width, height };
511
+ }
512
+ function usage(message, suggestion) {
513
+ return new GamecrateError(message, Exit.Usage, suggestion ? `did you mean ${suggestion}?` : undefined);
514
+ }
515
+ function suggest(word, candidates) {
516
+ const target = word.toLowerCase();
517
+ const limit = Math.max(2, Math.floor(target.length / 3));
518
+ let best;
519
+ let bestDistance = Infinity;
520
+ for (const candidate of candidates) {
521
+ const d = distance(target, candidate.toLowerCase());
522
+ if (d < bestDistance && d <= limit) {
523
+ best = candidate;
524
+ bestDistance = d;
525
+ }
526
+ }
527
+ return best;
528
+ }
529
+ function distance(a, b) {
530
+ let prev = Array.from({ length: b.length + 1 }, (_, i) => i);
531
+ for (let i = 1;i <= a.length; i++) {
532
+ const row = [i];
533
+ for (let j = 1;j <= b.length; j++) {
534
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
535
+ row[j] = Math.min(row[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost);
536
+ }
537
+ prev = row;
538
+ }
539
+ return prev[b.length];
540
+ }
541
+
542
+ // src/cli/help.ts
543
+ var NAME = "gamecrate";
544
+ function flags() {
545
+ return buildProgram().options;
546
+ }
547
+ function renderHelp(topic, config) {
548
+ if (!topic)
549
+ return topLevel(config);
550
+ const sub = SUBCOMMANDS.find((s) => s.name === topic);
551
+ if (sub)
552
+ return subcommandHelp(sub);
553
+ const game = own(config?.games, topic);
554
+ if (game)
555
+ return gameHelp(topic, game);
556
+ const candidates = [...SUBCOMMANDS.map((s) => s.name), ...Object.keys(config?.games ?? {})];
557
+ const hint = suggest(topic, candidates);
558
+ throw new GamecrateError(`no help topic ${topic}`, Exit.Usage, hint ? `did you mean ${hint}?` : `topics: ${candidates.join(", ")}`);
559
+ }
560
+ function topLevel(config) {
561
+ const lines = [
562
+ `${NAME} — run modded games in containers`,
563
+ "",
564
+ "usage:",
565
+ ` ${NAME} <game> [profile] [flags] [-- game args]`,
566
+ ` ${NAME} <subcommand> [args] [flags]`,
567
+ "",
568
+ "subcommands:"
569
+ ];
570
+ const verbs = SUBCOMMANDS.map((s) => [`${s.name} ${s.usage}`.trim(), s.summary]);
571
+ lines.push(...columns(verbs, 2));
572
+ lines.push("", "flags:");
573
+ lines.push(...columns(flags().map(flagRow), 2));
574
+ const games = Object.entries(config?.games ?? {});
575
+ if (games.length > 0) {
576
+ lines.push("", "games:");
577
+ lines.push(...columns(games.map(([name, game]) => [name, gameSummary(game)]), 2));
578
+ lines.push("", `${NAME} help <game> lists that game's profiles.`);
579
+ }
580
+ lines.push("", "Game args go after a bare --. Env vars are GAMECRATE_ prefixed fallbacks only.");
581
+ return lines.join(`
582
+ `) + `
583
+ `;
584
+ }
585
+ function subcommandHelp(sub) {
586
+ const lines = [`usage: ${NAME} ${sub.name} ${sub.usage}`.trimEnd() + " [flags]", "", ` ${sub.summary}`];
587
+ const names = [...sub.flags, ...GLOBAL_FLAGS];
588
+ const all = flags();
589
+ const specs = names.map((name) => all.find((f) => f.long === name)).filter((f) => f !== undefined);
590
+ if (specs.length > 0) {
591
+ lines.push("", "flags:");
592
+ lines.push(...columns(specs.map(flagRow), 2));
593
+ }
594
+ if (sub.name === "run") {
595
+ lines.push("", ` The subcommand slot defaults to run, so \`${NAME} <game> <profile>\` works.`);
596
+ }
597
+ return lines.join(`
598
+ `) + `
599
+ `;
600
+ }
601
+ function gameHelp(name, game) {
602
+ const lines = [`usage: ${NAME} ${name} [profile] [flags] [-- game args]`, "", "profiles:"];
603
+ const rows = [];
604
+ for (const [profile, config] of Object.entries(game.profiles)) {
605
+ const notes = [];
606
+ if (config.alias)
607
+ notes.push(`alias for ${config.alias}`);
608
+ if (config.extends)
609
+ notes.push(`extends ${config.extends}`);
610
+ if (config.autoDependencies)
611
+ notes.push("auto dependencies");
612
+ const count = config.mods?.length ?? 0;
613
+ if (!config.alias)
614
+ notes.push(count === 1 ? "1 entry" : `${count} entries`);
615
+ if (config.aliases?.length)
616
+ notes.push(`aka ${config.aliases.join(", ")}`);
617
+ rows.push([profile, notes.join(", ")]);
618
+ }
619
+ if (rows.length === 0)
620
+ rows.push(["(none declared)", ""]);
621
+ lines.push(...columns(rows, 2));
622
+ if (game.aliases && Object.keys(game.aliases).length > 0) {
623
+ lines.push("", "mod name aliases:");
624
+ lines.push(...columns(Object.entries(game.aliases).map(([k, v]) => [k, v]), 2));
625
+ }
626
+ lines.push("", `modes: ${game.modes.join(", ")}`);
627
+ lines.push(`core: ${game.core}`);
628
+ if (game.dlc.length > 0)
629
+ lines.push(`dlc: ${game.dlc.join(", ")}`);
630
+ lines.push(`game files: ${game.gameFiles.source === "mount" ? game.gameFiles.host ?? "(unset)" : game.image.ref}`);
631
+ return lines.join(`
632
+ `) + `
633
+ `;
634
+ }
635
+ function gameSummary(game) {
636
+ const count = Object.keys(game.profiles).length;
637
+ return `${count === 1 ? "1 profile" : `${count} profiles`}; modes ${game.modes.join(", ")}`;
638
+ }
639
+ function flagRow(spec) {
640
+ const notes = [];
641
+ if (Array.isArray(spec.defaultValue))
642
+ notes.push("repeatable");
643
+ const env = FLAG_ENV[spec.long ?? ""];
644
+ if (env)
645
+ notes.push(`$${env}`);
646
+ const summary = spec.description;
647
+ return [spec.flags, notes.length > 0 ? `${summary} (${notes.join(", ")})` : summary];
648
+ }
649
+ function placeholder(spec) {
650
+ return spec.flags.split(/[ ,]+/).find((token) => token.startsWith("<")) ?? "";
651
+ }
652
+ function columns(rows, indent) {
653
+ const width = Math.max(0, ...rows.map((r) => r[0].length));
654
+ const pad = " ".repeat(indent);
655
+ return rows.map(([left, right]) => right ? `${pad}${left.padEnd(width)} ${right}` : `${pad}${left}`);
656
+ }
657
+ function renderCompletion(shell) {
658
+ const verbs = SUBCOMMANDS.map((s) => s.name).join(" ");
659
+ const options = flags();
660
+ const names = options.flatMap((f) => f.short ? [f.long, f.short] : [f.long]).join(" ");
661
+ const valueFlags = options.filter((f) => f.required).map((f) => f.long);
662
+ const fn = `_${NAME.replace(/-/g, "_")}`;
663
+ if (shell === "bash") {
664
+ const cases = options.filter((f) => f.argChoices).map((f) => ` ${f.long}) COMPREPLY=($(compgen -W "${f.argChoices.join(" ")}" -- "$cur")); return ;;`).join(`
665
+ `);
666
+ return `${fn}() {
667
+ local cur prev
668
+ cur="\${COMP_WORDS[COMP_CWORD]}"
669
+ prev="\${COMP_WORDS[COMP_CWORD-1]}"
670
+ case "$prev" in
671
+ ${cases}
672
+ ${valueFlags.join("|")}) return ;;
673
+ esac
674
+ if [[ "$cur" == -* ]]; then
675
+ COMPREPLY=($(compgen -W "${names}" -- "$cur"))
676
+ return
677
+ fi
678
+ if [[ $COMP_CWORD -eq 1 ]]; then
679
+ local games
680
+ games=$(${NAME} list --json 2>/dev/null | grep -oE '"[A-Za-z0-9._-]+"' | tr -d '"')
681
+ COMPREPLY=($(compgen -W "${verbs} $games" -- "$cur"))
682
+ fi
683
+ }
684
+ complete -F ${fn} ${NAME}
685
+ `;
686
+ }
687
+ const zshVerbs = SUBCOMMANDS.map((s) => ` '${s.name}:${s.summary.replace(/'/g, "'\\''")}'`).join(`
688
+ `);
689
+ const zshFlags = options.map((f) => {
690
+ const desc = f.description.replace(/'/g, "'\\''").replace(/[[\]:]/g, "");
691
+ const arg = placeholder(f);
692
+ const value = arg ? `:${arg.replace(/[<>]/g, "")}:${f.argChoices ? `(${f.argChoices.join(" ")})` : "_files"}` : "";
693
+ const repeat = Array.isArray(f.defaultValue) ? "*" : "";
694
+ return ` '${repeat}${f.long}[${desc}]${value}'`;
695
+ }).join(`
696
+ `);
697
+ return `#compdef ${NAME}
698
+
699
+ ${fn}() {
700
+ local -a verbs
701
+ verbs=(
702
+ ${zshVerbs}
703
+ )
704
+ _arguments -s \\
705
+ ${zshFlags} \\
706
+ '1: :{_describe verb verbs}' \\
707
+ '*:: :->rest'
708
+ }
709
+
710
+ ${fn} "$@"
711
+ `;
712
+ }
713
+
714
+ // src/cli/output.ts
715
+ import { closeSync, existsSync as existsSync2, lstatSync, mkdirSync, openSync, readdirSync, rmSync, symlinkSync, unlinkSync, writeSync } from "node:fs";
716
+ import { basename as basename2, join as join3, resolve } from "node:path";
717
+
718
+ // src/docker/spec.ts
719
+ import { existsSync, realpathSync } from "node:fs";
720
+ import { homedir, hostname } from "node:os";
721
+ import { basename, join } from "node:path";
722
+ var CONTAINER_RUNTIME_DIR = "/tmp/xdg";
723
+ var CONTAINER_LOG_DIR = "/logs";
724
+ var X11_SOCKET_DIR = "/tmp/.X11-unix";
725
+ var CONTAINER_XAUTHORITY = "/tmp/xauth";
726
+ var CONTAINER_XDG_DIR = "/xdg";
727
+ var RUNTIME_DIR_SIZE = "64m";
728
+ var HOME_SIZE = "64m";
729
+ var MASK_SIZE = "1m";
730
+ function buildRunSpec(plan, modMounts, identity) {
731
+ const { gameConfig: game, settings } = plan;
732
+ const headed = plan.mode === "headed";
733
+ const mounts = [];
734
+ const env = {
735
+ HOME: identity.home,
736
+ USER: identity.user,
737
+ LOGNAME: identity.user
738
+ };
739
+ if (game.gameFiles.source === "mount") {
740
+ if (!game.gameFiles.host) {
741
+ throw new GamecrateError(`gameFiles.source is "mount" but no host path is set for ${plan.game}`, Exit.Config);
742
+ }
743
+ mounts.push({ type: "bind", source: hostPath(game.gameFiles.host), target: game.gameFiles.container, readonly: true });
744
+ }
745
+ mounts.push({ type: "bind", source: hostPath(plan.stageDirHost), target: game.modsDir.container, readonly: true });
746
+ for (const mount of modMounts) {
747
+ mounts.push(mount.type === "bind" ? { ...mount, readonly: true } : mount);
748
+ }
749
+ mounts.push({ type: "bind", source: hostPath(plan.dataDirHost), target: game.dataDir.container });
750
+ const command = headed ? [game.executable] : [
751
+ "xvfb-run",
752
+ "-a",
753
+ `--server-args=-screen 0 ${settings.width}x${settings.height}x24`,
754
+ game.executable
755
+ ];
756
+ if (game.dataDir.mode === "arg") {
757
+ command.push(validateDataDirArg(game.dataDir));
758
+ } else {
759
+ Object.assign(env, game.dataDir.env);
760
+ }
761
+ if (game.logFile.mode === "arg") {
762
+ mounts.push({ type: "bind", source: hostPath(plan.runDirHost), target: CONTAINER_LOG_DIR });
763
+ command.push(game.logFile.arg, `${CONTAINER_LOG_DIR}/Player.log`);
764
+ }
765
+ for (const target of game.modsDir.mask ?? []) {
766
+ mounts.push({ type: "tmpfs", target, size: MASK_SIZE, uid: identity.uid, gid: identity.gid, mode: "755" });
767
+ }
768
+ if (identity.uid !== 0) {
769
+ mounts.push({ type: "tmpfs", target: identity.home, size: HOME_SIZE, uid: identity.uid, gid: identity.gid, mode: "700" });
770
+ }
771
+ mounts.push({
772
+ type: "tmpfs",
773
+ target: CONTAINER_RUNTIME_DIR,
774
+ size: RUNTIME_DIR_SIZE,
775
+ uid: identity.uid,
776
+ gid: identity.gid,
777
+ mode: "700"
778
+ });
779
+ env.XDG_RUNTIME_DIR = CONTAINER_RUNTIME_DIR;
780
+ mounts.push({ type: "bind", source: hostPath(plan.configDirHost), target: CONTAINER_XDG_DIR });
781
+ env.XDG_CONFIG_HOME = `${CONTAINER_XDG_DIR}/config`;
782
+ env.XDG_CACHE_HOME = `${CONTAINER_XDG_DIR}/cache`;
783
+ env.XDG_DATA_HOME ??= `${CONTAINER_XDG_DIR}/data`;
784
+ if (headed) {
785
+ if (settings.display === "x11") {
786
+ const x11 = x11Session();
787
+ if (x11) {
788
+ mounts.push({ type: "bind", source: X11_SOCKET_DIR, target: X11_SOCKET_DIR });
789
+ env.DISPLAY = x11.display;
790
+ env.XDG_SESSION_TYPE = "x11";
791
+ env.SDL_VIDEODRIVER = "x11";
792
+ env.QT_QPA_PLATFORM = "xcb";
793
+ if (x11.xauthority) {
794
+ mounts.push({ type: "bind", source: x11.xauthority, target: CONTAINER_XAUTHORITY, readonly: true });
795
+ env.XAUTHORITY = CONTAINER_XAUTHORITY;
796
+ }
797
+ }
798
+ } else {
799
+ const wayland = waylandSocket();
800
+ if (wayland) {
801
+ const target = `${CONTAINER_RUNTIME_DIR}/${wayland.name}`;
802
+ mounts.push({ type: "bind", source: wayland.source, target });
803
+ env.WAYLAND_DISPLAY = wayland.name;
804
+ env.XDG_SESSION_TYPE = "wayland";
805
+ env.SDL_VIDEODRIVER = "wayland";
806
+ env.QT_QPA_PLATFORM = "wayland";
807
+ }
808
+ }
809
+ if (settings.audio) {
810
+ for (const socket of audioSockets()) {
811
+ mounts.push({ type: "bind", source: socket.source, target: `${CONTAINER_RUNTIME_DIR}/${socket.name}` });
812
+ }
813
+ env.PULSE_SERVER = `unix:${CONTAINER_RUNTIME_DIR}/pulse/native`;
814
+ }
815
+ }
816
+ const deviceCgroupRules = [];
817
+ if (settings.input) {
818
+ mounts.push({ type: "bind", source: "/dev/input", target: "/dev/input", readonly: true });
819
+ deviceCgroupRules.push("c 13:* rmw");
820
+ }
821
+ const devices = [];
822
+ if (settings.gpu)
823
+ devices.push("nvidia.com/gpu=all");
824
+ Object.assign(env, glEnv(settings.gpu));
825
+ command.push(...settings.gameArgs ?? []);
826
+ return {
827
+ image: game.image.ref,
828
+ name: containerName(plan),
829
+ labels: {
830
+ "gamecrate.game": plan.game,
831
+ "gamecrate.profile": plan.profile,
832
+ ...plan.instance === undefined ? {} : { "gamecrate.instance": plan.instance }
833
+ },
834
+ identity,
835
+ env,
836
+ mounts,
837
+ devices,
838
+ deviceCgroupRules,
839
+ network: settings.network,
840
+ memory: settings.memory,
841
+ memorySwap: settings.memory,
842
+ cpus: settings.cpus,
843
+ pidsLimit: settings.pidsLimit,
844
+ ulimits: ["core=0"],
845
+ workdir: game.gameFiles.container,
846
+ ...headed && settings.display === "x11" ? { hostname: hostname() } : {},
847
+ command,
848
+ extraArgs: [...settings.dockerArgs ?? []]
849
+ };
850
+ }
851
+ function containerName(plan) {
852
+ const base = `gamecrate-${plan.game}-${plan.profile}`;
853
+ return plan.instance === undefined ? base : `${base}-${plan.instance}`;
854
+ }
855
+ function windowTitle(plan) {
856
+ const base = `${plan.game} ${plan.profile}`;
857
+ return plan.instance === undefined ? base : `${base} / ${plan.instance}`;
858
+ }
859
+ function toDockerArgs(spec) {
860
+ const args = ["run", "--rm", "--init", "--name", spec.name];
861
+ if (spec.hostname !== undefined)
862
+ args.push("--hostname", spec.hostname);
863
+ for (const [key, value] of Object.entries(spec.labels))
864
+ args.push("--label", `${key}=${value}`);
865
+ args.push("--user", `${spec.identity.uid}:${spec.identity.gid}`);
866
+ for (const [key, value] of Object.entries(spec.env))
867
+ args.push("--env", `${key}=${value}`);
868
+ for (const mount of spec.mounts)
869
+ args.push(...mountArgs(mount));
870
+ for (const device of spec.devices)
871
+ args.push("--device", device);
872
+ for (const rule of spec.deviceCgroupRules)
873
+ args.push("--device-cgroup-rule", rule);
874
+ for (const ulimit of spec.ulimits)
875
+ args.push("--ulimit", ulimit);
876
+ args.push("--network", spec.network);
877
+ args.push("--memory", spec.memory);
878
+ args.push("--memory-swap", spec.memorySwap);
879
+ args.push("--cpus", String(spec.cpus));
880
+ args.push("--pids-limit", String(spec.pidsLimit));
881
+ args.push("--workdir", spec.workdir);
882
+ args.push(...spec.extraArgs);
883
+ args.push("--pull=never");
884
+ const [entrypoint, ...rest] = spec.command;
885
+ if (entrypoint !== undefined)
886
+ args.push("--entrypoint", entrypoint);
887
+ args.push(spec.image, ...rest);
888
+ return args;
889
+ }
890
+ function mountArgs(mount) {
891
+ if (mount.type === "tmpfs") {
892
+ const opts = ["rw"];
893
+ if (mount.uid !== undefined)
894
+ opts.push(`uid=${mount.uid}`);
895
+ if (mount.gid !== undefined)
896
+ opts.push(`gid=${mount.gid}`);
897
+ if (mount.mode)
898
+ opts.push(`mode=${mount.mode}`);
899
+ opts.push(`size=${mount.size ?? RUNTIME_DIR_SIZE}`);
900
+ return ["--tmpfs", `${mount.target}:${opts.join(",")}`];
901
+ }
902
+ if (!mount.source) {
903
+ throw new GamecrateError(`bind mount at ${mount.target} has no source`, Exit.Config);
904
+ }
905
+ const fields = [`type=bind`, `src=${mount.source}`, `dst=${mount.target}`];
906
+ if (mount.readonly)
907
+ fields.push("readonly");
908
+ return ["--mount", fields.map(csvField).join(",")];
909
+ }
910
+ function csvField(field) {
911
+ if (!field.includes(",") && !field.includes('"'))
912
+ return field;
913
+ return `"${field.replaceAll('"', '""')}"`;
914
+ }
915
+ function validateDataDirArg(dataDir) {
916
+ if (dataDir.container.includes("=")) {
917
+ throw new GamecrateError(`container data path contains "=": ${dataDir.container}`, Exit.Config, "RimWorld silently ignores -savedatafolder when the argv element does not split into exactly two parts, and the save is lost with --rm.");
918
+ }
919
+ const parts = dataDir.arg.split("=");
920
+ if (parts.length !== 2) {
921
+ throw new GamecrateError(`dataDir.arg must contain exactly one "=": ${dataDir.arg}`, Exit.Config);
922
+ }
923
+ if (trimSlash(parts[1] ?? "") !== trimSlash(dataDir.container)) {
924
+ throw new GamecrateError(`dataDir.arg points at ${parts[1]} but the mount target is ${dataDir.container}`, Exit.Config, "The engine would write to a path that is not the mounted data directory.");
925
+ }
926
+ return dataDir.arg;
927
+ }
928
+ function trimSlash(path) {
929
+ return path.length > 1 ? path.replace(/\/+$/, "") : path;
930
+ }
931
+ function glEnv(gpu) {
932
+ if (!gpu)
933
+ return { LIBGL_ALWAYS_SOFTWARE: "1", GALLIUM_DRIVER: "llvmpipe" };
934
+ const env = { LIBGL_ALWAYS_SOFTWARE: "0", GALLIUM_DRIVER: "" };
935
+ if (hasNvidia()) {
936
+ env.__GLX_VENDOR_LIBRARY_NAME = "nvidia";
937
+ env.__NV_PRIME_RENDER_OFFLOAD = "1";
938
+ }
939
+ return env;
940
+ }
941
+ function hasNvidia() {
942
+ return existsSync("/dev/nvidiactl") || existsSync("/etc/cdi/nvidia.yaml") || existsSync("/usr/share/vulkan/icd.d/nvidia_icd.json");
943
+ }
944
+ function x11Session() {
945
+ const display = process.env.DISPLAY;
946
+ if (!display)
947
+ return null;
948
+ const cookie = process.env.XAUTHORITY ?? join(homedir(), ".Xauthority");
949
+ return { display, xauthority: existsSync(cookie) ? cookie : null };
950
+ }
951
+ function waylandSocket() {
952
+ const display = process.env.WAYLAND_DISPLAY;
953
+ const runtime = process.env.XDG_RUNTIME_DIR;
954
+ if (!display)
955
+ return null;
956
+ const source = display.startsWith("/") ? display : runtime ? join(runtime, display) : null;
957
+ if (!source || !existsSync(source))
958
+ return null;
959
+ return { source, name: basename(source) };
960
+ }
961
+ function audioSockets() {
962
+ const runtime = process.env.XDG_RUNTIME_DIR;
963
+ if (!runtime)
964
+ return [];
965
+ const found = [];
966
+ for (const name of ["pipewire-0", "pulse/native"]) {
967
+ const source = join(runtime, name);
968
+ if (existsSync(source))
969
+ found.push({ source, name });
970
+ }
971
+ return found;
972
+ }
973
+ function hostPath(path) {
974
+ try {
975
+ return realpathSync(path);
976
+ } catch {
977
+ return path;
978
+ }
979
+ }
980
+
981
+ // src/mods/staleness.ts
982
+ import { readdir, stat } from "node:fs/promises";
983
+ import { join as join2, relative } from "node:path";
984
+ var SKIP_DIRS = new Set([".git", ".retired", ".vs", "bin", "node_modules", "obj"]);
985
+ var ENTRY_LIMIT = 20000;
986
+ async function scanBuildTimes(dir) {
987
+ const times = { sourceTimes: [] };
988
+ let budget = ENTRY_LIMIT;
989
+ const walk = async (current, inAssemblies) => {
990
+ let entries;
991
+ try {
992
+ entries = await readdir(current, { withFileTypes: true });
993
+ } catch {
994
+ return;
995
+ }
996
+ for (const entry of entries) {
997
+ if (budget-- <= 0)
998
+ return;
999
+ const path = join2(current, entry.name);
1000
+ if (entry.isDirectory()) {
1001
+ if (SKIP_DIRS.has(entry.name.toLowerCase()))
1002
+ continue;
1003
+ await walk(path, inAssemblies || entry.name === "Assemblies");
1004
+ continue;
1005
+ }
1006
+ if (!entry.isFile())
1007
+ continue;
1008
+ const lower = entry.name.toLowerCase();
1009
+ const isSource = lower.endsWith(".cs");
1010
+ const isAssembly = inAssemblies && lower.endsWith(".dll");
1011
+ if (!isSource && !isAssembly)
1012
+ continue;
1013
+ let mtimeMs;
1014
+ try {
1015
+ mtimeMs = (await stat(path)).mtimeMs;
1016
+ } catch {
1017
+ continue;
1018
+ }
1019
+ const found = { path: relative(dir, path), mtimeMs };
1020
+ if (isSource) {
1021
+ times.sourceTimes.push(mtimeMs);
1022
+ if (mtimeMs > (times.newestSource?.mtimeMs ?? -1))
1023
+ times.newestSource = found;
1024
+ } else if (mtimeMs > (times.newestAssembly?.mtimeMs ?? -1)) {
1025
+ times.newestAssembly = found;
1026
+ }
1027
+ }
1028
+ };
1029
+ await walk(dir, false);
1030
+ return times;
1031
+ }
1032
+ function decideStale(times) {
1033
+ const { newestSource, newestAssembly } = times;
1034
+ if (newestSource === undefined)
1035
+ return false;
1036
+ return newestAssembly === undefined || newestSource.mtimeMs > newestAssembly.mtimeMs;
1037
+ }
1038
+ function staleReport(times) {
1039
+ const { newestSource, newestAssembly } = times;
1040
+ if (newestSource === undefined || newestAssembly === undefined)
1041
+ return null;
1042
+ if (newestSource.mtimeMs <= newestAssembly.mtimeMs)
1043
+ return null;
1044
+ return {
1045
+ newestSource: newestSource.path,
1046
+ newestSourceMs: newestSource.mtimeMs,
1047
+ assembly: newestAssembly.path,
1048
+ assemblyMs: newestAssembly.mtimeMs,
1049
+ newerCount: times.sourceTimes.filter((t) => t > newestAssembly.mtimeMs).length
1050
+ };
1051
+ }
1052
+ var INDENT = " ".repeat("warning: ".length);
1053
+ function staleWarning(packageId, report, now = Date.now()) {
1054
+ const files = report.newerCount === 1 ? "1 source file" : `${report.newerCount} source files`;
1055
+ return [
1056
+ `${packageId} has ${files} newer than ${report.assembly}`,
1057
+ `${INDENT}newest: ${report.newestSource} (${ago(report.newestSourceMs, now)})`,
1058
+ `${INDENT}you are probably running a stale build`
1059
+ ].join(`
1060
+ `);
1061
+ }
1062
+ function duration(ms) {
1063
+ const seconds2 = Math.max(0, Math.round(ms / 1000));
1064
+ if (seconds2 < 60)
1065
+ return `${seconds2}s`;
1066
+ if (seconds2 < 3600)
1067
+ return `${Math.floor(seconds2 / 60)}m`;
1068
+ if (seconds2 < 86400)
1069
+ return `${Math.floor(seconds2 / 3600)}h`;
1070
+ return `${Math.floor(seconds2 / 86400)}d`;
1071
+ }
1072
+ function ago(mtimeMs, now = Date.now()) {
1073
+ return `${duration(now - mtimeMs)} ago`;
1074
+ }
1075
+
1076
+ // src/cli/output.ts
1077
+ function status(message) {
1078
+ process.stderr.write(line(message));
1079
+ }
1080
+ function warn(message) {
1081
+ process.stderr.write(line(`warning: ${message}`));
1082
+ }
1083
+ function redirectOutput(path) {
1084
+ const fd = openSync(path, "w");
1085
+ const stdout = process.stdout.write;
1086
+ const stderr = process.stderr.write;
1087
+ let open = true;
1088
+ const write = (chunk, encodingOrCallback, callback) => {
1089
+ append(fd, chunk);
1090
+ const done = typeof encodingOrCallback === "function" ? encodingOrCallback : callback;
1091
+ done?.();
1092
+ return true;
1093
+ };
1094
+ process.stdout.write = write;
1095
+ process.stderr.write = write;
1096
+ return {
1097
+ close() {
1098
+ if (!open)
1099
+ return;
1100
+ open = false;
1101
+ process.stdout.write = stdout;
1102
+ process.stderr.write = stderr;
1103
+ closeSync(fd);
1104
+ }
1105
+ };
1106
+ }
1107
+ async function forwardOutput(stream, target) {
1108
+ for await (const chunk of stream)
1109
+ target.write(chunk);
1110
+ }
1111
+ function line(message) {
1112
+ return message.endsWith(`
1113
+ `) ? message : `${message}
1114
+ `;
1115
+ }
1116
+ function reportProblems(problems) {
1117
+ if (problems.length === 0) {
1118
+ process.stderr.write(line("resolution failed with no reported detail"));
1119
+ process.exit(Exit.Resolution);
1120
+ }
1121
+ const groups = new Map;
1122
+ for (const problem of problems) {
1123
+ const group = groups.get(problem.where);
1124
+ if (group)
1125
+ group.push(problem);
1126
+ else
1127
+ groups.set(problem.where, [problem]);
1128
+ }
1129
+ const out = [`${problems.length} problem${problems.length === 1 ? "" : "s"}:`];
1130
+ for (const [where, group] of groups) {
1131
+ out.push(` ${where}`);
1132
+ for (const problem of group) {
1133
+ out.push(` ${problem.message}`);
1134
+ if (problem.suggestion)
1135
+ out.push(` did you mean ${problem.suggestion}?`);
1136
+ }
1137
+ }
1138
+ process.stderr.write(`${out.join(`
1139
+ `)}
1140
+ `);
1141
+ process.exit(Exit.Resolution);
1142
+ }
1143
+ function planWarnings(plan) {
1144
+ if (!plan.warnOnStale)
1145
+ return plan.warnings;
1146
+ const stale = plan.mods.filter((mod) => mod.staleReport !== undefined).map((mod) => staleWarning(mod.packageId, mod.staleReport));
1147
+ return [...plan.warnings, ...stale];
1148
+ }
1149
+ function planPayload(plan) {
1150
+ return {
1151
+ game: plan.game,
1152
+ profile: plan.profile,
1153
+ ...plan.instance === undefined ? {} : { instance: plan.instance },
1154
+ mode: plan.mode,
1155
+ ...plan.marker === undefined ? {} : { marker: plan.marker },
1156
+ timeoutSeconds: plan.timeoutSeconds,
1157
+ renderWaitSeconds: plan.renderWaitSeconds,
1158
+ profileDir: resolve(plan.profileDir),
1159
+ instanceDir: resolve(plan.instanceDir),
1160
+ containerName: containerName(plan),
1161
+ dataDirHost: resolve(plan.dataDirHost),
1162
+ stageDirHost: resolve(plan.stageDirHost),
1163
+ logsDirHost: resolve(plan.logsDirHost),
1164
+ mods: plan.mods.map((mod) => ({
1165
+ packageId: mod.packageId,
1166
+ kind: mod.kind,
1167
+ hostDir: resolve(mod.hostDir),
1168
+ containerDir: mod.containerDir,
1169
+ origin: mod.explicit ? "explicit" : "auto",
1170
+ stale: mod.stale === true,
1171
+ ...mod.staleReport === undefined ? {} : { staleReport: mod.staleReport },
1172
+ ...mod.workshopId === undefined ? {} : { workshopId: mod.workshopId }
1173
+ })),
1174
+ warnings: planWarnings(plan)
1175
+ };
1176
+ }
1177
+ function printPlan(plan, asJson) {
1178
+ const payload = planPayload(plan);
1179
+ if (asJson) {
1180
+ process.stdout.write(`${JSON.stringify(payload, null, 2)}
1181
+ `);
1182
+ return;
1183
+ }
1184
+ const title = payload.instance === undefined ? `${payload.game} ${payload.profile} (${payload.mode})` : `${payload.game} ${payload.profile} / ${payload.instance} (${payload.mode})`;
1185
+ const out = [
1186
+ title,
1187
+ ` profile ${payload.profileDir}`,
1188
+ ` instance ${payload.instanceDir}`,
1189
+ ` container ${payload.containerName}`,
1190
+ ` data ${payload.dataDirHost}`,
1191
+ ` stage ${payload.stageDirHost}`,
1192
+ ` logs ${payload.logsDirHost}`
1193
+ ];
1194
+ if (payload.marker !== undefined)
1195
+ out.push(` marker ${payload.marker}`);
1196
+ out.push(` timeout ${payload.timeoutSeconds}s, render wait ${payload.renderWaitSeconds}s`);
1197
+ out.push(` mods ${payload.mods.length}`);
1198
+ const width = Math.max(0, ...payload.mods.map((m) => m.packageId.length));
1199
+ for (const mod of payload.mods) {
1200
+ const notes = [mod.kind, mod.origin];
1201
+ if (mod.stale)
1202
+ notes.push("stale");
1203
+ out.push(` ${mod.packageId.padEnd(width)} ${notes.join(" ")} ${mod.hostDir} -> ${mod.containerDir}`);
1204
+ }
1205
+ for (const warning of payload.warnings)
1206
+ out.push(` warning: ${warning}`);
1207
+ process.stdout.write(`${out.join(`
1208
+ `)}
1209
+ `);
1210
+ }
1211
+ function runTimestamp(now = new Date) {
1212
+ return now.toISOString().replace(/[-:.]/g, "");
1213
+ }
1214
+ function openRunLog(logsDir, now) {
1215
+ const runsDir = join3(logsDir, "runs");
1216
+ mkdirSync(runsDir, { recursive: true });
1217
+ const dir = uniqueRunDir(runsDir, runTimestamp(now));
1218
+ mkdirSync(dir);
1219
+ linkCurrent(logsDir, dir);
1220
+ rotateRuns(logsDir, 10);
1221
+ return dir;
1222
+ }
1223
+ var encoder = new TextEncoder;
1224
+ function append(fd, chunk) {
1225
+ writeSync(fd, typeof chunk === "string" ? encoder.encode(chunk) : chunk);
1226
+ }
1227
+ function uniqueRunDir(runsDir, stamp) {
1228
+ let candidate = join3(runsDir, stamp);
1229
+ for (let n = 2;existsSync2(candidate); n++)
1230
+ candidate = join3(runsDir, `${stamp}-${n}`);
1231
+ return candidate;
1232
+ }
1233
+ function linkCurrent(logsDir, target) {
1234
+ const link = join3(logsDir, "current");
1235
+ try {
1236
+ lstatSync(link);
1237
+ unlinkSync(link);
1238
+ } catch {}
1239
+ symlinkSync(join3("runs", basename2(target)), link, "dir");
1240
+ }
1241
+ function rotateRuns(logsDir, keep) {
1242
+ const runsDir = join3(logsDir, "runs");
1243
+ if (keep < 1 || !existsSync2(runsDir))
1244
+ return [];
1245
+ const dirs = readdirSync(runsDir, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort().reverse();
1246
+ const removed = dirs.slice(keep);
1247
+ for (const name of removed)
1248
+ rmSync(join3(runsDir, name), { recursive: true, force: true });
1249
+ return removed;
1250
+ }
1251
+
1252
+ // src/config/load.ts
1253
+ import { parse as parseYaml } from "yaml";
1254
+ import { z as z2 } from "zod";
1255
+ import { access, readdir as readdir2, readFile } from "node:fs/promises";
1256
+ import { homedir as homedir2 } from "node:os";
1257
+ import { dirname as dirname2, join as join5, resolve as resolve3 } from "node:path";
1258
+
1259
+ // src/plugin.ts
1260
+ import { readFileSync, statSync } from "node:fs";
1261
+ import { dirname, isAbsolute, join as join4, resolve as resolve2 } from "node:path";
1262
+ import { pathToFileURL } from "node:url";
1263
+ import { exports as exportsField, legacy } from "resolve.exports";
1264
+ var PLUGIN_API_VERSION = 1;
1265
+ var REQUIRED_FUNCTIONS = [
1266
+ "parseManifest",
1267
+ "renderModsConfig",
1268
+ "mergePrefs",
1269
+ "parseVersion"
1270
+ ];
1271
+ function fail(spec, message, detail) {
1272
+ throw new GamecrateError(`plugin "${spec}": ${message}`, Exit.Config, detail);
1273
+ }
1274
+ function entryOf(dir) {
1275
+ let manifest;
1276
+ try {
1277
+ manifest = JSON.parse(readFileSync(join4(dir, "package.json"), "utf8"));
1278
+ } catch {
1279
+ return resolve2(dir, "index.js");
1280
+ }
1281
+ let entry;
1282
+ try {
1283
+ entry = exportsField(manifest, ".", { conditions: ["bun"] })?.[0];
1284
+ } catch {}
1285
+ entry ??= legacy(manifest, { fields: ["module", "main"] });
1286
+ return resolve2(dir, entry ?? "index.js");
1287
+ }
1288
+ function packageDir(spec, from) {
1289
+ let dir = resolve2(from);
1290
+ for (;; ) {
1291
+ const candidate = join4(dir, "node_modules", spec);
1292
+ if (statSync(join4(candidate, "package.json"), { throwIfNoEntry: false })?.isFile())
1293
+ return candidate;
1294
+ const parent = dirname(dir);
1295
+ if (parent === dir)
1296
+ return null;
1297
+ dir = parent;
1298
+ }
1299
+ }
1300
+ function locate(spec, from) {
1301
+ const expanded = expandHome(spec);
1302
+ let target;
1303
+ if (expanded.startsWith(".") || isAbsolute(expanded)) {
1304
+ target = resolve2(from, expanded);
1305
+ } else {
1306
+ target = packageDir(expanded, from);
1307
+ if (target === null) {
1308
+ fail(spec, `cannot be resolved from ${from}`, "install it, or give a path starting with ./");
1309
+ }
1310
+ }
1311
+ return statSync(target, { throwIfNoEntry: false })?.isDirectory() ? entryOf(target) : target;
1312
+ }
1313
+ function check(spec, value) {
1314
+ if (typeof value !== "object" || value === null)
1315
+ fail(spec, "has no default export");
1316
+ const plugin = value;
1317
+ if (plugin.apiVersion !== PLUGIN_API_VERSION) {
1318
+ fail(spec, `speaks apiVersion ${String(plugin.apiVersion)}, this build speaks ${PLUGIN_API_VERSION}`);
1319
+ }
1320
+ if (typeof plugin.game !== "string" || plugin.game === "")
1321
+ fail(spec, "declares no game name");
1322
+ const missing = REQUIRED_FUNCTIONS.filter((name) => typeof plugin[name] !== "function");
1323
+ if (missing.length > 0)
1324
+ fail(spec, `is missing ${missing.join(", ")}`);
1325
+ if (typeof plugin.defaults !== "object" || plugin.defaults === null) {
1326
+ fail(spec, "declares no defaults object");
1327
+ }
1328
+ if (typeof plugin.windowedPrefs !== "object" || plugin.windowedPrefs === null) {
1329
+ fail(spec, "declares no windowedPrefs object");
1330
+ }
1331
+ return plugin;
1332
+ }
1333
+ async function loadPlugins(specs, configFile) {
1334
+ const from = dirname(configFile);
1335
+ const out = new Map;
1336
+ for (const spec of specs) {
1337
+ const target = locate(spec, from);
1338
+ let module;
1339
+ try {
1340
+ module = await import(pathToFileURL(target).href);
1341
+ } catch (error) {
1342
+ fail(spec, `failed to load ${target}`, error instanceof Error ? error.message : String(error));
1343
+ }
1344
+ const plugin = check(spec, module.default);
1345
+ if (out.has(plugin.game))
1346
+ fail(spec, `also claims the game "${plugin.game}"`);
1347
+ out.set(plugin.game, plugin);
1348
+ }
1349
+ return out;
1350
+ }
1351
+ function requirePlugin(plugins, game) {
1352
+ const plugin = plugins.get(game);
1353
+ if (plugin === undefined) {
1354
+ throw new GamecrateError(`no plugin provides the game "${game}"`, Exit.Config, `loaded plugins: ${[...plugins.keys()].join(", ") || "(none)"}`);
1355
+ }
1356
+ return plugin;
1357
+ }
1358
+
1359
+ // src/config/builtin.ts
1360
+ var DEFAULT_DATA_ROOT = "~/.local/share/gamecrate";
1361
+ var DEFAULT_SETTINGS = {
1362
+ width: 1920,
1363
+ height: 1080,
1364
+ devMode: true,
1365
+ runInBackground: true,
1366
+ resetModsConfigOnCrash: false,
1367
+ gpu: true,
1368
+ audio: true,
1369
+ input: false,
1370
+ network: "bridge",
1371
+ display: "x11",
1372
+ memory: "8g",
1373
+ cpus: 6,
1374
+ pidsLimit: 1024
1375
+ };
1376
+
1377
+ // src/config/jsonc.ts
1378
+ import { parse, printParseErrorCode } from "jsonc-parser";
1379
+ function parseJsonc(text) {
1380
+ const errors = [];
1381
+ const value = parse(text, errors, { allowTrailingComma: true, allowEmptyContent: false });
1382
+ const first = errors[0];
1383
+ if (first !== undefined) {
1384
+ throw new GamecrateError("config is not valid JSON", Exit.Config, `${printParseErrorCode(first.error)} at offset ${first.offset}`);
1385
+ }
1386
+ return value;
1387
+ }
1388
+
1389
+ // src/config/validate.ts
1390
+ import { z } from "zod";
1391
+ var MODES2 = ["headed", "headless", "screenshot"];
1392
+ var HINTS = "\x00gamecrate/hints:";
1393
+ function obj(shape) {
1394
+ const known = Object.keys(shape);
1395
+ return z.strictObject(shape, {
1396
+ error: (issue) => issue.code === "unrecognized_keys" ? HINTS + JSON.stringify(issue.keys.map((key) => suggest(key, known) ?? null)) : "expected an object"
1397
+ });
1398
+ }
1399
+ function hintsFor(message) {
1400
+ if (!message.startsWith(HINTS))
1401
+ return [];
1402
+ return JSON.parse(message.slice(HINTS.length));
1403
+ }
1404
+ function requiredWhen(key, when) {
1405
+ return (ctx) => {
1406
+ if (!when(ctx.value) || ctx.value[key] !== undefined)
1407
+ return;
1408
+ ctx.issues.push({ code: "custom", message: `missing required key "${key}"`, path: [key], input: ctx.value });
1409
+ };
1410
+ }
1411
+ var str = z.string({ error: "expected a string" });
1412
+ var num = z.number({ error: "expected a number" });
1413
+ var bool = z.boolean({ error: "expected a boolean" });
1414
+ var strArray = z.array(z.string({ error: "expected an array of strings" }), {
1415
+ error: "expected an array of strings"
1416
+ });
1417
+ var strMap = z.record(z.string(), z.string({ error: "expected an object of string values" }), {
1418
+ error: "expected an object of string values"
1419
+ });
1420
+ function oneOf(values) {
1421
+ return z.enum(values, { error: `expected one of ${values.join(", ")}` });
1422
+ }
1423
+ var modeName = z.unknown().check((ctx) => {
1424
+ const value = ctx.value;
1425
+ if (typeof value === "string" && MODES2.includes(value))
1426
+ return;
1427
+ const hint = typeof value === "string" ? suggest(value, MODES2) : undefined;
1428
+ ctx.issues.push({
1429
+ code: "custom",
1430
+ message: `expected one of ${MODES2.join(", ")}`,
1431
+ input: value,
1432
+ ...hint === undefined ? {} : { params: { suggestion: `did you mean "${hint}"?` } }
1433
+ });
1434
+ });
1435
+ var settings = obj({
1436
+ width: num.optional(),
1437
+ height: num.optional(),
1438
+ devMode: bool.optional(),
1439
+ runInBackground: bool.optional(),
1440
+ resetModsConfigOnCrash: bool.optional(),
1441
+ gpu: bool.optional(),
1442
+ audio: bool.optional(),
1443
+ input: bool.optional(),
1444
+ network: oneOf(["none", "bridge", "host"]).optional(),
1445
+ display: oneOf(["x11", "wayland"]).optional(),
1446
+ memory: str.optional(),
1447
+ cpus: num.optional(),
1448
+ pidsLimit: num.optional(),
1449
+ prefsExtra: strMap.optional(),
1450
+ gameArgs: strArray.optional(),
1451
+ dockerArgs: strArray.optional()
1452
+ });
1453
+ var dynamicModEntry = obj({
1454
+ match: str,
1455
+ first: strArray.optional(),
1456
+ sort: oneOf(["alpha", "none"]).optional(),
1457
+ minMatches: num.optional()
1458
+ });
1459
+ var objectModEntry = obj({
1460
+ id: str,
1461
+ workshop: num.optional(),
1462
+ path: str.optional(),
1463
+ optional: bool.optional()
1464
+ });
1465
+ var modEntry = z.unknown().check((ctx) => {
1466
+ const value = ctx.value;
1467
+ if (typeof value === "string") {
1468
+ if (value.trim() === "")
1469
+ ctx.issues.push({ code: "custom", message: "mod entry is empty", input: value });
1470
+ return;
1471
+ }
1472
+ if (!isObj(value)) {
1473
+ ctx.issues.push({ code: "custom", message: "expected a packageId string or an object", input: value });
1474
+ return;
1475
+ }
1476
+ const schema = value["match"] !== undefined ? dynamicModEntry : objectModEntry;
1477
+ const result = schema.safeParse(value);
1478
+ if (result.success)
1479
+ return;
1480
+ for (const issue of result.error.issues)
1481
+ ctx.issues.push({ ...issue, input: value });
1482
+ });
1483
+ var profile = obj({
1484
+ mods: z.array(modEntry, { error: "expected an array" }).optional(),
1485
+ extends: str.optional(),
1486
+ exclude: strArray.optional(),
1487
+ includeBase: bool.optional(),
1488
+ autoDependencies: bool.optional(),
1489
+ settings: settings.optional(),
1490
+ instances: z.record(z.string(), obj({ worktree: str.optional(), settings: settings.optional() }), {
1491
+ error: "expected an object"
1492
+ }).optional(),
1493
+ alias: str.optional(),
1494
+ aliases: strArray.optional()
1495
+ }).check((ctx) => {
1496
+ const v = ctx.value;
1497
+ if (v.alias !== undefined && (v.extends !== undefined || v.mods !== undefined)) {
1498
+ ctx.issues.push({
1499
+ code: "custom",
1500
+ message: 'an alias profile cannot also declare "mods" or "extends"',
1501
+ input: v
1502
+ });
1503
+ }
1504
+ });
1505
+ var game = obj({
1506
+ gameFiles: obj({ source: oneOf(["mount", "image"]), host: str.optional(), container: str }).check(requiredWhen("host", (v) => v["source"] === "mount")),
1507
+ dataDir: obj({
1508
+ container: str,
1509
+ mode: oneOf(["arg", "env"]),
1510
+ arg: str.optional(),
1511
+ env: strMap.optional()
1512
+ }).check(requiredWhen("arg", (v) => v["mode"] === "arg"), requiredWhen("env", (v) => v["mode"] === "env")),
1513
+ modsDir: obj({ container: str, mask: strArray.optional() }),
1514
+ logFile: obj({ mode: oneOf(["arg", "copy-out"]), arg: str.optional(), from: str.optional() }).check(requiredWhen("arg", (v) => v["mode"] === "arg"), requiredWhen("from", (v) => v["mode"] === "copy-out")),
1515
+ image: obj({ ref: str, acquire: oneOf(["pull", "build"]), context: str.optional() }).check(requiredWhen("context", (v) => v["acquire"] === "build")),
1516
+ executable: str,
1517
+ steamAppId: num,
1518
+ workshopRoot: z.union([z.string(), z.null()], { error: "expected a string or null" }),
1519
+ scanRoots: z.array(obj({ path: str, maxDepth: num, exclude: strArray.optional() }), {
1520
+ error: "expected an array"
1521
+ }),
1522
+ manifest: obj({ file: str }),
1523
+ modsConfig: obj({ file: str }),
1524
+ prefs: obj({ file: str }),
1525
+ saveExtensions: strArray,
1526
+ core: str,
1527
+ dlc: strArray,
1528
+ preCore: strArray.optional(),
1529
+ base: strArray.optional(),
1530
+ library: z.record(z.string(), obj({ workshop: num.optional(), path: str.optional() }).check((ctx) => {
1531
+ if (ctx.value["workshop"] === undefined && ctx.value["path"] === undefined) {
1532
+ ctx.issues.push({
1533
+ code: "custom",
1534
+ message: 'library entry needs a "workshop" id or a "path"',
1535
+ input: ctx.value
1536
+ });
1537
+ }
1538
+ }), { error: "expected an object" }).optional(),
1539
+ modes: z.array(modeName, { error: "expected a non-empty array" }).min(1, {
1540
+ error: "expected a non-empty array"
1541
+ }),
1542
+ aliases: strMap.optional(),
1543
+ settings: settings.optional(),
1544
+ ignoresWmDelete: bool.optional(),
1545
+ profiles: z.record(z.string(), profile, { error: "expected an object" })
1546
+ });
1547
+ var root = obj({
1548
+ plugins: strArray.optional(),
1549
+ dataRoot: str,
1550
+ defaults: obj({ settings: settings.optional() }).optional(),
1551
+ games: z.unknown()
1552
+ });
1553
+ function validateConfig(cfg) {
1554
+ const problems = [];
1555
+ if (!isObj(cfg)) {
1556
+ problems.push({ where: "", message: "expected the config to be an object" });
1557
+ return { config: { dataRoot: "", games: {} }, problems };
1558
+ }
1559
+ collect2(problems, "", root, cfg);
1560
+ const games = cfg["games"];
1561
+ if (!isObj(games)) {
1562
+ problems.push({ where: "/games", message: 'missing required key "games", or it is not an object' });
1563
+ return { config: cfg, problems };
1564
+ }
1565
+ for (const [name, entry] of Object.entries(games)) {
1566
+ collect2(problems, `/games/${esc(name)}`, game, entry);
1567
+ }
1568
+ crossReference(problems, games);
1569
+ return { config: cfg, problems };
1570
+ }
1571
+ function collect2(problems, prefix, schema, value) {
1572
+ const result = schema.safeParse(value);
1573
+ if (result.success)
1574
+ return;
1575
+ for (const issue of result.error.issues) {
1576
+ if (issue.code === "unrecognized_keys") {
1577
+ const hints = hintsFor(issue.message);
1578
+ issue.keys.forEach((key2, i) => {
1579
+ const problem2 = {
1580
+ where: `${prefix}${pointer(issue.path)}/${esc(key2)}`,
1581
+ message: `unknown key "${key2}"`
1582
+ };
1583
+ const hint2 = hints[i];
1584
+ if (hint2 != null)
1585
+ problem2.suggestion = `did you mean "${hint2}"?`;
1586
+ problems.push(problem2);
1587
+ });
1588
+ continue;
1589
+ }
1590
+ const key = issue.path.at(-1);
1591
+ const missing = typeof key === "string" && valueAt(value, issue.path) === undefined;
1592
+ const problem = {
1593
+ where: `${prefix}${pointer(issue.path)}`,
1594
+ message: missing ? `missing required key "${key}"` : issue.message
1595
+ };
1596
+ const hint = issue.params?.suggestion;
1597
+ if (hint !== undefined)
1598
+ problem.suggestion = hint;
1599
+ problems.push(problem);
1600
+ }
1601
+ }
1602
+ function pointer(path) {
1603
+ return path.map((segment) => `/${esc(String(segment))}`).join("");
1604
+ }
1605
+ function valueAt(root_, path) {
1606
+ let current = root_;
1607
+ for (const segment of path) {
1608
+ if (current === null || typeof current !== "object")
1609
+ return;
1610
+ current = own(current, String(segment));
1611
+ }
1612
+ return current;
1613
+ }
1614
+ function crossReference(p, games) {
1615
+ for (const [gameName, game_] of Object.entries(games)) {
1616
+ const where = `/games/${esc(gameName)}`;
1617
+ checkName(p, where, gameName, "game");
1618
+ if (!isObj(game_))
1619
+ continue;
1620
+ const profiles = game_["profiles"];
1621
+ if (!isObj(profiles))
1622
+ continue;
1623
+ const names = Object.keys(profiles);
1624
+ for (const [name, prof] of Object.entries(profiles)) {
1625
+ const w = `${where}/profiles/${esc(name)}`;
1626
+ checkName(p, w, name, "profile");
1627
+ if (!isObj(prof))
1628
+ continue;
1629
+ const parent = prof["extends"];
1630
+ if (typeof parent === "string" && !Object.hasOwn(profiles, parent)) {
1631
+ const prob = { where: `${w}/extends`, message: `extends unknown profile "${parent}"` };
1632
+ const hint = suggest(parent, names);
1633
+ if (hint)
1634
+ prob.suggestion = `did you mean "${hint}"?`;
1635
+ p.push(prob);
1636
+ }
1637
+ const alias = prof["alias"];
1638
+ if (typeof alias === "string" && alias !== "modless" && !Object.hasOwn(profiles, alias)) {
1639
+ const prob = { where: `${w}/alias`, message: `alias of unknown profile "${alias}"` };
1640
+ const hint = suggest(alias, [...names, "modless"]);
1641
+ if (hint)
1642
+ prob.suggestion = `did you mean "${hint}"?`;
1643
+ p.push(prob);
1644
+ }
1645
+ if (typeof alias === "string" && alias === name) {
1646
+ p.push({ where: `${w}/alias`, message: "a profile cannot alias itself" });
1647
+ }
1648
+ const aliases = prof["aliases"];
1649
+ if (Array.isArray(aliases)) {
1650
+ for (const [i, entry] of aliases.entries()) {
1651
+ if (typeof entry !== "string")
1652
+ continue;
1653
+ const at = `${w}/aliases/${i}`;
1654
+ checkName(p, at, entry, "profile alias");
1655
+ if (names.some((k) => k.toLowerCase() === entry.toLowerCase())) {
1656
+ p.push({ where: at, message: `alias "${entry}" is already a profile name` });
1657
+ }
1658
+ }
1659
+ }
1660
+ const instances = prof["instances"];
1661
+ if (isObj(instances)) {
1662
+ for (const instance of Object.keys(instances)) {
1663
+ checkName(p, `${w}/instances/${esc(instance)}`, instance, "instance");
1664
+ }
1665
+ }
1666
+ }
1667
+ }
1668
+ }
1669
+ function checkName(p, where, name, kind) {
1670
+ if (RESERVED_NAMES.includes(name)) {
1671
+ p.push({ where, message: `"${name}" is a reserved name and cannot be used as a ${kind} name` });
1672
+ return;
1673
+ }
1674
+ if (!NAME_PATTERN.test(name)) {
1675
+ p.push({ where, message: `${kind} name "${name}" must match ${NAME_PATTERN.source}` });
1676
+ }
1677
+ }
1678
+ function esc(segment) {
1679
+ return segment.replace(/~/g, "~0").replace(/\//g, "~1");
1680
+ }
1681
+ function isObj(v) {
1682
+ return typeof v === "object" && v !== null && !Array.isArray(v);
1683
+ }
1684
+
1685
+ // src/config/load.ts
1686
+ function defaultConfigPath() {
1687
+ const base = process.env["XDG_CONFIG_HOME"] ?? join5(homedir2(), ".config");
1688
+ return join5(base, "gamecrate", "profiles.json");
1689
+ }
1690
+ var PROJECT_CONFIG = ".gamecrate.yml";
1691
+ var projectName = z2.custom((v) => typeof v === "string" && NAME_PATTERN.test(v), "expected a name");
1692
+ var projectStr = z2.string({ error: "expected a string" });
1693
+ var projectBool = z2.boolean({ error: "expected true or false" });
1694
+ var projectList = z2.custom((v) => Array.isArray(v) && v.every((entry) => typeof entry === "string"), "expected an array of strings");
1695
+ var projectSeconds = z2.custom((v) => Number.isSafeInteger(v) && v >= 0, "expected a whole number of seconds");
1696
+ function oneOf2(values) {
1697
+ return z2.enum(values, { error: `expected one of ${values.join(", ")}` });
1698
+ }
1699
+ var BUILD_POLICIES2 = ["auto", "always", "never"];
1700
+ var projectResolution = z2.string({ error: "expected dimensions like 1920x1080" }).check((ctx) => {
1701
+ try {
1702
+ parseResolution(ctx.value);
1703
+ } catch (error) {
1704
+ ctx.issues.push({ code: "custom", message: error.message, input: ctx.value });
1705
+ }
1706
+ }).transform(parseResolution);
1707
+ var PROJECT_SCHEMA = z2.strictObject({
1708
+ game: projectName.optional(),
1709
+ profile: projectName.optional(),
1710
+ mods: projectList.optional(),
1711
+ without: projectList.optional(),
1712
+ only: projectList.optional(),
1713
+ dockerArgs: projectList.optional(),
1714
+ gameArgs: projectList.optional(),
1715
+ worktree: projectList.optional(),
1716
+ use: projectList.optional(),
1717
+ marker: projectStr.optional(),
1718
+ instance: projectStr.optional(),
1719
+ log: projectStr.optional(),
1720
+ timeout: projectSeconds.optional(),
1721
+ renderWait: projectSeconds.optional(),
1722
+ dryRun: projectBool.optional(),
1723
+ printPlan: projectBool.optional(),
1724
+ json: projectBool.optional(),
1725
+ root: projectBool.optional(),
1726
+ noWorktree: projectBool.optional(),
1727
+ noStaleCheck: projectBool.optional(),
1728
+ replace: projectBool.optional(),
1729
+ mode: oneOf2(["headed", "headless", "screenshot"]).optional(),
1730
+ pull: oneOf2(["always", "missing", "never"]).optional(),
1731
+ sort: oneOf2(["topo", "none"]).optional(),
1732
+ network: oneOf2(["none", "bridge", "host"]).optional(),
1733
+ build: z2.union([z2.boolean().transform((on) => on ? "always" : "never"), z2.enum(BUILD_POLICIES2)], {
1734
+ error: `expected one of ${BUILD_POLICIES2.join(", ")}`
1735
+ }).optional(),
1736
+ resolution: projectResolution.optional()
1737
+ }, { error: "expected an object" });
1738
+ async function findProjectConfig(start = process.cwd()) {
1739
+ let dir = resolve3(start);
1740
+ for (;; ) {
1741
+ const file = join5(dir, PROJECT_CONFIG);
1742
+ try {
1743
+ await access(file);
1744
+ return file;
1745
+ } catch (error) {
1746
+ if (error.code !== "ENOENT")
1747
+ throw error;
1748
+ }
1749
+ const parent = dirname2(dir);
1750
+ if (parent === dir)
1751
+ return;
1752
+ dir = parent;
1753
+ }
1754
+ }
1755
+ async function loadProjectDefaults(start = process.cwd()) {
1756
+ const file = await findProjectConfig(start);
1757
+ if (file === undefined)
1758
+ return {};
1759
+ let raw;
1760
+ try {
1761
+ raw = parseYaml(await readFile(file, "utf8"));
1762
+ } catch (error) {
1763
+ throw new GamecrateError(`project config is invalid: ${file}`, Exit.Config, error.message);
1764
+ }
1765
+ return validateProjectDefaults(raw, file);
1766
+ }
1767
+ function validateProjectDefaults(raw, file) {
1768
+ if (raw === null)
1769
+ return {};
1770
+ const result = PROJECT_SCHEMA.safeParse(raw);
1771
+ if (result.success)
1772
+ return result.data;
1773
+ const problems = [];
1774
+ for (const issue of result.error.issues) {
1775
+ if (issue.code === "unrecognized_keys") {
1776
+ for (const key of issue.keys)
1777
+ problems.push(` /${key}: unknown key`);
1778
+ continue;
1779
+ }
1780
+ problems.push(` /${issue.path.join("/")}: ${issue.message}`);
1781
+ }
1782
+ throw new GamecrateError(`project config is invalid: ${file}`, Exit.Config, problems.join(`
1783
+ `));
1784
+ }
1785
+ async function loadConfig(path) {
1786
+ const file = path ?? defaultConfigPath();
1787
+ let user;
1788
+ try {
1789
+ user = parseJsonc(await readFile(file, "utf8"));
1790
+ } catch (err) {
1791
+ if (err instanceof GamecrateError) {
1792
+ throw new GamecrateError(`${err.message}: ${file}`, err.code, err.detail);
1793
+ }
1794
+ if (err.code !== "ENOENT")
1795
+ throw err;
1796
+ }
1797
+ const specs = isObj(user) && user["plugins"] !== undefined ? user["plugins"] : [];
1798
+ if (!Array.isArray(specs) || specs.some((s) => typeof s !== "string")) {
1799
+ throw new GamecrateError(`config is invalid: ${file}`, Exit.Config, " /plugins: expected an array of strings");
1800
+ }
1801
+ const plugins = await loadPlugins(specs, file);
1802
+ const base = {
1803
+ dataRoot: DEFAULT_DATA_ROOT,
1804
+ defaults: { settings: structuredClone(DEFAULT_SETTINGS) },
1805
+ games: Object.fromEntries([...plugins].map(([name, plugin]) => [name, structuredClone(plugin.defaults)]))
1806
+ };
1807
+ const { config, problems } = validateConfig(user === undefined ? base : deepMerge(base, user));
1808
+ if (problems.length > 0) {
1809
+ const detail = problems.map((p) => {
1810
+ const hint = p.suggestion ? ` (${p.suggestion})` : "";
1811
+ return ` ${p.where || "/"}: ${p.message}${hint}${origin(p.where, user, plugins)}`;
1812
+ }).join(`
1813
+ `);
1814
+ const merged = plugins.size === 0 ? "" : ` (merged with defaults from: ${[...plugins.keys()].join(", ")})`;
1815
+ throw new GamecrateError(`config is invalid: ${file}${merged}`, Exit.Config, detail);
1816
+ }
1817
+ return { config: expandPaths(config), plugins };
1818
+ }
1819
+ function origin(where, user, plugins) {
1820
+ if (!where.startsWith("/"))
1821
+ return "";
1822
+ const segments = where.slice(1).split("/").map((s) => s.replace(/~1/g, "/").replace(/~0/g, "~"));
1823
+ if (valueAt2(user, segments) !== undefined)
1824
+ return "";
1825
+ const [section, name, ...rest] = segments;
1826
+ const plugin = section === "games" && name !== undefined ? plugins.get(name) : undefined;
1827
+ if (plugin === undefined)
1828
+ return " <- not in this file";
1829
+ return valueAt2(plugin.defaults, rest) === undefined ? ` <- not in this file, and the ${name} plugin's defaults do not supply it` : ` <- from the ${name} plugin's defaults, not this file`;
1830
+ }
1831
+ function valueAt2(value, segments) {
1832
+ let current = value;
1833
+ for (const segment of segments) {
1834
+ if (Array.isArray(current))
1835
+ current = current[Number(segment)];
1836
+ else if (isObj(current))
1837
+ current = own(current, segment);
1838
+ else
1839
+ return;
1840
+ }
1841
+ return current;
1842
+ }
1843
+ function resolveSettings(root2, game2, profile2, ...overrides) {
1844
+ let out = structuredClone(DEFAULT_SETTINGS);
1845
+ for (const layer of [root2.defaults?.settings, game2.settings, profile2.settings, ...overrides]) {
1846
+ if (layer)
1847
+ out = deepMerge(out, layer, true);
1848
+ }
1849
+ return out;
1850
+ }
1851
+ function resolveProfile(game2, name) {
1852
+ return resolveNamed(game2, name, []);
1853
+ }
1854
+ function resolveNamed(game2, name, seen) {
1855
+ if (name.toLowerCase() === "modless")
1856
+ return { mods: [], exclude: [], includeBase: false };
1857
+ const key = profileKey(game2, name);
1858
+ if (key === undefined) {
1859
+ throw new GamecrateError(`unknown profile "${name}"`, Exit.Resolution, `known profiles: ${Object.keys(game2.profiles).join(", ") || "(none)"}, modless`);
1860
+ }
1861
+ if (seen.includes(key)) {
1862
+ throw new GamecrateError(`profile "${key}" inherits from itself`, Exit.Config, [...seen, key].join(" -> "));
1863
+ }
1864
+ const self = own(game2.profiles, key);
1865
+ if (self.alias !== undefined)
1866
+ return resolveNamed(game2, self.alias, [...seen, key]);
1867
+ const parent = self.extends !== undefined ? resolveNamed(game2, self.extends, [...seen, key]) : {};
1868
+ const exclude = [...parent.exclude ?? [], ...self.exclude ?? []];
1869
+ const out = {
1870
+ mods: subtract([...parent.mods ?? [], ...self.mods ?? []], exclude),
1871
+ exclude,
1872
+ settings: deepMerge(parent.settings ?? {}, self.settings ?? {}, true)
1873
+ };
1874
+ const instances = deepMerge(parent.instances ?? {}, self.instances ?? {}, true);
1875
+ if (Object.keys(instances).length > 0)
1876
+ out.instances = instances;
1877
+ const includeBase = self.includeBase ?? parent.includeBase;
1878
+ if (includeBase !== undefined)
1879
+ out.includeBase = includeBase;
1880
+ const auto = self.autoDependencies ?? parent.autoDependencies;
1881
+ if (auto !== undefined)
1882
+ out.autoDependencies = auto;
1883
+ return out;
1884
+ }
1885
+ function canonicalProfile(game2, name) {
1886
+ if (name.toLowerCase() === "modless")
1887
+ return "modless";
1888
+ const seen = [];
1889
+ let current = name;
1890
+ for (;; ) {
1891
+ const key = profileKey(game2, current);
1892
+ if (key === undefined || seen.includes(key))
1893
+ return key ?? current;
1894
+ const next = own(game2.profiles, key)?.alias;
1895
+ if (next === undefined)
1896
+ return key;
1897
+ seen.push(key);
1898
+ current = next;
1899
+ }
1900
+ }
1901
+ function profileDataDir(root2, game2, profile2) {
1902
+ return resolve3(expandHome(root2.dataRoot), game2, canonicalProfile(own(root2.games, game2), profile2));
1903
+ }
1904
+ async function profileDirs(root2, game2, profile2) {
1905
+ if (profile2 !== undefined)
1906
+ return [profileDataDir(root2, game2, profile2)];
1907
+ const dir = join5(expandHome(root2.dataRoot), game2);
1908
+ return (await readdir2(dir).catch(() => [])).map((name) => join5(dir, name));
1909
+ }
1910
+ function profileKey(game2, name) {
1911
+ if (Object.hasOwn(game2.profiles, name))
1912
+ return name;
1913
+ const lower = name.toLowerCase();
1914
+ const direct = Object.keys(game2.profiles).find((k) => k.toLowerCase() === lower);
1915
+ if (direct !== undefined)
1916
+ return direct;
1917
+ return Object.keys(game2.profiles).find((k) => (own(game2.profiles, k)?.aliases ?? []).some((a) => a.toLowerCase() === lower));
1918
+ }
1919
+ function subtract(mods, exclude) {
1920
+ if (exclude.length === 0)
1921
+ return mods;
1922
+ const patterns = exclude.map(globToRegExp);
1923
+ return mods.filter((entry) => {
1924
+ const ids = entryIds(entry);
1925
+ if (ids.length === 0)
1926
+ return true;
1927
+ return !ids.some((id) => patterns.some((re) => re.test(id)));
1928
+ });
1929
+ }
1930
+ function entryIds(entry) {
1931
+ if (typeof entry === "string")
1932
+ return [entry, entry.replace(/^(workshop|path):/, "")];
1933
+ if ("id" in entry)
1934
+ return [entry.id];
1935
+ return [];
1936
+ }
1937
+ function globToRegExp(pattern) {
1938
+ const body = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".");
1939
+ return new RegExp(`^${body}$`, "i");
1940
+ }
1941
+ function deepMerge(base, over, concatArrays = false) {
1942
+ if (Array.isArray(base) && Array.isArray(over)) {
1943
+ return concatArrays ? [...base, ...over] : [...over];
1944
+ }
1945
+ if (isObj(base) && isObj(over)) {
1946
+ const out = { ...base };
1947
+ for (const [k, v] of Object.entries(over)) {
1948
+ if (v === undefined)
1949
+ continue;
1950
+ out[k] = Object.hasOwn(out, k) ? deepMerge(out[k], v, concatArrays) : v;
1951
+ }
1952
+ return out;
1953
+ }
1954
+ return over;
1955
+ }
1956
+ function expandPaths(config) {
1957
+ config.dataRoot = expandHome(config.dataRoot);
1958
+ for (const game2 of Object.values(config.games)) {
1959
+ if (game2.gameFiles.host !== undefined)
1960
+ game2.gameFiles.host = expandHome(game2.gameFiles.host);
1961
+ if (game2.image.context !== undefined)
1962
+ game2.image.context = expandHome(game2.image.context);
1963
+ if (game2.workshopRoot !== null)
1964
+ game2.workshopRoot = expandHome(game2.workshopRoot);
1965
+ for (const root2 of game2.scanRoots)
1966
+ root2.path = expandHome(root2.path);
1967
+ for (const entry of Object.values(game2.library ?? {})) {
1968
+ if (entry.path !== undefined)
1969
+ entry.path = expandHome(entry.path);
1970
+ }
1971
+ }
1972
+ return config;
1973
+ }
1974
+ function expandHome(p) {
1975
+ if (p === "~")
1976
+ return homedir2();
1977
+ return p.startsWith("~/") ? join5(homedir2(), p.slice(2)) : p;
1978
+ }
1979
+
1980
+ // src/docker/identity.ts
1981
+ import { userInfo } from "node:os";
1982
+ function resolveIdentity(useRoot) {
1983
+ if (useRoot)
1984
+ return { uid: 0, gid: 0, home: "/root", user: "root" };
1985
+ const uid = process.getuid?.() ?? 0;
1986
+ const gid = process.getgid?.() ?? 0;
1987
+ return { uid, gid, home: "/tmp/home", user: hostUserName(uid) };
1988
+ }
1989
+ function hostUserName(uid) {
1990
+ try {
1991
+ const name = userInfo().username;
1992
+ if (name)
1993
+ return name;
1994
+ } catch {}
1995
+ return process.env.USER ?? process.env.LOGNAME ?? `uid-${uid}`;
1996
+ }
1997
+
1998
+ // src/docker/preflight.ts
1999
+ import { existsSync as existsSync3, readFileSync as readFileSync2 } from "node:fs";
2000
+ import { homedir as homedir3 } from "node:os";
2001
+ import { basename as basename3, join as join7 } from "node:path";
2002
+
2003
+ // src/docker/run.ts
2004
+ import { spawn } from "node:child_process";
2005
+ import { createWriteStream, mkdirSync as mkdirSync2 } from "node:fs";
2006
+ import { open, readdir as readdir3, stat as stat2 } from "node:fs/promises";
2007
+ import { join as join6 } from "node:path";
2008
+ import { setTimeout as sleep } from "node:timers/promises";
2009
+ import { TextDecoder } from "node:util";
2010
+ function spawnArgv(argv, stdio) {
2011
+ return spawn(argv[0], argv.slice(1), { stdio });
2012
+ }
2013
+ function exited(proc) {
2014
+ return new Promise((resolve4, reject) => {
2015
+ proc.once("error", reject);
2016
+ proc.once("close", (code) => resolve4(code ?? 1));
2017
+ });
2018
+ }
2019
+ async function collect3(stream) {
2020
+ const chunks = [];
2021
+ for await (const chunk of stream)
2022
+ chunks.push(chunk);
2023
+ return Buffer.concat(chunks).toString("utf8");
2024
+ }
2025
+ async function capture(argv) {
2026
+ try {
2027
+ const proc = spawnArgv(argv, ["ignore", "pipe", "pipe"]);
2028
+ const [stdout, stderr, code] = await Promise.all([
2029
+ collect3(proc.stdout),
2030
+ collect3(proc.stderr),
2031
+ exited(proc)
2032
+ ]);
2033
+ return { code, stdout, stderr };
2034
+ } catch (error) {
2035
+ return { code: 127, stdout: "", stderr: error instanceof Error ? error.message : String(error) };
2036
+ }
2037
+ }
2038
+ var STDOUT_LOG = "stdout.log";
2039
+ var MARKER_POLL_MS = 200;
2040
+ async function runContainer(spec, opts) {
2041
+ const stopTimeout = opts.stopTimeoutSeconds ?? 10;
2042
+ mkdirSync2(opts.logDir, { recursive: true });
2043
+ const sink = createWriteStream(join6(opts.logDir, STDOUT_LOG));
2044
+ const proc = spawnArgv(["docker", ...toDockerArgs(spec)], ["inherit", "pipe", "pipe"]);
2045
+ let interrupted = false;
2046
+ const onSignal = () => {
2047
+ if (interrupted)
2048
+ return;
2049
+ interrupted = true;
2050
+ stopContainer(spec.name, stopTimeout);
2051
+ };
2052
+ process.on("SIGINT", onSignal);
2053
+ process.on("SIGTERM", onSignal);
2054
+ const code = exited(proc);
2055
+ try {
2056
+ await Promise.all([
2057
+ tee(proc.stdout, sink, process.stdout),
2058
+ tee(proc.stderr, sink, process.stderr)
2059
+ ]);
2060
+ const status2 = await code;
2061
+ return interrupted ? Exit.Interrupted : status2;
2062
+ } finally {
2063
+ process.off("SIGINT", onSignal);
2064
+ process.off("SIGTERM", onSignal);
2065
+ await new Promise((resolve4) => sink.end(resolve4));
2066
+ }
2067
+ }
2068
+ async function stopContainer(name, timeoutSeconds) {
2069
+ const proc = spawnArgv(["docker", "stop", "--timeout", String(timeoutSeconds), name], "ignore");
2070
+ await exited(proc).catch(() => {});
2071
+ }
2072
+ async function waitForMarker(sources, marker, timeoutSeconds) {
2073
+ const deadline = Date.now() + timeoutSeconds * 1000;
2074
+ const carry = Math.max(marker.length - 1, 0);
2075
+ const seen = new Map;
2076
+ const startedAt = Date.now();
2077
+ while (true) {
2078
+ for (const path of await expandSources(sources)) {
2079
+ let state = seen.get(path);
2080
+ if (state === undefined) {
2081
+ state = { offset: await staleSize(path, startedAt), tail: "", decoder: new TextDecoder };
2082
+ seen.set(path, state);
2083
+ }
2084
+ if (await scan(path, state, marker, carry))
2085
+ return true;
2086
+ }
2087
+ if (Date.now() >= deadline)
2088
+ return false;
2089
+ await sleep(Math.min(MARKER_POLL_MS, Math.max(deadline - Date.now(), 0)));
2090
+ }
2091
+ }
2092
+ async function staleSize(path, startedAt) {
2093
+ return stat2(path).then((info) => info.mtimeMs < startedAt ? info.size : 0, () => 0);
2094
+ }
2095
+ async function scan(path, state, marker, carry) {
2096
+ const handle = await open(path, "r").catch(() => null);
2097
+ if (handle === null)
2098
+ return false;
2099
+ try {
2100
+ const { size } = await handle.stat();
2101
+ if (size < state.offset) {
2102
+ state.offset = 0;
2103
+ state.tail = "";
2104
+ state.decoder = new TextDecoder;
2105
+ }
2106
+ if (size <= state.offset)
2107
+ return false;
2108
+ const buffer = Buffer.alloc(size - state.offset);
2109
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, state.offset);
2110
+ state.offset += bytesRead;
2111
+ const text = state.tail + state.decoder.decode(buffer.subarray(0, bytesRead), { stream: true });
2112
+ if (text.includes(marker))
2113
+ return true;
2114
+ state.tail = carry > 0 ? text.slice(-carry) : "";
2115
+ return false;
2116
+ } catch {
2117
+ return false;
2118
+ } finally {
2119
+ await handle.close().catch(() => {});
2120
+ }
2121
+ }
2122
+ async function expandSources(sources) {
2123
+ const out = [];
2124
+ for (const source of sources) {
2125
+ const info = await stat2(source).catch(() => null);
2126
+ if (info === null) {
2127
+ out.push(source);
2128
+ continue;
2129
+ }
2130
+ if (!info.isDirectory()) {
2131
+ out.push(source);
2132
+ continue;
2133
+ }
2134
+ const entries = await readdir3(source).catch(() => []);
2135
+ for (const entry of entries) {
2136
+ if (entry.toLowerCase().endsWith(".log"))
2137
+ out.push(join6(source, entry));
2138
+ }
2139
+ }
2140
+ return out;
2141
+ }
2142
+ async function tee(stream, sink, mirror) {
2143
+ for await (const chunk of stream) {
2144
+ mirror.write(chunk);
2145
+ sink.write(chunk);
2146
+ }
2147
+ }
2148
+
2149
+ // src/docker/preflight.ts
2150
+ var CDI_SPEC = "/etc/cdi/nvidia.yaml";
2151
+ async function preflight(plan) {
2152
+ const problems = [];
2153
+ const game2 = plan.gameConfig;
2154
+ const dockerOk = await checkDocker(problems);
2155
+ if (dockerOk) {
2156
+ await checkImage(plan, problems);
2157
+ }
2158
+ if (plan.settings.gpu)
2159
+ checkCdi(problems);
2160
+ checkGameDir(plan, problems);
2161
+ checkBindSources(plan, problems);
2162
+ if (plan.mode === "headed")
2163
+ checkDisplay(plan, problems);
2164
+ if (game2.gameFiles.source === "image" && game2.image.acquire === "build" && !game2.image.context) {
2165
+ problems.push({
2166
+ where: `/games/${plan.game}/image/context`,
2167
+ message: 'image.acquire is "build" but no build context is configured'
2168
+ });
2169
+ }
2170
+ return problems;
2171
+ }
2172
+ async function checkDocker(problems) {
2173
+ const result = await capture(["docker", "version", "--format", "{{.Server.Version}}"]);
2174
+ if (result.code === 0)
2175
+ return true;
2176
+ problems.push({
2177
+ where: "docker",
2178
+ message: `docker is not reachable: ${firstLine(result.stderr) || `exit ${result.code}`}`,
2179
+ suggestion: "start the docker daemon, or check that your user is in the docker group"
2180
+ });
2181
+ return false;
2182
+ }
2183
+ async function checkImageRunnable(ref, where, game2, problems) {
2184
+ const run = await capture(["docker", "run", "--rm", "--entrypoint", "/bin/true", ref]);
2185
+ if (run.code === 0)
2186
+ return;
2187
+ const err = firstLine(run.stderr);
2188
+ const corrupt = /content store|failed to extract layer|not found/i.test(run.stderr);
2189
+ problems.push({
2190
+ where,
2191
+ message: corrupt ? `image ${ref} is present but unrunnable; its layers are missing from the content store` : `image ${ref} is present but failed to start: ${err || `exit ${run.code}`}`,
2192
+ suggestion: corrupt ? `docker image rm ${ref} && docker builder prune -f, then gamecrate build ${game2}` : undefined
2193
+ });
2194
+ }
2195
+ async function checkImage(plan, problems) {
2196
+ const image = plan.gameConfig.image;
2197
+ const where = `/games/${plan.game}/image/ref`;
2198
+ const present = await capture(["docker", "image", "inspect", image.ref]);
2199
+ if (present.code === 0) {
2200
+ await checkImageRunnable(image.ref, where, plan.game, problems);
2201
+ return;
2202
+ }
2203
+ if (image.acquire === "build") {
2204
+ problems.push({
2205
+ where,
2206
+ message: `image ${image.ref} is not present locally`,
2207
+ suggestion: `gamecrate build ${plan.game}`
2208
+ });
2209
+ return;
2210
+ }
2211
+ const remote = await capture(["docker", "manifest", "inspect", image.ref]);
2212
+ if (remote.code === 0)
2213
+ return;
2214
+ const host = registryHost(image.ref);
2215
+ if (host && needsLogin(remote.stderr) && !hasStoredAuth(host)) {
2216
+ problems.push({
2217
+ where,
2218
+ message: `not authenticated to ${host}, so ${image.ref} cannot be pulled`,
2219
+ suggestion: `docker login ${host}`
2220
+ });
2221
+ return;
2222
+ }
2223
+ problems.push({
2224
+ where,
2225
+ message: `image ${image.ref} is not present locally and cannot be pulled: ${firstLine(remote.stderr) || `exit ${remote.code}`}`,
2226
+ suggestion: host ? `docker login ${host}` : undefined
2227
+ });
2228
+ }
2229
+ function checkDisplay(plan, problems) {
2230
+ if (plan.settings.display === "x11") {
2231
+ if (x11Session())
2232
+ return;
2233
+ problems.push({
2234
+ where: "DISPLAY",
2235
+ message: "DISPLAY is not set; a headed X11 launch would open nothing",
2236
+ suggestion: 'set settings.display to "wayland", or use --mode headless'
2237
+ });
2238
+ return;
2239
+ }
2240
+ if (waylandSocket())
2241
+ return;
2242
+ problems.push({
2243
+ where: "WAYLAND_DISPLAY",
2244
+ message: "no wayland socket found; a headed launch would open a blank window",
2245
+ suggestion: "run from a wayland session, or use --mode headless"
2246
+ });
2247
+ }
2248
+ function checkCdi(problems) {
2249
+ if (!existsSync3(CDI_SPEC)) {
2250
+ problems.push({
2251
+ where: CDI_SPEC,
2252
+ message: "no CDI spec, so --device nvidia.com/gpu=all cannot resolve",
2253
+ suggestion: "sudo nvidia-ctk cdi generate --output=/etc/cdi/nvidia.yaml"
2254
+ });
2255
+ return;
2256
+ }
2257
+ let text = "";
2258
+ try {
2259
+ text = readFileSync2(CDI_SPEC, "utf8");
2260
+ } catch (error) {
2261
+ problems.push({ where: CDI_SPEC, message: `CDI spec is unreadable: ${message(error)}` });
2262
+ return;
2263
+ }
2264
+ if (!/^\s*-?\s*name:\s*["']?all["']?\s*$/m.test(text)) {
2265
+ problems.push({
2266
+ where: CDI_SPEC,
2267
+ message: 'CDI spec does not declare a device named "all"',
2268
+ suggestion: "sudo nvidia-ctk cdi generate --output=/etc/cdi/nvidia.yaml"
2269
+ });
2270
+ }
2271
+ }
2272
+ function checkGameDir(plan, problems) {
2273
+ const files = plan.gameConfig.gameFiles;
2274
+ if (files.source !== "mount")
2275
+ return;
2276
+ const where = `/games/${plan.game}/gameFiles/host`;
2277
+ if (!files.host) {
2278
+ problems.push({ where, message: 'gameFiles.source is "mount" but no host path is set' });
2279
+ return;
2280
+ }
2281
+ if (!existsSync3(files.host)) {
2282
+ problems.push({ where, message: `game directory does not exist: ${files.host}` });
2283
+ return;
2284
+ }
2285
+ const executable = join7(files.host, basename3(plan.gameConfig.executable));
2286
+ if (!existsSync3(executable)) {
2287
+ problems.push({
2288
+ where,
2289
+ message: `${files.host} does not contain ${basename3(plan.gameConfig.executable)}`,
2290
+ suggestion: "point gameFiles.host at the install directory, not its parent"
2291
+ });
2292
+ }
2293
+ }
2294
+ function checkBindSources(plan, problems) {
2295
+ let mounts;
2296
+ try {
2297
+ mounts = buildRunSpec(plan, [], resolveIdentity(false)).mounts;
2298
+ } catch (error) {
2299
+ problems.push({
2300
+ where: `/games/${plan.game}`,
2301
+ message: error instanceof GamecrateError ? error.message : message(error),
2302
+ suggestion: error instanceof GamecrateError ? error.detail : undefined
2303
+ });
2304
+ return;
2305
+ }
2306
+ for (const mount of mounts) {
2307
+ if (mount.type !== "bind" || !mount.source)
2308
+ continue;
2309
+ if (existsSync3(mount.source))
2310
+ continue;
2311
+ if (mount.source.startsWith(plan.profileDir))
2312
+ continue;
2313
+ problems.push({
2314
+ where: mount.source,
2315
+ message: `bind source does not exist and would be mounted at ${mount.target}`
2316
+ });
2317
+ }
2318
+ }
2319
+ function registryHost(ref) {
2320
+ const first = ref.split("/")[0];
2321
+ if (!first || !ref.includes("/"))
2322
+ return null;
2323
+ if (first === "localhost" || first.includes(".") || first.includes(":"))
2324
+ return first;
2325
+ return null;
2326
+ }
2327
+ function needsLogin(stderr) {
2328
+ return /unauthorized|authentication required|denied|forbidden/i.test(stderr);
2329
+ }
2330
+ function hasStoredAuth(host) {
2331
+ const path = join7(process.env.DOCKER_CONFIG ?? join7(homedir3(), ".docker"), "config.json");
2332
+ try {
2333
+ const config = JSON.parse(readFileSync2(path, "utf8"));
2334
+ if (config.credsStore || config.credHelpers?.[host])
2335
+ return true;
2336
+ return Object.keys(config.auths ?? {}).some((key) => key === host || key.includes(`//${host}`));
2337
+ } catch {
2338
+ return false;
2339
+ }
2340
+ }
2341
+ function firstLine(text) {
2342
+ return text.trim().split(`
2343
+ `)[0]?.trim() ?? "";
2344
+ }
2345
+ function message(error) {
2346
+ return error instanceof Error ? error.message : String(error);
2347
+ }
2348
+
2349
+ // src/docker/window.ts
2350
+ import { basename as basename4 } from "node:path";
2351
+ import { setTimeout as sleep2 } from "node:timers/promises";
2352
+ var WAIT_MS = 180000;
2353
+ var POLL_MS = 500;
2354
+ async function toplevels() {
2355
+ const { code, stdout } = await capture(["wmctrl", "-lx"]);
2356
+ if (code === 127)
2357
+ return null;
2358
+ const found = [];
2359
+ for (const line2 of stdout.split(`
2360
+ `)) {
2361
+ const [id, , wmClass] = line2.trim().split(/\s+/);
2362
+ if (id !== undefined && wmClass !== undefined)
2363
+ found.push({ id, wmClass });
2364
+ }
2365
+ return found;
2366
+ }
2367
+ async function adoptNewWindow(opts) {
2368
+ const before = await toplevels();
2369
+ if (before === null) {
2370
+ warn("wmctrl is not installed, so the window keeps the game's own title");
2371
+ return { stop: () => {} };
2372
+ }
2373
+ const seen = new Set(before.map((w) => w.id));
2374
+ const wanted = basename4(opts.executable).toLowerCase();
2375
+ let stopped = false;
2376
+ (async () => {
2377
+ const deadline = Date.now() + WAIT_MS;
2378
+ while (!stopped && Date.now() < deadline) {
2379
+ await sleep2(POLL_MS);
2380
+ if (stopped)
2381
+ return;
2382
+ const now = await toplevels() ?? [];
2383
+ const match = now.find((w) => !seen.has(w.id) && w.wmClass.toLowerCase().includes(wanted));
2384
+ if (match === undefined)
2385
+ continue;
2386
+ await capture(["wmctrl", "-i", "-r", match.id, "-N", opts.title]);
2387
+ await adopt(match.id, opts);
2388
+ if (opts.stripDelete)
2389
+ await watchForClose(match.id, () => stopped, opts.onClosed);
2390
+ return;
2391
+ }
2392
+ })();
2393
+ return { stop: () => void (stopped = true) };
2394
+ }
2395
+ async function watchForClose(id, stopped, onClosed) {
2396
+ let misses = 0;
2397
+ while (!stopped()) {
2398
+ await sleep2(POLL_MS);
2399
+ if (stopped())
2400
+ return;
2401
+ const now = await toplevels();
2402
+ if (now === null)
2403
+ return;
2404
+ misses = now.some((w) => w.id === id) ? 0 : misses + 1;
2405
+ if (misses >= 2) {
2406
+ onClosed();
2407
+ return;
2408
+ }
2409
+ }
2410
+ }
2411
+ async function adopt(id, opts) {
2412
+ const protocols = await capture(["xprop", "-id", id, "WM_PROTOCOLS"]);
2413
+ if (protocols.code === 127) {
2414
+ warn("xprop is not installed, so nothing out here can fix the window's close button");
2415
+ return;
2416
+ }
2417
+ await claimPid(id);
2418
+ if (opts.stripDelete)
2419
+ await dropDeleteProtocol(id, parseAtoms(protocols.stdout));
2420
+ }
2421
+ async function claimPid(id) {
2422
+ const pid = String(process.pid);
2423
+ await capture(["xprop", "-id", id, "-f", "_NET_WM_PID", "32c", "-set", "_NET_WM_PID", pid]);
2424
+ }
2425
+ async function dropDeleteProtocol(id, atoms) {
2426
+ if (!atoms.includes("WM_DELETE_WINDOW"))
2427
+ return;
2428
+ const kept = atoms.filter((atom) => atom !== "WM_DELETE_WINDOW");
2429
+ if (kept.length === 0) {
2430
+ await capture(["xprop", "-id", id, "-remove", "WM_PROTOCOLS"]);
2431
+ return;
2432
+ }
2433
+ const format = `32${"a".repeat(kept.length)}`;
2434
+ await capture([
2435
+ "xprop",
2436
+ "-id",
2437
+ id,
2438
+ "-f",
2439
+ "WM_PROTOCOLS",
2440
+ format,
2441
+ "-set",
2442
+ "WM_PROTOCOLS",
2443
+ kept.join(", ")
2444
+ ]);
2445
+ }
2446
+ function parseAtoms(stdout) {
2447
+ const list = stdout.slice(stdout.indexOf(":") + 1).replace(/^\s*protocols\s*/, "");
2448
+ return list.split(",").map((atom) => atom.trim()).filter((atom) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(atom));
2449
+ }
2450
+
2451
+ // src/launch/generate.ts
2452
+ import { mkdir, readdir as readdir4, readFile as readFile2, writeFile } from "node:fs/promises";
2453
+ import { dirname as dirname3, join as join8 } from "node:path";
2454
+ async function readInstallVersion(game2, plugin) {
2455
+ const host = game2.gameFiles.host;
2456
+ if (game2.gameFiles.source !== "mount" || host === undefined)
2457
+ return null;
2458
+ let raw;
2459
+ try {
2460
+ raw = await readFile2(join8(expandHome(host), "Version.txt"), "utf8");
2461
+ } catch {
2462
+ return null;
2463
+ }
2464
+ return plugin.parseVersion(raw.replace(/^/, "").trim());
2465
+ }
2466
+ async function readKnownExpansions(game2, plugin, warnings) {
2467
+ if (game2.dlc.length === 0)
2468
+ return [];
2469
+ const host = game2.gameFiles.host;
2470
+ if (game2.gameFiles.source !== "mount" || host === undefined)
2471
+ return [...game2.dlc];
2472
+ const dataDir = join8(expandHome(host), "Data");
2473
+ let entries;
2474
+ try {
2475
+ entries = await readdir4(dataDir, { withFileTypes: true });
2476
+ } catch {
2477
+ warnings.push(`could not read ${dataDir}; falling back to the configured dlc list`);
2478
+ return [...game2.dlc];
2479
+ }
2480
+ const found = [];
2481
+ for (const entry of entries) {
2482
+ if (!entry.isDirectory())
2483
+ continue;
2484
+ let id = null;
2485
+ try {
2486
+ id = plugin.parseManifest(await readFile2(join8(dataDir, entry.name, game2.manifest.file), "utf8"))?.packageId ?? null;
2487
+ } catch {
2488
+ continue;
2489
+ }
2490
+ if (id !== null && id.toLowerCase() !== game2.core.toLowerCase())
2491
+ found.push(id);
2492
+ }
2493
+ const order = game2.dlc.map((id) => id.toLowerCase());
2494
+ return found.sort((a, b) => {
2495
+ const ai = order.indexOf(a.toLowerCase());
2496
+ const bi = order.indexOf(b.toLowerCase());
2497
+ return (ai === -1 ? order.length : ai) - (bi === -1 ? order.length : bi);
2498
+ });
2499
+ }
2500
+ async function generateModsConfig(plan) {
2501
+ const game2 = plan.gameConfig;
2502
+ const target = join8(plan.dataDirHost, game2.modsConfig.file);
2503
+ await mkdir(dirname3(target), { recursive: true });
2504
+ const installed = await readInstallVersion(game2, plan.plugin);
2505
+ if (installed === null) {
2506
+ plan.warnings.push(`could not read Version.txt for ${plan.game}; ModsConfig version may be rejected`);
2507
+ }
2508
+ const declared = new Map([game2.core, ...game2.dlc].map((id) => [id.toLowerCase(), id]));
2509
+ const seen = new Set;
2510
+ const activeMods = [];
2511
+ for (const mod of plan.mods) {
2512
+ const key = mod.packageId.toLowerCase();
2513
+ if (seen.has(key))
2514
+ continue;
2515
+ seen.add(key);
2516
+ activeMods.push(key);
2517
+ }
2518
+ const knownExpansions = (await readKnownExpansions(game2, plan.plugin, plan.warnings)).map((id) => declared.get(id.toLowerCase()) ?? id);
2519
+ await writeFile(target, fromPlugin(target, () => plan.plugin.renderModsConfig({
2520
+ version: installed?.version ?? "",
2521
+ buildNumber: installed?.buildNumber ?? -1,
2522
+ activeMods,
2523
+ knownExpansions
2524
+ })));
2525
+ return target;
2526
+ }
2527
+ function fromPlugin(target, render) {
2528
+ try {
2529
+ return render();
2530
+ } catch (cause) {
2531
+ if (cause instanceof GamecrateError)
2532
+ throw cause;
2533
+ const error = new GamecrateError(`${target}: ${cause instanceof Error ? cause.message : String(cause)}`, Exit.Config);
2534
+ error.cause = cause;
2535
+ throw error;
2536
+ }
2537
+ }
2538
+ function ownedPrefs(plan) {
2539
+ const { settings: settings2 } = plan;
2540
+ const bool2 = (value) => value ? "True" : "False";
2541
+ const owned = {
2542
+ screenWidth: String(settings2.width),
2543
+ screenHeight: String(settings2.height),
2544
+ devMode: bool2(settings2.devMode),
2545
+ runInBackground: bool2(settings2.runInBackground),
2546
+ ...plan.plugin.windowedPrefs
2547
+ };
2548
+ Object.assign(owned, settings2.prefsExtra ?? {});
2549
+ owned.resetModsConfigOnCrash = "False";
2550
+ return owned;
2551
+ }
2552
+ async function mergePrefs(plan) {
2553
+ const target = join8(plan.dataDirHost, plan.gameConfig.prefs.file);
2554
+ await mkdir(dirname3(target), { recursive: true });
2555
+ let existing = null;
2556
+ try {
2557
+ existing = await readFile2(target, "utf8");
2558
+ } catch (error) {
2559
+ if (error.code !== "ENOENT")
2560
+ throw error;
2561
+ }
2562
+ await writeFile(target, fromPlugin(target, () => plan.plugin.mergePrefs(existing, ownedPrefs(plan))));
2563
+ return target;
2564
+ }
2565
+
2566
+ // src/launch/prepare.ts
2567
+ import { existsSync as existsSync4 } from "node:fs";
2568
+ import { open as open2, readdir as readdir5, readFile as readFile3, unlink, writeFile as writeFile2 } from "node:fs/promises";
2569
+ import { join as join9 } from "node:path";
2570
+ import { setTimeout as sleep3 } from "node:timers/promises";
2571
+ async function inherit(argv, stdin) {
2572
+ const proc = spawnArgv(argv, [stdin === undefined ? "ignore" : "pipe", "pipe", "pipe"]);
2573
+ const code = exited(proc);
2574
+ if (stdin !== undefined) {
2575
+ proc.stdin.on("error", () => {});
2576
+ proc.stdin.end(stdin);
2577
+ }
2578
+ await Promise.all([
2579
+ forwardOutput(proc.stdout, process.stdout),
2580
+ forwardOutput(proc.stderr, process.stderr)
2581
+ ]);
2582
+ return code;
2583
+ }
2584
+ async function imageDigest(ref) {
2585
+ const { code, stdout } = await capture(["docker", "image", "inspect", "--format", "{{.Id}}", ref]);
2586
+ const id = stdout.trim();
2587
+ return code === 0 && id.length > 0 ? id : null;
2588
+ }
2589
+ async function imageLabel(ref, label) {
2590
+ const format = `{{index .Config.Labels "${label}"}}`;
2591
+ const { code, stdout } = await capture(["docker", "image", "inspect", "--format", format, ref]);
2592
+ const value = stdout.trim();
2593
+ if (code !== 0 || value.length === 0 || value === "<no value>")
2594
+ return null;
2595
+ return value;
2596
+ }
2597
+ async function acquireImage(game2, config, pull) {
2598
+ const { image } = config;
2599
+ const present = await imageDigest(image.ref) !== null;
2600
+ if (image.acquire === "build") {
2601
+ if (image.context === undefined) {
2602
+ throw new GamecrateError(`${game2} has image.acquire "build" but no context`, Exit.Config);
2603
+ }
2604
+ if (present && pull !== "always")
2605
+ return;
2606
+ if (await inherit(["docker", "build", "--tag", image.ref, image.context]) !== 0) {
2607
+ throw new GamecrateError(`docker build failed for ${image.ref}`, Exit.Environment);
2608
+ }
2609
+ return;
2610
+ }
2611
+ if (pull === "never") {
2612
+ if (present)
2613
+ return;
2614
+ throw new GamecrateError(`--pull never but ${image.ref} is not present locally`, Exit.Environment);
2615
+ }
2616
+ if (pull === "missing" && present)
2617
+ return;
2618
+ if (await inherit(["docker", "pull", image.ref]) !== 0) {
2619
+ if (present)
2620
+ return;
2621
+ throw new GamecrateError(`docker pull failed for ${image.ref}`, Exit.Environment);
2622
+ }
2623
+ }
2624
+ var RUNTIME_PACKAGES = ["xorg-server-xvfb", "xorg-xwd", "imagemagick", "mesa", "ttf-dejavu"];
2625
+ var RUNTIME_SUFFIX = "-gamecrate";
2626
+ var BASE_LABEL = "gamecrate.base";
2627
+ function runtimeLayerRef(ref) {
2628
+ const at = ref.indexOf("@");
2629
+ const head = at > 0 ? ref.slice(0, at) : ref;
2630
+ const colon = head.lastIndexOf(":");
2631
+ const tagged = colon > head.lastIndexOf("/");
2632
+ const name = tagged ? head.slice(0, colon) : head;
2633
+ if (at > 0) {
2634
+ const digest = ref.slice(at + 1);
2635
+ const hex = digest.slice(digest.indexOf(":") + 1);
2636
+ return `${name}:sha-${hex.slice(0, 12)}${RUNTIME_SUFFIX}`;
2637
+ }
2638
+ return `${name}:${tagged ? head.slice(colon + 1) : "latest"}${RUNTIME_SUFFIX}`;
2639
+ }
2640
+ async function ensureRuntimeLayer(ref) {
2641
+ const derived = runtimeLayerRef(ref);
2642
+ const base = await imageDigest(ref);
2643
+ if (base !== null && await imageLabel(derived, BASE_LABEL) === base)
2644
+ return derived;
2645
+ const pacman = `pacman -Syu --noconfirm --needed ${RUNTIME_PACKAGES.join(" ")} && pacman -Scc --noconfirm`;
2646
+ const apt = "apt-get update && apt-get install -y --no-install-recommends" + " xvfb x11-apps imagemagick libgl1-mesa-dri fonts-dejavu && rm -rf /var/lib/apt/lists/*";
2647
+ const install = `if command -v pacman >/dev/null 2>&1; then ${pacman};` + ` elif command -v apt-get >/dev/null 2>&1; then ${apt};` + ' else echo "no supported package manager in the base image" >&2; exit 1; fi';
2648
+ const dockerfile = [
2649
+ `FROM ${ref}`,
2650
+ "USER root",
2651
+ `RUN ${install}`,
2652
+ "RUN command -v xvfb-run && command -v Xvfb",
2653
+ `LABEL ${BASE_LABEL}=${base}`
2654
+ ].join(`
2655
+ `);
2656
+ const code = await inherit(["docker", "build", "--tag", derived, "-f", "-", "."], new TextEncoder().encode(dockerfile));
2657
+ if (code !== 0) {
2658
+ throw new GamecrateError(`could not build the offscreen runtime layer ${derived}`, Exit.Environment, "headless and screenshot modes need an X server in the image");
2659
+ }
2660
+ return derived;
2661
+ }
2662
+ async function buildTarget(dir) {
2663
+ let entries;
2664
+ try {
2665
+ entries = await readdir5(dir);
2666
+ } catch {
2667
+ return null;
2668
+ }
2669
+ const slnx = entries.find((e) => e.endsWith(".slnx"));
2670
+ if (slnx)
2671
+ return join9(dir, slnx);
2672
+ const csproj = entries.find((e) => e.endsWith(".csproj"));
2673
+ return csproj ? join9(dir, csproj) : null;
2674
+ }
2675
+ async function buildLocalMods(plan, policy2) {
2676
+ if (policy2 === "never")
2677
+ return;
2678
+ const wanted = plan.mods.filter((m) => m.kind === "local" && (policy2 === "always" || m.stale === true));
2679
+ if (wanted.length === 0)
2680
+ return;
2681
+ for (const mod of wanted) {
2682
+ const target = await buildTarget(mod.hostDir);
2683
+ if (target === null)
2684
+ continue;
2685
+ const code = await inherit(["dotnet", "build", target, "-v", "quiet", "--nologo"]);
2686
+ if (code !== 0) {
2687
+ throw new GamecrateError(`dotnet build failed for ${mod.packageId}`, Exit.Environment, target);
2688
+ }
2689
+ mod.stale = false;
2690
+ delete mod.staleReport;
2691
+ }
2692
+ }
2693
+ async function takeLock(plan) {
2694
+ const path = lockPath(plan);
2695
+ const what = plan.instance === undefined ? plan.profile : `${plan.profile} (${plan.instance})`;
2696
+ const name = containerName(plan);
2697
+ const up = await capture(["docker", "ps", "--quiet", "--filter", `name=^${name}$`]);
2698
+ if (up.stdout.trim().length > 0) {
2699
+ throw new GamecrateError(`${plan.game} ${what} is already running (container ${name})`, Exit.Refused, `stop it with: docker stop ${name}
2700
+ or relaunch with --replace`);
2701
+ }
2702
+ if (existsSync4(path)) {
2703
+ const holder = await readFile3(path, "utf8").catch(() => "");
2704
+ const pid = Number(holder.split(`
2705
+ `)[0]);
2706
+ const alive = Number.isInteger(pid) && pid > 0 && isRunning(pid);
2707
+ if (alive) {
2708
+ throw new GamecrateError(`${plan.game} ${what} is already running (pid ${pid})`, Exit.Refused, `if that is wrong, delete ${path}
2709
+ or relaunch with --replace`);
2710
+ }
2711
+ await unlink(path).catch(() => {});
2712
+ }
2713
+ const handle = await open2(path, "wx").catch(() => null);
2714
+ if (handle === null) {
2715
+ throw new GamecrateError(`could not take the launch lock at ${path}`, Exit.Environment);
2716
+ }
2717
+ await handle.writeFile(`${process.pid}
2718
+ ${new Date().toISOString()}
2719
+ `);
2720
+ await handle.close();
2721
+ return {
2722
+ release: async () => {
2723
+ await unlink(path).catch(() => {});
2724
+ }
2725
+ };
2726
+ }
2727
+ function lockPath(plan) {
2728
+ return join9(plan.instanceDir, ".gamecrate", "lock");
2729
+ }
2730
+ var RELEASE_WAIT_MS = 1e4;
2731
+ var RELEASE_POLL_MS = 100;
2732
+ var REPLACE_STOP_TIMEOUT_SECONDS = 10;
2733
+ async function replacePrevious(plan) {
2734
+ const name = containerName(plan);
2735
+ const path = lockPath(plan);
2736
+ const up = await capture(["docker", "ps", "--quiet", "--filter", `name=^${name}$`]);
2737
+ const running = up.stdout.trim().length > 0;
2738
+ if (!running && !existsSync4(path))
2739
+ return;
2740
+ if (running) {
2741
+ status(`stopping ${name}`);
2742
+ await stopContainer(name, REPLACE_STOP_TIMEOUT_SECONDS);
2743
+ }
2744
+ const deadline = Date.now() + RELEASE_WAIT_MS;
2745
+ while (existsSync4(path) && Date.now() < deadline) {
2746
+ const holder = await readFile3(path, "utf8").catch(() => "");
2747
+ const pid = Number(holder.split(`
2748
+ `)[0]);
2749
+ if (!Number.isInteger(pid) || pid <= 0 || !isRunning(pid))
2750
+ break;
2751
+ await sleep3(RELEASE_POLL_MS);
2752
+ }
2753
+ await unlink(path).catch(() => {});
2754
+ }
2755
+ function isRunning(pid) {
2756
+ try {
2757
+ process.kill(pid, 0);
2758
+ return true;
2759
+ } catch {
2760
+ return false;
2761
+ }
2762
+ }
2763
+ async function captureScreenshot(container, plan) {
2764
+ const name = `${plan.game}.png`;
2765
+ const target = `${CONTAINER_LOG_DIR}/${name}`;
2766
+ const script = 'D=":$(ls /tmp/.X11-unix 2>/dev/null | head -1 | tr -d X)";' + ' [ "$D" = ":" ] && { echo "no X socket in the container" >&2; exit 1; };' + ` import -display "$D" -window root ${target} 2>/dev/null` + ` || xwd -root -display "$D" | magick xwd:- ${target}`;
2767
+ const code = await inherit(["docker", "exec", container, "sh", "-c", script]);
2768
+ const host = join9(plan.runDirHost, name);
2769
+ if (code !== 0 || !existsSync4(host))
2770
+ return null;
2771
+ return host;
2772
+ }
2773
+ async function writeLaunchRecord(plan, image) {
2774
+ const digest = await imageDigest(image);
2775
+ const line2 = JSON.stringify({
2776
+ at: new Date().toISOString(),
2777
+ game: plan.game,
2778
+ profile: plan.profile,
2779
+ ...plan.instance === undefined ? {} : { instance: plan.instance },
2780
+ image,
2781
+ digest,
2782
+ mode: plan.mode,
2783
+ mods: plan.mods.map((m) => ({
2784
+ packageId: m.packageId,
2785
+ hostDir: m.hostDir,
2786
+ ...m.worktree === undefined ? {} : { worktree: m.worktree }
2787
+ }))
2788
+ });
2789
+ const path = join9(plan.instanceDir, ".gamecrate", "launches.jsonl");
2790
+ await writeFile2(path, `${line2}
2791
+ `, { flag: "a" });
2792
+ }
2793
+
2794
+ // src/launch/instance.ts
2795
+ import { createHash } from "node:crypto";
2796
+ import { basename as basename5, join as join10 } from "node:path";
2797
+
2798
+ // src/mods/worktree.ts
2799
+ import { spawnSync } from "node:child_process";
2800
+ import { existsSync as existsSync5, realpathSync as realpathSync2 } from "node:fs";
2801
+ import { isAbsolute as isAbsolute2, resolve as resolve4, sep } from "node:path";
2802
+ function inspect(dir) {
2803
+ const r = spawnSync("git", ["-C", dir, "rev-parse", "--path-format=absolute", "--show-toplevel", "--git-dir", "--git-common-dir", "--abbrev-ref", "HEAD"], { encoding: "utf8" });
2804
+ if (r.status !== 0 || typeof r.stdout !== "string")
2805
+ return null;
2806
+ const lines = r.stdout.trim().split(`
2807
+ `);
2808
+ if (lines.length < 4)
2809
+ return null;
2810
+ const [toplevel, gitDir, gitCommonDir, branch] = lines;
2811
+ return { toplevel, gitDir, gitCommonDir, branch };
2812
+ }
2813
+ function canonical(p) {
2814
+ try {
2815
+ return realpathSync2(p);
2816
+ } catch {
2817
+ return resolve4(p);
2818
+ }
2819
+ }
2820
+ function resolveWorktree(dir, source, order) {
2821
+ const raw = expandHome(dir);
2822
+ const abs = isAbsolute2(raw) ? raw : resolve4(process.cwd(), raw);
2823
+ if (!existsSync5(abs)) {
2824
+ return { where: abs, message: `--worktree path does not exist`, suggestion: "check the path, or drop the flag" };
2825
+ }
2826
+ const info = inspect(abs);
2827
+ if (info === null) {
2828
+ return source === "cwd" ? { where: abs, message: "not a git repository", suggestion: "ignored" } : { where: abs, message: "not a git repository, or its gitdir has been pruned", suggestion: "run `git worktree prune` in the parent repo" };
2829
+ }
2830
+ if (info.gitDir === info.gitCommonDir) {
2831
+ return source === "cwd" ? { where: abs, message: "primary checkout, not a linked worktree", suggestion: "ignored" } : {
2832
+ where: abs,
2833
+ message: "is the primary checkout, not a linked worktree",
2834
+ suggestion: "nothing to promote; drop --worktree"
2835
+ };
2836
+ }
2837
+ return { root: canonical(info.toplevel), branch: info.branch, source, order };
2838
+ }
2839
+ function contains(request, dir) {
2840
+ const target = canonical(dir);
2841
+ return target === request.root || target.startsWith(request.root + sep);
2842
+ }
2843
+ function collectRequests(flags2, env, cwd, disabled) {
2844
+ if (disabled)
2845
+ return { requests: [], problems: [] };
2846
+ const requests = [];
2847
+ const problems = [];
2848
+ let order = 0;
2849
+ const add = (dir, source) => {
2850
+ const got = resolveWorktree(dir, source, order);
2851
+ if ("root" in got) {
2852
+ if (!requests.some((r) => r.root === got.root)) {
2853
+ requests.push(got);
2854
+ order += 1;
2855
+ }
2856
+ } else if (source !== "cwd") {
2857
+ problems.push(got);
2858
+ }
2859
+ };
2860
+ for (const f of flags2)
2861
+ add(f, "flag");
2862
+ if (env !== undefined && env !== "" && env !== "off")
2863
+ add(env, "env");
2864
+ add(cwd, "cwd");
2865
+ return { requests, problems };
2866
+ }
2867
+
2868
+ // src/launch/instance.ts
2869
+ var SLUG_LIMIT = 24;
2870
+ function resolveInstance(options) {
2871
+ const { profileDir, profile: profile2, args } = options;
2872
+ const configured = lookup(profile2, args.instance);
2873
+ const flags2 = configured?.worktree === undefined ? args.worktree ?? [] : [configured.worktree, ...args.worktree ?? []];
2874
+ const { requests, problems } = collectRequests(flags2, options.env ?? process.env["GAMECRATE_WORKTREE"], options.cwd ?? process.cwd(), args.noWorktree ?? false);
2875
+ const name = args.instance === undefined ? derive(requests) : named(args.instance);
2876
+ return {
2877
+ ...name === undefined ? {} : { name },
2878
+ dir: name === undefined ? profileDir : join10(profileDir, "instances", name),
2879
+ requests,
2880
+ problems,
2881
+ ...configured?.settings === undefined ? {} : { settings: configured.settings }
2882
+ };
2883
+ }
2884
+ function lookup(profile2, name) {
2885
+ const instances = profile2?.instances;
2886
+ if (instances === undefined || name === undefined)
2887
+ return;
2888
+ const exact = own(instances, name);
2889
+ if (exact !== undefined)
2890
+ return exact;
2891
+ const lower = name.toLowerCase();
2892
+ const key = Object.keys(instances).find((k) => k.toLowerCase() === lower);
2893
+ return key === undefined ? undefined : own(instances, key);
2894
+ }
2895
+ function named(name) {
2896
+ if (!NAME_PATTERN.test(name)) {
2897
+ throw new GamecrateError(`invalid instance name "${name}"`, Exit.Usage, `it becomes a directory and a container name, so it must match ${NAME_PATTERN.source}`);
2898
+ }
2899
+ return name;
2900
+ }
2901
+ function derive(requests) {
2902
+ const first = requests[0];
2903
+ if (first === undefined)
2904
+ return;
2905
+ const digest = createHash("sha256").update(requests.map((r) => r.root).join("\x00")).digest("hex");
2906
+ return `${slug(first.root)}-${digest.slice(0, 6)}`;
2907
+ }
2908
+ function slug(root2) {
2909
+ const body = basename5(root2).toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^[^a-z0-9]+/, "").slice(0, SLUG_LIMIT).replace(/[-._]+$/, "");
2910
+ return body === "" ? "wt" : body;
2911
+ }
2912
+
2913
+ // src/launch/resolve.ts
2914
+ import { join as join12 } from "node:path";
2915
+
2916
+ // src/mods/modindex.ts
2917
+ import { existsSync as existsSync6, readFileSync as readFileSync3, statSync as statSync2 } from "node:fs";
2918
+ import { mkdir as mkdir2, readdir as readdir6, readFile as readFile4, writeFile as writeFile3 } from "node:fs/promises";
2919
+ import { homedir as homedir4 } from "node:os";
2920
+ import { dirname as dirname4, join as join11, relative as relative2, resolve as resolvePath } from "node:path";
2921
+ import picomatch from "picomatch";
2922
+ function cacheDir() {
2923
+ return join11(process.env["XDG_CACHE_HOME"] ?? join11(homedir4(), ".cache"), "gamecrate");
2924
+ }
2925
+ function globMatch(pattern, path) {
2926
+ return picomatch.isMatch(path, pattern.replace(/[[\]{}()!,@+|^$.\\]/g, "\\$&"), { dot: true });
2927
+ }
2928
+ function excluded(patterns, relativePath) {
2929
+ return patterns.some((p) => globMatch(p, relativePath) || globMatch(p, `${relativePath}/`));
2930
+ }
2931
+ var ALWAYS_EXCLUDE = ["**/.worktrees/**", "**/.claude/worktrees/**"];
2932
+ function inLinkedWorktree(dir, stopAt) {
2933
+ let current = dir;
2934
+ for (;; ) {
2935
+ const git = join11(current, ".git");
2936
+ if (existsSync6(git)) {
2937
+ try {
2938
+ if (statSync2(git).isFile())
2939
+ return true;
2940
+ } catch {
2941
+ return false;
2942
+ }
2943
+ return false;
2944
+ }
2945
+ if (current === stopAt)
2946
+ return false;
2947
+ const parent = dirname4(current);
2948
+ if (parent === current)
2949
+ return false;
2950
+ current = parent;
2951
+ }
2952
+ }
2953
+ async function scanLocalRoot(root2, rootIndex, manifestFile, found) {
2954
+ const base = resolvePath(expandHome(root2.path));
2955
+ const exclude = [...root2.exclude ?? [], ...rootIndex === -1 ? [] : ALWAYS_EXCLUDE];
2956
+ const walk = async (dir, depth) => {
2957
+ const manifest = join11(dir, manifestFile);
2958
+ if (existsSync6(manifest)) {
2959
+ found.push({
2960
+ dir,
2961
+ kind: "local",
2962
+ rootIndex,
2963
+ linkedWorktree: inLinkedWorktree(dir, base)
2964
+ });
2965
+ return;
2966
+ }
2967
+ if (depth >= root2.maxDepth)
2968
+ return;
2969
+ let entries;
2970
+ try {
2971
+ entries = await readdir6(dir, { withFileTypes: true });
2972
+ } catch {
2973
+ return;
2974
+ }
2975
+ for (const entry of entries) {
2976
+ if (!entry.isDirectory() || entry.name.startsWith(".git"))
2977
+ continue;
2978
+ const child = join11(dir, entry.name);
2979
+ if (excluded(exclude, relative2(base, child)))
2980
+ continue;
2981
+ await walk(child, depth + 1);
2982
+ }
2983
+ };
2984
+ if (!existsSync6(base))
2985
+ return;
2986
+ await walk(base, 0);
2987
+ }
2988
+ async function scanWorkshopRoot(workshopRoot, rootIndex, manifestFile, found) {
2989
+ const base = resolvePath(expandHome(workshopRoot));
2990
+ let entries;
2991
+ try {
2992
+ entries = await readdir6(base, { withFileTypes: true });
2993
+ } catch {
2994
+ return;
2995
+ }
2996
+ for (const entry of entries) {
2997
+ if (!entry.isDirectory() || !/^\d+$/.test(entry.name))
2998
+ continue;
2999
+ const dir = join11(base, entry.name);
3000
+ if (!existsSync6(join11(dir, manifestFile)))
3001
+ continue;
3002
+ found.push({ dir, kind: "workshop", rootIndex, linkedWorktree: false, workshopId: Number(entry.name) });
3003
+ }
3004
+ }
3005
+ async function scanGameData(game2, rootIndex, found) {
3006
+ const host = game2.gameFiles.host;
3007
+ if (game2.gameFiles.source !== "mount" || host === undefined)
3008
+ return;
3009
+ const data = join11(resolvePath(expandHome(host)), "Data");
3010
+ let entries;
3011
+ try {
3012
+ entries = await readdir6(data, { withFileTypes: true });
3013
+ } catch {
3014
+ return;
3015
+ }
3016
+ for (const entry of entries) {
3017
+ if (!entry.isDirectory())
3018
+ continue;
3019
+ const dir = join11(data, entry.name);
3020
+ if (!existsSync6(join11(dir, game2.manifest.file)))
3021
+ continue;
3022
+ found.push({ dir, kind: "official", rootIndex, linkedWorktree: false });
3023
+ }
3024
+ }
3025
+ var CACHE_VERSION = 2;
3026
+ function workshopStamp(game2) {
3027
+ if (game2.workshopRoot === null)
3028
+ return null;
3029
+ const acf = join11(dirname4(dirname4(resolvePath(expandHome(game2.workshopRoot)))), `appworkshop_${game2.steamAppId}.acf`);
3030
+ try {
3031
+ const info = statSync2(acf);
3032
+ return `${info.mtimeMs}:${info.size}`;
3033
+ } catch {
3034
+ return null;
3035
+ }
3036
+ }
3037
+ async function readWorkshopCache(game2, stamp) {
3038
+ try {
3039
+ const raw = JSON.parse(await readFile4(join11(cacheDir(), `${game2}.workshop.json`), "utf8"));
3040
+ if (raw.version !== CACHE_VERSION || raw.stamp !== stamp)
3041
+ return null;
3042
+ return raw.records;
3043
+ } catch {
3044
+ return null;
3045
+ }
3046
+ }
3047
+ async function writeWorkshopCache(game2, stamp, records) {
3048
+ const payload = { version: CACHE_VERSION, stamp, records };
3049
+ try {
3050
+ await mkdir2(cacheDir(), { recursive: true });
3051
+ await writeFile3(join11(cacheDir(), `${game2}.workshop.json`), JSON.stringify(payload));
3052
+ } catch {}
3053
+ }
3054
+ function toRecord(candidate, manifest, game2) {
3055
+ const kind = manifest.packageId.toLowerCase() === game2.core.toLowerCase() ? "core" : candidate.kind;
3056
+ const record = {
3057
+ packageId: manifest.packageId,
3058
+ dir: candidate.dir,
3059
+ kind,
3060
+ manifest,
3061
+ linkedWorktree: candidate.linkedWorktree,
3062
+ rootIndex: candidate.rootIndex
3063
+ };
3064
+ if (candidate.workshopId !== undefined)
3065
+ record.workshopId = candidate.workshopId;
3066
+ return record;
3067
+ }
3068
+ function tier(r) {
3069
+ return [
3070
+ r.overridden ?? Number.MAX_SAFE_INTEGER,
3071
+ r.selectedWorktree ?? Number.MAX_SAFE_INTEGER,
3072
+ r.kind === "workshop" ? 1 : 0,
3073
+ r.linkedWorktree ? 1 : 0,
3074
+ r.rootIndex
3075
+ ];
3076
+ }
3077
+ function compareTiers(a, b) {
3078
+ const x = tier(a);
3079
+ const y = tier(b);
3080
+ for (let i = 0;i < x.length; i += 1) {
3081
+ if (x[i] !== y[i])
3082
+ return x[i] - y[i];
3083
+ }
3084
+ return 0;
3085
+ }
3086
+ function rank(a, b) {
3087
+ return compareTiers(a, b) || (a.dir < b.dir ? -1 : a.dir > b.dir ? 1 : 0);
3088
+ }
3089
+ async function applyWorktreeRequests(index, requests, config) {
3090
+ if (requests.length === 0)
3091
+ return;
3092
+ const known = new Set;
3093
+ for (const bucket of index.byPackageId.values()) {
3094
+ for (const record of bucket) {
3095
+ known.add(record.dir);
3096
+ for (const request of requests) {
3097
+ if (!contains(request, record.dir))
3098
+ continue;
3099
+ record.worktree = { root: request.root, branch: request.branch, source: request.source };
3100
+ record.selectedWorktree = request.order;
3101
+ break;
3102
+ }
3103
+ }
3104
+ }
3105
+ for (const request of requests) {
3106
+ const found = [];
3107
+ await scanLocalRoot({ path: request.root, maxDepth: WORKTREE_SCAN_DEPTH, exclude: WORKTREE_EXCLUDE }, -1, config.manifest.file, found);
3108
+ const fresh = found.filter((c) => !known.has(c.dir));
3109
+ if (fresh.length === 0)
3110
+ continue;
3111
+ for (const record of await parseAll(fresh, config, index.plugin, index.problems)) {
3112
+ record.worktree = { root: request.root, branch: request.branch, source: request.source };
3113
+ record.selectedWorktree = request.order;
3114
+ insert(index, record);
3115
+ }
3116
+ }
3117
+ for (const bucket of index.byPackageId.values())
3118
+ bucket.sort(rank);
3119
+ }
3120
+ var WORKTREE_SCAN_DEPTH = 5;
3121
+ var WORKTREE_EXCLUDE = ["**/.retired/**", "**/node_modules/**", "**/bin/**", "**/obj/**"];
3122
+ async function applySourceOverrides(index, overrides, config) {
3123
+ const problems = [];
3124
+ if (overrides.length === 0)
3125
+ return problems;
3126
+ for (const [order, spec] of overrides.entries()) {
3127
+ const eq = spec.indexOf("=");
3128
+ if (eq < 1) {
3129
+ problems.push({ where: spec, message: "--use takes <packageId>=<path>" });
3130
+ continue;
3131
+ }
3132
+ const wanted = spec.slice(0, eq);
3133
+ const dir = resolvePath(expandHome(spec.slice(eq + 1)));
3134
+ const file = join11(dir, config.manifest.file);
3135
+ if (!existsSync6(file)) {
3136
+ problems.push({
3137
+ where: spec,
3138
+ message: `no ${config.manifest.file} under ${dir}`,
3139
+ suggestion: "point --use at the mod directory, not the repo root"
3140
+ });
3141
+ continue;
3142
+ }
3143
+ let manifest;
3144
+ try {
3145
+ manifest = index.plugin.parseManifest(readFileSync3(file, "utf8"));
3146
+ } catch (error) {
3147
+ problems.push({ where: file, message: `could not parse: ${String(error)}` });
3148
+ continue;
3149
+ }
3150
+ if (manifest === null) {
3151
+ problems.push({ where: file, message: `${file} declares no packageId` });
3152
+ continue;
3153
+ }
3154
+ if (manifest.packageId.toLowerCase() !== wanted.toLowerCase()) {
3155
+ problems.push({
3156
+ where: spec,
3157
+ message: `${dir} declares ${manifest.packageId}, not ${wanted}`,
3158
+ suggestion: `use --use ${manifest.packageId}=${dir}`
3159
+ });
3160
+ continue;
3161
+ }
3162
+ const key = manifest.packageId.toLowerCase();
3163
+ const existing = (index.byPackageId.get(key) ?? []).find((r) => r.dir === dir);
3164
+ if (existing) {
3165
+ existing.overridden = order;
3166
+ } else {
3167
+ const record = toRecord({ dir, kind: "local", rootIndex: -1, linkedWorktree: false }, manifest, config);
3168
+ record.overridden = order;
3169
+ insert(index, record);
3170
+ }
3171
+ index.byPackageId.get(key)?.sort(rank);
3172
+ }
3173
+ return problems;
3174
+ }
3175
+ function insert(index, record) {
3176
+ const key = record.packageId.toLowerCase();
3177
+ const bucket = index.byPackageId.get(key);
3178
+ if (bucket)
3179
+ bucket.push(record);
3180
+ else
3181
+ index.byPackageId.set(key, [record]);
3182
+ if (record.workshopId !== undefined && !index.byWorkshopId.has(record.workshopId)) {
3183
+ index.byWorkshopId.set(record.workshopId, record);
3184
+ }
3185
+ const short = key.split(".").pop();
3186
+ if (short !== undefined && short.length > 0) {
3187
+ const ids = index.byShortName.get(short);
3188
+ if (ids) {
3189
+ if (!ids.includes(record.packageId))
3190
+ ids.push(record.packageId);
3191
+ } else {
3192
+ index.byShortName.set(short, [record.packageId]);
3193
+ }
3194
+ }
3195
+ }
3196
+ async function buildIndex(game2, config, plugin) {
3197
+ const index = {
3198
+ game: game2,
3199
+ plugin,
3200
+ byPackageId: new Map,
3201
+ byWorkshopId: new Map,
3202
+ byShortName: new Map,
3203
+ problems: []
3204
+ };
3205
+ const local = [];
3206
+ await scanGameData(config, -1, local);
3207
+ for (const [i, root2] of config.scanRoots.entries()) {
3208
+ await scanLocalRoot(root2, i, config.manifest.file, local);
3209
+ }
3210
+ for (const record of await parseAll(local, config, plugin, index.problems))
3211
+ insert(index, record);
3212
+ if (config.workshopRoot !== null) {
3213
+ const stamp = workshopStamp(config);
3214
+ const cached = stamp === null ? null : await readWorkshopCache(game2, stamp);
3215
+ if (cached) {
3216
+ for (const record of cached)
3217
+ insert(index, record);
3218
+ } else {
3219
+ const items = [];
3220
+ await scanWorkshopRoot(config.workshopRoot, config.scanRoots.length, config.manifest.file, items);
3221
+ const records = await parseAll(items, config, plugin, index.problems);
3222
+ for (const record of records)
3223
+ insert(index, record);
3224
+ if (stamp !== null)
3225
+ await writeWorkshopCache(game2, stamp, records);
3226
+ }
3227
+ }
3228
+ for (const bucket of index.byPackageId.values())
3229
+ bucket.sort(rank);
3230
+ return index;
3231
+ }
3232
+ async function parseAll(candidates, config, plugin, problems) {
3233
+ const records = await Promise.all(candidates.map(async (candidate) => {
3234
+ const file = join11(candidate.dir, config.manifest.file);
3235
+ try {
3236
+ const manifest = plugin.parseManifest(await readFile4(file, "utf8"));
3237
+ return manifest === null ? null : toRecord(candidate, manifest, config);
3238
+ } catch (error) {
3239
+ problems.push({
3240
+ where: file,
3241
+ message: error instanceof Error ? error.message : String(error)
3242
+ });
3243
+ return null;
3244
+ }
3245
+ }));
3246
+ return records.filter((r) => r !== null);
3247
+ }
3248
+ function resolveModRef(index, ref, game2) {
3249
+ return honorOverride(index, resolveRaw(index, ref, game2));
3250
+ }
3251
+ function honorOverride(index, record) {
3252
+ if (record === null || record.overridden !== undefined)
3253
+ return record;
3254
+ const best = index.byPackageId.get(record.packageId.toLowerCase())?.[0];
3255
+ return best?.overridden !== undefined ? best : record;
3256
+ }
3257
+ function resolveRaw(index, ref, game2) {
3258
+ if (ref.startsWith("path:"))
3259
+ return byPath(index, ref.slice(5), game2);
3260
+ if (ref.startsWith("workshop:")) {
3261
+ const id = Number(ref.slice(9));
3262
+ return Number.isInteger(id) ? index.byWorkshopId.get(id) ?? null : null;
3263
+ }
3264
+ const direct = pick(index, ref.toLowerCase(), ref);
3265
+ if (direct)
3266
+ return direct;
3267
+ const alias = own(game2.aliases, ref) ?? own(game2.aliases, ref.toLowerCase());
3268
+ if (alias !== undefined && alias.toLowerCase() !== ref.toLowerCase()) {
3269
+ return resolveRaw(index, alias, game2);
3270
+ }
3271
+ const short = index.byShortName.get(ref.toLowerCase());
3272
+ if (short && short.length === 1)
3273
+ return pick(index, short[0].toLowerCase(), ref);
3274
+ if (short && short.length > 1) {
3275
+ throw new GamecrateError(`"${ref}" is a short name for ${short.length} mods`, Exit.Resolution, short.join(", "));
3276
+ }
3277
+ return null;
3278
+ }
3279
+ function pick(index, key, ref) {
3280
+ const bucket = index.byPackageId.get(key);
3281
+ const best = bucket?.[0];
3282
+ if (!best)
3283
+ return null;
3284
+ const runnerUp = bucket[1];
3285
+ if (runnerUp && compareTiers(best, runnerUp) === 0) {
3286
+ if (best.selectedWorktree !== undefined || runnerUp.selectedWorktree !== undefined) {
3287
+ throw new GamecrateError(`"${ref}" is declared by ${bucket.length} indistinguishable directories`, Exit.Resolution, bucket.map((r) => r.dir).join(`
3288
+ `));
3289
+ }
3290
+ index.problems.push({
3291
+ where: ref,
3292
+ message: `resolved by directory name: ${bucket.length} candidates tie on every rule`,
3293
+ suggestion: `using ${best.dir}`
3294
+ });
3295
+ }
3296
+ return best;
3297
+ }
3298
+ function byPath(index, raw, game2) {
3299
+ const dir = resolvePath(expandHome(raw));
3300
+ for (const bucket of index.byPackageId.values()) {
3301
+ const hit = bucket.find((record) => record.dir === dir || record.dir === raw);
3302
+ if (hit)
3303
+ return hit;
3304
+ }
3305
+ const file = join11(dir, game2.manifest.file);
3306
+ if (!existsSync6(file))
3307
+ return null;
3308
+ try {
3309
+ const manifest = index.plugin.parseManifest(readFileSync3(file, "utf8"));
3310
+ if (manifest === null)
3311
+ return null;
3312
+ return toRecord({ dir, kind: "local", rootIndex: -1, linkedWorktree: false }, manifest, game2);
3313
+ } catch {
3314
+ return null;
3315
+ }
3316
+ }
3317
+
3318
+ // src/launch/resolve.ts
3319
+ var DEFAULT_TIMEOUT_SECONDS = 420;
3320
+ var DEFAULT_RENDER_WAIT_SECONDS = 25;
3321
+ function isDynamic(entry) {
3322
+ return typeof entry !== "string" && "match" in entry;
3323
+ }
3324
+ function refFor(entry, game2) {
3325
+ const object = typeof entry === "string" ? { id: entry } : entry;
3326
+ if (object.path !== undefined)
3327
+ return `path:${object.path}`;
3328
+ if (object.workshop !== undefined)
3329
+ return `workshop:${object.workshop}`;
3330
+ if (object.id.includes(":"))
3331
+ return object.id;
3332
+ const pin = own(game2.library, object.id) ?? own(game2.library, object.id.toLowerCase());
3333
+ if (pin?.path !== undefined)
3334
+ return `path:${pin.path}`;
3335
+ if (pin?.workshop !== undefined)
3336
+ return `workshop:${pin.workshop}`;
3337
+ return object.id;
3338
+ }
3339
+ function expandDynamic(entry, index, where, problems) {
3340
+ const pattern = globToRegExp(entry.match);
3341
+ const matched = [];
3342
+ for (const records of index.byPackageId.values()) {
3343
+ const record = records[0];
3344
+ if (record && pattern.test(record.packageId))
3345
+ matched.push(record.packageId);
3346
+ }
3347
+ const minMatches = entry.minMatches ?? 1;
3348
+ if (matched.length < minMatches) {
3349
+ problems.push({
3350
+ where,
3351
+ message: `"${entry.match}" matched ${matched.length} mod(s), needs at least ${minMatches}`
3352
+ });
3353
+ }
3354
+ const firstOrder = (entry.first ?? []).map((id) => id.toLowerCase());
3355
+ const head = matched.filter((id) => firstOrder.includes(id.toLowerCase())).sort((a, b) => firstOrder.indexOf(a.toLowerCase()) - firstOrder.indexOf(b.toLowerCase()));
3356
+ const rest = matched.filter((id) => !firstOrder.includes(id.toLowerCase()));
3357
+ if ((entry.sort ?? "alpha") === "alpha") {
3358
+ rest.sort((a, b) => a.toLowerCase() < b.toLowerCase() ? -1 : a.toLowerCase() > b.toLowerCase() ? 1 : 0);
3359
+ }
3360
+ return [...head, ...rest];
3361
+ }
3362
+ function collectSlots(game2, gameName, profileName, profile2, args) {
3363
+ const modless = profileName === "modless";
3364
+ const slots = [];
3365
+ const at = `/games/${gameName}`;
3366
+ if (!modless)
3367
+ for (const [i, id] of (game2.preCore ?? []).entries())
3368
+ slots.push({ entry: id, where: `${at}/preCore/${i}` });
3369
+ slots.push({ entry: game2.core, where: `${at}/core` });
3370
+ for (const [i, id] of game2.dlc.entries())
3371
+ slots.push({ entry: id, where: `${at}/dlc/${i}`, dlc: true });
3372
+ if (!modless && profile2.includeBase !== false) {
3373
+ for (const [i, id] of (game2.base ?? []).entries())
3374
+ slots.push({ entry: id, where: `${at}/base/${i}` });
3375
+ }
3376
+ const only = args.only ?? [];
3377
+ const declared = only.length > 0 ? only : profile2.mods ?? [];
3378
+ const source = only.length > 0 ? "flag --only" : `${at}/profiles/${profileName}/mods`;
3379
+ for (const [i, entry] of declared.entries())
3380
+ slots.push({ entry, where: `${source}/${i}` });
3381
+ for (const [i, id] of (args.mods ?? []).entries())
3382
+ slots.push({ entry: id, where: `flag --mod/${i}` });
3383
+ return slots;
3384
+ }
3385
+ function insertDependencies(list, present, index, game2, problems) {
3386
+ let i = 0;
3387
+ outer:
3388
+ while (i < list.length) {
3389
+ const mod = list[i];
3390
+ for (const dep of mod.record.manifest.modDependencies) {
3391
+ const key = dep.packageId.toLowerCase();
3392
+ if (present.has(key))
3393
+ continue;
3394
+ present.add(key);
3395
+ const record = resolveModRef(index, dep.packageId, game2);
3396
+ if (!record) {
3397
+ problems.push({
3398
+ where: mod.record.packageId,
3399
+ message: `declares a dependency on ${dep.packageId}, which is not installed`,
3400
+ suggestion: dep.steamWorkshopUrl
3401
+ });
3402
+ continue;
3403
+ }
3404
+ present.add(record.packageId.toLowerCase());
3405
+ list.splice(i, 0, { record, explicit: false });
3406
+ continue outer;
3407
+ }
3408
+ i++;
3409
+ }
3410
+ }
3411
+ function topoSort(list, problems, core) {
3412
+ const position = new Map;
3413
+ for (const [i, mod] of list.entries())
3414
+ position.set(mod.record.packageId.toLowerCase(), i);
3415
+ const edges = new Set;
3416
+ const addEdge = (from, to) => {
3417
+ if (from === undefined || to === undefined || from === to)
3418
+ return;
3419
+ edges.add(`${from}>${to}`);
3420
+ };
3421
+ for (const [i, mod] of list.entries()) {
3422
+ const { loadAfter, loadBefore, forceLoadAfter, forceLoadBefore } = mod.record.manifest;
3423
+ for (const id of [...loadAfter, ...forceLoadAfter])
3424
+ addEdge(position.get(id.toLowerCase()), i);
3425
+ for (const id of [...loadBefore, ...forceLoadBefore])
3426
+ addEdge(i, position.get(id.toLowerCase()));
3427
+ }
3428
+ const indegree = list.map(() => 0);
3429
+ const outgoing = list.map(() => []);
3430
+ const incoming = list.map(() => []);
3431
+ for (const edge of edges) {
3432
+ const [from, to] = edge.split(">").map(Number);
3433
+ outgoing[from].push(to);
3434
+ incoming[to].push(from);
3435
+ indegree[to] += 1;
3436
+ }
3437
+ const preCore = new Set;
3438
+ const coreIndex = core === undefined ? undefined : position.get(core.toLowerCase());
3439
+ if (coreIndex !== undefined) {
3440
+ const queue = [coreIndex];
3441
+ while (queue.length > 0) {
3442
+ for (const from of incoming[queue.pop()]) {
3443
+ if (preCore.has(from))
3444
+ continue;
3445
+ preCore.add(from);
3446
+ queue.push(from);
3447
+ }
3448
+ }
3449
+ }
3450
+ const phase = (i) => preCore.has(i) ? 0 : 1;
3451
+ const sorted = [];
3452
+ const done = list.map(() => false);
3453
+ for (;; ) {
3454
+ let next = -1;
3455
+ for (let i = 0;i < list.length; i++) {
3456
+ if (done[i] || indegree[i] !== 0)
3457
+ continue;
3458
+ if (next === -1 || phase(i) < phase(next))
3459
+ next = i;
3460
+ }
3461
+ if (next === -1)
3462
+ break;
3463
+ done[next] = true;
3464
+ sorted.push(list[next]);
3465
+ for (const to of outgoing[next])
3466
+ indegree[to] -= 1;
3467
+ }
3468
+ const cycle = list.filter((_, i) => !done[i]);
3469
+ if (cycle.length > 0) {
3470
+ problems.push({
3471
+ where: "flag --sort topo",
3472
+ message: `load-order cycle among ${cycle.map((m) => m.record.packageId).join(", ")}; left in profile order`
3473
+ });
3474
+ sorted.push(...cycle);
3475
+ }
3476
+ return sorted;
3477
+ }
3478
+ function incompatibilityWarnings(list) {
3479
+ const byId = new Map(list.map((mod) => [mod.record.packageId.toLowerCase(), mod.record.packageId]));
3480
+ const seen = new Set;
3481
+ const warnings = [];
3482
+ for (const mod of list) {
3483
+ for (const id of mod.record.manifest.incompatibleWith) {
3484
+ const other = byId.get(id.toLowerCase());
3485
+ if (!other)
3486
+ continue;
3487
+ const pair = [mod.record.packageId, other].map((s) => s.toLowerCase()).sort().join("|");
3488
+ if (seen.has(pair))
3489
+ continue;
3490
+ seen.add(pair);
3491
+ warnings.push(`${mod.record.packageId} declares it is incompatible with ${other}; both are active`);
3492
+ }
3493
+ }
3494
+ return warnings;
3495
+ }
3496
+ async function resolvePlan(options) {
3497
+ const { game: gameName, profile: requestedProfile, root: root2 } = options;
3498
+ const args = options.args ?? {};
3499
+ const problems = [];
3500
+ const warnings = [];
3501
+ const game2 = own(root2.games, gameName);
3502
+ if (!game2) {
3503
+ throw new GamecrateError(`unknown game "${gameName}"`, Exit.Config, `known: ${Object.keys(root2.games).join(", ")}`);
3504
+ }
3505
+ if (!NAME_PATTERN.test(requestedProfile)) {
3506
+ throw new GamecrateError(`invalid profile name "${requestedProfile}"`, Exit.Usage);
3507
+ }
3508
+ const profileName = canonicalProfile(game2, requestedProfile);
3509
+ const profile2 = profileName === "modless" ? { mods: [], includeBase: false } : resolveProfile(game2, profileName);
3510
+ const profileDir = profileDataDir(root2, gameName, profileName);
3511
+ const instance = resolveInstance({
3512
+ profileDir,
3513
+ profile: profile2,
3514
+ args,
3515
+ ...options.cwd === undefined ? {} : { cwd: options.cwd }
3516
+ });
3517
+ problems.push(...instance.problems);
3518
+ const settings2 = resolveSettings(root2, game2, profile2, instance.settings, args.resolution);
3519
+ if (args.network !== undefined)
3520
+ settings2.network = args.network;
3521
+ if (args.gameArgs?.length)
3522
+ settings2.gameArgs = [...settings2.gameArgs ?? [], ...args.gameArgs];
3523
+ if (args.dockerArgs?.length)
3524
+ settings2.dockerArgs = [...settings2.dockerArgs ?? [], ...args.dockerArgs];
3525
+ const plugin = requirePlugin(options.plugins, gameName);
3526
+ const index = options.index ?? await buildIndex(gameName, game2, plugin);
3527
+ await applyWorktreeRequests(index, instance.requests, game2);
3528
+ problems.push(...await applySourceOverrides(index, args.use ?? [], game2));
3529
+ const excluded2 = [...profile2.exclude ?? [], ...args.without ?? []].map(globToRegExp);
3530
+ const isExcluded = (id) => excluded2.some((pattern) => pattern.test(id));
3531
+ const staged = [];
3532
+ const present = new Set;
3533
+ for (const slot of collectSlots(game2, gameName, profileName, profile2, args)) {
3534
+ const entry = slot.entry;
3535
+ const refs = isDynamic(entry) ? expandDynamic(entry, index, slot.where, problems).map((id) => ({ ref: id, optional: false })) : [{ ref: refFor(entry, game2), optional: typeof entry !== "string" && entry.optional === true }];
3536
+ for (const { ref, optional } of refs) {
3537
+ const record = resolveModRef(index, ref, game2);
3538
+ if (!record) {
3539
+ if (slot.dlc === true)
3540
+ continue;
3541
+ if (optional)
3542
+ warnings.push(`optional mod ${ref} is not installed; skipped`);
3543
+ else
3544
+ problems.push({ where: slot.where, message: `no mod matches "${ref}"` });
3545
+ continue;
3546
+ }
3547
+ const key = record.packageId.toLowerCase();
3548
+ if (present.has(key) || isExcluded(record.packageId))
3549
+ continue;
3550
+ present.add(key);
3551
+ staged.push({ record, explicit: true });
3552
+ }
3553
+ }
3554
+ if (profile2.autoDependencies === true)
3555
+ insertDependencies(staged, present, index, game2, problems);
3556
+ const ordered = args.sort === "none" ? staged : topoSort(staged, problems, game2.core);
3557
+ warnings.push(...incompatibilityWarnings(ordered));
3558
+ const mods = [];
3559
+ for (const { record, explicit } of ordered) {
3560
+ const times = record.kind === "local" ? await scanBuildTimes(record.dir) : null;
3561
+ const report = times === null ? null : staleReport(times);
3562
+ if (record.worktree) {
3563
+ warnings.push(`${record.packageId} comes from worktree ${record.worktree.branch} (${record.worktree.source}): ${record.dir}`);
3564
+ }
3565
+ const shadowed = (index.byPackageId.get(record.packageId.toLowerCase()) ?? []).filter((other) => other.dir !== record.dir).map((other) => other.dir);
3566
+ mods.push({
3567
+ packageId: record.packageId,
3568
+ hostDir: record.dir,
3569
+ containerDir: `${game2.modsDir.container}/${record.packageId}`,
3570
+ kind: record.kind,
3571
+ ...record.workshopId === undefined ? {} : { workshopId: record.workshopId },
3572
+ explicit,
3573
+ stale: times === null ? false : decideStale(times),
3574
+ ...report === null ? {} : { staleReport: report },
3575
+ ...record.worktree === undefined ? {} : { worktree: { ...record.worktree, selected: record.selectedWorktree !== undefined } },
3576
+ ...shadowed.length === 0 ? {} : { shadowed }
3577
+ });
3578
+ }
3579
+ problems.push(...index.problems);
3580
+ if (game2.dataDir.mode === "arg" && game2.dataDir.arg.split("=").length !== 2) {
3581
+ problems.push({
3582
+ where: `/games/${gameName}/dataDir/arg`,
3583
+ message: `"${game2.dataDir.arg}" must contain exactly one "="; RimWorld silently ignores anything else`
3584
+ });
3585
+ }
3586
+ if (game2.dataDir.container.includes("=")) {
3587
+ problems.push({
3588
+ where: `/games/${gameName}/dataDir/container`,
3589
+ message: `container data path "${game2.dataDir.container}" contains "=", which disables the override silently`
3590
+ });
3591
+ }
3592
+ const mode = args.mode ?? "headed";
3593
+ if (!game2.modes.includes(mode)) {
3594
+ problems.push({ where: "flag --mode", message: `${gameName} does not support mode "${mode}"` });
3595
+ }
3596
+ const plan = {
3597
+ game: gameName,
3598
+ gameConfig: game2,
3599
+ plugin,
3600
+ profile: profileName,
3601
+ settings: settings2,
3602
+ mods,
3603
+ profileDir,
3604
+ ...instance.name === undefined ? {} : { instance: instance.name },
3605
+ instanceDir: instance.dir,
3606
+ dataDirHost: join12(instance.dir, "game"),
3607
+ configDirHost: join12(profileDir, "config"),
3608
+ stageDirHost: join12(instance.dir, ".stage"),
3609
+ logsDirHost: join12(instance.dir, "logs"),
3610
+ runDirHost: join12(instance.dir, "logs"),
3611
+ mode,
3612
+ ...args.marker === undefined ? {} : { marker: args.marker },
3613
+ timeoutSeconds: args.timeout ?? DEFAULT_TIMEOUT_SECONDS,
3614
+ renderWaitSeconds: args.renderWait ?? DEFAULT_RENDER_WAIT_SECONDS,
3615
+ warnOnStale: args.noStaleCheck !== true,
3616
+ warnings
3617
+ };
3618
+ return { plan, problems };
3619
+ }
3620
+
3621
+ // src/launch/stage.ts
3622
+ import { lstat, mkdir as mkdir3, readdir as readdir7, realpath, rm, stat as stat3 } from "node:fs/promises";
3623
+ import { basename as basename6, join as join13 } from "node:path";
3624
+ async function stageMods(plan) {
3625
+ await rm(plan.stageDirHost, { recursive: true, force: true });
3626
+ await mkdir3(plan.stageDirHost, { recursive: true });
3627
+ const mounts = [];
3628
+ for (const mod of plan.mods) {
3629
+ if (mod.kind === "core" || mod.kind === "official")
3630
+ continue;
3631
+ let source;
3632
+ try {
3633
+ source = await realpath(mod.hostDir);
3634
+ } catch {
3635
+ throw new GamecrateError(`mod directory for ${mod.packageId} is missing`, Exit.Environment, mod.hostDir);
3636
+ }
3637
+ if (!(await stat3(source)).isDirectory()) {
3638
+ throw new GamecrateError(`${mod.packageId} does not resolve to a directory`, Exit.Environment, source);
3639
+ }
3640
+ await mkdir3(join13(plan.stageDirHost, basename6(mod.containerDir)), { recursive: true });
3641
+ mounts.push({ type: "bind", source, target: mod.containerDir, readonly: true });
3642
+ }
3643
+ return mounts;
3644
+ }
3645
+ async function ensureProfileTree(plan) {
3646
+ for (const dir of [
3647
+ plan.profileDir,
3648
+ plan.instanceDir,
3649
+ plan.dataDirHost,
3650
+ join13(plan.configDirHost, "config"),
3651
+ join13(plan.configDirHost, "data"),
3652
+ join13(plan.configDirHost, "cache"),
3653
+ join13(plan.logsDirHost, "runs"),
3654
+ plan.stageDirHost,
3655
+ join13(plan.instanceDir, ".gamecrate"),
3656
+ ...engineDirs(plan)
3657
+ ]) {
3658
+ await mkdir3(dir, { recursive: true });
3659
+ }
3660
+ }
3661
+ function engineDirs(plan) {
3662
+ const { dataDir, modsDir } = plan.gameConfig;
3663
+ if (!modsDir.container.startsWith(`${dataDir.container}/`))
3664
+ return [];
3665
+ return [join13(plan.dataDirHost, modsDir.container.slice(dataDir.container.length + 1))];
3666
+ }
3667
+ async function detectForeignOwnership(dir, uid, limit = 100) {
3668
+ const foreign = [];
3669
+ const queue = [dir];
3670
+ while (queue.length > 0 && foreign.length < limit) {
3671
+ const current = queue.shift();
3672
+ let info;
3673
+ try {
3674
+ info = await lstat(current);
3675
+ } catch {
3676
+ continue;
3677
+ }
3678
+ if (info.uid !== uid) {
3679
+ foreign.push(current);
3680
+ if (foreign.length >= limit)
3681
+ break;
3682
+ }
3683
+ if (!info.isDirectory())
3684
+ continue;
3685
+ try {
3686
+ for (const entry of await readdir7(current))
3687
+ queue.push(join13(current, entry));
3688
+ } catch {
3689
+ continue;
3690
+ }
3691
+ }
3692
+ return foreign;
3693
+ }
3694
+
3695
+ // src/index.ts
3696
+ var VERSION = "0.1.0";
3697
+ var STOP_TIMEOUT_SECONDS = 10;
3698
+ async function main(argv) {
3699
+ const probe = parseArgs(argv);
3700
+ if (probe.subcommand === "version") {
3701
+ process.stdout.write(`gamecrate ${VERSION}
3702
+ `);
3703
+ return Exit.Ok;
3704
+ }
3705
+ const [{ config, plugins }, defaults] = await Promise.all([loadConfig(), loadProjectDefaults()]);
3706
+ const args = parseArgs(argv, { games: Object.keys(config.games), defaults });
3707
+ if (args.help) {
3708
+ process.stdout.write(renderHelp(helpTopic(args), config));
3709
+ return Exit.Ok;
3710
+ }
3711
+ const redirect = args.log !== undefined && (args.subcommand === "run" || args.subcommand === "shell") ? redirectOutput(args.log) : undefined;
3712
+ try {
3713
+ return await dispatch(args, config, plugins);
3714
+ } catch (error) {
3715
+ return reportFatal(error);
3716
+ } finally {
3717
+ redirect?.close();
3718
+ }
3719
+ }
3720
+ async function dispatch(args, config, plugins) {
3721
+ switch (args.subcommand) {
3722
+ case "help":
3723
+ return help(args, config);
3724
+ case "list":
3725
+ return list(args, config);
3726
+ case "mods":
3727
+ return mods(args, config, plugins);
3728
+ case "doctor":
3729
+ return doctor(config, plugins);
3730
+ case "clean":
3731
+ return clean(args, config);
3732
+ case "clone":
3733
+ return clone(args, config);
3734
+ case "logs":
3735
+ return logs(args, config);
3736
+ case "verify":
3737
+ return verify(args, config, plugins);
3738
+ case "build":
3739
+ return build(args, config);
3740
+ case "shell":
3741
+ return run(args, config, plugins, true);
3742
+ case "config":
3743
+ return configEdit(args);
3744
+ case "fix-perms":
3745
+ return fixPerms(args, config);
3746
+ case "run":
3747
+ return run(args, config, plugins, false);
3748
+ default:
3749
+ throw new GamecrateError(`no such subcommand ${args.subcommand}`, Exit.Usage);
3750
+ }
3751
+ }
3752
+ function helpTopic(args) {
3753
+ if (args.subcommand === "help")
3754
+ return args.rest[0];
3755
+ if (args.subcommand === "run")
3756
+ return args.game;
3757
+ return args.subcommand;
3758
+ }
3759
+ function help(args, config) {
3760
+ const topic = args.rest[0];
3761
+ if (topic === "completion") {
3762
+ const shell = args.rest[1];
3763
+ if (shell !== "bash" && shell !== "zsh") {
3764
+ throw new GamecrateError("help completion takes bash or zsh", Exit.Usage);
3765
+ }
3766
+ process.stdout.write(renderCompletion(shell));
3767
+ return Exit.Ok;
3768
+ }
3769
+ process.stdout.write(renderHelp(topic, config));
3770
+ return Exit.Ok;
3771
+ }
3772
+ function requireGame(args, config) {
3773
+ const game2 = args.game;
3774
+ if (game2 === undefined) {
3775
+ throw new GamecrateError(`${args.subcommand} needs a game`, Exit.Usage, `known games: ${Object.keys(config.games).join(", ")}`);
3776
+ }
3777
+ if (!Object.hasOwn(config.games, game2)) {
3778
+ throw new GamecrateError(`unknown game "${game2}"`, Exit.Config, `known games: ${Object.keys(config.games).join(", ")}`);
3779
+ }
3780
+ return game2;
3781
+ }
3782
+ function instanceDir(args, config, game2, profile2) {
3783
+ const dir = profileDataDir(config, game2, profile2);
3784
+ let spec;
3785
+ try {
3786
+ spec = resolveProfile(config.games[game2], profile2);
3787
+ } catch {
3788
+ spec = undefined;
3789
+ }
3790
+ return resolveInstance({ profileDir: dir, ...spec === undefined ? {} : { profile: spec }, args }).dir;
3791
+ }
3792
+ function reportEnvironment(problems) {
3793
+ process.stderr.write(`${problems.length} environment problem(s):
3794
+ `);
3795
+ for (const problem of problems) {
3796
+ process.stderr.write(` ${problem.where}
3797
+ ${problem.message}
3798
+ `);
3799
+ if (problem.suggestion)
3800
+ process.stderr.write(` try: ${problem.suggestion}
3801
+ `);
3802
+ }
3803
+ process.exit(Exit.Environment);
3804
+ }
3805
+ async function run(args, config, plugins, asShell) {
3806
+ const game2 = requireGame(args, config);
3807
+ const profile2 = args.profile ?? "modless";
3808
+ const index = await buildIndex(game2, config.games[game2], requirePlugin(plugins, game2));
3809
+ const { plan, problems } = await resolvePlan({ game: game2, profile: profile2, root: config, plugins, args, index });
3810
+ if (problems.length > 0)
3811
+ reportProblems(problems);
3812
+ const identity = resolveIdentity(args.root);
3813
+ if (args.printPlan || args.dryRun) {
3814
+ const environment2 = await preflight(plan);
3815
+ buildRunSpec(plan, [], identity);
3816
+ if (args.printPlan)
3817
+ printPlan(plan, args.json);
3818
+ for (const warning of planWarnings(plan))
3819
+ warn(warning);
3820
+ if (environment2.length > 0)
3821
+ return reportEnvironment(environment2);
3822
+ if (!args.printPlan) {
3823
+ const what = plan.instance === undefined ? profile2 : `${profile2}/${plan.instance}`;
3824
+ status(`${game2} ${what}: ${plan.mods.length} mods resolve cleanly`);
3825
+ }
3826
+ return Exit.Ok;
3827
+ }
3828
+ const environment = await preflight(plan);
3829
+ if (environment.length > 0)
3830
+ return reportEnvironment(environment);
3831
+ await ensureProfileTree(plan);
3832
+ if (args.replace)
3833
+ await replacePrevious(plan);
3834
+ const lock = await takeLock(plan);
3835
+ try {
3836
+ return await launch(plan, args, config, identity, asShell);
3837
+ } finally {
3838
+ await lock.release();
3839
+ }
3840
+ }
3841
+ async function launch(plan, args, config, identity, asShell) {
3842
+ const game2 = plan.game;
3843
+ const profile2 = plan.profile;
3844
+ const foreign = await detectForeignOwnership(plan.dataDirHost, identity.uid, 5);
3845
+ if (foreign.length > 0) {
3846
+ throw new GamecrateError(`${foreign.length} path(s) under ${plan.dataDirHost} are not owned by uid ${identity.uid}`, Exit.Environment, `${foreign.join(`
3847
+ `)}
3848
+ run: gamecrate fix-perms ${game2} ${profile2}`);
3849
+ }
3850
+ const runDir = openRunLog(plan.logsDirHost);
3851
+ plan.runDirHost = runDir;
3852
+ await buildLocalMods(plan, args.build ?? "auto");
3853
+ await acquireImage(game2, config.games[game2], args.pull ?? "missing");
3854
+ const runtimeImage = plan.mode === "headed" ? config.games[game2].image.ref : await ensureRuntimeLayer(config.games[game2].image.ref);
3855
+ const modMounts = await stageMods(plan);
3856
+ await generateModsConfig(plan);
3857
+ await mergePrefs(plan);
3858
+ for (const warning of planWarnings(plan))
3859
+ warn(warning);
3860
+ const spec = buildRunSpec(plan, modMounts, identity);
3861
+ spec.image = runtimeImage;
3862
+ if (asShell) {
3863
+ spec.command = ["/bin/bash"];
3864
+ spec.extraArgs = [...spec.extraArgs, "--interactive", "--tty"];
3865
+ }
3866
+ await writeLaunchRecord(plan, spec.image);
3867
+ try {
3868
+ if (plan.marker !== undefined && !asShell)
3869
+ return await runWithMarker(spec, plan, runDir);
3870
+ if (plan.mode === "screenshot" && !asShell)
3871
+ return await runWithScreenshot(spec, plan, runDir);
3872
+ if (plan.mode !== "headed" && !asShell)
3873
+ return await runBounded(spec, plan, runDir);
3874
+ let windowClosed = false;
3875
+ const window = asShell || plan.settings.display !== "x11" ? null : await adoptNewWindow({
3876
+ executable: plan.gameConfig.executable,
3877
+ title: windowTitle(plan),
3878
+ stripDelete: plan.gameConfig.ignoresWmDelete === true,
3879
+ onClosed: () => {
3880
+ windowClosed = true;
3881
+ stopContainer(spec.name, STOP_TIMEOUT_SECONDS);
3882
+ }
3883
+ });
3884
+ try {
3885
+ const code = await runContainer(spec, {
3886
+ logDir: runDir,
3887
+ stopTimeoutSeconds: STOP_TIMEOUT_SECONDS
3888
+ });
3889
+ return windowClosed ? Exit.Ok : normalize(code);
3890
+ } finally {
3891
+ window?.stop();
3892
+ }
3893
+ } finally {
3894
+ await copyOutLogs(plan);
3895
+ }
3896
+ }
3897
+ function markerSources(plan, logDir) {
3898
+ const sources = [join14(logDir, STDOUT_LOG)];
3899
+ const { logFile } = plan.gameConfig;
3900
+ if (logFile.mode === "arg")
3901
+ sources.push(join14(logDir, "Player.log"));
3902
+ else
3903
+ sources.push(join14(plan.dataDirHost, logFile.from));
3904
+ return sources;
3905
+ }
3906
+ async function runBounded(spec, plan, logDir) {
3907
+ const container = runContainer(spec, { logDir, stopTimeoutSeconds: STOP_TIMEOUT_SECONDS });
3908
+ const winner = await Promise.race([
3909
+ container.then((code) => ({ kind: "exit", code })),
3910
+ sleep4(plan.timeoutSeconds * 1000).then(() => ({ kind: "timeout" }))
3911
+ ]);
3912
+ if (winner.kind === "exit")
3913
+ return normalize(winner.code);
3914
+ status(`no marker given; stopping after ${plan.timeoutSeconds}s`);
3915
+ await stopContainer(spec.name, STOP_TIMEOUT_SECONDS);
3916
+ await container;
3917
+ return Exit.Ok;
3918
+ }
3919
+ async function runWithScreenshot(spec, plan, logDir) {
3920
+ const container = runContainer(spec, { logDir, stopTimeoutSeconds: STOP_TIMEOUT_SECONDS });
3921
+ const settled = sleep4(plan.renderWaitSeconds * 1000).then(() => "ready");
3922
+ const winner = await Promise.race([
3923
+ container.then((code) => ({ kind: "exit", code })),
3924
+ settled.then(() => ({ kind: "ready" }))
3925
+ ]);
3926
+ if (winner.kind === "exit") {
3927
+ status(`game exited before the ${plan.renderWaitSeconds}s render wait finished; no frame captured`);
3928
+ return normalize(winner.code);
3929
+ }
3930
+ const shot = await grabFrame(spec.name, plan);
3931
+ await stopContainer(spec.name, STOP_TIMEOUT_SECONDS);
3932
+ await container;
3933
+ return shot === null ? Exit.Environment : Exit.Ok;
3934
+ }
3935
+ async function grabFrame(container, plan) {
3936
+ const path = await captureScreenshot(container, plan);
3937
+ if (path === null)
3938
+ warn("screenshot capture failed; is imagemagick in the image?");
3939
+ else
3940
+ status(`screenshot: ${path}`);
3941
+ return path;
3942
+ }
3943
+ async function runWithMarker(spec, plan, logDir) {
3944
+ const marker = plan.marker;
3945
+ const container = runContainer(spec, { logDir, stopTimeoutSeconds: STOP_TIMEOUT_SECONDS });
3946
+ const seen = waitForMarker(markerSources(plan, logDir), marker, plan.timeoutSeconds);
3947
+ const winner = await Promise.race([
3948
+ container.then((code) => ({ kind: "exit", code })),
3949
+ seen.then((hit) => ({ kind: "marker", hit }))
3950
+ ]);
3951
+ if (winner.kind === "exit")
3952
+ return normalize(winner.code);
3953
+ if (plan.mode === "screenshot")
3954
+ await grabFrame(spec.name, plan);
3955
+ await stopContainer(spec.name, STOP_TIMEOUT_SECONDS);
3956
+ await container;
3957
+ if (winner.hit) {
3958
+ status(`marker seen: ${marker}`);
3959
+ return Exit.Ok;
3960
+ }
3961
+ status(`marker "${marker}" not seen within ${plan.timeoutSeconds}s`);
3962
+ return Exit.MarkerTimeout;
3963
+ }
3964
+ function normalize(code) {
3965
+ return Number.isInteger(code) && code >= 0 && code <= 255 ? code : Exit.GameFailed;
3966
+ }
3967
+ async function copyOutLogs(plan) {
3968
+ const spec = plan.gameConfig.logFile;
3969
+ if (spec.mode !== "copy-out")
3970
+ return;
3971
+ const source = join14(plan.dataDirHost, spec.from);
3972
+ if (!existsSync7(source))
3973
+ return;
3974
+ const target = join14(plan.runDirHost, basename7(spec.from.replace(/\/+$/, "")));
3975
+ try {
3976
+ await cp(source, target, { recursive: true, force: true });
3977
+ } catch (error) {
3978
+ warn(`could not copy ${source}: ${describe(error)}`);
3979
+ }
3980
+ }
3981
+ function list(args, config) {
3982
+ const games = args.game === undefined ? Object.keys(config.games) : [requireGame(args, config)];
3983
+ if (args.json) {
3984
+ const payload = games.map((name) => {
3985
+ const game2 = config.games[name];
3986
+ return {
3987
+ game: name,
3988
+ core: game2.core,
3989
+ dlc: game2.dlc,
3990
+ modes: game2.modes,
3991
+ profiles: Object.entries(game2.profiles).map(([profile2, spec]) => ({
3992
+ profile: profile2,
3993
+ alias: spec.alias ?? null,
3994
+ extends: spec.extends ?? null,
3995
+ mods: spec.mods?.length ?? 0,
3996
+ instances: Object.keys(spec.instances ?? {})
3997
+ }))
3998
+ };
3999
+ });
4000
+ process.stdout.write(`${JSON.stringify(payload, null, 2)}
4001
+ `);
4002
+ return Exit.Ok;
4003
+ }
4004
+ const out = [];
4005
+ for (const name of games) {
4006
+ const game2 = config.games[name];
4007
+ const width = Math.max(7, ...Object.keys(game2.profiles).map((n) => n.length));
4008
+ out.push(`${name} (${game2.modes.join(", ")})`);
4009
+ out.push(` ${"modless".padEnd(width)} built-in: core + official DLC`);
4010
+ for (const [profile2, spec] of Object.entries(game2.profiles)) {
4011
+ const notes = [];
4012
+ if (spec.alias)
4013
+ notes.push(`alias for ${spec.alias}`);
4014
+ if (spec.extends)
4015
+ notes.push(`extends ${spec.extends}`);
4016
+ const count = spec.mods?.length ?? 0;
4017
+ if (!spec.alias)
4018
+ notes.push(count === 1 ? "1 entry" : `${count} entries`);
4019
+ if (spec.aliases?.length)
4020
+ notes.push(`aka ${spec.aliases.join(", ")}`);
4021
+ out.push(` ${profile2.padEnd(width)} ${notes.join(", ")}`);
4022
+ const instances = Object.keys(spec.instances ?? {});
4023
+ if (instances.length > 0)
4024
+ out.push(` ${" ".repeat(width)} instances: ${instances.join(", ")}`);
4025
+ }
4026
+ }
4027
+ process.stdout.write(`${out.join(`
4028
+ `)}
4029
+ `);
4030
+ return Exit.Ok;
4031
+ }
4032
+ async function mods(args, config, plugins) {
4033
+ const game2 = requireGame(args, config);
4034
+ const profile2 = args.profile ?? "modless";
4035
+ const index = await buildIndex(game2, config.games[game2], requirePlugin(plugins, game2));
4036
+ const { plan, problems } = await resolvePlan({ game: game2, profile: profile2, root: config, plugins, args, index });
4037
+ if (problems.length > 0)
4038
+ reportProblems(problems);
4039
+ printPlan(plan, args.json);
4040
+ return Exit.Ok;
4041
+ }
4042
+ async function doctor(config, plugins) {
4043
+ let failed = false;
4044
+ for (const game2 of Object.keys(config.games)) {
4045
+ const { plan, problems } = await resolvePlan({ game: game2, profile: "modless", root: config, plugins });
4046
+ const all = [...problems, ...await preflight(plan)];
4047
+ if (all.length === 0) {
4048
+ status(`${game2}: ok`);
4049
+ continue;
4050
+ }
4051
+ failed = true;
4052
+ status(`${game2}: ${all.length} problem(s)`);
4053
+ for (const problem of all) {
4054
+ process.stderr.write(` ${problem.where}
4055
+ ${problem.message}
4056
+ `);
4057
+ if (problem.suggestion)
4058
+ process.stderr.write(` try: ${problem.suggestion}
4059
+ `);
4060
+ }
4061
+ }
4062
+ return failed ? Exit.Environment : Exit.Ok;
4063
+ }
4064
+ async function logs(args, config) {
4065
+ const game2 = requireGame(args, config);
4066
+ const profile2 = args.profile ?? "modless";
4067
+ const runs = join14(instanceDir(args, config, game2, profile2), "logs", "runs");
4068
+ const latest = (await readdir8(runs, { withFileTypes: true }).catch(() => [])).filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort().at(-1);
4069
+ if (latest === undefined) {
4070
+ throw new GamecrateError(`no runs recorded for ${game2} ${profile2}`, Exit.Usage, runs);
4071
+ }
4072
+ const dir = join14(runs, latest);
4073
+ const files = (await readdir8(dir, { withFileTypes: true })).filter((entry) => entry.isFile()).map((entry) => entry.name).sort();
4074
+ if (args.json) {
4075
+ process.stdout.write(`${JSON.stringify({ run: latest, dir, files }, null, 2)}
4076
+ `);
4077
+ return Exit.Ok;
4078
+ }
4079
+ status(dir);
4080
+ for (const name of files) {
4081
+ const text = await readFile5(join14(dir, name), "utf8").catch(() => "");
4082
+ for (const line2 of text.split(`
4083
+ `)) {
4084
+ if (line2.length > 0)
4085
+ process.stdout.write(`${name}: ${line2}
4086
+ `);
4087
+ }
4088
+ }
4089
+ return Exit.Ok;
4090
+ }
4091
+ async function inspectContainer(name) {
4092
+ const { code, stdout: text } = await capture(["docker", "inspect", name]);
4093
+ if (code !== 0)
4094
+ return null;
4095
+ let first;
4096
+ try {
4097
+ first = JSON.parse(text)[0];
4098
+ } catch {
4099
+ return null;
4100
+ }
4101
+ if (first === undefined)
4102
+ return null;
4103
+ const startedAt = Date.parse(first.State?.StartedAt ?? "");
4104
+ return {
4105
+ running: first.State?.Running === true,
4106
+ startedAt: Number.isNaN(startedAt) ? Date.now() : startedAt,
4107
+ mounts: (first.Mounts ?? []).filter((m) => m.Source !== undefined && m.Destination !== undefined).map((m) => ({ source: m.Source, destination: m.Destination }))
4108
+ };
4109
+ }
4110
+ function boundStatus(mod) {
4111
+ if (mod.report !== null) {
4112
+ const files = mod.report.newerCount === 1 ? "1 source" : `${mod.report.newerCount} sources`;
4113
+ return `STALE - ${files} newer`;
4114
+ }
4115
+ if (mod.assembly !== null)
4116
+ return "OK";
4117
+ return mod.hasSources ? "STALE - never built" : "(xml only)";
4118
+ }
4119
+ async function verify(args, config, plugins) {
4120
+ const game2 = requireGame(args, config);
4121
+ const profile2 = args.profile ?? "modless";
4122
+ const { plan, problems } = await resolvePlan({ game: game2, profile: profile2, root: config, plugins, args });
4123
+ if (problems.length > 0)
4124
+ reportProblems(problems);
4125
+ const name = containerName(plan);
4126
+ const info = await inspectContainer(name);
4127
+ if (info === null || !info.running) {
4128
+ throw new GamecrateError(`no container named ${name} is running`, Exit.Environment, "launch it first, or name the run with --instance or --worktree");
4129
+ }
4130
+ const prefix = `${plan.gameConfig.modsDir.container}/`;
4131
+ const bound = info.mounts.filter((m) => m.destination.startsWith(prefix)).filter((m) => !m.destination.slice(prefix.length).includes("/")).sort((a, b) => a.destination < b.destination ? -1 : 1);
4132
+ const boundMods = await Promise.all(bound.map(async (mount) => {
4133
+ const times = await scanBuildTimes(mount.source);
4134
+ const request = resolveWorktree(mount.source, "ref", 0);
4135
+ return {
4136
+ packageId: mount.destination.slice(prefix.length),
4137
+ hostDir: mount.source,
4138
+ branch: "root" in request ? request.branch : null,
4139
+ assembly: times.newestAssembly ?? null,
4140
+ hasSources: times.newestSource !== undefined,
4141
+ stale: decideStale(times),
4142
+ report: staleReport(times)
4143
+ };
4144
+ }));
4145
+ const stale = boundMods.filter((mod) => mod.stale);
4146
+ if (args.json) {
4147
+ const payload = {
4148
+ container: name,
4149
+ running: true,
4150
+ upSeconds: Math.round((Date.now() - info.startedAt) / 1000),
4151
+ instance: plan.instance ?? plan.profile,
4152
+ stale: stale.length,
4153
+ mods: boundMods.map((mod) => ({
4154
+ packageId: mod.packageId,
4155
+ hostDir: mod.hostDir,
4156
+ branch: mod.branch,
4157
+ assembly: mod.assembly?.path ?? null,
4158
+ assemblyMs: mod.assembly?.mtimeMs ?? null,
4159
+ stale: mod.stale,
4160
+ status: boundStatus(mod),
4161
+ ...mod.report === null ? {} : { report: mod.report }
4162
+ }))
4163
+ };
4164
+ process.stdout.write(`${JSON.stringify(payload, null, 2)}
4165
+ `);
4166
+ return stale.length > 0 ? Exit.Stale : Exit.Ok;
4167
+ }
4168
+ process.stdout.write(`${renderVerify(name, info, plan, boundMods)}
4169
+ `);
4170
+ return stale.length > 0 ? Exit.Stale : Exit.Ok;
4171
+ }
4172
+ function renderVerify(name, info, plan, boundMods) {
4173
+ const out = [
4174
+ "",
4175
+ ` container ${name} (up ${duration(Date.now() - info.startedAt)})`,
4176
+ ` instance ${plan.instance ?? plan.profile}`,
4177
+ ""
4178
+ ];
4179
+ if (boundMods.length === 0) {
4180
+ out.push(" no boundMods are bind-mounted into this container");
4181
+ return out.join(`
4182
+ `);
4183
+ }
4184
+ const paths = boundMods.map((mod) => shortenHome(mod.hostDir));
4185
+ const idWidth = Math.max(...boundMods.map((mod) => mod.packageId.length));
4186
+ const pathWidth = Math.max(...paths.map((p) => p.length));
4187
+ const stamps = boundMods.map((mod) => mod.assembly === null ? "no assemblies" : `${mod.assembly.path} ${ago(mod.assembly.mtimeMs)}`);
4188
+ const stampWidth = Math.max(...stamps.map((s) => s.length));
4189
+ for (const [i, mod] of boundMods.entries()) {
4190
+ const origin2 = mod.branch === null ? "" : `worktree ${mod.branch}`;
4191
+ out.push(` ${mod.packageId.padEnd(idWidth)} ${paths[i].padEnd(pathWidth)} ${origin2}`.trimEnd());
4192
+ out.push(` ${" ".repeat(idWidth)} ${stamps[i].padEnd(stampWidth)} ${boundStatus(mod)}`);
4193
+ }
4194
+ return out.join(`
4195
+ `);
4196
+ }
4197
+ function shortenHome(path) {
4198
+ const home = homedir5();
4199
+ return path === home || path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path;
4200
+ }
4201
+ async function clean(args, config) {
4202
+ const game2 = requireGame(args, config);
4203
+ const profile2 = args.profile;
4204
+ if (profile2 === undefined)
4205
+ throw new GamecrateError("clean needs a profile", Exit.Usage);
4206
+ const dir = profileDataDir(config, game2, profile2);
4207
+ const tier2 = args.cleanTier ?? "staging";
4208
+ const saveSuffixes = config.games[game2].saveExtensions.map((ext) => `.${ext.replace(/^\./, "")}`.toLowerCase());
4209
+ if (tier2 !== "all") {
4210
+ const target = join14(instanceDir(args, config, game2, profile2), tier2 === "logs" ? "logs" : ".stage");
4211
+ await rm2(target, { recursive: true, force: true });
4212
+ status(`removed ${target}`);
4213
+ return Exit.Ok;
4214
+ }
4215
+ const saves = await countSaves(dir, saveSuffixes);
4216
+ if (!args.yes) {
4217
+ throw new GamecrateError(`clean --all would delete ${dir}, including ${saves} save file(s)`, Exit.Usage, "add --yes to confirm");
4218
+ }
4219
+ await rm2(dir, { recursive: true, force: true });
4220
+ status(`removed ${dir} (${saves} save file(s))`);
4221
+ return Exit.Ok;
4222
+ }
4223
+ async function countSaves(dir, suffixes) {
4224
+ let count = 0;
4225
+ const queue = [dir];
4226
+ while (queue.length > 0) {
4227
+ const current = queue.shift();
4228
+ let entries;
4229
+ try {
4230
+ entries = await readdir8(current, { withFileTypes: true });
4231
+ } catch {
4232
+ continue;
4233
+ }
4234
+ for (const entry of entries) {
4235
+ if (entry.isDirectory())
4236
+ queue.push(join14(current, entry.name));
4237
+ else if (suffixes.some((s) => entry.name.toLowerCase().endsWith(s)))
4238
+ count++;
4239
+ }
4240
+ }
4241
+ return count;
4242
+ }
4243
+ async function clone(args, config) {
4244
+ const game2 = requireGame(args, config);
4245
+ const [src, dst] = args.rest;
4246
+ if (src === undefined || dst === undefined) {
4247
+ throw new GamecrateError("clone needs a source and a destination profile", Exit.Usage);
4248
+ }
4249
+ const from = join14(profileDataDir(config, game2, src), "game");
4250
+ const to = join14(profileDataDir(config, game2, dst), "game");
4251
+ if (!existsSync7(from))
4252
+ throw new GamecrateError(`${from} does not exist`, Exit.Usage);
4253
+ if (existsSync7(to) && !args.yes) {
4254
+ throw new GamecrateError(`${to} already exists`, Exit.Usage, "add --yes to overwrite");
4255
+ }
4256
+ await mkdir4(to, { recursive: true });
4257
+ const code = await spawnStatus(["cp", "-a", "--reflink=auto", `${from}/.`, to]);
4258
+ if (code !== 0)
4259
+ throw new GamecrateError(`cp failed with exit ${code}`, Exit.Environment);
4260
+ status(`cloned ${from} -> ${to}`);
4261
+ return Exit.Ok;
4262
+ }
4263
+ async function build(args, config) {
4264
+ const game2 = requireGame(args, config);
4265
+ await acquireImage(game2, config.games[game2], args.pull ?? "always");
4266
+ status(`${config.games[game2].image.ref} is ready`);
4267
+ return Exit.Ok;
4268
+ }
4269
+ async function configEdit(args) {
4270
+ if (args.rest[0] !== "edit")
4271
+ throw new GamecrateError("config takes one word: edit", Exit.Usage);
4272
+ const path = defaultConfigPath();
4273
+ await mkdir4(dirname5(path), { recursive: true });
4274
+ if (!existsSync7(path))
4275
+ await writeFile4(path, `{
4276
+ "games": {}
4277
+ }
4278
+ `);
4279
+ const editor = process.env.VISUAL ?? process.env.EDITOR;
4280
+ if (editor === undefined)
4281
+ throw new GamecrateError("no $EDITOR or $VISUAL set", Exit.Usage, path);
4282
+ if (await spawnStatus([...editor.split(" "), path], true) !== 0)
4283
+ return Exit.Usage;
4284
+ await loadConfig(path);
4285
+ status(`${path} is valid`);
4286
+ return Exit.Ok;
4287
+ }
4288
+ async function fixPerms(args, config) {
4289
+ const game2 = requireGame(args, config);
4290
+ const identity = resolveIdentity(false);
4291
+ const found = [];
4292
+ for (const dir of await profileDirs(config, game2, args.profile)) {
4293
+ if (!existsSync7(dir))
4294
+ continue;
4295
+ found.push(...await detectForeignOwnership(dir, identity.uid, 1e4));
4296
+ }
4297
+ if (found.length === 0) {
4298
+ status(`${game2}: every path is owned by uid ${identity.uid}`);
4299
+ return Exit.Ok;
4300
+ }
4301
+ if (args.dryRun || !args.yes) {
4302
+ for (const path of found)
4303
+ process.stdout.write(`would chown ${identity.uid}:${identity.gid} ${path}
4304
+ `);
4305
+ status(`${found.length} foreign-owned path(s); re-run with --yes to chown them`);
4306
+ return Exit.Environment;
4307
+ }
4308
+ let fixed = 0;
4309
+ const stuck = [];
4310
+ for (const path of found) {
4311
+ const chowned = await chown(path, identity.uid, identity.gid).then(() => true, () => false);
4312
+ if (chowned) {
4313
+ fixed += 1;
4314
+ continue;
4315
+ }
4316
+ if (await removeIfEmptyDir(path)) {
4317
+ status(`removed empty ${path}; it will be recreated on the next run`);
4318
+ fixed += 1;
4319
+ continue;
4320
+ }
4321
+ stuck.push(path);
4322
+ }
4323
+ if (fixed > 0)
4324
+ status(`fixed ${fixed} path(s) for ${identity.uid}:${identity.gid}`);
4325
+ if (stuck.length > 0) {
4326
+ for (const path of stuck)
4327
+ warn(`cannot chown ${path}`);
4328
+ throw new GamecrateError(`${stuck.length} path(s) still not owned by uid ${identity.uid}`, Exit.Environment, `sudo chown -R ${identity.uid}:${identity.gid} ${stuck.join(" ")}`);
4329
+ }
4330
+ return Exit.Ok;
4331
+ }
4332
+ async function removeIfEmptyDir(path) {
4333
+ const info = await stat4(path).catch(() => null);
4334
+ if (info === null || !info.isDirectory())
4335
+ return false;
4336
+ const entries = await readdir8(path).catch(() => null);
4337
+ if (entries === null || entries.length > 0)
4338
+ return false;
4339
+ return rmdir(path).then(() => true, () => false);
4340
+ }
4341
+ async function spawnStatus(argv, interactive = false) {
4342
+ const proc = spawnArgv(argv, [interactive ? "inherit" : "ignore", "inherit", "inherit"]);
4343
+ return exited(proc);
4344
+ }
4345
+ function describe(error) {
4346
+ return error instanceof Error ? error.message : String(error);
4347
+ }
4348
+ function reportFatal(error) {
4349
+ if (error instanceof GamecrateError) {
4350
+ process.stderr.write(`gamecrate: ${error.message}
4351
+ `);
4352
+ if (error.detail)
4353
+ process.stderr.write(`${error.detail}
4354
+ `);
4355
+ return error.code;
4356
+ }
4357
+ process.stderr.write(`gamecrate: ${describe(error)}
4358
+ `);
4359
+ return Exit.GameFailed;
4360
+ }
4361
+ try {
4362
+ process.exit(await main(process.argv.slice(2)));
4363
+ } catch (error) {
4364
+ process.exit(reportFatal(error));
4365
+ }