@gamecrate/cli 2.3.0 → 2.4.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
@@ -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,2208 +1037,2239 @@ function requireGame(args, config) {
1033
1037
  }
1034
1038
 
1035
1039
  // src/image/refs.ts
1036
- import { existsSync as existsSync8 } from "node:fs";
1040
+ import { existsSync as existsSync8, readlinkSync } from "node:fs";
1037
1041
  import { mkdir as mkdir3, 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 HOST_X11_DIR = "/run/host-x11";
1060
- var CONTAINER_XAUTHORITY = "/tmp/xauth";
1061
- var CONTAINER_XDG_DIR = "/xdg";
1062
- var RUNTIME_DIR_SIZE = "64m";
1063
- var HOME_SIZE = "64m";
1064
- var MASK_SIZE = "1m";
1065
- function refuseProtonHeaded(game, mode, image) {
1066
- if (image?.launcher !== "proton" || mode !== "headed")
1067
- return;
1068
- 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);
1069
1081
  }
1070
- function buildRunSpec(plan, modMounts, identity, image) {
1071
- const { gameConfig: game, settings } = plan;
1072
- const headed = plan.mode === "headed";
1073
- const mounts = [];
1074
- const env = {
1075
- HOME: identity.home,
1076
- USER: identity.user,
1077
- LOGNAME: identity.user
1078
- };
1079
- addGameFiles(mounts, plan);
1080
- addStage(mounts, plan, modMounts);
1081
- const proton = image?.launcher === "proton";
1082
- const executable = image?.executable ?? game.executable;
1083
- refuseProtonHeaded(plan.game, plan.mode, image);
1084
- const command = proton ? ["run-headless-windows", winPath(join(game.gameFiles.container, basename(executable)))] : headed ? ["run-headed", executable] : [
1085
- "xvfb-run",
1086
- "-a",
1087
- `--server-args=-screen 0 ${settings.width}x${settings.height}x24`,
1088
- executable
1089
- ];
1090
- if (headed)
1091
- env.SCREEN = `${settings.width}x${settings.height}x24`;
1092
- if (proton) {
1093
- Object.assign(env, {
1094
- SCREEN: `${settings.width}x${settings.height}x24`,
1095
- DESKTOP: `${settings.width}x${settings.height}`,
1096
- STEAM_COMPAT_DATA_PATH: `${CONTAINER_XDG_DIR}/proton`
1097
- });
1098
- }
1099
- if (game.dataDir.mode === "arg") {
1100
- const arg = validateDataDirArg(game.dataDir);
1101
- const eq = arg.indexOf("=");
1102
- command.push(proton ? `${arg.slice(0, eq + 1)}${winPath(arg.slice(eq + 1))}` : arg);
1103
- } else
1104
- Object.assign(env, game.dataDir.env);
1105
- if (game.logFile.mode === "arg") {
1106
- mounts.push({ type: "bind", source: hostPath(plan.runDirHost), target: CONTAINER_LOG_DIR });
1107
- const log = `${CONTAINER_LOG_DIR}/Player.log`;
1108
- command.push(game.logFile.arg, proton ? winPath(log) : log);
1109
- }
1110
- addScratch(mounts, env, plan, identity);
1111
- if (headed)
1112
- addSession(mounts, env, plan, identity);
1113
- const deviceCgroupRules = [];
1114
- if (settings.input) {
1115
- mounts.push({ type: "bind", source: "/dev/input", target: "/dev/input", readonly: true });
1116
- 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");
1117
1088
  }
1118
- const devices = [];
1119
- if (settings.gpu)
1120
- devices.push("nvidia.com/gpu=all");
1121
- Object.assign(env, glEnv(settings.gpu));
1122
- command.push(...settings.gameArgs ?? []);
1123
- return {
1124
- image: game.image.ref,
1125
- name: containerName(plan),
1126
- labels: {
1127
- "gamecrate.game": plan.game,
1128
- "gamecrate.profile": plan.profile,
1129
- ...plan.instance === undefined ? {} : { "gamecrate.instance": plan.instance }
1130
- },
1131
- identity,
1132
- env,
1133
- mounts,
1134
- devices,
1135
- deviceCgroupRules,
1136
- network: settings.network,
1137
- memory: settings.memory,
1138
- memorySwap: settings.memory,
1139
- cpus: settings.cpus,
1140
- pidsLimit: settings.pidsLimit,
1141
- ulimits: ["core=0"],
1142
- workdir: game.gameFiles.container,
1143
- ...headed && settings.display === "x11" ? { hostname: hostname() } : {},
1144
- command,
1145
- extraArgs: [...settings.dockerArgs ?? []]
1146
- };
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");
1147
1095
  }
1148
- function addGameFiles(mounts, plan) {
1149
- const { gameFiles } = plan.gameConfig;
1150
- if (gameFiles.source !== "mount")
1151
- return;
1152
- if (!gameFiles.host) {
1153
- 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;
1154
1106
  }
1155
- mounts.push({ type: "bind", source: hostPath(gameFiles.host), target: gameFiles.container, readonly: true });
1156
1107
  }
1157
- function addStage(mounts, plan, modMounts) {
1158
- const game = plan.gameConfig;
1159
- mounts.push({ type: "bind", source: hostPath(plan.stageDirHost), target: game.modsDir.container, readonly: true });
1160
- for (const mount of modMounts) {
1161
- 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
+ }
1162
1118
  }
1163
- mounts.push({ type: "bind", source: hostPath(plan.dataDirHost), target: game.dataDir.container });
1119
+ return statSync(target, { throwIfNoEntry: false })?.isDirectory() ? entryOf(target) : target;
1164
1120
  }
1165
- function addScratch(mounts, env, plan, identity) {
1166
- const { uid, gid } = identity;
1167
- for (const target of plan.gameConfig.modsDir.mask ?? []) {
1168
- 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}`);
1169
1127
  }
1170
- if (uid !== 0) {
1171
- 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");
1172
1135
  }
1173
- mounts.push({ type: "tmpfs", target: CONTAINER_RUNTIME_DIR, size: RUNTIME_DIR_SIZE, uid, gid, mode: "700" });
1174
- env.XDG_RUNTIME_DIR = CONTAINER_RUNTIME_DIR;
1175
- mounts.push({ type: "bind", source: hostPath(plan.configDirHost), target: CONTAINER_XDG_DIR });
1176
- env.XDG_CONFIG_HOME = `${CONTAINER_XDG_DIR}/config`;
1177
- env.XDG_CACHE_HOME = `${CONTAINER_XDG_DIR}/cache`;
1178
- env.XDG_DATA_HOME ??= `${CONTAINER_XDG_DIR}/data`;
1179
- }
1180
- function addSession(mounts, env, plan, identity) {
1181
- const { settings } = plan;
1182
- if (settings.display === "x11")
1183
- addX11(mounts, env, identity);
1184
- else
1185
- addWayland(mounts, env);
1186
- if (!settings.audio)
1187
- return;
1188
- for (const socket of audioSockets()) {
1189
- 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");
1190
1138
  }
1191
- env.PULSE_SERVER = `unix:${CONTAINER_RUNTIME_DIR}/pulse/native`;
1192
- }
1193
- function addX11(mounts, env, identity) {
1194
- const x11 = x11Session();
1195
- if (!x11)
1196
- return;
1197
- mounts.push({ type: "tmpfs", target: X11_SOCKET_DIR, uid: identity.uid, gid: identity.gid, mode: "1777" });
1198
- mounts.push({ type: "bind", source: X11_SOCKET_DIR, target: HOST_X11_DIR, readonly: true });
1199
- env.HOST_X11_DIR = HOST_X11_DIR;
1200
- env.DISPLAY = x11.display;
1201
- env.XDG_SESSION_TYPE = "x11";
1202
- env.SDL_VIDEODRIVER = "x11";
1203
- env.QT_QPA_PLATFORM = "xcb";
1204
- if (!x11.xauthority)
1205
- return;
1206
- mounts.push({ type: "bind", source: x11.xauthority, target: CONTAINER_XAUTHORITY, readonly: true });
1207
- env.XAUTHORITY = CONTAINER_XAUTHORITY;
1208
- }
1209
- function addWayland(mounts, env) {
1210
- const wayland = waylandSocket();
1211
- if (!wayland)
1212
- return;
1213
- mounts.push({ type: "bind", source: wayland.source, target: `${CONTAINER_RUNTIME_DIR}/${wayland.name}` });
1214
- env.WAYLAND_DISPLAY = wayland.name;
1215
- env.XDG_SESSION_TYPE = "wayland";
1216
- env.SDL_VIDEODRIVER = "wayland";
1217
- env.QT_QPA_PLATFORM = "wayland";
1218
- }
1219
- function containerName(plan) {
1220
- const base = `gamecrate-${plan.game}-${plan.profile}`;
1221
- return plan.instance === undefined ? base : `${base}-${plan.instance}`;
1139
+ return plugin;
1222
1140
  }
1223
- function windowTitle(plan) {
1224
- const base = `${plan.game} ${plan.profile}`;
1225
- 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;
1226
1158
  }
1227
- function toDockerArgs(spec) {
1228
- const args = ["run", "--rm", "--init", "--name", spec.name];
1229
- if (spec.hostname !== undefined)
1230
- args.push("--hostname", spec.hostname);
1231
- for (const [key, value] of Object.entries(spec.labels))
1232
- args.push("--label", `${key}=${value}`);
1233
- args.push("--user", `${spec.identity.uid}:${spec.identity.gid}`);
1234
- for (const [key, value] of Object.entries(spec.env))
1235
- args.push("--env", `${key}=${value}`);
1236
- for (const mount of spec.mounts)
1237
- args.push(...mountArgs(mount));
1238
- for (const device of spec.devices)
1239
- args.push("--device", device);
1240
- for (const rule of spec.deviceCgroupRules)
1241
- args.push("--device-cgroup-rule", rule);
1242
- for (const ulimit of spec.ulimits)
1243
- args.push("--ulimit", ulimit);
1244
- 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");
1245
- const [entrypoint, ...rest] = spec.command;
1246
- if (entrypoint !== undefined)
1247
- args.push("--entrypoint", entrypoint);
1248
- args.push(spec.image, ...rest);
1249
- return args;
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;
1250
1165
  }
1251
- function mountArgs(mount) {
1252
- if (mount.type === "tmpfs") {
1253
- const opts = ["rw"];
1254
- if (mount.uid !== undefined)
1255
- opts.push(`uid=${mount.uid}`);
1256
- if (mount.gid !== undefined)
1257
- opts.push(`gid=${mount.gid}`);
1258
- if (mount.mode)
1259
- opts.push(`mode=${mount.mode}`);
1260
- opts.push(`size=${mount.size ?? RUNTIME_DIR_SIZE}`);
1261
- return ["--tmpfs", `${mount.target}:${opts.join(",")}`];
1262
- }
1263
- if (!mount.source) {
1264
- 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}`);
1265
1199
  }
1266
- const fields = [`type=bind`, `src=${mount.source}`, `dst=${mount.target}`];
1267
- if (mount.readonly)
1268
- fields.push("readonly");
1269
- return ["--mount", fields.map(csvField).join(",")];
1270
- }
1271
- function csvField(field) {
1272
- if (!field.includes(",") && !field.includes('"'))
1273
- return field;
1274
- return `"${field.replaceAll('"', '""')}"`;
1200
+ return value;
1275
1201
  }
1276
- function winPath(unix) {
1277
- 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(", ")}`);
1278
1212
  }
1279
- function validateDataDirArg(dataDir) {
1280
- if (dataDir.container.includes("=")) {
1281
- 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.");
1282
- }
1283
- const parts = dataDir.arg.split("=");
1284
- if (parts.length !== 2) {
1285
- throw new GamecrateError(`dataDir.arg must contain exactly one "=": ${dataDir.arg}`, Exit.Config);
1286
- }
1287
- if (trimSlash(parts[1] ?? "") !== trimSlash(dataDir.container)) {
1288
- 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
+ }
1289
1223
  }
1290
- return dataDir.arg;
1291
- }
1292
- function trimSlash(path) {
1293
- let end = path.length;
1294
- while (end > 1 && path[end - 1] === "/")
1295
- end--;
1296
- return path.slice(0, end);
1297
- }
1298
- function glEnv(gpu) {
1299
- if (!gpu)
1300
- return { LIBGL_ALWAYS_SOFTWARE: "1", GALLIUM_DRIVER: "llvmpipe" };
1301
- const env = { LIBGL_ALWAYS_SOFTWARE: "0", GALLIUM_DRIVER: "" };
1302
- if (hasNvidia()) {
1303
- env.__GLX_VENDOR_LIBRARY_NAME = "nvidia";
1304
- 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);
1305
1228
  }
1306
- return env;
1307
- }
1308
- function hasNvidia() {
1309
- return existsSync("/dev/nvidiactl") || existsSync("/etc/cdi/nvidia.yaml") || existsSync("/usr/share/vulkan/icd.d/nvidia_icd.json");
1310
- }
1311
- function x11Session() {
1312
- const display = process.env.DISPLAY;
1313
- if (!display)
1314
- return null;
1315
- const cookie = process.env.XAUTHORITY ?? join(homedir(), ".Xauthority");
1316
- return { display, xauthority: existsSync(cookie) ? cookie : null };
1317
- }
1318
- function waylandSocket() {
1319
- const display = process.env.WAYLAND_DISPLAY;
1320
- const runtime = process.env.XDG_RUNTIME_DIR;
1321
- if (!display)
1322
- return null;
1323
- let source = null;
1324
- if (display.startsWith("/"))
1325
- source = display;
1326
- else if (runtime)
1327
- source = join(runtime, display);
1328
- if (!source || !existsSync(source))
1329
- return null;
1330
- return { source, name: basename(source) };
1331
1229
  }
1332
- function audioSockets() {
1333
- const runtime = process.env.XDG_RUNTIME_DIR;
1334
- if (!runtime)
1335
- return [];
1336
- const found = [];
1337
- for (const name of ["pipewire-0", "pulse/native"]) {
1338
- const source = join(runtime, name);
1339
- if (existsSync(source))
1340
- 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;
1341
1238
  }
1342
- return found;
1239
+ return readConfigText(text, path);
1343
1240
  }
1344
- function hostPath(path) {
1345
- try {
1346
- return realpathSync(path);
1347
- } catch {
1348
- 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));
1349
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));
1350
1254
  }
1351
1255
 
1352
- // src/docker/run.ts
1353
- function spawnArgv(argv, stdio, detached = false) {
1354
- return spawn(argv[0], argv.slice(1), { stdio, detached });
1355
- }
1356
- function exited(proc) {
1357
- return new Promise((resolve, reject) => {
1358
- proc.once("error", reject);
1359
- 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"
1360
1264
  });
1361
1265
  }
1362
- async function collect2(stream) {
1363
- const chunks = [];
1364
- for await (const chunk of stream)
1365
- chunks.push(chunk);
1366
- return Buffer.concat(chunks).toString("utf8");
1367
- }
1368
- async function capture(argv) {
1266
+ function hintsFor(message) {
1267
+ if (!message.startsWith(HINTS))
1268
+ return [];
1369
1269
  try {
1370
- const proc = spawnArgv(argv, ["ignore", "pipe", "pipe"]);
1371
- const [stdout, stderr, code] = await Promise.all([
1372
- collect2(proc.stdout),
1373
- collect2(proc.stderr),
1374
- exited(proc)
1375
- ]);
1376
- return { code, stdout, stderr };
1377
- } catch (error) {
1378
- return { code: 127, stdout: "", stderr: error instanceof Error ? error.message : String(error) };
1270
+ return JSON.parse(message.slice(HINTS.length));
1271
+ } catch {
1272
+ return [];
1379
1273
  }
1380
1274
  }
1381
- async function captureLive(argv, env) {
1382
- const proc = spawn(argv[0], argv.slice(1), { stdio: ["ignore", "pipe", "pipe"], env });
1383
- const chunks = [];
1384
- const keep = async (stream) => {
1385
- for await (const chunk of stream) {
1386
- process.stderr.write(chunk);
1387
- chunks.push(chunk);
1388
- }
1389
- };
1390
- const [, , code] = await Promise.all([keep(proc.stdout), keep(proc.stderr), exited(proc)]);
1391
- return { code, text: Buffer.concat(chunks).toString("utf8") };
1392
- }
1393
- var STDOUT_LOG = "stdout.log";
1394
- var MARKER_POLL_MS = 200;
1395
- async function runContainer(spec, opts) {
1396
- const stopTimeout = opts.stopTimeoutSeconds ?? 10;
1397
- mkdirSync(opts.logDir, { recursive: true });
1398
- const sink = createWriteStream(join2(opts.logDir, STDOUT_LOG));
1399
- const proc = spawnArgv(["docker", ...toDockerArgs(spec)], ["inherit", "pipe", "pipe"]);
1400
- let interrupted = false;
1401
- const onSignal = () => {
1402
- if (interrupted)
1275
+ function requiredWhen(key, when) {
1276
+ return (ctx) => {
1277
+ if (!when(ctx.value) || ctx.value[key] !== undefined)
1403
1278
  return;
1404
- interrupted = true;
1405
- stopContainer(spec.name, stopTimeout);
1279
+ ctx.issues.push({ code: "custom", message: `missing required key "${key}"`, path: [key], input: ctx.value });
1406
1280
  };
1407
- process.on("SIGINT", onSignal);
1408
- process.on("SIGTERM", onSignal);
1409
- const code = exited(proc);
1410
- try {
1411
- await Promise.all([
1412
- tee(proc.stdout, sink, process.stdout),
1413
- tee(proc.stderr, sink, process.stderr)
1414
- ]);
1415
- const status = await code;
1416
- return interrupted ? Exit.Interrupted : status;
1417
- } finally {
1418
- process.off("SIGINT", onSignal);
1419
- process.off("SIGTERM", onSignal);
1420
- await new Promise((resolve) => sink.end(resolve));
1421
- }
1422
1281
  }
1423
- var STOP_TIMEOUT_SECONDS = 10;
1424
- async function stopContainer(name, timeoutSeconds) {
1425
- const proc = spawnArgv(["docker", "stop", "--timeout", String(timeoutSeconds), name], "ignore");
1426
- await exited(proc).catch(() => {});
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(", ")}` });
1427
1293
  }
