@gamecrate/cli 1.2.0 → 1.3.0

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