@gamecrate/cli 1.3.0 → 1.4.1

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,10 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
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";
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 basename11, dirname as dirname9, join as join22 } from "node:path";
7
+ import { basename as basename11, dirname as dirname10, join as join22 } from "node:path";
8
8
  import { setTimeout as sleep7 } from "node:timers/promises";
9
9
 
10
10
  // src/cli/args.ts
@@ -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) {
@@ -313,11 +313,14 @@ function checkValueTokens(program, head) {
313
313
  const value = head[++i];
314
314
  if (value === undefined)
315
315
  return;
316
- if (option.long === "--docker-arg")
317
- continue;
318
- if (value.startsWith("-") && value.length > 1) {
319
- throw usage(`${option.long ?? token} needs a value, got the flag ${value}`);
320
- }
316
+ checkValue(option, token, value);
317
+ }
318
+ }
319
+ function checkValue(option, token, value) {
320
+ if (option.long === "--docker-arg")
321
+ return;
322
+ if (value.startsWith("-") && value.length > 1) {
323
+ throw usage(`${option.long ?? token} needs a value, got the flag ${value}`);
321
324
  }
322
325
  }
323
326
  function parseArgs(argv, opts = {}) {
@@ -325,47 +328,16 @@ function parseArgs(argv, opts = {}) {
325
328
  const sep = argv.indexOf("--");
326
329
  const head = sep === -1 ? argv : argv.slice(0, sep);
327
330
  const program = buildProgram();
328
- const seen = new Set;
329
- const counts = new Map;
330
- const worktree = [];
331
- let cleanTier;
332
- for (const option of program.options) {
333
- const long = option.long ?? option.flags;
334
- program.on(`option:${option.name()}`, (value) => {
335
- seen.add(long);
336
- counts.set(long, (counts.get(long) ?? 0) + 1);
337
- if (long === "--worktree" && value !== undefined)
338
- worktree.push(value);
339
- if (long === "--staging" || long === "--logs" || long === "--all") {
340
- cleanTier = long.slice(2);
341
- }
342
- });
343
- }
331
+ const log = recordFlags(program);
332
+ const { seen, counts, worktree } = log;
344
333
  checkValueTokens(program, head);
345
334
  try {
346
335
  program.parse(head, { from: "user" });
347
336
  } catch (error) {
348
337
  throw translate(error, program);
349
338
  }
350
- for (const option of program.options) {
351
- const long = option.long ?? option.flags;
352
- const repeatable = Array.isArray(option.defaultValue);
353
- if (option.required && !repeatable && (counts.get(long) ?? 0) > 1) {
354
- throw usage(`${long} given more than once`);
355
- }
356
- }
357
- if (seen.has("--build") && seen.has("--no-build"))
358
- throw usage("--build and --no-build contradict");
359
- if (seen.has("--replace") && seen.has("--no-replace"))
360
- throw usage("--replace and --no-replace contradict");
361
- if (seen.has("--detach")) {
362
- if (seen.has("--no-detach"))
363
- throw usage("--detach and --no-detach contradict");
364
- if (seen.has("--dry-run"))
365
- throw usage("--detach and --dry-run contradict");
366
- if (seen.has("--print-plan"))
367
- throw usage("--detach and --print-plan contradict");
368
- }
339
+ checkRepeats(program, counts);
340
+ checkContradictions(seen);
369
341
  const values = program.opts();
370
342
  const envBuild = applyEnv(program, seen, env, values);
371
343
  const out = {
@@ -405,7 +377,7 @@ function parseArgs(argv, opts = {}) {
405
377
  out.sort = values["sort"];
406
378
  out.instance = values["instance"];
407
379
  out.build = envBuild ?? policy(values["build"]);
408
- out.cleanTier = cleanTier;
380
+ out.cleanTier = log.cleanTier;
409
381
  if (opts.defaults?.game !== undefined)
410
382
  out.game = opts.defaults.game;
411
383
  applyPositionals(out, program.args, opts.games, out.help);
@@ -422,6 +394,45 @@ function parseArgs(argv, opts = {}) {
422
394
  }
423
395
  return out;
424
396
  }
397
+ function recordFlags(program) {
398
+ const log = { seen: new Set, counts: new Map, worktree: [] };
399
+ for (const option of program.options) {
400
+ const long = option.long ?? option.flags;
401
+ program.on(`option:${option.name()}`, (value) => {
402
+ log.seen.add(long);
403
+ log.counts.set(long, (log.counts.get(long) ?? 0) + 1);
404
+ if (long === "--worktree" && value !== undefined)
405
+ log.worktree.push(value);
406
+ if (long === "--staging" || long === "--logs" || long === "--all" || long === "--downloads") {
407
+ log.cleanTier = long.slice(2);
408
+ }
409
+ });
410
+ }
411
+ return log;
412
+ }
413
+ function checkRepeats(program, counts) {
414
+ for (const option of program.options) {
415
+ const long = option.long ?? option.flags;
416
+ const repeatable = Array.isArray(option.defaultValue);
417
+ if (option.required && !repeatable && (counts.get(long) ?? 0) > 1) {
418
+ throw usage(`${long} given more than once`);
419
+ }
420
+ }
421
+ }
422
+ function checkContradictions(seen) {
423
+ if (seen.has("--build") && seen.has("--no-build"))
424
+ throw usage("--build and --no-build contradict");
425
+ if (seen.has("--replace") && seen.has("--no-replace"))
426
+ throw usage("--replace and --no-replace contradict");
427
+ if (!seen.has("--detach"))
428
+ return;
429
+ if (seen.has("--no-detach"))
430
+ throw usage("--detach and --no-detach contradict");
431
+ if (seen.has("--dry-run"))
432
+ throw usage("--detach and --dry-run contradict");
433
+ if (seen.has("--print-plan"))
434
+ throw usage("--detach and --print-plan contradict");
435
+ }
425
436
  function policy(value) {
426
437
  if (value === true)
427
438
  return "always";
@@ -454,22 +465,22 @@ function applyPositionals(out, positional, games, help = false) {
454
465
  out.help = true;
455
466
  return;
456
467
  }
468
+ const { sub, slots, rest } = routePositionals(out, first, positional, games);
469
+ const left = fillSlots(out, slots, rest);
470
+ if (!help && (out.subverb === "add" || out.subverb === "rm")) {
471
+ if (out.game === undefined)
472
+ throw usage(`mods ${out.subverb} needs a game`);
473
+ if (out.subverb === "rm" && out.rest.length === 0)
474
+ throw usage("mods rm needs at least one mod id");
475
+ }
476
+ if (left.length > 0) {
477
+ const shape = sub ? `${sub.name} ${sub.usage}`.trim() : `${out.game} [profile]`;
478
+ throw usage(`unexpected argument ${left[0]}`, `gamecrate ${shape}`);
479
+ }
480
+ }
481
+ function routePositionals(out, first, positional, games) {
457
482
  const sub = SUBCOMMANDS.find((s) => s.name === first);
458
- let slots;
459
- let rest;
460
- if (sub) {
461
- out.subcommand = sub.name;
462
- rest = positional.slice(1);
463
- const head = rest[0];
464
- const verbSlots = head === undefined || sub.subverbs === undefined ? undefined : own(sub.subverbs, head);
465
- if (verbSlots === undefined) {
466
- slots = [...sub.positionals];
467
- } else {
468
- out.subverb = head;
469
- rest.shift();
470
- slots = [...verbSlots];
471
- }
472
- } else {
483
+ if (!sub) {
473
484
  const known = !NAME_PATTERN.test(first) ? false : games === undefined || games.includes(first);
474
485
  if (!known) {
475
486
  const candidates = [...SUBCOMMANDS.map((s) => s.name), ...games ?? []];
@@ -477,16 +488,26 @@ function applyPositionals(out, positional, games, help = false) {
477
488
  }
478
489
  out.subcommand = "run";
479
490
  out.game = first;
480
- slots = ["profile", "rest"];
481
- rest = positional.slice(1);
482
- }
491
+ return { slots: ["profile", "rest"], rest: positional.slice(1) };
492
+ }
493
+ out.subcommand = sub.name;
494
+ const rest = positional.slice(1);
495
+ const head = rest[0];
496
+ const verbSlots = head === undefined || sub.subverbs === undefined ? undefined : own(sub.subverbs, head);
497
+ if (verbSlots === undefined)
498
+ return { sub, slots: [...sub.positionals], rest };
499
+ out.subverb = head;
500
+ rest.shift();
501
+ return { sub, slots: [...verbSlots], rest };
502
+ }
503
+ function fillSlots(out, slots, rest) {
504
+ const left = [...rest];
483
505
  for (const slot of slots) {
484
506
  if (slot === "rest") {
485
- out.rest = rest;
486
- rest = [];
487
- break;
507
+ out.rest = left;
508
+ return [];
488
509
  }
489
- const value = rest.shift();
510
+ const value = left.shift();
490
511
  if (value === undefined)
491
512
  break;
492
513
  if (!NAME_PATTERN.test(value))
@@ -496,16 +517,7 @@ function applyPositionals(out, positional, games, help = false) {
496
517
  else
497
518
  out.profile = value;
498
519
  }
499
- if (!help && (out.subverb === "add" || out.subverb === "rm")) {
500
- if (out.game === undefined)
501
- throw usage(`mods ${out.subverb} needs a game`);
502
- if (out.subverb === "rm" && out.rest.length === 0)
503
- throw usage("mods rm needs at least one mod id");
504
- }
505
- if (rest.length > 0) {
506
- const shape = sub ? `${sub.name} ${sub.usage}`.trim() : `${out.game} [profile]`;
507
- throw usage(`unexpected argument ${rest[0]}`, `gamecrate ${shape}`);
508
- }
520
+ return left;
509
521
  }
510
522
  function applyEnv(program, seen, env, values) {
511
523
  let build;
@@ -519,27 +531,38 @@ function applyEnv(program, seen, env, values) {
519
531
  continue;
520
532
  seen.add(flag);
521
533
  if (flag === "--build") {
522
- if (!BUILD_POLICIES.includes(raw)) {
523
- throw usage(`${name} must be one of ${BUILD_POLICIES.join(", ")}, got ${raw}`);
524
- }
525
- build = raw;
534
+ build = envBuildPolicy(name, raw);
526
535
  continue;
527
536
  }
528
- const option = program.options.find((o) => o.long === flag);
529
- const key = option.attributeName();
530
- if (!option.required) {
531
- if (truthy(raw))
532
- values[key] = true;
533
- continue;
534
- }
535
- if (option.argChoices && !option.argChoices.includes(raw)) {
536
- throw usage(`${name} must be one of ${option.argChoices.join(", ")}, got ${raw}`);
537
- }
538
- values[key] = option.parseArg === undefined ? raw : option.parseArg(raw, values[key]);
537
+ applyEnvValue(program, flag, name, raw, values);
539
538
  }
540
539
  return build;
541
540
  }
541
+ function envBuildPolicy(name, raw) {
542
+ if (!BUILD_POLICIES.includes(raw)) {
543
+ throw usage(`${name} must be one of ${BUILD_POLICIES.join(", ")}, got ${raw}`);
544
+ }
545
+ return raw;
546
+ }
547
+ function applyEnvValue(program, flag, name, raw, values) {
548
+ const option = program.options.find((o) => o.long === flag);
549
+ const key = option.attributeName();
550
+ if (!option.required) {
551
+ if (truthy(raw))
552
+ values[key] = true;
553
+ return;
554
+ }
555
+ if (option.argChoices && !option.argChoices.includes(raw)) {
556
+ throw usage(`${name} must be one of ${option.argChoices.join(", ")}, got ${raw}`);
557
+ }
558
+ values[key] = option.parseArg === undefined ? raw : option.parseArg(raw, values[key]);
559
+ }
542
560
  function applyDefaults(out, seen, defaults, hasGameArgs) {
561
+ applyListDefaults(out, seen, defaults, hasGameArgs);
562
+ applyScalarDefaults(out, defaults);
563
+ applyFlagDefaults(out, seen, defaults);
564
+ }
565
+ function applyListDefaults(out, seen, defaults, hasGameArgs) {
543
566
  if (!seen.has("--mod") && defaults.mods !== undefined)
544
567
  out.mods = [...defaults.mods];
545
568
  if (!seen.has("--without") && defaults.without !== undefined)
@@ -555,6 +578,8 @@ function applyDefaults(out, seen, defaults, hasGameArgs) {
555
578
  out.use = [...defaults.use];
556
579
  if (!hasGameArgs && defaults.gameArgs !== undefined)
557
580
  out.gameArgs = [...defaults.gameArgs];
581
+ }
582
+ function applyScalarDefaults(out, defaults) {
558
583
  out.mode ??= defaults.mode;
559
584
  out.marker ??= defaults.marker;
560
585
  out.timeout ??= defaults.timeout;
@@ -566,6 +591,8 @@ function applyDefaults(out, seen, defaults, hasGameArgs) {
566
591
  out.build ??= defaults.build;
567
592
  out.sort ??= defaults.sort;
568
593
  out.instance ??= defaults.instance;
594
+ }
595
+ function applyFlagDefaults(out, seen, defaults) {
569
596
  if (!seen.has("--dry-run"))
570
597
  out.dryRun = defaults.dryRun ?? out.dryRun;
571
598
  if (!seen.has("--print-plan"))
@@ -639,7 +666,11 @@ function modSource(values) {
639
666
  if (kinds.length > 1)
640
667
  throw usage(`${kinds[0]} and ${kinds[1]} contradict: a source has one kind`);
641
668
  if (url === undefined) {
642
- const stray = refs[0] === undefined ? subdir === undefined ? undefined : "--subdir" : `--${refs[0]}`;
669
+ let stray;
670
+ if (refs[0] !== undefined)
671
+ stray = `--${refs[0]}`;
672
+ else if (subdir !== undefined)
673
+ stray = "--subdir";
643
674
  if (stray !== undefined)
644
675
  throw usage(`${stray} only applies to a --git source`);
645
676
  }
@@ -739,25 +770,7 @@ function list(args, config, defaults) {
739
770
  const games = args.game === undefined ? Object.keys(config.games) : [requireGame(args, config)];
740
771
  const fromProject = (game, profile) => defaults.game === game && own(defaults.profiles, profile) !== undefined;
741
772
  if (args.json) {
742
- const payload = games.map((name) => {
743
- const game = config.games[name];
744
- return {
745
- game: name,
746
- core: game.core,
747
- dlc: game.dlc,
748
- modes: game.modes,
749
- profiles: Object.entries(game.profiles).map(([profile, spec]) => ({
750
- profile,
751
- alias: spec.alias ?? null,
752
- description: spec.description ?? null,
753
- extends: spec.extends ?? null,
754
- mods: spec.mods?.length ?? 0,
755
- instances: Object.keys(spec.instances ?? {}),
756
- source: fromProject(name, profile) ? "project" : "config"
757
- }))
758
- };
759
- });
760
- process.stdout.write(`${JSON.stringify(payload, null, 2)}
773
+ process.stdout.write(`${JSON.stringify(jsonReport(games, config, fromProject), null, 2)}
761
774
  `);
762
775
  return Exit.Ok;
763
776
  }
@@ -766,27 +779,9 @@ function list(args, config, defaults) {
766
779
  for (const name of games) {
767
780
  const game = config.games[name];
768
781
  const width = Math.max(7, ...Object.keys(game.profiles).map((n) => n.length));
769
- out.push(`${name} (${game.modes.join(", ")})`);
770
- out.push(` ${"modless".padEnd(width)} built-in: core + official DLC`);
782
+ out.push(`${name} (${game.modes.join(", ")})`, ` ${"modless".padEnd(width)} built-in: core + official DLC`);
771
783
  for (const [profile, spec] of Object.entries(game.profiles)) {
772
- const notes = [];
773
- if (spec.alias)
774
- notes.push(`alias for ${spec.alias}`);
775
- if (spec.extends)
776
- notes.push(`extends ${spec.extends}`);
777
- const count = spec.mods?.length ?? 0;
778
- if (!spec.alias)
779
- notes.push(count === 1 ? "1 entry" : `${count} entries`);
780
- if (spec.aliases?.length)
781
- notes.push(`aka ${spec.aliases.join(", ")}`);
782
- if (fromProject(name, profile))
783
- notes.push(`from ${source}`);
784
- out.push(` ${profile.padEnd(width)} ${notes.join(", ")}`);
785
- if (spec.description)
786
- out.push(` ${" ".repeat(width)} ${spec.description}`);
787
- const instances = Object.keys(spec.instances ?? {});
788
- if (instances.length > 0)
789
- out.push(` ${" ".repeat(width)} instances: ${instances.join(", ")}`);
784
+ out.push(...profileRows(profile, spec, width, profileNotes(spec, fromProject(name, profile), source)));
790
785
  }
791
786
  }
792
787
  process.stdout.write(`${out.join(`
@@ -794,11 +789,56 @@ function list(args, config, defaults) {
794
789
  `);
795
790
  return Exit.Ok;
796
791
  }
792
+ function jsonReport(games, config, fromProject) {
793
+ return games.map((name) => {
794
+ const game = config.games[name];
795
+ return {
796
+ game: name,
797
+ core: game.core,
798
+ dlc: game.dlc,
799
+ modes: game.modes,
800
+ profiles: Object.entries(game.profiles).map(([profile, spec]) => ({
801
+ profile,
802
+ alias: spec.alias ?? null,
803
+ description: spec.description ?? null,
804
+ extends: spec.extends ?? null,
805
+ mods: spec.mods?.length ?? 0,
806
+ instances: Object.keys(spec.instances ?? {}),
807
+ source: fromProject(name, profile) ? "project" : "config"
808
+ }))
809
+ };
810
+ });
811
+ }
812
+ function profileNotes(spec, isProject, source) {
813
+ const notes = [];
814
+ if (spec.alias)
815
+ notes.push(`alias for ${spec.alias}`);
816
+ if (spec.extends)
817
+ notes.push(`extends ${spec.extends}`);
818
+ const count = spec.mods?.length ?? 0;
819
+ if (!spec.alias)
820
+ notes.push(count === 1 ? "1 entry" : `${count} entries`);
821
+ if (spec.aliases?.length)
822
+ notes.push(`aka ${spec.aliases.join(", ")}`);
823
+ if (isProject)
824
+ notes.push(`from ${source}`);
825
+ return notes;
826
+ }
827
+ function profileRows(profile, spec, width, notes) {
828
+ const pad = " ".repeat(width);
829
+ const rows = [` ${profile.padEnd(width)} ${notes.join(", ")}`];
830
+ if (spec.description)
831
+ rows.push(` ${pad} ${spec.description}`);
832
+ const instances = Object.keys(spec.instances ?? {});
833
+ if (instances.length > 0)
834
+ rows.push(` ${pad} instances: ${instances.join(", ")}`);
835
+ return rows;
836
+ }
797
837
 
798
838
  // src/cli/mods.ts
799
- import { existsSync as existsSync5 } from "node:fs";
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";
839
+ import { existsSync as existsSync6 } from "node:fs";
840
+ import { mkdir as mkdir2, readFile as readFile7, readdir as readdir6, writeFile as writeFile3 } from "node:fs/promises";
841
+ import { dirname as dirname7, join as join12, relative as relative2, resolve as resolve4 } from "node:path";
802
842
 
803
843
  // src/config/load.ts
804
844
  import { z as z2 } from "zod";
@@ -1262,53 +1302,69 @@ function crossReference(p, games) {
1262
1302
  const profiles = game_["profiles"];
1263
1303
  if (!isObj(profiles))
1264
1304
  continue;
1265
- const names = Object.keys(profiles);
1266
1305
  for (const [name, prof] of Object.entries(profiles)) {
1267
1306
  const w = `${where}/profiles/${esc(name)}`;
1268
1307
  checkName(p, w, name, "profile");
1269
- if (!isObj(prof))
1270
- continue;
1271
- const parent = prof["extends"];
1272
- if (typeof parent === "string" && !resolves(profiles, parent)) {
1273
- const prob = { where: `${w}/extends`, message: `extends unknown profile "${parent}"` };
1274
- const hint = suggest(parent, names);
1275
- if (hint)
1276
- prob.suggestion = `did you mean "${hint}"?`;
1277
- p.push(prob);
1278
- }
1279
- const alias = prof["alias"];
1280
- if (typeof alias === "string" && !resolves(profiles, alias)) {
1281
- const prob = { where: `${w}/alias`, message: `alias of unknown profile "${alias}"` };
1282
- const hint = suggest(alias, [...names, "modless"]);
1283
- if (hint)
1284
- prob.suggestion = `did you mean "${hint}"?`;
1285
- p.push(prob);
1286
- }
1287
- if (typeof alias === "string" && alias.toLowerCase() === name.toLowerCase()) {
1288
- p.push({ where: `${w}/alias`, message: "a profile cannot alias itself" });
1289
- }
1290
- const aliases = prof["aliases"];
1291
- if (Array.isArray(aliases)) {
1292
- for (const [i, entry] of aliases.entries()) {
1293
- if (typeof entry !== "string")
1294
- continue;
1295
- const at = `${w}/aliases/${i}`;
1296
- checkName(p, at, entry, "profile alias");
1297
- if (names.some((k) => k.toLowerCase() === entry.toLowerCase())) {
1298
- p.push({ where: at, message: `alias "${entry}" is already a profile name` });
1299
- }
1300
- }
1301
- }
1302
- const instances = prof["instances"];
1303
- if (isObj(instances)) {
1304
- for (const instance of Object.keys(instances)) {
1305
- checkName(p, `${w}/instances/${esc(instance)}`, instance, "instance");
1306
- }
1307
- }
1308
+ if (isObj(prof))
1309
+ checkProfile(p, w, name, prof, profiles);
1308
1310
  }
1309
1311
  checkCollisions(p, where, gameName, profiles, containers);
1310
1312
  }
1311
1313
  }
1314
+ function checkProfile(p, w, name, prof, profiles) {
1315
+ const names = Object.keys(profiles);
1316
+ checkExtends(p, w, prof, profiles, names);
1317
+ checkAlias(p, w, name, prof, profiles, names);
1318
+ checkAliases(p, w, prof, names);
1319
+ checkInstances(p, w, prof);
1320
+ }
1321
+ function checkExtends(p, w, prof, profiles, names) {
1322
+ const parent = prof["extends"];
1323
+ if (typeof parent !== "string" || resolves(profiles, parent))
1324
+ return;
1325
+ const prob = { where: `${w}/extends`, message: `extends unknown profile "${parent}"` };
1326
+ const hint = suggest(parent, names);
1327
+ if (hint)
1328
+ prob.suggestion = `did you mean "${hint}"?`;
1329
+ p.push(prob);
1330
+ }
1331
+ function checkAlias(p, w, name, prof, profiles, names) {
1332
+ const alias = prof["alias"];
1333
+ if (typeof alias !== "string")
1334
+ return;
1335
+ if (!resolves(profiles, alias)) {
1336
+ const prob = { where: `${w}/alias`, message: `alias of unknown profile "${alias}"` };
1337
+ const hint = suggest(alias, [...names, "modless"]);
1338
+ if (hint)
1339
+ prob.suggestion = `did you mean "${hint}"?`;
1340
+ p.push(prob);
1341
+ }
1342
+ if (alias.toLowerCase() === name.toLowerCase()) {
1343
+ p.push({ where: `${w}/alias`, message: "a profile cannot alias itself" });
1344
+ }
1345
+ }
1346
+ function checkAliases(p, w, prof, names) {
1347
+ const aliases = prof["aliases"];
1348
+ if (!Array.isArray(aliases))
1349
+ return;
1350
+ for (const [i, entry] of aliases.entries()) {
1351
+ if (typeof entry !== "string")
1352
+ continue;
1353
+ const at = `${w}/aliases/${i}`;
1354
+ checkName(p, at, entry, "profile alias");
1355
+ if (names.some((k) => k.toLowerCase() === entry.toLowerCase())) {
1356
+ p.push({ where: at, message: `alias "${entry}" is already a profile name` });
1357
+ }
1358
+ }
1359
+ }
1360
+ function checkInstances(p, w, prof) {
1361
+ const instances = prof["instances"];
1362
+ if (!isObj(instances))
1363
+ return;
1364
+ for (const instance of Object.keys(instances)) {
1365
+ checkName(p, `${w}/instances/${esc(instance)}`, instance, "instance");
1366
+ }
1367
+ }
1312
1368
  function resolves(profiles, name) {
1313
1369
  if (name.toLowerCase() === "modless")
1314
1370
  return true;
@@ -1338,36 +1394,45 @@ function checkCollisions(p, where, gameName, profiles, containers) {
1338
1394
  containers.set(prefix + lower, w);
1339
1395
  if (!isObj(prof))
1340
1396
  continue;
1341
- const aliases = prof["aliases"];
1342
- if (Array.isArray(aliases)) {
1343
- for (const [i, entry] of aliases.entries()) {
1344
- if (typeof entry !== "string")
1345
- continue;
1346
- const owner = aliasOwners.get(entry.toLowerCase());
1347
- if (owner !== undefined) {
1348
- p.push({
1349
- where: `${w}/aliases/${i}`,
1350
- message: `alias "${entry}" is already declared by profile "${owner}"`
1351
- });
1352
- continue;
1353
- }
1354
- aliasOwners.set(entry.toLowerCase(), name);
1355
- }
1397
+ checkAliasOwners(p, w, prof, name, aliasOwners);
1398
+ checkInstanceContainers(p, w, profiles, name, `${prefix}${lower}`, containers);
1399
+ }
1400
+ }
1401
+ function checkAliasOwners(p, w, prof, name, owners) {
1402
+ const aliases = prof["aliases"];
1403
+ if (!Array.isArray(aliases))
1404
+ return;
1405
+ for (const [i, entry] of aliases.entries()) {
1406
+ if (typeof entry !== "string")
1407
+ continue;
1408
+ const owner = owners.get(entry.toLowerCase());
1409
+ if (owner !== undefined) {
1410
+ p.push({
1411
+ where: `${w}/aliases/${i}`,
1412
+ message: `alias "${entry}" is already declared by profile "${owner}"`
1413
+ });
1414
+ continue;
1356
1415
  }
1357
- const declared = prof["instances"];
1358
- for (const instance of instanceNames(profiles, name, prof)) {
1359
- const container = `${prefix}${lower}-${instance.toLowerCase()}`;
1360
- const at = isObj(declared) && Object.hasOwn(declared, instance) ? `${w}/instances/${esc(instance)}` : w;
1361
- const first = containers.get(container);
1362
- if (first !== undefined) {
1363
- p.push({
1364
- where: at,
1365
- message: `instance "${instance}" makes a container name that collides with ${first}`
1366
- });
1367
- continue;
1368
- }
1369
- containers.set(container, at);
1416
+ owners.set(entry.toLowerCase(), name);
1417
+ }
1418
+ }
1419
+ function checkInstanceContainers(p, w, profiles, name, prefix, containers) {
1420
+ const prof = profiles[name];
1421
+ if (!isObj(prof))
1422
+ return;
1423
+ const declared = prof["instances"];
1424
+ for (const instance of instanceNames(profiles, name, prof)) {
1425
+ const container = `${prefix}-${instance.toLowerCase()}`;
1426
+ const at = isObj(declared) && Object.hasOwn(declared, instance) ? `${w}/instances/${esc(instance)}` : w;
1427
+ const first = containers.get(container);
1428
+ if (first !== undefined) {
1429
+ p.push({
1430
+ where: at,
1431
+ message: `instance "${instance}" makes a container name that collides with ${first}`
1432
+ });
1433
+ continue;
1370
1434
  }
1435
+ containers.set(container, at);
1371
1436
  }
1372
1437
  }
1373
1438
  function instanceNames(profiles, name, prof) {
@@ -1389,7 +1454,7 @@ function checkName(p, where, name, kind) {
1389
1454
  }
1390
1455
  }
1391
1456
  function esc(segment) {
1392
- return segment.replace(/~/g, "~0").replace(/\//g, "~1");
1457
+ return segment.replaceAll("~", "~0").replaceAll("/", "~1");
1393
1458
  }
1394
1459
  function isObj(v) {
1395
1460
  return typeof v === "object" && v !== null && !Array.isArray(v);
@@ -1416,8 +1481,9 @@ async function probe(dir, stem) {
1416
1481
  }
1417
1482
  }
1418
1483
  if (found.length > 1) {
1419
- throw new GamecrateError(`two configs in ${dir}`, Exit.Config, `${found.map((f) => ` ${basename2(f)}`).join(`
1420
- `)}
1484
+ const rows = found.map((f) => ` ${basename2(f)}`).join(`
1485
+ `);
1486
+ throw new GamecrateError(`two configs in ${dir}`, Exit.Config, `${rows}
1421
1487
  keep one`);
1422
1488
  }
1423
1489
  return found[0];
@@ -1599,7 +1665,7 @@ var GAME_SCOPED_KEYS = ["profiles", "settings", "library"];
1599
1665
  function origin(where, user, plugins, project) {
1600
1666
  if (!where.startsWith("/"))
1601
1667
  return "";
1602
- const segments = where.slice(1).split("/").map((s) => s.replace(/~1/g, "/").replace(/~0/g, "~"));
1668
+ const segments = where.slice(1).split("/").map((s) => s.replaceAll("~1", "/").replaceAll("~0", "~"));
1603
1669
  const [section, name, sub, ...rest] = segments;
1604
1670
  const repoGame = project?.game;
1605
1671
  const repoSection = GAME_SCOPED_KEYS.find((k) => k === sub);
@@ -1720,7 +1786,7 @@ function entryIds(entry) {
1720
1786
  return [];
1721
1787
  }
1722
1788
  function globToRegExp(pattern) {
1723
- const body = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".");
1789
+ const body = pattern.replaceAll(/[.+^${}()|[\]\\]/g, String.raw`\$&`).replaceAll("*", ".*").replaceAll("?", ".");
1724
1790
  return new RegExp(`^${body}$`, "i");
1725
1791
  }
1726
1792
  function deepMerge(base, over, concatArrays = false) {
@@ -1814,7 +1880,7 @@ function unflowGrownMaps(doc, path) {
1814
1880
  function editYaml(text, edits) {
1815
1881
  const doc = parseDocument2(text);
1816
1882
  if (doc.errors.length > 0)
1817
- throw doc.errors[0];
1883
+ throw new GamecrateError(doc.errors[0].message, Exit.Config);
1818
1884
  for (const edit of edits) {
1819
1885
  if (edit.value === undefined) {
1820
1886
  if (doc.hasIn(edit.path))
@@ -1876,83 +1942,25 @@ function buildRunSpec(plan, modMounts, identity) {
1876
1942
  USER: identity.user,
1877
1943
  LOGNAME: identity.user
1878
1944
  };
1879
- if (game.gameFiles.source === "mount") {
1880
- if (!game.gameFiles.host) {
1881
- throw new GamecrateError(`gameFiles.source is "mount" but no host path is set for ${plan.game}`, Exit.Config);
1882
- }
1883
- mounts.push({ type: "bind", source: hostPath(game.gameFiles.host), target: game.gameFiles.container, readonly: true });
1884
- }
1885
- mounts.push({ type: "bind", source: hostPath(plan.stageDirHost), target: game.modsDir.container, readonly: true });
1886
- for (const mount of modMounts) {
1887
- mounts.push(mount.type === "bind" ? { ...mount, readonly: true } : mount);
1888
- }
1889
- mounts.push({ type: "bind", source: hostPath(plan.dataDirHost), target: game.dataDir.container });
1945
+ addGameFiles(mounts, plan);
1946
+ addStage(mounts, plan, modMounts);
1890
1947
  const command = headed ? [game.executable] : [
1891
1948
  "xvfb-run",
1892
1949
  "-a",
1893
1950
  `--server-args=-screen 0 ${settings.width}x${settings.height}x24`,
1894
1951
  game.executable
1895
1952
  ];
1896
- if (game.dataDir.mode === "arg") {
1953
+ if (game.dataDir.mode === "arg")
1897
1954
  command.push(validateDataDirArg(game.dataDir));
1898
- } else {
1955
+ else
1899
1956
  Object.assign(env, game.dataDir.env);
1900
- }
1901
1957
  if (game.logFile.mode === "arg") {
1902
1958
  mounts.push({ type: "bind", source: hostPath(plan.runDirHost), target: CONTAINER_LOG_DIR });
1903
1959
  command.push(game.logFile.arg, `${CONTAINER_LOG_DIR}/Player.log`);
1904
1960
  }
1905
- for (const target of game.modsDir.mask ?? []) {
1906
- mounts.push({ type: "tmpfs", target, size: MASK_SIZE, uid: identity.uid, gid: identity.gid, mode: "755" });
1907
- }
1908
- if (identity.uid !== 0) {
1909
- mounts.push({ type: "tmpfs", target: identity.home, size: HOME_SIZE, uid: identity.uid, gid: identity.gid, mode: "700" });
1910
- }
1911
- mounts.push({
1912
- type: "tmpfs",
1913
- target: CONTAINER_RUNTIME_DIR,
1914
- size: RUNTIME_DIR_SIZE,
1915
- uid: identity.uid,
1916
- gid: identity.gid,
1917
- mode: "700"
1918
- });
1919
- env.XDG_RUNTIME_DIR = CONTAINER_RUNTIME_DIR;
1920
- mounts.push({ type: "bind", source: hostPath(plan.configDirHost), target: CONTAINER_XDG_DIR });
1921
- env.XDG_CONFIG_HOME = `${CONTAINER_XDG_DIR}/config`;
1922
- env.XDG_CACHE_HOME = `${CONTAINER_XDG_DIR}/cache`;
1923
- env.XDG_DATA_HOME ??= `${CONTAINER_XDG_DIR}/data`;
1924
- if (headed) {
1925
- if (settings.display === "x11") {
1926
- const x11 = x11Session();
1927
- if (x11) {
1928
- mounts.push({ type: "bind", source: X11_SOCKET_DIR, target: X11_SOCKET_DIR });
1929
- env.DISPLAY = x11.display;
1930
- env.XDG_SESSION_TYPE = "x11";
1931
- env.SDL_VIDEODRIVER = "x11";
1932
- env.QT_QPA_PLATFORM = "xcb";
1933
- if (x11.xauthority) {
1934
- mounts.push({ type: "bind", source: x11.xauthority, target: CONTAINER_XAUTHORITY, readonly: true });
1935
- env.XAUTHORITY = CONTAINER_XAUTHORITY;
1936
- }
1937
- }
1938
- } else {
1939
- const wayland = waylandSocket();
1940
- if (wayland) {
1941
- const target = `${CONTAINER_RUNTIME_DIR}/${wayland.name}`;
1942
- mounts.push({ type: "bind", source: wayland.source, target });
1943
- env.WAYLAND_DISPLAY = wayland.name;
1944
- env.XDG_SESSION_TYPE = "wayland";
1945
- env.SDL_VIDEODRIVER = "wayland";
1946
- env.QT_QPA_PLATFORM = "wayland";
1947
- }
1948
- }
1949
- if (settings.audio) {
1950
- for (const socket of audioSockets()) {
1951
- mounts.push({ type: "bind", source: socket.source, target: `${CONTAINER_RUNTIME_DIR}/${socket.name}` });
1952
- }
1953
- env.PULSE_SERVER = `unix:${CONTAINER_RUNTIME_DIR}/pulse/native`;
1954
- }
1955
- }
1961
+ addScratch(mounts, env, plan, identity);
1962
+ if (headed)
1963
+ addSession(mounts, env, plan);
1956
1964
  const deviceCgroupRules = [];
1957
1965
  if (settings.input) {
1958
1966
  mounts.push({ type: "bind", source: "/dev/input", target: "/dev/input", readonly: true });
@@ -1988,6 +1996,75 @@ function buildRunSpec(plan, modMounts, identity) {
1988
1996
  extraArgs: [...settings.dockerArgs ?? []]
1989
1997
  };
1990
1998
  }
1999
+ function addGameFiles(mounts, plan) {
2000
+ const { gameFiles } = plan.gameConfig;
2001
+ if (gameFiles.source !== "mount")
2002
+ return;
2003
+ if (!gameFiles.host) {
2004
+ throw new GamecrateError(`gameFiles.source is "mount" but no host path is set for ${plan.game}`, Exit.Config);
2005
+ }
2006
+ mounts.push({ type: "bind", source: hostPath(gameFiles.host), target: gameFiles.container, readonly: true });
2007
+ }
2008
+ function addStage(mounts, plan, modMounts) {
2009
+ const game = plan.gameConfig;
2010
+ mounts.push({ type: "bind", source: hostPath(plan.stageDirHost), target: game.modsDir.container, readonly: true });
2011
+ for (const mount of modMounts) {
2012
+ mounts.push(mount.type === "bind" ? { ...mount, readonly: true } : mount);
2013
+ }
2014
+ mounts.push({ type: "bind", source: hostPath(plan.dataDirHost), target: game.dataDir.container });
2015
+ }
2016
+ function addScratch(mounts, env, plan, identity) {
2017
+ const { uid, gid } = identity;
2018
+ for (const target of plan.gameConfig.modsDir.mask ?? []) {
2019
+ mounts.push({ type: "tmpfs", target, size: MASK_SIZE, uid, gid, mode: "755" });
2020
+ }
2021
+ if (uid !== 0) {
2022
+ mounts.push({ type: "tmpfs", target: identity.home, size: HOME_SIZE, uid, gid, mode: "700" });
2023
+ }
2024
+ mounts.push({ type: "tmpfs", target: CONTAINER_RUNTIME_DIR, size: RUNTIME_DIR_SIZE, uid, gid, mode: "700" });
2025
+ env.XDG_RUNTIME_DIR = CONTAINER_RUNTIME_DIR;
2026
+ mounts.push({ type: "bind", source: hostPath(plan.configDirHost), target: CONTAINER_XDG_DIR });
2027
+ env.XDG_CONFIG_HOME = `${CONTAINER_XDG_DIR}/config`;
2028
+ env.XDG_CACHE_HOME = `${CONTAINER_XDG_DIR}/cache`;
2029
+ env.XDG_DATA_HOME ??= `${CONTAINER_XDG_DIR}/data`;
2030
+ }
2031
+ function addSession(mounts, env, plan) {
2032
+ const { settings } = plan;
2033
+ if (settings.display === "x11")
2034
+ addX11(mounts, env);
2035
+ else
2036
+ addWayland(mounts, env);
2037
+ if (!settings.audio)
2038
+ return;
2039
+ for (const socket of audioSockets()) {
2040
+ mounts.push({ type: "bind", source: socket.source, target: `${CONTAINER_RUNTIME_DIR}/${socket.name}` });
2041
+ }
2042
+ env.PULSE_SERVER = `unix:${CONTAINER_RUNTIME_DIR}/pulse/native`;
2043
+ }
2044
+ function addX11(mounts, env) {
2045
+ const x11 = x11Session();
2046
+ if (!x11)
2047
+ return;
2048
+ mounts.push({ type: "bind", source: X11_SOCKET_DIR, target: X11_SOCKET_DIR });
2049
+ env.DISPLAY = x11.display;
2050
+ env.XDG_SESSION_TYPE = "x11";
2051
+ env.SDL_VIDEODRIVER = "x11";
2052
+ env.QT_QPA_PLATFORM = "xcb";
2053
+ if (!x11.xauthority)
2054
+ return;
2055
+ mounts.push({ type: "bind", source: x11.xauthority, target: CONTAINER_XAUTHORITY, readonly: true });
2056
+ env.XAUTHORITY = CONTAINER_XAUTHORITY;
2057
+ }
2058
+ function addWayland(mounts, env) {
2059
+ const wayland = waylandSocket();
2060
+ if (!wayland)
2061
+ return;
2062
+ mounts.push({ type: "bind", source: wayland.source, target: `${CONTAINER_RUNTIME_DIR}/${wayland.name}` });
2063
+ env.WAYLAND_DISPLAY = wayland.name;
2064
+ env.XDG_SESSION_TYPE = "wayland";
2065
+ env.SDL_VIDEODRIVER = "wayland";
2066
+ env.QT_QPA_PLATFORM = "wayland";
2067
+ }
1991
2068
  function containerName(plan) {
1992
2069
  const base = `gamecrate-${plan.game}-${plan.profile}`;
1993
2070
  return plan.instance === undefined ? base : `${base}-${plan.instance}`;
@@ -2013,14 +2090,7 @@ function toDockerArgs(spec) {
2013
2090
  args.push("--device-cgroup-rule", rule);
2014
2091
  for (const ulimit of spec.ulimits)
2015
2092
  args.push("--ulimit", ulimit);
2016
- args.push("--network", spec.network);
2017
- args.push("--memory", spec.memory);
2018
- args.push("--memory-swap", spec.memorySwap);
2019
- args.push("--cpus", String(spec.cpus));
2020
- args.push("--pids-limit", String(spec.pidsLimit));
2021
- args.push("--workdir", spec.workdir);
2022
- args.push(...spec.extraArgs);
2023
- args.push("--pull=never");
2093
+ args.push("--network", spec.network, "--memory", spec.memory, "--memory-swap", spec.memorySwap, "--cpus", String(spec.cpus), "--pids-limit", String(spec.pidsLimit), "--workdir", spec.workdir, ...spec.extraArgs, "--pull=never");
2024
2094
  const [entrypoint, ...rest] = spec.command;
2025
2095
  if (entrypoint !== undefined)
2026
2096
  args.push("--entrypoint", entrypoint);
@@ -2066,7 +2136,10 @@ function validateDataDirArg(dataDir) {
2066
2136
  return dataDir.arg;
2067
2137
  }
2068
2138
  function trimSlash(path) {
2069
- return path.length > 1 ? path.replace(/\/+$/, "") : path;
2139
+ let end = path.length;
2140
+ while (end > 1 && path[end - 1] === "/")
2141
+ end--;
2142
+ return path.slice(0, end);
2070
2143
  }
2071
2144
  function glEnv(gpu) {
2072
2145
  if (!gpu)
@@ -2093,7 +2166,11 @@ function waylandSocket() {
2093
2166
  const runtime = process.env.XDG_RUNTIME_DIR;
2094
2167
  if (!display)
2095
2168
  return null;
2096
- const source = display.startsWith("/") ? display : runtime ? join4(runtime, display) : null;
2169
+ let source = null;
2170
+ if (display.startsWith("/"))
2171
+ source = display;
2172
+ else if (runtime)
2173
+ source = join4(runtime, display);
2097
2174
  if (!source || !existsSync(source))
2098
2175
  return null;
2099
2176
  return { source, name: basename3(source) };
@@ -2265,50 +2342,52 @@ import { join as join6, relative } from "node:path";
2265
2342
  var SKIP_DIRS = new Set([".git", ".retired", ".vs", "bin", "node_modules", "obj"]);
2266
2343
  var ENTRY_LIMIT = 20000;
2267
2344
  async function scanBuildTimes(dir) {
2268
- const times = { sourceTimes: [] };
2269
- let budget = ENTRY_LIMIT;
2270
- const walk = async (current, inAssemblies) => {
2271
- let entries;
2272
- try {
2273
- entries = await readdir3(current, { withFileTypes: true });
2274
- } catch {
2345
+ const state = { root: dir, times: { sourceTimes: [] }, budget: ENTRY_LIMIT };
2346
+ await walk(state, dir, false);
2347
+ return state.times;
2348
+ }
2349
+ async function walk(state, current, inAssemblies) {
2350
+ let entries;
2351
+ try {
2352
+ entries = await readdir3(current, { withFileTypes: true });
2353
+ } catch {
2354
+ return;
2355
+ }
2356
+ for (const entry of entries) {
2357
+ if (state.budget-- <= 0)
2275
2358
  return;
2276
- }
2277
- for (const entry of entries) {
2278
- if (budget-- <= 0)
2279
- return;
2280
- const path = join6(current, entry.name);
2281
- if (entry.isDirectory()) {
2282
- if (SKIP_DIRS.has(entry.name.toLowerCase()))
2283
- continue;
2284
- await walk(path, inAssemblies || entry.name === "Assemblies");
2285
- continue;
2286
- }
2287
- if (!entry.isFile())
2288
- continue;
2289
- const lower = entry.name.toLowerCase();
2290
- const isSource = lower.endsWith(".cs");
2291
- const isAssembly = inAssemblies && lower.endsWith(".dll");
2292
- if (!isSource && !isAssembly)
2293
- continue;
2294
- let mtimeMs;
2295
- try {
2296
- mtimeMs = (await stat3(path)).mtimeMs;
2297
- } catch {
2298
- continue;
2299
- }
2300
- const found = { path: relative(dir, path), mtimeMs };
2301
- if (isSource) {
2302
- times.sourceTimes.push(mtimeMs);
2303
- if (mtimeMs > (times.newestSource?.mtimeMs ?? -1))
2304
- times.newestSource = found;
2305
- } else if (mtimeMs > (times.newestAssembly?.mtimeMs ?? -1)) {
2306
- times.newestAssembly = found;
2359
+ const path = join6(current, entry.name);
2360
+ if (entry.isDirectory()) {
2361
+ if (!SKIP_DIRS.has(entry.name.toLowerCase())) {
2362
+ await walk(state, path, inAssemblies || entry.name === "Assemblies");
2307
2363
  }
2364
+ continue;
2308
2365
  }
2309
- };
2310
- await walk(dir, false);
2311
- return times;
2366
+ if (entry.isFile())
2367
+ await record(state, path, entry.name, inAssemblies);
2368
+ }
2369
+ }
2370
+ async function record(state, path, name, inAssemblies) {
2371
+ const lower = name.toLowerCase();
2372
+ const isSource = lower.endsWith(".cs");
2373
+ const isAssembly = inAssemblies && lower.endsWith(".dll");
2374
+ if (!isSource && !isAssembly)
2375
+ return;
2376
+ let mtimeMs;
2377
+ try {
2378
+ mtimeMs = (await stat3(path)).mtimeMs;
2379
+ } catch {
2380
+ return;
2381
+ }
2382
+ const { times } = state;
2383
+ const found = { path: relative(state.root, path), mtimeMs };
2384
+ if (isSource) {
2385
+ times.sourceTimes.push(mtimeMs);
2386
+ if (mtimeMs > (times.newestSource?.mtimeMs ?? -1))
2387
+ times.newestSource = found;
2388
+ } else if (mtimeMs > (times.newestAssembly?.mtimeMs ?? -1)) {
2389
+ times.newestAssembly = found;
2390
+ }
2312
2391
  }
2313
2392
  var SKEW_MS = 1000;
2314
2393
  function newerThan(source, assembly) {
@@ -2475,8 +2554,7 @@ function printPlan(plan, asJson) {
2475
2554
  ];
2476
2555
  if (payload.marker !== undefined)
2477
2556
  out.push(` marker ${payload.marker}`);
2478
- out.push(` timeout ${payload.timeoutSeconds}s, render wait ${payload.renderWaitSeconds}s`);
2479
- out.push(` mods ${payload.mods.length}`);
2557
+ out.push(` timeout ${payload.timeoutSeconds}s, render wait ${payload.renderWaitSeconds}s`, ` mods ${payload.mods.length}`);
2480
2558
  const width = Math.max(0, ...payload.mods.map((m) => m.packageId.length));
2481
2559
  for (const mod of payload.mods) {
2482
2560
  const notes = [mod.kind, mod.origin];
@@ -2491,7 +2569,7 @@ function printPlan(plan, asJson) {
2491
2569
  `);
2492
2570
  }
2493
2571
  function runTimestamp(now = new Date) {
2494
- return now.toISOString().replace(/[-:.]/g, "");
2572
+ return now.toISOString().replaceAll(/[-:.]/g, "");
2495
2573
  }
2496
2574
  function openRunLog(logsDir, now) {
2497
2575
  const runsDir = join7(logsDir, "runs");
@@ -2508,8 +2586,11 @@ function append(fd, chunk) {
2508
2586
  }
2509
2587
  function uniqueRunDir(runsDir, stamp) {
2510
2588
  let candidate = join7(runsDir, stamp);
2511
- for (let n = 2;existsSync2(candidate); n++)
2589
+ let n = 2;
2590
+ while (existsSync2(candidate)) {
2512
2591
  candidate = join7(runsDir, `${stamp}-${n}`);
2592
+ n++;
2593
+ }
2513
2594
  return candidate;
2514
2595
  }
2515
2596
  var WAIT_NOTICE = { firstMs: 2000, everyMs: 30000 };
@@ -2588,17 +2669,8 @@ async function imageLabel(ref, label) {
2588
2669
  async function acquireImage(game, config, pull) {
2589
2670
  const { image } = config;
2590
2671
  const present = await imageDigest(image.ref) !== null;
2591
- if (image.acquire === "build") {
2592
- if (image.context === undefined) {
2593
- throw new GamecrateError(`${game} has image.acquire "build" but no context`, Exit.Config);
2594
- }
2595
- if (present && pull !== "always")
2596
- return;
2597
- if (await inherit(["docker", "build", "--tag", image.ref, image.context]) !== 0) {
2598
- throw new GamecrateError(`docker build failed for ${image.ref}`, Exit.Environment);
2599
- }
2600
- return;
2601
- }
2672
+ if (image.acquire === "build")
2673
+ return await buildImage(game, image, present, pull);
2602
2674
  if (pull === "never") {
2603
2675
  if (present)
2604
2676
  return;
@@ -2612,6 +2684,16 @@ async function acquireImage(game, config, pull) {
2612
2684
  throw new GamecrateError(`docker pull failed for ${image.ref}`, Exit.Environment);
2613
2685
  }
2614
2686
  }
2687
+ async function buildImage(game, image, present, pull) {
2688
+ if (image.context === undefined) {
2689
+ throw new GamecrateError(`${game} has image.acquire "build" but no context`, Exit.Config);
2690
+ }
2691
+ if (present && pull !== "always")
2692
+ return;
2693
+ if (await inherit(["docker", "build", "--tag", image.ref, image.context]) !== 0) {
2694
+ throw new GamecrateError(`docker build failed for ${image.ref}`, Exit.Environment);
2695
+ }
2696
+ }
2615
2697
  var RUNTIME_PACKAGES = ["xorg-server-xvfb", "xorg-xwd", "imagemagick", "mesa", "ttf-dejavu"];
2616
2698
  var RUNTIME_SUFFIX = "-gamecrate";
2617
2699
  var BASE_LABEL = "gamecrate.base";
@@ -2880,7 +2962,10 @@ function sourcesRoot(dataRoot) {
2880
2962
  return join9(dataRoot, "sources");
2881
2963
  }
2882
2964
  function normalizeUrl(url) {
2883
- const trimmed = url.replace(/(?:\/|\.git)+$/, "");
2965
+ let trimmed = url;
2966
+ while (trimmed.endsWith("/") || trimmed.endsWith(".git")) {
2967
+ trimmed = trimmed.slice(0, trimmed.endsWith("/") ? -1 : -4);
2968
+ }
2884
2969
  if (/^[A-Za-z][A-Za-z0-9+.-]*:\/\//.test(trimmed)) {
2885
2970
  return trimmed.replace(/^([A-Za-z][A-Za-z0-9+.-]*:\/\/)([^/@]*@)?([^/]*)/, (_, scheme, user, host) => `${scheme.toLowerCase()}${user ?? ""}${host.toLowerCase()}`);
2886
2971
  }
@@ -2892,10 +2977,10 @@ function cloneDir(dataRoot, url, ref) {
2892
2977
  return join9(sourcesRoot(dataRoot), name, `${ref.kind}-${slug(ref.value)}-${hash(ref.value, 6)}`);
2893
2978
  }
2894
2979
  function lastSegment(url) {
2895
- return url.split(/[/:]/).filter((part) => part.length > 0).pop() ?? "repo";
2980
+ return url.split(/[/:]/).findLast((part) => part.length > 0) ?? "repo";
2896
2981
  }
2897
2982
  function slug(value) {
2898
- const out = value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
2983
+ const out = value.toLowerCase().replaceAll(/[^a-z0-9]+/g, "-").replaceAll(/^-|-$/g, "");
2899
2984
  return (out.length === 0 ? "x" : out).slice(0, SLUG_LIMIT);
2900
2985
  }
2901
2986
  function hash(value, length) {
@@ -3044,8 +3129,7 @@ function reachedEntries(game, profile, args) {
3044
3129
  if (profile.includeBase !== false)
3045
3130
  out.push(...game.base ?? []);
3046
3131
  const only = args.only ?? [];
3047
- out.push(...only.length > 0 ? only : profile.mods ?? []);
3048
- out.push(...args.mods ?? []);
3132
+ out.push(...only.length > 0 ? only : profile.mods ?? [], ...args.mods ?? []);
3049
3133
  const dropped = [...profile.exclude ?? [], ...args.without ?? []].map(globToRegExp);
3050
3134
  return out.filter((entry) => {
3051
3135
  if (typeof entry !== "string" && "match" in entry)
@@ -3143,8 +3227,9 @@ async function prepareSources(game, profileName, args, dataRoot, allowFetch) {
3143
3227
 
3144
3228
  // src/mods/steamcmd.ts
3145
3229
  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";
3230
+ import { accessSync, constants, existsSync as existsSync5, mkdirSync as mkdirSync3, statSync as statSync2 } from "node:fs";
3231
+ import { readdir as readdir5, rm as rm2 } from "node:fs/promises";
3232
+ import { delimiter, dirname as dirname5, join as join10 } from "node:path";
3148
3233
  import { setTimeout as sleep4 } from "node:timers/promises";
3149
3234
  var STEAMCMD_IMAGE = "steamcmd/steamcmd";
3150
3235
  function steamHome(dataRoot) {
@@ -3153,6 +3238,14 @@ function steamHome(dataRoot) {
3153
3238
  function downloadRoot(dataRoot, game) {
3154
3239
  return join10(steamHome(dataRoot), "steamapps", "workshop", "content", String(game.steamAppId));
3155
3240
  }
3241
+ async function removeDownloads(root, steamAppId) {
3242
+ if (!existsSync5(root))
3243
+ return `no workshop downloads at ${root}`;
3244
+ const items = (await readdir5(root).catch(() => [])).length;
3245
+ await rm2(root, { recursive: true, force: true });
3246
+ await rm2(join10(dirname5(dirname5(root)), `appworkshop_${steamAppId}.acf`), { force: true });
3247
+ return `removed ${root} (${items} item(s))`;
3248
+ }
3156
3249
  function resolveSteamcmd(config) {
3157
3250
  const home = steamHome(config.dataRoot);
3158
3251
  const configured = config.steamcmd?.path === undefined ? undefined : expandHome(config.steamcmd.path);
@@ -3220,7 +3313,7 @@ function workshopUrlId(url) {
3220
3313
  const id = parsed.searchParams.get("id");
3221
3314
  return id !== null && /^\d+$/.test(id) ? id : undefined;
3222
3315
  }
3223
- var ANSI = new RegExp(`${String.fromCharCode(27)}\\[[0-9;?]*[ -/]*[@-~]`, "g");
3316
+ var ANSI = new RegExp(String.raw`${String.fromCodePoint(27)}\[[0-9;?]*[ -/]*[@-~]`, "g");
3224
3317
  var SUCCESS = /Success\. Downloaded item (\d+) to "([^"]+)" \((\d+) bytes\)/g;
3225
3318
  var FAILED = /ERROR! Download item (\d+) failed \(([^)]+)\)/g;
3226
3319
  var ATTEMPTS = 2;
@@ -3283,7 +3376,7 @@ ${r.stderr ?? ""}`.replace(ANSI, "");
3283
3376
 
3284
3377
  // src/mods/workshopapi.ts
3285
3378
  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";
3379
+ import { basename as basename5, dirname as dirname6, join as join11 } from "node:path";
3287
3380
 
3288
3381
  // src/mods/acf.ts
3289
3382
  var MAX_DEPTH = 100;
@@ -3324,50 +3417,66 @@ function quoted(text, start) {
3324
3417
  }
3325
3418
  return null;
3326
3419
  }
3327
- function body(text, start, depth) {
3328
- if (depth > MAX_DEPTH)
3329
- return null;
3420
+ function root2(text) {
3330
3421
  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 };
3422
+ let i = skip(text, 0);
3423
+ while (i < text.length) {
3338
3424
  if (text[i] !== '"')
3339
3425
  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)
3426
+ const next = readEntry(text, i, 0, node);
3427
+ if (next === null)
3345
3428
  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
- }
3429
+ i = skip(text, next);
3430
+ }
3431
+ return node;
3432
+ }
3433
+ function block(text, start, depth) {
3434
+ if (depth > MAX_DEPTH)
3435
+ return null;
3436
+ const node = {};
3437
+ let i = skip(text, start);
3438
+ while (i < text.length) {
3439
+ if (text[i] === "}")
3440
+ return { node, next: i + 1 };
3354
3441
  if (text[i] !== '"')
3355
3442
  return null;
3356
- const value = quoted(text, i);
3357
- if (value === null)
3443
+ const next = readEntry(text, i, depth, node);
3444
+ if (next === null)
3358
3445
  return null;
3359
- node[key.value] = value.value;
3360
- i = value.next;
3446
+ i = skip(text, next);
3361
3447
  }
3448
+ return null;
3449
+ }
3450
+ function readEntry(text, start, depth, node) {
3451
+ const key = quoted(text, start);
3452
+ if (key === null)
3453
+ return null;
3454
+ const i = skip(text, key.next);
3455
+ if (i >= text.length)
3456
+ return null;
3457
+ if (text[i] === "{") {
3458
+ const child = block(text, i + 1, depth + 1);
3459
+ if (child === null)
3460
+ return null;
3461
+ node[key.value] = child.node;
3462
+ return child.next;
3463
+ }
3464
+ if (text[i] !== '"')
3465
+ return null;
3466
+ const value = quoted(text, i);
3467
+ if (value === null)
3468
+ return null;
3469
+ node[key.value] = value.value;
3470
+ return value.next;
3362
3471
  }
3363
3472
  function parseAcf(text) {
3364
- return body(text, 0, 0)?.node ?? {};
3473
+ return root2(text) ?? {};
3365
3474
  }
3366
- function section(root, name) {
3367
- const direct = root[name];
3475
+ function section(node, name) {
3476
+ const direct = node[name];
3368
3477
  if (typeof direct === "object")
3369
3478
  return direct;
3370
- for (const child of Object.values(root)) {
3479
+ for (const child of Object.values(node)) {
3371
3480
  if (typeof child === "object" && typeof child[name] === "object")
3372
3481
  return child[name];
3373
3482
  }
@@ -3484,14 +3593,14 @@ function readDetails(payload) {
3484
3593
  const id = entry["publishedfileid"];
3485
3594
  const result = entry["result"];
3486
3595
  if (typeof id !== "string" || typeof result !== "number") {
3487
- throw new Error("steam returned a body this does not understand");
3596
+ throw new TypeError("steam returned a body this does not understand");
3488
3597
  }
3489
3598
  out.set(id, { result, timeUpdated: Number(entry["time_updated"]) || 0 });
3490
3599
  }
3491
3600
  return out;
3492
3601
  }
3493
3602
  function acfPath(root) {
3494
- return join11(dirname5(dirname5(root)), `appworkshop_${basename5(root)}.acf`);
3603
+ return join11(dirname6(dirname6(root)), `appworkshop_${basename5(root)}.acf`);
3495
3604
  }
3496
3605
  async function exists(dir) {
3497
3606
  try {
@@ -3536,14 +3645,16 @@ async function modsAdd(args, ctx) {
3536
3645
  const twice = [...where.values()].filter((at) => at.length > 1);
3537
3646
  if (twice.length > 0) {
3538
3647
  const lines = [...where.entries()].filter(([, at]) => at.length > 1);
3539
- throw new GamecrateError(`${twice.length} mod id(s) are declared by more than one directory in ${describe(source)}`, Exit.Config, `${lines.map(([id, at]) => ` ${id}: ${at.join(", ")}`).join(`
3540
- `)}
3648
+ const rows = lines.map(([id, at]) => ` ${id}: ${at.join(", ")}`).join(`
3649
+ `);
3650
+ throw new GamecrateError(`${twice.length} mod id(s) are declared by more than one directory in ${describe(source)}`, Exit.Config, `${rows}
3541
3651
  pin one of them with --subdir`);
3542
3652
  }
3543
3653
  const clashes = pins.filter((pin) => existingKey(target.existing, pin.id) !== undefined);
3544
3654
  if (clashes.length > 0 && args.force !== true) {
3545
- throw new GamecrateError(`${clashes.length} mod id(s) are already pinned in ${target.file}`, Exit.Config, `${clashes.map((pin) => ` ${pin.id}`).join(`
3546
- `)}
3655
+ const rows = clashes.map((pin) => ` ${pin.id}`).join(`
3656
+ `);
3657
+ throw new GamecrateError(`${clashes.length} mod id(s) are already pinned in ${target.file}`, Exit.Config, `${rows}
3547
3658
  overwrite them with --force`);
3548
3659
  }
3549
3660
  const edits = [];
@@ -3553,8 +3664,8 @@ overwrite them with --force`);
3553
3664
  edits.push({ path: [...target.prefix, old], value: undefined });
3554
3665
  edits.push({ path: [...target.prefix, pin.id], value: pin.entry });
3555
3666
  }
3556
- if (!existsSync5(target.file)) {
3557
- await mkdir2(dirname6(target.file), { recursive: true });
3667
+ if (!existsSync6(target.file)) {
3668
+ await mkdir2(dirname7(target.file), { recursive: true });
3558
3669
  await writeFile3(target.file, "");
3559
3670
  }
3560
3671
  await writeConfig(target.file, edits);
@@ -3582,11 +3693,28 @@ async function modsRm(args, ctx) {
3582
3693
  async function modsSync(args, ctx) {
3583
3694
  const only = args.rest.map((id) => id.toLowerCase());
3584
3695
  const games = args.game === undefined ? Object.keys(ctx.config.games) : [requireGame(args, ctx.config)];
3696
+ const { wanted, subscribed } = collectPins(ctx.config, games, only);
3697
+ const named = [...wanted.map((pin) => pin.id), ...subscribed.flatMap((one) => one.pins.map((pin) => pin.id))];
3698
+ const missing = only.filter((id) => !named.some((pinned) => pinned.toLowerCase() === id));
3699
+ if (missing.length > 0) {
3700
+ throw new GamecrateError(`${missing.length} mod id(s) are not git- or workshop-pinned in the library`, Exit.Resolution, missing.map((id) => ` ${id}`).join(`
3701
+ `));
3702
+ }
3703
+ if (wanted.length === 0 && subscribed.length === 0) {
3704
+ status("nothing to sync");
3705
+ return Exit.Ok;
3706
+ }
3707
+ await syncGit(ctx, wanted);
3708
+ for (const one of subscribed)
3709
+ await syncWorkshop(ctx, one.game, one.pins);
3710
+ return Exit.Ok;
3711
+ }
3712
+ function collectPins(config, games, only) {
3585
3713
  const wanted = [];
3586
3714
  const subscribed = [];
3587
3715
  for (const name of games) {
3588
3716
  const pins = [];
3589
- for (const [id, entry] of Object.entries(ctx.config.games[name]?.library ?? {})) {
3717
+ for (const [id, entry] of Object.entries(config.games[name]?.library ?? {})) {
3590
3718
  if (only.length > 0 && !only.includes(id.toLowerCase()))
3591
3719
  continue;
3592
3720
  if (entry.git !== undefined)
@@ -3597,16 +3725,9 @@ async function modsSync(args, ctx) {
3597
3725
  if (pins.length > 0)
3598
3726
  subscribed.push({ game: name, pins });
3599
3727
  }
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));
3602
- if (missing.length > 0) {
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(`
3604
- `));
3605
- }
3606
- if (wanted.length === 0 && subscribed.length === 0) {
3607
- status("nothing to sync");
3608
- return Exit.Ok;
3609
- }
3728
+ return { wanted, subscribed };
3729
+ }
3730
+ async function syncGit(ctx, wanted) {
3610
3731
  const branches = new Map;
3611
3732
  const fetched = new Set;
3612
3733
  for (const pin of wanted) {
@@ -3619,20 +3740,20 @@ async function modsSync(args, ctx) {
3619
3740
  const dir = cloneDir(ctx.config.dataRoot, pin.git, ref);
3620
3741
  if (!fetched.has(dir)) {
3621
3742
  fetched.add(dir);
3622
- const unlock = await lockDir(dir);
3623
- try {
3624
- const result = await ensureClone(ctx.config.dataRoot, gitPin(pin.git, pin.entry), ref, "force");
3625
- if (result.warning !== undefined)
3626
- warn(result.warning);
3627
- } finally {
3628
- await unlock();
3629
- }
3743
+ await fetchClone(ctx, pin, ref, dir);
3630
3744
  }
3631
3745
  status(`synced ${pin.id} at ${ref.kind} ${ref.value}`);
3632
3746
  }
3633
- for (const one of subscribed)
3634
- await syncWorkshop(ctx, one.game, one.pins);
3635
- return Exit.Ok;
3747
+ }
3748
+ async function fetchClone(ctx, pin, ref, dir) {
3749
+ const unlock = await lockDir(dir);
3750
+ try {
3751
+ const result = await ensureClone(ctx.config.dataRoot, gitPin(pin.git, pin.entry), ref, "force");
3752
+ if (result.warning !== undefined)
3753
+ warn(result.warning);
3754
+ } finally {
3755
+ await unlock();
3756
+ }
3636
3757
  }
3637
3758
  async function syncWorkshop(ctx, name, pins) {
3638
3759
  const game = ctx.config.games[name];
@@ -3753,7 +3874,7 @@ async function discover(source, game, plugin, ctx) {
3753
3874
  await unlock();
3754
3875
  }
3755
3876
  const start = source.subdir === undefined ? root : join12(root, source.subdir);
3756
- const found = await walk(start, game.manifest.file, plugin);
3877
+ const found = await walk2(start, game.manifest.file, plugin);
3757
3878
  return found.map(({ id, dir: at }) => {
3758
3879
  const entry = { git: source.url };
3759
3880
  const pinned = source.ref;
@@ -3769,7 +3890,7 @@ async function discover(source, game, plugin, ctx) {
3769
3890
  return { id, entry };
3770
3891
  });
3771
3892
  }
3772
- async function walk(root, manifestFile, plugin) {
3893
+ async function walk2(root, manifestFile, plugin) {
3773
3894
  const out = [];
3774
3895
  const descend = async (dir) => {
3775
3896
  const id = await readId(dir, manifestFile, plugin);
@@ -3777,7 +3898,7 @@ async function walk(root, manifestFile, plugin) {
3777
3898
  out.push({ id, dir });
3778
3899
  return;
3779
3900
  }
3780
- const entries = await readdir5(dir, { withFileTypes: true }).catch(() => []);
3901
+ const entries = await readdir6(dir, { withFileTypes: true }).catch(() => []);
3781
3902
  for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
3782
3903
  if (entry.isDirectory() && !entry.name.startsWith("."))
3783
3904
  await descend(join12(dir, entry.name));
@@ -3833,14 +3954,10 @@ function topLevel(config) {
3833
3954
  "subcommands:"
3834
3955
  ];
3835
3956
  const verbs = SUBCOMMANDS.map((s) => [`${s.name} ${s.usage}`.trim(), s.summary]);
3836
- lines.push(...columns(verbs, 2));
3837
- lines.push("", "flags:");
3838
- lines.push(...columns(flags().map(flagRow), 2));
3957
+ lines.push(...columns(verbs, 2), "", "flags:", ...columns(flags().map(flagRow), 2));
3839
3958
  const games = Object.entries(config?.games ?? {});
3840
3959
  if (games.length > 0) {
3841
- lines.push("", "games:");
3842
- lines.push(...columns(games.map(([name, game]) => [name, gameSummary(game)]), 2));
3843
- lines.push("", `${NAME} help <game> lists that game's profiles.`);
3960
+ lines.push("", "games:", ...columns(games.map(([name, game]) => [name, gameSummary(game)]), 2), "", `${NAME} help <game> lists that game's profiles.`);
3844
3961
  }
3845
3962
  lines.push("", "Game args go after a bare --. Env vars are GAMECRATE_ prefixed fallbacks only.");
3846
3963
  return lines.join(`
@@ -3853,8 +3970,7 @@ function subcommandHelp(sub) {
3853
3970
  const all = flags();
3854
3971
  const specs = names.map((name) => all.find((f) => f.long === name)).filter((f) => f !== undefined);
3855
3972
  if (specs.length > 0) {
3856
- lines.push("", "flags:");
3857
- lines.push(...columns(specs.map(flagRow), 2));
3973
+ lines.push("", "flags:", ...columns(specs.map(flagRow), 2));
3858
3974
  }
3859
3975
  if (sub.name === "run") {
3860
3976
  lines.push("", ` The subcommand slot defaults to run, so \`${NAME} <game> <profile>\` works.`);
@@ -3865,31 +3981,14 @@ function subcommandHelp(sub) {
3865
3981
  }
3866
3982
  function gameHelp(name, game) {
3867
3983
  const lines = [`usage: ${NAME} ${name} [profile] [flags] [-- game args]`, "", "profiles:"];
3868
- const rows = [];
3869
- for (const [profile, config] of Object.entries(game.profiles)) {
3870
- const notes = [];
3871
- if (config.alias)
3872
- notes.push(`alias for ${config.alias}`);
3873
- if (config.extends)
3874
- notes.push(`extends ${config.extends}`);
3875
- if (config.autoDependencies)
3876
- notes.push("auto dependencies");
3877
- const count = config.mods?.length ?? 0;
3878
- if (!config.alias)
3879
- notes.push(count === 1 ? "1 entry" : `${count} entries`);
3880
- if (config.aliases?.length)
3881
- notes.push(`aka ${config.aliases.join(", ")}`);
3882
- rows.push([profile, notes.join(", ")]);
3883
- }
3984
+ const rows = Object.entries(game.profiles).map(([profile, config]) => [profile, profileNotes2(config)]);
3884
3985
  if (rows.length === 0)
3885
3986
  rows.push(["(none declared)", ""]);
3886
3987
  lines.push(...columns(rows, 2));
3887
3988
  if (game.aliases && Object.keys(game.aliases).length > 0) {
3888
- lines.push("", "mod name aliases:");
3889
- lines.push(...columns(Object.entries(game.aliases).map(([k, v]) => [k, v]), 2));
3989
+ lines.push("", "mod name aliases:", ...columns(Object.entries(game.aliases).map(([k, v]) => [k, v]), 2));
3890
3990
  }
3891
- lines.push("", `modes: ${game.modes.join(", ")}`);
3892
- lines.push(`core: ${game.core}`);
3991
+ lines.push("", `modes: ${game.modes.join(", ")}`, `core: ${game.core}`);
3893
3992
  if (game.dlc.length > 0)
3894
3993
  lines.push(`dlc: ${game.dlc.join(", ")}`);
3895
3994
  lines.push(`game files: ${game.gameFiles.source === "mount" ? game.gameFiles.host ?? "(unset)" : game.image.ref}`);
@@ -3897,9 +3996,25 @@ function gameHelp(name, game) {
3897
3996
  `) + `
3898
3997
  `;
3899
3998
  }
3999
+ function profileNotes2(config) {
4000
+ const notes = [];
4001
+ if (config.alias)
4002
+ notes.push(`alias for ${config.alias}`);
4003
+ if (config.extends)
4004
+ notes.push(`extends ${config.extends}`);
4005
+ if (config.autoDependencies === false)
4006
+ notes.push("no auto dependencies");
4007
+ const count = config.mods?.length ?? 0;
4008
+ if (!config.alias)
4009
+ notes.push(count === 1 ? "1 entry" : `${count} entries`);
4010
+ if (config.aliases?.length)
4011
+ notes.push(`aka ${config.aliases.join(", ")}`);
4012
+ return notes.join(", ");
4013
+ }
3900
4014
  function gameSummary(game) {
3901
4015
  const count = Object.keys(game.profiles).length;
3902
- return `${count === 1 ? "1 profile" : `${count} profiles`}; modes ${game.modes.join(", ")}`;
4016
+ const profiles = count === 1 ? "1 profile" : `${count} profiles`;
4017
+ return `${profiles}; modes ${game.modes.join(", ")}`;
3903
4018
  }
3904
4019
  function flagRow(spec) {
3905
4020
  const notes = [];
@@ -3924,7 +4039,7 @@ function renderCompletion(shell) {
3924
4039
  const options = flags();
3925
4040
  const names = options.flatMap((f) => f.short ? [f.long, f.short] : [f.long]).join(" ");
3926
4041
  const valueFlags = options.filter((f) => f.required).map((f) => f.long);
3927
- const fn = `_${NAME.replace(/-/g, "_")}`;
4042
+ const fn = `_${NAME.replaceAll("-", "_")}`;
3928
4043
  if (shell === "bash") {
3929
4044
  const cases = options.filter((f) => f.argChoices).map((f) => ` ${f.long}) COMPREPLY=($(compgen -W "${f.argChoices.join(" ")}" -- "$cur")); return ;;`).join(`
3930
4045
  `);
@@ -3949,12 +4064,13 @@ ${cases}
3949
4064
  complete -F ${fn} ${NAME}
3950
4065
  `;
3951
4066
  }
3952
- const zshVerbs = SUBCOMMANDS.map((s) => ` '${s.name}:${s.summary.replace(/'/g, "'\\''")}'`).join(`
4067
+ const zshVerbs = SUBCOMMANDS.map((s) => ` '${s.name}:${s.summary.replaceAll("'", String.raw`'\''`)}'`).join(`
3953
4068
  `);
3954
4069
  const zshFlags = options.map((f) => {
3955
- const desc = f.description.replace(/'/g, "'\\''").replace(/[[\]:]/g, "");
4070
+ const desc = f.description.replaceAll("'", String.raw`'\''`).replaceAll(/[[\]:]/g, "");
3956
4071
  const arg = placeholder(f);
3957
- const value = arg ? `:${arg.replace(/[<>]/g, "")}:${f.argChoices ? `(${f.argChoices.join(" ")})` : "_files"}` : "";
4072
+ const choices = f.argChoices ? `(${f.argChoices.join(" ")})` : "_files";
4073
+ const value = arg ? `:${arg.replaceAll(/[<>]/g, "")}:${choices}` : "";
3958
4074
  const repeat = Array.isArray(f.defaultValue) ? "*" : "";
3959
4075
  return ` '${repeat}${f.long}[${desc}]${value}'`;
3960
4076
  }).join(`
@@ -3995,7 +4111,7 @@ function hostUserName(uid) {
3995
4111
  }
3996
4112
 
3997
4113
  // src/docker/preflight.ts
3998
- import { existsSync as existsSync6, readFileSync as readFileSync3 } from "node:fs";
4114
+ import { existsSync as existsSync7, readFileSync as readFileSync3 } from "node:fs";
3999
4115
  import { homedir as homedir3 } from "node:os";
4000
4116
  import { basename as basename6, join as join13 } from "node:path";
4001
4117
  var CDI_SPEC = "/etc/cdi/nvidia.yaml";
@@ -4026,7 +4142,7 @@ async function checkDocker(problems) {
4026
4142
  return true;
4027
4143
  problems.push({
4028
4144
  where: "docker",
4029
- message: `docker is not reachable: ${firstLine(result.stderr) || `exit ${result.code}`}`,
4145
+ message: `docker is not reachable: ${firstLine(result.stderr) || exitCode(result.code)}`,
4030
4146
  suggestion: "start the docker daemon, or check that your user is in the docker group"
4031
4147
  });
4032
4148
  return false;
@@ -4039,7 +4155,7 @@ async function checkImageRunnable(ref, where, game, problems) {
4039
4155
  const corrupt = /content store|failed to extract layer|not found/i.test(run.stderr);
4040
4156
  problems.push({
4041
4157
  where,
4042
- message: corrupt ? `image ${ref} is present but unrunnable; its layers are missing from the content store` : `image ${ref} is present but failed to start: ${err || `exit ${run.code}`}`,
4158
+ message: corrupt ? `image ${ref} is present but unrunnable; its layers are missing from the content store` : `image ${ref} is present but failed to start: ${err || exitCode(run.code)}`,
4043
4159
  suggestion: corrupt ? `docker image rm ${ref} && docker builder prune -f, then gamecrate build ${game}` : undefined
4044
4160
  });
4045
4161
  }
@@ -4073,7 +4189,7 @@ async function checkImage(plan, problems) {
4073
4189
  }
4074
4190
  problems.push({
4075
4191
  where,
4076
- message: `image ${image.ref} is not present locally and cannot be pulled: ${firstLine(remote.stderr) || `exit ${remote.code}`}`,
4192
+ message: `image ${image.ref} is not present locally and cannot be pulled: ${firstLine(remote.stderr) || exitCode(remote.code)}`,
4077
4193
  suggestion: host ? `docker login ${host}` : undefined
4078
4194
  });
4079
4195
  }
@@ -4097,7 +4213,7 @@ function checkDisplay(plan, problems) {
4097
4213
  });
4098
4214
  }
4099
4215
  function checkCdi(problems) {
4100
- if (!existsSync6(CDI_SPEC)) {
4216
+ if (!existsSync7(CDI_SPEC)) {
4101
4217
  problems.push({
4102
4218
  where: CDI_SPEC,
4103
4219
  message: "no CDI spec, so --device nvidia.com/gpu=all cannot resolve",
@@ -4112,7 +4228,7 @@ function checkCdi(problems) {
4112
4228
  problems.push({ where: CDI_SPEC, message: `CDI spec is unreadable: ${message(error)}` });
4113
4229
  return;
4114
4230
  }
4115
- if (!/^\s*-?\s*name:\s*["']?all["']?\s*$/m.test(text)) {
4231
+ if (!/^[ \t]*(?:-[ \t]*)?name:[ \t]*["']?all["']?[ \t]*$/m.test(text)) {
4116
4232
  problems.push({
4117
4233
  where: CDI_SPEC,
4118
4234
  message: 'CDI spec does not declare a device named "all"',
@@ -4129,12 +4245,12 @@ function checkGameDir(plan, problems) {
4129
4245
  problems.push({ where, message: 'gameFiles.source is "mount" but no host path is set' });
4130
4246
  return;
4131
4247
  }
4132
- if (!existsSync6(files.host)) {
4248
+ if (!existsSync7(files.host)) {
4133
4249
  problems.push({ where, message: `game directory does not exist: ${files.host}` });
4134
4250
  return;
4135
4251
  }
4136
4252
  const executable = join13(files.host, basename6(plan.gameConfig.executable));
4137
- if (!existsSync6(executable)) {
4253
+ if (!existsSync7(executable)) {
4138
4254
  problems.push({
4139
4255
  where,
4140
4256
  message: `${files.host} does not contain ${basename6(plan.gameConfig.executable)}`,
@@ -4157,7 +4273,7 @@ function checkBindSources(plan, problems) {
4157
4273
  for (const mount of mounts) {
4158
4274
  if (mount.type !== "bind" || !mount.source)
4159
4275
  continue;
4160
- if (existsSync6(mount.source))
4276
+ if (existsSync7(mount.source))
4161
4277
  continue;
4162
4278
  if (mount.source.startsWith(plan.profileDir))
4163
4279
  continue;
@@ -4189,6 +4305,9 @@ function hasStoredAuth(host) {
4189
4305
  return false;
4190
4306
  }
4191
4307
  }
4308
+ function exitCode(code) {
4309
+ return `exit ${code}`;
4310
+ }
4192
4311
  function firstLine(text) {
4193
4312
  return text.trim().split(`
4194
4313
  `)[0]?.trim() ?? "";
@@ -4248,27 +4367,37 @@ async function adoptNewWindow(opts) {
4248
4367
  }
4249
4368
  const seen = new Set(before.map((w) => w.id));
4250
4369
  let stopped = false;
4251
- (async () => {
4252
- const deadline = Date.now() + WAIT_MS;
4253
- while (!stopped && Date.now() < deadline) {
4254
- await sleep5(POLL_MS);
4255
- if (stopped)
4256
- return;
4257
- for (const match of newMatches(await toplevels() ?? [], seen, opts.executable)) {
4258
- if (stopped)
4259
- return;
4260
- if (await claimedByPeer(match.id))
4261
- continue;
4262
- if (!await adopt(match.id, opts))
4263
- continue;
4264
- await capture(["wmctrl", "-i", "-r", match.id, "-N", opts.title]);
4265
- if (opts.stripDelete)
4266
- await watchForClose(match.id, () => stopped, opts.onClosed);
4267
- return;
4268
- }
4370
+ pollForWindow(seen, opts, () => stopped);
4371
+ return {
4372
+ stop: () => {
4373
+ stopped = true;
4269
4374
  }
4270
- })();
4271
- return { stop: () => void (stopped = true) };
4375
+ };
4376
+ }
4377
+ async function pollForWindow(seen, opts, stopped) {
4378
+ const deadline = Date.now() + WAIT_MS;
4379
+ while (!stopped() && Date.now() < deadline) {
4380
+ await sleep5(POLL_MS);
4381
+ if (stopped())
4382
+ return;
4383
+ if (await adoptFirstMatch(seen, opts, stopped))
4384
+ return;
4385
+ }
4386
+ }
4387
+ async function adoptFirstMatch(seen, opts, stopped) {
4388
+ for (const match of newMatches(await toplevels() ?? [], seen, opts.executable)) {
4389
+ if (stopped())
4390
+ return true;
4391
+ if (await claimedByPeer(match.id))
4392
+ continue;
4393
+ if (!await adopt(match.id, opts))
4394
+ continue;
4395
+ await capture(["wmctrl", "-i", "-r", match.id, "-N", opts.title]);
4396
+ if (opts.stripDelete)
4397
+ await watchForClose(match.id, stopped, opts.onClosed);
4398
+ return true;
4399
+ }
4400
+ return false;
4272
4401
  }
4273
4402
  async function watchForClose(id, stopped, onClosed) {
4274
4403
  let misses = 0;
@@ -4327,12 +4456,12 @@ async function dropDeleteProtocol(id, atoms) {
4327
4456
  }
4328
4457
  function parseAtoms(stdout) {
4329
4458
  const list = stdout.slice(stdout.indexOf(":") + 1).replace(/^\s*protocols\s*/, "");
4330
- return list.split(",").map((atom) => atom.trim()).filter((atom) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(atom));
4459
+ return list.split(",").map((atom) => atom.trim()).filter((atom) => /^[A-Za-z_]\w*$/.test(atom));
4331
4460
  }
4332
4461
 
4333
4462
  // src/launch/generate.ts
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";
4463
+ import { mkdir as mkdir3, readdir as readdir7, readFile as readFile8, writeFile as writeFile4 } from "node:fs/promises";
4464
+ import { dirname as dirname8, join as join14 } from "node:path";
4336
4465
  async function readInstallVersion(game, plugin) {
4337
4466
  const host = game.gameFiles.host;
4338
4467
  if (game.gameFiles.source !== "mount" || host === undefined)
@@ -4354,7 +4483,7 @@ async function readKnownExpansions(game, plugin, warnings) {
4354
4483
  const dataDir = join14(expandHome(host), "Data");
4355
4484
  let entries;
4356
4485
  try {
4357
- entries = await readdir6(dataDir, { withFileTypes: true });
4486
+ entries = await readdir7(dataDir, { withFileTypes: true });
4358
4487
  } catch {
4359
4488
  warnings.push(`could not read ${dataDir}; falling back to the configured dlc list`);
4360
4489
  return [...game.dlc];
@@ -4382,7 +4511,7 @@ async function readKnownExpansions(game, plugin, warnings) {
4382
4511
  async function generateModsConfig(plan) {
4383
4512
  const game = plan.gameConfig;
4384
4513
  const target = join14(plan.dataDirHost, game.modsConfig.file);
4385
- await mkdir3(dirname7(target), { recursive: true });
4514
+ await mkdir3(dirname8(target), { recursive: true });
4386
4515
  const installed = await readInstallVersion(game, plan.plugin);
4387
4516
  if (installed === null) {
4388
4517
  plan.warnings.push(`could not read Version.txt for ${plan.game}; ModsConfig version may be rejected`);
@@ -4433,7 +4562,7 @@ function ownedPrefs(plan) {
4433
4562
  }
4434
4563
  async function mergePrefs(plan) {
4435
4564
  const target = join14(plan.dataDirHost, plan.gameConfig.prefs.file);
4436
- await mkdir3(dirname7(target), { recursive: true });
4565
+ await mkdir3(dirname8(target), { recursive: true });
4437
4566
  let existing = null;
4438
4567
  try {
4439
4568
  existing = await readFile8(target, "utf8");
@@ -4446,8 +4575,8 @@ async function mergePrefs(plan) {
4446
4575
  }
4447
4576
 
4448
4577
  // src/launch/supervisor.ts
4449
- import { existsSync as existsSync7 } from "node:fs";
4450
- import { readFile as readFile9, readlink, rm as rm2, writeFile as writeFile5 } from "node:fs/promises";
4578
+ import { existsSync as existsSync8 } from "node:fs";
4579
+ import { readFile as readFile9, readlink, rm as rm3, writeFile as writeFile5 } from "node:fs/promises";
4451
4580
  import { basename as basename8, join as join15 } from "node:path";
4452
4581
  import { setTimeout as sleep6 } from "node:timers/promises";
4453
4582
  async function forkSupervisor(plan, argv) {
@@ -4506,7 +4635,7 @@ async function supervisorFailed(dir, code) {
4506
4635
  const record = { at: new Date().toISOString(), code, reason: "failed" };
4507
4636
  const wrote = await writeExit(dir, record).then(() => true, () => false);
4508
4637
  if (wrote)
4509
- await rm2(join15(dir, ".gamecrate", "lock"), { force: true }).catch(() => {});
4638
+ await rm3(join15(dir, ".gamecrate", "lock"), { force: true }).catch(() => {});
4510
4639
  return code;
4511
4640
  }
4512
4641
  async function writeExit(instanceDir, record) {
@@ -4564,7 +4693,7 @@ async function awaitRunLog(instanceDir, lock, poll = RUN_LOG_POLL_MS, notice = W
4564
4693
  });
4565
4694
  const began = target === undefined ? undefined : runStartedAt(basename8(target));
4566
4695
  const current = began !== undefined && (Number.isNaN(written) || began >= written);
4567
- if (current && existsSync7(currentLog(instanceDir)))
4696
+ if (current && existsSync8(currentLog(instanceDir)))
4568
4697
  return true;
4569
4698
  if (!isRunning(lock.pid, lock.startedAt))
4570
4699
  return false;
@@ -4584,7 +4713,7 @@ import { basename as basename9, join as join16 } from "node:path";
4584
4713
 
4585
4714
  // src/mods/worktree.ts
4586
4715
  import { spawnSync as spawnSync3 } from "node:child_process";
4587
- import { existsSync as existsSync8, realpathSync as realpathSync2 } from "node:fs";
4716
+ import { existsSync as existsSync9, realpathSync as realpathSync2 } from "node:fs";
4588
4717
  import { isAbsolute as isAbsolute2, resolve as resolve5, sep } from "node:path";
4589
4718
  function inspect(dir) {
4590
4719
  const r = spawnSync3("git", ["-C", dir, "rev-parse", "--path-format=absolute", "--show-toplevel", "--git-dir", "--git-common-dir", "--abbrev-ref", "HEAD"], { encoding: "utf8" });
@@ -4607,7 +4736,7 @@ function canonical(p) {
4607
4736
  function resolveWorktree(dir, source, order) {
4608
4737
  const raw = expandHome(dir);
4609
4738
  const abs = isAbsolute2(raw) ? raw : resolve5(process.cwd(), raw);
4610
- if (!existsSync8(abs)) {
4739
+ if (!existsSync9(abs)) {
4611
4740
  return { where: abs, message: `--worktree path does not exist`, suggestion: "check the path, or drop the flag" };
4612
4741
  }
4613
4742
  const info = inspect(abs);
@@ -4693,7 +4822,9 @@ function derive(requests) {
4693
4822
  return `${slug2(first.root)}-${digest.slice(0, 6)}`;
4694
4823
  }
4695
4824
  function slug2(root) {
4696
- const body = basename9(root).toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^[^a-z0-9]+/, "").slice(0, SLUG_LIMIT2).replace(/[-._]+$/, "");
4825
+ let body = basename9(root).toLowerCase().replaceAll(/[^a-z0-9._-]+/g, "-").replace(/^[^a-z0-9]+/, "").slice(0, SLUG_LIMIT2);
4826
+ while (body !== "" && "-._".includes(body.slice(-1)))
4827
+ body = body.slice(0, -1);
4697
4828
  return body === "" ? "wt" : body;
4698
4829
  }
4699
4830
 
@@ -4702,16 +4833,16 @@ import { join as join18 } from "node:path";
4702
4833
 
4703
4834
  // src/mods/modindex.ts
4704
4835
  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";
4836
+ import { existsSync as existsSync10, readFileSync as readFileSync5, statSync as statSync3 } from "node:fs";
4837
+ import { mkdir as mkdir4, readdir as readdir8, readFile as readFile10, writeFile as writeFile6 } from "node:fs/promises";
4707
4838
  import { homedir as homedir4 } from "node:os";
4708
- import { dirname as dirname8, join as join17, relative as relative3, sep as sep2, resolve as resolvePath } from "node:path";
4839
+ import { dirname as dirname9, join as join17, relative as relative3, sep as sep2, resolve as resolvePath } from "node:path";
4709
4840
  import picomatch from "picomatch";
4710
4841
  function cacheDir() {
4711
4842
  return join17(process.env["XDG_CACHE_HOME"] ?? join17(homedir4(), ".cache"), "gamecrate");
4712
4843
  }
4713
4844
  function globMatch(pattern, path) {
4714
- return picomatch.isMatch(path, pattern.replace(/[[\]{}()!,@+|^$.\\]/g, "\\$&"), { dot: true });
4845
+ return picomatch.isMatch(path, pattern.replaceAll(/[[\]{}()!,@+|^$.\\]/g, String.raw`\$&`), { dot: true });
4715
4846
  }
4716
4847
  function excluded(patterns, relativePath) {
4717
4848
  return patterns.some((p) => globMatch(p, relativePath) || globMatch(p, `${relativePath}/`));
@@ -4721,7 +4852,7 @@ function inLinkedWorktree(dir, stopAt) {
4721
4852
  let current = dir;
4722
4853
  for (;; ) {
4723
4854
  const git = join17(current, ".git");
4724
- if (existsSync9(git)) {
4855
+ if (existsSync10(git)) {
4725
4856
  try {
4726
4857
  if (statSync3(git).isFile())
4727
4858
  return true;
@@ -4732,7 +4863,7 @@ function inLinkedWorktree(dir, stopAt) {
4732
4863
  }
4733
4864
  if (current === stopAt)
4734
4865
  return false;
4735
- const parent = dirname8(current);
4866
+ const parent = dirname9(current);
4736
4867
  if (parent === current)
4737
4868
  return false;
4738
4869
  current = parent;
@@ -4743,7 +4874,7 @@ async function scanLocalRoot(root, rootIndex, manifestFile, found) {
4743
4874
  const exclude = [...root.exclude ?? [], ...rootIndex === -1 ? [] : ALWAYS_EXCLUDE];
4744
4875
  const walk = async (dir, depth) => {
4745
4876
  const manifest = join17(dir, manifestFile);
4746
- if (existsSync9(manifest)) {
4877
+ if (existsSync10(manifest)) {
4747
4878
  found.push({
4748
4879
  dir,
4749
4880
  kind: "local",
@@ -4756,7 +4887,7 @@ async function scanLocalRoot(root, rootIndex, manifestFile, found) {
4756
4887
  return;
4757
4888
  let entries;
4758
4889
  try {
4759
- entries = await readdir7(dir, { withFileTypes: true });
4890
+ entries = await readdir8(dir, { withFileTypes: true });
4760
4891
  } catch {
4761
4892
  return;
4762
4893
  }
@@ -4769,7 +4900,7 @@ async function scanLocalRoot(root, rootIndex, manifestFile, found) {
4769
4900
  await walk(child, depth + 1);
4770
4901
  }
4771
4902
  };
4772
- if (!existsSync9(base))
4903
+ if (!existsSync10(base))
4773
4904
  return;
4774
4905
  await walk(base, 0);
4775
4906
  }
@@ -4777,7 +4908,7 @@ async function scanWorkshopRoot(workshopRoot, rootIndex, manifestFile, found) {
4777
4908
  const base = resolvePath(expandHome(workshopRoot));
4778
4909
  let entries;
4779
4910
  try {
4780
- entries = await readdir7(base, { withFileTypes: true });
4911
+ entries = await readdir8(base, { withFileTypes: true });
4781
4912
  } catch {
4782
4913
  return;
4783
4914
  }
@@ -4785,7 +4916,7 @@ async function scanWorkshopRoot(workshopRoot, rootIndex, manifestFile, found) {
4785
4916
  if (!entry.isDirectory() || !/^\d+$/.test(entry.name))
4786
4917
  continue;
4787
4918
  const dir = join17(base, entry.name);
4788
- if (!existsSync9(join17(dir, manifestFile)))
4919
+ if (!existsSync10(join17(dir, manifestFile)))
4789
4920
  continue;
4790
4921
  found.push({ dir, kind: "workshop", rootIndex, linkedWorktree: false, workshopId: Number(entry.name) });
4791
4922
  }
@@ -4797,7 +4928,7 @@ async function scanGameData(game, rootIndex, found) {
4797
4928
  const data = join17(resolvePath(expandHome(host)), "Data");
4798
4929
  let entries;
4799
4930
  try {
4800
- entries = await readdir7(data, { withFileTypes: true });
4931
+ entries = await readdir8(data, { withFileTypes: true });
4801
4932
  } catch {
4802
4933
  return;
4803
4934
  }
@@ -4805,14 +4936,14 @@ async function scanGameData(game, rootIndex, found) {
4805
4936
  if (!entry.isDirectory())
4806
4937
  continue;
4807
4938
  const dir = join17(data, entry.name);
4808
- if (!existsSync9(join17(dir, game.manifest.file)))
4939
+ if (!existsSync10(join17(dir, game.manifest.file)))
4809
4940
  continue;
4810
4941
  found.push({ dir, kind: "official", rootIndex, linkedWorktree: false });
4811
4942
  }
4812
4943
  }
4813
4944
  var CACHE_VERSION = 4;
4814
4945
  function acfPath2(root, steamAppId) {
4815
- return join17(dirname8(dirname8(resolvePath(expandHome(root)))), `appworkshop_${steamAppId}.acf`);
4946
+ return join17(dirname9(dirname9(resolvePath(expandHome(root)))), `appworkshop_${steamAppId}.acf`);
4816
4947
  }
4817
4948
  function contentPairs(acf) {
4818
4949
  let text;
@@ -4836,7 +4967,7 @@ function workshopStamp(game, dataRoot) {
4836
4967
  return null;
4837
4968
  pairs.push(...found);
4838
4969
  }
4839
- const download = createHash3("sha1").update(pairs.sort().join(`
4970
+ const download = createHash3("sha256").update(pairs.toSorted().join(`
4840
4971
  `)).digest("hex");
4841
4972
  if (game.workshopRoot === null)
4842
4973
  return download;
@@ -4897,7 +5028,7 @@ function compareTiers(a, b) {
4897
5028
  return 0;
4898
5029
  }
4899
5030
  function rank(a, b) {
4900
- return compareTiers(a, b) || byClone(a, b) || (a.dir < b.dir ? -1 : a.dir > b.dir ? 1 : 0);
5031
+ return compareTiers(a, b) || byClone(a, b) || Number(a.dir > b.dir) - Number(a.dir < b.dir);
4901
5032
  }
4902
5033
  function byClone(a, b) {
4903
5034
  if (a.clonedAt === undefined || b.clonedAt === undefined)
@@ -4911,13 +5042,7 @@ async function applyWorktreeRequests(index, requests, config) {
4911
5042
  for (const bucket of index.byPackageId.values()) {
4912
5043
  for (const record of bucket) {
4913
5044
  known.add(record.dir);
4914
- for (const request of requests) {
4915
- if (!contains(request, record.dir))
4916
- continue;
4917
- record.worktree = { root: request.root, branch: request.branch, source: request.source };
4918
- record.selectedWorktree = request.order;
4919
- break;
4920
- }
5045
+ stampOwner(record, requests);
4921
5046
  }
4922
5047
  }
4923
5048
  for (const request of requests) {
@@ -4927,14 +5052,25 @@ async function applyWorktreeRequests(index, requests, config) {
4927
5052
  if (fresh.length === 0)
4928
5053
  continue;
4929
5054
  for (const record of await parseAll(fresh, config, index.plugin, index.problems)) {
4930
- record.worktree = { root: request.root, branch: request.branch, source: request.source };
4931
- record.selectedWorktree = request.order;
5055
+ markWorktree(record, request);
4932
5056
  insert(index, record);
4933
5057
  }
4934
5058
  }
4935
5059
  for (const bucket of index.byPackageId.values())
4936
5060
  bucket.sort(rank);
4937
5061
  }
5062
+ function stampOwner(record, requests) {
5063
+ for (const request of requests) {
5064
+ if (!contains(request, record.dir))
5065
+ continue;
5066
+ markWorktree(record, request);
5067
+ return;
5068
+ }
5069
+ }
5070
+ function markWorktree(record, request) {
5071
+ record.worktree = { root: request.root, branch: request.branch, source: request.source };
5072
+ record.selectedWorktree = request.order;
5073
+ }
4938
5074
  var WORKTREE_SCAN_DEPTH = 5;
4939
5075
  var WORKTREE_EXCLUDE = ["**/.retired/**", "**/node_modules/**", "**/bin/**", "**/obj/**"];
4940
5076
  async function applySourceOverrides(index, overrides, config) {
@@ -4950,7 +5086,7 @@ async function applySourceOverrides(index, overrides, config) {
4950
5086
  const wanted = spec.slice(0, eq);
4951
5087
  const dir = resolvePath(expandHome(spec.slice(eq + 1)));
4952
5088
  const file = join17(dir, config.manifest.file);
4953
- if (!existsSync9(file)) {
5089
+ if (!existsSync10(file)) {
4954
5090
  problems.push({
4955
5091
  where: spec,
4956
5092
  message: `no ${config.manifest.file} under ${dir}`,
@@ -5020,51 +5156,58 @@ async function buildIndex(game, config, plugin, sourcesDir, dataRoot) {
5020
5156
  byShortName: new Map,
5021
5157
  problems: []
5022
5158
  };
5159
+ const cacheIndex = config.scanRoots.length;
5160
+ await indexLocal(index, config, sourcesDir, cacheIndex);
5161
+ await indexWorkshop(index, config, dataRoot, cacheIndex);
5162
+ for (const bucket of index.byPackageId.values())
5163
+ bucket.sort(rank);
5164
+ return index;
5165
+ }
5166
+ async function indexLocal(index, config, sourcesDir, cacheIndex) {
5023
5167
  const local = [];
5024
5168
  await scanGameData(config, -1, local);
5025
5169
  for (const [i, root] of config.scanRoots.entries()) {
5026
5170
  await scanLocalRoot(root, i, config.manifest.file, local);
5027
5171
  }
5028
- const cacheIndex = config.scanRoots.length;
5029
- const parsed = await parseAll(local, config, plugin, index.problems);
5172
+ const parsed = await parseAll(local, config, index.plugin, index.problems);
5030
5173
  if (sourcesDir !== undefined) {
5031
5174
  const cache = [];
5032
5175
  await scanLocalRoot({ path: sourcesDir, maxDepth: 4 }, cacheIndex, config.manifest.file, cache);
5033
- parsed.push(...oneModPerClone(await parseAll(cache, config, plugin, index.problems), sourcesDir));
5176
+ const records = await parseAll(cache, config, index.plugin, index.problems);
5177
+ parsed.push(...oneModPerClone(records, sourcesDir));
5034
5178
  }
5035
5179
  for (const record of parsed)
5036
5180
  insert(index, record);
5037
- const workshopRoots = [];
5181
+ }
5182
+ async function indexWorkshop(index, config, dataRoot, cacheIndex) {
5183
+ const roots = [];
5038
5184
  if (dataRoot !== undefined)
5039
- workshopRoots.push(downloadRoot(dataRoot, config));
5185
+ roots.push(downloadRoot(dataRoot, config));
5040
5186
  if (config.workshopRoot !== null)
5041
- workshopRoots.push(config.workshopRoot);
5042
- if (workshopRoots.length > 0) {
5043
- const stamp = workshopStamp(config, dataRoot);
5044
- const cached = stamp === null ? null : await readWorkshopCache(game, stamp);
5045
- if (cached) {
5046
- for (const record of cached)
5047
- insert(index, record);
5048
- } else {
5049
- const items = [];
5050
- for (const [i, root] of workshopRoots.entries()) {
5051
- await scanWorkshopRoot(root, cacheIndex + 1 + i, config.manifest.file, items);
5052
- }
5053
- const records = await parseAll(items, config, plugin, index.problems);
5054
- for (const record of records)
5055
- insert(index, record);
5056
- if (stamp !== null)
5057
- await writeWorkshopCache(game, stamp, records);
5058
- }
5187
+ roots.push(config.workshopRoot);
5188
+ if (roots.length === 0)
5189
+ return;
5190
+ const stamp = workshopStamp(config, dataRoot);
5191
+ const cached = stamp === null ? null : await readWorkshopCache(index.game, stamp);
5192
+ if (cached) {
5193
+ for (const record of cached)
5194
+ insert(index, record);
5195
+ return;
5059
5196
  }
5060
- for (const bucket of index.byPackageId.values())
5061
- bucket.sort(rank);
5062
- return index;
5197
+ const items = [];
5198
+ for (const [i, root] of roots.entries()) {
5199
+ await scanWorkshopRoot(root, cacheIndex + 1 + i, config.manifest.file, items);
5200
+ }
5201
+ const records = await parseAll(items, config, index.plugin, index.problems);
5202
+ for (const record of records)
5203
+ insert(index, record);
5204
+ if (stamp !== null)
5205
+ await writeWorkshopCache(index.game, stamp, records);
5063
5206
  }
5064
5207
  function oneModPerClone(records, sourcesDir) {
5065
5208
  const kept = new Map;
5066
5209
  const stamps = new Map;
5067
- for (const record of [...records].sort((a, b) => a.dir < b.dir ? -1 : a.dir > b.dir ? 1 : 0)) {
5210
+ for (const record of [...records].sort((a, b) => Number(a.dir > b.dir) - Number(a.dir < b.dir))) {
5068
5211
  const clone = relative3(sourcesDir, record.dir).split(sep2).slice(0, 2).join(sep2);
5069
5212
  const at = join17(sourcesDir, clone);
5070
5213
  let stamp = stamps.get(at);
@@ -5119,9 +5262,9 @@ function resolveRaw(index, ref, game) {
5119
5262
  return resolveRaw(index, alias, game);
5120
5263
  }
5121
5264
  const short = index.byShortName.get(ref.toLowerCase());
5122
- if (short && short.length === 1)
5265
+ if (short?.length === 1)
5123
5266
  return pick(index, short[0].toLowerCase(), ref);
5124
- if (short && short.length > 1) {
5267
+ if (short !== undefined && short.length > 1) {
5125
5268
  throw new GamecrateError(`"${ref}" is a short name for ${short.length} mods`, Exit.Resolution, short.join(", "));
5126
5269
  }
5127
5270
  return null;
@@ -5155,7 +5298,7 @@ function byPath(index, raw, game) {
5155
5298
  return hit;
5156
5299
  }
5157
5300
  const file = join17(dir, game.manifest.file);
5158
- if (!existsSync9(file))
5301
+ if (!existsSync10(file))
5159
5302
  return null;
5160
5303
  try {
5161
5304
  const manifest = index.plugin.parseManifest(readFileSync5(file, "utf8"));
@@ -5215,7 +5358,7 @@ function expandDynamic(entry, index, where, problems) {
5215
5358
  const head = matched.filter((id) => firstOrder.includes(id.toLowerCase())).sort((a, b) => firstOrder.indexOf(a.toLowerCase()) - firstOrder.indexOf(b.toLowerCase()));
5216
5359
  const rest = matched.filter((id) => !firstOrder.includes(id.toLowerCase()));
5217
5360
  if ((entry.sort ?? "alpha") === "alpha") {
5218
- rest.sort((a, b) => a.toLowerCase() < b.toLowerCase() ? -1 : a.toLowerCase() > b.toLowerCase() ? 1 : 0);
5361
+ rest.sort((a, b) => Number(a.toLowerCase() > b.toLowerCase()) - Number(a.toLowerCase() < b.toLowerCase()));
5219
5362
  }
5220
5363
  return [...head, ...rest];
5221
5364
  }
@@ -5272,6 +5415,42 @@ function topoSort(list, problems, core) {
5272
5415
  const position = new Map;
5273
5416
  for (const [i, mod] of list.entries())
5274
5417
  position.set(mod.record.packageId.toLowerCase(), i);
5418
+ const { indegree, outgoing, incoming } = graph(list, position);
5419
+ const coreIndex = core === undefined ? undefined : position.get(core.toLowerCase());
5420
+ const preCore = reachesCore(incoming, coreIndex);
5421
+ const phase = (i) => preCore.has(i) ? 0 : 1;
5422
+ const sorted = [];
5423
+ const done = list.map(() => false);
5424
+ for (;; ) {
5425
+ const next = nextReady(done, indegree, phase);
5426
+ if (next === -1)
5427
+ break;
5428
+ done[next] = true;
5429
+ sorted.push(list[next]);
5430
+ for (const to of outgoing[next])
5431
+ indegree[to] -= 1;
5432
+ }
5433
+ const cycle = list.filter((_, i) => !done[i]);
5434
+ if (cycle.length > 0) {
5435
+ problems.push({
5436
+ where: "flag --sort topo",
5437
+ message: `load-order cycle among ${cycle.map((m) => m.record.packageId).join(", ")}; left in profile order`
5438
+ });
5439
+ sorted.push(...cycle);
5440
+ }
5441
+ return sorted;
5442
+ }
5443
+ function nextReady(done, indegree, phase) {
5444
+ let next = -1;
5445
+ for (let i = 0;i < done.length; i++) {
5446
+ if (done[i] || indegree[i] !== 0)
5447
+ continue;
5448
+ if (next === -1 || phase(i) < phase(next))
5449
+ next = i;
5450
+ }
5451
+ return next;
5452
+ }
5453
+ function graph(list, position) {
5275
5454
  const edges = new Set;
5276
5455
  const addEdge = (from, to) => {
5277
5456
  if (from === undefined || to === undefined || from === to)
@@ -5294,46 +5473,22 @@ function topoSort(list, problems, core) {
5294
5473
  incoming[to].push(from);
5295
5474
  indegree[to] += 1;
5296
5475
  }
5476
+ return { indegree, outgoing, incoming };
5477
+ }
5478
+ function reachesCore(incoming, coreIndex) {
5297
5479
  const preCore = new Set;
5298
- const coreIndex = core === undefined ? undefined : position.get(core.toLowerCase());
5299
- if (coreIndex !== undefined) {
5300
- const queue = [coreIndex];
5301
- while (queue.length > 0) {
5302
- for (const from of incoming[queue.pop()]) {
5303
- if (preCore.has(from))
5304
- continue;
5305
- preCore.add(from);
5306
- queue.push(from);
5307
- }
5308
- }
5309
- }
5310
- const phase = (i) => preCore.has(i) ? 0 : 1;
5311
- const sorted = [];
5312
- const done = list.map(() => false);
5313
- for (;; ) {
5314
- let next = -1;
5315
- for (let i = 0;i < list.length; i++) {
5316
- if (done[i] || indegree[i] !== 0)
5480
+ if (coreIndex === undefined)
5481
+ return preCore;
5482
+ const queue = [coreIndex];
5483
+ while (queue.length > 0) {
5484
+ for (const from of incoming[queue.pop()]) {
5485
+ if (preCore.has(from))
5317
5486
  continue;
5318
- if (next === -1 || phase(i) < phase(next))
5319
- next = i;
5487
+ preCore.add(from);
5488
+ queue.push(from);
5320
5489
  }
5321
- if (next === -1)
5322
- break;
5323
- done[next] = true;
5324
- sorted.push(list[next]);
5325
- for (const to of outgoing[next])
5326
- indegree[to] -= 1;
5327
5490
  }
5328
- const cycle = list.filter((_, i) => !done[i]);
5329
- if (cycle.length > 0) {
5330
- problems.push({
5331
- where: "flag --sort topo",
5332
- message: `load-order cycle among ${cycle.map((m) => m.record.packageId).join(", ")}; left in profile order`
5333
- });
5334
- sorted.push(...cycle);
5335
- }
5336
- return sorted;
5491
+ return preCore;
5337
5492
  }
5338
5493
  function incompatibilityWarnings(list) {
5339
5494
  const byId = new Map(list.map((mod) => [mod.record.packageId.toLowerCase(), mod.record.packageId]));
@@ -5353,6 +5508,96 @@ function incompatibilityWarnings(list) {
5353
5508
  }
5354
5509
  return warnings;
5355
5510
  }
5511
+ function stageSlots(input) {
5512
+ const { game, gameName, profileName, profile, args, index } = input;
5513
+ const excluded = [...profile.exclude ?? [], ...args.without ?? []].map(globToRegExp);
5514
+ const isExcluded = (id) => excluded.some((pattern) => pattern.test(id));
5515
+ const staged = [];
5516
+ const present = new Set;
5517
+ for (const slot of collectSlots(game, gameName, profileName, profile, args)) {
5518
+ for (const { ref, optional } of slotRefs(slot, input)) {
5519
+ const record = resolveModRef(index, ref, game);
5520
+ if (!record) {
5521
+ reportMissing(slot, ref, optional, input);
5522
+ continue;
5523
+ }
5524
+ const key = record.packageId.toLowerCase();
5525
+ if (present.has(key) || isExcluded(record.packageId))
5526
+ continue;
5527
+ present.add(key);
5528
+ staged.push({ record, explicit: true });
5529
+ }
5530
+ }
5531
+ return { staged, present };
5532
+ }
5533
+ function slotRefs(slot, input) {
5534
+ const { entry } = slot;
5535
+ if (isDynamic(entry)) {
5536
+ return expandDynamic(entry, input.index, slot.where, input.problems).map((id) => ({ ref: id, optional: false }));
5537
+ }
5538
+ return [
5539
+ {
5540
+ ref: refFor(entry, input.game, input.sources),
5541
+ optional: typeof entry !== "string" && entry.optional === true
5542
+ }
5543
+ ];
5544
+ }
5545
+ function reportMissing(slot, ref, optional, input) {
5546
+ if (slot.dlc === true)
5547
+ return;
5548
+ if (optional) {
5549
+ input.warnings.push(`optional mod ${ref} is not installed; skipped`);
5550
+ return;
5551
+ }
5552
+ if (ref.startsWith("workshop:") && input.unfetched.has(ref.slice(9))) {
5553
+ input.problems.push({ where: slot.where, message: notFetched(ref.slice(9)) });
5554
+ return;
5555
+ }
5556
+ input.problems.push({ where: slot.where, message: `no mod matches "${ref}"` });
5557
+ }
5558
+ function relabelUnfetched(problems, unfetched) {
5559
+ if (unfetched.size === 0)
5560
+ return;
5561
+ for (const problem of problems) {
5562
+ const id = workshopUrlId(problem.suggestion);
5563
+ if (id !== undefined && unfetched.has(id))
5564
+ problem.message = notFetched(id);
5565
+ }
5566
+ }
5567
+ async function describeMod({ record, explicit }, game, index, warnings) {
5568
+ const times = record.kind === "local" ? await scanBuildTimes(record.dir) : null;
5569
+ const report = times === null ? null : staleReport(times);
5570
+ if (record.worktree) {
5571
+ warnings.push(`${record.packageId} comes from worktree ${record.worktree.branch} (${record.worktree.source}): ${record.dir}`);
5572
+ }
5573
+ const shadowed = (index.byPackageId.get(record.packageId.toLowerCase()) ?? []).filter((other) => other.dir !== record.dir).map((other) => other.dir);
5574
+ return {
5575
+ packageId: record.packageId,
5576
+ hostDir: record.dir,
5577
+ containerDir: `${game.modsDir.container}/${record.packageId}`,
5578
+ kind: record.kind,
5579
+ ...record.workshopId === undefined ? {} : { workshopId: record.workshopId },
5580
+ explicit,
5581
+ stale: times === null ? false : decideStale(times),
5582
+ ...report === null ? {} : { staleReport: report },
5583
+ ...record.worktree === undefined ? {} : { worktree: { ...record.worktree, selected: record.selectedWorktree !== undefined } },
5584
+ ...shadowed.length === 0 ? {} : { shadowed }
5585
+ };
5586
+ }
5587
+ function checkDataDir(problems, gameName, game) {
5588
+ if (game.dataDir.mode === "arg" && game.dataDir.arg.split("=").length !== 2) {
5589
+ problems.push({
5590
+ where: `/games/${gameName}/dataDir/arg`,
5591
+ message: `"${game.dataDir.arg}" must contain exactly one "="; RimWorld silently ignores anything else`
5592
+ });
5593
+ }
5594
+ if (game.dataDir.container.includes("=")) {
5595
+ problems.push({
5596
+ where: `/games/${gameName}/dataDir/container`,
5597
+ message: `container data path "${game.dataDir.container}" contains "=", which disables the override silently`
5598
+ });
5599
+ }
5600
+ }
5356
5601
  async function resolvePlan(options) {
5357
5602
  const { game: gameName, profile: requestedProfile, root } = options;
5358
5603
  const args = options.args ?? {};
@@ -5388,78 +5633,28 @@ async function resolvePlan(options) {
5388
5633
  const index = options.index ?? await buildIndex(gameName, game, plugin, sourcesRoot(root.dataRoot), root.dataRoot);
5389
5634
  await applyWorktreeRequests(index, instance.requests, game);
5390
5635
  problems.push(...await applySourceOverrides(index, args.use ?? [], game));
5391
- const excluded = [...profile.exclude ?? [], ...args.without ?? []].map(globToRegExp);
5392
- const isExcluded = (id) => excluded.some((pattern) => pattern.test(id));
5393
- const staged = [];
5394
- const present = new Set;
5395
- for (const slot of collectSlots(game, gameName, profileName, profile, args)) {
5396
- const entry = slot.entry;
5397
- const refs = isDynamic(entry) ? expandDynamic(entry, index, slot.where, problems).map((id) => ({ ref: id, optional: false })) : [{ ref: refFor(entry, game, sources), optional: typeof entry !== "string" && entry.optional === true }];
5398
- for (const { ref, optional } of refs) {
5399
- const record = resolveModRef(index, ref, game);
5400
- if (!record) {
5401
- if (slot.dlc === true)
5402
- continue;
5403
- if (optional)
5404
- warnings.push(`optional mod ${ref} is not installed; skipped`);
5405
- else if (ref.startsWith("workshop:") && unfetched.has(ref.slice(9))) {
5406
- problems.push({ where: slot.where, message: notFetched(ref.slice(9)) });
5407
- } else
5408
- problems.push({ where: slot.where, message: `no mod matches "${ref}"` });
5409
- continue;
5410
- }
5411
- const key = record.packageId.toLowerCase();
5412
- if (present.has(key) || isExcluded(record.packageId))
5413
- continue;
5414
- present.add(key);
5415
- staged.push({ record, explicit: true });
5416
- }
5417
- }
5418
- if (profile.autoDependencies === true)
5636
+ const { staged, present } = stageSlots({
5637
+ game,
5638
+ gameName,
5639
+ profileName,
5640
+ profile,
5641
+ args,
5642
+ index,
5643
+ sources,
5644
+ unfetched,
5645
+ problems,
5646
+ warnings
5647
+ });
5648
+ if (profile.autoDependencies !== false)
5419
5649
  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
- }
5650
+ relabelUnfetched(problems, unfetched);
5427
5651
  const ordered = args.sort === "none" ? staged : topoSort(staged, problems, game.core);
5428
5652
  warnings.push(...incompatibilityWarnings(ordered));
5429
5653
  const mods = [];
5430
- for (const { record, explicit } of ordered) {
5431
- const times = record.kind === "local" ? await scanBuildTimes(record.dir) : null;
5432
- const report = times === null ? null : staleReport(times);
5433
- if (record.worktree) {
5434
- warnings.push(`${record.packageId} comes from worktree ${record.worktree.branch} (${record.worktree.source}): ${record.dir}`);
5435
- }
5436
- const shadowed = (index.byPackageId.get(record.packageId.toLowerCase()) ?? []).filter((other) => other.dir !== record.dir).map((other) => other.dir);
5437
- mods.push({
5438
- packageId: record.packageId,
5439
- hostDir: record.dir,
5440
- containerDir: `${game.modsDir.container}/${record.packageId}`,
5441
- kind: record.kind,
5442
- ...record.workshopId === undefined ? {} : { workshopId: record.workshopId },
5443
- explicit,
5444
- stale: times === null ? false : decideStale(times),
5445
- ...report === null ? {} : { staleReport: report },
5446
- ...record.worktree === undefined ? {} : { worktree: { ...record.worktree, selected: record.selectedWorktree !== undefined } },
5447
- ...shadowed.length === 0 ? {} : { shadowed }
5448
- });
5449
- }
5654
+ for (const entry of ordered)
5655
+ mods.push(await describeMod(entry, game, index, warnings));
5450
5656
  problems.push(...index.problems);
5451
- if (game.dataDir.mode === "arg" && game.dataDir.arg.split("=").length !== 2) {
5452
- problems.push({
5453
- where: `/games/${gameName}/dataDir/arg`,
5454
- message: `"${game.dataDir.arg}" must contain exactly one "="; RimWorld silently ignores anything else`
5455
- });
5456
- }
5457
- if (game.dataDir.container.includes("=")) {
5458
- problems.push({
5459
- where: `/games/${gameName}/dataDir/container`,
5460
- message: `container data path "${game.dataDir.container}" contains "=", which disables the override silently`
5461
- });
5462
- }
5657
+ checkDataDir(problems, gameName, game);
5463
5658
  const mode = args.mode ?? "headed";
5464
5659
  if (!game.modes.includes(mode)) {
5465
5660
  problems.push({ where: "flag --mode", message: `${gameName} does not support mode "${mode}"` });
@@ -5493,7 +5688,7 @@ async function resolvePlan(options) {
5493
5688
  }
5494
5689
 
5495
5690
  // src/run/registry.ts
5496
- import { readdir as readdir8 } from "node:fs/promises";
5691
+ import { readdir as readdir9 } from "node:fs/promises";
5497
5692
  import { join as join19 } from "node:path";
5498
5693
  var FORMAT = '{{.Names}}\t{{.Label "gamecrate.game"}}\t{{.Label "gamecrate.profile"}}\t{{.Label "gamecrate.instance"}}\t{{.Status}}';
5499
5694
  function parseDockerRuns(stdout) {
@@ -5532,7 +5727,7 @@ async function walkLocks(dataRoot) {
5532
5727
  return out;
5533
5728
  }
5534
5729
  async function entries(dir) {
5535
- const found = await readdir8(dir, { withFileTypes: true }).catch(() => []);
5730
+ const found = await readdir9(dir, { withFileTypes: true }).catch(() => []);
5536
5731
  return found.filter((entry) => entry.isDirectory()).map((entry) => entry.name);
5537
5732
  }
5538
5733
  async function push(out, path) {
@@ -5557,35 +5752,40 @@ async function listRuns(dataRoot, docker = dockerPs) {
5557
5752
  const out = [...running];
5558
5753
  for (const lock of await walkLocks(dataRoot)) {
5559
5754
  const match = byContainer.get(lock.container);
5560
- if (match !== undefined) {
5561
- const stale = match.pid !== undefined && !isRunning(match.pid, match.startedAt);
5562
- if (match.pid === undefined || stale && isRunning(lock.pid, lock.startedAt)) {
5563
- match.pid = lock.pid;
5564
- match.startedAt = lock.startedAt;
5565
- if (lock.mode !== undefined)
5566
- match.mode = lock.mode;
5567
- }
5568
- continue;
5569
- }
5570
- out.push({
5571
- game: lock.game,
5572
- profile: lock.profile,
5573
- ...lock.instance === undefined ? {} : { instance: lock.instance },
5574
- container: lock.container,
5575
- pid: lock.pid,
5576
- ...lock.mode === undefined ? {} : { mode: lock.mode },
5577
- startedAt: lock.startedAt,
5578
- status: isRunning(lock.pid, lock.startedAt) ? "starting" : "orphaned"
5579
- });
5755
+ if (match === undefined)
5756
+ out.push(fromLock(lock));
5757
+ else
5758
+ mergeLock(match, lock);
5580
5759
  }
5581
5760
  return out;
5582
5761
  }
5762
+ function mergeLock(match, lock) {
5763
+ const stale = match.pid !== undefined && !isRunning(match.pid, match.startedAt);
5764
+ if (match.pid !== undefined && !(stale && isRunning(lock.pid, lock.startedAt)))
5765
+ return;
5766
+ match.pid = lock.pid;
5767
+ match.startedAt = lock.startedAt;
5768
+ if (lock.mode !== undefined)
5769
+ match.mode = lock.mode;
5770
+ }
5771
+ function fromLock(lock) {
5772
+ return {
5773
+ game: lock.game,
5774
+ profile: lock.profile,
5775
+ ...lock.instance === undefined ? {} : { instance: lock.instance },
5776
+ container: lock.container,
5777
+ pid: lock.pid,
5778
+ ...lock.mode === undefined ? {} : { mode: lock.mode },
5779
+ startedAt: lock.startedAt,
5780
+ status: isRunning(lock.pid, lock.startedAt) ? "starting" : "orphaned"
5781
+ };
5782
+ }
5583
5783
 
5584
5784
  // src/launch/stage.ts
5585
- import { lstat, mkdir as mkdir5, readdir as readdir9, realpath as realpath2, rm as rm3, stat as stat5 } from "node:fs/promises";
5785
+ import { lstat, mkdir as mkdir5, readdir as readdir10, realpath as realpath2, rm as rm4, stat as stat5 } from "node:fs/promises";
5586
5786
  import { basename as basename10, join as join20 } from "node:path";
5587
5787
  async function stageMods(plan) {
5588
- await rm3(plan.stageDirHost, { recursive: true, force: true });
5788
+ await rm4(plan.stageDirHost, { recursive: true, force: true });
5589
5789
  await mkdir5(plan.stageDirHost, { recursive: true });
5590
5790
  const mounts = [];
5591
5791
  for (const mod of plan.mods) {
@@ -5646,7 +5846,7 @@ async function detectForeignOwnership(dir, uid, limit = 100) {
5646
5846
  if (!info.isDirectory())
5647
5847
  continue;
5648
5848
  try {
5649
- for (const entry of await readdir9(current))
5849
+ for (const entry of await readdir10(current))
5650
5850
  queue.push(join20(current, entry));
5651
5851
  } catch {
5652
5852
  continue;
@@ -5656,7 +5856,7 @@ async function detectForeignOwnership(dir, uid, limit = 100) {
5656
5856
  }
5657
5857
 
5658
5858
  // src/mods/workshop.ts
5659
- import { existsSync as existsSync10 } from "node:fs";
5859
+ import { existsSync as existsSync11 } from "node:fs";
5660
5860
  import { readFile as readFile11 } from "node:fs/promises";
5661
5861
  import { join as join21, resolve as resolvePath2 } from "node:path";
5662
5862
  var ROUNDS = 5;
@@ -5671,26 +5871,9 @@ async function prepareWorkshop(game, profileName, args, config, allowFetch, plug
5671
5871
  for (let round = 0;round < ROUNDS && frontier.length > 0; round++) {
5672
5872
  for (const id of frontier)
5673
5873
  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];
5874
+ if (allowFetch)
5875
+ warnings.push(...await fetchRound(config, game, roots, frontier));
5876
+ frontier = await dependenciesOf(frontier, ids, roots, game, plugin, problems);
5694
5877
  }
5695
5878
  if (frontier.length > 0) {
5696
5879
  problems.push({
@@ -5701,11 +5884,32 @@ async function prepareWorkshop(game, profileName, args, config, allowFetch, plug
5701
5884
  }
5702
5885
  return { ids, warnings, problems, unfetched: [...ids].filter((id) => itemDir(roots, id) === undefined) };
5703
5886
  }
5887
+ async function fetchRound(config, game, roots, frontier) {
5888
+ try {
5889
+ const drift = await checkDrift(frontier, roots);
5890
+ const downloaded = await downloadItems(config, game, config.dataRoot, drift.needed);
5891
+ return [...drift.warnings, ...downloaded.warnings];
5892
+ } catch (error) {
5893
+ return [`could not download workshop items: ${error instanceof Error ? error.message : String(error)}`];
5894
+ }
5895
+ }
5896
+ async function dependenciesOf(frontier, seen, roots, game, plugin, problems) {
5897
+ const next = new Set;
5898
+ for (const id of frontier) {
5899
+ const manifest = await manifestOf(roots, id, game, plugin, problems);
5900
+ for (const dep of manifest?.modDependencies ?? []) {
5901
+ const depId = workshopUrlId(dep.steamWorkshopUrl);
5902
+ if (depId !== undefined && !seen.has(depId))
5903
+ next.add(depId);
5904
+ }
5905
+ }
5906
+ return [...next];
5907
+ }
5704
5908
  function mountedRoots(dataRoot, game) {
5705
5909
  return [downloadRoot(dataRoot, game), ...game.workshopRoot === null ? [] : [game.workshopRoot]];
5706
5910
  }
5707
5911
  function itemDir(roots, id) {
5708
- return roots.map((root) => join21(root, id)).find((dir) => existsSync10(dir));
5912
+ return roots.map((root) => join21(root, id)).find((dir) => existsSync11(dir));
5709
5913
  }
5710
5914
  async function manifestOf(roots, id, game, plugin, problems) {
5711
5915
  const dir = itemDir(roots, id);
@@ -5713,7 +5917,7 @@ async function manifestOf(roots, id, game, plugin, problems) {
5713
5917
  }
5714
5918
  async function manifestAt(dir, game, plugin, problems) {
5715
5919
  const file = join21(dir, game.manifest.file);
5716
- if (!existsSync10(file))
5920
+ if (!existsSync11(file))
5717
5921
  return null;
5718
5922
  try {
5719
5923
  return plugin.parseManifest(await readFile11(file, "utf8"));
@@ -5737,26 +5941,26 @@ async function localSeeds(game, profile, args, sources, plugin, problems) {
5737
5941
  function localDirs(game, profile, args, sources) {
5738
5942
  const out = new Set;
5739
5943
  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)));
5944
+ const dir = localDirOf(entry, game, sources);
5945
+ if (dir !== undefined)
5946
+ out.add(dir);
5757
5947
  }
5758
5948
  return [...out];
5759
5949
  }
5950
+ function localDirOf(entry, game, sources) {
5951
+ if (typeof entry !== "string" && "match" in entry)
5952
+ return;
5953
+ const object = typeof entry === "string" ? { id: entry } : entry;
5954
+ if (object.path !== undefined)
5955
+ return resolvePath2(expandHome(object.path));
5956
+ if (object.id.includes(":"))
5957
+ return;
5958
+ const pin = libraryPin(game, object.id);
5959
+ const clone = sources.get(object.id.toLowerCase());
5960
+ if (clone !== undefined)
5961
+ return pin?.subdir === undefined ? clone : join21(clone, pin.subdir);
5962
+ return pin?.path === undefined ? undefined : resolvePath2(expandHome(pin.path));
5963
+ }
5760
5964
  function wantedIds(game, profile, args) {
5761
5965
  const out = new Set;
5762
5966
  for (const entry of reachedEntries(game, profile, args)) {
@@ -5788,7 +5992,7 @@ function published2(value) {
5788
5992
  }
5789
5993
 
5790
5994
  // src/index.ts
5791
- var VERSION = "1.3.0";
5995
+ var VERSION = "1.4.1";
5792
5996
  async function main(argv) {
5793
5997
  const supervised = supervisedDir(argv);
5794
5998
  try {
@@ -5920,7 +6124,7 @@ async function run2(argv, args, config, plugins, defaults, asShell) {
5920
6124
  const sources = await prepareSources(gameConfig, profile, args, config.dataRoot, allowFetch);
5921
6125
  try {
5922
6126
  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);
6127
+ return await resolved({ argv, args, config, plugins, asShell, game, profile, sources, workshop, allowFetch });
5924
6128
  } finally {
5925
6129
  await sources.release();
5926
6130
  }
@@ -5936,7 +6140,8 @@ function warnUnfetched(problems, unfetched) {
5936
6140
  }
5937
6141
  return fatal;
5938
6142
  }
5939
- async function resolved(argv, args, config, plugins, asShell, game, profile, sources, workshop, allowFetch) {
6143
+ async function resolved(inputs) {
6144
+ const { argv, args, config, plugins, asShell, game, profile, sources, workshop, allowFetch } = inputs;
5940
6145
  for (const warning of [...sources.warnings, ...workshop.warnings])
5941
6146
  warn(warning);
5942
6147
  const index = await buildIndex(game, config.games[game], requirePlugin(plugins, game), sourcesRoot(config.dataRoot), config.dataRoot);
@@ -5945,21 +6150,8 @@ async function resolved(argv, args, config, plugins, asShell, game, profile, sou
5945
6150
  if (fatal.length > 0)
5946
6151
  reportProblems(fatal);
5947
6152
  const identity = resolveIdentity(args.root);
5948
- if (args.printPlan || args.dryRun) {
5949
- const environment = await preflight(plan);
5950
- buildRunSpec(plan, [], identity);
5951
- if (args.printPlan)
5952
- printPlan(plan, args.json);
5953
- for (const warning of planWarnings(plan))
5954
- warn(warning);
5955
- if (environment.length > 0)
5956
- reportEnvironment(environment);
5957
- if (!args.printPlan) {
5958
- const what = plan.instance === undefined ? profile : `${profile}/${plan.instance}`;
5959
- status(`${game} ${what}: ${plan.mods.length} mods resolve cleanly`);
5960
- }
5961
- return Exit.Ok;
5962
- }
6153
+ if (args.printPlan || args.dryRun)
6154
+ return await reportPlanOnly(plan, args, profile, identity);
5963
6155
  const environment = await preflight(plan);
5964
6156
  if (environment.length > 0)
5965
6157
  reportEnvironment(environment);
@@ -5979,6 +6171,21 @@ async function resolved(argv, args, config, plugins, asShell, game, profile, sou
5979
6171
  await lock.release();
5980
6172
  }
5981
6173
  }
6174
+ async function reportPlanOnly(plan, args, profile, identity) {
6175
+ const environment = await preflight(plan);
6176
+ buildRunSpec(plan, [], identity);
6177
+ if (args.printPlan)
6178
+ printPlan(plan, args.json);
6179
+ for (const warning of planWarnings(plan))
6180
+ warn(warning);
6181
+ if (environment.length > 0)
6182
+ reportEnvironment(environment);
6183
+ if (!args.printPlan) {
6184
+ const what = plan.instance === undefined ? profile : `${profile}/${plan.instance}`;
6185
+ status(`${plan.game} ${what}: ${plan.mods.length} mods resolve cleanly`);
6186
+ }
6187
+ return Exit.Ok;
6188
+ }
5982
6189
  async function launch(plan, args, config, identity, asShell, profileSpec, releaseSources) {
5983
6190
  const game = plan.game;
5984
6191
  const profile = plan.profile;
@@ -5992,12 +6199,13 @@ run: gamecrate fix-perms ${game} ${profile}`);
5992
6199
  plan.runDirHost = runDir;
5993
6200
  const supervisorLog = args.supervised && args.log === undefined ? redirectOutput(join22(runDir, "supervisor.log")) : undefined;
5994
6201
  try {
5995
- return await execute(plan, args, config, identity, asShell, profileSpec, runDir, releaseSources);
6202
+ return await execute({ plan, args, config, identity, asShell, profileSpec, runDir, releaseSources });
5996
6203
  } finally {
5997
6204
  supervisorLog?.close();
5998
6205
  }
5999
6206
  }
6000
- async function execute(plan, args, config, identity, asShell, profileSpec, runDir, releaseSources) {
6207
+ async function execute(inputs) {
6208
+ const { plan, args, config, identity, asShell, profileSpec, runDir, releaseSources } = inputs;
6001
6209
  const game = plan.game;
6002
6210
  await buildLocalMods(plan, buildPolicy(args, profileSpec));
6003
6211
  await releaseSources();
@@ -6122,9 +6330,9 @@ async function copyOutLogs(plan) {
6122
6330
  if (spec.mode !== "copy-out")
6123
6331
  return;
6124
6332
  const source = join22(plan.dataDirHost, spec.from);
6125
- if (!existsSync11(source))
6333
+ if (!existsSync12(source))
6126
6334
  return;
6127
- const target = join22(plan.runDirHost, basename11(spec.from.replace(/\/+$/, "")));
6335
+ const target = join22(plan.runDirHost, basename11(spec.from));
6128
6336
  try {
6129
6337
  await cp(source, target, { recursive: true, force: true });
6130
6338
  } catch (error) {
@@ -6147,24 +6355,17 @@ async function mods(args, config, plugins, defaults) {
6147
6355
  return Exit.Ok;
6148
6356
  }
6149
6357
  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;
6358
+ if (Object.values(game.library ?? {}).some((entry) => entry.workshop !== undefined))
6359
+ return true;
6360
+ const slots = [...game.preCore ?? [], game.core, ...game.dlc, ...game.base ?? []];
6361
+ if (slots.some((ref) => ref.startsWith("workshop:")))
6362
+ return true;
6363
+ return Object.values(game.profiles).some((profile) => (profile.mods ?? []).some(isWorkshopEntry));
6364
+ }
6365
+ function isWorkshopEntry(entry) {
6366
+ if (typeof entry === "string")
6367
+ return entry.startsWith("workshop:");
6368
+ return "workshop" in entry && entry.workshop !== undefined;
6168
6369
  }
6169
6370
  function steamcmdSource(runner, config) {
6170
6371
  if (runner.kind === "docker")
@@ -6178,37 +6379,44 @@ async function doctor(config, plugins) {
6178
6379
  const gameConfig = config.games[game];
6179
6380
  const sources = cachedSources(gameConfig, "modless", {}, config.dataRoot);
6180
6381
  const { plan, problems } = await resolvePlan({ game, profile: "modless", root: config, plugins, sources });
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];
6196
- if (all.length === 0) {
6197
- status(`${game}: ok`);
6198
- continue;
6199
- }
6200
- failed = true;
6201
- status(`${game}: ${all.length} problem(s)`);
6202
- for (const problem of all) {
6203
- process.stderr.write(` ${problem.where}
6382
+ const all = [...problems, ...await preflight(plan), ...steamcmdProblems(game, gameConfig, config)];
6383
+ if (!reportDoctor(game, all))
6384
+ failed = true;
6385
+ }
6386
+ return failed ? Exit.Environment : Exit.Ok;
6387
+ }
6388
+ function steamcmdProblems(game, gameConfig, config) {
6389
+ if (!usesWorkshop(gameConfig))
6390
+ return [];
6391
+ const problems = [];
6392
+ try {
6393
+ status(`${game}: steamcmd ${steamcmdSource(resolveSteamcmd(config), config)}`);
6394
+ } catch (error) {
6395
+ problems.push({
6396
+ where: "steamcmd",
6397
+ message: error instanceof Error ? error.message : String(error),
6398
+ ...error instanceof GamecrateError && error.detail !== undefined ? { suggestion: error.detail } : {}
6399
+ });
6400
+ }
6401
+ const root = downloadRoot(config.dataRoot, gameConfig);
6402
+ status(`${game}: workshop downloads ${root}${existsSync12(root) ? "" : " (not created yet)"}`);
6403
+ return problems;
6404
+ }
6405
+ function reportDoctor(game, all) {
6406
+ if (all.length === 0) {
6407
+ status(`${game}: ok`);
6408
+ return true;
6409
+ }
6410
+ status(`${game}: ${all.length} problem(s)`);
6411
+ for (const problem of all) {
6412
+ process.stderr.write(` ${problem.where}
6204
6413
  ${problem.message}
6205
6414
  `);
6206
- if (problem.suggestion)
6207
- process.stderr.write(` try: ${problem.suggestion}
6415
+ if (problem.suggestion)
6416
+ process.stderr.write(` try: ${problem.suggestion}
6208
6417
  `);
6209
- }
6210
6418
  }
6211
- return failed ? Exit.Environment : Exit.Ok;
6419
+ return false;
6212
6420
  }
6213
6421
  async function logs(args, config, defaults) {
6214
6422
  const game = requireGame(args, config);
@@ -6217,12 +6425,12 @@ async function logs(args, config, defaults) {
6217
6425
  if (args.follow)
6218
6426
  return await follow(dir, false, `${game} ${profile}`);
6219
6427
  const runs = join22(dir, "logs", "runs");
6220
- const latest = (await readdir10(runs, { withFileTypes: true }).catch(() => [])).filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort().at(-1);
6428
+ const latest = (await readdir11(runs, { withFileTypes: true }).catch(() => [])).filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort().at(-1);
6221
6429
  if (latest === undefined) {
6222
6430
  throw new GamecrateError(`no runs recorded for ${game} ${profile}`, Exit.Usage, runs);
6223
6431
  }
6224
6432
  const runDir = join22(runs, latest);
6225
- const files = (await readdir10(runDir, { withFileTypes: true })).filter((entry) => entry.isFile()).map((entry) => entry.name).sort();
6433
+ const files = (await readdir11(runDir, { withFileTypes: true })).filter((entry) => entry.isFile()).map((entry) => entry.name).sort();
6226
6434
  if (args.json) {
6227
6435
  process.stdout.write(`${JSON.stringify({ run: latest, dir: runDir, files }, null, 2)}
6228
6436
  `);
@@ -6277,7 +6485,7 @@ async function verify(args, config, plugins, defaults) {
6277
6485
  reportProblems(problems);
6278
6486
  const name = containerName(plan);
6279
6487
  const info = await inspectContainer(name);
6280
- if (info === null || !info.running) {
6488
+ if (!info?.running) {
6281
6489
  throw new GamecrateError(`no container named ${name} is running`, Exit.Environment, "launch it first, or name the run with --instance or --worktree");
6282
6490
  }
6283
6491
  const prefix = `${plan.gameConfig.modsDir.container}/`;
@@ -6341,8 +6549,7 @@ function renderVerify(name, info, plan, boundMods) {
6341
6549
  const stampWidth = Math.max(...stamps.map((s) => s.length));
6342
6550
  for (const [i, mod] of boundMods.entries()) {
6343
6551
  const origin = mod.branch === null ? "" : `worktree ${mod.branch}`;
6344
- out.push(` ${mod.packageId.padEnd(idWidth)} ${paths[i].padEnd(pathWidth)} ${origin}`.trimEnd());
6345
- out.push(` ${" ".repeat(idWidth)} ${stamps[i].padEnd(stampWidth)} ${boundStatus(mod)}`);
6552
+ out.push(` ${mod.packageId.padEnd(idWidth)} ${paths[i].padEnd(pathWidth)} ${origin}`.trimEnd(), ` ${" ".repeat(idWidth)} ${stamps[i].padEnd(stampWidth)} ${boundStatus(mod)}`);
6346
6553
  }
6347
6554
  return out.join(`
6348
6555
  `);
@@ -6357,18 +6564,24 @@ async function clean(args, config, defaults) {
6357
6564
  const dir = profileDataDir(config, game, profile);
6358
6565
  const tier = args.cleanTier ?? "staging";
6359
6566
  const saveSuffixes = config.games[game].saveExtensions.map((ext) => `.${ext.replace(/^\./, "")}`.toLowerCase());
6567
+ const downloads = downloadRoot(config.dataRoot, config.games[game]);
6568
+ if (tier === "downloads") {
6569
+ status(await removeDownloads(downloads, config.games[game].steamAppId));
6570
+ return Exit.Ok;
6571
+ }
6360
6572
  if (tier !== "all") {
6361
6573
  const target = join22(instanceDir(args, config, game, profile), tier === "logs" ? "logs" : ".stage");
6362
- await rm4(target, { recursive: true, force: true });
6574
+ await rm5(target, { recursive: true, force: true });
6363
6575
  status(`removed ${target}`);
6364
6576
  return Exit.Ok;
6365
6577
  }
6366
6578
  const saves = await countSaves(dir, saveSuffixes);
6367
6579
  if (!args.yes) {
6368
- throw new GamecrateError(`clean --all would delete ${dir}, including ${saves} save file(s)`, Exit.Usage, "add --yes to confirm");
6580
+ 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");
6369
6581
  }
6370
- await rm4(dir, { recursive: true, force: true });
6582
+ await rm5(dir, { recursive: true, force: true });
6371
6583
  status(`removed ${dir} (${saves} save file(s))`);
6584
+ status(await removeDownloads(downloads, config.games[game].steamAppId));
6372
6585
  return Exit.Ok;
6373
6586
  }
6374
6587
  async function countSaves(dir, suffixes) {
@@ -6378,7 +6591,7 @@ async function countSaves(dir, suffixes) {
6378
6591
  const current = queue.shift();
6379
6592
  let entries;
6380
6593
  try {
6381
- entries = await readdir10(current, { withFileTypes: true });
6594
+ entries = await readdir11(current, { withFileTypes: true });
6382
6595
  } catch {
6383
6596
  continue;
6384
6597
  }
@@ -6399,9 +6612,9 @@ async function clone(args, config) {
6399
6612
  }
6400
6613
  const from = join22(profileDataDir(config, game, src), "game");
6401
6614
  const to = join22(profileDataDir(config, game, dst), "game");
6402
- if (!existsSync11(from))
6615
+ if (!existsSync12(from))
6403
6616
  throw new GamecrateError(`${from} does not exist`, Exit.Usage);
6404
- if (existsSync11(to) && !args.yes) {
6617
+ if (existsSync12(to) && !args.yes) {
6405
6618
  throw new GamecrateError(`${to} already exists`, Exit.Usage, "add --yes to overwrite");
6406
6619
  }
6407
6620
  await mkdir6(to, { recursive: true });
@@ -6467,7 +6680,7 @@ async function follow(dir, fromStart, what) {
6467
6680
  const held = lock !== undefined && isRunning(lock.pid, lock.startedAt);
6468
6681
  const live = held && await awaitRunLog(dir, lock);
6469
6682
  const file = currentLog(dir);
6470
- if (!existsSync11(file)) {
6683
+ if (!existsSync12(file)) {
6471
6684
  throw new GamecrateError(`no captured output for ${what}`, Exit.Usage, file);
6472
6685
  }
6473
6686
  return await spawnStatus(tailArgv(file, fromStart, live ? lock.pid : undefined), true);
@@ -6501,8 +6714,8 @@ async function configEdit(args) {
6501
6714
  throw new GamecrateError("config takes one word: edit", Exit.Usage);
6502
6715
  const existing = await findGlobalConfig();
6503
6716
  const path = existing ?? join22(globalConfigDir(), "profiles.yml");
6504
- await mkdir6(dirname9(path), { recursive: true });
6505
- if (!existsSync11(path)) {
6717
+ await mkdir6(dirname10(path), { recursive: true });
6718
+ if (!existsSync12(path)) {
6506
6719
  await writeFile7(path, `# gamecrate config. see https://github.com/RimWorks/gamecrate
6507
6720
  ` + `plugins: []
6508
6721
  ` + `games: {}
@@ -6517,12 +6730,30 @@ async function configEdit(args) {
6517
6730
  status(`${path} is valid`);
6518
6731
  return Exit.Ok;
6519
6732
  }
6733
+ async function repairOwnership(found, identity) {
6734
+ let fixed = 0;
6735
+ const stuck = [];
6736
+ for (const path of found) {
6737
+ const chowned = await chown(path, identity.uid, identity.gid).then(() => true, () => false);
6738
+ if (chowned) {
6739
+ fixed += 1;
6740
+ continue;
6741
+ }
6742
+ if (await removeIfEmptyDir(path)) {
6743
+ status(`removed empty ${path}; it will be recreated on the next run`);
6744
+ fixed += 1;
6745
+ continue;
6746
+ }
6747
+ stuck.push(path);
6748
+ }
6749
+ return { fixed, stuck };
6750
+ }
6520
6751
  async function fixPerms(args, config) {
6521
6752
  const game = requireGame(args, config);
6522
6753
  const identity = resolveIdentity(false);
6523
6754
  const found = [];
6524
6755
  for (const dir of await profileDirs(config, game, args.profile)) {
6525
- if (!existsSync11(dir))
6756
+ if (!existsSync12(dir))
6526
6757
  continue;
6527
6758
  found.push(...await detectForeignOwnership(dir, identity.uid, 1e4));
6528
6759
  }
@@ -6537,21 +6768,7 @@ async function fixPerms(args, config) {
6537
6768
  status(`${found.length} foreign-owned path(s); re-run with --yes to chown them`);
6538
6769
  return Exit.Environment;
6539
6770
  }
6540
- let fixed = 0;
6541
- const stuck = [];
6542
- for (const path of found) {
6543
- const chowned = await chown(path, identity.uid, identity.gid).then(() => true, () => false);
6544
- if (chowned) {
6545
- fixed += 1;
6546
- continue;
6547
- }
6548
- if (await removeIfEmptyDir(path)) {
6549
- status(`removed empty ${path}; it will be recreated on the next run`);
6550
- fixed += 1;
6551
- continue;
6552
- }
6553
- stuck.push(path);
6554
- }
6771
+ const { fixed, stuck } = await repairOwnership(found, identity);
6555
6772
  if (fixed > 0)
6556
6773
  status(`fixed ${fixed} path(s) for ${identity.uid}:${identity.gid}`);
6557
6774
  if (stuck.length > 0) {
@@ -6565,7 +6782,7 @@ async function removeIfEmptyDir(path) {
6565
6782
  const info = await stat6(path).catch(() => null);
6566
6783
  if (info === null || !info.isDirectory())
6567
6784
  return false;
6568
- const entries = await readdir10(path).catch(() => null);
6785
+ const entries = await readdir11(path).catch(() => null);
6569
6786
  if (entries === null || entries.length > 0)
6570
6787
  return false;
6571
6788
  return rmdir(path).then(() => true, () => false);