@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
@@ -0,0 +1,247 @@
1
+ import esmock from 'esmock'
2
+ import sinon from 'sinon'
3
+ import test from 'ava'
4
+
5
+ /**
6
+ * Build Status Module Tests
7
+ *
8
+ * Tests the per-project build status persistence API.
9
+ * Mocks the config module with a simple in-memory store.
10
+ */
11
+
12
+ test.beforeEach(async (t) => {
13
+ const sandbox = sinon.createSandbox()
14
+
15
+ // In-memory store that mimics config.get/set for 'projects'
16
+ const store = {}
17
+ const configMock = {
18
+ default: {
19
+ get: sandbox.stub().callsFake((key) => store[key]),
20
+ set: sandbox.stub().callsFake((key, value) => { store[key] = value }),
21
+ },
22
+ }
23
+
24
+ const mod = await esmock('./build-status.js', {
25
+ '#lib/conf/config.js': configMock,
26
+ })
27
+
28
+ t.context = { sandbox, store, configMock, ...mod }
29
+ })
30
+
31
+ test.afterEach.always((t) => {
32
+ t.context.sandbox.restore()
33
+ })
34
+
35
+ // ─────────────────────────────────────────────────────────────────────────────
36
+ // projectKey
37
+ // ─────────────────────────────────────────────────────────────────────────────
38
+
39
+ test('projectKey returns a 12-character hex string', (t) => {
40
+ const { projectKey } = t.context
41
+ const key = projectKey('/Users/test/my-project')
42
+ t.is(key.length, 12)
43
+ t.regex(key, /^[0-9a-f]{12}$/)
44
+ })
45
+
46
+ test('projectKey returns the same key for the same path', (t) => {
47
+ const { projectKey } = t.context
48
+ const key1 = projectKey('/Users/test/my-project')
49
+ const key2 = projectKey('/Users/test/my-project')
50
+ t.is(key1, key2)
51
+ })
52
+
53
+ test('projectKey returns different keys for different paths', (t) => {
54
+ const { projectKey } = t.context
55
+ const key1 = projectKey('/Users/test/project-a')
56
+ const key2 = projectKey('/Users/test/project-b')
57
+ t.not(key1, key2)
58
+ })
59
+
60
+ // ─────────────────────────────────────────────────────────────────────────────
61
+ // recordStatus
62
+ // ─────────────────────────────────────────────────────────────────────────────
63
+
64
+ test('recordStatus writes status with timestamp to config', (t) => {
65
+ const { recordStatus, projectKey, configMock } = t.context
66
+ const projectPath = '/Users/test/my-project'
67
+
68
+ recordStatus(projectPath, 'build', 'ok')
69
+
70
+ t.true(configMock.default.set.calledOnce)
71
+ const [setKey, setValue] = configMock.default.set.firstCall.args
72
+ t.is(setKey, 'projects')
73
+
74
+ const key = projectKey(projectPath)
75
+ const entry = setValue[key]
76
+ t.is(entry.projectPath, projectPath)
77
+ t.is(entry.buildStatus.build.status, 'ok')
78
+ t.is(typeof entry.buildStatus.build.timestamp, 'number')
79
+ })
80
+
81
+ test('recordStatus records failed status', (t) => {
82
+ const { recordStatus, projectKey, configMock } = t.context
83
+ const projectPath = '/Users/test/my-project'
84
+
85
+ recordStatus(projectPath, 'pdf', 'failed')
86
+
87
+ const [, setValue] = configMock.default.set.firstCall.args
88
+ const key = projectKey(projectPath)
89
+ t.is(setValue[key].buildStatus.pdf.status, 'failed')
90
+ })
91
+
92
+ test('recordStatus silently ignores untracked commands', (t) => {
93
+ const { recordStatus, configMock } = t.context
94
+
95
+ recordStatus('/Users/test/my-project', 'preview', 'ok')
96
+
97
+ t.false(configMock.default.set.called)
98
+ })
99
+
100
+ test('recordStatus preserves existing command entries', (t) => {
101
+ const { recordStatus, projectKey, store } = t.context
102
+ const projectPath = '/Users/test/my-project'
103
+ const key = projectKey(projectPath)
104
+
105
+ // Pre-populate with a build entry
106
+ store.projects = {
107
+ [key]: {
108
+ projectPath,
109
+ buildStatus: {
110
+ build: { status: 'ok', timestamp: 1000 },
111
+ },
112
+ },
113
+ }
114
+
115
+ recordStatus(projectPath, 'pdf', 'failed')
116
+
117
+ const entry = store.projects[key]
118
+ t.is(entry.buildStatus.build.status, 'ok')
119
+ t.is(entry.buildStatus.build.timestamp, 1000)
120
+ t.is(entry.buildStatus.pdf.status, 'failed')
121
+ })
122
+
123
+ // ─────────────────────────────────────────────────────────────────────────────
124
+ // getStatus
125
+ // ─────────────────────────────────────────────────────────────────────────────
126
+
127
+ test('getStatus returns stored status entry', (t) => {
128
+ const { getStatus, projectKey, store } = t.context
129
+ const projectPath = '/Users/test/my-project'
130
+ const key = projectKey(projectPath)
131
+
132
+ store.projects = {
133
+ [key]: {
134
+ projectPath,
135
+ buildStatus: {
136
+ build: { status: 'ok', timestamp: 1000 },
137
+ },
138
+ },
139
+ }
140
+
141
+ const result = getStatus(projectPath, 'build')
142
+ t.deepEqual(result, { status: 'ok', timestamp: 1000 })
143
+ })
144
+
145
+ test('getStatus returns undefined for unknown project', (t) => {
146
+ const { getStatus } = t.context
147
+
148
+ const result = getStatus('/Users/test/unknown-project', 'build')
149
+ t.is(result, undefined)
150
+ })
151
+
152
+ test('getStatus returns undefined for unrecorded command', (t) => {
153
+ const { getStatus, projectKey, store } = t.context
154
+ const projectPath = '/Users/test/my-project'
155
+ const key = projectKey(projectPath)
156
+
157
+ store.projects = {
158
+ [key]: {
159
+ projectPath,
160
+ buildStatus: {
161
+ build: { status: 'ok', timestamp: 1000 },
162
+ },
163
+ },
164
+ }
165
+
166
+ const result = getStatus(projectPath, 'epub')
167
+ t.is(result, undefined)
168
+ })
169
+
170
+ // ─────────────────────────────────────────────────────────────────────────────
171
+ // clearStatus
172
+ // ─────────────────────────────────────────────────────────────────────────────
173
+
174
+ test('clearStatus removes project entry', (t) => {
175
+ const { clearStatus, projectKey, store, configMock } = t.context
176
+ const projectPath = '/Users/test/my-project'
177
+ const key = projectKey(projectPath)
178
+
179
+ store.projects = {
180
+ [key]: {
181
+ projectPath,
182
+ buildStatus: {
183
+ build: { status: 'ok', timestamp: 1000 },
184
+ },
185
+ },
186
+ }
187
+
188
+ clearStatus(projectPath)
189
+
190
+ const [, setValue] = configMock.default.set.firstCall.args
191
+ t.truthy(setValue[key], 'project entry should still exist')
192
+ t.is(setValue[key].projectPath, projectPath, 'projectPath should be preserved')
193
+ t.is(setValue[key].buildStatus, undefined, 'buildStatus should be removed')
194
+ })
195
+
196
+ test('clearStatus is a no-op for unknown project', (t) => {
197
+ const { clearStatus, configMock, store } = t.context
198
+
199
+ store.projects = {}
200
+ clearStatus('/Users/test/unknown-project')
201
+
202
+ t.false(configMock.default.set.called)
203
+ })
204
+
205
+ test('clearStatus is a no-op when project has no buildStatus', (t) => {
206
+ const { clearStatus, projectKey, configMock, store } = t.context
207
+ const projectPath = '/Users/test/my-project'
208
+ const key = projectKey(projectPath)
209
+
210
+ store.projects = {
211
+ [key]: { projectPath },
212
+ }
213
+
214
+ clearStatus(projectPath)
215
+
216
+ t.false(configMock.default.set.called)
217
+ })
218
+
219
+ // ─────────────────────────────────────────────────────────────────────────────
220
+ // Multiple projects
221
+ // ─────────────────────────────────────────────────────────────────────────────
222
+
223
+ test('multiple projects are tracked independently', (t) => {
224
+ const { recordStatus, getStatus } = t.context
225
+ const projectA = '/Users/test/project-a'
226
+ const projectB = '/Users/test/project-b'
227
+
228
+ recordStatus(projectA, 'build', 'ok')
229
+ recordStatus(projectB, 'build', 'failed')
230
+
231
+ t.is(getStatus(projectA, 'build').status, 'ok')
232
+ t.is(getStatus(projectB, 'build').status, 'failed')
233
+ })
234
+
235
+ // ─────────────────────────────────────────────────────────────────────────────
236
+ // TRACKED_COMMANDS
237
+ // ─────────────────────────────────────────────────────────────────────────────
238
+
239
+ test('TRACKED_COMMANDS contains build, pdf, and epub', (t) => {
240
+ const { TRACKED_COMMANDS } = t.context
241
+ t.deepEqual([...TRACKED_COMMANDS], ['build', 'pdf', 'epub'])
242
+ })
243
+
244
+ test('TRACKED_COMMANDS is frozen', (t) => {
245
+ const { TRACKED_COMMANDS } = t.context
246
+ t.true(Object.isFrozen(TRACKED_COMMANDS))
247
+ })
@@ -56,6 +56,15 @@ export default {
56
56
  * Relative path to quire-11ty installed in the project directory.
57
57
  */
58
58
  quire11tyPath: '.',
59
+ /**
60
+ * How stale an output must be before `quire doctor` warns.
61
+ * - 'ZERO': any time difference (0 minutes)
62
+ * - 'SHORT': 5 minutes
63
+ * - 'HOURLY': 60 minutes (default)
64
+ * - 'DAILY': 12 hours
65
+ * - 'NEVER': disable stale output warnings
66
+ */
67
+ staleThreshold: 'HOURLY',
59
68
  /**
60
69
  * Version of quire-11ty to install when creating new projects.
61
70
  */
@@ -78,4 +87,9 @@ export default {
78
87
  * File name for the quire-11ty version file.
79
88
  */
80
89
  versionFile: '.quire',
90
+ /**
91
+ * Per-project data (internal).
92
+ * Keyed by SHA-256 hash of the project's absolute path.
93
+ */
94
+ projects: {},
81
95
  }
@@ -45,7 +45,7 @@ export function formatSettings(store, { configPath, showInternal = false, useCol
45
45
  lines.push('')
46
46
 
47
47
  for (const [key, value] of Object.entries(store)) {
48
- if (key.startsWith('__internal__') && !showInternal) continue
48
+ if ((key.startsWith('__internal__') || key === 'projects') && !showInternal) continue
49
49
  const description = getKeyDescription(key)
50
50
  lines.push(` ${style.cyan(key)}: ${JSON.stringify(value)}`)
51
51
  if (description) {
@@ -50,6 +50,11 @@ export default {
50
50
  type: 'string',
51
51
  description: 'Path to the quire-11ty package (use "." for local development)'
52
52
  },
53
+ staleThreshold: {
54
+ type: 'string',
55
+ enum: ['ZERO', 'SHORT', 'HOURLY', 'DAILY', 'NEVER'],
56
+ description: 'How stale an output must be before doctor warns (ZERO=0m, SHORT=5m, HOURLY=60m, DAILY=12h, NEVER=disabled)'
57
+ },
53
58
  quireVersion: {
54
59
  type: 'string',
55
60
  description: 'Version of quire-11ty to use ("latest" or specific version)'
@@ -71,5 +76,45 @@ export default {
71
76
  versionFile: {
72
77
  type: 'string',
73
78
  description: 'Filename used to identify Quire projects'
74
- }
79
+ },
80
+ projects: {
81
+ type: 'object',
82
+ description: 'Per-project data keyed by hashed project path (internal)',
83
+ additionalProperties: {
84
+ type: 'object',
85
+ properties: {
86
+ projectPath: { type: 'string' },
87
+ buildStatus: {
88
+ type: 'object',
89
+ properties: {
90
+ build: {
91
+ type: 'object',
92
+ properties: {
93
+ status: { type: 'string', enum: ['ok', 'failed'] },
94
+ timestamp: { type: 'number' },
95
+ },
96
+ required: ['status', 'timestamp'],
97
+ },
98
+ pdf: {
99
+ type: 'object',
100
+ properties: {
101
+ status: { type: 'string', enum: ['ok', 'failed'] },
102
+ timestamp: { type: 'number' },
103
+ },
104
+ required: ['status', 'timestamp'],
105
+ },
106
+ epub: {
107
+ type: 'object',
108
+ properties: {
109
+ status: { type: 'string', enum: ['ok', 'failed'] },
110
+ timestamp: { type: 'number' },
111
+ },
112
+ required: ['status', 'timestamp'],
113
+ },
114
+ },
115
+ },
116
+ },
117
+ required: ['projectPath'],
118
+ },
119
+ },
75
120
  }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Shared CLI constants
3
+ *
4
+ * Centralized constants used across the CLI to avoid duplication
5
+ * and ensure consistency.
6
+ *
7
+ * @module lib/constants
8
+ */
9
+
10
+ /**
11
+ * Base URL for Quire documentation (without trailing slash)
12
+ *
13
+ * Use with URL constructor for path joining:
14
+ * @example
15
+ * new URL('install-uninstall/', DOCS_BASE_URL).href
16
+ * // => 'https://quire.getty.edu/docs-v1/install-uninstall/'
17
+ */
18
+ export const DOCS_BASE_URL = 'https://quire.getty.edu/docs-v1'
19
+
20
+ /**
21
+ * Package name for quire-11ty
22
+ */
23
+ export const QUIRE_11TY_PACKAGE = '@thegetty/quire-11ty'
24
+
25
+ /**
26
+ * Minimum required Node.js major version
27
+ */
28
+ export const REQUIRED_NODE_VERSION = 22