@gamecrate/cli 0.1.0 → 1.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.
Files changed (51) hide show
  1. package/dist/gamecrate.js +246 -246
  2. package/dist/lib.js +296 -0
  3. package/dist/types/cli/args.d.ts +40 -0
  4. package/dist/types/cli/help.d.ts +7 -0
  5. package/dist/types/cli/output.d.ts +56 -0
  6. package/dist/types/config/builtin.d.ts +3 -0
  7. package/dist/types/config/jsonc.d.ts +5 -0
  8. package/dist/types/config/load.d.ts +45 -0
  9. package/dist/types/config/validate.d.ts +12 -0
  10. package/dist/types/docker/identity.d.ts +6 -0
  11. package/dist/types/docker/preflight.d.ts +3 -0
  12. package/dist/types/docker/run.d.ts +37 -0
  13. package/dist/types/docker/spec.d.ts +24 -0
  14. package/dist/types/docker/window.d.ts +21 -0
  15. package/dist/types/index.d.ts +2 -0
  16. package/dist/types/launch/generate.d.ts +8 -0
  17. package/dist/types/launch/instance.d.ts +20 -0
  18. package/dist/types/launch/prepare.d.ts +44 -0
  19. package/dist/types/launch/resolve.d.ts +17 -0
  20. package/dist/types/launch/stage.d.ts +13 -0
  21. package/dist/types/lib.d.ts +3 -0
  22. package/dist/types/mods/modindex.d.ts +29 -0
  23. package/dist/types/mods/staleness.d.ts +28 -0
  24. package/dist/types/mods/worktree.d.ts +18 -0
  25. package/dist/types/plugin.d.ts +35 -0
  26. package/dist/types/types.d.ts +394 -0
  27. package/package.json +15 -10
  28. package/src/cli/args.ts +0 -592
  29. package/src/cli/help.ts +0 -193
  30. package/src/cli/output.ts +0 -246
  31. package/src/config/builtin.ts +0 -19
  32. package/src/config/jsonc.ts +0 -21
  33. package/src/config/load.ts +0 -387
  34. package/src/config/validate.ts +0 -0
  35. package/src/docker/identity.ts +0 -25
  36. package/src/docker/preflight.ts +0 -243
  37. package/src/docker/run.ts +0 -212
  38. package/src/docker/spec.ts +0 -357
  39. package/src/docker/window.ts +0 -152
  40. package/src/index.ts +0 -875
  41. package/src/launch/generate.ts +0 -151
  42. package/src/launch/instance.ts +0 -106
  43. package/src/launch/prepare.ts +0 -332
  44. package/src/launch/resolve.ts +0 -383
  45. package/src/launch/stage.ts +0 -97
  46. package/src/lib.ts +0 -22
  47. package/src/mods/modindex.ts +0 -539
  48. package/src/mods/staleness.ts +0 -125
  49. package/src/mods/worktree.ts +0 -107
  50. package/src/plugin.ts +0 -152
  51. package/src/types.ts +0 -423
