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

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/patches/README.md +19 -0
  6. package/patches/install-npm-version+1.0.9.patch +12119 -0
  7. package/schemas/objects.schema.json +12 -38
  8. package/schemas/publication.schema.json +0 -22
  9. package/schemas/references.schema.json +0 -1
  10. package/src/Command.js +26 -6
  11. package/src/Command.spec.js +99 -0
  12. package/src/commands/README.md +213 -122
  13. package/src/commands/build.js +37 -36
  14. package/src/commands/build.spec.js +113 -0
  15. package/src/commands/build.test.js +397 -0
  16. package/src/commands/clean.js +25 -19
  17. package/src/commands/clean.spec.js +108 -0
  18. package/src/commands/clean.test.js +260 -0
  19. package/src/commands/config.js +251 -0
  20. package/src/commands/config.spec.js +107 -0
  21. package/src/commands/config.test.js +715 -0
  22. package/src/commands/create.js +42 -14
  23. package/src/commands/create.spec.js +112 -0
  24. package/src/commands/create.test.js +415 -0
  25. package/src/commands/epub.js +59 -30
  26. package/src/commands/epub.spec.js +114 -0
  27. package/src/commands/epub.test.js +503 -0
  28. package/src/commands/index.js +9 -2
  29. package/src/commands/info.js +18 -24
  30. package/src/commands/info.spec.js +64 -0
  31. package/src/commands/info.test.js +415 -0
  32. package/src/commands/pdf.js +58 -84
  33. package/src/commands/pdf.spec.js +114 -0
  34. package/src/commands/pdf.test.js +464 -0
  35. package/src/commands/preview.js +32 -31
  36. package/src/commands/preview.spec.js +97 -0
  37. package/src/commands/preview.test.js +250 -0
  38. package/src/commands/use.js +56 -0
  39. package/src/commands/use.spec.js +61 -0
  40. package/src/commands/use.test.js +280 -0
  41. package/src/commands/validate.js +31 -19
  42. package/src/commands/validate.spec.js +82 -0
  43. package/src/commands/validate.test.js +234 -0
  44. package/src/commands/workflows.js +70 -0
  45. package/src/errors/build/build-failed-error.js +19 -0
  46. package/src/errors/build/config-field-missing-error.js +20 -0
  47. package/src/errors/build/config-file-not-found-error.js +20 -0
  48. package/src/errors/build/index.js +11 -0
  49. package/src/errors/index.js +48 -0
  50. package/src/errors/install/dependency-install-error.js +19 -0
  51. package/src/errors/install/directory-not-empty-error.js +25 -0
  52. package/src/errors/install/index.js +12 -0
  53. package/src/errors/install/invalid-path-error.js +31 -0
  54. package/src/errors/install/invalid-starter-error.js +27 -0
  55. package/src/errors/install/version-not-found-error.js +35 -0
  56. package/src/errors/output/epub-generation-error.js +19 -0
  57. package/src/errors/output/index.js +14 -0
  58. package/src/errors/output/invalid-epub-library-error.js +20 -0
  59. package/src/errors/output/invalid-pdf-library-error.js +20 -0
  60. package/src/errors/output/missing-build-output-error.js +21 -0
  61. package/src/errors/output/pdf-generation-error.js +24 -0
  62. package/src/errors/output/tool-not-found-error.js +37 -0
  63. package/src/errors/project/index.js +10 -0
  64. package/src/errors/project/not-in-project-error.js +20 -0
  65. package/src/errors/project/project-create-error.js +21 -0
  66. package/src/errors/quire-error.js +27 -0
  67. package/src/errors/validation/validation-error.js +20 -11
  68. package/src/helpers/clean.js +1 -1
  69. package/src/helpers/docs-url.js +32 -0
  70. package/src/helpers/test-cwd.js +5 -6
  71. package/src/helpers/test-cwd.test.js +192 -0
  72. package/src/helpers/which.js +10 -4
  73. package/src/lib/11ty/README.md +135 -19
  74. package/src/lib/11ty/api.js +196 -94
  75. package/src/lib/11ty/cli.js +91 -37
  76. package/src/lib/11ty/index.js +64 -5
  77. package/src/lib/11ty/index.test.js +655 -0
  78. package/src/lib/README.md +275 -0
  79. package/src/lib/commander/index.js +100 -0
  80. package/src/lib/commander/index.test.js +86 -0
  81. package/src/lib/commander/options.js +195 -0
  82. package/src/lib/commander/options.test.js +109 -0
  83. package/src/lib/conf/README.md +84 -73
  84. package/src/lib/conf/config.js +5 -3
  85. package/src/lib/conf/config.test.js +281 -0
  86. package/src/lib/conf/defaults.js +44 -0
  87. package/src/lib/conf/format.js +60 -0
  88. package/src/lib/conf/format.test.js +106 -0
  89. package/src/lib/conf/helpers.js +91 -0
  90. package/src/lib/conf/helpers.test.js +136 -0
  91. package/src/lib/conf/index.js +22 -0
  92. package/src/lib/conf/schema.js +53 -8
  93. package/src/lib/epub/README.md +133 -2
  94. package/src/lib/epub/engines.js +46 -0
  95. package/src/lib/epub/epub.js +36 -11
  96. package/src/lib/epub/index.js +111 -21
  97. package/src/lib/epub/index.test.js +518 -0
  98. package/src/lib/epub/pandoc.js +33 -4
  99. package/src/lib/epub/pandoc.test.js +122 -0
  100. package/src/lib/epub/schema.js +21 -0
  101. package/src/lib/error/README.md +170 -0
  102. package/src/lib/error/handler.js +107 -0
  103. package/src/lib/git/README.md +151 -2
  104. package/src/lib/git/index.js +217 -13
  105. package/src/lib/git/index.spec.js +80 -0
  106. package/src/lib/git/index.test.js +453 -0
  107. package/src/lib/installer/index.js +309 -0
  108. package/src/lib/installer/index.spec.js +83 -0
  109. package/src/lib/installer/index.test.js +545 -0
  110. package/src/lib/logger/README.md +424 -0
  111. package/src/lib/logger/debug.js +132 -0
  112. package/src/lib/logger/debug.spec.js +130 -0
  113. package/src/lib/logger/debug.test.js +128 -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
