@slugbugblue/trax 0.5.0 → 0.9.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/src/cli.js ADDED
@@ -0,0 +1,623 @@
1
+ #!/usr/bin/env node
2
+ /* Copyright 2022 Chad Transtrum
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
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.
15
+ */
16
+
17
+ // CLI for Trax, with a fallback REPL
18
+
19
+ import process from 'node:process'
20
+ import repl from 'node:repl'
21
+
22
+ import YAML from 'yaml'
23
+
24
+ import { Trax } from './engine.js'
25
+ import { version } from './version.js'
26
+ import * as tty from './tty.js'
27
+
28
+ // Load all the commands as individual plugins
29
+ import { deleteCmd } from './cmds/delete.js'
30
+ import { helpCmd } from './cmds/help.js'
31
+ import {
32
+ importCmd,
33
+ exportCmd,
34
+ getFile,
35
+ saveFile,
36
+ findFiles,
37
+ } from './cmds/import-export.js'
38
+ import { listCmd } from './cmds/list.js'
39
+ import { newCmd } from './cmds/new.js'
40
+ import { playCmd, tryCmd } from './cmds/play-try.js'
41
+ import { selectCmd } from './cmds/select.js'
42
+ import { undoCmd } from './cmds/undo.js'
43
+ import { viewCmd } from './cmds/view.js'
44
+
45
+ // Because typeof [] and null are both 'object'. is([], 'arr') => true
46
+ const is = (variable, type) =>
47
+ Object.prototype.toString
48
+ .call(variable)
49
+ .slice(8, -1)
50
+ .toLowerCase()
51
+ .startsWith(type)
52
+
53
+ // Sample command:
54
+ const versionCmd = {
55
+ name: 'version',
56
+ opts: ['--version', '-v'],
57
+ desc: 'display version number',
58
+ help: 'Print out the current version of this program.',
59
+ fn: (CLI) => CLI.out('Trax CLI v' + version),
60
+ }
61
+
62
+ const quitCmd = {
63
+ name: 'quit',
64
+ alt: 'exit',
65
+ desc: 'press <Ctrl-D> to exit',
66
+ help: 'To exit, press Ctrl-D on a blank line or type "quit".',
67
+ hide: true,
68
+ fn() {
69
+ // I actually have no idea why a broken promise is required here,
70
+ // but I tried a lot of different options, and this is what worked
71
+ return new Promise(() => {
72
+ if (CLI.repl) {
73
+ CLI.repl.close()
74
+ }
75
+ })
76
+ },
77
+ }
78
+
79
+ const plugins = [
80
+ deleteCmd,
81
+ exportCmd,
82
+ helpCmd,
83
+ importCmd,
84
+ listCmd,
85
+ newCmd,
86
+ playCmd,
87
+ quitCmd,
88
+ selectCmd,
89
+ tryCmd,
90
+ undoCmd,
91
+ versionCmd,
92
+ viewCmd,
93
+ ]
94
+
95
+ const newId = (DATA) => {
96
+ let max = 0
97
+ let min = Number.POSITIVE_INFINITY
98
+ if (DATA.games) {
99
+ for (const id of Object.keys(DATA.games)) {
100
+ max = Math.max(max, Number(id) || 0)
101
+ min = Math.min(min, Number(id) || 0)
102
+ }
103
+ } else {
104
+ DATA.games = {}
105
+ }
106
+
107
+ if (max === 0 || min <= 1) return String(max + 1)
108
+
109
+ return String(min - 1)
110
+ }
111
+
112
+ // Colorize text according to simplistic and not very complete patterns
113
+ const color = (text, def) => {
114
+ let out = ''
115
+ let quote = false
116
+ let id = false
117
+ const colors = [def]
118
+ const c = (t) => tty.color(t, colors[colors.length - 1] || CLI.COLORS.default)
119
+ const short = (t) => tty.color(t, CLI.COLORS.short) + c()
120
+ for (const char of text) {
121
+ switch (char) {
122
+ case '"':
123
+ if (quote) {
124
+ colors.pop()
125
+ } else {
126
+ colors.push(CLI.COLORS.command)
127
+ }
128
+
129
+ out += c('')
130
+ quote = !quote
131
+ break
132
+ case '#':
133
+ id = true
134
+ colors.push(CLI.COLORS.id)
135
+ out += c('#')
136
+ break
137
+ case '|':
138
+ out += short('|')
139
+ break
140
+ case '[':
141
+ colors.push(CLI.COLORS.optional)
142
+ out += short('[')
143
+ break
144
+ case ']':
145
+ colors.pop()
146
+ out += short(']')
147
+ break
148
+ case '<':
149
+ colors.push(CLI.COLORS.variable)
150
+ out += c('')
151
+ break
152
+ case '>':
153
+ colors.pop()
154
+ out += c('')
155
+ break
156
+ default:
157
+ if (id && !/[\da-z]/.test(char)) {
158
+ id = false
159
+ colors.pop()
160
+ out += c(char)
161
+ } else {
162
+ out += char
163
+ }
164
+ }
165
+ }
166
+
167
+ return tty.color(out, def || CLI.COLORS.default)
168
+ }
169
+
170
+ // Internal representations of files
171
+ const CONFIG = { id: 0 }
172
+ const DATA = { games: {} }
173
+
174
+ // CLI context object for plugins
175
+ const CLI = {
176
+ // Quick access to all stored games
177
+ get GAMES() {
178
+ return DATA.games || {}
179
+ },
180
+ // And information about the current game
181
+ get GAME() {
182
+ return (CONFIG.id && DATA.games?.[CONFIG.id]) || {}
183
+ },
184
+ // And the current game ID
185
+ get ID() {
186
+ return CONFIG.id
187
+ },
188
+
189
+ // Provide access to the current Trax object
190
+ TRAX: new Trax(),
191
+
192
+ // Color codes
193
+ COLORS: {
194
+ black: 240,
195
+ command: 33,
196
+ default: 39,
197
+ error: 205,
198
+ fatal: 127,
199
+ help: 74,
200
+ id: 195,
201
+ variable: 123,
202
+ optional: 110,
203
+ short: 153,
204
+ white: 250,
205
+ },
206
+
207
+ // All of the above COLORS will be populated automatically into "color", where
208
+ // they can be called as functions, to apply the given color, eg:
209
+ // CLI.color('hi') and CLI.color.default('hi') are equivalent
210
+ color,
211
+
212
+ cmd(command) {
213
+ // Return a command structure object or an empty object
214
+ if (is(command, 'str') && command in commands) {
215
+ return commands[command]
216
+ }
217
+
218
+ return {}
219
+ },
220
+
221
+ get commands() {
222
+ return Object.keys(commands)
223
+ },
224
+
225
+ delete(id) {
226
+ delete DATA.games[id]
227
+ saveData()
228
+ },
229
+
230
+ do(cmd, ...args) {
231
+ // Call another plugin
232
+ if (cmd in commands) {
233
+ commands[cmd].fn(CLI, ...args)
234
+ } else {
235
+ throw new SyntaxError(`${cmd} is not a valid command`)
236
+ }
237
+ },
238
+
239
+ async doNext(...cmd) {
240
+ // Execute another command line
241
+ await evaluate(cmd.join(' '))
242
+ },
243
+
244
+ error(text) {
245
+ // Prints an error to stdout
246
+ CLI.out(CLI.color.error(text))
247
+ },
248
+
249
+ newGame(rules, players, moves) {
250
+ // Creates a new game
251
+ const id = newId(DATA)
252
+ const trax = new Trax(rules, moves, 'cli')
253
+ DATA.games[id] = {
254
+ id,
255
+ rules: trax.rules,
256
+ name: trax.name,
257
+ turn: trax.turn,
258
+ players,
259
+ moves: trax.moves.join(' '),
260
+ }
261
+
262
+ CONFIG.id = id
263
+ CLI.TRAX = trax
264
+
265
+ saveConfig()
266
+ saveData()
267
+
268
+ return id
269
+ },
270
+
271
+ out(text) {
272
+ // Prints to stdout
273
+ console.info(text + tty.color(''))
274
+ },
275
+
276
+ resolveCommand(word) {
277
+ // Determine if an entered word is a valid command.
278
+ // Returns the command if a single match is found, an array if multiple
279
+ // commands match, or undefined if nothing matches.
280
+ if (is(word, 'str') && word.length > 0) {
281
+ return abbreviations[word.toLowerCase()]
282
+ }
283
+
284
+ return undefined
285
+ },
286
+
287
+ setGame(moves) {
288
+ // Updates the moves for the current game
289
+ const game = CLI.GAME
290
+ const trax = new Trax(game.rules, moves, 'cli')
291
+ if (game.over || trax.over) game.over = trax.over
292
+ game.moves = trax.moves.join(' ')
293
+ game.turn = trax.turn
294
+ CLI.TRAX = trax
295
+
296
+ saveData()
297
+ },
298
+
299
+ updateGameData() {
300
+ const data = DATA.games[CLI.GAME.id]
301
+ const moves = CLI.TRAX.moves.join(' ')
302
+ if (data.moves !== moves) {
303
+ data.moves = moves
304
+ data.turn = CLI.TRAX.turn
305
+ if (CLI.TRAX.over) data.over = true
306
+ saveData()
307
+ }
308
+ },
309
+
310
+ load(id) {
311
+ if (!id) id = Object.keys(DATA.games || {})[0]
312
+ if (id && DATA.games?.[id] && DATA.games[id].rules) {
313
+ if (id !== String(CONFIG.id)) {
314
+ CONFIG.id = id
315
+ saveConfig()
316
+ }
317
+
318
+ const game = CLI.GAME
319
+ if (!game.players) game.players = ['white', 'black']
320
+ CLI.TRAX = new Trax(game.rules, game.moves, 'cli')
321
+ }
322
+ },
323
+
324
+ // Pass through functions
325
+ bubble: tty.bubble,
326
+ display: tty.display,
327
+ name: tty.name,
328
+
329
+ is,
330
+ version,
331
+ }
332
+
333
+ let startRepl = false
334
+
335
+ const getConfig = async () => {
336
+ await getFile('config', 'trax.yaml', CONFIG, YAML.parse)
337
+ return CONFIG
338
+ }
339
+
340
+ const getData = async () => {
341
+ await getFile('data', 'trax.json', DATA, JSON.parse)
342
+ return DATA
343
+ }
344
+
345
+ const saveConfig = () => {
346
+ saveFile('config', 'trax.yaml', YAML.stringify(CONFIG))
347
+ }
348
+
349
+ const saveData = () => {
350
+ saveFile('data', 'trax.json', JSON.stringify(DATA, null, 2) + '\n')
351
+ }
352
+
353
+ const OPTS = {}
354
+
355
+ const parseCommandLine = () => {
356
+ let cmd = ''
357
+ const args = process.argv.slice(2)
358
+ let dash = true
359
+ for (const arg of args) {
360
+ if (dash && arg.startsWith('-')) {
361
+ if (arg === '--') dash = false
362
+ if (arg === '-i' || arg === '--interactive') startRepl = true
363
+ if (arg in OPTS) {
364
+ cmd = OPTS[arg] + ' ' + cmd
365
+ }
366
+ } else {
367
+ cmd += arg + ' '
368
+ }
369
+ }
370
+
371
+ return cmd.slice(0, -1)
372
+ }
373
+
374
+ // Auto-populate the color functions
375
+ for (const [name, value] of Object.entries(CLI.COLORS)) {
376
+ CLI.color[name] = (text) => color(text, value)
377
+ }
378
+
379
+ const forceArray = (item) =>
380
+ is(item, 'arr') ? item : is(item, 'undef') ? [] : [item]
381
+
382
+ const abbreviations = {}
383
+ const commands = {}
384
+ const aliases = []
385
+ const regexes = []
386
+
387
+ const addAbbrev = (abbr, full) => {
388
+ for (let abbrev of abbr) {
389
+ while (abbrev.length > 0) {
390
+ const abbrSet = abbreviations[abbrev] || new Set()
391
+ abbrSet.add(full)
392
+ abbreviations[abbrev] = abbrSet
393
+ abbrev = abbrev.slice(0, -1)
394
+ }
395
+ }
396
+ }
397
+
398
+ const makeComp = (comp) => {
399
+ if (is(comp, 'arr') || is(comp, 'func')) return comp
400
+ if (is(comp, 'str')) return comp.split(' ')
401
+ return []
402
+ }
403
+
404
+ const processPlugins = () => {
405
+ // Process all the plugin command entries
406
+ for (const cmd of plugins) {
407
+ if (cmd.opts) {
408
+ for (const opt of forceArray(cmd.opts)) {
409
+ OPTS[opt] = cmd.name
410
+ }
411
+ }
412
+
413
+ if (cmd.rx) {
414
+ for (const rx of forceArray(cmd.rx)) {
415
+ regexes.push({ rx, name: cmd.name })
416
+ }
417
+ }
418
+
419
+ const entry = {
420
+ alt: forceArray(cmd.alt),
421
+ args: cmd.args || '',
422
+ comp: makeComp(cmd.comp),
423
+ desc: cmd.desc,
424
+ help: forceArray(cmd.help),
425
+ hide: cmd.hide,
426
+ fn: cmd.fn,
427
+ }
428
+ addAbbrev([cmd.name, ...entry.alt], cmd.name)
429
+ commands[cmd.name] = entry
430
+
431
+ aliases.push(cmd.name)
432
+ for (const alias of entry.alt) {
433
+ aliases.push(alias)
434
+ }
435
+ }
436
+
437
+ for (const [abbr, value] of Object.entries(abbreviations)) {
438
+ const newValue = [...value]
439
+ abbreviations[abbr] = value.size > 1 ? newValue.sort() : newValue[0]
440
+ }
441
+ }
442
+
443
+ const expand = (completions, match) => {
444
+ completions = [...completions] // Do not modify in-place
445
+ if (completions.includes('<cmd>')) {
446
+ // All commands
447
+ completions[completions.indexOf('<cmd>')] = aliases.join('|')
448
+ }
449
+
450
+ if (completions.includes('<id>')) {
451
+ // Game numbers
452
+ const ids = []
453
+ for (const game of Object.values(DATA.games || {})) {
454
+ if (game.id) ids.push('#' + game.id)
455
+ }
456
+
457
+ completions[completions.indexOf('<id>')] = ids.join('|')
458
+ }
459
+
460
+ if (completions.includes('<move>')) {
461
+ // Move numbers
462
+ const moves = []
463
+ for (let move = 1; move <= CLI.TRAX.moves.length; move++) {
464
+ moves.push(String(move))
465
+ }
466
+
467
+ completions[completions.indexOf('<move>')] = moves.join('|')
468
+ }
469
+
470
+ if (completions.includes('<play>')) {
471
+ // Moves
472
+ const moves = CLI.TRAX.possibleMoves()
473
+ completions[completions.indexOf('<play>')] = moves.join('|')
474
+ }
475
+
476
+ if (completions.includes('<file>')) {
477
+ // Files ... a bit tricky since we don't want to async in the middle of
478
+ // a completion, but we do want to list all the .trx files in the folder
479
+ // we are looking for ... so we cheat and initiate a search on the first
480
+ // call, and maybe have completions by the second or third call ...
481
+ completions[completions.indexOf('<file>')] = findFiles(match).join('|')
482
+ }
483
+
484
+ return completions
485
+ }
486
+
487
+ const completer = (line) => {
488
+ const words = line.split(/\s+/).filter((w) => w)
489
+
490
+ let match = words.slice(-1)[0] || ''
491
+ const previous = new Set(words.slice(1, -1).map((s) => s.toLowerCase()))
492
+ if (line.endsWith(' ')) {
493
+ previous.add(match.toLowerCase())
494
+ match = ''
495
+ }
496
+
497
+ let completions = []
498
+ const cmd = abbreviations[String(words[0]).toLowerCase()]
499
+
500
+ if (is(cmd, 'str') && (line.endsWith(' ') || words.length > 1)) {
501
+ completions = commands[cmd].comp
502
+ if (is(completions, 'func')) {
503
+ completions = completions(CLI, words.slice(1), aliases)
504
+ }
505
+
506
+ completions = expand(completions, match)
507
+ if (words.length === 1 || (words.length === 2 && !line.endsWith(' '))) {
508
+ completions.push('help', '?')
509
+ }
510
+ } else {
511
+ completions = aliases
512
+ }
513
+
514
+ for (let i = 0; i < completions.length; i++) {
515
+ const word = completions[i]
516
+ if (word.includes('|')) {
517
+ const comps = word.split('|')
518
+ if (comps.some((c) => previous.has(c.toLowerCase()))) {
519
+ completions[i] = ''
520
+ } else {
521
+ completions[i] = comps
522
+ }
523
+ }
524
+ }
525
+
526
+ completions = completions
527
+ .flat()
528
+ .filter((c) => c && !previous.has(c.toLowerCase()))
529
+
530
+ if (match && completions.length > 0) {
531
+ completions = completions.filter((c) => {
532
+ return c.toLowerCase().startsWith(match.toLowerCase())
533
+ })
534
+ }
535
+
536
+ if (!match && completions.length === 1) completions.push(' ')
537
+
538
+ completions = completions.map((c) => c + ' ').sort()
539
+
540
+ return [completions, match]
541
+ }
542
+
543
+ const evaluate = async (command) => {
544
+ const [CMD, ...words] = command.split(/\s+/).filter((c) => c)
545
+ const cmdLower = CMD.toLowerCase()
546
+ CLI.CMD = cmdLower
547
+ const cmd = abbreviations[cmdLower]
548
+ if (is(cmd, 'str')) {
549
+ if (words.length > 0 && abbreviations[words[0].toLowerCase()] === 'help') {
550
+ commands.help.fn(CLI, cmd)
551
+ } else {
552
+ await commands[cmd].fn(CLI, ...words)
553
+ }
554
+ } else if (is(cmd, 'arr')) {
555
+ CLI.error('Ambiguous command. Perhaps you meant one of the following:')
556
+ const maybe = new Set()
557
+
558
+ for (const alias of cmd) {
559
+ if (alias.startsWith(cmdLower)) maybe.add(alias)
560
+
561
+ let alts = commands[alias].alt
562
+ alts = is(alts, 'arr') ? alts : [alts || '']
563
+
564
+ for (const alt of alts) {
565
+ if (alt.startsWith(cmdLower)) maybe.add(alt)
566
+ }
567
+ }
568
+
569
+ if (maybe.size > 0) {
570
+ for (const alias of [...maybe].sort()) {
571
+ tty.outty(CLI.color.command(' ' + alias))
572
+ }
573
+ }
574
+ } else if (CMD) {
575
+ let rxCmd
576
+ for (const regex of regexes) {
577
+ if (regex.rx.test(CMD)) rxCmd = regex.name
578
+ }
579
+
580
+ if (rxCmd) {
581
+ await commands[rxCmd].fn(CLI, CMD, ...words)
582
+ } else {
583
+ CLI.error('Unknown command. Type "help" for a list of valid commands.')
584
+ }
585
+ }
586
+
587
+ return undefined
588
+ }
589
+
590
+ const main = async () => {
591
+ processPlugins()
592
+ const command = parseCommandLine()
593
+ if (is(command, 'str')) {
594
+ await Promise.all([getConfig(), getData()])
595
+ CLI.load(CONFIG.id)
596
+ if (command) await evaluate(command)
597
+ if (startRepl || !command) {
598
+ if (!command) {
599
+ tty.outty(color('Welcome to the Trax CLI v' + version + '.'))
600
+ tty.outty(color('Type "help" for more information.'))
601
+ }
602
+
603
+ CLI.repl = repl.start({
604
+ prompt: 'trax❯ ',
605
+ async eval(cmd, ctx, fn, cb) {
606
+ try {
607
+ await evaluate(cmd)
608
+ } catch (error) {
609
+ tty.outty(error, CLI.COLORS.fatal)
610
+ throw error
611
+ }
612
+
613
+ cb(null, undefined)
614
+ },
615
+ ignoreUndefined: true,
616
+ completer,
617
+ preview: true,
618
+ })
619
+ }
620
+ }
621
+ }
622
+
623
+ main()
@@ -0,0 +1,71 @@
1
+ /* Copyright 2022 Chad Transtrum
2
+ *
3
+ * Licensed under the Apache License, Version 2.0 (the "License");
4
+ * you may not use this file except in compliance with the License.
5
+ * You may obtain a copy of the License at
6
+ *
7
+ * http://www.apache.org/licenses/LICENSE-2.0
8
+ *
9
+ * Unless required by applicable law or agreed to in writing, software
10
+ * distributed under the License is distributed on an "AS IS" BASIS,
11
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ * See the License for the specific language governing permissions and
13
+ * limitations under the License.
14
+ */
15
+
16
+ // CLI delete command
17
+
18
+ export const deleteCmd = {
19
+ name: 'delete',
20
+ alt: ['remove', 'rm'],
21
+ args: '[#id] ["force"]',
22
+ comp: '<id> force',
23
+ desc: 'delete a game',
24
+ help: [
25
+ 'Delete the current game. Or specify a game number to delete that game.',
26
+ 'If the game is in progress, you must type "force" to delete it.',
27
+ ],
28
+ }
29
+
30
+ const selectAnyGame = (CLI) => {
31
+ if (CLI.GAMES) {
32
+ const games = Object.values(CLI.GAMES).sort((a, b) => {
33
+ // Active games first
34
+ if (a.over && !b.over) return 1
35
+ if (b.over && !a.over) return -1
36
+ // Games with more moves first
37
+ return (b.moves?.length || 0) - (a.moves?.length || 0)
38
+ })
39
+ if (games.length > 0) {
40
+ CLI.do('select', String(games[0].id))
41
+ }
42
+ }
43
+ }
44
+
45
+ deleteCmd.fn = (CLI, id, force = 'x') => {
46
+ const current = String(CLI.GAME.id)
47
+ if (!id) id = current
48
+ if ('force'.startsWith(id)) {
49
+ id = current
50
+ force = 'force'
51
+ }
52
+
53
+ if (id.startsWith('#')) id = id.slice(1)
54
+
55
+ const game = CLI.GAMES[id]
56
+
57
+ if (!game || !String(game.id)) {
58
+ return CLI.error('Invalid id. Type "list" to see available games.')
59
+ }
60
+
61
+ if (game.over || game.moves === '' || 'force'.startsWith(force)) {
62
+ CLI.delete(id)
63
+ CLI.out(CLI.color('Deleted game #' + id + '.'))
64
+
65
+ if (id === String(current)) {
66
+ selectAnyGame(CLI)
67
+ }
68
+ } else {
69
+ CLI.error('Game #' + id + ' is still active. Use "force" to delete it.')
70
+ }
71
+ }