@thegetty/quire-cli 1.0.0-rc.39 → 1.0.0-rc.41

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/bin/cli.js CHANGED
@@ -20,7 +20,20 @@ process.removeAllListeners('warning')
20
20
  process.env.QUIRE_LOG_LEVEL = config.get('logLevel') || 'info'
21
21
 
22
22
  /**
23
- * Dynamic import ensures env var is set before logger modules are loaded
23
+ * Set NO_COLOR env var from config before importing CLI modules
24
+ *
25
+ * Chalk 5.x and ora read NO_COLOR at module load time.
26
+ * Setting it here ensures color is disabled before any styled imports.
27
+ * If NO_COLOR is already set in the shell environment, we do not override it.
28
+ *
29
+ * @see https://no-color.org/
30
+ */
31
+ if (process.env.NO_COLOR === undefined && config.get('logUseColor') === false) {
32
+ process.env.NO_COLOR = '1'
33
+ }
34
+
35
+ /**
36
+ * Dynamic import ensures env vars are set before logger modules are loaded
24
37
  */
25
38
  const { default: cli } = await import('#src/main.js')
26
39
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@thegetty/quire-cli",
3
3
  "description": "Quire command-line interface",
4
- "version": "1.0.0-rc.39",
4
+ "version": "1.0.0-rc.41",
5
5
  "author": "Getty Digital",
6
6
  "license": "SEE LICENSE IN https://github.com/thegetty/quire/blob/main/LICENSE",
