@gamecrate/cli 1.4.1 → 2.0.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 +1868 -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 +27 -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,688 @@ 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@sha256:bb56228089823908bc3f6452ba3eb10f35f395813b2358758080452201ee4792",
|
|
4500
|
+
proton: "ghcr.io/rimworks/gamecrate/runtime-base-proton@sha256:55ee5e995a88207074ac455fc1932adb57239da66536bed26041436cb7f44fea"
|
|
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 craneLabels(ref) {
|
|
4726
|
+
const { code, stdout } = await capture(craneArgv([], ref, [["crane", "config", ref]]));
|
|
4727
|
+
if (code !== 0)
|
|
4728
|
+
return null;
|
|
4729
|
+
try {
|
|
4730
|
+
const parsed = JSON.parse(stdout);
|
|
4731
|
+
return parsed.config?.Labels ?? {};
|
|
4732
|
+
} catch {
|
|
4733
|
+
return null;
|
|
4734
|
+
}
|
|
4735
|
+
}
|
|
4736
|
+
|
|
4737
|
+
// src/image/gate.ts
|
|
4738
|
+
function decideGate(input) {
|
|
4739
|
+
if (input.force)
|
|
4740
|
+
return { build: true, reason: "forced" };
|
|
4741
|
+
if (!input.imagePresent)
|
|
4742
|
+
return { build: true, reason: "no-image" };
|
|
4743
|
+
if (input.labelled === null)
|
|
4744
|
+
return { build: true, reason: "no-label" };
|
|
4745
|
+
if (input.published === null)
|
|
4746
|
+
return { build: true, reason: "unknown-published" };
|
|
4747
|
+
if (input.published === input.labelled)
|
|
4748
|
+
return { build: false, reason: "up-to-date" };
|
|
4749
|
+
return { build: true, reason: "buildid-changed" };
|
|
4750
|
+
}
|
|
4751
|
+
|
|
4752
|
+
// src/image/input.ts
|
|
4753
|
+
import { join as join14 } from "node:path";
|
|
4754
|
+
function branchPasswordKey(branch) {
|
|
4755
|
+
return `STEAM_BRANCH_PASSWORD_${branch.toUpperCase().replaceAll(/[^A-Z0-9]/g, "_")}`;
|
|
4756
|
+
}
|
|
4757
|
+
function branchPassword(branch) {
|
|
4758
|
+
const key = branchPasswordKey(branch.name);
|
|
4759
|
+
const keyed = process.env[key];
|
|
4760
|
+
if (keyed !== undefined && keyed !== "")
|
|
4761
|
+
return keyed;
|
|
4762
|
+
if (branch.password !== true)
|
|
4763
|
+
return;
|
|
4764
|
+
const bare = process.env.STEAM_BRANCH_PASSWORD;
|
|
4765
|
+
if (bare === undefined || bare === "") {
|
|
4766
|
+
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`);
|
|
4767
|
+
}
|
|
4768
|
+
return bare;
|
|
4769
|
+
}
|
|
4770
|
+
function repoOf(ref) {
|
|
4771
|
+
const at = ref.indexOf("@");
|
|
4772
|
+
const head = at > 0 ? ref.slice(0, at) : ref;
|
|
4773
|
+
const colon = head.lastIndexOf(":");
|
|
4774
|
+
return colon > head.lastIndexOf("/") ? head.slice(0, colon) : head;
|
|
4775
|
+
}
|
|
4776
|
+
function resolveImage(flags, game, configured) {
|
|
4777
|
+
if (configured !== undefined)
|
|
4778
|
+
return repoOf(configured);
|
|
4779
|
+
if (flags.push) {
|
|
4780
|
+
throw new GamecrateError("--push needs a target repository", Exit.Usage, `pass --image <repo>, or set games.${game}.image.ref in a config file`);
|
|
4781
|
+
}
|
|
4782
|
+
return `gamecrate/${game}-game`;
|
|
4783
|
+
}
|
|
4784
|
+
function mergeForGame(game, defaults, config) {
|
|
4785
|
+
const base = { dataRoot: "", games: { [game]: defaults } };
|
|
4786
|
+
const user = { games: { [game]: config?.games?.[game] ?? {} } };
|
|
4787
|
+
return mergeUserConfig(base, user).games[game];
|
|
4788
|
+
}
|
|
4789
|
+
async function resolveSteamBuildInput(game, config, overrides, cwd, configFile) {
|
|
4790
|
+
const fromConfig = overrides.plugins === undefined && config?.plugins !== undefined;
|
|
4791
|
+
const specs = overrides.plugins ?? config?.plugins ?? [`@gamecrate/${game}`];
|
|
4792
|
+
const from = fromConfig && configFile !== undefined ? configFile : join14(cwd, ".gamecrate.yaml");
|
|
4793
|
+
const plugins = await loadPlugins(specs, from);
|
|
4794
|
+
const plugin = plugins.get(game);
|
|
4795
|
+
if (plugin === undefined) {
|
|
4796
|
+
throw new GamecrateError(`no plugin provides the game "${game}"`, Exit.Resolution, `tried: ${specs.join(", ")}. loaded: ${[...plugins.keys()].join(", ") || "(none)"}`);
|
|
4797
|
+
}
|
|
4798
|
+
const merged = mergeForGame(game, plugin.defaults, config);
|
|
4799
|
+
const want = (value, key) => {
|
|
4800
|
+
if (value === undefined) {
|
|
4801
|
+
throw new GamecrateError(`${game} has no ${key}`, Exit.Config, `the plugin ${specs.join(", ")} must declare ${key} in its defaults`);
|
|
4802
|
+
}
|
|
4803
|
+
return value;
|
|
4804
|
+
};
|
|
4805
|
+
const steamBuild = want(merged.steamBuild, "steamBuild");
|
|
4806
|
+
const checked = steamBuildSchema.safeParse(steamBuild);
|
|
4807
|
+
if (!checked.success) {
|
|
4808
|
+
const first = checked.error.issues[0];
|
|
4809
|
+
throw new GamecrateError(`${game} steamBuild is wrong: ${first.message}`, Exit.Config, `at steamBuild.${first.path.join(".")}, declared by ${specs.join(", ")}`);
|
|
4810
|
+
}
|
|
4811
|
+
const push = overrides.push === true;
|
|
4812
|
+
const image = resolveImage({ load: !push, push }, game, overrides.image ?? merged.image?.ref);
|
|
4813
|
+
return {
|
|
4814
|
+
game,
|
|
4815
|
+
steamAppId: want(merged.steamAppId, "steamAppId"),
|
|
4816
|
+
versionFile: want(merged.version, "version.file").file,
|
|
4817
|
+
gamePath: want(merged.gameFiles, "gameFiles").container,
|
|
4818
|
+
executable: want(merged.executable, "executable"),
|
|
4819
|
+
branches: steamBuild.branches,
|
|
4820
|
+
variants: steamBuild.variants,
|
|
4821
|
+
image: image.toLowerCase()
|
|
4822
|
+
};
|
|
4823
|
+
}
|
|
4824
|
+
|
|
4825
|
+
// src/image/tags.ts
|
|
4826
|
+
function sanitizeVersion(raw, fallback) {
|
|
4827
|
+
const first = raw.trim().split(/\s+/)[0] ?? "";
|
|
4828
|
+
const mapped = first.replaceAll(/[^A-Za-z0-9._-]/g, "-").replace(/-+$/, "");
|
|
4829
|
+
return mapped.length > 0 ? mapped : fallback;
|
|
4830
|
+
}
|
|
4831
|
+
function versionPrefixes(version) {
|
|
4832
|
+
const parts = version.split(".");
|
|
4833
|
+
if (parts.length < 2 || parts.some((p) => p === ""))
|
|
4834
|
+
return [];
|
|
4835
|
+
return parts.slice(0, -1).map((_, i) => parts.slice(0, i + 1).join("."));
|
|
4836
|
+
}
|
|
4837
|
+
function tagsFor(input) {
|
|
4838
|
+
const scope = input.defaultBranch ? "" : `-${input.branch}`;
|
|
4839
|
+
const versioned = [];
|
|
4840
|
+
const latest = [];
|
|
4841
|
+
if (input.defaultVariant) {
|
|
4842
|
+
versioned.push(`${input.version}${scope}`);
|
|
4843
|
+
latest.push(`latest${scope}`);
|
|
4844
|
+
}
|
|
4845
|
+
versioned.push(`${input.version}${scope}-${input.variant}`);
|
|
4846
|
+
latest.push(`latest${scope}-${input.variant}`);
|
|
4847
|
+
for (const prefix of versionPrefixes(input.version)) {
|
|
4848
|
+
if (input.defaultVariant)
|
|
4849
|
+
latest.push(`${prefix}${scope}`);
|
|
4850
|
+
latest.push(`${prefix}${scope}-${input.variant}`);
|
|
4851
|
+
}
|
|
4852
|
+
for (const alias of input.aliases ?? []) {
|
|
4853
|
+
if (input.defaultVariant)
|
|
4854
|
+
latest.push(alias);
|
|
4855
|
+
latest.push(`${alias}-${input.variant}`);
|
|
4856
|
+
}
|
|
4857
|
+
return [...versioned, ...latest];
|
|
4858
|
+
}
|
|
4859
|
+
|
|
4860
|
+
// src/image/build.ts
|
|
4861
|
+
async function steamBuild(input, opts) {
|
|
4862
|
+
const branches = narrow(input.branches, opts.onlyBranches, "branch", "branches");
|
|
4863
|
+
const variants = narrow(input.variants, opts.onlyVariants, "variant", "variants");
|
|
4864
|
+
if (opts.push)
|
|
4865
|
+
checkRegistryAuthEarly(input.image);
|
|
4866
|
+
const defaultBranch = input.branches[0].name;
|
|
4867
|
+
const defaultVariant = input.variants[0].name;
|
|
4868
|
+
const results = [];
|
|
4869
|
+
for (const branch of branches) {
|
|
4870
|
+
const published = await publishedBuildId(opts.config, input.steamAppId, branch.name).catch(() => null);
|
|
4871
|
+
const downloads = new Map;
|
|
4872
|
+
for (const variant of variants) {
|
|
4873
|
+
results.push(await cell(input, opts, { branch, variant, published, defaultBranch, defaultVariant, downloads }));
|
|
4874
|
+
}
|
|
4875
|
+
}
|
|
4876
|
+
return results;
|
|
4877
|
+
}
|
|
4878
|
+
function narrow(all, only, kind, plural) {
|
|
4879
|
+
if (only === undefined || only.length === 0)
|
|
4880
|
+
return all;
|
|
4881
|
+
const known = new Set(all.map((entry) => entry.name));
|
|
4882
|
+
const missing = only.filter((name) => !known.has(name));
|
|
4883
|
+
if (missing.length > 0) {
|
|
4884
|
+
throw new GamecrateError(`unknown ${kind} ${missing.join(", ")}`, Exit.Usage, `declared ${plural}: ${all.map((entry) => entry.name).join(", ")}`);
|
|
4885
|
+
}
|
|
4886
|
+
return all.filter((entry) => only.includes(entry.name));
|
|
4887
|
+
}
|
|
4888
|
+
async function cell(input, opts, ctx) {
|
|
4889
|
+
const { branch, variant } = ctx;
|
|
4890
|
+
const row = { branch: branch.name, variant: variant.name };
|
|
4891
|
+
const say = (text) => status(`${branch.name}/${variant.name} ${text}`);
|
|
4892
|
+
if (!opts.push && variant.base === "none") {
|
|
4893
|
+
say("skipped, reference-only, use --push");
|
|
4894
|
+
return { ...row, status: "skipped", reason: "reference-only, use --push", tags: [] };
|
|
4895
|
+
}
|
|
4896
|
+
const tagInput = {
|
|
4897
|
+
branch: branch.name,
|
|
4898
|
+
variant: variant.name,
|
|
4899
|
+
defaultBranch: branch.name === ctx.defaultBranch,
|
|
4900
|
+
defaultVariant: variant.name === ctx.defaultVariant,
|
|
4901
|
+
aliases: branch.tags
|
|
4902
|
+
};
|
|
4903
|
+
const probe = tagsFor({ version: "0", ...tagInput });
|
|
4904
|
+
const gateRef = `${input.image}:${probe.find((tag) => tag.startsWith("latest"))}`;
|
|
4905
|
+
const found = await readGate(gateRef, opts);
|
|
4906
|
+
const decision = decideGate({
|
|
4907
|
+
published: ctx.published,
|
|
4908
|
+
imagePresent: found.present,
|
|
4909
|
+
labelled: found.buildid,
|
|
4910
|
+
force: opts.force
|
|
4911
|
+
});
|
|
4912
|
+
if (!decision.build) {
|
|
4913
|
+
say(`skipped, ${decision.reason}`);
|
|
4914
|
+
return { ...row, status: "skipped", reason: decision.reason, tags: [] };
|
|
4915
|
+
}
|
|
4916
|
+
try {
|
|
4917
|
+
const dir = await download(input, opts, ctx, say);
|
|
4918
|
+
const raw = await readFile8(join15(dir, input.versionFile), "utf8");
|
|
4919
|
+
const version = sanitizeVersion(raw, ctx.published ?? "unknown");
|
|
4920
|
+
const tags = tagsFor({ ...tagInput, version });
|
|
4921
|
+
const layers = join15(steamHome(opts.config.dataRoot), "layers");
|
|
4922
|
+
mkdirSync4(layers, { recursive: true });
|
|
4923
|
+
const tar = join15(layers, `${branch.name}-${variant.name}-${version}.tar`);
|
|
4924
|
+
const base = resolveBase(variant.base, opts.baseOverride);
|
|
4925
|
+
const versioned = `${input.image}:${tags[0]}`;
|
|
4926
|
+
try {
|
|
4927
|
+
say(`appending onto ${base ?? "scratch"}`);
|
|
4928
|
+
await craneAppend({
|
|
4929
|
+
gameDir: dir,
|
|
4930
|
+
include: variant.include,
|
|
4931
|
+
gamePath: input.gamePath,
|
|
4932
|
+
base,
|
|
4933
|
+
platform: opts.platform,
|
|
4934
|
+
tag: versioned,
|
|
4935
|
+
out: tar
|
|
4936
|
+
});
|
|
4937
|
+
if (opts.push) {
|
|
4938
|
+
say(`pushing ${versioned}`);
|
|
4939
|
+
await cranePush(tar, versioned);
|
|
4940
|
+
await craneMutateLabels(versioned, labelsFor(input, ctx, base));
|
|
4941
|
+
for (const tag of tags.slice(1))
|
|
4942
|
+
await craneTag(versioned, tag);
|
|
4943
|
+
}
|
|
4944
|
+
if (opts.load && base !== null) {
|
|
4945
|
+
say(`loading ${versioned}`);
|
|
4946
|
+
await dockerLoad(tar, versioned);
|
|
4947
|
+
await dockerLabel(versioned, labelsFor(input, ctx, base));
|
|
4948
|
+
for (const tag of tags.slice(1))
|
|
4949
|
+
await dockerTag(versioned, `${input.image}:${tag}`);
|
|
4950
|
+
}
|
|
4951
|
+
} finally {
|
|
4952
|
+
rmSync3(tar, { force: true });
|
|
4953
|
+
rmSync3(`${tar}.layer.tar`, { force: true });
|
|
4954
|
+
}
|
|
4955
|
+
return { ...row, status: "built", reason: decision.reason, tags };
|
|
4956
|
+
} catch (error) {
|
|
4957
|
+
return { ...row, status: "failed", reason: message(error), tags: [] };
|
|
4958
|
+
}
|
|
4959
|
+
}
|
|
4960
|
+
async function download(input, opts, ctx, say) {
|
|
4961
|
+
const key = ctx.variant.depot ?? "default";
|
|
4962
|
+
let pending = ctx.downloads.get(key);
|
|
4963
|
+
if (pending === undefined) {
|
|
4964
|
+
say(`downloading ${input.steamAppId} (branch ${ctx.branch.name}, depot ${key})`);
|
|
4965
|
+
pending = downloadApp(opts.config, {
|
|
4966
|
+
steamAppId: input.steamAppId,
|
|
4967
|
+
branch: ctx.branch.name,
|
|
4968
|
+
depot: ctx.variant.depot,
|
|
4969
|
+
password: branchPassword(ctx.branch),
|
|
4970
|
+
dataRoot: opts.config.dataRoot
|
|
4971
|
+
});
|
|
4972
|
+
ctx.downloads.set(key, pending);
|
|
4973
|
+
} else {
|
|
4974
|
+
say(`reusing the ${ctx.branch.name} ${key} download`);
|
|
4975
|
+
}
|
|
4976
|
+
return (await pending).dir;
|
|
4977
|
+
}
|
|
4978
|
+
function labelsFor(input, ctx, base) {
|
|
4979
|
+
const labels = {
|
|
4980
|
+
"gamecrate.variant": ctx.variant.name,
|
|
4981
|
+
"gamecrate.branch": ctx.branch.name,
|
|
4982
|
+
"gamecrate.executable": ctx.branch.executable?.[ctx.variant.name] ?? ctx.variant.executable ?? input.executable,
|
|
4983
|
+
"gamecrate.launcher": ctx.variant.base === "proton" ? "proton" : "direct"
|
|
4984
|
+
};
|
|
4985
|
+
if (ctx.published !== null)
|
|
4986
|
+
labels["steam.buildid"] = ctx.published;
|
|
4987
|
+
if (base !== null)
|
|
4988
|
+
labels["gamecrate.runtime"] = base;
|
|
4989
|
+
return labels;
|
|
4990
|
+
}
|
|
4991
|
+
async function readGate(ref, opts) {
|
|
4992
|
+
const reads = [];
|
|
4993
|
+
if (opts.push)
|
|
4994
|
+
reads.push(await craneLabels(ref));
|
|
4995
|
+
if (opts.load)
|
|
4996
|
+
reads.push(await inspectLabels(ref));
|
|
4997
|
+
if (reads.some((labels) => labels === null))
|
|
4998
|
+
return { present: false, buildid: null };
|
|
4999
|
+
const ids = reads.map((labels) => labels?.["steam.buildid"] ?? null);
|
|
5000
|
+
return { present: reads.length > 0, buildid: ids.every((id) => id === ids[0]) ? ids[0] ?? null : null };
|
|
5001
|
+
}
|
|
5002
|
+
async function inspectLabels(ref) {
|
|
5003
|
+
const { code, stdout } = await capture(["docker", "image", "inspect", "--format", "{{json .Config.Labels}}", ref]);
|
|
5004
|
+
if (code !== 0)
|
|
5005
|
+
return null;
|
|
5006
|
+
try {
|
|
5007
|
+
return JSON.parse(stdout.trim()) ?? {};
|
|
5008
|
+
} catch {
|
|
5009
|
+
return null;
|
|
5010
|
+
}
|
|
5011
|
+
}
|
|
5012
|
+
async function dockerLoad(tar, ref) {
|
|
5013
|
+
const { code, stdout, stderr } = await capture(["docker", "load", "-i", tar]);
|
|
5014
|
+
if (code !== 0 || !stdout.includes(`Loaded image: ${ref}`)) {
|
|
5015
|
+
throw new GamecrateError(`docker load failed for ${tar}`, Exit.Environment, `expected "Loaded image: ${ref}", got: ${`${stdout}
|
|
5016
|
+
${stderr}`.trim() || "(no output)"}`);
|
|
5017
|
+
}
|
|
5018
|
+
}
|
|
5019
|
+
async function dockerLabel(ref, labels) {
|
|
5020
|
+
const dir = mkdtempSync(join15(tmpdir(), "gamecrate-label-"));
|
|
5021
|
+
writeFileSync2(join15(dir, "Dockerfile"), `FROM ${ref}
|
|
5022
|
+
`);
|
|
5023
|
+
const argv = ["docker", "build", "-f", join15(dir, "Dockerfile"), "-t", ref];
|
|
5024
|
+
for (const [key, value] of Object.entries(labels))
|
|
5025
|
+
argv.push("--label", `${key}=${value}`);
|
|
5026
|
+
argv.push(dir);
|
|
5027
|
+
const { code, stdout, stderr } = await capture(argv);
|
|
5028
|
+
rmSync3(dir, { recursive: true, force: true });
|
|
5029
|
+
if (code !== 0) {
|
|
5030
|
+
throw new GamecrateError(`docker build --label failed for ${ref}`, Exit.Environment, `${stdout}
|
|
5031
|
+
${stderr}`.trim());
|
|
5032
|
+
}
|
|
5033
|
+
}
|
|
5034
|
+
async function dockerTag(from, ref) {
|
|
5035
|
+
const { code, stdout, stderr } = await capture(["docker", "tag", from, ref]);
|
|
5036
|
+
if (code !== 0) {
|
|
5037
|
+
throw new GamecrateError(`docker tag ${ref} failed`, Exit.Environment, `${stdout}
|
|
5038
|
+
${stderr}`.trim());
|
|
5039
|
+
}
|
|
5040
|
+
}
|
|
5041
|
+
function message(error) {
|
|
5042
|
+
const head = error instanceof Error ? error.message : String(error);
|
|
5043
|
+
const detail = error instanceof GamecrateError ? error.detail?.replaceAll(/\s+/g, " ").trim() : undefined;
|
|
5044
|
+
return detail ? `${head}: ${detail}` : head;
|
|
5045
|
+
}
|
|
5046
|
+
|
|
5047
|
+
// src/cli/steam.ts
|
|
5048
|
+
function sessionPaths(home) {
|
|
5049
|
+
return [
|
|
5050
|
+
join16(home, ".steam", "config", "config.vdf"),
|
|
5051
|
+
join16(home, ".local", "share", "Steam", "config", "config.vdf"),
|
|
5052
|
+
join16(home, "Steam", "config", "config.vdf")
|
|
5053
|
+
];
|
|
5054
|
+
}
|
|
5055
|
+
function findSession(home) {
|
|
5056
|
+
return sessionPaths(home).find((path) => existsSync8(path));
|
|
5057
|
+
}
|
|
5058
|
+
function seedSession(home, body) {
|
|
5059
|
+
for (const path of sessionPaths(home)) {
|
|
5060
|
+
mkdirSync5(dirname9(path), { recursive: true });
|
|
5061
|
+
writeFileSync3(path, body);
|
|
5062
|
+
}
|
|
5063
|
+
}
|
|
5064
|
+
function resolveSession(config, env = process.env) {
|
|
5065
|
+
const home = steamHome(config.dataRoot);
|
|
5066
|
+
const raw = env["STEAM_CONFIG_VDF"];
|
|
5067
|
+
if (raw !== undefined && raw !== "") {
|
|
5068
|
+
seedSession(home, Buffer.from(raw, "base64"));
|
|
5069
|
+
return sessionPaths(home)[0];
|
|
5070
|
+
}
|
|
5071
|
+
const mine = findSession(home);
|
|
5072
|
+
if (mine !== undefined)
|
|
5073
|
+
return mine;
|
|
5074
|
+
const fallback = findSession(homedir3());
|
|
5075
|
+
if (fallback !== undefined) {
|
|
5076
|
+
seedSession(home, readFileSync5(fallback));
|
|
5077
|
+
return fallback;
|
|
5078
|
+
}
|
|
5079
|
+
throw new GamecrateError("no steam session found", Exit.Environment, `run gamecrate steam login, or set STEAM_CONFIG_VDF. looked under ${home} and ${homedir3()}`);
|
|
5080
|
+
}
|
|
5081
|
+
async function steamBuildCommand(args, ctx) {
|
|
5082
|
+
const game = args.game;
|
|
5083
|
+
if (game === undefined)
|
|
5084
|
+
throw new GamecrateError("steam build needs a game", Exit.Usage);
|
|
5085
|
+
status(`steam session from ${resolveSession(ctx.config)}`);
|
|
5086
|
+
steamAccount(ctx.config.dataRoot);
|
|
5087
|
+
const push = args.push === true;
|
|
5088
|
+
const load = args.load === true || !push;
|
|
5089
|
+
const plugins = args.plugin !== undefined && args.plugin.length > 0 ? args.plugin : undefined;
|
|
5090
|
+
const input = await resolveSteamBuildInput(game, ctx.config, { image: args.image, plugins, push }, ctx.cwd, ctx.configFile);
|
|
5091
|
+
const results = await steamBuild(input, {
|
|
5092
|
+
config: ctx.config,
|
|
5093
|
+
push,
|
|
5094
|
+
load,
|
|
5095
|
+
platform: args.platform ?? "linux/amd64",
|
|
5096
|
+
baseOverride: args.base,
|
|
5097
|
+
force: args.force === true,
|
|
5098
|
+
onlyBranches: args.branches,
|
|
5099
|
+
onlyVariants: args.variant
|
|
5100
|
+
});
|
|
5101
|
+
if (args.json)
|
|
5102
|
+
process.stdout.write(`${JSON.stringify(results, null, 2)}
|
|
5103
|
+
`);
|
|
5104
|
+
else
|
|
5105
|
+
for (const row of results)
|
|
5106
|
+
status(`${row.branch}/${row.variant} ${row.status} ${row.reason}`);
|
|
5107
|
+
return results.some((row) => row.status === "failed") ? Exit.Environment : Exit.Ok;
|
|
5108
|
+
}
|
|
5109
|
+
async function steamLogin(args, ctx) {
|
|
5110
|
+
const home = steamHome(ctx.config.dataRoot);
|
|
5111
|
+
mkdirSync5(home, { recursive: true });
|
|
5112
|
+
const runner = resolveSteamcmd(ctx.config);
|
|
5113
|
+
const username = args.username ?? await prompt("steam account name: ");
|
|
5114
|
+
if (username === "")
|
|
5115
|
+
throw new GamecrateError("steam login needs an account name", Exit.Usage);
|
|
5116
|
+
const argv = runner.kind === "docker" ? [...runner.argv.slice(0, 2), "-it", ...runner.argv.slice(2)] : [...runner.argv];
|
|
5117
|
+
const result = spawnSync3(argv[0], [...argv.slice(1), "+login", username, "+quit"], {
|
|
5118
|
+
stdio: "inherit",
|
|
5119
|
+
env: { ...process.env, ...runner.env }
|
|
5120
|
+
});
|
|
5121
|
+
if (result.error) {
|
|
5122
|
+
throw new GamecrateError(`could not run steamcmd: ${argv[0]}`, Exit.Environment, result.error.message);
|
|
5123
|
+
}
|
|
5124
|
+
const vdf = findSession(home);
|
|
5125
|
+
if (result.status !== 0 || vdf === undefined) {
|
|
5126
|
+
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}`);
|
|
5127
|
+
}
|
|
5128
|
+
writeFileSync3(accountFile(ctx.config.dataRoot), `${username}
|
|
5129
|
+
`);
|
|
5130
|
+
status(`session written to ${vdf}`);
|
|
5131
|
+
if (args.print === true)
|
|
5132
|
+
process.stdout.write(`${readFileSync5(vdf).toString("base64")}
|
|
5133
|
+
`);
|
|
5134
|
+
return Exit.Ok;
|
|
5135
|
+
}
|
|
5136
|
+
async function prompt(question) {
|
|
5137
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
5138
|
+
try {
|
|
5139
|
+
return (await rl.question(question)).trim();
|
|
5140
|
+
} finally {
|
|
5141
|
+
rl.close();
|
|
5142
|
+
}
|
|
5143
|
+
}
|
|
3927
5144
|
|
|
3928
5145
|
// src/cli/help.ts
|
|
3929
5146
|
var NAME = "gamecrate";
|
|
3930
5147
|
function flags() {
|
|
3931
|
-
|
|
5148
|
+
const seen = new Set;
|
|
5149
|
+
return allOptions(buildProgram()).filter((o) => {
|
|
5150
|
+
const long = o.long ?? o.flags;
|
|
5151
|
+
if (o.hidden || seen.has(long))
|
|
5152
|
+
return false;
|
|
5153
|
+
seen.add(long);
|
|
5154
|
+
return true;
|
|
5155
|
+
});
|
|
3932
5156
|
}
|
|
3933
5157
|
function renderHelp(topic, config) {
|
|
3934
5158
|
if (!topic)
|
|
@@ -4111,16 +5335,114 @@ function hostUserName(uid) {
|
|
|
4111
5335
|
}
|
|
4112
5336
|
|
|
4113
5337
|
// src/docker/preflight.ts
|
|
4114
|
-
import { existsSync as
|
|
4115
|
-
import { homedir as
|
|
4116
|
-
import { basename as basename6, join as
|
|
5338
|
+
import { existsSync as existsSync9, readFileSync as readFileSync6 } from "node:fs";
|
|
5339
|
+
import { homedir as homedir4 } from "node:os";
|
|
5340
|
+
import { basename as basename6, join as join17 } from "node:path";
|
|
5341
|
+
|
|
5342
|
+
// src/launch/image.ts
|
|
5343
|
+
var NO_IMAGE = {
|
|
5344
|
+
present: false,
|
|
5345
|
+
runtime: null,
|
|
5346
|
+
launcher: null,
|
|
5347
|
+
executable: null,
|
|
5348
|
+
branch: null,
|
|
5349
|
+
variant: null,
|
|
5350
|
+
buildid: null
|
|
5351
|
+
};
|
|
5352
|
+
async function readImageFacts(ref) {
|
|
5353
|
+
if (ref.trim() === "")
|
|
5354
|
+
return NO_IMAGE;
|
|
5355
|
+
if (await imageDigest(ref) === null)
|
|
5356
|
+
return NO_IMAGE;
|
|
5357
|
+
const [runtime, launcher, executable, branch, variant, buildid] = await Promise.all([
|
|
5358
|
+
imageLabel(ref, "gamecrate.runtime"),
|
|
5359
|
+
imageLabel(ref, "gamecrate.launcher"),
|
|
5360
|
+
imageLabel(ref, "gamecrate.executable"),
|
|
5361
|
+
imageLabel(ref, "gamecrate.branch"),
|
|
5362
|
+
imageLabel(ref, "gamecrate.variant"),
|
|
5363
|
+
imageLabel(ref, "steam.buildid")
|
|
5364
|
+
]);
|
|
5365
|
+
return { present: true, runtime, launcher, executable, branch, variant, buildid };
|
|
5366
|
+
}
|
|
5367
|
+
function imageLaunch(facts) {
|
|
5368
|
+
const launcher = facts.launcher === "proton" || facts.launcher === "direct" ? facts.launcher : undefined;
|
|
5369
|
+
return {
|
|
5370
|
+
...launcher === undefined ? {} : { launcher },
|
|
5371
|
+
...facts.executable === null ? {} : { executable: facts.executable }
|
|
5372
|
+
};
|
|
5373
|
+
}
|
|
5374
|
+
function imageProblem(input) {
|
|
5375
|
+
const { game, ref, mode, facts } = input;
|
|
5376
|
+
const where = `/games/${game}/image/ref`;
|
|
5377
|
+
const build = `gamecrate steam build ${game}`;
|
|
5378
|
+
if (ref.trim() === "") {
|
|
5379
|
+
return {
|
|
5380
|
+
where,
|
|
5381
|
+
message: `${game} has no image.ref configured`,
|
|
5382
|
+
suggestion: `${build}, then set games.${game}.image.ref to the tag it prints`
|
|
5383
|
+
};
|
|
5384
|
+
}
|
|
5385
|
+
if (!facts.present) {
|
|
5386
|
+
return {
|
|
5387
|
+
where,
|
|
5388
|
+
message: `image ${ref} is not present locally and could not be pulled`,
|
|
5389
|
+
suggestion: build
|
|
5390
|
+
};
|
|
5391
|
+
}
|
|
5392
|
+
if (mode === "headed")
|
|
5393
|
+
return null;
|
|
5394
|
+
if (facts.runtime === null) {
|
|
5395
|
+
return {
|
|
5396
|
+
where,
|
|
5397
|
+
message: `image ${ref} has no gamecrate.runtime label, so gamecrate did not build it and --mode ${mode} has no X server to use`,
|
|
5398
|
+
suggestion: `${build} rebuilds it on a runtime base`
|
|
5399
|
+
};
|
|
5400
|
+
}
|
|
5401
|
+
return null;
|
|
5402
|
+
}
|
|
5403
|
+
function markerProblem(input) {
|
|
5404
|
+
if (input.marker !== undefined)
|
|
5405
|
+
return null;
|
|
5406
|
+
if (input.facts.launcher !== "proton")
|
|
5407
|
+
return null;
|
|
5408
|
+
return {
|
|
5409
|
+
where: `/games/${input.game}/image/ref`,
|
|
5410
|
+
message: `${input.game} runs under proton, which cannot report the game's exit code`,
|
|
5411
|
+
suggestion: "pass --marker <text>: a log line is the only success signal this image has"
|
|
5412
|
+
};
|
|
5413
|
+
}
|
|
5414
|
+
function imageFor(game, profile, flag) {
|
|
5415
|
+
if (flag !== undefined)
|
|
5416
|
+
return flag;
|
|
5417
|
+
const spec = resolveProfile(game, profile);
|
|
5418
|
+
if (spec.image !== undefined)
|
|
5419
|
+
return spec.image;
|
|
5420
|
+
if (spec.gameVersion === undefined)
|
|
5421
|
+
return;
|
|
5422
|
+
return `${repoOf2(game.image.ref)}:${spec.gameVersion}`;
|
|
5423
|
+
}
|
|
5424
|
+
function repoOf2(ref) {
|
|
5425
|
+
const colon = ref.lastIndexOf(":");
|
|
5426
|
+
return colon === -1 || ref.includes("/", colon) ? ref : ref.slice(0, colon);
|
|
5427
|
+
}
|
|
5428
|
+
function withImageOverride(game, ref) {
|
|
5429
|
+
if (ref === undefined)
|
|
5430
|
+
return game;
|
|
5431
|
+
return {
|
|
5432
|
+
...game,
|
|
5433
|
+
image: { ...game.image, ref, acquire: "pull" },
|
|
5434
|
+
gameFiles: { ...game.gameFiles, source: "image" }
|
|
5435
|
+
};
|
|
5436
|
+
}
|
|
5437
|
+
|
|
5438
|
+
// src/docker/preflight.ts
|
|
4117
5439
|
var CDI_SPEC = "/etc/cdi/nvidia.yaml";
|
|
4118
|
-
async function preflight(plan) {
|
|
5440
|
+
async function preflight(plan, asShell = false) {
|
|
4119
5441
|
const problems = [];
|
|
4120
5442
|
const game = plan.gameConfig;
|
|
4121
5443
|
const dockerOk = await checkDocker(problems);
|
|
4122
5444
|
if (dockerOk) {
|
|
4123
|
-
await checkImage(plan, problems);
|
|
5445
|
+
await checkImage(plan, problems, asShell);
|
|
4124
5446
|
}
|
|
4125
5447
|
if (plan.settings.gpu)
|
|
4126
5448
|
checkCdi(problems);
|
|
@@ -4159,12 +5481,30 @@ async function checkImageRunnable(ref, where, game, problems) {
|
|
|
4159
5481
|
suggestion: corrupt ? `docker image rm ${ref} && docker builder prune -f, then gamecrate build ${game}` : undefined
|
|
4160
5482
|
});
|
|
4161
5483
|
}
|
|
4162
|
-
async function checkImage(plan, problems) {
|
|
5484
|
+
async function checkImage(plan, problems, asShell) {
|
|
4163
5485
|
const image = plan.gameConfig.image;
|
|
4164
5486
|
const where = `/games/${plan.game}/image/ref`;
|
|
4165
|
-
const
|
|
4166
|
-
|
|
5487
|
+
const facts = await readImageFacts(image.ref);
|
|
5488
|
+
const problem = imageProblem({ game: plan.game, ref: image.ref, mode: plan.mode, facts });
|
|
5489
|
+
if (facts.present) {
|
|
4167
5490
|
await checkImageRunnable(image.ref, where, plan.game, problems);
|
|
5491
|
+
if (problem)
|
|
5492
|
+
problems.push(problem);
|
|
5493
|
+
if (asShell)
|
|
5494
|
+
return;
|
|
5495
|
+
const mode = protonHeadedProblem(plan, facts, where);
|
|
5496
|
+
if (mode) {
|
|
5497
|
+
problems.push(mode);
|
|
5498
|
+
return;
|
|
5499
|
+
}
|
|
5500
|
+
const marker = markerProblem({ game: plan.game, facts, marker: plan.marker });
|
|
5501
|
+
if (marker)
|
|
5502
|
+
problems.push(marker);
|
|
5503
|
+
return;
|
|
5504
|
+
}
|
|
5505
|
+
if (image.ref.trim() === "") {
|
|
5506
|
+
if (problem)
|
|
5507
|
+
problems.push(problem);
|
|
4168
5508
|
return;
|
|
4169
5509
|
}
|
|
4170
5510
|
if (image.acquire === "build") {
|
|
@@ -4183,15 +5523,26 @@ async function checkImage(plan, problems) {
|
|
|
4183
5523
|
problems.push({
|
|
4184
5524
|
where,
|
|
4185
5525
|
message: `not authenticated to ${host}, so ${image.ref} cannot be pulled`,
|
|
4186
|
-
suggestion: `docker login ${host}`
|
|
5526
|
+
suggestion: `docker login ${host}, or gamecrate steam build ${plan.game} to make it locally`
|
|
4187
5527
|
});
|
|
4188
5528
|
return;
|
|
4189
5529
|
}
|
|
4190
|
-
|
|
4191
|
-
|
|
4192
|
-
|
|
4193
|
-
|
|
4194
|
-
|
|
5530
|
+
if (problem) {
|
|
5531
|
+
problems.push({
|
|
5532
|
+
...problem,
|
|
5533
|
+
message: `${problem.message}: ${firstLine(remote.stderr) || exitCode(remote.code)}`
|
|
5534
|
+
});
|
|
5535
|
+
}
|
|
5536
|
+
}
|
|
5537
|
+
function protonHeadedProblem(plan, facts, where) {
|
|
5538
|
+
try {
|
|
5539
|
+
refuseProtonHeaded(plan.game, plan.mode, imageLaunch(facts));
|
|
5540
|
+
return null;
|
|
5541
|
+
} catch (error) {
|
|
5542
|
+
if (!(error instanceof GamecrateError))
|
|
5543
|
+
throw error;
|
|
5544
|
+
return { where, message: error.message, suggestion: error.detail };
|
|
5545
|
+
}
|
|
4195
5546
|
}
|
|
4196
5547
|
function checkDisplay(plan, problems) {
|
|
4197
5548
|
if (plan.settings.display === "x11") {
|
|
@@ -4213,7 +5564,7 @@ function checkDisplay(plan, problems) {
|
|
|
4213
5564
|
});
|
|
4214
5565
|
}
|
|
4215
5566
|
function checkCdi(problems) {
|
|
4216
|
-
if (!
|
|
5567
|
+
if (!existsSync9(CDI_SPEC)) {
|
|
4217
5568
|
problems.push({
|
|
4218
5569
|
where: CDI_SPEC,
|
|
4219
5570
|
message: "no CDI spec, so --device nvidia.com/gpu=all cannot resolve",
|
|
@@ -4223,9 +5574,9 @@ function checkCdi(problems) {
|
|
|
4223
5574
|
}
|
|
4224
5575
|
let text = "";
|
|
4225
5576
|
try {
|
|
4226
|
-
text =
|
|
5577
|
+
text = readFileSync6(CDI_SPEC, "utf8");
|
|
4227
5578
|
} catch (error) {
|
|
4228
|
-
problems.push({ where: CDI_SPEC, message: `CDI spec is unreadable: ${
|
|
5579
|
+
problems.push({ where: CDI_SPEC, message: `CDI spec is unreadable: ${message2(error)}` });
|
|
4229
5580
|
return;
|
|
4230
5581
|
}
|
|
4231
5582
|
if (!/^[ \t]*(?:-[ \t]*)?name:[ \t]*["']?all["']?[ \t]*$/m.test(text)) {
|
|
@@ -4245,12 +5596,12 @@ function checkGameDir(plan, problems) {
|
|
|
4245
5596
|
problems.push({ where, message: 'gameFiles.source is "mount" but no host path is set' });
|
|
4246
5597
|
return;
|
|
4247
5598
|
}
|
|
4248
|
-
if (!
|
|
5599
|
+
if (!existsSync9(files.host)) {
|
|
4249
5600
|
problems.push({ where, message: `game directory does not exist: ${files.host}` });
|
|
4250
5601
|
return;
|
|
4251
5602
|
}
|
|
4252
|
-
const executable =
|
|
4253
|
-
if (!
|
|
5603
|
+
const executable = join17(files.host, basename6(plan.gameConfig.executable));
|
|
5604
|
+
if (!existsSync9(executable)) {
|
|
4254
5605
|
problems.push({
|
|
4255
5606
|
where,
|
|
4256
5607
|
message: `${files.host} does not contain ${basename6(plan.gameConfig.executable)}`,
|
|
@@ -4265,7 +5616,7 @@ function checkBindSources(plan, problems) {
|
|
|
4265
5616
|
} catch (error) {
|
|
4266
5617
|
problems.push({
|
|
4267
5618
|
where: `/games/${plan.game}`,
|
|
4268
|
-
message: error instanceof GamecrateError ? error.message :
|
|
5619
|
+
message: error instanceof GamecrateError ? error.message : message2(error),
|
|
4269
5620
|
suggestion: error instanceof GamecrateError ? error.detail : undefined
|
|
4270
5621
|
});
|
|
4271
5622
|
return;
|
|
@@ -4273,7 +5624,7 @@ function checkBindSources(plan, problems) {
|
|
|
4273
5624
|
for (const mount of mounts) {
|
|
4274
5625
|
if (mount.type !== "bind" || !mount.source)
|
|
4275
5626
|
continue;
|
|
4276
|
-
if (
|
|
5627
|
+
if (existsSync9(mount.source))
|
|
4277
5628
|
continue;
|
|
4278
5629
|
if (mount.source.startsWith(plan.profileDir))
|
|
4279
5630
|
continue;
|
|
@@ -4295,9 +5646,9 @@ function needsLogin(stderr) {
|
|
|
4295
5646
|
return /unauthorized|authentication required|denied|forbidden/i.test(stderr);
|
|
4296
5647
|
}
|
|
4297
5648
|
function hasStoredAuth(host) {
|
|
4298
|
-
const path =
|
|
5649
|
+
const path = join17(process.env.DOCKER_CONFIG ?? join17(homedir4(), ".docker"), "config.json");
|
|
4299
5650
|
try {
|
|
4300
|
-
const config = JSON.parse(
|
|
5651
|
+
const config = JSON.parse(readFileSync6(path, "utf8"));
|
|
4301
5652
|
if (config.credsStore || config.credHelpers?.[host])
|
|
4302
5653
|
return true;
|
|
4303
5654
|
return Object.keys(config.auths ?? {}).some((key) => key === host || key.includes(`//${host}`));
|
|
@@ -4312,14 +5663,14 @@ function firstLine(text) {
|
|
|
4312
5663
|
return text.trim().split(`
|
|
4313
5664
|
`)[0]?.trim() ?? "";
|
|
4314
5665
|
}
|
|
4315
|
-
function
|
|
5666
|
+
function message2(error) {
|
|
4316
5667
|
return error instanceof Error ? error.message : String(error);
|
|
4317
5668
|
}
|
|
4318
5669
|
|
|
4319
5670
|
// src/docker/window.ts
|
|
4320
|
-
import { readFileSync as
|
|
5671
|
+
import { readFileSync as readFileSync7 } from "node:fs";
|
|
4321
5672
|
import { basename as basename7 } from "node:path";
|
|
4322
|
-
import { setTimeout as
|
|
5673
|
+
import { setTimeout as sleep6 } from "node:timers/promises";
|
|
4323
5674
|
var WAIT_MS = 180000;
|
|
4324
5675
|
var POLL_MS = 500;
|
|
4325
5676
|
function newMatches(now, seen, executable) {
|
|
@@ -4335,7 +5686,7 @@ function isPeerClaim(pid, self) {
|
|
|
4335
5686
|
if (pid === self)
|
|
4336
5687
|
return false;
|
|
4337
5688
|
try {
|
|
4338
|
-
const argv =
|
|
5689
|
+
const argv = readFileSync7(`/proc/${pid}/cmdline`, "utf8").split("\x00");
|
|
4339
5690
|
return argv.some((arg) => basename7(arg).startsWith("gamecrate"));
|
|
4340
5691
|
} catch {
|
|
4341
5692
|
return false;
|
|
@@ -4377,7 +5728,7 @@ async function adoptNewWindow(opts) {
|
|
|
4377
5728
|
async function pollForWindow(seen, opts, stopped) {
|
|
4378
5729
|
const deadline = Date.now() + WAIT_MS;
|
|
4379
5730
|
while (!stopped() && Date.now() < deadline) {
|
|
4380
|
-
await
|
|
5731
|
+
await sleep6(POLL_MS);
|
|
4381
5732
|
if (stopped())
|
|
4382
5733
|
return;
|
|
4383
5734
|
if (await adoptFirstMatch(seen, opts, stopped))
|
|
@@ -4402,7 +5753,7 @@ async function adoptFirstMatch(seen, opts, stopped) {
|
|
|
4402
5753
|
async function watchForClose(id, stopped, onClosed) {
|
|
4403
5754
|
let misses = 0;
|
|
4404
5755
|
while (!stopped()) {
|
|
4405
|
-
await
|
|
5756
|
+
await sleep6(POLL_MS);
|
|
4406
5757
|
if (stopped())
|
|
4407
5758
|
return;
|
|
4408
5759
|
const now = await toplevels();
|
|
@@ -4460,19 +5811,27 @@ function parseAtoms(stdout) {
|
|
|
4460
5811
|
}
|
|
4461
5812
|
|
|
4462
5813
|
// src/launch/generate.ts
|
|
4463
|
-
import { mkdir as mkdir3, readdir as readdir7, readFile as
|
|
4464
|
-
import { dirname as
|
|
5814
|
+
import { mkdir as mkdir3, readdir as readdir7, readFile as readFile9, writeFile as writeFile4 } from "node:fs/promises";
|
|
5815
|
+
import { dirname as dirname10, join as join18 } from "node:path";
|
|
4465
5816
|
async function readInstallVersion(game, plugin) {
|
|
5817
|
+
const raw = await installVersionText(game);
|
|
5818
|
+
if (raw === null)
|
|
5819
|
+
return null;
|
|
5820
|
+
return plugin.parseVersion(raw.replace(/^/, "").trim());
|
|
5821
|
+
}
|
|
5822
|
+
async function installVersionText(game) {
|
|
5823
|
+
if (game.gameFiles.source !== "mount") {
|
|
5824
|
+
const ref = game.image?.ref;
|
|
5825
|
+
return ref === undefined || ref === "" ? null : await readFromImage(ref, `${game.gameFiles.container}/${game.version.file}`);
|
|
5826
|
+
}
|
|
4466
5827
|
const host = game.gameFiles.host;
|
|
4467
|
-
if (
|
|
5828
|
+
if (host === undefined)
|
|
4468
5829
|
return null;
|
|
4469
|
-
let raw;
|
|
4470
5830
|
try {
|
|
4471
|
-
|
|
5831
|
+
return await readFile9(join18(expandHome(host), game.version.file), "utf8");
|
|
4472
5832
|
} catch {
|
|
4473
5833
|
return null;
|
|
4474
5834
|
}
|
|
4475
|
-
return plugin.parseVersion(raw.replace(/^/, "").trim());
|
|
4476
5835
|
}
|
|
4477
5836
|
async function readKnownExpansions(game, plugin, warnings) {
|
|
4478
5837
|
if (game.dlc.length === 0)
|
|
@@ -4480,7 +5839,7 @@ async function readKnownExpansions(game, plugin, warnings) {
|
|
|
4480
5839
|
const host = game.gameFiles.host;
|
|
4481
5840
|
if (game.gameFiles.source !== "mount" || host === undefined)
|
|
4482
5841
|
return [...game.dlc];
|
|
4483
|
-
const dataDir =
|
|
5842
|
+
const dataDir = join18(expandHome(host), "Data");
|
|
4484
5843
|
let entries;
|
|
4485
5844
|
try {
|
|
4486
5845
|
entries = await readdir7(dataDir, { withFileTypes: true });
|
|
@@ -4494,7 +5853,7 @@ async function readKnownExpansions(game, plugin, warnings) {
|
|
|
4494
5853
|
continue;
|
|
4495
5854
|
let id = null;
|
|
4496
5855
|
try {
|
|
4497
|
-
id = plugin.parseManifest(await
|
|
5856
|
+
id = plugin.parseManifest(await readFile9(join18(dataDir, entry.name, game.manifest.file), "utf8"))?.packageId ?? null;
|
|
4498
5857
|
} catch {
|
|
4499
5858
|
continue;
|
|
4500
5859
|
}
|
|
@@ -4510,11 +5869,11 @@ async function readKnownExpansions(game, plugin, warnings) {
|
|
|
4510
5869
|
}
|
|
4511
5870
|
async function generateModsConfig(plan) {
|
|
4512
5871
|
const game = plan.gameConfig;
|
|
4513
|
-
const target =
|
|
4514
|
-
await mkdir3(
|
|
5872
|
+
const target = join18(plan.dataDirHost, game.modsConfig.file);
|
|
5873
|
+
await mkdir3(dirname10(target), { recursive: true });
|
|
4515
5874
|
const installed = await readInstallVersion(game, plan.plugin);
|
|
4516
5875
|
if (installed === null) {
|
|
4517
|
-
plan.warnings.push(`could not read
|
|
5876
|
+
plan.warnings.push(`could not read ${game.version.file} for ${plan.game}; ModsConfig version may be rejected`);
|
|
4518
5877
|
}
|
|
4519
5878
|
const declared = new Map([game.core, ...game.dlc].map((id) => [id.toLowerCase(), id]));
|
|
4520
5879
|
const seen = new Set;
|
|
@@ -4561,11 +5920,11 @@ function ownedPrefs(plan) {
|
|
|
4561
5920
|
return owned;
|
|
4562
5921
|
}
|
|
4563
5922
|
async function mergePrefs(plan) {
|
|
4564
|
-
const target =
|
|
4565
|
-
await mkdir3(
|
|
5923
|
+
const target = join18(plan.dataDirHost, plan.gameConfig.prefs.file);
|
|
5924
|
+
await mkdir3(dirname10(target), { recursive: true });
|
|
4566
5925
|
let existing = null;
|
|
4567
5926
|
try {
|
|
4568
|
-
existing = await
|
|
5927
|
+
existing = await readFile9(target, "utf8");
|
|
4569
5928
|
} catch (error) {
|
|
4570
5929
|
if (error.code !== "ENOENT")
|
|
4571
5930
|
throw error;
|
|
@@ -4574,11 +5933,89 @@ async function mergePrefs(plan) {
|
|
|
4574
5933
|
return target;
|
|
4575
5934
|
}
|
|
4576
5935
|
|
|
5936
|
+
// src/index.ts
|
|
5937
|
+
import { createInterface as createInterface2 } from "node:readline/promises";
|
|
5938
|
+
|
|
5939
|
+
// src/launch/updates.ts
|
|
5940
|
+
import { existsSync as existsSync10, mkdirSync as mkdirSync6, readFileSync as readFileSync8, writeFileSync as writeFileSync4 } from "node:fs";
|
|
5941
|
+
import { homedir as homedir5 } from "node:os";
|
|
5942
|
+
import { dirname as dirname11, join as join19 } from "node:path";
|
|
5943
|
+
var DEFAULT_HOURS = 6;
|
|
5944
|
+
function shouldCheck(input) {
|
|
5945
|
+
if (input.spec?.check === false)
|
|
5946
|
+
return { check: false, reason: "disabled" };
|
|
5947
|
+
const { branch, buildid } = input.facts;
|
|
5948
|
+
if (!input.facts.present || branch === null || buildid === null) {
|
|
5949
|
+
return { check: false, reason: "not-ours" };
|
|
5950
|
+
}
|
|
5951
|
+
const hours = input.spec?.everyHours ?? DEFAULT_HOURS;
|
|
5952
|
+
if (hours <= 0 || input.lastCheckedAt === null)
|
|
5953
|
+
return { check: true };
|
|
5954
|
+
const due = input.lastCheckedAt + hours * 3600000;
|
|
5955
|
+
return input.now >= due ? { check: true } : { check: false, reason: "throttled" };
|
|
5956
|
+
}
|
|
5957
|
+
function stampFile(imageId) {
|
|
5958
|
+
const root = process.env["XDG_CACHE_HOME"] ?? join19(homedir5(), ".cache");
|
|
5959
|
+
return join19(root, "gamecrate", "updates", `${imageId.replace(/[^A-Za-z0-9]/g, "-")}.json`);
|
|
5960
|
+
}
|
|
5961
|
+
function lastCheckedAt(imageId) {
|
|
5962
|
+
const file = stampFile(imageId);
|
|
5963
|
+
if (!existsSync10(file))
|
|
5964
|
+
return null;
|
|
5965
|
+
try {
|
|
5966
|
+
const at = JSON.parse(readFileSync8(file, "utf8")).at;
|
|
5967
|
+
return typeof at === "number" ? at : null;
|
|
5968
|
+
} catch {
|
|
5969
|
+
return null;
|
|
5970
|
+
}
|
|
5971
|
+
}
|
|
5972
|
+
function recordCheck(imageId, now) {
|
|
5973
|
+
const file = stampFile(imageId);
|
|
5974
|
+
try {
|
|
5975
|
+
mkdirSync6(dirname11(file), { recursive: true });
|
|
5976
|
+
writeFileSync4(file, JSON.stringify({ at: now }));
|
|
5977
|
+
} catch {}
|
|
5978
|
+
}
|
|
5979
|
+
async function offerRebuild(input) {
|
|
5980
|
+
const { game, config, args, facts } = input;
|
|
5981
|
+
const spec = config.games[game]?.image.updates;
|
|
5982
|
+
const ref = config.games[game].image.ref;
|
|
5983
|
+
const id = await imageDigest(ref);
|
|
5984
|
+
if (id === null)
|
|
5985
|
+
return;
|
|
5986
|
+
const now = Date.now();
|
|
5987
|
+
const verdict = shouldCheck({ facts, spec, lastCheckedAt: lastCheckedAt(id), now });
|
|
5988
|
+
if (!verdict.check)
|
|
5989
|
+
return;
|
|
5990
|
+
const plugins = args.plugin !== undefined && args.plugin.length > 0 ? args.plugin : undefined;
|
|
5991
|
+
const built = await resolveSteamBuildInput(game, config, { plugins }, input.cwd, input.configFile);
|
|
5992
|
+
const published = await publishedBuildId(config, built.steamAppId, facts.branch);
|
|
5993
|
+
recordCheck(id, now);
|
|
5994
|
+
const decision = decideGate({ published, imagePresent: true, labelled: facts.buildid, force: false });
|
|
5995
|
+
if (!decision.build || decision.reason !== "buildid-changed")
|
|
5996
|
+
return;
|
|
5997
|
+
const cell = `${facts.branch}/${facts.variant ?? "default"}`;
|
|
5998
|
+
warn(`${game} ${cell} is at build ${facts.buildid}, steam publishes ${published}`);
|
|
5999
|
+
if (!await input.ask(`rebuild ${ref} now? this downloads the game again [y/N] `)) {
|
|
6000
|
+
status(`keeping the current image. gamecrate steam build ${game} rebuilds it later`);
|
|
6001
|
+
return;
|
|
6002
|
+
}
|
|
6003
|
+
await steamBuild(built, {
|
|
6004
|
+
config,
|
|
6005
|
+
push: false,
|
|
6006
|
+
load: true,
|
|
6007
|
+
platform: args.platform ?? "linux/amd64",
|
|
6008
|
+
force: true,
|
|
6009
|
+
onlyBranches: [facts.branch],
|
|
6010
|
+
onlyVariants: facts.variant === null ? [] : [facts.variant]
|
|
6011
|
+
});
|
|
6012
|
+
}
|
|
6013
|
+
|
|
4577
6014
|
// src/launch/supervisor.ts
|
|
4578
|
-
import { existsSync as
|
|
4579
|
-
import { readFile as
|
|
4580
|
-
import { basename as basename8, join as
|
|
4581
|
-
import { setTimeout as
|
|
6015
|
+
import { existsSync as existsSync11 } from "node:fs";
|
|
6016
|
+
import { readFile as readFile10, readlink, rm as rm3, writeFile as writeFile5 } from "node:fs/promises";
|
|
6017
|
+
import { basename as basename8, join as join20 } from "node:path";
|
|
6018
|
+
import { setTimeout as sleep7 } from "node:timers/promises";
|
|
4582
6019
|
async function forkSupervisor(plan, argv) {
|
|
4583
6020
|
await clearLock(plan);
|
|
4584
6021
|
const name = containerName(plan);
|
|
@@ -4635,15 +6072,15 @@ async function supervisorFailed(dir, code) {
|
|
|
4635
6072
|
const record = { at: new Date().toISOString(), code, reason: "failed" };
|
|
4636
6073
|
const wrote = await writeExit(dir, record).then(() => true, () => false);
|
|
4637
6074
|
if (wrote)
|
|
4638
|
-
await rm3(
|
|
6075
|
+
await rm3(join20(dir, ".gamecrate", "lock"), { force: true }).catch(() => {});
|
|
4639
6076
|
return code;
|
|
4640
6077
|
}
|
|
4641
6078
|
async function writeExit(instanceDir, record) {
|
|
4642
|
-
await writeFile5(
|
|
6079
|
+
await writeFile5(join20(instanceDir, ".gamecrate", "last-exit.json"), `${JSON.stringify(record)}
|
|
4643
6080
|
`);
|
|
4644
6081
|
}
|
|
4645
6082
|
async function lastExit(instanceDir) {
|
|
4646
|
-
const text = await
|
|
6083
|
+
const text = await readFile10(join20(instanceDir, ".gamecrate", "last-exit.json"), "utf8").catch(() => {
|
|
4647
6084
|
return;
|
|
4648
6085
|
});
|
|
4649
6086
|
if (text === undefined)
|
|
@@ -4666,7 +6103,7 @@ function endedAfter(record, notBefore) {
|
|
|
4666
6103
|
return !Number.isNaN(at) && at >= began;
|
|
4667
6104
|
}
|
|
4668
6105
|
async function awaitExit(instanceDir, poll = WAIT_POLL_MS) {
|
|
4669
|
-
const file =
|
|
6106
|
+
const file = join20(instanceDir, ".gamecrate", "lock");
|
|
4670
6107
|
let watching;
|
|
4671
6108
|
for (;; ) {
|
|
4672
6109
|
const lock = await readLock(file) ?? watching;
|
|
@@ -4678,12 +6115,12 @@ async function awaitExit(instanceDir, poll = WAIT_POLL_MS) {
|
|
|
4678
6115
|
if (!isRunning(lock.pid, lock.startedAt))
|
|
4679
6116
|
return "orphaned";
|
|
4680
6117
|
watching = lock;
|
|
4681
|
-
await
|
|
6118
|
+
await sleep7(poll);
|
|
4682
6119
|
}
|
|
4683
6120
|
}
|
|
4684
6121
|
var RUN_LOG_POLL_MS = 250;
|
|
4685
6122
|
async function awaitRunLog(instanceDir, lock, poll = RUN_LOG_POLL_MS, notice = WAIT_NOTICE) {
|
|
4686
|
-
const link =
|
|
6123
|
+
const link = join20(instanceDir, "logs", "current");
|
|
4687
6124
|
const written = Date.parse(lock.startedAt);
|
|
4688
6125
|
const since = Date.now();
|
|
4689
6126
|
let lastNotice = 0;
|
|
@@ -4693,7 +6130,7 @@ async function awaitRunLog(instanceDir, lock, poll = RUN_LOG_POLL_MS, notice = W
|
|
|
4693
6130
|
});
|
|
4694
6131
|
const began = target === undefined ? undefined : runStartedAt(basename8(target));
|
|
4695
6132
|
const current = began !== undefined && (Number.isNaN(written) || began >= written);
|
|
4696
|
-
if (current &&
|
|
6133
|
+
if (current && existsSync11(currentLog(instanceDir)))
|
|
4697
6134
|
return true;
|
|
4698
6135
|
if (!isRunning(lock.pid, lock.startedAt))
|
|
4699
6136
|
return false;
|
|
@@ -4703,20 +6140,20 @@ async function awaitRunLog(instanceDir, lock, poll = RUN_LOG_POLL_MS, notice = W
|
|
|
4703
6140
|
status(`waiting for ${lock.game} ${lock.profile} to open its log (${label})`);
|
|
4704
6141
|
lastNotice = waited;
|
|
4705
6142
|
}
|
|
4706
|
-
await
|
|
6143
|
+
await sleep7(poll);
|
|
4707
6144
|
}
|
|
4708
6145
|
}
|
|
4709
6146
|
|
|
4710
6147
|
// src/launch/instance.ts
|
|
4711
6148
|
import { createHash as createHash2 } from "node:crypto";
|
|
4712
|
-
import { basename as basename9, join as
|
|
6149
|
+
import { basename as basename9, join as join21 } from "node:path";
|
|
4713
6150
|
|
|
4714
6151
|
// src/mods/worktree.ts
|
|
4715
|
-
import { spawnSync as
|
|
4716
|
-
import { existsSync as
|
|
6152
|
+
import { spawnSync as spawnSync4 } from "node:child_process";
|
|
6153
|
+
import { existsSync as existsSync12, realpathSync as realpathSync2 } from "node:fs";
|
|
4717
6154
|
import { isAbsolute as isAbsolute2, resolve as resolve5, sep } from "node:path";
|
|
4718
6155
|
function inspect(dir) {
|
|
4719
|
-
const r =
|
|
6156
|
+
const r = spawnSync4("git", ["-C", dir, "rev-parse", "--path-format=absolute", "--show-toplevel", "--git-dir", "--git-common-dir", "--abbrev-ref", "HEAD"], { encoding: "utf8" });
|
|
4720
6157
|
if (r.status !== 0 || typeof r.stdout !== "string")
|
|
4721
6158
|
return null;
|
|
4722
6159
|
const lines = r.stdout.trim().split(`
|
|
@@ -4736,7 +6173,7 @@ function canonical(p) {
|
|
|
4736
6173
|
function resolveWorktree(dir, source, order) {
|
|
4737
6174
|
const raw = expandHome(dir);
|
|
4738
6175
|
const abs = isAbsolute2(raw) ? raw : resolve5(process.cwd(), raw);
|
|
4739
|
-
if (!
|
|
6176
|
+
if (!existsSync12(abs)) {
|
|
4740
6177
|
return { where: abs, message: `--worktree path does not exist`, suggestion: "check the path, or drop the flag" };
|
|
4741
6178
|
}
|
|
4742
6179
|
const info = inspect(abs);
|
|
@@ -4791,7 +6228,7 @@ function resolveInstance(options) {
|
|
|
4791
6228
|
const name = args.instance === undefined ? derive(requests) : named(args.instance);
|
|
4792
6229
|
return {
|
|
4793
6230
|
...name === undefined ? {} : { name },
|
|
4794
|
-
dir: name === undefined ? profileDir :
|
|
6231
|
+
dir: name === undefined ? profileDir : join21(profileDir, "instances", name),
|
|
4795
6232
|
requests,
|
|
4796
6233
|
problems,
|
|
4797
6234
|
...configured?.settings === undefined ? {} : { settings: configured.settings }
|
|
@@ -4829,17 +6266,17 @@ function slug2(root) {
|
|
|
4829
6266
|
}
|
|
4830
6267
|
|
|
4831
6268
|
// src/launch/resolve.ts
|
|
4832
|
-
import { join as
|
|
6269
|
+
import { join as join23 } from "node:path";
|
|
4833
6270
|
|
|
4834
6271
|
// src/mods/modindex.ts
|
|
4835
6272
|
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
|
|
6273
|
+
import { existsSync as existsSync13, readFileSync as readFileSync9, statSync as statSync3 } from "node:fs";
|
|
6274
|
+
import { mkdir as mkdir4, readdir as readdir8, readFile as readFile11, writeFile as writeFile6 } from "node:fs/promises";
|
|
6275
|
+
import { homedir as homedir6 } from "node:os";
|
|
6276
|
+
import { dirname as dirname12, join as join22, relative as relative3, sep as sep2, resolve as resolvePath } from "node:path";
|
|
4840
6277
|
import picomatch from "picomatch";
|
|
4841
6278
|
function cacheDir() {
|
|
4842
|
-
return
|
|
6279
|
+
return join22(process.env["XDG_CACHE_HOME"] ?? join22(homedir6(), ".cache"), "gamecrate");
|
|
4843
6280
|
}
|
|
4844
6281
|
function globMatch(pattern, path) {
|
|
4845
6282
|
return picomatch.isMatch(path, pattern.replaceAll(/[[\]{}()!,@+|^$.\\]/g, String.raw`\$&`), { dot: true });
|
|
@@ -4851,8 +6288,8 @@ var ALWAYS_EXCLUDE = ["**/.worktrees/**", "**/.claude/worktrees/**"];
|
|
|
4851
6288
|
function inLinkedWorktree(dir, stopAt) {
|
|
4852
6289
|
let current = dir;
|
|
4853
6290
|
for (;; ) {
|
|
4854
|
-
const git =
|
|
4855
|
-
if (
|
|
6291
|
+
const git = join22(current, ".git");
|
|
6292
|
+
if (existsSync13(git)) {
|
|
4856
6293
|
try {
|
|
4857
6294
|
if (statSync3(git).isFile())
|
|
4858
6295
|
return true;
|
|
@@ -4863,7 +6300,7 @@ function inLinkedWorktree(dir, stopAt) {
|
|
|
4863
6300
|
}
|
|
4864
6301
|
if (current === stopAt)
|
|
4865
6302
|
return false;
|
|
4866
|
-
const parent =
|
|
6303
|
+
const parent = dirname12(current);
|
|
4867
6304
|
if (parent === current)
|
|
4868
6305
|
return false;
|
|
4869
6306
|
current = parent;
|
|
@@ -4873,8 +6310,8 @@ async function scanLocalRoot(root, rootIndex, manifestFile, found) {
|
|
|
4873
6310
|
const base = resolvePath(expandHome(root.path));
|
|
4874
6311
|
const exclude = [...root.exclude ?? [], ...rootIndex === -1 ? [] : ALWAYS_EXCLUDE];
|
|
4875
6312
|
const walk = async (dir, depth) => {
|
|
4876
|
-
const manifest =
|
|
4877
|
-
if (
|
|
6313
|
+
const manifest = join22(dir, manifestFile);
|
|
6314
|
+
if (existsSync13(manifest)) {
|
|
4878
6315
|
found.push({
|
|
4879
6316
|
dir,
|
|
4880
6317
|
kind: "local",
|
|
@@ -4894,13 +6331,13 @@ async function scanLocalRoot(root, rootIndex, manifestFile, found) {
|
|
|
4894
6331
|
for (const entry of entries) {
|
|
4895
6332
|
if (!entry.isDirectory() || entry.name.startsWith(".git"))
|
|
4896
6333
|
continue;
|
|
4897
|
-
const child =
|
|
6334
|
+
const child = join22(dir, entry.name);
|
|
4898
6335
|
if (excluded(exclude, relative3(base, child)))
|
|
4899
6336
|
continue;
|
|
4900
6337
|
await walk(child, depth + 1);
|
|
4901
6338
|
}
|
|
4902
6339
|
};
|
|
4903
|
-
if (!
|
|
6340
|
+
if (!existsSync13(base))
|
|
4904
6341
|
return;
|
|
4905
6342
|
await walk(base, 0);
|
|
4906
6343
|
}
|
|
@@ -4915,17 +6352,68 @@ async function scanWorkshopRoot(workshopRoot, rootIndex, manifestFile, found) {
|
|
|
4915
6352
|
for (const entry of entries) {
|
|
4916
6353
|
if (!entry.isDirectory() || !/^\d+$/.test(entry.name))
|
|
4917
6354
|
continue;
|
|
4918
|
-
const dir =
|
|
4919
|
-
if (!
|
|
6355
|
+
const dir = join22(base, entry.name);
|
|
6356
|
+
if (!existsSync13(join22(dir, manifestFile)))
|
|
4920
6357
|
continue;
|
|
4921
6358
|
found.push({ dir, kind: "workshop", rootIndex, linkedWorktree: false, workshopId: Number(entry.name) });
|
|
4922
6359
|
}
|
|
4923
6360
|
}
|
|
4924
|
-
async function
|
|
4925
|
-
|
|
4926
|
-
|
|
6361
|
+
async function gameDataDir(game) {
|
|
6362
|
+
if (game.gameFiles.source === "mount") {
|
|
6363
|
+
const host = game.gameFiles.host;
|
|
6364
|
+
return host === undefined ? null : join22(resolvePath(expandHome(host)), "Data");
|
|
6365
|
+
}
|
|
6366
|
+
const ref = game.image?.ref;
|
|
6367
|
+
if (ref === undefined || ref === "")
|
|
6368
|
+
return null;
|
|
6369
|
+
const id = await imageDigest(ref);
|
|
6370
|
+
if (id === null)
|
|
6371
|
+
return null;
|
|
6372
|
+
const out = join22(cacheDir(), "official", id.replace(/[^A-Za-z0-9]/g, "-"), "Data");
|
|
6373
|
+
if (!existsSync13(out))
|
|
6374
|
+
await copyOfficialManifests(game, ref, out);
|
|
6375
|
+
return existsSync13(out) ? out : null;
|
|
6376
|
+
}
|
|
6377
|
+
var MARK = "@@gamecrate@@ ";
|
|
6378
|
+
async function copyOfficialManifests(game, ref, out) {
|
|
6379
|
+
const script = [
|
|
6380
|
+
"for d in " + game.gameFiles.container + "/Data/*/; do",
|
|
6381
|
+
' f="${d}' + game.manifest.file + '"',
|
|
6382
|
+
' [ -f "$f" ] || continue',
|
|
6383
|
+
' echo "' + MARK + '$(basename "${d%/}")"',
|
|
6384
|
+
' cat "$f"',
|
|
6385
|
+
"done"
|
|
6386
|
+
].join(`
|
|
6387
|
+
`);
|
|
6388
|
+
const { code, stdout } = await capture(["docker", "run", "--rm", "--entrypoint", "sh", ref, "-c", script]);
|
|
6389
|
+
if (code !== 0)
|
|
4927
6390
|
return;
|
|
4928
|
-
const
|
|
6391
|
+
for (const block of stdout.split(MARK).slice(1)) {
|
|
6392
|
+
const cut = block.indexOf(`
|
|
6393
|
+
`);
|
|
6394
|
+
if (cut === -1)
|
|
6395
|
+
continue;
|
|
6396
|
+
const name = block.slice(0, cut).trim();
|
|
6397
|
+
if (name === "" || name.includes("/"))
|
|
6398
|
+
continue;
|
|
6399
|
+
const file = join22(out, name, game.manifest.file);
|
|
6400
|
+
await mkdir4(dirname12(file), { recursive: true });
|
|
6401
|
+
await writeFile6(file, block.slice(cut + 1));
|
|
6402
|
+
}
|
|
6403
|
+
}
|
|
6404
|
+
async function scanGameData(game, rootIndex, found, problems, gameName) {
|
|
6405
|
+
const data = await gameDataDir(game);
|
|
6406
|
+
if (data === null) {
|
|
6407
|
+
const ref = game.image?.ref;
|
|
6408
|
+
if (game.gameFiles.source !== "mount" && ref !== undefined && ref !== "") {
|
|
6409
|
+
problems.push({
|
|
6410
|
+
where: `/games/${gameName}/image/ref`,
|
|
6411
|
+
message: `${ref} is not present, so ${gameName} has no core or expansions to load`,
|
|
6412
|
+
suggestion: `gamecrate steam build ${gameName}, or docker pull ${ref}`
|
|
6413
|
+
});
|
|
6414
|
+
}
|
|
6415
|
+
return;
|
|
6416
|
+
}
|
|
4929
6417
|
let entries;
|
|
4930
6418
|
try {
|
|
4931
6419
|
entries = await readdir8(data, { withFileTypes: true });
|
|
@@ -4935,20 +6423,20 @@ async function scanGameData(game, rootIndex, found) {
|
|
|
4935
6423
|
for (const entry of entries) {
|
|
4936
6424
|
if (!entry.isDirectory())
|
|
4937
6425
|
continue;
|
|
4938
|
-
const dir =
|
|
4939
|
-
if (!
|
|
6426
|
+
const dir = join22(data, entry.name);
|
|
6427
|
+
if (!existsSync13(join22(dir, game.manifest.file)))
|
|
4940
6428
|
continue;
|
|
4941
6429
|
found.push({ dir, kind: "official", rootIndex, linkedWorktree: false });
|
|
4942
6430
|
}
|
|
4943
6431
|
}
|
|
4944
6432
|
var CACHE_VERSION = 4;
|
|
4945
6433
|
function acfPath2(root, steamAppId) {
|
|
4946
|
-
return
|
|
6434
|
+
return join22(dirname12(dirname12(resolvePath(expandHome(root)))), `appworkshop_${steamAppId}.acf`);
|
|
4947
6435
|
}
|
|
4948
6436
|
function contentPairs(acf) {
|
|
4949
6437
|
let text;
|
|
4950
6438
|
try {
|
|
4951
|
-
text =
|
|
6439
|
+
text = readFileSync9(acf, "utf8");
|
|
4952
6440
|
} catch (error) {
|
|
4953
6441
|
if (error.code === "ENOENT")
|
|
4954
6442
|
return [];
|
|
@@ -4980,7 +6468,7 @@ function workshopStamp(game, dataRoot) {
|
|
|
4980
6468
|
}
|
|
4981
6469
|
async function readWorkshopCache(game, stamp) {
|
|
4982
6470
|
try {
|
|
4983
|
-
const raw = JSON.parse(await
|
|
6471
|
+
const raw = JSON.parse(await readFile11(join22(cacheDir(), `${game}.workshop.json`), "utf8"));
|
|
4984
6472
|
if (raw.version !== CACHE_VERSION || raw.stamp !== stamp)
|
|
4985
6473
|
return null;
|
|
4986
6474
|
return raw.records;
|
|
@@ -4992,7 +6480,7 @@ async function writeWorkshopCache(game, stamp, records) {
|
|
|
4992
6480
|
const payload = { version: CACHE_VERSION, stamp, records };
|
|
4993
6481
|
try {
|
|
4994
6482
|
await mkdir4(cacheDir(), { recursive: true });
|
|
4995
|
-
await writeFile6(
|
|
6483
|
+
await writeFile6(join22(cacheDir(), `${game}.workshop.json`), JSON.stringify(payload));
|
|
4996
6484
|
} catch {}
|
|
4997
6485
|
}
|
|
4998
6486
|
function toRecord(candidate, manifest, game) {
|
|
@@ -5085,8 +6573,8 @@ async function applySourceOverrides(index, overrides, config) {
|
|
|
5085
6573
|
}
|
|
5086
6574
|
const wanted = spec.slice(0, eq);
|
|
5087
6575
|
const dir = resolvePath(expandHome(spec.slice(eq + 1)));
|
|
5088
|
-
const file =
|
|
5089
|
-
if (!
|
|
6576
|
+
const file = join22(dir, config.manifest.file);
|
|
6577
|
+
if (!existsSync13(file)) {
|
|
5090
6578
|
problems.push({
|
|
5091
6579
|
where: spec,
|
|
5092
6580
|
message: `no ${config.manifest.file} under ${dir}`,
|
|
@@ -5096,7 +6584,7 @@ async function applySourceOverrides(index, overrides, config) {
|
|
|
5096
6584
|
}
|
|
5097
6585
|
let manifest;
|
|
5098
6586
|
try {
|
|
5099
|
-
manifest = index.plugin.parseManifest(
|
|
6587
|
+
manifest = index.plugin.parseManifest(readFileSync9(file, "utf8"));
|
|
5100
6588
|
} catch (error) {
|
|
5101
6589
|
problems.push({ where: file, message: `could not parse: ${String(error)}` });
|
|
5102
6590
|
continue;
|
|
@@ -5165,7 +6653,7 @@ async function buildIndex(game, config, plugin, sourcesDir, dataRoot) {
|
|
|
5165
6653
|
}
|
|
5166
6654
|
async function indexLocal(index, config, sourcesDir, cacheIndex) {
|
|
5167
6655
|
const local = [];
|
|
5168
|
-
await scanGameData(config, -1, local);
|
|
6656
|
+
await scanGameData(config, -1, local, index.problems, index.game);
|
|
5169
6657
|
for (const [i, root] of config.scanRoots.entries()) {
|
|
5170
6658
|
await scanLocalRoot(root, i, config.manifest.file, local);
|
|
5171
6659
|
}
|
|
@@ -5209,7 +6697,7 @@ function oneModPerClone(records, sourcesDir) {
|
|
|
5209
6697
|
const stamps = new Map;
|
|
5210
6698
|
for (const record of [...records].sort((a, b) => Number(a.dir > b.dir) - Number(a.dir < b.dir))) {
|
|
5211
6699
|
const clone = relative3(sourcesDir, record.dir).split(sep2).slice(0, 2).join(sep2);
|
|
5212
|
-
const at =
|
|
6700
|
+
const at = join22(sourcesDir, clone);
|
|
5213
6701
|
let stamp = stamps.get(at);
|
|
5214
6702
|
if (stamp === undefined) {
|
|
5215
6703
|
stamp = statSync3(at).mtimeMs;
|
|
@@ -5224,9 +6712,9 @@ function oneModPerClone(records, sourcesDir) {
|
|
|
5224
6712
|
}
|
|
5225
6713
|
async function parseAll(candidates, config, plugin, problems) {
|
|
5226
6714
|
const records = await Promise.all(candidates.map(async (candidate) => {
|
|
5227
|
-
const file =
|
|
6715
|
+
const file = join22(candidate.dir, config.manifest.file);
|
|
5228
6716
|
try {
|
|
5229
|
-
const manifest = plugin.parseManifest(await
|
|
6717
|
+
const manifest = plugin.parseManifest(await readFile11(file, "utf8"));
|
|
5230
6718
|
return manifest === null ? null : toRecord(candidate, manifest, config);
|
|
5231
6719
|
} catch (error) {
|
|
5232
6720
|
problems.push({
|
|
@@ -5297,11 +6785,11 @@ function byPath(index, raw, game) {
|
|
|
5297
6785
|
if (hit)
|
|
5298
6786
|
return hit;
|
|
5299
6787
|
}
|
|
5300
|
-
const file =
|
|
5301
|
-
if (!
|
|
6788
|
+
const file = join22(dir, game.manifest.file);
|
|
6789
|
+
if (!existsSync13(file))
|
|
5302
6790
|
return null;
|
|
5303
6791
|
try {
|
|
5304
|
-
const manifest = index.plugin.parseManifest(
|
|
6792
|
+
const manifest = index.plugin.parseManifest(readFileSync9(file, "utf8"));
|
|
5305
6793
|
if (manifest === null)
|
|
5306
6794
|
return null;
|
|
5307
6795
|
return toRecord({ dir, kind: "local", rootIndex: -1, linkedWorktree: false }, manifest, game);
|
|
@@ -5335,7 +6823,7 @@ function refFor(entry, game, sources) {
|
|
|
5335
6823
|
if (pin?.git !== undefined) {
|
|
5336
6824
|
const dir = sources.get(object.id.toLowerCase());
|
|
5337
6825
|
if (dir !== undefined)
|
|
5338
|
-
return `path:${pin.subdir === undefined ? dir :
|
|
6826
|
+
return `path:${pin.subdir === undefined ? dir : join23(dir, pin.subdir)}`;
|
|
5339
6827
|
}
|
|
5340
6828
|
return object.id;
|
|
5341
6829
|
}
|
|
@@ -5672,11 +7160,11 @@ async function resolvePlan(options) {
|
|
|
5672
7160
|
profileDir,
|
|
5673
7161
|
...instance.name === undefined ? {} : { instance: instance.name },
|
|
5674
7162
|
instanceDir: instance.dir,
|
|
5675
|
-
dataDirHost:
|
|
5676
|
-
configDirHost:
|
|
5677
|
-
stageDirHost:
|
|
5678
|
-
logsDirHost:
|
|
5679
|
-
runDirHost:
|
|
7163
|
+
dataDirHost: join23(instance.dir, "game"),
|
|
7164
|
+
configDirHost: join23(profileDir, "config"),
|
|
7165
|
+
stageDirHost: join23(instance.dir, ".stage"),
|
|
7166
|
+
logsDirHost: join23(instance.dir, "logs"),
|
|
7167
|
+
runDirHost: join23(instance.dir, "logs"),
|
|
5680
7168
|
mode,
|
|
5681
7169
|
...args.marker === undefined ? {} : { marker: args.marker },
|
|
5682
7170
|
timeoutSeconds: args.timeout ?? DEFAULT_TIMEOUT_SECONDS,
|
|
@@ -5689,7 +7177,7 @@ async function resolvePlan(options) {
|
|
|
5689
7177
|
|
|
5690
7178
|
// src/run/registry.ts
|
|
5691
7179
|
import { readdir as readdir9 } from "node:fs/promises";
|
|
5692
|
-
import { join as
|
|
7180
|
+
import { join as join24 } from "node:path";
|
|
5693
7181
|
var FORMAT = '{{.Names}}\t{{.Label "gamecrate.game"}}\t{{.Label "gamecrate.profile"}}\t{{.Label "gamecrate.instance"}}\t{{.Status}}';
|
|
5694
7182
|
function parseDockerRuns(stdout) {
|
|
5695
7183
|
const out = [];
|
|
@@ -5714,13 +7202,13 @@ function parseDockerRuns(stdout) {
|
|
|
5714
7202
|
async function walkLocks(dataRoot) {
|
|
5715
7203
|
const out = [];
|
|
5716
7204
|
for (const game of await entries(dataRoot)) {
|
|
5717
|
-
const gameDir =
|
|
7205
|
+
const gameDir = join24(dataRoot, game);
|
|
5718
7206
|
for (const profile of await entries(gameDir)) {
|
|
5719
|
-
const profileDir =
|
|
5720
|
-
await push(out,
|
|
5721
|
-
const instancesDir =
|
|
7207
|
+
const profileDir = join24(gameDir, profile);
|
|
7208
|
+
await push(out, join24(profileDir, ".gamecrate", "lock"));
|
|
7209
|
+
const instancesDir = join24(profileDir, "instances");
|
|
5722
7210
|
for (const instance of await entries(instancesDir)) {
|
|
5723
|
-
await push(out,
|
|
7211
|
+
await push(out, join24(instancesDir, instance, ".gamecrate", "lock"));
|
|
5724
7212
|
}
|
|
5725
7213
|
}
|
|
5726
7214
|
}
|
|
@@ -5783,7 +7271,7 @@ function fromLock(lock) {
|
|
|
5783
7271
|
|
|
5784
7272
|
// src/launch/stage.ts
|
|
5785
7273
|
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
|
|
7274
|
+
import { basename as basename10, join as join25 } from "node:path";
|
|
5787
7275
|
async function stageMods(plan) {
|
|
5788
7276
|
await rm4(plan.stageDirHost, { recursive: true, force: true });
|
|
5789
7277
|
await mkdir5(plan.stageDirHost, { recursive: true });
|
|
@@ -5800,7 +7288,7 @@ async function stageMods(plan) {
|
|
|
5800
7288
|
if (!(await stat5(source)).isDirectory()) {
|
|
5801
7289
|
throw new GamecrateError(`${mod.packageId} does not resolve to a directory`, Exit.Environment, source);
|
|
5802
7290
|
}
|
|
5803
|
-
await mkdir5(
|
|
7291
|
+
await mkdir5(join25(plan.stageDirHost, basename10(mod.containerDir)), { recursive: true });
|
|
5804
7292
|
mounts.push({ type: "bind", source, target: mod.containerDir, readonly: true });
|
|
5805
7293
|
}
|
|
5806
7294
|
return mounts;
|
|
@@ -5810,12 +7298,12 @@ async function ensureProfileTree(plan) {
|
|
|
5810
7298
|
plan.profileDir,
|
|
5811
7299
|
plan.instanceDir,
|
|
5812
7300
|
plan.dataDirHost,
|
|
5813
|
-
|
|
5814
|
-
|
|
5815
|
-
|
|
5816
|
-
|
|
7301
|
+
join25(plan.configDirHost, "config"),
|
|
7302
|
+
join25(plan.configDirHost, "data"),
|
|
7303
|
+
join25(plan.configDirHost, "cache"),
|
|
7304
|
+
join25(plan.logsDirHost, "runs"),
|
|
5817
7305
|
plan.stageDirHost,
|
|
5818
|
-
|
|
7306
|
+
join25(plan.instanceDir, ".gamecrate"),
|
|
5819
7307
|
...engineDirs(plan)
|
|
5820
7308
|
]) {
|
|
5821
7309
|
await mkdir5(dir, { recursive: true });
|
|
@@ -5825,7 +7313,7 @@ function engineDirs(plan) {
|
|
|
5825
7313
|
const { dataDir, modsDir } = plan.gameConfig;
|
|
5826
7314
|
if (!modsDir.container.startsWith(`${dataDir.container}/`))
|
|
5827
7315
|
return [];
|
|
5828
|
-
return [
|
|
7316
|
+
return [join25(plan.dataDirHost, modsDir.container.slice(dataDir.container.length + 1))];
|
|
5829
7317
|
}
|
|
5830
7318
|
async function detectForeignOwnership(dir, uid, limit = 100) {
|
|
5831
7319
|
const foreign = [];
|
|
@@ -5847,7 +7335,7 @@ async function detectForeignOwnership(dir, uid, limit = 100) {
|
|
|
5847
7335
|
continue;
|
|
5848
7336
|
try {
|
|
5849
7337
|
for (const entry of await readdir10(current))
|
|
5850
|
-
queue.push(
|
|
7338
|
+
queue.push(join25(current, entry));
|
|
5851
7339
|
} catch {
|
|
5852
7340
|
continue;
|
|
5853
7341
|
}
|
|
@@ -5856,9 +7344,9 @@ async function detectForeignOwnership(dir, uid, limit = 100) {
|
|
|
5856
7344
|
}
|
|
5857
7345
|
|
|
5858
7346
|
// src/mods/workshop.ts
|
|
5859
|
-
import { existsSync as
|
|
5860
|
-
import { readFile as
|
|
5861
|
-
import { join as
|
|
7347
|
+
import { existsSync as existsSync14 } from "node:fs";
|
|
7348
|
+
import { readFile as readFile12 } from "node:fs/promises";
|
|
7349
|
+
import { join as join26, resolve as resolvePath2 } from "node:path";
|
|
5862
7350
|
var ROUNDS = 5;
|
|
5863
7351
|
async function prepareWorkshop(game, profileName, args, config, allowFetch, plugin, sources) {
|
|
5864
7352
|
const roots = mountedRoots(config.dataRoot, game);
|
|
@@ -5909,18 +7397,18 @@ function mountedRoots(dataRoot, game) {
|
|
|
5909
7397
|
return [downloadRoot(dataRoot, game), ...game.workshopRoot === null ? [] : [game.workshopRoot]];
|
|
5910
7398
|
}
|
|
5911
7399
|
function itemDir(roots, id) {
|
|
5912
|
-
return roots.map((root) =>
|
|
7400
|
+
return roots.map((root) => join26(root, id)).find((dir) => existsSync14(dir));
|
|
5913
7401
|
}
|
|
5914
7402
|
async function manifestOf(roots, id, game, plugin, problems) {
|
|
5915
7403
|
const dir = itemDir(roots, id);
|
|
5916
7404
|
return dir === undefined ? null : await manifestAt(dir, game, plugin, problems);
|
|
5917
7405
|
}
|
|
5918
7406
|
async function manifestAt(dir, game, plugin, problems) {
|
|
5919
|
-
const file =
|
|
5920
|
-
if (!
|
|
7407
|
+
const file = join26(dir, game.manifest.file);
|
|
7408
|
+
if (!existsSync14(file))
|
|
5921
7409
|
return null;
|
|
5922
7410
|
try {
|
|
5923
|
-
return plugin.parseManifest(await
|
|
7411
|
+
return plugin.parseManifest(await readFile12(file, "utf8"));
|
|
5924
7412
|
} catch (error) {
|
|
5925
7413
|
problems.push({ where: file, message: error instanceof Error ? error.message : String(error) });
|
|
5926
7414
|
return null;
|
|
@@ -5958,7 +7446,7 @@ function localDirOf(entry, game, sources) {
|
|
|
5958
7446
|
const pin = libraryPin(game, object.id);
|
|
5959
7447
|
const clone = sources.get(object.id.toLowerCase());
|
|
5960
7448
|
if (clone !== undefined)
|
|
5961
|
-
return pin?.subdir === undefined ? clone :
|
|
7449
|
+
return pin?.subdir === undefined ? clone : join26(clone, pin.subdir);
|
|
5962
7450
|
return pin?.path === undefined ? undefined : resolvePath2(expandHome(pin.path));
|
|
5963
7451
|
}
|
|
5964
7452
|
function wantedIds(game, profile, args) {
|
|
@@ -5992,7 +7480,7 @@ function published2(value) {
|
|
|
5992
7480
|
}
|
|
5993
7481
|
|
|
5994
7482
|
// src/index.ts
|
|
5995
|
-
var VERSION = "
|
|
7483
|
+
var VERSION = "2.0.0";
|
|
5996
7484
|
async function main(argv) {
|
|
5997
7485
|
const supervised = supervisedDir(argv);
|
|
5998
7486
|
try {
|
|
@@ -6043,6 +7531,12 @@ async function dispatch(argv, args, config, plugins, defaults) {
|
|
|
6043
7531
|
return modsSync(args, ctx);
|
|
6044
7532
|
return mods(args, config, plugins, defaults);
|
|
6045
7533
|
}
|
|
7534
|
+
case "steam": {
|
|
7535
|
+
const ctx = { config, plugins, cwd: process.cwd(), configFile: await globalConfigPath() };
|
|
7536
|
+
if (args.subverb === "login")
|
|
7537
|
+
return steamLogin(args, ctx);
|
|
7538
|
+
return steamBuildCommand(args, ctx);
|
|
7539
|
+
}
|
|
6046
7540
|
case "doctor":
|
|
6047
7541
|
return doctor(config, plugins);
|
|
6048
7542
|
case "clean":
|
|
@@ -6060,7 +7554,7 @@ async function dispatch(argv, args, config, plugins, defaults) {
|
|
|
6060
7554
|
case "stop":
|
|
6061
7555
|
return stop(args, config, defaults);
|
|
6062
7556
|
case "attach":
|
|
6063
|
-
return
|
|
7557
|
+
return attach2(args, config, defaults);
|
|
6064
7558
|
case "wait":
|
|
6065
7559
|
return waitFor(args, config, defaults);
|
|
6066
7560
|
case "shell":
|
|
@@ -6118,8 +7612,9 @@ function reportEnvironment(problems) {
|
|
|
6118
7612
|
}
|
|
6119
7613
|
async function run2(argv, args, config, plugins, defaults, asShell) {
|
|
6120
7614
|
const game = requireGame(args, config);
|
|
6121
|
-
const profile =
|
|
6122
|
-
const gameConfig = config.games[game];
|
|
7615
|
+
const profile = launchProfile(args, defaults, config.games[game]);
|
|
7616
|
+
const gameConfig = withImageOverride(config.games[game], imageFor(config.games[game], profile, args.image));
|
|
7617
|
+
config.games[game] = gameConfig;
|
|
6123
7618
|
const allowFetch = !args.dryRun && !args.printPlan;
|
|
6124
7619
|
const sources = await prepareSources(gameConfig, profile, args, config.dataRoot, allowFetch);
|
|
6125
7620
|
try {
|
|
@@ -6151,8 +7646,8 @@ async function resolved(inputs) {
|
|
|
6151
7646
|
reportProblems(fatal);
|
|
6152
7647
|
const identity = resolveIdentity(args.root);
|
|
6153
7648
|
if (args.printPlan || args.dryRun)
|
|
6154
|
-
return await reportPlanOnly(plan, args, profile, identity);
|
|
6155
|
-
const environment = await preflight(plan);
|
|
7649
|
+
return await reportPlanOnly(plan, args, profile, identity, asShell);
|
|
7650
|
+
const environment = await preflight(plan, asShell);
|
|
6156
7651
|
if (environment.length > 0)
|
|
6157
7652
|
reportEnvironment(environment);
|
|
6158
7653
|
await ensureProfileTree(plan);
|
|
@@ -6171,8 +7666,8 @@ async function resolved(inputs) {
|
|
|
6171
7666
|
await lock.release();
|
|
6172
7667
|
}
|
|
6173
7668
|
}
|
|
6174
|
-
async function reportPlanOnly(plan, args, profile, identity) {
|
|
6175
|
-
const environment = await preflight(plan);
|
|
7669
|
+
async function reportPlanOnly(plan, args, profile, identity, asShell) {
|
|
7670
|
+
const environment = await preflight(plan, asShell);
|
|
6176
7671
|
buildRunSpec(plan, [], identity);
|
|
6177
7672
|
if (args.printPlan)
|
|
6178
7673
|
printPlan(plan, args.json);
|
|
@@ -6197,7 +7692,7 @@ run: gamecrate fix-perms ${game} ${profile}`);
|
|
|
6197
7692
|
}
|
|
6198
7693
|
const runDir = openRunLog(plan.logsDirHost);
|
|
6199
7694
|
plan.runDirHost = runDir;
|
|
6200
|
-
const supervisorLog = args.supervised && args.log === undefined ? redirectOutput(
|
|
7695
|
+
const supervisorLog = args.supervised && args.log === undefined ? redirectOutput(join27(runDir, "supervisor.log")) : undefined;
|
|
6201
7696
|
try {
|
|
6202
7697
|
return await execute({ plan, args, config, identity, asShell, profileSpec, runDir, releaseSources });
|
|
6203
7698
|
} finally {
|
|
@@ -6206,87 +7701,140 @@ run: gamecrate fix-perms ${game} ${profile}`);
|
|
|
6206
7701
|
}
|
|
6207
7702
|
async function execute(inputs) {
|
|
6208
7703
|
const { plan, args, config, identity, asShell, profileSpec, runDir, releaseSources } = inputs;
|
|
6209
|
-
const game = plan.game;
|
|
6210
7704
|
await buildLocalMods(plan, buildPolicy(args, profileSpec));
|
|
6211
7705
|
await releaseSources();
|
|
6212
|
-
await
|
|
6213
|
-
const runtimeImage = plan.mode === "headed" ? config.games[game].image.ref : await ensureRuntimeLayer(config.games[game].image.ref);
|
|
7706
|
+
const { facts, imageStart } = await readyImage(plan, config, args, asShell);
|
|
6214
7707
|
const modMounts = await stageMods(plan);
|
|
6215
7708
|
await generateModsConfig(plan);
|
|
6216
7709
|
await mergePrefs(plan);
|
|
6217
7710
|
for (const warning of planWarnings(plan))
|
|
6218
7711
|
warn(warning);
|
|
6219
|
-
const spec = buildRunSpec(plan, modMounts, identity);
|
|
6220
|
-
spec.image = runtimeImage;
|
|
7712
|
+
const spec = buildRunSpec(plan, modMounts, identity, imageStart);
|
|
6221
7713
|
if (asShell) {
|
|
6222
7714
|
spec.command = ["/bin/bash"];
|
|
6223
7715
|
spec.extraArgs = [...spec.extraArgs, "--interactive", "--tty"];
|
|
6224
7716
|
}
|
|
6225
7717
|
await writeLaunchRecord(plan, spec.image);
|
|
7718
|
+
const trustExit = facts.launcher !== "proton";
|
|
7719
|
+
try {
|
|
7720
|
+
return await dispatchRun(spec, plan, runDir, trustExit, asShell);
|
|
7721
|
+
} finally {
|
|
7722
|
+
await copyOutLogs(plan);
|
|
7723
|
+
}
|
|
7724
|
+
}
|
|
7725
|
+
async function readyImage(plan, config, args, asShell) {
|
|
7726
|
+
const game = plan.game;
|
|
7727
|
+
const ref = config.games[game].image.ref;
|
|
6226
7728
|
try {
|
|
6227
|
-
|
|
6228
|
-
|
|
6229
|
-
|
|
6230
|
-
|
|
6231
|
-
|
|
6232
|
-
|
|
6233
|
-
|
|
6234
|
-
|
|
6235
|
-
|
|
6236
|
-
|
|
6237
|
-
|
|
6238
|
-
|
|
6239
|
-
|
|
6240
|
-
|
|
7729
|
+
await acquireImage(game, config.games[game], args.pull ?? "missing");
|
|
7730
|
+
} catch (error) {
|
|
7731
|
+
const absent = imageProblem({ game, ref, mode: plan.mode, facts: await readImageFacts(ref) });
|
|
7732
|
+
if (absent === null)
|
|
7733
|
+
throw error;
|
|
7734
|
+
throw new GamecrateError(absent.message, Exit.Environment, absent.suggestion);
|
|
7735
|
+
}
|
|
7736
|
+
let facts = await readImageFacts(ref);
|
|
7737
|
+
if (await rebuiltForUpdate(plan, config, args))
|
|
7738
|
+
facts = await readImageFacts(ref);
|
|
7739
|
+
const problem = imageProblem({ game, ref, mode: plan.mode, facts });
|
|
7740
|
+
if (problem !== null) {
|
|
7741
|
+
throw new GamecrateError(problem.message, Exit.Environment, problem.suggestion);
|
|
7742
|
+
}
|
|
7743
|
+
const imageStart = asShell ? undefined : imageLaunch(facts);
|
|
7744
|
+
refuseProtonHeaded(game, plan.mode, imageStart);
|
|
7745
|
+
const needsMarker = asShell ? null : markerProblem({ game, facts, marker: plan.marker });
|
|
7746
|
+
if (needsMarker !== null) {
|
|
7747
|
+
throw new GamecrateError(needsMarker.message, Exit.Usage, needsMarker.suggestion);
|
|
7748
|
+
}
|
|
7749
|
+
return { facts, imageStart };
|
|
7750
|
+
}
|
|
7751
|
+
async function rebuiltForUpdate(plan, config, args) {
|
|
7752
|
+
const facts = await readImageFacts(config.games[plan.game].image.ref);
|
|
7753
|
+
let rebuilt = false;
|
|
7754
|
+
await offerRebuild({
|
|
7755
|
+
game: plan.game,
|
|
7756
|
+
config,
|
|
7757
|
+
args,
|
|
7758
|
+
facts,
|
|
7759
|
+
cwd: process.cwd(),
|
|
7760
|
+
configFile: await globalConfigPath(),
|
|
7761
|
+
ask: async (question) => {
|
|
7762
|
+
if (args.yes)
|
|
7763
|
+
return rebuilt = true;
|
|
7764
|
+
if (process.stdin.isTTY !== true || args.json) {
|
|
7765
|
+
warn("no terminal to ask, so the launch keeps the current image");
|
|
7766
|
+
return false;
|
|
6241
7767
|
}
|
|
6242
|
-
|
|
6243
|
-
|
|
6244
|
-
|
|
6245
|
-
|
|
6246
|
-
|
|
6247
|
-
}
|
|
6248
|
-
|
|
6249
|
-
|
|
6250
|
-
|
|
6251
|
-
|
|
6252
|
-
|
|
7768
|
+
const rl = createInterface2({ input: process.stdin, output: process.stderr });
|
|
7769
|
+
try {
|
|
7770
|
+
const said = (await rl.question(question)).trim().toLowerCase();
|
|
7771
|
+
rebuilt = said === "y" || said === "yes";
|
|
7772
|
+
return rebuilt;
|
|
7773
|
+
} finally {
|
|
7774
|
+
rl.close();
|
|
7775
|
+
}
|
|
7776
|
+
}
|
|
7777
|
+
});
|
|
7778
|
+
return rebuilt;
|
|
7779
|
+
}
|
|
7780
|
+
async function dispatchRun(spec, plan, runDir, trustExit, asShell) {
|
|
7781
|
+
if (plan.marker !== undefined && !asShell)
|
|
7782
|
+
return await runWithMarker(spec, plan, runDir, trustExit);
|
|
7783
|
+
if (plan.mode === "screenshot" && !asShell)
|
|
7784
|
+
return await runWithScreenshot(spec, plan, runDir, trustExit);
|
|
7785
|
+
if (plan.mode !== "headed" && !asShell)
|
|
7786
|
+
return await runBounded(spec, plan, runDir, trustExit);
|
|
7787
|
+
let windowClosed = false;
|
|
7788
|
+
const window = asShell || plan.settings.display !== "x11" ? null : await adoptNewWindow({
|
|
7789
|
+
executable: plan.gameConfig.executable,
|
|
7790
|
+
title: windowTitle(plan),
|
|
7791
|
+
stripDelete: plan.gameConfig.ignoresWmDelete === true,
|
|
7792
|
+
onClosed: () => {
|
|
7793
|
+
windowClosed = true;
|
|
7794
|
+
stopContainer(spec.name, STOP_TIMEOUT_SECONDS);
|
|
6253
7795
|
}
|
|
7796
|
+
});
|
|
7797
|
+
try {
|
|
7798
|
+
const code = await runContainer(spec, { logDir: runDir, stopTimeoutSeconds: STOP_TIMEOUT_SECONDS });
|
|
7799
|
+
if (windowClosed)
|
|
7800
|
+
return { code: Exit.Ok, reason: "window-closed" };
|
|
7801
|
+
return { code: normalize(code), reason: reasonFor(code) };
|
|
6254
7802
|
} finally {
|
|
6255
|
-
|
|
7803
|
+
window?.stop();
|
|
6256
7804
|
}
|
|
6257
7805
|
}
|
|
6258
7806
|
function markerSources(plan, logDir) {
|
|
6259
|
-
const sources = [
|
|
7807
|
+
const sources = [join27(logDir, STDOUT_LOG)];
|
|
6260
7808
|
const { logFile } = plan.gameConfig;
|
|
6261
7809
|
if (logFile.mode === "arg")
|
|
6262
|
-
sources.push(
|
|
7810
|
+
sources.push(join27(logDir, "Player.log"));
|
|
6263
7811
|
else
|
|
6264
|
-
sources.push(
|
|
7812
|
+
sources.push(join27(plan.dataDirHost, logFile.from));
|
|
6265
7813
|
return sources;
|
|
6266
7814
|
}
|
|
6267
|
-
async function runBounded(spec, plan, logDir) {
|
|
7815
|
+
async function runBounded(spec, plan, logDir, trustExit) {
|
|
6268
7816
|
const container = runContainer(spec, { logDir, stopTimeoutSeconds: STOP_TIMEOUT_SECONDS });
|
|
6269
7817
|
const winner = await Promise.race([
|
|
6270
7818
|
container.then((code) => ({ kind: "exit", code })),
|
|
6271
|
-
|
|
7819
|
+
sleep8(plan.timeoutSeconds * 1000).then(() => ({ kind: "timeout" }))
|
|
6272
7820
|
]);
|
|
6273
7821
|
if (winner.kind === "exit")
|
|
6274
|
-
return
|
|
7822
|
+
return exitResult(winner.code, trustExit);
|
|
6275
7823
|
status(`no marker given; stopping after ${plan.timeoutSeconds}s`);
|
|
6276
7824
|
await stopContainer(spec.name, STOP_TIMEOUT_SECONDS);
|
|
6277
7825
|
await container;
|
|
6278
7826
|
return { code: Exit.Ok, reason: "timeout" };
|
|
6279
7827
|
}
|
|
6280
|
-
async function runWithScreenshot(spec, plan, logDir) {
|
|
7828
|
+
async function runWithScreenshot(spec, plan, logDir, trustExit) {
|
|
6281
7829
|
const container = runContainer(spec, { logDir, stopTimeoutSeconds: STOP_TIMEOUT_SECONDS });
|
|
6282
|
-
const settled =
|
|
7830
|
+
const settled = sleep8(plan.renderWaitSeconds * 1000).then(() => "ready");
|
|
6283
7831
|
const winner = await Promise.race([
|
|
6284
7832
|
container.then((code) => ({ kind: "exit", code })),
|
|
6285
7833
|
settled.then(() => ({ kind: "ready" }))
|
|
6286
7834
|
]);
|
|
6287
7835
|
if (winner.kind === "exit") {
|
|
6288
7836
|
status(`game exited before the ${plan.renderWaitSeconds}s render wait finished; no frame captured`);
|
|
6289
|
-
return
|
|
7837
|
+
return exitResult(winner.code, trustExit);
|
|
6290
7838
|
}
|
|
6291
7839
|
const shot = await grabFrame(spec.name, plan);
|
|
6292
7840
|
await stopContainer(spec.name, STOP_TIMEOUT_SECONDS);
|
|
@@ -6296,21 +7844,35 @@ async function runWithScreenshot(spec, plan, logDir) {
|
|
|
6296
7844
|
async function grabFrame(container, plan) {
|
|
6297
7845
|
const path = await captureScreenshot(container, plan);
|
|
6298
7846
|
if (path === null)
|
|
6299
|
-
warn("screenshot capture failed;
|
|
7847
|
+
warn("screenshot capture failed; the capture command printed the reason above");
|
|
6300
7848
|
else
|
|
6301
7849
|
status(`screenshot: ${path}`);
|
|
6302
7850
|
return path;
|
|
6303
7851
|
}
|
|
6304
|
-
|
|
7852
|
+
var MARKER_GRACE_MS = 1000;
|
|
7853
|
+
async function runWithMarker(spec, plan, logDir, trustExit) {
|
|
6305
7854
|
const marker = plan.marker;
|
|
6306
7855
|
const container = runContainer(spec, { logDir, stopTimeoutSeconds: STOP_TIMEOUT_SECONDS });
|
|
6307
|
-
|
|
7856
|
+
let waited;
|
|
7857
|
+
const seen = waitForMarker(markerSources(plan, logDir), marker, plan.timeoutSeconds).then((hit) => {
|
|
7858
|
+
waited = hit;
|
|
7859
|
+
return hit;
|
|
7860
|
+
});
|
|
6308
7861
|
const winner = await Promise.race([
|
|
6309
7862
|
container.then((code) => ({ kind: "exit", code })),
|
|
6310
7863
|
seen.then((hit) => ({ kind: "marker", hit }))
|
|
6311
7864
|
]);
|
|
6312
|
-
if (winner.kind === "exit")
|
|
6313
|
-
|
|
7865
|
+
if (winner.kind === "exit") {
|
|
7866
|
+
if (trustExit || winner.code === Exit.Interrupted)
|
|
7867
|
+
return exitResult(winner.code, trustExit);
|
|
7868
|
+
const hit = await Promise.race([seen, sleep8(MARKER_GRACE_MS).then(() => false)]);
|
|
7869
|
+
if (hit) {
|
|
7870
|
+
status(`marker seen: ${marker}`);
|
|
7871
|
+
return { code: Exit.Ok, reason: "marker" };
|
|
7872
|
+
}
|
|
7873
|
+
status(`container exited before the marker "${marker}" appeared`);
|
|
7874
|
+
return waited === false ? { code: Exit.MarkerTimeout, reason: "marker-timeout" } : { code: Exit.GameFailed, reason: "exited" };
|
|
7875
|
+
}
|
|
6314
7876
|
if (plan.mode === "screenshot")
|
|
6315
7877
|
await grabFrame(spec.name, plan);
|
|
6316
7878
|
await stopContainer(spec.name, STOP_TIMEOUT_SECONDS);
|
|
@@ -6322,6 +7884,12 @@ async function runWithMarker(spec, plan, logDir) {
|
|
|
6322
7884
|
status(`marker "${marker}" not seen within ${plan.timeoutSeconds}s`);
|
|
6323
7885
|
return { code: Exit.MarkerTimeout, reason: "marker-timeout" };
|
|
6324
7886
|
}
|
|
7887
|
+
function exitResult(code, trustExit) {
|
|
7888
|
+
if (trustExit || code === Exit.Interrupted) {
|
|
7889
|
+
return { code: normalize(code), reason: reasonFor(code) };
|
|
7890
|
+
}
|
|
7891
|
+
return { code: Exit.GameFailed, reason: "exited" };
|
|
7892
|
+
}
|
|
6325
7893
|
function normalize(code) {
|
|
6326
7894
|
return Number.isInteger(code) && code >= 0 && code <= 255 ? code : Exit.GameFailed;
|
|
6327
7895
|
}
|
|
@@ -6329,10 +7897,10 @@ async function copyOutLogs(plan) {
|
|
|
6329
7897
|
const spec = plan.gameConfig.logFile;
|
|
6330
7898
|
if (spec.mode !== "copy-out")
|
|
6331
7899
|
return;
|
|
6332
|
-
const source =
|
|
6333
|
-
if (!
|
|
7900
|
+
const source = join27(plan.dataDirHost, spec.from);
|
|
7901
|
+
if (!existsSync15(source))
|
|
6334
7902
|
return;
|
|
6335
|
-
const target =
|
|
7903
|
+
const target = join27(plan.runDirHost, basename11(spec.from));
|
|
6336
7904
|
try {
|
|
6337
7905
|
await cp(source, target, { recursive: true, force: true });
|
|
6338
7906
|
} catch (error) {
|
|
@@ -6399,7 +7967,7 @@ function steamcmdProblems(game, gameConfig, config) {
|
|
|
6399
7967
|
});
|
|
6400
7968
|
}
|
|
6401
7969
|
const root = downloadRoot(config.dataRoot, gameConfig);
|
|
6402
|
-
status(`${game}: workshop downloads ${root}${
|
|
7970
|
+
status(`${game}: workshop downloads ${root}${existsSync15(root) ? "" : " (not created yet)"}`);
|
|
6403
7971
|
return problems;
|
|
6404
7972
|
}
|
|
6405
7973
|
function reportDoctor(game, all) {
|
|
@@ -6424,12 +7992,12 @@ async function logs(args, config, defaults) {
|
|
|
6424
7992
|
const dir = instanceDir(args, config, game, profile);
|
|
6425
7993
|
if (args.follow)
|
|
6426
7994
|
return await follow(dir, false, `${game} ${profile}`);
|
|
6427
|
-
const runs =
|
|
7995
|
+
const runs = join27(dir, "logs", "runs");
|
|
6428
7996
|
const latest = (await readdir11(runs, { withFileTypes: true }).catch(() => [])).filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort().at(-1);
|
|
6429
7997
|
if (latest === undefined) {
|
|
6430
7998
|
throw new GamecrateError(`no runs recorded for ${game} ${profile}`, Exit.Usage, runs);
|
|
6431
7999
|
}
|
|
6432
|
-
const runDir =
|
|
8000
|
+
const runDir = join27(runs, latest);
|
|
6433
8001
|
const files = (await readdir11(runDir, { withFileTypes: true })).filter((entry) => entry.isFile()).map((entry) => entry.name).sort();
|
|
6434
8002
|
if (args.json) {
|
|
6435
8003
|
process.stdout.write(`${JSON.stringify({ run: latest, dir: runDir, files }, null, 2)}
|
|
@@ -6438,7 +8006,7 @@ async function logs(args, config, defaults) {
|
|
|
6438
8006
|
}
|
|
6439
8007
|
status(runDir);
|
|
6440
8008
|
for (const name of files) {
|
|
6441
|
-
const text = await
|
|
8009
|
+
const text = await readFile13(join27(runDir, name), "utf8").catch(() => "");
|
|
6442
8010
|
for (const line of text.split(`
|
|
6443
8011
|
`)) {
|
|
6444
8012
|
if (line.length > 0)
|
|
@@ -6555,7 +8123,7 @@ function renderVerify(name, info, plan, boundMods) {
|
|
|
6555
8123
|
`);
|
|
6556
8124
|
}
|
|
6557
8125
|
function shortenHome(path) {
|
|
6558
|
-
const home =
|
|
8126
|
+
const home = homedir7();
|
|
6559
8127
|
return path === home || path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path;
|
|
6560
8128
|
}
|
|
6561
8129
|
async function clean(args, config, defaults) {
|
|
@@ -6570,7 +8138,7 @@ async function clean(args, config, defaults) {
|
|
|
6570
8138
|
return Exit.Ok;
|
|
6571
8139
|
}
|
|
6572
8140
|
if (tier !== "all") {
|
|
6573
|
-
const target =
|
|
8141
|
+
const target = join27(instanceDir(args, config, game, profile), tier === "logs" ? "logs" : ".stage");
|
|
6574
8142
|
await rm5(target, { recursive: true, force: true });
|
|
6575
8143
|
status(`removed ${target}`);
|
|
6576
8144
|
return Exit.Ok;
|
|
@@ -6597,7 +8165,7 @@ async function countSaves(dir, suffixes) {
|
|
|
6597
8165
|
}
|
|
6598
8166
|
for (const entry of entries) {
|
|
6599
8167
|
if (entry.isDirectory())
|
|
6600
|
-
queue.push(
|
|
8168
|
+
queue.push(join27(current, entry.name));
|
|
6601
8169
|
else if (suffixes.some((s) => entry.name.toLowerCase().endsWith(s)))
|
|
6602
8170
|
count++;
|
|
6603
8171
|
}
|
|
@@ -6610,11 +8178,11 @@ async function clone(args, config) {
|
|
|
6610
8178
|
if (src === undefined || dst === undefined) {
|
|
6611
8179
|
throw new GamecrateError("clone needs a source and a destination profile", Exit.Usage);
|
|
6612
8180
|
}
|
|
6613
|
-
const from =
|
|
6614
|
-
const to =
|
|
6615
|
-
if (!
|
|
8181
|
+
const from = join27(profileDataDir(config, game, src), "game");
|
|
8182
|
+
const to = join27(profileDataDir(config, game, dst), "game");
|
|
8183
|
+
if (!existsSync15(from))
|
|
6616
8184
|
throw new GamecrateError(`${from} does not exist`, Exit.Usage);
|
|
6617
|
-
if (
|
|
8185
|
+
if (existsSync15(to) && !args.yes) {
|
|
6618
8186
|
throw new GamecrateError(`${to} already exists`, Exit.Usage, "add --yes to overwrite");
|
|
6619
8187
|
}
|
|
6620
8188
|
await mkdir6(to, { recursive: true });
|
|
@@ -6656,7 +8224,7 @@ async function ps(args, config) {
|
|
|
6656
8224
|
async function stop(args, config, defaults) {
|
|
6657
8225
|
const game = requireGame(args, config);
|
|
6658
8226
|
const profile = profileOf(args, defaults);
|
|
6659
|
-
const file =
|
|
8227
|
+
const file = join27(instanceDir(args, config, game, profile), ".gamecrate", "lock");
|
|
6660
8228
|
const record = await readLock(file);
|
|
6661
8229
|
if (record === undefined) {
|
|
6662
8230
|
status(`${game} ${profile} is not running`);
|
|
@@ -6670,17 +8238,17 @@ async function stop(args, config, defaults) {
|
|
|
6670
8238
|
status(outcome === "signalled" ? `stopped ${record.container}` : `cleared the stale lock for ${record.container}`);
|
|
6671
8239
|
return Exit.Ok;
|
|
6672
8240
|
}
|
|
6673
|
-
async function
|
|
8241
|
+
async function attach2(args, config, defaults) {
|
|
6674
8242
|
const game = requireGame(args, config);
|
|
6675
8243
|
const profile = profileOf(args, defaults);
|
|
6676
8244
|
return await follow(instanceDir(args, config, game, profile), true, `${game} ${profile}`);
|
|
6677
8245
|
}
|
|
6678
8246
|
async function follow(dir, fromStart, what) {
|
|
6679
|
-
const lock = await readLock(
|
|
8247
|
+
const lock = await readLock(join27(dir, ".gamecrate", "lock"));
|
|
6680
8248
|
const held = lock !== undefined && isRunning(lock.pid, lock.startedAt);
|
|
6681
8249
|
const live = held && await awaitRunLog(dir, lock);
|
|
6682
8250
|
const file = currentLog(dir);
|
|
6683
|
-
if (!
|
|
8251
|
+
if (!existsSync15(file)) {
|
|
6684
8252
|
throw new GamecrateError(`no captured output for ${what}`, Exit.Usage, file);
|
|
6685
8253
|
}
|
|
6686
8254
|
return await spawnStatus(tailArgv(file, fromStart, live ? lock.pid : undefined), true);
|
|
@@ -6713,9 +8281,9 @@ async function configEdit(args) {
|
|
|
6713
8281
|
if (args.rest[0] !== "edit")
|
|
6714
8282
|
throw new GamecrateError("config takes one word: edit", Exit.Usage);
|
|
6715
8283
|
const existing = await findGlobalConfig();
|
|
6716
|
-
const path = existing ??
|
|
6717
|
-
await mkdir6(
|
|
6718
|
-
if (!
|
|
8284
|
+
const path = existing ?? join27(globalConfigDir(), "profiles.yml");
|
|
8285
|
+
await mkdir6(dirname13(path), { recursive: true });
|
|
8286
|
+
if (!existsSync15(path)) {
|
|
6719
8287
|
await writeFile7(path, `# gamecrate config. see https://github.com/RimWorks/gamecrate
|
|
6720
8288
|
` + `plugins: []
|
|
6721
8289
|
` + `games: {}
|
|
@@ -6753,7 +8321,7 @@ async function fixPerms(args, config) {
|
|
|
6753
8321
|
const identity = resolveIdentity(false);
|
|
6754
8322
|
const found = [];
|
|
6755
8323
|
for (const dir of await profileDirs(config, game, args.profile)) {
|
|
6756
|
-
if (!
|
|
8324
|
+
if (!existsSync15(dir))
|
|
6757
8325
|
continue;
|
|
6758
8326
|
found.push(...await detectForeignOwnership(dir, identity.uid, 1e4));
|
|
6759
8327
|
}
|