@gamecrate/cli 2.3.1 → 2.5.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
@@ -2,7 +2,7 @@
2
2
 
3
3
  // src/index.ts
4
4
  import { existsSync as existsSync16 } from "node:fs";
5
- import { chown, cp, mkdir as mkdir7, readdir as readdir12, readFile as readFile13, rm as rm6, rmdir, stat as stat6, writeFile as writeFile7 } from "node:fs/promises";
5
+ import { chown, cp, mkdir as mkdir7, readdir as readdir12, readFile as readFile14, rm as rm6, rmdir, stat as stat6, writeFile as writeFile7 } from "node:fs/promises";
6
6
  import { homedir as homedir7 } from "node:os";
7
7
  import { basename as basename11, dirname as dirname14, join as join28 } from "node:path";
8
8
  import { setTimeout as sleep8 } from "node:timers/promises";
@@ -724,23 +724,27 @@ function applyPositionals(out, positional, games, help = false) {
724
724
  }
725
725
  const { sub, slots, rest } = routePositionals(out, first, positional, games);
726
726
  const left = fillSlots(out, slots, rest);
727
- if (!help && (out.subverb === "add" || out.subverb === "rm")) {
727
+ if (!help)
728
+ requireSubverbArgs(out);
729
+ if (left.length > 0) {
730
+ const shape = sub ? `${sub.name} ${sub.usage}`.trim() : `${out.game} [profile]`;
731
+ throw usage(`unexpected argument ${left[0]}`, `gamecrate ${shape}`);
732
+ }
733
+ }
734
+ function requireSubverbArgs(out) {
735
+ if (out.subverb === "add" || out.subverb === "rm") {
728
736
  if (out.game === undefined)
729
737
  throw usage(`mods ${out.subverb} needs a game`);
730
738
  if (out.subverb === "rm" && out.rest.length === 0)
731
739
  throw usage("mods rm needs at least one mod id");
732
740
  }
733
- if (!help && out.subcommand === "steam") {
734
- if (out.subverb === undefined) {
735
- throw usage("steam needs a subverb", "gamecrate steam build <game>, or gamecrate steam login");
736
- }
737
- if (out.subverb === "build" && out.game === undefined)
738
- throw usage("steam build needs a game");
739
- }
740
- if (left.length > 0) {
741
- const shape = sub ? `${sub.name} ${sub.usage}`.trim() : `${out.game} [profile]`;
742
- throw usage(`unexpected argument ${left[0]}`, `gamecrate ${shape}`);
741
+ if (out.subcommand !== "steam")
742
+ return;
743
+ if (out.subverb === undefined) {
744
+ throw usage("steam needs a subverb", "gamecrate steam build <game>, or gamecrate steam login");
743
745
  }
746
+ if (out.subverb === "build" && out.game === undefined)
747
+ throw usage("steam build needs a game");
744
748
  }
745
749
  function routePositionals(out, first, positional, games) {
746
750
  const sub = SUBCOMMANDS.find((s) => s.name === first);
@@ -1033,2207 +1037,2240 @@ function requireGame(args, config) {
1033
1037
  }
1034
1038
 
1035
1039
  // src/image/refs.ts
1036
- import { existsSync as existsSync8 } from "node:fs";
1037
- import { mkdir as mkdir3, readdir as readdir7, rename, rm as rm3, symlink, unlink as unlink3 } from "node:fs/promises";
1040
+ import { existsSync as existsSync8, readlinkSync } from "node:fs";
1041
+ import { mkdir as mkdir3, readFile as readFile6, readdir as readdir7, rename, rm as rm3, symlink, unlink as unlink3 } from "node:fs/promises";
1038
1042
  import { dirname as dirname6, join as join11 } from "node:path";
1039
1043
 
1040
1044
  // src/cli/output.ts
1041
1045
  import { closeSync, existsSync as existsSync2, lstatSync, mkdirSync as mkdirSync2, openSync, readdirSync, rmSync, symlinkSync, unlinkSync, writeSync } from "node:fs";
1042
- import { basename as basename2, join as join4, resolve } from "node:path";
1046
+ import { basename as basename3, join as join6, resolve as resolve4 } from "node:path";
1043
1047
 
1044
1048
  // src/docker/run.ts
1045
1049
  import { spawn } from "node:child_process";
1046
1050
  import { createWriteStream, mkdirSync } from "node:fs";
1047
- import { open, readdir, stat } from "node:fs/promises";
1048
- import { join as join2 } from "node:path";
1051
+ import { open, readdir as readdir2, stat } from "node:fs/promises";
1052
+ import { join as join4 } from "node:path";
1049
1053
  import { setTimeout as sleep } from "node:timers/promises";
1050
1054
  import { TextDecoder } from "node:util";
1051
1055
 
1052
1056
  // src/docker/spec.ts
1053
1057
  import { existsSync, realpathSync } from "node:fs";
1054
- import { homedir, hostname } from "node:os";
1055
- import { basename, join } from "node:path";
1056
- var CONTAINER_RUNTIME_DIR = "/tmp/xdg";
1057
- var CONTAINER_LOG_DIR = "/logs";
1058
- var X11_SOCKET_DIR = "/tmp/.X11-unix";
1059
- var CONTAINER_XAUTHORITY = "/tmp/xauth";
1060
- var CONTAINER_XDG_DIR = "/xdg";
1061
- var RUNTIME_DIR_SIZE = "64m";
1062
- var HOME_SIZE = "64m";
1063
- var MASK_SIZE = "1m";
1064
- function refuseProtonHeaded(game, mode, image) {
1065
- if (image?.launcher !== "proton" || mode !== "headed")
1066
- return;
1067
- throw new GamecrateError(`${game}: a proton image only runs offscreen`, Exit.Config, 'relaunch with --mode headless, or use a variant whose gamecrate.launcher is "direct"');
1058
+ import { homedir as homedir2, hostname } from "node:os";
1059
+ import { basename as basename2, isAbsolute as isAbsolute2, join as join3, resolve as resolve3 } from "node:path";
1060
+
1061
+ // src/config/load.ts
1062
+ import { z as z2 } from "zod";
1063
+ import { access, readdir, readFile as readFile2 } from "node:fs/promises";
1064
+ import { homedir } from "node:os";
1065
+ import { basename, dirname as dirname2, join as join2, resolve as resolve2 } from "node:path";
1066
+
1067
+ // src/plugin.ts
1068
+ import { readFileSync, statSync } from "node:fs";
1069
+ import { dirname, isAbsolute, join, resolve } from "node:path";
1070
+ import { pathToFileURL } from "node:url";
1071
+ import { exports as exportsField, legacy } from "resolve.exports";
1072
+ var PLUGIN_API_VERSION = 2;
1073
+ var REQUIRED_FUNCTIONS = [
1074
+ "parseManifest",
1075
+ "renderModsConfig",
1076
+ "mergePrefs",
1077
+ "parseVersion"
1078
+ ];
1079
+ function fail(spec, message, detail) {
1080
+ throw new GamecrateError(`plugin "${spec}": ${message}`, Exit.Config, detail);
1068
1081
  }
1069
- function buildRunSpec(plan, modMounts, identity, image) {
1070
- const { gameConfig: game, settings } = plan;
1071
- const headed = plan.mode === "headed";
1072
- const mounts = [];
1073
- const env = {
1074
- HOME: identity.home,
1075
- USER: identity.user,
1076
- LOGNAME: identity.user
1077
- };
1078
- addGameFiles(mounts, plan);
1079
- addStage(mounts, plan, modMounts);
1080
- const proton = image?.launcher === "proton";
1081
- const executable = image?.executable ?? game.executable;
1082
- refuseProtonHeaded(plan.game, plan.mode, image);
1083
- const command = proton ? ["run-headless-windows", winPath(join(game.gameFiles.container, basename(executable)))] : headed ? [executable] : [
1084
- "xvfb-run",
1085
- "-a",
1086
- `--server-args=-screen 0 ${settings.width}x${settings.height}x24`,
1087
- executable
1088
- ];
1089
- if (proton) {
1090
- Object.assign(env, {
1091
- SCREEN: `${settings.width}x${settings.height}x24`,
1092
- DESKTOP: `${settings.width}x${settings.height}`,
1093
- STEAM_COMPAT_DATA_PATH: `${CONTAINER_XDG_DIR}/proton`
1094
- });
1095
- }
1096
- if (game.dataDir.mode === "arg") {
1097
- const arg = validateDataDirArg(game.dataDir);
1098
- const eq = arg.indexOf("=");
1099
- command.push(proton ? `${arg.slice(0, eq + 1)}${winPath(arg.slice(eq + 1))}` : arg);
1100
- } else
1101
- Object.assign(env, game.dataDir.env);
1102
- if (game.logFile.mode === "arg") {
1103
- mounts.push({ type: "bind", source: hostPath(plan.runDirHost), target: CONTAINER_LOG_DIR });
1104
- const log = `${CONTAINER_LOG_DIR}/Player.log`;
1105
- command.push(game.logFile.arg, proton ? winPath(log) : log);
1106
- }
1107
- addScratch(mounts, env, plan, identity);
1108
- if (headed)
1109
- addSession(mounts, env, plan);
1110
- const deviceCgroupRules = [];
1111
- if (settings.input) {
1112
- mounts.push({ type: "bind", source: "/dev/input", target: "/dev/input", readonly: true });
1113
- deviceCgroupRules.push("c 13:* rmw");
1082
+ function entryOf(dir) {
1083
+ let manifest;
1084
+ try {
1085
+ manifest = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
1086
+ } catch {
1087
+ return resolve(dir, "index.js");
1114
1088
  }
1115
- const devices = [];
1116
- if (settings.gpu)
1117
- devices.push("nvidia.com/gpu=all");
1118
- Object.assign(env, glEnv(settings.gpu));
1119
- command.push(...settings.gameArgs ?? []);
1120
- return {
1121
- image: game.image.ref,
1122
- name: containerName(plan),
1123
- labels: {
1124
- "gamecrate.game": plan.game,
1125
- "gamecrate.profile": plan.profile,
1126
- ...plan.instance === undefined ? {} : { "gamecrate.instance": plan.instance }
1127
- },
1128
- identity,
1129
- env,
1130
- mounts,
1131
- devices,
1132
- deviceCgroupRules,
1133
- network: settings.network,
1134
- memory: settings.memory,
1135
- memorySwap: settings.memory,
1136
- cpus: settings.cpus,
1137
- pidsLimit: settings.pidsLimit,
1138
- ulimits: ["core=0"],
1139
- workdir: game.gameFiles.container,
1140
- ...headed && settings.display === "x11" ? { hostname: hostname() } : {},
1141
- command,
1142
- extraArgs: [...settings.dockerArgs ?? []]
1143
- };
1089
+ let entry;
1090
+ try {
1091
+ entry = exportsField(manifest, ".", { conditions: ["bun"] })?.[0];
1092
+ } catch {}
1093
+ entry ??= legacy(manifest, { fields: ["module", "main"] });
1094
+ return resolve(dir, entry ?? "index.js");
1144
1095
  }
1145
- function addGameFiles(mounts, plan) {
1146
- const { gameFiles } = plan.gameConfig;
1147
- if (gameFiles.source !== "mount")
1148
- return;
1149
- if (!gameFiles.host) {
1150
- throw new GamecrateError(`gameFiles.source is "mount" but no host path is set for ${plan.game}`, Exit.Config);
1096
+ function packageDir(spec, from) {
1097
+ let dir = resolve(from);
1098
+ for (;; ) {
1099
+ const candidate = join(dir, "node_modules", spec);
1100
+ if (statSync(join(candidate, "package.json"), { throwIfNoEntry: false })?.isFile())
1101
+ return candidate;
1102
+ const parent = dirname(dir);
1103
+ if (parent === dir)
1104
+ return null;
1105
+ dir = parent;
1151
1106
  }
1152
- mounts.push({ type: "bind", source: hostPath(gameFiles.host), target: gameFiles.container, readonly: true });
1153
1107
  }
1154
- function addStage(mounts, plan, modMounts) {
1155
- const game = plan.gameConfig;
1156
- mounts.push({ type: "bind", source: hostPath(plan.stageDirHost), target: game.modsDir.container, readonly: true });
1157
- for (const mount of modMounts) {
1158
- mounts.push(mount.type === "bind" ? { ...mount, readonly: true } : mount);
1108
+ function locate(spec, from) {
1109
+ const expanded = expandHome(spec);
1110
+ let target;
1111
+ if (expanded.startsWith(".") || isAbsolute(expanded)) {
1112
+ target = resolve(from, expanded);
1113
+ } else {
1114
+ target = packageDir(expanded, from);
1115
+ if (target === null) {
1116
+ fail(spec, `cannot be resolved from ${from}`, "install it, or give a path starting with ./");
1117
+ }
1159
1118
  }
1160
- mounts.push({ type: "bind", source: hostPath(plan.dataDirHost), target: game.dataDir.container });
1119
+ return statSync(target, { throwIfNoEntry: false })?.isDirectory() ? entryOf(target) : target;
1161
1120
  }
1162
- function addScratch(mounts, env, plan, identity) {
1163
- const { uid, gid } = identity;
1164
- for (const target of plan.gameConfig.modsDir.mask ?? []) {
1165
- mounts.push({ type: "tmpfs", target, size: MASK_SIZE, uid, gid, mode: "755" });
1121
+ function check(spec, value) {
1122
+ if (typeof value !== "object" || value === null)
1123
+ fail(spec, "has no default export");
1124
+ const plugin = value;
1125
+ if (plugin.apiVersion !== PLUGIN_API_VERSION) {
1126
+ fail(spec, `speaks apiVersion ${String(plugin.apiVersion)}, this build speaks ${PLUGIN_API_VERSION}`);
1166
1127
  }
1167
- if (uid !== 0) {
1168
- mounts.push({ type: "tmpfs", target: identity.home, size: HOME_SIZE, uid, gid, mode: "700" });
1128
+ if (typeof plugin.game !== "string" || plugin.game === "")
1129
+ fail(spec, "declares no game name");
1130
+ const missing = REQUIRED_FUNCTIONS.filter((name) => typeof plugin[name] !== "function");
1131
+ if (missing.length > 0)
1132
+ fail(spec, `is missing ${missing.join(", ")}`);
1133
+ if (typeof plugin.defaults !== "object" || plugin.defaults === null) {
1134
+ fail(spec, "declares no defaults object");
1169
1135
  }
1170
- mounts.push({ type: "tmpfs", target: CONTAINER_RUNTIME_DIR, size: RUNTIME_DIR_SIZE, uid, gid, mode: "700" });
1171
- env.XDG_RUNTIME_DIR = CONTAINER_RUNTIME_DIR;
1172
- mounts.push({ type: "bind", source: hostPath(plan.configDirHost), target: CONTAINER_XDG_DIR });
1173
- env.XDG_CONFIG_HOME = `${CONTAINER_XDG_DIR}/config`;
1174
- env.XDG_CACHE_HOME = `${CONTAINER_XDG_DIR}/cache`;
1175
- env.XDG_DATA_HOME ??= `${CONTAINER_XDG_DIR}/data`;
1176
- }
1177
- function addSession(mounts, env, plan) {
1178
- const { settings } = plan;
1179
- if (settings.display === "x11")
1180
- addX11(mounts, env);
1181
- else
1182
- addWayland(mounts, env);
1183
- if (!settings.audio)
1184
- return;
1185
- for (const socket of audioSockets()) {
1186
- mounts.push({ type: "bind", source: socket.source, target: `${CONTAINER_RUNTIME_DIR}/${socket.name}` });
1136
+ if (typeof plugin.windowedPrefs !== "object" || plugin.windowedPrefs === null) {
1137
+ fail(spec, "declares no windowedPrefs object");
1187
1138
  }
1188
- env.PULSE_SERVER = `unix:${CONTAINER_RUNTIME_DIR}/pulse/native`;
1189
- }
1190
- function addX11(mounts, env) {
1191
- const x11 = x11Session();
1192
- if (!x11)
1193
- return;
1194
- mounts.push({ type: "bind", source: X11_SOCKET_DIR, target: X11_SOCKET_DIR });
1195
- env.DISPLAY = x11.display;
1196
- env.XDG_SESSION_TYPE = "x11";
1197
- env.SDL_VIDEODRIVER = "x11";
1198
- env.QT_QPA_PLATFORM = "xcb";
1199
- if (!x11.xauthority)
1200
- return;
1201
- mounts.push({ type: "bind", source: x11.xauthority, target: CONTAINER_XAUTHORITY, readonly: true });
1202
- env.XAUTHORITY = CONTAINER_XAUTHORITY;
1203
- }
1204
- function addWayland(mounts, env) {
1205
- const wayland = waylandSocket();
1206
- if (!wayland)
1207
- return;
1208
- mounts.push({ type: "bind", source: wayland.source, target: `${CONTAINER_RUNTIME_DIR}/${wayland.name}` });
1209
- env.WAYLAND_DISPLAY = wayland.name;
1210
- env.XDG_SESSION_TYPE = "wayland";
1211
- env.SDL_VIDEODRIVER = "wayland";
1212
- env.QT_QPA_PLATFORM = "wayland";
1139
+ return plugin;
1213
1140
  }
1214
- function containerName(plan) {
1215
- const base = `gamecrate-${plan.game}-${plan.profile}`;
1216
- return plan.instance === undefined ? base : `${base}-${plan.instance}`;
1141
+ async function loadPlugins(specs, configFile) {
1142
+ const from = dirname(configFile);
1143
+ const out = new Map;
1144
+ for (const spec of specs) {
1145
+ const target = locate(spec, from);
1146
+ let module;
1147
+ try {
1148
+ module = await import(pathToFileURL(target).href);
1149
+ } catch (error) {
1150
+ fail(spec, `failed to load ${target}`, error instanceof Error ? error.message : String(error));
1151
+ }
1152
+ const plugin = check(spec, module.default);
1153
+ if (out.has(plugin.game))
1154
+ fail(spec, `also claims the game "${plugin.game}"`);
1155
+ out.set(plugin.game, plugin);
1156
+ }
1157
+ return out;
1217
1158
  }
1218
- function windowTitle(plan) {
1219
- const base = `${plan.game} ${plan.profile}`;
1220
- return plan.instance === undefined ? base : `${base} / ${plan.instance}`;
1159
+ function requirePlugin(plugins, game) {
1160
+ const plugin = plugins.get(game);
1161
+ if (plugin === undefined) {
1162
+ throw new GamecrateError(`no plugin provides the game "${game}"`, Exit.Config, `loaded plugins: ${[...plugins.keys()].join(", ") || "(none)"}`);
1163
+ }
1164
+ return plugin;
1221
1165
  }
1222
- function toDockerArgs(spec) {
1223
- const args = ["run", "--rm", "--init", "--name", spec.name];
1224
- if (spec.hostname !== undefined)
1225
- args.push("--hostname", spec.hostname);
1226
- for (const [key, value] of Object.entries(spec.labels))
1227
- args.push("--label", `${key}=${value}`);
1228
- args.push("--user", `${spec.identity.uid}:${spec.identity.gid}`);
1229
- for (const [key, value] of Object.entries(spec.env))
1230
- args.push("--env", `${key}=${value}`);
1231
- for (const mount of spec.mounts)
1232
- args.push(...mountArgs(mount));
1233
- for (const device of spec.devices)
1234
- args.push("--device", device);
1235
- for (const rule of spec.deviceCgroupRules)
1236
- args.push("--device-cgroup-rule", rule);
1237
- for (const ulimit of spec.ulimits)
1238
- args.push("--ulimit", ulimit);
1239
- args.push("--network", spec.network, "--memory", spec.memory, "--memory-swap", spec.memorySwap, "--cpus", String(spec.cpus), "--pids-limit", String(spec.pidsLimit), "--workdir", spec.workdir, ...spec.extraArgs, "--pull=never");
1240
- const [entrypoint, ...rest] = spec.command;
1241
- if (entrypoint !== undefined)
1242
- args.push("--entrypoint", entrypoint);
1243
- args.push(spec.image, ...rest);
1244
- return args;
1245
- }
1246
- function mountArgs(mount) {
1247
- if (mount.type === "tmpfs") {
1248
- const opts = ["rw"];
1249
- if (mount.uid !== undefined)
1250
- opts.push(`uid=${mount.uid}`);
1251
- if (mount.gid !== undefined)
1252
- opts.push(`gid=${mount.gid}`);
1253
- if (mount.mode)
1254
- opts.push(`mode=${mount.mode}`);
1255
- opts.push(`size=${mount.size ?? RUNTIME_DIR_SIZE}`);
1256
- return ["--tmpfs", `${mount.target}:${opts.join(",")}`];
1257
- }
1258
- if (!mount.source) {
1259
- throw new GamecrateError(`bind mount at ${mount.target} has no source`, Exit.Config);
1166
+
1167
+ // src/config/builtin.ts
1168
+ var DEFAULT_DATA_ROOT = "~/.local/share/gamecrate";
1169
+ var DEFAULT_SETTINGS = {
1170
+ width: 1920,
1171
+ height: 1080,
1172
+ devMode: true,
1173
+ runInBackground: true,
1174
+ resetModsConfigOnCrash: false,
1175
+ gpu: true,
1176
+ audio: true,
1177
+ input: false,
1178
+ network: "bridge",
1179
+ display: "x11",
1180
+ memory: "8g",
1181
+ cpus: 6,
1182
+ pidsLimit: 1024
1183
+ };
1184
+
1185
+ // src/config/read.ts
1186
+ import { readFile } from "node:fs/promises";
1187
+ import { extname } from "node:path";
1188
+ import { parseTree } from "jsonc-parser";
1189
+ import { isMap, parse as parseYaml, parseDocument } from "yaml";
1190
+
1191
+ // src/config/jsonc.ts
1192
+ import { parse, printParseErrorCode } from "jsonc-parser";
1193
+ function parseJsonc(text) {
1194
+ const errors = [];
1195
+ const value = parse(text, errors, { allowTrailingComma: true, allowEmptyContent: false });
1196
+ const first = errors[0];
1197
+ if (first !== undefined) {
1198
+ throw new GamecrateError("config is not valid JSON", Exit.Config, `${printParseErrorCode(first.error)} at offset ${first.offset}`);
1260
1199
  }
1261
- const fields = [`type=bind`, `src=${mount.source}`, `dst=${mount.target}`];
1262
- if (mount.readonly)
1263
- fields.push("readonly");
1264
- return ["--mount", fields.map(csvField).join(",")];
1265
- }
1266
- function csvField(field) {
1267
- if (!field.includes(",") && !field.includes('"'))
1268
- return field;
1269
- return `"${field.replaceAll('"', '""')}"`;
1200
+ return value;
1270
1201
  }
1271
- function winPath(unix) {
1272
- return `Z:${unix.replaceAll("/", "\\")}`;
1202
+
1203
+ // src/config/read.ts
1204
+ var CONFIG_SUFFIXES = [".yml", ".yaml", ".json", ".jsonc"];
1205
+ function isYaml(path) {
1206
+ const suffix = extname(path).toLowerCase();
1207
+ if (suffix === ".yml" || suffix === ".yaml")
1208
+ return true;
1209
+ if (suffix === ".json" || suffix === ".jsonc")
1210
+ return false;
1211
+ throw new GamecrateError(`config is not a format gamecrate reads: ${path}`, Exit.Config, `use one of ${CONFIG_SUFFIXES.join(", ")}`);
1273
1212
  }
1274
- function validateDataDirArg(dataDir) {
1275
- if (dataDir.container.includes("=")) {
1276
- throw new GamecrateError(`container data path contains "=": ${dataDir.container}`, Exit.Config, "RimWorld silently ignores -savedatafolder when the argv element does not split into exactly two parts, and the save is lost with --rm.");
1277
- }
1278
- const parts = dataDir.arg.split("=");
1279
- if (parts.length !== 2) {
1280
- throw new GamecrateError(`dataDir.arg must contain exactly one "=": ${dataDir.arg}`, Exit.Config);
1281
- }
1282
- if (trimSlash(parts[1] ?? "") !== trimSlash(dataDir.container)) {
1283
- throw new GamecrateError(`dataDir.arg points at ${parts[1]} but the mount target is ${dataDir.container}`, Exit.Config, "The engine would write to a path that is not the mounted data directory.");
1213
+ function readConfigText(text, path) {
1214
+ if (!isYaml(path)) {
1215
+ try {
1216
+ return parseJsonc(text);
1217
+ } catch (error) {
1218
+ if (error instanceof GamecrateError) {
1219
+ throw new GamecrateError(`${error.message}: ${path}`, error.code, error.detail);
1220
+ }
1221
+ throw error;
1222
+ }
1284
1223
  }
1285
- return dataDir.arg;
1286
- }
1287
- function trimSlash(path) {
1288
- let end = path.length;
1289
- while (end > 1 && path[end - 1] === "/")
1290
- end--;
1291
- return path.slice(0, end);
1292
- }
1293
- function glEnv(gpu) {
1294
- if (!gpu)
1295
- return { LIBGL_ALWAYS_SOFTWARE: "1", GALLIUM_DRIVER: "llvmpipe" };
1296
- const env = { LIBGL_ALWAYS_SOFTWARE: "0", GALLIUM_DRIVER: "" };
1297
- if (hasNvidia()) {
1298
- env.__GLX_VENDOR_LIBRARY_NAME = "nvidia";
1299
- env.__NV_PRIME_RENDER_OFFLOAD = "1";
1224
+ try {
1225
+ return parseYaml(text);
1226
+ } catch (error) {
1227
+ throw new GamecrateError(`config is invalid: ${path}`, Exit.Config, error.message);
1300
1228
  }
1301
- return env;
1302
- }
1303
- function hasNvidia() {
1304
- return existsSync("/dev/nvidiactl") || existsSync("/etc/cdi/nvidia.yaml") || existsSync("/usr/share/vulkan/icd.d/nvidia_icd.json");
1305
- }
1306
- function x11Session() {
1307
- const display = process.env.DISPLAY;
1308
- if (!display)
1309
- return null;
1310
- const cookie = process.env.XAUTHORITY ?? join(homedir(), ".Xauthority");
1311
- return { display, xauthority: existsSync(cookie) ? cookie : null };
1312
- }
1313
- function waylandSocket() {
1314
- const display = process.env.WAYLAND_DISPLAY;
1315
- const runtime = process.env.XDG_RUNTIME_DIR;
1316
- if (!display)
1317
- return null;
1318
- let source = null;
1319
- if (display.startsWith("/"))
1320
- source = display;
1321
- else if (runtime)
1322
- source = join(runtime, display);
1323
- if (!source || !existsSync(source))
1324
- return null;
1325
- return { source, name: basename(source) };
1326
1229
  }
1327
- function audioSockets() {
1328
- const runtime = process.env.XDG_RUNTIME_DIR;
1329
- if (!runtime)
1330
- return [];
1331
- const found = [];
1332
- for (const name of ["pipewire-0", "pulse/native"]) {
1333
- const source = join(runtime, name);
1334
- if (existsSync(source))
1335
- found.push({ source, name });
1230
+ async function readConfigFile(path) {
1231
+ let text;
1232
+ try {
1233
+ text = await readFile(path, "utf8");
1234
+ } catch (error) {
1235
+ if (error.code === "ENOENT")
1236
+ return;
1237
+ throw error;
1336
1238
  }
1337
- return found;
1239
+ return readConfigText(text, path);
1338
1240
  }
1339
- function hostPath(path) {
1340
- try {
1341
- return realpathSync(path);
1342
- } catch {
1343
- return path;
1241
+ function orderedKeys(text, path, key) {
1242
+ if (isYaml(path)) {
1243
+ const node = parseDocument(text).get(key, true);
1244
+ if (!isMap(node))
1245
+ return [];
1246
+ return node.items.map((item) => String(item.key.value ?? item.key));
1344
1247
  }
1248
+ const root = parseTree(text);
1249
+ const holder = root?.children?.find((child) => child.children?.[0]?.value === key);
1250
+ const value = holder?.children?.[1];
1251
+ if (value?.type !== "object")
1252
+ return [];
1253
+ return (value.children ?? []).map((prop) => String(prop.children?.[0]?.value));
1345
1254
  }
1346
1255
 
1347
- // src/docker/run.ts
1348
- function spawnArgv(argv, stdio, detached = false) {
1349
- return spawn(argv[0], argv.slice(1), { stdio, detached });
1350
- }
1351
- function exited(proc) {
1352
- return new Promise((resolve, reject) => {
1353
- proc.once("error", reject);
1354
- proc.once("close", (code) => resolve(code ?? 1));
1256
+ // src/config/validate.ts
1257
+ import { z } from "zod";
1258
+ var MODES2 = ["headed", "headless", "screenshot"];
1259
+ var HINTS = "\x00gamecrate/hints:";
1260
+ function obj(shape) {
1261
+ const known = Object.keys(shape);
1262
+ return z.strictObject(shape, {
1263
+ error: (issue) => issue.code === "unrecognized_keys" ? HINTS + JSON.stringify(issue.keys.map((key) => suggest(key, known) ?? null)) : "expected an object"
1355
1264
  });
1356
1265
  }
1357
- async function collect2(stream) {
1358
- const chunks = [];
1359
- for await (const chunk of stream)
1360
- chunks.push(chunk);
1361
- return Buffer.concat(chunks).toString("utf8");
1362
- }
1363
- async function capture(argv) {
1266
+ function hintsFor(message) {
1267
+ if (!message.startsWith(HINTS))
1268
+ return [];
1364
1269
  try {
1365
- const proc = spawnArgv(argv, ["ignore", "pipe", "pipe"]);
1366
- const [stdout, stderr, code] = await Promise.all([
1367
- collect2(proc.stdout),
1368
- collect2(proc.stderr),
1369
- exited(proc)
1370
- ]);
1371
- return { code, stdout, stderr };
1372
- } catch (error) {
1373
- return { code: 127, stdout: "", stderr: error instanceof Error ? error.message : String(error) };
1270
+ return JSON.parse(message.slice(HINTS.length));
1271
+ } catch {
1272
+ return [];
1374
1273
  }
1375
1274
  }
1376
- async function captureLive(argv, env) {
1377
- const proc = spawn(argv[0], argv.slice(1), { stdio: ["ignore", "pipe", "pipe"], env });
1378
- const chunks = [];
1379
- const keep = async (stream) => {
1380
- for await (const chunk of stream) {
1381
- process.stderr.write(chunk);
1382
- chunks.push(chunk);
1383
- }
1384
- };
1385
- const [, , code] = await Promise.all([keep(proc.stdout), keep(proc.stderr), exited(proc)]);
1386
- return { code, text: Buffer.concat(chunks).toString("utf8") };
1387
- }
1388
- var STDOUT_LOG = "stdout.log";
1389
- var MARKER_POLL_MS = 200;
1390
- async function runContainer(spec, opts) {
1391
- const stopTimeout = opts.stopTimeoutSeconds ?? 10;
1392
- mkdirSync(opts.logDir, { recursive: true });
1393
- const sink = createWriteStream(join2(opts.logDir, STDOUT_LOG));
1394
- const proc = spawnArgv(["docker", ...toDockerArgs(spec)], ["inherit", "pipe", "pipe"]);
1395
- let interrupted = false;
1396
- const onSignal = () => {
1397
- if (interrupted)
1275
+ function requiredWhen(key, when) {
1276
+ return (ctx) => {
1277
+ if (!when(ctx.value) || ctx.value[key] !== undefined)
1398
1278
  return;
1399
- interrupted = true;
1400
- stopContainer(spec.name, stopTimeout);
1279
+ ctx.issues.push({ code: "custom", message: `missing required key "${key}"`, path: [key], input: ctx.value });
1401
1280
  };
1402
- process.on("SIGINT", onSignal);
1403
- process.on("SIGTERM", onSignal);
1404
- const code = exited(proc);
1405
- try {
1406
- await Promise.all([
1407
- tee(proc.stdout, sink, process.stdout),
1408
- tee(proc.stderr, sink, process.stderr)
1409
- ]);
1410
- const status = await code;
1411
- return interrupted ? Exit.Interrupted : status;
1412
- } finally {
1413
- process.off("SIGINT", onSignal);
1414
- process.off("SIGTERM", onSignal);
1415
- await new Promise((resolve) => sink.end(resolve));
1416
- }
1417
- }
1418
- var STOP_TIMEOUT_SECONDS = 10;
1419
- async function stopContainer(name, timeoutSeconds) {
1420
- const proc = spawnArgv(["docker", "stop", "--timeout", String(timeoutSeconds), name], "ignore");
1421
- await exited(proc).catch(() => {});
1422
1281
  }
1423
- async function waitForMarker(sources, marker, timeoutSeconds) {
1424
- const deadline = Date.now() + timeoutSeconds * 1000;
1425
- const carry = Math.max(marker.length - 1, 0);
1426
- const seen = new Map;
1427
- const startedAt = Date.now();
1428
- while (true) {
1429
- for (const path of await expandSources(sources)) {
1430
- let state = seen.get(path);
1431
- if (state === undefined) {
1432
- state = { offset: await staleSize(path, startedAt), tail: "", decoder: new TextDecoder };
1433
- seen.set(path, state);
1434
- }
1435
- if (await scan(path, state, marker, carry))
1436
- return true;
1437
- }
1438
- if (Date.now() >= deadline)
1439
- return false;
1440
- await sleep(Math.min(MARKER_POLL_MS, Math.max(deadline - Date.now(), 0)));
1441
- }
1442
- }
1443
- async function staleSize(path, startedAt) {
1444
- return stat(path).then((info) => info.mtimeMs < startedAt ? info.size : 0, () => 0);
1445
- }
1446
- async function scan(path, state, marker, carry) {
1447
- const handle = await open(path, "r").catch(() => null);
1448
- if (handle === null)
1449
- return false;
1450
- try {
1451
- const { size } = await handle.stat();
1452
- if (size < state.offset) {
1453
- state.offset = 0;
1454
- state.tail = "";
1455
- state.decoder = new TextDecoder;
1456
- }
1457
- if (size <= state.offset)
1458
- return false;
1459
- const buffer = Buffer.alloc(size - state.offset);
1460
- const { bytesRead } = await handle.read(buffer, 0, buffer.length, state.offset);
1461
- state.offset += bytesRead;
1462
- const text = state.tail + state.decoder.decode(buffer.subarray(0, bytesRead), { stream: true });
1463
- if (text.includes(marker))
1464
- return true;
1465
- state.tail = carry > 0 ? text.slice(-carry) : "";
1466
- return false;
1467
- } catch {
1468
- return false;
1469
- } finally {
1470
- await handle.close().catch(() => {});
1471
- }
1282
+ var str = z.string({ error: "expected a string" });
1283
+ var num = z.number({ error: "expected a number" });
1284
+ var bool = z.boolean({ error: "expected a boolean" });
1285
+ var strArray = z.array(z.string({ error: "expected an array of strings" }), {
1286
+ error: "expected an array of strings"
1287
+ });
1288
+ var strMap = z.record(z.string(), z.string({ error: "expected an object of string values" }), {
1289
+ error: "expected an object of string values"
1290
+ });
1291
+ function oneOf(values) {
1292
+ return z.enum(values, { error: `expected one of ${values.join(", ")}` });
1472
1293
  }
1473
- async function expandSources(sources) {
1474
- const out = [];
1475
- for (const source of sources) {
1476
- const info = await stat(source).catch(() => null);
1477
- if (info === null) {
1478
- out.push(source);
1479
- continue;
1480
- }
1481
- if (!info.isDirectory()) {
1482
- out.push(source);
1483
- continue;
1484
- }
1485
- const entries = await readdir(source).catch(() => []);
1486
- for (const entry of entries) {
1487
- if (entry.toLowerCase().endsWith(".log"))
1488
- out.push(join2(source, entry));
1489
- }
1294
+ var modeName = z.unknown().check((ctx) => {
1295
+ const value = ctx.value;
1296
+ if (typeof value === "string" && MODES2.includes(value))
1297
+ return;
1298
+ const hint = typeof value === "string" ? suggest(value, MODES2) : undefined;
1299
+ ctx.issues.push({
1300
+ code: "custom",
1301
+ message: `expected one of ${MODES2.join(", ")}`,
1302
+ input: value,
1303
+ ...hint === undefined ? {} : { params: { suggestion: `did you mean "${hint}"?` } }
1304
+ });
1305
+ });
1306
+ var settings = obj({
1307
+ width: num.optional(),
1308
+ height: num.optional(),
1309
+ devMode: bool.optional(),
1310
+ runInBackground: bool.optional(),
1311
+ resetModsConfigOnCrash: bool.optional(),
1312
+ gpu: bool.optional(),
1313
+ audio: bool.optional(),
1314
+ input: bool.optional(),
1315
+ network: oneOf(["none", "bridge", "host"]).optional(),
1316
+ display: oneOf(["x11", "wayland"]).optional(),
1317
+ memory: str.optional(),
1318
+ cpus: num.optional(),
1319
+ pidsLimit: num.optional(),
1320
+ prefsExtra: strMap.optional(),
1321
+ gameArgs: strArray.optional(),
1322
+ dockerArgs: strArray.optional()
1323
+ });
1324
+ var dynamicModEntry = obj({
1325
+ match: str,
1326
+ first: strArray.optional(),
1327
+ sort: oneOf(["alpha", "none"]).optional(),
1328
+ minMatches: num.optional()
1329
+ });
1330
+ var objectModEntry = obj({
1331
+ id: str,
1332
+ workshop: num.optional(),
1333
+ path: str.optional(),
1334
+ optional: bool.optional()
1335
+ });
1336
+ var modEntry = z.unknown().check((ctx) => {
1337
+ const value = ctx.value;
1338
+ if (typeof value === "string") {
1339
+ if (value.trim() === "")
1340
+ ctx.issues.push({ code: "custom", message: "mod entry is empty", input: value });
1341
+ return;
1490
1342
  }
1491
- return out;
1492
- }
1493
- async function tee(stream, sink, mirror) {
1494
- for await (const chunk of stream) {
1495
- mirror.write(chunk);
1496
- sink.write(chunk);
1343
+ if (!isObj(value)) {
1344
+ ctx.issues.push({ code: "custom", message: "expected a packageId string or an object", input: value });
1345
+ return;
1497
1346
  }
1498
- }
1499
-
1500
- // src/mods/staleness.ts
1501
- import { readdir as readdir2, stat as stat2 } from "node:fs/promises";
1502
- import { join as join3, relative } from "node:path";
1503
- var SKIP_DIRS = new Set([".git", ".retired", ".vs", "bin", "node_modules", "obj"]);
1504
- var ENTRY_LIMIT = 20000;
1505
- async function scanBuildTimes(dir) {
1506
- const state = { root: dir, times: { sourceTimes: [] }, budget: ENTRY_LIMIT };
1507
- await walk(state, dir, false);
1508
- return state.times;
1509
- }
1510
- async function walk(state, current, inAssemblies) {
1511
- let entries;
1512
- try {
1513
- entries = await readdir2(current, { withFileTypes: true });
1514
- } catch {
1347
+ const schema = value["match"] !== undefined ? dynamicModEntry : objectModEntry;
1348
+ const result = schema.safeParse(value);
1349
+ if (result.success)
1515
1350
  return;
1351
+ for (const issue of result.error.issues)
1352
+ ctx.issues.push({ ...issue, input: value });
1353
+ });
1354
+ var profile = obj({
1355
+ mods: z.array(modEntry, { error: "expected an array" }).optional(),
1356
+ extends: str.optional(),
1357
+ exclude: strArray.optional(),
1358
+ includeBase: bool.optional(),
1359
+ autoDependencies: bool.optional(),
1360
+ settings: settings.optional(),
1361
+ instances: z.record(z.string(), obj({ worktree: str.optional(), settings: settings.optional() }), {
1362
+ error: "expected an object"
1363
+ }).optional(),
1364
+ alias: str.optional(),
1365
+ aliases: strArray.optional(),
1366
+ description: str.optional(),
1367
+ gameVersion: str.optional(),
1368
+ image: str.optional(),
1369
+ windowTitle: str.optional(),
1370
+ windowIcon: str.optional(),
1371
+ detach: bool.optional(),
1372
+ replace: bool.optional(),
1373
+ build: oneOf(["auto", "always", "never"]).optional()
1374
+ }).check((ctx) => {
1375
+ const v = ctx.value;
1376
+ if (v.alias !== undefined && (v.extends !== undefined || v.mods !== undefined)) {
1377
+ ctx.issues.push({
1378
+ code: "custom",
1379
+ message: 'an alias profile cannot also declare "mods" or "extends"',
1380
+ input: v
1381
+ });
1516
1382
  }
1517
- for (const entry of entries) {
1518
- if (state.budget-- <= 0)
1519
- return;
1520
- const path = join3(current, entry.name);
1521
- if (entry.isDirectory()) {
1522
- if (!SKIP_DIRS.has(entry.name.toLowerCase())) {
1523
- await walk(state, path, inAssemblies || entry.name === "Assemblies");
1524
- }
1525
- continue;
1383
+ });
1384
+ var libraryEntry = obj({
1385
+ workshop: num.optional(),
1386
+ path: str.optional(),
1387
+ git: str.optional(),
1388
+ branch: str.optional(),
1389
+ tag: str.optional(),
1390
+ commit: str.optional(),
1391
+ subdir: str.optional()
1392
+ }).check((ctx) => {
1393
+ const v = ctx.value;
1394
+ const push = (message, path) => {
1395
+ ctx.issues.push({ code: "custom", message, input: v, ...path === undefined ? {} : { path } });
1396
+ };
1397
+ const sources = ["workshop", "path", "git"].filter((k) => v[k] !== undefined);
1398
+ if (sources.length === 0)
1399
+ push('library entry needs a "workshop" id, a "path", or a "git" url');
1400
+ if (sources.length > 1)
1401
+ push(`library entry takes only one of ${sources.join(", ")}`);
1402
+ const refs = ["branch", "tag", "commit"].filter((k) => v[k] !== undefined);
1403
+ if (refs.length > 1)
1404
+ push(`library entry takes only one of branch, tag or commit, got ${refs.join(", ")}`);
1405
+ if (v["git"] === undefined) {
1406
+ for (const key of [...refs, ...v["subdir"] === undefined ? [] : ["subdir"]]) {
1407
+ push(`"${key}" needs a "git" url`, [key]);
1526
1408
  }
1527
- if (entry.isFile())
1528
- await record(state, path, entry.name, inAssemblies);
1529
- }
1530
- }
1531
- async function record(state, path, name, inAssemblies) {
1532
- const lower = name.toLowerCase();
1533
- const isSource = lower.endsWith(".cs");
1534
- const isAssembly = inAssemblies && lower.endsWith(".dll");
1535
- if (!isSource && !isAssembly)
1536
- return;
1537
- let mtimeMs;
1538
- try {
1539
- mtimeMs = (await stat2(path)).mtimeMs;
1540
- } catch {
1541
- return;
1542
1409
  }
1543
- const { times } = state;
1544
- const found = { path: relative(state.root, path), mtimeMs };
1545
- if (isSource) {
1546
- times.sourceTimes.push(mtimeMs);
1547
- if (mtimeMs > (times.newestSource?.mtimeMs ?? -1))
1548
- times.newestSource = found;
1549
- } else if (mtimeMs > (times.newestAssembly?.mtimeMs ?? -1)) {
1550
- times.newestAssembly = found;
1410
+ const subdir = v["subdir"];
1411
+ if (typeof subdir === "string" && (subdir.startsWith("/") || subdir.split("/").includes(".."))) {
1412
+ push('"subdir" must be a relative path inside the repo, with no ".." segment', ["subdir"]);
1551
1413
  }
1414
+ });
1415
+ function repeats(entries, key) {
1416
+ const seen = new Set;
1417
+ const found = [];
1418
+ entries.forEach((entry, index) => {
1419
+ const name = entry?.[key];
1420
+ if (typeof name !== "string")
1421
+ return;
1422
+ if (seen.has(name))
1423
+ found.push({ index, name });
1424
+ else
1425
+ seen.add(name);
1426
+ });
1427
+ return found;
1552
1428
  }
1553
- var SKEW_MS = 1000;
1554
- function newerThan(source, assembly) {
1555
- return source - assembly > SKEW_MS;
1556
- }
1557
- function decideStale(times) {
1558
- const { newestSource, newestAssembly } = times;
1559
- if (newestSource === undefined)
1560
- return false;
1561
- return newestAssembly === undefined || newerThan(newestSource.mtimeMs, newestAssembly.mtimeMs);
1429
+ function describe(value) {
1430
+ return typeof value === "string" ? value : JSON.stringify(value);
1562
1431
  }
1563
- function staleReport(times) {
1564
- const { newestSource, newestAssembly } = times;
1565
- if (newestSource === undefined || newestAssembly === undefined)
1566
- return null;
1567
- if (!newerThan(newestSource.mtimeMs, newestAssembly.mtimeMs))
1568
- return null;
1569
- return {
1570
- newestSource: newestSource.path,
1571
- newestSourceMs: newestSource.mtimeMs,
1572
- assembly: newestAssembly.path,
1573
- assemblyMs: newestAssembly.mtimeMs,
1574
- newerCount: times.sourceTimes.filter((t) => newerThan(t, newestAssembly.mtimeMs)).length
1575
- };
1576
- }
1577
- var INDENT = " ".repeat("warning: ".length);
1578
- function staleWarning(packageId, report, now = Date.now()) {
1579
- const files = report.newerCount === 1 ? "1 source file" : `${report.newerCount} source files`;
1580
- return [
1581
- `${packageId} has ${files} newer than ${report.assembly}`,
1582
- `${INDENT}newest: ${report.newestSource} (${ago(report.newestSourceMs, now)})`,
1583
- `${INDENT}you are probably running a stale build`
1584
- ].join(`
1585
- `);
1586
- }
1587
- function duration(ms) {
1588
- const seconds = Math.max(0, Math.round(ms / 1000));
1589
- if (seconds < 60)
1590
- return `${seconds}s`;
1591
- if (seconds < 3600)
1592
- return `${Math.floor(seconds / 60)}m`;
1593
- if (seconds < 86400)
1594
- return `${Math.floor(seconds / 3600)}h`;
1595
- return `${Math.floor(seconds / 86400)}d`;
1596
- }
1597
- function ago(mtimeMs, now = Date.now()) {
1598
- return `${duration(now - mtimeMs)} ago`;
1599
- }
1600
-
1601
- // src/cli/output.ts
1602
- function status(message) {
1603
- process.stderr.write(line(message));
1604
- }
1605
- function warn(message) {
1606
- process.stderr.write(line(`warning: ${message}`));
1607
- }
1608
- function redirectOutput(path, keep = false) {
1609
- const fd = openSync(path, keep ? "a" : "w");
1610
- const stdout = process.stdout.write;
1611
- const stderr = process.stderr.write;
1612
- let open = true;
1613
- const write = (chunk, encodingOrCallback, callback) => {
1614
- append(fd, chunk);
1615
- const done = typeof encodingOrCallback === "function" ? encodingOrCallback : callback;
1616
- done?.();
1617
- return true;
1432
+ var TAG_COMPONENT = /^\w[\w.-]*$/;
1433
+ function steamBuildRules(ctx) {
1434
+ const push = (message, path, suggestion) => {
1435
+ ctx.issues.push({
1436
+ code: "custom",
1437
+ message,
1438
+ path,
1439
+ input: ctx.value,
1440
+ ...suggestion === undefined ? {} : { params: { suggestion } }
1441
+ });
1618
1442
  };
1619
- process.stdout.write = write;
1620
- process.stderr.write = write;
1621
- return {
1622
- close() {
1623
- if (!open)
1443
+ const branches = ctx.value["branches"];
1444
+ const variants = ctx.value["variants"];
1445
+ if (Array.isArray(variants) && variants.length === 0) {
1446
+ push("steamBuild.variants cannot be empty", ["variants"]);
1447
+ }
1448
+ if (Array.isArray(branches)) {
1449
+ if (branches.length === 0)
1450
+ push("steamBuild.branches cannot be empty", ["branches"]);
1451
+ for (const dup of repeats(branches, "name")) {
1452
+ push(`duplicate branch name "${dup.name}"`, ["branches", dup.index, "name"]);
1453
+ }
1454
+ branches.forEach((branch, index) => {
1455
+ const name = branch?.["name"];
1456
+ if (typeof name === "string" && !TAG_COMPONENT.test(name)) {
1457
+ push(`branch name "${name}" must match ${TAG_COMPONENT.source}`, ["branches", index, "name"]);
1458
+ }
1459
+ const tags = branch?.["tags"];
1460
+ if (!Array.isArray(tags))
1624
1461
  return;
1625
- open = false;
1626
- process.stdout.write = stdout;
1627
- process.stderr.write = stderr;
1628
- closeSync(fd);
1462
+ tags.forEach((tag, at) => {
1463
+ if (typeof tag !== "string" || TAG_COMPONENT.test(tag))
1464
+ return;
1465
+ push(`branch tag "${String(tag)}" must match ${TAG_COMPONENT.source}`, ["branches", index, "tags", at]);
1466
+ });
1467
+ });
1468
+ }
1469
+ if (!Array.isArray(variants))
1470
+ return;
1471
+ for (const dup of repeats(variants, "name")) {
1472
+ push(`duplicate variant name "${dup.name}"`, ["variants", dup.index, "name"]);
1473
+ }
1474
+ variants.forEach((variant, index) => {
1475
+ const v = variant;
1476
+ const base = v?.["base"];
1477
+ if (base !== "xvfb" && base !== "proton")
1478
+ return;
1479
+ const depot = v?.["depot"] ?? "linux";
1480
+ if (depot === "macos") {
1481
+ push("a macos depot cannot be runnable", ["variants", index, "base"], 'set base to "none"; no macos container runtime exists');
1482
+ return;
1629
1483
  }
1630
- };
1631
- }
1632
- async function forwardOutput(stream, target) {
1633
- for await (const chunk of stream)
1634
- target.write(chunk);
1635
- }
1636
- function line(message) {
1637
- return message.endsWith(`
1638
- `) ? message : `${message}
1639
- `;
1484
+ const wants = depot === "windows" ? "proton" : "xvfb";
1485
+ if (base === wants)
1486
+ return;
1487
+ push(`a ${describe(depot)} depot cannot run on the "${base}" base`, ["variants", index, "base"], depot === "windows" ? 'set base to "proton"; it is the only base with wine' : 'set base to "xvfb", or set depot to "windows" if the image should run under wine');
1488
+ });
1640
1489
  }
1641
- function reportProblems(problems) {
1642
- if (problems.length === 0) {
1643
- throw new GamecrateError("resolution failed with no reported detail", Exit.Resolution);
1490
+ var steamBuildSchema = obj({
1491
+ branches: z.array(obj({
1492
+ name: str,
1493
+ password: bool.optional(),
1494
+ tags: strArray.optional(),
1495
+ executable: z.record(str, str).optional()
1496
+ }), {
1497
+ error: "expected an array"
1498
+ }),
1499
+ variants: z.array(obj({
1500
+ name: str,
1501
+ depot: oneOf(["linux", "windows", "macos"]).optional(),
1502
+ base: oneOf(["xvfb", "proton", "none"]),
1503
+ include: strArray,
1504
+ executable: str.optional()
1505
+ }), { error: "expected an array" })
1506
+ }).check(steamBuildRules);
1507
+ var game = obj({
1508
+ gameFiles: obj({ source: oneOf(["mount", "image"]), host: str.optional(), container: str }).check(requiredWhen("host", (v) => v["source"] === "mount")),
1509
+ dataDir: obj({
1510
+ container: str,
1511
+ mode: oneOf(["arg", "env"]),
1512
+ arg: str.optional(),
1513
+ env: strMap.optional()
1514
+ }).check(requiredWhen("arg", (v) => v["mode"] === "arg"), requiredWhen("env", (v) => v["mode"] === "env")),
1515
+ modsDir: obj({ container: str, mask: strArray.optional() }),
1516
+ logFile: obj({ mode: oneOf(["arg", "copy-out"]), arg: str.optional(), from: str.optional() }).check(requiredWhen("arg", (v) => v["mode"] === "arg"), requiredWhen("from", (v) => v["mode"] === "copy-out")),
1517
+ image: obj({
1518
+ ref: str,
1519
+ acquire: oneOf(["pull", "build"]),
1520
+ context: str.optional(),
1521
+ updates: obj({ check: bool.optional(), everyHours: num.optional() }).optional()
1522
+ }).check(requiredWhen("context", (v) => v["acquire"] === "build")),
1523
+ executable: str,
1524
+ managed: strArray.optional(),
1525
+ steamAppId: num,
1526
+ workshopRoot: z.union([z.string(), z.null()], { error: "expected a string or null" }),
1527
+ scanRoots: z.array(obj({ path: str, maxDepth: num, exclude: strArray.optional() }), {
1528
+ error: "expected an array"
1529
+ }),
1530
+ manifest: obj({ file: str }),
1531
+ modsConfig: obj({ file: str }),
1532
+ prefs: obj({ file: str }),
1533
+ version: obj({ file: str }),
1534
+ steamBuild: steamBuildSchema,
1535
+ saveExtensions: strArray,
1536
+ core: str,
1537
+ dlc: strArray,
1538
+ preCore: strArray.optional(),
1539
+ base: strArray.optional(),
1540
+ library: z.record(z.string(), libraryEntry, { error: "expected an object" }).optional(),
1541
+ modes: z.array(modeName, { error: "expected a non-empty array" }).min(1, {
1542
+ error: "expected a non-empty array"
1543
+ }),
1544
+ aliases: strMap.optional(),
1545
+ settings: settings.optional(),
1546
+ ignoresWmDelete: bool.optional(),
1547
+ profiles: z.record(z.string(), profile, { error: "expected an object" })
1548
+ });
1549
+ var root = obj({
1550
+ plugins: strArray.optional(),
1551
+ dataRoot: str,
1552
+ defaults: obj({ settings: settings.optional() }).optional(),
1553
+ steamcmd: obj({ path: str.optional() }).optional(),
1554
+ games: z.unknown()
1555
+ });
1556
+ function validateConfig(cfg) {
1557
+ const problems = [];
1558
+ if (!isObj(cfg)) {
1559
+ problems.push({ where: "", message: "expected the config to be an object" });
1560
+ return { config: { dataRoot: "", games: {} }, problems };
1644
1561
  }
1645
- const groups = new Map;
1646
- for (const problem of problems) {
1647
- const group = groups.get(problem.where);
1648
- if (group)
1649
- group.push(problem);
1650
- else
1651
- groups.set(problem.where, [problem]);
1562
+ collect2(problems, "", root, cfg);
1563
+ const games = cfg["games"];
1564
+ if (!isObj(games)) {
1565
+ problems.push({ where: "/games", message: 'missing required key "games", or it is not an object' });
1566
+ return { config: cfg, problems };
1652
1567
  }
1653
- const out = [];
1654
- for (const [where, group] of groups) {
1655
- out.push(` ${where}`);
1656
- for (const problem of group) {
1657
- out.push(` ${problem.message}`);
1658
- if (problem.suggestion)
1659
- out.push(` did you mean ${problem.suggestion}?`);
1660
- }
1568
+ for (const [name, entry] of Object.entries(games)) {
1569
+ collect2(problems, `/games/${esc(name)}`, game, entry);
1661
1570
  }
1662
- throw new GamecrateError(`${problems.length} problem${problems.length === 1 ? "" : "s"}`, Exit.Resolution, out.join(`
1663
- `));
1664
- }
1665
- function planWarnings(plan) {
1666
- if (!plan.warnOnStale)
1667
- return plan.warnings;
1668
- const stale = plan.mods.filter((mod) => mod.staleReport !== undefined).map((mod) => staleWarning(mod.packageId, mod.staleReport));
1669
- return [...plan.warnings, ...stale];
1670
- }
1671
- function planPayload(plan) {
1672
- return {
1673
- game: plan.game,
1674
- profile: plan.profile,
1675
- ...plan.instance === undefined ? {} : { instance: plan.instance },
1676
- mode: plan.mode,
1677
- ...plan.marker === undefined ? {} : { marker: plan.marker },
1678
- timeoutSeconds: plan.timeoutSeconds,
1679
- renderWaitSeconds: plan.renderWaitSeconds,
1680
- profileDir: resolve(plan.profileDir),
1681
- instanceDir: resolve(plan.instanceDir),
1682
- containerName: containerName(plan),
1683
- dataDirHost: resolve(plan.dataDirHost),
1684
- stageDirHost: resolve(plan.stageDirHost),
1685
- logsDirHost: resolve(plan.logsDirHost),
1686
- mods: plan.mods.map((mod) => ({
1687
- packageId: mod.packageId,
1688
- kind: mod.kind,
1689
- hostDir: resolve(mod.hostDir),
1690
- containerDir: mod.containerDir,
1691
- origin: mod.explicit ? "explicit" : "auto",
1692
- stale: mod.stale === true,
1693
- ...mod.staleReport === undefined ? {} : { staleReport: mod.staleReport },
1694
- ...mod.workshopId === undefined ? {} : { workshopId: mod.workshopId }
1695
- })),
1696
- warnings: planWarnings(plan)
1697
- };
1571
+ crossReference(problems, games);
1572
+ return { config: cfg, problems };
1698
1573
  }
1699
- function printPlan(plan, asJson) {
1700
- const payload = planPayload(plan);
1701
- if (asJson) {
1702
- process.stdout.write(`${JSON.stringify(payload, null, 2)}
1703
- `);
1574
+ function collect2(problems, prefix, schema, value) {
1575
+ const result = schema.safeParse(value);
1576
+ if (result.success)
1704
1577
  return;
1578
+ for (const issue of result.error.issues) {
1579
+ if (issue.code === "unrecognized_keys") {
1580
+ const hints = hintsFor(issue.message);
1581
+ issue.keys.forEach((key, i) => {
1582
+ const problem = {
1583
+ where: `${prefix}${pointer(issue.path)}/${esc(key)}`,
1584
+ message: `unknown key "${key}"`
1585
+ };
1586
+ const hint = hints[i];
1587
+ if (hint != null)
1588
+ problem.suggestion = `did you mean "${hint}"?`;
1589
+ problems.push(problem);
1590
+ });
1591
+ continue;
1592
+ }
1593
+ const key = issue.path.at(-1);
1594
+ const missing = typeof key === "string" && valueAt(value, issue.path) === undefined;
1595
+ const problem = {
1596
+ where: `${prefix}${pointer(issue.path)}`,
1597
+ message: missing ? `missing required key "${key}"` : issue.message
1598
+ };
1599
+ const hint = issue.params?.suggestion;
1600
+ if (hint !== undefined)
1601
+ problem.suggestion = hint;
1602
+ problems.push(problem);
1705
1603
  }
1706
- const title = payload.instance === undefined ? `${payload.game} ${payload.profile} (${payload.mode})` : `${payload.game} ${payload.profile} / ${payload.instance} (${payload.mode})`;
1707
- const out = [
1708
- title,
1709
- ` profile ${payload.profileDir}`,
1710
- ` instance ${payload.instanceDir}`,
1711
- ` container ${payload.containerName}`,
1712
- ` data ${payload.dataDirHost}`,
1713
- ` stage ${payload.stageDirHost}`,
1714
- ` logs ${payload.logsDirHost}`
1715
- ];
1716
- if (payload.marker !== undefined)
1717
- out.push(` marker ${payload.marker}`);
1718
- out.push(` timeout ${payload.timeoutSeconds}s, render wait ${payload.renderWaitSeconds}s`, ` mods ${payload.mods.length}`);
1719
- const width = Math.max(0, ...payload.mods.map((m) => m.packageId.length));
1720
- for (const mod of payload.mods) {
1721
- const notes = [mod.kind, mod.origin];
1722
- if (mod.stale)
1723
- notes.push("stale");
1724
- out.push(` ${mod.packageId.padEnd(width)} ${notes.join(" ")} ${mod.hostDir} -> ${mod.containerDir}`);
1725
- }
1726
- for (const warning of payload.warnings)
1727
- out.push(` warning: ${warning}`);
1728
- process.stdout.write(`${out.join(`
1729
- `)}
1730
- `);
1731
- }
1732
- function runTimestamp(now = new Date) {
1733
- return now.toISOString().replaceAll(/[-:.]/g, "");
1734
1604
  }
1735
- function openRunLog(logsDir, now) {
1736
- const runsDir = join4(logsDir, "runs");
1737
- mkdirSync2(runsDir, { recursive: true });
1738
- const dir = uniqueRunDir(runsDir, runTimestamp(now));
1739
- mkdirSync2(dir);
1740
- linkCurrent(logsDir, dir);
1741
- rotateRuns(logsDir, 10);
1742
- return dir;
1743
- }
1744
- var encoder = new TextEncoder;
1745
- function append(fd, chunk) {
1746
- writeSync(fd, typeof chunk === "string" ? encoder.encode(chunk) : chunk);
1605
+ function pointer(path) {
1606
+ return path.map((segment) => `/${esc(String(segment))}`).join("");
1747
1607
  }
1748
- function uniqueRunDir(runsDir, stamp) {
1749
- let candidate = join4(runsDir, stamp);
1750
- let n = 2;
1751
- while (existsSync2(candidate)) {
1752
- candidate = join4(runsDir, `${stamp}-${n}`);
1753
- n++;
1608
+ function valueAt(root_, path) {
1609
+ let current = root_;
1610
+ for (const segment of path) {
1611
+ if (current === null || typeof current !== "object")
1612
+ return;
1613
+ current = own(current, String(segment));
1754
1614
  }
1755
- return candidate;
1615
+ return current;
1756
1616
  }
1757
- var WAIT_NOTICE = { firstMs: 2000, everyMs: 30000 };
1758
- function waitNotice(waitedMs, lastNoticeMs, schedule = WAIT_NOTICE) {
1759
- if (waitedMs < schedule.firstMs)
1760
- return;
1761
- if (lastNoticeMs > 0 && waitedMs - lastNoticeMs < schedule.everyMs)
1762
- return;
1763
- return waitedMs < 60000 ? `${Math.floor(waitedMs / 1000)}s` : `${Math.floor(waitedMs / 60000)}m`;
1617
+ function crossReference(p, games) {
1618
+ const containers = new Map;
1619
+ for (const [gameName, game_] of Object.entries(games)) {
1620
+ const where = `/games/${esc(gameName)}`;
1621
+ checkName(p, where, gameName, "game");
1622
+ if (!isObj(game_))
1623
+ continue;
1624
+ checkVariantNames(p, where, game_);
1625
+ const profiles = game_["profiles"];
1626
+ if (!isObj(profiles))
1627
+ continue;
1628
+ for (const [name, prof] of Object.entries(profiles)) {
1629
+ const w = `${where}/profiles/${esc(name)}`;
1630
+ checkName(p, w, name, "profile");
1631
+ if (isObj(prof))
1632
+ checkProfile(p, w, name, prof, profiles);
1633
+ }
1634
+ checkCollisions(p, where, gameName, profiles, containers);
1635
+ }
1764
1636
  }
1765
- function runStartedAt(name) {
1766
- const m = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(\d{3})Z/.exec(name);
1767
- if (m === null)
1637
+ function checkVariantNames(p, where, game_) {
1638
+ const steamBuild = game_["steamBuild"];
1639
+ const variants = isObj(steamBuild) ? steamBuild["variants"] : undefined;
1640
+ if (!Array.isArray(variants))
1768
1641
  return;
1769
- const at = Date.parse(`${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:${m[6]}.${m[7]}Z`);
1770
- return Number.isNaN(at) ? undefined : at;
1771
- }
1772
- function currentLog(instanceDir) {
1773
- return join4(instanceDir, "logs", "current", STDOUT_LOG);
1774
- }
1775
- function tailArgv(file, fromStart, livePid) {
1776
- const argv = ["tail"];
1777
- if (fromStart)
1778
- argv.push("-n", "+1");
1779
- if (livePid !== undefined)
1780
- argv.push("-f", "--pid", String(livePid));
1781
- argv.push(file);
1782
- return argv;
1642
+ variants.forEach((variant, index) => {
1643
+ const name = isObj(variant) ? variant["name"] : undefined;
1644
+ if (typeof name !== "string")
1645
+ return;
1646
+ checkName(p, `${where}/steamBuild/variants/${index}/name`, name, "variant");
1647
+ });
1783
1648
  }
1784
- function linkCurrent(logsDir, target) {
1785
- const link = join4(logsDir, "current");
1786
- try {
1787
- lstatSync(link);
1788
- unlinkSync(link);
1789
- } catch {}
1790
- symlinkSync(join4("runs", basename2(target)), link, "dir");
1649
+ function checkProfile(p, w, name, prof, profiles) {
1650
+ const names = Object.keys(profiles);
1651
+ checkExtends(p, w, prof, profiles, names);
1652
+ checkAlias(p, w, name, prof, profiles, names);
1653
+ checkAliases(p, w, prof, names);
1654
+ checkInstances(p, w, prof);
1791
1655
  }
1792
- function rotateRuns(logsDir, keep) {
1793
- const runsDir = join4(logsDir, "runs");
1794
- if (keep < 1 || !existsSync2(runsDir))
1795
- return [];
1796
- const dirs = readdirSync(runsDir, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort().reverse();
1797
- const removed = dirs.slice(keep);
1798
- for (const name of removed)
1799
- rmSync(join4(runsDir, name), { recursive: true, force: true });
1800
- return removed;
1656
+ function checkExtends(p, w, prof, profiles, names) {
1657
+ const parent = prof["extends"];
1658
+ if (typeof parent !== "string" || resolves(profiles, parent))
1659
+ return;
1660
+ const prob = { where: `${w}/extends`, message: `extends unknown profile "${parent}"` };
1661
+ const hint = suggest(parent, names);
1662
+ if (hint)
1663
+ prob.suggestion = `did you mean "${hint}"?`;
1664
+ p.push(prob);
1801
1665
  }
1802
-
1803
- // src/launch/prepare.ts
1804
- import { existsSync as existsSync3, readFileSync } from "node:fs";
1805
- import { open as open2, readdir as readdir3, readFile, unlink, writeFile } from "node:fs/promises";
1806
- import { join as join5 } from "node:path";
1807
- import { setTimeout as sleep2 } from "node:timers/promises";
1808
- async function inherit(argv) {
1809
- const proc = spawnArgv(argv, ["ignore", "pipe", "pipe"]);
1810
- const code = exited(proc);
1811
- await Promise.all([
1812
- forwardOutput(proc.stdout, process.stdout),
1813
- forwardOutput(proc.stderr, process.stderr)
1814
- ]);
1815
- return code;
1666
+ function checkAlias(p, w, name, prof, profiles, names) {
1667
+ const alias = prof["alias"];
1668
+ if (typeof alias !== "string")
1669
+ return;
1670
+ if (!resolves(profiles, alias)) {
1671
+ const prob = { where: `${w}/alias`, message: `alias of unknown profile "${alias}"` };
1672
+ const hint = suggest(alias, [...names, "modless"]);
1673
+ if (hint)
1674
+ prob.suggestion = `did you mean "${hint}"?`;
1675
+ p.push(prob);
1676
+ }
1677
+ if (alias.toLowerCase() === name.toLowerCase()) {
1678
+ p.push({ where: `${w}/alias`, message: "a profile cannot alias itself" });
1679
+ }
1816
1680
  }
1817
- async function imageDigest(ref) {
1818
- const { code, stdout } = await capture(["docker", "image", "inspect", "--format", "{{.Id}}", ref]);
1819
- const id = stdout.trim();
1820
- return code === 0 && id.length > 0 ? id : null;
1681
+ function checkAliases(p, w, prof, names) {
1682
+ const aliases = prof["aliases"];
1683
+ if (!Array.isArray(aliases))
1684
+ return;
1685
+ for (const [i, entry] of aliases.entries()) {
1686
+ if (typeof entry !== "string")
1687
+ continue;
1688
+ const at = `${w}/aliases/${i}`;
1689
+ checkName(p, at, entry, "profile alias");
1690
+ if (names.some((k) => k.toLowerCase() === entry.toLowerCase())) {
1691
+ p.push({ where: at, message: `alias "${entry}" is already a profile name` });
1692
+ }
1693
+ }
1821
1694
  }
1822
- async function readFromImage(ref, path) {
1823
- const { code, stdout } = await capture(["docker", "run", "--rm", "--entrypoint", "cat", ref, path]);
1824
- return code === 0 ? stdout : null;
1695
+ function checkInstances(p, w, prof) {
1696
+ const instances = prof["instances"];
1697
+ if (!isObj(instances))
1698
+ return;
1699
+ for (const instance of Object.keys(instances)) {
1700
+ checkName(p, `${w}/instances/${esc(instance)}`, instance, "instance");
1701
+ }
1825
1702
  }
1826
- async function imageLabel(ref, label) {
1827
- const format = `{{index .Config.Labels "${label}"}}`;
1828
- const { code, stdout } = await capture(["docker", "image", "inspect", "--format", format, ref]);
1829
- const value = stdout.trim();
1830
- if (code !== 0 || value.length === 0 || value === "<no value>")
1831
- return null;
1832
- return value;
1703
+ function resolves(profiles, name) {
1704
+ if (name.toLowerCase() === "modless")
1705
+ return true;
1706
+ return profileKey({ profiles }, name) !== undefined;
1833
1707
  }
1834
- async function acquireImage(game, config, pull) {
1835
- const { image } = config;
1836
- const present = await imageDigest(image.ref) !== null;
1837
- if (image.acquire === "build")
1838
- return await buildImage(game, image, present, pull);
1839
- if (pull === "never") {
1840
- if (present)
1841
- return;
1842
- throw new GamecrateError(`--pull never but ${image.ref} is not present locally`, Exit.Environment);
1708
+ function checkCollisions(p, where, gameName, profiles, containers) {
1709
+ const names = new Map;
1710
+ const aliasOwners = new Map;
1711
+ const prefix = `${gameName.toLowerCase()}-`;
1712
+ for (const [name, prof] of Object.entries(profiles)) {
1713
+ const w = `${where}/profiles/${esc(name)}`;
1714
+ const lower = name.toLowerCase();
1715
+ const twin = names.get(lower);
1716
+ if (twin !== undefined) {
1717
+ p.push({
1718
+ where: w,
1719
+ message: `profile "${name}" differs from "${twin}" only in case, so both share one data directory`
1720
+ });
1721
+ continue;
1722
+ }
1723
+ names.set(lower, name);
1724
+ const clash = containers.get(prefix + lower);
1725
+ if (clash !== undefined) {
1726
+ p.push({ where: w, message: `container name collides with ${clash}` });
1727
+ continue;
1728
+ }
1729
+ containers.set(prefix + lower, w);
1730
+ if (!isObj(prof))
1731
+ continue;
1732
+ checkAliasOwners(p, w, prof, name, aliasOwners);
1733
+ checkInstanceContainers(p, w, profiles, name, `${prefix}${lower}`, containers);
1843
1734
  }
1844
- if (pull === "missing" && present)
1735
+ }
1736
+ function checkAliasOwners(p, w, prof, name, owners) {
1737
+ const aliases = prof["aliases"];
1738
+ if (!Array.isArray(aliases))
1845
1739
  return;
1846
- if (await inherit(["docker", "pull", image.ref]) !== 0) {
1847
- if (present)
1848
- return;
1849
- throw new GamecrateError(`docker pull failed for ${image.ref}`, Exit.Environment);
1740
+ for (const [i, entry] of aliases.entries()) {
1741
+ if (typeof entry !== "string")
1742
+ continue;
1743
+ const owner = owners.get(entry.toLowerCase());
1744
+ if (owner !== undefined) {
1745
+ p.push({
1746
+ where: `${w}/aliases/${i}`,
1747
+ message: `alias "${entry}" is already declared by profile "${owner}"`
1748
+ });
1749
+ continue;
1750
+ }
1751
+ owners.set(entry.toLowerCase(), name);
1850
1752
  }
1851
1753
  }
1852
- async function buildImage(game, image, present, pull) {
1853
- if (image.context === undefined) {
1854
- throw new GamecrateError(`${game} has image.acquire "build" but no context`, Exit.Config);
1855
- }
1856
- if (present && pull !== "always")
1754
+ function checkInstanceContainers(p, w, profiles, name, prefix, containers) {
1755
+ const prof = profiles[name];
1756
+ if (!isObj(prof))
1857
1757
  return;
1858
- if (await inherit(["docker", "build", "--tag", image.ref, image.context]) !== 0) {
1859
- throw new GamecrateError(`docker build failed for ${image.ref}`, Exit.Environment);
1758
+ const declared = prof["instances"];
1759
+ for (const instance of instanceNames(profiles, name, prof)) {
1760
+ const container = `${prefix}-${instance.toLowerCase()}`;
1761
+ const at = isObj(declared) && Object.hasOwn(declared, instance) ? `${w}/instances/${esc(instance)}` : w;
1762
+ const first = containers.get(container);
1763
+ if (first !== undefined) {
1764
+ p.push({
1765
+ where: at,
1766
+ message: `instance "${instance}" makes a container name that collides with ${first}`
1767
+ });
1768
+ continue;
1769
+ }
1770
+ containers.set(container, at);
1860
1771
  }
1861
1772
  }
1862
- async function buildTarget(dir) {
1863
- let entries;
1773
+ function instanceNames(profiles, name, prof) {
1864
1774
  try {
1865
- entries = await readdir3(dir);
1775
+ const resolved = resolveProfile({ profiles }, name).instances;
1776
+ return isObj(resolved) ? Object.keys(resolved) : [];
1866
1777
  } catch {
1867
- return null;
1778
+ const declared = prof["instances"];
1779
+ return isObj(declared) ? Object.keys(declared) : [];
1868
1780
  }
1869
- const slnx = entries.find((e) => e.endsWith(".slnx"));
1870
- if (slnx)
1871
- return join5(dir, slnx);
1872
- const csproj = entries.find((e) => e.endsWith(".csproj"));
1873
- return csproj ? join5(dir, csproj) : null;
1874
1781
  }
1875
- async function buildLocalMods(plan, policy) {
1876
- if (policy === "never")
1877
- return;
1878
- const wanted = plan.mods.filter((m) => m.kind === "local" && (policy === "always" || m.stale === true));
1879
- if (wanted.length === 0)
1782
+ function checkName(p, where, name, kind) {
1783
+ if (RESERVED_NAMES.includes(name.toLowerCase())) {
1784
+ p.push({ where, message: `"${name}" is a reserved name and cannot be used as a ${kind} name` });
1880
1785
  return;
1881
- for (const mod of wanted) {
1882
- const target = await buildTarget(mod.hostDir);
1883
- if (target === null)
1884
- continue;
1885
- const code = await inherit(["dotnet", "build", target, "-v", "quiet", "--nologo"]);
1886
- if (code !== 0) {
1887
- throw new GamecrateError(`dotnet build failed for ${mod.packageId}`, Exit.Environment, target);
1888
- }
1889
- mod.stale = false;
1890
- delete mod.staleReport;
1891
- }
1892
- }
1893
- async function clearLock(plan) {
1894
- const path = lockPath(plan);
1895
- const what = plan.instance === undefined ? plan.profile : `${plan.profile} (${plan.instance})`;
1896
- const name = containerName(plan);
1897
- const up = await capture(["docker", "ps", "--quiet", "--filter", `name=^${name}$`]);
1898
- if (up.stdout.trim().length > 0) {
1899
- throw new GamecrateError(`${plan.game} ${what} is already running (container ${name})`, Exit.Refused, `stop it with: docker stop ${name}
1900
- or relaunch with --replace`);
1901
1786
  }
1902
- const held = await readLock(path);
1903
- if (held !== undefined && isRunning(held.pid, held.startedAt)) {
1904
- throw new GamecrateError(`${plan.game} ${what} is already running (pid ${held.pid})`, Exit.Refused, `if that is wrong, delete ${path}
1905
- or relaunch with --replace`);
1787
+ if (!NAME_PATTERN.test(name)) {
1788
+ p.push({ where, message: `${kind} name "${name}" must match ${NAME_PATTERN.source}` });
1906
1789
  }
1907
- if (existsSync3(path))
1908
- await unlink(path).catch(() => {});
1909
1790
  }
1910
- async function unlinkHeld(path, pid, startedAt) {
1911
- const held = await readLock(path);
1912
- if (held === undefined)
1913
- return;
1914
- if (held.pid !== pid)
1915
- return;
1916
- if (startedAt !== undefined && held.startedAt !== startedAt)
1917
- return;
1918
- await unlink(path).catch(() => {});
1791
+ function esc(segment) {
1792
+ return segment.replaceAll("~", "~0").replaceAll("/", "~1");
1919
1793
  }
1920
- function heldLock(plan) {
1921
- const path = lockPath(plan);
1922
- return {
1923
- release: async () => {
1924
- await unlinkHeld(path, process.pid);
1925
- }
1926
- };
1794
+ function isObj(v) {
1795
+ return typeof v === "object" && v !== null && !Array.isArray(v);
1927
1796
  }
1928
- async function takeLock(plan) {
1929
- await clearLock(plan);
1930
- await writeLock(plan, {
1931
- pid: process.pid,
1932
- container: containerName(plan),
1933
- game: plan.game,
1934
- profile: plan.profile,
1935
- ...plan.instance === undefined ? {} : { instance: plan.instance },
1936
- detached: false,
1937
- mode: plan.mode
1938
- });
1939
- return heldLock(plan);
1797
+
1798
+ // src/config/load.ts
1799
+ function globalConfigDir() {
1800
+ const base = process.env["XDG_CONFIG_HOME"] ?? join2(homedir(), ".config");
1801
+ return join2(base, "gamecrate");
1940
1802
  }
1941
- function lockPath(plan) {
1942
- return join5(plan.instanceDir, ".gamecrate", "lock");
1803
+ async function findGlobalConfig() {
1804
+ return probe(globalConfigDir(), "profiles");
1943
1805
  }
1944
- async function readLock(path) {
1945
- const text = await readFile(path, "utf8").catch(() => {
1946
- return;
1947
- });
1948
- if (text === undefined)
1949
- return;
1806
+ async function probe(dir, stem) {
1807
+ const found = [];
1808
+ for (const suffix of CONFIG_SUFFIXES) {
1809
+ const file = join2(dir, `${stem}${suffix}`);
1810
+ try {
1811
+ await access(file);
1812
+ found.push(file);
1813
+ } catch (error) {
1814
+ if (error.code !== "ENOENT")
1815
+ throw error;
1816
+ }
1817
+ }
1818
+ if (found.length > 1) {
1819
+ const rows = found.map((f) => ` ${basename(f)}`).join(`
1820
+ `);
1821
+ throw new GamecrateError(`two configs in ${dir}`, Exit.Config, `${rows}
1822
+ keep one`);
1823
+ }
1824
+ return found[0];
1825
+ }
1826
+ var projectName = z2.custom((v) => typeof v === "string" && NAME_PATTERN.test(v), "expected a name");
1827
+ var projectStr = z2.string({ error: "expected a string" });
1828
+ var projectBool = z2.boolean({ error: "expected true or false" });
1829
+ var projectList = z2.custom((v) => Array.isArray(v) && v.every((entry) => typeof entry === "string"), "expected an array of strings");
1830
+ var projectSeconds = z2.custom((v) => Number.isSafeInteger(v) && v >= 0, "expected a whole number of seconds");
1831
+ function oneOf2(values) {
1832
+ return z2.enum(values, { error: `expected one of ${values.join(", ")}` });
1833
+ }
1834
+ var BUILD_POLICIES2 = ["auto", "always", "never"];
1835
+ var projectResolution = z2.string({ error: "expected dimensions like 1920x1080" }).check((ctx) => {
1950
1836
  try {
1951
- const value = JSON.parse(text);
1952
- return Number.isInteger(value?.pid) && value.pid > 0 ? value : undefined;
1953
- } catch {
1837
+ parseResolution(ctx.value);
1838
+ } catch (error) {
1839
+ ctx.issues.push({ code: "custom", message: error.message, input: ctx.value });
1840
+ }
1841
+ }).transform(parseResolution);
1842
+ var PROJECT_OBJECT = z2.strictObject({
1843
+ game: projectName.optional(),
1844
+ defaultProfile: projectName.optional(),
1845
+ profiles: z2.record(z2.string(), z2.unknown()).optional(),
1846
+ settings: z2.record(z2.string(), z2.unknown()).optional(),
1847
+ library: z2.record(z2.string(), z2.unknown()).optional(),
1848
+ detach: projectBool.optional(),
1849
+ mods: projectList.optional(),
1850
+ without: projectList.optional(),
1851
+ only: projectList.optional(),
1852
+ dockerArgs: projectList.optional(),
1853
+ gameArgs: projectList.optional(),
1854
+ worktree: projectList.optional(),
1855
+ use: projectList.optional(),
1856
+ marker: projectStr.optional(),
1857
+ instance: projectStr.optional(),
1858
+ log: projectStr.optional(),
1859
+ timeout: projectSeconds.optional(),
1860
+ renderWait: projectSeconds.optional(),
1861
+ dryRun: projectBool.optional(),
1862
+ printPlan: projectBool.optional(),
1863
+ json: projectBool.optional(),
1864
+ root: projectBool.optional(),
1865
+ noWorktree: projectBool.optional(),
1866
+ noStaleCheck: projectBool.optional(),
1867
+ replace: projectBool.optional(),
1868
+ mode: oneOf2(["headed", "headless", "screenshot"]).optional(),
1869
+ pull: oneOf2(["always", "missing", "never"]).optional(),
1870
+ sort: oneOf2(["topo", "none"]).optional(),
1871
+ network: oneOf2(["none", "bridge", "host"]).optional(),
1872
+ build: z2.union([z2.boolean().transform((on) => on ? "always" : "never"), z2.enum(BUILD_POLICIES2)], {
1873
+ error: `expected one of ${BUILD_POLICIES2.join(", ")}`
1874
+ }).optional(),
1875
+ resolution: projectResolution.optional()
1876
+ }, { error: "expected an object" });
1877
+ var PROJECT_SCHEMA = PROJECT_OBJECT.check((ctx) => {
1878
+ const { game, profiles, settings, library } = ctx.value;
1879
+ if (game !== undefined)
1954
1880
  return;
1881
+ for (const [key, value] of [
1882
+ ["profiles", profiles],
1883
+ ["settings", settings],
1884
+ ["library", library]
1885
+ ]) {
1886
+ if (value === undefined)
1887
+ continue;
1888
+ ctx.issues.push({
1889
+ code: "custom",
1890
+ path: [key],
1891
+ message: "needs a top-level game: to say which game it belongs to",
1892
+ input: ctx.value
1893
+ });
1894
+ }
1895
+ });
1896
+ async function findProjectConfig(start = process.cwd()) {
1897
+ let dir = resolve2(start);
1898
+ for (;; ) {
1899
+ const file = await probe(dir, ".gamecrate");
1900
+ if (file !== undefined)
1901
+ return file;
1902
+ const parent = dirname2(dir);
1903
+ if (parent === dir)
1904
+ return;
1905
+ dir = parent;
1955
1906
  }
1956
1907
  }
1957
- async function writeLock(plan, record) {
1958
- const path = lockPath(plan);
1959
- const handle = await open2(path, "wx").catch(() => null);
1960
- if (handle === null) {
1961
- throw new GamecrateError(`could not take the launch lock at ${path}`, Exit.Environment);
1908
+ async function loadProjectDefaults(start = process.cwd()) {
1909
+ const file = await findProjectConfig(start);
1910
+ if (file === undefined)
1911
+ return {};
1912
+ const text = await readFile2(file, "utf8");
1913
+ const defaults = validateProjectDefaults(readConfigText(text, file), file);
1914
+ const profiles = defaults.profiles;
1915
+ if (profiles !== undefined) {
1916
+ const order = orderedKeys(text, file, "profiles");
1917
+ if (order.length !== Object.keys(profiles).length || order.some((key) => !Object.hasOwn(profiles, key))) {
1918
+ throw new GamecrateError(`config is invalid: ${file}`, Exit.Config, "duplicate profiles key");
1919
+ }
1920
+ defaults.profileOrder = order;
1921
+ }
1922
+ defaults.configPath = file;
1923
+ return defaults;
1924
+ }
1925
+ function validateProjectDefaults(raw, file) {
1926
+ if (raw === null)
1927
+ return {};
1928
+ const result = PROJECT_SCHEMA.safeParse(raw);
1929
+ if (result.success)
1930
+ return result.data;
1931
+ const problems = [];
1932
+ for (const issue of result.error.issues) {
1933
+ if (issue.code === "unrecognized_keys") {
1934
+ for (const key of issue.keys)
1935
+ problems.push(` /${key}: unknown key`);
1936
+ continue;
1937
+ }
1938
+ problems.push(` /${issue.path.join("/")}: ${issue.message}`);
1962
1939
  }
1963
- await handle.writeFile(JSON.stringify({ ...record, startedAt: new Date().toISOString() }));
1964
- await handle.close();
1940
+ throw new GamecrateError(`project config is invalid: ${file}`, Exit.Config, problems.join(`
1941
+ `));
1965
1942
  }
1966
- var RELEASE_POLL_MS = 100;
1967
- var DRAIN_ALLOWANCE_MS = 1e4;
1968
- var STOP_RELEASE_WAIT_MS = STOP_TIMEOUT_SECONDS * 1000 + DRAIN_ALLOWANCE_MS;
1969
- async function stopRun(record, lockFile) {
1970
- let signalled = false;
1971
- if (isRunning(record.pid, record.startedAt)) {
1972
- try {
1973
- process.kill(record.pid, "SIGTERM");
1974
- signalled = true;
1975
- } catch {}
1943
+ async function loadConfig(path, project) {
1944
+ const file = path ?? await findGlobalConfig() ?? join2(globalConfigDir(), "profiles.yml");
1945
+ const user = await readConfigFile(file);
1946
+ const specs = isObj(user) && user["plugins"] !== undefined ? user["plugins"] : [];
1947
+ if (!Array.isArray(specs) || specs.some((s) => typeof s !== "string")) {
1948
+ throw new GamecrateError(`config is invalid: ${file}`, Exit.Config, " /plugins: expected an array of strings");
1976
1949
  }
1977
- if (!signalled)
1978
- await stopContainer(record.container, STOP_TIMEOUT_SECONDS);
1979
- const deadline = Date.now() + STOP_RELEASE_WAIT_MS;
1980
- while (existsSync3(lockFile)) {
1981
- const held = await readLock(lockFile);
1982
- if (held === undefined)
1983
- break;
1984
- if (!isRunning(held.pid, held.startedAt)) {
1985
- await unlinkHeld(lockFile, record.pid, record.startedAt);
1986
- break;
1987
- }
1988
- if (Date.now() >= deadline)
1989
- return "held";
1990
- await sleep2(RELEASE_POLL_MS);
1950
+ const plugins = await loadPlugins(specs, file);
1951
+ const base = {
1952
+ dataRoot: DEFAULT_DATA_ROOT,
1953
+ defaults: { settings: structuredClone(DEFAULT_SETTINGS) },
1954
+ games: Object.fromEntries([...plugins].map(([name, plugin]) => [name, structuredClone(plugin.defaults)]))
1955
+ };
1956
+ const merged = user === undefined || user === null ? base : mergeUserConfig(base, user);
1957
+ const spliced = applyProject(merged, project);
1958
+ const { config, problems } = validateConfig(spliced);
1959
+ if (problems.length > 0) {
1960
+ const detail = problems.map((p) => {
1961
+ const hint = p.suggestion ? ` (${p.suggestion})` : "";
1962
+ return ` ${p.where || "/"}: ${p.message}${hint}${origin(p.where, user, plugins, project)}`;
1963
+ }).join(`
1964
+ `);
1965
+ const from = plugins.size === 0 ? "" : ` (merged with defaults from: ${[...plugins.keys()].join(", ")})`;
1966
+ throw new GamecrateError(`config is invalid: ${file}${from}`, Exit.Config, detail);
1991
1967
  }
1992
- return signalled ? "signalled" : "orphaned";
1968
+ return { config: expandPaths(config), plugins };
1993
1969
  }
1994
- async function replacePrevious(plan) {
1995
- const name = containerName(plan);
1996
- const path = lockPath(plan);
1997
- const up = await capture(["docker", "ps", "--quiet", "--filter", `name=^${name}$`]);
1998
- const running = up.stdout.trim().length > 0;
1999
- const held = await readLock(path);
2000
- if (!running && held === undefined && !existsSync3(path))
2001
- return;
2002
- if (held !== undefined) {
2003
- status(`stopping ${held.container}`);
2004
- await stopRun(held, path);
2005
- return;
1970
+ function applyProject(config, project) {
1971
+ if (project === undefined)
1972
+ return config;
1973
+ const game = project.game;
1974
+ if (game === undefined)
1975
+ return config;
1976
+ if (project.profiles === undefined && project.settings === undefined && project.library === undefined) {
1977
+ return config;
2006
1978
  }
2007
- if (running) {
2008
- status(`stopping ${name}`);
2009
- await stopContainer(name, STOP_TIMEOUT_SECONDS);
1979
+ const existing = own(config.games, game);
1980
+ if (existing === undefined) {
1981
+ throw new GamecrateError(`the project config names game "${game}", which is not configured`, Exit.Config, `known games: ${Object.keys(config.games).join(", ") || "none"}`);
1982
+ }
1983
+ const target = {
1984
+ ...existing,
1985
+ profiles: { ...existing.profiles, ...project.profiles },
1986
+ ...project.library === undefined ? {} : { library: spliceLibrary(existing.library, project.library) }
1987
+ };
1988
+ config.games[game] = target;
1989
+ if (project.settings !== undefined) {
1990
+ target.settings = deepMerge(target.settings ?? {}, project.settings);
2010
1991
  }
1992
+ return config;
2011
1993
  }
2012
- var CLOCK_TICKS_PER_SECOND = 100;
2013
- var START_TIME_SLACK_MS = 2000;
2014
- function isRunning(pid, startedAt) {
2015
- try {
2016
- process.kill(pid, 0);
2017
- } catch {
2018
- return false;
1994
+ function spliceLibrary(global, project) {
1995
+ const replaced = new Set(Object.keys(project).map((id) => id.toLowerCase()));
1996
+ const kept = Object.entries(global ?? {}).filter(([id]) => !replaced.has(id.toLowerCase()));
1997
+ return { ...Object.fromEntries(kept), ...project };
1998
+ }
1999
+ var GAME_SCOPED_KEYS = ["profiles", "settings", "library"];
2000
+ function origin(where, user, plugins, project) {
2001
+ if (!where.startsWith("/"))
2002
+ return "";
2003
+ const segments = where.slice(1).split("/").map((s) => s.replaceAll("~1", "/").replaceAll("~0", "~"));
2004
+ const [section, name, sub, ...rest] = segments;
2005
+ const repoGame = project?.game;
2006
+ const repoSection = GAME_SCOPED_KEYS.find((k) => k === sub);
2007
+ if (repoGame !== undefined && section === "games" && name === repoGame && repoSection !== undefined && valueAt2(project?.[repoSection], rest) !== undefined) {
2008
+ return " <- from the .gamecrate project config, not this file";
2019
2009
  }
2020
- if (startedAt === undefined)
2021
- return true;
2022
- const written = Date.parse(startedAt);
2023
- if (Number.isNaN(written))
2024
- return true;
2025
- const began = processStart(pid);
2026
- return began === undefined || began <= written + START_TIME_SLACK_MS;
2010
+ if (valueAt2(user, segments) !== undefined)
2011
+ return "";
2012
+ const plugin = section === "games" && name !== undefined ? plugins.get(name) : undefined;
2013
+ if (plugin === undefined)
2014
+ return " <- not in this file";
2015
+ 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`;
2027
2016
  }
2028
- function processStart(pid) {
2029
- try {
2030
- const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
2031
- const fields = stat.slice(stat.lastIndexOf(")") + 2).split(" ");
2032
- const ticks = Number(fields[19]);
2033
- const boot = bootTime();
2034
- if (!Number.isFinite(ticks) || boot === undefined)
2017
+ function valueAt2(value, segments) {
2018
+ let current = value;
2019
+ for (const segment of segments) {
2020
+ if (Array.isArray(current))
2021
+ current = current[Number(segment)];
2022
+ else if (isObj(current))
2023
+ current = own(current, segment);
2024
+ else
2035
2025
  return;
2036
- return boot + ticks / CLOCK_TICKS_PER_SECOND * 1000;
2037
- } catch {
2038
- return;
2039
2026
  }
2027
+ return current;
2040
2028
  }
2041
- function bootTime() {
2042
- const line = readFileSync("/proc/stat", "utf8").split(`
2043
- `).find((each) => each.startsWith("btime "));
2044
- const seconds = Number(line?.slice("btime ".length));
2045
- return Number.isFinite(seconds) && seconds > 0 ? seconds * 1000 : undefined;
2046
- }
2047
- async function captureScreenshot(container, plan) {
2048
- const name = `${plan.game}.png`;
2049
- const target = `${CONTAINER_LOG_DIR}/${name}`;
2050
- const script = 'D=":$(ls /tmp/.X11-unix 2>/dev/null | head -1 | tr -d X)";' + ' [ "$D" = ":" ] && { echo "no X socket in the container" >&2; exit 1; };' + " X=$(ls -d /tmp/xvfb-run.*/Xauthority 2>/dev/null | head -1);" + ' [ -n "$X" ] && export XAUTHORITY="$X";' + " M=$(command -v magick || command -v convert);" + ' [ -z "$M" ] && { echo "no imagemagick in the container" >&2; exit 1; };' + ` import -display "$D" -window root ${target} 2>/dev/null` + ` || xwd -root -display "$D" | "$M" xwd:- ${target}`;
2051
- const code = await inherit(["docker", "exec", container, "sh", "-c", script]);
2052
- const host = join5(plan.runDirHost, name);
2053
- if (code !== 0 || !existsSync3(host))
2054
- return null;
2055
- return host;
2056
- }
2057
- async function writeLaunchRecord(plan, image) {
2058
- const digest = await imageDigest(image);
2059
- const line = JSON.stringify({
2060
- at: new Date().toISOString(),
2061
- game: plan.game,
2062
- profile: plan.profile,
2063
- ...plan.instance === undefined ? {} : { instance: plan.instance },
2064
- image,
2065
- digest,
2066
- mode: plan.mode,
2067
- mods: plan.mods.map((m) => ({
2068
- packageId: m.packageId,
2069
- hostDir: m.hostDir,
2070
- ...m.worktree === undefined ? {} : { worktree: m.worktree }
2071
- }))
2072
- });
2073
- const path = join5(plan.instanceDir, ".gamecrate", "launches.jsonl");
2074
- await writeFile(path, `${line}
2075
- `, { flag: "a" });
2029
+ function resolveSettings(root, game, profile, ...overrides) {
2030
+ let out = structuredClone(DEFAULT_SETTINGS);
2031
+ for (const layer of [root.defaults?.settings, game.settings, profile.settings, ...overrides]) {
2032
+ if (layer)
2033
+ out = deepMerge(out, layer, true);
2034
+ }
2035
+ return out;
2076
2036
  }
2077
-
2078
- // src/mods/modindex.ts
2079
- import { createHash as createHash2 } from "node:crypto";
2080
- import { existsSync as existsSync7, readFileSync as readFileSync4, statSync as statSync3 } from "node:fs";
2081
- import { mkdir as mkdir2, readdir as readdir6, readFile as readFile5, writeFile as writeFile2 } from "node:fs/promises";
2082
- import { homedir as homedir3 } from "node:os";
2083
- import { dirname as dirname5, join as join10, relative as relative2, sep as sep2, resolve as resolvePath } from "node:path";
2084
- import picomatch from "picomatch";
2085
-
2086
- // src/config/load.ts
2087
- import { z as z2 } from "zod";
2088
- import { access, readdir as readdir4, readFile as readFile3 } from "node:fs/promises";
2089
- import { homedir as homedir2 } from "node:os";
2090
- import { basename as basename3, dirname as dirname2, join as join7, resolve as resolve3 } from "node:path";
2091
-
2092
- // src/plugin.ts
2093
- import { readFileSync as readFileSync2, statSync } from "node:fs";
2094
- import { dirname, isAbsolute, join as join6, resolve as resolve2 } from "node:path";
2095
- import { pathToFileURL } from "node:url";
2096
- import { exports as exportsField, legacy } from "resolve.exports";
2097
- var PLUGIN_API_VERSION = 2;
2098
- var REQUIRED_FUNCTIONS = [
2099
- "parseManifest",
2100
- "renderModsConfig",
2101
- "mergePrefs",
2102
- "parseVersion"
2103
- ];
2104
- function fail(spec, message, detail) {
2105
- throw new GamecrateError(`plugin "${spec}": ${message}`, Exit.Config, detail);
2037
+ function resolveProfile(game, name) {
2038
+ return resolveNamed(game, name, []);
2106
2039
  }
2107
- function entryOf(dir) {
2108
- let manifest;
2109
- try {
2110
- manifest = JSON.parse(readFileSync2(join6(dir, "package.json"), "utf8"));
2111
- } catch {
2112
- return resolve2(dir, "index.js");
2040
+ function resolveNamed(game, name, seen) {
2041
+ if (name.toLowerCase() === "modless")
2042
+ return { mods: [], exclude: [], includeBase: false };
2043
+ const key = profileKey(game, name);
2044
+ if (key === undefined) {
2045
+ throw new GamecrateError(`unknown profile "${name}"`, Exit.Resolution, `known profiles: ${Object.keys(game.profiles).join(", ") || "(none)"}, modless`);
2046
+ }
2047
+ if (seen.includes(key)) {
2048
+ throw new GamecrateError(`profile "${key}" inherits from itself`, Exit.Config, [...seen, key].join(" -> "));
2049
+ }
2050
+ const self = own(game.profiles, key);
2051
+ if (self.alias !== undefined)
2052
+ return resolveNamed(game, self.alias, [...seen, key]);
2053
+ const parent = self.extends !== undefined ? resolveNamed(game, self.extends, [...seen, key]) : {};
2054
+ const exclude = [...parent.exclude ?? [], ...self.exclude ?? []];
2055
+ const out = {
2056
+ mods: subtract([...parent.mods ?? [], ...self.mods ?? []], exclude),
2057
+ exclude,
2058
+ settings: deepMerge(parent.settings ?? {}, self.settings ?? {}, true)
2059
+ };
2060
+ const instances = deepMerge(parent.instances ?? {}, self.instances ?? {}, true);
2061
+ if (Object.keys(instances).length > 0)
2062
+ out.instances = instances;
2063
+ const includeBase = self.includeBase ?? parent.includeBase;
2064
+ if (includeBase !== undefined)
2065
+ out.includeBase = includeBase;
2066
+ const auto = self.autoDependencies ?? parent.autoDependencies;
2067
+ if (auto !== undefined)
2068
+ out.autoDependencies = auto;
2069
+ for (const field of ["detach", "replace", "build", "gameVersion", "image", "windowTitle", "windowIcon"]) {
2070
+ const value = self[field] ?? parent[field];
2071
+ if (value !== undefined)
2072
+ Object.assign(out, { [field]: value });
2113
2073
  }
2114
- let entry;
2115
- try {
2116
- entry = exportsField(manifest, ".", { conditions: ["bun"] })?.[0];
2117
- } catch {}
2118
- entry ??= legacy(manifest, { fields: ["module", "main"] });
2119
- return resolve2(dir, entry ?? "index.js");
2074
+ return out;
2120
2075
  }
2121
- function packageDir(spec, from) {
2122
- let dir = resolve2(from);
2076
+ function canonicalProfile(game, name) {
2077
+ if (name.toLowerCase() === "modless")
2078
+ return "modless";
2079
+ const seen = [];
2080
+ let current = name;
2123
2081
  for (;; ) {
2124
- const candidate = join6(dir, "node_modules", spec);
2125
- if (statSync(join6(candidate, "package.json"), { throwIfNoEntry: false })?.isFile())
2126
- return candidate;
2127
- const parent = dirname(dir);
2128
- if (parent === dir)
2129
- return null;
2130
- dir = parent;
2082
+ const key = profileKey(game, current);
2083
+ if (key === undefined || seen.includes(key))
2084
+ return key ?? current;
2085
+ const next = own(game.profiles, key)?.alias;
2086
+ if (next === undefined)
2087
+ return key;
2088
+ seen.push(key);
2089
+ current = next;
2131
2090
  }
2132
2091
  }
2133
- function locate(spec, from) {
2134
- const expanded = expandHome(spec);
2135
- let target;
2136
- if (expanded.startsWith(".") || isAbsolute(expanded)) {
2137
- target = resolve2(from, expanded);
2138
- } else {
2139
- target = packageDir(expanded, from);
2140
- if (target === null) {
2141
- fail(spec, `cannot be resolved from ${from}`, "install it, or give a path starting with ./");
2142
- }
2143
- }
2144
- return statSync(target, { throwIfNoEntry: false })?.isDirectory() ? entryOf(target) : target;
2092
+ function profileDataDir(root, game, profile) {
2093
+ return resolve2(expandHome(root.dataRoot), game, canonicalProfile(own(root.games, game), profile));
2145
2094
  }
2146
- function check(spec, value) {
2147
- if (typeof value !== "object" || value === null)
2148
- fail(spec, "has no default export");
2149
- const plugin = value;
2150
- if (plugin.apiVersion !== PLUGIN_API_VERSION) {
2151
- fail(spec, `speaks apiVersion ${String(plugin.apiVersion)}, this build speaks ${PLUGIN_API_VERSION}`);
2152
- }
2153
- if (typeof plugin.game !== "string" || plugin.game === "")
2154
- fail(spec, "declares no game name");
2155
- const missing = REQUIRED_FUNCTIONS.filter((name) => typeof plugin[name] !== "function");
2156
- if (missing.length > 0)
2157
- fail(spec, `is missing ${missing.join(", ")}`);
2158
- if (typeof plugin.defaults !== "object" || plugin.defaults === null) {
2159
- fail(spec, "declares no defaults object");
2160
- }
2161
- if (typeof plugin.windowedPrefs !== "object" || plugin.windowedPrefs === null) {
2162
- fail(spec, "declares no windowedPrefs object");
2163
- }
2164
- return plugin;
2095
+ async function profileDirs(root, game, profile) {
2096
+ if (profile !== undefined)
2097
+ return [profileDataDir(root, game, profile)];
2098
+ const dir = join2(expandHome(root.dataRoot), game);
2099
+ return (await readdir(dir).catch(() => [])).map((name) => join2(dir, name));
2165
2100
  }
2166
- async function loadPlugins(specs, configFile) {
2167
- const from = dirname(configFile);
2168
- const out = new Map;
2169
- for (const spec of specs) {
2170
- const target = locate(spec, from);
2171
- let module;
2172
- try {
2173
- module = await import(pathToFileURL(target).href);
2174
- } catch (error) {
2175
- fail(spec, `failed to load ${target}`, error instanceof Error ? error.message : String(error));
2176
- }
2177
- const plugin = check(spec, module.default);
2178
- if (out.has(plugin.game))
2179
- fail(spec, `also claims the game "${plugin.game}"`);
2180
- out.set(plugin.game, plugin);
2181
- }
2182
- return out;
2101
+ function profileKey(game, name) {
2102
+ if (Object.hasOwn(game.profiles, name))
2103
+ return name;
2104
+ const lower = name.toLowerCase();
2105
+ const direct = Object.keys(game.profiles).find((k) => k.toLowerCase() === lower);
2106
+ if (direct !== undefined)
2107
+ return direct;
2108
+ return Object.keys(game.profiles).find((k) => (own(game.profiles, k)?.aliases ?? []).some((a) => typeof a === "string" && a.toLowerCase() === lower));
2183
2109
  }
2184
- function requirePlugin(plugins, game) {
2185
- const plugin = plugins.get(game);
2186
- if (plugin === undefined) {
2187
- throw new GamecrateError(`no plugin provides the game "${game}"`, Exit.Config, `loaded plugins: ${[...plugins.keys()].join(", ") || "(none)"}`);
2188
- }
2189
- return plugin;
2110
+ function subtract(mods, exclude) {
2111
+ if (exclude.length === 0)
2112
+ return mods;
2113
+ const patterns = exclude.map(globToRegExp);
2114
+ return mods.filter((entry) => {
2115
+ const ids = entryIds(entry);
2116
+ if (ids.length === 0)
2117
+ return true;
2118
+ return !ids.some((id) => patterns.some((re) => re.test(id)));
2119
+ });
2190
2120
  }
2191
-
2192
- // src/config/builtin.ts
2193
- var DEFAULT_DATA_ROOT = "~/.local/share/gamecrate";
2194
- var DEFAULT_SETTINGS = {
2195
- width: 1920,
2196
- height: 1080,
2197
- devMode: true,
2198
- runInBackground: true,
2199
- resetModsConfigOnCrash: false,
2200
- gpu: true,
2201
- audio: true,
2202
- input: false,
2203
- network: "bridge",
2204
- display: "x11",
2205
- memory: "8g",
2206
- cpus: 6,
2207
- pidsLimit: 1024
2208
- };
2209
-
2210
- // src/config/read.ts
2211
- import { readFile as readFile2 } from "node:fs/promises";
2212
- import { extname } from "node:path";
2213
- import { parseTree } from "jsonc-parser";
2214
- import { isMap, parse as parseYaml, parseDocument } from "yaml";
2215
-
2216
- // src/config/jsonc.ts
2217
- import { parse, printParseErrorCode } from "jsonc-parser";
2218
- function parseJsonc(text) {
2219
- const errors = [];
2220
- const value = parse(text, errors, { allowTrailingComma: true, allowEmptyContent: false });
2221
- const first = errors[0];
2222
- if (first !== undefined) {
2223
- throw new GamecrateError("config is not valid JSON", Exit.Config, `${printParseErrorCode(first.error)} at offset ${first.offset}`);
2121
+ function entryIds(entry) {
2122
+ if (typeof entry === "string")
2123
+ return [entry, entry.replace(/^(workshop|path):/, "")];
2124
+ if ("id" in entry)
2125
+ return [entry.id];
2126
+ return [];
2127
+ }
2128
+ function globToRegExp(pattern) {
2129
+ const body = pattern.replaceAll(/[.+^${}()|[\]\\]/g, String.raw`\$&`).replaceAll("*", ".*").replaceAll("?", ".");
2130
+ return new RegExp(`^${body}$`, "i");
2131
+ }
2132
+ function mergeUserConfig(base, user) {
2133
+ const out = deepMerge(base, user);
2134
+ const games = isObj(user) ? user["games"] : undefined;
2135
+ if (!isObj(games))
2136
+ return out;
2137
+ for (const name of Object.keys(games)) {
2138
+ const theirs = own(games, name);
2139
+ const steamBuild = isObj(theirs) ? theirs["steamBuild"] : undefined;
2140
+ const added = isObj(steamBuild) ? steamBuild["branches"] : undefined;
2141
+ const declared = own(base.games, name)?.steamBuild?.branches;
2142
+ const target = own(out.games, name);
2143
+ if (!Array.isArray(added) || !Array.isArray(declared) || target === undefined)
2144
+ continue;
2145
+ target.steamBuild.branches = concatBranches(declared, added);
2224
2146
  }
2225
- return value;
2147
+ return out;
2226
2148
  }
2227
-
2228
- // src/config/read.ts
2229
- var CONFIG_SUFFIXES = [".yml", ".yaml", ".json", ".jsonc"];
2230
- function isYaml(path) {
2231
- const suffix = extname(path).toLowerCase();
2232
- if (suffix === ".yml" || suffix === ".yaml")
2233
- return true;
2234
- if (suffix === ".json" || suffix === ".jsonc")
2235
- return false;
2236
- throw new GamecrateError(`config is not a format gamecrate reads: ${path}`, Exit.Config, `use one of ${CONFIG_SUFFIXES.join(", ")}`);
2149
+ function branchName(entry) {
2150
+ return isObj(entry) && typeof entry["name"] === "string" ? entry["name"] : undefined;
2237
2151
  }
2238
- function readConfigText(text, path) {
2239
- if (!isYaml(path)) {
2240
- try {
2241
- return parseJsonc(text);
2242
- } catch (error) {
2243
- if (error instanceof GamecrateError) {
2244
- throw new GamecrateError(`${error.message}: ${path}`, error.code, error.detail);
2245
- }
2246
- throw error;
2152
+ function concatBranches(declared, added) {
2153
+ const out = [...declared];
2154
+ const at = new Map;
2155
+ out.forEach((branch, index) => {
2156
+ const name = branchName(branch);
2157
+ if (name !== undefined && !at.has(name))
2158
+ at.set(name, index);
2159
+ });
2160
+ for (const entry of added) {
2161
+ const name = branchName(entry);
2162
+ const index = name === undefined ? undefined : at.get(name);
2163
+ if (index === undefined) {
2164
+ if (name !== undefined)
2165
+ at.set(name, out.length);
2166
+ out.push(entry);
2167
+ continue;
2247
2168
  }
2169
+ out[index] = deepMerge(out[index], entry);
2248
2170
  }
2249
- try {
2250
- return parseYaml(text);
2251
- } catch (error) {
2252
- throw new GamecrateError(`config is invalid: ${path}`, Exit.Config, error.message);
2253
- }
2171
+ return out;
2254
2172
  }
2255
- async function readConfigFile(path) {
2256
- let text;
2257
- try {
2258
- text = await readFile2(path, "utf8");
2259
- } catch (error) {
2260
- if (error.code === "ENOENT")
2261
- return;
2262
- throw error;
2173
+ function deepMerge(base, over, concatArrays = false) {
2174
+ if (Array.isArray(base) && Array.isArray(over)) {
2175
+ return concatArrays ? [...base, ...over] : [...over];
2263
2176
  }
2264
- return readConfigText(text, path);
2265
- }
2266
- function orderedKeys(text, path, key) {
2267
- if (isYaml(path)) {
2268
- const node = parseDocument(text).get(key, true);
2269
- if (!isMap(node))
2270
- return [];
2271
- return node.items.map((item) => String(item.key.value ?? item.key));
2177
+ if (isObj(base) && isObj(over)) {
2178
+ const out = { ...base };
2179
+ for (const [k, v] of Object.entries(over)) {
2180
+ if (v === undefined)
2181
+ continue;
2182
+ out[k] = Object.hasOwn(out, k) ? deepMerge(out[k], v, concatArrays) : v;
2183
+ }
2184
+ return out;
2272
2185
  }
2273
- const root = parseTree(text);
2274
- const holder = root?.children?.find((child) => child.children?.[0]?.value === key);
2275
- const value = holder?.children?.[1];
2276
- if (value?.type !== "object")
2277
- return [];
2278
- return (value.children ?? []).map((prop) => String(prop.children?.[0]?.value));
2279
- }
2280
-
2281
- // src/config/validate.ts
2282
- import { z } from "zod";
2283
- var MODES2 = ["headed", "headless", "screenshot"];
2284
- var HINTS = "\x00gamecrate/hints:";
2285
- function obj(shape) {
2286
- const known = Object.keys(shape);
2287
- return z.strictObject(shape, {
2288
- error: (issue) => issue.code === "unrecognized_keys" ? HINTS + JSON.stringify(issue.keys.map((key) => suggest(key, known) ?? null)) : "expected an object"
2289
- });
2186
+ return over;
2290
2187
  }
2291
- function hintsFor(message) {
2292
- if (!message.startsWith(HINTS))
2293
- return [];
2294
- try {
2295
- return JSON.parse(message.slice(HINTS.length));
2296
- } catch {
2297
- return [];
2188
+ function expandPaths(config) {
2189
+ config.dataRoot = expandHome(config.dataRoot);
2190
+ if (config.steamcmd?.path !== undefined)
2191
+ config.steamcmd.path = expandHome(config.steamcmd.path);
2192
+ for (const game of Object.values(config.games)) {
2193
+ if (game.gameFiles.host !== undefined)
2194
+ game.gameFiles.host = expandHome(game.gameFiles.host);
2195
+ if (game.image.context !== undefined)
2196
+ game.image.context = expandHome(game.image.context);
2197
+ if (game.workshopRoot !== null)
2198
+ game.workshopRoot = expandHome(game.workshopRoot);
2199
+ for (const root of game.scanRoots)
2200
+ root.path = expandHome(root.path);
2201
+ for (const entry of Object.values(game.library ?? {})) {
2202
+ if (entry.path !== undefined)
2203
+ entry.path = expandHome(entry.path);
2204
+ }
2298
2205
  }
2206
+ return config;
2299
2207
  }
2300
- function requiredWhen(key, when) {
2301
- return (ctx) => {
2302
- if (!when(ctx.value) || ctx.value[key] !== undefined)
2303
- return;
2304
- ctx.issues.push({ code: "custom", message: `missing required key "${key}"`, path: [key], input: ctx.value });
2305
- };
2306
- }
2307
- var str = z.string({ error: "expected a string" });
2308
- var num = z.number({ error: "expected a number" });
2309
- var bool = z.boolean({ error: "expected a boolean" });
2310
- var strArray = z.array(z.string({ error: "expected an array of strings" }), {
2311
- error: "expected an array of strings"
2312
- });
2313
- var strMap = z.record(z.string(), z.string({ error: "expected an object of string values" }), {
2314
- error: "expected an object of string values"
2315
- });
2316
- function oneOf(values) {
2317
- return z.enum(values, { error: `expected one of ${values.join(", ")}` });
2208
+ function expandHome(p) {
2209
+ if (p === "~")
2210
+ return homedir();
2211
+ return p.startsWith("~/") ? join2(homedir(), p.slice(2)) : p;
2318
2212
  }
2319
- var modeName = z.unknown().check((ctx) => {
2320
- const value = ctx.value;
2321
- if (typeof value === "string" && MODES2.includes(value))
2322
- return;
2323
- const hint = typeof value === "string" ? suggest(value, MODES2) : undefined;
2324
- ctx.issues.push({
2325
- code: "custom",
2326
- message: `expected one of ${MODES2.join(", ")}`,
2327
- input: value,
2328
- ...hint === undefined ? {} : { params: { suggestion: `did you mean "${hint}"?` } }
2329
- });
2330
- });
2331
- var settings = obj({
2332
- width: num.optional(),
2333
- height: num.optional(),
2334
- devMode: bool.optional(),
2335
- runInBackground: bool.optional(),
2336
- resetModsConfigOnCrash: bool.optional(),
2337
- gpu: bool.optional(),
2338
- audio: bool.optional(),
2339
- input: bool.optional(),
2340
- network: oneOf(["none", "bridge", "host"]).optional(),
2341
- display: oneOf(["x11", "wayland"]).optional(),
2342
- memory: str.optional(),
2343
- cpus: num.optional(),
2344
- pidsLimit: num.optional(),
2345
- prefsExtra: strMap.optional(),
2346
- gameArgs: strArray.optional(),
2347
- dockerArgs: strArray.optional()
2348
- });
2349
- var dynamicModEntry = obj({
2350
- match: str,
2351
- first: strArray.optional(),
2352
- sort: oneOf(["alpha", "none"]).optional(),
2353
- minMatches: num.optional()
2354
- });
2355
- var objectModEntry = obj({
2356
- id: str,
2357
- workshop: num.optional(),
2358
- path: str.optional(),
2359
- optional: bool.optional()
2360
- });
2361
- var modEntry = z.unknown().check((ctx) => {
2362
- const value = ctx.value;
2363
- if (typeof value === "string") {
2364
- if (value.trim() === "")
2365
- ctx.issues.push({ code: "custom", message: "mod entry is empty", input: value });
2366
- return;
2367
- }
2368
- if (!isObj(value)) {
2369
- ctx.issues.push({ code: "custom", message: "expected a packageId string or an object", input: value });
2213
+
2214
+ // src/docker/spec.ts
2215
+ var CONTAINER_RUNTIME_DIR = "/tmp/xdg";
2216
+ var CONTAINER_LOG_DIR = "/logs";
2217
+ var X11_SOCKET_DIR = "/tmp/.X11-unix";
2218
+ var CONTAINER_XAUTHORITY = "/tmp/xauth";
2219
+ var CONTAINER_XDG_DIR = "/xdg";
2220
+ var RUNTIME_DIR_SIZE = "64m";
2221
+ var HOME_SIZE = "64m";
2222
+ var MASK_SIZE = "1m";
2223
+ function refuseProtonHeaded(game, mode, image) {
2224
+ if (image?.launcher !== "proton" || mode !== "headed")
2370
2225
  return;
2226
+ throw new GamecrateError(`${game}: a proton image only runs offscreen`, Exit.Config, 'relaunch with --mode headless, or use a variant whose gamecrate.launcher is "direct"');
2227
+ }
2228
+ function launchCommand(plan, executable, proton) {
2229
+ const { gameConfig: game, settings } = plan;
2230
+ if (proton) {
2231
+ return ["run-headless-windows", winPath(join3(game.gameFiles.container, basename2(executable)))];
2371
2232
  }
2372
- const schema = value["match"] !== undefined ? dynamicModEntry : objectModEntry;
2373
- const result = schema.safeParse(value);
2374
- if (result.success)
2233
+ if (plan.mode === "headed")
2234
+ return [executable];
2235
+ return ["xvfb-run", "-a", `--server-args=-screen 0 ${settings.width}x${settings.height}x24`, executable];
2236
+ }
2237
+ function appendGameArgs(command, mounts, env, plan, proton) {
2238
+ const { gameConfig: game } = plan;
2239
+ if (game.dataDir.mode === "arg") {
2240
+ const arg = validateDataDirArg(game.dataDir);
2241
+ const eq = arg.indexOf("=");
2242
+ command.push(proton ? `${arg.slice(0, eq + 1)}${winPath(arg.slice(eq + 1))}` : arg);
2243
+ } else
2244
+ Object.assign(env, game.dataDir.env);
2245
+ if (game.logFile.mode !== "arg")
2375
2246
  return;
2376
- for (const issue of result.error.issues)
2377
- ctx.issues.push({ ...issue, input: value });
2378
- });
2379
- var profile = obj({
2380
- mods: z.array(modEntry, { error: "expected an array" }).optional(),
2381
- extends: str.optional(),
2382
- exclude: strArray.optional(),
2383
- includeBase: bool.optional(),
2384
- autoDependencies: bool.optional(),
2385
- settings: settings.optional(),
2386
- instances: z.record(z.string(), obj({ worktree: str.optional(), settings: settings.optional() }), {
2387
- error: "expected an object"
2388
- }).optional(),
2389
- alias: str.optional(),
2390
- aliases: strArray.optional(),
2391
- description: str.optional(),
2392
- gameVersion: str.optional(),
2393
- image: str.optional(),
2394
- detach: bool.optional(),
2395
- replace: bool.optional(),
2396
- build: oneOf(["auto", "always", "never"]).optional()
2397
- }).check((ctx) => {
2398
- const v = ctx.value;
2399
- if (v.alias !== undefined && (v.extends !== undefined || v.mods !== undefined)) {
2400
- ctx.issues.push({
2401
- code: "custom",
2402
- message: 'an alias profile cannot also declare "mods" or "extends"',
2403
- input: v
2247
+ mounts.push({ type: "bind", source: hostPath(plan.runDirHost), target: CONTAINER_LOG_DIR });
2248
+ const log = `${CONTAINER_LOG_DIR}/Player.log`;
2249
+ command.push(game.logFile.arg, proton ? winPath(log) : log);
2250
+ }
2251
+ function buildRunSpec(plan, modMounts, identity, image) {
2252
+ const { gameConfig: game, settings } = plan;
2253
+ const headed = plan.mode === "headed";
2254
+ const mounts = [];
2255
+ const env = {
2256
+ HOME: identity.home,
2257
+ USER: identity.user,
2258
+ LOGNAME: identity.user
2259
+ };
2260
+ addGameFiles(mounts, plan);
2261
+ addStage(mounts, plan, modMounts);
2262
+ const proton = image?.launcher === "proton";
2263
+ const executable = image?.executable ?? game.executable;
2264
+ refuseProtonHeaded(plan.game, plan.mode, image);
2265
+ const command = launchCommand(plan, executable, proton);
2266
+ if (proton) {
2267
+ Object.assign(env, {
2268
+ SCREEN: `${settings.width}x${settings.height}x24`,
2269
+ DESKTOP: `${settings.width}x${settings.height}`,
2270
+ STEAM_COMPAT_DATA_PATH: `${CONTAINER_XDG_DIR}/proton`
2404
2271
  });
2405
2272
  }
2406
- });
2407
- var libraryEntry = obj({
2408
- workshop: num.optional(),
2409
- path: str.optional(),
2410
- git: str.optional(),
2411
- branch: str.optional(),
2412
- tag: str.optional(),
2413
- commit: str.optional(),
2414
- subdir: str.optional()
2415
- }).check((ctx) => {
2416
- const v = ctx.value;
2417
- const push = (message, path) => {
2418
- ctx.issues.push({ code: "custom", message, input: v, ...path === undefined ? {} : { path } });
2419
- };
2420
- const sources = ["workshop", "path", "git"].filter((k) => v[k] !== undefined);
2421
- if (sources.length === 0)
2422
- push('library entry needs a "workshop" id, a "path", or a "git" url');
2423
- if (sources.length > 1)
2424
- push(`library entry takes only one of ${sources.join(", ")}`);
2425
- const refs = ["branch", "tag", "commit"].filter((k) => v[k] !== undefined);
2426
- if (refs.length > 1)
2427
- push(`library entry takes only one of branch, tag or commit, got ${refs.join(", ")}`);
2428
- if (v["git"] === undefined) {
2429
- for (const key of [...refs, ...v["subdir"] === undefined ? [] : ["subdir"]]) {
2430
- push(`"${key}" needs a "git" url`, [key]);
2431
- }
2273
+ appendGameArgs(command, mounts, env, plan, proton);
2274
+ addScratch(mounts, env, plan, identity);
2275
+ if (headed)
2276
+ addSession(mounts, env, plan);
2277
+ const deviceCgroupRules = [];
2278
+ if (settings.input) {
2279
+ mounts.push({ type: "bind", source: "/dev/input", target: "/dev/input", readonly: true });
2280
+ deviceCgroupRules.push("c 13:* rmw");
2432
2281
  }
2433
- const subdir = v["subdir"];
2434
- if (typeof subdir === "string" && (subdir.startsWith("/") || subdir.split("/").includes(".."))) {
2435
- push('"subdir" must be a relative path inside the repo, with no ".." segment', ["subdir"]);
2282
+ const devices = [];
2283
+ if (settings.gpu)
2284
+ devices.push("nvidia.com/gpu=all");
2285
+ Object.assign(env, glEnv(settings.gpu));
2286
+ command.push(...settings.gameArgs ?? []);
2287
+ return {
2288
+ image: game.image.ref,
2289
+ name: containerName(plan),
2290
+ labels: {
2291
+ "gamecrate.game": plan.game,
2292
+ "gamecrate.profile": plan.profile,
2293
+ ...plan.instance === undefined ? {} : { "gamecrate.instance": plan.instance }
2294
+ },
2295
+ identity,
2296
+ env,
2297
+ mounts,
2298
+ devices,
2299
+ deviceCgroupRules,
2300
+ network: settings.network,
2301
+ memory: settings.memory,
2302
+ memorySwap: settings.memory,
2303
+ cpus: settings.cpus,
2304
+ pidsLimit: settings.pidsLimit,
2305
+ ulimits: ["core=0"],
2306
+ workdir: game.gameFiles.container,
2307
+ ...headed && settings.display === "x11" ? { hostname: hostname() } : {},
2308
+ command,
2309
+ extraArgs: [...settings.dockerArgs ?? []]
2310
+ };
2311
+ }
2312
+ function addGameFiles(mounts, plan) {
2313
+ const { gameFiles } = plan.gameConfig;
2314
+ if (gameFiles.source !== "mount")
2315
+ return;
2316
+ if (!gameFiles.host) {
2317
+ throw new GamecrateError(`gameFiles.source is "mount" but no host path is set for ${plan.game}`, Exit.Config);
2436
2318
  }
2437
- });
2438
- function repeats(entries, key) {
2439
- const seen = new Set;
2440
- const found = [];
2441
- entries.forEach((entry, index) => {
2442
- const name = entry?.[key];
2443
- if (typeof name !== "string")
2444
- return;
2445
- if (seen.has(name))
2446
- found.push({ index, name });
2447
- else
2448
- seen.add(name);
2449
- });
2450
- return found;
2319
+ mounts.push({ type: "bind", source: hostPath(gameFiles.host), target: gameFiles.container, readonly: true });
2451
2320
  }
2452
- function describe(value) {
2453
- return typeof value === "string" ? value : JSON.stringify(value);
2321
+ function addStage(mounts, plan, modMounts) {
2322
+ const game = plan.gameConfig;
2323
+ mounts.push({ type: "bind", source: hostPath(plan.stageDirHost), target: game.modsDir.container, readonly: true });
2324
+ for (const mount of modMounts) {
2325
+ mounts.push(mount.type === "bind" ? { ...mount, readonly: true } : mount);
2326
+ }
2327
+ mounts.push({ type: "bind", source: hostPath(plan.dataDirHost), target: game.dataDir.container });
2454
2328
  }
2455
- var TAG_COMPONENT = /^\w[\w.-]*$/;
2456
- function steamBuildRules(ctx) {
2457
- const push = (message, path, suggestion) => {
2458
- ctx.issues.push({
2459
- code: "custom",
2460
- message,
2461
- path,
2462
- input: ctx.value,
2463
- ...suggestion === undefined ? {} : { params: { suggestion } }
2464
- });
2465
- };
2466
- const branches = ctx.value["branches"];
2467
- const variants = ctx.value["variants"];
2468
- if (Array.isArray(variants) && variants.length === 0) {
2469
- push("steamBuild.variants cannot be empty", ["variants"]);
2329
+ function addScratch(mounts, env, plan, identity) {
2330
+ const { uid, gid } = identity;
2331
+ for (const target of plan.gameConfig.modsDir.mask ?? []) {
2332
+ mounts.push({ type: "tmpfs", target, size: MASK_SIZE, uid, gid, mode: "755" });
2470
2333
  }
2471
- if (Array.isArray(branches)) {
2472
- if (branches.length === 0)
2473
- push("steamBuild.branches cannot be empty", ["branches"]);
2474
- for (const dup of repeats(branches, "name")) {
2475
- push(`duplicate branch name "${dup.name}"`, ["branches", dup.index, "name"]);
2476
- }
2477
- branches.forEach((branch, index) => {
2478
- const name = branch?.["name"];
2479
- if (typeof name === "string" && !TAG_COMPONENT.test(name)) {
2480
- push(`branch name "${name}" must match ${TAG_COMPONENT.source}`, ["branches", index, "name"]);
2481
- }
2482
- const tags = branch?.["tags"];
2483
- if (!Array.isArray(tags))
2484
- return;
2485
- tags.forEach((tag, at) => {
2486
- if (typeof tag !== "string" || TAG_COMPONENT.test(tag))
2487
- return;
2488
- push(`branch tag "${String(tag)}" must match ${TAG_COMPONENT.source}`, ["branches", index, "tags", at]);
2489
- });
2490
- });
2334
+ if (uid !== 0) {
2335
+ mounts.push({ type: "tmpfs", target: identity.home, size: HOME_SIZE, uid, gid, mode: "700" });
2491
2336
  }
2492
- if (!Array.isArray(variants))
2337
+ mounts.push({ type: "tmpfs", target: CONTAINER_RUNTIME_DIR, size: RUNTIME_DIR_SIZE, uid, gid, mode: "700" });
2338
+ env.XDG_RUNTIME_DIR = CONTAINER_RUNTIME_DIR;
2339
+ mounts.push({ type: "bind", source: hostPath(plan.configDirHost), target: CONTAINER_XDG_DIR });
2340
+ env.XDG_CONFIG_HOME = `${CONTAINER_XDG_DIR}/config`;
2341
+ env.XDG_CACHE_HOME = `${CONTAINER_XDG_DIR}/cache`;
2342
+ env.XDG_DATA_HOME ??= `${CONTAINER_XDG_DIR}/data`;
2343
+ }
2344
+ function addSession(mounts, env, plan) {
2345
+ const { settings } = plan;
2346
+ if (settings.display === "x11")
2347
+ addX11(mounts, env);
2348
+ else
2349
+ addWayland(mounts, env);
2350
+ if (!settings.audio)
2493
2351
  return;
2494
- for (const dup of repeats(variants, "name")) {
2495
- push(`duplicate variant name "${dup.name}"`, ["variants", dup.index, "name"]);
2352
+ for (const socket of audioSockets()) {
2353
+ mounts.push({ type: "bind", source: socket.source, target: `${CONTAINER_RUNTIME_DIR}/${socket.name}` });
2496
2354
  }
2497
- variants.forEach((variant, index) => {
2498
- const v = variant;
2499
- const base = v?.["base"];
2500
- if (base !== "xvfb" && base !== "proton")
2501
- return;
2502
- const depot = v?.["depot"] ?? "linux";
2503
- if (depot === "macos") {
2504
- push("a macos depot cannot be runnable", ["variants", index, "base"], 'set base to "none"; no macos container runtime exists');
2505
- return;
2506
- }
2507
- const wants = depot === "windows" ? "proton" : "xvfb";
2508
- if (base === wants)
2509
- return;
2510
- push(`a ${describe(depot)} depot cannot run on the "${base}" base`, ["variants", index, "base"], depot === "windows" ? 'set base to "proton"; it is the only base with wine' : 'set base to "xvfb", or set depot to "windows" if the image should run under wine');
2511
- });
2355
+ env.PULSE_SERVER = `unix:${CONTAINER_RUNTIME_DIR}/pulse/native`;
2512
2356
  }
2513
- var steamBuildSchema = obj({
2514
- branches: z.array(obj({
2515
- name: str,
2516
- password: bool.optional(),
2517
- tags: strArray.optional(),
2518
- executable: z.record(str, str).optional()
2519
- }), {
2520
- error: "expected an array"
2521
- }),
2522
- variants: z.array(obj({
2523
- name: str,
2524
- depot: oneOf(["linux", "windows", "macos"]).optional(),
2525
- base: oneOf(["xvfb", "proton", "none"]),
2526
- include: strArray,
2527
- executable: str.optional()
2528
- }), { error: "expected an array" })
2529
- }).check(steamBuildRules);
2530
- var game = obj({
2531
- gameFiles: obj({ source: oneOf(["mount", "image"]), host: str.optional(), container: str }).check(requiredWhen("host", (v) => v["source"] === "mount")),
2532
- dataDir: obj({
2533
- container: str,
2534
- mode: oneOf(["arg", "env"]),
2535
- arg: str.optional(),
2536
- env: strMap.optional()
2537
- }).check(requiredWhen("arg", (v) => v["mode"] === "arg"), requiredWhen("env", (v) => v["mode"] === "env")),
2538
- modsDir: obj({ container: str, mask: strArray.optional() }),
2539
- logFile: obj({ mode: oneOf(["arg", "copy-out"]), arg: str.optional(), from: str.optional() }).check(requiredWhen("arg", (v) => v["mode"] === "arg"), requiredWhen("from", (v) => v["mode"] === "copy-out")),
2540
- image: obj({
2541
- ref: str,
2542
- acquire: oneOf(["pull", "build"]),
2543
- context: str.optional(),
2544
- updates: obj({ check: bool.optional(), everyHours: num.optional() }).optional()
2545
- }).check(requiredWhen("context", (v) => v["acquire"] === "build")),
2546
- executable: str,
2547
- managed: strArray.optional(),
2548
- steamAppId: num,
2549
- workshopRoot: z.union([z.string(), z.null()], { error: "expected a string or null" }),
2550
- scanRoots: z.array(obj({ path: str, maxDepth: num, exclude: strArray.optional() }), {
2551
- error: "expected an array"
2552
- }),
2553
- manifest: obj({ file: str }),
2554
- modsConfig: obj({ file: str }),
2555
- prefs: obj({ file: str }),
2556
- version: obj({ file: str }),
2557
- steamBuild: steamBuildSchema,
2558
- saveExtensions: strArray,
2559
- core: str,
2560
- dlc: strArray,
2561
- preCore: strArray.optional(),
2562
- base: strArray.optional(),
2563
- library: z.record(z.string(), libraryEntry, { error: "expected an object" }).optional(),
2564
- modes: z.array(modeName, { error: "expected a non-empty array" }).min(1, {
2565
- error: "expected a non-empty array"
2566
- }),
2567
- aliases: strMap.optional(),
2568
- settings: settings.optional(),
2569
- ignoresWmDelete: bool.optional(),
2570
- profiles: z.record(z.string(), profile, { error: "expected an object" })
2571
- });
2572
- var root = obj({
2573
- plugins: strArray.optional(),
2574
- dataRoot: str,
2575
- defaults: obj({ settings: settings.optional() }).optional(),
2576
- steamcmd: obj({ path: str.optional() }).optional(),
2577
- games: z.unknown()
2578
- });
2579
- function validateConfig(cfg) {
2580
- const problems = [];
2581
- if (!isObj(cfg)) {
2582
- problems.push({ where: "", message: "expected the config to be an object" });
2583
- return { config: { dataRoot: "", games: {} }, problems };
2357
+ function addX11(mounts, env) {
2358
+ const x11 = x11Session();
2359
+ if (!x11)
2360
+ return;
2361
+ mounts.push({ type: "bind", source: X11_SOCKET_DIR, target: X11_SOCKET_DIR });
2362
+ env.DISPLAY = x11.display;
2363
+ env.XDG_SESSION_TYPE = "x11";
2364
+ env.SDL_VIDEODRIVER = "x11";
2365
+ env.QT_QPA_PLATFORM = "xcb";
2366
+ if (!x11.xauthority)
2367
+ return;
2368
+ mounts.push({ type: "bind", source: x11.xauthority, target: CONTAINER_XAUTHORITY, readonly: true });
2369
+ env.XAUTHORITY = CONTAINER_XAUTHORITY;
2370
+ }
2371
+ function addWayland(mounts, env) {
2372
+ const wayland = waylandSocket();
2373
+ if (!wayland)
2374
+ return;
2375
+ mounts.push({ type: "bind", source: wayland.source, target: `${CONTAINER_RUNTIME_DIR}/${wayland.name}` });
2376
+ env.WAYLAND_DISPLAY = wayland.name;
2377
+ env.XDG_SESSION_TYPE = "wayland";
2378
+ env.SDL_VIDEODRIVER = "wayland";
2379
+ env.QT_QPA_PLATFORM = "wayland";
2380
+ }
2381
+ function containerName(plan) {
2382
+ const base = `gamecrate-${plan.game}-${plan.profile}`;
2383
+ return plan.instance === undefined ? base : `${base}-${plan.instance}`;
2384
+ }
2385
+ function windowIcon(plan, configDir) {
2386
+ const named = resolveProfile(plan.gameConfig, plan.profile).windowIcon;
2387
+ if (named === undefined)
2388
+ return;
2389
+ const path = expandHome(named);
2390
+ return isAbsolute2(path) ? path : resolve3(configDir, path);
2391
+ }
2392
+ function windowTitle(plan) {
2393
+ const own = resolveProfile(plan.gameConfig, plan.profile).windowTitle;
2394
+ const base = own ?? `${plan.game} ${plan.profile}`;
2395
+ return plan.instance === undefined ? base : `${base} / ${plan.instance}`;
2396
+ }
2397
+ function toDockerArgs(spec) {
2398
+ const args = ["run", "--rm", "--init", "--name", spec.name];
2399
+ if (spec.hostname !== undefined)
2400
+ args.push("--hostname", spec.hostname);
2401
+ for (const [key, value] of Object.entries(spec.labels))
2402
+ args.push("--label", `${key}=${value}`);
2403
+ args.push("--user", `${spec.identity.uid}:${spec.identity.gid}`);
2404
+ for (const [key, value] of Object.entries(spec.env))
2405
+ args.push("--env", `${key}=${value}`);
2406
+ for (const mount of spec.mounts)
2407
+ args.push(...mountArgs(mount));
2408
+ for (const device of spec.devices)
2409
+ args.push("--device", device);
2410
+ for (const rule of spec.deviceCgroupRules)
2411
+ args.push("--device-cgroup-rule", rule);
2412
+ for (const ulimit of spec.ulimits)
2413
+ args.push("--ulimit", ulimit);
2414
+ args.push("--network", spec.network, "--memory", spec.memory, "--memory-swap", spec.memorySwap, "--cpus", String(spec.cpus), "--pids-limit", String(spec.pidsLimit), "--workdir", spec.workdir, ...spec.extraArgs, "--pull=never");
2415
+ const [entrypoint, ...rest] = spec.command;
2416
+ if (entrypoint !== undefined)
2417
+ args.push("--entrypoint", entrypoint);
2418
+ args.push(spec.image, ...rest);
2419
+ return args;
2420
+ }
2421
+ function mountArgs(mount) {
2422
+ if (mount.type === "tmpfs") {
2423
+ const opts = ["rw"];
2424
+ if (mount.uid !== undefined)
2425
+ opts.push(`uid=${mount.uid}`);
2426
+ if (mount.gid !== undefined)
2427
+ opts.push(`gid=${mount.gid}`);
2428
+ if (mount.mode)
2429
+ opts.push(`mode=${mount.mode}`);
2430
+ opts.push(`size=${mount.size ?? RUNTIME_DIR_SIZE}`);
2431
+ return ["--tmpfs", `${mount.target}:${opts.join(",")}`];
2584
2432
  }
2585
- collect3(problems, "", root, cfg);
2586
- const games = cfg["games"];
2587
- if (!isObj(games)) {
2588
- problems.push({ where: "/games", message: 'missing required key "games", or it is not an object' });
2589
- return { config: cfg, problems };
2433
+ if (!mount.source) {
2434
+ throw new GamecrateError(`bind mount at ${mount.target} has no source`, Exit.Config);
2590
2435
  }
2591
- for (const [name, entry] of Object.entries(games)) {
2592
- collect3(problems, `/games/${esc(name)}`, game, entry);
2436
+ const fields = [`type=bind`, `src=${mount.source}`, `dst=${mount.target}`];
2437
+ if (mount.readonly)
2438
+ fields.push("readonly");
2439
+ return ["--mount", fields.map(csvField).join(",")];
2440
+ }
2441
+ function csvField(field) {
2442
+ if (!field.includes(",") && !field.includes('"'))
2443
+ return field;
2444
+ return `"${field.replaceAll('"', '""')}"`;
2445
+ }
2446
+ function winPath(unix) {
2447
+ return `Z:${unix.replaceAll("/", "\\")}`;
2448
+ }
2449
+ function validateDataDirArg(dataDir) {
2450
+ if (dataDir.container.includes("=")) {
2451
+ throw new GamecrateError(`container data path contains "=": ${dataDir.container}`, Exit.Config, "RimWorld silently ignores -savedatafolder when the argv element does not split into exactly two parts, and the save is lost with --rm.");
2593
2452
  }
2594
- crossReference(problems, games);
2595
- return { config: cfg, problems };
2453
+ const parts = dataDir.arg.split("=");
2454
+ if (parts.length !== 2) {
2455
+ throw new GamecrateError(`dataDir.arg must contain exactly one "=": ${dataDir.arg}`, Exit.Config);
2456
+ }
2457
+ if (trimSlash(parts[1] ?? "") !== trimSlash(dataDir.container)) {
2458
+ throw new GamecrateError(`dataDir.arg points at ${parts[1]} but the mount target is ${dataDir.container}`, Exit.Config, "The engine would write to a path that is not the mounted data directory.");
2459
+ }
2460
+ return dataDir.arg;
2461
+ }
2462
+ function trimSlash(path) {
2463
+ let end = path.length;
2464
+ while (end > 1 && path[end - 1] === "/")
2465
+ end--;
2466
+ return path.slice(0, end);
2596
2467
  }
2597
- function collect3(problems, prefix, schema, value) {
2598
- const result = schema.safeParse(value);
2599
- if (result.success)
2600
- return;
2601
- for (const issue of result.error.issues) {
2602
- if (issue.code === "unrecognized_keys") {
2603
- const hints = hintsFor(issue.message);
2604
- issue.keys.forEach((key, i) => {
2605
- const problem = {
2606
- where: `${prefix}${pointer(issue.path)}/${esc(key)}`,
2607
- message: `unknown key "${key}"`
2608
- };
2609
- const hint = hints[i];
2610
- if (hint != null)
2611
- problem.suggestion = `did you mean "${hint}"?`;
2612
- problems.push(problem);
2613
- });
2614
- continue;
2615
- }
2616
- const key = issue.path.at(-1);
2617
- const missing = typeof key === "string" && valueAt(value, issue.path) === undefined;
2618
- const problem = {
2619
- where: `${prefix}${pointer(issue.path)}`,
2620
- message: missing ? `missing required key "${key}"` : issue.message
2621
- };
2622
- const hint = issue.params?.suggestion;
2623
- if (hint !== undefined)
2624
- problem.suggestion = hint;
2625
- problems.push(problem);
2468
+ function glEnv(gpu) {
2469
+ if (!gpu)
2470
+ return { LIBGL_ALWAYS_SOFTWARE: "1", GALLIUM_DRIVER: "llvmpipe" };
2471
+ const env = { LIBGL_ALWAYS_SOFTWARE: "0", GALLIUM_DRIVER: "" };
2472
+ if (hasNvidia()) {
2473
+ env.__GLX_VENDOR_LIBRARY_NAME = "nvidia";
2474
+ env.__NV_PRIME_RENDER_OFFLOAD = "1";
2626
2475
  }
2476
+ return env;
2627
2477
  }
2628
- function pointer(path) {
2629
- return path.map((segment) => `/${esc(String(segment))}`).join("");
2478
+ function hasNvidia() {
2479
+ return existsSync("/dev/nvidiactl") || existsSync("/etc/cdi/nvidia.yaml") || existsSync("/usr/share/vulkan/icd.d/nvidia_icd.json");
2630
2480
  }
2631
- function valueAt(root_, path) {
2632
- let current = root_;
2633
- for (const segment of path) {
2634
- if (current === null || typeof current !== "object")
2635
- return;
2636
- current = own(current, String(segment));
2481
+ function x11Session() {
2482
+ const display = process.env.DISPLAY;
2483
+ if (!display)
2484
+ return null;
2485
+ const cookie = process.env.XAUTHORITY ?? join3(homedir2(), ".Xauthority");
2486
+ return { display, xauthority: existsSync(cookie) ? cookie : null };
2487
+ }
2488
+ function waylandSocket() {
2489
+ const display = process.env.WAYLAND_DISPLAY;
2490
+ const runtime = process.env.XDG_RUNTIME_DIR;
2491
+ if (!display)
2492
+ return null;
2493
+ let source = null;
2494
+ if (display.startsWith("/"))
2495
+ source = display;
2496
+ else if (runtime)
2497
+ source = join3(runtime, display);
2498
+ if (!source || !existsSync(source))
2499
+ return null;
2500
+ return { source, name: basename2(source) };
2501
+ }
2502
+ function audioSockets() {
2503
+ const runtime = process.env.XDG_RUNTIME_DIR;
2504
+ if (!runtime)
2505
+ return [];
2506
+ const found = [];
2507
+ for (const name of ["pipewire-0", "pulse/native"]) {
2508
+ const source = join3(runtime, name);
2509
+ if (existsSync(source))
2510
+ found.push({ source, name });
2637
2511
  }
2638
- return current;
2512
+ return found;
2639
2513
  }
2640
- function crossReference(p, games) {
2641
- const containers = new Map;
2642
- for (const [gameName, game_] of Object.entries(games)) {
2643
- const where = `/games/${esc(gameName)}`;
2644
- checkName(p, where, gameName, "game");
2645
- if (!isObj(game_))
2646
- continue;
2647
- checkVariantNames(p, where, game_);
2648
- const profiles = game_["profiles"];
2649
- if (!isObj(profiles))
2650
- continue;
2651
- for (const [name, prof] of Object.entries(profiles)) {
2652
- const w = `${where}/profiles/${esc(name)}`;
2653
- checkName(p, w, name, "profile");
2654
- if (isObj(prof))
2655
- checkProfile(p, w, name, prof, profiles);
2656
- }
2657
- checkCollisions(p, where, gameName, profiles, containers);
2514
+ function hostPath(path) {
2515
+ try {
2516
+ return realpathSync(path);
2517
+ } catch {
2518
+ return path;
2658
2519
  }
2659
2520
  }
2660
- function checkVariantNames(p, where, game_) {
2661
- const steamBuild = game_["steamBuild"];
2662
- const variants = isObj(steamBuild) ? steamBuild["variants"] : undefined;
2663
- if (!Array.isArray(variants))
2664
- return;
2665
- variants.forEach((variant, index) => {
2666
- const name = isObj(variant) ? variant["name"] : undefined;
2667
- if (typeof name !== "string")
2668
- return;
2669
- checkName(p, `${where}/steamBuild/variants/${index}/name`, name, "variant");
2670
- });
2521
+
2522
+ // src/docker/run.ts
2523
+ function spawnArgv(argv, stdio, detached = false) {
2524
+ return spawn(argv[0], argv.slice(1), { stdio, detached });
2671
2525
  }
2672
- function checkProfile(p, w, name, prof, profiles) {
2673
- const names = Object.keys(profiles);
2674
- checkExtends(p, w, prof, profiles, names);
2675
- checkAlias(p, w, name, prof, profiles, names);
2676
- checkAliases(p, w, prof, names);
2677
- checkInstances(p, w, prof);
2526
+ function exited(proc) {
2527
+ return new Promise((resolve, reject) => {
2528
+ proc.once("error", reject);
2529
+ proc.once("close", (code) => resolve(code ?? 1));
2530
+ });
2678
2531
  }
2679
- function checkExtends(p, w, prof, profiles, names) {
2680
- const parent = prof["extends"];
2681
- if (typeof parent !== "string" || resolves(profiles, parent))
2682
- return;
2683
- const prob = { where: `${w}/extends`, message: `extends unknown profile "${parent}"` };
2684
- const hint = suggest(parent, names);
2685
- if (hint)
2686
- prob.suggestion = `did you mean "${hint}"?`;
2687
- p.push(prob);
2532
+ async function collect3(stream) {
2533
+ const chunks = [];
2534
+ for await (const chunk of stream)
2535
+ chunks.push(chunk);
2536
+ return Buffer.concat(chunks).toString("utf8");
2688
2537
  }
2689
- function checkAlias(p, w, name, prof, profiles, names) {
2690
- const alias = prof["alias"];
2691
- if (typeof alias !== "string")
2692
- return;
2693
- if (!resolves(profiles, alias)) {
2694
- const prob = { where: `${w}/alias`, message: `alias of unknown profile "${alias}"` };
2695
- const hint = suggest(alias, [...names, "modless"]);
2696
- if (hint)
2697
- prob.suggestion = `did you mean "${hint}"?`;
2698
- p.push(prob);
2538
+ async function capture(argv) {
2539
+ try {
2540
+ const proc = spawnArgv(argv, ["ignore", "pipe", "pipe"]);
2541
+ const [stdout, stderr, code] = await Promise.all([
2542
+ collect3(proc.stdout),
2543
+ collect3(proc.stderr),
2544
+ exited(proc)
2545
+ ]);
2546
+ return { code, stdout, stderr };
2547
+ } catch (error) {
2548
+ return { code: 127, stdout: "", stderr: error instanceof Error ? error.message : String(error) };
2699
2549
  }
2700
- if (alias.toLowerCase() === name.toLowerCase()) {
2701
- p.push({ where: `${w}/alias`, message: "a profile cannot alias itself" });
2550
+ }
2551
+ async function captureLive(argv, env) {
2552
+ const proc = spawn(argv[0], argv.slice(1), { stdio: ["ignore", "pipe", "pipe"], env });
2553
+ const chunks = [];
2554
+ const keep = async (stream) => {
2555
+ for await (const chunk of stream) {
2556
+ process.stderr.write(chunk);
2557
+ chunks.push(chunk);
2558
+ }
2559
+ };
2560
+ const [, , code] = await Promise.all([keep(proc.stdout), keep(proc.stderr), exited(proc)]);
2561
+ return { code, text: Buffer.concat(chunks).toString("utf8") };
2562
+ }
2563
+ var STDOUT_LOG = "stdout.log";
2564
+ var MARKER_POLL_MS = 200;
2565
+ async function runContainer(spec, opts) {
2566
+ const stopTimeout = opts.stopTimeoutSeconds ?? 10;
2567
+ mkdirSync(opts.logDir, { recursive: true });
2568
+ const sink = createWriteStream(join4(opts.logDir, STDOUT_LOG));
2569
+ const proc = spawnArgv(["docker", ...toDockerArgs(spec)], ["inherit", "pipe", "pipe"]);
2570
+ let interrupted = false;
2571
+ const onSignal = () => {
2572
+ if (interrupted)
2573
+ return;
2574
+ interrupted = true;
2575
+ stopContainer(spec.name, stopTimeout);
2576
+ };
2577
+ process.on("SIGINT", onSignal);
2578
+ process.on("SIGTERM", onSignal);
2579
+ const code = exited(proc);
2580
+ try {
2581
+ await Promise.all([
2582
+ tee(proc.stdout, sink, process.stdout),
2583
+ tee(proc.stderr, sink, process.stderr)
2584
+ ]);
2585
+ const status = await code;
2586
+ return interrupted ? Exit.Interrupted : status;
2587
+ } finally {
2588
+ process.off("SIGINT", onSignal);
2589
+ process.off("SIGTERM", onSignal);
2590
+ await new Promise((resolve) => sink.end(resolve));
2702
2591
  }
2703
2592
  }
2704
- function checkAliases(p, w, prof, names) {
2705
- const aliases = prof["aliases"];
2706
- if (!Array.isArray(aliases))
2707
- return;
2708
- for (const [i, entry] of aliases.entries()) {
2709
- if (typeof entry !== "string")
2710
- continue;
2711
- const at = `${w}/aliases/${i}`;
2712
- checkName(p, at, entry, "profile alias");
2713
- if (names.some((k) => k.toLowerCase() === entry.toLowerCase())) {
2714
- p.push({ where: at, message: `alias "${entry}" is already a profile name` });
2593
+ var STOP_TIMEOUT_SECONDS = 10;
2594
+ async function stopContainer(name, timeoutSeconds) {
2595
+ const proc = spawnArgv(["docker", "stop", "--timeout", String(timeoutSeconds), name], "ignore");
2596
+ await exited(proc).catch(() => {});
2597
+ }
2598
+ async function waitForMarker(sources, marker, timeoutSeconds) {
2599
+ const deadline = Date.now() + timeoutSeconds * 1000;
2600
+ const carry = Math.max(marker.length - 1, 0);
2601
+ const seen = new Map;
2602
+ const startedAt = Date.now();
2603
+ while (true) {
2604
+ for (const path of await expandSources(sources)) {
2605
+ let state = seen.get(path);
2606
+ if (state === undefined) {
2607
+ state = { offset: await staleSize(path, startedAt), tail: "", decoder: new TextDecoder };
2608
+ seen.set(path, state);
2609
+ }
2610
+ if (await scan(path, state, marker, carry))
2611
+ return true;
2715
2612
  }
2613
+ if (Date.now() >= deadline)
2614
+ return false;
2615
+ await sleep(Math.min(MARKER_POLL_MS, Math.max(deadline - Date.now(), 0)));
2716
2616
  }
2717
2617
  }
2718
- function checkInstances(p, w, prof) {
2719
- const instances = prof["instances"];
2720
- if (!isObj(instances))
2721
- return;
2722
- for (const instance of Object.keys(instances)) {
2723
- checkName(p, `${w}/instances/${esc(instance)}`, instance, "instance");
2618
+ async function staleSize(path, startedAt) {
2619
+ return stat(path).then((info) => info.mtimeMs < startedAt ? info.size : 0, () => 0);
2620
+ }
2621
+ async function scan(path, state, marker, carry) {
2622
+ const handle = await open(path, "r").catch(() => null);
2623
+ if (handle === null)
2624
+ return false;
2625
+ try {
2626
+ const { size } = await handle.stat();
2627
+ if (size < state.offset) {
2628
+ state.offset = 0;
2629
+ state.tail = "";
2630
+ state.decoder = new TextDecoder;
2631
+ }
2632
+ if (size <= state.offset)
2633
+ return false;
2634
+ const buffer = Buffer.alloc(size - state.offset);
2635
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, state.offset);
2636
+ state.offset += bytesRead;
2637
+ const text = state.tail + state.decoder.decode(buffer.subarray(0, bytesRead), { stream: true });
2638
+ if (text.includes(marker))
2639
+ return true;
2640
+ state.tail = carry > 0 ? text.slice(-carry) : "";
2641
+ return false;
2642
+ } catch {
2643
+ return false;
2644
+ } finally {
2645
+ await handle.close().catch(() => {});
2724
2646
  }
2725
2647
  }
2726
- function resolves(profiles, name) {
2727
- if (name.toLowerCase() === "modless")
2728
- return true;
2729
- return profileKey({ profiles }, name) !== undefined;
2730
- }
2731
- function checkCollisions(p, where, gameName, profiles, containers) {
2732
- const names = new Map;
2733
- const aliasOwners = new Map;
2734
- const prefix = `${gameName.toLowerCase()}-`;
2735
- for (const [name, prof] of Object.entries(profiles)) {
2736
- const w = `${where}/profiles/${esc(name)}`;
2737
- const lower = name.toLowerCase();
2738
- const twin = names.get(lower);
2739
- if (twin !== undefined) {
2740
- p.push({
2741
- where: w,
2742
- message: `profile "${name}" differs from "${twin}" only in case, so both share one data directory`
2743
- });
2648
+ async function expandSources(sources) {
2649
+ const out = [];
2650
+ for (const source of sources) {
2651
+ const info = await stat(source).catch(() => null);
2652
+ if (info === null) {
2653
+ out.push(source);
2744
2654
  continue;
2745
2655
  }
2746
- names.set(lower, name);
2747
- const clash = containers.get(prefix + lower);
2748
- if (clash !== undefined) {
2749
- p.push({ where: w, message: `container name collides with ${clash}` });
2656
+ if (!info.isDirectory()) {
2657
+ out.push(source);
2750
2658
  continue;
2751
2659
  }
2752
- containers.set(prefix + lower, w);
2753
- if (!isObj(prof))
2754
- continue;
2755
- checkAliasOwners(p, w, prof, name, aliasOwners);
2756
- checkInstanceContainers(p, w, profiles, name, `${prefix}${lower}`, containers);
2660
+ const entries = await readdir2(source).catch(() => []);
2661
+ for (const entry of entries) {
2662
+ if (entry.toLowerCase().endsWith(".log"))
2663
+ out.push(join4(source, entry));
2664
+ }
2757
2665
  }
2666
+ return out;
2758
2667
  }
2759
- function checkAliasOwners(p, w, prof, name, owners) {
2760
- const aliases = prof["aliases"];
2761
- if (!Array.isArray(aliases))
2762
- return;
2763
- for (const [i, entry] of aliases.entries()) {
2764
- if (typeof entry !== "string")
2765
- continue;
2766
- const owner = owners.get(entry.toLowerCase());
2767
- if (owner !== undefined) {
2768
- p.push({
2769
- where: `${w}/aliases/${i}`,
2770
- message: `alias "${entry}" is already declared by profile "${owner}"`
2771
- });
2772
- continue;
2773
- }
2774
- owners.set(entry.toLowerCase(), name);
2668
+ async function tee(stream, sink, mirror) {
2669
+ for await (const chunk of stream) {
2670
+ mirror.write(chunk);
2671
+ sink.write(chunk);
2775
2672
  }
2776
2673
  }
2777
- function checkInstanceContainers(p, w, profiles, name, prefix, containers) {
2778
- const prof = profiles[name];
2779
- if (!isObj(prof))
2674
+
2675
+ // src/mods/staleness.ts
2676
+ import { readdir as readdir3, stat as stat2 } from "node:fs/promises";
2677
+ import { join as join5, relative } from "node:path";
2678
+ var SKIP_DIRS = new Set([".git", ".retired", ".vs", "bin", "node_modules", "obj"]);
2679
+ var ENTRY_LIMIT = 20000;
2680
+ async function scanBuildTimes(dir) {
2681
+ const state = { root: dir, times: { sourceTimes: [] }, budget: ENTRY_LIMIT };
2682
+ await walk(state, dir, false);
2683
+ return state.times;
2684
+ }
2685
+ async function walk(state, current, inAssemblies) {
2686
+ let entries;
2687
+ try {
2688
+ entries = await readdir3(current, { withFileTypes: true });
2689
+ } catch {
2780
2690
  return;
2781
- const declared = prof["instances"];
2782
- for (const instance of instanceNames(profiles, name, prof)) {
2783
- const container = `${prefix}-${instance.toLowerCase()}`;
2784
- const at = isObj(declared) && Object.hasOwn(declared, instance) ? `${w}/instances/${esc(instance)}` : w;
2785
- const first = containers.get(container);
2786
- if (first !== undefined) {
2787
- p.push({
2788
- where: at,
2789
- message: `instance "${instance}" makes a container name that collides with ${first}`
2790
- });
2691
+ }
2692
+ for (const entry of entries) {
2693
+ if (state.budget-- <= 0)
2694
+ return;
2695
+ const path = join5(current, entry.name);
2696
+ if (entry.isDirectory()) {
2697
+ if (!SKIP_DIRS.has(entry.name.toLowerCase())) {
2698
+ await walk(state, path, inAssemblies || entry.name === "Assemblies");
2699
+ }
2791
2700
  continue;
2792
2701
  }
2793
- containers.set(container, at);
2702
+ if (entry.isFile())
2703
+ await record(state, path, entry.name, inAssemblies);
2794
2704
  }
2795
2705
  }
2796
- function instanceNames(profiles, name, prof) {
2706
+ async function record(state, path, name, inAssemblies) {
2707
+ const lower = name.toLowerCase();
2708
+ const isSource = lower.endsWith(".cs");
2709
+ const isAssembly = inAssemblies && lower.endsWith(".dll");
2710
+ if (!isSource && !isAssembly)
2711
+ return;
2712
+ let mtimeMs;
2797
2713
  try {
2798
- const resolved = resolveProfile({ profiles }, name).instances;
2799
- return isObj(resolved) ? Object.keys(resolved) : [];
2714
+ mtimeMs = (await stat2(path)).mtimeMs;
2800
2715
  } catch {
2801
- const declared = prof["instances"];
2802
- return isObj(declared) ? Object.keys(declared) : [];
2716
+ return;
2717
+ }
2718
+ const { times } = state;
2719
+ const found = { path: relative(state.root, path), mtimeMs };
2720
+ if (isSource) {
2721
+ times.sourceTimes.push(mtimeMs);
2722
+ if (mtimeMs > (times.newestSource?.mtimeMs ?? -1))
2723
+ times.newestSource = found;
2724
+ } else if (mtimeMs > (times.newestAssembly?.mtimeMs ?? -1)) {
2725
+ times.newestAssembly = found;
2803
2726
  }
2804
2727
  }
2805
- function checkName(p, where, name, kind) {
2806
- if (RESERVED_NAMES.includes(name.toLowerCase())) {
2807
- p.push({ where, message: `"${name}" is a reserved name and cannot be used as a ${kind} name` });
2728
+ var SKEW_MS = 1000;
2729
+ function newerThan(source, assembly) {
2730
+ return source - assembly > SKEW_MS;
2731
+ }
2732
+ function decideStale(times) {
2733
+ const { newestSource, newestAssembly } = times;
2734
+ if (newestSource === undefined)
2735
+ return false;
2736
+ return newestAssembly === undefined || newerThan(newestSource.mtimeMs, newestAssembly.mtimeMs);
2737
+ }
2738
+ function staleReport(times) {
2739
+ const { newestSource, newestAssembly } = times;
2740
+ if (newestSource === undefined || newestAssembly === undefined)
2741
+ return null;
2742
+ if (!newerThan(newestSource.mtimeMs, newestAssembly.mtimeMs))
2743
+ return null;
2744
+ return {
2745
+ newestSource: newestSource.path,
2746
+ newestSourceMs: newestSource.mtimeMs,
2747
+ assembly: newestAssembly.path,
2748
+ assemblyMs: newestAssembly.mtimeMs,
2749
+ newerCount: times.sourceTimes.filter((t) => newerThan(t, newestAssembly.mtimeMs)).length
2750
+ };
2751
+ }
2752
+ var INDENT = " ".repeat("warning: ".length);
2753
+ function staleWarning(packageId, report, now = Date.now()) {
2754
+ const files = report.newerCount === 1 ? "1 source file" : `${report.newerCount} source files`;
2755
+ return [
2756
+ `${packageId} has ${files} newer than ${report.assembly}`,
2757
+ `${INDENT}newest: ${report.newestSource} (${ago(report.newestSourceMs, now)})`,
2758
+ `${INDENT}you are probably running a stale build`
2759
+ ].join(`
2760
+ `);
2761
+ }
2762
+ function duration(ms) {
2763
+ const seconds = Math.max(0, Math.round(ms / 1000));
2764
+ if (seconds < 60)
2765
+ return `${seconds}s`;
2766
+ if (seconds < 3600)
2767
+ return `${Math.floor(seconds / 60)}m`;
2768
+ if (seconds < 86400)
2769
+ return `${Math.floor(seconds / 3600)}h`;
2770
+ return `${Math.floor(seconds / 86400)}d`;
2771
+ }
2772
+ function ago(mtimeMs, now = Date.now()) {
2773
+ return `${duration(now - mtimeMs)} ago`;
2774
+ }
2775
+
2776
+ // src/cli/output.ts
2777
+ function status(message) {
2778
+ process.stderr.write(line(message));
2779
+ }
2780
+ function warn(message) {
2781
+ process.stderr.write(line(`warning: ${message}`));
2782
+ }
2783
+ function redirectOutput(path, keep = false) {
2784
+ const fd = openSync(path, keep ? "a" : "w");
2785
+ const stdout = process.stdout.write;
2786
+ const stderr = process.stderr.write;
2787
+ let open = true;
2788
+ const write = (chunk, encodingOrCallback, callback) => {
2789
+ append(fd, chunk);
2790
+ const done = typeof encodingOrCallback === "function" ? encodingOrCallback : callback;
2791
+ done?.();
2792
+ return true;
2793
+ };
2794
+ process.stdout.write = write;
2795
+ process.stderr.write = write;
2796
+ return {
2797
+ close() {
2798
+ if (!open)
2799
+ return;
2800
+ open = false;
2801
+ process.stdout.write = stdout;
2802
+ process.stderr.write = stderr;
2803
+ closeSync(fd);
2804
+ }
2805
+ };
2806
+ }
2807
+ async function forwardOutput(stream, target) {
2808
+ for await (const chunk of stream)
2809
+ target.write(chunk);
2810
+ }
2811
+ function line(message) {
2812
+ return message.endsWith(`
2813
+ `) ? message : `${message}
2814
+ `;
2815
+ }
2816
+ function reportProblems(problems) {
2817
+ if (problems.length === 0) {
2818
+ throw new GamecrateError("resolution failed with no reported detail", Exit.Resolution);
2819
+ }
2820
+ const groups = new Map;
2821
+ for (const problem of problems) {
2822
+ const group = groups.get(problem.where);
2823
+ if (group)
2824
+ group.push(problem);
2825
+ else
2826
+ groups.set(problem.where, [problem]);
2827
+ }
2828
+ const out = [];
2829
+ for (const [where, group] of groups) {
2830
+ out.push(` ${where}`);
2831
+ for (const problem of group) {
2832
+ out.push(` ${problem.message}`);
2833
+ if (problem.suggestion)
2834
+ out.push(` did you mean ${problem.suggestion}?`);
2835
+ }
2836
+ }
2837
+ throw new GamecrateError(`${problems.length} problem${problems.length === 1 ? "" : "s"}`, Exit.Resolution, out.join(`
2838
+ `));
2839
+ }
2840
+ function planWarnings(plan) {
2841
+ if (!plan.warnOnStale)
2842
+ return plan.warnings;
2843
+ const stale = plan.mods.filter((mod) => mod.staleReport !== undefined).map((mod) => staleWarning(mod.packageId, mod.staleReport));
2844
+ return [...plan.warnings, ...stale];
2845
+ }
2846
+ function planPayload(plan) {
2847
+ return {
2848
+ game: plan.game,
2849
+ profile: plan.profile,
2850
+ ...plan.instance === undefined ? {} : { instance: plan.instance },
2851
+ mode: plan.mode,
2852
+ ...plan.marker === undefined ? {} : { marker: plan.marker },
2853
+ timeoutSeconds: plan.timeoutSeconds,
2854
+ renderWaitSeconds: plan.renderWaitSeconds,
2855
+ profileDir: resolve4(plan.profileDir),
2856
+ instanceDir: resolve4(plan.instanceDir),
2857
+ containerName: containerName(plan),
2858
+ dataDirHost: resolve4(plan.dataDirHost),
2859
+ stageDirHost: resolve4(plan.stageDirHost),
2860
+ logsDirHost: resolve4(plan.logsDirHost),
2861
+ mods: plan.mods.map((mod) => ({
2862
+ packageId: mod.packageId,
2863
+ kind: mod.kind,
2864
+ hostDir: resolve4(mod.hostDir),
2865
+ containerDir: mod.containerDir,
2866
+ origin: mod.explicit ? "explicit" : "auto",
2867
+ stale: mod.stale === true,
2868
+ ...mod.staleReport === undefined ? {} : { staleReport: mod.staleReport },
2869
+ ...mod.workshopId === undefined ? {} : { workshopId: mod.workshopId }
2870
+ })),
2871
+ warnings: planWarnings(plan)
2872
+ };
2873
+ }
2874
+ function printPlan(plan, asJson) {
2875
+ const payload = planPayload(plan);
2876
+ if (asJson) {
2877
+ process.stdout.write(`${JSON.stringify(payload, null, 2)}
2878
+ `);
2808
2879
  return;
2809
2880
  }
2810
- if (!NAME_PATTERN.test(name)) {
2811
- p.push({ where, message: `${kind} name "${name}" must match ${NAME_PATTERN.source}` });
2881
+ const title = payload.instance === undefined ? `${payload.game} ${payload.profile} (${payload.mode})` : `${payload.game} ${payload.profile} / ${payload.instance} (${payload.mode})`;
2882
+ const out = [
2883
+ title,
2884
+ ` profile ${payload.profileDir}`,
2885
+ ` instance ${payload.instanceDir}`,
2886
+ ` container ${payload.containerName}`,
2887
+ ` data ${payload.dataDirHost}`,
2888
+ ` stage ${payload.stageDirHost}`,
2889
+ ` logs ${payload.logsDirHost}`
2890
+ ];
2891
+ if (payload.marker !== undefined)
2892
+ out.push(` marker ${payload.marker}`);
2893
+ out.push(` timeout ${payload.timeoutSeconds}s, render wait ${payload.renderWaitSeconds}s`, ` mods ${payload.mods.length}`);
2894
+ const width = Math.max(0, ...payload.mods.map((m) => m.packageId.length));
2895
+ for (const mod of payload.mods) {
2896
+ const notes = [mod.kind, mod.origin];
2897
+ if (mod.stale)
2898
+ notes.push("stale");
2899
+ out.push(` ${mod.packageId.padEnd(width)} ${notes.join(" ")} ${mod.hostDir} -> ${mod.containerDir}`);
2812
2900
  }
2901
+ for (const warning of payload.warnings)
2902
+ out.push(` warning: ${warning}`);
2903
+ process.stdout.write(`${out.join(`
2904
+ `)}
2905
+ `);
2813
2906
  }
2814
- function esc(segment) {
2815
- return segment.replaceAll("~", "~0").replaceAll("/", "~1");
2816
- }
2817
- function isObj(v) {
2818
- return typeof v === "object" && v !== null && !Array.isArray(v);
2907
+ function runTimestamp(now = new Date) {
2908
+ return now.toISOString().replaceAll(/[-:.]/g, "");
2819
2909
  }
2820
-
2821
- // src/config/load.ts
2822
- function globalConfigDir() {
2823
- const base = process.env["XDG_CONFIG_HOME"] ?? join7(homedir2(), ".config");
2824
- return join7(base, "gamecrate");
2910
+ function openRunLog(logsDir, now) {
2911
+ const runsDir = join6(logsDir, "runs");
2912
+ mkdirSync2(runsDir, { recursive: true });
2913
+ const dir = uniqueRunDir(runsDir, runTimestamp(now));
2914
+ mkdirSync2(dir);
2915
+ linkCurrent(logsDir, dir);
2916
+ rotateRuns(logsDir, 10);
2917
+ return dir;
2825
2918
  }
2826
- async function findGlobalConfig() {
2827
- return probe(globalConfigDir(), "profiles");
2919
+ var encoder = new TextEncoder;
2920
+ function append(fd, chunk) {
2921
+ writeSync(fd, typeof chunk === "string" ? encoder.encode(chunk) : chunk);
2828
2922
  }
2829
- async function probe(dir, stem) {
2830
- const found = [];
2831
- for (const suffix of CONFIG_SUFFIXES) {
2832
- const file = join7(dir, `${stem}${suffix}`);
2833
- try {
2834
- await access(file);
2835
- found.push(file);
2836
- } catch (error) {
2837
- if (error.code !== "ENOENT")
2838
- throw error;
2839
- }
2840
- }
2841
- if (found.length > 1) {
2842
- const rows = found.map((f) => ` ${basename3(f)}`).join(`
2843
- `);
2844
- throw new GamecrateError(`two configs in ${dir}`, Exit.Config, `${rows}
2845
- keep one`);
2923
+ function uniqueRunDir(runsDir, stamp) {
2924
+ let candidate = join6(runsDir, stamp);
2925
+ let n = 2;
2926
+ while (existsSync2(candidate)) {
2927
+ candidate = join6(runsDir, `${stamp}-${n}`);
2928
+ n++;
2846
2929
  }
2847
- return found[0];
2930
+ return candidate;
2848
2931
  }
2849
- var projectName = z2.custom((v) => typeof v === "string" && NAME_PATTERN.test(v), "expected a name");
2850
- var projectStr = z2.string({ error: "expected a string" });
2851
- var projectBool = z2.boolean({ error: "expected true or false" });
2852
- var projectList = z2.custom((v) => Array.isArray(v) && v.every((entry) => typeof entry === "string"), "expected an array of strings");
2853
- var projectSeconds = z2.custom((v) => Number.isSafeInteger(v) && v >= 0, "expected a whole number of seconds");
2854
- function oneOf2(values) {
2855
- return z2.enum(values, { error: `expected one of ${values.join(", ")}` });
2932
+ var WAIT_NOTICE = { firstMs: 2000, everyMs: 30000 };
2933
+ function waitNotice(waitedMs, lastNoticeMs, schedule = WAIT_NOTICE) {
2934
+ if (waitedMs < schedule.firstMs)
2935
+ return;
2936
+ if (lastNoticeMs > 0 && waitedMs - lastNoticeMs < schedule.everyMs)
2937
+ return;
2938
+ return waitedMs < 60000 ? `${Math.floor(waitedMs / 1000)}s` : `${Math.floor(waitedMs / 60000)}m`;
2856
2939
  }
2857
- var BUILD_POLICIES2 = ["auto", "always", "never"];
2858
- var projectResolution = z2.string({ error: "expected dimensions like 1920x1080" }).check((ctx) => {
2859
- try {
2860
- parseResolution(ctx.value);
2861
- } catch (error) {
2862
- ctx.issues.push({ code: "custom", message: error.message, input: ctx.value });
2863
- }
2864
- }).transform(parseResolution);
2865
- var PROJECT_OBJECT = z2.strictObject({
2866
- game: projectName.optional(),
2867
- defaultProfile: projectName.optional(),
2868
- profiles: z2.record(z2.string(), z2.unknown()).optional(),
2869
- settings: z2.record(z2.string(), z2.unknown()).optional(),
2870
- library: z2.record(z2.string(), z2.unknown()).optional(),
2871
- detach: projectBool.optional(),
2872
- mods: projectList.optional(),
2873
- without: projectList.optional(),
2874
- only: projectList.optional(),
2875
- dockerArgs: projectList.optional(),
2876
- gameArgs: projectList.optional(),
2877
- worktree: projectList.optional(),
2878
- use: projectList.optional(),
2879
- marker: projectStr.optional(),
2880
- instance: projectStr.optional(),
2881
- log: projectStr.optional(),
2882
- timeout: projectSeconds.optional(),
2883
- renderWait: projectSeconds.optional(),
2884
- dryRun: projectBool.optional(),
2885
- printPlan: projectBool.optional(),
2886
- json: projectBool.optional(),
2887
- root: projectBool.optional(),
2888
- noWorktree: projectBool.optional(),
2889
- noStaleCheck: projectBool.optional(),
2890
- replace: projectBool.optional(),
2891
- mode: oneOf2(["headed", "headless", "screenshot"]).optional(),
2892
- pull: oneOf2(["always", "missing", "never"]).optional(),
2893
- sort: oneOf2(["topo", "none"]).optional(),
2894
- network: oneOf2(["none", "bridge", "host"]).optional(),
2895
- build: z2.union([z2.boolean().transform((on) => on ? "always" : "never"), z2.enum(BUILD_POLICIES2)], {
2896
- error: `expected one of ${BUILD_POLICIES2.join(", ")}`
2897
- }).optional(),
2898
- resolution: projectResolution.optional()
2899
- }, { error: "expected an object" });
2900
- var PROJECT_SCHEMA = PROJECT_OBJECT.check((ctx) => {
2901
- const { game, profiles, settings, library } = ctx.value;
2902
- if (game !== undefined)
2940
+ function runStartedAt(name) {
2941
+ const m = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(\d{3})Z/.exec(name);
2942
+ if (m === null)
2903
2943
  return;
2904
- for (const [key, value] of [
2905
- ["profiles", profiles],
2906
- ["settings", settings],
2907
- ["library", library]
2908
- ]) {
2909
- if (value === undefined)
2910
- continue;
2911
- ctx.issues.push({
2912
- code: "custom",
2913
- path: [key],
2914
- message: "needs a top-level game: to say which game it belongs to",
2915
- input: ctx.value
2916
- });
2917
- }
2918
- });
2919
- async function findProjectConfig(start = process.cwd()) {
2920
- let dir = resolve3(start);
2921
- for (;; ) {
2922
- const file = await probe(dir, ".gamecrate");
2923
- if (file !== undefined)
2924
- return file;
2925
- const parent = dirname2(dir);
2926
- if (parent === dir)
2927
- return;
2928
- dir = parent;
2929
- }
2944
+ const at = Date.parse(`${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:${m[6]}.${m[7]}Z`);
2945
+ return Number.isNaN(at) ? undefined : at;
2930
2946
  }
2931
- async function loadProjectDefaults(start = process.cwd()) {
2932
- const file = await findProjectConfig(start);
2933
- if (file === undefined)
2934
- return {};
2935
- const text = await readFile3(file, "utf8");
2936
- const defaults = validateProjectDefaults(readConfigText(text, file), file);
2937
- const profiles = defaults.profiles;
2938
- if (profiles !== undefined) {
2939
- const order = orderedKeys(text, file, "profiles");
2940
- if (order.length !== Object.keys(profiles).length || order.some((key) => !Object.hasOwn(profiles, key))) {
2941
- throw new GamecrateError(`config is invalid: ${file}`, Exit.Config, "duplicate profiles key");
2942
- }
2943
- defaults.profileOrder = order;
2944
- }
2945
- defaults.configPath = file;
2946
- return defaults;
2947
+ function currentLog(instanceDir) {
2948
+ return join6(instanceDir, "logs", "current", STDOUT_LOG);
2947
2949
  }
2948
- function validateProjectDefaults(raw, file) {
2949
- if (raw === null)
2950
- return {};
2951
- const result = PROJECT_SCHEMA.safeParse(raw);
2952
- if (result.success)
2953
- return result.data;
2954
- const problems = [];
2955
- for (const issue of result.error.issues) {
2956
- if (issue.code === "unrecognized_keys") {
2957
- for (const key of issue.keys)
2958
- problems.push(` /${key}: unknown key`);
2959
- continue;
2960
- }
2961
- problems.push(` /${issue.path.join("/")}: ${issue.message}`);
2962
- }
2963
- throw new GamecrateError(`project config is invalid: ${file}`, Exit.Config, problems.join(`
2964
- `));
2950
+ function tailArgv(file, fromStart, livePid) {
2951
+ const argv = ["tail"];
2952
+ if (fromStart)
2953
+ argv.push("-n", "+1");
2954
+ if (livePid !== undefined)
2955
+ argv.push("-f", "--pid", String(livePid));
2956
+ argv.push(file);
2957
+ return argv;
2965
2958
  }
2966
- async function loadConfig(path, project) {
2967
- const file = path ?? await findGlobalConfig() ?? join7(globalConfigDir(), "profiles.yml");
2968
- const user = await readConfigFile(file);
2969
- const specs = isObj(user) && user["plugins"] !== undefined ? user["plugins"] : [];
2970
- if (!Array.isArray(specs) || specs.some((s) => typeof s !== "string")) {
2971
- throw new GamecrateError(`config is invalid: ${file}`, Exit.Config, " /plugins: expected an array of strings");
2972
- }
2973
- const plugins = await loadPlugins(specs, file);
2974
- const base = {
2975
- dataRoot: DEFAULT_DATA_ROOT,
2976
- defaults: { settings: structuredClone(DEFAULT_SETTINGS) },
2977
- games: Object.fromEntries([...plugins].map(([name, plugin]) => [name, structuredClone(plugin.defaults)]))
2978
- };
2979
- const merged = user === undefined || user === null ? base : mergeUserConfig(base, user);
2980
- const spliced = applyProject(merged, project);
2981
- const { config, problems } = validateConfig(spliced);
2982
- if (problems.length > 0) {
2983
- const detail = problems.map((p) => {
2984
- const hint = p.suggestion ? ` (${p.suggestion})` : "";
2985
- return ` ${p.where || "/"}: ${p.message}${hint}${origin(p.where, user, plugins, project)}`;
2986
- }).join(`
2987
- `);
2988
- const from = plugins.size === 0 ? "" : ` (merged with defaults from: ${[...plugins.keys()].join(", ")})`;
2989
- throw new GamecrateError(`config is invalid: ${file}${from}`, Exit.Config, detail);
2990
- }
2991
- return { config: expandPaths(config), plugins };
2959
+ function linkCurrent(logsDir, target) {
2960
+ const link = join6(logsDir, "current");
2961
+ try {
2962
+ lstatSync(link);
2963
+ unlinkSync(link);
2964
+ } catch {}
2965
+ symlinkSync(join6("runs", basename3(target)), link, "dir");
2966
+ }
2967
+ function rotateRuns(logsDir, keep) {
2968
+ const runsDir = join6(logsDir, "runs");
2969
+ if (keep < 1 || !existsSync2(runsDir))
2970
+ return [];
2971
+ const dirs = readdirSync(runsDir, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort().reverse();
2972
+ const removed = dirs.slice(keep);
2973
+ for (const name of removed)
2974
+ rmSync(join6(runsDir, name), { recursive: true, force: true });
2975
+ return removed;
2992
2976
  }
2993
- function applyProject(config, project) {
2994
- if (project === undefined)
2995
- return config;
2996
- const game = project.game;
2997
- if (game === undefined)
2998
- return config;
2999
- if (project.profiles === undefined && project.settings === undefined && project.library === undefined) {
3000
- return config;
3001
- }
3002
- const existing = own(config.games, game);
3003
- if (existing === undefined) {
3004
- throw new GamecrateError(`the project config names game "${game}", which is not configured`, Exit.Config, `known games: ${Object.keys(config.games).join(", ") || "none"}`);
3005
- }
3006
- const target = {
3007
- ...existing,
3008
- profiles: { ...existing.profiles, ...project.profiles },
3009
- ...project.library === undefined ? {} : { library: spliceLibrary(existing.library, project.library) }
3010
- };
3011
- config.games[game] = target;
3012
- if (project.settings !== undefined) {
3013
- target.settings = deepMerge(target.settings ?? {}, project.settings);
3014
- }
3015
- return config;
2977
+
2978
+ // src/launch/prepare.ts
2979
+ import { existsSync as existsSync3, readFileSync as readFileSync2 } from "node:fs";
2980
+ import { open as open2, readdir as readdir4, readFile as readFile3, unlink, writeFile } from "node:fs/promises";
2981
+ import { join as join7 } from "node:path";
2982
+ import { setTimeout as sleep2 } from "node:timers/promises";
2983
+ async function inherit(argv) {
2984
+ const proc = spawnArgv(argv, ["ignore", "pipe", "pipe"]);
2985
+ const code = exited(proc);
2986
+ await Promise.all([
2987
+ forwardOutput(proc.stdout, process.stdout),
2988
+ forwardOutput(proc.stderr, process.stderr)
2989
+ ]);
2990
+ return code;
3016
2991
  }
3017
- function spliceLibrary(global, project) {
3018
- const replaced = new Set(Object.keys(project).map((id) => id.toLowerCase()));
3019
- const kept = Object.entries(global ?? {}).filter(([id]) => !replaced.has(id.toLowerCase()));
3020
- return { ...Object.fromEntries(kept), ...project };
2992
+ async function imageDigest(ref) {
2993
+ const { code, stdout } = await capture(["docker", "image", "inspect", "--format", "{{.Id}}", ref]);
2994
+ const id = stdout.trim();
2995
+ return code === 0 && id.length > 0 ? id : null;
3021
2996
  }
3022
- var GAME_SCOPED_KEYS = ["profiles", "settings", "library"];
3023
- function origin(where, user, plugins, project) {
3024
- if (!where.startsWith("/"))
3025
- return "";
3026
- const segments = where.slice(1).split("/").map((s) => s.replaceAll("~1", "/").replaceAll("~0", "~"));
3027
- const [section, name, sub, ...rest] = segments;
3028
- const repoGame = project?.game;
3029
- const repoSection = GAME_SCOPED_KEYS.find((k) => k === sub);
3030
- if (repoGame !== undefined && section === "games" && name === repoGame && repoSection !== undefined && valueAt2(project?.[repoSection], rest) !== undefined) {
3031
- return " <- from the .gamecrate project config, not this file";
3032
- }
3033
- if (valueAt2(user, segments) !== undefined)
3034
- return "";
3035
- const plugin = section === "games" && name !== undefined ? plugins.get(name) : undefined;
3036
- if (plugin === undefined)
3037
- return " <- not in this file";
3038
- 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`;
2997
+ async function repoDigest(ref) {
2998
+ const format = "{{range .RepoDigests}}{{.}}{{break}}{{end}}";
2999
+ const { code, stdout } = await capture(["docker", "image", "inspect", "--format", format, ref]);
3000
+ const at = stdout.trim().lastIndexOf("@");
3001
+ return code === 0 && at !== -1 ? stdout.trim().slice(at + 1) : null;
3039
3002
  }
3040
- function valueAt2(value, segments) {
3041
- let current = value;
3042
- for (const segment of segments) {
3043
- if (Array.isArray(current))
3044
- current = current[Number(segment)];
3045
- else if (isObj(current))
3046
- current = own(current, segment);
3047
- else
3003
+ async function readFromImage(ref, path) {
3004
+ const { code, stdout } = await capture(["docker", "run", "--rm", "--entrypoint", "cat", ref, path]);
3005
+ return code === 0 ? stdout : null;
3006
+ }
3007
+ async function imageLabel(ref, label) {
3008
+ const format = `{{index .Config.Labels "${label}"}}`;
3009
+ const { code, stdout } = await capture(["docker", "image", "inspect", "--format", format, ref]);
3010
+ const value = stdout.trim();
3011
+ if (code !== 0 || value.length === 0 || value === "<no value>")
3012
+ return null;
3013
+ return value;
3014
+ }
3015
+ async function acquireImage(game, config, pull) {
3016
+ const { image } = config;
3017
+ const present = await imageDigest(image.ref) !== null;
3018
+ if (image.acquire === "build")
3019
+ return await buildImage(game, image, present, pull);
3020
+ if (pull === "never") {
3021
+ if (present)
3048
3022
  return;
3023
+ throw new GamecrateError(`--pull never but ${image.ref} is not present locally`, Exit.Environment);
3049
3024
  }
3050
- return current;
3051
- }
3052
- function resolveSettings(root, game, profile, ...overrides) {
3053
- let out = structuredClone(DEFAULT_SETTINGS);
3054
- for (const layer of [root.defaults?.settings, game.settings, profile.settings, ...overrides]) {
3055
- if (layer)
3056
- out = deepMerge(out, layer, true);
3025
+ if (pull === "missing" && present)
3026
+ return;
3027
+ if (await inherit(["docker", "pull", image.ref]) !== 0) {
3028
+ if (present)
3029
+ return;
3030
+ throw new GamecrateError(`docker pull failed for ${image.ref}`, Exit.Environment);
3057
3031
  }
3058
- return out;
3059
3032
  }
3060
- function resolveProfile(game, name) {
3061
- return resolveNamed(game, name, []);
3062
- }
3063
- function resolveNamed(game, name, seen) {
3064
- if (name.toLowerCase() === "modless")
3065
- return { mods: [], exclude: [], includeBase: false };
3066
- const key = profileKey(game, name);
3067
- if (key === undefined) {
3068
- throw new GamecrateError(`unknown profile "${name}"`, Exit.Resolution, `known profiles: ${Object.keys(game.profiles).join(", ") || "(none)"}, modless`);
3069
- }
3070
- if (seen.includes(key)) {
3071
- throw new GamecrateError(`profile "${key}" inherits from itself`, Exit.Config, [...seen, key].join(" -> "));
3033
+ async function buildImage(game, image, present, pull) {
3034
+ if (image.context === undefined) {
3035
+ throw new GamecrateError(`${game} has image.acquire "build" but no context`, Exit.Config);
3072
3036
  }
3073
- const self = own(game.profiles, key);
3074
- if (self.alias !== undefined)
3075
- return resolveNamed(game, self.alias, [...seen, key]);
3076
- const parent = self.extends !== undefined ? resolveNamed(game, self.extends, [...seen, key]) : {};
3077
- const exclude = [...parent.exclude ?? [], ...self.exclude ?? []];
3078
- const out = {
3079
- mods: subtract([...parent.mods ?? [], ...self.mods ?? []], exclude),
3080
- exclude,
3081
- settings: deepMerge(parent.settings ?? {}, self.settings ?? {}, true)
3082
- };
3083
- const instances = deepMerge(parent.instances ?? {}, self.instances ?? {}, true);
3084
- if (Object.keys(instances).length > 0)
3085
- out.instances = instances;
3086
- const includeBase = self.includeBase ?? parent.includeBase;
3087
- if (includeBase !== undefined)
3088
- out.includeBase = includeBase;
3089
- const auto = self.autoDependencies ?? parent.autoDependencies;
3090
- if (auto !== undefined)
3091
- out.autoDependencies = auto;
3092
- for (const field of ["detach", "replace", "build", "gameVersion", "image"]) {
3093
- const value = self[field] ?? parent[field];
3094
- if (value !== undefined)
3095
- Object.assign(out, { [field]: value });
3037
+ if (present && pull !== "always")
3038
+ return;
3039
+ if (await inherit(["docker", "build", "--tag", image.ref, image.context]) !== 0) {
3040
+ throw new GamecrateError(`docker build failed for ${image.ref}`, Exit.Environment);
3096
3041
  }
3097
- return out;
3098
3042
  }
3099
- function canonicalProfile(game, name) {
3100
- if (name.toLowerCase() === "modless")
3101
- return "modless";
3102
- const seen = [];
3103
- let current = name;
3104
- for (;; ) {
3105
- const key = profileKey(game, current);
3106
- if (key === undefined || seen.includes(key))
3107
- return key ?? current;
3108
- const next = own(game.profiles, key)?.alias;
3109
- if (next === undefined)
3110
- return key;
3111
- seen.push(key);
3112
- current = next;
3043
+ async function buildTarget(dir) {
3044
+ let entries;
3045
+ try {
3046
+ entries = await readdir4(dir);
3047
+ } catch {
3048
+ return null;
3113
3049
  }
3050
+ const slnx = entries.find((e) => e.endsWith(".slnx"));
3051
+ if (slnx)
3052
+ return join7(dir, slnx);
3053
+ const csproj = entries.find((e) => e.endsWith(".csproj"));
3054
+ return csproj ? join7(dir, csproj) : null;
3114
3055
  }
3115
- function profileDataDir(root, game, profile) {
3116
- return resolve3(expandHome(root.dataRoot), game, canonicalProfile(own(root.games, game), profile));
3056
+ async function buildLocalMods(plan, policy, refsNote) {
3057
+ if (policy === "never")
3058
+ return;
3059
+ const wanted = plan.mods.filter((m) => m.kind === "local" && (policy === "always" || m.stale === true));
3060
+ if (wanted.length === 0)
3061
+ return;
3062
+ const failed = [];
3063
+ for (const mod of wanted) {
3064
+ const target = await buildTarget(mod.hostDir);
3065
+ if (target === null)
3066
+ continue;
3067
+ const code = await inherit(["dotnet", "build", target, "-v", "quiet", "--nologo"]);
3068
+ if (code !== 0) {
3069
+ failed.push(`${mod.packageId} ${target}`);
3070
+ continue;
3071
+ }
3072
+ mod.stale = false;
3073
+ delete mod.staleReport;
3074
+ }
3075
+ if (failed.length === 0)
3076
+ return;
3077
+ const what = failed.length === 1 ? failed[0].split(" ")[0] : `${failed.length} mods`;
3078
+ throw new GamecrateError(`dotnet build failed for ${what}`, Exit.Environment, [...failed, refsNote].filter((part) => part !== undefined).join(`
3079
+ `));
3117
3080
  }
3118
- async function profileDirs(root, game, profile) {
3119
- if (profile !== undefined)
3120
- return [profileDataDir(root, game, profile)];
3121
- const dir = join7(expandHome(root.dataRoot), game);
3122
- return (await readdir4(dir).catch(() => [])).map((name) => join7(dir, name));
3081
+ async function clearLock(plan) {
3082
+ const path = lockPath(plan);
3083
+ const what = plan.instance === undefined ? plan.profile : `${plan.profile} (${plan.instance})`;
3084
+ const name = containerName(plan);
3085
+ const up = await capture(["docker", "ps", "--quiet", "--filter", `name=^${name}$`]);
3086
+ if (up.stdout.trim().length > 0) {
3087
+ throw new GamecrateError(`${plan.game} ${what} is already running (container ${name})`, Exit.Refused, `stop it with: docker stop ${name}
3088
+ or relaunch with --replace`);
3089
+ }
3090
+ const held = await readLock(path);
3091
+ if (held !== undefined && isRunning(held.pid, held.startedAt)) {
3092
+ throw new GamecrateError(`${plan.game} ${what} is already running (pid ${held.pid})`, Exit.Refused, `if that is wrong, delete ${path}
3093
+ or relaunch with --replace`);
3094
+ }
3095
+ if (existsSync3(path))
3096
+ await unlink(path).catch(() => {});
3123
3097
  }
3124
- function profileKey(game, name) {
3125
- if (Object.hasOwn(game.profiles, name))
3126
- return name;
3127
- const lower = name.toLowerCase();
3128
- const direct = Object.keys(game.profiles).find((k) => k.toLowerCase() === lower);
3129
- if (direct !== undefined)
3130
- return direct;
3131
- return Object.keys(game.profiles).find((k) => (own(game.profiles, k)?.aliases ?? []).some((a) => typeof a === "string" && a.toLowerCase() === lower));
3098
+ async function unlinkHeld(path, pid, startedAt) {
3099
+ const held = await readLock(path);
3100
+ if (held === undefined)
3101
+ return;
3102
+ if (held.pid !== pid)
3103
+ return;
3104
+ if (startedAt !== undefined && held.startedAt !== startedAt)
3105
+ return;
3106
+ await unlink(path).catch(() => {});
3132
3107
  }
3133
- function subtract(mods, exclude) {
3134
- if (exclude.length === 0)
3135
- return mods;
3136
- const patterns = exclude.map(globToRegExp);
3137
- return mods.filter((entry) => {
3138
- const ids = entryIds(entry);
3139
- if (ids.length === 0)
3140
- return true;
3141
- return !ids.some((id) => patterns.some((re) => re.test(id)));
3142
- });
3108
+ function heldLock(plan) {
3109
+ const path = lockPath(plan);
3110
+ return {
3111
+ release: async () => {
3112
+ await unlinkHeld(path, process.pid);
3113
+ }
3114
+ };
3143
3115
  }
3144
- function entryIds(entry) {
3145
- if (typeof entry === "string")
3146
- return [entry, entry.replace(/^(workshop|path):/, "")];
3147
- if ("id" in entry)
3148
- return [entry.id];
3149
- return [];
3116
+ async function takeLock(plan) {
3117
+ await clearLock(plan);
3118
+ await writeLock(plan, {
3119
+ pid: process.pid,
3120
+ container: containerName(plan),
3121
+ game: plan.game,
3122
+ profile: plan.profile,
3123
+ ...plan.instance === undefined ? {} : { instance: plan.instance },
3124
+ detached: false,
3125
+ mode: plan.mode
3126
+ });
3127
+ return heldLock(plan);
3150
3128
  }
3151
- function globToRegExp(pattern) {
3152
- const body = pattern.replaceAll(/[.+^${}()|[\]\\]/g, String.raw`\$&`).replaceAll("*", ".*").replaceAll("?", ".");
3153
- return new RegExp(`^${body}$`, "i");
3129
+ function lockPath(plan) {
3130
+ return join7(plan.instanceDir, ".gamecrate", "lock");
3154
3131
  }
3155
- function mergeUserConfig(base, user) {
3156
- const out = deepMerge(base, user);
3157
- const games = isObj(user) ? user["games"] : undefined;
3158
- if (!isObj(games))
3159
- return out;
3160
- for (const name of Object.keys(games)) {
3161
- const theirs = own(games, name);
3162
- const steamBuild = isObj(theirs) ? theirs["steamBuild"] : undefined;
3163
- const added = isObj(steamBuild) ? steamBuild["branches"] : undefined;
3164
- const declared = own(base.games, name)?.steamBuild?.branches;
3165
- const target = own(out.games, name);
3166
- if (!Array.isArray(added) || !Array.isArray(declared) || target === undefined)
3167
- continue;
3168
- target.steamBuild.branches = concatBranches(declared, added);
3132
+ async function readLock(path) {
3133
+ const text = await readFile3(path, "utf8").catch(() => {
3134
+ return;
3135
+ });
3136
+ if (text === undefined)
3137
+ return;
3138
+ try {
3139
+ const value = JSON.parse(text);
3140
+ return Number.isInteger(value?.pid) && value.pid > 0 ? value : undefined;
3141
+ } catch {
3142
+ return;
3169
3143
  }
3170
- return out;
3171
3144
  }
3172
- function branchName(entry) {
3173
- return isObj(entry) && typeof entry["name"] === "string" ? entry["name"] : undefined;
3145
+ async function writeLock(plan, record) {
3146
+ const path = lockPath(plan);
3147
+ const handle = await open2(path, "wx").catch(() => null);
3148
+ if (handle === null) {
3149
+ throw new GamecrateError(`could not take the launch lock at ${path}`, Exit.Environment);
3150
+ }
3151
+ await handle.writeFile(JSON.stringify({ ...record, startedAt: new Date().toISOString() }));
3152
+ await handle.close();
3174
3153
  }
3175
- function concatBranches(declared, added) {
3176
- const out = [...declared];
3177
- const at = new Map;
3178
- out.forEach((branch, index) => {
3179
- const name = branchName(branch);
3180
- if (name !== undefined && !at.has(name))
3181
- at.set(name, index);
3182
- });
3183
- for (const entry of added) {
3184
- const name = branchName(entry);
3185
- const index = name === undefined ? undefined : at.get(name);
3186
- if (index === undefined) {
3187
- if (name !== undefined)
3188
- at.set(name, out.length);
3189
- out.push(entry);
3190
- continue;
3154
+ var RELEASE_POLL_MS = 100;
3155
+ var DRAIN_ALLOWANCE_MS = 1e4;
3156
+ var STOP_RELEASE_WAIT_MS = STOP_TIMEOUT_SECONDS * 1000 + DRAIN_ALLOWANCE_MS;
3157
+ async function stopRun(record, lockFile) {
3158
+ let signalled = false;
3159
+ if (isRunning(record.pid, record.startedAt)) {
3160
+ try {
3161
+ process.kill(record.pid, "SIGTERM");
3162
+ signalled = true;
3163
+ } catch {}
3164
+ }
3165
+ if (!signalled)
3166
+ await stopContainer(record.container, STOP_TIMEOUT_SECONDS);
3167
+ const deadline = Date.now() + STOP_RELEASE_WAIT_MS;
3168
+ while (existsSync3(lockFile)) {
3169
+ const held = await readLock(lockFile);
3170
+ if (held === undefined)
3171
+ break;
3172
+ if (!isRunning(held.pid, held.startedAt)) {
3173
+ await unlinkHeld(lockFile, record.pid, record.startedAt);
3174
+ break;
3191
3175
  }
3192
- out[index] = deepMerge(out[index], entry);
3176
+ if (Date.now() >= deadline)
3177
+ return "held";
3178
+ await sleep2(RELEASE_POLL_MS);
3193
3179
  }
3194
- return out;
3180
+ return signalled ? "signalled" : "orphaned";
3195
3181
  }
3196
- function deepMerge(base, over, concatArrays = false) {
3197
- if (Array.isArray(base) && Array.isArray(over)) {
3198
- return concatArrays ? [...base, ...over] : [...over];
3182
+ async function replacePrevious(plan) {
3183
+ const name = containerName(plan);
3184
+ const path = lockPath(plan);
3185
+ const up = await capture(["docker", "ps", "--quiet", "--filter", `name=^${name}$`]);
3186
+ const running = up.stdout.trim().length > 0;
3187
+ const held = await readLock(path);
3188
+ if (!running && held === undefined && !existsSync3(path))
3189
+ return;
3190
+ if (held !== undefined) {
3191
+ status(`stopping ${held.container}`);
3192
+ await stopRun(held, path);
3193
+ return;
3199
3194
  }
3200
- if (isObj(base) && isObj(over)) {
3201
- const out = { ...base };
3202
- for (const [k, v] of Object.entries(over)) {
3203
- if (v === undefined)
3204
- continue;
3205
- out[k] = Object.hasOwn(out, k) ? deepMerge(out[k], v, concatArrays) : v;
3206
- }
3207
- return out;
3195
+ if (running) {
3196
+ status(`stopping ${name}`);
3197
+ await stopContainer(name, STOP_TIMEOUT_SECONDS);
3208
3198
  }
3209
- return over;
3210
3199
  }
3211
- function expandPaths(config) {
3212
- config.dataRoot = expandHome(config.dataRoot);
3213
- if (config.steamcmd?.path !== undefined)
3214
- config.steamcmd.path = expandHome(config.steamcmd.path);
3215
- for (const game of Object.values(config.games)) {
3216
- if (game.gameFiles.host !== undefined)
3217
- game.gameFiles.host = expandHome(game.gameFiles.host);
3218
- if (game.image.context !== undefined)
3219
- game.image.context = expandHome(game.image.context);
3220
- if (game.workshopRoot !== null)
3221
- game.workshopRoot = expandHome(game.workshopRoot);
3222
- for (const root of game.scanRoots)
3223
- root.path = expandHome(root.path);
3224
- for (const entry of Object.values(game.library ?? {})) {
3225
- if (entry.path !== undefined)
3226
- entry.path = expandHome(entry.path);
3227
- }
3200
+ var CLOCK_TICKS_PER_SECOND = 100;
3201
+ var START_TIME_SLACK_MS = 2000;
3202
+ function isRunning(pid, startedAt) {
3203
+ try {
3204
+ process.kill(pid, 0);
3205
+ } catch {
3206
+ return false;
3228
3207
  }
3229
- return config;
3208
+ if (startedAt === undefined)
3209
+ return true;
3210
+ const written = Date.parse(startedAt);
3211
+ if (Number.isNaN(written))
3212
+ return true;
3213
+ const began = processStart(pid);
3214
+ return began === undefined || began <= written + START_TIME_SLACK_MS;
3230
3215
  }
3231
- function expandHome(p) {
3232
- if (p === "~")
3233
- return homedir2();
3234
- return p.startsWith("~/") ? join7(homedir2(), p.slice(2)) : p;
3216
+ function processStart(pid) {
3217
+ try {
3218
+ const stat = readFileSync2(`/proc/${pid}/stat`, "utf8");
3219
+ const fields = stat.slice(stat.lastIndexOf(")") + 2).split(" ");
3220
+ const ticks = Number(fields[19]);
3221
+ const boot = bootTime();
3222
+ if (!Number.isFinite(ticks) || boot === undefined)
3223
+ return;
3224
+ return boot + ticks / CLOCK_TICKS_PER_SECOND * 1000;
3225
+ } catch {
3226
+ return;
3227
+ }
3228
+ }
3229
+ function bootTime() {
3230
+ const line = readFileSync2("/proc/stat", "utf8").split(`
3231
+ `).find((each) => each.startsWith("btime "));
3232
+ const seconds = Number(line?.slice("btime ".length));
3233
+ return Number.isFinite(seconds) && seconds > 0 ? seconds * 1000 : undefined;
3234
+ }
3235
+ async function captureScreenshot(container, plan) {
3236
+ const name = `${plan.game}.png`;
3237
+ const target = `${CONTAINER_LOG_DIR}/${name}`;
3238
+ const script = 'D=":$(ls /tmp/.X11-unix 2>/dev/null | head -1 | tr -d X)";' + ' [ "$D" = ":" ] && { echo "no X socket in the container" >&2; exit 1; };' + " X=$(ls -d /tmp/xvfb-run.*/Xauthority 2>/dev/null | head -1);" + ' [ -n "$X" ] && export XAUTHORITY="$X";' + " M=$(command -v magick || command -v convert);" + ' [ -z "$M" ] && { echo "no imagemagick in the container" >&2; exit 1; };' + ` import -display "$D" -window root ${target} 2>/dev/null` + ` || xwd -root -display "$D" | "$M" xwd:- ${target}`;
3239
+ const code = await inherit(["docker", "exec", container, "sh", "-c", script]);
3240
+ const host = join7(plan.runDirHost, name);
3241
+ if (code !== 0 || !existsSync3(host))
3242
+ return null;
3243
+ return host;
3244
+ }
3245
+ async function writeLaunchRecord(plan, image) {
3246
+ const digest = await imageDigest(image);
3247
+ const line = JSON.stringify({
3248
+ at: new Date().toISOString(),
3249
+ game: plan.game,
3250
+ profile: plan.profile,
3251
+ ...plan.instance === undefined ? {} : { instance: plan.instance },
3252
+ image,
3253
+ digest,
3254
+ mode: plan.mode,
3255
+ mods: plan.mods.map((m) => ({
3256
+ packageId: m.packageId,
3257
+ hostDir: m.hostDir,
3258
+ ...m.worktree === undefined ? {} : { worktree: m.worktree }
3259
+ }))
3260
+ });
3261
+ const path = join7(plan.instanceDir, ".gamecrate", "launches.jsonl");
3262
+ await writeFile(path, `${line}
3263
+ `, { flag: "a" });
3235
3264
  }
3236
3265
 
3266
+ // src/mods/modindex.ts
3267
+ import { createHash as createHash2 } from "node:crypto";
3268
+ import { existsSync as existsSync7, readFileSync as readFileSync4, statSync as statSync3 } from "node:fs";
3269
+ import { mkdir as mkdir2, readdir as readdir6, readFile as readFile5, writeFile as writeFile2 } from "node:fs/promises";
3270
+ import { homedir as homedir3 } from "node:os";
3271
+ import { dirname as dirname5, join as join10, relative as relative2, sep as sep2, resolve as resolvePath } from "node:path";
3272
+ import picomatch from "picomatch";
3273
+
3237
3274
  // src/mods/acf.ts
3238
3275
  var MAX_DEPTH = 100;
3239
3276
  function isSpace(c) {
@@ -3926,7 +3963,7 @@ function buildIdFor(output, branch) {
3926
3963
  // src/mods/worktree.ts
3927
3964
  import { spawnSync as spawnSync3 } from "node:child_process";
3928
3965
  import { existsSync as existsSync6, realpathSync as realpathSync2 } from "node:fs";
3929
- import { isAbsolute as isAbsolute2, resolve as resolve4, sep } from "node:path";
3966
+ import { isAbsolute as isAbsolute3, resolve as resolve5, sep } from "node:path";
3930
3967
  function inspect(dir) {
3931
3968
  const r = spawnSync3("git", ["-C", dir, "rev-parse", "--path-format=absolute", "--show-toplevel", "--git-dir", "--git-common-dir", "--abbrev-ref", "HEAD"], { encoding: "utf8" });
3932
3969
  if (r.status !== 0 || typeof r.stdout !== "string")
@@ -3942,12 +3979,12 @@ function canonical(p) {
3942
3979
  try {
3943
3980
  return realpathSync2(p);
3944
3981
  } catch {
3945
- return resolve4(p);
3982
+ return resolve5(p);
3946
3983
  }
3947
3984
  }
3948
3985
  function resolveWorktree(dir, source, order) {
3949
3986
  const raw = expandHome(dir);
3950
- const abs = isAbsolute2(raw) ? raw : resolve4(process.cwd(), raw);
3987
+ const abs = isAbsolute3(raw) ? raw : resolve5(process.cwd(), raw);
3951
3988
  if (!existsSync6(abs)) {
3952
3989
  return { where: abs, message: `--worktree path does not exist`, suggestion: "check the path, or drop the flag" };
3953
3990
  }
@@ -4521,8 +4558,32 @@ function byPath(index, raw, game) {
4521
4558
  function refsRoot() {
4522
4559
  return join11(cacheDir(), "refs");
4523
4560
  }
4524
- function refsLink(game) {
4525
- return join11(refsRoot(), "current", game);
4561
+ function refsLink(game, version) {
4562
+ if (version === undefined)
4563
+ return join11(refsRoot(), "current", game);
4564
+ return join11(refsRoot(), "version", game, version);
4565
+ }
4566
+ async function readVersion(dir) {
4567
+ const text = await readFile6(join11(dir, "Version.txt"), "utf8").catch(() => {
4568
+ return;
4569
+ });
4570
+ const hit = text === undefined ? null : /^(\d+\.\d+)/.exec(text.trim());
4571
+ return hit === null ? undefined : hit[1];
4572
+ }
4573
+ async function pointBoth(game, dir) {
4574
+ const version = await readVersion(dir);
4575
+ const link = await point(refsLink(game), dir);
4576
+ if (version !== undefined)
4577
+ await point(refsLink(game, version), dir);
4578
+ return { link, version };
4579
+ }
4580
+ function currentRefs(game) {
4581
+ try {
4582
+ const target = readlinkSync(refsLink(game));
4583
+ return `refs in use: ${target.replace(/^.*\/refs\//, "")}`;
4584
+ } catch {
4585
+ return;
4586
+ }
4526
4587
  }
4527
4588
  var MARK2 = "@@gamecrate@@ ";
4528
4589
  function extractScript(candidates, container) {
@@ -4536,6 +4597,7 @@ function extractScript(candidates, container) {
4536
4597
  ' [ -f "$1" ] || continue',
4537
4598
  ` echo "${MARK2}$d"`,
4538
4599
  ' cp "$@" /out/',
4600
+ ` cp '${join11(container, "Version.txt")}' /out/ 2>/dev/null || true`,
4539
4601
  " exit 0",
4540
4602
  "done",
4541
4603
  "exit 3"
@@ -4553,9 +4615,8 @@ async function extractRefs(game, config) {
4553
4615
  throw new GamecrateError(`${ref} is not present, so ${game} has no assemblies to extract`, Exit.Environment, `gamecrate steam build ${game}, or docker pull ${ref}`);
4554
4616
  }
4555
4617
  const dir = join11(refsRoot(), digest.replace(/[^A-Za-z0-9]/g, "-"));
4556
- const link = refsLink(game);
4557
4618
  if (existsSync8(dir)) {
4558
- return { dir, link: await point(link, dir), digest, source: "(cached)", count: await dllCount(dir) };
4619
+ return { dir, ...await pointBoth(game, dir), digest, source: "(cached)", count: await dllCount(dir) };
4559
4620
  }
4560
4621
  const partial = `${dir}.partial`;
4561
4622
  await rm3(partial, { recursive: true, force: true });
@@ -4587,7 +4648,7 @@ async function extractRefs(game, config) {
4587
4648
  throw new GamecrateError(`${ref} reported assemblies at ${source} but none arrived`, Exit.Environment, "the copy ran inside the image; check /out was writable by the calling uid");
4588
4649
  }
4589
4650
  await rename(partial, dir);
4590
- return { dir, link: await point(link, dir), digest, source, count };
4651
+ return { dir, ...await pointBoth(game, dir), digest, source, count };
4591
4652
  }
4592
4653
  async function dllCount(dir) {
4593
4654
  const entries = await readdir7(dir).catch(() => []);
@@ -4675,11 +4736,11 @@ function profileRows(profile, spec, width, notes) {
4675
4736
 
4676
4737
  // src/cli/mods.ts
4677
4738
  import { existsSync as existsSync9 } from "node:fs";
4678
- import { mkdir as mkdir4, readFile as readFile8, readdir as readdir8, writeFile as writeFile4 } from "node:fs/promises";
4679
- import { dirname as dirname9, join as join14, relative as relative3, resolve as resolve5 } from "node:path";
4739
+ import { mkdir as mkdir4, readFile as readFile9, readdir as readdir8, writeFile as writeFile4 } from "node:fs/promises";
4740
+ import { dirname as dirname9, join as join14, relative as relative3, resolve as resolve6 } from "node:path";
4680
4741
 
4681
4742
  // src/config/write.ts
4682
- import { chmod, readFile as readFile6, realpath, rename as rename2, stat as stat3, writeFile as writeFile3 } from "node:fs/promises";
4743
+ import { chmod, readFile as readFile7, realpath, rename as rename2, stat as stat3, writeFile as writeFile3 } from "node:fs/promises";
4683
4744
  import { dirname as dirname7, extname as extname2, join as join12 } from "node:path";
4684
4745
  import { applyEdits, modify } from "jsonc-parser";
4685
4746
  import { isMap as isMap2, parseDocument as parseDocument2 } from "yaml";
@@ -4702,7 +4763,7 @@ async function writeConfig(file, edits) {
4702
4763
  return;
4703
4764
  const yaml = isYaml2(file);
4704
4765
  const target = await realpath(file);
4705
- const before = await readFile6(target, "utf8");
4766
+ const before = await readFile7(target, "utf8");
4706
4767
  const after = yaml ? editYaml(before, edits) : editJson(before, edits);
4707
4768
  const mode = (await stat3(target)).mode & 511;
4708
4769
  const temp = join12(dirname7(target), `.gamecrate-write-${process.pid}.tmp`);
@@ -4744,7 +4805,7 @@ function editYaml(text, edits) {
4744
4805
  }
4745
4806
 
4746
4807
  // src/mods/workshopapi.ts
4747
- import { readFile as readFile7, stat as stat4 } from "node:fs/promises";
4808
+ import { readFile as readFile8, stat as stat4 } from "node:fs/promises";
4748
4809
  import { basename as basename5, dirname as dirname8, join as join13 } from "node:path";
4749
4810
  var ENDPOINT = "https://api.steampowered.com/ISteamRemoteStorage/GetPublishedFileDetails/v1/";
4750
4811
  var CHUNK = 100;
@@ -4858,7 +4919,7 @@ async function exists(dir) {
4858
4919
  }
4859
4920
  async function readText(path) {
4860
4921
  try {
4861
- return await readFile7(path, "utf8");
4922
+ return await readFile8(path, "utf8");
4862
4923
  } catch {
4863
4924
  return "";
4864
4925
  }
@@ -5094,7 +5155,7 @@ function existingKey(library, id) {
5094
5155
  }
5095
5156
  async function discover(source, game, plugin, ctx) {
5096
5157
  if (source.kind === "path") {
5097
- const dir = resolve5(ctx.cwd, expandHome(source.value));
5158
+ const dir = resolve6(ctx.cwd, expandHome(source.value));
5098
5159
  const id = await readId(dir, game.manifest.file, plugin);
5099
5160
  return id === undefined ? [] : [{ id, entry: { path: dir } }];
5100
5161
  }
@@ -5155,7 +5216,7 @@ async function walk2(root, manifestFile, plugin) {
5155
5216
  return out;
5156
5217
  }
5157
5218
  async function readId(dir, manifestFile, plugin) {
5158
- const text = await readFile8(join14(dir, manifestFile), "utf8").catch(() => {
5219
+ const text = await readFile9(join14(dir, manifestFile), "utf8").catch(() => {
5159
5220
  return;
5160
5221
  });
5161
5222
  if (text === undefined)
@@ -5190,7 +5251,7 @@ import { createInterface } from "node:readline/promises";
5190
5251
 
5191
5252
  // src/image/build.ts
5192
5253
  import { mkdirSync as mkdirSync4, mkdtempSync, rmSync as rmSync3, writeFileSync as writeFileSync2 } from "node:fs";
5193
- import { readFile as readFile9 } from "node:fs/promises";
5254
+ import { readFile as readFile10 } from "node:fs/promises";
5194
5255
  import { tmpdir } from "node:os";
5195
5256
  import { join as join17 } from "node:path";
5196
5257
 
@@ -5534,8 +5595,12 @@ async function resolveSteamBuildInput(game, config, overrides, cwd, configFile)
5534
5595
  // src/image/tags.ts
5535
5596
  function sanitizeVersion(raw, fallback) {
5536
5597
  const first = raw.trim().split(/\s+/)[0] ?? "";
5537
- const mapped = first.replaceAll(/[^A-Za-z0-9._-]/g, "-").replace(/-+$/, "");
5538
- return mapped.length > 0 ? mapped : fallback;
5598
+ const mapped = first.replaceAll(/[^\w.-]/g, "-");
5599
+ let end = mapped.length;
5600
+ while (end > 0 && mapped.charAt(end - 1) === "-")
5601
+ end -= 1;
5602
+ const trimmed = mapped.slice(0, end);
5603
+ return trimmed.length > 0 ? trimmed : fallback;
5539
5604
  }
5540
5605
  function versionPrefixes(version) {
5541
5606
  const parts = version.split(".");
@@ -5624,7 +5689,7 @@ async function cell(input, opts, ctx) {
5624
5689
  }
5625
5690
  try {
5626
5691
  const dir = await download(input, opts, ctx, say);
5627
- const raw = await readFile9(join17(dir, input.versionFile), "utf8");
5692
+ const raw = await readFile10(join17(dir, input.versionFile), "utf8");
5628
5693
  const version = sanitizeVersion(raw, ctx.published ?? "unknown");
5629
5694
  const tags = tagsFor({ ...tagInput, version });
5630
5695
  const layers = join17(steamHome(opts.config.dataRoot), "layers");
@@ -6224,6 +6289,10 @@ async function checkImage(plan, problems, asShell) {
6224
6289
  problems.push(marker);
6225
6290
  return;
6226
6291
  }
6292
+ await checkAbsentImage(plan, problems, where, problem);
6293
+ }
6294
+ async function checkAbsentImage(plan, problems, where, problem) {
6295
+ const image = plan.gameConfig.image;
6227
6296
  if (image.ref.trim() === "") {
6228
6297
  if (problem)
6229
6298
  problems.push(problem);
@@ -6393,11 +6462,143 @@ function message2(error) {
6393
6462
  import { readFileSync as readFileSync8 } from "node:fs";
6394
6463
  import { basename as basename7 } from "node:path";
6395
6464
  import { setTimeout as sleep6 } from "node:timers/promises";
6465
+
6466
+ // src/docker/icon.ts
6467
+ import { execFile } from "node:child_process";
6468
+ import { connect } from "node:net";
6469
+ import { promisify } from "node:util";
6470
+ var run2 = promisify(execFile);
6471
+ var CHANGE_PROPERTY = 18;
6472
+ var INTERN_ATOM = 16;
6473
+ var CARDINAL = 6;
6474
+ function padding(length) {
6475
+ return (4 - length % 4) % 4;
6476
+ }
6477
+ function displayNumber(display) {
6478
+ return display.replace(/^.*:/, "").split(".")[0] ?? "0";
6479
+ }
6480
+ async function cookieFor(display) {
6481
+ const wanted = displayNumber(display);
6482
+ try {
6483
+ const { stdout } = await run2("xauth", ["list"]);
6484
+ for (const line of stdout.split(`
6485
+ `)) {
6486
+ const match = /^\S+:(\d+)\s+MIT-MAGIC-COOKIE-1\s+([0-9a-f]+)$/.exec(line.trim());
6487
+ if (match && match[1] === wanted)
6488
+ return Buffer.from(match[2], "hex");
6489
+ }
6490
+ } catch {
6491
+ return null;
6492
+ }
6493
+ return null;
6494
+ }
6495
+ async function argbPixels(path, size) {
6496
+ for (const tool of ["magick", "convert"]) {
6497
+ try {
6498
+ const { stdout } = await run2(tool, [path, "-resize", `${size}x${size}!`, "-depth", "8", "RGBA:-"], { encoding: "buffer", maxBuffer: 64 * 1024 * 1024 });
6499
+ if (stdout.length === size * size * 4)
6500
+ return stdout;
6501
+ } catch {
6502
+ continue;
6503
+ }
6504
+ }
6505
+ return null;
6506
+ }
6507
+ async function openDisplay(display) {
6508
+ const cookie = await cookieFor(display);
6509
+ if (cookie === null)
6510
+ return null;
6511
+ const socket = connect(`/tmp/.X11-unix/X${displayNumber(display)}`);
6512
+ try {
6513
+ await new Promise((resolve, reject) => {
6514
+ socket.once("connect", resolve);
6515
+ socket.once("error", reject);
6516
+ });
6517
+ } catch {
6518
+ return null;
6519
+ }
6520
+ const name = Buffer.from("MIT-MAGIC-COOKIE-1");
6521
+ const head = Buffer.alloc(12);
6522
+ head.write("l", 0, "ascii");
6523
+ head.writeUInt16LE(11, 2);
6524
+ head.writeUInt16LE(name.length, 6);
6525
+ head.writeUInt16LE(cookie.length, 8);
6526
+ socket.write(Buffer.concat([
6527
+ head,
6528
+ name,
6529
+ Buffer.alloc(padding(name.length)),
6530
+ cookie,
6531
+ Buffer.alloc(padding(cookie.length))
6532
+ ]));
6533
+ const reply = await new Promise((resolve) => socket.once("data", resolve));
6534
+ if (reply[0] !== 1) {
6535
+ socket.destroy();
6536
+ return null;
6537
+ }
6538
+ return socket;
6539
+ }
6540
+ function internAtom(socket, name) {
6541
+ const length = 8 + name.length + padding(name.length);
6542
+ const request = Buffer.alloc(length);
6543
+ request.writeUInt8(INTERN_ATOM, 0);
6544
+ request.writeUInt16LE(length / 4, 2);
6545
+ request.writeUInt16LE(name.length, 4);
6546
+ request.write(name, 8, "ascii");
6547
+ socket.write(request);
6548
+ return new Promise((resolve) => socket.once("data", (d) => resolve(d.readUInt32LE(8))));
6549
+ }
6550
+ function iconProperty(rgba, size) {
6551
+ const body = Buffer.alloc(8 + size * size * 4);
6552
+ body.writeUInt32LE(size, 0);
6553
+ body.writeUInt32LE(size, 4);
6554
+ for (let i = 0;i < size * size; i += 1) {
6555
+ const [r, g, b, a] = [rgba[i * 4], rgba[i * 4 + 1], rgba[i * 4 + 2], rgba[i * 4 + 3]];
6556
+ body.writeUInt32LE((a << 24 | r << 16 | g << 8 | b) >>> 0, 8 + i * 4);
6557
+ }
6558
+ return body;
6559
+ }
6560
+ var ICON_SIZE = 64;
6561
+ async function setWindowIcon(windowId, iconPath) {
6562
+ const display = process.env.DISPLAY;
6563
+ if (display === undefined || display === "")
6564
+ return;
6565
+ const rgba = await argbPixels(iconPath, ICON_SIZE);
6566
+ if (rgba === null) {
6567
+ warn(`could not read ${iconPath}, so the window keeps its own icon`);
6568
+ return;
6569
+ }
6570
+ const socket = await openDisplay(display);
6571
+ if (socket === null) {
6572
+ warn("could not reach the X server to set the window icon");
6573
+ return;
6574
+ }
6575
+ try {
6576
+ const atom = await internAtom(socket, "_NET_WM_ICON");
6577
+ const body = iconProperty(rgba, ICON_SIZE);
6578
+ const request = Buffer.alloc(24);
6579
+ request.writeUInt8(CHANGE_PROPERTY, 0);
6580
+ request.writeUInt16LE((24 + body.length) / 4, 2);
6581
+ request.writeUInt32LE(Number.parseInt(windowId, 16), 4);
6582
+ request.writeUInt32LE(atom, 8);
6583
+ request.writeUInt32LE(CARDINAL, 12);
6584
+ request.writeUInt8(32, 16);
6585
+ request.writeUInt32LE(body.length / 4, 20);
6586
+ socket.write(Buffer.concat([request, body]));
6587
+ await new Promise((resolve) => setTimeout(resolve, 100));
6588
+ } finally {
6589
+ socket.end();
6590
+ }
6591
+ }
6592
+
6593
+ // src/docker/window.ts
6396
6594
  var WAIT_MS = 180000;
6397
6595
  var POLL_MS = 500;
6398
6596
  function newMatches(now, seen, executable) {
6399
6597
  const wanted = basename7(executable).toLowerCase();
6400
- return now.filter((w) => !seen.has(w.id) && w.wmClass.toLowerCase().includes(wanted));
6598
+ return now.filter((w) => !seen.has(w.id) && classMatches(w.wmClass, wanted));
6599
+ }
6600
+ function classMatches(wmClass, wanted) {
6601
+ return wmClass.toLowerCase().split(".").some((part) => part.length >= 3 && (wanted.includes(part) || part.includes(wanted)));
6401
6602
  }
6402
6603
  function parseWindowPid(stdout) {
6403
6604
  const match = /_NET_WM_PID\(CARDINAL\)\s*=\s*(\d+)/.exec(stdout);
@@ -6466,6 +6667,8 @@ async function adoptFirstMatch(seen, opts, stopped) {
6466
6667
  if (!await adopt(match.id, opts))
6467
6668
  continue;
6468
6669
  await capture(["wmctrl", "-i", "-r", match.id, "-N", opts.title]);
6670
+ if (opts.icon !== undefined)
6671
+ await setWindowIcon(match.id, opts.icon);
6469
6672
  if (opts.stripDelete)
6470
6673
  await watchForClose(match.id, stopped, opts.onClosed);
6471
6674
  return true;
@@ -6533,7 +6736,7 @@ function parseAtoms(stdout) {
6533
6736
  }
6534
6737
 
6535
6738
  // src/launch/generate.ts
6536
- import { mkdir as mkdir5, readdir as readdir9, readFile as readFile10, writeFile as writeFile5 } from "node:fs/promises";
6739
+ import { mkdir as mkdir5, readdir as readdir9, readFile as readFile11, writeFile as writeFile5 } from "node:fs/promises";
6537
6740
  import { dirname as dirname12, join as join20 } from "node:path";
6538
6741
  async function readInstallVersion(game, plugin) {
6539
6742
  const raw = await installVersionText(game);
@@ -6550,7 +6753,7 @@ async function installVersionText(game) {
6550
6753
  if (host === undefined)
6551
6754
  return null;
6552
6755
  try {
6553
- return await readFile10(join20(expandHome(host), game.version.file), "utf8");
6756
+ return await readFile11(join20(expandHome(host), game.version.file), "utf8");
6554
6757
  } catch {
6555
6758
  return null;
6556
6759
  }
@@ -6575,7 +6778,7 @@ async function readKnownExpansions(game, plugin, warnings) {
6575
6778
  continue;
6576
6779
  let id = null;
6577
6780
  try {
6578
- id = plugin.parseManifest(await readFile10(join20(dataDir, entry.name, game.manifest.file), "utf8"))?.packageId ?? null;
6781
+ id = plugin.parseManifest(await readFile11(join20(dataDir, entry.name, game.manifest.file), "utf8"))?.packageId ?? null;
6579
6782
  } catch {
6580
6783
  continue;
6581
6784
  }
@@ -6646,7 +6849,7 @@ async function mergePrefs(plan) {
6646
6849
  await mkdir5(dirname12(target), { recursive: true });
6647
6850
  let existing = null;
6648
6851
  try {
6649
- existing = await readFile10(target, "utf8");
6852
+ existing = await readFile11(target, "utf8");
6650
6853
  } catch (error) {
6651
6854
  if (error.code !== "ENOENT")
6652
6855
  throw error;
@@ -6735,7 +6938,7 @@ async function offerRebuild(input) {
6735
6938
 
6736
6939
  // src/launch/supervisor.ts
6737
6940
  import { existsSync as existsSync14 } from "node:fs";
6738
- import { readFile as readFile11, readlink, rm as rm4, writeFile as writeFile6 } from "node:fs/promises";
6941
+ import { readFile as readFile12, readlink, rm as rm4, writeFile as writeFile6 } from "node:fs/promises";
6739
6942
  import { basename as basename8, join as join22 } from "node:path";
6740
6943
  import { setTimeout as sleep7 } from "node:timers/promises";
6741
6944
  async function forkSupervisor(plan, argv) {
@@ -6802,7 +7005,7 @@ async function writeExit(instanceDir, record) {
6802
7005
  `);
6803
7006
  }
6804
7007
  async function lastExit(instanceDir) {
6805
- const text = await readFile11(join22(instanceDir, ".gamecrate", "last-exit.json"), "utf8").catch(() => {
7008
+ const text = await readFile12(join22(instanceDir, ".gamecrate", "last-exit.json"), "utf8").catch(() => {
6806
7009
  return;
6807
7010
  });
6808
7011
  if (text === undefined)
@@ -7463,7 +7666,7 @@ async function detectForeignOwnership(dir, uid, limit = 100) {
7463
7666
 
7464
7667
  // src/mods/workshop.ts
7465
7668
  import { existsSync as existsSync15 } from "node:fs";
7466
- import { readFile as readFile12 } from "node:fs/promises";
7669
+ import { readFile as readFile13 } from "node:fs/promises";
7467
7670
  import { join as join27, resolve as resolvePath2 } from "node:path";
7468
7671
  var ROUNDS = 5;
7469
7672
  async function prepareWorkshop(game, profileName, args, config, allowFetch, plugin, sources) {
@@ -7526,7 +7729,7 @@ async function manifestAt(dir, game, plugin, problems) {
7526
7729
  if (!existsSync15(file))
7527
7730
  return null;
7528
7731
  try {
7529
- return plugin.parseManifest(await readFile12(file, "utf8"));
7732
+ return plugin.parseManifest(await readFile13(file, "utf8"));
7530
7733
  } catch (error) {
7531
7734
  problems.push({ where: file, message: error instanceof Error ? error.message : String(error) });
7532
7735
  return null;
@@ -7598,7 +7801,7 @@ function published2(value) {
7598
7801
  }
7599
7802
 
7600
7803
  // src/index.ts
7601
- var VERSION = "2.3.1";
7804
+ var VERSION = "2.5.0";
7602
7805
  async function main(argv) {
7603
7806
  const supervised = supervisedDir(argv);
7604
7807
  try {
@@ -7678,17 +7881,21 @@ async function dispatch(argv, args, config, plugins, defaults) {
7678
7881
  case "wait":
7679
7882
  return waitFor(args, config, defaults);
7680
7883
  case "shell":
7681
- return run2(argv, args, config, plugins, defaults, true);
7884
+ return run3(argv, args, config, plugins, defaults, true);
7682
7885
  case "config":
7683
7886
  return configEdit(args);
7684
7887
  case "fix-perms":
7685
7888
  return fixPerms(args, config);
7686
7889
  case "run":
7687
- return run2(argv, args, config, plugins, defaults, false);
7890
+ return run3(argv, args, config, plugins, defaults, false);
7688
7891
  default:
7689
7892
  throw new GamecrateError(`no such subcommand ${args.subcommand}`, Exit.Usage);
7690
7893
  }
7691
7894
  }
7895
+ function iconOption(plan, configFile) {
7896
+ const icon = windowIcon(plan, dirname14(configFile));
7897
+ return icon === undefined ? {} : { icon };
7898
+ }
7692
7899
  function helpTopic(args) {
7693
7900
  if (args.subcommand === "help")
7694
7901
  return args.rest[0];
@@ -7730,7 +7937,7 @@ function reportEnvironment(problems) {
7730
7937
  throw new GamecrateError(`${problems.length} environment problem(s)`, Exit.Environment, out.join(`
7731
7938
  `));
7732
7939
  }
7733
- async function run2(argv, args, config, plugins, defaults, asShell) {
7940
+ async function run3(argv, args, config, plugins, defaults, asShell) {
7734
7941
  const game = requireGame(args, config);
7735
7942
  const profile = launchProfile(args, defaults, config.games[game]);
7736
7943
  const gameConfig = gameForImage(args, config, defaults, game);
@@ -7821,7 +8028,7 @@ run: gamecrate fix-perms ${game} ${profile}`);
7821
8028
  }
7822
8029
  async function execute(inputs) {
7823
8030
  const { plan, args, config, identity, asShell, profileSpec, runDir, releaseSources } = inputs;
7824
- await buildLocalMods(plan, buildPolicy(args, profileSpec));
8031
+ await buildLocalMods(plan, buildPolicy(args, profileSpec), currentRefs(plan.game));
7825
8032
  await releaseSources();
7826
8033
  const { facts, imageStart } = await readyImage(plan, config, args, asShell);
7827
8034
  const modMounts = await stageMods(plan);
@@ -7910,6 +8117,7 @@ async function dispatchRun(spec, plan, runDir, trustExit, asShell) {
7910
8117
  const window = asShell || plan.settings.display !== "x11" ? null : await adoptNewWindow({
7911
8118
  executable: plan.gameConfig.executable,
7912
8119
  title: windowTitle(plan),
8120
+ ...iconOption(plan, await globalConfigPath()),
7913
8121
  stripDelete: plan.gameConfig.ignoresWmDelete === true,
7914
8122
  onClosed: () => {
7915
8123
  windowClosed = true;
@@ -8088,12 +8296,38 @@ async function doctor(config, plugins) {
8088
8296
  const gameConfig = config.games[game];
8089
8297
  const sources = cachedSources(gameConfig, "modless", {}, config.dataRoot);
8090
8298
  const { plan, problems } = await resolvePlan({ game, profile: "modless", root: config, plugins, sources });
8091
- const all = [...problems, ...await preflight(plan), ...steamcmdProblems(game, gameConfig, config)];
8299
+ const all = [
8300
+ ...problems,
8301
+ ...await preflight(plan),
8302
+ ...steamcmdProblems(game, gameConfig, config),
8303
+ ...await baseDriftProblems(game, gameConfig)
8304
+ ];
8092
8305
  if (!reportDoctor(game, all))
8093
8306
  failed = true;
8094
8307
  }
8095
8308
  return failed ? Exit.Environment : Exit.Ok;
8096
8309
  }
8310
+ async function baseDriftProblems(game, gameConfig) {
8311
+ const ref = gameConfig.image.ref;
8312
+ if (ref.trim() === "")
8313
+ return [];
8314
+ const facts = await readImageFacts(ref);
8315
+ if (!facts.present || facts.runtime === null)
8316
+ return [];
8317
+ const base = RUNTIME_BASE[facts.launcher === "proton" ? "proton" : "xvfb"];
8318
+ const current = await repoDigest(base);
8319
+ if (current === null)
8320
+ return [];
8321
+ if (facts.runtime.endsWith(current))
8322
+ return [];
8323
+ return [
8324
+ {
8325
+ where: `/games/${game}/image/ref`,
8326
+ message: `${ref} was built on an older ${base}`,
8327
+ suggestion: `gamecrate steam build ${game} to rebuild it on the base you have`
8328
+ }
8329
+ ];
8330
+ }
8097
8331
  function steamcmdProblems(game, gameConfig, config) {
8098
8332
  if (!usesWorkshop(gameConfig))
8099
8333
  return [];
@@ -8147,7 +8381,7 @@ async function logs(args, config, defaults) {
8147
8381
  }
8148
8382
  status(runDir);
8149
8383
  for (const name of files) {
8150
- const text = await readFile13(join28(runDir, name), "utf8").catch(() => "");
8384
+ const text = await readFile14(join28(runDir, name), "utf8").catch(() => "");
8151
8385
  for (const line of text.split(`
8152
8386
  `)) {
8153
8387
  if (line.length > 0)