@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,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
|
+
}
|