@@ -0,0 +1,21 @@
1
+ /**
2
+ * EPUB engine schema definition
3
+ *
4
+ * This module has NO imports to avoid circular dependencies.
5
+ * It serves as the single source of truth for EPUB engine options.
6
+ */
7
+
8
+ /**
9
+ * Supported EPUB engines
10
+ * @type {string[]}
11
+ */
12
+ export const ENGINES = ['epubjs', 'pandoc']
13
+
14
+ /**
15
+ * Schema definition for epubEngine config property
16
+ */
17
+ export const schema = {
18
+ type: 'string',
19
+ enum: ENGINES,
20
+ description: 'Default EPUB engine to use (epubjs, pandoc)'
21
+ }
@@ -0,0 +1,170 @@
1
+ # Error Handler
2
+
3
+ Centralized error handling for the Quire CLI. All command errors flow through this handler for consistent formatting and exit code management.
4
+
5
+ ## Usage
6
+
7
+ ```javascript
8
+ import { handleError, handleErrors, formatError } from '#lib/error/handler.js'
9
+
10
+ try {
11
+ await someOperation()
12
+ } catch (error) {
13
+ handleError(error, { debug: options.debug })
14
+ }
15
+ ```
16
+
17
+ ## API
18
+
19
+ ### `formatError(error, options)`
20
+
21
+ Formats a QuireError for user display.
22
+
23
+ **Parameters:**
24
+ - `error` - The error to format (QuireError or Error)
25
+ - `options.debug` - Include error code in output (default: `false`)
26
+
27
+ **Returns:** Formatted string with error message and metadata.
28
+
29
+ **Output format:**
30
+ ```
31
+ [ERROR_CODE] Message # Code shown only in debug mode
32
+ File: /path/to/file # If error.filePath is set
33
+ Suggestion: Actionable fix # If error.suggestion is set
34
+ Learn more: https://docs.url # If error.docsUrl is set
35
+ Tip: Run with --debug for more details # If not in debug mode and showDebugHint !== false
36
+ ```
37
+
38
+ ### `handleError(error, options)`
39
+
40
+ Handles a single error: formats it, logs it, and exits.
41
+
42
+ **Parameters:**
43
+ - `error` - The error to handle
44
+ - `options.exit` - Whether to exit process (default: `true`)
45
+ - `options.exitFn` - Exit function for testing (default: `process.exit`)
46
+ - `options.debug` - Show stack traces (default: `false`)
47
+
48
+ **Returns:** Exit code (number)
49
+
50
+ **Behavior:**
51
+ - QuireError: Uses structured properties (code, exitCode, suggestion, docsUrl)
52
+ - Other errors: Shows as "Unexpected error" with issue report link
53
+
54
+ ### `handleErrors(errors, options)`
55
+
56
+ Handles multiple errors (for batch validation scenarios).
57
+
58
+ **Parameters:** Same as `handleError`, but takes array of errors.
59
+
60
+ **Returns:** Highest exit code from all errors.
61
+
62
+ ## Debug Mode
63
+
64
+ When `--debug` is enabled:
65
+ - Error codes are shown in the header (e.g., `[TOOL_NOT_FOUND]`)
66
+ - Stack traces are printed after the error message
67
+ - The "Tip: Run with --debug" hint is suppressed
68
+
69
+ ## Debug Hint Control
70
+
71
+ By default, errors display a tip suggesting the user run with `--debug` for more details. This can be disabled for errors where the fix is obvious and debug info wouldn't help.
72
+
73
+ ### Disabling the hint
74
+
75
+ Set `showDebugHint: false` in the error options:
76
+
77
+ ```javascript
78
+ class NotInProjectError extends QuireError {
79
+ constructor(commandName) {
80
+ super(`Must run inside a Quire project`, {
81
+ code: 'NOT_IN_PROJECT',
82
+ suggestion: "Navigate to your project folder with 'cd your-project-name'",
83
+ showDebugHint: false // Fix is obvious, debug won't help
84
+ })
85
+ }
86
+ }
87
+ ```
88
+
89
+ ### When to disable
90
+
91
+ Disable the hint for "simple user errors" where:
92
+ - The fix is immediately obvious from the message
93
+ - Debug output wouldn't provide additional useful information
94
+ - The user just needs to take a simple corrective action
95
+
96
+ **Examples of errors with hint disabled:**
97
+ - `NotInProjectError` - just cd to project folder
98
+ - `DirectoryNotEmptyError` - choose empty directory
99
+ - `InvalidPathError` - verify path exists
100
+ - `MissingBuildOutputError` - run `quire build` first
101
+ - `InvalidPdfLibraryError` - use valid library name
102
+ - `InvalidEpubLibraryError` - use valid library name
103
+
104
+ ### When to keep the hint
105
+
106
+ Keep the hint (default) for errors where:
107
+ - The issue may be complex or environmental
108
+ - Stack traces or debug logging would help diagnosis
109
+ - The user may need to report a bug
110
+
111
+ **Examples of errors with hint enabled (default):**
112
+ - `BuildFailedError` - complex build issues
113
+ - `PdfGenerationError` - tool/process issues
114
+ - `EpubGenerationError` - tool/process issues
115
+ - `ToolNotFoundError` - PATH/environment issues
116
+ - `DependencyInstallError` - npm issues
117
+ - Configuration and YAML validation errors
118
+
119
+ ## QuireError Properties
120
+
121
+ The handler understands these QuireError properties:
122
+
123
+ | Property | Type | Description |
124
+ |----------|------|-------------|
125
+ | `message` | string | Main error message |
126
+ | `code` | string | Error code (e.g., `'TOOL_NOT_FOUND'`) |
127
+ | `exitCode` | number | Process exit code (default: 1) |
128
+ | `suggestion` | string | Actionable fix for the user |
129
+ | `docsUrl` | string | Link to relevant documentation |
130
+ | `filePath` | string | Source file that caused the error |
131
+ | `showDebugHint` | boolean | Whether to show --debug hint (default: true) |
132
+
133
+ ## Exit Codes
134
+
135
+ | Code | Category | Examples |
136
+ |------|----------|----------|
137
+ | 1 | General error | Unexpected errors |
138
+ | 2 | Project error | Not in project, directory not empty |
139
+ | 3 | Build error | Config not found, build failed |
140
+ | 4 | Validation error | YAML parse error, validation failed |
141
+ | 5 | Output error | PDF/EPUB generation failed |
142
+ | 6 | Install error | Invalid path, dependency install failed |
143
+
144
+ ## Testing
145
+
146
+ Use the `exit` and `exitFn` options to prevent actual process exit:
147
+
148
+ ```javascript
149
+ import { handleError } from '#lib/error/handler.js'
150
+
151
+ const exitCode = handleError(error, {
152
+ exit: false, // Don't call process.exit
153
+ debug: true
154
+ })
155
+
156
+ t.is(exitCode, 5)
157
+ ```
158
+
159
+ Or provide a mock exit function:
160
+
161
+ ```javascript
162
+ const mockExit = sinon.stub()
163
+
164
+ handleError(error, {
165
+ exitFn: mockExit,
166
+ debug: false
167
+ })
168
+
169
+ t.true(mockExit.calledWith(5))
170
+ ```
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Centralized error handler for the Quire CLI
3
+ *
4
+ * Provides consistent error formatting and exit code management.
5
+ * All command errors should flow through this handler.
6
+ *
7
+ * @module lib/error/handler
8
+ */
9
+ import { logger } from '#lib/logger/index.js'
10
+ import QuireError from '#src/errors/quire-error.js'
11
+
12
+ /**
13
+ * Format a QuireError for user display
14
+ *
15
+ * @param {QuireError} error - The error to format
16
+ * @param {Object} options - Formatting options
17
+ * @param {boolean} options.debug - Include error code in output (default: false)
18
+ * @returns {string} Formatted error message
19
+ */
20
+ export function formatError(error, options = {}) {
21
+ const { debug = false } = options
22
+ // Only show error code (e.g., INVALID_PATH) in debug mode
23
+ const header = (debug && error.code) ? `${error.code} ${error.message}` : error.message
24
+ const lines = [header]
25
+ if (error.filePath) lines.push(` File: ${error.filePath}`)
26
+ if (error.suggestion) lines.push(` Suggestion: ${error.suggestion}`)
27
+ if (error.docsUrl) lines.push(` Learn more: ${error.docsUrl}`)
28
+ // Show debug hint when not in debug mode and error opts in
29
+ if (!debug && error.showDebugHint !== false) {
30
+ lines.push(' Tip: Run with --debug for more details')
31
+ }
32
+ return lines.join('\n')
33
+ }
34
+
35
+ /**
36
+ * Handle a single error
37
+ *
38
+ * @param {Error} error - The error to handle
39
+ * @param {Object} options
40
+ * @param {boolean} options.exit - Whether to exit process (default: true)
41
+ * @param {Function} options.exitFn - Exit function for testing (default: process.exit)
42
+ * @param {boolean} options.debug - Show stack traces (default: false)
43
+ * @returns {number} Exit code
44
+ */
45
+ export function handleError(error, options = {}) {
46
+ const { exit = true, exitFn = process.exit, debug = false } = options
47
+
48
+ if (error instanceof QuireError) {
49
+ logger.error(formatError(error, { debug }))
50
+
51
+ if (debug && error.stack) {
52
+ logger.debug('Stack trace:')
53
+ logger.debug(error.stack)
54
+ }
55
+
56
+ if (exit) exitFn(error.exitCode)
57
+ return error.exitCode
58
+ }
59
+
60
+ // Non-Quire errors (unexpected)
61
+ logger.error(`Unexpected error: ${error.message}`)
62
+ logger.info('Please report this issue: https://github.com/thegetty/quire/issues')
63
+
64
+ if (debug && error.stack) {
65
+ logger.debug('Stack trace:')
66
+ logger.debug(error.stack)
67
+ }
68
+
69
+ if (exit) exitFn(1)
70
+ return 1
71
+ }
72
+
73
+ /**
74
+ * Handle multiple errors (for batch validation)
75
+ *
76
+ * @param {Error[]} errors - Array of errors
77
+ * @param {Object} options - Same as handleError
78
+ * @returns {number} Highest exit code
79
+ */
80
+ export function handleErrors(errors, options = {}) {
81
+ if (!errors.length) return 0
82
+
83
+ const { exit = true, exitFn = process.exit, debug = false } = options
84
+
85
+ logger.error(`${errors.length} error(s) occurred:\n`)
86
+
87
+ let maxExitCode = 1
88
+ errors.forEach((error, i) => {
89
+ const formatted = error instanceof QuireError ? formatError(error, { debug }) : error.message
90
+ logger.error(`${i + 1}. ${formatted}`)
91
+ if (error instanceof QuireError && error.exitCode > maxExitCode) {
92
+ maxExitCode = error.exitCode
93
+ }
94
+ })
95
+
96
+ if (debug) {
97
+ errors.forEach((error) => {
98
+ if (error.stack) {
99
+ logger.debug(`Stack trace for ${error.message}:`)
100
+ logger.debug(error.stack)
101
+ }
102
+ })
103
+ }
104
+
105
+ if (exit) exitFn(maxExitCode)
106
+ return maxExitCode
107
+ }
@@ -1,3 +1,152 @@
1
- ## CLI Git Module
1
+ # Git Façade
2
2
 
