@gamecrate/cli 1.4.1 → 2.0.0

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