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

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 (104) hide show
  1. package/package.json +5 -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/help.js +60 -0
  13. package/src/commands/help.test.js +132 -0
  14. package/src/commands/info.js +79 -71
  15. package/src/commands/info.spec.js +8 -0
  16. package/src/commands/info.test.js +173 -76
  17. package/src/commands/pdf.js +10 -3
  18. package/src/commands/validate.js +39 -7
  19. package/src/commands/validate.spec.js +8 -0
  20. package/src/commands/validate.test.js +144 -0
  21. package/src/errors/help/help-topic-not-found-error.js +22 -0
  22. package/src/errors/help/index.js +8 -0
  23. package/src/errors/index.js +6 -0
  24. package/src/errors/input/index.js +8 -0
  25. package/src/errors/input/invalid-input-error.js +21 -0
  26. package/src/helpers/pager.js +59 -0
  27. package/src/helpers/pager.test.js +85 -0
  28. package/src/lib/README.md +77 -22
  29. package/src/lib/conf/README.md +6 -0
  30. package/src/lib/conf/build-status.js +103 -0
  31. package/src/lib/conf/build-status.test.js +247 -0
  32. package/src/lib/conf/defaults.js +14 -0
  33. package/src/lib/conf/format.js +1 -1
  34. package/src/lib/conf/schema.js +46 -1
  35. package/src/lib/constants.js +28 -0
  36. package/src/lib/doctor/README.md +667 -0
  37. package/src/lib/doctor/checks/environment/cli-version.js +62 -0
  38. package/src/lib/doctor/checks/environment/cli-version.test.js +132 -0
  39. package/src/lib/doctor/checks/environment/git-available.js +77 -0
  40. package/src/lib/doctor/checks/environment/git-available.test.js +52 -0
  41. package/src/lib/doctor/checks/environment/index.js +13 -0
  42. package/src/lib/doctor/checks/environment/node-version.js +66 -0
  43. package/src/lib/doctor/checks/environment/node-version.test.js +17 -0
  44. package/src/lib/doctor/checks/environment/npm-available.js +66 -0
  45. package/src/lib/doctor/checks/environment/npm-available.test.js +52 -0
  46. package/src/lib/doctor/checks/environment/os-info.js +48 -0
  47. package/src/lib/doctor/checks/environment/os-info.test.js +81 -0
  48. package/src/lib/doctor/checks/environment/runtime-info.js +51 -0
  49. package/src/lib/doctor/checks/environment/runtime-info.test.js +132 -0
  50. package/src/lib/doctor/checks/outputs/epub-output.js +119 -0
  51. package/src/lib/doctor/checks/outputs/epub-output.test.js +277 -0
  52. package/src/lib/doctor/checks/outputs/index.js +10 -0
  53. package/src/lib/doctor/checks/outputs/pdf-output.js +144 -0
  54. package/src/lib/doctor/checks/outputs/pdf-output.test.js +377 -0
  55. package/src/lib/doctor/checks/outputs/stale-build.js +122 -0
  56. package/src/lib/doctor/checks/outputs/stale-build.test.js +282 -0
  57. package/src/lib/doctor/checks/project/data-files.js +56 -0
  58. package/src/lib/doctor/checks/project/data-files.test.js +125 -0
  59. package/src/lib/doctor/checks/project/dependencies.js +53 -0
  60. package/src/lib/doctor/checks/project/dependencies.test.js +71 -0
  61. package/src/lib/doctor/checks/project/index.js +11 -0
  62. package/src/lib/doctor/checks/project/quire-11ty.js +98 -0
  63. package/src/lib/doctor/checks/project/quire-11ty.test.js +170 -0
  64. package/src/lib/doctor/checks/project/quire-project.js +38 -0
  65. package/src/lib/doctor/checks/project/quire-project.test.js +47 -0
  66. package/src/lib/doctor/checks/tools/index.js +10 -0
  67. package/src/lib/doctor/checks/tools/pandoc-available.js +82 -0
  68. package/src/lib/doctor/checks/tools/pandoc-available.test.js +73 -0
  69. package/src/lib/doctor/checks/tools/prince-available.js +81 -0
  70. package/src/lib/doctor/checks/tools/prince-available.test.js +73 -0
  71. package/src/lib/doctor/constants.js +39 -0
  72. package/src/lib/doctor/formatDuration.js +108 -0
  73. package/src/lib/doctor/formatDuration.test.js +76 -0
  74. package/src/lib/doctor/formatters/human.js +257 -0
  75. package/src/lib/doctor/formatters/human.test.js +463 -0
  76. package/src/lib/doctor/formatters/index.js +8 -0
  77. package/src/lib/doctor/formatters/json.js +78 -0
  78. package/src/lib/doctor/formatters/json.test.js +174 -0
  79. package/src/lib/doctor/formatters/shared.js +129 -0
  80. package/src/lib/doctor/formatters/shared.test.js +194 -0
  81. package/src/lib/doctor/index.js +271 -0
  82. package/src/lib/doctor/index.test.js +797 -0
  83. package/src/lib/help/frontmatter.js +77 -0
  84. package/src/lib/help/frontmatter.test.js +139 -0
  85. package/src/lib/help/index.js +135 -0
  86. package/src/lib/help/index.test.js +160 -0
  87. package/src/lib/help/topics/configuration.md +77 -0
  88. package/src/lib/help/topics/debugging.md +69 -0
  89. package/src/lib/help/topics/epub.md +74 -0
  90. package/src/lib/help/topics/pdf.md +74 -0
  91. package/src/lib/help/topics/publishing.md +80 -0
  92. package/src/lib/help/topics/workflows.md +50 -0
  93. package/src/lib/platform.js +95 -0
  94. package/src/lib/project/build.js +44 -25
  95. package/src/lib/project/build.test.js +30 -8
  96. package/src/lib/project/detect.js +16 -2
  97. package/src/lib/project/index.js +18 -2
  98. package/src/lib/project/output-paths.js +87 -0
  99. package/src/lib/project/output-paths.test.js +66 -0
  100. package/src/lib/project/paths.js +48 -0
  101. package/src/main.js +38 -3
  102. package/src/packageConfig.js +17 -0
  103. package/src/validators/validate-data-files.js +154 -0
  104. package/src/validators/validate-data-files.test.js +217 -0
