@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/README.md +38 -224
- package/dist/gamecrate.js +3128 -1509
- package/dist/lib.js +66 -14
- package/dist/types/cli/args.d.ts +19 -1
- package/dist/types/cli/game.d.ts +2 -0
- package/dist/types/cli/list.d.ts +2 -0
- package/dist/types/cli/mods.d.ts +16 -0
- package/dist/types/cli/output.d.ts +23 -1
- package/dist/types/cli/profile.d.ts +6 -0
- package/dist/types/config/load.d.ts +5 -3
- package/dist/types/config/read.d.ts +11 -0
- package/dist/types/config/write.d.ts +14 -0
- package/dist/types/docker/run.d.ts +3 -1
- package/dist/types/docker/window.d.ts +30 -1
- package/dist/types/launch/prepare.d.ts +47 -4
- package/dist/types/launch/resolve.d.ts +8 -1
- package/dist/types/launch/supervisor.d.ts +48 -0
- package/dist/types/mods/modindex.d.ts +4 -3
- package/dist/types/mods/source.d.ts +56 -0
- package/dist/types/run/registry.d.ts +22 -0
- package/dist/types/types.d.ts +70 -2
- package/package.json +1 -1
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(),
|
|
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
|
|
289
|
+
var PROJECT_OBJECT = z2.strictObject({
|
|
261
290
|
game: projectName.optional(),
|
|
262
|
-
|
|
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;
|
package/dist/types/cli/args.d.ts
CHANGED
|
@@ -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,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
|
|
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
|
|
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
|
|
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
|
-
*
|
|
35
|
-
*
|
|
36
|
-
|
|
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
|
|
22
|
-
* Local roots rescan every launch; only the workshop scan is cached,
|
|
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 {};
|