@thegetty/quire-cli 1.0.0-rc.37 → 1.0.0-rc.38

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.
Files changed (85) hide show
  1. package/package.json +3 -2
  2. package/src/commands/build.js +13 -6
  3. package/src/commands/clean.js +10 -2
  4. package/src/commands/clean.spec.js +10 -0
  5. package/src/commands/clean.test.js +122 -0
  6. package/src/commands/config.js +3 -1
  7. package/src/commands/config.test.js +42 -1
  8. package/src/commands/doctor.js +251 -0
  9. package/src/commands/doctor.spec.js +115 -0
  10. package/src/commands/doctor.test.js +1409 -0
  11. package/src/commands/epub.js +9 -2
  12. package/src/commands/info.js +79 -71
  13. package/src/commands/info.spec.js +8 -0
  14. package/src/commands/info.test.js +173 -76
  15. package/src/commands/pdf.js +10 -3
  16. package/src/commands/validate.js +39 -7
  17. package/src/commands/validate.spec.js +8 -0
  18. package/src/commands/validate.test.js +144 -0
  19. package/src/lib/README.md +77 -22
  20. package/src/lib/conf/README.md +6 -0
  21. package/src/lib/conf/build-status.js +103 -0
  22. package/src/lib/conf/build-status.test.js +247 -0
  23. package/src/lib/conf/defaults.js +14 -0
  24. package/src/lib/conf/format.js +1 -1
  25. package/src/lib/conf/schema.js +46 -1
  26. package/src/lib/constants.js +28 -0
  27. package/src/lib/doctor/README.md +667 -0
  28. package/src/lib/doctor/checks/environment/cli-version.js +62 -0
  29. package/src/lib/doctor/checks/environment/cli-version.test.js +132 -0
  30. package/src/lib/doctor/checks/environment/git-available.js +77 -0
  31. package/src/lib/doctor/checks/environment/git-available.test.js +52 -0
  32. package/src/lib/doctor/checks/environment/index.js +13 -0
  33. package/src/lib/doctor/checks/environment/node-version.js +66 -0
  34. package/src/lib/doctor/checks/environment/node-version.test.js +17 -0
  35. package/src/lib/doctor/checks/environment/npm-available.js +66 -0
  36. package/src/lib/doctor/checks/environment/npm-available.test.js +52 -0
  37. package/src/lib/doctor/checks/environment/os-info.js +48 -0
  38. package/src/lib/doctor/checks/environment/os-info.test.js +81 -0
  39. package/src/lib/doctor/checks/environment/runtime-info.js +51 -0
  40. package/src/lib/doctor/checks/environment/runtime-info.test.js +132 -0
  41. package/src/lib/doctor/checks/outputs/epub-output.js +119 -0
  42. package/src/lib/doctor/checks/outputs/epub-output.test.js +277 -0
  43. package/src/lib/doctor/checks/outputs/index.js +10 -0
  44. package/src/lib/doctor/checks/outputs/pdf-output.js +144 -0
  45. package/src/lib/doctor/checks/outputs/pdf-output.test.js +377 -0
  46. package/src/lib/doctor/checks/outputs/stale-build.js +122 -0
  47. package/src/lib/doctor/checks/outputs/stale-build.test.js +282 -0
  48. package/src/lib/doctor/checks/project/data-files.js +56 -0
  49. package/src/lib/doctor/checks/project/data-files.test.js +125 -0
  50. package/src/lib/doctor/checks/project/dependencies.js +53 -0
  51. package/src/lib/doctor/checks/project/dependencies.test.js +71 -0
  52. package/src/lib/doctor/checks/project/index.js +11 -0
  53. package/src/lib/doctor/checks/project/quire-11ty.js +98 -0
  54. package/src/lib/doctor/checks/project/quire-11ty.test.js +170 -0
  55. package/src/lib/doctor/checks/project/quire-project.js +38 -0
  56. package/src/lib/doctor/checks/project/quire-project.test.js +47 -0
  57. package/src/lib/doctor/checks/tools/index.js +10 -0
  58. package/src/lib/doctor/checks/tools/pandoc-available.js +82 -0
  59. package/src/lib/doctor/checks/tools/pandoc-available.test.js +73 -0
  60. package/src/lib/doctor/checks/tools/prince-available.js +81 -0
  61. package/src/lib/doctor/checks/tools/prince-available.test.js +73 -0
  62. package/src/lib/doctor/constants.js +39 -0
  63. package/src/lib/doctor/formatDuration.js +108 -0
  64. package/src/lib/doctor/formatDuration.test.js +76 -0
  65. package/src/lib/doctor/formatters/human.js +257 -0
  66. package/src/lib/doctor/formatters/human.test.js +463 -0
  67. package/src/lib/doctor/formatters/index.js +8 -0
  68. package/src/lib/doctor/formatters/json.js +78 -0
  69. package/src/lib/doctor/formatters/json.test.js +174 -0
  70. package/src/lib/doctor/formatters/shared.js +129 -0
  71. package/src/lib/doctor/formatters/shared.test.js +194 -0
  72. package/src/lib/doctor/index.js +271 -0
  73. package/src/lib/doctor/index.test.js +797 -0
  74. package/src/lib/platform.js +95 -0
  75. package/src/lib/project/build.js +44 -25
  76. package/src/lib/project/build.test.js +30 -8
  77. package/src/lib/project/detect.js +16 -2
  78. package/src/lib/project/index.js +18 -2
  79. package/src/lib/project/output-paths.js +87 -0
  80. package/src/lib/project/output-paths.test.js +66 -0
  81. package/src/lib/project/paths.js +48 -0
  82. package/src/main.js +24 -2
  83. package/src/packageConfig.js +17 -0
  84. package/src/validators/validate-data-files.js +154 -0
  85. package/src/validators/validate-data-files.test.js +217 -0
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.37",
4
+ "version": "1.0.0-rc.38",
5
5
  "author": "Getty Digital",
