@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/src/index.ts ADDED
@@ -0,0 +1,875 @@
1
+ #!/usr/bin/env node
2
+ import { existsSync } from 'node:fs'
3
+ import { chown, cp, mkdir, readdir, readFile, rm, rmdir, stat, writeFile } from 'node:fs/promises'
4
+ import { homedir } from 'node:os'
5
+ import { basename, dirname, join } from 'node:path'
6
+ import { setTimeout as sleep } from 'node:timers/promises'
7
+
8
+ import { parseArgs } from './cli/args'
9
+ import { renderCompletion, renderHelp } from './cli/help'
10
+ import { openRunLog, planWarnings, printPlan, redirectOutput, reportProblems, status, warn } from './cli/output'
11
+ import {
12
+ defaultConfigPath,
13
+ loadConfig,
14
+ loadProjectDefaults,
15
+ profileDataDir,
16
+ profileDirs,
17
+ resolveProfile,
18
+ } from './config/load'
19
+ import { resolveIdentity } from './docker/identity'
20
+ import { preflight } from './docker/preflight'
21
+ import { capture, exited, spawnArgv, runContainer, stopContainer, STDOUT_LOG, waitForMarker } from './docker/run'
22
+ import { buildRunSpec, containerName, windowTitle } from './docker/spec'
23
+ import { adoptNewWindow } from './docker/window'
24
+ import { generateModsConfig, mergePrefs } from './launch/generate'
25
+ import {
26
+ acquireImage,
27
+ buildLocalMods,
28
+ captureScreenshot,
29
+ ensureRuntimeLayer,
30
+ replacePrevious,
31
+ takeLock,
32
+ writeLaunchRecord,
33
+ } from './launch/prepare'
34
+ import { resolveInstance } from './launch/instance'
35
+ import { resolvePlan } from './launch/resolve'
36
+ import { detectForeignOwnership, ensureProfileTree, stageMods } from './launch/stage'
37
+ import { buildIndex } from './mods/modindex'
38
+ import { requirePlugin } from './plugin'
39
+ import type { GamePlugin } from './plugin'
40
+ import { ago, decideStale, duration, scanBuildTimes, staleReport } from './mods/staleness'
41
+ import { resolveWorktree } from './mods/worktree'
42
+ import { GamecrateError, Exit } from './types'
43
+ import type {
44
+ DockerRunSpec,
45
+ Identity,
46
+ LaunchPlan,
47
+ ParsedArgs,
48
+ Problem,
49
+ ProfileConfig,
50
+ RootConfig,
51
+ StaleReport,
52
+ } from './types'
53
+
54
+ const VERSION = '0.1.0'
55
+ const STOP_TIMEOUT_SECONDS = 10
56
+
57
+ async function main(argv: string[]): Promise<number> {
58
+ const probe = parseArgs(argv)
59
+ if (probe.subcommand === 'version') {
60
+ process.stdout.write(`gamecrate ${VERSION}\n`)
61
+ return Exit.Ok
62
+ }
63
+
64
+ const [{ config, plugins }, defaults] = await Promise.all([loadConfig(), loadProjectDefaults()])
65
+ const args = parseArgs(argv, { games: Object.keys(config.games), defaults })
66
+
67
+ if (args.help) {
68
+ process.stdout.write(renderHelp(helpTopic(args), config))
69
+ return Exit.Ok
70
+ }
71
+ const redirect =
72
+ args.log !== undefined && (args.subcommand === 'run' || args.subcommand === 'shell')
73
+ ? redirectOutput(args.log)
74
+ : undefined
75
+
76
+ try {
77
+ return await dispatch(args, config, plugins)
78
+ } catch (error) {
79
+ // Printed here, not at the top level: with --log the failure belongs in the log file,
80
+ // and the top-level printer only runs once the redirect is already closed.
81
+ return reportFatal(error)
82
+ } finally {
83
+ redirect?.close()
84
+ }
85
+ }
86
+
87
+ async function dispatch(
88
+ args: ParsedArgs,
89
+ config: RootConfig,
90
+ plugins: Map<string, GamePlugin>,
91
+ ): Promise<number> {
92
+ switch (args.subcommand) {
93
+ case 'help':
94
+ return help(args, config)
95
+ case 'list':
96
+ return list(args, config)
97
+ case 'mods':
98
+ return mods(args, config, plugins)
99
+ case 'doctor':
100
+ return doctor(config, plugins)
101
+ case 'clean':
102
+ return clean(args, config)
103
+ case 'clone':
104
+ return clone(args, config)
105
+ case 'logs':
106
+ return logs(args, config)
107
+ case 'verify':
108
+ return verify(args, config, plugins)
109
+ case 'build':
110
+ return build(args, config)
111
+ case 'shell':
112
+ return run(args, config, plugins, true)
113
+ case 'config':
114
+ return configEdit(args)
115
+ case 'fix-perms':
116
+ return fixPerms(args, config)
117
+ case 'run':
118
+ return run(args, config, plugins, false)
119
+ default:
120
+ throw new GamecrateError(`no such subcommand ${args.subcommand}`, Exit.Usage)
121
+ }
122
+ }
123
+
124
+ function helpTopic(args: ParsedArgs): string | undefined {
125
+ if (args.subcommand === 'help') return args.rest[0]
126
+ if (args.subcommand === 'run') return args.game
127
+ return args.subcommand
128
+ }
129
+
130
+ function help(args: ParsedArgs, config: RootConfig): number {
131
+ const topic = args.rest[0]
132
+ if (topic === 'completion') {
133
+ const shell = args.rest[1]
134
+ if (shell !== 'bash' && shell !== 'zsh') {
135
+ throw new GamecrateError('help completion takes bash or zsh', Exit.Usage)
136
+ }
137
+ process.stdout.write(renderCompletion(shell))
138
+ return Exit.Ok
139
+ }
140
+ process.stdout.write(renderHelp(topic, config))
141
+ return Exit.Ok
142
+ }
143
+
144
+ function requireGame(args: ParsedArgs, config: RootConfig): string {
145
+ const game = args.game
146
+ if (game === undefined) {
147
+ throw new GamecrateError(
148
+ `${args.subcommand} needs a game`,
149
+ Exit.Usage,
150
+ `known games: ${Object.keys(config.games).join(', ')}`,
151
+ )
152
+ }
153
+ if (!Object.hasOwn(config.games, game)) {
154
+ throw new GamecrateError(
155
+ `unknown game "${game}"`,
156
+ Exit.Config,
157
+ `known games: ${Object.keys(config.games).join(', ')}`,
158
+ )
159
+ }
160
+ return game
161
+ }
162
+
163
+ /**
164
+ * Where a subcommand should look when `--instance` or `--worktree` names one. Resolving the
165
+ * profile can throw on a name that was never defined, which is not this helper's business.
166
+ */
167
+ function instanceDir(args: ParsedArgs, config: RootConfig, game: string, profile: string): string {
168
+ const dir = profileDataDir(config, game, profile)
169
+ let spec: ProfileConfig | undefined
170
+ try {
171
+ spec = resolveProfile(config.games[game]!, profile)
172
+ } catch {
173
+ spec = undefined
174
+ }
175
+ return resolveInstance({ profileDir: dir, ...(spec === undefined ? {} : { profile: spec }), args }).dir
176
+ }
177
+
178
+ /** Environment problems are exit 5, never 4: they are about this machine, not the config. */
179
+ function reportEnvironment(problems: Problem[]): never {
180
+ process.stderr.write(`${problems.length} environment problem(s):\n`)
181
+ for (const problem of problems) {
182
+ process.stderr.write(` ${problem.where}\n ${problem.message}\n`)
183
+ if (problem.suggestion) process.stderr.write(` try: ${problem.suggestion}\n`)
184
+ }
185
+ process.exit(Exit.Environment)
186
+ }
187
+
188
+ /**
189
+ * config -> index -> resolve -> stage -> generate -> run spec -> execute.
190
+ * `--dry-run` and `--print-plan` stop after validation, before the first write.
191
+ */
192
+ async function run(
193
+ args: ParsedArgs,
194
+ config: RootConfig,
195
+ plugins: Map<string, GamePlugin>,
196
+ asShell: boolean,
197
+ ): Promise<number> {
198
+ const game = requireGame(args, config)
199
+ const profile = args.profile ?? 'modless'
200
+
201
+ const index = await buildIndex(game, config.games[game]!, requirePlugin(plugins, game))
202
+ const { plan, problems } = await resolvePlan({ game, profile, root: config, plugins, args, index })
203
+ if (problems.length > 0) reportProblems(problems)
204
+
205
+ const identity = resolveIdentity(args.root)
206
+
207
+ if (args.printPlan || args.dryRun) {
208
+ const environment = await preflight(plan)
209
+ // buildRunSpec is a validation gate of its own: the "=" landmine throws here.
210
+ buildRunSpec(plan, [], identity)
211
+ if (args.printPlan) printPlan(plan, args.json)
212
+ for (const warning of planWarnings(plan)) warn(warning)
213
+ if (environment.length > 0) return reportEnvironment(environment)
214
+ if (!args.printPlan) {
215
+ const what = plan.instance === undefined ? profile : `${profile}/${plan.instance}`
216
+ status(`${game} ${what}: ${plan.mods.length} mods resolve cleanly`)
217
+ }
218
+ return Exit.Ok
219
+ }
220
+
221
+ const environment = await preflight(plan)
222
+ if (environment.length > 0) return reportEnvironment(environment)
223
+
224
+ await ensureProfileTree(plan)
225
+ if (args.replace) await replacePrevious(plan)
226
+ const lock = await takeLock(plan)
227
+ try {
228
+ return await launch(plan, args, config, identity, asShell)
229
+ } finally {
230
+ await lock.release()
231
+ }
232
+ }
233
+
234
+ async function launch(
235
+ plan: LaunchPlan,
236
+ args: ParsedArgs,
237
+ config: RootConfig,
238
+ identity: Identity,
239
+ asShell: boolean,
240
+ ): Promise<number> {
241
+ const game = plan.game
242
+ const profile = plan.profile
243
+ const foreign = await detectForeignOwnership(plan.dataDirHost, identity.uid, 5)
244
+ if (foreign.length > 0) {
245
+ throw new GamecrateError(
246
+ `${foreign.length} path(s) under ${plan.dataDirHost} are not owned by uid ${identity.uid}`,
247
+ Exit.Environment,
248
+ `${foreign.join('\n')}\nrun: gamecrate fix-perms ${game} ${profile}`,
249
+ )
250
+ }
251
+
252
+ const runDir = openRunLog(plan.logsDirHost)
253
+ plan.runDirHost = runDir
254
+
255
+ await buildLocalMods(plan, args.build ?? 'auto')
256
+ await acquireImage(game, config.games[game]!, args.pull ?? 'missing')
257
+ // Offscreen modes need an X server the published images do not ship; add it once, on top.
258
+ const runtimeImage =
259
+ plan.mode === 'headed'
260
+ ? config.games[game]!.image.ref
261
+ : await ensureRuntimeLayer(config.games[game]!.image.ref)
262
+
263
+ const modMounts = await stageMods(plan)
264
+ await generateModsConfig(plan)
265
+ await mergePrefs(plan)
266
+ for (const warning of planWarnings(plan)) warn(warning)
267
+
268
+ const spec = buildRunSpec(plan, modMounts, identity)
269
+ spec.image = runtimeImage
270
+ if (asShell) {
271
+ spec.command = ['/bin/bash']
272
+ spec.extraArgs = [...spec.extraArgs, '--interactive', '--tty']
273
+ }
274
+ await writeLaunchRecord(plan, spec.image)
275
+
276
+ try {
277
+ // Every path below hands the container to runContainer, which owns SIGINT/SIGTERM: its
278
+ // handler stops the container, returns 130, and still flushes the log.
279
+ if (plan.marker !== undefined && !asShell) return await runWithMarker(spec, plan, runDir)
280
+ if (plan.mode === 'screenshot' && !asShell) return await runWithScreenshot(spec, plan, runDir)
281
+ // An offscreen run has nobody to close the window, so --timeout bounds it even with no
282
+ // marker. Without this it runs forever and keeps the profile lock.
283
+ if (plan.mode !== 'headed' && !asShell) return await runBounded(spec, plan, runDir)
284
+ // Only X11 lets us touch the window from out here; a wayland client owns its own caption
285
+ // and its own close button.
286
+ let windowClosed = false
287
+ const window =
288
+ asShell || plan.settings.display !== 'x11'
289
+ ? null
290
+ : await adoptNewWindow({
291
+ executable: plan.gameConfig.executable,
292
+ title: windowTitle(plan),
293
+ stripDelete: plan.gameConfig.ignoresWmDelete === true,
294
+ onClosed: () => {
295
+ windowClosed = true
296
+ void stopContainer(spec.name, STOP_TIMEOUT_SECONDS)
297
+ },
298
+ })
299
+ try {
300
+ const code = await runContainer(spec, {
301
+ logDir: runDir,
302
+ stopTimeoutSeconds: STOP_TIMEOUT_SECONDS,
303
+ })
304
+ return windowClosed ? Exit.Ok : normalize(code)
305
+ } finally {
306
+ window?.stop()
307
+ }
308
+ } finally {
309
+ await copyOutLogs(plan)
310
+ }
311
+ }
312
+
313
+ /**
314
+ * Where a marker can appear. An engine may route its own log away from stdout, and a
315
+ * copy-out dir is bind-mounted, so both are readable live.
316
+ */
317
+ function markerSources(plan: LaunchPlan, logDir: string): string[] {
318
+ const sources = [join(logDir, STDOUT_LOG)]
319
+ const { logFile } = plan.gameConfig
320
+ if (logFile.mode === 'arg') sources.push(join(logDir, 'Player.log'))
321
+ else sources.push(join(plan.dataDirHost, logFile.from))
322
+ return sources
323
+ }
324
+
325
+ /** Offscreen run with no marker: the deadline is the only thing that can end it. */
326
+ async function runBounded(
327
+ spec: DockerRunSpec,
328
+ plan: LaunchPlan,
329
+ logDir: string,
330
+ ): Promise<number> {
331
+ const container = runContainer(spec, { logDir, stopTimeoutSeconds: STOP_TIMEOUT_SECONDS })
332
+ const winner = await Promise.race([
333
+ container.then((code) => ({ kind: 'exit' as const, code })),
334
+ sleep(plan.timeoutSeconds * 1000).then(() => ({ kind: 'timeout' as const })),
335
+ ])
336
+ if (winner.kind === 'exit') return normalize(winner.code)
337
+
338
+ status(`no marker given; stopping after ${plan.timeoutSeconds}s`)
339
+ await stopContainer(spec.name, STOP_TIMEOUT_SECONDS)
340
+ await container
341
+ return Exit.Ok
342
+ }
343
+
344
+ /** Waits for the game to render, grabs one frame, then stops the container. */
345
+ async function runWithScreenshot(
346
+ spec: DockerRunSpec,
347
+ plan: LaunchPlan,
348
+ logDir: string,
349
+ ): Promise<number> {
350
+ const container = runContainer(spec, { logDir, stopTimeoutSeconds: STOP_TIMEOUT_SECONDS })
351
+ const settled = sleep(plan.renderWaitSeconds * 1000).then(() => 'ready' as const)
352
+
353
+ const winner = await Promise.race([
354
+ container.then((code) => ({ kind: 'exit' as const, code })),
355
+ settled.then(() => ({ kind: 'ready' as const })),
356
+ ])
357
+ if (winner.kind === 'exit') {
358
+ status(`game exited before the ${plan.renderWaitSeconds}s render wait finished; no frame captured`)
359
+ return normalize(winner.code)
360
+ }
361
+
362
+ const shot = await grabFrame(spec.name, plan)
363
+ await stopContainer(spec.name, STOP_TIMEOUT_SECONDS)
364
+ await container
365
+ return shot === null ? Exit.Environment : Exit.Ok
366
+ }
367
+
368
+ async function grabFrame(container: string, plan: LaunchPlan): Promise<string | null> {
369
+ const path = await captureScreenshot(container, plan)
370
+ if (path === null) warn('screenshot capture failed; is imagemagick in the image?')
371
+ else status(`screenshot: ${path}`)
372
+ return path
373
+ }
374
+ /** The marker races the container; whichever finishes first decides the exit code. */
375
+ async function runWithMarker(
376
+ spec: DockerRunSpec,
377
+ plan: LaunchPlan,
378
+ logDir: string,
379
+ ): Promise<number> {
380
+ const marker = plan.marker!
381
+ const container = runContainer(spec, { logDir, stopTimeoutSeconds: STOP_TIMEOUT_SECONDS })
382
+ const seen = waitForMarker(markerSources(plan, logDir), marker, plan.timeoutSeconds)
383
+
384
+ const winner = await Promise.race([
385
+ container.then((code) => ({ kind: 'exit' as const, code })),
386
+ seen.then((hit) => ({ kind: 'marker' as const, hit })),
387
+ ])
388
+ if (winner.kind === 'exit') return normalize(winner.code)
389
+
390
+ if (plan.mode === 'screenshot') await grabFrame(spec.name, plan)
391
+ await stopContainer(spec.name, STOP_TIMEOUT_SECONDS)
392
+ await container
393
+ if (winner.hit) {
394
+ status(`marker seen: ${marker}`)
395
+ return Exit.Ok
396
+ }
397
+ status(`marker "${marker}" not seen within ${plan.timeoutSeconds}s`)
398
+ return Exit.MarkerTimeout
399
+ }
400
+
401
+ function normalize(code: number): number {
402
+ return Number.isInteger(code) && code >= 0 && code <= 255 ? code : Exit.GameFailed
403
+ }
404
+
405
+ /** A copy-out game writes logs under its own data root with no flag, so they move after. */
406
+ async function copyOutLogs(plan: LaunchPlan): Promise<void> {
407
+ const spec = plan.gameConfig.logFile
408
+ if (spec.mode !== 'copy-out') return
409
+ const source = join(plan.dataDirHost, spec.from)
410
+ if (!existsSync(source)) return
411
+ const target = join(plan.runDirHost, basename(spec.from.replace(/\/+$/, '')))
412
+ try {
413
+ await cp(source, target, { recursive: true, force: true })
414
+ } catch (error) {
415
+ warn(`could not copy ${source}: ${describe(error)}`)
416
+ }
417
+ }
418
+
419
+
420
+ function list(args: ParsedArgs, config: RootConfig): number {
421
+ const games = args.game === undefined ? Object.keys(config.games) : [requireGame(args, config)]
422
+
423
+ if (args.json) {
424
+ const payload = games.map((name) => {
425
+ const game = config.games[name]!
426
+ return {
427
+ game: name,
428
+ core: game.core,
429
+ dlc: game.dlc,
430
+ modes: game.modes,
431
+ profiles: Object.entries(game.profiles).map(([profile, spec]) => ({
432
+ profile,
433
+ alias: spec.alias ?? null,
434
+ extends: spec.extends ?? null,
435
+ mods: spec.mods?.length ?? 0,
436
+ instances: Object.keys(spec.instances ?? {}),
437
+ })),
438
+ }
439
+ })
440
+ process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`)
441
+ return Exit.Ok
442
+ }
443
+
444
+ const out: string[] = []
445
+ for (const name of games) {
446
+ const game = config.games[name]!
447
+ const width = Math.max(7, ...Object.keys(game.profiles).map((n) => n.length))
448
+ out.push(`${name} (${game.modes.join(', ')})`)
449
+ out.push(` ${'modless'.padEnd(width)} built-in: core + official DLC`)
450
+ for (const [profile, spec] of Object.entries(game.profiles)) {
451
+ const notes: string[] = []
452
+ if (spec.alias) notes.push(`alias for ${spec.alias}`)
453
+ if (spec.extends) notes.push(`extends ${spec.extends}`)
454
+ const count = spec.mods?.length ?? 0
455
+ if (!spec.alias) notes.push(count === 1 ? '1 entry' : `${count} entries`)
456
+ if (spec.aliases?.length) notes.push(`aka ${spec.aliases.join(', ')}`)
457
+ out.push(` ${profile.padEnd(width)} ${notes.join(', ')}`)
458
+ const instances = Object.keys(spec.instances ?? {})
459
+ if (instances.length > 0) out.push(` ${' '.repeat(width)} instances: ${instances.join(', ')}`)
460
+ }
461
+ }
462
+ process.stdout.write(`${out.join('\n')}\n`)
463
+ return Exit.Ok
464
+ }
465
+
466
+ async function mods(args: ParsedArgs, config: RootConfig, plugins: Map<string, GamePlugin>): Promise<number> {
467
+ const game = requireGame(args, config)
468
+ const profile = args.profile ?? 'modless'
469
+ const index = await buildIndex(game, config.games[game]!, requirePlugin(plugins, game))
470
+ const { plan, problems } = await resolvePlan({ game, profile, root: config, plugins, args, index })
471
+ if (problems.length > 0) reportProblems(problems)
472
+ printPlan(plan, args.json)
473
+ return Exit.Ok
474
+ }
475
+
476
+ async function doctor(config: RootConfig, plugins: Map<string, GamePlugin>): Promise<number> {
477
+ let failed = false
478
+ for (const game of Object.keys(config.games)) {
479
+ const { plan, problems } = await resolvePlan({ game, profile: 'modless', root: config, plugins })
480
+ const all = [...problems, ...(await preflight(plan))]
481
+ if (all.length === 0) {
482
+ status(`${game}: ok`)
483
+ continue
484
+ }
485
+ failed = true
486
+ status(`${game}: ${all.length} problem(s)`)
487
+ for (const problem of all) {
488
+ process.stderr.write(` ${problem.where}\n ${problem.message}\n`)
489
+ if (problem.suggestion) process.stderr.write(` try: ${problem.suggestion}\n`)
490
+ }
491
+ }
492
+ return failed ? Exit.Environment : Exit.Ok
493
+ }
494
+
495
+ async function logs(args: ParsedArgs, config: RootConfig): Promise<number> {
496
+ const game = requireGame(args, config)
497
+ const profile = args.profile ?? 'modless'
498
+ const runs = join(instanceDir(args, config, game, profile), 'logs', 'runs')
499
+
500
+ const latest = (await readdir(runs, { withFileTypes: true }).catch(() => []))
501
+ .filter((entry) => entry.isDirectory())
502
+ .map((entry) => entry.name)
503
+ .sort()
504
+ .at(-1)
505
+ if (latest === undefined) {
506
+ throw new GamecrateError(`no runs recorded for ${game} ${profile}`, Exit.Usage, runs)
507
+ }
508
+
509
+ const dir = join(runs, latest)
510
+ const files = (await readdir(dir, { withFileTypes: true }))
511
+ .filter((entry) => entry.isFile())
512
+ .map((entry) => entry.name)
513
+ .sort()
514
+
515
+ if (args.json) {
516
+ process.stdout.write(`${JSON.stringify({ run: latest, dir, files }, null, 2)}\n`)
517
+ return Exit.Ok
518
+ }
519
+
520
+ status(dir)
521
+ for (const name of files) {
522
+ const text = await readFile(join(dir, name), 'utf8').catch(() => '')
523
+ for (const line of text.split('\n')) {
524
+ if (line.length > 0) process.stdout.write(`${name}: ${line}\n`)
525
+ }
526
+ }
527
+ return Exit.Ok
528
+ }
529
+
530
+ interface DockerInspect {
531
+ State?: { Running?: boolean; StartedAt?: string }
532
+ Mounts?: { Source?: string; Destination?: string }[]
533
+ }
534
+
535
+ interface ContainerInfo {
536
+ running: boolean
537
+ startedAt: number
538
+ mounts: { source: string; destination: string }[]
539
+ }
540
+
541
+ async function inspectContainer(name: string): Promise<ContainerInfo | null> {
542
+ const { code, stdout: text } = await capture(['docker', 'inspect', name])
543
+ if (code !== 0) return null
544
+
545
+ let first: DockerInspect | undefined
546
+ try {
547
+ first = (JSON.parse(text) as DockerInspect[])[0]
548
+ } catch {
549
+ return null
550
+ }
551
+ if (first === undefined) return null
552
+
553
+ const startedAt = Date.parse(first.State?.StartedAt ?? '')
554
+ return {
555
+ running: first.State?.Running === true,
556
+ startedAt: Number.isNaN(startedAt) ? Date.now() : startedAt,
557
+ mounts: (first.Mounts ?? [])
558
+ .filter((m) => m.Source !== undefined && m.Destination !== undefined)
559
+ .map((m) => ({ source: m.Source!, destination: m.Destination! })),
560
+ }
561
+ }
562
+
563
+ interface BoundMod {
564
+ packageId: string
565
+ hostDir: string
566
+ branch: string | null
567
+ assembly: { path: string; mtimeMs: number } | null
568
+ hasSources: boolean
569
+ stale: boolean
570
+ report: StaleReport | null
571
+ }
572
+
573
+ function boundStatus(mod: BoundMod): string {
574
+ if (mod.report !== null) {
575
+ const files = mod.report.newerCount === 1 ? '1 source' : `${mod.report.newerCount} sources`
576
+ return `STALE - ${files} newer`
577
+ }
578
+ if (mod.assembly !== null) return 'OK'
579
+ return mod.hasSources ? 'STALE - never built' : '(xml only)'
580
+ }
581
+
582
+ /**
583
+ * What the container is running right now, read off its own mounts. A green build proves the
584
+ * compiler ran somewhere, not that it wrote into the directory this container bound.
585
+ */
586
+ async function verify(args: ParsedArgs, config: RootConfig, plugins: Map<string, GamePlugin>): Promise<number> {
587
+ const game = requireGame(args, config)
588
+ const profile = args.profile ?? 'modless'
589
+ const { plan, problems } = await resolvePlan({ game, profile, root: config, plugins, args })
590
+ if (problems.length > 0) reportProblems(problems)
591
+
592
+ const name = containerName(plan)
593
+ const info = await inspectContainer(name)
594
+ if (info === null || !info.running) {
595
+ throw new GamecrateError(
596
+ `no container named ${name} is running`,
597
+ Exit.Environment,
598
+ 'launch it first, or name the run with --instance or --worktree',
599
+ )
600
+ }
601
+
602
+ // Per-mod binds are one level under the mods dir; the staged tree itself is the parent.
603
+ const prefix = `${plan.gameConfig.modsDir.container}/`
604
+ const bound = info.mounts
605
+ .filter((m) => m.destination.startsWith(prefix))
606
+ .filter((m) => !m.destination.slice(prefix.length).includes('/'))
607
+ .sort((a, b) => (a.destination < b.destination ? -1 : 1))
608
+
609
+ const boundMods: BoundMod[] = await Promise.all(
610
+ bound.map(async (mount): Promise<BoundMod> => {
611
+ const times = await scanBuildTimes(mount.source)
612
+ const request = resolveWorktree(mount.source, 'ref', 0)
613
+ return {
614
+ packageId: mount.destination.slice(prefix.length),
615
+ hostDir: mount.source,
616
+ branch: 'root' in request ? request.branch : null,
617
+ assembly: times.newestAssembly ?? null,
618
+ hasSources: times.newestSource !== undefined,
619
+ stale: decideStale(times),
620
+ report: staleReport(times),
621
+ }
622
+ }),
623
+ )
624
+
625
+ const stale = boundMods.filter((mod) => mod.stale)
626
+ if (args.json) {
627
+ const payload = {
628
+ container: name,
629
+ running: true,
630
+ upSeconds: Math.round((Date.now() - info.startedAt) / 1000),
631
+ instance: plan.instance ?? plan.profile,
632
+ stale: stale.length,
633
+ mods: boundMods.map((mod) => ({
634
+ packageId: mod.packageId,
635
+ hostDir: mod.hostDir,
636
+ branch: mod.branch,
637
+ assembly: mod.assembly?.path ?? null,
638
+ assemblyMs: mod.assembly?.mtimeMs ?? null,
639
+ stale: mod.stale,
640
+ status: boundStatus(mod),
641
+ ...(mod.report === null ? {} : { report: mod.report }),
642
+ })),
643
+ }
644
+ process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`)
645
+ return stale.length > 0 ? Exit.Stale : Exit.Ok
646
+ }
647
+
648
+ process.stdout.write(`${renderVerify(name, info, plan, boundMods)}\n`)
649
+ return stale.length > 0 ? Exit.Stale : Exit.Ok
650
+ }
651
+
652
+ function renderVerify(
653
+ name: string,
654
+ info: ContainerInfo,
655
+ plan: LaunchPlan,
656
+ boundMods: BoundMod[],
657
+ ): string {
658
+ const out = [
659
+ '',
660
+ ` container ${name} (up ${duration(Date.now() - info.startedAt)})`,
661
+ ` instance ${plan.instance ?? plan.profile}`,
662
+ '',
663
+ ]
664
+ if (boundMods.length === 0) {
665
+ out.push(' no boundMods are bind-mounted into this container')
666
+ return out.join('\n')
667
+ }
668
+
669
+ const paths = boundMods.map((mod) => shortenHome(mod.hostDir))
670
+ const idWidth = Math.max(...boundMods.map((mod) => mod.packageId.length))
671
+ const pathWidth = Math.max(...paths.map((p) => p.length))
672
+ const stamps = boundMods.map((mod) =>
673
+ mod.assembly === null ? 'no assemblies' : `${mod.assembly.path} ${ago(mod.assembly.mtimeMs)}`,
674
+ )
675
+ const stampWidth = Math.max(...stamps.map((s) => s.length))
676
+
677
+ for (const [i, mod] of boundMods.entries()) {
678
+ const origin = mod.branch === null ? '' : `worktree ${mod.branch}`
679
+ out.push(` ${mod.packageId.padEnd(idWidth)} ${paths[i]!.padEnd(pathWidth)} ${origin}`.trimEnd())
680
+ out.push(` ${' '.repeat(idWidth)} ${stamps[i]!.padEnd(stampWidth)} ${boundStatus(mod)}`)
681
+ }
682
+ return out.join('\n')
683
+ }
684
+
685
+ function shortenHome(path: string): string {
686
+ const home = homedir()
687
+ return path === home || path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path
688
+ }
689
+
690
+ /** Tiered on purpose: the default tier can never reach a save. */
691
+ async function clean(args: ParsedArgs, config: RootConfig): Promise<number> {
692
+ const game = requireGame(args, config)
693
+ const profile = args.profile
694
+ if (profile === undefined) throw new GamecrateError('clean needs a profile', Exit.Usage)
695
+
696
+ const dir = profileDataDir(config, game, profile)
697
+ const tier = args.cleanTier ?? 'staging'
698
+ const saveSuffixes = config.games[game]!.saveExtensions.map((ext) => `.${ext.replace(/^\./, '')}`.toLowerCase())
699
+
700
+ // The cheap tiers belong to one instance; --all takes the profile and every instance with it.
701
+ if (tier !== 'all') {
702
+ const target = join(instanceDir(args, config, game, profile), tier === 'logs' ? 'logs' : '.stage')
703
+ await rm(target, { recursive: true, force: true })
704
+ status(`removed ${target}`)
705
+ return Exit.Ok
706
+ }
707
+
708
+ const saves = await countSaves(dir, saveSuffixes)
709
+ if (!args.yes) {
710
+ throw new GamecrateError(
711
+ `clean --all would delete ${dir}, including ${saves} save file(s)`,
712
+ Exit.Usage,
713
+ 'add --yes to confirm',
714
+ )
715
+ }
716
+ await rm(dir, { recursive: true, force: true })
717
+ status(`removed ${dir} (${saves} save file(s))`)
718
+ return Exit.Ok
719
+ }
720
+
721
+ async function countSaves(dir: string, suffixes: string[]): Promise<number> {
722
+ let count = 0
723
+ const queue = [dir]
724
+ while (queue.length > 0) {
725
+ const current = queue.shift()!
726
+ let entries
727
+ try {
728
+ entries = await readdir(current, { withFileTypes: true })
729
+ } catch {
730
+ continue
731
+ }
732
+ for (const entry of entries) {
733
+ if (entry.isDirectory()) queue.push(join(current, entry.name))
734
+ else if (suffixes.some((s) => entry.name.toLowerCase().endsWith(s))) count++
735
+ }
736
+ }
737
+ return count
738
+ }
739
+
740
+ /** `cp -a --reflink=auto`: on btrfs the precious tier copies in constant time. */
741
+ async function clone(args: ParsedArgs, config: RootConfig): Promise<number> {
742
+ const game = requireGame(args, config)
743
+ const [src, dst] = args.rest
744
+ if (src === undefined || dst === undefined) {
745
+ throw new GamecrateError('clone needs a source and a destination profile', Exit.Usage)
746
+ }
747
+
748
+ const from = join(profileDataDir(config, game, src), 'game')
749
+ const to = join(profileDataDir(config, game, dst), 'game')
750
+ if (!existsSync(from)) throw new GamecrateError(`${from} does not exist`, Exit.Usage)
751
+ if (existsSync(to) && !args.yes) {
752
+ throw new GamecrateError(`${to} already exists`, Exit.Usage, 'add --yes to overwrite')
753
+ }
754
+
755
+ await mkdir(to, { recursive: true })
756
+ const code = await spawnStatus(['cp', '-a', '--reflink=auto', `${from}/.`, to])
757
+ if (code !== 0) throw new GamecrateError(`cp failed with exit ${code}`, Exit.Environment)
758
+ status(`cloned ${from} -> ${to}`)
759
+ return Exit.Ok
760
+ }
761
+
762
+ async function build(args: ParsedArgs, config: RootConfig): Promise<number> {
763
+ const game = requireGame(args, config)
764
+ await acquireImage(game, config.games[game]!, args.pull ?? 'always')
765
+ status(`${config.games[game]!.image.ref} is ready`)
766
+ return Exit.Ok
767
+ }
768
+
769
+ async function configEdit(args: ParsedArgs): Promise<number> {
770
+ if (args.rest[0] !== 'edit') throw new GamecrateError('config takes one word: edit', Exit.Usage)
771
+
772
+ const path = defaultConfigPath()
773
+ await mkdir(dirname(path), { recursive: true })
774
+ if (!existsSync(path)) await writeFile(path, '{\n "games": {}\n}\n')
775
+
776
+ const editor = process.env.VISUAL ?? process.env.EDITOR
777
+ if (editor === undefined) throw new GamecrateError('no $EDITOR or $VISUAL set', Exit.Usage, path)
778
+ if ((await spawnStatus([...editor.split(' '), path], true)) !== 0) return Exit.Usage
779
+
780
+ await loadConfig(path)
781
+ status(`${path} is valid`)
782
+ return Exit.Ok
783
+ }
784
+
785
+ /** Never silently chowns: it reports what it found and only acts under --yes. */
786
+ async function fixPerms(args: ParsedArgs, config: RootConfig): Promise<number> {
787
+ const game = requireGame(args, config)
788
+ const identity = resolveIdentity(false)
789
+ const found: string[] = []
790
+ for (const dir of await profileDirs(config, game, args.profile)) {
791
+ if (!existsSync(dir)) continue
792
+ found.push(...(await detectForeignOwnership(dir, identity.uid, 10_000)))
793
+ }
794
+
795
+ if (found.length === 0) {
796
+ status(`${game}: every path is owned by uid ${identity.uid}`)
797
+ return Exit.Ok
798
+ }
799
+ if (args.dryRun || !args.yes) {
800
+ for (const path of found) process.stdout.write(`would chown ${identity.uid}:${identity.gid} ${path}\n`)
801
+ status(`${found.length} foreign-owned path(s); re-run with --yes to chown them`)
802
+ return Exit.Environment
803
+ }
804
+ // chown of a foreign-owned path needs root either way, so an empty directory whose parent
805
+ // we own is recovered by removing it: the next launch recreates it as the caller.
806
+ let fixed = 0
807
+ const stuck: string[] = []
808
+ for (const path of found) {
809
+ const chowned = await chown(path, identity.uid, identity.gid).then(
810
+ () => true,
811
+ () => false,
812
+ )
813
+ if (chowned) {
814
+ fixed += 1
815
+ continue
816
+ }
817
+ if (await removeIfEmptyDir(path)) {
818
+ status(`removed empty ${path}; it will be recreated on the next run`)
819
+ fixed += 1
820
+ continue
821
+ }
822
+ stuck.push(path)
823
+ }
824
+
825
+ if (fixed > 0) status(`fixed ${fixed} path(s) for ${identity.uid}:${identity.gid}`)
826
+ if (stuck.length > 0) {
827
+ for (const path of stuck) warn(`cannot chown ${path}`)
828
+ throw new GamecrateError(
829
+ `${stuck.length} path(s) still not owned by uid ${identity.uid}`,
830
+ Exit.Environment,
831
+ `sudo chown -R ${identity.uid}:${identity.gid} ${stuck.join(' ')}`,
832
+ )
833
+ }
834
+ return Exit.Ok
835
+ }
836
+
837
+ /** Removing needs write on the parent, not ownership of the directory itself. */
838
+ async function removeIfEmptyDir(path: string): Promise<boolean> {
839
+ const info = await stat(path).catch(() => null)
840
+ if (info === null || !info.isDirectory()) return false
841
+ const entries = await readdir(path).catch((): string[] | null => null)
842
+ if (entries === null || entries.length > 0) return false
843
+ // fs.rm on a directory needs recursive:true; rmdir is the one that removes an empty dir.
844
+ return rmdir(path).then(
845
+ () => true,
846
+ () => false,
847
+ )
848
+ }
849
+
850
+ async function spawnStatus(argv: string[], interactive = false): Promise<number> {
851
+ const proc = spawnArgv(argv, [interactive ? 'inherit' : 'ignore', 'inherit', 'inherit'])
852
+ return exited(proc)
853
+ }
854
+
855
+ function describe(error: unknown): string {
856
+ return error instanceof Error ? error.message : String(error)
857
+ }
858
+
859
+ /** Prints a failure and returns its exit code. Shared, so --log and the bare path agree. */
860
+ function reportFatal(error: unknown): number {
861
+ if (error instanceof GamecrateError) {
862
+ process.stderr.write(`gamecrate: ${error.message}\n`)
863
+ if (error.detail) process.stderr.write(`${error.detail}\n`)
864
+ return error.code
865
+ }
866
+ process.stderr.write(`gamecrate: ${describe(error)}\n`)
867
+ return Exit.GameFailed
868
+ }
869
+
870
+ try {
871
+ process.exit(await main(process.argv.slice(2)))
872
+ } catch (error) {
873
+ // Only reachable for failures before dispatch: arg parsing and config loading.
874
+ process.exit(reportFatal(error))
875
+ }