@slugbugblue/trax-cli 0.12.1 → 0.13.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/.claude/settings.local.json +8 -0
- package/CHANGELOG.md +6 -0
- package/LICENSE +1 -1
- package/README.md +1 -1
- package/eslint.config.js +24 -0
- package/package.json +7 -18
- package/src/cli.js +246 -106
- package/src/cmds/analyze.js +72 -66
- package/src/cmds/delete.js +35 -43
- package/src/cmds/help.js +54 -57
- package/src/cmds/import-export.js +148 -122
- package/src/cmds/index.js +40 -0
- package/src/cmds/list.js +92 -87
- package/src/cmds/new.js +50 -55
- package/src/cmds/notes.js +20 -18
- package/src/cmds/play-try.js +147 -106
- package/src/cmds/puzzles.js +53 -39
- package/src/cmds/select.js +22 -31
- package/src/cmds/suggest.js +28 -37
- package/src/cmds/undo.js +31 -26
- package/src/cmds/view.js +42 -51
- package/src/utils.js +1 -1
- package/src/version.js +1 -1
- package/xo.config.js +10 -0
- package/src/types.d.ts +0 -33
package/src/cli.js
CHANGED
|
@@ -1,51 +1,141 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* You may obtain a copy of the License at
|
|
7
|
-
*
|
|
8
|
-
* http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
-
*
|
|
10
|
-
* Unless required by applicable law or agreed to in writing, software
|
|
11
|
-
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
-
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
-
* See the License for the specific language governing permissions and
|
|
14
|
-
* limitations under the License.
|
|
2
|
+
/** CLI for Trax, with a fallback REPL
|
|
3
|
+
* @copyright 2022-2026
|
|
4
|
+
* @author Chad Transtrum <chad@transtrum.net>
|
|
5
|
+
* @license Apache-2.0
|
|
15
6
|
*/
|
|
16
7
|
|
|
17
|
-
// CLI for Trax, with a fallback REPL
|
|
18
|
-
|
|
19
8
|
import process from 'node:process'
|
|
20
9
|
import repl from 'node:repl'
|
|
21
10
|
import YAML from 'yaml'
|
|
22
11
|
import { Trax } from '@slugbugblue/trax'
|
|
23
|
-
import { version as traxVersion } from '@slugbugblue/trax/version'
|
|
24
12
|
import { version as analystVersion } from '@slugbugblue/trax-analyst'
|
|
25
13
|
import * as tty from '@slugbugblue/trax-tty'
|
|
26
|
-
|
|
27
|
-
import {
|
|
28
|
-
import { deleteCmd } from './cmds/delete.js'
|
|
29
|
-
import { helpCmd } from './cmds/help.js'
|
|
30
|
-
import {
|
|
31
|
-
importCmd,
|
|
32
|
-
exportCmd,
|
|
33
|
-
getFile,
|
|
34
|
-
saveFile,
|
|
35
|
-
findFiles,
|
|
36
|
-
} from './cmds/import-export.js'
|
|
37
|
-
import { listCmd } from './cmds/list.js'
|
|
38
|
-
import { newCmd } from './cmds/new.js'
|
|
39
|
-
import { notesCmd } from './cmds/notes.js'
|
|
40
|
-
import { playCmd, tryCmd } from './cmds/play-try.js'
|
|
41
|
-
import { puzzlesCmd } from './cmds/puzzles.js'
|
|
42
|
-
import { selectCmd } from './cmds/select.js'
|
|
43
|
-
import { suggestCmd } from './cmds/suggest.js'
|
|
44
|
-
import { undoCmd } from './cmds/undo.js'
|
|
45
|
-
import { viewCmd } from './cmds/view.js'
|
|
14
|
+
import { allCmds } from './cmds/index.js'
|
|
15
|
+
import { getFile, saveFile, findFiles } from './cmds/import-export.js'
|
|
46
16
|
import { version } from './version.js'
|
|
47
17
|
|
|
48
|
-
|
|
18
|
+
/**
|
|
19
|
+
* @callback Colorize
|
|
20
|
+
* @param {string} text
|
|
21
|
+
* @param {string | number} [defaultColor]
|
|
22
|
+
* @returns {string}
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @typedef {Colorize & {
|
|
27
|
+
* black: Colorize
|
|
28
|
+
* command: Colorize
|
|
29
|
+
* default: Colorize
|
|
30
|
+
* error: Colorize
|
|
31
|
+
* fatal: Colorize
|
|
32
|
+
* help: Colorize
|
|
33
|
+
* id: Colorize
|
|
34
|
+
* optional: Colorize
|
|
35
|
+
* short: Colorize
|
|
36
|
+
* success: Colorize
|
|
37
|
+
* variable: Colorize
|
|
38
|
+
* white: Colorize
|
|
39
|
+
* }} Colorer
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* @typedef {object} GameNote
|
|
44
|
+
* @property {number} move - the move number
|
|
45
|
+
* @property {string} note - the note text
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
/** @typedef {GameNote[]} GameNotes */
|
|
49
|
+
|
|
50
|
+
/** @typedef {'trax' | 'trax8' | 'traxloop'} TraxVariant */
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* @typedef {object} GameData
|
|
54
|
+
* @property {string} id - game identifier
|
|
55
|
+
* @property {TraxVariant} rules - game variant
|
|
56
|
+
* @property {string} name - display name of the game variant
|
|
57
|
+
* @property {number} turn - current player (1 or 2)
|
|
58
|
+
* @property {string[]} players - player names [white, black]
|
|
59
|
+
* @property {string} moves - space-separated move notation
|
|
60
|
+
* @property {boolean} [over] - whether the game has ended
|
|
61
|
+
* @property {string} [puzzle] - puzzle ID if this is a puzzle game
|
|
62
|
+
* @property {number} [max] - max moves for puzzle win condition
|
|
63
|
+
* @property {GameNotes} [notes] - annotations
|
|
64
|
+
*/
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* @typedef {object} PuzzleStats
|
|
68
|
+
* @property {number} attempts - number of times started
|
|
69
|
+
* @property {number} solved - number of times solved
|
|
70
|
+
*/
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* @typedef {object} Command
|
|
74
|
+
* @property {string[]} alt - alternative names
|
|
75
|
+
* @property {string} args - argument syntax string
|
|
76
|
+
* @property {string[] | Function} comp - tab-completion hints
|
|
77
|
+
* @property {string} desc - short description
|
|
78
|
+
* @property {string[]} help - detailed help lines
|
|
79
|
+
* @property {boolean} [hide] - whether to hide from the help listing
|
|
80
|
+
* @property {Function} fn - command handler
|
|
81
|
+
*/
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Plugin definition as supplied by a command module (looser than Command).
|
|
85
|
+
* @typedef {object} PluginDef
|
|
86
|
+
* @property {string} name
|
|
87
|
+
* @property {string | string[]} [alt]
|
|
88
|
+
* @property {string} [args]
|
|
89
|
+
* @property {string | string[] | Function} [comp]
|
|
90
|
+
* @property {string} desc
|
|
91
|
+
* @property {string | string[]} [help]
|
|
92
|
+
* @property {boolean} [hide]
|
|
93
|
+
* @property {string | string[]} [opts]
|
|
94
|
+
* @property {RegExp | RegExp[]} [rx]
|
|
95
|
+
* @property {Function} fn
|
|
96
|
+
*/
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* @typedef {object} CLIContext
|
|
100
|
+
* @property {Record<string, GameData>} GAMES - all stored games
|
|
101
|
+
* @property {GameData} GAME - currently selected game
|
|
102
|
+
* @property {string} ID - current game ID
|
|
103
|
+
* @property {number} puzzleLevel - current puzzle level
|
|
104
|
+
* @property {import('@slugbugblue/trax').Trax} TRAX - current Trax instance
|
|
105
|
+
* @property {Record<string, number>} COLORS - ANSI color codes by name
|
|
106
|
+
* @property {Colorer} color - colorizer function
|
|
107
|
+
* @property {(command: string) => Command} cmd - get a command object by name
|
|
108
|
+
* @property {string[]} commands - all registered command names
|
|
109
|
+
* @property {(id: string) => void} delete - delete a game by ID
|
|
110
|
+
* @property {(cmd: string, ...args: unknown[]) => void} do - invoke a command
|
|
111
|
+
* @property {(...parts: string[]) => Promise<void>} doNext - execute a command line
|
|
112
|
+
* @property {(text: string) => void} error - print an error to stdout
|
|
113
|
+
* @property {(move: string) => string} fixNotation - normalize move notation
|
|
114
|
+
* @property {(id?: string) => void} load - load a game by ID
|
|
115
|
+
* @property {(rules: string, players: string[], moves: string) => string} newGame - create a new game
|
|
116
|
+
* @property {(puzzle: object) => string} newPuzzle - start a new puzzle game
|
|
117
|
+
* @property {(text: string) => void} out - print to stdout
|
|
118
|
+
* @property {(n: number, noun: string, nouns?: string) => string} plural - pluralize a noun
|
|
119
|
+
* @property {(id: string) => PuzzleStats} puzzle - get puzzle stats
|
|
120
|
+
* @property {(id: string) => void} puzzleSolved - record a puzzle as solved
|
|
121
|
+
* @property {(word?: string) => string | string[] | undefined} resolveCommand - resolve abbreviation
|
|
122
|
+
* @property {() => void} save - persist data to disk
|
|
123
|
+
* @property {(moves: string[]) => void} setGame - update the current game's moves
|
|
124
|
+
* @property {() => void} updateGameData - sync TRAX state into stored game data
|
|
125
|
+
* @property {Function} bubble - tty bubble renderer
|
|
126
|
+
* @property {Function} display - tty board display
|
|
127
|
+
* @property {Function} name - tty name formatter
|
|
128
|
+
* @property {(variable: unknown, type: string) => boolean} is - type checker
|
|
129
|
+
* @property {string} version - CLI version string
|
|
130
|
+
* @property {string} CMD - command currently being evaluated
|
|
131
|
+
* @property {import('node:repl').REPLServer | undefined} repl - active REPL instance
|
|
132
|
+
*/
|
|
133
|
+
|
|
134
|
+
/** Check the type of a value, correctly handling arrays and null.
|
|
135
|
+
* @param {unknown} variable - value to check
|
|
136
|
+
* @param {string} type - expected type prefix (e.g. 'str', 'arr', 'func', 'undef')
|
|
137
|
+
* @returns {boolean}
|
|
138
|
+
*/
|
|
49
139
|
const is = (variable, type) =>
|
|
50
140
|
Object.prototype.toString
|
|
51
141
|
.call(variable)
|
|
@@ -59,10 +149,11 @@ const versionCmd = {
|
|
|
59
149
|
opts: ['--version', '-v'],
|
|
60
150
|
desc: 'display version number',
|
|
61
151
|
help: 'Print out the current version of this program.',
|
|
152
|
+
/** @param {CLIContext} CLI */
|
|
62
153
|
fn: (CLI) =>
|
|
63
154
|
CLI.out(
|
|
64
155
|
[
|
|
65
|
-
`Trax: engine v${
|
|
156
|
+
`Trax: engine v${Trax.version}`,
|
|
66
157
|
`analyst v${analystVersion}`,
|
|
67
158
|
`tty v${tty.version}`,
|
|
68
159
|
`cli v${version}`,
|
|
@@ -87,26 +178,12 @@ const quitCmd = {
|
|
|
87
178
|
},
|
|
88
179
|
}
|
|
89
180
|
|
|
90
|
-
const plugins = [
|
|
91
|
-
analyzeCmd,
|
|
92
|
-
deleteCmd,
|
|
93
|
-
exportCmd,
|
|
94
|
-
helpCmd,
|
|
95
|
-
importCmd,
|
|
96
|
-
listCmd,
|
|
97
|
-
newCmd,
|
|
98
|
-
notesCmd,
|
|
99
|
-
playCmd,
|
|
100
|
-
puzzlesCmd,
|
|
101
|
-
quitCmd,
|
|
102
|
-
selectCmd,
|
|
103
|
-
suggestCmd,
|
|
104
|
-
tryCmd,
|
|
105
|
-
undoCmd,
|
|
106
|
-
versionCmd,
|
|
107
|
-
viewCmd,
|
|
108
|
-
]
|
|
181
|
+
const plugins = [...allCmds, quitCmd, versionCmd]
|
|
109
182
|
|
|
183
|
+
/** Generate a new unique game ID, filling gaps from the low end.
|
|
184
|
+
* @param {{ games?: Record<string, GameData> }} DATA
|
|
185
|
+
* @returns {string}
|
|
186
|
+
*/
|
|
110
187
|
const newId = (DATA) => {
|
|
111
188
|
let max = 0
|
|
112
189
|
let min = Number.POSITIVE_INFINITY
|
|
@@ -133,7 +210,9 @@ const color = (text, defaultColor) => {
|
|
|
133
210
|
let quote = false
|
|
134
211
|
let id = false
|
|
135
212
|
const colors = [defaultColor]
|
|
136
|
-
|
|
213
|
+
/** @param {string} [t] @returns {string} */
|
|
214
|
+
const c = (t = '') => tty.color(t, colors.at(-1) || CLI.COLORS.default)
|
|
215
|
+
/** @param {string} t @returns {string} */
|
|
137
216
|
const short = (t) => tty.color(t, CLI.COLORS.short) + c()
|
|
138
217
|
for (const char of text) {
|
|
139
218
|
switch (char) {
|
|
@@ -186,7 +265,7 @@ const color = (text, defaultColor) => {
|
|
|
186
265
|
}
|
|
187
266
|
|
|
188
267
|
default: {
|
|
189
|
-
if (id && !/[\da-z]
|
|
268
|
+
if (id && !/[\da-z]/v.test(char)) {
|
|
190
269
|
id = false
|
|
191
270
|
colors.pop()
|
|
192
271
|
out += c(char)
|
|
@@ -201,7 +280,9 @@ const color = (text, defaultColor) => {
|
|
|
201
280
|
}
|
|
202
281
|
|
|
203
282
|
// Internal representations of files
|
|
283
|
+
/** @type {{ id: string, puzzleLevel: number }} */
|
|
204
284
|
const CONFIG = { id: '', puzzleLevel: 0 }
|
|
285
|
+
/** @type {{ games: Record<string, GameData>, puzzles: Record<string, PuzzleStats> }} */
|
|
205
286
|
const DATA = { games: {}, puzzles: {} }
|
|
206
287
|
|
|
207
288
|
// CLI context object for plugins
|
|
@@ -247,24 +328,27 @@ const CLI = {
|
|
|
247
328
|
// CLI.color('hi') and CLI.color.default('hi') are equivalent
|
|
248
329
|
color,
|
|
249
330
|
|
|
331
|
+
/** @param {string} command @returns {Command} */
|
|
250
332
|
cmd(command) {
|
|
251
333
|
// Return a command structure object or an empty object
|
|
252
334
|
if (is(command, 'str') && command in commands) {
|
|
253
335
|
return commands[command]
|
|
254
336
|
}
|
|
255
337
|
|
|
256
|
-
return {}
|
|
338
|
+
return /** @type {Command} */ ({})
|
|
257
339
|
},
|
|
258
340
|
|
|
259
341
|
get commands() {
|
|
260
342
|
return Object.keys(commands)
|
|
261
343
|
},
|
|
262
344
|
|
|
345
|
+
/** @param {string} id */
|
|
263
346
|
delete(id) {
|
|
264
347
|
delete DATA.games[id]
|
|
265
348
|
saveData()
|
|
266
349
|
},
|
|
267
350
|
|
|
351
|
+
/** @param {string} cmd @param {...unknown} args */
|
|
268
352
|
do(cmd, ...args) {
|
|
269
353
|
// Call another plugin
|
|
270
354
|
if (cmd in commands) {
|
|
@@ -274,17 +358,20 @@ const CLI = {
|
|
|
274
358
|
}
|
|
275
359
|
},
|
|
276
360
|
|
|
361
|
+
/** @param {...string} cmd */
|
|
277
362
|
async doNext(...cmd) {
|
|
278
363
|
// Execute another command line
|
|
279
364
|
await evaluate(cmd.join(' '))
|
|
280
365
|
},
|
|
281
366
|
|
|
367
|
+
/** @param {string} text */
|
|
282
368
|
error(text) {
|
|
283
369
|
// Prints an error to stdout
|
|
284
370
|
CLI.out(CLI.color.error(text))
|
|
285
371
|
},
|
|
286
372
|
|
|
287
373
|
// Notation manipulation
|
|
374
|
+
/** @param {string} move @returns {string} */
|
|
288
375
|
fixNotation(move) {
|
|
289
376
|
if (move.endsWith('b')) return move.slice(0, -1) + '\\'
|
|
290
377
|
if (move.endsWith('s')) return move.slice(0, -1) + '/'
|
|
@@ -292,6 +379,7 @@ const CLI = {
|
|
|
292
379
|
return move
|
|
293
380
|
},
|
|
294
381
|
|
|
382
|
+
/** @param {string} [id] */
|
|
295
383
|
load(id) {
|
|
296
384
|
id ||= Object.keys(DATA.games || {})[0]
|
|
297
385
|
if (id && DATA.games?.[id] && DATA.games[id].rules) {
|
|
@@ -300,12 +388,18 @@ const CLI = {
|
|
|
300
388
|
saveConfig()
|
|
301
389
|
}
|
|
302
390
|
|
|
303
|
-
const game =
|
|
391
|
+
const game = DATA.games[id]
|
|
304
392
|
game.players ||= ['white', 'black']
|
|
305
393
|
CLI.TRAX = new Trax(game.rules, game.moves, 'cli')
|
|
306
394
|
}
|
|
307
395
|
},
|
|
308
396
|
|
|
397
|
+
/**
|
|
398
|
+
* @param {TraxVariant} rules
|
|
399
|
+
* @param {string[]} players
|
|
400
|
+
* @param {string} moves
|
|
401
|
+
* @returns {string}
|
|
402
|
+
*/
|
|
309
403
|
newGame(rules, players, moves) {
|
|
310
404
|
// Creates a new game
|
|
311
405
|
const id = newId(DATA)
|
|
@@ -328,6 +422,7 @@ const CLI = {
|
|
|
328
422
|
return id
|
|
329
423
|
},
|
|
330
424
|
|
|
425
|
+
/** @param {import('@slugbugblue/trax-puzzles').Puzzle} puzzle @returns {string} */
|
|
331
426
|
newPuzzle(puzzle) {
|
|
332
427
|
// Starts a new puzzle
|
|
333
428
|
const id = newId(DATA)
|
|
@@ -348,7 +443,7 @@ const CLI = {
|
|
|
348
443
|
name: trax.name,
|
|
349
444
|
turn: trax.turn,
|
|
350
445
|
max: puzzle.max,
|
|
351
|
-
players: trax.turn === 1 ? players : players.
|
|
446
|
+
players: trax.turn === 1 ? players : players.toReversed(),
|
|
352
447
|
moves: trax.moves.join(' '),
|
|
353
448
|
notes,
|
|
354
449
|
}
|
|
@@ -369,30 +464,40 @@ const CLI = {
|
|
|
369
464
|
return id
|
|
370
465
|
},
|
|
371
466
|
|
|
467
|
+
/** @param {string} text */
|
|
372
468
|
out(text) {
|
|
373
469
|
// Prints to stdout
|
|
374
470
|
console.info(text + tty.color(''))
|
|
375
471
|
},
|
|
376
472
|
|
|
473
|
+
/**
|
|
474
|
+
* @param {number} n
|
|
475
|
+
* @param {string} noun
|
|
476
|
+
* @param {string} [nouns]
|
|
477
|
+
* @returns {string}
|
|
478
|
+
*/
|
|
377
479
|
plural(n, noun, nouns) {
|
|
378
480
|
return String(n) + ' ' + (Math.abs(n) === 1 ? noun : nouns || noun + 's')
|
|
379
481
|
},
|
|
380
482
|
|
|
483
|
+
/** @param {string} id @returns {PuzzleStats} */
|
|
381
484
|
puzzle(id) {
|
|
382
485
|
return DATA.puzzles?.[id] || { attempts: 0, solved: 0 }
|
|
383
486
|
},
|
|
384
487
|
|
|
488
|
+
/** @param {string} id */
|
|
385
489
|
puzzleSolved(id) {
|
|
386
490
|
DATA.puzzles ||= {}
|
|
387
491
|
DATA.puzzles[id] ||= { attempts: 1, solved: 0 }
|
|
388
492
|
DATA.puzzles[id].solved += 1
|
|
389
493
|
},
|
|
390
494
|
|
|
495
|
+
/** @param {string} [word] @returns {string | string[] | undefined} */
|
|
391
496
|
resolveCommand(word) {
|
|
392
497
|
// Determine if an entered word is a valid command.
|
|
393
498
|
// Returns the command if a single match is found, an array if multiple
|
|
394
499
|
// commands match, or undefined if nothing matches.
|
|
395
|
-
if (is(word, 'str')
|
|
500
|
+
if (word && is(word, 'str')) {
|
|
396
501
|
return abbreviations[word.toLowerCase()]
|
|
397
502
|
}
|
|
398
503
|
|
|
@@ -404,9 +509,10 @@ const CLI = {
|
|
|
404
509
|
saveData()
|
|
405
510
|
},
|
|
406
511
|
|
|
512
|
+
/** @param {string[]} moves */
|
|
407
513
|
setGame(moves) {
|
|
408
514
|
// Updates the moves for the current game
|
|
409
|
-
const game = CLI.
|
|
515
|
+
const game = DATA.games[CLI.ID]
|
|
410
516
|
const trax = new Trax(game.rules, moves, 'cli')
|
|
411
517
|
if (game.over || trax.over) game.over = trax.over
|
|
412
518
|
game.moves = trax.moves.join(' ')
|
|
@@ -417,7 +523,7 @@ const CLI = {
|
|
|
417
523
|
},
|
|
418
524
|
|
|
419
525
|
updateGameData() {
|
|
420
|
-
const data = DATA.games[CLI.
|
|
526
|
+
const data = DATA.games[CLI.ID]
|
|
421
527
|
const moves = CLI.TRAX.moves.join(' ')
|
|
422
528
|
if (data.moves !== moves) {
|
|
423
529
|
data.moves = moves
|
|
@@ -434,30 +540,41 @@ const CLI = {
|
|
|
434
540
|
|
|
435
541
|
is,
|
|
436
542
|
version,
|
|
543
|
+
|
|
544
|
+
// Dynamically assigned during evaluate() and main()
|
|
545
|
+
CMD: '',
|
|
546
|
+
/** @type {import('node:repl').REPLServer | undefined} */
|
|
547
|
+
repl: undefined,
|
|
437
548
|
}
|
|
438
549
|
|
|
439
550
|
let startRepl = false
|
|
440
551
|
|
|
552
|
+
/** @returns {Promise<typeof CONFIG>} */
|
|
441
553
|
const getConfig = async () => {
|
|
442
554
|
await getFile('config', 'trax.yaml', CONFIG, YAML.parse)
|
|
443
555
|
return CONFIG
|
|
444
556
|
}
|
|
445
557
|
|
|
558
|
+
/** @returns {Promise<typeof DATA>} */
|
|
446
559
|
const getData = async () => {
|
|
447
560
|
await getFile('data', 'trax.json', DATA, JSON.parse)
|
|
448
561
|
return DATA
|
|
449
562
|
}
|
|
450
563
|
|
|
564
|
+
/** @returns {void} */
|
|
451
565
|
const saveConfig = () => {
|
|
452
566
|
saveFile('config', 'trax.yaml', YAML.stringify(CONFIG))
|
|
453
567
|
}
|
|
454
568
|
|
|
569
|
+
/** @returns {void} */
|
|
455
570
|
const saveData = () => {
|
|
456
571
|
saveFile('data', 'trax.json', JSON.stringify(DATA, null, 2) + '\n')
|
|
457
572
|
}
|
|
458
573
|
|
|
574
|
+
/** @type {Record<string, string>} */
|
|
459
575
|
const OPTS = {}
|
|
460
576
|
|
|
577
|
+
/** @returns {string} */
|
|
461
578
|
const parseCommandLine = () => {
|
|
462
579
|
let cmd = ''
|
|
463
580
|
const args = process.argv.slice(2)
|
|
@@ -479,37 +596,61 @@ const parseCommandLine = () => {
|
|
|
479
596
|
|
|
480
597
|
// Auto-populate the color functions
|
|
481
598
|
for (const [name, value] of Object.entries(CLI.COLORS)) {
|
|
482
|
-
|
|
599
|
+
// @ts-ignore — dynamic string-keyed assignment onto the Colorer type
|
|
600
|
+
CLI.color[name] = (/** @type {string} */ text) => color(text, value)
|
|
483
601
|
}
|
|
484
602
|
|
|
603
|
+
/**
|
|
604
|
+
* @template T
|
|
605
|
+
* @param {T | T[] | undefined} item
|
|
606
|
+
* @returns {T[]}
|
|
607
|
+
*/
|
|
485
608
|
const forceArray = (item) =>
|
|
486
|
-
|
|
609
|
+
Array.isArray(item) ? item : item === undefined ? [] : [item]
|
|
487
610
|
|
|
611
|
+
/** @type {Record<string, string | string[]>} */
|
|
488
612
|
const abbreviations = {}
|
|
613
|
+
/** @type {Record<string, Command>} */
|
|
489
614
|
const commands = {}
|
|
615
|
+
/** @type {string[]} */
|
|
490
616
|
const aliases = []
|
|
617
|
+
/** @type {{rx: RegExp, name: string}[]} */
|
|
491
618
|
const regexes = []
|
|
492
619
|
|
|
620
|
+
/**
|
|
621
|
+
* @param {string[]} abbr
|
|
622
|
+
* @param {string} full
|
|
623
|
+
*/
|
|
493
624
|
const addAbbrev = (abbr, full) => {
|
|
494
625
|
for (let abbrev of abbr) {
|
|
495
626
|
while (abbrev.length > 0) {
|
|
496
|
-
const
|
|
497
|
-
|
|
498
|
-
|
|
627
|
+
const existing = abbreviations[abbrev]
|
|
628
|
+
if (!existing) {
|
|
629
|
+
abbreviations[abbrev] = full
|
|
630
|
+
} else if (typeof existing === 'string') {
|
|
631
|
+
if (existing !== full) abbreviations[abbrev] = [existing, full]
|
|
632
|
+
} else if (!existing.includes(full)) {
|
|
633
|
+
existing.push(full)
|
|
634
|
+
}
|
|
635
|
+
|
|
499
636
|
abbrev = abbrev.slice(0, -1)
|
|
500
637
|
}
|
|
501
638
|
}
|
|
502
639
|
}
|
|
503
640
|
|
|
641
|
+
/**
|
|
642
|
+
* @param {string | string[] | Function | undefined} comp
|
|
643
|
+
* @returns {string[] | Function}
|
|
644
|
+
*/
|
|
504
645
|
const makeComp = (comp) => {
|
|
505
|
-
if (
|
|
506
|
-
if (
|
|
646
|
+
if (Array.isArray(comp) || typeof comp === 'function') return comp
|
|
647
|
+
if (typeof comp === 'string') return comp.split(' ')
|
|
507
648
|
return []
|
|
508
649
|
}
|
|
509
650
|
|
|
510
651
|
const processPlugins = () => {
|
|
511
652
|
// Process all the plugin command entries
|
|
512
|
-
for (const cmd of plugins) {
|
|
653
|
+
for (const cmd of /** @type {PluginDef[]} */ (plugins)) {
|
|
513
654
|
if (cmd.opts) {
|
|
514
655
|
for (const opt of forceArray(cmd.opts)) {
|
|
515
656
|
OPTS[opt] = cmd.name
|
|
@@ -541,11 +682,15 @@ const processPlugins = () => {
|
|
|
541
682
|
}
|
|
542
683
|
|
|
543
684
|
for (const [abbr, value] of Object.entries(abbreviations)) {
|
|
544
|
-
|
|
545
|
-
abbreviations[abbr] = value.size > 1 ? newValue.sort() : newValue[0]
|
|
685
|
+
if (Array.isArray(value)) abbreviations[abbr] = value.toSorted()
|
|
546
686
|
}
|
|
547
687
|
}
|
|
548
688
|
|
|
689
|
+
/**
|
|
690
|
+
* @param {string[]} completions
|
|
691
|
+
* @param {string} match
|
|
692
|
+
* @returns {string[]}
|
|
693
|
+
*/
|
|
549
694
|
const expand = (completions, match) => {
|
|
550
695
|
completions = [...completions] // Do not modify in-place
|
|
551
696
|
if (completions.includes('<cmd>')) {
|
|
@@ -590,8 +735,9 @@ const expand = (completions, match) => {
|
|
|
590
735
|
return completions
|
|
591
736
|
}
|
|
592
737
|
|
|
738
|
+
/** @param {string} line @returns {[string[], string]} */
|
|
593
739
|
const completer = (line) => {
|
|
594
|
-
const words = line.split(/\s+/).filter(Boolean)
|
|
740
|
+
const words = line.split(/\s+/v).filter(Boolean)
|
|
595
741
|
|
|
596
742
|
let match = words.at(-1) || ''
|
|
597
743
|
const previous = new Set(words.slice(1, -1).map((s) => s.toLowerCase()))
|
|
@@ -600,14 +746,16 @@ const completer = (line) => {
|
|
|
600
746
|
match = ''
|
|
601
747
|
}
|
|
602
748
|
|
|
749
|
+
/** @type {string[]} */
|
|
603
750
|
let completions = []
|
|
604
751
|
const cmd = abbreviations[String(words[0]).toLowerCase()]
|
|
605
752
|
|
|
606
|
-
if (
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
753
|
+
if (typeof cmd === 'string' && (line.endsWith(' ') || words.length > 1)) {
|
|
754
|
+
const { comp } = commands[cmd]
|
|
755
|
+
completions =
|
|
756
|
+
typeof comp === 'function'
|
|
757
|
+
? /** @type {string[]} */ (comp(CLI, words.slice(1), aliases))
|
|
758
|
+
: comp
|
|
611
759
|
|
|
612
760
|
completions = expand(completions, match)
|
|
613
761
|
if (words.length === 1 || (words.length === 2 && !line.endsWith(' '))) {
|
|
@@ -617,18 +765,12 @@ const completer = (line) => {
|
|
|
617
765
|
completions = aliases
|
|
618
766
|
}
|
|
619
767
|
|
|
620
|
-
for (let i = 0; i < completions.length; i++) {
|
|
621
|
-
const word = completions[i]
|
|
622
|
-
if (word.includes('|')) {
|
|
623
|
-
const comps = word.split('|')
|
|
624
|
-
completions[i] = comps.some((c) => previous.has(c.toLowerCase()))
|
|
625
|
-
? ''
|
|
626
|
-
: comps
|
|
627
|
-
}
|
|
628
|
-
}
|
|
629
|
-
|
|
630
768
|
completions = completions
|
|
631
|
-
.
|
|
769
|
+
.flatMap((word) => {
|
|
770
|
+
if (!word.includes('|')) return word
|
|
771
|
+
const comps = word.split('|')
|
|
772
|
+
return comps.some((c) => previous.has(c.toLowerCase())) ? '' : comps
|
|
773
|
+
})
|
|
632
774
|
.filter((c) => c && !previous.has(c.toLowerCase()))
|
|
633
775
|
|
|
634
776
|
if (match && completions.length > 0) {
|
|
@@ -639,20 +781,17 @@ const completer = (line) => {
|
|
|
639
781
|
|
|
640
782
|
if (!match && completions.length === 1) completions.push(' ')
|
|
641
783
|
|
|
642
|
-
completions = completions.map((c) => c + ' ').
|
|
784
|
+
completions = completions.map((c) => c + ' ').toSorted()
|
|
643
785
|
|
|
644
786
|
return [completions, match]
|
|
645
787
|
}
|
|
646
788
|
|
|
789
|
+
/** @param {string[]} cmd @returns {Set<string>} */
|
|
647
790
|
const ambiguousSuggestions = (cmd) => {
|
|
648
|
-
const maybe = new Set()
|
|
791
|
+
const maybe = /** @type {Set<string>} */ (new Set())
|
|
649
792
|
for (const alias of cmd) {
|
|
650
793
|
if (alias.startsWith(CLI.CMD)) maybe.add(alias)
|
|
651
|
-
|
|
652
|
-
let alts = commands[alias].alt
|
|
653
|
-
alts = is(alts, 'arr') ? alts : [alts || '']
|
|
654
|
-
|
|
655
|
-
for (const alt of alts) {
|
|
794
|
+
for (const alt of commands[alias].alt) {
|
|
656
795
|
if (alt.startsWith(CLI.CMD)) maybe.add(alt)
|
|
657
796
|
}
|
|
658
797
|
}
|
|
@@ -660,30 +799,31 @@ const ambiguousSuggestions = (cmd) => {
|
|
|
660
799
|
return maybe
|
|
661
800
|
}
|
|
662
801
|
|
|
802
|
+
/** @param {string} command */
|
|
663
803
|
const evaluate = async (command) => {
|
|
664
|
-
const [CMD, ...words] = command.split(/\s+/).filter(Boolean)
|
|
804
|
+
const [CMD, ...words] = command.split(/\s+/v).filter(Boolean)
|
|
665
805
|
const cmdLower = CMD?.toLowerCase() || ''
|
|
666
806
|
CLI.CMD = cmdLower
|
|
667
807
|
let cmd = abbreviations[cmdLower]
|
|
668
808
|
|
|
669
809
|
// If we are not in a REPL, let's remove hidden commands
|
|
670
|
-
if (!CLI.repl &&
|
|
810
|
+
if (!CLI.repl && Array.isArray(cmd)) {
|
|
671
811
|
cmd = cmd.filter((c) => !commands[c].hide)
|
|
672
812
|
if (cmd.length === 1) cmd = cmd[0]
|
|
673
813
|
}
|
|
674
814
|
|
|
675
|
-
if (
|
|
815
|
+
if (typeof cmd === 'string') {
|
|
676
816
|
if (words.length > 0 && abbreviations[words[0].toLowerCase()] === 'help') {
|
|
677
817
|
commands.help.fn(CLI, cmd)
|
|
678
818
|
} else {
|
|
679
819
|
await commands[cmd].fn(CLI, ...words)
|
|
680
820
|
}
|
|
681
|
-
} else if (
|
|
821
|
+
} else if (Array.isArray(cmd)) {
|
|
682
822
|
CLI.error('Ambiguous command. Perhaps you meant one of the following:')
|
|
683
823
|
const maybe = ambiguousSuggestions(cmd)
|
|
684
824
|
|
|
685
825
|
if (maybe.size > 0) {
|
|
686
|
-
for (const alias of [...maybe].
|
|
826
|
+
for (const alias of [...maybe].toSorted()) {
|
|
687
827
|
tty.outty(CLI.color.command(' ' + alias))
|
|
688
828
|
}
|
|
689
829
|
}
|
|
@@ -716,11 +856,11 @@ const main = async () => {
|
|
|
716
856
|
|
|
717
857
|
CLI.repl = repl.start({
|
|
718
858
|
prompt: 'trax❯ ',
|
|
719
|
-
async eval(cmd,
|
|
859
|
+
async eval(cmd, _ctx, _fn, cb) {
|
|
720
860
|
try {
|
|
721
861
|
await evaluate(cmd)
|
|
722
862
|
} catch (error) {
|
|
723
|
-
tty.outty(error, CLI.COLORS.fatal)
|
|
863
|
+
tty.outty(String(error), CLI.COLORS.fatal)
|
|
724
864
|
throw error
|
|
725
865
|
}
|
|
726
866
|
|