3
- This `quire-cli/lib/git` module is a façade for interacting with `git` when creating new Quire projects and contributing to the `quire` code base, including the `quire-cli`.
3
+ A façade module that abstracts git command-line operations for the Quire CLI.
4
+
5
+ ## Purpose
6
+
7
+ This module provides a unified interface for git operations with:
8
+
9
+ - Encapsulation of git implementation details
10
+ - Consistent logging prefixed with `[CLI:lib/git]`
11
+ - Unified error handling
12
+ - Easy mockability for testing
13
+
14
+ ## Usage
15
+
16
+ ### Singleton (for global operations)
17
+
18
+ ```javascript
19
+ import git from '#lib/git/index.js'
20
+
21
+ // Check git availability
22
+ if (!git.isAvailable()) {
23
+ console.error('git is not installed')
24
+ }
25
+
26
+ // Get git version
27
+ // @see https://git-scm.com/docs/git-version
28
+ const version = await git.version()
29
+ ```
30
+
31
+ ### Git Class (for repository-scoped operations)
32
+
33
+ ```javascript
34
+ import { Git } from '#lib/git/index.js'
35
+
36
+ // Create instance with working directory
37
+ const repo = new Git('/path/to/project')
38
+
39
+ // Clone a repository
40
+ // @see https://git-scm.com/docs/git-clone
41
+ await repo.clone('https://github.com/user/repo', '.')
42
+
43
+ // Initialize a repository
44
+ // @see https://git-scm.com/docs/git-init
45
+ await repo.init()
46
+
47
+ // Stage files
48
+ // @see https://git-scm.com/docs/git-add
49
+ await repo.add('.')
50
+ await repo.add(['file1.js', 'file2.js'])
51
+
52
+ // Create a commit
53
+ // @see https://git-scm.com/docs/git-commit
54
+ await repo.commit('Initial commit')
55
+
56
+ // Remove files from tracking
57
+ // @see https://git-scm.com/docs/git-rm
58
+ await repo.rm('package.json')
59
+ await repo.rm(['file1.js', 'file2.js'])
60
+ ```
61
+
62
+ ## API
63
+
64
+ ### Constructor
65
+
66
+ #### `new Git(cwd)`
67
+
68
+ Create a Git façade instance scoped to a working directory.
69
+
70
+ **Parameters:**
71
+ - `cwd` (string, optional) - Working directory for all operations
72
+
73
+ ### Methods
74
+
75
+ #### `isAvailable()`
76
+
77
+ Check if git is available in PATH.
78
+
79
+ #### `version()`
80
+
81
+ Get the installed git version.
82
+
83
+ #### `add(files)`
84
+
85
+ Stage files for commit.
86
+
87
+ **Parameters:**
88
+ - `files` (string|string[]) - Files to stage (use `'.'` for all)
89
+
90
+ #### `clone(url, destination)`
91
+
92
+ Clone a repository.
93
+
94
+ **Parameters:**
95
+ - `url` (string) - Repository URL
96
+ - `destination` (string, default: `'.'`) - Destination directory
97
+
98
+ #### `commit(message)`
99
+
100
+ Create a commit with staged changes.
101
+
102
+ **Parameters:**
103
+ - `message` (string) - Commit message
104
+
105
+ #### `init()`
106
+
107
+ Initialize a new git repository.
108
+
109
+ #### `rm(files)`
110
+
111
+ Remove files from the working tree and index.
112
+
113
+ **Parameters:**
114
+ - `files` (string|string[]) - Files to remove
115
+
116
+ ## Testing
117
+
118
+ ### Mocking the singleton
119
+
120
+ ```javascript
121
+ import esmock from 'esmock'
122
+
123
+ const mockGit = {
124
+ isAvailable: sandbox.stub().returns(true),
125
+ version: sandbox.stub().resolves('2.39.0'),
126
+ }
127
+
128
+ const MyCommand = await esmock('./mycommand.js', {
129
+ '#lib/git/index.js': { default: mockGit }
130
+ })
131
+ ```
132
+
133
+ ### Mocking the Git class
134
+
135
+ ```javascript
136
+ import esmock from 'esmock'
137
+
138
+ const MockGit = class {
139
+ constructor(cwd) {
140
+ this.cwd = cwd
141
+ this.add = sandbox.stub().resolves()
142
+ this.clone = sandbox.stub().resolves()
143
+ this.commit = sandbox.stub().resolves()
144
+ this.init = sandbox.stub().resolves()
145
+ this.rm = sandbox.stub().resolves()
146
+ }
147
+ }
148
+
149
+ const MyCommand = await esmock('./mycommand.js', {
150
+ '#lib/git/index.js': { Git: MockGit }
151
+ })
152
+ ```
@@ -1,22 +1,226 @@
1
- import { simpleGit } from 'simple-git'
1
+ /**
2
+ * Git façade providing abstracted git operations
3
+ *
4
+ * Provides a consistent interface for git commands with unified
5
+ * logging, error handling, and testability.
6
+ *
7
+ * @example using default singleton for global operations
8
+ * import git from '#lib/git/index.js'
9
+ * const version = await git.version()
10
+ * if (!git.isAvailable()) { ... }
11
+ *
12
+ * @example using Git class for repository-scoped operations
13
+ * import { Git } from '#lib/git/index.js'
14
+ * const repo = new Git('/path/to/project')
15
+ * await repo.init()
16
+ * await repo.add('.')
17
+ * await repo.commit('Initial commit')
18
+ *
19
+ * @example mocking in tests
20
+ * const mockGit = {
21
+ * clone: sandbox.stub().resolves(),
22
+ * commit: sandbox.stub().resolves(),
23
+ * }
24
+ * const MyCommand = await esmock('./mycommand.js', {
25
+ * '#lib/git/index.js': { default: mockGit }
26
+ * })
27
+ *
28
+ * @see https://git-scm.com/docs - git documentation
29
+ * @module git
30
+ */
31
+ import { execa } from 'execa'
32
+ import fs from 'node:fs'
33
+ import path from 'node:path'
34
+ import which from '#helpers/which.js'
35
+ import createDebug from '#debug'
36
+
37
+ const debug = createDebug('lib:git')
38
+
39
+ /**
40
+ * Check if a source string is a remote URL (vs local path)
41
+ *
42
+ * Git supports various remote URL formats:
43
+ * - https://github.com/user/repo.git
44
+ * - git@github.com:user/repo.git
45
+ * - ssh://git@github.com/user/repo.git
46
+ * - http://github.com/user/repo.git
47
+ * - git://github.com/user/repo.git
48
+ *
49
+ * @param {string} source - Source string to check
50
+ * @returns {boolean} True if source appears to be a remote URL
51
+ */
52
+ export function isRemoteUrl(source) {
53
+ return source.startsWith('https://') ||
54
+ source.startsWith('git@') ||
55
+ source.startsWith('ssh://') ||
56
+ source.startsWith('http://') ||
57
+ source.startsWith('git://')
58
+ }
2
59
 
