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