1428
- async function waitForMarker(sources, marker, timeoutSeconds) {
1429
- const deadline = Date.now() + timeoutSeconds * 1000;
1430
- const carry = Math.max(marker.length - 1, 0);
1431
- const seen = new Map;
1432
- const startedAt = Date.now();
1433
- while (true) {
1434
- for (const path of await expandSources(sources)) {
1435
- let state = seen.get(path);
1436
- if (state === undefined) {
1437
- state = { offset: await staleSize(path, startedAt), tail: "", decoder: new TextDecoder };
1438
- seen.set(path, state);
1439
- }
1440
- if (await scan(path, state, marker, carry))
1441
- return true;
1442
- }
1443
- if (Date.now() >= deadline)
1444
- return false;
1445
- await sleep(Math.min(MARKER_POLL_MS, Math.max(deadline - Date.now(), 0)));
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;
1446
1342
  }
1447
- }
1448
- async function staleSize(path, startedAt) {
1449
- return stat(path).then((info) => info.mtimeMs < startedAt ? info.size : 0, () => 0);
1450
- }
1451
- async function scan(path, state, marker, carry) {
1452
- const handle = await open(path, "r").catch(() => null);
1453
- if (handle === null)
1454
- return false;
1455
- try {
1456
- const { size } = await handle.stat();
1457
- if (size < state.offset) {
1458
- state.offset = 0;
1459
- state.tail = "";
1460
- state.decoder = new TextDecoder;
1461
- }
1462
- if (size <= state.offset)
1463
- return false;
1464
- const buffer = Buffer.alloc(size - state.offset);
1465
- const { bytesRead } = await handle.read(buffer, 0, buffer.length, state.offset);
1466
- state.offset += bytesRead;
1467
- const text = state.tail + state.decoder.decode(buffer.subarray(0, bytesRead), { stream: true });
1468
- if (text.includes(marker))
1469
- return true;
1470
- state.tail = carry > 0 ? text.slice(-carry) : "";
1471
- return false;
1472
- } catch {
1473
- return false;
1474
- } finally {
1475
- await handle.close().catch(() => {});
1343
+ if (!isObj(value)) {
1344
+ ctx.issues.push({ code: "custom", message: "expected a packageId string or an object", input: value });
1345
+ return;
1476
1346
  }
1477
- }
1478
- async function expandSources(sources) {
1479
- const out = [];
1480
- for (const source of sources) {
1481
- const info = await stat(source).catch(() => null);
1482
- if (info === null) {
1483
- out.push(source);
1484
- continue;
1485
- }
1486
- if (!info.isDirectory()) {
1487
- out.push(source);
1488
- continue;
1489
- }
1490
- const entries = await readdir(source).catch(() => []);
1491
- for (const entry of entries) {
1492
- if (entry.toLowerCase().endsWith(".log"))
1493
- out.push(join2(source, entry));
1347
+ const schema = value["match"] !== undefined ? dynamicModEntry : objectModEntry;
1348
+ const result = schema.safeParse(value);
1349
+ if (result.success)
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
+ });
1382
+ }
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]);
1494
1408
  }
1495
1409
  }
1496
- return out;
1497
- }
1498
- async function tee(stream, sink, mirror) {
1499
- for await (const chunk of stream) {
1500
- mirror.write(chunk);
1501
- sink.write(chunk);
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"]);
1502
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;
1503
1428
  }
1504
-
1505
- // src/mods/staleness.ts
1506
- import { readdir as readdir2, stat as stat2 } from "node:fs/promises";
1507
- import { join as join3, relative } from "node:path";
1508
- var SKIP_DIRS = new Set([".git", ".retired", ".vs", "bin", "node_modules", "obj"]);
1509
- var ENTRY_LIMIT = 20000;
1510
- async function scanBuildTimes(dir) {
1511
- const state = { root: dir, times: { sourceTimes: [] }, budget: ENTRY_LIMIT };
1512
- await walk(state, dir, false);
1513
- return state.times;
1429
+ function describe(value) {
1430
+ return typeof value === "string" ? value : JSON.stringify(value);
1514
1431
  }
1515
- async function walk(state, current, inAssemblies) {
1516
- let entries;
1517
- try {
1518
- entries = await readdir2(current, { withFileTypes: true });
1519
- } catch {
1520
- return;
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
+ });
1442
+ };
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"]);
1521
1447
  }
1522
- for (const entry of entries) {
1523
- if (state.budget-- <= 0)
1524
- return;
1525
- const path = join3(current, entry.name);
1526
- if (entry.isDirectory()) {
1527
- if (!SKIP_DIRS.has(entry.name.toLowerCase())) {
1528
- await walk(state, path, inAssemblies || entry.name === "Assemblies");
1529
- }
1530
- continue;
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"]);
1531
1453
  }
1532
- if (entry.isFile())
1533
- await record(state, path, entry.name, inAssemblies);
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))
1461
+ return;
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
+ });
1534
1468
  }
1535
- }
1536
- async function record(state, path, name, inAssemblies) {
1537
- const lower = name.toLowerCase();
1538
- const isSource = lower.endsWith(".cs");
1539
- const isAssembly = inAssemblies && lower.endsWith(".dll");
1540
- if (!isSource && !isAssembly)
1541
- return;
1542
- let mtimeMs;
1543
- try {
1544
- mtimeMs = (await stat2(path)).mtimeMs;
1545
- } catch {
1469
+ if (!Array.isArray(variants))
1546
1470
  return;
1471
+ for (const dup of repeats(variants, "name")) {
1472
+ push(`duplicate variant name "${dup.name}"`, ["variants", dup.index, "name"]);
1547
1473
  }
1548
- const { times } = state;
1549
- const found = { path: relative(state.root, path), mtimeMs };
1550
- if (isSource) {
1551
- times.sourceTimes.push(mtimeMs);
1552
- if (mtimeMs > (times.newestSource?.mtimeMs ?? -1))
1553
- times.newestSource = found;
1554
- } else if (mtimeMs > (times.newestAssembly?.mtimeMs ?? -1)) {
1555
- times.newestAssembly = found;
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;
1483
+ }
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
+ });
1489
+ }
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 };
1561
+ }
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 };
1567
+ }
1568
+ for (const [name, entry] of Object.entries(games)) {
1569
+ collect2(problems, `/games/${esc(name)}`, game, entry);
1556
1570
  }
1571
+ crossReference(problems, games);
1572
+ return { config: cfg, problems };
1557
1573
  }
1558
- var SKEW_MS = 1000;
1559
- function newerThan(source, assembly) {
1560
- return source - assembly > SKEW_MS;
1574
+ function collect2(problems, prefix, schema, value) {
1575
+ const result = schema.safeParse(value);
1576
+ if (result.success)
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);
1603
+ }
1561
1604
  }
1562
- function decideStale(times) {
1563
- const { newestSource, newestAssembly } = times;
1564
- if (newestSource === undefined)
1565
- return false;
1566
- return newestAssembly === undefined || newerThan(newestSource.mtimeMs, newestAssembly.mtimeMs);
1605
+ function pointer(path) {
1606
+ return path.map((segment) => `/${esc(String(segment))}`).join("");
1567
1607
  }
1568
- function staleReport(times) {
1569
- const { newestSource, newestAssembly } = times;
1570
- if (newestSource === undefined || newestAssembly === undefined)
1571
- return null;
1572
- if (!newerThan(newestSource.mtimeMs, newestAssembly.mtimeMs))
1573
- return null;
1574
- return {
1575
- newestSource: newestSource.path,
1576
- newestSourceMs: newestSource.mtimeMs,
1577
- assembly: newestAssembly.path,
1578
- assemblyMs: newestAssembly.mtimeMs,
1579
- newerCount: times.sourceTimes.filter((t) => newerThan(t, newestAssembly.mtimeMs)).length
1580
- };
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));
1614
+ }
1615
+ return current;
1581
1616
  }
1582
- var INDENT = " ".repeat("warning: ".length);
1583
- function staleWarning(packageId, report, now = Date.now()) {
1584
- const files = report.newerCount === 1 ? "1 source file" : `${report.newerCount} source files`;
1585
- return [
1586
- `${packageId} has ${files} newer than ${report.assembly}`,
1587
- `${INDENT}newest: ${report.newestSource} (${ago(report.newestSourceMs, now)})`,
1588
- `${INDENT}you are probably running a stale build`
1589
- ].join(`
1590
- `);
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
+ }
1591
1636
  }
1592
- function duration(ms) {
1593
- const seconds = Math.max(0, Math.round(ms / 1000));
1594
- if (seconds < 60)
1595
- return `${seconds}s`;
1596
- if (seconds < 3600)
1597
- return `${Math.floor(seconds / 60)}m`;
1598
- if (seconds < 86400)
1599
- return `${Math.floor(seconds / 3600)}h`;
1600
- return `${Math.floor(seconds / 86400)}d`;
1637
+ function checkVariantNames(p, where, game_) {
1638
+ const steamBuild = game_["steamBuild"];
1639
+ const variants = isObj(steamBuild) ? steamBuild["variants"] : undefined;
1640
+ if (!Array.isArray(variants))
1641
+ return;
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
+ });
1601
1648
  }
1602
- function ago(mtimeMs, now = Date.now()) {
1603
- return `${duration(now - mtimeMs)} ago`;
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);
1604
1655
  }
1605
-
1606
- // src/cli/output.ts
1607
- function status(message) {
1608
- process.stderr.write(line(message));
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);
1609
1665
  }
1610
- function warn(message) {
1611
- process.stderr.write(line(`warning: ${message}`));
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
+ }
1612
1680
  }
1613
- function redirectOutput(path, keep = false) {
1614
- const fd = openSync(path, keep ? "a" : "w");
1615
- const stdout = process.stdout.write;
1616
- const stderr = process.stderr.write;
1617
- let open = true;
1618
- const write = (chunk, encodingOrCallback, callback) => {
1619
- append(fd, chunk);
1620
- const done = typeof encodingOrCallback === "function" ? encodingOrCallback : callback;
1621
- done?.();
1622
- return true;
1623
- };
1624
- process.stdout.write = write;
1625
- process.stderr.write = write;
1626
- return {
1627
- close() {
1628
- if (!open)
1629
- return;
1630
- open = false;
1631
- process.stdout.write = stdout;
1632
- process.stderr.write = stderr;
1633
- closeSync(fd);
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` });
1634
1692
  }
1635
- };
1693
+ }
1636
1694
  }
1637
- async function forwardOutput(stream, target) {
1638
- for await (const chunk of stream)
1639
- target.write(chunk);
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
+ }
1640
1702
  }
1641
- function line(message) {
1642
- return message.endsWith(`
1643
- `) ? message : `${message}
1644
- `;
1703
+ function resolves(profiles, name) {
1704
+ if (name.toLowerCase() === "modless")
1705
+ return true;
1706
+ return profileKey({ profiles }, name) !== undefined;
1645
1707
  }
1646
- function reportProblems(problems) {
1647
- if (problems.length === 0) {
1648
- throw new GamecrateError("resolution failed with no reported detail", Exit.Resolution);
1649
- }
1650
- const groups = new Map;
1651
- for (const problem of problems) {
1652
- const group = groups.get(problem.where);
1653
- if (group)
1654
- group.push(problem);
1655
- else
1656
- groups.set(problem.where, [problem]);
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);
1657
1734
  }
1658
- const out = [];
1659
- for (const [where, group] of groups) {
1660
- out.push(` ${where}`);
1661
- for (const problem of group) {
1662
- out.push(` ${problem.message}`);
1663
- if (problem.suggestion)
1664
- out.push(` did you mean ${problem.suggestion}?`);
1735
+ }
1736
+ function checkAliasOwners(p, w, prof, name, owners) {
1737
+ const aliases = prof["aliases"];
1738
+ if (!Array.isArray(aliases))
1739
+ return;
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;
1665
1750
  }
1751
+ owners.set(entry.toLowerCase(), name);
1666
1752
  }
1667
- throw new GamecrateError(`${problems.length} problem${problems.length === 1 ? "" : "s"}`, Exit.Resolution, out.join(`
1668
- `));
1669
1753
  }
1670
- function planWarnings(plan) {
1671
- if (!plan.warnOnStale)
1672
- return plan.warnings;
1673
- const stale = plan.mods.filter((mod) => mod.staleReport !== undefined).map((mod) => staleWarning(mod.packageId, mod.staleReport));
1674
- return [...plan.warnings, ...stale];
1754
+ function checkInstanceContainers(p, w, profiles, name, prefix, containers) {
1755
+ const prof = profiles[name];
1756
+ if (!isObj(prof))
1757
+ return;
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);
1771
+ }
1675
1772
  }
1676
- function planPayload(plan) {
1677
- return {
1678
- game: plan.game,
1679
- profile: plan.profile,
1680
- ...plan.instance === undefined ? {} : { instance: plan.instance },
1681
- mode: plan.mode,
1682
- ...plan.marker === undefined ? {} : { marker: plan.marker },
1683
- timeoutSeconds: plan.timeoutSeconds,
1684
- renderWaitSeconds: plan.renderWaitSeconds,
1685
- profileDir: resolve(plan.profileDir),
1686
- instanceDir: resolve(plan.instanceDir),
1687
- containerName: containerName(plan),
1688
- dataDirHost: resolve(plan.dataDirHost),
1689
- stageDirHost: resolve(plan.stageDirHost),
1690
- logsDirHost: resolve(plan.logsDirHost),
1691
- mods: plan.mods.map((mod) => ({
1692
- packageId: mod.packageId,
1693
- kind: mod.kind,
1694
- hostDir: resolve(mod.hostDir),
1695
- containerDir: mod.containerDir,
1696
- origin: mod.explicit ? "explicit" : "auto",
1697
- stale: mod.stale === true,
1698
- ...mod.staleReport === undefined ? {} : { staleReport: mod.staleReport },
1699
- ...mod.workshopId === undefined ? {} : { workshopId: mod.workshopId }
1700
- })),
1701
- warnings: planWarnings(plan)
1702
- };
1773
+ function instanceNames(profiles, name, prof) {
1774
+ try {
1775
+ const resolved = resolveProfile({ profiles }, name).instances;
1776
+ return isObj(resolved) ? Object.keys(resolved) : [];
1777
+ } catch {
1778
+ const declared = prof["instances"];
1779
+ return isObj(declared) ? Object.keys(declared) : [];
1780
+ }
1703
1781
  }
1704
- function printPlan(plan, asJson) {
1705
- const payload = planPayload(plan);
1706
- if (asJson) {
1707
- process.stdout.write(`${JSON.stringify(payload, null, 2)}
1708
- `);
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` });
1709
1785
  return;
1710
1786
  }
