@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
package/src/cli/help.ts DELETED
@@ -1,193 +0,0 @@
1
- import type { GameConfig, RootConfig } from '../types'
2
- import { GamecrateError, Exit, own } from '../types'
3
- import type { Option } from 'commander'
4
- import { FLAG_ENV, GLOBAL_FLAGS, SUBCOMMANDS, buildProgram, suggest } from './args'
5
- import type { SubcommandSpec } from './args'
6
-
7
- const NAME = 'gamecrate'
8
-
9
- /** Help and completion describe whatever the parser accepts, never a second list. */
10
- function flags(): readonly Option[] {
11
- return buildProgram().options
12
- }
13
-
14
- /**
15
- * Three levels: no topic gives the top level, a subcommand name gives its usage,
16
- * a game name gives that game's profiles and modes.
17
- */
18
- export function renderHelp(topic?: string, config?: RootConfig): string {
19
- if (!topic) return topLevel(config)
20
-
21
- const sub = SUBCOMMANDS.find((s) => s.name === topic)
22
- if (sub) return subcommandHelp(sub)
23
-
24
- const game = own(config?.games, topic)
25
- if (game) return gameHelp(topic, game)
26
-
27
- const candidates = [...SUBCOMMANDS.map((s) => s.name), ...Object.keys(config?.games ?? {})]
28
- const hint = suggest(topic, candidates)
29
- throw new GamecrateError(
30
- `no help topic ${topic}`,
31
- Exit.Usage,
32
- hint ? `did you mean ${hint}?` : `topics: ${candidates.join(', ')}`,
33
- )
34
- }
35
-
36
- function topLevel(config?: RootConfig): string {
37
- const lines = [
38
- `${NAME} — run modded games in containers`,
39
- '',
40
- 'usage:',
41
- ` ${NAME} <game> [profile] [flags] [-- game args]`,
42
- ` ${NAME} <subcommand> [args] [flags]`,
43
- '',
44
- 'subcommands:',
45
- ]
46
-
47
- const verbs = SUBCOMMANDS.map((s) => [`${s.name} ${s.usage}`.trim(), s.summary] as const)
48
- lines.push(...columns(verbs, 2))
49
- lines.push('', 'flags:')
50
- lines.push(...columns(flags().map(flagRow), 2))
51
-
52
- const games = Object.entries(config?.games ?? {})
53
- if (games.length > 0) {
54
- lines.push('', 'games:')
55
- lines.push(...columns(games.map(([name, game]) => [name, gameSummary(game)] as const), 2))
56
- lines.push('', `${NAME} help <game> lists that game's profiles.`)
57
- }
58
-
59
- lines.push('', 'Game args go after a bare --. Env vars are GAMECRATE_ prefixed fallbacks only.')
60
- return lines.join('\n') + '\n'
61
- }
62
-
63
- function subcommandHelp(sub: SubcommandSpec): string {
64
- const lines = [`usage: ${NAME} ${sub.name} ${sub.usage}`.trimEnd() + ' [flags]', '', ` ${sub.summary}`]
65
-
66
- const names = [...sub.flags, ...GLOBAL_FLAGS]
67
- const all = flags()
68
- const specs = names
69
- .map((name) => all.find((f) => f.long === name))
70
- .filter((f): f is Option => f !== undefined)
71
- if (specs.length > 0) {
72
- lines.push('', 'flags:')
73
- lines.push(...columns(specs.map(flagRow), 2))
74
- }
75
- if (sub.name === 'run') {
76
- lines.push('', ` The subcommand slot defaults to run, so \`${NAME} <game> <profile>\` works.`)
77
- }
78
- return lines.join('\n') + '\n'
79
- }
80
-
81
- function gameHelp(name: string, game: GameConfig): string {
82
- const lines = [`usage: ${NAME} ${name} [profile] [flags] [-- game args]`, '', 'profiles:']
83
-
84
- const rows: (readonly [string, string])[] = []
85
- for (const [profile, config] of Object.entries(game.profiles)) {
86
- const notes: string[] = []
87
- if (config.alias) notes.push(`alias for ${config.alias}`)
88
- if (config.extends) notes.push(`extends ${config.extends}`)
89
- if (config.autoDependencies) notes.push('auto dependencies')
90
- const count = config.mods?.length ?? 0
91
- if (!config.alias) notes.push(count === 1 ? '1 entry' : `${count} entries`)
92
- if (config.aliases?.length) notes.push(`aka ${config.aliases.join(', ')}`)
93
- rows.push([profile, notes.join(', ')])
94
- }
95
- if (rows.length === 0) rows.push(['(none declared)', ''])
96
- lines.push(...columns(rows, 2))
97
-
98
- if (game.aliases && Object.keys(game.aliases).length > 0) {
99
- lines.push('', 'mod name aliases:')
100
- lines.push(...columns(Object.entries(game.aliases).map(([k, v]) => [k, v] as const), 2))
101
- }
102
-
103
- lines.push('', `modes: ${game.modes.join(', ')}`)
104
- lines.push(`core: ${game.core}`)
105
- if (game.dlc.length > 0) lines.push(`dlc: ${game.dlc.join(', ')}`)
106
- lines.push(`game files: ${game.gameFiles.source === 'mount' ? game.gameFiles.host ?? '(unset)' : game.image.ref}`)
107
- return lines.join('\n') + '\n'
108
- }
109
-
110
- function gameSummary(game: GameConfig): string {
111
- const count = Object.keys(game.profiles).length
112
- return `${count === 1 ? '1 profile' : `${count} profiles`}; modes ${game.modes.join(', ')}`
113
- }
114
-
115
- function flagRow(spec: Option): readonly [string, string] {
116
- const notes: string[] = []
117
- if (Array.isArray(spec.defaultValue)) notes.push('repeatable')
118
- const env = FLAG_ENV[spec.long ?? '']
119
- if (env) notes.push(`$${env}`)
120
- const summary = spec.description
121
- return [spec.flags, notes.length > 0 ? `${summary} (${notes.join(', ')})` : summary]
122
- }
123
-
124
- /** The `<id>` part of a flag string, for the shell completions. */
125
- function placeholder(spec: Option): string {
126
- return spec.flags.split(/[ ,]+/).find((token) => token.startsWith('<')) ?? ''
127
- }
128
-
129
- function columns(rows: readonly (readonly [string, string])[], indent: number): string[] {
130
- const width = Math.max(0, ...rows.map((r) => r[0].length))
131
- const pad = ' '.repeat(indent)
132
- return rows.map(([left, right]) => (right ? `${pad}${left.padEnd(width)} ${right}` : `${pad}${left}`))
133
- }
134
-
135
- export function renderCompletion(shell: 'bash' | 'zsh'): string {
136
- const verbs = SUBCOMMANDS.map((s) => s.name).join(' ')
137
- const options = flags()
138
- const names = options.flatMap((f) => (f.short ? [f.long!, f.short] : [f.long!])).join(' ')
139
- const valueFlags = options.filter((f) => f.required).map((f) => f.long!)
140
- const fn = `_${NAME.replace(/-/g, '_')}`
141
-
142
- if (shell === 'bash') {
143
- const cases = options
144
- .filter((f) => f.argChoices)
145
- .map((f) => ` ${f.long}) COMPREPLY=($(compgen -W "${f.argChoices!.join(' ')}" -- "$cur")); return ;;`)
146
- .join('\n')
147
- return `${fn}() {
148
- local cur prev
149
- cur="\${COMP_WORDS[COMP_CWORD]}"
150
- prev="\${COMP_WORDS[COMP_CWORD-1]}"
151
- case "$prev" in
152
- ${cases}
153
- ${valueFlags.join('|')}) return ;;
154
- esac
155
- if [[ "$cur" == -* ]]; then
156
- COMPREPLY=($(compgen -W "${names}" -- "$cur"))
157
- return
158
- fi
159
- if [[ $COMP_CWORD -eq 1 ]]; then
160
- local games
161
- games=$(${NAME} list --json 2>/dev/null | grep -oE '"[A-Za-z0-9._-]+"' | tr -d '"')
162
- COMPREPLY=($(compgen -W "${verbs} $games" -- "$cur"))
163
- fi
164
- }
165
- complete -F ${fn} ${NAME}
166
- `
167
- }
168
-
169
- const zshVerbs = SUBCOMMANDS.map((s) => ` '${s.name}:${s.summary.replace(/'/g, "'\\''")}'`).join('\n')
170
- const zshFlags = options.map((f) => {
171
- const desc = f.description.replace(/'/g, "'\\''").replace(/[[\]:]/g, '')
172
- const arg = placeholder(f)
173
- const value = arg ? `:${arg.replace(/[<>]/g, '')}:${f.argChoices ? `(${f.argChoices.join(' ')})` : '_files'}` : ''
174
- const repeat = Array.isArray(f.defaultValue) ? '*' : ''
175
- return ` '${repeat}${f.long}[${desc}]${value}'`
176
- }).join('\n')
177
-
178
- return `#compdef ${NAME}
179
-
180
- ${fn}() {
181
- local -a verbs
182
- verbs=(
183
- ${zshVerbs}
184
- )
185
- _arguments -s \\
186
- ${zshFlags} \\
187
- '1: :{_describe verb verbs}' \\
188
- '*:: :->rest'
189
- }
190
-
191
- ${fn} "$@"
192
- `
193
- }
package/src/cli/output.ts DELETED
@@ -1,246 +0,0 @@
1
- import { closeSync, existsSync, lstatSync, mkdirSync, openSync, readdirSync, rmSync, symlinkSync, unlinkSync, writeSync } from 'node:fs'
2
- import { basename, join, resolve } from 'node:path'
3
- import type { Readable } from 'node:stream'
4
- import { containerName } from '../docker/spec'
5
- import { staleWarning } from '../mods/staleness'
6
- import type { LaunchPlan, Problem, ResolvedMod } from '../types'
7
- import { Exit } from '../types'
8
-
9
- /** Tool status. Never stdout: stdout belongs to the game. */
10
- export function status(message: string): void {
11
- process.stderr.write(line(message))
12
- }
13
-
14
- export function warn(message: string): void {
15
- process.stderr.write(line(`warning: ${message}`))
16
- }
17
-
18
- export interface OutputRedirect {
19
- close(): void
20
- }
21
-
22
- export function redirectOutput(path: string): OutputRedirect {
23
- const fd = openSync(path, 'w')
24
- const stdout = process.stdout.write
25
- const stderr = process.stderr.write
26
- let open = true
27
- const write = ((
28
- chunk: string | Uint8Array,
29
- encodingOrCallback?: BufferEncoding | (() => void),
30
- callback?: () => void,
31
- ) => {
32
- append(fd, chunk)
33
- const done = typeof encodingOrCallback === 'function' ? encodingOrCallback : callback
34
- done?.()
35
- return true
36
- }) as typeof process.stdout.write
37
- process.stdout.write = write
38
- process.stderr.write = write
39
-
40
- return {
41
- close() {
42
- if (!open) return
43
- open = false
44
- process.stdout.write = stdout
45
- process.stderr.write = stderr
46
- closeSync(fd)
47
- },
48
- }
49
- }
50
-
51
- export async function forwardOutput(stream: Readable, target: NodeJS.WriteStream): Promise<void> {
52
- for await (const chunk of stream) target.write(chunk as Buffer)
53
- }
54
-
55
- function line(message: string): string {
56
- return message.endsWith('\n') ? message : `${message}\n`
57
- }
58
-
59
- /**
60
- * Every collected failure at once, grouped by location. Resolution stops before any
61
- * side effect, so reporting the first problem only would hide the rest.
62
- */
63
- export function reportProblems(problems: Problem[]): never {
64
- if (problems.length === 0) {
65
- process.stderr.write(line('resolution failed with no reported detail'))
66
- process.exit(Exit.Resolution)
67
- }
68
-
69
- const groups = new Map<string, Problem[]>()
70
- for (const problem of problems) {
71
- const group = groups.get(problem.where)
72
- if (group) group.push(problem)
73
- else groups.set(problem.where, [problem])
74
- }
75
-
76
- const out = [`${problems.length} problem${problems.length === 1 ? '' : 's'}:`]
77
- for (const [where, group] of groups) {
78
- out.push(` ${where}`)
79
- for (const problem of group) {
80
- out.push(` ${problem.message}`)
81
- if (problem.suggestion) out.push(` did you mean ${problem.suggestion}?`)
82
- }
83
- }
84
- process.stderr.write(`${out.join('\n')}\n`)
85
- process.exit(Exit.Resolution)
86
- }
87
-
88
- export interface PlanModPayload {
89
- packageId: string
90
- kind: ResolvedMod['kind']
91
- hostDir: string
92
- containerDir: string
93
- origin: 'explicit' | 'auto'
94
- stale: boolean
95
- staleReport?: ResolvedMod['staleReport']
96
- workshopId?: number
97
- }
98
-
99
- export interface PlanPayload {
100
- game: string
101
- profile: string
102
- instance?: string
103
- mode: string
104
- marker?: string
105
- timeoutSeconds: number
106
- renderWaitSeconds: number
107
- profileDir: string
108
- instanceDir: string
109
- containerName: string
110
- dataDirHost: string
111
- stageDirHost: string
112
- logsDirHost: string
113
- mods: PlanModPayload[]
114
- warnings: string[]
115
- }
116
-
117
- /**
118
- * Regenerated from the mods rather than stored, because a successful `--build` clears a stale
119
- * report and the warning has to disappear with it.
120
- */
121
- export function planWarnings(plan: LaunchPlan): string[] {
122
- if (!plan.warnOnStale) return plan.warnings
123
- const stale = plan.mods
124
- .filter((mod) => mod.staleReport !== undefined)
125
- .map((mod) => staleWarning(mod.packageId, mod.staleReport!))
126
- return [...plan.warnings, ...stale]
127
- }
128
-
129
- /** The `--print-plan --json` payload. Bind mounts leave no host-readable link to assert on. */
130
- export function planPayload(plan: LaunchPlan): PlanPayload {
131
- return {
132
- game: plan.game,
133
- profile: plan.profile,
134
- ...(plan.instance === undefined ? {} : { instance: plan.instance }),
135
- mode: plan.mode,
136
- ...(plan.marker === undefined ? {} : { marker: plan.marker }),
137
- timeoutSeconds: plan.timeoutSeconds,
138
- renderWaitSeconds: plan.renderWaitSeconds,
139
- profileDir: resolve(plan.profileDir),
140
- instanceDir: resolve(plan.instanceDir),
141
- containerName: containerName(plan),
142
- dataDirHost: resolve(plan.dataDirHost),
143
- stageDirHost: resolve(plan.stageDirHost),
144
- logsDirHost: resolve(plan.logsDirHost),
145
- mods: plan.mods.map((mod) => ({
146
- packageId: mod.packageId,
147
- kind: mod.kind,
148
- hostDir: resolve(mod.hostDir),
149
- containerDir: mod.containerDir,
150
- origin: mod.explicit ? 'explicit' : 'auto',
151
- stale: mod.stale === true,
152
- ...(mod.staleReport === undefined ? {} : { staleReport: mod.staleReport }),
153
- ...(mod.workshopId === undefined ? {} : { workshopId: mod.workshopId }),
154
- })),
155
- warnings: planWarnings(plan),
156
- }
157
- }
158
-
159
- export function printPlan(plan: LaunchPlan, asJson: boolean): void {
160
- const payload = planPayload(plan)
161
- if (asJson) {
162
- process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`)
163
- return
164
- }
165
-
166
- const title = payload.instance === undefined
167
- ? `${payload.game} ${payload.profile} (${payload.mode})`
168
- : `${payload.game} ${payload.profile} / ${payload.instance} (${payload.mode})`
169
- const out = [
170
- title,
171
- ` profile ${payload.profileDir}`,
172
- ` instance ${payload.instanceDir}`,
173
- ` container ${payload.containerName}`,
174
- ` data ${payload.dataDirHost}`,
175
- ` stage ${payload.stageDirHost}`,
176
- ` logs ${payload.logsDirHost}`,
177
- ]
178
- if (payload.marker !== undefined) out.push(` marker ${payload.marker}`)
179
- out.push(` timeout ${payload.timeoutSeconds}s, render wait ${payload.renderWaitSeconds}s`)
180
- out.push(` mods ${payload.mods.length}`)
181
-
182
- const width = Math.max(0, ...payload.mods.map((m) => m.packageId.length))
183
- for (const mod of payload.mods) {
184
- const notes: string[] = [mod.kind, mod.origin]
185
- if (mod.stale) notes.push('stale')
186
- out.push(` ${mod.packageId.padEnd(width)} ${notes.join(' ')} ${mod.hostDir} -> ${mod.containerDir}`)
187
- }
188
- for (const warning of payload.warnings) out.push(` warning: ${warning}`)
189
- process.stdout.write(`${out.join('\n')}\n`)
190
- }
191
-
192
- /** Sortable lexicographically and safe on every filesystem: 20260730T142335123Z. */
193
- export function runTimestamp(now: Date = new Date()): string {
194
- return now.toISOString().replace(/[-:.]/g, '')
195
- }
196
-
197
- /** Makes <logsDir>/runs/<ts>, repoints `current` at it, rotates the old ones, returns the dir. */
198
- export function openRunLog(logsDir: string, now?: Date): string {
199
- const runsDir = join(logsDir, 'runs')
200
- mkdirSync(runsDir, { recursive: true })
201
-
202
- const dir = uniqueRunDir(runsDir, runTimestamp(now))
203
- mkdirSync(dir)
204
- linkCurrent(logsDir, dir)
205
- rotateRuns(logsDir, 10)
206
- return dir
207
- }
208
-
209
- const encoder = new TextEncoder()
210
-
211
- function append(fd: number, chunk: string | Uint8Array): void {
212
- writeSync(fd, typeof chunk === 'string' ? encoder.encode(chunk) : chunk)
213
- }
214
-
215
- function uniqueRunDir(runsDir: string, stamp: string): string {
216
- let candidate = join(runsDir, stamp)
217
- for (let n = 2; existsSync(candidate); n++) candidate = join(runsDir, `${stamp}-${n}`)
218
- return candidate
219
- }
220
-
221
- function linkCurrent(logsDir: string, target: string): void {
222
- const link = join(logsDir, 'current')
223
- try {
224
- lstatSync(link)
225
- unlinkSync(link)
226
- } catch {
227
- // No previous link; nothing to clear.
228
- }
229
- symlinkSync(join('runs', basename(target)), link, 'dir')
230
- }
231
-
232
- /** Landmine 7: a 180MB Player-prev.log was 68% of a profile tree. Retention is the cap. */
233
- export function rotateRuns(logsDir: string, keep: number): string[] {
234
- const runsDir = join(logsDir, 'runs')
235
- if (keep < 1 || !existsSync(runsDir)) return []
236
-
237
- const dirs = readdirSync(runsDir, { withFileTypes: true })
238
- .filter((entry) => entry.isDirectory())
239
- .map((entry) => entry.name)
240
- .sort()
241
- .reverse()
242
-
243
- const removed = dirs.slice(keep)
244
- for (const name of removed) rmSync(join(runsDir, name), { recursive: true, force: true })
245
- return removed
246
- }
@@ -1,19 +0,0 @@
1
- import type { Settings } from '../types'
2
-
3
- export const DEFAULT_DATA_ROOT = '~/.local/share/gamecrate'
4
-
5
- export const DEFAULT_SETTINGS: Settings = {
6
- width: 1920,
7
- height: 1080,
8
- devMode: true,
9
- runInBackground: true,
10
- resetModsConfigOnCrash: false,
11
- gpu: true,
12
- audio: true,
13
- input: false,
14
- network: 'bridge',
15
- display: 'x11',
16
- memory: '8g',
17
- cpus: 6,
18
- pidsLimit: 1024,
19
- }
@@ -1,21 +0,0 @@
1
- import { parse, printParseErrorCode } from 'jsonc-parser'
2
- import type { ParseError } from 'jsonc-parser'
3
- import { GamecrateError, Exit } from '../types'
4
-
5
- /**
6
- * JSON with line and block comments and trailing commas. jsonc-parser recovers from
7
- * syntax errors and still returns a value, so the error list is the only success signal.
8
- */
9
- export function parseJsonc(text: string): unknown {
10
- const errors: ParseError[] = []
11
- const value = parse(text, errors, { allowTrailingComma: true, allowEmptyContent: false })
12
- const first = errors[0]
13
- if (first !== undefined) {
14
- throw new GamecrateError(
15
- 'config is not valid JSON',
16
- Exit.Config,
17
- `${printParseErrorCode(first.error)} at offset ${first.offset}`,
18
- )
19
- }
20
- return value
21
- }