@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 +14 -1
- package/package.json +1 -1
- package/src/commands/config.js +7 -22
- package/src/commands/config.test.js +109 -20
- package/src/errors/help/help-topic-not-found-error.js +12 -4
- package/src/errors/index.js +5 -1
- package/src/errors/validation/index.js +3 -0
- package/src/errors/validation/unknown-config-key-error.js +27 -0
- package/src/errors/validation/unknown-config-operation-error.js +24 -0
- package/src/helpers/suggest-similar.js +124 -0
- package/src/helpers/suggest-similar.test.js +108 -0
- package/src/lib/commander/index.js +2 -0
- package/src/lib/commander/options.js +24 -0
- package/src/lib/commander/options.test.js +46 -1
- package/src/lib/help/index.js +12 -1
- package/src/lib/help/index.test.js +29 -1
- package/src/lib/logger/index.js +1 -1
- package/src/lib/logger/index.test.js +101 -0
- package/src/main.js +26 -0
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
|
-
*
|
|
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
package/src/commands/config.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
|
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
|
|
438
|
-
|
|
439
|
-
|
|
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
|
|
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
|
|
503
|
-
|
|
504
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
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
|
|
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
|
|
17
|
-
docsUrl:
|
|
23
|
+
suggestion,
|
|
24
|
+
docsUrl: docsUrl('quire-commands/'),
|
|
25
|
+
showDebugHint: false
|
|
18
26
|
}
|
|
19
27
|
)
|
|
20
28
|
this.topic = topic
|
package/src/errors/index.js
CHANGED
|
@@ -45,7 +45,11 @@ export {
|
|
|
45
45
|
} from './install/index.js'
|
|
46
46
|
|
|
47
47
|
// Validation errors (exit code: 4)
|
|
48
|
-
export {
|
|
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,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
|
+
})
|
|
@@ -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
|
+
})
|
package/src/lib/help/index.js
CHANGED
|
@@ -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
|
-
|
|
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()
|
|
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
|
|
package/src/lib/logger/index.js
CHANGED
|
@@ -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')) {
|