@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 +860 -678
- package/dist/types/cli/output.d.ts +1 -1
- package/package.json +1 -1
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
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
|
329
|
-
const counts =
|
|
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
|
-
|
|
351
|
-
|
|
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
|
-
|
|
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
|
|
481
|
-
|
|
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 =
|
|
486
|
-
|
|
487
|
-
break;
|
|
507
|
+
out.rest = left;
|
|
508
|
+
return [];
|
|
488
509
|
}
|
|
489
|
-
const value =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
1270
|
-
|
|
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
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
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
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
1880
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1906
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
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
|
-
|
|
2278
|
-
if (
|
|
2279
|
-
|
|
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
|
-
|
|
2311
|
-
|
|
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().
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(/[/:]/).
|
|
2980
|
+
return url.split(/[/:]/).findLast((part) => part.length > 0) ?? "repo";
|
|
2896
2981
|
}
|
|
2897
2982
|
function slug(value) {
|
|
2898
|
-
const out = value.toLowerCase().
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3659
|
-
|
|
3660
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
4070
|
+
const desc = f.description.replaceAll("'", String.raw`'\''`).replaceAll(/[[\]:]/g, "");
|
|
3981
4071
|
const arg = placeholder(f);
|
|
3982
|
-
const
|
|
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) ||
|
|
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 ||
|
|
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) ||
|
|
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 (
|
|
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
|
-
(
|
|
4277
|
-
|
|
4278
|
-
|
|
4279
|
-
|
|
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
|
-
|
|
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_]
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
5181
|
+
}
|
|
5182
|
+
async function indexWorkshop(index, config, dataRoot, cacheIndex) {
|
|
5183
|
+
const roots = [];
|
|
5063
5184
|
if (dataRoot !== undefined)
|
|
5064
|
-
|
|
5185
|
+
roots.push(downloadRoot(dataRoot, config));
|
|
5065
5186
|
if (config.workshopRoot !== null)
|
|
5066
|
-
|
|
5067
|
-
if (
|
|
5068
|
-
|
|
5069
|
-
|
|
5070
|
-
|
|
5071
|
-
|
|
5072
|
-
|
|
5073
|
-
|
|
5074
|
-
|
|
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
|
-
|
|
5086
|
-
|
|
5087
|
-
|
|
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
|
|
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
|
|
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()
|
|
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
|
-
|
|
5324
|
-
|
|
5325
|
-
|
|
5326
|
-
|
|
5327
|
-
|
|
5328
|
-
|
|
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
|
-
|
|
5344
|
-
|
|
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
|
|
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
|
|
5417
|
-
|
|
5418
|
-
|
|
5419
|
-
|
|
5420
|
-
|
|
5421
|
-
|
|
5422
|
-
|
|
5423
|
-
|
|
5424
|
-
|
|
5425
|
-
|
|
5426
|
-
|
|
5427
|
-
|
|
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
|
-
|
|
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
|
|
5456
|
-
|
|
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
|
-
|
|
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
|
|
5586
|
-
|
|
5587
|
-
|
|
5588
|
-
|
|
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.
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
6180
|
-
|
|
6181
|
-
|
|
6182
|
-
|
|
6183
|
-
|
|
6184
|
-
|
|
6185
|
-
|
|
6186
|
-
|
|
6187
|
-
|
|
6188
|
-
|
|
6189
|
-
|
|
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
|
|
6211
|
-
if (
|
|
6212
|
-
|
|
6213
|
-
|
|
6214
|
-
|
|
6215
|
-
|
|
6216
|
-
|
|
6217
|
-
|
|
6218
|
-
|
|
6219
|
-
|
|
6220
|
-
|
|
6221
|
-
|
|
6222
|
-
|
|
6223
|
-
|
|
6224
|
-
|
|
6225
|
-
|
|
6226
|
-
|
|
6227
|
-
|
|
6228
|
-
|
|
6229
|
-
|
|
6230
|
-
|
|
6231
|
-
|
|
6232
|
-
|
|
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
|
-
|
|
6236
|
-
|
|
6415
|
+
if (problem.suggestion)
|
|
6416
|
+
process.stderr.write(` try: ${problem.suggestion}
|
|
6237
6417
|
`);
|
|
6238
|
-
}
|
|
6239
6418
|
}
|
|
6240
|
-
return
|
|
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 (
|
|
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
|
-
|
|
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) {
|