@gamecrate/cli 1.4.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
@@ -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" || long === "--downloads") {
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;
526
- continue;
527
- }
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;
534
+ build = envBuildPolicy(name, raw);
533
535
  continue;
534
536
  }
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,6 +789,51 @@ 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
839
  import { existsSync as existsSync6 } from "node:fs";
@@ -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)
@@ -3229,7 +3313,7 @@ function workshopUrlId(url) {
3229
3313
  const id = parsed.searchParams.get("id");
3230
3314
  return id !== null && /^\d+$/.test(id) ? id : undefined;
3231
3315
  }
3232
- var ANSI = new RegExp(`${String.fromCharCode(27)}\\[[0-9;?]*[ -/]*[@-~]`, "g");
3316
+ var ANSI = new RegExp(String.raw`${String.fromCodePoint(27)}\[[0-9;?]*[ -/]*[@-~]`, "g");
3233
3317
  var SUCCESS = /Success\. Downloaded item (\d+) to "([^"]+)" \((\d+) bytes\)/g;
3234
3318
  var FAILED = /ERROR! Download item (\d+) failed \(([^)]+)\)/g;
3235
3319
  var ATTEMPTS = 2;
@@ -3509,7 +3593,7 @@ function readDetails(payload) {
3509
3593
  const id = entry["publishedfileid"];
3510
3594
  const result = entry["result"];
3511
3595
  if (typeof id !== "string" || typeof result !== "number") {
3512
- throw new Error("steam returned a body this does not understand");
3596
+ throw new TypeError("steam returned a body this does not understand");
3513
3597
  }
3514
3598
  out.set(id, { result, timeUpdated: Number(entry["time_updated"]) || 0 });
3515
3599
  }
@@ -3561,14 +3645,16 @@ async function modsAdd(args, ctx) {
3561
3645
  const twice = [...where.values()].filter((at) => at.length > 1);
3562
3646
  if (twice.length > 0) {
3563
3647
  const lines = [...where.entries()].filter(([, at]) => at.length > 1);
3564
- 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(`
3565
- `)}
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}
3566
3651
  pin one of them with --subdir`);
3567
3652
  }
3568
3653
  const clashes = pins.filter((pin) => existingKey(target.existing, pin.id) !== undefined);
3569
3654
  if (clashes.length > 0 && args.force !== true) {
3570
- throw new GamecrateError(`${clashes.length} mod id(s) are already pinned in ${target.file}`, Exit.Config, `${clashes.map((pin) => ` ${pin.id}`).join(`
3571
- `)}
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}
3572
3658
  overwrite them with --force`);
3573
3659
  }
3574
3660
  const edits = [];
@@ -3607,21 +3693,7 @@ async function modsRm(args, ctx) {
3607
3693
  async function modsSync(args, ctx) {
3608
3694
  const only = args.rest.map((id) => id.toLowerCase());
3609
3695
  const games = args.game === undefined ? Object.keys(ctx.config.games) : [requireGame(args, ctx.config)];
3610
- const wanted = [];
3611
- const subscribed = [];
3612
- for (const name of games) {
3613
- const pins = [];
3614
- for (const [id, entry] of Object.entries(ctx.config.games[name]?.library ?? {})) {
3615
- if (only.length > 0 && !only.includes(id.toLowerCase()))
3616
- continue;
3617
- if (entry.git !== undefined)
3618
- wanted.push({ id, git: entry.git, entry });
3619
- else if (entry.workshop !== undefined)
3620
- pins.push({ id, item: String(entry.workshop) });
3621
- }
3622
- if (pins.length > 0)
3623
- subscribed.push({ game: name, pins });
3624
- }
3696
+ const { wanted, subscribed } = collectPins(ctx.config, games, only);
3625
3697
  const named = [...wanted.map((pin) => pin.id), ...subscribed.flatMap((one) => one.pins.map((pin) => pin.id))];
3626
3698
  const missing = only.filter((id) => !named.some((pinned) => pinned.toLowerCase() === id));
3627
3699
  if (missing.length > 0) {
@@ -3632,6 +3704,30 @@ async function modsSync(args, ctx) {
3632
3704
  status("nothing to sync");
3633
3705
  return Exit.Ok;
3634
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) {
3713
+ const wanted = [];
3714
+ const subscribed = [];
3715
+ for (const name of games) {
3716
+ const pins = [];
3717
+ for (const [id, entry] of Object.entries(config.games[name]?.library ?? {})) {
3718
+ if (only.length > 0 && !only.includes(id.toLowerCase()))
3719
+ continue;
3720
+ if (entry.git !== undefined)
3721
+ wanted.push({ id, git: entry.git, entry });
3722
+ else if (entry.workshop !== undefined)
3723
+ pins.push({ id, item: String(entry.workshop) });
3724
+ }
3725
+ if (pins.length > 0)
3726
+ subscribed.push({ game: name, pins });
3727
+ }
3728
+ return { wanted, subscribed };
3729
+ }
3730
+ async function syncGit(ctx, wanted) {
3635
3731
  const branches = new Map;
3636
3732
  const fetched = new Set;
3637
3733
  for (const pin of wanted) {
@@ -3644,20 +3740,20 @@ async function modsSync(args, ctx) {
3644
3740
  const dir = cloneDir(ctx.config.dataRoot, pin.git, ref);
3645
3741
  if (!fetched.has(dir)) {
3646
3742
  fetched.add(dir);
3647
- const unlock = await lockDir(dir);
3648
- try {
3649
- const result = await ensureClone(ctx.config.dataRoot, gitPin(pin.git, pin.entry), ref, "force");
3650
- if (result.warning !== undefined)
3651
- warn(result.warning);
3652
- } finally {
3653
- await unlock();
3654
- }
3743
+ await fetchClone(ctx, pin, ref, dir);
3655
3744
  }
3656
3745
  status(`synced ${pin.id} at ${ref.kind} ${ref.value}`);
3657
3746
  }
3658
- for (const one of subscribed)
3659
- await syncWorkshop(ctx, one.game, one.pins);
3660
- 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
+ }
3661
3757
  }
3662
3758
  async function syncWorkshop(ctx, name, pins) {
3663
3759
  const game = ctx.config.games[name];
@@ -3778,7 +3874,7 @@ async function discover(source, game, plugin, ctx) {
3778
3874
  await unlock();
3779
3875
  }
3780
3876
  const start = source.subdir === undefined ? root : join12(root, source.subdir);
3781
- const found = await walk(start, game.manifest.file, plugin);
3877
+ const found = await walk2(start, game.manifest.file, plugin);
3782
3878
  return found.map(({ id, dir: at }) => {
3783
3879
  const entry = { git: source.url };
3784
3880
  const pinned = source.ref;
@@ -3794,7 +3890,7 @@ async function discover(source, game, plugin, ctx) {
3794
3890
  return { id, entry };
3795
3891
  });
3796
3892
  }
3797
- async function walk(root, manifestFile, plugin) {
3893
+ async function walk2(root, manifestFile, plugin) {
3798
3894
  const out = [];
3799
3895
  const descend = async (dir) => {
3800
3896
  const id = await readId(dir, manifestFile, plugin);
@@ -3858,14 +3954,10 @@ function topLevel(config) {
3858
3954
  "subcommands:"
3859
3955
  ];
3860
3956
  const verbs = SUBCOMMANDS.map((s) => [`${s.name} ${s.usage}`.trim(), s.summary]);
3861
- lines.push(...columns(verbs, 2));
3862
- lines.push("", "flags:");
3863
- lines.push(...columns(flags().map(flagRow), 2));
3957
+ lines.push(...columns(verbs, 2), "", "flags:", ...columns(flags().map(flagRow), 2));
3864
3958
  const games = Object.entries(config?.games ?? {});
3865
3959
  if (games.length > 0) {
3866
- lines.push("", "games:");
3867
- lines.push(...columns(games.map(([name, game]) => [name, gameSummary(game)]), 2));
3868
- 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.`);
3869
3961
  }
