@gamecrate/cli 0.1.0 → 1.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.
Files changed (58) hide show
  1. package/README.md +204 -33
  2. package/dist/gamecrate.js +1486 -715
  3. package/dist/lib.js +320 -0
  4. package/dist/types/cli/args.d.ts +56 -0
  5. package/dist/types/cli/game.d.ts +2 -0
  6. package/dist/types/cli/help.d.ts +7 -0
  7. package/dist/types/cli/list.d.ts +2 -0
  8. package/dist/types/cli/output.d.ts +78 -0
  9. package/dist/types/cli/profile.d.ts +6 -0
  10. package/dist/types/config/builtin.d.ts +3 -0
  11. package/dist/types/config/jsonc.d.ts +5 -0
  12. package/dist/types/config/load.d.ts +47 -0
  13. package/dist/types/config/read.d.ts +11 -0
  14. package/dist/types/config/validate.d.ts +12 -0
  15. package/dist/types/docker/identity.d.ts +6 -0
  16. package/dist/types/docker/preflight.d.ts +3 -0
  17. package/dist/types/docker/run.d.ts +39 -0
  18. package/dist/types/docker/spec.d.ts +24 -0
  19. package/dist/types/docker/window.d.ts +50 -0
  20. package/dist/types/index.d.ts +2 -0
  21. package/dist/types/launch/generate.d.ts +8 -0
  22. package/dist/types/launch/instance.d.ts +20 -0
  23. package/dist/types/launch/prepare.d.ts +87 -0
  24. package/dist/types/launch/resolve.d.ts +17 -0
  25. package/dist/types/launch/stage.d.ts +13 -0
  26. package/dist/types/launch/supervisor.d.ts +48 -0
  27. package/dist/types/lib.d.ts +3 -0
  28. package/dist/types/mods/modindex.d.ts +29 -0
  29. package/dist/types/mods/staleness.d.ts +28 -0
  30. package/dist/types/mods/worktree.d.ts +18 -0
  31. package/dist/types/plugin.d.ts +35 -0
  32. package/dist/types/run/registry.d.ts +22 -0
  33. package/dist/types/types.d.ts +428 -0
  34. package/package.json +15 -10
  35. package/src/cli/args.ts +0 -592
  36. package/src/cli/help.ts +0 -193
  37. package/src/cli/output.ts +0 -246
  38. package/src/config/builtin.ts +0 -19
  39. package/src/config/jsonc.ts +0 -21
  40. package/src/config/load.ts +0 -387
  41. package/src/config/validate.ts +0 -0
  42. package/src/docker/identity.ts +0 -25
  43. package/src/docker/preflight.ts +0 -243
  44. package/src/docker/run.ts +0 -212
  45. package/src/docker/spec.ts +0 -357
  46. package/src/docker/window.ts +0 -152
  47. package/src/index.ts +0 -875
  48. package/src/launch/generate.ts +0 -151
  49. package/src/launch/instance.ts +0 -106
  50. package/src/launch/prepare.ts +0 -332
  51. package/src/launch/resolve.ts +0 -383
  52. package/src/launch/stage.ts +0 -97
  53. package/src/lib.ts +0 -22
  54. package/src/mods/modindex.ts +0 -539
  55. package/src/mods/staleness.ts +0 -125
  56. package/src/mods/worktree.ts +0 -107
  57. package/src/plugin.ts +0 -152
  58. package/src/types.ts +0 -423
package/dist/gamecrate.js CHANGED
@@ -1,11 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
 
3
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";
4
+ import { existsSync as existsSync8 } from "node:fs";
5
+ import { chown, cp, mkdir as mkdir4, readdir as readdir9, readFile as readFile7, rm as rm3, rmdir, stat as stat4, writeFile as writeFile5 } from "node:fs/promises";
6
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";
7
+ import { basename as basename10, dirname as dirname5, join as join16 } from "node:path";
8
+ import { setTimeout as sleep5 } from "node:timers/promises";
9
9
 
10
10
  // src/cli/args.ts
11
11
  import { Command, CommanderError, Option } from "commander";
@@ -23,6 +23,9 @@ var Exit = {
23
23
  Stale: 8,
24
24
  Interrupted: 130
25
25
  };
26
+ function reasonFor(code) {
27
+ return code === Exit.Interrupted ? "stopped" : "exited";
28
+ }
26
29
 