@@ -232,3 +232,147 @@ test('validate command should pass debug option through', async (t) => {
232
232
  t.true(mockYamlValidation.called, 'validation should run')
233
233
  // Debug output should be logged (verified through console.debug stub)
234
234
  })
235
+
236
+ // ─────────────────────────────────────────────────────────────────────────────
237
+ // JSON output tests
238
+ // ─────────────────────────────────────────────────────────────────────────────
239
+
240
+ test.serial('validate --json should output valid JSON when all files pass', async (t) => {
241
+ const { sandbox, fs, mockLogger } = t.context
242
+ const consoleLogStub = sandbox.stub(console, 'log')
243
+
244
+ const mockYamlValidation = sandbox.stub()
245
+ const mockTestcwd = sandbox.stub()
246
+
247
+ const ValidateCommand = await esmock('./validate.js', {
248
+ '../validators/validate-yaml.js': {
249
+ default: mockYamlValidation
250
+ },
251
+ '#lib/project/index.js': {
252
+ default: { getProjectRoot: () => '/project' }
253
+ },
254
+ '#helpers/test-cwd.js': {
255
+ default: mockTestcwd
256
+ },
257
+ '#lib/logger/index.js': {
258
+ logger: mockLogger
259
+ },
260
+ 'fs-extra': fs
261
+ })
262
+
263
+ const command = new ValidateCommand()
264
+ command.name = sandbox.stub().returns('validate')
265
+
266
+ command.action({ json: true }, command)
267
+
268
+ // Should output via console.log, not logger.info
269
+ t.true(consoleLogStub.calledOnce, 'console.log should be called once')
270
+ t.false(mockLogger.info.called, 'logger.info should not be called in JSON mode')
271
+
272
+ const output = JSON.parse(consoleLogStub.firstCall.args[0])
273
+
274
+ // Verify JSON structure
275
+ t.truthy(output.summary, 'JSON should have summary section')
276
+ t.truthy(output.files, 'JSON should have files section')
277
+ t.is(output.summary.files, 2, 'should report 2 YAML files')
278
+ t.is(output.summary.passed, 2, 'both files should pass')
279
+ t.is(output.summary.failed, 0, 'no files should fail')
280
+
281
+ // Verify each file result
282
+ t.true(output.files.every((f) => f.status === 'passed'), 'all files should have passed status')
283
+ })
284
+
285
+ test.serial('validate --json should output valid JSON with errors and throw', async (t) => {
286
+ const { sandbox, fs, mockLogger } = t.context
287
+ const consoleLogStub = sandbox.stub(console, 'log')
288
+
289
+ const validationError = new Error('Invalid YAML')
290
+ validationError.reason = 'Invalid YAML: missing required field'
291
+ const mockYamlValidation = sandbox.stub().throws(validationError)
292
+ const mockTestcwd = sandbox.stub()
293
+
294
+ const ValidateCommand = await esmock('./validate.js', {
295
+ '../validators/validate-yaml.js': {
296
+ default: mockYamlValidation
297
+ },
298
+ '#lib/project/index.js': {
299
+ default: { getProjectRoot: () => '/project' }
300
+ },
301
+ '#helpers/test-cwd.js': {
302
+ default: mockTestcwd
303
+ },
304
+ '#lib/logger/index.js': {
305
+ logger: mockLogger
306
+ },
307
+ 'fs-extra': fs
308
+ })
309
+
310
+ const command = new ValidateCommand()
311
+ command.name = sandbox.stub().returns('validate')
312
+ command.logger = mockLogger
313
+
314
+ // Should still throw ValidationError for exit code
315
+ const error = t.throws(() => command.action({ json: true }, command), { instanceOf: ValidationError })
316
+ t.is(error.code, 'VALIDATION_FAILED')
317
+
318
+ // Should output JSON before throwing
319
+ t.true(consoleLogStub.calledOnce, 'console.log should be called once')
320
+
321
+ const output = JSON.parse(consoleLogStub.firstCall.args[0])
322
+
323
+ t.is(output.summary.files, 2)
324
+ t.is(output.summary.failed, 2, 'both files should fail')
325
+ t.is(output.summary.passed, 0)
326
+
327
+ // Verify error details in file results
328
+ t.true(output.files.every((f) => f.status === 'failed'), 'all files should have failed status')
329
+ t.true(output.files.every((f) => f.error), 'all failed files should have error details')
330
+
331
+ // logger.error should NOT be called in JSON mode
332
+ t.false(mockLogger.error.called, 'logger.error should not be called in JSON mode')
333
+ })
334
+
335
+ test.serial('validate --json should output mixed results', async (t) => {
336
+ const { sandbox, fs, mockLogger } = t.context
337
+ const consoleLogStub = sandbox.stub(console, 'log')
338
+
339
+ // First file passes, second file fails
340
+ const validationError = new Error('Invalid YAML')
341
+ validationError.reason = 'duplicated mapping key at line 5'
342
+ const mockYamlValidation = sandbox.stub()
343
+ mockYamlValidation.onFirstCall().returns(undefined)
344
+ mockYamlValidation.onSecondCall().throws(validationError)
345
+ const mockTestcwd = sandbox.stub()
346
+
347
+ const ValidateCommand = await esmock('./validate.js', {
348
+ '../validators/validate-yaml.js': {
349
+ default: mockYamlValidation
350
+ },
351
+ '#lib/project/index.js': {
352
+ default: { getProjectRoot: () => '/project' }
353
+ },
354
+ '#helpers/test-cwd.js': {
355
+ default: mockTestcwd
356
+ },
357
+ '#lib/logger/index.js': {
358
+ logger: mockLogger
359
+ },
360
+ 'fs-extra': fs
361
+ })
362
+
363
+ const command = new ValidateCommand()
364
+ command.name = sandbox.stub().returns('validate')
365
+ command.logger = mockLogger
366
+
367
+ // Should throw because there are errors
368
+ t.throws(() => command.action({ json: true }, command), { instanceOf: ValidationError })
369
+
370
+ const output = JSON.parse(consoleLogStub.firstCall.args[0])
371
+
372
+ t.is(output.summary.files, 2)
373
+ t.is(output.summary.passed, 1)
374
+ t.is(output.summary.failed, 1)
375
+ t.is(output.files[0].status, 'passed')
376
+ t.is(output.files[1].status, 'failed')
377
+ t.is(output.files[1].error, 'duplicated mapping key at line 5')
378
+ })
@@ -0,0 +1,22 @@
1
+ import InvalidInputError from '../input/invalid-input-error.js'
2
+ import QuireError from '../quire-error.js'
3
+
4
+ /**
5
+ * Error thrown when a requested help topic does not exist
6
+ */
7
+ export default class HelpTopicNotFoundError extends InvalidInputError {
8
+ constructor(topic) {
9
+ super(
10
+ `Unknown help topic: ${topic}`,
11
+ {
12
+ code: 'HELP_TOPIC_NOT_FOUND',
13
+ exitCode: 2,
14
+ inputValue: topic,
15
+ inputName: 'topic',
16
+ suggestion: 'Run "quire help --list" to see available topics',
17
+ docsUrl: `${QuireError.DOCS_BASE}/quire-commands/`
18
+ }
19
+ )
20
+ this.topic = topic
21
+ }
22
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Help errors (exit code: 2)
3
+ *
4
+ * Errors related to the help system and topic lookup.
5
+ *
6
+ * @module errors/help
7
+ */
8
+ export { default as HelpTopicNotFoundError } from './help-topic-not-found-error.js'
@@ -46,3 +46,9 @@ export {
46
46
 
47
47
  // Validation errors (exit code: 4)
48
48
  export { default as ValidationError } from './validation/validation-error.js'
49
+
50
+ // Input errors (exit code: 2)
51
+ export { InvalidInputError } from './input/index.js'
52
+
53
+ // Help errors (exit code: 2)
54
+ export { HelpTopicNotFoundError } from './help/index.js'
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Input errors (exit code: 2)
3
+ *
4
+ * Errors related to invalid user input (arguments, options, values).
5
+ *
6
+ * @module errors/input
7
+ */
8
+ export { default as InvalidInputError } from './invalid-input-error.js'
@@ -0,0 +1,21 @@
1
+ import QuireError from '../quire-error.js'
2
+
3
+ /**
4
+ * Error thrown when user input is invalid
5
+ *
6
+ * Generic error for invalid arguments, options, or other user-provided values.
7
+ * Can be extended for specific input validation scenarios.
8
+ */
9
+ export default class InvalidInputError extends QuireError {
10
+ constructor(message, options = {}) {
11
+ super(message, {
12
+ code: options.code || 'INVALID_INPUT',
13
+ exitCode: options.exitCode ?? 2,
14
+ suggestion: options.suggestion,
15
+ docsUrl: options.docsUrl,
16
+ ...options
17
+ })
18
+ this.inputValue = options.inputValue
19
+ this.inputName = options.inputName
20
+ }
21
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Terminal pager utility
3
+ *
4
+ * Provides paged output for long content in interactive terminals.
5
+ * Paging can be disabled with:
6
+ * - `--no-pager` CLI flag (sets NO_PAGER env var)
7
+ * - `NO_PAGER=1` environment variable
8
+ * - `PAGER=cat` traditional Unix convention (content passes through without paging)
9
+ *
10
+ * @module helpers/pager
11
+ */
12
+ import { spawn } from 'node:child_process'
13
+
14
+ /**
15
+ * Output content with optional paging for long content
16
+ *
17
+ * Uses system pager (less/more) when:
18
+ * - Output is to an interactive terminal (TTY)
19
+ * - Content exceeds terminal height
20
+ * - Paging is not disabled via NO_PAGER env var
21
+ *
22
+ * The PAGER env var selects the pager program (default: less on Unix, more on
23
+ * Windows). Setting PAGER=cat effectively disables paging as a traditional
24
+ * Unix convention.
25
+ *
26
+ * @param {string} content - Content to output
27
+ * @returns {Promise<void>}
28
+ */
29
+ export async function outputWithPaging(content) {
30
+ const noPager = process.env.NO_PAGER
31
+ const lines = content.split('\n').length
32
+ const isTTY = process.stdout.isTTY
33
+ const terminalRows = process.stdout.rows || 24
34
+
35
+ // Use pager if content exceeds terminal height and we're in a TTY
36
+ if (!noPager && isTTY && lines > terminalRows) {
37
+ const pager = process.env.PAGER || (process.platform === 'win32' ? 'more' : 'less')
38
+ const pagerArgs = pager === 'less' ? ['-R'] : [] // -R preserves ANSI colors
39
+
40
+ return new Promise((resolve) => {
41
+ const child = spawn(pager, pagerArgs, {
42
+ stdio: ['pipe', 'inherit', 'inherit']
43
+ })
44
+
45
+ child.stdin.write(content + '\n')
46
+ child.stdin.end()
47
+
48
+ child.on('close', resolve)
49
+ child.on('error', () => {
50
+ // Fallback to direct output if pager fails
51
+ process.stdout.write(content + '\n')
52
+ resolve()
53
+ })
54
+ })
55
+ }
56
+
57
+ // Direct output for non-TTY or short content
58
+ process.stdout.write(content + '\n')
59
+ }
@@ -0,0 +1,85 @@
1
+ import sinon from 'sinon'
2
+ import test from 'ava'
3
+
4
+ /**
5
+ * Pager Helper Tests
6
+ */
7
+
8
+ test.beforeEach((t) => {
9
+ t.context.sandbox = sinon.createSandbox()
10
+ })
11
+
12
+ test.afterEach.always((t) => {
13
+ t.context.sandbox.restore()
14
+ })
15
+
16
+ test.serial('outputWithPaging() writes directly when not a TTY', async (t) => {
17
+ const { sandbox } = t.context
18
+ const originalIsTTY = process.stdout.isTTY
19
+ const stdoutWrite = sandbox.stub(process.stdout, 'write')
20
+
21
+ try {
22
+ // Simulate non-TTY (piped output)
23
+ Object.defineProperty(process.stdout, 'isTTY', { value: false, configurable: true })
24
+
25
+ const { outputWithPaging } = await import('./pager.js')
26
+ await outputWithPaging('Short content')
27
+
28
+ t.true(stdoutWrite.calledWith('Short content\n'))
29
+ } finally {
30
+ Object.defineProperty(process.stdout, 'isTTY', { value: originalIsTTY, configurable: true })
31
+ }
32
+ })
33
+
34
+ test.serial('outputWithPaging() writes directly when content is short', async (t) => {
35
+ const { sandbox } = t.context
36
+ const originalIsTTY = process.stdout.isTTY
37
+ const originalRows = process.stdout.rows
38
+ const stdoutWrite = sandbox.stub(process.stdout, 'write')
39
+
40
+ try {
41
+ // Simulate TTY with 24 rows
42
+ Object.defineProperty(process.stdout, 'isTTY', { value: true, configurable: true })
43
+ Object.defineProperty(process.stdout, 'rows', { value: 24, configurable: true })
44
+
45
+ const { outputWithPaging } = await import('./pager.js')
46
+ await outputWithPaging('Line 1\nLine 2\nLine 3')
47
+
48
+ t.true(stdoutWrite.calledWith('Line 1\nLine 2\nLine 3\n'))
49
+ } finally {
50
+ Object.defineProperty(process.stdout, 'isTTY', { value: originalIsTTY, configurable: true })
51
+ Object.defineProperty(process.stdout, 'rows', { value: originalRows, configurable: true })
52
+ }
53
+ })
54
+
55
+ test.serial('outputWithPaging() writes directly when NO_PAGER is set', async (t) => {
56
+ const { sandbox } = t.context
57
+ const originalIsTTY = process.stdout.isTTY
58
+ const originalRows = process.stdout.rows
59
+ const originalNoPager = process.env.NO_PAGER
60
+ const stdoutWrite = sandbox.stub(process.stdout, 'write')
61
+
62
+ try {
63
+ // Simulate TTY with small terminal (would normally trigger pager)
64
+ Object.defineProperty(process.stdout, 'isTTY', { value: true, configurable: true })
65
+ Object.defineProperty(process.stdout, 'rows', { value: 5, configurable: true })
66
+ process.env.NO_PAGER = '1'
67
+
68
+ const { outputWithPaging } = await import('./pager.js')
69
+
70
+ // Content longer than terminal height
71
+ const longContent = Array.from({ length: 20 }, (_, i) => `Line ${i + 1}`).join('\n')
72
+ await outputWithPaging(longContent)
73
+
74
+ // Should write directly despite long content, because pager is disabled
75
+ t.true(stdoutWrite.calledWith(longContent + '\n'))
76
+ } finally {
77
+ Object.defineProperty(process.stdout, 'isTTY', { value: originalIsTTY, configurable: true })
78
+ Object.defineProperty(process.stdout, 'rows', { value: originalRows, configurable: true })
79
+ if (originalNoPager === undefined) {
80
+ delete process.env.NO_PAGER
81
+ } else {
82
+ process.env.NO_PAGER = originalNoPager
83
+ }
84
+ }
85
+ })
package/src/lib/README.md CHANGED
@@ -5,28 +5,28 @@ This directory contains domain-specific modules that encapsulate the core functi
5
5
  ## Architecture Overview
6
6
 
7
7
  ```
8
- Commands Layer
9
- ┌─────────────────────────────────────────┐
10
- │ build preview new pdf epub info
11
- └──────────────────┬──────────────────────┘
12
-
13
- ┌──────────────────┴──────────────────────┐
14
- lib/ modules
15
- └─────────────────────────────────────────┘
16
-
17
- ┌─────────────────────────────────────────────────────────────────┐
18
- Domain Modules
19
- │ ┌────────────┐ ┌──────────┐ ┌────────────┐ ┌─────────────┐
20
- │ │ project/ │ │ 11ty/ │ │ pdf/ │ │ epub/ │ │
21
- │ │ │ │ │ │ │ │ │ │
22
- │ │ - paths │ │ - api │ │ - pagedjs │ │ - epubjs │ │
23
- │ │ - config │ │ - cli │ │ - prince │ │ - pandoc │ │
24
- │ │ - detect │ │ │ │ │ │ │ │
25
- │ │ - version │ │ │ │ │ │ │ │
26
- │ └────┬───────┘ └────┬─────┘ └────┬───────┘ └──────┬──────┘
27
- └───────┼───────────────┼─────────────┼─────────────────┼─────────┘
28
- │ │ │
29
- ┌────────┴─────────────┴─────────────────┘
8
+ Commands Layer
9
+ ┌───────────────────────────────────────────────────┐
10
+ │ build preview new pdf epub info doctor
11
+ └────────────────────────┬──────────────────────────┘
12
+
13
+ ┌────────────────────────┴──────────────────────────┐
14
+ lib/ modules
15
+ └───────────────────────────────────────────────────┘
16
+
17
+ ┌─────────────────────────────────────────────────────────────────────────┐
18
+ Domain Modules
19
+ │ ┌────────────┐ ┌──────────┐ ┌────────────┐ ┌──────────┐ ┌────────┐
20
+ │ │ project/ │ │ 11ty/ │ │ pdf/ │ │ epub/ │ │doctor/ │ │
21
+ │ │ │ │ │ │ │ │ │ │ │ │
22
+ │ │ - paths │ │ - api │ │ - pagedjs │ │ - epubjs │-checks
23
+ │ │ - config │ │ - cli │ │ - prince │ │ - pandoc │-format
24
+ │ │ - detect │ │ │ │ │ │ │ │ │ │
25
+ │ │ - version │ │ │ │ │ │ │ │ │ │
26
+ │ └────┬───────┘ └────┬─────┘ └────┬───────┘ └────┬─────┘ └───┬────┘
27
+ └───────┼───────────────┼─────────────┼───────────────┼─────────────┼──────┘
28
+ │ │ │
29
+ ┌────────┴─────────────┴───────────────┴─────────────┘
30
30
  │ │
31
31
  ┌───────┴──────┴──────────────────────────────────────────────────┐
32
32
  │ Installation Module │
@@ -65,6 +65,10 @@ These modules encapsulate specific business domains of Quire.
65
65
  |--------|-------------|
66
66
  | `paths` (default) | Singleton for path resolution |
67
67
  | `Paths` | Class for custom path instances |
68
+ | `DATA_DIR` | Path to data files directory (`content/_data`) |
69
+ | `PROJECT_MARKERS` | Files that identify a Quire project |
70
+ | `REQUIRED_DATA_FILES` | Required data files (`publication.yaml`) |
71
+ | `SOURCE_DIRECTORIES` | Directories monitored for changes |
68
72
  | `detect(dirpath)` | Check if directory is a Quire project |
69
73
  | `loadProjectConfig(projectRoot?)` | Load and validate project config |
70
74
  | `getVersion(projectPath?)` | Read `quire-11ty` version from project |
@@ -107,6 +111,57 @@ These modules encapsulate specific business domains of Quire.
107
111
 
108
112
  **Dependencies:** `project/`
109
113
 
114
+ #### `doctor/`
115
+ **Purpose:** Diagnostic checks for Quire environment and project health.
116
+
117
+ | Export | Description |
118
+ |--------|-------------|
119
+ | `default` | Object with all check functions and arrays |
120
+ | `checks` | Flat array of all diagnostic checks |
121
+ | `checkSections` | Checks organized by section (Environment, Project) |
122
+ | `runAllChecks()` | Run all checks, return flat results array |
123
+ | `runAllChecksWithSections()` | Run all checks, return results by section |
124
+ | `checkNodeVersion()` | Verify Node.js >= 22 |
125
+ | `checkNpmAvailable()` | Verify npm in PATH |
126
+ | `checkGitAvailable()` | Verify git in PATH |
127
+ | `checkQuireProject()` | Detect project marker files |
128
+ | `checkDependencies()` | Verify node_modules exists |
129
+ | `checkDataFiles()` | Validate YAML files in content/_data/ |
130
+ | `checkStaleBuild()` | Compare source vs build timestamps |
131
+
132
+ **Sub-modules:**
133
+
134
+ | File | Description |
135
+ |------|-------------|
136
+ | `formatDuration.js` | Human-readable time duration formatting |
137
+
138
+ **Duration Formatting:**
139
+
140
+ The `formatDuration` function converts milliseconds to the most appropriate time unit:
141
+
142
+ | Duration | Example Output |
143
+ |----------------|-----------------|
144
+ | < 1 minute | "45 seconds" |
145
+ | < 1 hour | "30 minutes" |
146
+ | < 1 day | "5 hours" |
147
+ | < 1 week | "3 days" |
148
+ | < 1 month | "2 weeks" |
149
+ | < 1 year | "3 months" |
150
+ | >= 1 year | "2 years" |
151
+
152
+ **Data Files Validation:**
153
+
154
+ The `checkDataFiles` function validates YAML files in `content/_data/`:
155
+
156
+ | Validation | Description |
157
+ |------------|-------------|
158
+ | Required files | Checks `publication.yaml` exists |
159
+ | YAML syntax | Parses each file and reports syntax errors |
160
+ | Schema validation | Validates against JSON schemas in `schemas/` |
161
+ | Duplicate IDs | Detects duplicate `id` values in arrays |
162
+
163
+ **Dependencies:** `project/`, `npm/`, `git/`, `validators/validate-data-files`
164
+
110
165
  #### `installer/`
111
166
  **Purpose:** Installation of `@thegetty/quire-11ty` into Quire projects.
112
167
 
@@ -93,6 +93,12 @@ quire settings get <key> --json # Output single value as JSON
93
93
  quire settings --json --debug # Include __internal__ keys in JSON output
94
94
  ```
95
95
 
96
+ `staleThreshold` How stale an output must be before `quire doctor` warns; default `'HOURLY'`. Options: `'ZERO'` (0 min), `'SHORT'` (5 min), `'HOURLY'` (60 min), `'DAILY'` (12 hours), `'NEVER'` (disabled).
97
+
98
+ ```sh
99
+ ❯ quire config set staleThreshold DAILY
100
+ ```
101
+
96
102
  The `--json` flag outputs raw JSON to stdout (bypassing the logger), suitable for piping to `jq` or other tools. `__internal__` keys are excluded from JSON output unless `--debug` is also passed, consistent with the plain-text display.
97
103
 
98
104
  | Key | Type | Default | Description |
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Per-project build status persistence
3
+ *
4
+ * Records command outcomes (ok/failed) for build, pdf, and epub commands
5
+ * so that `quire doctor` can distinguish "never ran" from "ran and failed"
6
+ * when no output files exist on disk.
7
+ *
8
+ * Status is stored in the global CLI config under the `projects` key,
9
+ * keyed by a SHA-256 hash of the project's absolute path to avoid
10
+ * dot-notation traversal issues.
11
+ *
12
+ * @module lib/conf/build-status
13
+ */
14
+ import { createHash } from 'node:crypto'
15
+ import config from '#lib/conf/config.js'
16
+ import createDebug from '#debug'
17
+
18
+ const debug = createDebug('lib:conf:build-status')
19
+
20
+ /**
21
+ * Commands whose status is tracked
22
+ * @type {readonly string[]}
23
+ */
24
+ export const TRACKED_COMMANDS = Object.freeze(['build', 'pdf', 'epub'])
25
+
26
+ /**
27
+ * Derive a fixed-length, dot-safe config key from a project path
28
+ *
29
+ * @param {string} projectPath - Absolute path to the project directory
30
+ * @returns {string} First 12 hex characters of the SHA-256 hash
31
+ */
32
+ export function projectKey(projectPath) {
33
+ return createHash('sha256').update(projectPath).digest('hex').slice(0, 12)
34
+ }
35
+
36
+ /**
37
+ * Record the outcome of a command for a project
38
+ *
39
+ * @param {string} projectPath - Absolute path to the project directory
40
+ * @param {string} command - Command name ('build', 'pdf', or 'epub')
41
+ * @param {'ok'|'failed'} status - Outcome of the command
42
+ */
43
+ export function recordStatus(projectPath, command, status) {
44
+ if (!TRACKED_COMMANDS.includes(command)) {
45
+ debug('ignoring untracked command: %s', command)
46
+ return
47
+ }
48
+
49
+ const key = projectKey(projectPath)
50
+ const projects = config.get('projects') || {}
51
+
52
+ const entry = projects[key] || { projectPath }
53
+ entry.projectPath = projectPath
54
+ entry.buildStatus = {
55
+ ...entry.buildStatus,
56
+ [command]: {
57
+ status,
58
+ timestamp: Date.now(),
59
+ },
60
+ }
61
+ projects[key] = entry
62
+
63
+ config.set('projects', projects)
64
+ debug('recorded %s=%s for %s (key=%s)', command, status, projectPath, key)
65
+ }
66
+
67
+ /**
68
+ * Retrieve the stored status for a command in a project
69
+ *
70
+ * @param {string} projectPath - Absolute path to the project directory
71
+ * @param {string} command - Command name ('build', 'pdf', or 'epub')
72
+ * @returns {{ status: 'ok'|'failed', timestamp: number } | undefined}
73
+ */
74
+ export function getStatus(projectPath, command) {
75
+ const key = projectKey(projectPath)
76
+ const projects = config.get('projects') || {}
77
+ const entry = projects[key]
78
+
79
+ if (!entry || !entry.buildStatus) {
80
+ debug('no status entry for key=%s', key)
81
+ return undefined
82
+ }
83
+
84
+ return entry.buildStatus[command]
85
+ }
86
+
87
+ /**
88
+ * Clear all stored status for a project
89
+ *
90
+ * @param {string} projectPath - Absolute path to the project directory
91
+ */
92
+ export function clearStatus(projectPath) {
93
+ const key = projectKey(projectPath)
94
+ const projects = config.get('projects') || {}
95
+ const entry = projects[key]
96
+
97
+ if (entry && entry.buildStatus) {
98
+ delete entry.buildStatus
99
+ projects[key] = entry
100
+ config.set('projects', projects)
101
+ debug('cleared build status for %s (key=%s)', projectPath, key)
102
+ }
103
+ }