@gamecrate/cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +257 -0
- package/dist/gamecrate.js +4365 -0
- package/package.json +53 -0
- package/src/cli/args.ts +592 -0
- package/src/cli/help.ts +193 -0
- package/src/cli/output.ts +246 -0
- package/src/config/builtin.ts +19 -0
- package/src/config/jsonc.ts +21 -0
- package/src/config/load.ts +387 -0
- package/src/config/validate.ts +0 -0
- package/src/docker/identity.ts +25 -0
- package/src/docker/preflight.ts +243 -0
- package/src/docker/run.ts +212 -0
- package/src/docker/spec.ts +357 -0
- package/src/docker/window.ts +152 -0
- package/src/index.ts +875 -0
- package/src/launch/generate.ts +151 -0
- package/src/launch/instance.ts +106 -0
- package/src/launch/prepare.ts +332 -0
- package/src/launch/resolve.ts +383 -0
- package/src/launch/stage.ts +97 -0
- package/src/lib.ts +22 -0
- package/src/mods/modindex.ts +539 -0
- package/src/mods/staleness.ts +125 -0
- package/src/mods/worktree.ts +107 -0
- package/src/plugin.ts +152 -0
- package/src/types.ts +423 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process'
|
|
2
|
+
import { existsSync, realpathSync } from 'node:fs'
|
|
3
|
+
import { isAbsolute, resolve, sep } from 'node:path'
|
|
4
|
+
|
|
5
|
+
import { expandHome } from '../config/load'
|
|
6
|
+
import type { Problem, WorktreeRequest, WorktreeSource } from '../types'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* One spawn answers every question: where the tree starts, whether it is linked, and what
|
|
10
|
+
* branch it is on. `gitDir !== gitCommonDir` is the exact linked-worktree test.
|
|
11
|
+
*/
|
|
12
|
+
function inspect(dir: string): { toplevel: string; gitDir: string; gitCommonDir: string; branch: string } | null {
|
|
13
|
+
const r = spawnSync(
|
|
14
|
+
'git',
|
|
15
|
+
['-C', dir, 'rev-parse', '--path-format=absolute', '--show-toplevel', '--git-dir', '--git-common-dir', '--abbrev-ref', 'HEAD'],
|
|
16
|
+
{ encoding: 'utf8' },
|
|
17
|
+
)
|
|
18
|
+
if (r.status !== 0 || typeof r.stdout !== 'string') return null
|
|
19
|
+
const lines = r.stdout.trim().split('\n')
|
|
20
|
+
if (lines.length < 4) return null
|
|
21
|
+
const [toplevel, gitDir, gitCommonDir, branch] = lines as [string, string, string, string]
|
|
22
|
+
return { toplevel, gitDir, gitCommonDir, branch }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function canonical(p: string): string {
|
|
26
|
+
try {
|
|
27
|
+
return realpathSync(p)
|
|
28
|
+
} catch {
|
|
29
|
+
return resolve(p)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Resolves one worktree request. Returns null when `dir` is not inside a LINKED worktree,
|
|
35
|
+
* which is what keeps a primary checkout, a non-repo directory and a pruned gitdir from
|
|
36
|
+
* counting as a selection.
|
|
37
|
+
*/
|
|
38
|
+
export function resolveWorktree(dir: string, source: WorktreeSource, order: number): WorktreeRequest | Problem {
|
|
39
|
+
const raw = expandHome(dir)
|
|
40
|
+
const abs = isAbsolute(raw) ? raw : resolve(process.cwd(), raw)
|
|
41
|
+
|
|
42
|
+
if (!existsSync(abs)) {
|
|
43
|
+
return { where: abs, message: `--worktree path does not exist`, suggestion: 'check the path, or drop the flag' }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const info = inspect(abs)
|
|
47
|
+
if (info === null) {
|
|
48
|
+
return source === 'cwd'
|
|
49
|
+
? { where: abs, message: 'not a git repository', suggestion: 'ignored' }
|
|
50
|
+
: { where: abs, message: 'not a git repository, or its gitdir has been pruned', suggestion: 'run `git worktree prune` in the parent repo' }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (info.gitDir === info.gitCommonDir) {
|
|
54
|
+
return source === 'cwd'
|
|
55
|
+
? { where: abs, message: 'primary checkout, not a linked worktree', suggestion: 'ignored' }
|
|
56
|
+
: {
|
|
57
|
+
where: abs,
|
|
58
|
+
message: 'is the primary checkout, not a linked worktree',
|
|
59
|
+
suggestion: 'nothing to promote; drop --worktree',
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return { root: canonical(info.toplevel), branch: info.branch, source, order }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** True when `dir` is the worktree root or lives underneath it. */
|
|
67
|
+
export function contains(request: WorktreeRequest, dir: string): boolean {
|
|
68
|
+
const target = canonical(dir)
|
|
69
|
+
return target === request.root || target.startsWith(request.root + sep)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Assembles every request in precedence order: explicit flags first (left to right), then the
|
|
74
|
+
* env var, then cwd. Only a linked worktree survives; everything else becomes an ignorable
|
|
75
|
+
* Problem so the caller can decide how loudly to say so.
|
|
76
|
+
*/
|
|
77
|
+
export function collectRequests(
|
|
78
|
+
flags: string[],
|
|
79
|
+
env: string | undefined,
|
|
80
|
+
cwd: string,
|
|
81
|
+
disabled: boolean,
|
|
82
|
+
): { requests: WorktreeRequest[]; problems: Problem[] } {
|
|
83
|
+
if (disabled) return { requests: [], problems: [] }
|
|
84
|
+
|
|
85
|
+
const requests: WorktreeRequest[] = []
|
|
86
|
+
const problems: Problem[] = []
|
|
87
|
+
let order = 0
|
|
88
|
+
|
|
89
|
+
const add = (dir: string, source: WorktreeSource): void => {
|
|
90
|
+
const got = resolveWorktree(dir, source, order)
|
|
91
|
+
if ('root' in got) {
|
|
92
|
+
// A directory named twice is one request, at its strongest position.
|
|
93
|
+
if (!requests.some((r) => r.root === got.root)) {
|
|
94
|
+
requests.push(got)
|
|
95
|
+
order += 1
|
|
96
|
+
}
|
|
97
|
+
} else if (source !== 'cwd') {
|
|
98
|
+
problems.push(got)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
for (const f of flags) add(f, 'flag')
|
|
103
|
+
if (env !== undefined && env !== '' && env !== 'off') add(env, 'env')
|
|
104
|
+
add(cwd, 'cwd')
|
|
105
|
+
|
|
106
|
+
return { requests, problems }
|
|
107
|
+
}
|
package/src/plugin.ts
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { readFileSync, statSync } from 'node:fs'
|
|
2
|
+
import { dirname, isAbsolute, join, resolve } from 'node:path'
|
|
3
|
+
import { pathToFileURL } from 'node:url'
|
|
4
|
+
|
|
5
|
+
import { exports as exportsField, legacy } from 'resolve.exports'
|
|
6
|
+
|
|
7
|
+
import { expandHome } from './config/load'
|
|
8
|
+
import { GamecrateError, Exit } from './types'
|
|
9
|
+
import type { GameConfig, ModManifest } from './types'
|
|
10
|
+
|
|
11
|
+
/** Bumped when a change would make an older plugin misbehave rather than merely lag. */
|
|
12
|
+
export const PLUGIN_API_VERSION = 1
|
|
13
|
+
|
|
14
|
+
export interface ModsConfigInput {
|
|
15
|
+
version: string
|
|
16
|
+
buildNumber: number
|
|
17
|
+
/** Lowercased packageIds, load order. */
|
|
18
|
+
activeMods: string[]
|
|
19
|
+
knownExpansions: string[]
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A game's file formats plus the parts of its config that describe the game, not this machine.
|
|
24
|
+
* Everything here takes plain data and throws plain Errors, so no plugin links the core runtime.
|
|
25
|
+
*/
|
|
26
|
+
export interface GamePlugin {
|
|
27
|
+
apiVersion: number
|
|
28
|
+
/** The key this game answers to on the command line. */
|
|
29
|
+
game: string
|
|
30
|
+
/** Merged under the user's `games.<game>` block, so a user only writes what is theirs. */
|
|
31
|
+
defaults: Partial<GameConfig>
|
|
32
|
+
/** null means "this file is not a mod manifest". A malformed file throws. */
|
|
33
|
+
parseManifest(text: string): ModManifest | null
|
|
34
|
+
renderModsConfig(input: ModsConfigInput): string
|
|
35
|
+
mergePrefs(existing: string | null, owned: Record<string, string>): string
|
|
36
|
+
/** Prefs keys that put the game in a window instead of fullscreen. */
|
|
37
|
+
windowedPrefs: Record<string, string>
|
|
38
|
+
/** Version.txt as the engine writes it. null when it does not parse. */
|
|
39
|
+
parseVersion(text: string): { version: string; buildNumber: number } | null
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const REQUIRED_FUNCTIONS = [
|
|
43
|
+
'parseManifest',
|
|
44
|
+
'renderModsConfig',
|
|
45
|
+
'mergePrefs',
|
|
46
|
+
'parseVersion',
|
|
47
|
+
] as const
|
|
48
|
+
|
|
49
|
+
function fail(spec: string, message: string, detail?: string): never {
|
|
50
|
+
throw new GamecrateError(`plugin "${spec}": ${message}`, Exit.Config, detail)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* A directory's entry point, read from its own package.json. A compiled binary must do this by
|
|
55
|
+
* hand: Bun's resolver never reads the target package.json there, so it only finds index.js.
|
|
56
|
+
*/
|
|
57
|
+
function entryOf(dir: string): string {
|
|
58
|
+
let manifest: unknown
|
|
59
|
+
try {
|
|
60
|
+
manifest = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'))
|
|
61
|
+
} catch {
|
|
62
|
+
// A directory with no package.json still has an index to try.
|
|
63
|
+
return resolve(dir, 'index.js')
|
|
64
|
+
}
|
|
65
|
+
let entry: string | undefined
|
|
66
|
+
try {
|
|
67
|
+
entry = exportsField(manifest, '.', { conditions: ['bun'] })?.[0]
|
|
68
|
+
} catch {
|
|
69
|
+
// No condition matched, so fall through to the legacy fields.
|
|
70
|
+
}
|
|
71
|
+
entry ??= legacy(manifest, { fields: ['module', 'main'] }) as string | undefined
|
|
72
|
+
return resolve(dir, entry ?? 'index.js')
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Walks node_modules upward by hand. require.resolve refuses "<pkg>/package.json" the moment a
|
|
77
|
+
* package has an exports map, and that file is the only thing that tells entryOf where to go.
|
|
78
|
+
*/
|
|
79
|
+
function packageDir(spec: string, from: string): string | null {
|
|
80
|
+
let dir = resolve(from)
|
|
81
|
+
for (;;) {
|
|
82
|
+
const candidate = join(dir, 'node_modules', spec)
|
|
83
|
+
if (statSync(join(candidate, 'package.json'), { throwIfNoEntry: false })?.isFile()) return candidate
|
|
84
|
+
const parent = dirname(dir)
|
|
85
|
+
if (parent === dir) return null
|
|
86
|
+
dir = parent
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** A path is anything that looks like one; everything else is a package. */
|
|
91
|
+
function locate(spec: string, from: string): string {
|
|
92
|
+
const expanded = expandHome(spec)
|
|
93
|
+
let target: string | null
|
|
94
|
+
if (expanded.startsWith('.') || isAbsolute(expanded)) {
|
|
95
|
+
target = resolve(from, expanded)
|
|
96
|
+
} else {
|
|
97
|
+
target = packageDir(expanded, from)
|
|
98
|
+
if (target === null) {
|
|
99
|
+
fail(spec, `cannot be resolved from ${from}`, 'install it, or give a path starting with ./')
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return statSync(target, { throwIfNoEntry: false })?.isDirectory() ? entryOf(target) : target
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function check(spec: string, value: unknown): GamePlugin {
|
|
106
|
+
if (typeof value !== 'object' || value === null) fail(spec, 'has no default export')
|
|
107
|
+
const plugin = value as Partial<GamePlugin>
|
|
108
|
+
if (plugin.apiVersion !== PLUGIN_API_VERSION) {
|
|
109
|
+
fail(spec, `speaks apiVersion ${String(plugin.apiVersion)}, this build speaks ${PLUGIN_API_VERSION}`)
|
|
110
|
+
}
|
|
111
|
+
if (typeof plugin.game !== 'string' || plugin.game === '') fail(spec, 'declares no game name')
|
|
112
|
+
const missing = REQUIRED_FUNCTIONS.filter((name) => typeof plugin[name] !== 'function')
|
|
113
|
+
if (missing.length > 0) fail(spec, `is missing ${missing.join(', ')}`)
|
|
114
|
+
if (typeof plugin.defaults !== 'object' || plugin.defaults === null) {
|
|
115
|
+
fail(spec, 'declares no defaults object')
|
|
116
|
+
}
|
|
117
|
+
if (typeof plugin.windowedPrefs !== 'object' || plugin.windowedPrefs === null) {
|
|
118
|
+
fail(spec, 'declares no windowedPrefs object')
|
|
119
|
+
}
|
|
120
|
+
return plugin as GamePlugin
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Keyed by game name. A second plugin claiming a name already taken is a config error. */
|
|
124
|
+
export async function loadPlugins(specs: string[], configFile: string): Promise<Map<string, GamePlugin>> {
|
|
125
|
+
const from = dirname(configFile)
|
|
126
|
+
const out = new Map<string, GamePlugin>()
|
|
127
|
+
for (const spec of specs) {
|
|
128
|
+
const target = locate(spec, from)
|
|
129
|
+
let module: { default?: unknown }
|
|
130
|
+
try {
|
|
131
|
+
module = (await import(pathToFileURL(target).href)) as { default?: unknown }
|
|
132
|
+
} catch (error) {
|
|
133
|
+
fail(spec, `failed to load ${target}`, error instanceof Error ? error.message : String(error))
|
|
134
|
+
}
|
|
135
|
+
const plugin = check(spec, module.default)
|
|
136
|
+
if (out.has(plugin.game)) fail(spec, `also claims the game "${plugin.game}"`)
|
|
137
|
+
out.set(plugin.game, plugin)
|
|
138
|
+
}
|
|
139
|
+
return out
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function requirePlugin(plugins: Map<string, GamePlugin>, game: string): GamePlugin {
|
|
143
|
+
const plugin = plugins.get(game)
|
|
144
|
+
if (plugin === undefined) {
|
|
145
|
+
throw new GamecrateError(
|
|
146
|
+
`no plugin provides the game "${game}"`,
|
|
147
|
+
Exit.Config,
|
|
148
|
+
`loaded plugins: ${[...plugins.keys()].join(', ') || '(none)'}`,
|
|
149
|
+
)
|
|
150
|
+
}
|
|
151
|
+
return plugin
|
|
152
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
import type { GamePlugin } from './plugin'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Shared contract for every module. Units code against this and nothing else;
|
|
5
|
+
* if a signature here is wrong, fix it here rather than working around it locally.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// ---------------------------------------------------------------- exit codes
|
|
9
|
+
|
|
10
|
+
export const Exit = {
|
|
11
|
+
Ok: 0,
|
|
12
|
+
GameFailed: 1,
|
|
13
|
+
Usage: 2,
|
|
14
|
+
Config: 3,
|
|
15
|
+
Resolution: 4,
|
|
16
|
+
Environment: 5,
|
|
17
|
+
MarkerTimeout: 6,
|
|
18
|
+
/** Refused: this profile+instance is already running. `--replace` is the way past it. */
|
|
19
|
+
Refused: 7,
|
|
20
|
+
/** `verify`: a bound mod's sources are newer than its assemblies. */
|
|
21
|
+
Stale: 8,
|
|
22
|
+
Interrupted: 130,
|
|
23
|
+
} as const
|
|
24
|
+
|
|
25
|
+
export type ExitCode = (typeof Exit)[keyof typeof Exit]
|
|
26
|
+
|
|
27
|
+
/** Every failure the tool raises deliberately. Anything else is a bug. */
|
|
28
|
+
export class GamecrateError extends Error {
|
|
29
|
+
constructor(
|
|
30
|
+
message: string,
|
|
31
|
+
readonly code: ExitCode,
|
|
32
|
+
readonly detail?: string,
|
|
33
|
+
) {
|
|
34
|
+
super(message)
|
|
35
|
+
this.name = 'GamecrateError'
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Collected and reported together, so one run surfaces every problem at once. */
|
|
40
|
+
export interface Problem {
|
|
41
|
+
/** JSON Pointer into profiles.json, or a file path, or a mod id. */
|
|
42
|
+
where: string
|
|
43
|
+
message: string
|
|
44
|
+
/** Populated by did-you-mean matching where it applies. */
|
|
45
|
+
suggestion?: string
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// -------------------------------------------------------------------- config
|
|
49
|
+
|
|
50
|
+
export type ModeName = 'headed' | 'headless' | 'screenshot'
|
|
51
|
+
export type PullPolicy = 'always' | 'missing' | 'never'
|
|
52
|
+
export type BuildPolicy = 'auto' | 'always' | 'never'
|
|
53
|
+
export type NetworkPolicy = 'none' | 'bridge' | 'host'
|
|
54
|
+
export type DisplayBackend = 'x11' | 'wayland'
|
|
55
|
+
|
|
56
|
+
export interface Settings {
|
|
57
|
+
width: number
|
|
58
|
+
height: number
|
|
59
|
+
devMode: boolean
|
|
60
|
+
runInBackground: boolean
|
|
61
|
+
/** Forced false when written; see landmine 5. */
|
|
62
|
+
resetModsConfigOnCrash: boolean
|
|
63
|
+
gpu: boolean
|
|
64
|
+
audio: boolean
|
|
65
|
+
input: boolean
|
|
66
|
+
network: NetworkPolicy
|
|
67
|
+
/** Which display server a headed run talks to. x11 is the one an outside tool can retitle. */
|
|
68
|
+
display: DisplayBackend
|
|
69
|
+
memory: string
|
|
70
|
+
cpus: number
|
|
71
|
+
pidsLimit: number
|
|
72
|
+
/** Verbatim passthrough into the generated Prefs file. */
|
|
73
|
+
prefsExtra?: Record<string, string>
|
|
74
|
+
gameArgs?: string[]
|
|
75
|
+
dockerArgs?: string[]
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface GameFilesSpec {
|
|
79
|
+
source: 'mount' | 'image'
|
|
80
|
+
/** Required when source is "mount". */
|
|
81
|
+
host?: string
|
|
82
|
+
container: string
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface ImageSpec {
|
|
86
|
+
ref: string
|
|
87
|
+
acquire: 'pull' | 'build'
|
|
88
|
+
/** Required when acquire is "build". */
|
|
89
|
+
context?: string
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** How the engine is told where its data directory is. Verified per game. */
|
|
93
|
+
export type DataDirSpec =
|
|
94
|
+
| { container: string; mode: 'arg'; arg: string }
|
|
95
|
+
| { container: string; mode: 'env'; env: Record<string, string> }
|
|
96
|
+
|
|
97
|
+
export interface ModsDirSpec {
|
|
98
|
+
/** Where the staged mod tree is bind-mounted. NOT necessarily under dataDir. */
|
|
99
|
+
container: string
|
|
100
|
+
/** Extra mod roots inside the image that must be masked with a tmpfs. */
|
|
101
|
+
mask?: string[]
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export type LogFileSpec =
|
|
105
|
+
| { mode: 'arg'; arg: string }
|
|
106
|
+
| { mode: 'copy-out'; from: string }
|
|
107
|
+
|
|
108
|
+
export interface ScanRoot {
|
|
109
|
+
path: string
|
|
110
|
+
maxDepth: number
|
|
111
|
+
exclude?: string[]
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface LibraryEntry {
|
|
115
|
+
workshop?: number
|
|
116
|
+
path?: string
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Matches a family of mods by pattern instead of naming each one. */
|
|
120
|
+
export interface DynamicModEntry {
|
|
121
|
+
match: string
|
|
122
|
+
first?: string[]
|
|
123
|
+
sort?: 'alpha' | 'none'
|
|
124
|
+
minMatches?: number
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export interface ModEntryObject {
|
|
128
|
+
id: string
|
|
129
|
+
workshop?: number
|
|
130
|
+
path?: string
|
|
131
|
+
optional?: boolean
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** A bare string is a packageId; `workshop:` and `path:` prefixes disambiguate. */
|
|
135
|
+
export type ModEntry = string | ModEntryObject | DynamicModEntry
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* A named sub-run of a profile with its own data directory, lock and container, so several
|
|
139
|
+
* can run at once. Selected with `--instance`, or derived from an explicit `--worktree`.
|
|
140
|
+
*/
|
|
141
|
+
export interface InstanceConfig {
|
|
142
|
+
/** Promoted ahead of every other worktree request when this instance is selected. */
|
|
143
|
+
worktree?: string
|
|
144
|
+
settings?: Partial<Settings>
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export interface ProfileConfig {
|
|
148
|
+
mods?: ModEntry[]
|
|
149
|
+
extends?: string
|
|
150
|
+
exclude?: string[]
|
|
151
|
+
includeBase?: boolean
|
|
152
|
+
autoDependencies?: boolean
|
|
153
|
+
settings?: Partial<Settings>
|
|
154
|
+
instances?: Record<string, InstanceConfig>
|
|
155
|
+
/** Marks this profile as another name for an existing one. */
|
|
156
|
+
alias?: string
|
|
157
|
+
/** Extra names this profile answers to, so one entry covers several spellings. */
|
|
158
|
+
aliases?: string[]
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export interface GameConfig {
|
|
162
|
+
gameFiles: GameFilesSpec
|
|
163
|
+
dataDir: DataDirSpec
|
|
164
|
+
modsDir: ModsDirSpec
|
|
165
|
+
logFile: LogFileSpec
|
|
166
|
+
image: ImageSpec
|
|
167
|
+
executable: string
|
|
168
|
+
steamAppId: number
|
|
169
|
+
workshopRoot: string | null
|
|
170
|
+
scanRoots: ScanRoot[]
|
|
171
|
+
manifest: { file: string }
|
|
172
|
+
modsConfig: { file: string }
|
|
173
|
+
prefs: { file: string }
|
|
174
|
+
/** Filename suffixes that mean "a save". `clean --all` counts them before it deletes. */
|
|
175
|
+
saveExtensions: string[]
|
|
176
|
+
core: string
|
|
177
|
+
dlc: string[]
|
|
178
|
+
preCore?: string[]
|
|
179
|
+
base?: string[]
|
|
180
|
+
library?: Record<string, LibraryEntry>
|
|
181
|
+
modes: ModeName[]
|
|
182
|
+
aliases?: Record<string, string>
|
|
183
|
+
settings?: Partial<Settings>
|
|
184
|
+
/** The engine claims WM_DELETE_WINDOW and drops it, so the titlebar X does nothing. */
|
|
185
|
+
ignoresWmDelete?: boolean
|
|
186
|
+
profiles: Record<string, ProfileConfig>
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export interface RootConfig {
|
|
190
|
+
/** Package names or paths, resolved from the config file's directory. One per game. */
|
|
191
|
+
plugins?: string[]
|
|
192
|
+
dataRoot: string
|
|
193
|
+
defaults?: { settings?: Partial<Settings> }
|
|
194
|
+
games: Record<string, GameConfig>
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ----------------------------------------------------------------- mod index
|
|
198
|
+
|
|
199
|
+
export type ModSourceKind = 'local' | 'workshop' | 'official' | 'core'
|
|
200
|
+
|
|
201
|
+
export interface ModManifest {
|
|
202
|
+
packageId: string
|
|
203
|
+
name?: string
|
|
204
|
+
modDependencies: { packageId: string; steamWorkshopUrl?: string }[]
|
|
205
|
+
loadAfter: string[]
|
|
206
|
+
loadBefore: string[]
|
|
207
|
+
forceLoadAfter: string[]
|
|
208
|
+
forceLoadBefore: string[]
|
|
209
|
+
incompatibleWith: string[]
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export interface ModRecord {
|
|
213
|
+
/** Manifest casing, preserved. Match on the lowercased form. */
|
|
214
|
+
packageId: string
|
|
215
|
+
dir: string
|
|
216
|
+
kind: ModSourceKind
|
|
217
|
+
manifest: ModManifest
|
|
218
|
+
/** Workshop item id when kind is "workshop". */
|
|
219
|
+
workshopId?: number
|
|
220
|
+
/** True when dir sits inside a linked git worktree; ranked below primaries. */
|
|
221
|
+
linkedWorktree: boolean
|
|
222
|
+
/**
|
|
223
|
+
* Request order when a caller deliberately selected the worktree this record lives in.
|
|
224
|
+
* The scanner can never set it, which is what stops an unselected worktree from winning.
|
|
225
|
+
*/
|
|
226
|
+
selectedWorktree?: number
|
|
227
|
+
/** Request order when `--use <packageId>=<dir>` named this record. Outranks a worktree. */
|
|
228
|
+
overridden?: number
|
|
229
|
+
/** Set only for a selected worktree; the branch costs a git spawn, so it is not scanned for. */
|
|
230
|
+
worktree?: { root: string; branch: string; source: WorktreeSource }
|
|
231
|
+
/** Which scanRoot produced it, for the precedence ladder. */
|
|
232
|
+
rootIndex: number
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export type WorktreeSource = 'flag' | 'env' | 'cwd' | 'ref'
|
|
236
|
+
|
|
237
|
+
export interface WorktreeRequest {
|
|
238
|
+
/** realpath'd worktree toplevel. */
|
|
239
|
+
root: string
|
|
240
|
+
branch: string
|
|
241
|
+
source: WorktreeSource
|
|
242
|
+
/** Lower wins. */
|
|
243
|
+
order: number
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export interface ModIndex {
|
|
247
|
+
game: string
|
|
248
|
+
plugin: GamePlugin
|
|
249
|
+
/** Keyed by lowercased packageId. Several records means a collision to resolve. */
|
|
250
|
+
byPackageId: Map<string, ModRecord[]>
|
|
251
|
+
byWorkshopId: Map<number, ModRecord>
|
|
252
|
+
/** Lowercased last dot-segment -> packageIds. CLI matching only. */
|
|
253
|
+
byShortName: Map<string, string[]>
|
|
254
|
+
problems: Problem[]
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ---------------------------------------------------------------- resolution
|
|
258
|
+
|
|
259
|
+
/** Why a mod looks stale, in enough detail to name the file that says so. */
|
|
260
|
+
export interface StaleReport {
|
|
261
|
+
/** Mod-relative path of the newest .cs. */
|
|
262
|
+
newestSource: string
|
|
263
|
+
newestSourceMs: number
|
|
264
|
+
/** Mod-relative path of the newest assembly it should have been compiled into. */
|
|
265
|
+
assembly: string
|
|
266
|
+
assemblyMs: number
|
|
267
|
+
newerCount: number
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export interface ResolvedMod {
|
|
271
|
+
packageId: string
|
|
272
|
+
hostDir: string
|
|
273
|
+
containerDir: string
|
|
274
|
+
kind: ModSourceKind
|
|
275
|
+
workshopId?: number
|
|
276
|
+
/** False when the entry came from autoDependencies rather than the profile. */
|
|
277
|
+
explicit: boolean
|
|
278
|
+
/** Set when a .cs file is newer than the staged assembly. */
|
|
279
|
+
stale?: boolean
|
|
280
|
+
/** Set only when there is an assembly to be stale against; drives the launch warning. */
|
|
281
|
+
staleReport?: StaleReport
|
|
282
|
+
/** Present when this mod came out of a linked worktree. */
|
|
283
|
+
worktree?: { root: string; branch: string; source: WorktreeSource; selected: boolean }
|
|
284
|
+
/** Other directories that declared this packageId and lost. Always emitted. */
|
|
285
|
+
shadowed?: string[]
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export interface LaunchPlan {
|
|
289
|
+
game: string
|
|
290
|
+
gameConfig: GameConfig
|
|
291
|
+
plugin: GamePlugin
|
|
292
|
+
profile: string
|
|
293
|
+
settings: Settings
|
|
294
|
+
mods: ResolvedMod[]
|
|
295
|
+
/** Absolute host path: <dataRoot>/<game>/<profile>. */
|
|
296
|
+
profileDir: string
|
|
297
|
+
/** Undefined for the base profile; a name when several runs share one profile. */
|
|
298
|
+
instance?: string
|
|
299
|
+
/** profileDir, or <profileDir>/instances/<instance>. Everything a run writes hangs off it. */
|
|
300
|
+
instanceDir: string
|
|
301
|
+
dataDirHost: string
|
|
302
|
+
/** <profileDir>/config. Shared by every instance, and holds the XDG dirs. */
|
|
303
|
+
configDirHost: string
|
|
304
|
+
stageDirHost: string
|
|
305
|
+
logsDirHost: string
|
|
306
|
+
/** <logsDirHost>/runs/<ts>, bound into the container so Player.log lands with stdout.log. */
|
|
307
|
+
runDirHost: string
|
|
308
|
+
mode: ModeName
|
|
309
|
+
marker?: string
|
|
310
|
+
timeoutSeconds: number
|
|
311
|
+
renderWaitSeconds: number
|
|
312
|
+
/** False under --no-stale-check. The check still runs, so staleReport stays truthful. */
|
|
313
|
+
warnOnStale: boolean
|
|
314
|
+
warnings: string[]
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// -------------------------------------------------------------------- docker
|
|
318
|
+
|
|
319
|
+
export interface Identity {
|
|
320
|
+
uid: number
|
|
321
|
+
gid: number
|
|
322
|
+
home: string
|
|
323
|
+
user: string
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
export interface Mount {
|
|
327
|
+
type: 'bind' | 'tmpfs'
|
|
328
|
+
source?: string
|
|
329
|
+
target: string
|
|
330
|
+
readonly?: boolean
|
|
331
|
+
/** tmpfs only. */
|
|
332
|
+
size?: string
|
|
333
|
+
uid?: number
|
|
334
|
+
gid?: number
|
|
335
|
+
mode?: string
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
export interface DockerRunSpec {
|
|
339
|
+
image: string
|
|
340
|
+
name: string
|
|
341
|
+
labels: Record<string, string>
|
|
342
|
+
identity: Identity
|
|
343
|
+
env: Record<string, string>
|
|
344
|
+
mounts: Mount[]
|
|
345
|
+
devices: string[]
|
|
346
|
+
deviceCgroupRules: string[]
|
|
347
|
+
network: NetworkPolicy
|
|
348
|
+
memory: string
|
|
349
|
+
memorySwap: string
|
|
350
|
+
cpus: number
|
|
351
|
+
pidsLimit: number
|
|
352
|
+
ulimits: string[]
|
|
353
|
+
workdir: string
|
|
354
|
+
/** The host's, when set: KWin appends `<@name>` to a caption from a foreign machine. */
|
|
355
|
+
hostname?: string
|
|
356
|
+
/** argv after the image name. */
|
|
357
|
+
command: string[]
|
|
358
|
+
extraArgs: string[]
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// ----------------------------------------------------------------------- cli
|
|
362
|
+
|
|
363
|
+
export interface ParsedArgs {
|
|
364
|
+
subcommand: string
|
|
365
|
+
game?: string
|
|
366
|
+
profile?: string
|
|
367
|
+
mods: string[]
|
|
368
|
+
without: string[]
|
|
369
|
+
only: string[]
|
|
370
|
+
mode?: ModeName
|
|
371
|
+
marker?: string
|
|
372
|
+
timeout?: number
|
|
373
|
+
renderWait?: number
|
|
374
|
+
resolution?: { width: number; height: number }
|
|
375
|
+
network?: NetworkPolicy
|
|
376
|
+
log?: string
|
|
377
|
+
pull?: PullPolicy
|
|
378
|
+
build?: BuildPolicy
|
|
379
|
+
sort?: 'topo' | 'none'
|
|
380
|
+
/** `clean` only: --staging is the default, --all additionally requires --yes. */
|
|
381
|
+
cleanTier?: 'staging' | 'logs' | 'all'
|
|
382
|
+
dockerArgs: string[]
|
|
383
|
+
gameArgs: string[]
|
|
384
|
+
dryRun: boolean
|
|
385
|
+
printPlan: boolean
|
|
386
|
+
json: boolean
|
|
387
|
+
root: boolean
|
|
388
|
+
yes: boolean
|
|
389
|
+
help: boolean
|
|
390
|
+
/** Repeatable; earlier flags outrank later ones. */
|
|
391
|
+
worktree: string[]
|
|
392
|
+
/** Names the sub-run: its own data directory, lock and container. */
|
|
393
|
+
instance?: string
|
|
394
|
+
/** Repeatable `packageId=path`; forces one mod's source, whatever the profile says. */
|
|
395
|
+
use: string[]
|
|
396
|
+
/** Suppresses ambient cwd selection and the env var. */
|
|
397
|
+
noWorktree: boolean
|
|
398
|
+
/** Suppresses the sources-newer-than-assemblies warning. The check itself still runs. */
|
|
399
|
+
noStaleCheck: boolean
|
|
400
|
+
/** Stops whatever holds this profile+instance, then launches. Never refuses. */
|
|
401
|
+
replace: boolean
|
|
402
|
+
rest: string[]
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
export type ProjectDefaults = Partial<
|
|
406
|
+
Omit<ParsedArgs, 'subcommand' | 'cleanTier' | 'yes' | 'help' | 'rest'>
|
|
407
|
+
>
|
|
408
|
+
|
|
409
|
+
/** Names that can never be a game or profile key. Enforced at config load. */
|
|
410
|
+
export const RESERVED_NAMES: readonly string[] = [
|
|
411
|
+
'run', 'list', 'mods', 'doctor', 'clean', 'clone', 'logs', 'build',
|
|
412
|
+
'shell', 'config', 'fix-perms', 'verify', 'help', 'version', 'modless',
|
|
413
|
+
]
|
|
414
|
+
|
|
415
|
+
export const NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Lookup in a bag keyed by user input. A bare index hands back Object.prototype members, so
|
|
419
|
+
* `--mod constructor` or a profile named `toString` would resolve to an inherited function.
|
|
420
|
+
*/
|
|
421
|
+
export function own<T>(bag: Record<string, T> | undefined, key: string): T | undefined {
|
|
422
|
+
return bag !== undefined && Object.hasOwn(bag, key) ? bag[key] : undefined
|
|
423
|
+
}
|