@gamecrate/cli 0.1.0 → 1.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.
Files changed (58) hide show
  1. package/README.md +204 -33
  2. package/dist/gamecrate.js +1486 -715
  3. package/dist/lib.js +320 -0
  4. package/dist/types/cli/args.d.ts +56 -0
  5. package/dist/types/cli/game.d.ts +2 -0
  6. package/dist/types/cli/help.d.ts +7 -0
  7. package/dist/types/cli/list.d.ts +2 -0
  8. package/dist/types/cli/output.d.ts +78 -0
  9. package/dist/types/cli/profile.d.ts +6 -0
  10. package/dist/types/config/builtin.d.ts +3 -0
  11. package/dist/types/config/jsonc.d.ts +5 -0
  12. package/dist/types/config/load.d.ts +47 -0
  13. package/dist/types/config/read.d.ts +11 -0
  14. package/dist/types/config/validate.d.ts +12 -0
  15. package/dist/types/docker/identity.d.ts +6 -0
  16. package/dist/types/docker/preflight.d.ts +3 -0
  17. package/dist/types/docker/run.d.ts +39 -0
  18. package/dist/types/docker/spec.d.ts +24 -0
  19. package/dist/types/docker/window.d.ts +50 -0
  20. package/dist/types/index.d.ts +2 -0
  21. package/dist/types/launch/generate.d.ts +8 -0
  22. package/dist/types/launch/instance.d.ts +20 -0
  23. package/dist/types/launch/prepare.d.ts +87 -0
  24. package/dist/types/launch/resolve.d.ts +17 -0
  25. package/dist/types/launch/stage.d.ts +13 -0
  26. package/dist/types/launch/supervisor.d.ts +48 -0
  27. package/dist/types/lib.d.ts +3 -0
  28. package/dist/types/mods/modindex.d.ts +29 -0
  29. package/dist/types/mods/staleness.d.ts +28 -0
  30. package/dist/types/mods/worktree.d.ts +18 -0
  31. package/dist/types/plugin.d.ts +35 -0
  32. package/dist/types/run/registry.d.ts +22 -0
  33. package/dist/types/types.d.ts +428 -0
  34. package/package.json +15 -10
  35. package/src/cli/args.ts +0 -592
  36. package/src/cli/help.ts +0 -193
  37. package/src/cli/output.ts +0 -246
  38. package/src/config/builtin.ts +0 -19
  39. package/src/config/jsonc.ts +0 -21
  40. package/src/config/load.ts +0 -387
  41. package/src/config/validate.ts +0 -0
  42. package/src/docker/identity.ts +0 -25
  43. package/src/docker/preflight.ts +0 -243
  44. package/src/docker/run.ts +0 -212
  45. package/src/docker/spec.ts +0 -357
  46. package/src/docker/window.ts +0 -152
  47. package/src/index.ts +0 -875
  48. package/src/launch/generate.ts +0 -151
  49. package/src/launch/instance.ts +0 -106
  50. package/src/launch/prepare.ts +0 -332
  51. package/src/launch/resolve.ts +0 -383
  52. package/src/launch/stage.ts +0 -97
  53. package/src/lib.ts +0 -22
  54. package/src/mods/modindex.ts +0 -539
  55. package/src/mods/staleness.ts +0 -125
  56. package/src/mods/worktree.ts +0 -107
  57. package/src/plugin.ts +0 -152
  58. package/src/types.ts +0 -423