7
7
  "bugs": {
@@ -7,6 +7,7 @@ import {
7
7
  getDefault,
8
8
  formatSettings,
9
9
  } from '#lib/conf/index.js'
10
+ import { UnknownConfigKeyError, UnknownConfigOperationError } from '#src/errors/index.js'
10
11
 
11
12
  /**
12
13
  * Valid operations for the settings command
@@ -77,16 +78,6 @@ Examples:
77
78
  super(SettingsCommand.definition)
78
79
  }
79
80
 
80
- /**
81
- * Log an unknown key error with valid keys hint
82
- *
83
- * @param {string} key - The unknown configuration key
84
- */
85
- #logUnknownKey(key) {
86
- this.logger.error(`Unknown configuration key: ${key}`)
87
- this.logger.info(`Valid keys: ${getValidKeys().join(', ')}`)
88
- }
89
-
90
81
  /**
91
82
  * Get a single configuration value
92
83
  *
@@ -100,8 +91,7 @@ Examples:
100
91
  }
101
92
 
102
93
  if (!isValidKey(key) && !key.startsWith('__internal__')) {
103
- this.#logUnknownKey(key)
104
- return
94
+ throw new UnknownConfigKeyError(key, getValidKeys())
105
95
  }
106
96
 
107
97
  const value = this.config.get(key)
@@ -131,8 +121,7 @@ Examples:
131
121
  }
132
122
 
133
123
  if (!isValidKey(key)) {
134
- this.#logUnknownKey(key)
135
- return
124
+ throw new UnknownConfigKeyError(key, getValidKeys())
136
125
  }
137
126
 
138
127
  const coercedValue = coerceValue(key, value)
@@ -157,8 +146,7 @@ Examples:
157
146
  }
158
147
 
159
148
  if (!isValidKey(key)) {
160
- this.#logUnknownKey(key)
161
- return
149
+ throw new UnknownConfigKeyError(key, getValidKeys())
162
150
  }
163
151
 
164
152
  this.config.delete(key)
@@ -174,8 +162,7 @@ Examples:
174
162
  #reset(key) {
175
163
  if (key) {
176
164
  if (!isValidKey(key)) {
177
- this.#logUnknownKey(key)
178
- return
165
+ throw new UnknownConfigKeyError(key, getValidKeys())
179
166
  }
180
167
  this.config.reset(key)
181
168
  const defaultValue = getDefault(key)
@@ -205,7 +192,7 @@ Examples:
205
192
  const output = formatSettings(this.config.store, {
206
193
  showInternal: options.debug,
207
194
  configPath: this.config.path,
208
- useColor: this.config.get('logUseColor'),
195
+ useColor: this.config.get('logUseColor') && !process.env.NO_COLOR,
209
196
  })
210
197
  this.logger.info(output)
211
198
  }
@@ -229,9 +216,7 @@ Examples:
229
216
 
230
217
  // Validate operation if provided
231
218
  if (operation && !OPERATIONS.includes(operation)) {
232
- this.logger.error(`Unknown operation: ${operation}`)
233
- this.logger.info(`Valid operations: ${OPERATIONS.join(', ')}`)
234
- return
219
+ throw new UnknownConfigOperationError(operation, [...OPERATIONS])
235
220
  }
236
221
 
237
222
  // Dispatch to operation handler
@@ -200,7 +200,7 @@ test('conf get should show error when key is missing', async (t) => {
200
200
  t.true(mockLogger.error.calledWith('Usage: quire conf get <key>'))
201
201
  })
202
202
 
203
- test('conf get should show error for unknown key', async (t) => {
203
+ test('conf get should throw UnknownConfigKeyError for unknown key', async (t) => {
204
204
  const { sandbox, mockLogger, mockConfig } = t.context
205
205
 
206
206
  const { default: ConfCommand } = await esmock('./config.js', {}, {
@@ -214,12 +214,55 @@ test('conf get should show error for unknown key', async (t) => {
214
214
  command.logger = mockLogger
215
215
  command.debug = sandbox.stub()
216
216
 
217
- await command.action('get', 'unknownKey', undefined, {})
217
+ const error = await t.throwsAsync(
218
+ () => command.action('get', 'unknownKey', undefined, {}),
219
+ { code: 'UNKNOWN_CONFIG_KEY' }
220
+ )
221
+ t.true(error.message.includes('Unknown configuration key: unknownKey'))
222
+ t.true(error.suggestion.includes('Valid keys:'))
223
+ })
218
224
 
219
- t.true(mockLogger.error.calledOnce)
220
- t.true(mockLogger.error.calledWith('Unknown configuration key: unknownKey'))
221
- t.true(mockLogger.info.calledOnce)
222
- t.true(mockLogger.info.firstCall.args[0].includes('Valid keys:'))
225
+ test('conf get should suggest similar key for close misspelling', async (t) => {
226
+ const { sandbox, mockLogger, mockConfig } = t.context
227
+
228
+ const { default: ConfCommand } = await esmock('./config.js', {}, {
229
+ '#lib/logger/index.js': {
230
+ default: () => mockLogger
231
+ }
232
+ })
233
+
234
+ const command = new ConfCommand()
235
+ command.config = mockConfig
236
+ command.logger = mockLogger
237
+ command.debug = sandbox.stub()
238
+
239
+ const error = await t.throwsAsync(
240
+ () => command.action('get', 'logLeve', undefined, {}),
241
+ { code: 'UNKNOWN_CONFIG_KEY' }
242
+ )
243
+ t.true(error.message.includes('Unknown configuration key: logLeve'))
244
+ t.true(error.suggestion.includes('Did you mean: logLevel?'))
245
+ })
246
+
247
+ test('conf get should suggest similar key for case difference', async (t) => {
248
+ const { sandbox, mockLogger, mockConfig } = t.context
249
+
250
+ const { default: ConfCommand } = await esmock('./config.js', {}, {
251
+ '#lib/logger/index.js': {
252
+ default: () => mockLogger
253
+ }
254
+ })
255
+
256
+ const command = new ConfCommand()
257
+ command.config = mockConfig
258
+ command.logger = mockLogger
259
+ command.debug = sandbox.stub()
260
+
261
+ const error = await t.throwsAsync(
262
+ () => command.action('get', 'loglevel', undefined, {}),
263
+ { code: 'UNKNOWN_CONFIG_KEY' }
264
+ )
265
+ t.true(error.suggestion.includes('Did you mean: logLevel?'))
223
266
  })
224
267
 
225
268
  // =============================================================================
@@ -420,7 +463,7 @@ test('conf delete should show error when key is missing', async (t) => {
420
463
  t.false(mockConfig.delete.called)
421
464
  })
422
465
 
423
- test('conf delete should show error for unknown key', async (t) => {
466
+ test('conf delete should throw UnknownConfigKeyError for unknown key', async (t) => {
424
467
  const { sandbox, mockLogger, mockConfig } = t.context
425
468
 
426
469
  const { default: ConfCommand } = await esmock('./config.js', {}, {
@@ -434,9 +477,10 @@ test('conf delete should show error for unknown key', async (t) => {
434
477
  command.logger = mockLogger
435
478
  command.debug = sandbox.stub()
436
479
 
437
- await command.action('delete', 'unknownKey', undefined, {})
438
-
439
- t.true(mockLogger.error.calledOnce)
480
+ await t.throwsAsync(
481
+ () => command.action('delete', 'unknownKey', undefined, {}),
482
+ { code: 'UNKNOWN_CONFIG_KEY' }
483
+ )
440
484
  t.false(mockConfig.delete.called)
441
485
  })
442
486
 
@@ -485,7 +529,7 @@ test('conf reset should reset all config when no key provided', async (t) => {
485
529
  t.true(mockLogger.info.calledWith('Configuration reset to defaults'))
486
530
  })
487
531
 
488
- test('conf reset should show error for unknown key', async (t) => {
532
+ test('conf reset should throw UnknownConfigKeyError for unknown key', async (t) => {
489
533
  const { sandbox, mockLogger, mockConfig } = t.context
490
534
 
491
535
  const { default: ConfCommand } = await esmock('./config.js', {}, {
@@ -499,9 +543,10 @@ test('conf reset should show error for unknown key', async (t) => {
499
543
  command.logger = mockLogger
500
544
  command.debug = sandbox.stub()
501
545
 
502
- await command.action('reset', 'unknownKey', undefined, {})
503
-
504
- t.true(mockLogger.error.calledOnce)
546
+ await t.throwsAsync(
547
+ () => command.action('reset', 'unknownKey', undefined, {}),
548
+ { code: 'UNKNOWN_CONFIG_KEY' }
549
+ )
505
550
  t.false(mockConfig.reset.called)
506
551
  })
507
552
 
@@ -533,7 +578,7 @@ test('conf path should show config file path', async (t) => {
533
578
  // Invalid operation
534
579
  // =============================================================================
535
580
 
536
- test('conf should show error for unknown operation', async (t) => {
581
+ test('conf should throw UnknownConfigOperationError for unknown operation', async (t) => {
537
582
  const { sandbox, mockLogger, mockConfig } = t.context
538
583
 
539
584
  const { default: ConfCommand } = await esmock('./config.js', {}, {
@@ -547,12 +592,56 @@ test('conf should show error for unknown operation', async (t) => {
547
592
  command.logger = mockLogger
548
593
  command.debug = sandbox.stub()
549
594
 
550
- await command.action('unknownOp', undefined, undefined, {})
595
+ const error = await t.throwsAsync(
596
+ () => command.action('unknownOp', undefined, undefined, {}),
597
+ { code: 'UNKNOWN_CONFIG_OPERATION' }
598
+ )
599
+ t.true(error.message.includes('Unknown operation: unknownOp'))
600
+ t.true(error.suggestion.includes('Valid operations:'))
601
+ })
551
602
 
552
- t.true(mockLogger.error.calledOnce)
553
- t.true(mockLogger.error.calledWith('Unknown operation: unknownOp'))
554
- t.true(mockLogger.info.calledOnce)
555
- t.true(mockLogger.info.firstCall.args[0].includes('Valid operations:'))
603
+ test('conf should suggest similar operation for close misspelling', async (t) => {
604
+ const { sandbox, mockLogger, mockConfig } = t.context
605
+
606
+ const { default: ConfCommand } = await esmock('./config.js', {}, {
607
+ '#lib/logger/index.js': {
608
+ default: () => mockLogger
609
+ }
610
+ })
611
+
612
+ const command = new ConfCommand()
613
+ command.config = mockConfig
614
+ command.logger = mockLogger
615
+ command.debug = sandbox.stub()
616
+
617
+ const error = await t.throwsAsync(
618
+ () => command.action('gt', undefined, undefined, {}),
619
+ { code: 'UNKNOWN_CONFIG_OPERATION' }
620
+ )
621
+ t.true(error.message.includes('Unknown operation: gt'))
622
+ t.true(error.suggestion.includes('Did you mean: get?'))
623
+ })
624
+
625
+ test('conf should suggest similar operation for transposition', async (t) => {
626
+ const { sandbox, mockLogger, mockConfig } = t.context
627
+
628
+ const { default: ConfCommand } = await esmock('./config.js', {}, {
629
+ '#lib/logger/index.js': {
630
+ default: () => mockLogger
631
+ }
632
+ })
633
+
634
+ const command = new ConfCommand()
635
+ command.config = mockConfig
636
+ command.logger = mockLogger
637
+ command.debug = sandbox.stub()
638
+
639
+ const error = await t.throwsAsync(
640
+ () => command.action('ste', undefined, undefined, {}),
641
+ { code: 'UNKNOWN_CONFIG_OPERATION' }
642
+ )
643
+ t.true(error.message.includes('Unknown operation: ste'))
644
+ t.true(error.suggestion.includes('Did you mean: set?'))
556
645
  })
557
646
 
558
647
  // =============================================================================
@@ -1,11 +1,18 @@
1
1
  import InvalidInputError from '../input/invalid-input-error.js'
2
- import QuireError from '../quire-error.js'
2
+ import { docsUrl } from '#helpers/docs-url.js'
3
+ import { suggestSimilar, formatSuggestion } from '#helpers/suggest-similar.js'
3
4
 
4
5
  /**
5
6
  * Error thrown when a requested help topic does not exist
7
+ *
8
+ * @param {string} topic - The unrecognized topic name
9
+ * @param {string[]} validTopics - Array of available topic names
6
10
  */
