@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/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 existsSync10 } from "node:fs";
5
- import { chown, cp, mkdir as mkdir6, readdir as readdir10, readFile as readFile10, rm as rm4, rmdir, stat as stat5, writeFile as writeFile7 } from "node:fs/promises";
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 basename10, dirname as dirname8, join as join19 } from "node:path";
8
- import { setTimeout as sleep6 } from "node:timers/promises";
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, saves included (needs --yes)").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");
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 existsSync5 } from "node:fs";
800
- import { mkdir as mkdir2, readFile as readFile6, readdir as readdir5, writeFile as writeFile3 } from "node:fs/promises";
801
- import { dirname as dirname5, join as join10, relative as relative2, resolve as resolve4 } from "node:path";
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 > newestAssembly.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 <= newestAssembly.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 > newestAssembly.mtimeMs).length
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 lockClone(dir) {
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 push = (entry) => {
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
- return;
3063
+ continue;
3042
3064
  }
3043
3065
  if ("match" in entry)
3044
- return;
3066
+ continue;
3045
3067
  if (entry.path === undefined && entry.workshop === undefined && !entry.id.includes(":"))
3046
3068
  out.push(entry.id);
3047
- };
3048
- for (const id of [...game.preCore ?? [], game.core, ...game.dlc])
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 lockClone(dir));
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() ?? join10(globalConfigDir(), "profiles.yml");
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 (!existsSync5(target.file)) {
3179
- await mkdir2(dirname5(target.file), { recursive: true });
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
- wanted.push({ id, git: entry.git, entry });
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 missing = only.filter((id) => !wanted.some((pin) => pin.id.toLowerCase() === id));
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 lockClone(dir);
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 dir = join10(game.workshopRoot, String(source.value));
3330
- const id = await readId(dir, game.manifest.file, plugin);
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 lockClone(dir);
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 : join10(root, source.subdir);
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 readdir5(dir, { withFileTypes: true }).catch(() => []);
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(join10(dir, entry.name));
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 readFile6(join10(dir, manifestFile), "utf8").catch(() => {
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 existsSync6, readFileSync as readFileSync3 } from "node:fs";
4023
+ import { existsSync as existsSync7, readFileSync as readFileSync3 } from "node:fs";
3589
4024
  import { homedir as homedir3 } from "node:os";
3590
- import { basename as basename5, join as join11 } from "node:path";
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 (!existsSync6(CDI_SPEC)) {
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 (!existsSync6(files.host)) {
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 = join11(files.host, basename5(plan.gameConfig.executable));
3727
- if (!existsSync6(executable)) {
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 ${basename5(plan.gameConfig.executable)}`,
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 (existsSync6(mount.source))
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 = join11(process.env.DOCKER_CONFIG ?? join11(homedir3(), ".docker"), "config.json");
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 basename6 } from "node:path";
3793
- import { setTimeout as sleep4 } from "node:timers/promises";
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 = basename6(executable).toLowerCase();
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) => basename6(arg).startsWith("gamecrate"));
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 sleep4(POLL_MS);
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 sleep4(POLL_MS);
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 readdir6, readFile as readFile7, writeFile as writeFile4 } from "node:fs/promises";
3925
- import { dirname as dirname6, join as join12 } from "node:path";
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 readFile7(join12(expandHome(host), "Version.txt"), "utf8");
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 = join12(expandHome(host), "Data");
4379
+ const dataDir = join14(expandHome(host), "Data");
3945
4380
  let entries;
3946
4381
  try {
3947
- entries = await readdir6(dataDir, { withFileTypes: true });
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 readFile7(join12(dataDir, entry.name, game.manifest.file), "utf8"))?.packageId ?? null;
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 = join12(plan.dataDirHost, game.modsConfig.file);
3975
- await mkdir3(dirname6(target), { recursive: true });
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 = join12(plan.dataDirHost, plan.gameConfig.prefs.file);
4026
- await mkdir3(dirname6(target), { recursive: true });
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 readFile7(target, "utf8");
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 existsSync7 } from "node:fs";
4040
- import { readFile as readFile8, readlink, rm as rm2, writeFile as writeFile5 } from "node:fs/promises";
4041
- import { basename as basename7, join as join13 } from "node:path";
4042
- import { setTimeout as sleep5 } from "node:timers/promises";
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 rm2(join13(dir, ".gamecrate", "lock"), { force: true }).catch(() => {});
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(join13(instanceDir, ".gamecrate", "last-exit.json"), `${JSON.stringify(record)}
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 readFile8(join13(instanceDir, ".gamecrate", "last-exit.json"), "utf8").catch(() => {
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 = join13(instanceDir, ".gamecrate", "lock");
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 sleep5(poll);
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 = join13(instanceDir, "logs", "current");
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(basename7(target));
4590
+ const began = target === undefined ? undefined : runStartedAt(basename8(target));
4156
4591
  const current = began !== undefined && (Number.isNaN(written) || began >= written);
4157
- if (current && existsSync7(currentLog(instanceDir)))
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 sleep5(poll);
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 basename8, join as join14 } from "node:path";
4608
+ import { basename as basename9, join as join16 } from "node:path";
4174
4609
 
4175
4610
  // src/mods/worktree.ts
4176
- import { spawnSync as spawnSync2 } from "node:child_process";
4177
- import { existsSync as existsSync8, realpathSync as realpathSync2 } from "node:fs";
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 = spawnSync2("git", ["-C", dir, "rev-parse", "--path-format=absolute", "--show-toplevel", "--git-dir", "--git-common-dir", "--abbrev-ref", "HEAD"], { encoding: "utf8" });
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 (!existsSync8(abs)) {
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 : join14(profileDir, "instances", name),
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 = basename8(root).toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^[^a-z0-9]+/, "").slice(0, SLUG_LIMIT2).replace(/[-._]+$/, "");
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 join16 } from "node:path";
4726
+ import { join as join18 } from "node:path";
4292
4727
 
4293
4728
  // src/mods/modindex.ts
4294
- import { existsSync as existsSync9, readFileSync as readFileSync5, statSync as statSync2 } from "node:fs";
4295
- import { mkdir as mkdir4, readdir as readdir7, readFile as readFile9, writeFile as writeFile6 } from "node:fs/promises";
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 dirname7, join as join15, relative as relative3, sep as sep2, resolve as resolvePath } from "node:path";
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 join15(process.env["XDG_CACHE_HOME"] ?? join15(homedir4(), ".cache"), "gamecrate");
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 = join15(current, ".git");
4313
- if (existsSync9(git)) {
4748
+ const git = join17(current, ".git");
4749
+ if (existsSync10(git)) {
4314
4750
  try {
4315
- if (statSync2(git).isFile())
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 = dirname7(current);
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 = join15(dir, manifestFile);
4335
- if (existsSync9(manifest)) {
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 readdir7(dir, { withFileTypes: true });
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 = join15(dir, entry.name);
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 (!existsSync9(base))
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 readdir7(base, { withFileTypes: true });
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 = join15(base, entry.name);
4377
- if (!existsSync9(join15(dir, manifestFile)))
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 = join15(resolvePath(expandHome(host)), "Data");
4822
+ const data = join17(resolvePath(expandHome(host)), "Data");
4387
4823
  let entries;
4388
4824
  try {
4389
- entries = await readdir7(data, { withFileTypes: true });
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 = join15(data, entry.name);
4397
- if (!existsSync9(join15(dir, game.manifest.file)))
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 = 3;
4403
- function workshopStamp(game) {
4404
- if (game.workshopRoot === null)
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
- const acf = join15(dirname7(dirname7(resolvePath(expandHome(game.workshopRoot)))), `appworkshop_${game.steamAppId}.acf`);
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 = statSync2(acf);
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 readFile9(join15(cacheDir(), `${game}.workshop.json`), "utf8"));
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(join15(cacheDir(), `${game}.workshop.json`), JSON.stringify(payload));
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 = join15(dir, config.manifest.file);
4517
- if (!existsSync9(file)) {
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
- if (config.workshopRoot !== null) {
4602
- const stamp = workshopStamp(config);
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
- await scanWorkshopRoot(config.workshopRoot, cacheIndex + 1, config.manifest.file, items);
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 = join15(sourcesDir, clone);
5094
+ const at = join17(sourcesDir, clone);
4627
5095
  let stamp = stamps.get(at);
4628
5096
  if (stamp === undefined) {
4629
- stamp = statSync2(at).mtimeMs;
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 = join15(candidate.dir, config.manifest.file);
5109
+ const file = join17(candidate.dir, config.manifest.file);
4642
5110
  try {
4643
- const manifest = plugin.parseManifest(await readFile9(file, "utf8"));
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 = join15(dir, game.manifest.file);
4715
- if (!existsSync9(file))
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 noWorkshopRoot(gameName, what) {
4734
- return `${gameName} has no workshopRoot, so ${what} cannot resolve`;
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 : join16(dir, pin.subdir)}`;
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:") && game.workshopRoot === null) {
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 === true)
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: join16(instance.dir, "game"),
5057
- configDirHost: join16(profileDir, "config"),
5058
- stageDirHost: join16(instance.dir, ".stage"),
5059
- logsDirHost: join16(instance.dir, "logs"),
5060
- runDirHost: join16(instance.dir, "logs"),
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 readdir8 } from "node:fs/promises";
5073
- import { join as join17 } from "node:path";
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 = join17(dataRoot, game);
5547
+ const gameDir = join19(dataRoot, game);
5099
5548
  for (const profile of await entries(gameDir)) {
5100
- const profileDir = join17(gameDir, profile);
5101
- await push(out, join17(profileDir, ".gamecrate", "lock"));
5102
- const instancesDir = join17(profileDir, "instances");
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, join17(instancesDir, instance, ".gamecrate", "lock"));
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 readdir8(dir, { withFileTypes: true }).catch(() => []);
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 readdir9, realpath as realpath2, rm as rm3, stat as stat4 } from "node:fs/promises";
5162
- import { basename as basename9, join as join18 } from "node:path";
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 rm3(plan.stageDirHost, { recursive: true, force: true });
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 stat4(source)).isDirectory()) {
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(join18(plan.stageDirHost, basename9(mod.containerDir)), { recursive: true });
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
- join18(plan.configDirHost, "config"),
5190
- join18(plan.configDirHost, "data"),
5191
- join18(plan.configDirHost, "cache"),
5192
- join18(plan.logsDirHost, "runs"),
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
- join18(plan.instanceDir, ".gamecrate"),
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 [join18(plan.dataDirHost, modsDir.container.slice(dataDir.container.length + 1))];
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 readdir9(current))
5226
- queue.push(join18(current, entry));
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.2.0";
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 run(argv, args, config, plugins, defaults, true);
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 run(argv, args, config, plugins, defaults, false);
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 run(argv, args, config, plugins, defaults, asShell) {
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
- return await resolved(argv, args, config, plugins, asShell, game, profile, sources);
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
- async function resolved(argv, args, config, plugins, asShell, game, profile, sources) {
5372
- for (const warning of sources.warnings)
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
- if (problems.length > 0)
5377
- reportProblems(problems);
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(join19(runDir, "supervisor.log")) : undefined;
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 = [join19(logDir, STDOUT_LOG)];
6080
+ const sources = [join22(logDir, STDOUT_LOG)];
5483
6081
  const { logFile } = plan.gameConfig;
5484
6082
  if (logFile.mode === "arg")
5485
- sources.push(join19(logDir, "Player.log"));
6083
+ sources.push(join22(logDir, "Player.log"));
5486
6084
  else
5487
- sources.push(join19(plan.dataDirHost, logFile.from));
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
- sleep6(plan.timeoutSeconds * 1000).then(() => ({ kind: "timeout" }))
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 = sleep6(plan.renderWaitSeconds * 1000).then(() => "ready");
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 = join19(plan.dataDirHost, spec.from);
5556
- if (!existsSync10(source))
6153
+ const source = join22(plan.dataDirHost, spec.from);
6154
+ if (!existsSync12(source))
5557
6155
  return;
5558
- const target = join19(plan.runDirHost, basename10(spec.from.replace(/\/+$/, "")));
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 { plan, problems } = await resolvePlan({ game, profile, root: config, plugins, args, index, sources });
5571
- if (problems.length > 0)
5572
- reportProblems(problems);
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 sources = cachedSources(config.games[game], "modless", {}, config.dataRoot);
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 workshop = workshopRootProblem(game, config.games[game]);
5582
- const all = [...problems, ...await preflight(plan), ...workshop === null ? [] : [workshop]];
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 = join19(dir, "logs", "runs");
5607
- const latest = (await readdir10(runs, { withFileTypes: true }).catch(() => [])).filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort().at(-1);
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 = join19(runs, latest);
5612
- const files = (await readdir10(runDir, { withFileTypes: true })).filter((entry) => entry.isFile()).map((entry) => entry.name).sort();
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 readFile10(join19(runDir, name), "utf8").catch(() => "");
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 = join19(instanceDir(args, config, game, profile), tier === "logs" ? "logs" : ".stage");
5749
- await rm4(target, { recursive: true, force: true });
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 rm4(dir, { recursive: true, force: true });
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 readdir10(current, { withFileTypes: true });
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(join19(current, entry.name));
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 = join19(profileDataDir(config, game, src), "game");
5788
- const to = join19(profileDataDir(config, game, dst), "game");
5789
- if (!existsSync10(from))
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 (existsSync10(to) && !args.yes) {
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 = join19(instanceDir(args, config, game, profile), ".gamecrate", "lock");
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(join19(dir, ".gamecrate", "lock"));
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 (!existsSync10(file)) {
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 ?? join19(globalConfigDir(), "profiles.yml");
5891
- await mkdir6(dirname8(path), { recursive: true });
5892
- if (!existsSync10(path)) {
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 (!existsSync10(dir))
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 stat5(path).catch(() => null);
6600
+ const info = await stat6(path).catch(() => null);
5953
6601
  if (info === null || !info.isDirectory())
5954
6602
  return false;
5955
- const entries = await readdir10(path).catch(() => null);
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);