@gamecrate/cli 1.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +204 -33
- package/dist/gamecrate.js +1270 -499
- package/dist/lib.js +29 -5
- package/dist/types/cli/args.d.ts +17 -1
- package/dist/types/cli/game.d.ts +2 -0
- package/dist/types/cli/list.d.ts +2 -0
- package/dist/types/cli/output.d.ts +23 -1
- package/dist/types/cli/profile.d.ts +6 -0
- package/dist/types/config/load.d.ts +5 -3
- package/dist/types/config/read.d.ts +11 -0
- package/dist/types/docker/run.d.ts +3 -1
- package/dist/types/docker/window.d.ts +30 -1
- package/dist/types/launch/prepare.d.ts +46 -3
- package/dist/types/launch/supervisor.d.ts +48 -0
- package/dist/types/run/registry.d.ts +22 -0
- package/dist/types/types.d.ts +36 -2
- 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 mkdir4, readdir as
|
|
4
|
+
import { existsSync as existsSync8 } from "node:fs";
|
|
5
|
+
import { chown, cp, mkdir as mkdir4, readdir as readdir9, readFile as readFile7, rm as rm3, rmdir, stat as stat4, writeFile as writeFile5 } from "node:fs/promises";
|
|
6
6
|
import { homedir as homedir5 } from "node:os";
|
|
7
|
-
import { basename as
|
|
8
|
-
import { setTimeout as
|
|
7
|
+
import { basename as basename10, dirname as dirname5, join as join16 } from "node:path";
|
|
8
|
+
import { setTimeout as sleep5 } from "node:timers/promises";
|
|
9
9
|
|
|
10
10
|
// src/cli/args.ts
|
|
11
11
|
import { Command, CommanderError, Option } from "commander";
|
|
@@ -23,6 +23,9 @@ var Exit = {
|
|
|
23
23
|
Stale: 8,
|
|
24
24
|
Interrupted: 130
|
|
25
25
|
};
|
|
26
|
+
function reasonFor(code) {
|
|
27
|
+
return code === Exit.Interrupted ? "stopped" : "exited";
|
|
28
|
+
}
|
|
26
29
|
|
|
27
30
|
class GamecrateError extends Error {
|
|
28
31
|
code;
|
|
@@ -49,7 +52,11 @@ var RESERVED_NAMES = [
|
|
|
49
52
|
"verify",
|
|
50
53
|
"help",
|
|
51
54
|
"version",
|
|
52
|
-
"modless"
|
|
55
|
+
"modless",
|
|
56
|
+
"ps",
|
|
57
|
+
"stop",
|
|
58
|
+
"attach",
|
|
59
|
+
"wait"
|
|
53
60
|
];
|
|
54
61
|
var NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
55
62
|
function own(bag, key) {
|
|
@@ -74,6 +81,8 @@ var RUN_FLAGS = [
|
|
|
74
81
|
"--no-stale-check",
|
|
75
82
|
"--replace",
|
|
76
83
|
"--no-replace",
|
|
84
|
+
"--detach",
|
|
85
|
+
"--no-detach",
|
|
77
86
|
"--sort",
|
|
78
87
|
"--docker-arg",
|
|
79
88
|
"--dry-run",
|
|
@@ -116,7 +125,7 @@ var SUBCOMMANDS = [
|
|
|
116
125
|
{
|
|
117
126
|
name: "clean",
|
|
118
127
|
summary: "tiered wipe of a profile",
|
|
119
|
-
usage: "<game>
|
|
128
|
+
usage: "<game> [profile]",
|
|
120
129
|
positionals: ["game", "profile"],
|
|
121
130
|
flags: ["--staging", "--logs", "--all", "--yes", "--instance", "--worktree", "--no-worktree"]
|
|
122
131
|
},
|
|
@@ -130,7 +139,35 @@ var SUBCOMMANDS = [
|
|
|
130
139
|
{
|
|
131
140
|
name: "logs",
|
|
132
141
|
summary: "tail or open the last run's captured logs",
|
|
133
|
-
usage: "<game>
|
|
142
|
+
usage: "<game> [profile]",
|
|
143
|
+
positionals: ["game", "profile"],
|
|
144
|
+
flags: ["--instance", "--worktree", "--no-worktree", "--follow"]
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
name: "attach",
|
|
148
|
+
summary: "stream a detached run's output; ctrl-c leaves the game running",
|
|
149
|
+
usage: "<game> [profile]",
|
|
150
|
+
positionals: ["game", "profile"],
|
|
151
|
+
flags: ["--instance", "--worktree", "--no-worktree"]
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
name: "wait",
|
|
155
|
+
summary: "block until a detached run ends, then exit with its code",
|
|
156
|
+
usage: "<game> [profile]",
|
|
157
|
+
positionals: ["game", "profile"],
|
|
158
|
+
flags: ["--instance", "--worktree", "--no-worktree"]
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
name: "ps",
|
|
162
|
+
summary: "every live run: game, profile/instance, mode, pid, container, uptime or status",
|
|
163
|
+
usage: "",
|
|
164
|
+
positionals: [],
|
|
165
|
+
flags: []
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
name: "stop",
|
|
169
|
+
summary: "stop a detached run and release its lock",
|
|
170
|
+
usage: "<game> [profile]",
|
|
134
171
|
positionals: ["game", "profile"],
|
|
135
172
|
flags: ["--instance", "--worktree", "--no-worktree"]
|
|
136
173
|
},
|
|
@@ -169,7 +206,7 @@ var SUBCOMMANDS = [
|
|
|
169
206
|
},
|
|
170
207
|
{
|
|
171
208
|
name: "config",
|
|
172
|
-
summary: "open
|
|
209
|
+
summary: "open the global config in $VISUAL or $EDITOR, validate on save",
|
|
173
210
|
usage: "edit",
|
|
174
211
|
positionals: ["rest"],
|
|
175
212
|
flags: []
|
|
@@ -231,7 +268,7 @@ function enumOption(flags, summary, values) {
|
|
|
231
268
|
}
|
|
232
269
|
function buildProgram() {
|
|
233
270
|
const program = new Command;
|
|
234
|
-
program.name("gamecrate").exitOverride().helpOption(false).allowExcessArguments(true).showSuggestionAfterError(false).configureOutput({ writeOut: () => {}, writeErr: () => {} }).argument("[args...]").option("--mod <id>", "add a mod to the profile set", collect, []).option("--without <id>", "drop a mod from the resolved set", collect, []).option("--only <id>", "restrict the resolved set to these mods", collect, []).option("--worktree <path>", "promote mods from this git worktree, in its own instance ($GAMECRATE_WORKTREE)", collect, []).option("--use <packageId>=<path>", "force one mod to load from this directory, whatever the profile pins", collect, []).option("--no-worktree", "ignore the current worktree and $GAMECRATE_WORKTREE").option("--instance <name>", "run under a named sub-profile with its own saves, logs and container").addOption(enumOption(`--mode <${MODES.join("|")}>`, "how the game is displayed", MODES)).option("--marker <str>", "exit 0 as soon as this string appears in the log").option("--timeout <seconds>", "
|
|
271
|
+
program.name("gamecrate").exitOverride().helpOption(false).allowExcessArguments(true).showSuggestionAfterError(false).configureOutput({ writeOut: () => {}, writeErr: () => {} }).argument("[args...]").option("--mod <id>", "add a mod to the profile set", collect, []).option("--without <id>", "drop a mod from the resolved set", collect, []).option("--only <id>", "restrict the resolved set to these mods", collect, []).option("--worktree <path>", "promote mods from this git worktree, in its own instance ($GAMECRATE_WORKTREE)", collect, []).option("--use <packageId>=<path>", "force one mod to load from this directory, whatever the profile pins", collect, []).option("--no-worktree", "ignore the current worktree and $GAMECRATE_WORKTREE").option("--instance <name>", "run under a named sub-profile with its own saves, logs and container").addOption(enumOption(`--mode <${MODES.join("|")}>`, "how the game is displayed", MODES)).option("--marker <str>", "exit 0 as soon as this string appears in the log").option("--timeout <seconds>", "bound a marker run, or a headless run with no marker", (v) => seconds("--timeout", v)).option("--render-wait <seconds>", "settle time before a screenshot is taken", (v) => seconds("--render-wait", v)).option("--resolution <width>x<height>", "override the game resolution", parseResolution).addOption(enumOption(`--network <${NETWORK_POLICIES.join("|")}>`, "the container's network mode", NETWORK_POLICIES)).option("--log <path>", "route launch stdout and stderr to one file").addOption(enumOption(`--pull <${PULL_POLICIES.join("|")}>`, "when to pull the runtime image", PULL_POLICIES)).option("--build", "build local C# mods before launching").option("--no-build", "never build, even when an assembly is stale").option("--no-stale-check", "do not warn when a mod's sources are newer than its assemblies").option("--replace", "stop whatever is holding this profile and instance, then launch").option("--no-replace", "refuse when this profile and instance are already running").option("--detach", "start the run in the background and return the prompt").option("--no-detach", "stay in the foreground, whatever the profile or project config asks for").addOption(new Option("--supervised <instanceDir>").hideHelp()).addOption(enumOption(`--sort <${SORTS.join("|")}>`, "load order: the profile order, or a topological sort", SORTS)).option("--docker-arg <arg>", "one extra argv element for docker run", collect, []).option("--dry-run", "resolve and validate fully, write nothing").option("--print-plan", "print the resolved launch plan instead of launching").option("--json", "machine-readable output").option("--root", "run as root instead of mapping the host uid").option("--staging", "clean: wipe .stage only (the default)").option("--logs", "clean: wipe the captured run logs").option("--all", "clean: wipe the whole profile, saves included (needs --yes)").option("-f, --follow", "keep printing as the run writes").option("-y, --yes", "skip destructive-action confirmation").option("-h, --help", "this help");
|
|
235
272
|
return program;
|
|
236
273
|
}
|
|
237
274
|
function checkValueTokens(program, head) {
|
|
@@ -298,6 +335,14 @@ function parseArgs(argv, opts = {}) {
|
|
|
298
335
|
throw usage("--build and --no-build contradict");
|
|
299
336
|
if (seen.has("--replace") && seen.has("--no-replace"))
|
|
300
337
|
throw usage("--replace and --no-replace contradict");
|
|
338
|
+
if (seen.has("--detach")) {
|
|
339
|
+
if (seen.has("--no-detach"))
|
|
340
|
+
throw usage("--detach and --no-detach contradict");
|
|
341
|
+
if (seen.has("--dry-run"))
|
|
342
|
+
throw usage("--detach and --dry-run contradict");
|
|
343
|
+
if (seen.has("--print-plan"))
|
|
344
|
+
throw usage("--detach and --print-plan contradict");
|
|
345
|
+
}
|
|
301
346
|
const values = program.opts();
|
|
302
347
|
const envBuild = applyEnv(program, seen, env, values);
|
|
303
348
|
const out = {
|
|
@@ -313,10 +358,15 @@ function parseArgs(argv, opts = {}) {
|
|
|
313
358
|
root: values["root"] === true,
|
|
314
359
|
yes: values["yes"] === true,
|
|
315
360
|
help: values["help"] === true,
|
|
361
|
+
follow: values["follow"] === true,
|
|
316
362
|
worktree,
|
|
317
363
|
noWorktree: seen.has("--no-worktree"),
|
|
318
364
|
noStaleCheck: seen.has("--no-stale-check"),
|
|
319
365
|
replace: values["replace"] === true,
|
|
366
|
+
noReplace: seen.has("--no-replace"),
|
|
367
|
+
detach: values["detach"] === true,
|
|
368
|
+
noDetach: seen.has("--no-detach"),
|
|
369
|
+
supervised: typeof values["supervised"] === "string",
|
|
320
370
|
use: values["use"],
|
|
321
371
|
rest: []
|
|
322
372
|
};
|
|
@@ -334,11 +384,12 @@ function parseArgs(argv, opts = {}) {
|
|
|
334
384
|
out.cleanTier = cleanTier;
|
|
335
385
|
if (opts.defaults?.game !== undefined)
|
|
336
386
|
out.game = opts.defaults.game;
|
|
337
|
-
if (opts.defaults?.profile !== undefined)
|
|
338
|
-
out.profile = opts.defaults.profile;
|
|
339
387
|
applyPositionals(out, program.args, opts.games);
|
|
340
388
|
if (opts.defaults !== undefined)
|
|
341
389
|
applyDefaults(out, seen, opts.defaults, sep !== -1);
|
|
390
|
+
if (seen.has("--detach") && out.subcommand === "shell") {
|
|
391
|
+
throw usage("shell cannot detach: a shell needs the terminal --detach gives up");
|
|
392
|
+
}
|
|
342
393
|
return out;
|
|
343
394
|
}
|
|
344
395
|
function policy(value) {
|
|
@@ -486,6 +537,17 @@ function applyDefaults(out, seen, defaults, hasGameArgs) {
|
|
|
486
537
|
out.noStaleCheck = defaults.noStaleCheck ?? out.noStaleCheck;
|
|
487
538
|
if (!seen.has("--replace") && !seen.has("--no-replace"))
|
|
488
539
|
out.replace = defaults.replace ?? out.replace;
|
|
540
|
+
if (!seen.has("--detach") && !seen.has("--no-detach"))
|
|
541
|
+
out.detach = defaults.detach ?? out.detach;
|
|
542
|
+
}
|
|
543
|
+
function wantsDetach(args, profile) {
|
|
544
|
+
return !args.supervised && !args.noDetach && (args.detach || profile.detach === true);
|
|
545
|
+
}
|
|
546
|
+
function wantsReplace(args, profile) {
|
|
547
|
+
return !args.supervised && !args.noReplace && (args.replace || profile.replace === true);
|
|
548
|
+
}
|
|
549
|
+
function buildPolicy(args, profile) {
|
|
550
|
+
return args.build ?? profile.build ?? "auto";
|
|
489
551
|
}
|
|
490
552
|
function truthy(value) {
|
|
491
553
|
return value === "1" || value.toLowerCase() === "true" || value.toLowerCase() === "yes";
|
|
@@ -538,11 +600,107 @@ function distance(a, b) {
|
|
|
538
600
|
}
|
|
539
601
|
return prev[b.length];
|
|
540
602
|
}
|
|
603
|
+
function supervisorArgv(userArgs, instanceDir, self = process.argv, execPath = process.execPath) {
|
|
604
|
+
const bin = self[1]?.startsWith("/$bunfs/") === true ? [execPath] : [execPath, self[1]];
|
|
605
|
+
const sep = userArgs.indexOf("--");
|
|
606
|
+
const head = [
|
|
607
|
+
...(sep === -1 ? userArgs : userArgs.slice(0, sep)).filter((arg) => arg !== "--detach"),
|
|
608
|
+
"--supervised",
|
|
609
|
+
instanceDir
|
|
610
|
+
];
|
|
611
|
+
const tail = sep === -1 ? [] : userArgs.slice(sep);
|
|
612
|
+
return [...bin, ...head, ...tail];
|
|
613
|
+
}
|
|
614
|
+
function supervisedDir(argv) {
|
|
615
|
+
const sep = argv.indexOf("--");
|
|
616
|
+
const head = sep === -1 ? argv : argv.slice(0, sep);
|
|
617
|
+
const at = head.indexOf("--supervised");
|
|
618
|
+
return at === -1 ? undefined : head[at + 1];
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// src/cli/game.ts
|
|
622
|
+
function requireGame(args, config) {
|
|
623
|
+
const game = args.game;
|
|
624
|
+
if (game === undefined) {
|
|
625
|
+
throw new GamecrateError(`${args.subcommand} needs a game`, Exit.Usage, `known games: ${Object.keys(config.games).join(", ")}`);
|
|
626
|
+
}
|
|
627
|
+
if (!Object.hasOwn(config.games, game)) {
|
|
628
|
+
throw new GamecrateError(`unknown game "${game}"`, Exit.Config, `known games: ${Object.keys(config.games).join(", ")}`);
|
|
629
|
+
}
|
|
630
|
+
return game;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// src/cli/list.ts
|
|
634
|
+
import { basename } from "node:path";
|
|
635
|
+
function list(args, config, defaults) {
|
|
636
|
+
const games = args.game === undefined ? Object.keys(config.games) : [requireGame(args, config)];
|
|
637
|
+
const fromProject = (game, profile) => defaults.game === game && own(defaults.profiles, profile) !== undefined;
|
|
638
|
+
if (args.json) {
|
|
639
|
+
const payload = games.map((name) => {
|
|
640
|
+
const game = config.games[name];
|
|
641
|
+
return {
|
|
642
|
+
game: name,
|
|
643
|
+
core: game.core,
|
|
644
|
+
dlc: game.dlc,
|
|
645
|
+
modes: game.modes,
|
|
646
|
+
profiles: Object.entries(game.profiles).map(([profile, spec]) => ({
|
|
647
|
+
profile,
|
|
648
|
+
alias: spec.alias ?? null,
|
|
649
|
+
description: spec.description ?? null,
|
|
650
|
+
extends: spec.extends ?? null,
|
|
651
|
+
mods: spec.mods?.length ?? 0,
|
|
652
|
+
instances: Object.keys(spec.instances ?? {}),
|
|
653
|
+
source: fromProject(name, profile) ? "project" : "config"
|
|
654
|
+
}))
|
|
655
|
+
};
|
|
656
|
+
});
|
|
657
|
+
process.stdout.write(`${JSON.stringify(payload, null, 2)}
|
|
658
|
+
`);
|
|
659
|
+
return Exit.Ok;
|
|
660
|
+
}
|
|
661
|
+
const source = defaults.configPath === undefined ? "the .gamecrate project config" : basename(defaults.configPath);
|
|
662
|
+
const out = [];
|
|
663
|
+
for (const name of games) {
|
|
664
|
+
const game = config.games[name];
|
|
665
|
+
const width = Math.max(7, ...Object.keys(game.profiles).map((n) => n.length));
|
|
666
|
+
out.push(`${name} (${game.modes.join(", ")})`);
|
|
667
|
+
out.push(` ${"modless".padEnd(width)} built-in: core + official DLC`);
|
|
668
|
+
for (const [profile, spec] of Object.entries(game.profiles)) {
|
|
669
|
+
const notes = [];
|
|
670
|
+
if (spec.alias)
|
|
671
|
+
notes.push(`alias for ${spec.alias}`);
|
|
672
|
+
if (spec.extends)
|
|
673
|
+
notes.push(`extends ${spec.extends}`);
|
|
674
|
+
const count = spec.mods?.length ?? 0;
|
|
675
|
+
if (!spec.alias)
|
|
676
|
+
notes.push(count === 1 ? "1 entry" : `${count} entries`);
|
|
677
|
+
if (spec.aliases?.length)
|
|
678
|
+
notes.push(`aka ${spec.aliases.join(", ")}`);
|
|
679
|
+
if (fromProject(name, profile))
|
|
680
|
+
notes.push(`from ${source}`);
|
|
681
|
+
out.push(` ${profile.padEnd(width)} ${notes.join(", ")}`);
|
|
682
|
+
if (spec.description)
|
|
683
|
+
out.push(` ${" ".repeat(width)} ${spec.description}`);
|
|
684
|
+
const instances = Object.keys(spec.instances ?? {});
|
|
685
|
+
if (instances.length > 0)
|
|
686
|
+
out.push(` ${" ".repeat(width)} instances: ${instances.join(", ")}`);
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
process.stdout.write(`${out.join(`
|
|
690
|
+
`)}
|
|
691
|
+
`);
|
|
692
|
+
return Exit.Ok;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// src/cli/profile.ts
|
|
696
|
+
function profileOf(args, defaults) {
|
|
697
|
+
return args.profile ?? defaults.defaultProfile ?? defaults.profileOrder?.[0] ?? "modless";
|
|
698
|
+
}
|
|
541
699
|
|
|
542
700
|
// src/cli/help.ts
|
|
543
701
|
var NAME = "gamecrate";
|
|
544
702
|
function flags() {
|
|
545
|
-
return buildProgram().options;
|
|
703
|
+
return buildProgram().options.filter((o) => !o.hidden);
|
|
546
704
|
}
|
|
547
705
|
function renderHelp(topic, config) {
|
|
548
706
|
if (!topic)
|
|
@@ -712,13 +870,21 @@ ${fn} "$@"
|
|
|
712
870
|
}
|
|
713
871
|
|
|
714
872
|
// src/cli/output.ts
|
|
715
|
-
import { closeSync, existsSync as existsSync2, lstatSync, mkdirSync, openSync, readdirSync, rmSync, symlinkSync, unlinkSync, writeSync } from "node:fs";
|
|
716
|
-
import { basename as
|
|
873
|
+
import { closeSync, existsSync as existsSync2, lstatSync, mkdirSync as mkdirSync2, openSync, readdirSync, rmSync, symlinkSync, unlinkSync, writeSync } from "node:fs";
|
|
874
|
+
import { basename as basename3, join as join4, resolve } from "node:path";
|
|
875
|
+
|
|
876
|
+
// src/docker/run.ts
|
|
877
|
+
import { spawn } from "node:child_process";
|
|
878
|
+
import { createWriteStream, mkdirSync } from "node:fs";
|
|
879
|
+
import { open, readdir, stat } from "node:fs/promises";
|
|
880
|
+
import { join as join2 } from "node:path";
|
|
881
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
882
|
+
import { TextDecoder } from "node:util";
|
|
717
883
|
|
|
718
884
|
// src/docker/spec.ts
|
|
719
885
|
import { existsSync, realpathSync } from "node:fs";
|
|
720
886
|
import { homedir, hostname } from "node:os";
|
|
721
|
-
import { basename, join } from "node:path";
|
|
887
|
+
import { basename as basename2, join } from "node:path";
|
|
722
888
|
var CONTAINER_RUNTIME_DIR = "/tmp/xdg";
|
|
723
889
|
var CONTAINER_LOG_DIR = "/logs";
|
|
724
890
|
var X11_SOCKET_DIR = "/tmp/.X11-unix";
|
|
@@ -956,7 +1122,7 @@ function waylandSocket() {
|
|
|
956
1122
|
const source = display.startsWith("/") ? display : runtime ? join(runtime, display) : null;
|
|
957
1123
|
if (!source || !existsSync(source))
|
|
958
1124
|
return null;
|
|
959
|
-
return { source, name:
|
|
1125
|
+
return { source, name: basename2(source) };
|
|
960
1126
|
}
|
|
961
1127
|
function audioSockets() {
|
|
962
1128
|
const runtime = process.env.XDG_RUNTIME_DIR;
|
|
@@ -978,9 +1144,150 @@ function hostPath(path) {
|
|
|
978
1144
|
}
|
|
979
1145
|
}
|
|
980
1146
|
|
|
1147
|
+
// src/docker/run.ts
|
|
1148
|
+
function spawnArgv(argv, stdio, detached = false) {
|
|
1149
|
+
return spawn(argv[0], argv.slice(1), { stdio, detached });
|
|
1150
|
+
}
|
|
1151
|
+
function exited(proc) {
|
|
1152
|
+
return new Promise((resolve, reject) => {
|
|
1153
|
+
proc.once("error", reject);
|
|
1154
|
+
proc.once("close", (code) => resolve(code ?? 1));
|
|
1155
|
+
});
|
|
1156
|
+
}
|
|
1157
|
+
async function collect2(stream) {
|
|
1158
|
+
const chunks = [];
|
|
1159
|
+
for await (const chunk of stream)
|
|
1160
|
+
chunks.push(chunk);
|
|
1161
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
1162
|
+
}
|
|
1163
|
+
async function capture(argv) {
|
|
1164
|
+
try {
|
|
1165
|
+
const proc = spawnArgv(argv, ["ignore", "pipe", "pipe"]);
|
|
1166
|
+
const [stdout, stderr, code] = await Promise.all([
|
|
1167
|
+
collect2(proc.stdout),
|
|
1168
|
+
collect2(proc.stderr),
|
|
1169
|
+
exited(proc)
|
|
1170
|
+
]);
|
|
1171
|
+
return { code, stdout, stderr };
|
|
1172
|
+
} catch (error) {
|
|
1173
|
+
return { code: 127, stdout: "", stderr: error instanceof Error ? error.message : String(error) };
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
var STDOUT_LOG = "stdout.log";
|
|
1177
|
+
var MARKER_POLL_MS = 200;
|
|
1178
|
+
async function runContainer(spec, opts) {
|
|
1179
|
+
const stopTimeout = opts.stopTimeoutSeconds ?? 10;
|
|
1180
|
+
mkdirSync(opts.logDir, { recursive: true });
|
|
1181
|
+
const sink = createWriteStream(join2(opts.logDir, STDOUT_LOG));
|
|
1182
|
+
const proc = spawnArgv(["docker", ...toDockerArgs(spec)], ["inherit", "pipe", "pipe"]);
|
|
1183
|
+
let interrupted = false;
|
|
1184
|
+
const onSignal = () => {
|
|
1185
|
+
if (interrupted)
|
|
1186
|
+
return;
|
|
1187
|
+
interrupted = true;
|
|
1188
|
+
stopContainer(spec.name, stopTimeout);
|
|
1189
|
+
};
|
|
1190
|
+
process.on("SIGINT", onSignal);
|
|
1191
|
+
process.on("SIGTERM", onSignal);
|
|
1192
|
+
const code = exited(proc);
|
|
1193
|
+
try {
|
|
1194
|
+
await Promise.all([
|
|
1195
|
+
tee(proc.stdout, sink, process.stdout),
|
|
1196
|
+
tee(proc.stderr, sink, process.stderr)
|
|
1197
|
+
]);
|
|
1198
|
+
const status = await code;
|
|
1199
|
+
return interrupted ? Exit.Interrupted : status;
|
|
1200
|
+
} finally {
|
|
1201
|
+
process.off("SIGINT", onSignal);
|
|
1202
|
+
process.off("SIGTERM", onSignal);
|
|
1203
|
+
await new Promise((resolve) => sink.end(resolve));
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
var STOP_TIMEOUT_SECONDS = 10;
|
|
1207
|
+
async function stopContainer(name, timeoutSeconds) {
|
|
1208
|
+
const proc = spawnArgv(["docker", "stop", "--timeout", String(timeoutSeconds), name], "ignore");
|
|
1209
|
+
await exited(proc).catch(() => {});
|
|
1210
|
+
}
|
|
1211
|
+
async function waitForMarker(sources, marker, timeoutSeconds) {
|
|
1212
|
+
const deadline = Date.now() + timeoutSeconds * 1000;
|
|
1213
|
+
const carry = Math.max(marker.length - 1, 0);
|
|
1214
|
+
const seen = new Map;
|
|
1215
|
+
const startedAt = Date.now();
|
|
1216
|
+
while (true) {
|
|
1217
|
+
for (const path of await expandSources(sources)) {
|
|
1218
|
+
let state = seen.get(path);
|
|
1219
|
+
if (state === undefined) {
|
|
1220
|
+
state = { offset: await staleSize(path, startedAt), tail: "", decoder: new TextDecoder };
|
|
1221
|
+
seen.set(path, state);
|
|
1222
|
+
}
|
|
1223
|
+
if (await scan(path, state, marker, carry))
|
|
1224
|
+
return true;
|
|
1225
|
+
}
|
|
1226
|
+
if (Date.now() >= deadline)
|
|
1227
|
+
return false;
|
|
1228
|
+
await sleep(Math.min(MARKER_POLL_MS, Math.max(deadline - Date.now(), 0)));
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
async function staleSize(path, startedAt) {
|
|
1232
|
+
return stat(path).then((info) => info.mtimeMs < startedAt ? info.size : 0, () => 0);
|
|
1233
|
+
}
|
|
1234
|
+
async function scan(path, state, marker, carry) {
|
|
1235
|
+
const handle = await open(path, "r").catch(() => null);
|
|
1236
|
+
if (handle === null)
|
|
1237
|
+
return false;
|
|
1238
|
+
try {
|
|
1239
|
+
const { size } = await handle.stat();
|
|
1240
|
+
if (size < state.offset) {
|
|
1241
|
+
state.offset = 0;
|
|
1242
|
+
state.tail = "";
|
|
1243
|
+
state.decoder = new TextDecoder;
|
|
1244
|
+
}
|
|
1245
|
+
if (size <= state.offset)
|
|
1246
|
+
return false;
|
|
1247
|
+
const buffer = Buffer.alloc(size - state.offset);
|
|
1248
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, state.offset);
|
|
1249
|
+
state.offset += bytesRead;
|
|
1250
|
+
const text = state.tail + state.decoder.decode(buffer.subarray(0, bytesRead), { stream: true });
|
|
1251
|
+
if (text.includes(marker))
|
|
1252
|
+
return true;
|
|
1253
|
+
state.tail = carry > 0 ? text.slice(-carry) : "";
|
|
1254
|
+
return false;
|
|
1255
|
+
} catch {
|
|
1256
|
+
return false;
|
|
1257
|
+
} finally {
|
|
1258
|
+
await handle.close().catch(() => {});
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
async function expandSources(sources) {
|
|
1262
|
+
const out = [];
|
|
1263
|
+
for (const source of sources) {
|
|
1264
|
+
const info = await stat(source).catch(() => null);
|
|
1265
|
+
if (info === null) {
|
|
1266
|
+
out.push(source);
|
|
1267
|
+
continue;
|
|
1268
|
+
}
|
|
1269
|
+
if (!info.isDirectory()) {
|
|
1270
|
+
out.push(source);
|
|
1271
|
+
continue;
|
|
1272
|
+
}
|
|
1273
|
+
const entries = await readdir(source).catch(() => []);
|
|
1274
|
+
for (const entry of entries) {
|
|
1275
|
+
if (entry.toLowerCase().endsWith(".log"))
|
|
1276
|
+
out.push(join2(source, entry));
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
return out;
|
|
1280
|
+
}
|
|
1281
|
+
async function tee(stream, sink, mirror) {
|
|
1282
|
+
for await (const chunk of stream) {
|
|
1283
|
+
mirror.write(chunk);
|
|
1284
|
+
sink.write(chunk);
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
|
|
981
1288
|
// src/mods/staleness.ts
|
|
982
|
-
import { readdir, stat } from "node:fs/promises";
|
|
983
|
-
import { join as
|
|
1289
|
+
import { readdir as readdir2, stat as stat2 } from "node:fs/promises";
|
|
1290
|
+
import { join as join3, relative } from "node:path";
|
|
984
1291
|
var SKIP_DIRS = new Set([".git", ".retired", ".vs", "bin", "node_modules", "obj"]);
|
|
985
1292
|
var ENTRY_LIMIT = 20000;
|
|
986
1293
|
async function scanBuildTimes(dir) {
|
|
@@ -989,14 +1296,14 @@ async function scanBuildTimes(dir) {
|
|
|
989
1296
|
const walk = async (current, inAssemblies) => {
|
|
990
1297
|
let entries;
|
|
991
1298
|
try {
|
|
992
|
-
entries = await
|
|
1299
|
+
entries = await readdir2(current, { withFileTypes: true });
|
|
993
1300
|
} catch {
|
|
994
1301
|
return;
|
|
995
1302
|
}
|
|
996
1303
|
for (const entry of entries) {
|
|
997
1304
|
if (budget-- <= 0)
|
|
998
1305
|
return;
|
|
999
|
-
const path =
|
|
1306
|
+
const path = join3(current, entry.name);
|
|
1000
1307
|
if (entry.isDirectory()) {
|
|
1001
1308
|
if (SKIP_DIRS.has(entry.name.toLowerCase()))
|
|
1002
1309
|
continue;
|
|
@@ -1012,7 +1319,7 @@ async function scanBuildTimes(dir) {
|
|
|
1012
1319
|
continue;
|
|
1013
1320
|
let mtimeMs;
|
|
1014
1321
|
try {
|
|
1015
|
-
mtimeMs = (await
|
|
1322
|
+
mtimeMs = (await stat2(path)).mtimeMs;
|
|
1016
1323
|
} catch {
|
|
1017
1324
|
continue;
|
|
1018
1325
|
}
|
|
@@ -1080,8 +1387,8 @@ function status(message) {
|
|
|
1080
1387
|
function warn(message) {
|
|
1081
1388
|
process.stderr.write(line(`warning: ${message}`));
|
|
1082
1389
|
}
|
|
1083
|
-
function redirectOutput(path) {
|
|
1084
|
-
const fd = openSync(path, "w");
|
|
1390
|
+
function redirectOutput(path, keep = false) {
|
|
1391
|
+
const fd = openSync(path, keep ? "a" : "w");
|
|
1085
1392
|
const stdout = process.stdout.write;
|
|
1086
1393
|
const stderr = process.stderr.write;
|
|
1087
1394
|
let open = true;
|
|
@@ -1115,8 +1422,7 @@ function line(message) {
|
|
|
1115
1422
|
}
|
|
1116
1423
|
function reportProblems(problems) {
|
|
1117
1424
|
if (problems.length === 0) {
|
|
1118
|
-
|
|
1119
|
-
process.exit(Exit.Resolution);
|
|
1425
|
+
throw new GamecrateError("resolution failed with no reported detail", Exit.Resolution);
|
|
1120
1426
|
}
|
|
1121
1427
|
const groups = new Map;
|
|
1122
1428
|
for (const problem of problems) {
|
|
@@ -1126,7 +1432,7 @@ function reportProblems(problems) {
|
|
|
1126
1432
|
else
|
|
1127
1433
|
groups.set(problem.where, [problem]);
|
|
1128
1434
|
}
|
|
1129
|
-
const out = [
|
|
1435
|
+
const out = [];
|
|
1130
1436
|
for (const [where, group] of groups) {
|
|
1131
1437
|
out.push(` ${where}`);
|
|
1132
1438
|
for (const problem of group) {
|
|
@@ -1135,10 +1441,8 @@ function reportProblems(problems) {
|
|
|
1135
1441
|
out.push(` did you mean ${problem.suggestion}?`);
|
|
1136
1442
|
}
|
|
1137
1443
|
}
|
|
1138
|
-
|
|
1139
|
-
`)
|
|
1140
|
-
`);
|
|
1141
|
-
process.exit(Exit.Resolution);
|
|
1444
|
+
throw new GamecrateError(`${problems.length} problem${problems.length === 1 ? "" : "s"}`, Exit.Resolution, out.join(`
|
|
1445
|
+
`));
|
|
1142
1446
|
}
|
|
1143
1447
|
function planWarnings(plan) {
|
|
1144
1448
|
if (!plan.warnOnStale)
|
|
@@ -1212,10 +1516,10 @@ function runTimestamp(now = new Date) {
|
|
|
1212
1516
|
return now.toISOString().replace(/[-:.]/g, "");
|
|
1213
1517
|
}
|
|
1214
1518
|
function openRunLog(logsDir, now) {
|
|
1215
|
-
const runsDir =
|
|
1216
|
-
|
|
1519
|
+
const runsDir = join4(logsDir, "runs");
|
|
1520
|
+
mkdirSync2(runsDir, { recursive: true });
|
|
1217
1521
|
const dir = uniqueRunDir(runsDir, runTimestamp(now));
|
|
1218
|
-
|
|
1522
|
+
mkdirSync2(dir);
|
|
1219
1523
|
linkCurrent(logsDir, dir);
|
|
1220
1524
|
rotateRuns(logsDir, 10);
|
|
1221
1525
|
return dir;
|
|
@@ -1225,40 +1529,66 @@ function append(fd, chunk) {
|
|
|
1225
1529
|
writeSync(fd, typeof chunk === "string" ? encoder.encode(chunk) : chunk);
|
|
1226
1530
|
}
|
|
1227
1531
|
function uniqueRunDir(runsDir, stamp) {
|
|
1228
|
-
let candidate =
|
|
1532
|
+
let candidate = join4(runsDir, stamp);
|
|
1229
1533
|
for (let n = 2;existsSync2(candidate); n++)
|
|
1230
|
-
candidate =
|
|
1534
|
+
candidate = join4(runsDir, `${stamp}-${n}`);
|
|
1231
1535
|
return candidate;
|
|
1232
1536
|
}
|
|
1537
|
+
var WAIT_NOTICE = { firstMs: 2000, everyMs: 30000 };
|
|
1538
|
+
function waitNotice(waitedMs, lastNoticeMs, schedule = WAIT_NOTICE) {
|
|
1539
|
+
if (waitedMs < schedule.firstMs)
|
|
1540
|
+
return;
|
|
1541
|
+
if (lastNoticeMs > 0 && waitedMs - lastNoticeMs < schedule.everyMs)
|
|
1542
|
+
return;
|
|
1543
|
+
return waitedMs < 60000 ? `${Math.floor(waitedMs / 1000)}s` : `${Math.floor(waitedMs / 60000)}m`;
|
|
1544
|
+
}
|
|
1545
|
+
function runStartedAt(name) {
|
|
1546
|
+
const m = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(\d{3})Z/.exec(name);
|
|
1547
|
+
if (m === null)
|
|
1548
|
+
return;
|
|
1549
|
+
const at = Date.parse(`${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:${m[6]}.${m[7]}Z`);
|
|
1550
|
+
return Number.isNaN(at) ? undefined : at;
|
|
1551
|
+
}
|
|
1552
|
+
function currentLog(instanceDir) {
|
|
1553
|
+
return join4(instanceDir, "logs", "current", STDOUT_LOG);
|
|
1554
|
+
}
|
|
1555
|
+
function tailArgv(file, fromStart, livePid) {
|
|
1556
|
+
const argv = ["tail"];
|
|
1557
|
+
if (fromStart)
|
|
1558
|
+
argv.push("-n", "+1");
|
|
1559
|
+
if (livePid !== undefined)
|
|
1560
|
+
argv.push("-f", "--pid", String(livePid));
|
|
1561
|
+
argv.push(file);
|
|
1562
|
+
return argv;
|
|
1563
|
+
}
|
|
1233
1564
|
function linkCurrent(logsDir, target) {
|
|
1234
|
-
const link =
|
|
1565
|
+
const link = join4(logsDir, "current");
|
|
1235
1566
|
try {
|
|
1236
1567
|
lstatSync(link);
|
|
1237
1568
|
unlinkSync(link);
|
|
1238
1569
|
} catch {}
|
|
1239
|
-
symlinkSync(
|
|
1570
|
+
symlinkSync(join4("runs", basename3(target)), link, "dir");
|
|
1240
1571
|
}
|
|
1241
1572
|
function rotateRuns(logsDir, keep) {
|
|
1242
|
-
const runsDir =
|
|
1573
|
+
const runsDir = join4(logsDir, "runs");
|
|
1243
1574
|
if (keep < 1 || !existsSync2(runsDir))
|
|
1244
1575
|
return [];
|
|
1245
1576
|
const dirs = readdirSync(runsDir, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort().reverse();
|
|
1246
1577
|
const removed = dirs.slice(keep);
|
|
1247
1578
|
for (const name of removed)
|
|
1248
|
-
rmSync(
|
|
1579
|
+
rmSync(join4(runsDir, name), { recursive: true, force: true });
|
|
1249
1580
|
return removed;
|
|
1250
1581
|
}
|
|
1251
1582
|
|
|
1252
1583
|
// src/config/load.ts
|
|
1253
|
-
import { parse as parseYaml } from "yaml";
|
|
1254
1584
|
import { z as z2 } from "zod";
|
|
1255
|
-
import { access, readdir as
|
|
1585
|
+
import { access, readdir as readdir3, readFile as readFile2 } from "node:fs/promises";
|
|
1256
1586
|
import { homedir as homedir2 } from "node:os";
|
|
1257
|
-
import { dirname as dirname2, join as
|
|
1587
|
+
import { basename as basename4, dirname as dirname2, join as join6, resolve as resolve3 } from "node:path";
|
|
1258
1588
|
|
|
1259
1589
|
// src/plugin.ts
|
|
1260
1590
|
import { readFileSync, statSync } from "node:fs";
|
|
1261
|
-
import { dirname, isAbsolute, join as
|
|
1591
|
+
import { dirname, isAbsolute, join as join5, resolve as resolve2 } from "node:path";
|
|
1262
1592
|
import { pathToFileURL } from "node:url";
|
|
1263
1593
|
import { exports as exportsField, legacy } from "resolve.exports";
|
|
1264
1594
|
var PLUGIN_API_VERSION = 1;
|
|
@@ -1274,7 +1604,7 @@ function fail(spec, message, detail) {
|
|
|
1274
1604
|
function entryOf(dir) {
|
|
1275
1605
|
let manifest;
|
|
1276
1606
|
try {
|
|
1277
|
-
manifest = JSON.parse(readFileSync(
|
|
1607
|
+
manifest = JSON.parse(readFileSync(join5(dir, "package.json"), "utf8"));
|
|
1278
1608
|
} catch {
|
|
1279
1609
|
return resolve2(dir, "index.js");
|
|
1280
1610
|
}
|
|
@@ -1288,8 +1618,8 @@ function entryOf(dir) {
|
|
|
1288
1618
|
function packageDir(spec, from) {
|
|
1289
1619
|
let dir = resolve2(from);
|
|
1290
1620
|
for (;; ) {
|
|
1291
|
-
const candidate =
|
|
1292
|
-
if (statSync(
|
|
1621
|
+
const candidate = join5(dir, "node_modules", spec);
|
|
1622
|
+
if (statSync(join5(candidate, "package.json"), { throwIfNoEntry: false })?.isFile())
|
|
1293
1623
|
return candidate;
|
|
1294
1624
|
const parent = dirname(dir);
|
|
1295
1625
|
if (parent === dir)
|
|
@@ -1374,6 +1704,12 @@ var DEFAULT_SETTINGS = {
|
|
|
1374
1704
|
pidsLimit: 1024
|
|
1375
1705
|
};
|
|
1376
1706
|
|
|
1707
|
+
// src/config/read.ts
|
|
1708
|
+
import { readFile } from "node:fs/promises";
|
|
1709
|
+
import { extname } from "node:path";
|
|
1710
|
+
import { parseTree } from "jsonc-parser";
|
|
1711
|
+
import { isMap, parse as parseYaml, parseDocument } from "yaml";
|
|
1712
|
+
|
|
1377
1713
|
// src/config/jsonc.ts
|
|
1378
1714
|
import { parse, printParseErrorCode } from "jsonc-parser";
|
|
1379
1715
|
function parseJsonc(text) {
|
|
@@ -1386,6 +1722,59 @@ function parseJsonc(text) {
|
|
|
1386
1722
|
return value;
|
|
1387
1723
|
}
|
|
1388
1724
|
|
|
1725
|
+
// src/config/read.ts
|
|
1726
|
+
var CONFIG_SUFFIXES = [".yml", ".yaml", ".json", ".jsonc"];
|
|
1727
|
+
function isYaml(path) {
|
|
1728
|
+
const suffix = extname(path).toLowerCase();
|
|
1729
|
+
if (suffix === ".yml" || suffix === ".yaml")
|
|
1730
|
+
return true;
|
|
1731
|
+
if (suffix === ".json" || suffix === ".jsonc")
|
|
1732
|
+
return false;
|
|
1733
|
+
throw new GamecrateError(`config is not a format gamecrate reads: ${path}`, Exit.Config, `use one of ${CONFIG_SUFFIXES.join(", ")}`);
|
|
1734
|
+
}
|
|
1735
|
+
function readConfigText(text, path) {
|
|
1736
|
+
if (!isYaml(path)) {
|
|
1737
|
+
try {
|
|
1738
|
+
return parseJsonc(text);
|
|
1739
|
+
} catch (error) {
|
|
1740
|
+
if (error instanceof GamecrateError) {
|
|
1741
|
+
throw new GamecrateError(`${error.message}: ${path}`, error.code, error.detail);
|
|
1742
|
+
}
|
|
1743
|
+
throw error;
|
|
1744
|
+
}
|
|
1745
|
+
}
|
|
1746
|
+
try {
|
|
1747
|
+
return parseYaml(text);
|
|
1748
|
+
} catch (error) {
|
|
1749
|
+
throw new GamecrateError(`config is invalid: ${path}`, Exit.Config, error.message);
|
|
1750
|
+
}
|
|
1751
|
+
}
|
|
1752
|
+
async function readConfigFile(path) {
|
|
1753
|
+
let text;
|
|
1754
|
+
try {
|
|
1755
|
+
text = await readFile(path, "utf8");
|
|
1756
|
+
} catch (error) {
|
|
1757
|
+
if (error.code === "ENOENT")
|
|
1758
|
+
return;
|
|
1759
|
+
throw error;
|
|
1760
|
+
}
|
|
1761
|
+
return readConfigText(text, path);
|
|
1762
|
+
}
|
|
1763
|
+
function orderedKeys(text, path, key) {
|
|
1764
|
+
if (isYaml(path)) {
|
|
1765
|
+
const node = parseDocument(text).get(key, true);
|
|
1766
|
+
if (!isMap(node))
|
|
1767
|
+
return [];
|
|
1768
|
+
return node.items.map((item) => String(item.key.value ?? item.key));
|
|
1769
|
+
}
|
|
1770
|
+
const root = parseTree(text);
|
|
1771
|
+
const holder = root?.children?.find((child) => child.children?.[0]?.value === key);
|
|
1772
|
+
const value = holder?.children?.[1];
|
|
1773
|
+
if (value?.type !== "object")
|
|
1774
|
+
return [];
|
|
1775
|
+
return (value.children ?? []).map((prop) => String(prop.children?.[0]?.value));
|
|
1776
|
+
}
|
|
1777
|
+
|
|
1389
1778
|
// src/config/validate.ts
|
|
1390
1779
|
import { z } from "zod";
|
|
1391
1780
|
var MODES2 = ["headed", "headless", "screenshot"];
|
|
@@ -1399,7 +1788,11 @@ function obj(shape) {
|
|
|
1399
1788
|
function hintsFor(message) {
|
|
1400
1789
|
if (!message.startsWith(HINTS))
|
|
1401
1790
|
return [];
|
|
1402
|
-
|
|
1791
|
+
try {
|
|
1792
|
+
return JSON.parse(message.slice(HINTS.length));
|
|
1793
|
+
} catch {
|
|
1794
|
+
return [];
|
|
1795
|
+
}
|
|
1403
1796
|
}
|
|
1404
1797
|
function requiredWhen(key, when) {
|
|
1405
1798
|
return (ctx) => {
|
|
@@ -1491,7 +1884,11 @@ var profile = obj({
|
|
|
1491
1884
|
error: "expected an object"
|
|
1492
1885
|
}).optional(),
|
|
1493
1886
|
alias: str.optional(),
|
|
1494
|
-
aliases: strArray.optional()
|
|
1887
|
+
aliases: strArray.optional(),
|
|
1888
|
+
description: str.optional(),
|
|
1889
|
+
detach: bool.optional(),
|
|
1890
|
+
replace: bool.optional(),
|
|
1891
|
+
build: oneOf(["auto", "always", "never"]).optional()
|
|
1495
1892
|
}).check((ctx) => {
|
|
1496
1893
|
const v = ctx.value;
|
|
1497
1894
|
if (v.alias !== undefined && (v.extends !== undefined || v.mods !== undefined)) {
|
|
@@ -1556,19 +1953,19 @@ function validateConfig(cfg) {
|
|
|
1556
1953
|
problems.push({ where: "", message: "expected the config to be an object" });
|
|
1557
1954
|
return { config: { dataRoot: "", games: {} }, problems };
|
|
1558
1955
|
}
|
|
1559
|
-
|
|
1956
|
+
collect3(problems, "", root, cfg);
|
|
1560
1957
|
const games = cfg["games"];
|
|
1561
1958
|
if (!isObj(games)) {
|
|
1562
1959
|
problems.push({ where: "/games", message: 'missing required key "games", or it is not an object' });
|
|
1563
1960
|
return { config: cfg, problems };
|
|
1564
1961
|
}
|
|
1565
1962
|
for (const [name, entry] of Object.entries(games)) {
|
|
1566
|
-
|
|
1963
|
+
collect3(problems, `/games/${esc(name)}`, game, entry);
|
|
1567
1964
|
}
|
|
1568
1965
|
crossReference(problems, games);
|
|
1569
1966
|
return { config: cfg, problems };
|
|
1570
1967
|
}
|
|
1571
|
-
function
|
|
1968
|
+
function collect3(problems, prefix, schema, value) {
|
|
1572
1969
|
const result = schema.safeParse(value);
|
|
1573
1970
|
if (result.success)
|
|
1574
1971
|
return;
|
|
@@ -1612,6 +2009,7 @@ function valueAt(root_, path) {
|
|
|
1612
2009
|
return current;
|
|
1613
2010
|
}
|
|
1614
2011
|
function crossReference(p, games) {
|
|
2012
|
+
const containers = new Map;
|
|
1615
2013
|
for (const [gameName, game_] of Object.entries(games)) {
|
|
1616
2014
|
const where = `/games/${esc(gameName)}`;
|
|
1617
2015
|
checkName(p, where, gameName, "game");
|
|
@@ -1627,7 +2025,7 @@ function crossReference(p, games) {
|
|
|
1627
2025
|
if (!isObj(prof))
|
|
1628
2026
|
continue;
|
|
1629
2027
|
const parent = prof["extends"];
|
|
1630
|
-
if (typeof parent === "string" && !
|
|
2028
|
+
if (typeof parent === "string" && !resolves(profiles, parent)) {
|
|
1631
2029
|
const prob = { where: `${w}/extends`, message: `extends unknown profile "${parent}"` };
|
|
1632
2030
|
const hint = suggest(parent, names);
|
|
1633
2031
|
if (hint)
|
|
@@ -1635,14 +2033,14 @@ function crossReference(p, games) {
|
|
|
1635
2033
|
p.push(prob);
|
|
1636
2034
|
}
|
|
1637
2035
|
const alias = prof["alias"];
|
|
1638
|
-
if (typeof alias === "string" &&
|
|
2036
|
+
if (typeof alias === "string" && !resolves(profiles, alias)) {
|
|
1639
2037
|
const prob = { where: `${w}/alias`, message: `alias of unknown profile "${alias}"` };
|
|
1640
2038
|
const hint = suggest(alias, [...names, "modless"]);
|
|
1641
2039
|
if (hint)
|
|
1642
2040
|
prob.suggestion = `did you mean "${hint}"?`;
|
|
1643
2041
|
p.push(prob);
|
|
1644
2042
|
}
|
|
1645
|
-
if (typeof alias === "string" && alias === name) {
|
|
2043
|
+
if (typeof alias === "string" && alias.toLowerCase() === name.toLowerCase()) {
|
|
1646
2044
|
p.push({ where: `${w}/alias`, message: "a profile cannot alias itself" });
|
|
1647
2045
|
}
|
|
1648
2046
|
const aliases = prof["aliases"];
|
|
@@ -1664,10 +2062,81 @@ function crossReference(p, games) {
|
|
|
1664
2062
|
}
|
|
1665
2063
|
}
|
|
1666
2064
|
}
|
|
2065
|
+
checkCollisions(p, where, gameName, profiles, containers);
|
|
2066
|
+
}
|
|
2067
|
+
}
|
|
2068
|
+
function resolves(profiles, name) {
|
|
2069
|
+
if (name.toLowerCase() === "modless")
|
|
2070
|
+
return true;
|
|
2071
|
+
return profileKey({ profiles }, name) !== undefined;
|
|
2072
|
+
}
|
|
2073
|
+
function checkCollisions(p, where, gameName, profiles, containers) {
|
|
2074
|
+
const names = new Map;
|
|
2075
|
+
const aliasOwners = new Map;
|
|
2076
|
+
const prefix = `${gameName.toLowerCase()}-`;
|
|
2077
|
+
for (const [name, prof] of Object.entries(profiles)) {
|
|
2078
|
+
const w = `${where}/profiles/${esc(name)}`;
|
|
2079
|
+
const lower = name.toLowerCase();
|
|
2080
|
+
const twin = names.get(lower);
|
|
2081
|
+
if (twin !== undefined) {
|
|
2082
|
+
p.push({
|
|
2083
|
+
where: w,
|
|
2084
|
+
message: `profile "${name}" differs from "${twin}" only in case, so both share one data directory`
|
|
2085
|
+
});
|
|
2086
|
+
continue;
|
|
2087
|
+
}
|
|
2088
|
+
names.set(lower, name);
|
|
2089
|
+
const clash = containers.get(prefix + lower);
|
|
2090
|
+
if (clash !== undefined) {
|
|
2091
|
+
p.push({ where: w, message: `container name collides with ${clash}` });
|
|
2092
|
+
continue;
|
|
2093
|
+
}
|
|
2094
|
+
containers.set(prefix + lower, w);
|
|
2095
|
+
if (!isObj(prof))
|
|
2096
|
+
continue;
|
|
2097
|
+
const aliases = prof["aliases"];
|
|
2098
|
+
if (Array.isArray(aliases)) {
|
|
2099
|
+
for (const [i, entry] of aliases.entries()) {
|
|
2100
|
+
if (typeof entry !== "string")
|
|
2101
|
+
continue;
|
|
2102
|
+
const owner = aliasOwners.get(entry.toLowerCase());
|
|
2103
|
+
if (owner !== undefined) {
|
|
2104
|
+
p.push({
|
|
2105
|
+
where: `${w}/aliases/${i}`,
|
|
2106
|
+
message: `alias "${entry}" is already declared by profile "${owner}"`
|
|
2107
|
+
});
|
|
2108
|
+
continue;
|
|
2109
|
+
}
|
|
2110
|
+
aliasOwners.set(entry.toLowerCase(), name);
|
|
2111
|
+
}
|
|
2112
|
+
}
|
|
2113
|
+
const declared = prof["instances"];
|
|
2114
|
+
for (const instance of instanceNames(profiles, name, prof)) {
|
|
2115
|
+
const container = `${prefix}${lower}-${instance.toLowerCase()}`;
|
|
2116
|
+
const at = isObj(declared) && Object.hasOwn(declared, instance) ? `${w}/instances/${esc(instance)}` : w;
|
|
2117
|
+
const first = containers.get(container);
|
|
2118
|
+
if (first !== undefined) {
|
|
2119
|
+
p.push({
|
|
2120
|
+
where: at,
|
|
2121
|
+
message: `instance "${instance}" makes a container name that collides with ${first}`
|
|
2122
|
+
});
|
|
2123
|
+
continue;
|
|
2124
|
+
}
|
|
2125
|
+
containers.set(container, at);
|
|
2126
|
+
}
|
|
2127
|
+
}
|
|
2128
|
+
}
|
|
2129
|
+
function instanceNames(profiles, name, prof) {
|
|
2130
|
+
try {
|
|
2131
|
+
const resolved = resolveProfile({ profiles }, name).instances;
|
|
2132
|
+
return isObj(resolved) ? Object.keys(resolved) : [];
|
|
2133
|
+
} catch {
|
|
2134
|
+
const declared = prof["instances"];
|
|
2135
|
+
return isObj(declared) ? Object.keys(declared) : [];
|
|
1667
2136
|
}
|
|
1668
2137
|
}
|
|
1669
2138
|
function checkName(p, where, name, kind) {
|
|
1670
|
-
if (RESERVED_NAMES.includes(name)) {
|
|
2139
|
+
if (RESERVED_NAMES.includes(name.toLowerCase())) {
|
|
1671
2140
|
p.push({ where, message: `"${name}" is a reserved name and cannot be used as a ${kind} name` });
|
|
1672
2141
|
return;
|
|
1673
2142
|
}
|
|
@@ -1683,11 +2152,32 @@ function isObj(v) {
|
|
|
1683
2152
|
}
|
|
1684
2153
|
|
|
1685
2154
|
// src/config/load.ts
|
|
1686
|
-
function
|
|
1687
|
-
const base = process.env["XDG_CONFIG_HOME"] ??
|
|
1688
|
-
return
|
|
2155
|
+
function globalConfigDir() {
|
|
2156
|
+
const base = process.env["XDG_CONFIG_HOME"] ?? join6(homedir2(), ".config");
|
|
2157
|
+
return join6(base, "gamecrate");
|
|
2158
|
+
}
|
|
2159
|
+
async function findGlobalConfig() {
|
|
2160
|
+
return probe(globalConfigDir(), "profiles");
|
|
2161
|
+
}
|
|
2162
|
+
async function probe(dir, stem) {
|
|
2163
|
+
const found = [];
|
|
2164
|
+
for (const suffix of CONFIG_SUFFIXES) {
|
|
2165
|
+
const file = join6(dir, `${stem}${suffix}`);
|
|
2166
|
+
try {
|
|
2167
|
+
await access(file);
|
|
2168
|
+
found.push(file);
|
|
2169
|
+
} catch (error) {
|
|
2170
|
+
if (error.code !== "ENOENT")
|
|
2171
|
+
throw error;
|
|
2172
|
+
}
|
|
2173
|
+
}
|
|
2174
|
+
if (found.length > 1) {
|
|
2175
|
+
throw new GamecrateError(`two configs in ${dir}`, Exit.Config, `${found.map((f) => ` ${basename4(f)}`).join(`
|
|
2176
|
+
`)}
|
|
2177
|
+
keep one`);
|
|
2178
|
+
}
|
|
2179
|
+
return found[0];
|
|
1689
2180
|
}
|
|
1690
|
-
var PROJECT_CONFIG = ".gamecrate.yml";
|
|
1691
2181
|
var projectName = z2.custom((v) => typeof v === "string" && NAME_PATTERN.test(v), "expected a name");
|
|
1692
2182
|
var projectStr = z2.string({ error: "expected a string" });
|
|
1693
2183
|
var projectBool = z2.boolean({ error: "expected true or false" });
|
|
@@ -1704,9 +2194,12 @@ var projectResolution = z2.string({ error: "expected dimensions like 1920x1080"
|
|
|
1704
2194
|
ctx.issues.push({ code: "custom", message: error.message, input: ctx.value });
|
|
1705
2195
|
}
|
|
1706
2196
|
}).transform(parseResolution);
|
|
1707
|
-
var
|
|
2197
|
+
var PROJECT_OBJECT = z2.strictObject({
|
|
1708
2198
|
game: projectName.optional(),
|
|
1709
|
-
|
|
2199
|
+
defaultProfile: projectName.optional(),
|
|
2200
|
+
profiles: z2.record(z2.string(), z2.unknown()).optional(),
|
|
2201
|
+
settings: z2.record(z2.string(), z2.unknown()).optional(),
|
|
2202
|
+
detach: projectBool.optional(),
|
|
1710
2203
|
mods: projectList.optional(),
|
|
1711
2204
|
without: projectList.optional(),
|
|
1712
2205
|
only: projectList.optional(),
|
|
@@ -1735,17 +2228,27 @@ var PROJECT_SCHEMA = z2.strictObject({
|
|
|
1735
2228
|
}).optional(),
|
|
1736
2229
|
resolution: projectResolution.optional()
|
|
1737
2230
|
}, { error: "expected an object" });
|
|
2231
|
+
var PROJECT_SCHEMA = PROJECT_OBJECT.check((ctx) => {
|
|
2232
|
+
const { game, profiles, settings } = ctx.value;
|
|
2233
|
+
if (game !== undefined)
|
|
2234
|
+
return;
|
|
2235
|
+
for (const [key, value] of [["profiles", profiles], ["settings", settings]]) {
|
|
2236
|
+
if (value === undefined)
|
|
2237
|
+
continue;
|
|
2238
|
+
ctx.issues.push({
|
|
2239
|
+
code: "custom",
|
|
2240
|
+
path: [key],
|
|
2241
|
+
message: "needs a top-level game: to say which game it belongs to",
|
|
2242
|
+
input: ctx.value
|
|
2243
|
+
});
|
|
2244
|
+
}
|
|
2245
|
+
});
|
|
1738
2246
|
async function findProjectConfig(start = process.cwd()) {
|
|
1739
2247
|
let dir = resolve3(start);
|
|
1740
2248
|
for (;; ) {
|
|
1741
|
-
const file =
|
|
1742
|
-
|
|
1743
|
-
await access(file);
|
|
2249
|
+
const file = await probe(dir, ".gamecrate");
|
|
2250
|
+
if (file !== undefined)
|
|
1744
2251
|
return file;
|
|
1745
|
-
} catch (error) {
|
|
1746
|
-
if (error.code !== "ENOENT")
|
|
1747
|
-
throw error;
|
|
1748
|
-
}
|
|
1749
2252
|
const parent = dirname2(dir);
|
|
1750
2253
|
if (parent === dir)
|
|
1751
2254
|
return;
|
|
@@ -1756,13 +2259,18 @@ async function loadProjectDefaults(start = process.cwd()) {
|
|
|
1756
2259
|
const file = await findProjectConfig(start);
|
|
1757
2260
|
if (file === undefined)
|
|
1758
2261
|
return {};
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
2262
|
+
const text = await readFile2(file, "utf8");
|
|
2263
|
+
const defaults = validateProjectDefaults(readConfigText(text, file), file);
|
|
2264
|
+
const profiles = defaults.profiles;
|
|
2265
|
+
if (profiles !== undefined) {
|
|
2266
|
+
const order = orderedKeys(text, file, "profiles");
|
|
2267
|
+
if (order.length !== Object.keys(profiles).length || order.some((key) => !Object.hasOwn(profiles, key))) {
|
|
2268
|
+
throw new GamecrateError(`config is invalid: ${file}`, Exit.Config, "duplicate profiles key");
|
|
2269
|
+
}
|
|
2270
|
+
defaults.profileOrder = order;
|
|
1764
2271
|
}
|
|
1765
|
-
|
|
2272
|
+
defaults.configPath = file;
|
|
2273
|
+
return defaults;
|
|
1766
2274
|
}
|
|
1767
2275
|
function validateProjectDefaults(raw, file) {
|
|
1768
2276
|
if (raw === null)
|
|
@@ -1782,18 +2290,9 @@ function validateProjectDefaults(raw, file) {
|
|
|
1782
2290
|
throw new GamecrateError(`project config is invalid: ${file}`, Exit.Config, problems.join(`
|
|
1783
2291
|
`));
|
|
1784
2292
|
}
|
|
1785
|
-
async function loadConfig(path) {
|
|
1786
|
-
const file = path ??
|
|
1787
|
-
|
|
1788
|
-
try {
|
|
1789
|
-
user = parseJsonc(await readFile(file, "utf8"));
|
|
1790
|
-
} catch (err) {
|
|
1791
|
-
if (err instanceof GamecrateError) {
|
|
1792
|
-
throw new GamecrateError(`${err.message}: ${file}`, err.code, err.detail);
|
|
1793
|
-
}
|
|
1794
|
-
if (err.code !== "ENOENT")
|
|
1795
|
-
throw err;
|
|
1796
|
-
}
|
|
2293
|
+
async function loadConfig(path, project) {
|
|
2294
|
+
const file = path ?? await findGlobalConfig() ?? join6(globalConfigDir(), "profiles.yml");
|
|
2295
|
+
const user = await readConfigFile(file);
|
|
1797
2296
|
const specs = isObj(user) && user["plugins"] !== undefined ? user["plugins"] : [];
|
|
1798
2297
|
if (!Array.isArray(specs) || specs.some((s) => typeof s !== "string")) {
|
|
1799
2298
|
throw new GamecrateError(`config is invalid: ${file}`, Exit.Config, " /plugins: expected an array of strings");
|
|
@@ -1804,29 +2303,57 @@ async function loadConfig(path) {
|
|
|
1804
2303
|
defaults: { settings: structuredClone(DEFAULT_SETTINGS) },
|
|
1805
2304
|
games: Object.fromEntries([...plugins].map(([name, plugin]) => [name, structuredClone(plugin.defaults)]))
|
|
1806
2305
|
};
|
|
1807
|
-
const
|
|
2306
|
+
const merged = user === undefined ? base : deepMerge(base, user);
|
|
2307
|
+
const spliced = applyProject(merged, project);
|
|
2308
|
+
const { config, problems } = validateConfig(spliced);
|
|
1808
2309
|
if (problems.length > 0) {
|
|
1809
2310
|
const detail = problems.map((p) => {
|
|
1810
2311
|
const hint = p.suggestion ? ` (${p.suggestion})` : "";
|
|
1811
|
-
return ` ${p.where || "/"}: ${p.message}${hint}${origin(p.where, user, plugins)}`;
|
|
2312
|
+
return ` ${p.where || "/"}: ${p.message}${hint}${origin(p.where, user, plugins, project)}`;
|
|
1812
2313
|
}).join(`
|
|
1813
2314
|
`);
|
|
1814
|
-
const
|
|
1815
|
-
throw new GamecrateError(`config is invalid: ${file}${
|
|
2315
|
+
const from = plugins.size === 0 ? "" : ` (merged with defaults from: ${[...plugins.keys()].join(", ")})`;
|
|
2316
|
+
throw new GamecrateError(`config is invalid: ${file}${from}`, Exit.Config, detail);
|
|
1816
2317
|
}
|
|
1817
2318
|
return { config: expandPaths(config), plugins };
|
|
1818
2319
|
}
|
|
1819
|
-
function
|
|
2320
|
+
function applyProject(config, project) {
|
|
2321
|
+
if (project === undefined)
|
|
2322
|
+
return config;
|
|
2323
|
+
const game = project.game;
|
|
2324
|
+
if (game === undefined)
|
|
2325
|
+
return config;
|
|
2326
|
+
if (project.profiles === undefined && project.settings === undefined)
|
|
2327
|
+
return config;
|
|
2328
|
+
const existing = own(config.games, game);
|
|
2329
|
+
if (existing === undefined) {
|
|
2330
|
+
throw new GamecrateError(`the project config names game "${game}", which is not configured`, Exit.Config, `known games: ${Object.keys(config.games).join(", ") || "none"}`);
|
|
2331
|
+
}
|
|
2332
|
+
const target = {
|
|
2333
|
+
...existing,
|
|
2334
|
+
profiles: { ...existing.profiles, ...project.profiles }
|
|
2335
|
+
};
|
|
2336
|
+
config.games[game] = target;
|
|
2337
|
+
if (project.settings !== undefined) {
|
|
2338
|
+
target.settings = deepMerge(target.settings ?? {}, project.settings);
|
|
2339
|
+
}
|
|
2340
|
+
return config;
|
|
2341
|
+
}
|
|
2342
|
+
function origin(where, user, plugins, project) {
|
|
1820
2343
|
if (!where.startsWith("/"))
|
|
1821
2344
|
return "";
|
|
1822
2345
|
const segments = where.slice(1).split("/").map((s) => s.replace(/~1/g, "/").replace(/~0/g, "~"));
|
|
2346
|
+
const [section, name, sub, ...rest] = segments;
|
|
2347
|
+
const repoGame = project?.game;
|
|
2348
|
+
if (repoGame !== undefined && section === "games" && name === repoGame && (sub === "profiles" || sub === "settings") && valueAt2(sub === "profiles" ? project?.profiles : project?.settings, rest) !== undefined) {
|
|
2349
|
+
return " <- from the .gamecrate project config, not this file";
|
|
2350
|
+
}
|
|
1823
2351
|
if (valueAt2(user, segments) !== undefined)
|
|
1824
2352
|
return "";
|
|
1825
|
-
const [section, name, ...rest] = segments;
|
|
1826
2353
|
const plugin = section === "games" && name !== undefined ? plugins.get(name) : undefined;
|
|
1827
2354
|
if (plugin === undefined)
|
|
1828
2355
|
return " <- not in this file";
|
|
1829
|
-
return valueAt2(plugin.defaults, rest) === undefined ? ` <- not in this file, and the ${name} plugin's defaults do not supply it` : ` <- from the ${name} plugin's defaults, not this file`;
|
|
2356
|
+
return valueAt2(plugin.defaults, [sub, ...rest].filter((s) => s !== undefined)) === undefined ? ` <- not in this file, and the ${name} plugin's defaults do not supply it` : ` <- from the ${name} plugin's defaults, not this file`;
|
|
1830
2357
|
}
|
|
1831
2358
|
function valueAt2(value, segments) {
|
|
1832
2359
|
let current = value;
|
|
@@ -1904,8 +2431,8 @@ function profileDataDir(root, game, profile) {
|
|
|
1904
2431
|
async function profileDirs(root, game, profile) {
|
|
1905
2432
|
if (profile !== undefined)
|
|
1906
2433
|
return [profileDataDir(root, game, profile)];
|
|
1907
|
-
const dir =
|
|
1908
|
-
return (await
|
|
2434
|
+
const dir = join6(expandHome(root.dataRoot), game);
|
|
2435
|
+
return (await readdir3(dir).catch(() => [])).map((name) => join6(dir, name));
|
|
1909
2436
|
}
|
|
1910
2437
|
function profileKey(game, name) {
|
|
1911
2438
|
if (Object.hasOwn(game.profiles, name))
|
|
@@ -1914,7 +2441,7 @@ function profileKey(game, name) {
|
|
|
1914
2441
|
const direct = Object.keys(game.profiles).find((k) => k.toLowerCase() === lower);
|
|
1915
2442
|
if (direct !== undefined)
|
|
1916
2443
|
return direct;
|
|
1917
|
-
return Object.keys(game.profiles).find((k) => (own(game.profiles, k)?.aliases ?? []).some((a) => a.toLowerCase() === lower));
|
|
2444
|
+
return Object.keys(game.profiles).find((k) => (own(game.profiles, k)?.aliases ?? []).some((a) => typeof a === "string" && a.toLowerCase() === lower));
|
|
1918
2445
|
}
|
|
1919
2446
|
function subtract(mods, exclude) {
|
|
1920
2447
|
if (exclude.length === 0)
|
|
@@ -1974,7 +2501,7 @@ function expandPaths(config) {
|
|
|
1974
2501
|
function expandHome(p) {
|
|
1975
2502
|
if (p === "~")
|
|
1976
2503
|
return homedir2();
|
|
1977
|
-
return p.startsWith("~/") ?
|
|
2504
|
+
return p.startsWith("~/") ? join6(homedir2(), p.slice(2)) : p;
|
|
1978
2505
|
}
|
|
1979
2506
|
|
|
1980
2507
|
// src/docker/identity.ts
|
|
@@ -1998,155 +2525,7 @@ function hostUserName(uid) {
|
|
|
1998
2525
|
// src/docker/preflight.ts
|
|
1999
2526
|
import { existsSync as existsSync3, readFileSync as readFileSync2 } from "node:fs";
|
|
2000
2527
|
import { homedir as homedir3 } from "node:os";
|
|
2001
|
-
import { basename as
|
|
2002
|
-
|
|
2003
|
-
// src/docker/run.ts
|
|
2004
|
-
import { spawn } from "node:child_process";
|
|
2005
|
-
import { createWriteStream, mkdirSync as mkdirSync2 } from "node:fs";
|
|
2006
|
-
import { open, readdir as readdir3, stat as stat2 } from "node:fs/promises";
|
|
2007
|
-
import { join as join6 } from "node:path";
|
|
2008
|
-
import { setTimeout as sleep } from "node:timers/promises";
|
|
2009
|
-
import { TextDecoder } from "node:util";
|
|
2010
|
-
function spawnArgv(argv, stdio) {
|
|
2011
|
-
return spawn(argv[0], argv.slice(1), { stdio });
|
|
2012
|
-
}
|
|
2013
|
-
function exited(proc) {
|
|
2014
|
-
return new Promise((resolve, reject) => {
|
|
2015
|
-
proc.once("error", reject);
|
|
2016
|
-
proc.once("close", (code) => resolve(code ?? 1));
|
|
2017
|
-
});
|
|
2018
|
-
}
|
|
2019
|
-
async function collect3(stream) {
|
|
2020
|
-
const chunks = [];
|
|
2021
|
-
for await (const chunk of stream)
|
|
2022
|
-
chunks.push(chunk);
|
|
2023
|
-
return Buffer.concat(chunks).toString("utf8");
|
|
2024
|
-
}
|
|
2025
|
-
async function capture(argv) {
|
|
2026
|
-
try {
|
|
2027
|
-
const proc = spawnArgv(argv, ["ignore", "pipe", "pipe"]);
|
|
2028
|
-
const [stdout, stderr, code] = await Promise.all([
|
|
2029
|
-
collect3(proc.stdout),
|
|
2030
|
-
collect3(proc.stderr),
|
|
2031
|
-
exited(proc)
|
|
2032
|
-
]);
|
|
2033
|
-
return { code, stdout, stderr };
|
|
2034
|
-
} catch (error) {
|
|
2035
|
-
return { code: 127, stdout: "", stderr: error instanceof Error ? error.message : String(error) };
|
|
2036
|
-
}
|
|
2037
|
-
}
|
|
2038
|
-
var STDOUT_LOG = "stdout.log";
|
|
2039
|
-
var MARKER_POLL_MS = 200;
|
|
2040
|
-
async function runContainer(spec, opts) {
|
|
2041
|
-
const stopTimeout = opts.stopTimeoutSeconds ?? 10;
|
|
2042
|
-
mkdirSync2(opts.logDir, { recursive: true });
|
|
2043
|
-
const sink = createWriteStream(join6(opts.logDir, STDOUT_LOG));
|
|
2044
|
-
const proc = spawnArgv(["docker", ...toDockerArgs(spec)], ["inherit", "pipe", "pipe"]);
|
|
2045
|
-
let interrupted = false;
|
|
2046
|
-
const onSignal = () => {
|
|
2047
|
-
if (interrupted)
|
|
2048
|
-
return;
|
|
2049
|
-
interrupted = true;
|
|
2050
|
-
stopContainer(spec.name, stopTimeout);
|
|
2051
|
-
};
|
|
2052
|
-
process.on("SIGINT", onSignal);
|
|
2053
|
-
process.on("SIGTERM", onSignal);
|
|
2054
|
-
const code = exited(proc);
|
|
2055
|
-
try {
|
|
2056
|
-
await Promise.all([
|
|
2057
|
-
tee(proc.stdout, sink, process.stdout),
|
|
2058
|
-
tee(proc.stderr, sink, process.stderr)
|
|
2059
|
-
]);
|
|
2060
|
-
const status = await code;
|
|
2061
|
-
return interrupted ? Exit.Interrupted : status;
|
|
2062
|
-
} finally {
|
|
2063
|
-
process.off("SIGINT", onSignal);
|
|
2064
|
-
process.off("SIGTERM", onSignal);
|
|
2065
|
-
await new Promise((resolve) => sink.end(resolve));
|
|
2066
|
-
}
|
|
2067
|
-
}
|
|
2068
|
-
async function stopContainer(name, timeoutSeconds) {
|
|
2069
|
-
const proc = spawnArgv(["docker", "stop", "--timeout", String(timeoutSeconds), name], "ignore");
|
|
2070
|
-
await exited(proc).catch(() => {});
|
|
2071
|
-
}
|
|
2072
|
-
async function waitForMarker(sources, marker, timeoutSeconds) {
|
|
2073
|
-
const deadline = Date.now() + timeoutSeconds * 1000;
|
|
2074
|
-
const carry = Math.max(marker.length - 1, 0);
|
|
2075
|
-
const seen = new Map;
|
|
2076
|
-
const startedAt = Date.now();
|
|
2077
|
-
while (true) {
|
|
2078
|
-
for (const path of await expandSources(sources)) {
|
|
2079
|
-
let state = seen.get(path);
|
|
2080
|
-
if (state === undefined) {
|
|
2081
|
-
state = { offset: await staleSize(path, startedAt), tail: "", decoder: new TextDecoder };
|
|
2082
|
-
seen.set(path, state);
|
|
2083
|
-
}
|
|
2084
|
-
if (await scan(path, state, marker, carry))
|
|
2085
|
-
return true;
|
|
2086
|
-
}
|
|
2087
|
-
if (Date.now() >= deadline)
|
|
2088
|
-
return false;
|
|
2089
|
-
await sleep(Math.min(MARKER_POLL_MS, Math.max(deadline - Date.now(), 0)));
|
|
2090
|
-
}
|
|
2091
|
-
}
|
|
2092
|
-
async function staleSize(path, startedAt) {
|
|
2093
|
-
return stat2(path).then((info) => info.mtimeMs < startedAt ? info.size : 0, () => 0);
|
|
2094
|
-
}
|
|
2095
|
-
async function scan(path, state, marker, carry) {
|
|
2096
|
-
const handle = await open(path, "r").catch(() => null);
|
|
2097
|
-
if (handle === null)
|
|
2098
|
-
return false;
|
|
2099
|
-
try {
|
|
2100
|
-
const { size } = await handle.stat();
|
|
2101
|
-
if (size < state.offset) {
|
|
2102
|
-
state.offset = 0;
|
|
2103
|
-
state.tail = "";
|
|
2104
|
-
state.decoder = new TextDecoder;
|
|
2105
|
-
}
|
|
2106
|
-
if (size <= state.offset)
|
|
2107
|
-
return false;
|
|
2108
|
-
const buffer = Buffer.alloc(size - state.offset);
|
|
2109
|
-
const { bytesRead } = await handle.read(buffer, 0, buffer.length, state.offset);
|
|
2110
|
-
state.offset += bytesRead;
|
|
2111
|
-
const text = state.tail + state.decoder.decode(buffer.subarray(0, bytesRead), { stream: true });
|
|
2112
|
-
if (text.includes(marker))
|
|
2113
|
-
return true;
|
|
2114
|
-
state.tail = carry > 0 ? text.slice(-carry) : "";
|
|
2115
|
-
return false;
|
|
2116
|
-
} catch {
|
|
2117
|
-
return false;
|
|
2118
|
-
} finally {
|
|
2119
|
-
await handle.close().catch(() => {});
|
|
2120
|
-
}
|
|
2121
|
-
}
|
|
2122
|
-
async function expandSources(sources) {
|
|
2123
|
-
const out = [];
|
|
2124
|
-
for (const source of sources) {
|
|
2125
|
-
const info = await stat2(source).catch(() => null);
|
|
2126
|
-
if (info === null) {
|
|
2127
|
-
out.push(source);
|
|
2128
|
-
continue;
|
|
2129
|
-
}
|
|
2130
|
-
if (!info.isDirectory()) {
|
|
2131
|
-
out.push(source);
|
|
2132
|
-
continue;
|
|
2133
|
-
}
|
|
2134
|
-
const entries = await readdir3(source).catch(() => []);
|
|
2135
|
-
for (const entry of entries) {
|
|
2136
|
-
if (entry.toLowerCase().endsWith(".log"))
|
|
2137
|
-
out.push(join6(source, entry));
|
|
2138
|
-
}
|
|
2139
|
-
}
|
|
2140
|
-
return out;
|
|
2141
|
-
}
|
|
2142
|
-
async function tee(stream, sink, mirror) {
|
|
2143
|
-
for await (const chunk of stream) {
|
|
2144
|
-
mirror.write(chunk);
|
|
2145
|
-
sink.write(chunk);
|
|
2146
|
-
}
|
|
2147
|
-
}
|
|
2148
|
-
|
|
2149
|
-
// src/docker/preflight.ts
|
|
2528
|
+
import { basename as basename5, join as join7 } from "node:path";
|
|
2150
2529
|
var CDI_SPEC = "/etc/cdi/nvidia.yaml";
|
|
2151
2530
|
async function preflight(plan) {
|
|
2152
2531
|
const problems = [];
|
|
@@ -2282,11 +2661,11 @@ function checkGameDir(plan, problems) {
|
|
|
2282
2661
|
problems.push({ where, message: `game directory does not exist: ${files.host}` });
|
|
2283
2662
|
return;
|
|
2284
2663
|
}
|
|
2285
|
-
const executable = join7(files.host,
|
|
2664
|
+
const executable = join7(files.host, basename5(plan.gameConfig.executable));
|
|
2286
2665
|
if (!existsSync3(executable)) {
|
|
2287
2666
|
problems.push({
|
|
2288
2667
|
where,
|
|
2289
|
-
message: `${files.host} does not contain ${
|
|
2668
|
+
message: `${files.host} does not contain ${basename5(plan.gameConfig.executable)}`,
|
|
2290
2669
|
suggestion: "point gameFiles.host at the install directory, not its parent"
|
|
2291
2670
|
});
|
|
2292
2671
|
}
|
|
@@ -2342,15 +2721,40 @@ function firstLine(text) {
|
|
|
2342
2721
|
return text.trim().split(`
|
|
2343
2722
|
`)[0]?.trim() ?? "";
|
|
2344
2723
|
}
|
|
2345
|
-
function message(error) {
|
|
2346
|
-
return error instanceof Error ? error.message : String(error);
|
|
2724
|
+
function message(error) {
|
|
2725
|
+
return error instanceof Error ? error.message : String(error);
|
|
2726
|
+
}
|
|
2727
|
+
|
|
2728
|
+
// src/docker/window.ts
|
|
2729
|
+
import { readFileSync as readFileSync3 } from "node:fs";
|
|
2730
|
+
import { basename as basename6 } from "node:path";
|
|
2731
|
+
import { setTimeout as sleep2 } from "node:timers/promises";
|
|
2732
|
+
var WAIT_MS = 180000;
|
|
2733
|
+
var POLL_MS = 500;
|
|
2734
|
+
function newMatches(now, seen, executable) {
|
|
2735
|
+
const wanted = basename6(executable).toLowerCase();
|
|
2736
|
+
return now.filter((w) => !seen.has(w.id) && w.wmClass.toLowerCase().includes(wanted));
|
|
2737
|
+
}
|
|
2738
|
+
function parseWindowPid(stdout) {
|
|
2739
|
+
const match = /_NET_WM_PID\(CARDINAL\)\s*=\s*(\d+)/.exec(stdout);
|
|
2740
|
+
const pid = Number(match?.[1]);
|
|
2741
|
+
return Number.isSafeInteger(pid) && pid > 0 ? pid : undefined;
|
|
2742
|
+
}
|
|
2743
|
+
function isPeerClaim(pid, self) {
|
|
2744
|
+
if (pid === self)
|
|
2745
|
+
return false;
|
|
2746
|
+
try {
|
|
2747
|
+
const argv = readFileSync3(`/proc/${pid}/cmdline`, "utf8").split("\x00");
|
|
2748
|
+
return argv.some((arg) => basename6(arg).startsWith("gamecrate"));
|
|
2749
|
+
} catch {
|
|
2750
|
+
return false;
|
|
2751
|
+
}
|
|
2752
|
+
}
|
|
2753
|
+
async function claimedByPeer(id) {
|
|
2754
|
+
const { stdout } = await capture(["xprop", "-id", id, "_NET_WM_PID"]);
|
|
2755
|
+
const pid = parseWindowPid(stdout);
|
|
2756
|
+
return pid !== undefined && isPeerClaim(pid, process.pid);
|
|
2347
2757
|
}
|
|
2348
|
-
|
|
2349
|
-
// src/docker/window.ts
|
|
2350
|
-
import { basename as basename4 } from "node:path";
|
|
2351
|
-
import { setTimeout as sleep2 } from "node:timers/promises";
|
|
2352
|
-
var WAIT_MS = 180000;
|
|
2353
|
-
var POLL_MS = 500;
|
|
2354
2758
|
async function toplevels() {
|
|
2355
2759
|
const { code, stdout } = await capture(["wmctrl", "-lx"]);
|
|
2356
2760
|
if (code === 127)
|
|
@@ -2371,7 +2775,6 @@ async function adoptNewWindow(opts) {
|
|
|
2371
2775
|
return { stop: () => {} };
|
|
2372
2776
|
}
|
|
2373
2777
|
const seen = new Set(before.map((w) => w.id));
|
|
2374
|
-
const wanted = basename4(opts.executable).toLowerCase();
|
|
2375
2778
|
let stopped = false;
|
|
2376
2779
|
(async () => {
|
|
2377
2780
|
const deadline = Date.now() + WAIT_MS;
|
|
@@ -2379,15 +2782,18 @@ async function adoptNewWindow(opts) {
|
|
|
2379
2782
|
await sleep2(POLL_MS);
|
|
2380
2783
|
if (stopped)
|
|
2381
2784
|
return;
|
|
2382
|
-
const
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
|
|
2388
|
-
|
|
2389
|
-
await
|
|
2390
|
-
|
|
2785
|
+
for (const match of newMatches(await toplevels() ?? [], seen, opts.executable)) {
|
|
2786
|
+
if (stopped)
|
|
2787
|
+
return;
|
|
2788
|
+
if (await claimedByPeer(match.id))
|
|
2789
|
+
continue;
|
|
2790
|
+
if (!await adopt(match.id, opts))
|
|
2791
|
+
continue;
|
|
2792
|
+
await capture(["wmctrl", "-i", "-r", match.id, "-N", opts.title]);
|
|
2793
|
+
if (opts.stripDelete)
|
|
2794
|
+
await watchForClose(match.id, () => stopped, opts.onClosed);
|
|
2795
|
+
return;
|
|
2796
|
+
}
|
|
2391
2797
|
}
|
|
2392
2798
|
})();
|
|
2393
2799
|
return { stop: () => void (stopped = true) };
|
|
@@ -2412,15 +2818,19 @@ async function adopt(id, opts) {
|
|
|
2412
2818
|
const protocols = await capture(["xprop", "-id", id, "WM_PROTOCOLS"]);
|
|
2413
2819
|
if (protocols.code === 127) {
|
|
2414
2820
|
warn("xprop is not installed, so nothing out here can fix the window's close button");
|
|
2415
|
-
return;
|
|
2821
|
+
return true;
|
|
2416
2822
|
}
|
|
2417
|
-
await claimPid(id)
|
|
2823
|
+
if (!await claimPid(id))
|
|
2824
|
+
return false;
|
|
2418
2825
|
if (opts.stripDelete)
|
|
2419
2826
|
await dropDeleteProtocol(id, parseAtoms(protocols.stdout));
|
|
2827
|
+
return true;
|
|
2420
2828
|
}
|
|
2421
2829
|
async function claimPid(id) {
|
|
2422
2830
|
const pid = String(process.pid);
|
|
2423
2831
|
await capture(["xprop", "-id", id, "-f", "_NET_WM_PID", "32c", "-set", "_NET_WM_PID", pid]);
|
|
2832
|
+
const readBack = await capture(["xprop", "-id", id, "_NET_WM_PID"]);
|
|
2833
|
+
return parseWindowPid(readBack.stdout) === process.pid;
|
|
2424
2834
|
}
|
|
2425
2835
|
async function dropDeleteProtocol(id, atoms) {
|
|
2426
2836
|
if (!atoms.includes("WM_DELETE_WINDOW"))
|
|
@@ -2449,7 +2859,7 @@ function parseAtoms(stdout) {
|
|
|
2449
2859
|
}
|
|
2450
2860
|
|
|
2451
2861
|
// src/launch/generate.ts
|
|
2452
|
-
import { mkdir, readdir as readdir4, readFile as
|
|
2862
|
+
import { mkdir, readdir as readdir4, readFile as readFile3, writeFile } from "node:fs/promises";
|
|
2453
2863
|
import { dirname as dirname3, join as join8 } from "node:path";
|
|
2454
2864
|
async function readInstallVersion(game, plugin) {
|
|
2455
2865
|
const host = game.gameFiles.host;
|
|
@@ -2457,7 +2867,7 @@ async function readInstallVersion(game, plugin) {
|
|
|
2457
2867
|
return null;
|
|
2458
2868
|
let raw;
|
|
2459
2869
|
try {
|
|
2460
|
-
raw = await
|
|
2870
|
+
raw = await readFile3(join8(expandHome(host), "Version.txt"), "utf8");
|
|
2461
2871
|
} catch {
|
|
2462
2872
|
return null;
|
|
2463
2873
|
}
|
|
@@ -2483,7 +2893,7 @@ async function readKnownExpansions(game, plugin, warnings) {
|
|
|
2483
2893
|
continue;
|
|
2484
2894
|
let id = null;
|
|
2485
2895
|
try {
|
|
2486
|
-
id = plugin.parseManifest(await
|
|
2896
|
+
id = plugin.parseManifest(await readFile3(join8(dataDir, entry.name, game.manifest.file), "utf8"))?.packageId ?? null;
|
|
2487
2897
|
} catch {
|
|
2488
2898
|
continue;
|
|
2489
2899
|
}
|
|
@@ -2554,7 +2964,7 @@ async function mergePrefs(plan) {
|
|
|
2554
2964
|
await mkdir(dirname3(target), { recursive: true });
|
|
2555
2965
|
let existing = null;
|
|
2556
2966
|
try {
|
|
2557
|
-
existing = await
|
|
2967
|
+
existing = await readFile3(target, "utf8");
|
|
2558
2968
|
} catch (error) {
|
|
2559
2969
|
if (error.code !== "ENOENT")
|
|
2560
2970
|
throw error;
|
|
@@ -2564,8 +2974,8 @@ async function mergePrefs(plan) {
|
|
|
2564
2974
|
}
|
|
2565
2975
|
|
|
2566
2976
|
// src/launch/prepare.ts
|
|
2567
|
-
import { existsSync as existsSync4 } from "node:fs";
|
|
2568
|
-
import { open as open2, readdir as readdir5, readFile as
|
|
2977
|
+
import { existsSync as existsSync4, readFileSync as readFileSync4 } from "node:fs";
|
|
2978
|
+
import { open as open2, readdir as readdir5, readFile as readFile4, unlink, writeFile as writeFile2 } from "node:fs/promises";
|
|
2569
2979
|
import { join as join9 } from "node:path";
|
|
2570
2980
|
import { setTimeout as sleep3 } from "node:timers/promises";
|
|
2571
2981
|
async function inherit(argv, stdin) {
|
|
@@ -2690,7 +3100,7 @@ async function buildLocalMods(plan, policy) {
|
|
|
2690
3100
|
delete mod.staleReport;
|
|
2691
3101
|
}
|
|
2692
3102
|
}
|
|
2693
|
-
async function
|
|
3103
|
+
async function clearLock(plan) {
|
|
2694
3104
|
const path = lockPath(plan);
|
|
2695
3105
|
const what = plan.instance === undefined ? plan.profile : `${plan.profile} (${plan.instance})`;
|
|
2696
3106
|
const name = containerName(plan);
|
|
@@ -2699,66 +3109,150 @@ async function takeLock(plan) {
|
|
|
2699
3109
|
throw new GamecrateError(`${plan.game} ${what} is already running (container ${name})`, Exit.Refused, `stop it with: docker stop ${name}
|
|
2700
3110
|
or relaunch with --replace`);
|
|
2701
3111
|
}
|
|
2702
|
-
|
|
2703
|
-
|
|
2704
|
-
|
|
2705
|
-
`)[0]);
|
|
2706
|
-
const alive = Number.isInteger(pid) && pid > 0 && isRunning(pid);
|
|
2707
|
-
if (alive) {
|
|
2708
|
-
throw new GamecrateError(`${plan.game} ${what} is already running (pid ${pid})`, Exit.Refused, `if that is wrong, delete ${path}
|
|
3112
|
+
const held = await readLock(path);
|
|
3113
|
+
if (held !== undefined && isRunning(held.pid, held.startedAt)) {
|
|
3114
|
+
throw new GamecrateError(`${plan.game} ${what} is already running (pid ${held.pid})`, Exit.Refused, `if that is wrong, delete ${path}
|
|
2709
3115
|
or relaunch with --replace`);
|
|
2710
|
-
}
|
|
2711
|
-
await unlink(path).catch(() => {});
|
|
2712
|
-
}
|
|
2713
|
-
const handle = await open2(path, "wx").catch(() => null);
|
|
2714
|
-
if (handle === null) {
|
|
2715
|
-
throw new GamecrateError(`could not take the launch lock at ${path}`, Exit.Environment);
|
|
2716
3116
|
}
|
|
2717
|
-
|
|
2718
|
-
|
|
2719
|
-
|
|
2720
|
-
|
|
3117
|
+
if (existsSync4(path))
|
|
3118
|
+
await unlink(path).catch(() => {});
|
|
3119
|
+
}
|
|
3120
|
+
async function unlinkHeld(path, pid, startedAt) {
|
|
3121
|
+
const held = await readLock(path);
|
|
3122
|
+
if (held === undefined)
|
|
3123
|
+
return;
|
|
3124
|
+
if (held.pid !== pid)
|
|
3125
|
+
return;
|
|
3126
|
+
if (startedAt !== undefined && held.startedAt !== startedAt)
|
|
3127
|
+
return;
|
|
3128
|
+
await unlink(path).catch(() => {});
|
|
3129
|
+
}
|
|
3130
|
+
function heldLock(plan) {
|
|
3131
|
+
const path = lockPath(plan);
|
|
2721
3132
|
return {
|
|
2722
3133
|
release: async () => {
|
|
2723
|
-
await
|
|
3134
|
+
await unlinkHeld(path, process.pid);
|
|
2724
3135
|
}
|
|
2725
3136
|
};
|
|
2726
3137
|
}
|
|
3138
|
+
async function takeLock(plan) {
|
|
3139
|
+
await clearLock(plan);
|
|
3140
|
+
await writeLock(plan, {
|
|
3141
|
+
pid: process.pid,
|
|
3142
|
+
container: containerName(plan),
|
|
3143
|
+
game: plan.game,
|
|
3144
|
+
profile: plan.profile,
|
|
3145
|
+
...plan.instance === undefined ? {} : { instance: plan.instance },
|
|
3146
|
+
detached: false,
|
|
3147
|
+
mode: plan.mode
|
|
3148
|
+
});
|
|
3149
|
+
return heldLock(plan);
|
|
3150
|
+
}
|
|
2727
3151
|
function lockPath(plan) {
|
|
2728
3152
|
return join9(plan.instanceDir, ".gamecrate", "lock");
|
|
2729
3153
|
}
|
|
2730
|
-
|
|
3154
|
+
async function readLock(path) {
|
|
3155
|
+
const text = await readFile4(path, "utf8").catch(() => {
|
|
3156
|
+
return;
|
|
3157
|
+
});
|
|
3158
|
+
if (text === undefined)
|
|
3159
|
+
return;
|
|
3160
|
+
try {
|
|
3161
|
+
const value = JSON.parse(text);
|
|
3162
|
+
return Number.isInteger(value?.pid) && value.pid > 0 ? value : undefined;
|
|
3163
|
+
} catch {
|
|
3164
|
+
return;
|
|
3165
|
+
}
|
|
3166
|
+
}
|
|
3167
|
+
async function writeLock(plan, record) {
|
|
3168
|
+
const path = lockPath(plan);
|
|
3169
|
+
const handle = await open2(path, "wx").catch(() => null);
|
|
3170
|
+
if (handle === null) {
|
|
3171
|
+
throw new GamecrateError(`could not take the launch lock at ${path}`, Exit.Environment);
|
|
3172
|
+
}
|
|
3173
|
+
await handle.writeFile(JSON.stringify({ ...record, startedAt: new Date().toISOString() }));
|
|
3174
|
+
await handle.close();
|
|
3175
|
+
}
|
|
2731
3176
|
var RELEASE_POLL_MS = 100;
|
|
2732
|
-
var
|
|
3177
|
+
var DRAIN_ALLOWANCE_MS = 1e4;
|
|
3178
|
+
var STOP_RELEASE_WAIT_MS = STOP_TIMEOUT_SECONDS * 1000 + DRAIN_ALLOWANCE_MS;
|
|
3179
|
+
async function stopRun(record, lockFile) {
|
|
3180
|
+
let signalled = false;
|
|
3181
|
+
if (isRunning(record.pid, record.startedAt)) {
|
|
3182
|
+
try {
|
|
3183
|
+
process.kill(record.pid, "SIGTERM");
|
|
3184
|
+
signalled = true;
|
|
3185
|
+
} catch {}
|
|
3186
|
+
}
|
|
3187
|
+
if (!signalled)
|
|
3188
|
+
await stopContainer(record.container, STOP_TIMEOUT_SECONDS);
|
|
3189
|
+
const deadline = Date.now() + STOP_RELEASE_WAIT_MS;
|
|
3190
|
+
while (existsSync4(lockFile)) {
|
|
3191
|
+
const held = await readLock(lockFile);
|
|
3192
|
+
if (held === undefined)
|
|
3193
|
+
break;
|
|
3194
|
+
if (!isRunning(held.pid, held.startedAt)) {
|
|
3195
|
+
await unlinkHeld(lockFile, record.pid, record.startedAt);
|
|
3196
|
+
break;
|
|
3197
|
+
}
|
|
3198
|
+
if (Date.now() >= deadline)
|
|
3199
|
+
return "held";
|
|
3200
|
+
await sleep3(RELEASE_POLL_MS);
|
|
3201
|
+
}
|
|
3202
|
+
return signalled ? "signalled" : "orphaned";
|
|
3203
|
+
}
|
|
2733
3204
|
async function replacePrevious(plan) {
|
|
2734
3205
|
const name = containerName(plan);
|
|
2735
3206
|
const path = lockPath(plan);
|
|
2736
3207
|
const up = await capture(["docker", "ps", "--quiet", "--filter", `name=^${name}$`]);
|
|
2737
3208
|
const running = up.stdout.trim().length > 0;
|
|
2738
|
-
|
|
3209
|
+
const held = await readLock(path);
|
|
3210
|
+
if (!running && held === undefined && !existsSync4(path))
|
|
2739
3211
|
return;
|
|
3212
|
+
if (held !== undefined) {
|
|
3213
|
+
status(`stopping ${held.container}`);
|
|
3214
|
+
await stopRun(held, path);
|
|
3215
|
+
return;
|
|
3216
|
+
}
|
|
2740
3217
|
if (running) {
|
|
2741
3218
|
status(`stopping ${name}`);
|
|
2742
|
-
await stopContainer(name,
|
|
2743
|
-
}
|
|
2744
|
-
const deadline = Date.now() + RELEASE_WAIT_MS;
|
|
2745
|
-
while (existsSync4(path) && Date.now() < deadline) {
|
|
2746
|
-
const holder = await readFile3(path, "utf8").catch(() => "");
|
|
2747
|
-
const pid = Number(holder.split(`
|
|
2748
|
-
`)[0]);
|
|
2749
|
-
if (!Number.isInteger(pid) || pid <= 0 || !isRunning(pid))
|
|
2750
|
-
break;
|
|
2751
|
-
await sleep3(RELEASE_POLL_MS);
|
|
3219
|
+
await stopContainer(name, STOP_TIMEOUT_SECONDS);
|
|
2752
3220
|
}
|
|
2753
|
-
await unlink(path).catch(() => {});
|
|
2754
3221
|
}
|
|
2755
|
-
|
|
3222
|
+
var CLOCK_TICKS_PER_SECOND = 100;
|
|
3223
|
+
var START_TIME_SLACK_MS = 2000;
|
|
3224
|
+
function isRunning(pid, startedAt) {
|
|
2756
3225
|
try {
|
|
2757
3226
|
process.kill(pid, 0);
|
|
2758
|
-
return true;
|
|
2759
3227
|
} catch {
|
|
2760
3228
|
return false;
|
|
2761
3229
|
}
|
|
3230
|
+
if (startedAt === undefined)
|
|
3231
|
+
return true;
|
|
3232
|
+
const written = Date.parse(startedAt);
|
|
3233
|
+
if (Number.isNaN(written))
|
|
3234
|
+
return true;
|
|
3235
|
+
const began = processStart(pid);
|
|
3236
|
+
return began === undefined || began <= written + START_TIME_SLACK_MS;
|
|
3237
|
+
}
|
|
3238
|
+
function processStart(pid) {
|
|
3239
|
+
try {
|
|
3240
|
+
const stat = readFileSync4(`/proc/${pid}/stat`, "utf8");
|
|
3241
|
+
const fields = stat.slice(stat.lastIndexOf(")") + 2).split(" ");
|
|
3242
|
+
const ticks = Number(fields[19]);
|
|
3243
|
+
const boot = bootTime();
|
|
3244
|
+
if (!Number.isFinite(ticks) || boot === undefined)
|
|
3245
|
+
return;
|
|
3246
|
+
return boot + ticks / CLOCK_TICKS_PER_SECOND * 1000;
|
|
3247
|
+
} catch {
|
|
3248
|
+
return;
|
|
3249
|
+
}
|
|
3250
|
+
}
|
|
3251
|
+
function bootTime() {
|
|
3252
|
+
const line = readFileSync4("/proc/stat", "utf8").split(`
|
|
3253
|
+
`).find((each) => each.startsWith("btime "));
|
|
3254
|
+
const seconds = Number(line?.slice("btime ".length));
|
|
3255
|
+
return Number.isFinite(seconds) && seconds > 0 ? seconds * 1000 : undefined;
|
|
2762
3256
|
}
|
|
2763
3257
|
async function captureScreenshot(container, plan) {
|
|
2764
3258
|
const name = `${plan.game}.png`;
|
|
@@ -2791,13 +3285,146 @@ async function writeLaunchRecord(plan, image) {
|
|
|
2791
3285
|
`, { flag: "a" });
|
|
2792
3286
|
}
|
|
2793
3287
|
|
|
3288
|
+
// src/launch/supervisor.ts
|
|
3289
|
+
import { existsSync as existsSync5 } from "node:fs";
|
|
3290
|
+
import { readFile as readFile5, readlink, rm, writeFile as writeFile3 } from "node:fs/promises";
|
|
3291
|
+
import { basename as basename7, join as join10 } from "node:path";
|
|
3292
|
+
import { setTimeout as sleep4 } from "node:timers/promises";
|
|
3293
|
+
async function forkSupervisor(plan, argv) {
|
|
3294
|
+
await clearLock(plan);
|
|
3295
|
+
const name = containerName(plan);
|
|
3296
|
+
const self = supervisorArgv(argv, plan.instanceDir);
|
|
3297
|
+
const bin = self[0];
|
|
3298
|
+
if (bin === undefined || bin === "") {
|
|
3299
|
+
throw new GamecrateError("cannot re-exec gamecrate: argv[0] is empty", Exit.Environment);
|
|
3300
|
+
}
|
|
3301
|
+
const child = spawnArgv(self, "ignore", true);
|
|
3302
|
+
child.unref();
|
|
3303
|
+
if (child.pid === undefined) {
|
|
3304
|
+
throw new GamecrateError(`could not fork a supervisor from ${bin}`, Exit.Environment);
|
|
3305
|
+
}
|
|
3306
|
+
try {
|
|
3307
|
+
await writeLock(plan, {
|
|
3308
|
+
pid: child.pid,
|
|
3309
|
+
container: name,
|
|
3310
|
+
game: plan.game,
|
|
3311
|
+
profile: plan.profile,
|
|
3312
|
+
...plan.instance === undefined ? {} : { instance: plan.instance },
|
|
3313
|
+
detached: true,
|
|
3314
|
+
mode: plan.mode
|
|
3315
|
+
});
|
|
3316
|
+
} catch (error) {
|
|
3317
|
+
const failure = kill(child.pid);
|
|
3318
|
+
if (failure !== undefined) {
|
|
3319
|
+
throw new GamecrateError(`could not confirm supervisor ${child.pid} died after the lock write failed`, Exit.Environment, `${failure}
|
|
3320
|
+
kill -9 ${child.pid} before launching this profile again`);
|
|
3321
|
+
}
|
|
3322
|
+
throw error;
|
|
3323
|
+
}
|
|
3324
|
+
status(`${plan.game} ${plan.profile} -> ${name} (pid ${child.pid})`);
|
|
3325
|
+
return Exit.Ok;
|
|
3326
|
+
}
|
|
3327
|
+
function kill(pid) {
|
|
3328
|
+
try {
|
|
3329
|
+
process.kill(pid, "SIGKILL");
|
|
3330
|
+
return;
|
|
3331
|
+
} catch (error) {
|
|
3332
|
+
const code = error.code;
|
|
3333
|
+
return code === "ESRCH" ? undefined : `could not kill it: ${code ?? String(error)}`;
|
|
3334
|
+
}
|
|
3335
|
+
}
|
|
3336
|
+
async function recordExit(plan, result) {
|
|
3337
|
+
await writeExit(plan.instanceDir, {
|
|
3338
|
+
at: new Date().toISOString(),
|
|
3339
|
+
code: result.code,
|
|
3340
|
+
reason: result.reason,
|
|
3341
|
+
container: containerName(plan),
|
|
3342
|
+
runDir: plan.runDirHost
|
|
3343
|
+
});
|
|
3344
|
+
}
|
|
3345
|
+
async function supervisorFailed(dir, code) {
|
|
3346
|
+
const record = { at: new Date().toISOString(), code, reason: "failed" };
|
|
3347
|
+
const wrote = await writeExit(dir, record).then(() => true, () => false);
|
|
3348
|
+
if (wrote)
|
|
3349
|
+
await rm(join10(dir, ".gamecrate", "lock"), { force: true }).catch(() => {});
|
|
3350
|
+
return code;
|
|
3351
|
+
}
|
|
3352
|
+
async function writeExit(instanceDir, record) {
|
|
3353
|
+
await writeFile3(join10(instanceDir, ".gamecrate", "last-exit.json"), `${JSON.stringify(record)}
|
|
3354
|
+
`);
|
|
3355
|
+
}
|
|
3356
|
+
async function lastExit(instanceDir) {
|
|
3357
|
+
const text = await readFile5(join10(instanceDir, ".gamecrate", "last-exit.json"), "utf8").catch(() => {
|
|
3358
|
+
return;
|
|
3359
|
+
});
|
|
3360
|
+
if (text === undefined)
|
|
3361
|
+
return;
|
|
3362
|
+
try {
|
|
3363
|
+
const value = JSON.parse(text);
|
|
3364
|
+
return Number.isInteger(value?.code) ? value : undefined;
|
|
3365
|
+
} catch {
|
|
3366
|
+
return;
|
|
3367
|
+
}
|
|
3368
|
+
}
|
|
3369
|
+
var WAIT_POLL_MS = 500;
|
|
3370
|
+
function endedAfter(record, notBefore) {
|
|
3371
|
+
if (notBefore === undefined)
|
|
3372
|
+
return true;
|
|
3373
|
+
const began = Date.parse(notBefore);
|
|
3374
|
+
if (Number.isNaN(began))
|
|
3375
|
+
return true;
|
|
3376
|
+
const at = Date.parse(record.at);
|
|
3377
|
+
return !Number.isNaN(at) && at >= began;
|
|
3378
|
+
}
|
|
3379
|
+
async function awaitExit(instanceDir, poll = WAIT_POLL_MS) {
|
|
3380
|
+
const file = join10(instanceDir, ".gamecrate", "lock");
|
|
3381
|
+
let watching;
|
|
3382
|
+
for (;; ) {
|
|
3383
|
+
const lock = await readLock(file) ?? watching;
|
|
3384
|
+
const found = await lastExit(instanceDir);
|
|
3385
|
+
if (found !== undefined && endedAfter(found, lock?.startedAt))
|
|
3386
|
+
return found;
|
|
3387
|
+
if (lock === undefined)
|
|
3388
|
+
return "absent";
|
|
3389
|
+
if (!isRunning(lock.pid, lock.startedAt))
|
|
3390
|
+
return "orphaned";
|
|
3391
|
+
watching = lock;
|
|
3392
|
+
await sleep4(poll);
|
|
3393
|
+
}
|
|
3394
|
+
}
|
|
3395
|
+
var RUN_LOG_POLL_MS = 250;
|
|
3396
|
+
async function awaitRunLog(instanceDir, lock, poll = RUN_LOG_POLL_MS, notice = WAIT_NOTICE) {
|
|
3397
|
+
const link = join10(instanceDir, "logs", "current");
|
|
3398
|
+
const written = Date.parse(lock.startedAt);
|
|
3399
|
+
const since = Date.now();
|
|
3400
|
+
let lastNotice = 0;
|
|
3401
|
+
for (;; ) {
|
|
3402
|
+
const target = await readlink(link).catch(() => {
|
|
3403
|
+
return;
|
|
3404
|
+
});
|
|
3405
|
+
const began = target === undefined ? undefined : runStartedAt(basename7(target));
|
|
3406
|
+
const current = began !== undefined && (Number.isNaN(written) || began >= written);
|
|
3407
|
+
if (current && existsSync5(currentLog(instanceDir)))
|
|
3408
|
+
return true;
|
|
3409
|
+
if (!isRunning(lock.pid, lock.startedAt))
|
|
3410
|
+
return false;
|
|
3411
|
+
const waited = Date.now() - since;
|
|
3412
|
+
const label = waitNotice(waited, lastNotice, notice);
|
|
3413
|
+
if (label !== undefined) {
|
|
3414
|
+
status(`waiting for ${lock.game} ${lock.profile} to open its log (${label})`);
|
|
3415
|
+
lastNotice = waited;
|
|
3416
|
+
}
|
|
3417
|
+
await sleep4(poll);
|
|
3418
|
+
}
|
|
3419
|
+
}
|
|
3420
|
+
|
|
2794
3421
|
// src/launch/instance.ts
|
|
2795
3422
|
import { createHash } from "node:crypto";
|
|
2796
|
-
import { basename as
|
|
3423
|
+
import { basename as basename8, join as join11 } from "node:path";
|
|
2797
3424
|
|
|
2798
3425
|
// src/mods/worktree.ts
|
|
2799
3426
|
import { spawnSync } from "node:child_process";
|
|
2800
|
-
import { existsSync as
|
|
3427
|
+
import { existsSync as existsSync6, realpathSync as realpathSync2 } from "node:fs";
|
|
2801
3428
|
import { isAbsolute as isAbsolute2, resolve as resolve4, sep } from "node:path";
|
|
2802
3429
|
function inspect(dir) {
|
|
2803
3430
|
const r = spawnSync("git", ["-C", dir, "rev-parse", "--path-format=absolute", "--show-toplevel", "--git-dir", "--git-common-dir", "--abbrev-ref", "HEAD"], { encoding: "utf8" });
|
|
@@ -2820,7 +3447,7 @@ function canonical(p) {
|
|
|
2820
3447
|
function resolveWorktree(dir, source, order) {
|
|
2821
3448
|
const raw = expandHome(dir);
|
|
2822
3449
|
const abs = isAbsolute2(raw) ? raw : resolve4(process.cwd(), raw);
|
|
2823
|
-
if (!
|
|
3450
|
+
if (!existsSync6(abs)) {
|
|
2824
3451
|
return { where: abs, message: `--worktree path does not exist`, suggestion: "check the path, or drop the flag" };
|
|
2825
3452
|
}
|
|
2826
3453
|
const info = inspect(abs);
|
|
@@ -2875,7 +3502,7 @@ function resolveInstance(options) {
|
|
|
2875
3502
|
const name = args.instance === undefined ? derive(requests) : named(args.instance);
|
|
2876
3503
|
return {
|
|
2877
3504
|
...name === undefined ? {} : { name },
|
|
2878
|
-
dir: name === undefined ? profileDir :
|
|
3505
|
+
dir: name === undefined ? profileDir : join11(profileDir, "instances", name),
|
|
2879
3506
|
requests,
|
|
2880
3507
|
problems,
|
|
2881
3508
|
...configured?.settings === undefined ? {} : { settings: configured.settings }
|
|
@@ -2906,21 +3533,21 @@ function derive(requests) {
|
|
|
2906
3533
|
return `${slug(first.root)}-${digest.slice(0, 6)}`;
|
|
2907
3534
|
}
|
|
2908
3535
|
function slug(root) {
|
|
2909
|
-
const body =
|
|
3536
|
+
const body = basename8(root).toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^[^a-z0-9]+/, "").slice(0, SLUG_LIMIT).replace(/[-._]+$/, "");
|
|
2910
3537
|
return body === "" ? "wt" : body;
|
|
2911
3538
|
}
|
|
2912
3539
|
|
|
2913
3540
|
// src/launch/resolve.ts
|
|
2914
|
-
import { join as
|
|
3541
|
+
import { join as join13 } from "node:path";
|
|
2915
3542
|
|
|
2916
3543
|
// src/mods/modindex.ts
|
|
2917
|
-
import { existsSync as
|
|
2918
|
-
import { mkdir as mkdir2, readdir as readdir6, readFile as
|
|
3544
|
+
import { existsSync as existsSync7, readFileSync as readFileSync5, statSync as statSync2 } from "node:fs";
|
|
3545
|
+
import { mkdir as mkdir2, readdir as readdir6, readFile as readFile6, writeFile as writeFile4 } from "node:fs/promises";
|
|
2919
3546
|
import { homedir as homedir4 } from "node:os";
|
|
2920
|
-
import { dirname as dirname4, join as
|
|
3547
|
+
import { dirname as dirname4, join as join12, relative as relative2, resolve as resolvePath } from "node:path";
|
|
2921
3548
|
import picomatch from "picomatch";
|
|
2922
3549
|
function cacheDir() {
|
|
2923
|
-
return
|
|
3550
|
+
return join12(process.env["XDG_CACHE_HOME"] ?? join12(homedir4(), ".cache"), "gamecrate");
|
|
2924
3551
|
}
|
|
2925
3552
|
function globMatch(pattern, path) {
|
|
2926
3553
|
return picomatch.isMatch(path, pattern.replace(/[[\]{}()!,@+|^$.\\]/g, "\\$&"), { dot: true });
|
|
@@ -2932,8 +3559,8 @@ var ALWAYS_EXCLUDE = ["**/.worktrees/**", "**/.claude/worktrees/**"];
|
|
|
2932
3559
|
function inLinkedWorktree(dir, stopAt) {
|
|
2933
3560
|
let current = dir;
|
|
2934
3561
|
for (;; ) {
|
|
2935
|
-
const git =
|
|
2936
|
-
if (
|
|
3562
|
+
const git = join12(current, ".git");
|
|
3563
|
+
if (existsSync7(git)) {
|
|
2937
3564
|
try {
|
|
2938
3565
|
if (statSync2(git).isFile())
|
|
2939
3566
|
return true;
|
|
@@ -2954,8 +3581,8 @@ async function scanLocalRoot(root, rootIndex, manifestFile, found) {
|
|
|
2954
3581
|
const base = resolvePath(expandHome(root.path));
|
|
2955
3582
|
const exclude = [...root.exclude ?? [], ...rootIndex === -1 ? [] : ALWAYS_EXCLUDE];
|
|
2956
3583
|
const walk = async (dir, depth) => {
|
|
2957
|
-
const manifest =
|
|
2958
|
-
if (
|
|
3584
|
+
const manifest = join12(dir, manifestFile);
|
|
3585
|
+
if (existsSync7(manifest)) {
|
|
2959
3586
|
found.push({
|
|
2960
3587
|
dir,
|
|
2961
3588
|
kind: "local",
|
|
@@ -2975,13 +3602,13 @@ async function scanLocalRoot(root, rootIndex, manifestFile, found) {
|
|
|
2975
3602
|
for (const entry of entries) {
|
|
2976
3603
|
if (!entry.isDirectory() || entry.name.startsWith(".git"))
|
|
2977
3604
|
continue;
|
|
2978
|
-
const child =
|
|
3605
|
+
const child = join12(dir, entry.name);
|
|
2979
3606
|
if (excluded(exclude, relative2(base, child)))
|
|
2980
3607
|
continue;
|
|
2981
3608
|
await walk(child, depth + 1);
|
|
2982
3609
|
}
|
|
2983
3610
|
};
|
|
2984
|
-
if (!
|
|
3611
|
+
if (!existsSync7(base))
|
|
2985
3612
|
return;
|
|
2986
3613
|
await walk(base, 0);
|
|
2987
3614
|
}
|
|
@@ -2996,8 +3623,8 @@ async function scanWorkshopRoot(workshopRoot, rootIndex, manifestFile, found) {
|
|
|
2996
3623
|
for (const entry of entries) {
|
|
2997
3624
|
if (!entry.isDirectory() || !/^\d+$/.test(entry.name))
|
|
2998
3625
|
continue;
|
|
2999
|
-
const dir =
|
|
3000
|
-
if (!
|
|
3626
|
+
const dir = join12(base, entry.name);
|
|
3627
|
+
if (!existsSync7(join12(dir, manifestFile)))
|
|
3001
3628
|
continue;
|
|
3002
3629
|
found.push({ dir, kind: "workshop", rootIndex, linkedWorktree: false, workshopId: Number(entry.name) });
|
|
3003
3630
|
}
|
|
@@ -3006,7 +3633,7 @@ async function scanGameData(game, rootIndex, found) {
|
|
|
3006
3633
|
const host = game.gameFiles.host;
|
|
3007
3634
|
if (game.gameFiles.source !== "mount" || host === undefined)
|
|
3008
3635
|
return;
|
|
3009
|
-
const data =
|
|
3636
|
+
const data = join12(resolvePath(expandHome(host)), "Data");
|
|
3010
3637
|
let entries;
|
|
3011
3638
|
try {
|
|
3012
3639
|
entries = await readdir6(data, { withFileTypes: true });
|
|
@@ -3016,8 +3643,8 @@ async function scanGameData(game, rootIndex, found) {
|
|
|
3016
3643
|
for (const entry of entries) {
|
|
3017
3644
|
if (!entry.isDirectory())
|
|
3018
3645
|
continue;
|
|
3019
|
-
const dir =
|
|
3020
|
-
if (!
|
|
3646
|
+
const dir = join12(data, entry.name);
|
|
3647
|
+
if (!existsSync7(join12(dir, game.manifest.file)))
|
|
3021
3648
|
continue;
|
|
3022
3649
|
found.push({ dir, kind: "official", rootIndex, linkedWorktree: false });
|
|
3023
3650
|
}
|
|
@@ -3026,7 +3653,7 @@ var CACHE_VERSION = 2;
|
|
|
3026
3653
|
function workshopStamp(game) {
|
|
3027
3654
|
if (game.workshopRoot === null)
|
|
3028
3655
|
return null;
|
|
3029
|
-
const acf =
|
|
3656
|
+
const acf = join12(dirname4(dirname4(resolvePath(expandHome(game.workshopRoot)))), `appworkshop_${game.steamAppId}.acf`);
|
|
3030
3657
|
try {
|
|
3031
3658
|
const info = statSync2(acf);
|
|
3032
3659
|
return `${info.mtimeMs}:${info.size}`;
|
|
@@ -3036,7 +3663,7 @@ function workshopStamp(game) {
|
|
|
3036
3663
|
}
|
|
3037
3664
|
async function readWorkshopCache(game, stamp) {
|
|
3038
3665
|
try {
|
|
3039
|
-
const raw = JSON.parse(await
|
|
3666
|
+
const raw = JSON.parse(await readFile6(join12(cacheDir(), `${game}.workshop.json`), "utf8"));
|
|
3040
3667
|
if (raw.version !== CACHE_VERSION || raw.stamp !== stamp)
|
|
3041
3668
|
return null;
|
|
3042
3669
|
return raw.records;
|
|
@@ -3048,7 +3675,7 @@ async function writeWorkshopCache(game, stamp, records) {
|
|
|
3048
3675
|
const payload = { version: CACHE_VERSION, stamp, records };
|
|
3049
3676
|
try {
|
|
3050
3677
|
await mkdir2(cacheDir(), { recursive: true });
|
|
3051
|
-
await
|
|
3678
|
+
await writeFile4(join12(cacheDir(), `${game}.workshop.json`), JSON.stringify(payload));
|
|
3052
3679
|
} catch {}
|
|
3053
3680
|
}
|
|
3054
3681
|
function toRecord(candidate, manifest, game) {
|
|
@@ -3131,8 +3758,8 @@ async function applySourceOverrides(index, overrides, config) {
|
|
|
3131
3758
|
}
|
|
3132
3759
|
const wanted = spec.slice(0, eq);
|
|
3133
3760
|
const dir = resolvePath(expandHome(spec.slice(eq + 1)));
|
|
3134
|
-
const file =
|
|
3135
|
-
if (!
|
|
3761
|
+
const file = join12(dir, config.manifest.file);
|
|
3762
|
+
if (!existsSync7(file)) {
|
|
3136
3763
|
problems.push({
|
|
3137
3764
|
where: spec,
|
|
3138
3765
|
message: `no ${config.manifest.file} under ${dir}`,
|
|
@@ -3142,7 +3769,7 @@ async function applySourceOverrides(index, overrides, config) {
|
|
|
3142
3769
|
}
|
|
3143
3770
|
let manifest;
|
|
3144
3771
|
try {
|
|
3145
|
-
manifest = index.plugin.parseManifest(
|
|
3772
|
+
manifest = index.plugin.parseManifest(readFileSync5(file, "utf8"));
|
|
3146
3773
|
} catch (error) {
|
|
3147
3774
|
problems.push({ where: file, message: `could not parse: ${String(error)}` });
|
|
3148
3775
|
continue;
|
|
@@ -3231,9 +3858,9 @@ async function buildIndex(game, config, plugin) {
|
|
|
3231
3858
|
}
|
|
3232
3859
|
async function parseAll(candidates, config, plugin, problems) {
|
|
3233
3860
|
const records = await Promise.all(candidates.map(async (candidate) => {
|
|
3234
|
-
const file =
|
|
3861
|
+
const file = join12(candidate.dir, config.manifest.file);
|
|
3235
3862
|
try {
|
|
3236
|
-
const manifest = plugin.parseManifest(await
|
|
3863
|
+
const manifest = plugin.parseManifest(await readFile6(file, "utf8"));
|
|
3237
3864
|
return manifest === null ? null : toRecord(candidate, manifest, config);
|
|
3238
3865
|
} catch (error) {
|
|
3239
3866
|
problems.push({
|
|
@@ -3302,11 +3929,11 @@ function byPath(index, raw, game) {
|
|
|
3302
3929
|
if (hit)
|
|
3303
3930
|
return hit;
|
|
3304
3931
|
}
|
|
3305
|
-
const file =
|
|
3306
|
-
if (!
|
|
3932
|
+
const file = join12(dir, game.manifest.file);
|
|
3933
|
+
if (!existsSync7(file))
|
|
3307
3934
|
return null;
|
|
3308
3935
|
try {
|
|
3309
|
-
const manifest = index.plugin.parseManifest(
|
|
3936
|
+
const manifest = index.plugin.parseManifest(readFileSync5(file, "utf8"));
|
|
3310
3937
|
if (manifest === null)
|
|
3311
3938
|
return null;
|
|
3312
3939
|
return toRecord({ dir, kind: "local", rootIndex: -1, linkedWorktree: false }, manifest, game);
|
|
@@ -3603,11 +4230,11 @@ async function resolvePlan(options) {
|
|
|
3603
4230
|
profileDir,
|
|
3604
4231
|
...instance.name === undefined ? {} : { instance: instance.name },
|
|
3605
4232
|
instanceDir: instance.dir,
|
|
3606
|
-
dataDirHost:
|
|
3607
|
-
configDirHost:
|
|
3608
|
-
stageDirHost:
|
|
3609
|
-
logsDirHost:
|
|
3610
|
-
runDirHost:
|
|
4233
|
+
dataDirHost: join13(instance.dir, "game"),
|
|
4234
|
+
configDirHost: join13(profileDir, "config"),
|
|
4235
|
+
stageDirHost: join13(instance.dir, ".stage"),
|
|
4236
|
+
logsDirHost: join13(instance.dir, "logs"),
|
|
4237
|
+
runDirHost: join13(instance.dir, "logs"),
|
|
3611
4238
|
mode,
|
|
3612
4239
|
...args.marker === undefined ? {} : { marker: args.marker },
|
|
3613
4240
|
timeoutSeconds: args.timeout ?? DEFAULT_TIMEOUT_SECONDS,
|
|
@@ -3618,11 +4245,100 @@ async function resolvePlan(options) {
|
|
|
3618
4245
|
return { plan, problems };
|
|
3619
4246
|
}
|
|
3620
4247
|
|
|
4248
|
+
// src/run/registry.ts
|
|
4249
|
+
import { readdir as readdir7 } from "node:fs/promises";
|
|
4250
|
+
import { join as join14 } from "node:path";
|
|
4251
|
+
var FORMAT = '{{.Names}}\t{{.Label "gamecrate.game"}}\t{{.Label "gamecrate.profile"}}\t{{.Label "gamecrate.instance"}}\t{{.Status}}';
|
|
4252
|
+
function parseDockerRuns(stdout) {
|
|
4253
|
+
const out = [];
|
|
4254
|
+
for (const line of stdout.split(`
|
|
4255
|
+
`)) {
|
|
4256
|
+
if (line.trim() === "")
|
|
4257
|
+
continue;
|
|
4258
|
+
const [container, game, profile, instance, uptime] = line.split("\t");
|
|
4259
|
+
if (container === undefined || game === undefined || profile === undefined)
|
|
4260
|
+
continue;
|
|
4261
|
+
out.push({
|
|
4262
|
+
game,
|
|
4263
|
+
profile,
|
|
4264
|
+
...instance ? { instance } : {},
|
|
4265
|
+
container,
|
|
4266
|
+
...uptime ? { uptime } : {},
|
|
4267
|
+
status: "running"
|
|
4268
|
+
});
|
|
4269
|
+
}
|
|
4270
|
+
return out;
|
|
4271
|
+
}
|
|
4272
|
+
async function walkLocks(dataRoot) {
|
|
4273
|
+
const out = [];
|
|
4274
|
+
for (const game of await entries(dataRoot)) {
|
|
4275
|
+
const gameDir = join14(dataRoot, game);
|
|
4276
|
+
for (const profile of await entries(gameDir)) {
|
|
4277
|
+
const profileDir = join14(gameDir, profile);
|
|
4278
|
+
await push(out, join14(profileDir, ".gamecrate", "lock"));
|
|
4279
|
+
const instancesDir = join14(profileDir, "instances");
|
|
4280
|
+
for (const instance of await entries(instancesDir)) {
|
|
4281
|
+
await push(out, join14(instancesDir, instance, ".gamecrate", "lock"));
|
|
4282
|
+
}
|
|
4283
|
+
}
|
|
4284
|
+
}
|
|
4285
|
+
return out;
|
|
4286
|
+
}
|
|
4287
|
+
async function entries(dir) {
|
|
4288
|
+
const found = await readdir7(dir, { withFileTypes: true }).catch(() => []);
|
|
4289
|
+
return found.filter((entry) => entry.isDirectory()).map((entry) => entry.name);
|
|
4290
|
+
}
|
|
4291
|
+
async function push(out, path) {
|
|
4292
|
+
const record = await readLock(path);
|
|
4293
|
+
if (record !== undefined)
|
|
4294
|
+
out.push(record);
|
|
4295
|
+
}
|
|
4296
|
+
var dockerPs = async () => {
|
|
4297
|
+
const { code, stdout } = await capture([
|
|
4298
|
+
"docker",
|
|
4299
|
+
"ps",
|
|
4300
|
+
"--filter",
|
|
4301
|
+
"label=gamecrate.game",
|
|
4302
|
+
"--format",
|
|
4303
|
+
FORMAT
|
|
4304
|
+
]);
|
|
4305
|
+
return code === 0 ? stdout : "";
|
|
4306
|
+
};
|
|
4307
|
+
async function listRuns(dataRoot, docker = dockerPs) {
|
|
4308
|
+
const running = parseDockerRuns(await docker());
|
|
4309
|
+
const byContainer = new Map(running.map((run) => [run.container, run]));
|
|
4310
|
+
const out = [...running];
|
|
4311
|
+
for (const lock of await walkLocks(dataRoot)) {
|
|
4312
|
+
const match = byContainer.get(lock.container);
|
|
4313
|
+
if (match !== undefined) {
|
|
4314
|
+
const stale = match.pid !== undefined && !isRunning(match.pid, match.startedAt);
|
|
4315
|
+
if (match.pid === undefined || stale && isRunning(lock.pid, lock.startedAt)) {
|
|
4316
|
+
match.pid = lock.pid;
|
|
4317
|
+
match.startedAt = lock.startedAt;
|
|
4318
|
+
if (lock.mode !== undefined)
|
|
4319
|
+
match.mode = lock.mode;
|
|
4320
|
+
}
|
|
4321
|
+
continue;
|
|
4322
|
+
}
|
|
4323
|
+
out.push({
|
|
4324
|
+
game: lock.game,
|
|
4325
|
+
profile: lock.profile,
|
|
4326
|
+
...lock.instance === undefined ? {} : { instance: lock.instance },
|
|
4327
|
+
container: lock.container,
|
|
4328
|
+
pid: lock.pid,
|
|
4329
|
+
...lock.mode === undefined ? {} : { mode: lock.mode },
|
|
4330
|
+
startedAt: lock.startedAt,
|
|
4331
|
+
status: isRunning(lock.pid, lock.startedAt) ? "starting" : "orphaned"
|
|
4332
|
+
});
|
|
4333
|
+
}
|
|
4334
|
+
return out;
|
|
4335
|
+
}
|
|
4336
|
+
|
|
3621
4337
|
// src/launch/stage.ts
|
|
3622
|
-
import { lstat, mkdir as mkdir3, readdir as
|
|
3623
|
-
import { basename as
|
|
4338
|
+
import { lstat, mkdir as mkdir3, readdir as readdir8, realpath, rm as rm2, stat as stat3 } from "node:fs/promises";
|
|
4339
|
+
import { basename as basename9, join as join15 } from "node:path";
|
|
3624
4340
|
async function stageMods(plan) {
|
|
3625
|
-
await
|
|
4341
|
+
await rm2(plan.stageDirHost, { recursive: true, force: true });
|
|
3626
4342
|
await mkdir3(plan.stageDirHost, { recursive: true });
|
|
3627
4343
|
const mounts = [];
|
|
3628
4344
|
for (const mod of plan.mods) {
|
|
@@ -3637,7 +4353,7 @@ async function stageMods(plan) {
|
|
|
3637
4353
|
if (!(await stat3(source)).isDirectory()) {
|
|
3638
4354
|
throw new GamecrateError(`${mod.packageId} does not resolve to a directory`, Exit.Environment, source);
|
|
3639
4355
|
}
|
|
3640
|
-
await mkdir3(
|
|
4356
|
+
await mkdir3(join15(plan.stageDirHost, basename9(mod.containerDir)), { recursive: true });
|
|
3641
4357
|
mounts.push({ type: "bind", source, target: mod.containerDir, readonly: true });
|
|
3642
4358
|
}
|
|
3643
4359
|
return mounts;
|
|
@@ -3647,12 +4363,12 @@ async function ensureProfileTree(plan) {
|
|
|
3647
4363
|
plan.profileDir,
|
|
3648
4364
|
plan.instanceDir,
|
|
3649
4365
|
plan.dataDirHost,
|
|
3650
|
-
|
|
3651
|
-
|
|
3652
|
-
|
|
3653
|
-
|
|
4366
|
+
join15(plan.configDirHost, "config"),
|
|
4367
|
+
join15(plan.configDirHost, "data"),
|
|
4368
|
+
join15(plan.configDirHost, "cache"),
|
|
4369
|
+
join15(plan.logsDirHost, "runs"),
|
|
3654
4370
|
plan.stageDirHost,
|
|
3655
|
-
|
|
4371
|
+
join15(plan.instanceDir, ".gamecrate"),
|
|
3656
4372
|
...engineDirs(plan)
|
|
3657
4373
|
]) {
|
|
3658
4374
|
await mkdir3(dir, { recursive: true });
|
|
@@ -3662,7 +4378,7 @@ function engineDirs(plan) {
|
|
|
3662
4378
|
const { dataDir, modsDir } = plan.gameConfig;
|
|
3663
4379
|
if (!modsDir.container.startsWith(`${dataDir.container}/`))
|
|
3664
4380
|
return [];
|
|
3665
|
-
return [
|
|
4381
|
+
return [join15(plan.dataDirHost, modsDir.container.slice(dataDir.container.length + 1))];
|
|
3666
4382
|
}
|
|
3667
4383
|
async function detectForeignOwnership(dir, uid, limit = 100) {
|
|
3668
4384
|
const foreign = [];
|
|
@@ -3683,8 +4399,8 @@ async function detectForeignOwnership(dir, uid, limit = 100) {
|
|
|
3683
4399
|
if (!info.isDirectory())
|
|
3684
4400
|
continue;
|
|
3685
4401
|
try {
|
|
3686
|
-
for (const entry of await
|
|
3687
|
-
queue.push(
|
|
4402
|
+
for (const entry of await readdir8(current))
|
|
4403
|
+
queue.push(join15(current, entry));
|
|
3688
4404
|
} catch {
|
|
3689
4405
|
continue;
|
|
3690
4406
|
}
|
|
@@ -3693,58 +4409,77 @@ async function detectForeignOwnership(dir, uid, limit = 100) {
|
|
|
3693
4409
|
}
|
|
3694
4410
|
|
|
3695
4411
|
// src/index.ts
|
|
3696
|
-
var VERSION = "1.
|
|
3697
|
-
var STOP_TIMEOUT_SECONDS = 10;
|
|
4412
|
+
var VERSION = "1.1.0";
|
|
3698
4413
|
async function main(argv) {
|
|
4414
|
+
const supervised = supervisedDir(argv);
|
|
4415
|
+
try {
|
|
4416
|
+
return await command(argv, supervised);
|
|
4417
|
+
} catch (error) {
|
|
4418
|
+
if (supervised === undefined)
|
|
4419
|
+
throw error;
|
|
4420
|
+
return await supervisorFailed(supervised, reportFatal(error));
|
|
4421
|
+
}
|
|
4422
|
+
}
|
|
4423
|
+
async function command(argv, supervised) {
|
|
3699
4424
|
const probe = parseArgs(argv);
|
|
3700
4425
|
if (probe.subcommand === "version") {
|
|
3701
4426
|
process.stdout.write(`gamecrate ${VERSION}
|
|
3702
4427
|
`);
|
|
3703
4428
|
return Exit.Ok;
|
|
3704
4429
|
}
|
|
3705
|
-
const
|
|
4430
|
+
const defaults = await loadProjectDefaults();
|
|
4431
|
+
const { config, plugins } = await loadConfig(undefined, defaults);
|
|
3706
4432
|
const args = parseArgs(argv, { games: Object.keys(config.games), defaults });
|
|
3707
4433
|
if (args.help) {
|
|
3708
4434
|
process.stdout.write(renderHelp(helpTopic(args), config));
|
|
3709
4435
|
return Exit.Ok;
|
|
3710
4436
|
}
|
|
3711
|
-
const redirect = args.log !== undefined && (args.subcommand === "run" || args.subcommand === "shell") ? redirectOutput(args.log) : undefined;
|
|
4437
|
+
const redirect = args.log !== undefined && (args.subcommand === "run" || args.subcommand === "shell") ? redirectOutput(args.log, args.supervised) : undefined;
|
|
3712
4438
|
try {
|
|
3713
|
-
return await dispatch(args, config, plugins);
|
|
4439
|
+
return await dispatch(argv, args, config, plugins, defaults);
|
|
3714
4440
|
} catch (error) {
|
|
3715
|
-
|
|
4441
|
+
const code = reportFatal(error);
|
|
4442
|
+
return supervised === undefined ? code : await supervisorFailed(supervised, code);
|
|
3716
4443
|
} finally {
|
|
3717
4444
|
redirect?.close();
|
|
3718
4445
|
}
|
|
3719
4446
|
}
|
|
3720
|
-
async function dispatch(args, config, plugins) {
|
|
4447
|
+
async function dispatch(argv, args, config, plugins, defaults) {
|
|
3721
4448
|
switch (args.subcommand) {
|
|
3722
4449
|
case "help":
|
|
3723
4450
|
return help(args, config);
|
|
3724
4451
|
case "list":
|
|
3725
|
-
return list(args, config);
|
|
4452
|
+
return list(args, config, defaults);
|
|
3726
4453
|
case "mods":
|
|
3727
|
-
return mods(args, config, plugins);
|
|
4454
|
+
return mods(args, config, plugins, defaults);
|
|
3728
4455
|
case "doctor":
|
|
3729
4456
|
return doctor(config, plugins);
|
|
3730
4457
|
case "clean":
|
|
3731
|
-
return clean(args, config);
|
|
4458
|
+
return clean(args, config, defaults);
|
|
3732
4459
|
case "clone":
|
|
3733
4460
|
return clone(args, config);
|
|
3734
4461
|
case "logs":
|
|
3735
|
-
return logs(args, config);
|
|
4462
|
+
return logs(args, config, defaults);
|
|
3736
4463
|
case "verify":
|
|
3737
|
-
return verify(args, config, plugins);
|
|
4464
|
+
return verify(args, config, plugins, defaults);
|
|
3738
4465
|
case "build":
|
|
3739
4466
|
return build(args, config);
|
|
4467
|
+
case "ps":
|
|
4468
|
+
return ps(args, config);
|
|
4469
|
+
case "stop":
|
|
4470
|
+
return stop(args, config, defaults);
|
|
4471
|
+
case "attach":
|
|
4472
|
+
return attach(args, config, defaults);
|
|
4473
|
+
case "wait":
|
|
4474
|
+
return waitFor(args, config, defaults);
|
|
3740
4475
|
case "shell":
|
|
3741
|
-
return run(args, config, plugins, true);
|
|
4476
|
+
return run(argv, args, config, plugins, defaults, true);
|
|
3742
4477
|
case "config":
|
|
3743
4478
|
return configEdit(args);
|
|
3744
4479
|
case "fix-perms":
|
|
3745
4480
|
return fixPerms(args, config);
|
|
3746
4481
|
case "run":
|
|
3747
|
-
return run(args, config, plugins, false);
|
|
4482
|
+
return run(argv, args, config, plugins, defaults, false);
|
|
3748
4483
|
default:
|
|
3749
4484
|
throw new GamecrateError(`no such subcommand ${args.subcommand}`, Exit.Usage);
|
|
3750
4485
|
}
|
|
@@ -3769,16 +4504,6 @@ function help(args, config) {
|
|
|
3769
4504
|
process.stdout.write(renderHelp(topic, config));
|
|
3770
4505
|
return Exit.Ok;
|
|
3771
4506
|
}
|
|
3772
|
-
function requireGame(args, config) {
|
|
3773
|
-
const game = args.game;
|
|
3774
|
-
if (game === undefined) {
|
|
3775
|
-
throw new GamecrateError(`${args.subcommand} needs a game`, Exit.Usage, `known games: ${Object.keys(config.games).join(", ")}`);
|
|
3776
|
-
}
|
|
3777
|
-
if (!Object.hasOwn(config.games, game)) {
|
|
3778
|
-
throw new GamecrateError(`unknown game "${game}"`, Exit.Config, `known games: ${Object.keys(config.games).join(", ")}`);
|
|
3779
|
-
}
|
|
3780
|
-
return game;
|
|
3781
|
-
}
|
|
3782
4507
|
function instanceDir(args, config, game, profile) {
|
|
3783
4508
|
const dir = profileDataDir(config, game, profile);
|
|
3784
4509
|
let spec;
|
|
@@ -3790,21 +4515,19 @@ function instanceDir(args, config, game, profile) {
|
|
|
3790
4515
|
return resolveInstance({ profileDir: dir, ...spec === undefined ? {} : { profile: spec }, args }).dir;
|
|
3791
4516
|
}
|
|
3792
4517
|
function reportEnvironment(problems) {
|
|
3793
|
-
|
|
3794
|
-
`);
|
|
4518
|
+
const out = [];
|
|
3795
4519
|
for (const problem of problems) {
|
|
3796
|
-
|
|
3797
|
-
${problem.message}
|
|
3798
|
-
`);
|
|
4520
|
+
out.push(` ${problem.where}
|
|
4521
|
+
${problem.message}`);
|
|
3799
4522
|
if (problem.suggestion)
|
|
3800
|
-
|
|
3801
|
-
`);
|
|
4523
|
+
out.push(` try: ${problem.suggestion}`);
|
|
3802
4524
|
}
|
|
3803
|
-
|
|
4525
|
+
throw new GamecrateError(`${problems.length} environment problem(s)`, Exit.Environment, out.join(`
|
|
4526
|
+
`));
|
|
3804
4527
|
}
|
|
3805
|
-
async function run(args, config, plugins, asShell) {
|
|
4528
|
+
async function run(argv, args, config, plugins, defaults, asShell) {
|
|
3806
4529
|
const game = requireGame(args, config);
|
|
3807
|
-
const profile = args
|
|
4530
|
+
const profile = profileOf(args, defaults);
|
|
3808
4531
|
const index = await buildIndex(game, config.games[game], requirePlugin(plugins, game));
|
|
3809
4532
|
const { plan, problems } = await resolvePlan({ game, profile, root: config, plugins, args, index });
|
|
3810
4533
|
if (problems.length > 0)
|
|
@@ -3818,7 +4541,7 @@ async function run(args, config, plugins, asShell) {
|
|
|
3818
4541
|
for (const warning of planWarnings(plan))
|
|
3819
4542
|
warn(warning);
|
|
3820
4543
|
if (environment.length > 0)
|
|
3821
|
-
|
|
4544
|
+
reportEnvironment(environment);
|
|
3822
4545
|
if (!args.printPlan) {
|
|
3823
4546
|
const what = plan.instance === undefined ? profile : `${profile}/${plan.instance}`;
|
|
3824
4547
|
status(`${game} ${what}: ${plan.mods.length} mods resolve cleanly`);
|
|
@@ -3827,18 +4550,24 @@ async function run(args, config, plugins, asShell) {
|
|
|
3827
4550
|
}
|
|
3828
4551
|
const environment = await preflight(plan);
|
|
3829
4552
|
if (environment.length > 0)
|
|
3830
|
-
|
|
4553
|
+
reportEnvironment(environment);
|
|
3831
4554
|
await ensureProfileTree(plan);
|
|
3832
|
-
|
|
4555
|
+
const profileSpec = resolveProfile(config.games[game], profile);
|
|
4556
|
+
if (wantsReplace(args, profileSpec))
|
|
3833
4557
|
await replacePrevious(plan);
|
|
3834
|
-
|
|
4558
|
+
if (!asShell && wantsDetach(args, profileSpec))
|
|
4559
|
+
return await forkSupervisor(plan, argv);
|
|
4560
|
+
const lock = args.supervised ? heldLock(plan) : await takeLock(plan);
|
|
3835
4561
|
try {
|
|
3836
|
-
|
|
4562
|
+
const result = await launch(plan, args, config, identity, asShell, profileSpec);
|
|
4563
|
+
if (args.supervised)
|
|
4564
|
+
await recordExit(plan, result);
|
|
4565
|
+
return result.code;
|
|
3837
4566
|
} finally {
|
|
3838
4567
|
await lock.release();
|
|
3839
4568
|
}
|
|
3840
4569
|
}
|
|
3841
|
-
async function launch(plan, args, config, identity, asShell) {
|
|
4570
|
+
async function launch(plan, args, config, identity, asShell, profileSpec) {
|
|
3842
4571
|
const game = plan.game;
|
|
3843
4572
|
const profile = plan.profile;
|
|
3844
4573
|
const foreign = await detectForeignOwnership(plan.dataDirHost, identity.uid, 5);
|
|
@@ -3849,7 +4578,16 @@ run: gamecrate fix-perms ${game} ${profile}`);
|
|
|
3849
4578
|
}
|
|
3850
4579
|
const runDir = openRunLog(plan.logsDirHost);
|
|
3851
4580
|
plan.runDirHost = runDir;
|
|
3852
|
-
|
|
4581
|
+
const supervisorLog = args.supervised && args.log === undefined ? redirectOutput(join16(runDir, "supervisor.log")) : undefined;
|
|
4582
|
+
try {
|
|
4583
|
+
return await execute(plan, args, config, identity, asShell, profileSpec, runDir);
|
|
4584
|
+
} finally {
|
|
4585
|
+
supervisorLog?.close();
|
|
4586
|
+
}
|
|
4587
|
+
}
|
|
4588
|
+
async function execute(plan, args, config, identity, asShell, profileSpec, runDir) {
|
|
4589
|
+
const game = plan.game;
|
|
4590
|
+
await buildLocalMods(plan, buildPolicy(args, profileSpec));
|
|
3853
4591
|
await acquireImage(game, config.games[game], args.pull ?? "missing");
|
|
3854
4592
|
const runtimeImage = plan.mode === "headed" ? config.games[game].image.ref : await ensureRuntimeLayer(config.games[game].image.ref);
|
|
3855
4593
|
const modMounts = await stageMods(plan);
|
|
@@ -3886,7 +4624,9 @@ run: gamecrate fix-perms ${game} ${profile}`);
|
|
|
3886
4624
|
logDir: runDir,
|
|
3887
4625
|
stopTimeoutSeconds: STOP_TIMEOUT_SECONDS
|
|
3888
4626
|
});
|
|
3889
|
-
|
|
4627
|
+
if (windowClosed)
|
|
4628
|
+
return { code: Exit.Ok, reason: "window-closed" };
|
|
4629
|
+
return { code: normalize(code), reason: reasonFor(code) };
|
|
3890
4630
|
} finally {
|
|
3891
4631
|
window?.stop();
|
|
3892
4632
|
}
|
|
@@ -3895,42 +4635,42 @@ run: gamecrate fix-perms ${game} ${profile}`);
|
|
|
3895
4635
|
}
|
|
3896
4636
|
}
|
|
3897
4637
|
function markerSources(plan, logDir) {
|
|
3898
|
-
const sources = [
|
|
4638
|
+
const sources = [join16(logDir, STDOUT_LOG)];
|
|
3899
4639
|
const { logFile } = plan.gameConfig;
|
|
3900
4640
|
if (logFile.mode === "arg")
|
|
3901
|
-
sources.push(
|
|
4641
|
+
sources.push(join16(logDir, "Player.log"));
|
|
3902
4642
|
else
|
|
3903
|
-
sources.push(
|
|
4643
|
+
sources.push(join16(plan.dataDirHost, logFile.from));
|
|
3904
4644
|
return sources;
|
|
3905
4645
|
}
|
|
3906
4646
|
async function runBounded(spec, plan, logDir) {
|
|
3907
4647
|
const container = runContainer(spec, { logDir, stopTimeoutSeconds: STOP_TIMEOUT_SECONDS });
|
|
3908
4648
|
const winner = await Promise.race([
|
|
3909
4649
|
container.then((code) => ({ kind: "exit", code })),
|
|
3910
|
-
|
|
4650
|
+
sleep5(plan.timeoutSeconds * 1000).then(() => ({ kind: "timeout" }))
|
|
3911
4651
|
]);
|
|
3912
4652
|
if (winner.kind === "exit")
|
|
3913
|
-
return normalize(winner.code);
|
|
4653
|
+
return { code: normalize(winner.code), reason: reasonFor(winner.code) };
|
|
3914
4654
|
status(`no marker given; stopping after ${plan.timeoutSeconds}s`);
|
|
3915
4655
|
await stopContainer(spec.name, STOP_TIMEOUT_SECONDS);
|
|
3916
4656
|
await container;
|
|
3917
|
-
return Exit.Ok;
|
|
4657
|
+
return { code: Exit.Ok, reason: "timeout" };
|
|
3918
4658
|
}
|
|
3919
4659
|
async function runWithScreenshot(spec, plan, logDir) {
|
|
3920
4660
|
const container = runContainer(spec, { logDir, stopTimeoutSeconds: STOP_TIMEOUT_SECONDS });
|
|
3921
|
-
const settled =
|
|
4661
|
+
const settled = sleep5(plan.renderWaitSeconds * 1000).then(() => "ready");
|
|
3922
4662
|
const winner = await Promise.race([
|
|
3923
4663
|
container.then((code) => ({ kind: "exit", code })),
|
|
3924
4664
|
settled.then(() => ({ kind: "ready" }))
|
|
3925
4665
|
]);
|
|
3926
4666
|
if (winner.kind === "exit") {
|
|
3927
4667
|
status(`game exited before the ${plan.renderWaitSeconds}s render wait finished; no frame captured`);
|
|
3928
|
-
return normalize(winner.code);
|
|
4668
|
+
return { code: normalize(winner.code), reason: reasonFor(winner.code) };
|
|
3929
4669
|
}
|
|
3930
4670
|
const shot = await grabFrame(spec.name, plan);
|
|
3931
4671
|
await stopContainer(spec.name, STOP_TIMEOUT_SECONDS);
|
|
3932
4672
|
await container;
|
|
3933
|
-
return shot === null ? Exit.Environment : Exit.Ok;
|
|
4673
|
+
return { code: shot === null ? Exit.Environment : Exit.Ok, reason: "stopped" };
|
|
3934
4674
|
}
|
|
3935
4675
|
async function grabFrame(container, plan) {
|
|
3936
4676
|
const path = await captureScreenshot(container, plan);
|
|
@@ -3949,17 +4689,17 @@ async function runWithMarker(spec, plan, logDir) {
|
|
|
3949
4689
|
seen.then((hit) => ({ kind: "marker", hit }))
|
|
3950
4690
|
]);
|
|
3951
4691
|
if (winner.kind === "exit")
|
|
3952
|
-
return normalize(winner.code);
|
|
4692
|
+
return { code: normalize(winner.code), reason: reasonFor(winner.code) };
|
|
3953
4693
|
if (plan.mode === "screenshot")
|
|
3954
4694
|
await grabFrame(spec.name, plan);
|
|
3955
4695
|
await stopContainer(spec.name, STOP_TIMEOUT_SECONDS);
|
|
3956
4696
|
await container;
|
|
3957
4697
|
if (winner.hit) {
|
|
3958
4698
|
status(`marker seen: ${marker}`);
|
|
3959
|
-
return Exit.Ok;
|
|
4699
|
+
return { code: Exit.Ok, reason: "marker" };
|
|
3960
4700
|
}
|
|
3961
4701
|
status(`marker "${marker}" not seen within ${plan.timeoutSeconds}s`);
|
|
3962
|
-
return Exit.MarkerTimeout;
|
|
4702
|
+
return { code: Exit.MarkerTimeout, reason: "marker-timeout" };
|
|
3963
4703
|
}
|
|
3964
4704
|
function normalize(code) {
|
|
3965
4705
|
return Number.isInteger(code) && code >= 0 && code <= 255 ? code : Exit.GameFailed;
|
|
@@ -3968,70 +4708,19 @@ async function copyOutLogs(plan) {
|
|
|
3968
4708
|
const spec = plan.gameConfig.logFile;
|
|
3969
4709
|
if (spec.mode !== "copy-out")
|
|
3970
4710
|
return;
|
|
3971
|
-
const source =
|
|
3972
|
-
if (!
|
|
4711
|
+
const source = join16(plan.dataDirHost, spec.from);
|
|
4712
|
+
if (!existsSync8(source))
|
|
3973
4713
|
return;
|
|
3974
|
-
const target =
|
|
4714
|
+
const target = join16(plan.runDirHost, basename10(spec.from.replace(/\/+$/, "")));
|
|
3975
4715
|
try {
|
|
3976
4716
|
await cp(source, target, { recursive: true, force: true });
|
|
3977
4717
|
} catch (error) {
|
|
3978
4718
|
warn(`could not copy ${source}: ${describe(error)}`);
|
|
3979
4719
|
}
|
|
3980
4720
|
}
|
|
3981
|
-
function
|
|
3982
|
-
const games = args.game === undefined ? Object.keys(config.games) : [requireGame(args, config)];
|
|
3983
|
-
if (args.json) {
|
|
3984
|
-
const payload = games.map((name) => {
|
|
3985
|
-
const game = config.games[name];
|
|
3986
|
-
return {
|
|
3987
|
-
game: name,
|
|
3988
|
-
core: game.core,
|
|
3989
|
-
dlc: game.dlc,
|
|
3990
|
-
modes: game.modes,
|
|
3991
|
-
profiles: Object.entries(game.profiles).map(([profile, spec]) => ({
|
|
3992
|
-
profile,
|
|
3993
|
-
alias: spec.alias ?? null,
|
|
3994
|
-
extends: spec.extends ?? null,
|
|
3995
|
-
mods: spec.mods?.length ?? 0,
|
|
3996
|
-
instances: Object.keys(spec.instances ?? {})
|
|
3997
|
-
}))
|
|
3998
|
-
};
|
|
3999
|
-
});
|
|
4000
|
-
process.stdout.write(`${JSON.stringify(payload, null, 2)}
|
|
4001
|
-
`);
|
|
4002
|
-
return Exit.Ok;
|
|
4003
|
-
}
|
|
4004
|
-
const out = [];
|
|
4005
|
-
for (const name of games) {
|
|
4006
|
-
const game = config.games[name];
|
|
4007
|
-
const width = Math.max(7, ...Object.keys(game.profiles).map((n) => n.length));
|
|
4008
|
-
out.push(`${name} (${game.modes.join(", ")})`);
|
|
4009
|
-
out.push(` ${"modless".padEnd(width)} built-in: core + official DLC`);
|
|
4010
|
-
for (const [profile, spec] of Object.entries(game.profiles)) {
|
|
4011
|
-
const notes = [];
|
|
4012
|
-
if (spec.alias)
|
|
4013
|
-
notes.push(`alias for ${spec.alias}`);
|
|
4014
|
-
if (spec.extends)
|
|
4015
|
-
notes.push(`extends ${spec.extends}`);
|
|
4016
|
-
const count = spec.mods?.length ?? 0;
|
|
4017
|
-
if (!spec.alias)
|
|
4018
|
-
notes.push(count === 1 ? "1 entry" : `${count} entries`);
|
|
4019
|
-
if (spec.aliases?.length)
|
|
4020
|
-
notes.push(`aka ${spec.aliases.join(", ")}`);
|
|
4021
|
-
out.push(` ${profile.padEnd(width)} ${notes.join(", ")}`);
|
|
4022
|
-
const instances = Object.keys(spec.instances ?? {});
|
|
4023
|
-
if (instances.length > 0)
|
|
4024
|
-
out.push(` ${" ".repeat(width)} instances: ${instances.join(", ")}`);
|
|
4025
|
-
}
|
|
4026
|
-
}
|
|
4027
|
-
process.stdout.write(`${out.join(`
|
|
4028
|
-
`)}
|
|
4029
|
-
`);
|
|
4030
|
-
return Exit.Ok;
|
|
4031
|
-
}
|
|
4032
|
-
async function mods(args, config, plugins) {
|
|
4721
|
+
async function mods(args, config, plugins, defaults) {
|
|
4033
4722
|
const game = requireGame(args, config);
|
|
4034
|
-
const profile = args
|
|
4723
|
+
const profile = profileOf(args, defaults);
|
|
4035
4724
|
const index = await buildIndex(game, config.games[game], requirePlugin(plugins, game));
|
|
4036
4725
|
const { plan, problems } = await resolvePlan({ game, profile, root: config, plugins, args, index });
|
|
4037
4726
|
if (problems.length > 0)
|
|
@@ -4061,24 +4750,27 @@ async function doctor(config, plugins) {
|
|
|
4061
4750
|
}
|
|
4062
4751
|
return failed ? Exit.Environment : Exit.Ok;
|
|
4063
4752
|
}
|
|
4064
|
-
async function logs(args, config) {
|
|
4753
|
+
async function logs(args, config, defaults) {
|
|
4065
4754
|
const game = requireGame(args, config);
|
|
4066
|
-
const profile = args
|
|
4067
|
-
const
|
|
4068
|
-
|
|
4755
|
+
const profile = profileOf(args, defaults);
|
|
4756
|
+
const dir = instanceDir(args, config, game, profile);
|
|
4757
|
+
if (args.follow)
|
|
4758
|
+
return await follow(dir, false, `${game} ${profile}`);
|
|
4759
|
+
const runs = join16(dir, "logs", "runs");
|
|
4760
|
+
const latest = (await readdir9(runs, { withFileTypes: true }).catch(() => [])).filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort().at(-1);
|
|
4069
4761
|
if (latest === undefined) {
|
|
4070
4762
|
throw new GamecrateError(`no runs recorded for ${game} ${profile}`, Exit.Usage, runs);
|
|
4071
4763
|
}
|
|
4072
|
-
const
|
|
4073
|
-
const files = (await
|
|
4764
|
+
const runDir = join16(runs, latest);
|
|
4765
|
+
const files = (await readdir9(runDir, { withFileTypes: true })).filter((entry) => entry.isFile()).map((entry) => entry.name).sort();
|
|
4074
4766
|
if (args.json) {
|
|
4075
|
-
process.stdout.write(`${JSON.stringify({ run: latest, dir, files }, null, 2)}
|
|
4767
|
+
process.stdout.write(`${JSON.stringify({ run: latest, dir: runDir, files }, null, 2)}
|
|
4076
4768
|
`);
|
|
4077
4769
|
return Exit.Ok;
|
|
4078
4770
|
}
|
|
4079
|
-
status(
|
|
4771
|
+
status(runDir);
|
|
4080
4772
|
for (const name of files) {
|
|
4081
|
-
const text = await
|
|
4773
|
+
const text = await readFile7(join16(runDir, name), "utf8").catch(() => "");
|
|
4082
4774
|
for (const line of text.split(`
|
|
4083
4775
|
`)) {
|
|
4084
4776
|
if (line.length > 0)
|
|
@@ -4116,9 +4808,9 @@ function boundStatus(mod) {
|
|
|
4116
4808
|
return "OK";
|
|
4117
4809
|
return mod.hasSources ? "STALE - never built" : "(xml only)";
|
|
4118
4810
|
}
|
|
4119
|
-
async function verify(args, config, plugins) {
|
|
4811
|
+
async function verify(args, config, plugins, defaults) {
|
|
4120
4812
|
const game = requireGame(args, config);
|
|
4121
|
-
const profile = args
|
|
4813
|
+
const profile = profileOf(args, defaults);
|
|
4122
4814
|
const { plan, problems } = await resolvePlan({ game, profile, root: config, plugins, args });
|
|
4123
4815
|
if (problems.length > 0)
|
|
4124
4816
|
reportProblems(problems);
|
|
@@ -4198,17 +4890,15 @@ function shortenHome(path) {
|
|
|
4198
4890
|
const home = homedir5();
|
|
4199
4891
|
return path === home || path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path;
|
|
4200
4892
|
}
|
|
4201
|
-
async function clean(args, config) {
|
|
4893
|
+
async function clean(args, config, defaults) {
|
|
4202
4894
|
const game = requireGame(args, config);
|
|
4203
|
-
const profile = args
|
|
4204
|
-
if (profile === undefined)
|
|
4205
|
-
throw new GamecrateError("clean needs a profile", Exit.Usage);
|
|
4895
|
+
const profile = profileOf(args, defaults);
|
|
4206
4896
|
const dir = profileDataDir(config, game, profile);
|
|
4207
4897
|
const tier = args.cleanTier ?? "staging";
|
|
4208
4898
|
const saveSuffixes = config.games[game].saveExtensions.map((ext) => `.${ext.replace(/^\./, "")}`.toLowerCase());
|
|
4209
4899
|
if (tier !== "all") {
|
|
4210
|
-
const target =
|
|
4211
|
-
await
|
|
4900
|
+
const target = join16(instanceDir(args, config, game, profile), tier === "logs" ? "logs" : ".stage");
|
|
4901
|
+
await rm3(target, { recursive: true, force: true });
|
|
4212
4902
|
status(`removed ${target}`);
|
|
4213
4903
|
return Exit.Ok;
|
|
4214
4904
|
}
|
|
@@ -4216,7 +4906,7 @@ async function clean(args, config) {
|
|
|
4216
4906
|
if (!args.yes) {
|
|
4217
4907
|
throw new GamecrateError(`clean --all would delete ${dir}, including ${saves} save file(s)`, Exit.Usage, "add --yes to confirm");
|
|
4218
4908
|
}
|
|
4219
|
-
await
|
|
4909
|
+
await rm3(dir, { recursive: true, force: true });
|
|
4220
4910
|
status(`removed ${dir} (${saves} save file(s))`);
|
|
4221
4911
|
return Exit.Ok;
|
|
4222
4912
|
}
|
|
@@ -4227,13 +4917,13 @@ async function countSaves(dir, suffixes) {
|
|
|
4227
4917
|
const current = queue.shift();
|
|
4228
4918
|
let entries;
|
|
4229
4919
|
try {
|
|
4230
|
-
entries = await
|
|
4920
|
+
entries = await readdir9(current, { withFileTypes: true });
|
|
4231
4921
|
} catch {
|
|
4232
4922
|
continue;
|
|
4233
4923
|
}
|
|
4234
4924
|
for (const entry of entries) {
|
|
4235
4925
|
if (entry.isDirectory())
|
|
4236
|
-
queue.push(
|
|
4926
|
+
queue.push(join16(current, entry.name));
|
|
4237
4927
|
else if (suffixes.some((s) => entry.name.toLowerCase().endsWith(s)))
|
|
4238
4928
|
count++;
|
|
4239
4929
|
}
|
|
@@ -4246,11 +4936,11 @@ async function clone(args, config) {
|
|
|
4246
4936
|
if (src === undefined || dst === undefined) {
|
|
4247
4937
|
throw new GamecrateError("clone needs a source and a destination profile", Exit.Usage);
|
|
4248
4938
|
}
|
|
4249
|
-
const from =
|
|
4250
|
-
const to =
|
|
4251
|
-
if (!
|
|
4939
|
+
const from = join16(profileDataDir(config, game, src), "game");
|
|
4940
|
+
const to = join16(profileDataDir(config, game, dst), "game");
|
|
4941
|
+
if (!existsSync8(from))
|
|
4252
4942
|
throw new GamecrateError(`${from} does not exist`, Exit.Usage);
|
|
4253
|
-
if (
|
|
4943
|
+
if (existsSync8(to) && !args.yes) {
|
|
4254
4944
|
throw new GamecrateError(`${to} already exists`, Exit.Usage, "add --yes to overwrite");
|
|
4255
4945
|
}
|
|
4256
4946
|
await mkdir4(to, { recursive: true });
|
|
@@ -4260,6 +4950,85 @@ async function clone(args, config) {
|
|
|
4260
4950
|
status(`cloned ${from} -> ${to}`);
|
|
4261
4951
|
return Exit.Ok;
|
|
4262
4952
|
}
|
|
4953
|
+
async function ps(args, config) {
|
|
4954
|
+
const runs = await listRuns(config.dataRoot);
|
|
4955
|
+
if (args.json) {
|
|
4956
|
+
process.stdout.write(`${JSON.stringify(runs, null, 2)}
|
|
4957
|
+
`);
|
|
4958
|
+
return Exit.Ok;
|
|
4959
|
+
}
|
|
4960
|
+
if (runs.length === 0) {
|
|
4961
|
+
status("nothing running");
|
|
4962
|
+
return Exit.Ok;
|
|
4963
|
+
}
|
|
4964
|
+
const rows = runs.map((entry) => [
|
|
4965
|
+
entry.game,
|
|
4966
|
+
entry.instance === undefined ? entry.profile : `${entry.profile}/${entry.instance}`,
|
|
4967
|
+
entry.mode ?? "",
|
|
4968
|
+
entry.pid === undefined ? "" : String(entry.pid),
|
|
4969
|
+
entry.container,
|
|
4970
|
+
entry.uptime ?? entry.status
|
|
4971
|
+
]);
|
|
4972
|
+
const widths = rows[0].map((_, i) => Math.max(...rows.map((row) => row[i].length)));
|
|
4973
|
+
for (const row of rows) {
|
|
4974
|
+
process.stdout.write(`${row.map((cell, i) => cell.padEnd(widths[i])).join(" ").trimEnd()}
|
|
4975
|
+
`);
|
|
4976
|
+
}
|
|
4977
|
+
if (runs.some((entry) => entry.status === "orphaned")) {
|
|
4978
|
+
warn("some locks have no container; run gamecrate stop to clear them");
|
|
4979
|
+
}
|
|
4980
|
+
return Exit.Ok;
|
|
4981
|
+
}
|
|
4982
|
+
async function stop(args, config, defaults) {
|
|
4983
|
+
const game = requireGame(args, config);
|
|
4984
|
+
const profile = profileOf(args, defaults);
|
|
4985
|
+
const file = join16(instanceDir(args, config, game, profile), ".gamecrate", "lock");
|
|
4986
|
+
const record = await readLock(file);
|
|
4987
|
+
if (record === undefined) {
|
|
4988
|
+
status(`${game} ${profile} is not running`);
|
|
4989
|
+
return Exit.Ok;
|
|
4990
|
+
}
|
|
4991
|
+
const outcome = await stopRun(record, file);
|
|
4992
|
+
if (outcome === "held") {
|
|
4993
|
+
warn(`pid ${record.pid} still holds ${file}; ${record.container} did not stop in time`);
|
|
4994
|
+
return Exit.Refused;
|
|
4995
|
+
}
|
|
4996
|
+
status(outcome === "signalled" ? `stopped ${record.container}` : `cleared the stale lock for ${record.container}`);
|
|
4997
|
+
return Exit.Ok;
|
|
4998
|
+
}
|
|
4999
|
+
async function attach(args, config, defaults) {
|
|
5000
|
+
const game = requireGame(args, config);
|
|
5001
|
+
const profile = profileOf(args, defaults);
|
|
5002
|
+
return await follow(instanceDir(args, config, game, profile), true, `${game} ${profile}`);
|
|
5003
|
+
}
|
|
5004
|
+
async function follow(dir, fromStart, what) {
|
|
5005
|
+
const lock = await readLock(join16(dir, ".gamecrate", "lock"));
|
|
5006
|
+
const held = lock !== undefined && isRunning(lock.pid, lock.startedAt);
|
|
5007
|
+
const live = held && await awaitRunLog(dir, lock);
|
|
5008
|
+
const file = currentLog(dir);
|
|
5009
|
+
if (!existsSync8(file)) {
|
|
5010
|
+
throw new GamecrateError(`no captured output for ${what}`, Exit.Usage, file);
|
|
5011
|
+
}
|
|
5012
|
+
return await spawnStatus(tailArgv(file, fromStart, live ? lock.pid : undefined), true);
|
|
5013
|
+
}
|
|
5014
|
+
async function waitFor(args, config, defaults) {
|
|
5015
|
+
const game = requireGame(args, config);
|
|
5016
|
+
const profile = profileOf(args, defaults);
|
|
5017
|
+
const dir = instanceDir(args, config, game, profile);
|
|
5018
|
+
const record = await awaitExit(dir);
|
|
5019
|
+
if (record === "orphaned") {
|
|
5020
|
+
throw new GamecrateError(`${game} ${profile}: the lock holder is gone and recorded no exit`, Exit.Refused, `run: gamecrate stop ${game} ${profile}`);
|
|
5021
|
+
}
|
|
5022
|
+
if (record === "absent") {
|
|
5023
|
+
throw new GamecrateError(`no run recorded for ${game} ${profile}`, Exit.Usage, dir);
|
|
5024
|
+
}
|
|
5025
|
+
if (args.json)
|
|
5026
|
+
process.stdout.write(`${JSON.stringify(record)}
|
|
5027
|
+
`);
|
|
5028
|
+
else
|
|
5029
|
+
status(`${game} ${profile}: ${record.reason} (${record.code})`);
|
|
5030
|
+
return record.code;
|
|
5031
|
+
}
|
|
4263
5032
|
async function build(args, config) {
|
|
4264
5033
|
const game = requireGame(args, config);
|
|
4265
5034
|
await acquireImage(game, config.games[game], args.pull ?? "always");
|
|
@@ -4269,13 +5038,15 @@ async function build(args, config) {
|
|
|
4269
5038
|
async function configEdit(args) {
|
|
4270
5039
|
if (args.rest[0] !== "edit")
|
|
4271
5040
|
throw new GamecrateError("config takes one word: edit", Exit.Usage);
|
|
4272
|
-
const
|
|
5041
|
+
const existing = await findGlobalConfig();
|
|
5042
|
+
const path = existing ?? join16(globalConfigDir(), "profiles.yml");
|
|
4273
5043
|
await mkdir4(dirname5(path), { recursive: true });
|
|
4274
|
-
if (!
|
|
4275
|
-
await
|
|
4276
|
-
|
|
4277
|
-
}
|
|
5044
|
+
if (!existsSync8(path)) {
|
|
5045
|
+
await writeFile5(path, `# gamecrate config. see https://github.com/RimWorks/gamecrate
|
|
5046
|
+
` + `plugins: []
|
|
5047
|
+
` + `games: {}
|
|
4278
5048
|
`);
|
|
5049
|
+
}
|
|
4279
5050
|
const editor = process.env.VISUAL ?? process.env.EDITOR;
|
|
4280
5051
|
if (editor === undefined)
|
|
4281
5052
|
throw new GamecrateError("no $EDITOR or $VISUAL set", Exit.Usage, path);
|
|
@@ -4290,7 +5061,7 @@ async function fixPerms(args, config) {
|
|
|
4290
5061
|
const identity = resolveIdentity(false);
|
|
4291
5062
|
const found = [];
|
|
4292
5063
|
for (const dir of await profileDirs(config, game, args.profile)) {
|
|
4293
|
-
if (!
|
|
5064
|
+
if (!existsSync8(dir))
|
|
4294
5065
|
continue;
|
|
4295
5066
|
found.push(...await detectForeignOwnership(dir, identity.uid, 1e4));
|
|
4296
5067
|
}
|
|
@@ -4333,7 +5104,7 @@ async function removeIfEmptyDir(path) {
|
|
|
4333
5104
|
const info = await stat4(path).catch(() => null);
|
|
4334
5105
|
if (info === null || !info.isDirectory())
|
|
4335
5106
|
return false;
|
|
4336
|
-
const entries = await
|
|
5107
|
+
const entries = await readdir9(path).catch(() => null);
|
|
4337
5108
|
if (entries === null || entries.length > 0)
|
|
4338
5109
|
return false;
|
|
4339
5110
|
return rmdir(path).then(() => true, () => false);
|