@slugbugblue/trax-cli 0.12.1 → 0.14.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.
@@ -1,19 +1,14 @@
1
- /* Copyright 2022-2025 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.
1
+ /** CLI import/export commands
2
+ * @copyright 2022-2026
3
+ * @author Chad Transtrum <chad@transtrum.net>
4
+ * @license Apache-2.0
14
5
  */
15
6
 
16
- // CLI import/export commands
7
+ /**
8
+ * @typedef {import('../cli.js').CLIContext} CLIContext
9
+ * @typedef {import('../cli.js').TraxVariant} TraxVariant
10
+ * @typedef {import('../cli.js').GameNotes} GameNotes
11
+ */
17
12
 
18
13
  import fs from 'node:fs/promises'
19
14
  import process from 'node:process'
@@ -22,14 +17,17 @@ import { Trax } from '@slugbugblue/trax'
22
17
  import { puzzles, sources } from '@slugbugblue/trax-puzzles'
23
18
 
24
19
  const paths = envPaths('trax', { suffix: '' })
20
+ /** @type {Record<string, string>} */
25
21
  const PATHS = {}
26
22
 
27
23
  // Store a copy of the CLI context object locally
24
+ /** @type {Partial<CLIContext>} */
28
25
  let cli = {}
29
26
 
27
+ /** @param {unknown} fsError */
30
28
  const fsOuch = (fsError) => {
31
29
  if (cli.out) {
32
- cli.out(fsError, cli.COLORS.fatal)
30
+ cli.out(String(fsError))
33
31
  } else {
34
32
  console.log(fsError)
35
33
  }
@@ -37,18 +35,23 @@ const fsOuch = (fsError) => {
37
35
  throw fsError
38
36
  }
39
37
 
38
+ /** @param {string} name @returns {string} */
40
39
  const expandPath = (name) => {
41
40
  // Expand home directory ... anything else?
42
- if (name.includes('~')) name = name.replace('~', process.env.HOME)
41
+ if (name.includes('~')) name = name.replace('~', process.env.HOME ?? '~')
43
42
  return name
44
43
  }
45
44
 
46
45
  // Return the actual path on disk, creating it if necessary.
47
46
  // Path is one of cache, config, data, log, temp.
47
+ /** @param {string} path @returns {Promise<string | false>} */
48
48
  const getPath = async (path) => {
49
49
  if (path in PATHS) return PATHS[path]
50
50
  if (path in paths) {
51
- const realPath = paths[path]
51
+ const pathsRecord = /** @type {Record<string, string>} */ (
52
+ /** @type {unknown} */ (paths)
53
+ )
54
+ const realPath = pathsRecord[path]
52
55
  await fs.mkdir(realPath, { recursive: true })
53
56
  PATHS[path] = realPath
54
57
  return realPath
@@ -57,6 +60,7 @@ const getPath = async (path) => {
57
60
  return false
58
61
  }
59
62
 
63
+ /** @returns {string} */
60
64
  const hexy = (length = 6) => {
61
65
  let hex = ''
62
66
  while (hex.length < length) {
@@ -66,6 +70,13 @@ const hexy = (length = 6) => {
66
70
  return hex
67
71
  }
68
72
 
73
+ /**
74
+ * @param {string} filetype
75
+ * @param {string} filename
76
+ * @param {Record<string, unknown>} variable
77
+ * @param {(s: string) => Record<string, unknown>} decode
78
+ * @returns {Promise<Record<string, unknown>>}
79
+ */
69
80
  export const getFile = async (filetype, filename, variable, decode) => {
70
81
  const path = await getPath(filetype)
71
82
  if (path) {
@@ -82,7 +93,9 @@ export const getFile = async (filetype, filename, variable, decode) => {
82
93
  } else {
83
94
  console.log(error)
84
95
  }
85
- } else if (fsError.code !== 'ENOENT') {
96
+ } else if (
97
+ /** @type {NodeJS.ErrnoException} */ (fsError).code !== 'ENOENT'
98
+ ) {
86
99
  fsOuch(fsError)
87
100
  }
88
101
  }
@@ -91,6 +104,12 @@ export const getFile = async (filetype, filename, variable, decode) => {
91
104
  return variable
92
105
  }
93
106
 
107
+ /**
108
+ * @param {string} filetype
109
+ * @param {string} filename
110
+ * @param {string} data
111
+ * @returns {Promise<void>}
112
+ */
94
113
  export const saveFile = async (filetype, filename, data) => {
95
114
  const path = await getPath(filetype)
96
115
  if (path) {
@@ -118,6 +137,61 @@ export const importCmd = {
118
137
  'Load a ".trx" game file into memory. Once imported, it will be assigned',
119
138
  'a game id and automatically selected for future commands.',
120
139
  ],
140
+ /** @param {CLIContext} CLI @param {string} [filename] */
141
+ async fn(CLI, filename) {
142
+ cli = CLI
143
+ if (!filename) {
144
+ CLI.error('Missing filename.')
145
+ return CLI.do('help', 'import')
146
+ }
147
+
148
+ let content
149
+ try {
150
+ content = await fs.readFile(expandPath(filename), 'utf8')
151
+ } catch (fsError) {
152
+ if (
153
+ /** @type {NodeJS.ErrnoException} */ (fsError).code === 'ENOENT' &&
154
+ !filename.endsWith('.trx')
155
+ ) {
156
+ try {
157
+ content = await fs.readFile(expandPath(filename + '.trx'), 'utf8')
158
+ } catch {
159
+ content = null
160
+ }
161
+ }
162
+
163
+ if (!content) return CLI.error(String(fsError))
164
+ }
165
+
166
+ if (!interpretFile(CLI, content)) {
167
+ CLI.error(filename + ' does not appear to be formatted correctly.')
168
+ }
169
+ },
170
+ }
171
+
172
+ /** Generate puzzle attribution comment lines for export.
173
+ * @param {CLIContext['GAME']} game - the current game
174
+ * @returns {string} - formatted attribution string, or empty if no puzzle
175
+ */
176
+ const puzzleAttribution = (game) => {
177
+ if (!game.puzzle) return ''
178
+ const puzzle = puzzles.find((p) => p.id === game.puzzle)
179
+ if (!puzzle) return ''
180
+ const source = sources[puzzle.src]
181
+ let attr = '; Puzzle ' + game.puzzle
182
+ if (source) {
183
+ attr += source.copyright
184
+ ? ' ©' + source.copyright + ' by '
185
+ : ' provided courtesy of '
186
+ attr += source.name
187
+ if (source.url) attr += '\n; ' + source.url
188
+ if (source.license) {
189
+ attr += '\n; Licensed under ' + source.license
190
+ attr += source.licenseUrl ? ' ' + source.licenseUrl : ''
191
+ }
192
+ }
193
+
194
+ return attr + '\n'
121
195
  }
122
196
 
123
197
  export const exportCmd = {
@@ -130,29 +204,102 @@ export const exportCmd = {
130
204
  'Export the current game to an external ".trx" file for safe-keeping or',
131
205
  'to share. Specify a game id to export a different game.',
132
206
  ],
207
+ /** @param {CLIContext} CLI @param {string} [id] @param {string} [filename] */
208
+ async fn(CLI, id, filename) {
209
+ cli = CLI
210
+ let game = CLI.GAME
211
+ if (id) {
212
+ const newid = id.replace(/^#/v, '')
213
+ game = CLI.GAMES[newid]
214
+ if (!game) {
215
+ filename = id
216
+ game = CLI.GAME
217
+ }
218
+ }
219
+
220
+ if (!game || !game.id) {
221
+ return CLI.error('No active game. Type "new" to start a game.')
222
+ }
223
+
224
+ const trax = new Trax(game.rules, game.moves, 'cli')
225
+
226
+ let content = Trax.names[game.rules].replace(' ', '') + '\n'
227
+ content += game.players?.[0] || 'white'
228
+ content += ' vs '
229
+ content += game.players?.[1] || 'black'
230
+ content += '\n; @slugbugblue/trax-cli v' + CLI.version + '\n'
231
+ content += puzzleAttribution(game)
232
+ content += interleaveNotation(trax.moves, game.notes)
233
+
234
+ filename &&= filename.replaceAll(/[^ a-z\d.~\/]/gv, '')
235
+ if (filename) {
236
+ const fname = filename.split('/').pop()
237
+ if (!fname) filename += game.rules + game.id
238
+ if (!fname || !fname.includes('.')) filename += '.trx'
239
+ CLI.out(CLI.color('Exporting #' + game.id + ' to ') + filename)
240
+ try {
241
+ fs.writeFile(expandPath(filename), content)
242
+ } catch (error) {
243
+ fsOuch(error)
244
+ }
245
+ } else {
246
+ CLI.out(content)
247
+ }
248
+ },
133
249
  }
134
250
 
135
251
  /** Interpret the rules string of a .trx file.
136
- * @arg {string} r - rules string in lower case
252
+ * @param {string} r - rules string in lower case
137
253
  * @returns {TraxVariant}
138
254
  */
139
255
  const getRules = (r) =>
140
256
  r.includes('loop') ? 'traxloop' : r.includes('8x8') ? 'trax8' : 'trax'
141
257
 
142
- /** Decode a .trx formatted string into a game, preserving players and comments.
143
- * @arg {any} CLI - the CLI object
144
- * @arg {string} content - the contents of the .trx file
145
- * @returns {boolean} - true if a game was created
258
+ /** Apply pending moves to the game, or record them as notes if the game is over.
259
+ * @param {string[]} moves - pending move tokens (mutated in place)
260
+ * @param {Trax} trax - the current game instance
261
+ * @param {GameNotes} notes - notes accumulator
146
262
  */
147
- const interpretFile = (CLI, content) => {
263
+ const applyMoves = (moves, trax, notes) => {
264
+ if (trax.gameOver) {
265
+ notes.push({ note: moves.map((c) => c.trim()).join(' '), move: trax.move })
266
+ } else {
267
+ trax.playMoves(moves.filter(Boolean))
268
+ moves.splice(0)
269
+ }
270
+ }
271
+
272
+ /** Record a comment segment as a note, unless it is the generator attribution.
273
+ * @param {string[]} comment - comment tokens from after the # or ; delimiter
274
+ * @param {Trax | undefined} trax - the current game instance, if initialized
275
+ * @param {GameNotes} notes - notes accumulator
276
+ */
277
+ const collectComment = (comment, trax, notes) => {
278
+ if (comment.length === 0) return
279
+ const note = comment.map((c) => c.trim()).join(' ')
280
+ if (!/^@slugbugblue\/trax-cli v/v.test(note)) {
281
+ notes.push({ note, move: trax?.move || 0 })
282
+ }
283
+ }
284
+
285
+ /** Parse a .trx formatted string into structured game data.
286
+ * @param {string} content - the contents of the .trx file
287
+ * @returns {{ rules: TraxVariant, players: string[], trax: Trax, notes: GameNotes } | null} - parsed game data, or null if invalid
288
+ */
289
+ const parseFile = (content) => {
290
+ /** @type {TraxVariant | undefined} */
148
291
  let rules
292
+ /** @type {Trax | undefined} */
149
293
  let trax
294
+ /** @type {string[]} */
150
295
  let players = []
296
+ /** @type {string[]} */
151
297
  const moves = []
152
- const comments = []
298
+ /** @type {GameNotes} */
299
+ const notes = []
153
300
  for (const line of content.split('\n')) {
154
301
  if (!line) continue
155
- const [ln, ...comment] = line.split(/[#;]/)
302
+ const [ln, ...comment] = line.split(/[#;]/v)
156
303
  if (ln) {
157
304
  const lower = ln.toLowerCase()
158
305
  if (!rules && lower.includes('trax')) {
@@ -169,33 +316,29 @@ const interpretFile = (CLI, content) => {
169
316
  }
170
317
 
171
318
  if (moves.length > 0) {
172
- if (!trax) return false
173
- if (trax.gameOver) {
174
- comments.push({
175
- note: moves.map((c) => c.trim()).join(' '),
176
- move: trax.move,
177
- })
178
- } else {
179
- trax.playMoves(moves.filter(Boolean))
180
- moves.splice(0)
181
- }
319
+ if (!trax) return null
320
+ applyMoves(moves, trax, notes)
182
321
  }
183
322
 
184
- if (comment.length > 0) {
185
- const note = comment.map((c) => c.trim()).join(' ')
186
- if (!/^@slugbugblue\/trax cli\.js v/.test(note)) {
187
- comments.push({
188
- note,
189
- move: trax?.move || 0,
190
- })
191
- }
192
- }
323
+ collectComment(comment, trax, notes)
193
324
  }
194
325
 
195
- if (!trax) return false
326
+ if (!trax || !rules) return null
327
+ return { rules, players, trax, notes }
328
+ }
329
+
330
+ /** Decode a .trx formatted string into a game, preserving players and comments.
331
+ * @param {CLIContext} CLI - the CLI object
332
+ * @param {string} content - the contents of the .trx file
333
+ * @returns {boolean} - true if a game was created
334
+ */
335
+ const interpretFile = (CLI, content) => {
336
+ const parsed = parseFile(content)
337
+ if (!parsed) return false
196
338
 
339
+ const { rules, players, trax, notes } = parsed
197
340
  CLI.do('new', rules, ...players)
198
- CLI.GAMES[String(CLI.GAME.id)].notes = comments
341
+ CLI.GAMES[String(CLI.GAME.id)].notes = notes
199
342
  if (trax.moves.length > 0) {
200
343
  CLI.do('play', ...trax.moves)
201
344
  } else {
@@ -205,36 +348,9 @@ const interpretFile = (CLI, content) => {
205
348
  return true
206
349
  }
207
350
 
208
- importCmd.fn = async (CLI, filename) => {
209
- cli = CLI
210
- if (!filename) {
211
- CLI.error('Missing filename.')
212
- return CLI.do('help', 'import')
213
- }
214
-
215
- let content
216
- try {
217
- content = await fs.readFile(expandPath(filename), 'utf8')
218
- } catch (fsError) {
219
- if (fsError.code === 'ENOENT' && !filename.endsWith('.trx')) {
220
- try {
221
- content = await fs.readFile(expandPath(filename + '.trx'), 'utf8')
222
- } catch {
223
- content = null
224
- }
225
- }
226
-
227
- if (!content) return CLI.error(fsError.toString())
228
- }
229
-
230
- if (!interpretFile(CLI, content)) {
231
- CLI.error(filename + ' does not appear to be formatted correctly.')
232
- }
233
- }
234
-
235
351
  /** Get all the notes for a certain move.
236
- * @arg {number} move - The move number
237
- * @arg {GameNotes} notes? - The notes for a game
352
+ * @param {number} move - The move number
353
+ * @param {GameNotes} [notes] - The notes for a game
238
354
  * @returns {string} - Notes formatted for this move, or empty string if none
239
355
  */
240
356
  const gameNotes = (move, notes) => {
@@ -248,18 +364,18 @@ const gameNotes = (move, notes) => {
248
364
  )
249
365
  }
250
366
 
251
- /** Join two strings together with a character with the appropriate joining
252
- * character depending on the length of the strings.
253
- * @arg {string} a - the first string
254
- * @arg {string} b - the second string
367
+ /** Join two strings together with the appropriate joining character
368
+ * depending on the combined length of the strings.
369
+ * @param {string} a - the first string
370
+ * @param {string} b - the second string
255
371
  * @returns {string} - both strings correctly joined
256
372
  */
257
373
  const join = (a, b) =>
258
374
  a.length === 0 ? b : a.length + b.length > 78 ? a + '\n' + b : a + ' ' + b
259
375
 
260
376
  /** Interleave the game moves with any notes, formatted for the export file.
261
- * @arg {string[]} moves - an array of move notations
262
- * @arg {GameNotes} notes? - The notes for a game
377
+ * @param {string[]} moves - an array of move notations
378
+ * @param {GameNotes} [notes] - The notes for a game
263
379
  * @returns {string} - A string that can be placed into an export file
264
380
  */
265
381
  const interleaveNotation = (moves, notes) => {
@@ -294,70 +410,10 @@ const interleaveNotation = (moves, notes) => {
294
410
  return content
295
411
  }
296
412
 
297
- exportCmd.fn = async (CLI, id, filename) => {
298
- cli = CLI
299
- let game = CLI.GAME
300
- if (id) {
301
- const newid = id.replace(/^#/, '')
302
- game = CLI.GAMES[newid]
303
- if (!game) {
304
- filename = id
305
- game = CLI.GAME
306
- }
307
- }
308
-
309
- if (!game || !game.id) {
310
- return CLI.error('No active game. Type "new" to start a game.')
311
- }
312
-
313
- const trax = new Trax(game.rules, game.moves, 'cli')
314
-
315
- let content = Trax.names[trax.rules].replace(' ', '') + '\n'
316
- content += game.players?.[0] || 'white'
317
- content += ' vs '
318
- content += game.players?.[1] || 'black'
319
- content += '\n; @slugbugblue/trax cli.js v' + CLI.version + '\n'
320
- if (game.puzzle) {
321
- const puzzle = puzzles.find((p) => p.id === game.puzzle)
322
- if (puzzle) {
323
- const source = sources[puzzle.src]
324
- content += '; Puzzle ' + game.puzzle
325
- if (source) {
326
- content += source.copyright
327
- ? ' ©' + source.copyright + ' by '
328
- : ' provided courtesy of '
329
- content += source.name
330
- if (source.url) content += '\n; ' + source.url
331
- if (source.license) {
332
- content += '\n; Licensed under ' + source.license
333
- content += source.licenseUrl ? ' ' + source.licenseUrl : ''
334
- }
335
- }
336
-
337
- content += '\n'
338
- }
339
- }
340
-
341
- content += interleaveNotation(trax.moves, game.notes)
342
-
343
- filename &&= filename.replaceAll(/[^ a-z\d.~/]/g, '')
344
- if (filename) {
345
- const fname = filename.split('/').pop()
346
- if (!fname) filename += game.rules + game.id
347
- if (!fname.includes('.')) filename += '.trx'
348
- CLI.out(CLI.color('Exporting #' + game.id + ' to ') + filename)
349
- try {
350
- fs.writeFile(expandPath(filename), content)
351
- } catch (error) {
352
- fsOuch(error)
353
- }
354
- } else {
355
- CLI.out(content)
356
- }
357
- }
358
-
413
+ /** @type {Record<string, string[]>} */
359
414
  const FOLDERS = {}
360
415
 
416
+ /** @param {string} [path] @returns {string[]} */
361
417
  export const findFiles = (path) => {
362
418
  if (typeof path !== 'string') path = ''
363
419
  let folder = '.'
@@ -372,7 +428,6 @@ export const findFiles = (path) => {
372
428
  if (!FOLDERS[prefix]) {
373
429
  FOLDERS[prefix] = []
374
430
  fs.readdir(folder, { withFileTypes: true })
375
- // eslint-disable-next-line promise/prefer-await-to-then
376
431
  .then((dir) => {
377
432
  // At this point, everything is happening asynchronously,
378
433
  // so we have to call findFiles at least twice before we
@@ -388,7 +443,6 @@ export const findFiles = (path) => {
388
443
 
389
444
  FOLDERS[prefix] = entries
390
445
  })
391
- // eslint-disable-next-line promise/prefer-await-to-then
392
446
  .catch(() => {
393
447
  // Errors are unimportant and should be silently ignored
394
448
  })
@@ -0,0 +1,40 @@
1
+ /**
2
+ * @copyright 2025-2026
3
+ * @author Chad Transtrum <chad@transtrum.net>
4
+ * @license Apache-2.0
5
+ */
6
+
7
+ // Load all the commands here and put them in a list.
8
+ // This just keeps the main program a tiny bit cleaner.
9
+
10
+ import { analyzeCmd } from './analyze.js'
11
+ import { deleteCmd } from './delete.js'
12
+ import { helpCmd } from './help.js'
13
+ import { importCmd, exportCmd } from './import-export.js'
14
+ import { listCmd } from './list.js'
15
+ import { newCmd } from './new.js'
16
+ import { notesCmd } from './notes.js'
17
+ import { playCmd, tryCmd } from './play-try.js'
18
+ import { puzzlesCmd } from './puzzles.js'
19
+ import { selectCmd } from './select.js'
20
+ import { suggestCmd } from './suggest.js'
21
+ import { undoCmd } from './undo.js'
22
+ import { viewCmd } from './view.js'
23
+
24
+ export const allCmds = [
25
+ analyzeCmd,
26
+ deleteCmd,
27
+ helpCmd,
28
+ importCmd,
29
+ exportCmd,
30
+ listCmd,
31
+ newCmd,
32
+ notesCmd,
33
+ playCmd,
34
+ tryCmd,
35
+ puzzlesCmd,
36
+ selectCmd,
37
+ suggestCmd,
38
+ undoCmd,
39
+ viewCmd,
40
+ ]