@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/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
- }