1711
- const title = payload.instance === undefined ? `${payload.game} ${payload.profile} (${payload.mode})` : `${payload.game} ${payload.profile} / ${payload.instance} (${payload.mode})`;
1712
- const out = [
1713
- title,
1714
- ` profile ${payload.profileDir}`,
1715
- ` instance ${payload.instanceDir}`,
1716
- ` container ${payload.containerName}`,
1717
- ` data ${payload.dataDirHost}`,
1718
- ` stage ${payload.stageDirHost}`,
1719
- ` logs ${payload.logsDirHost}`
1720
- ];
1721
- if (payload.marker !== undefined)
1722
- out.push(` marker ${payload.marker}`);
1723
- out.push(` timeout ${payload.timeoutSeconds}s, render wait ${payload.renderWaitSeconds}s`, ` mods ${payload.mods.length}`);
1724
- const width = Math.max(0, ...payload.mods.map((m) => m.packageId.length));
1725
- for (const mod of payload.mods) {
1726
- const notes = [mod.kind, mod.origin];
1727
- if (mod.stale)
1728
- notes.push("stale");
1729
- out.push(` ${mod.packageId.padEnd(width)} ${notes.join(" ")} ${mod.hostDir} -> ${mod.containerDir}`);
1787
+ if (!NAME_PATTERN.test(name)) {
1788
+ p.push({ where, message: `${kind} name "${name}" must match ${NAME_PATTERN.source}` });
1730
1789
  }
1731
- for (const warning of payload.warnings)
1732
- out.push(` warning: ${warning}`);
1733
- process.stdout.write(`${out.join(`
1734
- `)}
1735
- `);
1736
1790
  }
1737
- function runTimestamp(now = new Date) {
1738
- return now.toISOString().replaceAll(/[-:.]/g, "");
1791
+ function esc(segment) {
1792
+ return segment.replaceAll("~", "~0").replaceAll("/", "~1");
1739
1793
  }
1740
- function openRunLog(logsDir, now) {
1741
- const runsDir = join4(logsDir, "runs");
1742
- mkdirSync2(runsDir, { recursive: true });
1743
- const dir = uniqueRunDir(runsDir, runTimestamp(now));
1744
- mkdirSync2(dir);
1745
- linkCurrent(logsDir, dir);
1746
- rotateRuns(logsDir, 10);
1747
- return dir;
1794
+ function isObj(v) {
1795
+ return typeof v === "object" && v !== null && !Array.isArray(v);
1748
1796
  }
1749
- var encoder = new TextEncoder;
1750
- function append(fd, chunk) {
1751
- writeSync(fd, typeof chunk === "string" ? encoder.encode(chunk) : chunk);
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");
1752
1802
  }
1753
- function uniqueRunDir(runsDir, stamp) {
1754
- let candidate = join4(runsDir, stamp);
1755
- let n = 2;
1756
- while (existsSync2(candidate)) {
1757
- candidate = join4(runsDir, `${stamp}-${n}`);
1758
- n++;
1803
+ async function findGlobalConfig() {
1804
+ return probe(globalConfigDir(), "profiles");
1805
+ }
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
+ }
1759
1817
  }
1760
- return candidate;
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];
1761
1825
  }
1762
- var WAIT_NOTICE = { firstMs: 2000, everyMs: 30000 };
1763
- function waitNotice(waitedMs, lastNoticeMs, schedule = WAIT_NOTICE) {
1764
- if (waitedMs < schedule.firstMs)
1765
- return;
1766
- if (lastNoticeMs > 0 && waitedMs - lastNoticeMs < schedule.everyMs)
1767
- return;
1768
- return waitedMs < 60000 ? `${Math.floor(waitedMs / 1000)}s` : `${Math.floor(waitedMs / 60000)}m`;
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(", ")}` });
1769
1833
  }
1770
- function runStartedAt(name) {
1771
- const m = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(\d{3})Z/.exec(name);
1772
- if (m === null)
1773
- return;
1774
- const at = Date.parse(`${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:${m[6]}.${m[7]}Z`);
1775
- return Number.isNaN(at) ? undefined : at;
1776
- }
1777
- function currentLog(instanceDir) {
1778
- return join4(instanceDir, "logs", "current", STDOUT_LOG);
1779
- }
1780
- function tailArgv(file, fromStart, livePid) {
1781
- const argv = ["tail"];
1782
- if (fromStart)
1783
- argv.push("-n", "+1");
1784
- if (livePid !== undefined)
1785
- argv.push("-f", "--pid", String(livePid));
1786
- argv.push(file);
1787
- return argv;
1788
- }
1789
- function linkCurrent(logsDir, target) {
1790
- const link = join4(logsDir, "current");
1834
+ var BUILD_POLICIES2 = ["auto", "always", "never"];
1835
+ var projectResolution = z2.string({ error: "expected dimensions like 1920x1080" }).check((ctx) => {
1791
1836
  try {
1792
- lstatSync(link);
1793
- unlinkSync(link);
1794
- } catch {}
1795
- symlinkSync(join4("runs", basename2(target)), link, "dir");
1796
- }
1797
- function rotateRuns(logsDir, keep) {
1798
- const runsDir = join4(logsDir, "runs");
1799
- if (keep < 1 || !existsSync2(runsDir))
1800
- return [];
1801
- const dirs = readdirSync(runsDir, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort().reverse();
1802
- const removed = dirs.slice(keep);
1803
- for (const name of removed)
1804
- rmSync(join4(runsDir, name), { recursive: true, force: true });
1805
- return removed;
1806
- }
1807
-
1808
- // src/launch/prepare.ts
1809
- import { existsSync as existsSync3, readFileSync } from "node:fs";
1810
- import { open as open2, readdir as readdir3, readFile, unlink, writeFile } from "node:fs/promises";
1811
- import { join as join5 } from "node:path";
1812
- import { setTimeout as sleep2 } from "node:timers/promises";
1813
- async function inherit(argv) {
1814
- const proc = spawnArgv(argv, ["ignore", "pipe", "pipe"]);
1815
- const code = exited(proc);
1816
- await Promise.all([
1817
- forwardOutput(proc.stdout, process.stdout),
1818
- forwardOutput(proc.stderr, process.stderr)
1819
- ]);
1820
- return code;
1821
- }
1822
- async function imageDigest(ref) {
1823
- const { code, stdout } = await capture(["docker", "image", "inspect", "--format", "{{.Id}}", ref]);
1824
- const id = stdout.trim();
1825
- return code === 0 && id.length > 0 ? id : null;
1826
- }
1827
- async function readFromImage(ref, path) {
1828
- const { code, stdout } = await capture(["docker", "run", "--rm", "--entrypoint", "cat", ref, path]);
1829
- return code === 0 ? stdout : null;
1830
- }
1831
- async function imageLabel(ref, label) {
1832
- const format = `{{index .Config.Labels "${label}"}}`;
1833
- const { code, stdout } = await capture(["docker", "image", "inspect", "--format", format, ref]);
1834
- const value = stdout.trim();
1835
- if (code !== 0 || value.length === 0 || value === "<no value>")
1836
- return null;
1837
- return value;
1838
- }
1839
- async function acquireImage(game, config, pull) {
1840
- const { image } = config;
1841
- const present = await imageDigest(image.ref) !== null;
1842
- if (image.acquire === "build")
1843
- return await buildImage(game, image, present, pull);
1844
- if (pull === "never") {
1845
- if (present)
1846
- return;
1847
- throw new GamecrateError(`--pull never but ${image.ref} is not present locally`, Exit.Environment);
1837
+ parseResolution(ctx.value);
1838
+ } catch (error) {
1839
+ ctx.issues.push({ code: "custom", message: error.message, input: ctx.value });
1848
1840
  }
1849
- if (pull === "missing" && present)
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)
1850
1880
  return;
1851
- if (await inherit(["docker", "pull", image.ref]) !== 0) {
1852
- if (present)
1853
- return;
1854
- throw new GamecrateError(`docker pull failed for ${image.ref}`, Exit.Environment);
1855
- }
1856
- }
1857
- async function buildImage(game, image, present, pull) {
1858
- if (image.context === undefined) {
1859
- throw new GamecrateError(`${game} has image.acquire "build" but no context`, Exit.Config);
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
+ });
1860
1894
  }
1861
- if (present && pull !== "always")
1862
- return;
1863
- if (await inherit(["docker", "build", "--tag", image.ref, image.context]) !== 0) {
1864
- throw new GamecrateError(`docker build failed for ${image.ref}`, Exit.Environment);
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;
1865
1906
  }
1866
1907
  }
1867
- async function buildTarget(dir) {
1868
- let entries;
1869
- try {
1870
- entries = await readdir3(dir);
1871
- } catch {
1872
- return null;
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;
1873
1921
  }
1874
- const slnx = entries.find((e) => e.endsWith(".slnx"));
1875
- if (slnx)
1876
- return join5(dir, slnx);
1877
- const csproj = entries.find((e) => e.endsWith(".csproj"));
1878
- return csproj ? join5(dir, csproj) : null;
1922
+ defaults.configPath = file;
1923
+ return defaults;
1879
1924
  }
1880
- async function buildLocalMods(plan, policy) {
1881
- if (policy === "never")
1882
- return;
1883
- const wanted = plan.mods.filter((m) => m.kind === "local" && (policy === "always" || m.stale === true));
1884
- if (wanted.length === 0)
1885
- return;
1886
- for (const mod of wanted) {
1887
- const target = await buildTarget(mod.hostDir);
1888
- if (target === null)
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`);
1889
1936
  continue;
1890
- const code = await inherit(["dotnet", "build", target, "-v", "quiet", "--nologo"]);
1891
- if (code !== 0) {
1892
- throw new GamecrateError(`dotnet build failed for ${mod.packageId}`, Exit.Environment, target);
1893
1937
  }
1894
- mod.stale = false;
1895
- delete mod.staleReport;
1938
+ problems.push(` /${issue.path.join("/")}: ${issue.message}`);
1896
1939
  }
1940
+ throw new GamecrateError(`project config is invalid: ${file}`, Exit.Config, problems.join(`
1941
+ `));
1897
1942
  }
1898
- async function clearLock(plan) {
1899
- const path = lockPath(plan);
1900
- const what = plan.instance === undefined ? plan.profile : `${plan.profile} (${plan.instance})`;
1901
- const name = containerName(plan);
1902
- const up = await capture(["docker", "ps", "--quiet", "--filter", `name=^${name}$`]);
1903
- if (up.stdout.trim().length > 0) {
1904
- throw new GamecrateError(`${plan.game} ${what} is already running (container ${name})`, Exit.Refused, `stop it with: docker stop ${name}
1905
- or relaunch with --replace`);
1906
- }
1907
- const held = await readLock(path);
1908
- if (held !== undefined && isRunning(held.pid, held.startedAt)) {
1909
- throw new GamecrateError(`${plan.game} ${what} is already running (pid ${held.pid})`, Exit.Refused, `if that is wrong, delete ${path}
1910
- or relaunch with --replace`);
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");
1911
1949
  }
1912
- if (existsSync3(path))
1913
- await unlink(path).catch(() => {});
1914
- }
1915
- async function unlinkHeld(path, pid, startedAt) {
1916
- const held = await readLock(path);
1917
- if (held === undefined)
1918
- return;
1919
- if (held.pid !== pid)
1920
- return;
1921
- if (startedAt !== undefined && held.startedAt !== startedAt)
1922
- return;
1923
- await unlink(path).catch(() => {});
1924
- }
1925
- function heldLock(plan) {
1926
- const path = lockPath(plan);
1927
- return {
1928
- release: async () => {
1929
- await unlinkHeld(path, process.pid);
1930
- }
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)]))
1931
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);
1967
+ }
1968
+ return { config: expandPaths(config), plugins };
1932
1969
  }
1933
- async function takeLock(plan) {
1934
- await clearLock(plan);
1935
- await writeLock(plan, {
1936
- pid: process.pid,
1937
- container: containerName(plan),
1938
- game: plan.game,
1939
- profile: plan.profile,
1940
- ...plan.instance === undefined ? {} : { instance: plan.instance },
1941
- detached: false,
1942
- mode: plan.mode
1943
- });
1944
- return heldLock(plan);
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;
1978
+ }
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);
1991
+ }
1992
+ return config;
1945
1993
  }
1946
- function lockPath(plan) {
1947
- return join5(plan.instanceDir, ".gamecrate", "lock");
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 };
1948
1998
  }
1949
- async function readLock(path) {
1950
- const text = await readFile(path, "utf8").catch(() => {
1951
- return;
1952
- });
1953
- if (text === undefined)
1954
- return;
1955
- try {
1956
- const value = JSON.parse(text);
1957
- return Number.isInteger(value?.pid) && value.pid > 0 ? value : undefined;
1958
- } catch {
1959
- return;
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";
1960
2009
  }
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`;
1961
2016
  }
1962
- async function writeLock(plan, record) {
1963
- const path = lockPath(plan);
1964
- const handle = await open2(path, "wx").catch(() => null);
1965
- if (handle === null) {
1966
- throw new GamecrateError(`could not take the launch lock at ${path}`, Exit.Environment);
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
2025
+ return;
1967
2026
  }
1968
- await handle.writeFile(JSON.stringify({ ...record, startedAt: new Date().toISOString() }));
1969
- await handle.close();
2027
+ return current;
1970
2028
  }
1971
- var RELEASE_POLL_MS = 100;
1972
- var DRAIN_ALLOWANCE_MS = 1e4;
1973
- var STOP_RELEASE_WAIT_MS = STOP_TIMEOUT_SECONDS * 1000 + DRAIN_ALLOWANCE_MS;
1974
- async function stopRun(record, lockFile) {
1975
- let signalled = false;
1976
- if (isRunning(record.pid, record.startedAt)) {
1977
- try {
1978
- process.kill(record.pid, "SIGTERM");
1979
- signalled = true;
1980
- } catch {}
1981
- }
1982
- if (!signalled)
1983
- await stopContainer(record.container, STOP_TIMEOUT_SECONDS);
1984
- const deadline = Date.now() + STOP_RELEASE_WAIT_MS;
1985
- while (existsSync3(lockFile)) {
1986
- const held = await readLock(lockFile);
1987
- if (held === undefined)
1988
- break;
1989
- if (!isRunning(held.pid, held.startedAt)) {
1990
- await unlinkHeld(lockFile, record.pid, record.startedAt);
1991
- break;
1992
- }
1993
- if (Date.now() >= deadline)
1994
- return "held";
1995
- await sleep2(RELEASE_POLL_MS);
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);
1996
2034
  }
1997
- return signalled ? "signalled" : "orphaned";
2035
+ return out;
1998
2036
  }
1999
- async function replacePrevious(plan) {
2000
- const name = containerName(plan);
2001
- const path = lockPath(plan);
2002
- const up = await capture(["docker", "ps", "--quiet", "--filter", `name=^${name}$`]);
2003
- const running = up.stdout.trim().length > 0;
2004
- const held = await readLock(path);
2005
- if (!running && held === undefined && !existsSync3(path))
2006
- return;
2007
- if (held !== undefined) {
2008
- status(`stopping ${held.container}`);
2009
- await stopRun(held, path);
2010
- return;
2037
+ function resolveProfile(game, name) {
2038
+ return resolveNamed(game, name, []);
2039
+ }
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`);
2011
2046
  }
2012
- if (running) {
2013
- status(`stopping ${name}`);
2014
- await stopContainer(name, STOP_TIMEOUT_SECONDS);
2047
+ if (seen.includes(key)) {
2048
+ throw new GamecrateError(`profile "${key}" inherits from itself`, Exit.Config, [...seen, key].join(" -> "));
2015
2049
  }
2016
- }
2017
- var CLOCK_TICKS_PER_SECOND = 100;
2018
- var START_TIME_SLACK_MS = 2000;
2019
- function isRunning(pid, startedAt) {
2020
- try {
2021
- process.kill(pid, 0);
2022
- } catch {
2023
- return false;
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 });
2024
2073
  }
2025
- if (startedAt === undefined)
2026
- return true;
2027
- const written = Date.parse(startedAt);
2028
- if (Number.isNaN(written))
2029
- return true;
2030
- const began = processStart(pid);
2031
- return began === undefined || began <= written + START_TIME_SLACK_MS;
2074
+ return out;
2032
2075
  }
