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