3
60
  /**
4
- * SimpleGit configuration
5
- * @see https://github.com/steveukx/git-js#configuration
61
+ * Check if a path is a git repository
62
+ *
63
+ * A directory is considered a git repository if it contains a .git directory.
64
+ *
65
+ * @param {string} dirPath - Path to check
66
+ * @returns {boolean} True if the path is a git repository
6
67
  */
7
- const options = {
8
- baseDir: process.cwd(),
9
- binary: 'git',
10
- maxConcurrentProcesses: 6,
11
- trimmed: false,
68
+ export function isGitRepository(dirPath) {
69
+ const gitDir = path.join(dirPath, '.git')
70
+ return fs.existsSync(gitDir)
12
71
  }
13
72
 
14
73
  /**
15
- * Configured SimpleGit instance
16
- * @see https://github.com/steveukx/git-js#api
74
+ * Validate a clone source before attempting to clone
17
75
  *
18
- * @type {SimpleGit}
76
+ * For remote URLs, validation is skipped (let git clone handle network errors).
77
+ * For local paths, validates the path exists and is a git repository.
78
+ *
79
+ * @param {string} source - Clone source (URL or local path)
80
+ * @returns {{ valid: boolean, reason?: string }} Validation result
19
81
  */
20
- const git = simpleGit(options)
82
+ export function validateCloneSource(source) {
83
+ // Remote URLs are assumed valid - let git clone handle errors
84
+ if (isRemoteUrl(source)) {
85
+ return { valid: true }
86
+ }
87
+
88
+ // Local path validation
89
+ if (!fs.existsSync(source)) {
90
+ return { valid: false, reason: 'path does not exist' }
91
+ }
92
+
93
+ if (!isGitRepository(source)) {
94
+ return { valid: false, reason: 'not a git repository' }
95
+ }
96
+
97
+ return { valid: true }
98
+ }
99
+
100
+ /**
101
+ * Git façade class
102
+ */
103
+ class Git {
104
+ /**
105
+ * Create a Git façade instance
106
+ * @param {string} [cwd] - Working directory for all operations
107
+ */
108
+ constructor(cwd) {
109
+ this.cwd = cwd
110
+ }
111
+
112
+ /**
113
+ * Get execa options with working directory
114
+ * @private
115
+ * @returns {Object} Options object for execa
116
+ */
117
+ #getOptions() {
118
+ return this.cwd ? { cwd: this.cwd } : {}
119
+ }
120
+
121
+ /**
122
+ * Get resolved working directory for logging
123
+ * @private
124
+ * @returns {string} Resolved absolute path
125
+ */
126
+ #resolvedCwd() {
127
+ return path.resolve(this.cwd || process.cwd())
128
+ }
129
+
130
+ /**
131
+ * Stage files for commit
132
+ * @see https://git-scm.com/docs/git-add
133
+ * @param {string|string[]} files - Files to stage (use '.' for all)
134
+ * @returns {Promise<void>}
135
+ */
136
+ async add(files) {
137
+ const fileList = Array.isArray(files) ? files : [files]
138
+ debug('staging files in %s: %s', this.#resolvedCwd(), fileList.join(', '))
139
+ const { stderr } = await execa('git', ['add', ...fileList], this.#getOptions())
140
+ if (stderr) {
141
+ debug('git add stderr: %s', stderr)
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Clone a repository
147
+ * @see https://git-scm.com/docs/git-clone
148
+ * @param {string} url - Repository URL
149
+ * @param {string} [destination='.'] - Destination directory
150
+ * @returns {Promise<void>}
151
+ */
152
+ async clone(url, destination = '.') {
153
+ const resolvedDest = path.resolve(this.#resolvedCwd(), destination)
154
+ debug('cloning %s to %s', url, resolvedDest)
155
+ const { stderr } = await execa('git', ['clone', url, destination], this.#getOptions())
156
+ if (stderr) {
157
+ debug('git clone stderr: %s', stderr)
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Create a commit
163
+ * @see https://git-scm.com/docs/git-commit
164
+ * @param {string} message - Commit message
165
+ * @returns {Promise<void>}
166
+ */
167
+ async commit(message) {
168
+ debug('committing in %s: %s...', this.#resolvedCwd(), message.substring(0, 50))
169
+ const { stderr } = await execa('git', ['commit', '-m', message], this.#getOptions())
170
+ if (stderr) {
171
+ debug('git commit stderr: %s', stderr)
172
+ }
173
+ }
174
+
175
+ /**
176
+ * Initialize a new repository
177
+ * @see https://git-scm.com/docs/git-init
178
+ * @returns {Promise<void>}
179
+ */
180
+ async init() {
181
+ debug('initializing repository in %s', this.#resolvedCwd())
182
+ const { stderr } = await execa('git', ['init'], this.#getOptions())
183
+ if (stderr) {
184
+ debug('git init stderr: %s', stderr)
185
+ }
186
+ }
187
+
188
+ /**
189
+ * Check if git is available in PATH
190
+ * @returns {boolean} True if git is available
191
+ */
192
+ isAvailable() {
193
+ return !!which('git')
194
+ }
195
+
196
+ /**
197
+ * Remove files from the working tree and index
198
+ * @see https://git-scm.com/docs/git-rm
199
+ * @param {string|string[]} files - Files to remove
200
+ * @returns {Promise<void>}
201
+ */
202
+ async rm(files) {
203
+ const fileList = Array.isArray(files) ? files : [files]
204
+ debug('removing files in %s: %s', this.#resolvedCwd(), fileList.join(', '))
205
+ const { stderr } = await execa('git', ['rm', ...fileList], this.#getOptions())
206
+ if (stderr) {
207
+ debug('git rm stderr: %s', stderr)
208
+ }
209
+ }
210
+
211
+ /**
212
+ * Get git version
213
+ * @see https://git-scm.com/docs/git-version
214
+ * @returns {Promise<string>} git version string
215
+ */
216
+ async version() {
217
+ const { stdout } = await execa('git', ['--version'])
218
+ return stdout.replace('git version ', '')
219
+ }
220
+ }
221
+
222
+ // Export class for repository-scoped operations
223
+ export { Git }
21
224
 
22
- export default git
225
+ // Export singleton instance for global operations
226
+ export default new Git()