@gamecrate/cli 1.0.0 → 1.2.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/lib.js CHANGED
@@ -2,7 +2,6 @@
2
2
  import { exports as exportsField, legacy } from "resolve.exports";
3
3
 
4
4
  // src/config/load.ts
5
- import { parse as parseYaml } from "yaml";
6
5
  import { z as z2 } from "zod";
7
6
 
8
7
  // src/cli/args.ts
@@ -21,7 +20,6 @@ var Exit = {
21
20
  Stale: 8,
22
21
  Interrupted: 130
23
22
  };
24
-
25
23
  class GamecrateError extends Error {
26
24
  code;
27
25
  detail;
@@ -74,6 +72,10 @@ function distance(a, b) {
74
72
  return prev[b.length];
75
73
  }
76
74
 
75
+ // src/config/read.ts
76
+ import { parseTree } from "jsonc-parser";
77
+ import { isMap, parse as parseYaml, parseDocument } from "yaml";
78
+
77
79
  // src/config/jsonc.ts
78
80
  import { parse, printParseErrorCode } from "jsonc-parser";
79
81
 
@@ -177,7 +179,11 @@ var profile = obj({
177
179
  error: "expected an object"
178
180
  }).optional(),
179
181
  alias: str.optional(),
180
- aliases: strArray.optional()
182
+ aliases: strArray.optional(),
183
+ description: str.optional(),
184
+ detach: bool.optional(),
185
+ replace: bool.optional(),
186
+ build: oneOf(["auto", "always", "never"]).optional()
181
187
  }).check((ctx) => {
182
188
  const v = ctx.value;
183
189
  if (v.alias !== undefined && (v.extends !== undefined || v.mods !== undefined)) {
@@ -188,6 +194,37 @@ var profile = obj({
188
194
  });
189
195
  }
190
196
  });
