@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.
Files changed (51) hide show
  1. package/dist/gamecrate.js +246 -246
  2. package/dist/lib.js +296 -0
  3. package/dist/types/cli/args.d.ts +40 -0
  4. package/dist/types/cli/help.d.ts +7 -0
  5. package/dist/types/cli/output.d.ts +56 -0
  6. package/dist/types/config/builtin.d.ts +3 -0
  7. package/dist/types/config/jsonc.d.ts +5 -0
  8. package/dist/types/config/load.d.ts +45 -0
  9. package/dist/types/config/validate.d.ts +12 -0
  10. package/dist/types/docker/identity.d.ts +6 -0
  11. package/dist/types/docker/preflight.d.ts +3 -0
  12. package/dist/types/docker/run.d.ts +37 -0
  13. package/dist/types/docker/spec.d.ts +24 -0
  14. package/dist/types/docker/window.d.ts +21 -0
  15. package/dist/types/index.d.ts +2 -0
  16. package/dist/types/launch/generate.d.ts +8 -0
  17. package/dist/types/launch/instance.d.ts +20 -0
  18. package/dist/types/launch/prepare.d.ts +44 -0
  19. package/dist/types/launch/resolve.d.ts +17 -0
  20. package/dist/types/launch/stage.d.ts +13 -0
  21. package/dist/types/lib.d.ts +3 -0
  22. package/dist/types/mods/modindex.d.ts +29 -0
  23. package/dist/types/mods/staleness.d.ts +28 -0
  24. package/dist/types/mods/worktree.d.ts +18 -0
  25. package/dist/types/plugin.d.ts +35 -0
  26. package/dist/types/types.d.ts +394 -0
  27. package/package.json +15 -10
  28. package/src/cli/args.ts +0 -592
  29. package/src/cli/help.ts +0 -193
  30. package/src/cli/output.ts +0 -246
  31. package/src/config/builtin.ts +0 -19
  32. package/src/config/jsonc.ts +0 -21
  33. package/src/config/load.ts +0 -387
  34. package/src/config/validate.ts +0 -0
  35. package/src/docker/identity.ts +0 -25
  36. package/src/docker/preflight.ts +0 -243
  37. package/src/docker/run.ts +0 -212
  38. package/src/docker/spec.ts +0 -357
  39. package/src/docker/window.ts +0 -152
  40. package/src/index.ts +0 -875
  41. package/src/launch/generate.ts +0 -151
  42. package/src/launch/instance.ts +0 -106
  43. package/src/launch/prepare.ts +0 -332
  44. package/src/launch/resolve.ts +0 -383
  45. package/src/launch/stage.ts +0 -97
  46. package/src/lib.ts +0 -22
  47. package/src/mods/modindex.ts +0 -539
  48. package/src/mods/staleness.ts +0 -125
  49. package/src/mods/worktree.ts +0 -107
  50. package/src/plugin.ts +0 -152
  51. package/src/types.ts +0 -423
@@ -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
- }
@@ -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
- }