@gamecrate/cli 1.0.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.
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
  }
@@ -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,19 +1953,19 @@ 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;
@@ -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;
@@ -1904,8 +2431,8 @@ function profileDataDir(root, game, profile) {
1904
2431
  async function profileDirs(root, game, profile) {
1905
2432
  if (profile !== undefined)
1906
2433
  return [profileDataDir(root, game, profile)];
1907
- const dir = join5(expandHome(root.dataRoot), game);
1908
- return (await readdir2(dir).catch(() => [])).map((name) => join5(dir, name));
2434
+ const dir = join6(expandHome(root.dataRoot), game);
2435
+ return (await readdir3(dir).catch(() => [])).map((name) => join6(dir, name));
1909
2436
  }
1910
2437
  function profileKey(game, name) {
1911
2438
  if (Object.hasOwn(game.profiles, name))
@@ -1914,7 +2441,7 @@ function profileKey(game, name) {
1914
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(game.profiles).find((k) => (own(game.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)
@@ -1974,7 +2501,7 @@ 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;
2504
+ return p.startsWith("~/") ? join6(homedir2(), p.slice(2)) : p;
1978
2505
  }
1979
2506
 
1980
2507
  // src/docker/identity.ts
@@ -1998,155 +2525,7 @@ function hostUserName(uid) {
1998
2525
  // src/docker/preflight.ts
1999
2526
  import { existsSync as existsSync3, readFileSync as readFileSync2 } from "node:fs";
2000
2527
  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((resolve, reject) => {
2015
- proc.once("error", reject);
2016
- proc.once("close", (code) => resolve(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 status = await code;
2061
- return interrupted ? Exit.Interrupted : status;
2062
- } finally {
2063
- process.off("SIGINT", onSignal);
2064
- process.off("SIGTERM", onSignal);
2065
- await new Promise((resolve) => sink.end(resolve));
2066
- }
2067
- }
2068
- async function stopContainer(name, timeoutSeconds) {
2069
- const proc = spawnArgv(["docker", "stop", "--timeout", String(timeoutSeconds), name], "ignore");
2070
- await exited(proc).catch(() => {});
2071
- }
2072
- async function waitForMarker(sources, marker, timeoutSeconds) {
2073
- const deadline = Date.now() + timeoutSeconds * 1000;
2074
- const carry = Math.max(marker.length - 1, 0);
2075
- const seen = new Map;
2076
- const startedAt = Date.now();
2077
- while (true) {
2078
- for (const path of await expandSources(sources)) {
2079
- let state = seen.get(path);
2080
- if (state === undefined) {
2081
- state = { offset: await staleSize(path, startedAt), tail: "", decoder: new TextDecoder };
2082
- seen.set(path, state);
2083
- }
2084
- if (await scan(path, state, marker, carry))
2085
- return true;
2086
- }
2087
- if (Date.now() >= deadline)
2088
- return false;
2089
- await sleep(Math.min(MARKER_POLL_MS, Math.max(deadline - Date.now(), 0)));
2090
- }
2091
- }
2092
- async function staleSize(path, startedAt) {
2093
- return stat2(path).then((info) => info.mtimeMs < startedAt ? info.size : 0, () => 0);
2094
- }
2095
- async function scan(path, state, marker, carry) {
2096
- const handle = await open(path, "r").catch(() => null);
2097
- if (handle === null)
2098
- return false;
2099
- try {
2100
- const { size } = await handle.stat();
2101
- if (size < state.offset) {
2102
- state.offset = 0;
2103
- state.tail = "";
2104
- state.decoder = new TextDecoder;
2105
- }
2106
- if (size <= state.offset)
2107
- return false;
2108
- const buffer = Buffer.alloc(size - state.offset);
2109
- const { bytesRead } = await handle.read(buffer, 0, buffer.length, state.offset);
2110
- state.offset += bytesRead;
2111
- const text = state.tail + state.decoder.decode(buffer.subarray(0, bytesRead), { stream: true });
2112
- if (text.includes(marker))
2113
- return true;
2114
- state.tail = carry > 0 ? text.slice(-carry) : "";
2115
- return false;
2116
- } catch {
2117
- return false;
2118
- } finally {
2119
- await handle.close().catch(() => {});
2120
- }
2121
- }
2122
- async function expandSources(sources) {
2123
- const out = [];
2124
- for (const source of sources) {
2125
- const info = await stat2(source).catch(() => null);
2126
- if (info === null) {
2127
- out.push(source);
2128
- continue;
2129
- }
2130
- if (!info.isDirectory()) {
2131
- out.push(source);
2132
- continue;
2133
- }
2134
- const entries = await readdir3(source).catch(() => []);
2135
- for (const entry of entries) {
2136
- if (entry.toLowerCase().endsWith(".log"))
2137
- out.push(join6(source, entry));
2138
- }
2139
- }
2140
- return out;
2141
- }
2142
- async function tee(stream, sink, mirror) {
2143
- for await (const chunk of stream) {
2144
- mirror.write(chunk);
2145
- sink.write(chunk);
2146
- }
2147
- }
2148
-
2149
- // src/docker/preflight.ts
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 = [];
@@ -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
  }
@@ -2342,15 +2721,40 @@ function firstLine(text) {
2342
2721
  return text.trim().split(`
2343
2722
  `)[0]?.trim() ?? "";
2344
2723
  }
2345
- function message(error) {
2346
- return error instanceof Error ? error.message : String(error);
2724
+ function message(error) {
2725
+ return error instanceof Error ? error.message : String(error);
2726
+ }
2727
+
2728
+ // src/docker/window.ts
2729
+ import { readFileSync as readFileSync3 } from "node:fs";
2730
+ import { basename as basename6 } from "node:path";
2731
+ import { setTimeout as sleep2 } from "node:timers/promises";
2732
+ var WAIT_MS = 180000;
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);
2347
2757
  }
2348
-
2349
- // src/docker/window.ts
2350
- import { basename as basename4 } from "node:path";
2351
- import { setTimeout as sleep2 } from "node:timers/promises";
2352
- var WAIT_MS = 180000;
2353
- var POLL_MS = 500;
2354
2758
  async function toplevels() {
2355
2759
  const { code, stdout } = await capture(["wmctrl", "-lx"]);
2356
2760
  if (code === 127)
@@ -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,7 +2859,7 @@ 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
2864
  async function readInstallVersion(game, plugin) {
2455
2865
  const host = game.gameFiles.host;
@@ -2457,7 +2867,7 @@ async function readInstallVersion(game, plugin) {
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
  }
@@ -2483,7 +2893,7 @@ async function readKnownExpansions(game, plugin, warnings) {
2483
2893
  continue;
2484
2894
  let id = null;
2485
2895
  try {
2486
- id = plugin.parseManifest(await readFile2(join8(dataDir, entry.name, game.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
  }
@@ -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) {
@@ -2690,7 +3100,7 @@ async function buildLocalMods(plan, policy) {
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`;
@@ -2791,13 +3285,146 @@ async function writeLaunchRecord(plan, image) {
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);
@@ -2875,7 +3502,7 @@ function resolveInstance(options) {
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 }
@@ -2906,21 +3533,21 @@ function derive(requests) {
2906
3533
  return `${slug(first.root)}-${digest.slice(0, 6)}`;
2907
3534
  }
2908
3535
  function slug(root) {
2909
- const body = basename5(root).toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^[^a-z0-9]+/, "").slice(0, SLUG_LIMIT).replace(/[-._]+$/, "");
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;
@@ -2954,8 +3581,8 @@ async function scanLocalRoot(root, rootIndex, manifestFile, found) {
2954
3581
  const base = resolvePath(expandHome(root.path));
2955
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",
@@ -2975,13 +3602,13 @@ async function scanLocalRoot(root, 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,8 +3623,8 @@ 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
  }
@@ -3006,7 +3633,7 @@ async function scanGameData(game, rootIndex, found) {
3006
3633
  const host = game.gameFiles.host;
3007
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,8 +3643,8 @@ async function scanGameData(game, 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, game.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
  }
@@ -3026,7 +3653,7 @@ var CACHE_VERSION = 2;
3026
3653
  function workshopStamp(game) {
3027
3654
  if (game.workshopRoot === null)
3028
3655
  return null;
3029
- const acf = join11(dirname4(dirname4(resolvePath(expandHome(game.workshopRoot)))), `appworkshop_${game.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}`;
@@ -3036,7 +3663,7 @@ function workshopStamp(game) {
3036
3663
  }
3037
3664
  async function readWorkshopCache(game, stamp) {
3038
3665
  try {
3039
- const raw = JSON.parse(await readFile4(join11(cacheDir(), `${game}.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;
@@ -3048,7 +3675,7 @@ 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(), `${game}.workshop.json`), JSON.stringify(payload));
3678
+ await writeFile4(join12(cacheDir(), `${game}.workshop.json`), JSON.stringify(payload));
3052
3679
  } catch {}
3053
3680
  }
3054
3681
  function toRecord(candidate, manifest, game) {
@@ -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;
@@ -3231,9 +3858,9 @@ async function buildIndex(game, 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({
@@ -3302,11 +3929,11 @@ function byPath(index, raw, game) {
3302
3929
  if (hit)
3303
3930
  return hit;
3304
3931
  }
3305
- const file = join11(dir, game.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
3939
  return toRecord({ dir, kind: "local", rootIndex: -1, linkedWorktree: false }, manifest, game);
@@ -3603,11 +4230,11 @@ async function resolvePlan(options) {
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 = "1.0.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,16 +4504,6 @@ 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 game = args.game;
3774
- if (game === 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, game)) {
3778
- throw new GamecrateError(`unknown game "${game}"`, Exit.Config, `known games: ${Object.keys(config.games).join(", ")}`);
3779
- }
3780
- return game;
3781
- }
3782
4507
  function instanceDir(args, config, game, profile) {
3783
4508
  const dir = profileDataDir(config, game, profile);
3784
4509
  let spec;
@@ -3790,21 +4515,19 @@ function instanceDir(args, config, game, profile) {
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) {
4528
+ async function run(argv, args, config, plugins, defaults, asShell) {
3806
4529
  const game = requireGame(args, config);
3807
- const profile = args.profile ?? "modless";
4530
+ const profile = profileOf(args, defaults);
3808
4531
  const index = await buildIndex(game, config.games[game], requirePlugin(plugins, game));
3809
4532
  const { plan, problems } = await resolvePlan({ game, profile, root: config, plugins, args, index });
3810
4533
  if (problems.length > 0)
@@ -3818,7 +4541,7 @@ async function run(args, config, plugins, asShell) {
3818
4541
  for (const warning of planWarnings(plan))
3819
4542
  warn(warning);
3820
4543
  if (environment.length > 0)
3821
- return reportEnvironment(environment);
4544
+ reportEnvironment(environment);
3822
4545
  if (!args.printPlan) {
3823
4546
  const what = plan.instance === undefined ? profile : `${profile}/${plan.instance}`;
3824
4547
  status(`${game} ${what}: ${plan.mods.length} mods resolve cleanly`);
@@ -3827,18 +4550,24 @@ async function run(args, config, plugins, asShell) {
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) {
4570
+ async function launch(plan, args, config, identity, asShell, profileSpec) {
3842
4571
  const game = plan.game;
3843
4572
  const profile = plan.profile;
3844
4573
  const foreign = await detectForeignOwnership(plan.dataDirHost, identity.uid, 5);
@@ -3849,7 +4578,16 @@ 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");
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));
3853
4591
  await acquireImage(game, config.games[game], args.pull ?? "missing");
3854
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);
@@ -3886,7 +4624,9 @@ run: gamecrate fix-perms ${game} ${profile}`);
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 ${game} ${profile}`);
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,70 +4708,19 @@ 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 game = config.games[name];
3986
- return {
3987
- game: name,
3988
- core: game.core,
3989
- dlc: game.dlc,
3990
- modes: game.modes,
3991
- profiles: Object.entries(game.profiles).map(([profile, spec]) => ({
3992
- profile,
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 game = config.games[name];
4007
- const width = Math.max(7, ...Object.keys(game.profiles).map((n) => n.length));
4008
- out.push(`${name} (${game.modes.join(", ")})`);
4009
- out.push(` ${"modless".padEnd(width)} built-in: core + official DLC`);
4010
- for (const [profile, spec] of Object.entries(game.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(` ${profile.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) {
4721
+ async function mods(args, config, plugins, defaults) {
4033
4722
  const game = requireGame(args, config);
4034
- const profile = args.profile ?? "modless";
4723
+ const profile = profileOf(args, defaults);
4035
4724
  const index = await buildIndex(game, config.games[game], requirePlugin(plugins, game));
4036
4725
  const { plan, problems } = await resolvePlan({ game, profile, root: config, plugins, args, index });
4037
4726
  if (problems.length > 0)
@@ -4061,24 +4750,27 @@ async function doctor(config, plugins) {
4061
4750
  }
4062
4751
  return failed ? Exit.Environment : Exit.Ok;
4063
4752
  }
4064
- async function logs(args, config) {
4753
+ async function logs(args, config, defaults) {
4065
4754
  const game = requireGame(args, config);
4066
- const profile = args.profile ?? "modless";
4067
- const runs = join14(instanceDir(args, config, game, profile), "logs", "runs");
4068
- const latest = (await readdir8(runs, { withFileTypes: true }).catch(() => [])).filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort().at(-1);
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
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(() => "");
4773
+ const text = await readFile7(join16(runDir, name), "utf8").catch(() => "");
4082
4774
  for (const line of text.split(`
4083
4775
  `)) {
4084
4776
  if (line.length > 0)
@@ -4116,9 +4808,9 @@ 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) {
4811
+ async function verify(args, config, plugins, defaults) {
4120
4812
  const game = requireGame(args, config);
4121
- const profile = args.profile ?? "modless";
4813
+ const profile = profileOf(args, defaults);
4122
4814
  const { plan, problems } = await resolvePlan({ game, profile, root: config, plugins, args });
4123
4815
  if (problems.length > 0)
4124
4816
  reportProblems(problems);
@@ -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) {
4893
+ async function clean(args, config, defaults) {
4202
4894
  const game = requireGame(args, config);
4203
- const profile = args.profile;
4204
- if (profile === undefined)
4205
- throw new GamecrateError("clean needs a profile", Exit.Usage);
4895
+ const profile = profileOf(args, defaults);
4206
4896
  const dir = profileDataDir(config, game, profile);
4207
4897
  const tier = args.cleanTier ?? "staging";
4208
4898
  const saveSuffixes = config.games[game].saveExtensions.map((ext) => `.${ext.replace(/^\./, "")}`.toLowerCase());
4209
4899
  if (tier !== "all") {
4210
- const target = join14(instanceDir(args, config, game, profile), tier === "logs" ? "logs" : ".stage");
4211
- await rm2(target, { recursive: true, force: true });
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
  }
@@ -4246,11 +4936,11 @@ async function clone(args, config) {
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, game, src), "game");
4250
- const to = join14(profileDataDir(config, game, 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,6 +4950,85 @@ 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
5033
  const game = requireGame(args, config);
4265
5034
  await acquireImage(game, config.games[game], args.pull ?? "always");
@@ -4269,13 +5038,15 @@ async function build(args, config) {
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);
@@ -4290,7 +5061,7 @@ async function fixPerms(args, config) {
4290
5061
  const identity = resolveIdentity(false);
4291
5062
  const found = [];
4292
5063
  for (const dir of await profileDirs(config, game, args.profile)) {
4293
- if (!existsSync7(dir))
5064
+ if (!existsSync8(dir))
4294
5065
  continue;
4295
5066
  found.push(...await detectForeignOwnership(dir, identity.uid, 1e4));
4296
5067
  }
@@ -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);