@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/resolve.ts
DELETED
|
@@ -1,383 +0,0 @@
|
|
|
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
|
-
}
|
package/src/launch/stage.ts
DELETED
|
@@ -1,97 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,22 +0,0 @@
|
|
|
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'
|