package/dist/lib.js ADDED
@@ -0,0 +1,296 @@
1
+ // src/plugin.ts
2
+ import { exports as exportsField, legacy } from "resolve.exports";
3
+
4
+ // src/config/load.ts
5
+ import { parse as parseYaml } from "yaml";
6
+ import { z as z2 } from "zod";
7
+
8
+ // src/cli/args.ts
9
+ import { Command, CommanderError, Option } from "commander";
10
+
11
+ // src/types.ts
12
+ var Exit = {
13
+ Ok: 0,
14
+ GameFailed: 1,
15
+ Usage: 2,
16
+ Config: 3,
17
+ Resolution: 4,
18
+ Environment: 5,
19
+ MarkerTimeout: 6,
20
+ Refused: 7,
21
+ Stale: 8,
22
+ Interrupted: 130
23
+ };
24
+
25
+ class GamecrateError extends Error {
26
+ code;
27
+ detail;
28
+ constructor(message, code, detail) {
29
+ super(message);
30
+ this.code = code;
31
+ this.detail = detail;
32
+ this.name = "GamecrateError";
33
+ }
34
+ }
35
+ var NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
36
+
37
+ // src/cli/args.ts
38
+ function parseResolution(value) {
39
+ const match = /^(\d+)x(\d+)$/i.exec(value);
40
+ const width = Number(match?.[1]);
41
+ const height = Number(match?.[2]);
42
+ if (!Number.isSafeInteger(width) || width <= 0 || !Number.isSafeInteger(height) || height <= 0) {
43
+ throw usage(`--resolution takes positive dimensions like 1920x1080, got ${value}`);
44
+ }
45
+ return { width, height };
46
+ }
47
+ function usage(message, suggestion) {
48
+ return new GamecrateError(message, Exit.Usage, suggestion ? `did you mean ${suggestion}?` : undefined);
49
+ }
50
+ function suggest(word, candidates) {
51
+ const target = word.toLowerCase();
52
+ const limit = Math.max(2, Math.floor(target.length / 3));
53
+ let best;
54
+ let bestDistance = Infinity;
55
+ for (const candidate of candidates) {
56
+ const d = distance(target, candidate.toLowerCase());
57
+ if (d < bestDistance && d <= limit) {
58
+ best = candidate;
59
+ bestDistance = d;
60
+ }
61
+ }
62
+ return best;
63
+ }
64
+ function distance(a, b) {
65
+ let prev = Array.from({ length: b.length + 1 }, (_, i) => i);
66
+ for (let i = 1;i <= a.length; i++) {
67
+ const row = [i];
68
+ for (let j = 1;j <= b.length; j++) {
69
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
70
+ row[j] = Math.min(row[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost);
71
+ }
72
+ prev = row;
73
+ }
74
+ return prev[b.length];
75
+ }
76
+
77
+ // src/config/jsonc.ts
78
+ import { parse, printParseErrorCode } from "jsonc-parser";
79
+
80
+ // src/config/validate.ts
81
+ import { z } from "zod";
82
+ var MODES = ["headed", "headless", "screenshot"];
83
+ var HINTS = "\x00gamecrate/hints:";
84
+ function obj(shape) {
85
+ const known = Object.keys(shape);
86
+ return z.strictObject(shape, {
87
+ error: (issue) => issue.code === "unrecognized_keys" ? HINTS + JSON.stringify(issue.keys.map((key) => suggest(key, known) ?? null)) : "expected an object"
88
+ });
89
+ }
90
+ function requiredWhen(key, when) {
91
+ return (ctx) => {
92
+ if (!when(ctx.value) || ctx.value[key] !== undefined)
93
+ return;
94
+ ctx.issues.push({ code: "custom", message: `missing required key "${key}"`, path: [key], input: ctx.value });
95
+ };
96
+ }
97
+ var str = z.string({ error: "expected a string" });
98
+ var num = z.number({ error: "expected a number" });
99
+ var bool = z.boolean({ error: "expected a boolean" });
100
+ var strArray = z.array(z.string({ error: "expected an array of strings" }), {
101
+ error: "expected an array of strings"
102
+ });
103
+ var strMap = z.record(z.string(), z.string({ error: "expected an object of string values" }), {
104
+ error: "expected an object of string values"
105
+ });
106
+ function oneOf(values) {
107
+ return z.enum(values, { error: `expected one of ${values.join(", ")}` });
108
+ }
109
+ var modeName = z.unknown().check((ctx) => {
110
+ const value = ctx.value;
111
+ if (typeof value === "string" && MODES.includes(value))
112
+ return;
113
+ const hint = typeof value === "string" ? suggest(value, MODES) : undefined;
114
+ ctx.issues.push({
115
+ code: "custom",
116
+ message: `expected one of ${MODES.join(", ")}`,
117
+ input: value,
118
+ ...hint === undefined ? {} : { params: { suggestion: `did you mean "${hint}"?` } }
119
+ });
120
+ });
121
+ var settings = obj({
122
+ width: num.optional(),
123
+ height: num.optional(),
124
+ devMode: bool.optional(),
125
+ runInBackground: bool.optional(),
126
+ resetModsConfigOnCrash: bool.optional(),
127
+ gpu: bool.optional(),
128
+ audio: bool.optional(),
129
+ input: bool.optional(),
130
+ network: oneOf(["none", "bridge", "host"]).optional(),
131
+ display: oneOf(["x11", "wayland"]).optional(),
132
+ memory: str.optional(),
133
+ cpus: num.optional(),
134
+ pidsLimit: num.optional(),
135
+ prefsExtra: strMap.optional(),
136
+ gameArgs: strArray.optional(),
137
+ dockerArgs: strArray.optional()
138
+ });
139
+ var dynamicModEntry = obj({
140
+ match: str,
141
+ first: strArray.optional(),
142
+ sort: oneOf(["alpha", "none"]).optional(),
143
+ minMatches: num.optional()
144
+ });
145
+ var objectModEntry = obj({
146
+ id: str,
147
+ workshop: num.optional(),
148
+ path: str.optional(),
149
+ optional: bool.optional()
150
+ });
151
+ var modEntry = z.unknown().check((ctx) => {
152
+ const value = ctx.value;
153
+ if (typeof value === "string") {
154
+ if (value.trim() === "")
155
+ ctx.issues.push({ code: "custom", message: "mod entry is empty", input: value });
156
+ return;
157
+ }
158
+ if (!isObj(value)) {
159
+ ctx.issues.push({ code: "custom", message: "expected a packageId string or an object", input: value });
160
+ return;
161
+ }
162
+ const schema = value["match"] !== undefined ? dynamicModEntry : objectModEntry;
163
+ const result = schema.safeParse(value);
164
+ if (result.success)
165
+ return;
166
+ for (const issue of result.error.issues)
167
+ ctx.issues.push({ ...issue, input: value });
168
+ });
169
+ var profile = obj({
170
+ mods: z.array(modEntry, { error: "expected an array" }).optional(),
171
+ extends: str.optional(),
172
+ exclude: strArray.optional(),
173
+ includeBase: bool.optional(),
174
+ autoDependencies: bool.optional(),
175
+ settings: settings.optional(),
176
+ instances: z.record(z.string(), obj({ worktree: str.optional(), settings: settings.optional() }), {
177
+ error: "expected an object"
178
+ }).optional(),
179
+ alias: str.optional(),
180
+ aliases: strArray.optional()
181
+ }).check((ctx) => {
182
+ const v = ctx.value;
183
+ if (v.alias !== undefined && (v.extends !== undefined || v.mods !== undefined)) {
184
+ ctx.issues.push({
185
+ code: "custom",
186
+ message: 'an alias profile cannot also declare "mods" or "extends"',
187
+ input: v
188
+ });
189
+ }
190
+ });
191
+ var game = obj({
192
+ gameFiles: obj({ source: oneOf(["mount", "image"]), host: str.optional(), container: str }).check(requiredWhen("host", (v) => v["source"] === "mount")),
193
+ dataDir: obj({
194
+ container: str,
195
+ mode: oneOf(["arg", "env"]),
196
+ arg: str.optional(),
197
+ env: strMap.optional()
198
+ }).check(requiredWhen("arg", (v) => v["mode"] === "arg"), requiredWhen("env", (v) => v["mode"] === "env")),
199
+ modsDir: obj({ container: str, mask: strArray.optional() }),
200
+ 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")),
201
+ image: obj({ ref: str, acquire: oneOf(["pull", "build"]), context: str.optional() }).check(requiredWhen("context", (v) => v["acquire"] === "build")),
202
+ executable: str,
203
+ steamAppId: num,
204
+ workshopRoot: z.union([z.string(), z.null()], { error: "expected a string or null" }),
205
+ scanRoots: z.array(obj({ path: str, maxDepth: num, exclude: strArray.optional() }), {
206
+ error: "expected an array"
207
+ }),
208
+ manifest: obj({ file: str }),
209
+ modsConfig: obj({ file: str }),
210
+ prefs: obj({ file: str }),
211
+ saveExtensions: strArray,
212
+ core: str,
213
+ dlc: strArray,
214
+ preCore: strArray.optional(),
215
+ base: strArray.optional(),
216
+ library: z.record(z.string(), obj({ workshop: num.optional(), path: str.optional() }).check((ctx) => {
217
+ if (ctx.value["workshop"] === undefined && ctx.value["path"] === undefined) {
218
+ ctx.issues.push({
219
+ code: "custom",
220
+ message: 'library entry needs a "workshop" id or a "path"',
221
+ input: ctx.value
222
+ });
223
+ }
224
+ }), { error: "expected an object" }).optional(),
225
+ modes: z.array(modeName, { error: "expected a non-empty array" }).min(1, {
226
+ error: "expected a non-empty array"
227
+ }),
228
+ aliases: strMap.optional(),
229
+ settings: settings.optional(),
230
+ ignoresWmDelete: bool.optional(),
231
+ profiles: z.record(z.string(), profile, { error: "expected an object" })
232
+ });
233
+ var root = obj({
234
+ plugins: strArray.optional(),
235
+ dataRoot: str,
236
+ defaults: obj({ settings: settings.optional() }).optional(),
237
+ games: z.unknown()
238
+ });
239
+ function isObj(v) {
240
+ return typeof v === "object" && v !== null && !Array.isArray(v);
241
+ }
242
+
243
+ // src/config/load.ts
244
+ var projectName = z2.custom((v) => typeof v === "string" && NAME_PATTERN.test(v), "expected a name");
245
+ var projectStr = z2.string({ error: "expected a string" });
246
+ var projectBool = z2.boolean({ error: "expected true or false" });
247
+ var projectList = z2.custom((v) => Array.isArray(v) && v.every((entry) => typeof entry === "string"), "expected an array of strings");
248
+ var projectSeconds = z2.custom((v) => Number.isSafeInteger(v) && v >= 0, "expected a whole number of seconds");
249
+ function oneOf2(values) {
250
+ return z2.enum(values, { error: `expected one of ${values.join(", ")}` });
251
+ }
252
+ var BUILD_POLICIES = ["auto", "always", "never"];
253
+ var projectResolution = z2.string({ error: "expected dimensions like 1920x1080" }).check((ctx) => {
254
+ try {
255
+ parseResolution(ctx.value);
256
+ } catch (error) {
257
+ ctx.issues.push({ code: "custom", message: error.message, input: ctx.value });
258
+ }
259
+ }).transform(parseResolution);
260
+ var PROJECT_SCHEMA = z2.strictObject({
261
+ game: projectName.optional(),
262
+ profile: projectName.optional(),
263
+ mods: projectList.optional(),
264
+ without: projectList.optional(),
265
+ only: projectList.optional(),
266
+ dockerArgs: projectList.optional(),
267
+ gameArgs: projectList.optional(),
268
+ worktree: projectList.optional(),
269
+ use: projectList.optional(),
270
+ marker: projectStr.optional(),
271
+ instance: projectStr.optional(),
272
+ log: projectStr.optional(),
273
+ timeout: projectSeconds.optional(),
274
+ renderWait: projectSeconds.optional(),
275
+ dryRun: projectBool.optional(),
276
+ printPlan: projectBool.optional(),
277
+ json: projectBool.optional(),
278
+ root: projectBool.optional(),
279
+ noWorktree: projectBool.optional(),
280
+ noStaleCheck: projectBool.optional(),
281
+ replace: projectBool.optional(),
282
+ mode: oneOf2(["headed", "headless", "screenshot"]).optional(),
283
+ pull: oneOf2(["always", "missing", "never"]).optional(),
284
+ sort: oneOf2(["topo", "none"]).optional(),
285
+ network: oneOf2(["none", "bridge", "host"]).optional(),
286
+ build: z2.union([z2.boolean().transform((on) => on ? "always" : "never"), z2.enum(BUILD_POLICIES)], {
287
+ error: `expected one of ${BUILD_POLICIES.join(", ")}`
288
+ }).optional(),
289
+ resolution: projectResolution.optional()
290
+ }, { error: "expected an object" });
291
+
292
+ // src/plugin.ts
293
+ var PLUGIN_API_VERSION = 1;
294
+ export {
295
+ PLUGIN_API_VERSION
296
+ };
@@ -0,0 +1,40 @@
1
+ import { Command } from 'commander';
2
+ import type { ParsedArgs, ProjectDefaults } from '../types';
3
+ export type PositionalSlot = 'game' | 'profile' | 'rest';
4
+ export interface SubcommandSpec {
5
+ name: string;
6
+ summary: string;
7
+ /** Rendered after the subcommand word in usage lines. */
8
+ usage: string;
9
+ positionals: PositionalSlot[];
10
+ /** Flag names beyond the global set, in the order help should show them. */
11
+ flags: readonly string[];
12
+ }
13
+ /** The subcommand table. `modless` is reserved as a built-in profile, not a verb. */
14
+ export declare const SUBCOMMANDS: readonly SubcommandSpec[];
15
+ /** Shown for every subcommand. */
16
+ export declare const GLOBAL_FLAGS: readonly ["--json", "--help"];
17
+ /** The GAMECRATE_ vars, by the flag they stand in for. Fallbacks only; a flag always wins. */
18
+ export declare const FLAG_ENV: Record<string, string>;
19
+ /**
20
+ * Every flag, in the order help lists them. The only source of truth for what the CLI
21
+ * accepts: help and completion read it back off the program.
22
+ */
23
+ export declare function buildProgram(): Command;
24
+ export interface ParseOptions {
25
+ env?: Record<string, string | undefined>;
26
+ defaults?: ProjectDefaults;
27
+ /** Game names from the loaded config; enables did-you-mean on the first positional. */
28
+ games?: readonly string[];
29
+ }
30
+ /**
31
+ * `gamecrate <game> [profile] [flags] [-- game args]`, with the subcommand slot
32
+ * defaulting to `run`. Game args come after a bare `--` and nowhere else.
33
+ */
34
+ export declare function parseArgs(argv: string[], opts?: ParseOptions): ParsedArgs;
35
+ export declare function parseResolution(value: string): {
36
+ width: number;
37
+ height: number;
38
+ };
39
+ /** Closest candidate within an edit distance that scales with word length. */
40
+ export declare function suggest(word: string, candidates: readonly string[]): string | undefined;
@@ -0,0 +1,7 @@
1
+ import type { RootConfig } from '../types';
2
+ /**
3
+ * Three levels: no topic gives the top level, a subcommand name gives its usage,
4
+ * a game name gives that game's profiles and modes.
5
+ */
6
+ export declare function renderHelp(topic?: string, config?: RootConfig): string;
7
+ export declare function renderCompletion(shell: 'bash' | 'zsh'): string;
@@ -0,0 +1,56 @@
1
+ import type { Readable } from 'node:stream';
2
+ import type { LaunchPlan, Problem, ResolvedMod } from '../types';
3
+ /** Tool status. Never stdout: stdout belongs to the game. */
4
+ export declare function status(message: string): void;
5
+ export declare function warn(message: string): void;
6
+ export interface OutputRedirect {
7
+ close(): void;
8
+ }
9
+ export declare function redirectOutput(path: string): OutputRedirect;
10
+ export declare function forwardOutput(stream: Readable, target: NodeJS.WriteStream): Promise<void>;
11
+ /**
12
+ * Every collected failure at once, grouped by location. Resolution stops before any
13
+ * side effect, so reporting the first problem only would hide the rest.
14
+ */
15
+ export declare function reportProblems(problems: Problem[]): never;
16
+ export interface PlanModPayload {
17
+ packageId: string;
18
+ kind: ResolvedMod['kind'];
19
+ hostDir: string;
20
+ containerDir: string;
21
+ origin: 'explicit' | 'auto';
22
+ stale: boolean;
23
+ staleReport?: ResolvedMod['staleReport'];
24
+ workshopId?: number;
25
+ }
26
+ export interface PlanPayload {
27
+ game: string;
28
+ profile: string;
29
+ instance?: string;
30
+ mode: string;
31
+ marker?: string;
32
+ timeoutSeconds: number;
33
+ renderWaitSeconds: number;
34
+ profileDir: string;
35
+ instanceDir: string;
36
+ containerName: string;
37
+ dataDirHost: string;
38
+ stageDirHost: string;
39
+ logsDirHost: string;
40
+ mods: PlanModPayload[];
41
+ warnings: string[];
42
+ }
43
+ /**
44
+ * Regenerated from the mods rather than stored, because a successful `--build` clears a stale
45
+ * report and the warning has to disappear with it.
46
+ */
47
+ export declare function planWarnings(plan: LaunchPlan): string[];
48
+ /** The `--print-plan --json` payload. Bind mounts leave no host-readable link to assert on. */
49
+ export declare function planPayload(plan: LaunchPlan): PlanPayload;
50
+ export declare function printPlan(plan: LaunchPlan, asJson: boolean): void;
51
+ /** Sortable lexicographically and safe on every filesystem: 20260730T142335123Z. */
52
+ export declare function runTimestamp(now?: Date): string;
53
+ /** Makes <logsDir>/runs/<ts>, repoints `current` at it, rotates the old ones, returns the dir. */
54
+ export declare function openRunLog(logsDir: string, now?: Date): string;
55
+ /** Landmine 7: a 180MB Player-prev.log was 68% of a profile tree. Retention is the cap. */
56
+ export declare function rotateRuns(logsDir: string, keep: number): string[];
@@ -0,0 +1,3 @@
1
+ import type { Settings } from '../types';
2
+ export declare const DEFAULT_DATA_ROOT = "~/.local/share/gamecrate";
3
+ export declare const DEFAULT_SETTINGS: Settings;
@@ -0,0 +1,5 @@
1
+ /**
2
+ * JSON with line and block comments and trailing commas. jsonc-parser recovers from
3
+ * syntax errors and still returns a value, so the error list is the only success signal.
4
+ */
5
+ export declare function parseJsonc(text: string): unknown;
@@ -0,0 +1,45 @@
1
+ import type { GameConfig, ModEntry, ProfileConfig, ProjectDefaults, RootConfig, Settings } from '../types';
2
+ import type { GamePlugin } from '../plugin';
3
+ export declare function defaultConfigPath(): string;
4
+ export declare function findProjectConfig(start?: string): Promise<string | undefined>;
5
+ export declare function loadProjectDefaults(start?: string): Promise<ProjectDefaults>;
6
+ export interface LoadedConfig {
7
+ config: RootConfig;
8
+ plugins: Map<string, GamePlugin>;
9
+ }
10
+ /**
11
+ * Reads profiles.json, loads the plugins it lists, then merges the user's blocks over each
12
+ * plugin's defaults. A missing file means no games, which every non-launch subcommand survives.
13
+ */
14
+ export declare function loadConfig(path?: string): Promise<LoadedConfig>;
15
+ /** defaults -> games.<game> -> profile -> instance -> CLI. Scalars replace, arrays concatenate. */
16
+ export declare function resolveSettings(root: RootConfig, game: GameConfig, profile: ProfileConfig, ...overrides: (Partial<Settings> | undefined)[]): Settings;
17
+ /**
18
+ * Flattens `alias` and the `extends` chain into one profile. `exclude` survives on
19
+ * the result so the caller can subtract it from preCore/core/dlc/base too.
20
+ */
21
+ export declare function resolveProfile(game: GameConfig, name: string): ProfileConfig;
22
+ /**
23
+ * The name a profile stores its data under. An alias must not get its own data directory,
24
+ * or your saves split depending on which spelling you typed.
25
+ */
26
+ export declare function canonicalProfile(game: GameConfig, name: string): string;
27
+ /**
28
+ * The one place a profile's data directory is named. Every subcommand goes through it, so
29
+ * `logs`, `clean` and `clone` land on the directory `run` actually used, alias or not.
30
+ */
31
+ export declare function profileDataDir(root: RootConfig, game: string, profile: string): string;
32
+ /**
33
+ * The directories a command should touch for one game. With no profile it lists what is on disk,
34
+ * verbatim: a directory name is already a path, and canonicalizing it skips odd-cased ones.
35
+ */
36
+ export declare function profileDirs(root: RootConfig, game: string, profile?: string): Promise<string[]>;
37
+ /** Removes entries whose id matches an exclusion. Dynamic entries are filtered after expansion. */
38
+ export declare function subtract(mods: ModEntry[], exclude: string[]): ModEntry[];
39
+ export declare function globToRegExp(pattern: string): RegExp;
40
+ /**
41
+ * A later list replaces an earlier one, so a user can shorten a plugin's `dlc` or `modes`.
42
+ * `concatArrays` is the settings ladder's rule, where gameArgs accumulate across layers.
43
+ */
44
+ export declare function deepMerge<T>(base: T, over: unknown, concatArrays?: boolean): T;
45
+ export declare function expandHome(p: string): string;
@@ -0,0 +1,12 @@
1
+ import type { Problem, RootConfig } from '../types';
2
+ type Bag = Record<string, unknown>;
3
+ /**
4
+ * Phase one checks shape, phase two checks cross-references. Every problem is
5
+ * collected with a JSON Pointer; nothing throws on the first failure.
6
+ */
7
+ export declare function validateConfig(cfg: unknown): {
8
+ config: RootConfig;
9
+ problems: Problem[];
10
+ };
11
+ export declare function isObj(v: unknown): v is Bag;
12
+ export {};
@@ -0,0 +1,6 @@
1
+ import type { Identity } from '../types';
2
+ /**
3
+ * The single source of truth for `--user`, HOME, and every tmpfs uid=/gid=.
4
+ * Out-of-sync values give a blank window with no error, so nothing else may guess.
5
+ */
6
+ export declare function resolveIdentity(useRoot: boolean): Identity;
@@ -0,0 +1,3 @@
1
+ import type { LaunchPlan, Problem } from '../types';
2
+ /** Every check the launch depends on, collected so one run reports all of them at once. */
3
+ export declare function preflight(plan: LaunchPlan): Promise<Problem[]>;
@@ -0,0 +1,37 @@
1
+ import type { ChildProcess, StdioOptions } from 'node:child_process';
2
+ import type { Readable } from 'node:stream';
3
+ import type { DockerRunSpec } from '../types';
4
+ /** argv as one array, the way every caller here has it. */
5
+ export declare function spawnArgv(argv: string[], stdio: StdioOptions): ChildProcess;
6
+ /** Rejects when the spawn itself fails, so a missing binary lands where a bad exit code would. */
7
+ export declare function exited(proc: ChildProcess): Promise<number>;
8
+ export declare function collect(stream: Readable): Promise<string>;
9
+ /**
10
+ * Runs a short command and collects both streams. A missing binary is exit 127 with the spawn
11
+ * error as stderr, so every caller can report it the same way rather than throwing.
12
+ */
13
+ export declare function capture(argv: string[]): Promise<{
14
+ code: number;
15
+ stdout: string;
16
+ stderr: string;
17
+ }>;
18
+ /** The tee'd combined stream, and what waitForMarker watches. */
19
+ export declare const STDOUT_LOG = "stdout.log";
20
+ export interface RunOptions {
21
+ /** Run log directory; created if missing. Receives stdout.log. */
22
+ logDir: string;
23
+ /** Passed to `docker stop --timeout` when a signal arrives. */
24
+ stopTimeoutSeconds?: number;
25
+ }
26
+ /**
27
+ * Spawns docker directly rather than through a pipeline, so the game's status is the status.
28
+ * `exec docker run | tee` returns tee's code, which is why the old scripts always reported 0.
29
+ */
30
+ export declare function runContainer(spec: DockerRunSpec, opts: RunOptions): Promise<number>;
31
+ export declare function stopContainer(name: string, timeoutSeconds: number): Promise<void>;
32
+ /**
33
+ * Host-side marker watch. Watches container stdout AND the game's own log file: RimWorld
34
+ * sends Verse.Log output to -logfile, never to stdout, so a stdout-only watch can never
35
+ * match a RimWorld mod's message.
36
+ */
37
+ export declare function waitForMarker(sources: string[], marker: string, timeoutSeconds: number): Promise<boolean>;
@@ -0,0 +1,24 @@
1
+ import type { DockerRunSpec, Identity, LaunchPlan, Mount } from '../types';
2
+ /** Container-side XDG_RUNTIME_DIR. A sized tmpfs; display and audio sockets land inside it. */
3
+ export declare const CONTAINER_RUNTIME_DIR = "/tmp/xdg";
4
+ /** Where the run directory is bound, so `-logfile /logs/Player.log` lands beside stdout.log. */
5
+ export declare const CONTAINER_LOG_DIR = "/logs";
6
+ export declare function buildRunSpec(plan: LaunchPlan, modMounts: Mount[], identity: Identity): DockerRunSpec;
7
+ /** Instances of one profile run side by side, so the name has to carry which one this is. */
8
+ export declare function containerName(plan: LaunchPlan): string;
9
+ /** What the window is renamed to, so a taskbar full of worktrees is readable. */
10
+ export declare function windowTitle(plan: LaunchPlan): string;
11
+ export declare function toDockerArgs(spec: DockerRunSpec): string[];
12
+ /**
13
+ * The host X session a headed run joins. The cookie is looked up separately from the socket
14
+ * because XWayland under a display manager keeps it in XDG_RUNTIME_DIR, not ~/.Xauthority.
15
+ * Whether the socket directory is really there is checkBindSources' job, as with every bind.
16
+ */
17
+ export declare function x11Session(): {
18
+ display: string;
19
+ xauthority: string | null;
20
+ } | null;
21
+ export declare function waylandSocket(): {
22
+ source: string;
23
+ name: string;
24
+ } | null;
@@ -0,0 +1,21 @@
1
+ export interface WindowWatch {
2
+ stop: () => void;
3
+ }
4
+ export interface AdoptOptions {
5
+ executable: string;
6
+ title: string;
7
+ /** Set for an engine that claims WM_DELETE_WINDOW and ignores it, RimWorld being the one. */
8
+ stripDelete: boolean;
9
+ /** Called once the window a stripDelete run adopted is gone. */
10
+ onClosed: () => void;
11
+ }
12
+ /**
13
+ * Retitles the window a headed X11 run opens and fixes what the WM knows about it. Snapshots
14
+ * the screen first and takes the first window that was not there: an X client inside a
15
+ * container reports a container-local _NET_WM_PID and hostname, so neither of those identifies
16
+ * the run from out here. The WM_CLASS comes from the executable, which narrows it to this game
17
+ * rather than any window that opened.
18
+ */
19
+ export declare function adoptNewWindow(opts: AdoptOptions): Promise<WindowWatch>;
20
+ /** `WM_PROTOCOLS(ATOM): protocols WM_DELETE_WINDOW, WM_TAKE_FOCUS`, or `: not found.` */
21
+ export declare function parseAtoms(stdout: string): string[];
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,8 @@
1
+ import type { LaunchPlan } from '../types';
2
+ /** Rewritten in full every launch; the resolved list is the only source of truth. */
3
+ export declare function generateModsConfig(plan: LaunchPlan): Promise<string>;
4
+ /**
5
+ * Merges key-by-key. The live Prefs holds ~40 tuned keys, so a rewrite destroys
6
+ * volumeMaster, uiScale, langFolderName and the nested screenShakeIntensity block.
7
+ */
8
+ export declare function mergePrefs(plan: LaunchPlan): Promise<string>;
@@ -0,0 +1,20 @@
1
+ import type { ParsedArgs, Problem, ProfileConfig, Settings, WorktreeRequest } from '../types';
2
+ export interface InstanceSelection {
3
+ /** Undefined for the base profile. */
4
+ name?: string;
5
+ /** profileDir, or <profileDir>/instances/<name>. */
6
+ dir: string;
7
+ requests: WorktreeRequest[];
8
+ problems: Problem[];
9
+ settings?: Partial<Settings>;
10
+ }
11
+ export interface InstanceOptions {
12
+ profileDir: string;
13
+ /** Absent when the caller only has a profile name, as `clean` on an unknown profile does. */
14
+ profile?: ProfileConfig;
15
+ args: Partial<ParsedArgs>;
16
+ cwd?: string;
17
+ env?: string;
18
+ }
19
+ /** Decides which sub-run of a profile this is. Any worktree in the set forks one. */
20
+ export declare function resolveInstance(options: InstanceOptions): InstanceSelection;
@@ -0,0 +1,44 @@
1
+ import type { BuildPolicy, GameConfig, LaunchPlan, PullPolicy } from '../types';
2
+ /** Resolved image id, so `launches.jsonl` records what actually ran, not a floating tag. */
3
+ export declare function imageDigest(ref: string): Promise<string | null>;
4
+ /**
5
+ * Pulls or builds per policy. Shared by the `build` subcommand and `run`, so a launch can no
6
+ * longer proceed against an image the user asked to refresh.
7
+ */
8
+ export declare function acquireImage(game: string, config: GameConfig, pull: PullPolicy): Promise<void>;
9
+ /**
10
+ * Tag for the derived image, keeping the registry path readable. A tag only exists after the
11
+ * last `/`: before it a colon is a registry port, and an `@` means a digest no suffix can ride.
12
+ */
13
+ export declare function runtimeLayerRef(ref: string): string;
14
+ /**
15
+ * Builds (once) a thin layer over the adapter's image carrying an X server and imagemagick.
16
+ * Detects the package manager so a debian-based game image works the same as an Arch one.
17
+ */
18
+ export declare function ensureRuntimeLayer(ref: string): Promise<string>;
19
+ /**
20
+ * Builds local mods before launch. `always` builds every local mod; `auto` builds only the
21
+ * ones resolution flagged stale; `never` skips. A build failure stops the launch — shipping
22
+ * the previous DLL after a failed compile is how you debug code that is not running.
23
+ */
24
+ export declare function buildLocalMods(plan: LaunchPlan, policy: BuildPolicy): Promise<void>;
25
+ /**
26
+ * Per-instance launch lock. Two concurrent runs would both `rm -rf` the same stage tree, so
27
+ * the second is refused rather than allowed to race. Separate instances never meet here.
28
+ */
29
+ export interface ProfileLock {
30
+ release: () => Promise<void>;
31
+ }
32
+ export declare function takeLock(plan: LaunchPlan): Promise<ProfileLock>;
33
+ /**
34
+ * `--replace`: stops the container for this profile and instance only, so a parallel worktree
35
+ * run is untouched. Waits for the holder to release before forcing, because its own release
36
+ * would otherwise unlink the lock we are about to take.
37
+ */
38
+ export declare function replacePrevious(plan: LaunchPlan): Promise<void>;
39
+ /**
40
+ * Grabs one frame from inside the running container. The run dir is already bind-mounted at
41
+ * CONTAINER_LOG_DIR, so the png lands next to that run's logs with no extra mount.
42
+ */
43
+ export declare function captureScreenshot(container: string, plan: LaunchPlan): Promise<string | null>;
44
+ export declare function writeLaunchRecord(plan: LaunchPlan, image: string): Promise<void>;
@@ -0,0 +1,17 @@
1
+ import type { GamePlugin } from '../plugin';
2
+ import type { LaunchPlan, ModIndex, ParsedArgs, Problem, RootConfig } from '../types';
3
+ export interface ResolveOptions {
4
+ game: string;
5
+ profile: string;
6
+ root: RootConfig;
7
+ plugins: Map<string, GamePlugin>;
8
+ args?: Partial<ParsedArgs>;
9
+ /** Prebuilt index; buildIndex runs when absent. */
10
+ index?: ModIndex;
11
+ /** Overrides process.cwd() for ambient worktree detection; tests set it. */
12
+ cwd?: string;
13
+ }
14
+ export declare function resolvePlan(options: ResolveOptions): Promise<{
15
+ plan: LaunchPlan;
16
+ problems: Problem[];
17
+ }>;