@@ -1,151 +0,0 @@
1
- import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises'
2
- import { dirname, join } from 'node:path'
3
-
4
- import { expandHome } from '../config/load'
5
- import { GamecrateError, Exit } from '../types'
6
- import type { GamePlugin } from '../plugin'
7
- import type { GameConfig, LaunchPlan } from '../types'
8
-
9
- async function readInstallVersion(
10
- game: GameConfig,
11
- plugin: GamePlugin,
12
- ): Promise<{ version: string; buildNumber: number } | null> {
13
- const host = game.gameFiles.host
14
- if (game.gameFiles.source !== 'mount' || host === undefined) return null
15
- let raw: string
16
- try {
17
- raw = await readFile(join(expandHome(host), 'Version.txt'), 'utf8')
18
- } catch {
19
- return null
20
- }
21
- return plugin.parseVersion(raw.replace(/^/, '').trim())
22
- }
23
-
24
- /** The expansions actually installed, ordered by the game's declared dlc list. */
25
- async function readKnownExpansions(
26
- game: GameConfig,
27
- plugin: GamePlugin,
28
- warnings: string[],
29
- ): Promise<string[]> {
30
- // An empty dlc list is the game saying it ships no expansions, so there is no Data/ to read
31
- // and nothing to order by. Scanning anyway warns about a directory that was never expected.
32
- if (game.dlc.length === 0) return []
33
- const host = game.gameFiles.host
34
- if (game.gameFiles.source !== 'mount' || host === undefined) return [...game.dlc]
35
- const dataDir = join(expandHome(host), 'Data')
36
- let entries
37
- try {
38
- entries = await readdir(dataDir, { withFileTypes: true })
39
- } catch {
40
- warnings.push(`could not read ${dataDir}; falling back to the configured dlc list`)
41
- return [...game.dlc]
42
- }
43
- const found: string[] = []
44
- for (const entry of entries) {
45
- if (!entry.isDirectory()) continue
46
- let id: string | null = null
47
- try {
48
- // The plugin reads the manifest's own packageId, so a dependency's can never land here.
49
- id = plugin.parseManifest(await readFile(join(dataDir, entry.name, game.manifest.file), 'utf8'))?.packageId ?? null
50
- } catch {
51
- continue
52
- }
53
- if (id !== null && id.toLowerCase() !== game.core.toLowerCase()) found.push(id)
54
- }
55
- const order = game.dlc.map((id) => id.toLowerCase())
56
- return found.sort((a, b) => {
57
- const ai = order.indexOf(a.toLowerCase())
58
- const bi = order.indexOf(b.toLowerCase())
59
- return (ai === -1 ? order.length : ai) - (bi === -1 ? order.length : bi)
60
- })
61
- }
62
-
63
- /** Rewritten in full every launch; the resolved list is the only source of truth. */
64
- export async function generateModsConfig(plan: LaunchPlan): Promise<string> {
65
- const game = plan.gameConfig
66
- const target = join(plan.dataDirHost, game.modsConfig.file)
67
- await mkdir(dirname(target), { recursive: true })
68
-
69
- const installed = await readInstallVersion(game, plan.plugin)
70
- if (installed === null) {
71
- plan.warnings.push(`could not read Version.txt for ${plan.game}; ModsConfig version may be rejected`)
72
- }
73
- // Engines fill a case-sensitive active set from these strings but look ids up lowercased, so
74
- // manifest casing reads back as inactive and SetActive appends a twin. Write lowercase only.
75
- const declared = new Map([game.core, ...game.dlc].map((id) => [id.toLowerCase(), id]))
76
- const seen = new Set<string>()
77
- const activeMods: string[] = []
78
- for (const mod of plan.mods) {
79
- const key = mod.packageId.toLowerCase()
80
- if (seen.has(key)) continue
81
- seen.add(key)
82
- activeMods.push(key)
83
- }
84
-
85
- const knownExpansions = (await readKnownExpansions(game, plan.plugin, plan.warnings)).map(
86
- (id) => declared.get(id.toLowerCase()) ?? id,
87
- )
88
- await writeFile(
89
- target,
90
- fromPlugin(target, () =>
91
- plan.plugin.renderModsConfig({
92
- version: installed?.version ?? '',
93
- buildNumber: installed?.buildNumber ?? -1,
94
- activeMods,
95
- knownExpansions,
96
- }),
97
- ),
98
- )
99
- return target
100
- }
101
-
102
- /**
103
- * A plugin that cannot parse or render a game file is a config failure, not a game crash,
104
- * and the message is useless without the path it was working on.
105
- */
106
- function fromPlugin(target: string, render: () => string): string {
107
- try {
108
- return render()
109
- } catch (cause) {
110
- if (cause instanceof GamecrateError) throw cause
111
- const error = new GamecrateError(`${target}: ${cause instanceof Error ? cause.message : String(cause)}`, Exit.Config)
112
- error.cause = cause
113
- throw error
114
- }
115
- }
116
-
117
- /** The keys the tool owns. Everything else in the user's Prefs survives untouched. */
118
- function ownedPrefs(plan: LaunchPlan): Record<string, string> {
119
- const { settings } = plan
120
- const bool = (value: boolean): string => (value ? 'True' : 'False')
121
- const owned: Record<string, string> = {
122
- screenWidth: String(settings.width),
123
- screenHeight: String(settings.height),
124
- devMode: bool(settings.devMode),
125
- runInBackground: bool(settings.runInBackground),
126
- ...plan.plugin.windowedPrefs,
127
- }
128
- Object.assign(owned, settings.prefsExtra ?? {})
129
- owned.resetModsConfigOnCrash = 'False'
130
- return owned
131
- }
132
-
133
- /**
134
- * Merges key-by-key. The live Prefs holds ~40 tuned keys, so a rewrite destroys
135
- * volumeMaster, uiScale, langFolderName and the nested screenShakeIntensity block.
136
- */
137
- export async function mergePrefs(plan: LaunchPlan): Promise<string> {
138
- const target = join(plan.dataDirHost, plan.gameConfig.prefs.file)
139
- await mkdir(dirname(target), { recursive: true })
140
-
141
- let existing: string | null = null
142
- try {
143
- existing = await readFile(target, 'utf8')
144
- } catch (error) {
145
- // Only a missing file means "start fresh". Any other read failure and the file is there
146
- // but unreadable, so writing would drop ~40 tuned keys we never got to see.
147
- if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
148
- }
149
- await writeFile(target, fromPlugin(target, () => plan.plugin.mergePrefs(existing, ownedPrefs(plan))))
150
- return target
151
- }
@@ -1,106 +0,0 @@
1
- import { createHash } from 'node:crypto'
2
- import { basename, join } from 'node:path'
3
-
4
- import { collectRequests } from '../mods/worktree'
5
- import { GamecrateError, Exit, NAME_PATTERN, own } from '../types'
6
- import type {
7
- InstanceConfig,
8
- ParsedArgs,
9
- Problem,
10
- ProfileConfig,
11
- Settings,
12
- WorktreeRequest,
13
- } from '../types'
14
-
15
- /** Enough to keep a name readable in `docker ps` without truncating the hash off the end. */
16
- const SLUG_LIMIT = 24
17
-
18
- export interface InstanceSelection {
19
- /** Undefined for the base profile. */
20
- name?: string
21
- /** profileDir, or <profileDir>/instances/<name>. */
22
- dir: string
23
- requests: WorktreeRequest[]
24
- problems: Problem[]
25
- settings?: Partial<Settings>
26
- }
27
-
28
- export interface InstanceOptions {
29
- profileDir: string
30
- /** Absent when the caller only has a profile name, as `clean` on an unknown profile does. */
31
- profile?: ProfileConfig
32
- args: Partial<ParsedArgs>
33
- cwd?: string
34
- env?: string
35
- }
36
-
37
- /** Decides which sub-run of a profile this is. Any worktree in the set forks one. */
38
- export function resolveInstance(options: InstanceOptions): InstanceSelection {
39
- const { profileDir, profile, args } = options
40
- const configured = lookup(profile, args.instance)
41
-
42
- const flags = configured?.worktree === undefined
43
- ? (args.worktree ?? [])
44
- : [configured.worktree, ...(args.worktree ?? [])]
45
-
46
- const { requests, problems } = collectRequests(
47
- flags,
48
- options.env ?? process.env['GAMECRATE_WORKTREE'],
49
- options.cwd ?? process.cwd(),
50
- args.noWorktree ?? false,
51
- )
52
-
53
- const name = args.instance === undefined ? derive(requests) : named(args.instance)
54
- return {
55
- ...(name === undefined ? {} : { name }),
56
- dir: name === undefined ? profileDir : join(profileDir, 'instances', name),
57
- requests,
58
- problems,
59
- ...(configured?.settings === undefined ? {} : { settings: configured.settings }),
60
- }
61
- }
62
-
63
- /** Same case-insensitive courtesy a profile key gets, so `--instance WT-A` finds `wt-a`. */
64
- function lookup(profile: ProfileConfig | undefined, name: string | undefined): InstanceConfig | undefined {
65
- const instances = profile?.instances
66
- if (instances === undefined || name === undefined) return undefined
67
- const exact = own(instances, name)
68
- if (exact !== undefined) return exact
69
- const lower = name.toLowerCase()
70
- const key = Object.keys(instances).find((k) => k.toLowerCase() === lower)
71
- return key === undefined ? undefined : own(instances, key)
72
- }
73
-
74
- function named(name: string): string {
75
- if (!NAME_PATTERN.test(name)) {
76
- throw new GamecrateError(
77
- `invalid instance name "${name}"`,
78
- Exit.Usage,
79
- `it becomes a directory and a container name, so it must match ${NAME_PATTERN.source}`,
80
- )
81
- }
82
- return name
83
- }
84
-
85
- /**
86
- * A cwd worktree names an instance the same as an explicit one: it stages a different mod set,
87
- * so it must not share a save dir, a lock or a container name with the profile. The hash covers
88
- * every root because two repos can both hold a worktree called `fix-thing`.
89
- */
90
- function derive(requests: WorktreeRequest[]): string | undefined {
91
- const first = requests[0]
92
- if (first === undefined) return undefined
93
-
94
- const digest = createHash('sha256').update(requests.map((r) => r.root).join('\0')).digest('hex')
95
- return `${slug(first.root)}-${digest.slice(0, 6)}`
96
- }
97
-
98
- function slug(root: string): string {
99
- const body = basename(root)
100
- .toLowerCase()
101
- .replace(/[^a-z0-9._-]+/g, '-')
102
- .replace(/^[^a-z0-9]+/, '')
103
- .slice(0, SLUG_LIMIT)
104
- .replace(/[-._]+$/, '')
105
- return body === '' ? 'wt' : body
106
- }
@@ -1,332 +0,0 @@
1
- import { existsSync } from 'node:fs'
2
- import { open, readdir, readFile, unlink, writeFile } from 'node:fs/promises'
3
- import { join } from 'node:path'
4
- import { setTimeout as sleep } from 'node:timers/promises'
5
-
6
- import { forwardOutput, status } from '../cli/output'
7
- import { capture, exited, spawnArgv, stopContainer } from '../docker/run'
8
- import { containerName, CONTAINER_LOG_DIR } from '../docker/spec'
9
- import { GamecrateError, Exit } from '../types'
10
- import type { BuildPolicy, GameConfig, LaunchPlan, PullPolicy } from '../types'
11
-
12
- async function inherit(argv: string[], stdin?: Uint8Array): Promise<number> {
13
- const proc = spawnArgv(argv, [stdin === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'])
14
- const code = exited(proc)
15
- if (stdin !== undefined) {
16
- // A child that never reads, or never started, must surface as its exit code, not a throw.
17
- proc.stdin!.on('error', () => {})
18
- proc.stdin!.end(stdin)
19
- }
20
- await Promise.all([
21
- forwardOutput(proc.stdout!, process.stdout),
22
- forwardOutput(proc.stderr!, process.stderr),
23
- ])
24
- return code
25
- }
26
-
27
- /** Resolved image id, so `launches.jsonl` records what actually ran, not a floating tag. */
28
- export async function imageDigest(ref: string): Promise<string | null> {
29
- const { code, stdout } = await capture(['docker', 'image', 'inspect', '--format', '{{.Id}}', ref])
30
- const id = stdout.trim()
31
- return code === 0 && id.length > 0 ? id : null
32
- }
33
-
34
- /** One label off an image, or null when the image or the label is missing. */
35
- async function imageLabel(ref: string, label: string): Promise<string | null> {
36
- const format = `{{index .Config.Labels "${label}"}}`
37
- const { code, stdout } = await capture(['docker', 'image', 'inspect', '--format', format, ref])
38
- const value = stdout.trim()
39
- if (code !== 0 || value.length === 0 || value === '<no value>') return null
40
- return value
41
- }
42
-
43
- /**
44
- * Pulls or builds per policy. Shared by the `build` subcommand and `run`, so a launch can no
45
- * longer proceed against an image the user asked to refresh.
46
- */
47
- export async function acquireImage(
48
- game: string,
49
- config: GameConfig,
50
- pull: PullPolicy,
51
- ): Promise<void> {
52
- const { image } = config
53
- const present = (await imageDigest(image.ref)) !== null
54
-
55
- if (image.acquire === 'build') {
56
- if (image.context === undefined) {
57
- throw new GamecrateError(`${game} has image.acquire "build" but no context`, Exit.Config)
58
- }
59
- if (present && pull !== 'always') return
60
- if ((await inherit(['docker', 'build', '--tag', image.ref, image.context])) !== 0) {
61
- throw new GamecrateError(`docker build failed for ${image.ref}`, Exit.Environment)
62
- }
63
- return
64
- }
65
-
66
- if (pull === 'never') {
67
- if (present) return
68
- throw new GamecrateError(`--pull never but ${image.ref} is not present locally`, Exit.Environment)
69
- }
70
- if (pull === 'missing' && present) return
71
-
72
- if ((await inherit(['docker', 'pull', image.ref])) !== 0) {
73
- if (present) return
74
- throw new GamecrateError(`docker pull failed for ${image.ref}`, Exit.Environment)
75
- }
76
- }
77
-
78
- /**
79
- * Packages headless and screenshot modes need. Neither game's published image ships them,
80
- * and they live in two repos with different publish flows, so gamecrate adds them itself
81
- * rather than making a third game mean editing a third repo.
82
- */
83
- const RUNTIME_PACKAGES = ['xorg-server-xvfb', 'xorg-xwd', 'imagemagick', 'mesa', 'ttf-dejavu']
84
- const RUNTIME_SUFFIX = '-gamecrate'
85
- const BASE_LABEL = 'gamecrate.base'
86
-
87
- /**
88
- * Tag for the derived image, keeping the registry path readable. A tag only exists after the
89
- * last `/`: before it a colon is a registry port, and an `@` means a digest no suffix can ride.
90
- */
91
- export function runtimeLayerRef(ref: string): string {
92
- const at = ref.indexOf('@')
93
- const head = at > 0 ? ref.slice(0, at) : ref
94
- const colon = head.lastIndexOf(':')
95
- const tagged = colon > head.lastIndexOf('/')
96
- const name = tagged ? head.slice(0, colon) : head
97
- if (at > 0) {
98
- // `name:tag@sha256:...` is legal, and the tag has to go: two tags is not a ref docker takes.
99
- const digest = ref.slice(at + 1)
100
- const hex = digest.slice(digest.indexOf(':') + 1)
101
- return `${name}:sha-${hex.slice(0, 12)}${RUNTIME_SUFFIX}`
102
- }
103
- return `${name}:${tagged ? head.slice(colon + 1) : 'latest'}${RUNTIME_SUFFIX}`
104
- }
105
-
106
- /**
107
- * Builds (once) a thin layer over the adapter's image carrying an X server and imagemagick.
108
- * Detects the package manager so a debian-based game image works the same as an Arch one.
109
- */
110
- export async function ensureRuntimeLayer(ref: string): Promise<string> {
111
- const derived = runtimeLayerRef(ref)
112
- const base = await imageDigest(ref)
113
-
114
- // Keyed on the base image id, not just presence: this layer used to be built once and kept
115
- // forever, so a rebuilt base left it months stale with none of the base's newer binaries.
116
- if (base !== null && (await imageLabel(derived, BASE_LABEL)) === base) return derived
117
-
118
- const pacman = `pacman -Syu --noconfirm --needed ${RUNTIME_PACKAGES.join(' ')} && pacman -Scc --noconfirm`
119
- const apt =
120
- 'apt-get update && apt-get install -y --no-install-recommends' +
121
- ' xvfb x11-apps imagemagick libgl1-mesa-dri fonts-dejavu && rm -rf /var/lib/apt/lists/*'
122
- // One line per RUN: shell continuations inside a heredoc-fed Dockerfile are a quoting trap.
123
- const install =
124
- `if command -v pacman >/dev/null 2>&1; then ${pacman};` +
125
- ` elif command -v apt-get >/dev/null 2>&1; then ${apt};` +
126
- ' else echo "no supported package manager in the base image" >&2; exit 1; fi'
127
-
128
- const dockerfile = [
129
- `FROM ${ref}`,
130
- 'USER root',
131
- `RUN ${install}`,
132
- 'RUN command -v xvfb-run && command -v Xvfb',
133
- `LABEL ${BASE_LABEL}=${base}`,
134
- ].join('\n')
135
-
136
- const code = await inherit(
137
- ['docker', 'build', '--tag', derived, '-f', '-', '.'],
138
- new TextEncoder().encode(dockerfile),
139
- )
140
- if (code !== 0) {
141
- throw new GamecrateError(
142
- `could not build the offscreen runtime layer ${derived}`,
143
- Exit.Environment,
144
- 'headless and screenshot modes need an X server in the image',
145
- )
146
- }
147
- return derived
148
- }
149
-
150
- /** A mod builds when it has a .csproj/.slnx and the policy asks for it. */
151
- async function buildTarget(dir: string): Promise<string | null> {
152
- let entries: string[]
153
- try {
154
- entries = await readdir(dir)
155
- } catch {
156
- return null
157
- }
158
- const slnx = entries.find((e) => e.endsWith('.slnx'))
159
- if (slnx) return join(dir, slnx)
160
- const csproj = entries.find((e) => e.endsWith('.csproj'))
161
- return csproj ? join(dir, csproj) : null
162
- }
163
-
164
- /**
165
- * Builds local mods before launch. `always` builds every local mod; `auto` builds only the
166
- * ones resolution flagged stale; `never` skips. A build failure stops the launch — shipping
167
- * the previous DLL after a failed compile is how you debug code that is not running.
168
- */
169
- export async function buildLocalMods(plan: LaunchPlan, policy: BuildPolicy): Promise<void> {
170
- if (policy === 'never') return
171
-
172
- const wanted = plan.mods.filter(
173
- (m) => m.kind === 'local' && (policy === 'always' || m.stale === true),
174
- )
175
- if (wanted.length === 0) return
176
-
177
- for (const mod of wanted) {
178
- const target = await buildTarget(mod.hostDir)
179
- if (target === null) continue
180
- const code = await inherit(['dotnet', 'build', target, '-v', 'quiet', '--nologo'])
181
- if (code !== 0) {
182
- throw new GamecrateError(
183
- `dotnet build failed for ${mod.packageId}`,
184
- Exit.Environment,
185
- target,
186
- )
187
- }
188
- mod.stale = false
189
- // The launch warning is derived from this, so clearing it is what silences the warning.
190
- delete mod.staleReport
191
- }
192
- }
193
-
194
- /**
195
- * Per-instance launch lock. Two concurrent runs would both `rm -rf` the same stage tree, so
196
- * the second is refused rather than allowed to race. Separate instances never meet here.
197
- */
198
- export interface ProfileLock {
199
- release: () => Promise<void>
200
- }
201
-
202
- export async function takeLock(plan: LaunchPlan): Promise<ProfileLock> {
203
- const path = lockPath(plan)
204
- const what = plan.instance === undefined ? plan.profile : `${plan.profile} (${plan.instance})`
205
-
206
- // A container outlives a launcher that was killed, which leaves the lock stale and the
207
- // game still up. Every teardown path here is keyed on the container name, so the second
208
- // run would stop the first run's container instead of its own.
209
- const name = containerName(plan)
210
- const up = await capture(['docker', 'ps', '--quiet', '--filter', `name=^${name}$`])
211
- if (up.stdout.trim().length > 0) {
212
- throw new GamecrateError(
213
- `${plan.game} ${what} is already running (container ${name})`,
214
- Exit.Refused,
215
- `stop it with: docker stop ${name}\nor relaunch with --replace`,
216
- )
217
- }
218
-
219
- if (existsSync(path)) {
220
- const holder = await readFile(path, 'utf8').catch(() => '')
221
- const pid = Number(holder.split('\n')[0])
222
- const alive = Number.isInteger(pid) && pid > 0 && isRunning(pid)
223
- if (alive) {
224
- throw new GamecrateError(
225
- `${plan.game} ${what} is already running (pid ${pid})`,
226
- Exit.Refused,
227
- `if that is wrong, delete ${path}\nor relaunch with --replace`,
228
- )
229
- }
230
- await unlink(path).catch(() => {})
231
- }
232
-
233
- // wx fails rather than truncating, which is what makes this a lock and not a note.
234
- const handle = await open(path, 'wx').catch(() => null)
235
- if (handle === null) {
236
- throw new GamecrateError(`could not take the launch lock at ${path}`, Exit.Environment)
237
- }
238
- await handle.writeFile(`${process.pid}\n${new Date().toISOString()}\n`)
239
- await handle.close()
240
-
241
- return {
242
- release: async () => {
243
- await unlink(path).catch(() => {})
244
- },
245
- }
246
- }
247
-
248
- function lockPath(plan: LaunchPlan): string {
249
- return join(plan.instanceDir, '.gamecrate', 'lock')
250
- }
251
-
252
- /** How long to let the holder notice its container died and drop the lock on its own. */
253
- const RELEASE_WAIT_MS = 10_000
254
- const RELEASE_POLL_MS = 100
255
- const REPLACE_STOP_TIMEOUT_SECONDS = 10
256
-
257
- /**
258
- * `--replace`: stops the container for this profile and instance only, so a parallel worktree
259
- * run is untouched. Waits for the holder to release before forcing, because its own release
260
- * would otherwise unlink the lock we are about to take.
261
- */
262
- export async function replacePrevious(plan: LaunchPlan): Promise<void> {
263
- const name = containerName(plan)
264
- const path = lockPath(plan)
265
- const up = await capture(['docker', 'ps', '--quiet', '--filter', `name=^${name}$`])
266
- const running = up.stdout.trim().length > 0
267
- if (!running && !existsSync(path)) return
268
-
269
- if (running) {
270
- status(`stopping ${name}`)
271
- await stopContainer(name, REPLACE_STOP_TIMEOUT_SECONDS)
272
- }
273
-
274
- const deadline = Date.now() + RELEASE_WAIT_MS
275
- while (existsSync(path) && Date.now() < deadline) {
276
- const holder = await readFile(path, 'utf8').catch(() => '')
277
- const pid = Number(holder.split('\n')[0])
278
- if (!Number.isInteger(pid) || pid <= 0 || !isRunning(pid)) break
279
- await sleep(RELEASE_POLL_MS)
280
- }
281
- await unlink(path).catch(() => {})
282
- }
283
-
284
- function isRunning(pid: number): boolean {
285
- try {
286
- process.kill(pid, 0)
287
- return true
288
- } catch {
289
- return false
290
- }
291
- }
292
-
293
- /**
294
- * Grabs one frame from inside the running container. The run dir is already bind-mounted at
295
- * CONTAINER_LOG_DIR, so the png lands next to that run's logs with no extra mount.
296
- */
297
- export async function captureScreenshot(container: string, plan: LaunchPlan): Promise<string | null> {
298
- const name = `${plan.game}.png`
299
- const target = `${CONTAINER_LOG_DIR}/${name}`
300
- // xvfb-run -a chooses the display number, so discover it from the socket rather than
301
- // assuming :99. There is exactly one X server in the container.
302
- const script =
303
- 'D=":$(ls /tmp/.X11-unix 2>/dev/null | head -1 | tr -d X)";' +
304
- ' [ "$D" = ":" ] && { echo "no X socket in the container" >&2; exit 1; };' +
305
- ` import -display "$D" -window root ${target} 2>/dev/null` +
306
- ` || xwd -root -display "$D" | magick xwd:- ${target}`
307
-
308
- const code = await inherit(['docker', 'exec', container, 'sh', '-c', script])
309
- const host = join(plan.runDirHost, name)
310
- if (code !== 0 || !existsSync(host)) return null
311
- return host
312
- }
313
-
314
- export async function writeLaunchRecord(plan: LaunchPlan, image: string): Promise<void> {
315
- const digest = await imageDigest(image)
316
- const line = JSON.stringify({
317
- at: new Date().toISOString(),
318
- game: plan.game,
319
- profile: plan.profile,
320
- ...(plan.instance === undefined ? {} : { instance: plan.instance }),
321
- image,
322
- digest,
323
- mode: plan.mode,
324
- mods: plan.mods.map((m) => ({
325
- packageId: m.packageId,
326
- hostDir: m.hostDir,
327
- ...(m.worktree === undefined ? {} : { worktree: m.worktree }),
328
- })),
329
- })
330
- const path = join(plan.instanceDir, '.gamecrate', 'launches.jsonl')
331
- await writeFile(path, `${line}\n`, { flag: 'a' })
332
- }