@gamecrate/cli 0.1.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +204 -33
- package/dist/gamecrate.js +1486 -715
- package/dist/lib.js +320 -0
- package/dist/types/cli/args.d.ts +56 -0
- package/dist/types/cli/game.d.ts +2 -0
- package/dist/types/cli/help.d.ts +7 -0
- package/dist/types/cli/list.d.ts +2 -0
- package/dist/types/cli/output.d.ts +78 -0
- package/dist/types/cli/profile.d.ts +6 -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 +47 -0
- package/dist/types/config/read.d.ts +11 -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 +39 -0
- package/dist/types/docker/spec.d.ts +24 -0
- package/dist/types/docker/window.d.ts +50 -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 +87 -0
- package/dist/types/launch/resolve.d.ts +17 -0
- package/dist/types/launch/stage.d.ts +13 -0
- package/dist/types/launch/supervisor.d.ts +48 -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/run/registry.d.ts +22 -0
- package/dist/types/types.d.ts +428 -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/cli/args.ts
DELETED
|
@@ -1,592 +0,0 @@
|
|
|
1
|
-
import { Command, CommanderError, Option } from 'commander'
|
|
2
|
-
import type { BuildPolicy, ModeName, NetworkPolicy, ParsedArgs, ProjectDefaults, PullPolicy } from '../types'
|
|
3
|
-
import { GamecrateError, Exit, NAME_PATTERN } from '../types'
|
|
4
|
-
|
|
5
|
-
export type PositionalSlot = 'game' | 'profile' | 'rest'
|
|
6
|
-
|
|
7
|
-
export interface SubcommandSpec {
|
|
8
|
-
name: string
|
|
9
|
-
summary: string
|
|
10
|
-
/** Rendered after the subcommand word in usage lines. */
|
|
11
|
-
usage: string
|
|
12
|
-
positionals: PositionalSlot[]
|
|
13
|
-
/** Flag names beyond the global set, in the order help should show them. */
|
|
14
|
-
flags: readonly string[]
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
const RUN_FLAGS = [
|
|
18
|
-
'--mod',
|
|
19
|
-
'--without',
|
|
20
|
-
'--only',
|
|
21
|
-
'--mode',
|
|
22
|
-
'--marker',
|
|
23
|
-
'--timeout',
|
|
24
|
-
'--render-wait',
|
|
25
|
-
'--resolution',
|
|
26
|
-
'--network',
|
|
27
|
-
'--log',
|
|
28
|
-
'--pull',
|
|
29
|
-
'--build',
|
|
30
|
-
'--no-build',
|
|
31
|
-
'--no-stale-check',
|
|
32
|
-
'--replace',
|
|
33
|
-
'--no-replace',
|
|
34
|
-
'--sort',
|
|
35
|
-
'--docker-arg',
|
|
36
|
-
'--dry-run',
|
|
37
|
-
'--print-plan',
|
|
38
|
-
'--root',
|
|
39
|
-
'--worktree',
|
|
40
|
-
'--no-worktree',
|
|
41
|
-
'--instance',
|
|
42
|
-
'--use',
|
|
43
|
-
] as const
|
|
44
|
-
|
|
45
|
-
/** The subcommand table. `modless` is reserved as a built-in profile, not a verb. */
|
|
46
|
-
export const SUBCOMMANDS: readonly SubcommandSpec[] = [
|
|
47
|
-
{
|
|
48
|
-
name: 'run',
|
|
49
|
-
summary: 'resolve, stage, launch (implied when the first word is a game)',
|
|
50
|
-
usage: '<game> [profile]',
|
|
51
|
-
positionals: ['game', 'profile', 'rest'],
|
|
52
|
-
flags: RUN_FLAGS,
|
|
53
|
-
},
|
|
54
|
-
{
|
|
55
|
-
name: 'list',
|
|
56
|
-
summary: "games, profiles, and each profile's provenance",
|
|
57
|
-
usage: '[game]',
|
|
58
|
-
positionals: ['game'],
|
|
59
|
-
flags: [],
|
|
60
|
-
},
|
|
61
|
-
{
|
|
62
|
-
name: 'mods',
|
|
63
|
-
summary: 'resolved mod set with source kind and absolute path',
|
|
64
|
-
usage: '<game> [profile]',
|
|
65
|
-
positionals: ['game', 'profile'],
|
|
66
|
-
flags: ['--mod', '--without', '--only', '--sort'],
|
|
67
|
-
},
|
|
68
|
-
{
|
|
69
|
-
name: 'doctor',
|
|
70
|
-
summary: 'preflight: docker, CDI, registry auth, game dirs, scan roots, perms',
|
|
71
|
-
usage: '',
|
|
72
|
-
positionals: [],
|
|
73
|
-
flags: [],
|
|
74
|
-
},
|
|
75
|
-
{
|
|
76
|
-
name: 'clean',
|
|
77
|
-
summary: 'tiered wipe of a profile',
|
|
78
|
-
usage: '<game> <profile>',
|
|
79
|
-
positionals: ['game', 'profile'],
|
|
80
|
-
flags: ['--staging', '--logs', '--all', '--yes', '--instance', '--worktree', '--no-worktree'],
|
|
81
|
-
},
|
|
82
|
-
{
|
|
83
|
-
name: 'clone',
|
|
84
|
-
summary: "reflink-copy a profile's precious tier",
|
|
85
|
-
usage: '<game> <src> <dst>',
|
|
86
|
-
positionals: ['game', 'rest'],
|
|
87
|
-
flags: ['--yes'],
|
|
88
|
-
},
|
|
89
|
-
{
|
|
90
|
-
name: 'logs',
|
|
91
|
-
summary: "tail or open the last run's captured logs",
|
|
92
|
-
usage: '<game> <profile>',
|
|
93
|
-
positionals: ['game', 'profile'],
|
|
94
|
-
flags: ['--instance', '--worktree', '--no-worktree'],
|
|
95
|
-
},
|
|
96
|
-
{
|
|
97
|
-
name: 'build',
|
|
98
|
-
summary: 'build or pull the runtime image, no launch',
|
|
99
|
-
usage: '<game>',
|
|
100
|
-
positionals: ['game'],
|
|
101
|
-
flags: ['--pull'],
|
|
102
|
-
},
|
|
103
|
-
{
|
|
104
|
-
name: 'shell',
|
|
105
|
-
summary: 'same mounts, bash instead of the game',
|
|
106
|
-
usage: '<game> [profile]',
|
|
107
|
-
positionals: ['game', 'profile'],
|
|
108
|
-
flags: [
|
|
109
|
-
'--mod', '--without', '--only', '--docker-arg', '--root',
|
|
110
|
-
'--worktree', '--no-worktree', '--instance', '--use', '--replace', '--log',
|
|
111
|
-
],
|
|
112
|
-
},
|
|
113
|
-
{
|
|
114
|
-
name: 'verify',
|
|
115
|
-
summary: 'what the running container actually bound, and whether it looks current',
|
|
116
|
-
usage: '<game> [profile]',
|
|
117
|
-
positionals: ['game', 'profile'],
|
|
118
|
-
flags: ['--instance', '--worktree', '--no-worktree'],
|
|
119
|
-
},
|
|
120
|
-
{
|
|
121
|
-
name: 'config',
|
|
122
|
-
summary: 'open profiles.json in $EDITOR, validate on save',
|
|
123
|
-
usage: 'edit',
|
|
124
|
-
positionals: ['rest'],
|
|
125
|
-
flags: [],
|
|
126
|
-
},
|
|
127
|
-
{
|
|
128
|
-
name: 'fix-perms',
|
|
129
|
-
summary: 'chown foreign-owned files back to the caller',
|
|
130
|
-
usage: '<game> [profile]',
|
|
131
|
-
positionals: ['game', 'profile'],
|
|
132
|
-
flags: ['--yes', '--dry-run'],
|
|
133
|
-
},
|
|
134
|
-
{
|
|
135
|
-
name: 'help',
|
|
136
|
-
summary: 'help for a subcommand or a game',
|
|
137
|
-
usage: '[topic]',
|
|
138
|
-
positionals: ['rest'],
|
|
139
|
-
flags: [],
|
|
140
|
-
},
|
|
141
|
-
{
|
|
142
|
-
name: 'version',
|
|
143
|
-
summary: 'print the version',
|
|
144
|
-
usage: '',
|
|
145
|
-
positionals: [],
|
|
146
|
-
flags: [],
|
|
147
|
-
},
|
|
148
|
-
]
|
|
149
|
-
|
|
150
|
-
/** Shown for every subcommand. */
|
|
151
|
-
export const GLOBAL_FLAGS = ['--json', '--help'] as const
|
|
152
|
-
|
|
153
|
-
const MODES: readonly ModeName[] = ['headed', 'headless', 'screenshot']
|
|
154
|
-
const PULL_POLICIES: readonly PullPolicy[] = ['always', 'missing', 'never']
|
|
155
|
-
const NETWORK_POLICIES: readonly NetworkPolicy[] = ['none', 'bridge', 'host']
|
|
156
|
-
const BUILD_POLICIES: readonly BuildPolicy[] = ['auto', 'always', 'never']
|
|
157
|
-
const SORTS = ['topo', 'none'] as const
|
|
158
|
-
|
|
159
|
-
/** The GAMECRATE_ vars, by the flag they stand in for. Fallbacks only; a flag always wins. */
|
|
160
|
-
export const FLAG_ENV: Record<string, string> = {
|
|
161
|
-
'--instance': 'GAMECRATE_INSTANCE',
|
|
162
|
-
'--mode': 'GAMECRATE_MODE',
|
|
163
|
-
'--marker': 'GAMECRATE_MARKER',
|
|
164
|
-
'--timeout': 'GAMECRATE_TIMEOUT',
|
|
165
|
-
'--render-wait': 'GAMECRATE_RENDER_WAIT',
|
|
166
|
-
'--network': 'GAMECRATE_NETWORK',
|
|
167
|
-
'--pull': 'GAMECRATE_PULL',
|
|
168
|
-
'--build': 'GAMECRATE_BUILD',
|
|
169
|
-
'--sort': 'GAMECRATE_SORT',
|
|
170
|
-
'--root': 'GAMECRATE_ROOT',
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
function collect(value: string, previous: string[]): string[] {
|
|
174
|
-
return [...previous, value]
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
function choice<T extends string>(flag: string, values: readonly T[]): (raw: string) => T {
|
|
178
|
-
return (raw) => {
|
|
179
|
-
if (!values.includes(raw as T)) {
|
|
180
|
-
throw usage(`${flag} must be one of ${values.join(', ')}, got ${raw}`)
|
|
181
|
-
}
|
|
182
|
-
return raw as T
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
/** Commander's own choices message reads nothing like ours, so keep the list for help only. */
|
|
187
|
-
function enumOption(flags: string, summary: string, values: readonly string[]): Option {
|
|
188
|
-
const long = flags.split(/[ ,]+/).find((token) => token.startsWith('--'))!
|
|
189
|
-
return new Option(flags, summary).choices([...values]).argParser(choice(long, values))
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
/**
|
|
193
|
-
* Every flag, in the order help lists them. The only source of truth for what the CLI
|
|
194
|
-
* accepts: help and completion read it back off the program.
|
|
195
|
-
*/
|
|
196
|
-
export function buildProgram(): Command {
|
|
197
|
-
const program = new Command()
|
|
198
|
-
program
|
|
199
|
-
.name('gamecrate')
|
|
200
|
-
.exitOverride()
|
|
201
|
-
.helpOption(false)
|
|
202
|
-
.allowExcessArguments(true)
|
|
203
|
-
.showSuggestionAfterError(false)
|
|
204
|
-
.configureOutput({ writeOut: () => {}, writeErr: () => {} })
|
|
205
|
-
.argument('[args...]')
|
|
206
|
-
.option('--mod <id>', 'add a mod to the profile set', collect, [])
|
|
207
|
-
.option('--without <id>', 'drop a mod from the resolved set', collect, [])
|
|
208
|
-
.option('--only <id>', 'restrict the resolved set to these mods', collect, [])
|
|
209
|
-
.option(
|
|
210
|
-
'--worktree <path>',
|
|
211
|
-
'promote mods from this git worktree, in its own instance ($GAMECRATE_WORKTREE)',
|
|
212
|
-
collect,
|
|
213
|
-
[],
|
|
214
|
-
)
|
|
215
|
-
.option('--use <packageId>=<path>', 'force one mod to load from this directory, whatever the profile pins', collect, [])
|
|
216
|
-
.option('--no-worktree', 'ignore the current worktree and $GAMECRATE_WORKTREE')
|
|
217
|
-
.option('--instance <name>', 'run under a named sub-profile with its own saves, logs and container')
|
|
218
|
-
.addOption(enumOption(`--mode <${MODES.join('|')}>`, 'how the game is displayed', MODES))
|
|
219
|
-
.option('--marker <str>', 'exit 0 as soon as this string appears in the log')
|
|
220
|
-
.option('--timeout <seconds>', 'kill the container after this long', (v) => seconds('--timeout', v))
|
|
221
|
-
.option('--render-wait <seconds>', 'settle time before a screenshot is taken', (v) =>
|
|
222
|
-
seconds('--render-wait', v))
|
|
223
|
-
.option('--resolution <width>x<height>', 'override the game resolution', parseResolution)
|
|
224
|
-
// host is what a mod's own server needs: it binds loopback inside the container, and
|
|
225
|
-
// -p only ever reaches the container's eth0.
|
|
226
|
-
.addOption(
|
|
227
|
-
enumOption(`--network <${NETWORK_POLICIES.join('|')}>`, "the container's network mode", NETWORK_POLICIES),
|
|
228
|
-
)
|
|
229
|
-
.option('--log <path>', 'route launch stdout and stderr to one file')
|
|
230
|
-
.addOption(
|
|
231
|
-
enumOption(`--pull <${PULL_POLICIES.join('|')}>`, 'when to pull the runtime image', PULL_POLICIES),
|
|
232
|
-
)
|
|
233
|
-
.option('--build', 'build local C# mods before launching')
|
|
234
|
-
.option('--no-build', 'never build, even when an assembly is stale')
|
|
235
|
-
.option('--no-stale-check', "do not warn when a mod's sources are newer than its assemblies")
|
|
236
|
-
.option('--replace', 'stop whatever is holding this profile and instance, then launch')
|
|
237
|
-
.option('--no-replace', 'refuse when this profile and instance are already running')
|
|
238
|
-
.addOption(
|
|
239
|
-
enumOption(`--sort <${SORTS.join('|')}>`, 'load order: the profile order, or a topological sort', SORTS),
|
|
240
|
-
)
|
|
241
|
-
.option('--docker-arg <arg>', 'one extra argv element for docker run', collect, [])
|
|
242
|
-
.option('--dry-run', 'resolve and validate fully, write nothing')
|
|
243
|
-
.option('--print-plan', 'print the resolved launch plan instead of launching')
|
|
244
|
-
.option('--json', 'machine-readable output')
|
|
245
|
-
.option('--root', 'run as root instead of mapping the host uid')
|
|
246
|
-
.option('--staging', 'clean: wipe .stage only (the default)')
|
|
247
|
-
.option('--logs', 'clean: wipe the captured run logs')
|
|
248
|
-
.option('--all', 'clean: wipe the whole profile, saves included (needs --yes)')
|
|
249
|
-
.option('-y, --yes', 'skip destructive-action confirmation')
|
|
250
|
-
.option('-h, --help', 'this help')
|
|
251
|
-
|
|
252
|
-
return program
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
/**
|
|
256
|
-
* Commander hands a value flag whatever token follows it, and its parsers cannot tell
|
|
257
|
-
* `--mod=-x` from `--mod -x`. Only the separate token can be a mistyped flag.
|
|
258
|
-
*/
|
|
259
|
-
function checkValueTokens(program: Command, head: string[]): void {
|
|
260
|
-
const valued = (name: string): Option | undefined =>
|
|
261
|
-
program.options.find((o) => (o.long === name || o.short === name) && (o.required || o.optional))
|
|
262
|
-
|
|
263
|
-
for (let i = 0; i < head.length; i++) {
|
|
264
|
-
const token = head[i]!
|
|
265
|
-
if (!token.startsWith('-') || token === '-') continue
|
|
266
|
-
|
|
267
|
-
const eq = token.indexOf('=')
|
|
268
|
-
if (token.startsWith('--') && eq !== -1) {
|
|
269
|
-
const name = token.slice(0, eq)
|
|
270
|
-
if (eq === token.length - 1 && valued(name) !== undefined) throw usage(`${name} needs a value`)
|
|
271
|
-
continue
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
const option = valued(token)
|
|
275
|
-
if (option === undefined) continue
|
|
276
|
-
const value = head[++i]
|
|
277
|
-
if (value === undefined) return // commander reports the missing argument itself
|
|
278
|
-
// --docker-arg is the one flag whose value legitimately starts with a dash.
|
|
279
|
-
if (option.long === '--docker-arg') continue
|
|
280
|
-
if (value.startsWith('-') && value.length > 1) {
|
|
281
|
-
throw usage(`${option.long ?? token} needs a value, got the flag ${value}`)
|
|
282
|
-
}
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
export interface ParseOptions {
|
|
287
|
-
env?: Record<string, string | undefined>
|
|
288
|
-
defaults?: ProjectDefaults
|
|
289
|
-
/** Game names from the loaded config; enables did-you-mean on the first positional. */
|
|
290
|
-
games?: readonly string[]
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
type Values = Record<string, unknown>
|
|
294
|
-
|
|
295
|
-
/**
|
|
296
|
-
* `gamecrate <game> [profile] [flags] [-- game args]`, with the subcommand slot
|
|
297
|
-
* defaulting to `run`. Game args come after a bare `--` and nowhere else.
|
|
298
|
-
*/
|
|
299
|
-
export function parseArgs(argv: string[], opts: ParseOptions = {}): ParsedArgs {
|
|
300
|
-
const env = opts.env ?? process.env
|
|
301
|
-
const sep = argv.indexOf('--')
|
|
302
|
-
const head = sep === -1 ? argv : argv.slice(0, sep)
|
|
303
|
-
|
|
304
|
-
const program = buildProgram()
|
|
305
|
-
const seen = new Set<string>()
|
|
306
|
-
const counts = new Map<string, number>()
|
|
307
|
-
const worktree: string[] = []
|
|
308
|
-
let cleanTier: ParsedArgs['cleanTier']
|
|
309
|
-
|
|
310
|
-
// Commander keeps no record of how often a flag appeared, or which half of a
|
|
311
|
-
// --x/--no-x pair the user typed; the events do.
|
|
312
|
-
for (const option of program.options) {
|
|
313
|
-
const long = option.long ?? option.flags
|
|
314
|
-
program.on(`option:${option.name()}`, (value?: string) => {
|
|
315
|
-
seen.add(long)
|
|
316
|
-
counts.set(long, (counts.get(long) ?? 0) + 1)
|
|
317
|
-
if (long === '--worktree' && value !== undefined) worktree.push(value)
|
|
318
|
-
if (long === '--staging' || long === '--logs' || long === '--all') {
|
|
319
|
-
cleanTier = long.slice(2) as ParsedArgs['cleanTier']
|
|
320
|
-
}
|
|
321
|
-
})
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
checkValueTokens(program, head)
|
|
325
|
-
try {
|
|
326
|
-
program.parse(head, { from: 'user' })
|
|
327
|
-
} catch (error) {
|
|
328
|
-
throw translate(error, program)
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
for (const option of program.options) {
|
|
332
|
-
const long = option.long ?? option.flags
|
|
333
|
-
const repeatable = Array.isArray(option.defaultValue)
|
|
334
|
-
if (option.required && !repeatable && (counts.get(long) ?? 0) > 1) {
|
|
335
|
-
throw usage(`${long} given more than once`)
|
|
336
|
-
}
|
|
337
|
-
}
|
|
338
|
-
if (seen.has('--build') && seen.has('--no-build')) throw usage('--build and --no-build contradict')
|
|
339
|
-
if (seen.has('--replace') && seen.has('--no-replace')) throw usage('--replace and --no-replace contradict')
|
|
340
|
-
|
|
341
|
-
const values = program.opts() as Values
|
|
342
|
-
const envBuild = applyEnv(program, seen, env, values)
|
|
343
|
-
|
|
344
|
-
const out: ParsedArgs = {
|
|
345
|
-
subcommand: 'run',
|
|
346
|
-
mods: values['mod'] as string[],
|
|
347
|
-
without: values['without'] as string[],
|
|
348
|
-
only: values['only'] as string[],
|
|
349
|
-
dockerArgs: values['dockerArg'] as string[],
|
|
350
|
-
gameArgs: sep === -1 ? [] : argv.slice(sep + 1),
|
|
351
|
-
dryRun: values['dryRun'] === true,
|
|
352
|
-
printPlan: values['printPlan'] === true,
|
|
353
|
-
json: values['json'] === true,
|
|
354
|
-
root: values['root'] === true,
|
|
355
|
-
yes: values['yes'] === true,
|
|
356
|
-
help: values['help'] === true,
|
|
357
|
-
worktree,
|
|
358
|
-
noWorktree: seen.has('--no-worktree'),
|
|
359
|
-
noStaleCheck: seen.has('--no-stale-check'),
|
|
360
|
-
replace: values['replace'] === true,
|
|
361
|
-
use: values['use'] as string[],
|
|
362
|
-
rest: [],
|
|
363
|
-
}
|
|
364
|
-
out.mode = values['mode'] as ModeName | undefined
|
|
365
|
-
out.marker = values['marker'] as string | undefined
|
|
366
|
-
out.timeout = values['timeout'] as number | undefined
|
|
367
|
-
out.renderWait = values['renderWait'] as number | undefined
|
|
368
|
-
out.resolution = values['resolution'] as { width: number; height: number } | undefined
|
|
369
|
-
out.network = values['network'] as NetworkPolicy | undefined
|
|
370
|
-
out.log = values['log'] as string | undefined
|
|
371
|
-
out.pull = values['pull'] as PullPolicy | undefined
|
|
372
|
-
out.sort = values['sort'] as 'topo' | 'none' | undefined
|
|
373
|
-
out.instance = values['instance'] as string | undefined
|
|
374
|
-
out.build = envBuild ?? policy(values['build'])
|
|
375
|
-
out.cleanTier = cleanTier
|
|
376
|
-
|
|
377
|
-
if (opts.defaults?.game !== undefined) out.game = opts.defaults.game
|
|
378
|
-
if (opts.defaults?.profile !== undefined) out.profile = opts.defaults.profile
|
|
379
|
-
|
|
380
|
-
applyPositionals(out, program.args, opts.games)
|
|
381
|
-
if (opts.defaults !== undefined) applyDefaults(out, seen, opts.defaults, sep !== -1)
|
|
382
|
-
return out
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
function policy(value: unknown): BuildPolicy | undefined {
|
|
386
|
-
if (value === true) return 'always'
|
|
387
|
-
if (value === false) return 'never'
|
|
388
|
-
return undefined
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
/** Commander's wording is its own; ours is the one the tests and the docs know. */
|
|
392
|
-
function translate(error: unknown, program: Command): unknown {
|
|
393
|
-
if (!(error instanceof CommanderError)) return error
|
|
394
|
-
const token = /'([^']+)'/.exec(error.message)?.[1] ?? ''
|
|
395
|
-
|
|
396
|
-
if (error.code === 'commander.unknownOption') {
|
|
397
|
-
const name = token.split('=')[0]!
|
|
398
|
-
const known = program.options.find((o) => o.long === name || o.short === name)
|
|
399
|
-
if (known !== undefined) return usage(`${name} takes no value`)
|
|
400
|
-
return usage(`unknown flag ${name}`, suggest(name, flagNames(program)))
|
|
401
|
-
}
|
|
402
|
-
if (error.code === 'commander.optionMissingArgument') {
|
|
403
|
-
return usage(`${token.split(' ')[0]} needs a value`)
|
|
404
|
-
}
|
|
405
|
-
return new GamecrateError(error.message.replace(/^error: /, ''), Exit.Usage)
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
function applyPositionals(out: ParsedArgs, positional: string[], games?: readonly string[]): void {
|
|
409
|
-
const first = positional[0]
|
|
410
|
-
if (first === undefined) {
|
|
411
|
-
if (out.game !== undefined) return
|
|
412
|
-
out.subcommand = 'help'
|
|
413
|
-
out.help = true
|
|
414
|
-
return
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
const sub = SUBCOMMANDS.find((s) => s.name === first)
|
|
418
|
-
let slots: PositionalSlot[]
|
|
419
|
-
let rest: string[]
|
|
420
|
-
|
|
421
|
-
if (sub) {
|
|
422
|
-
out.subcommand = sub.name
|
|
423
|
-
slots = [...sub.positionals]
|
|
424
|
-
rest = positional.slice(1)
|
|
425
|
-
} else {
|
|
426
|
-
const known = !NAME_PATTERN.test(first) ? false : games === undefined || games.includes(first)
|
|
427
|
-
if (!known) {
|
|
428
|
-
const candidates = [...SUBCOMMANDS.map((s) => s.name), ...(games ?? [])]
|
|
429
|
-
throw usage(`${first} is not a game or a subcommand`, suggest(first, candidates))
|
|
430
|
-
}
|
|
431
|
-
out.subcommand = 'run'
|
|
432
|
-
out.game = first
|
|
433
|
-
slots = ['profile', 'rest']
|
|
434
|
-
rest = positional.slice(1)
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
for (const slot of slots) {
|
|
438
|
-
if (slot === 'rest') {
|
|
439
|
-
out.rest = rest
|
|
440
|
-
rest = []
|
|
441
|
-
break
|
|
442
|
-
}
|
|
443
|
-
const value = rest.shift()
|
|
444
|
-
if (value === undefined) break
|
|
445
|
-
if (!NAME_PATTERN.test(value)) throw usage(`${value} is not a valid ${slot} name`)
|
|
446
|
-
if (slot === 'game') out.game = value
|
|
447
|
-
else out.profile = value
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
if (rest.length > 0) {
|
|
451
|
-
const shape = sub ? `${sub.name} ${sub.usage}`.trim() : `${out.game} [profile]`
|
|
452
|
-
throw usage(`unexpected argument ${rest[0]}`, `gamecrate ${shape}`)
|
|
453
|
-
}
|
|
454
|
-
}
|
|
455
|
-
|
|
456
|
-
/**
|
|
457
|
-
* Env vars are a fallback only, and only under the GAMECRATE_ prefix. Values go through
|
|
458
|
-
* the flag's own parser, so a bad one fails the way a bad flag does.
|
|
459
|
-
*/
|
|
460
|
-
function applyEnv(
|
|
461
|
-
program: Command,
|
|
462
|
-
seen: Set<string>,
|
|
463
|
-
env: Record<string, string | undefined>,
|
|
464
|
-
values: Values,
|
|
465
|
-
): BuildPolicy | undefined {
|
|
466
|
-
let build: BuildPolicy | undefined
|
|
467
|
-
for (const [flag, name] of Object.entries(FLAG_ENV)) {
|
|
468
|
-
if (seen.has(flag)) continue
|
|
469
|
-
if (flag === '--build' && seen.has('--no-build')) continue
|
|
470
|
-
const raw = env[name]
|
|
471
|
-
if (raw === undefined || raw === '') continue
|
|
472
|
-
seen.add(flag)
|
|
473
|
-
|
|
474
|
-
if (flag === '--build') {
|
|
475
|
-
if (!BUILD_POLICIES.includes(raw as BuildPolicy)) {
|
|
476
|
-
throw usage(`${name} must be one of ${BUILD_POLICIES.join(', ')}, got ${raw}`)
|
|
477
|
-
}
|
|
478
|
-
build = raw as BuildPolicy
|
|
479
|
-
continue
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
const option = program.options.find((o) => o.long === flag)!
|
|
483
|
-
const key = option.attributeName()
|
|
484
|
-
if (!option.required) {
|
|
485
|
-
if (truthy(raw)) values[key] = true
|
|
486
|
-
continue
|
|
487
|
-
}
|
|
488
|
-
if (option.argChoices && !option.argChoices.includes(raw)) {
|
|
489
|
-
throw usage(`${name} must be one of ${option.argChoices.join(', ')}, got ${raw}`)
|
|
490
|
-
}
|
|
491
|
-
values[key] = option.parseArg === undefined ? raw : option.parseArg(raw, values[key])
|
|
492
|
-
}
|
|
493
|
-
return build
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
function applyDefaults(
|
|
497
|
-
out: ParsedArgs,
|
|
498
|
-
seen: Set<string>,
|
|
499
|
-
defaults: ProjectDefaults,
|
|
500
|
-
hasGameArgs: boolean,
|
|
501
|
-
): void {
|
|
502
|
-
if (!seen.has('--mod') && defaults.mods !== undefined) out.mods = [...defaults.mods]
|
|
503
|
-
if (!seen.has('--without') && defaults.without !== undefined) out.without = [...defaults.without]
|
|
504
|
-
if (!seen.has('--only') && defaults.only !== undefined) out.only = [...defaults.only]
|
|
505
|
-
if (!seen.has('--docker-arg') && defaults.dockerArgs !== undefined) out.dockerArgs = [...defaults.dockerArgs]
|
|
506
|
-
if (!seen.has('--worktree') && !seen.has('--no-worktree') && defaults.worktree !== undefined) {
|
|
507
|
-
out.worktree = [...defaults.worktree]
|
|
508
|
-
}
|
|
509
|
-
if (!seen.has('--use') && defaults.use !== undefined) out.use = [...defaults.use]
|
|
510
|
-
if (!hasGameArgs && defaults.gameArgs !== undefined) out.gameArgs = [...defaults.gameArgs]
|
|
511
|
-
|
|
512
|
-
out.mode ??= defaults.mode
|
|
513
|
-
out.marker ??= defaults.marker
|
|
514
|
-
out.timeout ??= defaults.timeout
|
|
515
|
-
out.renderWait ??= defaults.renderWait
|
|
516
|
-
out.resolution ??= defaults.resolution
|
|
517
|
-
out.network ??= defaults.network
|
|
518
|
-
out.log ??= defaults.log
|
|
519
|
-
out.pull ??= defaults.pull
|
|
520
|
-
out.build ??= defaults.build
|
|
521
|
-
out.sort ??= defaults.sort
|
|
522
|
-
out.instance ??= defaults.instance
|
|
523
|
-
|
|
524
|
-
if (!seen.has('--dry-run')) out.dryRun = defaults.dryRun ?? out.dryRun
|
|
525
|
-
if (!seen.has('--print-plan')) out.printPlan = defaults.printPlan ?? out.printPlan
|
|
526
|
-
if (!seen.has('--json')) out.json = defaults.json ?? out.json
|
|
527
|
-
if (!seen.has('--root')) out.root = defaults.root ?? out.root
|
|
528
|
-
if (!seen.has('--no-worktree') && !seen.has('--worktree')) {
|
|
529
|
-
out.noWorktree = defaults.noWorktree ?? out.noWorktree
|
|
530
|
-
}
|
|
531
|
-
if (!seen.has('--no-stale-check')) out.noStaleCheck = defaults.noStaleCheck ?? out.noStaleCheck
|
|
532
|
-
if (!seen.has('--replace') && !seen.has('--no-replace')) out.replace = defaults.replace ?? out.replace
|
|
533
|
-
}
|
|
534
|
-
|
|
535
|
-
function truthy(value: string): boolean {
|
|
536
|
-
return value === '1' || value.toLowerCase() === 'true' || value.toLowerCase() === 'yes'
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
function flagNames(program: Command): string[] {
|
|
540
|
-
return program.options.flatMap((o) => [o.long, o.short].filter((f): f is string => f !== undefined))
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
function seconds(flag: string, value: string): number {
|
|
544
|
-
const n = Number(value)
|
|
545
|
-
if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0) {
|
|
546
|
-
throw usage(`${flag} takes a whole number of seconds, got ${value}`)
|
|
547
|
-
}
|
|
548
|
-
return n
|
|
549
|
-
}
|
|
550
|
-
|
|
551
|
-
export function parseResolution(value: string): { width: number; height: number } {
|
|
552
|
-
const match = /^(\d+)x(\d+)$/i.exec(value)
|
|
553
|
-
const width = Number(match?.[1])
|
|
554
|
-
const height = Number(match?.[2])
|
|
555
|
-
if (!Number.isSafeInteger(width) || width <= 0 || !Number.isSafeInteger(height) || height <= 0) {
|
|
556
|
-
throw usage(`--resolution takes positive dimensions like 1920x1080, got ${value}`)
|
|
557
|
-
}
|
|
558
|
-
return { width, height }
|
|
559
|
-
}
|
|
560
|
-
|
|
561
|
-
function usage(message: string, suggestion?: string): GamecrateError {
|
|
562
|
-
return new GamecrateError(message, Exit.Usage, suggestion ? `did you mean ${suggestion}?` : undefined)
|
|
563
|
-
}
|
|
564
|
-
|
|
565
|
-
/** Closest candidate within an edit distance that scales with word length. */
|
|
566
|
-
export function suggest(word: string, candidates: readonly string[]): string | undefined {
|
|
567
|
-
const target = word.toLowerCase()
|
|
568
|
-
const limit = Math.max(2, Math.floor(target.length / 3))
|
|
569
|
-
let best: string | undefined
|
|
570
|
-
let bestDistance = Infinity
|
|
571
|
-
for (const candidate of candidates) {
|
|
572
|
-
const d = distance(target, candidate.toLowerCase())
|
|
573
|
-
if (d < bestDistance && d <= limit) {
|
|
574
|
-
best = candidate
|
|
575
|
-
bestDistance = d
|
|
576
|
-
}
|
|
577
|
-
}
|
|
578
|
-
return best
|
|
579
|
-
}
|
|
580
|
-
|
|
581
|
-
function distance(a: string, b: string): number {
|
|
582
|
-
let prev = Array.from({ length: b.length + 1 }, (_, i) => i)
|
|
583
|
-
for (let i = 1; i <= a.length; i++) {
|
|
584
|
-
const row = [i]
|
|
585
|
-
for (let j = 1; j <= b.length; j++) {
|
|
586
|
-
const cost = a[i - 1] === b[j - 1] ? 0 : 1
|
|
587
|
-
row[j] = Math.min(row[j - 1]! + 1, prev[j]! + 1, prev[j - 1]! + cost)
|
|
588
|
-
}
|
|
589
|
-
prev = row
|
|
590
|
-
}
|
|
591
|
-
return prev[b.length]!
|
|
592
|
-
}
|