2033
- function processStart(pid) {
2034
- try {
2035
- const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
2036
- const fields = stat.slice(stat.lastIndexOf(")") + 2).split(" ");
2037
- const ticks = Number(fields[19]);
2038
- const boot = bootTime();
2039
- if (!Number.isFinite(ticks) || boot === undefined)
2040
- return;
2041
- return boot + ticks / CLOCK_TICKS_PER_SECOND * 1000;
2042
- } catch {
2043
- return;
2076
+ function canonicalProfile(game, name) {
2077
+ if (name.toLowerCase() === "modless")
2078
+ return "modless";
2079
+ const seen = [];
2080
+ let current = name;
2081
+ for (;; ) {
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;
2044
2090
  }
2045
2091
  }
2046
- function bootTime() {
2047
- const line = readFileSync("/proc/stat", "utf8").split(`
2048
- `).find((each) => each.startsWith("btime "));
2049
- const seconds = Number(line?.slice("btime ".length));
2050
- return Number.isFinite(seconds) && seconds > 0 ? seconds * 1000 : undefined;
2092
+ function profileDataDir(root, game, profile) {
2093
+ return resolve2(expandHome(root.dataRoot), game, canonicalProfile(own(root.games, game), profile));
2051
2094
  }
2052
- async function captureScreenshot(container, plan) {
2053
- const name = `${plan.game}.png`;
2054
- const target = `${CONTAINER_LOG_DIR}/${name}`;
2055
- 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}`;
2056
- const code = await inherit(["docker", "exec", container, "sh", "-c", script]);
2057
- const host = join5(plan.runDirHost, name);
2058
- if (code !== 0 || !existsSync3(host))
2059
- return null;
2060
- return host;
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));
2061
2100
  }
2062
- async function writeLaunchRecord(plan, image) {
2063
- const digest = await imageDigest(image);
2064
- const line = JSON.stringify({
2065
- at: new Date().toISOString(),
2066
- game: plan.game,
2067
- profile: plan.profile,
2068
- ...plan.instance === undefined ? {} : { instance: plan.instance },
2069
- image,
2070
- digest,
2071
- mode: plan.mode,
2072
- mods: plan.mods.map((m) => ({
2073
- packageId: m.packageId,
2074
- hostDir: m.hostDir,
2075
- ...m.worktree === undefined ? {} : { worktree: m.worktree }
2076
- }))
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));
2109
+ }
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)));
2077
2119
  });
2078
- const path = join5(plan.instanceDir, ".gamecrate", "launches.jsonl");
2079
- await writeFile(path, `${line}
2080
- `, { flag: "a" });
2081
2120
  }
2082
-
2083
- // src/mods/modindex.ts
2084
- import { createHash as createHash2 } from "node:crypto";
2085
- import { existsSync as existsSync7, readFileSync as readFileSync4, statSync as statSync3 } from "node:fs";
2086
- import { mkdir as mkdir2, readdir as readdir6, readFile as readFile5, writeFile as writeFile2 } from "node:fs/promises";
2087
- import { homedir as homedir3 } from "node:os";
2088
- import { dirname as dirname5, join as join10, relative as relative2, sep as sep2, resolve as resolvePath } from "node:path";
2089
- import picomatch from "picomatch";
2090
-
2091
- // src/config/load.ts
2092
- import { z as z2 } from "zod";
2093
- import { access, readdir as readdir4, readFile as readFile3 } from "node:fs/promises";
2094
- import { homedir as homedir2 } from "node:os";
2095
- import { basename as basename3, dirname as dirname2, join as join7, resolve as resolve3 } from "node:path";
2096
-
2097
- // src/plugin.ts
2098
- import { readFileSync as readFileSync2, statSync } from "node:fs";
2099
- import { dirname, isAbsolute, join as join6, resolve as resolve2 } from "node:path";
2100
- import { pathToFileURL } from "node:url";
2101
- import { exports as exportsField, legacy } from "resolve.exports";
2102
- var PLUGIN_API_VERSION = 2;
2103
- var REQUIRED_FUNCTIONS = [
2104
- "parseManifest",
2105
- "renderModsConfig",
2106
- "mergePrefs",
2107
- "parseVersion"
2108
- ];
2109
- function fail(spec, message, detail) {
2110
- throw new GamecrateError(`plugin "${spec}": ${message}`, Exit.Config, detail);
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 [];
2111
2127
  }
2112
- function entryOf(dir) {
2113
- let manifest;
2114
- try {
2115
- manifest = JSON.parse(readFileSync2(join6(dir, "package.json"), "utf8"));
2116
- } catch {
2117
- return resolve2(dir, "index.js");
2118
- }
2119
- let entry;
2120
- try {
2121
- entry = exportsField(manifest, ".", { conditions: ["bun"] })?.[0];
2122
- } catch {}
2123
- entry ??= legacy(manifest, { fields: ["module", "main"] });
2124
- return resolve2(dir, entry ?? "index.js");
2128
+ function globToRegExp(pattern) {
2129
+ const body = pattern.replaceAll(/[.+^${}()|[\]\\]/g, String.raw`\$&`).replaceAll("*", ".*").replaceAll("?", ".");
2130
+ return new RegExp(`^${body}$`, "i");
2125
2131
  }
2126
- function packageDir(spec, from) {
2127
- let dir = resolve2(from);
2128
- for (;; ) {
2129
- const candidate = join6(dir, "node_modules", spec);
2130
- if (statSync(join6(candidate, "package.json"), { throwIfNoEntry: false })?.isFile())
2131
- return candidate;
2132
- const parent = dirname(dir);
2133
- if (parent === dir)
2134
- return null;
2135
- dir = parent;
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);
2136
2146
  }
2147
+ return out;
2137
2148
  }
2138
- function locate(spec, from) {
2139
- const expanded = expandHome(spec);
2140
- let target;
2141
- if (expanded.startsWith(".") || isAbsolute(expanded)) {
2142
- target = resolve2(from, expanded);
2143
- } else {
2144
- target = packageDir(expanded, from);
2145
- if (target === null) {
2146
- fail(spec, `cannot be resolved from ${from}`, "install it, or give a path starting with ./");
2149
+ function branchName(entry) {
2150
+ return isObj(entry) && typeof entry["name"] === "string" ? entry["name"] : undefined;
2151
+ }
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;
2147
2168
  }
2169
+ out[index] = deepMerge(out[index], entry);
2148
2170
  }
2149
- return statSync(target, { throwIfNoEntry: false })?.isDirectory() ? entryOf(target) : target;
2171
+ return out;
2150
2172
  }
2151
- function check(spec, value) {
2152
- if (typeof value !== "object" || value === null)
2153
- fail(spec, "has no default export");
2154
- const plugin = value;
2155
- if (plugin.apiVersion !== PLUGIN_API_VERSION) {
2156
- fail(spec, `speaks apiVersion ${String(plugin.apiVersion)}, this build speaks ${PLUGIN_API_VERSION}`);
2157
- }
2158
- if (typeof plugin.game !== "string" || plugin.game === "")
2159
- fail(spec, "declares no game name");
2160
- const missing = REQUIRED_FUNCTIONS.filter((name) => typeof plugin[name] !== "function");
2161
- if (missing.length > 0)
2162
- fail(spec, `is missing ${missing.join(", ")}`);
2163
- if (typeof plugin.defaults !== "object" || plugin.defaults === null) {
2164
- fail(spec, "declares no defaults object");
2165
- }
2166
- if (typeof plugin.windowedPrefs !== "object" || plugin.windowedPrefs === null) {
2167
- fail(spec, "declares no windowedPrefs object");
2173
+ function deepMerge(base, over, concatArrays = false) {
2174
+ if (Array.isArray(base) && Array.isArray(over)) {
2175
+ return concatArrays ? [...base, ...over] : [...over];
2168
2176
  }
2169
- return plugin;
2170
- }
2171
- async function loadPlugins(specs, configFile) {
2172
- const from = dirname(configFile);
2173
- const out = new Map;
2174
- for (const spec of specs) {
2175
- const target = locate(spec, from);
2176
- let module;
2177
- try {
2178
- module = await import(pathToFileURL(target).href);
2179
- } catch (error) {
2180
- fail(spec, `failed to load ${target}`, error instanceof Error ? error.message : String(error));
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;
2181
2183
  }
2182
- const plugin = check(spec, module.default);
2183
- if (out.has(plugin.game))
2184
- fail(spec, `also claims the game "${plugin.game}"`);
2185
- out.set(plugin.game, plugin);
2184
+ return out;
2186
2185
  }
2187
- return out;
2186
+ return over;
2188
2187
  }
2189
- function requirePlugin(plugins, game) {
2190
- const plugin = plugins.get(game);
2191
- if (plugin === undefined) {
2192
- throw new GamecrateError(`no plugin provides the game "${game}"`, Exit.Config, `loaded plugins: ${[...plugins.keys()].join(", ") || "(none)"}`);
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
+ }
2193
2205
  }
2194
- return plugin;
2206
+ return config;
2195
2207
  }
2196
-
2197
- // src/config/builtin.ts
2198
- var DEFAULT_DATA_ROOT = "~/.local/share/gamecrate";
2199
- var DEFAULT_SETTINGS = {
2200
- width: 1920,
2201
- height: 1080,
2202
- devMode: true,
2203
- runInBackground: true,
2204
- resetModsConfigOnCrash: false,
2205
- gpu: true,
2206
- audio: true,
2207
- input: false,
2208
- network: "bridge",
2209
- display: "x11",
2210
- memory: "8g",
2211
- cpus: 6,
2212
- pidsLimit: 1024
2213
- };
2214
-
2215
- // src/config/read.ts
2216
- import { readFile as readFile2 } from "node:fs/promises";
2217
- import { extname } from "node:path";
2218
- import { parseTree } from "jsonc-parser";
2219
- import { isMap, parse as parseYaml, parseDocument } from "yaml";
2220
-
2221
- // src/config/jsonc.ts
2222
- import { parse, printParseErrorCode } from "jsonc-parser";
2223
- function parseJsonc(text) {
2224
- const errors = [];
2225
- const value = parse(text, errors, { allowTrailingComma: true, allowEmptyContent: false });
2226
- const first = errors[0];
2227
- if (first !== undefined) {
2228
- throw new GamecrateError("config is not valid JSON", Exit.Config, `${printParseErrorCode(first.error)} at offset ${first.offset}`);
2229
- }
2230
- return value;
2208
+ function expandHome(p) {
2209
+ if (p === "~")
2210
+ return homedir();
2211
+ return p.startsWith("~/") ? join2(homedir(), p.slice(2)) : p;
2231
2212
  }
2232
2213
 
2233
- // src/config/read.ts
2234
- var CONFIG_SUFFIXES = [".yml", ".yaml", ".json", ".jsonc"];
2235
- function isYaml(path) {
2236
- const suffix = extname(path).toLowerCase();
2237
- if (suffix === ".yml" || suffix === ".yaml")
2238
- return true;
2239
- if (suffix === ".json" || suffix === ".jsonc")
2240
- return false;
2241
- throw new GamecrateError(`config is not a format gamecrate reads: ${path}`, Exit.Config, `use one of ${CONFIG_SUFFIXES.join(", ")}`);
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")
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"');
2242
2227
  }
2243
- function readConfigText(text, path) {
2244
- if (!isYaml(path)) {
2245
- try {
2246
- return parseJsonc(text);
2247
- } catch (error) {
2248
- if (error instanceof GamecrateError) {
2249
- throw new GamecrateError(`${error.message}: ${path}`, error.code, error.detail);
2250
- }
2251
- throw error;
2252
- }
2253
- }
2254
- try {
2255
- return parseYaml(text);
2256
- } catch (error) {
2257
- throw new GamecrateError(`config is invalid: ${path}`, Exit.Config, error.message);
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)))];
2258
2232
  }
2233
+ if (plan.mode === "headed")
2234
+ return [executable];
2235
+ return ["xvfb-run", "-a", `--server-args=-screen 0 ${settings.width}x${settings.height}x24`, executable];
2259
2236
  }
2260
- async function readConfigFile(path) {
2261
- let text;
2262
- try {
2263
- text = await readFile2(path, "utf8");
2264
- } catch (error) {
2265
- if (error.code === "ENOENT")
2266
- return;
2267
- throw error;
2268
- }
2269
- return readConfigText(text, path);
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")
2246
+ return;
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);
2270
2250
  }
2271
- function orderedKeys(text, path, key) {
2272
- if (isYaml(path)) {
2273
- const node = parseDocument(text).get(key, true);
2274
- if (!isMap(node))
2275
- return [];
2276
- return node.items.map((item) => String(item.key.value ?? item.key));
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`
2271
+ });
2277
2272
  }
2278
- const root = parseTree(text);
2279
- const holder = root?.children?.find((child) => child.children?.[0]?.value === key);
2280
- const value = holder?.children?.[1];
2281
- if (value?.type !== "object")
2282
- return [];
2283
- return (value.children ?? []).map((prop) => String(prop.children?.[0]?.value));
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");
2281
+ }
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
+ };
2284
2311
  }
2285
-
2286
- // src/config/validate.ts
2287
- import { z } from "zod";
2288
- var MODES2 = ["headed", "headless", "screenshot"];
2289
- var HINTS = "\x00gamecrate/hints:";
2290
- function obj(shape) {
2291
- const known = Object.keys(shape);
2292
- return z.strictObject(shape, {
2293
- error: (issue) => issue.code === "unrecognized_keys" ? HINTS + JSON.stringify(issue.keys.map((key) => suggest(key, known) ?? null)) : "expected an object"
2294
- });
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);
2318
+ }
2319
+ mounts.push({ type: "bind", source: hostPath(gameFiles.host), target: gameFiles.container, readonly: true });
2295
2320
  }
2296
- function hintsFor(message) {
2297
- if (!message.startsWith(HINTS))
2298
- return [];
2299
- try {
2300
- return JSON.parse(message.slice(HINTS.length));
2301
- } catch {
2302
- return [];
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);
2303
2326
  }
2327
+ mounts.push({ type: "bind", source: hostPath(plan.dataDirHost), target: game.dataDir.container });
2304
2328
  }