3870
3962
  lines.push("", "Game args go after a bare --. Env vars are GAMECRATE_ prefixed fallbacks only.");
3871
3963
  return lines.join(`
@@ -3878,8 +3970,7 @@ function subcommandHelp(sub) {
3878
3970
  const all = flags();
3879
3971
  const specs = names.map((name) => all.find((f) => f.long === name)).filter((f) => f !== undefined);
3880
3972
  if (specs.length > 0) {
3881
- lines.push("", "flags:");
3882
- lines.push(...columns(specs.map(flagRow), 2));
3973
+ lines.push("", "flags:", ...columns(specs.map(flagRow), 2));
3883
3974
  }
3884
3975
  if (sub.name === "run") {
3885
3976
  lines.push("", ` The subcommand slot defaults to run, so \`${NAME} <game> <profile>\` works.`);
@@ -3890,31 +3981,14 @@ function subcommandHelp(sub) {
3890
3981
  }
3891
3982
  function gameHelp(name, game) {
3892
3983
  const lines = [`usage: ${NAME} ${name} [profile] [flags] [-- game args]`, "", "profiles:"];
3893
- const rows = [];
3894
- for (const [profile, config] of Object.entries(game.profiles)) {
3895
- const notes = [];
3896
- if (config.alias)
3897
- notes.push(`alias for ${config.alias}`);
3898
- if (config.extends)
3899
- notes.push(`extends ${config.extends}`);
3900
- if (config.autoDependencies === false)
3901
- notes.push("no auto dependencies");
3902
- const count = config.mods?.length ?? 0;
3903
- if (!config.alias)
3904
- notes.push(count === 1 ? "1 entry" : `${count} entries`);
3905
- if (config.aliases?.length)
3906
- notes.push(`aka ${config.aliases.join(", ")}`);
3907
- rows.push([profile, notes.join(", ")]);
3908
- }
3984
+ const rows = Object.entries(game.profiles).map(([profile, config]) => [profile, profileNotes2(config)]);
3909
3985
  if (rows.length === 0)
3910
3986
  rows.push(["(none declared)", ""]);
3911
3987
  lines.push(...columns(rows, 2));
3912
3988
  if (game.aliases && Object.keys(game.aliases).length > 0) {
3913
- lines.push("", "mod name aliases:");
3914
- 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));
3915
3990
  }
3916
- lines.push("", `modes: ${game.modes.join(", ")}`);
3917
- lines.push(`core: ${game.core}`);
3991
+ lines.push("", `modes: ${game.modes.join(", ")}`, `core: ${game.core}`);
3918
3992
  if (game.dlc.length > 0)
3919
3993
  lines.push(`dlc: ${game.dlc.join(", ")}`);
3920
3994
  lines.push(`game files: ${game.gameFiles.source === "mount" ? game.gameFiles.host ?? "(unset)" : game.image.ref}`);