6
6
  "license": "SEE LICENSE IN https://github.com/thegetty/quire/blob/main/LICENSE",
7
7
  "bugs": {
@@ -78,7 +78,8 @@
78
78
  "read-package-up": "^11.0.0",
79
79
  "semver": "^7.3.8",
80
80
  "update-notifier": "^7.3.1",
81
- "which": "^2.0.2"
81
+ "which": "^2.0.2",
82
+ "wrap-ansi": "^8.1.0"
82
83
  },
83
84
  "devDependencies": {
84
85
  "ava": "^6.2.0",
@@ -4,6 +4,7 @@ import { withOutputModes } from '#lib/commander/index.js'
4
4
  import { api, cli } from '#lib/11ty/index.js'
5
5
  import paths from '#lib/project/index.js'
6
6
  import { clean } from '#helpers/clean.js'
7
+ import { recordStatus } from '#lib/conf/build-status.js'
7
8
  import reporter from '#lib/reporter/index.js'
8
9
  import testcwd from '#helpers/test-cwd.js'
9
10
 
@@ -49,12 +50,18 @@ Note: Run before "quire pdf" or "quire epub" commands.
49
50
  // Configure reporter for this command
50
51
  reporter.configure({ quiet: options.quiet, verbose: options.verbose })
51
52
 
52
- if (options['11ty'] === 'api') {
53
- this.debug('running eleventy using lib/11ty api')
54
- await api.build(options)
55
- } else {
56
- this.debug('running eleventy using lib/11ty cli')
57
- await cli.build(options)
53
+ try {
54
+ if (options['11ty'] === 'api') {
55
+ this.debug('running eleventy using lib/11ty api')
56
+ await api.build(options)
57
+ } else {
58
+ this.debug('running eleventy using lib/11ty cli')
59
+ await cli.build(options)
60
+ }
61
+ recordStatus(paths.getProjectRoot(), 'build', 'ok')
62
+ } catch (error) {
63
+ recordStatus(paths.getProjectRoot(), 'build', 'failed')
64
+ throw error
58
65
  }
59
66
  }
60
67
 
@@ -1,6 +1,7 @@
1
1
  import Command from '#src/Command.js'
2
2
  import { withOutputModes } from '#lib/commander/index.js'
3
3
  import { clean } from '#helpers/clean.js'
4
+ import { clearStatus } from '#lib/conf/build-status.js'
4
5
  import paths from '#lib/project/index.js'
5
6
  import testcwd from '#helpers/test-cwd.js'
6
7
 
@@ -31,6 +32,7 @@ Examples:
31
32
  options: [
32
33
  [ '-d, --dry-run', 'show paths to be cleaned without deleting files' ],
33
34
  [ '--dryrun', 'alias for --dry-run', { hidden: true, implies: { dryRun: true } } ],
35
+ [ '--status', 'also clear stored build status for this project' ],
34
36
  ],
35
37
  })