2305
- function requiredWhen(key, when) {
2306
- return (ctx) => {
2307
- if (!when(ctx.value) || ctx.value[key] !== undefined)
2308
- return;
2309
- ctx.issues.push({ code: "custom", message: `missing required key "${key}"`, path: [key], input: ctx.value });
2310
- };
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" });
2333
+ }
2334
+ if (uid !== 0) {
2335
+ mounts.push({ type: "tmpfs", target: identity.home, size: HOME_SIZE, uid, gid, mode: "700" });
2336
+ }
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`;
2311
2343
  }
2312
- var str = z.string({ error: "expected a string" });
2313
- var num = z.number({ error: "expected a number" });
2314
- var bool = z.boolean({ error: "expected a boolean" });
2315
- var strArray = z.array(z.string({ error: "expected an array of strings" }), {
2316
- error: "expected an array of strings"
2317
- });
2318
- var strMap = z.record(z.string(), z.string({ error: "expected an object of string values" }), {
2319
- error: "expected an object of string values"
2320
- });
2321
- function oneOf(values) {
2322
- return z.enum(values, { error: `expected one of ${values.join(", ")}` });
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)
2351
+ return;
2352
+ for (const socket of audioSockets()) {
2353
+ mounts.push({ type: "bind", source: socket.source, target: `${CONTAINER_RUNTIME_DIR}/${socket.name}` });
2354
+ }
2355
+ env.PULSE_SERVER = `unix:${CONTAINER_RUNTIME_DIR}/pulse/native`;
2323
2356
  }
2324
- var modeName = z.unknown().check((ctx) => {
2325
- const value = ctx.value;
2326
- if (typeof value === "string" && MODES2.includes(value))
2357
+ function addX11(mounts, env) {
2358
+ const x11 = x11Session();
2359
+ if (!x11)
2327
2360
  return;
2328
- const hint = typeof value === "string" ? suggest(value, MODES2) : undefined;
2329
- ctx.issues.push({
2330
- code: "custom",
2331
- message: `expected one of ${MODES2.join(", ")}`,
2332
- input: value,
2333
- ...hint === undefined ? {} : { params: { suggestion: `did you mean "${hint}"?` } }
2334
- });
2335
- });
2336
- var settings = obj({
2337
- width: num.optional(),
2338
- height: num.optional(),
2339
- devMode: bool.optional(),
2340
- runInBackground: bool.optional(),
2341
- resetModsConfigOnCrash: bool.optional(),
2342
- gpu: bool.optional(),
2343
- audio: bool.optional(),
2344
- input: bool.optional(),
2345
- network: oneOf(["none", "bridge", "host"]).optional(),
2346
- display: oneOf(["x11", "wayland"]).optional(),
2347
- memory: str.optional(),
2348
- cpus: num.optional(),
2349
- pidsLimit: num.optional(),
2350
- prefsExtra: strMap.optional(),
2351
- gameArgs: strArray.optional(),
2352
- dockerArgs: strArray.optional()
2353
- });
2354
- var dynamicModEntry = obj({
2355
- match: str,
2356
- first: strArray.optional(),
2357
- sort: oneOf(["alpha", "none"]).optional(),
2358
- minMatches: num.optional()
2359
- });
2360
- var objectModEntry = obj({
2361
- id: str,
2362
- workshop: num.optional(),
2363
- path: str.optional(),
2364
- optional: bool.optional()
2365
- });
2366
- var modEntry = z.unknown().check((ctx) => {
2367
- const value = ctx.value;
2368
- if (typeof value === "string") {
2369
- if (value.trim() === "")
2370
- ctx.issues.push({ code: "custom", message: "mod entry is empty", input: value });
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)
2371
2367
  return;
2372
- }
2373
- if (!isObj(value)) {
2374
- ctx.issues.push({ code: "custom", message: "expected a packageId string or an object", input: value });
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)
2375
2374
  return;
2376
- }
2377
- const schema = value["match"] !== undefined ? dynamicModEntry : objectModEntry;
2378
- const result = schema.safeParse(value);
2379
- if (result.success)
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)
2380
2388
  return;
2381
- for (const issue of result.error.issues)
2382
- ctx.issues.push({ ...issue, input: value });
2383
- });
2384
- var profile = obj({
2385
- mods: z.array(modEntry, { error: "expected an array" }).optional(),
2386
- extends: str.optional(),
2387
- exclude: strArray.optional(),
2388
- includeBase: bool.optional(),
2389
- autoDependencies: bool.optional(),
2390
- settings: settings.optional(),
2391
- instances: z.record(z.string(), obj({ worktree: str.optional(), settings: settings.optional() }), {
2392
- error: "expected an object"
2393
- }).optional(),
2394
- alias: str.optional(),
2395
- aliases: strArray.optional(),
2396
- description: str.optional(),
2397
- gameVersion: str.optional(),
2398
- image: str.optional(),
2399
- detach: bool.optional(),
2400
- replace: bool.optional(),
2401
- build: oneOf(["auto", "always", "never"]).optional()
2402
- }).check((ctx) => {
2403
- const v = ctx.value;
2404
- if (v.alias !== undefined && (v.extends !== undefined || v.mods !== undefined)) {
2405
- ctx.issues.push({
2406
- code: "custom",
2407
- message: 'an alias profile cannot also declare "mods" or "extends"',
2408
- input: v
2409
- });
2410
- }
2411
- });
2412
- var libraryEntry = obj({
2413
- workshop: num.optional(),
2414
- path: str.optional(),
2415
- git: str.optional(),
2416
- branch: str.optional(),
2417
- tag: str.optional(),
2418
- commit: str.optional(),
2419
- subdir: str.optional()
2420
- }).check((ctx) => {
2421
- const v = ctx.value;
2422
- const push = (message, path) => {
2423
- ctx.issues.push({ code: "custom", message, input: v, ...path === undefined ? {} : { path } });
2424
- };
2425
- const sources = ["workshop", "path", "git"].filter((k) => v[k] !== undefined);
2426
- if (sources.length === 0)
2427
- push('library entry needs a "workshop" id, a "path", or a "git" url');
2428
- if (sources.length > 1)
2429
- push(`library entry takes only one of ${sources.join(", ")}`);
2430
- const refs = ["branch", "tag", "commit"].filter((k) => v[k] !== undefined);
2431
- if (refs.length > 1)
2432
- push(`library entry takes only one of branch, tag or commit, got ${refs.join(", ")}`);
2433
- if (v["git"] === undefined) {
2434
- for (const key of [...refs, ...v["subdir"] === undefined ? [] : ["subdir"]]) {
2435
- push(`"${key}" needs a "git" url`, [key]);
2436
- }
2437
- }
2438
- const subdir = v["subdir"];
2439
- if (typeof subdir === "string" && (subdir.startsWith("/") || subdir.split("/").includes(".."))) {
2440
- push('"subdir" must be a relative path inside the repo, with no ".." segment', ["subdir"]);
2441
- }
2442
- });
2443
- function repeats(entries, key) {
2444
- const seen = new Set;
2445
- const found = [];
2446
- entries.forEach((entry, index) => {
2447
- const name = entry?.[key];
2448
- if (typeof name !== "string")
2449
- return;
2450
- if (seen.has(name))
2451
- found.push({ index, name });
2452
- else
2453
- seen.add(name);
2454
- });
2455
- return found;
2389
+ const path = expandHome(named);
2390
+ return isAbsolute2(path) ? path : resolve3(configDir, path);
2456
2391
  }
2457
- var TAG_COMPONENT = /^[A-Za-z0-9_][A-Za-z0-9._-]*$/;
2458
- function steamBuildRules(ctx) {
2459
- const push = (message, path, suggestion) => {
2460
- ctx.issues.push({
2461
- code: "custom",
2462
- message,
2463
- path,
2464
- input: ctx.value,
2465
- ...suggestion === undefined ? {} : { params: { suggestion } }
2466
- });
2467
- };
2468
- const branches = ctx.value["branches"];
2469
- const variants = ctx.value["variants"];
2470
- if (Array.isArray(variants) && variants.length === 0) {
2471
- push("steamBuild.variants cannot be empty", ["variants"]);
2472
- }
2473
- if (Array.isArray(branches)) {
2474
- if (branches.length === 0)
2475
- push("steamBuild.branches cannot be empty", ["branches"]);
2476
- for (const dup of repeats(branches, "name")) {
2477
- push(`duplicate branch name "${dup.name}"`, ["branches", dup.index, "name"]);
2478
- }
2479
- branches.forEach((branch, index) => {
2480
- const name = branch?.["name"];
2481
- if (typeof name === "string" && !TAG_COMPONENT.test(name)) {
2482
- push(`branch name "${name}" must match ${TAG_COMPONENT.source}`, ["branches", index, "name"]);
2483
- }
2484
- const tags = branch?.["tags"];
2485
- if (!Array.isArray(tags))
2486
- return;
2487
- tags.forEach((tag, at) => {
2488
- if (typeof tag !== "string" || TAG_COMPONENT.test(tag))
2489
- return;
2490
- push(`branch tag "${String(tag)}" must match ${TAG_COMPONENT.source}`, ["branches", index, "tags", at]);
2491
- });
2492
- });
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(",")}`];
2493
2432
  }
