@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.
@@ -0,0 +1,387 @@
1
+ import { parse as parseYaml } from 'yaml'
2
+ import { z } from 'zod'
3
+ import { access, readdir, readFile } from 'node:fs/promises'
4
+ import { homedir } from 'node:os'
5
+ import { dirname, join, resolve } from 'node:path'
6
+ import { parseResolution } from '../cli/args'
7
+ import { GamecrateError, Exit, NAME_PATTERN, own } from '../types'
8
+ import type {
9
+ BuildPolicy,
10
+ GameConfig,
11
+ ModEntry,
12
+ ProfileConfig,
13
+ ProjectDefaults,
14
+ RootConfig,
15
+ Settings,
16
+ } from '../types'
17
+ import { loadPlugins } from '../plugin'
18
+ import type { GamePlugin } from '../plugin'
19
+ import { DEFAULT_DATA_ROOT, DEFAULT_SETTINGS } from './builtin'
20
+ import { parseJsonc } from './jsonc'
21
+ import { isObj, validateConfig } from './validate'
22
+
23
+ export function defaultConfigPath(): string {
24
+ const base = process.env['XDG_CONFIG_HOME'] ?? join(homedir(), '.config')
25
+ return join(base, 'gamecrate', 'profiles.json')
26
+ }
27
+
28
+ const PROJECT_CONFIG = '.gamecrate.yml'
29
+
30
+ const projectName = z.custom<string>((v) => typeof v === 'string' && NAME_PATTERN.test(v), 'expected a name')
31
+ const projectStr = z.string({ error: 'expected a string' })
32
+ const projectBool = z.boolean({ error: 'expected true or false' })
33
+ const projectList = z.custom<string[]>(
34
+ (v) => Array.isArray(v) && v.every((entry) => typeof entry === 'string'),
35
+ 'expected an array of strings',
36
+ )
37
+ const projectSeconds = z.custom<number>(
38
+ (v) => Number.isSafeInteger(v) && (v as number) >= 0,
39
+ 'expected a whole number of seconds',
40
+ )
41
+
42
+ function oneOf<T extends string>(values: readonly [T, ...T[]]) {
43
+ return z.enum(values, { error: `expected one of ${values.join(', ')}` })
44
+ }
45
+
46
+ const BUILD_POLICIES = ['auto', 'always', 'never'] as const
47
+
48
+ /** Only the resolution is stored differently from how it is written. */
49
+ const projectResolution = z
50
+ .string({ error: 'expected dimensions like 1920x1080' })
51
+ .check((ctx) => {
52
+ try {
53
+ parseResolution(ctx.value)
54
+ } catch (error) {
55
+ ctx.issues.push({ code: 'custom', message: (error as Error).message, input: ctx.value })
56
+ }
57
+ })
58
+ .transform(parseResolution)
59
+
60
+ const PROJECT_SCHEMA = z.strictObject(
61
+ {
62
+ game: projectName.optional(),
63
+ profile: projectName.optional(),
64
+ mods: projectList.optional(),
65
+ without: projectList.optional(),
66
+ only: projectList.optional(),
67
+ dockerArgs: projectList.optional(),
68
+ gameArgs: projectList.optional(),
69
+ worktree: projectList.optional(),
70
+ use: projectList.optional(),
71
+ marker: projectStr.optional(),
72
+ instance: projectStr.optional(),
73
+ log: projectStr.optional(),
74
+ timeout: projectSeconds.optional(),
75
+ renderWait: projectSeconds.optional(),
76
+ dryRun: projectBool.optional(),
77
+ printPlan: projectBool.optional(),
78
+ json: projectBool.optional(),
79
+ root: projectBool.optional(),
80
+ noWorktree: projectBool.optional(),
81
+ noStaleCheck: projectBool.optional(),
82
+ replace: projectBool.optional(),
83
+ mode: oneOf(['headed', 'headless', 'screenshot']).optional(),
84
+ pull: oneOf(['always', 'missing', 'never']).optional(),
85
+ sort: oneOf(['topo', 'none']).optional(),
86
+ network: oneOf(['none', 'bridge', 'host']).optional(),
87
+ // A bare yes/no is the common spelling; the three-way policy is the full one.
88
+ build: z
89
+ .union([z.boolean().transform((on): BuildPolicy => (on ? 'always' : 'never')), z.enum(BUILD_POLICIES)], {
90
+ error: `expected one of ${BUILD_POLICIES.join(', ')}`,
91
+ })
92
+ .optional(),
93
+ resolution: projectResolution.optional(),
94
+ },
95
+ { error: 'expected an object' },
96
+ )
97
+
98
+ export async function findProjectConfig(start = process.cwd()): Promise<string | undefined> {
99
+ let dir = resolve(start)
100
+ for (;;) {
101
+ const file = join(dir, PROJECT_CONFIG)
102
+ try {
103
+ await access(file)
104
+ return file
105
+ } catch (error) {
106
+ if ((error as { code?: string }).code !== 'ENOENT') throw error
107
+ }
108
+ const parent = dirname(dir)
109
+ if (parent === dir) return undefined
110
+ dir = parent
111
+ }
112
+ }
113
+
114
+ export async function loadProjectDefaults(start = process.cwd()): Promise<ProjectDefaults> {
115
+ const file = await findProjectConfig(start)
116
+ if (file === undefined) return {}
117
+
118
+ let raw: unknown
119
+ try {
120
+ raw = parseYaml(await readFile(file, 'utf8'))
121
+ } catch (error) {
122
+ throw new GamecrateError(`project config is invalid: ${file}`, Exit.Config, (error as Error).message)
123
+ }
124
+ return validateProjectDefaults(raw, file)
125
+ }
126
+
127
+ function validateProjectDefaults(raw: unknown, file: string): ProjectDefaults {
128
+ if (raw === null) return {}
129
+
130
+ const result = PROJECT_SCHEMA.safeParse(raw)
131
+ if (result.success) return result.data as ProjectDefaults
132
+
133
+ const problems: string[] = []
134
+ for (const issue of result.error.issues) {
135
+ if (issue.code === 'unrecognized_keys') {
136
+ for (const key of issue.keys) problems.push(` /${key}: unknown key`)
137
+ continue
138
+ }
139
+ problems.push(` /${issue.path.join('/')}: ${issue.message}`)
140
+ }
141
+ throw new GamecrateError(`project config is invalid: ${file}`, Exit.Config, problems.join('\n'))
142
+ }
143
+
144
+ export interface LoadedConfig {
145
+ config: RootConfig
146
+ plugins: Map<string, GamePlugin>
147
+ }
148
+
149
+ /**
150
+ * Reads profiles.json, loads the plugins it lists, then merges the user's blocks over each
151
+ * plugin's defaults. A missing file means no games, which every non-launch subcommand survives.
152
+ */
153
+ export async function loadConfig(path?: string): Promise<LoadedConfig> {
154
+ const file = path ?? defaultConfigPath()
155
+ let user: unknown
156
+ try {
157
+ user = parseJsonc(await readFile(file, 'utf8'))
158
+ } catch (err) {
159
+ if (err instanceof GamecrateError) {
160
+ throw new GamecrateError(`${err.message}: ${file}`, err.code, err.detail)
161
+ }
162
+ if ((err as { code?: string }).code !== 'ENOENT') throw err
163
+ }
164
+
165
+ const specs = isObj(user) && user['plugins'] !== undefined ? user['plugins'] : []
166
+ if (!Array.isArray(specs) || specs.some((s) => typeof s !== 'string')) {
167
+ throw new GamecrateError(`config is invalid: ${file}`, Exit.Config, ' /plugins: expected an array of strings')
168
+ }
169
+ const plugins = await loadPlugins(specs as string[], file)
170
+
171
+ const base: RootConfig = {
172
+ dataRoot: DEFAULT_DATA_ROOT,
173
+ defaults: { settings: structuredClone(DEFAULT_SETTINGS) },
174
+ games: Object.fromEntries(
175
+ [...plugins].map(([name, plugin]) => [name, structuredClone(plugin.defaults) as GameConfig]),
176
+ ),
177
+ }
178
+ const { config, problems } = validateConfig(user === undefined ? base : deepMerge(base, user))
179
+ if (problems.length > 0) {
180
+ const detail = problems
181
+ .map((p) => {
182
+ const hint = p.suggestion ? ` (${p.suggestion})` : ''
183
+ return ` ${p.where || '/'}: ${p.message}${hint}${origin(p.where, user, plugins)}`
184
+ })
185
+ .join('\n')
186
+ const merged = plugins.size === 0 ? '' : ` (merged with defaults from: ${[...plugins.keys()].join(', ')})`
187
+ throw new GamecrateError(`config is invalid: ${file}${merged}`, Exit.Config, detail)
188
+ }
189
+ return { config: expandPaths(config), plugins }
190
+ }
191
+
192
+ /**
193
+ * Says where a problem's key actually came from. A pointer the user's file does not contain
194
+ * arrived with a plugin's defaults, and blaming profiles.json for it sends them key-hunting.
195
+ */
196
+ function origin(where: string, user: unknown, plugins: Map<string, GamePlugin>): string {
197
+ if (!where.startsWith('/')) return ''
198
+ const segments = where.slice(1).split('/').map((s) => s.replace(/~1/g, '/').replace(/~0/g, '~'))
199
+ if (valueAt(user, segments) !== undefined) return ''
200
+
201
+ const [section, name, ...rest] = segments
202
+ const plugin = section === 'games' && name !== undefined ? plugins.get(name) : undefined
203
+ if (plugin === undefined) return ' <- not in this file'
204
+ return valueAt(plugin.defaults, rest) === undefined
205
+ ? ` <- not in this file, and the ${name} plugin's defaults do not supply it`
206
+ : ` <- from the ${name} plugin's defaults, not this file`
207
+ }
208
+
209
+ function valueAt(value: unknown, segments: string[]): unknown {
210
+ let current = value
211
+ for (const segment of segments) {
212
+ if (Array.isArray(current)) current = current[Number(segment)]
213
+ else if (isObj(current)) current = own(current, segment)
214
+ else return undefined
215
+ }
216
+ return current
217
+ }
218
+
219
+ /** defaults -> games.<game> -> profile -> instance -> CLI. Scalars replace, arrays concatenate. */
220
+ export function resolveSettings(
221
+ root: RootConfig,
222
+ game: GameConfig,
223
+ profile: ProfileConfig,
224
+ ...overrides: (Partial<Settings> | undefined)[]
225
+ ): Settings {
226
+ let out: Settings = structuredClone(DEFAULT_SETTINGS)
227
+ for (const layer of [root.defaults?.settings, game.settings, profile.settings, ...overrides]) {
228
+ if (layer) out = deepMerge(out, layer, true)
229
+ }
230
+ return out
231
+ }
232
+
233
+ /**
234
+ * Flattens `alias` and the `extends` chain into one profile. `exclude` survives on
235
+ * the result so the caller can subtract it from preCore/core/dlc/base too.
236
+ */
237
+ export function resolveProfile(game: GameConfig, name: string): ProfileConfig {
238
+ return resolveNamed(game, name, [])
239
+ }
240
+
241
+ function resolveNamed(game: GameConfig, name: string, seen: string[]): ProfileConfig {
242
+ if (name.toLowerCase() === 'modless') return { mods: [], exclude: [], includeBase: false }
243
+
244
+ const key = profileKey(game, name)
245
+ if (key === undefined) {
246
+ throw new GamecrateError(
247
+ `unknown profile "${name}"`,
248
+ Exit.Resolution,
249
+ `known profiles: ${Object.keys(game.profiles).join(', ') || '(none)'}, modless`,
250
+ )
251
+ }
252
+ if (seen.includes(key)) {
253
+ throw new GamecrateError(
254
+ `profile "${key}" inherits from itself`,
255
+ Exit.Config,
256
+ [...seen, key].join(' -> '),
257
+ )
258
+ }
259
+
260
+ const self = own(game.profiles, key)!
261
+ if (self.alias !== undefined) return resolveNamed(game, self.alias, [...seen, key])
262
+
263
+ const parent: ProfileConfig =
264
+ self.extends !== undefined ? resolveNamed(game, self.extends, [...seen, key]) : {}
265
+
266
+ const exclude = [...(parent.exclude ?? []), ...(self.exclude ?? [])]
267
+ const out: ProfileConfig = {
268
+ mods: subtract([...(parent.mods ?? []), ...(self.mods ?? [])], exclude),
269
+ exclude,
270
+ settings: deepMerge(parent.settings ?? {}, self.settings ?? {}, true),
271
+ }
272
+ // A child inherits its parent's instances and may redefine one by name.
273
+ const instances = deepMerge(parent.instances ?? {}, self.instances ?? {}, true)
274
+ if (Object.keys(instances).length > 0) out.instances = instances
275
+ const includeBase = self.includeBase ?? parent.includeBase
276
+ if (includeBase !== undefined) out.includeBase = includeBase
277
+ const auto = self.autoDependencies ?? parent.autoDependencies
278
+ if (auto !== undefined) out.autoDependencies = auto
279
+ return out
280
+ }
281
+
282
+ /**
283
+ * The name a profile stores its data under. An alias must not get its own data directory,
284
+ * or your saves split depending on which spelling you typed.
285
+ */
286
+ export function canonicalProfile(game: GameConfig, name: string): string {
287
+ if (name.toLowerCase() === 'modless') return 'modless'
288
+ const seen: string[] = []
289
+ let current = name
290
+ for (;;) {
291
+ const key = profileKey(game, current)
292
+ if (key === undefined || seen.includes(key)) return key ?? current
293
+ const next = own(game.profiles, key)?.alias
294
+ if (next === undefined) return key
295
+ seen.push(key)
296
+ current = next
297
+ }
298
+ }
299
+
300
+ /**
301
+ * The one place a profile's data directory is named. Every subcommand goes through it, so
302
+ * `logs`, `clean` and `clone` land on the directory `run` actually used, alias or not.
303
+ */
304
+ export function profileDataDir(root: RootConfig, game: string, profile: string): string {
305
+ return resolve(expandHome(root.dataRoot), game, canonicalProfile(own(root.games, game)!, profile))
306
+ }
307
+
308
+ /**
309
+ * The directories a command should touch for one game. With no profile it lists what is on disk,
310
+ * verbatim: a directory name is already a path, and canonicalizing it skips odd-cased ones.
311
+ */
312
+ export async function profileDirs(root: RootConfig, game: string, profile?: string): Promise<string[]> {
313
+ if (profile !== undefined) return [profileDataDir(root, game, profile)]
314
+ const dir = join(expandHome(root.dataRoot), game)
315
+ return (await readdir(dir).catch(() => [] as string[])).map((name) => join(dir, name))
316
+ }
317
+
318
+ function profileKey(game: GameConfig, name: string): string | undefined {
319
+ if (Object.hasOwn(game.profiles, name)) return name
320
+ const lower = name.toLowerCase()
321
+ const direct = Object.keys(game.profiles).find((k) => k.toLowerCase() === lower)
322
+ if (direct !== undefined) return direct
323
+ // A profile's own `aliases` are extra names for it, so one entry answers to several.
324
+ return Object.keys(game.profiles).find((k) =>
325
+ (own(game.profiles, k)?.aliases ?? []).some((a) => a.toLowerCase() === lower),
326
+ )
327
+ }
328
+
329
+ /** Removes entries whose id matches an exclusion. Dynamic entries are filtered after expansion. */
330
+ export function subtract(mods: ModEntry[], exclude: string[]): ModEntry[] {
331
+ if (exclude.length === 0) return mods
332
+ const patterns = exclude.map(globToRegExp)
333
+ return mods.filter((entry) => {
334
+ const ids = entryIds(entry)
335
+ if (ids.length === 0) return true
336
+ return !ids.some((id) => patterns.some((re) => re.test(id)))
337
+ })
338
+ }
339
+
340
+ function entryIds(entry: ModEntry): string[] {
341
+ if (typeof entry === 'string') return [entry, entry.replace(/^(workshop|path):/, '')]
342
+ if ('id' in entry) return [entry.id]
343
+ return []
344
+ }
345
+
346
+ export function globToRegExp(pattern: string): RegExp {
347
+ const body = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.')
348
+ return new RegExp(`^${body}$`, 'i')
349
+ }
350
+
351
+ /**
352
+ * A later list replaces an earlier one, so a user can shorten a plugin's `dlc` or `modes`.
353
+ * `concatArrays` is the settings ladder's rule, where gameArgs accumulate across layers.
354
+ */
355
+ export function deepMerge<T>(base: T, over: unknown, concatArrays = false): T {
356
+ if (Array.isArray(base) && Array.isArray(over)) {
357
+ return (concatArrays ? [...base, ...over] : [...over]) as unknown as T
358
+ }
359
+ if (isObj(base) && isObj(over)) {
360
+ const out: Record<string, unknown> = { ...base }
361
+ for (const [k, v] of Object.entries(over)) {
362
+ if (v === undefined) continue
363
+ out[k] = Object.hasOwn(out, k) ? deepMerge(out[k], v, concatArrays) : v
364
+ }
365
+ return out as unknown as T
366
+ }
367
+ return over as T
368
+ }
369
+
370
+ function expandPaths(config: RootConfig): RootConfig {
371
+ config.dataRoot = expandHome(config.dataRoot)
372
+ for (const game of Object.values(config.games)) {
373
+ if (game.gameFiles.host !== undefined) game.gameFiles.host = expandHome(game.gameFiles.host)
374
+ if (game.image.context !== undefined) game.image.context = expandHome(game.image.context)
375
+ if (game.workshopRoot !== null) game.workshopRoot = expandHome(game.workshopRoot)
376
+ for (const root of game.scanRoots) root.path = expandHome(root.path)
377
+ for (const entry of Object.values(game.library ?? {})) {
378
+ if (entry.path !== undefined) entry.path = expandHome(entry.path)
379
+ }
380
+ }
381
+ return config
382
+ }
383
+
384
+ export function expandHome(p: string): string {
385
+ if (p === '~') return homedir()
386
+ return p.startsWith('~/') ? join(homedir(), p.slice(2)) : p
387
+ }
Binary file
@@ -0,0 +1,25 @@
1
+ import { userInfo } from 'node:os'
2
+ import type { Identity } from '../types'
3
+
4
+ /**
5
+ * The single source of truth for `--user`, HOME, and every tmpfs uid=/gid=.
6
+ * Out-of-sync values give a blank window with no error, so nothing else may guess.
7
+ */
8
+ export function resolveIdentity(useRoot: boolean): Identity {
9
+ if (useRoot) return { uid: 0, gid: 0, home: '/root', user: 'root' }
10
+
11
+ const uid = process.getuid?.() ?? 0
12
+ const gid = process.getgid?.() ?? 0
13
+ return { uid, gid, home: '/tmp/home', user: hostUserName(uid) }
14
+ }
15
+
16
+ /** Neither image has a passwd entry for uid 1000, so USER/LOGNAME must be stated. */
17
+ function hostUserName(uid: number): string {
18
+ try {
19
+ const name = userInfo().username
20
+ if (name) return name
21
+ } catch {
22
+ // No passwd entry for the caller either; fall through to the env.
23
+ }
24
+ return process.env.USER ?? process.env.LOGNAME ?? `uid-${uid}`
25
+ }
@@ -0,0 +1,243 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { homedir } from 'node:os'
3
+ import { basename, join } from 'node:path'
4
+ import type { LaunchPlan, Problem } from '../types'
5
+ import { GamecrateError } from '../types'
6
+ import { resolveIdentity } from './identity'
7
+ import { capture } from './run'
8
+ import { buildRunSpec, waylandSocket, x11Session } from './spec'
9
+
10
+ const CDI_SPEC = '/etc/cdi/nvidia.yaml'
11
+
12
+ /** Every check the launch depends on, collected so one run reports all of them at once. */
13
+ export async function preflight(plan: LaunchPlan): Promise<Problem[]> {
14
+ const problems: Problem[] = []
15
+ const game = plan.gameConfig
16
+
17
+ const dockerOk = await checkDocker(problems)
18
+ if (dockerOk) {
19
+ await checkImage(plan, problems)
20
+ }
21
+
22
+ if (plan.settings.gpu) checkCdi(problems)
23
+ checkGameDir(plan, problems)
24
+ checkBindSources(plan, problems)
25
+ if (plan.mode === 'headed') checkDisplay(plan, problems)
26
+
27
+ if (game.gameFiles.source === 'image' && game.image.acquire === 'build' && !game.image.context) {
28
+ problems.push({
29
+ where: `/games/${plan.game}/image/context`,
30
+ message: 'image.acquire is "build" but no build context is configured',
31
+ })
32
+ }
33
+
34
+ return problems
35
+ }
36
+
37
+ async function checkDocker(problems: Problem[]): Promise<boolean> {
38
+ const result = await capture(['docker', 'version', '--format', '{{.Server.Version}}'])
39
+ if (result.code === 0) return true
40
+
41
+ problems.push({
42
+ where: 'docker',
43
+ message: `docker is not reachable: ${firstLine(result.stderr) || `exit ${result.code}`}`,
44
+ suggestion: 'start the docker daemon, or check that your user is in the docker group',
45
+ })
46
+ return false
47
+ }
48
+
49
+ /**
50
+ * `docker image inspect` succeeds on an image whose layers are missing from the content
51
+ * store, so presence is not runnability. Actually starting it is the only honest check.
52
+ */
53
+ async function checkImageRunnable(
54
+ ref: string,
55
+ where: string,
56
+ game: string,
57
+ problems: Problem[],
58
+ ): Promise<void> {
59
+ const run = await capture(['docker', 'run', '--rm', '--entrypoint', '/bin/true', ref])
60
+ if (run.code === 0) return
61
+
62
+ const err = firstLine(run.stderr)
63
+ const corrupt = /content store|failed to extract layer|not found/i.test(run.stderr)
64
+ problems.push({
65
+ where,
66
+ message: corrupt
67
+ ? `image ${ref} is present but unrunnable; its layers are missing from the content store`
68
+ : `image ${ref} is present but failed to start: ${err || `exit ${run.code}`}`,
69
+ suggestion: corrupt
70
+ ? `docker image rm ${ref} && docker builder prune -f, then gamecrate build ${game}`
71
+ : undefined,
72
+ })
73
+ }
74
+
75
+ async function checkImage(plan: LaunchPlan, problems: Problem[]): Promise<void> {
76
+ const image = plan.gameConfig.image
77
+ const where = `/games/${plan.game}/image/ref`
78
+
79
+ const present = await capture(['docker', 'image', 'inspect', image.ref])
80
+ if (present.code === 0) {
81
+ await checkImageRunnable(image.ref, where, plan.game, problems)
82
+ return
83
+ }
84
+
85
+ if (image.acquire === 'build') {
86
+ problems.push({
87
+ where,
88
+ message: `image ${image.ref} is not present locally`,
89
+ suggestion: `gamecrate build ${plan.game}`,
90
+ })
91
+ return
92
+ }
93
+
94
+ const remote = await capture(['docker', 'manifest', 'inspect', image.ref])
95
+ if (remote.code === 0) return
96
+
97
+ const host = registryHost(image.ref)
98
+ if (host && needsLogin(remote.stderr) && !hasStoredAuth(host)) {
99
+ problems.push({
100
+ where,
101
+ message: `not authenticated to ${host}, so ${image.ref} cannot be pulled`,
102
+ suggestion: `docker login ${host}`,
103
+ })
104
+ return
105
+ }
106
+
107
+ problems.push({
108
+ where,
109
+ message: `image ${image.ref} is not present locally and cannot be pulled: ${firstLine(remote.stderr) || `exit ${remote.code}`}`,
110
+ suggestion: host ? `docker login ${host}` : undefined,
111
+ })
112
+ }
113
+
114
+ /** A headed run with no display server opens nothing and reports no error of its own. */
115
+ function checkDisplay(plan: LaunchPlan, problems: Problem[]): void {
116
+ if (plan.settings.display === 'x11') {
117
+ if (x11Session()) return
118
+ problems.push({
119
+ where: 'DISPLAY',
120
+ message: 'DISPLAY is not set; a headed X11 launch would open nothing',
121
+ suggestion: 'set settings.display to "wayland", or use --mode headless',
122
+ })
123
+ return
124
+ }
125
+
126
+ if (waylandSocket()) return
127
+ problems.push({
128
+ where: 'WAYLAND_DISPLAY',
129
+ message: 'no wayland socket found; a headed launch would open a blank window',
130
+ suggestion: 'run from a wayland session, or use --mode headless',
131
+ })
132
+ }
133
+
134
+ /** Docker's own failure here is "could not select device driver", which names nothing useful. */
135
+ function checkCdi(problems: Problem[]): void {
136
+ if (!existsSync(CDI_SPEC)) {
137
+ problems.push({
138
+ where: CDI_SPEC,
139
+ message: 'no CDI spec, so --device nvidia.com/gpu=all cannot resolve',
140
+ suggestion: 'sudo nvidia-ctk cdi generate --output=/etc/cdi/nvidia.yaml',
141
+ })
142
+ return
143
+ }
144
+
145
+ let text = ''
146
+ try {
147
+ text = readFileSync(CDI_SPEC, 'utf8')
148
+ } catch (error) {
149
+ problems.push({ where: CDI_SPEC, message: `CDI spec is unreadable: ${message(error)}` })
150
+ return
151
+ }
152
+
153
+ if (!/^\s*-?\s*name:\s*["']?all["']?\s*$/m.test(text)) {
154
+ problems.push({
155
+ where: CDI_SPEC,
156
+ message: 'CDI spec does not declare a device named "all"',
157
+ suggestion: 'sudo nvidia-ctk cdi generate --output=/etc/cdi/nvidia.yaml',
158
+ })
159
+ }
160
+ }
161
+
162
+ function checkGameDir(plan: LaunchPlan, problems: Problem[]): void {
163
+ const files = plan.gameConfig.gameFiles
164
+ if (files.source !== 'mount') return
165
+
166
+ const where = `/games/${plan.game}/gameFiles/host`
167
+ if (!files.host) {
168
+ problems.push({ where, message: 'gameFiles.source is "mount" but no host path is set' })
169
+ return
170
+ }
171
+ if (!existsSync(files.host)) {
172
+ problems.push({ where, message: `game directory does not exist: ${files.host}` })
173
+ return
174
+ }
175
+
176
+ const executable = join(files.host, basename(plan.gameConfig.executable))
177
+ if (!existsSync(executable)) {
178
+ problems.push({
179
+ where,
180
+ message: `${files.host} does not contain ${basename(plan.gameConfig.executable)}`,
181
+ suggestion: 'point gameFiles.host at the install directory, not its parent',
182
+ })
183
+ }
184
+ }
185
+
186
+ function checkBindSources(plan: LaunchPlan, problems: Problem[]): void {
187
+ let mounts
188
+ try {
189
+ mounts = buildRunSpec(plan, [], resolveIdentity(false)).mounts
190
+ } catch (error) {
191
+ problems.push({
192
+ where: `/games/${plan.game}`,
193
+ message: error instanceof GamecrateError ? error.message : message(error),
194
+ suggestion: error instanceof GamecrateError ? error.detail : undefined,
195
+ })
196
+ return
197
+ }
198
+
199
+ for (const mount of mounts) {
200
+ if (mount.type !== 'bind' || !mount.source) continue
201
+ if (existsSync(mount.source)) continue
202
+ // The profile tree is the tool's own output; ensureProfileTree creates it before docker runs.
203
+ if (mount.source.startsWith(plan.profileDir)) continue
204
+ problems.push({
205
+ where: mount.source,
206
+ message: `bind source does not exist and would be mounted at ${mount.target}`,
207
+ })
208
+ }
209
+ }
210
+
211
+ function registryHost(ref: string): string | null {
212
+ const first = ref.split('/')[0]
213
+ if (!first || !ref.includes('/')) return null
214
+ if (first === 'localhost' || first.includes('.') || first.includes(':')) return first
215
+ return null
216
+ }
217
+
218
+ function needsLogin(stderr: string): boolean {
219
+ return /unauthorized|authentication required|denied|forbidden/i.test(stderr)
220
+ }
221
+
222
+ function hasStoredAuth(host: string): boolean {
223
+ const path = join(process.env.DOCKER_CONFIG ?? join(homedir(), '.docker'), 'config.json')
224
+ try {
225
+ const config = JSON.parse(readFileSync(path, 'utf8')) as {
226
+ auths?: Record<string, unknown>
227
+ credsStore?: string
228
+ credHelpers?: Record<string, string>
229
+ }
230
+ if (config.credsStore || config.credHelpers?.[host]) return true
231
+ return Object.keys(config.auths ?? {}).some((key) => key === host || key.includes(`//${host}`))
232
+ } catch {
233
+ return false
234
+ }
235
+ }
236
+
237
+ function firstLine(text: string): string {
238
+ return text.trim().split('\n')[0]?.trim() ?? ''
239
+ }
240
+
241
+ function message(error: unknown): string {
242
+ return error instanceof Error ? error.message : String(error)
243
+ }