diff --git a/.changeset/add-body-requirement-prompt.md b/.changeset/add-body-requirement-prompt.md new file mode 100644 index 0000000..4b65e27 --- /dev/null +++ b/.changeset/add-body-requirement-prompt.md @@ -0,0 +1,12 @@ +--- +"@labcatr/labcommitr": minor +--- + +feat: add body requirement prompt to init command + +- New prompt in init flow to set commit body as required or optional +- "Yes" option marked as recommended for better commit practices +- Configuration properly respected in commit prompts +- When body is required, commit prompts show "required" and remove "Skip" option +- Defaults to optional for backward compatibility + diff --git a/.changeset/add-commit-editor-support.md b/.changeset/add-commit-editor-support.md new file mode 100644 index 0000000..6c70807 --- /dev/null +++ b/.changeset/add-commit-editor-support.md @@ -0,0 +1,12 @@ +--- +"@labcatr/labcommitr": minor +--- + +feat: add editor support for commit body input + +- Users can now open their preferred editor for writing commit bodies +- Supports both inline and editor input methods +- Automatically detects available editors (VS Code, Vim, Nano, etc.) +- Improved experience for multi-line commit bodies +- Configurable editor preference in configuration files + diff --git a/.changeset/config-loading-system.md b/.changeset/config-loading-system.md deleted file mode 100644 index 4609f7b..0000000 --- a/.changeset/config-loading-system.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@labcatr/labcommitr": minor ---- - -feat: add intelligent configuration file discovery - -- Tool now automatically finds configuration files in project roots -- Prioritizes git repositories and supports monorepo structures -- Provides clear error messages when configuration files have issues -- Improved performance with smart caching for faster subsequent runs -- Works reliably across different project structures and environments \ No newline at end of file diff --git a/.changeset/config-validation-system.md b/.changeset/config-validation-system.md deleted file mode 100644 index 4abdd46..0000000 --- a/.changeset/config-validation-system.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@labcatr/labcommitr": minor ---- - -feat: add configuration file validation - -- Configuration files are now validated for syntax and required fields -- Clear error messages help users fix configuration issues quickly -- Tool prevents common mistakes like missing commit types or invalid IDs -- Improved reliability when loading project-specific configurations -- Validates commit type IDs contain only lowercase letters as required diff --git a/.changeset/config.json b/.changeset/config.json index 91b6a95..fce1c26 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -4,7 +4,7 @@ "commit": false, "fixed": [], "linked": [], - "access": "restricted", + "access": "public", "baseBranch": "main", "updateInternalDependencies": "patch", "ignore": [] diff --git a/.changeset/fix-label-truncation.md b/.changeset/fix-label-truncation.md new file mode 100644 index 0000000..12af765 --- /dev/null +++ b/.changeset/fix-label-truncation.md @@ -0,0 +1,11 @@ +--- +"@labcatr/labcommitr": patch +--- + +fix: prevent label text truncation in prompts + +- Increased label width from 6 to 7 characters to accommodate longer labels +- Fixes issue where "subject" label was being truncated to "subjec" +- Applied to both commit and init command prompts for consistency +- All labels now properly display full text with centered alignment + diff --git a/.changeset/improve-commit-command-ux.md b/.changeset/improve-commit-command-ux.md new file mode 100644 index 0000000..478537b --- /dev/null +++ b/.changeset/improve-commit-command-ux.md @@ -0,0 +1,12 @@ +--- +"@labcatr/labcommitr": minor +--- + +feat: enhance commit command user experience + +- Terminal automatically clears at command start for maximum available space +- Improved staged file detection with support for renamed and copied files +- Color-coded Git status indicators (A, M, D, R, C) matching Git's default colors +- Connector lines added to files and preview displays for better visual flow +- More accurate file status reporting with copy detection using -C50 flag + diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6584392..41d8fb1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -50,7 +50,7 @@ jobs: commit: "[ci] release" title: "[ci] release" env: - # Needs access to push to main - GITHUB_TOKEN: ${{ secrets.VOXEL_GITHUB_TOKEN }} + # Uses built-in GITHUB_TOKEN (automatically available, no secret needed) + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needs access to publish to npm NPM_TOKEN: ${{ secrets.NPM_AUTH_TOKEN }} diff --git a/.gitignore b/.gitignore index 957676a..9c44b80 100644 --- a/.gitignore +++ b/.gitignore @@ -24,19 +24,62 @@ jspm_packages/ # Snowpack dependency directory (https://snowpack.dev/) web_modules/ -# TypeScript cache +# TypeScript build artifacts *.tsbuildinfo +dist/ +build/ +out/ + +# Coverage reports +coverage/ +.nyc_output/ +*.lcov + +# Testing +.test-temp/ +test-results/ +scripts/ + +# npm/yarn/pnpm +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +.npm +.yarn + +# OS files +.DS_Store +Thumbs.db + +# IDE/Editor +.vscode/ +.idea/ +*.swp +*.swo +*~ +.project +.classpath +.settings/ + +# Temporary files +*.tmp +*.temp +.cache/ ### Other # Remove rust files for now rust-src/ -dist/ -### Development -# Development progress tracking (internal use only) -DEVELOPMENT_PROGRESS.md -REQUIREMENTS.md -CONFIG_SCHEMA.md -DEVELOPMENT_GUIDELINES.md -ARCHITECTURE_DECISIONS.md +### Documentation +# Ignore all .md files except README.md, CHANGELOG.md, and .changeset/*.md (local reference only) +*.md +!README.md +!CHANGELOG.md +!.changeset/*.md + +### Labcommitr Configuration +# User-specific configuration file (generated by 'lab init') +.labcommitr.config.yaml +.labcommitr.config.yml diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..de6d50d --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,67 @@ +# @labcatr/labcommitr + +## 0.1.0 + +### Minor Changes + +- feat: add body requirement prompt to init command + - New prompt in init flow to set commit body as required or optional + - "Yes" option marked as recommended for better commit practices + - Configuration properly respected in commit prompts + - When body is required, commit prompts show "required" and remove "Skip" option + - Defaults to optional for backward compatibility + +- feat: add editor support for commit body input + - Users can now open their preferred editor for writing commit bodies + - Supports both inline and editor input methods + - Automatically detects available editors (VS Code, Vim, Nano, etc.) + - Improved experience for multi-line commit bodies + - Configurable editor preference in configuration files + +- 8837714: feat: add working CLI framework with basic commands + - Tool now provides functional command-line interface with help system + - Both `labcommitr` and `lab` command aliases are now available + - Added `--version` flag to display current tool version + - Added `config show` command to display and debug configuration + - Interactive help system guides users through available commands + - Clear error messages when invalid commands are used + - Foundation ready for init and commit commands in upcoming releases + +- 20ab2ee: feat: add intelligent configuration file discovery + - Tool now automatically finds configuration files in project roots + - Prioritizes git repositories and supports monorepo structures + - Provides clear error messages when configuration files have issues + - Improved performance with smart caching for faster subsequent runs + - Works reliably across different project structures and environments + +- e041576: feat: add configuration file validation + - Configuration files are now validated for syntax and required fields + - Clear error messages help users fix configuration issues quickly + - Tool prevents common mistakes like missing commit types or invalid IDs + - Improved reliability when loading project-specific configurations + - Validates commit type IDs contain only lowercase letters as required + +- feat: enhance commit command user experience + - Terminal automatically clears at command start for maximum available space + - Improved staged file detection with support for renamed and copied files + - Color-coded Git status indicators (A, M, D, R, C) matching Git's default colors + - Connector lines added to files and preview displays for better visual flow + - More accurate file status reporting with copy detection using -C50 flag + +- 677a4ad: feat: add interactive init command with Clef mascot + - Introduced Clef, an animated cat mascot for enhanced user experience + - Implemented clean, minimal CLI prompts following modern design patterns + - Added four configuration presets: Conventional Commits, Gitmoji, Angular, and Minimal + - Created interactive setup flow with preset selection, emoji support, and scope configuration + - Integrated animated character that appears at key moments: intro, processing, and outro + - Automatic YAML configuration file generation with validation + - Non-intrusive design with clean labels and compact spacing + - Graceful degradation for terminals without animation support + +### Patch Changes + +- fix: prevent label text truncation in prompts + - Increased label width from 6 to 7 characters to accommodate longer labels + - Fixes issue where "subject" label was being truncated to "subjec" + - Applied to both commit and init command prompts for consistency + - All labels now properly display full text with centered alignment diff --git a/dist/index.d.ts b/dist/index.d.ts deleted file mode 100644 index cb0ff5c..0000000 --- a/dist/index.d.ts +++ /dev/null @@ -1 +0,0 @@ -export {}; diff --git a/dist/index.js b/dist/index.js deleted file mode 100644 index 3040bf5..0000000 --- a/dist/index.js +++ /dev/null @@ -1,3 +0,0 @@ -console.log("Hello World!"); -export {}; -//# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/dist/index.js.map b/dist/index.js.map deleted file mode 100644 index 895157f..0000000 --- a/dist/index.js.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,CAAC,GAAG,CAAC,cAAc,CAAC,CAAC"} \ No newline at end of file diff --git a/package.json b/package.json index b8bf9b2..b91d1ac 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@labcatr/labcommitr", - "version": "0.0.1", + "version": "0.1.0", "description": "Labcommitr is a solution for building standardized git commits, hassle-free!", "main": "dist/index.js", "scripts": { @@ -9,11 +9,14 @@ "format": "pnpm run format:code", "format:ci": "pnpm run format:code", "format:code": "prettier -w \"**/*\" --ignore-unknown --cache", - "version": "changeset version && pnpm install --no-frozen-lockfile && pnpm run format" + "version": "changeset version && pnpm install --no-frozen-lockfile && pnpm run format", + "test:commit:sandbox": "bash scripts/test-commit-sandbox.sh", + "test:commit:reset": "bash scripts/reset-sandbox.sh" }, "type": "module", "bin": { - "labcommitr": "./dist/index.js" + "labcommitr": "./dist/index.js", + "lab": "./dist/index.js" }, "keywords": [ "git", @@ -29,12 +32,15 @@ "license": "ISC", "dependencies": { "@changesets/cli": "^2.29.7", + "@clack/prompts": "^0.11.0", "@types/node": "^24.3.3", "boxen": "^8.0.1", + "commander": "^14.0.2", "consola": "^3.4.2", "cosmiconfig": "^9.0.0", "js-yaml": "^4.1.0", "magicast": "^0.3.5", + "picocolors": "^1.1.1", "prettier": "^3.6.2", "typescript": "^5.9.2", "ufo": "^1.6.1" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e9bed2a..605c165 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,22 +1,27 @@ -lockfileVersion: '9.0' +lockfileVersion: "9.0" settings: autoInstallPeers: true excludeLinksFromLockfile: false importers: - .: dependencies: - '@changesets/cli': + "@changesets/cli": specifier: ^2.29.7 version: 2.29.7(@types/node@24.3.3) - '@types/node': + "@clack/prompts": + specifier: ^0.11.0 + version: 0.11.0 + "@types/node": specifier: ^24.3.3 version: 24.3.3 boxen: specifier: ^8.0.1 version: 8.0.1 + commander: + specifier: ^14.0.2 + version: 14.0.2 consola: specifier: ^3.4.2 version: 3.4.2 @@ -29,6 +34,9 @@ importers: magicast: specifier: ^0.3.5 version: 0.3.5 + picocolors: + specifier: ^1.1.1 + version: 1.1.1 prettier: specifier: ^3.6.2 version: 3.6.2 @@ -39,596 +47,1057 @@ importers: specifier: ^1.6.1 version: 1.6.1 devDependencies: - '@types/js-yaml': + "@types/js-yaml": specifier: ^4.0.9 version: 4.0.9 packages: - - '@babel/code-frame@7.27.1': - resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} - engines: {node: '>=6.9.0'} - - '@babel/helper-string-parser@7.25.9': - resolution: {integrity: sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==} - engines: {node: '>=6.9.0'} - - '@babel/helper-validator-identifier@7.25.9': - resolution: {integrity: sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==} - engines: {node: '>=6.9.0'} - - '@babel/helper-validator-identifier@7.27.1': - resolution: {integrity: sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==} - engines: {node: '>=6.9.0'} - - '@babel/parser@7.27.0': - resolution: {integrity: sha512-iaepho73/2Pz7w2eMS0Q5f83+0RKI7i4xmiYeBmDzfRVbQtTOG7Ts0S4HzJVsTMGI9keU8rNfuZr8DKfSt7Yyg==} - engines: {node: '>=6.0.0'} + "@babel/code-frame@7.27.1": + resolution: + { + integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==, + } + engines: { node: ">=6.9.0" } + + "@babel/helper-string-parser@7.25.9": + resolution: + { + integrity: sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==, + } + engines: { node: ">=6.9.0" } + + "@babel/helper-validator-identifier@7.25.9": + resolution: + { + integrity: sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==, + } + engines: { node: ">=6.9.0" } + + "@babel/helper-validator-identifier@7.27.1": + resolution: + { + integrity: sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==, + } + engines: { node: ">=6.9.0" } + + "@babel/parser@7.27.0": + resolution: + { + integrity: sha512-iaepho73/2Pz7w2eMS0Q5f83+0RKI7i4xmiYeBmDzfRVbQtTOG7Ts0S4HzJVsTMGI9keU8rNfuZr8DKfSt7Yyg==, + } + engines: { node: ">=6.0.0" } hasBin: true - '@babel/runtime@7.27.0': - resolution: {integrity: sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==} - engines: {node: '>=6.9.0'} - - '@babel/types@7.27.0': - resolution: {integrity: sha512-H45s8fVLYjbhFH62dIJ3WtmJ6RSPt/3DRO0ZcT2SUiYiQyz3BLVb9ADEnLl91m74aQPS3AzzeajZHYOalWe3bg==} - engines: {node: '>=6.9.0'} - - '@changesets/apply-release-plan@7.0.13': - resolution: {integrity: sha512-BIW7bofD2yAWoE8H4V40FikC+1nNFEKBisMECccS16W1rt6qqhNTBDmIw5HaqmMgtLNz9e7oiALiEUuKrQ4oHg==} - - '@changesets/assemble-release-plan@6.0.9': - resolution: {integrity: sha512-tPgeeqCHIwNo8sypKlS3gOPmsS3wP0zHt67JDuL20P4QcXiw/O4Hl7oXiuLnP9yg+rXLQ2sScdV1Kkzde61iSQ==} - - '@changesets/changelog-git@0.2.1': - resolution: {integrity: sha512-x/xEleCFLH28c3bQeQIyeZf8lFXyDFVn1SgcBiR2Tw/r4IAWlk1fzxCEZ6NxQAjF2Nwtczoen3OA2qR+UawQ8Q==} - - '@changesets/cli@2.29.7': - resolution: {integrity: sha512-R7RqWoaksyyKXbKXBTbT4REdy22yH81mcFK6sWtqSanxUCbUi9Uf+6aqxZtDQouIqPdem2W56CdxXgsxdq7FLQ==} + "@babel/runtime@7.27.0": + resolution: + { + integrity: sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==, + } + engines: { node: ">=6.9.0" } + + "@babel/types@7.27.0": + resolution: + { + integrity: sha512-H45s8fVLYjbhFH62dIJ3WtmJ6RSPt/3DRO0ZcT2SUiYiQyz3BLVb9ADEnLl91m74aQPS3AzzeajZHYOalWe3bg==, + } + engines: { node: ">=6.9.0" } + + "@changesets/apply-release-plan@7.0.13": + resolution: + { + integrity: sha512-BIW7bofD2yAWoE8H4V40FikC+1nNFEKBisMECccS16W1rt6qqhNTBDmIw5HaqmMgtLNz9e7oiALiEUuKrQ4oHg==, + } + + "@changesets/assemble-release-plan@6.0.9": + resolution: + { + integrity: sha512-tPgeeqCHIwNo8sypKlS3gOPmsS3wP0zHt67JDuL20P4QcXiw/O4Hl7oXiuLnP9yg+rXLQ2sScdV1Kkzde61iSQ==, + } + + "@changesets/changelog-git@0.2.1": + resolution: + { + integrity: sha512-x/xEleCFLH28c3bQeQIyeZf8lFXyDFVn1SgcBiR2Tw/r4IAWlk1fzxCEZ6NxQAjF2Nwtczoen3OA2qR+UawQ8Q==, + } + + "@changesets/cli@2.29.7": + resolution: + { + integrity: sha512-R7RqWoaksyyKXbKXBTbT4REdy22yH81mcFK6sWtqSanxUCbUi9Uf+6aqxZtDQouIqPdem2W56CdxXgsxdq7FLQ==, + } hasBin: true - '@changesets/config@3.1.1': - resolution: {integrity: sha512-bd+3Ap2TKXxljCggI0mKPfzCQKeV/TU4yO2h2C6vAihIo8tzseAn2e7klSuiyYYXvgu53zMN1OeYMIQkaQoWnA==} - - '@changesets/errors@0.2.0': - resolution: {integrity: sha512-6BLOQUscTpZeGljvyQXlWOItQyU71kCdGz7Pi8H8zdw6BI0g3m43iL4xKUVPWtG+qrrL9DTjpdn8eYuCQSRpow==} - - '@changesets/get-dependents-graph@2.1.3': - resolution: {integrity: sha512-gphr+v0mv2I3Oxt19VdWRRUxq3sseyUpX9DaHpTUmLj92Y10AGy+XOtV+kbM6L/fDcpx7/ISDFK6T8A/P3lOdQ==} - - '@changesets/get-release-plan@4.0.13': - resolution: {integrity: sha512-DWG1pus72FcNeXkM12tx+xtExyH/c9I1z+2aXlObH3i9YA7+WZEVaiHzHl03thpvAgWTRaH64MpfHxozfF7Dvg==} - - '@changesets/get-version-range-type@0.4.0': - resolution: {integrity: sha512-hwawtob9DryoGTpixy1D3ZXbGgJu1Rhr+ySH2PvTLHvkZuQ7sRT4oQwMh0hbqZH1weAooedEjRsbrWcGLCeyVQ==} - - '@changesets/git@3.0.4': - resolution: {integrity: sha512-BXANzRFkX+XcC1q/d27NKvlJ1yf7PSAgi8JG6dt8EfbHFHi4neau7mufcSca5zRhwOL8j9s6EqsxmT+s+/E6Sw==} - - '@changesets/logger@0.1.1': - resolution: {integrity: sha512-OQtR36ZlnuTxKqoW4Sv6x5YIhOmClRd5pWsjZsddYxpWs517R0HkyiefQPIytCVh4ZcC5x9XaG8KTdd5iRQUfg==} - - '@changesets/parse@0.4.1': - resolution: {integrity: sha512-iwksMs5Bf/wUItfcg+OXrEpravm5rEd9Bf4oyIPL4kVTmJQ7PNDSd6MDYkpSJR1pn7tz/k8Zf2DhTCqX08Ou+Q==} - - '@changesets/pre@2.0.2': - resolution: {integrity: sha512-HaL/gEyFVvkf9KFg6484wR9s0qjAXlZ8qWPDkTyKF6+zqjBe/I2mygg3MbpZ++hdi0ToqNUF8cjj7fBy0dg8Ug==} - - '@changesets/read@0.6.5': - resolution: {integrity: sha512-UPzNGhsSjHD3Veb0xO/MwvasGe8eMyNrR/sT9gR8Q3DhOQZirgKhhXv/8hVsI0QpPjR004Z9iFxoJU6in3uGMg==} - - '@changesets/should-skip-package@0.1.2': - resolution: {integrity: sha512-qAK/WrqWLNCP22UDdBTMPH5f41elVDlsNyat180A33dWxuUDyNpg6fPi/FyTZwRriVjg0L8gnjJn2F9XAoF0qw==} - - '@changesets/types@4.1.0': - resolution: {integrity: sha512-LDQvVDv5Kb50ny2s25Fhm3d9QSZimsoUGBsUioj6MC3qbMUCuC8GPIvk/M6IvXx3lYhAs0lwWUQLb+VIEUCECw==} - - '@changesets/types@6.1.0': - resolution: {integrity: sha512-rKQcJ+o1nKNgeoYRHKOS07tAMNd3YSN0uHaJOZYjBAgxfV7TUE7JE+z4BzZdQwb5hKaYbayKN5KrYV7ODb2rAA==} - - '@changesets/write@0.4.0': - resolution: {integrity: sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q==} - - '@inquirer/external-editor@1.0.1': - resolution: {integrity: sha512-Oau4yL24d2B5IL4ma4UpbQigkVhzPDXLoqy1ggK4gnHg/stmkffJE4oOXHXF3uz0UEpywG68KcyXsyYpA1Re/Q==} - engines: {node: '>=18'} + "@changesets/config@3.1.1": + resolution: + { + integrity: sha512-bd+3Ap2TKXxljCggI0mKPfzCQKeV/TU4yO2h2C6vAihIo8tzseAn2e7klSuiyYYXvgu53zMN1OeYMIQkaQoWnA==, + } + + "@changesets/errors@0.2.0": + resolution: + { + integrity: sha512-6BLOQUscTpZeGljvyQXlWOItQyU71kCdGz7Pi8H8zdw6BI0g3m43iL4xKUVPWtG+qrrL9DTjpdn8eYuCQSRpow==, + } + + "@changesets/get-dependents-graph@2.1.3": + resolution: + { + integrity: sha512-gphr+v0mv2I3Oxt19VdWRRUxq3sseyUpX9DaHpTUmLj92Y10AGy+XOtV+kbM6L/fDcpx7/ISDFK6T8A/P3lOdQ==, + } + + "@changesets/get-release-plan@4.0.13": + resolution: + { + integrity: sha512-DWG1pus72FcNeXkM12tx+xtExyH/c9I1z+2aXlObH3i9YA7+WZEVaiHzHl03thpvAgWTRaH64MpfHxozfF7Dvg==, + } + + "@changesets/get-version-range-type@0.4.0": + resolution: + { + integrity: sha512-hwawtob9DryoGTpixy1D3ZXbGgJu1Rhr+ySH2PvTLHvkZuQ7sRT4oQwMh0hbqZH1weAooedEjRsbrWcGLCeyVQ==, + } + + "@changesets/git@3.0.4": + resolution: + { + integrity: sha512-BXANzRFkX+XcC1q/d27NKvlJ1yf7PSAgi8JG6dt8EfbHFHi4neau7mufcSca5zRhwOL8j9s6EqsxmT+s+/E6Sw==, + } + + "@changesets/logger@0.1.1": + resolution: + { + integrity: sha512-OQtR36ZlnuTxKqoW4Sv6x5YIhOmClRd5pWsjZsddYxpWs517R0HkyiefQPIytCVh4ZcC5x9XaG8KTdd5iRQUfg==, + } + + "@changesets/parse@0.4.1": + resolution: + { + integrity: sha512-iwksMs5Bf/wUItfcg+OXrEpravm5rEd9Bf4oyIPL4kVTmJQ7PNDSd6MDYkpSJR1pn7tz/k8Zf2DhTCqX08Ou+Q==, + } + + "@changesets/pre@2.0.2": + resolution: + { + integrity: sha512-HaL/gEyFVvkf9KFg6484wR9s0qjAXlZ8qWPDkTyKF6+zqjBe/I2mygg3MbpZ++hdi0ToqNUF8cjj7fBy0dg8Ug==, + } + + "@changesets/read@0.6.5": + resolution: + { + integrity: sha512-UPzNGhsSjHD3Veb0xO/MwvasGe8eMyNrR/sT9gR8Q3DhOQZirgKhhXv/8hVsI0QpPjR004Z9iFxoJU6in3uGMg==, + } + + "@changesets/should-skip-package@0.1.2": + resolution: + { + integrity: sha512-qAK/WrqWLNCP22UDdBTMPH5f41elVDlsNyat180A33dWxuUDyNpg6fPi/FyTZwRriVjg0L8gnjJn2F9XAoF0qw==, + } + + "@changesets/types@4.1.0": + resolution: + { + integrity: sha512-LDQvVDv5Kb50ny2s25Fhm3d9QSZimsoUGBsUioj6MC3qbMUCuC8GPIvk/M6IvXx3lYhAs0lwWUQLb+VIEUCECw==, + } + + "@changesets/types@6.1.0": + resolution: + { + integrity: sha512-rKQcJ+o1nKNgeoYRHKOS07tAMNd3YSN0uHaJOZYjBAgxfV7TUE7JE+z4BzZdQwb5hKaYbayKN5KrYV7ODb2rAA==, + } + + "@changesets/write@0.4.0": + resolution: + { + integrity: sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q==, + } + + "@clack/core@0.5.0": + resolution: + { + integrity: sha512-p3y0FIOwaYRUPRcMO7+dlmLh8PSRcrjuTndsiA0WAFbWES0mLZlrjVoBRZ9DzkPFJZG6KGkJmoEAY0ZcVWTkow==, + } + + "@clack/prompts@0.11.0": + resolution: + { + integrity: sha512-pMN5FcrEw9hUkZA4f+zLlzivQSeQf5dRGJjSUbvVYDLvpKCdQx5OaknvKzgbtXOizhP+SJJJjqEbOe55uKKfAw==, + } + + "@inquirer/external-editor@1.0.1": + resolution: + { + integrity: sha512-Oau4yL24d2B5IL4ma4UpbQigkVhzPDXLoqy1ggK4gnHg/stmkffJE4oOXHXF3uz0UEpywG68KcyXsyYpA1Re/Q==, + } + engines: { node: ">=18" } peerDependencies: - '@types/node': '>=18' + "@types/node": ">=18" peerDependenciesMeta: - '@types/node': + "@types/node": optional: true - '@manypkg/find-root@1.1.0': - resolution: {integrity: sha512-mki5uBvhHzO8kYYix/WRy2WX8S3B5wdVSc9D6KcU5lQNglP2yt58/VfLuAK49glRXChosY8ap2oJ1qgma3GUVA==} - - '@manypkg/get-packages@1.1.3': - resolution: {integrity: sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A==} - - '@nodelib/fs.scandir@2.1.5': - resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} - engines: {node: '>= 8'} - - '@nodelib/fs.stat@2.0.5': - resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} - engines: {node: '>= 8'} - - '@nodelib/fs.walk@1.2.8': - resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} - engines: {node: '>= 8'} - - '@types/js-yaml@4.0.9': - resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==} - - '@types/node@12.20.55': - resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==} - - '@types/node@24.3.3': - resolution: {integrity: sha512-GKBNHjoNw3Kra1Qg5UXttsY5kiWMEfoHq2TmXb+b1rcm6N7B3wTrFYIf/oSZ1xNQ+hVVijgLkiDZh7jRRsh+Gw==} + "@manypkg/find-root@1.1.0": + resolution: + { + integrity: sha512-mki5uBvhHzO8kYYix/WRy2WX8S3B5wdVSc9D6KcU5lQNglP2yt58/VfLuAK49glRXChosY8ap2oJ1qgma3GUVA==, + } + + "@manypkg/get-packages@1.1.3": + resolution: + { + integrity: sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A==, + } + + "@nodelib/fs.scandir@2.1.5": + resolution: + { + integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==, + } + engines: { node: ">= 8" } + + "@nodelib/fs.stat@2.0.5": + resolution: + { + integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==, + } + engines: { node: ">= 8" } + + "@nodelib/fs.walk@1.2.8": + resolution: + { + integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==, + } + engines: { node: ">= 8" } + + "@types/js-yaml@4.0.9": + resolution: + { + integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==, + } + + "@types/node@12.20.55": + resolution: + { + integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==, + } + + "@types/node@24.3.3": + resolution: + { + integrity: sha512-GKBNHjoNw3Kra1Qg5UXttsY5kiWMEfoHq2TmXb+b1rcm6N7B3wTrFYIf/oSZ1xNQ+hVVijgLkiDZh7jRRsh+Gw==, + } ansi-align@3.0.1: - resolution: {integrity: sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==} + resolution: + { + integrity: sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==, + } ansi-colors@4.1.3: - resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} - engines: {node: '>=6'} + resolution: + { + integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==, + } + engines: { node: ">=6" } ansi-regex@5.0.1: - resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} - engines: {node: '>=8'} + resolution: + { + integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==, + } + engines: { node: ">=8" } ansi-regex@6.1.0: - resolution: {integrity: sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==} - engines: {node: '>=12'} + resolution: + { + integrity: sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==, + } + engines: { node: ">=12" } ansi-styles@6.2.1: - resolution: {integrity: sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==} - engines: {node: '>=12'} + resolution: + { + integrity: sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==, + } + engines: { node: ">=12" } argparse@1.0.10: - resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + resolution: + { + integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==, + } argparse@2.0.1: - resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + resolution: + { + integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==, + } array-union@2.1.0: - resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} - engines: {node: '>=8'} + resolution: + { + integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==, + } + engines: { node: ">=8" } better-path-resolve@1.0.0: - resolution: {integrity: sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==} - engines: {node: '>=4'} + resolution: + { + integrity: sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==, + } + engines: { node: ">=4" } boxen@8.0.1: - resolution: {integrity: sha512-F3PH5k5juxom4xktynS7MoFY+NUWH5LC4CnH11YB8NPew+HLpmBLCybSAEyb2F+4pRXhuhWqFesoQd6DAyc2hw==} - engines: {node: '>=18'} + resolution: + { + integrity: sha512-F3PH5k5juxom4xktynS7MoFY+NUWH5LC4CnH11YB8NPew+HLpmBLCybSAEyb2F+4pRXhuhWqFesoQd6DAyc2hw==, + } + engines: { node: ">=18" } braces@3.0.3: - resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} - engines: {node: '>=8'} + resolution: + { + integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==, + } + engines: { node: ">=8" } callsites@3.1.0: - resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} - engines: {node: '>=6'} + resolution: + { + integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==, + } + engines: { node: ">=6" } camelcase@8.0.0: - resolution: {integrity: sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==} - engines: {node: '>=16'} + resolution: + { + integrity: sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==, + } + engines: { node: ">=16" } chalk@5.4.1: - resolution: {integrity: sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==} - engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + resolution: + { + integrity: sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==, + } + engines: { node: ^12.17.0 || ^14.13 || >=16.0.0 } chardet@2.1.0: - resolution: {integrity: sha512-bNFETTG/pM5ryzQ9Ad0lJOTa6HWD/YsScAR3EnCPZRPlQh77JocYktSHOUHelyhm8IARL+o4c4F1bP5KVOjiRA==} + resolution: + { + integrity: sha512-bNFETTG/pM5ryzQ9Ad0lJOTa6HWD/YsScAR3EnCPZRPlQh77JocYktSHOUHelyhm8IARL+o4c4F1bP5KVOjiRA==, + } ci-info@3.9.0: - resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} - engines: {node: '>=8'} + resolution: + { + integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==, + } + engines: { node: ">=8" } cli-boxes@3.0.0: - resolution: {integrity: sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==} - engines: {node: '>=10'} + resolution: + { + integrity: sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==, + } + engines: { node: ">=10" } + + commander@14.0.2: + resolution: + { + integrity: sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ==, + } + engines: { node: ">=20" } consola@3.4.2: - resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} - engines: {node: ^14.18.0 || >=16.10.0} + resolution: + { + integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==, + } + engines: { node: ^14.18.0 || >=16.10.0 } cosmiconfig@9.0.0: - resolution: {integrity: sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==} - engines: {node: '>=14'} + resolution: + { + integrity: sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==, + } + engines: { node: ">=14" } peerDependencies: - typescript: '>=4.9.5' + typescript: ">=4.9.5" peerDependenciesMeta: typescript: optional: true cross-spawn@7.0.6: - resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} - engines: {node: '>= 8'} + resolution: + { + integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==, + } + engines: { node: ">= 8" } detect-indent@6.1.0: - resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==} - engines: {node: '>=8'} + resolution: + { + integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==, + } + engines: { node: ">=8" } dir-glob@3.0.1: - resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} - engines: {node: '>=8'} + resolution: + { + integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==, + } + engines: { node: ">=8" } emoji-regex@10.5.0: - resolution: {integrity: sha512-lb49vf1Xzfx080OKA0o6l8DQQpV+6Vg95zyCJX9VB/BqKYlhG7N4wgROUUHRA+ZPUefLnteQOad7z1kT2bV7bg==} + resolution: + { + integrity: sha512-lb49vf1Xzfx080OKA0o6l8DQQpV+6Vg95zyCJX9VB/BqKYlhG7N4wgROUUHRA+ZPUefLnteQOad7z1kT2bV7bg==, + } emoji-regex@8.0.0: - resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + resolution: + { + integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==, + } enquirer@2.4.1: - resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==} - engines: {node: '>=8.6'} + resolution: + { + integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==, + } + engines: { node: ">=8.6" } env-paths@2.2.1: - resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} - engines: {node: '>=6'} + resolution: + { + integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==, + } + engines: { node: ">=6" } error-ex@1.3.4: - resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} + resolution: + { + integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==, + } esprima@4.0.1: - resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} - engines: {node: '>=4'} + resolution: + { + integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==, + } + engines: { node: ">=4" } hasBin: true extendable-error@0.1.7: - resolution: {integrity: sha512-UOiS2in6/Q0FK0R0q6UY9vYpQ21mr/Qn1KOnte7vsACuNJf514WvCCUHSRCPcgjPT2bAhNIJdlE6bVap1GKmeg==} + resolution: + { + integrity: sha512-UOiS2in6/Q0FK0R0q6UY9vYpQ21mr/Qn1KOnte7vsACuNJf514WvCCUHSRCPcgjPT2bAhNIJdlE6bVap1GKmeg==, + } fast-glob@3.3.3: - resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} - engines: {node: '>=8.6.0'} + resolution: + { + integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==, + } + engines: { node: ">=8.6.0" } fastq@1.19.1: - resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} + resolution: + { + integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==, + } fill-range@7.1.1: - resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} - engines: {node: '>=8'} + resolution: + { + integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==, + } + engines: { node: ">=8" } find-up@4.1.0: - resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} - engines: {node: '>=8'} + resolution: + { + integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==, + } + engines: { node: ">=8" } fs-extra@7.0.1: - resolution: {integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==} - engines: {node: '>=6 <7 || >=8'} + resolution: + { + integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==, + } + engines: { node: ">=6 <7 || >=8" } fs-extra@8.1.0: - resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==} - engines: {node: '>=6 <7 || >=8'} + resolution: + { + integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==, + } + engines: { node: ">=6 <7 || >=8" } get-east-asian-width@1.4.0: - resolution: {integrity: sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==} - engines: {node: '>=18'} + resolution: + { + integrity: sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==, + } + engines: { node: ">=18" } glob-parent@5.1.2: - resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} - engines: {node: '>= 6'} + resolution: + { + integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==, + } + engines: { node: ">= 6" } globby@11.1.0: - resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} - engines: {node: '>=10'} + resolution: + { + integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==, + } + engines: { node: ">=10" } graceful-fs@4.2.11: - resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + resolution: + { + integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==, + } human-id@4.1.1: - resolution: {integrity: sha512-3gKm/gCSUipeLsRYZbbdA1BD83lBoWUkZ7G9VFrhWPAU76KwYo5KR8V28bpoPm/ygy0x5/GCbpRQdY7VLYCoIg==} + resolution: + { + integrity: sha512-3gKm/gCSUipeLsRYZbbdA1BD83lBoWUkZ7G9VFrhWPAU76KwYo5KR8V28bpoPm/ygy0x5/GCbpRQdY7VLYCoIg==, + } hasBin: true iconv-lite@0.6.3: - resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} - engines: {node: '>=0.10.0'} + resolution: + { + integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==, + } + engines: { node: ">=0.10.0" } ignore@5.3.2: - resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} - engines: {node: '>= 4'} + resolution: + { + integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==, + } + engines: { node: ">= 4" } import-fresh@3.3.1: - resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} - engines: {node: '>=6'} + resolution: + { + integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==, + } + engines: { node: ">=6" } is-arrayish@0.2.1: - resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + resolution: + { + integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==, + } is-extglob@2.1.1: - resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} - engines: {node: '>=0.10.0'} + resolution: + { + integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==, + } + engines: { node: ">=0.10.0" } is-fullwidth-code-point@3.0.0: - resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} - engines: {node: '>=8'} + resolution: + { + integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==, + } + engines: { node: ">=8" } is-glob@4.0.3: - resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} - engines: {node: '>=0.10.0'} + resolution: + { + integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==, + } + engines: { node: ">=0.10.0" } is-number@7.0.0: - resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} - engines: {node: '>=0.12.0'} + resolution: + { + integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==, + } + engines: { node: ">=0.12.0" } is-subdir@1.2.0: - resolution: {integrity: sha512-2AT6j+gXe/1ueqbW6fLZJiIw3F8iXGJtt0yDrZaBhAZEG1raiTxKWU+IPqMCzQAXOUCKdA4UDMgacKH25XG2Cw==} - engines: {node: '>=4'} + resolution: + { + integrity: sha512-2AT6j+gXe/1ueqbW6fLZJiIw3F8iXGJtt0yDrZaBhAZEG1raiTxKWU+IPqMCzQAXOUCKdA4UDMgacKH25XG2Cw==, + } + engines: { node: ">=4" } is-windows@1.0.2: - resolution: {integrity: sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==} - engines: {node: '>=0.10.0'} + resolution: + { + integrity: sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==, + } + engines: { node: ">=0.10.0" } isexe@2.0.0: - resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + resolution: + { + integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==, + } js-tokens@4.0.0: - resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + resolution: + { + integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==, + } js-yaml@3.14.1: - resolution: {integrity: sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==} + resolution: + { + integrity: sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==, + } hasBin: true js-yaml@4.1.0: - resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} + resolution: + { + integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==, + } hasBin: true json-parse-even-better-errors@2.3.1: - resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + resolution: + { + integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==, + } jsonfile@4.0.0: - resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} + resolution: + { + integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==, + } lines-and-columns@1.2.4: - resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + resolution: + { + integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==, + } locate-path@5.0.0: - resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} - engines: {node: '>=8'} + resolution: + { + integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==, + } + engines: { node: ">=8" } lodash.startcase@4.4.0: - resolution: {integrity: sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==} + resolution: + { + integrity: sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==, + } magicast@0.3.5: - resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==} + resolution: + { + integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==, + } merge2@1.4.1: - resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} - engines: {node: '>= 8'} + resolution: + { + integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==, + } + engines: { node: ">= 8" } micromatch@4.0.8: - resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} - engines: {node: '>=8.6'} + resolution: + { + integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==, + } + engines: { node: ">=8.6" } mri@1.2.0: - resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} - engines: {node: '>=4'} + resolution: + { + integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==, + } + engines: { node: ">=4" } outdent@0.5.0: - resolution: {integrity: sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q==} + resolution: + { + integrity: sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q==, + } p-filter@2.1.0: - resolution: {integrity: sha512-ZBxxZ5sL2HghephhpGAQdoskxplTwr7ICaehZwLIlfL6acuVgZPm8yBNuRAFBGEqtD/hmUeq9eqLg2ys9Xr/yw==} - engines: {node: '>=8'} + resolution: + { + integrity: sha512-ZBxxZ5sL2HghephhpGAQdoskxplTwr7ICaehZwLIlfL6acuVgZPm8yBNuRAFBGEqtD/hmUeq9eqLg2ys9Xr/yw==, + } + engines: { node: ">=8" } p-limit@2.3.0: - resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} - engines: {node: '>=6'} + resolution: + { + integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==, + } + engines: { node: ">=6" } p-locate@4.1.0: - resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} - engines: {node: '>=8'} + resolution: + { + integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==, + } + engines: { node: ">=8" } p-map@2.1.0: - resolution: {integrity: sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==} - engines: {node: '>=6'} + resolution: + { + integrity: sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==, + } + engines: { node: ">=6" } p-try@2.2.0: - resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} - engines: {node: '>=6'} + resolution: + { + integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==, + } + engines: { node: ">=6" } package-manager-detector@0.2.11: - resolution: {integrity: sha512-BEnLolu+yuz22S56CU1SUKq3XC3PkwD5wv4ikR4MfGvnRVcmzXR9DwSlW2fEamyTPyXHomBJRzgapeuBvRNzJQ==} + resolution: + { + integrity: sha512-BEnLolu+yuz22S56CU1SUKq3XC3PkwD5wv4ikR4MfGvnRVcmzXR9DwSlW2fEamyTPyXHomBJRzgapeuBvRNzJQ==, + } parent-module@1.0.1: - resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} - engines: {node: '>=6'} + resolution: + { + integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==, + } + engines: { node: ">=6" } parse-json@5.2.0: - resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} - engines: {node: '>=8'} + resolution: + { + integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==, + } + engines: { node: ">=8" } path-exists@4.0.0: - resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} - engines: {node: '>=8'} + resolution: + { + integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==, + } + engines: { node: ">=8" } path-key@3.1.1: - resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} - engines: {node: '>=8'} + resolution: + { + integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==, + } + engines: { node: ">=8" } path-type@4.0.0: - resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} - engines: {node: '>=8'} + resolution: + { + integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==, + } + engines: { node: ">=8" } picocolors@1.1.1: - resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + resolution: + { + integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==, + } picomatch@2.3.1: - resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} - engines: {node: '>=8.6'} + resolution: + { + integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==, + } + engines: { node: ">=8.6" } pify@4.0.1: - resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} - engines: {node: '>=6'} + resolution: + { + integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==, + } + engines: { node: ">=6" } prettier@2.8.8: - resolution: {integrity: sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==} - engines: {node: '>=10.13.0'} + resolution: + { + integrity: sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==, + } + engines: { node: ">=10.13.0" } hasBin: true prettier@3.6.2: - resolution: {integrity: sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==} - engines: {node: '>=14'} + resolution: + { + integrity: sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==, + } + engines: { node: ">=14" } hasBin: true quansync@0.2.10: - resolution: {integrity: sha512-t41VRkMYbkHyCYmOvx/6URnN80H7k4X0lLdBMGsz+maAwrJQYB1djpV6vHrQIBE0WBSGqhtEHrK9U3DWWH8v7A==} + resolution: + { + integrity: sha512-t41VRkMYbkHyCYmOvx/6URnN80H7k4X0lLdBMGsz+maAwrJQYB1djpV6vHrQIBE0WBSGqhtEHrK9U3DWWH8v7A==, + } queue-microtask@1.2.3: - resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + resolution: + { + integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==, + } read-yaml-file@1.1.0: - resolution: {integrity: sha512-VIMnQi/Z4HT2Fxuwg5KrY174U1VdUIASQVWXXyqtNRtxSr9IYkn1rsI6Tb6HsrHCmB7gVpNwX6JxPTHcH6IoTA==} - engines: {node: '>=6'} + resolution: + { + integrity: sha512-VIMnQi/Z4HT2Fxuwg5KrY174U1VdUIASQVWXXyqtNRtxSr9IYkn1rsI6Tb6HsrHCmB7gVpNwX6JxPTHcH6IoTA==, + } + engines: { node: ">=6" } regenerator-runtime@0.14.1: - resolution: {integrity: sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==} + resolution: + { + integrity: sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==, + } resolve-from@4.0.0: - resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} - engines: {node: '>=4'} + resolution: + { + integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==, + } + engines: { node: ">=4" } resolve-from@5.0.0: - resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} - engines: {node: '>=8'} + resolution: + { + integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==, + } + engines: { node: ">=8" } reusify@1.1.0: - resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} - engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + resolution: + { + integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==, + } + engines: { iojs: ">=1.0.0", node: ">=0.10.0" } run-parallel@1.2.0: - resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + resolution: + { + integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==, + } safer-buffer@2.1.2: - resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + resolution: + { + integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==, + } semver@7.7.1: - resolution: {integrity: sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==} - engines: {node: '>=10'} + resolution: + { + integrity: sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==, + } + engines: { node: ">=10" } hasBin: true shebang-command@2.0.0: - resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} - engines: {node: '>=8'} + resolution: + { + integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==, + } + engines: { node: ">=8" } shebang-regex@3.0.0: - resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} - engines: {node: '>=8'} + resolution: + { + integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==, + } + engines: { node: ">=8" } signal-exit@4.1.0: - resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} - engines: {node: '>=14'} + resolution: + { + integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==, + } + engines: { node: ">=14" } + + sisteransi@1.0.5: + resolution: + { + integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==, + } slash@3.0.0: - resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} - engines: {node: '>=8'} + resolution: + { + integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==, + } + engines: { node: ">=8" } source-map-js@1.2.1: - resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} - engines: {node: '>=0.10.0'} + resolution: + { + integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==, + } + engines: { node: ">=0.10.0" } spawndamnit@3.0.1: - resolution: {integrity: sha512-MmnduQUuHCoFckZoWnXsTg7JaiLBJrKFj9UI2MbRPGaJeVpsLcVBu6P/IGZovziM/YBsellCmsprgNA+w0CzVg==} + resolution: + { + integrity: sha512-MmnduQUuHCoFckZoWnXsTg7JaiLBJrKFj9UI2MbRPGaJeVpsLcVBu6P/IGZovziM/YBsellCmsprgNA+w0CzVg==, + } sprintf-js@1.0.3: - resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + resolution: + { + integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==, + } string-width@4.2.3: - resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} - engines: {node: '>=8'} + resolution: + { + integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==, + } + engines: { node: ">=8" } string-width@7.2.0: - resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} - engines: {node: '>=18'} + resolution: + { + integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==, + } + engines: { node: ">=18" } strip-ansi@6.0.1: - resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} - engines: {node: '>=8'} + resolution: + { + integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==, + } + engines: { node: ">=8" } strip-ansi@7.1.0: - resolution: {integrity: sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==} - engines: {node: '>=12'} + resolution: + { + integrity: sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==, + } + engines: { node: ">=12" } strip-bom@3.0.0: - resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} - engines: {node: '>=4'} + resolution: + { + integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==, + } + engines: { node: ">=4" } term-size@2.2.1: - resolution: {integrity: sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==} - engines: {node: '>=8'} + resolution: + { + integrity: sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==, + } + engines: { node: ">=8" } to-regex-range@5.0.1: - resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} - engines: {node: '>=8.0'} + resolution: + { + integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==, + } + engines: { node: ">=8.0" } type-fest@4.41.0: - resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} - engines: {node: '>=16'} + resolution: + { + integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==, + } + engines: { node: ">=16" } typescript@5.9.2: - resolution: {integrity: sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==} - engines: {node: '>=14.17'} + resolution: + { + integrity: sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==, + } + engines: { node: ">=14.17" } hasBin: true ufo@1.6.1: - resolution: {integrity: sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==} + resolution: + { + integrity: sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==, + } undici-types@7.10.0: - resolution: {integrity: sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==} + resolution: + { + integrity: sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==, + } universalify@0.1.2: - resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} - engines: {node: '>= 4.0.0'} + resolution: + { + integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==, + } + engines: { node: ">= 4.0.0" } which@2.0.2: - resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} - engines: {node: '>= 8'} + resolution: + { + integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==, + } + engines: { node: ">= 8" } hasBin: true widest-line@5.0.0: - resolution: {integrity: sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA==} - engines: {node: '>=18'} + resolution: + { + integrity: sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA==, + } + engines: { node: ">=18" } wrap-ansi@9.0.2: - resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} - engines: {node: '>=18'} + resolution: + { + integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==, + } + engines: { node: ">=18" } snapshots: - - '@babel/code-frame@7.27.1': + "@babel/code-frame@7.27.1": dependencies: - '@babel/helper-validator-identifier': 7.27.1 + "@babel/helper-validator-identifier": 7.27.1 js-tokens: 4.0.0 picocolors: 1.1.1 - '@babel/helper-string-parser@7.25.9': {} + "@babel/helper-string-parser@7.25.9": {} - '@babel/helper-validator-identifier@7.25.9': {} + "@babel/helper-validator-identifier@7.25.9": {} - '@babel/helper-validator-identifier@7.27.1': {} + "@babel/helper-validator-identifier@7.27.1": {} - '@babel/parser@7.27.0': + "@babel/parser@7.27.0": dependencies: - '@babel/types': 7.27.0 + "@babel/types": 7.27.0 - '@babel/runtime@7.27.0': + "@babel/runtime@7.27.0": dependencies: regenerator-runtime: 0.14.1 - '@babel/types@7.27.0': + "@babel/types@7.27.0": dependencies: - '@babel/helper-string-parser': 7.25.9 - '@babel/helper-validator-identifier': 7.25.9 + "@babel/helper-string-parser": 7.25.9 + "@babel/helper-validator-identifier": 7.25.9 - '@changesets/apply-release-plan@7.0.13': + "@changesets/apply-release-plan@7.0.13": dependencies: - '@changesets/config': 3.1.1 - '@changesets/get-version-range-type': 0.4.0 - '@changesets/git': 3.0.4 - '@changesets/should-skip-package': 0.1.2 - '@changesets/types': 6.1.0 - '@manypkg/get-packages': 1.1.3 + "@changesets/config": 3.1.1 + "@changesets/get-version-range-type": 0.4.0 + "@changesets/git": 3.0.4 + "@changesets/should-skip-package": 0.1.2 + "@changesets/types": 6.1.0 + "@manypkg/get-packages": 1.1.3 detect-indent: 6.1.0 fs-extra: 7.0.1 lodash.startcase: 4.4.0 @@ -637,37 +1106,37 @@ snapshots: resolve-from: 5.0.0 semver: 7.7.1 - '@changesets/assemble-release-plan@6.0.9': + "@changesets/assemble-release-plan@6.0.9": dependencies: - '@changesets/errors': 0.2.0 - '@changesets/get-dependents-graph': 2.1.3 - '@changesets/should-skip-package': 0.1.2 - '@changesets/types': 6.1.0 - '@manypkg/get-packages': 1.1.3 + "@changesets/errors": 0.2.0 + "@changesets/get-dependents-graph": 2.1.3 + "@changesets/should-skip-package": 0.1.2 + "@changesets/types": 6.1.0 + "@manypkg/get-packages": 1.1.3 semver: 7.7.1 - '@changesets/changelog-git@0.2.1': - dependencies: - '@changesets/types': 6.1.0 - - '@changesets/cli@2.29.7(@types/node@24.3.3)': - dependencies: - '@changesets/apply-release-plan': 7.0.13 - '@changesets/assemble-release-plan': 6.0.9 - '@changesets/changelog-git': 0.2.1 - '@changesets/config': 3.1.1 - '@changesets/errors': 0.2.0 - '@changesets/get-dependents-graph': 2.1.3 - '@changesets/get-release-plan': 4.0.13 - '@changesets/git': 3.0.4 - '@changesets/logger': 0.1.1 - '@changesets/pre': 2.0.2 - '@changesets/read': 0.6.5 - '@changesets/should-skip-package': 0.1.2 - '@changesets/types': 6.1.0 - '@changesets/write': 0.4.0 - '@inquirer/external-editor': 1.0.1(@types/node@24.3.3) - '@manypkg/get-packages': 1.1.3 + "@changesets/changelog-git@0.2.1": + dependencies: + "@changesets/types": 6.1.0 + + "@changesets/cli@2.29.7(@types/node@24.3.3)": + dependencies: + "@changesets/apply-release-plan": 7.0.13 + "@changesets/assemble-release-plan": 6.0.9 + "@changesets/changelog-git": 0.2.1 + "@changesets/config": 3.1.1 + "@changesets/errors": 0.2.0 + "@changesets/get-dependents-graph": 2.1.3 + "@changesets/get-release-plan": 4.0.13 + "@changesets/git": 3.0.4 + "@changesets/logger": 0.1.1 + "@changesets/pre": 2.0.2 + "@changesets/read": 0.6.5 + "@changesets/should-skip-package": 0.1.2 + "@changesets/types": 6.1.0 + "@changesets/write": 0.4.0 + "@inquirer/external-editor": 1.0.1(@types/node@24.3.3) + "@manypkg/get-packages": 1.1.3 ansi-colors: 4.1.3 ci-info: 3.9.0 enquirer: 2.4.1 @@ -681,130 +1150,141 @@ snapshots: spawndamnit: 3.0.1 term-size: 2.2.1 transitivePeerDependencies: - - '@types/node' + - "@types/node" - '@changesets/config@3.1.1': + "@changesets/config@3.1.1": dependencies: - '@changesets/errors': 0.2.0 - '@changesets/get-dependents-graph': 2.1.3 - '@changesets/logger': 0.1.1 - '@changesets/types': 6.1.0 - '@manypkg/get-packages': 1.1.3 + "@changesets/errors": 0.2.0 + "@changesets/get-dependents-graph": 2.1.3 + "@changesets/logger": 0.1.1 + "@changesets/types": 6.1.0 + "@manypkg/get-packages": 1.1.3 fs-extra: 7.0.1 micromatch: 4.0.8 - '@changesets/errors@0.2.0': + "@changesets/errors@0.2.0": dependencies: extendable-error: 0.1.7 - '@changesets/get-dependents-graph@2.1.3': + "@changesets/get-dependents-graph@2.1.3": dependencies: - '@changesets/types': 6.1.0 - '@manypkg/get-packages': 1.1.3 + "@changesets/types": 6.1.0 + "@manypkg/get-packages": 1.1.3 picocolors: 1.1.1 semver: 7.7.1 - '@changesets/get-release-plan@4.0.13': + "@changesets/get-release-plan@4.0.13": dependencies: - '@changesets/assemble-release-plan': 6.0.9 - '@changesets/config': 3.1.1 - '@changesets/pre': 2.0.2 - '@changesets/read': 0.6.5 - '@changesets/types': 6.1.0 - '@manypkg/get-packages': 1.1.3 + "@changesets/assemble-release-plan": 6.0.9 + "@changesets/config": 3.1.1 + "@changesets/pre": 2.0.2 + "@changesets/read": 0.6.5 + "@changesets/types": 6.1.0 + "@manypkg/get-packages": 1.1.3 - '@changesets/get-version-range-type@0.4.0': {} + "@changesets/get-version-range-type@0.4.0": {} - '@changesets/git@3.0.4': + "@changesets/git@3.0.4": dependencies: - '@changesets/errors': 0.2.0 - '@manypkg/get-packages': 1.1.3 + "@changesets/errors": 0.2.0 + "@manypkg/get-packages": 1.1.3 is-subdir: 1.2.0 micromatch: 4.0.8 spawndamnit: 3.0.1 - '@changesets/logger@0.1.1': + "@changesets/logger@0.1.1": dependencies: picocolors: 1.1.1 - '@changesets/parse@0.4.1': + "@changesets/parse@0.4.1": dependencies: - '@changesets/types': 6.1.0 + "@changesets/types": 6.1.0 js-yaml: 3.14.1 - '@changesets/pre@2.0.2': + "@changesets/pre@2.0.2": dependencies: - '@changesets/errors': 0.2.0 - '@changesets/types': 6.1.0 - '@manypkg/get-packages': 1.1.3 + "@changesets/errors": 0.2.0 + "@changesets/types": 6.1.0 + "@manypkg/get-packages": 1.1.3 fs-extra: 7.0.1 - '@changesets/read@0.6.5': + "@changesets/read@0.6.5": dependencies: - '@changesets/git': 3.0.4 - '@changesets/logger': 0.1.1 - '@changesets/parse': 0.4.1 - '@changesets/types': 6.1.0 + "@changesets/git": 3.0.4 + "@changesets/logger": 0.1.1 + "@changesets/parse": 0.4.1 + "@changesets/types": 6.1.0 fs-extra: 7.0.1 p-filter: 2.1.0 picocolors: 1.1.1 - '@changesets/should-skip-package@0.1.2': + "@changesets/should-skip-package@0.1.2": dependencies: - '@changesets/types': 6.1.0 - '@manypkg/get-packages': 1.1.3 + "@changesets/types": 6.1.0 + "@manypkg/get-packages": 1.1.3 - '@changesets/types@4.1.0': {} + "@changesets/types@4.1.0": {} - '@changesets/types@6.1.0': {} + "@changesets/types@6.1.0": {} - '@changesets/write@0.4.0': + "@changesets/write@0.4.0": dependencies: - '@changesets/types': 6.1.0 + "@changesets/types": 6.1.0 fs-extra: 7.0.1 human-id: 4.1.1 prettier: 2.8.8 - '@inquirer/external-editor@1.0.1(@types/node@24.3.3)': + "@clack/core@0.5.0": + dependencies: + picocolors: 1.1.1 + sisteransi: 1.0.5 + + "@clack/prompts@0.11.0": + dependencies: + "@clack/core": 0.5.0 + picocolors: 1.1.1 + sisteransi: 1.0.5 + + "@inquirer/external-editor@1.0.1(@types/node@24.3.3)": dependencies: chardet: 2.1.0 iconv-lite: 0.6.3 optionalDependencies: - '@types/node': 24.3.3 + "@types/node": 24.3.3 - '@manypkg/find-root@1.1.0': + "@manypkg/find-root@1.1.0": dependencies: - '@babel/runtime': 7.27.0 - '@types/node': 12.20.55 + "@babel/runtime": 7.27.0 + "@types/node": 12.20.55 find-up: 4.1.0 fs-extra: 8.1.0 - '@manypkg/get-packages@1.1.3': + "@manypkg/get-packages@1.1.3": dependencies: - '@babel/runtime': 7.27.0 - '@changesets/types': 4.1.0 - '@manypkg/find-root': 1.1.0 + "@babel/runtime": 7.27.0 + "@changesets/types": 4.1.0 + "@manypkg/find-root": 1.1.0 fs-extra: 8.1.0 globby: 11.1.0 read-yaml-file: 1.1.0 - '@nodelib/fs.scandir@2.1.5': + "@nodelib/fs.scandir@2.1.5": dependencies: - '@nodelib/fs.stat': 2.0.5 + "@nodelib/fs.stat": 2.0.5 run-parallel: 1.2.0 - '@nodelib/fs.stat@2.0.5': {} + "@nodelib/fs.stat@2.0.5": {} - '@nodelib/fs.walk@1.2.8': + "@nodelib/fs.walk@1.2.8": dependencies: - '@nodelib/fs.scandir': 2.1.5 + "@nodelib/fs.scandir": 2.1.5 fastq: 1.19.1 - '@types/js-yaml@4.0.9': {} + "@types/js-yaml@4.0.9": {} - '@types/node@12.20.55': {} + "@types/node@12.20.55": {} - '@types/node@24.3.3': + "@types/node@24.3.3": dependencies: undici-types: 7.10.0 @@ -859,6 +1339,8 @@ snapshots: cli-boxes@3.0.0: {} + commander@14.0.2: {} + consola@3.4.2: {} cosmiconfig@9.0.0(typescript@5.9.2): @@ -903,8 +1385,8 @@ snapshots: fast-glob@3.3.3: dependencies: - '@nodelib/fs.stat': 2.0.5 - '@nodelib/fs.walk': 1.2.8 + "@nodelib/fs.stat": 2.0.5 + "@nodelib/fs.walk": 1.2.8 glob-parent: 5.1.2 merge2: 1.4.1 micromatch: 4.0.8 @@ -1011,8 +1493,8 @@ snapshots: magicast@0.3.5: dependencies: - '@babel/parser': 7.27.0 - '@babel/types': 7.27.0 + "@babel/parser": 7.27.0 + "@babel/types": 7.27.0 source-map-js: 1.2.1 merge2@1.4.1: {} @@ -1052,7 +1534,7 @@ snapshots: parse-json@5.2.0: dependencies: - '@babel/code-frame': 7.27.1 + "@babel/code-frame": 7.27.1 error-ex: 1.3.4 json-parse-even-better-errors: 2.3.1 lines-and-columns: 1.2.4 @@ -1108,6 +1590,8 @@ snapshots: signal-exit@4.1.0: {} + sisteransi@1.0.5: {} + slash@3.0.0: {} source-map-js@1.2.1: {} diff --git a/src/cli/commands/commit.ts b/src/cli/commands/commit.ts new file mode 100644 index 0000000..c702f63 --- /dev/null +++ b/src/cli/commands/commit.ts @@ -0,0 +1,21 @@ +/** + * Commit command implementation + * + * Interactive commit creation with standardized formatting based on + * project configuration. + */ + +import { Command } from "commander"; +import { commitAction } from "./commit/index.js"; + +/** + * Commit command + */ +export const commitCommand = new Command("commit") + .description("Create a standardized commit (interactive)") + .alias("c") + .option("-t, --type ", "Commit type (feat, fix, etc.)") + .option("-s, --scope ", "Commit scope") + .option("-m, --message ", "Commit subject") + .option("--no-verify", "Bypass git hooks") + .action(commitAction); diff --git a/src/cli/commands/commit/editor.ts b/src/cli/commands/commit/editor.ts new file mode 100644 index 0000000..37a4995 --- /dev/null +++ b/src/cli/commands/commit/editor.ts @@ -0,0 +1,167 @@ +/** + * Editor Support for Commit Body + * + * Handles spawning external editors (nvim, vim, vi) for commit message body input + */ + +import { spawnSync } from "child_process"; +import { + writeFileSync, + readFileSync, + unlinkSync, + mkdtempSync, + rmdirSync, +} from "fs"; +import { join, dirname } from "path"; +import { tmpdir } from "os"; +import { Logger } from "../../../lib/logger.js"; + +/** + * Detect available editor in priority order: nvim → vim → vi + * Also checks $EDITOR and $VISUAL environment variables + */ +export function detectEditor(): string | null { + // Check environment variables first (user preference) + const envEditor = process.env.EDITOR || process.env.VISUAL; + if (envEditor) { + // Verify the editor exists + const check = spawnSync("which", [envEditor], { encoding: "utf-8" }); + if (check.status === 0) { + return envEditor.trim(); + } + } + + // Try nvim, vim, vi in order + const editors = ["nvim", "vim", "vi"]; + for (const editor of editors) { + const check = spawnSync("which", [editor], { encoding: "utf-8" }); + if (check.status === 0) { + return editor; + } + } + + return null; +} + +/** + * Open editor with content and return edited text + * + * @param initialContent - Initial content to show in editor + * @param editor - Editor command to use (if null, auto-detect) + * @returns Edited content or null if cancelled/failed + */ +export function editInEditor( + initialContent: string = "", + editor?: string | null, +): string | null { + const editorCommand = editor || detectEditor(); + + if (!editorCommand) { + Logger.error("No editor found"); + console.error("\n No editor available (nvim, vim, or vi)"); + console.error( + " Set $EDITOR environment variable to your preferred editor\n", + ); + return null; + } + + // Create temporary file + let tempFile: string; + try { + const tempDir = mkdtempSync(join(tmpdir(), "labcommitr-")); + tempFile = join(tempDir, "COMMIT_BODY"); + writeFileSync(tempFile, initialContent, "utf-8"); + } catch (error) { + Logger.error("Failed to create temporary file"); + const errorMessage = error instanceof Error ? error.message : String(error); + console.error(`\n Error: ${errorMessage}\n`); + return null; + } + + // Spawn editor + try { + // Determine editor arguments based on editor type + let editorArgs: string[]; + const isNeovim = editorCommand.includes("nvim"); + + if (isNeovim) { + // Neovim: use -f flag to run in foreground (don't detach) + editorArgs = ["-f", tempFile]; + } else { + // Vim/Vi: just pass the file + editorArgs = [tempFile]; + } + + const result = spawnSync(editorCommand, editorArgs, { + stdio: "inherit", + shell: false, + }); + + // Editor returned - check if successful + if (result.error) { + Logger.error(`Editor execution failed: ${editorCommand}`); + const errorMessage = + result.error instanceof Error + ? result.error.message + : String(result.error); + console.error(`\n Editor error: ${errorMessage}\n`); + + // Cleanup + try { + unlinkSync(tempFile); + } catch { + // Ignore cleanup errors + } + + return null; + } + + // Read back the file + try { + const content = readFileSync(tempFile, "utf-8"); + + // Cleanup + try { + unlinkSync(tempFile); + // Also try to remove temp dir if empty + const tempDir = dirname(tempFile); + try { + rmdirSync(tempDir); + } catch { + // Ignore if not empty + } + } catch { + // Ignore cleanup errors + } + + return content.trim(); + } catch (error) { + Logger.error("Failed to read edited file"); + const errorMessage = + error instanceof Error ? error.message : String(error); + console.error(`\n Error reading file: ${errorMessage}\n`); + + // Cleanup + try { + unlinkSync(tempFile); + } catch { + // Ignore cleanup errors + } + + return null; + } + } catch (error) { + Logger.error(`Failed to spawn editor: ${editorCommand}`); + const errorMessage = error instanceof Error ? error.message : String(error); + console.error(`\n Error: ${errorMessage}\n`); + + // Cleanup + try { + unlinkSync(tempFile); + } catch { + // Ignore cleanup errors + } + + return null; + } +} diff --git a/src/cli/commands/commit/formatter.ts b/src/cli/commands/commit/formatter.ts new file mode 100644 index 0000000..0635c6a --- /dev/null +++ b/src/cli/commands/commit/formatter.ts @@ -0,0 +1,29 @@ +/** + * Message Formatter + * + * Formats commit messages according to configuration template + */ + +import type { LabcommitrConfig } from "../../../lib/config/types.js"; + +/** + * Format commit message from template + */ +export function formatCommitMessage( + config: LabcommitrConfig, + type: string, + typeEmoji: string | undefined, + scope: string | undefined, + subject: string, +): string { + let message = config.format.template; + + // Replace variables + const emoji = config.config.emoji_enabled && typeEmoji ? typeEmoji : ""; + message = message.replace("{emoji}", emoji); + message = message.replace("{type}", type); + message = message.replace("{scope}", scope || ""); + message = message.replace("{subject}", subject); + + return message; +} diff --git a/src/cli/commands/commit/git.ts b/src/cli/commands/commit/git.ts new file mode 100644 index 0000000..3d45a95 --- /dev/null +++ b/src/cli/commands/commit/git.ts @@ -0,0 +1,294 @@ +/** + * Git Operations + * + * Handles all git operations for the commit command: + * - Checking git status + * - Staging files + * - Getting staged file information + * - Executing commits + * - Cleanup (unstaging) + */ + +import { execSync, spawnSync } from "child_process"; +import { Logger } from "../../../lib/logger.js"; +import type { StagedFileInfo, GitStatus } from "./types.js"; + +/** + * Execute git command and return stdout + * Uses spawnSync with separate command and args to avoid shell interpretation + * This prevents issues with special characters like parentheses and colons + */ +function execGit(args: string[]): string { + try { + const result = spawnSync("git", args, { + encoding: "utf-8", + stdio: ["ignore", "pipe", "pipe"], + }); + + if (result.error) { + throw result.error; + } + + if (result.status !== 0) { + const stderr = result.stderr?.toString() || "Unknown error"; + const error = new Error(stderr); + (error as any).code = result.status; + throw error; + } + + return result.stdout?.toString().trim() || ""; + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : String(error); + Logger.error(`Git command failed: git ${args.join(" ")}`); + Logger.error(errorMessage); + throw error; + } +} + +/** + * Check if current directory is a git repository + */ +export function isGitRepository(): boolean { + try { + execGit(["rev-parse", "--git-dir"]); + return true; + } catch { + return false; + } +} + +/** + * Get staged files (files already staged before auto-stage) + */ +function getStagedFiles(): string[] { + try { + const output = execGit(["diff", "--cached", "--name-only"]); + return output ? output.split("\n").filter((f) => f.trim()) : []; + } catch { + return []; + } +} + +/** + * Get unstaged tracked files (modified/deleted) + */ +function getUnstagedTrackedFiles(): string[] { + try { + const output = execGit(["diff", "--name-only"]); + return output ? output.split("\n").filter((f) => f.trim()) : []; + } catch { + return []; + } +} + +/** + * Check if there are untracked files + */ +function hasUntrackedFiles(): boolean { + try { + const output = execGit(["ls-files", "--others", "--exclude-standard"]); + return output.trim().length > 0; + } catch { + return false; + } +} + +/** + * Stage all modified/deleted tracked files (git add -u) + */ +export function stageAllTrackedFiles(): string[] { + const beforeStaged = getStagedFiles(); + execGit(["add", "-u"]); + const afterStaged = getStagedFiles(); + + // Return files that were newly staged + return afterStaged.filter((file) => !beforeStaged.includes(file)); +} + +/** + * Get detailed information about staged files + */ +export function getStagedFilesInfo(): StagedFileInfo[] { + try { + // Get file statuses + // Use --find-copies-harder with threshold to detect copied files (C) + // --find-copies-harder also checks unmodified files as potential sources + // Threshold 50 means 50% similarity required + const statusOutput = execGit([ + "diff", + "--cached", + "--name-status", + "--find-copies-harder", + "-C50", + ]); + if (!statusOutput) return []; + + // Get line statistics + const statsOutput = execGit(["diff", "--cached", "--numstat", "--format="]); + + const statusLines = statusOutput.split("\n").filter((l) => l.trim()); + const statsMap = new Map< + string, + { additions: number; deletions: number } + >(); + + if (statsOutput) { + const statsLines = statsOutput.split("\n").filter((l) => l.trim()); + for (const line of statsLines) { + const parts = line.split(/\s+/); + if (parts.length >= 3) { + const additions = parseInt(parts[0], 10) || 0; + const deletions = parseInt(parts[1], 10) || 0; + const path = parts.slice(2).join(" "); + statsMap.set(path, { additions, deletions }); + } + } + } + + const files: StagedFileInfo[] = []; + const alreadyStaged = new Set(getStagedFiles()); + + for (const line of statusLines) { + // Handle different git diff --cached --name-status formats: + // - Simple: "A file.ts", "M file.ts", "D file.ts" + // - Renamed: "R100\told.ts\tnew.ts" or "R\told.ts\tnew.ts" + // - Copied: "C100\toriginal.ts\tcopy.ts" or "C\toriginal.ts\tcopy.ts" + + let match = line.match(/^([MAD])\s+(.+)$/); + let statusCode: string; + let path: string; + + if (match) { + // Simple format: A, M, D + [, statusCode, path] = match; + } else { + // Renamed or Copied format: R100\told\tnew or R\told\tnew + match = line.match(/^([RC])(?:\d+)?\s+(.+)\t(.+)$/); + if (match) { + const [, code, oldPath, newPath] = match; + statusCode = code; + // For renames/copies, use the new path + path = newPath; + } else { + // Skip lines that don't match expected format + continue; + } + } + + const stats = statsMap.get(path); + + // Determine status type + let status: StagedFileInfo["status"] = "M"; + if (statusCode === "A") status = "A"; + else if (statusCode === "D") status = "D"; + else if (statusCode === "R") status = "R"; + else if (statusCode === "C") status = "C"; + else if (statusCode === "M") status = "M"; + + files.push({ + path, + status, + additions: stats?.additions, + deletions: stats?.deletions, + }); + } + + return files; + } catch { + return []; + } +} + +/** + * Get git status information + */ +export function getGitStatus(alreadyStagedPaths: string[]): GitStatus { + const alreadyStaged = alreadyStagedPaths; + const allStagedInfo = getStagedFilesInfo(); + + // Separate already staged from newly staged + const alreadyStagedSet = new Set(alreadyStaged); + const alreadyStagedInfo: StagedFileInfo[] = []; + const newlyStagedInfo: StagedFileInfo[] = []; + + for (const file of allStagedInfo) { + const info = { ...file, wasAlreadyStaged: alreadyStagedSet.has(file.path) }; + if (alreadyStagedSet.has(file.path)) { + alreadyStagedInfo.push(info); + } else { + newlyStagedInfo.push(info); + } + } + + return { + alreadyStaged: alreadyStagedInfo, + newlyStaged: newlyStagedInfo, + totalStaged: allStagedInfo.length, + hasUnstagedTracked: getUnstagedTrackedFiles().length > 0, + hasUntracked: hasUntrackedFiles(), + }; +} + +/** + * Check if there are any staged files + */ +export function hasStagedFiles(): boolean { + return getStagedFiles().length > 0; +} + +/** + * Get count of staged files + */ +export function getStagedFilesCount(): number { + return getStagedFiles().length; +} + +/** + * Execute git commit + */ +export function createCommit( + subject: string, + body: string | undefined, + sign: boolean, + noVerify: boolean, +): string { + const args: string[] = ["commit"]; + + // Add subject + args.push("-m", subject); + + // Add body (each -m adds a paragraph) + if (body) { + const bodyLines = body.split("\n"); + for (const line of bodyLines) { + args.push("-m", line); + } + } + + // Sign commit if enabled + if (sign) { + args.push("-S"); + } + + // Bypass hooks if requested + if (noVerify) { + args.push("--no-verify"); + } + + execGit(args); + + // Get commit hash + try { + return execGit(["rev-parse", "HEAD"]).substring(0, 7); + } catch { + return "unknown"; + } +} + +/** + * Unstage specific files + */ +export function unstageFiles(files: string[]): void { + if (files.length === 0) return; + execGit(["reset", "HEAD", "--", ...files]); +} diff --git a/src/cli/commands/commit/index.ts b/src/cli/commands/commit/index.ts new file mode 100644 index 0000000..c1256c9 --- /dev/null +++ b/src/cli/commands/commit/index.ts @@ -0,0 +1,286 @@ +/** + * Commit Command Main Handler + * + * Orchestrates the complete commit workflow: + * 1. Load configuration + * 2. Check/stage files (early, before prompts) + * 3. Display file verification + * 4. Collect commit data via prompts + * 5. Preview and confirm + * 6. Execute commit + * 7. Cleanup on cancellation/failure + */ + +import { loadConfig, ConfigError } from "../../../lib/config/index.js"; +import { Logger } from "../../../lib/logger.js"; +import { isGitRepository } from "./git.js"; +import { + stageAllTrackedFiles, + hasStagedFiles, + getGitStatus, + createCommit, + unstageFiles, +} from "./git.js"; +import { + promptType, + promptScope, + promptSubject, + promptBody, + displayStagedFiles, + displayPreview, +} from "./prompts.js"; +import { formatCommitMessage } from "./formatter.js"; +import type { CommitState } from "./types.js"; +import { success } from "../init/colors.js"; + +/** + * Clear terminal screen for clean prompt display + * Only clears if running in a TTY environment + */ +function clearTerminal(): void { + if (process.stdout.isTTY) { + process.stdout.write("\x1B[2J"); // Clear screen + process.stdout.write("\x1B[H"); // Move cursor to home position + process.stdout.write("\x1B[3J"); // Clear scrollback buffer (optional, for full clear) + } +} + +/** + * Handle cleanup: unstage files we staged + */ +async function cleanup(state: CommitState): Promise { + if (state.newlyStagedFiles.length > 0) { + console.log(); + console.log("◐ Cleaning up..."); + unstageFiles(state.newlyStagedFiles); + + const preservedCount = state.alreadyStagedFiles.length; + if (preservedCount > 0) { + console.log( + `✓ Unstaged ${state.newlyStagedFiles.length} file${state.newlyStagedFiles.length !== 1 ? "s" : ""} (preserved ${preservedCount} already-staged file${preservedCount !== 1 ? "s" : ""})`, + ); + } else { + console.log(`✓ Unstaged files successfully`); + } + } +} + +/** + * Main commit action handler + */ +export async function commitAction(options: { + type?: string; + scope?: string; + message?: string; + verify?: boolean; +}): Promise { + // Clear terminal for clean prompt display + clearTerminal(); + + try { + // Step 1: Load configuration + const configResult = await loadConfig(); + + if (!configResult.config) { + Logger.error("Configuration not found"); + console.error("\n Run 'lab init' to create configuration file.\n"); + process.exit(1); + } + + const config = configResult.config; + + // Step 2: Verify git repository + if (!isGitRepository()) { + Logger.error("Not a git repository"); + console.error("\n Initialize git first: git init\n"); + process.exit(1); + } + + // Step 3: Early file check/staging + const autoStageEnabled = config.advanced.git.auto_stage; + let alreadyStagedFiles: string[] = []; + let newlyStagedFiles: string[] = []; + + // Get already staged files (before we do anything) + if (autoStageEnabled) { + // Check what's already staged + const { execSync } = await import("child_process"); + try { + const stagedOutput = execSync("git diff --cached --name-only", { + encoding: "utf-8", + }).trim(); + alreadyStagedFiles = stagedOutput + ? stagedOutput.split("\n").filter((f) => f.trim()) + : []; + } catch { + alreadyStagedFiles = []; + } + + // Check if there are unstaged tracked files + try { + const unstagedOutput = execSync("git diff --name-only", { + encoding: "utf-8", + }).trim(); + const hasUnstagedTracked = unstagedOutput.length > 0; + + if (!hasUnstagedTracked && alreadyStagedFiles.length === 0) { + // Check for untracked files + try { + const untrackedOutput = execSync( + "git ls-files --others --exclude-standard", + { encoding: "utf-8" }, + ).trim(); + const hasUntracked = untrackedOutput.length > 0; + + if (hasUntracked) { + console.error("\n⚠ No tracked files to stage"); + console.error( + "\n Only untracked files exist. Stage them manually with 'git add '\n", + ); + process.exit(1); + } else { + console.error("\n⚠ No modified files to stage"); + console.error( + "\n All files are already committed or there are no changes.", + ); + console.error(" Nothing to commit.\n"); + process.exit(1); + } + } catch { + console.error("\n⚠ No modified files to stage"); + console.error( + "\n All files are already committed or there are no changes.", + ); + console.error(" Nothing to commit.\n"); + process.exit(1); + } + return; + } + + // Stage remaining files + if (hasUnstagedTracked) { + console.log("◐ Staging files..."); + if (alreadyStagedFiles.length > 0) { + console.log( + ` Found ${alreadyStagedFiles.length} file${alreadyStagedFiles.length !== 1 ? "s" : ""} already staged, ${unstagedOutput.split("\n").filter((f) => f.trim()).length} file${unstagedOutput.split("\n").filter((f) => f.trim()).length !== 1 ? "s" : ""} unstaged`, + ); + } + newlyStagedFiles = stageAllTrackedFiles(); + console.log( + `✓ Staged ${newlyStagedFiles.length} file${newlyStagedFiles.length !== 1 ? "s" : ""}${alreadyStagedFiles.length > 0 ? " (preserved existing staging)" : ""}`, + ); + } + } catch { + // Error getting unstaged files, continue + } + } else { + // auto_stage: false - check if anything is staged + if (!hasStagedFiles()) { + console.error("\n✗ Error: No files staged for commit"); + console.error("\n Nothing has been staged. Please stage files first:"); + console.error(" • Use 'git add ' to stage specific files"); + console.error(" • Use 'git add -u' to stage all modified files"); + console.error(" • Or enable auto_stage in your config\n"); + process.exit(1); + } + + // Get already staged files for tracking + const { execSync } = await import("child_process"); + try { + const stagedOutput = execSync("git diff --cached --name-only", { + encoding: "utf-8", + }).trim(); + alreadyStagedFiles = stagedOutput + ? stagedOutput.split("\n").filter((f) => f.trim()) + : []; + } catch { + alreadyStagedFiles = []; + } + } + + // Step 4: Display staged files verification and wait for confirmation + const gitStatus = getGitStatus(alreadyStagedFiles); + await displayStagedFiles(gitStatus); + + // Step 5: Collect commit data via prompts + const { type, emoji } = await promptType(config, options.type); + const scope = await promptScope(config, type, options.scope); + const subject = await promptSubject(config, options.message); + const body = await promptBody(config); + + // Step 6: Format and preview message + const formattedMessage = formatCommitMessage( + config, + type, + emoji, + scope, + subject, + ); + + const confirmed = await displayPreview(formattedMessage, body); + + if (!confirmed) { + // User selected "No, let me edit" + await cleanup({ + config, + autoStageEnabled, + alreadyStagedFiles, + newlyStagedFiles, + type, + typeEmoji: emoji, + scope, + subject, + body, + formattedMessage, + }); + console.log("\nCommit cancelled."); + process.exit(0); + } + + // Step 7: Execute commit + console.log(); + console.log("◐ Creating commit..."); + + try { + const commitHash = createCommit( + formattedMessage, + body, + config.advanced.git.sign_commits, + options.verify === false, + ); + + console.log(`${success("✓")} Commit created successfully!`); + console.log(` ${commitHash} ${formattedMessage}`); + } catch (error: unknown) { + // Cleanup on failure + await cleanup({ + config, + autoStageEnabled, + alreadyStagedFiles, + newlyStagedFiles, + type, + typeEmoji: emoji, + scope, + subject, + body, + formattedMessage, + }); + + const errorMessage = + error instanceof Error ? error.message : String(error); + console.error(`\n✗ Error: Git commit failed`); + console.error(`\n ${errorMessage}\n`); + process.exit(1); + } + } catch (error: unknown) { + if (error instanceof ConfigError) { + Logger.error("Configuration error"); + console.error(error.formatForUser()); + process.exit(1); + } + + const errorMessage = error instanceof Error ? error.message : String(error); + Logger.error(`Unexpected error: ${errorMessage}`); + process.exit(1); + } +} diff --git a/src/cli/commands/commit/prompts.ts b/src/cli/commands/commit/prompts.ts new file mode 100644 index 0000000..8fe4f0e --- /dev/null +++ b/src/cli/commands/commit/prompts.ts @@ -0,0 +1,950 @@ +/** + * Commit Command Prompts + * + * Interactive prompts for commit creation + * Uses same styling as init command for consistency + */ + +import { select, text, isCancel, log } from "@clack/prompts"; +import { labelColors, textColors, success, attention } from "../init/colors.js"; +import type { + LabcommitrConfig, + CommitType, +} from "../../../lib/config/types.js"; +import type { ValidationError } from "./types.js"; +import { editInEditor, detectEditor } from "./editor.js"; + +/** + * Create compact color-coded label + * Labels are 9 characters wide (7 chars + 2 padding spaces) for alignment + * Text is centered within the label + */ +function label( + text: string, + color: "magenta" | "cyan" | "blue" | "yellow" | "green", +): string { + const colorFn = { + magenta: labelColors.bgBrightMagenta, + cyan: labelColors.bgBrightCyan, + blue: labelColors.bgBrightBlue, + yellow: labelColors.bgBrightYellow, + green: labelColors.bgBrightGreen, + }[color]; + + // Center text within 7-character width (accommodates "subject" and "preview") + // For visual centering: when padding is odd, put extra space on LEFT for better balance + const width = 7; + const textLength = Math.min(text.length, width); // Cap at width + const padding = width - textLength; + // For odd padding (1, 3, 5...), ceil puts extra space on LEFT (better visual weight) + // For even padding (2, 4, 6...), floor/ceil both work the same + const leftPad = Math.ceil(padding / 2); + const rightPad = padding - leftPad; + const centeredText = + " ".repeat(leftPad) + text.substring(0, textLength) + " ".repeat(rightPad); + + return colorFn(` ${centeredText} `); +} + +/** + * Handle prompt cancellation + */ +function handleCancel(value: unknown): void { + if (isCancel(value)) { + console.log("\nCommit cancelled."); + process.exit(0); + } +} + +/** + * Prompt for commit type selection + */ +export async function promptType( + config: LabcommitrConfig, + providedType?: string, +): Promise<{ type: string; emoji?: string }> { + // If type provided via CLI flag, validate it + if (providedType) { + const typeConfig = config.types.find((t) => t.id === providedType); + if (!typeConfig) { + const available = config.types + .map((t) => ` • ${t.id} - ${t.description}`) + .join("\n"); + console.error(`\n✗ Error: Invalid commit type '${providedType}'`); + console.error( + "\n The commit type is not defined in your configuration.", + ); + console.error("\n Available types:"); + console.error(available); + console.error("\n Solutions:"); + console.error(" • Use one of the available types listed above"); + console.error(" • Check your configuration file for custom types\n"); + process.exit(1); + } + return { + type: providedType, + emoji: typeConfig.emoji, + }; + } + + const selected = await select({ + message: `${label("type", "magenta")} ${textColors.pureWhite("Select commit type:")}`, + options: config.types.map((type) => ({ + value: type.id, + label: `${type.id.padEnd(8)} ${type.description}`, + hint: type.description, + })), + }); + + handleCancel(selected); + const typeId = selected as string; + const typeConfig = config.types.find((t) => t.id === typeId)!; + + return { + type: typeId, + emoji: typeConfig.emoji, + }; +} + +/** + * Prompt for scope input + */ +export async function promptScope( + config: LabcommitrConfig, + selectedType: string, + providedScope?: string, +): Promise { + const isRequired = config.validation.require_scope_for.includes(selectedType); + const allowedScopes = config.validation.allowed_scopes; + + // If scope provided via CLI flag, validate it + if (providedScope !== undefined) { + if (providedScope === "" && isRequired) { + console.error( + `\n✗ Error: Scope is required for commit type '${selectedType}'`, + ); + process.exit(1); + } + if (allowedScopes.length > 0 && !allowedScopes.includes(providedScope)) { + console.error(`\n✗ Error: Invalid scope '${providedScope}'`); + console.error(`\n Allowed scopes: ${allowedScopes.join(", ")}\n`); + process.exit(1); + } + return providedScope || undefined; + } + + // Use select if allowed scopes are defined + if (allowedScopes.length > 0) { + const options = [ + ...allowedScopes.map((scope) => ({ + value: scope, + label: scope, + })), + { + value: "__custom__", + label: "(custom) Type a custom scope", + }, + ]; + + const selected = await select({ + message: `${label("scope", "blue")} ${textColors.pureWhite( + `Enter scope ${isRequired ? "(required for '" + selectedType + "')" : "(optional)"}:`, + )}`, + options, + }); + + handleCancel(selected); + + if (selected === "__custom__") { + const custom = await text({ + message: `${label("scope", "blue")} ${textColors.pureWhite("Enter custom scope:")}`, + placeholder: "", + validate: (value) => { + if (isRequired && !value) { + return "Scope is required for this commit type"; + } + return undefined; + }, + }); + + handleCancel(custom); + return custom ? (custom as string) : undefined; + } + + return selected as string; + } + + // Use text input for free-form scope + const scope = await text({ + message: `${label("scope", "blue")} ${textColors.pureWhite( + `Enter scope ${isRequired ? "(required)" : "(optional)"}:`, + )}`, + placeholder: "", + validate: (value) => { + if (isRequired && !value) { + return "Scope is required for this commit type"; + } + return undefined; + }, + }); + + handleCancel(scope); + return scope ? (scope as string) : undefined; +} + +/** + * Validate subject against config rules + */ +function validateSubject( + config: LabcommitrConfig, + subject: string, +): ValidationError[] { + const errors: ValidationError[] = []; + + // Check min length + if (subject.length < config.validation.subject_min_length) { + errors.push({ + message: `Subject too short (${subject.length} characters)`, + context: `Minimum length: ${config.validation.subject_min_length}`, + }); + } + + // Check max length + if (subject.length > config.format.subject_max_length) { + errors.push({ + message: `Subject too long (${subject.length} characters)`, + context: `Maximum length: ${config.format.subject_max_length}`, + }); + } + + // Check prohibited words (case-insensitive) + const lowerSubject = subject.toLowerCase(); + const foundWords: string[] = []; + for (const word of config.validation.prohibited_words) { + if (lowerSubject.includes(word.toLowerCase())) { + foundWords.push(word); + } + } + + if (foundWords.length > 0) { + errors.push({ + message: `Subject contains prohibited words: ${foundWords.join(", ")}`, + context: "Please rephrase your commit message", + }); + } + + return errors; +} + +/** + * Prompt for subject input + */ +export async function promptSubject( + config: LabcommitrConfig, + providedMessage?: string, +): Promise { + if (providedMessage) { + const errors = validateSubject(config, providedMessage); + if (errors.length > 0) { + console.error("\n✗ Validation failed:"); + for (const error of errors) { + console.error(` • ${error.message}`); + if (error.context) { + console.error(` ${error.context}`); + } + } + console.error(); + process.exit(1); + } + return providedMessage; + } + + let subject: string | symbol = ""; + let errors: ValidationError[] = []; + + do { + if (errors.length > 0) { + console.log(); + console.log(`${attention("⚠")} ${attention("Validation failed:")}`); + for (const error of errors) { + console.log(` • ${error.message}`); + if (error.context) { + console.log(` ${error.context}`); + } + } + console.log(); + } + + subject = await text({ + message: `${label("subject", "cyan")} ${textColors.pureWhite( + `Enter commit subject (max ${config.format.subject_max_length} chars):`, + )}`, + placeholder: "", + validate: (value) => { + const validationErrors = validateSubject(config, value); + if (validationErrors.length > 0) { + // Return first error message for inline display + const firstError = validationErrors[0]; + let message = firstError.message; + if (firstError.context) { + message += `\n ${firstError.context}`; + } + return message; + } + return undefined; + }, + }); + + handleCancel(subject); + + if (typeof subject === "string") { + errors = validateSubject(config, subject); + } + } while (errors.length > 0 && typeof subject === "string"); + + return subject as string; +} + +/** + * Validate body against config rules + */ +function validateBody( + config: LabcommitrConfig, + body: string, +): ValidationError[] { + const errors: ValidationError[] = []; + const bodyConfig = config.format.body; + + // Check required + if (bodyConfig.required && !body) { + errors.push({ + message: "Body is required", + context: "Please provide a commit body", + }); + return errors; + } + + // Skip other checks if body is empty and not required + if (!body) { + return errors; + } + + // Check min length + if (body.length < bodyConfig.min_length) { + errors.push({ + message: `Body too short (${body.length} characters)`, + context: `Minimum length: ${bodyConfig.min_length}`, + }); + } + + // Check max length + if (bodyConfig.max_length !== null && body.length > bodyConfig.max_length) { + errors.push({ + message: `Body too long (${body.length} characters)`, + context: `Maximum length: ${bodyConfig.max_length}`, + }); + } + + // Check prohibited words (case-insensitive) + const lowerBody = body.toLowerCase(); + const foundWords: string[] = []; + for (const word of config.validation.prohibited_words_body) { + if (lowerBody.includes(word.toLowerCase())) { + foundWords.push(word); + } + } + + if (foundWords.length > 0) { + errors.push({ + message: `Body contains prohibited words: ${foundWords.join(", ")}`, + context: "Please rephrase your commit message", + }); + } + + return errors; +} +/** + * Prompt for body input with editor support + */ +export async function promptBody( + config: LabcommitrConfig, +): Promise { + const bodyConfig = config.format.body; + const editorAvailable = detectEditor() !== null; + const preference = bodyConfig.editor_preference; + + // Explicitly check if body is required (handle potential type coercion) + const isRequired = bodyConfig.required === true; + + // If editor preference is "editor" but no editor available, fall back to inline + if (preference === "editor" && !editorAvailable) { + console.log(); + console.log( + `${attention("⚠")} ${attention("Editor not available, using inline input")}`, + ); + console.log(); + // Fall through to inline input + } else if (preference === "editor" && editorAvailable && !isRequired) { + // Optional body with editor preference - use editor directly + const edited = await promptBodyWithEditor(config, ""); + return edited || undefined; + } else if (preference === "editor" && editorAvailable && isRequired) { + // Required body with editor preference - use editor with validation loop + return await promptBodyRequiredWithEditor(config); + } + + // Inline input path + if (!isRequired) { + // Optional body - offer choice if editor available and preference allows + if (editorAvailable && preference === "auto") { + const inputMethod = await select({ + message: `${label("body", "yellow")} ${textColors.pureWhite("Enter commit body (optional):")}`, + options: [ + { + value: "inline", + label: "Type inline (single/multi-line)", + }, + { + value: "editor", + label: "Open in editor", + }, + { + value: "skip", + label: "Skip (no body)", + }, + ], + }); + + handleCancel(inputMethod); + + if (inputMethod === "skip") { + return undefined; + } else if (inputMethod === "editor") { + return await promptBodyWithEditor(config, ""); + } + // Fall through to inline + } + + const body = await text({ + message: `${label("body", "yellow")} ${textColors.pureWhite("Enter commit body (optional):")}`, + placeholder: "Press Enter to skip", + validate: (value) => { + if (!value) return undefined; // Empty is OK if optional + const errors = validateBody(config, value); + if (errors.length > 0) { + return errors[0].message; + } + return undefined; + }, + }); + + handleCancel(body); + return body ? (body as string) : undefined; + } + + // Required body + let body: string | symbol = ""; + let errors: ValidationError[] = []; + + do { + if (errors.length > 0) { + console.log(); + console.log(`${attention("⚠")} ${attention("Validation failed:")}`); + for (const error of errors) { + console.log(` • ${error.message}`); + if (error.context) { + console.log(` ${error.context}`); + } + } + console.log(); + } + + // For required body, offer editor option if available and preference allows + if (editorAvailable && (preference === "auto" || preference === "inline")) { + const inputMethod = await select({ + message: `${label("body", "yellow")} ${textColors.pureWhite( + `Enter commit body (required${bodyConfig.min_length > 0 ? `, min ${bodyConfig.min_length} chars` : ""}):`, + )}`, + options: [ + { + value: "inline", + label: "Type inline", + }, + { + value: "editor", + label: "Open in editor", + }, + ], + }); + + handleCancel(inputMethod); + + if (inputMethod === "editor") { + const editorBody = await promptBodyWithEditor(config, body as string); + if (editorBody !== null && editorBody !== undefined) { + body = editorBody; + } else { + // Editor cancelled or failed, continue loop + continue; + } + } else { + // Inline input + body = await text({ + message: `${label("body", "yellow")} ${textColors.pureWhite( + `Enter commit body (required${bodyConfig.min_length > 0 ? `, min ${bodyConfig.min_length} chars` : ""}):`, + )}`, + placeholder: "", + validate: (value) => { + const validationErrors = validateBody(config, value); + if (validationErrors.length > 0) { + return validationErrors[0].message; + } + return undefined; + }, + }); + + handleCancel(body); + } + } else { + // No editor choice, just inline + body = await text({ + message: `${label("body", "yellow")} ${textColors.pureWhite( + `Enter commit body (required${bodyConfig.min_length > 0 ? `, min ${bodyConfig.min_length} chars` : ""}):`, + )}`, + placeholder: "", + validate: (value) => { + const validationErrors = validateBody(config, value); + if (validationErrors.length > 0) { + return validationErrors[0].message; + } + return undefined; + }, + }); + + handleCancel(body); + } + + if (typeof body === "string") { + errors = validateBody(config, body); + } + } while (errors.length > 0 && typeof body === "string"); + + return body as string; +} + +/** + * Prompt for required body using external editor (with validation loop) + */ +async function promptBodyRequiredWithEditor( + config: LabcommitrConfig, +): Promise { + const bodyConfig = config.format.body; + let body: string = ""; + let errors: ValidationError[] = []; + + do { + if (errors.length > 0) { + console.log(); + console.log(`${attention("⚠")} ${attention("Validation failed:")}`); + for (const error of errors) { + console.log(` • ${error.message}`); + if (error.context) { + console.log(` ${error.context}`); + } + } + console.log(); + } + + const edited = await promptBodyWithEditor(config, body); + if (edited === null || edited === undefined) { + // Editor cancelled, ask what to do + const choice = await select({ + message: `${label("body", "yellow")} ${textColors.pureWhite("Editor cancelled. What would you like to do?")}`, + options: [ + { + value: "retry", + label: "Try editor again", + }, + { + value: "inline", + label: "Switch to inline input", + }, + { + value: "cancel", + label: "Cancel commit", + }, + ], + }); + + handleCancel(choice); + + if (choice === "cancel") { + console.log("\nCommit cancelled."); + process.exit(0); + } else if (choice === "inline") { + // Fall back to inline for required body + const inlineBody = await text({ + message: `${label("body", "yellow")} ${textColors.pureWhite( + `Enter commit body (required${bodyConfig.min_length > 0 ? `, min ${bodyConfig.min_length} chars` : ""}):`, + )}`, + placeholder: "", + validate: (value) => { + const validationErrors = validateBody(config, value); + if (validationErrors.length > 0) { + return validationErrors[0].message; + } + return undefined; + }, + }); + + handleCancel(inlineBody); + if (typeof inlineBody === "string") { + body = inlineBody; + errors = validateBody(config, body); + } + break; // Exit loop after inline input + } + // Otherwise continue loop (retry editor) + continue; + } + + body = edited; + errors = validateBody(config, body); + } while (errors.length > 0); + + return body; +} + +/** + * Prompt for body using external editor + */ +async function promptBodyWithEditor( + config: LabcommitrConfig, + initialContent: string, +): Promise { + console.log(); + console.log("◐ Opening editor..."); + console.log(); + + const edited = editInEditor(initialContent); + + if (edited === null) { + // Editor failed or was cancelled + console.log(); + console.log("⚠ Editor cancelled or unavailable, returning to prompts"); + console.log(); + return undefined; + } + + // Validate the edited content + const errors = validateBody(config, edited); + if (errors.length > 0) { + console.log(); + console.log(`${attention("⚠")} ${attention("Validation failed:")}`); + for (const error of errors) { + console.log(` • ${error.message}`); + if (error.context) { + console.log(` ${error.context}`); + } + } + console.log(); + + // Ask if user wants to re-edit or go back to inline + const choice = await select({ + message: `${label("body", "yellow")} ${textColors.pureWhite("Validation failed. What would you like to do?")}`, + options: [ + { + value: "re-edit", + label: "Edit again", + }, + { + value: "inline", + label: "Type inline instead", + }, + { + value: "cancel", + label: "Cancel commit", + }, + ], + }); + + handleCancel(choice); + + if (choice === "cancel") { + console.log("\nCommit cancelled."); + process.exit(0); + } else if (choice === "re-edit") { + return await promptBodyWithEditor(config, edited); + } else { + // Return undefined to trigger inline prompt + return undefined; + } + } + + return edited; +} + +/** + * Render a line with connector (│) character at the start + * Maintains visual consistency with @clack/prompts connector lines + */ +function renderWithConnector(content: string): string { + return `│ ${content}`; +} + +/** + * Display staged files verification with connector line support + * Uses @clack/prompts log.info() to start connector, then manually + * renders connector lines for multi-line content, and ends with + * a confirmation prompt to maintain visual continuity. + */ +export async function displayStagedFiles(status: { + alreadyStaged: Array<{ + path: string; + status: string; + additions?: number; + deletions?: number; + }>; + newlyStaged: Array<{ + path: string; + status: string; + additions?: number; + deletions?: number; + }>; + totalStaged: number; +}): Promise { + // Start connector line using @clack/prompts + log.info( + `${label("files", "green")} ${textColors.pureWhite( + `Files to be committed (${status.totalStaged} file${status.totalStaged !== 1 ? "s" : ""}):`, + )}`, + ); + + // Group files by status + const groupByStatus = ( + files: Array<{ + path: string; + status: string; + additions?: number; + deletions?: number; + }>, + ) => { + const groups: Record = { + M: [], + A: [], + D: [], + R: [], + C: [], + }; + + for (const file of files) { + const statusCode = file.status as keyof typeof groups; + if (groups[statusCode]) { + groups[statusCode].push(file); + } + } + + return groups; + }; + + const formatStats = (additions?: number, deletions?: number) => { + if (additions === undefined || deletions === undefined) { + return ""; + } + const addStr = additions > 0 ? `+${additions}` : ""; + const delStr = deletions > 0 ? `-${deletions}` : ""; + if (!addStr && !delStr) { + return ""; + } + const parts: string[] = []; + if (addStr) parts.push(addStr); + if (delStr) parts.push(delStr); + return ` (${parts.join(" ")} lines)`; + }; + + const formatStatusName = (status: string) => { + const map: Record = { + M: "Modified", + A: "Added", + D: "Deleted", + R: "Renamed", + C: "Copied", + }; + return map[status] || status; + }; + + /** + * Color code git status indicator to match git's default colors + */ + const colorStatusCode = (status: string): string => { + switch (status) { + case "A": + return textColors.gitAdded(status); + case "M": + return textColors.gitModified(status); + case "D": + return textColors.gitDeleted(status); + case "R": + return textColors.gitRenamed(status); + case "C": + return textColors.gitCopied(status); + default: + return status; + } + }; + + // Render content with connector lines + // Empty line after header + console.log(renderWithConnector("")); + + // Show already staged if any + if (status.alreadyStaged.length > 0) { + const alreadyPlural = status.alreadyStaged.length !== 1 ? "s" : ""; + console.log( + renderWithConnector( + textColors.brightCyan( + `Already staged (${status.alreadyStaged.length} file${alreadyPlural}):`, + ), + ), + ); + const groups = groupByStatus(status.alreadyStaged); + for (const [statusCode, files] of Object.entries(groups)) { + if (files.length > 0) { + console.log( + renderWithConnector( + ` ${formatStatusName(statusCode)} (${files.length}):`, + ), + ); + for (const file of files) { + console.log( + renderWithConnector( + ` ${colorStatusCode(file.status)} ${file.path}${formatStats(file.additions, file.deletions)}`, + ), + ); + } + } + } + console.log(renderWithConnector("")); + } + + // Show newly staged if any + if (status.newlyStaged.length > 0) { + const newlyPlural = status.newlyStaged.length !== 1 ? "s" : ""; + console.log( + renderWithConnector( + textColors.brightYellow( + `Auto-staged (${status.newlyStaged.length} file${newlyPlural}):`, + ), + ), + ); + const groups = groupByStatus(status.newlyStaged); + for (const [statusCode, files] of Object.entries(groups)) { + if (files.length > 0) { + console.log( + renderWithConnector( + ` ${formatStatusName(statusCode)} (${files.length}):`, + ), + ); + for (const file of files) { + console.log( + renderWithConnector( + ` ${colorStatusCode(file.status)} ${file.path}${formatStats(file.additions, file.deletions)}`, + ), + ); + } + } + } + console.log(renderWithConnector("")); + } + + // If no separation needed, show all together + if (status.alreadyStaged.length === 0 && status.newlyStaged.length > 0) { + const groups = groupByStatus(status.newlyStaged); + for (const [statusCode, files] of Object.entries(groups)) { + if (files.length > 0) { + console.log( + renderWithConnector( + ` ${formatStatusName(statusCode)} (${files.length}):`, + ), + ); + for (const file of files) { + console.log( + renderWithConnector( + ` ${file.status} ${file.path}${formatStats(file.additions, file.deletions)}`, + ), + ); + } + } + } + console.log(renderWithConnector("")); + } + + // Separator line with connector + console.log( + renderWithConnector("─────────────────────────────────────────────"), + ); + + // Use select prompt for confirmation (maintains connector continuity) + const confirmation = await select({ + message: "Press Enter to continue, Esc to cancel", + options: [ + { + value: "continue", + label: "Continue", + }, + ], + }); + + handleCancel(confirmation); +} + +/** + * Display commit message preview with connector line support + * Uses @clack/prompts log.info() to start connector, then manually + * renders connector lines for multi-line preview content. + */ +export async function displayPreview( + formattedMessage: string, + body: string | undefined, +): Promise { + // Start connector line using @clack/prompts + log.info( + `${label("preview", "green")} ${textColors.pureWhite("Commit message preview:")}`, + ); + + // Render content with connector lines + // Empty line after header + console.log(renderWithConnector("")); + console.log(renderWithConnector(textColors.brightCyan(formattedMessage))); + + if (body) { + console.log(renderWithConnector("")); + const bodyLines = body.split("\n"); + for (const line of bodyLines) { + console.log(renderWithConnector(textColors.white(line))); + } + } + + console.log(renderWithConnector("")); + // Separator line with connector + console.log( + renderWithConnector("─────────────────────────────────────────────"), + ); + + const confirmed = await select({ + message: `${success("✓")} ${textColors.pureWhite("Ready to commit?")}`, + options: [ + { + value: true, + label: "Yes, create commit", + }, + { + value: false, + label: "No, let me edit", + }, + ], + }); + + handleCancel(confirmed); + return confirmed as boolean; +} diff --git a/src/cli/commands/commit/types.ts b/src/cli/commands/commit/types.ts new file mode 100644 index 0000000..48f5d17 --- /dev/null +++ b/src/cli/commands/commit/types.ts @@ -0,0 +1,75 @@ +/** + * Commit Command Types + * + * Type definitions for commit command state and data structures + */ + +import type { LabcommitrConfig } from "../../../lib/config/types.js"; + +/** + * Staged file information + */ +export interface StagedFileInfo { + /** File path relative to repo root */ + path: string; + /** Git status code: M (modified), A (added), D (deleted), R (renamed), C (copied) */ + status: "M" | "A" | "D" | "R" | "C"; + /** Lines added (undefined if unknown) */ + additions?: number; + /** Lines deleted (undefined if unknown) */ + deletions?: number; + /** Whether file was already staged before we started (auto_stage context) */ + wasAlreadyStaged?: boolean; +} + +/** + * Git status information + */ +export interface GitStatus { + /** Files already staged before auto-stage */ + alreadyStaged: StagedFileInfo[]; + /** Files staged by auto-stage */ + newlyStaged: StagedFileInfo[]; + /** Total staged files */ + totalStaged: number; + /** Whether there are unstaged tracked files */ + hasUnstagedTracked: boolean; + /** Whether there are untracked files */ + hasUntracked: boolean; +} + +/** + * Commit state throughout the workflow + */ +export interface CommitState { + /** Configuration loaded from file */ + config: LabcommitrConfig; + /** Whether auto-stage is enabled */ + autoStageEnabled: boolean; + /** Files staged before we started (for cleanup) */ + alreadyStagedFiles: string[]; + /** Files we staged via auto-stage (for cleanup) */ + newlyStagedFiles: string[]; + /** Selected commit type ID */ + type: string; + /** Selected commit type emoji (if emoji enabled) */ + typeEmoji?: string; + /** Commit scope (optional) */ + scope?: string; + /** Commit subject */ + subject: string; + /** Commit body (optional) */ + body?: string; + /** Formatted commit message (subject line) */ + formattedMessage: string; +} + +/** + * Validation error details + */ +export interface ValidationError { + /** Error message */ + message: string; + /** Additional context */ + context?: string; +} diff --git a/src/cli/commands/config.ts b/src/cli/commands/config.ts new file mode 100644 index 0000000..ac04374 --- /dev/null +++ b/src/cli/commands/config.ts @@ -0,0 +1,76 @@ +/** + * Config command implementation + * + * Provides utilities for working with labcommitr configuration: + * - show: Display currently loaded configuration + * - validate: Check configuration validity without loading + * + * This command is primarily for debugging and troubleshooting. + */ + +import { Command } from "commander"; +import { loadConfig } from "../../lib/config/index.js"; +import { Logger } from "../../lib/logger.js"; +import { ConfigError } from "../../lib/config/types.js"; + +/** + * Config command + */ +export const configCommand = new Command("config") + .description("Manage labcommitr configuration") + .addCommand( + new Command("show") + .description("Display the current configuration") + .option("-p, --path ", "Start search from specific directory") + .action(showConfigAction), + ); + +/** + * Show config action handler + * Loads and displays the current configuration with source information + */ +async function showConfigAction(options: { path?: string }): Promise { + try { + Logger.info("Loading configuration...\n"); + + const result = await loadConfig(options.path); + + // Display config source + Logger.success(`Configuration loaded from: ${result.source}`); + + if (result.source === "defaults") { + Logger.warn("Using built-in defaults (no config file found)"); + } + + if (result.path) { + Logger.info(`Config file path: ${result.path}`); + } + + Logger.info( + `Emoji mode: ${result.emojiModeActive ? "enabled" : "disabled (terminal fallback)"}`, + ); + + // Display configuration (formatted JSON) + console.log("\nConfiguration:"); + console.log(JSON.stringify(result.config, null, 2)); + } catch (error) { + if (error instanceof ConfigError) { + // ConfigError already has formatted output + Logger.error(error.message); + if (error.details) { + console.error(error.details); + } + if (error.solutions && error.solutions.length > 0) { + console.error("\nSolutions:"); + error.solutions.forEach((solution, index) => { + console.error(` ${index + 1}. ${solution}`); + }); + } + } else { + // Unexpected error + Logger.error("Failed to load configuration"); + console.error(error); + } + process.exit(1); + } +} diff --git a/src/cli/commands/index.ts b/src/cli/commands/index.ts new file mode 100644 index 0000000..6930219 --- /dev/null +++ b/src/cli/commands/index.ts @@ -0,0 +1,10 @@ +/** + * Command exports + * + * Central export point for all CLI commands. + * Makes imports cleaner and provides single location for command overview. + */ + +export { configCommand } from "./config.js"; +export { initCommand } from "./init/index.js"; +export { commitCommand } from "./commit.js"; diff --git a/src/cli/commands/init/clef.ts b/src/cli/commands/init/clef.ts new file mode 100644 index 0000000..e2793cd --- /dev/null +++ b/src/cli/commands/init/clef.ts @@ -0,0 +1,425 @@ +/** + * Clef the Cat - Animated CLI Mascot + * + * Provides animated character that appears at key moments during + * the initialization flow. Uses ANSI escape codes for terminal + * control and animation effects. + * + * Appearance pattern: + * - Intro: Walks in, introduces tool, walks off + * - Processing: Appears during config generation + * - Outro: Celebrates completion and exits + * + * Gracefully degrades to static display in non-TTY environments. + */ + +import { setTimeout as sleep } from "timers/promises"; +import { textColors, success, attention } from "./colors.js"; + +interface AnimationCapabilities { + supportsAnimation: boolean; + supportsColor: boolean; + terminalWidth: number; + terminalHeight: number; +} + +/** + * Clef mascot controller + * Manages all animation sequences and terminal interactions + */ +class Clef { + private caps: AnimationCapabilities; + private currentX: number = 0; + + // Raw ASCII art frames (unprocessed) + private readonly rawFrames = { + standing: ` /\\_/\\ \n ( ^.^ ) \n /| | \n(|_ |_)`, + walk1: ` /\\_/\\ \n ( ^.^ ) \n /| |\\ \n(_| _|)`, + walk2: ` /\\_/\\ \n ( ^.^ ) \n /| |\\ \n(|_ |_)`, + typing: ` /\\_/\\ \n ( -.- ) \n /|⌨ | \n(_|__|_)`, + celebrate: ` /\\_/\\ \n ( ^ω^ ) \n | | \n/ \\ `, + waving: ` /\\_/\\ \n ( ^.^ )~ \n /| | \n(|_ |_)`, + }; + + // Normalized frames (uniform dimensions) + private frames!: typeof this.rawFrames; + + // Frame dimensions after normalization + private frameWidth = 0; + private frameHeight = 0; + + constructor() { + this.caps = this.detectCapabilities(); + this.normalizeFrames(); // Initializes this.frames + + // Debug: Log normalized frame dimensions + if (process.env.LABCOMMITR_DEBUG) { + console.log( + `Normalized frame dimensions: ${this.frameWidth} x ${this.frameHeight}`, + ); + console.log( + "Standing frame lines:", + this.frames.standing + .split("\n") + .map((l, i) => `${i}: [${l}] (len=${l.length})`), + ); + } + } + + /** + * Normalize all frames to uniform width and height + * Ensures consistent alignment across all animation frames + * Critical for terminal compatibility with different fonts/dimensions + */ + private normalizeFrames(): void { + // Find maximum width across all frames + const allLines = Object.values(this.rawFrames).flatMap((frame: string) => + frame.split("\n"), + ); + this.frameWidth = Math.max(...allLines.map((line: string) => line.length)); + + // Find maximum height across all frames + this.frameHeight = Math.max( + ...Object.values(this.rawFrames).map( + (frame: string) => frame.split("\n").length, + ), + ); + + // Normalize each frame to maximum dimensions + const keyedFrames: Partial = {}; + (Object.keys(this.rawFrames) as Array).forEach( + (key) => { + const lines = this.rawFrames[key].split("\n"); + const normalized = lines.map((line: string) => + line.padEnd(this.frameWidth, " "), + ); + // Pad height if necessary + while (normalized.length < this.frameHeight) { + normalized.push(" ".repeat(this.frameWidth)); + } + keyedFrames[key] = normalized.join("\n"); + }, + ); + this.frames = keyedFrames as typeof this.rawFrames; + } + + /** + * Detect terminal animation and color support + * Returns capability object for conditional rendering + */ + private detectCapabilities(): AnimationCapabilities { + return { + supportsAnimation: + process.stdout.isTTY && + process.env.TERM !== "dumb" && + !process.env.CI && + process.env.NO_COLOR !== "1", + supportsColor: process.stdout.isTTY && process.env.NO_COLOR !== "1", + terminalWidth: process.stdout.columns || 80, + terminalHeight: process.stdout.rows || 24, + }; + } + + /** + * Clear entire screen including scrollback buffer + */ + private clearScreen(): void { + if (this.caps.supportsAnimation) { + process.stdout.write("\x1B[2J"); // Clear screen + process.stdout.write("\x1B[H"); // Move cursor to home + process.stdout.write("\x1B[3J"); // Clear scrollback + } + } + + /** + * Hide terminal cursor during animations + */ + private hideCursor(): void { + if (this.caps.supportsAnimation) { + process.stdout.write("\x1B[?25l"); + } + } + + /** + * Show terminal cursor after animations + */ + private showCursor(): void { + if (this.caps.supportsAnimation) { + process.stdout.write("\x1B[?25h"); + } + } + + /** + * Render ASCII art frame at specific horizontal position + * Uses absolute cursor positioning for each line + * Adds 1 line of padding above the cat (starts at line 2) + */ + private renderFrame(frame: string, x: number): void { + const lines = frame.split("\n"); + lines.forEach((line, idx) => { + // Move cursor to position (row, column) + // Start at line 2 (1 line padding above) + process.stdout.write(`\x1B[${idx + 2};${x}H`); + // Make cat white for better visibility + process.stdout.write(textColors.pureWhite(line)); + }); + } + + /** + * Type text character by character at specific position + * Creates typewriter effect for introducing Clef + * Repositions cursor for each character to handle concurrent animations + */ + private async typeText( + text: string, + x: number, + y: number, + delay: number = 40, + ): Promise { + // Type each character with explicit positioning + // This ensures text appears correctly even while cat legs animate + for (let i = 0; i < text.length; i++) { + // Reposition cursor for each character (handles concurrent animations) + process.stdout.write(`\x1B[${y};${x + i}H`); + process.stdout.write(textColors.pureWhite(text[i])); + await sleep(delay); + } + } + + /** + * Clear a specific line from startX to end of line + */ + private clearLine(y: number, startX: number): void { + process.stdout.write(`\x1B[${y};${startX}H`); + process.stdout.write("\x1B[K"); // Clear from cursor to end of line + } + + /** + * Animate legs in place without horizontal movement + * Continues until shouldContinue callback returns false + */ + private async animateLegs( + x: number, + shouldContinue: () => boolean, + ): Promise { + const frames = [this.frames.walk1, this.frames.walk2]; + let frameIndex = 0; + + while (shouldContinue()) { + this.renderFrame(frames[frameIndex % 2], x); + frameIndex++; + await sleep(200); // Leg animation speed + } + } + + /** + * Fade out cat Houston-style + * Erases cat from bottom to top for smooth disappearance + */ + private async fadeOut(x: number): Promise { + const catLines = this.frames.standing.split("\n"); + + // Erase from bottom to top + // Cat starts at line 2 (1 line padding), so fade from line 5 to line 2 + for (let i = catLines.length - 1; i >= 0; i--) { + process.stdout.write(`\x1B[${2 + i};${x}H`); + process.stdout.write(" ".repeat(20)); // Clear line with spaces + await sleep(80); + } + } + + /** + * Animate character walking from start to end position + * Creates smooth horizontal movement using frame interpolation + * Used for processing and outro sequences + */ + private async walk( + startX: number, + endX: number, + duration: number, + ): Promise { + if (!this.caps.supportsAnimation) return; + + const frames = [this.frames.walk1, this.frames.walk2]; + const steps = 15; + const delay = duration / steps; + + this.hideCursor(); + + for (let i = 0; i <= steps; i++) { + const progress = i / steps; + const currentX = Math.floor(startX + (endX - startX) * progress); + + this.clearScreen(); + this.renderFrame(frames[i % 2], currentX); + + await sleep(delay); + } + + this.showCursor(); + this.currentX = endX; + } + + /** + * Introduction sequence + * Cat appears stationary with animated legs, text types beside it + * Houston-style: text types out, clears, new text types, then fades + * Duration: approximately 5 seconds + */ + async intro(): Promise { + if (!this.caps.supportsAnimation) { + // Static fallback for non-TTY environments + console.log(this.frames.standing); + console.log("Hey there! My name is Clef!"); + console.log("Let me help you get started...meoww!\n"); + await sleep(2000); + return; + } + + this.hideCursor(); + this.clearScreen(); + + const catX = 1; // Start at column 1 (adds 1 column of left padding) + const textX = catX + this.frameWidth + 1; // 1 space padding after normalized frame + const labelY = 3; // Line 2 of cat output - label "Clef:" + const messageY = 4; // Line 3 of cat output - message text + + // Messages to type + const messages = [ + "Hey there! My name is Clef!", + "Let me help you get started...meoww!", + ]; + + // Start leg animation in background (non-blocking) + let isAnimating = true; + const animationPromise = this.animateLegs(catX, () => isAnimating); + + // Write static label "Clef:" in blue + process.stdout.write(`\x1B[${labelY};${textX}H`); + process.stdout.write(textColors.labelBlue("Clef: ")); + + // Type first message on line below + await this.typeText(messages[0], textX, messageY); + await sleep(1000); + + // Clear message only (keep label) + this.clearLine(messageY, textX); + await sleep(300); + + // Type second message + await this.typeText(messages[1], textX, messageY); + await sleep(1200); + + // Stop leg animation + isAnimating = false; + await animationPromise; + + // Fade out cat Houston-style + await this.fadeOut(catX); + + // Small pause before clearing + await sleep(200); + + // Clear screen for prompts + this.clearScreen(); + this.showCursor(); + } + + /** + * Processing sequence + * Shows character typing while async task executes + * Duration: depends on task execution time + */ + async processing(message: string, task: () => Promise): Promise { + if (!this.caps.supportsAnimation) { + // Static fallback + console.log(this.frames.typing); + console.log(message); + await task(); + return; + } + + // Walk in from left + await this.walk(0, 10, 800); + + // Show typing animation + this.clearScreen(); + console.log(this.frames.typing); + console.log(` ${attention(message)}`); + + // Execute actual task + await task(); + + await sleep(500); + + // Walk off to right + await this.walk(10, this.caps.terminalWidth, 800); + + // Complete clear + this.clearScreen(); + } + + /** + * Outro sequence + * Cat and text display side by side using normal console output + * Astro Houston-style: stays on screen as final message (no clear, no walk off) + * Duration: approximately 2 seconds + */ + async outro(): Promise { + if (!this.caps.supportsAnimation) { + // Static fallback + console.log(this.frames.waving); + console.log("You're all set! Happy committing!"); + return; // No clear - message stays visible + } + + // Add spacing before outro + console.log(); + + // Display cat and text side by side + // Cat on left, "Clef:" label and message on right + const catLines = this.frames.waving.split("\n"); + const catX = 1; // Start at column 1 (adds 1 column of left padding) + const textX = catX + this.frameWidth + 1; // 1 space padding after cat + + // Display cat lines with label/message beside appropriate lines + for (let i = 0; i < catLines.length; i++) { + if (i === 1) { + // Line 1: Face line - display "Clef:" label + console.log( + textColors.pureWhite(catLines[i]) + + " " + + textColors.labelBlue("Clef:"), + ); + } else if (i === 2) { + // Line 2: Body line - display message text + console.log( + textColors.pureWhite(catLines[i]) + + " " + + textColors.pureWhite("You're all set! Happy committing!"), + ); + } else { + // Other lines: just the cat in white + console.log(textColors.pureWhite(catLines[i])); + } + } + + console.log(); // Extra line at end + + // Small pause to let user see the message + await sleep(1500); + + // Done - cat and message remain visible (no clear, no cursor hide) + } + + /** + * Stop any running animation + * Ensures cursor is visible on interrupt + */ + stop(): void { + this.showCursor(); + } +} + +// Singleton instance for use across init command +export const clef = new Clef(); diff --git a/src/cli/commands/init/colors.ts b/src/cli/commands/init/colors.ts new file mode 100644 index 0000000..1f01723 --- /dev/null +++ b/src/cli/commands/init/colors.ts @@ -0,0 +1,141 @@ +/** + * Custom Color Palette + * + * Provides bright, energetic colors using ANSI 256-color codes + * for a modern, high-contrast CLI experience. + * + * Design Philosophy: + * - Vibrant but readable + * - Positive energy with warm, inviting tones + * - High contrast for easy scanning + * - Consistent visual hierarchy + */ + +/** + * Bright background colors for step labels + * Uses ANSI 256-color palette for vibrant, saturated colors + * Text is black (30m) for maximum contrast + */ +export const labelColors = { + /** + * Uniform Light Blue (#77c0f7) - Consistent label color + * Used for all step labels for unified appearance + */ + bgBrightMagenta: (text: string) => `\x1b[48;5;117m\x1b[30m${text}\x1b[0m`, + bgBrightCyan: (text: string) => `\x1b[48;5;117m\x1b[30m${text}\x1b[0m`, + bgBrightBlue: (text: string) => `\x1b[48;5;117m\x1b[30m${text}\x1b[0m`, + bgBrightYellow: (text: string) => `\x1b[48;5;117m\x1b[30m${text}\x1b[0m`, + bgBrightGreen: (text: string) => `\x1b[48;5;117m\x1b[30m${text}\x1b[0m`, +}; + +/** + * Bright foreground colors for text + * High visibility, energetic tones + */ +export const textColors = { + /** + * Bright Cyan - Clear, friendly, welcoming + * Perfect for intro text and informational content + */ + brightCyan: (text: string) => `\x1b[38;5;51m${text}\x1b[0m`, + + /** + * Bright Green - Celebratory, positive, success + * Ideal for completion messages and success indicators + */ + brightGreen: (text: string) => `\x1b[38;5;46m${text}\x1b[0m`, + + /** + * Bright Yellow - Active, processing, attention + * Great for processing messages and highlights + */ + brightYellow: (text: string) => `\x1b[38;5;226m${text}\x1b[0m`, + + /** + * Bright Magenta - Emphasis, important, highlight + * Perfect for filenames and key information + */ + brightMagenta: (text: string) => `\x1b[38;5;201m${text}\x1b[0m`, + + /** + * Bright White - Maximum visibility, bold statements + * Excellent for headings and important text + */ + brightWhite: (text: string) => `\x1b[38;5;231m${text}\x1b[0m`, + + /** + * Normal White - Clean, neutral, readable + * Perfect for intro/outro messages (normal weight, not bold) + */ + white: (text: string) => `\x1b[37m${text}\x1b[0m`, + + /** + * Pure White (#FFF) - Maximum brightness, perfect white + * For typed message text that needs to be crystal clear + */ + pureWhite: (text: string) => `\x1b[38;5;231m${text}\x1b[0m`, + + /** + * Label Blue (#547fef) - Character name/speaker label color + * Perfect for "Clef:" label + */ + labelBlue: (text: string) => `\x1b[38;5;75m${text}\x1b[0m`, + + /** + * Git Status Colors - Match git's default color scheme + */ + // Added (A) - Green (success, positive) + gitAdded: (text: string) => `\x1b[38;5;46m${text}\x1b[0m`, // Bright green + + // Modified (M) - Yellow (caution, change) + gitModified: (text: string) => `\x1b[38;5;226m${text}\x1b[0m`, // Bright yellow + + // Deleted (D) - Red (danger, removal) + gitDeleted: (text: string) => `\x1b[38;5;196m${text}\x1b[0m`, // Bright red + + // Renamed (R) - Cyan (transformation) + gitRenamed: (text: string) => `\x1b[38;5;51m${text}\x1b[0m`, // Bright cyan + + // Copied (C) - Magenta (duplication) + gitCopied: (text: string) => `\x1b[38;5;201m${text}\x1b[0m`, // Bright magenta +}; + +/** + * Text modifiers + */ +export const modifiers = { + /** + * Bold text for emphasis + */ + bold: (text: string) => `\x1b[1m${text}\x1b[22m`, + + /** + * Combine bold with color + */ + boldColor: (text: string, colorFn: (s: string) => string) => + `\x1b[1m${colorFn(text)}\x1b[22m\x1b[0m`, +}; + +/** + * Convenience function: Bold + Bright Green (success messages) + */ +export const success = (text: string) => + modifiers.boldColor(text, textColors.brightGreen); + +/** + * Convenience function: Bold + Bright Cyan (friendly messages) + */ +export const info = (text: string) => + modifiers.boldColor(text, textColors.brightCyan); + +/** + * Convenience function: Bold + Bright Yellow (attention/action) + */ +export const attention = (text: string) => + modifiers.boldColor(text, textColors.brightYellow); + +/** + * Convenience function: Bold + Bright Magenta (highlight/important) + */ +export const highlight = (text: string) => + modifiers.boldColor(text, textColors.brightMagenta); diff --git a/src/cli/commands/init/config-generator.ts b/src/cli/commands/init/config-generator.ts new file mode 100644 index 0000000..c3fb083 --- /dev/null +++ b/src/cli/commands/init/config-generator.ts @@ -0,0 +1,65 @@ +/** + * Configuration File Generator + * + * Generates YAML configuration files from user choices and preset + * definitions. Includes validation before writing to ensure all + * generated configs are valid. + */ + +import { writeFile } from "fs/promises"; +import { dump } from "js-yaml"; +import path from "path"; +import type { LabcommitrConfig } from "../../../lib/config/types.js"; +import { ConfigValidator } from "../../../lib/config/validator.js"; + +/** + * Add header comment to YAML output + * Provides context and documentation link for users + */ +function addHeader(yaml: string): string { + return `# Labcommitr Configuration +# Generated by: lab init +# Edit this file to customize your commit workflow +# Documentation: https://github.com/labcatr/labcommitr#config + +${yaml}`; +} + +/** + * Generate and write configuration file + * Validates config before writing to prevent invalid output + * + * @param config - Complete configuration object + * @param projectRoot - Path to project root directory + * @returns Path to written config file + */ +export async function generateConfigFile( + config: LabcommitrConfig, + projectRoot: string, +): Promise { + // Validate config before writing + const validator = new ConfigValidator(); + const validation = validator.validate(config); + + if (!validation.valid) { + throw new Error( + `Generated config failed validation: ${validation.errors[0].message}`, + ); + } + + // Convert to YAML with formatting options + const yaml = dump(config, { + indent: 2, + lineWidth: 80, + noRefs: true, + }); + + // Add header comments + const content = addHeader(yaml); + + // Write to file + const configPath = path.join(projectRoot, ".labcommitr.config.yaml"); + await writeFile(configPath, content, "utf-8"); + + return configPath; +} diff --git a/src/cli/commands/init/index.ts b/src/cli/commands/init/index.ts new file mode 100644 index 0000000..e228add --- /dev/null +++ b/src/cli/commands/init/index.ts @@ -0,0 +1,175 @@ +/** + * Init Command + * + * Interactive initialization command that guides users through creating + * a project-specific configuration file. Provides preset selection, + * customization options, and animated mascot for enhanced UX. + * + * Flow (Astro-style): + * 1. Intro animation (Clef introduces tool, then clears) + * 2. User prompts (preset, emoji, auto-stage) + * 3. Summary display (show choices, then clear) + * 4. Processing checklist (compact steps, stays visible) + * 5. Outro animation (Clef appears below, stays on screen) + */ + +import { Command } from "commander"; +import { existsSync } from "fs"; +import path from "path"; +import { clef } from "./clef.js"; +import { + promptPreset, + promptEmoji, + promptAutoStage, + promptBodyRequired, + displayProcessingSteps, +} from "./prompts.js"; +import { buildConfig, getPreset } from "../../../lib/presets/index.js"; +import { generateConfigFile } from "./config-generator.js"; +import { Logger } from "../../../lib/logger.js"; + +/** + * Detect project root directory + * Prioritizes git repository root, falls back to current directory + */ +async function detectProjectRoot(): Promise { + const { execSync } = await import("child_process"); + + try { + // Try to find git root + const gitRoot = execSync("git rev-parse --show-toplevel", { + encoding: "utf-8", + stdio: ["pipe", "pipe", "ignore"], + }).trim(); + + return gitRoot; + } catch { + // Not in a git repository + return null; + } +} + +/** + * Check if configuration file already exists + */ +function configExists(projectRoot: string): boolean { + const configPath = path.join(projectRoot, ".labcommitr.config.yaml"); + return existsSync(configPath); +} + +/** + * Init command definition + */ +export const initCommand = new Command("init") + .description("Initialize labcommitr configuration in your project") + .option("-f, --force", "Overwrite existing configuration") + .option( + "--preset ", + "Use a specific preset (conventional, gitmoji, angular, minimal)", + ) + .action(initAction); + +/** + * Init action handler + * Orchestrates the complete initialization flow + */ +async function initAction(options: { + force?: boolean; + preset?: string; +}): Promise { + try { + // Intro: Clef introduces herself + await clef.intro(); + // Screen is now completely clear + + // Detect project root + const projectRoot = await detectProjectRoot(); + if (!projectRoot) { + Logger.error("Not a git repository. Initialize git first: git init"); + process.exit(1); + } + + // Check for existing config + if (configExists(projectRoot) && !options.force) { + Logger.error("Configuration already exists. Use --force to overwrite."); + Logger.info(`File: ${path.join(projectRoot, ".labcommitr.config.yaml")}`); + process.exit(1); + } + + // Prompts: Clean labels, no cat + // Note: @clack/prompts clears each prompt after selection (their default behavior) + const presetId = options.preset || (await promptPreset()); + getPreset(presetId); + + const emojiEnabled = await promptEmoji(); + const autoStage = await promptAutoStage(); + const bodyRequired = await promptBodyRequired(); + + // Small pause before processing + await new Promise((resolve) => setTimeout(resolve, 800)); + + // Add spacing before processing section + console.log(); + + // Build config from choices + const config = buildConfig(presetId, { + emoji: emojiEnabled, + scope: "optional", + autoStage, + bodyRequired, + }); + + // Show title "Labcommitr initializing..." + console.log("Labcommitr initializing...\n"); + + // Show compact processing steps (Astro pattern: checklist stays visible) + await displayProcessingSteps([ + { + message: "Writing .labcommitr.config.yaml", + task: async () => { + await generateConfigFile(config, projectRoot); + await new Promise((resolve) => setTimeout(resolve, 800)); + }, + }, + { + message: "Validating configuration", + task: async () => { + await new Promise((resolve) => setTimeout(resolve, 600)); + }, + }, + { + message: "Setup complete", + task: async () => { + await new Promise((resolve) => setTimeout(resolve, 400)); + }, + }, + ]); + + // Change title to "Labcommitr initialized!" in green + // Move up to overwrite the "initializing..." title + // Current position is after the blank line following "Setup complete" + // Need to go up: 1 blank line + 3 steps (each with ✔) + 1 blank line after title + 1 title line = 6 lines + process.stdout.write("\x1B[6A"); // Move up 6 lines to title + process.stdout.write("\r"); // Move to start of line + process.stdout.write("\x1B[K"); // Clear the line + console.log("\x1B[32mLabcommitr initialized!\x1B[0m"); // Green text + process.stdout.write("\x1B[5B"); // Move back down 5 lines (title + blank + 3 steps) + + // Processing list stays visible (no clear) + + // Outro: Clef appears below processing list (Astro pattern) + await clef.outro(); + // Cat and message stay on screen - done! + } catch (error) { + // Ensure cursor is visible on error + clef.stop(); + + if (error instanceof Error) { + Logger.error(error.message); + } else { + Logger.error("Failed to initialize configuration"); + } + + process.exit(1); + } +} diff --git a/src/cli/commands/init/prompts.ts b/src/cli/commands/init/prompts.ts new file mode 100644 index 0000000..e0b0d72 --- /dev/null +++ b/src/cli/commands/init/prompts.ts @@ -0,0 +1,307 @@ +/** + * Interactive Prompts + * + * Provides clean, minimal prompts for user configuration choices. + * Uses compact color-coded labels on the left side for visual + * hierarchy without boxes or excessive whitespace. + * + * Label pattern: [colored label] [2 spaces] [content] + */ + +import { select, multiselect, isCancel } from "@clack/prompts"; +import { + labelColors, + textColors, + success, + info, + attention, + highlight, +} from "./colors.js"; + +/** + * Create compact color-coded label + * Labels are 9 characters wide (7 chars + 2 padding spaces) for alignment + * Uses bright ANSI 256 colors for high visibility + * Text is centered within the label + */ +function label( + text: string, + color: "magenta" | "cyan" | "blue" | "yellow" | "green", +): string { + const colorFn = { + magenta: labelColors.bgBrightMagenta, + cyan: labelColors.bgBrightCyan, + blue: labelColors.bgBrightBlue, + yellow: labelColors.bgBrightYellow, + green: labelColors.bgBrightGreen, + }[color]; + + // Center text within 7-character width (accommodates all current labels) + // For visual centering: when padding is odd, put extra space on LEFT for better balance + const width = 7; + const textLength = Math.min(text.length, width); // Cap at width + const padding = width - textLength; + // For odd padding (1, 3, 5...), ceil puts extra space on LEFT (better visual weight) + // For even padding (2, 4, 6...), floor/ceil both work the same + const leftPad = Math.ceil(padding / 2); + const rightPad = padding - leftPad; + const centeredText = + " ".repeat(leftPad) + text.substring(0, textLength) + " ".repeat(rightPad); + + return colorFn(` ${centeredText} `); +} + +/** + * Handle prompt cancellation + * Exits process gracefully when user cancels + */ +function handleCancel(value: unknown): void { + if (isCancel(value)) { + console.log("\nSetup cancelled."); + process.exit(0); + } +} + +/** + * Preset option data structure + * Keeps descriptions for future use while labels only show examples + */ +const PRESET_OPTIONS = [ + { + value: "conventional", + name: "Conventional Commits (Recommended)", + description: "Popular across open-source and personal projects.", + example: "fix(dining): add security to treat container", + }, + { + value: "angular", + name: "Angular Convention", + description: "Strict format used by Angular and enterprise teams.", + example: "fix(snacks): add security to treat container", + }, + { + value: "minimal", + name: "Minimal Setup", + description: "Start with basics, customize everything yourself later.", + example: "fix: add security to treat container", + }, +] as const; + +/** + * Prompt for commit style preset selection + */ +export async function promptPreset(): Promise { + const preset = await select({ + message: `${label("preset", "magenta")} ${textColors.pureWhite("Which commit style fits your project?")}`, + options: PRESET_OPTIONS.map((option) => ({ + value: option.value, + label: `${option.name} - e.g., ${option.example}`, + // description is kept in PRESET_OPTIONS for future use + })), + }); + + handleCancel(preset); + return preset as string; +} + +/** + * Prompt for emoji support preference + */ +export async function promptEmoji(): Promise { + const emoji = await select({ + message: `${label("emoji", "cyan")} ${textColors.pureWhite("Enable emoji support in commits?")}`, + options: [ + { + value: false, + label: "No (Recommended)", + hint: "Text-only commits", + }, + { + value: true, + label: "Yes", + hint: "Include emojis for better visibility", + }, + ], + }); + + handleCancel(emoji); + return emoji as boolean; +} + +/** + * Prompt for auto-stage behavior + * When enabled, stages modified/deleted tracked files automatically (git add -u) + */ +export async function promptAutoStage(): Promise { + const autoStage = await select({ + message: `${label("stage", "yellow")} ${textColors.pureWhite("Stage files automatically?")}`, + options: [ + { + value: false, + label: "No (Recommended)", + hint: "I'll stage files manually with 'git add'", + }, + { + value: true, + label: "Yes", + hint: "Auto-stage modified files before committing", + }, + ], + }); + + handleCancel(autoStage); + return autoStage as boolean; +} + +/** + * Prompt for commit body requirement + * When enabled, commit body becomes required during commit creation + */ +export async function promptBodyRequired(): Promise { + const bodyRequired = await select({ + message: `${label("body", "green")} ${textColors.pureWhite("Require commit body?")}`, + options: [ + { + value: true, + label: "Yes (Recommended)", + hint: "Always require body with commit", + }, + { + value: false, + label: "No", + hint: "Body is optional", + }, + ], + }); + + handleCancel(bodyRequired); + return bodyRequired as boolean; +} + +/** + * Prompt for scope configuration mode + */ +export async function promptScope(): Promise< + "optional" | "selective" | "always" | "never" +> { + const scope = await select({ + message: `${label("scope", "blue")} ${textColors.pureWhite("How should scopes work?")}`, + options: [ + { + value: "optional", + label: "Optional", + hint: "Flexible (recommended)", + }, + { + value: "selective", + label: "Required for specific types", + hint: "Customizable rules", + }, + { + value: "always", + label: "Always required", + hint: "Strict enforcement", + }, + { + value: "never", + label: "Never use scopes", + hint: "Simplest format", + }, + ], + }); + + handleCancel(scope); + return scope as "optional" | "selective" | "always" | "never"; +} + +/** + * Prompt for selective scope type selection + * Only shown when user selects "selective" scope mode + */ +export async function promptScopeTypes( + types: Array<{ id: string; description: string }>, +): Promise { + const selected = await multiselect({ + message: `${label("types", "blue")} ${textColors.pureWhite("Which types require a scope?")}`, + options: types.map((type) => ({ + value: type.id, + label: type.id, + hint: type.description, + })), + required: false, + }); + + handleCancel(selected); + return selected as string[]; +} + +/** + * Display completed prompts in compact form (Astro pattern) + * Shows what the user selected after @clack/prompts clears itself + * This simulates keeping prompts visible on screen + */ +export function displayCompletedPrompts(config: { + preset: string; + emoji: boolean; + scope: string; +}): void { + console.log( + `${label("preset", "magenta")} ${textColors.brightCyan(config.preset)}`, + ); + console.log( + `${label("emoji", "cyan")} ${textColors.brightCyan(config.emoji ? "Yes" : "No")}`, + ); + console.log( + `${label("scope", "blue")} ${textColors.brightCyan(config.scope)}`, + ); + console.log(); // Extra line +} + +/** + * Display processing steps as compact checklist (Astro-style) + * Shows what's happening during config generation + * Each step executes its task and displays success when complete + */ +export async function displayProcessingSteps( + steps: Array<{ message: string; task: () => Promise }>, +): Promise { + for (const step of steps) { + // Show pending state with spinning indicator + process.stdout.write(` ${textColors.brightCyan("◐")} ${step.message}...`); + + // Execute task + await step.task(); + + // Clear line and show success checkmark + process.stdout.write("\r"); // Return to start of line + console.log(` ${success("✔")} ${step.message}`); + } + console.log(); // Extra newline after all steps +} + +/** + * Display configuration file write result + */ +export function displayConfigResult(filename: string): void { + console.log(`${label("config", "green")} Writing ${highlight(filename)}`); + console.log(` ${success("Done")}\n`); +} + +/** + * Display next steps after successful setup + */ +export function displayNextSteps(): void { + console.log(`${success("✓ Ready to commit!")}\n`); + console.log( + `${label("next", "yellow")} ${attention("Get started with these commands:")}\n`, + ); + console.log( + ` ${textColors.brightCyan("lab config show")} View your configuration`, + ); + console.log( + ` ${textColors.brightCyan("lab commit")} Create your first commit\n`, + ); + console.log( + ` ${textColors.brightYellow("Customize anytime by editing .labcommitr.config.yaml")}\n`, + ); +} diff --git a/src/cli/program.ts b/src/cli/program.ts new file mode 100644 index 0000000..23ae0ee --- /dev/null +++ b/src/cli/program.ts @@ -0,0 +1,64 @@ +/** + * Commander.js program configuration + * + * This module sets up the CLI program structure including: + * - Program metadata (name, version, description) + * - Global options (--verbose, --silent, etc.) + * - Command registration + * - Help text customization + */ + +import { Command } from "commander"; +import { readFileSync } from "fs"; +import { fileURLToPath } from "url"; +import { dirname, join } from "path"; + +// Commands +import { configCommand } from "./commands/config.js"; +import { initCommand } from "./commands/init/index.js"; +import { commitCommand } from "./commands/commit.js"; + +// Get package.json for version info +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const packageJsonPath = join(__dirname, "../../package.json"); +const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8")); + +/** + * Main CLI program instance + */ +export const program = new Command(); + +// Program metadata +program + .name("labcommitr") + .description( + "A CLI tool for standardized git commits with customizable workflows", + ) + .version(packageJson.version, "-v, --version", "Display version number") + .helpOption("-h, --help", "Display help information"); + +// Global options (future: --verbose, --no-emoji, etc.) +// program.option('--verbose', 'Enable verbose logging'); + +// Register commands +program.addCommand(configCommand); +program.addCommand(initCommand); +program.addCommand(commitCommand); + +// Customize help text +program.addHelpText( + "after", + ` +Examples: + $ labcommitr init Initialize config in current project + $ lab commit Create a standardized commit (interactive) + $ lab config show Display current configuration + +Documentation: + https://github.com/labcatr/labcommitr#readme +`, +); + +// Error on unknown commands +program.showSuggestionAfterError(true); diff --git a/src/cli/utils/error-handler.ts b/src/cli/utils/error-handler.ts new file mode 100644 index 0000000..3624b13 --- /dev/null +++ b/src/cli/utils/error-handler.ts @@ -0,0 +1,63 @@ +/** + * CLI Error Handler + * + * Centralized error handling for the CLI application. + * Transforms different error types into user-friendly output. + * + * Error Types Handled: + * - ConfigError: Configuration validation and loading errors + * - Commander errors: Invalid options, missing arguments + * - System errors: File permissions, network issues + * - Unknown errors: Unexpected exceptions + */ + +import { Logger } from "../../lib/logger.js"; +import { ConfigError } from "../../lib/config/types.js"; + +/** + * Handle CLI errors with appropriate formatting + * Determines error type and displays user-friendly message + * + * @param error - Error object to handle + */ +export function handleCliError(error: unknown): void { + if (error instanceof ConfigError) { + // Configuration errors (already formatted in Step 3) + Logger.error(error.message); + + if (error.details) { + console.error(error.details); + } + + if (error.solutions && error.solutions.length > 0) { + console.error("\n💡 Solutions:"); + error.solutions.forEach((solution, index) => { + console.error(` ${index + 1}. ${solution}`); + }); + } + + return; + } + + if (error instanceof Error) { + // Standard errors + if (error.name === "CommanderError") { + // Commander.js validation errors (handled by Commander itself) + return; + } + + // Generic error + Logger.error(`Error: ${error.message}`); + + if (process.env.DEBUG) { + console.error("\nStack trace:"); + console.error(error.stack); + } + + return; + } + + // Unknown error type + Logger.error("An unexpected error occurred"); + console.error(error); +} diff --git a/src/cli/utils/version.ts b/src/cli/utils/version.ts new file mode 100644 index 0000000..2966cb5 --- /dev/null +++ b/src/cli/utils/version.ts @@ -0,0 +1,34 @@ +/** + * Version utilities + * + * Utilities for retrieving and displaying version information. + * Provides consistent version formatting across the CLI. + */ + +import { readFileSync } from "fs"; +import { fileURLToPath } from "url"; +import { dirname, join } from "path"; + +/** + * Get package version from package.json + * @returns Version string (e.g., "0.0.1") + */ +export function getVersion(): string { + const __filename = fileURLToPath(import.meta.url); + const __dirname = dirname(__filename); + const packageJsonPath = join(__dirname, "../../../package.json"); + const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8")); + return packageJson.version; +} + +/** + * Get full version info (name + version) + * @returns Full version string (e.g., "@labcatr/labcommitr v0.0.1") + */ +export function getFullVersion(): string { + const __filename = fileURLToPath(import.meta.url); + const __dirname = dirname(__filename); + const packageJsonPath = join(__dirname, "../../../package.json"); + const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8")); + return `${packageJson.name} v${packageJson.version}`; +} diff --git a/src/index.ts b/src/index.ts index 019c0f4..579048b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1 +1,33 @@ -console.log("Hello World!"); +#!/usr/bin/env node + +/** + * Labcommitr CLI Entry Point + * + * This file serves as the main entry point for the labcommitr CLI tool. + * It initializes the Commander.js program and delegates command handling + * to modular command implementations. + * + * Architecture: + * - Minimal orchestration logic (setup and error handling only) + * - Command implementations delegated to src/cli/commands/ + * - Global error handling for uncaught exceptions + */ + +import { program } from "./cli/program.js"; +import { handleCliError } from "./cli/utils/error-handler.js"; + +/** + * Main CLI execution + * Parses process arguments and executes the appropriate command + */ +async function main(): Promise { + try { + await program.parseAsync(process.argv); + } catch (error) { + handleCliError(error); + process.exit(1); + } +} + +// Execute CLI +main(); diff --git a/src/lib/config/defaults.ts b/src/lib/config/defaults.ts index edd2bd6..5eab263 100644 --- a/src/lib/config/defaults.ts +++ b/src/lib/config/defaults.ts @@ -1,26 +1,26 @@ /** * Default configuration values for labcommitr - * + * * This module provides sensible default values for all optional configuration fields, * ensuring the tool works out-of-the-box without requiring any configuration. * These defaults are merged with user-provided configuration to create the final config. */ -import type { LabcommitrConfig, RawConfig } from './types.js'; +import type { LabcommitrConfig, RawConfig } from "./types.js"; /** * Complete default configuration object - * + * * This serves as the baseline configuration when no user config is provided. * All fields are populated with sensible defaults that provide a good starting point. - * + * * Note: The 'types' array is intentionally empty here as it must be provided by the user * or through preset initialization. This ensures users consciously choose their commit types. */ export const DEFAULT_CONFIG: LabcommitrConfig = { /** Default schema version for new configurations */ - version: '1.0', - + version: "1.0", + /** Basic configuration with emoji enabled by default */ config: { // Enable emoji mode with automatic terminal detection @@ -28,18 +28,29 @@ export const DEFAULT_CONFIG: LabcommitrConfig = { // Let the system auto-detect emoji support (null = auto-detect) force_emoji_detection: null, }, - + /** Standard commit message format following conventional commits */ format: { // Template supports both emoji and text modes through variable substitution - template: '{emoji}{type}({scope}): {subject}', + template: "{emoji}{type}({scope}): {subject}", // Standard 50-character limit for commit subjects (git best practice) subject_max_length: 50, + // Commit message body configuration + body: { + // Body is optional by default (user can provide if needed) + required: false, + // No minimum length when body is provided (user decides) + min_length: 0, + // No maximum length (unlimited) + max_length: null, + // Auto-detect best method (inline for short, editor for long) + editor_preference: "auto", + }, }, - + /** Empty types array - must be provided by user or preset */ types: [], - + /** Minimal validation rules - not overly restrictive by default */ validation: { // No types require scope by default (user can enable per project) @@ -50,8 +61,10 @@ export const DEFAULT_CONFIG: LabcommitrConfig = { subject_min_length: 3, // No prohibited words by default (user can customize) prohibited_words: [], + // No prohibited words in body by default (separate from subject) + prohibited_words_body: [], }, - + /** Conservative advanced settings - minimal automation by default */ advanced: { // No aliases by default (user can add custom shortcuts) @@ -68,99 +81,110 @@ export const DEFAULT_CONFIG: LabcommitrConfig = { /** * Standard commit types for reference and preset initialization - * + * * These represent a curated set of commit types following conventional commits * and common industry practices. They are used in presets but not automatically * merged with user configuration - user must explicitly choose their types. */ export const DEFAULT_COMMIT_TYPES = [ { - id: 'feat', - description: 'A new feature for the user', - emoji: '✨', + id: "feat", + description: "A new feature for the user", + emoji: "✨", }, { - id: 'fix', - description: 'A bug fix for the user', - emoji: '🐛', + id: "fix", + description: "A bug fix for the user", + emoji: "🐛", }, { - id: 'docs', - description: 'Documentation changes', - emoji: '📚', + id: "docs", + description: "Documentation changes", + emoji: "📚", }, { - id: 'style', - description: 'Code style changes (formatting, missing semicolons, etc.)', - emoji: '💄', + id: "style", + description: "Code style changes (formatting, missing semicolons, etc.)", + emoji: "💄", }, { - id: 'refactor', - description: 'Code refactoring without changing functionality', - emoji: '♻️', + id: "refactor", + description: "Code refactoring without changing functionality", + emoji: "♻️", }, { - id: 'test', - description: 'Adding or updating tests', - emoji: '🧪', + id: "test", + description: "Adding or updating tests", + emoji: "🧪", }, { - id: 'chore', - description: 'Maintenance tasks, build changes, etc.', - emoji: '🔧', + id: "chore", + description: "Maintenance tasks, build changes, etc.", + emoji: "🔧", }, ]; /** * Merges user-provided raw configuration with default values - * + * * This function implements the "defaults filling" logic where user-provided * values take precedence over defaults, but missing fields are filled in * with sensible defaults. - * + * * @param rawConfig - User-provided configuration (potentially incomplete) * @returns Complete configuration with all fields populated */ export function mergeWithDefaults(rawConfig: RawConfig): LabcommitrConfig { // Create a deep copy of defaults to avoid mutation const merged: LabcommitrConfig = JSON.parse(JSON.stringify(DEFAULT_CONFIG)); - + // Apply user-provided values, preserving the types array merged.version = rawConfig.version ?? merged.version; merged.types = rawConfig.types; // Required field, always from user - + // Merge nested objects while preserving user preferences if (rawConfig.config) { merged.config = { ...merged.config, ...rawConfig.config }; } - + if (rawConfig.format) { merged.format = { ...merged.format, ...rawConfig.format }; + + // Handle nested body configuration if provided + if (rawConfig.format.body) { + merged.format.body = { + ...merged.format.body, + ...rawConfig.format.body, + }; + } } - + if (rawConfig.validation) { merged.validation = { ...merged.validation, ...rawConfig.validation }; } - + if (rawConfig.advanced) { merged.advanced = { ...merged.advanced, ...rawConfig.advanced }; - + // Handle nested git configuration if (rawConfig.advanced.git) { - merged.advanced.git = { ...merged.advanced.git, ...rawConfig.advanced.git }; + merged.advanced.git = { + ...merged.advanced.git, + ...rawConfig.advanced.git, + }; } } - + return merged; } /** * Creates a complete default configuration when no user config exists - * + * * This is used as a fallback when no configuration file can be found * and the user chooses not to initialize one. Provides a minimal but * functional configuration using the standard commit types. - * + * * @returns Complete default configuration ready for use */ export function createFallbackConfig(): LabcommitrConfig { diff --git a/src/lib/config/index.ts b/src/lib/config/index.ts index 0468f94..8cf9a4b 100644 --- a/src/lib/config/index.ts +++ b/src/lib/config/index.ts @@ -1,35 +1,37 @@ /** * Configuration system exports for labcommitr - * + * * This module provides the public API for the configuration loading system. * It exports the main classes, interfaces, and utility functions needed * by other parts of the application. */ // Re-export all types and interfaces -export * from './types.js'; +export * from "./types.js"; // Re-export configuration defaults and utilities -export * from './defaults.js'; +export * from "./defaults.js"; // Re-export main configuration loader -export * from './loader.js'; +export * from "./loader.js"; // Re-export configuration validator -export * from './validator.js'; +export * from "./validator.js"; /** * Convenience function to create and use a ConfigLoader instance - * + * * This provides a simple API for one-off configuration loading without * needing to manage ConfigLoader instances manually. Most consumers * should use this function rather than instantiating ConfigLoader directly. - * + * * @param startPath - Directory to start searching from (defaults to process.cwd()) * @returns Promise resolving to complete configuration with metadata */ -export async function loadConfig(startPath?: string): Promise { - const { ConfigLoader } = await import('./loader.js'); +export async function loadConfig( + startPath?: string, +): Promise { + const { ConfigLoader } = await import("./loader.js"); const loader = new ConfigLoader(); return loader.load(startPath); } diff --git a/src/lib/config/loader.ts b/src/lib/config/loader.ts index ede2bf2..bd9cac4 100644 --- a/src/lib/config/loader.ts +++ b/src/lib/config/loader.ts @@ -1,25 +1,25 @@ /** * Configuration loading system for labcommitr - * + * * This module handles the discovery, parsing, and processing of configuration files. * It implements the async-first architecture with git-prioritized project root detection, * smart caching, and comprehensive error handling. */ -import { promises as fs } from 'node:fs'; -import path from 'node:path'; -import * as yaml from 'js-yaml'; +import { promises as fs } from "node:fs"; +import path from "node:path"; +import * as yaml from "js-yaml"; -import type { - LabcommitrConfig, - RawConfig, - ConfigLoadResult, +import type { + LabcommitrConfig, + RawConfig, + ConfigLoadResult, ProjectRoot, CachedConfig, - ConfigError -} from './types.js'; -import { mergeWithDefaults, createFallbackConfig } from './defaults.js'; -import { ConfigValidator } from './validator.js'; +} from "./types.js"; +import { ConfigError } from "./types.js"; +import { mergeWithDefaults, createFallbackConfig } from "./defaults.js"; +import { ConfigValidator } from "./validator.js"; /** * Configuration file names to search for (in priority order) @@ -27,13 +27,13 @@ import { ConfigValidator } from './validator.js'; * Fallback: .labcommitr.config.yml */ const CONFIG_FILENAMES = [ - '.labcommitr.config.yaml', - '.labcommitr.config.yml' + ".labcommitr.config.yaml", + ".labcommitr.config.yml", ] as const; /** * Main configuration loader class - * + * * Handles the complete configuration loading pipeline: * 1. Project root detection (git-prioritized) * 2. Configuration file discovery @@ -45,48 +45,47 @@ const CONFIG_FILENAMES = [ export class ConfigLoader { /** Cache for loaded configurations to improve performance */ private configCache = new Map(); - + /** Cache for project root paths to avoid repeated filesystem traversal */ private projectRootCache = new Map(); /** * Main entry point for configuration loading - * + * * This method orchestrates the entire configuration loading process, * from project root detection through final configuration assembly. - * + * * @param startPath - Directory to start searching from (defaults to process.cwd()) * @returns Promise resolving to complete configuration with metadata */ public async load(startPath?: string): Promise { const searchStartPath = startPath ?? process.cwd(); - + try { // Step 1: Detect project root with git prioritization const projectRoot = await this.findProjectRoot(searchStartPath); - + // Step 2: Look for configuration file within project boundaries const configPath = await this.findConfigFile(projectRoot.path); - + if (!configPath) { // No config found - return fallback configuration return this.createFallbackResult(projectRoot); } - + // Step 3: Check if we have this config cached const cached = this.getCachedConfig(configPath); - if (cached && await this.isCacheValid(cached, configPath)) { + if (cached && (await this.isCacheValid(cached, configPath))) { return cached.data; } - + // Step 4: Load and process the configuration file const result = await this.loadConfigFile(configPath, projectRoot); - + // Step 5: Cache the result for future use this.cacheConfig(configPath, result); - + return result; - } catch (error) { // Transform any errors into user-friendly ConfigError instances throw this.transformError(error, searchStartPath); @@ -95,13 +94,13 @@ export class ConfigLoader { /** * Finds the project root directory using git-prioritized detection - * + * * Search strategy: * 1. Traverse upward from start directory * 2. Priority 1: Look for .git directory (git repository root) * 3. Priority 2: Look for package.json (Node.js project root) * 4. Fallback: Stop at filesystem root - * + * * @param startPath - Directory to begin search from * @returns Promise resolving to project root information */ @@ -111,42 +110,42 @@ export class ConfigLoader { if (cachedRoot) { return cachedRoot; } - + let currentDir = path.resolve(startPath); let projectRoot: ProjectRoot | null = null; - + // Traverse upward until we find a project marker or hit filesystem root while (currentDir !== path.dirname(currentDir)) { // Priority 1: Check for .git directory (git repository root) - if (await this.directoryExists(path.join(currentDir, '.git'))) { - projectRoot = await this.createProjectRoot(currentDir, 'git'); + if (await this.directoryExists(path.join(currentDir, ".git"))) { + projectRoot = await this.createProjectRoot(currentDir, "git"); break; } - + // Priority 2: Check for package.json (Node.js project root) - if (await this.fileExists(path.join(currentDir, 'package.json'))) { - projectRoot = await this.createProjectRoot(currentDir, 'package.json'); + if (await this.fileExists(path.join(currentDir, "package.json"))) { + projectRoot = await this.createProjectRoot(currentDir, "package.json"); break; } - + // Move up one directory level currentDir = path.dirname(currentDir); } - + // Fallback: Use filesystem root if no project markers found if (!projectRoot) { - projectRoot = await this.createProjectRoot(currentDir, 'filesystem-root'); + projectRoot = await this.createProjectRoot(currentDir, "filesystem-root"); } - + // Cache the result for future lookups this.projectRootCache.set(startPath, projectRoot); - + return projectRoot; } /** * Searches for configuration file within project boundaries - * + * * @param projectRoot - Project root directory to search in * @returns Promise resolving to config file path or null if not found */ @@ -154,38 +153,41 @@ export class ConfigLoader { // Search for each possible config filename in order of preference for (const filename of CONFIG_FILENAMES) { const configPath = path.join(projectRoot, filename); - + if (await this.fileExists(configPath)) { return configPath; } } - + return null; } /** * Creates a ProjectRoot object with monorepo detection - * + * * @param rootPath - The detected project root path * @param markerType - What type of marker identified this as the root * @returns Promise resolving to complete ProjectRoot information */ - private async createProjectRoot(rootPath: string, markerType: ProjectRoot['markerType']): Promise { + private async createProjectRoot( + rootPath: string, + markerType: ProjectRoot["markerType"], + ): Promise { // For now, implement basic monorepo detection by counting package.json files // More sophisticated detection can be added later const subprojects = await this.findSubprojects(rootPath); - + return { path: rootPath, markerType, isMonorepo: subprojects.length > 1, // Multiple package.json = likely monorepo - subprojects + subprojects, }; } /** * Finds subprojects within the project root (basic monorepo support) - * + * * @param rootPath - Project root directory to search * @returns Promise resolving to array of subproject paths */ @@ -195,16 +197,20 @@ export class ConfigLoader { try { const entries = await fs.readdir(rootPath, { withFileTypes: true }); const subprojects: string[] = []; - + for (const entry of entries) { - if (entry.isDirectory() && !entry.name.startsWith('.')) { - const packageJsonPath = path.join(rootPath, entry.name, 'package.json'); + if (entry.isDirectory() && !entry.name.startsWith(".")) { + const packageJsonPath = path.join( + rootPath, + entry.name, + "package.json", + ); if (await this.fileExists(packageJsonPath)) { subprojects.push(path.join(rootPath, entry.name)); } } } - + return subprojects; } catch { // If we can't read the directory, return empty array @@ -214,73 +220,102 @@ export class ConfigLoader { /** * Creates a fallback configuration result when no config file is found - * + * * @param projectRoot - Project root information * @returns ConfigLoadResult with default configuration */ private createFallbackResult(projectRoot: ProjectRoot): ConfigLoadResult { const fallbackConfig = createFallbackConfig(); - + return { config: fallbackConfig, - source: 'defaults', + source: "defaults", loadedAt: Date.now(), - emojiModeActive: this.detectEmojiSupport() // TODO: Implement emoji detection + emojiModeActive: this.detectEmojiSupport(), // TODO: Implement emoji detection }; } /** * Loads and processes a configuration file - * + * * @param configPath - Path to the configuration file * @param projectRoot - Project root information * @returns Promise resolving to processed configuration */ - private async loadConfigFile(configPath: string, projectRoot: ProjectRoot): Promise { + private async loadConfigFile( + configPath: string, + projectRoot: ProjectRoot, + ): Promise { // Validate file permissions before attempting to read await this.validateFilePermissions(configPath); - + // Parse the YAML file const rawConfig = await this.parseYamlFile(configPath); - + // Validate the parsed configuration const validator = new ConfigValidator(); const validationResult = validator.validate(rawConfig); - + if (!validationResult.valid) { - // Transform validation errors into user-friendly ConfigError - const errorMessages = validationResult.errors.map(error => - `${error.field}: ${error.message}` - ).join('\n'); - - throw new (Error as any)( // TODO: Use proper ConfigError import - `Invalid configuration in ${configPath}`, - `Configuration validation failed:\n${errorMessages}`, + // Format errors with rich context for user-friendly output + const formattedErrors = validationResult.errors + .map((error, index) => { + const count = index + 1; + const location = error.fieldDisplay || error.field; + + let errorBlock = `\n${count}. ${location}:\n`; + errorBlock += ` ${error.userMessage || error.message}\n`; + + if (error.value !== undefined) { + errorBlock += ` Found: ${JSON.stringify(error.value)}\n`; + } + + if (error.issue) { + errorBlock += ` Issue: ${error.issue}\n`; + } + + if (error.expectedFormat) { + errorBlock += ` Rule: ${error.expectedFormat}\n`; + } + + if (error.examples && error.examples.length > 0) { + errorBlock += ` Examples: ${error.examples.join(", ")}\n`; + } + + return errorBlock; + }) + .join("\n"); + + const count = validationResult.errors.length; + const plural = count === 1 ? "error" : "errors"; + const filename = path.basename(configPath); + + throw new ConfigError( + `Configuration Error: ${filename}`, + `Found ${count} validation ${plural}:${formattedErrors}`, [ - 'Check the configuration file syntax and required fields', - 'Ensure all commit types have valid "id" and "description" fields', - 'Verify that type IDs contain only lowercase letters (a-z)', - 'Run \'lab init\' to generate a valid configuration file' + `Edit ${filename} to fix the issues listed above`, + "See documentation for valid field formats: https://github.com/labcatr/labcommitr#config", ], - configPath + configPath, ); } - + // Merge with defaults to create complete configuration const processedConfig = mergeWithDefaults(rawConfig); - + return { config: processedConfig, - source: 'project', + source: "project", path: configPath, loadedAt: Date.now(), - emojiModeActive: this.detectEmojiSupport() // TODO: Implement emoji detection + emojiModeActive: this.detectEmojiSupport(), // TODO: Implement emoji detection }; } /** * Utility: Check if a file exists - * + * * @param filePath - Path to check * @returns Promise resolving to whether file exists */ @@ -295,7 +330,7 @@ export class ConfigLoader { /** * Utility: Check if a directory exists - * + * * @param dirPath - Directory path to check * @returns Promise resolving to whether directory exists */ @@ -310,10 +345,10 @@ export class ConfigLoader { /** * Detects whether the current terminal supports emoji display - * + * * TODO: Implement proper emoji detection logic * For now, returns true as a placeholder - * + * * @returns Whether emojis should be displayed */ private detectEmojiSupport(): boolean { @@ -323,10 +358,10 @@ export class ConfigLoader { /** * Retrieves cached configuration if available - * + * * This method checks the in-memory cache for previously loaded configurations * to avoid redundant file system operations and parsing. - * + * * @param configPath - Path to configuration file * @returns Cached configuration or undefined if not cached */ @@ -336,24 +371,27 @@ export class ConfigLoader { /** * Validates whether cached configuration is still valid - * + * * Cache validity is determined by comparing file modification time * with the cache timestamp. If the file has been modified since * caching, the cache is considered invalid. - * + * * @param cached - Cached configuration entry * @param configPath - Path to configuration file * @returns Promise resolving to whether cache is valid */ - private async isCacheValid(cached: CachedConfig, configPath: string): Promise { + private async isCacheValid( + cached: CachedConfig, + configPath: string, + ): Promise { try { // Get file modification time const stats = await fs.stat(configPath); const fileModifiedTime = stats.mtime.getTime(); - + // Cache is valid if file hasn't been modified since caching // Allow small time difference (1 second) to account for filesystem precision - return fileModifiedTime <= (cached.timestamp + 1000); + return fileModifiedTime <= cached.timestamp + 1000; } catch { // If we can't stat the file, assume cache is invalid // This handles cases where file was deleted or permissions changed @@ -363,11 +401,11 @@ export class ConfigLoader { /** * Caches a configuration result for performance optimization - * + * * Stores the configuration result in memory with metadata for * cache invalidation. Future loads of the same file will use * the cached result if the file hasn't been modified. - * + * * @param configPath - Path to configuration file * @param result - Configuration result to cache */ @@ -375,14 +413,15 @@ export class ConfigLoader { const cacheEntry: CachedConfig = { data: result, timestamp: Date.now(), - watchedPaths: [configPath] // For future file watching enhancement + watchedPaths: [configPath], // For future file watching enhancement }; - + this.configCache.set(configPath, cacheEntry); - + // Optional: Implement cache size limit to prevent memory leaks // For now, keep it simple - can add LRU eviction later if needed - if (this.configCache.size > 50) { // Arbitrary limit + if (this.configCache.size > 50) { + // Arbitrary limit // Remove oldest entries (simple FIFO eviction) const entries = Array.from(this.configCache.entries()); const oldestKey = entries[0][0]; @@ -392,10 +431,10 @@ export class ConfigLoader { /** * Validates that a file exists and is readable - * + * * This method performs pre-read validation to provide clear error messages * when files cannot be accessed due to permission issues or missing files. - * + * * @param filePath - Path to file to validate * @throws ConfigError if file cannot be read */ @@ -403,60 +442,60 @@ export class ConfigLoader { try { await fs.access(filePath, fs.constants.R_OK); } catch (error: any) { - if (error.code === 'ENOENT') { + if (error.code === "ENOENT") { // File not found - this is handled upstream, but provide clear error if called directly throw new (Error as any)( // TODO: Use proper ConfigError import `Configuration file not found: ${filePath}`, - 'The file does not exist', - ['Run \'lab init\' to create a configuration file'], - filePath + "The file does not exist", + ["Run 'lab init' to create a configuration file"], + filePath, ); - } else if (error.code === 'EACCES') { + } else if (error.code === "EACCES") { // Permission denied - provide actionable solutions throw new (Error as any)( // TODO: Use proper ConfigError import `Cannot read configuration file: ${filePath}`, - 'Permission denied - insufficient file permissions', + "Permission denied - insufficient file permissions", [ `Check file permissions: ls -la ${path.basename(filePath)}`, `Fix permissions: chmod 644 ${path.basename(filePath)}`, - 'Verify file ownership with your system administrator' + "Verify file ownership with your system administrator", ], - filePath + filePath, ); - } else if (error.code === 'ENOTDIR') { + } else if (error.code === "ENOTDIR") { // Path component is not a directory throw new (Error as any)( // TODO: Use proper ConfigError import `Invalid path to configuration file: ${filePath}`, - 'A component in the path is not a directory', + "A component in the path is not a directory", [ - 'Verify the file path is correct', - 'Check that all parent directories exist' + "Verify the file path is correct", + "Check that all parent directories exist", ], - filePath + filePath, ); } - + // Re-throw unexpected errors with additional context throw new (Error as any)( // TODO: Use proper ConfigError import `Failed to access configuration file: ${filePath}`, `System error: ${error.message}`, [ - 'Check file and directory permissions', - 'Verify the file path is correct', - 'Contact system administrator if the problem persists' + "Check file and directory permissions", + "Verify the file path is correct", + "Contact system administrator if the problem persists", ], - filePath + filePath, ); } } /** * Parses a YAML configuration file with comprehensive error handling - * + * * This method reads and parses YAML files using js-yaml's safe loader * to prevent code execution. It provides detailed error messages for * common YAML syntax issues and validation problems. - * + * * @param filePath - Path to YAML file to parse * @returns Promise resolving to parsed configuration object * @throws ConfigError for YAML syntax or structure errors @@ -464,66 +503,65 @@ export class ConfigLoader { private async parseYamlFile(filePath: string): Promise { try { // Read file content as UTF-8 text - const fileContent = await fs.readFile(filePath, 'utf8'); - + const fileContent = await fs.readFile(filePath, "utf8"); + // Check for empty file (common user error) if (!fileContent.trim()) { throw new (Error as any)( // TODO: Use proper ConfigError import `Configuration file is empty: ${filePath}`, - 'The file contains no content or only whitespace', + "The file contains no content or only whitespace", [ - 'Add configuration content to the file', - 'Run \'lab init\' to generate a valid configuration file', - 'Copy from another project or use documentation examples' + "Add configuration content to the file", + "Run 'lab init' to generate a valid configuration file", + "Copy from another project or use documentation examples", ], - filePath + filePath, ); } - + // Parse YAML with safe loader (prevents code execution) // Use DEFAULT_SCHEMA for full YAML 1.2 compatibility - const parsed = yaml.load(fileContent, { + const parsed = yaml.load(fileContent, { schema: yaml.DEFAULT_SCHEMA, - filename: filePath // Helps with error reporting + filename: filePath, // Helps with error reporting }); - + // Validate that result is an object (not null, string, array, etc.) - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { - const actualType = Array.isArray(parsed) ? 'array' : typeof parsed; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + const actualType = Array.isArray(parsed) ? "array" : typeof parsed; throw new (Error as any)( // TODO: Use proper ConfigError import `Invalid configuration structure in ${filePath}`, `Configuration must be a YAML object, but got ${actualType}`, [ - 'Ensure the file contains a valid YAML object (key-value pairs)', - 'Check that the file starts with object properties, not a list or scalar', - 'Run \'lab init\' to generate a valid configuration file' + "Ensure the file contains a valid YAML object (key-value pairs)", + "Check that the file starts with object properties, not a list or scalar", + "Run 'lab init' to generate a valid configuration file", ], - filePath + filePath, ); } - + // Basic structure validation - ensure required 'types' field exists const config = parsed as any; if (!Array.isArray(config.types)) { throw new (Error as any)( // TODO: Use proper ConfigError import `Missing required 'types' field in ${filePath}`, - 'Configuration must include a \'types\' array defining commit types', + "Configuration must include a 'types' array defining commit types", [ - 'Add a \'types\' field with an array of commit type objects', - 'Each type should have \'id\', \'description\', and optionally \'emoji\'', - 'Run \'lab init\' to generate a valid configuration file' + "Add a 'types' field with an array of commit type objects", + "Each type should have 'id', 'description', and optionally 'emoji'", + "Run 'lab init' to generate a valid configuration file", ], - filePath + filePath, ); } - + return config as RawConfig; - } catch (error: any) { // Transform YAML parsing errors into user-friendly messages if (error instanceof yaml.YAMLException) { const { message, mark } = error; - + // Extract line and column information if available if (mark) { const lineInfo = `line ${mark.line + 1}, column ${mark.column + 1}`; @@ -532,11 +570,11 @@ export class ConfigLoader { `Parsing error: ${message}`, [ `Check the syntax around line ${mark.line + 1}`, - 'Common issues: incorrect indentation, missing colons, unquoted special characters', - 'Use a YAML validator (e.g., yamllint) to identify syntax issues', - 'Run \'lab init\' to generate a fresh config file' + "Common issues: incorrect indentation, missing colons, unquoted special characters", + "Use a YAML validator (e.g., yamllint) to identify syntax issues", + "Run 'lab init' to generate a fresh config file", ], - filePath + filePath, ); } else { // YAML error without specific location @@ -544,140 +582,142 @@ export class ConfigLoader { `Invalid YAML syntax in ${filePath}`, `Parsing error: ${message}`, [ - 'Check YAML syntax throughout the file', - 'Common issues: incorrect indentation, missing colons, unquoted special characters', - 'Use a YAML validator to identify issues', - 'Run \'lab init\' to generate a fresh config file' + "Check YAML syntax throughout the file", + "Common issues: incorrect indentation, missing colons, unquoted special characters", + "Use a YAML validator to identify issues", + "Run 'lab init' to generate a fresh config file", ], - filePath + filePath, ); } } - + // Re-throw if it's already a ConfigError (from our validation above) - if (error.name === 'ConfigError') { + if (error.name === "ConfigError") { throw error; } - + // Handle file system errors that might occur during reading - if (error.code === 'EISDIR') { + if (error.code === "EISDIR") { throw new (Error as any)( // TODO: Use proper ConfigError import `Cannot read configuration: ${filePath} is a directory`, - 'Expected a file but found a directory', + "Expected a file but found a directory", [ - 'Ensure the path points to a file, not a directory', - 'Check for naming conflicts with directories' + "Ensure the path points to a file, not a directory", + "Check for naming conflicts with directories", ], - filePath + filePath, ); } - + // Generic error fallback with context throw new (Error as any)( // TODO: Use proper ConfigError import `Failed to parse configuration file: ${filePath}`, `Unexpected error: ${error.message}`, [ - 'Verify the file is a valid YAML file', - 'Check file encoding (should be UTF-8)', - 'Run \'lab init\' to generate a fresh config file' + "Verify the file is a valid YAML file", + "Check file encoding (should be UTF-8)", + "Run 'lab init' to generate a fresh config file", ], - filePath + filePath, ); } } /** * Transforms various error types into user-friendly ConfigError instances - * + * * This method serves as the central error transformation point, ensuring * all errors thrown by the configuration system provide actionable guidance * to users rather than technical implementation details. - * + * * @param error - The original error that occurred * @param context - Additional context about where the error occurred * @returns ConfigError with user-friendly messaging and solutions */ private transformError(error: any, context: string): Error { // If it's already a ConfigError, pass it through unchanged - if (error.name === 'ConfigError') { + if (error.name === "ConfigError") { return error; } - + // Handle common file system errors with specific guidance - if (error.code === 'ENOENT') { + if (error.code === "ENOENT") { return new (Error as any)( // TODO: Use proper ConfigError import `No configuration found starting from ${context}`, - 'Could not locate a labcommitr configuration file in the project', + "Could not locate a labcommitr configuration file in the project", [ - 'Run \'lab init\' to create a configuration file', - 'Ensure you\'re in a git repository or Node.js project', - 'Check that you have read permissions for the directory tree' - ] + "Run 'lab init' to create a configuration file", + "Ensure you're in a git repository or Node.js project", + "Check that you have read permissions for the directory tree", + ], ); } - - if (error.code === 'EACCES') { + + if (error.code === "EACCES") { return new (Error as any)( // TODO: Use proper ConfigError import `Permission denied while searching for configuration`, `Cannot access directory or file: ${error.path || context}`, [ - 'Check directory permissions in the project tree', - 'Ensure you have read access to the project directory', - 'Contact your system administrator if in a shared environment' - ] + "Check directory permissions in the project tree", + "Ensure you have read access to the project directory", + "Contact your system administrator if in a shared environment", + ], ); } - - if (error.code === 'ENOTDIR') { + + if (error.code === "ENOTDIR") { return new (Error as any)( // TODO: Use proper ConfigError import `Invalid directory structure encountered`, `Expected directory but found file: ${error.path || context}`, [ - 'Verify the project directory structure is correct', - 'Check for files where directories are expected' - ] + "Verify the project directory structure is correct", + "Check for files where directories are expected", + ], ); } - + // Handle YAML-related errors (these should typically be caught upstream) if (error instanceof yaml.YAMLException) { return new (Error as any)( // TODO: Use proper ConfigError import `Configuration file contains invalid YAML syntax`, `YAML parsing error: ${error.message}`, [ - 'Check YAML syntax in your configuration file', - 'Use a YAML validator to identify issues', - 'Run \'lab init\' to generate a fresh config file' - ] + "Check YAML syntax in your configuration file", + "Use a YAML validator to identify issues", + "Run 'lab init' to generate a fresh config file", + ], ); } - + // Handle timeout errors (e.g., from slow file systems) - if (error.code === 'ETIMEDOUT') { + if (error.code === "ETIMEDOUT") { return new (Error as any)( // TODO: Use proper ConfigError import `Timeout while accessing configuration files`, - 'File system operation took too long to complete', + "File system operation took too long to complete", [ - 'Check if the file system is responsive', - 'Try again in a few moments', - 'Consider checking disk space and system load' - ] + "Check if the file system is responsive", + "Try again in a few moments", + "Consider checking disk space and system load", + ], ); } - + // Generic error fallback with as much context as possible - const errorMessage = error.message || 'Unknown error occurred'; - const errorContext = error.stack ? `\n\nTechnical details:\n${error.stack}` : ''; - + const errorMessage = error.message || "Unknown error occurred"; + const errorContext = error.stack + ? `\n\nTechnical details:\n${error.stack}` + : ""; + return new (Error as any)( // TODO: Use proper ConfigError import `Configuration loading failed`, `${errorMessage}${errorContext}`, [ - 'Check file permissions and syntax', - 'Verify you\'re in a valid project directory', - 'Run \'lab init\' to reset configuration', - 'Report this issue if the problem persists with details about your setup' - ] + "Check file permissions and syntax", + "Verify you're in a valid project directory", + "Run 'lab init' to reset configuration", + "Report this issue if the problem persists with details about your setup", + ], ); } } diff --git a/src/lib/config/types.ts b/src/lib/config/types.ts index 661ee12..c9bf283 100644 --- a/src/lib/config/types.ts +++ b/src/lib/config/types.ts @@ -1,6 +1,6 @@ /** * TypeScript interfaces for labcommitr configuration system - * + * * This file defines the core types used throughout the config loading system, * ensuring type safety and clear contracts between components. */ @@ -18,6 +18,21 @@ export interface CommitType { emoji?: string; } +/** + * Commit message body configuration + * Controls how commit message body/description is collected and validated + */ +export interface BodyConfig { + /** Whether commit body is required (default: false) */ + required: boolean; + /** Minimum length when body is provided (default: 0 = no minimum) */ + min_length: number; + /** Maximum length (null = unlimited, default: null) */ + max_length: number | null; + /** Preferred editor for body input (default: "auto") */ + editor_preference: "auto" | "inline" | "editor"; +} + /** * Main configuration interface - fully resolved with all defaults applied * This represents the complete configuration structure after processing @@ -38,6 +53,8 @@ export interface LabcommitrConfig { template: string; /** Maximum length for commit subject line */ subject_max_length: number; + /** Configuration for commit message body/description */ + body: BodyConfig; }; /** Array of available commit types (presence = enabled) */ types: CommitType[]; @@ -51,6 +68,8 @@ export interface LabcommitrConfig { subject_min_length: number; /** Words prohibited in commit subjects */ prohibited_words: string[]; + /** Words prohibited in commit body (separate from subject) */ + prohibited_words_body: string[]; }; /** Advanced configuration options */ advanced: { @@ -75,15 +94,15 @@ export interface RawConfig { /** Schema version for future compatibility */ version?: string; /** Basic configuration settings */ - config?: Partial; + config?: Partial; /** Commit message formatting rules */ - format?: Partial; + format?: Partial; /** Array of available commit types - REQUIRED FIELD */ types: CommitType[]; /** Validation rules for commit messages */ - validation?: Partial; + validation?: Partial; /** Advanced configuration options */ - advanced?: Partial; + advanced?: Partial; } /** @@ -94,7 +113,7 @@ export interface ConfigLoadResult { /** The fully processed configuration */ config: LabcommitrConfig; /** Source of the configuration */ - source: 'project' | 'global' | 'defaults'; + source: "project" | "global" | "defaults"; /** Absolute path to config file (if loaded from file) */ path?: string; /** Timestamp when config was loaded */ @@ -111,7 +130,7 @@ export interface ProjectRoot { /** Absolute path to the project root directory */ path: string; /** Type of marker that identified this as project root */ - markerType: 'git' | 'package.json' | 'filesystem-root'; + markerType: "git" | "package.json" | "filesystem-root"; /** Whether this appears to be a monorepo structure */ isMonorepo: boolean; /** Paths to detected subprojects (if any) */ @@ -144,15 +163,25 @@ export interface ValidationResult { /** * Individual validation error - * Provides specific information about what failed validation + * Provides specific information about what failed validation with rich context */ export interface ValidationError { - /** The configuration field that failed validation */ + /** Technical field path (e.g., "types[0].id") */ field: string; - /** Human-readable error message */ + /** User-friendly field description (e.g., "Commit type #1 → ID field") */ + fieldDisplay: string; + /** Technical error message for developers */ message: string; - /** The actual value that failed validation */ + /** User-friendly error explanation */ + userMessage: string; + /** The actual problematic value */ value?: unknown; + /** What format/type was expected */ + expectedFormat?: string; + /** Array of valid example values */ + examples?: string[]; + /** Specific issue identified (e.g., "Contains dash (-)") */ + issue?: string; } /** @@ -162,7 +191,7 @@ export interface ValidationError { export class ConfigError extends Error { /** * Creates a new configuration error with user-friendly messaging - * + * * @param message - Primary error message (what went wrong) * @param details - Technical details about the error * @param solutions - Array of actionable solutions for the user @@ -172,33 +201,33 @@ export class ConfigError extends Error { message: string, public readonly details: string, public readonly solutions: string[], - public readonly filePath?: string + public readonly filePath?: string, ) { super(message); - this.name = 'ConfigError'; - + this.name = "ConfigError"; + // Ensure proper prototype chain for instanceof checks Object.setPrototypeOf(this, ConfigError.prototype); } - + /** * Formats the error for display to users * Includes the message, details, and actionable solutions */ public formatForUser(): string { let output = `❌ ${this.message}\n`; - + if (this.details) { output += `\nDetails: ${this.details}\n`; } - + if (this.solutions.length > 0) { output += `\nSolutions:\n`; - this.solutions.forEach(solution => { + this.solutions.forEach((solution) => { output += ` ${solution}\n`; }); } - + return output; } } diff --git a/src/lib/config/validator.ts b/src/lib/config/validator.ts index dd54808..8f68ad1 100644 --- a/src/lib/config/validator.ts +++ b/src/lib/config/validator.ts @@ -1,13 +1,18 @@ /** * Configuration validation system for labcommitr - * + * * Implements incremental validation following the CONFIG_SCHEMA.md specification. * Phase 1: Basic schema validation (required fields, types, structure) - * Phase 2: Business logic validation (uniqueness, cross-references) + * Phase 2: Business logic validation (uniqueness, cross-references) * Phase 3: Advanced validation (templates, industry standards) */ -import type { RawConfig, ValidationResult, ValidationError, CommitType } from './types.js'; +import type { + RawConfig, + ValidationResult, + ValidationError, + CommitType, +} from "./types.js"; /** * Configuration validator class @@ -22,28 +27,33 @@ export class ConfigValidator { */ validate(config: unknown): ValidationResult { const errors: ValidationError[] = []; - + // Phase 1: Basic structure validation if (!this.isValidConfigStructure(config)) { errors.push({ - field: 'root', - message: 'Configuration must be an object', - value: config + field: "root", + fieldDisplay: "Configuration root", + message: "Configuration must be an object", + userMessage: + "Configuration file must contain an object with key-value pairs", + value: config, + expectedFormat: 'YAML object with fields like "version", "types", etc.', + issue: "Found non-object value at root level", }); return { valid: false, errors }; } - + const typedConfig = config as RawConfig; - + // Validate required types array errors.push(...this.validateTypes(typedConfig)); - + // Validate optional sections (only basic structure for Phase 1) errors.push(...this.validateOptionalSections(typedConfig)); - + return { valid: errors.length === 0, - errors + errors, }; } @@ -54,42 +64,62 @@ export class ConfigValidator { */ private validateTypes(config: RawConfig): ValidationError[] { const errors: ValidationError[] = []; - + // Check if types field exists if (!config.types) { errors.push({ - field: 'types', + field: "types", + fieldDisplay: "Commit types array", message: 'Required field "types" is missing', - value: undefined + userMessage: 'Configuration must include a "types" array', + value: undefined, + expectedFormat: "array with at least one commit type object", + examples: ["feat", "fix", "docs", "refactor", "test"], + issue: "Missing required field", }); return errors; } - + // Check if types is an array if (!Array.isArray(config.types)) { errors.push({ - field: 'types', + field: "types", + fieldDisplay: "Commit types", message: 'Field "types" must be an array', - value: config.types + userMessage: 'The "types" field must be a list of commit type objects', + value: config.types, + expectedFormat: "array with at least one commit type object", + issue: "Found non-array value", }); return errors; } - + // Check if types array is non-empty if (config.types.length === 0) { errors.push({ - field: 'types', + field: "types", + fieldDisplay: "Commit types array", message: 'Field "types" must contain at least one commit type', - value: config.types + userMessage: "Configuration must define at least one commit type", + value: config.types, + expectedFormat: "array with at least one commit type object", + examples: [ + "feat (features)", + "fix (bug fixes)", + "docs (documentation)", + "refactor (code restructuring)", + "test (testing)", + ], + issue: "Empty types array", }); return errors; } - + // Validate each commit type config.types.forEach((type, index) => { errors.push(...this.validateCommitType(type, index)); }); - + return errors; } @@ -102,79 +132,153 @@ export class ConfigValidator { private validateCommitType(type: unknown, index: number): ValidationError[] { const errors: ValidationError[] = []; const fieldPrefix = `types[${index}]`; - + const displayPrefix = `Commit type #${index + 1}`; + // Check if type is an object - if (!type || typeof type !== 'object' || Array.isArray(type)) { + if (!type || typeof type !== "object" || Array.isArray(type)) { errors.push({ field: fieldPrefix, - message: 'Each commit type must be an object', - value: type + fieldDisplay: displayPrefix, + message: "Each commit type must be an object", + userMessage: + "Each commit type must be an object with id and description fields", + value: type, + expectedFormat: 'object with "id" and "description" fields', + issue: "Found non-object value", }); return errors; } - + const commitType = type as Partial; - + // Validate required 'id' field if (!commitType.id) { errors.push({ field: `${fieldPrefix}.id`, + fieldDisplay: `${displayPrefix} → ID field`, message: 'Required field "id" is missing', - value: commitType.id + userMessage: "Every commit type must have an ID", + value: commitType.id, + expectedFormat: "lowercase letters only (a-z)", + examples: ["feat", "fix", "docs", "refactor", "test"], + issue: "Missing required field", }); - } else if (typeof commitType.id !== 'string') { + } else if (typeof commitType.id !== "string") { errors.push({ field: `${fieldPrefix}.id`, + fieldDisplay: `${displayPrefix} → ID field`, message: 'Field "id" must be a string', - value: commitType.id + userMessage: "Commit type ID must be text", + value: commitType.id, + expectedFormat: "lowercase letters only (a-z)", + examples: ["feat", "fix", "docs", "refactor", "test"], + issue: "Found non-string value", }); - } else if (commitType.id.trim() === '') { + } else if (commitType.id.trim() === "") { errors.push({ field: `${fieldPrefix}.id`, + fieldDisplay: `${displayPrefix} → ID field`, message: 'Field "id" cannot be empty', - value: commitType.id + userMessage: "Commit type ID cannot be empty", + value: commitType.id, + expectedFormat: "lowercase letters only (a-z)", + examples: ["feat", "fix", "docs", "refactor", "test"], + issue: "Empty string", }); } else if (!/^[a-z]+$/.test(commitType.id)) { + // Identify specific problematic characters + const invalidChars = commitType.id + .split("") + .filter((char) => !/[a-z]/.test(char)) + .filter((char, idx, arr) => arr.indexOf(char) === idx) // unique + .map((char) => { + if (char === char.toUpperCase() && char !== char.toLowerCase()) { + return `${char} (uppercase)`; + } else if (char === "-") { + return `- (dash)`; + } else if (char === "_") { + return `_ (underscore)`; + } else if (/\d/.test(char)) { + return `${char} (number)`; + } else if (char === " ") { + return "(space)"; + } else { + return `${char} (special character)`; + } + }); + errors.push({ field: `${fieldPrefix}.id`, + fieldDisplay: `${displayPrefix} → ID field`, message: 'Field "id" must contain only lowercase letters (a-z)', - value: commitType.id + userMessage: "Commit type IDs must be lowercase letters only", + value: commitType.id, + expectedFormat: "lowercase letters only (a-z)", + examples: ["feat", "fix", "docs", "refactor", "test"], + issue: `Contains invalid characters: ${invalidChars.join(", ")}`, }); } - + // Validate required 'description' field if (!commitType.description) { errors.push({ field: `${fieldPrefix}.description`, + fieldDisplay: `${displayPrefix} → description field`, message: 'Required field "description" is missing', - value: commitType.description + userMessage: "Every commit type must have a description", + value: commitType.description, + examples: [ + '"A new feature"', + '"Bug fix for users"', + '"Documentation changes"', + ], + issue: "Missing required field", }); - } else if (typeof commitType.description !== 'string') { + } else if (typeof commitType.description !== "string") { errors.push({ field: `${fieldPrefix}.description`, + fieldDisplay: `${displayPrefix} → description field`, message: 'Field "description" must be a string', - value: commitType.description + userMessage: "Commit type description must be text", + value: commitType.description, + examples: [ + '"A new feature"', + '"Bug fix for users"', + '"Documentation changes"', + ], + issue: "Found non-string value", }); - } else if (commitType.description.trim() === '') { + } else if (commitType.description.trim() === "") { errors.push({ field: `${fieldPrefix}.description`, + fieldDisplay: `${displayPrefix} → description field`, message: 'Field "description" cannot be empty', - value: commitType.description + userMessage: "Commit type description cannot be empty", + value: commitType.description, + examples: [ + '"A new feature"', + '"Bug fix for users"', + '"Documentation changes"', + ], + issue: "Empty string", }); } - + // Validate optional 'emoji' field if (commitType.emoji !== undefined) { - if (typeof commitType.emoji !== 'string') { + if (typeof commitType.emoji !== "string") { errors.push({ field: `${fieldPrefix}.emoji`, + fieldDisplay: `${displayPrefix} → emoji field`, message: 'Field "emoji" must be a string', - value: commitType.emoji + userMessage: "Emoji field must be text if provided", + value: commitType.emoji, + issue: "Found non-string value", }); } // Note: Emoji format validation will be added in Phase 3 } - + return errors; } @@ -185,52 +289,89 @@ export class ConfigValidator { */ private validateOptionalSections(config: RawConfig): ValidationError[] { const errors: ValidationError[] = []; - + // Validate version field if present - if (config.version !== undefined && typeof config.version !== 'string') { + if (config.version !== undefined && typeof config.version !== "string") { errors.push({ - field: 'version', + field: "version", + fieldDisplay: "Schema version", message: 'Field "version" must be a string', - value: config.version + userMessage: "The version field must be text", + value: config.version, + expectedFormat: 'version string (e.g., "1.0")', + issue: "Found non-string value", }); } - + // Validate config section if present - if (config.config !== undefined && (typeof config.config !== 'object' || Array.isArray(config.config))) { + if ( + config.config !== undefined && + (typeof config.config !== "object" || Array.isArray(config.config)) + ) { errors.push({ - field: 'config', + field: "config", + fieldDisplay: "Config section", message: 'Field "config" must be an object', - value: config.config + userMessage: + "The config section must be an object with key-value pairs", + value: config.config, + expectedFormat: "object with configuration settings", + issue: "Found non-object value", }); } - + // Validate format section if present - if (config.format !== undefined && (typeof config.format !== 'object' || Array.isArray(config.format))) { + if ( + config.format !== undefined && + (typeof config.format !== "object" || Array.isArray(config.format)) + ) { errors.push({ - field: 'format', + field: "format", + fieldDisplay: "Format section", message: 'Field "format" must be an object', - value: config.format + userMessage: + "The format section must be an object with formatting rules", + value: config.format, + expectedFormat: "object with format settings", + issue: "Found non-object value", }); } - + // Validate validation section if present - if (config.validation !== undefined && (typeof config.validation !== 'object' || Array.isArray(config.validation))) { + if ( + config.validation !== undefined && + (typeof config.validation !== "object" || + Array.isArray(config.validation)) + ) { errors.push({ - field: 'validation', + field: "validation", + fieldDisplay: "Validation section", message: 'Field "validation" must be an object', - value: config.validation + userMessage: + "The validation section must be an object with validation rules", + value: config.validation, + expectedFormat: "object with validation settings", + issue: "Found non-object value", }); } - + // Validate advanced section if present - if (config.advanced !== undefined && (typeof config.advanced !== 'object' || Array.isArray(config.advanced))) { + if ( + config.advanced !== undefined && + (typeof config.advanced !== "object" || Array.isArray(config.advanced)) + ) { errors.push({ - field: 'advanced', + field: "advanced", + fieldDisplay: "Advanced section", message: 'Field "advanced" must be an object', - value: config.advanced + userMessage: + "The advanced section must be an object with advanced settings", + value: config.advanced, + expectedFormat: "object with advanced configuration", + issue: "Found non-object value", }); } - + return errors; } @@ -240,9 +381,11 @@ export class ConfigValidator { * @returns Whether input is a valid object structure */ private isValidConfigStructure(config: unknown): config is RawConfig { - return config !== null && - config !== undefined && - typeof config === 'object' && - !Array.isArray(config); + return ( + config !== null && + config !== undefined && + typeof config === "object" && + !Array.isArray(config) + ); } } diff --git a/src/lib/presets/angular.ts b/src/lib/presets/angular.ts new file mode 100644 index 0000000..32c7fd0 --- /dev/null +++ b/src/lib/presets/angular.ts @@ -0,0 +1,74 @@ +/** + * Angular Preset + * + * Strict commit message convention used by the Angular project + * and many enterprise teams. Enforces consistent commit formatting + * with comprehensive type coverage. + * + * Format: type(scope): subject + * Example: feat(compiler): add support for standalone components + */ + +import type { Preset } from "./index.js"; + +export const angularPreset: Preset = { + id: "angular", + name: "Angular Convention", + description: "Strict format used by Angular and enterprise teams", + defaults: { + emoji_enabled: false, + scope_mode: "optional", + }, + types: [ + { + id: "feat", + description: "A new feature", + emoji: "✨", + }, + { + id: "fix", + description: "A bug fix", + emoji: "🐛", + }, + { + id: "docs", + description: "Documentation only changes", + emoji: "📚", + }, + { + id: "style", + description: "Changes that do not affect code meaning", + emoji: "💄", + }, + { + id: "refactor", + description: "Code change that neither fixes a bug nor adds a feature", + emoji: "♻️", + }, + { + id: "perf", + description: "Code change that improves performance", + emoji: "⚡", + }, + { + id: "test", + description: "Adding missing tests or correcting existing tests", + emoji: "🧪", + }, + { + id: "build", + description: "Changes that affect the build system or dependencies", + emoji: "🏗️", + }, + { + id: "ci", + description: "Changes to CI configuration files and scripts", + emoji: "💚", + }, + { + id: "chore", + description: "Other changes that don't modify src or test files", + emoji: "🔧", + }, + ], +}; diff --git a/src/lib/presets/conventional.ts b/src/lib/presets/conventional.ts new file mode 100644 index 0000000..7360c85 --- /dev/null +++ b/src/lib/presets/conventional.ts @@ -0,0 +1,59 @@ +/** + * Conventional Commits Preset + * + * Industry-standard commit message convention used widely in + * open-source projects. Provides clear semantic commit types + * with optional scopes for better organization. + * + * Format: type(scope): subject + * Example: feat(api): add user authentication endpoint + */ + +import type { Preset } from "./index.js"; + +export const conventionalPreset: Preset = { + id: "conventional", + name: "Conventional Commits", + description: "Industry-standard format used by most open-source projects", + defaults: { + emoji_enabled: false, + scope_mode: "optional", + }, + types: [ + { + id: "feat", + description: "A new feature for the user", + emoji: "✨", + }, + { + id: "fix", + description: "A bug fix for the user", + emoji: "🐛", + }, + { + id: "docs", + description: "Documentation changes", + emoji: "📚", + }, + { + id: "style", + description: "Code style changes (formatting, semicolons, etc.)", + emoji: "💄", + }, + { + id: "refactor", + description: "Code refactoring without changing functionality", + emoji: "♻️", + }, + { + id: "test", + description: "Adding or updating tests", + emoji: "🧪", + }, + { + id: "chore", + description: "Maintenance tasks, build changes, etc.", + emoji: "🔧", + }, + ], +}; diff --git a/src/lib/presets/gitmoji.ts b/src/lib/presets/gitmoji.ts new file mode 100644 index 0000000..a897b29 --- /dev/null +++ b/src/lib/presets/gitmoji.ts @@ -0,0 +1,74 @@ +/** + * Gitmoji Preset + * + * Visual commit format using emojis to represent commit types. + * Popular in creative and frontend development communities for + * improved scannability in commit logs. + * + * Format: emoji type(scope): subject + * Example: ✨ feat(ui): add dark mode toggle + */ + +import type { Preset } from "./index.js"; + +export const gitmojiPreset: Preset = { + id: "gitmoji", + name: "Gitmoji Style", + description: "Visual commits with emojis for better scannability", + defaults: { + emoji_enabled: true, + scope_mode: "optional", + }, + types: [ + { + id: "feat", + description: "Introduce new features", + emoji: "✨", + }, + { + id: "fix", + description: "Fix a bug", + emoji: "🐛", + }, + { + id: "docs", + description: "Add or update documentation", + emoji: "📚", + }, + { + id: "style", + description: "Improve structure or format of code", + emoji: "🎨", + }, + { + id: "refactor", + description: "Refactor code", + emoji: "♻️", + }, + { + id: "perf", + description: "Improve performance", + emoji: "⚡", + }, + { + id: "test", + description: "Add or update tests", + emoji: "✅", + }, + { + id: "build", + description: "Add or update build scripts", + emoji: "👷", + }, + { + id: "ci", + description: "Add or update CI configuration", + emoji: "💚", + }, + { + id: "chore", + description: "Miscellaneous chores", + emoji: "🔧", + }, + ], +}; diff --git a/src/lib/presets/index.ts b/src/lib/presets/index.ts new file mode 100644 index 0000000..e0ef966 --- /dev/null +++ b/src/lib/presets/index.ts @@ -0,0 +1,129 @@ +/** + * Preset System + * + * Provides pre-configured commit convention templates that can be + * used to quickly initialize project configuration. Each preset + * includes commit types, format settings, and sensible defaults. + * + * Presets available: + * - Conventional: Industry-standard semantic commit format + * - Gitmoji: Visual emoji-based commit types + * - Angular: Strict format used in Angular projects + * - Minimal: Basic setup for custom configuration + */ + +import type { LabcommitrConfig } from "../config/types.js"; + +/** + * Preset definition interface + * Defines structure and defaults for a commit convention + */ +export interface Preset { + id: string; + name: string; + description: string; + defaults: { + emoji_enabled: boolean; + scope_mode: "optional" | "selective" | "always" | "never"; + }; + types: Array<{ + id: string; + description: string; + emoji: string; + }>; +} + +// Import individual preset definitions +import { conventionalPreset } from "./conventional.js"; +import { gitmojiPreset } from "./gitmoji.js"; +import { angularPreset } from "./angular.js"; +import { minimalPreset } from "./minimal.js"; + +/** + * Preset registry + * Maps preset IDs to their configurations + */ +export const PRESETS: Record = { + conventional: conventionalPreset, + gitmoji: gitmojiPreset, + angular: angularPreset, + minimal: minimalPreset, +}; + +/** + * Get preset configuration by ID + * Throws error if preset not found + */ +export function getPreset(id: string): Preset { + const preset = PRESETS[id]; + if (!preset) { + throw new Error(`Unknown preset: ${id}`); + } + return preset; +} + +/** + * Build complete configuration from preset and user choices + * Merges preset defaults with user customizations + */ +export function buildConfig( + presetId: string, + customizations: { + emoji?: boolean; + // Scope prompt removed in init; default to optional unless provided + scope?: "optional" | "selective" | "always" | "never"; + scopeRequiredFor?: string[]; + // Git integration + autoStage?: boolean; + // Body requirement + bodyRequired?: boolean; + }, +): LabcommitrConfig { + const preset = getPreset(presetId); + + // Determine which types require scopes + let requireScopeFor: string[] = []; + const scopeMode = customizations.scope ?? preset.defaults.scope_mode; + + if (scopeMode === "always") { + requireScopeFor = preset.types.map((t) => t.id); + } else if (scopeMode === "selective" && customizations.scopeRequiredFor) { + requireScopeFor = customizations.scopeRequiredFor; + } + + return { + version: "1.0", + config: { + emoji_enabled: customizations.emoji ?? preset.defaults.emoji_enabled, + force_emoji_detection: null, + }, + format: { + // Template is determined by style; emoji is handled at render time + template: "{type}({scope}): {subject}", + subject_max_length: 50, + // Body configuration (respects user choice, defaults to optional) + body: { + required: customizations.bodyRequired ?? false, + min_length: 0, + max_length: null, + editor_preference: "auto", + }, + }, + types: preset.types, + validation: { + require_scope_for: requireScopeFor, + allowed_scopes: [], + subject_min_length: 3, + prohibited_words: [], + prohibited_words_body: [], + }, + advanced: { + aliases: {}, + git: { + auto_stage: customizations.autoStage ?? false, + // Security best-practice: enable signed commits by default + sign_commits: true, + }, + }, + }; +} diff --git a/src/lib/presets/minimal.ts b/src/lib/presets/minimal.ts new file mode 100644 index 0000000..cd9b088 --- /dev/null +++ b/src/lib/presets/minimal.ts @@ -0,0 +1,44 @@ +/** + * Minimal Preset + * + * Basic starting configuration with essential commit types. + * Designed for teams who want to build custom conventions + * from a simple foundation. + * + * Format: type(scope): subject + * Example: feat: implement new feature + */ + +import type { Preset } from "./index.js"; + +export const minimalPreset: Preset = { + id: "minimal", + name: "Minimal Setup", + description: "Start with basics, customize everything yourself later", + defaults: { + emoji_enabled: false, + scope_mode: "optional", + }, + types: [ + { + id: "feat", + description: "New feature", + emoji: "✨", + }, + { + id: "fix", + description: "Bug fix", + emoji: "🐛", + }, + { + id: "docs", + description: "Documentation", + emoji: "📚", + }, + { + id: "chore", + description: "Maintenance", + emoji: "🔧", + }, + ], +}; diff --git a/tsconfig.json b/tsconfig.json index dd9c1c3..51c7342 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -107,5 +107,7 @@ /* Completeness */ // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ "skipLibCheck": true /* Skip type checking all .d.ts files. */ - } + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "scripts", ".test-temp"] }