2494
- if (!Array.isArray(variants))
2495
- return;
2496
- for (const dup of repeats(variants, "name")) {
2497
- push(`duplicate variant name "${dup.name}"`, ["variants", dup.index, "name"]);
2433
+ if (!mount.source) {
2434
+ throw new GamecrateError(`bind mount at ${mount.target} has no source`, Exit.Config);
2498
2435
  }
2499
- variants.forEach((variant, index) => {
2500
- const v = variant;
2501
- const base = v?.["base"];
2502
- if (base !== "xvfb" && base !== "proton")
2503
- return;
2504
- const depot = v?.["depot"] ?? "linux";
2505
- if (depot === "macos") {
2506
- push("a macos depot cannot be runnable", ["variants", index, "base"], 'set base to "none"; no macos container runtime exists');
2507
- return;
2508
- }
2509
- const wants = depot === "windows" ? "proton" : "xvfb";
2510
- if (base === wants)
2511
- return;
2512
- push(`a ${String(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');
2513
- });
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(",")];
2514
2440
  }
2515
- var steamBuildSchema = obj({
2516
- branches: z.array(obj({
2517
- name: str,
2518
- password: bool.optional(),
2519
- tags: strArray.optional(),
2520
- executable: z.record(str, str).optional()
2521
- }), {
2522
- error: "expected an array"
2523
- }),
2524
- variants: z.array(obj({
2525
- name: str,
2526
- depot: oneOf(["linux", "windows", "macos"]).optional(),
2527
- base: oneOf(["xvfb", "proton", "none"]),
2528
- include: strArray,
2529
- executable: str.optional()
2530
- }), { error: "expected an array" })
2531
- }).check(steamBuildRules);
2532
- var game = obj({
2533
- gameFiles: obj({ source: oneOf(["mount", "image"]), host: str.optional(), container: str }).check(requiredWhen("host", (v) => v["source"] === "mount")),
2534
- dataDir: obj({
2535
- container: str,
2536
- mode: oneOf(["arg", "env"]),
2537
- arg: str.optional(),
2538
- env: strMap.optional()
2539
- }).check(requiredWhen("arg", (v) => v["mode"] === "arg"), requiredWhen("env", (v) => v["mode"] === "env")),
2540
- modsDir: obj({ container: str, mask: strArray.optional() }),
2541
- 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")),
2542
- image: obj({
2543
- ref: str,
2544
- acquire: oneOf(["pull", "build"]),
2545
- context: str.optional(),
2546
- updates: obj({ check: bool.optional(), everyHours: num.optional() }).optional()
2547
- }).check(requiredWhen("context", (v) => v["acquire"] === "build")),
2548
- executable: str,
2549
- managed: strArray.optional(),
2550
- steamAppId: num,
2551
- workshopRoot: z.union([z.string(), z.null()], { error: "expected a string or null" }),
2552
- scanRoots: z.array(obj({ path: str, maxDepth: num, exclude: strArray.optional() }), {
2553
- error: "expected an array"
2554
- }),
2555
- manifest: obj({ file: str }),
2556
- modsConfig: obj({ file: str }),
2557
- prefs: obj({ file: str }),
2558
- version: obj({ file: str }),
2559
- steamBuild: steamBuildSchema,
2560
- saveExtensions: strArray,
2561
- core: str,
2562
- dlc: strArray,
2563
- preCore: strArray.optional(),
2564
- base: strArray.optional(),
2565
- library: z.record(z.string(), libraryEntry, { error: "expected an object" }).optional(),
2566
- modes: z.array(modeName, { error: "expected a non-empty array" }).min(1, {
2567
- error: "expected a non-empty array"
2568
- }),
2569
- aliases: strMap.optional(),
2570
- settings: settings.optional(),
2571
- ignoresWmDelete: bool.optional(),
2572
- profiles: z.record(z.string(), profile, { error: "expected an object" })
2573
- });
2574
- var root = obj({
2575
- plugins: strArray.optional(),
2576
- dataRoot: str,
2577
- defaults: obj({ settings: settings.optional() }).optional(),
2578
- steamcmd: obj({ path: str.optional() }).optional(),
2579
- games: z.unknown()
2580
- });
2581
- function validateConfig(cfg) {
2582
- const problems = [];
2583
- if (!isObj(cfg)) {
2584
- problems.push({ where: "", message: "expected the config to be an object" });
2585
- return { config: { dataRoot: "", games: {} }, problems };
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.");
2586
2452
  }
2587
- collect3(problems, "", root, cfg);
2588
- const games = cfg["games"];
2589
- if (!isObj(games)) {
2590
- problems.push({ where: "/games", message: 'missing required key "games", or it is not an object' });
2591
- 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);
2592
2456
  }
2593
- for (const [name, entry] of Object.entries(games)) {
2594
- collect3(problems, `/games/${esc(name)}`, game, entry);
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.");
2595
2459
  }
2596
- crossReference(problems, games);
2597
- return { config: cfg, problems };
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);
2598
2467
  }
2599
- function collect3(problems, prefix, schema, value) {
2600
- const result = schema.safeParse(value);
2601
- if (result.success)
2602
- return;
2603
- for (const issue of result.error.issues) {
2604
- if (issue.code === "unrecognized_keys") {
2605
- const hints = hintsFor(issue.message);
2606
- issue.keys.forEach((key, i) => {
2607
- const problem = {
2608
- where: `${prefix}${pointer(issue.path)}/${esc(key)}`,
2609
- message: `unknown key "${key}"`
2610
- };
2611
- const hint = hints[i];
2612
- if (hint != null)
2613
- problem.suggestion = `did you mean "${hint}"?`;
2614
- problems.push(problem);
2615
- });
2616
- continue;
2617
- }
2618
- const key = issue.path.at(-1);
2619
- const missing = typeof key === "string" && valueAt(value, issue.path) === undefined;
2620
- const problem = {
2621
- where: `${prefix}${pointer(issue.path)}`,
2622
- message: missing ? `missing required key "${key}"` : issue.message
2623
- };
2624
- const hint = issue.params?.suggestion;
2625
- if (hint !== undefined)
2626
- problem.suggestion = hint;
2627
- 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";
2628
2475
  }
2476
+ return env;
2629
2477
  }
2630
- function pointer(path) {
2631
- 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");
2632
2480
  }
2633
- function valueAt(root_, path) {
2634
- let current = root_;
2635
- for (const segment of path) {
2636
- if (current === null || typeof current !== "object")
2637
- return;
2638
- 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 });
2639
2511
  }
2640
- return current;
2512
+ return found;
2641
2513
  }
2642
- function crossReference(p, games) {
2643
- const containers = new Map;
2644
- for (const [gameName, game_] of Object.entries(games)) {
2645
- const where = `/games/${esc(gameName)}`;
2646
- checkName(p, where, gameName, "game");
2647
- if (!isObj(game_))
2648
- continue;
2649
- checkVariantNames(p, where, game_);
2650
- const profiles = game_["profiles"];
2651
- if (!isObj(profiles))
2652
- continue;
2653
- for (const [name, prof] of Object.entries(profiles)) {
2654
- const w = `${where}/profiles/${esc(name)}`;
2655
- checkName(p, w, name, "profile");
2656
- if (isObj(prof))
2657
- checkProfile(p, w, name, prof, profiles);
2658
- }
2659
- checkCollisions(p, where, gameName, profiles, containers);
2514
+ function hostPath(path) {
2515
+ try {
2516
+ return realpathSync(path);
2517
+ } catch {
2518
+ return path;
2660
2519
  }
2661
2520
  }
2662
- function checkVariantNames(p, where, game_) {
2663
- const steamBuild = game_["steamBuild"];
2664
- const variants = isObj(steamBuild) ? steamBuild["variants"] : undefined;
2665
- if (!Array.isArray(variants))
2666
- return;
2667
- variants.forEach((variant, index) => {
2668
- const name = isObj(variant) ? variant["name"] : undefined;
2669
- if (typeof name !== "string")
2670
- return;
2671
- checkName(p, `${where}/steamBuild/variants/${index}/name`, name, "variant");
2672
- });
2521
+
2522
+ // src/docker/run.ts
2523
+ function spawnArgv(argv, stdio, detached = false) {
2524
+ return spawn(argv[0], argv.slice(1), { stdio, detached });
2673
2525
  }
2674
- function checkProfile(p, w, name, prof, profiles) {
2675
- const names = Object.keys(profiles);
2676
- checkExtends(p, w, prof, profiles, names);
2677
- checkAlias(p, w, name, prof, profiles, names);
2678
- checkAliases(p, w, prof, names);
2679
- 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
+ });
2680
2531
  }
2681
- function checkExtends(p, w, prof, profiles, names) {
2682
- const parent = prof["extends"];
2683
- if (typeof parent !== "string" || resolves(profiles, parent))
2684
- return;
2685
- const prob = { where: `${w}/extends`, message: `extends unknown profile "${parent}"` };
2686
- const hint = suggest(parent, names);
2687
- if (hint)
2688
- prob.suggestion = `did you mean "${hint}"?`;
2689
- 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");
2690
2537
  }
2691
- function checkAlias(p, w, name, prof, profiles, names) {
2692
- const alias = prof["alias"];
2693
- if (typeof alias !== "string")
2694
- return;
2695
- if (!resolves(profiles, alias)) {
2696
- const prob = { where: `${w}/alias`, message: `alias of unknown profile "${alias}"` };
2697
- const hint = suggest(alias, [...names, "modless"]);
2698
- if (hint)
2699
- prob.suggestion = `did you mean "${hint}"?`;
2700
- 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) };
2701
2549
  }
2702
- if (alias.toLowerCase() === name.toLowerCase()) {
2703
- 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));
2704
2591
  }
2705
2592
  }
2706
- function checkAliases(p, w, prof, names) {
2707
- const aliases = prof["aliases"];
2708
- if (!Array.isArray(aliases))
2709
- return;
2710
- for (const [i, entry] of aliases.entries()) {
2711
- if (typeof entry !== "string")
2712
- continue;
2713
- const at = `${w}/aliases/${i}`;
2714
- checkName(p, at, entry, "profile alias");
2715
- if (names.some((k) => k.toLowerCase() === entry.toLowerCase())) {
2716
- 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;
2717
2612
  }
2613
+ if (Date.now() >= deadline)
2614
+ return false;
2615
+ await sleep(Math.min(MARKER_POLL_MS, Math.max(deadline - Date.now(), 0)));
2718
2616
  }
2719
2617
  }
2720
- function checkInstances(p, w, prof) {
2721
- const instances = prof["instances"];
2722
- if (!isObj(instances))
2723
- return;
2724
- for (const instance of Object.keys(instances)) {
2725
- 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(() => {});
2726
2646
  }
2727
2647
  }
2728
- function resolves(profiles, name) {
2729
- if (name.toLowerCase() === "modless")
2730
- return true;
2731
- return profileKey({ profiles }, name) !== undefined;
2732
- }
2733
- function checkCollisions(p, where, gameName, profiles, containers) {
2734
- const names = new Map;
2735
- const aliasOwners = new Map;
2736
- const prefix = `${gameName.toLowerCase()}-`;
2737
- for (const [name, prof] of Object.entries(profiles)) {
2738
- const w = `${where}/profiles/${esc(name)}`;
2739
- const lower = name.toLowerCase();
2740
- const twin = names.get(lower);
2741
- if (twin !== undefined) {
2742
- p.push({
2743
- where: w,
2744
- message: `profile "${name}" differs from "${twin}" only in case, so both share one data directory`
2745
- });
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);
2746
2654
  continue;
2747
2655
  }
2748
- names.set(lower, name);
2749
- const clash = containers.get(prefix + lower);
2750
- if (clash !== undefined) {
2751
- p.push({ where: w, message: `container name collides with ${clash}` });
2656
+ if (!info.isDirectory()) {
2657
+ out.push(source);
2752
2658
  continue;
2753
2659
  }
2754
- containers.set(prefix + lower, w);
2755
- if (!isObj(prof))
2756
- continue;
2757
- checkAliasOwners(p, w, prof, name, aliasOwners);
2758
- 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
+ }
2759
2665
  }
2666
+ return out;
2760
2667
  }
2761
- function checkAliasOwners(p, w, prof, name, owners) {
2762
- const aliases = prof["aliases"];
2763
- if (!Array.isArray(aliases))
2764
- return;
2765
- for (const [i, entry] of aliases.entries()) {
2766
- if (typeof entry !== "string")
2767
- continue;
2768
- const owner = owners.get(entry.toLowerCase());
2769
- if (owner !== undefined) {
2770
- p.push({
2771
- where: `${w}/aliases/${i}`,
2772
- message: `alias "${entry}" is already declared by profile "${owner}"`
2773
- });
2774
- continue;
2775
- }
2776
- 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);
2777
2672
  }
2778
2673
  }
2779
- function checkInstanceContainers(p, w, profiles, name, prefix, containers) {
2780
- const prof = profiles[name];
2781
- 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 {
2782
2690
  return;
2783
- const declared = prof["instances"];
2784
- for (const instance of instanceNames(profiles, name, prof)) {
2785
- const container = `${prefix}-${instance.toLowerCase()}`;
2786
- const at = isObj(declared) && Object.hasOwn(declared, instance) ? `${w}/instances/${esc(instance)}` : w;
2787
- const first = containers.get(container);
2788
- if (first !== undefined) {
2789
- p.push({
2790
- where: at,
2791
- message: `instance "${instance}" makes a container name that collides with ${first}`
2792
- });
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
+ }
2793
2700
  continue;
2794
2701
  }
2795
- containers.set(container, at);
2702
+ if (entry.isFile())
2703
+ await record(state, path, entry.name, inAssemblies);
2796
2704
  }
2797
2705
  }
2798
- 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;
2799
2713
  try {
2800
- const resolved = resolveProfile({ profiles }, name).instances;
2801
- return isObj(resolved) ? Object.keys(resolved) : [];
2714
+ mtimeMs = (await stat2(path)).mtimeMs;
2802
2715
  } catch {
2803
- const declared = prof["instances"];
2804
- 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;
2726
+ }
2727
+ }
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
+ }
2805
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
+ };
2806
2873
  }
2807
- function checkName(p, where, name, kind) {
2808
- if (RESERVED_NAMES.includes(name.toLowerCase())) {
2809
- p.push({ where, message: `"${name}" is a reserved name and cannot be used as a ${kind} name` });
2874
+ function printPlan(plan, asJson) {
2875
+ const payload = planPayload(plan);
2876
+ if (asJson) {
2877
+ process.stdout.write(`${JSON.stringify(payload, null, 2)}
2878
+ `);
2810
2879
  return;
2811
2880
  }
2812
- if (!NAME_PATTERN.test(name)) {
2813
- 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}`);
2814
2900
  }
2901
+ for (const warning of payload.warnings)
2902
+ out.push(` warning: ${warning}`);
2903
+ process.stdout.write(`${out.join(`
2904
+ `)}
2905
+ `);
2815
2906
  }
2816
- function esc(segment) {
2817
- return segment.replaceAll("~", "~0").replaceAll("/", "~1");
2818
- }
2819
- function isObj(v) {
2820
- return typeof v === "object" && v !== null && !Array.isArray(v);
2907
+ function runTimestamp(now = new Date) {
2908
+ return now.toISOString().replaceAll(/[-:.]/g, "");
2821
2909
  }
2822
-
2823
- // src/config/load.ts
2824
- function globalConfigDir() {
2825
- const base = process.env["XDG_CONFIG_HOME"] ?? join7(homedir2(), ".config");
2826
- 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;
2827
2918
  }
2828
- async function findGlobalConfig() {
2829
- return probe(globalConfigDir(), "profiles");
2919
+ var encoder = new TextEncoder;
2920
+ function append(fd, chunk) {
2921
+ writeSync(fd, typeof chunk === "string" ? encoder.encode(chunk) : chunk);
2830
2922
  }
2831
- async function probe(dir, stem) {
2832
- const found = [];
2833
- for (const suffix of CONFIG_SUFFIXES) {
2834
- const file = join7(dir, `${stem}${suffix}`);
2835
- try {
2836
- await access(file);
2837
- found.push(file);
2838
- } catch (error) {
2839
- if (error.code !== "ENOENT")
2840
- throw error;
2841
- }
2842
- }
2843
- if (found.length > 1) {
2844
- const rows = found.map((f) => ` ${basename3(f)}`).join(`
2845
- `);
2846
- throw new GamecrateError(`two configs in ${dir}`, Exit.Config, `${rows}
2847
- 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++;
2848
2929
  }
2849
- return found[0];
2930
+ return candidate;
2850
2931
  }
2851
- var projectName = z2.custom((v) => typeof v === "string" && NAME_PATTERN.test(v), "expected a name");
2852
- var projectStr = z2.string({ error: "expected a string" });
2853
- var projectBool = z2.boolean({ error: "expected true or false" });
2854
- var projectList = z2.custom((v) => Array.isArray(v) && v.every((entry) => typeof entry === "string"), "expected an array of strings");
2855
- var projectSeconds = z2.custom((v) => Number.isSafeInteger(v) && v >= 0, "expected a whole number of seconds");
2856
- function oneOf2(values) {
2857
- 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`;
2858
2939
  }
2859
- var BUILD_POLICIES2 = ["auto", "always", "never"];
2860
- var projectResolution = z2.string({ error: "expected dimensions like 1920x1080" }).check((ctx) => {
2861
- try {
2862
- parseResolution(ctx.value);
2863
- } catch (error) {
2864
- ctx.issues.push({ code: "custom", message: error.message, input: ctx.value });
2865
- }
2866
- }).transform(parseResolution);
2867
- var PROJECT_OBJECT = z2.strictObject({
2868
- game: projectName.optional(),
2869
- defaultProfile: projectName.optional(),
2870
- profiles: z2.record(z2.string(), z2.unknown()).optional(),
2871
- settings: z2.record(z2.string(), z2.unknown()).optional(),
2872
- library: z2.record(z2.string(), z2.unknown()).optional(),
2873
- detach: projectBool.optional(),
2874
- mods: projectList.optional(),
2875
- without: projectList.optional(),
2876
- only: projectList.optional(),
2877
- dockerArgs: projectList.optional(),
2878
- gameArgs: projectList.optional(),
2879
- worktree: projectList.optional(),
2880
- use: projectList.optional(),
2881
- marker: projectStr.optional(),
2882
- instance: projectStr.optional(),
2883
- log: projectStr.optional(),
2884
- timeout: projectSeconds.optional(),
2885
- renderWait: projectSeconds.optional(),
2886
- dryRun: projectBool.optional(),
2887
- printPlan: projectBool.optional(),
2888
- json: projectBool.optional(),
2889
- root: projectBool.optional(),
2890
- noWorktree: projectBool.optional(),
2891
- noStaleCheck: projectBool.optional(),
2892
- replace: projectBool.optional(),
2893
- mode: oneOf2(["headed", "headless", "screenshot"]).optional(),
2894
- pull: oneOf2(["always", "missing", "never"]).optional(),
2895
- sort: oneOf2(["topo", "none"]).optional(),
2896
- network: oneOf2(["none", "bridge", "host"]).optional(),
2897
- build: z2.union([z2.boolean().transform((on) => on ? "always" : "never"), z2.enum(BUILD_POLICIES2)], {
2898
- error: `expected one of ${BUILD_POLICIES2.join(", ")}`
2899
- }).optional(),
2900
- resolution: projectResolution.optional()
2901
- }, { error: "expected an object" });
2902
- var PROJECT_SCHEMA = PROJECT_OBJECT.check((ctx) => {
2903
- const { game, profiles, settings, library } = ctx.value;
2904
- 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)
2905
2943
  return;
2906
- for (const [key, value] of [
2907
- ["profiles", profiles],
2908
- ["settings", settings],
2909
- ["library", library]
2910
- ]) {
2911
- if (value === undefined)
2912
- continue;
2913
- ctx.issues.push({
2914
- code: "custom",
2915
- path: [key],
2916
- message: "needs a top-level game: to say which game it belongs to",
2917
- input: ctx.value
2918
- });
2919
- }
2920
- });
2921
- async function findProjectConfig(start = process.cwd()) {
2922
- let dir = resolve3(start);
2923
- for (;; ) {
2924
- const file = await probe(dir, ".gamecrate");
2925
- if (file !== undefined)
2926
- return file;
2927
- const parent = dirname2(dir);
2928
- if (parent === dir)
2929
- return;
2930
- dir = parent;
2931
- }
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;
2932
2946
  }
2933
- async function loadProjectDefaults(start = process.cwd()) {
2934
- const file = await findProjectConfig(start);
2935
- if (file === undefined)
2936
- return {};
2937
- const text = await readFile3(file, "utf8");
2938
- const defaults = validateProjectDefaults(readConfigText(text, file), file);
2939
- const profiles = defaults.profiles;
2940
- if (profiles !== undefined) {
2941
- const order = orderedKeys(text, file, "profiles");
2942
- if (order.length !== Object.keys(profiles).length || order.some((key) => !Object.hasOwn(profiles, key))) {
2943
- throw new GamecrateError(`config is invalid: ${file}`, Exit.Config, "duplicate profiles key");
2944
- }
2945
- defaults.profileOrder = order;
2946
- }
2947
- defaults.configPath = file;
2948
- return defaults;
2947
+ function currentLog(instanceDir) {
2948
+ return join6(instanceDir, "logs", "current", STDOUT_LOG);
2949
2949
  }
2950
- function validateProjectDefaults(raw, file) {
2951
- if (raw === null)
2952
- return {};
2953
- const result = PROJECT_SCHEMA.safeParse(raw);
2954
- if (result.success)
2955
- return result.data;
2956
- const problems = [];
2957
- for (const issue of result.error.issues) {
2958
- if (issue.code === "unrecognized_keys") {
2959
- for (const key of issue.keys)
2960
- problems.push(` /${key}: unknown key`);
2961
- continue;
2962
- }
2963
- problems.push(` /${issue.path.join("/")}: ${issue.message}`);
2964
- }
2965
- throw new GamecrateError(`project config is invalid: ${file}`, Exit.Config, problems.join(`
2966
- `));
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;
2967
2958
  }
2968
- async function loadConfig(path, project) {
2969
- const file = path ?? await findGlobalConfig() ?? join7(globalConfigDir(), "profiles.yml");
2970
- const user = await readConfigFile(file);
2971
- const specs = isObj(user) && user["plugins"] !== undefined ? user["plugins"] : [];
2972
- if (!Array.isArray(specs) || specs.some((s) => typeof s !== "string")) {
2973
- throw new GamecrateError(`config is invalid: ${file}`, Exit.Config, " /plugins: expected an array of strings");
2974
- }
2975
- const plugins = await loadPlugins(specs, file);
2976
- const base = {
2977
- dataRoot: DEFAULT_DATA_ROOT,
2978
- defaults: { settings: structuredClone(DEFAULT_SETTINGS) },
2979
- games: Object.fromEntries([...plugins].map(([name, plugin]) => [name, structuredClone(plugin.defaults)]))
2980
- };
2981
- const merged = user === undefined || user === null ? base : mergeUserConfig(base, user);
2982
- const spliced = applyProject(merged, project);
2983
- const { config, problems } = validateConfig(spliced);
2984
- if (problems.length > 0) {
2985
- const detail = problems.map((p) => {
2986
- const hint = p.suggestion ? ` (${p.suggestion})` : "";
2987
- return ` ${p.where || "/"}: ${p.message}${hint}${origin(p.where, user, plugins, project)}`;
2988
- }).join(`
2989
- `);
2990
- const from = plugins.size === 0 ? "" : ` (merged with defaults from: ${[...plugins.keys()].join(", ")})`;
2991
- throw new GamecrateError(`config is invalid: ${file}${from}`, Exit.Config, detail);
2992
- }
2993
- 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;
2994
2976
  }
2995
- function applyProject(config, project) {
2996
- if (project === undefined)
2997
- return config;
2998
- const game = project.game;
2999
- if (game === undefined)
3000
- return config;
3001
- if (project.profiles === undefined && project.settings === undefined && project.library === undefined) {
3002
- return config;
3003
- }
3004
- const existing = own(config.games, game);
3005
- if (existing === undefined) {
3006
- throw new GamecrateError(`the project config names game "${game}", which is not configured`, Exit.Config, `known games: ${Object.keys(config.games).join(", ") || "none"}`);
3007
- }
3008
- const target = {
3009
- ...existing,
3010
- profiles: { ...existing.profiles, ...project.profiles },
3011
- ...project.library === undefined ? {} : { library: spliceLibrary(existing.library, project.library) }
3012
- };
3013
- config.games[game] = target;
3014
- if (project.settings !== undefined) {
3015
- target.settings = deepMerge(target.settings ?? {}, project.settings);
3016
- }
3017
- 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;
3018
2991
  }
3019
- function spliceLibrary(global, project) {
3020
- const replaced = new Set(Object.keys(project).map((id) => id.toLowerCase()));
3021
- const kept = Object.entries(global ?? {}).filter(([id]) => !replaced.has(id.toLowerCase()));
3022
- 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;
3023
2996
  }
3024
- var GAME_SCOPED_KEYS = ["profiles", "settings", "library"];
3025
- function origin(where, user, plugins, project) {
3026
- if (!where.startsWith("/"))
3027
- return "";
3028
- const segments = where.slice(1).split("/").map((s) => s.replaceAll("~1", "/").replaceAll("~0", "~"));
3029
- const [section, name, sub, ...rest] = segments;
3030
- const repoGame = project?.game;
3031
- const repoSection = GAME_SCOPED_KEYS.find((k) => k === sub);
3032
- if (repoGame !== undefined && section === "games" && name === repoGame && repoSection !== undefined && valueAt2(project?.[repoSection], rest) !== undefined) {
3033
- return " <- from the .gamecrate project config, not this file";
3034
- }
3035
- if (valueAt2(user, segments) !== undefined)
3036
- return "";
3037
- const plugin = section === "games" && name !== undefined ? plugins.get(name) : undefined;
3038
- if (plugin === undefined)
3039
- return " <- not in this file";
3040
- 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;
3041
3002
  }
3042
- function valueAt2(value, segments) {
3043
- let current = value;
3044
- for (const segment of segments) {
3045
- if (Array.isArray(current))
3046
- current = current[Number(segment)];
3047
- else if (isObj(current))
3048
- current = own(current, segment);
3049
- 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)
3050
3022
  return;
3023
+ throw new GamecrateError(`--pull never but ${image.ref} is not present locally`, Exit.Environment);
3051
3024
  }
3052
- return current;
3053
- }
3054
- function resolveSettings(root, game, profile, ...overrides) {
3055
- let out = structuredClone(DEFAULT_SETTINGS);
3056
- for (const layer of [root.defaults?.settings, game.settings, profile.settings, ...overrides]) {
3057
- if (layer)
3058
- 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);
3059
3031
  }
3060
- return out;
3061
- }
3062
- function resolveProfile(game, name) {
3063
- return resolveNamed(game, name, []);
3064
3032
  }
3065
- function resolveNamed(game, name, seen) {
3066
- if (name.toLowerCase() === "modless")
3067
- return { mods: [], exclude: [], includeBase: false };
3068
- const key = profileKey(game, name);
3069
- if (key === undefined) {
3070
- throw new GamecrateError(`unknown profile "${name}"`, Exit.Resolution, `known profiles: ${Object.keys(game.profiles).join(", ") || "(none)"}, modless`);
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);
3071
3036
  }
3072
- if (seen.includes(key)) {
3073
- throw new GamecrateError(`profile "${key}" inherits from itself`, Exit.Config, [...seen, key].join(" -> "));
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);
3074
3041
  }
3075
- const self = own(game.profiles, key);
3076
- if (self.alias !== undefined)
3077
- return resolveNamed(game, self.alias, [...seen, key]);
3078
- const parent = self.extends !== undefined ? resolveNamed(game, self.extends, [...seen, key]) : {};
3079
- const exclude = [...parent.exclude ?? [], ...self.exclude ?? []];
3080
- const out = {
3081
- mods: subtract([...parent.mods ?? [], ...self.mods ?? []], exclude),
3082
- exclude,
3083
- settings: deepMerge(parent.settings ?? {}, self.settings ?? {}, true)
3084
- };
3085
- const instances = deepMerge(parent.instances ?? {}, self.instances ?? {}, true);
3086
- if (Object.keys(instances).length > 0)
3087
- out.instances = instances;
3088
- const includeBase = self.includeBase ?? parent.includeBase;
3089
- if (includeBase !== undefined)
3090
- out.includeBase = includeBase;
3091
- const auto = self.autoDependencies ?? parent.autoDependencies;
3092
- if (auto !== undefined)
3093
- out.autoDependencies = auto;
3094
- for (const field of ["detach", "replace", "build", "gameVersion", "image"]) {
3095
- const value = self[field] ?? parent[field];
3096
- if (value !== undefined)
3097
- Object.assign(out, { [field]: value });
3042
+ }
3043
+ async function buildTarget(dir) {
3044
+ let entries;
3045
+ try {
3046
+ entries = await readdir4(dir);
3047
+ } catch {
3048
+ return null;
3098
3049
  }
3099
- return out;
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;
3100
3055
  }
3101
- function canonicalProfile(game, name) {
3102
- if (name.toLowerCase() === "modless")
3103
- return "modless";
3104
- const seen = [];
3105
- let current = name;
3106
- for (;; ) {
3107
- const key = profileKey(game, current);
3108
- if (key === undefined || seen.includes(key))
3109
- return key ?? current;
3110
- const next = own(game.profiles, key)?.alias;
3111
- if (next === undefined)
3112
- return key;
3113
- seen.push(key);
3114
- current = next;
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;
3115
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
+ `));
3116
3080
  }
3117
- function profileDataDir(root, game, profile) {
3118
- return resolve3(expandHome(root.dataRoot), game, canonicalProfile(own(root.games, game), profile));
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(() => {});
3119
3097
  }
3120
- async function profileDirs(root, game, profile) {
3121
- if (profile !== undefined)
3122
- return [profileDataDir(root, game, profile)];
3123
- const dir = join7(expandHome(root.dataRoot), game);
3124
- return (await readdir4(dir).catch(() => [])).map((name) => join7(dir, name));
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(() => {});
3125
3107
  }
3126
- function profileKey(game, name) {
3127
- if (Object.hasOwn(game.profiles, name))
3128
- return name;
3129
- const lower = name.toLowerCase();
3130
- const direct = Object.keys(game.profiles).find((k) => k.toLowerCase() === lower);
3131
- if (direct !== undefined)
3132
- return direct;
3133
- return Object.keys(game.profiles).find((k) => (own(game.profiles, k)?.aliases ?? []).some((a) => typeof a === "string" && a.toLowerCase() === lower));
3108
+ function heldLock(plan) {
3109
+ const path = lockPath(plan);
3110
+ return {
3111
+ release: async () => {
3112
+ await unlinkHeld(path, process.pid);
3113
+ }
3114
+ };
3134
3115
  }
3135
- function subtract(mods, exclude) {
3136
- if (exclude.length === 0)
3137
- return mods;
3138
- const patterns = exclude.map(globToRegExp);
3139
- return mods.filter((entry) => {
3140
- const ids = entryIds(entry);
3141
- if (ids.length === 0)
3142
- return true;
3143
- return !ids.some((id) => patterns.some((re) => re.test(id)));
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
3144
3126
  });
3127
+ return heldLock(plan);
3145
3128
  }
3146
- function entryIds(entry) {
3147
- if (typeof entry === "string")
3148
- return [entry, entry.replace(/^(workshop|path):/, "")];
3149
- if ("id" in entry)
3150
- return [entry.id];
3151
- return [];
3152
- }
3153
- function globToRegExp(pattern) {
3154
- const body = pattern.replaceAll(/[.+^${}()|[\]\\]/g, String.raw`\$&`).replaceAll("*", ".*").replaceAll("?", ".");
3155
- return new RegExp(`^${body}$`, "i");
3129
+ function lockPath(plan) {
3130
+ return join7(plan.instanceDir, ".gamecrate", "lock");
3156
3131
  }
3157
- function mergeUserConfig(base, user) {
3158
- const out = deepMerge(base, user);
3159
- const games = isObj(user) ? user["games"] : undefined;
3160
- if (!isObj(games))
3161
- return out;
3162
- for (const name of Object.keys(games)) {
3163
- const theirs = own(games, name);
3164
- const steamBuild = isObj(theirs) ? theirs["steamBuild"] : undefined;
3165
- const added = isObj(steamBuild) ? steamBuild["branches"] : undefined;
3166
- const declared = own(base.games, name)?.steamBuild?.branches;
3167
- const target = own(out.games, name);
3168
- if (!Array.isArray(added) || !Array.isArray(declared) || target === undefined)
3169
- continue;
3170
- 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;
3171
3143
  }
3172
- return out;
3173
3144
  }
3174
- function branchName(entry) {
3175
- 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();
3176
3153
  }
3177
- function concatBranches(declared, added) {
3178
- const out = [...declared];
3179
- const at = new Map;
3180
- out.forEach((branch, index) => {
3181
- const name = branchName(branch);
3182
- if (name !== undefined && !at.has(name))
3183
- at.set(name, index);
3184
- });
3185
- for (const entry of added) {
3186
- const name = branchName(entry);
3187
- const index = name === undefined ? undefined : at.get(name);
3188
- if (index === undefined) {
3189
- if (name !== undefined)
3190
- at.set(name, out.length);
3191
- out.push(entry);
3192
- 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;
3193
3175
  }
3194
- out[index] = deepMerge(out[index], entry);
3176
+ if (Date.now() >= deadline)
3177
+ return "held";
3178
+ await sleep2(RELEASE_POLL_MS);
3195
3179
  }
3196
- return out;
3180
+ return signalled ? "signalled" : "orphaned";
3197
3181
  }
3198
- function deepMerge(base, over, concatArrays = false) {
3199
- if (Array.isArray(base) && Array.isArray(over)) {
3200
- 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;
3201
3194
  }
3202
- if (isObj(base) && isObj(over)) {
3203
- const out = { ...base };
3204
- for (const [k, v] of Object.entries(over)) {
3205
- if (v === undefined)
3206
- continue;
3207
- out[k] = Object.hasOwn(out, k) ? deepMerge(out[k], v, concatArrays) : v;
3208
- }
3209
- return out;
3195
+ if (running) {
3196
+ status(`stopping ${name}`);
3197
+ await stopContainer(name, STOP_TIMEOUT_SECONDS);
3210
3198
  }
3211
- return over;
3212
3199
  }
3213
- function expandPaths(config) {
3214
- config.dataRoot = expandHome(config.dataRoot);
3215
- if (config.steamcmd?.path !== undefined)
3216
- config.steamcmd.path = expandHome(config.steamcmd.path);
3217
- for (const game of Object.values(config.games)) {
3218
- if (game.gameFiles.host !== undefined)
3219
- game.gameFiles.host = expandHome(game.gameFiles.host);
3220
- if (game.image.context !== undefined)
3221
- game.image.context = expandHome(game.image.context);
3222
- if (game.workshopRoot !== null)
3223
- game.workshopRoot = expandHome(game.workshopRoot);
3224
- for (const root of game.scanRoots)
3225
- root.path = expandHome(root.path);
3226
- for (const entry of Object.values(game.library ?? {})) {
3227
- if (entry.path !== undefined)
3228
- entry.path = expandHome(entry.path);
3229
- }
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;
3230
3207
  }
3231
- 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;
3232
3215
  }
3233
- function expandHome(p) {
3234
- if (p === "~")
3235
- return homedir2();
3236
- 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;
3237
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" });
3264
+ }
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";
3238
3273
 
3239
3274
  // src/mods/acf.ts
3240
3275
  var MAX_DEPTH = 100;
@@ -3928,7 +3963,7 @@ function buildIdFor(output, branch) {
3928
3963
  // src/mods/worktree.ts
3929
3964
  import { spawnSync as spawnSync3 } from "node:child_process";
3930
3965
  import { existsSync as existsSync6, realpathSync as realpathSync2 } from "node:fs";
3931
- import { isAbsolute as isAbsolute2, resolve as resolve4, sep } from "node:path";
3966
+ import { isAbsolute as isAbsolute3, resolve as resolve5, sep } from "node:path";
3932
3967
  function inspect(dir) {
3933
3968
  const r = spawnSync3("git", ["-C", dir, "rev-parse", "--path-format=absolute", "--show-toplevel", "--git-dir", "--git-common-dir", "--abbrev-ref", "HEAD"], { encoding: "utf8" });
3934
3969
  if (r.status !== 0 || typeof r.stdout !== "string")
@@ -3944,12 +3979,12 @@ function canonical(p) {
3944
3979
  try {
3945
3980
  return realpathSync2(p);
3946
3981
  } catch {
3947
- return resolve4(p);
3982
+ return resolve5(p);
3948
3983
  }
3949
3984
  }
3950
3985
  function resolveWorktree(dir, source, order) {
3951
3986
  const raw = expandHome(dir);
3952
- const abs = isAbsolute2(raw) ? raw : resolve4(process.cwd(), raw);
3987
+ const abs = isAbsolute3(raw) ? raw : resolve5(process.cwd(), raw);
3953
3988
  if (!existsSync6(abs)) {
3954
3989
  return { where: abs, message: `--worktree path does not exist`, suggestion: "check the path, or drop the flag" };
3955
3990
  }
@@ -4526,9 +4561,20 @@ function refsRoot() {
4526
4561
  function refsLink(game) {
4527
4562
  return join11(refsRoot(), "current", game);
4528
4563
  }
4564
+ function currentRefs(game) {
4565
+ try {
4566
+ const target = readlinkSync(refsLink(game));
4567
+ return `refs in use: ${target.replace(/^.*\/refs\//, "")}`;
4568
+ } catch {
4569
+ return;
4570
+ }
4571
+ }
4529
4572
  var MARK2 = "@@gamecrate@@ ";
4530
4573
  function extractScript(candidates, container) {
4531
- const quoted = candidates.map((c) => `'${join11(container, c).replaceAll("'", String.raw`'\''`)}'`);
4574
+ const quoted = candidates.map((c) => {
4575
+ const escaped = join11(container, c).replaceAll("'", String.raw`'\''`);
4576
+ return `'${escaped}'`;
4577
+ });
4532
4578
  return [
4533
4579
  `for d in ${quoted.join(" ")}; do`,
4534
4580
  ' set -- "$d"/*.dll',
@@ -4675,7 +4721,7 @@ function profileRows(profile, spec, width, notes) {
4675
4721
  // src/cli/mods.ts
4676
4722
  import { existsSync as existsSync9 } from "node:fs";
4677
4723
  import { mkdir as mkdir4, readFile as readFile8, readdir as readdir8, writeFile as writeFile4 } from "node:fs/promises";
4678
- import { dirname as dirname9, join as join14, relative as relative3, resolve as resolve5 } from "node:path";
4724
+ import { dirname as dirname9, join as join14, relative as relative3, resolve as resolve6 } from "node:path";
4679
4725
 
4680
4726
  // src/config/write.ts
4681
4727
  import { chmod, readFile as readFile6, realpath, rename as rename2, stat as stat3, writeFile as writeFile3 } from "node:fs/promises";
@@ -4880,7 +4926,7 @@ async function modsAdd(args, ctx) {
4880
4926
  const target = await resolveTarget(args, ctx, game);
4881
4927
  const pins = await discover(source, gameConfig, requirePlugin(ctx.plugins, game), ctx);
4882
4928
  if (pins.length === 0) {
4883
- throw new GamecrateError(`no mod manifest at ${describe(source)}`, Exit.Resolution, `looked for ${gameConfig.manifest.file}`);
4929
+ throw new GamecrateError(`no mod manifest at ${describe2(source)}`, Exit.Resolution, `looked for ${gameConfig.manifest.file}`);
4884
4930
  }
4885
4931
  const where = new Map;
4886
4932
  for (const pin of pins) {
@@ -4893,7 +4939,7 @@ async function modsAdd(args, ctx) {
4893
4939
  const lines = [...where.entries()].filter(([, at]) => at.length > 1);
4894
4940
  const rows = lines.map(([id, at]) => ` ${id}: ${at.join(", ")}`).join(`
4895
4941
  `);
4896
- throw new GamecrateError(`${twice.length} mod id(s) are declared by more than one directory in ${describe(source)}`, Exit.Config, `${rows}
4942
+ throw new GamecrateError(`${twice.length} mod id(s) are declared by more than one directory in ${describe2(source)}`, Exit.Config, `${rows}
4897
4943
  pin one of them with --subdir`);
4898
4944
  }
4899
4945
  const clashes = pins.filter((pin) => existingKey(target.existing, pin.id) !== undefined);
@@ -5023,7 +5069,7 @@ async function syncWorkshop(ctx, name, pins) {
5023
5069
  function gitPin(url, entry) {
5024
5070
  return { url, ...entry.subdir === undefined ? {} : { subdir: entry.subdir } };
5025
5071
  }
5026
- function describe(source) {
5072
+ function describe2(source) {
5027
5073
  if (source.kind === "path")
5028
5074
  return source.value;
5029
5075
  if (source.kind === "workshop")
@@ -5093,7 +5139,7 @@ function existingKey(library, id) {
5093
5139
  }
5094
5140
  async function discover(source, game, plugin, ctx) {
5095
5141
  if (source.kind === "path") {
5096
- const dir = resolve5(ctx.cwd, expandHome(source.value));
5142
+ const dir = resolve6(ctx.cwd, expandHome(source.value));
5097
5143
  const id = await readId(dir, game.manifest.file, plugin);
5098
5144
  return id === undefined ? [] : [{ id, entry: { path: dir } }];
5099
5145
  }
@@ -5302,7 +5348,8 @@ function dockerRun(mounts, script) {
5302
5348
  ];
5303
5349
  }
5304
5350
  function shq(value) {
5305
- return `'${value.replaceAll("'", `'\\''`)}'`;
5351
+ const escaped = value.replaceAll("'", String.raw`'\''`);
5352
+ return `'${escaped}'`;
5306
5353
  }
5307
5354
  function craneArgv(mounts, ref, lines) {
5308
5355
  const creds = ref === null ? null : registryCreds();
@@ -5353,7 +5400,7 @@ async function runLive(argv, what) {
5353
5400
  }
5354
5401
  function lastLine(text) {
5355
5402
  return text.split(`
5356
- `).map((l) => l.trim()).filter((l) => l !== "").at(-1) ?? "no output";
5403
+ `).map((l) => l.trim()).findLast((l) => l !== "") ?? "no output";
5357
5404
  }
5358
5405
  async function craneAppend(opts) {
5359
5406
  for (const p of opts.include) {
@@ -5532,12 +5579,16 @@ async function resolveSteamBuildInput(game, config, overrides, cwd, configFile)
5532
5579
  // src/image/tags.ts
5533
5580
  function sanitizeVersion(raw, fallback) {
5534
5581
  const first = raw.trim().split(/\s+/)[0] ?? "";
5535
- const mapped = first.replaceAll(/[^A-Za-z0-9._-]/g, "-").replace(/-+$/, "");
5536
- return mapped.length > 0 ? mapped : fallback;
5582
+ const mapped = first.replaceAll(/[^\w.-]/g, "-");
5583
+ let end = mapped.length;
5584
+ while (end > 0 && mapped.charAt(end - 1) === "-")
5585
+ end -= 1;
5586
+ const trimmed = mapped.slice(0, end);
5587
+ return trimmed.length > 0 ? trimmed : fallback;
5537
5588
  }
5538
5589
  function versionPrefixes(version) {
5539
5590
  const parts = version.split(".");
5540
- if (parts.length < 2 || parts.some((p) => p === ""))
5591
+ if (parts.length < 2 || parts.includes(""))
5541
5592
  return [];
5542
5593
  return parts.slice(0, -1).map((_, i) => parts.slice(0, i + 1).join("."));
5543
5594
  }
@@ -5683,6 +5734,10 @@ async function download(input, opts, ctx, say) {
5683
5734
  }
5684
5735
  return (await pending).dir;
5685
5736
  }
5737
+ function loadOutput(stdout, stderr) {
5738
+ return `${stdout}
5739
+ ${stderr}`.trim() || "(no output)";
5740
+ }
5686
5741
  async function resolvedBase(base, platform) {
5687
5742
  if (base.includes("@sha256:"))
5688
5743
  return base;
@@ -5708,7 +5763,7 @@ async function readGate(ref, opts) {
5708
5763
  reads.push(await craneLabels(ref));
5709
5764
  if (opts.load)
5710
5765
  reads.push(await inspectLabels(ref));
5711
- if (reads.some((labels) => labels === null))
5766
+ if (reads.includes(null))
5712
5767
  return { present: false, buildid: null };
5713
5768
  const ids = reads.map((labels) => labels?.["steam.buildid"] ?? null);
5714
5769
  return { present: reads.length > 0, buildid: ids.every((id) => id === ids[0]) ? ids[0] ?? null : null };
@@ -5726,8 +5781,7 @@ async function inspectLabels(ref) {
5726
5781
  async function dockerLoad(tar, ref) {
5727
5782
  const { code, stdout, stderr } = await capture(["docker", "load", "-i", tar]);
5728
5783
  if (code !== 0 || !stdout.includes(`Loaded image: ${ref}`)) {
5729
- throw new GamecrateError(`docker load failed for ${tar}`, Exit.Environment, `expected "Loaded image: ${ref}", got: ${`${stdout}
5730
- ${stderr}`.trim() || "(no output)"}`);
5784
+ throw new GamecrateError(`docker load failed for ${tar}`, Exit.Environment, `expected "Loaded image: ${ref}", got: ${loadOutput(stdout, stderr)}`);
5731
5785
  }
5732
5786
  }
5733
5787
  async function dockerLabel(ref, labels) {
@@ -6002,7 +6056,10 @@ ${cases}
6002
6056
  complete -F ${fn} ${NAME}
6003
6057
  `;
6004
6058
  }
6005
- const zshVerbs = SUBCOMMANDS.map((s) => ` '${s.name}:${s.summary.replaceAll("'", String.raw`'\''`)}'`).join(`
6059
+ const zshVerbs = SUBCOMMANDS.map((s) => {
6060
+ const summary = s.summary.replaceAll("'", String.raw`'\''`);
6061
+ return ` '${s.name}:${summary}'`;
6062
+ }).join(`
6006
6063
  `);
6007
6064
  const zshFlags = options.map((f) => {
6008
6065
  const desc = f.description.replaceAll("'", String.raw`'\''`).replaceAll(/[[\]:]/g, "");
@@ -6216,6 +6273,10 @@ async function checkImage(plan, problems, asShell) {
6216
6273
  problems.push(marker);
6217
6274
  return;
6218
6275
  }
6276
+ await checkAbsentImage(plan, problems, where, problem);
6277
+ }
6278
+ async function checkAbsentImage(plan, problems, where, problem) {
6279
+ const image = plan.gameConfig.image;
6219
6280
  if (image.ref.trim() === "") {
6220
6281
  if (problem)
6221
6282
  problems.push(problem);
@@ -6385,11 +6446,143 @@ function message2(error) {
6385
6446
  import { readFileSync as readFileSync8 } from "node:fs";
6386
6447
  import { basename as basename7 } from "node:path";
6387
6448
  import { setTimeout as sleep6 } from "node:timers/promises";
6449
+
6450
+ // src/docker/icon.ts
6451
+ import { execFile } from "node:child_process";
6452
+ import { connect } from "node:net";
6453
+ import { promisify } from "node:util";
6454
+ var run2 = promisify(execFile);
6455
+ var CHANGE_PROPERTY = 18;
6456
+ var INTERN_ATOM = 16;
6457
+ var CARDINAL = 6;
6458
+ function padding(length) {
6459
+ return (4 - length % 4) % 4;
6460
+ }
6461
+ function displayNumber(display) {
6462
+ return display.replace(/^.*:/, "").split(".")[0] ?? "0";
6463
+ }
6464
+ async function cookieFor(display) {
6465
+ const wanted = displayNumber(display);
6466
+ try {
6467
+ const { stdout } = await run2("xauth", ["list"]);
6468
+ for (const line of stdout.split(`
6469
+ `)) {
6470
+ const match = /^\S+:(\d+)\s+MIT-MAGIC-COOKIE-1\s+([0-9a-f]+)$/.exec(line.trim());
6471
+ if (match && match[1] === wanted)
6472
+ return Buffer.from(match[2], "hex");
6473
+ }
6474
+ } catch {
6475
+ return null;
6476
+ }
6477
+ return null;
6478
+ }
6479
+ async function argbPixels(path, size) {
6480
+ for (const tool of ["magick", "convert"]) {
6481
+ try {
6482
+ const { stdout } = await run2(tool, [path, "-resize", `${size}x${size}!`, "-depth", "8", "RGBA:-"], { encoding: "buffer", maxBuffer: 64 * 1024 * 1024 });
6483
+ if (stdout.length === size * size * 4)
6484
+ return stdout;
6485
+ } catch {
6486
+ continue;
6487
+ }
6488
+ }
6489
+ return null;
6490
+ }
6491
+ async function openDisplay(display) {
6492
+ const cookie = await cookieFor(display);
6493
+ if (cookie === null)
6494
+ return null;
6495
+ const socket = connect(`/tmp/.X11-unix/X${displayNumber(display)}`);
6496
+ try {
6497
+ await new Promise((resolve, reject) => {
6498
+ socket.once("connect", resolve);
6499
+ socket.once("error", reject);
6500
+ });
6501
+ } catch {
6502
+ return null;
6503
+ }
6504
+ const name = Buffer.from("MIT-MAGIC-COOKIE-1");
6505
+ const head = Buffer.alloc(12);
6506
+ head.write("l", 0, "ascii");
6507
+ head.writeUInt16LE(11, 2);
6508
+ head.writeUInt16LE(name.length, 6);
6509
+ head.writeUInt16LE(cookie.length, 8);
6510
+ socket.write(Buffer.concat([
6511
+ head,
6512
+ name,
6513
+ Buffer.alloc(padding(name.length)),
6514
+ cookie,
6515
+ Buffer.alloc(padding(cookie.length))
6516
+ ]));
6517
+ const reply = await new Promise((resolve) => socket.once("data", resolve));
6518
+ if (reply[0] !== 1) {
6519
+ socket.destroy();
6520
+ return null;
6521
+ }
6522
+ return socket;
6523
+ }
6524
+ function internAtom(socket, name) {
6525
+ const length = 8 + name.length + padding(name.length);
6526
+ const request = Buffer.alloc(length);
6527
+ request.writeUInt8(INTERN_ATOM, 0);
6528
+ request.writeUInt16LE(length / 4, 2);
6529
+ request.writeUInt16LE(name.length, 4);
6530
+ request.write(name, 8, "ascii");
6531
+ socket.write(request);
6532
+ return new Promise((resolve) => socket.once("data", (d) => resolve(d.readUInt32LE(8))));
6533
+ }
6534
+ function iconProperty(rgba, size) {
6535
+ const body = Buffer.alloc(8 + size * size * 4);
6536
+ body.writeUInt32LE(size, 0);
6537
+ body.writeUInt32LE(size, 4);
6538
+ for (let i = 0;i < size * size; i += 1) {
6539
+ const [r, g, b, a] = [rgba[i * 4], rgba[i * 4 + 1], rgba[i * 4 + 2], rgba[i * 4 + 3]];
6540
+ body.writeUInt32LE((a << 24 | r << 16 | g << 8 | b) >>> 0, 8 + i * 4);
6541
+ }
6542
+ return body;
6543
+ }
6544
+ var ICON_SIZE = 64;
6545
+ async function setWindowIcon(windowId, iconPath) {
6546
+ const display = process.env.DISPLAY;
6547
+ if (display === undefined || display === "")
6548
+ return;
6549
+ const rgba = await argbPixels(iconPath, ICON_SIZE);
6550
+ if (rgba === null) {
6551
+ warn(`could not read ${iconPath}, so the window keeps its own icon`);
6552
+ return;
6553
+ }
6554
+ const socket = await openDisplay(display);
6555
+ if (socket === null) {
6556
+ warn("could not reach the X server to set the window icon");
6557
+ return;
6558
+ }
6559
+ try {
6560
+ const atom = await internAtom(socket, "_NET_WM_ICON");
6561
+ const body = iconProperty(rgba, ICON_SIZE);
6562
+ const request = Buffer.alloc(24);
6563
+ request.writeUInt8(CHANGE_PROPERTY, 0);
6564
+ request.writeUInt16LE((24 + body.length) / 4, 2);
6565
+ request.writeUInt32LE(Number.parseInt(windowId, 16), 4);
6566
+ request.writeUInt32LE(atom, 8);
6567
+ request.writeUInt32LE(CARDINAL, 12);
6568
+ request.writeUInt8(32, 16);
6569
+ request.writeUInt32LE(body.length / 4, 20);
6570
+ socket.write(Buffer.concat([request, body]));
6571
+ await new Promise((resolve) => setTimeout(resolve, 100));
6572
+ } finally {
6573
+ socket.end();
6574
+ }
6575
+ }
6576
+
6577
+ // src/docker/window.ts
6388
6578
  var WAIT_MS = 180000;
6389
6579
  var POLL_MS = 500;
6390
6580
  function newMatches(now, seen, executable) {
6391
6581
  const wanted = basename7(executable).toLowerCase();
6392
- return now.filter((w) => !seen.has(w.id) && w.wmClass.toLowerCase().includes(wanted));
6582
+ return now.filter((w) => !seen.has(w.id) && classMatches(w.wmClass, wanted));
6583
+ }
6584
+ function classMatches(wmClass, wanted) {
6585
+ return wmClass.toLowerCase().split(".").some((part) => part.length >= 3 && (wanted.includes(part) || part.includes(wanted)));
6393
6586
  }
6394
6587
  function parseWindowPid(stdout) {
6395
6588
  const match = /_NET_WM_PID\(CARDINAL\)\s*=\s*(\d+)/.exec(stdout);
@@ -6458,6 +6651,8 @@ async function adoptFirstMatch(seen, opts, stopped) {
6458
6651
  if (!await adopt(match.id, opts))
6459
6652
  continue;
6460
6653
  await capture(["wmctrl", "-i", "-r", match.id, "-N", opts.title]);
6654
+ if (opts.icon !== undefined)
6655
+ await setWindowIcon(match.id, opts.icon);
6461
6656
  if (opts.stripDelete)
6462
6657
  await watchForClose(match.id, stopped, opts.onClosed);
6463
6658
  return true;
@@ -7590,7 +7785,7 @@ function published2(value) {
7590
7785
  }
7591
7786
 
7592
7787
  // src/index.ts
7593
- var VERSION = "2.3.0";
7788
+ var VERSION = "2.4.0";
7594
7789
  async function main(argv) {
7595
7790
  const supervised = supervisedDir(argv);
7596
7791
  try {
@@ -7670,17 +7865,21 @@ async function dispatch(argv, args, config, plugins, defaults) {
7670
7865
  case "wait":
7671
7866
  return waitFor(args, config, defaults);
7672
7867
  case "shell":
7673
- return run2(argv, args, config, plugins, defaults, true);
7868
+ return run3(argv, args, config, plugins, defaults, true);
7674
7869
  case "config":
7675
7870
  return configEdit(args);
7676
7871
  case "fix-perms":
7677
7872
  return fixPerms(args, config);
7678
7873
  case "run":
7679
- return run2(argv, args, config, plugins, defaults, false);
7874
+ return run3(argv, args, config, plugins, defaults, false);
7680
7875
  default:
7681
7876
  throw new GamecrateError(`no such subcommand ${args.subcommand}`, Exit.Usage);
7682
7877
  }
7683
7878
  }
7879
+ function iconOption(plan, configFile) {
7880
+ const icon = windowIcon(plan, dirname14(configFile));
7881
+ return icon === undefined ? {} : { icon };
7882
+ }
7684
7883
  function helpTopic(args) {
7685
7884
  if (args.subcommand === "help")
7686
7885
  return args.rest[0];
@@ -7722,7 +7921,7 @@ function reportEnvironment(problems) {
7722
7921
  throw new GamecrateError(`${problems.length} environment problem(s)`, Exit.Environment, out.join(`
7723
7922
  `));
7724
7923
  }
7725
- async function run2(argv, args, config, plugins, defaults, asShell) {
7924
+ async function run3(argv, args, config, plugins, defaults, asShell) {
7726
7925
  const game = requireGame(args, config);
7727
7926
  const profile = launchProfile(args, defaults, config.games[game]);
7728
7927
  const gameConfig = gameForImage(args, config, defaults, game);
@@ -7813,7 +8012,7 @@ run: gamecrate fix-perms ${game} ${profile}`);
7813
8012
  }
7814
8013
  async function execute(inputs) {
7815
8014
  const { plan, args, config, identity, asShell, profileSpec, runDir, releaseSources } = inputs;
7816
- await buildLocalMods(plan, buildPolicy(args, profileSpec));
8015
+ await buildLocalMods(plan, buildPolicy(args, profileSpec), currentRefs(plan.game));
7817
8016
  await releaseSources();
7818
8017
  const { facts, imageStart } = await readyImage(plan, config, args, asShell);
7819
8018
  const modMounts = await stageMods(plan);
@@ -7871,8 +8070,10 @@ async function rebuiltForUpdate(plan, config, args) {
7871
8070
  cwd: process.cwd(),
7872
8071
  configFile: await globalConfigPath(),
7873
8072
  ask: async (question) => {
7874
- if (args.yes)
7875
- return rebuilt = true;
8073
+ if (args.yes) {
8074
+ rebuilt = true;
8075
+ return true;
8076
+ }
7876
8077
  if (process.stdin.isTTY !== true || args.json) {
7877
8078
  warn("no terminal to ask, so the launch keeps the current image");
7878
8079
  return false;
@@ -7900,6 +8101,7 @@ async function dispatchRun(spec, plan, runDir, trustExit, asShell) {
7900
8101
  const window = asShell || plan.settings.display !== "x11" ? null : await adoptNewWindow({
7901
8102
  executable: plan.gameConfig.executable,
7902
8103
  title: windowTitle(plan),
8104
+ ...iconOption(plan, await globalConfigPath()),
7903
8105
  stripDelete: plan.gameConfig.ignoresWmDelete === true,
7904
8106
  onClosed: () => {
7905
8107
  windowClosed = true;
@@ -8016,7 +8218,7 @@ async function copyOutLogs(plan) {
8016
8218
  try {
8017
8219
  await cp(source, target, { recursive: true, force: true });
8018
8220
  } catch (error) {
8019
- warn(`could not copy ${source}: ${describe2(error)}`);
8221
+ warn(`could not copy ${source}: ${describe3(error)}`);
8020
8222
  }
8021
8223
  }
8022
8224
  async function mods(args, config, plugins, defaults) {
@@ -8078,12 +8280,38 @@ async function doctor(config, plugins) {
8078
8280
  const gameConfig = config.games[game];
8079
8281
  const sources = cachedSources(gameConfig, "modless", {}, config.dataRoot);
8080
8282
  const { plan, problems } = await resolvePlan({ game, profile: "modless", root: config, plugins, sources });
8081
- const all = [...problems, ...await preflight(plan), ...steamcmdProblems(game, gameConfig, config)];
8283
+ const all = [
8284
+ ...problems,
8285
+ ...await preflight(plan),
8286
+ ...steamcmdProblems(game, gameConfig, config),
8287
+ ...await baseDriftProblems(game, gameConfig)
8288
+ ];
8082
8289
  if (!reportDoctor(game, all))
8083
8290
  failed = true;
8084
8291
  }
8085
8292
  return failed ? Exit.Environment : Exit.Ok;
8086
8293
  }
8294
+ async function baseDriftProblems(game, gameConfig) {
8295
+ const ref = gameConfig.image.ref;
8296
+ if (ref.trim() === "")
8297
+ return [];
8298
+ const facts = await readImageFacts(ref);
8299
+ if (!facts.present || facts.runtime === null)
8300
+ return [];
8301
+ const base = RUNTIME_BASE[facts.launcher === "proton" ? "proton" : "xvfb"];
8302
+ const current = await repoDigest(base);
8303
+ if (current === null)
8304
+ return [];
8305
+ if (facts.runtime.endsWith(current))
8306
+ return [];
8307
+ return [
8308
+ {
8309
+ where: `/games/${game}/image/ref`,
8310
+ message: `${ref} was built on an older ${base}`,
8311
+ suggestion: `gamecrate steam build ${game} to rebuild it on the base you have`
8312
+ }
8313
+ ];
8314
+ }
8087
8315
  function steamcmdProblems(game, gameConfig, config) {
8088
8316
  if (!usesWorkshop(gameConfig))
8089
8317
  return [];
@@ -8491,7 +8719,7 @@ async function spawnStatus(argv, interactive = false) {
8491
8719
  const proc = spawnArgv(argv, [interactive ? "inherit" : "ignore", "inherit", "inherit"]);
8492
8720
  return exited(proc);
8493
8721
  }
8494
- function describe2(error) {
8722
+ function describe3(error) {
8495
8723
  return error instanceof Error ? error.message : String(error);
8496
8724
  }
8497
8725
  function reportFatal(error) {
@@ -8503,7 +8731,7 @@ function reportFatal(error) {
8503
8731
  `);
8504
8732
  return error.code;
8505
8733
  }
8506
- process.stderr.write(`gamecrate: ${describe2(error)}
8734
+ process.stderr.write(`gamecrate: ${describe3(error)}
8507
8735
  `);
8508
8736
  return Exit.GameFailed;
8509
8737
  }