@@ -3922,9 +3996,25 @@ function gameHelp(name, game) {
3922
3996
  `) + `
3923
3997
  `;
3924
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
+ }
3925
4014
  function gameSummary(game) {
3926
4015
  const count = Object.keys(game.profiles).length;
3927
- 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(", ")}`;
3928
4018
  }
3929
4019
  function flagRow(spec) {
3930
4020
  const notes = [];
@@ -3949,7 +4039,7 @@ function renderCompletion(shell) {
3949
4039
  const options = flags();
3950
4040
  const names = options.flatMap((f) => f.short ? [f.long, f.short] : [f.long]).join(" ");
3951
4041
  const valueFlags = options.filter((f) => f.required).map((f) => f.long);
3952
- const fn = `_${NAME.replace(/-/g, "_")}`;
4042
+ const fn = `_${NAME.replaceAll("-", "_")}`;
3953
4043
  if (shell === "bash") {
3954
4044
  const cases = options.filter((f) => f.argChoices).map((f) => ` ${f.long}) COMPREPLY=($(compgen -W "${f.argChoices.join(" ")}" -- "$cur")); return ;;`).join(`
3955
4045
  `);
@@ -3974,12 +4064,13 @@ ${cases}
3974
4064
  complete -F ${fn} ${NAME}
3975
4065
  `;
3976
4066
  }
3977
- 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(`
3978
4068
  `);
3979
4069
  const zshFlags = options.map((f) => {
3980
- const desc = f.description.replace(/'/g, "'\\''").replace(/[[\]:]/g, "");
4070
+ const desc = f.description.replaceAll("'", String.raw`'\''`).replaceAll(/[[\]:]/g, "");
3981
4071
  const arg = placeholder(f);
3982
- 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}` : "";
3983
4074
  const repeat = Array.isArray(f.defaultValue) ? "*" : "";
3984
4075
  return ` '${repeat}${f.long}[${desc}]${value}'`;
3985
4076
  }).join(`
@@ -4051,7 +4142,7 @@ async function checkDocker(problems) {
4051
4142
  return true;
4052
4143
  problems.push({
4053
4144
  where: "docker",
4054
- message: `docker is not reachable: ${firstLine(result.stderr) || `exit ${result.code}`}`,
4145
+ message: `docker is not reachable: ${firstLine(result.stderr) || exitCode(result.code)}`,
4055
4146
  suggestion: "start the docker daemon, or check that your user is in the docker group"
4056
4147
  });
4057
4148
  return false;
@@ -4064,7 +4155,7 @@ async function checkImageRunnable(ref, where, game, problems) {
4064
4155
  const corrupt = /content store|failed to extract layer|not found/i.test(run.stderr);
4065
4156
  problems.push({
4066
4157
  where,
4067
- 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)}`,
4068
4159
  suggestion: corrupt ? `docker image rm ${ref} && docker builder prune -f, then gamecrate build ${game}` : undefined
4069
4160
  });
4070
4161
  }
@@ -4098,7 +4189,7 @@ async function checkImage(plan, problems) {
4098
4189
  }
4099
4190
  problems.push({
4100
4191
  where,
4101
- 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)}`,
4102
4193
  suggestion: host ? `docker login ${host}` : undefined
4103
4194
  });
4104
4195
  }
@@ -4137,7 +4228,7 @@ function checkCdi(problems) {
4137
4228
  problems.push({ where: CDI_SPEC, message: `CDI spec is unreadable: ${message(error)}` });
4138
4229
  return;
4139
4230
  }
4140
- if (!/^\s*-?\s*name:\s*["']?all["']?\s*$/m.test(text)) {
4231
+ if (!/^[ \t]*(?:-[ \t]*)?name:[ \t]*["']?all["']?[ \t]*$/m.test(text)) {
4141
4232
  problems.push({
4142
4233
  where: CDI_SPEC,
4143
4234
  message: 'CDI spec does not declare a device named "all"',
@@ -4214,6 +4305,9 @@ function hasStoredAuth(host) {
4214
4305
  return false;
4215
4306
  }
4216
4307
  }
4308
+ function exitCode(code) {
4309
+ return `exit ${code}`;
4310
+ }
4217
4311
  function firstLine(text) {
4218
4312
  return text.trim().split(`
