@gamecrate/cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,383 @@
1
+ import { join } from 'node:path'
2
+
3
+ import { canonicalProfile, globToRegExp, profileDataDir, resolveProfile, resolveSettings } from '../config/load'
4
+ import { buildIndex, resolveModRef, applyWorktreeRequests, applySourceOverrides } from '../mods/modindex'
5
+ import { decideStale, scanBuildTimes, staleReport } from '../mods/staleness'
6
+ import { GamecrateError, Exit, NAME_PATTERN, own } from '../types'
7
+ import { requirePlugin } from '../plugin'
8
+ import type { GamePlugin } from '../plugin'
9
+ import { resolveInstance } from './instance'
10
+ import type {
11
+ DynamicModEntry,
12
+ GameConfig,
13
+ LaunchPlan,
14
+ ModEntry,
15
+ ModIndex,
16
+ ModRecord,
17
+ ModeName,
18
+ ParsedArgs,
19
+ Problem,
20
+ ProfileConfig,
21
+ ResolvedMod,
22
+ RootConfig,
23
+ } from '../types'
24
+
25
+ export interface ResolveOptions {
26
+ game: string
27
+ profile: string
28
+ root: RootConfig
29
+ plugins: Map<string, GamePlugin>
30
+ args?: Partial<ParsedArgs>
31
+ /** Prebuilt index; buildIndex runs when absent. */
32
+ index?: ModIndex
33
+ /** Overrides process.cwd() for ambient worktree detection; tests set it. */
34
+ cwd?: string
35
+ }
36
+
37
+ const DEFAULT_TIMEOUT_SECONDS = 420
38
+ const DEFAULT_RENDER_WAIT_SECONDS = 25
39
+
40
+ function isDynamic(entry: ModEntry): entry is DynamicModEntry {
41
+ return typeof entry !== 'string' && 'match' in entry
42
+ }
43
+
44
+ interface Staged {
45
+ record: ModRecord
46
+ explicit: boolean
47
+ }
48
+
49
+ interface Slot {
50
+ entry: ModEntry
51
+ where: string
52
+ /** Declared DLC: known to the game, not necessarily owned here. A miss is normal. */
53
+ dlc?: boolean
54
+ }
55
+
56
+ /** A ref the index understands, with library pins applied. */
57
+ function refFor(entry: string | { id: string; workshop?: number; path?: string }, game: GameConfig): string {
58
+ const object = typeof entry === 'string' ? { id: entry } : entry
59
+ if (object.path !== undefined) return `path:${object.path}`
60
+ if (object.workshop !== undefined) return `workshop:${object.workshop}`
61
+ if (object.id.includes(':')) return object.id
62
+ const pin = own(game.library, object.id) ?? own(game.library, object.id.toLowerCase())
63
+ if (pin?.path !== undefined) return `path:${pin.path}`
64
+ if (pin?.workshop !== undefined) return `workshop:${pin.workshop}`
65
+ return object.id
66
+ }
67
+
68
+ function expandDynamic(
69
+ entry: DynamicModEntry,
70
+ index: ModIndex,
71
+ where: string,
72
+ problems: Problem[],
73
+ ): string[] {
74
+ const pattern = globToRegExp(entry.match)
75
+ const matched: string[] = []
76
+ for (const records of index.byPackageId.values()) {
77
+ const record = records[0]
78
+ if (record && pattern.test(record.packageId)) matched.push(record.packageId)
79
+ }
80
+ const minMatches = entry.minMatches ?? 1
81
+ if (matched.length < minMatches) {
82
+ problems.push({
83
+ where,
84
+ message: `"${entry.match}" matched ${matched.length} mod(s), needs at least ${minMatches}`,
85
+ })
86
+ }
87
+ const firstOrder = (entry.first ?? []).map((id) => id.toLowerCase())
88
+ const head = matched
89
+ .filter((id) => firstOrder.includes(id.toLowerCase()))
90
+ .sort((a, b) => firstOrder.indexOf(a.toLowerCase()) - firstOrder.indexOf(b.toLowerCase()))
91
+ const rest = matched.filter((id) => !firstOrder.includes(id.toLowerCase()))
92
+ if ((entry.sort ?? 'alpha') === 'alpha') {
93
+ rest.sort((a, b) => (a.toLowerCase() < b.toLowerCase() ? -1 : a.toLowerCase() > b.toLowerCase() ? 1 : 0))
94
+ }
95
+ return [...head, ...rest]
96
+ }
97
+
98
+ function collectSlots(
99
+ game: GameConfig,
100
+ gameName: string,
101
+ profileName: string,
102
+ profile: ProfileConfig,
103
+ args: Partial<ParsedArgs>,
104
+ ): Slot[] {
105
+ const modless = profileName === 'modless'
106
+ const slots: Slot[] = []
107
+ const at = `/games/${gameName}`
108
+ if (!modless) for (const [i, id] of (game.preCore ?? []).entries()) slots.push({ entry: id, where: `${at}/preCore/${i}` })
109
+ slots.push({ entry: game.core, where: `${at}/core` })
110
+ for (const [i, id] of game.dlc.entries()) slots.push({ entry: id, where: `${at}/dlc/${i}`, dlc: true })
111
+ if (!modless && profile.includeBase !== false) {
112
+ for (const [i, id] of (game.base ?? []).entries()) slots.push({ entry: id, where: `${at}/base/${i}` })
113
+ }
114
+ const only = args.only ?? []
115
+ const declared: ModEntry[] = only.length > 0 ? only : (profile.mods ?? [])
116
+ const source = only.length > 0 ? 'flag --only' : `${at}/profiles/${profileName}/mods`
117
+ for (const [i, entry] of declared.entries()) slots.push({ entry, where: `${source}/${i}` })
118
+ for (const [i, id] of (args.mods ?? []).entries()) slots.push({ entry: id, where: `flag --mod/${i}` })
119
+ return slots
120
+ }
121
+
122
+ function insertDependencies(
123
+ list: Staged[],
124
+ present: Set<string>,
125
+ index: ModIndex,
126
+ game: GameConfig,
127
+ problems: Problem[],
128
+ ): void {
129
+ let i = 0
130
+ outer: while (i < list.length) {
131
+ const mod = list[i]!
132
+ for (const dep of mod.record.manifest.modDependencies) {
133
+ const key = dep.packageId.toLowerCase()
134
+ if (present.has(key)) continue
135
+ present.add(key)
136
+ const record = resolveModRef(index, dep.packageId, game)
137
+ if (!record) {
138
+ problems.push({
139
+ where: mod.record.packageId,
140
+ message: `declares a dependency on ${dep.packageId}, which is not installed`,
141
+ suggestion: dep.steamWorkshopUrl,
142
+ })
143
+ continue
144
+ }
145
+ present.add(record.packageId.toLowerCase())
146
+ list.splice(i, 0, { record, explicit: false })
147
+ continue outer
148
+ }
149
+ i++
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Kahn's algorithm over loadAfter/loadBefore/forceLoad*, profile order as tiebreak. Anything
155
+ * that must precede core is emitted first: a patching runtime that loads after an ordinary
156
+ * mod has already missed its window, and the symptom is a black screen, not an error.
157
+ */
158
+ function topoSort(list: Staged[], problems: Problem[], core?: string): Staged[] {
159
+ const position = new Map<string, number>()
160
+ for (const [i, mod] of list.entries()) position.set(mod.record.packageId.toLowerCase(), i)
161
+
162
+ const edges = new Set<string>()
163
+ const addEdge = (from: number | undefined, to: number | undefined): void => {
164
+ if (from === undefined || to === undefined || from === to) return
165
+ edges.add(`${from}>${to}`)
166
+ }
167
+ for (const [i, mod] of list.entries()) {
168
+ const { loadAfter, loadBefore, forceLoadAfter, forceLoadBefore } = mod.record.manifest
169
+ for (const id of [...loadAfter, ...forceLoadAfter]) addEdge(position.get(id.toLowerCase()), i)
170
+ for (const id of [...loadBefore, ...forceLoadBefore]) addEdge(i, position.get(id.toLowerCase()))
171
+ }
172
+
173
+ const indegree = list.map(() => 0)
174
+ const outgoing = list.map((): number[] => [])
175
+ // `incoming` is how the pre-core walk below follows the edges backwards.
176
+ const incoming = list.map((): number[] => [])
177
+ for (const edge of edges) {
178
+ const [from, to] = edge.split('>').map(Number) as [number, number]
179
+ outgoing[from]!.push(to)
180
+ incoming[to]!.push(from)
181
+ indegree[to]! += 1
182
+ }
183
+ const preCore = new Set<number>()
184
+ const coreIndex = core === undefined ? undefined : position.get(core.toLowerCase())
185
+ if (coreIndex !== undefined) {
186
+ const queue = [coreIndex]
187
+ while (queue.length > 0) {
188
+ for (const from of incoming[queue.pop()!]!) {
189
+ if (preCore.has(from)) continue
190
+ preCore.add(from)
191
+ queue.push(from)
192
+ }
193
+ }
194
+ }
195
+ const phase = (i: number): number => (preCore.has(i) ? 0 : 1)
196
+
197
+ const sorted: Staged[] = []
198
+ const done = list.map(() => false)
199
+ for (;;) {
200
+ let next = -1
201
+ for (let i = 0; i < list.length; i++) {
202
+ if (done[i] || indegree[i] !== 0) continue
203
+ if (next === -1 || phase(i) < phase(next)) next = i
204
+ }
205
+ if (next === -1) break
206
+ done[next] = true
207
+ sorted.push(list[next]!)
208
+ for (const to of outgoing[next]!) indegree[to]! -= 1
209
+ }
210
+
211
+ const cycle = list.filter((_, i) => !done[i])
212
+ if (cycle.length > 0) {
213
+ problems.push({
214
+ where: 'flag --sort topo',
215
+ message: `load-order cycle among ${cycle.map((m) => m.record.packageId).join(', ')}; left in profile order`,
216
+ })
217
+ sorted.push(...cycle)
218
+ }
219
+ return sorted
220
+ }
221
+
222
+ function incompatibilityWarnings(list: Staged[]): string[] {
223
+ const byId = new Map(list.map((mod) => [mod.record.packageId.toLowerCase(), mod.record.packageId]))
224
+ const seen = new Set<string>()
225
+ const warnings: string[] = []
226
+ for (const mod of list) {
227
+ for (const id of mod.record.manifest.incompatibleWith) {
228
+ const other = byId.get(id.toLowerCase())
229
+ if (!other) continue
230
+ const pair = [mod.record.packageId, other].map((s) => s.toLowerCase()).sort().join('|')
231
+ if (seen.has(pair)) continue
232
+ seen.add(pair)
233
+ warnings.push(`${mod.record.packageId} declares it is incompatible with ${other}; both are active`)
234
+ }
235
+ }
236
+ return warnings
237
+ }
238
+
239
+ export async function resolvePlan(
240
+ options: ResolveOptions,
241
+ ): Promise<{ plan: LaunchPlan; problems: Problem[] }> {
242
+ const { game: gameName, profile: requestedProfile, root } = options
243
+ const args = options.args ?? {}
244
+ const problems: Problem[] = []
245
+ const warnings: string[] = []
246
+
247
+ const game = own(root.games, gameName)
248
+ if (!game) {
249
+ throw new GamecrateError(`unknown game "${gameName}"`, Exit.Config, `known: ${Object.keys(root.games).join(', ')}`)
250
+ }
251
+ if (!NAME_PATTERN.test(requestedProfile)) {
252
+ throw new GamecrateError(`invalid profile name "${requestedProfile}"`, Exit.Usage)
253
+ }
254
+ // An alias resolves to its profile's own name, so both spellings share one data dir.
255
+ const profileName = canonicalProfile(game, requestedProfile)
256
+
257
+ const profile: ProfileConfig =
258
+ profileName === 'modless' ? { mods: [], includeBase: false } : resolveProfile(game, profileName)
259
+
260
+ const profileDir = profileDataDir(root, gameName, profileName)
261
+ const instance = resolveInstance({
262
+ profileDir,
263
+ profile,
264
+ args,
265
+ ...(options.cwd === undefined ? {} : { cwd: options.cwd }),
266
+ })
267
+ problems.push(...instance.problems)
268
+
269
+ const settings = resolveSettings(root, game, profile, instance.settings, args.resolution)
270
+ if (args.network !== undefined) settings.network = args.network
271
+ if (args.gameArgs?.length) settings.gameArgs = [...(settings.gameArgs ?? []), ...args.gameArgs]
272
+ if (args.dockerArgs?.length) settings.dockerArgs = [...(settings.dockerArgs ?? []), ...args.dockerArgs]
273
+
274
+ const plugin = requirePlugin(options.plugins, gameName)
275
+ const index = options.index ?? (await buildIndex(gameName, game, plugin))
276
+ await applyWorktreeRequests(index, instance.requests, game)
277
+ problems.push(...(await applySourceOverrides(index, args.use ?? [], game)))
278
+
279
+ const excluded = [...(profile.exclude ?? []), ...(args.without ?? [])].map(globToRegExp)
280
+ const isExcluded = (id: string): boolean => excluded.some((pattern) => pattern.test(id))
281
+
282
+ const staged: Staged[] = []
283
+ const present = new Set<string>()
284
+ for (const slot of collectSlots(game, gameName, profileName, profile, args)) {
285
+ const entry = slot.entry
286
+ const refs: { ref: string; optional: boolean }[] = isDynamic(entry)
287
+ ? expandDynamic(entry, index, slot.where, problems).map((id) => ({ ref: id, optional: false }))
288
+ : [{ ref: refFor(entry, game), optional: typeof entry !== 'string' && entry.optional === true }]
289
+
290
+ for (const { ref, optional } of refs) {
291
+ const record = resolveModRef(index, ref, game)
292
+ if (!record) {
293
+ // A declared DLC is what the game can have, not what this machine owns.
294
+ if (slot.dlc === true) continue
295
+ if (optional) warnings.push(`optional mod ${ref} is not installed; skipped`)
296
+ else problems.push({ where: slot.where, message: `no mod matches "${ref}"` })
297
+ continue
298
+ }
299
+ const key = record.packageId.toLowerCase()
300
+ if (present.has(key) || isExcluded(record.packageId)) continue
301
+ present.add(key)
302
+ staged.push({ record, explicit: true })
303
+ }
304
+ }
305
+
306
+ if (profile.autoDependencies === true) insertDependencies(staged, present, index, game, problems)
307
+
308
+ const ordered = args.sort === 'none' ? staged : topoSort(staged, problems, game.core)
309
+ warnings.push(...incompatibilityWarnings(ordered))
310
+
311
+ const mods: ResolvedMod[] = []
312
+ for (const { record, explicit } of ordered) {
313
+ // Every local mod, not just a worktree: the primary checkout goes stale exactly as easily.
314
+ const times = record.kind === 'local' ? await scanBuildTimes(record.dir) : null
315
+ const report = times === null ? null : staleReport(times)
316
+ if (record.worktree) {
317
+ warnings.push(
318
+ `${record.packageId} comes from worktree ${record.worktree.branch} (${record.worktree.source}): ${record.dir}`,
319
+ )
320
+ }
321
+ const shadowed = (index.byPackageId.get(record.packageId.toLowerCase()) ?? [])
322
+ .filter((other) => other.dir !== record.dir)
323
+ .map((other) => other.dir)
324
+ mods.push({
325
+ packageId: record.packageId,
326
+ hostDir: record.dir,
327
+ containerDir: `${game.modsDir.container}/${record.packageId}`,
328
+ kind: record.kind,
329
+ ...(record.workshopId === undefined ? {} : { workshopId: record.workshopId }),
330
+ explicit,
331
+ stale: times === null ? false : decideStale(times),
332
+ ...(report === null ? {} : { staleReport: report }),
333
+ ...(record.worktree === undefined
334
+ ? {}
335
+ : { worktree: { ...record.worktree, selected: record.selectedWorktree !== undefined } }),
336
+ ...(shadowed.length === 0 ? {} : { shadowed }),
337
+ })
338
+ }
339
+ problems.push(...index.problems)
340
+
341
+ if (game.dataDir.mode === 'arg' && game.dataDir.arg.split('=').length !== 2) {
342
+ problems.push({
343
+ where: `/games/${gameName}/dataDir/arg`,
344
+ message: `"${game.dataDir.arg}" must contain exactly one "="; RimWorld silently ignores anything else`,
345
+ })
346
+ }
347
+ if (game.dataDir.container.includes('=')) {
348
+ problems.push({
349
+ where: `/games/${gameName}/dataDir/container`,
350
+ message: `container data path "${game.dataDir.container}" contains "=", which disables the override silently`,
351
+ })
352
+ }
353
+
354
+ const mode: ModeName = args.mode ?? 'headed'
355
+ if (!game.modes.includes(mode)) {
356
+ problems.push({ where: 'flag --mode', message: `${gameName} does not support mode "${mode}"` })
357
+ }
358
+
359
+ const plan: LaunchPlan = {
360
+ game: gameName,
361
+ gameConfig: game,
362
+ plugin,
363
+ profile: profileName,
364
+ settings,
365
+ mods,
366
+ profileDir,
367
+ ...(instance.name === undefined ? {} : { instance: instance.name }),
368
+ instanceDir: instance.dir,
369
+ dataDirHost: join(instance.dir, 'game'),
370
+ configDirHost: join(profileDir, 'config'),
371
+ stageDirHost: join(instance.dir, '.stage'),
372
+ logsDirHost: join(instance.dir, 'logs'),
373
+ // Replaced with the real logs/runs/<ts> directory once a run actually opens one.
374
+ runDirHost: join(instance.dir, 'logs'),
375
+ mode,
376
+ ...(args.marker === undefined ? {} : { marker: args.marker }),
377
+ timeoutSeconds: args.timeout ?? DEFAULT_TIMEOUT_SECONDS,
378
+ renderWaitSeconds: args.renderWait ?? DEFAULT_RENDER_WAIT_SECONDS,
379
+ warnOnStale: args.noStaleCheck !== true,
380
+ warnings,
381
+ }
382
+ return { plan, problems }
383
+ }
@@ -0,0 +1,97 @@
1
+ import { lstat, mkdir, readdir, realpath, rm, stat } from 'node:fs/promises'
2
+ import { basename, join } from 'node:path'
3
+
4
+ import { GamecrateError, Exit } from '../types'
5
+ import type { LaunchPlan, Mount } from '../types'
6
+
7
+ /**
8
+ * Wipes and rebuilds the staging tree, returning one read-only bind per mod.
9
+ * Never creates a symlink: a host symlink into a mod checkout dangles inside the container.
10
+ */
11
+ export async function stageMods(plan: LaunchPlan): Promise<Mount[]> {
12
+ await rm(plan.stageDirHost, { recursive: true, force: true })
13
+ await mkdir(plan.stageDirHost, { recursive: true })
14
+
15
+ const mounts: Mount[] = []
16
+ for (const mod of plan.mods) {
17
+ // Core and the official expansions already live inside the game-files mount.
18
+ if (mod.kind === 'core' || mod.kind === 'official') continue
19
+ let source: string
20
+ try {
21
+ source = await realpath(mod.hostDir)
22
+ } catch {
23
+ throw new GamecrateError(
24
+ `mod directory for ${mod.packageId} is missing`,
25
+ Exit.Environment,
26
+ mod.hostDir,
27
+ )
28
+ }
29
+ if (!(await stat(source)).isDirectory()) {
30
+ throw new GamecrateError(`${mod.packageId} does not resolve to a directory`, Exit.Environment, source)
31
+ }
32
+ await mkdir(join(plan.stageDirHost, basename(mod.containerDir)), { recursive: true })
33
+ mounts.push({ type: 'bind', source, target: mod.containerDir, readonly: true })
34
+ }
35
+ return mounts
36
+ }
37
+
38
+ /** Pre-creates the profile skeleton as the caller, before docker can create it as root. */
39
+ export async function ensureProfileTree(plan: LaunchPlan): Promise<void> {
40
+ for (const dir of [
41
+ plan.profileDir,
42
+ plan.instanceDir,
43
+ plan.dataDirHost,
44
+ // .NET's GetFolderPath returns "" for a directory that does not exist, so an app asking
45
+ // for LocalApplicationData on an empty HOME gets nothing back. Create them, do not just
46
+ // point at them.
47
+ join(plan.configDirHost, 'config'),
48
+ join(plan.configDirHost, 'data'),
49
+ join(plan.configDirHost, 'cache'),
50
+ join(plan.logsDirHost, 'runs'),
51
+ plan.stageDirHost,
52
+ join(plan.instanceDir, '.gamecrate'),
53
+ ...engineDirs(plan),
54
+ ]) {
55
+ await mkdir(dir, { recursive: true })
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Bind-mount targets docker would otherwise create as root. A mods dir can sit inside
61
+ * the data dir, so a missing one comes back root-owned and blocks the next run.
62
+ */
63
+ function engineDirs(plan: LaunchPlan): string[] {
64
+ const { dataDir, modsDir } = plan.gameConfig
65
+ if (!modsDir.container.startsWith(`${dataDir.container}/`)) return []
66
+ return [join(plan.dataDirHost, modsDir.container.slice(dataDir.container.length + 1))]
67
+ }
68
+
69
+
70
+ /**
71
+ * Walks with lstat semantics, so a dangling symlink inside a mounted tree is reported
72
+ * rather than thrown. Stops once `limit` foreign paths are found.
73
+ */
74
+ export async function detectForeignOwnership(dir: string, uid: number, limit = 100): Promise<string[]> {
75
+ const foreign: string[] = []
76
+ const queue = [dir]
77
+ while (queue.length > 0 && foreign.length < limit) {
78
+ const current = queue.shift()!
79
+ let info
80
+ try {
81
+ info = await lstat(current)
82
+ } catch {
83
+ continue
84
+ }
85
+ if (info.uid !== uid) {
86
+ foreign.push(current)
87
+ if (foreign.length >= limit) break
88
+ }
89
+ if (!info.isDirectory()) continue
90
+ try {
91
+ for (const entry of await readdir(current)) queue.push(join(current, entry))
92
+ } catch {
93
+ continue
94
+ }
95
+ }
96
+ return foreign
97
+ }
package/src/lib.ts ADDED
@@ -0,0 +1,22 @@
1
+ export type { GamePlugin, ModsConfigInput } from './plugin'
2
+ export { PLUGIN_API_VERSION } from './plugin'
3
+ export type {
4
+ DataDirSpec,
5
+ DisplayBackend,
6
+ DynamicModEntry,
7
+ GameConfig,
8
+ GameFilesSpec,
9
+ ImageSpec,
10
+ InstanceConfig,
11
+ LibraryEntry,
12
+ LogFileSpec,
13
+ ModEntry,
14
+ ModEntryObject,
15
+ ModManifest,
16
+ ModeName,
17
+ ModsDirSpec,
18
+ NetworkPolicy,
19
+ ProfileConfig,
20
+ ScanRoot,
21
+ Settings,
22
+ } from './types'