197
+ var libraryEntry = obj({
198
+ workshop: num.optional(),
199
+ path: str.optional(),
200
+ git: str.optional(),
201
+ branch: str.optional(),
202
+ tag: str.optional(),
203
+ commit: str.optional(),
204
+ subdir: str.optional()
205
+ }).check((ctx) => {
206
+ const v = ctx.value;
207
+ const push = (message, path) => {
208
+ ctx.issues.push({ code: "custom", message, input: v, ...path === undefined ? {} : { path } });
209
+ };
210
+ const sources = ["workshop", "path", "git"].filter((k) => v[k] !== undefined);
211
+ if (sources.length === 0)
212
+ push('library entry needs a "workshop" id, a "path", or a "git" url');
213
+ if (sources.length > 1)
214
+ push(`library entry takes only one of ${sources.join(", ")}`);
215
+ const refs = ["branch", "tag", "commit"].filter((k) => v[k] !== undefined);
216
+ if (refs.length > 1)
217
+ push(`library entry takes only one of branch, tag or commit, got ${refs.join(", ")}`);
218
+ if (v["git"] === undefined) {
219
+ for (const key of [...refs, ...v["subdir"] === undefined ? [] : ["subdir"]]) {
220
+ push(`"${key}" needs a "git" url`, [key]);
221
+ }
222
+ }
223
+ const subdir = v["subdir"];
224
+ if (typeof subdir === "string" && (subdir.startsWith("/") || subdir.split("/").includes(".."))) {
225
+ push('"subdir" must be a relative path inside the repo, with no ".." segment', ["subdir"]);
226
+ }
227
+ });
191
228
  var game = obj({
192
229
  gameFiles: obj({ source: oneOf(["mount", "image"]), host: str.optional(), container: str }).check(requiredWhen("host", (v) => v["source"] === "mount")),
193
230
  dataDir: obj({
@@ -213,15 +250,7 @@ var game = obj({
213
250
  dlc: strArray,
214
251
  preCore: strArray.optional(),
215
252
  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(),
253
+ library: z.record(z.string(), libraryEntry, { error: "expected an object" }).optional(),
225
254
  modes: z.array(modeName, { error: "expected a non-empty array" }).min(1, {
226
255
  error: "expected a non-empty array"
227
256
  }),
@@ -257,9 +286,13 @@ var projectResolution = z2.string({ error: "expected dimensions like 1920x1080"
257
286
  ctx.issues.push({ code: "custom", message: error.message, input: ctx.value });
258
287
  }
259
288
  }).transform(parseResolution);
260
- var PROJECT_SCHEMA = z2.strictObject({
289
+ var PROJECT_OBJECT = z2.strictObject({
261
290
  game: projectName.optional(),
262
- profile: projectName.optional(),
291
+ defaultProfile: projectName.optional(),
292
+ profiles: z2.record(z2.string(), z2.unknown()).optional(),
293
+ settings: z2.record(z2.string(), z2.unknown()).optional(),
294
+ library: z2.record(z2.string(), z2.unknown()).optional(),
295
+ detach: projectBool.optional(),
263
296
  mods: projectList.optional(),
264
297
  without: projectList.optional(),
265
298
  only: projectList.optional(),
@@ -288,6 +321,25 @@ var PROJECT_SCHEMA = z2.strictObject({
288
321
  }).optional(),
289
322
  resolution: projectResolution.optional()
290
323
  }, { error: "expected an object" });
324
+ var PROJECT_SCHEMA = PROJECT_OBJECT.check((ctx) => {
325
+ const { game, profiles, settings, library } = ctx.value;
326
+ if (game !== undefined)
327
+ return;
328
+ for (const [key, value] of [
329
+ ["profiles", profiles],
330
+ ["settings", settings],
331
+ ["library", library]
332
+ ]) {
333
+ if (value === undefined)
334
+ continue;
335
+ ctx.issues.push({
336
+ code: "custom",
337
+ path: [key],
338
+ message: "needs a top-level game: to say which game it belongs to",
339
+ input: ctx.value
340
+ });
341
+ }
342
+ });
291
343
 
292
344
  // src/plugin.ts
293
345
  var PLUGIN_API_VERSION = 1;
@@ -1,5 +1,5 @@
1
1
  import { Command } from 'commander';
2
- import type { ParsedArgs, ProjectDefaults } from '../types';
2
+ import type { BuildPolicy, ParsedArgs, ProfileConfig, ProjectDefaults } from '../types';
3
3
  export type PositionalSlot = 'game' | 'profile' | 'rest';
4
4
  export interface SubcommandSpec {
5
5
  name: string;
@@ -9,6 +9,8 @@ export interface SubcommandSpec {
9
9
  positionals: PositionalSlot[];
10
10
  /** Flag names beyond the global set, in the order help should show them. */
11
11
  flags: readonly string[];
12
+ /** Slots to use instead of `positionals` when the next word is one of these. */
13
+ subverbs?: Readonly<Record<string, PositionalSlot[]>>;
12
14
  }
13
15
  /** The subcommand table. `modless` is reserved as a built-in profile, not a verb. */
14
16
  export declare const SUBCOMMANDS: readonly SubcommandSpec[];
@@ -32,9 +34,25 @@ export interface ParseOptions {
32
34
  * defaulting to `run`. Game args come after a bare `--` and nowhere else.
33
35
  */
34
36
  export declare function parseArgs(argv: string[], opts?: ParseOptions): ParsedArgs;
37
+ /**
38
+ * Any layer can ask for this; the --no-detach flag is the only refusal. The supervisor is
39
+ * already the fork, and a profile or project `detach: true` reaches it too: it never forks again.
40
+ */
41
+ export declare function wantsDetach(args: ParsedArgs, profile: ProfileConfig): boolean;
42
+ /** The parent already replaced the previous run, and the only lock left is the child's own. */
43
+ export declare function wantsReplace(args: ParsedArgs, profile: ProfileConfig): boolean;
44
+ /** Three-way, so first defined wins. --no-build already arrives as 'never'. */
45
+ export declare function buildPolicy(args: ParsedArgs, profile: ProfileConfig): BuildPolicy;
35
46
  export declare function parseResolution(value: string): {
36
47
  width: number;
37
48
  height: number;
38
49
  };
39
50
  /** Closest candidate within an edit distance that scales with word length. */
40
51
  export declare function suggest(word: string, candidates: readonly string[]): string | undefined;
52
+ /**
53
+ * The argv that re-execs this same gamecrate as a supervisor. The compiled binary reports
54
+ * a virtual /$bunfs path as argv[1], which the child would read as a game name.
55
+ */
56
+ export declare function supervisorArgv(userArgs: string[], instanceDir: string, self?: string[], execPath?: string): string[];
57
+ /** Read straight off argv: the recovery path runs before anything is parsed or loaded. */
58
+ export declare function supervisedDir(argv: string[]): string | undefined;
@@ -0,0 +1,2 @@
1
+ import type { ParsedArgs, RootConfig } from '../types';
2
+ export declare function requireGame(args: ParsedArgs, config: RootConfig): string;
@@ -0,0 +1,2 @@
1
+ import type { ParsedArgs, ProjectDefaults, RootConfig } from '../types';
2
+ export declare function list(args: ParsedArgs, config: RootConfig, defaults: ProjectDefaults): number;
@@ -0,0 +1,16 @@
1
+ import type { GamePlugin } from '../plugin';
2
+ import type { ParsedArgs, ProjectDefaults, RootConfig } from '../types';
3
+ export interface ModsContext {
4
+ config: RootConfig;
5
+ plugins: Map<string, GamePlugin>;
6
+ defaults: ProjectDefaults;
7
+ /** Where findProjectConfig starts walking. Injectable so tests need no chdir. */
8
+ cwd: string;
9
+ /** The resolved global config path, or where to create one. Injectable for the same reason. */
10
+ globalPath: string;
11
+ }
12
+ /** The same fallback loadConfig uses, so an error names the file `config edit` would open. */
13
+ export declare function globalConfigPath(): Promise<string>;
14
+ export declare function modsAdd(args: ParsedArgs, ctx: ModsContext): Promise<number>;
15
+ export declare function modsRm(args: ParsedArgs, ctx: ModsContext): Promise<number>;
16
+ export declare function modsSync(args: ParsedArgs, ctx: ModsContext): Promise<number>;
@@ -6,7 +6,7 @@ export declare function warn(message: string): void;
6
6
  export interface OutputRedirect {
7
7
  close(): void;
8
8
  }
9
- export declare function redirectOutput(path: string): OutputRedirect;
9
+ export declare function redirectOutput(path: string, keep?: boolean): OutputRedirect;
10
10
  export declare function forwardOutput(stream: Readable, target: NodeJS.WriteStream): Promise<void>;
11
11
  /**
12
12
  * Every collected failure at once, grouped by location. Resolution stops before any
@@ -52,5 +52,27 @@ export declare function printPlan(plan: LaunchPlan, asJson: boolean): void;
52
52
  export declare function runTimestamp(now?: Date): string;
53
53
  /** Makes <logsDir>/runs/<ts>, repoints `current` at it, rotates the old ones, returns the dir. */
54
54
  export declare function openRunLog(logsDir: string, now?: Date): string;
55
+ /** Below the first a wait is not worth mentioning; the log normally appears in milliseconds. */
56
+ export interface NoticeSchedule {
57
+ firstMs: number;
58
+ everyMs: number;
59
+ }
60
+ export declare const WAIT_NOTICE: NoticeSchedule;
61
+ /**
62
+ * The elapsed label when a wait is due a line, else undefined. Driven by elapsed time and not
63
+ * by poll count: a quarter-second poll would scroll a line per pass and read as its own kind
64
+ * of broken, while a silent ten-minute image build looks exactly like a hang. Both branches
65
+ * floor, so the label never claims more time than has passed.
66
+ */
67
+ export declare function waitNotice(waitedMs: number, lastNoticeMs: number, schedule?: NoticeSchedule): string | undefined;
68
+ /** Inverse of runTimestamp. Anchored at the start, so the -2 collision suffix is tolerated. */
69
+ export declare function runStartedAt(name: string): number | undefined;
70
+ /** linkCurrent keeps `current` pointed at the newest run, so the run dir is never guessed. */
71
+ export declare function currentLog(instanceDir: string): string;
72
+ /**
73
+ * tail -f never ends on its own, so a live run gets --pid and tail leaves when the holder
74
+ * does. With no live holder nothing will write again, so -f is dropped or it hangs forever.
75
+ */
76
+ export declare function tailArgv(file: string, fromStart: boolean, livePid?: number): string[];
55
77
  /** Landmine 7: a 180MB Player-prev.log was 68% of a profile tree. Retention is the cap. */
56
78
  export declare function rotateRuns(logsDir: string, keep: number): string[];
@@ -0,0 +1,6 @@
1
+ import type { ParsedArgs, ProjectDefaults } from '../types';
2
+ /**
3
+ * Not in parseArgs: filling a default there would erase the difference between a typed
4
+ * profile and a defaulted one, which clean and fix-perms both need.
5
+ */
6
+ export declare function profileOf(args: ParsedArgs, defaults: ProjectDefaults): string;
@@ -1,6 +1,7 @@
1
1
  import type { GameConfig, ModEntry, ProfileConfig, ProjectDefaults, RootConfig, Settings } from '../types';
2
2
  import type { GamePlugin } from '../plugin';
3
- export declare function defaultConfigPath(): string;
3
+ export declare function globalConfigDir(): string;
4
+ export declare function findGlobalConfig(): Promise<string | undefined>;
4
5
  export declare function findProjectConfig(start?: string): Promise<string | undefined>;
5
6
  export declare function loadProjectDefaults(start?: string): Promise<ProjectDefaults>;
6
7
  export interface LoadedConfig {
@@ -8,10 +9,10 @@ export interface LoadedConfig {
8
9
  plugins: Map<string, GamePlugin>;
9
10
  }
10
11
  /**
11
- * Reads profiles.json, loads the plugins it lists, then merges the user's blocks over each
12
+ * Reads the global config, loads the plugins it lists, then merges the user's blocks over each
12
13
  * plugin's defaults. A missing file means no games, which every non-launch subcommand survives.
13
14
  */
14
- export declare function loadConfig(path?: string): Promise<LoadedConfig>;
15
+ export declare function loadConfig(path?: string, project?: ProjectDefaults): Promise<LoadedConfig>;
15
16
  /** defaults -> games.<game> -> profile -> instance -> CLI. Scalars replace, arrays concatenate. */
16
17
  export declare function resolveSettings(root: RootConfig, game: GameConfig, profile: ProfileConfig, ...overrides: (Partial<Settings> | undefined)[]): Settings;
17
18
  /**
@@ -34,6 +35,7 @@ export declare function profileDataDir(root: RootConfig, game: string, profile:
34
35
  * verbatim: a directory name is already a path, and canonicalizing it skips odd-cased ones.
35
36
  */
36
37
  export declare function profileDirs(root: RootConfig, game: string, profile?: string): Promise<string[]>;
38
+ export declare function profileKey(game: GameConfig, name: string): string | undefined;
37
39
  /** Removes entries whose id matches an exclusion. Dynamic entries are filtered after expansion. */
38
40
  export declare function subtract(mods: ModEntry[], exclude: string[]): ModEntry[];
39
41
  export declare function globToRegExp(pattern: string): RegExp;
@@ -0,0 +1,11 @@
1
+ /** Probe order. yaml first: it is what `config edit` writes and what the docs show. */
2
+ export declare const CONFIG_SUFFIXES: readonly [".yml", ".yaml", ".json", ".jsonc"];
3
+ export declare function readConfigText(text: string, path: string): unknown;
4
+ /** Undefined when the file is not there; every other read error propagates. */
5
+ export declare function readConfigFile(path: string): Promise<unknown>;
6
+ /**
7
+ * Source order of the keys under one top-level object. Object.keys sorts all-integer keys
8
+ * to the front, and NAME_PATTERN lets a profile be called 2024, so the syntax tree is the
9
+ * only honest answer to "which profile was written first".
10
+ */
11
+ export declare function orderedKeys(text: string, path: string, key: string): string[];
@@ -0,0 +1,14 @@
1
+ /** A value of `undefined` deletes the key rather than writing an undefined. */
2
+ export interface ConfigEdit {
3
+ path: (string | number)[];
4
+ value: unknown;
5
+ }
6
+ /**
7
+ * jsonc-parser never sniffs the file, so an inserted block lands with whatever width it is
8
+ * handed. A file indented with tabs and edited with spaces reads as two files.
9
+ */
10
+ export declare function detectIndent(text: string): {
11
+ tabSize: number;
12
+ insertSpaces: boolean;
13
+ };
14
+ export declare function writeConfig(file: string, edits: ConfigEdit[]): Promise<void>;
@@ -2,7 +2,7 @@ import type { ChildProcess, StdioOptions } from 'node:child_process';
2
2
  import type { Readable } from 'node:stream';
3
3
  import type { DockerRunSpec } from '../types';
4
4
  /** argv as one array, the way every caller here has it. */
5
- export declare function spawnArgv(argv: string[], stdio: StdioOptions): ChildProcess;
5
+ export declare function spawnArgv(argv: string[], stdio: StdioOptions, detached?: boolean): ChildProcess;
6
6
  /** Rejects when the spawn itself fails, so a missing binary lands where a bad exit code would. */
7
7
  export declare function exited(proc: ChildProcess): Promise<number>;
8
8
  export declare function collect(stream: Readable): Promise<string>;
@@ -28,6 +28,8 @@ export interface RunOptions {
28
28
  * `exec docker run | tee` returns tee's code, which is why the old scripts always reported 0.
29
29
  */
30
30
  export declare function runContainer(spec: DockerRunSpec, opts: RunOptions): Promise<number>;
31
+ /** Grace docker gives the game before SIGKILL, and the budget every teardown is measured against. */
32
+ export declare const STOP_TIMEOUT_SECONDS = 10;
31
33
  export declare function stopContainer(name: string, timeoutSeconds: number): Promise<void>;
32
34
  /**
33
35
  * Host-side marker watch. Watches container stdout AND the game's own log file: RimWorld
@@ -1,6 +1,34 @@
1
1
  export interface WindowWatch {
2
2
  stop: () => void;
3
3
  }
4
+ export interface Toplevel {
5
+ id: string;
6
+ wmClass: string;
7
+ }
8
+ /** Every window that appeared since the snapshot and belongs to this game, in wmctrl's order. */
9
+ export declare function newMatches(now: Toplevel[], seen: Set<string>, executable: string): Toplevel[];
10
+ export declare function parseWindowPid(stdout: string): number | undefined;
11
+ /**
12
+ * Another supervisor's window. Our own claim, or a dead or non-gamecrate pid, is not.
13
+ * Deliberately not `isRunning`: the cmdline read already throws for a dead pid, and it stays
14
+ * readable for a supervisor owned by another user, where a signal check is denied and would
15
+ * have us steal that run's window.
16
+ *
17
+ * Matched on each argument's own basename rather than anywhere in the raw cmdline, so a
18
+ * gamecrate log path or data directory in an unrelated process's arguments is not a peer. An
19
+ * editor opened on a directory literally named gamecrate still is; nothing in /proc separates
20
+ * those two.
21
+ *
22
+ * It also stops matching `bun run src/index.ts`, where the old substring match caught the repo
23
+ * directory in the script path. Installed users are unaffected, the bin and gamecrate.js both
24
+ * match; it costs peer detection between two concurrent from-source dev runs.
25
+ *
26
+ * Residual: before adoption `_NET_WM_PID` is the container's pid namespace, so this looks a
27
+ * container-local number up in the host's `/proc`. A false hit makes the run skip its own
28
+ * window for the whole wait. Container game pids are small and low host pids are kernel
29
+ * threads with an empty cmdline, so in practice the read returns nothing and no match happens.
30
+ */
31
+ export declare function isPeerClaim(pid: number, self: number): boolean;
4
32
  export interface AdoptOptions {
5
33
  executable: string;
6
34
  title: string;
@@ -14,7 +42,8 @@ export interface AdoptOptions {
14
42
  * the screen first and takes the first window that was not there: an X client inside a
15
43
  * container reports a container-local _NET_WM_PID and hostname, so neither of those identifies
16
44
  * 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.
45
+ * rather than any window that opened. Once adopted, _NET_WM_PID holds our pid, so a second run
46
+ * starting at the same time can see the window is already spoken for and keep waiting for its own.
18
47
  */
19
48
  export declare function adoptNewWindow(opts: AdoptOptions): Promise<WindowWatch>;
20
49
  /** `WM_PROTOCOLS(ATOM): protocols WM_DELETE_WINDOW, WM_TAKE_FOCUS`, or `: not found.` */
@@ -18,7 +18,7 @@ export declare function runtimeLayerRef(ref: string): string;
18
18
  export declare function ensureRuntimeLayer(ref: string): Promise<string>;
19
19
  /**
20
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
21
+ * ones resolution flagged stale; `never` skips. A build failure stops the launch, shipping
22
22
  * the previous DLL after a failed compile is how you debug code that is not running.
23
23
  */
24
24
  export declare function buildLocalMods(plan: LaunchPlan, policy: BuildPolicy): Promise<void>;
@@ -29,13 +29,56 @@ export declare function buildLocalMods(plan: LaunchPlan, policy: BuildPolicy): P
29
29
  export interface ProfileLock {
30
30
  release: () => Promise<void>;
31
31
  }
32
+ /**
33
+ * Refuses when this profile and instance are already up, and clears a lock whose holder is
34
+ * gone. Detach runs this before it forks, so a stale lock cannot strand the child.
35
+ */
36
+ export declare function clearLock(plan: LaunchPlan): Promise<void>;
37
+ /** The parent wrote this lock with the child's pid, so the child only has to drop it. */
38
+ export declare function heldLock(plan: LaunchPlan): ProfileLock;
32
39
  export declare function takeLock(plan: LaunchPlan): Promise<ProfileLock>;
40
+ /** Everything ps, stop, attach and wait need about a run, without reopening the container. */
41
+ export interface LockRecord {
42
+ pid: number;
43
+ container: string;
44
+ game: string;
45
+ profile: string;
46
+ instance?: string;
47
+ detached: boolean;
48
+ mode?: string;
49
+ startedAt: string;
50
+ }
51
+ export declare function lockPath(plan: LaunchPlan): string;
52
+ export declare function readLock(path: string): Promise<LockRecord | undefined>;
53
+ /** wx fails rather than truncating, which is what makes this a lock and not a note. */
54
+ export declare function writeLock(plan: LaunchPlan, record: Omit<LockRecord, 'startedAt'>): Promise<void>;
33
55
  /**
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.
56
+ * A signalled supervisor cannot finish before its own `docker stop --timeout` does, so the
57
+ * budget is derived from that rather than guessed alongside it.
58
+ */
59
+ export declare const STOP_RELEASE_WAIT_MS: number;
60
+ /**
61
+ * What stopRun did, because "the supervisor took the signal" and "the lock was stale anyway"
62
+ * are not the same answer and the caller has to say which one it is.
63
+ */
64
+ export type StopOutcome = 'signalled' | 'orphaned' | 'held';
65
+ /**
66
+ * Signals the supervisor, not the container. runContainer turns SIGTERM into a docker stop and
67
+ * returns 130, so the run records itself as stopped rather than crashed. Only a run whose
68
+ * supervisor is already gone gets the container stopped out from under it.
69
+ */
70
+ export declare function stopRun(record: LockRecord, lockFile: string): Promise<StopOutcome>;
71
+ /**
72
+ * `--replace`: ends the run for this profile and instance only, so a parallel worktree run is
73
+ * untouched. Goes through stopRun so the replaced run reports `stopped`; a raw docker stop
74
+ * would land as 137 instead.
37
75
  */
38
76
  export declare function replacePrevious(plan: LaunchPlan): Promise<void>;
77
+ /**
78
+ * A pid alone is not proof. Detach leaves a long-lived process per run, so a recycled number
79
+ * would refuse every future launch and give ps a ghost with an invented uptime.
80
+ */
81
+ export declare function isRunning(pid: number, startedAt?: string): boolean;
39
82
  /**
40
83
  * Grabs one frame from inside the running container. The run dir is already bind-mounted at
41
84
  * CONTAINER_LOG_DIR, so the png lands next to that run's logs with no extra mount.
@@ -1,5 +1,5 @@
1
1
  import type { GamePlugin } from '../plugin';
2
- import type { LaunchPlan, ModIndex, ParsedArgs, Problem, RootConfig } from '../types';
2
+ import type { GameConfig, LaunchPlan, ModIndex, ParsedArgs, Problem, RootConfig } from '../types';
3
3
  export interface ResolveOptions {
4
4
  game: string;
5
5
  profile: string;
@@ -10,7 +10,14 @@ export interface ResolveOptions {
10
10
  index?: ModIndex;
11
11
  /** Overrides process.cwd() for ambient worktree detection; tests set it. */
12
12
  cwd?: string;
13
+ /** Lowercased packageId to the clone directory prepared for its git pin. */
14
+ sources?: ReadonlyMap<string, string>;
13
15
  }
16
+ /**
17
+ * doctor resolves the modless profile, so no mod ref is ever resolved there. Read the config
18
+ * instead: library pins, plus every profile's mods in both the object and bare-string forms.
19
+ */
20
+ export declare function workshopRootProblem(gameName: string, game: GameConfig): Problem | null;
14
21
  export declare function resolvePlan(options: ResolveOptions): Promise<{
15
22
  plan: LaunchPlan;
16
23
  problems: Problem[];
@@ -0,0 +1,48 @@
1
+ import type { NoticeSchedule } from '../cli/output';
2
+ import type { LockRecord } from './prepare';
3
+ import type { ExitReason, LaunchPlan, LaunchResult } from '../types';
4
+ /**
5
+ * Written once, after the spawn, with the child's pid: a take-then-rewrite would leave a dead
6
+ * pid over a live child, and the next launcher would unlink it and race the same stage tree.
7
+ * Writing first is not open either, since a placeholder pid reads as "no lock" everywhere.
8
+ *
9
+ * So the child is killed when the write loses the `wx` race, because a supervisor with no lock
10
+ * runs unguarded and its release would drop the winner's lock.
11
+ *
12
+ * Residual: a parent killed between the spawn and the write leaves that child unguarded, with
13
+ * nothing left to kill it. That window spans a fork and an exec, so milliseconds.
14
+ */
15
+ export declare function forkSupervisor(plan: LaunchPlan, argv: string[]): Promise<number>;
16
+ export declare function recordExit(plan: LaunchPlan, result: LaunchResult): Promise<void>;
17
+ /**
18
+ * The supervisor has no terminal and its stdio is discarded, so the exit record is the only way
19
+ * a caller ever learns it died. The lock goes only once that record is on disk, so a failed
20
+ * write leaves it: clearLock takes a dead holder on the next launch, while a cleared lock with
21
+ * no record makes wait answer "no run recorded" for a run that really failed.
22
+ */
23
+ export declare function supervisorFailed(dir: string, code: number): Promise<number>;
24
+ export interface ExitRecord {
25
+ at: string;
26
+ code: number;
27
+ reason: ExitReason;
28
+ container?: string;
29
+ runDir?: string;
30
+ }
31
+ /** The other half of writeExit. Missing or corrupt reads as "no exit recorded", never a throw. */
32
+ export declare function lastExit(instanceDir: string): Promise<ExitRecord | undefined>;
33
+ /**
34
+ * Blocks while a live holder has the lock. `orphaned` is a holder that died without recording
35
+ * anything, which is reachable with SIGKILL; `absent` is a run that never happened. Neither
36
+ * can ever produce an exit code, so neither may keep waiting for one.
37
+ *
38
+ * Once a lock has been seen, the pid outranks the file: the lock can vanish because someone
39
+ * else cleared it, but a live pid still owes us a record.
40
+ */
41
+ export declare function awaitExit(instanceDir: string, poll?: number): Promise<ExitRecord | 'orphaned' | 'absent'>;
42
+ /**
43
+ * The lock is written right after the spawn, but the supervisor only opens its run log after
44
+ * preflight, staging and the image, so `current` can point at the previous run for as long as
45
+ * a pull takes. tail follows a descriptor, so starting there would watch a finished run and
46
+ * never catch up. Waiting is bounded by the holder: if it dies first, there is nothing coming.
47
+ */
48
+ export declare function awaitRunLog(instanceDir: string, lock: LockRecord, poll?: number, notice?: NoticeSchedule): Promise<boolean>;
@@ -18,10 +18,11 @@ export declare function applyWorktreeRequests(index: ModIndex, requests: Worktre
18
18
  */
19
19
  export declare function applySourceOverrides(index: ModIndex, overrides: string[], config: GameConfig): Promise<Problem[]>;
20
20
  /**
21
- * Scans the game install, then every scan root in declaration order, then the workshop root.
22
- * Local roots rescan every launch; only the workshop scan is cached, against the acf stamp.
21
+ * Scans the game install, then every scan root in declaration order, then the source cache,
22
+ * then the workshop root. Local roots rescan every launch; only the workshop scan is cached,
23
+ * against the acf stamp.
23
24
  */
24
- export declare function buildIndex(game: string, config: GameConfig, plugin: GamePlugin): Promise<ModIndex>;
25
+ export declare function buildIndex(game: string, config: GameConfig, plugin: GamePlugin, sourcesDir?: string): Promise<ModIndex>;
25
26
  /**
26
27
  * `path:` and `workshop:` are explicit; a bare string resolves as exact packageId, then the
27
28
  * game's alias map, then a CLI-only short name. Ambiguity that the ladder cannot break is fatal.
@@ -0,0 +1,56 @@
1
+ import type { GameConfig, LibraryEntry, ParsedArgs } from '../types';
2
+ export type GitRef = {
3
+ kind: 'branch' | 'tag' | 'commit';
4
+ value: string;
5
+ };
6
+ /**
7
+ * A library pin by id, case-blind. Exact then lowercase covers every normal config without a
8
+ * scan; the scan is the only way to reach a mixed-case key from a ref spelled differently.
9
+ */
10
+ export declare function libraryPin(game: GameConfig, id: string): LibraryEntry | undefined;
11
+ export declare function sourcesRoot(dataRoot: string): string;
12
+ export declare function normalizeUrl(url: string): string;
13
+ export declare function cloneDir(dataRoot: string, url: string, ref: GitRef): string;
14
+ export interface GitPin {
15
+ url: string;
16
+ ref?: GitRef;
17
+ subdir?: string;
18
+ }
19
+ export type SyncMode = 'use' | 'fetch' | 'force';
20
+ export interface SyncResult {
21
+ dir: string;
22
+ warning?: string;
23
+ }
24
+ export declare function isMoving(ref: GitRef): boolean;
25
+ export declare function gitRefOf(pin: {
26
+ branch?: string;
27
+ tag?: string;
28
+ commit?: string;
29
+ }): GitRef | undefined;
30
+ export declare function defaultBranch(url: string): GitRef;
31
+ export declare function ensureClone(dataRoot: string, pin: GitPin, ref: GitRef, mode: SyncMode): Promise<SyncResult>;
32
+ export declare function lockClone(dir: string): Promise<() => Promise<void>>;
33
+ export declare function unlinkOrphan(path: string, seen: string): Promise<boolean>;
34
+ export interface PreparedSources {
35
+ /** lowercased packageId -> clone directory. Handed to resolvePlan as `sources`. */
36
+ dirs: Map<string, string>;
37
+ warnings: string[];
38
+ /** One entry per distinct clone this call handled, fetched or reused. Makes dedupe testable. */
39
+ fetched: string[];
40
+ /** Released by execute(), and by run()'s finally. Never before buildLocalMods. */
41
+ release: () => Promise<void>;
42
+ }
43
+ /**
44
+ * The map `prepareSources` builds, read off the cache alone: no lock, no clone, no network.
45
+ * `mods` and `verify` must not fetch, but with no map at all a git pin's `subdir` is dropped and
46
+ * the scan answers by packageId instead, which is a coin flip between two directories of one
47
+ * repo. An unpinned entry has no ref to derive a directory from, so it takes the one `branch-*`
48
+ * clone already on disk.
49
+ */
50
+ export declare function cachedSources(game: GameConfig, profileName: string, args: Partial<ParsedArgs>, dataRoot: string): Map<string, string>;
51
+ /**
52
+ * Clones or fetches every git-pinned mod the run will ask for, before the index is built, and
53
+ * keeps one lock per clone until the caller releases it. `git()` is spawnSync, so these
54
+ * serialize whatever this function looks like.
55
+ */
56
+ export declare function prepareSources(game: GameConfig, profileName: string, args: ParsedArgs, dataRoot: string, allowFetch: boolean): Promise<PreparedSources>;
@@ -0,0 +1,22 @@
1
+ import type { LockRecord } from '../launch/prepare';
2
+ export interface RunRecord {
3
+ game: string;
4
+ profile: string;
5
+ instance?: string;
6
+ container: string;
7
+ pid?: number;
8
+ mode?: string;
9
+ startedAt?: string;
10
+ uptime?: string;
11
+ status: 'running' | 'starting' | 'orphaned';
12
+ }
13
+ export declare function parseDockerRuns(stdout: string): RunRecord[];
14
+ /** <dataRoot>/<game>/<profile>/.gamecrate/lock, plus one level of instances under each. */
15
+ export declare function walkLocks(dataRoot: string): Promise<LockRecord[]>;
16
+ type Docker = () => Promise<string>;
17
+ /**
18
+ * Docker answers for anything with a container. The lock walk covers the phase before one
19
+ * exists (pull, build, stage) and a lock whose container is already gone.
20
+ */
21
+ export declare function listRuns(dataRoot: string, docker?: Docker): Promise<RunRecord[]>;
22
+ export {};