36
38
 
@@ -41,10 +43,16 @@ Examples:
41
43
  async action(options, command) {
42
44
  this.debug('called with options %O', options)
43
45
 
44
- const deletedPaths = await clean(paths.getProjectRoot(), paths.toObject(), options)
46
+ const projectRoot = paths.getProjectRoot()
47
+ const deletedPaths = await clean(projectRoot, paths.toObject(), options)
48
+
49
+ if (options.status && !options.dryRun) {
50
+ clearStatus(projectRoot)
51
+ this.logger.info('Cleared build status for this project')
52
+ }
45
53
 
46
54
  if (!deletedPaths || !deletedPaths.length) {
47
- this.logger.info('No files to delete')
55
+ if (!options.status) this.logger.info('No files to delete')
48
56
  return
49
57
  }
50
58
 
@@ -42,6 +42,7 @@ test('registered command has correct options', (t) => {
42
42
  // Get all options
43
43
  const dryRunOption = command.options.find((opt) => opt.long === '--dry-run')
44
44
  const dryrunAlias = command.options.find((opt) => opt.long === '--dryrun')
45
+ const statusOption = command.options.find((opt) => opt.long === '--status')
45
46
  const progressOption = command.options.find((opt) => opt.long === '--progress')
46
47
  const quietOption = command.options.find((opt) => opt.long === '--quiet')
47
48
  const verboseOption = command.options.find((opt) => opt.long === '--verbose')
@@ -50,6 +51,7 @@ test('registered command has correct options', (t) => {
50
51
  // Verify all options exist
51
52
  t.truthy(dryRunOption, '--dry-run option should exist')
52
53
  t.truthy(dryrunAlias, '--dryrun alias should exist')
54
+ t.truthy(statusOption, '--status option should exist')
53
55
  t.truthy(progressOption, '--progress option should exist')
54
56
  t.truthy(quietOption, '--quiet option should exist')
55
57
  t.truthy(verboseOption, '--verbose option should exist')
@@ -58,6 +60,7 @@ test('registered command has correct options', (t) => {
58
60
  // Verify they are Option instances
59
61
  t.true(dryRunOption instanceof Option, '--dry-run should be Option instance')
60
62
  t.true(dryrunAlias instanceof Option, '--dryrun should be Option instance')
63
+ t.true(statusOption instanceof Option, '--status should be Option instance')
61
64
  t.true(progressOption instanceof Option, '--progress should be Option instance')
62
65
  t.true(quietOption instanceof Option, '--quiet should be Option instance')
63
66
  t.true(verboseOption instanceof Option, '--verbose should be Option instance')
@@ -73,6 +76,12 @@ test('registered command has correct options', (t) => {
73
76
  t.is(dryrunAlias.long, '--dryrun')
74
77
  t.true(dryrunAlias.hidden, '--dryrun should be hidden from help')
75
78
 
79
+ // --status clears stored build status
80
+ t.is(statusOption.long, '--status')
81
+ t.falsy(statusOption.short, '--status should not have a short flag')
82
+ t.truthy(statusOption.description)
83
+ t.false(statusOption.required, '--status should not require a value')
84
+
76
85
  // --progress is a hidden alias for --verbose
77
86
  t.is(progressOption.long, '--progress')
78
87
  t.truthy(progressOption.description)
@@ -101,6 +110,7 @@ test('command options are accessible via public API', (t) => {
101
110
 
102
111
  t.true(optionNames.includes('--dry-run'))
103
112
  t.true(optionNames.includes('--dryrun'))
113
+ t.true(optionNames.includes('--status'))
104
114
  t.true(optionNames.includes('--progress'))
105
115
  t.true(optionNames.includes('--quiet'))
106
116
  t.true(optionNames.includes('--verbose'))
@@ -258,3 +258,125 @@ test('clean command should pass all options to clean helper', async (t) => {
258
258
 
259
259
  t.true(mockClean.calledWith('/project', { output: '_site' }, options), 'clean should receive all options')
260
260
  })
261
+
262
+ test('clean --status calls clearStatus for the project', async (t) => {
263
+ const { sandbox, fs, mockLogger } = t.context
264
+
265
+ const mockClean = sandbox.stub().resolves(['/project/_site'])
266
+ const mockTestcwd = sandbox.stub()
267
+ const mockClearStatus = sandbox.stub()
268
+
269
+ const CleanCommand = await esmock('./clean.js', {
270
+ '#helpers/clean.js': {
271
+ clean: mockClean
272
+ },
273
+ '#lib/conf/build-status.js': {
274
+ clearStatus: mockClearStatus
275
+ },
276
+ '#lib/project/index.js': {
277
+ default: {
278
+ getProjectRoot: () => '/project',
279
+ toObject: () => ({ output: '_site' })
280
+ }
281
+ },
282
+ '#helpers/test-cwd.js': {
283
+ default: mockTestcwd
284
+ },
285
+ '#lib/logger/index.js': {
286
+ logger: mockLogger
287
+ },
288
+ 'fs-extra': fs
289
+ })
290
+
291
+ const command = new CleanCommand()
292
+ command.name = sandbox.stub().returns('clean')
293
+ command.logger = mockLogger
294
+ command.debug = sandbox.stub()
295
+
296
+ await command.action({ status: true }, command)
297
+
298
+ t.true(mockClearStatus.calledOnce, 'clearStatus should be called once')
299
+ t.true(mockClearStatus.calledWith('/project'), 'clearStatus should be called with project root')
300
+
301
+ const calls = mockLogger.info.args.map((a) => a[0])
302
+ t.true(calls.some((c) => c.includes('Cleared build status')), 'should log status cleared message')
303
+ })
304
+
305
+ test('clean --status does not call clearStatus during dry-run', async (t) => {
306
+ const { sandbox, fs, mockLogger } = t.context
307
+
308
+ const mockClean = sandbox.stub().resolves(['/project/_site'])
309
+ const mockTestcwd = sandbox.stub()
310
+ const mockClearStatus = sandbox.stub()
311
+
312
+ const CleanCommand = await esmock('./clean.js', {
313
+ '#helpers/clean.js': {
314
+ clean: mockClean
315
+ },
316
+ '#lib/conf/build-status.js': {
317
+ clearStatus: mockClearStatus
318
+ },
319
+ '#lib/project/index.js': {
320
+ default: {
321
+ getProjectRoot: () => '/project',
322
+ toObject: () => ({ output: '_site' })
323
+ }
324
+ },
325
+ '#helpers/test-cwd.js': {
326
+ default: mockTestcwd
327
+ },
328
+ '#lib/logger/index.js': {
329
+ logger: mockLogger
330
+ },
331
+ 'fs-extra': fs
332
+ })
333
+
334
+ const command = new CleanCommand()
335
+ command.name = sandbox.stub().returns('clean')
336
+ command.logger = mockLogger
337
+ command.debug = sandbox.stub()
338
+
339
+ await command.action({ status: true, dryRun: true }, command)
340
+
341
+ t.false(mockClearStatus.called, 'clearStatus should not be called during dry-run')
342
+ })
343
+
344
+ test('clean --status with no files still clears status', async (t) => {
345
+ const { sandbox, fs, mockLogger } = t.context
346
+
347
+ const mockClean = sandbox.stub().resolves([])
348
+ const mockTestcwd = sandbox.stub()
349
+ const mockClearStatus = sandbox.stub()
350
+
351
+ const CleanCommand = await esmock('./clean.js', {
352
+ '#helpers/clean.js': {
353
+ clean: mockClean
354
+ },
355
+ '#lib/conf/build-status.js': {
356
+ clearStatus: mockClearStatus
357
+ },
358
+ '#lib/project/index.js': {
359
+ default: {
360
+ getProjectRoot: () => '/project',
361
+ toObject: () => ({ output: '_site' })
362
+ }
363
+ },
364
+ '#helpers/test-cwd.js': {
365
+ default: mockTestcwd
366
+ },
367
+ '#lib/logger/index.js': {
368
+ logger: mockLogger
369
+ },
370
+ 'fs-extra': fs
371
+ })
372
+
373
+ const command = new CleanCommand()
374
+ command.name = sandbox.stub().returns('clean')
375
+ command.logger = mockLogger
376
+ command.debug = sandbox.stub()
377
+
378
+ await command.action({ status: true }, command)
379
+
380
+ t.true(mockClearStatus.calledOnce, 'clearStatus should be called even when no files deleted')
381
+ t.true(mockClearStatus.calledWith('/project'), 'clearStatus should be called with project root')
382
+ })
@@ -194,7 +194,9 @@ Examples:
194
194
  const store = options.debug
195
195
  ? this.config.store
196
196
  : Object.fromEntries(
197
- Object.entries(this.config.store).filter(([k]) => !k.startsWith('__internal__'))
197
+ Object.entries(this.config.store).filter(([k]) =>
198
+ !k.startsWith('__internal__') && k !== 'projects'
199
+ )
198
200
  )
199
201
  console.log(JSON.stringify(store, null, 2))
200
202
  return
@@ -28,7 +28,8 @@ test.beforeEach((t) => {
28
28
  logShowLevel: false,
29
29
  projectTemplate: 'https://github.com/thegetty/quire-starter-default',
30
30
  quireVersion: '1.0.0',
31
- __internal__secretKey: 'hidden-value'
31
+ __internal__secretKey: 'hidden-value',
32
+ projects: { abc123: { projectPath: '/test', buildStatus: { build: { status: 'ok', timestamp: 1000 } } } }
32
33
  },
33
34
  get: (key) => t.context.mockConfig.store[key],
34
35
  set: t.context.sandbox.stub(),
@@ -115,6 +116,46 @@ test('conf command should show internal config values with debug flag', async (t
115
116
  t.true(output.includes('__internal__secretKey: "hidden-value"'))
116
117
  })
117
118
 
119
+ test('conf command should hide projects by default', async (t) => {
120
+ const { sandbox, mockLogger, mockConfig } = t.context
121
+
122
+ const { default: ConfCommand } = await esmock('./config.js', {}, {
123
+ '#lib/logger/index.js': {
124
+ default: () => mockLogger
125
+ }
126
+ })
127
+
128
+ const command = new ConfCommand()
129
+ command.config = mockConfig
130
+ command.logger = mockLogger
131
+ command.debug = sandbox.stub()
132
+
133
+ await command.action(undefined, undefined, undefined, {})
134
+
135
+ const output = mockLogger.info.firstCall.args[0]
136
+ t.false(output.includes('projects'))
137
+ })
138
+
139
+ test('conf command should show projects with debug flag', async (t) => {
140
+ const { sandbox, mockLogger, mockConfig } = t.context
141
+
142
+ const { default: ConfCommand } = await esmock('./config.js', {}, {
143
+ '#lib/logger/index.js': {
144
+ default: () => mockLogger
145
+ }
146
+ })
147
+
148
+ const command = new ConfCommand()
149
+ command.config = mockConfig
150
+ command.logger = mockLogger
151
+ command.debug = sandbox.stub()
152
+
153
+ await command.action(undefined, undefined, undefined, { debug: true })
154
+
155
+ const output = mockLogger.info.firstCall.args[0]
156
+ t.true(output.includes('projects'))
157
+ })
158
+
118
159
  // =============================================================================
119
160
  // Get operation
120
161
  // =============================================================================
@@ -0,0 +1,251 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import Command from '#src/Command.js'
4
+ import { withOutputModes } from '#lib/commander/index.js'
5
+ import { clearStatus } from '#lib/conf/build-status.js'
6
+ import { runAllChecksWithSections, checkSections, SECTION_NAMES, CHECK_IDS } from '#lib/doctor/index.js'
7
+ import { formatHuman } from '#lib/doctor/formatters/human.js'
8
+ import { formatJson } from '#lib/doctor/formatters/json.js'
9
+
10
+ /**
11
+ * Map section name to its check IDs
12
+ */
13
+ const SECTION_CHECK_MAP = Object.fromEntries(
14
+ checkSections.map((s) => [s.name.toLowerCase(), s.checks.map((c) => c.id)])
15
+ )
16
+
17
+ /**
18
+ * Quire CLI `doctor` Command
19
+ *
20
+ * Diagnose common issues with the Quire environment and project setup.
21
+ *
22
+ * @class DoctorCommand
23
+ * @extends {Command}
24
+ */
25
+ export default class DoctorCommand extends Command {
26
+ static definition = withOutputModes({
27
+ name: 'doctor',
28
+ aliases: ['checkup', 'check', 'diagnostic', 'health'],
29
+ description: 'Diagnose common issues with your Quire setup',
30
+ summary: 'check environment and project health',
31
+ docsLink: 'quire-commands/#troubleshooting',
32
+ helpText: `
33
+ Runs diagnostic checks organized into four sections:
34
+ • Environment: os, cli, node, runtime, npm, git
35
+ • Tools: prince, pandoc
36
+ • Project: project, deps, 11ty, data
37
+ • Outputs: build, pdf, epub
38
+
39
+ Examples:
40
+ quire doctor Run all diagnostic checks
41
+ quire doctor all Run all checks (same as no argument)
42
+ quire doctor environment Check environment section only
43
+ quire doctor node Check Node.js version only
44
+ quire doctor node git Check multiple items
45
+ quire doctor --errors Show only failed checks
46
+ quire doctor --warnings Show only warnings
47
+ quire doctor --verbose Show additional details (paths, versions)
48
+ quire doctor --json Output results as JSON (to stdout)
49
+ quire doctor --json report.json Save JSON results to file
50
+ quire doctor --reset Clear stored build status
51
+ quire checkup Alias for doctor command
52
+
53
+ CI/Scripting:
54
+ quire doctor --quiet Exit code only (0=success, 1=failure)
55
+ quire doctor --quiet --json out.json Save JSON report silently
56
+ `,
57
+ version: '1.0.0',
58
+ args: [
59
+ [
60
+ '[checks...]',
61
+ `run specific check(s): all, ${SECTION_NAMES.join(', ')}, or ${CHECK_IDS.join(', ')}`,
62
+ ],
63
+ ],
64
+ options: [
65
+ ['-e, --errors', 'show only failed checks'],
66
+ ['-w, --warnings', 'show only warnings'],
67
+ ['--json [file]', 'output results as JSON (to standard out or a file)'],
68
+ ['--reset', 'clear stored build status for the current project'],
69
+ ],
70
+ })
71
+
72
+ constructor() {
73
+ super(DoctorCommand.definition)
74
+ }
75
+
76
+ async action(checks, options, command) {
77
+ this.debug('called with checks=%O options=%O', checks, options)
78
+
79
+ // --reset: clear stored build status and exit without running checks
80
+ if (options.reset) {
81
+ clearStatus(process.cwd())
82
+ if (!options.quiet) {
83
+ this.logger.info('Build status cleared for this project')
84
+ }
85
+ return
86
+ }
87
+
88
+ // Parse variadic checks argument into sections and individual checks
89
+ const filterOptions = { sections: null, checks: null }
90
+ let label = 'diagnostic checks'
91
+
92
+ if (checks.length > 0) {
93
+ const values = checks.map((v) => v.toLowerCase())
94
+
95
+ // "all" means no filtering
96
+ if (!values.includes('all')) {
97
+ // Separate section names from individual check IDs
98
+ const sectionValues = values.filter((v) => SECTION_NAMES.includes(v))
99
+ const checkValues = values.filter((v) => CHECK_IDS.includes(v))
100
+
101
+ // If we have section names, use them as section filter
102
+ if (sectionValues.length > 0 && checkValues.length === 0) {
103
+ filterOptions.sections = sectionValues
104
+ label = sectionValues.length === 1
105
+ ? `${sectionValues[0]} checks`
106
+ : `${sectionValues.join(', ')} checks`
107
+ } else if (checkValues.length > 0) {
108
+ // If we have individual checks (with or without sections),
109
+ // expand sections to their check IDs and combine
110
+ const expandedChecks = new Set(checkValues)
111
+ for (const section of sectionValues) {
112
+ const sectionCheckIds = SECTION_CHECK_MAP[section] || []
113
+ sectionCheckIds.forEach((id) => expandedChecks.add(id))
114
+ }
115
+ filterOptions.checks = [...expandedChecks]
116
+ label = filterOptions.checks.length === 1
117
+ ? `${filterOptions.checks[0]} check`
118
+ : `${filterOptions.checks.join(', ')} checks`
119
+ }
120
+ }
121
+ }
122
+
123
+ const sections = await runAllChecksWithSections(filterOptions)
124
+
125
+ // JSON output mode (options.json is true for flag-only, or string for file path)
126
+ if (options.json) {
127
+ this.outputJson(sections, options)
128
+ return
129
+ }
130
+
131
+ // Quiet mode without JSON: just exit with appropriate code
132
+ if (options.quiet) {
133
+ this.outputQuiet(sections)
134
+ return
135
+ }
136
+
137
+ // Human-readable output
138
+ this.outputHuman(sections, options, label)
139
+ }
140
+
141
+ /**
142
+ * Quiet mode: no output, just exit code
143
+ * @param {Array} sections - Check results organized by section
144
+ */
145
+ outputQuiet(sections) {
146
+ const { exitCode } = formatJson(sections, {})
147
+ if (exitCode !== 0) {
148
+ process.exitCode = exitCode
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Output results as JSON for programmatic consumption
154
+ * @param {Array} sections - Check results organized by section
155
+ * @param {Object} options - Command options (options.json is true or filename string)
156
+ */
157
+ outputJson(sections, options) {
158
+ const { json, exitCode } = formatJson(sections, {
159
+ errors: options.errors,
160
+ warnings: options.warnings,
161
+ verbose: options.verbose,
162
+ })
163
+
164
+ // Write to file (if string path provided) or stdout (if just --json flag)
165
+ if (typeof options.json === 'string') {
166
+ const outputPath = path.resolve(options.json)
167
+ fs.writeFileSync(outputPath, json + '\n')
168
+ if (!options.quiet) {
169
+ this.logger.info(`Results written to ${outputPath}`)
170
+ }
171
+ } else if (!options.quiet) {
172
+ // Write to stdout (bypassing logger formatting) unless quiet mode
173
+ console.log(json)
174
+ }
175
+
176
+ // Set exit code if any checks failed
177
+ if (exitCode !== 0) {
178
+ process.exitCode = exitCode
179
+ }
180
+ }
181
+
182
+ /**
183
+ * Map log level to console method for direct output
184
+ *
185
+ * Nota bene: doctor output bypasses the logger to avoid the `[quire]`
186
+ * prefix on every line. The formatter already handles its own formatting
187
+ * (icons, indentation, section headers). Using console methods directly
188
+ * routes output to the correct output stream (stdout for info, stderr for
189
+ * warn/error) without logger prefix and level labels.
190
+ */
191
+ static #consoleMethods = {
192
+ info: console.log,
193
+ warn: console.warn,
194
+ error: console.error,
195
+ }
196
+
197
+ /**
198
+ * Output results in human-readable format
199
+ * @param {Array} sections - Check results organized by section
200
+ * @param {Object} options - Command options
201
+ * @param {string} label - Description of checks being run
202
+ */
203
+ outputHuman(sections, options, label) {
204
+ const { lines, summary, key, exitCode, isEmpty } = formatHuman(sections, {
205
+ errors: options.errors,
206
+ warnings: options.warnings,
207
+ verbose: options.verbose,
208
+ label,
209
+ })
210
+
211
+ // Handle case where filters excluded all results
212
+ if (isEmpty) {
213
+ for (const { text, level } of lines) {
214
+ this.#log(level, text)
215
+ }
216
+ return
217
+ }
218
+
219
+ // Output all formatted lines routed by log level
220
+ for (const { text, level } of lines) {
221
+ this.#log(level, text)
222
+ }
223
+
224
+ // Output summary
225
+ if (summary) {
226
+ this.#log(summary.level, summary.text)
227
+ }
228
+
229
+ // Output symbol key
230
+ if (key) {
231
+ this.#log('info', '')
232
+ this.#log(key.level, key.text)
233
+ }
234
+
235
+ // Set exit code if any checks failed
236
+ if (exitCode !== 0) {
237
+ process.exitCode = exitCode
238
+ }
239
+ }
240
+
241
+ /**
242
+ * Write a line to the console, routed by log level
243
+ *
244
+ * @param {string} level - Log level (info, warn, error)
245
+ * @param {string} text - Text to output
246
+ */
247
+ #log(level, text) {
248
+ const method = DoctorCommand.#consoleMethods[level] || console.log
249
+ method(text)
250
+ }
251
+ }