7
11
  export default class HelpTopicNotFoundError extends InvalidInputError {
8
- constructor(topic) {
12
+ constructor(topic, validTopics = []) {
13
+ const suggestion = formatSuggestion(suggestSimilar(topic, validTopics))
14
+ || 'Run "quire help --list" to see available topics'
15
+
9
16
  super(
10
17
  `Unknown help topic: ${topic}`,
11
18
  {
@@ -13,8 +20,9 @@ export default class HelpTopicNotFoundError extends InvalidInputError {
13
20
  exitCode: 2,
14
21
  inputValue: topic,
15
22
  inputName: 'topic',
16
- suggestion: 'Run "quire help --list" to see available topics',
17
- docsUrl: `${QuireError.DOCS_BASE}/quire-commands/`
23
+ suggestion,
24
+ docsUrl: docsUrl('quire-commands/'),
25
+ showDebugHint: false
18
26
  }
19
27
  )
20
28
  this.topic = topic
@@ -45,7 +45,11 @@ export {
45
45
  } from './install/index.js'
46
46
 
47
47
  // Validation errors (exit code: 4)
48
- export { default as ValidationError } from './validation/validation-error.js'
48
+ export {
49
+ ValidationError,
50
+ UnknownConfigKeyError,
51
+ UnknownConfigOperationError
52
+ } from './validation/index.js'
49
53
 
50
54
  // Input errors (exit code: 2)
51
55
  export { InvalidInputError } from './input/index.js'
@@ -0,0 +1,3 @@
1
+ export { default as ValidationError } from './validation-error.js'
2
+ export { default as UnknownConfigKeyError } from './unknown-config-key-error.js'
3
+ export { default as UnknownConfigOperationError } from './unknown-config-operation-error.js'
@@ -0,0 +1,27 @@
1
+ import QuireError from '../quire-error.js'
2
+ import { docsUrl } from '#helpers/docs-url.js'
3
+ import { suggestSimilar, formatSuggestion } from '#helpers/suggest-similar.js'
4
+
5
+ /**
6
+ * Error thrown when an unrecognized configuration key is used
7
+ *
8
+ * @param {string} key - The unrecognized key
9
+ * @param {string[]} validKeys - Array of valid configuration keys
10
+ */
11
+ export default class UnknownConfigKeyError extends QuireError {
12
+ constructor(key, validKeys) {
13
+ const suggestion = formatSuggestion(suggestSimilar(key, validKeys))
14
+ || `Valid keys: ${validKeys.join(', ')}`
15
+
16
+ super(
17
+ `Unknown configuration key: ${key}`,
18
+ {
19
+ code: 'UNKNOWN_CONFIG_KEY',
20
+ exitCode: 4,
21
+ suggestion,
22
+ docsUrl: docsUrl('quire-commands/'),
23
+ showDebugHint: false
24
+ }
25
+ )
26
+ }
27
+ }
@@ -0,0 +1,24 @@
1
+ import QuireError from '../quire-error.js'
2
+ import { docsUrl } from '#helpers/docs-url.js'
3
+ import { suggestSimilar, formatSuggestion } from '#helpers/suggest-similar.js'
4
+
5
+ /**
6
+ * Error thrown when an unrecognized settings operation is used
7
+ */
8
+ export default class UnknownConfigOperationError extends QuireError {
9
+ constructor(operation, validOperations) {
10
+ const suggestion = formatSuggestion(suggestSimilar(operation, validOperations))
11
+ || `Valid operations: ${validOperations.join(', ')}`
12
+
13
+ super(
14
+ `Unknown operation: ${operation}`,
15
+ {
16
+ code: 'UNKNOWN_CONFIG_OPERATION',
17
+ exitCode: 4,
18
+ suggestion,
19
+ docsUrl: docsUrl('quire-commands/'),
20
+ showDebugHint: false
21
+ }
22
+ )
23
+ }
24
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Fuzzy string matching for CLI suggestions
3
+ *
4
+ * Uses Damerau-Levenshtein distance (optimal string alignment variant)
5
+ * to find close matches for misspelled user input. The algorithm and
6
+ * parameters match Commander.js's built-in suggestion implementation
7
+ * for consistency.
8
+ *
9
+ * @module helpers/suggest-similar
10
+ */
11
+
12
+ const MAX_DISTANCE = 3
13
+ const MIN_SIMILARITY = 0.4
14
+
15
+ /**
16
+ * Calculate Damerau-Levenshtein distance between two strings
17
+ *
18
+ * Supports insertions, deletions, substitutions, and transpositions
19
+ * of adjacent characters. Each substring is edited at most once
20
+ * (optimal string alignment variant).
21
+ *
22
+ * @param {string} a - First string
23
+ * @param {string} b - Second string
24
+ * @returns {number} Edit distance
25
+ * @private
26
+ */
27
+ function editDistance(a, b) {
28
+ if (Math.abs(a.length - b.length) > MAX_DISTANCE) {
29
+ return Math.max(a.length, b.length)
30
+ }
31
+
32
+ const d = []
33
+
34
+ for (let i = 0; i <= a.length; ++i) d[i] = [i]
35
+ for (let j = 0; j <= b.length; ++j) d[0][j] = j
36
+
37
+ for (let j = 1; j <= b.length; ++j) {
38
+ for (let i = 1; i <= a.length; ++i) {
39
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1
40
+ d[i][j] = Math.min(
41
+ d[i - 1][j] + 1, // deletion
42
+ d[i][j - 1] + 1, // insertion
43
+ d[i - 1][j - 1] + cost, // substitution
44
+ )
45
+ // transposition
46
+ if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) {
47
+ d[i][j] = Math.min(d[i][j], d[i - 2][j - 2] + 1)
48
+ }
49
+ }
50
+ }
51
+
52
+ return d[a.length][b.length]
53
+ }
54
+
55
+ /**
56
+ * Find similar strings from a list of candidates
57
+ *
58
+ * Returns the closest matches by edit distance, filtered by a minimum
59
+ * similarity threshold (40%). Only returns matches at the best distance
60
+ * found (does not mix distances).
61
+ *
62
+ * @param {string} word - The misspelled input
63
+ * @param {string[]} candidates - Valid values to match against
64
+ * @returns {string[]} Sorted array of best matches (empty if none close enough)
65
+ *
66
+ * @example
67
+ * suggestSimilar('gt', ['get', 'set', 'delete', 'reset', 'path'])
68
+ * // → ['get']
69
+ *
70
+ * @example
71
+ * suggestSimilar('logLeve', ['logLevel', 'logPrefix', 'verbose'])
72
+ * // → ['logLevel']
73
+ */
74
+ export function suggestSimilar(word, candidates) {
75
+ if (!word || !candidates?.length) return []
76
+
77
+ const unique = [...new Set(candidates)]
78
+ let similar = []
79
+ let bestDistance = MAX_DISTANCE
80
+
81
+ for (const candidate of unique) {
82
+ if (candidate.length <= 1) continue
83
+
84
+ const distance = editDistance(word, candidate)
85
+ const length = Math.max(word.length, candidate.length)
86
+ const similarity = (length - distance) / length
87
+
88
+ if (similarity > MIN_SIMILARITY) {
89
+ if (distance < bestDistance) {
90
+ bestDistance = distance
91
+ similar = [candidate]
92
+ } else if (distance === bestDistance) {
93
+ similar.push(candidate)
94
+ }
95
+ }
96
+ }
97
+
98
+ return similar.sort((a, b) => a.localeCompare(b))
99
+ }
100
+
101
+ /**
102
+ * Format suggestion matches into a user-facing string
103
+ *
104
+ * @param {string[]} matches - Array of suggested matches
105
+ * @returns {string|undefined} Formatted suggestion or undefined if no matches
106
+ *
107
+ * @example
108
+ * formatSuggestion(['logLevel'])
109
+ * // → 'Did you mean: logLevel?'
110
+ *
111
+ * @example
112
+ * formatSuggestion(['get', 'set'])
113
+ * // → 'Did you mean one of: get, set?'
114
+ *
115
+ * @example
116
+ * formatSuggestion([])
117
+ * // → undefined
118
+ */
119
+ export function formatSuggestion(matches) {
120
+ if (!matches?.length) return undefined
121
+ return (matches.length === 1)
122
+ ? `Did you mean: ${matches[0]}?`
123
+ : `Did you mean one of: ${matches.join(', ')}?`
124
+ }
@@ -0,0 +1,108 @@
1
+ import test from 'ava'
2
+ import { suggestSimilar, formatSuggestion } from './suggest-similar.js'
3
+
4
+ // ─────────────────────────────────────────────────────────────────────────────
5
+ // suggestSimilar
6
+ // ─────────────────────────────────────────────────────────────────────────────
7
+
8
+ test('suggestSimilar returns match for single-character deletion', (t) => {
9
+ const result = suggestSimilar('gt', ['get', 'set', 'delete', 'reset', 'path'])
10
+ t.deepEqual(result, ['get'])
11
+ })
12
+
13
+ test('suggestSimilar returns match for single-character insertion', (t) => {
14
+ const result = suggestSimilar('geet', ['get', 'set', 'delete'])
15
+ t.deepEqual(result, ['get'])
16
+ })
17
+
18
+ test('suggestSimilar returns match for single-character substitution', (t) => {
19
+ const result = suggestSimilar('gat', ['get', 'set', 'delete'])
20
+ t.deepEqual(result, ['get'])
21
+ })
22
+
23
+ test('suggestSimilar returns match for transposition', (t) => {
24
+ const result = suggestSimilar('gte', ['get', 'set', 'delete'])
25
+ t.deepEqual(result, ['get'])
26
+ })
27
+
28
+ test('suggestSimilar returns match for camelCase near-miss', (t) => {
29
+ const result = suggestSimilar('logLeve', ['logLevel', 'logPrefix', 'verbose'])
30
+ t.deepEqual(result, ['logLevel'])
31
+ })
32
+
33
+ test('suggestSimilar returns match for case difference', (t) => {
34
+ // 'loglevel' vs 'logLevel' differs by one character (L vs l)
35
+ const result = suggestSimilar('loglevel', ['logLevel', 'logPrefix', 'verbose'])
36
+ t.deepEqual(result, ['logLevel'])
37
+ })
38
+
39
+ test('suggestSimilar returns empty array when no candidates are similar', (t) => {
40
+ const result = suggestSimilar('xyzabc', ['get', 'set', 'delete', 'reset', 'path'])
41
+ t.deepEqual(result, [])
42
+ })
43
+
44
+ test('suggestSimilar returns empty array for empty input', (t) => {
45
+ t.deepEqual(suggestSimilar('', ['get', 'set']), [])
46
+ })
47
+
48
+ test('suggestSimilar returns empty array for null input', (t) => {
49
+ t.deepEqual(suggestSimilar(null, ['get', 'set']), [])
50
+ })
51
+
52
+ test('suggestSimilar returns empty array for empty candidates', (t) => {
53
+ t.deepEqual(suggestSimilar('get', []), [])
54
+ })
55
+
56
+ test('suggestSimilar returns empty array for null candidates', (t) => {
57
+ t.deepEqual(suggestSimilar('get', null), [])
58
+ })
59
+
60
+ test('suggestSimilar excludes single-character candidates', (t) => {
61
+ // 'g' is distance 0 from 'g', but should be excluded
62
+ const result = suggestSimilar('g', ['g', 'get', 'set'])
63
+ t.deepEqual(result, [])
64
+ })
65
+
66
+ test('suggestSimilar deduplicates candidates', (t) => {
67
+ const result = suggestSimilar('gt', ['get', 'get', 'get'])
68
+ t.deepEqual(result, ['get'])
69
+ })
70
+
71
+ test('suggestSimilar returns multiple matches sorted alphabetically', (t) => {
72
+ // 'bild' is distance 1 from 'bind' and 'build' — both should match
73
+ // but 'build' is distance 1 (deletion), 'bind' is distance 2 (substitution + deletion)
74
+ // Let's use a case where two candidates have equal distance
75
+ const result = suggestSimilar('delet', ['delete', 'reset'])
76
+ // 'delet' → 'delete' distance 1, 'delet' → 'reset' distance 2
77
+ t.deepEqual(result, ['delete'])
78
+ })
79
+
80
+ test('suggestSimilar returns multiple equally-close matches', (t) => {
81
+ // Both 'bar' and 'baz' are distance 1 from 'bax'
82
+ const result = suggestSimilar('bax', ['bar', 'baz', 'foo'])
83
+ t.deepEqual(result, ['bar', 'baz'])
84
+ })
85
+
86
+ // ─────────────────────────────────────────────────────────────────────────────
87
+ // formatSuggestion
88
+ // ─────────────────────────────────────────────────────────────────────────────
89
+
90
+ test('formatSuggestion returns undefined for empty array', (t) => {
91
+ t.is(formatSuggestion([]), undefined)
92
+ })
93
+
94
+ test('formatSuggestion returns undefined for null', (t) => {
95
+ t.is(formatSuggestion(null), undefined)
96
+ })
97
+
98
+ test('formatSuggestion returns undefined for undefined', (t) => {
99
+ t.is(formatSuggestion(undefined), undefined)
100
+ })
101
+
102
+ test('formatSuggestion formats single match', (t) => {
103
+ t.is(formatSuggestion(['logLevel']), 'Did you mean: logLevel?')
104
+ })
105
+
106
+ test('formatSuggestion formats multiple matches', (t) => {
107
+ t.is(formatSuggestion(['bar', 'baz']), 'Did you mean one of: bar, baz?')
108
+ })
@@ -10,6 +10,8 @@ import { Argument, Option } from 'commander'
10
10
 
11
11
  // Re-export shared option definitions
12
12
  export {
13
+ colorOption,
14
+ noColorOption,
13
15
  quietOption,
14
16
  verboseOption,
15
17
  debugOption,
@@ -124,6 +124,30 @@ export const debugOption = [
124
124
  { conflicts: ['quiet'] }
125
125
  ]
126
126
 
127
+ /**
128
+ * Color options - enable/disable colored output
129
+ *
130
+ * Commander.js requires separate option definitions for --color and --no-color
131
+ * to support both the positive and negative forms. When --color has no default,
132
+ * the three-state semantics are:
133
+ *
134
+ * - `--color` → options.color = true (force color on)
135
+ * - `--no-color` → options.color = false (force color off)
136
+ * - (no flag) → options.color = undefined (falls back to env/config)
137
+ *
138
+ * Respects the NO_COLOR environment variable (https://no-color.org/).
139
+ * Respects config default: `quire settings set logUseColor false`
140
+ *
141
+ * @type {Array[]}
142
+ */
143
+ export const colorOption = [
144
+ '--color', 'force colored output (overrides NO_COLOR env var)',
145
+ ]
146
+
147
+ export const noColorOption = [
148
+ '--no-color', 'disable colored output',
149
+ ]
150
+
127
151
  /**
128
152
  * Standard output mode options (quiet, verbose, debug, progress)
129
153
  *
@@ -1,7 +1,7 @@
1
1
  import test from 'ava'
2
2
  import { Command } from 'commander'
3
3
  import { arrayToOption } from './index.js'
4
- import { quietOption, verboseOption, debugOption } from './options.js'
4
+ import { colorOption, noColorOption, quietOption, verboseOption, debugOption } from './options.js'
5
5
 
6
6
  // ─────────────────────────────────────────────────────────────────────────────
7
7
  // Integration tests for option conflicts
@@ -16,6 +16,8 @@ import { quietOption, verboseOption, debugOption } from './options.js'
16
16
  function createTestProgram() {
17
17
  const program = new Command()
18
18
  program
19
+ .addOption(arrayToOption(colorOption))
20
+ .addOption(arrayToOption(noColorOption))
19
21
  .addOption(arrayToOption(quietOption))
20
22
  .addOption(arrayToOption(verboseOption))
21
23
  .addOption(arrayToOption(debugOption))
@@ -107,3 +109,46 @@ test('options order does not affect conflict detection', (t) => {
107
109
  t.throws(() => program1.parse(['node', 'test', '--quiet', '--verbose']))
108
110
  t.throws(() => program2.parse(['node', 'test', '--verbose', '--quiet']))
109
111
  })
112
+
113
+ // ─────────────────────────────────────────────────────────────────────────────
114
+ // Color option tests
115
+ // ─────────────────────────────────────────────────────────────────────────────
116
+
117
+ test('--no-color sets color to false', (t) => {
118
+ const program = createTestProgram()
119
+ program.parse(['node', 'test', '--no-color'])
120
+ t.is(program.opts().color, false)
121
+ })
122
+
123
+ test('--color sets color to true', (t) => {
124
+ const program = createTestProgram()
125
+ program.parse(['node', 'test', '--color'])
126
+ t.is(program.opts().color, true)
127
+ })
128
+
129
+ test('no color flag leaves color undefined', (t) => {
130
+ const program = createTestProgram()
131
+ program.parse(['node', 'test'])
132
+ t.is(program.opts().color, undefined)
133
+ })
134
+
135
+ test('--no-color can be combined with --verbose', (t) => {
136
+ const program = createTestProgram()
137
+ t.notThrows(() => {
138
+ program.parse(['node', 'test', '--no-color', '--verbose'])
139
+ })
140
+ })
141
+
142
+ test('--no-color can be combined with --quiet', (t) => {
143
+ const program = createTestProgram()
144
+ t.notThrows(() => {
145
+ program.parse(['node', 'test', '--no-color', '--quiet'])
146
+ })
147
+ })
148
+
149
+ test('--no-color can be combined with --debug', (t) => {
150
+ const program = createTestProgram()
151
+ t.notThrows(() => {
152
+ program.parse(['node', 'test', '--no-color', '--debug'])
153
+ })
154
+ })
@@ -59,6 +59,16 @@ async function listTopics() {
59
59
  return topics.sort((a, b) => a.name.localeCompare(b.name))
60
60
  }
61
61
 
62
+ /**
63
+ * Get available topic names from the topics directory
64
+ * @returns {Promise<string[]>} Topic names (filenames without .md)
65
+ */
66
+ async function getTopicNames() {
67
+ if (!await fs.pathExists(TOPICS_DIR)) return []
68
+ const files = await fs.readdir(TOPICS_DIR)
69
+ return files.filter((f) => f.endsWith('.md')).map((f) => path.basename(f, '.md'))
70
+ }
71
+
62
72
  /**
63
73
  * Load a help topic by name
64
74
  * @param {string} name - Topic name (without .md extension)
@@ -69,7 +79,8 @@ async function loadTopic(name) {
69
79
  const filePath = path.join(TOPICS_DIR, `${name}.md`)
70
80
 
71
81
  if (!await fs.pathExists(filePath)) {
72
- throw new HelpTopicNotFoundError(name)
82
+ const validTopics = await getTopicNames()
83
+ throw new HelpTopicNotFoundError(name, validTopics)
73
84
  }
74
85
 
75
86
  const content = await fs.readFile(filePath, 'utf-8')
@@ -134,7 +134,10 @@ test('getTopicContent() throws HelpTopicNotFoundError for missing topic', async
134
134
  const { sandbox } = t.context
135
135
 
136
136
  const mockFs = {
137
- pathExists: sandbox.stub().resolves(false)
137
+ pathExists: sandbox.stub()
138
+ .onFirstCall().resolves(false) // topic file does not exist
139
+ .onSecondCall().resolves(true), // topics directory exists
140
+ readdir: sandbox.stub().resolves(['epub.md', 'pdf.md', 'debugging.md'])
138
141
  }
139
142
 
140
143
  const { getTopicContent } = await esmock('./index.js', {
@@ -150,6 +153,31 @@ test('getTopicContent() throws HelpTopicNotFoundError for missing topic', async
150
153
  t.is(error.topic, 'nonexistent')
151
154
  })
152
155
 
156
+ test('getTopicContent() suggests similar topic for typo', async (t) => {
157
+ const { sandbox } = t.context
158
+
159
+ const mockFs = {
160
+ pathExists: sandbox.stub()
161
+ .onFirstCall().resolves(false)
162
+ .onSecondCall().resolves(true),
163
+ readdir: sandbox.stub().resolves([
164
+ 'configuration.md', 'debugging.md', 'epub.md',
165
+ 'pdf.md', 'publishing.md', 'workflows.md'
166
+ ])
167
+ }
168
+
169
+ const { getTopicContent } = await esmock('./index.js', {
170
+ 'fs-extra': mockFs
171
+ })
172
+
173
+ const error = await t.throwsAsync(
174
+ () => getTopicContent('edub'),
175
+ { name: 'HelpTopicNotFoundError' }
176
+ )
177
+
178
+ t.is(error.suggestion, 'Did you mean: epub?')
179
+ })
180
+
153
181
  test('getTopicsDir() returns the topics directory path', async (t) => {
154
182
  const { getTopicsDir } = await import('./index.js')
155
183
 
@@ -162,7 +162,7 @@ export default function createLogger(name = 'quire', level) {
162
162
  const prefix = config.get('logPrefix')
163
163
  const prefixStyle = config.get('logPrefixStyle')
164
164
  const showLevel = config.get('logShowLevel')
165
- const useColor = config.get('logUseColor')
165
+ const useColor = config.get('logUseColor') && !process.env.NO_COLOR
166
166
  const colorMessages = config.get('logColorMessages')
167
167
 
168
168
  const parts = []
@@ -475,3 +475,104 @@ test.serial('invalid env var value falls back to info', async (t) => {
475
475
  }
476
476
  }
477
477
  })
478
+
479
+ // ─────────────────────────────────────────────────────────────────────────────
480
+ // NO_COLOR environment variable tests
481
+ // ─────────────────────────────────────────────────────────────────────────────
482
+
483
+ test.serial('logger disables color when NO_COLOR env var is set', async (t) => {
484
+ const { sandbox } = t.context
485
+ const consoleErrorStub = sandbox.stub(console, 'error')
486
+ const originalNoColor = process.env.NO_COLOR
487
+
488
+ try {
489
+ process.env.NO_COLOR = '1'
490
+
491
+ const mockConfig = {
492
+ get: sandbox.stub().callsFake((key) => ({
493
+ logPrefix: 'quire',
494
+ logPrefixStyle: 'bracket',
495
+ logShowLevel: true,
496
+ logUseColor: true, // Config says use color, but NO_COLOR overrides
497
+ logColorMessages: true,
498
+ })[key])
499
+ }
500
+
501
+ const { default: createLoggerMocked } = await esmock('./index.js', {
502
+ '#lib/conf/config.js': { default: mockConfig }
503
+ })
504
+
505
+ const log = createLoggerMocked('test:nocolor', 'error')
506
+ log.error('Error message')
507
+
508
+ t.true(consoleErrorStub.calledOnce)
509
+ const output = consoleErrorStub.firstCall.args.join(' ')
510
+ // Should not contain ANSI escape sequences when NO_COLOR is set
511
+ t.false(/\u001b\[/.test(output), 'output should not contain ANSI escape codes')
512
+ // Should still contain the actual text
513
+ t.true(output.includes('[quire]'))
514
+ t.true(output.includes('ERROR'))
515
+ t.true(output.includes('Error message'))
516
+ } finally {
517
+ if (originalNoColor === undefined) {
518
+ delete process.env.NO_COLOR
519
+ } else {
520
+ process.env.NO_COLOR = originalNoColor
521
+ }
522
+ }
523
+ })
524
+
525
+ test.serial('logger uses color when NO_COLOR env var is not set and config enables color', async (t) => {
526
+ const { sandbox } = t.context
527
+ const consoleErrorStub = sandbox.stub(console, 'error')
528
+ const originalNoColor = process.env.NO_COLOR
529
+
530
+ try {
531
+ delete process.env.NO_COLOR
532
+
533
+ const mockConfig = {
534
+ get: sandbox.stub().callsFake((key) => ({
535
+ logPrefix: 'quire',
536
+ logPrefixStyle: 'bracket',
537
+ logShowLevel: true,
538
+ logUseColor: true,
539
+ logColorMessages: true,
540
+ })[key])
541
+ }
542
+
543
+ // Create a mock chalk that always applies ANSI styling regardless of TTY/env
544
+ const ansiRed = (s) => `\u001b[91m${s}\u001b[39m`
545
+ const ansiRedInverse = (s) => `\u001b[91m\u001b[7m${s}\u001b[27m\u001b[39m`
546
+ const ansiBold = (s) => `\u001b[1m${s}\u001b[22m`
547
+ const mockChalk = {
548
+ bold: ansiBold,
549
+ gray: (s) => s,
550
+ yellow: (s) => s,
551
+ magenta: (s) => s,
552
+ red: ansiRed,
553
+ redBright: { inverse: ansiRedInverse },
554
+ 'yellow.inverse': (s) => s,
555
+ }
556
+ // Support chained properties
557
+ mockChalk.yellow.inverse = (s) => s
558
+
559
+ const { default: createLoggerMocked } = await esmock('./index.js', {
560
+ '#lib/conf/config.js': { default: mockConfig },
561
+ 'chalk': { default: mockChalk }
562
+ })
563
+
564
+ const log = createLoggerMocked('test:withcolor', 'error')
565
+ log.error('Error message')
566
+
567
+ t.true(consoleErrorStub.calledOnce)
568
+ const output = consoleErrorStub.firstCall.args.join(' ')
569
+ // Should contain ANSI escape sequences when color is enabled and NO_COLOR not set
570
+ t.true(/\u001b\[/.test(output), 'output should contain ANSI escape codes')
571
+ } finally {
572
+ if (originalNoColor === undefined) {
573
+ delete process.env.NO_COLOR
574
+ } else {
575
+ process.env.NO_COLOR = originalNoColor
576
+ }
577
+ }
578
+ })
package/src/main.js CHANGED
@@ -2,6 +2,8 @@ import { Command, Argument, Option } from 'commander'
2
2
  import {
3
3
  arrayToArgument,
4
4
  arrayToOption,
5
+ colorOption,
6
+ noColorOption,
5
7
  quietOption,
6
8
  verboseOption,
7
9
  debugOption
@@ -34,12 +36,20 @@ Output Modes:
34
36
 
35
37
  Set defaults: quire settings set verbose true
36
38
 
39
+ Color Output:
40
+ --no-color Disable colored output
41
+ --color Force colored output (overrides NO_COLOR env var)
42
+
43
+ Respects NO_COLOR environment variable (https://no-color.org/)
44
+ Set default: quire settings set logUseColor false
45
+
37
46
  Paging:
38
47
  --no-pager Disable paging for long output
39
48
  NO_PAGER=1 Disable paging via environment variable
40
49
  PAGER=cat Traditional Unix alternative (passes output through)
41
50
 
42
51
  Environment Variables:
52
+ NO_COLOR Disable colored output (https://no-color.org/)
43
53
  NO_PAGER=1 Disable paging for long output
44
54
  PAGER=<program> Set pager program (default: less). Use PAGER=cat to disable
45
55
  DEBUG=quire:* Enable debug output for all modules
@@ -50,6 +60,8 @@ Examples:
50
60
  $ quire build Build the publication
51
61
  $ quire build --verbose Build with detailed progress
52
62
  $ quire build --debug Build with debug output
63
+ $ quire build --no-color Build without colored output
64
+ $ NO_COLOR=1 quire build Build without colored output
53
65
  $ DEBUG=quire:* quire pdf Generate PDF with debug output
54
66
  `
55
67
 
@@ -66,6 +78,8 @@ program
66
78
  .name('quire')
67
79
  .description('Quire command-line interface')
68
80
  .version(version, '-V, --version', 'output quire version number')
81
+ .addOption(arrayToOption(colorOption))
82
+ .addOption(arrayToOption(noColorOption))
69
83
  .addOption(arrayToOption(quietOption))
70
84
  .addOption(arrayToOption(verboseOption))
71
85
  .addOption(arrayToOption(debugOption))
@@ -88,6 +102,8 @@ program
88
102
  * - --quiet: Suppress progress spinners (for CI/scripts)
89
103
  * - --verbose: Show detailed progress (paths, timing, steps)
90
104
  * - --debug: Enable DEBUG namespace + tool debug modes (for developers)
105
+ * - --no-color: Disable colored output (sets NO_COLOR env var)
106
+ * - --color: Force colored output (overrides NO_COLOR env var)
91
107
  *
92
108
  * These global options are passed through to commands via opts()
93
109
  * and should be merged with command-level options.
@@ -95,6 +111,16 @@ program
95
111
  program.hook('preAction', (thisCommand) => {
96
112
  const opts = thisCommand.opts()
97
113
 
114
+ // Handle --no-color / --color flag
115
+ // Sets NO_COLOR env var for chalk, ora, and logger to read
116
+ if (opts.color === false) {
117
+ process.env.NO_COLOR = '1'
118
+ delete process.env.FORCE_COLOR
119
+ } else if (opts.color === true) {
120
+ delete process.env.NO_COLOR
121
+ process.env.FORCE_COLOR = '1'
122
+ }
123
+
98
124
  // --debug or config.debug enables the quire:* DEBUG namespace for internal logging
99
125
  // CLI flag takes precedence, then config setting
100
126
  if (opts.debug ?? config.get('debug')) {