@gamecrate/cli 1.2.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -7
- package/dist/gamecrate.js +902 -254
- 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 +61 -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 +6 -1
- 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
|
|
4
|
+
import { existsSync as existsSync12 } from "node:fs";
|
|
5
|
+
import { chown, cp, mkdir as mkdir6, readdir as readdir11, readFile as readFile12, rm as rm5, 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 dirname10, 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";
|
|
@@ -150,7 +150,7 @@ var SUBCOMMANDS = [
|
|
|
150
150
|
summary: "tiered wipe of a profile",
|
|
151
151
|
usage: "<game> [profile]",
|
|
152
152
|
positionals: ["game", "profile"],
|
|
153
|
-
flags: ["--staging", "--logs", "--all", "--yes", "--instance", "--worktree", "--no-worktree"]
|
|
153
|
+
flags: ["--staging", "--logs", "--all", "--downloads", "--yes", "--instance", "--worktree", "--no-worktree"]
|
|
154
154
|
},
|
|
155
155
|
{
|
|
156
156
|
name: "clone",
|
|
@@ -291,7 +291,7 @@ function enumOption(flags, summary, values) {
|
|
|
291
291
|
}
|
|
292
292
|
function buildProgram() {
|
|
293
293
|
const program = new Command;
|
|
294
|
-
program.name("gamecrate").exitOverride().helpOption(false).allowExcessArguments(true).showSuggestionAfterError(false).configureOutput({ writeOut: () => {}, writeErr: () => {} }).argument("[args...]").option("--mod <id>", "add a mod to the profile set", collect, []).option("--without <id>", "drop a mod from the resolved set", collect, []).option("--only <id>", "restrict the resolved set to these mods", collect, []).option("--worktree <path>", "promote mods from this git worktree, in its own instance ($GAMECRATE_WORKTREE)", collect, []).option("--use <packageId>=<path>", "force one mod to load from this directory, whatever the profile pins", collect, []).option("--no-worktree", "ignore the current worktree and $GAMECRATE_WORKTREE").option("--instance <name>", "run under a named sub-profile with its own saves, logs and container").addOption(enumOption(`--mode <${MODES.join("|")}>`, "how the game is displayed", MODES)).option("--marker <str>", "exit 0 as soon as this string appears in the log").option("--timeout <seconds>", "bound a marker run, or a headless run with no marker", (v) => seconds("--timeout", v)).option("--render-wait <seconds>", "settle time before a screenshot is taken", (v) => seconds("--render-wait", v)).option("--resolution <width>x<height>", "override the game resolution", parseResolution).addOption(enumOption(`--network <${NETWORK_POLICIES.join("|")}>`, "the container's network mode", NETWORK_POLICIES)).option("--log <path>", "route launch stdout and stderr to one file").addOption(enumOption(`--pull <${PULL_POLICIES.join("|")}>`, "when to pull the runtime image", PULL_POLICIES)).option("--build", "build local C# mods before launching").option("--no-build", "never build, even when an assembly is stale").option("--no-stale-check", "do not warn when a mod's sources are newer than its assemblies").option("--replace", "stop whatever is holding this profile and instance, then launch").option("--no-replace", "refuse when this profile and instance are already running").option("--detach", "start the run in the background and return the prompt").option("--no-detach", "stay in the foreground, whatever the profile or project config asks for").addOption(new Option("--supervised <instanceDir>").hideHelp()).addOption(enumOption(`--sort <${SORTS.join("|")}>`, "load order: the profile order, or a topological sort", SORTS)).option("--docker-arg <arg>", "one extra argv element for docker run", collect, []).option("--dry-run", "resolve and validate fully, write nothing").option("--print-plan", "print the resolved launch plan instead of launching").option("--json", "machine-readable output").option("--root", "run as root instead of mapping the host uid").option("--staging", "clean: wipe .stage only (the default)").option("--logs", "clean: wipe the captured run logs").option("--all", "clean: wipe the whole profile
|
|
294
|
+
program.name("gamecrate").exitOverride().helpOption(false).allowExcessArguments(true).showSuggestionAfterError(false).configureOutput({ writeOut: () => {}, writeErr: () => {} }).argument("[args...]").option("--mod <id>", "add a mod to the profile set", collect, []).option("--without <id>", "drop a mod from the resolved set", collect, []).option("--only <id>", "restrict the resolved set to these mods", collect, []).option("--worktree <path>", "promote mods from this git worktree, in its own instance ($GAMECRATE_WORKTREE)", collect, []).option("--use <packageId>=<path>", "force one mod to load from this directory, whatever the profile pins", collect, []).option("--no-worktree", "ignore the current worktree and $GAMECRATE_WORKTREE").option("--instance <name>", "run under a named sub-profile with its own saves, logs and container").addOption(enumOption(`--mode <${MODES.join("|")}>`, "how the game is displayed", MODES)).option("--marker <str>", "exit 0 as soon as this string appears in the log").option("--timeout <seconds>", "bound a marker run, or a headless run with no marker", (v) => seconds("--timeout", v)).option("--render-wait <seconds>", "settle time before a screenshot is taken", (v) => seconds("--render-wait", v)).option("--resolution <width>x<height>", "override the game resolution", parseResolution).addOption(enumOption(`--network <${NETWORK_POLICIES.join("|")}>`, "the container's network mode", NETWORK_POLICIES)).option("--log <path>", "route launch stdout and stderr to one file").addOption(enumOption(`--pull <${PULL_POLICIES.join("|")}>`, "when to pull the runtime image", PULL_POLICIES)).option("--build", "build local C# mods before launching").option("--no-build", "never build, even when an assembly is stale").option("--no-stale-check", "do not warn when a mod's sources are newer than its assemblies").option("--replace", "stop whatever is holding this profile and instance, then launch").option("--no-replace", "refuse when this profile and instance are already running").option("--detach", "start the run in the background and return the prompt").option("--no-detach", "stay in the foreground, whatever the profile or project config asks for").addOption(new Option("--supervised <instanceDir>").hideHelp()).addOption(enumOption(`--sort <${SORTS.join("|")}>`, "load order: the profile order, or a topological sort", SORTS)).option("--docker-arg <arg>", "one extra argv element for docker run", collect, []).option("--dry-run", "resolve and validate fully, write nothing").option("--print-plan", "print the resolved launch plan instead of launching").option("--json", "machine-readable output").option("--root", "run as root instead of mapping the host uid").option("--staging", "clean: wipe .stage only (the default)").option("--logs", "clean: wipe the captured run logs").option("--all", "clean: wipe the whole profile and the game downloads (needs --yes)").option("--downloads", "clean: wipe this game's workshop downloads, keeping steamcmd").option("-f, --follow", "keep printing as the run writes").option("-y, --yes", "skip destructive-action confirmation").option("--path <dir>", "mods add: take the mod from this directory").option("--workshop <id>", "mods add: take the mod from this steam workshop item", workshopId).option("--git <url>", "mods add: clone the mod from this repository").option("--branch <name>", "mods add: track this git branch").option("--tag <name>", "mods add: pin this git tag").option("--commit <sha>", "mods add: pin this git commit").option("--subdir <path>", "mods add: the mod folder inside the repository").option("--global", "write to the global config").option("--project", "write to the project config").option("--force", "overwrite an entry that is already there").option("-h, --help", "this help");
|
|
295
295
|
return program;
|
|
296
296
|
}
|
|
297
297
|
function checkValueTokens(program, head) {
|
|
@@ -336,7 +336,7 @@ function parseArgs(argv, opts = {}) {
|
|
|
336
336
|
counts.set(long, (counts.get(long) ?? 0) + 1);
|
|
337
337
|
if (long === "--worktree" && value !== undefined)
|
|
338
338
|
worktree.push(value);
|
|
339
|
-
if (long === "--staging" || long === "--logs" || long === "--all") {
|
|
339
|
+
if (long === "--staging" || long === "--logs" || long === "--all" || long === "--downloads") {
|
|
340
340
|
cleanTier = long.slice(2);
|
|
341
341
|
}
|
|
342
342
|
});
|
|
@@ -796,9 +796,9 @@ function list(args, config, defaults) {
|
|
|
796
796
|
}
|
|
797
797
|
|
|
798
798
|
// src/cli/mods.ts
|
|
799
|
-
import { existsSync as
|
|
800
|
-
import { mkdir as mkdir2, readFile as
|
|
801
|
-
import { dirname as
|
|
799
|
+
import { existsSync as existsSync6 } from "node:fs";
|
|
800
|
+
import { mkdir as mkdir2, readFile as readFile7, readdir as readdir6, writeFile as writeFile3 } from "node:fs/promises";
|
|
801
|
+
import { dirname as dirname7, 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,404 @@ 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, existsSync as existsSync5, mkdirSync as mkdirSync3, statSync as statSync2 } from "node:fs";
|
|
3147
|
+
import { readdir as readdir5, rm as rm2 } from "node:fs/promises";
|
|
3148
|
+
import { delimiter, dirname as dirname5, join as join10 } from "node:path";
|
|
3149
|
+
import { setTimeout as sleep4 } from "node:timers/promises";
|
|
3150
|
+
var STEAMCMD_IMAGE = "steamcmd/steamcmd";
|
|
3151
|
+
function steamHome(dataRoot) {
|
|
3152
|
+
return join10(dataRoot, "steam");
|
|
3153
|
+
}
|
|
3154
|
+
function downloadRoot(dataRoot, game) {
|
|
3155
|
+
return join10(steamHome(dataRoot), "steamapps", "workshop", "content", String(game.steamAppId));
|
|
3156
|
+
}
|
|
3157
|
+
async function removeDownloads(root, steamAppId) {
|
|
3158
|
+
if (!existsSync5(root))
|
|
3159
|
+
return `no workshop downloads at ${root}`;
|
|
3160
|
+
const items = (await readdir5(root).catch(() => [])).length;
|
|
3161
|
+
await rm2(root, { recursive: true, force: true });
|
|
3162
|
+
await rm2(join10(dirname5(dirname5(root)), `appworkshop_${steamAppId}.acf`), { force: true });
|
|
3163
|
+
return `removed ${root} (${items} item(s))`;
|
|
3164
|
+
}
|
|
3165
|
+
function resolveSteamcmd(config) {
|
|
3166
|
+
const home = steamHome(config.dataRoot);
|
|
3167
|
+
const configured = config.steamcmd?.path === undefined ? undefined : expandHome(config.steamcmd.path);
|
|
3168
|
+
if (configured !== undefined) {
|
|
3169
|
+
if (!executable(configured)) {
|
|
3170
|
+
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");
|
|
3171
|
+
}
|
|
3172
|
+
return { kind: "host", argv: [configured], env: { HOME: home } };
|
|
3173
|
+
}
|
|
3174
|
+
const found = onPath("steamcmd");
|
|
3175
|
+
if (found !== undefined)
|
|
3176
|
+
return { kind: "host", argv: [found], env: { HOME: home } };
|
|
3177
|
+
if (onPath("docker") !== undefined) {
|
|
3178
|
+
return {
|
|
3179
|
+
kind: "docker",
|
|
3180
|
+
argv: [
|
|
3181
|
+
"docker",
|
|
3182
|
+
"run",
|
|
3183
|
+
"--rm",
|
|
3184
|
+
"--user",
|
|
3185
|
+
`${process.getuid?.() ?? 0}:${process.getgid?.() ?? 0}`,
|
|
3186
|
+
"-v",
|
|
3187
|
+
`${home}:${home}`,
|
|
3188
|
+
"-e",
|
|
3189
|
+
`HOME=${home}`,
|
|
3190
|
+
STEAMCMD_IMAGE
|
|
3191
|
+
],
|
|
3192
|
+
env: {}
|
|
3193
|
+
};
|
|
3194
|
+
}
|
|
3195
|
+
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}`);
|
|
3196
|
+
}
|
|
3197
|
+
function executable(path) {
|
|
3198
|
+
try {
|
|
3199
|
+
accessSync(path, constants.X_OK);
|
|
3200
|
+
return statSync2(path).isFile();
|
|
3201
|
+
} catch {
|
|
3202
|
+
return false;
|
|
3203
|
+
}
|
|
3204
|
+
}
|
|
3205
|
+
function onPath(name) {
|
|
3206
|
+
for (const dir of (process.env.PATH ?? "").split(delimiter)) {
|
|
3207
|
+
if (dir.length === 0)
|
|
3208
|
+
continue;
|
|
3209
|
+
const candidate = join10(dir, name);
|
|
3210
|
+
if (executable(candidate))
|
|
3211
|
+
return candidate;
|
|
3212
|
+
}
|
|
3213
|
+
return;
|
|
3214
|
+
}
|
|
3215
|
+
function workshopUrlId(url) {
|
|
3216
|
+
if (url === undefined)
|
|
3217
|
+
return;
|
|
3218
|
+
const client = /^steam:\/\/url\/CommunityFilePage\/(\d+)$/i.exec(url.trim());
|
|
3219
|
+
if (client !== null)
|
|
3220
|
+
return client[1];
|
|
3221
|
+
let parsed;
|
|
3222
|
+
try {
|
|
3223
|
+
parsed = new URL(url);
|
|
3224
|
+
} catch {
|
|
3225
|
+
return;
|
|
3226
|
+
}
|
|
3227
|
+
if (!/(^|\.)steamcommunity\.com$/.test(parsed.hostname.toLowerCase()))
|
|
3228
|
+
return;
|
|
3229
|
+
const id = parsed.searchParams.get("id");
|
|
3230
|
+
return id !== null && /^\d+$/.test(id) ? id : undefined;
|
|
3231
|
+
}
|
|
3232
|
+
var ANSI = new RegExp(`${String.fromCharCode(27)}\\[[0-9;?]*[ -/]*[@-~]`, "g");
|
|
3233
|
+
var SUCCESS = /Success\. Downloaded item (\d+) to "([^"]+)" \((\d+) bytes\)/g;
|
|
3234
|
+
var FAILED = /ERROR! Download item (\d+) failed \(([^)]+)\)/g;
|
|
3235
|
+
var ATTEMPTS = 2;
|
|
3236
|
+
var RETRY_DELAY_MS = 1000;
|
|
3237
|
+
async function downloadItems(config, game, dataRoot, ids) {
|
|
3238
|
+
const items = new Map;
|
|
3239
|
+
const warnings = [];
|
|
3240
|
+
if (ids.length === 0)
|
|
3241
|
+
return { items, warnings };
|
|
3242
|
+
const runner = resolveSteamcmd(config);
|
|
3243
|
+
mkdirSync3(steamHome(dataRoot), { recursive: true });
|
|
3244
|
+
const reasons = new Map;
|
|
3245
|
+
const release = await lockDir(steamHome(dataRoot));
|
|
3246
|
+
try {
|
|
3247
|
+
let pending = ids;
|
|
3248
|
+
for (let attempt = 0;attempt < ATTEMPTS && pending.length > 0; attempt++) {
|
|
3249
|
+
const output = run(runner, game, dataRoot, pending);
|
|
3250
|
+
for (const m of output.matchAll(SUCCESS)) {
|
|
3251
|
+
items.set(m[1], { ok: true, dir: m[2], bytes: Number(m[3]) });
|
|
3252
|
+
}
|
|
3253
|
+
for (const m of output.matchAll(FAILED))
|
|
3254
|
+
reasons.set(m[1], m[2]);
|
|
3255
|
+
pending = pending.filter((id) => !items.has(id));
|
|
3256
|
+
if (pending.length > 0 && attempt + 1 < ATTEMPTS)
|
|
3257
|
+
await sleep4(RETRY_DELAY_MS);
|
|
3258
|
+
}
|
|
3259
|
+
} finally {
|
|
3260
|
+
await release();
|
|
3261
|
+
}
|
|
3262
|
+
for (const id of ids) {
|
|
3263
|
+
if (items.has(id))
|
|
3264
|
+
continue;
|
|
3265
|
+
const reason = reasons.get(id) ?? "steamcmd reported nothing for it";
|
|
3266
|
+
items.set(id, { ok: false, reason });
|
|
3267
|
+
warnings.push(`could not download workshop item ${id}: ${reason}`);
|
|
3268
|
+
}
|
|
3269
|
+
return { items, warnings };
|
|
3270
|
+
}
|
|
3271
|
+
function run(runner, game, dataRoot, ids) {
|
|
3272
|
+
const argv = [
|
|
3273
|
+
...runner.argv,
|
|
3274
|
+
"+force_install_dir",
|
|
3275
|
+
steamHome(dataRoot),
|
|
3276
|
+
"+login",
|
|
3277
|
+
"anonymous",
|
|
3278
|
+
...ids.flatMap((id) => ["+workshop_download_item", String(game.steamAppId), id]),
|
|
3279
|
+
"+quit"
|
|
3280
|
+
];
|
|
3281
|
+
const r = spawnSync2(argv[0], argv.slice(1), {
|
|
3282
|
+
encoding: "utf8",
|
|
3283
|
+
env: { ...process.env, ...runner.env },
|
|
3284
|
+
maxBuffer: 64 * 1024 * 1024
|
|
3285
|
+
});
|
|
3286
|
+
if (r.error) {
|
|
3287
|
+
throw new GamecrateError(`could not run steamcmd: ${argv[0]}`, Exit.Environment, r.error.message);
|
|
3288
|
+
}
|
|
3289
|
+
return `${r.stdout ?? ""}
|
|
3290
|
+
${r.stderr ?? ""}`.replace(ANSI, "");
|
|
3291
|
+
}
|
|
3292
|
+
|
|
3293
|
+
// src/mods/workshopapi.ts
|
|
3294
|
+
import { readFile as readFile6, stat as stat4 } from "node:fs/promises";
|
|
3295
|
+
import { basename as basename5, dirname as dirname6, join as join11 } from "node:path";
|
|
3296
|
+
|
|
3297
|
+
// src/mods/acf.ts
|
|
3298
|
+
var MAX_DEPTH = 100;
|
|
3299
|
+
function isSpace(c) {
|
|
3300
|
+
return c === " " || c === "\t" || c === "\r" || c === `
|
|
3301
|
+
`;
|
|
3302
|
+
}
|
|
3303
|
+
function skip(text, start) {
|
|
3304
|
+
let i = start;
|
|
3305
|
+
for (;; ) {
|
|
3306
|
+
while (i < text.length && isSpace(text[i]))
|
|
3307
|
+
i += 1;
|
|
3308
|
+
if (!text.startsWith("//", i))
|
|
3309
|
+
return i;
|
|
3310
|
+
const nl = text.indexOf(`
|
|
3311
|
+
`, i);
|
|
3312
|
+
if (nl === -1)
|
|
3313
|
+
return text.length;
|
|
3314
|
+
i = nl + 1;
|
|
3315
|
+
}
|
|
3316
|
+
}
|
|
3317
|
+
function quoted(text, start) {
|
|
3318
|
+
let out = "";
|
|
3319
|
+
let i = start + 1;
|
|
3320
|
+
while (i < text.length) {
|
|
3321
|
+
const c = text[i];
|
|
3322
|
+
if (c === "\\") {
|
|
3323
|
+
if (i + 1 >= text.length)
|
|
3324
|
+
return null;
|
|
3325
|
+
out += text[i + 1];
|
|
3326
|
+
i += 2;
|
|
3327
|
+
continue;
|
|
3328
|
+
}
|
|
3329
|
+
if (c === '"')
|
|
3330
|
+
return { value: out, next: i + 1 };
|
|
3331
|
+
out += c;
|
|
3332
|
+
i += 1;
|
|
3333
|
+
}
|
|
3334
|
+
return null;
|
|
3335
|
+
}
|
|
3336
|
+
function root2(text) {
|
|
3337
|
+
const node = {};
|
|
3338
|
+
let i = skip(text, 0);
|
|
3339
|
+
while (i < text.length) {
|
|
3340
|
+
if (text[i] !== '"')
|
|
3341
|
+
return null;
|
|
3342
|
+
const next = readEntry(text, i, 0, node);
|
|
3343
|
+
if (next === null)
|
|
3344
|
+
return null;
|
|
3345
|
+
i = skip(text, next);
|
|
3346
|
+
}
|
|
3347
|
+
return node;
|
|
3348
|
+
}
|
|
3349
|
+
function block(text, start, depth) {
|
|
3350
|
+
if (depth > MAX_DEPTH)
|
|
3351
|
+
return null;
|
|
3352
|
+
const node = {};
|
|
3353
|
+
let i = skip(text, start);
|
|
3354
|
+
while (i < text.length) {
|
|
3355
|
+
if (text[i] === "}")
|
|
3356
|
+
return { node, next: i + 1 };
|
|
3357
|
+
if (text[i] !== '"')
|
|
3358
|
+
return null;
|
|
3359
|
+
const next = readEntry(text, i, depth, node);
|
|
3360
|
+
if (next === null)
|
|
3361
|
+
return null;
|
|
3362
|
+
i = skip(text, next);
|
|
3363
|
+
}
|
|
3364
|
+
return null;
|
|
3365
|
+
}
|
|
3366
|
+
function readEntry(text, start, depth, node) {
|
|
3367
|
+
const key = quoted(text, start);
|
|
3368
|
+
if (key === null)
|
|
3369
|
+
return null;
|
|
3370
|
+
const i = skip(text, key.next);
|
|
3371
|
+
if (i >= text.length)
|
|
3372
|
+
return null;
|
|
3373
|
+
if (text[i] === "{") {
|
|
3374
|
+
const child = block(text, i + 1, depth + 1);
|
|
3375
|
+
if (child === null)
|
|
3376
|
+
return null;
|
|
3377
|
+
node[key.value] = child.node;
|
|
3378
|
+
return child.next;
|
|
3379
|
+
}
|
|
3380
|
+
if (text[i] !== '"')
|
|
3381
|
+
return null;
|
|
3382
|
+
const value = quoted(text, i);
|
|
3383
|
+
if (value === null)
|
|
3384
|
+
return null;
|
|
3385
|
+
node[key.value] = value.value;
|
|
3386
|
+
return value.next;
|
|
3387
|
+
}
|
|
3388
|
+
function parseAcf(text) {
|
|
3389
|
+
return root2(text) ?? {};
|
|
3390
|
+
}
|
|
3391
|
+
function section(node, name) {
|
|
3392
|
+
const direct = node[name];
|
|
3393
|
+
if (typeof direct === "object")
|
|
3394
|
+
return direct;
|
|
3395
|
+
for (const child of Object.values(node)) {
|
|
3396
|
+
if (typeof child === "object" && typeof child[name] === "object")
|
|
3397
|
+
return child[name];
|
|
3398
|
+
}
|
|
3399
|
+
return;
|
|
3400
|
+
}
|
|
3401
|
+
function installedItems(text) {
|
|
3402
|
+
const out = new Map;
|
|
3403
|
+
const items = section(parseAcf(text), "WorkshopItemsInstalled");
|
|
3404
|
+
if (items === undefined)
|
|
3405
|
+
return out;
|
|
3406
|
+
for (const [id, entry] of Object.entries(items)) {
|
|
3407
|
+
if (typeof entry !== "object")
|
|
3408
|
+
continue;
|
|
3409
|
+
const manifest = entry["manifest"];
|
|
3410
|
+
if (typeof manifest !== "string" || manifest === "")
|
|
3411
|
+
continue;
|
|
3412
|
+
out.set(id, { manifest, timeupdated: Number(entry["timeupdated"]) || 0 });
|
|
3413
|
+
}
|
|
3414
|
+
return out;
|
|
3415
|
+
}
|
|
3416
|
+
|
|
3417
|
+
// src/mods/workshopapi.ts
|
|
3418
|
+
var ENDPOINT = "https://api.steampowered.com/ISteamRemoteStorage/GetPublishedFileDetails/v1/";
|
|
3419
|
+
var CHUNK = 100;
|
|
3420
|
+
var TIMEOUT_MS = 5000;
|
|
3421
|
+
async function checkDrift(ids, roots, fetchImpl = fetch) {
|
|
3422
|
+
const wanted = [...new Set(ids)];
|
|
3423
|
+
const { loaded, listed } = await installs(wanted, roots);
|
|
3424
|
+
const missing = [];
|
|
3425
|
+
for (const id of wanted) {
|
|
3426
|
+
if (!listed.has(id) || !await onDisk(roots, id))
|
|
3427
|
+
missing.push(id);
|
|
3428
|
+
}
|
|
3429
|
+
let details;
|
|
3430
|
+
try {
|
|
3431
|
+
details = await published(wanted, fetchImpl);
|
|
3432
|
+
} catch (error) {
|
|
3433
|
+
return {
|
|
3434
|
+
needed: missing,
|
|
3435
|
+
unavailable: [],
|
|
3436
|
+
warnings: [`could not ask steam which items changed (${reason(error)}); only missing items will download`]
|
|
3437
|
+
};
|
|
3438
|
+
}
|
|
3439
|
+
const needed = new Set(missing);
|
|
3440
|
+
const unavailable = [];
|
|
3441
|
+
const warnings = [];
|
|
3442
|
+
for (const id of wanted) {
|
|
3443
|
+
const detail = details.get(id);
|
|
3444
|
+
if (detail === undefined)
|
|
3445
|
+
continue;
|
|
3446
|
+
if (detail.result !== 1) {
|
|
3447
|
+
warnings.push(`workshop item ${id} is not available (result ${detail.result}); skipping it`);
|
|
3448
|
+
needed.delete(id);
|
|
3449
|
+
unavailable.push(id);
|
|
3450
|
+
continue;
|
|
3451
|
+
}
|
|
3452
|
+
const local = loaded.get(id);
|
|
3453
|
+
if (local !== undefined && detail.timeUpdated > local)
|
|
3454
|
+
needed.add(id);
|
|
3455
|
+
}
|
|
3456
|
+
return { needed: [...needed], unavailable, warnings };
|
|
3457
|
+
}
|
|
3458
|
+
async function installs(ids, roots) {
|
|
3459
|
+
const loaded = new Map;
|
|
3460
|
+
const listed = new Set;
|
|
3461
|
+
for (const root of roots) {
|
|
3462
|
+
const items = installedItems(await readText(acfPath(root)));
|
|
3463
|
+
for (const id of ids) {
|
|
3464
|
+
const item = items.get(id);
|
|
3465
|
+
if (item === undefined)
|
|
3466
|
+
continue;
|
|
3467
|
+
listed.add(id);
|
|
3468
|
+
if (!loaded.has(id) && await exists(join11(root, id)))
|
|
3469
|
+
loaded.set(id, item.timeupdated);
|
|
3470
|
+
}
|
|
3471
|
+
}
|
|
3472
|
+
return { loaded, listed };
|
|
3473
|
+
}
|
|
3474
|
+
async function onDisk(roots, id) {
|
|
3475
|
+
for (const root of roots) {
|
|
3476
|
+
if (await exists(join11(root, id)))
|
|
3477
|
+
return true;
|
|
3478
|
+
}
|
|
3479
|
+
return false;
|
|
3480
|
+
}
|
|
3481
|
+
async function published(ids, fetchImpl) {
|
|
3482
|
+
const out = new Map;
|
|
3483
|
+
if (ids.length === 0)
|
|
3484
|
+
return out;
|
|
3485
|
+
const abort = new AbortController;
|
|
3486
|
+
const timer = setTimeout(() => abort.abort(new Error(`no answer in ${TIMEOUT_MS}ms`)), TIMEOUT_MS);
|
|
3487
|
+
try {
|
|
3488
|
+
for (let i = 0;i < ids.length; i += CHUNK) {
|
|
3489
|
+
const chunk = ids.slice(i, i + CHUNK);
|
|
3490
|
+
const body = new URLSearchParams({ itemcount: String(chunk.length) });
|
|
3491
|
+
chunk.forEach((id, n) => body.set(`publishedfileids[${n}]`, id));
|
|
3492
|
+
const response = await fetchImpl(ENDPOINT, { method: "POST", body, signal: abort.signal });
|
|
3493
|
+
if (!response.ok)
|
|
3494
|
+
throw new Error(`steam answered ${response.status}`);
|
|
3495
|
+
for (const [id, detail] of readDetails(await response.json()))
|
|
3496
|
+
out.set(id, detail);
|
|
3497
|
+
}
|
|
3498
|
+
} finally {
|
|
3499
|
+
clearTimeout(timer);
|
|
3500
|
+
}
|
|
3501
|
+
return out;
|
|
3502
|
+
}
|
|
3503
|
+
function readDetails(payload) {
|
|
3504
|
+
const list = payload?.response?.publishedfiledetails;
|
|
3505
|
+
if (!Array.isArray(list))
|
|
3506
|
+
throw new Error("steam returned a body this does not understand");
|
|
3507
|
+
const out = new Map;
|
|
3508
|
+
for (const entry of list) {
|
|
3509
|
+
const id = entry["publishedfileid"];
|
|
3510
|
+
const result = entry["result"];
|
|
3511
|
+
if (typeof id !== "string" || typeof result !== "number") {
|
|
3512
|
+
throw new Error("steam returned a body this does not understand");
|
|
3513
|
+
}
|
|
3514
|
+
out.set(id, { result, timeUpdated: Number(entry["time_updated"]) || 0 });
|
|
3515
|
+
}
|
|
3516
|
+
return out;
|
|
3517
|
+
}
|
|
3518
|
+
function acfPath(root) {
|
|
3519
|
+
return join11(dirname6(dirname6(root)), `appworkshop_${basename5(root)}.acf`);
|
|
3520
|
+
}
|
|
3521
|
+
async function exists(dir) {
|
|
3522
|
+
try {
|
|
3523
|
+
return (await stat4(dir)).isDirectory();
|
|
3524
|
+
} catch {
|
|
3525
|
+
return false;
|
|
3526
|
+
}
|
|
3527
|
+
}
|
|
3528
|
+
async function readText(path) {
|
|
3529
|
+
try {
|
|
3530
|
+
return await readFile6(path, "utf8");
|
|
3531
|
+
} catch {
|
|
3532
|
+
return "";
|
|
3533
|
+
}
|
|
3534
|
+
}
|
|
3535
|
+
function reason(error) {
|
|
3536
|
+
return error instanceof Error ? error.message : String(error);
|
|
3537
|
+
}
|
|
3538
|
+
|
|
3133
3539
|
// src/cli/mods.ts
|
|
3134
3540
|
async function globalConfigPath() {
|
|
3135
|
-
return await findGlobalConfig() ??
|
|
3541
|
+
return await findGlobalConfig() ?? join12(globalConfigDir(), "profiles.yml");
|
|
3136
3542
|
}
|
|
3137
3543
|
async function modsAdd(args, ctx) {
|
|
3138
3544
|
const game = requireGame(args, ctx.config);
|
|
@@ -3141,9 +3547,6 @@ async function modsAdd(args, ctx) {
|
|
|
3141
3547
|
if (source === undefined) {
|
|
3142
3548
|
throw new GamecrateError("mods add needs one of --path, --workshop or --git", Exit.Usage);
|
|
3143
3549
|
}
|
|
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
3550
|
const target = await resolveTarget(args, ctx, game);
|
|
3148
3551
|
const pins = await discover(source, gameConfig, requirePlugin(ctx.plugins, game), ctx);
|
|
3149
3552
|
if (pins.length === 0) {
|
|
@@ -3175,8 +3578,8 @@ overwrite them with --force`);
|
|
|
3175
3578
|
edits.push({ path: [...target.prefix, old], value: undefined });
|
|
3176
3579
|
edits.push({ path: [...target.prefix, pin.id], value: pin.entry });
|
|
3177
3580
|
}
|
|
3178
|
-
if (!
|
|
3179
|
-
await mkdir2(
|
|
3581
|
+
if (!existsSync6(target.file)) {
|
|
3582
|
+
await mkdir2(dirname7(target.file), { recursive: true });
|
|
3180
3583
|
await writeFile3(target.file, "");
|
|
3181
3584
|
}
|
|
3182
3585
|
await writeConfig(target.file, edits);
|
|
@@ -3205,21 +3608,27 @@ async function modsSync(args, ctx) {
|
|
|
3205
3608
|
const only = args.rest.map((id) => id.toLowerCase());
|
|
3206
3609
|
const games = args.game === undefined ? Object.keys(ctx.config.games) : [requireGame(args, ctx.config)];
|
|
3207
3610
|
const wanted = [];
|
|
3611
|
+
const subscribed = [];
|
|
3208
3612
|
for (const name of games) {
|
|
3613
|
+
const pins = [];
|
|
3209
3614
|
for (const [id, entry] of Object.entries(ctx.config.games[name]?.library ?? {})) {
|
|
3210
|
-
if (entry.git === undefined)
|
|
3211
|
-
continue;
|
|
3212
3615
|
if (only.length > 0 && !only.includes(id.toLowerCase()))
|
|
3213
3616
|
continue;
|
|
3214
|
-
|
|
3617
|
+
if (entry.git !== undefined)
|
|
3618
|
+
wanted.push({ id, git: entry.git, entry });
|
|
3619
|
+
else if (entry.workshop !== undefined)
|
|
3620
|
+
pins.push({ id, item: String(entry.workshop) });
|
|
3215
3621
|
}
|
|
3622
|
+
if (pins.length > 0)
|
|
3623
|
+
subscribed.push({ game: name, pins });
|
|
3216
3624
|
}
|
|
3217
|
-
const
|
|
3625
|
+
const named = [...wanted.map((pin) => pin.id), ...subscribed.flatMap((one) => one.pins.map((pin) => pin.id))];
|
|
3626
|
+
const missing = only.filter((id) => !named.some((pinned) => pinned.toLowerCase() === id));
|
|
3218
3627
|
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(`
|
|
3628
|
+
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
3629
|
`));
|
|
3221
3630
|
}
|
|
3222
|
-
if (wanted.length === 0) {
|
|
3631
|
+
if (wanted.length === 0 && subscribed.length === 0) {
|
|
3223
3632
|
status("nothing to sync");
|
|
3224
3633
|
return Exit.Ok;
|
|
3225
3634
|
}
|
|
@@ -3235,7 +3644,7 @@ async function modsSync(args, ctx) {
|
|
|
3235
3644
|
const dir = cloneDir(ctx.config.dataRoot, pin.git, ref);
|
|
3236
3645
|
if (!fetched.has(dir)) {
|
|
3237
3646
|
fetched.add(dir);
|
|
3238
|
-
const unlock = await
|
|
3647
|
+
const unlock = await lockDir(dir);
|
|
3239
3648
|
try {
|
|
3240
3649
|
const result = await ensureClone(ctx.config.dataRoot, gitPin(pin.git, pin.entry), ref, "force");
|
|
3241
3650
|
if (result.warning !== undefined)
|
|
@@ -3246,8 +3655,29 @@ async function modsSync(args, ctx) {
|
|
|
3246
3655
|
}
|
|
3247
3656
|
status(`synced ${pin.id} at ${ref.kind} ${ref.value}`);
|
|
3248
3657
|
}
|
|
3658
|
+
for (const one of subscribed)
|
|
3659
|
+
await syncWorkshop(ctx, one.game, one.pins);
|
|
3249
3660
|
return Exit.Ok;
|
|
3250
3661
|
}
|
|
3662
|
+
async function syncWorkshop(ctx, name, pins) {
|
|
3663
|
+
const game = ctx.config.games[name];
|
|
3664
|
+
const drift = await checkDrift(pins.map((pin) => pin.item), [downloadRoot(ctx.config.dataRoot, game)], ctx.fetch);
|
|
3665
|
+
for (const line of drift.warnings)
|
|
3666
|
+
warn(line);
|
|
3667
|
+
const report = drift.needed.length === 0 ? undefined : await downloadItems(ctx.config, game, ctx.config.dataRoot, drift.needed);
|
|
3668
|
+
for (const line of report?.warnings ?? [])
|
|
3669
|
+
warn(line);
|
|
3670
|
+
const unavailable = new Set(drift.unavailable);
|
|
3671
|
+
for (const pin of pins) {
|
|
3672
|
+
const outcome = report?.items.get(pin.item);
|
|
3673
|
+
if (unavailable.has(pin.item))
|
|
3674
|
+
status(`${pin.id} is unavailable at workshop item ${pin.item}`);
|
|
3675
|
+
else if (outcome === undefined)
|
|
3676
|
+
status(`${pin.id} is up to date at workshop item ${pin.item}`);
|
|
3677
|
+
else if (outcome.ok)
|
|
3678
|
+
status(`synced ${pin.id} at workshop item ${pin.item}`);
|
|
3679
|
+
}
|
|
3680
|
+
}
|
|
3251
3681
|
function gitPin(url, entry) {
|
|
3252
3682
|
return { url, ...entry.subdir === undefined ? {} : { subdir: entry.subdir } };
|
|
3253
3683
|
}
|
|
@@ -3326,13 +3756,18 @@ async function discover(source, game, plugin, ctx) {
|
|
|
3326
3756
|
return id === undefined ? [] : [{ id, entry: { path: dir } }];
|
|
3327
3757
|
}
|
|
3328
3758
|
if (source.kind === "workshop") {
|
|
3329
|
-
const
|
|
3330
|
-
const
|
|
3759
|
+
const item = String(source.value);
|
|
3760
|
+
const report = await downloadItems(ctx.config, game, ctx.config.dataRoot, [item]);
|
|
3761
|
+
const outcome = report.items.get(item);
|
|
3762
|
+
if (outcome?.ok !== true) {
|
|
3763
|
+
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}`);
|
|
3764
|
+
}
|
|
3765
|
+
const id = await readId(outcome.dir, game.manifest.file, plugin);
|
|
3331
3766
|
return id === undefined ? [] : [{ id, entry: { workshop: source.value } }];
|
|
3332
3767
|
}
|
|
3333
3768
|
const ref = source.ref ?? defaultBranch(source.url);
|
|
3334
3769
|
const dir = cloneDir(ctx.config.dataRoot, source.url, ref);
|
|
3335
|
-
const unlock = await
|
|
3770
|
+
const unlock = await lockDir(dir);
|
|
3336
3771
|
let root;
|
|
3337
3772
|
try {
|
|
3338
3773
|
const result = await ensureClone(ctx.config.dataRoot, { url: source.url }, ref, "fetch");
|
|
@@ -3342,7 +3777,7 @@ async function discover(source, game, plugin, ctx) {
|
|
|
3342
3777
|
} finally {
|
|
3343
3778
|
await unlock();
|
|
3344
3779
|
}
|
|
3345
|
-
const start = source.subdir === undefined ? root :
|
|
3780
|
+
const start = source.subdir === undefined ? root : join12(root, source.subdir);
|
|
3346
3781
|
const found = await walk(start, game.manifest.file, plugin);
|
|
3347
3782
|
return found.map(({ id, dir: at }) => {
|
|
3348
3783
|
const entry = { git: source.url };
|
|
@@ -3367,17 +3802,17 @@ async function walk(root, manifestFile, plugin) {
|
|
|
3367
3802
|
out.push({ id, dir });
|
|
3368
3803
|
return;
|
|
3369
3804
|
}
|
|
3370
|
-
const entries = await
|
|
3805
|
+
const entries = await readdir6(dir, { withFileTypes: true }).catch(() => []);
|
|
3371
3806
|
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
3372
3807
|
if (entry.isDirectory() && !entry.name.startsWith("."))
|
|
3373
|
-
await descend(
|
|
3808
|
+
await descend(join12(dir, entry.name));
|
|
3374
3809
|
}
|
|
3375
3810
|
};
|
|
3376
3811
|
await descend(root);
|
|
3377
3812
|
return out;
|
|
3378
3813
|
}
|
|
3379
3814
|
async function readId(dir, manifestFile, plugin) {
|
|
3380
|
-
const text = await
|
|
3815
|
+
const text = await readFile7(join12(dir, manifestFile), "utf8").catch(() => {
|
|
3381
3816
|
return;
|
|
3382
3817
|
});
|
|
3383
3818
|
if (text === undefined)
|
|
@@ -3462,8 +3897,8 @@ function gameHelp(name, game) {
|
|
|
3462
3897
|
notes.push(`alias for ${config.alias}`);
|
|
3463
3898
|
if (config.extends)
|
|
3464
3899
|
notes.push(`extends ${config.extends}`);
|
|
3465
|
-
if (config.autoDependencies)
|
|
3466
|
-
notes.push("auto dependencies");
|
|
3900
|
+
if (config.autoDependencies === false)
|
|
3901
|
+
notes.push("no auto dependencies");
|
|
3467
3902
|
const count = config.mods?.length ?? 0;
|
|
3468
3903
|
if (!config.alias)
|
|
3469
3904
|
notes.push(count === 1 ? "1 entry" : `${count} entries`);
|
|
@@ -3585,9 +4020,9 @@ function hostUserName(uid) {
|
|
|
3585
4020
|
}
|
|
3586
4021
|
|
|
3587
4022
|
// src/docker/preflight.ts
|
|
3588
|
-
import { existsSync as
|
|
4023
|
+
import { existsSync as existsSync7, readFileSync as readFileSync3 } from "node:fs";
|
|
3589
4024
|
import { homedir as homedir3 } from "node:os";
|
|
3590
|
-
import { basename as
|
|
4025
|
+
import { basename as basename6, join as join13 } from "node:path";
|
|
3591
4026
|
var CDI_SPEC = "/etc/cdi/nvidia.yaml";
|
|
3592
4027
|
async function preflight(plan) {
|
|
3593
4028
|
const problems = [];
|
|
@@ -3687,7 +4122,7 @@ function checkDisplay(plan, problems) {
|
|
|
3687
4122
|
});
|
|
3688
4123
|
}
|
|
3689
4124
|
function checkCdi(problems) {
|
|
3690
|
-
if (!
|
|
4125
|
+
if (!existsSync7(CDI_SPEC)) {
|
|
3691
4126
|
problems.push({
|
|
3692
4127
|
where: CDI_SPEC,
|
|
3693
4128
|
message: "no CDI spec, so --device nvidia.com/gpu=all cannot resolve",
|
|
@@ -3719,15 +4154,15 @@ function checkGameDir(plan, problems) {
|
|
|
3719
4154
|
problems.push({ where, message: 'gameFiles.source is "mount" but no host path is set' });
|
|
3720
4155
|
return;
|
|
3721
4156
|
}
|
|
3722
|
-
if (!
|
|
4157
|
+
if (!existsSync7(files.host)) {
|
|
3723
4158
|
problems.push({ where, message: `game directory does not exist: ${files.host}` });
|
|
3724
4159
|
return;
|
|
3725
4160
|
}
|
|
3726
|
-
const executable =
|
|
3727
|
-
if (!
|
|
4161
|
+
const executable = join13(files.host, basename6(plan.gameConfig.executable));
|
|
4162
|
+
if (!existsSync7(executable)) {
|
|
3728
4163
|
problems.push({
|
|
3729
4164
|
where,
|
|
3730
|
-
message: `${files.host} does not contain ${
|
|
4165
|
+
message: `${files.host} does not contain ${basename6(plan.gameConfig.executable)}`,
|
|
3731
4166
|
suggestion: "point gameFiles.host at the install directory, not its parent"
|
|
3732
4167
|
});
|
|
3733
4168
|
}
|
|
@@ -3747,7 +4182,7 @@ function checkBindSources(plan, problems) {
|
|
|
3747
4182
|
for (const mount of mounts) {
|
|
3748
4183
|
if (mount.type !== "bind" || !mount.source)
|
|
3749
4184
|
continue;
|
|
3750
|
-
if (
|
|
4185
|
+
if (existsSync7(mount.source))
|
|
3751
4186
|
continue;
|
|
3752
4187
|
if (mount.source.startsWith(plan.profileDir))
|
|
3753
4188
|
continue;
|
|
@@ -3769,7 +4204,7 @@ function needsLogin(stderr) {
|
|
|
3769
4204
|
return /unauthorized|authentication required|denied|forbidden/i.test(stderr);
|
|
3770
4205
|
}
|
|
3771
4206
|
function hasStoredAuth(host) {
|
|
3772
|
-
const path =
|
|
4207
|
+
const path = join13(process.env.DOCKER_CONFIG ?? join13(homedir3(), ".docker"), "config.json");
|
|
3773
4208
|
try {
|
|
3774
4209
|
const config = JSON.parse(readFileSync3(path, "utf8"));
|
|
3775
4210
|
if (config.credsStore || config.credHelpers?.[host])
|
|
@@ -3789,12 +4224,12 @@ function message(error) {
|
|
|
3789
4224
|
|
|
3790
4225
|
// src/docker/window.ts
|
|
3791
4226
|
import { readFileSync as readFileSync4 } from "node:fs";
|
|
3792
|
-
import { basename as
|
|
3793
|
-
import { setTimeout as
|
|
4227
|
+
import { basename as basename7 } from "node:path";
|
|
4228
|
+
import { setTimeout as sleep5 } from "node:timers/promises";
|
|
3794
4229
|
var WAIT_MS = 180000;
|
|
3795
4230
|
var POLL_MS = 500;
|
|
3796
4231
|
function newMatches(now, seen, executable) {
|
|
3797
|
-
const wanted =
|
|
4232
|
+
const wanted = basename7(executable).toLowerCase();
|
|
3798
4233
|
return now.filter((w) => !seen.has(w.id) && w.wmClass.toLowerCase().includes(wanted));
|
|
3799
4234
|
}
|
|
3800
4235
|
function parseWindowPid(stdout) {
|
|
@@ -3807,7 +4242,7 @@ function isPeerClaim(pid, self) {
|
|
|
3807
4242
|
return false;
|
|
3808
4243
|
try {
|
|
3809
4244
|
const argv = readFileSync4(`/proc/${pid}/cmdline`, "utf8").split("\x00");
|
|
3810
|
-
return argv.some((arg) =>
|
|
4245
|
+
return argv.some((arg) => basename7(arg).startsWith("gamecrate"));
|
|
3811
4246
|
} catch {
|
|
3812
4247
|
return false;
|
|
3813
4248
|
}
|
|
@@ -3841,7 +4276,7 @@ async function adoptNewWindow(opts) {
|
|
|
3841
4276
|
(async () => {
|
|
3842
4277
|
const deadline = Date.now() + WAIT_MS;
|
|
3843
4278
|
while (!stopped && Date.now() < deadline) {
|
|
3844
|
-
await
|
|
4279
|
+
await sleep5(POLL_MS);
|
|
3845
4280
|
if (stopped)
|
|
3846
4281
|
return;
|
|
3847
4282
|
for (const match of newMatches(await toplevels() ?? [], seen, opts.executable)) {
|
|
@@ -3863,7 +4298,7 @@ async function adoptNewWindow(opts) {
|
|
|
3863
4298
|
async function watchForClose(id, stopped, onClosed) {
|
|
3864
4299
|
let misses = 0;
|
|
3865
4300
|
while (!stopped()) {
|
|
3866
|
-
await
|
|
4301
|
+
await sleep5(POLL_MS);
|
|
3867
4302
|
if (stopped())
|
|
3868
4303
|
return;
|
|
3869
4304
|
const now = await toplevels();
|
|
@@ -3921,15 +4356,15 @@ function parseAtoms(stdout) {
|
|
|
3921
4356
|
}
|
|
3922
4357
|
|
|
3923
4358
|
// src/launch/generate.ts
|
|
3924
|
-
import { mkdir as mkdir3, readdir as
|
|
3925
|
-
import { dirname as
|
|
4359
|
+
import { mkdir as mkdir3, readdir as readdir7, readFile as readFile8, writeFile as writeFile4 } from "node:fs/promises";
|
|
4360
|
+
import { dirname as dirname8, join as join14 } from "node:path";
|
|
3926
4361
|
async function readInstallVersion(game, plugin) {
|
|
3927
4362
|
const host = game.gameFiles.host;
|
|
3928
4363
|
if (game.gameFiles.source !== "mount" || host === undefined)
|
|
3929
4364
|
return null;
|
|
3930
4365
|
let raw;
|
|
3931
4366
|
try {
|
|
3932
|
-
raw = await
|
|
4367
|
+
raw = await readFile8(join14(expandHome(host), "Version.txt"), "utf8");
|
|
3933
4368
|
} catch {
|
|
3934
4369
|
return null;
|
|
3935
4370
|
}
|
|
@@ -3941,10 +4376,10 @@ async function readKnownExpansions(game, plugin, warnings) {
|
|
|
3941
4376
|
const host = game.gameFiles.host;
|
|
3942
4377
|
if (game.gameFiles.source !== "mount" || host === undefined)
|
|
3943
4378
|
return [...game.dlc];
|
|
3944
|
-
const dataDir =
|
|
4379
|
+
const dataDir = join14(expandHome(host), "Data");
|
|
3945
4380
|
let entries;
|
|
3946
4381
|
try {
|
|
3947
|
-
entries = await
|
|
4382
|
+
entries = await readdir7(dataDir, { withFileTypes: true });
|
|
3948
4383
|
} catch {
|
|
3949
4384
|
warnings.push(`could not read ${dataDir}; falling back to the configured dlc list`);
|
|
3950
4385
|
return [...game.dlc];
|
|
@@ -3955,7 +4390,7 @@ async function readKnownExpansions(game, plugin, warnings) {
|
|
|
3955
4390
|
continue;
|
|
3956
4391
|
let id = null;
|
|
3957
4392
|
try {
|
|
3958
|
-
id = plugin.parseManifest(await
|
|
4393
|
+
id = plugin.parseManifest(await readFile8(join14(dataDir, entry.name, game.manifest.file), "utf8"))?.packageId ?? null;
|
|
3959
4394
|
} catch {
|
|
3960
4395
|
continue;
|
|
3961
4396
|
}
|
|
@@ -3971,8 +4406,8 @@ async function readKnownExpansions(game, plugin, warnings) {
|
|
|
3971
4406
|
}
|
|
3972
4407
|
async function generateModsConfig(plan) {
|
|
3973
4408
|
const game = plan.gameConfig;
|
|
3974
|
-
const target =
|
|
3975
|
-
await mkdir3(
|
|
4409
|
+
const target = join14(plan.dataDirHost, game.modsConfig.file);
|
|
4410
|
+
await mkdir3(dirname8(target), { recursive: true });
|
|
3976
4411
|
const installed = await readInstallVersion(game, plan.plugin);
|
|
3977
4412
|
if (installed === null) {
|
|
3978
4413
|
plan.warnings.push(`could not read Version.txt for ${plan.game}; ModsConfig version may be rejected`);
|
|
@@ -4022,11 +4457,11 @@ function ownedPrefs(plan) {
|
|
|
4022
4457
|
return owned;
|
|
4023
4458
|
}
|
|
4024
4459
|
async function mergePrefs(plan) {
|
|
4025
|
-
const target =
|
|
4026
|
-
await mkdir3(
|
|
4460
|
+
const target = join14(plan.dataDirHost, plan.gameConfig.prefs.file);
|
|
4461
|
+
await mkdir3(dirname8(target), { recursive: true });
|
|
4027
4462
|
let existing = null;
|
|
4028
4463
|
try {
|
|
4029
|
-
existing = await
|
|
4464
|
+
existing = await readFile8(target, "utf8");
|
|
4030
4465
|
} catch (error) {
|
|
4031
4466
|
if (error.code !== "ENOENT")
|
|
4032
4467
|
throw error;
|
|
@@ -4036,10 +4471,10 @@ async function mergePrefs(plan) {
|
|
|
4036
4471
|
}
|
|
4037
4472
|
|
|
4038
4473
|
// src/launch/supervisor.ts
|
|
4039
|
-
import { existsSync as
|
|
4040
|
-
import { readFile as
|
|
4041
|
-
import { basename as
|
|
4042
|
-
import { setTimeout as
|
|
4474
|
+
import { existsSync as existsSync8 } from "node:fs";
|
|
4475
|
+
import { readFile as readFile9, readlink, rm as rm3, writeFile as writeFile5 } from "node:fs/promises";
|
|
4476
|
+
import { basename as basename8, join as join15 } from "node:path";
|
|
4477
|
+
import { setTimeout as sleep6 } from "node:timers/promises";
|
|
4043
4478
|
async function forkSupervisor(plan, argv) {
|
|
4044
4479
|
await clearLock(plan);
|
|
4045
4480
|
const name = containerName(plan);
|
|
@@ -4096,15 +4531,15 @@ async function supervisorFailed(dir, code) {
|
|
|
4096
4531
|
const record = { at: new Date().toISOString(), code, reason: "failed" };
|
|
4097
4532
|
const wrote = await writeExit(dir, record).then(() => true, () => false);
|
|
4098
4533
|
if (wrote)
|
|
4099
|
-
await
|
|
4534
|
+
await rm3(join15(dir, ".gamecrate", "lock"), { force: true }).catch(() => {});
|
|
4100
4535
|
return code;
|
|
4101
4536
|
}
|
|
4102
4537
|
async function writeExit(instanceDir, record) {
|
|
4103
|
-
await writeFile5(
|
|
4538
|
+
await writeFile5(join15(instanceDir, ".gamecrate", "last-exit.json"), `${JSON.stringify(record)}
|
|
4104
4539
|
`);
|
|
4105
4540
|
}
|
|
4106
4541
|
async function lastExit(instanceDir) {
|
|
4107
|
-
const text = await
|
|
4542
|
+
const text = await readFile9(join15(instanceDir, ".gamecrate", "last-exit.json"), "utf8").catch(() => {
|
|
4108
4543
|
return;
|
|
4109
4544
|
});
|
|
4110
4545
|
if (text === undefined)
|
|
@@ -4127,7 +4562,7 @@ function endedAfter(record, notBefore) {
|
|
|
4127
4562
|
return !Number.isNaN(at) && at >= began;
|
|
4128
4563
|
}
|
|
4129
4564
|
async function awaitExit(instanceDir, poll = WAIT_POLL_MS) {
|
|
4130
|
-
const file =
|
|
4565
|
+
const file = join15(instanceDir, ".gamecrate", "lock");
|
|
4131
4566
|
let watching;
|
|
4132
4567
|
for (;; ) {
|
|
4133
4568
|
const lock = await readLock(file) ?? watching;
|
|
@@ -4139,12 +4574,12 @@ async function awaitExit(instanceDir, poll = WAIT_POLL_MS) {
|
|
|
4139
4574
|
if (!isRunning(lock.pid, lock.startedAt))
|
|
4140
4575
|
return "orphaned";
|
|
4141
4576
|
watching = lock;
|
|
4142
|
-
await
|
|
4577
|
+
await sleep6(poll);
|
|
4143
4578
|
}
|
|
4144
4579
|
}
|
|
4145
4580
|
var RUN_LOG_POLL_MS = 250;
|
|
4146
4581
|
async function awaitRunLog(instanceDir, lock, poll = RUN_LOG_POLL_MS, notice = WAIT_NOTICE) {
|
|
4147
|
-
const link =
|
|
4582
|
+
const link = join15(instanceDir, "logs", "current");
|
|
4148
4583
|
const written = Date.parse(lock.startedAt);
|
|
4149
4584
|
const since = Date.now();
|
|
4150
4585
|
let lastNotice = 0;
|
|
@@ -4152,9 +4587,9 @@ async function awaitRunLog(instanceDir, lock, poll = RUN_LOG_POLL_MS, notice = W
|
|
|
4152
4587
|
const target = await readlink(link).catch(() => {
|
|
4153
4588
|
return;
|
|
4154
4589
|
});
|
|
4155
|
-
const began = target === undefined ? undefined : runStartedAt(
|
|
4590
|
+
const began = target === undefined ? undefined : runStartedAt(basename8(target));
|
|
4156
4591
|
const current = began !== undefined && (Number.isNaN(written) || began >= written);
|
|
4157
|
-
if (current &&
|
|
4592
|
+
if (current && existsSync8(currentLog(instanceDir)))
|
|
4158
4593
|
return true;
|
|
4159
4594
|
if (!isRunning(lock.pid, lock.startedAt))
|
|
4160
4595
|
return false;
|
|
@@ -4164,20 +4599,20 @@ async function awaitRunLog(instanceDir, lock, poll = RUN_LOG_POLL_MS, notice = W
|
|
|
4164
4599
|
status(`waiting for ${lock.game} ${lock.profile} to open its log (${label})`);
|
|
4165
4600
|
lastNotice = waited;
|
|
4166
4601
|
}
|
|
4167
|
-
await
|
|
4602
|
+
await sleep6(poll);
|
|
4168
4603
|
}
|
|
4169
4604
|
}
|
|
4170
4605
|
|
|
4171
4606
|
// src/launch/instance.ts
|
|
4172
4607
|
import { createHash as createHash2 } from "node:crypto";
|
|
4173
|
-
import { basename as
|
|
4608
|
+
import { basename as basename9, join as join16 } from "node:path";
|
|
4174
4609
|
|
|
4175
4610
|
// src/mods/worktree.ts
|
|
4176
|
-
import { spawnSync as
|
|
4177
|
-
import { existsSync as
|
|
4611
|
+
import { spawnSync as spawnSync3 } from "node:child_process";
|
|
4612
|
+
import { existsSync as existsSync9, realpathSync as realpathSync2 } from "node:fs";
|
|
4178
4613
|
import { isAbsolute as isAbsolute2, resolve as resolve5, sep } from "node:path";
|
|
4179
4614
|
function inspect(dir) {
|
|
4180
|
-
const r =
|
|
4615
|
+
const r = spawnSync3("git", ["-C", dir, "rev-parse", "--path-format=absolute", "--show-toplevel", "--git-dir", "--git-common-dir", "--abbrev-ref", "HEAD"], { encoding: "utf8" });
|
|
4181
4616
|
if (r.status !== 0 || typeof r.stdout !== "string")
|
|
4182
4617
|
return null;
|
|
4183
4618
|
const lines = r.stdout.trim().split(`
|
|
@@ -4197,7 +4632,7 @@ function canonical(p) {
|
|
|
4197
4632
|
function resolveWorktree(dir, source, order) {
|
|
4198
4633
|
const raw = expandHome(dir);
|
|
4199
4634
|
const abs = isAbsolute2(raw) ? raw : resolve5(process.cwd(), raw);
|
|
4200
|
-
if (!
|
|
4635
|
+
if (!existsSync9(abs)) {
|
|
4201
4636
|
return { where: abs, message: `--worktree path does not exist`, suggestion: "check the path, or drop the flag" };
|
|
4202
4637
|
}
|
|
4203
4638
|
const info = inspect(abs);
|
|
@@ -4252,7 +4687,7 @@ function resolveInstance(options) {
|
|
|
4252
4687
|
const name = args.instance === undefined ? derive(requests) : named(args.instance);
|
|
4253
4688
|
return {
|
|
4254
4689
|
...name === undefined ? {} : { name },
|
|
4255
|
-
dir: name === undefined ? profileDir :
|
|
4690
|
+
dir: name === undefined ? profileDir : join16(profileDir, "instances", name),
|
|
4256
4691
|
requests,
|
|
4257
4692
|
problems,
|
|
4258
4693
|
...configured?.settings === undefined ? {} : { settings: configured.settings }
|
|
@@ -4283,21 +4718,22 @@ function derive(requests) {
|
|
|
4283
4718
|
return `${slug2(first.root)}-${digest.slice(0, 6)}`;
|
|
4284
4719
|
}
|
|
4285
4720
|
function slug2(root) {
|
|
4286
|
-
const body =
|
|
4721
|
+
const body = basename9(root).toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^[^a-z0-9]+/, "").slice(0, SLUG_LIMIT2).replace(/[-._]+$/, "");
|
|
4287
4722
|
return body === "" ? "wt" : body;
|
|
4288
4723
|
}
|
|
4289
4724
|
|
|
4290
4725
|
// src/launch/resolve.ts
|
|
4291
|
-
import { join as
|
|
4726
|
+
import { join as join18 } from "node:path";
|
|
4292
4727
|
|
|
4293
4728
|
// src/mods/modindex.ts
|
|
4294
|
-
import {
|
|
4295
|
-
import {
|
|
4729
|
+
import { createHash as createHash3 } from "node:crypto";
|
|
4730
|
+
import { existsSync as existsSync10, readFileSync as readFileSync5, statSync as statSync3 } from "node:fs";
|
|
4731
|
+
import { mkdir as mkdir4, readdir as readdir8, readFile as readFile10, writeFile as writeFile6 } from "node:fs/promises";
|
|
4296
4732
|
import { homedir as homedir4 } from "node:os";
|
|
4297
|
-
import { dirname as
|
|
4733
|
+
import { dirname as dirname9, join as join17, relative as relative3, sep as sep2, resolve as resolvePath } from "node:path";
|
|
4298
4734
|
import picomatch from "picomatch";
|
|
4299
4735
|
function cacheDir() {
|
|
4300
|
-
return
|
|
4736
|
+
return join17(process.env["XDG_CACHE_HOME"] ?? join17(homedir4(), ".cache"), "gamecrate");
|
|
4301
4737
|
}
|
|
4302
4738
|
function globMatch(pattern, path) {
|
|
4303
4739
|
return picomatch.isMatch(path, pattern.replace(/[[\]{}()!,@+|^$.\\]/g, "\\$&"), { dot: true });
|
|
@@ -4309,10 +4745,10 @@ var ALWAYS_EXCLUDE = ["**/.worktrees/**", "**/.claude/worktrees/**"];
|
|
|
4309
4745
|
function inLinkedWorktree(dir, stopAt) {
|
|
4310
4746
|
let current = dir;
|
|
4311
4747
|
for (;; ) {
|
|
4312
|
-
const git =
|
|
4313
|
-
if (
|
|
4748
|
+
const git = join17(current, ".git");
|
|
4749
|
+
if (existsSync10(git)) {
|
|
4314
4750
|
try {
|
|
4315
|
-
if (
|
|
4751
|
+
if (statSync3(git).isFile())
|
|
4316
4752
|
return true;
|
|
4317
4753
|
} catch {
|
|
4318
4754
|
return false;
|
|
@@ -4321,7 +4757,7 @@ function inLinkedWorktree(dir, stopAt) {
|
|
|
4321
4757
|
}
|
|
4322
4758
|
if (current === stopAt)
|
|
4323
4759
|
return false;
|
|
4324
|
-
const parent =
|
|
4760
|
+
const parent = dirname9(current);
|
|
4325
4761
|
if (parent === current)
|
|
4326
4762
|
return false;
|
|
4327
4763
|
current = parent;
|
|
@@ -4331,8 +4767,8 @@ async function scanLocalRoot(root, rootIndex, manifestFile, found) {
|
|
|
4331
4767
|
const base = resolvePath(expandHome(root.path));
|
|
4332
4768
|
const exclude = [...root.exclude ?? [], ...rootIndex === -1 ? [] : ALWAYS_EXCLUDE];
|
|
4333
4769
|
const walk = async (dir, depth) => {
|
|
4334
|
-
const manifest =
|
|
4335
|
-
if (
|
|
4770
|
+
const manifest = join17(dir, manifestFile);
|
|
4771
|
+
if (existsSync10(manifest)) {
|
|
4336
4772
|
found.push({
|
|
4337
4773
|
dir,
|
|
4338
4774
|
kind: "local",
|
|
@@ -4345,20 +4781,20 @@ async function scanLocalRoot(root, rootIndex, manifestFile, found) {
|
|
|
4345
4781
|
return;
|
|
4346
4782
|
let entries;
|
|
4347
4783
|
try {
|
|
4348
|
-
entries = await
|
|
4784
|
+
entries = await readdir8(dir, { withFileTypes: true });
|
|
4349
4785
|
} catch {
|
|
4350
4786
|
return;
|
|
4351
4787
|
}
|
|
4352
4788
|
for (const entry of entries) {
|
|
4353
4789
|
if (!entry.isDirectory() || entry.name.startsWith(".git"))
|
|
4354
4790
|
continue;
|
|
4355
|
-
const child =
|
|
4791
|
+
const child = join17(dir, entry.name);
|
|
4356
4792
|
if (excluded(exclude, relative3(base, child)))
|
|
4357
4793
|
continue;
|
|
4358
4794
|
await walk(child, depth + 1);
|
|
4359
4795
|
}
|
|
4360
4796
|
};
|
|
4361
|
-
if (!
|
|
4797
|
+
if (!existsSync10(base))
|
|
4362
4798
|
return;
|
|
4363
4799
|
await walk(base, 0);
|
|
4364
4800
|
}
|
|
@@ -4366,15 +4802,15 @@ async function scanWorkshopRoot(workshopRoot, rootIndex, manifestFile, found) {
|
|
|
4366
4802
|
const base = resolvePath(expandHome(workshopRoot));
|
|
4367
4803
|
let entries;
|
|
4368
4804
|
try {
|
|
4369
|
-
entries = await
|
|
4805
|
+
entries = await readdir8(base, { withFileTypes: true });
|
|
4370
4806
|
} catch {
|
|
4371
4807
|
return;
|
|
4372
4808
|
}
|
|
4373
4809
|
for (const entry of entries) {
|
|
4374
4810
|
if (!entry.isDirectory() || !/^\d+$/.test(entry.name))
|
|
4375
4811
|
continue;
|
|
4376
|
-
const dir =
|
|
4377
|
-
if (!
|
|
4812
|
+
const dir = join17(base, entry.name);
|
|
4813
|
+
if (!existsSync10(join17(dir, manifestFile)))
|
|
4378
4814
|
continue;
|
|
4379
4815
|
found.push({ dir, kind: "workshop", rootIndex, linkedWorktree: false, workshopId: Number(entry.name) });
|
|
4380
4816
|
}
|
|
@@ -4383,37 +4819,62 @@ async function scanGameData(game, rootIndex, found) {
|
|
|
4383
4819
|
const host = game.gameFiles.host;
|
|
4384
4820
|
if (game.gameFiles.source !== "mount" || host === undefined)
|
|
4385
4821
|
return;
|
|
4386
|
-
const data =
|
|
4822
|
+
const data = join17(resolvePath(expandHome(host)), "Data");
|
|
4387
4823
|
let entries;
|
|
4388
4824
|
try {
|
|
4389
|
-
entries = await
|
|
4825
|
+
entries = await readdir8(data, { withFileTypes: true });
|
|
4390
4826
|
} catch {
|
|
4391
4827
|
return;
|
|
4392
4828
|
}
|
|
4393
4829
|
for (const entry of entries) {
|
|
4394
4830
|
if (!entry.isDirectory())
|
|
4395
4831
|
continue;
|
|
4396
|
-
const dir =
|
|
4397
|
-
if (!
|
|
4832
|
+
const dir = join17(data, entry.name);
|
|
4833
|
+
if (!existsSync10(join17(dir, game.manifest.file)))
|
|
4398
4834
|
continue;
|
|
4399
4835
|
found.push({ dir, kind: "official", rootIndex, linkedWorktree: false });
|
|
4400
4836
|
}
|
|
4401
4837
|
}
|
|
4402
|
-
var CACHE_VERSION =
|
|
4403
|
-
function
|
|
4404
|
-
|
|
4838
|
+
var CACHE_VERSION = 4;
|
|
4839
|
+
function acfPath2(root, steamAppId) {
|
|
4840
|
+
return join17(dirname9(dirname9(resolvePath(expandHome(root)))), `appworkshop_${steamAppId}.acf`);
|
|
4841
|
+
}
|
|
4842
|
+
function contentPairs(acf) {
|
|
4843
|
+
let text;
|
|
4844
|
+
try {
|
|
4845
|
+
text = readFileSync5(acf, "utf8");
|
|
4846
|
+
} catch (error) {
|
|
4847
|
+
if (error.code === "ENOENT")
|
|
4848
|
+
return [];
|
|
4849
|
+
return null;
|
|
4850
|
+
}
|
|
4851
|
+
const pairs = [...installedItems(text)].map(([id, item]) => `${id}:${item.manifest}`).sort();
|
|
4852
|
+
if (pairs.length === 0 && text.trim() !== "" && Object.keys(parseAcf(text)).length === 0)
|
|
4405
4853
|
return null;
|
|
4406
|
-
|
|
4854
|
+
return pairs;
|
|
4855
|
+
}
|
|
4856
|
+
function workshopStamp(game, dataRoot) {
|
|
4857
|
+
const pairs = [];
|
|
4858
|
+
for (const root of dataRoot === undefined ? [] : [downloadRoot(dataRoot, game)]) {
|
|
4859
|
+
const found = contentPairs(acfPath2(root, game.steamAppId));
|
|
4860
|
+
if (found === null)
|
|
4861
|
+
return null;
|
|
4862
|
+
pairs.push(...found);
|
|
4863
|
+
}
|
|
4864
|
+
const download = createHash3("sha256").update(pairs.toSorted().join(`
|
|
4865
|
+
`)).digest("hex");
|
|
4866
|
+
if (game.workshopRoot === null)
|
|
4867
|
+
return download;
|
|
4407
4868
|
try {
|
|
4408
|
-
const info =
|
|
4409
|
-
return `${info.mtimeMs}:${info.size}`;
|
|
4869
|
+
const info = statSync3(acfPath2(game.workshopRoot, game.steamAppId));
|
|
4870
|
+
return `${download}|${info.mtimeMs}:${info.size}`;
|
|
4410
4871
|
} catch {
|
|
4411
4872
|
return null;
|
|
4412
4873
|
}
|
|
4413
4874
|
}
|
|
4414
4875
|
async function readWorkshopCache(game, stamp) {
|
|
4415
4876
|
try {
|
|
4416
|
-
const raw = JSON.parse(await
|
|
4877
|
+
const raw = JSON.parse(await readFile10(join17(cacheDir(), `${game}.workshop.json`), "utf8"));
|
|
4417
4878
|
if (raw.version !== CACHE_VERSION || raw.stamp !== stamp)
|
|
4418
4879
|
return null;
|
|
4419
4880
|
return raw.records;
|
|
@@ -4425,7 +4886,7 @@ async function writeWorkshopCache(game, stamp, records) {
|
|
|
4425
4886
|
const payload = { version: CACHE_VERSION, stamp, records };
|
|
4426
4887
|
try {
|
|
4427
4888
|
await mkdir4(cacheDir(), { recursive: true });
|
|
4428
|
-
await writeFile6(
|
|
4889
|
+
await writeFile6(join17(cacheDir(), `${game}.workshop.json`), JSON.stringify(payload));
|
|
4429
4890
|
} catch {}
|
|
4430
4891
|
}
|
|
4431
4892
|
function toRecord(candidate, manifest, game) {
|
|
@@ -4513,8 +4974,8 @@ async function applySourceOverrides(index, overrides, config) {
|
|
|
4513
4974
|
}
|
|
4514
4975
|
const wanted = spec.slice(0, eq);
|
|
4515
4976
|
const dir = resolvePath(expandHome(spec.slice(eq + 1)));
|
|
4516
|
-
const file =
|
|
4517
|
-
if (!
|
|
4977
|
+
const file = join17(dir, config.manifest.file);
|
|
4978
|
+
if (!existsSync10(file)) {
|
|
4518
4979
|
problems.push({
|
|
4519
4980
|
where: spec,
|
|
4520
4981
|
message: `no ${config.manifest.file} under ${dir}`,
|
|
@@ -4575,7 +5036,7 @@ function insert(index, record) {
|
|
|
4575
5036
|
}
|
|
4576
5037
|
}
|
|
4577
5038
|
}
|
|
4578
|
-
async function buildIndex(game, config, plugin, sourcesDir) {
|
|
5039
|
+
async function buildIndex(game, config, plugin, sourcesDir, dataRoot) {
|
|
4579
5040
|
const index = {
|
|
4580
5041
|
game,
|
|
4581
5042
|
plugin,
|
|
@@ -4598,15 +5059,22 @@ async function buildIndex(game, config, plugin, sourcesDir) {
|
|
|
4598
5059
|
}
|
|
4599
5060
|
for (const record of parsed)
|
|
4600
5061
|
insert(index, record);
|
|
4601
|
-
|
|
4602
|
-
|
|
5062
|
+
const workshopRoots = [];
|
|
5063
|
+
if (dataRoot !== undefined)
|
|
5064
|
+
workshopRoots.push(downloadRoot(dataRoot, config));
|
|
5065
|
+
if (config.workshopRoot !== null)
|
|
5066
|
+
workshopRoots.push(config.workshopRoot);
|
|
5067
|
+
if (workshopRoots.length > 0) {
|
|
5068
|
+
const stamp = workshopStamp(config, dataRoot);
|
|
4603
5069
|
const cached = stamp === null ? null : await readWorkshopCache(game, stamp);
|
|
4604
5070
|
if (cached) {
|
|
4605
5071
|
for (const record of cached)
|
|
4606
5072
|
insert(index, record);
|
|
4607
5073
|
} else {
|
|
4608
5074
|
const items = [];
|
|
4609
|
-
|
|
5075
|
+
for (const [i, root] of workshopRoots.entries()) {
|
|
5076
|
+
await scanWorkshopRoot(root, cacheIndex + 1 + i, config.manifest.file, items);
|
|
5077
|
+
}
|
|
4610
5078
|
const records = await parseAll(items, config, plugin, index.problems);
|
|
4611
5079
|
for (const record of records)
|
|
4612
5080
|
insert(index, record);
|
|
@@ -4623,10 +5091,10 @@ function oneModPerClone(records, sourcesDir) {
|
|
|
4623
5091
|
const stamps = new Map;
|
|
4624
5092
|
for (const record of [...records].sort((a, b) => a.dir < b.dir ? -1 : a.dir > b.dir ? 1 : 0)) {
|
|
4625
5093
|
const clone = relative3(sourcesDir, record.dir).split(sep2).slice(0, 2).join(sep2);
|
|
4626
|
-
const at =
|
|
5094
|
+
const at = join17(sourcesDir, clone);
|
|
4627
5095
|
let stamp = stamps.get(at);
|
|
4628
5096
|
if (stamp === undefined) {
|
|
4629
|
-
stamp =
|
|
5097
|
+
stamp = statSync3(at).mtimeMs;
|
|
4630
5098
|
stamps.set(at, stamp);
|
|
4631
5099
|
}
|
|
4632
5100
|
record.clonedAt = stamp;
|
|
@@ -4638,9 +5106,9 @@ function oneModPerClone(records, sourcesDir) {
|
|
|
4638
5106
|
}
|
|
4639
5107
|
async function parseAll(candidates, config, plugin, problems) {
|
|
4640
5108
|
const records = await Promise.all(candidates.map(async (candidate) => {
|
|
4641
|
-
const file =
|
|
5109
|
+
const file = join17(candidate.dir, config.manifest.file);
|
|
4642
5110
|
try {
|
|
4643
|
-
const manifest = plugin.parseManifest(await
|
|
5111
|
+
const manifest = plugin.parseManifest(await readFile10(file, "utf8"));
|
|
4644
5112
|
return manifest === null ? null : toRecord(candidate, manifest, config);
|
|
4645
5113
|
} catch (error) {
|
|
4646
5114
|
problems.push({
|
|
@@ -4711,8 +5179,8 @@ function byPath(index, raw, game) {
|
|
|
4711
5179
|
if (hit)
|
|
4712
5180
|
return hit;
|
|
4713
5181
|
}
|
|
4714
|
-
const file =
|
|
4715
|
-
if (!
|
|
5182
|
+
const file = join17(dir, game.manifest.file);
|
|
5183
|
+
if (!existsSync10(file))
|
|
4716
5184
|
return null;
|
|
4717
5185
|
try {
|
|
4718
5186
|
const manifest = index.plugin.parseManifest(readFileSync5(file, "utf8"));
|
|
@@ -4730,35 +5198,8 @@ var DEFAULT_RENDER_WAIT_SECONDS = 25;
|
|
|
4730
5198
|
function isDynamic(entry) {
|
|
4731
5199
|
return typeof entry !== "string" && "match" in entry;
|
|
4732
5200
|
}
|
|
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
|
-
};
|
|
5201
|
+
function notFetched(id) {
|
|
5202
|
+
return `workshop item ${id} is not fetched, and fetching is off for this command; a real launch would fetch it`;
|
|
4762
5203
|
}
|
|
4763
5204
|
function refFor(entry, game, sources) {
|
|
4764
5205
|
const object = typeof entry === "string" ? { id: entry } : entry;
|
|
@@ -4776,7 +5217,7 @@ function refFor(entry, game, sources) {
|
|
|
4776
5217
|
if (pin?.git !== undefined) {
|
|
4777
5218
|
const dir = sources.get(object.id.toLowerCase());
|
|
4778
5219
|
if (dir !== undefined)
|
|
4779
|
-
return `path:${pin.subdir === undefined ? dir :
|
|
5220
|
+
return `path:${pin.subdir === undefined ? dir : join18(dir, pin.subdir)}`;
|
|
4780
5221
|
}
|
|
4781
5222
|
return object.id;
|
|
4782
5223
|
}
|
|
@@ -4941,6 +5382,7 @@ async function resolvePlan(options) {
|
|
|
4941
5382
|
const { game: gameName, profile: requestedProfile, root } = options;
|
|
4942
5383
|
const args = options.args ?? {};
|
|
4943
5384
|
const sources = options.sources ?? new Map;
|
|
5385
|
+
const unfetched = new Set(options.unfetched ?? []);
|
|
4944
5386
|
const problems = [];
|
|
4945
5387
|
const warnings = [];
|
|
4946
5388
|
const game = own(root.games, gameName);
|
|
@@ -4968,7 +5410,7 @@ async function resolvePlan(options) {
|
|
|
4968
5410
|
if (args.dockerArgs?.length)
|
|
4969
5411
|
settings.dockerArgs = [...settings.dockerArgs ?? [], ...args.dockerArgs];
|
|
4970
5412
|
const plugin = requirePlugin(options.plugins, gameName);
|
|
4971
|
-
const index = options.index ?? await buildIndex(gameName, game, plugin, sourcesRoot(root.dataRoot));
|
|
5413
|
+
const index = options.index ?? await buildIndex(gameName, game, plugin, sourcesRoot(root.dataRoot), root.dataRoot);
|
|
4972
5414
|
await applyWorktreeRequests(index, instance.requests, game);
|
|
4973
5415
|
problems.push(...await applySourceOverrides(index, args.use ?? [], game));
|
|
4974
5416
|
const excluded = [...profile.exclude ?? [], ...args.without ?? []].map(globToRegExp);
|
|
@@ -4985,11 +5427,8 @@ async function resolvePlan(options) {
|
|
|
4985
5427
|
continue;
|
|
4986
5428
|
if (optional)
|
|
4987
5429
|
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
|
-
});
|
|
5430
|
+
else if (ref.startsWith("workshop:") && unfetched.has(ref.slice(9))) {
|
|
5431
|
+
problems.push({ where: slot.where, message: notFetched(ref.slice(9)) });
|
|
4993
5432
|
} else
|
|
4994
5433
|
problems.push({ where: slot.where, message: `no mod matches "${ref}"` });
|
|
4995
5434
|
continue;
|
|
@@ -5001,8 +5440,15 @@ async function resolvePlan(options) {
|
|
|
5001
5440
|
staged.push({ record, explicit: true });
|
|
5002
5441
|
}
|
|
5003
5442
|
}
|
|
5004
|
-
if (profile.autoDependencies
|
|
5443
|
+
if (profile.autoDependencies !== false)
|
|
5005
5444
|
insertDependencies(staged, present, index, game, problems);
|
|
5445
|
+
if (unfetched.size > 0) {
|
|
5446
|
+
for (const problem of problems) {
|
|
5447
|
+
const id = workshopUrlId(problem.suggestion);
|
|
5448
|
+
if (id !== undefined && unfetched.has(id))
|
|
5449
|
+
problem.message = notFetched(id);
|
|
5450
|
+
}
|
|
5451
|
+
}
|
|
5006
5452
|
const ordered = args.sort === "none" ? staged : topoSort(staged, problems, game.core);
|
|
5007
5453
|
warnings.push(...incompatibilityWarnings(ordered));
|
|
5008
5454
|
const mods = [];
|
|
@@ -5043,6 +5489,9 @@ async function resolvePlan(options) {
|
|
|
5043
5489
|
if (!game.modes.includes(mode)) {
|
|
5044
5490
|
problems.push({ where: "flag --mode", message: `${gameName} does not support mode "${mode}"` });
|
|
5045
5491
|
}
|
|
5492
|
+
if (unfetched.size > 0) {
|
|
5493
|
+
warnings.unshift(`this plan is provisional: ${unfetched.size} workshop item(s) are not fetched, and a real launch would fetch them first`);
|
|
5494
|
+
}
|
|
5046
5495
|
const plan = {
|
|
5047
5496
|
game: gameName,
|
|
5048
5497
|
gameConfig: game,
|
|
@@ -5053,11 +5502,11 @@ async function resolvePlan(options) {
|
|
|
5053
5502
|
profileDir,
|
|
5054
5503
|
...instance.name === undefined ? {} : { instance: instance.name },
|
|
5055
5504
|
instanceDir: instance.dir,
|
|
5056
|
-
dataDirHost:
|
|
5057
|
-
configDirHost:
|
|
5058
|
-
stageDirHost:
|
|
5059
|
-
logsDirHost:
|
|
5060
|
-
runDirHost:
|
|
5505
|
+
dataDirHost: join18(instance.dir, "game"),
|
|
5506
|
+
configDirHost: join18(profileDir, "config"),
|
|
5507
|
+
stageDirHost: join18(instance.dir, ".stage"),
|
|
5508
|
+
logsDirHost: join18(instance.dir, "logs"),
|
|
5509
|
+
runDirHost: join18(instance.dir, "logs"),
|
|
5061
5510
|
mode,
|
|
5062
5511
|
...args.marker === undefined ? {} : { marker: args.marker },
|
|
5063
5512
|
timeoutSeconds: args.timeout ?? DEFAULT_TIMEOUT_SECONDS,
|
|
@@ -5069,8 +5518,8 @@ async function resolvePlan(options) {
|
|
|
5069
5518
|
}
|
|
5070
5519
|
|
|
5071
5520
|
// src/run/registry.ts
|
|
5072
|
-
import { readdir as
|
|
5073
|
-
import { join as
|
|
5521
|
+
import { readdir as readdir9 } from "node:fs/promises";
|
|
5522
|
+
import { join as join19 } from "node:path";
|
|
5074
5523
|
var FORMAT = '{{.Names}}\t{{.Label "gamecrate.game"}}\t{{.Label "gamecrate.profile"}}\t{{.Label "gamecrate.instance"}}\t{{.Status}}';
|
|
5075
5524
|
function parseDockerRuns(stdout) {
|
|
5076
5525
|
const out = [];
|
|
@@ -5095,20 +5544,20 @@ function parseDockerRuns(stdout) {
|
|
|
5095
5544
|
async function walkLocks(dataRoot) {
|
|
5096
5545
|
const out = [];
|
|
5097
5546
|
for (const game of await entries(dataRoot)) {
|
|
5098
|
-
const gameDir =
|
|
5547
|
+
const gameDir = join19(dataRoot, game);
|
|
5099
5548
|
for (const profile of await entries(gameDir)) {
|
|
5100
|
-
const profileDir =
|
|
5101
|
-
await push(out,
|
|
5102
|
-
const instancesDir =
|
|
5549
|
+
const profileDir = join19(gameDir, profile);
|
|
5550
|
+
await push(out, join19(profileDir, ".gamecrate", "lock"));
|
|
5551
|
+
const instancesDir = join19(profileDir, "instances");
|
|
5103
5552
|
for (const instance of await entries(instancesDir)) {
|
|
5104
|
-
await push(out,
|
|
5553
|
+
await push(out, join19(instancesDir, instance, ".gamecrate", "lock"));
|
|
5105
5554
|
}
|
|
5106
5555
|
}
|
|
5107
5556
|
}
|
|
5108
5557
|
return out;
|
|
5109
5558
|
}
|
|
5110
5559
|
async function entries(dir) {
|
|
5111
|
-
const found = await
|
|
5560
|
+
const found = await readdir9(dir, { withFileTypes: true }).catch(() => []);
|
|
5112
5561
|
return found.filter((entry) => entry.isDirectory()).map((entry) => entry.name);
|
|
5113
5562
|
}
|
|
5114
5563
|
async function push(out, path) {
|
|
@@ -5158,10 +5607,10 @@ async function listRuns(dataRoot, docker = dockerPs) {
|
|
|
5158
5607
|
}
|
|
5159
5608
|
|
|
5160
5609
|
// src/launch/stage.ts
|
|
5161
|
-
import { lstat, mkdir as mkdir5, readdir as
|
|
5162
|
-
import { basename as
|
|
5610
|
+
import { lstat, mkdir as mkdir5, readdir as readdir10, realpath as realpath2, rm as rm4, stat as stat5 } from "node:fs/promises";
|
|
5611
|
+
import { basename as basename10, join as join20 } from "node:path";
|
|
5163
5612
|
async function stageMods(plan) {
|
|
5164
|
-
await
|
|
5613
|
+
await rm4(plan.stageDirHost, { recursive: true, force: true });
|
|
5165
5614
|
await mkdir5(plan.stageDirHost, { recursive: true });
|
|
5166
5615
|
const mounts = [];
|
|
5167
5616
|
for (const mod of plan.mods) {
|
|
@@ -5173,10 +5622,10 @@ async function stageMods(plan) {
|
|
|
5173
5622
|
} catch {
|
|
5174
5623
|
throw new GamecrateError(`mod directory for ${mod.packageId} is missing`, Exit.Environment, mod.hostDir);
|
|
5175
5624
|
}
|
|
5176
|
-
if (!(await
|
|
5625
|
+
if (!(await stat5(source)).isDirectory()) {
|
|
5177
5626
|
throw new GamecrateError(`${mod.packageId} does not resolve to a directory`, Exit.Environment, source);
|
|
5178
5627
|
}
|
|
5179
|
-
await mkdir5(
|
|
5628
|
+
await mkdir5(join20(plan.stageDirHost, basename10(mod.containerDir)), { recursive: true });
|
|
5180
5629
|
mounts.push({ type: "bind", source, target: mod.containerDir, readonly: true });
|
|
5181
5630
|
}
|
|
5182
5631
|
return mounts;
|
|
@@ -5186,12 +5635,12 @@ async function ensureProfileTree(plan) {
|
|
|
5186
5635
|
plan.profileDir,
|
|
5187
5636
|
plan.instanceDir,
|
|
5188
5637
|
plan.dataDirHost,
|
|
5189
|
-
|
|
5190
|
-
|
|
5191
|
-
|
|
5192
|
-
|
|
5638
|
+
join20(plan.configDirHost, "config"),
|
|
5639
|
+
join20(plan.configDirHost, "data"),
|
|
5640
|
+
join20(plan.configDirHost, "cache"),
|
|
5641
|
+
join20(plan.logsDirHost, "runs"),
|
|
5193
5642
|
plan.stageDirHost,
|
|
5194
|
-
|
|
5643
|
+
join20(plan.instanceDir, ".gamecrate"),
|
|
5195
5644
|
...engineDirs(plan)
|
|
5196
5645
|
]) {
|
|
5197
5646
|
await mkdir5(dir, { recursive: true });
|
|
@@ -5201,7 +5650,7 @@ function engineDirs(plan) {
|
|
|
5201
5650
|
const { dataDir, modsDir } = plan.gameConfig;
|
|
5202
5651
|
if (!modsDir.container.startsWith(`${dataDir.container}/`))
|
|
5203
5652
|
return [];
|
|
5204
|
-
return [
|
|
5653
|
+
return [join20(plan.dataDirHost, modsDir.container.slice(dataDir.container.length + 1))];
|
|
5205
5654
|
}
|
|
5206
5655
|
async function detectForeignOwnership(dir, uid, limit = 100) {
|
|
5207
5656
|
const foreign = [];
|
|
@@ -5222,8 +5671,8 @@ async function detectForeignOwnership(dir, uid, limit = 100) {
|
|
|
5222
5671
|
if (!info.isDirectory())
|
|
5223
5672
|
continue;
|
|
5224
5673
|
try {
|
|
5225
|
-
for (const entry of await
|
|
5226
|
-
queue.push(
|
|
5674
|
+
for (const entry of await readdir10(current))
|
|
5675
|
+
queue.push(join20(current, entry));
|
|
5227
5676
|
} catch {
|
|
5228
5677
|
continue;
|
|
5229
5678
|
}
|
|
@@ -5231,8 +5680,144 @@ async function detectForeignOwnership(dir, uid, limit = 100) {
|
|
|
5231
5680
|
return foreign;
|
|
5232
5681
|
}
|
|
5233
5682
|
|
|
5683
|
+
// src/mods/workshop.ts
|
|
5684
|
+
import { existsSync as existsSync11 } from "node:fs";
|
|
5685
|
+
import { readFile as readFile11 } from "node:fs/promises";
|
|
5686
|
+
import { join as join21, resolve as resolvePath2 } from "node:path";
|
|
5687
|
+
var ROUNDS = 5;
|
|
5688
|
+
async function prepareWorkshop(game, profileName, args, config, allowFetch, plugin, sources) {
|
|
5689
|
+
const roots = mountedRoots(config.dataRoot, game);
|
|
5690
|
+
const ids = new Set;
|
|
5691
|
+
const warnings = [];
|
|
5692
|
+
const problems = [];
|
|
5693
|
+
const profile = resolveProfile(game, profileName);
|
|
5694
|
+
const seeded = await localSeeds(game, profile, args, sources, plugin, problems);
|
|
5695
|
+
let frontier = [...new Set([...wantedIds(game, profile, args), ...seeded])];
|
|
5696
|
+
for (let round = 0;round < ROUNDS && frontier.length > 0; round++) {
|
|
5697
|
+
for (const id of frontier)
|
|
5698
|
+
ids.add(id);
|
|
5699
|
+
if (allowFetch)
|
|
5700
|
+
warnings.push(...await fetchRound(config, game, roots, frontier));
|
|
5701
|
+
frontier = await dependenciesOf(frontier, ids, roots, game, plugin, problems);
|
|
5702
|
+
}
|
|
5703
|
+
if (frontier.length > 0) {
|
|
5704
|
+
problems.push({
|
|
5705
|
+
where: `profile ${profileName}`,
|
|
5706
|
+
message: `workshop dependencies are still unresolved after ${ROUNDS} rounds: ${frontier.join(", ")}`,
|
|
5707
|
+
suggestion: "name them in the profile so they download in the first round"
|
|
5708
|
+
});
|
|
5709
|
+
}
|
|
5710
|
+
return { ids, warnings, problems, unfetched: [...ids].filter((id) => itemDir(roots, id) === undefined) };
|
|
5711
|
+
}
|
|
5712
|
+
async function fetchRound(config, game, roots, frontier) {
|
|
5713
|
+
try {
|
|
5714
|
+
const drift = await checkDrift(frontier, roots);
|
|
5715
|
+
const downloaded = await downloadItems(config, game, config.dataRoot, drift.needed);
|
|
5716
|
+
return [...drift.warnings, ...downloaded.warnings];
|
|
5717
|
+
} catch (error) {
|
|
5718
|
+
return [`could not download workshop items: ${error instanceof Error ? error.message : String(error)}`];
|
|
5719
|
+
}
|
|
5720
|
+
}
|
|
5721
|
+
async function dependenciesOf(frontier, seen, roots, game, plugin, problems) {
|
|
5722
|
+
const next = new Set;
|
|
5723
|
+
for (const id of frontier) {
|
|
5724
|
+
const manifest = await manifestOf(roots, id, game, plugin, problems);
|
|
5725
|
+
for (const dep of manifest?.modDependencies ?? []) {
|
|
5726
|
+
const depId = workshopUrlId(dep.steamWorkshopUrl);
|
|
5727
|
+
if (depId !== undefined && !seen.has(depId))
|
|
5728
|
+
next.add(depId);
|
|
5729
|
+
}
|
|
5730
|
+
}
|
|
5731
|
+
return [...next];
|
|
5732
|
+
}
|
|
5733
|
+
function mountedRoots(dataRoot, game) {
|
|
5734
|
+
return [downloadRoot(dataRoot, game), ...game.workshopRoot === null ? [] : [game.workshopRoot]];
|
|
5735
|
+
}
|
|
5736
|
+
function itemDir(roots, id) {
|
|
5737
|
+
return roots.map((root) => join21(root, id)).find((dir) => existsSync11(dir));
|
|
5738
|
+
}
|
|
5739
|
+
async function manifestOf(roots, id, game, plugin, problems) {
|
|
5740
|
+
const dir = itemDir(roots, id);
|
|
5741
|
+
return dir === undefined ? null : await manifestAt(dir, game, plugin, problems);
|
|
5742
|
+
}
|
|
5743
|
+
async function manifestAt(dir, game, plugin, problems) {
|
|
5744
|
+
const file = join21(dir, game.manifest.file);
|
|
5745
|
+
if (!existsSync11(file))
|
|
5746
|
+
return null;
|
|
5747
|
+
try {
|
|
5748
|
+
return plugin.parseManifest(await readFile11(file, "utf8"));
|
|
5749
|
+
} catch (error) {
|
|
5750
|
+
problems.push({ where: file, message: error instanceof Error ? error.message : String(error) });
|
|
5751
|
+
return null;
|
|
5752
|
+
}
|
|
5753
|
+
}
|
|
5754
|
+
async function localSeeds(game, profile, args, sources, plugin, problems) {
|
|
5755
|
+
const out = new Set;
|
|
5756
|
+
for (const dir of localDirs(game, profile, args, sources)) {
|
|
5757
|
+
const manifest = await manifestAt(dir, game, plugin, problems);
|
|
5758
|
+
for (const dep of manifest?.modDependencies ?? []) {
|
|
5759
|
+
const id = workshopUrlId(dep.steamWorkshopUrl);
|
|
5760
|
+
if (id !== undefined)
|
|
5761
|
+
out.add(id);
|
|
5762
|
+
}
|
|
5763
|
+
}
|
|
5764
|
+
return [...out];
|
|
5765
|
+
}
|
|
5766
|
+
function localDirs(game, profile, args, sources) {
|
|
5767
|
+
const out = new Set;
|
|
5768
|
+
for (const entry of reachedEntries(game, profile, args)) {
|
|
5769
|
+
const dir = localDirOf(entry, game, sources);
|
|
5770
|
+
if (dir !== undefined)
|
|
5771
|
+
out.add(dir);
|
|
5772
|
+
}
|
|
5773
|
+
return [...out];
|
|
5774
|
+
}
|
|
5775
|
+
function localDirOf(entry, game, sources) {
|
|
5776
|
+
if (typeof entry !== "string" && "match" in entry)
|
|
5777
|
+
return;
|
|
5778
|
+
const object = typeof entry === "string" ? { id: entry } : entry;
|
|
5779
|
+
if (object.path !== undefined)
|
|
5780
|
+
return resolvePath2(expandHome(object.path));
|
|
5781
|
+
if (object.id.includes(":"))
|
|
5782
|
+
return;
|
|
5783
|
+
const pin = libraryPin(game, object.id);
|
|
5784
|
+
const clone = sources.get(object.id.toLowerCase());
|
|
5785
|
+
if (clone !== undefined)
|
|
5786
|
+
return pin?.subdir === undefined ? clone : join21(clone, pin.subdir);
|
|
5787
|
+
return pin?.path === undefined ? undefined : resolvePath2(expandHome(pin.path));
|
|
5788
|
+
}
|
|
5789
|
+
function wantedIds(game, profile, args) {
|
|
5790
|
+
const out = new Set;
|
|
5791
|
+
for (const entry of reachedEntries(game, profile, args)) {
|
|
5792
|
+
const id = workshopIdOf(entry, game);
|
|
5793
|
+
if (id !== undefined)
|
|
5794
|
+
out.add(id);
|
|
5795
|
+
}
|
|
5796
|
+
return [...out];
|
|
5797
|
+
}
|
|
5798
|
+
function workshopIdOf(entry, game) {
|
|
5799
|
+
if (typeof entry !== "string" && "match" in entry)
|
|
5800
|
+
return;
|
|
5801
|
+
const object = typeof entry === "string" ? { id: entry } : entry;
|
|
5802
|
+
if (object.path !== undefined)
|
|
5803
|
+
return;
|
|
5804
|
+
if (object.workshop !== undefined)
|
|
5805
|
+
return published2(String(object.workshop));
|
|
5806
|
+
if (object.id.startsWith("workshop:"))
|
|
5807
|
+
return published2(object.id.slice("workshop:".length));
|
|
5808
|
+
if (object.id.includes(":"))
|
|
5809
|
+
return;
|
|
5810
|
+
const pin = libraryPin(game, object.id);
|
|
5811
|
+
if (pin?.path !== undefined)
|
|
5812
|
+
return;
|
|
5813
|
+
return pin?.workshop === undefined ? undefined : published2(String(pin.workshop));
|
|
5814
|
+
}
|
|
5815
|
+
function published2(value) {
|
|
5816
|
+
return /^\d+$/.test(value) ? value : undefined;
|
|
5817
|
+
}
|
|
5818
|
+
|
|
5234
5819
|
// src/index.ts
|
|
5235
|
-
var VERSION = "1.
|
|
5820
|
+
var VERSION = "1.4.0";
|
|
5236
5821
|
async function main(argv) {
|
|
5237
5822
|
const supervised = supervisedDir(argv);
|
|
5238
5823
|
try {
|
|
@@ -5304,13 +5889,13 @@ async function dispatch(argv, args, config, plugins, defaults) {
|
|
|
5304
5889
|
case "wait":
|
|
5305
5890
|
return waitFor(args, config, defaults);
|
|
5306
5891
|
case "shell":
|
|
5307
|
-
return
|
|
5892
|
+
return run2(argv, args, config, plugins, defaults, true);
|
|
5308
5893
|
case "config":
|
|
5309
5894
|
return configEdit(args);
|
|
5310
5895
|
case "fix-perms":
|
|
5311
5896
|
return fixPerms(args, config);
|
|
5312
5897
|
case "run":
|
|
5313
|
-
return
|
|
5898
|
+
return run2(argv, args, config, plugins, defaults, false);
|
|
5314
5899
|
default:
|
|
5315
5900
|
throw new GamecrateError(`no such subcommand ${args.subcommand}`, Exit.Usage);
|
|
5316
5901
|
}
|
|
@@ -5356,25 +5941,38 @@ function reportEnvironment(problems) {
|
|
|
5356
5941
|
throw new GamecrateError(`${problems.length} environment problem(s)`, Exit.Environment, out.join(`
|
|
5357
5942
|
`));
|
|
5358
5943
|
}
|
|
5359
|
-
async function
|
|
5944
|
+
async function run2(argv, args, config, plugins, defaults, asShell) {
|
|
5360
5945
|
const game = requireGame(args, config);
|
|
5361
5946
|
const profile = profileOf(args, defaults);
|
|
5362
5947
|
const gameConfig = config.games[game];
|
|
5363
5948
|
const allowFetch = !args.dryRun && !args.printPlan;
|
|
5364
5949
|
const sources = await prepareSources(gameConfig, profile, args, config.dataRoot, allowFetch);
|
|
5365
5950
|
try {
|
|
5366
|
-
|
|
5951
|
+
const workshop = await prepareWorkshop(gameConfig, profile, args, config, allowFetch, requirePlugin(plugins, game), sources.dirs);
|
|
5952
|
+
return await resolved(argv, args, config, plugins, asShell, game, profile, sources, workshop, allowFetch);
|
|
5367
5953
|
} finally {
|
|
5368
5954
|
await sources.release();
|
|
5369
5955
|
}
|
|
5370
5956
|
}
|
|
5371
|
-
|
|
5372
|
-
|
|
5957
|
+
function warnUnfetched(problems, unfetched) {
|
|
5958
|
+
const provisional = new Set(unfetched.map(notFetched));
|
|
5959
|
+
const fatal = [];
|
|
5960
|
+
for (const problem of problems) {
|
|
5961
|
+
if (provisional.has(problem.message))
|
|
5962
|
+
warn(`${problem.where}: ${problem.message}`);
|
|
5963
|
+
else
|
|
5964
|
+
fatal.push(problem);
|
|
5965
|
+
}
|
|
5966
|
+
return fatal;
|
|
5967
|
+
}
|
|
5968
|
+
async function resolved(argv, args, config, plugins, asShell, game, profile, sources, workshop, allowFetch) {
|
|
5969
|
+
for (const warning of [...sources.warnings, ...workshop.warnings])
|
|
5373
5970
|
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
|
-
|
|
5971
|
+
const index = await buildIndex(game, config.games[game], requirePlugin(plugins, game), sourcesRoot(config.dataRoot), config.dataRoot);
|
|
5972
|
+
const { plan, problems } = await resolvePlan({ game, profile, root: config, plugins, args, index, sources: sources.dirs, unfetched: allowFetch ? undefined : workshop.unfetched });
|
|
5973
|
+
const fatal = warnUnfetched([...workshop.problems, ...problems], allowFetch ? [] : workshop.unfetched);
|
|
5974
|
+
if (fatal.length > 0)
|
|
5975
|
+
reportProblems(fatal);
|
|
5378
5976
|
const identity = resolveIdentity(args.root);
|
|
5379
5977
|
if (args.printPlan || args.dryRun) {
|
|
5380
5978
|
const environment = await preflight(plan);
|
|
@@ -5421,7 +6019,7 @@ run: gamecrate fix-perms ${game} ${profile}`);
|
|
|
5421
6019
|
}
|
|
5422
6020
|
const runDir = openRunLog(plan.logsDirHost);
|
|
5423
6021
|
plan.runDirHost = runDir;
|
|
5424
|
-
const supervisorLog = args.supervised && args.log === undefined ? redirectOutput(
|
|
6022
|
+
const supervisorLog = args.supervised && args.log === undefined ? redirectOutput(join22(runDir, "supervisor.log")) : undefined;
|
|
5425
6023
|
try {
|
|
5426
6024
|
return await execute(plan, args, config, identity, asShell, profileSpec, runDir, releaseSources);
|
|
5427
6025
|
} finally {
|
|
@@ -5479,19 +6077,19 @@ async function execute(plan, args, config, identity, asShell, profileSpec, runDi
|
|
|
5479
6077
|
}
|
|
5480
6078
|
}
|
|
5481
6079
|
function markerSources(plan, logDir) {
|
|
5482
|
-
const sources = [
|
|
6080
|
+
const sources = [join22(logDir, STDOUT_LOG)];
|
|
5483
6081
|
const { logFile } = plan.gameConfig;
|
|
5484
6082
|
if (logFile.mode === "arg")
|
|
5485
|
-
sources.push(
|
|
6083
|
+
sources.push(join22(logDir, "Player.log"));
|
|
5486
6084
|
else
|
|
5487
|
-
sources.push(
|
|
6085
|
+
sources.push(join22(plan.dataDirHost, logFile.from));
|
|
5488
6086
|
return sources;
|
|
5489
6087
|
}
|
|
5490
6088
|
async function runBounded(spec, plan, logDir) {
|
|
5491
6089
|
const container = runContainer(spec, { logDir, stopTimeoutSeconds: STOP_TIMEOUT_SECONDS });
|
|
5492
6090
|
const winner = await Promise.race([
|
|
5493
6091
|
container.then((code) => ({ kind: "exit", code })),
|
|
5494
|
-
|
|
6092
|
+
sleep7(plan.timeoutSeconds * 1000).then(() => ({ kind: "timeout" }))
|
|
5495
6093
|
]);
|
|
5496
6094
|
if (winner.kind === "exit")
|
|
5497
6095
|
return { code: normalize(winner.code), reason: reasonFor(winner.code) };
|
|
@@ -5502,7 +6100,7 @@ async function runBounded(spec, plan, logDir) {
|
|
|
5502
6100
|
}
|
|
5503
6101
|
async function runWithScreenshot(spec, plan, logDir) {
|
|
5504
6102
|
const container = runContainer(spec, { logDir, stopTimeoutSeconds: STOP_TIMEOUT_SECONDS });
|
|
5505
|
-
const settled =
|
|
6103
|
+
const settled = sleep7(plan.renderWaitSeconds * 1000).then(() => "ready");
|
|
5506
6104
|
const winner = await Promise.race([
|
|
5507
6105
|
container.then((code) => ({ kind: "exit", code })),
|
|
5508
6106
|
settled.then(() => ({ kind: "ready" }))
|
|
@@ -5552,10 +6150,10 @@ async function copyOutLogs(plan) {
|
|
|
5552
6150
|
const spec = plan.gameConfig.logFile;
|
|
5553
6151
|
if (spec.mode !== "copy-out")
|
|
5554
6152
|
return;
|
|
5555
|
-
const source =
|
|
5556
|
-
if (!
|
|
6153
|
+
const source = join22(plan.dataDirHost, spec.from);
|
|
6154
|
+
if (!existsSync12(source))
|
|
5557
6155
|
return;
|
|
5558
|
-
const target =
|
|
6156
|
+
const target = join22(plan.runDirHost, basename11(spec.from.replace(/\/+$/, "")));
|
|
5559
6157
|
try {
|
|
5560
6158
|
await cp(source, target, { recursive: true, force: true });
|
|
5561
6159
|
} catch (error) {
|
|
@@ -5565,21 +6163,65 @@ async function copyOutLogs(plan) {
|
|
|
5565
6163
|
async function mods(args, config, plugins, defaults) {
|
|
5566
6164
|
const game = requireGame(args, config);
|
|
5567
6165
|
const profile = profileOf(args, defaults);
|
|
5568
|
-
const index = await buildIndex(game, config.games[game], requirePlugin(plugins, game), sourcesRoot(config.dataRoot));
|
|
5569
6166
|
const sources = cachedSources(config.games[game], profile, args, config.dataRoot);
|
|
5570
|
-
const
|
|
5571
|
-
|
|
5572
|
-
|
|
6167
|
+
const workshop = await prepareWorkshop(config.games[game], profile, args, config, false, requirePlugin(plugins, game), sources);
|
|
6168
|
+
for (const warning of workshop.warnings)
|
|
6169
|
+
warn(warning);
|
|
6170
|
+
const index = await buildIndex(game, config.games[game], requirePlugin(plugins, game), sourcesRoot(config.dataRoot), config.dataRoot);
|
|
6171
|
+
const { plan, problems } = await resolvePlan({ game, profile, root: config, plugins, args, index, sources, unfetched: workshop.unfetched });
|
|
6172
|
+
const fatal = warnUnfetched([...workshop.problems, ...problems], workshop.unfetched);
|
|
6173
|
+
if (fatal.length > 0)
|
|
6174
|
+
reportProblems(fatal);
|
|
5573
6175
|
printPlan(plan, args.json);
|
|
5574
6176
|
return Exit.Ok;
|
|
5575
6177
|
}
|
|
6178
|
+
function usesWorkshop(game) {
|
|
6179
|
+
for (const entry of Object.values(game.library ?? {})) {
|
|
6180
|
+
if (entry.workshop !== undefined)
|
|
6181
|
+
return true;
|
|
6182
|
+
}
|
|
6183
|
+
for (const ref of [...game.preCore ?? [], game.core, ...game.dlc, ...game.base ?? []]) {
|
|
6184
|
+
if (ref.startsWith("workshop:"))
|
|
6185
|
+
return true;
|
|
6186
|
+
}
|
|
6187
|
+
for (const profile of Object.values(game.profiles)) {
|
|
6188
|
+
for (const entry of profile.mods ?? []) {
|
|
6189
|
+
if (typeof entry === "string") {
|
|
6190
|
+
if (entry.startsWith("workshop:"))
|
|
6191
|
+
return true;
|
|
6192
|
+
} else if ("workshop" in entry && entry.workshop !== undefined)
|
|
6193
|
+
return true;
|
|
6194
|
+
}
|
|
6195
|
+
}
|
|
6196
|
+
return false;
|
|
6197
|
+
}
|
|
6198
|
+
function steamcmdSource(runner, config) {
|
|
6199
|
+
if (runner.kind === "docker")
|
|
6200
|
+
return `docker image ${STEAMCMD_IMAGE}`;
|
|
6201
|
+
const where = config.steamcmd?.path === undefined ? "on PATH" : "steamcmd.path";
|
|
6202
|
+
return `${runner.argv[0]} (${where})`;
|
|
6203
|
+
}
|
|
5576
6204
|
async function doctor(config, plugins) {
|
|
5577
6205
|
let failed = false;
|
|
5578
6206
|
for (const game of Object.keys(config.games)) {
|
|
5579
|
-
const
|
|
6207
|
+
const gameConfig = config.games[game];
|
|
6208
|
+
const sources = cachedSources(gameConfig, "modless", {}, config.dataRoot);
|
|
5580
6209
|
const { plan, problems } = await resolvePlan({ game, profile: "modless", root: config, plugins, sources });
|
|
5581
|
-
const
|
|
5582
|
-
|
|
6210
|
+
const steamcmd = [];
|
|
6211
|
+
if (usesWorkshop(gameConfig)) {
|
|
6212
|
+
try {
|
|
6213
|
+
status(`${game}: steamcmd ${steamcmdSource(resolveSteamcmd(config), config)}`);
|
|
6214
|
+
} catch (error) {
|
|
6215
|
+
steamcmd.push({
|
|
6216
|
+
where: "steamcmd",
|
|
6217
|
+
message: error instanceof Error ? error.message : String(error),
|
|
6218
|
+
...error instanceof GamecrateError && error.detail !== undefined ? { suggestion: error.detail } : {}
|
|
6219
|
+
});
|
|
6220
|
+
}
|
|
6221
|
+
const root = downloadRoot(config.dataRoot, gameConfig);
|
|
6222
|
+
status(`${game}: workshop downloads ${root}${existsSync12(root) ? "" : " (not created yet)"}`);
|
|
6223
|
+
}
|
|
6224
|
+
const all = [...problems, ...await preflight(plan), ...steamcmd];
|
|
5583
6225
|
if (all.length === 0) {
|
|
5584
6226
|
status(`${game}: ok`);
|
|
5585
6227
|
continue;
|
|
@@ -5603,13 +6245,13 @@ async function logs(args, config, defaults) {
|
|
|
5603
6245
|
const dir = instanceDir(args, config, game, profile);
|
|
5604
6246
|
if (args.follow)
|
|
5605
6247
|
return await follow(dir, false, `${game} ${profile}`);
|
|
5606
|
-
const runs =
|
|
5607
|
-
const latest = (await
|
|
6248
|
+
const runs = join22(dir, "logs", "runs");
|
|
6249
|
+
const latest = (await readdir11(runs, { withFileTypes: true }).catch(() => [])).filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort().at(-1);
|
|
5608
6250
|
if (latest === undefined) {
|
|
5609
6251
|
throw new GamecrateError(`no runs recorded for ${game} ${profile}`, Exit.Usage, runs);
|
|
5610
6252
|
}
|
|
5611
|
-
const runDir =
|
|
5612
|
-
const files = (await
|
|
6253
|
+
const runDir = join22(runs, latest);
|
|
6254
|
+
const files = (await readdir11(runDir, { withFileTypes: true })).filter((entry) => entry.isFile()).map((entry) => entry.name).sort();
|
|
5613
6255
|
if (args.json) {
|
|
5614
6256
|
process.stdout.write(`${JSON.stringify({ run: latest, dir: runDir, files }, null, 2)}
|
|
5615
6257
|
`);
|
|
@@ -5617,7 +6259,7 @@ async function logs(args, config, defaults) {
|
|
|
5617
6259
|
}
|
|
5618
6260
|
status(runDir);
|
|
5619
6261
|
for (const name of files) {
|
|
5620
|
-
const text = await
|
|
6262
|
+
const text = await readFile12(join22(runDir, name), "utf8").catch(() => "");
|
|
5621
6263
|
for (const line of text.split(`
|
|
5622
6264
|
`)) {
|
|
5623
6265
|
if (line.length > 0)
|
|
@@ -5744,18 +6386,24 @@ async function clean(args, config, defaults) {
|
|
|
5744
6386
|
const dir = profileDataDir(config, game, profile);
|
|
5745
6387
|
const tier = args.cleanTier ?? "staging";
|
|
5746
6388
|
const saveSuffixes = config.games[game].saveExtensions.map((ext) => `.${ext.replace(/^\./, "")}`.toLowerCase());
|
|
6389
|
+
const downloads = downloadRoot(config.dataRoot, config.games[game]);
|
|
6390
|
+
if (tier === "downloads") {
|
|
6391
|
+
status(await removeDownloads(downloads, config.games[game].steamAppId));
|
|
6392
|
+
return Exit.Ok;
|
|
6393
|
+
}
|
|
5747
6394
|
if (tier !== "all") {
|
|
5748
|
-
const target =
|
|
5749
|
-
await
|
|
6395
|
+
const target = join22(instanceDir(args, config, game, profile), tier === "logs" ? "logs" : ".stage");
|
|
6396
|
+
await rm5(target, { recursive: true, force: true });
|
|
5750
6397
|
status(`removed ${target}`);
|
|
5751
6398
|
return Exit.Ok;
|
|
5752
6399
|
}
|
|
5753
6400
|
const saves = await countSaves(dir, saveSuffixes);
|
|
5754
6401
|
if (!args.yes) {
|
|
5755
|
-
throw new GamecrateError(`clean --all would delete ${dir}, including ${saves} save file(s)`, Exit.Usage, "add --yes to confirm");
|
|
6402
|
+
throw new GamecrateError(`clean --all would delete ${dir}, including ${saves} save file(s), and ${game}'s workshop downloads`, Exit.Usage, "add --yes to confirm, or --downloads to drop only the downloads");
|
|
5756
6403
|
}
|
|
5757
|
-
await
|
|
6404
|
+
await rm5(dir, { recursive: true, force: true });
|
|
5758
6405
|
status(`removed ${dir} (${saves} save file(s))`);
|
|
6406
|
+
status(await removeDownloads(downloads, config.games[game].steamAppId));
|
|
5759
6407
|
return Exit.Ok;
|
|
5760
6408
|
}
|
|
5761
6409
|
async function countSaves(dir, suffixes) {
|
|
@@ -5765,13 +6413,13 @@ async function countSaves(dir, suffixes) {
|
|
|
5765
6413
|
const current = queue.shift();
|
|
5766
6414
|
let entries;
|
|
5767
6415
|
try {
|
|
5768
|
-
entries = await
|
|
6416
|
+
entries = await readdir11(current, { withFileTypes: true });
|
|
5769
6417
|
} catch {
|
|
5770
6418
|
continue;
|
|
5771
6419
|
}
|
|
5772
6420
|
for (const entry of entries) {
|
|
5773
6421
|
if (entry.isDirectory())
|
|
5774
|
-
queue.push(
|
|
6422
|
+
queue.push(join22(current, entry.name));
|
|
5775
6423
|
else if (suffixes.some((s) => entry.name.toLowerCase().endsWith(s)))
|
|
5776
6424
|
count++;
|
|
5777
6425
|
}
|
|
@@ -5784,11 +6432,11 @@ async function clone(args, config) {
|
|
|
5784
6432
|
if (src === undefined || dst === undefined) {
|
|
5785
6433
|
throw new GamecrateError("clone needs a source and a destination profile", Exit.Usage);
|
|
5786
6434
|
}
|
|
5787
|
-
const from =
|
|
5788
|
-
const to =
|
|
5789
|
-
if (!
|
|
6435
|
+
const from = join22(profileDataDir(config, game, src), "game");
|
|
6436
|
+
const to = join22(profileDataDir(config, game, dst), "game");
|
|
6437
|
+
if (!existsSync12(from))
|
|
5790
6438
|
throw new GamecrateError(`${from} does not exist`, Exit.Usage);
|
|
5791
|
-
if (
|
|
6439
|
+
if (existsSync12(to) && !args.yes) {
|
|
5792
6440
|
throw new GamecrateError(`${to} already exists`, Exit.Usage, "add --yes to overwrite");
|
|
5793
6441
|
}
|
|
5794
6442
|
await mkdir6(to, { recursive: true });
|
|
@@ -5830,7 +6478,7 @@ async function ps(args, config) {
|
|
|
5830
6478
|
async function stop(args, config, defaults) {
|
|
5831
6479
|
const game = requireGame(args, config);
|
|
5832
6480
|
const profile = profileOf(args, defaults);
|
|
5833
|
-
const file =
|
|
6481
|
+
const file = join22(instanceDir(args, config, game, profile), ".gamecrate", "lock");
|
|
5834
6482
|
const record = await readLock(file);
|
|
5835
6483
|
if (record === undefined) {
|
|
5836
6484
|
status(`${game} ${profile} is not running`);
|
|
@@ -5850,11 +6498,11 @@ async function attach(args, config, defaults) {
|
|
|
5850
6498
|
return await follow(instanceDir(args, config, game, profile), true, `${game} ${profile}`);
|
|
5851
6499
|
}
|
|
5852
6500
|
async function follow(dir, fromStart, what) {
|
|
5853
|
-
const lock = await readLock(
|
|
6501
|
+
const lock = await readLock(join22(dir, ".gamecrate", "lock"));
|
|
5854
6502
|
const held = lock !== undefined && isRunning(lock.pid, lock.startedAt);
|
|
5855
6503
|
const live = held && await awaitRunLog(dir, lock);
|
|
5856
6504
|
const file = currentLog(dir);
|
|
5857
|
-
if (!
|
|
6505
|
+
if (!existsSync12(file)) {
|
|
5858
6506
|
throw new GamecrateError(`no captured output for ${what}`, Exit.Usage, file);
|
|
5859
6507
|
}
|
|
5860
6508
|
return await spawnStatus(tailArgv(file, fromStart, live ? lock.pid : undefined), true);
|
|
@@ -5887,9 +6535,9 @@ async function configEdit(args) {
|
|
|
5887
6535
|
if (args.rest[0] !== "edit")
|
|
5888
6536
|
throw new GamecrateError("config takes one word: edit", Exit.Usage);
|
|
5889
6537
|
const existing = await findGlobalConfig();
|
|
5890
|
-
const path = existing ??
|
|
5891
|
-
await mkdir6(
|
|
5892
|
-
if (!
|
|
6538
|
+
const path = existing ?? join22(globalConfigDir(), "profiles.yml");
|
|
6539
|
+
await mkdir6(dirname10(path), { recursive: true });
|
|
6540
|
+
if (!existsSync12(path)) {
|
|
5893
6541
|
await writeFile7(path, `# gamecrate config. see https://github.com/RimWorks/gamecrate
|
|
5894
6542
|
` + `plugins: []
|
|
5895
6543
|
` + `games: {}
|
|
@@ -5909,7 +6557,7 @@ async function fixPerms(args, config) {
|
|
|
5909
6557
|
const identity = resolveIdentity(false);
|
|
5910
6558
|
const found = [];
|
|
5911
6559
|
for (const dir of await profileDirs(config, game, args.profile)) {
|
|
5912
|
-
if (!
|
|
6560
|
+
if (!existsSync12(dir))
|
|
5913
6561
|
continue;
|
|
5914
6562
|
found.push(...await detectForeignOwnership(dir, identity.uid, 1e4));
|
|
5915
6563
|
}
|
|
@@ -5949,10 +6597,10 @@ async function fixPerms(args, config) {
|
|
|
5949
6597
|
return Exit.Ok;
|
|
5950
6598
|
}
|
|
5951
6599
|
async function removeIfEmptyDir(path) {
|
|
5952
|
-
const info = await
|
|
6600
|
+
const info = await stat6(path).catch(() => null);
|
|
5953
6601
|
if (info === null || !info.isDirectory())
|
|
5954
6602
|
return false;
|
|
5955
|
-
const entries = await
|
|
6603
|
+
const entries = await readdir11(path).catch(() => null);
|
|
5956
6604
|
if (entries === null || entries.length > 0)
|
|
5957
6605
|
return false;
|
|
5958
6606
|
return rmdir(path).then(() => true, () => false);
|