@gamecrate/cli 1.4.1 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -2
- package/dist/gamecrate.js +1881 -300
- package/dist/lib.js +101 -2
- package/dist/types/cli/args.d.ts +6 -3
- package/dist/types/cli/profile.d.ts +6 -1
- package/dist/types/cli/steam.d.ts +27 -0
- package/dist/types/config/load.d.ts +5 -0
- package/dist/types/config/validate.d.ts +23 -0
- package/dist/types/docker/preflight.d.ts +1 -1
- package/dist/types/docker/run.d.ts +8 -0
- package/dist/types/docker/spec.d.ts +12 -2
- package/dist/types/image/base.d.ts +11 -0
- package/dist/types/image/build.d.ts +26 -0
- package/dist/types/image/crane.d.ts +32 -0
- package/dist/types/image/gate.d.ts +18 -0
- package/dist/types/image/input.d.ts +40 -0
- package/dist/types/image/tags.d.ts +23 -0
- package/dist/types/launch/image.d.ts +45 -0
- package/dist/types/launch/prepare.d.ts +4 -10
- package/dist/types/launch/updates.d.ts +35 -0
- package/dist/types/mods/steamcmd.d.ts +34 -0
- package/dist/types/plugin.d.ts +1 -1
- package/dist/types/types.d.ts +54 -3
- package/package.json +1 -1
package/dist/gamecrate.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
|
-
import { existsSync as
|
|
5
|
-
import { chown, cp, mkdir as mkdir6, readdir as readdir11, readFile as
|
|
6
|
-
import { homedir as
|
|
7
|
-
import { basename as basename11, dirname as
|
|
8
|
-
import { setTimeout as
|
|
4
|
+
import { existsSync as existsSync15 } from "node:fs";
|
|
5
|
+
import { chown, cp, mkdir as mkdir6, readdir as readdir11, readFile as readFile13, rm as rm5, rmdir, stat as stat6, writeFile as writeFile7 } from "node:fs/promises";
|
|
6
|
+
import { homedir as homedir7 } from "node:os";
|
|
7
|
+
import { basename as basename11, dirname as dirname13, join as join27 } from "node:path";
|
|
8
|
+
import { setTimeout as sleep8 } from "node:timers/promises";
|
|
9
9
|
|
|
10
10
|
// src/cli/args.ts
|
|
11
11
|
import { Command, CommanderError, Option } from "commander";
|
|
@@ -59,7 +59,9 @@ var RESERVED_NAMES = [
|
|
|
59
59
|
"wait",
|
|
60
60
|
"add",
|
|
61
61
|
"rm",
|
|
62
|
-
"sync"
|
|
62
|
+
"sync",
|
|
63
|
+
"steam",
|
|
64
|
+
"login"
|
|
63
65
|
];
|
|
64
66
|
var NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
65
67
|
function own(bag, key) {
|
|
@@ -94,7 +96,8 @@ var RUN_FLAGS = [
|
|
|
94
96
|
"--worktree",
|
|
95
97
|
"--no-worktree",
|
|
96
98
|
"--instance",
|
|
97
|
-
"--use"
|
|
99
|
+
"--use",
|
|
100
|
+
"--image"
|
|
98
101
|
];
|
|
99
102
|
var SUBCOMMANDS = [
|
|
100
103
|
{
|
|
@@ -201,6 +204,29 @@ var SUBCOMMANDS = [
|
|
|
201
204
|
positionals: ["game"],
|
|
202
205
|
flags: ["--pull"]
|
|
203
206
|
},
|
|
207
|
+
{
|
|
208
|
+
name: "steam",
|
|
209
|
+
summary: "build a game image from steam, or prime a steam session",
|
|
210
|
+
usage: "build <game> | login",
|
|
211
|
+
positionals: ["game"],
|
|
212
|
+
subverbs: {
|
|
213
|
+
build: ["game"],
|
|
214
|
+
login: []
|
|
215
|
+
},
|
|
216
|
+
flags: [
|
|
217
|
+
"--variant",
|
|
218
|
+
"--beta",
|
|
219
|
+
"--image",
|
|
220
|
+
"--plugin",
|
|
221
|
+
"--load",
|
|
222
|
+
"--push",
|
|
223
|
+
"--base",
|
|
224
|
+
"--platform",
|
|
225
|
+
"--force",
|
|
226
|
+
"--print",
|
|
227
|
+
"--username"
|
|
228
|
+
]
|
|
229
|
+
},
|
|
204
230
|
{
|
|
205
231
|
name: "shell",
|
|
206
232
|
summary: "same mounts, bash instead of the game",
|
|
@@ -289,13 +315,220 @@ function enumOption(flags, summary, values) {
|
|
|
289
315
|
const long = flags.split(/[ ,]+/).find((token) => token.startsWith("--"));
|
|
290
316
|
return new Option(flags, summary).choices([...values]).argParser(choice(long, values));
|
|
291
317
|
}
|
|
318
|
+
var OPTIONS = {
|
|
319
|
+
"--mod": (cmd) => {
|
|
320
|
+
cmd.option("--mod <id>", "add a mod to the profile set", collect, []);
|
|
321
|
+
},
|
|
322
|
+
"--without": (cmd) => {
|
|
323
|
+
cmd.option("--without <id>", "drop a mod from the resolved set", collect, []);
|
|
324
|
+
},
|
|
325
|
+
"--only": (cmd) => {
|
|
326
|
+
cmd.option("--only <id>", "restrict the resolved set to these mods", collect, []);
|
|
327
|
+
},
|
|
328
|
+
"--worktree": (cmd) => {
|
|
329
|
+
cmd.option("--worktree <path>", "promote mods from this git worktree, in its own instance ($GAMECRATE_WORKTREE)", collect, []);
|
|
330
|
+
},
|
|
331
|
+
"--use": (cmd) => {
|
|
332
|
+
cmd.option("--use <packageId>=<path>", "force one mod to load from this directory, whatever the profile pins", collect, []);
|
|
333
|
+
},
|
|
334
|
+
"--no-worktree": (cmd) => {
|
|
335
|
+
cmd.option("--no-worktree", "ignore the current worktree and $GAMECRATE_WORKTREE");
|
|
336
|
+
},
|
|
337
|
+
"--instance": (cmd) => {
|
|
338
|
+
cmd.option("--instance <name>", "run under a named sub-profile with its own saves, logs and container");
|
|
339
|
+
},
|
|
340
|
+
"--mode": (cmd) => {
|
|
341
|
+
cmd.addOption(enumOption(`--mode <${MODES.join("|")}>`, "how the game is displayed", MODES));
|
|
342
|
+
},
|
|
343
|
+
"--marker": (cmd) => {
|
|
344
|
+
cmd.option("--marker <str>", "exit 0 as soon as this string appears in the log");
|
|
345
|
+
},
|
|
346
|
+
"--timeout": (cmd) => {
|
|
347
|
+
cmd.option("--timeout <seconds>", "bound a marker run, or a headless run with no marker", (v) => seconds("--timeout", v));
|
|
348
|
+
},
|
|
349
|
+
"--render-wait": (cmd) => {
|
|
350
|
+
cmd.option("--render-wait <seconds>", "settle time before a screenshot is taken", (v) => seconds("--render-wait", v));
|
|
351
|
+
},
|
|
352
|
+
"--resolution": (cmd) => {
|
|
353
|
+
cmd.option("--resolution <width>x<height>", "override the game resolution", parseResolution);
|
|
354
|
+
},
|
|
355
|
+
"--network": (cmd) => {
|
|
356
|
+
cmd.addOption(enumOption(`--network <${NETWORK_POLICIES.join("|")}>`, "the container's network mode", NETWORK_POLICIES));
|
|
357
|
+
},
|
|
358
|
+
"--log": (cmd) => {
|
|
359
|
+
cmd.option("--log <path>", "route launch stdout and stderr to one file");
|
|
360
|
+
},
|
|
361
|
+
"--pull": (cmd) => {
|
|
362
|
+
cmd.addOption(enumOption(`--pull <${PULL_POLICIES.join("|")}>`, "when to pull the runtime image", PULL_POLICIES));
|
|
363
|
+
},
|
|
364
|
+
"--build": (cmd) => {
|
|
365
|
+
cmd.option("--build", "build local C# mods before launching");
|
|
366
|
+
},
|
|
367
|
+
"--no-build": (cmd) => {
|
|
368
|
+
cmd.option("--no-build", "never build, even when an assembly is stale");
|
|
369
|
+
},
|
|
370
|
+
"--no-stale-check": (cmd) => {
|
|
371
|
+
cmd.option("--no-stale-check", "do not warn when a mod's sources are newer than its assemblies");
|
|
372
|
+
},
|
|
373
|
+
"--replace": (cmd) => {
|
|
374
|
+
cmd.option("--replace", "stop whatever is holding this profile and instance, then launch");
|
|
375
|
+
},
|
|
376
|
+
"--no-replace": (cmd) => {
|
|
377
|
+
cmd.option("--no-replace", "refuse when this profile and instance are already running");
|
|
378
|
+
},
|
|
379
|
+
"--detach": (cmd) => {
|
|
380
|
+
cmd.option("--detach", "start the run in the background and return the prompt");
|
|
381
|
+
},
|
|
382
|
+
"--no-detach": (cmd) => {
|
|
383
|
+
cmd.option("--no-detach", "stay in the foreground, whatever the profile or project config asks for");
|
|
384
|
+
},
|
|
385
|
+
"--supervised": (cmd) => {
|
|
386
|
+
cmd.addOption(new Option("--supervised <instanceDir>").hideHelp());
|
|
387
|
+
},
|
|
388
|
+
"--sort": (cmd) => {
|
|
389
|
+
cmd.addOption(enumOption(`--sort <${SORTS.join("|")}>`, "load order: the profile order, or a topological sort", SORTS));
|
|
390
|
+
},
|
|
391
|
+
"--docker-arg": (cmd) => {
|
|
392
|
+
cmd.option("--docker-arg <arg>", "one extra argv element for docker run", collect, []);
|
|
393
|
+
},
|
|
394
|
+
"--dry-run": (cmd) => {
|
|
395
|
+
cmd.option("--dry-run", "resolve and validate fully, write nothing");
|
|
396
|
+
},
|
|
397
|
+
"--print-plan": (cmd) => {
|
|
398
|
+
cmd.option("--print-plan", "print the resolved launch plan instead of launching");
|
|
399
|
+
},
|
|
400
|
+
"--json": (cmd) => {
|
|
401
|
+
cmd.option("--json", "machine-readable output");
|
|
402
|
+
},
|
|
403
|
+
"--root": (cmd) => {
|
|
404
|
+
cmd.option("--root", "run as root instead of mapping the host uid");
|
|
405
|
+
},
|
|
406
|
+
"--staging": (cmd) => {
|
|
407
|
+
cmd.option("--staging", "clean: wipe .stage only (the default)");
|
|
408
|
+
},
|
|
409
|
+
"--logs": (cmd) => {
|
|
410
|
+
cmd.option("--logs", "clean: wipe the captured run logs");
|
|
411
|
+
},
|
|
412
|
+
"--all": (cmd) => {
|
|
413
|
+
cmd.option("--all", "clean: wipe the whole profile and the game downloads (needs --yes)");
|
|
414
|
+
},
|
|
415
|
+
"--downloads": (cmd) => {
|
|
416
|
+
cmd.option("--downloads", "clean: wipe this game's workshop downloads, keeping steamcmd");
|
|
417
|
+
},
|
|
418
|
+
"--follow": (cmd) => {
|
|
419
|
+
cmd.option("-f, --follow", "keep printing as the run writes");
|
|
420
|
+
},
|
|
421
|
+
"--yes": (cmd) => {
|
|
422
|
+
cmd.option("-y, --yes", "skip destructive-action confirmation");
|
|
423
|
+
},
|
|
424
|
+
"--path": (cmd) => {
|
|
425
|
+
cmd.option("--path <dir>", "mods add: take the mod from this directory");
|
|
426
|
+
},
|
|
427
|
+
"--workshop": (cmd) => {
|
|
428
|
+
cmd.option("--workshop <id>", "mods add: take the mod from this steam workshop item", workshopId);
|
|
429
|
+
},
|
|
430
|
+
"--git": (cmd) => {
|
|
431
|
+
cmd.option("--git <url>", "mods add: clone the mod from this repository");
|
|
432
|
+
},
|
|
433
|
+
"--branch": (cmd) => {
|
|
434
|
+
cmd.option("--branch <name>", "mods add: track this git branch");
|
|
435
|
+
},
|
|
436
|
+
"--tag": (cmd) => {
|
|
437
|
+
cmd.option("--tag <name>", "mods add: pin this git tag");
|
|
438
|
+
},
|
|
439
|
+
"--commit": (cmd) => {
|
|
440
|
+
cmd.option("--commit <sha>", "mods add: pin this git commit");
|
|
441
|
+
},
|
|
442
|
+
"--subdir": (cmd) => {
|
|
443
|
+
cmd.option("--subdir <path>", "mods add: the mod folder inside the repository");
|
|
444
|
+
},
|
|
445
|
+
"--global": (cmd) => {
|
|
446
|
+
cmd.option("--global", "write to the global config");
|
|
447
|
+
},
|
|
448
|
+
"--project": (cmd) => {
|
|
449
|
+
cmd.option("--project", "write to the project config");
|
|
450
|
+
},
|
|
451
|
+
"--force": (cmd) => {
|
|
452
|
+
cmd.option("--force", "mods add: overwrite an existing entry; steam build: rebuild even when the buildid matches");
|
|
453
|
+
},
|
|
454
|
+
"--variant": (cmd) => {
|
|
455
|
+
cmd.option("--variant <name>", "steam build: only this image variant", collect, []);
|
|
456
|
+
},
|
|
457
|
+
"--beta": (cmd) => {
|
|
458
|
+
cmd.option("--beta <name>", "steam build: only this steam branch", collect, []);
|
|
459
|
+
},
|
|
460
|
+
"--plugin": (cmd) => {
|
|
461
|
+
cmd.option("--plugin <spec>", "steam build: an explicit plugin package", collect, []);
|
|
462
|
+
},
|
|
463
|
+
"--image": (cmd) => {
|
|
464
|
+
cmd.option("--image <ref>", "run: launch this gamecrate-built image; steam build: the target repository");
|
|
465
|
+
},
|
|
466
|
+
"--load": (cmd) => {
|
|
467
|
+
cmd.option("--load", "steam build: load the result into the local docker daemon");
|
|
468
|
+
},
|
|
469
|
+
"--push": (cmd) => {
|
|
470
|
+
cmd.option("--push", "steam build: push the result to a registry");
|
|
471
|
+
},
|
|
472
|
+
"--base": (cmd) => {
|
|
473
|
+
cmd.option("--base <ref>", "steam build: override the published runtime base");
|
|
474
|
+
},
|
|
475
|
+
"--platform": (cmd) => {
|
|
476
|
+
cmd.option("--platform <os/arch>", "steam build: what the manifest claims", "linux/amd64");
|
|
477
|
+
},
|
|
478
|
+
"--print": (cmd) => {
|
|
479
|
+
cmd.option("--print", "steam login: also print the session as base64");
|
|
480
|
+
},
|
|
481
|
+
"--username": (cmd) => {
|
|
482
|
+
cmd.option("--username <name>", "steam login: skip the account name prompt");
|
|
483
|
+
},
|
|
484
|
+
"--help": (cmd) => {
|
|
485
|
+
cmd.option("-h, --help", "this help");
|
|
486
|
+
}
|
|
487
|
+
};
|
|
488
|
+
function quiet(cmd) {
|
|
489
|
+
return cmd.exitOverride().helpOption(false).allowExcessArguments(true).showSuggestionAfterError(false).configureOutput({ writeOut: () => {}, writeErr: () => {} });
|
|
490
|
+
}
|
|
491
|
+
function attach(cmd, names) {
|
|
492
|
+
for (const name of names)
|
|
493
|
+
OPTIONS[name]?.(cmd);
|
|
494
|
+
return cmd;
|
|
495
|
+
}
|
|
292
496
|
function buildProgram() {
|
|
293
|
-
const program = new Command;
|
|
294
|
-
program
|
|
497
|
+
const program = quiet(new Command).name("gamecrate").enablePositionalOptions().argument("[args...]");
|
|
498
|
+
attach(program, [...RUN_FLAGS, ...GLOBAL_FLAGS, "--supervised"]);
|
|
499
|
+
track(program);
|
|
500
|
+
for (const sub of SUBCOMMANDS) {
|
|
501
|
+
if (sub.name === "run")
|
|
502
|
+
continue;
|
|
503
|
+
const cmd = quiet(program.command(sub.name)).argument("[args...]");
|
|
504
|
+
attach(cmd, [...sub.flags, ...GLOBAL_FLAGS]);
|
|
505
|
+
track(cmd);
|
|
506
|
+
}
|
|
295
507
|
return program;
|
|
296
508
|
}
|
|
509
|
+
var ACTIVE = new WeakMap;
|
|
510
|
+
function track(cmd) {
|
|
511
|
+
cmd.action((_args, _opts, self) => {
|
|
512
|
+
ACTIVE.set(self.parent ?? self, self);
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
function matched(program) {
|
|
516
|
+
const cmd = ACTIVE.get(program) ?? program;
|
|
517
|
+
const positional = cmd === program ? [...program.args] : [cmd.name(), ...cmd.args];
|
|
518
|
+
return { cmd, positional, values: { ...program.opts(), ...cmd.opts() } };
|
|
519
|
+
}
|
|
520
|
+
function allOptions(program) {
|
|
521
|
+
return [...program.options, ...program.commands.flatMap((c) => c.options)];
|
|
522
|
+
}
|
|
523
|
+
var REFUSALS = {
|
|
524
|
+
"shell --detach": "shell cannot detach: a shell needs the terminal --detach gives up"
|
|
525
|
+
};
|
|
526
|
+
function ownersOf(name) {
|
|
527
|
+
const run = RUN_FLAGS.includes(name) ? ["run"] : [];
|
|
528
|
+
return [...run, ...SUBCOMMANDS.filter((s) => s.name !== "run" && s.flags.includes(name)).map((s) => s.name)];
|
|
529
|
+
}
|
|
297
530
|
function checkValueTokens(program, head) {
|
|
298
|
-
const valued = (name) => program.
|
|
531
|
+
const valued = (name) => allOptions(program).find((o) => (o.long === name || o.short === name) && (o.required || o.optional));
|
|
299
532
|
for (let i = 0;i < head.length; i++) {
|
|
300
533
|
const token = head[i];
|
|
301
534
|
if (!token.startsWith("-") || token === "-")
|
|
@@ -334,18 +567,18 @@ function parseArgs(argv, opts = {}) {
|
|
|
334
567
|
try {
|
|
335
568
|
program.parse(head, { from: "user" });
|
|
336
569
|
} catch (error) {
|
|
337
|
-
throw translate(error, program);
|
|
570
|
+
throw translate(error, program, SUBCOMMANDS.some((x) => x.name === head[0]) ? head[0] : undefined);
|
|
338
571
|
}
|
|
339
572
|
checkRepeats(program, counts);
|
|
340
573
|
checkContradictions(seen);
|
|
341
|
-
const values = program
|
|
342
|
-
const envBuild = applyEnv(
|
|
574
|
+
const { cmd, positional, values } = matched(program);
|
|
575
|
+
const envBuild = applyEnv(cmd, seen, env, values);
|
|
343
576
|
const out = {
|
|
344
577
|
subcommand: "run",
|
|
345
|
-
mods: values["mod"],
|
|
346
|
-
without: values["without"],
|
|
347
|
-
only: values["only"],
|
|
348
|
-
dockerArgs: values["dockerArg"],
|
|
578
|
+
mods: values["mod"] ?? [],
|
|
579
|
+
without: values["without"] ?? [],
|
|
580
|
+
only: values["only"] ?? [],
|
|
581
|
+
dockerArgs: values["dockerArg"] ?? [],
|
|
349
582
|
gameArgs: sep === -1 ? [] : argv.slice(sep + 1),
|
|
350
583
|
dryRun: values["dryRun"] === true,
|
|
351
584
|
printPlan: values["printPlan"] === true,
|
|
@@ -363,7 +596,7 @@ function parseArgs(argv, opts = {}) {
|
|
|
363
596
|
detach: values["detach"] === true,
|
|
364
597
|
noDetach: seen.has("--no-detach"),
|
|
365
598
|
supervised: typeof values["supervised"] === "string",
|
|
366
|
-
use: values["use"],
|
|
599
|
+
use: values["use"] ?? [],
|
|
367
600
|
rest: []
|
|
368
601
|
};
|
|
369
602
|
out.mode = values["mode"];
|
|
@@ -378,10 +611,20 @@ function parseArgs(argv, opts = {}) {
|
|
|
378
611
|
out.instance = values["instance"];
|
|
379
612
|
out.build = envBuild ?? policy(values["build"]);
|
|
380
613
|
out.cleanTier = log.cleanTier;
|
|
614
|
+
out.variant = values["variant"] ?? [];
|
|
615
|
+
out.branches = values["beta"] ?? [];
|
|
616
|
+
out.plugin = values["plugin"] ?? [];
|
|
617
|
+
out.image = values["image"];
|
|
618
|
+
out.load = values["load"] === true;
|
|
619
|
+
out.push = values["push"] === true;
|
|
620
|
+
out.base = values["base"];
|
|
621
|
+
out.platform = values["platform"] ?? "linux/amd64";
|
|
622
|
+
out.print = values["print"] === true;
|
|
623
|
+
out.username = values["username"];
|
|
381
624
|
if (opts.defaults?.game !== undefined)
|
|
382
625
|
out.game = opts.defaults.game;
|
|
383
|
-
applyPositionals(out,
|
|
384
|
-
if (out.subverb !== undefined && !out.help) {
|
|
626
|
+
applyPositionals(out, positional, opts.games, out.help);
|
|
627
|
+
if (out.subcommand === "mods" && out.subverb !== undefined && !out.help) {
|
|
385
628
|
if (out.subverb === "add")
|
|
386
629
|
out.source = modSource(values);
|
|
387
630
|
if (out.subverb !== "sync")
|
|
@@ -389,29 +632,28 @@ function parseArgs(argv, opts = {}) {
|
|
|
389
632
|
}
|
|
390
633
|
if (opts.defaults !== undefined)
|
|
391
634
|
applyDefaults(out, seen, opts.defaults, sep !== -1);
|
|
392
|
-
if (seen.has("--detach") && out.subcommand === "shell") {
|
|
393
|
-
throw usage("shell cannot detach: a shell needs the terminal --detach gives up");
|
|
394
|
-
}
|
|
395
635
|
return out;
|
|
396
636
|
}
|
|
397
637
|
function recordFlags(program) {
|
|
398
638
|
const log = { seen: new Set, counts: new Map, worktree: [] };
|
|
399
|
-
for (const
|
|
400
|
-
const
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
639
|
+
for (const cmd of [program, ...program.commands]) {
|
|
640
|
+
for (const option of cmd.options) {
|
|
641
|
+
const long = option.long ?? option.flags;
|
|
642
|
+
cmd.on(`option:${option.name()}`, (value) => {
|
|
643
|
+
log.seen.add(long);
|
|
644
|
+
log.counts.set(long, (log.counts.get(long) ?? 0) + 1);
|
|
645
|
+
if (long === "--worktree" && value !== undefined)
|
|
646
|
+
log.worktree.push(value);
|
|
647
|
+
if (long === "--staging" || long === "--logs" || long === "--all" || long === "--downloads") {
|
|
648
|
+
log.cleanTier = long.slice(2);
|
|
649
|
+
}
|
|
650
|
+
});
|
|
651
|
+
}
|
|
410
652
|
}
|
|
411
653
|
return log;
|
|
412
654
|
}
|
|
413
655
|
function checkRepeats(program, counts) {
|
|
414
|
-
for (const option of program
|
|
656
|
+
for (const option of allOptions(program)) {
|
|
415
657
|
const long = option.long ?? option.flags;
|
|
416
658
|
const repeatable = Array.isArray(option.defaultValue);
|
|
417
659
|
if (option.required && !repeatable && (counts.get(long) ?? 0) > 1) {
|
|
@@ -440,15 +682,22 @@ function policy(value) {
|
|
|
440
682
|
return "never";
|
|
441
683
|
return;
|
|
442
684
|
}
|
|
443
|
-
function translate(error, program) {
|
|
685
|
+
function translate(error, program, verb) {
|
|
444
686
|
if (!(error instanceof CommanderError))
|
|
445
687
|
return error;
|
|
446
688
|
const token = /'([^']+)'/.exec(error.message)?.[1] ?? "";
|
|
447
689
|
if (error.code === "commander.unknownOption") {
|
|
448
690
|
const name = token.split("=")[0];
|
|
449
|
-
const
|
|
450
|
-
if (
|
|
691
|
+
const here = (verb === undefined ? program : program.commands.find((c) => c.name() === verb))?.options;
|
|
692
|
+
if (here?.some((o) => o.long === name || o.short === name))
|
|
451
693
|
return usage(`${name} takes no value`);
|
|
694
|
+
const bespoke = REFUSALS[`${verb ?? "run"} ${name}`];
|
|
695
|
+
if (bespoke !== undefined)
|
|
696
|
+
return usage(bespoke);
|
|
697
|
+
const owners = ownersOf(name);
|
|
698
|
+
if (owners.length > 0) {
|
|
699
|
+
return usage(`${verb ?? "run"} does not take ${name}`, owners.map((o) => `gamecrate ${o}`).join(" or "));
|
|
700
|
+
}
|
|
452
701
|
return usage(`unknown flag ${name}`, suggest(name, flagNames(program)));
|
|
453
702
|
}
|
|
454
703
|
if (error.code === "commander.optionMissingArgument") {
|
|
@@ -473,6 +722,13 @@ function applyPositionals(out, positional, games, help = false) {
|
|
|
473
722
|
if (out.subverb === "rm" && out.rest.length === 0)
|
|
474
723
|
throw usage("mods rm needs at least one mod id");
|
|
475
724
|
}
|
|
725
|
+
if (!help && out.subcommand === "steam") {
|
|
726
|
+
if (out.subverb === undefined) {
|
|
727
|
+
throw usage("steam needs a subverb", "gamecrate steam build <game>, or gamecrate steam login");
|
|
728
|
+
}
|
|
729
|
+
if (out.subverb === "build" && out.game === undefined)
|
|
730
|
+
throw usage("steam build needs a game");
|
|
731
|
+
}
|
|
476
732
|
if (left.length > 0) {
|
|
477
733
|
const shape = sub ? `${sub.name} ${sub.usage}`.trim() : `${out.game} [profile]`;
|
|
478
734
|
throw usage(`unexpected argument ${left[0]}`, `gamecrate ${shape}`);
|
|
@@ -526,6 +782,8 @@ function applyEnv(program, seen, env, values) {
|
|
|
526
782
|
continue;
|
|
527
783
|
if (flag === "--build" && seen.has("--no-build"))
|
|
528
784
|
continue;
|
|
785
|
+
if (!program.options.some((o) => o.long === flag))
|
|
786
|
+
continue;
|
|
529
787
|
const raw = env[name];
|
|
530
788
|
if (raw === undefined || raw === "")
|
|
531
789
|
continue;
|
|
@@ -546,6 +804,8 @@ function envBuildPolicy(name, raw) {
|
|
|
546
804
|
}
|
|
547
805
|
function applyEnvValue(program, flag, name, raw, values) {
|
|
548
806
|
const option = program.options.find((o) => o.long === flag);
|
|
807
|
+
if (option === undefined)
|
|
808
|
+
return;
|
|
549
809
|
const key = option.attributeName();
|
|
550
810
|
if (!option.required) {
|
|
551
811
|
if (truthy(raw))
|
|
@@ -624,7 +884,7 @@ function truthy(value) {
|
|
|
624
884
|
return value === "1" || value.toLowerCase() === "true" || value.toLowerCase() === "yes";
|
|
625
885
|
}
|
|
626
886
|
function flagNames(program) {
|
|
627
|
-
return program.
|
|
887
|
+
return allOptions(program).flatMap((o) => [o.long, o.short].filter((f) => f !== undefined));
|
|
628
888
|
}
|
|
629
889
|
function seconds(flag, value) {
|
|
630
890
|
const n = Number(value);
|
|
@@ -851,7 +1111,7 @@ import { readFileSync, statSync } from "node:fs";
|
|
|
851
1111
|
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
852
1112
|
import { pathToFileURL } from "node:url";
|
|
853
1113
|
import { exports as exportsField, legacy } from "resolve.exports";
|
|
854
|
-
var PLUGIN_API_VERSION =
|
|
1114
|
+
var PLUGIN_API_VERSION = 2;
|
|
855
1115
|
var REQUIRED_FUNCTIONS = [
|
|
856
1116
|
"parseManifest",
|
|
857
1117
|
"renderModsConfig",
|
|
@@ -1146,6 +1406,8 @@ var profile = obj({
|
|
|
1146
1406
|
alias: str.optional(),
|
|
1147
1407
|
aliases: strArray.optional(),
|
|
1148
1408
|
description: str.optional(),
|
|
1409
|
+
gameVersion: str.optional(),
|
|
1410
|
+
image: str.optional(),
|
|
1149
1411
|
detach: bool.optional(),
|
|
1150
1412
|
replace: bool.optional(),
|
|
1151
1413
|
build: oneOf(["auto", "always", "never"]).optional()
|
|
@@ -1190,6 +1452,95 @@ var libraryEntry = obj({
|
|
|
1190
1452
|
push('"subdir" must be a relative path inside the repo, with no ".." segment', ["subdir"]);
|
|
1191
1453
|
}
|
|
1192
1454
|
});
|
|
1455
|
+
function repeats(entries, key) {
|
|
1456
|
+
const seen = new Set;
|
|
1457
|
+
const found = [];
|
|
1458
|
+
entries.forEach((entry, index) => {
|
|
1459
|
+
const name = entry?.[key];
|
|
1460
|
+
if (typeof name !== "string")
|
|
1461
|
+
return;
|
|
1462
|
+
if (seen.has(name))
|
|
1463
|
+
found.push({ index, name });
|
|
1464
|
+
else
|
|
1465
|
+
seen.add(name);
|
|
1466
|
+
});
|
|
1467
|
+
return found;
|
|
1468
|
+
}
|
|
1469
|
+
var TAG_COMPONENT = /^[A-Za-z0-9_][A-Za-z0-9._-]*$/;
|
|
1470
|
+
function steamBuildRules(ctx) {
|
|
1471
|
+
const push = (message, path, suggestion) => {
|
|
1472
|
+
ctx.issues.push({
|
|
1473
|
+
code: "custom",
|
|
1474
|
+
message,
|
|
1475
|
+
path,
|
|
1476
|
+
input: ctx.value,
|
|
1477
|
+
...suggestion === undefined ? {} : { params: { suggestion } }
|
|
1478
|
+
});
|
|
1479
|
+
};
|
|
1480
|
+
const branches = ctx.value["branches"];
|
|
1481
|
+
const variants = ctx.value["variants"];
|
|
1482
|
+
if (Array.isArray(variants) && variants.length === 0) {
|
|
1483
|
+
push("steamBuild.variants cannot be empty", ["variants"]);
|
|
1484
|
+
}
|
|
1485
|
+
if (Array.isArray(branches)) {
|
|
1486
|
+
if (branches.length === 0)
|
|
1487
|
+
push("steamBuild.branches cannot be empty", ["branches"]);
|
|
1488
|
+
for (const dup of repeats(branches, "name")) {
|
|
1489
|
+
push(`duplicate branch name "${dup.name}"`, ["branches", dup.index, "name"]);
|
|
1490
|
+
}
|
|
1491
|
+
branches.forEach((branch, index) => {
|
|
1492
|
+
const name = branch?.["name"];
|
|
1493
|
+
if (typeof name === "string" && !TAG_COMPONENT.test(name)) {
|
|
1494
|
+
push(`branch name "${name}" must match ${TAG_COMPONENT.source}`, ["branches", index, "name"]);
|
|
1495
|
+
}
|
|
1496
|
+
const tags = branch?.["tags"];
|
|
1497
|
+
if (!Array.isArray(tags))
|
|
1498
|
+
return;
|
|
1499
|
+
tags.forEach((tag, at) => {
|
|
1500
|
+
if (typeof tag !== "string" || TAG_COMPONENT.test(tag))
|
|
1501
|
+
return;
|
|
1502
|
+
push(`branch tag "${String(tag)}" must match ${TAG_COMPONENT.source}`, ["branches", index, "tags", at]);
|
|
1503
|
+
});
|
|
1504
|
+
});
|
|
1505
|
+
}
|
|
1506
|
+
if (!Array.isArray(variants))
|
|
1507
|
+
return;
|
|
1508
|
+
for (const dup of repeats(variants, "name")) {
|
|
1509
|
+
push(`duplicate variant name "${dup.name}"`, ["variants", dup.index, "name"]);
|
|
1510
|
+
}
|
|
1511
|
+
variants.forEach((variant, index) => {
|
|
1512
|
+
const v = variant;
|
|
1513
|
+
const base = v?.["base"];
|
|
1514
|
+
if (base !== "xvfb" && base !== "proton")
|
|
1515
|
+
return;
|
|
1516
|
+
const depot = v?.["depot"] ?? "linux";
|
|
1517
|
+
if (depot === "macos") {
|
|
1518
|
+
push("a macos depot cannot be runnable", ["variants", index, "base"], 'set base to "none"; no macos container runtime exists');
|
|
1519
|
+
return;
|
|
1520
|
+
}
|
|
1521
|
+
const wants = depot === "windows" ? "proton" : "xvfb";
|
|
1522
|
+
if (base === wants)
|
|
1523
|
+
return;
|
|
1524
|
+
push(`a ${String(depot)} depot cannot run on the "${base}" base`, ["variants", index, "base"], depot === "windows" ? 'set base to "proton"; it is the only base with wine' : 'set base to "xvfb", or set depot to "windows" if the image should run under wine');
|
|
1525
|
+
});
|
|
1526
|
+
}
|
|
1527
|
+
var steamBuildSchema = obj({
|
|
1528
|
+
branches: z.array(obj({
|
|
1529
|
+
name: str,
|
|
1530
|
+
password: bool.optional(),
|
|
1531
|
+
tags: strArray.optional(),
|
|
1532
|
+
executable: z.record(str, str).optional()
|
|
1533
|
+
}), {
|
|
1534
|
+
error: "expected an array"
|
|
1535
|
+
}),
|
|
1536
|
+
variants: z.array(obj({
|
|
1537
|
+
name: str,
|
|
1538
|
+
depot: oneOf(["linux", "windows", "macos"]).optional(),
|
|
1539
|
+
base: oneOf(["xvfb", "proton", "none"]),
|
|
1540
|
+
include: strArray,
|
|
1541
|
+
executable: str.optional()
|
|
1542
|
+
}), { error: "expected an array" })
|
|
1543
|
+
}).check(steamBuildRules);
|
|
1193
1544
|
var game = obj({
|
|
1194
1545
|
gameFiles: obj({ source: oneOf(["mount", "image"]), host: str.optional(), container: str }).check(requiredWhen("host", (v) => v["source"] === "mount")),
|
|
1195
1546
|
dataDir: obj({
|
|
@@ -1200,7 +1551,12 @@ var game = obj({
|
|
|
1200
1551
|
}).check(requiredWhen("arg", (v) => v["mode"] === "arg"), requiredWhen("env", (v) => v["mode"] === "env")),
|
|
1201
1552
|
modsDir: obj({ container: str, mask: strArray.optional() }),
|
|
1202
1553
|
logFile: obj({ mode: oneOf(["arg", "copy-out"]), arg: str.optional(), from: str.optional() }).check(requiredWhen("arg", (v) => v["mode"] === "arg"), requiredWhen("from", (v) => v["mode"] === "copy-out")),
|
|
1203
|
-
image: obj({
|
|
1554
|
+
image: obj({
|
|
1555
|
+
ref: str,
|
|
1556
|
+
acquire: oneOf(["pull", "build"]),
|
|
1557
|
+
context: str.optional(),
|
|
1558
|
+
updates: obj({ check: bool.optional(), everyHours: num.optional() }).optional()
|
|
1559
|
+
}).check(requiredWhen("context", (v) => v["acquire"] === "build")),
|
|
1204
1560
|
executable: str,
|
|
1205
1561
|
steamAppId: num,
|
|
1206
1562
|
workshopRoot: z.union([z.string(), z.null()], { error: "expected a string or null" }),
|
|
@@ -1210,6 +1566,8 @@ var game = obj({
|
|
|
1210
1566
|
manifest: obj({ file: str }),
|
|
1211
1567
|
modsConfig: obj({ file: str }),
|
|
1212
1568
|
prefs: obj({ file: str }),
|
|
1569
|
+
version: obj({ file: str }),
|
|
1570
|
+
steamBuild: steamBuildSchema,
|
|
1213
1571
|
saveExtensions: strArray,
|
|
1214
1572
|
core: str,
|
|
1215
1573
|
dlc: strArray,
|
|
@@ -1299,6 +1657,7 @@ function crossReference(p, games) {
|
|
|
1299
1657
|
checkName(p, where, gameName, "game");
|
|
1300
1658
|
if (!isObj(game_))
|
|
1301
1659
|
continue;
|
|
1660
|
+
checkVariantNames(p, where, game_);
|
|
1302
1661
|
const profiles = game_["profiles"];
|
|
1303
1662
|
if (!isObj(profiles))
|
|
1304
1663
|
continue;
|
|
@@ -1311,6 +1670,18 @@ function crossReference(p, games) {
|
|
|
1311
1670
|
checkCollisions(p, where, gameName, profiles, containers);
|
|
1312
1671
|
}
|
|
1313
1672
|
}
|
|
1673
|
+
function checkVariantNames(p, where, game_) {
|
|
1674
|
+
const steamBuild = game_["steamBuild"];
|
|
1675
|
+
const variants = isObj(steamBuild) ? steamBuild["variants"] : undefined;
|
|
1676
|
+
if (!Array.isArray(variants))
|
|
1677
|
+
return;
|
|
1678
|
+
variants.forEach((variant, index) => {
|
|
1679
|
+
const name = isObj(variant) ? variant["name"] : undefined;
|
|
1680
|
+
if (typeof name !== "string")
|
|
1681
|
+
return;
|
|
1682
|
+
checkName(p, `${where}/steamBuild/variants/${index}/name`, name, "variant");
|
|
1683
|
+
});
|
|
1684
|
+
}
|
|
1314
1685
|
function checkProfile(p, w, name, prof, profiles) {
|
|
1315
1686
|
const names = Object.keys(profiles);
|
|
1316
1687
|
checkExtends(p, w, prof, profiles, names);
|
|
@@ -1618,7 +1989,7 @@ async function loadConfig(path, project) {
|
|
|
1618
1989
|
defaults: { settings: structuredClone(DEFAULT_SETTINGS) },
|
|
1619
1990
|
games: Object.fromEntries([...plugins].map(([name, plugin]) => [name, structuredClone(plugin.defaults)]))
|
|
1620
1991
|
};
|
|
1621
|
-
const merged = user === undefined || user === null ? base :
|
|
1992
|
+
const merged = user === undefined || user === null ? base : mergeUserConfig(base, user);
|
|
1622
1993
|
const spliced = applyProject(merged, project);
|
|
1623
1994
|
const { config, problems } = validateConfig(spliced);
|
|
1624
1995
|
if (problems.length > 0) {
|
|
@@ -1731,6 +2102,11 @@ function resolveNamed(game, name, seen) {
|
|
|
1731
2102
|
const auto = self.autoDependencies ?? parent.autoDependencies;
|
|
1732
2103
|
if (auto !== undefined)
|
|
1733
2104
|
out.autoDependencies = auto;
|
|
2105
|
+
for (const field of ["detach", "replace", "build", "gameVersion", "image"]) {
|
|
2106
|
+
const value = self[field] ?? parent[field];
|
|
2107
|
+
if (value !== undefined)
|
|
2108
|
+
Object.assign(out, { [field]: value });
|
|
2109
|
+
}
|
|
1734
2110
|
return out;
|
|
1735
2111
|
}
|
|
1736
2112
|
function canonicalProfile(game, name) {
|
|
@@ -1789,6 +2165,47 @@ function globToRegExp(pattern) {
|
|
|
1789
2165
|
const body = pattern.replaceAll(/[.+^${}()|[\]\\]/g, String.raw`\$&`).replaceAll("*", ".*").replaceAll("?", ".");
|
|
1790
2166
|
return new RegExp(`^${body}$`, "i");
|
|
1791
2167
|
}
|
|
2168
|
+
function mergeUserConfig(base, user) {
|
|
2169
|
+
const out = deepMerge(base, user);
|
|
2170
|
+
const games = isObj(user) ? user["games"] : undefined;
|
|
2171
|
+
if (!isObj(games))
|
|
2172
|
+
return out;
|
|
2173
|
+
for (const name of Object.keys(games)) {
|
|
2174
|
+
const theirs = own(games, name);
|
|
2175
|
+
const steamBuild = isObj(theirs) ? theirs["steamBuild"] : undefined;
|
|
2176
|
+
const added = isObj(steamBuild) ? steamBuild["branches"] : undefined;
|
|
2177
|
+
const declared = own(base.games, name)?.steamBuild?.branches;
|
|
2178
|
+
const target = own(out.games, name);
|
|
2179
|
+
if (!Array.isArray(added) || !Array.isArray(declared) || target === undefined)
|
|
2180
|
+
continue;
|
|
2181
|
+
target.steamBuild.branches = concatBranches(declared, added);
|
|
2182
|
+
}
|
|
2183
|
+
return out;
|
|
2184
|
+
}
|
|
2185
|
+
function branchName(entry) {
|
|
2186
|
+
return isObj(entry) && typeof entry["name"] === "string" ? entry["name"] : undefined;
|
|
2187
|
+
}
|
|
2188
|
+
function concatBranches(declared, added) {
|
|
2189
|
+
const out = [...declared];
|
|
2190
|
+
const at = new Map;
|
|
2191
|
+
out.forEach((branch, index) => {
|
|
2192
|
+
const name = branchName(branch);
|
|
2193
|
+
if (name !== undefined && !at.has(name))
|
|
2194
|
+
at.set(name, index);
|
|
2195
|
+
});
|
|
2196
|
+
for (const entry of added) {
|
|
2197
|
+
const name = branchName(entry);
|
|
2198
|
+
const index = name === undefined ? undefined : at.get(name);
|
|
2199
|
+
if (index === undefined) {
|
|
2200
|
+
if (name !== undefined)
|
|
2201
|
+
at.set(name, out.length);
|
|
2202
|
+
out.push(entry);
|
|
2203
|
+
continue;
|
|
2204
|
+
}
|
|
2205
|
+
out[index] = deepMerge(out[index], entry);
|
|
2206
|
+
}
|
|
2207
|
+
return out;
|
|
2208
|
+
}
|
|
1792
2209
|
function deepMerge(base, over, concatArrays = false) {
|
|
1793
2210
|
if (Array.isArray(base) && Array.isArray(over)) {
|
|
1794
2211
|
return concatArrays ? [...base, ...over] : [...over];
|
|
@@ -1933,7 +2350,12 @@ var CONTAINER_XDG_DIR = "/xdg";
|
|
|
1933
2350
|
var RUNTIME_DIR_SIZE = "64m";
|
|
1934
2351
|
var HOME_SIZE = "64m";
|
|
1935
2352
|
var MASK_SIZE = "1m";
|
|
1936
|
-
function
|
|
2353
|
+
function refuseProtonHeaded(game, mode, image) {
|
|
2354
|
+
if (image?.launcher !== "proton" || mode !== "headed")
|
|
2355
|
+
return;
|
|
2356
|
+
throw new GamecrateError(`${game}: a proton image only runs offscreen`, Exit.Config, 'relaunch with --mode headless, or use a variant whose gamecrate.launcher is "direct"');
|
|
2357
|
+
}
|
|
2358
|
+
function buildRunSpec(plan, modMounts, identity, image) {
|
|
1937
2359
|
const { gameConfig: game, settings } = plan;
|
|
1938
2360
|
const headed = plan.mode === "headed";
|
|
1939
2361
|
const mounts = [];
|
|
@@ -1944,19 +2366,32 @@ function buildRunSpec(plan, modMounts, identity) {
|
|
|
1944
2366
|
};
|
|
1945
2367
|
addGameFiles(mounts, plan);
|
|
1946
2368
|
addStage(mounts, plan, modMounts);
|
|
1947
|
-
const
|
|
2369
|
+
const proton = image?.launcher === "proton";
|
|
2370
|
+
const executable = image?.executable ?? game.executable;
|
|
2371
|
+
refuseProtonHeaded(plan.game, plan.mode, image);
|
|
2372
|
+
const command = proton ? ["run-headless-windows", winPath(join4(game.gameFiles.container, basename3(executable)))] : headed ? [executable] : [
|
|
1948
2373
|
"xvfb-run",
|
|
1949
2374
|
"-a",
|
|
1950
2375
|
`--server-args=-screen 0 ${settings.width}x${settings.height}x24`,
|
|
1951
|
-
|
|
2376
|
+
executable
|
|
1952
2377
|
];
|
|
1953
|
-
if (
|
|
1954
|
-
|
|
1955
|
-
|
|
2378
|
+
if (proton) {
|
|
2379
|
+
Object.assign(env, {
|
|
2380
|
+
SCREEN: `${settings.width}x${settings.height}x24`,
|
|
2381
|
+
DESKTOP: `${settings.width}x${settings.height}`,
|
|
2382
|
+
STEAM_COMPAT_DATA_PATH: `${CONTAINER_XDG_DIR}/proton`
|
|
2383
|
+
});
|
|
2384
|
+
}
|
|
2385
|
+
if (game.dataDir.mode === "arg") {
|
|
2386
|
+
const arg = validateDataDirArg(game.dataDir);
|
|
2387
|
+
const eq = arg.indexOf("=");
|
|
2388
|
+
command.push(proton ? `${arg.slice(0, eq + 1)}${winPath(arg.slice(eq + 1))}` : arg);
|
|
2389
|
+
} else
|
|
1956
2390
|
Object.assign(env, game.dataDir.env);
|
|
1957
2391
|
if (game.logFile.mode === "arg") {
|
|
1958
2392
|
mounts.push({ type: "bind", source: hostPath(plan.runDirHost), target: CONTAINER_LOG_DIR });
|
|
1959
|
-
|
|
2393
|
+
const log = `${CONTAINER_LOG_DIR}/Player.log`;
|
|
2394
|
+
command.push(game.logFile.arg, proton ? winPath(log) : log);
|
|
1960
2395
|
}
|
|
1961
2396
|
addScratch(mounts, env, plan, identity);
|
|
1962
2397
|
if (headed)
|
|
@@ -2122,6 +2557,9 @@ function csvField(field) {
|
|
|
2122
2557
|
return field;
|
|
2123
2558
|
return `"${field.replaceAll('"', '""')}"`;
|
|
2124
2559
|
}
|
|
2560
|
+
function winPath(unix) {
|
|
2561
|
+
return `Z:${unix.replaceAll("/", "\\")}`;
|
|
2562
|
+
}
|
|
2125
2563
|
function validateDataDirArg(dataDir) {
|
|
2126
2564
|
if (dataDir.container.includes("=")) {
|
|
2127
2565
|
throw new GamecrateError(`container data path contains "=": ${dataDir.container}`, Exit.Config, "RimWorld silently ignores -savedatafolder when the argv element does not split into exactly two parts, and the save is lost with --rm.");
|
|
@@ -2224,6 +2662,18 @@ async function capture(argv) {
|
|
|
2224
2662
|
return { code: 127, stdout: "", stderr: error instanceof Error ? error.message : String(error) };
|
|
2225
2663
|
}
|
|
2226
2664
|
}
|
|
2665
|
+
async function captureLive(argv, env) {
|
|
2666
|
+
const proc = spawn(argv[0], argv.slice(1), { stdio: ["ignore", "pipe", "pipe"], env });
|
|
2667
|
+
const chunks = [];
|
|
2668
|
+
const keep = async (stream) => {
|
|
2669
|
+
for await (const chunk of stream) {
|
|
2670
|
+
process.stderr.write(chunk);
|
|
2671
|
+
chunks.push(chunk);
|
|
2672
|
+
}
|
|
2673
|
+
};
|
|
2674
|
+
const [, , code] = await Promise.all([keep(proc.stdout), keep(proc.stderr), exited(proc)]);
|
|
2675
|
+
return { code, text: Buffer.concat(chunks).toString("utf8") };
|
|
2676
|
+
}
|
|
2227
2677
|
var STDOUT_LOG = "stdout.log";
|
|
2228
2678
|
var MARKER_POLL_MS = 200;
|
|
2229
2679
|
async function runContainer(spec, opts) {
|
|
@@ -2640,13 +3090,9 @@ function rotateRuns(logsDir, keep) {
|
|
|
2640
3090
|
}
|
|
2641
3091
|
|
|
2642
3092
|
// src/launch/prepare.ts
|
|
2643
|
-
async function inherit(argv
|
|
2644
|
-
const proc = spawnArgv(argv, [
|
|
3093
|
+
async function inherit(argv) {
|
|
3094
|
+
const proc = spawnArgv(argv, ["ignore", "pipe", "pipe"]);
|
|
2645
3095
|
const code = exited(proc);
|
|
2646
|
-
if (stdin !== undefined) {
|
|
2647
|
-
proc.stdin.on("error", () => {});
|
|
2648
|
-
proc.stdin.end(stdin);
|
|
2649
|
-
}
|
|
2650
3096
|
await Promise.all([
|
|
2651
3097
|
forwardOutput(proc.stdout, process.stdout),
|
|
2652
3098
|
forwardOutput(proc.stderr, process.stderr)
|
|
@@ -2658,6 +3104,10 @@ async function imageDigest(ref) {
|
|
|
2658
3104
|
const id = stdout.trim();
|
|
2659
3105
|
return code === 0 && id.length > 0 ? id : null;
|
|
2660
3106
|
}
|
|
3107
|
+
async function readFromImage(ref, path) {
|
|
3108
|
+
const { code, stdout } = await capture(["docker", "run", "--rm", "--entrypoint", "cat", ref, path]);
|
|
3109
|
+
return code === 0 ? stdout : null;
|
|
3110
|
+
}
|
|
2661
3111
|
async function imageLabel(ref, label) {
|
|
2662
3112
|
const format = `{{index .Config.Labels "${label}"}}`;
|
|
2663
3113
|
const { code, stdout } = await capture(["docker", "image", "inspect", "--format", format, ref]);
|
|
@@ -2694,44 +3144,6 @@ async function buildImage(game, image, present, pull) {
|
|
|
2694
3144
|
throw new GamecrateError(`docker build failed for ${image.ref}`, Exit.Environment);
|
|
2695
3145
|
}
|
|
2696
3146
|
}
|
|
2697
|
-
var RUNTIME_PACKAGES = ["xorg-server-xvfb", "xorg-xwd", "imagemagick", "mesa", "ttf-dejavu"];
|
|
2698
|
-
var RUNTIME_SUFFIX = "-gamecrate";
|
|
2699
|
-
var BASE_LABEL = "gamecrate.base";
|
|
2700
|
-
function runtimeLayerRef(ref) {
|
|
2701
|
-
const at = ref.indexOf("@");
|
|
2702
|
-
const head = at > 0 ? ref.slice(0, at) : ref;
|
|
2703
|
-
const colon = head.lastIndexOf(":");
|
|
2704
|
-
const tagged = colon > head.lastIndexOf("/");
|
|
2705
|
-
const name = tagged ? head.slice(0, colon) : head;
|
|
2706
|
-
if (at > 0) {
|
|
2707
|
-
const digest = ref.slice(at + 1);
|
|
2708
|
-
const hex = digest.slice(digest.indexOf(":") + 1);
|
|
2709
|
-
return `${name}:sha-${hex.slice(0, 12)}${RUNTIME_SUFFIX}`;
|
|
2710
|
-
}
|
|
2711
|
-
return `${name}:${tagged ? head.slice(colon + 1) : "latest"}${RUNTIME_SUFFIX}`;
|
|
2712
|
-
}
|
|
2713
|
-
async function ensureRuntimeLayer(ref) {
|
|
2714
|
-
const derived = runtimeLayerRef(ref);
|
|
2715
|
-
const base = await imageDigest(ref);
|
|
2716
|
-
if (base !== null && await imageLabel(derived, BASE_LABEL) === base)
|
|
2717
|
-
return derived;
|
|
2718
|
-
const pacman = `pacman -Syu --noconfirm --needed ${RUNTIME_PACKAGES.join(" ")} && pacman -Scc --noconfirm`;
|
|
2719
|
-
const apt = "apt-get update && apt-get install -y --no-install-recommends" + " xvfb x11-apps imagemagick libgl1-mesa-dri fonts-dejavu && rm -rf /var/lib/apt/lists/*";
|
|
2720
|
-
const install = `if command -v pacman >/dev/null 2>&1; then ${pacman};` + ` elif command -v apt-get >/dev/null 2>&1; then ${apt};` + ' else echo "no supported package manager in the base image" >&2; exit 1; fi';
|
|
2721
|
-
const dockerfile = [
|
|
2722
|
-
`FROM ${ref}`,
|
|
2723
|
-
"USER root",
|
|
2724
|
-
`RUN ${install}`,
|
|
2725
|
-
"RUN command -v xvfb-run && command -v Xvfb",
|
|
2726
|
-
`LABEL ${BASE_LABEL}=${base}`
|
|
2727
|
-
].join(`
|
|
2728
|
-
`);
|
|
2729
|
-
const code = await inherit(["docker", "build", "--tag", derived, "-f", "-", "."], new TextEncoder().encode(dockerfile));
|
|
2730
|
-
if (code !== 0) {
|
|
2731
|
-
throw new GamecrateError(`could not build the offscreen runtime layer ${derived}`, Exit.Environment, "headless and screenshot modes need an X server in the image");
|
|
2732
|
-
}
|
|
2733
|
-
return derived;
|
|
2734
|
-
}
|
|
2735
3147
|
async function buildTarget(dir) {
|
|
2736
3148
|
let entries;
|
|
2737
3149
|
try {
|
|
@@ -2920,7 +3332,7 @@ function bootTime() {
|
|
|
2920
3332
|
async function captureScreenshot(container, plan) {
|
|
2921
3333
|
const name = `${plan.game}.png`;
|
|
2922
3334
|
const target = `${CONTAINER_LOG_DIR}/${name}`;
|
|
2923
|
-
const script = 'D=":$(ls /tmp/.X11-unix 2>/dev/null | head -1 | tr -d X)";' + ' [ "$D" = ":" ] && { echo "no X socket in the container" >&2; exit 1; };' + ` import -display "$D" -window root ${target} 2>/dev/null` + ` || xwd -root -display "$D" |
|
|
3335
|
+
const script = 'D=":$(ls /tmp/.X11-unix 2>/dev/null | head -1 | tr -d X)";' + ' [ "$D" = ":" ] && { echo "no X socket in the container" >&2; exit 1; };' + " X=$(ls -d /tmp/xvfb-run.*/Xauthority 2>/dev/null | head -1);" + ' [ -n "$X" ] && export XAUTHORITY="$X";' + " M=$(command -v magick || command -v convert);" + ' [ -z "$M" ] && { echo "no imagemagick in the container" >&2; exit 1; };' + ` import -display "$D" -window root ${target} 2>/dev/null` + ` || xwd -root -display "$D" | "$M" xwd:- ${target}`;
|
|
2924
3336
|
const code = await inherit(["docker", "exec", container, "sh", "-c", script]);
|
|
2925
3337
|
const host = join8(plan.runDirHost, name);
|
|
2926
3338
|
if (code !== 0 || !existsSync3(host))
|
|
@@ -3227,7 +3639,17 @@ async function prepareSources(game, profileName, args, dataRoot, allowFetch) {
|
|
|
3227
3639
|
|
|
3228
3640
|
// src/mods/steamcmd.ts
|
|
3229
3641
|
import { spawnSync as spawnSync2 } from "node:child_process";
|
|
3230
|
-
import {
|
|
3642
|
+
import { randomBytes } from "node:crypto";
|
|
3643
|
+
import {
|
|
3644
|
+
accessSync,
|
|
3645
|
+
constants,
|
|
3646
|
+
existsSync as existsSync5,
|
|
3647
|
+
mkdirSync as mkdirSync3,
|
|
3648
|
+
readFileSync as readFileSync3,
|
|
3649
|
+
rmSync as rmSync2,
|
|
3650
|
+
statSync as statSync2,
|
|
3651
|
+
writeFileSync
|
|
3652
|
+
} from "node:fs";
|
|
3231
3653
|
import { readdir as readdir5, rm as rm2 } from "node:fs/promises";
|
|
3232
3654
|
import { delimiter, dirname as dirname5, join as join10 } from "node:path";
|
|
3233
3655
|
import { setTimeout as sleep4 } from "node:timers/promises";
|
|
@@ -3235,6 +3657,19 @@ var STEAMCMD_IMAGE = "steamcmd/steamcmd";
|
|
|
3235
3657
|
function steamHome(dataRoot) {
|
|
3236
3658
|
return join10(dataRoot, "steam");
|
|
3237
3659
|
}
|
|
3660
|
+
function accountFile(dataRoot) {
|
|
3661
|
+
return join10(steamHome(dataRoot), "account");
|
|
3662
|
+
}
|
|
3663
|
+
function steamAccount(dataRoot) {
|
|
3664
|
+
const fromEnv = process.env.STEAM_USERNAME;
|
|
3665
|
+
if (fromEnv !== undefined && fromEnv !== "")
|
|
3666
|
+
return fromEnv;
|
|
3667
|
+
const file = accountFile(dataRoot);
|
|
3668
|
+
const saved = existsSync5(file) ? readFileSync3(file, "utf8").trim() : "";
|
|
3669
|
+
if (saved !== "")
|
|
3670
|
+
return saved;
|
|
3671
|
+
throw new GamecrateError("a game download needs a steam account", Exit.Environment, `set STEAM_USERNAME, or run \`gamecrate steam login\` once to record one at ${file}`);
|
|
3672
|
+
}
|
|
3238
3673
|
function downloadRoot(dataRoot, game) {
|
|
3239
3674
|
return join10(steamHome(dataRoot), "steamapps", "workshop", "content", String(game.steamAppId));
|
|
3240
3675
|
}
|
|
@@ -3353,7 +3788,7 @@ async function downloadItems(config, game, dataRoot, ids) {
|
|
|
3353
3788
|
return { items, warnings };
|
|
3354
3789
|
}
|
|
3355
3790
|
function run(runner, game, dataRoot, ids) {
|
|
3356
|
-
|
|
3791
|
+
return spawnSteamcmd([
|
|
3357
3792
|
...runner.argv,
|
|
3358
3793
|
"+force_install_dir",
|
|
3359
3794
|
steamHome(dataRoot),
|
|
@@ -3361,7 +3796,18 @@ function run(runner, game, dataRoot, ids) {
|
|
|
3361
3796
|
"anonymous",
|
|
3362
3797
|
...ids.flatMap((id) => ["+workshop_download_item", String(game.steamAppId), id]),
|
|
3363
3798
|
"+quit"
|
|
3364
|
-
];
|
|
3799
|
+
], runner);
|
|
3800
|
+
}
|
|
3801
|
+
async function spawnSteamcmdLive(argv, runner) {
|
|
3802
|
+
let text;
|
|
3803
|
+
try {
|
|
3804
|
+
({ text } = await captureLive(argv, { ...process.env, ...runner.env }));
|
|
3805
|
+
} catch (error) {
|
|
3806
|
+
throw new GamecrateError(`could not run steamcmd: ${argv[0]}`, Exit.Environment, error instanceof Error ? error.message : String(error));
|
|
3807
|
+
}
|
|
3808
|
+
return text.replace(ANSI, "");
|
|
3809
|
+
}
|
|
3810
|
+
function spawnSteamcmd(argv, runner) {
|
|
3365
3811
|
const r = spawnSync2(argv[0], argv.slice(1), {
|
|
3366
3812
|
encoding: "utf8",
|
|
3367
3813
|
env: { ...process.env, ...runner.env },
|
|
@@ -3373,6 +3819,107 @@ function run(runner, game, dataRoot, ids) {
|
|
|
3373
3819
|
return `${r.stdout ?? ""}
|
|
3374
3820
|
${r.stderr ?? ""}`.replace(ANSI, "");
|
|
3375
3821
|
}
|
|
3822
|
+
function appDownloadRoot(dataRoot, appId, branch, depot) {
|
|
3823
|
+
return join10(steamHome(dataRoot), "apps", `${appId}-${branch}-${depot ?? "native"}`);
|
|
3824
|
+
}
|
|
3825
|
+
var appOk = (id) => new RegExp(`Success! App '${id}'`);
|
|
3826
|
+
var appState = (id) => new RegExp(`Error! App '${id}' state is (0x[0-9a-fA-F]+)`);
|
|
3827
|
+
var LOGIN_FAILED = /Login Failure|FAILED \(Invalid Password\)|Account Logon Denied|Two-factor/i;
|
|
3828
|
+
async function downloadApp(config, opts) {
|
|
3829
|
+
const user = steamAccount(opts.dataRoot);
|
|
3830
|
+
const runner = resolveSteamcmd(config);
|
|
3831
|
+
const dir = appDownloadRoot(opts.dataRoot, opts.steamAppId, opts.branch, opts.depot);
|
|
3832
|
+
mkdirSync3(dir, { recursive: true });
|
|
3833
|
+
const commands = [
|
|
3834
|
+
["@ShutdownOnFailedCommand", "1"],
|
|
3835
|
+
["@NoPromptForPassword", "1"],
|
|
3836
|
+
["force_install_dir", dir],
|
|
3837
|
+
...opts.depot === undefined ? [] : [["@sSteamCmdForcePlatformType", opts.depot]],
|
|
3838
|
+
["login", user],
|
|
3839
|
+
[
|
|
3840
|
+
"app_update",
|
|
3841
|
+
String(opts.steamAppId),
|
|
3842
|
+
"-beta",
|
|
3843
|
+
opts.branch,
|
|
3844
|
+
...opts.password === undefined ? [] : ["-betapassword", opts.password]
|
|
3845
|
+
],
|
|
3846
|
+
["quit"]
|
|
3847
|
+
];
|
|
3848
|
+
const script = opts.password === undefined ? undefined : writeRunscript(opts.dataRoot, commands);
|
|
3849
|
+
const argv = script === undefined ? [...runner.argv, ...commands.flatMap((c) => [`+${c[0]}`, ...c.slice(1)])] : [...runner.argv, "+runscript", script];
|
|
3850
|
+
const release = await lockDir(steamHome(opts.dataRoot));
|
|
3851
|
+
let output;
|
|
3852
|
+
try {
|
|
3853
|
+
output = await spawnSteamcmdLive(argv, runner);
|
|
3854
|
+
} finally {
|
|
3855
|
+
if (script !== undefined)
|
|
3856
|
+
rmSync2(script, { force: true });
|
|
3857
|
+
await release();
|
|
3858
|
+
}
|
|
3859
|
+
if (appOk(opts.steamAppId).test(output))
|
|
3860
|
+
return { dir, warnings: [] };
|
|
3861
|
+
if (LOGIN_FAILED.test(output)) {
|
|
3862
|
+
throw new GamecrateError(`the steam login failed for ${user}`, Exit.Environment, "run `gamecrate steam login` to sign in again, including any steam guard code");
|
|
3863
|
+
}
|
|
3864
|
+
const state = appState(opts.steamAppId).exec(output);
|
|
3865
|
+
throw new GamecrateError(`steamcmd did not install app ${opts.steamAppId} on branch "${opts.branch}"`, Exit.Environment, state === null ? "the branch may not exist, or the password may be wrong. steam reports both the same way" : `steam left the app in state ${state[1]}. the branch may not exist, or the password may be wrong`);
|
|
3866
|
+
}
|
|
3867
|
+
function writeRunscript(dataRoot, commands) {
|
|
3868
|
+
const home = steamHome(dataRoot);
|
|
3869
|
+
mkdirSync3(home, { recursive: true });
|
|
3870
|
+
const path = join10(home, `runscript-${randomBytes(9).toString("hex")}.txt`);
|
|
3871
|
+
const body = commands.map((c) => c.map(quoteIfSpaced).join(" ")).join(`
|
|
3872
|
+
`);
|
|
3873
|
+
writeFileSync(path, `${body}
|
|
3874
|
+
`, { mode: 384, flag: "wx" });
|
|
3875
|
+
return path;
|
|
3876
|
+
}
|
|
3877
|
+
function quoteIfSpaced(arg) {
|
|
3878
|
+
return /\s/.test(arg) ? `"${arg}"` : arg;
|
|
3879
|
+
}
|
|
3880
|
+
async function publishedBuildId(config, appId, branch) {
|
|
3881
|
+
const user = steamAccount(config.dataRoot);
|
|
3882
|
+
mkdirSync3(steamHome(config.dataRoot), { recursive: true });
|
|
3883
|
+
const release = await lockDir(steamHome(config.dataRoot));
|
|
3884
|
+
let output;
|
|
3885
|
+
try {
|
|
3886
|
+
const runner = resolveSteamcmd(config);
|
|
3887
|
+
output = spawnSteamcmd([
|
|
3888
|
+
...runner.argv,
|
|
3889
|
+
"+@ShutdownOnFailedCommand",
|
|
3890
|
+
"1",
|
|
3891
|
+
"+@NoPromptForPassword",
|
|
3892
|
+
"1",
|
|
3893
|
+
"+login",
|
|
3894
|
+
user,
|
|
3895
|
+
"+app_info_update",
|
|
3896
|
+
"1",
|
|
3897
|
+
"+app_info_print",
|
|
3898
|
+
String(appId),
|
|
3899
|
+
"+quit"
|
|
3900
|
+
], runner);
|
|
3901
|
+
} finally {
|
|
3902
|
+
await release();
|
|
3903
|
+
}
|
|
3904
|
+
return buildIdFor(output, branch);
|
|
3905
|
+
}
|
|
3906
|
+
function buildIdFor(output, branch) {
|
|
3907
|
+
const key = `"${branch}"`;
|
|
3908
|
+
let inBranches = false;
|
|
3909
|
+
let inBranch = false;
|
|
3910
|
+
for (const line of output.split(`
|
|
3911
|
+
`)) {
|
|
3912
|
+
if (line.includes('"branches"'))
|
|
3913
|
+
inBranches = true;
|
|
3914
|
+
if (inBranches && line.includes(key))
|
|
3915
|
+
inBranch = true;
|
|
3916
|
+
if (inBranch && line.includes('"buildid"')) {
|
|
3917
|
+
const digits = (line.split('"').at(-2) ?? "").replaceAll(/\D/g, "");
|
|
3918
|
+
return digits === "" ? null : digits;
|
|
3919
|
+
}
|
|
3920
|
+
}
|
|
3921
|
+
return null;
|
|
3922
|
+
}
|
|
3376
3923
|
|
|
3377
3924
|
// src/mods/workshopapi.ts
|
|
3378
3925
|
import { readFile as readFile6, stat as stat4 } from "node:fs/promises";
|
|
@@ -3924,11 +4471,701 @@ async function readId(dir, manifestFile, plugin) {
|
|
|
3924
4471
|
function profileOf(args, defaults) {
|
|
3925
4472
|
return args.profile ?? defaults.defaultProfile ?? defaults.profileOrder?.[0] ?? "modless";
|
|
3926
4473
|
}
|
|
4474
|
+
function launchProfile(args, defaults, game) {
|
|
4475
|
+
const named = args.profile ?? defaults.defaultProfile ?? defaults.profileOrder?.[0];
|
|
4476
|
+
if (named !== undefined)
|
|
4477
|
+
return named;
|
|
4478
|
+
const known = Object.keys(game.profiles);
|
|
4479
|
+
if (known.length === 0)
|
|
4480
|
+
return "modless";
|
|
4481
|
+
throw new GamecrateError("no profile named, and no defaultProfile is set", Exit.Usage, `known profiles: ${[...known, "modless"].join(", ")}. set defaults.defaultProfile to pick one every time`);
|
|
4482
|
+
}
|
|
4483
|
+
|
|
4484
|
+
// src/cli/steam.ts
|
|
4485
|
+
import { spawnSync as spawnSync3 } from "node:child_process";
|
|
4486
|
+
import { existsSync as existsSync8, mkdirSync as mkdirSync5, readFileSync as readFileSync5, writeFileSync as writeFileSync3 } from "node:fs";
|
|
4487
|
+
import { homedir as homedir3 } from "node:os";
|
|
4488
|
+
import { dirname as dirname9, join as join16 } from "node:path";
|
|
4489
|
+
import { createInterface } from "node:readline/promises";
|
|
4490
|
+
|
|
4491
|
+
// src/image/build.ts
|
|
4492
|
+
import { mkdirSync as mkdirSync4, mkdtempSync, rmSync as rmSync3, writeFileSync as writeFileSync2 } from "node:fs";
|
|
4493
|
+
import { readFile as readFile8 } from "node:fs/promises";
|
|
4494
|
+
import { tmpdir } from "node:os";
|
|
4495
|
+
import { join as join15 } from "node:path";
|
|
4496
|
+
|
|
4497
|
+
// src/image/base.ts
|
|
4498
|
+
var RUNTIME_BASE = {
|
|
4499
|
+
xvfb: "ghcr.io/rimworks/gamecrate/runtime-base:1",
|
|
4500
|
+
proton: "ghcr.io/rimworks/gamecrate/runtime-base-proton:1"
|
|
4501
|
+
};
|
|
4502
|
+
function resolveBase(kind, override) {
|
|
4503
|
+
if (kind === "none")
|
|
4504
|
+
return null;
|
|
4505
|
+
const pinned = RUNTIME_BASE[kind];
|
|
4506
|
+
if (!pinned) {
|
|
4507
|
+
throw new GamecrateError(`unknown runtime base "${kind}"`, Exit.Config, `known values: ${[...Object.keys(RUNTIME_BASE), "none"].join(", ")}`);
|
|
4508
|
+
}
|
|
4509
|
+
return override ?? pinned;
|
|
4510
|
+
}
|
|
4511
|
+
|
|
4512
|
+
// src/image/crane.ts
|
|
4513
|
+
import { existsSync as existsSync7, readFileSync as readFileSync4 } from "node:fs";
|
|
4514
|
+
import { dirname as dirname8, join as join13 } from "node:path";
|
|
4515
|
+
import { setTimeout as sleep5 } from "node:timers/promises";
|
|
4516
|
+
var CRANE_IMAGE = "gcr.io/go-containerregistry/crane:debug";
|
|
4517
|
+
var ATTEMPTS2 = 3;
|
|
4518
|
+
var RETRY_DELAY_MS2 = 1000;
|
|
4519
|
+
var CONTAINER_CONFIG = "/tmp/gamecrate-docker";
|
|
4520
|
+
var USER_VAR = "GAMECRATE_REGISTRY_USER";
|
|
4521
|
+
var PASSWORD_VAR = "GAMECRATE_REGISTRY_PASSWORD";
|
|
4522
|
+
function registryCreds() {
|
|
4523
|
+
const user = process.env[USER_VAR] ?? "";
|
|
4524
|
+
const password = process.env[PASSWORD_VAR] ?? "";
|
|
4525
|
+
if (user !== "" && password !== "")
|
|
4526
|
+
return { user };
|
|
4527
|
+
if (user === "" && password === "")
|
|
4528
|
+
return null;
|
|
4529
|
+
throw new GamecrateError(`only one of ${USER_VAR} and ${PASSWORD_VAR} is set`, Exit.Environment, "set both, or neither to fall back to the docker config");
|
|
4530
|
+
}
|
|
4531
|
+
function dockerConfigDir() {
|
|
4532
|
+
const dir = process.env.DOCKER_CONFIG ?? join13(process.env.HOME ?? "", ".docker");
|
|
4533
|
+
if (dir === ".docker" || !existsSync7(join13(dir, "config.json")))
|
|
4534
|
+
return;
|
|
4535
|
+
return dir;
|
|
4536
|
+
}
|
|
4537
|
+
function authArgs() {
|
|
4538
|
+
if (registryCreds() !== null) {
|
|
4539
|
+
return ["-e", PASSWORD_VAR, "-e", `DOCKER_CONFIG=${CONTAINER_CONFIG}`];
|
|
4540
|
+
}
|
|
4541
|
+
const dir = dockerConfigDir();
|
|
4542
|
+
if (dir === undefined)
|
|
4543
|
+
return [];
|
|
4544
|
+
return ["-v", `${dir}:/dockercfg:ro`, "-e", "DOCKER_CONFIG=/dockercfg"];
|
|
4545
|
+
}
|
|
4546
|
+
var DOCKER_HUB = "index.docker.io";
|
|
4547
|
+
function registryOf(ref) {
|
|
4548
|
+
const slash = ref.indexOf("/");
|
|
4549
|
+
if (slash === -1)
|
|
4550
|
+
return DOCKER_HUB;
|
|
4551
|
+
const head = ref.slice(0, slash);
|
|
4552
|
+
if (head === "docker.io")
|
|
4553
|
+
return DOCKER_HUB;
|
|
4554
|
+
if (head === "localhost" || head.includes(".") || head.includes(":"))
|
|
4555
|
+
return head;
|
|
4556
|
+
return DOCKER_HUB;
|
|
4557
|
+
}
|
|
4558
|
+
function keyHost(key) {
|
|
4559
|
+
return key.replace(/^https?:\/\//, "").split("/")[0] ?? key;
|
|
4560
|
+
}
|
|
4561
|
+
function forRegistry(table, registry) {
|
|
4562
|
+
for (const [key, value] of Object.entries(table ?? {})) {
|
|
4563
|
+
if (keyHost(key) === registry)
|
|
4564
|
+
return value;
|
|
4565
|
+
}
|
|
4566
|
+
return;
|
|
4567
|
+
}
|
|
4568
|
+
function checkRegistryAuthEarly(ref) {
|
|
4569
|
+
if (registryCreds() !== null)
|
|
4570
|
+
return;
|
|
4571
|
+
const dir = dockerConfigDir();
|
|
4572
|
+
if (dir === undefined) {
|
|
4573
|
+
throw new GamecrateError(`no registry credentials for ${registryOf(ref)}`, Exit.Environment, `there is no docker config.json to read. set ${USER_VAR} and ${PASSWORD_VAR}`);
|
|
4574
|
+
}
|
|
4575
|
+
const path = join13(dir, "config.json");
|
|
4576
|
+
let parsed;
|
|
4577
|
+
try {
|
|
4578
|
+
parsed = JSON.parse(readFileSync4(path, "utf8"));
|
|
4579
|
+
} catch {
|
|
4580
|
+
return;
|
|
4581
|
+
}
|
|
4582
|
+
const registry = registryOf(ref);
|
|
4583
|
+
const entry = forRegistry(parsed.auths, registry);
|
|
4584
|
+
if ((entry?.auth ?? entry?.identitytoken ?? "") !== "")
|
|
4585
|
+
return;
|
|
4586
|
+
const helper = forRegistry(parsed.credHelpers, registry) ?? parsed.credsStore ?? "";
|
|
4587
|
+
throw new GamecrateError(`no registry credentials for ${registry}`, Exit.Environment, helper === "" ? `${path} has no auths entry for ${registry}. set ${USER_VAR} and ${PASSWORD_VAR}` : `${path} stores ${registry} credentials in a helper (${helper}) the crane container cannot run. set ${USER_VAR} and ${PASSWORD_VAR}`);
|
|
4588
|
+
}
|
|
4589
|
+
function dockerRun(mounts, script) {
|
|
4590
|
+
return [
|
|
4591
|
+
"docker",
|
|
4592
|
+
"run",
|
|
4593
|
+
"--rm",
|
|
4594
|
+
"--user",
|
|
4595
|
+
`${process.getuid?.() ?? 0}:${process.getgid?.() ?? 0}`,
|
|
4596
|
+
...mounts.flatMap((m) => ["-v", m]),
|
|
4597
|
+
...authArgs(),
|
|
4598
|
+
"--entrypoint",
|
|
4599
|
+
"sh",
|
|
4600
|
+
CRANE_IMAGE,
|
|
4601
|
+
"-c",
|
|
4602
|
+
script
|
|
4603
|
+
];
|
|
4604
|
+
}
|
|
4605
|
+
function shq(value) {
|
|
4606
|
+
return `'${value.replaceAll("'", `'\\''`)}'`;
|
|
4607
|
+
}
|
|
4608
|
+
function craneArgv(mounts, ref, lines) {
|
|
4609
|
+
const creds = ref === null ? null : registryCreds();
|
|
4610
|
+
const login = creds === null ? [] : ["crane", "auth", "login", registryOf(ref), "-u", creds.user, "--password-stdin"];
|
|
4611
|
+
const script = [
|
|
4612
|
+
"set -e",
|
|
4613
|
+
...login.length === 0 ? [] : [`printf '%s' "$${PASSWORD_VAR}" | ${login.map(shq).join(" ")} >&2`],
|
|
4614
|
+
...lines.map((line) => line.map(shq).join(" "))
|
|
4615
|
+
].join(`
|
|
4616
|
+
`);
|
|
4617
|
+
return dockerRun(mounts, script);
|
|
4618
|
+
}
|
|
4619
|
+
async function runOnce(argv, what) {
|
|
4620
|
+
const { code, stdout, stderr } = await capture(argv);
|
|
4621
|
+
if (code === 0)
|
|
4622
|
+
return;
|
|
4623
|
+
throw new GamecrateError(`${what} failed`, Exit.Environment, `${stdout}
|
|
4624
|
+
${stderr}`.trim());
|
|
4625
|
+
}
|
|
4626
|
+
var HEARTBEAT_MS = 1000;
|
|
4627
|
+
async function live(argv, what) {
|
|
4628
|
+
const since = Date.now();
|
|
4629
|
+
let lastNotice = 0;
|
|
4630
|
+
const timer = setInterval(() => {
|
|
4631
|
+
const waited = Date.now() - since;
|
|
4632
|
+
const notice = waitNotice(waited, lastNotice);
|
|
4633
|
+
if (notice === undefined)
|
|
4634
|
+
return;
|
|
4635
|
+
lastNotice = waited;
|
|
4636
|
+
status(`${what} (${notice})`);
|
|
4637
|
+
}, HEARTBEAT_MS);
|
|
4638
|
+
try {
|
|
4639
|
+
return await captureLive(argv).catch((error) => ({
|
|
4640
|
+
code: 127,
|
|
4641
|
+
text: error instanceof Error ? error.message : String(error)
|
|
4642
|
+
}));
|
|
4643
|
+
} finally {
|
|
4644
|
+
clearInterval(timer);
|
|
4645
|
+
}
|
|
4646
|
+
}
|
|
4647
|
+
async function runLive(argv, what) {
|
|
4648
|
+
const { code, text } = await live(argv, what);
|
|
4649
|
+
if (code === 0)
|
|
4650
|
+
return;
|
|
4651
|
+
throw new GamecrateError(`${what} failed`, Exit.Environment, text.trim());
|
|
4652
|
+
}
|
|
4653
|
+
function lastLine(text) {
|
|
4654
|
+
return text.split(`
|
|
4655
|
+
`).map((l) => l.trim()).filter((l) => l !== "").at(-1) ?? "no output";
|
|
4656
|
+
}
|
|
4657
|
+
async function craneAppend(opts) {
|
|
4658
|
+
for (const p of opts.include) {
|
|
4659
|
+
if (!existsSync7(join13(opts.gameDir, p))) {
|
|
4660
|
+
throw new GamecrateError(`include path not found in the game dir: ${p}`, Exit.Resolution, `looked under ${opts.gameDir}. check the variant's include list`);
|
|
4661
|
+
}
|
|
4662
|
+
}
|
|
4663
|
+
const outDir = dirname8(opts.out);
|
|
4664
|
+
const layer = `${opts.out}.layer.tar`;
|
|
4665
|
+
const prefix = opts.gamePath.replace(/^\/+/, "");
|
|
4666
|
+
const stage = `/stage/${prefix}`;
|
|
4667
|
+
const tar = opts.include.length > 0 ? ["tar", "-C", "/stage", "-cf", layer, ...opts.include.map((p) => `${prefix}/${p}`)] : [
|
|
4668
|
+
"tar",
|
|
4669
|
+
"-C",
|
|
4670
|
+
"/stage",
|
|
4671
|
+
`--exclude=${prefix}/steamapps`,
|
|
4672
|
+
`--exclude=${prefix}/lost+found`,
|
|
4673
|
+
"-cf",
|
|
4674
|
+
layer,
|
|
4675
|
+
prefix
|
|
4676
|
+
];
|
|
4677
|
+
const append = [
|
|
4678
|
+
"crane",
|
|
4679
|
+
"append",
|
|
4680
|
+
"--platform",
|
|
4681
|
+
opts.platform,
|
|
4682
|
+
...opts.base === null ? [] : ["-b", opts.base],
|
|
4683
|
+
"-t",
|
|
4684
|
+
opts.tag,
|
|
4685
|
+
"-f",
|
|
4686
|
+
layer,
|
|
4687
|
+
"-o",
|
|
4688
|
+
opts.out
|
|
4689
|
+
];
|
|
4690
|
+
const argv = craneArgv([`${opts.gameDir}:${stage}:ro`, `${outDir}:${outDir}`], null, [
|
|
4691
|
+
tar,
|
|
4692
|
+
append,
|
|
4693
|
+
["rm", "-f", layer]
|
|
4694
|
+
]);
|
|
4695
|
+
await runLive(argv, `crane append for ${opts.out}`);
|
|
4696
|
+
}
|
|
4697
|
+
async function cranePush(tar, ref) {
|
|
4698
|
+
const dir = dirname8(tar);
|
|
4699
|
+
checkRegistryAuthEarly(ref);
|
|
4700
|
+
const argv = craneArgv([`${dir}:${dir}:ro`], ref, [["crane", "push", tar, ref]]);
|
|
4701
|
+
let last = "";
|
|
4702
|
+
status(`pushing ${tar} to ${ref}`);
|
|
4703
|
+
for (let attempt = 0;attempt < ATTEMPTS2; attempt++) {
|
|
4704
|
+
const { code, text } = await live(argv, `crane push ${ref}`);
|
|
4705
|
+
if (code === 0)
|
|
4706
|
+
return;
|
|
4707
|
+
last = text.trim();
|
|
4708
|
+
if (attempt + 1 < ATTEMPTS2) {
|
|
4709
|
+
warn(`push attempt ${attempt + 1} of ${ATTEMPTS2} failed: ${lastLine(last)}. retrying`);
|
|
4710
|
+
await sleep5(RETRY_DELAY_MS2);
|
|
4711
|
+
}
|
|
4712
|
+
}
|
|
4713
|
+
throw new GamecrateError(`crane push failed for ${ref}`, Exit.Environment, last);
|
|
4714
|
+
}
|
|
4715
|
+
async function craneTag(ref, tag) {
|
|
4716
|
+
await runOnce(craneArgv([], ref, [["crane", "tag", ref, tag]]), `crane tag ${ref} ${tag}`);
|
|
4717
|
+
}
|
|
4718
|
+
async function craneMutateLabels(ref, labels) {
|
|
4719
|
+
const args = ["crane", "mutate", ref];
|
|
4720
|
+
for (const [key, value] of Object.entries(labels))
|
|
4721
|
+
args.push("--label", `${key}=${value}`);
|
|
4722
|
+
args.push("-t", ref);
|
|
4723
|
+
await runOnce(craneArgv([], ref, [args]), `crane mutate ${ref}`);
|
|
4724
|
+
}
|
|
4725
|
+
async function craneDigest(ref, platform) {
|
|
4726
|
+
const argv = craneArgv([], ref, [["crane", "digest", "--platform", platform, ref]]);
|
|
4727
|
+
const { code, stdout } = await capture(argv);
|
|
4728
|
+
const digest = stdout.trim();
|
|
4729
|
+
return code === 0 && digest.startsWith("sha256:") ? digest : null;
|
|
4730
|
+
}
|
|
4731
|
+
async function craneLabels(ref) {
|
|
4732
|
+
const { code, stdout } = await capture(craneArgv([], ref, [["crane", "config", ref]]));
|
|
4733
|
+
if (code !== 0)
|
|
4734
|
+
return null;
|
|
4735
|
+
try {
|
|
4736
|
+
const parsed = JSON.parse(stdout);
|
|
4737
|
+
return parsed.config?.Labels ?? {};
|
|
4738
|
+
} catch {
|
|
4739
|
+
return null;
|
|
4740
|
+
}
|
|
4741
|
+
}
|
|
4742
|
+
|
|
4743
|
+
// src/image/gate.ts
|
|
4744
|
+
function decideGate(input) {
|
|
4745
|
+
if (input.force)
|
|
4746
|
+
return { build: true, reason: "forced" };
|
|
4747
|
+
if (!input.imagePresent)
|
|
4748
|
+
return { build: true, reason: "no-image" };
|
|
4749
|
+
if (input.labelled === null)
|
|
4750
|
+
return { build: true, reason: "no-label" };
|
|
4751
|
+
if (input.published === null)
|
|
4752
|
+
return { build: true, reason: "unknown-published" };
|
|
4753
|
+
if (input.published === input.labelled)
|
|
4754
|
+
return { build: false, reason: "up-to-date" };
|
|
4755
|
+
return { build: true, reason: "buildid-changed" };
|
|
4756
|
+
}
|
|
4757
|
+
|
|
4758
|
+
// src/image/input.ts
|
|
4759
|
+
import { join as join14 } from "node:path";
|
|
4760
|
+
function branchPasswordKey(branch) {
|
|
4761
|
+
return `STEAM_BRANCH_PASSWORD_${branch.toUpperCase().replaceAll(/[^A-Z0-9]/g, "_")}`;
|
|
4762
|
+
}
|
|
4763
|
+
function branchPassword(branch) {
|
|
4764
|
+
const key = branchPasswordKey(branch.name);
|
|
4765
|
+
const keyed = process.env[key];
|
|
4766
|
+
if (keyed !== undefined && keyed !== "")
|
|
4767
|
+
return keyed;
|
|
4768
|
+
if (branch.password !== true)
|
|
4769
|
+
return;
|
|
4770
|
+
const bare = process.env.STEAM_BRANCH_PASSWORD;
|
|
4771
|
+
if (bare === undefined || bare === "") {
|
|
4772
|
+
throw new GamecrateError(`branch "${branch.name}" needs a password and none is set`, Exit.Environment, `set ${key}, or STEAM_BRANCH_PASSWORD, when you build one branch`);
|
|
4773
|
+
}
|
|
4774
|
+
return bare;
|
|
4775
|
+
}
|
|
4776
|
+
function repoOf(ref) {
|
|
4777
|
+
const at = ref.indexOf("@");
|
|
4778
|
+
const head = at > 0 ? ref.slice(0, at) : ref;
|
|
4779
|
+
const colon = head.lastIndexOf(":");
|
|
4780
|
+
return colon > head.lastIndexOf("/") ? head.slice(0, colon) : head;
|
|
4781
|
+
}
|
|
4782
|
+
function resolveImage(flags, game, configured) {
|
|
4783
|
+
if (configured !== undefined)
|
|
4784
|
+
return repoOf(configured);
|
|
4785
|
+
if (flags.push) {
|
|
4786
|
+
throw new GamecrateError("--push needs a target repository", Exit.Usage, `pass --image <repo>, or set games.${game}.image.ref in a config file`);
|
|
4787
|
+
}
|
|
4788
|
+
return `gamecrate/${game}-game`;
|
|
4789
|
+
}
|
|
4790
|
+
function mergeForGame(game, defaults, config) {
|
|
4791
|
+
const base = { dataRoot: "", games: { [game]: defaults } };
|
|
4792
|
+
const user = { games: { [game]: config?.games?.[game] ?? {} } };
|
|
4793
|
+
return mergeUserConfig(base, user).games[game];
|
|
4794
|
+
}
|
|
4795
|
+
async function resolveSteamBuildInput(game, config, overrides, cwd, configFile) {
|
|
4796
|
+
const fromConfig = overrides.plugins === undefined && config?.plugins !== undefined;
|
|
4797
|
+
const specs = overrides.plugins ?? config?.plugins ?? [`@gamecrate/${game}`];
|
|
4798
|
+
const from = fromConfig && configFile !== undefined ? configFile : join14(cwd, ".gamecrate.yaml");
|
|
4799
|
+
const plugins = await loadPlugins(specs, from);
|
|
4800
|
+
const plugin = plugins.get(game);
|
|
4801
|
+
if (plugin === undefined) {
|
|
4802
|
+
throw new GamecrateError(`no plugin provides the game "${game}"`, Exit.Resolution, `tried: ${specs.join(", ")}. loaded: ${[...plugins.keys()].join(", ") || "(none)"}`);
|
|
4803
|
+
}
|
|
4804
|
+
const merged = mergeForGame(game, plugin.defaults, config);
|
|
4805
|
+
const want = (value, key) => {
|
|
4806
|
+
if (value === undefined) {
|
|
4807
|
+
throw new GamecrateError(`${game} has no ${key}`, Exit.Config, `the plugin ${specs.join(", ")} must declare ${key} in its defaults`);
|
|
4808
|
+
}
|
|
4809
|
+
return value;
|
|
4810
|
+
};
|
|
4811
|
+
const steamBuild = want(merged.steamBuild, "steamBuild");
|
|
4812
|
+
const checked = steamBuildSchema.safeParse(steamBuild);
|
|
4813
|
+
if (!checked.success) {
|
|
4814
|
+
const first = checked.error.issues[0];
|
|
4815
|
+
throw new GamecrateError(`${game} steamBuild is wrong: ${first.message}`, Exit.Config, `at steamBuild.${first.path.join(".")}, declared by ${specs.join(", ")}`);
|
|
4816
|
+
}
|
|
4817
|
+
const push = overrides.push === true;
|
|
4818
|
+
const image = resolveImage({ load: !push, push }, game, overrides.image ?? merged.image?.ref);
|
|
4819
|
+
return {
|
|
4820
|
+
game,
|
|
4821
|
+
steamAppId: want(merged.steamAppId, "steamAppId"),
|
|
4822
|
+
versionFile: want(merged.version, "version.file").file,
|
|
4823
|
+
gamePath: want(merged.gameFiles, "gameFiles").container,
|
|
4824
|
+
executable: want(merged.executable, "executable"),
|
|
4825
|
+
branches: steamBuild.branches,
|
|
4826
|
+
variants: steamBuild.variants,
|
|
4827
|
+
image: image.toLowerCase()
|
|
4828
|
+
};
|
|
4829
|
+
}
|
|
4830
|
+
|
|
4831
|
+
// src/image/tags.ts
|
|
4832
|
+
function sanitizeVersion(raw, fallback) {
|
|
4833
|
+
const first = raw.trim().split(/\s+/)[0] ?? "";
|
|
4834
|
+
const mapped = first.replaceAll(/[^A-Za-z0-9._-]/g, "-").replace(/-+$/, "");
|
|
4835
|
+
return mapped.length > 0 ? mapped : fallback;
|
|
4836
|
+
}
|
|
4837
|
+
function versionPrefixes(version) {
|
|
4838
|
+
const parts = version.split(".");
|
|
4839
|
+
if (parts.length < 2 || parts.some((p) => p === ""))
|
|
4840
|
+
return [];
|
|
4841
|
+
return parts.slice(0, -1).map((_, i) => parts.slice(0, i + 1).join("."));
|
|
4842
|
+
}
|
|
4843
|
+
function tagsFor(input) {
|
|
4844
|
+
const scope = input.defaultBranch ? "" : `-${input.branch}`;
|
|
4845
|
+
const versioned = [];
|
|
4846
|
+
const latest = [];
|
|
4847
|
+
if (input.defaultVariant) {
|
|
4848
|
+
versioned.push(`${input.version}${scope}`);
|
|
4849
|
+
latest.push(`latest${scope}`);
|
|
4850
|
+
}
|
|
4851
|
+
versioned.push(`${input.version}${scope}-${input.variant}`);
|
|
4852
|
+
latest.push(`latest${scope}-${input.variant}`);
|
|
4853
|
+
for (const prefix of versionPrefixes(input.version)) {
|
|
4854
|
+
if (input.defaultVariant)
|
|
4855
|
+
latest.push(`${prefix}${scope}`);
|
|
4856
|
+
latest.push(`${prefix}${scope}-${input.variant}`);
|
|
4857
|
+
}
|
|
4858
|
+
for (const alias of input.aliases ?? []) {
|
|
4859
|
+
if (input.defaultVariant)
|
|
4860
|
+
latest.push(alias);
|
|
4861
|
+
latest.push(`${alias}-${input.variant}`);
|
|
4862
|
+
}
|
|
4863
|
+
return [...versioned, ...latest];
|
|
4864
|
+
}
|
|
4865
|
+
|
|
4866
|
+
// src/image/build.ts
|
|
4867
|
+
async function steamBuild(input, opts) {
|
|
4868
|
+
const branches = narrow(input.branches, opts.onlyBranches, "branch", "branches");
|
|
4869
|
+
const variants = narrow(input.variants, opts.onlyVariants, "variant", "variants");
|
|
4870
|
+
if (opts.push)
|
|
4871
|
+
checkRegistryAuthEarly(input.image);
|
|
4872
|
+
const defaultBranch = input.branches[0].name;
|
|
4873
|
+
const defaultVariant = input.variants[0].name;
|
|
4874
|
+
const results = [];
|
|
4875
|
+
for (const branch of branches) {
|
|
4876
|
+
const published = await publishedBuildId(opts.config, input.steamAppId, branch.name).catch(() => null);
|
|
4877
|
+
const downloads = new Map;
|
|
4878
|
+
for (const variant of variants) {
|
|
4879
|
+
results.push(await cell(input, opts, { branch, variant, published, defaultBranch, defaultVariant, downloads }));
|
|
4880
|
+
}
|
|
4881
|
+
}
|
|
4882
|
+
return results;
|
|
4883
|
+
}
|
|
4884
|
+
function narrow(all, only, kind, plural) {
|
|
4885
|
+
if (only === undefined || only.length === 0)
|
|
4886
|
+
return all;
|
|
4887
|
+
const known = new Set(all.map((entry) => entry.name));
|
|
4888
|
+
const missing = only.filter((name) => !known.has(name));
|
|
4889
|
+
if (missing.length > 0) {
|
|
4890
|
+
throw new GamecrateError(`unknown ${kind} ${missing.join(", ")}`, Exit.Usage, `declared ${plural}: ${all.map((entry) => entry.name).join(", ")}`);
|
|
4891
|
+
}
|
|
4892
|
+
return all.filter((entry) => only.includes(entry.name));
|
|
4893
|
+
}
|
|
4894
|
+
async function cell(input, opts, ctx) {
|
|
4895
|
+
const { branch, variant } = ctx;
|
|
4896
|
+
const row = { branch: branch.name, variant: variant.name };
|
|
4897
|
+
const say = (text) => status(`${branch.name}/${variant.name} ${text}`);
|
|
4898
|
+
if (!opts.push && variant.base === "none") {
|
|
4899
|
+
say("skipped, reference-only, use --push");
|
|
4900
|
+
return { ...row, status: "skipped", reason: "reference-only, use --push", tags: [] };
|
|
4901
|
+
}
|
|
4902
|
+
const tagInput = {
|
|
4903
|
+
branch: branch.name,
|
|
4904
|
+
variant: variant.name,
|
|
4905
|
+
defaultBranch: branch.name === ctx.defaultBranch,
|
|
4906
|
+
defaultVariant: variant.name === ctx.defaultVariant,
|
|
4907
|
+
aliases: branch.tags
|
|
4908
|
+
};
|
|
4909
|
+
const probe = tagsFor({ version: "0", ...tagInput });
|
|
4910
|
+
const gateRef = `${input.image}:${probe.find((tag) => tag.startsWith("latest"))}`;
|
|
4911
|
+
const found = await readGate(gateRef, opts);
|
|
4912
|
+
const decision = decideGate({
|
|
4913
|
+
published: ctx.published,
|
|
4914
|
+
imagePresent: found.present,
|
|
4915
|
+
labelled: found.buildid,
|
|
4916
|
+
force: opts.force
|
|
4917
|
+
});
|
|
4918
|
+
if (!decision.build) {
|
|
4919
|
+
say(`skipped, ${decision.reason}`);
|
|
4920
|
+
return { ...row, status: "skipped", reason: decision.reason, tags: [] };
|
|
4921
|
+
}
|
|
4922
|
+
try {
|
|
4923
|
+
const dir = await download(input, opts, ctx, say);
|
|
4924
|
+
const raw = await readFile8(join15(dir, input.versionFile), "utf8");
|
|
4925
|
+
const version = sanitizeVersion(raw, ctx.published ?? "unknown");
|
|
4926
|
+
const tags = tagsFor({ ...tagInput, version });
|
|
4927
|
+
const layers = join15(steamHome(opts.config.dataRoot), "layers");
|
|
4928
|
+
mkdirSync4(layers, { recursive: true });
|
|
4929
|
+
const tar = join15(layers, `${branch.name}-${variant.name}-${version}.tar`);
|
|
4930
|
+
const base = resolveBase(variant.base, opts.baseOverride);
|
|
4931
|
+
const baseDigest = base === null ? null : await resolvedBase(base, opts.platform);
|
|
4932
|
+
const versioned = `${input.image}:${tags[0]}`;
|
|
4933
|
+
try {
|
|
4934
|
+
say(`appending onto ${base ?? "scratch"}`);
|
|
4935
|
+
await craneAppend({
|
|
4936
|
+
gameDir: dir,
|
|
4937
|
+
include: variant.include,
|
|
4938
|
+
gamePath: input.gamePath,
|
|
4939
|
+
base,
|
|
4940
|
+
platform: opts.platform,
|
|
4941
|
+
tag: versioned,
|
|
4942
|
+
out: tar
|
|
4943
|
+
});
|
|
4944
|
+
if (opts.push) {
|
|
4945
|
+
say(`pushing ${versioned}`);
|
|
4946
|
+
await cranePush(tar, versioned);
|
|
4947
|
+
await craneMutateLabels(versioned, labelsFor(input, ctx, baseDigest));
|
|
4948
|
+
for (const tag of tags.slice(1))
|
|
4949
|
+
await craneTag(versioned, tag);
|
|
4950
|
+
}
|
|
4951
|
+
if (opts.load && base !== null) {
|
|
4952
|
+
say(`loading ${versioned}`);
|
|
4953
|
+
await dockerLoad(tar, versioned);
|
|
4954
|
+
await dockerLabel(versioned, labelsFor(input, ctx, baseDigest));
|
|
4955
|
+
for (const tag of tags.slice(1))
|
|
4956
|
+
await dockerTag(versioned, `${input.image}:${tag}`);
|
|
4957
|
+
}
|
|
4958
|
+
} finally {
|
|
4959
|
+
rmSync3(tar, { force: true });
|
|
4960
|
+
rmSync3(`${tar}.layer.tar`, { force: true });
|
|
4961
|
+
}
|
|
4962
|
+
return { ...row, status: "built", reason: decision.reason, tags };
|
|
4963
|
+
} catch (error) {
|
|
4964
|
+
return { ...row, status: "failed", reason: message(error), tags: [] };
|
|
4965
|
+
}
|
|
4966
|
+
}
|
|
4967
|
+
async function download(input, opts, ctx, say) {
|
|
4968
|
+
const key = ctx.variant.depot ?? "default";
|
|
4969
|
+
let pending = ctx.downloads.get(key);
|
|
4970
|
+
if (pending === undefined) {
|
|
4971
|
+
say(`downloading ${input.steamAppId} (branch ${ctx.branch.name}, depot ${key})`);
|
|
4972
|
+
pending = downloadApp(opts.config, {
|
|
4973
|
+
steamAppId: input.steamAppId,
|
|
4974
|
+
branch: ctx.branch.name,
|
|
4975
|
+
depot: ctx.variant.depot,
|
|
4976
|
+
password: branchPassword(ctx.branch),
|
|
4977
|
+
dataRoot: opts.config.dataRoot
|
|
4978
|
+
});
|
|
4979
|
+
ctx.downloads.set(key, pending);
|
|
4980
|
+
} else {
|
|
4981
|
+
say(`reusing the ${ctx.branch.name} ${key} download`);
|
|
4982
|
+
}
|
|
4983
|
+
return (await pending).dir;
|
|
4984
|
+
}
|
|
4985
|
+
async function resolvedBase(base, platform) {
|
|
4986
|
+
if (base.includes("@sha256:"))
|
|
4987
|
+
return base;
|
|
4988
|
+
const digest = await craneDigest(base, platform);
|
|
4989
|
+
return digest === null ? base : `${base.split(":")[0]}@${digest}`;
|
|
4990
|
+
}
|
|
4991
|
+
function labelsFor(input, ctx, base) {
|
|
4992
|
+
const labels = {
|
|
4993
|
+
"gamecrate.variant": ctx.variant.name,
|
|
4994
|
+
"gamecrate.branch": ctx.branch.name,
|
|
4995
|
+
"gamecrate.executable": ctx.branch.executable?.[ctx.variant.name] ?? ctx.variant.executable ?? input.executable,
|
|
4996
|
+
"gamecrate.launcher": ctx.variant.base === "proton" ? "proton" : "direct"
|
|
4997
|
+
};
|
|
4998
|
+
if (ctx.published !== null)
|
|
4999
|
+
labels["steam.buildid"] = ctx.published;
|
|
5000
|
+
if (base !== null)
|
|
5001
|
+
labels["gamecrate.runtime"] = base;
|
|
5002
|
+
return labels;
|
|
5003
|
+
}
|
|
5004
|
+
async function readGate(ref, opts) {
|
|
5005
|
+
const reads = [];
|
|
5006
|
+
if (opts.push)
|
|
5007
|
+
reads.push(await craneLabels(ref));
|
|
5008
|
+
if (opts.load)
|
|
5009
|
+
reads.push(await inspectLabels(ref));
|
|
5010
|
+
if (reads.some((labels) => labels === null))
|
|
5011
|
+
return { present: false, buildid: null };
|
|
5012
|
+
const ids = reads.map((labels) => labels?.["steam.buildid"] ?? null);
|
|
5013
|
+
return { present: reads.length > 0, buildid: ids.every((id) => id === ids[0]) ? ids[0] ?? null : null };
|
|
5014
|
+
}
|
|
5015
|
+
async function inspectLabels(ref) {
|
|
5016
|
+
const { code, stdout } = await capture(["docker", "image", "inspect", "--format", "{{json .Config.Labels}}", ref]);
|
|
5017
|
+
if (code !== 0)
|
|
5018
|
+
return null;
|
|
5019
|
+
try {
|
|
5020
|
+
return JSON.parse(stdout.trim()) ?? {};
|
|
5021
|
+
} catch {
|
|
5022
|
+
return null;
|
|
5023
|
+
}
|
|
5024
|
+
}
|
|
5025
|
+
async function dockerLoad(tar, ref) {
|
|
5026
|
+
const { code, stdout, stderr } = await capture(["docker", "load", "-i", tar]);
|
|
5027
|
+
if (code !== 0 || !stdout.includes(`Loaded image: ${ref}`)) {
|
|
5028
|
+
throw new GamecrateError(`docker load failed for ${tar}`, Exit.Environment, `expected "Loaded image: ${ref}", got: ${`${stdout}
|
|
5029
|
+
${stderr}`.trim() || "(no output)"}`);
|
|
5030
|
+
}
|
|
5031
|
+
}
|
|
5032
|
+
async function dockerLabel(ref, labels) {
|
|
5033
|
+
const dir = mkdtempSync(join15(tmpdir(), "gamecrate-label-"));
|
|
5034
|
+
writeFileSync2(join15(dir, "Dockerfile"), `FROM ${ref}
|
|
5035
|
+
`);
|
|
5036
|
+
const argv = ["docker", "build", "-f", join15(dir, "Dockerfile"), "-t", ref];
|
|
5037
|
+
for (const [key, value] of Object.entries(labels))
|
|
5038
|
+
argv.push("--label", `${key}=${value}`);
|
|
5039
|
+
argv.push(dir);
|
|
5040
|
+
const { code, stdout, stderr } = await capture(argv);
|
|
5041
|
+
rmSync3(dir, { recursive: true, force: true });
|
|
5042
|
+
if (code !== 0) {
|
|
5043
|
+
throw new GamecrateError(`docker build --label failed for ${ref}`, Exit.Environment, `${stdout}
|
|
5044
|
+
${stderr}`.trim());
|
|
5045
|
+
}
|
|
5046
|
+
}
|
|
5047
|
+
async function dockerTag(from, ref) {
|
|
5048
|
+
const { code, stdout, stderr } = await capture(["docker", "tag", from, ref]);
|
|
5049
|
+
if (code !== 0) {
|
|
5050
|
+
throw new GamecrateError(`docker tag ${ref} failed`, Exit.Environment, `${stdout}
|
|
5051
|
+
${stderr}`.trim());
|
|
5052
|
+
}
|
|
5053
|
+
}
|
|
5054
|
+
function message(error) {
|
|
5055
|
+
const head = error instanceof Error ? error.message : String(error);
|
|
5056
|
+
const detail = error instanceof GamecrateError ? error.detail?.replaceAll(/\s+/g, " ").trim() : undefined;
|
|
5057
|
+
return detail ? `${head}: ${detail}` : head;
|
|
5058
|
+
}
|
|
5059
|
+
|
|
5060
|
+
// src/cli/steam.ts
|
|
5061
|
+
function sessionPaths(home) {
|
|
5062
|
+
return [
|
|
5063
|
+
join16(home, ".steam", "config", "config.vdf"),
|
|
5064
|
+
join16(home, ".local", "share", "Steam", "config", "config.vdf"),
|
|
5065
|
+
join16(home, "Steam", "config", "config.vdf")
|
|
5066
|
+
];
|
|
5067
|
+
}
|
|
5068
|
+
function findSession(home) {
|
|
5069
|
+
return sessionPaths(home).find((path) => existsSync8(path));
|
|
5070
|
+
}
|
|
5071
|
+
function seedSession(home, body) {
|
|
5072
|
+
for (const path of sessionPaths(home)) {
|
|
5073
|
+
mkdirSync5(dirname9(path), { recursive: true });
|
|
5074
|
+
writeFileSync3(path, body);
|
|
5075
|
+
}
|
|
5076
|
+
}
|
|
5077
|
+
function resolveSession(config, env = process.env) {
|
|
5078
|
+
const home = steamHome(config.dataRoot);
|
|
5079
|
+
const raw = env["STEAM_CONFIG_VDF"];
|
|
5080
|
+
if (raw !== undefined && raw !== "") {
|
|
5081
|
+
seedSession(home, Buffer.from(raw, "base64"));
|
|
5082
|
+
return sessionPaths(home)[0];
|
|
5083
|
+
}
|
|
5084
|
+
const mine = findSession(home);
|
|
5085
|
+
if (mine !== undefined)
|
|
5086
|
+
return mine;
|
|
5087
|
+
const fallback = findSession(homedir3());
|
|
5088
|
+
if (fallback !== undefined) {
|
|
5089
|
+
seedSession(home, readFileSync5(fallback));
|
|
5090
|
+
return fallback;
|
|
5091
|
+
}
|
|
5092
|
+
throw new GamecrateError("no steam session found", Exit.Environment, `run gamecrate steam login, or set STEAM_CONFIG_VDF. looked under ${home} and ${homedir3()}`);
|
|
5093
|
+
}
|
|
5094
|
+
async function steamBuildCommand(args, ctx) {
|
|
5095
|
+
const game = args.game;
|
|
5096
|
+
if (game === undefined)
|
|
5097
|
+
throw new GamecrateError("steam build needs a game", Exit.Usage);
|
|
5098
|
+
status(`steam session from ${resolveSession(ctx.config)}`);
|
|
5099
|
+
steamAccount(ctx.config.dataRoot);
|
|
5100
|
+
const push = args.push === true;
|
|
5101
|
+
const load = args.load === true || !push;
|
|
5102
|
+
const plugins = args.plugin !== undefined && args.plugin.length > 0 ? args.plugin : undefined;
|
|
5103
|
+
const input = await resolveSteamBuildInput(game, ctx.config, { image: args.image, plugins, push }, ctx.cwd, ctx.configFile);
|
|
5104
|
+
const results = await steamBuild(input, {
|
|
5105
|
+
config: ctx.config,
|
|
5106
|
+
push,
|
|
5107
|
+
load,
|
|
5108
|
+
platform: args.platform ?? "linux/amd64",
|
|
5109
|
+
baseOverride: args.base,
|
|
5110
|
+
force: args.force === true,
|
|
5111
|
+
onlyBranches: args.branches,
|
|
5112
|
+
onlyVariants: args.variant
|
|
5113
|
+
});
|
|
5114
|
+
if (args.json)
|
|
5115
|
+
process.stdout.write(`${JSON.stringify(results, null, 2)}
|
|
5116
|
+
`);
|
|
5117
|
+
else
|
|
5118
|
+
for (const row of results)
|
|
5119
|
+
status(`${row.branch}/${row.variant} ${row.status} ${row.reason}`);
|
|
5120
|
+
return results.some((row) => row.status === "failed") ? Exit.Environment : Exit.Ok;
|
|
5121
|
+
}
|
|
5122
|
+
async function steamLogin(args, ctx) {
|
|
5123
|
+
const home = steamHome(ctx.config.dataRoot);
|
|
5124
|
+
mkdirSync5(home, { recursive: true });
|
|
5125
|
+
const runner = resolveSteamcmd(ctx.config);
|
|
5126
|
+
const username = args.username ?? await prompt("steam account name: ");
|
|
5127
|
+
if (username === "")
|
|
5128
|
+
throw new GamecrateError("steam login needs an account name", Exit.Usage);
|
|
5129
|
+
const argv = runner.kind === "docker" ? [...runner.argv.slice(0, 2), "-it", ...runner.argv.slice(2)] : [...runner.argv];
|
|
5130
|
+
const result = spawnSync3(argv[0], [...argv.slice(1), "+login", username, "+quit"], {
|
|
5131
|
+
stdio: "inherit",
|
|
5132
|
+
env: { ...process.env, ...runner.env }
|
|
5133
|
+
});
|
|
5134
|
+
if (result.error) {
|
|
5135
|
+
throw new GamecrateError(`could not run steamcmd: ${argv[0]}`, Exit.Environment, result.error.message);
|
|
5136
|
+
}
|
|
5137
|
+
const vdf = findSession(home);
|
|
5138
|
+
if (result.status !== 0 || vdf === undefined) {
|
|
5139
|
+
throw new GamecrateError("steam login did not leave a session behind", Exit.Environment, `steamcmd exited ${result.status ?? "on a signal"} and wrote no config.vdf under ${home}`);
|
|
5140
|
+
}
|
|
5141
|
+
writeFileSync3(accountFile(ctx.config.dataRoot), `${username}
|
|
5142
|
+
`);
|
|
5143
|
+
status(`session written to ${vdf}`);
|
|
5144
|
+
if (args.print === true)
|
|
5145
|
+
process.stdout.write(`${readFileSync5(vdf).toString("base64")}
|
|
5146
|
+
`);
|
|
5147
|
+
return Exit.Ok;
|
|
5148
|
+
}
|
|
5149
|
+
async function prompt(question) {
|
|
5150
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
5151
|
+
try {
|
|
5152
|
+
return (await rl.question(question)).trim();
|
|
5153
|
+
} finally {
|
|
5154
|
+
rl.close();
|
|
5155
|
+
}
|
|
5156
|
+
}
|
|
3927
5157
|
|
|
3928
5158
|
// src/cli/help.ts
|
|
3929
5159
|
var NAME = "gamecrate";
|
|
3930
5160
|
function flags() {
|
|
3931
|
-
|
|
5161
|
+
const seen = new Set;
|
|
5162
|
+
return allOptions(buildProgram()).filter((o) => {
|
|
5163
|
+
const long = o.long ?? o.flags;
|
|
5164
|
+
if (o.hidden || seen.has(long))
|
|
5165
|
+
return false;
|
|
5166
|
+
seen.add(long);
|
|
5167
|
+
return true;
|
|
5168
|
+
});
|
|
3932
5169
|
}
|
|
3933
5170
|
function renderHelp(topic, config) {
|
|
3934
5171
|
if (!topic)
|
|
@@ -4111,16 +5348,114 @@ function hostUserName(uid) {
|
|
|
4111
5348
|
}
|
|
4112
5349
|
|
|
4113
5350
|
// src/docker/preflight.ts
|
|
4114
|
-
import { existsSync as
|
|
4115
|
-
import { homedir as
|
|
4116
|
-
import { basename as basename6, join as
|
|
5351
|
+
import { existsSync as existsSync9, readFileSync as readFileSync6 } from "node:fs";
|
|
5352
|
+
import { homedir as homedir4 } from "node:os";
|
|
5353
|
+
import { basename as basename6, join as join17 } from "node:path";
|
|
5354
|
+
|
|
5355
|
+
// src/launch/image.ts
|
|
5356
|
+
var NO_IMAGE = {
|
|
5357
|
+
present: false,
|
|
5358
|
+
runtime: null,
|
|
5359
|
+
launcher: null,
|
|
5360
|
+
executable: null,
|
|
5361
|
+
branch: null,
|
|
5362
|
+
variant: null,
|
|
5363
|
+
buildid: null
|
|
5364
|
+
};
|
|
5365
|
+
async function readImageFacts(ref) {
|
|
5366
|
+
if (ref.trim() === "")
|
|
5367
|
+
return NO_IMAGE;
|
|
5368
|
+
if (await imageDigest(ref) === null)
|
|
5369
|
+
return NO_IMAGE;
|
|
5370
|
+
const [runtime, launcher, executable, branch, variant, buildid] = await Promise.all([
|
|
5371
|
+
imageLabel(ref, "gamecrate.runtime"),
|
|
5372
|
+
imageLabel(ref, "gamecrate.launcher"),
|
|
5373
|
+
imageLabel(ref, "gamecrate.executable"),
|
|
5374
|
+
imageLabel(ref, "gamecrate.branch"),
|
|
5375
|
+
imageLabel(ref, "gamecrate.variant"),
|
|
5376
|
+
imageLabel(ref, "steam.buildid")
|
|
5377
|
+
]);
|
|
5378
|
+
return { present: true, runtime, launcher, executable, branch, variant, buildid };
|
|
5379
|
+
}
|
|
5380
|
+
function imageLaunch(facts) {
|
|
5381
|
+
const launcher = facts.launcher === "proton" || facts.launcher === "direct" ? facts.launcher : undefined;
|
|
5382
|
+
return {
|
|
5383
|
+
...launcher === undefined ? {} : { launcher },
|
|
5384
|
+
...facts.executable === null ? {} : { executable: facts.executable }
|
|
5385
|
+
};
|
|
5386
|
+
}
|
|
5387
|
+
function imageProblem(input) {
|
|
5388
|
+
const { game, ref, mode, facts } = input;
|
|
5389
|
+
const where = `/games/${game}/image/ref`;
|
|
5390
|
+
const build = `gamecrate steam build ${game}`;
|
|
5391
|
+
if (ref.trim() === "") {
|
|
5392
|
+
return {
|
|
5393
|
+
where,
|
|
5394
|
+
message: `${game} has no image.ref configured`,
|
|
5395
|
+
suggestion: `${build}, then set games.${game}.image.ref to the tag it prints`
|
|
5396
|
+
};
|
|
5397
|
+
}
|
|
5398
|
+
if (!facts.present) {
|
|
5399
|
+
return {
|
|
5400
|
+
where,
|
|
5401
|
+
message: `image ${ref} is not present locally and could not be pulled`,
|
|
5402
|
+
suggestion: build
|
|
5403
|
+
};
|
|
5404
|
+
}
|
|
5405
|
+
if (mode === "headed")
|
|
5406
|
+
return null;
|
|
5407
|
+
if (facts.runtime === null) {
|
|
5408
|
+
return {
|
|
5409
|
+
where,
|
|
5410
|
+
message: `image ${ref} has no gamecrate.runtime label, so gamecrate did not build it and --mode ${mode} has no X server to use`,
|
|
5411
|
+
suggestion: `${build} rebuilds it on a runtime base`
|
|
5412
|
+
};
|
|
5413
|
+
}
|
|
5414
|
+
return null;
|
|
5415
|
+
}
|
|
5416
|
+
function markerProblem(input) {
|
|
5417
|
+
if (input.marker !== undefined)
|
|
5418
|
+
return null;
|
|
5419
|
+
if (input.facts.launcher !== "proton")
|
|
5420
|
+
return null;
|
|
5421
|
+
return {
|
|
5422
|
+
where: `/games/${input.game}/image/ref`,
|
|
5423
|
+
message: `${input.game} runs under proton, which cannot report the game's exit code`,
|
|
5424
|
+
suggestion: "pass --marker <text>: a log line is the only success signal this image has"
|
|
5425
|
+
};
|
|
5426
|
+
}
|
|
5427
|
+
function imageFor(game, profile, flag) {
|
|
5428
|
+
if (flag !== undefined)
|
|
5429
|
+
return flag;
|
|
5430
|
+
const spec = resolveProfile(game, profile);
|
|
5431
|
+
if (spec.image !== undefined)
|
|
5432
|
+
return spec.image;
|
|
5433
|
+
if (spec.gameVersion === undefined)
|
|
5434
|
+
return;
|
|
5435
|
+
return `${repoOf2(game.image.ref)}:${spec.gameVersion}`;
|
|
5436
|
+
}
|
|
5437
|
+
function repoOf2(ref) {
|
|
5438
|
+
const colon = ref.lastIndexOf(":");
|
|
5439
|
+
return colon === -1 || ref.includes("/", colon) ? ref : ref.slice(0, colon);
|
|
5440
|
+
}
|
|
5441
|
+
function withImageOverride(game, ref) {
|
|
5442
|
+
if (ref === undefined)
|
|
5443
|
+
return game;
|
|
5444
|
+
return {
|
|
5445
|
+
...game,
|
|
5446
|
+
image: { ...game.image, ref, acquire: "pull" },
|
|
5447
|
+
gameFiles: { ...game.gameFiles, source: "image" }
|
|
5448
|
+
};
|
|
5449
|
+
}
|
|
5450
|
+
|
|
5451
|
+
// src/docker/preflight.ts
|
|
4117
5452
|
var CDI_SPEC = "/etc/cdi/nvidia.yaml";
|
|
4118
|
-
async function preflight(plan) {
|
|
5453
|
+
async function preflight(plan, asShell = false) {
|
|
4119
5454
|
const problems = [];
|
|
4120
5455
|
const game = plan.gameConfig;
|
|
4121
5456
|
const dockerOk = await checkDocker(problems);
|
|
4122
5457
|
if (dockerOk) {
|
|
4123
|
-
await checkImage(plan, problems);
|
|
5458
|
+
await checkImage(plan, problems, asShell);
|
|
4124
5459
|
}
|
|
4125
5460
|
if (plan.settings.gpu)
|
|
4126
5461
|
checkCdi(problems);
|
|
@@ -4159,12 +5494,30 @@ async function checkImageRunnable(ref, where, game, problems) {
|
|
|
4159
5494
|
suggestion: corrupt ? `docker image rm ${ref} && docker builder prune -f, then gamecrate build ${game}` : undefined
|
|
4160
5495
|
});
|
|
4161
5496
|
}
|
|
4162
|
-
async function checkImage(plan, problems) {
|
|
5497
|
+
async function checkImage(plan, problems, asShell) {
|
|
4163
5498
|
const image = plan.gameConfig.image;
|
|
4164
5499
|
const where = `/games/${plan.game}/image/ref`;
|
|
4165
|
-
const
|
|
4166
|
-
|
|
5500
|
+
const facts = await readImageFacts(image.ref);
|
|
5501
|
+
const problem = imageProblem({ game: plan.game, ref: image.ref, mode: plan.mode, facts });
|
|
5502
|
+
if (facts.present) {
|
|
4167
5503
|
await checkImageRunnable(image.ref, where, plan.game, problems);
|
|
5504
|
+
if (problem)
|
|
5505
|
+
problems.push(problem);
|
|
5506
|
+
if (asShell)
|
|
5507
|
+
return;
|
|
5508
|
+
const mode = protonHeadedProblem(plan, facts, where);
|
|
5509
|
+
if (mode) {
|
|
5510
|
+
problems.push(mode);
|
|
5511
|
+
return;
|
|
5512
|
+
}
|
|
5513
|
+
const marker = markerProblem({ game: plan.game, facts, marker: plan.marker });
|
|
5514
|
+
if (marker)
|
|
5515
|
+
problems.push(marker);
|
|
5516
|
+
return;
|
|
5517
|
+
}
|
|
5518
|
+
if (image.ref.trim() === "") {
|
|
5519
|
+
if (problem)
|
|
5520
|
+
problems.push(problem);
|
|
4168
5521
|
return;
|
|
4169
5522
|
}
|
|
4170
5523
|
if (image.acquire === "build") {
|
|
@@ -4183,15 +5536,26 @@ async function checkImage(plan, problems) {
|
|
|
4183
5536
|
problems.push({
|
|
4184
5537
|
where,
|
|
4185
5538
|
message: `not authenticated to ${host}, so ${image.ref} cannot be pulled`,
|
|
4186
|
-
suggestion: `docker login ${host}`
|
|
5539
|
+
suggestion: `docker login ${host}, or gamecrate steam build ${plan.game} to make it locally`
|
|
4187
5540
|
});
|
|
4188
5541
|
return;
|
|
4189
5542
|
}
|
|
4190
|
-
|
|
4191
|
-
|
|
4192
|
-
|
|
4193
|
-
|
|
4194
|
-
|
|
5543
|
+
if (problem) {
|
|
5544
|
+
problems.push({
|
|
5545
|
+
...problem,
|
|
5546
|
+
message: `${problem.message}: ${firstLine(remote.stderr) || exitCode(remote.code)}`
|
|
5547
|
+
});
|
|
5548
|
+
}
|
|
5549
|
+
}
|
|
5550
|
+
function protonHeadedProblem(plan, facts, where) {
|
|
5551
|
+
try {
|
|
5552
|
+
refuseProtonHeaded(plan.game, plan.mode, imageLaunch(facts));
|
|
5553
|
+
return null;
|
|
5554
|
+
} catch (error) {
|
|
5555
|
+
if (!(error instanceof GamecrateError))
|
|
5556
|
+
throw error;
|
|
5557
|
+
return { where, message: error.message, suggestion: error.detail };
|
|
5558
|
+
}
|
|
4195
5559
|
}
|
|
4196
5560
|
function checkDisplay(plan, problems) {
|
|
4197
5561
|
if (plan.settings.display === "x11") {
|
|
@@ -4213,7 +5577,7 @@ function checkDisplay(plan, problems) {
|
|
|
4213
5577
|
});
|
|
4214
5578
|
}
|
|
4215
5579
|
function checkCdi(problems) {
|
|
4216
|
-
if (!
|
|
5580
|
+
if (!existsSync9(CDI_SPEC)) {
|
|
4217
5581
|
problems.push({
|
|
4218
5582
|
where: CDI_SPEC,
|
|
4219
5583
|
message: "no CDI spec, so --device nvidia.com/gpu=all cannot resolve",
|
|
@@ -4223,9 +5587,9 @@ function checkCdi(problems) {
|
|
|
4223
5587
|
}
|
|
4224
5588
|
let text = "";
|
|
4225
5589
|
try {
|
|
4226
|
-
text =
|
|
5590
|
+
text = readFileSync6(CDI_SPEC, "utf8");
|
|
4227
5591
|
} catch (error) {
|
|
4228
|
-
problems.push({ where: CDI_SPEC, message: `CDI spec is unreadable: ${
|
|
5592
|
+
problems.push({ where: CDI_SPEC, message: `CDI spec is unreadable: ${message2(error)}` });
|
|
4229
5593
|
return;
|
|
4230
5594
|
}
|
|
4231
5595
|
if (!/^[ \t]*(?:-[ \t]*)?name:[ \t]*["']?all["']?[ \t]*$/m.test(text)) {
|
|
@@ -4245,12 +5609,12 @@ function checkGameDir(plan, problems) {
|
|
|
4245
5609
|
problems.push({ where, message: 'gameFiles.source is "mount" but no host path is set' });
|
|
4246
5610
|
return;
|
|
4247
5611
|
}
|
|
4248
|
-
if (!
|
|
5612
|
+
if (!existsSync9(files.host)) {
|
|
4249
5613
|
problems.push({ where, message: `game directory does not exist: ${files.host}` });
|
|
4250
5614
|
return;
|
|
4251
5615
|
}
|
|
4252
|
-
const executable =
|
|
4253
|
-
if (!
|
|
5616
|
+
const executable = join17(files.host, basename6(plan.gameConfig.executable));
|
|
5617
|
+
if (!existsSync9(executable)) {
|
|
4254
5618
|
problems.push({
|
|
4255
5619
|
where,
|
|
4256
5620
|
message: `${files.host} does not contain ${basename6(plan.gameConfig.executable)}`,
|
|
@@ -4265,7 +5629,7 @@ function checkBindSources(plan, problems) {
|
|
|
4265
5629
|
} catch (error) {
|
|
4266
5630
|
problems.push({
|
|
4267
5631
|
where: `/games/${plan.game}`,
|
|
4268
|
-
message: error instanceof GamecrateError ? error.message :
|
|
5632
|
+
message: error instanceof GamecrateError ? error.message : message2(error),
|
|
4269
5633
|
suggestion: error instanceof GamecrateError ? error.detail : undefined
|
|
4270
5634
|
});
|
|
4271
5635
|
return;
|
|
@@ -4273,7 +5637,7 @@ function checkBindSources(plan, problems) {
|
|
|
4273
5637
|
for (const mount of mounts) {
|
|
4274
5638
|
if (mount.type !== "bind" || !mount.source)
|
|
4275
5639
|
continue;
|
|
4276
|
-
if (
|
|
5640
|
+
if (existsSync9(mount.source))
|
|
4277
5641
|
continue;
|
|
4278
5642
|
if (mount.source.startsWith(plan.profileDir))
|
|
4279
5643
|
continue;
|
|
@@ -4295,9 +5659,9 @@ function needsLogin(stderr) {
|
|
|
4295
5659
|
return /unauthorized|authentication required|denied|forbidden/i.test(stderr);
|
|
4296
5660
|
}
|
|
4297
5661
|
function hasStoredAuth(host) {
|
|
4298
|
-
const path =
|
|
5662
|
+
const path = join17(process.env.DOCKER_CONFIG ?? join17(homedir4(), ".docker"), "config.json");
|
|
4299
5663
|
try {
|
|
4300
|
-
const config = JSON.parse(
|
|
5664
|
+
const config = JSON.parse(readFileSync6(path, "utf8"));
|
|
4301
5665
|
if (config.credsStore || config.credHelpers?.[host])
|
|
4302
5666
|
return true;
|
|
4303
5667
|
return Object.keys(config.auths ?? {}).some((key) => key === host || key.includes(`//${host}`));
|
|
@@ -4312,14 +5676,14 @@ function firstLine(text) {
|
|
|
4312
5676
|
return text.trim().split(`
|
|
4313
5677
|
`)[0]?.trim() ?? "";
|
|
4314
5678
|
}
|
|
4315
|
-
function
|
|
5679
|
+
function message2(error) {
|
|
4316
5680
|
return error instanceof Error ? error.message : String(error);
|
|
4317
5681
|
}
|
|
4318
5682
|
|
|
4319
5683
|
// src/docker/window.ts
|
|
4320
|
-
import { readFileSync as
|
|
5684
|
+
import { readFileSync as readFileSync7 } from "node:fs";
|
|
4321
5685
|
import { basename as basename7 } from "node:path";
|
|
4322
|
-
import { setTimeout as
|
|
5686
|
+
import { setTimeout as sleep6 } from "node:timers/promises";
|
|
4323
5687
|
var WAIT_MS = 180000;
|
|
4324
5688
|
var POLL_MS = 500;
|
|
4325
5689
|
function newMatches(now, seen, executable) {
|
|
@@ -4335,7 +5699,7 @@ function isPeerClaim(pid, self) {
|
|
|
4335
5699
|
if (pid === self)
|
|
4336
5700
|
return false;
|
|
4337
5701
|
try {
|
|
4338
|
-
const argv =
|
|
5702
|
+
const argv = readFileSync7(`/proc/${pid}/cmdline`, "utf8").split("\x00");
|
|
4339
5703
|
return argv.some((arg) => basename7(arg).startsWith("gamecrate"));
|
|
4340
5704
|
} catch {
|
|
4341
5705
|
return false;
|
|
@@ -4377,7 +5741,7 @@ async function adoptNewWindow(opts) {
|
|
|
4377
5741
|
async function pollForWindow(seen, opts, stopped) {
|
|
4378
5742
|
const deadline = Date.now() + WAIT_MS;
|
|
4379
5743
|
while (!stopped() && Date.now() < deadline) {
|
|
4380
|
-
await
|
|
5744
|
+
await sleep6(POLL_MS);
|
|
4381
5745
|
if (stopped())
|
|
4382
5746
|
return;
|
|
4383
5747
|
if (await adoptFirstMatch(seen, opts, stopped))
|
|
@@ -4402,7 +5766,7 @@ async function adoptFirstMatch(seen, opts, stopped) {
|
|
|
4402
5766
|
async function watchForClose(id, stopped, onClosed) {
|
|
4403
5767
|
let misses = 0;
|
|
4404
5768
|
while (!stopped()) {
|
|
4405
|
-
await
|
|
5769
|
+
await sleep6(POLL_MS);
|
|
4406
5770
|
if (stopped())
|
|
4407
5771
|
return;
|
|
4408
5772
|
const now = await toplevels();
|
|
@@ -4460,19 +5824,27 @@ function parseAtoms(stdout) {
|
|
|
4460
5824
|
}
|
|
4461
5825
|
|
|
4462
5826
|
// src/launch/generate.ts
|
|
4463
|
-
import { mkdir as mkdir3, readdir as readdir7, readFile as
|
|
4464
|
-
import { dirname as
|
|
5827
|
+
import { mkdir as mkdir3, readdir as readdir7, readFile as readFile9, writeFile as writeFile4 } from "node:fs/promises";
|
|
5828
|
+
import { dirname as dirname10, join as join18 } from "node:path";
|
|
4465
5829
|
async function readInstallVersion(game, plugin) {
|
|
5830
|
+
const raw = await installVersionText(game);
|
|
5831
|
+
if (raw === null)
|
|
5832
|
+
return null;
|
|
5833
|
+
return plugin.parseVersion(raw.replace(/^/, "").trim());
|
|
5834
|
+
}
|
|
5835
|
+
async function installVersionText(game) {
|
|
5836
|
+
if (game.gameFiles.source !== "mount") {
|
|
5837
|
+
const ref = game.image?.ref;
|
|
5838
|
+
return ref === undefined || ref === "" ? null : await readFromImage(ref, `${game.gameFiles.container}/${game.version.file}`);
|
|
5839
|
+
}
|
|
4466
5840
|
const host = game.gameFiles.host;
|
|
4467
|
-
if (
|
|
5841
|
+
if (host === undefined)
|
|
4468
5842
|
return null;
|
|
4469
|
-
let raw;
|
|
4470
5843
|
try {
|
|
4471
|
-
|
|
5844
|
+
return await readFile9(join18(expandHome(host), game.version.file), "utf8");
|
|
4472
5845
|
} catch {
|
|
4473
5846
|
return null;
|
|
4474
5847
|
}
|
|
4475
|
-
return plugin.parseVersion(raw.replace(/^/, "").trim());
|
|
4476
5848
|
}
|
|
4477
5849
|
async function readKnownExpansions(game, plugin, warnings) {
|
|
4478
5850
|
if (game.dlc.length === 0)
|
|
@@ -4480,7 +5852,7 @@ async function readKnownExpansions(game, plugin, warnings) {
|
|
|
4480
5852
|
const host = game.gameFiles.host;
|
|
4481
5853
|
if (game.gameFiles.source !== "mount" || host === undefined)
|
|
4482
5854
|
return [...game.dlc];
|
|
4483
|
-
const dataDir =
|
|
5855
|
+
const dataDir = join18(expandHome(host), "Data");
|
|
4484
5856
|
let entries;
|
|
4485
5857
|
try {
|
|
4486
5858
|
entries = await readdir7(dataDir, { withFileTypes: true });
|
|
@@ -4494,7 +5866,7 @@ async function readKnownExpansions(game, plugin, warnings) {
|
|
|
4494
5866
|
continue;
|
|
4495
5867
|
let id = null;
|
|
4496
5868
|
try {
|
|
4497
|
-
id = plugin.parseManifest(await
|
|
5869
|
+
id = plugin.parseManifest(await readFile9(join18(dataDir, entry.name, game.manifest.file), "utf8"))?.packageId ?? null;
|
|
4498
5870
|
} catch {
|
|
4499
5871
|
continue;
|
|
4500
5872
|
}
|
|
@@ -4510,11 +5882,11 @@ async function readKnownExpansions(game, plugin, warnings) {
|
|
|
4510
5882
|
}
|
|
4511
5883
|
async function generateModsConfig(plan) {
|
|
4512
5884
|
const game = plan.gameConfig;
|
|
4513
|
-
const target =
|
|
4514
|
-
await mkdir3(
|
|
5885
|
+
const target = join18(plan.dataDirHost, game.modsConfig.file);
|
|
5886
|
+
await mkdir3(dirname10(target), { recursive: true });
|
|
4515
5887
|
const installed = await readInstallVersion(game, plan.plugin);
|
|
4516
5888
|
if (installed === null) {
|
|
4517
|
-
plan.warnings.push(`could not read
|
|
5889
|
+
plan.warnings.push(`could not read ${game.version.file} for ${plan.game}; ModsConfig version may be rejected`);
|
|
4518
5890
|
}
|
|
4519
5891
|
const declared = new Map([game.core, ...game.dlc].map((id) => [id.toLowerCase(), id]));
|
|
4520
5892
|
const seen = new Set;
|
|
@@ -4561,11 +5933,11 @@ function ownedPrefs(plan) {
|
|
|
4561
5933
|
return owned;
|
|
4562
5934
|
}
|
|
4563
5935
|
async function mergePrefs(plan) {
|
|
4564
|
-
const target =
|
|
4565
|
-
await mkdir3(
|
|
5936
|
+
const target = join18(plan.dataDirHost, plan.gameConfig.prefs.file);
|
|
5937
|
+
await mkdir3(dirname10(target), { recursive: true });
|
|
4566
5938
|
let existing = null;
|
|
4567
5939
|
try {
|
|
4568
|
-
existing = await
|
|
5940
|
+
existing = await readFile9(target, "utf8");
|
|
4569
5941
|
} catch (error) {
|
|
4570
5942
|
if (error.code !== "ENOENT")
|
|
4571
5943
|
throw error;
|
|
@@ -4574,11 +5946,89 @@ async function mergePrefs(plan) {
|
|
|
4574
5946
|
return target;
|
|
4575
5947
|
}
|
|
4576
5948
|
|
|
5949
|
+
// src/index.ts
|
|
5950
|
+
import { createInterface as createInterface2 } from "node:readline/promises";
|
|
5951
|
+
|
|
5952
|
+
// src/launch/updates.ts
|
|
5953
|
+
import { existsSync as existsSync10, mkdirSync as mkdirSync6, readFileSync as readFileSync8, writeFileSync as writeFileSync4 } from "node:fs";
|
|
5954
|
+
import { homedir as homedir5 } from "node:os";
|
|
5955
|
+
import { dirname as dirname11, join as join19 } from "node:path";
|
|
5956
|
+
var DEFAULT_HOURS = 6;
|
|
5957
|
+
function shouldCheck(input) {
|
|
5958
|
+
if (input.spec?.check === false)
|
|
5959
|
+
return { check: false, reason: "disabled" };
|
|
5960
|
+
const { branch, buildid } = input.facts;
|
|
5961
|
+
if (!input.facts.present || branch === null || buildid === null) {
|
|
5962
|
+
return { check: false, reason: "not-ours" };
|
|
5963
|
+
}
|
|
5964
|
+
const hours = input.spec?.everyHours ?? DEFAULT_HOURS;
|
|
5965
|
+
if (hours <= 0 || input.lastCheckedAt === null)
|
|
5966
|
+
return { check: true };
|
|
5967
|
+
const due = input.lastCheckedAt + hours * 3600000;
|
|
5968
|
+
return input.now >= due ? { check: true } : { check: false, reason: "throttled" };
|
|
5969
|
+
}
|
|
5970
|
+
function stampFile(imageId) {
|
|
5971
|
+
const root = process.env["XDG_CACHE_HOME"] ?? join19(homedir5(), ".cache");
|
|
5972
|
+
return join19(root, "gamecrate", "updates", `${imageId.replace(/[^A-Za-z0-9]/g, "-")}.json`);
|
|
5973
|
+
}
|
|
5974
|
+
function lastCheckedAt(imageId) {
|
|
5975
|
+
const file = stampFile(imageId);
|
|
5976
|
+
if (!existsSync10(file))
|
|
5977
|
+
return null;
|
|
5978
|
+
try {
|
|
5979
|
+
const at = JSON.parse(readFileSync8(file, "utf8")).at;
|
|
5980
|
+
return typeof at === "number" ? at : null;
|
|
5981
|
+
} catch {
|
|
5982
|
+
return null;
|
|
5983
|
+
}
|
|
5984
|
+
}
|
|
5985
|
+
function recordCheck(imageId, now) {
|
|
5986
|
+
const file = stampFile(imageId);
|
|
5987
|
+
try {
|
|
5988
|
+
mkdirSync6(dirname11(file), { recursive: true });
|
|
5989
|
+
writeFileSync4(file, JSON.stringify({ at: now }));
|
|
5990
|
+
} catch {}
|
|
5991
|
+
}
|
|
5992
|
+
async function offerRebuild(input) {
|
|
5993
|
+
const { game, config, args, facts } = input;
|
|
5994
|
+
const spec = config.games[game]?.image.updates;
|
|
5995
|
+
const ref = config.games[game].image.ref;
|
|
5996
|
+
const id = await imageDigest(ref);
|
|
5997
|
+
if (id === null)
|
|
5998
|
+
return;
|
|
5999
|
+
const now = Date.now();
|
|
6000
|
+
const verdict = shouldCheck({ facts, spec, lastCheckedAt: lastCheckedAt(id), now });
|
|
6001
|
+
if (!verdict.check)
|
|
6002
|
+
return;
|
|
6003
|
+
const plugins = args.plugin !== undefined && args.plugin.length > 0 ? args.plugin : undefined;
|
|
6004
|
+
const built = await resolveSteamBuildInput(game, config, { plugins }, input.cwd, input.configFile);
|
|
6005
|
+
const published = await publishedBuildId(config, built.steamAppId, facts.branch);
|
|
6006
|
+
recordCheck(id, now);
|
|
6007
|
+
const decision = decideGate({ published, imagePresent: true, labelled: facts.buildid, force: false });
|
|
6008
|
+
if (!decision.build || decision.reason !== "buildid-changed")
|
|
6009
|
+
return;
|
|
6010
|
+
const cell = `${facts.branch}/${facts.variant ?? "default"}`;
|
|
6011
|
+
warn(`${game} ${cell} is at build ${facts.buildid}, steam publishes ${published}`);
|
|
6012
|
+
if (!await input.ask(`rebuild ${ref} now? this downloads the game again [y/N] `)) {
|
|
6013
|
+
status(`keeping the current image. gamecrate steam build ${game} rebuilds it later`);
|
|
6014
|
+
return;
|
|
6015
|
+
}
|
|
6016
|
+
await steamBuild(built, {
|
|
6017
|
+
config,
|
|
6018
|
+
push: false,
|
|
6019
|
+
load: true,
|
|
6020
|
+
platform: args.platform ?? "linux/amd64",
|
|
6021
|
+
force: true,
|
|
6022
|
+
onlyBranches: [facts.branch],
|
|
6023
|
+
onlyVariants: facts.variant === null ? [] : [facts.variant]
|
|
6024
|
+
});
|
|
6025
|
+
}
|
|
6026
|
+
|
|
4577
6027
|
// src/launch/supervisor.ts
|
|
4578
|
-
import { existsSync as
|
|
4579
|
-
import { readFile as
|
|
4580
|
-
import { basename as basename8, join as
|
|
4581
|
-
import { setTimeout as
|
|
6028
|
+
import { existsSync as existsSync11 } from "node:fs";
|
|
6029
|
+
import { readFile as readFile10, readlink, rm as rm3, writeFile as writeFile5 } from "node:fs/promises";
|
|
6030
|
+
import { basename as basename8, join as join20 } from "node:path";
|
|
6031
|
+
import { setTimeout as sleep7 } from "node:timers/promises";
|
|
4582
6032
|
async function forkSupervisor(plan, argv) {
|
|
4583
6033
|
await clearLock(plan);
|
|
4584
6034
|
const name = containerName(plan);
|
|
@@ -4635,15 +6085,15 @@ async function supervisorFailed(dir, code) {
|
|
|
4635
6085
|
const record = { at: new Date().toISOString(), code, reason: "failed" };
|
|
4636
6086
|
const wrote = await writeExit(dir, record).then(() => true, () => false);
|
|
4637
6087
|
if (wrote)
|
|
4638
|
-
await rm3(
|
|
6088
|
+
await rm3(join20(dir, ".gamecrate", "lock"), { force: true }).catch(() => {});
|
|
4639
6089
|
return code;
|
|
4640
6090
|
}
|
|
4641
6091
|
async function writeExit(instanceDir, record) {
|
|
4642
|
-
await writeFile5(
|
|
6092
|
+
await writeFile5(join20(instanceDir, ".gamecrate", "last-exit.json"), `${JSON.stringify(record)}
|
|
4643
6093
|
`);
|
|
4644
6094
|
}
|
|
4645
6095
|
async function lastExit(instanceDir) {
|
|
4646
|
-
const text = await
|
|
6096
|
+
const text = await readFile10(join20(instanceDir, ".gamecrate", "last-exit.json"), "utf8").catch(() => {
|
|
4647
6097
|
return;
|
|
4648
6098
|
});
|
|
4649
6099
|
if (text === undefined)
|
|
@@ -4666,7 +6116,7 @@ function endedAfter(record, notBefore) {
|
|
|
4666
6116
|
return !Number.isNaN(at) && at >= began;
|
|
4667
6117
|
}
|
|
4668
6118
|
async function awaitExit(instanceDir, poll = WAIT_POLL_MS) {
|
|
4669
|
-
const file =
|
|
6119
|
+
const file = join20(instanceDir, ".gamecrate", "lock");
|
|
4670
6120
|
let watching;
|
|
4671
6121
|
for (;; ) {
|
|
4672
6122
|
const lock = await readLock(file) ?? watching;
|
|
@@ -4678,12 +6128,12 @@ async function awaitExit(instanceDir, poll = WAIT_POLL_MS) {
|
|
|
4678
6128
|
if (!isRunning(lock.pid, lock.startedAt))
|
|
4679
6129
|
return "orphaned";
|
|
4680
6130
|
watching = lock;
|
|
4681
|
-
await
|
|
6131
|
+
await sleep7(poll);
|
|
4682
6132
|
}
|
|
4683
6133
|
}
|
|
4684
6134
|
var RUN_LOG_POLL_MS = 250;
|
|
4685
6135
|
async function awaitRunLog(instanceDir, lock, poll = RUN_LOG_POLL_MS, notice = WAIT_NOTICE) {
|
|
4686
|
-
const link =
|
|
6136
|
+
const link = join20(instanceDir, "logs", "current");
|
|
4687
6137
|
const written = Date.parse(lock.startedAt);
|
|
4688
6138
|
const since = Date.now();
|
|
4689
6139
|
let lastNotice = 0;
|
|
@@ -4693,7 +6143,7 @@ async function awaitRunLog(instanceDir, lock, poll = RUN_LOG_POLL_MS, notice = W
|
|
|
4693
6143
|
});
|
|
4694
6144
|
const began = target === undefined ? undefined : runStartedAt(basename8(target));
|
|
4695
6145
|
const current = began !== undefined && (Number.isNaN(written) || began >= written);
|
|
4696
|
-
if (current &&
|
|
6146
|
+
if (current && existsSync11(currentLog(instanceDir)))
|
|
4697
6147
|
return true;
|
|
4698
6148
|
if (!isRunning(lock.pid, lock.startedAt))
|
|
4699
6149
|
return false;
|
|
@@ -4703,20 +6153,20 @@ async function awaitRunLog(instanceDir, lock, poll = RUN_LOG_POLL_MS, notice = W
|
|
|
4703
6153
|
status(`waiting for ${lock.game} ${lock.profile} to open its log (${label})`);
|
|
4704
6154
|
lastNotice = waited;
|
|
4705
6155
|
}
|
|
4706
|
-
await
|
|
6156
|
+
await sleep7(poll);
|
|
4707
6157
|
}
|
|
4708
6158
|
}
|
|
4709
6159
|
|
|
4710
6160
|
// src/launch/instance.ts
|
|
4711
6161
|
import { createHash as createHash2 } from "node:crypto";
|
|
4712
|
-
import { basename as basename9, join as
|
|
6162
|
+
import { basename as basename9, join as join21 } from "node:path";
|
|
4713
6163
|
|
|
4714
6164
|
// src/mods/worktree.ts
|
|
4715
|
-
import { spawnSync as
|
|
4716
|
-
import { existsSync as
|
|
6165
|
+
import { spawnSync as spawnSync4 } from "node:child_process";
|
|
6166
|
+
import { existsSync as existsSync12, realpathSync as realpathSync2 } from "node:fs";
|
|
4717
6167
|
import { isAbsolute as isAbsolute2, resolve as resolve5, sep } from "node:path";
|
|
4718
6168
|
function inspect(dir) {
|
|
4719
|
-
const r =
|
|
6169
|
+
const r = spawnSync4("git", ["-C", dir, "rev-parse", "--path-format=absolute", "--show-toplevel", "--git-dir", "--git-common-dir", "--abbrev-ref", "HEAD"], { encoding: "utf8" });
|
|
4720
6170
|
if (r.status !== 0 || typeof r.stdout !== "string")
|
|
4721
6171
|
return null;
|
|
4722
6172
|
const lines = r.stdout.trim().split(`
|
|
@@ -4736,7 +6186,7 @@ function canonical(p) {
|
|
|
4736
6186
|
function resolveWorktree(dir, source, order) {
|
|
4737
6187
|
const raw = expandHome(dir);
|
|
4738
6188
|
const abs = isAbsolute2(raw) ? raw : resolve5(process.cwd(), raw);
|
|
4739
|
-
if (!
|
|
6189
|
+
if (!existsSync12(abs)) {
|
|
4740
6190
|
return { where: abs, message: `--worktree path does not exist`, suggestion: "check the path, or drop the flag" };
|
|
4741
6191
|
}
|
|
4742
6192
|
const info = inspect(abs);
|
|
@@ -4791,7 +6241,7 @@ function resolveInstance(options) {
|
|
|
4791
6241
|
const name = args.instance === undefined ? derive(requests) : named(args.instance);
|
|
4792
6242
|
return {
|
|
4793
6243
|
...name === undefined ? {} : { name },
|
|
4794
|
-
dir: name === undefined ? profileDir :
|
|
6244
|
+
dir: name === undefined ? profileDir : join21(profileDir, "instances", name),
|
|
4795
6245
|
requests,
|
|
4796
6246
|
problems,
|
|
4797
6247
|
...configured?.settings === undefined ? {} : { settings: configured.settings }
|
|
@@ -4829,17 +6279,17 @@ function slug2(root) {
|
|
|
4829
6279
|
}
|
|
4830
6280
|
|
|
4831
6281
|
// src/launch/resolve.ts
|
|
4832
|
-
import { join as
|
|
6282
|
+
import { join as join23 } from "node:path";
|
|
4833
6283
|
|
|
4834
6284
|
// src/mods/modindex.ts
|
|
4835
6285
|
import { createHash as createHash3 } from "node:crypto";
|
|
4836
|
-
import { existsSync as
|
|
4837
|
-
import { mkdir as mkdir4, readdir as readdir8, readFile as
|
|
4838
|
-
import { homedir as
|
|
4839
|
-
import { dirname as
|
|
6286
|
+
import { existsSync as existsSync13, readFileSync as readFileSync9, statSync as statSync3 } from "node:fs";
|
|
6287
|
+
import { mkdir as mkdir4, readdir as readdir8, readFile as readFile11, writeFile as writeFile6 } from "node:fs/promises";
|
|
6288
|
+
import { homedir as homedir6 } from "node:os";
|
|
6289
|
+
import { dirname as dirname12, join as join22, relative as relative3, sep as sep2, resolve as resolvePath } from "node:path";
|
|
4840
6290
|
import picomatch from "picomatch";
|
|
4841
6291
|
function cacheDir() {
|
|
4842
|
-
return
|
|
6292
|
+
return join22(process.env["XDG_CACHE_HOME"] ?? join22(homedir6(), ".cache"), "gamecrate");
|
|
4843
6293
|
}
|
|
4844
6294
|
function globMatch(pattern, path) {
|
|
4845
6295
|
return picomatch.isMatch(path, pattern.replaceAll(/[[\]{}()!,@+|^$.\\]/g, String.raw`\$&`), { dot: true });
|
|
@@ -4851,8 +6301,8 @@ var ALWAYS_EXCLUDE = ["**/.worktrees/**", "**/.claude/worktrees/**"];
|
|
|
4851
6301
|
function inLinkedWorktree(dir, stopAt) {
|
|
4852
6302
|
let current = dir;
|
|
4853
6303
|
for (;; ) {
|
|
4854
|
-
const git =
|
|
4855
|
-
if (
|
|
6304
|
+
const git = join22(current, ".git");
|
|
6305
|
+
if (existsSync13(git)) {
|
|
4856
6306
|
try {
|
|
4857
6307
|
if (statSync3(git).isFile())
|
|
4858
6308
|
return true;
|
|
@@ -4863,7 +6313,7 @@ function inLinkedWorktree(dir, stopAt) {
|
|
|
4863
6313
|
}
|
|
4864
6314
|
if (current === stopAt)
|
|
4865
6315
|
return false;
|
|
4866
|
-
const parent =
|
|
6316
|
+
const parent = dirname12(current);
|
|
4867
6317
|
if (parent === current)
|
|
4868
6318
|
return false;
|
|
4869
6319
|
current = parent;
|
|
@@ -4873,8 +6323,8 @@ async function scanLocalRoot(root, rootIndex, manifestFile, found) {
|
|
|
4873
6323
|
const base = resolvePath(expandHome(root.path));
|
|
4874
6324
|
const exclude = [...root.exclude ?? [], ...rootIndex === -1 ? [] : ALWAYS_EXCLUDE];
|
|
4875
6325
|
const walk = async (dir, depth) => {
|
|
4876
|
-
const manifest =
|
|
4877
|
-
if (
|
|
6326
|
+
const manifest = join22(dir, manifestFile);
|
|
6327
|
+
if (existsSync13(manifest)) {
|
|
4878
6328
|
found.push({
|
|
4879
6329
|
dir,
|
|
4880
6330
|
kind: "local",
|
|
@@ -4894,13 +6344,13 @@ async function scanLocalRoot(root, rootIndex, manifestFile, found) {
|
|
|
4894
6344
|
for (const entry of entries) {
|
|
4895
6345
|
if (!entry.isDirectory() || entry.name.startsWith(".git"))
|
|
4896
6346
|
continue;
|
|
4897
|
-
const child =
|
|
6347
|
+
const child = join22(dir, entry.name);
|
|
4898
6348
|
if (excluded(exclude, relative3(base, child)))
|
|
4899
6349
|
continue;
|
|
4900
6350
|
await walk(child, depth + 1);
|
|
4901
6351
|
}
|
|
4902
6352
|
};
|
|
4903
|
-
if (!
|
|
6353
|
+
if (!existsSync13(base))
|
|
4904
6354
|
return;
|
|
4905
6355
|
await walk(base, 0);
|
|
4906
6356
|
}
|
|
@@ -4915,17 +6365,68 @@ async function scanWorkshopRoot(workshopRoot, rootIndex, manifestFile, found) {
|
|
|
4915
6365
|
for (const entry of entries) {
|
|
4916
6366
|
if (!entry.isDirectory() || !/^\d+$/.test(entry.name))
|
|
4917
6367
|
continue;
|
|
4918
|
-
const dir =
|
|
4919
|
-
if (!
|
|
6368
|
+
const dir = join22(base, entry.name);
|
|
6369
|
+
if (!existsSync13(join22(dir, manifestFile)))
|
|
4920
6370
|
continue;
|
|
4921
6371
|
found.push({ dir, kind: "workshop", rootIndex, linkedWorktree: false, workshopId: Number(entry.name) });
|
|
4922
6372
|
}
|
|
4923
6373
|
}
|
|
4924
|
-
async function
|
|
4925
|
-
|
|
4926
|
-
|
|
6374
|
+
async function gameDataDir(game) {
|
|
6375
|
+
if (game.gameFiles.source === "mount") {
|
|
6376
|
+
const host = game.gameFiles.host;
|
|
6377
|
+
return host === undefined ? null : join22(resolvePath(expandHome(host)), "Data");
|
|
6378
|
+
}
|
|
6379
|
+
const ref = game.image?.ref;
|
|
6380
|
+
if (ref === undefined || ref === "")
|
|
6381
|
+
return null;
|
|
6382
|
+
const id = await imageDigest(ref);
|
|
6383
|
+
if (id === null)
|
|
6384
|
+
return null;
|
|
6385
|
+
const out = join22(cacheDir(), "official", id.replace(/[^A-Za-z0-9]/g, "-"), "Data");
|
|
6386
|
+
if (!existsSync13(out))
|
|
6387
|
+
await copyOfficialManifests(game, ref, out);
|
|
6388
|
+
return existsSync13(out) ? out : null;
|
|
6389
|
+
}
|
|
6390
|
+
var MARK = "@@gamecrate@@ ";
|
|
6391
|
+
async function copyOfficialManifests(game, ref, out) {
|
|
6392
|
+
const script = [
|
|
6393
|
+
"for d in " + game.gameFiles.container + "/Data/*/; do",
|
|
6394
|
+
' f="${d}' + game.manifest.file + '"',
|
|
6395
|
+
' [ -f "$f" ] || continue',
|
|
6396
|
+
' echo "' + MARK + '$(basename "${d%/}")"',
|
|
6397
|
+
' cat "$f"',
|
|
6398
|
+
"done"
|
|
6399
|
+
].join(`
|
|
6400
|
+
`);
|
|
6401
|
+
const { code, stdout } = await capture(["docker", "run", "--rm", "--entrypoint", "sh", ref, "-c", script]);
|
|
6402
|
+
if (code !== 0)
|
|
4927
6403
|
return;
|
|
4928
|
-
const
|
|
6404
|
+
for (const block of stdout.split(MARK).slice(1)) {
|
|
6405
|
+
const cut = block.indexOf(`
|
|
6406
|
+
`);
|
|
6407
|
+
if (cut === -1)
|
|
6408
|
+
continue;
|
|
6409
|
+
const name = block.slice(0, cut).trim();
|
|
6410
|
+
if (name === "" || name.includes("/"))
|
|
6411
|
+
continue;
|
|
6412
|
+
const file = join22(out, name, game.manifest.file);
|
|
6413
|
+
await mkdir4(dirname12(file), { recursive: true });
|
|
6414
|
+
await writeFile6(file, block.slice(cut + 1));
|
|
6415
|
+
}
|
|
6416
|
+
}
|
|
6417
|
+
async function scanGameData(game, rootIndex, found, problems, gameName) {
|
|
6418
|
+
const data = await gameDataDir(game);
|
|
6419
|
+
if (data === null) {
|
|
6420
|
+
const ref = game.image?.ref;
|
|
6421
|
+
if (game.gameFiles.source !== "mount" && ref !== undefined && ref !== "") {
|
|
6422
|
+
problems.push({
|
|
6423
|
+
where: `/games/${gameName}/image/ref`,
|
|
6424
|
+
message: `${ref} is not present, so ${gameName} has no core or expansions to load`,
|
|
6425
|
+
suggestion: `gamecrate steam build ${gameName}, or docker pull ${ref}`
|
|
6426
|
+
});
|
|
6427
|
+
}
|
|
6428
|
+
return;
|
|
6429
|
+
}
|
|
4929
6430
|
let entries;
|
|
4930
6431
|
try {
|
|
4931
6432
|
entries = await readdir8(data, { withFileTypes: true });
|
|
@@ -4935,20 +6436,20 @@ async function scanGameData(game, rootIndex, found) {
|
|
|
4935
6436
|
for (const entry of entries) {
|
|
4936
6437
|
if (!entry.isDirectory())
|
|
4937
6438
|
continue;
|
|
4938
|
-
const dir =
|
|
4939
|
-
if (!
|
|
6439
|
+
const dir = join22(data, entry.name);
|
|
6440
|
+
if (!existsSync13(join22(dir, game.manifest.file)))
|
|
4940
6441
|
continue;
|
|
4941
6442
|
found.push({ dir, kind: "official", rootIndex, linkedWorktree: false });
|
|
4942
6443
|
}
|
|
4943
6444
|
}
|
|
4944
6445
|
var CACHE_VERSION = 4;
|
|
4945
6446
|
function acfPath2(root, steamAppId) {
|
|
4946
|
-
return
|
|
6447
|
+
return join22(dirname12(dirname12(resolvePath(expandHome(root)))), `appworkshop_${steamAppId}.acf`);
|
|
4947
6448
|
}
|
|
4948
6449
|
function contentPairs(acf) {
|
|
4949
6450
|
let text;
|
|
4950
6451
|
try {
|
|
4951
|
-
text =
|
|
6452
|
+
text = readFileSync9(acf, "utf8");
|
|
4952
6453
|
} catch (error) {
|
|
4953
6454
|
if (error.code === "ENOENT")
|
|
4954
6455
|
return [];
|
|
@@ -4980,7 +6481,7 @@ function workshopStamp(game, dataRoot) {
|
|
|
4980
6481
|
}
|
|
4981
6482
|
async function readWorkshopCache(game, stamp) {
|
|
4982
6483
|
try {
|
|
4983
|
-
const raw = JSON.parse(await
|
|
6484
|
+
const raw = JSON.parse(await readFile11(join22(cacheDir(), `${game}.workshop.json`), "utf8"));
|
|
4984
6485
|
if (raw.version !== CACHE_VERSION || raw.stamp !== stamp)
|
|
4985
6486
|
return null;
|
|
4986
6487
|
return raw.records;
|
|
@@ -4992,7 +6493,7 @@ async function writeWorkshopCache(game, stamp, records) {
|
|
|
4992
6493
|
const payload = { version: CACHE_VERSION, stamp, records };
|
|
4993
6494
|
try {
|
|
4994
6495
|
await mkdir4(cacheDir(), { recursive: true });
|
|
4995
|
-
await writeFile6(
|
|
6496
|
+
await writeFile6(join22(cacheDir(), `${game}.workshop.json`), JSON.stringify(payload));
|
|
4996
6497
|
} catch {}
|
|
4997
6498
|
}
|
|
4998
6499
|
function toRecord(candidate, manifest, game) {
|
|
@@ -5085,8 +6586,8 @@ async function applySourceOverrides(index, overrides, config) {
|
|
|
5085
6586
|
}
|
|
5086
6587
|
const wanted = spec.slice(0, eq);
|
|
5087
6588
|
const dir = resolvePath(expandHome(spec.slice(eq + 1)));
|
|
5088
|
-
const file =
|
|
5089
|
-
if (!
|
|
6589
|
+
const file = join22(dir, config.manifest.file);
|
|
6590
|
+
if (!existsSync13(file)) {
|
|
5090
6591
|
problems.push({
|
|
5091
6592
|
where: spec,
|
|
5092
6593
|
message: `no ${config.manifest.file} under ${dir}`,
|
|
@@ -5096,7 +6597,7 @@ async function applySourceOverrides(index, overrides, config) {
|
|
|
5096
6597
|
}
|
|
5097
6598
|
let manifest;
|
|
5098
6599
|
try {
|
|
5099
|
-
manifest = index.plugin.parseManifest(
|
|
6600
|
+
manifest = index.plugin.parseManifest(readFileSync9(file, "utf8"));
|
|
5100
6601
|
} catch (error) {
|
|
5101
6602
|
problems.push({ where: file, message: `could not parse: ${String(error)}` });
|
|
5102
6603
|
continue;
|
|
@@ -5165,7 +6666,7 @@ async function buildIndex(game, config, plugin, sourcesDir, dataRoot) {
|
|
|
5165
6666
|
}
|
|
5166
6667
|
async function indexLocal(index, config, sourcesDir, cacheIndex) {
|
|
5167
6668
|
const local = [];
|
|
5168
|
-
await scanGameData(config, -1, local);
|
|
6669
|
+
await scanGameData(config, -1, local, index.problems, index.game);
|
|
5169
6670
|
for (const [i, root] of config.scanRoots.entries()) {
|
|
5170
6671
|
await scanLocalRoot(root, i, config.manifest.file, local);
|
|
5171
6672
|
}
|
|
@@ -5209,7 +6710,7 @@ function oneModPerClone(records, sourcesDir) {
|
|
|
5209
6710
|
const stamps = new Map;
|
|
5210
6711
|
for (const record of [...records].sort((a, b) => Number(a.dir > b.dir) - Number(a.dir < b.dir))) {
|
|
5211
6712
|
const clone = relative3(sourcesDir, record.dir).split(sep2).slice(0, 2).join(sep2);
|
|
5212
|
-
const at =
|
|
6713
|
+
const at = join22(sourcesDir, clone);
|
|
5213
6714
|
let stamp = stamps.get(at);
|
|
5214
6715
|
if (stamp === undefined) {
|
|
5215
6716
|
stamp = statSync3(at).mtimeMs;
|
|
@@ -5224,9 +6725,9 @@ function oneModPerClone(records, sourcesDir) {
|
|
|
5224
6725
|
}
|
|
5225
6726
|
async function parseAll(candidates, config, plugin, problems) {
|
|
5226
6727
|
const records = await Promise.all(candidates.map(async (candidate) => {
|
|
5227
|
-
const file =
|
|
6728
|
+
const file = join22(candidate.dir, config.manifest.file);
|
|
5228
6729
|
try {
|
|
5229
|
-
const manifest = plugin.parseManifest(await
|
|
6730
|
+
const manifest = plugin.parseManifest(await readFile11(file, "utf8"));
|
|
5230
6731
|
return manifest === null ? null : toRecord(candidate, manifest, config);
|
|
5231
6732
|
} catch (error) {
|
|
5232
6733
|
problems.push({
|
|
@@ -5297,11 +6798,11 @@ function byPath(index, raw, game) {
|
|
|
5297
6798
|
if (hit)
|
|
5298
6799
|
return hit;
|
|
5299
6800
|
}
|
|
5300
|
-
const file =
|
|
5301
|
-
if (!
|
|
6801
|
+
const file = join22(dir, game.manifest.file);
|
|
6802
|
+
if (!existsSync13(file))
|
|
5302
6803
|
return null;
|
|
5303
6804
|
try {
|
|
5304
|
-
const manifest = index.plugin.parseManifest(
|
|
6805
|
+
const manifest = index.plugin.parseManifest(readFileSync9(file, "utf8"));
|
|
5305
6806
|
if (manifest === null)
|
|
5306
6807
|
return null;
|
|
5307
6808
|
return toRecord({ dir, kind: "local", rootIndex: -1, linkedWorktree: false }, manifest, game);
|
|
@@ -5335,7 +6836,7 @@ function refFor(entry, game, sources) {
|
|
|
5335
6836
|
if (pin?.git !== undefined) {
|
|
5336
6837
|
const dir = sources.get(object.id.toLowerCase());
|
|
5337
6838
|
if (dir !== undefined)
|
|
5338
|
-
return `path:${pin.subdir === undefined ? dir :
|
|
6839
|
+
return `path:${pin.subdir === undefined ? dir : join23(dir, pin.subdir)}`;
|
|
5339
6840
|
}
|
|
5340
6841
|
return object.id;
|
|
5341
6842
|
}
|
|
@@ -5672,11 +7173,11 @@ async function resolvePlan(options) {
|
|
|
5672
7173
|
profileDir,
|
|
5673
7174
|
...instance.name === undefined ? {} : { instance: instance.name },
|
|
5674
7175
|
instanceDir: instance.dir,
|
|
5675
|
-
dataDirHost:
|
|
5676
|
-
configDirHost:
|
|
5677
|
-
stageDirHost:
|
|
5678
|
-
logsDirHost:
|
|
5679
|
-
runDirHost:
|
|
7176
|
+
dataDirHost: join23(instance.dir, "game"),
|
|
7177
|
+
configDirHost: join23(profileDir, "config"),
|
|
7178
|
+
stageDirHost: join23(instance.dir, ".stage"),
|
|
7179
|
+
logsDirHost: join23(instance.dir, "logs"),
|
|
7180
|
+
runDirHost: join23(instance.dir, "logs"),
|
|
5680
7181
|
mode,
|
|
5681
7182
|
...args.marker === undefined ? {} : { marker: args.marker },
|
|
5682
7183
|
timeoutSeconds: args.timeout ?? DEFAULT_TIMEOUT_SECONDS,
|
|
@@ -5689,7 +7190,7 @@ async function resolvePlan(options) {
|
|
|
5689
7190
|
|
|
5690
7191
|
// src/run/registry.ts
|
|
5691
7192
|
import { readdir as readdir9 } from "node:fs/promises";
|
|
5692
|
-
import { join as
|
|
7193
|
+
import { join as join24 } from "node:path";
|
|
5693
7194
|
var FORMAT = '{{.Names}}\t{{.Label "gamecrate.game"}}\t{{.Label "gamecrate.profile"}}\t{{.Label "gamecrate.instance"}}\t{{.Status}}';
|
|
5694
7195
|
function parseDockerRuns(stdout) {
|
|
5695
7196
|
const out = [];
|
|
@@ -5714,13 +7215,13 @@ function parseDockerRuns(stdout) {
|
|
|
5714
7215
|
async function walkLocks(dataRoot) {
|
|
5715
7216
|
const out = [];
|
|
5716
7217
|
for (const game of await entries(dataRoot)) {
|
|
5717
|
-
const gameDir =
|
|
7218
|
+
const gameDir = join24(dataRoot, game);
|
|
5718
7219
|
for (const profile of await entries(gameDir)) {
|
|
5719
|
-
const profileDir =
|
|
5720
|
-
await push(out,
|
|
5721
|
-
const instancesDir =
|
|
7220
|
+
const profileDir = join24(gameDir, profile);
|
|
7221
|
+
await push(out, join24(profileDir, ".gamecrate", "lock"));
|
|
7222
|
+
const instancesDir = join24(profileDir, "instances");
|
|
5722
7223
|
for (const instance of await entries(instancesDir)) {
|
|
5723
|
-
await push(out,
|
|
7224
|
+
await push(out, join24(instancesDir, instance, ".gamecrate", "lock"));
|
|
5724
7225
|
}
|
|
5725
7226
|
}
|
|
5726
7227
|
}
|
|
@@ -5783,7 +7284,7 @@ function fromLock(lock) {
|
|
|
5783
7284
|
|
|
5784
7285
|
// src/launch/stage.ts
|
|
5785
7286
|
import { lstat, mkdir as mkdir5, readdir as readdir10, realpath as realpath2, rm as rm4, stat as stat5 } from "node:fs/promises";
|
|
5786
|
-
import { basename as basename10, join as
|
|
7287
|
+
import { basename as basename10, join as join25 } from "node:path";
|
|
5787
7288
|
async function stageMods(plan) {
|
|
5788
7289
|
await rm4(plan.stageDirHost, { recursive: true, force: true });
|
|
5789
7290
|
await mkdir5(plan.stageDirHost, { recursive: true });
|
|
@@ -5800,7 +7301,7 @@ async function stageMods(plan) {
|
|
|
5800
7301
|
if (!(await stat5(source)).isDirectory()) {
|
|
5801
7302
|
throw new GamecrateError(`${mod.packageId} does not resolve to a directory`, Exit.Environment, source);
|
|
5802
7303
|
}
|
|
5803
|
-
await mkdir5(
|
|
7304
|
+
await mkdir5(join25(plan.stageDirHost, basename10(mod.containerDir)), { recursive: true });
|
|
5804
7305
|
mounts.push({ type: "bind", source, target: mod.containerDir, readonly: true });
|
|
5805
7306
|
}
|
|
5806
7307
|
return mounts;
|
|
@@ -5810,12 +7311,12 @@ async function ensureProfileTree(plan) {
|
|
|
5810
7311
|
plan.profileDir,
|
|
5811
7312
|
plan.instanceDir,
|
|
5812
7313
|
plan.dataDirHost,
|
|
5813
|
-
|
|
5814
|
-
|
|
5815
|
-
|
|
5816
|
-
|
|
7314
|
+
join25(plan.configDirHost, "config"),
|
|
7315
|
+
join25(plan.configDirHost, "data"),
|
|
7316
|
+
join25(plan.configDirHost, "cache"),
|
|
7317
|
+
join25(plan.logsDirHost, "runs"),
|
|
5817
7318
|
plan.stageDirHost,
|
|
5818
|
-
|
|
7319
|
+
join25(plan.instanceDir, ".gamecrate"),
|
|
5819
7320
|
...engineDirs(plan)
|
|
5820
7321
|
]) {
|
|
5821
7322
|
await mkdir5(dir, { recursive: true });
|
|
@@ -5825,7 +7326,7 @@ function engineDirs(plan) {
|
|
|
5825
7326
|
const { dataDir, modsDir } = plan.gameConfig;
|
|
5826
7327
|
if (!modsDir.container.startsWith(`${dataDir.container}/`))
|
|
5827
7328
|
return [];
|
|
5828
|
-
return [
|
|
7329
|
+
return [join25(plan.dataDirHost, modsDir.container.slice(dataDir.container.length + 1))];
|
|
5829
7330
|
}
|
|
5830
7331
|
async function detectForeignOwnership(dir, uid, limit = 100) {
|
|
5831
7332
|
const foreign = [];
|
|
@@ -5847,7 +7348,7 @@ async function detectForeignOwnership(dir, uid, limit = 100) {
|
|
|
5847
7348
|
continue;
|
|
5848
7349
|
try {
|
|
5849
7350
|
for (const entry of await readdir10(current))
|
|
5850
|
-
queue.push(
|
|
7351
|
+
queue.push(join25(current, entry));
|
|
5851
7352
|
} catch {
|
|
5852
7353
|
continue;
|
|
5853
7354
|
}
|
|
@@ -5856,9 +7357,9 @@ async function detectForeignOwnership(dir, uid, limit = 100) {
|
|
|
5856
7357
|
}
|
|
5857
7358
|
|
|
5858
7359
|
// src/mods/workshop.ts
|
|
5859
|
-
import { existsSync as
|
|
5860
|
-
import { readFile as
|
|
5861
|
-
import { join as
|
|
7360
|
+
import { existsSync as existsSync14 } from "node:fs";
|
|
7361
|
+
import { readFile as readFile12 } from "node:fs/promises";
|
|
7362
|
+
import { join as join26, resolve as resolvePath2 } from "node:path";
|
|
5862
7363
|
var ROUNDS = 5;
|
|
5863
7364
|
async function prepareWorkshop(game, profileName, args, config, allowFetch, plugin, sources) {
|
|
5864
7365
|
const roots = mountedRoots(config.dataRoot, game);
|
|
@@ -5909,18 +7410,18 @@ function mountedRoots(dataRoot, game) {
|
|
|
5909
7410
|
return [downloadRoot(dataRoot, game), ...game.workshopRoot === null ? [] : [game.workshopRoot]];
|
|
5910
7411
|
}
|
|
5911
7412
|
function itemDir(roots, id) {
|
|
5912
|
-
return roots.map((root) =>
|
|
7413
|
+
return roots.map((root) => join26(root, id)).find((dir) => existsSync14(dir));
|
|
5913
7414
|
}
|
|
5914
7415
|
async function manifestOf(roots, id, game, plugin, problems) {
|
|
5915
7416
|
const dir = itemDir(roots, id);
|
|
5916
7417
|
return dir === undefined ? null : await manifestAt(dir, game, plugin, problems);
|
|
5917
7418
|
}
|
|
5918
7419
|
async function manifestAt(dir, game, plugin, problems) {
|
|
5919
|
-
const file =
|
|
5920
|
-
if (!
|
|
7420
|
+
const file = join26(dir, game.manifest.file);
|
|
7421
|
+
if (!existsSync14(file))
|
|
5921
7422
|
return null;
|
|
5922
7423
|
try {
|
|
5923
|
-
return plugin.parseManifest(await
|
|
7424
|
+
return plugin.parseManifest(await readFile12(file, "utf8"));
|
|
5924
7425
|
} catch (error) {
|
|
5925
7426
|
problems.push({ where: file, message: error instanceof Error ? error.message : String(error) });
|
|
5926
7427
|
return null;
|
|
@@ -5958,7 +7459,7 @@ function localDirOf(entry, game, sources) {
|
|
|
5958
7459
|
const pin = libraryPin(game, object.id);
|
|
5959
7460
|
const clone = sources.get(object.id.toLowerCase());
|
|
5960
7461
|
if (clone !== undefined)
|
|
5961
|
-
return pin?.subdir === undefined ? clone :
|
|
7462
|
+
return pin?.subdir === undefined ? clone : join26(clone, pin.subdir);
|
|
5962
7463
|
return pin?.path === undefined ? undefined : resolvePath2(expandHome(pin.path));
|
|
5963
7464
|
}
|
|
5964
7465
|
function wantedIds(game, profile, args) {
|
|
@@ -5992,7 +7493,7 @@ function published2(value) {
|
|
|
5992
7493
|
}
|
|
5993
7494
|
|
|
5994
7495
|
// src/index.ts
|
|
5995
|
-
var VERSION = "1.
|
|
7496
|
+
var VERSION = "2.1.0";
|
|
5996
7497
|
async function main(argv) {
|
|
5997
7498
|
const supervised = supervisedDir(argv);
|
|
5998
7499
|
try {
|
|
@@ -6043,6 +7544,12 @@ async function dispatch(argv, args, config, plugins, defaults) {
|
|
|
6043
7544
|
return modsSync(args, ctx);
|
|
6044
7545
|
return mods(args, config, plugins, defaults);
|
|
6045
7546
|
}
|
|
7547
|
+
case "steam": {
|
|
7548
|
+
const ctx = { config, plugins, cwd: process.cwd(), configFile: await globalConfigPath() };
|
|
7549
|
+
if (args.subverb === "login")
|
|
7550
|
+
return steamLogin(args, ctx);
|
|
7551
|
+
return steamBuildCommand(args, ctx);
|
|
7552
|
+
}
|
|
6046
7553
|
case "doctor":
|
|
6047
7554
|
return doctor(config, plugins);
|
|
6048
7555
|
case "clean":
|
|
@@ -6060,7 +7567,7 @@ async function dispatch(argv, args, config, plugins, defaults) {
|
|
|
6060
7567
|
case "stop":
|
|
6061
7568
|
return stop(args, config, defaults);
|
|
6062
7569
|
case "attach":
|
|
6063
|
-
return
|
|
7570
|
+
return attach2(args, config, defaults);
|
|
6064
7571
|
case "wait":
|
|
6065
7572
|
return waitFor(args, config, defaults);
|
|
6066
7573
|
case "shell":
|
|
@@ -6118,8 +7625,9 @@ function reportEnvironment(problems) {
|
|
|
6118
7625
|
}
|
|
6119
7626
|
async function run2(argv, args, config, plugins, defaults, asShell) {
|
|
6120
7627
|
const game = requireGame(args, config);
|
|
6121
|
-
const profile =
|
|
6122
|
-
const gameConfig = config.games[game];
|
|
7628
|
+
const profile = launchProfile(args, defaults, config.games[game]);
|
|
7629
|
+
const gameConfig = withImageOverride(config.games[game], imageFor(config.games[game], profile, args.image));
|
|
7630
|
+
config.games[game] = gameConfig;
|
|
6123
7631
|
const allowFetch = !args.dryRun && !args.printPlan;
|
|
6124
7632
|
const sources = await prepareSources(gameConfig, profile, args, config.dataRoot, allowFetch);
|
|
6125
7633
|
try {
|
|
@@ -6151,8 +7659,8 @@ async function resolved(inputs) {
|
|
|
6151
7659
|
reportProblems(fatal);
|
|
6152
7660
|
const identity = resolveIdentity(args.root);
|
|
6153
7661
|
if (args.printPlan || args.dryRun)
|
|
6154
|
-
return await reportPlanOnly(plan, args, profile, identity);
|
|
6155
|
-
const environment = await preflight(plan);
|
|
7662
|
+
return await reportPlanOnly(plan, args, profile, identity, asShell);
|
|
7663
|
+
const environment = await preflight(plan, asShell);
|
|
6156
7664
|
if (environment.length > 0)
|
|
6157
7665
|
reportEnvironment(environment);
|
|
6158
7666
|
await ensureProfileTree(plan);
|
|
@@ -6171,8 +7679,8 @@ async function resolved(inputs) {
|
|
|
6171
7679
|
await lock.release();
|
|
6172
7680
|
}
|
|
6173
7681
|
}
|
|
6174
|
-
async function reportPlanOnly(plan, args, profile, identity) {
|
|
6175
|
-
const environment = await preflight(plan);
|
|
7682
|
+
async function reportPlanOnly(plan, args, profile, identity, asShell) {
|
|
7683
|
+
const environment = await preflight(plan, asShell);
|
|
6176
7684
|
buildRunSpec(plan, [], identity);
|
|
6177
7685
|
if (args.printPlan)
|
|
6178
7686
|
printPlan(plan, args.json);
|
|
@@ -6197,7 +7705,7 @@ run: gamecrate fix-perms ${game} ${profile}`);
|
|
|
6197
7705
|
}
|
|
6198
7706
|
const runDir = openRunLog(plan.logsDirHost);
|
|
6199
7707
|
plan.runDirHost = runDir;
|
|
6200
|
-
const supervisorLog = args.supervised && args.log === undefined ? redirectOutput(
|
|
7708
|
+
const supervisorLog = args.supervised && args.log === undefined ? redirectOutput(join27(runDir, "supervisor.log")) : undefined;
|
|
6201
7709
|
try {
|
|
6202
7710
|
return await execute({ plan, args, config, identity, asShell, profileSpec, runDir, releaseSources });
|
|
6203
7711
|
} finally {
|
|
@@ -6206,87 +7714,140 @@ run: gamecrate fix-perms ${game} ${profile}`);
|
|
|
6206
7714
|
}
|
|
6207
7715
|
async function execute(inputs) {
|
|
6208
7716
|
const { plan, args, config, identity, asShell, profileSpec, runDir, releaseSources } = inputs;
|
|
6209
|
-
const game = plan.game;
|
|
6210
7717
|
await buildLocalMods(plan, buildPolicy(args, profileSpec));
|
|
6211
7718
|
await releaseSources();
|
|
6212
|
-
await
|
|
6213
|
-
const runtimeImage = plan.mode === "headed" ? config.games[game].image.ref : await ensureRuntimeLayer(config.games[game].image.ref);
|
|
7719
|
+
const { facts, imageStart } = await readyImage(plan, config, args, asShell);
|
|
6214
7720
|
const modMounts = await stageMods(plan);
|
|
6215
7721
|
await generateModsConfig(plan);
|
|
6216
7722
|
await mergePrefs(plan);
|
|
6217
7723
|
for (const warning of planWarnings(plan))
|
|
6218
7724
|
warn(warning);
|
|
6219
|
-
const spec = buildRunSpec(plan, modMounts, identity);
|
|
6220
|
-
spec.image = runtimeImage;
|
|
7725
|
+
const spec = buildRunSpec(plan, modMounts, identity, imageStart);
|
|
6221
7726
|
if (asShell) {
|
|
6222
7727
|
spec.command = ["/bin/bash"];
|
|
6223
7728
|
spec.extraArgs = [...spec.extraArgs, "--interactive", "--tty"];
|
|
6224
7729
|
}
|
|
6225
7730
|
await writeLaunchRecord(plan, spec.image);
|
|
7731
|
+
const trustExit = facts.launcher !== "proton";
|
|
7732
|
+
try {
|
|
7733
|
+
return await dispatchRun(spec, plan, runDir, trustExit, asShell);
|
|
7734
|
+
} finally {
|
|
7735
|
+
await copyOutLogs(plan);
|
|
7736
|
+
}
|
|
7737
|
+
}
|
|
7738
|
+
async function readyImage(plan, config, args, asShell) {
|
|
7739
|
+
const game = plan.game;
|
|
7740
|
+
const ref = config.games[game].image.ref;
|
|
6226
7741
|
try {
|
|
6227
|
-
|
|
6228
|
-
|
|
6229
|
-
|
|
6230
|
-
|
|
6231
|
-
|
|
6232
|
-
|
|
6233
|
-
|
|
6234
|
-
|
|
6235
|
-
|
|
6236
|
-
|
|
6237
|
-
|
|
6238
|
-
|
|
6239
|
-
|
|
6240
|
-
|
|
7742
|
+
await acquireImage(game, config.games[game], args.pull ?? "missing");
|
|
7743
|
+
} catch (error) {
|
|
7744
|
+
const absent = imageProblem({ game, ref, mode: plan.mode, facts: await readImageFacts(ref) });
|
|
7745
|
+
if (absent === null)
|
|
7746
|
+
throw error;
|
|
7747
|
+
throw new GamecrateError(absent.message, Exit.Environment, absent.suggestion);
|
|
7748
|
+
}
|
|
7749
|
+
let facts = await readImageFacts(ref);
|
|
7750
|
+
if (await rebuiltForUpdate(plan, config, args))
|
|
7751
|
+
facts = await readImageFacts(ref);
|
|
7752
|
+
const problem = imageProblem({ game, ref, mode: plan.mode, facts });
|
|
7753
|
+
if (problem !== null) {
|
|
7754
|
+
throw new GamecrateError(problem.message, Exit.Environment, problem.suggestion);
|
|
7755
|
+
}
|
|
7756
|
+
const imageStart = asShell ? undefined : imageLaunch(facts);
|
|
7757
|
+
refuseProtonHeaded(game, plan.mode, imageStart);
|
|
7758
|
+
const needsMarker = asShell ? null : markerProblem({ game, facts, marker: plan.marker });
|
|
7759
|
+
if (needsMarker !== null) {
|
|
7760
|
+
throw new GamecrateError(needsMarker.message, Exit.Usage, needsMarker.suggestion);
|
|
7761
|
+
}
|
|
7762
|
+
return { facts, imageStart };
|
|
7763
|
+
}
|
|
7764
|
+
async function rebuiltForUpdate(plan, config, args) {
|
|
7765
|
+
const facts = await readImageFacts(config.games[plan.game].image.ref);
|
|
7766
|
+
let rebuilt = false;
|
|
7767
|
+
await offerRebuild({
|
|
7768
|
+
game: plan.game,
|
|
7769
|
+
config,
|
|
7770
|
+
args,
|
|
7771
|
+
facts,
|
|
7772
|
+
cwd: process.cwd(),
|
|
7773
|
+
configFile: await globalConfigPath(),
|
|
7774
|
+
ask: async (question) => {
|
|
7775
|
+
if (args.yes)
|
|
7776
|
+
return rebuilt = true;
|
|
7777
|
+
if (process.stdin.isTTY !== true || args.json) {
|
|
7778
|
+
warn("no terminal to ask, so the launch keeps the current image");
|
|
7779
|
+
return false;
|
|
6241
7780
|
}
|
|
6242
|
-
|
|
6243
|
-
|
|
6244
|
-
|
|
6245
|
-
|
|
6246
|
-
|
|
6247
|
-
}
|
|
6248
|
-
|
|
6249
|
-
|
|
6250
|
-
|
|
6251
|
-
|
|
6252
|
-
|
|
7781
|
+
const rl = createInterface2({ input: process.stdin, output: process.stderr });
|
|
7782
|
+
try {
|
|
7783
|
+
const said = (await rl.question(question)).trim().toLowerCase();
|
|
7784
|
+
rebuilt = said === "y" || said === "yes";
|
|
7785
|
+
return rebuilt;
|
|
7786
|
+
} finally {
|
|
7787
|
+
rl.close();
|
|
7788
|
+
}
|
|
7789
|
+
}
|
|
7790
|
+
});
|
|
7791
|
+
return rebuilt;
|
|
7792
|
+
}
|
|
7793
|
+
async function dispatchRun(spec, plan, runDir, trustExit, asShell) {
|
|
7794
|
+
if (plan.marker !== undefined && !asShell)
|
|
7795
|
+
return await runWithMarker(spec, plan, runDir, trustExit);
|
|
7796
|
+
if (plan.mode === "screenshot" && !asShell)
|
|
7797
|
+
return await runWithScreenshot(spec, plan, runDir, trustExit);
|
|
7798
|
+
if (plan.mode !== "headed" && !asShell)
|
|
7799
|
+
return await runBounded(spec, plan, runDir, trustExit);
|
|
7800
|
+
let windowClosed = false;
|
|
7801
|
+
const window = asShell || plan.settings.display !== "x11" ? null : await adoptNewWindow({
|
|
7802
|
+
executable: plan.gameConfig.executable,
|
|
7803
|
+
title: windowTitle(plan),
|
|
7804
|
+
stripDelete: plan.gameConfig.ignoresWmDelete === true,
|
|
7805
|
+
onClosed: () => {
|
|
7806
|
+
windowClosed = true;
|
|
7807
|
+
stopContainer(spec.name, STOP_TIMEOUT_SECONDS);
|
|
6253
7808
|
}
|
|
7809
|
+
});
|
|
7810
|
+
try {
|
|
7811
|
+
const code = await runContainer(spec, { logDir: runDir, stopTimeoutSeconds: STOP_TIMEOUT_SECONDS });
|
|
7812
|
+
if (windowClosed)
|
|
7813
|
+
return { code: Exit.Ok, reason: "window-closed" };
|
|
7814
|
+
return { code: normalize(code), reason: reasonFor(code) };
|
|
6254
7815
|
} finally {
|
|
6255
|
-
|
|
7816
|
+
window?.stop();
|
|
6256
7817
|
}
|
|
6257
7818
|
}
|
|
6258
7819
|
function markerSources(plan, logDir) {
|
|
6259
|
-
const sources = [
|
|
7820
|
+
const sources = [join27(logDir, STDOUT_LOG)];
|
|
6260
7821
|
const { logFile } = plan.gameConfig;
|
|
6261
7822
|
if (logFile.mode === "arg")
|
|
6262
|
-
sources.push(
|
|
7823
|
+
sources.push(join27(logDir, "Player.log"));
|
|
6263
7824
|
else
|
|
6264
|
-
sources.push(
|
|
7825
|
+
sources.push(join27(plan.dataDirHost, logFile.from));
|
|
6265
7826
|
return sources;
|
|
6266
7827
|
}
|
|
6267
|
-
async function runBounded(spec, plan, logDir) {
|
|
7828
|
+
async function runBounded(spec, plan, logDir, trustExit) {
|
|
6268
7829
|
const container = runContainer(spec, { logDir, stopTimeoutSeconds: STOP_TIMEOUT_SECONDS });
|
|
6269
7830
|
const winner = await Promise.race([
|
|
6270
7831
|
container.then((code) => ({ kind: "exit", code })),
|
|
6271
|
-
|
|
7832
|
+
sleep8(plan.timeoutSeconds * 1000).then(() => ({ kind: "timeout" }))
|
|
6272
7833
|
]);
|
|
6273
7834
|
if (winner.kind === "exit")
|
|
6274
|
-
return
|
|
7835
|
+
return exitResult(winner.code, trustExit);
|
|
6275
7836
|
status(`no marker given; stopping after ${plan.timeoutSeconds}s`);
|
|
6276
7837
|
await stopContainer(spec.name, STOP_TIMEOUT_SECONDS);
|
|
6277
7838
|
await container;
|
|
6278
7839
|
return { code: Exit.Ok, reason: "timeout" };
|
|
6279
7840
|
}
|
|
6280
|
-
async function runWithScreenshot(spec, plan, logDir) {
|
|
7841
|
+
async function runWithScreenshot(spec, plan, logDir, trustExit) {
|
|
6281
7842
|
const container = runContainer(spec, { logDir, stopTimeoutSeconds: STOP_TIMEOUT_SECONDS });
|
|
6282
|
-
const settled =
|
|
7843
|
+
const settled = sleep8(plan.renderWaitSeconds * 1000).then(() => "ready");
|
|
6283
7844
|
const winner = await Promise.race([
|
|
6284
7845
|
container.then((code) => ({ kind: "exit", code })),
|
|
6285
7846
|
settled.then(() => ({ kind: "ready" }))
|
|
6286
7847
|
]);
|
|
6287
7848
|
if (winner.kind === "exit") {
|
|
6288
7849
|
status(`game exited before the ${plan.renderWaitSeconds}s render wait finished; no frame captured`);
|
|
6289
|
-
return
|
|
7850
|
+
return exitResult(winner.code, trustExit);
|
|
6290
7851
|
}
|
|
6291
7852
|
const shot = await grabFrame(spec.name, plan);
|
|
6292
7853
|
await stopContainer(spec.name, STOP_TIMEOUT_SECONDS);
|
|
@@ -6296,21 +7857,35 @@ async function runWithScreenshot(spec, plan, logDir) {
|
|
|
6296
7857
|
async function grabFrame(container, plan) {
|
|
6297
7858
|
const path = await captureScreenshot(container, plan);
|
|
6298
7859
|
if (path === null)
|
|
6299
|
-
warn("screenshot capture failed;
|
|
7860
|
+
warn("screenshot capture failed; the capture command printed the reason above");
|
|
6300
7861
|
else
|
|
6301
7862
|
status(`screenshot: ${path}`);
|
|
6302
7863
|
return path;
|
|
6303
7864
|
}
|
|
6304
|
-
|
|
7865
|
+
var MARKER_GRACE_MS = 1000;
|
|
7866
|
+
async function runWithMarker(spec, plan, logDir, trustExit) {
|
|
6305
7867
|
const marker = plan.marker;
|
|
6306
7868
|
const container = runContainer(spec, { logDir, stopTimeoutSeconds: STOP_TIMEOUT_SECONDS });
|
|
6307
|
-
|
|
7869
|
+
let waited;
|
|
7870
|
+
const seen = waitForMarker(markerSources(plan, logDir), marker, plan.timeoutSeconds).then((hit) => {
|
|
7871
|
+
waited = hit;
|
|
7872
|
+
return hit;
|
|
7873
|
+
});
|
|
6308
7874
|
const winner = await Promise.race([
|
|
6309
7875
|
container.then((code) => ({ kind: "exit", code })),
|
|
6310
7876
|
seen.then((hit) => ({ kind: "marker", hit }))
|
|
6311
7877
|
]);
|
|
6312
|
-
if (winner.kind === "exit")
|
|
6313
|
-
|
|
7878
|
+
if (winner.kind === "exit") {
|
|
7879
|
+
if (trustExit || winner.code === Exit.Interrupted)
|
|
7880
|
+
return exitResult(winner.code, trustExit);
|
|
7881
|
+
const hit = await Promise.race([seen, sleep8(MARKER_GRACE_MS).then(() => false)]);
|
|
7882
|
+
if (hit) {
|
|
7883
|
+
status(`marker seen: ${marker}`);
|
|
7884
|
+
return { code: Exit.Ok, reason: "marker" };
|
|
7885
|
+
}
|
|
7886
|
+
status(`container exited before the marker "${marker}" appeared`);
|
|
7887
|
+
return waited === false ? { code: Exit.MarkerTimeout, reason: "marker-timeout" } : { code: Exit.GameFailed, reason: "exited" };
|
|
7888
|
+
}
|
|
6314
7889
|
if (plan.mode === "screenshot")
|
|
6315
7890
|
await grabFrame(spec.name, plan);
|
|
6316
7891
|
await stopContainer(spec.name, STOP_TIMEOUT_SECONDS);
|
|
@@ -6322,6 +7897,12 @@ async function runWithMarker(spec, plan, logDir) {
|
|
|
6322
7897
|
status(`marker "${marker}" not seen within ${plan.timeoutSeconds}s`);
|
|
6323
7898
|
return { code: Exit.MarkerTimeout, reason: "marker-timeout" };
|
|
6324
7899
|
}
|
|
7900
|
+
function exitResult(code, trustExit) {
|
|
7901
|
+
if (trustExit || code === Exit.Interrupted) {
|
|
7902
|
+
return { code: normalize(code), reason: reasonFor(code) };
|
|
7903
|
+
}
|
|
7904
|
+
return { code: Exit.GameFailed, reason: "exited" };
|
|
7905
|
+
}
|
|
6325
7906
|
function normalize(code) {
|
|
6326
7907
|
return Number.isInteger(code) && code >= 0 && code <= 255 ? code : Exit.GameFailed;
|
|
6327
7908
|
}
|
|
@@ -6329,10 +7910,10 @@ async function copyOutLogs(plan) {
|
|
|
6329
7910
|
const spec = plan.gameConfig.logFile;
|
|
6330
7911
|
if (spec.mode !== "copy-out")
|
|
6331
7912
|
return;
|
|
6332
|
-
const source =
|
|
6333
|
-
if (!
|
|
7913
|
+
const source = join27(plan.dataDirHost, spec.from);
|
|
7914
|
+
if (!existsSync15(source))
|
|
6334
7915
|
return;
|
|
6335
|
-
const target =
|
|
7916
|
+
const target = join27(plan.runDirHost, basename11(spec.from));
|
|
6336
7917
|
try {
|
|
6337
7918
|
await cp(source, target, { recursive: true, force: true });
|
|
6338
7919
|
} catch (error) {
|
|
@@ -6399,7 +7980,7 @@ function steamcmdProblems(game, gameConfig, config) {
|
|
|
6399
7980
|
});
|
|
6400
7981
|
}
|
|
6401
7982
|
const root = downloadRoot(config.dataRoot, gameConfig);
|
|
6402
|
-
status(`${game}: workshop downloads ${root}${
|
|
7983
|
+
status(`${game}: workshop downloads ${root}${existsSync15(root) ? "" : " (not created yet)"}`);
|
|
6403
7984
|
return problems;
|
|
6404
7985
|
}
|
|
6405
7986
|
function reportDoctor(game, all) {
|
|
@@ -6424,12 +8005,12 @@ async function logs(args, config, defaults) {
|
|
|
6424
8005
|
const dir = instanceDir(args, config, game, profile);
|
|
6425
8006
|
if (args.follow)
|
|
6426
8007
|
return await follow(dir, false, `${game} ${profile}`);
|
|
6427
|
-
const runs =
|
|
8008
|
+
const runs = join27(dir, "logs", "runs");
|
|
6428
8009
|
const latest = (await readdir11(runs, { withFileTypes: true }).catch(() => [])).filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort().at(-1);
|
|
6429
8010
|
if (latest === undefined) {
|
|
6430
8011
|
throw new GamecrateError(`no runs recorded for ${game} ${profile}`, Exit.Usage, runs);
|
|
6431
8012
|
}
|
|
6432
|
-
const runDir =
|
|
8013
|
+
const runDir = join27(runs, latest);
|
|
6433
8014
|
const files = (await readdir11(runDir, { withFileTypes: true })).filter((entry) => entry.isFile()).map((entry) => entry.name).sort();
|
|
6434
8015
|
if (args.json) {
|
|
6435
8016
|
process.stdout.write(`${JSON.stringify({ run: latest, dir: runDir, files }, null, 2)}
|
|
@@ -6438,7 +8019,7 @@ async function logs(args, config, defaults) {
|
|
|
6438
8019
|
}
|
|
6439
8020
|
status(runDir);
|
|
6440
8021
|
for (const name of files) {
|
|
6441
|
-
const text = await
|
|
8022
|
+
const text = await readFile13(join27(runDir, name), "utf8").catch(() => "");
|
|
6442
8023
|
for (const line of text.split(`
|
|
6443
8024
|
`)) {
|
|
6444
8025
|
if (line.length > 0)
|
|
@@ -6555,7 +8136,7 @@ function renderVerify(name, info, plan, boundMods) {
|
|
|
6555
8136
|
`);
|
|
6556
8137
|
}
|
|
6557
8138
|
function shortenHome(path) {
|
|
6558
|
-
const home =
|
|
8139
|
+
const home = homedir7();
|
|
6559
8140
|
return path === home || path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path;
|
|
6560
8141
|
}
|
|
6561
8142
|
async function clean(args, config, defaults) {
|
|
@@ -6570,7 +8151,7 @@ async function clean(args, config, defaults) {
|
|
|
6570
8151
|
return Exit.Ok;
|
|
6571
8152
|
}
|
|
6572
8153
|
if (tier !== "all") {
|
|
6573
|
-
const target =
|
|
8154
|
+
const target = join27(instanceDir(args, config, game, profile), tier === "logs" ? "logs" : ".stage");
|
|
6574
8155
|
await rm5(target, { recursive: true, force: true });
|
|
6575
8156
|
status(`removed ${target}`);
|
|
6576
8157
|
return Exit.Ok;
|
|
@@ -6597,7 +8178,7 @@ async function countSaves(dir, suffixes) {
|
|
|
6597
8178
|
}
|
|
6598
8179
|
for (const entry of entries) {
|
|
6599
8180
|
if (entry.isDirectory())
|
|
6600
|
-
queue.push(
|
|
8181
|
+
queue.push(join27(current, entry.name));
|
|
6601
8182
|
else if (suffixes.some((s) => entry.name.toLowerCase().endsWith(s)))
|
|
6602
8183
|
count++;
|
|
6603
8184
|
}
|
|
@@ -6610,11 +8191,11 @@ async function clone(args, config) {
|
|
|
6610
8191
|
if (src === undefined || dst === undefined) {
|
|
6611
8192
|
throw new GamecrateError("clone needs a source and a destination profile", Exit.Usage);
|
|
6612
8193
|
}
|
|
6613
|
-
const from =
|
|
6614
|
-
const to =
|
|
6615
|
-
if (!
|
|
8194
|
+
const from = join27(profileDataDir(config, game, src), "game");
|
|
8195
|
+
const to = join27(profileDataDir(config, game, dst), "game");
|
|
8196
|
+
if (!existsSync15(from))
|
|
6616
8197
|
throw new GamecrateError(`${from} does not exist`, Exit.Usage);
|
|
6617
|
-
if (
|
|
8198
|
+
if (existsSync15(to) && !args.yes) {
|
|
6618
8199
|
throw new GamecrateError(`${to} already exists`, Exit.Usage, "add --yes to overwrite");
|
|
6619
8200
|
}
|
|
6620
8201
|
await mkdir6(to, { recursive: true });
|
|
@@ -6656,7 +8237,7 @@ async function ps(args, config) {
|
|
|
6656
8237
|
async function stop(args, config, defaults) {
|
|
6657
8238
|
const game = requireGame(args, config);
|
|
6658
8239
|
const profile = profileOf(args, defaults);
|
|
6659
|
-
const file =
|
|
8240
|
+
const file = join27(instanceDir(args, config, game, profile), ".gamecrate", "lock");
|
|
6660
8241
|
const record = await readLock(file);
|
|
6661
8242
|
if (record === undefined) {
|
|
6662
8243
|
status(`${game} ${profile} is not running`);
|
|
@@ -6670,17 +8251,17 @@ async function stop(args, config, defaults) {
|
|
|
6670
8251
|
status(outcome === "signalled" ? `stopped ${record.container}` : `cleared the stale lock for ${record.container}`);
|
|
6671
8252
|
return Exit.Ok;
|
|
6672
8253
|
}
|
|
6673
|
-
async function
|
|
8254
|
+
async function attach2(args, config, defaults) {
|
|
6674
8255
|
const game = requireGame(args, config);
|
|
6675
8256
|
const profile = profileOf(args, defaults);
|
|
6676
8257
|
return await follow(instanceDir(args, config, game, profile), true, `${game} ${profile}`);
|
|
6677
8258
|
}
|
|
6678
8259
|
async function follow(dir, fromStart, what) {
|
|
6679
|
-
const lock = await readLock(
|
|
8260
|
+
const lock = await readLock(join27(dir, ".gamecrate", "lock"));
|
|
6680
8261
|
const held = lock !== undefined && isRunning(lock.pid, lock.startedAt);
|
|
6681
8262
|
const live = held && await awaitRunLog(dir, lock);
|
|
6682
8263
|
const file = currentLog(dir);
|
|
6683
|
-
if (!
|
|
8264
|
+
if (!existsSync15(file)) {
|
|
6684
8265
|
throw new GamecrateError(`no captured output for ${what}`, Exit.Usage, file);
|
|
6685
8266
|
}
|
|
6686
8267
|
return await spawnStatus(tailArgv(file, fromStart, live ? lock.pid : undefined), true);
|
|
@@ -6713,9 +8294,9 @@ async function configEdit(args) {
|
|
|
6713
8294
|
if (args.rest[0] !== "edit")
|
|
6714
8295
|
throw new GamecrateError("config takes one word: edit", Exit.Usage);
|
|
6715
8296
|
const existing = await findGlobalConfig();
|
|
6716
|
-
const path = existing ??
|
|
6717
|
-
await mkdir6(
|
|
6718
|
-
if (!
|
|
8297
|
+
const path = existing ?? join27(globalConfigDir(), "profiles.yml");
|
|
8298
|
+
await mkdir6(dirname13(path), { recursive: true });
|
|
8299
|
+
if (!existsSync15(path)) {
|
|
6719
8300
|
await writeFile7(path, `# gamecrate config. see https://github.com/RimWorks/gamecrate
|
|
6720
8301
|
` + `plugins: []
|
|
6721
8302
|
` + `games: {}
|
|
@@ -6753,7 +8334,7 @@ async function fixPerms(args, config) {
|
|
|
6753
8334
|
const identity = resolveIdentity(false);
|
|
6754
8335
|
const found = [];
|
|
6755
8336
|
for (const dir of await profileDirs(config, game, args.profile)) {
|
|
6756
|
-
if (!
|
|
8337
|
+
if (!existsSync15(dir))
|
|
6757
8338
|
continue;
|
|
6758
8339
|
found.push(...await detectForeignOwnership(dir, identity.uid, 1e4));
|
|
6759
8340
|
}
|