@gamecrate/cli 1.2.0 → 1.3.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/README.md +19 -7
- package/dist/gamecrate.js +829 -216
- package/dist/lib.js +1 -0
- package/dist/types/cli/mods.d.ts +2 -0
- package/dist/types/launch/resolve.d.ts +4 -6
- package/dist/types/mods/acf.d.ts +19 -0
- package/dist/types/mods/modindex.d.ts +8 -3
- package/dist/types/mods/source.d.ts +7 -2
- package/dist/types/mods/steamcmd.d.ts +56 -0
- package/dist/types/mods/workshop.d.ts +15 -0
- package/dist/types/mods/workshopapi.d.ts +13 -0
- package/dist/types/types.d.ts +4 -0
- package/package.json +1 -1
package/dist/gamecrate.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
|
-
import { existsSync as
|
|
5
|
-
import { chown, cp, mkdir as mkdir6, readdir as readdir10, readFile as
|
|
4
|
+
import { existsSync as existsSync11 } from "node:fs";
|
|
5
|
+
import { chown, cp, mkdir as mkdir6, readdir as readdir10, readFile as readFile12, rm as rm4, rmdir, stat as stat6, writeFile as writeFile7 } from "node:fs/promises";
|
|
6
6
|
import { homedir as homedir5 } from "node:os";
|
|
7
|
-
import { basename as
|
|
8
|
-
import { setTimeout as
|
|
7
|
+
import { basename as basename11, dirname as dirname9, join as join22 } from "node:path";
|
|
8
|
+
import { setTimeout as sleep7 } from "node:timers/promises";
|
|
9
9
|
|
|
10
10
|
// src/cli/args.ts
|
|
11
11
|
import { Command, CommanderError, Option } from "commander";
|
|
@@ -797,8 +797,8 @@ function list(args, config, defaults) {
|
|
|
797
797
|
|
|
798
798
|
// src/cli/mods.ts
|
|
799
799
|
import { existsSync as existsSync5 } from "node:fs";
|
|
800
|
-
import { mkdir as mkdir2, readFile as
|
|
801
|
-
import { dirname as
|
|
800
|
+
import { mkdir as mkdir2, readFile as readFile7, readdir as readdir5, writeFile as writeFile3 } from "node:fs/promises";
|
|
801
|
+
import { dirname as dirname6, join as join12, relative as relative2, resolve as resolve4 } from "node:path";
|
|
802
802
|
|
|
803
803
|
// src/config/load.ts
|
|
804
804
|
import { z as z2 } from "zod";
|
|
@@ -1188,6 +1188,7 @@ var root = obj({
|
|
|
1188
1188
|
plugins: strArray.optional(),
|
|
1189
1189
|
dataRoot: str,
|
|
1190
1190
|
defaults: obj({ settings: settings.optional() }).optional(),
|
|
1191
|
+
steamcmd: obj({ path: str.optional() }).optional(),
|
|
1191
1192
|
games: z.unknown()
|
|
1192
1193
|
});
|
|
1193
1194
|
function validateConfig(cfg) {
|
|
@@ -1739,6 +1740,8 @@ function deepMerge(base, over, concatArrays = false) {
|
|
|
1739
1740
|
}
|
|
1740
1741
|
function expandPaths(config) {
|
|
1741
1742
|
config.dataRoot = expandHome(config.dataRoot);
|
|
1743
|
+
if (config.steamcmd?.path !== undefined)
|
|
1744
|
+
config.steamcmd.path = expandHome(config.steamcmd.path);
|
|
1742
1745
|
for (const game of Object.values(config.games)) {
|
|
1743
1746
|
if (game.gameFiles.host !== undefined)
|
|
1744
1747
|
game.gameFiles.host = expandHome(game.gameFiles.host);
|
|
@@ -2307,24 +2310,28 @@ async function scanBuildTimes(dir) {
|
|
|
2307
2310
|
await walk(dir, false);
|
|
2308
2311
|
return times;
|
|
2309
2312
|
}
|
|
2313
|
+
var SKEW_MS = 1000;
|
|
2314
|
+
function newerThan(source, assembly) {
|
|
2315
|
+
return source - assembly > SKEW_MS;
|
|
2316
|
+
}
|
|
2310
2317
|
function decideStale(times) {
|
|
2311
2318
|
const { newestSource, newestAssembly } = times;
|
|
2312
2319
|
if (newestSource === undefined)
|
|
2313
2320
|
return false;
|
|
2314
|
-
return newestAssembly === undefined || newestSource.mtimeMs
|
|
2321
|
+
return newestAssembly === undefined || newerThan(newestSource.mtimeMs, newestAssembly.mtimeMs);
|
|
2315
2322
|
}
|
|
2316
2323
|
function staleReport(times) {
|
|
2317
2324
|
const { newestSource, newestAssembly } = times;
|
|
2318
2325
|
if (newestSource === undefined || newestAssembly === undefined)
|
|
2319
2326
|
return null;
|
|
2320
|
-
if (newestSource.mtimeMs
|
|
2327
|
+
if (!newerThan(newestSource.mtimeMs, newestAssembly.mtimeMs))
|
|
2321
2328
|
return null;
|
|
2322
2329
|
return {
|
|
2323
2330
|
newestSource: newestSource.path,
|
|
2324
2331
|
newestSourceMs: newestSource.mtimeMs,
|
|
2325
2332
|
assembly: newestAssembly.path,
|
|
2326
2333
|
assemblyMs: newestAssembly.mtimeMs,
|
|
2327
|
-
newerCount: times.sourceTimes.filter((t) => t
|
|
2334
|
+
newerCount: times.sourceTimes.filter((t) => newerThan(t, newestAssembly.mtimeMs)).length
|
|
2328
2335
|
};
|
|
2329
2336
|
}
|
|
2330
2337
|
var INDENT = " ".repeat("warning: ".length);
|
|
@@ -2972,7 +2979,7 @@ function staleWarning2(dir, url, step) {
|
|
|
2972
2979
|
return `${what}, using the clone already on disk`;
|
|
2973
2980
|
return `${what}, using ${sha} from ${ago(Number(seconds) * 1000)}`;
|
|
2974
2981
|
}
|
|
2975
|
-
async function
|
|
2982
|
+
async function lockDir(dir) {
|
|
2976
2983
|
const path = `${dir}.lock`;
|
|
2977
2984
|
await mkdir(dirname4(path), { recursive: true });
|
|
2978
2985
|
const deadline = Date.now() + LOCK_TIMEOUT_MS;
|
|
@@ -3032,31 +3039,35 @@ function releaser(path) {
|
|
|
3032
3039
|
await unlink2(path).catch(() => {});
|
|
3033
3040
|
};
|
|
3034
3041
|
}
|
|
3042
|
+
function reachedEntries(game, profile, args) {
|
|
3043
|
+
const out = [...game.preCore ?? [], game.core, ...game.dlc];
|
|
3044
|
+
if (profile.includeBase !== false)
|
|
3045
|
+
out.push(...game.base ?? []);
|
|
3046
|
+
const only = args.only ?? [];
|
|
3047
|
+
out.push(...only.length > 0 ? only : profile.mods ?? []);
|
|
3048
|
+
out.push(...args.mods ?? []);
|
|
3049
|
+
const dropped = [...profile.exclude ?? [], ...args.without ?? []].map(globToRegExp);
|
|
3050
|
+
return out.filter((entry) => {
|
|
3051
|
+
if (typeof entry !== "string" && "match" in entry)
|
|
3052
|
+
return true;
|
|
3053
|
+
const id = typeof entry === "string" ? entry : entry.id;
|
|
3054
|
+
return !dropped.some((pattern) => pattern.test(id));
|
|
3055
|
+
});
|
|
3056
|
+
}
|
|
3035
3057
|
function pinnableIds(game, profile, args) {
|
|
3036
3058
|
const out = [];
|
|
3037
|
-
const
|
|
3059
|
+
for (const entry of reachedEntries(game, profile, args)) {
|
|
3038
3060
|
if (typeof entry === "string") {
|
|
3039
3061
|
if (!entry.includes(":"))
|
|
3040
3062
|
out.push(entry);
|
|
3041
|
-
|
|
3063
|
+
continue;
|
|
3042
3064
|
}
|
|
3043
3065
|
if ("match" in entry)
|
|
3044
|
-
|
|
3066
|
+
continue;
|
|
3045
3067
|
if (entry.path === undefined && entry.workshop === undefined && !entry.id.includes(":"))
|
|
3046
3068
|
out.push(entry.id);
|
|
3047
|
-
}
|
|
3048
|
-
|
|
3049
|
-
push(id);
|
|
3050
|
-
if (profile.includeBase !== false)
|
|
3051
|
-
for (const id of game.base ?? [])
|
|
3052
|
-
push(id);
|
|
3053
|
-
const only = args.only ?? [];
|
|
3054
|
-
for (const entry of only.length > 0 ? only : profile.mods ?? [])
|
|
3055
|
-
push(entry);
|
|
3056
|
-
for (const id of args.mods ?? [])
|
|
3057
|
-
push(id);
|
|
3058
|
-
const dropped = [...profile.exclude ?? [], ...args.without ?? []].map(globToRegExp);
|
|
3059
|
-
return out.filter((id) => !dropped.some((pattern) => pattern.test(id)));
|
|
3069
|
+
}
|
|
3070
|
+
return out;
|
|
3060
3071
|
}
|
|
3061
3072
|
function cachedSources(game, profileName, args, dataRoot) {
|
|
3062
3073
|
const dirs = new Map;
|
|
@@ -3117,7 +3128,7 @@ async function prepareSources(game, profileName, args, dataRoot, allowFetch) {
|
|
|
3117
3128
|
try {
|
|
3118
3129
|
for (const dir of [...jobs.keys()].sort()) {
|
|
3119
3130
|
const job = jobs.get(dir);
|
|
3120
|
-
locks.push(await
|
|
3131
|
+
locks.push(await lockDir(dir));
|
|
3121
3132
|
const result = await ensureClone(dataRoot, job.pin, job.ref, allowFetch ? "fetch" : "use");
|
|
3122
3133
|
fetched.push(result.dir);
|
|
3123
3134
|
if (result.warning !== undefined)
|
|
@@ -3130,9 +3141,379 @@ async function prepareSources(game, profileName, args, dataRoot, allowFetch) {
|
|
|
3130
3141
|
return { dirs, warnings, fetched, release };
|
|
3131
3142
|
}
|
|
3132
3143
|
|
|
3144
|
+
// src/mods/steamcmd.ts
|
|
3145
|
+
import { spawnSync as spawnSync2 } from "node:child_process";
|
|
3146
|
+
import { accessSync, constants, mkdirSync as mkdirSync3, statSync as statSync2 } from "node:fs";
|
|
3147
|
+
import { delimiter, join as join10 } from "node:path";
|
|
3148
|
+
import { setTimeout as sleep4 } from "node:timers/promises";
|
|
3149
|
+
var STEAMCMD_IMAGE = "steamcmd/steamcmd";
|
|
3150
|
+
function steamHome(dataRoot) {
|
|
3151
|
+
return join10(dataRoot, "steam");
|
|
3152
|
+
}
|
|
3153
|
+
function downloadRoot(dataRoot, game) {
|
|
3154
|
+
return join10(steamHome(dataRoot), "steamapps", "workshop", "content", String(game.steamAppId));
|
|
3155
|
+
}
|
|
3156
|
+
function resolveSteamcmd(config) {
|
|
3157
|
+
const home = steamHome(config.dataRoot);
|
|
3158
|
+
const configured = config.steamcmd?.path === undefined ? undefined : expandHome(config.steamcmd.path);
|
|
3159
|
+
if (configured !== undefined) {
|
|
3160
|
+
if (!executable(configured)) {
|
|
3161
|
+
throw new GamecrateError(`steamcmd.path is not an executable file: ${configured}`, Exit.Environment, "point steamcmd.path at the steamcmd binary, or remove it to use PATH or docker");
|
|
3162
|
+
}
|
|
3163
|
+
return { kind: "host", argv: [configured], env: { HOME: home } };
|
|
3164
|
+
}
|
|
3165
|
+
const found = onPath("steamcmd");
|
|
3166
|
+
if (found !== undefined)
|
|
3167
|
+
return { kind: "host", argv: [found], env: { HOME: home } };
|
|
3168
|
+
if (onPath("docker") !== undefined) {
|
|
3169
|
+
return {
|
|
3170
|
+
kind: "docker",
|
|
3171
|
+
argv: [
|
|
3172
|
+
"docker",
|
|
3173
|
+
"run",
|
|
3174
|
+
"--rm",
|
|
3175
|
+
"--user",
|
|
3176
|
+
`${process.getuid?.() ?? 0}:${process.getgid?.() ?? 0}`,
|
|
3177
|
+
"-v",
|
|
3178
|
+
`${home}:${home}`,
|
|
3179
|
+
"-e",
|
|
3180
|
+
`HOME=${home}`,
|
|
3181
|
+
STEAMCMD_IMAGE
|
|
3182
|
+
],
|
|
3183
|
+
env: {}
|
|
3184
|
+
};
|
|
3185
|
+
}
|
|
3186
|
+
throw new GamecrateError("steamcmd is not available", Exit.Environment, `steamcmd.path is unset, steamcmd is not on PATH, and docker is not there to run ${STEAMCMD_IMAGE}`);
|
|
3187
|
+
}
|
|
3188
|
+
function executable(path) {
|
|
3189
|
+
try {
|
|
3190
|
+
accessSync(path, constants.X_OK);
|
|
3191
|
+
return statSync2(path).isFile();
|
|
3192
|
+
} catch {
|
|
3193
|
+
return false;
|
|
3194
|
+
}
|
|
3195
|
+
}
|
|
3196
|
+
function onPath(name) {
|
|
3197
|
+
for (const dir of (process.env.PATH ?? "").split(delimiter)) {
|
|
3198
|
+
if (dir.length === 0)
|
|
3199
|
+
continue;
|
|
3200
|
+
const candidate = join10(dir, name);
|
|
3201
|
+
if (executable(candidate))
|
|
3202
|
+
return candidate;
|
|
3203
|
+
}
|
|
3204
|
+
return;
|
|
3205
|
+
}
|
|
3206
|
+
function workshopUrlId(url) {
|
|
3207
|
+
if (url === undefined)
|
|
3208
|
+
return;
|
|
3209
|
+
const client = /^steam:\/\/url\/CommunityFilePage\/(\d+)$/i.exec(url.trim());
|
|
3210
|
+
if (client !== null)
|
|
3211
|
+
return client[1];
|
|
3212
|
+
let parsed;
|
|
3213
|
+
try {
|
|
3214
|
+
parsed = new URL(url);
|
|
3215
|
+
} catch {
|
|
3216
|
+
return;
|
|
3217
|
+
}
|
|
3218
|
+
if (!/(^|\.)steamcommunity\.com$/.test(parsed.hostname.toLowerCase()))
|
|
3219
|
+
return;
|
|
3220
|
+
const id = parsed.searchParams.get("id");
|
|
3221
|
+
return id !== null && /^\d+$/.test(id) ? id : undefined;
|
|
3222
|
+
}
|
|
3223
|
+
var ANSI = new RegExp(`${String.fromCharCode(27)}\\[[0-9;?]*[ -/]*[@-~]`, "g");
|
|
3224
|
+
var SUCCESS = /Success\. Downloaded item (\d+) to "([^"]+)" \((\d+) bytes\)/g;
|
|
3225
|
+
var FAILED = /ERROR! Download item (\d+) failed \(([^)]+)\)/g;
|
|
3226
|
+
var ATTEMPTS = 2;
|
|
3227
|
+
var RETRY_DELAY_MS = 1000;
|
|
3228
|
+
async function downloadItems(config, game, dataRoot, ids) {
|
|
3229
|
+
const items = new Map;
|
|
3230
|
+
const warnings = [];
|
|
3231
|
+
if (ids.length === 0)
|
|
3232
|
+
return { items, warnings };
|
|
3233
|
+
const runner = resolveSteamcmd(config);
|
|
3234
|
+
mkdirSync3(steamHome(dataRoot), { recursive: true });
|
|
3235
|
+
const reasons = new Map;
|
|
3236
|
+
const release = await lockDir(steamHome(dataRoot));
|
|
3237
|
+
try {
|
|
3238
|
+
let pending = ids;
|
|
3239
|
+
for (let attempt = 0;attempt < ATTEMPTS && pending.length > 0; attempt++) {
|
|
3240
|
+
const output = run(runner, game, dataRoot, pending);
|
|
3241
|
+
for (const m of output.matchAll(SUCCESS)) {
|
|
3242
|
+
items.set(m[1], { ok: true, dir: m[2], bytes: Number(m[3]) });
|
|
3243
|
+
}
|
|
3244
|
+
for (const m of output.matchAll(FAILED))
|
|
3245
|
+
reasons.set(m[1], m[2]);
|
|
3246
|
+
pending = pending.filter((id) => !items.has(id));
|
|
3247
|
+
if (pending.length > 0 && attempt + 1 < ATTEMPTS)
|
|
3248
|
+
await sleep4(RETRY_DELAY_MS);
|
|
3249
|
+
}
|
|
3250
|
+
} finally {
|
|
3251
|
+
await release();
|
|
3252
|
+
}
|
|
3253
|
+
for (const id of ids) {
|
|
3254
|
+
if (items.has(id))
|
|
3255
|
+
continue;
|
|
3256
|
+
const reason = reasons.get(id) ?? "steamcmd reported nothing for it";
|
|
3257
|
+
items.set(id, { ok: false, reason });
|
|
3258
|
+
warnings.push(`could not download workshop item ${id}: ${reason}`);
|
|
3259
|
+
}
|
|
3260
|
+
return { items, warnings };
|
|
3261
|
+
}
|
|
3262
|
+
function run(runner, game, dataRoot, ids) {
|
|
3263
|
+
const argv = [
|
|
3264
|
+
...runner.argv,
|
|
3265
|
+
"+force_install_dir",
|
|
3266
|
+
steamHome(dataRoot),
|
|
3267
|
+
"+login",
|
|
3268
|
+
"anonymous",
|
|
3269
|
+
...ids.flatMap((id) => ["+workshop_download_item", String(game.steamAppId), id]),
|
|
3270
|
+
"+quit"
|
|
3271
|
+
];
|
|
3272
|
+
const r = spawnSync2(argv[0], argv.slice(1), {
|
|
3273
|
+
encoding: "utf8",
|
|
3274
|
+
env: { ...process.env, ...runner.env },
|
|
3275
|
+
maxBuffer: 64 * 1024 * 1024
|
|
3276
|
+
});
|
|
3277
|
+
if (r.error) {
|
|
3278
|
+
throw new GamecrateError(`could not run steamcmd: ${argv[0]}`, Exit.Environment, r.error.message);
|
|
3279
|
+
}
|
|
3280
|
+
return `${r.stdout ?? ""}
|
|
3281
|
+
${r.stderr ?? ""}`.replace(ANSI, "");
|
|
3282
|
+
}
|
|
3283
|
+
|
|
3284
|
+
// src/mods/workshopapi.ts
|
|
3285
|
+
import { readFile as readFile6, stat as stat4 } from "node:fs/promises";
|
|
3286
|
+
import { basename as basename5, dirname as dirname5, join as join11 } from "node:path";
|
|
3287
|
+
|
|
3288
|
+
// src/mods/acf.ts
|
|
3289
|
+
var MAX_DEPTH = 100;
|
|
3290
|
+
function isSpace(c) {
|
|
3291
|
+
return c === " " || c === "\t" || c === "\r" || c === `
|
|
3292
|
+
`;
|
|
3293
|
+
}
|
|
3294
|
+
function skip(text, start) {
|
|
3295
|
+
let i = start;
|
|
3296
|
+
for (;; ) {
|
|
3297
|
+
while (i < text.length && isSpace(text[i]))
|
|
3298
|
+
i += 1;
|
|
3299
|
+
if (!text.startsWith("//", i))
|
|
3300
|
+
return i;
|
|
3301
|
+
const nl = text.indexOf(`
|
|
3302
|
+
`, i);
|
|
3303
|
+
if (nl === -1)
|
|
3304
|
+
return text.length;
|
|
3305
|
+
i = nl + 1;
|
|
3306
|
+
}
|
|
3307
|
+
}
|
|
3308
|
+
function quoted(text, start) {
|
|
3309
|
+
let out = "";
|
|
3310
|
+
let i = start + 1;
|
|
3311
|
+
while (i < text.length) {
|
|
3312
|
+
const c = text[i];
|
|
3313
|
+
if (c === "\\") {
|
|
3314
|
+
if (i + 1 >= text.length)
|
|
3315
|
+
return null;
|
|
3316
|
+
out += text[i + 1];
|
|
3317
|
+
i += 2;
|
|
3318
|
+
continue;
|
|
3319
|
+
}
|
|
3320
|
+
if (c === '"')
|
|
3321
|
+
return { value: out, next: i + 1 };
|
|
3322
|
+
out += c;
|
|
3323
|
+
i += 1;
|
|
3324
|
+
}
|
|
3325
|
+
return null;
|
|
3326
|
+
}
|
|
3327
|
+
function body(text, start, depth) {
|
|
3328
|
+
if (depth > MAX_DEPTH)
|
|
3329
|
+
return null;
|
|
3330
|
+
const node = {};
|
|
3331
|
+
let i = start;
|
|
3332
|
+
for (;; ) {
|
|
3333
|
+
i = skip(text, i);
|
|
3334
|
+
if (i >= text.length)
|
|
3335
|
+
return depth === 0 ? { node, next: i } : null;
|
|
3336
|
+
if (text[i] === "}")
|
|
3337
|
+
return depth === 0 ? null : { node, next: i + 1 };
|
|
3338
|
+
if (text[i] !== '"')
|
|
3339
|
+
return null;
|
|
3340
|
+
const key = quoted(text, i);
|
|
3341
|
+
if (key === null)
|
|
3342
|
+
return null;
|
|
3343
|
+
i = skip(text, key.next);
|
|
3344
|
+
if (i >= text.length)
|
|
3345
|
+
return null;
|
|
3346
|
+
if (text[i] === "{") {
|
|
3347
|
+
const child = body(text, i + 1, depth + 1);
|
|
3348
|
+
if (child === null)
|
|
3349
|
+
return null;
|
|
3350
|
+
node[key.value] = child.node;
|
|
3351
|
+
i = child.next;
|
|
3352
|
+
continue;
|
|
3353
|
+
}
|
|
3354
|
+
if (text[i] !== '"')
|
|
3355
|
+
return null;
|
|
3356
|
+
const value = quoted(text, i);
|
|
3357
|
+
if (value === null)
|
|
3358
|
+
return null;
|
|
3359
|
+
node[key.value] = value.value;
|
|
3360
|
+
i = value.next;
|
|
3361
|
+
}
|
|
3362
|
+
}
|
|
3363
|
+
function parseAcf(text) {
|
|
3364
|
+
return body(text, 0, 0)?.node ?? {};
|
|
3365
|
+
}
|
|
3366
|
+
function section(root, name) {
|
|
3367
|
+
const direct = root[name];
|
|
3368
|
+
if (typeof direct === "object")
|
|
3369
|
+
return direct;
|
|
3370
|
+
for (const child of Object.values(root)) {
|
|
3371
|
+
if (typeof child === "object" && typeof child[name] === "object")
|
|
3372
|
+
return child[name];
|
|
3373
|
+
}
|
|
3374
|
+
return;
|
|
3375
|
+
}
|
|
3376
|
+
function installedItems(text) {
|
|
3377
|
+
const out = new Map;
|
|
3378
|
+
const items = section(parseAcf(text), "WorkshopItemsInstalled");
|
|
3379
|
+
if (items === undefined)
|
|
3380
|
+
return out;
|
|
3381
|
+
for (const [id, entry] of Object.entries(items)) {
|
|
3382
|
+
if (typeof entry !== "object")
|
|
3383
|
+
continue;
|
|
3384
|
+
const manifest = entry["manifest"];
|
|
3385
|
+
if (typeof manifest !== "string" || manifest === "")
|
|
3386
|
+
continue;
|
|
3387
|
+
out.set(id, { manifest, timeupdated: Number(entry["timeupdated"]) || 0 });
|
|
3388
|
+
}
|
|
3389
|
+
return out;
|
|
3390
|
+
}
|
|
3391
|
+
|
|
3392
|
+
// src/mods/workshopapi.ts
|
|
3393
|
+
var ENDPOINT = "https://api.steampowered.com/ISteamRemoteStorage/GetPublishedFileDetails/v1/";
|
|
3394
|
+
var CHUNK = 100;
|
|
3395
|
+
var TIMEOUT_MS = 5000;
|
|
3396
|
+
async function checkDrift(ids, roots, fetchImpl = fetch) {
|
|
3397
|
+
const wanted = [...new Set(ids)];
|
|
3398
|
+
const { loaded, listed } = await installs(wanted, roots);
|
|
3399
|
+
const missing = [];
|
|
3400
|
+
for (const id of wanted) {
|
|
3401
|
+
if (!listed.has(id) || !await onDisk(roots, id))
|
|
3402
|
+
missing.push(id);
|
|
3403
|
+
}
|
|
3404
|
+
let details;
|
|
3405
|
+
try {
|
|
3406
|
+
details = await published(wanted, fetchImpl);
|
|
3407
|
+
} catch (error) {
|
|
3408
|
+
return {
|
|
3409
|
+
needed: missing,
|
|
3410
|
+
unavailable: [],
|
|
3411
|
+
warnings: [`could not ask steam which items changed (${reason(error)}); only missing items will download`]
|
|
3412
|
+
};
|
|
3413
|
+
}
|
|
3414
|
+
const needed = new Set(missing);
|
|
3415
|
+
const unavailable = [];
|
|
3416
|
+
const warnings = [];
|
|
3417
|
+
for (const id of wanted) {
|
|
3418
|
+
const detail = details.get(id);
|
|
3419
|
+
if (detail === undefined)
|
|
3420
|
+
continue;
|
|
3421
|
+
if (detail.result !== 1) {
|
|
3422
|
+
warnings.push(`workshop item ${id} is not available (result ${detail.result}); skipping it`);
|
|
3423
|
+
needed.delete(id);
|
|
3424
|
+
unavailable.push(id);
|
|
3425
|
+
continue;
|
|
3426
|
+
}
|
|
3427
|
+
const local = loaded.get(id);
|
|
3428
|
+
if (local !== undefined && detail.timeUpdated > local)
|
|
3429
|
+
needed.add(id);
|
|
3430
|
+
}
|
|
3431
|
+
return { needed: [...needed], unavailable, warnings };
|
|
3432
|
+
}
|
|
3433
|
+
async function installs(ids, roots) {
|
|
3434
|
+
const loaded = new Map;
|
|
3435
|
+
const listed = new Set;
|
|
3436
|
+
for (const root of roots) {
|
|
3437
|
+
const items = installedItems(await readText(acfPath(root)));
|
|
3438
|
+
for (const id of ids) {
|
|
3439
|
+
const item = items.get(id);
|
|
3440
|
+
if (item === undefined)
|
|
3441
|
+
continue;
|
|
3442
|
+
listed.add(id);
|
|
3443
|
+
if (!loaded.has(id) && await exists(join11(root, id)))
|
|
3444
|
+
loaded.set(id, item.timeupdated);
|
|
3445
|
+
}
|
|
3446
|
+
}
|
|
3447
|
+
return { loaded, listed };
|
|
3448
|
+
}
|
|
3449
|
+
async function onDisk(roots, id) {
|
|
3450
|
+
for (const root of roots) {
|
|
3451
|
+
if (await exists(join11(root, id)))
|
|
3452
|
+
return true;
|
|
3453
|
+
}
|
|
3454
|
+
return false;
|
|
3455
|
+
}
|
|
3456
|
+
async function published(ids, fetchImpl) {
|
|
3457
|
+
const out = new Map;
|
|
3458
|
+
if (ids.length === 0)
|
|
3459
|
+
return out;
|
|
3460
|
+
const abort = new AbortController;
|
|
3461
|
+
const timer = setTimeout(() => abort.abort(new Error(`no answer in ${TIMEOUT_MS}ms`)), TIMEOUT_MS);
|
|
3462
|
+
try {
|
|
3463
|
+
for (let i = 0;i < ids.length; i += CHUNK) {
|
|
3464
|
+
const chunk = ids.slice(i, i + CHUNK);
|
|
3465
|
+
const body = new URLSearchParams({ itemcount: String(chunk.length) });
|
|
3466
|
+
chunk.forEach((id, n) => body.set(`publishedfileids[${n}]`, id));
|
|
3467
|
+
const response = await fetchImpl(ENDPOINT, { method: "POST", body, signal: abort.signal });
|
|
3468
|
+
if (!response.ok)
|
|
3469
|
+
throw new Error(`steam answered ${response.status}`);
|
|
3470
|
+
for (const [id, detail] of readDetails(await response.json()))
|
|
3471
|
+
out.set(id, detail);
|
|
3472
|
+
}
|
|
3473
|
+
} finally {
|
|
3474
|
+
clearTimeout(timer);
|
|
3475
|
+
}
|
|
3476
|
+
return out;
|
|
3477
|
+
}
|
|
3478
|
+
function readDetails(payload) {
|
|
3479
|
+
const list = payload?.response?.publishedfiledetails;
|
|
3480
|
+
if (!Array.isArray(list))
|
|
3481
|
+
throw new Error("steam returned a body this does not understand");
|
|
3482
|
+
const out = new Map;
|
|
3483
|
+
for (const entry of list) {
|
|
3484
|
+
const id = entry["publishedfileid"];
|
|
3485
|
+
const result = entry["result"];
|
|
3486
|
+
if (typeof id !== "string" || typeof result !== "number") {
|
|
3487
|
+
throw new Error("steam returned a body this does not understand");
|
|
3488
|
+
}
|
|
3489
|
+
out.set(id, { result, timeUpdated: Number(entry["time_updated"]) || 0 });
|
|
3490
|
+
}
|
|
3491
|
+
return out;
|
|
3492
|
+
}
|
|
3493
|
+
function acfPath(root) {
|
|
3494
|
+
return join11(dirname5(dirname5(root)), `appworkshop_${basename5(root)}.acf`);
|
|
3495
|
+
}
|
|
3496
|
+
async function exists(dir) {
|
|
3497
|
+
try {
|
|
3498
|
+
return (await stat4(dir)).isDirectory();
|
|
3499
|
+
} catch {
|
|
3500
|
+
return false;
|
|
3501
|
+
}
|
|
3502
|
+
}
|
|
3503
|
+
async function readText(path) {
|
|
3504
|
+
try {
|
|
3505
|
+
return await readFile6(path, "utf8");
|
|
3506
|
+
} catch {
|
|
3507
|
+
return "";
|
|
3508
|
+
}
|
|
3509
|
+
}
|
|
3510
|
+
function reason(error) {
|
|
3511
|
+
return error instanceof Error ? error.message : String(error);
|
|
3512
|
+
}
|
|
3513
|
+
|
|
3133
3514
|
// src/cli/mods.ts
|
|
3134
3515
|
async function globalConfigPath() {
|
|
3135
|
-
return await findGlobalConfig() ??
|
|
3516
|
+
return await findGlobalConfig() ?? join12(globalConfigDir(), "profiles.yml");
|
|
3136
3517
|
}
|
|
3137
3518
|
async function modsAdd(args, ctx) {
|
|
3138
3519
|
const game = requireGame(args, ctx.config);
|
|
@@ -3141,9 +3522,6 @@ async function modsAdd(args, ctx) {
|
|
|
3141
3522
|
if (source === undefined) {
|
|
3142
3523
|
throw new GamecrateError("mods add needs one of --path, --workshop or --git", Exit.Usage);
|
|
3143
3524
|
}
|
|
3144
|
-
if (source.kind === "workshop" && gameConfig.workshopRoot === null) {
|
|
3145
|
-
throw new GamecrateError(`games.${game}.workshopRoot is null, so workshop item ${source.value} cannot resolve`, Exit.Config, `set games.${game}.workshopRoot to the workshop directory for app ${gameConfig.steamAppId}`);
|
|
3146
|
-
}
|
|
3147
3525
|
const target = await resolveTarget(args, ctx, game);
|
|
3148
3526
|
const pins = await discover(source, gameConfig, requirePlugin(ctx.plugins, game), ctx);
|
|
3149
3527
|
if (pins.length === 0) {
|
|
@@ -3176,7 +3554,7 @@ overwrite them with --force`);
|
|
|
3176
3554
|
edits.push({ path: [...target.prefix, pin.id], value: pin.entry });
|
|
3177
3555
|
}
|
|
3178
3556
|
if (!existsSync5(target.file)) {
|
|
3179
|
-
await mkdir2(
|
|
3557
|
+
await mkdir2(dirname6(target.file), { recursive: true });
|
|
3180
3558
|
await writeFile3(target.file, "");
|
|
3181
3559
|
}
|
|
3182
3560
|
await writeConfig(target.file, edits);
|
|
@@ -3205,21 +3583,27 @@ async function modsSync(args, ctx) {
|
|
|
3205
3583
|
const only = args.rest.map((id) => id.toLowerCase());
|
|
3206
3584
|
const games = args.game === undefined ? Object.keys(ctx.config.games) : [requireGame(args, ctx.config)];
|
|
3207
3585
|
const wanted = [];
|
|
3586
|
+
const subscribed = [];
|
|
3208
3587
|
for (const name of games) {
|
|
3588
|
+
const pins = [];
|
|
3209
3589
|
for (const [id, entry] of Object.entries(ctx.config.games[name]?.library ?? {})) {
|
|
3210
|
-
if (entry.git === undefined)
|
|
3211
|
-
continue;
|
|
3212
3590
|
if (only.length > 0 && !only.includes(id.toLowerCase()))
|
|
3213
3591
|
continue;
|
|
3214
|
-
|
|
3592
|
+
if (entry.git !== undefined)
|
|
3593
|
+
wanted.push({ id, git: entry.git, entry });
|
|
3594
|
+
else if (entry.workshop !== undefined)
|
|
3595
|
+
pins.push({ id, item: String(entry.workshop) });
|
|
3215
3596
|
}
|
|
3597
|
+
if (pins.length > 0)
|
|
3598
|
+
subscribed.push({ game: name, pins });
|
|
3216
3599
|
}
|
|
3217
|
-
const
|
|
3600
|
+
const named = [...wanted.map((pin) => pin.id), ...subscribed.flatMap((one) => one.pins.map((pin) => pin.id))];
|
|
3601
|
+
const missing = only.filter((id) => !named.some((pinned) => pinned.toLowerCase() === id));
|
|
3218
3602
|
if (missing.length > 0) {
|
|
3219
|
-
throw new GamecrateError(`${missing.length} mod id(s) are not git-pinned in the library`, Exit.Resolution, missing.map((id) => ` ${id}`).join(`
|
|
3603
|
+
throw new GamecrateError(`${missing.length} mod id(s) are not git- or workshop-pinned in the library`, Exit.Resolution, missing.map((id) => ` ${id}`).join(`
|
|
3220
3604
|
`));
|
|
3221
3605
|
}
|
|
3222
|
-
if (wanted.length === 0) {
|
|
3606
|
+
if (wanted.length === 0 && subscribed.length === 0) {
|
|
3223
3607
|
status("nothing to sync");
|
|
3224
3608
|
return Exit.Ok;
|
|
3225
3609
|
}
|
|
@@ -3235,7 +3619,7 @@ async function modsSync(args, ctx) {
|
|
|
3235
3619
|
const dir = cloneDir(ctx.config.dataRoot, pin.git, ref);
|
|
3236
3620
|
if (!fetched.has(dir)) {
|
|
3237
3621
|
fetched.add(dir);
|
|
3238
|
-
const unlock = await
|
|
3622
|
+
const unlock = await lockDir(dir);
|
|
3239
3623
|
try {
|
|
3240
3624
|
const result = await ensureClone(ctx.config.dataRoot, gitPin(pin.git, pin.entry), ref, "force");
|
|
3241
3625
|
if (result.warning !== undefined)
|
|
@@ -3246,8 +3630,29 @@ async function modsSync(args, ctx) {
|
|
|
3246
3630
|
}
|
|
3247
3631
|
status(`synced ${pin.id} at ${ref.kind} ${ref.value}`);
|
|
3248
3632
|
}
|
|
3633
|
+
for (const one of subscribed)
|
|
3634
|
+
await syncWorkshop(ctx, one.game, one.pins);
|
|
3249
3635
|
return Exit.Ok;
|
|
3250
3636
|
}
|
|
3637
|
+
async function syncWorkshop(ctx, name, pins) {
|
|
3638
|
+
const game = ctx.config.games[name];
|
|
3639
|
+
const drift = await checkDrift(pins.map((pin) => pin.item), [downloadRoot(ctx.config.dataRoot, game)], ctx.fetch);
|
|
3640
|
+
for (const line of drift.warnings)
|
|
3641
|
+
warn(line);
|
|
3642
|
+
const report = drift.needed.length === 0 ? undefined : await downloadItems(ctx.config, game, ctx.config.dataRoot, drift.needed);
|
|
3643
|
+
for (const line of report?.warnings ?? [])
|
|
3644
|
+
warn(line);
|
|
3645
|
+
const unavailable = new Set(drift.unavailable);
|
|
3646
|
+
for (const pin of pins) {
|
|
3647
|
+
const outcome = report?.items.get(pin.item);
|
|
3648
|
+
if (unavailable.has(pin.item))
|
|
3649
|
+
status(`${pin.id} is unavailable at workshop item ${pin.item}`);
|
|
3650
|
+
else if (outcome === undefined)
|
|
3651
|
+
status(`${pin.id} is up to date at workshop item ${pin.item}`);
|
|
3652
|
+
else if (outcome.ok)
|
|
3653
|
+
status(`synced ${pin.id} at workshop item ${pin.item}`);
|
|
3654
|
+
}
|
|
3655
|
+
}
|
|
3251
3656
|
function gitPin(url, entry) {
|
|
3252
3657
|
return { url, ...entry.subdir === undefined ? {} : { subdir: entry.subdir } };
|
|
3253
3658
|
}
|
|
@@ -3326,13 +3731,18 @@ async function discover(source, game, plugin, ctx) {
|
|
|
3326
3731
|
return id === undefined ? [] : [{ id, entry: { path: dir } }];
|
|
3327
3732
|
}
|
|
3328
3733
|
if (source.kind === "workshop") {
|
|
3329
|
-
const
|
|
3330
|
-
const
|
|
3734
|
+
const item = String(source.value);
|
|
3735
|
+
const report = await downloadItems(ctx.config, game, ctx.config.dataRoot, [item]);
|
|
3736
|
+
const outcome = report.items.get(item);
|
|
3737
|
+
if (outcome?.ok !== true) {
|
|
3738
|
+
throw new GamecrateError(`could not download workshop item ${item}: ${outcome?.reason ?? "steamcmd reported nothing for it"}`, Exit.Resolution, `check that ${item} is still published and public at https://steamcommunity.com/sharedfiles/filedetails/?id=${item}`);
|
|
3739
|
+
}
|
|
3740
|
+
const id = await readId(outcome.dir, game.manifest.file, plugin);
|
|
3331
3741
|
return id === undefined ? [] : [{ id, entry: { workshop: source.value } }];
|
|
3332
3742
|
}
|
|
3333
3743
|
const ref = source.ref ?? defaultBranch(source.url);
|
|
3334
3744
|
const dir = cloneDir(ctx.config.dataRoot, source.url, ref);
|
|
3335
|
-
const unlock = await
|
|
3745
|
+
const unlock = await lockDir(dir);
|
|
3336
3746
|
let root;
|
|
3337
3747
|
try {
|
|
3338
3748
|
const result = await ensureClone(ctx.config.dataRoot, { url: source.url }, ref, "fetch");
|
|
@@ -3342,7 +3752,7 @@ async function discover(source, game, plugin, ctx) {
|
|
|
3342
3752
|
} finally {
|
|
3343
3753
|
await unlock();
|
|
3344
3754
|
}
|
|
3345
|
-
const start = source.subdir === undefined ? root :
|
|
3755
|
+
const start = source.subdir === undefined ? root : join12(root, source.subdir);
|
|
3346
3756
|
const found = await walk(start, game.manifest.file, plugin);
|
|
3347
3757
|
return found.map(({ id, dir: at }) => {
|
|
3348
3758
|
const entry = { git: source.url };
|
|
@@ -3370,14 +3780,14 @@ async function walk(root, manifestFile, plugin) {
|
|
|
3370
3780
|
const entries = await readdir5(dir, { withFileTypes: true }).catch(() => []);
|
|
3371
3781
|
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
3372
3782
|
if (entry.isDirectory() && !entry.name.startsWith("."))
|
|
3373
|
-
await descend(
|
|
3783
|
+
await descend(join12(dir, entry.name));
|
|
3374
3784
|
}
|
|
3375
3785
|
};
|
|
3376
3786
|
await descend(root);
|
|
3377
3787
|
return out;
|
|
3378
3788
|
}
|
|
3379
3789
|
async function readId(dir, manifestFile, plugin) {
|
|
3380
|
-
const text = await
|
|
3790
|
+
const text = await readFile7(join12(dir, manifestFile), "utf8").catch(() => {
|
|
3381
3791
|
return;
|
|
3382
3792
|
});
|
|
3383
3793
|
if (text === undefined)
|
|
@@ -3587,7 +3997,7 @@ function hostUserName(uid) {
|
|
|
3587
3997
|
// src/docker/preflight.ts
|
|
3588
3998
|
import { existsSync as existsSync6, readFileSync as readFileSync3 } from "node:fs";
|
|
3589
3999
|
import { homedir as homedir3 } from "node:os";
|
|
3590
|
-
import { basename as
|
|
4000
|
+
import { basename as basename6, join as join13 } from "node:path";
|
|
3591
4001
|
var CDI_SPEC = "/etc/cdi/nvidia.yaml";
|
|
3592
4002
|
async function preflight(plan) {
|
|
3593
4003
|
const problems = [];
|
|
@@ -3723,11 +4133,11 @@ function checkGameDir(plan, problems) {
|
|
|
3723
4133
|
problems.push({ where, message: `game directory does not exist: ${files.host}` });
|
|
3724
4134
|
return;
|
|
3725
4135
|
}
|
|
3726
|
-
const executable =
|
|
4136
|
+
const executable = join13(files.host, basename6(plan.gameConfig.executable));
|
|
3727
4137
|
if (!existsSync6(executable)) {
|
|
3728
4138
|
problems.push({
|
|
3729
4139
|
where,
|
|
3730
|
-
message: `${files.host} does not contain ${
|
|
4140
|
+
message: `${files.host} does not contain ${basename6(plan.gameConfig.executable)}`,
|
|
3731
4141
|
suggestion: "point gameFiles.host at the install directory, not its parent"
|
|
3732
4142
|
});
|
|
3733
4143
|
}
|
|
@@ -3769,7 +4179,7 @@ function needsLogin(stderr) {
|
|
|
3769
4179
|
return /unauthorized|authentication required|denied|forbidden/i.test(stderr);
|
|
3770
4180
|
}
|
|
3771
4181
|
function hasStoredAuth(host) {
|
|
3772
|
-
const path =
|
|
4182
|
+
const path = join13(process.env.DOCKER_CONFIG ?? join13(homedir3(), ".docker"), "config.json");
|
|
3773
4183
|
try {
|
|
3774
4184
|
const config = JSON.parse(readFileSync3(path, "utf8"));
|
|
3775
4185
|
if (config.credsStore || config.credHelpers?.[host])
|
|
@@ -3789,12 +4199,12 @@ function message(error) {
|
|
|
3789
4199
|
|
|
3790
4200
|
// src/docker/window.ts
|
|
3791
4201
|
import { readFileSync as readFileSync4 } from "node:fs";
|
|
3792
|
-
import { basename as
|
|
3793
|
-
import { setTimeout as
|
|
4202
|
+
import { basename as basename7 } from "node:path";
|
|
4203
|
+
import { setTimeout as sleep5 } from "node:timers/promises";
|
|
3794
4204
|
var WAIT_MS = 180000;
|
|
3795
4205
|
var POLL_MS = 500;
|
|
3796
4206
|
function newMatches(now, seen, executable) {
|
|
3797
|
-
const wanted =
|
|
4207
|
+
const wanted = basename7(executable).toLowerCase();
|
|
3798
4208
|
return now.filter((w) => !seen.has(w.id) && w.wmClass.toLowerCase().includes(wanted));
|
|
3799
4209
|
}
|
|
3800
4210
|
function parseWindowPid(stdout) {
|
|
@@ -3807,7 +4217,7 @@ function isPeerClaim(pid, self) {
|
|
|
3807
4217
|
return false;
|
|
3808
4218
|
try {
|
|
3809
4219
|
const argv = readFileSync4(`/proc/${pid}/cmdline`, "utf8").split("\x00");
|
|
3810
|
-
return argv.some((arg) =>
|
|
4220
|
+
return argv.some((arg) => basename7(arg).startsWith("gamecrate"));
|
|
3811
4221
|
} catch {
|
|
3812
4222
|
return false;
|
|
3813
4223
|
}
|
|
@@ -3841,7 +4251,7 @@ async function adoptNewWindow(opts) {
|
|
|
3841
4251
|
(async () => {
|
|
3842
4252
|
const deadline = Date.now() + WAIT_MS;
|
|
3843
4253
|
while (!stopped && Date.now() < deadline) {
|
|
3844
|
-
await
|
|
4254
|
+
await sleep5(POLL_MS);
|
|
3845
4255
|
if (stopped)
|
|
3846
4256
|
return;
|
|
3847
4257
|
for (const match of newMatches(await toplevels() ?? [], seen, opts.executable)) {
|
|
@@ -3863,7 +4273,7 @@ async function adoptNewWindow(opts) {
|
|
|
3863
4273
|
async function watchForClose(id, stopped, onClosed) {
|
|
3864
4274
|
let misses = 0;
|
|
3865
4275
|
while (!stopped()) {
|
|
3866
|
-
await
|
|
4276
|
+
await sleep5(POLL_MS);
|
|
3867
4277
|
if (stopped())
|
|
3868
4278
|
return;
|
|
3869
4279
|
const now = await toplevels();
|
|
@@ -3921,15 +4331,15 @@ function parseAtoms(stdout) {
|
|
|
3921
4331
|
}
|
|
3922
4332
|
|
|
3923
4333
|
// src/launch/generate.ts
|
|
3924
|
-
import { mkdir as mkdir3, readdir as readdir6, readFile as
|
|
3925
|
-
import { dirname as
|
|
4334
|
+
import { mkdir as mkdir3, readdir as readdir6, readFile as readFile8, writeFile as writeFile4 } from "node:fs/promises";
|
|
4335
|
+
import { dirname as dirname7, join as join14 } from "node:path";
|
|
3926
4336
|
async function readInstallVersion(game, plugin) {
|
|
3927
4337
|
const host = game.gameFiles.host;
|
|
3928
4338
|
if (game.gameFiles.source !== "mount" || host === undefined)
|
|
3929
4339
|
return null;
|
|
3930
4340
|
let raw;
|
|
3931
4341
|
try {
|
|
3932
|
-
raw = await
|
|
4342
|
+
raw = await readFile8(join14(expandHome(host), "Version.txt"), "utf8");
|
|
3933
4343
|
} catch {
|
|
3934
4344
|
return null;
|
|
3935
4345
|
}
|
|
@@ -3941,7 +4351,7 @@ async function readKnownExpansions(game, plugin, warnings) {
|
|
|
3941
4351
|
const host = game.gameFiles.host;
|
|
3942
4352
|
if (game.gameFiles.source !== "mount" || host === undefined)
|
|
3943
4353
|
return [...game.dlc];
|
|
3944
|
-
const dataDir =
|
|
4354
|
+
const dataDir = join14(expandHome(host), "Data");
|
|
3945
4355
|
let entries;
|
|
3946
4356
|
try {
|
|
3947
4357
|
entries = await readdir6(dataDir, { withFileTypes: true });
|
|
@@ -3955,7 +4365,7 @@ async function readKnownExpansions(game, plugin, warnings) {
|
|
|
3955
4365
|
continue;
|
|
3956
4366
|
let id = null;
|
|
3957
4367
|
try {
|
|
3958
|
-
id = plugin.parseManifest(await
|
|
4368
|
+
id = plugin.parseManifest(await readFile8(join14(dataDir, entry.name, game.manifest.file), "utf8"))?.packageId ?? null;
|
|
3959
4369
|
} catch {
|
|
3960
4370
|
continue;
|
|
3961
4371
|
}
|
|
@@ -3971,8 +4381,8 @@ async function readKnownExpansions(game, plugin, warnings) {
|
|
|
3971
4381
|
}
|
|
3972
4382
|
async function generateModsConfig(plan) {
|
|
3973
4383
|
const game = plan.gameConfig;
|
|
3974
|
-
const target =
|
|
3975
|
-
await mkdir3(
|
|
4384
|
+
const target = join14(plan.dataDirHost, game.modsConfig.file);
|
|
4385
|
+
await mkdir3(dirname7(target), { recursive: true });
|
|
3976
4386
|
const installed = await readInstallVersion(game, plan.plugin);
|
|
3977
4387
|
if (installed === null) {
|
|
3978
4388
|
plan.warnings.push(`could not read Version.txt for ${plan.game}; ModsConfig version may be rejected`);
|
|
@@ -4022,11 +4432,11 @@ function ownedPrefs(plan) {
|
|
|
4022
4432
|
return owned;
|
|
4023
4433
|
}
|
|
4024
4434
|
async function mergePrefs(plan) {
|
|
4025
|
-
const target =
|
|
4026
|
-
await mkdir3(
|
|
4435
|
+
const target = join14(plan.dataDirHost, plan.gameConfig.prefs.file);
|
|
4436
|
+
await mkdir3(dirname7(target), { recursive: true });
|
|
4027
4437
|
let existing = null;
|
|
4028
4438
|
try {
|
|
4029
|
-
existing = await
|
|
4439
|
+
existing = await readFile8(target, "utf8");
|
|
4030
4440
|
} catch (error) {
|
|
4031
4441
|
if (error.code !== "ENOENT")
|
|
4032
4442
|
throw error;
|
|
@@ -4037,9 +4447,9 @@ async function mergePrefs(plan) {
|
|
|
4037
4447
|
|
|
4038
4448
|
// src/launch/supervisor.ts
|
|
4039
4449
|
import { existsSync as existsSync7 } from "node:fs";
|
|
4040
|
-
import { readFile as
|
|
4041
|
-
import { basename as
|
|
4042
|
-
import { setTimeout as
|
|
4450
|
+
import { readFile as readFile9, readlink, rm as rm2, writeFile as writeFile5 } from "node:fs/promises";
|
|
4451
|
+
import { basename as basename8, join as join15 } from "node:path";
|
|
4452
|
+
import { setTimeout as sleep6 } from "node:timers/promises";
|
|
4043
4453
|
async function forkSupervisor(plan, argv) {
|
|
4044
4454
|
await clearLock(plan);
|
|
4045
4455
|
const name = containerName(plan);
|
|
@@ -4096,15 +4506,15 @@ async function supervisorFailed(dir, code) {
|
|
|
4096
4506
|
const record = { at: new Date().toISOString(), code, reason: "failed" };
|
|
4097
4507
|
const wrote = await writeExit(dir, record).then(() => true, () => false);
|
|
4098
4508
|
if (wrote)
|
|
4099
|
-
await rm2(
|
|
4509
|
+
await rm2(join15(dir, ".gamecrate", "lock"), { force: true }).catch(() => {});
|
|
4100
4510
|
return code;
|
|
4101
4511
|
}
|
|
4102
4512
|
async function writeExit(instanceDir, record) {
|
|
4103
|
-
await writeFile5(
|
|
4513
|
+
await writeFile5(join15(instanceDir, ".gamecrate", "last-exit.json"), `${JSON.stringify(record)}
|
|
4104
4514
|
`);
|
|
4105
4515
|
}
|
|
4106
4516
|
async function lastExit(instanceDir) {
|
|
4107
|
-
const text = await
|
|
4517
|
+
const text = await readFile9(join15(instanceDir, ".gamecrate", "last-exit.json"), "utf8").catch(() => {
|
|
4108
4518
|
return;
|
|
4109
4519
|
});
|
|
4110
4520
|
if (text === undefined)
|
|
@@ -4127,7 +4537,7 @@ function endedAfter(record, notBefore) {
|
|
|
4127
4537
|
return !Number.isNaN(at) && at >= began;
|
|
4128
4538
|
}
|
|
4129
4539
|
async function awaitExit(instanceDir, poll = WAIT_POLL_MS) {
|
|
4130
|
-
const file =
|
|
4540
|
+
const file = join15(instanceDir, ".gamecrate", "lock");
|
|
4131
4541
|
let watching;
|
|
4132
4542
|
for (;; ) {
|
|
4133
4543
|
const lock = await readLock(file) ?? watching;
|
|
@@ -4139,12 +4549,12 @@ async function awaitExit(instanceDir, poll = WAIT_POLL_MS) {
|
|
|
4139
4549
|
if (!isRunning(lock.pid, lock.startedAt))
|
|
4140
4550
|
return "orphaned";
|
|
4141
4551
|
watching = lock;
|
|
4142
|
-
await
|
|
4552
|
+
await sleep6(poll);
|
|
4143
4553
|
}
|
|
4144
4554
|
}
|
|
4145
4555
|
var RUN_LOG_POLL_MS = 250;
|
|
4146
4556
|
async function awaitRunLog(instanceDir, lock, poll = RUN_LOG_POLL_MS, notice = WAIT_NOTICE) {
|
|
4147
|
-
const link =
|
|
4557
|
+
const link = join15(instanceDir, "logs", "current");
|
|
4148
4558
|
const written = Date.parse(lock.startedAt);
|
|
4149
4559
|
const since = Date.now();
|
|
4150
4560
|
let lastNotice = 0;
|
|
@@ -4152,7 +4562,7 @@ async function awaitRunLog(instanceDir, lock, poll = RUN_LOG_POLL_MS, notice = W
|
|
|
4152
4562
|
const target = await readlink(link).catch(() => {
|
|
4153
4563
|
return;
|
|
4154
4564
|
});
|
|
4155
|
-
const began = target === undefined ? undefined : runStartedAt(
|
|
4565
|
+
const began = target === undefined ? undefined : runStartedAt(basename8(target));
|
|
4156
4566
|
const current = began !== undefined && (Number.isNaN(written) || began >= written);
|
|
4157
4567
|
if (current && existsSync7(currentLog(instanceDir)))
|
|
4158
4568
|
return true;
|
|
@@ -4164,20 +4574,20 @@ async function awaitRunLog(instanceDir, lock, poll = RUN_LOG_POLL_MS, notice = W
|
|
|
4164
4574
|
status(`waiting for ${lock.game} ${lock.profile} to open its log (${label})`);
|
|
4165
4575
|
lastNotice = waited;
|
|
4166
4576
|
}
|
|
4167
|
-
await
|
|
4577
|
+
await sleep6(poll);
|
|
4168
4578
|
}
|
|
4169
4579
|
}
|
|
4170
4580
|
|
|
4171
4581
|
// src/launch/instance.ts
|
|
4172
4582
|
import { createHash as createHash2 } from "node:crypto";
|
|
4173
|
-
import { basename as
|
|
4583
|
+
import { basename as basename9, join as join16 } from "node:path";
|
|
4174
4584
|
|
|
4175
4585
|
// src/mods/worktree.ts
|
|
4176
|
-
import { spawnSync as
|
|
4586
|
+
import { spawnSync as spawnSync3 } from "node:child_process";
|
|
4177
4587
|
import { existsSync as existsSync8, realpathSync as realpathSync2 } from "node:fs";
|
|
4178
4588
|
import { isAbsolute as isAbsolute2, resolve as resolve5, sep } from "node:path";
|
|
4179
4589
|
function inspect(dir) {
|
|
4180
|
-
const r =
|
|
4590
|
+
const r = spawnSync3("git", ["-C", dir, "rev-parse", "--path-format=absolute", "--show-toplevel", "--git-dir", "--git-common-dir", "--abbrev-ref", "HEAD"], { encoding: "utf8" });
|
|
4181
4591
|
if (r.status !== 0 || typeof r.stdout !== "string")
|
|
4182
4592
|
return null;
|
|
4183
4593
|
const lines = r.stdout.trim().split(`
|
|
@@ -4252,7 +4662,7 @@ function resolveInstance(options) {
|
|
|
4252
4662
|
const name = args.instance === undefined ? derive(requests) : named(args.instance);
|
|
4253
4663
|
return {
|
|
4254
4664
|
...name === undefined ? {} : { name },
|
|
4255
|
-
dir: name === undefined ? profileDir :
|
|
4665
|
+
dir: name === undefined ? profileDir : join16(profileDir, "instances", name),
|
|
4256
4666
|
requests,
|
|
4257
4667
|
problems,
|
|
4258
4668
|
...configured?.settings === undefined ? {} : { settings: configured.settings }
|
|
@@ -4283,21 +4693,22 @@ function derive(requests) {
|
|
|
4283
4693
|
return `${slug2(first.root)}-${digest.slice(0, 6)}`;
|
|
4284
4694
|
}
|
|
4285
4695
|
function slug2(root) {
|
|
4286
|
-
const body =
|
|
4696
|
+
const body = basename9(root).toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^[^a-z0-9]+/, "").slice(0, SLUG_LIMIT2).replace(/[-._]+$/, "");
|
|
4287
4697
|
return body === "" ? "wt" : body;
|
|
4288
4698
|
}
|
|
4289
4699
|
|
|
4290
4700
|
// src/launch/resolve.ts
|
|
4291
|
-
import { join as
|
|
4701
|
+
import { join as join18 } from "node:path";
|
|
4292
4702
|
|
|
4293
4703
|
// src/mods/modindex.ts
|
|
4294
|
-
import {
|
|
4295
|
-
import {
|
|
4704
|
+
import { createHash as createHash3 } from "node:crypto";
|
|
4705
|
+
import { existsSync as existsSync9, readFileSync as readFileSync5, statSync as statSync3 } from "node:fs";
|
|
4706
|
+
import { mkdir as mkdir4, readdir as readdir7, readFile as readFile10, writeFile as writeFile6 } from "node:fs/promises";
|
|
4296
4707
|
import { homedir as homedir4 } from "node:os";
|
|
4297
|
-
import { dirname as
|
|
4708
|
+
import { dirname as dirname8, join as join17, relative as relative3, sep as sep2, resolve as resolvePath } from "node:path";
|
|
4298
4709
|
import picomatch from "picomatch";
|
|
4299
4710
|
function cacheDir() {
|
|
4300
|
-
return
|
|
4711
|
+
return join17(process.env["XDG_CACHE_HOME"] ?? join17(homedir4(), ".cache"), "gamecrate");
|
|
4301
4712
|
}
|
|
4302
4713
|
function globMatch(pattern, path) {
|
|
4303
4714
|
return picomatch.isMatch(path, pattern.replace(/[[\]{}()!,@+|^$.\\]/g, "\\$&"), { dot: true });
|
|
@@ -4309,10 +4720,10 @@ var ALWAYS_EXCLUDE = ["**/.worktrees/**", "**/.claude/worktrees/**"];
|
|
|
4309
4720
|
function inLinkedWorktree(dir, stopAt) {
|
|
4310
4721
|
let current = dir;
|
|
4311
4722
|
for (;; ) {
|
|
4312
|
-
const git =
|
|
4723
|
+
const git = join17(current, ".git");
|
|
4313
4724
|
if (existsSync9(git)) {
|
|
4314
4725
|
try {
|
|
4315
|
-
if (
|
|
4726
|
+
if (statSync3(git).isFile())
|
|
4316
4727
|
return true;
|
|
4317
4728
|
} catch {
|
|
4318
4729
|
return false;
|
|
@@ -4321,7 +4732,7 @@ function inLinkedWorktree(dir, stopAt) {
|
|
|
4321
4732
|
}
|
|
4322
4733
|
if (current === stopAt)
|
|
4323
4734
|
return false;
|
|
4324
|
-
const parent =
|
|
4735
|
+
const parent = dirname8(current);
|
|
4325
4736
|
if (parent === current)
|
|
4326
4737
|
return false;
|
|
4327
4738
|
current = parent;
|
|
@@ -4331,7 +4742,7 @@ async function scanLocalRoot(root, rootIndex, manifestFile, found) {
|
|
|
4331
4742
|
const base = resolvePath(expandHome(root.path));
|
|
4332
4743
|
const exclude = [...root.exclude ?? [], ...rootIndex === -1 ? [] : ALWAYS_EXCLUDE];
|
|
4333
4744
|
const walk = async (dir, depth) => {
|
|
4334
|
-
const manifest =
|
|
4745
|
+
const manifest = join17(dir, manifestFile);
|
|
4335
4746
|
if (existsSync9(manifest)) {
|
|
4336
4747
|
found.push({
|
|
4337
4748
|
dir,
|
|
@@ -4352,7 +4763,7 @@ async function scanLocalRoot(root, rootIndex, manifestFile, found) {
|
|
|
4352
4763
|
for (const entry of entries) {
|
|
4353
4764
|
if (!entry.isDirectory() || entry.name.startsWith(".git"))
|
|
4354
4765
|
continue;
|
|
4355
|
-
const child =
|
|
4766
|
+
const child = join17(dir, entry.name);
|
|
4356
4767
|
if (excluded(exclude, relative3(base, child)))
|
|
4357
4768
|
continue;
|
|
4358
4769
|
await walk(child, depth + 1);
|
|
@@ -4373,8 +4784,8 @@ async function scanWorkshopRoot(workshopRoot, rootIndex, manifestFile, found) {
|
|
|
4373
4784
|
for (const entry of entries) {
|
|
4374
4785
|
if (!entry.isDirectory() || !/^\d+$/.test(entry.name))
|
|
4375
4786
|
continue;
|
|
4376
|
-
const dir =
|
|
4377
|
-
if (!existsSync9(
|
|
4787
|
+
const dir = join17(base, entry.name);
|
|
4788
|
+
if (!existsSync9(join17(dir, manifestFile)))
|
|
4378
4789
|
continue;
|
|
4379
4790
|
found.push({ dir, kind: "workshop", rootIndex, linkedWorktree: false, workshopId: Number(entry.name) });
|
|
4380
4791
|
}
|
|
@@ -4383,7 +4794,7 @@ async function scanGameData(game, rootIndex, found) {
|
|
|
4383
4794
|
const host = game.gameFiles.host;
|
|
4384
4795
|
if (game.gameFiles.source !== "mount" || host === undefined)
|
|
4385
4796
|
return;
|
|
4386
|
-
const data =
|
|
4797
|
+
const data = join17(resolvePath(expandHome(host)), "Data");
|
|
4387
4798
|
let entries;
|
|
4388
4799
|
try {
|
|
4389
4800
|
entries = await readdir7(data, { withFileTypes: true });
|
|
@@ -4393,27 +4804,52 @@ async function scanGameData(game, rootIndex, found) {
|
|
|
4393
4804
|
for (const entry of entries) {
|
|
4394
4805
|
if (!entry.isDirectory())
|
|
4395
4806
|
continue;
|
|
4396
|
-
const dir =
|
|
4397
|
-
if (!existsSync9(
|
|
4807
|
+
const dir = join17(data, entry.name);
|
|
4808
|
+
if (!existsSync9(join17(dir, game.manifest.file)))
|
|
4398
4809
|
continue;
|
|
4399
4810
|
found.push({ dir, kind: "official", rootIndex, linkedWorktree: false });
|
|
4400
4811
|
}
|
|
4401
4812
|
}
|
|
4402
|
-
var CACHE_VERSION =
|
|
4403
|
-
function
|
|
4404
|
-
|
|
4813
|
+
var CACHE_VERSION = 4;
|
|
4814
|
+
function acfPath2(root, steamAppId) {
|
|
4815
|
+
return join17(dirname8(dirname8(resolvePath(expandHome(root)))), `appworkshop_${steamAppId}.acf`);
|
|
4816
|
+
}
|
|
4817
|
+
function contentPairs(acf) {
|
|
4818
|
+
let text;
|
|
4819
|
+
try {
|
|
4820
|
+
text = readFileSync5(acf, "utf8");
|
|
4821
|
+
} catch (error) {
|
|
4822
|
+
if (error.code === "ENOENT")
|
|
4823
|
+
return [];
|
|
4824
|
+
return null;
|
|
4825
|
+
}
|
|
4826
|
+
const pairs = [...installedItems(text)].map(([id, item]) => `${id}:${item.manifest}`).sort();
|
|
4827
|
+
if (pairs.length === 0 && text.trim() !== "" && Object.keys(parseAcf(text)).length === 0)
|
|
4405
4828
|
return null;
|
|
4406
|
-
|
|
4829
|
+
return pairs;
|
|
4830
|
+
}
|
|
4831
|
+
function workshopStamp(game, dataRoot) {
|
|
4832
|
+
const pairs = [];
|
|
4833
|
+
for (const root of dataRoot === undefined ? [] : [downloadRoot(dataRoot, game)]) {
|
|
4834
|
+
const found = contentPairs(acfPath2(root, game.steamAppId));
|
|
4835
|
+
if (found === null)
|
|
4836
|
+
return null;
|
|
4837
|
+
pairs.push(...found);
|
|
4838
|
+
}
|
|
4839
|
+
const download = createHash3("sha1").update(pairs.sort().join(`
|
|
4840
|
+
`)).digest("hex");
|
|
4841
|
+
if (game.workshopRoot === null)
|
|
4842
|
+
return download;
|
|
4407
4843
|
try {
|
|
4408
|
-
const info =
|
|
4409
|
-
return `${info.mtimeMs}:${info.size}`;
|
|
4844
|
+
const info = statSync3(acfPath2(game.workshopRoot, game.steamAppId));
|
|
4845
|
+
return `${download}|${info.mtimeMs}:${info.size}`;
|
|
4410
4846
|
} catch {
|
|
4411
4847
|
return null;
|
|
4412
4848
|
}
|
|
4413
4849
|
}
|
|
4414
4850
|
async function readWorkshopCache(game, stamp) {
|
|
4415
4851
|
try {
|
|
4416
|
-
const raw = JSON.parse(await
|
|
4852
|
+
const raw = JSON.parse(await readFile10(join17(cacheDir(), `${game}.workshop.json`), "utf8"));
|
|
4417
4853
|
if (raw.version !== CACHE_VERSION || raw.stamp !== stamp)
|
|
4418
4854
|
return null;
|
|
4419
4855
|
return raw.records;
|
|
@@ -4425,7 +4861,7 @@ async function writeWorkshopCache(game, stamp, records) {
|
|
|
4425
4861
|
const payload = { version: CACHE_VERSION, stamp, records };
|
|
4426
4862
|
try {
|
|
4427
4863
|
await mkdir4(cacheDir(), { recursive: true });
|
|
4428
|
-
await writeFile6(
|
|
4864
|
+
await writeFile6(join17(cacheDir(), `${game}.workshop.json`), JSON.stringify(payload));
|
|
4429
4865
|
} catch {}
|
|
4430
4866
|
}
|
|
4431
4867
|
function toRecord(candidate, manifest, game) {
|
|
@@ -4513,7 +4949,7 @@ async function applySourceOverrides(index, overrides, config) {
|
|
|
4513
4949
|
}
|
|
4514
4950
|
const wanted = spec.slice(0, eq);
|
|
4515
4951
|
const dir = resolvePath(expandHome(spec.slice(eq + 1)));
|
|
4516
|
-
const file =
|
|
4952
|
+
const file = join17(dir, config.manifest.file);
|
|
4517
4953
|
if (!existsSync9(file)) {
|
|
4518
4954
|
problems.push({
|
|
4519
4955
|
where: spec,
|
|
@@ -4575,7 +5011,7 @@ function insert(index, record) {
|
|
|
4575
5011
|
}
|
|
4576
5012
|
}
|
|
4577
5013
|
}
|
|
4578
|
-
async function buildIndex(game, config, plugin, sourcesDir) {
|
|
5014
|
+
async function buildIndex(game, config, plugin, sourcesDir, dataRoot) {
|
|
4579
5015
|
const index = {
|
|
4580
5016
|
game,
|
|
4581
5017
|
plugin,
|
|
@@ -4598,15 +5034,22 @@ async function buildIndex(game, config, plugin, sourcesDir) {
|
|
|
4598
5034
|
}
|
|
4599
5035
|
for (const record of parsed)
|
|
4600
5036
|
insert(index, record);
|
|
4601
|
-
|
|
4602
|
-
|
|
5037
|
+
const workshopRoots = [];
|
|
5038
|
+
if (dataRoot !== undefined)
|
|
5039
|
+
workshopRoots.push(downloadRoot(dataRoot, config));
|
|
5040
|
+
if (config.workshopRoot !== null)
|
|
5041
|
+
workshopRoots.push(config.workshopRoot);
|
|
5042
|
+
if (workshopRoots.length > 0) {
|
|
5043
|
+
const stamp = workshopStamp(config, dataRoot);
|
|
4603
5044
|
const cached = stamp === null ? null : await readWorkshopCache(game, stamp);
|
|
4604
5045
|
if (cached) {
|
|
4605
5046
|
for (const record of cached)
|
|
4606
5047
|
insert(index, record);
|
|
4607
5048
|
} else {
|
|
4608
5049
|
const items = [];
|
|
4609
|
-
|
|
5050
|
+
for (const [i, root] of workshopRoots.entries()) {
|
|
5051
|
+
await scanWorkshopRoot(root, cacheIndex + 1 + i, config.manifest.file, items);
|
|
5052
|
+
}
|
|
4610
5053
|
const records = await parseAll(items, config, plugin, index.problems);
|
|
4611
5054
|
for (const record of records)
|
|
4612
5055
|
insert(index, record);
|
|
@@ -4623,10 +5066,10 @@ function oneModPerClone(records, sourcesDir) {
|
|
|
4623
5066
|
const stamps = new Map;
|
|
4624
5067
|
for (const record of [...records].sort((a, b) => a.dir < b.dir ? -1 : a.dir > b.dir ? 1 : 0)) {
|
|
4625
5068
|
const clone = relative3(sourcesDir, record.dir).split(sep2).slice(0, 2).join(sep2);
|
|
4626
|
-
const at =
|
|
5069
|
+
const at = join17(sourcesDir, clone);
|
|
4627
5070
|
let stamp = stamps.get(at);
|
|
4628
5071
|
if (stamp === undefined) {
|
|
4629
|
-
stamp =
|
|
5072
|
+
stamp = statSync3(at).mtimeMs;
|
|
4630
5073
|
stamps.set(at, stamp);
|
|
4631
5074
|
}
|
|
4632
5075
|
record.clonedAt = stamp;
|
|
@@ -4638,9 +5081,9 @@ function oneModPerClone(records, sourcesDir) {
|
|
|
4638
5081
|
}
|
|
4639
5082
|
async function parseAll(candidates, config, plugin, problems) {
|
|
4640
5083
|
const records = await Promise.all(candidates.map(async (candidate) => {
|
|
4641
|
-
const file =
|
|
5084
|
+
const file = join17(candidate.dir, config.manifest.file);
|
|
4642
5085
|
try {
|
|
4643
|
-
const manifest = plugin.parseManifest(await
|
|
5086
|
+
const manifest = plugin.parseManifest(await readFile10(file, "utf8"));
|
|
4644
5087
|
return manifest === null ? null : toRecord(candidate, manifest, config);
|
|
4645
5088
|
} catch (error) {
|
|
4646
5089
|
problems.push({
|
|
@@ -4711,7 +5154,7 @@ function byPath(index, raw, game) {
|
|
|
4711
5154
|
if (hit)
|
|
4712
5155
|
return hit;
|
|
4713
5156
|
}
|
|
4714
|
-
const file =
|
|
5157
|
+
const file = join17(dir, game.manifest.file);
|
|
4715
5158
|
if (!existsSync9(file))
|
|
4716
5159
|
return null;
|
|
4717
5160
|
try {
|
|
@@ -4730,35 +5173,8 @@ var DEFAULT_RENDER_WAIT_SECONDS = 25;
|
|
|
4730
5173
|
function isDynamic(entry) {
|
|
4731
5174
|
return typeof entry !== "string" && "match" in entry;
|
|
4732
5175
|
}
|
|
4733
|
-
function
|
|
4734
|
-
return
|
|
4735
|
-
}
|
|
4736
|
-
function workshopRootProblem(gameName, game) {
|
|
4737
|
-
if (game.workshopRoot !== null)
|
|
4738
|
-
return null;
|
|
4739
|
-
const ids = new Set;
|
|
4740
|
-
for (const entry of Object.values(game.library ?? {})) {
|
|
4741
|
-
if (entry.workshop !== undefined)
|
|
4742
|
-
ids.add(String(entry.workshop));
|
|
4743
|
-
}
|
|
4744
|
-
for (const profile of Object.values(game.profiles)) {
|
|
4745
|
-
for (const entry of profile.mods ?? []) {
|
|
4746
|
-
if (typeof entry === "string") {
|
|
4747
|
-
if (entry.startsWith("workshop:"))
|
|
4748
|
-
ids.add(entry.slice(9));
|
|
4749
|
-
} else if ("workshop" in entry && entry.workshop !== undefined)
|
|
4750
|
-
ids.add(String(entry.workshop));
|
|
4751
|
-
}
|
|
4752
|
-
}
|
|
4753
|
-
if (ids.size === 0)
|
|
4754
|
-
return null;
|
|
4755
|
-
const shown = [...ids].slice(0, 3).join(", ");
|
|
4756
|
-
const tail = `${ids.size} workshop reference(s)`;
|
|
4757
|
-
return {
|
|
4758
|
-
where: `/games/${gameName}/workshopRoot`,
|
|
4759
|
-
message: `${noWorkshopRoot(gameName, tail)}: ${shown}${ids.size > 3 ? ", ..." : ""}`,
|
|
4760
|
-
suggestion: `set games.${gameName}.workshopRoot, or pin those mods with path: or git:`
|
|
4761
|
-
};
|
|
5176
|
+
function notFetched(id) {
|
|
5177
|
+
return `workshop item ${id} is not fetched, and fetching is off for this command; a real launch would fetch it`;
|
|
4762
5178
|
}
|
|
4763
5179
|
function refFor(entry, game, sources) {
|
|
4764
5180
|
const object = typeof entry === "string" ? { id: entry } : entry;
|
|
@@ -4776,7 +5192,7 @@ function refFor(entry, game, sources) {
|
|
|
4776
5192
|
if (pin?.git !== undefined) {
|
|
4777
5193
|
const dir = sources.get(object.id.toLowerCase());
|
|
4778
5194
|
if (dir !== undefined)
|
|
4779
|
-
return `path:${pin.subdir === undefined ? dir :
|
|
5195
|
+
return `path:${pin.subdir === undefined ? dir : join18(dir, pin.subdir)}`;
|
|
4780
5196
|
}
|
|
4781
5197
|
return object.id;
|
|
4782
5198
|
}
|
|
@@ -4941,6 +5357,7 @@ async function resolvePlan(options) {
|
|
|
4941
5357
|
const { game: gameName, profile: requestedProfile, root } = options;
|
|
4942
5358
|
const args = options.args ?? {};
|
|
4943
5359
|
const sources = options.sources ?? new Map;
|
|
5360
|
+
const unfetched = new Set(options.unfetched ?? []);
|
|
4944
5361
|
const problems = [];
|
|
4945
5362
|
const warnings = [];
|
|
4946
5363
|
const game = own(root.games, gameName);
|
|
@@ -4968,7 +5385,7 @@ async function resolvePlan(options) {
|
|
|
4968
5385
|
if (args.dockerArgs?.length)
|
|
4969
5386
|
settings.dockerArgs = [...settings.dockerArgs ?? [], ...args.dockerArgs];
|
|
4970
5387
|
const plugin = requirePlugin(options.plugins, gameName);
|
|
4971
|
-
const index = options.index ?? await buildIndex(gameName, game, plugin, sourcesRoot(root.dataRoot));
|
|
5388
|
+
const index = options.index ?? await buildIndex(gameName, game, plugin, sourcesRoot(root.dataRoot), root.dataRoot);
|
|
4972
5389
|
await applyWorktreeRequests(index, instance.requests, game);
|
|
4973
5390
|
problems.push(...await applySourceOverrides(index, args.use ?? [], game));
|
|
4974
5391
|
const excluded = [...profile.exclude ?? [], ...args.without ?? []].map(globToRegExp);
|
|
@@ -4985,11 +5402,8 @@ async function resolvePlan(options) {
|
|
|
4985
5402
|
continue;
|
|
4986
5403
|
if (optional)
|
|
4987
5404
|
warnings.push(`optional mod ${ref} is not installed; skipped`);
|
|
4988
|
-
else if (ref.startsWith("workshop:") &&
|
|
4989
|
-
problems.push({
|
|
4990
|
-
where: slot.where,
|
|
4991
|
-
message: noWorkshopRoot(gameName, ref)
|
|
4992
|
-
});
|
|
5405
|
+
else if (ref.startsWith("workshop:") && unfetched.has(ref.slice(9))) {
|
|
5406
|
+
problems.push({ where: slot.where, message: notFetched(ref.slice(9)) });
|
|
4993
5407
|
} else
|
|
4994
5408
|
problems.push({ where: slot.where, message: `no mod matches "${ref}"` });
|
|
4995
5409
|
continue;
|
|
@@ -5003,6 +5417,13 @@ async function resolvePlan(options) {
|
|
|
5003
5417
|
}
|
|
5004
5418
|
if (profile.autoDependencies === true)
|
|
5005
5419
|
insertDependencies(staged, present, index, game, problems);
|
|
5420
|
+
if (unfetched.size > 0) {
|
|
5421
|
+
for (const problem of problems) {
|
|
5422
|
+
const id = workshopUrlId(problem.suggestion);
|
|
5423
|
+
if (id !== undefined && unfetched.has(id))
|
|
5424
|
+
problem.message = notFetched(id);
|
|
5425
|
+
}
|
|
5426
|
+
}
|
|
5006
5427
|
const ordered = args.sort === "none" ? staged : topoSort(staged, problems, game.core);
|
|
5007
5428
|
warnings.push(...incompatibilityWarnings(ordered));
|
|
5008
5429
|
const mods = [];
|
|
@@ -5043,6 +5464,9 @@ async function resolvePlan(options) {
|
|
|
5043
5464
|
if (!game.modes.includes(mode)) {
|
|
5044
5465
|
problems.push({ where: "flag --mode", message: `${gameName} does not support mode "${mode}"` });
|
|
5045
5466
|
}
|
|
5467
|
+
if (unfetched.size > 0) {
|
|
5468
|
+
warnings.unshift(`this plan is provisional: ${unfetched.size} workshop item(s) are not fetched, and a real launch would fetch them first`);
|
|
5469
|
+
}
|
|
5046
5470
|
const plan = {
|
|
5047
5471
|
game: gameName,
|
|
5048
5472
|
gameConfig: game,
|
|
@@ -5053,11 +5477,11 @@ async function resolvePlan(options) {
|
|
|
5053
5477
|
profileDir,
|
|
5054
5478
|
...instance.name === undefined ? {} : { instance: instance.name },
|
|
5055
5479
|
instanceDir: instance.dir,
|
|
5056
|
-
dataDirHost:
|
|
5057
|
-
configDirHost:
|
|
5058
|
-
stageDirHost:
|
|
5059
|
-
logsDirHost:
|
|
5060
|
-
runDirHost:
|
|
5480
|
+
dataDirHost: join18(instance.dir, "game"),
|
|
5481
|
+
configDirHost: join18(profileDir, "config"),
|
|
5482
|
+
stageDirHost: join18(instance.dir, ".stage"),
|
|
5483
|
+
logsDirHost: join18(instance.dir, "logs"),
|
|
5484
|
+
runDirHost: join18(instance.dir, "logs"),
|
|
5061
5485
|
mode,
|
|
5062
5486
|
...args.marker === undefined ? {} : { marker: args.marker },
|
|
5063
5487
|
timeoutSeconds: args.timeout ?? DEFAULT_TIMEOUT_SECONDS,
|
|
@@ -5070,7 +5494,7 @@ async function resolvePlan(options) {
|
|
|
5070
5494
|
|
|
5071
5495
|
// src/run/registry.ts
|
|
5072
5496
|
import { readdir as readdir8 } from "node:fs/promises";
|
|
5073
|
-
import { join as
|
|
5497
|
+
import { join as join19 } from "node:path";
|
|
5074
5498
|
var FORMAT = '{{.Names}}\t{{.Label "gamecrate.game"}}\t{{.Label "gamecrate.profile"}}\t{{.Label "gamecrate.instance"}}\t{{.Status}}';
|
|
5075
5499
|
function parseDockerRuns(stdout) {
|
|
5076
5500
|
const out = [];
|
|
@@ -5095,13 +5519,13 @@ function parseDockerRuns(stdout) {
|
|
|
5095
5519
|
async function walkLocks(dataRoot) {
|
|
5096
5520
|
const out = [];
|
|
5097
5521
|
for (const game of await entries(dataRoot)) {
|
|
5098
|
-
const gameDir =
|
|
5522
|
+
const gameDir = join19(dataRoot, game);
|
|
5099
5523
|
for (const profile of await entries(gameDir)) {
|
|
5100
|
-
const profileDir =
|
|
5101
|
-
await push(out,
|
|
5102
|
-
const instancesDir =
|
|
5524
|
+
const profileDir = join19(gameDir, profile);
|
|
5525
|
+
await push(out, join19(profileDir, ".gamecrate", "lock"));
|
|
5526
|
+
const instancesDir = join19(profileDir, "instances");
|
|
5103
5527
|
for (const instance of await entries(instancesDir)) {
|
|
5104
|
-
await push(out,
|
|
5528
|
+
await push(out, join19(instancesDir, instance, ".gamecrate", "lock"));
|
|
5105
5529
|
}
|
|
5106
5530
|
}
|
|
5107
5531
|
}
|
|
@@ -5158,8 +5582,8 @@ async function listRuns(dataRoot, docker = dockerPs) {
|
|
|
5158
5582
|
}
|
|
5159
5583
|
|
|
5160
5584
|
// src/launch/stage.ts
|
|
5161
|
-
import { lstat, mkdir as mkdir5, readdir as readdir9, realpath as realpath2, rm as rm3, stat as
|
|
5162
|
-
import { basename as
|
|
5585
|
+
import { lstat, mkdir as mkdir5, readdir as readdir9, realpath as realpath2, rm as rm3, stat as stat5 } from "node:fs/promises";
|
|
5586
|
+
import { basename as basename10, join as join20 } from "node:path";
|
|
5163
5587
|
async function stageMods(plan) {
|
|
5164
5588
|
await rm3(plan.stageDirHost, { recursive: true, force: true });
|
|
5165
5589
|
await mkdir5(plan.stageDirHost, { recursive: true });
|
|
@@ -5173,10 +5597,10 @@ async function stageMods(plan) {
|
|
|
5173
5597
|
} catch {
|
|
5174
5598
|
throw new GamecrateError(`mod directory for ${mod.packageId} is missing`, Exit.Environment, mod.hostDir);
|
|
5175
5599
|
}
|
|
5176
|
-
if (!(await
|
|
5600
|
+
if (!(await stat5(source)).isDirectory()) {
|
|
5177
5601
|
throw new GamecrateError(`${mod.packageId} does not resolve to a directory`, Exit.Environment, source);
|
|
5178
5602
|
}
|
|
5179
|
-
await mkdir5(
|
|
5603
|
+
await mkdir5(join20(plan.stageDirHost, basename10(mod.containerDir)), { recursive: true });
|
|
5180
5604
|
mounts.push({ type: "bind", source, target: mod.containerDir, readonly: true });
|
|
5181
5605
|
}
|
|
5182
5606
|
return mounts;
|
|
@@ -5186,12 +5610,12 @@ async function ensureProfileTree(plan) {
|
|
|
5186
5610
|
plan.profileDir,
|
|
5187
5611
|
plan.instanceDir,
|
|
5188
5612
|
plan.dataDirHost,
|
|
5189
|
-
|
|
5190
|
-
|
|
5191
|
-
|
|
5192
|
-
|
|
5613
|
+
join20(plan.configDirHost, "config"),
|
|
5614
|
+
join20(plan.configDirHost, "data"),
|
|
5615
|
+
join20(plan.configDirHost, "cache"),
|
|
5616
|
+
join20(plan.logsDirHost, "runs"),
|
|
5193
5617
|
plan.stageDirHost,
|
|
5194
|
-
|
|
5618
|
+
join20(plan.instanceDir, ".gamecrate"),
|
|
5195
5619
|
...engineDirs(plan)
|
|
5196
5620
|
]) {
|
|
5197
5621
|
await mkdir5(dir, { recursive: true });
|
|
@@ -5201,7 +5625,7 @@ function engineDirs(plan) {
|
|
|
5201
5625
|
const { dataDir, modsDir } = plan.gameConfig;
|
|
5202
5626
|
if (!modsDir.container.startsWith(`${dataDir.container}/`))
|
|
5203
5627
|
return [];
|
|
5204
|
-
return [
|
|
5628
|
+
return [join20(plan.dataDirHost, modsDir.container.slice(dataDir.container.length + 1))];
|
|
5205
5629
|
}
|
|
5206
5630
|
async function detectForeignOwnership(dir, uid, limit = 100) {
|
|
5207
5631
|
const foreign = [];
|
|
@@ -5223,7 +5647,7 @@ async function detectForeignOwnership(dir, uid, limit = 100) {
|
|
|
5223
5647
|
continue;
|
|
5224
5648
|
try {
|
|
5225
5649
|
for (const entry of await readdir9(current))
|
|
5226
|
-
queue.push(
|
|
5650
|
+
queue.push(join20(current, entry));
|
|
5227
5651
|
} catch {
|
|
5228
5652
|
continue;
|
|
5229
5653
|
}
|
|
@@ -5231,8 +5655,140 @@ async function detectForeignOwnership(dir, uid, limit = 100) {
|
|
|
5231
5655
|
return foreign;
|
|
5232
5656
|
}
|
|
5233
5657
|
|
|
5658
|
+
// src/mods/workshop.ts
|
|
5659
|
+
import { existsSync as existsSync10 } from "node:fs";
|
|
5660
|
+
import { readFile as readFile11 } from "node:fs/promises";
|
|
5661
|
+
import { join as join21, resolve as resolvePath2 } from "node:path";
|
|
5662
|
+
var ROUNDS = 5;
|
|
5663
|
+
async function prepareWorkshop(game, profileName, args, config, allowFetch, plugin, sources) {
|
|
5664
|
+
const roots = mountedRoots(config.dataRoot, game);
|
|
5665
|
+
const ids = new Set;
|
|
5666
|
+
const warnings = [];
|
|
5667
|
+
const problems = [];
|
|
5668
|
+
const profile = resolveProfile(game, profileName);
|
|
5669
|
+
const seeded = await localSeeds(game, profile, args, sources, plugin, problems);
|
|
5670
|
+
let frontier = [...new Set([...wantedIds(game, profile, args), ...seeded])];
|
|
5671
|
+
for (let round = 0;round < ROUNDS && frontier.length > 0; round++) {
|
|
5672
|
+
for (const id of frontier)
|
|
5673
|
+
ids.add(id);
|
|
5674
|
+
if (allowFetch) {
|
|
5675
|
+
try {
|
|
5676
|
+
const drift = await checkDrift(frontier, roots);
|
|
5677
|
+
warnings.push(...drift.warnings);
|
|
5678
|
+
const downloaded = await downloadItems(config, game, config.dataRoot, drift.needed);
|
|
5679
|
+
warnings.push(...downloaded.warnings);
|
|
5680
|
+
} catch (error) {
|
|
5681
|
+
warnings.push(`could not download workshop items: ${error instanceof Error ? error.message : String(error)}`);
|
|
5682
|
+
}
|
|
5683
|
+
}
|
|
5684
|
+
const next = new Set;
|
|
5685
|
+
for (const id of frontier) {
|
|
5686
|
+
const manifest = await manifestOf(roots, id, game, plugin, problems);
|
|
5687
|
+
for (const dep of manifest?.modDependencies ?? []) {
|
|
5688
|
+
const depId = workshopUrlId(dep.steamWorkshopUrl);
|
|
5689
|
+
if (depId !== undefined && !ids.has(depId))
|
|
5690
|
+
next.add(depId);
|
|
5691
|
+
}
|
|
5692
|
+
}
|
|
5693
|
+
frontier = [...next];
|
|
5694
|
+
}
|
|
5695
|
+
if (frontier.length > 0) {
|
|
5696
|
+
problems.push({
|
|
5697
|
+
where: `profile ${profileName}`,
|
|
5698
|
+
message: `workshop dependencies are still unresolved after ${ROUNDS} rounds: ${frontier.join(", ")}`,
|
|
5699
|
+
suggestion: "name them in the profile so they download in the first round"
|
|
5700
|
+
});
|
|
5701
|
+
}
|
|
5702
|
+
return { ids, warnings, problems, unfetched: [...ids].filter((id) => itemDir(roots, id) === undefined) };
|
|
5703
|
+
}
|
|
5704
|
+
function mountedRoots(dataRoot, game) {
|
|
5705
|
+
return [downloadRoot(dataRoot, game), ...game.workshopRoot === null ? [] : [game.workshopRoot]];
|
|
5706
|
+
}
|
|
5707
|
+
function itemDir(roots, id) {
|
|
5708
|
+
return roots.map((root) => join21(root, id)).find((dir) => existsSync10(dir));
|
|
5709
|
+
}
|
|
5710
|
+
async function manifestOf(roots, id, game, plugin, problems) {
|
|
5711
|
+
const dir = itemDir(roots, id);
|
|
5712
|
+
return dir === undefined ? null : await manifestAt(dir, game, plugin, problems);
|
|
5713
|
+
}
|
|
5714
|
+
async function manifestAt(dir, game, plugin, problems) {
|
|
5715
|
+
const file = join21(dir, game.manifest.file);
|
|
5716
|
+
if (!existsSync10(file))
|
|
5717
|
+
return null;
|
|
5718
|
+
try {
|
|
5719
|
+
return plugin.parseManifest(await readFile11(file, "utf8"));
|
|
5720
|
+
} catch (error) {
|
|
5721
|
+
problems.push({ where: file, message: error instanceof Error ? error.message : String(error) });
|
|
5722
|
+
return null;
|
|
5723
|
+
}
|
|
5724
|
+
}
|
|
5725
|
+
async function localSeeds(game, profile, args, sources, plugin, problems) {
|
|
5726
|
+
const out = new Set;
|
|
5727
|
+
for (const dir of localDirs(game, profile, args, sources)) {
|
|
5728
|
+
const manifest = await manifestAt(dir, game, plugin, problems);
|
|
5729
|
+
for (const dep of manifest?.modDependencies ?? []) {
|
|
5730
|
+
const id = workshopUrlId(dep.steamWorkshopUrl);
|
|
5731
|
+
if (id !== undefined)
|
|
5732
|
+
out.add(id);
|
|
5733
|
+
}
|
|
5734
|
+
}
|
|
5735
|
+
return [...out];
|
|
5736
|
+
}
|
|
5737
|
+
function localDirs(game, profile, args, sources) {
|
|
5738
|
+
const out = new Set;
|
|
5739
|
+
for (const entry of reachedEntries(game, profile, args)) {
|
|
5740
|
+
if (typeof entry !== "string" && "match" in entry)
|
|
5741
|
+
continue;
|
|
5742
|
+
const object = typeof entry === "string" ? { id: entry } : entry;
|
|
5743
|
+
if (object.path !== undefined) {
|
|
5744
|
+
out.add(resolvePath2(expandHome(object.path)));
|
|
5745
|
+
continue;
|
|
5746
|
+
}
|
|
5747
|
+
if (object.id.includes(":"))
|
|
5748
|
+
continue;
|
|
5749
|
+
const pin = libraryPin(game, object.id);
|
|
5750
|
+
const clone = sources.get(object.id.toLowerCase());
|
|
5751
|
+
if (clone !== undefined) {
|
|
5752
|
+
out.add(pin?.subdir === undefined ? clone : join21(clone, pin.subdir));
|
|
5753
|
+
continue;
|
|
5754
|
+
}
|
|
5755
|
+
if (pin?.path !== undefined)
|
|
5756
|
+
out.add(resolvePath2(expandHome(pin.path)));
|
|
5757
|
+
}
|
|
5758
|
+
return [...out];
|
|
5759
|
+
}
|
|
5760
|
+
function wantedIds(game, profile, args) {
|
|
5761
|
+
const out = new Set;
|
|
5762
|
+
for (const entry of reachedEntries(game, profile, args)) {
|
|
5763
|
+
const id = workshopIdOf(entry, game);
|
|
5764
|
+
if (id !== undefined)
|
|
5765
|
+
out.add(id);
|
|
5766
|
+
}
|
|
5767
|
+
return [...out];
|
|
5768
|
+
}
|
|
5769
|
+
function workshopIdOf(entry, game) {
|
|
5770
|
+
if (typeof entry !== "string" && "match" in entry)
|
|
5771
|
+
return;
|
|
5772
|
+
const object = typeof entry === "string" ? { id: entry } : entry;
|
|
5773
|
+
if (object.path !== undefined)
|
|
5774
|
+
return;
|
|
5775
|
+
if (object.workshop !== undefined)
|
|
5776
|
+
return published2(String(object.workshop));
|
|
5777
|
+
if (object.id.startsWith("workshop:"))
|
|
5778
|
+
return published2(object.id.slice("workshop:".length));
|
|
5779
|
+
if (object.id.includes(":"))
|
|
5780
|
+
return;
|
|
5781
|
+
const pin = libraryPin(game, object.id);
|
|
5782
|
+
if (pin?.path !== undefined)
|
|
5783
|
+
return;
|
|
5784
|
+
return pin?.workshop === undefined ? undefined : published2(String(pin.workshop));
|
|
5785
|
+
}
|
|
5786
|
+
function published2(value) {
|
|
5787
|
+
return /^\d+$/.test(value) ? value : undefined;
|
|
5788
|
+
}
|
|
5789
|
+
|
|
5234
5790
|
// src/index.ts
|
|
5235
|
-
var VERSION = "1.
|
|
5791
|
+
var VERSION = "1.3.0";
|
|
5236
5792
|
async function main(argv) {
|
|
5237
5793
|
const supervised = supervisedDir(argv);
|
|
5238
5794
|
try {
|
|
@@ -5304,13 +5860,13 @@ async function dispatch(argv, args, config, plugins, defaults) {
|
|
|
5304
5860
|
case "wait":
|
|
5305
5861
|
return waitFor(args, config, defaults);
|
|
5306
5862
|
case "shell":
|
|
5307
|
-
return
|
|
5863
|
+
return run2(argv, args, config, plugins, defaults, true);
|
|
5308
5864
|
case "config":
|
|
5309
5865
|
return configEdit(args);
|
|
5310
5866
|
case "fix-perms":
|
|
5311
5867
|
return fixPerms(args, config);
|
|
5312
5868
|
case "run":
|
|
5313
|
-
return
|
|
5869
|
+
return run2(argv, args, config, plugins, defaults, false);
|
|
5314
5870
|
default:
|
|
5315
5871
|
throw new GamecrateError(`no such subcommand ${args.subcommand}`, Exit.Usage);
|
|
5316
5872
|
}
|
|
@@ -5356,25 +5912,38 @@ function reportEnvironment(problems) {
|
|
|
5356
5912
|
throw new GamecrateError(`${problems.length} environment problem(s)`, Exit.Environment, out.join(`
|
|
5357
5913
|
`));
|
|
5358
5914
|
}
|
|
5359
|
-
async function
|
|
5915
|
+
async function run2(argv, args, config, plugins, defaults, asShell) {
|
|
5360
5916
|
const game = requireGame(args, config);
|
|
5361
5917
|
const profile = profileOf(args, defaults);
|
|
5362
5918
|
const gameConfig = config.games[game];
|
|
5363
5919
|
const allowFetch = !args.dryRun && !args.printPlan;
|
|
5364
5920
|
const sources = await prepareSources(gameConfig, profile, args, config.dataRoot, allowFetch);
|
|
5365
5921
|
try {
|
|
5366
|
-
|
|
5922
|
+
const workshop = await prepareWorkshop(gameConfig, profile, args, config, allowFetch, requirePlugin(plugins, game), sources.dirs);
|
|
5923
|
+
return await resolved(argv, args, config, plugins, asShell, game, profile, sources, workshop, allowFetch);
|
|
5367
5924
|
} finally {
|
|
5368
5925
|
await sources.release();
|
|
5369
5926
|
}
|
|
5370
5927
|
}
|
|
5371
|
-
|
|
5372
|
-
|
|
5928
|
+
function warnUnfetched(problems, unfetched) {
|
|
5929
|
+
const provisional = new Set(unfetched.map(notFetched));
|
|
5930
|
+
const fatal = [];
|
|
5931
|
+
for (const problem of problems) {
|
|
5932
|
+
if (provisional.has(problem.message))
|
|
5933
|
+
warn(`${problem.where}: ${problem.message}`);
|
|
5934
|
+
else
|
|
5935
|
+
fatal.push(problem);
|
|
5936
|
+
}
|
|
5937
|
+
return fatal;
|
|
5938
|
+
}
|
|
5939
|
+
async function resolved(argv, args, config, plugins, asShell, game, profile, sources, workshop, allowFetch) {
|
|
5940
|
+
for (const warning of [...sources.warnings, ...workshop.warnings])
|
|
5373
5941
|
warn(warning);
|
|
5374
|
-
const index = await buildIndex(game, config.games[game], requirePlugin(plugins, game), sourcesRoot(config.dataRoot));
|
|
5375
|
-
const { plan, problems } = await resolvePlan({ game, profile, root: config, plugins, args, index, sources: sources.dirs });
|
|
5376
|
-
|
|
5377
|
-
|
|
5942
|
+
const index = await buildIndex(game, config.games[game], requirePlugin(plugins, game), sourcesRoot(config.dataRoot), config.dataRoot);
|
|
5943
|
+
const { plan, problems } = await resolvePlan({ game, profile, root: config, plugins, args, index, sources: sources.dirs, unfetched: allowFetch ? undefined : workshop.unfetched });
|
|
5944
|
+
const fatal = warnUnfetched([...workshop.problems, ...problems], allowFetch ? [] : workshop.unfetched);
|
|
5945
|
+
if (fatal.length > 0)
|
|
5946
|
+
reportProblems(fatal);
|
|
5378
5947
|
const identity = resolveIdentity(args.root);
|
|
5379
5948
|
if (args.printPlan || args.dryRun) {
|
|
5380
5949
|
const environment = await preflight(plan);
|
|
@@ -5421,7 +5990,7 @@ run: gamecrate fix-perms ${game} ${profile}`);
|
|
|
5421
5990
|
}
|
|
5422
5991
|
const runDir = openRunLog(plan.logsDirHost);
|
|
5423
5992
|
plan.runDirHost = runDir;
|
|
5424
|
-
const supervisorLog = args.supervised && args.log === undefined ? redirectOutput(
|
|
5993
|
+
const supervisorLog = args.supervised && args.log === undefined ? redirectOutput(join22(runDir, "supervisor.log")) : undefined;
|
|
5425
5994
|
try {
|
|
5426
5995
|
return await execute(plan, args, config, identity, asShell, profileSpec, runDir, releaseSources);
|
|
5427
5996
|
} finally {
|
|
@@ -5479,19 +6048,19 @@ async function execute(plan, args, config, identity, asShell, profileSpec, runDi
|
|
|
5479
6048
|
}
|
|
5480
6049
|
}
|
|
5481
6050
|
function markerSources(plan, logDir) {
|
|
5482
|
-
const sources = [
|
|
6051
|
+
const sources = [join22(logDir, STDOUT_LOG)];
|
|
5483
6052
|
const { logFile } = plan.gameConfig;
|
|
5484
6053
|
if (logFile.mode === "arg")
|
|
5485
|
-
sources.push(
|
|
6054
|
+
sources.push(join22(logDir, "Player.log"));
|
|
5486
6055
|
else
|
|
5487
|
-
sources.push(
|
|
6056
|
+
sources.push(join22(plan.dataDirHost, logFile.from));
|
|
5488
6057
|
return sources;
|
|
5489
6058
|
}
|
|
5490
6059
|
async function runBounded(spec, plan, logDir) {
|
|
5491
6060
|
const container = runContainer(spec, { logDir, stopTimeoutSeconds: STOP_TIMEOUT_SECONDS });
|
|
5492
6061
|
const winner = await Promise.race([
|
|
5493
6062
|
container.then((code) => ({ kind: "exit", code })),
|
|
5494
|
-
|
|
6063
|
+
sleep7(plan.timeoutSeconds * 1000).then(() => ({ kind: "timeout" }))
|
|
5495
6064
|
]);
|
|
5496
6065
|
if (winner.kind === "exit")
|
|
5497
6066
|
return { code: normalize(winner.code), reason: reasonFor(winner.code) };
|
|
@@ -5502,7 +6071,7 @@ async function runBounded(spec, plan, logDir) {
|
|
|
5502
6071
|
}
|
|
5503
6072
|
async function runWithScreenshot(spec, plan, logDir) {
|
|
5504
6073
|
const container = runContainer(spec, { logDir, stopTimeoutSeconds: STOP_TIMEOUT_SECONDS });
|
|
5505
|
-
const settled =
|
|
6074
|
+
const settled = sleep7(plan.renderWaitSeconds * 1000).then(() => "ready");
|
|
5506
6075
|
const winner = await Promise.race([
|
|
5507
6076
|
container.then((code) => ({ kind: "exit", code })),
|
|
5508
6077
|
settled.then(() => ({ kind: "ready" }))
|
|
@@ -5552,10 +6121,10 @@ async function copyOutLogs(plan) {
|
|
|
5552
6121
|
const spec = plan.gameConfig.logFile;
|
|
5553
6122
|
if (spec.mode !== "copy-out")
|
|
5554
6123
|
return;
|
|
5555
|
-
const source =
|
|
5556
|
-
if (!
|
|
6124
|
+
const source = join22(plan.dataDirHost, spec.from);
|
|
6125
|
+
if (!existsSync11(source))
|
|
5557
6126
|
return;
|
|
5558
|
-
const target =
|
|
6127
|
+
const target = join22(plan.runDirHost, basename11(spec.from.replace(/\/+$/, "")));
|
|
5559
6128
|
try {
|
|
5560
6129
|
await cp(source, target, { recursive: true, force: true });
|
|
5561
6130
|
} catch (error) {
|
|
@@ -5565,21 +6134,65 @@ async function copyOutLogs(plan) {
|
|
|
5565
6134
|
async function mods(args, config, plugins, defaults) {
|
|
5566
6135
|
const game = requireGame(args, config);
|
|
5567
6136
|
const profile = profileOf(args, defaults);
|
|
5568
|
-
const index = await buildIndex(game, config.games[game], requirePlugin(plugins, game), sourcesRoot(config.dataRoot));
|
|
5569
6137
|
const sources = cachedSources(config.games[game], profile, args, config.dataRoot);
|
|
5570
|
-
const
|
|
5571
|
-
|
|
5572
|
-
|
|
6138
|
+
const workshop = await prepareWorkshop(config.games[game], profile, args, config, false, requirePlugin(plugins, game), sources);
|
|
6139
|
+
for (const warning of workshop.warnings)
|
|
6140
|
+
warn(warning);
|
|
6141
|
+
const index = await buildIndex(game, config.games[game], requirePlugin(plugins, game), sourcesRoot(config.dataRoot), config.dataRoot);
|
|
6142
|
+
const { plan, problems } = await resolvePlan({ game, profile, root: config, plugins, args, index, sources, unfetched: workshop.unfetched });
|
|
6143
|
+
const fatal = warnUnfetched([...workshop.problems, ...problems], workshop.unfetched);
|
|
6144
|
+
if (fatal.length > 0)
|
|
6145
|
+
reportProblems(fatal);
|
|
5573
6146
|
printPlan(plan, args.json);
|
|
5574
6147
|
return Exit.Ok;
|
|
5575
6148
|
}
|
|
6149
|
+
function usesWorkshop(game) {
|
|
6150
|
+
for (const entry of Object.values(game.library ?? {})) {
|
|
6151
|
+
if (entry.workshop !== undefined)
|
|
6152
|
+
return true;
|
|
6153
|
+
}
|
|
6154
|
+
for (const ref of [...game.preCore ?? [], game.core, ...game.dlc, ...game.base ?? []]) {
|
|
6155
|
+
if (ref.startsWith("workshop:"))
|
|
6156
|
+
return true;
|
|
6157
|
+
}
|
|
6158
|
+
for (const profile of Object.values(game.profiles)) {
|
|
6159
|
+
for (const entry of profile.mods ?? []) {
|
|
6160
|
+
if (typeof entry === "string") {
|
|
6161
|
+
if (entry.startsWith("workshop:"))
|
|
6162
|
+
return true;
|
|
6163
|
+
} else if ("workshop" in entry && entry.workshop !== undefined)
|
|
6164
|
+
return true;
|
|
6165
|
+
}
|
|
6166
|
+
}
|
|
6167
|
+
return false;
|
|
6168
|
+
}
|
|
6169
|
+
function steamcmdSource(runner, config) {
|
|
6170
|
+
if (runner.kind === "docker")
|
|
6171
|
+
return `docker image ${STEAMCMD_IMAGE}`;
|
|
6172
|
+
const where = config.steamcmd?.path === undefined ? "on PATH" : "steamcmd.path";
|
|
6173
|
+
return `${runner.argv[0]} (${where})`;
|
|
6174
|
+
}
|
|
5576
6175
|
async function doctor(config, plugins) {
|
|
5577
6176
|
let failed = false;
|
|
5578
6177
|
for (const game of Object.keys(config.games)) {
|
|
5579
|
-
const
|
|
6178
|
+
const gameConfig = config.games[game];
|
|
6179
|
+
const sources = cachedSources(gameConfig, "modless", {}, config.dataRoot);
|
|
5580
6180
|
const { plan, problems } = await resolvePlan({ game, profile: "modless", root: config, plugins, sources });
|
|
5581
|
-
const
|
|
5582
|
-
|
|
6181
|
+
const steamcmd = [];
|
|
6182
|
+
if (usesWorkshop(gameConfig)) {
|
|
6183
|
+
try {
|
|
6184
|
+
status(`${game}: steamcmd ${steamcmdSource(resolveSteamcmd(config), config)}`);
|
|
6185
|
+
} catch (error) {
|
|
6186
|
+
steamcmd.push({
|
|
6187
|
+
where: "steamcmd",
|
|
6188
|
+
message: error instanceof Error ? error.message : String(error),
|
|
6189
|
+
...error instanceof GamecrateError && error.detail !== undefined ? { suggestion: error.detail } : {}
|
|
6190
|
+
});
|
|
6191
|
+
}
|
|
6192
|
+
const root = downloadRoot(config.dataRoot, gameConfig);
|
|
6193
|
+
status(`${game}: workshop downloads ${root}${existsSync11(root) ? "" : " (not created yet)"}`);
|
|
6194
|
+
}
|
|
6195
|
+
const all = [...problems, ...await preflight(plan), ...steamcmd];
|
|
5583
6196
|
if (all.length === 0) {
|
|
5584
6197
|
status(`${game}: ok`);
|
|
5585
6198
|
continue;
|
|
@@ -5603,12 +6216,12 @@ async function logs(args, config, defaults) {
|
|
|
5603
6216
|
const dir = instanceDir(args, config, game, profile);
|
|
5604
6217
|
if (args.follow)
|
|
5605
6218
|
return await follow(dir, false, `${game} ${profile}`);
|
|
5606
|
-
const runs =
|
|
6219
|
+
const runs = join22(dir, "logs", "runs");
|
|
5607
6220
|
const latest = (await readdir10(runs, { withFileTypes: true }).catch(() => [])).filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort().at(-1);
|
|
5608
6221
|
if (latest === undefined) {
|
|
5609
6222
|
throw new GamecrateError(`no runs recorded for ${game} ${profile}`, Exit.Usage, runs);
|
|
5610
6223
|
}
|
|
5611
|
-
const runDir =
|
|
6224
|
+
const runDir = join22(runs, latest);
|
|
5612
6225
|
const files = (await readdir10(runDir, { withFileTypes: true })).filter((entry) => entry.isFile()).map((entry) => entry.name).sort();
|
|
5613
6226
|
if (args.json) {
|
|
5614
6227
|
process.stdout.write(`${JSON.stringify({ run: latest, dir: runDir, files }, null, 2)}
|
|
@@ -5617,7 +6230,7 @@ async function logs(args, config, defaults) {
|
|
|
5617
6230
|
}
|
|
5618
6231
|
status(runDir);
|
|
5619
6232
|
for (const name of files) {
|
|
5620
|
-
const text = await
|
|
6233
|
+
const text = await readFile12(join22(runDir, name), "utf8").catch(() => "");
|
|
5621
6234
|
for (const line of text.split(`
|
|
5622
6235
|
`)) {
|
|
5623
6236
|
if (line.length > 0)
|
|
@@ -5745,7 +6358,7 @@ async function clean(args, config, defaults) {
|
|
|
5745
6358
|
const tier = args.cleanTier ?? "staging";
|
|
5746
6359
|
const saveSuffixes = config.games[game].saveExtensions.map((ext) => `.${ext.replace(/^\./, "")}`.toLowerCase());
|
|
5747
6360
|
if (tier !== "all") {
|
|
5748
|
-
const target =
|
|
6361
|
+
const target = join22(instanceDir(args, config, game, profile), tier === "logs" ? "logs" : ".stage");
|
|
5749
6362
|
await rm4(target, { recursive: true, force: true });
|
|
5750
6363
|
status(`removed ${target}`);
|
|
5751
6364
|
return Exit.Ok;
|
|
@@ -5771,7 +6384,7 @@ async function countSaves(dir, suffixes) {
|
|
|
5771
6384
|
}
|
|
5772
6385
|
for (const entry of entries) {
|
|
5773
6386
|
if (entry.isDirectory())
|
|
5774
|
-
queue.push(
|
|
6387
|
+
queue.push(join22(current, entry.name));
|
|
5775
6388
|
else if (suffixes.some((s) => entry.name.toLowerCase().endsWith(s)))
|
|
5776
6389
|
count++;
|
|
5777
6390
|
}
|
|
@@ -5784,11 +6397,11 @@ async function clone(args, config) {
|
|
|
5784
6397
|
if (src === undefined || dst === undefined) {
|
|
5785
6398
|
throw new GamecrateError("clone needs a source and a destination profile", Exit.Usage);
|
|
5786
6399
|
}
|
|
5787
|
-
const from =
|
|
5788
|
-
const to =
|
|
5789
|
-
if (!
|
|
6400
|
+
const from = join22(profileDataDir(config, game, src), "game");
|
|
6401
|
+
const to = join22(profileDataDir(config, game, dst), "game");
|
|
6402
|
+
if (!existsSync11(from))
|
|
5790
6403
|
throw new GamecrateError(`${from} does not exist`, Exit.Usage);
|
|
5791
|
-
if (
|
|
6404
|
+
if (existsSync11(to) && !args.yes) {
|
|
5792
6405
|
throw new GamecrateError(`${to} already exists`, Exit.Usage, "add --yes to overwrite");
|
|
5793
6406
|
}
|
|
5794
6407
|
await mkdir6(to, { recursive: true });
|
|
@@ -5830,7 +6443,7 @@ async function ps(args, config) {
|
|
|
5830
6443
|
async function stop(args, config, defaults) {
|
|
5831
6444
|
const game = requireGame(args, config);
|
|
5832
6445
|
const profile = profileOf(args, defaults);
|
|
5833
|
-
const file =
|
|
6446
|
+
const file = join22(instanceDir(args, config, game, profile), ".gamecrate", "lock");
|
|
5834
6447
|
const record = await readLock(file);
|
|
5835
6448
|
if (record === undefined) {
|
|
5836
6449
|
status(`${game} ${profile} is not running`);
|
|
@@ -5850,11 +6463,11 @@ async function attach(args, config, defaults) {
|
|
|
5850
6463
|
return await follow(instanceDir(args, config, game, profile), true, `${game} ${profile}`);
|
|
5851
6464
|
}
|
|
5852
6465
|
async function follow(dir, fromStart, what) {
|
|
5853
|
-
const lock = await readLock(
|
|
6466
|
+
const lock = await readLock(join22(dir, ".gamecrate", "lock"));
|
|
5854
6467
|
const held = lock !== undefined && isRunning(lock.pid, lock.startedAt);
|
|
5855
6468
|
const live = held && await awaitRunLog(dir, lock);
|
|
5856
6469
|
const file = currentLog(dir);
|
|
5857
|
-
if (!
|
|
6470
|
+
if (!existsSync11(file)) {
|
|
5858
6471
|
throw new GamecrateError(`no captured output for ${what}`, Exit.Usage, file);
|
|
5859
6472
|
}
|
|
5860
6473
|
return await spawnStatus(tailArgv(file, fromStart, live ? lock.pid : undefined), true);
|
|
@@ -5887,9 +6500,9 @@ async function configEdit(args) {
|
|
|
5887
6500
|
if (args.rest[0] !== "edit")
|
|
5888
6501
|
throw new GamecrateError("config takes one word: edit", Exit.Usage);
|
|
5889
6502
|
const existing = await findGlobalConfig();
|
|
5890
|
-
const path = existing ??
|
|
5891
|
-
await mkdir6(
|
|
5892
|
-
if (!
|
|
6503
|
+
const path = existing ?? join22(globalConfigDir(), "profiles.yml");
|
|
6504
|
+
await mkdir6(dirname9(path), { recursive: true });
|
|
6505
|
+
if (!existsSync11(path)) {
|
|
5893
6506
|
await writeFile7(path, `# gamecrate config. see https://github.com/RimWorks/gamecrate
|
|
5894
6507
|
` + `plugins: []
|
|
5895
6508
|
` + `games: {}
|
|
@@ -5909,7 +6522,7 @@ async function fixPerms(args, config) {
|
|
|
5909
6522
|
const identity = resolveIdentity(false);
|
|
5910
6523
|
const found = [];
|
|
5911
6524
|
for (const dir of await profileDirs(config, game, args.profile)) {
|
|
5912
|
-
if (!
|
|
6525
|
+
if (!existsSync11(dir))
|
|
5913
6526
|
continue;
|
|
5914
6527
|
found.push(...await detectForeignOwnership(dir, identity.uid, 1e4));
|
|
5915
6528
|
}
|
|
@@ -5949,7 +6562,7 @@ async function fixPerms(args, config) {
|
|
|
5949
6562
|
return Exit.Ok;
|
|
5950
6563
|
}
|
|
5951
6564
|
async function removeIfEmptyDir(path) {
|
|
5952
|
-
const info = await
|
|
6565
|
+
const info = await stat6(path).catch(() => null);
|
|
5953
6566
|
if (info === null || !info.isDirectory())
|
|
5954
6567
|
return false;
|
|
5955
6568
|
const entries = await readdir10(path).catch(() => null);
|