4219
4313
  `)[0]?.trim() ?? "";
@@ -4273,27 +4367,37 @@ async function adoptNewWindow(opts) {
4273
4367
  }
4274
4368
  const seen = new Set(before.map((w) => w.id));
4275
4369
  let stopped = false;
4276
- (async () => {
4277
- const deadline = Date.now() + WAIT_MS;
4278
- while (!stopped && Date.now() < deadline) {
4279
- await sleep5(POLL_MS);
4280
- if (stopped)
4281
- return;
4282
- for (const match of newMatches(await toplevels() ?? [], seen, opts.executable)) {
4283
- if (stopped)
4284
- return;
4285
- if (await claimedByPeer(match.id))
4286
- continue;
4287
- if (!await adopt(match.id, opts))
4288
- continue;
4289
- await capture(["wmctrl", "-i", "-r", match.id, "-N", opts.title]);
4290
- if (opts.stripDelete)
4291
- await watchForClose(match.id, () => stopped, opts.onClosed);
4292
- return;
4293
- }
4370
+ pollForWindow(seen, opts, () => stopped);
4371
+ return {
4372
+ stop: () => {
4373
+ stopped = true;
4294
4374
  }
4295
- })();
4296
- 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;
4297
4401
  }
4298
4402
  async function watchForClose(id, stopped, onClosed) {
4299
4403
  let misses = 0;
@@ -4352,7 +4456,7 @@ async function dropDeleteProtocol(id, atoms) {
4352
4456
  }
4353
4457
  function parseAtoms(stdout) {
4354
4458
  const list = stdout.slice(stdout.indexOf(":") + 1).replace(/^\s*protocols\s*/, "");
4355
- 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));
4356
4460
  }
4357
4461
 
4358
4462
  // src/launch/generate.ts
@@ -4718,7 +4822,9 @@ function derive(requests) {
4718
4822
  return `${slug2(first.root)}-${digest.slice(0, 6)}`;
4719
4823
  }
4720
4824
  function slug2(root) {
4721
- 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);
4722
4828
  return body === "" ? "wt" : body;
4723
4829
  }
4724
4830
 
@@ -4736,7 +4842,7 @@ function cacheDir() {
4736
4842
  return join17(process.env["XDG_CACHE_HOME"] ?? join17(homedir4(), ".cache"), "gamecrate");
4737
4843
  }
4738
4844
  function globMatch(pattern, path) {
4739
- return picomatch.isMatch(path, pattern.replace(/[[\]{}()!,@+|^$.\\]/g, "\\$&"), { dot: true });
4845
+ return picomatch.isMatch(path, pattern.replaceAll(/[[\]{}()!,@+|^$.\\]/g, String.raw`\$&`), { dot: true });
4740
4846
  }
4741
4847
  function excluded(patterns, relativePath) {
4742
4848
  return patterns.some((p) => globMatch(p, relativePath) || globMatch(p, `${relativePath}/`));
@@ -4922,7 +5028,7 @@ function compareTiers(a, b) {
4922
5028
  return 0;
4923
5029
  }
4924
5030
  function rank(a, b) {
4925
- 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);
4926
5032
  }
4927
5033
  function byClone(a, b) {
4928
5034
  if (a.clonedAt === undefined || b.clonedAt === undefined)
@@ -4936,13 +5042,7 @@ async function applyWorktreeRequests(index, requests, config) {
4936
5042
  for (const bucket of index.byPackageId.values()) {
4937
5043
  for (const record of bucket) {
4938
5044
  known.add(record.dir);
4939
- for (const request of requests) {
4940
- if (!contains(request, record.dir))
4941
- continue;
4942
- record.worktree = { root: request.root, branch: request.branch, source: request.source };
4943
- record.selectedWorktree = request.order;
4944
- break;
4945
- }
5045
+ stampOwner(record, requests);
4946
5046
  }
4947
5047
  }
4948
5048
  for (const request of requests) {
@@ -4952,14 +5052,25 @@ async function applyWorktreeRequests(index, requests, config) {
4952
5052
  if (fresh.length === 0)
4953
5053
  continue;
4954
5054
  for (const record of await parseAll(fresh, config, index.plugin, index.problems)) {
4955
- record.worktree = { root: request.root, branch: request.branch, source: request.source };
4956
- record.selectedWorktree = request.order;
5055
+ markWorktree(record, request);
4957
5056
  insert(index, record);
4958
5057
  }
4959
5058
  }
4960
5059
  for (const bucket of index.byPackageId.values())
4961
5060
  bucket.sort(rank);
4962
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
+ }
4963
5074
  var WORKTREE_SCAN_DEPTH = 5;
4964
5075
  var WORKTREE_EXCLUDE = ["**/.retired/**", "**/node_modules/**", "**/bin/**", "**/obj/**"];
4965
5076
  async function applySourceOverrides(index, overrides, config) {
@@ -5045,51 +5156,58 @@ async function buildIndex(game, config, plugin, sourcesDir, dataRoot) {
5045
5156
  byShortName: new Map,
5046
5157
  problems: []
5047
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) {
5048
5167
  const local = [];
5049
5168
  await scanGameData(config, -1, local);
5050
5169
  for (const [i, root] of config.scanRoots.entries()) {
5051
5170
  await scanLocalRoot(root, i, config.manifest.file, local);
5052
5171
  }
5053
- const cacheIndex = config.scanRoots.length;
5054
- const parsed = await parseAll(local, config, plugin, index.problems);
5172
+ const parsed = await parseAll(local, config, index.plugin, index.problems);
5055
5173
  if (sourcesDir !== undefined) {
5056
5174
  const cache = [];
5057
5175
  await scanLocalRoot({ path: sourcesDir, maxDepth: 4 }, cacheIndex, config.manifest.file, cache);
5058
- 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));
5059
5178
  }
5060
5179
  for (const record of parsed)
5061
5180
  insert(index, record);
5062
- const workshopRoots = [];
5181
+ }
5182
+ async function indexWorkshop(index, config, dataRoot, cacheIndex) {
5183
+ const roots = [];
5063
5184
  if (dataRoot !== undefined)
5064
- workshopRoots.push(downloadRoot(dataRoot, config));
5185
+ roots.push(downloadRoot(dataRoot, config));
5065
5186
  if (config.workshopRoot !== null)
5066
- workshopRoots.push(config.workshopRoot);
5067
- if (workshopRoots.length > 0) {
5068
- const stamp = workshopStamp(config, dataRoot);
5069
- const cached = stamp === null ? null : await readWorkshopCache(game, stamp);
5070
- if (cached) {
5071
- for (const record of cached)
5072
- insert(index, record);
5073
- } else {
5074
- const items = [];
5075
- for (const [i, root] of workshopRoots.entries()) {
5076
- await scanWorkshopRoot(root, cacheIndex + 1 + i, config.manifest.file, items);
5077
- }
5078
- const records = await parseAll(items, config, plugin, index.problems);
5079
- for (const record of records)
5080
- insert(index, record);
5081
- if (stamp !== null)
5082
- await writeWorkshopCache(game, stamp, records);
5083
- }
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;
5084
5196
  }
5085
- for (const bucket of index.byPackageId.values())
5086
- bucket.sort(rank);
5087
- 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);
5088
5206
  }
5089
5207
  function oneModPerClone(records, sourcesDir) {
5090
5208
  const kept = new Map;
5091
5209
  const stamps = new Map;
5092
- 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))) {
5093
5211
  const clone = relative3(sourcesDir, record.dir).split(sep2).slice(0, 2).join(sep2);
5094
5212
  const at = join17(sourcesDir, clone);
5095
5213
  let stamp = stamps.get(at);
@@ -5144,9 +5262,9 @@ function resolveRaw(index, ref, game) {
5144
5262
  return resolveRaw(index, alias, game);
5145
5263
  }
5146
5264
  const short = index.byShortName.get(ref.toLowerCase());
5147
- if (short && short.length === 1)
5265
+ if (short?.length === 1)
5148
5266
  return pick(index, short[0].toLowerCase(), ref);
5149
- if (short && short.length > 1) {
5267
+ if (short !== undefined && short.length > 1) {
5150
5268
  throw new GamecrateError(`"${ref}" is a short name for ${short.length} mods`, Exit.Resolution, short.join(", "));
5151
5269
  }
5152
5270
  return null;
@@ -5240,7 +5358,7 @@ function expandDynamic(entry, index, where, problems) {
5240
5358
  const head = matched.filter((id) => firstOrder.includes(id.toLowerCase())).sort((a, b) => firstOrder.indexOf(a.toLowerCase()) - firstOrder.indexOf(b.toLowerCase()));
5241
5359
  const rest = matched.filter((id) => !firstOrder.includes(id.toLowerCase()));
5242
5360
  if ((entry.sort ?? "alpha") === "alpha") {
5243
- 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()));
5244
5362
  }
5245
5363
  return [...head, ...rest];
5246
5364
  }
@@ -5297,6 +5415,42 @@ function topoSort(list, problems, core) {
5297
5415
  const position = new Map;
5298
5416
  for (const [i, mod] of list.entries())
5299
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) {
5300
5454
  const edges = new Set;
5301
5455
  const addEdge = (from, to) => {
5302
5456
  if (from === undefined || to === undefined || from === to)
@@ -5319,46 +5473,22 @@ function topoSort(list, problems, core) {
5319
5473
  incoming[to].push(from);
5320
5474
  indegree[to] += 1;
5321
5475
  }
5476
+ return { indegree, outgoing, incoming };
5477
+ }
5478
+ function reachesCore(incoming, coreIndex) {
5322
5479
  const preCore = new Set;
5323
- const coreIndex = core === undefined ? undefined : position.get(core.toLowerCase());
5324
- if (coreIndex !== undefined) {
5325
- const queue = [coreIndex];
5326
- while (queue.length > 0) {
5327
- for (const from of incoming[queue.pop()]) {
5328
- if (preCore.has(from))
5329
- continue;
5330
- preCore.add(from);
5331
- queue.push(from);
5332
- }
5333
- }
5334
- }
5335
- const phase = (i) => preCore.has(i) ? 0 : 1;
5336
- const sorted = [];
5337
- const done = list.map(() => false);
5338
- for (;; ) {
5339
- let next = -1;
5340
- for (let i = 0;i < list.length; i++) {
5341
- 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))
5342
5486
  continue;
5343
- if (next === -1 || phase(i) < phase(next))
5344
- next = i;
5487
+ preCore.add(from);
5488
+ queue.push(from);
5345
5489
  }
5346
- if (next === -1)
5347
- break;
5348
- done[next] = true;
5349
- sorted.push(list[next]);
5350
- for (const to of outgoing[next])
5351
- indegree[to] -= 1;
5352
- }
5353
- const cycle = list.filter((_, i) => !done[i]);
5354
- if (cycle.length > 0) {
5355
- problems.push({
5356
- where: "flag --sort topo",
5357
- message: `load-order cycle among ${cycle.map((m) => m.record.packageId).join(", ")}; left in profile order`
5358
- });
5359
- sorted.push(...cycle);
5360
5490
  }
5361
- return sorted;
5491
+ return preCore;
5362
5492
  }
5363
5493
  function incompatibilityWarnings(list) {
5364
5494
  const byId = new Map(list.map((mod) => [mod.record.packageId.toLowerCase(), mod.record.packageId]));
@@ -5378,6 +5508,96 @@ function incompatibilityWarnings(list) {
5378
5508
  }
5379
5509
  return warnings;
5380
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
+ }
5381
5601
  async function resolvePlan(options) {
5382
5602
  const { game: gameName, profile: requestedProfile, root } = options;
5383
5603
  const args = options.args ?? {};
@@ -5413,78 +5633,28 @@ async function resolvePlan(options) {
5413
5633
  const index = options.index ?? await buildIndex(gameName, game, plugin, sourcesRoot(root.dataRoot), root.dataRoot);
5414
5634
  await applyWorktreeRequests(index, instance.requests, game);
5415
5635
  problems.push(...await applySourceOverrides(index, args.use ?? [], game));
5416
- const excluded = [...profile.exclude ?? [], ...args.without ?? []].map(globToRegExp);
5417
- const isExcluded = (id) => excluded.some((pattern) => pattern.test(id));
5418
- const staged = [];
5419
- const present = new Set;
5420
- for (const slot of collectSlots(game, gameName, profileName, profile, args)) {
5421
- const entry = slot.entry;
5422
- 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 }];
5423
- for (const { ref, optional } of refs) {
5424
- const record = resolveModRef(index, ref, game);
5425
- if (!record) {
5426
- if (slot.dlc === true)
5427
- continue;
5428
- if (optional)
5429
- warnings.push(`optional mod ${ref} is not installed; skipped`);
5430
- else if (ref.startsWith("workshop:") && unfetched.has(ref.slice(9))) {
5431
- problems.push({ where: slot.where, message: notFetched(ref.slice(9)) });
5432
- } else
5433
- problems.push({ where: slot.where, message: `no mod matches "${ref}"` });
5434
- continue;
5435
- }
5436
- const key = record.packageId.toLowerCase();
5437
- if (present.has(key) || isExcluded(record.packageId))
5438
- continue;
5439
- present.add(key);
5440
- staged.push({ record, explicit: true });
5441
- }
5442
- }
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
+ });
5443
5648
  if (profile.autoDependencies !== false)
5444
5649
  insertDependencies(staged, present, index, game, problems);
5445
- if (unfetched.size > 0) {
5446
- for (const problem of problems) {
5447
- const id = workshopUrlId(problem.suggestion);
5448
- if (id !== undefined && unfetched.has(id))
5449
- problem.message = notFetched(id);
5450
- }
5451
- }
5650
+ relabelUnfetched(problems, unfetched);
5452
5651
  const ordered = args.sort === "none" ? staged : topoSort(staged, problems, game.core);
5453
5652
  warnings.push(...incompatibilityWarnings(ordered));
5454
5653
  const mods = [];
5455
- for (const { record, explicit } of ordered) {
5456
- const times = record.kind === "local" ? await scanBuildTimes(record.dir) : null;
5457
- const report = times === null ? null : staleReport(times);
5458
- if (record.worktree) {
5459
- warnings.push(`${record.packageId} comes from worktree ${record.worktree.branch} (${record.worktree.source}): ${record.dir}`);
5460
- }
5461
- const shadowed = (index.byPackageId.get(record.packageId.toLowerCase()) ?? []).filter((other) => other.dir !== record.dir).map((other) => other.dir);
5462
- mods.push({
5463
- packageId: record.packageId,
5464
- hostDir: record.dir,
5465
- containerDir: `${game.modsDir.container}/${record.packageId}`,
5466
- kind: record.kind,
5467
- ...record.workshopId === undefined ? {} : { workshopId: record.workshopId },
5468
- explicit,
5469
- stale: times === null ? false : decideStale(times),
5470
- ...report === null ? {} : { staleReport: report },
5471
- ...record.worktree === undefined ? {} : { worktree: { ...record.worktree, selected: record.selectedWorktree !== undefined } },
5472
- ...shadowed.length === 0 ? {} : { shadowed }
5473
- });
5474
- }
5654
+ for (const entry of ordered)
5655
+ mods.push(await describeMod(entry, game, index, warnings));
5475
5656
  problems.push(...index.problems);
5476
- if (game.dataDir.mode === "arg" && game.dataDir.arg.split("=").length !== 2) {
5477
- problems.push({
5478
- where: `/games/${gameName}/dataDir/arg`,
5479
- message: `"${game.dataDir.arg}" must contain exactly one "="; RimWorld silently ignores anything else`
5480
- });
5481
- }
5482
- if (game.dataDir.container.includes("=")) {
5483
- problems.push({
5484
- where: `/games/${gameName}/dataDir/container`,
5485
- message: `container data path "${game.dataDir.container}" contains "=", which disables the override silently`
5486
- });
5487
- }
5657
+ checkDataDir(problems, gameName, game);
5488
5658
  const mode = args.mode ?? "headed";
5489
5659
  if (!game.modes.includes(mode)) {
5490
5660
  problems.push({ where: "flag --mode", message: `${gameName} does not support mode "${mode}"` });
@@ -5582,29 +5752,34 @@ async function listRuns(dataRoot, docker = dockerPs) {
5582
5752
  const out = [...running];
5583
5753
  for (const lock of await walkLocks(dataRoot)) {
5584
5754
  const match = byContainer.get(lock.container);
5585
- if (match !== undefined) {
5586
- const stale = match.pid !== undefined && !isRunning(match.pid, match.startedAt);
5587
- if (match.pid === undefined || stale && isRunning(lock.pid, lock.startedAt)) {
5588
- match.pid = lock.pid;
5589
- match.startedAt = lock.startedAt;
5590
- if (lock.mode !== undefined)
5591
- match.mode = lock.mode;
5592
- }
5593
- continue;
5594
- }
5595
- out.push({
5596
- game: lock.game,
5597
- profile: lock.profile,
5598
- ...lock.instance === undefined ? {} : { instance: lock.instance },
5599
- container: lock.container,
5600
- pid: lock.pid,
5601
- ...lock.mode === undefined ? {} : { mode: lock.mode },
5602
- startedAt: lock.startedAt,
5603
- status: isRunning(lock.pid, lock.startedAt) ? "starting" : "orphaned"
5604
- });
5755
+ if (match === undefined)
5756
+ out.push(fromLock(lock));
5757
+ else
5758
+ mergeLock(match, lock);
5605
5759
  }
5606
5760
  return out;
5607
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
+ }
5608
5783
 
5609
5784
  // src/launch/stage.ts
5610
5785
  import { lstat, mkdir as mkdir5, readdir as readdir10, realpath as realpath2, rm as rm4, stat as stat5 } from "node:fs/promises";
@@ -5817,7 +5992,7 @@ function published2(value) {
5817
5992
  }
5818
5993
 
5819
5994
  // src/index.ts
5820
- var VERSION = "1.4.0";
5995
+ var VERSION = "1.4.1";
5821
5996
  async function main(argv) {
5822
5997
  const supervised = supervisedDir(argv);
5823
5998
  try {
@@ -5949,7 +6124,7 @@ async function run2(argv, args, config, plugins, defaults, asShell) {
5949
6124
  const sources = await prepareSources(gameConfig, profile, args, config.dataRoot, allowFetch);
5950
6125
  try {
5951
6126
  const workshop = await prepareWorkshop(gameConfig, profile, args, config, allowFetch, requirePlugin(plugins, game), sources.dirs);
5952
- return await resolved(argv, args, config, plugins, asShell, game, profile, sources, workshop, allowFetch);
6127
+ return await resolved({ argv, args, config, plugins, asShell, game, profile, sources, workshop, allowFetch });
5953
6128
  } finally {
5954
6129
  await sources.release();
5955
6130
  }
@@ -5965,7 +6140,8 @@ function warnUnfetched(problems, unfetched) {
5965
6140
  }
5966
6141
  return fatal;
5967
6142
  }
5968
- 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;
5969
6145
  for (const warning of [...sources.warnings, ...workshop.warnings])
5970
6146
  warn(warning);
5971
6147
  const index = await buildIndex(game, config.games[game], requirePlugin(plugins, game), sourcesRoot(config.dataRoot), config.dataRoot);
@@ -5974,21 +6150,8 @@ async function resolved(argv, args, config, plugins, asShell, game, profile, sou
5974
6150
  if (fatal.length > 0)
5975
6151
  reportProblems(fatal);
5976
6152
  const identity = resolveIdentity(args.root);
5977
- if (args.printPlan || args.dryRun) {
5978
- const environment = await preflight(plan);
5979
- buildRunSpec(plan, [], identity);
5980
- if (args.printPlan)
5981
- printPlan(plan, args.json);
5982
- for (const warning of planWarnings(plan))
5983
- warn(warning);
5984
- if (environment.length > 0)
5985
- reportEnvironment(environment);
5986
- if (!args.printPlan) {
5987
- const what = plan.instance === undefined ? profile : `${profile}/${plan.instance}`;
5988
- status(`${game} ${what}: ${plan.mods.length} mods resolve cleanly`);
5989
- }
5990
- return Exit.Ok;
5991
- }
6153
+ if (args.printPlan || args.dryRun)
6154
+ return await reportPlanOnly(plan, args, profile, identity);
5992
6155
  const environment = await preflight(plan);
5993
6156
  if (environment.length > 0)
5994
6157
  reportEnvironment(environment);
@@ -6008,6 +6171,21 @@ async function resolved(argv, args, config, plugins, asShell, game, profile, sou
6008
6171
  await lock.release();
6009
6172
  }
6010
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
+ }
6011
6189
  async function launch(plan, args, config, identity, asShell, profileSpec, releaseSources) {
6012
6190
  const game = plan.game;
6013
6191
  const profile = plan.profile;
@@ -6021,12 +6199,13 @@ run: gamecrate fix-perms ${game} ${profile}`);
6021
6199
  plan.runDirHost = runDir;
