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