@thegetty/quire-cli 1.0.0-rc.34 → 1.0.0-rc.36

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 (159) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +9 -0
  3. package/bin/cli.js +19 -1
  4. package/package.json +21 -8
  5. package/schemas/config.schema.json +194 -0
  6. package/schemas/figures.schema.json +56 -0
  7. package/schemas/layout.schema.json +5 -0
  8. package/schemas/objects.schema.json +62 -0
  9. package/schemas/publication.schema.json +140 -0
  10. package/schemas/references.schema.json +29 -0
  11. package/src/Command.js +26 -6
  12. package/src/Command.spec.js +99 -0
  13. package/src/commands/README.md +213 -122
  14. package/src/commands/build.js +46 -37
  15. package/src/commands/build.spec.js +113 -0
  16. package/src/commands/build.test.js +402 -0
  17. package/src/commands/clean.js +25 -19
  18. package/src/commands/clean.spec.js +108 -0
  19. package/src/commands/clean.test.js +260 -0
  20. package/src/commands/config.js +251 -0
  21. package/src/commands/config.spec.js +107 -0
  22. package/src/commands/config.test.js +715 -0
  23. package/src/commands/create.js +42 -14
  24. package/src/commands/create.spec.js +112 -0
  25. package/src/commands/create.test.js +415 -0
  26. package/src/commands/epub.js +59 -30
  27. package/src/commands/epub.spec.js +114 -0
  28. package/src/commands/epub.test.js +503 -0
  29. package/src/commands/index.js +9 -2
  30. package/src/commands/info.js +18 -24
  31. package/src/commands/info.spec.js +64 -0
  32. package/src/commands/info.test.js +415 -0
  33. package/src/commands/pdf.js +58 -84
  34. package/src/commands/pdf.spec.js +114 -0
  35. package/src/commands/pdf.test.js +464 -0
  36. package/src/commands/preview.js +26 -31
  37. package/src/commands/preview.spec.js +97 -0
  38. package/src/commands/preview.test.js +250 -0
  39. package/src/commands/use.js +56 -0
  40. package/src/commands/use.spec.js +61 -0
  41. package/src/commands/use.test.js +280 -0
  42. package/src/commands/validate.js +31 -19
  43. package/src/commands/validate.spec.js +82 -0
  44. package/src/commands/validate.test.js +234 -0
  45. package/src/commands/workflows.js +70 -0
  46. package/src/errors/build/build-failed-error.js +19 -0
  47. package/src/errors/build/config-field-missing-error.js +20 -0
  48. package/src/errors/build/config-file-not-found-error.js +20 -0
  49. package/src/errors/build/index.js +11 -0
  50. package/src/errors/index.js +48 -0
  51. package/src/errors/install/dependency-install-error.js +19 -0
  52. package/src/errors/install/directory-not-empty-error.js +25 -0
  53. package/src/errors/install/index.js +12 -0
  54. package/src/errors/install/invalid-path-error.js +31 -0
  55. package/src/errors/install/invalid-starter-error.js +27 -0
  56. package/src/errors/install/version-not-found-error.js +35 -0
  57. package/src/errors/output/epub-generation-error.js +19 -0
  58. package/src/errors/output/index.js +14 -0
  59. package/src/errors/output/invalid-epub-library-error.js +20 -0
  60. package/src/errors/output/invalid-pdf-library-error.js +20 -0
  61. package/src/errors/output/missing-build-output-error.js +21 -0
  62. package/src/errors/output/pdf-generation-error.js +24 -0
  63. package/src/errors/output/tool-not-found-error.js +37 -0
  64. package/src/errors/project/index.js +10 -0
  65. package/src/errors/project/not-in-project-error.js +20 -0
  66. package/src/errors/project/project-create-error.js +21 -0
  67. package/src/errors/quire-error.js +27 -0
  68. package/src/errors/validation/validation-error.js +20 -11
  69. package/src/helpers/clean.js +1 -1
  70. package/src/helpers/docs-url.js +32 -0
  71. package/src/helpers/test-cwd.js +5 -6
  72. package/src/helpers/test-cwd.test.js +192 -0
  73. package/src/helpers/which.js +10 -4
  74. package/src/lib/11ty/README.md +135 -19
  75. package/src/lib/11ty/api.js +176 -93
  76. package/src/lib/11ty/cli.js +77 -35
  77. package/src/lib/11ty/index.js +64 -5
  78. package/src/lib/11ty/index.test.js +655 -0
  79. package/src/lib/README.md +275 -0
  80. package/src/lib/commander/index.js +100 -0
  81. package/src/lib/commander/index.test.js +86 -0
  82. package/src/lib/commander/options.js +195 -0
  83. package/src/lib/commander/options.test.js +109 -0
  84. package/src/lib/conf/README.md +84 -73
  85. package/src/lib/conf/config.js +5 -3
  86. package/src/lib/conf/config.test.js +281 -0
  87. package/src/lib/conf/defaults.js +44 -0
  88. package/src/lib/conf/format.js +60 -0
  89. package/src/lib/conf/format.test.js +106 -0
  90. package/src/lib/conf/helpers.js +91 -0
  91. package/src/lib/conf/helpers.test.js +136 -0
  92. package/src/lib/conf/index.js +22 -0
  93. package/src/lib/conf/schema.js +53 -8
  94. package/src/lib/epub/README.md +133 -2
  95. package/src/lib/epub/engines.js +46 -0
  96. package/src/lib/epub/epub.js +36 -11
  97. package/src/lib/epub/index.js +111 -21
  98. package/src/lib/epub/index.test.js +518 -0
  99. package/src/lib/epub/pandoc.js +33 -4
  100. package/src/lib/epub/pandoc.test.js +122 -0
  101. package/src/lib/epub/schema.js +21 -0
  102. package/src/lib/error/README.md +170 -0
  103. package/src/lib/error/handler.js +107 -0
  104. package/src/lib/git/README.md +151 -2
  105. package/src/lib/git/index.js +217 -13
  106. package/src/lib/git/index.spec.js +80 -0
  107. package/src/lib/git/index.test.js +453 -0
  108. package/src/lib/installer/index.js +309 -0
  109. package/src/lib/installer/index.spec.js +83 -0
  110. package/src/lib/installer/index.test.js +545 -0
  111. package/src/lib/logger/README.md +424 -0
  112. package/src/lib/logger/debug.js +90 -0
  113. package/src/lib/logger/debug.spec.js +130 -0
  114. package/src/lib/logger/index.js +228 -0
  115. package/src/lib/logger/index.spec.js +131 -0
  116. package/src/lib/logger/index.test.js +477 -0
  117. package/src/lib/npm/README.md +127 -0
  118. package/src/lib/npm/index.js +198 -0
  119. package/src/lib/npm/index.spec.js +60 -0
  120. package/src/lib/npm/index.test.js +355 -0
  121. package/src/lib/pdf/README.md +131 -0
  122. package/src/lib/pdf/engines.js +46 -0
  123. package/src/lib/pdf/index.js +124 -21
  124. package/src/lib/pdf/index.test.js +708 -0
  125. package/src/lib/pdf/paged.js +100 -52
  126. package/src/lib/pdf/paged.test.js +366 -0
  127. package/src/lib/pdf/prince.js +115 -37
  128. package/src/lib/pdf/prince.test.js +202 -0
  129. package/src/lib/pdf/schema.js +21 -0
  130. package/src/lib/pdf/split.js +61 -33
  131. package/src/lib/pdf/split.test.js +445 -0
  132. package/src/lib/process/manager.js +110 -0
  133. package/src/lib/process/manager.test.js +55 -0
  134. package/src/lib/project/build.js +143 -0
  135. package/src/lib/project/build.test.js +253 -0
  136. package/src/lib/project/config.js +48 -0
  137. package/src/lib/project/config.test.js +134 -0
  138. package/src/{helpers/is-quire.js → lib/project/detect.js} +5 -3
  139. package/src/lib/project/detect.test.js +157 -0
  140. package/src/lib/project/index.js +40 -0
  141. package/src/lib/project/paths.js +224 -0
  142. package/src/lib/project/version.js +110 -0
  143. package/src/lib/project/version.test.js +350 -0
  144. package/src/lib/reporter/README.md +211 -2
  145. package/src/lib/reporter/index.js +512 -0
  146. package/src/lib/reporter/index.test.js +593 -0
  147. package/src/main.js +134 -47
  148. package/src/main.spec.js +61 -0
  149. package/src/main.test.js +347 -0
  150. package/src/validators/utils.js +2 -1
  151. package/src/commands/conf.js +0 -43
  152. package/src/commands/version.js +0 -43
  153. package/src/lib/11ty/paths.js +0 -103
  154. package/src/lib/i18n/README.md +0 -3
  155. package/src/lib/i18n/config.js +0 -53
  156. package/src/lib/i18n/index.js +0 -43
  157. package/src/lib/i18n/localeService.js +0 -58
  158. package/src/lib/quire/README.md +0 -19
  159. package/src/lib/quire/index.js +0 -350