27
30
  class GamecrateError extends Error {
28
31
  code;
@@ -49,7 +52,11 @@ var RESERVED_NAMES = [
49
52
  "verify",
50
53
  "help",
51
54
  "version",
52
- "modless"
55
+ "modless",
56
+ "ps",
57
+ "stop",
58
+ "attach",
59
+ "wait"
53
60
  ];
54
61
  var NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
55
62
  function own(bag, key) {
@@ -74,6 +81,8 @@ var RUN_FLAGS = [
74
81
  "--no-stale-check",
75
82
  "--replace",
76
83
  "--no-replace",
84
+ "--detach",
85
+ "--no-detach",
77
86
  "--sort",
78
87
  "--docker-arg",
79
88
  "--dry-run",
@@ -116,7 +125,7 @@ var SUBCOMMANDS = [
116
125
  {
117
126
  name: "clean",
118
127
  summary: "tiered wipe of a profile",
119
- usage: "<game> <profile>",
128
+ usage: "<game> [profile]",
120
129
  positionals: ["game", "profile"],
121
130
  flags: ["--staging", "--logs", "--all", "--yes", "--instance", "--worktree", "--no-worktree"]
122
131
  },
@@ -130,7 +139,35 @@ var SUBCOMMANDS = [
130
139
  {
131
140
  name: "logs",
132
141
  summary: "tail or open the last run's captured logs",
133
- usage: "<game> <profile>",
142
+ usage: "<game> [profile]",
143
+ positionals: ["game", "profile"],
144
+ flags: ["--instance", "--worktree", "--no-worktree", "--follow"]
145
+ },
146
+ {
147
+ name: "attach",
148
+ summary: "stream a detached run's output; ctrl-c leaves the game running",
149
+ usage: "<game> [profile]",
150
+ positionals: ["game", "profile"],
151
+ flags: ["--instance", "--worktree", "--no-worktree"]
152
+ },
153
+ {
154
+ name: "wait",
155
+ summary: "block until a detached run ends, then exit with its code",
156
+ usage: "<game> [profile]",
157
+ positionals: ["game", "profile"],
158
+ flags: ["--instance", "--worktree", "--no-worktree"]
159
+ },
160
+ {
161
+ name: "ps",
162
+ summary: "every live run: game, profile/instance, mode, pid, container, uptime or status",
163
+ usage: "",
164
+ positionals: [],
165
+ flags: []
166
+ },
167
+ {
168
+ name: "stop",
169
+ summary: "stop a detached run and release its lock",
170
+ usage: "<game> [profile]",
134
171
  positionals: ["game", "profile"],
135
172
  flags: ["--instance", "--worktree", "--no-worktree"]
136
173
  },
@@ -169,7 +206,7 @@ var SUBCOMMANDS = [
169
206
  },
170
207
  {
171
208
  name: "config",
172
- summary: "open profiles.json in $EDITOR, validate on save",
209
+ summary: "open the global config in $VISUAL or $EDITOR, validate on save",
173
210
  usage: "edit",
174
211
  positionals: ["rest"],
175
212
  flags: []
@@ -231,7 +268,7 @@ function enumOption(flags, summary, values) {
231
268
  }
232
269
  function buildProgram() {
233
270
  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");
271
+ 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>", "bound a marker run, or a headless run with no marker", (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").option("--detach", "start the run in the background and return the prompt").option("--no-detach", "stay in the foreground, whatever the profile or project config asks for").addOption(new Option("--supervised <instanceDir>").hideHelp()).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("-f, --follow", "keep printing as the run writes").option("-y, --yes", "skip destructive-action confirmation").option("-h, --help", "this help");
235
272
  return program;
236
273
  }
237
274
  function checkValueTokens(program, head) {
@@ -298,6 +335,14 @@ function parseArgs(argv, opts = {}) {
298
335
  throw usage("--build and --no-build contradict");
299
336
  if (seen.has("--replace") && seen.has("--no-replace"))
300
337
  throw usage("--replace and --no-replace contradict");
338
+ if (seen.has("--detach")) {
339
+ if (seen.has("--no-detach"))
340
+ throw usage("--detach and --no-detach contradict");
341
+ if (seen.has("--dry-run"))
342
+ throw usage("--detach and --dry-run contradict");
343
+ if (seen.has("--print-plan"))
344
+ throw usage("--detach and --print-plan contradict");
345
+ }
301
346
  const values = program.opts();
302
347
  const envBuild = applyEnv(program, seen, env, values);
303
348
  const out = {
@@ -313,10 +358,15 @@ function parseArgs(argv, opts = {}) {
313
358
  root: values["root"] === true,
314
359
  yes: values["yes"] === true,
315
360
  help: values["help"] === true,
361
+ follow: values["follow"] === true,
316
362
  worktree,
317
363
  noWorktree: seen.has("--no-worktree"),
318
364
  noStaleCheck: seen.has("--no-stale-check"),
319
365
  replace: values["replace"] === true,
366
+ noReplace: seen.has("--no-replace"),
367
+ detach: values["detach"] === true,
368
+ noDetach: seen.has("--no-detach"),
369
+ supervised: typeof values["supervised"] === "string",
320
370
  use: values["use"],
321
371
  rest: []
322
372
  };
@@ -334,11 +384,12 @@ function parseArgs(argv, opts = {}) {
334
384
  out.cleanTier = cleanTier;
335
385
  if (opts.defaults?.game !== undefined)
336
386
  out.game = opts.defaults.game;
337
- if (opts.defaults?.profile !== undefined)
338
- out.profile = opts.defaults.profile;
339
387
  applyPositionals(out, program.args, opts.games);
340
388
  if (opts.defaults !== undefined)
341
389
  applyDefaults(out, seen, opts.defaults, sep !== -1);
390
+ if (seen.has("--detach") && out.subcommand === "shell") {
391
+ throw usage("shell cannot detach: a shell needs the terminal --detach gives up");
392
+ }
342
393
  return out;
343
394
  }
344
395
  function policy(value) {
@@ -486,6 +537,17 @@ function applyDefaults(out, seen, defaults, hasGameArgs) {
486
537
  out.noStaleCheck = defaults.noStaleCheck ?? out.noStaleCheck;
487
538
  if (!seen.has("--replace") && !seen.has("--no-replace"))
488
539
  out.replace = defaults.replace ?? out.replace;
540
+ if (!seen.has("--detach") && !seen.has("--no-detach"))
541
+ out.detach = defaults.detach ?? out.detach;
542
+ }
543
+ function wantsDetach(args, profile) {
544
+ return !args.supervised && !args.noDetach && (args.detach || profile.detach === true);
545
+ }
546
+ function wantsReplace(args, profile) {
547
+ return !args.supervised && !args.noReplace && (args.replace || profile.replace === true);
548
+ }
549
+ function buildPolicy(args, profile) {
550
+ return args.build ?? profile.build ?? "auto";
489
551
  }
490
552
  function truthy(value) {
491
553
  return value === "1" || value.toLowerCase() === "true" || value.toLowerCase() === "yes";
@@ -538,11 +600,107 @@ function distance(a, b) {
538
600
  }
539
601
  return prev[b.length];
540
602
  }
603
+ function supervisorArgv(userArgs, instanceDir, self = process.argv, execPath = process.execPath) {
604
+ const bin = self[1]?.startsWith("/$bunfs/") === true ? [execPath] : [execPath, self[1]];
605
+ const sep = userArgs.indexOf("--");
606
+ const head = [
607
+ ...(sep === -1 ? userArgs : userArgs.slice(0, sep)).filter((arg) => arg !== "--detach"),
608
+ "--supervised",
609
+ instanceDir
610
+ ];
611
+ const tail = sep === -1 ? [] : userArgs.slice(sep);
612
+ return [...bin, ...head, ...tail];
613
+ }
614
+ function supervisedDir(argv) {
615
+ const sep = argv.indexOf("--");
616
+ const head = sep === -1 ? argv : argv.slice(0, sep);
617
+ const at = head.indexOf("--supervised");
618
+ return at === -1 ? undefined : head[at + 1];
619
+ }
620
+
621
+ // src/cli/game.ts
622
+ function requireGame(args, config) {
623
+ const game = args.game;
624
+ if (game === undefined) {
625
+ throw new GamecrateError(`${args.subcommand} needs a game`, Exit.Usage, `known games: ${Object.keys(config.games).join(", ")}`);
626
+ }
627
+ if (!Object.hasOwn(config.games, game)) {
628
+ throw new GamecrateError(`unknown game "${game}"`, Exit.Config, `known games: ${Object.keys(config.games).join(", ")}`);
629
+ }
630
+ return game;
631
+ }
632
+
633
+ // src/cli/list.ts
634
+ import { basename } from "node:path";
635
+ function list(args, config, defaults) {
636
+ const games = args.game === undefined ? Object.keys(config.games) : [requireGame(args, config)];
637
+ const fromProject = (game, profile) => defaults.game === game && own(defaults.profiles, profile) !== undefined;
638
+ if (args.json) {
639
+ const payload = games.map((name) => {
640
+ const game = config.games[name];
641
+ return {
642
+ game: name,
643
+ core: game.core,
644
+ dlc: game.dlc,
645
+ modes: game.modes,
646
+ profiles: Object.entries(game.profiles).map(([profile, spec]) => ({
647
+ profile,
648
+ alias: spec.alias ?? null,
649
+ description: spec.description ?? null,
650
+ extends: spec.extends ?? null,
651
+ mods: spec.mods?.length ?? 0,
652
+ instances: Object.keys(spec.instances ?? {}),
653
+ source: fromProject(name, profile) ? "project" : "config"
654
+ }))
655
+ };
656
+ });
657
+ process.stdout.write(`${JSON.stringify(payload, null, 2)}
658
+ `);
659
+ return Exit.Ok;
660
+ }
661
+ const source = defaults.configPath === undefined ? "the .gamecrate project config" : basename(defaults.configPath);
662
+ const out = [];
663
+ for (const name of games) {
664
+ const game = config.games[name];
665
+ const width = Math.max(7, ...Object.keys(game.profiles).map((n) => n.length));
666
+ out.push(`${name} (${game.modes.join(", ")})`);
667
+ out.push(` ${"modless".padEnd(width)} built-in: core + official DLC`);
668
+ for (const [profile, spec] of Object.entries(game.profiles)) {
669
+ const notes = [];
670
+ if (spec.alias)
671
+ notes.push(`alias for ${spec.alias}`);
672
+ if (spec.extends)
673
+ notes.push(`extends ${spec.extends}`);
674
+ const count = spec.mods?.length ?? 0;
675
+ if (!spec.alias)
676
+ notes.push(count === 1 ? "1 entry" : `${count} entries`);
677
+ if (spec.aliases?.length)
678
+ notes.push(`aka ${spec.aliases.join(", ")}`);
679
+ if (fromProject(name, profile))
680
+ notes.push(`from ${source}`);
681
+ out.push(` ${profile.padEnd(width)} ${notes.join(", ")}`);
682
+ if (spec.description)
683
+ out.push(` ${" ".repeat(width)} ${spec.description}`);
684
+ const instances = Object.keys(spec.instances ?? {});
685
+ if (instances.length > 0)
686
+ out.push(` ${" ".repeat(width)} instances: ${instances.join(", ")}`);
687
+ }
688
+ }
689
+ process.stdout.write(`${out.join(`
690
+ `)}
691
+ `);
692
+ return Exit.Ok;
693
+ }
694
+
695
+ // src/cli/profile.ts
696
+ function profileOf(args, defaults) {
697
+ return args.profile ?? defaults.defaultProfile ?? defaults.profileOrder?.[0] ?? "modless";
698
+ }
541
699
 
542
700
  // src/cli/help.ts
543
701
  var NAME = "gamecrate";
544
702
  function flags() {
545
- return buildProgram().options;
703
+ return buildProgram().options.filter((o) => !o.hidden);
546
704
  }
547
705
  function renderHelp(topic, config) {
548
706
  if (!topic)
@@ -712,13 +870,21 @@ ${fn} "$@"
712
870
  }
713
871
 
714
872
  // 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";
873
+ import { closeSync, existsSync as existsSync2, lstatSync, mkdirSync as mkdirSync2, openSync, readdirSync, rmSync, symlinkSync, unlinkSync, writeSync } from "node:fs";
874
+ import { basename as basename3, join as join4, resolve } from "node:path";
875
+
876
+ // src/docker/run.ts
877
+ import { spawn } from "node:child_process";
878
+ import { createWriteStream, mkdirSync } from "node:fs";
879
+ import { open, readdir, stat } from "node:fs/promises";
880
+ import { join as join2 } from "node:path";
881
+ import { setTimeout as sleep } from "node:timers/promises";
882
+ import { TextDecoder } from "node:util";
717
883
 
718
884
  // src/docker/spec.ts
719
885
  import { existsSync, realpathSync } from "node:fs";
720
886
  import { homedir, hostname } from "node:os";
721
- import { basename, join } from "node:path";
887
+ import { basename as basename2, join } from "node:path";
722
888
  var CONTAINER_RUNTIME_DIR = "/tmp/xdg";
723
889
  var CONTAINER_LOG_DIR = "/logs";
724
890
  var X11_SOCKET_DIR = "/tmp/.X11-unix";
@@ -956,7 +1122,7 @@ function waylandSocket() {
956
1122
  const source = display.startsWith("/") ? display : runtime ? join(runtime, display) : null;
957
1123
  if (!source || !existsSync(source))
958
1124
  return null;
959
- return { source, name: basename(source) };
1125
+ return { source, name: basename2(source) };
960
1126
  }
961
1127
  function audioSockets() {
962
1128
  const runtime = process.env.XDG_RUNTIME_DIR;
@@ -978,9 +1144,150 @@ function hostPath(path) {
978
1144
  }
979
1145
  }
980
1146
 
1147
+ // src/docker/run.ts
1148
+ function spawnArgv(argv, stdio, detached = false) {
1149
+ return spawn(argv[0], argv.slice(1), { stdio, detached });
1150
+ }
1151
+ function exited(proc) {
1152
+ return new Promise((resolve, reject) => {
1153
+ proc.once("error", reject);
1154
+ proc.once("close", (code) => resolve(code ?? 1));
1155
+ });
1156
+ }
1157
+ async function collect2(stream) {
1158
+ const chunks = [];
1159
+ for await (const chunk of stream)
1160
+ chunks.push(chunk);
1161
+ return Buffer.concat(chunks).toString("utf8");
1162
+ }
1163
+ async function capture(argv) {
1164
+ try {
1165
+ const proc = spawnArgv(argv, ["ignore", "pipe", "pipe"]);
1166
+ const [stdout, stderr, code] = await Promise.all([
1167
+ collect2(proc.stdout),
1168
+ collect2(proc.stderr),
1169
+ exited(proc)
1170
+ ]);
1171
+ return { code, stdout, stderr };
1172
+ } catch (error) {
1173
+ return { code: 127, stdout: "", stderr: error instanceof Error ? error.message : String(error) };
1174
+ }
1175
+ }
1176
+ var STDOUT_LOG = "stdout.log";
1177
+ var MARKER_POLL_MS = 200;
1178
+ async function runContainer(spec, opts) {
1179
+ const stopTimeout = opts.stopTimeoutSeconds ?? 10;
1180
+ mkdirSync(opts.logDir, { recursive: true });
1181
+ const sink = createWriteStream(join2(opts.logDir, STDOUT_LOG));
1182
+ const proc = spawnArgv(["docker", ...toDockerArgs(spec)], ["inherit", "pipe", "pipe"]);
1183
+ let interrupted = false;
1184
+ const onSignal = () => {
1185
+ if (interrupted)
1186
+ return;
1187
+ interrupted = true;
1188
+ stopContainer(spec.name, stopTimeout);
1189
+ };
1190
+ process.on("SIGINT", onSignal);
1191
+ process.on("SIGTERM", onSignal);
1192
+ const code = exited(proc);
1193
+ try {
1194
+ await Promise.all([
1195
+ tee(proc.stdout, sink, process.stdout),
1196
+ tee(proc.stderr, sink, process.stderr)
1197
+ ]);
1198
+ const status = await code;
1199
+ return interrupted ? Exit.Interrupted : status;
1200
+ } finally {
1201
+ process.off("SIGINT", onSignal);
1202
+ process.off("SIGTERM", onSignal);
1203
+ await new Promise((resolve) => sink.end(resolve));
1204
+ }
1205
+ }
1206
+ var STOP_TIMEOUT_SECONDS = 10;
1207
+ async function stopContainer(name, timeoutSeconds) {
1208
+ const proc = spawnArgv(["docker", "stop", "--timeout", String(timeoutSeconds), name], "ignore");
1209
+ await exited(proc).catch(() => {});
1210
+ }
1211
+ async function waitForMarker(sources, marker, timeoutSeconds) {
1212
+ const deadline = Date.now() + timeoutSeconds * 1000;
1213
+ const carry = Math.max(marker.length - 1, 0);
1214
+ const seen = new Map;
1215
+ const startedAt = Date.now();
1216
+ while (true) {
1217
+ for (const path of await expandSources(sources)) {
1218
+ let state = seen.get(path);
1219
+ if (state === undefined) {
1220
+ state = { offset: await staleSize(path, startedAt), tail: "", decoder: new TextDecoder };
1221
+ seen.set(path, state);
1222
+ }
1223
+ if (await scan(path, state, marker, carry))
1224
+ return true;
1225
+ }
1226
+ if (Date.now() >= deadline)
1227
+ return false;
1228
+ await sleep(Math.min(MARKER_POLL_MS, Math.max(deadline - Date.now(), 0)));
1229
+ }
1230
+ }
1231
+ async function staleSize(path, startedAt) {
1232
+ return stat(path).then((info) => info.mtimeMs < startedAt ? info.size : 0, () => 0);
1233
+ }
1234
+ async function scan(path, state, marker, carry) {
1235
+ const handle = await open(path, "r").catch(() => null);
1236
+ if (handle === null)
1237
+ return false;
1238
+ try {
1239
+ const { size } = await handle.stat();
1240
+ if (size < state.offset) {
1241
+ state.offset = 0;
1242
+ state.tail = "";
1243
+ state.decoder = new TextDecoder;
1244
+ }
1245
+ if (size <= state.offset)
1246
+ return false;
1247
+ const buffer = Buffer.alloc(size - state.offset);
1248
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, state.offset);
1249
+ state.offset += bytesRead;
1250
+ const text = state.tail + state.decoder.decode(buffer.subarray(0, bytesRead), { stream: true });
1251
+ if (text.includes(marker))
1252
+ return true;
1253
+ state.tail = carry > 0 ? text.slice(-carry) : "";
1254
+ return false;
1255
+ } catch {
1256
+ return false;
1257
+ } finally {
1258
+ await handle.close().catch(() => {});
1259
+ }
1260
+ }
1261
+ async function expandSources(sources) {
1262
+ const out = [];
1263
+ for (const source of sources) {
1264
+ const info = await stat(source).catch(() => null);
1265
+ if (info === null) {
1266
+ out.push(source);
1267
+ continue;
1268
+ }
1269
+ if (!info.isDirectory()) {
1270
+ out.push(source);
1271
+ continue;
1272
+ }
1273
+ const entries = await readdir(source).catch(() => []);
1274
+ for (const entry of entries) {
1275
+ if (entry.toLowerCase().endsWith(".log"))
1276
+ out.push(join2(source, entry));
1277
+ }
1278
+ }
1279
+ return out;
1280
+ }
1281
+ async function tee(stream, sink, mirror) {
1282
+ for await (const chunk of stream) {
1283
+ mirror.write(chunk);
1284
+ sink.write(chunk);
1285
+ }
1286
+ }
1287
+
981
1288
  // src/mods/staleness.ts
982
- import { readdir, stat } from "node:fs/promises";
983
- import { join as join2, relative } from "node:path";
1289
+ import { readdir as readdir2, stat as stat2 } from "node:fs/promises";
1290
+ import { join as join3, relative } from "node:path";
984
1291
  var SKIP_DIRS = new Set([".git", ".retired", ".vs", "bin", "node_modules", "obj"]);
985
1292
  var ENTRY_LIMIT = 20000;
986
1293
  async function scanBuildTimes(dir) {
@@ -989,14 +1296,14 @@ async function scanBuildTimes(dir) {
989
1296
  const walk = async (current, inAssemblies) => {
990
1297
  let entries;
991
1298
  try {
992
- entries = await readdir(current, { withFileTypes: true });
1299
+ entries = await readdir2(current, { withFileTypes: true });
993
1300
  } catch {
994
1301
  return;
995
1302
  }
996
1303
  for (const entry of entries) {
997
1304
  if (budget-- <= 0)
998
1305
  return;
999
- const path = join2(current, entry.name);
1306
+ const path = join3(current, entry.name);
1000
1307
  if (entry.isDirectory()) {
1001
1308
  if (SKIP_DIRS.has(entry.name.toLowerCase()))
1002
1309
  continue;
@@ -1012,7 +1319,7 @@ async function scanBuildTimes(dir) {
1012
1319
  continue;
1013
1320
  let mtimeMs;
1014
1321
  try {
1015
- mtimeMs = (await stat(path)).mtimeMs;
1322
+ mtimeMs = (await stat2(path)).mtimeMs;
1016
1323
  } catch {
1017
1324
  continue;
1018
1325
  }
@@ -1060,14 +1367,14 @@ function staleWarning(packageId, report, now = Date.now()) {
1060
1367
  `);
1061
1368
  }
1062
1369
  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`;
1370
+ const seconds = Math.max(0, Math.round(ms / 1000));
1371
+ if (seconds < 60)
1372
+ return `${seconds}s`;
1373
+ if (seconds < 3600)
1374
+ return `${Math.floor(seconds / 60)}m`;
1375
+ if (seconds < 86400)
1376
+ return `${Math.floor(seconds / 3600)}h`;
1377
+ return `${Math.floor(seconds / 86400)}d`;
1071
1378
  }
1072
1379
  function ago(mtimeMs, now = Date.now()) {
1073
1380
  return `${duration(now - mtimeMs)} ago`;
@@ -1080,8 +1387,8 @@ function status(message) {
1080
1387
  function warn(message) {
1081
1388
  process.stderr.write(line(`warning: ${message}`));
1082
1389
  }
1083
- function redirectOutput(path) {
1084
- const fd = openSync(path, "w");
1390
+ function redirectOutput(path, keep = false) {
1391
+ const fd = openSync(path, keep ? "a" : "w");
1085
1392
  const stdout = process.stdout.write;
1086
1393
  const stderr = process.stderr.write;
1087
1394
  let open = true;
@@ -1115,8 +1422,7 @@ function line(message) {
1115
1422
  }
1116
1423
  function reportProblems(problems) {
1117
1424
  if (problems.length === 0) {
1118
- process.stderr.write(line("resolution failed with no reported detail"));
1119
- process.exit(Exit.Resolution);
1425
+ throw new GamecrateError("resolution failed with no reported detail", Exit.Resolution);
1120
1426
  }
1121
1427
  const groups = new Map;
1122
1428
  for (const problem of problems) {
@@ -1126,7 +1432,7 @@ function reportProblems(problems) {
1126
1432
  else
1127
1433
  groups.set(problem.where, [problem]);
1128
1434
  }
1129
- const out = [`${problems.length} problem${problems.length === 1 ? "" : "s"}:`];
1435
+ const out = [];
1130
1436
  for (const [where, group] of groups) {
1131
1437
  out.push(` ${where}`);
1132
1438
  for (const problem of group) {
@@ -1135,10 +1441,8 @@ function reportProblems(problems) {
1135
1441
  out.push(` did you mean ${problem.suggestion}?`);
1136
1442
  }
1137
1443
  }
1138
- process.stderr.write(`${out.join(`
1139
- `)}
1140
- `);
1141
- process.exit(Exit.Resolution);
1444
+ throw new GamecrateError(`${problems.length} problem${problems.length === 1 ? "" : "s"}`, Exit.Resolution, out.join(`
1445
+ `));
1142
1446
  }
1143
1447
  function planWarnings(plan) {
1144
1448
  if (!plan.warnOnStale)
@@ -1212,10 +1516,10 @@ function runTimestamp(now = new Date) {
1212
1516
  return now.toISOString().replace(/[-:.]/g, "");
1213
1517
  }
1214
1518
  function openRunLog(logsDir, now) {
1215
- const runsDir = join3(logsDir, "runs");
1216
- mkdirSync(runsDir, { recursive: true });
1519
+ const runsDir = join4(logsDir, "runs");
1520
+ mkdirSync2(runsDir, { recursive: true });
1217
1521
  const dir = uniqueRunDir(runsDir, runTimestamp(now));
1218
- mkdirSync(dir);
1522
+ mkdirSync2(dir);
1219
1523
  linkCurrent(logsDir, dir);
1220
1524
  rotateRuns(logsDir, 10);
1221
1525
  return dir;
@@ -1225,40 +1529,66 @@ function append(fd, chunk) {
1225
1529
  writeSync(fd, typeof chunk === "string" ? encoder.encode(chunk) : chunk);
1226
1530
  }
1227
1531
  function uniqueRunDir(runsDir, stamp) {
1228
- let candidate = join3(runsDir, stamp);
1532
+ let candidate = join4(runsDir, stamp);
1229
1533
  for (let n = 2;existsSync2(candidate); n++)
1230
- candidate = join3(runsDir, `${stamp}-${n}`);
1534
+ candidate = join4(runsDir, `${stamp}-${n}`);
1231
1535
  return candidate;
1232
1536
  }
1537
+ var WAIT_NOTICE = { firstMs: 2000, everyMs: 30000 };
1538
+ function waitNotice(waitedMs, lastNoticeMs, schedule = WAIT_NOTICE) {
1539
+ if (waitedMs < schedule.firstMs)
1540
+ return;
1541
+ if (lastNoticeMs > 0 && waitedMs - lastNoticeMs < schedule.everyMs)
1542
+ return;
1543
+ return waitedMs < 60000 ? `${Math.floor(waitedMs / 1000)}s` : `${Math.floor(waitedMs / 60000)}m`;
1544
+ }
1545
+ function runStartedAt(name) {
1546
+ const m = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(\d{3})Z/.exec(name);
1547
+ if (m === null)
1548
+ return;
1549
+ const at = Date.parse(`${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:${m[6]}.${m[7]}Z`);
1550
+ return Number.isNaN(at) ? undefined : at;
1551
+ }
1552
+ function currentLog(instanceDir) {
1553
+ return join4(instanceDir, "logs", "current", STDOUT_LOG);
1554
+ }
1555
+ function tailArgv(file, fromStart, livePid) {
1556
+ const argv = ["tail"];
1557
+ if (fromStart)
1558
+ argv.push("-n", "+1");
1559
+ if (livePid !== undefined)
1560
+ argv.push("-f", "--pid", String(livePid));
1561
+ argv.push(file);
1562
+ return argv;
1563
+ }
1233
1564
  function linkCurrent(logsDir, target) {
1234
- const link = join3(logsDir, "current");
1565
+ const link = join4(logsDir, "current");
1235
1566
  try {
1236
1567
  lstatSync(link);
1237
1568
  unlinkSync(link);
1238
1569
  } catch {}
1239
- symlinkSync(join3("runs", basename2(target)), link, "dir");
1570
+ symlinkSync(join4("runs", basename3(target)), link, "dir");
1240
1571
  }
1241
1572
  function rotateRuns(logsDir, keep) {
1242
- const runsDir = join3(logsDir, "runs");
1573
+ const runsDir = join4(logsDir, "runs");
1243
1574
  if (keep < 1 || !existsSync2(runsDir))
1244
1575
  return [];
1245
1576
  const dirs = readdirSync(runsDir, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort().reverse();
1246
1577
  const removed = dirs.slice(keep);
1247
1578
  for (const name of removed)
1248
- rmSync(join3(runsDir, name), { recursive: true, force: true });
1579
+ rmSync(join4(runsDir, name), { recursive: true, force: true });
1249
1580
  return removed;
1250
1581
  }
1251
1582
 
1252
1583
  // src/config/load.ts
1253
- import { parse as parseYaml } from "yaml";
1254
1584
  import { z as z2 } from "zod";
1255
- import { access, readdir as readdir2, readFile } from "node:fs/promises";
1585
+ import { access, readdir as readdir3, readFile as readFile2 } from "node:fs/promises";
1256
1586
  import { homedir as homedir2 } from "node:os";
1257
- import { dirname as dirname2, join as join5, resolve as resolve3 } from "node:path";
1587
+ import { basename as basename4, dirname as dirname2, join as join6, resolve as resolve3 } from "node:path";
1258
1588
 
1259
1589
  // src/plugin.ts
1260
1590
  import { readFileSync, statSync } from "node:fs";
1261
- import { dirname, isAbsolute, join as join4, resolve as resolve2 } from "node:path";
1591
+ import { dirname, isAbsolute, join as join5, resolve as resolve2 } from "node:path";
1262
1592
  import { pathToFileURL } from "node:url";
1263
1593
  import { exports as exportsField, legacy } from "resolve.exports";
1264
1594
  var PLUGIN_API_VERSION = 1;
@@ -1274,7 +1604,7 @@ function fail(spec, message, detail) {
1274
1604
  function entryOf(dir) {
1275
1605
  let manifest;
1276
1606
  try {
1277
- manifest = JSON.parse(readFileSync(join4(dir, "package.json"), "utf8"));
1607
+ manifest = JSON.parse(readFileSync(join5(dir, "package.json"), "utf8"));
1278
1608
  } catch {
1279
1609
  return resolve2(dir, "index.js");
1280
1610
  }
@@ -1288,8 +1618,8 @@ function entryOf(dir) {
1288
1618
  function packageDir(spec, from) {
1289
1619
  let dir = resolve2(from);
1290
1620
  for (;; ) {
1291
- const candidate = join4(dir, "node_modules", spec);
1292
- if (statSync(join4(candidate, "package.json"), { throwIfNoEntry: false })?.isFile())
1621
+ const candidate = join5(dir, "node_modules", spec);
1622
+ if (statSync(join5(candidate, "package.json"), { throwIfNoEntry: false })?.isFile())
1293
1623
  return candidate;
1294
1624
  const parent = dirname(dir);
1295
1625
  if (parent === dir)
@@ -1374,6 +1704,12 @@ var DEFAULT_SETTINGS = {
1374
1704
  pidsLimit: 1024
1375
1705
  };
1376
1706
 
1707
+ // src/config/read.ts
1708
+ import { readFile } from "node:fs/promises";
1709
+ import { extname } from "node:path";
1710
+ import { parseTree } from "jsonc-parser";
1711
+ import { isMap, parse as parseYaml, parseDocument } from "yaml";
1712
+
1377
1713
  // src/config/jsonc.ts
1378
1714
  import { parse, printParseErrorCode } from "jsonc-parser";
1379
1715
  function parseJsonc(text) {
@@ -1386,6 +1722,59 @@ function parseJsonc(text) {
1386
1722
  return value;
1387
1723
  }
1388
1724
 
1725
+ // src/config/read.ts
1726
+ var CONFIG_SUFFIXES = [".yml", ".yaml", ".json", ".jsonc"];
1727
+ function isYaml(path) {
1728
+ const suffix = extname(path).toLowerCase();
1729
+ if (suffix === ".yml" || suffix === ".yaml")
1730
+ return true;
1731
+ if (suffix === ".json" || suffix === ".jsonc")
1732
+ return false;
1733
+ throw new GamecrateError(`config is not a format gamecrate reads: ${path}`, Exit.Config, `use one of ${CONFIG_SUFFIXES.join(", ")}`);
1734
+ }
1735
+ function readConfigText(text, path) {
1736
+ if (!isYaml(path)) {
1737
+ try {
1738
+ return parseJsonc(text);
1739
+ } catch (error) {
1740
+ if (error instanceof GamecrateError) {
1741
+ throw new GamecrateError(`${error.message}: ${path}`, error.code, error.detail);
1742
+ }
1743
+ throw error;
1744
+ }
1745
+ }
1746
+ try {
1747
+ return parseYaml(text);
1748
+ } catch (error) {
1749
+ throw new GamecrateError(`config is invalid: ${path}`, Exit.Config, error.message);
1750
+ }
1751
+ }
1752
+ async function readConfigFile(path) {
1753
+ let text;
1754
+ try {
1755
+ text = await readFile(path, "utf8");
1756
+ } catch (error) {
1757
+ if (error.code === "ENOENT")
1758
+ return;
1759
+ throw error;
1760
+ }
1761
+ return readConfigText(text, path);
1762
+ }
1763
+ function orderedKeys(text, path, key) {
1764
+ if (isYaml(path)) {
1765
+ const node = parseDocument(text).get(key, true);
1766
+ if (!isMap(node))
1767
+ return [];
1768
+ return node.items.map((item) => String(item.key.value ?? item.key));
1769
+ }
1770
+ const root = parseTree(text);
1771
+ const holder = root?.children?.find((child) => child.children?.[0]?.value === key);
1772
+ const value = holder?.children?.[1];
1773
+ if (value?.type !== "object")
1774
+ return [];
1775
+ return (value.children ?? []).map((prop) => String(prop.children?.[0]?.value));
1776
+ }
1777
+
1389
1778
  // src/config/validate.ts
1390
1779
  import { z } from "zod";
1391
1780
  var MODES2 = ["headed", "headless", "screenshot"];
@@ -1399,7 +1788,11 @@ function obj(shape) {
1399
1788
  function hintsFor(message) {
1400
1789
  if (!message.startsWith(HINTS))
1401
1790
  return [];
1402
- return JSON.parse(message.slice(HINTS.length));
1791
+ try {
1792
+ return JSON.parse(message.slice(HINTS.length));
1793
+ } catch {
1794
+ return [];
1795
+ }
1403
1796
  }
1404
1797
  function requiredWhen(key, when) {
1405
1798
  return (ctx) => {
@@ -1491,7 +1884,11 @@ var profile = obj({
1491
1884
  error: "expected an object"
1492
1885
  }).optional(),
1493
1886
  alias: str.optional(),
1494
- aliases: strArray.optional()
1887
+ aliases: strArray.optional(),
1888
+ description: str.optional(),
1889
+ detach: bool.optional(),
1890
+ replace: bool.optional(),
1891
+ build: oneOf(["auto", "always", "never"]).optional()
1495
1892
  }).check((ctx) => {
1496
1893
  const v = ctx.value;
1497
1894
  if (v.alias !== undefined && (v.extends !== undefined || v.mods !== undefined)) {
@@ -1556,34 +1953,34 @@ function validateConfig(cfg) {
1556
1953
  problems.push({ where: "", message: "expected the config to be an object" });
1557
1954
  return { config: { dataRoot: "", games: {} }, problems };
1558
1955
  }
1559
- collect2(problems, "", root, cfg);
1956
+ collect3(problems, "", root, cfg);
1560
1957
  const games = cfg["games"];
1561
1958
  if (!isObj(games)) {
1562
1959
  problems.push({ where: "/games", message: 'missing required key "games", or it is not an object' });
1563
1960
  return { config: cfg, problems };
1564
1961
  }
1565
1962
  for (const [name, entry] of Object.entries(games)) {
1566
- collect2(problems, `/games/${esc(name)}`, game, entry);
1963
+ collect3(problems, `/games/${esc(name)}`, game, entry);
1567
1964
  }
1568
1965
  crossReference(problems, games);
1569
1966
  return { config: cfg, problems };
1570
1967
  }
1571
- function collect2(problems, prefix, schema, value) {
1968
+ function collect3(problems, prefix, schema, value) {
1572
1969
  const result = schema.safeParse(value);
1573
1970
  if (result.success)
1574
1971
  return;
1575
1972
  for (const issue of result.error.issues) {
1576
1973
  if (issue.code === "unrecognized_keys") {
1577
1974
  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}"`
1975
+ issue.keys.forEach((key, i) => {
1976
+ const problem = {
1977
+ where: `${prefix}${pointer(issue.path)}/${esc(key)}`,
1978
+ message: `unknown key "${key}"`
1582
1979
  };
1583
- const hint2 = hints[i];
1584
- if (hint2 != null)
1585
- problem2.suggestion = `did you mean "${hint2}"?`;
1586
- problems.push(problem2);
1980
+ const hint = hints[i];
1981
+ if (hint != null)
1982
+ problem.suggestion = `did you mean "${hint}"?`;
1983
+ problems.push(problem);
1587
1984
  });
1588
1985
  continue;
1589
1986
  }
@@ -1612,6 +2009,7 @@ function valueAt(root_, path) {
1612
2009
  return current;
1613
2010
  }
1614
2011
  function crossReference(p, games) {
2012
+ const containers = new Map;
1615
2013
  for (const [gameName, game_] of Object.entries(games)) {
1616
2014
  const where = `/games/${esc(gameName)}`;
1617
2015
  checkName(p, where, gameName, "game");
@@ -1627,7 +2025,7 @@ function crossReference(p, games) {
1627
2025
  if (!isObj(prof))
1628
2026
  continue;
1629
2027
  const parent = prof["extends"];
1630
- if (typeof parent === "string" && !Object.hasOwn(profiles, parent)) {
2028
+ if (typeof parent === "string" && !resolves(profiles, parent)) {
1631
2029
  const prob = { where: `${w}/extends`, message: `extends unknown profile "${parent}"` };
1632
2030
  const hint = suggest(parent, names);
1633
2031
  if (hint)
@@ -1635,14 +2033,14 @@ function crossReference(p, games) {
1635
2033
  p.push(prob);
1636
2034
  }
1637
2035
  const alias = prof["alias"];
1638
- if (typeof alias === "string" && alias !== "modless" && !Object.hasOwn(profiles, alias)) {
2036
+ if (typeof alias === "string" && !resolves(profiles, alias)) {
1639
2037
  const prob = { where: `${w}/alias`, message: `alias of unknown profile "${alias}"` };
1640
2038
  const hint = suggest(alias, [...names, "modless"]);
1641
2039
  if (hint)
1642
2040
  prob.suggestion = `did you mean "${hint}"?`;
1643
2041
  p.push(prob);
1644
2042
  }
1645
- if (typeof alias === "string" && alias === name) {
2043
+ if (typeof alias === "string" && alias.toLowerCase() === name.toLowerCase()) {
1646
2044
  p.push({ where: `${w}/alias`, message: "a profile cannot alias itself" });
1647
2045
  }
1648
2046
  const aliases = prof["aliases"];
@@ -1664,10 +2062,81 @@ function crossReference(p, games) {
1664
2062
  }
1665
2063
  }
1666
2064
  }
2065
+ checkCollisions(p, where, gameName, profiles, containers);
2066
+ }
2067
+ }
2068
+ function resolves(profiles, name) {
2069
+ if (name.toLowerCase() === "modless")
2070
+ return true;
2071
+ return profileKey({ profiles }, name) !== undefined;
2072
+ }
2073
+ function checkCollisions(p, where, gameName, profiles, containers) {
2074
+ const names = new Map;
2075
+ const aliasOwners = new Map;
2076
+ const prefix = `${gameName.toLowerCase()}-`;
2077
+ for (const [name, prof] of Object.entries(profiles)) {
2078
+ const w = `${where}/profiles/${esc(name)}`;
2079
+ const lower = name.toLowerCase();
2080
+ const twin = names.get(lower);
2081
+ if (twin !== undefined) {
2082
+ p.push({
2083
+ where: w,
2084
+ message: `profile "${name}" differs from "${twin}" only in case, so both share one data directory`
2085
+ });
2086
+ continue;
2087
+ }
2088
+ names.set(lower, name);
2089
+ const clash = containers.get(prefix + lower);
2090
+ if (clash !== undefined) {
2091
+ p.push({ where: w, message: `container name collides with ${clash}` });
2092
+ continue;
2093
+ }
2094
+ containers.set(prefix + lower, w);
2095
+ if (!isObj(prof))
2096
+ continue;
2097
+ const aliases = prof["aliases"];
2098
+ if (Array.isArray(aliases)) {
2099
+ for (const [i, entry] of aliases.entries()) {
2100
+ if (typeof entry !== "string")
2101
+ continue;
2102
+ const owner = aliasOwners.get(entry.toLowerCase());
2103
+ if (owner !== undefined) {
2104
+ p.push({
2105
+ where: `${w}/aliases/${i}`,
2106
+ message: `alias "${entry}" is already declared by profile "${owner}"`
2107
+ });
2108
+ continue;
2109
+ }
2110
+ aliasOwners.set(entry.toLowerCase(), name);
2111
+ }
2112
+ }
2113
+ const declared = prof["instances"];
2114
+ for (const instance of instanceNames(profiles, name, prof)) {
2115
+ const container = `${prefix}${lower}-${instance.toLowerCase()}`;
2116
+ const at = isObj(declared) && Object.hasOwn(declared, instance) ? `${w}/instances/${esc(instance)}` : w;
2117
+ const first = containers.get(container);
2118
+ if (first !== undefined) {
2119
+ p.push({
2120
+ where: at,
2121
+ message: `instance "${instance}" makes a container name that collides with ${first}`
2122
+ });
2123
+ continue;
2124
+ }
2125
+ containers.set(container, at);
2126
+ }
2127
+ }
2128
+ }
2129
+ function instanceNames(profiles, name, prof) {
2130
+ try {
2131
+ const resolved = resolveProfile({ profiles }, name).instances;
2132
+ return isObj(resolved) ? Object.keys(resolved) : [];
2133
+ } catch {
2134
+ const declared = prof["instances"];
2135
+ return isObj(declared) ? Object.keys(declared) : [];
1667
2136
  }
1668
2137
  }
1669
2138
  function checkName(p, where, name, kind) {
1670
- if (RESERVED_NAMES.includes(name)) {
2139
+ if (RESERVED_NAMES.includes(name.toLowerCase())) {
1671
2140
  p.push({ where, message: `"${name}" is a reserved name and cannot be used as a ${kind} name` });
1672
2141
  return;
1673
2142
  }
@@ -1683,11 +2152,32 @@ function isObj(v) {
1683
2152
  }
1684
2153
 
1685
2154
  // 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");
2155
+ function globalConfigDir() {
2156
+ const base = process.env["XDG_CONFIG_HOME"] ?? join6(homedir2(), ".config");
2157
+ return join6(base, "gamecrate");
2158
+ }
2159
+ async function findGlobalConfig() {
2160
+ return probe(globalConfigDir(), "profiles");
2161
+ }
2162
+ async function probe(dir, stem) {
2163
+ const found = [];
2164
+ for (const suffix of CONFIG_SUFFIXES) {
2165
+ const file = join6(dir, `${stem}${suffix}`);
2166
+ try {
2167
+ await access(file);
2168
+ found.push(file);
2169
+ } catch (error) {
2170
+ if (error.code !== "ENOENT")
2171
+ throw error;
2172
+ }
2173
+ }
2174
+ if (found.length > 1) {
2175
+ throw new GamecrateError(`two configs in ${dir}`, Exit.Config, `${found.map((f) => ` ${basename4(f)}`).join(`
2176
+ `)}
2177
+ keep one`);
2178
+ }
2179
+ return found[0];
1689
2180
  }
1690
- var PROJECT_CONFIG = ".gamecrate.yml";
1691
2181
  var projectName = z2.custom((v) => typeof v === "string" && NAME_PATTERN.test(v), "expected a name");
1692
2182
  var projectStr = z2.string({ error: "expected a string" });
1693
2183
  var projectBool = z2.boolean({ error: "expected true or false" });
@@ -1704,9 +2194,12 @@ var projectResolution = z2.string({ error: "expected dimensions like 1920x1080"
1704
2194
  ctx.issues.push({ code: "custom", message: error.message, input: ctx.value });
1705
2195
  }
1706
2196
  }).transform(parseResolution);
1707
- var PROJECT_SCHEMA = z2.strictObject({
2197
+ var PROJECT_OBJECT = z2.strictObject({
1708
2198
  game: projectName.optional(),
1709
- profile: projectName.optional(),
2199
+ defaultProfile: projectName.optional(),
2200
+ profiles: z2.record(z2.string(), z2.unknown()).optional(),
2201
+ settings: z2.record(z2.string(), z2.unknown()).optional(),
2202
+ detach: projectBool.optional(),
1710
2203
  mods: projectList.optional(),
1711
2204
  without: projectList.optional(),
1712
2205
  only: projectList.optional(),
@@ -1735,17 +2228,27 @@ var PROJECT_SCHEMA = z2.strictObject({
1735
2228
  }).optional(),
1736
2229
  resolution: projectResolution.optional()
1737
2230
  }, { error: "expected an object" });
2231
+ var PROJECT_SCHEMA = PROJECT_OBJECT.check((ctx) => {
2232
+ const { game, profiles, settings } = ctx.value;
2233
+ if (game !== undefined)
2234
+ return;
2235
+ for (const [key, value] of [["profiles", profiles], ["settings", settings]]) {
2236
+ if (value === undefined)
2237
+ continue;
2238
+ ctx.issues.push({
2239
+ code: "custom",
2240
+ path: [key],
2241
+ message: "needs a top-level game: to say which game it belongs to",
2242
+ input: ctx.value
2243
+ });
2244
+ }
2245
+ });
1738
2246
  async function findProjectConfig(start = process.cwd()) {
1739
2247
  let dir = resolve3(start);
1740
2248
  for (;; ) {
1741
- const file = join5(dir, PROJECT_CONFIG);
1742
- try {
1743
- await access(file);
2249
+ const file = await probe(dir, ".gamecrate");
2250
+ if (file !== undefined)
1744
2251
  return file;
1745
- } catch (error) {
1746
- if (error.code !== "ENOENT")
1747
- throw error;
1748
- }
1749
2252
  const parent = dirname2(dir);
1750
2253
  if (parent === dir)
1751
2254
  return;
@@ -1756,13 +2259,18 @@ async function loadProjectDefaults(start = process.cwd()) {
1756
2259
  const file = await findProjectConfig(start);
1757
2260
  if (file === undefined)
1758
2261
  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);
2262
+ const text = await readFile2(file, "utf8");
2263
+ const defaults = validateProjectDefaults(readConfigText(text, file), file);
2264
+ const profiles = defaults.profiles;
2265
+ if (profiles !== undefined) {
2266
+ const order = orderedKeys(text, file, "profiles");
2267
+ if (order.length !== Object.keys(profiles).length || order.some((key) => !Object.hasOwn(profiles, key))) {
2268
+ throw new GamecrateError(`config is invalid: ${file}`, Exit.Config, "duplicate profiles key");
2269
+ }
2270
+ defaults.profileOrder = order;
1764
2271
  }
1765
- return validateProjectDefaults(raw, file);
2272
+ defaults.configPath = file;
2273
+ return defaults;
1766
2274
  }
1767
2275
  function validateProjectDefaults(raw, file) {
1768
2276
  if (raw === null)
@@ -1782,18 +2290,9 @@ function validateProjectDefaults(raw, file) {
1782
2290
  throw new GamecrateError(`project config is invalid: ${file}`, Exit.Config, problems.join(`
1783
2291
  `));
1784
2292
  }
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
- }
2293
+ async function loadConfig(path, project) {
2294
+ const file = path ?? await findGlobalConfig() ?? join6(globalConfigDir(), "profiles.yml");
2295
+ const user = await readConfigFile(file);
1797
2296
  const specs = isObj(user) && user["plugins"] !== undefined ? user["plugins"] : [];
1798
2297
  if (!Array.isArray(specs) || specs.some((s) => typeof s !== "string")) {
1799
2298
  throw new GamecrateError(`config is invalid: ${file}`, Exit.Config, " /plugins: expected an array of strings");
@@ -1804,29 +2303,57 @@ async function loadConfig(path) {
1804
2303
  defaults: { settings: structuredClone(DEFAULT_SETTINGS) },
1805
2304
  games: Object.fromEntries([...plugins].map(([name, plugin]) => [name, structuredClone(plugin.defaults)]))
1806
2305
  };
1807
- const { config, problems } = validateConfig(user === undefined ? base : deepMerge(base, user));
2306
+ const merged = user === undefined ? base : deepMerge(base, user);
2307
+ const spliced = applyProject(merged, project);
2308
+ const { config, problems } = validateConfig(spliced);
1808
2309
  if (problems.length > 0) {
1809
2310
  const detail = problems.map((p) => {
1810
2311
  const hint = p.suggestion ? ` (${p.suggestion})` : "";
1811
- return ` ${p.where || "/"}: ${p.message}${hint}${origin(p.where, user, plugins)}`;
2312
+ return ` ${p.where || "/"}: ${p.message}${hint}${origin(p.where, user, plugins, project)}`;
1812
2313
  }).join(`
1813
2314
  `);
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);
2315
+ const from = plugins.size === 0 ? "" : ` (merged with defaults from: ${[...plugins.keys()].join(", ")})`;
2316
+ throw new GamecrateError(`config is invalid: ${file}${from}`, Exit.Config, detail);
1816
2317
  }
1817
2318
  return { config: expandPaths(config), plugins };
1818
2319
  }
1819
- function origin(where, user, plugins) {
2320
+ function applyProject(config, project) {
2321
+ if (project === undefined)
2322
+ return config;
2323
+ const game = project.game;
2324
+ if (game === undefined)
2325
+ return config;
2326
+ if (project.profiles === undefined && project.settings === undefined)
2327
+ return config;
2328
+ const existing = own(config.games, game);
2329
+ if (existing === undefined) {
2330
+ throw new GamecrateError(`the project config names game "${game}", which is not configured`, Exit.Config, `known games: ${Object.keys(config.games).join(", ") || "none"}`);
2331
+ }
2332
+ const target = {
2333
+ ...existing,
2334
+ profiles: { ...existing.profiles, ...project.profiles }
2335
+ };
2336
+ config.games[game] = target;
2337
+ if (project.settings !== undefined) {
2338
+ target.settings = deepMerge(target.settings ?? {}, project.settings);
2339
+ }
2340
+ return config;
2341
+ }
2342
+ function origin(where, user, plugins, project) {
1820
2343
  if (!where.startsWith("/"))
1821
2344
  return "";
1822
2345
  const segments = where.slice(1).split("/").map((s) => s.replace(/~1/g, "/").replace(/~0/g, "~"));
2346
+ const [section, name, sub, ...rest] = segments;
2347
+ const repoGame = project?.game;
2348
+ if (repoGame !== undefined && section === "games" && name === repoGame && (sub === "profiles" || sub === "settings") && valueAt2(sub === "profiles" ? project?.profiles : project?.settings, rest) !== undefined) {
2349
+ return " <- from the .gamecrate project config, not this file";
2350
+ }
1823
2351
  if (valueAt2(user, segments) !== undefined)
1824
2352
  return "";
1825
- const [section, name, ...rest] = segments;
1826
2353
  const plugin = section === "games" && name !== undefined ? plugins.get(name) : undefined;
1827
2354
  if (plugin === undefined)
1828
2355
  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`;
2356
+ return valueAt2(plugin.defaults, [sub, ...rest].filter((s) => s !== undefined)) === 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
2357
  }
1831
2358
  function valueAt2(value, segments) {
1832
2359
  let current = value;
@@ -1840,31 +2367,31 @@ function valueAt2(value, segments) {
1840
2367
  }
1841
2368
  return current;
1842
2369
  }
1843
- function resolveSettings(root2, game2, profile2, ...overrides) {
2370
+ function resolveSettings(root, game, profile, ...overrides) {
1844
2371
  let out = structuredClone(DEFAULT_SETTINGS);
1845
- for (const layer of [root2.defaults?.settings, game2.settings, profile2.settings, ...overrides]) {
2372
+ for (const layer of [root.defaults?.settings, game.settings, profile.settings, ...overrides]) {
1846
2373
  if (layer)
1847
2374
  out = deepMerge(out, layer, true);
1848
2375
  }
1849
2376
  return out;
1850
2377
  }
1851
- function resolveProfile(game2, name) {
1852
- return resolveNamed(game2, name, []);
2378
+ function resolveProfile(game, name) {
2379
+ return resolveNamed(game, name, []);
1853
2380
  }
1854
- function resolveNamed(game2, name, seen) {
2381
+ function resolveNamed(game, name, seen) {
1855
2382
  if (name.toLowerCase() === "modless")
1856
2383
  return { mods: [], exclude: [], includeBase: false };
1857
- const key = profileKey(game2, name);
2384
+ const key = profileKey(game, name);
1858
2385
  if (key === undefined) {
1859
- throw new GamecrateError(`unknown profile "${name}"`, Exit.Resolution, `known profiles: ${Object.keys(game2.profiles).join(", ") || "(none)"}, modless`);
2386
+ throw new GamecrateError(`unknown profile "${name}"`, Exit.Resolution, `known profiles: ${Object.keys(game.profiles).join(", ") || "(none)"}, modless`);
1860
2387
  }
1861
2388
  if (seen.includes(key)) {
1862
2389
  throw new GamecrateError(`profile "${key}" inherits from itself`, Exit.Config, [...seen, key].join(" -> "));
1863
2390
  }
1864
- const self = own(game2.profiles, key);
2391
+ const self = own(game.profiles, key);
1865
2392
  if (self.alias !== undefined)
1866
- return resolveNamed(game2, self.alias, [...seen, key]);
1867
- const parent = self.extends !== undefined ? resolveNamed(game2, self.extends, [...seen, key]) : {};
2393
+ return resolveNamed(game, self.alias, [...seen, key]);
2394
+ const parent = self.extends !== undefined ? resolveNamed(game, self.extends, [...seen, key]) : {};
1868
2395
  const exclude = [...parent.exclude ?? [], ...self.exclude ?? []];
1869
2396
  const out = {
1870
2397
  mods: subtract([...parent.mods ?? [], ...self.mods ?? []], exclude),
@@ -1882,39 +2409,39 @@ function resolveNamed(game2, name, seen) {
1882
2409
  out.autoDependencies = auto;
1883
2410
  return out;
1884
2411
  }
1885
- function canonicalProfile(game2, name) {
2412
+ function canonicalProfile(game, name) {
1886
2413
  if (name.toLowerCase() === "modless")
1887
2414
  return "modless";
1888
2415
  const seen = [];
1889
2416
  let current = name;
1890
2417
  for (;; ) {
1891
- const key = profileKey(game2, current);
2418
+ const key = profileKey(game, current);
1892
2419
  if (key === undefined || seen.includes(key))
1893
2420
  return key ?? current;
1894
- const next = own(game2.profiles, key)?.alias;
2421
+ const next = own(game.profiles, key)?.alias;
1895
2422
  if (next === undefined)
1896
2423
  return key;
1897
2424
  seen.push(key);
1898
2425
  current = next;
1899
2426
  }
1900
2427
  }
1901
- function profileDataDir(root2, game2, profile2) {
1902
- return resolve3(expandHome(root2.dataRoot), game2, canonicalProfile(own(root2.games, game2), profile2));
2428
+ function profileDataDir(root, game, profile) {
2429
+ return resolve3(expandHome(root.dataRoot), game, canonicalProfile(own(root.games, game), profile));
1903
2430
  }
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));
2431
+ async function profileDirs(root, game, profile) {
2432
+ if (profile !== undefined)
2433
+ return [profileDataDir(root, game, profile)];
2434
+ const dir = join6(expandHome(root.dataRoot), game);
2435
+ return (await readdir3(dir).catch(() => [])).map((name) => join6(dir, name));
1909
2436
  }
1910
- function profileKey(game2, name) {
1911
- if (Object.hasOwn(game2.profiles, name))
2437
+ function profileKey(game, name) {
2438
+ if (Object.hasOwn(game.profiles, name))
1912
2439
  return name;
1913
2440
  const lower = name.toLowerCase();
1914
- const direct = Object.keys(game2.profiles).find((k) => k.toLowerCase() === lower);
2441
+ const direct = Object.keys(game.profiles).find((k) => k.toLowerCase() === lower);
1915
2442
  if (direct !== undefined)
1916
2443
  return direct;
1917
- return Object.keys(game2.profiles).find((k) => (own(game2.profiles, k)?.aliases ?? []).some((a) => a.toLowerCase() === lower));
2444
+ return Object.keys(game.profiles).find((k) => (own(game.profiles, k)?.aliases ?? []).some((a) => typeof a === "string" && a.toLowerCase() === lower));
1918
2445
  }
1919
2446
  function subtract(mods, exclude) {
1920
2447
  if (exclude.length === 0)
@@ -1955,16 +2482,16 @@ function deepMerge(base, over, concatArrays = false) {
1955
2482
  }
1956
2483
  function expandPaths(config) {
1957
2484
  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 ?? {})) {
2485
+ for (const game of Object.values(config.games)) {
2486
+ if (game.gameFiles.host !== undefined)
2487
+ game.gameFiles.host = expandHome(game.gameFiles.host);
2488
+ if (game.image.context !== undefined)
2489
+ game.image.context = expandHome(game.image.context);
2490
+ if (game.workshopRoot !== null)
2491
+ game.workshopRoot = expandHome(game.workshopRoot);
2492
+ for (const root of game.scanRoots)
2493
+ root.path = expandHome(root.path);
2494
+ for (const entry of Object.values(game.library ?? {})) {
1968
2495
  if (entry.path !== undefined)
1969
2496
  entry.path = expandHome(entry.path);
1970
2497
  }
@@ -1974,183 +2501,35 @@ function expandPaths(config) {
1974
2501
  function expandHome(p) {
1975
2502
  if (p === "~")
1976
2503
  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;
2504
+ return p.startsWith("~/") ? join6(homedir2(), p.slice(2)) : p;
2141
2505
  }
2142
- async function tee(stream, sink, mirror) {
2143
- for await (const chunk of stream) {
2144
- mirror.write(chunk);
2145
- sink.write(chunk);
2146
- }
2506
+
2507
+ // src/docker/identity.ts
2508
+ import { userInfo } from "node:os";
2509
+ function resolveIdentity(useRoot) {
2510
+ if (useRoot)
2511
+ return { uid: 0, gid: 0, home: "/root", user: "root" };
2512
+ const uid = process.getuid?.() ?? 0;
2513
+ const gid = process.getgid?.() ?? 0;
2514
+ return { uid, gid, home: "/tmp/home", user: hostUserName(uid) };
2515
+ }
2516
+ function hostUserName(uid) {
2517
+ try {
2518
+ const name = userInfo().username;
2519
+ if (name)
2520
+ return name;
2521
+ } catch {}
2522
+ return process.env.USER ?? process.env.LOGNAME ?? `uid-${uid}`;
2147
2523
  }
2148
2524
 
2149
2525
  // src/docker/preflight.ts
2526
+ import { existsSync as existsSync3, readFileSync as readFileSync2 } from "node:fs";
2527
+ import { homedir as homedir3 } from "node:os";
2528
+ import { basename as basename5, join as join7 } from "node:path";
2150
2529
  var CDI_SPEC = "/etc/cdi/nvidia.yaml";
2151
2530
  async function preflight(plan) {
2152
2531
  const problems = [];
2153
- const game2 = plan.gameConfig;
2532
+ const game = plan.gameConfig;
2154
2533
  const dockerOk = await checkDocker(problems);
2155
2534
  if (dockerOk) {
2156
2535
  await checkImage(plan, problems);
@@ -2161,7 +2540,7 @@ async function preflight(plan) {
2161
2540
  checkBindSources(plan, problems);
2162
2541
  if (plan.mode === "headed")
2163
2542
  checkDisplay(plan, problems);
2164
- if (game2.gameFiles.source === "image" && game2.image.acquire === "build" && !game2.image.context) {
2543
+ if (game.gameFiles.source === "image" && game.image.acquire === "build" && !game.image.context) {
2165
2544
  problems.push({
2166
2545
  where: `/games/${plan.game}/image/context`,
2167
2546
  message: 'image.acquire is "build" but no build context is configured'
@@ -2180,7 +2559,7 @@ async function checkDocker(problems) {
2180
2559
  });
2181
2560
  return false;
2182
2561
  }
2183
- async function checkImageRunnable(ref, where, game2, problems) {
2562
+ async function checkImageRunnable(ref, where, game, problems) {
2184
2563
  const run = await capture(["docker", "run", "--rm", "--entrypoint", "/bin/true", ref]);
2185
2564
  if (run.code === 0)
2186
2565
  return;
@@ -2189,7 +2568,7 @@ async function checkImageRunnable(ref, where, game2, problems) {
2189
2568
  problems.push({
2190
2569
  where,
2191
2570
  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
2571
+ suggestion: corrupt ? `docker image rm ${ref} && docker builder prune -f, then gamecrate build ${game}` : undefined
2193
2572
  });
2194
2573
  }
2195
2574
  async function checkImage(plan, problems) {
@@ -2282,11 +2661,11 @@ function checkGameDir(plan, problems) {
2282
2661
  problems.push({ where, message: `game directory does not exist: ${files.host}` });
2283
2662
  return;
2284
2663
  }
2285
- const executable = join7(files.host, basename3(plan.gameConfig.executable));
2664
+ const executable = join7(files.host, basename5(plan.gameConfig.executable));
2286
2665
  if (!existsSync3(executable)) {
2287
2666
  problems.push({
2288
2667
  where,
2289
- message: `${files.host} does not contain ${basename3(plan.gameConfig.executable)}`,
2668
+ message: `${files.host} does not contain ${basename5(plan.gameConfig.executable)}`,
2290
2669
  suggestion: "point gameFiles.host at the install directory, not its parent"
2291
2670
  });
2292
2671
  }
@@ -2347,18 +2726,43 @@ function message(error) {
2347
2726
  }
2348
2727
 
2349
2728
  // src/docker/window.ts
2350
- import { basename as basename4 } from "node:path";
2729
+ import { readFileSync as readFileSync3 } from "node:fs";
2730
+ import { basename as basename6 } from "node:path";
2351
2731
  import { setTimeout as sleep2 } from "node:timers/promises";
2352
2732
  var WAIT_MS = 180000;
2353
2733
  var POLL_MS = 500;
2734
+ function newMatches(now, seen, executable) {
2735
+ const wanted = basename6(executable).toLowerCase();
2736
+ return now.filter((w) => !seen.has(w.id) && w.wmClass.toLowerCase().includes(wanted));
2737
+ }
2738
+ function parseWindowPid(stdout) {
2739
+ const match = /_NET_WM_PID\(CARDINAL\)\s*=\s*(\d+)/.exec(stdout);
2740
+ const pid = Number(match?.[1]);
2741
+ return Number.isSafeInteger(pid) && pid > 0 ? pid : undefined;
2742
+ }
2743
+ function isPeerClaim(pid, self) {
2744
+ if (pid === self)
2745
+ return false;
2746
+ try {
2747
+ const argv = readFileSync3(`/proc/${pid}/cmdline`, "utf8").split("\x00");
2748
+ return argv.some((arg) => basename6(arg).startsWith("gamecrate"));
2749
+ } catch {
2750
+ return false;
2751
+ }
2752
+ }
2753
+ async function claimedByPeer(id) {
2754
+ const { stdout } = await capture(["xprop", "-id", id, "_NET_WM_PID"]);
2755
+ const pid = parseWindowPid(stdout);
2756
+ return pid !== undefined && isPeerClaim(pid, process.pid);
2757
+ }
2354
2758
  async function toplevels() {
2355
2759
  const { code, stdout } = await capture(["wmctrl", "-lx"]);
2356
2760
  if (code === 127)
2357
2761
  return null;
2358
2762
  const found = [];
2359
- for (const line2 of stdout.split(`
2763
+ for (const line of stdout.split(`
2360
2764
  `)) {
2361
- const [id, , wmClass] = line2.trim().split(/\s+/);
2765
+ const [id, , wmClass] = line.trim().split(/\s+/);
2362
2766
  if (id !== undefined && wmClass !== undefined)
2363
2767
  found.push({ id, wmClass });
2364
2768
  }
@@ -2371,7 +2775,6 @@ async function adoptNewWindow(opts) {
2371
2775
  return { stop: () => {} };
2372
2776
  }
2373
2777
  const seen = new Set(before.map((w) => w.id));
2374
- const wanted = basename4(opts.executable).toLowerCase();
2375
2778
  let stopped = false;
2376
2779
  (async () => {
2377
2780
  const deadline = Date.now() + WAIT_MS;
@@ -2379,15 +2782,18 @@ async function adoptNewWindow(opts) {
2379
2782
  await sleep2(POLL_MS);
2380
2783
  if (stopped)
2381
2784
  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;
2785
+ for (const match of newMatches(await toplevels() ?? [], seen, opts.executable)) {
2786
+ if (stopped)
2787
+ return;
2788
+ if (await claimedByPeer(match.id))
2789
+ continue;
2790
+ if (!await adopt(match.id, opts))
2791
+ continue;
2792
+ await capture(["wmctrl", "-i", "-r", match.id, "-N", opts.title]);
2793
+ if (opts.stripDelete)
2794
+ await watchForClose(match.id, () => stopped, opts.onClosed);
2795
+ return;
2796
+ }
2391
2797
  }
2392
2798
  })();
2393
2799
  return { stop: () => void (stopped = true) };
@@ -2412,15 +2818,19 @@ async function adopt(id, opts) {
2412
2818
  const protocols = await capture(["xprop", "-id", id, "WM_PROTOCOLS"]);
2413
2819
  if (protocols.code === 127) {
2414
2820
  warn("xprop is not installed, so nothing out here can fix the window's close button");
2415
- return;
2821
+ return true;
2416
2822
  }
2417
- await claimPid(id);
2823
+ if (!await claimPid(id))
2824
+ return false;
2418
2825
  if (opts.stripDelete)
2419
2826
  await dropDeleteProtocol(id, parseAtoms(protocols.stdout));
2827
+ return true;
2420
2828
  }
2421
2829
  async function claimPid(id) {
2422
2830
  const pid = String(process.pid);
2423
2831
  await capture(["xprop", "-id", id, "-f", "_NET_WM_PID", "32c", "-set", "_NET_WM_PID", pid]);
2832
+ const readBack = await capture(["xprop", "-id", id, "_NET_WM_PID"]);
2833
+ return parseWindowPid(readBack.stdout) === process.pid;
2424
2834
  }
2425
2835
  async function dropDeleteProtocol(id, atoms) {
2426
2836
  if (!atoms.includes("WM_DELETE_WINDOW"))
@@ -2449,33 +2859,33 @@ function parseAtoms(stdout) {
2449
2859
  }
2450
2860
 
2451
2861
  // src/launch/generate.ts
2452
- import { mkdir, readdir as readdir4, readFile as readFile2, writeFile } from "node:fs/promises";
2862
+ import { mkdir, readdir as readdir4, readFile as readFile3, writeFile } from "node:fs/promises";
2453
2863
  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)
2864
+ async function readInstallVersion(game, plugin) {
2865
+ const host = game.gameFiles.host;
2866
+ if (game.gameFiles.source !== "mount" || host === undefined)
2457
2867
  return null;
2458
2868
  let raw;
2459
2869
  try {
2460
- raw = await readFile2(join8(expandHome(host), "Version.txt"), "utf8");
2870
+ raw = await readFile3(join8(expandHome(host), "Version.txt"), "utf8");
2461
2871
  } catch {
2462
2872
  return null;
2463
2873
  }
2464
2874
  return plugin.parseVersion(raw.replace(/^/, "").trim());
2465
2875
  }
2466
- async function readKnownExpansions(game2, plugin, warnings) {
2467
- if (game2.dlc.length === 0)
2876
+ async function readKnownExpansions(game, plugin, warnings) {
2877
+ if (game.dlc.length === 0)
2468
2878
  return [];
2469
- const host = game2.gameFiles.host;
2470
- if (game2.gameFiles.source !== "mount" || host === undefined)
2471
- return [...game2.dlc];
2879
+ const host = game.gameFiles.host;
2880
+ if (game.gameFiles.source !== "mount" || host === undefined)
2881
+ return [...game.dlc];
2472
2882
  const dataDir = join8(expandHome(host), "Data");
2473
2883
  let entries;
2474
2884
  try {
2475
2885
  entries = await readdir4(dataDir, { withFileTypes: true });
2476
2886
  } catch {
2477
2887
  warnings.push(`could not read ${dataDir}; falling back to the configured dlc list`);
2478
- return [...game2.dlc];
2888
+ return [...game.dlc];
2479
2889
  }
2480
2890
  const found = [];
2481
2891
  for (const entry of entries) {
@@ -2483,14 +2893,14 @@ async function readKnownExpansions(game2, plugin, warnings) {
2483
2893
  continue;
2484
2894
  let id = null;
2485
2895
  try {
2486
- id = plugin.parseManifest(await readFile2(join8(dataDir, entry.name, game2.manifest.file), "utf8"))?.packageId ?? null;
2896
+ id = plugin.parseManifest(await readFile3(join8(dataDir, entry.name, game.manifest.file), "utf8"))?.packageId ?? null;
2487
2897
  } catch {
2488
2898
  continue;
2489
2899
  }
2490
- if (id !== null && id.toLowerCase() !== game2.core.toLowerCase())
2900
+ if (id !== null && id.toLowerCase() !== game.core.toLowerCase())
2491
2901
  found.push(id);
2492
2902
  }
2493
- const order = game2.dlc.map((id) => id.toLowerCase());
2903
+ const order = game.dlc.map((id) => id.toLowerCase());
2494
2904
  return found.sort((a, b) => {
2495
2905
  const ai = order.indexOf(a.toLowerCase());
2496
2906
  const bi = order.indexOf(b.toLowerCase());
@@ -2498,14 +2908,14 @@ async function readKnownExpansions(game2, plugin, warnings) {
2498
2908
  });
2499
2909
  }
2500
2910
  async function generateModsConfig(plan) {
2501
- const game2 = plan.gameConfig;
2502
- const target = join8(plan.dataDirHost, game2.modsConfig.file);
2911
+ const game = plan.gameConfig;
2912
+ const target = join8(plan.dataDirHost, game.modsConfig.file);
2503
2913
  await mkdir(dirname3(target), { recursive: true });
2504
- const installed = await readInstallVersion(game2, plan.plugin);
2914
+ const installed = await readInstallVersion(game, plan.plugin);
2505
2915
  if (installed === null) {
2506
2916
  plan.warnings.push(`could not read Version.txt for ${plan.game}; ModsConfig version may be rejected`);
2507
2917
  }
2508
- const declared = new Map([game2.core, ...game2.dlc].map((id) => [id.toLowerCase(), id]));
2918
+ const declared = new Map([game.core, ...game.dlc].map((id) => [id.toLowerCase(), id]));
2509
2919
  const seen = new Set;
2510
2920
  const activeMods = [];
2511
2921
  for (const mod of plan.mods) {
@@ -2515,7 +2925,7 @@ async function generateModsConfig(plan) {
2515
2925
  seen.add(key);
2516
2926
  activeMods.push(key);
2517
2927
  }
2518
- const knownExpansions = (await readKnownExpansions(game2, plan.plugin, plan.warnings)).map((id) => declared.get(id.toLowerCase()) ?? id);
2928
+ const knownExpansions = (await readKnownExpansions(game, plan.plugin, plan.warnings)).map((id) => declared.get(id.toLowerCase()) ?? id);
2519
2929
  await writeFile(target, fromPlugin(target, () => plan.plugin.renderModsConfig({
2520
2930
  version: installed?.version ?? "",
2521
2931
  buildNumber: installed?.buildNumber ?? -1,
@@ -2536,16 +2946,16 @@ function fromPlugin(target, render) {
2536
2946
  }
2537
2947
  }
2538
2948
  function ownedPrefs(plan) {
2539
- const { settings: settings2 } = plan;
2540
- const bool2 = (value) => value ? "True" : "False";
2949
+ const { settings } = plan;
2950
+ const bool = (value) => value ? "True" : "False";
2541
2951
  const owned = {
2542
- screenWidth: String(settings2.width),
2543
- screenHeight: String(settings2.height),
2544
- devMode: bool2(settings2.devMode),
2545
- runInBackground: bool2(settings2.runInBackground),
2952
+ screenWidth: String(settings.width),
2953
+ screenHeight: String(settings.height),
2954
+ devMode: bool(settings.devMode),
2955
+ runInBackground: bool(settings.runInBackground),
2546
2956
  ...plan.plugin.windowedPrefs
2547
2957
  };
2548
- Object.assign(owned, settings2.prefsExtra ?? {});
2958
+ Object.assign(owned, settings.prefsExtra ?? {});
2549
2959
  owned.resetModsConfigOnCrash = "False";
2550
2960
  return owned;
2551
2961
  }
@@ -2554,7 +2964,7 @@ async function mergePrefs(plan) {
2554
2964
  await mkdir(dirname3(target), { recursive: true });
2555
2965
  let existing = null;
2556
2966
  try {
2557
- existing = await readFile2(target, "utf8");
2967
+ existing = await readFile3(target, "utf8");
2558
2968
  } catch (error) {
2559
2969
  if (error.code !== "ENOENT")
2560
2970
  throw error;
@@ -2564,8 +2974,8 @@ async function mergePrefs(plan) {
2564
2974
  }
2565
2975
 
2566
2976
  // 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";
2977
+ import { existsSync as existsSync4, readFileSync as readFileSync4 } from "node:fs";
2978
+ import { open as open2, readdir as readdir5, readFile as readFile4, unlink, writeFile as writeFile2 } from "node:fs/promises";
2569
2979
  import { join as join9 } from "node:path";
2570
2980
  import { setTimeout as sleep3 } from "node:timers/promises";
2571
2981
  async function inherit(argv, stdin) {
@@ -2594,12 +3004,12 @@ async function imageLabel(ref, label) {
2594
3004
  return null;
2595
3005
  return value;
2596
3006
  }
2597
- async function acquireImage(game2, config, pull) {
3007
+ async function acquireImage(game, config, pull) {
2598
3008
  const { image } = config;
2599
3009
  const present = await imageDigest(image.ref) !== null;
2600
3010
  if (image.acquire === "build") {
2601
3011
  if (image.context === undefined) {
2602
- throw new GamecrateError(`${game2} has image.acquire "build" but no context`, Exit.Config);
3012
+ throw new GamecrateError(`${game} has image.acquire "build" but no context`, Exit.Config);
2603
3013
  }
2604
3014
  if (present && pull !== "always")
2605
3015
  return;
@@ -2672,10 +3082,10 @@ async function buildTarget(dir) {
2672
3082
  const csproj = entries.find((e) => e.endsWith(".csproj"));
2673
3083
  return csproj ? join9(dir, csproj) : null;
2674
3084
  }
2675
- async function buildLocalMods(plan, policy2) {
2676
- if (policy2 === "never")
3085
+ async function buildLocalMods(plan, policy) {
3086
+ if (policy === "never")
2677
3087
  return;
2678
- const wanted = plan.mods.filter((m) => m.kind === "local" && (policy2 === "always" || m.stale === true));
3088
+ const wanted = plan.mods.filter((m) => m.kind === "local" && (policy === "always" || m.stale === true));
2679
3089
  if (wanted.length === 0)
2680
3090
  return;
2681
3091
  for (const mod of wanted) {
@@ -2690,7 +3100,7 @@ async function buildLocalMods(plan, policy2) {
2690
3100
  delete mod.staleReport;
2691
3101
  }
2692
3102
  }
2693
- async function takeLock(plan) {
3103
+ async function clearLock(plan) {
2694
3104
  const path = lockPath(plan);
2695
3105
  const what = plan.instance === undefined ? plan.profile : `${plan.profile} (${plan.instance})`;
2696
3106
  const name = containerName(plan);
@@ -2699,66 +3109,150 @@ async function takeLock(plan) {
2699
3109
  throw new GamecrateError(`${plan.game} ${what} is already running (container ${name})`, Exit.Refused, `stop it with: docker stop ${name}
2700
3110
  or relaunch with --replace`);
2701
3111
  }
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}
3112
+ const held = await readLock(path);
3113
+ if (held !== undefined && isRunning(held.pid, held.startedAt)) {
3114
+ throw new GamecrateError(`${plan.game} ${what} is already running (pid ${held.pid})`, Exit.Refused, `if that is wrong, delete ${path}
2709
3115
  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
3116
  }
2717
- await handle.writeFile(`${process.pid}
2718
- ${new Date().toISOString()}
2719
- `);
2720
- await handle.close();
3117
+ if (existsSync4(path))
3118
+ await unlink(path).catch(() => {});
3119
+ }
3120
+ async function unlinkHeld(path, pid, startedAt) {
3121
+ const held = await readLock(path);
3122
+ if (held === undefined)
3123
+ return;
3124
+ if (held.pid !== pid)
3125
+ return;
3126
+ if (startedAt !== undefined && held.startedAt !== startedAt)
3127
+ return;
3128
+ await unlink(path).catch(() => {});
3129
+ }
3130
+ function heldLock(plan) {
3131
+ const path = lockPath(plan);
2721
3132
  return {
2722
3133
  release: async () => {
2723
- await unlink(path).catch(() => {});
3134
+ await unlinkHeld(path, process.pid);
2724
3135
  }
2725
3136
  };
2726
3137
  }
3138
+ async function takeLock(plan) {
3139
+ await clearLock(plan);
3140
+ await writeLock(plan, {
3141
+ pid: process.pid,
3142
+ container: containerName(plan),
3143
+ game: plan.game,
3144
+ profile: plan.profile,
3145
+ ...plan.instance === undefined ? {} : { instance: plan.instance },
3146
+ detached: false,
3147
+ mode: plan.mode
3148
+ });
3149
+ return heldLock(plan);
3150
+ }
2727
3151
  function lockPath(plan) {
2728
3152
  return join9(plan.instanceDir, ".gamecrate", "lock");
2729
3153
  }
2730
- var RELEASE_WAIT_MS = 1e4;
3154
+ async function readLock(path) {
3155
+ const text = await readFile4(path, "utf8").catch(() => {
3156
+ return;
3157
+ });
3158
+ if (text === undefined)
3159
+ return;
3160
+ try {
3161
+ const value = JSON.parse(text);
3162
+ return Number.isInteger(value?.pid) && value.pid > 0 ? value : undefined;
3163
+ } catch {
3164
+ return;
3165
+ }
3166
+ }
3167
+ async function writeLock(plan, record) {
3168
+ const path = lockPath(plan);
3169
+ const handle = await open2(path, "wx").catch(() => null);
3170
+ if (handle === null) {
3171
+ throw new GamecrateError(`could not take the launch lock at ${path}`, Exit.Environment);
3172
+ }
3173
+ await handle.writeFile(JSON.stringify({ ...record, startedAt: new Date().toISOString() }));
3174
+ await handle.close();
3175
+ }
2731
3176
  var RELEASE_POLL_MS = 100;
2732
- var REPLACE_STOP_TIMEOUT_SECONDS = 10;
3177
+ var DRAIN_ALLOWANCE_MS = 1e4;
3178
+ var STOP_RELEASE_WAIT_MS = STOP_TIMEOUT_SECONDS * 1000 + DRAIN_ALLOWANCE_MS;
3179
+ async function stopRun(record, lockFile) {
3180
+ let signalled = false;
3181
+ if (isRunning(record.pid, record.startedAt)) {
3182
+ try {
3183
+ process.kill(record.pid, "SIGTERM");
3184
+ signalled = true;
3185
+ } catch {}
3186
+ }
3187
+ if (!signalled)
3188
+ await stopContainer(record.container, STOP_TIMEOUT_SECONDS);
3189
+ const deadline = Date.now() + STOP_RELEASE_WAIT_MS;
3190
+ while (existsSync4(lockFile)) {
3191
+ const held = await readLock(lockFile);
3192
+ if (held === undefined)
3193
+ break;
3194
+ if (!isRunning(held.pid, held.startedAt)) {
3195
+ await unlinkHeld(lockFile, record.pid, record.startedAt);
3196
+ break;
3197
+ }
3198
+ if (Date.now() >= deadline)
3199
+ return "held";
3200
+ await sleep3(RELEASE_POLL_MS);
3201
+ }
3202
+ return signalled ? "signalled" : "orphaned";
3203
+ }
2733
3204
  async function replacePrevious(plan) {
2734
3205
  const name = containerName(plan);
2735
3206
  const path = lockPath(plan);
2736
3207
  const up = await capture(["docker", "ps", "--quiet", "--filter", `name=^${name}$`]);
2737
3208
  const running = up.stdout.trim().length > 0;
2738
- if (!running && !existsSync4(path))
3209
+ const held = await readLock(path);
3210
+ if (!running && held === undefined && !existsSync4(path))
2739
3211
  return;
3212
+ if (held !== undefined) {
3213
+ status(`stopping ${held.container}`);
3214
+ await stopRun(held, path);
3215
+ return;
3216
+ }
2740
3217
  if (running) {
2741
3218
  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);
3219
+ await stopContainer(name, STOP_TIMEOUT_SECONDS);
2752
3220
  }
2753
- await unlink(path).catch(() => {});
2754
3221
  }
2755
- function isRunning(pid) {
3222
+ var CLOCK_TICKS_PER_SECOND = 100;
3223
+ var START_TIME_SLACK_MS = 2000;
3224
+ function isRunning(pid, startedAt) {
2756
3225
  try {
2757
3226
  process.kill(pid, 0);
2758
- return true;
2759
3227
  } catch {
2760
3228
  return false;
2761
3229
  }
3230
+ if (startedAt === undefined)
3231
+ return true;
3232
+ const written = Date.parse(startedAt);
3233
+ if (Number.isNaN(written))
3234
+ return true;
3235
+ const began = processStart(pid);
3236
+ return began === undefined || began <= written + START_TIME_SLACK_MS;
3237
+ }
3238
+ function processStart(pid) {
3239
+ try {
3240
+ const stat = readFileSync4(`/proc/${pid}/stat`, "utf8");
3241
+ const fields = stat.slice(stat.lastIndexOf(")") + 2).split(" ");
3242
+ const ticks = Number(fields[19]);
3243
+ const boot = bootTime();
3244
+ if (!Number.isFinite(ticks) || boot === undefined)
3245
+ return;
3246
+ return boot + ticks / CLOCK_TICKS_PER_SECOND * 1000;
3247
+ } catch {
3248
+ return;
3249
+ }
3250
+ }
3251
+ function bootTime() {
3252
+ const line = readFileSync4("/proc/stat", "utf8").split(`
3253
+ `).find((each) => each.startsWith("btime "));
3254
+ const seconds = Number(line?.slice("btime ".length));
3255
+ return Number.isFinite(seconds) && seconds > 0 ? seconds * 1000 : undefined;
2762
3256
  }
2763
3257
  async function captureScreenshot(container, plan) {
2764
3258
  const name = `${plan.game}.png`;
@@ -2772,7 +3266,7 @@ async function captureScreenshot(container, plan) {
2772
3266
  }
2773
3267
  async function writeLaunchRecord(plan, image) {
2774
3268
  const digest = await imageDigest(image);
2775
- const line2 = JSON.stringify({
3269
+ const line = JSON.stringify({
2776
3270
  at: new Date().toISOString(),
2777
3271
  game: plan.game,
2778
3272
  profile: plan.profile,
@@ -2787,17 +3281,150 @@ async function writeLaunchRecord(plan, image) {
2787
3281
  }))
2788
3282
  });
2789
3283
  const path = join9(plan.instanceDir, ".gamecrate", "launches.jsonl");
2790
- await writeFile2(path, `${line2}
3284
+ await writeFile2(path, `${line}
2791
3285
  `, { flag: "a" });
2792
3286
  }
2793
3287
 
3288
+ // src/launch/supervisor.ts
3289
+ import { existsSync as existsSync5 } from "node:fs";
3290
+ import { readFile as readFile5, readlink, rm, writeFile as writeFile3 } from "node:fs/promises";
3291
+ import { basename as basename7, join as join10 } from "node:path";
3292
+ import { setTimeout as sleep4 } from "node:timers/promises";
3293
+ async function forkSupervisor(plan, argv) {
3294
+ await clearLock(plan);
3295
+ const name = containerName(plan);
3296
+ const self = supervisorArgv(argv, plan.instanceDir);
3297
+ const bin = self[0];
3298
+ if (bin === undefined || bin === "") {
3299
+ throw new GamecrateError("cannot re-exec gamecrate: argv[0] is empty", Exit.Environment);
3300
+ }
3301
+ const child = spawnArgv(self, "ignore", true);
3302
+ child.unref();
3303
+ if (child.pid === undefined) {
3304
+ throw new GamecrateError(`could not fork a supervisor from ${bin}`, Exit.Environment);
3305
+ }
3306
+ try {
3307
+ await writeLock(plan, {
3308
+ pid: child.pid,
3309
+ container: name,
3310
+ game: plan.game,
3311
+ profile: plan.profile,
3312
+ ...plan.instance === undefined ? {} : { instance: plan.instance },
3313
+ detached: true,
3314
+ mode: plan.mode
3315
+ });
3316
+ } catch (error) {
3317
+ const failure = kill(child.pid);
3318
+ if (failure !== undefined) {
3319
+ throw new GamecrateError(`could not confirm supervisor ${child.pid} died after the lock write failed`, Exit.Environment, `${failure}
3320
+ kill -9 ${child.pid} before launching this profile again`);
3321
+ }
3322
+ throw error;
3323
+ }
3324
+ status(`${plan.game} ${plan.profile} -> ${name} (pid ${child.pid})`);
3325
+ return Exit.Ok;
3326
+ }
3327
+ function kill(pid) {
3328
+ try {
3329
+ process.kill(pid, "SIGKILL");
3330
+ return;
3331
+ } catch (error) {
3332
+ const code = error.code;
3333
+ return code === "ESRCH" ? undefined : `could not kill it: ${code ?? String(error)}`;
3334
+ }
3335
+ }
3336
+ async function recordExit(plan, result) {
3337
+ await writeExit(plan.instanceDir, {
3338
+ at: new Date().toISOString(),
3339
+ code: result.code,
3340
+ reason: result.reason,
3341
+ container: containerName(plan),
3342
+ runDir: plan.runDirHost
3343
+ });
3344
+ }
3345
+ async function supervisorFailed(dir, code) {
3346
+ const record = { at: new Date().toISOString(), code, reason: "failed" };
3347
+ const wrote = await writeExit(dir, record).then(() => true, () => false);
3348
+ if (wrote)
3349
+ await rm(join10(dir, ".gamecrate", "lock"), { force: true }).catch(() => {});
3350
+ return code;
3351
+ }
3352
+ async function writeExit(instanceDir, record) {
3353
+ await writeFile3(join10(instanceDir, ".gamecrate", "last-exit.json"), `${JSON.stringify(record)}
3354
+ `);
3355
+ }
3356
+ async function lastExit(instanceDir) {
3357
+ const text = await readFile5(join10(instanceDir, ".gamecrate", "last-exit.json"), "utf8").catch(() => {
3358
+ return;
3359
+ });
3360
+ if (text === undefined)
3361
+ return;
3362
+ try {
3363
+ const value = JSON.parse(text);
3364
+ return Number.isInteger(value?.code) ? value : undefined;
3365
+ } catch {
3366
+ return;
3367
+ }
3368
+ }
3369
+ var WAIT_POLL_MS = 500;
3370
+ function endedAfter(record, notBefore) {
3371
+ if (notBefore === undefined)
3372
+ return true;
3373
+ const began = Date.parse(notBefore);
3374
+ if (Number.isNaN(began))
3375
+ return true;
3376
+ const at = Date.parse(record.at);
3377
+ return !Number.isNaN(at) && at >= began;
3378
+ }
3379
+ async function awaitExit(instanceDir, poll = WAIT_POLL_MS) {
3380
+ const file = join10(instanceDir, ".gamecrate", "lock");
3381
+ let watching;
3382
+ for (;; ) {
3383
+ const lock = await readLock(file) ?? watching;
3384
+ const found = await lastExit(instanceDir);
3385
+ if (found !== undefined && endedAfter(found, lock?.startedAt))
3386
+ return found;
3387
+ if (lock === undefined)
3388
+ return "absent";
3389
+ if (!isRunning(lock.pid, lock.startedAt))
3390
+ return "orphaned";
3391
+ watching = lock;
3392
+ await sleep4(poll);
3393
+ }
3394
+ }
3395
+ var RUN_LOG_POLL_MS = 250;
3396
+ async function awaitRunLog(instanceDir, lock, poll = RUN_LOG_POLL_MS, notice = WAIT_NOTICE) {
3397
+ const link = join10(instanceDir, "logs", "current");
3398
+ const written = Date.parse(lock.startedAt);
3399
+ const since = Date.now();
3400
+ let lastNotice = 0;
3401
+ for (;; ) {
3402
+ const target = await readlink(link).catch(() => {
3403
+ return;
3404
+ });
3405
+ const began = target === undefined ? undefined : runStartedAt(basename7(target));
3406
+ const current = began !== undefined && (Number.isNaN(written) || began >= written);
3407
+ if (current && existsSync5(currentLog(instanceDir)))
3408
+ return true;
3409
+ if (!isRunning(lock.pid, lock.startedAt))
3410
+ return false;
3411
+ const waited = Date.now() - since;
3412
+ const label = waitNotice(waited, lastNotice, notice);
3413
+ if (label !== undefined) {
3414
+ status(`waiting for ${lock.game} ${lock.profile} to open its log (${label})`);
3415
+ lastNotice = waited;
3416
+ }
3417
+ await sleep4(poll);
3418
+ }
3419
+ }
3420
+
2794
3421
  // src/launch/instance.ts
2795
3422
  import { createHash } from "node:crypto";
2796
- import { basename as basename5, join as join10 } from "node:path";
3423
+ import { basename as basename8, join as join11 } from "node:path";
2797
3424
 
2798
3425
  // src/mods/worktree.ts
2799
3426
  import { spawnSync } from "node:child_process";
2800
- import { existsSync as existsSync5, realpathSync as realpathSync2 } from "node:fs";
3427
+ import { existsSync as existsSync6, realpathSync as realpathSync2 } from "node:fs";
2801
3428
  import { isAbsolute as isAbsolute2, resolve as resolve4, sep } from "node:path";
2802
3429
  function inspect(dir) {
2803
3430
  const r = spawnSync("git", ["-C", dir, "rev-parse", "--path-format=absolute", "--show-toplevel", "--git-dir", "--git-common-dir", "--abbrev-ref", "HEAD"], { encoding: "utf8" });
@@ -2820,7 +3447,7 @@ function canonical(p) {
2820
3447
  function resolveWorktree(dir, source, order) {
2821
3448
  const raw = expandHome(dir);
2822
3449
  const abs = isAbsolute2(raw) ? raw : resolve4(process.cwd(), raw);
2823
- if (!existsSync5(abs)) {
3450
+ if (!existsSync6(abs)) {
2824
3451
  return { where: abs, message: `--worktree path does not exist`, suggestion: "check the path, or drop the flag" };
2825
3452
  }
2826
3453
  const info = inspect(abs);
@@ -2840,7 +3467,7 @@ function contains(request, dir) {
2840
3467
  const target = canonical(dir);
2841
3468
  return target === request.root || target.startsWith(request.root + sep);
2842
3469
  }
2843
- function collectRequests(flags2, env, cwd, disabled) {
3470
+ function collectRequests(flags, env, cwd, disabled) {
2844
3471
  if (disabled)
2845
3472
  return { requests: [], problems: [] };
2846
3473
  const requests = [];
@@ -2857,7 +3484,7 @@ function collectRequests(flags2, env, cwd, disabled) {
2857
3484
  problems.push(got);
2858
3485
  }
2859
3486
  };
2860
- for (const f of flags2)
3487
+ for (const f of flags)
2861
3488
  add(f, "flag");
2862
3489
  if (env !== undefined && env !== "" && env !== "off")
2863
3490
  add(env, "env");
@@ -2868,21 +3495,21 @@ function collectRequests(flags2, env, cwd, disabled) {
2868
3495
  // src/launch/instance.ts
2869
3496
  var SLUG_LIMIT = 24;
2870
3497
  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);
3498
+ const { profileDir, profile, args } = options;
3499
+ const configured = lookup(profile, args.instance);
3500
+ const flags = configured?.worktree === undefined ? args.worktree ?? [] : [configured.worktree, ...args.worktree ?? []];
3501
+ const { requests, problems } = collectRequests(flags, options.env ?? process.env["GAMECRATE_WORKTREE"], options.cwd ?? process.cwd(), args.noWorktree ?? false);
2875
3502
  const name = args.instance === undefined ? derive(requests) : named(args.instance);
2876
3503
  return {
2877
3504
  ...name === undefined ? {} : { name },
2878
- dir: name === undefined ? profileDir : join10(profileDir, "instances", name),
3505
+ dir: name === undefined ? profileDir : join11(profileDir, "instances", name),
2879
3506
  requests,
2880
3507
  problems,
2881
3508
  ...configured?.settings === undefined ? {} : { settings: configured.settings }
2882
3509
  };
2883
3510
  }
2884
- function lookup(profile2, name) {
2885
- const instances = profile2?.instances;
3511
+ function lookup(profile, name) {
3512
+ const instances = profile?.instances;
2886
3513
  if (instances === undefined || name === undefined)
2887
3514
  return;
2888
3515
  const exact = own(instances, name);
@@ -2905,22 +3532,22 @@ function derive(requests) {
2905
3532
  const digest = createHash("sha256").update(requests.map((r) => r.root).join("\x00")).digest("hex");
2906
3533
  return `${slug(first.root)}-${digest.slice(0, 6)}`;
2907
3534
  }
2908
- function slug(root2) {
2909
- const body = basename5(root2).toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^[^a-z0-9]+/, "").slice(0, SLUG_LIMIT).replace(/[-._]+$/, "");
3535
+ function slug(root) {
3536
+ const body = basename8(root).toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^[^a-z0-9]+/, "").slice(0, SLUG_LIMIT).replace(/[-._]+$/, "");
2910
3537
  return body === "" ? "wt" : body;
2911
3538
  }
2912
3539
 
2913
3540
  // src/launch/resolve.ts
2914
- import { join as join12 } from "node:path";
3541
+ import { join as join13 } from "node:path";
2915
3542
 
2916
3543
  // 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";
3544
+ import { existsSync as existsSync7, readFileSync as readFileSync5, statSync as statSync2 } from "node:fs";
3545
+ import { mkdir as mkdir2, readdir as readdir6, readFile as readFile6, writeFile as writeFile4 } from "node:fs/promises";
2919
3546
  import { homedir as homedir4 } from "node:os";
2920
- import { dirname as dirname4, join as join11, relative as relative2, resolve as resolvePath } from "node:path";
3547
+ import { dirname as dirname4, join as join12, relative as relative2, resolve as resolvePath } from "node:path";
2921
3548
  import picomatch from "picomatch";
2922
3549
  function cacheDir() {
2923
- return join11(process.env["XDG_CACHE_HOME"] ?? join11(homedir4(), ".cache"), "gamecrate");
3550
+ return join12(process.env["XDG_CACHE_HOME"] ?? join12(homedir4(), ".cache"), "gamecrate");
2924
3551
  }
2925
3552
  function globMatch(pattern, path) {
2926
3553
  return picomatch.isMatch(path, pattern.replace(/[[\]{}()!,@+|^$.\\]/g, "\\$&"), { dot: true });
@@ -2932,8 +3559,8 @@ var ALWAYS_EXCLUDE = ["**/.worktrees/**", "**/.claude/worktrees/**"];
2932
3559
  function inLinkedWorktree(dir, stopAt) {
2933
3560
  let current = dir;
2934
3561
  for (;; ) {
2935
- const git = join11(current, ".git");
2936
- if (existsSync6(git)) {
3562
+ const git = join12(current, ".git");
3563
+ if (existsSync7(git)) {
2937
3564
  try {
2938
3565
  if (statSync2(git).isFile())
2939
3566
  return true;
@@ -2950,12 +3577,12 @@ function inLinkedWorktree(dir, stopAt) {
2950
3577
  current = parent;
2951
3578
  }
2952
3579
  }
2953
- async function scanLocalRoot(root2, rootIndex, manifestFile, found) {
2954
- const base = resolvePath(expandHome(root2.path));
2955
- const exclude = [...root2.exclude ?? [], ...rootIndex === -1 ? [] : ALWAYS_EXCLUDE];
3580
+ async function scanLocalRoot(root, rootIndex, manifestFile, found) {
3581
+ const base = resolvePath(expandHome(root.path));
3582
+ const exclude = [...root.exclude ?? [], ...rootIndex === -1 ? [] : ALWAYS_EXCLUDE];
2956
3583
  const walk = async (dir, depth) => {
2957
- const manifest = join11(dir, manifestFile);
2958
- if (existsSync6(manifest)) {
3584
+ const manifest = join12(dir, manifestFile);
3585
+ if (existsSync7(manifest)) {
2959
3586
  found.push({
2960
3587
  dir,
2961
3588
  kind: "local",
@@ -2964,7 +3591,7 @@ async function scanLocalRoot(root2, rootIndex, manifestFile, found) {
2964
3591
  });
2965
3592
  return;
2966
3593
  }
2967
- if (depth >= root2.maxDepth)
3594
+ if (depth >= root.maxDepth)
2968
3595
  return;
2969
3596
  let entries;
2970
3597
  try {
@@ -2975,13 +3602,13 @@ async function scanLocalRoot(root2, rootIndex, manifestFile, found) {
2975
3602
  for (const entry of entries) {
2976
3603
  if (!entry.isDirectory() || entry.name.startsWith(".git"))
2977
3604
  continue;
2978
- const child = join11(dir, entry.name);
3605
+ const child = join12(dir, entry.name);
2979
3606
  if (excluded(exclude, relative2(base, child)))
2980
3607
  continue;
2981
3608
  await walk(child, depth + 1);
2982
3609
  }
2983
3610
  };
2984
- if (!existsSync6(base))
3611
+ if (!existsSync7(base))
2985
3612
  return;
2986
3613
  await walk(base, 0);
2987
3614
  }
@@ -2996,17 +3623,17 @@ async function scanWorkshopRoot(workshopRoot, rootIndex, manifestFile, found) {
2996
3623
  for (const entry of entries) {
2997
3624
  if (!entry.isDirectory() || !/^\d+$/.test(entry.name))
2998
3625
  continue;
2999
- const dir = join11(base, entry.name);
3000
- if (!existsSync6(join11(dir, manifestFile)))
3626
+ const dir = join12(base, entry.name);
3627
+ if (!existsSync7(join12(dir, manifestFile)))
3001
3628
  continue;
3002
3629
  found.push({ dir, kind: "workshop", rootIndex, linkedWorktree: false, workshopId: Number(entry.name) });
3003
3630
  }
3004
3631
  }
3005
- async function scanGameData(game2, rootIndex, found) {
3006
- const host = game2.gameFiles.host;
3007
- if (game2.gameFiles.source !== "mount" || host === undefined)
3632
+ async function scanGameData(game, rootIndex, found) {
3633
+ const host = game.gameFiles.host;
3634
+ if (game.gameFiles.source !== "mount" || host === undefined)
3008
3635
  return;
3009
- const data = join11(resolvePath(expandHome(host)), "Data");
3636
+ const data = join12(resolvePath(expandHome(host)), "Data");
3010
3637
  let entries;
3011
3638
  try {
3012
3639
  entries = await readdir6(data, { withFileTypes: true });
@@ -3016,17 +3643,17 @@ async function scanGameData(game2, rootIndex, found) {
3016
3643
  for (const entry of entries) {
3017
3644
  if (!entry.isDirectory())
3018
3645
  continue;
3019
- const dir = join11(data, entry.name);
3020
- if (!existsSync6(join11(dir, game2.manifest.file)))
3646
+ const dir = join12(data, entry.name);
3647
+ if (!existsSync7(join12(dir, game.manifest.file)))
3021
3648
  continue;
3022
3649
  found.push({ dir, kind: "official", rootIndex, linkedWorktree: false });
3023
3650
  }
3024
3651
  }
3025
3652
  var CACHE_VERSION = 2;
3026
- function workshopStamp(game2) {
3027
- if (game2.workshopRoot === null)
3653
+ function workshopStamp(game) {
3654
+ if (game.workshopRoot === null)
3028
3655
  return null;
3029
- const acf = join11(dirname4(dirname4(resolvePath(expandHome(game2.workshopRoot)))), `appworkshop_${game2.steamAppId}.acf`);
3656
+ const acf = join12(dirname4(dirname4(resolvePath(expandHome(game.workshopRoot)))), `appworkshop_${game.steamAppId}.acf`);
3030
3657
  try {
3031
3658
  const info = statSync2(acf);
3032
3659
  return `${info.mtimeMs}:${info.size}`;
@@ -3034,9 +3661,9 @@ function workshopStamp(game2) {
3034
3661
  return null;
3035
3662
  }
3036
3663
  }
3037
- async function readWorkshopCache(game2, stamp) {
3664
+ async function readWorkshopCache(game, stamp) {
3038
3665
  try {
3039
- const raw = JSON.parse(await readFile4(join11(cacheDir(), `${game2}.workshop.json`), "utf8"));
3666
+ const raw = JSON.parse(await readFile6(join12(cacheDir(), `${game}.workshop.json`), "utf8"));
3040
3667
  if (raw.version !== CACHE_VERSION || raw.stamp !== stamp)
3041
3668
  return null;
3042
3669
  return raw.records;
@@ -3044,15 +3671,15 @@ async function readWorkshopCache(game2, stamp) {
3044
3671
  return null;
3045
3672
  }
3046
3673
  }
3047
- async function writeWorkshopCache(game2, stamp, records) {
3674
+ async function writeWorkshopCache(game, stamp, records) {
3048
3675
  const payload = { version: CACHE_VERSION, stamp, records };
3049
3676
  try {
3050
3677
  await mkdir2(cacheDir(), { recursive: true });
3051
- await writeFile3(join11(cacheDir(), `${game2}.workshop.json`), JSON.stringify(payload));
3678
+ await writeFile4(join12(cacheDir(), `${game}.workshop.json`), JSON.stringify(payload));
3052
3679
  } catch {}
3053
3680
  }
3054
- function toRecord(candidate, manifest, game2) {
3055
- const kind = manifest.packageId.toLowerCase() === game2.core.toLowerCase() ? "core" : candidate.kind;
3681
+ function toRecord(candidate, manifest, game) {
3682
+ const kind = manifest.packageId.toLowerCase() === game.core.toLowerCase() ? "core" : candidate.kind;
3056
3683
  const record = {
3057
3684
  packageId: manifest.packageId,
3058
3685
  dir: candidate.dir,
@@ -3131,8 +3758,8 @@ async function applySourceOverrides(index, overrides, config) {
3131
3758
  }
3132
3759
  const wanted = spec.slice(0, eq);
3133
3760
  const dir = resolvePath(expandHome(spec.slice(eq + 1)));
3134
- const file = join11(dir, config.manifest.file);
3135
- if (!existsSync6(file)) {
3761
+ const file = join12(dir, config.manifest.file);
3762
+ if (!existsSync7(file)) {
3136
3763
  problems.push({
3137
3764
  where: spec,
3138
3765
  message: `no ${config.manifest.file} under ${dir}`,
@@ -3142,7 +3769,7 @@ async function applySourceOverrides(index, overrides, config) {
3142
3769
  }
3143
3770
  let manifest;
3144
3771
  try {
3145
- manifest = index.plugin.parseManifest(readFileSync3(file, "utf8"));
3772
+ manifest = index.plugin.parseManifest(readFileSync5(file, "utf8"));
3146
3773
  } catch (error) {
3147
3774
  problems.push({ where: file, message: `could not parse: ${String(error)}` });
3148
3775
  continue;
@@ -3193,9 +3820,9 @@ function insert(index, record) {
3193
3820
  }
3194
3821
  }
3195
3822
  }
3196
- async function buildIndex(game2, config, plugin) {
3823
+ async function buildIndex(game, config, plugin) {
3197
3824
  const index = {
3198
- game: game2,
3825
+ game,
3199
3826
  plugin,
3200
3827
  byPackageId: new Map,
3201
3828
  byWorkshopId: new Map,
@@ -3204,14 +3831,14 @@ async function buildIndex(game2, config, plugin) {
3204
3831
  };
3205
3832
  const local = [];
3206
3833
  await scanGameData(config, -1, local);
3207
- for (const [i, root2] of config.scanRoots.entries()) {
3208
- await scanLocalRoot(root2, i, config.manifest.file, local);
3834
+ for (const [i, root] of config.scanRoots.entries()) {
3835
+ await scanLocalRoot(root, i, config.manifest.file, local);
3209
3836
  }
3210
3837
  for (const record of await parseAll(local, config, plugin, index.problems))
3211
3838
  insert(index, record);
3212
3839
  if (config.workshopRoot !== null) {
3213
3840
  const stamp = workshopStamp(config);
3214
- const cached = stamp === null ? null : await readWorkshopCache(game2, stamp);
3841
+ const cached = stamp === null ? null : await readWorkshopCache(game, stamp);
3215
3842
  if (cached) {
3216
3843
  for (const record of cached)
3217
3844
  insert(index, record);
@@ -3222,7 +3849,7 @@ async function buildIndex(game2, config, plugin) {
3222
3849
  for (const record of records)
3223
3850
  insert(index, record);
3224
3851
  if (stamp !== null)
3225
- await writeWorkshopCache(game2, stamp, records);
3852
+ await writeWorkshopCache(game, stamp, records);
3226
3853
  }
3227
3854
  }
3228
3855
  for (const bucket of index.byPackageId.values())
@@ -3231,9 +3858,9 @@ async function buildIndex(game2, config, plugin) {
3231
3858
  }
3232
3859
  async function parseAll(candidates, config, plugin, problems) {
3233
3860
  const records = await Promise.all(candidates.map(async (candidate) => {
3234
- const file = join11(candidate.dir, config.manifest.file);
3861
+ const file = join12(candidate.dir, config.manifest.file);
3235
3862
  try {
3236
- const manifest = plugin.parseManifest(await readFile4(file, "utf8"));
3863
+ const manifest = plugin.parseManifest(await readFile6(file, "utf8"));
3237
3864
  return manifest === null ? null : toRecord(candidate, manifest, config);
3238
3865
  } catch (error) {
3239
3866
  problems.push({
@@ -3245,8 +3872,8 @@ async function parseAll(candidates, config, plugin, problems) {
3245
3872
  }));
3246
3873
  return records.filter((r) => r !== null);
3247
3874
  }
3248
- function resolveModRef(index, ref, game2) {
3249
- return honorOverride(index, resolveRaw(index, ref, game2));
3875
+ function resolveModRef(index, ref, game) {
3876
+ return honorOverride(index, resolveRaw(index, ref, game));
3250
3877
  }
3251
3878
  function honorOverride(index, record) {
3252
3879
  if (record === null || record.overridden !== undefined)
@@ -3254,9 +3881,9 @@ function honorOverride(index, record) {
3254
3881
  const best = index.byPackageId.get(record.packageId.toLowerCase())?.[0];
3255
3882
  return best?.overridden !== undefined ? best : record;
3256
3883
  }
3257
- function resolveRaw(index, ref, game2) {
3884
+ function resolveRaw(index, ref, game) {
3258
3885
  if (ref.startsWith("path:"))
3259
- return byPath(index, ref.slice(5), game2);
3886
+ return byPath(index, ref.slice(5), game);
3260
3887
  if (ref.startsWith("workshop:")) {
3261
3888
  const id = Number(ref.slice(9));
3262
3889
  return Number.isInteger(id) ? index.byWorkshopId.get(id) ?? null : null;
@@ -3264,9 +3891,9 @@ function resolveRaw(index, ref, game2) {
3264
3891
  const direct = pick(index, ref.toLowerCase(), ref);
3265
3892
  if (direct)
3266
3893
  return direct;
3267
- const alias = own(game2.aliases, ref) ?? own(game2.aliases, ref.toLowerCase());
3894
+ const alias = own(game.aliases, ref) ?? own(game.aliases, ref.toLowerCase());
3268
3895
  if (alias !== undefined && alias.toLowerCase() !== ref.toLowerCase()) {
3269
- return resolveRaw(index, alias, game2);
3896
+ return resolveRaw(index, alias, game);
3270
3897
  }
3271
3898
  const short = index.byShortName.get(ref.toLowerCase());
3272
3899
  if (short && short.length === 1)
@@ -3295,21 +3922,21 @@ function pick(index, key, ref) {
3295
3922
  }
3296
3923
  return best;
3297
3924
  }
3298
- function byPath(index, raw, game2) {
3925
+ function byPath(index, raw, game) {
3299
3926
  const dir = resolvePath(expandHome(raw));
3300
3927
  for (const bucket of index.byPackageId.values()) {
3301
3928
  const hit = bucket.find((record) => record.dir === dir || record.dir === raw);
3302
3929
  if (hit)
3303
3930
  return hit;
3304
3931
  }
3305
- const file = join11(dir, game2.manifest.file);
3306
- if (!existsSync6(file))
3932
+ const file = join12(dir, game.manifest.file);
3933
+ if (!existsSync7(file))
3307
3934
  return null;
3308
3935
  try {
3309
- const manifest = index.plugin.parseManifest(readFileSync3(file, "utf8"));
3936
+ const manifest = index.plugin.parseManifest(readFileSync5(file, "utf8"));
3310
3937
  if (manifest === null)
3311
3938
  return null;
3312
- return toRecord({ dir, kind: "local", rootIndex: -1, linkedWorktree: false }, manifest, game2);
3939
+ return toRecord({ dir, kind: "local", rootIndex: -1, linkedWorktree: false }, manifest, game);
3313
3940
  } catch {
3314
3941
  return null;
3315
3942
  }
@@ -3321,7 +3948,7 @@ var DEFAULT_RENDER_WAIT_SECONDS = 25;
3321
3948
  function isDynamic(entry) {
3322
3949
  return typeof entry !== "string" && "match" in entry;
3323
3950
  }
3324
- function refFor(entry, game2) {
3951
+ function refFor(entry, game) {
3325
3952
  const object = typeof entry === "string" ? { id: entry } : entry;
3326
3953
  if (object.path !== undefined)
3327
3954
  return `path:${object.path}`;
@@ -3329,7 +3956,7 @@ function refFor(entry, game2) {
3329
3956
  return `workshop:${object.workshop}`;
3330
3957
  if (object.id.includes(":"))
3331
3958
  return object.id;
3332
- const pin = own(game2.library, object.id) ?? own(game2.library, object.id.toLowerCase());
3959
+ const pin = own(game.library, object.id) ?? own(game.library, object.id.toLowerCase());
3333
3960
  if (pin?.path !== undefined)
3334
3961
  return `path:${pin.path}`;
3335
3962
  if (pin?.workshop !== undefined)
@@ -3359,22 +3986,22 @@ function expandDynamic(entry, index, where, problems) {
3359
3986
  }
3360
3987
  return [...head, ...rest];
3361
3988
  }
3362
- function collectSlots(game2, gameName, profileName, profile2, args) {
3989
+ function collectSlots(game, gameName, profileName, profile, args) {
3363
3990
  const modless = profileName === "modless";
3364
3991
  const slots = [];
3365
3992
  const at = `/games/${gameName}`;
3366
3993
  if (!modless)
3367
- for (const [i, id] of (game2.preCore ?? []).entries())
3994
+ for (const [i, id] of (game.preCore ?? []).entries())
3368
3995
  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())
3996
+ slots.push({ entry: game.core, where: `${at}/core` });
3997
+ for (const [i, id] of game.dlc.entries())
3371
3998
  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())
3999
+ if (!modless && profile.includeBase !== false) {
4000
+ for (const [i, id] of (game.base ?? []).entries())
3374
4001
  slots.push({ entry: id, where: `${at}/base/${i}` });
3375
4002
  }
3376
4003
  const only = args.only ?? [];
3377
- const declared = only.length > 0 ? only : profile2.mods ?? [];
4004
+ const declared = only.length > 0 ? only : profile.mods ?? [];
3378
4005
  const source = only.length > 0 ? "flag --only" : `${at}/profiles/${profileName}/mods`;
3379
4006
  for (const [i, entry] of declared.entries())
3380
4007
  slots.push({ entry, where: `${source}/${i}` });
@@ -3382,7 +4009,7 @@ function collectSlots(game2, gameName, profileName, profile2, args) {
3382
4009
  slots.push({ entry: id, where: `flag --mod/${i}` });
3383
4010
  return slots;
3384
4011
  }
3385
- function insertDependencies(list, present, index, game2, problems) {
4012
+ function insertDependencies(list, present, index, game, problems) {
3386
4013
  let i = 0;
3387
4014
  outer:
3388
4015
  while (i < list.length) {
@@ -3392,7 +4019,7 @@ function insertDependencies(list, present, index, game2, problems) {
3392
4019
  if (present.has(key))
3393
4020
  continue;
3394
4021
  present.add(key);
3395
- const record = resolveModRef(index, dep.packageId, game2);
4022
+ const record = resolveModRef(index, dep.packageId, game);
3396
4023
  if (!record) {
3397
4024
  problems.push({
3398
4025
  where: mod.record.packageId,
@@ -3494,47 +4121,47 @@ function incompatibilityWarnings(list) {
3494
4121
  return warnings;
3495
4122
  }
3496
4123
  async function resolvePlan(options) {
3497
- const { game: gameName, profile: requestedProfile, root: root2 } = options;
4124
+ const { game: gameName, profile: requestedProfile, root } = options;
3498
4125
  const args = options.args ?? {};
3499
4126
  const problems = [];
3500
4127
  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(", ")}`);
4128
+ const game = own(root.games, gameName);
4129
+ if (!game) {
4130
+ throw new GamecrateError(`unknown game "${gameName}"`, Exit.Config, `known: ${Object.keys(root.games).join(", ")}`);
3504
4131
  }
3505
4132
  if (!NAME_PATTERN.test(requestedProfile)) {
3506
4133
  throw new GamecrateError(`invalid profile name "${requestedProfile}"`, Exit.Usage);
3507
4134
  }
3508
- const profileName = canonicalProfile(game2, requestedProfile);
3509
- const profile2 = profileName === "modless" ? { mods: [], includeBase: false } : resolveProfile(game2, profileName);
3510
- const profileDir = profileDataDir(root2, gameName, profileName);
4135
+ const profileName = canonicalProfile(game, requestedProfile);
4136
+ const profile = profileName === "modless" ? { mods: [], includeBase: false } : resolveProfile(game, profileName);
4137
+ const profileDir = profileDataDir(root, gameName, profileName);
3511
4138
  const instance = resolveInstance({
3512
4139
  profileDir,
3513
- profile: profile2,
4140
+ profile,
3514
4141
  args,
3515
4142
  ...options.cwd === undefined ? {} : { cwd: options.cwd }
3516
4143
  });
3517
4144
  problems.push(...instance.problems);
3518
- const settings2 = resolveSettings(root2, game2, profile2, instance.settings, args.resolution);
4145
+ const settings = resolveSettings(root, game, profile, instance.settings, args.resolution);
3519
4146
  if (args.network !== undefined)
3520
- settings2.network = args.network;
4147
+ settings.network = args.network;
3521
4148
  if (args.gameArgs?.length)
3522
- settings2.gameArgs = [...settings2.gameArgs ?? [], ...args.gameArgs];
4149
+ settings.gameArgs = [...settings.gameArgs ?? [], ...args.gameArgs];
3523
4150
  if (args.dockerArgs?.length)
3524
- settings2.dockerArgs = [...settings2.dockerArgs ?? [], ...args.dockerArgs];
4151
+ settings.dockerArgs = [...settings.dockerArgs ?? [], ...args.dockerArgs];
3525
4152
  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));
4153
+ const index = options.index ?? await buildIndex(gameName, game, plugin);
4154
+ await applyWorktreeRequests(index, instance.requests, game);
4155
+ problems.push(...await applySourceOverrides(index, args.use ?? [], game));
4156
+ const excluded = [...profile.exclude ?? [], ...args.without ?? []].map(globToRegExp);
4157
+ const isExcluded = (id) => excluded.some((pattern) => pattern.test(id));
3531
4158
  const staged = [];
3532
4159
  const present = new Set;
3533
- for (const slot of collectSlots(game2, gameName, profileName, profile2, args)) {
4160
+ for (const slot of collectSlots(game, gameName, profileName, profile, args)) {
3534
4161
  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 }];
4162
+ const refs = isDynamic(entry) ? expandDynamic(entry, index, slot.where, problems).map((id) => ({ ref: id, optional: false })) : [{ ref: refFor(entry, game), optional: typeof entry !== "string" && entry.optional === true }];
3536
4163
  for (const { ref, optional } of refs) {
3537
- const record = resolveModRef(index, ref, game2);
4164
+ const record = resolveModRef(index, ref, game);
3538
4165
  if (!record) {
3539
4166
  if (slot.dlc === true)
3540
4167
  continue;
@@ -3551,9 +4178,9 @@ async function resolvePlan(options) {
3551
4178
  staged.push({ record, explicit: true });
3552
4179
  }
3553
4180
  }
3554
- if (profile2.autoDependencies === true)
3555
- insertDependencies(staged, present, index, game2, problems);
3556
- const ordered = args.sort === "none" ? staged : topoSort(staged, problems, game2.core);
4181
+ if (profile.autoDependencies === true)
4182
+ insertDependencies(staged, present, index, game, problems);
4183
+ const ordered = args.sort === "none" ? staged : topoSort(staged, problems, game.core);
3557
4184
  warnings.push(...incompatibilityWarnings(ordered));
3558
4185
  const mods = [];
3559
4186
  for (const { record, explicit } of ordered) {
@@ -3566,7 +4193,7 @@ async function resolvePlan(options) {
3566
4193
  mods.push({
3567
4194
  packageId: record.packageId,
3568
4195
  hostDir: record.dir,
3569
- containerDir: `${game2.modsDir.container}/${record.packageId}`,
4196
+ containerDir: `${game.modsDir.container}/${record.packageId}`,
3570
4197
  kind: record.kind,
3571
4198
  ...record.workshopId === undefined ? {} : { workshopId: record.workshopId },
3572
4199
  explicit,
@@ -3577,37 +4204,37 @@ async function resolvePlan(options) {
3577
4204
  });
3578
4205
  }
3579
4206
  problems.push(...index.problems);
3580
- if (game2.dataDir.mode === "arg" && game2.dataDir.arg.split("=").length !== 2) {
4207
+ if (game.dataDir.mode === "arg" && game.dataDir.arg.split("=").length !== 2) {
3581
4208
  problems.push({
3582
4209
  where: `/games/${gameName}/dataDir/arg`,
3583
- message: `"${game2.dataDir.arg}" must contain exactly one "="; RimWorld silently ignores anything else`
4210
+ message: `"${game.dataDir.arg}" must contain exactly one "="; RimWorld silently ignores anything else`
3584
4211
  });
3585
4212
  }
3586
- if (game2.dataDir.container.includes("=")) {
4213
+ if (game.dataDir.container.includes("=")) {
3587
4214
  problems.push({
3588
4215
  where: `/games/${gameName}/dataDir/container`,
3589
- message: `container data path "${game2.dataDir.container}" contains "=", which disables the override silently`
4216
+ message: `container data path "${game.dataDir.container}" contains "=", which disables the override silently`
3590
4217
  });
3591
4218
  }
3592
4219
  const mode = args.mode ?? "headed";
3593
- if (!game2.modes.includes(mode)) {
4220
+ if (!game.modes.includes(mode)) {
3594
4221
  problems.push({ where: "flag --mode", message: `${gameName} does not support mode "${mode}"` });
3595
4222
  }
3596
4223
  const plan = {
3597
4224
  game: gameName,
3598
- gameConfig: game2,
4225
+ gameConfig: game,
3599
4226
  plugin,
3600
4227
  profile: profileName,
3601
- settings: settings2,
4228
+ settings,
3602
4229
  mods,
3603
4230
  profileDir,
3604
4231
  ...instance.name === undefined ? {} : { instance: instance.name },
3605
4232
  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"),
4233
+ dataDirHost: join13(instance.dir, "game"),
4234
+ configDirHost: join13(profileDir, "config"),
4235
+ stageDirHost: join13(instance.dir, ".stage"),
4236
+ logsDirHost: join13(instance.dir, "logs"),
4237
+ runDirHost: join13(instance.dir, "logs"),
3611
4238
  mode,
3612
4239
  ...args.marker === undefined ? {} : { marker: args.marker },
3613
4240
  timeoutSeconds: args.timeout ?? DEFAULT_TIMEOUT_SECONDS,
@@ -3618,11 +4245,100 @@ async function resolvePlan(options) {
3618
4245
  return { plan, problems };
3619
4246
  }
3620
4247
 
4248
+ // src/run/registry.ts
4249
+ import { readdir as readdir7 } from "node:fs/promises";
4250
+ import { join as join14 } from "node:path";
4251
+ var FORMAT = '{{.Names}}\t{{.Label "gamecrate.game"}}\t{{.Label "gamecrate.profile"}}\t{{.Label "gamecrate.instance"}}\t{{.Status}}';
4252
+ function parseDockerRuns(stdout) {
4253
+ const out = [];
4254
+ for (const line of stdout.split(`
4255
+ `)) {
4256
+ if (line.trim() === "")
4257
+ continue;
4258
+ const [container, game, profile, instance, uptime] = line.split("\t");
4259
+ if (container === undefined || game === undefined || profile === undefined)
4260
+ continue;
4261
+ out.push({
4262
+ game,
4263
+ profile,
4264
+ ...instance ? { instance } : {},
4265
+ container,
4266
+ ...uptime ? { uptime } : {},
4267
+ status: "running"
4268
+ });
4269
+ }
4270
+ return out;
4271
+ }
4272
+ async function walkLocks(dataRoot) {
4273
+ const out = [];
4274
+ for (const game of await entries(dataRoot)) {
4275
+ const gameDir = join14(dataRoot, game);
4276
+ for (const profile of await entries(gameDir)) {
4277
+ const profileDir = join14(gameDir, profile);
4278
+ await push(out, join14(profileDir, ".gamecrate", "lock"));
4279
+ const instancesDir = join14(profileDir, "instances");
4280
+ for (const instance of await entries(instancesDir)) {
4281
+ await push(out, join14(instancesDir, instance, ".gamecrate", "lock"));
4282
+ }
4283
+ }
4284
+ }
4285
+ return out;
4286
+ }
4287
+ async function entries(dir) {
4288
+ const found = await readdir7(dir, { withFileTypes: true }).catch(() => []);
4289
+ return found.filter((entry) => entry.isDirectory()).map((entry) => entry.name);
4290
+ }
4291
+ async function push(out, path) {
4292
+ const record = await readLock(path);
4293
+ if (record !== undefined)
4294
+ out.push(record);
4295
+ }
4296
+ var dockerPs = async () => {
4297
+ const { code, stdout } = await capture([
4298
+ "docker",
4299
+ "ps",
4300
+ "--filter",
4301
+ "label=gamecrate.game",
4302
+ "--format",
4303
+ FORMAT
4304
+ ]);
4305
+ return code === 0 ? stdout : "";
4306
+ };
4307
+ async function listRuns(dataRoot, docker = dockerPs) {
4308
+ const running = parseDockerRuns(await docker());
4309
+ const byContainer = new Map(running.map((run) => [run.container, run]));
4310
+ const out = [...running];
4311
+ for (const lock of await walkLocks(dataRoot)) {
4312
+ const match = byContainer.get(lock.container);
4313
+ if (match !== undefined) {
4314
+ const stale = match.pid !== undefined && !isRunning(match.pid, match.startedAt);
4315
+ if (match.pid === undefined || stale && isRunning(lock.pid, lock.startedAt)) {
4316
+ match.pid = lock.pid;
4317
+ match.startedAt = lock.startedAt;
4318
+ if (lock.mode !== undefined)
4319
+ match.mode = lock.mode;
4320
+ }
4321
+ continue;
4322
+ }
4323
+ out.push({
4324
+ game: lock.game,
4325
+ profile: lock.profile,
4326
+ ...lock.instance === undefined ? {} : { instance: lock.instance },
4327
+ container: lock.container,
4328
+ pid: lock.pid,
4329
+ ...lock.mode === undefined ? {} : { mode: lock.mode },
4330
+ startedAt: lock.startedAt,
4331
+ status: isRunning(lock.pid, lock.startedAt) ? "starting" : "orphaned"
4332
+ });
4333
+ }
4334
+ return out;
4335
+ }
4336
+
3621
4337
  // 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";
4338
+ import { lstat, mkdir as mkdir3, readdir as readdir8, realpath, rm as rm2, stat as stat3 } from "node:fs/promises";
4339
+ import { basename as basename9, join as join15 } from "node:path";
3624
4340
  async function stageMods(plan) {
3625
- await rm(plan.stageDirHost, { recursive: true, force: true });
4341
+ await rm2(plan.stageDirHost, { recursive: true, force: true });
3626
4342
  await mkdir3(plan.stageDirHost, { recursive: true });
3627
4343
  const mounts = [];
3628
4344
  for (const mod of plan.mods) {
@@ -3637,7 +4353,7 @@ async function stageMods(plan) {
3637
4353
  if (!(await stat3(source)).isDirectory()) {
3638
4354
  throw new GamecrateError(`${mod.packageId} does not resolve to a directory`, Exit.Environment, source);
3639
4355
  }
3640
- await mkdir3(join13(plan.stageDirHost, basename6(mod.containerDir)), { recursive: true });
4356
+ await mkdir3(join15(plan.stageDirHost, basename9(mod.containerDir)), { recursive: true });
3641
4357
  mounts.push({ type: "bind", source, target: mod.containerDir, readonly: true });
3642
4358
  }
3643
4359
  return mounts;
@@ -3647,12 +4363,12 @@ async function ensureProfileTree(plan) {
3647
4363
  plan.profileDir,
3648
4364
  plan.instanceDir,
3649
4365
  plan.dataDirHost,
3650
- join13(plan.configDirHost, "config"),
3651
- join13(plan.configDirHost, "data"),
3652
- join13(plan.configDirHost, "cache"),
3653
- join13(plan.logsDirHost, "runs"),
4366
+ join15(plan.configDirHost, "config"),
4367
+ join15(plan.configDirHost, "data"),
4368
+ join15(plan.configDirHost, "cache"),
4369
+ join15(plan.logsDirHost, "runs"),
3654
4370
  plan.stageDirHost,
3655
- join13(plan.instanceDir, ".gamecrate"),
4371
+ join15(plan.instanceDir, ".gamecrate"),
3656
4372
  ...engineDirs(plan)
3657
4373
  ]) {
3658
4374
  await mkdir3(dir, { recursive: true });
@@ -3662,7 +4378,7 @@ function engineDirs(plan) {
3662
4378
  const { dataDir, modsDir } = plan.gameConfig;
3663
4379
  if (!modsDir.container.startsWith(`${dataDir.container}/`))
3664
4380
  return [];
3665
- return [join13(plan.dataDirHost, modsDir.container.slice(dataDir.container.length + 1))];
4381
+ return [join15(plan.dataDirHost, modsDir.container.slice(dataDir.container.length + 1))];
3666
4382
  }
3667
4383
  async function detectForeignOwnership(dir, uid, limit = 100) {
3668
4384
  const foreign = [];
@@ -3683,8 +4399,8 @@ async function detectForeignOwnership(dir, uid, limit = 100) {
3683
4399
  if (!info.isDirectory())
3684
4400
  continue;
3685
4401
  try {
3686
- for (const entry of await readdir7(current))
3687
- queue.push(join13(current, entry));
4402
+ for (const entry of await readdir8(current))
4403
+ queue.push(join15(current, entry));
3688
4404
  } catch {
3689
4405
  continue;
3690
4406
  }
@@ -3693,58 +4409,77 @@ async function detectForeignOwnership(dir, uid, limit = 100) {
3693
4409
  }
3694
4410
 
3695
4411
  // src/index.ts
3696
- var VERSION = "0.1.0";
3697
- var STOP_TIMEOUT_SECONDS = 10;
4412
+ var VERSION = "1.1.0";
3698
4413
  async function main(argv) {
4414
+ const supervised = supervisedDir(argv);
4415
+ try {
4416
+ return await command(argv, supervised);
4417
+ } catch (error) {
4418
+ if (supervised === undefined)
4419
+ throw error;
4420
+ return await supervisorFailed(supervised, reportFatal(error));
4421
+ }
4422
+ }
4423
+ async function command(argv, supervised) {
3699
4424
  const probe = parseArgs(argv);
3700
4425
  if (probe.subcommand === "version") {
3701
4426
  process.stdout.write(`gamecrate ${VERSION}
3702
4427
  `);
3703
4428
  return Exit.Ok;
3704
4429
  }
3705
- const [{ config, plugins }, defaults] = await Promise.all([loadConfig(), loadProjectDefaults()]);
4430
+ const defaults = await loadProjectDefaults();
4431
+ const { config, plugins } = await loadConfig(undefined, defaults);
3706
4432
  const args = parseArgs(argv, { games: Object.keys(config.games), defaults });
3707
4433
  if (args.help) {
3708
4434
  process.stdout.write(renderHelp(helpTopic(args), config));
3709
4435
  return Exit.Ok;
3710
4436
  }
3711
- const redirect = args.log !== undefined && (args.subcommand === "run" || args.subcommand === "shell") ? redirectOutput(args.log) : undefined;
4437
+ const redirect = args.log !== undefined && (args.subcommand === "run" || args.subcommand === "shell") ? redirectOutput(args.log, args.supervised) : undefined;
3712
4438
  try {
3713
- return await dispatch(args, config, plugins);
4439
+ return await dispatch(argv, args, config, plugins, defaults);
3714
4440
  } catch (error) {
3715
- return reportFatal(error);
4441
+ const code = reportFatal(error);
4442
+ return supervised === undefined ? code : await supervisorFailed(supervised, code);
3716
4443
  } finally {
3717
4444
  redirect?.close();
3718
4445
  }
3719
4446
  }
3720
- async function dispatch(args, config, plugins) {
4447
+ async function dispatch(argv, args, config, plugins, defaults) {
3721
4448
  switch (args.subcommand) {
3722
4449
  case "help":
3723
4450
  return help(args, config);
3724
4451
  case "list":
3725
- return list(args, config);
4452
+ return list(args, config, defaults);
3726
4453
  case "mods":
3727
- return mods(args, config, plugins);
4454
+ return mods(args, config, plugins, defaults);
3728
4455
  case "doctor":
3729
4456
  return doctor(config, plugins);
3730
4457
  case "clean":
3731
- return clean(args, config);
4458
+ return clean(args, config, defaults);
3732
4459
  case "clone":
3733
4460
  return clone(args, config);
3734
4461
  case "logs":
3735
- return logs(args, config);
4462
+ return logs(args, config, defaults);
3736
4463
  case "verify":
3737
- return verify(args, config, plugins);
4464
+ return verify(args, config, plugins, defaults);
3738
4465
  case "build":
3739
4466
  return build(args, config);
4467
+ case "ps":
4468
+ return ps(args, config);
4469
+ case "stop":
4470
+ return stop(args, config, defaults);
4471
+ case "attach":
4472
+ return attach(args, config, defaults);
4473
+ case "wait":
4474
+ return waitFor(args, config, defaults);
3740
4475
  case "shell":
3741
- return run(args, config, plugins, true);
4476
+ return run(argv, args, config, plugins, defaults, true);
3742
4477
  case "config":
3743
4478
  return configEdit(args);
3744
4479
  case "fix-perms":
3745
4480
  return fixPerms(args, config);
3746
4481
  case "run":
3747
- return run(args, config, plugins, false);
4482
+ return run(argv, args, config, plugins, defaults, false);
3748
4483
  default:
3749
4484
  throw new GamecrateError(`no such subcommand ${args.subcommand}`, Exit.Usage);
3750
4485
  }
@@ -3769,89 +4504,92 @@ function help(args, config) {
3769
4504
  process.stdout.write(renderHelp(topic, config));
3770
4505
  return Exit.Ok;
3771
4506
  }
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);
4507
+ function instanceDir(args, config, game, profile) {
4508
+ const dir = profileDataDir(config, game, profile);
3784
4509
  let spec;
3785
4510
  try {
3786
- spec = resolveProfile(config.games[game2], profile2);
4511
+ spec = resolveProfile(config.games[game], profile);
3787
4512
  } catch {
3788
4513
  spec = undefined;
3789
4514
  }
3790
4515
  return resolveInstance({ profileDir: dir, ...spec === undefined ? {} : { profile: spec }, args }).dir;
3791
4516
  }
3792
4517
  function reportEnvironment(problems) {
3793
- process.stderr.write(`${problems.length} environment problem(s):
3794
- `);
4518
+ const out = [];
3795
4519
  for (const problem of problems) {
3796
- process.stderr.write(` ${problem.where}
3797
- ${problem.message}
3798
- `);
4520
+ out.push(` ${problem.where}
4521
+ ${problem.message}`);
3799
4522
  if (problem.suggestion)
3800
- process.stderr.write(` try: ${problem.suggestion}
3801
- `);
4523
+ out.push(` try: ${problem.suggestion}`);
3802
4524
  }
3803
- process.exit(Exit.Environment);
4525
+ throw new GamecrateError(`${problems.length} environment problem(s)`, Exit.Environment, out.join(`
4526
+ `));
3804
4527
  }
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 });
4528
+ async function run(argv, args, config, plugins, defaults, asShell) {
4529
+ const game = requireGame(args, config);
4530
+ const profile = profileOf(args, defaults);
4531
+ const index = await buildIndex(game, config.games[game], requirePlugin(plugins, game));
4532
+ const { plan, problems } = await resolvePlan({ game, profile, root: config, plugins, args, index });
3810
4533
  if (problems.length > 0)
3811
4534
  reportProblems(problems);
3812
4535
  const identity = resolveIdentity(args.root);
3813
4536
  if (args.printPlan || args.dryRun) {
3814
- const environment2 = await preflight(plan);
4537
+ const environment = await preflight(plan);
3815
4538
  buildRunSpec(plan, [], identity);
3816
4539
  if (args.printPlan)
3817
4540
  printPlan(plan, args.json);
3818
4541
  for (const warning of planWarnings(plan))
3819
4542
  warn(warning);
3820
- if (environment2.length > 0)
3821
- return reportEnvironment(environment2);
4543
+ if (environment.length > 0)
4544
+ reportEnvironment(environment);
3822
4545
  if (!args.printPlan) {
3823
- const what = plan.instance === undefined ? profile2 : `${profile2}/${plan.instance}`;
3824
- status(`${game2} ${what}: ${plan.mods.length} mods resolve cleanly`);
4546
+ const what = plan.instance === undefined ? profile : `${profile}/${plan.instance}`;
4547
+ status(`${game} ${what}: ${plan.mods.length} mods resolve cleanly`);
3825
4548
  }
3826
4549
  return Exit.Ok;
3827
4550
  }
3828
4551
  const environment = await preflight(plan);
3829
4552
  if (environment.length > 0)
3830
- return reportEnvironment(environment);
4553
+ reportEnvironment(environment);
3831
4554
  await ensureProfileTree(plan);
3832
- if (args.replace)
4555
+ const profileSpec = resolveProfile(config.games[game], profile);
4556
+ if (wantsReplace(args, profileSpec))
3833
4557
  await replacePrevious(plan);
3834
- const lock = await takeLock(plan);
4558
+ if (!asShell && wantsDetach(args, profileSpec))
4559
+ return await forkSupervisor(plan, argv);
4560
+ const lock = args.supervised ? heldLock(plan) : await takeLock(plan);
3835
4561
  try {
3836
- return await launch(plan, args, config, identity, asShell);
4562
+ const result = await launch(plan, args, config, identity, asShell, profileSpec);
4563
+ if (args.supervised)
4564
+ await recordExit(plan, result);
4565
+ return result.code;
3837
4566
  } finally {
3838
4567
  await lock.release();
3839
4568
  }
3840
4569
  }
3841
- async function launch(plan, args, config, identity, asShell) {
3842
- const game2 = plan.game;
3843
- const profile2 = plan.profile;
4570
+ async function launch(plan, args, config, identity, asShell, profileSpec) {
4571
+ const game = plan.game;
4572
+ const profile = plan.profile;
3844
4573
  const foreign = await detectForeignOwnership(plan.dataDirHost, identity.uid, 5);
3845
4574
  if (foreign.length > 0) {
3846
4575
  throw new GamecrateError(`${foreign.length} path(s) under ${plan.dataDirHost} are not owned by uid ${identity.uid}`, Exit.Environment, `${foreign.join(`
3847
4576
  `)}
3848
- run: gamecrate fix-perms ${game2} ${profile2}`);
4577
+ run: gamecrate fix-perms ${game} ${profile}`);
3849
4578
  }
3850
4579
  const runDir = openRunLog(plan.logsDirHost);
3851
4580
  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);
4581
+ const supervisorLog = args.supervised && args.log === undefined ? redirectOutput(join16(runDir, "supervisor.log")) : undefined;
4582
+ try {
4583
+ return await execute(plan, args, config, identity, asShell, profileSpec, runDir);
4584
+ } finally {
4585
+ supervisorLog?.close();
4586
+ }
4587
+ }
4588
+ async function execute(plan, args, config, identity, asShell, profileSpec, runDir) {
4589
+ const game = plan.game;
4590
+ await buildLocalMods(plan, buildPolicy(args, profileSpec));
4591
+ await acquireImage(game, config.games[game], args.pull ?? "missing");
4592
+ const runtimeImage = plan.mode === "headed" ? config.games[game].image.ref : await ensureRuntimeLayer(config.games[game].image.ref);
3855
4593
  const modMounts = await stageMods(plan);
3856
4594
  await generateModsConfig(plan);
3857
4595
  await mergePrefs(plan);
@@ -3886,7 +4624,9 @@ run: gamecrate fix-perms ${game2} ${profile2}`);
3886
4624
  logDir: runDir,
3887
4625
  stopTimeoutSeconds: STOP_TIMEOUT_SECONDS
3888
4626
  });
3889
- return windowClosed ? Exit.Ok : normalize(code);
4627
+ if (windowClosed)
4628
+ return { code: Exit.Ok, reason: "window-closed" };
4629
+ return { code: normalize(code), reason: reasonFor(code) };
3890
4630
  } finally {
3891
4631
  window?.stop();
3892
4632
  }
@@ -3895,42 +4635,42 @@ run: gamecrate fix-perms ${game2} ${profile2}`);
3895
4635
  }
3896
4636
  }
3897
4637
  function markerSources(plan, logDir) {
3898
- const sources = [join14(logDir, STDOUT_LOG)];
4638
+ const sources = [join16(logDir, STDOUT_LOG)];
3899
4639
  const { logFile } = plan.gameConfig;
3900
4640
  if (logFile.mode === "arg")
3901
- sources.push(join14(logDir, "Player.log"));
4641
+ sources.push(join16(logDir, "Player.log"));
3902
4642
  else
3903
- sources.push(join14(plan.dataDirHost, logFile.from));
4643
+ sources.push(join16(plan.dataDirHost, logFile.from));
3904
4644
  return sources;
3905
4645
  }
3906
4646
  async function runBounded(spec, plan, logDir) {
3907
4647
  const container = runContainer(spec, { logDir, stopTimeoutSeconds: STOP_TIMEOUT_SECONDS });
3908
4648
  const winner = await Promise.race([
3909
4649
  container.then((code) => ({ kind: "exit", code })),
3910
- sleep4(plan.timeoutSeconds * 1000).then(() => ({ kind: "timeout" }))
4650
+ sleep5(plan.timeoutSeconds * 1000).then(() => ({ kind: "timeout" }))
3911
4651
  ]);
3912
4652
  if (winner.kind === "exit")
3913
- return normalize(winner.code);
4653
+ return { code: normalize(winner.code), reason: reasonFor(winner.code) };
3914
4654
  status(`no marker given; stopping after ${plan.timeoutSeconds}s`);
3915
4655
  await stopContainer(spec.name, STOP_TIMEOUT_SECONDS);
3916
4656
  await container;
3917
- return Exit.Ok;
4657
+ return { code: Exit.Ok, reason: "timeout" };
3918
4658
  }
3919
4659
  async function runWithScreenshot(spec, plan, logDir) {
3920
4660
  const container = runContainer(spec, { logDir, stopTimeoutSeconds: STOP_TIMEOUT_SECONDS });
3921
- const settled = sleep4(plan.renderWaitSeconds * 1000).then(() => "ready");
4661
+ const settled = sleep5(plan.renderWaitSeconds * 1000).then(() => "ready");
3922
4662
  const winner = await Promise.race([
3923
4663
  container.then((code) => ({ kind: "exit", code })),
3924
4664
  settled.then(() => ({ kind: "ready" }))
3925
4665
  ]);
3926
4666
  if (winner.kind === "exit") {
3927
4667
  status(`game exited before the ${plan.renderWaitSeconds}s render wait finished; no frame captured`);
3928
- return normalize(winner.code);
4668
+ return { code: normalize(winner.code), reason: reasonFor(winner.code) };
3929
4669
  }
3930
4670
  const shot = await grabFrame(spec.name, plan);
3931
4671
  await stopContainer(spec.name, STOP_TIMEOUT_SECONDS);
3932
4672
  await container;
3933
- return shot === null ? Exit.Environment : Exit.Ok;
4673
+ return { code: shot === null ? Exit.Environment : Exit.Ok, reason: "stopped" };
3934
4674
  }
3935
4675
  async function grabFrame(container, plan) {
3936
4676
  const path = await captureScreenshot(container, plan);
@@ -3949,17 +4689,17 @@ async function runWithMarker(spec, plan, logDir) {
3949
4689
  seen.then((hit) => ({ kind: "marker", hit }))
3950
4690
  ]);
3951
4691
  if (winner.kind === "exit")
3952
- return normalize(winner.code);
4692
+ return { code: normalize(winner.code), reason: reasonFor(winner.code) };
3953
4693
  if (plan.mode === "screenshot")
3954
4694
  await grabFrame(spec.name, plan);
3955
4695
  await stopContainer(spec.name, STOP_TIMEOUT_SECONDS);
3956
4696
  await container;
3957
4697
  if (winner.hit) {
3958
4698
  status(`marker seen: ${marker}`);
3959
- return Exit.Ok;
4699
+ return { code: Exit.Ok, reason: "marker" };
3960
4700
  }
3961
4701
  status(`marker "${marker}" not seen within ${plan.timeoutSeconds}s`);
3962
- return Exit.MarkerTimeout;
4702
+ return { code: Exit.MarkerTimeout, reason: "marker-timeout" };
3963
4703
  }
3964
4704
  function normalize(code) {
3965
4705
  return Number.isInteger(code) && code >= 0 && code <= 255 ? code : Exit.GameFailed;
@@ -3968,72 +4708,21 @@ async function copyOutLogs(plan) {
3968
4708
  const spec = plan.gameConfig.logFile;
3969
4709
  if (spec.mode !== "copy-out")
3970
4710
  return;
3971
- const source = join14(plan.dataDirHost, spec.from);
3972
- if (!existsSync7(source))
4711
+ const source = join16(plan.dataDirHost, spec.from);
4712
+ if (!existsSync8(source))
3973
4713
  return;
3974
- const target = join14(plan.runDirHost, basename7(spec.from.replace(/\/+$/, "")));
4714
+ const target = join16(plan.runDirHost, basename10(spec.from.replace(/\/+$/, "")));
3975
4715
  try {
3976
4716
  await cp(source, target, { recursive: true, force: true });
3977
4717
  } catch (error) {
3978
4718
  warn(`could not copy ${source}: ${describe(error)}`);
3979
4719
  }
3980
4720
  }
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 });
4721
+ async function mods(args, config, plugins, defaults) {
4722
+ const game = requireGame(args, config);
4723
+ const profile = profileOf(args, defaults);
4724
+ const index = await buildIndex(game, config.games[game], requirePlugin(plugins, game));
4725
+ const { plan, problems } = await resolvePlan({ game, profile, root: config, plugins, args, index });
4037
4726
  if (problems.length > 0)
4038
4727
  reportProblems(problems);
4039
4728
  printPlan(plan, args.json);
@@ -4041,15 +4730,15 @@ async function mods(args, config, plugins) {
4041
4730
  }
4042
4731
  async function doctor(config, plugins) {
4043
4732
  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 });
4733
+ for (const game of Object.keys(config.games)) {
4734
+ const { plan, problems } = await resolvePlan({ game, profile: "modless", root: config, plugins });
4046
4735
  const all = [...problems, ...await preflight(plan)];
4047
4736
  if (all.length === 0) {
4048
- status(`${game2}: ok`);
4737
+ status(`${game}: ok`);
4049
4738
  continue;
4050
4739
  }
4051
4740
  failed = true;
4052
- status(`${game2}: ${all.length} problem(s)`);
4741
+ status(`${game}: ${all.length} problem(s)`);
4053
4742
  for (const problem of all) {
4054
4743
  process.stderr.write(` ${problem.where}
4055
4744
  ${problem.message}
@@ -4061,28 +4750,31 @@ async function doctor(config, plugins) {
4061
4750
  }
4062
4751
  return failed ? Exit.Environment : Exit.Ok;
4063
4752
  }
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);
4753
+ async function logs(args, config, defaults) {
4754
+ const game = requireGame(args, config);
4755
+ const profile = profileOf(args, defaults);
4756
+ const dir = instanceDir(args, config, game, profile);
4757
+ if (args.follow)
4758
+ return await follow(dir, false, `${game} ${profile}`);
4759
+ const runs = join16(dir, "logs", "runs");
4760
+ const latest = (await readdir9(runs, { withFileTypes: true }).catch(() => [])).filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort().at(-1);
4069
4761
  if (latest === undefined) {
4070
- throw new GamecrateError(`no runs recorded for ${game2} ${profile2}`, Exit.Usage, runs);
4762
+ throw new GamecrateError(`no runs recorded for ${game} ${profile}`, Exit.Usage, runs);
4071
4763
  }
4072
- const dir = join14(runs, latest);
4073
- const files = (await readdir8(dir, { withFileTypes: true })).filter((entry) => entry.isFile()).map((entry) => entry.name).sort();
4764
+ const runDir = join16(runs, latest);
4765
+ const files = (await readdir9(runDir, { withFileTypes: true })).filter((entry) => entry.isFile()).map((entry) => entry.name).sort();
4074
4766
  if (args.json) {
4075
- process.stdout.write(`${JSON.stringify({ run: latest, dir, files }, null, 2)}
4767
+ process.stdout.write(`${JSON.stringify({ run: latest, dir: runDir, files }, null, 2)}
4076
4768
  `);
4077
4769
  return Exit.Ok;
4078
4770
  }
4079
- status(dir);
4771
+ status(runDir);
4080
4772
  for (const name of files) {
4081
- const text = await readFile5(join14(dir, name), "utf8").catch(() => "");
4082
- for (const line2 of text.split(`
4773
+ const text = await readFile7(join16(runDir, name), "utf8").catch(() => "");
4774
+ for (const line of text.split(`
4083
4775
  `)) {
4084
- if (line2.length > 0)
4085
- process.stdout.write(`${name}: ${line2}
4776
+ if (line.length > 0)
4777
+ process.stdout.write(`${name}: ${line}
4086
4778
  `);
4087
4779
  }
4088
4780
  }
@@ -4116,10 +4808,10 @@ function boundStatus(mod) {
4116
4808
  return "OK";
4117
4809
  return mod.hasSources ? "STALE - never built" : "(xml only)";
4118
4810
  }
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 });
4811
+ async function verify(args, config, plugins, defaults) {
4812
+ const game = requireGame(args, config);
4813
+ const profile = profileOf(args, defaults);
4814
+ const { plan, problems } = await resolvePlan({ game, profile, root: config, plugins, args });
4123
4815
  if (problems.length > 0)
4124
4816
  reportProblems(problems);
4125
4817
  const name = containerName(plan);
@@ -4187,8 +4879,8 @@ function renderVerify(name, info, plan, boundMods) {
4187
4879
  const stamps = boundMods.map((mod) => mod.assembly === null ? "no assemblies" : `${mod.assembly.path} ${ago(mod.assembly.mtimeMs)}`);
4188
4880
  const stampWidth = Math.max(...stamps.map((s) => s.length));
4189
4881
  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());
4882
+ const origin = mod.branch === null ? "" : `worktree ${mod.branch}`;
4883
+ out.push(` ${mod.packageId.padEnd(idWidth)} ${paths[i].padEnd(pathWidth)} ${origin}`.trimEnd());
4192
4884
  out.push(` ${" ".repeat(idWidth)} ${stamps[i].padEnd(stampWidth)} ${boundStatus(mod)}`);
4193
4885
  }
4194
4886
  return out.join(`
@@ -4198,17 +4890,15 @@ function shortenHome(path) {
4198
4890
  const home = homedir5();
4199
4891
  return path === home || path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path;
4200
4892
  }
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 });
4893
+ async function clean(args, config, defaults) {
4894
+ const game = requireGame(args, config);
4895
+ const profile = profileOf(args, defaults);
4896
+ const dir = profileDataDir(config, game, profile);
4897
+ const tier = args.cleanTier ?? "staging";
4898
+ const saveSuffixes = config.games[game].saveExtensions.map((ext) => `.${ext.replace(/^\./, "")}`.toLowerCase());
4899
+ if (tier !== "all") {
4900
+ const target = join16(instanceDir(args, config, game, profile), tier === "logs" ? "logs" : ".stage");
4901
+ await rm3(target, { recursive: true, force: true });
4212
4902
  status(`removed ${target}`);
4213
4903
  return Exit.Ok;
4214
4904
  }
@@ -4216,7 +4906,7 @@ async function clean(args, config) {
4216
4906
  if (!args.yes) {
4217
4907
  throw new GamecrateError(`clean --all would delete ${dir}, including ${saves} save file(s)`, Exit.Usage, "add --yes to confirm");
4218
4908
  }
4219
- await rm2(dir, { recursive: true, force: true });
4909
+ await rm3(dir, { recursive: true, force: true });
4220
4910
  status(`removed ${dir} (${saves} save file(s))`);
4221
4911
  return Exit.Ok;
4222
4912
  }
@@ -4227,13 +4917,13 @@ async function countSaves(dir, suffixes) {
4227
4917
  const current = queue.shift();
4228
4918
  let entries;
4229
4919
  try {
4230
- entries = await readdir8(current, { withFileTypes: true });
4920
+ entries = await readdir9(current, { withFileTypes: true });
4231
4921
  } catch {
4232
4922
  continue;
4233
4923
  }
4234
4924
  for (const entry of entries) {
4235
4925
  if (entry.isDirectory())
4236
- queue.push(join14(current, entry.name));
4926
+ queue.push(join16(current, entry.name));
4237
4927
  else if (suffixes.some((s) => entry.name.toLowerCase().endsWith(s)))
4238
4928
  count++;
4239
4929
  }
@@ -4241,16 +4931,16 @@ async function countSaves(dir, suffixes) {
4241
4931
  return count;
4242
4932
  }
4243
4933
  async function clone(args, config) {
4244
- const game2 = requireGame(args, config);
4934
+ const game = requireGame(args, config);
4245
4935
  const [src, dst] = args.rest;
4246
4936
  if (src === undefined || dst === undefined) {
4247
4937
  throw new GamecrateError("clone needs a source and a destination profile", Exit.Usage);
4248
4938
  }
4249
- const from = join14(profileDataDir(config, game2, src), "game");
4250
- const to = join14(profileDataDir(config, game2, dst), "game");
4251
- if (!existsSync7(from))
4939
+ const from = join16(profileDataDir(config, game, src), "game");
4940
+ const to = join16(profileDataDir(config, game, dst), "game");
4941
+ if (!existsSync8(from))
4252
4942
  throw new GamecrateError(`${from} does not exist`, Exit.Usage);
4253
- if (existsSync7(to) && !args.yes) {
4943
+ if (existsSync8(to) && !args.yes) {
4254
4944
  throw new GamecrateError(`${to} already exists`, Exit.Usage, "add --yes to overwrite");
4255
4945
  }
4256
4946
  await mkdir4(to, { recursive: true });
@@ -4260,22 +4950,103 @@ async function clone(args, config) {
4260
4950
  status(`cloned ${from} -> ${to}`);
4261
4951
  return Exit.Ok;
4262
4952
  }
4953
+ async function ps(args, config) {
4954
+ const runs = await listRuns(config.dataRoot);
4955
+ if (args.json) {
4956
+ process.stdout.write(`${JSON.stringify(runs, null, 2)}
4957
+ `);
4958
+ return Exit.Ok;
4959
+ }
4960
+ if (runs.length === 0) {
4961
+ status("nothing running");
4962
+ return Exit.Ok;
4963
+ }
4964
+ const rows = runs.map((entry) => [
4965
+ entry.game,
4966
+ entry.instance === undefined ? entry.profile : `${entry.profile}/${entry.instance}`,
4967
+ entry.mode ?? "",
4968
+ entry.pid === undefined ? "" : String(entry.pid),
4969
+ entry.container,
4970
+ entry.uptime ?? entry.status
4971
+ ]);
4972
+ const widths = rows[0].map((_, i) => Math.max(...rows.map((row) => row[i].length)));
4973
+ for (const row of rows) {
4974
+ process.stdout.write(`${row.map((cell, i) => cell.padEnd(widths[i])).join(" ").trimEnd()}
4975
+ `);
4976
+ }
4977
+ if (runs.some((entry) => entry.status === "orphaned")) {
4978
+ warn("some locks have no container; run gamecrate stop to clear them");
4979
+ }
4980
+ return Exit.Ok;
4981
+ }
4982
+ async function stop(args, config, defaults) {
4983
+ const game = requireGame(args, config);
4984
+ const profile = profileOf(args, defaults);
4985
+ const file = join16(instanceDir(args, config, game, profile), ".gamecrate", "lock");
4986
+ const record = await readLock(file);
4987
+ if (record === undefined) {
4988
+ status(`${game} ${profile} is not running`);
4989
+ return Exit.Ok;
4990
+ }
4991
+ const outcome = await stopRun(record, file);
4992
+ if (outcome === "held") {
4993
+ warn(`pid ${record.pid} still holds ${file}; ${record.container} did not stop in time`);
4994
+ return Exit.Refused;
4995
+ }
4996
+ status(outcome === "signalled" ? `stopped ${record.container}` : `cleared the stale lock for ${record.container}`);
4997
+ return Exit.Ok;
4998
+ }
4999
+ async function attach(args, config, defaults) {
5000
+ const game = requireGame(args, config);
5001
+ const profile = profileOf(args, defaults);
5002
+ return await follow(instanceDir(args, config, game, profile), true, `${game} ${profile}`);
5003
+ }
5004
+ async function follow(dir, fromStart, what) {
5005
+ const lock = await readLock(join16(dir, ".gamecrate", "lock"));
5006
+ const held = lock !== undefined && isRunning(lock.pid, lock.startedAt);
5007
+ const live = held && await awaitRunLog(dir, lock);
5008
+ const file = currentLog(dir);
5009
+ if (!existsSync8(file)) {
5010
+ throw new GamecrateError(`no captured output for ${what}`, Exit.Usage, file);
5011
+ }
5012
+ return await spawnStatus(tailArgv(file, fromStart, live ? lock.pid : undefined), true);
5013
+ }
5014
+ async function waitFor(args, config, defaults) {
5015
+ const game = requireGame(args, config);
5016
+ const profile = profileOf(args, defaults);
5017
+ const dir = instanceDir(args, config, game, profile);
5018
+ const record = await awaitExit(dir);
5019
+ if (record === "orphaned") {
5020
+ throw new GamecrateError(`${game} ${profile}: the lock holder is gone and recorded no exit`, Exit.Refused, `run: gamecrate stop ${game} ${profile}`);
5021
+ }
5022
+ if (record === "absent") {
5023
+ throw new GamecrateError(`no run recorded for ${game} ${profile}`, Exit.Usage, dir);
5024
+ }
5025
+ if (args.json)
5026
+ process.stdout.write(`${JSON.stringify(record)}
5027
+ `);
5028
+ else
5029
+ status(`${game} ${profile}: ${record.reason} (${record.code})`);
5030
+ return record.code;
5031
+ }
4263
5032
  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`);
5033
+ const game = requireGame(args, config);
5034
+ await acquireImage(game, config.games[game], args.pull ?? "always");
5035
+ status(`${config.games[game].image.ref} is ready`);
4267
5036
  return Exit.Ok;
4268
5037
  }
4269
5038
  async function configEdit(args) {
4270
5039
  if (args.rest[0] !== "edit")
4271
5040
  throw new GamecrateError("config takes one word: edit", Exit.Usage);
4272
- const path = defaultConfigPath();
5041
+ const existing = await findGlobalConfig();
5042
+ const path = existing ?? join16(globalConfigDir(), "profiles.yml");
4273
5043
  await mkdir4(dirname5(path), { recursive: true });
4274
- if (!existsSync7(path))
4275
- await writeFile4(path, `{
4276
- "games": {}
4277
- }
5044
+ if (!existsSync8(path)) {
5045
+ await writeFile5(path, `# gamecrate config. see https://github.com/RimWorks/gamecrate
5046
+ ` + `plugins: []
5047
+ ` + `games: {}
4278
5048
  `);
5049
+ }
4279
5050
  const editor = process.env.VISUAL ?? process.env.EDITOR;
4280
5051
  if (editor === undefined)
4281
5052
  throw new GamecrateError("no $EDITOR or $VISUAL set", Exit.Usage, path);
@@ -4286,16 +5057,16 @@ async function configEdit(args) {
4286
5057
  return Exit.Ok;
4287
5058
  }
4288
5059
  async function fixPerms(args, config) {
4289
- const game2 = requireGame(args, config);
5060
+ const game = requireGame(args, config);
4290
5061
  const identity = resolveIdentity(false);
4291
5062
  const found = [];
4292
- for (const dir of await profileDirs(config, game2, args.profile)) {
4293
- if (!existsSync7(dir))
5063
+ for (const dir of await profileDirs(config, game, args.profile)) {
5064
+ if (!existsSync8(dir))
4294
5065
  continue;
4295
5066
  found.push(...await detectForeignOwnership(dir, identity.uid, 1e4));
4296
5067
  }
4297
5068
  if (found.length === 0) {
4298
- status(`${game2}: every path is owned by uid ${identity.uid}`);
5069
+ status(`${game}: every path is owned by uid ${identity.uid}`);
4299
5070
  return Exit.Ok;
4300
5071
  }
4301
5072
  if (args.dryRun || !args.yes) {
@@ -4333,7 +5104,7 @@ async function removeIfEmptyDir(path) {
4333
5104
  const info = await stat4(path).catch(() => null);
4334
5105
  if (info === null || !info.isDirectory())
4335
5106
  return false;
4336
- const entries = await readdir8(path).catch(() => null);
5107
+ const entries = await readdir9(path).catch(() => null);
4337
5108
  if (entries === null || entries.length > 0)
4338
5109
  return false;
4339
5110
  return rmdir(path).then(() => true, () => false);