@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/mods/modindex.ts
DELETED
|
@@ -1,539 +0,0 @@
|
|
|
1
|
-
import { existsSync, readFileSync, statSync } from 'node:fs'
|
|
2
|
-
import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises'
|
|
3
|
-
import { homedir } from 'node:os'
|
|
4
|
-
import { dirname, join, relative, resolve as resolvePath } from 'node:path'
|
|
5
|
-
|
|
6
|
-
import picomatch from 'picomatch'
|
|
7
|
-
|
|
8
|
-
import { expandHome } from '../config/load'
|
|
9
|
-
import { GamecrateError, Exit, own } from '../types'
|
|
10
|
-
import { contains } from './worktree'
|
|
11
|
-
import type { GamePlugin } from '../plugin'
|
|
12
|
-
import type {
|
|
13
|
-
GameConfig,
|
|
14
|
-
ModIndex,
|
|
15
|
-
ModManifest,
|
|
16
|
-
ModRecord,
|
|
17
|
-
ModSourceKind,
|
|
18
|
-
Problem,
|
|
19
|
-
ScanRoot,
|
|
20
|
-
WorktreeRequest,
|
|
21
|
-
} from '../types'
|
|
22
|
-
|
|
23
|
-
/** Cache lives outside the profile tree so `clean --all` can never invalidate it. */
|
|
24
|
-
function cacheDir(): string {
|
|
25
|
-
return join(process.env['XDG_CACHE_HOME'] ?? join(homedir(), '.cache'), 'gamecrate')
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* `**` spans separators, `*` and `?` do not; everything else is literal. Mod folders are
|
|
30
|
-
* routinely named `[KV] Mod Manager`, so brackets and braces must not be glob syntax.
|
|
31
|
-
*/
|
|
32
|
-
export function globMatch(pattern: string, path: string): boolean {
|
|
33
|
-
return picomatch.isMatch(path, pattern.replace(/[[\]{}()!,@+|^$.\\]/g, '\\$&'), { dot: true })
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
function excluded(patterns: string[], relativePath: string): boolean {
|
|
37
|
-
return patterns.some((p) => globMatch(p, relativePath) || globMatch(p, `${relativePath}/`))
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
// ---------------------------------------------------------------------- scan
|
|
41
|
-
|
|
42
|
-
interface Candidate {
|
|
43
|
-
dir: string
|
|
44
|
-
kind: ModSourceKind
|
|
45
|
-
rootIndex: number
|
|
46
|
-
linkedWorktree: boolean
|
|
47
|
-
workshopId?: number
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* Worktrees are opt-in, never ambient inventory: an abandoned agent tree must not be a
|
|
52
|
-
* candidate for a launch nobody pointed at it. The overlay scan (rootIndex -1) skips these,
|
|
53
|
-
* which is how a deliberately selected worktree still gets in.
|
|
54
|
-
*/
|
|
55
|
-
const ALWAYS_EXCLUDE = ['**/.worktrees/**', '**/.claude/worktrees/**']
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* A `.git` file rather than a directory means a linked worktree or submodule. The walk stops
|
|
59
|
-
* at the scan root because a scan root can hold many independent repos, not one.
|
|
60
|
-
*/
|
|
61
|
-
function inLinkedWorktree(dir: string, stopAt: string): boolean {
|
|
62
|
-
let current = dir
|
|
63
|
-
for (;;) {
|
|
64
|
-
const git = join(current, '.git')
|
|
65
|
-
if (existsSync(git)) {
|
|
66
|
-
try {
|
|
67
|
-
if (statSync(git).isFile()) return true
|
|
68
|
-
} catch {
|
|
69
|
-
return false
|
|
70
|
-
}
|
|
71
|
-
return false
|
|
72
|
-
}
|
|
73
|
-
if (current === stopAt) return false
|
|
74
|
-
const parent = dirname(current)
|
|
75
|
-
if (parent === current) return false
|
|
76
|
-
current = parent
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
async function scanLocalRoot(
|
|
81
|
-
root: ScanRoot,
|
|
82
|
-
rootIndex: number,
|
|
83
|
-
manifestFile: string,
|
|
84
|
-
found: Candidate[],
|
|
85
|
-
): Promise<void> {
|
|
86
|
-
const base = resolvePath(expandHome(root.path))
|
|
87
|
-
const exclude = [...(root.exclude ?? []), ...(rootIndex === -1 ? [] : ALWAYS_EXCLUDE)]
|
|
88
|
-
|
|
89
|
-
const walk = async (dir: string, depth: number): Promise<void> => {
|
|
90
|
-
const manifest = join(dir, manifestFile)
|
|
91
|
-
if (existsSync(manifest)) {
|
|
92
|
-
found.push({
|
|
93
|
-
dir,
|
|
94
|
-
kind: 'local',
|
|
95
|
-
rootIndex,
|
|
96
|
-
linkedWorktree: inLinkedWorktree(dir, base),
|
|
97
|
-
})
|
|
98
|
-
// Never descend into a mod: 93 of 325 workshop items ship per-version About files.
|
|
99
|
-
return
|
|
100
|
-
}
|
|
101
|
-
if (depth >= root.maxDepth) return
|
|
102
|
-
|
|
103
|
-
let entries
|
|
104
|
-
try {
|
|
105
|
-
entries = await readdir(dir, { withFileTypes: true })
|
|
106
|
-
} catch {
|
|
107
|
-
return
|
|
108
|
-
}
|
|
109
|
-
for (const entry of entries) {
|
|
110
|
-
if (!entry.isDirectory() || entry.name.startsWith('.git')) continue
|
|
111
|
-
const child = join(dir, entry.name)
|
|
112
|
-
if (excluded(exclude, relative(base, child))) continue
|
|
113
|
-
await walk(child, depth + 1)
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
if (!existsSync(base)) return
|
|
118
|
-
await walk(base, 0)
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
/** Depth-exact at `<root>/<numericId>/About/About.<ext>`; recursing produces phantoms. */
|
|
122
|
-
async function scanWorkshopRoot(
|
|
123
|
-
workshopRoot: string,
|
|
124
|
-
rootIndex: number,
|
|
125
|
-
manifestFile: string,
|
|
126
|
-
found: Candidate[],
|
|
127
|
-
): Promise<void> {
|
|
128
|
-
const base = resolvePath(expandHome(workshopRoot))
|
|
129
|
-
let entries
|
|
130
|
-
try {
|
|
131
|
-
entries = await readdir(base, { withFileTypes: true })
|
|
132
|
-
} catch {
|
|
133
|
-
return
|
|
134
|
-
}
|
|
135
|
-
for (const entry of entries) {
|
|
136
|
-
if (!entry.isDirectory() || !/^\d+$/.test(entry.name)) continue
|
|
137
|
-
const dir = join(base, entry.name)
|
|
138
|
-
if (!existsSync(join(dir, manifestFile))) continue
|
|
139
|
-
found.push({ dir, kind: 'workshop', rootIndex, linkedWorktree: false, workshopId: Number(entry.name) })
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
/** Core and the official expansions live inside the game install, one level under Data/. */
|
|
144
|
-
async function scanGameData(
|
|
145
|
-
game: GameConfig,
|
|
146
|
-
rootIndex: number,
|
|
147
|
-
found: Candidate[],
|
|
148
|
-
): Promise<void> {
|
|
149
|
-
const host = game.gameFiles.host
|
|
150
|
-
if (game.gameFiles.source !== 'mount' || host === undefined) return
|
|
151
|
-
const data = join(resolvePath(expandHome(host)), 'Data')
|
|
152
|
-
let entries
|
|
153
|
-
try {
|
|
154
|
-
entries = await readdir(data, { withFileTypes: true })
|
|
155
|
-
} catch {
|
|
156
|
-
return
|
|
157
|
-
}
|
|
158
|
-
for (const entry of entries) {
|
|
159
|
-
if (!entry.isDirectory()) continue
|
|
160
|
-
const dir = join(data, entry.name)
|
|
161
|
-
if (!existsSync(join(dir, game.manifest.file))) continue
|
|
162
|
-
found.push({ dir, kind: 'official', rootIndex, linkedWorktree: false })
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
// --------------------------------------------------------------------- index
|
|
167
|
-
|
|
168
|
-
const CACHE_VERSION = 2
|
|
169
|
-
|
|
170
|
-
interface CacheFile {
|
|
171
|
-
version: number
|
|
172
|
-
stamp: string
|
|
173
|
-
records: ModRecord[]
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
function workshopStamp(game: GameConfig): string | null {
|
|
177
|
-
if (game.workshopRoot === null) return null
|
|
178
|
-
const acf = join(dirname(dirname(resolvePath(expandHome(game.workshopRoot)))), `appworkshop_${game.steamAppId}.acf`)
|
|
179
|
-
try {
|
|
180
|
-
const info = statSync(acf)
|
|
181
|
-
return `${info.mtimeMs}:${info.size}`
|
|
182
|
-
} catch {
|
|
183
|
-
return null
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
async function readWorkshopCache(game: string, stamp: string): Promise<ModRecord[] | null> {
|
|
188
|
-
try {
|
|
189
|
-
const raw = JSON.parse(await readFile(join(cacheDir(), `${game}.workshop.json`), 'utf8')) as CacheFile
|
|
190
|
-
if (raw.version !== CACHE_VERSION || raw.stamp !== stamp) return null
|
|
191
|
-
return raw.records
|
|
192
|
-
} catch {
|
|
193
|
-
return null
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
async function writeWorkshopCache(game: string, stamp: string, records: ModRecord[]): Promise<void> {
|
|
198
|
-
const payload: CacheFile = { version: CACHE_VERSION, stamp, records }
|
|
199
|
-
try {
|
|
200
|
-
await mkdir(cacheDir(), { recursive: true })
|
|
201
|
-
await writeFile(join(cacheDir(), `${game}.workshop.json`), JSON.stringify(payload))
|
|
202
|
-
} catch {
|
|
203
|
-
// A cache that cannot be written is a slower launch, never a failed one.
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
function toRecord(candidate: Candidate, manifest: ModManifest, game: GameConfig): ModRecord {
|
|
208
|
-
const kind: ModSourceKind =
|
|
209
|
-
manifest.packageId.toLowerCase() === game.core.toLowerCase() ? 'core' : candidate.kind
|
|
210
|
-
const record: ModRecord = {
|
|
211
|
-
packageId: manifest.packageId,
|
|
212
|
-
dir: candidate.dir,
|
|
213
|
-
kind,
|
|
214
|
-
manifest,
|
|
215
|
-
linkedWorktree: candidate.linkedWorktree,
|
|
216
|
-
rootIndex: candidate.rootIndex,
|
|
217
|
-
}
|
|
218
|
-
if (candidate.workshopId !== undefined) record.workshopId = candidate.workshopId
|
|
219
|
-
return record
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
/** Selection first: naming a directory is the strongest statement of intent available. */
|
|
223
|
-
function tier(r: ModRecord): number[] {
|
|
224
|
-
return [
|
|
225
|
-
r.overridden ?? Number.MAX_SAFE_INTEGER,
|
|
226
|
-
r.selectedWorktree ?? Number.MAX_SAFE_INTEGER,
|
|
227
|
-
r.kind === 'workshop' ? 1 : 0,
|
|
228
|
-
r.linkedWorktree ? 1 : 0,
|
|
229
|
-
r.rootIndex,
|
|
230
|
-
]
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
function compareTiers(a: ModRecord, b: ModRecord): number {
|
|
234
|
-
const x = tier(a)
|
|
235
|
-
const y = tier(b)
|
|
236
|
-
for (let i = 0; i < x.length; i += 1) {
|
|
237
|
-
if (x[i] !== y[i]) return x[i]! - y[i]!
|
|
238
|
-
}
|
|
239
|
-
return 0
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
/** Selection, then non-workshop, then non-worktree, then scan-root order. */
|
|
243
|
-
function rank(a: ModRecord, b: ModRecord): number {
|
|
244
|
-
return compareTiers(a, b) || (a.dir < b.dir ? -1 : a.dir > b.dir ? 1 : 0)
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
/**
|
|
248
|
-
* Stamps every record inside a requested worktree, then scans the worktree itself so a tree
|
|
249
|
-
* no scanRoot reaches still contributes. Only a caller who stood in, typed, or exported a
|
|
250
|
-
* directory can produce `selectedWorktree`.
|
|
251
|
-
*/
|
|
252
|
-
export async function applyWorktreeRequests(
|
|
253
|
-
index: ModIndex,
|
|
254
|
-
requests: WorktreeRequest[],
|
|
255
|
-
config: GameConfig,
|
|
256
|
-
): Promise<void> {
|
|
257
|
-
if (requests.length === 0) return
|
|
258
|
-
|
|
259
|
-
const known = new Set<string>()
|
|
260
|
-
for (const bucket of index.byPackageId.values()) {
|
|
261
|
-
for (const record of bucket) {
|
|
262
|
-
known.add(record.dir)
|
|
263
|
-
for (const request of requests) {
|
|
264
|
-
if (!contains(request, record.dir)) continue
|
|
265
|
-
record.worktree = { root: request.root, branch: request.branch, source: request.source }
|
|
266
|
-
record.selectedWorktree = request.order
|
|
267
|
-
break
|
|
268
|
-
}
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
// Overlay scan: coverage by scanRoot is incidental, so never depend on it.
|
|
273
|
-
for (const request of requests) {
|
|
274
|
-
const found: Candidate[] = []
|
|
275
|
-
await scanLocalRoot(
|
|
276
|
-
{ path: request.root, maxDepth: WORKTREE_SCAN_DEPTH, exclude: WORKTREE_EXCLUDE },
|
|
277
|
-
-1,
|
|
278
|
-
config.manifest.file,
|
|
279
|
-
found,
|
|
280
|
-
)
|
|
281
|
-
const fresh = found.filter((c) => !known.has(c.dir))
|
|
282
|
-
if (fresh.length === 0) continue
|
|
283
|
-
for (const record of await parseAll(fresh, config, index.plugin, index.problems)) {
|
|
284
|
-
record.worktree = { root: request.root, branch: request.branch, source: request.source }
|
|
285
|
-
record.selectedWorktree = request.order
|
|
286
|
-
insert(index, record)
|
|
287
|
-
}
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
for (const bucket of index.byPackageId.values()) bucket.sort(rank)
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
/** Deep enough for a repo-shaped worktree without walking a whole build tree. */
|
|
294
|
-
const WORKTREE_SCAN_DEPTH = 5
|
|
295
|
-
const WORKTREE_EXCLUDE = ['**/.retired/**', '**/node_modules/**', '**/bin/**', '**/obj/**']
|
|
296
|
-
|
|
297
|
-
/**
|
|
298
|
-
* Forces one packageId to come from a named directory, whatever the profile pinned. This is
|
|
299
|
-
* the only override that reaches a mod already in preCore/core/dlc/base, because slot
|
|
300
|
-
* expansion is first-wins and a later `--mod` entry for a present id is dropped.
|
|
301
|
-
*/
|
|
302
|
-
export async function applySourceOverrides(
|
|
303
|
-
index: ModIndex,
|
|
304
|
-
overrides: string[],
|
|
305
|
-
config: GameConfig,
|
|
306
|
-
): Promise<Problem[]> {
|
|
307
|
-
const problems: Problem[] = []
|
|
308
|
-
if (overrides.length === 0) return problems
|
|
309
|
-
|
|
310
|
-
for (const [order, spec] of overrides.entries()) {
|
|
311
|
-
const eq = spec.indexOf('=')
|
|
312
|
-
if (eq < 1) {
|
|
313
|
-
problems.push({ where: spec, message: '--use takes <packageId>=<path>' })
|
|
314
|
-
continue
|
|
315
|
-
}
|
|
316
|
-
const wanted = spec.slice(0, eq)
|
|
317
|
-
const dir = resolvePath(expandHome(spec.slice(eq + 1)))
|
|
318
|
-
|
|
319
|
-
const file = join(dir, config.manifest.file)
|
|
320
|
-
if (!existsSync(file)) {
|
|
321
|
-
problems.push({
|
|
322
|
-
where: spec,
|
|
323
|
-
message: `no ${config.manifest.file} under ${dir}`,
|
|
324
|
-
suggestion: 'point --use at the mod directory, not the repo root',
|
|
325
|
-
})
|
|
326
|
-
continue
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
let manifest: ModManifest | null
|
|
330
|
-
try {
|
|
331
|
-
manifest = index.plugin.parseManifest(readFileSync(file, 'utf8'))
|
|
332
|
-
} catch (error) {
|
|
333
|
-
problems.push({ where: file, message: `could not parse: ${String(error)}` })
|
|
334
|
-
continue
|
|
335
|
-
}
|
|
336
|
-
if (manifest === null) {
|
|
337
|
-
problems.push({ where: file, message: `${file} declares no packageId` })
|
|
338
|
-
continue
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
if (manifest.packageId.toLowerCase() !== wanted.toLowerCase()) {
|
|
342
|
-
problems.push({
|
|
343
|
-
where: spec,
|
|
344
|
-
message: `${dir} declares ${manifest.packageId}, not ${wanted}`,
|
|
345
|
-
suggestion: `use --use ${manifest.packageId}=${dir}`,
|
|
346
|
-
})
|
|
347
|
-
continue
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
const key = manifest.packageId.toLowerCase()
|
|
351
|
-
const existing = (index.byPackageId.get(key) ?? []).find((r) => r.dir === dir)
|
|
352
|
-
if (existing) {
|
|
353
|
-
existing.overridden = order
|
|
354
|
-
} else {
|
|
355
|
-
const record = toRecord({ dir, kind: 'local', rootIndex: -1, linkedWorktree: false }, manifest, config)
|
|
356
|
-
record.overridden = order
|
|
357
|
-
insert(index, record)
|
|
358
|
-
}
|
|
359
|
-
index.byPackageId.get(key)?.sort(rank)
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
return problems
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
function insert(index: ModIndex, record: ModRecord): void {
|
|
366
|
-
const key = record.packageId.toLowerCase()
|
|
367
|
-
const bucket = index.byPackageId.get(key)
|
|
368
|
-
if (bucket) bucket.push(record)
|
|
369
|
-
else index.byPackageId.set(key, [record])
|
|
370
|
-
|
|
371
|
-
if (record.workshopId !== undefined && !index.byWorkshopId.has(record.workshopId)) {
|
|
372
|
-
index.byWorkshopId.set(record.workshopId, record)
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
const short = key.split('.').pop()
|
|
376
|
-
if (short !== undefined && short.length > 0) {
|
|
377
|
-
const ids = index.byShortName.get(short)
|
|
378
|
-
if (ids) {
|
|
379
|
-
if (!ids.includes(record.packageId)) ids.push(record.packageId)
|
|
380
|
-
} else {
|
|
381
|
-
index.byShortName.set(short, [record.packageId])
|
|
382
|
-
}
|
|
383
|
-
}
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
/**
|
|
387
|
-
* Scans the game install, then every scan root in declaration order, then the workshop root.
|
|
388
|
-
* Local roots rescan every launch; only the workshop scan is cached, against the acf stamp.
|
|
389
|
-
*/
|
|
390
|
-
export async function buildIndex(game: string, config: GameConfig, plugin: GamePlugin): Promise<ModIndex> {
|
|
391
|
-
const index: ModIndex = {
|
|
392
|
-
game,
|
|
393
|
-
plugin,
|
|
394
|
-
byPackageId: new Map(),
|
|
395
|
-
byWorkshopId: new Map(),
|
|
396
|
-
byShortName: new Map(),
|
|
397
|
-
problems: [],
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
const local: Candidate[] = []
|
|
401
|
-
await scanGameData(config, -1, local)
|
|
402
|
-
for (const [i, root] of config.scanRoots.entries()) {
|
|
403
|
-
await scanLocalRoot(root, i, config.manifest.file, local)
|
|
404
|
-
}
|
|
405
|
-
for (const record of await parseAll(local, config, plugin, index.problems)) insert(index, record)
|
|
406
|
-
|
|
407
|
-
if (config.workshopRoot !== null) {
|
|
408
|
-
const stamp = workshopStamp(config)
|
|
409
|
-
const cached = stamp === null ? null : await readWorkshopCache(game, stamp)
|
|
410
|
-
if (cached) {
|
|
411
|
-
for (const record of cached) insert(index, record)
|
|
412
|
-
} else {
|
|
413
|
-
const items: Candidate[] = []
|
|
414
|
-
await scanWorkshopRoot(config.workshopRoot, config.scanRoots.length, config.manifest.file, items)
|
|
415
|
-
const records = await parseAll(items, config, plugin, index.problems)
|
|
416
|
-
for (const record of records) insert(index, record)
|
|
417
|
-
if (stamp !== null) await writeWorkshopCache(game, stamp, records)
|
|
418
|
-
}
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
for (const bucket of index.byPackageId.values()) bucket.sort(rank)
|
|
422
|
-
return index
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
async function parseAll(
|
|
426
|
-
candidates: Candidate[],
|
|
427
|
-
config: GameConfig,
|
|
428
|
-
plugin: GamePlugin,
|
|
429
|
-
problems: Problem[],
|
|
430
|
-
): Promise<ModRecord[]> {
|
|
431
|
-
const records = await Promise.all(
|
|
432
|
-
candidates.map(async (candidate): Promise<ModRecord | null> => {
|
|
433
|
-
const file = join(candidate.dir, config.manifest.file)
|
|
434
|
-
try {
|
|
435
|
-
const manifest = plugin.parseManifest(await readFile(file, 'utf8'))
|
|
436
|
-
// A scan walks into plenty of directories that were never mods. null is the plugin
|
|
437
|
-
// saying so; a thrown error means the file is broken and worth reporting.
|
|
438
|
-
return manifest === null ? null : toRecord(candidate, manifest, config)
|
|
439
|
-
} catch (error) {
|
|
440
|
-
problems.push({
|
|
441
|
-
where: file,
|
|
442
|
-
message: error instanceof Error ? error.message : String(error),
|
|
443
|
-
})
|
|
444
|
-
return null
|
|
445
|
-
}
|
|
446
|
-
}),
|
|
447
|
-
)
|
|
448
|
-
return records.filter((r): r is ModRecord => r !== null)
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
// ------------------------------------------------------------------- resolve
|
|
452
|
-
|
|
453
|
-
/**
|
|
454
|
-
* `path:` and `workshop:` are explicit; a bare string resolves as exact packageId, then the
|
|
455
|
-
* game's alias map, then a CLI-only short name. Ambiguity that the ladder cannot break is fatal.
|
|
456
|
-
*/
|
|
457
|
-
export function resolveModRef(index: ModIndex, ref: string, game: GameConfig): ModRecord | null {
|
|
458
|
-
return honorOverride(index, resolveRaw(index, ref, game))
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
/**
|
|
462
|
-
* `--use` has to beat a `library` pin, and a pin resolves through byWorkshopId without ever
|
|
463
|
-
* touching the ladder. So the override is applied after the ref resolves, by packageId.
|
|
464
|
-
*/
|
|
465
|
-
function honorOverride(index: ModIndex, record: ModRecord | null): ModRecord | null {
|
|
466
|
-
if (record === null || record.overridden !== undefined) return record
|
|
467
|
-
const best = index.byPackageId.get(record.packageId.toLowerCase())?.[0]
|
|
468
|
-
return best?.overridden !== undefined ? best : record
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
function resolveRaw(index: ModIndex, ref: string, game: GameConfig): ModRecord | null {
|
|
472
|
-
if (ref.startsWith('path:')) return byPath(index, ref.slice(5), game)
|
|
473
|
-
if (ref.startsWith('workshop:')) {
|
|
474
|
-
const id = Number(ref.slice(9))
|
|
475
|
-
return Number.isInteger(id) ? index.byWorkshopId.get(id) ?? null : null
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
const direct = pick(index, ref.toLowerCase(), ref)
|
|
479
|
-
if (direct) return direct
|
|
480
|
-
|
|
481
|
-
const alias = own(game.aliases, ref) ?? own(game.aliases, ref.toLowerCase())
|
|
482
|
-
if (alias !== undefined && alias.toLowerCase() !== ref.toLowerCase()) {
|
|
483
|
-
return resolveRaw(index, alias, game)
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
const short = index.byShortName.get(ref.toLowerCase())
|
|
487
|
-
if (short && short.length === 1) return pick(index, short[0]!.toLowerCase(), ref)
|
|
488
|
-
if (short && short.length > 1) {
|
|
489
|
-
throw new GamecrateError(
|
|
490
|
-
`"${ref}" is a short name for ${short.length} mods`,
|
|
491
|
-
Exit.Resolution,
|
|
492
|
-
short.join(', '),
|
|
493
|
-
)
|
|
494
|
-
}
|
|
495
|
-
return null
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
function pick(index: ModIndex, key: string, ref: string): ModRecord | null {
|
|
499
|
-
const bucket = index.byPackageId.get(key)
|
|
500
|
-
const best = bucket?.[0]
|
|
501
|
-
if (!best) return null
|
|
502
|
-
const runnerUp = bucket![1]
|
|
503
|
-
if (runnerUp && compareTiers(best, runnerUp) === 0) {
|
|
504
|
-
// Two dirs inside one selected worktree is a user error. An unselected tie is a coin
|
|
505
|
-
// flip the plan already exposes via `shadowed`, so it warns rather than stopping a run.
|
|
506
|
-
if (best.selectedWorktree !== undefined || runnerUp.selectedWorktree !== undefined) {
|
|
507
|
-
throw new GamecrateError(
|
|
508
|
-
`"${ref}" is declared by ${bucket!.length} indistinguishable directories`,
|
|
509
|
-
Exit.Resolution,
|
|
510
|
-
bucket!.map((r) => r.dir).join('\n'),
|
|
511
|
-
)
|
|
512
|
-
}
|
|
513
|
-
index.problems.push({
|
|
514
|
-
where: ref,
|
|
515
|
-
message: `resolved by directory name: ${bucket!.length} candidates tie on every rule`,
|
|
516
|
-
suggestion: `using ${best.dir}`,
|
|
517
|
-
})
|
|
518
|
-
}
|
|
519
|
-
return best
|
|
520
|
-
}
|
|
521
|
-
|
|
522
|
-
/** An explicit path always wins, including one the scan never reached. */
|
|
523
|
-
function byPath(index: ModIndex, raw: string, game: GameConfig): ModRecord | null {
|
|
524
|
-
const dir = resolvePath(expandHome(raw))
|
|
525
|
-
for (const bucket of index.byPackageId.values()) {
|
|
526
|
-
const hit = bucket.find((record) => record.dir === dir || record.dir === raw)
|
|
527
|
-
if (hit) return hit
|
|
528
|
-
}
|
|
529
|
-
|
|
530
|
-
const file = join(dir, game.manifest.file)
|
|
531
|
-
if (!existsSync(file)) return null
|
|
532
|
-
try {
|
|
533
|
-
const manifest = index.plugin.parseManifest(readFileSync(file, 'utf8'))
|
|
534
|
-
if (manifest === null) return null
|
|
535
|
-
return toRecord({ dir, kind: 'local', rootIndex: -1, linkedWorktree: false }, manifest, game)
|
|
536
|
-
} catch {
|
|
537
|
-
return null
|
|
538
|
-
}
|
|
539
|
-
}
|
package/src/mods/staleness.ts
DELETED
|
@@ -1,125 +0,0 @@
|
|
|
1
|
-
import { readdir, stat } from 'node:fs/promises'
|
|
2
|
-
import { join, relative } from 'node:path'
|
|
3
|
-
|
|
4
|
-
import type { StaleReport } from '../types'
|
|
5
|
-
|
|
6
|
-
/** Directories that never hold a mod's own sources or shipped assemblies. */
|
|
7
|
-
const SKIP_DIRS = new Set(['.git', '.retired', '.vs', 'bin', 'node_modules', 'obj'])
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* A mod's Textures tree alone runs to five figures, so the cap has to clear it. A walk that
|
|
11
|
-
* stops before it reaches Source/ reports "fresh" for a mod it never looked at.
|
|
12
|
-
*/
|
|
13
|
-
const ENTRY_LIMIT = 20_000
|
|
14
|
-
|
|
15
|
-
export interface Timestamped {
|
|
16
|
-
/** Relative to the mod directory. */
|
|
17
|
-
path: string
|
|
18
|
-
mtimeMs: number
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export interface BuildTimes {
|
|
22
|
-
newestSource?: Timestamped
|
|
23
|
-
newestAssembly?: Timestamped
|
|
24
|
-
/** Every .cs mtime, so a report can count how many beat the assembly. */
|
|
25
|
-
sourceTimes: number[]
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/** One walk answers both questions: is this stale, and which files say so. */
|
|
29
|
-
export async function scanBuildTimes(dir: string): Promise<BuildTimes> {
|
|
30
|
-
const times: BuildTimes = { sourceTimes: [] }
|
|
31
|
-
let budget = ENTRY_LIMIT
|
|
32
|
-
|
|
33
|
-
const walk = async (current: string, inAssemblies: boolean): Promise<void> => {
|
|
34
|
-
let entries
|
|
35
|
-
try {
|
|
36
|
-
entries = await readdir(current, { withFileTypes: true })
|
|
37
|
-
} catch {
|
|
38
|
-
return
|
|
39
|
-
}
|
|
40
|
-
for (const entry of entries) {
|
|
41
|
-
if (budget-- <= 0) return
|
|
42
|
-
const path = join(current, entry.name)
|
|
43
|
-
if (entry.isDirectory()) {
|
|
44
|
-
if (SKIP_DIRS.has(entry.name.toLowerCase())) continue
|
|
45
|
-
// RimWorld ships per-version Assemblies dirs, so it is any depth, not just the root.
|
|
46
|
-
await walk(path, inAssemblies || entry.name === 'Assemblies')
|
|
47
|
-
continue
|
|
48
|
-
}
|
|
49
|
-
if (!entry.isFile()) continue
|
|
50
|
-
const lower = entry.name.toLowerCase()
|
|
51
|
-
const isSource = lower.endsWith('.cs')
|
|
52
|
-
const isAssembly = inAssemblies && lower.endsWith('.dll')
|
|
53
|
-
if (!isSource && !isAssembly) continue
|
|
54
|
-
|
|
55
|
-
let mtimeMs: number
|
|
56
|
-
try {
|
|
57
|
-
mtimeMs = (await stat(path)).mtimeMs
|
|
58
|
-
} catch {
|
|
59
|
-
continue
|
|
60
|
-
}
|
|
61
|
-
const found: Timestamped = { path: relative(dir, path), mtimeMs }
|
|
62
|
-
if (isSource) {
|
|
63
|
-
times.sourceTimes.push(mtimeMs)
|
|
64
|
-
if (mtimeMs > (times.newestSource?.mtimeMs ?? -1)) times.newestSource = found
|
|
65
|
-
} else if (mtimeMs > (times.newestAssembly?.mtimeMs ?? -1)) {
|
|
66
|
-
times.newestAssembly = found
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
await walk(dir, false)
|
|
72
|
-
return times
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* Drives `--build auto`, so a mod that has never been compiled counts as stale. The warning
|
|
77
|
-
* is the stricter one: see staleReport.
|
|
78
|
-
*/
|
|
79
|
-
export function decideStale(times: BuildTimes): boolean {
|
|
80
|
-
const { newestSource, newestAssembly } = times
|
|
81
|
-
if (newestSource === undefined) return false
|
|
82
|
-
return newestAssembly === undefined || newestSource.mtimeMs > newestAssembly.mtimeMs
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
/**
|
|
86
|
-
* Null when there is nothing to say: no C#, no assemblies to compare against, or the build is
|
|
87
|
-
* current. A mod may legitimately ship XML only, so this never reports on one.
|
|
88
|
-
*/
|
|
89
|
-
export function staleReport(times: BuildTimes): StaleReport | null {
|
|
90
|
-
const { newestSource, newestAssembly } = times
|
|
91
|
-
if (newestSource === undefined || newestAssembly === undefined) return null
|
|
92
|
-
if (newestSource.mtimeMs <= newestAssembly.mtimeMs) return null
|
|
93
|
-
return {
|
|
94
|
-
newestSource: newestSource.path,
|
|
95
|
-
newestSourceMs: newestSource.mtimeMs,
|
|
96
|
-
assembly: newestAssembly.path,
|
|
97
|
-
assemblyMs: newestAssembly.mtimeMs,
|
|
98
|
-
newerCount: times.sourceTimes.filter((t) => t > newestAssembly.mtimeMs).length,
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
/** Lines up the continuation under the message, past the `warning: ` that warn() adds. */
|
|
103
|
-
const INDENT = ' '.repeat('warning: '.length)
|
|
104
|
-
|
|
105
|
-
export function staleWarning(packageId: string, report: StaleReport, now = Date.now()): string {
|
|
106
|
-
const files = report.newerCount === 1 ? '1 source file' : `${report.newerCount} source files`
|
|
107
|
-
return [
|
|
108
|
-
`${packageId} has ${files} newer than ${report.assembly}`,
|
|
109
|
-
`${INDENT}newest: ${report.newestSource} (${ago(report.newestSourceMs, now)})`,
|
|
110
|
-
`${INDENT}you are probably running a stale build`,
|
|
111
|
-
].join('\n')
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
/** Coarse on purpose: "4m" is the whole signal, a duration to the second is noise. */
|
|
115
|
-
export function duration(ms: number): string {
|
|
116
|
-
const seconds = Math.max(0, Math.round(ms / 1000))
|
|
117
|
-
if (seconds < 60) return `${seconds}s`
|
|
118
|
-
if (seconds < 3600) return `${Math.floor(seconds / 60)}m`
|
|
119
|
-
if (seconds < 86_400) return `${Math.floor(seconds / 3600)}h`
|
|
120
|
-
return `${Math.floor(seconds / 86_400)}d`
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
export function ago(mtimeMs: number, now = Date.now()): string {
|
|
124
|
-
return `${duration(now - mtimeMs)} ago`
|
|
125
|
-
}
|