6022
6200
  const supervisorLog = args.supervised && args.log === undefined ? redirectOutput(join22(runDir, "supervisor.log")) : undefined;
6023
6201
  try {
6024
- return await execute(plan, args, config, identity, asShell, profileSpec, runDir, releaseSources);
6202
+ return await execute({ plan, args, config, identity, asShell, profileSpec, runDir, releaseSources });
6025
6203
  } finally {
6026
6204
  supervisorLog?.close();
6027
6205
  }
6028
6206
  }
6029
- 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;
6030
6209
  const game = plan.game;
6031
6210
  await buildLocalMods(plan, buildPolicy(args, profileSpec));
6032
6211
  await releaseSources();
@@ -6153,7 +6332,7 @@ async function copyOutLogs(plan) {
6153
6332
  const source = join22(plan.dataDirHost, spec.from);
6154
6333
  if (!existsSync12(source))
6155
6334
  return;
6156
- const target = join22(plan.runDirHost, basename11(spec.from.replace(/\/+$/, "")));
6335
+ const target = join22(plan.runDirHost, basename11(spec.from));
6157
6336
  try {
6158
6337
  await cp(source, target, { recursive: true, force: true });
6159
6338
  } catch (error) {
@@ -6176,24 +6355,17 @@ async function mods(args, config, plugins, defaults) {
6176
6355
  return Exit.Ok;
6177
6356
  }
6178
6357
  function usesWorkshop(game) {
6179
- for (const entry of Object.values(game.library ?? {})) {
6180
- if (entry.workshop !== undefined)
6181
- return true;
6182
- }
6183
- for (const ref of [...game.preCore ?? [], game.core, ...game.dlc, ...game.base ?? []]) {
6184
- if (ref.startsWith("workshop:"))
6185
- return true;
6186
- }
6187
- for (const profile of Object.values(game.profiles)) {
6188
- for (const entry of profile.mods ?? []) {
6189
- if (typeof entry === "string") {
6190
- if (entry.startsWith("workshop:"))
6191
- return true;
6192
- } else if ("workshop" in entry && entry.workshop !== undefined)
6193
- return true;
6194
- }
6195
- }
6196
- return false;
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;
6197
6369
  }
6198
6370
  function steamcmdSource(runner, config) {
6199
6371
  if (runner.kind === "docker")
@@ -6207,37 +6379,44 @@ async function doctor(config, plugins) {
6207
6379
  const gameConfig = config.games[game];
6208
6380
  const sources = cachedSources(gameConfig, "modless", {}, config.dataRoot);
6209
6381
  const { plan, problems } = await resolvePlan({ game, profile: "modless", root: config, plugins, sources });
6210
- const steamcmd = [];
6211
- if (usesWorkshop(gameConfig)) {
6212
- try {
6213
- status(`${game}: steamcmd ${steamcmdSource(resolveSteamcmd(config), config)}`);
6214
- } catch (error) {
6215
- steamcmd.push({
6216
- where: "steamcmd",
6217
- message: error instanceof Error ? error.message : String(error),
6218
- ...error instanceof GamecrateError && error.detail !== undefined ? { suggestion: error.detail } : {}
6219
- });
6220
- }
6221
- const root = downloadRoot(config.dataRoot, gameConfig);
6222
- status(`${game}: workshop downloads ${root}${existsSync12(root) ? "" : " (not created yet)"}`);
6223
- }
6224
- const all = [...problems, ...await preflight(plan), ...steamcmd];
6225
- if (all.length === 0) {
6226
- status(`${game}: ok`);
6227
- continue;
6228
- }
6229
- failed = true;
6230
- status(`${game}: ${all.length} problem(s)`);
6231
- for (const problem of all) {
6232
- 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}
6233
6413
  ${problem.message}
6234
6414
  `);
6235
- if (problem.suggestion)
6236
- process.stderr.write(` try: ${problem.suggestion}
6415
+ if (problem.suggestion)
6416
+ process.stderr.write(` try: ${problem.suggestion}
6237
6417
  `);
6238
- }
6239
6418
  }
6240
- return failed ? Exit.Environment : Exit.Ok;
6419
+ return false;
6241
6420
  }
6242
6421
  async function logs(args, config, defaults) {
6243
6422
  const game = requireGame(args, config);
@@ -6306,7 +6485,7 @@ async function verify(args, config, plugins, defaults) {
6306
6485
  reportProblems(problems);
6307
6486
  const name = containerName(plan);
6308
6487
  const info = await inspectContainer(name);
6309
- if (info === null || !info.running) {
6488
+ if (!info?.running) {
6310
6489
  throw new GamecrateError(`no container named ${name} is running`, Exit.Environment, "launch it first, or name the run with --instance or --worktree");
6311
6490
  }
6312
6491
  const prefix = `${plan.gameConfig.modsDir.container}/`;
@@ -6370,8 +6549,7 @@ function renderVerify(name, info, plan, boundMods) {
6370
6549
  const stampWidth = Math.max(...stamps.map((s) => s.length));
6371
6550
  for (const [i, mod] of boundMods.entries()) {
6372
6551
  const origin = mod.branch === null ? "" : `worktree ${mod.branch}`;
6373
- out.push(` ${mod.packageId.padEnd(idWidth)} ${paths[i].padEnd(pathWidth)} ${origin}`.trimEnd());
6374
- 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)}`);
6375
6553
  }
6376
6554
  return out.join(`
6377
6555
  `);
@@ -6552,6 +6730,24 @@ async function configEdit(args) {
6552
6730
  status(`${path} is valid`);
6553
6731
  return Exit.Ok;
6554
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
+ }
6555
6751
  async function fixPerms(args, config) {
6556
6752
  const game = requireGame(args, config);
6557
6753
  const identity = resolveIdentity(false);
@@ -6572,21 +6768,7 @@ async function fixPerms(args, config) {
6572
6768
  status(`${found.length} foreign-owned path(s); re-run with --yes to chown them`);
6573
6769
  return Exit.Environment;
6574
6770
  }
6575
- let fixed = 0;
6576
- const stuck = [];
6577
- for (const path of found) {
6578
- const chowned = await chown(path, identity.uid, identity.gid).then(() => true, () => false);
6579
- if (chowned) {
6580
- fixed += 1;
6581
- continue;
6582
- }
6583
- if (await removeIfEmptyDir(path)) {
6584
- status(`removed empty ${path}; it will be recreated on the next run`);
6585
- fixed += 1;
6586
- continue;
6587
- }
6588
- stuck.push(path);
6589
- }
6771
+ const { fixed, stuck } = await repairOwnership(found, identity);
6590
6772
  if (fixed > 0)
6591
6773
  status(`fixed ${fixed} path(s) for ${identity.uid}:${identity.gid}`);
6592
6774
  if (stuck.length > 0) {