package/src/main.js CHANGED
@@ -1,10 +1,50 @@
1
- import { Argument, Command, Option } from 'commander'
1
+ import { Command, Argument, Option } from 'commander'
2
+ import {
3
+ arrayToArgument,
4
+ arrayToOption,
5
+ quietOption,
6
+ verboseOption,
7
+ debugOption
8
+ } from '#lib/commander/index.js'
2
9
  import commands from '#src/commands/index.js'
3
10
  import config from '#lib/conf/config.js'
11
+ import { handleError } from '#lib/error/handler.js'
12
+ import { docsUrl, DOCS_BASE } from '#helpers/docs-url.js'
4
13
  import packageConfig from '#src/packageConfig.js'
14
+ import { enableDebug } from '#lib/logger/debug.js'
5
15
 
6
16
  const { version } = packageConfig
7
17
 
18
+ const mainHelpText = `
19
+ Docs: ${DOCS_BASE}
20
+
21
+ Common Workflows:
22
+ New project quire new my-book && cd my-book && quire preview
23
+ Build for web quire build
24
+ Generate PDF quire pdf --build
25
+ Generate EPUB quire epub --build
26
+
27
+ Run 'quire help workflows' for detailed workflow documentation.
28
+
29
+ Output Modes:
30
+ -q, --quiet Suppress progress output (for CI/scripts)
31
+ -v, --verbose Show detailed progress (paths, timing, steps)
32
+ --debug Enable debug output for developers/troubleshooting
33
+
34
+ Set defaults: quire settings set verbose true
35
+
36
+ Environment Variables:
37
+ DEBUG=quire:* Enable debug output for all modules
38
+ DEBUG=quire:lib:pdf Enable debug output for PDF module only
39
+ DEBUG=quire:lib:* Enable debug output for all lib modules
40
+
41
+ Examples:
42
+ $ quire build Build the publication
43
+ $ quire build --verbose Build with detailed progress
44
+ $ quire build --debug Build with debug output
45
+ $ DEBUG=quire:* quire pdf Generate PDF with debug output
46
+ `
47
+
8
48
  /**
9
49
  * Quire CLI implements the command pattern.
10
50
  *
@@ -17,29 +57,67 @@ const program = new Command()
17
57
  program
18
58
  .name('quire')
19
59
  .description('Quire command-line interface')
20
- .version(version, '-v, --version', 'output quire version number')
60
+ .version(version, '-V, --version', 'output quire version number')
61
+ .addOption(arrayToOption(quietOption))
62
+ .addOption(arrayToOption(verboseOption))
63
+ .addOption(arrayToOption(debugOption))
64
+ .addHelpText('after', mainHelpText)
21
65
  .configureHelp({
22
66
  helpWidth: 80,
23
67
  sortOptions: false,
24
68
  sortSubcommands: false,
69
+ styleOptionTerm: (term) => {
70
+ // Add spacing to long-only options to align with short-flag options
71
+ return /^-\w/.test(term) ? term : term.padStart(term.length + 4)
72
+ },
25
73
  })
26
74
 
27
75
  /**
28
- * Register each command as a subcommand of this program
76
+ * Handle global options before any command runs
29
77
  *
30
- * @todo refactor command definition to allow for per-command custom help text
78
+ * Output mode semantics:
79
+ * - --quiet: Suppress progress spinners (for CI/scripts)
80
+ * - --verbose: Show detailed progress (paths, timing, steps)
81
+ * - --debug: Enable DEBUG namespace + tool debug modes (for developers)
82
+ *
83
+ * These global options are passed through to commands via opts()
84
+ * and should be merged with command-level options.
85
+ */
86
+ program.hook('preAction', (thisCommand) => {
87
+ const opts = thisCommand.opts()
88
+
89
+ // --debug or config.debug enables the quire:* DEBUG namespace for internal logging
90
+ // CLI flag takes precedence, then config setting
91
+ if (opts.debug ?? config.get('debug')) {
92
+ enableDebug('quire:*')
93
+ }
94
+ })
95
+
96
+ /**
97
+ * Register each command as a subcommand of this program
31
98
  * @see https://github.com/tj/commander.js?tab=readme-ov-file#automated-help
32
99
  */
