@gamecrate/cli 0.1.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/dist/gamecrate.js +246 -246
  2. package/dist/lib.js +296 -0
  3. package/dist/types/cli/args.d.ts +40 -0
  4. package/dist/types/cli/help.d.ts +7 -0
  5. package/dist/types/cli/output.d.ts +56 -0
  6. package/dist/types/config/builtin.d.ts +3 -0
  7. package/dist/types/config/jsonc.d.ts +5 -0
  8. package/dist/types/config/load.d.ts +45 -0
  9. package/dist/types/config/validate.d.ts +12 -0
  10. package/dist/types/docker/identity.d.ts +6 -0
  11. package/dist/types/docker/preflight.d.ts +3 -0
  12. package/dist/types/docker/run.d.ts +37 -0
  13. package/dist/types/docker/spec.d.ts +24 -0
  14. package/dist/types/docker/window.d.ts +21 -0
  15. package/dist/types/index.d.ts +2 -0
  16. package/dist/types/launch/generate.d.ts +8 -0
  17. package/dist/types/launch/instance.d.ts +20 -0
  18. package/dist/types/launch/prepare.d.ts +44 -0
  19. package/dist/types/launch/resolve.d.ts +17 -0
  20. package/dist/types/launch/stage.d.ts +13 -0
  21. package/dist/types/lib.d.ts +3 -0
  22. package/dist/types/mods/modindex.d.ts +29 -0
  23. package/dist/types/mods/staleness.d.ts +28 -0
  24. package/dist/types/mods/worktree.d.ts +18 -0
  25. package/dist/types/plugin.d.ts +35 -0
  26. package/dist/types/types.d.ts +394 -0
  27. package/package.json +15 -10
  28. package/src/cli/args.ts +0 -592
  29. package/src/cli/help.ts +0 -193
  30. package/src/cli/output.ts +0 -246
  31. package/src/config/builtin.ts +0 -19
  32. package/src/config/jsonc.ts +0 -21
  33. package/src/config/load.ts +0 -387
  34. package/src/config/validate.ts +0 -0
  35. package/src/docker/identity.ts +0 -25
  36. package/src/docker/preflight.ts +0 -243
  37. package/src/docker/run.ts +0 -212
  38. package/src/docker/spec.ts +0 -357
  39. package/src/docker/window.ts +0 -152
  40. package/src/index.ts +0 -875
  41. package/src/launch/generate.ts +0 -151
  42. package/src/launch/instance.ts +0 -106
  43. package/src/launch/prepare.ts +0 -332
  44. package/src/launch/resolve.ts +0 -383
  45. package/src/launch/stage.ts +0 -97
  46. package/src/lib.ts +0 -22
  47. package/src/mods/modindex.ts +0 -539
  48. package/src/mods/staleness.ts +0 -125
  49. package/src/mods/worktree.ts +0 -107
  50. package/src/plugin.ts +0 -152
  51. package/src/types.ts +0 -423
@@ -1,107 +0,0 @@
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 DELETED
@@ -1,152 +0,0 @@
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 DELETED
@@ -1,423 +0,0 @@
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
- }