@gamecrate/cli 0.1.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/gamecrate.js +246 -246
- package/dist/lib.js +296 -0
- package/dist/types/cli/args.d.ts +40 -0
- package/dist/types/cli/help.d.ts +7 -0
- package/dist/types/cli/output.d.ts +56 -0
- package/dist/types/config/builtin.d.ts +3 -0
- package/dist/types/config/jsonc.d.ts +5 -0
- package/dist/types/config/load.d.ts +45 -0
- package/dist/types/config/validate.d.ts +12 -0
- package/dist/types/docker/identity.d.ts +6 -0
- package/dist/types/docker/preflight.d.ts +3 -0
- package/dist/types/docker/run.d.ts +37 -0
- package/dist/types/docker/spec.d.ts +24 -0
- package/dist/types/docker/window.d.ts +21 -0
- package/dist/types/index.d.ts +2 -0
- package/dist/types/launch/generate.d.ts +8 -0
- package/dist/types/launch/instance.d.ts +20 -0
- package/dist/types/launch/prepare.d.ts +44 -0
- package/dist/types/launch/resolve.d.ts +17 -0
- package/dist/types/launch/stage.d.ts +13 -0
- package/dist/types/lib.d.ts +3 -0
- package/dist/types/mods/modindex.d.ts +29 -0
- package/dist/types/mods/staleness.d.ts +28 -0
- package/dist/types/mods/worktree.d.ts +18 -0
- package/dist/types/plugin.d.ts +35 -0
- package/dist/types/types.d.ts +394 -0
- package/package.json +15 -10
- package/src/cli/args.ts +0 -592
- package/src/cli/help.ts +0 -193
- package/src/cli/output.ts +0 -246
- package/src/config/builtin.ts +0 -19
- package/src/config/jsonc.ts +0 -21
- package/src/config/load.ts +0 -387
- package/src/config/validate.ts +0 -0
- package/src/docker/identity.ts +0 -25
- package/src/docker/preflight.ts +0 -243
- package/src/docker/run.ts +0 -212
- package/src/docker/spec.ts +0 -357
- package/src/docker/window.ts +0 -152
- package/src/index.ts +0 -875
- package/src/launch/generate.ts +0 -151
- package/src/launch/instance.ts +0 -106
- package/src/launch/prepare.ts +0 -332
- package/src/launch/resolve.ts +0 -383
- package/src/launch/stage.ts +0 -97
- package/src/lib.ts +0 -22
- package/src/mods/modindex.ts +0 -539
- package/src/mods/staleness.ts +0 -125
- package/src/mods/worktree.ts +0 -107
- package/src/plugin.ts +0 -152
- package/src/types.ts +0 -423
package/src/launch/generate.ts
DELETED
|
@@ -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
|
-
}
|
package/src/launch/instance.ts
DELETED
|
@@ -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
|
-
}
|
package/src/launch/prepare.ts
DELETED
|
@@ -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
|
-
}
|