@gamecrate/cli 1.3.0 → 1.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/gamecrate.js +1029 -812
- package/dist/types/cli/output.d.ts +1 -1
- package/dist/types/mods/steamcmd.d.ts +5 -0
- package/dist/types/types.d.ts +2 -1
- package/package.json +1 -1
package/dist/gamecrate.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
|
-
import { existsSync as
|
|
5
|
-
import { chown, cp, mkdir as mkdir6, readdir as
|
|
4
|
+
import { existsSync as existsSync12 } from "node:fs";
|
|
5
|
+
import { chown, cp, mkdir as mkdir6, readdir as readdir11, readFile as readFile12, rm as rm5, rmdir, stat as stat6, writeFile as writeFile7 } from "node:fs/promises";
|
|
6
6
|
import { homedir as homedir5 } from "node:os";
|
|
7
|
-
import { basename as basename11, dirname as
|
|
7
|
+
import { basename as basename11, dirname as dirname10, join as join22 } from "node:path";
|
|
8
8
|
import { setTimeout as sleep7 } from "node:timers/promises";
|
|
9
9
|
|
|
10
10
|
// src/cli/args.ts
|
|
@@ -150,7 +150,7 @@ var SUBCOMMANDS = [
|
|
|
150
150
|
summary: "tiered wipe of a profile",
|
|
151
151
|
usage: "<game> [profile]",
|
|
152
152
|
positionals: ["game", "profile"],
|
|
153
|
-
flags: ["--staging", "--logs", "--all", "--yes", "--instance", "--worktree", "--no-worktree"]
|
|
153
|
+
flags: ["--staging", "--logs", "--all", "--downloads", "--yes", "--instance", "--worktree", "--no-worktree"]
|
|
154
154
|
},
|
|
155
155
|
{
|
|
156
156
|
name: "clone",
|
|
@@ -291,7 +291,7 @@ function enumOption(flags, summary, values) {
|
|
|
291
291
|
}
|
|
292
292
|
function buildProgram() {
|
|
293
293
|
const program = new Command;
|
|
294
|
-
program.name("gamecrate").exitOverride().helpOption(false).allowExcessArguments(true).showSuggestionAfterError(false).configureOutput({ writeOut: () => {}, writeErr: () => {} }).argument("[args...]").option("--mod <id>", "add a mod to the profile set", collect, []).option("--without <id>", "drop a mod from the resolved set", collect, []).option("--only <id>", "restrict the resolved set to these mods", collect, []).option("--worktree <path>", "promote mods from this git worktree, in its own instance ($GAMECRATE_WORKTREE)", collect, []).option("--use <packageId>=<path>", "force one mod to load from this directory, whatever the profile pins", collect, []).option("--no-worktree", "ignore the current worktree and $GAMECRATE_WORKTREE").option("--instance <name>", "run under a named sub-profile with its own saves, logs and container").addOption(enumOption(`--mode <${MODES.join("|")}>`, "how the game is displayed", MODES)).option("--marker <str>", "exit 0 as soon as this string appears in the log").option("--timeout <seconds>", "bound a marker run, or a headless run with no marker", (v) => seconds("--timeout", v)).option("--render-wait <seconds>", "settle time before a screenshot is taken", (v) => seconds("--render-wait", v)).option("--resolution <width>x<height>", "override the game resolution", parseResolution).addOption(enumOption(`--network <${NETWORK_POLICIES.join("|")}>`, "the container's network mode", NETWORK_POLICIES)).option("--log <path>", "route launch stdout and stderr to one file").addOption(enumOption(`--pull <${PULL_POLICIES.join("|")}>`, "when to pull the runtime image", PULL_POLICIES)).option("--build", "build local C# mods before launching").option("--no-build", "never build, even when an assembly is stale").option("--no-stale-check", "do not warn when a mod's sources are newer than its assemblies").option("--replace", "stop whatever is holding this profile and instance, then launch").option("--no-replace", "refuse when this profile and instance are already running").option("--detach", "start the run in the background and return the prompt").option("--no-detach", "stay in the foreground, whatever the profile or project config asks for").addOption(new Option("--supervised <instanceDir>").hideHelp()).addOption(enumOption(`--sort <${SORTS.join("|")}>`, "load order: the profile order, or a topological sort", SORTS)).option("--docker-arg <arg>", "one extra argv element for docker run", collect, []).option("--dry-run", "resolve and validate fully, write nothing").option("--print-plan", "print the resolved launch plan instead of launching").option("--json", "machine-readable output").option("--root", "run as root instead of mapping the host uid").option("--staging", "clean: wipe .stage only (the default)").option("--logs", "clean: wipe the captured run logs").option("--all", "clean: wipe the whole profile
|
|
294
|
+
program.name("gamecrate").exitOverride().helpOption(false).allowExcessArguments(true).showSuggestionAfterError(false).configureOutput({ writeOut: () => {}, writeErr: () => {} }).argument("[args...]").option("--mod <id>", "add a mod to the profile set", collect, []).option("--without <id>", "drop a mod from the resolved set", collect, []).option("--only <id>", "restrict the resolved set to these mods", collect, []).option("--worktree <path>", "promote mods from this git worktree, in its own instance ($GAMECRATE_WORKTREE)", collect, []).option("--use <packageId>=<path>", "force one mod to load from this directory, whatever the profile pins", collect, []).option("--no-worktree", "ignore the current worktree and $GAMECRATE_WORKTREE").option("--instance <name>", "run under a named sub-profile with its own saves, logs and container").addOption(enumOption(`--mode <${MODES.join("|")}>`, "how the game is displayed", MODES)).option("--marker <str>", "exit 0 as soon as this string appears in the log").option("--timeout <seconds>", "bound a marker run, or a headless run with no marker", (v) => seconds("--timeout", v)).option("--render-wait <seconds>", "settle time before a screenshot is taken", (v) => seconds("--render-wait", v)).option("--resolution <width>x<height>", "override the game resolution", parseResolution).addOption(enumOption(`--network <${NETWORK_POLICIES.join("|")}>`, "the container's network mode", NETWORK_POLICIES)).option("--log <path>", "route launch stdout and stderr to one file").addOption(enumOption(`--pull <${PULL_POLICIES.join("|")}>`, "when to pull the runtime image", PULL_POLICIES)).option("--build", "build local C# mods before launching").option("--no-build", "never build, even when an assembly is stale").option("--no-stale-check", "do not warn when a mod's sources are newer than its assemblies").option("--replace", "stop whatever is holding this profile and instance, then launch").option("--no-replace", "refuse when this profile and instance are already running").option("--detach", "start the run in the background and return the prompt").option("--no-detach", "stay in the foreground, whatever the profile or project config asks for").addOption(new Option("--supervised <instanceDir>").hideHelp()).addOption(enumOption(`--sort <${SORTS.join("|")}>`, "load order: the profile order, or a topological sort", SORTS)).option("--docker-arg <arg>", "one extra argv element for docker run", collect, []).option("--dry-run", "resolve and validate fully, write nothing").option("--print-plan", "print the resolved launch plan instead of launching").option("--json", "machine-readable output").option("--root", "run as root instead of mapping the host uid").option("--staging", "clean: wipe .stage only (the default)").option("--logs", "clean: wipe the captured run logs").option("--all", "clean: wipe the whole profile and the game downloads (needs --yes)").option("--downloads", "clean: wipe this game's workshop downloads, keeping steamcmd").option("-f, --follow", "keep printing as the run writes").option("-y, --yes", "skip destructive-action confirmation").option("--path <dir>", "mods add: take the mod from this directory").option("--workshop <id>", "mods add: take the mod from this steam workshop item", workshopId).option("--git <url>", "mods add: clone the mod from this repository").option("--branch <name>", "mods add: track this git branch").option("--tag <name>", "mods add: pin this git tag").option("--commit <sha>", "mods add: pin this git commit").option("--subdir <path>", "mods add: the mod folder inside the repository").option("--global", "write to the global config").option("--project", "write to the project config").option("--force", "overwrite an entry that is already there").option("-h, --help", "this help");
|
|
295
295
|
return program;
|
|
296
296
|
}
|
|
297
297
|
function checkValueTokens(program, head) {
|
|
@@ -313,11 +313,14 @@ function checkValueTokens(program, head) {
|
|
|
313
313
|
const value = head[++i];
|
|
314
314
|
if (value === undefined)
|
|
315
315
|
return;
|
|
316
|
-
|
|
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") {
|
|
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;
|
|
534
|
+
build = envBuildPolicy(name, raw);
|
|
526
535
|
continue;
|
|
527
536
|
}
|
|
528
|
-
|
|
529
|
-
const key = option.attributeName();
|
|
530
|
-
if (!option.required) {
|
|
531
|
-
if (truthy(raw))
|
|
532
|
-
values[key] = true;
|
|
533
|
-
continue;
|
|
534
|
-
}
|
|
535
|
-
if (option.argChoices && !option.argChoices.includes(raw)) {
|
|
536
|
-
throw usage(`${name} must be one of ${option.argChoices.join(", ")}, got ${raw}`);
|
|
537
|
-
}
|
|
538
|
-
values[key] = option.parseArg === undefined ? raw : option.parseArg(raw, values[key]);
|
|
537
|
+
applyEnvValue(program, flag, name, raw, values);
|
|
539
538
|
}
|
|
540
539
|
return build;
|
|
541
540
|
}
|
|
541
|
+
function envBuildPolicy(name, raw) {
|
|
542
|
+
if (!BUILD_POLICIES.includes(raw)) {
|
|
543
|
+
throw usage(`${name} must be one of ${BUILD_POLICIES.join(", ")}, got ${raw}`);
|
|
544
|
+
}
|
|
545
|
+
return raw;
|
|
546
|
+
}
|
|
547
|
+
function applyEnvValue(program, flag, name, raw, values) {
|
|
548
|
+
const option = program.options.find((o) => o.long === flag);
|
|
549
|
+
const key = option.attributeName();
|
|
550
|
+
if (!option.required) {
|
|
551
|
+
if (truthy(raw))
|
|
552
|
+
values[key] = true;
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
if (option.argChoices && !option.argChoices.includes(raw)) {
|
|
556
|
+
throw usage(`${name} must be one of ${option.argChoices.join(", ")}, got ${raw}`);
|
|
557
|
+
}
|
|
558
|
+
values[key] = option.parseArg === undefined ? raw : option.parseArg(raw, values[key]);
|
|
559
|
+
}
|
|
542
560
|
function applyDefaults(out, seen, defaults, hasGameArgs) {
|
|
561
|
+
applyListDefaults(out, seen, defaults, hasGameArgs);
|
|
562
|
+
applyScalarDefaults(out, defaults);
|
|
563
|
+
applyFlagDefaults(out, seen, defaults);
|
|
564
|
+
}
|
|
565
|
+
function applyListDefaults(out, seen, defaults, hasGameArgs) {
|
|
543
566
|
if (!seen.has("--mod") && defaults.mods !== undefined)
|
|
544
567
|
out.mods = [...defaults.mods];
|
|
545
568
|
if (!seen.has("--without") && defaults.without !== undefined)
|
|
@@ -555,6 +578,8 @@ function applyDefaults(out, seen, defaults, hasGameArgs) {
|
|
|
555
578
|
out.use = [...defaults.use];
|
|
556
579
|
if (!hasGameArgs && defaults.gameArgs !== undefined)
|
|
557
580
|
out.gameArgs = [...defaults.gameArgs];
|
|
581
|
+
}
|
|
582
|
+
function applyScalarDefaults(out, defaults) {
|
|
558
583
|
out.mode ??= defaults.mode;
|
|
559
584
|
out.marker ??= defaults.marker;
|
|
560
585
|
out.timeout ??= defaults.timeout;
|
|
@@ -566,6 +591,8 @@ function applyDefaults(out, seen, defaults, hasGameArgs) {
|
|
|
566
591
|
out.build ??= defaults.build;
|
|
567
592
|
out.sort ??= defaults.sort;
|
|
568
593
|
out.instance ??= defaults.instance;
|
|
594
|
+
}
|
|
595
|
+
function applyFlagDefaults(out, seen, defaults) {
|
|
569
596
|
if (!seen.has("--dry-run"))
|
|
570
597
|
out.dryRun = defaults.dryRun ?? out.dryRun;
|
|
571
598
|
if (!seen.has("--print-plan"))
|
|
@@ -639,7 +666,11 @@ function modSource(values) {
|
|
|
639
666
|
if (kinds.length > 1)
|
|
640
667
|
throw usage(`${kinds[0]} and ${kinds[1]} contradict: a source has one kind`);
|
|
641
668
|
if (url === undefined) {
|
|
642
|
-
|
|
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,11 +789,56 @@ function list(args, config, defaults) {
|
|
|
794
789
|
`);
|
|
795
790
|
return Exit.Ok;
|
|
796
791
|
}
|
|
792
|
+
function jsonReport(games, config, fromProject) {
|
|
793
|
+
return games.map((name) => {
|
|
794
|
+
const game = config.games[name];
|
|
795
|
+
return {
|
|
796
|
+
game: name,
|
|
797
|
+
core: game.core,
|
|
798
|
+
dlc: game.dlc,
|
|
799
|
+
modes: game.modes,
|
|
800
|
+
profiles: Object.entries(game.profiles).map(([profile, spec]) => ({
|
|
801
|
+
profile,
|
|
802
|
+
alias: spec.alias ?? null,
|
|
803
|
+
description: spec.description ?? null,
|
|
804
|
+
extends: spec.extends ?? null,
|
|
805
|
+
mods: spec.mods?.length ?? 0,
|
|
806
|
+
instances: Object.keys(spec.instances ?? {}),
|
|
807
|
+
source: fromProject(name, profile) ? "project" : "config"
|
|
808
|
+
}))
|
|
809
|
+
};
|
|
810
|
+
});
|
|
811
|
+
}
|
|
812
|
+
function profileNotes(spec, isProject, source) {
|
|
813
|
+
const notes = [];
|
|
814
|
+
if (spec.alias)
|
|
815
|
+
notes.push(`alias for ${spec.alias}`);
|
|
816
|
+
if (spec.extends)
|
|
817
|
+
notes.push(`extends ${spec.extends}`);
|
|
818
|
+
const count = spec.mods?.length ?? 0;
|
|
819
|
+
if (!spec.alias)
|
|
820
|
+
notes.push(count === 1 ? "1 entry" : `${count} entries`);
|
|
821
|
+
if (spec.aliases?.length)
|
|
822
|
+
notes.push(`aka ${spec.aliases.join(", ")}`);
|
|
823
|
+
if (isProject)
|
|
824
|
+
notes.push(`from ${source}`);
|
|
825
|
+
return notes;
|
|
826
|
+
}
|
|
827
|
+
function profileRows(profile, spec, width, notes) {
|
|
828
|
+
const pad = " ".repeat(width);
|
|
829
|
+
const rows = [` ${profile.padEnd(width)} ${notes.join(", ")}`];
|
|
830
|
+
if (spec.description)
|
|
831
|
+
rows.push(` ${pad} ${spec.description}`);
|
|
832
|
+
const instances = Object.keys(spec.instances ?? {});
|
|
833
|
+
if (instances.length > 0)
|
|
834
|
+
rows.push(` ${pad} instances: ${instances.join(", ")}`);
|
|
835
|
+
return rows;
|
|
836
|
+
}
|
|
797
837
|
|
|
798
838
|
// src/cli/mods.ts
|
|
799
|
-
import { existsSync as
|
|
800
|
-
import { mkdir as mkdir2, readFile as readFile7, readdir as
|
|
801
|
-
import { dirname as
|
|
839
|
+
import { existsSync as existsSync6 } from "node:fs";
|
|
840
|
+
import { mkdir as mkdir2, readFile as readFile7, readdir as readdir6, writeFile as writeFile3 } from "node:fs/promises";
|
|
841
|
+
import { dirname as dirname7, join as join12, relative as relative2, resolve as resolve4 } from "node:path";
|
|
802
842
|
|
|
803
843
|
// src/config/load.ts
|
|
804
844
|
import { z as z2 } from "zod";
|
|
@@ -1262,53 +1302,69 @@ function crossReference(p, games) {
|
|
|
1262
1302
|
const profiles = game_["profiles"];
|
|
1263
1303
|
if (!isObj(profiles))
|
|
1264
1304
|
continue;
|
|
1265
|
-
const names = Object.keys(profiles);
|
|
1266
1305
|
for (const [name, prof] of Object.entries(profiles)) {
|
|
1267
1306
|
const w = `${where}/profiles/${esc(name)}`;
|
|
1268
1307
|
checkName(p, w, name, "profile");
|
|
1269
|
-
if (
|
|
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)
|
|
@@ -3143,8 +3227,9 @@ async function prepareSources(game, profileName, args, dataRoot, allowFetch) {
|
|
|
3143
3227
|
|
|
3144
3228
|
// src/mods/steamcmd.ts
|
|
3145
3229
|
import { spawnSync as spawnSync2 } from "node:child_process";
|
|
3146
|
-
import { accessSync, constants, mkdirSync as mkdirSync3, statSync as statSync2 } from "node:fs";
|
|
3147
|
-
import {
|
|
3230
|
+
import { accessSync, constants, existsSync as existsSync5, mkdirSync as mkdirSync3, statSync as statSync2 } from "node:fs";
|
|
3231
|
+
import { readdir as readdir5, rm as rm2 } from "node:fs/promises";
|
|
3232
|
+
import { delimiter, dirname as dirname5, join as join10 } from "node:path";
|
|
3148
3233
|
import { setTimeout as sleep4 } from "node:timers/promises";
|
|
3149
3234
|
var STEAMCMD_IMAGE = "steamcmd/steamcmd";
|
|
3150
3235
|
function steamHome(dataRoot) {
|
|
@@ -3153,6 +3238,14 @@ function steamHome(dataRoot) {
|
|
|
3153
3238
|
function downloadRoot(dataRoot, game) {
|
|
3154
3239
|
return join10(steamHome(dataRoot), "steamapps", "workshop", "content", String(game.steamAppId));
|
|
3155
3240
|
}
|
|
3241
|
+
async function removeDownloads(root, steamAppId) {
|
|
3242
|
+
if (!existsSync5(root))
|
|
3243
|
+
return `no workshop downloads at ${root}`;
|
|
3244
|
+
const items = (await readdir5(root).catch(() => [])).length;
|
|
3245
|
+
await rm2(root, { recursive: true, force: true });
|
|
3246
|
+
await rm2(join10(dirname5(dirname5(root)), `appworkshop_${steamAppId}.acf`), { force: true });
|
|
3247
|
+
return `removed ${root} (${items} item(s))`;
|
|
3248
|
+
}
|
|
3156
3249
|
function resolveSteamcmd(config) {
|
|
3157
3250
|
const home = steamHome(config.dataRoot);
|
|
3158
3251
|
const configured = config.steamcmd?.path === undefined ? undefined : expandHome(config.steamcmd.path);
|
|
@@ -3220,7 +3313,7 @@ function workshopUrlId(url) {
|
|
|
3220
3313
|
const id = parsed.searchParams.get("id");
|
|
3221
3314
|
return id !== null && /^\d+$/.test(id) ? id : undefined;
|
|
3222
3315
|
}
|
|
3223
|
-
var ANSI = new RegExp(`${String.
|
|
3316
|
+
var ANSI = new RegExp(String.raw`${String.fromCodePoint(27)}\[[0-9;?]*[ -/]*[@-~]`, "g");
|
|
3224
3317
|
var SUCCESS = /Success\. Downloaded item (\d+) to "([^"]+)" \((\d+) bytes\)/g;
|
|
3225
3318
|
var FAILED = /ERROR! Download item (\d+) failed \(([^)]+)\)/g;
|
|
3226
3319
|
var ATTEMPTS = 2;
|
|
@@ -3283,7 +3376,7 @@ ${r.stderr ?? ""}`.replace(ANSI, "");
|
|
|
3283
3376
|
|
|
3284
3377
|
// src/mods/workshopapi.ts
|
|
3285
3378
|
import { readFile as readFile6, stat as stat4 } from "node:fs/promises";
|
|
3286
|
-
import { basename as basename5, dirname as
|
|
3379
|
+
import { basename as basename5, dirname as dirname6, join as join11 } from "node:path";
|
|
3287
3380
|
|
|
3288
3381
|
// src/mods/acf.ts
|
|
3289
3382
|
var MAX_DEPTH = 100;
|
|
@@ -3324,50 +3417,66 @@ function quoted(text, start) {
|
|
|
3324
3417
|
}
|
|
3325
3418
|
return null;
|
|
3326
3419
|
}
|
|
3327
|
-
function
|
|
3328
|
-
if (depth > MAX_DEPTH)
|
|
3329
|
-
return null;
|
|
3420
|
+
function root2(text) {
|
|
3330
3421
|
const node = {};
|
|
3331
|
-
let i =
|
|
3332
|
-
|
|
3333
|
-
i = skip(text, i);
|
|
3334
|
-
if (i >= text.length)
|
|
3335
|
-
return depth === 0 ? { node, next: i } : null;
|
|
3336
|
-
if (text[i] === "}")
|
|
3337
|
-
return depth === 0 ? null : { node, next: i + 1 };
|
|
3422
|
+
let i = skip(text, 0);
|
|
3423
|
+
while (i < text.length) {
|
|
3338
3424
|
if (text[i] !== '"')
|
|
3339
3425
|
return null;
|
|
3340
|
-
const
|
|
3341
|
-
if (
|
|
3342
|
-
return null;
|
|
3343
|
-
i = skip(text, key.next);
|
|
3344
|
-
if (i >= text.length)
|
|
3426
|
+
const next = readEntry(text, i, 0, node);
|
|
3427
|
+
if (next === null)
|
|
3345
3428
|
return null;
|
|
3346
|
-
|
|
3347
|
-
|
|
3348
|
-
|
|
3349
|
-
|
|
3350
|
-
|
|
3351
|
-
|
|
3352
|
-
|
|
3353
|
-
|
|
3429
|
+
i = skip(text, next);
|
|
3430
|
+
}
|
|
3431
|
+
return node;
|
|
3432
|
+
}
|
|
3433
|
+
function block(text, start, depth) {
|
|
3434
|
+
if (depth > MAX_DEPTH)
|
|
3435
|
+
return null;
|
|
3436
|
+
const node = {};
|
|
3437
|
+
let i = skip(text, start);
|
|
3438
|
+
while (i < text.length) {
|
|
3439
|
+
if (text[i] === "}")
|
|
3440
|
+
return { node, next: i + 1 };
|
|
3354
3441
|
if (text[i] !== '"')
|
|
3355
3442
|
return null;
|
|
3356
|
-
const
|
|
3357
|
-
if (
|
|
3443
|
+
const next = readEntry(text, i, depth, node);
|
|
3444
|
+
if (next === null)
|
|
3358
3445
|
return null;
|
|
3359
|
-
|
|
3360
|
-
i = value.next;
|
|
3446
|
+
i = skip(text, next);
|
|
3361
3447
|
}
|
|
3448
|
+
return null;
|
|
3449
|
+
}
|
|
3450
|
+
function readEntry(text, start, depth, node) {
|
|
3451
|
+
const key = quoted(text, start);
|
|
3452
|
+
if (key === null)
|
|
3453
|
+
return null;
|
|
3454
|
+
const i = skip(text, key.next);
|
|
3455
|
+
if (i >= text.length)
|
|
3456
|
+
return null;
|
|
3457
|
+
if (text[i] === "{") {
|
|
3458
|
+
const child = block(text, i + 1, depth + 1);
|
|
3459
|
+
if (child === null)
|
|
3460
|
+
return null;
|
|
3461
|
+
node[key.value] = child.node;
|
|
3462
|
+
return child.next;
|
|
3463
|
+
}
|
|
3464
|
+
if (text[i] !== '"')
|
|
3465
|
+
return null;
|
|
3466
|
+
const value = quoted(text, i);
|
|
3467
|
+
if (value === null)
|
|
3468
|
+
return null;
|
|
3469
|
+
node[key.value] = value.value;
|
|
3470
|
+
return value.next;
|
|
3362
3471
|
}
|
|
3363
3472
|
function parseAcf(text) {
|
|
3364
|
-
return
|
|
3473
|
+
return root2(text) ?? {};
|
|
3365
3474
|
}
|
|
3366
|
-
function section(
|
|
3367
|
-
const direct =
|
|
3475
|
+
function section(node, name) {
|
|
3476
|
+
const direct = node[name];
|
|
3368
3477
|
if (typeof direct === "object")
|
|
3369
3478
|
return direct;
|
|
3370
|
-
for (const child of Object.values(
|
|
3479
|
+
for (const child of Object.values(node)) {
|
|
3371
3480
|
if (typeof child === "object" && typeof child[name] === "object")
|
|
3372
3481
|
return child[name];
|
|
3373
3482
|
}
|
|
@@ -3484,14 +3593,14 @@ function readDetails(payload) {
|
|
|
3484
3593
|
const id = entry["publishedfileid"];
|
|
3485
3594
|
const result = entry["result"];
|
|
3486
3595
|
if (typeof id !== "string" || typeof result !== "number") {
|
|
3487
|
-
throw new
|
|
3596
|
+
throw new TypeError("steam returned a body this does not understand");
|
|
3488
3597
|
}
|
|
3489
3598
|
out.set(id, { result, timeUpdated: Number(entry["time_updated"]) || 0 });
|
|
3490
3599
|
}
|
|
3491
3600
|
return out;
|
|
3492
3601
|
}
|
|
3493
3602
|
function acfPath(root) {
|
|
3494
|
-
return join11(
|
|
3603
|
+
return join11(dirname6(dirname6(root)), `appworkshop_${basename5(root)}.acf`);
|
|
3495
3604
|
}
|
|
3496
3605
|
async function exists(dir) {
|
|
3497
3606
|
try {
|
|
@@ -3536,14 +3645,16 @@ async function modsAdd(args, ctx) {
|
|
|
3536
3645
|
const twice = [...where.values()].filter((at) => at.length > 1);
|
|
3537
3646
|
if (twice.length > 0) {
|
|
3538
3647
|
const lines = [...where.entries()].filter(([, at]) => at.length > 1);
|
|
3539
|
-
|
|
3540
|
-
`)
|
|
3648
|
+
const rows = lines.map(([id, at]) => ` ${id}: ${at.join(", ")}`).join(`
|
|
3649
|
+
`);
|
|
3650
|
+
throw new GamecrateError(`${twice.length} mod id(s) are declared by more than one directory in ${describe(source)}`, Exit.Config, `${rows}
|
|
3541
3651
|
pin one of them with --subdir`);
|
|
3542
3652
|
}
|
|
3543
3653
|
const clashes = pins.filter((pin) => existingKey(target.existing, pin.id) !== undefined);
|
|
3544
3654
|
if (clashes.length > 0 && args.force !== true) {
|
|
3545
|
-
|
|
3546
|
-
`)
|
|
3655
|
+
const rows = clashes.map((pin) => ` ${pin.id}`).join(`
|
|
3656
|
+
`);
|
|
3657
|
+
throw new GamecrateError(`${clashes.length} mod id(s) are already pinned in ${target.file}`, Exit.Config, `${rows}
|
|
3547
3658
|
overwrite them with --force`);
|
|
3548
3659
|
}
|
|
3549
3660
|
const edits = [];
|
|
@@ -3553,8 +3664,8 @@ overwrite them with --force`);
|
|
|
3553
3664
|
edits.push({ path: [...target.prefix, old], value: undefined });
|
|
3554
3665
|
edits.push({ path: [...target.prefix, pin.id], value: pin.entry });
|
|
3555
3666
|
}
|
|
3556
|
-
if (!
|
|
3557
|
-
await mkdir2(
|
|
3667
|
+
if (!existsSync6(target.file)) {
|
|
3668
|
+
await mkdir2(dirname7(target.file), { recursive: true });
|
|
3558
3669
|
await writeFile3(target.file, "");
|
|
3559
3670
|
}
|
|
3560
3671
|
await writeConfig(target.file, edits);
|
|
@@ -3582,11 +3693,28 @@ async function modsRm(args, ctx) {
|
|
|
3582
3693
|
async function modsSync(args, ctx) {
|
|
3583
3694
|
const only = args.rest.map((id) => id.toLowerCase());
|
|
3584
3695
|
const games = args.game === undefined ? Object.keys(ctx.config.games) : [requireGame(args, ctx.config)];
|
|
3696
|
+
const { wanted, subscribed } = collectPins(ctx.config, games, only);
|
|
3697
|
+
const named = [...wanted.map((pin) => pin.id), ...subscribed.flatMap((one) => one.pins.map((pin) => pin.id))];
|
|
3698
|
+
const missing = only.filter((id) => !named.some((pinned) => pinned.toLowerCase() === id));
|
|
3699
|
+
if (missing.length > 0) {
|
|
3700
|
+
throw new GamecrateError(`${missing.length} mod id(s) are not git- or workshop-pinned in the library`, Exit.Resolution, missing.map((id) => ` ${id}`).join(`
|
|
3701
|
+
`));
|
|
3702
|
+
}
|
|
3703
|
+
if (wanted.length === 0 && subscribed.length === 0) {
|
|
3704
|
+
status("nothing to sync");
|
|
3705
|
+
return Exit.Ok;
|
|
3706
|
+
}
|
|
3707
|
+
await syncGit(ctx, wanted);
|
|
3708
|
+
for (const one of subscribed)
|
|
3709
|
+
await syncWorkshop(ctx, one.game, one.pins);
|
|
3710
|
+
return Exit.Ok;
|
|
3711
|
+
}
|
|
3712
|
+
function collectPins(config, games, only) {
|
|
3585
3713
|
const wanted = [];
|
|
3586
3714
|
const subscribed = [];
|
|
3587
3715
|
for (const name of games) {
|
|
3588
3716
|
const pins = [];
|
|
3589
|
-
for (const [id, entry] of Object.entries(
|
|
3717
|
+
for (const [id, entry] of Object.entries(config.games[name]?.library ?? {})) {
|
|
3590
3718
|
if (only.length > 0 && !only.includes(id.toLowerCase()))
|
|
3591
3719
|
continue;
|
|
3592
3720
|
if (entry.git !== undefined)
|
|
@@ -3597,16 +3725,9 @@ async function modsSync(args, ctx) {
|
|
|
3597
3725
|
if (pins.length > 0)
|
|
3598
3726
|
subscribed.push({ game: name, pins });
|
|
3599
3727
|
}
|
|
3600
|
-
|
|
3601
|
-
|
|
3602
|
-
|
|
3603
|
-
throw new GamecrateError(`${missing.length} mod id(s) are not git- or workshop-pinned in the library`, Exit.Resolution, missing.map((id) => ` ${id}`).join(`
|
|
3604
|
-
`));
|
|
3605
|
-
}
|
|
3606
|
-
if (wanted.length === 0 && subscribed.length === 0) {
|
|
3607
|
-
status("nothing to sync");
|
|
3608
|
-
return Exit.Ok;
|
|
3609
|
-
}
|
|
3728
|
+
return { wanted, subscribed };
|
|
3729
|
+
}
|
|
3730
|
+
async function syncGit(ctx, wanted) {
|
|
3610
3731
|
const branches = new Map;
|
|
3611
3732
|
const fetched = new Set;
|
|
3612
3733
|
for (const pin of wanted) {
|
|
@@ -3619,20 +3740,20 @@ async function modsSync(args, ctx) {
|
|
|
3619
3740
|
const dir = cloneDir(ctx.config.dataRoot, pin.git, ref);
|
|
3620
3741
|
if (!fetched.has(dir)) {
|
|
3621
3742
|
fetched.add(dir);
|
|
3622
|
-
|
|
3623
|
-
try {
|
|
3624
|
-
const result = await ensureClone(ctx.config.dataRoot, gitPin(pin.git, pin.entry), ref, "force");
|
|
3625
|
-
if (result.warning !== undefined)
|
|
3626
|
-
warn(result.warning);
|
|
3627
|
-
} finally {
|
|
3628
|
-
await unlock();
|
|
3629
|
-
}
|
|
3743
|
+
await fetchClone(ctx, pin, ref, dir);
|
|
3630
3744
|
}
|
|
3631
3745
|
status(`synced ${pin.id} at ${ref.kind} ${ref.value}`);
|
|
3632
3746
|
}
|
|
3633
|
-
|
|
3634
|
-
|
|
3635
|
-
|
|
3747
|
+
}
|
|
3748
|
+
async function fetchClone(ctx, pin, ref, dir) {
|
|
3749
|
+
const unlock = await lockDir(dir);
|
|
3750
|
+
try {
|
|
3751
|
+
const result = await ensureClone(ctx.config.dataRoot, gitPin(pin.git, pin.entry), ref, "force");
|
|
3752
|
+
if (result.warning !== undefined)
|
|
3753
|
+
warn(result.warning);
|
|
3754
|
+
} finally {
|
|
3755
|
+
await unlock();
|
|
3756
|
+
}
|
|
3636
3757
|
}
|
|
3637
3758
|
async function syncWorkshop(ctx, name, pins) {
|
|
3638
3759
|
const game = ctx.config.games[name];
|
|
@@ -3753,7 +3874,7 @@ async function discover(source, game, plugin, ctx) {
|
|
|
3753
3874
|
await unlock();
|
|
3754
3875
|
}
|
|
3755
3876
|
const start = source.subdir === undefined ? root : join12(root, source.subdir);
|
|
3756
|
-
const found = await
|
|
3877
|
+
const found = await walk2(start, game.manifest.file, plugin);
|
|
3757
3878
|
return found.map(({ id, dir: at }) => {
|
|
3758
3879
|
const entry = { git: source.url };
|
|
3759
3880
|
const pinned = source.ref;
|
|
@@ -3769,7 +3890,7 @@ async function discover(source, game, plugin, ctx) {
|
|
|
3769
3890
|
return { id, entry };
|
|
3770
3891
|
});
|
|
3771
3892
|
}
|
|
3772
|
-
async function
|
|
3893
|
+
async function walk2(root, manifestFile, plugin) {
|
|
3773
3894
|
const out = [];
|
|
3774
3895
|
const descend = async (dir) => {
|
|
3775
3896
|
const id = await readId(dir, manifestFile, plugin);
|
|
@@ -3777,7 +3898,7 @@ async function walk(root, manifestFile, plugin) {
|
|
|
3777
3898
|
out.push({ id, dir });
|
|
3778
3899
|
return;
|
|
3779
3900
|
}
|
|
3780
|
-
const entries = await
|
|
3901
|
+
const entries = await readdir6(dir, { withFileTypes: true }).catch(() => []);
|
|
3781
3902
|
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
3782
3903
|
if (entry.isDirectory() && !entry.name.startsWith("."))
|
|
3783
3904
|
await descend(join12(dir, entry.name));
|
|
@@ -3833,14 +3954,10 @@ function topLevel(config) {
|
|
|
3833
3954
|
"subcommands:"
|
|
3834
3955
|
];
|
|
3835
3956
|
const verbs = SUBCOMMANDS.map((s) => [`${s.name} ${s.usage}`.trim(), s.summary]);
|
|
3836
|
-
lines.push(...columns(verbs, 2));
|
|
3837
|
-
lines.push("", "flags:");
|
|
3838
|
-
lines.push(...columns(flags().map(flagRow), 2));
|
|
3957
|
+
lines.push(...columns(verbs, 2), "", "flags:", ...columns(flags().map(flagRow), 2));
|
|
3839
3958
|
const games = Object.entries(config?.games ?? {});
|
|
3840
3959
|
if (games.length > 0) {
|
|
3841
|
-
lines.push("", "games:");
|
|
3842
|
-
lines.push(...columns(games.map(([name, game]) => [name, gameSummary(game)]), 2));
|
|
3843
|
-
lines.push("", `${NAME} help <game> lists that game's profiles.`);
|
|
3960
|
+
lines.push("", "games:", ...columns(games.map(([name, game]) => [name, gameSummary(game)]), 2), "", `${NAME} help <game> lists that game's profiles.`);
|
|
3844
3961
|
}
|
|
3845
3962
|
lines.push("", "Game args go after a bare --. Env vars are GAMECRATE_ prefixed fallbacks only.");
|
|
3846
3963
|
return lines.join(`
|
|
@@ -3853,8 +3970,7 @@ function subcommandHelp(sub) {
|
|
|
3853
3970
|
const all = flags();
|
|
3854
3971
|
const specs = names.map((name) => all.find((f) => f.long === name)).filter((f) => f !== undefined);
|
|
3855
3972
|
if (specs.length > 0) {
|
|
3856
|
-
lines.push("", "flags:");
|
|
3857
|
-
lines.push(...columns(specs.map(flagRow), 2));
|
|
3973
|
+
lines.push("", "flags:", ...columns(specs.map(flagRow), 2));
|
|
3858
3974
|
}
|
|
3859
3975
|
if (sub.name === "run") {
|
|
3860
3976
|
lines.push("", ` The subcommand slot defaults to run, so \`${NAME} <game> <profile>\` works.`);
|
|
@@ -3865,31 +3981,14 @@ function subcommandHelp(sub) {
|
|
|
3865
3981
|
}
|
|
3866
3982
|
function gameHelp(name, game) {
|
|
3867
3983
|
const lines = [`usage: ${NAME} ${name} [profile] [flags] [-- game args]`, "", "profiles:"];
|
|
3868
|
-
const rows = [];
|
|
3869
|
-
for (const [profile, config] of Object.entries(game.profiles)) {
|
|
3870
|
-
const notes = [];
|
|
3871
|
-
if (config.alias)
|
|
3872
|
-
notes.push(`alias for ${config.alias}`);
|
|
3873
|
-
if (config.extends)
|
|
3874
|
-
notes.push(`extends ${config.extends}`);
|
|
3875
|
-
if (config.autoDependencies)
|
|
3876
|
-
notes.push("auto dependencies");
|
|
3877
|
-
const count = config.mods?.length ?? 0;
|
|
3878
|
-
if (!config.alias)
|
|
3879
|
-
notes.push(count === 1 ? "1 entry" : `${count} entries`);
|
|
3880
|
-
if (config.aliases?.length)
|
|
3881
|
-
notes.push(`aka ${config.aliases.join(", ")}`);
|
|
3882
|
-
rows.push([profile, notes.join(", ")]);
|
|
3883
|
-
}
|
|
3984
|
+
const rows = Object.entries(game.profiles).map(([profile, config]) => [profile, profileNotes2(config)]);
|
|
3884
3985
|
if (rows.length === 0)
|
|
3885
3986
|
rows.push(["(none declared)", ""]);
|
|
3886
3987
|
lines.push(...columns(rows, 2));
|
|
3887
3988
|
if (game.aliases && Object.keys(game.aliases).length > 0) {
|
|
3888
|
-
lines.push("", "mod name aliases:");
|
|
3889
|
-
lines.push(...columns(Object.entries(game.aliases).map(([k, v]) => [k, v]), 2));
|
|
3989
|
+
lines.push("", "mod name aliases:", ...columns(Object.entries(game.aliases).map(([k, v]) => [k, v]), 2));
|
|
3890
3990
|
}
|
|
3891
|
-
lines.push("", `modes: ${game.modes.join(", ")}`);
|
|
3892
|
-
lines.push(`core: ${game.core}`);
|
|
3991
|
+
lines.push("", `modes: ${game.modes.join(", ")}`, `core: ${game.core}`);
|
|
3893
3992
|
if (game.dlc.length > 0)
|
|
3894
3993
|
lines.push(`dlc: ${game.dlc.join(", ")}`);
|
|
3895
3994
|
lines.push(`game files: ${game.gameFiles.source === "mount" ? game.gameFiles.host ?? "(unset)" : game.image.ref}`);
|
|
@@ -3897,9 +3996,25 @@ function gameHelp(name, game) {
|
|
|
3897
3996
|
`) + `
|
|
3898
3997
|
`;
|
|
3899
3998
|
}
|
|
3999
|
+
function profileNotes2(config) {
|
|
4000
|
+
const notes = [];
|
|
4001
|
+
if (config.alias)
|
|
4002
|
+
notes.push(`alias for ${config.alias}`);
|
|
4003
|
+
if (config.extends)
|
|
4004
|
+
notes.push(`extends ${config.extends}`);
|
|
4005
|
+
if (config.autoDependencies === false)
|
|
4006
|
+
notes.push("no auto dependencies");
|
|
4007
|
+
const count = config.mods?.length ?? 0;
|
|
4008
|
+
if (!config.alias)
|
|
4009
|
+
notes.push(count === 1 ? "1 entry" : `${count} entries`);
|
|
4010
|
+
if (config.aliases?.length)
|
|
4011
|
+
notes.push(`aka ${config.aliases.join(", ")}`);
|
|
4012
|
+
return notes.join(", ");
|
|
4013
|
+
}
|
|
3900
4014
|
function gameSummary(game) {
|
|
3901
4015
|
const count = Object.keys(game.profiles).length;
|
|
3902
|
-
|
|
4016
|
+
const profiles = count === 1 ? "1 profile" : `${count} profiles`;
|
|
4017
|
+
return `${profiles}; modes ${game.modes.join(", ")}`;
|
|
3903
4018
|
}
|
|
3904
4019
|
function flagRow(spec) {
|
|
3905
4020
|
const notes = [];
|
|
@@ -3924,7 +4039,7 @@ function renderCompletion(shell) {
|
|
|
3924
4039
|
const options = flags();
|
|
3925
4040
|
const names = options.flatMap((f) => f.short ? [f.long, f.short] : [f.long]).join(" ");
|
|
3926
4041
|
const valueFlags = options.filter((f) => f.required).map((f) => f.long);
|
|
3927
|
-
const fn = `_${NAME.
|
|
4042
|
+
const fn = `_${NAME.replaceAll("-", "_")}`;
|
|
3928
4043
|
if (shell === "bash") {
|
|
3929
4044
|
const cases = options.filter((f) => f.argChoices).map((f) => ` ${f.long}) COMPREPLY=($(compgen -W "${f.argChoices.join(" ")}" -- "$cur")); return ;;`).join(`
|
|
3930
4045
|
`);
|
|
@@ -3949,12 +4064,13 @@ ${cases}
|
|
|
3949
4064
|
complete -F ${fn} ${NAME}
|
|
3950
4065
|
`;
|
|
3951
4066
|
}
|
|
3952
|
-
const zshVerbs = SUBCOMMANDS.map((s) => ` '${s.name}:${s.summary.
|
|
4067
|
+
const zshVerbs = SUBCOMMANDS.map((s) => ` '${s.name}:${s.summary.replaceAll("'", String.raw`'\''`)}'`).join(`
|
|
3953
4068
|
`);
|
|
3954
4069
|
const zshFlags = options.map((f) => {
|
|
3955
|
-
const desc = f.description.
|
|
4070
|
+
const desc = f.description.replaceAll("'", String.raw`'\''`).replaceAll(/[[\]:]/g, "");
|
|
3956
4071
|
const arg = placeholder(f);
|
|
3957
|
-
const
|
|
4072
|
+
const choices = f.argChoices ? `(${f.argChoices.join(" ")})` : "_files";
|
|
4073
|
+
const value = arg ? `:${arg.replaceAll(/[<>]/g, "")}:${choices}` : "";
|
|
3958
4074
|
const repeat = Array.isArray(f.defaultValue) ? "*" : "";
|
|
3959
4075
|
return ` '${repeat}${f.long}[${desc}]${value}'`;
|
|
3960
4076
|
}).join(`
|
|
@@ -3995,7 +4111,7 @@ function hostUserName(uid) {
|
|
|
3995
4111
|
}
|
|
3996
4112
|
|
|
3997
4113
|
// src/docker/preflight.ts
|
|
3998
|
-
import { existsSync as
|
|
4114
|
+
import { existsSync as existsSync7, readFileSync as readFileSync3 } from "node:fs";
|
|
3999
4115
|
import { homedir as homedir3 } from "node:os";
|
|
4000
4116
|
import { basename as basename6, join as join13 } from "node:path";
|
|
4001
4117
|
var CDI_SPEC = "/etc/cdi/nvidia.yaml";
|
|
@@ -4026,7 +4142,7 @@ async function checkDocker(problems) {
|
|
|
4026
4142
|
return true;
|
|
4027
4143
|
problems.push({
|
|
4028
4144
|
where: "docker",
|
|
4029
|
-
message: `docker is not reachable: ${firstLine(result.stderr) ||
|
|
4145
|
+
message: `docker is not reachable: ${firstLine(result.stderr) || exitCode(result.code)}`,
|
|
4030
4146
|
suggestion: "start the docker daemon, or check that your user is in the docker group"
|
|
4031
4147
|
});
|
|
4032
4148
|
return false;
|
|
@@ -4039,7 +4155,7 @@ async function checkImageRunnable(ref, where, game, problems) {
|
|
|
4039
4155
|
const corrupt = /content store|failed to extract layer|not found/i.test(run.stderr);
|
|
4040
4156
|
problems.push({
|
|
4041
4157
|
where,
|
|
4042
|
-
message: corrupt ? `image ${ref} is present but unrunnable; its layers are missing from the content store` : `image ${ref} is present but failed to start: ${err ||
|
|
4158
|
+
message: corrupt ? `image ${ref} is present but unrunnable; its layers are missing from the content store` : `image ${ref} is present but failed to start: ${err || exitCode(run.code)}`,
|
|
4043
4159
|
suggestion: corrupt ? `docker image rm ${ref} && docker builder prune -f, then gamecrate build ${game}` : undefined
|
|
4044
4160
|
});
|
|
4045
4161
|
}
|
|
@@ -4073,7 +4189,7 @@ async function checkImage(plan, problems) {
|
|
|
4073
4189
|
}
|
|
4074
4190
|
problems.push({
|
|
4075
4191
|
where,
|
|
4076
|
-
message: `image ${image.ref} is not present locally and cannot be pulled: ${firstLine(remote.stderr) ||
|
|
4192
|
+
message: `image ${image.ref} is not present locally and cannot be pulled: ${firstLine(remote.stderr) || exitCode(remote.code)}`,
|
|
4077
4193
|
suggestion: host ? `docker login ${host}` : undefined
|
|
4078
4194
|
});
|
|
4079
4195
|
}
|
|
@@ -4097,7 +4213,7 @@ function checkDisplay(plan, problems) {
|
|
|
4097
4213
|
});
|
|
4098
4214
|
}
|
|
4099
4215
|
function checkCdi(problems) {
|
|
4100
|
-
if (!
|
|
4216
|
+
if (!existsSync7(CDI_SPEC)) {
|
|
4101
4217
|
problems.push({
|
|
4102
4218
|
where: CDI_SPEC,
|
|
4103
4219
|
message: "no CDI spec, so --device nvidia.com/gpu=all cannot resolve",
|
|
@@ -4112,7 +4228,7 @@ function checkCdi(problems) {
|
|
|
4112
4228
|
problems.push({ where: CDI_SPEC, message: `CDI spec is unreadable: ${message(error)}` });
|
|
4113
4229
|
return;
|
|
4114
4230
|
}
|
|
4115
|
-
if (
|
|
4231
|
+
if (!/^[ \t]*(?:-[ \t]*)?name:[ \t]*["']?all["']?[ \t]*$/m.test(text)) {
|
|
4116
4232
|
problems.push({
|
|
4117
4233
|
where: CDI_SPEC,
|
|
4118
4234
|
message: 'CDI spec does not declare a device named "all"',
|
|
@@ -4129,12 +4245,12 @@ function checkGameDir(plan, problems) {
|
|
|
4129
4245
|
problems.push({ where, message: 'gameFiles.source is "mount" but no host path is set' });
|
|
4130
4246
|
return;
|
|
4131
4247
|
}
|
|
4132
|
-
if (!
|
|
4248
|
+
if (!existsSync7(files.host)) {
|
|
4133
4249
|
problems.push({ where, message: `game directory does not exist: ${files.host}` });
|
|
4134
4250
|
return;
|
|
4135
4251
|
}
|
|
4136
4252
|
const executable = join13(files.host, basename6(plan.gameConfig.executable));
|
|
4137
|
-
if (!
|
|
4253
|
+
if (!existsSync7(executable)) {
|
|
4138
4254
|
problems.push({
|
|
4139
4255
|
where,
|
|
4140
4256
|
message: `${files.host} does not contain ${basename6(plan.gameConfig.executable)}`,
|
|
@@ -4157,7 +4273,7 @@ function checkBindSources(plan, problems) {
|
|
|
4157
4273
|
for (const mount of mounts) {
|
|
4158
4274
|
if (mount.type !== "bind" || !mount.source)
|
|
4159
4275
|
continue;
|
|
4160
|
-
if (
|
|
4276
|
+
if (existsSync7(mount.source))
|
|
4161
4277
|
continue;
|
|
4162
4278
|
if (mount.source.startsWith(plan.profileDir))
|
|
4163
4279
|
continue;
|
|
@@ -4189,6 +4305,9 @@ function hasStoredAuth(host) {
|
|
|
4189
4305
|
return false;
|
|
4190
4306
|
}
|
|
4191
4307
|
}
|
|
4308
|
+
function exitCode(code) {
|
|
4309
|
+
return `exit ${code}`;
|
|
4310
|
+
}
|
|
4192
4311
|
function firstLine(text) {
|
|
4193
4312
|
return text.trim().split(`
|
|
4194
4313
|
`)[0]?.trim() ?? "";
|
|
@@ -4248,27 +4367,37 @@ async function adoptNewWindow(opts) {
|
|
|
4248
4367
|
}
|
|
4249
4368
|
const seen = new Set(before.map((w) => w.id));
|
|
4250
4369
|
let stopped = false;
|
|
4251
|
-
(
|
|
4252
|
-
|
|
4253
|
-
|
|
4254
|
-
|
|
4255
|
-
if (stopped)
|
|
4256
|
-
return;
|
|
4257
|
-
for (const match of newMatches(await toplevels() ?? [], seen, opts.executable)) {
|
|
4258
|
-
if (stopped)
|
|
4259
|
-
return;
|
|
4260
|
-
if (await claimedByPeer(match.id))
|
|
4261
|
-
continue;
|
|
4262
|
-
if (!await adopt(match.id, opts))
|
|
4263
|
-
continue;
|
|
4264
|
-
await capture(["wmctrl", "-i", "-r", match.id, "-N", opts.title]);
|
|
4265
|
-
if (opts.stripDelete)
|
|
4266
|
-
await watchForClose(match.id, () => stopped, opts.onClosed);
|
|
4267
|
-
return;
|
|
4268
|
-
}
|
|
4370
|
+
pollForWindow(seen, opts, () => stopped);
|
|
4371
|
+
return {
|
|
4372
|
+
stop: () => {
|
|
4373
|
+
stopped = true;
|
|
4269
4374
|
}
|
|
4270
|
-
}
|
|
4271
|
-
|
|
4375
|
+
};
|
|
4376
|
+
}
|
|
4377
|
+
async function pollForWindow(seen, opts, stopped) {
|
|
4378
|
+
const deadline = Date.now() + WAIT_MS;
|
|
4379
|
+
while (!stopped() && Date.now() < deadline) {
|
|
4380
|
+
await sleep5(POLL_MS);
|
|
4381
|
+
if (stopped())
|
|
4382
|
+
return;
|
|
4383
|
+
if (await adoptFirstMatch(seen, opts, stopped))
|
|
4384
|
+
return;
|
|
4385
|
+
}
|
|
4386
|
+
}
|
|
4387
|
+
async function adoptFirstMatch(seen, opts, stopped) {
|
|
4388
|
+
for (const match of newMatches(await toplevels() ?? [], seen, opts.executable)) {
|
|
4389
|
+
if (stopped())
|
|
4390
|
+
return true;
|
|
4391
|
+
if (await claimedByPeer(match.id))
|
|
4392
|
+
continue;
|
|
4393
|
+
if (!await adopt(match.id, opts))
|
|
4394
|
+
continue;
|
|
4395
|
+
await capture(["wmctrl", "-i", "-r", match.id, "-N", opts.title]);
|
|
4396
|
+
if (opts.stripDelete)
|
|
4397
|
+
await watchForClose(match.id, stopped, opts.onClosed);
|
|
4398
|
+
return true;
|
|
4399
|
+
}
|
|
4400
|
+
return false;
|
|
4272
4401
|
}
|
|
4273
4402
|
async function watchForClose(id, stopped, onClosed) {
|
|
4274
4403
|
let misses = 0;
|
|
@@ -4327,12 +4456,12 @@ async function dropDeleteProtocol(id, atoms) {
|
|
|
4327
4456
|
}
|
|
4328
4457
|
function parseAtoms(stdout) {
|
|
4329
4458
|
const list = stdout.slice(stdout.indexOf(":") + 1).replace(/^\s*protocols\s*/, "");
|
|
4330
|
-
return list.split(",").map((atom) => atom.trim()).filter((atom) => /^[A-Za-z_]
|
|
4459
|
+
return list.split(",").map((atom) => atom.trim()).filter((atom) => /^[A-Za-z_]\w*$/.test(atom));
|
|
4331
4460
|
}
|
|
4332
4461
|
|
|
4333
4462
|
// src/launch/generate.ts
|
|
4334
|
-
import { mkdir as mkdir3, readdir as
|
|
4335
|
-
import { dirname as
|
|
4463
|
+
import { mkdir as mkdir3, readdir as readdir7, readFile as readFile8, writeFile as writeFile4 } from "node:fs/promises";
|
|
4464
|
+
import { dirname as dirname8, join as join14 } from "node:path";
|
|
4336
4465
|
async function readInstallVersion(game, plugin) {
|
|
4337
4466
|
const host = game.gameFiles.host;
|
|
4338
4467
|
if (game.gameFiles.source !== "mount" || host === undefined)
|
|
@@ -4354,7 +4483,7 @@ async function readKnownExpansions(game, plugin, warnings) {
|
|
|
4354
4483
|
const dataDir = join14(expandHome(host), "Data");
|
|
4355
4484
|
let entries;
|
|
4356
4485
|
try {
|
|
4357
|
-
entries = await
|
|
4486
|
+
entries = await readdir7(dataDir, { withFileTypes: true });
|
|
4358
4487
|
} catch {
|
|
4359
4488
|
warnings.push(`could not read ${dataDir}; falling back to the configured dlc list`);
|
|
4360
4489
|
return [...game.dlc];
|
|
@@ -4382,7 +4511,7 @@ async function readKnownExpansions(game, plugin, warnings) {
|
|
|
4382
4511
|
async function generateModsConfig(plan) {
|
|
4383
4512
|
const game = plan.gameConfig;
|
|
4384
4513
|
const target = join14(plan.dataDirHost, game.modsConfig.file);
|
|
4385
|
-
await mkdir3(
|
|
4514
|
+
await mkdir3(dirname8(target), { recursive: true });
|
|
4386
4515
|
const installed = await readInstallVersion(game, plan.plugin);
|
|
4387
4516
|
if (installed === null) {
|
|
4388
4517
|
plan.warnings.push(`could not read Version.txt for ${plan.game}; ModsConfig version may be rejected`);
|
|
@@ -4433,7 +4562,7 @@ function ownedPrefs(plan) {
|
|
|
4433
4562
|
}
|
|
4434
4563
|
async function mergePrefs(plan) {
|
|
4435
4564
|
const target = join14(plan.dataDirHost, plan.gameConfig.prefs.file);
|
|
4436
|
-
await mkdir3(
|
|
4565
|
+
await mkdir3(dirname8(target), { recursive: true });
|
|
4437
4566
|
let existing = null;
|
|
4438
4567
|
try {
|
|
4439
4568
|
existing = await readFile8(target, "utf8");
|
|
@@ -4446,8 +4575,8 @@ async function mergePrefs(plan) {
|
|
|
4446
4575
|
}
|
|
4447
4576
|
|
|
4448
4577
|
// src/launch/supervisor.ts
|
|
4449
|
-
import { existsSync as
|
|
4450
|
-
import { readFile as readFile9, readlink, rm as
|
|
4578
|
+
import { existsSync as existsSync8 } from "node:fs";
|
|
4579
|
+
import { readFile as readFile9, readlink, rm as rm3, writeFile as writeFile5 } from "node:fs/promises";
|
|
4451
4580
|
import { basename as basename8, join as join15 } from "node:path";
|
|
4452
4581
|
import { setTimeout as sleep6 } from "node:timers/promises";
|
|
4453
4582
|
async function forkSupervisor(plan, argv) {
|
|
@@ -4506,7 +4635,7 @@ async function supervisorFailed(dir, code) {
|
|
|
4506
4635
|
const record = { at: new Date().toISOString(), code, reason: "failed" };
|
|
4507
4636
|
const wrote = await writeExit(dir, record).then(() => true, () => false);
|
|
4508
4637
|
if (wrote)
|
|
4509
|
-
await
|
|
4638
|
+
await rm3(join15(dir, ".gamecrate", "lock"), { force: true }).catch(() => {});
|
|
4510
4639
|
return code;
|
|
4511
4640
|
}
|
|
4512
4641
|
async function writeExit(instanceDir, record) {
|
|
@@ -4564,7 +4693,7 @@ async function awaitRunLog(instanceDir, lock, poll = RUN_LOG_POLL_MS, notice = W
|
|
|
4564
4693
|
});
|
|
4565
4694
|
const began = target === undefined ? undefined : runStartedAt(basename8(target));
|
|
4566
4695
|
const current = began !== undefined && (Number.isNaN(written) || began >= written);
|
|
4567
|
-
if (current &&
|
|
4696
|
+
if (current && existsSync8(currentLog(instanceDir)))
|
|
4568
4697
|
return true;
|
|
4569
4698
|
if (!isRunning(lock.pid, lock.startedAt))
|
|
4570
4699
|
return false;
|
|
@@ -4584,7 +4713,7 @@ import { basename as basename9, join as join16 } from "node:path";
|
|
|
4584
4713
|
|
|
4585
4714
|
// src/mods/worktree.ts
|
|
4586
4715
|
import { spawnSync as spawnSync3 } from "node:child_process";
|
|
4587
|
-
import { existsSync as
|
|
4716
|
+
import { existsSync as existsSync9, realpathSync as realpathSync2 } from "node:fs";
|
|
4588
4717
|
import { isAbsolute as isAbsolute2, resolve as resolve5, sep } from "node:path";
|
|
4589
4718
|
function inspect(dir) {
|
|
4590
4719
|
const r = spawnSync3("git", ["-C", dir, "rev-parse", "--path-format=absolute", "--show-toplevel", "--git-dir", "--git-common-dir", "--abbrev-ref", "HEAD"], { encoding: "utf8" });
|
|
@@ -4607,7 +4736,7 @@ function canonical(p) {
|
|
|
4607
4736
|
function resolveWorktree(dir, source, order) {
|
|
4608
4737
|
const raw = expandHome(dir);
|
|
4609
4738
|
const abs = isAbsolute2(raw) ? raw : resolve5(process.cwd(), raw);
|
|
4610
|
-
if (!
|
|
4739
|
+
if (!existsSync9(abs)) {
|
|
4611
4740
|
return { where: abs, message: `--worktree path does not exist`, suggestion: "check the path, or drop the flag" };
|
|
4612
4741
|
}
|
|
4613
4742
|
const info = inspect(abs);
|
|
@@ -4693,7 +4822,9 @@ function derive(requests) {
|
|
|
4693
4822
|
return `${slug2(first.root)}-${digest.slice(0, 6)}`;
|
|
4694
4823
|
}
|
|
4695
4824
|
function slug2(root) {
|
|
4696
|
-
|
|
4825
|
+
let body = basename9(root).toLowerCase().replaceAll(/[^a-z0-9._-]+/g, "-").replace(/^[^a-z0-9]+/, "").slice(0, SLUG_LIMIT2);
|
|
4826
|
+
while (body !== "" && "-._".includes(body.slice(-1)))
|
|
4827
|
+
body = body.slice(0, -1);
|
|
4697
4828
|
return body === "" ? "wt" : body;
|
|
4698
4829
|
}
|
|
4699
4830
|
|
|
@@ -4702,16 +4833,16 @@ import { join as join18 } from "node:path";
|
|
|
4702
4833
|
|
|
4703
4834
|
// src/mods/modindex.ts
|
|
4704
4835
|
import { createHash as createHash3 } from "node:crypto";
|
|
4705
|
-
import { existsSync as
|
|
4706
|
-
import { mkdir as mkdir4, readdir as
|
|
4836
|
+
import { existsSync as existsSync10, readFileSync as readFileSync5, statSync as statSync3 } from "node:fs";
|
|
4837
|
+
import { mkdir as mkdir4, readdir as readdir8, readFile as readFile10, writeFile as writeFile6 } from "node:fs/promises";
|
|
4707
4838
|
import { homedir as homedir4 } from "node:os";
|
|
4708
|
-
import { dirname as
|
|
4839
|
+
import { dirname as dirname9, join as join17, relative as relative3, sep as sep2, resolve as resolvePath } from "node:path";
|
|
4709
4840
|
import picomatch from "picomatch";
|
|
4710
4841
|
function cacheDir() {
|
|
4711
4842
|
return join17(process.env["XDG_CACHE_HOME"] ?? join17(homedir4(), ".cache"), "gamecrate");
|
|
4712
4843
|
}
|
|
4713
4844
|
function globMatch(pattern, path) {
|
|
4714
|
-
return picomatch.isMatch(path, pattern.
|
|
4845
|
+
return picomatch.isMatch(path, pattern.replaceAll(/[[\]{}()!,@+|^$.\\]/g, String.raw`\$&`), { dot: true });
|
|
4715
4846
|
}
|
|
4716
4847
|
function excluded(patterns, relativePath) {
|
|
4717
4848
|
return patterns.some((p) => globMatch(p, relativePath) || globMatch(p, `${relativePath}/`));
|
|
@@ -4721,7 +4852,7 @@ function inLinkedWorktree(dir, stopAt) {
|
|
|
4721
4852
|
let current = dir;
|
|
4722
4853
|
for (;; ) {
|
|
4723
4854
|
const git = join17(current, ".git");
|
|
4724
|
-
if (
|
|
4855
|
+
if (existsSync10(git)) {
|
|
4725
4856
|
try {
|
|
4726
4857
|
if (statSync3(git).isFile())
|
|
4727
4858
|
return true;
|
|
@@ -4732,7 +4863,7 @@ function inLinkedWorktree(dir, stopAt) {
|
|
|
4732
4863
|
}
|
|
4733
4864
|
if (current === stopAt)
|
|
4734
4865
|
return false;
|
|
4735
|
-
const parent =
|
|
4866
|
+
const parent = dirname9(current);
|
|
4736
4867
|
if (parent === current)
|
|
4737
4868
|
return false;
|
|
4738
4869
|
current = parent;
|
|
@@ -4743,7 +4874,7 @@ async function scanLocalRoot(root, rootIndex, manifestFile, found) {
|
|
|
4743
4874
|
const exclude = [...root.exclude ?? [], ...rootIndex === -1 ? [] : ALWAYS_EXCLUDE];
|
|
4744
4875
|
const walk = async (dir, depth) => {
|
|
4745
4876
|
const manifest = join17(dir, manifestFile);
|
|
4746
|
-
if (
|
|
4877
|
+
if (existsSync10(manifest)) {
|
|
4747
4878
|
found.push({
|
|
4748
4879
|
dir,
|
|
4749
4880
|
kind: "local",
|
|
@@ -4756,7 +4887,7 @@ async function scanLocalRoot(root, rootIndex, manifestFile, found) {
|
|
|
4756
4887
|
return;
|
|
4757
4888
|
let entries;
|
|
4758
4889
|
try {
|
|
4759
|
-
entries = await
|
|
4890
|
+
entries = await readdir8(dir, { withFileTypes: true });
|
|
4760
4891
|
} catch {
|
|
4761
4892
|
return;
|
|
4762
4893
|
}
|
|
@@ -4769,7 +4900,7 @@ async function scanLocalRoot(root, rootIndex, manifestFile, found) {
|
|
|
4769
4900
|
await walk(child, depth + 1);
|
|
4770
4901
|
}
|
|
4771
4902
|
};
|
|
4772
|
-
if (!
|
|
4903
|
+
if (!existsSync10(base))
|
|
4773
4904
|
return;
|
|
4774
4905
|
await walk(base, 0);
|
|
4775
4906
|
}
|
|
@@ -4777,7 +4908,7 @@ async function scanWorkshopRoot(workshopRoot, rootIndex, manifestFile, found) {
|
|
|
4777
4908
|
const base = resolvePath(expandHome(workshopRoot));
|
|
4778
4909
|
let entries;
|
|
4779
4910
|
try {
|
|
4780
|
-
entries = await
|
|
4911
|
+
entries = await readdir8(base, { withFileTypes: true });
|
|
4781
4912
|
} catch {
|
|
4782
4913
|
return;
|
|
4783
4914
|
}
|
|
@@ -4785,7 +4916,7 @@ async function scanWorkshopRoot(workshopRoot, rootIndex, manifestFile, found) {
|
|
|
4785
4916
|
if (!entry.isDirectory() || !/^\d+$/.test(entry.name))
|
|
4786
4917
|
continue;
|
|
4787
4918
|
const dir = join17(base, entry.name);
|
|
4788
|
-
if (!
|
|
4919
|
+
if (!existsSync10(join17(dir, manifestFile)))
|
|
4789
4920
|
continue;
|
|
4790
4921
|
found.push({ dir, kind: "workshop", rootIndex, linkedWorktree: false, workshopId: Number(entry.name) });
|
|
4791
4922
|
}
|
|
@@ -4797,7 +4928,7 @@ async function scanGameData(game, rootIndex, found) {
|
|
|
4797
4928
|
const data = join17(resolvePath(expandHome(host)), "Data");
|
|
4798
4929
|
let entries;
|
|
4799
4930
|
try {
|
|
4800
|
-
entries = await
|
|
4931
|
+
entries = await readdir8(data, { withFileTypes: true });
|
|
4801
4932
|
} catch {
|
|
4802
4933
|
return;
|
|
4803
4934
|
}
|
|
@@ -4805,14 +4936,14 @@ async function scanGameData(game, rootIndex, found) {
|
|
|
4805
4936
|
if (!entry.isDirectory())
|
|
4806
4937
|
continue;
|
|
4807
4938
|
const dir = join17(data, entry.name);
|
|
4808
|
-
if (!
|
|
4939
|
+
if (!existsSync10(join17(dir, game.manifest.file)))
|
|
4809
4940
|
continue;
|
|
4810
4941
|
found.push({ dir, kind: "official", rootIndex, linkedWorktree: false });
|
|
4811
4942
|
}
|
|
4812
4943
|
}
|
|
4813
4944
|
var CACHE_VERSION = 4;
|
|
4814
4945
|
function acfPath2(root, steamAppId) {
|
|
4815
|
-
return join17(
|
|
4946
|
+
return join17(dirname9(dirname9(resolvePath(expandHome(root)))), `appworkshop_${steamAppId}.acf`);
|
|
4816
4947
|
}
|
|
4817
4948
|
function contentPairs(acf) {
|
|
4818
4949
|
let text;
|
|
@@ -4836,7 +4967,7 @@ function workshopStamp(game, dataRoot) {
|
|
|
4836
4967
|
return null;
|
|
4837
4968
|
pairs.push(...found);
|
|
4838
4969
|
}
|
|
4839
|
-
const download = createHash3("
|
|
4970
|
+
const download = createHash3("sha256").update(pairs.toSorted().join(`
|
|
4840
4971
|
`)).digest("hex");
|
|
4841
4972
|
if (game.workshopRoot === null)
|
|
4842
4973
|
return download;
|
|
@@ -4897,7 +5028,7 @@ function compareTiers(a, b) {
|
|
|
4897
5028
|
return 0;
|
|
4898
5029
|
}
|
|
4899
5030
|
function rank(a, b) {
|
|
4900
|
-
return compareTiers(a, b) || byClone(a, b) || (a.dir
|
|
5031
|
+
return compareTiers(a, b) || byClone(a, b) || Number(a.dir > b.dir) - Number(a.dir < b.dir);
|
|
4901
5032
|
}
|
|
4902
5033
|
function byClone(a, b) {
|
|
4903
5034
|
if (a.clonedAt === undefined || b.clonedAt === undefined)
|
|
@@ -4911,13 +5042,7 @@ async function applyWorktreeRequests(index, requests, config) {
|
|
|
4911
5042
|
for (const bucket of index.byPackageId.values()) {
|
|
4912
5043
|
for (const record of bucket) {
|
|
4913
5044
|
known.add(record.dir);
|
|
4914
|
-
|
|
4915
|
-
if (!contains(request, record.dir))
|
|
4916
|
-
continue;
|
|
4917
|
-
record.worktree = { root: request.root, branch: request.branch, source: request.source };
|
|
4918
|
-
record.selectedWorktree = request.order;
|
|
4919
|
-
break;
|
|
4920
|
-
}
|
|
5045
|
+
stampOwner(record, requests);
|
|
4921
5046
|
}
|
|
4922
5047
|
}
|
|
4923
5048
|
for (const request of requests) {
|
|
@@ -4927,14 +5052,25 @@ async function applyWorktreeRequests(index, requests, config) {
|
|
|
4927
5052
|
if (fresh.length === 0)
|
|
4928
5053
|
continue;
|
|
4929
5054
|
for (const record of await parseAll(fresh, config, index.plugin, index.problems)) {
|
|
4930
|
-
record
|
|
4931
|
-
record.selectedWorktree = request.order;
|
|
5055
|
+
markWorktree(record, request);
|
|
4932
5056
|
insert(index, record);
|
|
4933
5057
|
}
|
|
4934
5058
|
}
|
|
4935
5059
|
for (const bucket of index.byPackageId.values())
|
|
4936
5060
|
bucket.sort(rank);
|
|
4937
5061
|
}
|
|
5062
|
+
function stampOwner(record, requests) {
|
|
5063
|
+
for (const request of requests) {
|
|
5064
|
+
if (!contains(request, record.dir))
|
|
5065
|
+
continue;
|
|
5066
|
+
markWorktree(record, request);
|
|
5067
|
+
return;
|
|
5068
|
+
}
|
|
5069
|
+
}
|
|
5070
|
+
function markWorktree(record, request) {
|
|
5071
|
+
record.worktree = { root: request.root, branch: request.branch, source: request.source };
|
|
5072
|
+
record.selectedWorktree = request.order;
|
|
5073
|
+
}
|
|
4938
5074
|
var WORKTREE_SCAN_DEPTH = 5;
|
|
4939
5075
|
var WORKTREE_EXCLUDE = ["**/.retired/**", "**/node_modules/**", "**/bin/**", "**/obj/**"];
|
|
4940
5076
|
async function applySourceOverrides(index, overrides, config) {
|
|
@@ -4950,7 +5086,7 @@ async function applySourceOverrides(index, overrides, config) {
|
|
|
4950
5086
|
const wanted = spec.slice(0, eq);
|
|
4951
5087
|
const dir = resolvePath(expandHome(spec.slice(eq + 1)));
|
|
4952
5088
|
const file = join17(dir, config.manifest.file);
|
|
4953
|
-
if (!
|
|
5089
|
+
if (!existsSync10(file)) {
|
|
4954
5090
|
problems.push({
|
|
4955
5091
|
where: spec,
|
|
4956
5092
|
message: `no ${config.manifest.file} under ${dir}`,
|
|
@@ -5020,51 +5156,58 @@ async function buildIndex(game, config, plugin, sourcesDir, dataRoot) {
|
|
|
5020
5156
|
byShortName: new Map,
|
|
5021
5157
|
problems: []
|
|
5022
5158
|
};
|
|
5159
|
+
const cacheIndex = config.scanRoots.length;
|
|
5160
|
+
await indexLocal(index, config, sourcesDir, cacheIndex);
|
|
5161
|
+
await indexWorkshop(index, config, dataRoot, cacheIndex);
|
|
5162
|
+
for (const bucket of index.byPackageId.values())
|
|
5163
|
+
bucket.sort(rank);
|
|
5164
|
+
return index;
|
|
5165
|
+
}
|
|
5166
|
+
async function indexLocal(index, config, sourcesDir, cacheIndex) {
|
|
5023
5167
|
const local = [];
|
|
5024
5168
|
await scanGameData(config, -1, local);
|
|
5025
5169
|
for (const [i, root] of config.scanRoots.entries()) {
|
|
5026
5170
|
await scanLocalRoot(root, i, config.manifest.file, local);
|
|
5027
5171
|
}
|
|
5028
|
-
const
|
|
5029
|
-
const parsed = await parseAll(local, config, plugin, index.problems);
|
|
5172
|
+
const parsed = await parseAll(local, config, index.plugin, index.problems);
|
|
5030
5173
|
if (sourcesDir !== undefined) {
|
|
5031
5174
|
const cache = [];
|
|
5032
5175
|
await scanLocalRoot({ path: sourcesDir, maxDepth: 4 }, cacheIndex, config.manifest.file, cache);
|
|
5033
|
-
|
|
5176
|
+
const records = await parseAll(cache, config, index.plugin, index.problems);
|
|
5177
|
+
parsed.push(...oneModPerClone(records, sourcesDir));
|
|
5034
5178
|
}
|
|
5035
5179
|
for (const record of parsed)
|
|
5036
5180
|
insert(index, record);
|
|
5037
|
-
|
|
5181
|
+
}
|
|
5182
|
+
async function indexWorkshop(index, config, dataRoot, cacheIndex) {
|
|
5183
|
+
const roots = [];
|
|
5038
5184
|
if (dataRoot !== undefined)
|
|
5039
|
-
|
|
5185
|
+
roots.push(downloadRoot(dataRoot, config));
|
|
5040
5186
|
if (config.workshopRoot !== null)
|
|
5041
|
-
|
|
5042
|
-
if (
|
|
5043
|
-
|
|
5044
|
-
|
|
5045
|
-
|
|
5046
|
-
|
|
5047
|
-
|
|
5048
|
-
|
|
5049
|
-
|
|
5050
|
-
for (const [i, root] of workshopRoots.entries()) {
|
|
5051
|
-
await scanWorkshopRoot(root, cacheIndex + 1 + i, config.manifest.file, items);
|
|
5052
|
-
}
|
|
5053
|
-
const records = await parseAll(items, config, plugin, index.problems);
|
|
5054
|
-
for (const record of records)
|
|
5055
|
-
insert(index, record);
|
|
5056
|
-
if (stamp !== null)
|
|
5057
|
-
await writeWorkshopCache(game, stamp, records);
|
|
5058
|
-
}
|
|
5187
|
+
roots.push(config.workshopRoot);
|
|
5188
|
+
if (roots.length === 0)
|
|
5189
|
+
return;
|
|
5190
|
+
const stamp = workshopStamp(config, dataRoot);
|
|
5191
|
+
const cached = stamp === null ? null : await readWorkshopCache(index.game, stamp);
|
|
5192
|
+
if (cached) {
|
|
5193
|
+
for (const record of cached)
|
|
5194
|
+
insert(index, record);
|
|
5195
|
+
return;
|
|
5059
5196
|
}
|
|
5060
|
-
|
|
5061
|
-
|
|
5062
|
-
|
|
5197
|
+
const items = [];
|
|
5198
|
+
for (const [i, root] of roots.entries()) {
|
|
5199
|
+
await scanWorkshopRoot(root, cacheIndex + 1 + i, config.manifest.file, items);
|
|
5200
|
+
}
|
|
5201
|
+
const records = await parseAll(items, config, index.plugin, index.problems);
|
|
5202
|
+
for (const record of records)
|
|
5203
|
+
insert(index, record);
|
|
5204
|
+
if (stamp !== null)
|
|
5205
|
+
await writeWorkshopCache(index.game, stamp, records);
|
|
5063
5206
|
}
|
|
5064
5207
|
function oneModPerClone(records, sourcesDir) {
|
|
5065
5208
|
const kept = new Map;
|
|
5066
5209
|
const stamps = new Map;
|
|
5067
|
-
for (const record of [...records].sort((a, b) => a.dir
|
|
5210
|
+
for (const record of [...records].sort((a, b) => Number(a.dir > b.dir) - Number(a.dir < b.dir))) {
|
|
5068
5211
|
const clone = relative3(sourcesDir, record.dir).split(sep2).slice(0, 2).join(sep2);
|
|
5069
5212
|
const at = join17(sourcesDir, clone);
|
|
5070
5213
|
let stamp = stamps.get(at);
|
|
@@ -5119,9 +5262,9 @@ function resolveRaw(index, ref, game) {
|
|
|
5119
5262
|
return resolveRaw(index, alias, game);
|
|
5120
5263
|
}
|
|
5121
5264
|
const short = index.byShortName.get(ref.toLowerCase());
|
|
5122
|
-
if (short
|
|
5265
|
+
if (short?.length === 1)
|
|
5123
5266
|
return pick(index, short[0].toLowerCase(), ref);
|
|
5124
|
-
if (short && short.length > 1) {
|
|
5267
|
+
if (short !== undefined && short.length > 1) {
|
|
5125
5268
|
throw new GamecrateError(`"${ref}" is a short name for ${short.length} mods`, Exit.Resolution, short.join(", "));
|
|
5126
5269
|
}
|
|
5127
5270
|
return null;
|
|
@@ -5155,7 +5298,7 @@ function byPath(index, raw, game) {
|
|
|
5155
5298
|
return hit;
|
|
5156
5299
|
}
|
|
5157
5300
|
const file = join17(dir, game.manifest.file);
|
|
5158
|
-
if (!
|
|
5301
|
+
if (!existsSync10(file))
|
|
5159
5302
|
return null;
|
|
5160
5303
|
try {
|
|
5161
5304
|
const manifest = index.plugin.parseManifest(readFileSync5(file, "utf8"));
|
|
@@ -5215,7 +5358,7 @@ function expandDynamic(entry, index, where, problems) {
|
|
|
5215
5358
|
const head = matched.filter((id) => firstOrder.includes(id.toLowerCase())).sort((a, b) => firstOrder.indexOf(a.toLowerCase()) - firstOrder.indexOf(b.toLowerCase()));
|
|
5216
5359
|
const rest = matched.filter((id) => !firstOrder.includes(id.toLowerCase()));
|
|
5217
5360
|
if ((entry.sort ?? "alpha") === "alpha") {
|
|
5218
|
-
rest.sort((a, b) => a.toLowerCase()
|
|
5361
|
+
rest.sort((a, b) => Number(a.toLowerCase() > b.toLowerCase()) - Number(a.toLowerCase() < b.toLowerCase()));
|
|
5219
5362
|
}
|
|
5220
5363
|
return [...head, ...rest];
|
|
5221
5364
|
}
|
|
@@ -5272,6 +5415,42 @@ function topoSort(list, problems, core) {
|
|
|
5272
5415
|
const position = new Map;
|
|
5273
5416
|
for (const [i, mod] of list.entries())
|
|
5274
5417
|
position.set(mod.record.packageId.toLowerCase(), i);
|
|
5418
|
+
const { indegree, outgoing, incoming } = graph(list, position);
|
|
5419
|
+
const coreIndex = core === undefined ? undefined : position.get(core.toLowerCase());
|
|
5420
|
+
const preCore = reachesCore(incoming, coreIndex);
|
|
5421
|
+
const phase = (i) => preCore.has(i) ? 0 : 1;
|
|
5422
|
+
const sorted = [];
|
|
5423
|
+
const done = list.map(() => false);
|
|
5424
|
+
for (;; ) {
|
|
5425
|
+
const next = nextReady(done, indegree, phase);
|
|
5426
|
+
if (next === -1)
|
|
5427
|
+
break;
|
|
5428
|
+
done[next] = true;
|
|
5429
|
+
sorted.push(list[next]);
|
|
5430
|
+
for (const to of outgoing[next])
|
|
5431
|
+
indegree[to] -= 1;
|
|
5432
|
+
}
|
|
5433
|
+
const cycle = list.filter((_, i) => !done[i]);
|
|
5434
|
+
if (cycle.length > 0) {
|
|
5435
|
+
problems.push({
|
|
5436
|
+
where: "flag --sort topo",
|
|
5437
|
+
message: `load-order cycle among ${cycle.map((m) => m.record.packageId).join(", ")}; left in profile order`
|
|
5438
|
+
});
|
|
5439
|
+
sorted.push(...cycle);
|
|
5440
|
+
}
|
|
5441
|
+
return sorted;
|
|
5442
|
+
}
|
|
5443
|
+
function nextReady(done, indegree, phase) {
|
|
5444
|
+
let next = -1;
|
|
5445
|
+
for (let i = 0;i < done.length; i++) {
|
|
5446
|
+
if (done[i] || indegree[i] !== 0)
|
|
5447
|
+
continue;
|
|
5448
|
+
if (next === -1 || phase(i) < phase(next))
|
|
5449
|
+
next = i;
|
|
5450
|
+
}
|
|
5451
|
+
return next;
|
|
5452
|
+
}
|
|
5453
|
+
function graph(list, position) {
|
|
5275
5454
|
const edges = new Set;
|
|
5276
5455
|
const addEdge = (from, to) => {
|
|
5277
5456
|
if (from === undefined || to === undefined || from === to)
|
|
@@ -5294,46 +5473,22 @@ function topoSort(list, problems, core) {
|
|
|
5294
5473
|
incoming[to].push(from);
|
|
5295
5474
|
indegree[to] += 1;
|
|
5296
5475
|
}
|
|
5476
|
+
return { indegree, outgoing, incoming };
|
|
5477
|
+
}
|
|
5478
|
+
function reachesCore(incoming, coreIndex) {
|
|
5297
5479
|
const preCore = new Set;
|
|
5298
|
-
|
|
5299
|
-
|
|
5300
|
-
|
|
5301
|
-
|
|
5302
|
-
|
|
5303
|
-
|
|
5304
|
-
continue;
|
|
5305
|
-
preCore.add(from);
|
|
5306
|
-
queue.push(from);
|
|
5307
|
-
}
|
|
5308
|
-
}
|
|
5309
|
-
}
|
|
5310
|
-
const phase = (i) => preCore.has(i) ? 0 : 1;
|
|
5311
|
-
const sorted = [];
|
|
5312
|
-
const done = list.map(() => false);
|
|
5313
|
-
for (;; ) {
|
|
5314
|
-
let next = -1;
|
|
5315
|
-
for (let i = 0;i < list.length; i++) {
|
|
5316
|
-
if (done[i] || indegree[i] !== 0)
|
|
5480
|
+
if (coreIndex === undefined)
|
|
5481
|
+
return preCore;
|
|
5482
|
+
const queue = [coreIndex];
|
|
5483
|
+
while (queue.length > 0) {
|
|
5484
|
+
for (const from of incoming[queue.pop()]) {
|
|
5485
|
+
if (preCore.has(from))
|
|
5317
5486
|
continue;
|
|
5318
|
-
|
|
5319
|
-
|
|
5487
|
+
preCore.add(from);
|
|
5488
|
+
queue.push(from);
|
|
5320
5489
|
}
|
|
5321
|
-
if (next === -1)
|
|
5322
|
-
break;
|
|
5323
|
-
done[next] = true;
|
|
5324
|
-
sorted.push(list[next]);
|
|
5325
|
-
for (const to of outgoing[next])
|
|
5326
|
-
indegree[to] -= 1;
|
|
5327
5490
|
}
|
|
5328
|
-
|
|
5329
|
-
if (cycle.length > 0) {
|
|
5330
|
-
problems.push({
|
|
5331
|
-
where: "flag --sort topo",
|
|
5332
|
-
message: `load-order cycle among ${cycle.map((m) => m.record.packageId).join(", ")}; left in profile order`
|
|
5333
|
-
});
|
|
5334
|
-
sorted.push(...cycle);
|
|
5335
|
-
}
|
|
5336
|
-
return sorted;
|
|
5491
|
+
return preCore;
|
|
5337
5492
|
}
|
|
5338
5493
|
function incompatibilityWarnings(list) {
|
|
5339
5494
|
const byId = new Map(list.map((mod) => [mod.record.packageId.toLowerCase(), mod.record.packageId]));
|
|
@@ -5353,6 +5508,96 @@ function incompatibilityWarnings(list) {
|
|
|
5353
5508
|
}
|
|
5354
5509
|
return warnings;
|
|
5355
5510
|
}
|
|
5511
|
+
function stageSlots(input) {
|
|
5512
|
+
const { game, gameName, profileName, profile, args, index } = input;
|
|
5513
|
+
const excluded = [...profile.exclude ?? [], ...args.without ?? []].map(globToRegExp);
|
|
5514
|
+
const isExcluded = (id) => excluded.some((pattern) => pattern.test(id));
|
|
5515
|
+
const staged = [];
|
|
5516
|
+
const present = new Set;
|
|
5517
|
+
for (const slot of collectSlots(game, gameName, profileName, profile, args)) {
|
|
5518
|
+
for (const { ref, optional } of slotRefs(slot, input)) {
|
|
5519
|
+
const record = resolveModRef(index, ref, game);
|
|
5520
|
+
if (!record) {
|
|
5521
|
+
reportMissing(slot, ref, optional, input);
|
|
5522
|
+
continue;
|
|
5523
|
+
}
|
|
5524
|
+
const key = record.packageId.toLowerCase();
|
|
5525
|
+
if (present.has(key) || isExcluded(record.packageId))
|
|
5526
|
+
continue;
|
|
5527
|
+
present.add(key);
|
|
5528
|
+
staged.push({ record, explicit: true });
|
|
5529
|
+
}
|
|
5530
|
+
}
|
|
5531
|
+
return { staged, present };
|
|
5532
|
+
}
|
|
5533
|
+
function slotRefs(slot, input) {
|
|
5534
|
+
const { entry } = slot;
|
|
5535
|
+
if (isDynamic(entry)) {
|
|
5536
|
+
return expandDynamic(entry, input.index, slot.where, input.problems).map((id) => ({ ref: id, optional: false }));
|
|
5537
|
+
}
|
|
5538
|
+
return [
|
|
5539
|
+
{
|
|
5540
|
+
ref: refFor(entry, input.game, input.sources),
|
|
5541
|
+
optional: typeof entry !== "string" && entry.optional === true
|
|
5542
|
+
}
|
|
5543
|
+
];
|
|
5544
|
+
}
|
|
5545
|
+
function reportMissing(slot, ref, optional, input) {
|
|
5546
|
+
if (slot.dlc === true)
|
|
5547
|
+
return;
|
|
5548
|
+
if (optional) {
|
|
5549
|
+
input.warnings.push(`optional mod ${ref} is not installed; skipped`);
|
|
5550
|
+
return;
|
|
5551
|
+
}
|
|
5552
|
+
if (ref.startsWith("workshop:") && input.unfetched.has(ref.slice(9))) {
|
|
5553
|
+
input.problems.push({ where: slot.where, message: notFetched(ref.slice(9)) });
|
|
5554
|
+
return;
|
|
5555
|
+
}
|
|
5556
|
+
input.problems.push({ where: slot.where, message: `no mod matches "${ref}"` });
|
|
5557
|
+
}
|
|
5558
|
+
function relabelUnfetched(problems, unfetched) {
|
|
5559
|
+
if (unfetched.size === 0)
|
|
5560
|
+
return;
|
|
5561
|
+
for (const problem of problems) {
|
|
5562
|
+
const id = workshopUrlId(problem.suggestion);
|
|
5563
|
+
if (id !== undefined && unfetched.has(id))
|
|
5564
|
+
problem.message = notFetched(id);
|
|
5565
|
+
}
|
|
5566
|
+
}
|
|
5567
|
+
async function describeMod({ record, explicit }, game, index, warnings) {
|
|
5568
|
+
const times = record.kind === "local" ? await scanBuildTimes(record.dir) : null;
|
|
5569
|
+
const report = times === null ? null : staleReport(times);
|
|
5570
|
+
if (record.worktree) {
|
|
5571
|
+
warnings.push(`${record.packageId} comes from worktree ${record.worktree.branch} (${record.worktree.source}): ${record.dir}`);
|
|
5572
|
+
}
|
|
5573
|
+
const shadowed = (index.byPackageId.get(record.packageId.toLowerCase()) ?? []).filter((other) => other.dir !== record.dir).map((other) => other.dir);
|
|
5574
|
+
return {
|
|
5575
|
+
packageId: record.packageId,
|
|
5576
|
+
hostDir: record.dir,
|
|
5577
|
+
containerDir: `${game.modsDir.container}/${record.packageId}`,
|
|
5578
|
+
kind: record.kind,
|
|
5579
|
+
...record.workshopId === undefined ? {} : { workshopId: record.workshopId },
|
|
5580
|
+
explicit,
|
|
5581
|
+
stale: times === null ? false : decideStale(times),
|
|
5582
|
+
...report === null ? {} : { staleReport: report },
|
|
5583
|
+
...record.worktree === undefined ? {} : { worktree: { ...record.worktree, selected: record.selectedWorktree !== undefined } },
|
|
5584
|
+
...shadowed.length === 0 ? {} : { shadowed }
|
|
5585
|
+
};
|
|
5586
|
+
}
|
|
5587
|
+
function checkDataDir(problems, gameName, game) {
|
|
5588
|
+
if (game.dataDir.mode === "arg" && game.dataDir.arg.split("=").length !== 2) {
|
|
5589
|
+
problems.push({
|
|
5590
|
+
where: `/games/${gameName}/dataDir/arg`,
|
|
5591
|
+
message: `"${game.dataDir.arg}" must contain exactly one "="; RimWorld silently ignores anything else`
|
|
5592
|
+
});
|
|
5593
|
+
}
|
|
5594
|
+
if (game.dataDir.container.includes("=")) {
|
|
5595
|
+
problems.push({
|
|
5596
|
+
where: `/games/${gameName}/dataDir/container`,
|
|
5597
|
+
message: `container data path "${game.dataDir.container}" contains "=", which disables the override silently`
|
|
5598
|
+
});
|
|
5599
|
+
}
|
|
5600
|
+
}
|
|
5356
5601
|
async function resolvePlan(options) {
|
|
5357
5602
|
const { game: gameName, profile: requestedProfile, root } = options;
|
|
5358
5603
|
const args = options.args ?? {};
|
|
@@ -5388,78 +5633,28 @@ async function resolvePlan(options) {
|
|
|
5388
5633
|
const index = options.index ?? await buildIndex(gameName, game, plugin, sourcesRoot(root.dataRoot), root.dataRoot);
|
|
5389
5634
|
await applyWorktreeRequests(index, instance.requests, game);
|
|
5390
5635
|
problems.push(...await applySourceOverrides(index, args.use ?? [], game));
|
|
5391
|
-
const
|
|
5392
|
-
|
|
5393
|
-
|
|
5394
|
-
|
|
5395
|
-
|
|
5396
|
-
|
|
5397
|
-
|
|
5398
|
-
|
|
5399
|
-
|
|
5400
|
-
|
|
5401
|
-
|
|
5402
|
-
|
|
5403
|
-
|
|
5404
|
-
warnings.push(`optional mod ${ref} is not installed; skipped`);
|
|
5405
|
-
else if (ref.startsWith("workshop:") && unfetched.has(ref.slice(9))) {
|
|
5406
|
-
problems.push({ where: slot.where, message: notFetched(ref.slice(9)) });
|
|
5407
|
-
} else
|
|
5408
|
-
problems.push({ where: slot.where, message: `no mod matches "${ref}"` });
|
|
5409
|
-
continue;
|
|
5410
|
-
}
|
|
5411
|
-
const key = record.packageId.toLowerCase();
|
|
5412
|
-
if (present.has(key) || isExcluded(record.packageId))
|
|
5413
|
-
continue;
|
|
5414
|
-
present.add(key);
|
|
5415
|
-
staged.push({ record, explicit: true });
|
|
5416
|
-
}
|
|
5417
|
-
}
|
|
5418
|
-
if (profile.autoDependencies === true)
|
|
5636
|
+
const { staged, present } = stageSlots({
|
|
5637
|
+
game,
|
|
5638
|
+
gameName,
|
|
5639
|
+
profileName,
|
|
5640
|
+
profile,
|
|
5641
|
+
args,
|
|
5642
|
+
index,
|
|
5643
|
+
sources,
|
|
5644
|
+
unfetched,
|
|
5645
|
+
problems,
|
|
5646
|
+
warnings
|
|
5647
|
+
});
|
|
5648
|
+
if (profile.autoDependencies !== false)
|
|
5419
5649
|
insertDependencies(staged, present, index, game, problems);
|
|
5420
|
-
|
|
5421
|
-
for (const problem of problems) {
|
|
5422
|
-
const id = workshopUrlId(problem.suggestion);
|
|
5423
|
-
if (id !== undefined && unfetched.has(id))
|
|
5424
|
-
problem.message = notFetched(id);
|
|
5425
|
-
}
|
|
5426
|
-
}
|
|
5650
|
+
relabelUnfetched(problems, unfetched);
|
|
5427
5651
|
const ordered = args.sort === "none" ? staged : topoSort(staged, problems, game.core);
|
|
5428
5652
|
warnings.push(...incompatibilityWarnings(ordered));
|
|
5429
5653
|
const mods = [];
|
|
5430
|
-
for (const
|
|
5431
|
-
|
|
5432
|
-
const report = times === null ? null : staleReport(times);
|
|
5433
|
-
if (record.worktree) {
|
|
5434
|
-
warnings.push(`${record.packageId} comes from worktree ${record.worktree.branch} (${record.worktree.source}): ${record.dir}`);
|
|
5435
|
-
}
|
|
5436
|
-
const shadowed = (index.byPackageId.get(record.packageId.toLowerCase()) ?? []).filter((other) => other.dir !== record.dir).map((other) => other.dir);
|
|
5437
|
-
mods.push({
|
|
5438
|
-
packageId: record.packageId,
|
|
5439
|
-
hostDir: record.dir,
|
|
5440
|
-
containerDir: `${game.modsDir.container}/${record.packageId}`,
|
|
5441
|
-
kind: record.kind,
|
|
5442
|
-
...record.workshopId === undefined ? {} : { workshopId: record.workshopId },
|
|
5443
|
-
explicit,
|
|
5444
|
-
stale: times === null ? false : decideStale(times),
|
|
5445
|
-
...report === null ? {} : { staleReport: report },
|
|
5446
|
-
...record.worktree === undefined ? {} : { worktree: { ...record.worktree, selected: record.selectedWorktree !== undefined } },
|
|
5447
|
-
...shadowed.length === 0 ? {} : { shadowed }
|
|
5448
|
-
});
|
|
5449
|
-
}
|
|
5654
|
+
for (const entry of ordered)
|
|
5655
|
+
mods.push(await describeMod(entry, game, index, warnings));
|
|
5450
5656
|
problems.push(...index.problems);
|
|
5451
|
-
|
|
5452
|
-
problems.push({
|
|
5453
|
-
where: `/games/${gameName}/dataDir/arg`,
|
|
5454
|
-
message: `"${game.dataDir.arg}" must contain exactly one "="; RimWorld silently ignores anything else`
|
|
5455
|
-
});
|
|
5456
|
-
}
|
|
5457
|
-
if (game.dataDir.container.includes("=")) {
|
|
5458
|
-
problems.push({
|
|
5459
|
-
where: `/games/${gameName}/dataDir/container`,
|
|
5460
|
-
message: `container data path "${game.dataDir.container}" contains "=", which disables the override silently`
|
|
5461
|
-
});
|
|
5462
|
-
}
|
|
5657
|
+
checkDataDir(problems, gameName, game);
|
|
5463
5658
|
const mode = args.mode ?? "headed";
|
|
5464
5659
|
if (!game.modes.includes(mode)) {
|
|
5465
5660
|
problems.push({ where: "flag --mode", message: `${gameName} does not support mode "${mode}"` });
|
|
@@ -5493,7 +5688,7 @@ async function resolvePlan(options) {
|
|
|
5493
5688
|
}
|
|
5494
5689
|
|
|
5495
5690
|
// src/run/registry.ts
|
|
5496
|
-
import { readdir as
|
|
5691
|
+
import { readdir as readdir9 } from "node:fs/promises";
|
|
5497
5692
|
import { join as join19 } from "node:path";
|
|
5498
5693
|
var FORMAT = '{{.Names}}\t{{.Label "gamecrate.game"}}\t{{.Label "gamecrate.profile"}}\t{{.Label "gamecrate.instance"}}\t{{.Status}}';
|
|
5499
5694
|
function parseDockerRuns(stdout) {
|
|
@@ -5532,7 +5727,7 @@ async function walkLocks(dataRoot) {
|
|
|
5532
5727
|
return out;
|
|
5533
5728
|
}
|
|
5534
5729
|
async function entries(dir) {
|
|
5535
|
-
const found = await
|
|
5730
|
+
const found = await readdir9(dir, { withFileTypes: true }).catch(() => []);
|
|
5536
5731
|
return found.filter((entry) => entry.isDirectory()).map((entry) => entry.name);
|
|
5537
5732
|
}
|
|
5538
5733
|
async function push(out, path) {
|
|
@@ -5557,35 +5752,40 @@ async function listRuns(dataRoot, docker = dockerPs) {
|
|
|
5557
5752
|
const out = [...running];
|
|
5558
5753
|
for (const lock of await walkLocks(dataRoot)) {
|
|
5559
5754
|
const match = byContainer.get(lock.container);
|
|
5560
|
-
if (match
|
|
5561
|
-
|
|
5562
|
-
|
|
5563
|
-
|
|
5564
|
-
match.startedAt = lock.startedAt;
|
|
5565
|
-
if (lock.mode !== undefined)
|
|
5566
|
-
match.mode = lock.mode;
|
|
5567
|
-
}
|
|
5568
|
-
continue;
|
|
5569
|
-
}
|
|
5570
|
-
out.push({
|
|
5571
|
-
game: lock.game,
|
|
5572
|
-
profile: lock.profile,
|
|
5573
|
-
...lock.instance === undefined ? {} : { instance: lock.instance },
|
|
5574
|
-
container: lock.container,
|
|
5575
|
-
pid: lock.pid,
|
|
5576
|
-
...lock.mode === undefined ? {} : { mode: lock.mode },
|
|
5577
|
-
startedAt: lock.startedAt,
|
|
5578
|
-
status: isRunning(lock.pid, lock.startedAt) ? "starting" : "orphaned"
|
|
5579
|
-
});
|
|
5755
|
+
if (match === undefined)
|
|
5756
|
+
out.push(fromLock(lock));
|
|
5757
|
+
else
|
|
5758
|
+
mergeLock(match, lock);
|
|
5580
5759
|
}
|
|
5581
5760
|
return out;
|
|
5582
5761
|
}
|
|
5762
|
+
function mergeLock(match, lock) {
|
|
5763
|
+
const stale = match.pid !== undefined && !isRunning(match.pid, match.startedAt);
|
|
5764
|
+
if (match.pid !== undefined && !(stale && isRunning(lock.pid, lock.startedAt)))
|
|
5765
|
+
return;
|
|
5766
|
+
match.pid = lock.pid;
|
|
5767
|
+
match.startedAt = lock.startedAt;
|
|
5768
|
+
if (lock.mode !== undefined)
|
|
5769
|
+
match.mode = lock.mode;
|
|
5770
|
+
}
|
|
5771
|
+
function fromLock(lock) {
|
|
5772
|
+
return {
|
|
5773
|
+
game: lock.game,
|
|
5774
|
+
profile: lock.profile,
|
|
5775
|
+
...lock.instance === undefined ? {} : { instance: lock.instance },
|
|
5776
|
+
container: lock.container,
|
|
5777
|
+
pid: lock.pid,
|
|
5778
|
+
...lock.mode === undefined ? {} : { mode: lock.mode },
|
|
5779
|
+
startedAt: lock.startedAt,
|
|
5780
|
+
status: isRunning(lock.pid, lock.startedAt) ? "starting" : "orphaned"
|
|
5781
|
+
};
|
|
5782
|
+
}
|
|
5583
5783
|
|
|
5584
5784
|
// src/launch/stage.ts
|
|
5585
|
-
import { lstat, mkdir as mkdir5, readdir as
|
|
5785
|
+
import { lstat, mkdir as mkdir5, readdir as readdir10, realpath as realpath2, rm as rm4, stat as stat5 } from "node:fs/promises";
|
|
5586
5786
|
import { basename as basename10, join as join20 } from "node:path";
|
|
5587
5787
|
async function stageMods(plan) {
|
|
5588
|
-
await
|
|
5788
|
+
await rm4(plan.stageDirHost, { recursive: true, force: true });
|
|
5589
5789
|
await mkdir5(plan.stageDirHost, { recursive: true });
|
|
5590
5790
|
const mounts = [];
|
|
5591
5791
|
for (const mod of plan.mods) {
|
|
@@ -5646,7 +5846,7 @@ async function detectForeignOwnership(dir, uid, limit = 100) {
|
|
|
5646
5846
|
if (!info.isDirectory())
|
|
5647
5847
|
continue;
|
|
5648
5848
|
try {
|
|
5649
|
-
for (const entry of await
|
|
5849
|
+
for (const entry of await readdir10(current))
|
|
5650
5850
|
queue.push(join20(current, entry));
|
|
5651
5851
|
} catch {
|
|
5652
5852
|
continue;
|
|
@@ -5656,7 +5856,7 @@ async function detectForeignOwnership(dir, uid, limit = 100) {
|
|
|
5656
5856
|
}
|
|
5657
5857
|
|
|
5658
5858
|
// src/mods/workshop.ts
|
|
5659
|
-
import { existsSync as
|
|
5859
|
+
import { existsSync as existsSync11 } from "node:fs";
|
|
5660
5860
|
import { readFile as readFile11 } from "node:fs/promises";
|
|
5661
5861
|
import { join as join21, resolve as resolvePath2 } from "node:path";
|
|
5662
5862
|
var ROUNDS = 5;
|
|
@@ -5671,26 +5871,9 @@ async function prepareWorkshop(game, profileName, args, config, allowFetch, plug
|
|
|
5671
5871
|
for (let round = 0;round < ROUNDS && frontier.length > 0; round++) {
|
|
5672
5872
|
for (const id of frontier)
|
|
5673
5873
|
ids.add(id);
|
|
5674
|
-
if (allowFetch)
|
|
5675
|
-
|
|
5676
|
-
|
|
5677
|
-
warnings.push(...drift.warnings);
|
|
5678
|
-
const downloaded = await downloadItems(config, game, config.dataRoot, drift.needed);
|
|
5679
|
-
warnings.push(...downloaded.warnings);
|
|
5680
|
-
} catch (error) {
|
|
5681
|
-
warnings.push(`could not download workshop items: ${error instanceof Error ? error.message : String(error)}`);
|
|
5682
|
-
}
|
|
5683
|
-
}
|
|
5684
|
-
const next = new Set;
|
|
5685
|
-
for (const id of frontier) {
|
|
5686
|
-
const manifest = await manifestOf(roots, id, game, plugin, problems);
|
|
5687
|
-
for (const dep of manifest?.modDependencies ?? []) {
|
|
5688
|
-
const depId = workshopUrlId(dep.steamWorkshopUrl);
|
|
5689
|
-
if (depId !== undefined && !ids.has(depId))
|
|
5690
|
-
next.add(depId);
|
|
5691
|
-
}
|
|
5692
|
-
}
|
|
5693
|
-
frontier = [...next];
|
|
5874
|
+
if (allowFetch)
|
|
5875
|
+
warnings.push(...await fetchRound(config, game, roots, frontier));
|
|
5876
|
+
frontier = await dependenciesOf(frontier, ids, roots, game, plugin, problems);
|
|
5694
5877
|
}
|
|
5695
5878
|
if (frontier.length > 0) {
|
|
5696
5879
|
problems.push({
|
|
@@ -5701,11 +5884,32 @@ async function prepareWorkshop(game, profileName, args, config, allowFetch, plug
|
|
|
5701
5884
|
}
|
|
5702
5885
|
return { ids, warnings, problems, unfetched: [...ids].filter((id) => itemDir(roots, id) === undefined) };
|
|
5703
5886
|
}
|
|
5887
|
+
async function fetchRound(config, game, roots, frontier) {
|
|
5888
|
+
try {
|
|
5889
|
+
const drift = await checkDrift(frontier, roots);
|
|
5890
|
+
const downloaded = await downloadItems(config, game, config.dataRoot, drift.needed);
|
|
5891
|
+
return [...drift.warnings, ...downloaded.warnings];
|
|
5892
|
+
} catch (error) {
|
|
5893
|
+
return [`could not download workshop items: ${error instanceof Error ? error.message : String(error)}`];
|
|
5894
|
+
}
|
|
5895
|
+
}
|
|
5896
|
+
async function dependenciesOf(frontier, seen, roots, game, plugin, problems) {
|
|
5897
|
+
const next = new Set;
|
|
5898
|
+
for (const id of frontier) {
|
|
5899
|
+
const manifest = await manifestOf(roots, id, game, plugin, problems);
|
|
5900
|
+
for (const dep of manifest?.modDependencies ?? []) {
|
|
5901
|
+
const depId = workshopUrlId(dep.steamWorkshopUrl);
|
|
5902
|
+
if (depId !== undefined && !seen.has(depId))
|
|
5903
|
+
next.add(depId);
|
|
5904
|
+
}
|
|
5905
|
+
}
|
|
5906
|
+
return [...next];
|
|
5907
|
+
}
|
|
5704
5908
|
function mountedRoots(dataRoot, game) {
|
|
5705
5909
|
return [downloadRoot(dataRoot, game), ...game.workshopRoot === null ? [] : [game.workshopRoot]];
|
|
5706
5910
|
}
|
|
5707
5911
|
function itemDir(roots, id) {
|
|
5708
|
-
return roots.map((root) => join21(root, id)).find((dir) =>
|
|
5912
|
+
return roots.map((root) => join21(root, id)).find((dir) => existsSync11(dir));
|
|
5709
5913
|
}
|
|
5710
5914
|
async function manifestOf(roots, id, game, plugin, problems) {
|
|
5711
5915
|
const dir = itemDir(roots, id);
|
|
@@ -5713,7 +5917,7 @@ async function manifestOf(roots, id, game, plugin, problems) {
|
|
|
5713
5917
|
}
|
|
5714
5918
|
async function manifestAt(dir, game, plugin, problems) {
|
|
5715
5919
|
const file = join21(dir, game.manifest.file);
|
|
5716
|
-
if (!
|
|
5920
|
+
if (!existsSync11(file))
|
|
5717
5921
|
return null;
|
|
5718
5922
|
try {
|
|
5719
5923
|
return plugin.parseManifest(await readFile11(file, "utf8"));
|
|
@@ -5737,26 +5941,26 @@ async function localSeeds(game, profile, args, sources, plugin, problems) {
|
|
|
5737
5941
|
function localDirs(game, profile, args, sources) {
|
|
5738
5942
|
const out = new Set;
|
|
5739
5943
|
for (const entry of reachedEntries(game, profile, args)) {
|
|
5740
|
-
|
|
5741
|
-
|
|
5742
|
-
|
|
5743
|
-
if (object.path !== undefined) {
|
|
5744
|
-
out.add(resolvePath2(expandHome(object.path)));
|
|
5745
|
-
continue;
|
|
5746
|
-
}
|
|
5747
|
-
if (object.id.includes(":"))
|
|
5748
|
-
continue;
|
|
5749
|
-
const pin = libraryPin(game, object.id);
|
|
5750
|
-
const clone = sources.get(object.id.toLowerCase());
|
|
5751
|
-
if (clone !== undefined) {
|
|
5752
|
-
out.add(pin?.subdir === undefined ? clone : join21(clone, pin.subdir));
|
|
5753
|
-
continue;
|
|
5754
|
-
}
|
|
5755
|
-
if (pin?.path !== undefined)
|
|
5756
|
-
out.add(resolvePath2(expandHome(pin.path)));
|
|
5944
|
+
const dir = localDirOf(entry, game, sources);
|
|
5945
|
+
if (dir !== undefined)
|
|
5946
|
+
out.add(dir);
|
|
5757
5947
|
}
|
|
5758
5948
|
return [...out];
|
|
5759
5949
|
}
|
|
5950
|
+
function localDirOf(entry, game, sources) {
|
|
5951
|
+
if (typeof entry !== "string" && "match" in entry)
|
|
5952
|
+
return;
|
|
5953
|
+
const object = typeof entry === "string" ? { id: entry } : entry;
|
|
5954
|
+
if (object.path !== undefined)
|
|
5955
|
+
return resolvePath2(expandHome(object.path));
|
|
5956
|
+
if (object.id.includes(":"))
|
|
5957
|
+
return;
|
|
5958
|
+
const pin = libraryPin(game, object.id);
|
|
5959
|
+
const clone = sources.get(object.id.toLowerCase());
|
|
5960
|
+
if (clone !== undefined)
|
|
5961
|
+
return pin?.subdir === undefined ? clone : join21(clone, pin.subdir);
|
|
5962
|
+
return pin?.path === undefined ? undefined : resolvePath2(expandHome(pin.path));
|
|
5963
|
+
}
|
|
5760
5964
|
function wantedIds(game, profile, args) {
|
|
5761
5965
|
const out = new Set;
|
|
5762
5966
|
for (const entry of reachedEntries(game, profile, args)) {
|
|
@@ -5788,7 +5992,7 @@ function published2(value) {
|
|
|
5788
5992
|
}
|
|
5789
5993
|
|
|
5790
5994
|
// src/index.ts
|
|
5791
|
-
var VERSION = "1.
|
|
5995
|
+
var VERSION = "1.4.1";
|
|
5792
5996
|
async function main(argv) {
|
|
5793
5997
|
const supervised = supervisedDir(argv);
|
|
5794
5998
|
try {
|
|
@@ -5920,7 +6124,7 @@ async function run2(argv, args, config, plugins, defaults, asShell) {
|
|
|
5920
6124
|
const sources = await prepareSources(gameConfig, profile, args, config.dataRoot, allowFetch);
|
|
5921
6125
|
try {
|
|
5922
6126
|
const workshop = await prepareWorkshop(gameConfig, profile, args, config, allowFetch, requirePlugin(plugins, game), sources.dirs);
|
|
5923
|
-
return await resolved(argv, args, config, plugins, asShell, game, profile, sources, workshop, allowFetch);
|
|
6127
|
+
return await resolved({ argv, args, config, plugins, asShell, game, profile, sources, workshop, allowFetch });
|
|
5924
6128
|
} finally {
|
|
5925
6129
|
await sources.release();
|
|
5926
6130
|
}
|
|
@@ -5936,7 +6140,8 @@ function warnUnfetched(problems, unfetched) {
|
|
|
5936
6140
|
}
|
|
5937
6141
|
return fatal;
|
|
5938
6142
|
}
|
|
5939
|
-
async function resolved(
|
|
6143
|
+
async function resolved(inputs) {
|
|
6144
|
+
const { argv, args, config, plugins, asShell, game, profile, sources, workshop, allowFetch } = inputs;
|
|
5940
6145
|
for (const warning of [...sources.warnings, ...workshop.warnings])
|
|
5941
6146
|
warn(warning);
|
|
5942
6147
|
const index = await buildIndex(game, config.games[game], requirePlugin(plugins, game), sourcesRoot(config.dataRoot), config.dataRoot);
|
|
@@ -5945,21 +6150,8 @@ async function resolved(argv, args, config, plugins, asShell, game, profile, sou
|
|
|
5945
6150
|
if (fatal.length > 0)
|
|
5946
6151
|
reportProblems(fatal);
|
|
5947
6152
|
const identity = resolveIdentity(args.root);
|
|
5948
|
-
if (args.printPlan || args.dryRun)
|
|
5949
|
-
|
|
5950
|
-
buildRunSpec(plan, [], identity);
|
|
5951
|
-
if (args.printPlan)
|
|
5952
|
-
printPlan(plan, args.json);
|
|
5953
|
-
for (const warning of planWarnings(plan))
|
|
5954
|
-
warn(warning);
|
|
5955
|
-
if (environment.length > 0)
|
|
5956
|
-
reportEnvironment(environment);
|
|
5957
|
-
if (!args.printPlan) {
|
|
5958
|
-
const what = plan.instance === undefined ? profile : `${profile}/${plan.instance}`;
|
|
5959
|
-
status(`${game} ${what}: ${plan.mods.length} mods resolve cleanly`);
|
|
5960
|
-
}
|
|
5961
|
-
return Exit.Ok;
|
|
5962
|
-
}
|
|
6153
|
+
if (args.printPlan || args.dryRun)
|
|
6154
|
+
return await reportPlanOnly(plan, args, profile, identity);
|
|
5963
6155
|
const environment = await preflight(plan);
|
|
5964
6156
|
if (environment.length > 0)
|
|
5965
6157
|
reportEnvironment(environment);
|
|
@@ -5979,6 +6171,21 @@ async function resolved(argv, args, config, plugins, asShell, game, profile, sou
|
|
|
5979
6171
|
await lock.release();
|
|
5980
6172
|
}
|
|
5981
6173
|
}
|
|
6174
|
+
async function reportPlanOnly(plan, args, profile, identity) {
|
|
6175
|
+
const environment = await preflight(plan);
|
|
6176
|
+
buildRunSpec(plan, [], identity);
|
|
6177
|
+
if (args.printPlan)
|
|
6178
|
+
printPlan(plan, args.json);
|
|
6179
|
+
for (const warning of planWarnings(plan))
|
|
6180
|
+
warn(warning);
|
|
6181
|
+
if (environment.length > 0)
|
|
6182
|
+
reportEnvironment(environment);
|
|
6183
|
+
if (!args.printPlan) {
|
|
6184
|
+
const what = plan.instance === undefined ? profile : `${profile}/${plan.instance}`;
|
|
6185
|
+
status(`${plan.game} ${what}: ${plan.mods.length} mods resolve cleanly`);
|
|
6186
|
+
}
|
|
6187
|
+
return Exit.Ok;
|
|
6188
|
+
}
|
|
5982
6189
|
async function launch(plan, args, config, identity, asShell, profileSpec, releaseSources) {
|
|
5983
6190
|
const game = plan.game;
|
|
5984
6191
|
const profile = plan.profile;
|
|
@@ -5992,12 +6199,13 @@ run: gamecrate fix-perms ${game} ${profile}`);
|
|
|
5992
6199
|
plan.runDirHost = runDir;
|
|
5993
6200
|
const supervisorLog = args.supervised && args.log === undefined ? redirectOutput(join22(runDir, "supervisor.log")) : undefined;
|
|
5994
6201
|
try {
|
|
5995
|
-
return await execute(plan, args, config, identity, asShell, profileSpec, runDir, releaseSources);
|
|
6202
|
+
return await execute({ plan, args, config, identity, asShell, profileSpec, runDir, releaseSources });
|
|
5996
6203
|
} finally {
|
|
5997
6204
|
supervisorLog?.close();
|
|
5998
6205
|
}
|
|
5999
6206
|
}
|
|
6000
|
-
async function execute(
|
|
6207
|
+
async function execute(inputs) {
|
|
6208
|
+
const { plan, args, config, identity, asShell, profileSpec, runDir, releaseSources } = inputs;
|
|
6001
6209
|
const game = plan.game;
|
|
6002
6210
|
await buildLocalMods(plan, buildPolicy(args, profileSpec));
|
|
6003
6211
|
await releaseSources();
|
|
@@ -6122,9 +6330,9 @@ async function copyOutLogs(plan) {
|
|
|
6122
6330
|
if (spec.mode !== "copy-out")
|
|
6123
6331
|
return;
|
|
6124
6332
|
const source = join22(plan.dataDirHost, spec.from);
|
|
6125
|
-
if (!
|
|
6333
|
+
if (!existsSync12(source))
|
|
6126
6334
|
return;
|
|
6127
|
-
const target = join22(plan.runDirHost, basename11(spec.from
|
|
6335
|
+
const target = join22(plan.runDirHost, basename11(spec.from));
|
|
6128
6336
|
try {
|
|
6129
6337
|
await cp(source, target, { recursive: true, force: true });
|
|
6130
6338
|
} catch (error) {
|
|
@@ -6147,24 +6355,17 @@ async function mods(args, config, plugins, defaults) {
|
|
|
6147
6355
|
return Exit.Ok;
|
|
6148
6356
|
}
|
|
6149
6357
|
function usesWorkshop(game) {
|
|
6150
|
-
|
|
6151
|
-
|
|
6152
|
-
|
|
6153
|
-
|
|
6154
|
-
|
|
6155
|
-
|
|
6156
|
-
|
|
6157
|
-
|
|
6158
|
-
|
|
6159
|
-
|
|
6160
|
-
|
|
6161
|
-
if (entry.startsWith("workshop:"))
|
|
6162
|
-
return true;
|
|
6163
|
-
} else if ("workshop" in entry && entry.workshop !== undefined)
|
|
6164
|
-
return true;
|
|
6165
|
-
}
|
|
6166
|
-
}
|
|
6167
|
-
return false;
|
|
6358
|
+
if (Object.values(game.library ?? {}).some((entry) => entry.workshop !== undefined))
|
|
6359
|
+
return true;
|
|
6360
|
+
const slots = [...game.preCore ?? [], game.core, ...game.dlc, ...game.base ?? []];
|
|
6361
|
+
if (slots.some((ref) => ref.startsWith("workshop:")))
|
|
6362
|
+
return true;
|
|
6363
|
+
return Object.values(game.profiles).some((profile) => (profile.mods ?? []).some(isWorkshopEntry));
|
|
6364
|
+
}
|
|
6365
|
+
function isWorkshopEntry(entry) {
|
|
6366
|
+
if (typeof entry === "string")
|
|
6367
|
+
return entry.startsWith("workshop:");
|
|
6368
|
+
return "workshop" in entry && entry.workshop !== undefined;
|
|
6168
6369
|
}
|
|
6169
6370
|
function steamcmdSource(runner, config) {
|
|
6170
6371
|
if (runner.kind === "docker")
|
|
@@ -6178,37 +6379,44 @@ async function doctor(config, plugins) {
|
|
|
6178
6379
|
const gameConfig = config.games[game];
|
|
6179
6380
|
const sources = cachedSources(gameConfig, "modless", {}, config.dataRoot);
|
|
6180
6381
|
const { plan, problems } = await resolvePlan({ game, profile: "modless", root: config, plugins, sources });
|
|
6181
|
-
const
|
|
6182
|
-
if (
|
|
6183
|
-
|
|
6184
|
-
|
|
6185
|
-
|
|
6186
|
-
|
|
6187
|
-
|
|
6188
|
-
|
|
6189
|
-
|
|
6190
|
-
|
|
6191
|
-
|
|
6192
|
-
|
|
6193
|
-
|
|
6194
|
-
|
|
6195
|
-
|
|
6196
|
-
|
|
6197
|
-
|
|
6198
|
-
|
|
6199
|
-
|
|
6200
|
-
|
|
6201
|
-
|
|
6202
|
-
|
|
6203
|
-
|
|
6382
|
+
const all = [...problems, ...await preflight(plan), ...steamcmdProblems(game, gameConfig, config)];
|
|
6383
|
+
if (!reportDoctor(game, all))
|
|
6384
|
+
failed = true;
|
|
6385
|
+
}
|
|
6386
|
+
return failed ? Exit.Environment : Exit.Ok;
|
|
6387
|
+
}
|
|
6388
|
+
function steamcmdProblems(game, gameConfig, config) {
|
|
6389
|
+
if (!usesWorkshop(gameConfig))
|
|
6390
|
+
return [];
|
|
6391
|
+
const problems = [];
|
|
6392
|
+
try {
|
|
6393
|
+
status(`${game}: steamcmd ${steamcmdSource(resolveSteamcmd(config), config)}`);
|
|
6394
|
+
} catch (error) {
|
|
6395
|
+
problems.push({
|
|
6396
|
+
where: "steamcmd",
|
|
6397
|
+
message: error instanceof Error ? error.message : String(error),
|
|
6398
|
+
...error instanceof GamecrateError && error.detail !== undefined ? { suggestion: error.detail } : {}
|
|
6399
|
+
});
|
|
6400
|
+
}
|
|
6401
|
+
const root = downloadRoot(config.dataRoot, gameConfig);
|
|
6402
|
+
status(`${game}: workshop downloads ${root}${existsSync12(root) ? "" : " (not created yet)"}`);
|
|
6403
|
+
return problems;
|
|
6404
|
+
}
|
|
6405
|
+
function reportDoctor(game, all) {
|
|
6406
|
+
if (all.length === 0) {
|
|
6407
|
+
status(`${game}: ok`);
|
|
6408
|
+
return true;
|
|
6409
|
+
}
|
|
6410
|
+
status(`${game}: ${all.length} problem(s)`);
|
|
6411
|
+
for (const problem of all) {
|
|
6412
|
+
process.stderr.write(` ${problem.where}
|
|
6204
6413
|
${problem.message}
|
|
6205
6414
|
`);
|
|
6206
|
-
|
|
6207
|
-
|
|
6415
|
+
if (problem.suggestion)
|
|
6416
|
+
process.stderr.write(` try: ${problem.suggestion}
|
|
6208
6417
|
`);
|
|
6209
|
-
}
|
|
6210
6418
|
}
|
|
6211
|
-
return
|
|
6419
|
+
return false;
|
|
6212
6420
|
}
|
|
6213
6421
|
async function logs(args, config, defaults) {
|
|
6214
6422
|
const game = requireGame(args, config);
|
|
@@ -6217,12 +6425,12 @@ async function logs(args, config, defaults) {
|
|
|
6217
6425
|
if (args.follow)
|
|
6218
6426
|
return await follow(dir, false, `${game} ${profile}`);
|
|
6219
6427
|
const runs = join22(dir, "logs", "runs");
|
|
6220
|
-
const latest = (await
|
|
6428
|
+
const latest = (await readdir11(runs, { withFileTypes: true }).catch(() => [])).filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort().at(-1);
|
|
6221
6429
|
if (latest === undefined) {
|
|
6222
6430
|
throw new GamecrateError(`no runs recorded for ${game} ${profile}`, Exit.Usage, runs);
|
|
6223
6431
|
}
|
|
6224
6432
|
const runDir = join22(runs, latest);
|
|
6225
|
-
const files = (await
|
|
6433
|
+
const files = (await readdir11(runDir, { withFileTypes: true })).filter((entry) => entry.isFile()).map((entry) => entry.name).sort();
|
|
6226
6434
|
if (args.json) {
|
|
6227
6435
|
process.stdout.write(`${JSON.stringify({ run: latest, dir: runDir, files }, null, 2)}
|
|
6228
6436
|
`);
|
|
@@ -6277,7 +6485,7 @@ async function verify(args, config, plugins, defaults) {
|
|
|
6277
6485
|
reportProblems(problems);
|
|
6278
6486
|
const name = containerName(plan);
|
|
6279
6487
|
const info = await inspectContainer(name);
|
|
6280
|
-
if (
|
|
6488
|
+
if (!info?.running) {
|
|
6281
6489
|
throw new GamecrateError(`no container named ${name} is running`, Exit.Environment, "launch it first, or name the run with --instance or --worktree");
|
|
6282
6490
|
}
|
|
6283
6491
|
const prefix = `${plan.gameConfig.modsDir.container}/`;
|
|
@@ -6341,8 +6549,7 @@ function renderVerify(name, info, plan, boundMods) {
|
|
|
6341
6549
|
const stampWidth = Math.max(...stamps.map((s) => s.length));
|
|
6342
6550
|
for (const [i, mod] of boundMods.entries()) {
|
|
6343
6551
|
const origin = mod.branch === null ? "" : `worktree ${mod.branch}`;
|
|
6344
|
-
out.push(` ${mod.packageId.padEnd(idWidth)} ${paths[i].padEnd(pathWidth)} ${origin}`.trimEnd());
|
|
6345
|
-
out.push(` ${" ".repeat(idWidth)} ${stamps[i].padEnd(stampWidth)} ${boundStatus(mod)}`);
|
|
6552
|
+
out.push(` ${mod.packageId.padEnd(idWidth)} ${paths[i].padEnd(pathWidth)} ${origin}`.trimEnd(), ` ${" ".repeat(idWidth)} ${stamps[i].padEnd(stampWidth)} ${boundStatus(mod)}`);
|
|
6346
6553
|
}
|
|
6347
6554
|
return out.join(`
|
|
6348
6555
|
`);
|
|
@@ -6357,18 +6564,24 @@ async function clean(args, config, defaults) {
|
|
|
6357
6564
|
const dir = profileDataDir(config, game, profile);
|
|
6358
6565
|
const tier = args.cleanTier ?? "staging";
|
|
6359
6566
|
const saveSuffixes = config.games[game].saveExtensions.map((ext) => `.${ext.replace(/^\./, "")}`.toLowerCase());
|
|
6567
|
+
const downloads = downloadRoot(config.dataRoot, config.games[game]);
|
|
6568
|
+
if (tier === "downloads") {
|
|
6569
|
+
status(await removeDownloads(downloads, config.games[game].steamAppId));
|
|
6570
|
+
return Exit.Ok;
|
|
6571
|
+
}
|
|
6360
6572
|
if (tier !== "all") {
|
|
6361
6573
|
const target = join22(instanceDir(args, config, game, profile), tier === "logs" ? "logs" : ".stage");
|
|
6362
|
-
await
|
|
6574
|
+
await rm5(target, { recursive: true, force: true });
|
|
6363
6575
|
status(`removed ${target}`);
|
|
6364
6576
|
return Exit.Ok;
|
|
6365
6577
|
}
|
|
6366
6578
|
const saves = await countSaves(dir, saveSuffixes);
|
|
6367
6579
|
if (!args.yes) {
|
|
6368
|
-
throw new GamecrateError(`clean --all would delete ${dir}, including ${saves} save file(s)`, Exit.Usage, "add --yes to confirm");
|
|
6580
|
+
throw new GamecrateError(`clean --all would delete ${dir}, including ${saves} save file(s), and ${game}'s workshop downloads`, Exit.Usage, "add --yes to confirm, or --downloads to drop only the downloads");
|
|
6369
6581
|
}
|
|
6370
|
-
await
|
|
6582
|
+
await rm5(dir, { recursive: true, force: true });
|
|
6371
6583
|
status(`removed ${dir} (${saves} save file(s))`);
|
|
6584
|
+
status(await removeDownloads(downloads, config.games[game].steamAppId));
|
|
6372
6585
|
return Exit.Ok;
|
|
6373
6586
|
}
|
|
6374
6587
|
async function countSaves(dir, suffixes) {
|
|
@@ -6378,7 +6591,7 @@ async function countSaves(dir, suffixes) {
|
|
|
6378
6591
|
const current = queue.shift();
|
|
6379
6592
|
let entries;
|
|
6380
6593
|
try {
|
|
6381
|
-
entries = await
|
|
6594
|
+
entries = await readdir11(current, { withFileTypes: true });
|
|
6382
6595
|
} catch {
|
|
6383
6596
|
continue;
|
|
6384
6597
|
}
|
|
@@ -6399,9 +6612,9 @@ async function clone(args, config) {
|
|
|
6399
6612
|
}
|
|
6400
6613
|
const from = join22(profileDataDir(config, game, src), "game");
|
|
6401
6614
|
const to = join22(profileDataDir(config, game, dst), "game");
|
|
6402
|
-
if (!
|
|
6615
|
+
if (!existsSync12(from))
|
|
6403
6616
|
throw new GamecrateError(`${from} does not exist`, Exit.Usage);
|
|
6404
|
-
if (
|
|
6617
|
+
if (existsSync12(to) && !args.yes) {
|
|
6405
6618
|
throw new GamecrateError(`${to} already exists`, Exit.Usage, "add --yes to overwrite");
|
|
6406
6619
|
}
|
|
6407
6620
|
await mkdir6(to, { recursive: true });
|
|
@@ -6467,7 +6680,7 @@ async function follow(dir, fromStart, what) {
|
|
|
6467
6680
|
const held = lock !== undefined && isRunning(lock.pid, lock.startedAt);
|
|
6468
6681
|
const live = held && await awaitRunLog(dir, lock);
|
|
6469
6682
|
const file = currentLog(dir);
|
|
6470
|
-
if (!
|
|
6683
|
+
if (!existsSync12(file)) {
|
|
6471
6684
|
throw new GamecrateError(`no captured output for ${what}`, Exit.Usage, file);
|
|
6472
6685
|
}
|
|
6473
6686
|
return await spawnStatus(tailArgv(file, fromStart, live ? lock.pid : undefined), true);
|
|
@@ -6501,8 +6714,8 @@ async function configEdit(args) {
|
|
|
6501
6714
|
throw new GamecrateError("config takes one word: edit", Exit.Usage);
|
|
6502
6715
|
const existing = await findGlobalConfig();
|
|
6503
6716
|
const path = existing ?? join22(globalConfigDir(), "profiles.yml");
|
|
6504
|
-
await mkdir6(
|
|
6505
|
-
if (!
|
|
6717
|
+
await mkdir6(dirname10(path), { recursive: true });
|
|
6718
|
+
if (!existsSync12(path)) {
|
|
6506
6719
|
await writeFile7(path, `# gamecrate config. see https://github.com/RimWorks/gamecrate
|
|
6507
6720
|
` + `plugins: []
|
|
6508
6721
|
` + `games: {}
|
|
@@ -6517,12 +6730,30 @@ async function configEdit(args) {
|
|
|
6517
6730
|
status(`${path} is valid`);
|
|
6518
6731
|
return Exit.Ok;
|
|
6519
6732
|
}
|
|
6733
|
+
async function repairOwnership(found, identity) {
|
|
6734
|
+
let fixed = 0;
|
|
6735
|
+
const stuck = [];
|
|
6736
|
+
for (const path of found) {
|
|
6737
|
+
const chowned = await chown(path, identity.uid, identity.gid).then(() => true, () => false);
|
|
6738
|
+
if (chowned) {
|
|
6739
|
+
fixed += 1;
|
|
6740
|
+
continue;
|
|
6741
|
+
}
|
|
6742
|
+
if (await removeIfEmptyDir(path)) {
|
|
6743
|
+
status(`removed empty ${path}; it will be recreated on the next run`);
|
|
6744
|
+
fixed += 1;
|
|
6745
|
+
continue;
|
|
6746
|
+
}
|
|
6747
|
+
stuck.push(path);
|
|
6748
|
+
}
|
|
6749
|
+
return { fixed, stuck };
|
|
6750
|
+
}
|
|
6520
6751
|
async function fixPerms(args, config) {
|
|
6521
6752
|
const game = requireGame(args, config);
|
|
6522
6753
|
const identity = resolveIdentity(false);
|
|
6523
6754
|
const found = [];
|
|
6524
6755
|
for (const dir of await profileDirs(config, game, args.profile)) {
|
|
6525
|
-
if (!
|
|
6756
|
+
if (!existsSync12(dir))
|
|
6526
6757
|
continue;
|
|
6527
6758
|
found.push(...await detectForeignOwnership(dir, identity.uid, 1e4));
|
|
6528
6759
|
}
|
|
@@ -6537,21 +6768,7 @@ async function fixPerms(args, config) {
|
|
|
6537
6768
|
status(`${found.length} foreign-owned path(s); re-run with --yes to chown them`);
|
|
6538
6769
|
return Exit.Environment;
|
|
6539
6770
|
}
|
|
6540
|
-
|
|
6541
|
-
const stuck = [];
|
|
6542
|
-
for (const path of found) {
|
|
6543
|
-
const chowned = await chown(path, identity.uid, identity.gid).then(() => true, () => false);
|
|
6544
|
-
if (chowned) {
|
|
6545
|
-
fixed += 1;
|
|
6546
|
-
continue;
|
|
6547
|
-
}
|
|
6548
|
-
if (await removeIfEmptyDir(path)) {
|
|
6549
|
-
status(`removed empty ${path}; it will be recreated on the next run`);
|
|
6550
|
-
fixed += 1;
|
|
6551
|
-
continue;
|
|
6552
|
-
}
|
|
6553
|
-
stuck.push(path);
|
|
6554
|
-
}
|
|
6771
|
+
const { fixed, stuck } = await repairOwnership(found, identity);
|
|
6555
6772
|
if (fixed > 0)
|
|
6556
6773
|
status(`fixed ${fixed} path(s) for ${identity.uid}:${identity.gid}`);
|
|
6557
6774
|
if (stuck.length > 0) {
|
|
@@ -6565,7 +6782,7 @@ async function removeIfEmptyDir(path) {
|
|
|
6565
6782
|
const info = await stat6(path).catch(() => null);
|
|
6566
6783
|
if (info === null || !info.isDirectory())
|
|
6567
6784
|
return false;
|
|
6568
|
-
const entries = await
|
|
6785
|
+
const entries = await readdir11(path).catch(() => null);
|
|
6569
6786
|
if (entries === null || entries.length > 0)
|
|
6570
6787
|
return false;
|
|
6571
6788
|
return rmdir(path).then(() => true, () => false);
|