@gamecrate/cli 1.4.1 → 2.1.0

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