33
100
  commands.forEach((command) => {
34
- const { action, alias, aliases, args, description, name, options } = command
101
+ const { action, alias, aliases, args, description, docsLink, helpText, hidden, name, options, summary } = command
35
102
 
36
103
  const subCommand = program
37
- .command(name)
104
+ .command(name, { hidden })
38
105
  .description(description)
106
+ .summary(summary || description)
39
107
  .addHelpCommand()
40
108
  .showHelpAfterError()
41
109
 
42
- if (alias instanceof String) {
110
+ // Append docs link and/or custom help text after built-in help
111
+ const customHelpText = [
112
+ docsLink && `Docs: ${docsUrl(docsLink)}`,
113
+ helpText?.trim()
114
+ ].filter(Boolean).join('\n\n')
115
+
116
+ if (customHelpText) {
117
+ subCommand.addHelpText('after', '\n' + customHelpText)
118
+ }
119
+
120
+ if (typeof alias === 'string') {
43
121
  subCommand.alias(alias)
44
122
  }
45
123
 
@@ -48,71 +126,80 @@ commands.forEach((command) => {
48
126
  }
49
127
 
50
128
  /**
51
- * @see https://github.com/tj/commander.js#more-configuration-1
129
+ * Register arguments with the subcommand
52
130
  */
53
131
  if (Array.isArray(args)) {
54
- args.forEach(([ name, description, configuration = {} ]) => {
55
- const argument = new Argument(name, description)
56
- if (configuration.choices) argument.choices(configuration.choices)
57
- if (configuration.default) argument.default(configuration.default)
132
+ args.forEach((entry) => {
133
+ const argument = entry instanceof Argument ? entry : arrayToArgument(entry)
58
134
  subCommand.addArgument(argument)
59
135
  })
60
136
  }
61
137
 
62
138
  /**
63
- * @see https://github.com/tj/commander.js/#options
139
+ * Register options with the subcommand
64
140
  */
65
141
  if (Array.isArray(options)) {
66
142
  options.forEach((entry) => {
67
- if (Array.isArray(entry)) {
68
- // ensure we can join the first two attributes as the option name
69
- // when only the short or the long flag is defined in the array
70
- if (entry[0].startsWith('--')) entry.unshift('\u0020'.repeat(4))
71
- if (entry[0].startsWith('-') && !entry[1].startsWith('--')) {
72
- entry.splice(1, 0, '')
73
- }
74
- // assign attribute names to the array of option attributes
75
- const [ short, long, description, defaultValue ] = entry
76
- // join short and long flags as the option name attribute
77
- const name = /^-\w/.test(short) && /^--\w/.test(long)
78
- ? [short, long].join(', ')
79
- : [short, long].join('')
80
- subCommand.option(name, description, defaultValue)
81
- } else {
82
- /**
83
- * @todo allow options to be defined by a configuration object
84
- * @see https://github.com/tj/commander.js/#more-configuration
85
- */
86
- // const option = new Option(name, description)
87
- // for (const property of configuration) {
88
- // option[property](configuration[property])
89
- // }
90
- // subCommand.addOption(option)
91
- console.error('@TODO please use an array to define option attributes')
92
- }
143
+ const option = entry instanceof Option ? entry : arrayToOption(entry)
144
+ subCommand.addOption(option)
93
145
  })
94
146
  }
95
147
 
148
+ /**
149
+ * Command lifecycle hook: called after action handler and nested subcommands
150
+ * @see https://github.com/tj/commander.js?tab=readme-ov-file#life-cycle-hooks
151
+ */
96
152
  if (command.postAction) {
97
- subCommand.hook('postAction', (thisCommand, actionCommand) => {
98
- command.postAction.call(command, thisCommand, actionCommand)
153
+ subCommand.hook('postAction', async (thisCommand, actionCommand) => {
154
+ try {
155
+ await command.postAction.call(command, thisCommand, actionCommand)
156
+ } catch (error) {
157
+ const { debug } = program.opts()
158
+ handleError(error, { debug })
159
+ }
99
160
  })
100
161
  }
101
162
 
163
+ /**
164
+ * Command lifecycle hook: called before action handler and nested subcommands
165
+ * @see https://github.com/tj/commander.js?tab=readme-ov-file#life-cycle-hooks
166
+ */
102
167
  if (command.preAction) {
103
- subCommand.hook('preAction', (thisCommand, actionCommand) => {
104
- command.preAction.call(command, thisCommand, actionCommand)
168
+ subCommand.hook('preAction', async (thisCommand, actionCommand) => {
169
+ try {
170
+ await command.preAction.call(command, thisCommand, actionCommand)
171
+ } catch (error) {
172
+ const { debug } = program.opts()
173
+ handleError(error, { debug })
174
+ }
105
175
  })
106
176
  }
107
177
 
178
+ /**
179
+ * Subcommand lifecyle hook: called before parsing direct subcommand
180
+ * @see https://github.com/tj/commander.js?tab=readme-ov-file#life-cycle-hooks
181
+ */
108
182
  if (command.preSubcommand) {
109
- subCommand.hook('preSubcommand', (thisCommand, theSubcommand) => {
110
- command.preSubcommand.call(command, thisCommand, theSubcommand)
183
+ subCommand.hook('preSubcommand', async (thisCommand, theSubcommand) => {
184
+ try {
185
+ await command.preSubcommand.call(command, thisCommand, theSubcommand)
186
+ } catch (error) {
187
+ const { debug } = program.opts()
188
+ handleError(error, { debug })
189
+ }
111
190
  })
112
191
  }
113
192
 
114
- // subCommand.action((args) => action.apply(command, args))
115
- subCommand.action(action)
193
+ // Wrap action in centralized error handler
194
+ // Using apply() preserves `this` context for `this.debug` and `this.logger`
195
+ subCommand.action(async (...args) => {
196
+ try {
197
+ await action.apply(command, args)
198
+ } catch (error) {
199
+ const { debug } = program.opts()
200
+ handleError(error, { debug })
201
+ }
202
+ })
116
203
 
117
204
  /**
118
205
  * Inject the CLI configuration into commands
@@ -0,0 +1,61 @@
1
+ import { Command } from 'commander'
2
+ import program from './main.js'
3
+ import test from 'ava'
4
+
5
+ /**
6
+ * Main Program Unit Tests
7
+ *
8
+ * Tests the CLI program configuration and setup.
9
+ * Command registration is tested indirectly through command spec files.
10
+ */
11
+
12
+ test('program exports a Commander.js Command instance', (t) => {
13
+ t.truthy(program)
14
+ t.true(program instanceof Command, 'program should be a Commander.js Command')
15
+ })
16
+
17
+ test('program has correct name and description', (t) => {
18
+ t.is(program.name(), 'quire')
19
+ t.truthy(program.description(), 'program should have a description')
20
+ t.regex(program.description(), /quire/i, 'description should mention Quire')
21
+ })
22
+
23
+ test('program has version option with correct flags', (t) => {
24
+ // Commander stores _version and the version option in the options array
25
+ t.truthy(program._version, 'program should have version set')
26
+
27
+ // Find the version option
28
+ const versionOption = program.options.find((opt) => opt.long === '--version')
29
+ t.truthy(versionOption, '--version option should exist')
30
+ t.is(versionOption.short, '-v', 'version option should have -v short flag')
31
+ t.truthy(versionOption.description, 'version option should have description')
32
+ })
33
+
34
+ test('program help is configured with correct width', (t) => {
35
+ // Access the help configuration
36
+ const helpConfig = program._helpConfiguration
37
+
38
+ t.truthy(helpConfig, 'help configuration should exist')
39
+ t.is(helpConfig.helpWidth, 80, 'help width should be 80')
40
+ })
41
+
42
+ test('program registers commands from commands/index.js', (t) => {
43
+ // Verify commands are registered
44
+ t.true(program.commands.length > 0, 'program should have registered commands')
45
+
46
+ // Verify expected commands exist
47
+ const commandNames = program.commands.map((cmd) => cmd.name())
48
+ t.true(commandNames.includes('build'), 'build command should be registered')
49
+ t.true(commandNames.includes('new'), 'new command should be registered')
50
+ t.true(commandNames.includes('preview'), 'preview command should be registered')
51
+ t.true(commandNames.includes('info'), 'info command should be registered')
52
+ })
53
+
54
+ test('program injects config into subcommands', (t) => {
55
+ // Pick a registered command and verify dependency injection of the config
56
+ const buildCommand = program.commands.find((cmd) => cmd.name() === 'build')
57
+
58
+ t.truthy(buildCommand, 'build command should exist')
59
+ t.truthy(buildCommand.config, 'subcommand should have config injected')
60
+ t.is(typeof buildCommand.config.get, 'function', 'config should have get method')
61
+ })
@@ -0,0 +1,347 @@
1
+ import test from 'ava'
2
+ import sinon from 'sinon'
3
+ import esmock from 'esmock'
4
+ import QuireError from '#src/errors/quire-error.js'
5
+
6
+ /**
7
+ * Main Program Integration Tests
8
+ *
9
+ * Tests the runtime behavior of the CLI program, including
10
+ * error handling in command lifecycle hooks.
11
+ */
12
+
13
+ test.beforeEach((t) => {
14
+ t.context.sandbox = sinon.createSandbox()
15
+ })
16
+
17
+ test.afterEach.always((t) => {
18
+ t.context.sandbox.restore()
19
+ })
20
+
21
+ /**
22
+ * Helper to create a mock command with configurable hooks
23
+ */
24
+ function createMockCommand(overrides = {}) {
25
+ return {
26
+ name: 'mock-command',
27
+ description: 'Mock command for integration tests',
28
+ summary: 'mocked command',
29
+ action: async () => {},
30
+ ...overrides
31
+ }
32
+ }
33
+
34
+ test('preAction hook errors are passed to handleError', async (t) => {
35
+ const { sandbox } = t.context
36
+
37
+ const testError = new QuireError('Test preAction error', {
38
+ code: 'TEST_ERROR',
39
+ exitCode: 42
40
+ })
41
+
42
+ const mockHandleError = sandbox.stub()
43
+
44
+ const mockCommand = createMockCommand({
45
+ preAction: () => {
46
+ throw testError
47
+ }
48
+ })
49
+
50
+ // Use esmock to inject our mock command and handleError
51
+ await esmock('./main.js', {
52
+ '#src/commands/index.js': {
53
+ default: [mockCommand]
54
+ },
55
+ '#lib/error/handler.js': {
56
+ handleError: mockHandleError
57
+ }
58
+ })
59
+
60
+ // The program is created at import time, so we need to trigger the hook
61
+ // by parsing a command. Import the mocked program.
62
+ const { default: program } = await esmock('./main.js', {
63
+ '#src/commands/index.js': {
64
+ default: [mockCommand]
65
+ },
66
+ '#lib/error/handler.js': {
67
+ handleError: mockHandleError
68
+ }
69
+ })
70
+
71
+ // Configure Commander to not exit on error during tests
72
+ program.exitOverride()
73
+ program.configureOutput({
74
+ writeErr: () => {},
75
+ writeOut: () => {}
76
+ })
77
+
78
+ // Parse the test command to trigger preAction
79
+ try {
80
+ await program.parseAsync(['node', 'quire', 'mock-command'])
81
+ } catch {
82
+ // Commander may throw after our error is handled
83
+ }
84
+
85
+ t.true(mockHandleError.called, 'handleError should be called for preAction errors')
86
+ t.is(mockHandleError.firstCall.args[0], testError, 'handleError should receive the thrown error')
87
+ })
88
+
89
+ test('postAction hook errors are passed to handleError', async (t) => {
90
+ const { sandbox } = t.context
91
+
92
+ const testError = new QuireError('Test postAction error', {
93
+ code: 'TEST_POST_ERROR',
94
+ exitCode: 43
95
+ })
96
+
97
+ const mockHandleError = sandbox.stub()
98
+
99
+ const mockCommand = createMockCommand({
100
+ action: async () => {
101
+ // Action succeeds
102
+ },
103
+ postAction: () => {
104
+ throw testError
105
+ }
106
+ })
107
+
108
+ const { default: program } = await esmock('./main.js', {
109
+ '#src/commands/index.js': {
110
+ default: [mockCommand]
111
+ },
112
+ '#lib/error/handler.js': {
113
+ handleError: mockHandleError
114
+ }
115
+ })
116
+
117
+ program.exitOverride()
118
+ program.configureOutput({
119
+ writeErr: () => {},
120
+ writeOut: () => {}
121
+ })
122
+
123
+ try {
124
+ await program.parseAsync(['node', 'quire', 'mock-command'])
125
+ } catch {
126
+ // Commander may throw after our error is handled
127
+ }
128
+
129
+ t.true(mockHandleError.called, 'handleError should be called for postAction errors')
130
+ t.is(mockHandleError.firstCall.args[0], testError, 'handleError should receive the thrown error')
131
+ })
132
+
133
+ test('action errors are passed to handleError', async (t) => {
134
+ const { sandbox } = t.context
135
+
136
+ const testError = new QuireError('Test action error', {
137
+ code: 'TEST_ACTION_ERROR',
138
+ exitCode: 44
139
+ })
140
+
141
+ const mockHandleError = sandbox.stub()
142
+
143
+ const mockCommand = createMockCommand({
144
+ action: async () => {
145
+ throw testError
146
+ }
147
+ })
148
+
149
+ const { default: program } = await esmock('./main.js', {
150
+ '#src/commands/index.js': {
151
+ default: [mockCommand]
152
+ },
153
+ '#lib/error/handler.js': {
154
+ handleError: mockHandleError
155
+ }
156
+ })
157
+
158
+ program.exitOverride()
159
+ program.configureOutput({
160
+ writeErr: () => {},
161
+ writeOut: () => {}
162
+ })
163
+
164
+ try {
165
+ await program.parseAsync(['node', 'quire', 'mock-command'])
166
+ } catch {
167
+ // Commander may throw after our error is handled
168
+ }
169
+
170
+ t.true(mockHandleError.called, 'handleError should be called for action errors')
171
+ t.is(mockHandleError.firstCall.args[0], testError, 'handleError should receive the thrown error')
172
+ })
173
+
174
+ test('preAction hook preserves this context for command methods', async (t) => {
175
+ const { sandbox } = t.context
176
+
177
+ const debugSpy = sandbox.spy()
178
+ let thisContext = null
179
+
180
+ const mockCommand = createMockCommand({
181
+ debug: debugSpy,
182
+ preAction: function () {
183
+ thisContext = this
184
+ this.debug('preAction called')
185
+ }
186
+ })
187
+
188
+ const { default: program } = await esmock('./main.js', {
189
+ '#src/commands/index.js': {
190
+ default: [mockCommand]
191
+ },
192
+ '#lib/error/handler.js': {
193
+ handleError: sandbox.stub()
194
+ }
195
+ })
196
+
197
+ program.exitOverride()
198
+ program.configureOutput({
199
+ writeErr: () => {},
200
+ writeOut: () => {}
201
+ })
202
+
203
+ try {
204
+ await program.parseAsync(['node', 'quire', 'mock-command'])
205
+ } catch {
206
+ // Ignore Commander errors
207
+ }
208
+
209
+ t.is(thisContext, mockCommand, 'this should be bound to the command instance')
210
+ t.true(debugSpy.called, 'this.debug should be callable')
211
+ })
212
+
213
+ test('postAction hook preserves this context for command methods', async (t) => {
214
+ const { sandbox } = t.context
215
+
216
+ const debugSpy = sandbox.spy()
217
+ let thisContext = null
218
+
219
+ const mockCommand = createMockCommand({
220
+ debug: debugSpy,
221
+ action: async () => {},
222
+ postAction: function () {
223
+ thisContext = this
224
+ this.debug('postAction called')
225
+ }
226
+ })
227
+
228
+ const { default: program } = await esmock('./main.js', {
229
+ '#src/commands/index.js': {
230
+ default: [mockCommand]
231
+ },
232
+ '#lib/error/handler.js': {
233
+ handleError: sandbox.stub()
234
+ }
235
+ })
236
+
237
+ program.exitOverride()
238
+ program.configureOutput({
239
+ writeErr: () => {},
240
+ writeOut: () => {}
241
+ })
242
+
243
+ try {
244
+ await program.parseAsync(['node', 'quire', 'mock-command'])
245
+ } catch {
246
+ // Ignore Commander errors
247
+ }
248
+
249
+ t.is(thisContext, mockCommand, 'this should be bound to the command instance')
250
+ t.true(debugSpy.called, 'this.debug should be callable')
251
+ })
252
+
253
+ test('async preAction hooks are awaited before action runs', async (t) => {
254
+ const { sandbox } = t.context
255
+
256
+ const callOrder = []
257
+
258
+ const mockCommand = createMockCommand({
259
+ preAction: async () => {
260
+ await new Promise((resolve) => setTimeout(resolve, 10))
261
+ callOrder.push('preAction')
262
+ },
263
+ action: async () => {
264
+ callOrder.push('action')
265
+ }
266
+ })
267
+
268
+ const { default: program } = await esmock('./main.js', {
269
+ '#src/commands/index.js': {
270
+ default: [mockCommand]
271
+ },
272
+ '#lib/error/handler.js': {
273
+ handleError: sandbox.stub()
274
+ }
275
+ })
276
+
277
+ program.exitOverride()
278
+ program.configureOutput({
279
+ writeErr: () => {},
280
+ writeOut: () => {}
281
+ })
282
+
283
+ try {
284
+ await program.parseAsync(['node', 'quire', 'mock-command'])
285
+ } catch {
286
+ // Ignore Commander errors
287
+ }
288
+
289
+ t.deepEqual(callOrder, ['preAction', 'action'], 'preAction should complete before action starts')
290
+ })
291
+
292
+ test('async postAction hooks are awaited after action completes', async (t) => {
293
+ const { sandbox } = t.context
294
+
295
+ const callOrder = []
296
+
297
+ const mockCommand = createMockCommand({
298
+ action: async () => {
299
+ callOrder.push('action')
300
+ },
301
+ postAction: async () => {
302
+ await new Promise((resolve) => setTimeout(resolve, 10))
303
+ callOrder.push('postAction')
304
+ }
305
+ })
306
+
307
+ const { default: program } = await esmock('./main.js', {
308
+ '#src/commands/index.js': {
309
+ default: [mockCommand]
310
+ },
311
+ '#lib/error/handler.js': {
312
+ handleError: sandbox.stub()
313
+ }
314
+ })
315
+
316
+ program.exitOverride()
317
+ program.configureOutput({
318
+ writeErr: () => {},
319
+ writeOut: () => {}
320
+ })
321
+
322
+ try {
323
+ await program.parseAsync(['node', 'quire', 'mock-command'])
324
+ } catch {
325
+ // Ignore Commander errors
326
+ }
327
+
328
+ t.deepEqual(callOrder, ['action', 'postAction'], 'postAction should run after action completes')
329
+ })
330
+
331
+ /**
332
+ * Nota bene: preSubcommand hook tests are skipped because:
333
+ * 1. `main` does not process a `subcommands` array in command definitions
334
+ * 2. No commands in the CLI currently use nested subcommands
335
+ * 3. The wrapped error handler pattern is identical to preAction/postAction
336
+ *
337
+ * @todo When nested subcommands are added, these tests should be implemented.
338
+ */
339
+ test.skip('preSubcommand hook errors are passed to handleError', async (t) => {
340
+ // Requires main.js to support nested subcommands in command definitions
341
+ t.pass()
342
+ })
343
+
344
+ test.skip('preSubcommand hook preserves this context for command methods', async (t) => {
345
+ // Requires main.js to support nested subcommands in command definitions
346
+ t.pass()
347
+ })
@@ -2,7 +2,7 @@ import YamlDuplicateIdError from '../errors/validation/yaml-duplicate-error.js'
2
2
  import { fileURLToPath } from 'url'
3
3
  import fs from 'node:fs'
4
4
  import path from 'path'
5
- import { projectRoot } from '#lib/11ty/index.js'
5
+ import { paths as eleventyPaths } from '#lib/11ty/index.js'
6
6
 
7
7
  const IMAGE_KEYS = new Set(['src', 'image', 'logo'])
8
8
 
@@ -37,6 +37,7 @@ function collectImagePaths(node, paths=[]) {
37
37
  function validateImage(label, src) {
38
38
  if(!src) return
39
39
 
40
+ const projectRoot = eleventyPaths.getProjectRoot()
40
41
  let assetPath = ''
41
42
  if(src.endsWith('.html')) {
42
43
  assetPath = path.join(projectRoot, 'content', '_assets', src)