From e6a5f703cc545a0d77457c4ca8e4f6ee36620997 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 22 Jun 2026 03:52:39 +0000 Subject: [PATCH 01/27] chore(deps-dev): bump lint-staged from 17.0.7 to 17.0.8 Bumps [lint-staged](https://github.com/lint-staged/lint-staged) from 17.0.7 to 17.0.8. - [Release notes](https://github.com/lint-staged/lint-staged/releases) - [Changelog](https://github.com/lint-staged/lint-staged/blob/main/CHANGELOG.md) - [Commits](https://github.com/lint-staged/lint-staged/compare/v17.0.7...v17.0.8) --- updated-dependencies: - dependency-name: lint-staged dependency-version: 17.0.8 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index e7b166d..77f5d71 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7175,9 +7175,9 @@ "license": "MIT" }, "node_modules/lint-staged": { - "version": "17.0.7", - "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-17.0.7.tgz", - "integrity": "sha512-JrSobt+tW3rH8IOMi8tDZd3foorM5yPEkLD/V2NxobgHrFfHWGee4MOLVuZeScgxftEwbHrPHIFA/ZL+nUJeuA==", + "version": "17.0.8", + "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-17.0.8.tgz", + "integrity": "sha512-B2P/d+jVW0UXOQ0MVMLrB/9ydA1P+zz6jYfdrbbEd9ur3S2rcbduFWKiUCC02Sm5hbC8nrm7y24WuYMG54HfxA==", "dev": true, "license": "MIT", "dependencies": { From 20b963711332b35fe799dffbb73507cda8a3b7ee Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 24 Jun 2026 03:52:34 +0000 Subject: [PATCH 02/27] chore(deps-dev): bump @commitlint/cli from 21.0.2 to 21.1.0 Bumps [@commitlint/cli](https://github.com/conventional-changelog/commitlint/tree/HEAD/@commitlint/cli) from 21.0.2 to 21.1.0. - [Release notes](https://github.com/conventional-changelog/commitlint/releases) - [Changelog](https://github.com/conventional-changelog/commitlint/blob/master/@commitlint/cli/CHANGELOG.md) - [Commits](https://github.com/conventional-changelog/commitlint/commits/v21.1.0/@commitlint/cli) --- updated-dependencies: - dependency-name: "@commitlint/cli" dependency-version: 21.1.0 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- package-lock.json | 131 +++++++++++++++++++++++----------------------- 1 file changed, 66 insertions(+), 65 deletions(-) diff --git a/package-lock.json b/package-lock.json index 77f5d71..fcfaa58 100644 --- a/package-lock.json +++ b/package-lock.json @@ -634,17 +634,18 @@ } }, "node_modules/@commitlint/cli": { - "version": "21.0.2", - "resolved": "https://registry.npmjs.org/@commitlint/cli/-/cli-21.0.2.tgz", - "integrity": "sha512-YMmfLbqBg+ZRvvmPhc+cilSQFrh/AgzVgCT1U/OifmUZEwPbvCtA8rN//YNaF9d5eoZphxVMGYtmwA2QgQORgg==", + "version": "21.1.0", + "resolved": "https://registry.npmjs.org/@commitlint/cli/-/cli-21.1.0.tgz", + "integrity": "sha512-CVwY6TxGv5naEaWxBdgNHko1xgL95Mb4WcIqp9iik33H0ctVqRv6YtekCntayhEP0T/apuiGvHu5HcCwFuVxEA==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/format": "^21.0.1", - "@commitlint/lint": "^21.0.2", - "@commitlint/load": "^21.0.2", - "@commitlint/read": "^21.0.2", - "@commitlint/types": "^21.0.1", + "@commitlint/config-conventional": "^21.1.0", + "@commitlint/format": "^21.1.0", + "@commitlint/lint": "^21.1.0", + "@commitlint/load": "^21.1.0", + "@commitlint/read": "^21.1.0", + "@commitlint/types": "^21.1.0", "tinyexec": "^1.0.0", "yargs": "^18.0.0" }, @@ -656,13 +657,13 @@ } }, "node_modules/@commitlint/config-conventional": { - "version": "21.0.2", - "resolved": "https://registry.npmjs.org/@commitlint/config-conventional/-/config-conventional-21.0.2.tgz", - "integrity": "sha512-P/ZRhryQmkj0Z0dY9FOoRwe3xkwJyyAdtXwt01NT2kuZttcG2CNYp1q5Ci3u+nDT2jcbJRw2kt13Czl1qKNPfg==", + "version": "21.1.0", + "resolved": "https://registry.npmjs.org/@commitlint/config-conventional/-/config-conventional-21.1.0.tgz", + "integrity": "sha512-BIFl8xM+3SLy3jrblUC3wmQLCVbLty+++6o859BDCmybVrQdXmIWO+dlkGIbv/M2bBoC55wGuh0zGiw3TPjL1g==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/types": "^21.0.1", + "@commitlint/types": "^21.1.0", "conventional-changelog-conventionalcommits": "^9.2.0" }, "engines": { @@ -670,13 +671,13 @@ } }, "node_modules/@commitlint/config-validator": { - "version": "21.0.1", - "resolved": "https://registry.npmjs.org/@commitlint/config-validator/-/config-validator-21.0.1.tgz", - "integrity": "sha512-Zd2UFdndeMMaW2O96HK0tdfT4gOImUvidMpAd/pws2zZ4m1nrAZ/9b/v2JYuE8fs86GpXv9F7LNaIuCIWhY+pA==", + "version": "21.1.0", + "resolved": "https://registry.npmjs.org/@commitlint/config-validator/-/config-validator-21.1.0.tgz", + "integrity": "sha512-gHczt1xqQSwfNqBmOI3HjejtTljkiBEUneExMmTBLD0WwTC78lAqDvNMyydbySt3DhpH0F9oX7Vvuks6s5XPFw==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/types": "^21.0.1", + "@commitlint/types": "^21.1.0", "ajv": "^8.11.0" }, "engines": { @@ -684,13 +685,13 @@ } }, "node_modules/@commitlint/ensure": { - "version": "21.0.1", - "resolved": "https://registry.npmjs.org/@commitlint/ensure/-/ensure-21.0.1.tgz", - "integrity": "sha512-jJ1037967wU7YN/xkv+iRlOBlmaOXPhPO5KQSqya6GyXzBlwuLzELBFao16DVg9dZyqmNrhewzwZ3SAibetHBQ==", + "version": "21.1.0", + "resolved": "https://registry.npmjs.org/@commitlint/ensure/-/ensure-21.1.0.tgz", + "integrity": "sha512-/S8Mo3Q1NtQUYDQjDmyQVPxfIwtnxq+guzMOkuGk8OSdwlzanm1WB9wDPIuuzlbMDDnBNbiAuBEUCcCNlfjrTQ==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/types": "^21.0.1", + "@commitlint/types": "^21.1.0", "es-toolkit": "^1.46.0" }, "engines": { @@ -708,13 +709,13 @@ } }, "node_modules/@commitlint/format": { - "version": "21.0.1", - "resolved": "https://registry.npmjs.org/@commitlint/format/-/format-21.0.1.tgz", - "integrity": "sha512-ksmG2+cHGtuDPQQbhBbC4unwm444+6TiPw0d1bKf67hntgZqZ8E0g1MuYKUuyT5IH4IMmXZhKq22/Z3jBvtQIw==", + "version": "21.1.0", + "resolved": "https://registry.npmjs.org/@commitlint/format/-/format-21.1.0.tgz", + "integrity": "sha512-ySymqKYBfjNrQ5N4W/l1iF2ISW1W7Eu/Oi/wRxlri31N0yjNyzUyUzQwyuZLDzTXIlMs4IZ7hIOfAZx8lO18gA==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/types": "^21.0.1", + "@commitlint/types": "^21.1.0", "picocolors": "^1.1.1" }, "engines": { @@ -722,13 +723,13 @@ } }, "node_modules/@commitlint/is-ignored": { - "version": "21.0.2", - "resolved": "https://registry.npmjs.org/@commitlint/is-ignored/-/is-ignored-21.0.2.tgz", - "integrity": "sha512-H5z4t8PC9tUsmZ/o+EptM3Nq8sTFtskAShdcqxCoyzklW5eaVT5xbrDAET2uypzir9Vsj4ZZmBtyKjYe2XqgeQ==", + "version": "21.1.0", + "resolved": "https://registry.npmjs.org/@commitlint/is-ignored/-/is-ignored-21.1.0.tgz", + "integrity": "sha512-RoRh1/YI+fYH+aid5lMQ2UD0vZ3p3Vf1KeUWT1ir3H/p/7T/6SFv1OiXLgLwUT8dP72EVWeEIyOfkiSWLZYVvw==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/types": "^21.0.1", + "@commitlint/types": "^21.1.0", "semver": "^7.6.0" }, "engines": { @@ -736,32 +737,32 @@ } }, "node_modules/@commitlint/lint": { - "version": "21.0.2", - "resolved": "https://registry.npmjs.org/@commitlint/lint/-/lint-21.0.2.tgz", - "integrity": "sha512-PnUmLYGeGLfW8oVatR9KpNxSHYAnJOEWlMZzfdeFOUq6WUrFx1fGQaWCWJqMoIll/xPM+GdfJV+tKHZVHhl0Fg==", + "version": "21.1.0", + "resolved": "https://registry.npmjs.org/@commitlint/lint/-/lint-21.1.0.tgz", + "integrity": "sha512-0DbfVVUjAWBfixW6v7CXXWVxMcj6Ukf/oB7O8NAbouP3jxmqUaC4eVQphxl3B3M0ii3cCQiR3sRAYxICwU2gAA==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/is-ignored": "^21.0.2", - "@commitlint/parse": "^21.0.2", - "@commitlint/rules": "^21.0.2", - "@commitlint/types": "^21.0.1" + "@commitlint/is-ignored": "^21.1.0", + "@commitlint/parse": "^21.1.0", + "@commitlint/rules": "^21.1.0", + "@commitlint/types": "^21.1.0" }, "engines": { "node": ">=22.12.0" } }, "node_modules/@commitlint/load": { - "version": "21.0.2", - "resolved": "https://registry.npmjs.org/@commitlint/load/-/load-21.0.2.tgz", - "integrity": "sha512-lwUE70hN0/qE/ZRROhbaX65ly/FF12DrqfReLCESo37M0OQCFAf2jRS+2tSCSORq+bm4Kdju7qNDj46uc1QzTA==", + "version": "21.1.0", + "resolved": "https://registry.npmjs.org/@commitlint/load/-/load-21.1.0.tgz", + "integrity": "sha512-juiClVEcoreNB0TNVkseO2EmNcpEs/Yhnmgbnm/hQAKBFRynKwIaoNIljXkx/3yvZcMO0EE8I2XOEI7d5KZG8Q==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/config-validator": "^21.0.1", + "@commitlint/config-validator": "^21.1.0", "@commitlint/execute-rule": "^21.0.1", - "@commitlint/resolve-extends": "^21.0.1", - "@commitlint/types": "^21.0.1", + "@commitlint/resolve-extends": "^21.1.0", + "@commitlint/types": "^21.1.0", "cosmiconfig": "^9.0.1", "cosmiconfig-typescript-loader": "^6.1.0", "es-toolkit": "^1.46.0", @@ -783,13 +784,13 @@ } }, "node_modules/@commitlint/parse": { - "version": "21.0.2", - "resolved": "https://registry.npmjs.org/@commitlint/parse/-/parse-21.0.2.tgz", - "integrity": "sha512-QVZJhGHTm+oiuWyEKOCTQ0ZM3mfJ0eGWFeHuj7WzSKEth+UukcCHac9GD8pgdFlg/qGkFWOtyaNd1T8REgagaw==", + "version": "21.1.0", + "resolved": "https://registry.npmjs.org/@commitlint/parse/-/parse-21.1.0.tgz", + "integrity": "sha512-HdAqbbjQS8eEtbR74Ysg2VNmbvAfeWLVYMkip/lHibNrtjRsC/97XAYN3/H5P0pEJtDfyTb3iLs8x6y0eu4OYA==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/types": "^21.0.1", + "@commitlint/types": "^21.1.0", "conventional-changelog-angular": "^8.2.0", "conventional-commits-parser": "^6.3.0" }, @@ -798,14 +799,14 @@ } }, "node_modules/@commitlint/read": { - "version": "21.0.2", - "resolved": "https://registry.npmjs.org/@commitlint/read/-/read-21.0.2.tgz", - "integrity": "sha512-BtsrnLVycSSKf4Q0gMch4giCj5NNlmcbhc8ra5vONgGtP2IjRDo33bEFtr5Pm+2N+5fXGWb2MksWPrspPfdhdw==", + "version": "21.1.0", + "resolved": "https://registry.npmjs.org/@commitlint/read/-/read-21.1.0.tgz", + "integrity": "sha512-ID7m79aw8d0dMlxuXHD2QGxEX3Fhl/mUPA80WwEW5VgeOpUHNahhwWJefDdoBDVZcDfbHuf429NrcK0gxQsQjA==", "dev": true, "license": "MIT", "dependencies": { "@commitlint/top-level": "^21.0.2", - "@commitlint/types": "^21.0.1", + "@commitlint/types": "^21.1.0", "git-raw-commits": "^5.0.0", "tinyexec": "^1.0.0" }, @@ -814,14 +815,14 @@ } }, "node_modules/@commitlint/resolve-extends": { - "version": "21.0.1", - "resolved": "https://registry.npmjs.org/@commitlint/resolve-extends/-/resolve-extends-21.0.1.tgz", - "integrity": "sha512-0DhjYWL6uYrY16Efa032fYk3woGJDU4AGWiG1XXltT9AMUNYKyb5cIZU2ivbaMZ3+kKFqUjikD2cjh66Sbh/Sg==", + "version": "21.1.0", + "resolved": "https://registry.npmjs.org/@commitlint/resolve-extends/-/resolve-extends-21.1.0.tgz", + "integrity": "sha512-SANYkxJDfMl3TvnyALWHEaiF5nc6FFaOnh7VvfxjT4X2vD4i2gVHhmfMm1fsrBwDRX98/XyM1XDo5sAd/KXcyQ==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/config-validator": "^21.0.1", - "@commitlint/types": "^21.0.1", + "@commitlint/config-validator": "^21.1.0", + "@commitlint/types": "^21.1.0", "es-toolkit": "^1.46.0", "global-directory": "^5.0.0", "resolve-from": "^5.0.0" @@ -831,16 +832,16 @@ } }, "node_modules/@commitlint/rules": { - "version": "21.0.2", - "resolved": "https://registry.npmjs.org/@commitlint/rules/-/rules-21.0.2.tgz", - "integrity": "sha512-k6tQ69Td7t2qUSIbik8D3TL1q3ZJpkEbV+yLogDzCRAdOxJm4ndhtBNREsLA1/puRfWvzS9eioF2w43WT+hHgQ==", + "version": "21.1.0", + "resolved": "https://registry.npmjs.org/@commitlint/rules/-/rules-21.1.0.tgz", + "integrity": "sha512-fOPEYSmKn1ZJptjLmCEjJfYqz0PUYr8ng6VY2ZW26sB7KtENR90CmAXHEmScBbOIZip+d/+OwqK12DFBuHTqsQ==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/ensure": "^21.0.1", + "@commitlint/ensure": "^21.1.0", "@commitlint/message": "^21.0.2", "@commitlint/to-lines": "^21.0.1", - "@commitlint/types": "^21.0.1" + "@commitlint/types": "^21.1.0" }, "engines": { "node": ">=22.12.0" @@ -870,9 +871,9 @@ } }, "node_modules/@commitlint/types": { - "version": "21.0.1", - "resolved": "https://registry.npmjs.org/@commitlint/types/-/types-21.0.1.tgz", - "integrity": "sha512-4u7w8jcoCUFWhjWnASYzZHAP34OqOtuFBN87nQmFvqda03YU0T6z+yB4w0gSAMpekiRqqGk5rt+qSlW+a2vSEg==", + "version": "21.1.0", + "resolved": "https://registry.npmjs.org/@commitlint/types/-/types-21.1.0.tgz", + "integrity": "sha512-YodnnnH1Cp+08nP8HGNJAIuB6L3/vdCTHVRTfF8Ik/wRCLOTsU9zwv3yO1cSPQRDa9CLYtE+UJ2K67r7CwMSFw==", "dev": true, "license": "MIT", "dependencies": { @@ -4868,9 +4869,9 @@ } }, "node_modules/es-toolkit": { - "version": "1.47.0", - "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.47.0.tgz", - "integrity": "sha512-n1GuoD0WEQZMBk5tttoZSqwgyLx01oqa5XsBmCHwPyNe1S9jPBEmtR2pSgp2kJuWE3ciFZ6yRHmY4pM4C3OOkw==", + "version": "1.48.1", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.48.1.tgz", + "integrity": "sha512-wfnXlwd5I75eXRtdD2vuEs50xHHESECDsGD7yiQnfFVNoa5522NwXEbmgo98LfiukSQHs+mBM7/YG3qKJB9/mQ==", "dev": true, "license": "MIT", "workspaces": [ From bd8c9477675ba60c08c7d195c842d27a9c0be3e3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 24 Jun 2026 03:52:45 +0000 Subject: [PATCH 03/27] chore(deps-dev): bump @commitlint/config-conventional Bumps [@commitlint/config-conventional](https://github.com/conventional-changelog/commitlint/tree/HEAD/@commitlint/config-conventional) from 21.0.2 to 21.1.0. - [Release notes](https://github.com/conventional-changelog/commitlint/releases) - [Changelog](https://github.com/conventional-changelog/commitlint/blob/master/@commitlint/config-conventional/CHANGELOG.md) - [Commits](https://github.com/conventional-changelog/commitlint/commits/v21.1.0/@commitlint/config-conventional) --- updated-dependencies: - dependency-name: "@commitlint/config-conventional" dependency-version: 21.1.0 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- package-lock.json | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/package-lock.json b/package-lock.json index 77f5d71..deb5cb3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -656,13 +656,13 @@ } }, "node_modules/@commitlint/config-conventional": { - "version": "21.0.2", - "resolved": "https://registry.npmjs.org/@commitlint/config-conventional/-/config-conventional-21.0.2.tgz", - "integrity": "sha512-P/ZRhryQmkj0Z0dY9FOoRwe3xkwJyyAdtXwt01NT2kuZttcG2CNYp1q5Ci3u+nDT2jcbJRw2kt13Czl1qKNPfg==", + "version": "21.1.0", + "resolved": "https://registry.npmjs.org/@commitlint/config-conventional/-/config-conventional-21.1.0.tgz", + "integrity": "sha512-BIFl8xM+3SLy3jrblUC3wmQLCVbLty+++6o859BDCmybVrQdXmIWO+dlkGIbv/M2bBoC55wGuh0zGiw3TPjL1g==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/types": "^21.0.1", + "@commitlint/types": "^21.1.0", "conventional-changelog-conventionalcommits": "^9.2.0" }, "engines": { @@ -870,9 +870,9 @@ } }, "node_modules/@commitlint/types": { - "version": "21.0.1", - "resolved": "https://registry.npmjs.org/@commitlint/types/-/types-21.0.1.tgz", - "integrity": "sha512-4u7w8jcoCUFWhjWnASYzZHAP34OqOtuFBN87nQmFvqda03YU0T6z+yB4w0gSAMpekiRqqGk5rt+qSlW+a2vSEg==", + "version": "21.1.0", + "resolved": "https://registry.npmjs.org/@commitlint/types/-/types-21.1.0.tgz", + "integrity": "sha512-YodnnnH1Cp+08nP8HGNJAIuB6L3/vdCTHVRTfF8Ik/wRCLOTsU9zwv3yO1cSPQRDa9CLYtE+UJ2K67r7CwMSFw==", "dev": true, "license": "MIT", "dependencies": { From d8a5744b78a78c2b0aea6170bb4243c513887df1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 25 Jun 2026 03:52:48 +0000 Subject: [PATCH 04/27] chore(deps-dev): bump @types/node from 26.0.0 to 26.0.1 Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 26.0.0 to 26.0.1. - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node) --- updated-dependencies: - dependency-name: "@types/node" dependency-version: 26.0.1 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 77f5d71..c93944a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2841,9 +2841,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "26.0.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.0.0.tgz", - "integrity": "sha512-vf2YFi1iY9lHGwNJMs01biZFbKJkrZR1T6/MlzjhJLPdntOHLhTrDSnSVcdtvjihi4VQNlrFRIxLsDBlQpAipA==", + "version": "26.0.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.0.1.tgz", + "integrity": "sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw==", "devOptional": true, "license": "MIT", "dependencies": { From b5316fd1f3b81b93189b6e7202fcf2b283175f0c Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Sun, 28 Jun 2026 13:52:12 -0500 Subject: [PATCH 05/27] feat(platform): add WordPress support --- README.md | 8 + docs/README.md | 26 +- docs/cli-reference.md | 32 +- docs/configuration.md | 48 ++- docs/emulsify-info-cli-updates.md | 8 +- docs/project-initialization.md | 48 ++- docs/systems.md | 46 ++- src/handlers/componentCreate.test.ts | 8 + src/handlers/componentInstall.test.ts | 8 + src/handlers/componentList.test.ts | 8 + src/handlers/hofs/withEmulsifySystem.test.ts | 53 +++ src/handlers/hofs/withEmulsifySystem.ts | 19 +- src/handlers/init.test.ts | 70 +++- src/handlers/init.ts | 14 +- src/handlers/systemInstall.test.ts | 367 +++++++++++++++++- src/handlers/systemInstall.ts | 112 +++++- src/index.ts | 6 +- src/schemas/emulsifyProjectConfig.json | 4 +- src/schemas/system.json | 2 +- src/schemas/variant.json | 31 +- src/types/_emulsifyProjectConfig.d.ts | 21 +- src/types/_system.d.ts | 19 +- src/types/_variant.d.ts | 25 +- src/types/handlers.d.ts | 6 +- src/util/getAvailableStarters.test.ts | 6 + src/util/getAvailableStarters.ts | 6 + src/util/platform/getPlatformInfo.test.ts | 46 ++- src/util/platform/getPlatformInfo.ts | 15 +- src/util/platform/getWordPressInfo.test.ts | 72 ++++ src/util/platform/getWordPressInfo.ts | 61 +++ .../platform/platformCompatibility.test.ts | 213 ++++++++++ src/util/platform/platformCompatibility.ts | 154 ++++++++ src/util/project/getEmulsifyConfig.test.ts | 50 ++- src/util/system/getAvailableSystems.test.ts | 4 +- src/util/system/getAvailableSystems.ts | 4 +- 35 files changed, 1496 insertions(+), 124 deletions(-) create mode 100644 src/util/platform/getWordPressInfo.test.ts create mode 100644 src/util/platform/getWordPressInfo.ts create mode 100644 src/util/platform/platformCompatibility.test.ts create mode 100644 src/util/platform/platformCompatibility.ts diff --git a/README.md b/README.md index cdd37ea..84de964 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,14 @@ emulsify component install card emulsify component create promo-card --directory molecules --format default ``` +Built-in platforms are `drupal`, `wordpress`, and `none`. For WordPress child themes, use the WordPress platform and starter: + +```bash +emulsify init "My Theme" ./wp-content/themes --platform wordpress +``` + +When WordPress is auto-detected, Emulsify initializes child themes into the detected themes directory, such as `wp-content/themes/my-theme` or `web/app/themes/my-theme` for Bedrock. + For non-interactive environments, pass the flags that normally prompt for input: ```bash diff --git a/docs/README.md b/docs/README.md index 5dffb77..a4e2867 100644 --- a/docs/README.md +++ b/docs/README.md @@ -2,16 +2,16 @@ These docs expand on the short project README and are organized by the task a project user or maintainer is usually trying to complete. -| Topic | Use This When | -| ----------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | -| [CLI Reference](./cli-reference.md) | Looking up commands, aliases, options, and examples. | -| [Project Initialization](./project-initialization.md) | Creating a new Emulsify project from a starter, including Drupal and non-interactive examples. | -| [Systems](./systems.md) | Listing built-in systems, installing systems, using custom system repositories, or understanding system configuration. | -| [Components](./components.md) | Listing installable components, installing components and dependencies, using dry runs, and creating local components. | -| [Project Configuration](./configuration.md) | Understanding `project.emulsify.json`, system and variant references, structure mappings, and validation. | -| [Component Template Overrides](./component-template-overrides.md) | Replacing the built-in `component create` templates with project-level templates. | -| [Hooks And Cache](./hooks-and-cache.md) | Understanding starter hooks, system install hooks, script execution, and the `~/.emulsify/cache` repository cache. | -| [Development](./development.md) | Setting up this repository, understanding source layout, and running checks. | -| [Release](./release.md) | Understanding CI, develop version bumps, semantic-release, and npm publishing. | -| [Contributors](./contributors.md) | Viewing project contributors moved out of the root README. | -| [Website Usage Copy](./emulsify-info-cli-updates.md) | Copy-ready usage content for the `emulsify.info` CLI page. | +| Topic | Use This When | +| ----------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | +| [CLI Reference](./cli-reference.md) | Looking up commands, aliases, options, and examples. | +| [Project Initialization](./project-initialization.md) | Creating a new Emulsify project from a starter, including Drupal, WordPress, and non-interactive examples. | +| [Systems](./systems.md) | Listing built-in systems, installing systems, understanding variant compatibility, or using custom system repositories. | +| [Components](./components.md) | Listing installable components, installing components and dependencies, using dry runs, and creating local components. | +| [Project Configuration](./configuration.md) | Understanding `project.emulsify.json`, system and variant references, structure mappings, and validation. | +| [Component Template Overrides](./component-template-overrides.md) | Replacing the built-in `component create` templates with project-level templates. | +| [Hooks And Cache](./hooks-and-cache.md) | Understanding starter hooks, system install hooks, script execution, and the `~/.emulsify/cache` repository cache. | +| [Development](./development.md) | Setting up this repository, understanding source layout, and running checks. | +| [Release](./release.md) | Understanding CI, develop version bumps, semantic-release, and npm publishing. | +| [Contributors](./contributors.md) | Viewing project contributors moved out of the root README. | +| [Website Usage Copy](./emulsify-info-cli-updates.md) | Copy-ready usage content for the `emulsify.info` CLI page. | diff --git a/docs/cli-reference.md b/docs/cli-reference.md index b8002cc..87c0a67 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -29,21 +29,29 @@ emulsify init [name] [path] Options: -| Option | Description | -| ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- | -| `-m, --machineName ` | Machine-friendly project folder and config name. If omitted, the CLI derives it from the project name. Drupal machine names use underscores. | -| `-s, --starter ` | Starter Git repository to clone. | -| `-c, --checkout ` | Starter commit, branch, or tag to check out after clone. | -| `-p, --platform ` | Platform to use when auto-detection is unavailable or should be overridden. Built-in starters are currently available for `drupal` and `none`. | -| `-y, --yes` | Accept default values for missing init options without prompting. | +| Option | Description | +| ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------- | +| `-m, --machineName ` | Machine-friendly project folder and config name. If omitted, the CLI derives it from the project name. Drupal machine names use underscores. | +| `-s, --starter ` | Starter Git repository to clone. | +| `-c, --checkout ` | Starter commit, branch, or tag to check out after clone. | +| `-p, --platform ` | Platform to use when auto-detection is unavailable or should be overridden. Built-in platforms are `drupal`, `wordpress`, and `none`. | +| `-y, --yes` | Accept default values for missing init options without prompting. | -When `--platform` is not provided and the platform cannot be detected, interactive terminals prompt with `drupal` and `none`. +When `--platform` is not provided and the platform cannot be detected, interactive terminals prompt with `drupal`, `wordpress`, and `none`. + +WordPress auto-detection creates child themes inside the detected themes directory: + +- Standard WordPress: `wp-content/themes/` +- Bedrock: `web/app/themes/` + +The built-in WordPress starter is `https://github.com/emulsify-ds/emulsify-wordpress-starter`. Examples: ```bash emulsify init "My Project" ./projects --platform none emulsify init "My Theme" ./web/themes/custom --platform drupal +emulsify init "My Theme" ./wp-content/themes --platform wordpress emulsify init "My Theme" ./web/themes/custom --platform drupal --machineName my_custom_theme emulsify init "My Project" ./projects --starter https://github.com/emulsify-ds/emulsify-starter --checkout main --platform none ``` @@ -93,6 +101,14 @@ emulsify system install --repository https://github.com/example/example-system.g Selecting `create a new system` writes `system.emulsify.json` in the current Emulsify project root. Complete the generated system name, repository, structures, variants, and components before using it to install or generate components. +System variant compatibility is selected from each variant's `platform` expression. Examples: + +- `"platform": "wordpress"` matches WordPress projects. +- `"platform": "drupal || wordpress"` matches Drupal and WordPress projects. +- `"platform": "none"` is generic and can be installed by any concrete project platform. + +Project configuration uses only concrete `project.platform` values: `drupal`, `wordpress`, or `none`. Only system variants use `||` expressions. A project with `project.platform: "none"` can install any component library system; when multiple variants are equally compatible, the CLI prompts in an interactive terminal or errors in non-interactive mode. + ## `component list` ```bash diff --git a/docs/configuration.md b/docs/configuration.md index dbdf885..6322efc 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -19,12 +19,14 @@ Emulsify CLI stores project state in `project.emulsify.json`. Commands search fo } ``` -| Field | Required | Description | -| --------------------- | -------- | ------------------------------------------------ | -| `project.platform` | Yes | Platform name for starter and variant selection. | -| `project.name` | Yes | Human-facing project name provided during init. | -| `project.machineName` | Yes | Machine-friendly folder/config name. | -| `starter.repository` | Yes | Starter repository used to create the project. | +| Field | Required | Description | +| --------------------- | -------- | ------------------------------------------------------------------------------------------- | +| `project.platform` | Yes | Concrete platform name for starter and variant selection: `drupal`, `wordpress`, or `none`. | +| `project.name` | Yes | Human-facing project name provided during init. | +| `project.machineName` | Yes | Machine-friendly folder/config name. | +| `starter.repository` | Yes | Starter repository used to create the project. | + +`project.platform` never uses compatibility expressions. Values such as `drupal || wordpress` are valid only on system variant `platform` fields. ## System And Variant Configuration @@ -48,15 +50,25 @@ Emulsify CLI stores project state in `project.emulsify.json`. Commands search fo } ``` -| Field | Required After System Install | Description | -| ---------------------------------- | ----------------------------- | ---------------------------------------------------------------------------------------------------------------------- | -| `system.repository` | Yes | Git repository containing the selected system. Must be a URL ending in `.git` for commands that parse the system name. | -| `system.checkout` | Yes | Commit, branch, or tag used for the cached system. | -| `variant.platform` | Yes | Platform variant selected from the system. Built-in schemas currently allow `none` and `drupal`. | -| `variant.structureImplementations` | Yes | Project-relative directories for each named system structure. | +| Field | Required After System Install | Description | +| ---------------------------------- | ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | +| `system.repository` | Yes | Git repository containing the selected system. Must be a URL ending in `.git` for commands that parse the system name. | +| `system.checkout` | Yes | Commit, branch, or tag used for the cached system. | +| `variant.platform` | Yes | Selected system variant platform string. It may be concrete, such as `wordpress`, or an expression such as `drupal \|\| wordpress`. | +| `variant.structureImplementations` | Yes | Project-relative directories for each named system structure. | Component commands require both `system` and `variant` to be present. +Variant compatibility examples: + +```json +{ "platform": "wordpress" } +{ "platform": "drupal || wordpress" } +{ "platform": "none" } +``` + +`variant.platform: "none"` is generic and can be selected by any concrete project platform. A project with `project.platform: "none"` can install any compatible component library system; if multiple variants are equally compatible, the CLI prompts in an interactive terminal or errors in non-interactive mode. + ## Structure Implementations Structure implementations tell the CLI where installed or generated components belong. @@ -107,9 +119,9 @@ Common validation issues: Manual edits are safe when they preserve the schema and match the installed system. Be careful changing: -| Field | Risk | -| ---------------------------------- | -------------------------------------------------------------------------------------------------------------------- | -| `system.checkout` | The local cache path depends on checkout. Changing it may cause the CLI to clone or load a different system version. | -| `system.repository` | Component commands parse the system name from this URL and expect a `.git` suffix. | -| `variant.structureImplementations` | Install and create destinations are calculated from these directories. | -| `project.platform` | System install selects variants from this value. Existing system and variant config may no longer match. | +| Field | Risk | +| ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `system.checkout` | The local cache path depends on checkout. Changing it may cause the CLI to clone or load a different system version. | +| `system.repository` | Component commands parse the system name from this URL and expect a `.git` suffix. | +| `variant.structureImplementations` | Install and create destinations are calculated from these directories. | +| `project.platform` | System install selects variants from this concrete value. Existing system and variant config may no longer match. Do not change it to a `\|\|` expression. | diff --git a/docs/emulsify-info-cli-updates.md b/docs/emulsify-info-cli-updates.md index 7987e43..680530d 100644 --- a/docs/emulsify-info-cli-updates.md +++ b/docs/emulsify-info-cli-updates.md @@ -40,22 +40,26 @@ Options: - `--machineName `: Sets the machine-friendly project name. When omitted, Emulsify CLI derives it from the project name. - `--starter `: Uses a specific starter repository. - `--checkout `: Checks out a specific starter commit, branch, or tag. -- `--platform `: Sets the project platform when auto-detection is unavailable or should be overridden. Built-in starters are available for `none` and `drupal`. +- `--platform `: Sets the project platform when auto-detection is unavailable or should be overridden. Built-in platforms are `drupal`, `wordpress`, and `none`. - `--yes`: Accepts default init values for missing options without prompting. Built-in starter repositories: - `https://github.com/emulsify-ds/emulsify-starter` - `https://github.com/emulsify-ds/emulsify-drupal-starter` +- `https://github.com/emulsify-ds/emulsify-wordpress-starter` Examples: ```bash emulsify init "My Project" ./projects --platform none emulsify init "My Theme" ./web/themes/custom --platform drupal --yes +emulsify init "My Theme" ./wp-content/themes --platform wordpress emulsify init "My Project" ./projects --platform none --starter https://github.com/emulsify-ds/emulsify-starter --checkout main ``` +When WordPress is auto-detected, Emulsify initializes child themes into the detected themes directory, such as `wp-content/themes/` or `web/app/themes/` for Bedrock. + ## Systems `emulsify system list` lists the built-in systems that Emulsify CLI can install. `emulsify system ls` is the same command. @@ -72,6 +76,8 @@ Built-in systems in this CLI version: `emulsify system install [name]` installs a system in the current Emulsify project. The command installs required components by default. +System variants can declare platform compatibility with values such as `"wordpress"`, `"drupal || wordpress"`, or generic `"none"`. Project configuration uses only concrete `project.platform` values: `drupal`, `wordpress`, or `none`. + Options: - `--repository `: Installs a system from a specific Git repository. Custom repository URLs must end in `.git`. diff --git a/docs/project-initialization.md b/docs/project-initialization.md index bc6fed7..3bedc1b 100644 --- a/docs/project-initialization.md +++ b/docs/project-initialization.md @@ -12,9 +12,14 @@ The command clones a starter, writes `project.emulsify.json`, installs dependenc The CLI tries to determine the platform from the current working directory before prompting or using defaults. +Built-in project platforms are `drupal`, `wordpress`, and `none`. + | Detection | Result | | ----------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | | Drupal Composer project with `extra.drupal-scaffold.locations.web-root` | Platform `drupal`, target parent `/themes/custom`. | +| Standard WordPress site with `wp-content/themes` | Platform `wordpress`, target parent `wp-content/themes`. | +| Bedrock WordPress site with `web/app/themes` | Platform `wordpress`, target parent `web/app/themes`. | +| Composer/custom web-root WordPress site with `web/wp-content/themes` | Platform `wordpress`, target parent `web/wp-content/themes`. | | Existing Emulsify project found by `project.emulsify.json` | Platform `none`, target parent `/web/themes/custom`. | | No detectable platform | Use `--platform`, prompt in an interactive terminal, or use `--yes` to accept defaults. | @@ -23,15 +28,17 @@ When the platform cannot be detected and stdin is a TTY, the prompt choices are: ```text ? Platform: ❯ drupal + wordpress none ``` Built-in starter repositories: -| Platform | Repository | Checkout | -| -------- | -------------------------------------------------------- | -------- | -| `none` | `https://github.com/emulsify-ds/emulsify-starter` | `main` | -| `drupal` | `https://github.com/emulsify-ds/emulsify-drupal-starter` | `main` | +| Platform | Repository | Checkout | +| ----------- | ----------------------------------------------------------- | -------- | +| `none` | `https://github.com/emulsify-ds/emulsify-starter` | `main` | +| `drupal` | `https://github.com/emulsify-ds/emulsify-drupal-starter` | `main` | +| `wordpress` | `https://github.com/emulsify-ds/emulsify-wordpress-starter` | `main` | You can override starter resolution with `--starter` and `--checkout`. @@ -91,16 +98,35 @@ Creates: ./web/themes/custom/my_theme ``` +WordPress child themes use hyphens and are initialized into the detected themes directory: + +```bash +emulsify init "My Theme" --platform wordpress +``` + +When run inside a standard WordPress site, this creates: + +```text +wp-content/themes/my-theme +``` + +When run inside a Bedrock site, this creates: + +```text +web/app/themes/my-theme +``` + If the target already exists, initialization stops with an error and does not overwrite the directory. ## Machine Names If `--machineName` is omitted, the CLI derives one from the project name by removing non-alphanumeric characters, replacing spaces, and lowercasing the result. -| Platform | Project Name | Derived Machine Name | -| -------- | ---------------- | -------------------- | -| `none` | `Marketing Site` | `marketing-site` | -| `drupal` | `My Theme` | `my_theme` | +| Platform | Project Name | Derived Machine Name | +| ----------- | ---------------- | -------------------- | +| `none` | `Marketing Site` | `marketing-site` | +| `drupal` | `My Theme` | `my_theme` | +| `wordpress` | `My Theme` | `my-theme` | Use `--machineName` when the folder name or Drupal theme machine name must be exact. @@ -126,7 +152,7 @@ With `--yes`, missing values use the current defaults: Explicit arguments and flags still take precedence over `--yes` defaults. -In non-interactive mode, provide `--platform drupal` or `--platform none` when auto-detection is unavailable. +In non-interactive mode, provide `--platform drupal`, `--platform wordpress`, or `--platform none` when auto-detection is unavailable. ## Custom Starter @@ -143,7 +169,9 @@ If `--checkout` is omitted, the starter repository default branch is cloned. ## Generated Project Configuration -After a Drupal init, the generated `project.emulsify.json` looks like this: +After init, the generated `project.emulsify.json` stores a concrete platform value. It never stores compatibility expressions such as `drupal || wordpress` in `project.platform`. + +For a Drupal init, the file looks like this: ```json { diff --git a/docs/systems.md b/docs/systems.md index 84c0339..227445d 100644 --- a/docs/systems.md +++ b/docs/systems.md @@ -49,7 +49,7 @@ For a built-in system, the command: 3. Checks out the latest Git tag when the built-in system reference does not specify a checkout. 4. Clones the system into the local Emulsify cache. 5. Reads and validates `system.emulsify.json` from the cached system. -6. Selects the variant that matches `project.platform`. +6. Selects the best compatible variant for `project.platform`. 7. Writes `system` and `variant` entries into `project.emulsify.json`. 8. Installs components marked `required: true`. 9. Installs variant-level general files and directories. @@ -119,7 +119,7 @@ The scaffold is intentionally minimal and must be completed before it represents } ``` -The generated variant platform follows the current project platform when it is `drupal` or `none`. If `system.emulsify.json` already exists, the CLI stops rather than overwrite it. +The generated variant platform follows the current project platform when it is `drupal`, `wordpress`, or `none`. If `system.emulsify.json` already exists, the CLI stops rather than overwrite it. Creating a new system definition does not clone a remote system, install components, install general assets, run install hooks, or update `project.emulsify.json`. @@ -153,7 +153,7 @@ After a successful install, `project.emulsify.json` includes system and variant } ``` -The exact checkout and structure mappings come from the installed system. +The exact checkout, selected variant `platform` string, and structure mappings come from the installed system. If the selected system variant used `"platform": "drupal || wordpress"`, that original expression is stored in the project `variant.platform` value so later component commands can rehydrate the same variant. ## System Repository Shape @@ -169,15 +169,37 @@ A system repository must contain `system.emulsify.json` at its root. The schema Each variant used by the CLI must include: -| Field | Required | Purpose | -| -------------------------- | -------- | ----------------------------------------------------------------------- | -| `platform` | Yes | Variant platform. Built-in schemas currently allow `none` and `drupal`. | -| `structureImplementations` | Yes | Mapping from structure names to project-relative directories. | -| `components` | Yes | Installable component definitions. | -| `directories` | No | General directories copied during system install. | -| `files` | No | General files copied during system install. | +| Field | Required | Purpose | +| -------------------------- | -------- | ------------------------------------------------------------- | +| `platform` | Yes | Variant platform compatibility expression. | +| `structureImplementations` | Yes | Mapping from structure names to project-relative directories. | +| `components` | Yes | Installable component definitions. | +| `directories` | No | General directories copied during system install. | +| `files` | No | General files copied during system install. | -To be installable, a system must include a variant whose `platform` matches the current project's `project.platform`. +## Variant Platform Compatibility + +System variants can target one platform or multiple compatible platforms: + +```json +{ "platform": "wordpress" } +{ "platform": "drupal || wordpress" } +{ "platform": "none" } +``` + +Supported platform tokens are `drupal`, `wordpress`, and `none`. + +`none` on a variant means generic. A variant with `"platform": "none"` can be installed by any concrete project platform. + +Project configuration is different: `project.platform` is always a concrete value (`drupal`, `wordpress`, or `none`). Do not put `||` expressions in `project.platform`; only system variants use compatibility expressions. + +When installing a system, the CLI prefers: + +1. An exact platform match. +2. An expression containing the concrete project platform, such as `drupal || wordpress`. +3. A generic `none` variant. + +A project with `project.platform: "none"` can install any component library system. If more than one variant is equally compatible and no single best match is obvious, the CLI prompts in an interactive terminal or fails with a clear non-interactive error. Minimal example: @@ -194,7 +216,7 @@ Minimal example: ], "variants": [ { - "platform": "drupal", + "platform": "drupal || wordpress", "structureImplementations": [ { "name": "base", diff --git a/src/handlers/componentCreate.test.ts b/src/handlers/componentCreate.test.ts index a9a5b90..641adac 100644 --- a/src/handlers/componentCreate.test.ts +++ b/src/handlers/componentCreate.test.ts @@ -203,6 +203,10 @@ describe('componentCreate', () => { }); it('throws when the configured variant is not found', async () => { + const wordpressVariant = { + ...variant, + platform: 'wordpress', + }; getEmulsifyConfigMock.mockResolvedValueOnce({ ...projectConfig, variant: { @@ -210,6 +214,10 @@ describe('componentCreate', () => { platform: 'none', }, }); + getJsonFromCachedFileMock.mockResolvedValueOnce({ + ...system, + variants: [wordpressVariant], + }); await expect(componentCreate('button', {})).rejects.toThrow( 'Unable to find configuration for the variant none within the system compound.', diff --git a/src/handlers/componentInstall.test.ts b/src/handlers/componentInstall.test.ts index dfc6ba4..ad515f4 100644 --- a/src/handlers/componentInstall.test.ts +++ b/src/handlers/componentInstall.test.ts @@ -191,6 +191,10 @@ describe('componentInstall', () => { }); it('throws when the configured variant is not found', async () => { + const wordpressVariant = { + ...variant, + platform: 'wordpress', + }; getEmulsifyConfigMock.mockResolvedValueOnce({ ...projectConfig, variant: { @@ -198,6 +202,10 @@ describe('componentInstall', () => { platform: 'none', }, }); + getJsonFromCachedFileMock.mockResolvedValueOnce({ + ...system, + variants: [wordpressVariant], + }); await expect(componentInstall('button', {})).rejects.toThrow( 'Unable to find configuration for the variant none within the system compound.', diff --git a/src/handlers/componentList.test.ts b/src/handlers/componentList.test.ts index 5fb0f30..7437387 100644 --- a/src/handlers/componentList.test.ts +++ b/src/handlers/componentList.test.ts @@ -165,6 +165,10 @@ describe('componentList', () => { }); it('throws when the configured variant is not found', async () => { + const wordpressVariant = { + ...variant, + platform: 'wordpress', + }; getEmulsifyConfigMock.mockResolvedValueOnce({ ...projectConfig, variant: { @@ -172,6 +176,10 @@ describe('componentList', () => { platform: 'none', }, }); + getJsonFromCachedFileMock.mockResolvedValueOnce({ + ...system, + variants: [wordpressVariant], + }); await expect(componentList()).rejects.toThrow( 'Unable to find configuration for the variant none within the system compound.', diff --git a/src/handlers/hofs/withEmulsifySystem.test.ts b/src/handlers/hofs/withEmulsifySystem.test.ts index 659ff4e..0fb2f62 100644 --- a/src/handlers/hofs/withEmulsifySystem.test.ts +++ b/src/handlers/hofs/withEmulsifySystem.test.ts @@ -167,6 +167,10 @@ describe('withEmulsifySystem', () => { }); it('throws a typed error when the configured variant is not found', async () => { + const wordpressVariant = { + ...variant, + platform: 'wordpress', + }; getEmulsifyConfigMock.mockResolvedValueOnce({ ...projectConfig, variant: { @@ -174,6 +178,10 @@ describe('withEmulsifySystem', () => { platform: 'none', }, }); + getJsonFromCachedFileMock.mockResolvedValueOnce({ + ...system, + variants: [wordpressVariant], + }); await expect(withEmulsifySystem('install components')).rejects.toThrow( 'Unable to find configuration for the variant none within the system compound.', @@ -190,4 +198,49 @@ describe('withEmulsifySystem', () => { expect(cloneIntoCacheMock).toHaveBeenCalledWith('systems', ['compound']); expect(cloneSystemMock).toHaveBeenCalledWith(projectConfig.system); }); + + it('resolves a selected variant stored as a platform compatibility expression', async () => { + const expressionProjectConfig = { + ...projectConfig, + variant: { + ...projectConfig.variant, + platform: 'drupal || wordpress', + }, + }; + const expressionVariant = { + ...variant, + platform: 'drupal || wordpress', + }; + const expressionSystem = { + ...system, + variants: [expressionVariant], + }; + getEmulsifyConfigMock.mockResolvedValueOnce(expressionProjectConfig); + getJsonFromCachedFileMock.mockResolvedValueOnce(expressionSystem); + + await expect(withEmulsifySystem('install components')).resolves.toEqual({ + emulsifyConfig: expressionProjectConfig, + systemName: 'compound', + systemConf: expressionSystem, + variantConf: expressionVariant, + }); + }); + + it('falls back to project platform compatibility when the stored variant expression is stale', async () => { + const staleProjectConfig = { + ...projectConfig, + variant: { + ...projectConfig.variant, + platform: 'drupal || wordpress', + }, + }; + getEmulsifyConfigMock.mockResolvedValueOnce(staleProjectConfig); + + await expect(withEmulsifySystem('install components')).resolves.toEqual({ + emulsifyConfig: staleProjectConfig, + systemName: 'compound', + systemConf: system, + variantConf: variant, + }); + }); }); diff --git a/src/handlers/hofs/withEmulsifySystem.ts b/src/handlers/hofs/withEmulsifySystem.ts index 33b5e94..46d9bd2 100644 --- a/src/handlers/hofs/withEmulsifySystem.ts +++ b/src/handlers/hofs/withEmulsifySystem.ts @@ -16,6 +16,10 @@ import cloneIntoCache from '../../util/cache/cloneIntoCache.js'; import getJsonFromCachedFile from '../../util/cache/getJsonFromCachedFile.js'; import getGitRepoNameFromUrl from '../../util/getGitRepoNameFromUrl.js'; import getEmulsifyConfig from '../../util/project/getEmulsifyConfig.js'; +import { + selectCompatiblePlatformVariant, + selectExactPlatformVariant, +} from '../../util/platform/platformCompatibility.js'; type EmulsifyProjectConfigurationWithSystem = EmulsifyProjectConfiguration & { system: NonNullable; @@ -105,9 +109,20 @@ export async function withEmulsifySystem( } const variantName = variantReference.platform; - const variantConf = systemConf.variants?.find( - ({ platform }) => platform === variantName, + const exactVariantSelection = selectExactPlatformVariant( + systemConf.variants, + variantName, + ); + const compatibleVariantSelection = selectCompatiblePlatformVariant( + systemConf.variants, + emulsifyConfig.project.platform, ); + const variantConf = + exactVariantSelection.status === 'selected' + ? exactVariantSelection.variant + : compatibleVariantSelection.status === 'selected' + ? compatibleVariantSelection.variant + : undefined; if (!variantConf) { throw new EmulsifySystemError( `Unable to find configuration for the variant ${variantName} within the system ${systemName}.`, diff --git a/src/handlers/init.test.ts b/src/handlers/init.test.ts index 759b5bd..da4c49f 100644 --- a/src/handlers/init.test.ts +++ b/src/handlers/init.test.ts @@ -141,6 +141,37 @@ describe('init', () => { expect(logMock).toHaveBeenCalledWith('info', systemSelectionMessage); }); + it('clones the WordPress starter into the detected themes directory when WordPress is auto-detected', async () => { + expect.assertions(2); + getPlatformInfoMock.mockReturnValueOnce({ + root, + name: 'wordpress', + emulsifyParentDirectory: `${root}/wp-content/themes`, + }); + + await init(progress)('My Theme'); + + expect(gitCloneMock).toHaveBeenCalledWith( + 'https://github.com/emulsify-ds/emulsify-wordpress-starter', + '/home/uname/Projects/cornflake/wp-content/themes/my-theme', + { '--branch': 'main' }, + ); + expect(writeJsonFileMock).toHaveBeenCalledWith( + '/home/uname/Projects/cornflake/wp-content/themes/my-theme/project.emulsify.json', + { + project: { + platform: 'wordpress', + machineName: 'my-theme', + name: 'My Theme', + }, + starter: { + repository: + 'https://github.com/emulsify-ds/emulsify-wordpress-starter', + }, + }, + ); + }); + it('uses the progress obj to display information on the init process', async () => { expect.assertions(5); await init(progress)('cornflake'); @@ -212,6 +243,35 @@ describe('init', () => { ); }); + it('can clone the WordPress starter based on platform input', async () => { + expect.assertions(2); + getPlatformInfoMock.mockReturnValueOnce(undefined); + + await init(progress)('cornflake', `${root}`, { + platform: 'wordpress', + }); + + expect(gitCloneMock).toHaveBeenCalledWith( + 'https://github.com/emulsify-ds/emulsify-wordpress-starter', + '/home/uname/Projects/cornflake/cornflake', + { '--branch': 'main' }, + ); + expect(writeJsonFileMock).toHaveBeenCalledWith( + '/home/uname/Projects/cornflake/cornflake/project.emulsify.json', + { + project: { + platform: 'wordpress', + machineName: 'cornflake', + name: 'cornflake', + }, + starter: { + repository: + 'https://github.com/emulsify-ds/emulsify-wordpress-starter', + }, + }, + ); + }); + it('uses flag-provided values without prompting in non-interactive mode', async () => { expect.assertions(5); getPlatformInfoMock.mockReturnValueOnce(undefined); @@ -248,7 +308,7 @@ describe('init', () => { ); }); - it('prompts for the platform with drupal and none choices when missing in an interactive terminal', async () => { + it('prompts for the platform with drupal, wordpress, and none choices when missing in an interactive terminal', async () => { expect.assertions(5); setStdinIsTTY(true); getPlatformInfoMock.mockReturnValueOnce(undefined); @@ -262,7 +322,7 @@ describe('init', () => { expect(selectMock).toHaveBeenCalledTimes(1); expect(selectMock).toHaveBeenNthCalledWith(1, { message: 'Platform:', - choices: ['drupal', 'none'], + choices: ['drupal', 'wordpress', 'none'], default: 'drupal', }); expect(writeJsonFileMock).toHaveBeenCalledWith( @@ -359,13 +419,13 @@ describe('init', () => { ); }); - it('throws if no repository is found or specified', async () => { + it('throws if a detected platform is not supported', async () => { expect.assertions(1); getPlatformInfoMock.mockReturnValueOnce({ name: 'invalid', }); await expect(init(progress)('cornflake', root)).rejects.toThrow( - 'Unable to find an Emulsify starter for your project. Please specify one using the --starter flag: emulsify init myTheme --starter https://github.com/emulsify-ds/emulsify-starter', + 'Unsupported platform "invalid". Supported platforms are "none", "drupal", and "wordpress".', ); }); @@ -397,7 +457,7 @@ describe('init', () => { expect(select).toHaveBeenCalledTimes(1); expect(select).toHaveBeenNthCalledWith(1, { message: 'Platform:', - choices: ['drupal', 'none'], + choices: ['drupal', 'wordpress', 'none'], default: 'drupal', }); expect(gitCloneMock).toHaveBeenCalled(); diff --git a/src/handlers/init.ts b/src/handlers/init.ts index ecfffac..d99eacb 100644 --- a/src/handlers/init.ts +++ b/src/handlers/init.ts @@ -23,6 +23,7 @@ import executeScript from '../util/fs/executeScript.js'; import getInitSuccessMessageForPlatform from '../util/platform/getInitSuccessMessageForPlatform.js'; import log from '../lib/log.js'; import CliError from '../lib/CliError.js'; +import { isPlatform } from '../util/platform/platformCompatibility.js'; const git = simpleGit(); @@ -31,6 +32,7 @@ const DEFAULT_PROJECT_NAME = 'emulsifyTheme'; const DEFAULT_PLATFORM: Platform = 'drupal'; const PLATFORM_CHOICES = [ 'drupal', + 'wordpress', 'none', ] as const satisfies readonly Platform[]; @@ -94,9 +96,15 @@ export default function init(progress: InstanceType) { } // If no platform name is given, and none can be detected, exit and error. - let platformName = (options?.platform || autoPlatformName) as - | Platform - | undefined; + const requestedPlatformName = options?.platform || autoPlatformName; + let platformName = isPlatform(requestedPlatformName) + ? requestedPlatformName + : undefined; + if (requestedPlatformName && !platformName) { + throw new CliError( + `Unsupported platform "${requestedPlatformName}". Supported platforms are "none", "drupal", and "wordpress".`, + ); + } if (!platformName) { if (acceptDefaults) { platformName = DEFAULT_PLATFORM; diff --git a/src/handlers/systemInstall.test.ts b/src/handlers/systemInstall.test.ts index 8ad88a5..07f5fdd 100644 --- a/src/handlers/systemInstall.test.ts +++ b/src/handlers/systemInstall.test.ts @@ -18,7 +18,7 @@ jest.mock('../util/fs/writeToJsonFile', () => jest.fn()); jest.mock('@inquirer/prompts'); import fs from 'fs'; -import type { EmulsifySystem } from '@emulsify-cli/config'; +import type { EmulsifySystem, Platform } from '@emulsify-cli/config'; import { select } from '@inquirer/prompts'; import log from '../lib/log.js'; import { EMULSIFY_SYSTEM_CONFIG_FILE } from '../lib/constants.js'; @@ -118,7 +118,7 @@ const availableSystems = [ }, ]; -function customSystemDefinition(platform: 'drupal' | 'none'): EmulsifySystem { +function customSystemDefinition(platform: Platform): EmulsifySystem { return { name: 'custom-system', homepage: 'https://example.com/custom-system', @@ -346,6 +346,29 @@ describe('systemInstall', () => { ); }); + it('uses the current wordpress platform in the custom system definition', async () => { + setStdinIsTTY(true); + selectMock.mockResolvedValueOnce('create a new system'); + getEmulsifyConfigMock.mockResolvedValueOnce({ + ...projectConfig, + project: { + ...projectConfig.project, + platform: 'wordpress', + }, + }); + findFileInCurrentPathMock.mockReturnValueOnce( + '/project/project.emulsify.json', + ); + existsSyncMock.mockReturnValueOnce(false); + + await systemInstall(undefined, {}); + + expect(writeToJsonFileMock).toHaveBeenCalledWith( + '/project/system.emulsify.json', + customSystemDefinition('wordpress'), + ); + }); + it('does not overwrite an existing custom system definition', async () => { setStdinIsTTY(true); selectMock.mockResolvedValueOnce('create a new system'); @@ -440,11 +463,34 @@ describe('systemInstall', () => { consoleErrorMock.mockRestore(); }); + it('throws validation errors from invalid system variant platform expressions', async () => { + const consoleErrorMock = jest + .spyOn(console, 'error') + .mockImplementation(jest.fn()); + getJsonFromCachedFileMock.mockResolvedValueOnce({ + ...system, + variants: [ + { + ...variant, + platform: 'drupal && wordpress', + }, + ], + }); + + await expect(systemInstall('compound', {})).rejects.toThrow( + 'The system install failed due to the validation errors reported above. Please fix the the errors in the "compound" configuration and try again.', + ); + + expect(consoleErrorMock).toHaveBeenCalled(); + + consoleErrorMock.mockRestore(); + }); + it('throws when the requested variant is not found', async () => { await expect( systemInstall('compound', { variant: 'none' }), ).rejects.toThrow( - 'Unable to find a variant (none) within the system (compound). Please check your Emulsify project config and make sure the project.platform value is correct, or select a system with a variant that is compatible with the platform you are using.', + 'Unable to find a compatible variant matching "none" for project platform "drupal" within the system (compound). Available variant platform expressions: drupal.', ); }); @@ -455,7 +501,7 @@ describe('systemInstall', () => { }); await expect(systemInstall('compound', {})).rejects.toThrow( - 'Unable to find a variant (drupal) within the system (compound). Please check your Emulsify project config and make sure the project.platform value is correct, or select a system with a variant that is compatible with the platform you are using.', + 'Unable to find a compatible variant for project platform "drupal" within the system (compound). Available variant platform expressions: none.', ); }); @@ -513,6 +559,319 @@ describe('systemInstall', () => { ); }); + it('installs a wordpress system variant for a wordpress project', async () => { + const wordpressVariant = { + ...variant, + platform: 'wordpress', + }; + getEmulsifyConfigMock.mockResolvedValueOnce({ + ...projectConfig, + project: { + ...projectConfig.project, + platform: 'wordpress', + }, + }); + getJsonFromCachedFileMock.mockResolvedValueOnce({ + ...system, + variants: [wordpressVariant], + }); + + await systemInstall('compound', {}); + + expect(setEmulsifyConfigMock).toHaveBeenCalledWith( + expect.objectContaining({ + variant: { + platform: 'wordpress', + structureImplementations: variant.structureImplementations, + }, + }), + ); + }); + + it('prefers an exact wordpress variant over shared or generic variants for a wordpress project', async () => { + const genericVariant = { + ...variant, + platform: 'none', + }; + const sharedVariant = { + ...variant, + platform: 'drupal || wordpress', + }; + const wordpressVariant = { + ...variant, + platform: 'wordpress', + }; + getEmulsifyConfigMock.mockResolvedValueOnce({ + ...projectConfig, + project: { + ...projectConfig.project, + platform: 'wordpress', + }, + }); + getJsonFromCachedFileMock.mockResolvedValueOnce({ + ...system, + variants: [genericVariant, sharedVariant, wordpressVariant], + }); + + await systemInstall('compound', {}); + + expect(setEmulsifyConfigMock).toHaveBeenCalledWith( + expect.objectContaining({ + variant: { + platform: 'wordpress', + structureImplementations: variant.structureImplementations, + }, + }), + ); + }); + + it('installs a shared system variant for a drupal project', async () => { + const expressionVariant = { + ...variant, + platform: 'drupal || wordpress', + }; + getJsonFromCachedFileMock.mockResolvedValueOnce({ + ...system, + variants: [expressionVariant], + }); + + await systemInstall('compound', {}); + + expect(setEmulsifyConfigMock).toHaveBeenCalledWith({ + system: { + repository: 'https://github.com/emulsify-ds/compound.git', + checkout: 'v1.0.0', + }, + variant: { + platform: 'drupal || wordpress', + structureImplementations: variant.structureImplementations, + }, + }); + expect(logMock).toHaveBeenCalledWith( + 'success', + 'Successfully installed the compound system using the drupal || wordpress variant.', + ); + }); + + it('installs a shared system variant for a wordpress project', async () => { + const expressionVariant = { + ...variant, + platform: 'drupal || wordpress', + }; + getEmulsifyConfigMock.mockResolvedValueOnce({ + ...projectConfig, + project: { + ...projectConfig.project, + platform: 'wordpress', + }, + }); + getJsonFromCachedFileMock.mockResolvedValueOnce({ + ...system, + variants: [expressionVariant], + }); + + await systemInstall('compound', {}); + + expect(setEmulsifyConfigMock).toHaveBeenCalledWith( + expect.objectContaining({ + variant: { + platform: 'drupal || wordpress', + structureImplementations: variant.structureImplementations, + }, + }), + ); + }); + + it('prefers a shared expression over a generic none variant for a concrete project', async () => { + const genericVariant = { + ...variant, + platform: 'none', + }; + const expressionVariant = { + ...variant, + platform: 'drupal || wordpress', + }; + getJsonFromCachedFileMock.mockResolvedValueOnce({ + ...system, + variants: [genericVariant, expressionVariant], + }); + + await systemInstall('compound', {}); + + expect(setEmulsifyConfigMock).toHaveBeenCalledWith( + expect.objectContaining({ + variant: { + platform: 'drupal || wordpress', + structureImplementations: variant.structureImplementations, + }, + }), + ); + }); + + it.each(['drupal', 'wordpress'] as const)( + 'allows a %s project platform to install a generic none variant', + async (platform) => { + const genericVariant = { + ...variant, + platform: 'none', + }; + getEmulsifyConfigMock.mockResolvedValueOnce({ + ...projectConfig, + project: { + ...projectConfig.project, + platform, + }, + }); + getJsonFromCachedFileMock.mockResolvedValueOnce({ + ...system, + variants: [genericVariant], + }); + + await systemInstall('compound', {}); + + expect(setEmulsifyConfigMock).toHaveBeenCalledWith( + expect.objectContaining({ + variant: { + platform: 'none', + structureImplementations: variant.structureImplementations, + }, + }), + ); + }, + ); + + it.each(['drupal', 'wordpress'] as const)( + 'allows a none project platform to install a %s-only system variant', + async (platform) => { + const platformVariant = { + ...variant, + platform, + }; + getEmulsifyConfigMock.mockResolvedValueOnce({ + ...projectConfig, + project: { + ...projectConfig.project, + platform: 'none', + }, + }); + getJsonFromCachedFileMock.mockResolvedValueOnce({ + ...system, + variants: [platformVariant], + }); + + await systemInstall('compound', {}); + + expect(setEmulsifyConfigMock).toHaveBeenCalledWith( + expect.objectContaining({ + variant: { + platform, + structureImplementations: variant.structureImplementations, + }, + }), + ); + }, + ); + + it('throws a clear error when a none project platform matches multiple variants in a non-interactive terminal', async () => { + getEmulsifyConfigMock.mockResolvedValueOnce({ + ...projectConfig, + project: { + ...projectConfig.project, + platform: 'none', + }, + }); + getJsonFromCachedFileMock.mockResolvedValueOnce({ + ...system, + variants: [ + { + ...variant, + platform: 'drupal', + }, + { + ...variant, + platform: 'wordpress', + }, + ], + }); + + await expect(systemInstall('compound', {})).rejects.toThrow( + 'Multiple compatible variants were found for project platform "none" within the system (compound): drupal, wordpress. Run this command in an interactive terminal or specify a variant.', + ); + }); + + it('prompts when a none project platform matches multiple variants in an interactive terminal', async () => { + setStdinIsTTY(true); + selectMock.mockResolvedValueOnce('wordpress'); + getEmulsifyConfigMock.mockResolvedValueOnce({ + ...projectConfig, + project: { + ...projectConfig.project, + platform: 'none', + }, + }); + getJsonFromCachedFileMock.mockResolvedValueOnce({ + ...system, + variants: [ + { + ...variant, + platform: 'drupal', + }, + { + ...variant, + platform: 'wordpress', + }, + ], + }); + + await systemInstall('compound', {}); + + expect(selectMock).toHaveBeenCalledWith({ + message: 'Choose a compound variant for project platform "none":', + choices: ['drupal', 'wordpress'], + }); + expect(setEmulsifyConfigMock).toHaveBeenCalledWith( + expect.objectContaining({ + variant: { + platform: 'wordpress', + structureImplementations: variant.structureImplementations, + }, + }), + ); + }); + + it('allows a none project platform to prefer an exact none variant', async () => { + const wordpressVariant = { + ...variant, + platform: 'wordpress', + }; + const genericVariant = { + ...variant, + platform: 'none', + }; + getEmulsifyConfigMock.mockResolvedValueOnce({ + ...projectConfig, + project: { + ...projectConfig.project, + platform: 'none', + }, + }); + getJsonFromCachedFileMock.mockResolvedValueOnce({ + ...system, + variants: [wordpressVariant, genericVariant], + }); + + await systemInstall('compound', {}); + + expect(setEmulsifyConfigMock).toHaveBeenCalledWith( + expect.objectContaining({ + variant: { + platform: 'none', + structureImplementations: variant.structureImplementations, + }, + }), + ); + }); + it('installs no required components when none are marked required', async () => { getJsonFromCachedFileMock.mockResolvedValueOnce({ ...system, diff --git a/src/handlers/systemInstall.ts b/src/handlers/systemInstall.ts index cc18194..ac5b7a7 100644 --- a/src/handlers/systemInstall.ts +++ b/src/handlers/systemInstall.ts @@ -32,6 +32,12 @@ import getEmulsifyConfig from '../util/project/getEmulsifyConfig.js'; import findFileInCurrentPath from '../util/fs/findFileInCurrentPath.js'; import executeScript from '../util/fs/executeScript.js'; import writeToJsonFile from '../util/fs/writeToJsonFile.js'; +import { + getVariantPlatformExpressions, + isPlatform, + selectCompatiblePlatformVariant, + selectExactPlatformVariant, +} from '../util/platform/platformCompatibility.js'; const CREATE_NEW_SYSTEM_CHOICE = 'create a new system'; const CANCEL_SYSTEM_INSTALL_CHOICE = 'cancel'; @@ -115,8 +121,95 @@ async function promptForSystemInstallChoice(): Promise { return selectedSystem; } +function getVariantSelectionErrorMessage( + systemConf: EmulsifySystem, + projectPlatform: Platform, + requestedVariant: string | void, +): string { + const availableVariants = + getVariantPlatformExpressions(systemConf.variants).join(', ') || 'none'; + const requestedVariantMessage = requestedVariant + ? ` matching "${requestedVariant}"` + : ''; + + return `Unable to find a compatible variant${requestedVariantMessage} for project platform "${projectPlatform}" within the system (${systemConf.name}). Available variant platform expressions: ${availableVariants}.`; +} + +async function promptForVariantChoice( + variants: T[], + projectPlatform: Platform, + systemName: string, +): Promise { + const selectedPlatform = await select({ + message: `Choose a ${systemName} variant for project platform "${projectPlatform}":`, + choices: variants.map(({ platform }) => platform), + }); + return variants.find(({ platform }) => platform === selectedPlatform) as T; +} + +async function resolveSystemVariant( + systemConf: EmulsifySystem, + projectPlatform: Platform, + requestedVariant: string | void, +) { + if (requestedVariant) { + const selection = selectExactPlatformVariant( + systemConf.variants, + requestedVariant, + ); + if (selection.status === 'selected') { + return selection.variant; + } + + if (selection.status === 'ambiguous' && process.stdin.isTTY === true) { + return await promptForVariantChoice( + selection.variants, + projectPlatform, + systemConf.name, + ); + } + + throw new CliError( + getVariantSelectionErrorMessage( + systemConf, + projectPlatform, + requestedVariant, + ), + ); + } + + const selection = selectCompatiblePlatformVariant( + systemConf.variants, + projectPlatform, + ); + if (selection.status === 'selected') { + return selection.variant; + } + + if (selection.status === 'ambiguous') { + if (process.stdin.isTTY === true) { + return await promptForVariantChoice( + selection.variants, + projectPlatform, + systemConf.name, + ); + } + + const compatibleVariants = selection.variants + .map(({ platform }) => platform) + .join(', '); + throw new CliError( + `Multiple compatible variants were found for project platform "${projectPlatform}" within the system (${systemConf.name}): ${compatibleVariants}. Run this command in an interactive terminal or specify a variant.`, + ); + } + + throw new CliError( + getVariantSelectionErrorMessage(systemConf, projectPlatform, undefined), + ); +} + function getCustomSystemPlatform(platform: string): Platform { - return platform === 'drupal' || platform === 'none' ? platform : 'none'; + return isPlatform(platform) ? platform : 'none'; } function buildCustomSystemDefinition(platform: Platform): EmulsifySystem { @@ -275,24 +368,19 @@ export default async function systemInstall( ); } - // Extract the variant name, and error if no variant is determinable. - const variantName: string | void = - options.variant || projectConfig.project.platform; - if (!variantName) { + const projectPlatform = projectConfig.project.platform; + if (!isPlatform(projectPlatform)) { throw new CliError( 'Unable to determine a variant for the specified system. Please either pass in a valid variant using the --variant flag.', ); } // @TODO: clone variants into their own cache bucket if a reference is provided. - const variantConf = systemConf.variants?.find( - ({ platform }) => platform === variantName, + const variantConf = await resolveSystemVariant( + systemConf, + projectPlatform, + options.variant, ); - if (!variantConf) { - throw new CliError( - `Unable to find a variant (${variantName}) within the system (${systemConf.name}). Please check your Emulsify project config and make sure the project.platform value is correct, or select a system with a variant that is compatible with the platform you are using.`, - ); - } // Update emulsify project config. try { diff --git a/src/index.ts b/src/index.ts index ed029e5..c1f3837 100644 --- a/src/index.ts +++ b/src/index.ts @@ -44,7 +44,9 @@ function getRootHelp(): string { ' -m, --machineName Set the project folder/config machine name.', ' -s, --starter Use a custom starter repository.', ' -c, --checkout Checkout for the starter repository.', - ' -p, --platform Select the project platform when auto-detection is unavailable.', + ' -p, --platform ', + ' Select the project platform when auto-detection is unavailable.', + ' Built-in platforms: drupal, wordpress, none.', ' -y, --yes Accept defaults for missing init values without prompting.', '', ' system list', @@ -110,7 +112,7 @@ program 'Starter commit, branch, or tag to check out after clone.', ) .option( - '-p --platform ', + '-p --platform ', 'Project platform to use when auto-detection is unavailable or should be overridden.', ) .option( diff --git a/src/schemas/emulsifyProjectConfig.json b/src/schemas/emulsifyProjectConfig.json index da77ccd..e75d762 100644 --- a/src/schemas/emulsifyProjectConfig.json +++ b/src/schemas/emulsifyProjectConfig.json @@ -9,7 +9,7 @@ "description": "Contains information about the Emulsify project.", "properties": { "platform": { - "type": "string", + "$ref": "variant.json#/definitions/platform", "description": "Name of the platform Emulsify is being used within." }, "name": { @@ -57,7 +57,7 @@ "description": "Contains information about the Emulsify system this project is utilizing", "properties": { "platform": { - "$ref": "variant.json#/definitions/platform" + "$ref": "variant.json#/definitions/platformExpression" }, "structureImplementations": { "$ref": "variant.json#/definitions/structureImplementations" diff --git a/src/schemas/system.json b/src/schemas/system.json index 2622bf6..3196271 100644 --- a/src/schemas/system.json +++ b/src/schemas/system.json @@ -47,7 +47,7 @@ "type": "object", "properties": { "platform": { - "$ref": "variant.json#/definitions/platform" + "$ref": "variant.json#/definitions/platformExpression" }, "structureImplementations": { "$ref": "variant.json#/definitions/structureImplementations" diff --git a/src/schemas/variant.json b/src/schemas/variant.json index 52899b2..0b5dfb6 100644 --- a/src/schemas/variant.json +++ b/src/schemas/variant.json @@ -6,8 +6,33 @@ "definitions": { "platform": { "type": "string", - "description": "Name of the variant, usually indicating the platform for which the variant is intended, such as WordPress, or Drupal9", - "enum": ["none", "drupal"] + "description": "Concrete platform Emulsify is being used within.", + "enum": ["none", "drupal", "wordpress"] + }, + "platformExpression": { + "description": "Platform compatibility expression for a variant. Accepts a concrete platform or multiple concrete platforms joined by ' || '.", + "anyOf": [ + { + "$ref": "#/definitions/platform" + }, + { + "type": "string", + "enum": [ + "none || drupal", + "none || wordpress", + "drupal || none", + "drupal || wordpress", + "wordpress || none", + "wordpress || drupal", + "none || drupal || wordpress", + "none || wordpress || drupal", + "drupal || none || wordpress", + "drupal || wordpress || none", + "wordpress || none || drupal", + "wordpress || drupal || none" + ] + } + ] }, "structureImplementations": { "type": "array", @@ -119,7 +144,7 @@ }, "properties": { "platform": { - "$ref": "#/definitions/platform" + "$ref": "#/definitions/platformExpression" }, "structureImplementations": { "$ref": "#/definitions/structureImplementations" diff --git a/src/types/_emulsifyProjectConfig.d.ts b/src/types/_emulsifyProjectConfig.d.ts index 26d3a5d..cdc8d21 100644 --- a/src/types/_emulsifyProjectConfig.d.ts +++ b/src/types/_emulsifyProjectConfig.d.ts @@ -27,7 +27,7 @@ export interface EmulsifyProjectConfiguration { /** * Name of the platform Emulsify is being used within. */ - platform: string; + platform: 'none' | 'drupal' | 'wordpress'; /** * Name of the project, such as 'Carmen Sandiego'. */ @@ -64,9 +64,24 @@ export interface EmulsifyProjectConfiguration { */ variant?: { /** - * Name of the variant, usually indicating the platform for which the variant is intended, such as WordPress, or Drupal9 + * Platform compatibility expression for a variant. Accepts a concrete platform or multiple concrete platforms joined by ' || '. */ - platform: 'none' | 'drupal'; + platform: + | ('none' | 'drupal' | 'wordpress') + | ( + | 'none || drupal' + | 'none || wordpress' + | 'drupal || none' + | 'drupal || wordpress' + | 'wordpress || none' + | 'wordpress || drupal' + | 'none || drupal || wordpress' + | 'none || wordpress || drupal' + | 'drupal || none || wordpress' + | 'drupal || wordpress || none' + | 'wordpress || none || drupal' + | 'wordpress || drupal || none' + ); structureImplementations: StructureImplementations; /** * Git repository containing the system this project is utilizing diff --git a/src/types/_system.d.ts b/src/types/_system.d.ts index ffd34f2..a40862c 100644 --- a/src/types/_system.d.ts +++ b/src/types/_system.d.ts @@ -117,9 +117,24 @@ export interface EmulsifySystem { */ variants?: { /** - * Name of the variant, usually indicating the platform for which the variant is intended, such as WordPress, or Drupal9 + * Platform compatibility expression for a variant. Accepts a concrete platform or multiple concrete platforms joined by ' || '. */ - platform: 'none' | 'drupal'; + platform: + | ('none' | 'drupal' | 'wordpress') + | ( + | 'none || drupal' + | 'none || wordpress' + | 'drupal || none' + | 'drupal || wordpress' + | 'wordpress || none' + | 'wordpress || drupal' + | 'none || drupal || wordpress' + | 'none || wordpress || drupal' + | 'drupal || none || wordpress' + | 'drupal || wordpress || none' + | 'wordpress || none || drupal' + | 'wordpress || drupal || none' + ); structureImplementations: StructureImplementations; components: Components; directories?: Directories; diff --git a/src/types/_variant.d.ts b/src/types/_variant.d.ts index 8706206..28947f0 100644 --- a/src/types/_variant.d.ts +++ b/src/types/_variant.d.ts @@ -6,9 +6,28 @@ */ /** - * Name of the variant, usually indicating the platform for which the variant is intended, such as WordPress, or Drupal9 + * Platform compatibility expression for a variant. Accepts a concrete platform or multiple concrete platforms joined by ' || '. */ -export type Platform = 'none' | 'drupal'; +export type PlatformExpression = + | Platform + | ( + | 'none || drupal' + | 'none || wordpress' + | 'drupal || none' + | 'drupal || wordpress' + | 'wordpress || none' + | 'wordpress || drupal' + | 'none || drupal || wordpress' + | 'none || wordpress || drupal' + | 'drupal || none || wordpress' + | 'drupal || wordpress || none' + | 'wordpress || none || drupal' + | 'wordpress || drupal || none' + ); +/** + * Concrete platform Emulsify is being used within. + */ +export type Platform = 'none' | 'drupal' | 'wordpress'; /** * Array containing an object for each structure specified in the system to which this variant belongs */ @@ -91,7 +110,7 @@ export type Files = { }[]; export interface EmulsifyVariant { - platform: Platform; + platform: PlatformExpression; structureImplementations: StructureImplementations; components: Components; directories?: Directories; diff --git a/src/types/handlers.d.ts b/src/types/handlers.d.ts index b905eb1..98c5589 100644 --- a/src/types/handlers.d.ts +++ b/src/types/handlers.d.ts @@ -2,13 +2,15 @@ * Module that exports types pertaining to command handlers. */ declare module '@emulsify-cli/handlers' { + import type { Platform, PlatformExpression } from '@emulsify-cli/config'; + export type InitHandlerOptions = { /** Git repository of the Emulsify starter to clone. */ starter?: string | void; /** Commit, branch, or tag to checkout after cloning the starter repository. */ checkout?: string | void; /** Platform name to use when platform auto-detection is unavailable or should be overridden. */ - platform?: string | void; + platform?: Platform | void; /** Machine-friendly project folder/config name. */ machineName?: string | void; /** Accept default values for missing init options without prompting. */ @@ -18,7 +20,7 @@ declare module '@emulsify-cli/handlers' { export type InstallSystemHandlerOptions = { repository?: string | void; checkout?: string | void; - variant?: string | void; + variant?: PlatformExpression | void; all?: boolean; }; diff --git a/src/util/getAvailableStarters.test.ts b/src/util/getAvailableStarters.test.ts index 3bdeefe..99ada6e 100644 --- a/src/util/getAvailableStarters.test.ts +++ b/src/util/getAvailableStarters.test.ts @@ -17,6 +17,12 @@ describe('getAvailableStarters', () => { repository: 'https://github.com/emulsify-ds/emulsify-drupal-starter', checkout: 'main', }, + { + platform: 'wordpress', + platformMajorVersion: 6, + repository: 'https://github.com/emulsify-ds/emulsify-wordpress-starter', + checkout: 'main', + }, ]; expect(getAvailableStarters()).toEqual(expected); diff --git a/src/util/getAvailableStarters.ts b/src/util/getAvailableStarters.ts index 8cbba3c..5a69d83 100644 --- a/src/util/getAvailableStarters.ts +++ b/src/util/getAvailableStarters.ts @@ -19,5 +19,11 @@ export default function getAvailableStarters(): EmulsifyStarter[] { repository: 'https://github.com/emulsify-ds/emulsify-drupal-starter', checkout: 'main', }, + { + platform: 'wordpress', + platformMajorVersion: 6, + repository: 'https://github.com/emulsify-ds/emulsify-wordpress-starter', + checkout: 'main', + }, ]; } diff --git a/src/util/platform/getPlatformInfo.test.ts b/src/util/platform/getPlatformInfo.test.ts index a6aa57f..7c09ace 100644 --- a/src/util/platform/getPlatformInfo.test.ts +++ b/src/util/platform/getPlatformInfo.test.ts @@ -1,11 +1,17 @@ jest.mock('./getDrupalInfo', () => jest.fn()); +jest.mock('./getWordPressInfo', () => jest.fn()); jest.mock('./getNoPlatformInfo', () => jest.fn()); import getDrupalInfo from './getDrupalInfo.js'; +import getWordPressInfo from './getWordPressInfo.js'; import getNoPlatformInfo from './getNoPlatformInfo.js'; import getPlatformInfo from './getPlatformInfo.js'; describe('getPlatformInfo', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + it('can return information about Drupal, if the user is currently within a Drupal instance', async () => { expect.assertions(1); (getDrupalInfo as jest.Mock).mockResolvedValueOnce({ @@ -23,26 +29,52 @@ describe('getPlatformInfo', () => { await expect(getPlatformInfo()).resolves.toEqual(expected); }); - it('returns no platform info if the user (cwd) is not within any detectable platform', async () => { - expect.assertions(1); + it('prefers Drupal when both Drupal and WordPress signals appear', async () => { + expect.assertions(2); (getDrupalInfo as jest.Mock).mockResolvedValueOnce({ - name: 'none', + name: 'drupal', + root: '/home/uname/Projects/cornflake/web', + platformMajorVersion: 11, + }); + (getWordPressInfo as jest.Mock).mockResolvedValueOnce({ + name: 'wordpress', + root: '/home/uname/Projects/cornflake', + emulsifyParentDirectory: + '/home/uname/Projects/cornflake/wp-content/themes', + }); + + await expect(getPlatformInfo()).resolves.toEqual({ + name: 'drupal', + root: '/home/uname/Projects/cornflake/web', + platformMajorVersion: 11, + }); + expect(getWordPressInfo).not.toHaveBeenCalled(); + }); + + it('can return information about WordPress, if the user is currently within a WordPress instance', async () => { + expect.assertions(1); + (getDrupalInfo as jest.Mock).mockResolvedValueOnce(undefined); + (getWordPressInfo as jest.Mock).mockResolvedValueOnce({ + name: 'wordpress', root: '/home/uname/Projects/cornflake', - platformMajorVersion: 1, + emulsifyParentDirectory: + '/home/uname/Projects/cornflake/wp-content/themes', }); const expected = { - name: 'none', - platformMajorVersion: 1, + name: 'wordpress', root: '/home/uname/Projects/cornflake', + emulsifyParentDirectory: + '/home/uname/Projects/cornflake/wp-content/themes', }; await expect(getPlatformInfo()).resolves.toEqual(expected); }); - it('returns no platform info if getDrupalInfo returns null', async () => { + it('returns no platform info if the user (cwd) is not within Drupal or WordPress but an Emulsify project is detected', async () => { expect.assertions(1); (getDrupalInfo as jest.Mock).mockResolvedValueOnce(null); + (getWordPressInfo as jest.Mock).mockResolvedValueOnce(null); (getNoPlatformInfo as jest.Mock).mockResolvedValueOnce({ name: 'none' }); const result = await getPlatformInfo(); diff --git a/src/util/platform/getPlatformInfo.ts b/src/util/platform/getPlatformInfo.ts index 26fc697..332e1da 100644 --- a/src/util/platform/getPlatformInfo.ts +++ b/src/util/platform/getPlatformInfo.ts @@ -2,15 +2,22 @@ import type { PlatformInstanceInfo } from '@emulsify-cli/internal'; import getDrupalInfo from './getDrupalInfo.js'; import getNoPlatformInfo from './getNoPlatformInfo.js'; +import getWordPressInfo from './getWordPressInfo.js'; /** * Returns information about the platform the user is currently within (cwd), if it * exists and is detectable. */ export default async function getPlatformInfo(): Promise { - // @TODO: add support for more platforms, such as wordpress. const drupal = await getDrupalInfo(); - const noPlatform = await getNoPlatformInfo(); - const platform = drupal ? drupal : noPlatform; - return platform; + if (drupal) { + return drupal; + } + + const wordpress = await getWordPressInfo(); + if (wordpress) { + return wordpress; + } + + return await getNoPlatformInfo(); } diff --git a/src/util/platform/getWordPressInfo.test.ts b/src/util/platform/getWordPressInfo.test.ts new file mode 100644 index 0000000..ba1afef --- /dev/null +++ b/src/util/platform/getWordPressInfo.test.ts @@ -0,0 +1,72 @@ +import process from 'process'; +import fs from 'fs'; +import { clearFoundFileCache } from '../fs/findFileInCurrentPath.js'; +import getWordPressInfo from './getWordPressInfo.js'; + +const cwd = jest.spyOn(process, 'cwd'); +const existsSync = jest.spyOn(fs, 'existsSync'); + +describe('getWordPressInfo', () => { + beforeEach(() => { + clearFoundFileCache(); + cwd.mockReset(); + existsSync.mockReset(); + }); + + it('detects a standard WordPress themes directory', async () => { + cwd.mockReturnValue( + '/home/uname/Projects/cornflake/wp-content/themes/my-theme', + ); + existsSync.mockImplementation( + (path) => + String(path) === '/home/uname/Projects/cornflake/wp-content/themes', + ); + + await expect(getWordPressInfo()).resolves.toEqual({ + name: 'wordpress', + root: '/home/uname/Projects/cornflake', + emulsifyParentDirectory: + '/home/uname/Projects/cornflake/wp-content/themes', + }); + }); + + it('detects a Bedrock themes directory', async () => { + cwd.mockReturnValue( + '/home/uname/Projects/cornflake/web/app/themes/my-theme', + ); + existsSync.mockImplementation( + (path) => + String(path) === '/home/uname/Projects/cornflake/web/app/themes', + ); + + await expect(getWordPressInfo()).resolves.toEqual({ + name: 'wordpress', + root: '/home/uname/Projects/cornflake', + emulsifyParentDirectory: '/home/uname/Projects/cornflake/web/app/themes', + }); + }); + + it('detects a Composer web-root WordPress themes directory', async () => { + cwd.mockReturnValue( + '/home/uname/Projects/cornflake/web/wp-content/themes/my-theme', + ); + existsSync.mockImplementation( + (path) => + String(path) === '/home/uname/Projects/cornflake/web/wp-content/themes', + ); + + await expect(getWordPressInfo()).resolves.toEqual({ + name: 'wordpress', + root: '/home/uname/Projects/cornflake', + emulsifyParentDirectory: + '/home/uname/Projects/cornflake/web/wp-content/themes', + }); + }); + + it('returns void if no WordPress themes directory is found', async () => { + cwd.mockReturnValue('/home/uname/Projects/cornflake'); + existsSync.mockReturnValue(false); + + await expect(getWordPressInfo()).resolves.toBe(undefined); + }); +}); diff --git a/src/util/platform/getWordPressInfo.ts b/src/util/platform/getWordPressInfo.ts new file mode 100644 index 0000000..4dc39f4 --- /dev/null +++ b/src/util/platform/getWordPressInfo.ts @@ -0,0 +1,61 @@ +import type { PlatformInstanceInfo } from '@emulsify-cli/internal'; +import { dirname } from 'path'; +import findFileInCurrentPath from '../fs/findFileInCurrentPath.js'; + +const WORDPRESS_THEMES_DIRECTORIES = [ + 'wp-content/themes', + 'web/app/themes', + 'web/wp-content/themes', +] as const; + +function getRootFromThemesDirectory( + themesDirectory: string, + relativeThemesDirectory: string, +): string { + return relativeThemesDirectory + .split('/') + .reduce((root) => dirname(root), themesDirectory); +} + +/** + * Looks for a WordPress project within the cwd, and returns information about + * the platform, site root, and themes directory. + */ +export default async function getWordPressInfo(): Promise { + try { + const detectedThemesDirectories = WORDPRESS_THEMES_DIRECTORIES.map( + (relativeThemesDirectory) => { + const themesDirectory = findFileInCurrentPath(relativeThemesDirectory); + if (!themesDirectory) { + return undefined; + } + + return { + relativeThemesDirectory, + themesDirectory, + root: getRootFromThemesDirectory( + themesDirectory, + relativeThemesDirectory, + ), + }; + }, + ).filter((info) => info !== undefined); + + const detected = detectedThemesDirectories.sort( + (a, b) => + b.themesDirectory.length - a.themesDirectory.length || + b.relativeThemesDirectory.length - a.relativeThemesDirectory.length, + )[0]; + if (!detected) { + return undefined; + } + + return { + name: 'wordpress', + root: detected.root, + emulsifyParentDirectory: detected.themesDirectory, + }; + } catch { + return undefined; + } +} diff --git a/src/util/platform/platformCompatibility.test.ts b/src/util/platform/platformCompatibility.test.ts new file mode 100644 index 0000000..11bf3ac --- /dev/null +++ b/src/util/platform/platformCompatibility.test.ts @@ -0,0 +1,213 @@ +import { + getVariantPlatformExpressions, + isPlatform, + normalizePlatformExpression, + parsePlatformExpression, + platformExpressionMatchesProject, + selectCompatiblePlatformVariant, + selectExactPlatformVariant, +} from './platformCompatibility.js'; + +const drupalVariant = { platform: 'drupal', name: 'drupal' }; +const wordpressVariant = { platform: 'wordpress', name: 'wordpress' }; +const sharedVariant = { platform: 'drupal || wordpress', name: 'shared' }; +const genericVariant = { platform: 'none', name: 'generic' }; + +describe('platformCompatibility', () => { + describe('isPlatform', () => { + it('validates known platform values', () => { + expect(isPlatform('drupal')).toBe(true); + expect(isPlatform('wordpress')).toBe(true); + expect(isPlatform('none')).toBe(true); + expect(isPlatform('java')).toBe(false); + expect(isPlatform(undefined)).toBe(false); + }); + }); + + describe('parsePlatformExpression', () => { + it('parses and trims compatibility expressions', () => { + expect(parsePlatformExpression(' drupal || wordpress ')).toEqual([ + 'drupal', + 'wordpress', + ]); + }); + + it('deduplicates platform expressions', () => { + expect(parsePlatformExpression('drupal || drupal')).toEqual(['drupal']); + }); + + it('rejects empty expressions', () => { + expect(() => parsePlatformExpression(' || ')).toThrow( + 'Invalid platform compatibility expression: || ', + ); + }); + + it('rejects unknown platform values', () => { + expect(() => parsePlatformExpression('drupal || java')).toThrow( + 'Invalid platform compatibility expression: drupal || java', + ); + }); + }); + + it('normalizes platform expression whitespace', () => { + expect(normalizePlatformExpression('drupal|| wordpress')).toBe( + 'drupal || wordpress', + ); + }); + + describe('platformExpressionMatchesProject', () => { + it('matches concrete and generic expressions for concrete projects', () => { + expect(platformExpressionMatchesProject('wordpress', 'wordpress')).toBe( + true, + ); + expect( + platformExpressionMatchesProject('drupal || wordpress', 'wordpress'), + ).toBe(true); + expect(platformExpressionMatchesProject('none', 'wordpress')).toBe(true); + expect(platformExpressionMatchesProject('drupal', 'wordpress')).toBe( + false, + ); + }); + + it('allows none projects to match any expression', () => { + expect(platformExpressionMatchesProject('drupal', 'none')).toBe(true); + expect(platformExpressionMatchesProject('wordpress', 'none')).toBe(true); + }); + }); + + describe('getVariantPlatformExpressions', () => { + it('returns variant platform strings', () => { + expect( + getVariantPlatformExpressions([drupalVariant, sharedVariant]), + ).toEqual(['drupal', 'drupal || wordpress']); + }); + + it('returns an empty list when variants are missing', () => { + expect(getVariantPlatformExpressions(undefined)).toEqual([]); + }); + }); + + describe('selectCompatiblePlatformVariant', () => { + it('prefers exact matches over shared and generic variants', () => { + expect( + selectCompatiblePlatformVariant( + [genericVariant, sharedVariant, wordpressVariant], + 'wordpress', + ), + ).toEqual({ + status: 'selected', + variant: wordpressVariant, + }); + }); + + it('prefers shared expressions over generic variants', () => { + expect( + selectCompatiblePlatformVariant( + [genericVariant, sharedVariant], + 'wordpress', + ), + ).toEqual({ + status: 'selected', + variant: sharedVariant, + }); + }); + + it('selects generic variants for concrete projects', () => { + expect( + selectCompatiblePlatformVariant([genericVariant], 'wordpress'), + ).toEqual({ + status: 'selected', + variant: genericVariant, + }); + }); + + it('selects any single variant for none projects', () => { + expect(selectCompatiblePlatformVariant([drupalVariant], 'none')).toEqual({ + status: 'selected', + variant: drupalVariant, + }); + }); + + it('reports ambiguity when multiple variants share the best rank', () => { + expect( + selectCompatiblePlatformVariant( + [ + { platform: 'drupal || wordpress', name: 'shared-a' }, + { platform: 'wordpress || drupal', name: 'shared-b' }, + ], + 'wordpress', + ), + ).toEqual({ + status: 'ambiguous', + variants: [ + { platform: 'drupal || wordpress', name: 'shared-a' }, + { platform: 'wordpress || drupal', name: 'shared-b' }, + ], + }); + }); + + it('reports ambiguity when none projects match multiple variants', () => { + expect( + selectCompatiblePlatformVariant( + [drupalVariant, wordpressVariant], + 'none', + ), + ).toEqual({ + status: 'ambiguous', + variants: [drupalVariant, wordpressVariant], + }); + }); + + it('returns none when no variant is compatible', () => { + expect( + selectCompatiblePlatformVariant([drupalVariant], 'wordpress'), + ).toEqual({ + status: 'none', + }); + expect(selectCompatiblePlatformVariant(undefined, 'wordpress')).toEqual({ + status: 'none', + }); + }); + }); + + describe('selectExactPlatformVariant', () => { + it('selects exact normalized platform expressions', () => { + expect( + selectExactPlatformVariant( + [{ platform: 'drupal || wordpress', name: 'shared' }], + 'drupal||wordpress', + ), + ).toEqual({ + status: 'selected', + variant: { platform: 'drupal || wordpress', name: 'shared' }, + }); + }); + + it('reports ambiguity for duplicated exact expressions', () => { + expect( + selectExactPlatformVariant( + [ + { platform: 'wordpress', name: 'one' }, + { platform: 'wordpress', name: 'two' }, + ], + 'wordpress', + ), + ).toEqual({ + status: 'ambiguous', + variants: [ + { platform: 'wordpress', name: 'one' }, + { platform: 'wordpress', name: 'two' }, + ], + }); + }); + + it('returns none when no exact variant exists', () => { + expect(selectExactPlatformVariant([drupalVariant], 'wordpress')).toEqual({ + status: 'none', + }); + expect(selectExactPlatformVariant(undefined, 'wordpress')).toEqual({ + status: 'none', + }); + }); + }); +}); diff --git a/src/util/platform/platformCompatibility.ts b/src/util/platform/platformCompatibility.ts new file mode 100644 index 0000000..b853142 --- /dev/null +++ b/src/util/platform/platformCompatibility.ts @@ -0,0 +1,154 @@ +import type { Platform } from '@emulsify-cli/config'; + +const PLATFORMS = [ + 'none', + 'drupal', + 'wordpress', +] as const satisfies readonly Platform[]; + +type VariantWithPlatform = { + platform: string; +}; + +type VariantSelectionResult = + | { + status: 'selected'; + variant: T; + } + | { + status: 'ambiguous'; + variants: T[]; + } + | { + status: 'none'; + }; + +export function isPlatform(value: string | void): value is Platform { + return PLATFORMS.includes(value as Platform); +} + +export function parsePlatformExpression(expression: string): Platform[] { + const platforms = expression + .split('||') + .map((platform) => platform.trim()) + .filter((platform) => platform.length > 0); + + if (platforms.length === 0 || !platforms.every(isPlatform)) { + throw new Error(`Invalid platform compatibility expression: ${expression}`); + } + + return [...new Set(platforms)] as Platform[]; +} + +export function normalizePlatformExpression(expression: string): string { + return parsePlatformExpression(expression).join(' || '); +} + +export function platformExpressionMatchesProject( + expression: string, + projectPlatform: Platform, +): boolean { + const platforms = parsePlatformExpression(expression); + + if (projectPlatform === 'none') { + return true; + } + + return platforms.includes(projectPlatform) || platforms.includes('none'); +} + +function getVariantMatchRank( + variantPlatform: string, + projectPlatform: Platform, +): number | undefined { + const platforms = parsePlatformExpression(variantPlatform); + const normalizedVariantPlatform = platforms.join(' || '); + + if (normalizedVariantPlatform === projectPlatform) { + return 0; + } + + if (projectPlatform === 'none') { + return 3; + } + + if (platforms.includes(projectPlatform)) { + return 1; + } + + if (platforms.includes('none')) { + return 2; + } + + return undefined; +} + +export function getVariantPlatformExpressions( + variants: readonly VariantWithPlatform[] | undefined, +): string[] { + return variants?.map(({ platform }) => platform) || []; +} + +export function selectCompatiblePlatformVariant( + variants: readonly T[] | undefined, + projectPlatform: Platform, +): VariantSelectionResult { + const rankedVariants = (variants || []) + .map((variant) => ({ + variant, + rank: getVariantMatchRank(variant.platform, projectPlatform), + })) + .filter( + (match): match is { variant: T; rank: number } => + match.rank !== undefined, + ); + + if (rankedVariants.length === 0) { + return { status: 'none' }; + } + + const bestRank = Math.min(...rankedVariants.map(({ rank }) => rank)); + const bestMatches = rankedVariants + .filter(({ rank }) => rank === bestRank) + .map(({ variant }) => variant); + + if (bestMatches.length === 1) { + return { + status: 'selected', + variant: bestMatches[0], + }; + } + + return { + status: 'ambiguous', + variants: bestMatches, + }; +} + +export function selectExactPlatformVariant( + variants: readonly T[] | undefined, + platformExpression: string, +): VariantSelectionResult { + const normalizedPlatformExpression = + normalizePlatformExpression(platformExpression); + const matches = (variants || []).filter( + ({ platform }) => + normalizePlatformExpression(platform) === normalizedPlatformExpression, + ); + + if (matches.length === 0) { + return { status: 'none' }; + } + + if (matches.length === 1) { + return { + status: 'selected', + variant: matches[0], + }; + } + + return { + status: 'ambiguous', + variants: matches, + }; +} diff --git a/src/util/project/getEmulsifyConfig.test.ts b/src/util/project/getEmulsifyConfig.test.ts index 1960611..0ec1c4c 100644 --- a/src/util/project/getEmulsifyConfig.test.ts +++ b/src/util/project/getEmulsifyConfig.test.ts @@ -31,6 +31,19 @@ describe('getEmulsifyConfig', () => { await expect(getEmulsifyConfig()).resolves.toEqual(projectConfig); }); + it('accepts wordpress as a concrete project platform', async () => { + const wordpressProjectConfig = { + ...projectConfig, + project: { + ...projectConfig.project, + platform: 'wordpress', + }, + }; + loadJsonFileMock.mockResolvedValueOnce(wordpressProjectConfig); + + await expect(getEmulsifyConfig()).resolves.toEqual(wordpressProjectConfig); + }); + it('returns void if no Emulsify config file is found within the users cwd', async () => { findFileMock.mockReturnValueOnce(undefined); await expect(getEmulsifyConfig()).resolves.toBe(undefined); @@ -62,7 +75,38 @@ describe('getEmulsifyConfig', () => { ); }); - it('reports schema-invalid variant platform values', async () => { + it('accepts variant platform compatibility expressions', async () => { + const expressionConfig = { + ...projectConfig, + system: { + repository: 'https://github.com/emulsify-ds/compound.git', + checkout: 'main', + }, + variant: { + platform: 'drupal || wordpress', + structureImplementations: [], + }, + }; + loadJsonFileMock.mockResolvedValueOnce(expressionConfig); + + await expect(getEmulsifyConfig()).resolves.toEqual(expressionConfig); + }); + + it('reports schema-invalid project platform expressions', async () => { + loadJsonFileMock.mockResolvedValueOnce({ + ...projectConfig, + project: { + ...projectConfig.project, + platform: 'drupal || wordpress', + }, + }); + + await expect(getEmulsifyConfig()).rejects.toThrow( + 'Invalid Emulsify project configuration in "/projects/project.emulsify.json": /project/platform must be equal to one of the allowed values', + ); + }); + + it('reports schema-invalid variant platform expressions', async () => { loadJsonFileMock.mockResolvedValueOnce({ ...projectConfig, system: { @@ -70,13 +114,13 @@ describe('getEmulsifyConfig', () => { checkout: 'main', }, variant: { - platform: 'wordpress', + platform: 'drupal && wordpress', structureImplementations: [], }, }); await expect(getEmulsifyConfig()).rejects.toThrow( - 'Invalid Emulsify project configuration in "/projects/project.emulsify.json": /variant/platform must be equal to one of the allowed values', + '/variant/platform must match a schema in anyOf', ); }); }); diff --git a/src/util/system/getAvailableSystems.test.ts b/src/util/system/getAvailableSystems.test.ts index a9ad72f..75002ed 100644 --- a/src/util/system/getAvailableSystems.test.ts +++ b/src/util/system/getAvailableSystems.test.ts @@ -7,12 +7,12 @@ describe('getAvailableSystems', () => { { name: 'compound', repository: 'https://github.com/emulsify-ds/compound.git', - platforms: ['none', 'drupal'], + platforms: ['none', 'drupal', 'wordpress'], }, { name: 'emulsify-ui-kit', repository: 'https://github.com/emulsify-ds/emulsify-ui-kit.git', - platforms: ['none', 'drupal'], + platforms: ['none', 'drupal', 'wordpress'], }, ]); }); diff --git a/src/util/system/getAvailableSystems.ts b/src/util/system/getAvailableSystems.ts index 37f1d60..787f737 100644 --- a/src/util/system/getAvailableSystems.ts +++ b/src/util/system/getAvailableSystems.ts @@ -16,12 +16,12 @@ export default async function getAvailableSystems(): Promise< { name: 'compound', repository: 'https://github.com/emulsify-ds/compound.git', - platforms: ['none', 'drupal'], + platforms: ['none', 'drupal', 'wordpress'], }, { name: 'emulsify-ui-kit', repository: 'https://github.com/emulsify-ds/emulsify-ui-kit.git', - platforms: ['none', 'drupal'], + platforms: ['none', 'drupal', 'wordpress'], }, ]; } From 41fd6f8ffb96b2dc45a0a1a5447b132d4a74df8b Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Sat, 4 Jul 2026 15:45:12 -0500 Subject: [PATCH 06/27] build(deps): update dependencies and audit fixes --- docs/development.md | 4 +- package-lock.json | 736 +++++++++++++++++++++++++------------------- package.json | 16 +- 3 files changed, 435 insertions(+), 321 deletions(-) diff --git a/docs/development.md b/docs/development.md index 912fa3e..08e0c80 100644 --- a/docs/development.md +++ b/docs/development.md @@ -36,7 +36,7 @@ emulsify --help | Script | Purpose | | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------ | -| `npm run build` | Generate schema types, compile TypeScript, copy `package.json` to `dist`, and install production dependencies in `dist`. | +| `npm run build` | Generate schema types, compile TypeScript, copy package metadata to `dist`, and install production dependencies in `dist`. | | `npm run build-schema-types` | Generate TypeScript definitions from JSON Schema files. | | `npm run build-ts` | Compile TypeScript with `tsconfig.dist.json`. | | `npm run watch` | Rebuild when files in `src` change. | @@ -75,4 +75,4 @@ npm run build npm run test ``` -`npm run build` may report npm audit warnings from dependencies after the production install in `dist`; those warnings are not TypeScript build failures. +`npm run build` installs production dependencies in `dist` from the root lockfile. diff --git a/package-lock.json b/package-lock.json index 16617b1..ce0e62f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,7 @@ "colorette": "^2.0.20", "commander": "^15.0.0", "consola": "^3.4.2", - "fs-extra": "^11.3.5", + "fs-extra": "^11.3.6", "progress": "^2.0.3", "simple-git": "^3.36.0" }, @@ -24,22 +24,22 @@ "emulsify": "dist/index.js" }, "devDependencies": { - "@commitlint/cli": "^21.0.2", - "@commitlint/config-conventional": "^21.0.2", + "@commitlint/cli": "^21.2.0", + "@commitlint/config-conventional": "^21.2.0", "@semantic-release/commit-analyzer": "^13.0.1", - "@semantic-release/github": "^12.0.8", + "@semantic-release/github": "^12.0.9", "@semantic-release/npm": "^13.1.5", "@semantic-release/release-notes-generator": "^14.1.1", "@types/fs-extra": "^11.0.4", "@types/jest": "^30.0.0", - "@types/node": "^26.0.0", + "@types/node": "^26.1.0", "@types/progress": "^2.0.7", "husky": "^9.1.7", "jest": "^30.4.2", "json-schema-to-typescript": "^15.0.4", - "lint-staged": "^17.0.7", + "lint-staged": "^17.0.8", "nodemon": "^3.1.14", - "prettier": "^3.8.4", + "prettier": "^3.9.4", "semantic-release": "^25.0.5", "ts-jest": "^29.4.11", "ts-node": "^10.9.2", @@ -107,13 +107,13 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -122,9 +122,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", - "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", "dev": true, "license": "MIT", "engines": { @@ -132,21 +132,21 @@ } }, "node_modules/@babel/core": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", - "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helpers": "^7.28.6", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -173,14 +173,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.29.1", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", - "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -190,14 +190,14 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", - "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.28.6", - "@babel/helper-validator-option": "^7.27.1", + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" @@ -217,9 +217,9 @@ } }, "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", "dev": true, "license": "MIT", "engines": { @@ -227,29 +227,29 @@ } }, "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -269,9 +269,9 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "dev": true, "license": "MIT", "engines": { @@ -279,9 +279,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "dev": true, "license": "MIT", "engines": { @@ -289,9 +289,9 @@ } }, "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", "dev": true, "license": "MIT", "engines": { @@ -299,27 +299,27 @@ } }, "node_modules/@babel/helpers": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", - "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", - "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.7" }, "bin": { "parser": "bin/babel-parser.js" @@ -568,33 +568,33 @@ } }, "node_modules/@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", - "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", "debug": "^4.3.1" }, "engines": { @@ -602,14 +602,14 @@ } }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -634,18 +634,18 @@ } }, "node_modules/@commitlint/cli": { - "version": "21.1.0", - "resolved": "https://registry.npmjs.org/@commitlint/cli/-/cli-21.1.0.tgz", - "integrity": "sha512-CVwY6TxGv5naEaWxBdgNHko1xgL95Mb4WcIqp9iik33H0ctVqRv6YtekCntayhEP0T/apuiGvHu5HcCwFuVxEA==", + "version": "21.2.0", + "resolved": "https://registry.npmjs.org/@commitlint/cli/-/cli-21.2.0.tgz", + "integrity": "sha512-4GLVIhUaT3c3GBlQ0GB80/5H3xXdn/Tgw4lrsuoOQVDu2wl4Xw0GuzSar8xZKSMv4H3xaKaQXmvH91GmdyYBZA==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/config-conventional": "^21.1.0", - "@commitlint/format": "^21.1.0", - "@commitlint/lint": "^21.1.0", - "@commitlint/load": "^21.1.0", - "@commitlint/read": "^21.1.0", - "@commitlint/types": "^21.1.0", + "@commitlint/config-conventional": "^21.2.0", + "@commitlint/format": "^21.2.0", + "@commitlint/lint": "^21.2.0", + "@commitlint/load": "^21.2.0", + "@commitlint/read": "^21.2.0", + "@commitlint/types": "^21.2.0", "tinyexec": "^1.0.0", "yargs": "^18.0.0" }, @@ -657,27 +657,27 @@ } }, "node_modules/@commitlint/config-conventional": { - "version": "21.1.0", - "resolved": "https://registry.npmjs.org/@commitlint/config-conventional/-/config-conventional-21.1.0.tgz", - "integrity": "sha512-BIFl8xM+3SLy3jrblUC3wmQLCVbLty+++6o859BDCmybVrQdXmIWO+dlkGIbv/M2bBoC55wGuh0zGiw3TPjL1g==", + "version": "21.2.0", + "resolved": "https://registry.npmjs.org/@commitlint/config-conventional/-/config-conventional-21.2.0.tgz", + "integrity": "sha512-Qf8WRDVcyVd14if6VTWenebxFbKnVnbzPUJjlzjkyJGeHK2xCGd63Dr1XZzj0plXKQb9P0BfOxoc1HVeCo2BWQ==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/types": "^21.1.0", - "conventional-changelog-conventionalcommits": "^9.2.0" + "@commitlint/types": "^21.2.0", + "conventional-changelog-conventionalcommits": "^10.0.0" }, "engines": { "node": ">=22.12.0" } }, "node_modules/@commitlint/config-validator": { - "version": "21.1.0", - "resolved": "https://registry.npmjs.org/@commitlint/config-validator/-/config-validator-21.1.0.tgz", - "integrity": "sha512-gHczt1xqQSwfNqBmOI3HjejtTljkiBEUneExMmTBLD0WwTC78lAqDvNMyydbySt3DhpH0F9oX7Vvuks6s5XPFw==", + "version": "21.2.0", + "resolved": "https://registry.npmjs.org/@commitlint/config-validator/-/config-validator-21.2.0.tgz", + "integrity": "sha512-t7AzNHAKeIdo/3NRGwzpufKHsKkPHmFs/56N2Fnsh0/r0rGtnQzTxk6vnFgjaGr4hdSQKNB50/KAhR9Yk4LJKA==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/types": "^21.1.0", + "@commitlint/types": "^21.2.0", "ajv": "^8.11.0" }, "engines": { @@ -685,13 +685,13 @@ } }, "node_modules/@commitlint/ensure": { - "version": "21.1.0", - "resolved": "https://registry.npmjs.org/@commitlint/ensure/-/ensure-21.1.0.tgz", - "integrity": "sha512-/S8Mo3Q1NtQUYDQjDmyQVPxfIwtnxq+guzMOkuGk8OSdwlzanm1WB9wDPIuuzlbMDDnBNbiAuBEUCcCNlfjrTQ==", + "version": "21.2.0", + "resolved": "https://registry.npmjs.org/@commitlint/ensure/-/ensure-21.2.0.tgz", + "integrity": "sha512-76IF9vDNS13lAzEEik9eKwzt8f9hYhWiwVXZ2AnyLCz5/f511FsEQ3pw1X3/zSQpdRLQU7i5qDMVKyXi1GWjSg==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/types": "^21.1.0", + "@commitlint/types": "^21.2.0", "es-toolkit": "^1.46.0" }, "engines": { @@ -709,13 +709,13 @@ } }, "node_modules/@commitlint/format": { - "version": "21.1.0", - "resolved": "https://registry.npmjs.org/@commitlint/format/-/format-21.1.0.tgz", - "integrity": "sha512-ySymqKYBfjNrQ5N4W/l1iF2ISW1W7Eu/Oi/wRxlri31N0yjNyzUyUzQwyuZLDzTXIlMs4IZ7hIOfAZx8lO18gA==", + "version": "21.2.0", + "resolved": "https://registry.npmjs.org/@commitlint/format/-/format-21.2.0.tgz", + "integrity": "sha512-c4q64xaav2U83t7k7RyzJerBZurPer7FxUOY0RL5L/6CZijZ7K+s6HIBGIghj0ey1P2+seRX0J9XQYtDued6tg==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/types": "^21.1.0", + "@commitlint/types": "^21.2.0", "picocolors": "^1.1.1" }, "engines": { @@ -723,13 +723,13 @@ } }, "node_modules/@commitlint/is-ignored": { - "version": "21.1.0", - "resolved": "https://registry.npmjs.org/@commitlint/is-ignored/-/is-ignored-21.1.0.tgz", - "integrity": "sha512-RoRh1/YI+fYH+aid5lMQ2UD0vZ3p3Vf1KeUWT1ir3H/p/7T/6SFv1OiXLgLwUT8dP72EVWeEIyOfkiSWLZYVvw==", + "version": "21.2.0", + "resolved": "https://registry.npmjs.org/@commitlint/is-ignored/-/is-ignored-21.2.0.tgz", + "integrity": "sha512-4/eB0vBN7L88O/oC4ajAEqi7j2ZfNgxl/+11RfAV9YosejZgDXhY2C9VcHnHJhOzPLoSy5P3Mg/46kqeyJfXKw==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/types": "^21.1.0", + "@commitlint/types": "^21.2.0", "semver": "^7.6.0" }, "engines": { @@ -737,32 +737,32 @@ } }, "node_modules/@commitlint/lint": { - "version": "21.1.0", - "resolved": "https://registry.npmjs.org/@commitlint/lint/-/lint-21.1.0.tgz", - "integrity": "sha512-0DbfVVUjAWBfixW6v7CXXWVxMcj6Ukf/oB7O8NAbouP3jxmqUaC4eVQphxl3B3M0ii3cCQiR3sRAYxICwU2gAA==", + "version": "21.2.0", + "resolved": "https://registry.npmjs.org/@commitlint/lint/-/lint-21.2.0.tgz", + "integrity": "sha512-ceO5dp9pLjEZ6y6qbq/uXWXDPykqqlTsyzoQ0NzecpisSJhK3kTy9qzQoPeJuWG/IMNdV1lO0RgmzqoAlSi1uw==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/is-ignored": "^21.1.0", - "@commitlint/parse": "^21.1.0", - "@commitlint/rules": "^21.1.0", - "@commitlint/types": "^21.1.0" + "@commitlint/is-ignored": "^21.2.0", + "@commitlint/parse": "^21.2.0", + "@commitlint/rules": "^21.2.0", + "@commitlint/types": "^21.2.0" }, "engines": { "node": ">=22.12.0" } }, "node_modules/@commitlint/load": { - "version": "21.1.0", - "resolved": "https://registry.npmjs.org/@commitlint/load/-/load-21.1.0.tgz", - "integrity": "sha512-juiClVEcoreNB0TNVkseO2EmNcpEs/Yhnmgbnm/hQAKBFRynKwIaoNIljXkx/3yvZcMO0EE8I2XOEI7d5KZG8Q==", + "version": "21.2.0", + "resolved": "https://registry.npmjs.org/@commitlint/load/-/load-21.2.0.tgz", + "integrity": "sha512-RjlzWQqruRwIenJEfZtq7kG97co97nKoHpflE5YnF61tDLXxHPrdWImgzw6VL6MlFyaOcVlk74eBV8ZQmc3oIA==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/config-validator": "^21.1.0", + "@commitlint/config-validator": "^21.2.0", "@commitlint/execute-rule": "^21.0.1", - "@commitlint/resolve-extends": "^21.1.0", - "@commitlint/types": "^21.1.0", + "@commitlint/resolve-extends": "^21.2.0", + "@commitlint/types": "^21.2.0", "cosmiconfig": "^9.0.1", "cosmiconfig-typescript-loader": "^6.1.0", "es-toolkit": "^1.46.0", @@ -774,9 +774,9 @@ } }, "node_modules/@commitlint/message": { - "version": "21.0.2", - "resolved": "https://registry.npmjs.org/@commitlint/message/-/message-21.0.2.tgz", - "integrity": "sha512-5n4aqHGD/FNnom/D5L8i7cYtV+xjuXcBL832C3w9VglEsZzIsoHpJsvxzJ7cgiOsOdc/2jU4t5+7qMHh7GBX3g==", + "version": "21.2.0", + "resolved": "https://registry.npmjs.org/@commitlint/message/-/message-21.2.0.tgz", + "integrity": "sha512-YxGoiXD/HXNXLJPrQwE5poXa+XH0CBEm+mdvbHQP0g6MV/dmJyUFCzPNzZbxL93GvZ70TmtTK0Z0/IBpAqHv8g==", "dev": true, "license": "MIT", "engines": { @@ -784,29 +784,85 @@ } }, "node_modules/@commitlint/parse": { - "version": "21.1.0", - "resolved": "https://registry.npmjs.org/@commitlint/parse/-/parse-21.1.0.tgz", - "integrity": "sha512-HdAqbbjQS8eEtbR74Ysg2VNmbvAfeWLVYMkip/lHibNrtjRsC/97XAYN3/H5P0pEJtDfyTb3iLs8x6y0eu4OYA==", + "version": "21.2.0", + "resolved": "https://registry.npmjs.org/@commitlint/parse/-/parse-21.2.0.tgz", + "integrity": "sha512-QHWxG4d0PLTF634/AdyZ0MQS+CLn5YOuJlCFhMMlSGKFxzYGUetkHBj18xgBD+6fVzUrA2lrCdi/vlS2f/oYXg==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/types": "^21.1.0", - "conventional-changelog-angular": "^8.2.0", - "conventional-commits-parser": "^6.3.0" + "@commitlint/types": "^21.2.0", + "conventional-changelog-angular": "^9.0.0", + "conventional-commits-parser": "^7.0.0" }, "engines": { "node": ">=22.12.0" } }, + "node_modules/@commitlint/parse/node_modules/@simple-libs/stream-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@simple-libs/stream-utils/-/stream-utils-2.0.0.tgz", + "integrity": "sha512-fCTuZK4QBa+39Oz9l4OGfJfz+GpwCp3AqO7Zch3to99xHPgstVsRFpeQ8LNd2o1Gv8raL2mCFwiaHh7bFSp5DQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://ko-fi.com/dangreen" + } + }, + "node_modules/@commitlint/parse/node_modules/conventional-changelog-angular": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/conventional-changelog-angular/-/conventional-changelog-angular-9.2.0.tgz", + "integrity": "sha512-2EihZvM1KmbBGwuUow8lNXLe+ECRFsyOcV8X0197/WI7Rvvgfi55Oicfw0wxJTXGGIVXSfj+xBoNj+cxLlIiVw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@conventional-changelog/template": "^1.2.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@commitlint/parse/node_modules/conventional-commits-parser": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/conventional-commits-parser/-/conventional-commits-parser-7.0.0.tgz", + "integrity": "sha512-dZe/p+FgGQLNJFqaCgNdl8j6a7gI8xuaN30Wy5g7nvyK3jqOpNUEUZ3pGJ5D68h89uRh038FtkeOk/bnGmYkmg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@simple-libs/stream-utils": "^2.0.0", + "meow": "^14.0.0" + }, + "bin": { + "conventional-commits-parser": "dist/cli/index.js" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@commitlint/parse/node_modules/meow": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/meow/-/meow-14.1.0.tgz", + "integrity": "sha512-EDYo6VlmtnumlcBCbh1gLJ//9jvM/ndXHfVXIFrZVr6fGcwTUyCTFNTLCKuY3ffbK8L/+3Mzqnd58RojiZqHVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@commitlint/read": { - "version": "21.1.0", - "resolved": "https://registry.npmjs.org/@commitlint/read/-/read-21.1.0.tgz", - "integrity": "sha512-ID7m79aw8d0dMlxuXHD2QGxEX3Fhl/mUPA80WwEW5VgeOpUHNahhwWJefDdoBDVZcDfbHuf429NrcK0gxQsQjA==", + "version": "21.2.0", + "resolved": "https://registry.npmjs.org/@commitlint/read/-/read-21.2.0.tgz", + "integrity": "sha512-ELx8Ovh/JoAw5lpvDgxc6Y0We9skf2IPI2RFN+gnYgDGjRdMSF8zeodxhZmcclLWzfUIF7hXLOa8gOlllYcvBA==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/top-level": "^21.0.2", - "@commitlint/types": "^21.1.0", + "@commitlint/top-level": "^21.2.0", + "@commitlint/types": "^21.2.0", "git-raw-commits": "^5.0.0", "tinyexec": "^1.0.0" }, @@ -815,14 +871,14 @@ } }, "node_modules/@commitlint/resolve-extends": { - "version": "21.1.0", - "resolved": "https://registry.npmjs.org/@commitlint/resolve-extends/-/resolve-extends-21.1.0.tgz", - "integrity": "sha512-SANYkxJDfMl3TvnyALWHEaiF5nc6FFaOnh7VvfxjT4X2vD4i2gVHhmfMm1fsrBwDRX98/XyM1XDo5sAd/KXcyQ==", + "version": "21.2.0", + "resolved": "https://registry.npmjs.org/@commitlint/resolve-extends/-/resolve-extends-21.2.0.tgz", + "integrity": "sha512-4O/1j51+79Wth9s/MGxt/5gs0XYLDgNlYpltQfhAvLE0itusLKs9zruxbiNg1oOkmkb9L9L4USYGjEj7n87NxA==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/config-validator": "^21.1.0", - "@commitlint/types": "^21.1.0", + "@commitlint/config-validator": "^21.2.0", + "@commitlint/types": "^21.2.0", "es-toolkit": "^1.46.0", "global-directory": "^5.0.0", "resolve-from": "^5.0.0" @@ -832,16 +888,16 @@ } }, "node_modules/@commitlint/rules": { - "version": "21.1.0", - "resolved": "https://registry.npmjs.org/@commitlint/rules/-/rules-21.1.0.tgz", - "integrity": "sha512-fOPEYSmKn1ZJptjLmCEjJfYqz0PUYr8ng6VY2ZW26sB7KtENR90CmAXHEmScBbOIZip+d/+OwqK12DFBuHTqsQ==", + "version": "21.2.0", + "resolved": "https://registry.npmjs.org/@commitlint/rules/-/rules-21.2.0.tgz", + "integrity": "sha512-C2yXMNpiB8ETZKfx5JD8+ExgF8vTU1VQMKPSUUYwqKpw9oJWQBrlXBpdU038mj2WPjof7o9UzFpmTyBeGMZwZg==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/ensure": "^21.1.0", - "@commitlint/message": "^21.0.2", + "@commitlint/ensure": "^21.2.0", + "@commitlint/message": "^21.2.0", "@commitlint/to-lines": "^21.0.1", - "@commitlint/types": "^21.1.0" + "@commitlint/types": "^21.2.0" }, "engines": { "node": ">=22.12.0" @@ -858,9 +914,9 @@ } }, "node_modules/@commitlint/top-level": { - "version": "21.0.2", - "resolved": "https://registry.npmjs.org/@commitlint/top-level/-/top-level-21.0.2.tgz", - "integrity": "sha512-s9KKM+e+mXgFeIh4n7KmOGAVT3mkJ3Fp1bBYHIK5pjeUwlEMzp/tZfb5u0Poa680AsQTXMEMRxZi1vQ9m2X5ug==", + "version": "21.2.0", + "resolved": "https://registry.npmjs.org/@commitlint/top-level/-/top-level-21.2.0.tgz", + "integrity": "sha512-Y5gmQ+KxzqCrBFJfLvFEPvvwD3LDiNZoTT2yeFBm96M8qhmqSzQc5DvX3rheAaAMjyIvMXOCLS/mWfdpONsjyQ==", "dev": true, "license": "MIT", "dependencies": { @@ -871,19 +927,62 @@ } }, "node_modules/@commitlint/types": { - "version": "21.1.0", - "resolved": "https://registry.npmjs.org/@commitlint/types/-/types-21.1.0.tgz", - "integrity": "sha512-YodnnnH1Cp+08nP8HGNJAIuB6L3/vdCTHVRTfF8Ik/wRCLOTsU9zwv3yO1cSPQRDa9CLYtE+UJ2K67r7CwMSFw==", + "version": "21.2.0", + "resolved": "https://registry.npmjs.org/@commitlint/types/-/types-21.2.0.tgz", + "integrity": "sha512-7zVFCDB2reMvJH5dmbKnOQPjZEvjdJTH8jc0U/PIPU1r3/+vf5pD1HlfitV2MWsWXrvu7u39iY1lyLUPOaN0Gw==", "dev": true, "license": "MIT", "dependencies": { - "conventional-commits-parser": "^6.3.0", + "conventional-commits-parser": "^7.0.0", "picocolors": "^1.1.1" }, "engines": { "node": ">=22.12.0" } }, + "node_modules/@commitlint/types/node_modules/@simple-libs/stream-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@simple-libs/stream-utils/-/stream-utils-2.0.0.tgz", + "integrity": "sha512-fCTuZK4QBa+39Oz9l4OGfJfz+GpwCp3AqO7Zch3to99xHPgstVsRFpeQ8LNd2o1Gv8raL2mCFwiaHh7bFSp5DQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://ko-fi.com/dangreen" + } + }, + "node_modules/@commitlint/types/node_modules/conventional-commits-parser": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/conventional-commits-parser/-/conventional-commits-parser-7.0.0.tgz", + "integrity": "sha512-dZe/p+FgGQLNJFqaCgNdl8j6a7gI8xuaN30Wy5g7nvyK3jqOpNUEUZ3pGJ5D68h89uRh038FtkeOk/bnGmYkmg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@simple-libs/stream-utils": "^2.0.0", + "meow": "^14.0.0" + }, + "bin": { + "conventional-commits-parser": "dist/cli/index.js" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@commitlint/types/node_modules/meow": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/meow/-/meow-14.1.0.tgz", + "integrity": "sha512-EDYo6VlmtnumlcBCbh1gLJ//9jvM/ndXHfVXIFrZVr6fGcwTUyCTFNTLCKuY3ffbK8L/+3Mzqnd58RojiZqHVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@conventional-changelog/git-client": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/@conventional-changelog/git-client/-/git-client-2.7.0.tgz", @@ -911,6 +1010,16 @@ } } }, + "node_modules/@conventional-changelog/template": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@conventional-changelog/template/-/template-1.2.0.tgz", + "integrity": "sha512-12qHxvlKjHmP0PQ+17EREgC7lWyLwbph1RKcZQZ7k7ZWGmrxfxC9gadHGfvzr0g0u8BhiBGg3tks93txodlyRQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22" + } + }, "node_modules/@cspotcode/source-map-support": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", @@ -1437,9 +1546,9 @@ } }, "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { - "version": "3.14.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", - "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.0.tgz", + "integrity": "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==", "dev": true, "license": "MIT", "dependencies": { @@ -2357,9 +2466,9 @@ } }, "node_modules/@semantic-release/github": { - "version": "12.0.8", - "resolved": "https://registry.npmjs.org/@semantic-release/github/-/github-12.0.8.tgz", - "integrity": "sha512-tej5AAgK5X9wHRoDmYhecMXEHEkFeGOY1XsEblKxu8pIQwahzf1STYyr7iPU6Lpbg6C5I3N2w/ocXrBo+L7jhw==", + "version": "12.0.9", + "resolved": "https://registry.npmjs.org/@semantic-release/github/-/github-12.0.9.tgz", + "integrity": "sha512-ODIqb0V3QqndipryEEiaBxUQCFjvv7Oese5Dt4omMGa60YRNEW0Sx3K+zri0uac2Y6S9nOlMehciWIzvvRCTGQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2389,9 +2498,9 @@ } }, "node_modules/@semantic-release/github/node_modules/undici": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.27.1.tgz", - "integrity": "sha512-UDdpiex+mzigiyrXrGbiUaF4HzTNhKbh2vRNFaTMzcqmLIPrZxaCtwo/1TMSuWoM1Xz3WiTo9KdgI3kRqYzJGg==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", "dev": true, "license": "MIT", "engines": { @@ -2842,9 +2951,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "26.0.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.0.1.tgz", - "integrity": "sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw==", + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.0.tgz", + "integrity": "sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw==", "devOptional": true, "license": "MIT", "dependencies": { @@ -3625,9 +3734,9 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", - "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", + "version": "2.10.42", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.42.tgz", + "integrity": "sha512-c/jurFrDLyui7o1J86yLkRu4LMsTYcBohveus7/I2Hzdn9KIP2bdJPTue/lR1KH46enoPbD77GKeSYNdyPoD3Q==", "dev": true, "license": "Apache-2.0", "bin": { @@ -3687,9 +3796,9 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", "dev": true, "license": "MIT", "dependencies": { @@ -3713,9 +3822,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", - "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "version": "4.28.4", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz", + "integrity": "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==", "dev": true, "funding": [ { @@ -3733,11 +3842,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.9.0", - "caniuse-lite": "^1.0.30001759", - "electron-to-chromium": "^1.5.263", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.2.0" + "baseline-browser-mapping": "^2.10.38", + "caniuse-lite": "^1.0.30001799", + "electron-to-chromium": "^1.5.376", + "node-releases": "^2.0.48", + "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" @@ -3799,9 +3908,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001774", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001774.tgz", - "integrity": "sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA==", + "version": "1.0.30001800", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001800.tgz", + "integrity": "sha512-MMHtuAz9Ys840zAY5F4k6fV5GaivZ9sPk+nz0mY+GYVzRBnYkN0mpqkSR92oWRQ19yQWo4HvBV/FnC16AJX8MA==", "dev": true, "funding": [ { @@ -4348,16 +4457,16 @@ } }, "node_modules/conventional-changelog-conventionalcommits": { - "version": "9.3.1", - "resolved": "https://registry.npmjs.org/conventional-changelog-conventionalcommits/-/conventional-changelog-conventionalcommits-9.3.1.tgz", - "integrity": "sha512-dTYtpIacRpcZgrvBYvBfArMmK2xvIpv2TaxM0/ZI5CBtNUzvF2x0t15HsbRABWprS6UPmvj+PzHVjSx4qAVKyw==", + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/conventional-changelog-conventionalcommits/-/conventional-changelog-conventionalcommits-10.2.0.tgz", + "integrity": "sha512-UtlM9GqolY7OmlQh5L/UEVoKsTUpTgUVy1PU8JN5gl5Ydaejb7WRklGliG1SKPxxj7hzA173eG3Kt5fYWE2pmg==", "dev": true, "license": "ISC", "dependencies": { - "compare-func": "^2.0.0" + "@conventional-changelog/template": "^1.2.0" }, "engines": { - "node": ">=18" + "node": ">=22" } }, "node_modules/conventional-changelog-writer": { @@ -4645,9 +4754,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.302", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz", - "integrity": "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==", + "version": "1.5.387", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.387.tgz", + "integrity": "sha512-TaxwufTFDufvPEoXdhwVrA3UdFWBeWGkYoJ1K8ldF1xe6gKfth6iRNS5lTQ5JPNOHdGQm8PT1QYKUqFLCiUefQ==", "dev": true, "license": "ISC" }, @@ -4869,9 +4978,9 @@ } }, "node_modules/es-toolkit": { - "version": "1.48.1", - "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.48.1.tgz", - "integrity": "sha512-wfnXlwd5I75eXRtdD2vuEs50xHHESECDsGD7yiQnfFVNoa5522NwXEbmgo98LfiukSQHs+mBM7/YG3qKJB9/mQ==", + "version": "1.49.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.49.0.tgz", + "integrity": "sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g==", "dev": true, "license": "MIT", "workspaces": [ @@ -5018,9 +5127,9 @@ } }, "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz", + "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==", "funding": [ { "type": "github", @@ -5156,9 +5265,9 @@ } }, "node_modules/fs-extra": { - "version": "11.3.5", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.5.tgz", - "integrity": "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==", + "version": "11.3.6", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.6.tgz", + "integrity": "sha512-w8ZNZr2mKIc7qeNaQ9AVPT1+iFaI+Avd4xudVOvdDJ8VytREi1Ft5Ih7hd9jjehod8vAM5GMsfQ/TpPf4EyoEA==", "license": "MIT", "dependencies": { "graceful-fs": "^4.2.0", @@ -5288,6 +5397,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/git-raw-commits/-/git-raw-commits-5.0.1.tgz", "integrity": "sha512-Y+csSm2GD/PCSh6Isd/WiMjNAydu0VBiG9J7EdQsNA5P9uXvLayqjmTsNlK5Gs9IhblFZqOU0yid5Il5JPoLiQ==", + "deprecated": "Deprecated and no longer maintained. Use @conventional-changelog/git-client instead.", "dev": true, "license": "MIT", "dependencies": { @@ -7057,10 +7167,20 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -7811,11 +7931,14 @@ "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.27", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", - "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "version": "2.0.50", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz", + "integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/nodemon": { "version": "3.1.14", @@ -7924,9 +8047,9 @@ } }, "node_modules/npm": { - "version": "11.12.1", - "resolved": "https://registry.npmjs.org/npm/-/npm-11.12.1.tgz", - "integrity": "sha512-zcoUuF1kezGSAo0CqtvoLXX3mkRqzuqYdL6Y5tdo8g69NVV3CkjQ6ZBhBgB4d7vGkPcV6TcvLi3GRKPDFX+xTA==", + "version": "11.18.0", + "resolved": "https://registry.npmjs.org/npm/-/npm-11.18.0.tgz", + "integrity": "sha512-T67M4L5wNm0cZ7EBLErcEkY1SmzEW/WJ+SADBzsFUY1UdAPfFHXFQtZ6SEXiK0+vzXysCvAsepbMaBTwnrAD+w==", "bundleDependencies": [ "@isaacs/string-locale-compare", "@npmcli/arborist", @@ -8005,8 +8128,8 @@ ], "dependencies": { "@isaacs/string-locale-compare": "^1.1.0", - "@npmcli/arborist": "^9.4.2", - "@npmcli/config": "^10.8.1", + "@npmcli/arborist": "^9.9.0", + "@npmcli/config": "^10.12.0", "@npmcli/fs": "^5.0.0", "@npmcli/map-workspaces": "^5.0.3", "@npmcli/metavuln-calculator": "^9.0.3", @@ -8024,46 +8147,46 @@ "fs-minipass": "^3.0.3", "glob": "^13.0.6", "graceful-fs": "^4.2.11", - "hosted-git-info": "^9.0.2", + "hosted-git-info": "^9.0.3", "ini": "^6.0.0", "init-package-json": "^8.2.5", - "is-cidr": "^6.0.3", + "is-cidr": "^6.0.4", "json-parse-even-better-errors": "^5.0.0", "libnpmaccess": "^10.0.3", - "libnpmdiff": "^8.1.5", - "libnpmexec": "^10.2.5", - "libnpmfund": "^7.0.19", + "libnpmdiff": "^8.1.11", + "libnpmexec": "^10.3.1", + "libnpmfund": "^7.0.25", "libnpmorg": "^8.0.1", - "libnpmpack": "^9.1.5", - "libnpmpublish": "^11.1.3", + "libnpmpack": "^9.1.11", + "libnpmpublish": "^11.2.0", "libnpmsearch": "^9.0.1", "libnpmteam": "^8.0.2", - "libnpmversion": "^8.0.3", - "make-fetch-happen": "^15.0.5", - "minimatch": "^10.2.4", + "libnpmversion": "^8.0.4", + "make-fetch-happen": "^15.0.6", + "minimatch": "^10.2.5", "minipass": "^7.1.3", "minipass-pipeline": "^1.2.4", "ms": "^2.1.2", - "node-gyp": "^12.2.0", + "node-gyp": "^12.4.0", "nopt": "^9.0.0", "npm-audit-report": "^7.0.0", "npm-install-checks": "^8.0.0", "npm-package-arg": "^13.0.2", "npm-pick-manifest": "^11.0.3", - "npm-profile": "^12.0.1", + "npm-profile": "^12.0.2", "npm-registry-fetch": "^19.1.1", "npm-user-validate": "^4.0.0", "p-map": "^7.0.4", - "pacote": "^21.5.0", + "pacote": "^21.5.1", "parse-conflict-json": "^5.0.1", "proc-log": "^6.1.0", "qrcode-terminal": "^0.12.0", "read": "^5.0.1", - "semver": "^7.7.4", + "semver": "^7.8.5", "spdx-expression-parse": "^4.0.0", "ssri": "^13.0.1", "supports-color": "^10.2.2", - "tar": "^7.5.11", + "tar": "^7.5.19", "text-table": "~0.2.0", "tiny-relative-date": "^2.0.2", "treeverse": "^3.0.0", @@ -8119,7 +8242,7 @@ "license": "ISC" }, "node_modules/npm/node_modules/@npmcli/agent": { - "version": "4.0.0", + "version": "4.0.2", "dev": true, "inBundle": true, "license": "ISC", @@ -8135,7 +8258,7 @@ } }, "node_modules/npm/node_modules/@npmcli/arborist": { - "version": "9.4.2", + "version": "9.9.0", "dev": true, "inBundle": true, "license": "ISC", @@ -8183,7 +8306,7 @@ } }, "node_modules/npm/node_modules/@npmcli/config": { - "version": "10.8.1", + "version": "10.12.0", "dev": true, "inBundle": true, "license": "ISC", @@ -8377,7 +8500,7 @@ } }, "node_modules/npm/node_modules/@sigstore/core": { - "version": "3.2.0", + "version": "3.2.1", "dev": true, "inBundle": true, "license": "Apache-2.0", @@ -8386,7 +8509,7 @@ } }, "node_modules/npm/node_modules/@sigstore/protobuf-specs": { - "version": "0.5.0", + "version": "0.5.1", "dev": true, "inBundle": true, "license": "Apache-2.0", @@ -8425,13 +8548,13 @@ } }, "node_modules/npm/node_modules/@sigstore/verify": { - "version": "3.1.0", + "version": "3.1.1", "dev": true, "inBundle": true, "license": "Apache-2.0", "dependencies": { "@sigstore/bundle": "^4.0.0", - "@sigstore/core": "^3.1.0", + "@sigstore/core": "^3.2.1", "@sigstore/protobuf-specs": "^0.5.0" }, "engines": { @@ -8500,7 +8623,7 @@ } }, "node_modules/npm/node_modules/bin-links": { - "version": "6.0.0", + "version": "6.0.2", "dev": true, "inBundle": true, "license": "ISC", @@ -8528,7 +8651,7 @@ } }, "node_modules/npm/node_modules/brace-expansion": { - "version": "5.0.4", + "version": "5.0.7", "dev": true, "inBundle": true, "license": "MIT", @@ -8597,7 +8720,7 @@ } }, "node_modules/npm/node_modules/cidr-regex": { - "version": "5.0.3", + "version": "5.0.5", "dev": true, "inBundle": true, "license": "BSD-2-Clause", @@ -8653,7 +8776,7 @@ } }, "node_modules/npm/node_modules/diff": { - "version": "8.0.3", + "version": "8.0.4", "dev": true, "inBundle": true, "license": "BSD-3-Clause", @@ -8721,7 +8844,7 @@ "license": "ISC" }, "node_modules/npm/node_modules/hosted-git-info": { - "version": "9.0.2", + "version": "9.0.3", "dev": true, "inBundle": true, "license": "ISC", @@ -8820,7 +8943,7 @@ } }, "node_modules/npm/node_modules/ip-address": { - "version": "10.1.0", + "version": "10.2.0", "dev": true, "inBundle": true, "license": "MIT", @@ -8829,12 +8952,12 @@ } }, "node_modules/npm/node_modules/is-cidr": { - "version": "6.0.3", + "version": "6.0.4", "dev": true, "inBundle": true, "license": "BSD-2-Clause", "dependencies": { - "cidr-regex": "^5.0.1" + "cidr-regex": "^5.0.4" }, "engines": { "node": ">=20" @@ -8902,12 +9025,12 @@ } }, "node_modules/npm/node_modules/libnpmdiff": { - "version": "8.1.5", + "version": "8.1.11", "dev": true, "inBundle": true, "license": "ISC", "dependencies": { - "@npmcli/arborist": "^9.4.2", + "@npmcli/arborist": "^9.9.0", "@npmcli/installed-package-contents": "^4.0.0", "binary-extensions": "^3.0.0", "diff": "^8.0.2", @@ -8921,13 +9044,13 @@ } }, "node_modules/npm/node_modules/libnpmexec": { - "version": "10.2.5", + "version": "10.3.1", "dev": true, "inBundle": true, "license": "ISC", "dependencies": { "@gar/promise-retry": "^1.0.0", - "@npmcli/arborist": "^9.4.2", + "@npmcli/arborist": "^9.9.0", "@npmcli/package-json": "^7.0.0", "@npmcli/run-script": "^10.0.0", "ci-info": "^4.0.0", @@ -8944,12 +9067,12 @@ } }, "node_modules/npm/node_modules/libnpmfund": { - "version": "7.0.19", + "version": "7.0.25", "dev": true, "inBundle": true, "license": "ISC", "dependencies": { - "@npmcli/arborist": "^9.4.2" + "@npmcli/arborist": "^9.9.0" }, "engines": { "node": "^20.17.0 || >=22.9.0" @@ -8969,12 +9092,12 @@ } }, "node_modules/npm/node_modules/libnpmpack": { - "version": "9.1.5", + "version": "9.1.11", "dev": true, "inBundle": true, "license": "ISC", "dependencies": { - "@npmcli/arborist": "^9.4.2", + "@npmcli/arborist": "^9.9.0", "@npmcli/run-script": "^10.0.0", "npm-package-arg": "^13.0.0", "pacote": "^21.0.2" @@ -8984,7 +9107,7 @@ } }, "node_modules/npm/node_modules/libnpmpublish": { - "version": "11.1.3", + "version": "11.2.0", "dev": true, "inBundle": true, "license": "ISC", @@ -9028,7 +9151,7 @@ } }, "node_modules/npm/node_modules/libnpmversion": { - "version": "8.0.3", + "version": "8.0.4", "dev": true, "inBundle": true, "license": "ISC", @@ -9044,7 +9167,7 @@ } }, "node_modules/npm/node_modules/lru-cache": { - "version": "11.2.7", + "version": "11.5.1", "dev": true, "inBundle": true, "license": "BlueOak-1.0.0", @@ -9053,7 +9176,7 @@ } }, "node_modules/npm/node_modules/make-fetch-happen": { - "version": "15.0.5", + "version": "15.0.6", "dev": true, "inBundle": true, "license": "ISC", @@ -9076,12 +9199,12 @@ } }, "node_modules/npm/node_modules/minimatch": { - "version": "10.2.4", + "version": "10.2.5", "dev": true, "inBundle": true, "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^5.0.2" + "brace-expansion": "^5.0.5" }, "engines": { "node": "18 || 20 || >=22" @@ -9129,35 +9252,17 @@ } }, "node_modules/npm/node_modules/minipass-flush": { - "version": "1.0.5", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/npm/node_modules/minipass-flush/node_modules/minipass": { - "version": "3.3.6", + "version": "1.0.6", "dev": true, "inBundle": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { - "yallist": "^4.0.0" + "minipass": "^7.1.3" }, "engines": { - "node": ">=8" + "node": ">=16 || 14 >=14.17" } }, - "node_modules/npm/node_modules/minipass-flush/node_modules/yallist": { - "version": "4.0.0", - "dev": true, - "inBundle": true, - "license": "ISC" - }, "node_modules/npm/node_modules/minipass-pipeline": { "version": "1.2.4", "dev": true, @@ -9237,7 +9342,7 @@ } }, "node_modules/npm/node_modules/node-gyp": { - "version": "12.2.0", + "version": "12.4.0", "dev": true, "inBundle": true, "license": "MIT", @@ -9245,12 +9350,12 @@ "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "graceful-fs": "^4.2.6", - "make-fetch-happen": "^15.0.0", "nopt": "^9.0.0", "proc-log": "^6.0.0", "semver": "^7.3.5", "tar": "^7.5.4", "tinyglobby": "^0.2.12", + "undici": "^6.25.0", "which": "^6.0.0" }, "bin": { @@ -9361,13 +9466,13 @@ } }, "node_modules/npm/node_modules/npm-profile": { - "version": "12.0.1", + "version": "12.0.2", "dev": true, "inBundle": true, "license": "ISC", "dependencies": { "npm-registry-fetch": "^19.0.0", - "proc-log": "^6.0.0" + "proc-log": "^6.1.0" }, "engines": { "node": "^20.17.0 || >=22.9.0" @@ -9414,7 +9519,7 @@ } }, "node_modules/npm/node_modules/pacote": { - "version": "21.5.0", + "version": "21.5.1", "dev": true, "inBundle": true, "license": "ISC", @@ -9475,7 +9580,7 @@ } }, "node_modules/npm/node_modules/postcss-selector-parser": { - "version": "7.1.1", + "version": "7.1.4", "dev": true, "inBundle": true, "license": "MIT", @@ -9572,7 +9677,7 @@ "optional": true }, "node_modules/npm/node_modules/semver": { - "version": "7.7.4", + "version": "7.8.5", "dev": true, "inBundle": true, "license": "ISC", @@ -9596,17 +9701,17 @@ } }, "node_modules/npm/node_modules/sigstore": { - "version": "4.1.0", + "version": "4.1.1", "dev": true, "inBundle": true, "license": "Apache-2.0", "dependencies": { "@sigstore/bundle": "^4.0.0", - "@sigstore/core": "^3.1.0", + "@sigstore/core": "^3.2.1", "@sigstore/protobuf-specs": "^0.5.0", - "@sigstore/sign": "^4.1.0", - "@sigstore/tuf": "^4.0.1", - "@sigstore/verify": "^3.1.0" + "@sigstore/sign": "^4.1.1", + "@sigstore/tuf": "^4.0.2", + "@sigstore/verify": "^3.1.1" }, "engines": { "node": "^20.17.0 || >=22.9.0" @@ -9623,12 +9728,12 @@ } }, "node_modules/npm/node_modules/socks": { - "version": "2.8.7", + "version": "2.8.9", "dev": true, "inBundle": true, "license": "MIT", "dependencies": { - "ip-address": "^10.0.1", + "ip-address": "^10.1.1", "smart-buffer": "^4.2.0" }, "engines": { @@ -9697,7 +9802,7 @@ } }, "node_modules/npm/node_modules/tar": { - "version": "7.5.11", + "version": "7.5.19", "dev": true, "inBundle": true, "license": "BlueOak-1.0.0", @@ -9725,13 +9830,13 @@ "license": "MIT" }, "node_modules/npm/node_modules/tinyglobby": { - "version": "0.2.15", + "version": "0.2.17", "dev": true, "inBundle": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -9758,7 +9863,7 @@ } }, "node_modules/npm/node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", + "version": "4.0.4", "dev": true, "inBundle": true, "license": "MIT", @@ -9792,6 +9897,15 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/npm/node_modules/undici": { + "version": "6.27.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, "node_modules/npm/node_modules/util-deprecate": { "version": "1.0.2", "dev": true, @@ -10308,9 +10422,9 @@ } }, "node_modules/prettier": { - "version": "3.8.4", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.4.tgz", - "integrity": "sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==", + "version": "3.9.4", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.4.tgz", + "integrity": "sha512-yWG/o/4oJfo036EKAfK6ACAoDOfHeRHx4tuxkfBZiauURiaSmYwlpOr5LQqKtIkRD2z1PLteme2WoxEnj4tHTg==", "dev": true, "license": "MIT", "bin": { @@ -12026,9 +12140,9 @@ "license": "MIT" }, "node_modules/undici": { - "version": "6.24.1", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.24.1.tgz", - "integrity": "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==", + "version": "6.27.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.27.0.tgz", + "integrity": "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==", "dev": true, "license": "MIT", "engines": { diff --git a/package.json b/package.json index a7c338b..cfce0f8 100644 --- a/package.json +++ b/package.json @@ -37,25 +37,25 @@ "version:develop": "node --import ./src/register.mjs ./src/scripts/versionDevelop.ts", "semantic-release": "semantic-release --config ./release.config.cjs", "prepare": "husky install", - "postbuild": "cp package.json dist/package.json && cd dist && npm install --only=production --ignore-scripts" + "postbuild": "cp package.json package-lock.json dist/ && cd dist && npm ci --omit=dev --ignore-scripts" }, "devDependencies": { - "@commitlint/cli": "^21.0.2", - "@commitlint/config-conventional": "^21.0.2", + "@commitlint/cli": "^21.2.0", + "@commitlint/config-conventional": "^21.2.0", "@semantic-release/commit-analyzer": "^13.0.1", - "@semantic-release/github": "^12.0.8", + "@semantic-release/github": "^12.0.9", "@semantic-release/npm": "^13.1.5", "@semantic-release/release-notes-generator": "^14.1.1", "@types/fs-extra": "^11.0.4", "@types/jest": "^30.0.0", - "@types/node": "^26.0.0", + "@types/node": "^26.1.0", "@types/progress": "^2.0.7", "husky": "^9.1.7", "jest": "^30.4.2", "json-schema-to-typescript": "^15.0.4", - "lint-staged": "^17.0.7", + "lint-staged": "^17.0.8", "nodemon": "^3.1.14", - "prettier": "^3.8.4", + "prettier": "^3.9.4", "semantic-release": "^25.0.5", "ts-jest": "^29.4.11", "ts-node": "^10.9.2", @@ -69,7 +69,7 @@ "colorette": "^2.0.20", "commander": "^15.0.0", "consola": "^3.4.2", - "fs-extra": "^11.3.5", + "fs-extra": "^11.3.6", "progress": "^2.0.3", "simple-git": "^3.36.0" } From 792c7cfe4bdc2afc9d48a4222f7dd0ccbf358c71 Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Sun, 19 Jul 2026 19:10:00 -0500 Subject: [PATCH 07/27] fix(package): remove nested dependencies from CLI tarball --- .github/workflows/ci.yml | 6 +- .github/workflows/publish.yml | 4 + docs/development.md | 41 ++-- docs/release.md | 14 +- package.json | 8 +- scripts/pack-dry-run.mjs | 15 ++ scripts/package-helpers.mjs | 361 ++++++++++++++++++++++++++++++++++ scripts/smoke-pack.mjs | 212 ++++++++++++++++++++ tsconfig.dist.json | 2 +- 9 files changed, 640 insertions(+), 23 deletions(-) create mode 100644 scripts/pack-dry-run.mjs create mode 100644 scripts/package-helpers.mjs create mode 100644 scripts/smoke-pack.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e8043b7..eec1adb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,5 +30,7 @@ jobs: run: npm run type - name: Test run: npm test - - name: Pack - run: npm pack --dry-run + - name: Verify package contents + run: npm run pack:dry-run + - name: Smoke test packed install + run: npm run smoke:pack diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 11dd374..0b0042a 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -26,6 +26,10 @@ jobs: run: npm ci - name: Build run: npm run build + - name: Verify package contents + run: npm run pack:dry-run + - name: Smoke test packed install + run: npm run smoke:pack - name: Publish # Semantic Release reads commit history and publishing tokens from CI. env: diff --git a/docs/development.md b/docs/development.md index 08e0c80..ac82bd1 100644 --- a/docs/development.md +++ b/docs/development.md @@ -34,20 +34,23 @@ emulsify --help ## Scripts -| Script | Purpose | -| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------ | -| `npm run build` | Generate schema types, compile TypeScript, copy package metadata to `dist`, and install production dependencies in `dist`. | -| `npm run build-schema-types` | Generate TypeScript definitions from JSON Schema files. | -| `npm run build-ts` | Compile TypeScript with `tsconfig.dist.json`. | -| `npm run watch` | Rebuild when files in `src` change. | -| `npm run watch-ts` | Recompile TypeScript when files in `src` change. | -| `npm run format` | Run Prettier on source TypeScript and JavaScript files. | -| `npm run lint` | Runs the Jest test suite through `npm run test`. | -| `npm run test` | Run Jest with coverage. | -| `npm run type` | Run TypeScript checking without emitting files. | -| `npm run twatch` | Run Jest without coverage in watch mode. | -| `npm run version:develop` | Update package metadata from semantic commits on `develop`. | -| `npm run semantic-release` | Publish through semantic-release using `release.config.cjs`. | +| Script | Purpose | +| ---------------------------- | -------------------------------------------------------------------------------------------------- | +| `npm run clean` | Remove all generated files from `dist`. | +| `npm run build` | Clean `dist`, generate schema types, compile the runtime package, and mark the CLI bin executable. | +| `npm run build-schema-types` | Generate TypeScript definitions from JSON Schema files. | +| `npm run build-ts` | Compile TypeScript with `tsconfig.dist.json`. | +| `npm run pack:dry-run` | Inspect npm's structured package manifest and assert the expected runtime-only contents. | +| `npm run smoke:pack` | Pack the real tarball, install it in a clean temporary project, and run the installed CLI. | +| `npm run watch` | Rebuild when files in `src` change. | +| `npm run watch-ts` | Recompile TypeScript when files in `src` change. | +| `npm run format` | Run Prettier on source TypeScript and JavaScript files. | +| `npm run lint` | Runs the Jest test suite through `npm run test`. | +| `npm run test` | Run Jest with coverage. | +| `npm run type` | Run TypeScript checking without emitting files. | +| `npm run twatch` | Run Jest without coverage in watch mode. | +| `npm run version:develop` | Update package metadata from semantic commits on `develop`. | +| `npm run semantic-release` | Publish through semantic-release using `release.config.cjs`. | Run one test file by passing the path after `--`: @@ -70,9 +73,15 @@ Run this after changing files in `src/schemas`. Before opening a PR, run: ```bash -npm run type +npm ci npm run build +npm run type npm run test +npm run pack:dry-run +npm run smoke:pack ``` -`npm run build` installs production dependencies in `dist` from the root lockfile. +The package checks verify npm's actual file list, create the real tarball, install it +with its declared runtime dependencies, and exercise `emulsify --help` and +`emulsify --version`. Temporary tarballs and install projects are removed whether +the smoke test passes or fails. diff --git a/docs/release.md b/docs/release.md index 342f39e..00a4007 100644 --- a/docs/release.md +++ b/docs/release.md @@ -20,9 +20,17 @@ npm ci npm run build npm run type npm run test -npm pack --dry-run +npm run pack:dry-run +npm run smoke:pack ``` +`pack:dry-run` uses npm's structured package manifest to require the CLI runtime +files and reject source tests, coverage, caches, package locks, and nested +`node_modules`. `smoke:pack` creates the real tarball, installs it with its +declared runtime dependencies in a clean temporary project, and runs +`emulsify --help` and `emulsify --version`. Both commands require a completed +build. + There are currently both `ci.yml` and `test.yml` workflows that validate pull requests and pushes. ## Develop Version Bump @@ -47,7 +55,9 @@ Pushes to `main` run the `Publish` workflow: 2. Install Node.js 24. 3. Run `npm ci`. 4. Run `npm run build`. -5. Run `npm run semantic-release`. +5. Run `npm run pack:dry-run`. +6. Run `npm run smoke:pack`. +7. Run `npm run semantic-release`. semantic-release is configured in `release.config.cjs` with: diff --git a/package.json b/package.json index cfce0f8..58b3c39 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,10 @@ "access": "public" }, "scripts": { + "clean": "node --input-type=module --eval \"import { rm } from 'node:fs/promises'; await rm('dist', { recursive: true, force: true });\"", + "prebuild": "npm run clean", "build": "npm run build-schema-types && npm run build-ts", + "postbuild": "node --input-type=module --eval \"import { chmod } from 'node:fs/promises'; await chmod('dist/index.js', 0o755);\"", "build-ts": "tsc --build tsconfig.dist.json", "build-schema-types": "node --import ./src/register.mjs ./src/scripts/jsonSchemaToTs.ts", "watch": "nodemon --watch src --ext ts,json --ignore src/types --exec npm run build", @@ -36,8 +39,9 @@ "twatch": "jest --no-coverage --watch --verbose", "version:develop": "node --import ./src/register.mjs ./src/scripts/versionDevelop.ts", "semantic-release": "semantic-release --config ./release.config.cjs", - "prepare": "husky install", - "postbuild": "cp package.json package-lock.json dist/ && cd dist && npm ci --omit=dev --ignore-scripts" + "pack:dry-run": "node ./scripts/pack-dry-run.mjs", + "smoke:pack": "node ./scripts/smoke-pack.mjs", + "prepare": "husky install" }, "devDependencies": { "@commitlint/cli": "^21.2.0", diff --git a/scripts/pack-dry-run.mjs b/scripts/pack-dry-run.mjs new file mode 100644 index 0000000..223deb0 --- /dev/null +++ b/scripts/pack-dry-run.mjs @@ -0,0 +1,15 @@ +import { + assertPackageContents, + packPackage, + printPackageSummary, + reportError, +} from './package-helpers.mjs'; + +try { + const { packResult } = packPackage({ dryRun: true }); + assertPackageContents(packResult); + printPackageSummary(packResult, 'Package dry run verified'); +} catch (error) { + reportError('Package dry run failed', error); + process.exitCode = 1; +} diff --git a/scripts/package-helpers.mjs b/scripts/package-helpers.mjs new file mode 100644 index 0000000..c4d4087 --- /dev/null +++ b/scripts/package-helpers.mjs @@ -0,0 +1,361 @@ +import { spawnSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export const repositoryRoot = resolve( + dirname(fileURLToPath(import.meta.url)), + '..', +); + +export const packageManifest = JSON.parse( + readFileSync(resolve(repositoryRoot, 'package.json'), 'utf8'), +); + +export const requiredPackagePaths = [ + 'LICENSE', + 'README.md', + 'package.json', + 'dist/index.js', + 'dist/schemas/emulsifyProjectConfig.json', + 'dist/schemas/system.json', + 'dist/schemas/variant.json', +]; + +const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm'; +const maxPackageEntries = 100; + +function displayArgument(argument) { + return /^[A-Za-z0-9_./:@=+-]+$/.test(argument) + ? argument + : JSON.stringify(argument); +} + +function displayCommand(command, args) { + return [command, ...args].map(displayArgument).join(' '); +} + +function ensureTrailingNewline(value) { + return value.endsWith('\n') ? value : `${value}\n`; +} + +export function reportError(heading, error) { + const detail = + error instanceof Error ? error.stack || error.message : String(error); + process.stderr.write(`${heading}\n${ensureTrailingNewline(detail)}`); +} + +export function printCommandDiagnostics( + result, + heading = 'Child process failed', +) { + const exitDescription = + result.status === null + ? `signal ${result.signal || 'unknown'}` + : `exit ${result.status}`; + + process.stderr.write( + [ + '', + heading, + `Command: ${displayCommand(result.command, result.args)}`, + `Working directory: ${result.cwd}`, + `Result: ${exitDescription}`, + 'stdout:', + result.stdout || '(empty)', + 'stderr:', + result.stderr || '(empty)', + '', + ].join('\n'), + ); +} + +export function runCommand( + command, + args, + { cwd = repositoryRoot, env = {}, shell = false } = {}, +) { + const childEnv = { + ...process.env, + NO_COLOR: '1', + npm_config_color: 'false', + npm_config_progress: 'false', + npm_config_update_notifier: 'false', + ...env, + }; + + // FORCE_COLOR takes precedence over NO_COLOR in Node and several CLI + // libraries, so inherited developer settings must not affect assertions. + delete childEnv.FORCE_COLOR; + + const child = spawnSync(command, args, { + cwd, + encoding: 'utf8', + env: childEnv, + maxBuffer: 10 * 1024 * 1024, + shell, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + const result = { + command, + args, + cwd, + status: child.status, + signal: child.signal, + stdout: child.stdout || '', + stderr: child.stderr || '', + }; + + if (child.error || child.status !== 0) { + printCommandDiagnostics(result); + const message = child.error + ? `${displayCommand(command, args)} could not start: ${child.error.message}` + : `${displayCommand(command, args)} failed with ${ + child.signal ? `signal ${child.signal}` : `exit ${child.status}` + }`; + throw new Error(message, child.error ? { cause: child.error } : undefined); + } + + return result; +} + +export function runNpm(args, options) { + return runCommand(npmCommand, args, options); +} + +function parsePackJson(commandResult) { + let parsed; + + try { + parsed = JSON.parse(commandResult.stdout); + } catch (error) { + printCommandDiagnostics( + commandResult, + 'npm pack succeeded but did not return valid JSON', + ); + throw new Error('Unable to parse npm pack JSON output', { cause: error }); + } + + if (!Array.isArray(parsed) || parsed.length !== 1) { + printCommandDiagnostics( + commandResult, + 'npm pack returned an unexpected JSON result', + ); + throw new Error( + `Expected npm pack to describe one package, received ${ + Array.isArray(parsed) ? parsed.length : typeof parsed + }`, + ); + } + + return parsed[0]; +} + +export function packPackage({ dryRun = false, packDestination } = {}) { + const args = ['pack']; + + if (dryRun) { + args.push('--dry-run'); + } + + args.push('--ignore-scripts', '--json'); + + if (packDestination) { + args.push('--pack-destination', packDestination); + } + + const commandResult = runNpm(args, { cwd: repositoryRoot }); + + return { + commandResult, + packResult: parsePackJson(commandResult), + }; +} + +function normalizePackagePath(path) { + return path.replaceAll('\\', '/').replace(/^\.\/+/, ''); +} + +function describePaths(paths) { + return [...new Set(paths)].sort().join(', '); +} + +export function assertPackageContents(packResult) { + const issues = []; + + if (!Array.isArray(packResult.files)) { + throw new Error('npm pack JSON result does not contain a files array'); + } + + const files = packResult.files.map((file) => ({ + ...file, + path: normalizePackagePath(String(file.path)), + })); + const paths = files.map((file) => file.path); + const pathSet = new Set(paths); + + const duplicates = paths.filter( + (path, index) => paths.indexOf(path) !== index, + ); + if (duplicates.length > 0) { + issues.push(`duplicate package paths: ${describePaths(duplicates)}`); + } + + const missing = requiredPackagePaths.filter((path) => !pathSet.has(path)); + if (missing.length > 0) { + issues.push(`missing required paths: ${describePaths(missing)}`); + } + + const forbidden = []; + for (const path of paths) { + const lowerPath = path.toLowerCase(); + const segments = lowerPath.split('/'); + const basename = segments.at(-1); + const hasForbiddenDirectory = segments.some((segment) => + [ + 'node_modules', + 'coverage', + '.nyc_output', + '.cache', + '.npm', + 'test', + 'tests', + '__tests__', + 'spec', + 'specs', + '__specs__', + ].includes(segment), + ); + const isTestOrSpecFile = /\.(?:test|spec)\.[^/]+$/i.test(path); + + if ( + hasForbiddenDirectory || + segments[0] === 'src' || + isTestOrSpecFile || + lowerPath.endsWith('.tsbuildinfo') || + basename === 'package-lock.json' || + lowerPath === 'dist/package.json' || + lowerPath === 'dist/scripts' || + lowerPath.startsWith('dist/scripts/') + ) { + forbidden.push(path); + } + } + + if (forbidden.length > 0) { + issues.push(`forbidden package paths: ${describePaths(forbidden)}`); + } + + if (paths.length > maxPackageEntries) { + issues.push( + `package contains ${paths.length} entries; maximum is ${maxPackageEntries}`, + ); + } + + if ( + !Number.isInteger(packResult.entryCount) || + packResult.entryCount !== paths.length + ) { + issues.push( + `entryCount ${String(packResult.entryCount)} does not match files length ${ + paths.length + }`, + ); + } + + if (!Array.isArray(packResult.bundled) || packResult.bundled.length !== 0) { + issues.push( + `bundled dependencies must be empty; received ${JSON.stringify( + packResult.bundled, + )}`, + ); + } + + const emulsifyBin = + packageManifest.bin && + typeof packageManifest.bin === 'object' && + packageManifest.bin.emulsify; + + if (emulsifyBin !== 'dist/index.js') { + issues.push( + `package.json bin.emulsify must be dist/index.js; received ${String( + emulsifyBin, + )}`, + ); + } + + const binEntry = files.find((file) => file.path === emulsifyBin); + if ( + binEntry && + (!Number.isInteger(binEntry.mode) || (binEntry.mode & 0o111) === 0) + ) { + issues.push( + `${emulsifyBin} is not executable in the package (mode ${String( + binEntry.mode, + )})`, + ); + } + + if (emulsifyBin) { + try { + const binSource = readFileSync( + resolve(repositoryRoot, emulsifyBin), + 'utf8', + ); + if (!binSource.startsWith('#!/usr/bin/env node\n')) { + issues.push( + `${emulsifyBin} does not begin with the expected Node.js shebang`, + ); + } + } catch (error) { + issues.push( + `unable to read built bin ${emulsifyBin}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + } + + if (issues.length > 0) { + throw new Error( + `Package content verification failed:\n${issues + .map((issue) => `- ${issue}`) + .join('\n')}`, + ); + } + + return { files, paths }; +} + +function formatBytes(bytes) { + if (!Number.isFinite(bytes)) { + return 'unknown size'; + } + + if (bytes < 1024) { + return `${bytes} B`; + } + + return `${(bytes / 1024).toFixed(1)} KiB`; +} + +export function printPackageSummary(packResult, label) { + process.stdout.write( + `${label}: ${packResult.entryCount} files, ${formatBytes( + packResult.size, + )} packed, ${formatBytes(packResult.unpackedSize)} unpacked\n`, + ); +} + +export function assertCommandOutput(result, expected, label) { + if (!result.stdout.includes(expected)) { + printCommandDiagnostics( + result, + `${label} succeeded but its output was unexpected`, + ); + throw new Error( + `${label} output did not include ${JSON.stringify(expected)}`, + ); + } +} diff --git a/scripts/smoke-pack.mjs b/scripts/smoke-pack.mjs new file mode 100644 index 0000000..ab49189 --- /dev/null +++ b/scripts/smoke-pack.mjs @@ -0,0 +1,212 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { + assertCommandOutput, + assertPackageContents, + packPackage, + packageManifest, + printPackageSummary, + reportError, + requiredPackagePaths, + runCommand, + runNpm, +} from './package-helpers.mjs'; + +function assertCondition(condition, message) { + if (!condition) { + throw new Error(message); + } +} + +function readJson(path) { + return JSON.parse(readFileSync(path, 'utf8')); +} + +function smokeTest(tempRoot) { + const tarballDirectory = join(tempRoot, 'tarballs'); + const projectDirectory = join(tempRoot, 'project'); + mkdirSync(tarballDirectory); + mkdirSync(projectDirectory); + + writeFileSync( + join(projectDirectory, 'package.json'), + `${JSON.stringify( + { + name: 'emulsify-cli-packed-smoke-test', + version: '0.0.0', + private: true, + }, + null, + 2, + )}\n`, + ); + + const { packResult } = packPackage({ packDestination: tarballDirectory }); + assertPackageContents(packResult); + + assertCondition( + typeof packResult.filename === 'string' && packResult.filename.length > 0, + 'npm pack JSON did not include a tarball filename', + ); + + const tarballPath = resolve(tarballDirectory, packResult.filename); + assertCondition( + existsSync(tarballPath), + `npm pack did not create ${tarballPath}`, + ); + + runNpm( + [ + 'install', + '--no-audit', + '--no-fund', + '--no-package-lock', + '--save=false', + tarballPath, + ], + { cwd: projectDirectory }, + ); + + const installedPackageRoot = join( + projectDirectory, + 'node_modules', + ...packageManifest.name.split('/'), + ); + const installedManifestPath = join(installedPackageRoot, 'package.json'); + assertCondition( + existsSync(installedManifestPath), + `Installed package manifest is missing: ${installedManifestPath}`, + ); + + const installedManifest = readJson(installedManifestPath); + assertCondition( + installedManifest.name === packageManifest.name, + `Installed package name is ${String( + installedManifest.name, + )}; expected ${packageManifest.name}`, + ); + assertCondition( + installedManifest.version === packageManifest.version, + `Installed package version is ${String( + installedManifest.version, + )}; expected ${packageManifest.version}`, + ); + assertCondition( + installedManifest.bin && + typeof installedManifest.bin === 'object' && + installedManifest.bin.emulsify === 'dist/index.js', + 'Installed package does not map the emulsify bin to dist/index.js', + ); + + const missingInstalledPaths = requiredPackagePaths.filter( + (path) => !existsSync(join(installedPackageRoot, path)), + ); + assertCondition( + missingInstalledPaths.length === 0, + `Installed package is missing: ${missingInstalledPaths.sort().join(', ')}`, + ); + + const installedBinPath = join( + installedPackageRoot, + installedManifest.bin.emulsify, + ); + const installedBinSource = readFileSync(installedBinPath, 'utf8'); + assertCondition( + installedBinSource.startsWith('#!/usr/bin/env node\n'), + 'Installed dist/index.js does not preserve the Node.js shebang', + ); + + const forbiddenInstalledPaths = [ + 'dist/node_modules', + 'dist/package.json', + 'dist/package-lock.json', + 'dist/scripts', + 'src', + 'coverage', + '.nyc_output', + '.cache', + '.npm', + ].filter((path) => existsSync(join(installedPackageRoot, path))); + assertCondition( + forbiddenInstalledPaths.length === 0, + `Installed package contains forbidden paths: ${forbiddenInstalledPaths + .sort() + .join(', ')}`, + ); + + const localBin = join( + projectDirectory, + 'node_modules', + '.bin', + process.platform === 'win32' ? 'emulsify.cmd' : 'emulsify', + ); + assertCondition( + existsSync(localBin), + `npm did not create the installed emulsify shim: ${localBin}`, + ); + + const commandOptions = { + cwd: projectDirectory, + shell: process.platform === 'win32', + }; + const helpResult = runCommand(localBin, ['--help'], commandOptions); + assertCommandOutput( + helpResult, + `Emulsify CLI ${packageManifest.version}`, + 'emulsify --help', + ); + assertCommandOutput(helpResult, 'Usage:', 'emulsify --help'); + + const versionResult = runCommand(localBin, ['--version'], commandOptions); + assertCommandOutput( + versionResult, + `Version: ${packageManifest.version}`, + 'emulsify --version', + ); + + printPackageSummary(packResult, 'Packed install smoke test passed'); +} + +const tempRoot = mkdtempSync(join(tmpdir(), 'emulsify-cli-pack-')); +let smokeError; +let cleanupError; + +try { + smokeTest(tempRoot); +} catch (error) { + smokeError = error; +} finally { + try { + rmSync(tempRoot, { + recursive: true, + force: true, + maxRetries: 3, + retryDelay: 100, + }); + } catch (error) { + cleanupError = error; + } +} + +if (smokeError) { + reportError('Packed install smoke test failed', smokeError); +} + +if (cleanupError) { + reportError( + `Packed install smoke test could not clean ${tempRoot}`, + cleanupError, + ); +} + +if (smokeError || cleanupError) { + process.exitCode = 1; +} diff --git a/tsconfig.dist.json b/tsconfig.dist.json index 0a11719..41ec6bd 100644 --- a/tsconfig.dist.json +++ b/tsconfig.dist.json @@ -1,4 +1,4 @@ { "extends": "./tsconfig.json", - "exclude": ["src/**/*.test.ts"] + "exclude": ["src/**/*.test.ts", "src/scripts/**/*.ts"] } From 1734e83fdcd5e516b7ddd0b0f9a6852fe2a097fd Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Sun, 19 Jul 2026 19:44:28 -0500 Subject: [PATCH 08/27] ci(release): verify CLI package and release calculation --- .github/workflows/ci.yml | 10 +- .github/workflows/develop-version-bump.yml | 36 +- .github/workflows/publish.yml | 71 +++- .github/workflows/test.yml | 28 -- config/release-analysis.cjs | 17 + docs/development.md | 17 +- docs/release.md | 179 ++++++--- package-lock.json | 4 +- package.json | 10 +- release.config.cjs | 25 +- scripts/verify-release-analysis.mjs | 314 ++++++++++++++++ scripts/verify-release-analysis.test.mjs | 245 +++++++++++++ scripts/version-develop.mjs | 370 +++++++++++++++++++ scripts/version-develop.test.mjs | 400 +++++++++++++++++++++ src/scripts/versionDevelop.ts | 234 ------------ 15 files changed, 1598 insertions(+), 362 deletions(-) delete mode 100644 .github/workflows/test.yml create mode 100644 config/release-analysis.cjs create mode 100644 scripts/verify-release-analysis.mjs create mode 100644 scripts/verify-release-analysis.test.mjs create mode 100644 scripts/version-develop.mjs create mode 100644 scripts/version-develop.test.mjs delete mode 100644 src/scripts/versionDevelop.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eec1adb..75eb704 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,6 +2,7 @@ name: CI on: pull_request: + branches: [develop, main] push: branches: [develop, main] @@ -20,7 +21,7 @@ jobs: - name: Install Node.js uses: actions/setup-node@v4 with: - node-version: '24.x' + node-version-file: '.nvmrc' cache: npm - name: Install run: npm ci @@ -34,3 +35,10 @@ jobs: run: npm run pack:dry-run - name: Smoke test packed install run: npm run smoke:pack + - name: Verify release calculation + if: github.event_name == 'pull_request' && github.base_ref == 'main' + env: + RELEASE_FROM: ${{ github.event.pull_request.base.sha }} + RELEASE_TO: HEAD + RELEASE_SQUASH_MESSAGE: ${{ github.event.pull_request.title }} + run: npm run release:analyze diff --git a/.github/workflows/develop-version-bump.yml b/.github/workflows/develop-version-bump.yml index 3fd2f33..c75a7e2 100644 --- a/.github/workflows/develop-version-bump.yml +++ b/.github/workflows/develop-version-bump.yml @@ -7,6 +7,7 @@ on: permissions: contents: write + pull-requests: write concurrency: group: develop-version-bump @@ -25,13 +26,15 @@ jobs: - name: Install Node.js uses: actions/setup-node@v4 with: - node-version: '24.x' + node-version-file: '.nvmrc' cache: 'npm' - name: Install run: npm ci - name: Update version from semantic commits - run: npm run version:develop -- "${{ github.event.before }}" "${{ github.sha }}" - - name: Commit package version + run: npm run version:develop -- "origin/main" "${{ github.sha }}" + - name: Open package version pull request + env: + GH_TOKEN: ${{ github.token }} run: | if git diff --quiet -- package.json package-lock.json; then echo "No package version changes to commit." @@ -39,9 +42,32 @@ jobs: fi NEXT_VERSION="$(node -p "require('./package.json').version")" + BUMP_BRANCH="chore/develop-version-bump" + PR_TITLE="chore(release): bump version to ${NEXT_VERSION}" + PR_BODY="Updates package metadata to ${NEXT_VERSION} after semantic commits landed on develop. + + Triggering commit: ${{ github.sha }}" git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git switch -c "${BUMP_BRANCH}" git add package.json package-lock.json - git commit -m "chore(release): bump version to ${NEXT_VERSION} [skip ci]" - git push origin HEAD:develop + git commit -m "${PR_TITLE}" + git push --force-with-lease origin HEAD:"${BUMP_BRANCH}" + + EXISTING_PR="$( + gh pr list \ + --base develop \ + --head "${BUMP_BRANCH}" \ + --state open \ + --json number \ + --jq '.[0].number // empty' + )" + + if [ -n "${EXISTING_PR}" ]; then + gh pr edit "${EXISTING_PR}" --title "${PR_TITLE}" --body "${PR_BODY}" + echo "Updated version bump PR #${EXISTING_PR}." + exit 0 + fi + + gh pr create --base develop --head "${BUMP_BRANCH}" --title "${PR_TITLE}" --body "${PR_BODY}" diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 0b0042a..918e596 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,35 +1,86 @@ -# Publishes the npm package and GitHub release metadata when changes land on main. +# Validates and publishes the exact commit that lands on main. name: Publish on: push: branches: [main] + permissions: - contents: write - issues: write - pull-requests: write - id-token: write + contents: read + +concurrency: + group: publish-${{ github.ref }} + cancel-in-progress: true + jobs: + validate: + name: Validate Main Commit + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ github.sha }} + - name: Install Node.js + uses: actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + cache: 'npm' + - name: Install + run: npm ci + - name: Verify release readiness + run: npm run release:verify + release: name: Publish Package + needs: validate runs-on: ubuntu-latest + permissions: + contents: write + issues: write + pull-requests: write + id-token: write steps: - name: Checkout uses: actions/checkout@v4 with: fetch-depth: 0 + ref: ${{ github.sha }} - name: Install Node.js uses: actions/setup-node@v4 with: - node-version: '24.x' + node-version-file: '.nvmrc' cache: 'npm' - name: Install run: npm ci - name: Build run: npm run build - - name: Verify package contents - run: npm run pack:dry-run - - name: Smoke test packed install - run: npm run smoke:pack + - name: Confirm validated commit is current main + env: + VALIDATED_SHA: ${{ github.sha }} + run: | + git fetch --no-tags origin "+refs/heads/main:refs/remotes/origin/main" + CURRENT_MAIN="$(git rev-parse refs/remotes/origin/main)" + if [ "${CURRENT_MAIN}" != "${VALIDATED_SHA}" ]; then + echo "Refusing to publish stale commit ${VALIDATED_SHA}; origin/main is ${CURRENT_MAIN}." + exit 1 + fi + - name: Verify semantic-release calculation + # Dry-run mode verifies the trusted main history without publishing. + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + run: npm run semantic-release -- --dry-run + - name: Reconfirm validated commit is current main + env: + VALIDATED_SHA: ${{ github.sha }} + run: | + git fetch --no-tags origin "+refs/heads/main:refs/remotes/origin/main" + CURRENT_MAIN="$(git rev-parse refs/remotes/origin/main)" + if [ "${CURRENT_MAIN}" != "${VALIDATED_SHA}" ]; then + echo "Refusing to publish stale commit ${VALIDATED_SHA}; origin/main is ${CURRENT_MAIN}." + exit 1 + fi - name: Publish # Semantic Release reads commit history and publishing tokens from CI. env: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml deleted file mode 100644 index cd5d01b..0000000 --- a/.github/workflows/test.yml +++ /dev/null @@ -1,28 +0,0 @@ -name: Test -on: - push: - branches: [main, develop] - pull_request: - branches: [main, develop] -jobs: - build: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - name: Install Node.js - uses: actions/setup-node@v4 - with: - node-version: 24 - cache: 'npm' - - name: Install - run: npm ci - # ts-jest only typechecks imported files, so full tsc and build are the handler regression gate before tests. - - name: Typecheck - run: npm run type - - name: Build - run: npm run build - - name: Test - run: npm run test diff --git a/config/release-analysis.cjs b/config/release-analysis.cjs new file mode 100644 index 0000000..019dc48 --- /dev/null +++ b/config/release-analysis.cjs @@ -0,0 +1,17 @@ +/** + * @file Shared conventional-commit analysis options for release automation. + */ + +const parserOpts = { + noteKeywords: ['BREAKING CHANGE', 'BREAKING CHANGES', 'BREAKING'], +}; + +const commitAnalyzerOptions = { + preset: 'angular', + parserOpts, +}; + +module.exports = { + commitAnalyzerOptions, + parserOpts, +}; diff --git a/docs/development.md b/docs/development.md index ac82bd1..fb5ac58 100644 --- a/docs/development.md +++ b/docs/development.md @@ -28,9 +28,10 @@ emulsify --help | `src/handlers/hofs` | Shared handler wrappers, including progress and system-loading helpers. | | `src/lib` | Logging, constants, and CLI error primitives. | | `src/schemas` | JSON Schemas for project, system, and variant configuration. | -| `src/scripts` | Maintenance scripts, including schema type generation and develop version bumps. | +| `src/scripts` | TypeScript maintenance scripts, including schema type generation. | | `src/types` | Type modules. Generated schema types are prefixed with `_`. | | `src/util` | Platform detection, cache utilities, filesystem helpers, component generation, and project config utilities. | +| `scripts` | Package verification and release-calculation scripts, plus their regression tests. | ## Scripts @@ -45,17 +46,21 @@ emulsify --help | `npm run watch` | Rebuild when files in `src` change. | | `npm run watch-ts` | Recompile TypeScript when files in `src` change. | | `npm run format` | Run Prettier on source TypeScript and JavaScript files. | -| `npm run lint` | Runs the Jest test suite through `npm run test`. | -| `npm run test` | Run Jest with coverage. | +| `npm run lint` | Run the complete test suite through `npm run test`. | +| `npm run test` | Run Jest coverage and release-automation regression tests. | +| `npm run test:unit` | Run the Jest unit suite with coverage. | +| `npm run test:release` | Run the release-calculation integration tests with Node's test runner. | | `npm run type` | Run TypeScript checking without emitting files. | | `npm run twatch` | Run Jest without coverage in watch mode. | -| `npm run version:develop` | Update package metadata from semantic commits on `develop`. | -| `npm run semantic-release` | Publish through semantic-release using `release.config.cjs`. | +| `npm run version:develop` | Update package metadata from the complete unreleased conventional-commit history. | +| `npm run release:analyze` | Verify the predicted release without invoking publishing plugins. | +| `npm run release:verify` | Run every build, test, package, smoke, and release-analysis gate used before publication. | +| `npm run semantic-release` | Publish through semantic-release; use `-- --dry-run` unless publication is explicitly intended. | Run one test file by passing the path after `--`: ```bash -npm run test -- src/handlers/componentCreate.test.ts +npm run test:unit -- src/handlers/componentCreate.test.ts ``` ## Generated Types diff --git a/docs/release.md b/docs/release.md index 00a4007..c695198 100644 --- a/docs/release.md +++ b/docs/release.md @@ -1,19 +1,37 @@ # Release -This project publishes to npm with GitHub Actions and semantic-release. - -## Branches - -| Branch | Behavior | -| --------- | --------------------------------------------------------------------------------------------------------- | -| `develop` | CI validates changes. A workflow may update `package.json` and `package-lock.json` from semantic commits. | -| `main` | CI validates changes and the publish workflow runs semantic-release. | - -## CI Checks - -The workflows use Node.js 24 and run the project checks from a clean install. - -Typical validation includes: +This project publishes `@emulsify/cli` with GitHub Actions and +semantic-release. Do not run a real semantic-release invocation from a local +checkout as part of routine verification. + +## Branch Strategy + +| Branch | Purpose | +| ------------------------ | ----------------------------------------------------------------------------------------------------------------------------- | +| Feature and fix branches | Open pull requests into `develop`. Read-only CI validates the prospective merge. | +| `develop` | Integration branch. Package metadata reflects the complete unreleased history since the latest stable release on `main`. | +| `main` | Release branch. Merging to `main` explicitly authorizes the exact merged commit for publication after all release gates pass. | + +Pushes to `develop` run the develop-version workflow. It finds the latest +stable `vX.Y.Z` tag reachable from `origin/main`, analyzes that tag's complete +unreleased range through the pushed commit, and opens or updates the dedicated +`chore/develop-version-bump` pull request when package metadata needs to change. +The calculation always starts from the stable tag, not the version already in +`package.json`, so a feature followed by later fixes remains one minor release. + +The repository must allow GitHub Actions to create pull requests for the +version-bump workflow. Because the pull request is created or updated with the +repository `GITHUB_TOKEN`, GitHub starts its `CI / Validate` run in an +approval-required state. A maintainer with write access must select +**Approve workflows to run**, then wait for that exact head commit to pass +before merging. Configure a GitHub App or personal access token for the bump +workflow instead if unattended validation is required. + +## Required Validation + +The single read-only `CI / Validate` job runs for pull requests targeting +`develop` or `main` and for pushes to either branch. It installs from the +lockfile and runs: ```bash npm ci @@ -24,68 +42,115 @@ npm run pack:dry-run npm run smoke:pack ``` -`pack:dry-run` uses npm's structured package manifest to require the CLI runtime -files and reject source tests, coverage, caches, package locks, and nested -`node_modules`. `smoke:pack` creates the real tarball, installs it with its -declared runtime dependencies in a clean temporary project, and runs -`emulsify --help` and `emulsify --version`. Both commands require a completed -build. - -There are currently both `ci.yml` and `test.yml` workflows that validate pull requests and pushes. +`npm run test` includes both the Jest suite and the release-automation +regression tests. `pack:dry-run` asserts npm's structured package manifest. +`smoke:pack` creates the real tarball, installs it with its declared runtime +dependencies in a clean temporary project, and runs `emulsify --help` and +`emulsify --version`. -## Develop Version Bump +Repository branch protection should require `CI / Validate`. Remove any +required-check reference to the retired duplicate `Test / build` job. -Pushes to `develop` run: +The CI workflow has only `contents: read` permission. It does not receive npm or +GitHub publishing credentials and cannot publish, push a release tag, or create +a GitHub release. -```bash -npm run version:develop -- -``` +## Release Calculation -If semantic commits imply a package version change, the workflow commits updated package metadata back to `develop` with: +The develop bumper, the safe analyzer, and semantic-release all import the same +Angular conventional-commit options from `config/release-analysis.cjs`. +Breaking releases require an explicit footer using one of: ```text -chore(release): bump version to [skip ci] +BREAKING CHANGE: +BREAKING CHANGES: +BREAKING: ``` -## Publishing +Run the non-publishing analyzer with: -Pushes to `main` run the `Publish` workflow: +```bash +npm run release:analyze +npm run release:analyze -- --base origin/main --head HEAD +``` -1. Check out full Git history. -2. Install Node.js 24. -3. Run `npm ci`. -4. Run `npm run build`. -5. Run `npm run pack:dry-run`. -6. Run `npm run smoke:pack`. -7. Run `npm run semantic-release`. +The analyzer calls only the conventional-commit analyzer. It reads Git history, +reports the predicted release type and version, and verifies package metadata. +It does not invoke npm, GitHub, or other publishing plugins; it does not change +files, create tags, or publish. -semantic-release is configured in `release.config.cjs` with: +The mutating develop command is: -| Plugin | Purpose | -| ------------------------------------------- | ------------------------------------------------- | -| `@semantic-release/commit-analyzer` | Determine the release type from semantic commits. | -| `@semantic-release/release-notes-generator` | Generate release notes. | -| `@semantic-release/npm` | Publish the package to npm. | -| `@semantic-release/github` | Publish GitHub release metadata. | +```bash +npm run version:develop -- origin/main HEAD +``` -The publish workflow uses `GITHUB_TOKEN` and `NPM_TOKEN` from GitHub Actions. +It updates `package.json`, the lockfile version, and the lockfile root-package +version to the single prediction calculated from stable release history. +Running it again against unchanged history produces no diff. -## Commit Conventions +## Develop-to-Main Merge Expectations -The release config uses the Angular preset. Breaking changes are detected from: +The established release strategy uses GitHub's **Create a merge commit** option +for the `develop` pull request into `main`. This preserves the individual +conventional commits that semantic-release analyzes. + +CI also evaluates the pull request title as a prospective squash commit. If +squash merging is used, the title must itself produce a release and must +preserve the release type predicted from the full prospective merge range. For +example: ```text -BREAKING CHANGE -BREAKING CHANGES -BREAKING +fix(release): prepare CLI patch release +feat(release): prepare CLI minor release ``` -Example release-driving commits: +A title such as `chore(release): prepare release` or +`ci(release): verify release` does not produce a semantic release. Use the +merge-commit strategy for breaking releases so the explicit breaking footer is +retained. -```text -fix(component): preserve dependency install order -feat(init): support non-interactive defaults -feat(system)!: require explicit custom checkouts +Merging to `main` is not merely an integration action: it starts the trusted +publication workflow. Do not merge until the intended version, release notes, +package contents, and merge strategy are ready to publish. + +## Validated Main Publication + +The `Publish` workflow runs only for a push to `main` and uses two jobs: + +1. `Validate Main Commit` checks out the event's exact `github.sha`, receives + only read permission, and runs `npm run release:verify`. That aggregate runs + the build, typecheck, all tests, package-content assertions, installed + tarball smoke test, and safe release analysis. +2. `Publish Package` waits for that exact-SHA validation. It alone receives + release permissions and credentials, checks out the same SHA, rebuilds + `dist`, and refuses to continue unless `origin/main` still points to the + validated commit. +3. The release job runs authenticated semantic-release with `--dry-run`, checks + `origin/main` again, and only then runs the real semantic-release command. + +Publish runs share a concurrency group, so a newer `main` push supersedes an +older run. The explicit SHA checks also prevent a stale rerun from publishing an +older commit. -BREAKING CHANGE: custom system installs now require --checkout. +semantic-release dry-run mode still verifies repository and registry +credentials, so it belongs only in the trusted post-merge release job. A +maintainer can repeat that check from the current trusted `main` commit with: + +```bash +GITHUB_TOKEN="$GITHUB_TOKEN" NPM_TOKEN="$NPM_TOKEN" \ + npm run semantic-release -- --dry-run ``` + +Pull-request CI uses `npm run release:analyze` instead and never receives those +credentials. Do not run `npm run semantic-release` without `--dry-run` unless +the maintainers intend to publish. + +## semantic-release Plugins + +| Plugin | Purpose | +| ------------------------------------------- | --------------------------------------------------------------------- | +| `@semantic-release/commit-analyzer` | Determine the release type from the shared conventional-commit rules. | +| `@semantic-release/release-notes-generator` | Generate release notes with the same parser options. | +| `@semantic-release/npm` | Publish the package to npm. | +| `@semantic-release/github` | Create GitHub release metadata. | diff --git a/package-lock.json b/package-lock.json index ce0e62f..62a59e5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@emulsify/cli", - "version": "2.2.0", + "version": "2.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@emulsify/cli", - "version": "2.2.0", + "version": "2.3.0", "license": "GPL-2.0", "dependencies": { "@inquirer/prompts": "^8.5.2", diff --git a/package.json b/package.json index 58b3c39..a5aa555 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@emulsify/cli", "productName": "Emulsify CLI", - "version": "2.2.0", + "version": "2.3.0", "description": "Command line interface for Emulsify", "repository": "git@github.com:emulsify-ds/emulsify-cli.git", "author": "Patrick Coffey ", @@ -33,11 +33,15 @@ "husky:commit-msg": "commitlint --edit $1", "husky:pre-commit": "lint-staged", "husky:pre-push": "npm run test", - "test": "jest --coverage --silent", + "test": "npm run test:unit && npm run test:release", + "test:unit": "jest --coverage --silent", + "test:release": "node --test ./scripts/version-develop.test.mjs ./scripts/verify-release-analysis.test.mjs", "lint": "npm run test", "type": "tsc --noEmit", "twatch": "jest --no-coverage --watch --verbose", - "version:develop": "node --import ./src/register.mjs ./src/scripts/versionDevelop.ts", + "version:develop": "node ./scripts/version-develop.mjs", + "release:analyze": "node ./scripts/verify-release-analysis.mjs", + "release:verify": "npm run build && npm run type && npm run test && npm run pack:dry-run && npm run smoke:pack && npm run release:analyze", "semantic-release": "semantic-release --config ./release.config.cjs", "pack:dry-run": "node ./scripts/pack-dry-run.mjs", "smoke:pack": "node ./scripts/smoke-pack.mjs", diff --git a/release.config.cjs b/release.config.cjs index 3081329..394a96e 100644 --- a/release.config.cjs +++ b/release.config.cjs @@ -1,31 +1,24 @@ -// release.config.cjs +const { + commitAnalyzerOptions, + parserOpts, +} = require('./config/release-analysis.cjs'); + module.exports = { branches: ['main'], - repositoryUrl: 'git@github.com:emulsify-ds/emulsify-cli.git', + repositoryUrl: 'https://github.com/emulsify-ds/emulsify-cli.git', plugins: [ - [ - '@semantic-release/commit-analyzer', - { - preset: 'angular', - parserOpts: { - noteKeywords: ['BREAKING CHANGE', 'BREAKING CHANGES', 'BREAKING'], - }, - }, - ], + ['@semantic-release/commit-analyzer', commitAnalyzerOptions], [ '@semantic-release/release-notes-generator', { preset: 'angular', - parserOpts: { - noteKeywords: ['BREAKING CHANGE', 'BREAKING CHANGES', 'BREAKING'], - }, + parserOpts, writerOpts: { commitsSort: ['subject', 'scope'], }, }, ], - '@semantic-release/npm', + ['@semantic-release/npm', { npmPublish: true }], '@semantic-release/github', ], }; - diff --git a/scripts/verify-release-analysis.mjs b/scripts/verify-release-analysis.mjs new file mode 100644 index 0000000..829ecdd --- /dev/null +++ b/scripts/verify-release-analysis.mjs @@ -0,0 +1,314 @@ +#!/usr/bin/env node + +/** + * @file Predict semantic-release output without loading publishing plugins. + */ + +import { readFileSync } from 'node:fs'; +import { basename, resolve } from 'node:path'; +import { + analyzeReleaseHistory, + analyzeReleaseType, + getCommitsInRange, +} from './version-develop.mjs'; + +/** + * Read the repository package version. + * + * @param {string} cwd - Repository root. + * @returns {string} Package version. + */ +export function readPackageVersion(cwd) { + const packageJson = JSON.parse( + readFileSync(resolve(cwd, 'package.json'), 'utf8'), + ); + + if ( + typeof packageJson.version !== 'string' || + packageJson.version.length === 0 + ) { + throw new Error('package.json must contain a version string.'); + } + + return packageJson.version; +} + +/** + * Require package metadata to equal the cumulative release prediction. + * + * @param {object} options - Assertion details. + * @param {string} options.packageVersion - Current package version. + * @param {string} options.predictedVersion - Predicted package version. + * @param {string} options.releaseType - Predicted release type. + * @param {string} options.releaseTag - Stable base tag. + * @returns {void} + */ +export function assertPackageVersion({ + packageVersion, + predictedVersion, + releaseType, + releaseTag, +}) { + if (packageVersion !== predictedVersion) { + throw new Error( + `package.json version ${packageVersion} does not match the semantic-release prediction ${predictedVersion} (${releaseType} from ${releaseTag}).`, + ); + } +} + +/** + * Require a prospective squash title to preserve the aggregate release type. + * + * @param {object} options - Assertion details. + * @param {string} options.squashTitle - Prospective squash title. + * @param {string|null} options.squashTitleReleaseType - Title-only result. + * @param {string|null} options.squashReleaseType - Base plus title result. + * @param {string} options.rangeReleaseType - Full-range result. + * @returns {void} + */ +export function assertSquashReleaseType({ + squashTitle, + squashTitleReleaseType, + squashReleaseType, + rangeReleaseType, +}) { + if (!squashTitleReleaseType) { + throw new Error( + `Squash title "${squashTitle}" would not produce a semantic release. Use a conventional title such as "feat(scope): ..." or "fix(scope): ...".`, + ); + } + + if (squashReleaseType !== rangeReleaseType) { + throw new Error( + `Squash title "${squashTitle}" predicts a ${squashReleaseType} release, but the full commit range predicts ${rangeReleaseType}.`, + ); + } +} + +/** + * Predict the release from complete history and an optional squash strategy. + * + * @param {object} options - Prediction options. + * @param {string} options.cwd - Repository root. + * @param {string} [options.base='origin/main'] - Ref used to select the tag. + * @param {string} [options.head='HEAD'] - Prospective release head. + * @param {string} [options.squashTitle] - Prospective squash commit title. + * @returns {Promise} Verified prediction. + */ +export async function predictRelease({ + cwd, + base = 'origin/main', + head = 'HEAD', + squashTitle, +}) { + const { + releaseTag, + previousVersion, + releaseType, + predictedVersion, + range, + commits, + } = await analyzeReleaseHistory({ cwd, base, head }); + + if (!releaseType || !predictedVersion) { + throw new Error( + `No semantic release is predicted from ${range}. Add a release-producing conventional commit.`, + ); + } + + const packageVersion = readPackageVersion(cwd); + assertPackageVersion({ + packageVersion, + predictedVersion, + releaseType, + releaseTag, + }); + + let squashTitleReleaseType; + let squashReleaseType; + if (squashTitle !== undefined) { + const prospectiveSquashCommit = { + hash: 'prospective-squash', + message: squashTitle, + }; + squashTitleReleaseType = await analyzeReleaseType( + [prospectiveSquashCommit], + cwd, + ); + const baseCommits = getCommitsInRange({ + cwd, + from: releaseTag, + to: base, + }); + squashReleaseType = await analyzeReleaseType( + [prospectiveSquashCommit, ...baseCommits], + cwd, + ); + assertSquashReleaseType({ + squashTitle, + squashTitleReleaseType, + squashReleaseType, + rangeReleaseType: releaseType, + }); + } + + return { + releaseTag, + previousVersion, + releaseType, + predictedVersion, + packageVersion, + range, + commitCount: commits.length, + ...(squashTitle === undefined + ? {} + : { + squashTitle, + squashTitleReleaseType, + squashReleaseType, + }), + }; +} + +/** + * Parse one required CLI option value. + * + * @param {string[]} argv - Raw CLI arguments. + * @param {number} index - Current argument index. + * @param {string} option - Option name. + * @returns {{value: string, consumed: number}} Parsed value. + */ +function readOption(argv, index, option) { + const argument = argv[index]; + const inlinePrefix = `${option}=`; + + if (argument.startsWith(inlinePrefix)) { + const value = argument.slice(inlinePrefix.length); + if (!value) { + throw new Error(`${option} requires a value.`); + } + return { value, consumed: 0 }; + } + + const value = argv[index + 1]; + if (!value || value.startsWith('--')) { + throw new Error(`${option} requires a value.`); + } + + return { value, consumed: 1 }; +} + +/** + * Parse analyzer CLI arguments with Core-compatible CI fallbacks. + * + * @param {string[]} argv - Raw CLI arguments. + * @param {NodeJS.ProcessEnv|object} [env=process.env] - Environment values. + * @returns {object} Analyzer options. + */ +export function parseArgs(argv, env = process.env) { + const parsed = { + base: env.RELEASE_FROM || env.RELEASE_BASE || 'origin/main', + head: env.RELEASE_TO || env.RELEASE_HEAD || 'HEAD', + squashTitle: + env.RELEASE_SQUASH_MESSAGE ?? env.RELEASE_SQUASH_TITLE ?? undefined, + help: false, + }; + + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + + if (argument === '--help' || argument === '-h') { + parsed.help = true; + continue; + } + + const option = ['--base', '--head', '--squash-title'].find( + (candidate) => + argument === candidate || argument.startsWith(`${candidate}=`), + ); + + if (!option) { + throw new Error(`Unknown option: ${argument}`); + } + + const { value, consumed } = readOption(argv, index, option); + index += consumed; + + if (option === '--base') { + parsed.base = value; + } else if (option === '--head') { + parsed.head = value; + } else { + parsed.squashTitle = value; + } + } + + return parsed; +} + +/** + * Format CLI usage. + * + * @returns {string} Usage text. + */ +export function usage() { + return [ + 'Usage: node scripts/verify-release-analysis.mjs [options]', + '', + 'Options:', + ' --base Ref used to find the latest stable release tag.', + ' --head Prospective release head. Defaults to HEAD.', + ' --squash-title Require a squash title to preserve the release type.', + ' --help Print this help text.', + ].join('\n'); +} + +/** + * Run the non-publishing release predictor. + * + * @param {string[]} [argv=process.argv.slice(2)] - CLI arguments. + * @param {NodeJS.ProcessEnv|object} [env=process.env] - Environment values. + * @param {string} [cwd=process.cwd()] - Repository root. + * @returns {Promise<number>} Process exit code. + */ +export async function runCli( + argv = process.argv.slice(2), + env = process.env, + cwd = process.cwd(), +) { + try { + const options = parseArgs(argv, env); + + if (options.help) { + console.log(usage()); + return 0; + } + + const prediction = await predictRelease({ + cwd, + base: options.base, + head: options.head, + squashTitle: options.squashTitle, + }); + + console.log( + `semantic-release predicts ${prediction.releaseType}: ${prediction.releaseTag} -> v${prediction.predictedVersion} from ${prediction.commitCount} commits.`, + ); + if (prediction.squashReleaseType) { + console.log( + `Squash title preserves the ${prediction.squashReleaseType} release.`, + ); + } + + return 0; + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + return 1; + } +} + +if (basename(process.argv[1] || '') === 'verify-release-analysis.mjs') { + runCli().then((exitCode) => { + process.exitCode = exitCode; + }); +} diff --git a/scripts/verify-release-analysis.test.mjs b/scripts/verify-release-analysis.test.mjs new file mode 100644 index 0000000..b6c1e99 --- /dev/null +++ b/scripts/verify-release-analysis.test.mjs @@ -0,0 +1,245 @@ +/** + * @file Tests for safe, non-publishing release prediction. + */ + +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; +import { + assertPackageVersion, + assertSquashReleaseType, + parseArgs, + predictRelease, +} from './verify-release-analysis.mjs'; + +function git(cwd, args) { + return execFileSync('git', args, { + cwd, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }).trim(); +} + +function writePackageMetadata(cwd, version) { + writeFileSync( + join(cwd, 'package.json'), + `${JSON.stringify( + { + name: 'emulsify-cli-release-verifier', + version, + }, + null, + 2, + )}\n`, + ); + writeFileSync( + join(cwd, 'package-lock.json'), + `${JSON.stringify( + { + name: 'emulsify-cli-release-verifier', + version, + lockfileVersion: 3, + packages: { + '': { + name: 'emulsify-cli-release-verifier', + version, + }, + }, + }, + null, + 2, + )}\n`, + ); +} + +function commitAll(cwd, message) { + git(cwd, ['add', '.']); + git(cwd, ['commit', '-m', message]); +} + +function createReleaseRepository(t) { + const cwd = mkdtempSync(join(tmpdir(), 'emulsify-cli-verifier-')); + const repository = { + cwd, + changeIndex: 0, + }; + + t.after(() => { + rmSync(cwd, { recursive: true, force: true }); + }); + + git(cwd, ['init', '--initial-branch=main']); + git(cwd, ['config', 'user.name', 'Release Test']); + git(cwd, ['config', 'user.email', 'release-test@example.com']); + writePackageMetadata(cwd, '1.0.0'); + commitAll(cwd, 'chore: establish release fixture'); + git(cwd, ['tag', 'v1.0.0']); + git(cwd, ['branch', 'release-base']); + + return repository; +} + +function addCommit(repository, message) { + repository.changeIndex += 1; + const filename = `change-${String(repository.changeIndex).padStart(3, '0')}.txt`; + writeFileSync(join(repository.cwd, filename), `${message}\n`); + commitAll(repository.cwd, message); +} + +test('safe analyzer accepts an equivalent squash title', async (t) => { + const repository = createReleaseRepository(t); + addCommit(repository, 'feat(cli): add public command'); + writePackageMetadata(repository.cwd, '1.1.0'); + commitAll(repository.cwd, 'chore(release): pre-bump minor version'); + + const prediction = await predictRelease({ + cwd: repository.cwd, + base: 'release-base', + squashTitle: 'feat(release): add public command', + }); + + assert.equal(prediction.releaseType, 'minor'); + assert.equal(prediction.predictedVersion, '1.1.0'); + assert.equal(prediction.squashTitleReleaseType, 'minor'); + assert.equal(prediction.squashReleaseType, 'minor'); +}); + +test('safe analyzer rejects package version mismatch', async (t) => { + const repository = createReleaseRepository(t); + addCommit(repository, 'feat(cli): add public command'); + + await assert.rejects( + predictRelease({ + cwd: repository.cwd, + base: 'release-base', + }), + /package\.json version 1\.0\.0 does not match.*1\.1\.0/, + ); +}); + +test('safe analyzer rejects non-release and mismatched squash titles', async (t) => { + const repository = createReleaseRepository(t); + addCommit(repository, 'feat(cli): add public command'); + writePackageMetadata(repository.cwd, '1.1.0'); + + await assert.rejects( + predictRelease({ + cwd: repository.cwd, + base: 'release-base', + squashTitle: 'chore(release): prepare command', + }), + /would not produce a semantic release/, + ); + await assert.rejects( + predictRelease({ + cwd: repository.cwd, + base: 'release-base', + squashTitle: 'fix(release): prepare command', + }), + /predicts a patch release, but the full commit range predicts minor/, + ); +}); + +test('prospective squash analysis includes release-producing base commits', async (t) => { + const repository = createReleaseRepository(t); + + git(repository.cwd, ['switch', 'release-base']); + addCommit(repository, 'feat(cli): add base command'); + + git(repository.cwd, ['switch', 'main']); + addCommit(repository, 'fix(cli): repair prospective command'); + git(repository.cwd, [ + 'merge', + '--no-ff', + 'release-base', + '-m', + 'Merge release base into prospective head', + ]); + writePackageMetadata(repository.cwd, '1.1.0'); + commitAll(repository.cwd, 'chore(release): pre-bump minor version'); + + const prediction = await predictRelease({ + cwd: repository.cwd, + base: 'release-base', + head: 'main', + squashTitle: 'fix(release): repair prospective command', + }); + + assert.equal(prediction.releaseType, 'minor'); + assert.equal(prediction.squashTitleReleaseType, 'patch'); + assert.equal(prediction.squashReleaseType, 'minor'); +}); + +test('safe analyzer rejects history without a release-producing commit', async (t) => { + const repository = createReleaseRepository(t); + addCommit(repository, 'docs: clarify command usage'); + + await assert.rejects( + predictRelease({ + cwd: repository.cwd, + base: 'release-base', + }), + /No semantic release is predicted/, + ); +}); + +test('analyzer CLI arguments use Core-compatible environment fallbacks', () => { + const env = { + RELEASE_FROM: 'base-from-env', + RELEASE_TO: 'head-from-env', + RELEASE_SQUASH_MESSAGE: 'feat(release): from environment', + }; + + assert.deepEqual(parseArgs([], env), { + base: 'base-from-env', + head: 'head-from-env', + squashTitle: 'feat(release): from environment', + help: false, + }); + assert.deepEqual( + parseArgs( + [ + '--base=base-from-cli', + '--head', + 'head-from-cli', + '--squash-title', + 'fix(release): from CLI', + ], + env, + ), + { + base: 'base-from-cli', + head: 'head-from-cli', + squashTitle: 'fix(release): from CLI', + help: false, + }, + ); + assert.throws(() => parseArgs(['--unknown'], env), /Unknown option/); + assert.throws(() => parseArgs(['--head'], env), /requires a value/); +}); + +test('assertion helpers report actionable version and squash failures', () => { + assert.throws( + () => + assertPackageVersion({ + packageVersion: '2.2.0', + predictedVersion: '2.3.0', + releaseType: 'minor', + releaseTag: 'v2.2.0', + }), + /semantic-release prediction 2\.3\.0/, + ); + assert.throws( + () => + assertSquashReleaseType({ + squashTitle: 'chore: prepare release', + squashTitleReleaseType: null, + squashReleaseType: null, + rangeReleaseType: 'minor', + }), + /would not produce a semantic release/, + ); +}); diff --git a/scripts/version-develop.mjs b/scripts/version-develop.mjs new file mode 100644 index 0000000..cc87e8b --- /dev/null +++ b/scripts/version-develop.mjs @@ -0,0 +1,370 @@ +#!/usr/bin/env node + +/** + * @file Update package metadata from complete unreleased semantic history. + */ + +import { execFileSync } from 'node:child_process'; +import { readFileSync, writeFileSync } from 'node:fs'; +import { basename, resolve } from 'node:path'; +import { analyzeCommits } from '@semantic-release/commit-analyzer'; +import releaseAnalysisConfig from '../config/release-analysis.cjs'; + +const RELEASE_TAG = /^v((?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*))$/; +const VERSION = /^(\d+)\.(\d+)\.(\d+)(?:-.+)?$/; +const RELEASE_TYPES = new Set(['major', 'minor', 'patch']); +const { commitAnalyzerOptions } = releaseAnalysisConfig; +const analyzerLogger = { + log: () => {}, +}; + +/** + * Run git and preserve its diagnostics when it fails. + * + * @param {string} cwd - Repository root. + * @param {string[]} args - Git arguments. + * @returns {string} Standard output. + */ +function git(cwd, args) { + try { + return execFileSync('git', args, { + cwd, + encoding: 'utf8', + maxBuffer: 10 * 1024 * 1024, + stdio: ['ignore', 'pipe', 'pipe'], + }); + } catch (error) { + const stderr = + error && typeof error === 'object' && 'stderr' in error + ? String(error.stderr).trim() + : ''; + const detail = stderr ? `: ${stderr}` : ''; + throw new Error(`git ${args.join(' ')} failed${detail}`, { + cause: error, + }); + } +} + +/** + * Parse git log records containing a hash and complete commit message. + * + * @param {string} output - Git log output using record and field separators. + * @returns {{hash: string, message: string}[]} Semantic-release commits. + */ +export function parseGitLog(output) { + return output + .split('\x1e') + .map((record) => record.trim()) + .filter(Boolean) + .map((record) => { + const separator = record.indexOf('\0'); + + if (separator < 1) { + return null; + } + + return { + hash: record.slice(0, separator).trim(), + message: record.slice(separator + 1).trim(), + }; + }) + .filter( + (commit) => + commit !== null && Boolean(commit.hash) && Boolean(commit.message), + ); +} + +/** + * Parse a stable semantic-release tag. + * + * @param {string} tag - Candidate tag. + * @returns {string} Version without its v prefix. + */ +export function parseReleaseTag(tag) { + const match = RELEASE_TAG.exec(String(tag).trim()); + + if (!match) { + throw new Error( + `Unsupported release tag "${tag}". Expected a stable tag such as v2.3.0.`, + ); + } + + return match[1]; +} + +/** + * Find the highest stable release tag reachable from a ref. + * + * Git version-sorts all reachable v-prefixed tags first. Filtering with the + * strict stable-tag expression excludes prereleases and unrelated tag names. + * + * @param {{cwd: string, base?: string}} options - Git selection options. + * @returns {string} Highest reachable stable release tag. + */ +export function getLatestReleaseTag({ cwd, base = 'origin/main' }) { + const output = git(cwd, [ + 'tag', + '--merged', + base, + '--list', + 'v[0-9]*', + '--sort=-version:refname', + ]); + const releaseTag = output + .split('\n') + .map((tag) => tag.trim()) + .find((tag) => RELEASE_TAG.test(tag)); + + if (!releaseTag) { + throw new Error( + `No stable semantic-release tag is reachable from "${base}".`, + ); + } + + return releaseTag; +} + +/** + * Read complete commit messages from a revision range. + * + * @param {{cwd: string, from?: string, to?: string}} options - Git range. + * @returns {{hash: string, message: string}[]} Semantic-release commits. + */ +export function getCommitsInRange({ cwd, from, to = 'HEAD' }) { + const range = from ? `${from}..${to}` : to; + const output = git(cwd, ['log', '--format=%x1e%H%x00%B', range, '--']); + + return parseGitLog(output); +} + +/** + * Analyze commits with the same public plugin and options as semantic-release. + * + * @param {{hash: string, message: string}[]} commits - Commits to analyze. + * @param {string} cwd - Repository root. + * @returns {Promise<string|null>} Release type or null. + */ +export async function analyzeReleaseType(commits, cwd) { + return analyzeCommits(commitAnalyzerOptions, { + commits, + cwd, + logger: analyzerLogger, + }); +} + +/** + * Increment a stable version by a semantic release type. + * + * @param {string} version - Stable version without a v prefix. + * @param {string} releaseType - major, minor, or patch. + * @returns {string} Incremented stable version. + */ +export function incrementVersion(version, releaseType) { + if (!RELEASE_TYPES.has(releaseType)) { + throw new Error(`Unsupported release type: ${releaseType}`); + } + + const match = VERSION.exec(version); + if (!match) { + throw new Error(`Unsupported package version: ${version}`); + } + + let major = Number(match[1]); + let minor = Number(match[2]); + let patch = Number(match[3]); + + if (releaseType === 'major') { + major += 1; + minor = 0; + patch = 0; + } else if (releaseType === 'minor') { + minor += 1; + patch = 0; + } else { + patch += 1; + } + + return `${major}.${minor}.${patch}`; +} + +/** + * Analyze all unreleased commits from the base's latest stable tag to a head. + * + * @param {object} options - Analysis options. + * @param {string} options.cwd - Repository root. + * @param {string} [options.base='origin/main'] - Ref used to select the tag. + * @param {string} [options.head='HEAD'] - Prospective release head. + * @returns {Promise<object>} Release prediction and analyzed commits. + */ +export async function analyzeReleaseHistory({ + cwd, + base = 'origin/main', + head = 'HEAD', +}) { + const releaseTag = getLatestReleaseTag({ cwd, base }); + const previousVersion = parseReleaseTag(releaseTag); + const commits = getCommitsInRange({ + cwd, + from: releaseTag, + to: head, + }); + const releaseType = await analyzeReleaseType(commits, cwd); + + return { + releaseTag, + previousVersion, + releaseType, + predictedVersion: releaseType + ? incrementVersion(previousVersion, releaseType) + : null, + range: `${releaseTag}..${head}`, + commits, + }; +} + +/** + * Apply one version to package.json and every root lockfile version field. + * + * @param {object} packageJson - Parsed package manifest. + * @param {object} packageLock - Parsed package lock. + * @param {string} version - Exact predicted version. + * @returns {void} + */ +export function updatePackageVersions(packageJson, packageLock, version) { + if ( + !packageLock.packages || + typeof packageLock.packages[''] !== 'object' || + packageLock.packages[''] === null + ) { + throw new Error('package-lock.json must contain a packages[""] entry.'); + } + + packageJson.version = version; + packageLock.version = version; + packageLock.packages[''].version = version; +} + +/** + * Read JSON from disk. + * + * @param {string} path - JSON file path. + * @returns {object} Parsed JSON. + */ +function readJson(path) { + return JSON.parse(readFileSync(path, 'utf8')); +} + +/** + * Write stable, newline-terminated JSON. + * + * @param {string} path - JSON file path. + * @param {object} value - JSON value. + * @returns {void} + */ +function writeJson(path, value) { + writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`); +} + +/** + * Update package metadata from the complete cumulative release calculation. + * + * @param {object} options - Runtime options. + * @param {string} options.cwd - Repository root. + * @param {string} [options.base='origin/main'] - Ref used to select the tag. + * @param {string} [options.to='HEAD'] - Prospective release head. + * @param {{log: (...args: unknown[]) => void}} [options.logger=console] + * Output logger. + * @returns {Promise<object>} Change and release details. + */ +export async function runVersionBump({ + cwd, + base = 'origin/main', + to = 'HEAD', + logger = console, +}) { + const { releaseType, predictedVersion, releaseTag } = + await analyzeReleaseHistory({ + cwd, + base, + head: to, + }); + + if (!releaseType) { + logger.log('No semantic version bump detected.'); + return { + changed: false, + releaseType: null, + }; + } + + const packageJsonPath = resolve(cwd, 'package.json'); + const packageLockPath = resolve(cwd, 'package-lock.json'); + const packageJson = readJson(packageJsonPath); + const packageLock = readJson(packageLockPath); + const currentVersions = [ + packageJson.version, + packageLock.version, + packageLock.packages?.['']?.version, + ]; + const versionsMatch = currentVersions.every( + (version) => version === predictedVersion, + ); + + if (versionsMatch) { + logger.log( + `Package metadata already matches ${predictedVersion} (${releaseType} from ${releaseTag}).`, + ); + return { + changed: false, + releaseType, + version: predictedVersion, + }; + } + + updatePackageVersions(packageJson, packageLock, predictedVersion); + writeJson(packageJsonPath, packageJson); + writeJson(packageLockPath, packageLock); + logger.log( + `Repaired package metadata (${currentVersions.join(', ')}) to ${predictedVersion} (${releaseType} from ${releaseTag}).`, + ); + + return { + changed: true, + releaseType, + version: predictedVersion, + }; +} + +/** + * Parse the positional develop-version CLI used by automation. + * + * @param {string[]} argv - Positional base and head refs. + * @param {NodeJS.ProcessEnv|object} [env=process.env] - Environment values. + * @returns {{base: string, to: string}} Parsed refs. + */ +export function parseVersionDevelopArgs(argv, env = process.env) { + if (argv.length > 2) { + throw new Error( + 'Usage: node scripts/version-develop.mjs [base-ref] [head-ref]', + ); + } + + return { + base: argv[0] || env.RELEASE_BASE || env.RELEASE_FROM || 'origin/main', + to: + argv[1] || env.GITHUB_SHA || env.RELEASE_TO || env.RELEASE_HEAD || 'HEAD', + }; +} + +if (basename(process.argv[1] || '') === 'version-develop.mjs') { + const { base, to } = parseVersionDevelopArgs(process.argv.slice(2)); + + runVersionBump({ + cwd: process.cwd(), + base, + to, + }).catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + }); +} diff --git a/scripts/version-develop.test.mjs b/scripts/version-develop.test.mjs new file mode 100644 index 0000000..9c9c190 --- /dev/null +++ b/scripts/version-develop.test.mjs @@ -0,0 +1,400 @@ +/** + * @file Deterministic tests for non-publishing release calculation. + */ + +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; +import releaseAnalysisConfig from '../config/release-analysis.cjs'; +import { + analyzeReleaseHistory, + analyzeReleaseType, + getLatestReleaseTag, + parseGitLog, + parseReleaseTag, + parseVersionDevelopArgs, + runVersionBump, +} from './version-develop.mjs'; + +const require = createRequire(import.meta.url); +const semanticReleaseConfig = require('../release.config.cjs'); +const silentLogger = { + log: () => {}, +}; + +function git(cwd, args) { + return execFileSync('git', args, { + cwd, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }).trim(); +} + +function writePackageMetadata(cwd, packageVersion, lockVersions = {}) { + const lockVersion = lockVersions.lockVersion ?? packageVersion; + const rootVersion = lockVersions.rootVersion ?? packageVersion; + + writeFileSync( + join(cwd, 'package.json'), + `${JSON.stringify( + { + name: 'emulsify-cli-release-fixture', + version: packageVersion, + }, + null, + 2, + )}\n`, + ); + writeFileSync( + join(cwd, 'package-lock.json'), + `${JSON.stringify( + { + name: 'emulsify-cli-release-fixture', + version: lockVersion, + lockfileVersion: 3, + packages: { + '': { + name: 'emulsify-cli-release-fixture', + version: rootVersion, + }, + }, + }, + null, + 2, + )}\n`, + ); +} + +function readVersions(cwd) { + const packageJson = JSON.parse( + readFileSync(join(cwd, 'package.json'), 'utf8'), + ); + const packageLock = JSON.parse( + readFileSync(join(cwd, 'package-lock.json'), 'utf8'), + ); + + return [ + packageJson.version, + packageLock.version, + packageLock.packages[''].version, + ]; +} + +function commitAll(cwd, message) { + git(cwd, ['add', '.']); + git(cwd, ['commit', '-m', message]); +} + +function createReleaseRepository(t, { tag = 'v1.0.0' } = {}) { + const cwd = mkdtempSync(join(tmpdir(), 'emulsify-cli-release-')); + const repository = { + cwd, + changeIndex: 0, + }; + + t.after(() => { + rmSync(cwd, { recursive: true, force: true }); + }); + + git(cwd, ['init', '--initial-branch=main']); + git(cwd, ['config', 'user.name', 'Release Test']); + git(cwd, ['config', 'user.email', 'release-test@example.com']); + writePackageMetadata(cwd, '1.0.0'); + commitAll(cwd, 'chore: establish release fixture'); + if (tag) { + git(cwd, ['tag', tag]); + } + git(cwd, ['branch', 'release-base']); + + return repository; +} + +function addCommit(repository, message) { + repository.changeIndex += 1; + const filename = `change-${String(repository.changeIndex).padStart(3, '0')}.txt`; + writeFileSync(join(repository.cwd, filename), `${message}\n`); + commitAll(repository.cwd, message); +} + +test('shared analyzer config uses Angular parsing without custom rules', () => { + assert.deepEqual(releaseAnalysisConfig.commitAnalyzerOptions, { + preset: 'angular', + parserOpts: { + noteKeywords: ['BREAKING CHANGE', 'BREAKING CHANGES', 'BREAKING'], + }, + }); + assert.equal( + Object.hasOwn(releaseAnalysisConfig.commitAnalyzerOptions, 'releaseRules'), + false, + ); + assert.equal( + releaseAnalysisConfig.commitAnalyzerOptions.parserOpts, + releaseAnalysisConfig.parserOpts, + ); + + const commitAnalyzer = semanticReleaseConfig.plugins.find( + (plugin) => + Array.isArray(plugin) && + plugin[0] === '@semantic-release/commit-analyzer', + ); + const releaseNotes = semanticReleaseConfig.plugins.find( + (plugin) => + Array.isArray(plugin) && + plugin[0] === '@semantic-release/release-notes-generator', + ); + + assert.deepEqual(commitAnalyzer, [ + '@semantic-release/commit-analyzer', + releaseAnalysisConfig.commitAnalyzerOptions, + ]); + assert.deepEqual( + releaseNotes[1].parserOpts, + releaseAnalysisConfig.parserOpts, + ); +}); + +test('structured git log parsing preserves complete commit messages', () => { + assert.deepEqual( + parseGitLog( + '\x1eabc123\0feat: add command\n\nBody text\n\x1edef456\0fix: repair command\n', + ), + [ + { + hash: 'abc123', + message: 'feat: add command\n\nBody text', + }, + { + hash: 'def456', + message: 'fix: repair command', + }, + ], + ); +}); + +test('public semantic-release analyzer covers patch, minor, major, and no release', async () => { + const cwd = process.cwd(); + + assert.equal( + await analyzeReleaseType( + [{ hash: 'patch', message: 'fix(cli): repair output' }], + cwd, + ), + 'patch', + ); + assert.equal( + await analyzeReleaseType( + [{ hash: 'minor', message: 'feat(cli): add output mode' }], + cwd, + ), + 'minor', + ); + assert.equal( + await analyzeReleaseType( + [ + { + hash: 'major', + message: + 'feat(cli): replace output mode\n\nBREAKING CHANGE: replace the output contract', + }, + ], + cwd, + ), + 'major', + ); + assert.equal( + await analyzeReleaseType( + [{ hash: 'none', message: 'chore(cli): update metadata' }], + cwd, + ), + null, + ); +}); + +test('stable tag selection is reachable, exact, and version-sorted', (t) => { + const repository = createReleaseRepository(t, { tag: 'v1.9.0' }); + addCommit(repository, 'chore: establish later stable release'); + git(repository.cwd, ['tag', 'v1.10.0']); + git(repository.cwd, ['tag', 'v2.0.0-beta.1']); + git(repository.cwd, ['tag', '1.11.0']); + + git(repository.cwd, ['switch', '--detach', 'v1.9.0']); + addCommit(repository, 'chore: create unreachable release line'); + git(repository.cwd, ['tag', 'v9.0.0']); + git(repository.cwd, ['switch', 'main']); + + assert.equal( + getLatestReleaseTag({ cwd: repository.cwd, base: 'main' }), + 'v1.10.0', + ); + assert.equal(parseReleaseTag('v1.10.0'), '1.10.0'); + assert.throws( + () => parseReleaseTag('v1.10.0-beta.1'), + /Expected a stable tag/, + ); +}); + +test('release analysis fails clearly when stable history is missing', async (t) => { + const repository = createReleaseRepository(t, { tag: null }); + addCommit(repository, 'fix(cli): repair command'); + + await assert.rejects( + analyzeReleaseHistory({ + cwd: repository.cwd, + base: 'release-base', + }), + /No stable semantic-release tag is reachable/, + ); +}); + +test('develop uses a newer stable tag on main that is not its ancestor', async (t) => { + const repository = createReleaseRepository(t); + git(repository.cwd, ['branch', 'develop']); + git(repository.cwd, ['switch', 'develop']); + addCommit(repository, 'feat(cli): add released command'); + writePackageMetadata(repository.cwd, '1.1.0'); + commitAll(repository.cwd, 'chore(release): pre-bump minor version'); + + git(repository.cwd, ['switch', 'main']); + git(repository.cwd, [ + 'merge', + '--no-ff', + 'develop', + '-m', + 'Merge develop for v1.1.0', + ]); + git(repository.cwd, ['tag', 'v1.1.0']); + + git(repository.cwd, ['switch', 'develop']); + addCommit(repository, 'fix(cli): repair command after release'); + + const result = await runVersionBump({ + cwd: repository.cwd, + base: 'main', + to: 'develop', + logger: silentLogger, + }); + + assert.deepEqual(result, { + changed: true, + releaseType: 'patch', + version: '1.1.1', + }); + assert.deepEqual(readVersions(repository.cwd), ['1.1.1', '1.1.1', '1.1.1']); + assert.notEqual( + git(repository.cwd, ['rev-parse', 'v1.1.0']), + git(repository.cwd, ['merge-base', 'v1.1.0', 'develop']), + ); +}); + +test('cumulative develop analysis does not increment a pre-bumped version again', async (t) => { + const repository = createReleaseRepository(t); + addCommit(repository, 'feat(cli): add public command'); + writePackageMetadata(repository.cwd, '1.1.0'); + commitAll(repository.cwd, 'chore(release): pre-bump minor version'); + addCommit(repository, 'fix(cli): repair public command'); + + const packageBefore = readFileSync( + join(repository.cwd, 'package.json'), + 'utf8', + ); + const lockBefore = readFileSync( + join(repository.cwd, 'package-lock.json'), + 'utf8', + ); + const result = await runVersionBump({ + cwd: repository.cwd, + base: 'release-base', + logger: silentLogger, + }); + + assert.deepEqual(result, { + changed: false, + releaseType: 'minor', + version: '1.1.0', + }); + assert.equal( + readFileSync(join(repository.cwd, 'package.json'), 'utf8'), + packageBefore, + ); + assert.equal( + readFileSync(join(repository.cwd, 'package-lock.json'), 'utf8'), + lockBefore, + ); + assert.equal(git(repository.cwd, ['status', '--porcelain']), ''); +}); + +test('develop bump repairs package and lock metadata to the exact prediction', async (t) => { + const repository = createReleaseRepository(t); + addCommit(repository, 'fix(cli): repair public command'); + writePackageMetadata(repository.cwd, '9.0.0', { + lockVersion: '8.0.0', + rootVersion: '7.0.0', + }); + + const result = await runVersionBump({ + cwd: repository.cwd, + base: 'release-base', + logger: silentLogger, + }); + + assert.deepEqual(result, { + changed: true, + releaseType: 'patch', + version: '1.0.1', + }); + assert.deepEqual(readVersions(repository.cwd), ['1.0.1', '1.0.1', '1.0.1']); +}); + +test('develop bump leaves non-release history unchanged', async (t) => { + const repository = createReleaseRepository(t); + addCommit(repository, 'docs: clarify command usage'); + const before = [ + readFileSync(join(repository.cwd, 'package.json'), 'utf8'), + readFileSync(join(repository.cwd, 'package-lock.json'), 'utf8'), + ]; + + const result = await runVersionBump({ + cwd: repository.cwd, + base: 'release-base', + logger: silentLogger, + }); + + assert.deepEqual(result, { + changed: false, + releaseType: null, + }); + assert.deepEqual( + [ + readFileSync(join(repository.cwd, 'package.json'), 'utf8'), + readFileSync(join(repository.cwd, 'package-lock.json'), 'utf8'), + ], + before, + ); +}); + +test('develop CLI arguments use positional refs before environment fallbacks', () => { + const env = { + RELEASE_BASE: 'base-from-env', + RELEASE_TO: 'head-from-env', + }; + + assert.deepEqual(parseVersionDevelopArgs([], env), { + base: 'base-from-env', + to: 'head-from-env', + }); + assert.deepEqual( + parseVersionDevelopArgs(['base-positional', 'head-positional'], env), + { + base: 'base-positional', + to: 'head-positional', + }, + ); + assert.throws( + () => parseVersionDevelopArgs(['base', 'head', 'extra'], env), + /Usage:/, + ); +}); diff --git a/src/scripts/versionDevelop.ts b/src/scripts/versionDevelop.ts deleted file mode 100644 index 24aeba3..0000000 --- a/src/scripts/versionDevelop.ts +++ /dev/null @@ -1,234 +0,0 @@ -/** @file Updates develop package metadata from semantic commit messages. */ -import { execFileSync } from 'child_process'; -import { readFile, writeFile } from 'fs/promises'; - -type ReleaseType = 'major' | 'minor' | 'patch'; - -type Version = { - major: number; - minor: number; - patch: number; -}; - -const zeroSha = /^0{40}$/; -const commitSeparator = '\n---EMULSIFY-COMMIT---\n'; - -/** - * Converts a version string into numeric semver parts. - * - * @param version version string, with an optional leading v. - * @returns Numeric major, minor, and patch values. - */ -export function parseVersion(version: string): Version { - const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-.+)?$/.exec(version); - - if (!match) { - throw new Error(`Unsupported version format: ${version}`); - } - - return { - major: Number(match[1]), - minor: Number(match[2]), - patch: Number(match[3]), - }; -} - -/** - * Formats numeric semver parts as a package version string. - * - * @param version Numeric semver parts. - * @returns A version string in major.minor.patch format. - */ -export function formatVersion(version: Version): string { - return `${version.major}.${version.minor}.${version.patch}`; -} - -/** - * Compares two numeric semver versions. - * - * @param left Version on the left side of the comparison. - * @param right Version on the right side of the comparison. - * @returns 1 when left is greater, -1 when right is greater, and 0 when equal. - */ -export function compareVersions(left: Version, right: Version): number { - if (left.major !== right.major) { - return left.major > right.major ? 1 : -1; - } - - if (left.minor !== right.minor) { - return left.minor > right.minor ? 1 : -1; - } - - if (left.patch !== right.patch) { - return left.patch > right.patch ? 1 : -1; - } - - return 0; -} - -/** - * Increments a version by the requested release type. - * - * @param version Version to increment. - * @param releaseType Semantic release type to apply. - * @returns The next version for the release type. - */ -export function incrementVersion( - version: Version, - releaseType: ReleaseType, -): Version { - if (releaseType === 'major') { - return { major: version.major + 1, minor: 0, patch: 0 }; - } - - if (releaseType === 'minor') { - return { major: version.major, minor: version.minor + 1, patch: 0 }; - } - - return { - major: version.major, - minor: version.minor, - patch: version.patch + 1, - }; -} - -/** - * Determines the highest release type represented by semantic commit messages. - * - * @param messages Full commit messages to inspect. - * @returns The highest required release type, or undefined when no release is needed. - */ -export function getReleaseType(messages: string[]): ReleaseType | undefined { - if (messages.some((message) => hasBreakingChange(message))) { - return 'major'; - } - - if (messages.some((message) => /^feat(?:\([^)]+\))?:/m.test(message))) { - return 'minor'; - } - - if ( - messages.some((message) => /^(?:fix|perf)(?:\([^)]+\))?:/m.test(message)) - ) { - return 'patch'; - } - - return undefined; -} - -/** - * Updates package and lockfile metadata to the supplied version. - * - * @param version Version string to write. - * @returns A promise that resolves after both files are updated. - */ -async function updatePackageMetadata(version: string): Promise<void> { - const packageJson = JSON.parse(await readFile('package.json', 'utf8')); - const packageLockJson = JSON.parse( - await readFile('package-lock.json', 'utf8'), - ); - - packageJson.version = version; - packageLockJson.version = version; - - if (packageLockJson.packages?.['']) { - packageLockJson.packages[''].version = version; - } - - await Promise.all([ - writeFile('package.json', `${JSON.stringify(packageJson, null, 2)}\n`), - writeFile( - 'package-lock.json', - `${JSON.stringify(packageLockJson, null, 2)}\n`, - ), - ]); -} - -/** - * Reads the repository package version. - * - * @returns A promise resolving to the package.json version. - */ -async function getCurrentPackageVersion(): Promise<Version> { - const packageJson = JSON.parse(await readFile('package.json', 'utf8')); - return parseVersion(packageJson.version); -} - -/** - * Finds the latest reachable git tag. - * - * @returns The latest tag name, or undefined when no tag is available. - */ -function getLatestTag(): string | undefined { - try { - return execFileSync('git', ['describe', '--tags', '--abbrev=0'], { - encoding: 'utf8', - }).trim(); - } catch { - return undefined; - } -} - -/** - * Reads commit messages from the requested git range. - * - * @param before Previous commit SHA from the push event. - * @param after Current commit SHA from the push event. - * @param latestTag Latest release tag used as a fallback range start. - * @returns Commit messages in the selected range. - */ -function getCommitMessages( - before: string | undefined, - after: string, - latestTag: string | undefined, -): string[] { - const rangeStart = before && !zeroSha.test(before) ? before : latestTag; - const range = rangeStart ? `${rangeStart}..${after}` : after; - const output = execFileSync( - 'git', - ['log', '--format=%B' + commitSeparator, range], - { - encoding: 'utf8', - }, - ).trim(); - - return output - .split(commitSeparator) - .map((message) => message.trim()) - .filter(Boolean); -} - -/** - * Checks whether a commit message carries a semantic breaking-change marker. - * - * @param message Commit message to inspect. - * @returns True when the message marks a breaking change. - */ -function hasBreakingChange(message: string): boolean { - return ( - /^[a-z]+(?:\([^)]+\))?!:/m.test(message) || - /^BREAKING(?: CHANGE| CHANGES)?:/m.test(message) - ); -} - -const [before, after = 'HEAD'] = process.argv.slice(2); -const latestTag = getLatestTag(); -const currentVersion = await getCurrentPackageVersion(); -const baseVersion = latestTag ? parseVersion(latestTag) : currentVersion; -const releaseType = getReleaseType(getCommitMessages(before, after, latestTag)); - -if (!releaseType) { - console.log('No semantic version change detected.'); - process.exitCode = 0; -} else { - const targetVersion = incrementVersion(baseVersion, releaseType); - - if (compareVersions(currentVersion, targetVersion) >= 0) { - console.log( - `Package version ${formatVersion(currentVersion)} is already at or ahead of ${formatVersion(targetVersion)}.`, - ); - } else { - await updatePackageMetadata(formatVersion(targetVersion)); - console.log(`Updated package metadata to ${formatVersion(targetVersion)}.`); - } -} From 767aeef8ba9635227190144e0d1a01eab8e36825 Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Sun, 19 Jul 2026 20:10:10 -0500 Subject: [PATCH 09/27] feat(audit): delegate project audits to Emulsify Core --- README.md | 9 +- docs/cli-reference.md | 25 ++ docs/emulsify-info-cli-updates.md | 9 + src/handlers/audit.test.ts | 482 ++++++++++++++++++++++++++++++ src/handlers/audit.ts | 203 +++++++++++++ src/index.ts | 16 +- 6 files changed, 742 insertions(+), 2 deletions(-) create mode 100644 src/handlers/audit.test.ts create mode 100644 src/handlers/audit.ts diff --git a/README.md b/README.md index 84de964..a4b8cde 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ # Emulsify CLI -Command line interface for creating Emulsify projects, installing component systems, installing system components, and generating local components. +Command line interface for creating Emulsify projects, installing component systems, installing system components, generating local components, and routing project audits to Emulsify Core. ## Requirements @@ -74,12 +74,19 @@ Detailed documentation lives in [docs](./docs/README.md). | Command | Alias | Description | | ----------------------------------- | ----------------------------- | ----------------------------------------------------------------- | | `emulsify init [name] [path]` | | Initializes an Emulsify project from a starter. | +| `emulsify audit [...args]` | | Runs the project-installed Emulsify Core audit. | | `emulsify system list` | `emulsify system ls` | Lists built-in systems available for installation. | | `emulsify system install [name]` | | Installs or scaffolds a system in the current Emulsify project. | | `emulsify component list` | `emulsify component ls` | Lists components available from the installed system and variant. | | `emulsify component install [name]` | `emulsify component i [name]` | Installs one component from the installed system and variant. | | `emulsify component create [name]` | `emulsify component c [name]` | Creates a local component in the current Emulsify project. | +`emulsify audit` is a convenience façade. The project-installed +`@emulsify/core` package remains the owner of the canonical `emulsify-audit` +machine interface, checks, findings, JSON schema, output, and exit behavior. +See the [CLI reference](./docs/cli-reference.md#audit) and +[Core audit documentation](https://github.com/emulsify-ds/emulsify-core/blob/develop/docs/audit.md). + ## Contributors See [Contributors](./docs/contributors.md). diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 87c0a67..474e9e3 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -15,6 +15,7 @@ The examples below reflect the command definitions in `src/index.ts` and the gen | Command | Alias | Description | | ----------------------------------- | ----------------------------- | ---------------------------------------------------------------- | | `emulsify init [name] [path]` | | Initialize an Emulsify project from a starter. | +| `emulsify audit [...args]` | | Run the project-installed Emulsify Core audit. | | `emulsify system list` | `emulsify system ls` | List built-in systems available for installation. | | `emulsify system install [name]` | | Install or scaffold a system in the current Emulsify project. | | `emulsify component list` | `emulsify component ls` | List components available from the installed system and variant. | @@ -56,6 +57,30 @@ emulsify init "My Theme" ./web/themes/custom --platform drupal --machineName my_ emulsify init "My Project" ./projects --starter https://github.com/emulsify-ds/emulsify-starter --checkout main --platform none ``` +## `audit` + +```bash +emulsify audit [...args] +``` + +This command is a convenience façade for the `emulsify-audit` executable +declared by the selected project's installed `@emulsify/core` package. It +resolves Core from `--root <dir>` when supplied, or from the current directory, +then forwards every argument plus stdin, stdout, stderr, and the Core process +exit status without decoration. + +```bash +emulsify audit --help +emulsify audit --json +emulsify audit --root /path/to/project --json --fail-on warn +``` + +Core must be installed in the selected project. `emulsify-audit` remains the +canonical Core-owned machine interface; Core owns its audit checks, findings, +JSON schema, documentation, output, and exit behavior. See the +[Emulsify Core audit documentation](https://github.com/emulsify-ds/emulsify-core/blob/develop/docs/audit.md) +for the current contract and options. + ## `system list` ```bash diff --git a/docs/emulsify-info-cli-updates.md b/docs/emulsify-info-cli-updates.md index 680530d..b638fee 100644 --- a/docs/emulsify-info-cli-updates.md +++ b/docs/emulsify-info-cli-updates.md @@ -23,6 +23,7 @@ npm install -g @emulsify/cli | Command | Alias | Description | | ----------------------------------- | ----------------------------- | ----------------------------------------------------------------- | | `emulsify init [name] [path]` | | Initializes an Emulsify project from a starter. | +| `emulsify audit [...args]` | | Runs the project-installed Emulsify Core audit. | | `emulsify system list` | `emulsify system ls` | Lists built-in systems available for installation. | | `emulsify system install [name]` | | Installs a system in the current Emulsify project. | | `emulsify component list` | `emulsify component ls` | Lists components available from the installed system and variant. | @@ -31,6 +32,14 @@ npm install -g @emulsify/cli Run `emulsify <command> --help` for current options. +`emulsify audit [...args]` is a convenience façade for the +project-installed `@emulsify/core` package. It forwards arguments, standard +streams, and exit status to Core's `emulsify-audit` executable. +`emulsify-audit` remains the canonical Core-owned machine interface, including +its checks, findings, JSON schema, documentation, output, and exit behavior. +See the +[Emulsify Core audit documentation](https://github.com/emulsify-ds/emulsify-core/blob/develop/docs/audit.md). + ## Initialize A Project `emulsify init [name] [path]` clones a starter, writes `project.emulsify.json`, installs dependencies, runs the starter init hook when present, and removes the starter Git history. diff --git a/src/handlers/audit.test.ts b/src/handlers/audit.test.ts new file mode 100644 index 0000000..f25e2f3 --- /dev/null +++ b/src/handlers/audit.test.ts @@ -0,0 +1,482 @@ +import type { SpawnSyncReturns } from 'node:child_process'; +import { tmpdir } from 'node:os'; +import { dirname, join, relative, resolve } from 'node:path'; +import audit, { + executeCoreAudit, + getAuditProjectRoot, + resolveCoreAuditBin, +} from './audit.js'; + +jest.unmock('child_process'); +jest.unmock('node:child_process'); +jest.unmock('fs'); +jest.unmock('node:fs'); +jest.unmock('fs/promises'); +jest.unmock('node:fs/promises'); + +const { spawnSync } = + jest.requireActual<typeof import('node:child_process')>('node:child_process'); +const { mkdir, mkdtemp, realpath, rm, writeFile } = + jest.requireActual<typeof import('node:fs/promises')>('node:fs/promises'); + +const repositoryRoot = process.cwd(); +const registerPath = join(repositoryRoot, 'src/register.mjs'); +const cliPath = join(repositoryRoot, 'src/index.ts'); +const fakeAuditRelativePath = './bin with spaces/fake audit.mjs'; +const fakeAuditSource = ` +import { readFileSync } from 'node:fs'; + +const args = process.argv.slice(2); +const stdin = readFileSync(0, 'utf8'); +const exitOption = args.indexOf('--fake-exit'); +const exitCode = exitOption === -1 ? 0 : Number(args[exitOption + 1]); +const report = { + source: '@emulsify/core', + args, + stdin, +}; + +if (args.includes('--help')) { + process.stdout.write('CORE AUDIT HELP\\n'); +} else if (args.includes('--json')) { + process.stdout.write(\`\${JSON.stringify(report)}\\n\`); +} else { + process.stdout.write(\`CORE HUMAN \${JSON.stringify(report)}\\n\`); +} + +if (args.includes('--fake-stderr')) { + process.stderr.write('CORE STDERR\\n'); +} + +process.exitCode = exitCode; +`; + +interface FakeCoreOptions { + bin?: unknown; + packageMetadata?: unknown; + writeAuditTarget?: boolean; +} + +interface FakeCoreProject { + auditPath: string; + projectRoot: string; + tempRoot: string; +} + +interface CliResult { + status: number | null; + stderr: string; + stdout: string; +} + +const tempRoots: string[] = []; + +async function createFakeCoreProject( + options: FakeCoreOptions = {}, +): Promise<FakeCoreProject> { + const bin = Object.prototype.hasOwnProperty.call(options, 'bin') + ? options.bin + : { + 'emulsify-audit': fakeAuditRelativePath, + }; + const packageMetadata = Object.prototype.hasOwnProperty.call( + options, + 'packageMetadata', + ) + ? options.packageMetadata + : { + name: '@emulsify/core', + version: '4.3.0-fixture', + type: 'module', + exports: { + './package.json': './package.json', + }, + bin, + }; + const { writeAuditTarget = true } = options; + const tempRoot = await mkdtemp(join(tmpdir(), 'emulsify cli audit ')); + const projectRoot = join(tempRoot, 'project with spaces'); + const coreRoot = join(projectRoot, 'node_modules', '@emulsify', 'core'); + const auditPath = resolve(coreRoot, fakeAuditRelativePath); + tempRoots.push(tempRoot); + + await mkdir(dirname(auditPath), { recursive: true }); + await writeFile( + join(projectRoot, 'package.json'), + `${JSON.stringify({ name: 'fake-emulsify-project' }, null, 2)}\n`, + ); + await writeFile( + join(coreRoot, 'package.json'), + `${JSON.stringify(packageMetadata, null, 2)}\n`, + ); + + if (writeAuditTarget) { + await writeFile(auditPath, fakeAuditSource); + } + + return { + auditPath: writeAuditTarget ? await realpath(auditPath) : auditPath, + projectRoot, + tempRoot, + }; +} + +function createSpawnResult( + status: number | null, + error?: Error, +): SpawnSyncReturns<Buffer> { + return { + pid: 123, + output: [null, null, null], + stdout: null, + stderr: null, + status, + signal: null, + ...(error ? { error } : {}), + } as unknown as SpawnSyncReturns<Buffer>; +} + +function runSourceCli(cwd: string, args: string[], input = ''): CliResult { + const result = spawnSync( + process.execPath, + ['--import', registerPath, cliPath, ...args], + { + cwd, + encoding: 'utf8', + env: { + ...process.env, + FORCE_COLOR: '0', + NODE_NO_WARNINGS: '1', + NO_COLOR: '1', + TS_NODE_CWD: repositoryRoot, + TS_NODE_PROJECT: join(repositoryRoot, 'tsconfig.json'), + TS_NODE_TRANSPILE_ONLY: 'true', + }, + input, + }, + ); + + return { + status: result.status, + stderr: String(result.stderr), + stdout: String(result.stdout), + }; +} + +function runCli(cwd: string, args: string[], input = ''): CliResult { + return runSourceCli(cwd, ['audit', ...args], input); +} + +afterEach(async () => { + process.exitCode = undefined; + jest.restoreAllMocks(); + + await Promise.all( + tempRoots + .splice(0) + .map((tempRoot) => rm(tempRoot, { recursive: true, force: true })), + ); +}); + +test('selects the last valid separate or inline root without rewriting args', () => { + const cwd = resolve('/workspace with spaces'); + + expect(getAuditProjectRoot([], cwd)).toBe(cwd); + expect(getAuditProjectRoot(['--root', 'first project'], cwd)).toBe( + resolve(cwd, 'first project'), + ); + expect( + getAuditProjectRoot( + ['--root=first project', '--root', 'second project'], + cwd, + ), + ).toBe(resolve(cwd, 'second project')); + expect(getAuditProjectRoot(['--root', '--json'], cwd)).toBe(cwd); + expect(getAuditProjectRoot(['--root='], cwd)).toBe(cwd); +}); + +test('resolves the declared Core audit target through fake package metadata', async () => { + const fixture = await createFakeCoreProject(); + + expect(resolveCoreAuditBin(fixture.projectRoot)).toBe(fixture.auditPath); + expect(fixture.auditPath).toContain(' '); +}); + +test('reports a missing project-installed Core package', async () => { + const tempRoot = await mkdtemp(join(tmpdir(), 'emulsify cli missing core ')); + tempRoots.push(tempRoot); + await writeFile(join(tempRoot, 'package.json'), '{}\n'); + + expect(() => resolveCoreAuditBin(tempRoot)).toThrow( + expect.objectContaining({ + message: expect.stringContaining( + '@emulsify/core is not installed for project root', + ), + exitCode: 2, + }), + ); +}); + +test.each([ + ['missing bin metadata', undefined], + ['non-object bin metadata', fakeAuditRelativePath], + ['missing audit entry', {}], + ['empty audit entry', { 'emulsify-audit': '' }], + ['non-string audit entry', { 'emulsify-audit': 42 }], +])('reports %s as an unsupported Core audit bin', async (_label, bin) => { + const fixture = await createFakeCoreProject({ bin }); + + expect(() => resolveCoreAuditBin(fixture.projectRoot)).toThrow( + expect.objectContaining({ + message: expect.stringContaining( + 'does not expose package.json#bin["emulsify-audit"]', + ), + exitCode: 2, + }), + ); +}); + +test.each([null, []])( + 'reports non-object Core package metadata', + async (packageMetadata) => { + const fixture = await createFakeCoreProject({ packageMetadata }); + + expect(() => resolveCoreAuditBin(fixture.projectRoot)).toThrow( + expect.objectContaining({ + message: expect.stringContaining( + 'Unable to read the installed @emulsify/core package metadata', + ), + exitCode: 2, + }), + ); + }, +); + +test('reports an unavailable declared audit target', async () => { + const fixture = await createFakeCoreProject({ + writeAuditTarget: false, + }); + + expect(() => resolveCoreAuditBin(fixture.projectRoot)).toThrow( + expect.objectContaining({ + message: expect.stringContaining( + 'declares an unavailable "emulsify-audit" target', + ), + exitCode: 2, + }), + ); +}); + +test.each([0, 1, 2])( + 'forwards the exact invocation and returns Core exit status %s', + (exitCode) => { + const spawnProcess = jest.fn().mockReturnValue(createSpawnResult(exitCode)); + const args = [ + '--json', + '--fail-on', + 'warn', + '--root', + 'project with spaces', + ]; + + expect( + executeCoreAudit( + '/core path/audit script.mjs', + args, + '/caller path', + '/node path/node', + spawnProcess, + ), + ).toBe(exitCode); + expect(spawnProcess).toHaveBeenCalledWith( + '/node path/node', + ['/core path/audit script.mjs', ...args], + { + cwd: '/caller path', + shell: false, + stdio: 'inherit', + }, + ); + }, +); + +test('reports synchronous and returned process startup failures', () => { + const thrownFailure = jest.fn(() => { + throw new Error('spawn unavailable'); + }); + const returnedFailure = jest + .fn() + .mockReturnValue(createSpawnResult(null, new Error('spawn EACCES'))); + + expect(() => + executeCoreAudit( + '/core/audit.mjs', + [], + '/project', + process.execPath, + thrownFailure, + ), + ).toThrow( + expect.objectContaining({ + message: expect.stringContaining( + 'Unable to start the project-installed "emulsify-audit" process: spawn unavailable', + ), + exitCode: 2, + }), + ); + expect(() => + executeCoreAudit( + '/core/audit.mjs', + [], + '/project', + process.execPath, + returnedFailure, + ), + ).toThrow( + expect.objectContaining({ + message: expect.stringContaining('spawn EACCES'), + exitCode: 2, + }), + ); +}); + +test('reports a child process that ends without an exit status', () => { + const spawnProcess = jest.fn().mockReturnValue(createSpawnResult(null)); + + expect(() => + executeCoreAudit( + '/core/audit.mjs', + [], + '/project', + process.execPath, + spawnProcess, + ), + ).toThrow( + expect.objectContaining({ + message: expect.stringContaining('ended without an exit status'), + exitCode: 2, + }), + ); +}); + +test('the handler resolves from --root and assigns Core status without logging', async () => { + const fixture = await createFakeCoreProject(); + const callerRoot = fixture.tempRoot; + const relativeRoot = relative(callerRoot, fixture.projectRoot); + const args = ['--root', relativeRoot, '--json']; + const spawnProcess = jest.fn().mockReturnValue(createSpawnResult(1)); + + audit(args, { + cwd: callerRoot, + execPath: '/node path/node', + spawnProcess, + }); + + expect(process.exitCode).toBe(1); + expect(spawnProcess).toHaveBeenCalledWith( + '/node path/node', + [fixture.auditPath, ...args], + { + cwd: callerRoot, + shell: false, + stdio: 'inherit', + }, + ); +}); + +test('forwards human stdout, stderr, and stdin byte-for-byte', async () => { + const fixture = await createFakeCoreProject(); + const args = ['--future-flag', 'value with spaces', '--fake-stderr']; + const input = 'forwarded stdin\n'; + const result = runCli(fixture.projectRoot, args, input); + const report = { + source: '@emulsify/core', + args, + stdin: input, + }; + + expect(result.status).toBe(0); + expect(result.stdout).toBe(`CORE HUMAN ${JSON.stringify(report)}\n`); + expect(result.stderr).toBe('CORE STDERR\n'); +}); + +test.each([0, 1, 2])( + 'keeps JSON stdout Core-only while preserving exit status %s', + async (exitCode) => { + const fixture = await createFakeCoreProject(); + const args = ['--json', '--fake-exit', String(exitCode)]; + const result = runCli(fixture.projectRoot, args); + const expectedDocument = { + source: '@emulsify/core', + args, + stdin: '', + }; + + expect(result.status).toBe(exitCode); + expect(result.stdout).toBe(`${JSON.stringify(expectedDocument)}\n`); + expect(result.stderr).toBe(''); + expect(JSON.parse(result.stdout)).toEqual(expectedDocument); + }, +); + +test('resolves a relative requested root containing spaces and preserves argv', async () => { + const fixture = await createFakeCoreProject(); + const relativeRoot = relative(fixture.tempRoot, fixture.projectRoot); + const args = [ + '--root', + relativeRoot, + '--json', + '--future-option', + 'future value with spaces', + ]; + const result = runCli(fixture.tempRoot, args, 'input through spaces'); + + expect(result.status).toBe(0); + expect(JSON.parse(result.stdout)).toEqual({ + source: '@emulsify/core', + args, + stdin: 'input through spaces', + }); + expect(result.stderr).toBe(''); +}); + +test('delegates audit help to Core without Commander prose', async () => { + const fixture = await createFakeCoreProject(); + const result = runCli(fixture.projectRoot, ['--help']); + + expect(result).toMatchObject({ + status: 0, + stdout: 'CORE AUDIT HELP\n', + stderr: '', + }); +}); + +test('advertises audit in root help', () => { + const result = runSourceCli(repositoryRoot, ['--help']); + + expect(result.status).toBe(0); + expect(result.stdout).toContain('emulsify audit [...args]'); + expect(result.stderr).toBe(''); +}); + +test('the command reports missing Core and malformed bin metadata as exit 2', async () => { + const missingRoot = await mkdtemp( + join(tmpdir(), 'emulsify cli missing project '), + ); + tempRoots.push(missingRoot); + await writeFile(join(missingRoot, 'package.json'), '{}\n'); + const missingResult = runCli(missingRoot, ['--json']); + + expect(missingResult.status).toBe(2); + expect(missingResult.stdout).toBe(''); + expect(missingResult.stderr).toContain('@emulsify/core is not installed'); + + const malformedFixture = await createFakeCoreProject({ bin: {} }); + const malformedResult = runCli(malformedFixture.projectRoot, ['--json']); + + expect(malformedResult.status).toBe(2); + expect(malformedResult.stdout).toBe(''); + expect(malformedResult.stderr).toContain( + 'does not expose package.json#bin["emulsify-audit"]', + ); +}); diff --git a/src/handlers/audit.ts b/src/handlers/audit.ts new file mode 100644 index 0000000..7923c14 --- /dev/null +++ b/src/handlers/audit.ts @@ -0,0 +1,203 @@ +import { spawnSync, type SpawnSyncReturns } from 'node:child_process'; +import { readFileSync, statSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { dirname, resolve } from 'node:path'; +import CliError from '../lib/CliError.js'; + +const CORE_PACKAGE_JSON = '@emulsify/core/package.json'; +const CORE_AUDIT_BIN = 'emulsify-audit'; +const AUDIT_FAILURE_EXIT_CODE = 2; + +type SpawnAuditProcess = ( + command: string, + args: string[], + options: { + cwd: string; + shell: false; + stdio: 'inherit'; + }, +) => SpawnSyncReturns<Buffer>; + +export interface AuditHandlerOptions { + cwd?: string; + execPath?: string; + spawnProcess?: SpawnAuditProcess; +} + +interface CorePackageMetadata { + version?: unknown; + bin?: unknown; +} + +function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function getCoreVersionLabel(packageMetadata: CorePackageMetadata): string { + return typeof packageMetadata.version === 'string' && + packageMetadata.version.length > 0 + ? ` ${packageMetadata.version}` + : ''; +} + +/** + * Find the requested audit root without consuming or rewriting Core's options. + * + * Core owns argument validation. Invalid or missing --root values deliberately + * fall back to the invocation directory here so Core can report the original + * argument failure with its own output and exit behavior. + */ +export function getAuditProjectRoot(args: string[], cwd: string): string { + let requestedRoot: string | undefined; + + for (let index = 0; index < args.length; index += 1) { + const argument = args[index]; + + if (argument === '--root') { + const value = args[index + 1]; + if (value && !value.startsWith('--')) { + requestedRoot = value; + index += 1; + } + continue; + } + + if (argument.startsWith('--root=')) { + const value = argument.slice('--root='.length); + if (value) { + requestedRoot = value; + } + } + } + + return resolve(cwd, requestedRoot ?? '.'); +} + +/** + * Resolve the audit executable declared by the project-installed Core package. + */ +export function resolveCoreAuditBin(projectRoot: string): string { + let corePackagePath: string; + + try { + const projectRequire = createRequire(resolve(projectRoot, 'package.json')); + corePackagePath = projectRequire.resolve(CORE_PACKAGE_JSON); + } catch { + throw new CliError( + `Unable to run "emulsify audit": @emulsify/core is not installed for project root "${projectRoot}". Install @emulsify/core in that project and try again.`, + AUDIT_FAILURE_EXIT_CODE, + ); + } + + let packageMetadata: CorePackageMetadata; + try { + const parsedMetadata: unknown = JSON.parse( + readFileSync(corePackagePath, 'utf8'), + ); + if ( + !parsedMetadata || + typeof parsedMetadata !== 'object' || + Array.isArray(parsedMetadata) + ) { + throw new Error('package metadata must be a JSON object'); + } + packageMetadata = parsedMetadata as CorePackageMetadata; + } catch (error) { + throw new CliError( + `Unable to read the installed @emulsify/core package metadata at "${corePackagePath}": ${getErrorMessage(error)}. Reinstall @emulsify/core and try again.`, + AUDIT_FAILURE_EXIT_CODE, + ); + } + + const bins = packageMetadata.bin; + const auditBin = + bins && typeof bins === 'object' + ? (bins as Record<string, unknown>)[CORE_AUDIT_BIN] + : undefined; + + if (typeof auditBin !== 'string' || auditBin.trim().length === 0) { + throw new CliError( + `Installed @emulsify/core${getCoreVersionLabel(packageMetadata)} does not expose package.json#bin["${CORE_AUDIT_BIN}"]. Upgrade @emulsify/core and try again.`, + AUDIT_FAILURE_EXIT_CODE, + ); + } + + const auditPath = resolve(dirname(corePackagePath), auditBin); + try { + if (!statSync(auditPath).isFile()) { + throw new Error('the declared target is not a file'); + } + } catch { + throw new CliError( + `Installed @emulsify/core${getCoreVersionLabel(packageMetadata)} declares an unavailable "${CORE_AUDIT_BIN}" target at "${auditPath}". Reinstall @emulsify/core and try again.`, + AUDIT_FAILURE_EXIT_CODE, + ); + } + + return auditPath; +} + +/** + * Run Core's audit process with unchanged arguments and inherited stdio. + */ +export function executeCoreAudit( + auditPath: string, + args: string[], + cwd: string, + execPath = process.execPath, + spawnProcess: SpawnAuditProcess = spawnSync, +): number { + let result: SpawnSyncReturns<Buffer>; + + try { + result = spawnProcess(execPath, [auditPath, ...args], { + cwd, + shell: false, + stdio: 'inherit', + }); + } catch (error) { + throw new CliError( + `Unable to start the project-installed "${CORE_AUDIT_BIN}" process: ${getErrorMessage(error)}`, + AUDIT_FAILURE_EXIT_CODE, + ); + } + + if (result.error) { + throw new CliError( + `Unable to start the project-installed "${CORE_AUDIT_BIN}" process: ${result.error.message}`, + AUDIT_FAILURE_EXIT_CODE, + ); + } + + if (typeof result.status !== 'number') { + throw new CliError( + `The project-installed "${CORE_AUDIT_BIN}" process ended without an exit status.`, + AUDIT_FAILURE_EXIT_CODE, + ); + } + + return result.status; +} + +/** + * Handler for the transparent `emulsify audit [...args]` Core façade. + */ +export default function audit( + args: string[], + { + cwd = process.cwd(), + execPath = process.execPath, + spawnProcess = spawnSync, + }: AuditHandlerOptions = {}, +): void { + const projectRoot = getAuditProjectRoot(args, cwd); + const auditPath = resolveCoreAuditBin(projectRoot); + + process.exitCode = executeCoreAudit( + auditPath, + args, + cwd, + execPath, + spawnProcess, + ); +} diff --git a/src/index.ts b/src/index.ts index c1f3837..520d218 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,7 @@ import systemInstall from './handlers/systemInstall.js'; import componentList from './handlers/componentList.js'; import componentInstall from './handlers/componentInstall.js'; import componentCreate from './handlers/componentCreate.js'; +import audit from './handlers/audit.js'; import CliError from './lib/CliError.js'; import log from './lib/log.js'; import { createRequire } from 'module'; @@ -19,13 +20,14 @@ function getRootHelp(): string { return [ `${packageInfo.productName} ${packageInfo.version}`, '', - 'Create Emulsify projects, choose component systems, install components, and generate local components.', + 'Create Emulsify projects, choose component systems, install components, generate local components, and route audits to Emulsify Core.', '', 'Usage:', ' emulsify', ' emulsify --help', ' emulsify <command> --help', ' emulsify init [name] [path] [options]', + ' emulsify audit [...args]', ' emulsify system install [name] [options]', ' emulsify component <command> [options]', '', @@ -49,6 +51,10 @@ function getRootHelp(): string { ' Built-in platforms: drupal, wordpress, none.', ' -y, --yes Accept defaults for missing init values without prompting.', '', + ' audit [...args]', + ' Run the project-installed Emulsify Core audit with unchanged arguments, output, and exit status.', + ' Run "emulsify audit --help" for Core-owned audit options.', + '', ' system list', ' List built-in component systems available for installation. Alias: system ls.', '', @@ -121,6 +127,14 @@ program ) .action(withProgressBar(init)); +program + .command('audit [args...]') + .description('Run the project-installed Emulsify Core audit') + .allowUnknownOption() + .helpOption(false) + .passThroughOptions() + .action((args: string[]) => audit(args)); + // System sub-commands. const system = program .command('system') From e8a52ee846839803724dfcf2431d187c8ca07c16 Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Sat, 29 Aug 2026 16:53:23 -0500 Subject: [PATCH 10/27] feat(cache): key the cache by repository and add cache clear --- README.md | 1 + docs/cli-reference.md | 22 + docs/hooks-and-cache.md | 21 +- jest.setup.cjs | 4 + src/handlers/cacheClear.test.ts | 134 ++++++ src/handlers/cacheClear.ts | 103 +++++ src/handlers/componentCreate.test.ts | 13 +- src/handlers/componentInstall.test.ts | 13 +- src/handlers/componentList.test.ts | 13 +- src/handlers/hofs/withEmulsifySystem.test.ts | 13 +- src/handlers/hofs/withEmulsifySystem.ts | 13 +- src/handlers/systemInstall.test.ts | 9 +- src/handlers/systemInstall.ts | 20 +- src/index.ts | 17 + src/lib/constants.test.ts | 6 + src/lib/constants.ts | 1 + src/types/cache.d.ts | 11 + src/types/handlers.d.ts | 5 + src/util/cache/cloneIntoCache.test.ts | 384 ++++++++++++++++-- src/util/cache/cloneIntoCache.ts | 191 ++++++++- src/util/cache/copyItemFromCache.test.ts | 23 +- src/util/cache/copyItemFromCache.ts | 17 +- src/util/cache/getCachedItemCheckout.test.ts | 44 +- src/util/cache/getCachedItemCheckout.ts | 12 +- src/util/cache/getCachedItemPath.test.ts | 82 ++-- src/util/cache/getCachedItemPath.ts | 40 +- src/util/cache/getJsonFromCachedFile.test.ts | 57 +-- src/util/cache/getJsonFromCachedFile.ts | 17 +- src/util/cache/normalizeRepositoryUrl.test.ts | 35 ++ src/util/cache/normalizeRepositoryUrl.ts | 29 ++ 30 files changed, 1141 insertions(+), 209 deletions(-) create mode 100644 src/handlers/cacheClear.test.ts create mode 100644 src/handlers/cacheClear.ts create mode 100644 src/util/cache/normalizeRepositoryUrl.test.ts create mode 100644 src/util/cache/normalizeRepositoryUrl.ts diff --git a/README.md b/README.md index a4b8cde..fa26e52 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,7 @@ Detailed documentation lives in [docs](./docs/README.md). | `emulsify component list` | `emulsify component ls` | Lists components available from the installed system and variant. | | `emulsify component install [name]` | `emulsify component i [name]` | Installs one component from the installed system and variant. | | `emulsify component create [name]` | `emulsify component c [name]` | Creates a local component in the current Emulsify project. | +| `emulsify cache clear` | | Clears locally cached system repositories. | `emulsify audit` is a convenience façade. The project-installed `@emulsify/core` package remains the owner of the canonical `emulsify-audit` diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 474e9e3..a49f341 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -21,6 +21,7 @@ The examples below reflect the command definitions in `src/index.ts` and the gen | `emulsify component list` | `emulsify component ls` | List components available from the installed system and variant. | | `emulsify component install [name]` | `emulsify component i [name]` | Install a component from the installed system and variant. | | `emulsify component create [name]` | `emulsify component c [name]` | Generate a new local component in the current project. | +| `emulsify cache clear` | | Clear locally cached system repositories. | ## `init` @@ -81,6 +82,27 @@ JSON schema, documentation, output, and exit behavior. See the [Emulsify Core audit documentation](https://github.com/emulsify-ds/emulsify-core/blob/develop/docs/audit.md) for the current contract and options. +## `cache clear` + +```bash +emulsify cache clear +``` + +Removes all locally cached repository entries under `~/.emulsify/cache`. The command reports the number of cache buckets and entries removed, and succeeds when the cache is already empty. + +Options: + +| Option | Description | +| ----------- | ------------------------------------------------- | +| `--dry-run` | Report cache contents without removing any files. | + +Examples: + +```bash +emulsify cache clear --dry-run +emulsify cache clear +``` + ## `system list` ```bash diff --git a/docs/hooks-and-cache.md b/docs/hooks-and-cache.md index ab0b929..bc04088 100644 --- a/docs/hooks-and-cache.md +++ b/docs/hooks-and-cache.md @@ -57,22 +57,31 @@ The cache path includes: | ------------------- | ------------------------------------------------------------------ | | Cache bucket | Systems currently use the `systems` bucket. | | Project config path | Different Emulsify projects get separate cache locations. | +| Repository | Different repository URLs get separate cache locations. | | Checkout | Different tags, branches, or commits get separate cache locations. | | System name | The parsed repository name becomes the final cache segment. | -The project path and checkout are hashed, so the full path is intentionally not human-friendly. +The project path, normalized repository URL, and checkout are hashed, so the full path is intentionally not human-friendly. This prevents same-named repositories at the same checkout from sharing an entry. ## Cache Reuse -If the expected cached repository already exists, the CLI reuses it instead of cloning again. This keeps repeated component installs fast and keeps a project pinned to the checkout recorded in `project.emulsify.json`. +After a successful clone, the CLI writes `.emulsify-cache.json` inside the cache entry. The sidecar records the repository, requested checkout, resolved Git ref, clone time, and a completion marker. It is written only after cloning and ref resolution succeed. -If you need to force a fresh clone, remove the relevant cache directory under: +Before reusing an entry, the CLI validates the sidecar, repository, checkout, local `origin` fetch URL, and local `HEAD`. Missing, malformed, incomplete, or mismatched entries are removed and cloned again. When the remote is reachable, the CLI also compares a named checkout such as `main` (or the default remote `HEAD`) with the recorded resolved ref and re-clones when it has advanced. If that remote check is unavailable, a locally valid clone remains usable. -```text -~/.emulsify/cache/systems +To inspect how much would be removed without changing files, run: + +```bash +emulsify cache clear --dry-run +``` + +To remove every cache bucket and entry under `~/.emulsify/cache`, run: + +```bash +emulsify cache clear ``` -There is currently no CLI command for clearing the cache. +Both commands report bucket and entry counts. Clearing an already empty cache succeeds without an error. ## Copy Behavior diff --git a/jest.setup.cjs b/jest.setup.cjs index fd5d93c..cc5b8ce 100644 --- a/jest.setup.cjs +++ b/jest.setup.cjs @@ -2,6 +2,9 @@ jest.mock('simple-git', () => { const mockGit = { clone: jest.fn(), branch: jest.fn(), + getRemotes: jest.fn(), + listRemote: jest.fn(), + revparse: jest.fn(), checkout: jest.fn(), fetch: jest.fn().mockReturnThis(), pull: jest.fn(), @@ -27,6 +30,7 @@ jest.mock('fs', () => ({ promises: { writeFile: jest.fn(), readFile: jest.fn(), + readdir: jest.fn(), rm: jest.fn(), mkdir: jest.fn(), copyFile: jest.fn(), diff --git a/src/handlers/cacheClear.test.ts b/src/handlers/cacheClear.test.ts new file mode 100644 index 0000000..39eb1bd --- /dev/null +++ b/src/handlers/cacheClear.test.ts @@ -0,0 +1,134 @@ +jest.mock('../lib/constants', () => ({ + CACHE_DIR: '/home/uname/.emulsify/cache', +})); +jest.mock('../lib/log', () => jest.fn()); + +import { promises as fs } from 'fs'; +import CliError from '../lib/CliError.js'; +import log from '../lib/log.js'; +import cacheClear from './cacheClear.js'; + +const readdirMock = fs.readdir as jest.Mock; +const rmMock = fs.rm as jest.Mock; +const logMock = log as jest.Mock; + +const directory = (name: string) => ({ + name, + isDirectory: () => true, +}); +const file = (name: string) => ({ + name, + isDirectory: () => false, +}); + +describe('cacheClear', () => { + beforeEach(() => { + jest.clearAllMocks(); + rmMock.mockResolvedValue(undefined); + }); + + function mockPopulatedCache(): void { + readdirMock + .mockResolvedValueOnce([ + directory('systems'), + directory('variants'), + file('README'), + ]) + .mockResolvedValueOnce([ + directory('system-entry-one'), + directory('system-entry-two'), + file('ignored-file'), + ]) + .mockResolvedValueOnce([directory('variant-entry')]); + } + + it('reports bucket and entry counts before removing the cache', async () => { + mockPopulatedCache(); + + await cacheClear(); + + expect(rmMock).toHaveBeenCalledWith('/home/uname/.emulsify/cache', { + recursive: true, + force: true, + }); + expect(logMock).toHaveBeenCalledWith( + 'success', + 'Cleared the Emulsify cache: removed 2 buckets and 3 entries.', + ); + }); + + it('reports cache contents without removing them during a dry run', async () => { + mockPopulatedCache(); + + await cacheClear({ dryRun: true }); + + expect(rmMock).not.toHaveBeenCalled(); + expect(logMock).toHaveBeenCalledWith( + 'info', + 'Dry run: the Emulsify cache contains 2 buckets and 3 entries. No files were removed.', + ); + }); + + it('exits successfully when the cache directory does not exist', async () => { + readdirMock.mockRejectedValueOnce( + Object.assign(new Error('missing'), { code: 'ENOENT' }), + ); + + await expect(cacheClear()).resolves.toBeUndefined(); + + expect(rmMock).not.toHaveBeenCalled(); + expect(logMock).toHaveBeenCalledWith( + 'info', + 'The Emulsify cache is already empty: 0 buckets and 0 entries.', + ); + }); + + it('clears an existing cache with no buckets', async () => { + readdirMock.mockResolvedValueOnce([]); + + await cacheClear(); + + expect(rmMock).toHaveBeenCalledTimes(1); + expect(logMock).toHaveBeenCalledWith( + 'success', + 'Cleared the Emulsify cache: removed 0 buckets and 0 entries.', + ); + }); + + it('wraps cache inspection failures in CliError', async () => { + readdirMock.mockRejectedValueOnce( + Object.assign(new Error('permission denied'), { code: 'EACCES' }), + ); + + await expect(cacheClear()).rejects.toEqual( + new CliError( + 'Unable to inspect the Emulsify cache at "/home/uname/.emulsify/cache": permission denied', + ), + ); + expect(rmMock).not.toHaveBeenCalled(); + }); + + it('wraps bucket inspection failures in CliError', async () => { + readdirMock + .mockResolvedValueOnce([directory('systems')]) + .mockRejectedValueOnce('read failed'); + + await expect(cacheClear()).rejects.toEqual( + new CliError( + 'Unable to inspect the Emulsify cache at "/home/uname/.emulsify/cache": read failed', + ), + ); + expect(rmMock).not.toHaveBeenCalled(); + }); + + it('wraps cache removal failures in CliError', async () => { + readdirMock.mockResolvedValueOnce([]); + rmMock.mockRejectedValueOnce(new Error('remove failed')); + + await expect(cacheClear()).rejects.toEqual( + new CliError( + 'Unable to clear the Emulsify cache at "/home/uname/.emulsify/cache": remove failed', + ), + ); + }); +}); diff --git a/src/handlers/cacheClear.ts b/src/handlers/cacheClear.ts new file mode 100644 index 0000000..21102b1 --- /dev/null +++ b/src/handlers/cacheClear.ts @@ -0,0 +1,103 @@ +import type { ClearCacheHandlerOptions } from '@emulsify-cli/handlers'; + +import { promises as fs } from 'fs'; +import { join } from 'path'; +import { CACHE_DIR } from '../lib/constants.js'; +import CliError from '../lib/CliError.js'; +import log from '../lib/log.js'; + +type CacheStats = { + exists: boolean; + bucketCount: number; + entryCount: number; +}; + +function isMissingPathError(error: unknown): boolean { + return ( + error !== null && + typeof error === 'object' && + 'code' in error && + error.code === 'ENOENT' + ); +} + +function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +async function inspectCache(): Promise<CacheStats> { + let contents; + try { + contents = await fs.readdir(CACHE_DIR, { withFileTypes: true }); + } catch (error) { + if (isMissingPathError(error)) { + return { exists: false, bucketCount: 0, entryCount: 0 }; + } + + throw new CliError( + `Unable to inspect the Emulsify cache at "${CACHE_DIR}": ${getErrorMessage(error)}`, + ); + } + + const buckets = contents.filter((entry) => entry.isDirectory()); + let entryCount = 0; + + try { + for (const bucket of buckets) { + const entries = await fs.readdir(join(CACHE_DIR, bucket.name), { + withFileTypes: true, + }); + entryCount += entries.filter((entry) => entry.isDirectory()).length; + } + } catch (error) { + throw new CliError( + `Unable to inspect the Emulsify cache at "${CACHE_DIR}": ${getErrorMessage(error)}`, + ); + } + + return { + exists: true, + bucketCount: buckets.length, + entryCount, + }; +} + +/** + * Clear all local Emulsify repository cache entries. + * + * @param options command options, including dry-run behavior. + * @throws {CliError} if the cache cannot be inspected or removed. + */ +export default async function cacheClear({ + dryRun = false, +}: ClearCacheHandlerOptions = {}): Promise<void> { + const stats = await inspectCache(); + if (!stats.exists) { + log( + 'info', + 'The Emulsify cache is already empty: 0 buckets and 0 entries.', + ); + return; + } + + if (dryRun) { + log( + 'info', + `Dry run: the Emulsify cache contains ${stats.bucketCount} buckets and ${stats.entryCount} entries. No files were removed.`, + ); + return; + } + + try { + await fs.rm(CACHE_DIR, { recursive: true, force: true }); + } catch (error) { + throw new CliError( + `Unable to clear the Emulsify cache at "${CACHE_DIR}": ${getErrorMessage(error)}`, + ); + } + + log( + 'success', + `Cleared the Emulsify cache: removed ${stats.bucketCount} buckets and ${stats.entryCount} entries.`, + ); +} diff --git a/src/handlers/componentCreate.test.ts b/src/handlers/componentCreate.test.ts index 641adac..55cab33 100644 --- a/src/handlers/componentCreate.test.ts +++ b/src/handlers/componentCreate.test.ts @@ -194,12 +194,13 @@ describe('componentCreate', () => { 'Unable to load configuration for the compound system. Please make sure the system is installed.', ); - expect(getJsonFromCachedFileMock).toHaveBeenCalledWith( - 'systems', - ['compound'], - 'main', - EMULSIFY_SYSTEM_CONFIG_FILE, - ); + expect(getJsonFromCachedFileMock).toHaveBeenCalledWith({ + bucket: 'systems', + itemPath: ['compound'], + repository: 'https://github.com/emulsify-ds/compound.git', + checkout: 'main', + fileName: EMULSIFY_SYSTEM_CONFIG_FILE, + }); }); it('throws when the configured variant is not found', async () => { diff --git a/src/handlers/componentInstall.test.ts b/src/handlers/componentInstall.test.ts index ad515f4..d63aa14 100644 --- a/src/handlers/componentInstall.test.ts +++ b/src/handlers/componentInstall.test.ts @@ -182,12 +182,13 @@ describe('componentInstall', () => { 'Unable to load configuration for the compound system. Please make sure the system is installed.', ); - expect(getJsonFromCachedFileMock).toHaveBeenCalledWith( - 'systems', - ['compound'], - 'main', - EMULSIFY_SYSTEM_CONFIG_FILE, - ); + expect(getJsonFromCachedFileMock).toHaveBeenCalledWith({ + bucket: 'systems', + itemPath: ['compound'], + repository: 'https://github.com/emulsify-ds/compound.git', + checkout: 'main', + fileName: EMULSIFY_SYSTEM_CONFIG_FILE, + }); }); it('throws when the configured variant is not found', async () => { diff --git a/src/handlers/componentList.test.ts b/src/handlers/componentList.test.ts index 7437387..e442a4d 100644 --- a/src/handlers/componentList.test.ts +++ b/src/handlers/componentList.test.ts @@ -156,12 +156,13 @@ describe('componentList', () => { 'Unable to load configuration for the compound system. Please make sure the system is installed.', ); - expect(getJsonFromCachedFileMock).toHaveBeenCalledWith( - 'systems', - ['compound'], - 'main', - EMULSIFY_SYSTEM_CONFIG_FILE, - ); + expect(getJsonFromCachedFileMock).toHaveBeenCalledWith({ + bucket: 'systems', + itemPath: ['compound'], + repository: 'https://github.com/emulsify-ds/compound.git', + checkout: 'main', + fileName: EMULSIFY_SYSTEM_CONFIG_FILE, + }); }); it('throws when the configured variant is not found', async () => { diff --git a/src/handlers/hofs/withEmulsifySystem.test.ts b/src/handlers/hofs/withEmulsifySystem.test.ts index 0fb2f62..9a1ab3b 100644 --- a/src/handlers/hofs/withEmulsifySystem.test.ts +++ b/src/handlers/hofs/withEmulsifySystem.test.ts @@ -158,12 +158,13 @@ describe('withEmulsifySystem', () => { await expect(withEmulsifySystem('install components')).rejects.toThrow( 'Unable to load configuration for the compound system. Please make sure the system is installed.', ); - expect(getJsonFromCachedFileMock).toHaveBeenCalledWith( - 'systems', - ['compound'], - 'main', - EMULSIFY_SYSTEM_CONFIG_FILE, - ); + expect(getJsonFromCachedFileMock).toHaveBeenCalledWith({ + bucket: 'systems', + itemPath: ['compound'], + repository: 'https://github.com/emulsify-ds/compound.git', + checkout: 'main', + fileName: EMULSIFY_SYSTEM_CONFIG_FILE, + }); }); it('throws a typed error when the configured variant is not found', async () => { diff --git a/src/handlers/hofs/withEmulsifySystem.ts b/src/handlers/hofs/withEmulsifySystem.ts index 46d9bd2..76ad331 100644 --- a/src/handlers/hofs/withEmulsifySystem.ts +++ b/src/handlers/hofs/withEmulsifySystem.ts @@ -95,12 +95,13 @@ export async function withEmulsifySystem( ); } - const systemConf: EmulsifySystem | void = await getJsonFromCachedFile( - 'systems', - [systemName], - systemReference.checkout, - EMULSIFY_SYSTEM_CONFIG_FILE, - ); + const systemConf: EmulsifySystem | void = await getJsonFromCachedFile({ + bucket: 'systems', + itemPath: [systemName], + repository: systemReference.repository, + checkout: systemReference.checkout, + fileName: EMULSIFY_SYSTEM_CONFIG_FILE, + }); if (!systemConf) { throw new EmulsifySystemError( diff --git a/src/handlers/systemInstall.test.ts b/src/handlers/systemInstall.test.ts index 07f5fdd..130c596 100644 --- a/src/handlers/systemInstall.test.ts +++ b/src/handlers/systemInstall.test.ts @@ -992,9 +992,12 @@ describe('systemInstall', () => { await systemInstall('compound', {}); - expect(getCachedItemCheckoutMock).toHaveBeenCalledWith('systems', [ - 'compound', - ]); + expect(getCachedItemCheckoutMock).toHaveBeenCalledWith({ + bucket: 'systems', + itemPath: ['compound'], + repository: 'https://github.com/emulsify-ds/compound.git', + checkout: undefined, + }); expect(setEmulsifyConfigMock).toHaveBeenCalledWith( expect.objectContaining({ system: { diff --git a/src/handlers/systemInstall.ts b/src/handlers/systemInstall.ts index ac5b7a7..74be365 100644 --- a/src/handlers/systemInstall.ts +++ b/src/handlers/systemInstall.ts @@ -332,12 +332,13 @@ export default async function systemInstall( }); // Load the system configuration file. - const systemConf: EmulsifySystem | void = await getJsonFromCachedFile( - 'systems', - [repo.name], - repo.checkout, - EMULSIFY_SYSTEM_CONFIG_FILE, - ); + const systemConf: EmulsifySystem | void = await getJsonFromCachedFile({ + bucket: 'systems', + itemPath: [repo.name], + repository: repo.repository, + checkout: repo.checkout, + fileName: EMULSIFY_SYSTEM_CONFIG_FILE, + }); // If there is no configuration file within the system, error. if (!systemConf) { @@ -388,7 +389,12 @@ export default async function systemInstall( // it can be stored in the project config. let checkout = repo.checkout; if (!checkout) { - checkout = await getCachedItemCheckout('systems', [repo.name]); + checkout = await getCachedItemCheckout({ + bucket: 'systems', + itemPath: [repo.name], + repository: repo.repository, + checkout: repo.checkout, + }); } await setEmulsifyConfig({ diff --git a/src/index.ts b/src/index.ts index 520d218..c0be130 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,6 +8,7 @@ import componentList from './handlers/componentList.js'; import componentInstall from './handlers/componentInstall.js'; import componentCreate from './handlers/componentCreate.js'; import audit from './handlers/audit.js'; +import cacheClear from './handlers/cacheClear.js'; import CliError from './lib/CliError.js'; import log from './lib/log.js'; import { createRequire } from 'module'; @@ -30,6 +31,7 @@ function getRootHelp(): string { ' emulsify audit [...args]', ' emulsify system install [name] [options]', ' emulsify component <command> [options]', + ' emulsify cache clear [options]', '', 'Common workflow:', ' emulsify init', @@ -85,6 +87,11 @@ function getRootHelp(): string { ' -y, --yes Replace existing generated components without prompting.', ' --dry-run Preview generated files without writing them.', '', + ' cache clear', + ' Remove all locally cached Emulsify repositories.', + ' Options:', + ' --dry-run Report cache contents without removing files.', + '', ' help [command]', ' Show help for a command.', '', @@ -210,6 +217,16 @@ component .description('Generate a new local component in the current project') .action(componentCreate); +// Cache sub-commands. +const cache = program + .command('cache') + .description('Inspect or clear locally cached repositories'); +cache + .command('clear') + .description('Remove all locally cached Emulsify repositories') + .option('--dry-run', 'Report cache contents without removing files.') + .action(cacheClear); + /* * Generate a styled version message using boxen and colorette. * This displays the product name and version in a visually appealing format. diff --git a/src/lib/constants.test.ts b/src/lib/constants.test.ts index 59b8bb5..790b241 100644 --- a/src/lib/constants.test.ts +++ b/src/lib/constants.test.ts @@ -3,6 +3,7 @@ import { CACHE_DIR, EMULSIFY_PROJECT_CONFIG_FILE, EMULSIFY_PROJECT_TEMPLATES_FOLDER, + EMULSIFY_CACHE_METADATA_FILE, } from './constants.js'; jest.mock('os', () => ({ @@ -22,6 +23,11 @@ const map = [ EMULSIFY_PROJECT_TEMPLATES_FOLDER, '.cli/templates', ], + [ + 'EMULSIFY_CACHE_METADATA_FILE', + EMULSIFY_CACHE_METADATA_FILE, + '.emulsify-cache.json', + ], ]; describe('constats', () => { diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 79ad9a0..1ab2c9d 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -12,5 +12,6 @@ export const EMULSIFY_PROJECT_TEMPLATES_FOLDER = `${EMULSIFY_PROJECT_HOOK_FOLDER export const EMULSIFY_PROJECT_HOOK_INIT = 'init.js'; export const EMULSIFY_PROJECT_HOOK_SYSTEM_INSTALL = 'systemInstall.js'; export const EMULSIFY_SYSTEM_CONFIG_FILE = 'system.emulsify.json'; +export const EMULSIFY_CACHE_METADATA_FILE = '.emulsify-cache.json'; export const UTIL_DIR = join(homedir(), '.emulsify'); export const CACHE_DIR = join(UTIL_DIR, 'cache'); diff --git a/src/types/cache.d.ts b/src/types/cache.d.ts index 0ae6b4b..f10af39 100644 --- a/src/types/cache.d.ts +++ b/src/types/cache.d.ts @@ -5,4 +5,15 @@ declare module '@emulsify-cli/cache' { export type CacheBucket = 'systems' | 'variants'; export type CacheItemPath = string[]; export type CacheCheckout = string | void; + + export type CachedItemPathOptions = { + /** Cache bucket containing the item. */ + bucket: CacheBucket; + /** Path segments identifying the item within the cache entry. */ + itemPath: CacheItemPath; + /** Repository whose contents are stored in the cache entry. */ + repository: string; + /** Commit, branch, or tag requested for the repository. */ + checkout: CacheCheckout; + }; } diff --git a/src/types/handlers.d.ts b/src/types/handlers.d.ts index 98c5589..51b451c 100644 --- a/src/types/handlers.d.ts +++ b/src/types/handlers.d.ts @@ -40,4 +40,9 @@ declare module '@emulsify-cli/handlers' { /** Preview planned component operations without writing, copying, or removing files. */ dryRun?: boolean; }; + + export type ClearCacheHandlerOptions = { + /** Report cache contents without removing them. */ + dryRun?: boolean; + }; } diff --git a/src/util/cache/cloneIntoCache.test.ts b/src/util/cache/cloneIntoCache.test.ts index 3ecfdbd..8d3c5d5 100644 --- a/src/util/cache/cloneIntoCache.test.ts +++ b/src/util/cache/cloneIntoCache.test.ts @@ -1,5 +1,7 @@ jest.mock('../../lib/constants', () => ({ CACHE_DIR: 'home/uname/.emulsify/cache', + EMULSIFY_CACHE_METADATA_FILE: '.emulsify-cache.json', + EMULSIFY_PROJECT_CONFIG_FILE: 'project.emulsify.json', })); jest.mock('../fs/findFileInCurrentPath', () => jest.fn()); @@ -7,65 +9,381 @@ import cloneIntoCache from './cloneIntoCache.js'; import findFileInCurrentPath from '../fs/findFileInCurrentPath.js'; import fs from 'fs'; -import { simpleGit as git } from 'simple-git'; +import { createHash } from 'crypto'; +import { dirname, resolve } from 'path'; +import { simpleGit } from 'simple-git'; const existsSyncMock = fs.existsSync as jest.Mock; +const readFileMock = fs.promises.readFile as jest.Mock; +const writeFileMock = fs.promises.writeFile as jest.Mock; +const rmMock = fs.promises.rm as jest.Mock; const mkdirMock = fs.promises.mkdir as jest.Mock; -const gitCloneMock = git().clone as jest.Mock; +const simpleGitMock = simpleGit as unknown as jest.Mock; +const gitMock = simpleGit(); +const gitCloneMock = gitMock.clone as jest.Mock; +const getRemotesMock = gitMock.getRemotes as jest.Mock; +const listRemoteMock = gitMock.listRemote as jest.Mock; +const revparseMock = gitMock.revparse as jest.Mock; -(findFileInCurrentPath as jest.Mock).mockReturnValue( - '/home/uname/projects/emulsify', -); +const projectPath = '/home/uname/projects/emulsify'; +const cloneOptions = { + repository: 'repo-path', + checkout: 'branch-name', +}; +const normalizedRepository = resolve(cloneOptions.repository); + +function cacheDestination(checkout: string | void): string { + const hash = createHash('md5') + .update( + JSON.stringify({ + projectPath, + repository: normalizedRepository, + checkout: checkout || '', + }), + ) + .digest('hex'); + return `home/uname/.emulsify/cache/systems/${hash}/cornflake`; +} + +const destination = cacheDestination(cloneOptions.checkout); +const metadataPath = `${destination}/.emulsify-cache.json`; + +function metadata(overrides: Record<string, unknown> = {}): string { + return JSON.stringify({ + repository: normalizedRepository, + checkout: 'branch-name', + resolvedRef: 'resolved-ref', + clonedAt: '2026-08-29T12:00:00.000Z', + complete: true, + ...overrides, + }); +} describe('cloneIntoCache', () => { beforeEach(() => { - existsSyncMock.mockClear(); - gitCloneMock.mockClear(); + jest.clearAllMocks(); + (findFileInCurrentPath as jest.Mock).mockReturnValue(projectPath); + existsSyncMock.mockReturnValue(false); + readFileMock.mockRejectedValue( + Object.assign(new Error('missing'), { code: 'ENOENT' }), + ); + writeFileMock.mockResolvedValue(undefined); + rmMock.mockResolvedValue(undefined); + mkdirMock.mockResolvedValue(undefined); + gitCloneMock.mockResolvedValue(undefined); + getRemotesMock.mockResolvedValue([ + { + name: 'origin', + refs: { + fetch: normalizedRepository, + push: normalizedRepository, + }, + }, + ]); + listRemoteMock.mockResolvedValue(''); + revparseMock.mockResolvedValue('resolved-ref\n'); }); - const cloneOptions = { - repository: 'repo-path', - checkout: 'branch-name', - }; + it('clones into the repository-aware path and writes complete metadata', async () => { + await cloneIntoCache('systems', ['cornflake'])(cloneOptions); + + expect(mkdirMock).toHaveBeenCalledWith(dirname(destination), { + recursive: true, + }); + expect(gitCloneMock).toHaveBeenCalledWith( + normalizedRepository, + destination, + { + '--branch': 'branch-name', + }, + ); + expect(simpleGitMock).toHaveBeenCalledWith(destination); + expect(revparseMock).toHaveBeenCalledWith(['HEAD']); + expect(writeFileMock).toHaveBeenCalledWith( + metadataPath, + expect.any(String), + { encoding: 'utf-8' }, + ); + + const writtenMetadata = JSON.parse(writeFileMock.mock.calls[0][1]); + expect(writtenMetadata).toEqual({ + repository: normalizedRepository, + checkout: 'branch-name', + resolvedRef: 'resolved-ref', + clonedAt: expect.any(String), + complete: true, + }); + expect(Number.isNaN(Date.parse(writtenMetadata.clonedAt))).toBe(false); + }); + + it('writes metadata only after the clone resolves', async () => { + let resolveClone: (() => void) | undefined; + gitCloneMock.mockImplementationOnce( + () => + new Promise<void>((resolve) => { + resolveClone = resolve; + }), + ); + + const clonePromise = cloneIntoCache('systems', ['cornflake'])(cloneOptions); + await Promise.resolve(); + expect(writeFileMock).not.toHaveBeenCalled(); + + resolveClone?.(); + await clonePromise; + expect(writeFileMock).toHaveBeenCalledTimes(1); + }); + + it('does not write metadata when cloning fails', async () => { + gitCloneMock.mockRejectedValueOnce(new Error('clone failed')); + + await expect( + cloneIntoCache('systems', ['cornflake'])(cloneOptions), + ).rejects.toThrow('clone failed'); + expect(writeFileMock).not.toHaveBeenCalled(); + }); + + it('reuses a complete cache entry with matching origin and refs', async () => { + existsSyncMock.mockReturnValueOnce(true); + readFileMock.mockResolvedValueOnce(metadata()); + listRemoteMock.mockResolvedValueOnce( + 'resolved-ref\trefs/heads/branch-name\n', + ); + + await cloneIntoCache('systems', ['cornflake'])(cloneOptions); + + expect(readFileMock).toHaveBeenCalledWith(metadataPath, { + encoding: 'utf-8', + }); + expect(gitCloneMock).not.toHaveBeenCalled(); + expect(rmMock).not.toHaveBeenCalled(); + expect(writeFileMock).not.toHaveBeenCalled(); + }); + + it('reuses a valid local entry when the remote cannot be reached', async () => { + existsSyncMock.mockReturnValueOnce(true); + readFileMock.mockResolvedValueOnce(metadata()); + listRemoteMock.mockRejectedValueOnce(new Error('offline')); + + await cloneIntoCache('systems', ['cornflake'])(cloneOptions); + + expect(gitCloneMock).not.toHaveBeenCalled(); + expect(rmMock).not.toHaveBeenCalled(); + }); + + it('reuses a valid default-branch entry when remote HEAD matches', async () => { + const defaultDestination = cacheDestination(undefined); + existsSyncMock.mockReturnValueOnce(true); + readFileMock.mockResolvedValueOnce(metadata({ checkout: null })); + listRemoteMock.mockResolvedValueOnce('resolved-ref\tHEAD\n'); + + await cloneIntoCache('systems', ['cornflake'])({ + repository: 'repo-path', + }); + + expect(readFileMock).toHaveBeenCalledWith( + `${defaultDestination}/.emulsify-cache.json`, + { encoding: 'utf-8' }, + ); + expect(listRemoteMock).toHaveBeenCalledWith([normalizedRepository, 'HEAD']); + expect(gitCloneMock).not.toHaveBeenCalled(); + expect(rmMock).not.toHaveBeenCalled(); + }); + + it('reuses a valid entry when the checkout is not a remote branch', async () => { + existsSyncMock.mockReturnValueOnce(true); + readFileMock.mockResolvedValueOnce(metadata()); + listRemoteMock.mockResolvedValueOnce( + 'resolved-ref\trefs/tags/branch-name\n', + ); + + await cloneIntoCache('systems', ['cornflake'])(cloneOptions); + + expect(listRemoteMock).toHaveBeenCalledWith([ + normalizedRepository, + 'refs/heads/branch-name', + 'refs/tags/branch-name^{}', + 'refs/tags/branch-name', + ]); + expect(gitCloneMock).not.toHaveBeenCalled(); + expect(rmMock).not.toHaveBeenCalled(); + }); - it('can return early if the cache item already exists', async () => { - expect.assertions(1); + it('prefers the peeled commit for an annotated tag', async () => { existsSyncMock.mockReturnValueOnce(true); + readFileMock.mockResolvedValueOnce(metadata()); + listRemoteMock.mockResolvedValueOnce( + [ + 'tag-object\trefs/tags/branch-name', + 'resolved-ref\trefs/tags/branch-name^{}', + ].join('\n'), + ); + await cloneIntoCache('systems', ['cornflake'])(cloneOptions); + expect(gitCloneMock).not.toHaveBeenCalled(); + expect(rmMock).not.toHaveBeenCalled(); + }); + + it('re-clones when the requested branch or tag no longer exists', async () => { + existsSyncMock.mockReturnValueOnce(true); + readFileMock.mockResolvedValueOnce(metadata()); + listRemoteMock.mockResolvedValueOnce(''); + + await cloneIntoCache('systems', ['cornflake'])(cloneOptions); + + expect(rmMock).toHaveBeenCalledTimes(1); + expect(gitCloneMock).toHaveBeenCalledTimes(1); + }); + + it('removes and re-clones an entry with no sidecar', async () => { + existsSyncMock.mockReturnValueOnce(true); + + await cloneIntoCache('systems', ['cornflake'])(cloneOptions); + + expect(rmMock).toHaveBeenCalledWith(destination, { + recursive: true, + force: true, + }); + expect(gitCloneMock).toHaveBeenCalledTimes(1); + }); + + it('removes and re-clones an entry with malformed sidecar JSON', async () => { + existsSyncMock.mockReturnValueOnce(true); + readFileMock.mockResolvedValueOnce('{not-json'); + + await cloneIntoCache('systems', ['cornflake'])(cloneOptions); + + expect(rmMock).toHaveBeenCalledTimes(1); + expect(gitCloneMock).toHaveBeenCalledTimes(1); }); - it('creates the bucketDir if it does not already exist', async () => { - expect.assertions(1); - existsSyncMock.mockReturnValueOnce(false).mockReturnValueOnce(false); + it.each([ + null, + { repository: 123 }, + { repository: 'repo-path', checkout: 123 }, + { + repository: 'repo-path', + checkout: 'branch-name', + resolvedRef: 123, + }, + { + repository: 'repo-path', + checkout: 'branch-name', + resolvedRef: '', + clonedAt: '2026-08-29T12:00:00.000Z', + complete: true, + }, + { + repository: 'repo-path', + checkout: 'branch-name', + resolvedRef: 'resolved-ref', + clonedAt: 123, + complete: true, + }, + { + repository: 'repo-path', + checkout: 'branch-name', + resolvedRef: 'resolved-ref', + clonedAt: 'not-a-date', + complete: true, + }, + { + repository: 'repo-path', + checkout: 'branch-name', + resolvedRef: 'resolved-ref', + clonedAt: '2026-08-29T12:00:00.000Z', + complete: false, + }, + ])('re-clones an entry with incomplete metadata: %p', async (value) => { + existsSyncMock.mockReturnValueOnce(true); + readFileMock.mockResolvedValueOnce(JSON.stringify(value)); + + await cloneIntoCache('systems', ['cornflake'])(cloneOptions); + + expect(rmMock).toHaveBeenCalledTimes(1); + expect(gitCloneMock).toHaveBeenCalledTimes(1); + }); + + it('removes and re-clones an entry for a different repository', async () => { + existsSyncMock.mockReturnValueOnce(true); + readFileMock.mockResolvedValueOnce( + metadata({ repository: 'different-repo-path' }), + ); + await cloneIntoCache('systems', ['cornflake'])(cloneOptions); - expect(mkdirMock).toHaveBeenCalledWith( - 'home/uname/.emulsify/cache/systems/2a39785f5c873d7a694ac505a8123bb9', + + expect(getRemotesMock).not.toHaveBeenCalled(); + expect(rmMock).toHaveBeenCalledTimes(1); + expect(gitCloneMock).toHaveBeenCalledTimes(1); + }); + + it('removes and re-clones an entry whose origin does not match', async () => { + existsSyncMock.mockReturnValueOnce(true); + readFileMock.mockResolvedValueOnce(metadata()); + getRemotesMock.mockResolvedValueOnce([ { - recursive: true, + name: 'origin', + refs: { fetch: 'different-repo-path', push: 'repo-path' }, }, - ); + ]); + + await cloneIntoCache('systems', ['cornflake'])(cloneOptions); + + expect(rmMock).toHaveBeenCalledTimes(1); + expect(gitCloneMock).toHaveBeenCalledTimes(1); }); - it('clones the repository into the correct bucket and folder', async () => { - expect.assertions(1); - existsSyncMock.mockReturnValueOnce(false).mockReturnValueOnce(true); + it('removes and re-clones an entry whose origin cannot be inspected', async () => { + existsSyncMock.mockReturnValueOnce(true); + readFileMock.mockResolvedValueOnce(metadata()); + getRemotesMock.mockRejectedValueOnce(new Error('not a git repository')); + await cloneIntoCache('systems', ['cornflake'])(cloneOptions); - expect(gitCloneMock).toHaveBeenCalledWith( - 'repo-path', - 'home/uname/.emulsify/cache/systems/2a39785f5c873d7a694ac505a8123bb9/cornflake', - { '--branch': 'branch-name' }, + + expect(rmMock).toHaveBeenCalledTimes(1); + expect(gitCloneMock).toHaveBeenCalledTimes(1); + }); + + it('removes and re-clones an entry whose local HEAD changed', async () => { + existsSyncMock.mockReturnValueOnce(true); + readFileMock.mockResolvedValueOnce(metadata()); + revparseMock + .mockResolvedValueOnce('different-local-ref\n') + .mockResolvedValueOnce('resolved-ref\n'); + + await cloneIntoCache('systems', ['cornflake'])(cloneOptions); + + expect(rmMock).toHaveBeenCalledTimes(1); + expect(gitCloneMock).toHaveBeenCalledTimes(1); + }); + + it('removes and re-clones when a moving remote ref advances', async () => { + existsSyncMock.mockReturnValueOnce(true); + readFileMock.mockResolvedValueOnce(metadata()); + listRemoteMock.mockResolvedValueOnce( + 'new-resolved-ref\trefs/heads/branch-name\n', ); + + await cloneIntoCache('systems', ['cornflake'])(cloneOptions); + + expect(rmMock).toHaveBeenCalledTimes(1); + expect(gitCloneMock).toHaveBeenCalledTimes(1); }); - it('clones using the default branch if no checkout is specified', async () => { - expect.assertions(1); - existsSyncMock.mockReturnValueOnce(false).mockReturnValueOnce(true); - await cloneIntoCache('systems', ['cornflake'])({ repository: 'repo-path' }); + it('clones the default branch and records an explicit null checkout', async () => { + const defaultDestination = cacheDestination(undefined); + + await cloneIntoCache('systems', ['cornflake'])({ + repository: 'repo-path', + }); + expect(gitCloneMock).toHaveBeenCalledWith( - 'repo-path', - 'home/uname/.emulsify/cache/systems/6ce9bab2a26010442d0708dc5ec76cf7/cornflake', + normalizedRepository, + defaultDestination, {}, ); + const writtenMetadata = JSON.parse(writeFileMock.mock.calls[0][1]); + expect(writtenMetadata.checkout).toBeNull(); }); }); diff --git a/src/util/cache/cloneIntoCache.ts b/src/util/cache/cloneIntoCache.ts index 491c5de..f4fe99b 100644 --- a/src/util/cache/cloneIntoCache.ts +++ b/src/util/cache/cloneIntoCache.ts @@ -3,9 +3,162 @@ import type { GitCloneOptions } from '@emulsify-cli/git'; import { simpleGit } from 'simple-git'; import { existsSync, promises as fs } from 'fs'; -import { dirname } from 'path'; +import { dirname, join } from 'path'; +import { EMULSIFY_CACHE_METADATA_FILE } from '../../lib/constants.js'; import getCachedItemPath from './getCachedItemPath.js'; +import normalizeRepositoryUrl from './normalizeRepositoryUrl.js'; + +type CacheMetadata = { + repository: string; + checkout: string | null; + resolvedRef: string; + clonedAt: string; + complete: true; +}; + +type RemoteRefResult = + | { status: 'found'; resolvedRef: string } + | { status: 'missing' } + | { status: 'unavailable' }; + +function isCacheMetadata(value: unknown): value is CacheMetadata { + if (!value || typeof value !== 'object') { + return false; + } + + const metadata = value as Partial<CacheMetadata>; + return ( + typeof metadata.repository === 'string' && + (typeof metadata.checkout === 'string' || metadata.checkout === null) && + typeof metadata.resolvedRef === 'string' && + metadata.resolvedRef.length > 0 && + typeof metadata.clonedAt === 'string' && + !Number.isNaN(Date.parse(metadata.clonedAt)) && + metadata.complete === true + ); +} + +async function readCacheMetadata( + destination: string, +): Promise<CacheMetadata | undefined> { + try { + const contents = await fs.readFile( + join(destination, EMULSIFY_CACHE_METADATA_FILE), + { encoding: 'utf-8' }, + ); + const metadata: unknown = JSON.parse(contents); + return isCacheMetadata(metadata) ? metadata : undefined; + } catch { + return undefined; + } +} + +async function writeCacheMetadata( + destination: string, + metadata: CacheMetadata, +): Promise<void> { + await fs.writeFile( + join(destination, EMULSIFY_CACHE_METADATA_FILE), + JSON.stringify(metadata, null, 2), + { encoding: 'utf-8' }, + ); +} + +function getRemoteRefCandidates(checkout: string | void): string[] { + if (!checkout) { + return ['HEAD']; + } + + if (checkout.startsWith('refs/heads/')) { + return [checkout]; + } + + if (checkout.startsWith('refs/tags/')) { + return [`${checkout}^{}`, checkout]; + } + + if (checkout.startsWith('refs/')) { + return [checkout]; + } + + return [ + `refs/heads/${checkout}`, + `refs/tags/${checkout}^{}`, + `refs/tags/${checkout}`, + ]; +} + +async function getRemoteResolvedRef( + repository: string, + checkout: string | void, +): Promise<RemoteRefResult> { + const remoteRefs = getRemoteRefCandidates(checkout); + + try { + const output = await simpleGit().listRemote([repository, ...remoteRefs]); + const resolvedRefs = new Map<string, string>(); + + for (const line of output.trim().split(/\r?\n/)) { + const [resolvedRef, refName] = line.trim().split(/\s+/, 2); + if (resolvedRef && refName) { + resolvedRefs.set(refName, resolvedRef); + } + } + + for (const remoteRef of remoteRefs) { + const resolvedRef = resolvedRefs.get(remoteRef); + if (resolvedRef) { + return { status: 'found', resolvedRef }; + } + } + + return { status: 'missing' }; + } catch { + // Remote availability should not prevent reuse of a valid local clone. + return { status: 'unavailable' }; + } +} + +async function canReuseCacheEntry( + destination: string, + repository: string, + checkout: string | void, +): Promise<boolean> { + const metadata = await readCacheMetadata(destination); + const expectedCheckout = checkout || null; + if ( + !metadata || + metadata.repository !== repository || + metadata.checkout !== expectedCheckout + ) { + return false; + } + + try { + const git = simpleGit(destination); + const origin = (await git.getRemotes(true)).find( + (remote) => remote.name === 'origin', + ); + if (!origin || normalizeRepositoryUrl(origin.refs.fetch) !== repository) { + return false; + } + + const localResolvedRef = (await git.revparse(['HEAD'])).trim(); + if (localResolvedRef !== metadata.resolvedRef) { + return false; + } + } catch { + return false; + } + + const remoteRef = await getRemoteResolvedRef(repository, checkout); + return ( + remoteRef.status === 'unavailable' || + (remoteRef.status === 'found' && + remoteRef.resolvedRef === metadata.resolvedRef) + ); +} /** * Clones a repository into the cache (util) directory, if it does not already exist. @@ -20,22 +173,31 @@ export default function cloneIntoCache( itemPath: CacheItemPath, ) { return async ({ repository, checkout }: GitCloneOptions): Promise<void> => { - const destination = getCachedItemPath(bucket, itemPath, checkout); + const normalizedRepository = normalizeRepositoryUrl(repository); + const destination = getCachedItemPath({ + bucket, + itemPath, + repository: normalizedRepository, + checkout, + }); const parentDir = dirname(destination); - // If the item is already in cache, simply return. + // Reuse only complete clones whose identity, origin, and resolved ref match. if (existsSync(destination)) { - return; - } + if ( + await canReuseCacheEntry(destination, normalizedRepository, checkout) + ) { + return; + } - // If the bucket/parent directory does not exist, create it. - if (!existsSync(parentDir)) { - await fs.mkdir(parentDir, { recursive: true }); + await fs.rm(destination, { recursive: true, force: true }); } + await fs.mkdir(parentDir, { recursive: true }); + const git = simpleGit(); await git.clone( - repository, + normalizedRepository, destination, checkout ? { @@ -43,5 +205,16 @@ export default function cloneIntoCache( } : {}, ); + + const resolvedRef = ( + await simpleGit(destination).revparse(['HEAD']) + ).trim(); + await writeCacheMetadata(destination, { + repository: normalizedRepository, + checkout: checkout || null, + resolvedRef, + clonedAt: new Date().toISOString(), + complete: true, + }); }; } diff --git a/src/util/cache/copyItemFromCache.test.ts b/src/util/cache/copyItemFromCache.test.ts index 7f65dfc..36d4fad 100644 --- a/src/util/cache/copyItemFromCache.test.ts +++ b/src/util/cache/copyItemFromCache.test.ts @@ -6,6 +6,7 @@ jest.mock('./getCachedItemPath', () => ); import { copy, remove } from 'fs-extra'; import getEmulsifyConfig from '../project/getEmulsifyConfig.js'; +import getCachedItemPath from './getCachedItemPath.js'; import copyItemFromCache from './copyItemFromCache.js'; // Mock the getEmulsifyConfig function @@ -14,6 +15,7 @@ jest.mock('../project/getEmulsifyConfig', () => jest.fn()); describe('copyItemFromCache', () => { const mockConfig = { system: { + repository: 'https://github.com/emulsify-ds/compound.git', checkout: 'checkoutBranch', }, }; @@ -33,6 +35,12 @@ describe('copyItemFromCache', () => { '/home/uname/.emulsify/cache/systems/12345/compound/components/00-base/colors', '/home/uname/Projects/drupal/web/themes/custom/cornflake/components/00-base/colors', ); + expect(getCachedItemPath).toHaveBeenCalledWith({ + bucket: 'systems', + itemPath: ['compound', 'components', '00-base', 'colors'], + repository: 'https://github.com/emulsify-ds/compound.git', + checkout: 'checkoutBranch', + }); }); it('can remove a destination before copying items from the cache if "force" is true', async () => { @@ -48,7 +56,11 @@ describe('copyItemFromCache', () => { }); it('should handle missing emulsifyConfig system checkout', async () => { - (getEmulsifyConfig as jest.Mock).mockResolvedValue({ system: {} }); + (getEmulsifyConfig as jest.Mock).mockResolvedValue({ + system: { + repository: 'https://github.com/emulsify-ds/compound.git', + }, + }); await copyItemFromCache( 'systems', @@ -60,6 +72,12 @@ describe('copyItemFromCache', () => { '/home/uname/.emulsify/cache/systems/12345/compound/components/00-base/colors', '/home/uname/Projects/drupal/web/themes/custom/cornflake/components/00-base/colors', ); + expect(getCachedItemPath).toHaveBeenCalledWith( + expect.objectContaining({ + repository: 'https://github.com/emulsify-ds/compound.git', + checkout: '', + }), + ); }); it('should handle undefined emulsifyConfig', async () => { @@ -75,5 +93,8 @@ describe('copyItemFromCache', () => { '/home/uname/.emulsify/cache/systems/12345/compound/components/00-base/colors', '/home/uname/Projects/drupal/web/themes/custom/cornflake/components/00-base/colors', ); + expect(getCachedItemPath).toHaveBeenCalledWith( + expect.objectContaining({ repository: '', checkout: '' }), + ); }); }); diff --git a/src/util/cache/copyItemFromCache.ts b/src/util/cache/copyItemFromCache.ts index 2731399..b287353 100644 --- a/src/util/cache/copyItemFromCache.ts +++ b/src/util/cache/copyItemFromCache.ts @@ -21,15 +21,14 @@ export default async function copyFileFromCache( ): Promise<void> { // Get the existing checkout setting if we have it and use it. const emulsifyConfig = await getEmulsifyConfig(); - let checkout = ''; - if ( - emulsifyConfig != undefined && - emulsifyConfig.system && - emulsifyConfig.system.checkout - ) { - checkout = emulsifyConfig.system.checkout; - } - const source = getCachedItemPath(bucket, itemPath, checkout); + const repository = emulsifyConfig?.system?.repository || ''; + const checkout = emulsifyConfig?.system?.checkout || ''; + const source = getCachedItemPath({ + bucket, + itemPath, + repository, + checkout, + }); if (force) { await remove(destination); diff --git a/src/util/cache/getCachedItemCheckout.test.ts b/src/util/cache/getCachedItemCheckout.test.ts index a122203..2cf199d 100644 --- a/src/util/cache/getCachedItemCheckout.test.ts +++ b/src/util/cache/getCachedItemCheckout.test.ts @@ -1,19 +1,39 @@ -jest.mock('./getCachedItemPath', () => - jest.fn(() => '/home/uname/.emulsify/cache/systems/12345/compound'), -); -import { simpleGit as git } from 'simple-git'; +jest.mock('../../lib/constants', () => ({ + CACHE_DIR: 'home/uname/.emulsify/cache', + EMULSIFY_PROJECT_CONFIG_FILE: 'project.emulsify.json', +})); +jest.mock('../fs/findFileInCurrentPath', () => jest.fn()); + +import { simpleGit } from 'simple-git'; +import findFileInCurrentPath from '../fs/findFileInCurrentPath.js'; import getCachedItemCheckout from './getCachedItemCheckout.js'; -const gitBranchMock = git().branch as jest.Mock; -gitBranchMock.mockResolvedValue({ - current: 'the-current-branch', -}); +const simpleGitMock = simpleGit as unknown as jest.Mock; +const gitBranchMock = simpleGit().branch as jest.Mock; describe('getCachedItemCheckout', () => { - it('can grab the current checkout of the specified repository', async () => { - expect.assertions(2); - await expect(getCachedItemCheckout('systems', ['compound'])).resolves.toBe( - 'the-current-branch', + beforeEach(() => { + jest.clearAllMocks(); + (findFileInCurrentPath as jest.Mock).mockReturnValue( + '/home/uname/projects/emulsify', + ); + gitBranchMock.mockResolvedValue({ + current: 'the-current-branch', + }); + }); + + it('forwards repository and checkout to the observable cache path', async () => { + await expect( + getCachedItemCheckout({ + bucket: 'systems', + itemPath: ['compound'], + repository: 'https://github.com/emulsify-ds/compound.git', + checkout: 'branch-name', + }), + ).resolves.toBe('the-current-branch'); + + expect(simpleGitMock).toHaveBeenCalledWith( + 'home/uname/.emulsify/cache/systems/9b98dd5ac046dc9335088731270ccc72/compound', ); expect(gitBranchMock).toHaveBeenCalledTimes(1); }); diff --git a/src/util/cache/getCachedItemCheckout.ts b/src/util/cache/getCachedItemCheckout.ts index 496b41e..9491e66 100644 --- a/src/util/cache/getCachedItemCheckout.ts +++ b/src/util/cache/getCachedItemCheckout.ts @@ -1,22 +1,20 @@ -import type { CacheBucket, CacheItemPath } from '@emulsify-cli/cache'; +import type { CachedItemPathOptions } from '@emulsify-cli/cache'; import { simpleGit } from 'simple-git'; import getCachedItemPath from './getCachedItemPath.js'; /** - * Takes a cache bucket and item path, and returns the checkout value (git branch/tag/commit) + * Takes an explicit cache identity and returns the checkout value (git branch/tag/commit) * that is currently checked out within the cloned item. * - * @param type CacheBucket value that specifies what type of cache this repository is. - * @param itemPath array of strings describing the path to the item cache within the specified bucket. + * @param options cache bucket, item path, repository, and requested checkout. * * @returns string indicating the branch/tag/commit that is currently checked out. */ export default async function getCachedItemCheckout( - bucket: CacheBucket, - itemPath: CacheItemPath, + options: CachedItemPathOptions, ): Promise<string> { - const location = getCachedItemPath(bucket, itemPath); + const location = getCachedItemPath(options); const git = simpleGit(location); return (await git.branch()).current; } diff --git a/src/util/cache/getCachedItemPath.test.ts b/src/util/cache/getCachedItemPath.test.ts index f8ee12f..640471c 100644 --- a/src/util/cache/getCachedItemPath.test.ts +++ b/src/util/cache/getCachedItemPath.test.ts @@ -4,44 +4,76 @@ jest.mock('../../lib/constants', () => ({ })); jest.mock('../fs/findFileInCurrentPath', () => jest.fn()); +import type { CachedItemPathOptions } from '@emulsify-cli/cache'; import findFileInCurrentPath from '../fs/findFileInCurrentPath.js'; import getCachedItemPath from './getCachedItemPath.js'; -const findFileMock = (findFileInCurrentPath as jest.Mock).mockReturnValue( - '/home/uname/projects/emulsify', -); +const findFileMock = findFileInCurrentPath as jest.Mock; +const baseOptions: CachedItemPathOptions = { + bucket: 'systems', + itemPath: ['compound', 'system.emulsify.json'], + repository: 'https://github.com/emulsify-ds/compound.git', + checkout: 'branch-name', +}; describe('getCachedItemPath', () => { - it('can produce the path to a cached file if given a cache bucket, cache item name, and filename', () => { - expect.assertions(2); + beforeEach(() => { + jest.clearAllMocks(); + findFileMock.mockReturnValue('/home/uname/projects/emulsify'); + }); + + it('produces the path to a cached item from its complete identity', () => { + expect(getCachedItemPath(baseOptions)).toBe( + 'home/uname/.emulsify/cache/systems/9b98dd5ac046dc9335088731270ccc72/compound/system.emulsify.json', + ); expect( - getCachedItemPath( - 'systems', - ['compound', 'system.emulsify.json'], - 'branch-name', - ), + getCachedItemPath({ + ...baseOptions, + bucket: 'variants', + itemPath: ['compound', 'drupal', 'variant.emulsify.json'], + }), ).toBe( - 'home/uname/.emulsify/cache/systems/2a39785f5c873d7a694ac505a8123bb9/compound/system.emulsify.json', + 'home/uname/.emulsify/cache/variants/9b98dd5ac046dc9335088731270ccc72/compound/drupal/variant.emulsify.json', ); + }); + + it('uses the repository URL in the cache key', () => { + const firstRepository = getCachedItemPath(baseOptions); + const secondRepository = getCachedItemPath({ + ...baseOptions, + repository: 'https://github.com/example/compound.git', + }); + + expect(firstRepository).not.toBe(secondRepository); + expect(secondRepository).toContain('/cbff5fecb35c99193f7b61a1feaba881/'); + }); + + it('uses the checkout in the cache key', () => { expect( - getCachedItemPath( - 'variants', - ['compound', 'drupal', 'variant.emulsify.json'], - 'branch-name', - ), - ).toBe( - 'home/uname/.emulsify/cache/variants/2a39785f5c873d7a694ac505a8123bb9/compound/drupal/variant.emulsify.json', + getCachedItemPath({ ...baseOptions, checkout: 'other-branch' }), + ).toContain('/330b95546066d5ea9bed9181f8f4f8b4/'); + }); + + it('normalizes whitespace and trailing slashes in repository URLs', () => { + expect( + getCachedItemPath({ + ...baseOptions, + repository: ' https://github.com/emulsify-ds/compound.git/ ', + }), + ).toBe(getCachedItemPath(baseOptions)); + }); + + it('treats an undefined checkout as an empty checkout', () => { + expect(getCachedItemPath({ ...baseOptions, checkout: undefined })).toBe( + getCachedItemPath({ ...baseOptions, checkout: '' }), ); }); it('throws an error if a project config file is not found', () => { findFileMock.mockReturnValueOnce(undefined); - expect(() => - getCachedItemPath( - 'systems', - ['compound', 'system.emulsify.json'], - 'branch-name', - ), - ).toThrow('Unable to find project.emulsify.json'); + + expect(() => getCachedItemPath(baseOptions)).toThrow( + 'Unable to find project.emulsify.json', + ); }); }); diff --git a/src/util/cache/getCachedItemPath.ts b/src/util/cache/getCachedItemPath.ts index 6274066..9e1c503 100644 --- a/src/util/cache/getCachedItemPath.ts +++ b/src/util/cache/getCachedItemPath.ts @@ -1,8 +1,4 @@ -import type { - CacheBucket, - CacheItemPath, - CacheCheckout, -} from '@emulsify-cli/cache'; +import type { CachedItemPathOptions } from '@emulsify-cli/cache'; import { join } from 'path'; import { createHash } from 'crypto'; @@ -11,38 +7,38 @@ import { EMULSIFY_PROJECT_CONFIG_FILE, } from '../../lib/constants.js'; import findFileInCurrentPath from '../fs/findFileInCurrentPath.js'; +import normalizeRepositoryUrl from './normalizeRepositoryUrl.js'; /** - * Accepts a cache bucket, item path, and item name, and returns the full - * path to the specified item. + * Accepts an explicit cache identity and returns the full path to the item. * - * @param bucket name of the bucket containing the cached item. - * @param itemPath array containing segments of the path to the cached item within the specified bucket. - * @param checkout commit, branch, or tag of the system this project is utilizing. + * @param options cache bucket, item path, repository, and checkout. * @returns string containing the full path to the specified cached item. */ -export default function getCachedItemPath( - bucket: CacheBucket, - itemPath: CacheItemPath, - checkout: CacheCheckout, -): string { +export default function getCachedItemPath({ + bucket, + itemPath, + repository, + checkout, +}: CachedItemPathOptions): string { const projectPath = findFileInCurrentPath(EMULSIFY_PROJECT_CONFIG_FILE); if (!projectPath) { throw new Error(`Unable to find ${EMULSIFY_PROJECT_CONFIG_FILE}`); } - // Preventing oddity when checkout isn't set. - if (!checkout || typeof checkout === 'undefined') { - checkout = ''; - } + const normalizedRepository = normalizeRepositoryUrl(repository); + const normalizedCheckout = checkout || ''; + const identity = JSON.stringify({ + projectPath, + repository: normalizedRepository, + checkout: normalizedCheckout, + }); return join( CACHE_DIR, bucket, - createHash('md5') - .update(`MBR${String(projectPath)}${String(checkout)}`) - .digest('hex'), + createHash('md5').update(identity).digest('hex'), ...itemPath, ); } diff --git a/src/util/cache/getJsonFromCachedFile.test.ts b/src/util/cache/getJsonFromCachedFile.test.ts index d878c4c..ca158c0 100644 --- a/src/util/cache/getJsonFromCachedFile.test.ts +++ b/src/util/cache/getJsonFromCachedFile.test.ts @@ -4,17 +4,21 @@ jest.mock('./getCachedItemPath', () => '/home/uname/.emulsify/cache/systems/12345/compound/system.emulsify.json', ), ); -jest.mock('../../lib/constants', () => ({ - CACHE_DIR: 'home/uname/.emulsify/cache', -})); jest.mock('../fs/loadJsonFile', () => jest.fn()); import loadJsonFile from '../fs/loadJsonFile.js'; +import getCachedItemPath from './getCachedItemPath.js'; import getJsonFromCachedFile from './getJsonFromCachedFile.js'; -const loadJsonMock = (loadJsonFile as jest.Mock).mockResolvedValue({ - the: 'json', -}); +const loadJsonMock = loadJsonFile as jest.Mock; +const getCachedItemPathMock = getCachedItemPath as jest.Mock; +const options = { + bucket: 'systems' as const, + itemPath: ['compound'], + repository: 'https://github.com/emulsify-ds/compound.git', + checkout: 'branch-name', + fileName: 'system.emulsify.json', +}; describe('getJsonFromCachedFile', () => { beforeEach(() => { @@ -24,52 +28,35 @@ describe('getJsonFromCachedFile', () => { }); }); - it('can load and parse the JSON from a file stored in cache', async () => { - expect.assertions(2); - await expect( - getJsonFromCachedFile( - 'systems', - ['compound'], - 'branch-name', - 'system.emulsify.json', - ), - ).resolves.toEqual({ + it('loads JSON using the complete cache identity', async () => { + await expect(getJsonFromCachedFile(options)).resolves.toEqual({ the: 'json', }); + expect(getCachedItemPathMock).toHaveBeenCalledWith({ + bucket: 'systems', + itemPath: ['compound', 'system.emulsify.json'], + repository: 'https://github.com/emulsify-ds/compound.git', + checkout: 'branch-name', + }); expect(loadJsonMock).toHaveBeenCalledWith( '/home/uname/.emulsify/cache/systems/12345/compound/system.emulsify.json', ); }); it('returns undefined if the file is not found', async () => { - expect.assertions(1); loadJsonMock.mockResolvedValueOnce(undefined); - await expect( - getJsonFromCachedFile( - 'systems', - ['compound'], - 'branch-name', - 'system.emulsify.json', - ), - ).resolves.toBe(undefined); + + await expect(getJsonFromCachedFile(options)).resolves.toBeUndefined(); }); it('throws malformed cached JSON errors with the cached file path', async () => { - expect.assertions(1); loadJsonMock.mockRejectedValueOnce( new Error( - 'Invalid JSON in "/home/uname/.emulsify/cache/systems/12345/compound/system.emulsify.json": Expected property name or \'}\' in JSON', + 'Invalid JSON in "/home/uname/.emulsify/cache/systems/12345/compound/system.emulsify.json": malformed', ), ); - await expect( - getJsonFromCachedFile( - 'systems', - ['compound'], - 'branch-name', - 'system.emulsify.json', - ), - ).rejects.toThrow( + await expect(getJsonFromCachedFile(options)).rejects.toThrow( 'Invalid JSON in "/home/uname/.emulsify/cache/systems/12345/compound/system.emulsify.json"', ); }); diff --git a/src/util/cache/getJsonFromCachedFile.ts b/src/util/cache/getJsonFromCachedFile.ts index 4249e1c..bb3204c 100644 --- a/src/util/cache/getJsonFromCachedFile.ts +++ b/src/util/cache/getJsonFromCachedFile.ts @@ -1,19 +1,16 @@ -import type { - CacheBucket, - CacheItemPath, - CacheCheckout, -} from '@emulsify-cli/cache'; +import type { CachedItemPathOptions } from '@emulsify-cli/cache'; import getCachedItemPath from './getCachedItemPath.js'; import loadJsonFile from '../fs/loadJsonFile.js'; export default async function getJsonFromCachedFile<Output>( - bucket: CacheBucket, - itemPath: CacheItemPath, - checkout: CacheCheckout, - fileName: string, + options: CachedItemPathOptions & { fileName: string }, ): Promise<Output | void> { + const { fileName, ...cachedItemOptions } = options; return loadJsonFile<Output>( - getCachedItemPath(bucket, [...itemPath, fileName], checkout), + getCachedItemPath({ + ...cachedItemOptions, + itemPath: [...cachedItemOptions.itemPath, fileName], + }), ); } diff --git a/src/util/cache/normalizeRepositoryUrl.test.ts b/src/util/cache/normalizeRepositoryUrl.test.ts new file mode 100644 index 0000000..e5cff4f --- /dev/null +++ b/src/util/cache/normalizeRepositoryUrl.test.ts @@ -0,0 +1,35 @@ +import { resolve } from 'path'; +import normalizeRepositoryUrl from './normalizeRepositoryUrl.js'; + +describe('normalizeRepositoryUrl', () => { + it('trims repository URLs and removes trailing slashes', () => { + expect( + normalizeRepositoryUrl(' https://example.com/project.git/// '), + ).toBe('https://example.com/project.git'); + }); + + it('preserves SCP-style repository URLs', () => { + expect(normalizeRepositoryUrl('git@example.com:org/project.git/')).toBe( + 'git@example.com:org/project.git', + ); + }); + + it('resolves relative local repository paths', () => { + expect(normalizeRepositoryUrl('./fixtures/project.git/')).toBe( + resolve('./fixtures/project.git'), + ); + }); + + it('preserves absolute POSIX and Windows repository paths', () => { + expect(normalizeRepositoryUrl('/tmp/project.git/')).toBe( + '/tmp/project.git', + ); + expect(normalizeRepositoryUrl('\\\\server\\share\\project.git')).toBe( + '\\\\server\\share\\project.git', + ); + }); + + it('preserves an empty repository fallback', () => { + expect(normalizeRepositoryUrl(' ')).toBe(''); + }); +}); diff --git a/src/util/cache/normalizeRepositoryUrl.ts b/src/util/cache/normalizeRepositoryUrl.ts new file mode 100644 index 0000000..ba7e7c4 --- /dev/null +++ b/src/util/cache/normalizeRepositoryUrl.ts @@ -0,0 +1,29 @@ +import { isAbsolute, resolve, win32 } from 'path'; + +/** + * Normalize repository URLs before using them as cache identity inputs. + * + * Trimming whitespace and trailing slashes keeps equivalent configured and + * on-disk remote values from producing separate cache entries. Git records + * relative local clone origins as absolute paths, so local paths are resolved + * before they are hashed, cloned, or compared. + */ +export default function normalizeRepositoryUrl(repository: string): string { + const normalizedRepository = repository.trim().replace(/\/+$/, ''); + if (!normalizedRepository) { + return ''; + } + + const isUri = /^[a-z][a-z\d+.-]*:\/\//i.test(normalizedRepository); + const isScpLike = /^(?:[^@/\\\s]+@)?[^:/\\\s]+:.+/.test(normalizedRepository); + if ( + isUri || + isScpLike || + isAbsolute(normalizedRepository) || + win32.isAbsolute(normalizedRepository) + ) { + return normalizedRepository; + } + + return resolve(normalizedRepository); +} From 9347041494fe0379733ad218be5076742784fe11 Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Sat, 29 Aug 2026 17:07:54 -0500 Subject: [PATCH 11/27] fix(system): validate cached system configuration on the read path --- docs/systems.md | 2 + src/handlers/hofs/withEmulsifySystem.test.ts | 91 +++++++++++++++++++ src/handlers/hofs/withEmulsifySystem.ts | 85 ++++++++++++++++- src/handlers/systemInstall.test.ts | 12 ++- src/handlers/systemInstall.ts | 26 +----- .../platform/platformCompatibility.test.ts | 36 ++++++++ src/util/platform/platformCompatibility.ts | 31 ++++++- src/util/system/validateSystemConfig.test.ts | 70 ++++++++++++++ src/util/system/validateSystemConfig.ts | 58 ++++++++++++ 9 files changed, 378 insertions(+), 33 deletions(-) create mode 100644 src/util/system/validateSystemConfig.test.ts create mode 100644 src/util/system/validateSystemConfig.ts diff --git a/docs/systems.md b/docs/systems.md index 227445d..17b9545 100644 --- a/docs/systems.md +++ b/docs/systems.md @@ -189,6 +189,8 @@ System variants can target one platform or multiple compatible platforms: Supported platform tokens are `drupal`, `wordpress`, and `none`. +When commands read an installed system, variants with platform expressions the current CLI does not recognize are skipped. The CLI emits one warning listing every skipped expression and continues when a usable variant remains; if none remains, the error lists the expressions so you can distinguish a typo from a system that may require a newer CLI. System installation remains strict and rejects unsupported platform expressions. + `none` on a variant means generic. A variant with `"platform": "none"` can be installed by any concrete project platform. Project configuration is different: `project.platform` is always a concrete value (`drupal`, `wordpress`, or `none`). Do not put `||` expressions in `project.platform`; only system variants use compatibility expressions. diff --git a/src/handlers/hofs/withEmulsifySystem.test.ts b/src/handlers/hofs/withEmulsifySystem.test.ts index 9a1ab3b..f2b6afc 100644 --- a/src/handlers/hofs/withEmulsifySystem.test.ts +++ b/src/handlers/hofs/withEmulsifySystem.test.ts @@ -5,9 +5,11 @@ jest.mock('../../util/project/getEmulsifyConfig', () => jest.fn()); jest.mock('../../util/cache/getJsonFromCachedFile', () => jest.fn()); jest.mock('../../util/cache/cloneIntoCache', () => jest.fn()); +jest.mock('../../lib/log', () => jest.fn()); import type { EmulsifySystem } from '@emulsify-cli/config'; import { EMULSIFY_SYSTEM_CONFIG_FILE } from '../../lib/constants.js'; +import log from '../../lib/log.js'; import getJsonFromCachedFile from '../../util/cache/getJsonFromCachedFile.js'; import cloneIntoCache from '../../util/cache/cloneIntoCache.js'; import getEmulsifyConfig from '../../util/project/getEmulsifyConfig.js'; @@ -19,6 +21,7 @@ import { const getEmulsifyConfigMock = getEmulsifyConfig as jest.Mock; const getJsonFromCachedFileMock = getJsonFromCachedFile as jest.Mock; const cloneIntoCacheMock = cloneIntoCache as jest.Mock; +const logMock = log as jest.Mock; const cloneSystemMock = jest.fn(); const projectConfig = { @@ -167,6 +170,94 @@ describe('withEmulsifySystem', () => { }); }); + it('rejects a cached system that fails schema validation with recovery guidance', async () => { + getJsonFromCachedFileMock.mockResolvedValueOnce({ + ...system, + homepage: 'not-a-uri', + }); + + const error = await withEmulsifySystem('install components').catch( + (caught: unknown) => caught, + ); + + expect(error).toBeInstanceOf(EmulsifySystemError); + expect(error).toMatchObject({ + message: + 'The cached copy of the compound system is invalid. Run "emulsify cache clear" and retry this command to re-clone it. To reinstall the system instead, remove the existing system and variant entries from project.emulsify.json, then re-run "emulsify system install".', + }); + }); + + it('rejects a cached variant missing components before callers can raise a TypeError', async () => { + const { components: _components, ...variantWithoutComponents } = variant; + getJsonFromCachedFileMock.mockResolvedValueOnce({ + ...system, + variants: [variantWithoutComponents], + }); + + const error = await withEmulsifySystem('list components').catch( + (caught: unknown) => caught, + ); + + expect(error).toBeInstanceOf(EmulsifySystemError); + expect(error).not.toBeInstanceOf(TypeError); + expect(error).toMatchObject({ + message: + 'The cached copy of the compound system is invalid. Run "emulsify cache clear" and retry this command to re-clone it. To reinstall the system instead, remove the existing system and variant entries from project.emulsify.json, then re-run "emulsify system install".', + }); + }); + + it('skips unsupported variants, warns once, and selects a usable variant', async () => { + const unsupportedVariant = { + ...variant, + platform: 'eleventy', + }; + getJsonFromCachedFileMock.mockResolvedValueOnce({ + ...system, + variants: [unsupportedVariant, variant], + }); + + await expect(withEmulsifySystem('list components')).resolves.toEqual({ + emulsifyConfig: projectConfig, + systemName: 'compound', + systemConf: { + ...system, + variants: [variant], + }, + variantConf: variant, + }); + expect(logMock).toHaveBeenCalledTimes(1); + expect(logMock).toHaveBeenCalledWith( + 'warn', + 'Skipped variants in the compound system with platform expressions this CLI does not understand: "eleventy". The system may require a newer Emulsify CLI or contain a typo.', + ); + }); + + it('lists every unsupported platform when no variant is usable', async () => { + getJsonFromCachedFileMock.mockResolvedValueOnce({ + ...system, + variants: [ + { ...variant, platform: 'eleventy' }, + { ...variant, platform: 'astro' }, + { ...variant, platform: 'eleventy' }, + ], + }); + + const error = await withEmulsifySystem('list components').catch( + (caught: unknown) => caught, + ); + + expect(error).toBeInstanceOf(EmulsifySystemError); + expect(error).toMatchObject({ + message: + 'Unable to find configuration for the variant drupal within the system compound. The CLI did not understand these variant platform expressions: "eleventy", "astro". The system may require a newer Emulsify CLI or contain a typo.', + }); + expect(logMock).toHaveBeenCalledTimes(1); + expect(logMock).toHaveBeenCalledWith( + 'warn', + 'Skipped variants in the compound system with platform expressions this CLI does not understand: "eleventy", "astro". The system may require a newer Emulsify CLI or contain a typo.', + ); + }); + it('throws a typed error when the configured variant is not found', async () => { const wordpressVariant = { ...variant, diff --git a/src/handlers/hofs/withEmulsifySystem.ts b/src/handlers/hofs/withEmulsifySystem.ts index 76ad331..61f43dd 100644 --- a/src/handlers/hofs/withEmulsifySystem.ts +++ b/src/handlers/hofs/withEmulsifySystem.ts @@ -12,6 +12,7 @@ import { EMULSIFY_SYSTEM_CONFIG_FILE, } from '../../lib/constants.js'; import CliError from '../../lib/CliError.js'; +import log from '../../lib/log.js'; import cloneIntoCache from '../../util/cache/cloneIntoCache.js'; import getJsonFromCachedFile from '../../util/cache/getJsonFromCachedFile.js'; import getGitRepoNameFromUrl from '../../util/getGitRepoNameFromUrl.js'; @@ -19,7 +20,9 @@ import getEmulsifyConfig from '../../util/project/getEmulsifyConfig.js'; import { selectCompatiblePlatformVariant, selectExactPlatformVariant, + tryParsePlatformExpression, } from '../../util/platform/platformCompatibility.js'; +import validateSystemConfig from '../../util/system/validateSystemConfig.js'; type EmulsifyProjectConfigurationWithSystem = EmulsifyProjectConfiguration & { system: NonNullable<EmulsifyProjectConfiguration['system']>; @@ -44,6 +47,55 @@ export class EmulsifySystemError extends CliError { } } +type PreparedCachedSystemConfig = { + systemConfig: unknown; + skippedPlatformExpressions: string[]; +}; + +function prepareCachedSystemConfig( + systemConfig: unknown, +): PreparedCachedSystemConfig { + if (systemConfig === null || typeof systemConfig !== 'object') { + return { systemConfig, skippedPlatformExpressions: [] }; + } + + const variants = (systemConfig as { variants?: unknown }).variants; + if (!Array.isArray(variants)) { + return { systemConfig, skippedPlatformExpressions: [] }; + } + + const skippedPlatformExpressions = new Set<string>(); + const supportedVariants = variants.filter((variant: unknown) => { + if (variant === null || typeof variant !== 'object') { + return true; + } + + const platform = (variant as { platform?: unknown }).platform; + if (typeof platform !== 'string') { + return true; + } + + if (tryParsePlatformExpression(platform)) { + return true; + } + + skippedPlatformExpressions.add(platform); + return false; + }); + + return { + systemConfig: + skippedPlatformExpressions.size === 0 + ? systemConfig + : { ...systemConfig, variants: supportedVariants }, + skippedPlatformExpressions: [...skippedPlatformExpressions], + }; +} + +function formatPlatformExpressions(expressions: readonly string[]): string { + return expressions.map((expression) => JSON.stringify(expression)).join(', '); +} + /** * Load and validate the current project's configured Emulsify system and variant. * @@ -56,7 +108,7 @@ export class EmulsifySystemError extends CliError { * @throws {Error} if the configured system repository URL is malformed before a repository name can be parsed. * @throws {EmulsifySystemError} if the configured system repository does not contain a parseable repository name. * @throws {EmulsifySystemError} if the configured system cannot be cloned or checked out from cache. - * @throws {EmulsifySystemError} if the cached system configuration cannot be loaded. + * @throws {EmulsifySystemError} if the cached system configuration cannot be loaded or validated. * @throws {EmulsifySystemError} if the configured variant cannot be found within the cached system configuration. * * @example @@ -95,7 +147,7 @@ export async function withEmulsifySystem( ); } - const systemConf: EmulsifySystem | void = await getJsonFromCachedFile({ + const loadedSystemConf: unknown = await getJsonFromCachedFile({ bucket: 'systems', itemPath: [systemName], repository: systemReference.repository, @@ -103,12 +155,33 @@ export async function withEmulsifySystem( fileName: EMULSIFY_SYSTEM_CONFIG_FILE, }); - if (!systemConf) { + if (!loadedSystemConf) { throw new EmulsifySystemError( `Unable to load configuration for the ${systemName} system. Please make sure the system is installed.`, ); } + let validation = await validateSystemConfig(loadedSystemConf); + const { systemConfig, skippedPlatformExpressions } = + prepareCachedSystemConfig(loadedSystemConf); + if (skippedPlatformExpressions.length > 0) { + validation = await validateSystemConfig(systemConfig); + } + + if (!validation.valid) { + throw new EmulsifySystemError( + `The cached copy of the ${systemName} system is invalid. Run "emulsify cache clear" and retry this command to re-clone it. To reinstall the system instead, remove the existing system and variant entries from project.emulsify.json, then re-run "emulsify system install".`, + ); + } + + const systemConf = validation.systemConfig; + if (skippedPlatformExpressions.length > 0) { + log( + 'warn', + `Skipped variants in the ${systemName} system with platform expressions this CLI does not understand: ${formatPlatformExpressions(skippedPlatformExpressions)}. The system may require a newer Emulsify CLI or contain a typo.`, + ); + } + const variantName = variantReference.platform; const exactVariantSelection = selectExactPlatformVariant( systemConf.variants, @@ -125,8 +198,12 @@ export async function withEmulsifySystem( ? compatibleVariantSelection.variant : undefined; if (!variantConf) { + const skippedPlatformMessage = + skippedPlatformExpressions.length > 0 + ? ` The CLI did not understand these variant platform expressions: ${formatPlatformExpressions(skippedPlatformExpressions)}. The system may require a newer Emulsify CLI or contain a typo.` + : ''; throw new EmulsifySystemError( - `Unable to find configuration for the variant ${variantName} within the system ${systemName}.`, + `Unable to find configuration for the variant ${variantName} within the system ${systemName}.${skippedPlatformMessage}`, ); } diff --git a/src/handlers/systemInstall.test.ts b/src/handlers/systemInstall.test.ts index 130c596..1cb425d 100644 --- a/src/handlers/systemInstall.test.ts +++ b/src/handlers/systemInstall.test.ts @@ -445,7 +445,7 @@ describe('systemInstall', () => { ); }); - it('throws validation errors from malformed system configuration', async () => { + it('preserves install validation output after shared extraction', async () => { const consoleErrorMock = jest .spyOn(console, 'error') .mockImplementation(jest.fn()); @@ -458,7 +458,15 @@ describe('systemInstall', () => { 'The system install failed due to the validation errors reported above. Please fix the the errors in the "compound" configuration and try again.', ); - expect(consoleErrorMock).toHaveBeenCalled(); + expect(consoleErrorMock).toHaveBeenCalledWith( + 'System configuration errors:', + expect.arrayContaining([ + expect.objectContaining({ + instancePath: '/homepage', + keyword: 'format', + }), + ]), + ); consoleErrorMock.mockRestore(); }); diff --git a/src/handlers/systemInstall.ts b/src/handlers/systemInstall.ts index 74be365..dd66a95 100644 --- a/src/handlers/systemInstall.ts +++ b/src/handlers/systemInstall.ts @@ -6,8 +6,6 @@ import type { Platform, } from '@emulsify-cli/config'; -import { Ajv } from 'ajv'; -import addFormats from 'ajv-formats'; import { dirname, join } from 'path'; import { existsSync } from 'fs'; import { select } from '@inquirer/prompts'; @@ -32,6 +30,7 @@ import getEmulsifyConfig from '../util/project/getEmulsifyConfig.js'; import findFileInCurrentPath from '../util/fs/findFileInCurrentPath.js'; import executeScript from '../util/fs/executeScript.js'; import writeToJsonFile from '../util/fs/writeToJsonFile.js'; +import validateSystemConfig from '../util/system/validateSystemConfig.js'; import { getVariantPlatformExpressions, isPlatform, @@ -44,16 +43,6 @@ const CANCEL_SYSTEM_INSTALL_CHOICE = 'cancel'; const SYSTEM_INSTALL_ERROR = 'Unable to download specified system. You must either specify a valid name of an out-of-the-box system using the --name flag, or specify a valid repository and branch/tag/commit using the --repository and --checkout flags.'; -async function loadSchemas() { - const [{ default: systemSchema }, { default: variantSchema }] = - await Promise.all([ - import('../schemas/system.json', { with: { type: 'json' } }), - import('../schemas/variant.json', { with: { type: 'json' } }), - ]); - - return { systemSchema, variantSchema }; -} - /** * Helper function that uses InstallSystemHandlerOptions input to determine what * system should be installed, if any. @@ -349,16 +338,9 @@ export default async function systemInstall( // Validate the system configuration file. try { - const { systemSchema, variantSchema } = await loadSchemas(); - const ajv = new Ajv(); - // This is unfortunate... - // @ts-ignore The ajv-formats typing is bad :( - addFormats(ajv, ['uri']); - ajv.addSchema(variantSchema, 'variant.json'); - const validate = ajv.compile(systemSchema); - - if (!validate(systemConf)) { - throw validate.errors; + const validation = await validateSystemConfig(systemConf); + if (!validation.valid) { + throw validation.errors; } } catch (e) { // We're logging to the console here instead of our normal logging mechanism diff --git a/src/util/platform/platformCompatibility.test.ts b/src/util/platform/platformCompatibility.test.ts index 11bf3ac..af7a51e 100644 --- a/src/util/platform/platformCompatibility.test.ts +++ b/src/util/platform/platformCompatibility.test.ts @@ -6,12 +6,14 @@ import { platformExpressionMatchesProject, selectCompatiblePlatformVariant, selectExactPlatformVariant, + tryParsePlatformExpression, } from './platformCompatibility.js'; const drupalVariant = { platform: 'drupal', name: 'drupal' }; const wordpressVariant = { platform: 'wordpress', name: 'wordpress' }; const sharedVariant = { platform: 'drupal || wordpress', name: 'shared' }; const genericVariant = { platform: 'none', name: 'generic' }; +const unknownVariant = { platform: 'eleventy', name: 'future' }; describe('platformCompatibility', () => { describe('isPlatform', () => { @@ -49,6 +51,19 @@ describe('platformCompatibility', () => { }); }); + describe('tryParsePlatformExpression', () => { + it('returns parsed platforms for valid expressions', () => { + expect(tryParsePlatformExpression('drupal || wordpress')).toEqual([ + 'drupal', + 'wordpress', + ]); + }); + + it('returns undefined for invalid expressions', () => { + expect(tryParsePlatformExpression('eleventy')).toBeUndefined(); + }); + }); + it('normalizes platform expression whitespace', () => { expect(normalizePlatformExpression('drupal|| wordpress')).toBe( 'drupal || wordpress', @@ -88,6 +103,18 @@ describe('platformCompatibility', () => { }); describe('selectCompatiblePlatformVariant', () => { + it('skips unparseable variants when a compatible variant exists', () => { + expect( + selectCompatiblePlatformVariant( + [unknownVariant, drupalVariant], + 'drupal', + ), + ).toEqual({ + status: 'selected', + variant: drupalVariant, + }); + }); + it('prefers exact matches over shared and generic variants', () => { expect( selectCompatiblePlatformVariant( @@ -171,6 +198,15 @@ describe('platformCompatibility', () => { }); describe('selectExactPlatformVariant', () => { + it('skips unparseable variants when an exact variant exists', () => { + expect( + selectExactPlatformVariant([unknownVariant, drupalVariant], 'drupal'), + ).toEqual({ + status: 'selected', + variant: drupalVariant, + }); + }); + it('selects exact normalized platform expressions', () => { expect( selectExactPlatformVariant( diff --git a/src/util/platform/platformCompatibility.ts b/src/util/platform/platformCompatibility.ts index b853142..ddfdb4d 100644 --- a/src/util/platform/platformCompatibility.ts +++ b/src/util/platform/platformCompatibility.ts @@ -40,6 +40,23 @@ export function parsePlatformExpression(expression: string): Platform[] { return [...new Set(platforms)] as Platform[]; } +/** + * Parse a platform expression without throwing for tokens this CLI does not + * understand yet. + * + * @param expression platform compatibility expression to parse. + * @returns parsed platforms, or undefined when the expression is invalid. + */ +export function tryParsePlatformExpression( + expression: string, +): Platform[] | undefined { + try { + return parsePlatformExpression(expression); + } catch { + return undefined; + } +} + export function normalizePlatformExpression(expression: string): string { return parsePlatformExpression(expression).join(' || '); } @@ -61,7 +78,11 @@ function getVariantMatchRank( variantPlatform: string, projectPlatform: Platform, ): number | undefined { - const platforms = parsePlatformExpression(variantPlatform); + const platforms = tryParsePlatformExpression(variantPlatform); + if (!platforms) { + return undefined; + } + const normalizedVariantPlatform = platforms.join(' || '); if (normalizedVariantPlatform === projectPlatform) { @@ -131,10 +152,10 @@ export function selectExactPlatformVariant<T extends VariantWithPlatform>( ): VariantSelectionResult<T> { const normalizedPlatformExpression = normalizePlatformExpression(platformExpression); - const matches = (variants || []).filter( - ({ platform }) => - normalizePlatformExpression(platform) === normalizedPlatformExpression, - ); + const matches = (variants || []).filter(({ platform }) => { + const platforms = tryParsePlatformExpression(platform); + return platforms?.join(' || ') === normalizedPlatformExpression; + }); if (matches.length === 0) { return { status: 'none' }; diff --git a/src/util/system/validateSystemConfig.test.ts b/src/util/system/validateSystemConfig.test.ts new file mode 100644 index 0000000..1a2d31c --- /dev/null +++ b/src/util/system/validateSystemConfig.test.ts @@ -0,0 +1,70 @@ +import validateSystemConfig from './validateSystemConfig.js'; + +const validSystem = { + name: 'compound', + homepage: 'https://example.com/compound', + repository: 'https://github.com/emulsify-ds/compound.git', + structure: [ + { + name: 'base', + description: 'Base components', + }, + ], + variants: [ + { + platform: 'drupal', + structureImplementations: [ + { + name: 'base', + directory: 'components/00-base', + }, + ], + components: [], + }, + ], +}; + +describe('validateSystemConfig', () => { + it('returns a typed system configuration when validation succeeds', async () => { + await expect(validateSystemConfig(validSystem)).resolves.toEqual({ + valid: true, + systemConfig: validSystem, + }); + }); + + it('returns Ajv errors when validation fails', async () => { + const result = await validateSystemConfig({ + ...validSystem, + homepage: 'not-a-uri', + }); + + expect(result).toEqual({ + valid: false, + errors: expect.arrayContaining([ + expect.objectContaining({ + instancePath: '/homepage', + keyword: 'format', + }), + ]), + }); + }); + + it('rejects variants that omit required components', async () => { + const [{ components: _components, ...variant }] = validSystem.variants; + const result = await validateSystemConfig({ + ...validSystem, + variants: [variant], + }); + + expect(result).toEqual({ + valid: false, + errors: expect.arrayContaining([ + expect.objectContaining({ + instancePath: '/variants/0', + keyword: 'required', + params: { missingProperty: 'components' }, + }), + ]), + }); + }); +}); diff --git a/src/util/system/validateSystemConfig.ts b/src/util/system/validateSystemConfig.ts new file mode 100644 index 0000000..0263d4d --- /dev/null +++ b/src/util/system/validateSystemConfig.ts @@ -0,0 +1,58 @@ +import type { EmulsifySystem } from '@emulsify-cli/config'; + +import { Ajv, type ErrorObject, type ValidateFunction } from 'ajv'; +import addFormats from 'ajv-formats'; + +export type SystemConfigValidationResult = + | { + valid: true; + systemConfig: EmulsifySystem; + } + | { + valid: false; + errors: ErrorObject[] | null | undefined; + }; + +async function loadSchemas() { + const [{ default: systemSchema }, { default: variantSchema }] = + await Promise.all([ + import('../../schemas/system.json', { with: { type: 'json' } }), + import('../../schemas/variant.json', { with: { type: 'json' } }), + ]); + + return { systemSchema, variantSchema }; +} + +let validatorPromise: Promise<ValidateFunction<EmulsifySystem>> | undefined; + +async function getValidator(): Promise<ValidateFunction<EmulsifySystem>> { + if (!validatorPromise) { + validatorPromise = loadSchemas().then(({ systemSchema, variantSchema }) => { + const ajv = new Ajv(); + // This is unfortunate... + // @ts-ignore The ajv-formats typing is bad :( + addFormats(ajv, ['uri']); + ajv.addSchema(variantSchema, 'variant.json'); + return ajv.compile<EmulsifySystem>(systemSchema); + }); + } + + return validatorPromise; +} + +/** + * Validate a loaded Emulsify system configuration with the shared schemas. + * + * @param systemConfig loaded JSON value to validate. + * @returns a valid typed system configuration or the Ajv validation errors. + */ +export default async function validateSystemConfig( + systemConfig: unknown, +): Promise<SystemConfigValidationResult> { + const validate = await getValidator(); + if (validate(systemConfig)) { + return { valid: true, systemConfig }; + } + + return { valid: false, errors: validate.errors }; +} From 0e65b0bb0d5f2a64dff81fead418679efb66e544 Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Sat, 29 Aug 2026 17:16:31 -0500 Subject: [PATCH 12/27] fix(fs): terminate the parent directory walk at the filesystem root --- src/util/fs/findFileInCurrentPath.test.ts | 49 +++++++++++++++++++++++ src/util/fs/findFileInCurrentPath.ts | 23 ++++++++--- 2 files changed, 67 insertions(+), 5 deletions(-) diff --git a/src/util/fs/findFileInCurrentPath.test.ts b/src/util/fs/findFileInCurrentPath.test.ts index 4c54f06..9f52f54 100644 --- a/src/util/fs/findFileInCurrentPath.test.ts +++ b/src/util/fs/findFileInCurrentPath.test.ts @@ -1,13 +1,21 @@ import process from 'process'; import fs from 'fs'; +import path from 'path'; import findFileInCurrentPath, { clearFoundFileCache, } from './findFileInCurrentPath.js'; +const posixDirname = path.posix.dirname; +const posixJoin = path.posix.join; +const win32Dirname = path.win32.dirname; +const win32Join = path.win32.join; + const cwd = jest .spyOn(process, 'cwd') .mockReturnValue('/home/uname/Projects/cornflake/themes/someTheme'); const existsSync = jest.spyOn(fs, 'existsSync'); +const dirname = jest.spyOn(path, 'dirname'); +const join = jest.spyOn(path, 'join'); describe('findFileInCurrentPath', () => { beforeEach(() => { @@ -15,6 +23,8 @@ describe('findFileInCurrentPath', () => { cwd.mockReset(); existsSync.mockReset(); cwd.mockReturnValue('/home/uname/Projects/cornflake/themes/someTheme'); + dirname.mockImplementation(posixDirname); + join.mockImplementation(posixJoin); }); it('can traverse up a directory until it finds the specified file', () => { @@ -52,6 +62,45 @@ describe('findFileInCurrentPath', () => { expect(findFileInCurrentPath('README.md')).toBe(undefined); }); + it('finds a file located at the POSIX filesystem root', () => { + expect.assertions(2); + cwd.mockReturnValue('/home/uname/Projects/cornflake'); + existsSync.mockImplementation( + (filePath) => String(filePath) === '/project.emulsify.json', + ); + + expect(findFileInCurrentPath('project.emulsify.json')).toBe( + '/project.emulsify.json', + ); + expect(existsSync).toHaveBeenLastCalledWith('/project.emulsify.json'); + }); + + it('terminates at a Windows drive root', () => { + expect.assertions(3); + dirname.mockImplementation(win32Dirname); + join.mockImplementation(win32Join); + cwd.mockReturnValue('C:\\'); + existsSync.mockReturnValue(false); + + expect(findFileInCurrentPath('project.emulsify.json')).toBe(undefined); + expect(existsSync).toHaveBeenCalledTimes(1); + expect(existsSync).toHaveBeenCalledWith('C:\\project.emulsify.json'); + }); + + it('terminates at a Windows UNC root', () => { + expect.assertions(3); + dirname.mockImplementation(win32Dirname); + join.mockImplementation(win32Join); + cwd.mockReturnValue('\\\\server\\share\\'); + existsSync.mockReturnValue(false); + + expect(findFileInCurrentPath('project.emulsify.json')).toBe(undefined); + expect(existsSync).toHaveBeenCalledTimes(1); + expect(existsSync).toHaveBeenCalledWith( + '\\\\server\\share\\project.emulsify.json', + ); + }); + it('returns different paths for the same file name from different cwds', () => { expect.assertions(2); cwd diff --git a/src/util/fs/findFileInCurrentPath.ts b/src/util/fs/findFileInCurrentPath.ts index 376fb38..c6b6b4f 100644 --- a/src/util/fs/findFileInCurrentPath.ts +++ b/src/util/fs/findFileInCurrentPath.ts @@ -1,5 +1,5 @@ import { existsSync } from 'fs'; -import { join, dirname, sep } from 'path'; +import { join, dirname } from 'path'; const foundFileCache = new Map<string, string | void>(); @@ -23,12 +23,25 @@ export default function findFileInCurrentPath(fileName: string): string | void { } let currentPath = currentWorkingDirectory; - while (currentPath !== sep && !existsSync(join(currentPath, fileName))) { - currentPath = dirname(currentPath); + let foundPath: string | void = undefined; + + while (true) { + const candidatePath = join(currentPath, fileName); + + if (existsSync(candidatePath)) { + foundPath = candidatePath; + break; + } + + const parentPath = dirname(currentPath); + + if (parentPath === currentPath) { + break; + } + + currentPath = parentPath; } - const foundPath = - currentPath !== sep ? join(currentPath, fileName) : undefined; foundFileCache.set(cacheKey, foundPath); return foundPath; From c324a10f0981dfb88374fe42367db8a994b2979b Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Sat, 29 Aug 2026 17:36:30 -0500 Subject: [PATCH 13/27] ci(workflows): run unit tests on ubuntu, macos, and windows --- .github/workflows/ci.yml | 29 +++- docs/release.md | 23 ++- src/handlers/componentCreate.test.ts | 31 ++-- src/handlers/componentInstall.test.ts | 28 ++-- src/handlers/init.test.ts | 148 +++++++++--------- src/handlers/systemInstall.test.ts | 48 +++--- src/lib/constants.test.ts | 16 +- src/util/cache/cloneIntoCache.test.ts | 12 +- src/util/cache/getCachedItemCheckout.test.ts | 20 ++- src/util/cache/getCachedItemPath.test.ts | 53 +++++-- src/util/fs/executeScript.test.ts | 30 ++-- src/util/fs/safeResolveWithin.test.ts | 32 ++-- src/util/platform/getDrupalInfo.test.ts | 10 +- src/util/platform/getNoPlatformInfo.test.ts | 10 +- src/util/platform/getWordPressInfo.test.ts | 48 +++--- src/util/project/generateComponent.test.ts | 90 ++++++----- .../project/installComponentFromCache.test.ts | 17 +- .../installGeneralAssetsFromCache.test.ts | 18 ++- .../project/resolveComponentTemplate.test.ts | 22 ++- 19 files changed, 396 insertions(+), 289 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 75eb704..7a40dd5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,9 +10,14 @@ permissions: contents: read jobs: - validate: - name: Validate - runs-on: ubuntu-latest + unit: + name: Unit (${{ matrix.os }}) + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} steps: - name: Checkout uses: actions/checkout@v4 @@ -31,6 +36,24 @@ jobs: run: npm run type - name: Test run: npm test + + package: + name: Package + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Install Node.js + uses: actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + cache: npm + - name: Install + run: npm ci + - name: Build + run: npm run build - name: Verify package contents run: npm run pack:dry-run - name: Smoke test packed install diff --git a/docs/release.md b/docs/release.md index c695198..8f7cc10 100644 --- a/docs/release.md +++ b/docs/release.md @@ -21,7 +21,7 @@ The calculation always starts from the stable tag, not the version already in The repository must allow GitHub Actions to create pull requests for the version-bump workflow. Because the pull request is created or updated with the -repository `GITHUB_TOKEN`, GitHub starts its `CI / Validate` run in an +repository `GITHUB_TOKEN`, GitHub starts its CI checks in an approval-required state. A maintainer with write access must select **Approve workflows to run**, then wait for that exact head commit to pass before merging. Configure a GitHub App or personal access token for the bump @@ -29,15 +29,24 @@ workflow instead if unattended validation is required. ## Required Validation -The single read-only `CI / Validate` job runs for pull requests targeting -`develop` or `main` and for pushes to either branch. It installs from the -lockfile and runs: +The read-only CI workflow runs for pull requests targeting `develop` or `main` +and for pushes to either branch. Its unit-test matrix runs on Ubuntu, macOS, +and Windows, with a 15-minute timeout for each operating system. Every matrix +job installs from the lockfile and runs: ```bash npm ci npm run build npm run type npm run test +``` + +The Ubuntu-only package job installs from the lockfile, builds the package, and +runs the OS-independent package checks: + +```bash +npm ci +npm run build npm run pack:dry-run npm run smoke:pack ``` @@ -48,8 +57,10 @@ regression tests. `pack:dry-run` asserts npm's structured package manifest. dependencies in a clean temporary project, and runs `emulsify --help` and `emulsify --version`. -Repository branch protection should require `CI / Validate`. Remove any -required-check reference to the retired duplicate `Test / build` job. +Repository branch protection should require `CI / Unit (ubuntu-latest)`, +`CI / Unit (macos-latest)`, `CI / Unit (windows-latest)`, and `CI / Package`. +Remove any required-check reference to the retired `CI / Validate` and +duplicate `Test / build` jobs. The CI workflow has only `contents: read` permission. It does not receive npm or GitHub publishing credentials and cannot publish, push a release tag, or create diff --git a/src/handlers/componentCreate.test.ts b/src/handlers/componentCreate.test.ts index 55cab33..d3e5b52 100644 --- a/src/handlers/componentCreate.test.ts +++ b/src/handlers/componentCreate.test.ts @@ -10,12 +10,17 @@ jest.mock('../util/fs/findFileInCurrentPath', () => jest.fn()); jest.mock('@inquirer/prompts'); import fs from 'fs'; +import { join, normalize, resolve, sep } from 'path'; import { pathExists, remove } from 'fs-extra'; import { select, confirm } from '@inquirer/prompts'; import type { EmulsifySystem } from '@emulsify-cli/config'; import log from '../lib/log.js'; import CliError from '../lib/CliError.js'; -import { EMULSIFY_SYSTEM_CONFIG_FILE } from '../lib/constants.js'; +import { + EMULSIFY_PROJECT_CONFIG_FILE, + EMULSIFY_PROJECT_TEMPLATES_FOLDER, + EMULSIFY_SYSTEM_CONFIG_FILE, +} from '../lib/constants.js'; import getEmulsifyConfig from '../util/project/getEmulsifyConfig.js'; import getJsonFromCachedFile from '../util/cache/getJsonFromCachedFile.js'; import cloneIntoCache from '../util/cache/cloneIntoCache.js'; @@ -44,12 +49,18 @@ function setStdinIsTTY(value: boolean | undefined) { } function mockComponentExistsWithoutTemplateOverrides() { + const templatePathFragment = `${sep}${normalize( + EMULSIFY_PROJECT_TEMPLATES_FOLDER, + )}${sep}`; pathExistsMock.mockImplementation( - (path) => !String(path).includes('/.cli/templates/'), + (path) => !String(path).includes(templatePathFragment), ); } -const projectConfigPath = '/project/project.emulsify.json'; +const projectRoot = resolve('/project'); +const projectConfigPath = join(projectRoot, EMULSIFY_PROJECT_CONFIG_FILE); +const componentBasePath = join(projectRoot, 'components', '00-base'); +const componentPath = (name: string) => join(componentBasePath, name); const projectConfig = { project: { @@ -279,9 +290,7 @@ describe('componentCreate', () => { await componentCreate('button', { directory: 'base' }); - expect(removeMock).toHaveBeenCalledWith( - '/project/components/00-base/button', - ); + expect(removeMock).toHaveBeenCalledWith(componentPath('button')); expect(logMock).toHaveBeenCalledWith( 'success', expect.stringContaining('Success!'), @@ -293,11 +302,11 @@ describe('componentCreate', () => { await componentCreate('button', { directory: 'base' }); - expect(mkdirMock).toHaveBeenCalledWith('/project/components/00-base', { + expect(mkdirMock).toHaveBeenCalledWith(componentBasePath, { recursive: true, }); expect(writeFileMock).toHaveBeenCalledWith( - '/project/components/00-base/button/button.twig', + join(componentPath('button'), 'button.twig'), expect.stringContaining('button.twig'), ); expect(logMock).toHaveBeenCalledWith( @@ -318,11 +327,9 @@ describe('componentCreate', () => { expect(selectMock).not.toHaveBeenCalled(); expect(confirmMock).not.toHaveBeenCalled(); - expect(removeMock).toHaveBeenCalledWith( - '/project/components/00-base/button', - ); + expect(removeMock).toHaveBeenCalledWith(componentPath('button')); expect(writeFileMock).toHaveBeenCalledWith( - '/project/components/00-base/button/button.component.yml', + join(componentPath('button'), 'button.component.yml'), expect.stringContaining('name: Button'), ); expect(logMock).toHaveBeenCalledWith( diff --git a/src/handlers/componentInstall.test.ts b/src/handlers/componentInstall.test.ts index d63aa14..67cadf3 100644 --- a/src/handlers/componentInstall.test.ts +++ b/src/handlers/componentInstall.test.ts @@ -11,6 +11,7 @@ jest.mock('../util/fs/findFileInCurrentPath', () => jest.fn()); jest.mock('@inquirer/prompts'); import { pathExists } from 'fs-extra'; +import { join, resolve } from 'path'; import { confirm } from '@inquirer/prompts'; import type { EmulsifySystem } from '@emulsify-cli/config'; import log from '../lib/log.js'; @@ -36,7 +37,10 @@ const findFileInCurrentPathMock = findFileInCurrentPath as jest.Mock; const pathExistsMock = pathExists as jest.Mock; const confirmMock = confirm as jest.Mock; -const projectConfigPath = '/project/project.emulsify.json'; +const projectRoot = resolve('/project'); +const projectConfigPath = join(projectRoot, 'project.emulsify.json'); +const componentPath = (name: string) => + join(projectRoot, 'components', '00-base', name); const projectConfig = { project: { @@ -248,14 +252,14 @@ describe('componentInstall', () => { 1, 'systems', ['compound', 'components/00-base', 'button'], - '/project/components/00-base/button', + componentPath('button'), true, ); expect(copyItemFromCacheMock).toHaveBeenNthCalledWith( 3, 'systems', ['compound', 'components/00-base', 'card'], - '/project/components/00-base/card', + componentPath('card'), true, ); }); @@ -267,14 +271,14 @@ describe('componentInstall', () => { 1, 'systems', ['compound', 'components/00-base', 'button'], - '/project/components/00-base/button', + componentPath('button'), true, ); expect(copyItemFromCacheMock).toHaveBeenNthCalledWith( 2, 'systems', ['compound', 'components/00-base', 'icon'], - '/project/components/00-base/icon', + componentPath('icon'), true, ); expect(logMock).toHaveBeenCalledWith( @@ -298,7 +302,7 @@ describe('componentInstall', () => { ); expect(logMock).toHaveBeenCalledWith( 'info', - expect.stringContaining('/project/components/00-base/card'), + expect.stringContaining(componentPath('card')), ); expect(logMock).toHaveBeenCalledWith( 'info', @@ -321,7 +325,7 @@ describe('componentInstall', () => { ); expect(logMock).toHaveBeenCalledWith( 'info', - expect.stringContaining('/project/components/00-base/icon'), + expect.stringContaining(componentPath('icon')), ); }); @@ -352,7 +356,7 @@ describe('componentInstall', () => { expect(copyItemFromCacheMock).toHaveBeenCalledWith( 'systems', ['compound', 'components/00-base', 'card'], - '/project/components/00-base/card', + componentPath('card'), false, ); }); @@ -371,7 +375,7 @@ describe('componentInstall', () => { expect(copyItemFromCacheMock).toHaveBeenCalledWith( 'systems', ['compound', 'components/00-base', 'button'], - '/project/components/00-base/button', + componentPath('button'), true, ); }); @@ -385,7 +389,7 @@ describe('componentInstall', () => { expect(copyItemFromCacheMock).toHaveBeenCalledWith( 'systems', ['compound', 'components/00-base', 'card'], - '/project/components/00-base/card', + componentPath('card'), true, ); }); @@ -409,7 +413,7 @@ describe('componentInstall', () => { 2, 'systems', ['compound', 'components/00-base', 'icon'], - '/project/components/00-base/icon', + componentPath('icon'), true, ); }); @@ -484,7 +488,7 @@ describe('componentInstall', () => { expect(copyItemFromCacheMock).toHaveBeenCalledWith( 'systems', ['compound', 'components/00-base', 'button'], - '/project/components/00-base/button', + componentPath('button'), false, ); }); diff --git a/src/handlers/init.test.ts b/src/handlers/init.test.ts index da4c49f..6b9ba95 100644 --- a/src/handlers/init.test.ts +++ b/src/handlers/init.test.ts @@ -6,6 +6,7 @@ jest.mock('../util/project/installDependencies', () => jest.fn()); jest.mock('@inquirer/prompts'); import fs from 'fs'; +import { join, resolve } from 'path'; import { simpleGit as git } from 'simple-git'; import log from '../lib/log.js'; import { input, select } from '@inquirer/prompts'; @@ -14,9 +15,26 @@ import installDependencies from '../util/project/installDependencies.js'; import getPlatformInfo from '../util/platform/getPlatformInfo.js'; import writeToJsonFile from '../util/fs/writeToJsonFile.js'; import executeScript from '../util/fs/executeScript.js'; +import { + EMULSIFY_PROJECT_CONFIG_FILE, + EMULSIFY_PROJECT_HOOK_FOLDER, + EMULSIFY_PROJECT_HOOK_INIT, +} from '../lib/constants.js'; import init from './init.js'; -const root = '/home/uname/Projects/cornflake'; +const root = resolve('/home/uname/Projects/cornflake'); +const defaultTarget = join(root, 'cornflake'); +const defaultConfigPath = join(defaultTarget, EMULSIFY_PROJECT_CONFIG_FILE); +const defaultGitPath = join(defaultTarget, '.git'); +const defaultInitHookPath = join( + defaultTarget, + EMULSIFY_PROJECT_HOOK_FOLDER, + EMULSIFY_PROJECT_HOOK_INIT, +); +const drupalThemesDirectory = join(root, 'web', 'themes', 'custom'); +const drupalTarget = join(drupalThemesDirectory, 'cornflake'); +const wordpressThemesDirectory = join(root, 'wp-content', 'themes'); +const wordpressTarget = join(wordpressThemesDirectory, 'my-theme'); const existsSyncMock = (fs.existsSync as jest.Mock).mockReturnValue(false); const rmMock = (fs.promises.rm as jest.Mock).mockReturnValue(true); @@ -88,34 +106,28 @@ describe('init', () => { expect(selectMock).not.toHaveBeenCalled(); expect(gitCloneMock).toHaveBeenCalledWith( 'https://github.com/emulsify-ds/emulsify-starter', - '/home/uname/Projects/cornflake/cornflake', + defaultTarget, { '--branch': 'main' }, ); - expect(rmMock).toHaveBeenCalledWith( - '/home/uname/Projects/cornflake/cornflake/.git', - { recursive: true }, - ); - expect(writeJsonFileMock).toHaveBeenCalledWith( - '/home/uname/Projects/cornflake/cornflake/project.emulsify.json', - { - project: { - platform: 'none', - machineName: 'cornflake', - name: 'cornflake', - }, - starter: { - repository: 'https://github.com/emulsify-ds/emulsify-starter', - }, + expect(rmMock).toHaveBeenCalledWith(defaultGitPath, { recursive: true }); + expect(writeJsonFileMock).toHaveBeenCalledWith(defaultConfigPath, { + project: { + platform: 'none', + machineName: 'cornflake', + name: 'cornflake', }, - ); + starter: { + repository: 'https://github.com/emulsify-ds/emulsify-starter', + }, + }); }); it('logs Drupal Composer guidance when Drupal is auto-detected', async () => { expect.assertions(4); getPlatformInfoMock.mockReturnValueOnce({ - root: `${root}/web`, + root: join(root, 'web'), name: 'drupal', - emulsifyParentDirectory: `${root}/web/themes/custom`, + emulsifyParentDirectory: drupalThemesDirectory, platformMajorVersion: 11, }); @@ -123,7 +135,7 @@ describe('init', () => { expect(gitCloneMock).toHaveBeenCalledWith( 'https://github.com/emulsify-ds/emulsify-drupal-starter', - '/home/uname/Projects/cornflake/web/themes/custom/cornflake', + drupalTarget, { '--branch': 'main' }, ); expect(logMock).toHaveBeenCalledWith( @@ -146,18 +158,18 @@ describe('init', () => { getPlatformInfoMock.mockReturnValueOnce({ root, name: 'wordpress', - emulsifyParentDirectory: `${root}/wp-content/themes`, + emulsifyParentDirectory: wordpressThemesDirectory, }); await init(progress)('My Theme'); expect(gitCloneMock).toHaveBeenCalledWith( 'https://github.com/emulsify-ds/emulsify-wordpress-starter', - '/home/uname/Projects/cornflake/wp-content/themes/my-theme', + wordpressTarget, { '--branch': 'main' }, ); expect(writeJsonFileMock).toHaveBeenCalledWith( - '/home/uname/Projects/cornflake/wp-content/themes/my-theme/project.emulsify.json', + join(wordpressTarget, EMULSIFY_PROJECT_CONFIG_FILE), { project: { platform: 'wordpress', @@ -202,14 +214,14 @@ describe('init', () => { }); expect(gitCloneMock).toHaveBeenCalledWith( 'https://github.com/emulsify-ds/emulsify-starter', - '/home/uname/Projects/cornflake/cornflake', + defaultTarget, { '--branch': 'main' }, ); expect(logMock).toHaveBeenCalledTimes(2); expect(logMock).toHaveBeenNthCalledWith( 1, 'success', - 'Created an Emulsify project in /home/uname/Projects/cornflake/cornflake.', + `Created an Emulsify project in ${defaultTarget}.`, ); }); @@ -222,7 +234,7 @@ describe('init', () => { }); expect(gitCloneMock).toHaveBeenCalledWith( 'https://github.com/emulsify-ds/emulsify-starter', - '/home/uname/Projects/cornflake/cornflake', + defaultTarget, {}, ); }); @@ -238,7 +250,7 @@ describe('init', () => { expect(gitCloneMock).toHaveBeenCalledWith( 'git@github.com:example/custom-starter.git', - '/home/uname/Projects/cornflake/cornflake', + defaultTarget, {}, ); }); @@ -253,23 +265,19 @@ describe('init', () => { expect(gitCloneMock).toHaveBeenCalledWith( 'https://github.com/emulsify-ds/emulsify-wordpress-starter', - '/home/uname/Projects/cornflake/cornflake', + defaultTarget, { '--branch': 'main' }, ); - expect(writeJsonFileMock).toHaveBeenCalledWith( - '/home/uname/Projects/cornflake/cornflake/project.emulsify.json', - { - project: { - platform: 'wordpress', - machineName: 'cornflake', - name: 'cornflake', - }, - starter: { - repository: - 'https://github.com/emulsify-ds/emulsify-wordpress-starter', - }, + expect(writeJsonFileMock).toHaveBeenCalledWith(defaultConfigPath, { + project: { + platform: 'wordpress', + machineName: 'cornflake', + name: 'cornflake', }, - ); + starter: { + repository: 'https://github.com/emulsify-ds/emulsify-wordpress-starter', + }, + }); }); it('uses flag-provided values without prompting in non-interactive mode', async () => { @@ -286,22 +294,19 @@ describe('init', () => { expect(selectMock).not.toHaveBeenCalled(); expect(gitCloneMock).toHaveBeenCalledWith( 'https://github.com/emulsify-ds/emulsify-starter', - '/home/uname/Projects/cornflake/cornflake', + defaultTarget, {}, ); - expect(writeJsonFileMock).toHaveBeenCalledWith( - '/home/uname/Projects/cornflake/cornflake/project.emulsify.json', - { - project: { - platform: 'drupal', - machineName: 'cornflake', - name: 'cornflake', - }, - starter: { - repository: 'https://github.com/emulsify-ds/emulsify-starter', - }, + expect(writeJsonFileMock).toHaveBeenCalledWith(defaultConfigPath, { + project: { + platform: 'drupal', + machineName: 'cornflake', + name: 'cornflake', }, - ); + starter: { + repository: 'https://github.com/emulsify-ds/emulsify-starter', + }, + }); expect(logMock).not.toHaveBeenCalledWith( 'warn', expect.stringContaining('composer require drupal/emulsify'), @@ -325,19 +330,16 @@ describe('init', () => { choices: ['drupal', 'wordpress', 'none'], default: 'drupal', }); - expect(writeJsonFileMock).toHaveBeenCalledWith( - '/home/uname/Projects/cornflake/cornflake/project.emulsify.json', - { - project: { - platform: 'none', - machineName: 'cornflake', - name: 'cornflake', - }, - starter: { - repository: 'https://github.com/emulsify-ds/emulsify-starter', - }, + expect(writeJsonFileMock).toHaveBeenCalledWith(defaultConfigPath, { + project: { + platform: 'none', + machineName: 'cornflake', + name: 'cornflake', }, - ); + starter: { + repository: 'https://github.com/emulsify-ds/emulsify-starter', + }, + }); expect(logMock).not.toHaveBeenCalledWith( 'warn', expect.stringContaining('composer require drupal/emulsify'), @@ -361,7 +363,7 @@ describe('init', () => { {}, ); expect(writeJsonFileMock).toHaveBeenCalledWith( - 'emulsifytheme/project.emulsify.json', + join('emulsifytheme', EMULSIFY_PROJECT_CONFIG_FILE), { project: { platform: 'drupal', @@ -378,18 +380,14 @@ describe('init', () => { it('installs the project dependencies', async () => { expect.assertions(1); await init(progress)('cornflake'); - expect(installDependencies).toHaveBeenCalledWith( - '/home/uname/Projects/cornflake/cornflake', - ); + expect(installDependencies).toHaveBeenCalledWith(defaultTarget); }); it('executes the init script within the Emulsify starter, if it exists', async () => { expect.assertions(1); existsSyncMock.mockReturnValueOnce(false).mockReturnValueOnce(true); await init(progress)('cornflake'); - expect(executeScript).toHaveBeenCalledWith( - '/home/uname/Projects/cornflake/cornflake/.cli/init.js', - ); + expect(executeScript).toHaveBeenCalledWith(defaultInitHookPath); }); it('throws if no valid platform name is detectable', async () => { @@ -433,7 +431,7 @@ describe('init', () => { expect.assertions(1); existsSyncMock.mockReturnValueOnce(true); await expect(init(progress)('cornflake')).rejects.toThrow( - 'The intended target is already occupied: /home/uname/Projects/cornflake/cornflake', + `The intended target is already occupied: ${defaultTarget}`, ); }); diff --git a/src/handlers/systemInstall.test.ts b/src/handlers/systemInstall.test.ts index 1cb425d..b101352 100644 --- a/src/handlers/systemInstall.test.ts +++ b/src/handlers/systemInstall.test.ts @@ -18,10 +18,16 @@ jest.mock('../util/fs/writeToJsonFile', () => jest.fn()); jest.mock('@inquirer/prompts'); import fs from 'fs'; +import { join, resolve } from 'path'; import type { EmulsifySystem, Platform } from '@emulsify-cli/config'; import { select } from '@inquirer/prompts'; import log from '../lib/log.js'; -import { EMULSIFY_SYSTEM_CONFIG_FILE } from '../lib/constants.js'; +import { + EMULSIFY_PROJECT_CONFIG_FILE, + EMULSIFY_PROJECT_HOOK_FOLDER, + EMULSIFY_PROJECT_HOOK_SYSTEM_INSTALL, + EMULSIFY_SYSTEM_CONFIG_FILE, +} from '../lib/constants.js'; import getAvailableSystems from '../util/system/getAvailableSystems.js'; import cloneIntoCache from '../util/cache/cloneIntoCache.js'; import getCachedItemCheckout from '../util/cache/getCachedItemCheckout.js'; @@ -54,6 +60,14 @@ const writeToJsonFileMock = writeToJsonFile as jest.Mock; const existsSyncMock = fs.existsSync as jest.Mock; const selectMock = select as jest.Mock; const originalStdinIsTTY = process.stdin.isTTY; +const projectRoot = resolve('/project'); +const projectConfigPath = join(projectRoot, EMULSIFY_PROJECT_CONFIG_FILE); +const systemConfigPath = join(projectRoot, EMULSIFY_SYSTEM_CONFIG_FILE); +const systemInstallHookPath = join( + systemConfigPath, + EMULSIFY_PROJECT_HOOK_FOLDER, + EMULSIFY_PROJECT_HOOK_SYSTEM_INSTALL, +); function setStdinIsTTY(value: boolean | undefined) { Object.defineProperty(process.stdin, 'isTTY', { @@ -213,7 +227,7 @@ describe('systemInstall', () => { getEmulsifyConfigMock.mockResolvedValue(projectConfig); writeToJsonFileMock.mockResolvedValue(undefined); // A found system config plus an existing hook covers the optional hook branch. - findFileInCurrentPathMock.mockReturnValue('/project/system.emulsify.json'); + findFileInCurrentPathMock.mockReturnValue(systemConfigPath); existsSyncMock.mockReturnValue(true); executeScriptMock.mockResolvedValue(undefined); }); @@ -302,15 +316,13 @@ describe('systemInstall', () => { it('writes a custom system definition when create a new system is selected', async () => { setStdinIsTTY(true); selectMock.mockResolvedValueOnce('create a new system'); - findFileInCurrentPathMock.mockReturnValueOnce( - '/project/project.emulsify.json', - ); + findFileInCurrentPathMock.mockReturnValueOnce(projectConfigPath); existsSyncMock.mockReturnValueOnce(false); await systemInstall(undefined, {}); expect(writeToJsonFileMock).toHaveBeenCalledWith( - '/project/system.emulsify.json', + systemConfigPath, customSystemDefinition('drupal'), ); expect(logMock).toHaveBeenCalledWith( @@ -333,15 +345,13 @@ describe('systemInstall', () => { platform: 'none', }, }); - findFileInCurrentPathMock.mockReturnValueOnce( - '/project/project.emulsify.json', - ); + findFileInCurrentPathMock.mockReturnValueOnce(projectConfigPath); existsSyncMock.mockReturnValueOnce(false); await systemInstall(undefined, {}); expect(writeToJsonFileMock).toHaveBeenCalledWith( - '/project/system.emulsify.json', + systemConfigPath, customSystemDefinition('none'), ); }); @@ -356,15 +366,13 @@ describe('systemInstall', () => { platform: 'wordpress', }, }); - findFileInCurrentPathMock.mockReturnValueOnce( - '/project/project.emulsify.json', - ); + findFileInCurrentPathMock.mockReturnValueOnce(projectConfigPath); existsSyncMock.mockReturnValueOnce(false); await systemInstall(undefined, {}); expect(writeToJsonFileMock).toHaveBeenCalledWith( - '/project/system.emulsify.json', + systemConfigPath, customSystemDefinition('wordpress'), ); }); @@ -372,9 +380,7 @@ describe('systemInstall', () => { it('does not overwrite an existing custom system definition', async () => { setStdinIsTTY(true); selectMock.mockResolvedValueOnce('create a new system'); - findFileInCurrentPathMock.mockReturnValueOnce( - '/project/project.emulsify.json', - ); + findFileInCurrentPathMock.mockReturnValueOnce(projectConfigPath); existsSyncMock.mockReturnValueOnce(true); await expect(systemInstall(undefined, {})).rejects.toThrow( @@ -387,9 +393,7 @@ describe('systemInstall', () => { it('does not run remote install side effects when creating a custom system definition', async () => { setStdinIsTTY(true); selectMock.mockResolvedValueOnce('create a new system'); - findFileInCurrentPathMock.mockReturnValueOnce( - '/project/project.emulsify.json', - ); + findFileInCurrentPathMock.mockReturnValueOnce(projectConfigPath); existsSyncMock.mockReturnValueOnce(false); await systemInstall(undefined, {}); @@ -536,9 +540,7 @@ describe('systemInstall', () => { system, variant, ); - expect(executeScriptMock).toHaveBeenCalledWith( - '/project/system.emulsify.json/.cli/systemInstall.js', - ); + expect(executeScriptMock).toHaveBeenCalledWith(systemInstallHookPath); expect(logMock).toHaveBeenCalledWith( 'success', 'Successfully installed the compound system using the drupal variant.', diff --git a/src/lib/constants.test.ts b/src/lib/constants.test.ts index 790b241..b920d49 100644 --- a/src/lib/constants.test.ts +++ b/src/lib/constants.test.ts @@ -5,14 +5,20 @@ import { EMULSIFY_PROJECT_TEMPLATES_FOLDER, EMULSIFY_CACHE_METADATA_FILE, } from './constants.js'; +import { join } from 'path'; -jest.mock('os', () => ({ - homedir: () => '/home/username', -})); +jest.mock('os', () => { + const { join } = jest.requireActual<typeof import('path')>('path'); + return { + homedir: () => join(process.cwd(), 'fixtures', 'home'), + }; +}); + +const mockHome = join(process.cwd(), 'fixtures', 'home'); const map = [ - ['UTIL_DIR', UTIL_DIR, '/home/username/.emulsify'], - ['CACHE_DIR', CACHE_DIR, '/home/username/.emulsify/cache'], + ['UTIL_DIR', UTIL_DIR, join(mockHome, '.emulsify')], + ['CACHE_DIR', CACHE_DIR, join(mockHome, '.emulsify', 'cache')], [ 'EMULSIFY_PROJECT_CONFIG_FILE', EMULSIFY_PROJECT_CONFIG_FILE, diff --git a/src/util/cache/cloneIntoCache.test.ts b/src/util/cache/cloneIntoCache.test.ts index 8d3c5d5..bd3c444 100644 --- a/src/util/cache/cloneIntoCache.test.ts +++ b/src/util/cache/cloneIntoCache.test.ts @@ -1,5 +1,5 @@ jest.mock('../../lib/constants', () => ({ - CACHE_DIR: 'home/uname/.emulsify/cache', + CACHE_DIR: 'cache', EMULSIFY_CACHE_METADATA_FILE: '.emulsify-cache.json', EMULSIFY_PROJECT_CONFIG_FILE: 'project.emulsify.json', })); @@ -10,7 +10,7 @@ import findFileInCurrentPath from '../fs/findFileInCurrentPath.js'; import fs from 'fs'; import { createHash } from 'crypto'; -import { dirname, resolve } from 'path'; +import { dirname, join, resolve } from 'path'; import { simpleGit } from 'simple-git'; const existsSyncMock = fs.existsSync as jest.Mock; @@ -25,7 +25,7 @@ const getRemotesMock = gitMock.getRemotes as jest.Mock; const listRemoteMock = gitMock.listRemote as jest.Mock; const revparseMock = gitMock.revparse as jest.Mock; -const projectPath = '/home/uname/projects/emulsify'; +const projectPath = resolve('fixtures', 'emulsify'); const cloneOptions = { repository: 'repo-path', checkout: 'branch-name', @@ -42,11 +42,11 @@ function cacheDestination(checkout: string | void): string { }), ) .digest('hex'); - return `home/uname/.emulsify/cache/systems/${hash}/cornflake`; + return join('cache', 'systems', hash, 'cornflake'); } const destination = cacheDestination(cloneOptions.checkout); -const metadataPath = `${destination}/.emulsify-cache.json`; +const metadataPath = join(destination, '.emulsify-cache.json'); function metadata(overrides: Record<string, unknown> = {}): string { return JSON.stringify({ @@ -182,7 +182,7 @@ describe('cloneIntoCache', () => { }); expect(readFileMock).toHaveBeenCalledWith( - `${defaultDestination}/.emulsify-cache.json`, + join(defaultDestination, '.emulsify-cache.json'), { encoding: 'utf-8' }, ); expect(listRemoteMock).toHaveBeenCalledWith([normalizedRepository, 'HEAD']); diff --git a/src/util/cache/getCachedItemCheckout.test.ts b/src/util/cache/getCachedItemCheckout.test.ts index 2cf199d..decc394 100644 --- a/src/util/cache/getCachedItemCheckout.test.ts +++ b/src/util/cache/getCachedItemCheckout.test.ts @@ -1,22 +1,28 @@ jest.mock('../../lib/constants', () => ({ - CACHE_DIR: 'home/uname/.emulsify/cache', + CACHE_DIR: 'cache', EMULSIFY_PROJECT_CONFIG_FILE: 'project.emulsify.json', })); jest.mock('../fs/findFileInCurrentPath', () => jest.fn()); import { simpleGit } from 'simple-git'; +import { createHash } from 'crypto'; +import { join, resolve } from 'path'; import findFileInCurrentPath from '../fs/findFileInCurrentPath.js'; import getCachedItemCheckout from './getCachedItemCheckout.js'; const simpleGitMock = simpleGit as unknown as jest.Mock; const gitBranchMock = simpleGit().branch as jest.Mock; +const projectPath = resolve('fixtures', 'emulsify'); +const repository = 'https://github.com/emulsify-ds/compound.git'; +const checkout = 'branch-name'; +const cacheKey = createHash('md5') + .update(JSON.stringify({ projectPath, repository, checkout })) + .digest('hex'); describe('getCachedItemCheckout', () => { beforeEach(() => { jest.clearAllMocks(); - (findFileInCurrentPath as jest.Mock).mockReturnValue( - '/home/uname/projects/emulsify', - ); + (findFileInCurrentPath as jest.Mock).mockReturnValue(projectPath); gitBranchMock.mockResolvedValue({ current: 'the-current-branch', }); @@ -27,13 +33,13 @@ describe('getCachedItemCheckout', () => { getCachedItemCheckout({ bucket: 'systems', itemPath: ['compound'], - repository: 'https://github.com/emulsify-ds/compound.git', - checkout: 'branch-name', + repository, + checkout, }), ).resolves.toBe('the-current-branch'); expect(simpleGitMock).toHaveBeenCalledWith( - 'home/uname/.emulsify/cache/systems/9b98dd5ac046dc9335088731270ccc72/compound', + join('cache', 'systems', cacheKey, 'compound'), ); expect(gitBranchMock).toHaveBeenCalledTimes(1); }); diff --git a/src/util/cache/getCachedItemPath.test.ts b/src/util/cache/getCachedItemPath.test.ts index 640471c..5f85a44 100644 --- a/src/util/cache/getCachedItemPath.test.ts +++ b/src/util/cache/getCachedItemPath.test.ts @@ -1,30 +1,50 @@ jest.mock('../../lib/constants', () => ({ - CACHE_DIR: 'home/uname/.emulsify/cache', + CACHE_DIR: 'cache', EMULSIFY_PROJECT_CONFIG_FILE: 'project.emulsify.json', })); jest.mock('../fs/findFileInCurrentPath', () => jest.fn()); import type { CachedItemPathOptions } from '@emulsify-cli/cache'; +import { createHash } from 'crypto'; +import { join, resolve, sep } from 'path'; import findFileInCurrentPath from '../fs/findFileInCurrentPath.js'; import getCachedItemPath from './getCachedItemPath.js'; +const cacheDirectory = 'cache'; +const projectPath = resolve('fixtures', 'emulsify'); +const baseRepository = 'https://github.com/emulsify-ds/compound.git'; +const baseCheckout = 'branch-name'; const findFileMock = findFileInCurrentPath as jest.Mock; const baseOptions: CachedItemPathOptions = { bucket: 'systems', itemPath: ['compound', 'system.emulsify.json'], - repository: 'https://github.com/emulsify-ds/compound.git', - checkout: 'branch-name', + repository: baseRepository, + checkout: baseCheckout, }; +function cacheKey(repository: string, checkout?: string): string { + return createHash('md5') + .update( + JSON.stringify({ projectPath, repository, checkout: checkout || '' }), + ) + .digest('hex'); +} + describe('getCachedItemPath', () => { beforeEach(() => { jest.clearAllMocks(); - findFileMock.mockReturnValue('/home/uname/projects/emulsify'); + findFileMock.mockReturnValue(projectPath); }); it('produces the path to a cached item from its complete identity', () => { expect(getCachedItemPath(baseOptions)).toBe( - 'home/uname/.emulsify/cache/systems/9b98dd5ac046dc9335088731270ccc72/compound/system.emulsify.json', + join( + cacheDirectory, + 'systems', + cacheKey(baseRepository, baseCheckout), + 'compound', + 'system.emulsify.json', + ), ); expect( getCachedItemPath({ @@ -33,25 +53,36 @@ describe('getCachedItemPath', () => { itemPath: ['compound', 'drupal', 'variant.emulsify.json'], }), ).toBe( - 'home/uname/.emulsify/cache/variants/9b98dd5ac046dc9335088731270ccc72/compound/drupal/variant.emulsify.json', + join( + cacheDirectory, + 'variants', + cacheKey(baseRepository, baseCheckout), + 'compound', + 'drupal', + 'variant.emulsify.json', + ), ); }); it('uses the repository URL in the cache key', () => { + const repository = 'https://github.com/example/compound.git'; const firstRepository = getCachedItemPath(baseOptions); const secondRepository = getCachedItemPath({ ...baseOptions, - repository: 'https://github.com/example/compound.git', + repository, }); expect(firstRepository).not.toBe(secondRepository); - expect(secondRepository).toContain('/cbff5fecb35c99193f7b61a1feaba881/'); + expect(secondRepository).toContain( + `${sep}${cacheKey(repository, baseCheckout)}${sep}`, + ); }); it('uses the checkout in the cache key', () => { - expect( - getCachedItemPath({ ...baseOptions, checkout: 'other-branch' }), - ).toContain('/330b95546066d5ea9bed9181f8f4f8b4/'); + const checkout = 'other-branch'; + expect(getCachedItemPath({ ...baseOptions, checkout })).toContain( + `${sep}${cacheKey(baseRepository, checkout)}${sep}`, + ); }); it('normalizes whitespace and trailing slashes in repository URLs', () => { diff --git a/src/util/fs/executeScript.test.ts b/src/util/fs/executeScript.test.ts index 80a8063..c20fd96 100644 --- a/src/util/fs/executeScript.test.ts +++ b/src/util/fs/executeScript.test.ts @@ -1,5 +1,5 @@ import { execFile } from 'child_process'; -import { resolve } from 'path'; +import { dirname, join, resolve } from 'path'; import executeScript from './executeScript.js'; jest.mock('child_process', () => ({ @@ -38,7 +38,7 @@ describe('executeScript', () => { it('executes a hook script with node and resolves stdout', async () => { expect.assertions(2); - const scriptPath = '/project/.cli/init.js'; + const scriptPath = resolve('project', '.cli', 'init.js'); mockExecFileResult(null, 'done'); await expect(executeScript(scriptPath)).resolves.toBe('done'); @@ -46,7 +46,7 @@ describe('executeScript', () => { process.execPath, [scriptPath], { - cwd: '/project/.cli', + cwd: dirname(scriptPath), encoding: 'utf8', }, expect.any(Function), @@ -55,7 +55,7 @@ describe('executeScript', () => { it('passes a hook path with spaces as an argument instead of a shell string', async () => { expect.assertions(1); - const scriptPath = '/project with spaces/.cli/init.js'; + const scriptPath = resolve('project with spaces', '.cli', 'init.js'); mockExecFileResult(null, 'done'); await executeScript(scriptPath); @@ -64,7 +64,7 @@ describe('executeScript', () => { process.execPath, [scriptPath], { - cwd: '/project with spaces/.cli', + cwd: dirname(scriptPath), encoding: 'utf8', }, expect.any(Function), @@ -73,7 +73,7 @@ describe('executeScript', () => { it('resolves relative hook paths before changing the working directory', async () => { expect.assertions(1); - const scriptPath = 'project/.cli/init.js'; + const scriptPath = join('project', '.cli', 'init.js'); const resolvedScriptPath = resolve(scriptPath); mockExecFileResult(null, 'done'); @@ -83,7 +83,7 @@ describe('executeScript', () => { process.execPath, [resolvedScriptPath], { - cwd: resolve('project/.cli'), + cwd: dirname(resolvedScriptPath), encoding: 'utf8', }, expect.any(Function), @@ -92,35 +92,39 @@ describe('executeScript', () => { it('resolves stderr when stdout is empty', async () => { expect.assertions(1); + const scriptPath = resolve('project', '.cli', 'init.js'); mockExecFileResult(null, '', 'well, that went poorly'); - await expect(executeScript('/project/.cli/init.js')).resolves.toBe( + await expect(executeScript(scriptPath)).resolves.toBe( 'well, that went poorly', ); }); it('resolves an empty string when stdout and stderr are empty', async () => { expect.assertions(1); + const scriptPath = resolve('project', '.cli', 'init.js'); mockExecFileResult(null); - await expect(executeScript('/project/.cli/init.js')).resolves.toBe(''); + await expect(executeScript(scriptPath)).resolves.toBe(''); }); it('rejects failed hooks with stderr context', async () => { expect.assertions(1); + const scriptPath = resolve('project', '.cli', 'init.js'); mockExecFileResult(new Error('Command failed'), '', 'hook failed\n'); - await expect(executeScript('/project/.cli/init.js')).rejects.toThrow( - 'Unable to execute hook script "/project/.cli/init.js": hook failed', + await expect(executeScript(scriptPath)).rejects.toThrow( + `Unable to execute hook script "${scriptPath}": hook failed`, ); }); it('rejects execution failures with the process error message', async () => { expect.assertions(1); + const scriptPath = resolve('project', '.cli', 'init.js'); mockExecFileResult(new Error('spawn failed')); - await expect(executeScript('/project/.cli/init.js')).rejects.toThrow( - 'Unable to execute hook script "/project/.cli/init.js": spawn failed', + await expect(executeScript(scriptPath)).rejects.toThrow( + `Unable to execute hook script "${scriptPath}": spawn failed`, ); }); }); diff --git a/src/util/fs/safeResolveWithin.test.ts b/src/util/fs/safeResolveWithin.test.ts index ecf77b1..95f85e4 100644 --- a/src/util/fs/safeResolveWithin.test.ts +++ b/src/util/fs/safeResolveWithin.test.ts @@ -1,16 +1,15 @@ +import { resolve } from 'path'; import safeResolveWithin from './safeResolveWithin.js'; describe('safeResolveWithin', () => { - const root = '/workspace/project'; + const root = resolve('workspace', 'project'); + const buttonDestination = resolve(root, 'components', '00-base', 'button'); it('accepts normalized paths that remain inside the root', () => { - expect( - safeResolveWithin( - root, - './components/../components/00-base/button', - 'Component destination', - ), - ).toBe('/workspace/project/components/00-base/button'); + const target = './components/../components/00-base/button'; + expect(safeResolveWithin(root, target, 'Component destination')).toBe( + buttonDestination, + ); }); it('accepts target path segments that remain inside the root', () => { @@ -20,34 +19,37 @@ describe('safeResolveWithin', () => { ['components', '00-base', 'button'], 'Component destination', ), - ).toBe('/workspace/project/components/00-base/button'); + ).toBe(buttonDestination); }); it('rejects path traversal outside the root', () => { + const target = '../../outside'; + const outsideRoot = resolve(root, target); expect(() => - safeResolveWithin(root, '../../outside', 'Component destination'), + safeResolveWithin(root, target, 'Component destination'), ).toThrow( - 'Component destination "../../outside" resolves to "/outside", which is outside the expected root "/workspace/project".', + `Component destination "${target}" resolves to "${outsideRoot}", which is outside the expected root "${root}".`, ); }); it('rejects absolute paths outside the root', () => { + const outsideRoot = resolve(root, '..', '..', 'tmp', 'outside'); expect(() => - safeResolveWithin(root, '/tmp/outside', 'Asset destination'), + safeResolveWithin(root, outsideRoot, 'Asset destination'), ).toThrow( - 'Asset destination "/tmp/outside" resolves to "/tmp/outside", which is outside the expected root "/workspace/project".', + `Asset destination "${outsideRoot}" resolves to "${outsideRoot}", which is outside the expected root "${root}".`, ); }); it('rejects the root itself by default', () => { expect(() => safeResolveWithin(root, '.', 'Asset destination')).toThrow( - 'Asset destination "." resolves to the expected root "/workspace/project", but a path inside the root is required.', + `Asset destination "." resolves to the expected root "${root}", but a path inside the root is required.`, ); }); it('allows the root itself only when explicitly requested', () => { expect( safeResolveWithin(root, '.', 'Project root', { allowRoot: true }), - ).toBe('/workspace/project'); + ).toBe(root); }); }); diff --git a/src/util/platform/getDrupalInfo.test.ts b/src/util/platform/getDrupalInfo.test.ts index eb97f38..3e0ccfa 100644 --- a/src/util/platform/getDrupalInfo.test.ts +++ b/src/util/platform/getDrupalInfo.test.ts @@ -4,6 +4,9 @@ jest.mock('../fs/loadJsonFile', () => jest.fn()); import findFileInCurrentPath from '../fs/findFileInCurrentPath.js'; import loadJsonFile from '../fs/loadJsonFile.js'; import getDrupalInfo from './getDrupalInfo.js'; +import { join, resolve } from 'path'; + +const projectRoot = resolve('fixtures', 'cornflake'); const loadJsonMock = (loadJsonFile as jest.Mock).mockResolvedValue({ extra: { @@ -15,7 +18,7 @@ const loadJsonMock = (loadJsonFile as jest.Mock).mockResolvedValue({ }, }); const findFileMock = (findFileInCurrentPath as jest.Mock).mockReturnValue( - '/home/uname/Projects/cornflake/composer.json', + join(projectRoot, 'composer.json'), ); describe('getDrupalInfo', () => { @@ -24,9 +27,8 @@ describe('getDrupalInfo', () => { await expect(getDrupalInfo()).resolves.toEqual({ name: 'drupal', platformMajorVersion: 11, - emulsifyParentDirectory: - '/home/uname/Projects/cornflake/web/themes/custom', - root: '/home/uname/Projects/cornflake/web/', + emulsifyParentDirectory: join(projectRoot, 'web', 'themes', 'custom'), + root: join(projectRoot, 'web/'), }); }); diff --git a/src/util/platform/getNoPlatformInfo.test.ts b/src/util/platform/getNoPlatformInfo.test.ts index 2d87e4c..93eede0 100644 --- a/src/util/platform/getNoPlatformInfo.test.ts +++ b/src/util/platform/getNoPlatformInfo.test.ts @@ -3,9 +3,12 @@ jest.mock('../fs/loadJsonFile', () => jest.fn()); import findFileInCurrentPath from '../fs/findFileInCurrentPath.js'; import getNoPlatformInfo from './getNoPlatformInfo.js'; +import { join, resolve } from 'path'; + +const projectRoot = resolve('fixtures', 'cornflake'); const findFileMock = (findFileInCurrentPath as jest.Mock).mockReturnValue( - '/home/uname/Projects/cornflake/project.emulsify.json', + join(projectRoot, 'project.emulsify.json'), ); describe('getNoPlatformInfo', () => { @@ -14,9 +17,8 @@ describe('getNoPlatformInfo', () => { await expect(getNoPlatformInfo()).resolves.toEqual({ name: 'none', platformMajorVersion: 1, - emulsifyParentDirectory: - '/home/uname/Projects/cornflake/web/themes/custom', - root: '/home/uname/Projects/cornflake', + emulsifyParentDirectory: join(projectRoot, 'web', 'themes', 'custom'), + root: projectRoot, }); }); diff --git a/src/util/platform/getWordPressInfo.test.ts b/src/util/platform/getWordPressInfo.test.ts index ba1afef..93b2996 100644 --- a/src/util/platform/getWordPressInfo.test.ts +++ b/src/util/platform/getWordPressInfo.test.ts @@ -1,10 +1,12 @@ import process from 'process'; import fs from 'fs'; +import { join, resolve } from 'path'; import { clearFoundFileCache } from '../fs/findFileInCurrentPath.js'; import getWordPressInfo from './getWordPressInfo.js'; const cwd = jest.spyOn(process, 'cwd'); const existsSync = jest.spyOn(fs, 'existsSync'); +const projectRoot = resolve('fixtures', 'cornflake'); describe('getWordPressInfo', () => { beforeEach(() => { @@ -14,57 +16,43 @@ describe('getWordPressInfo', () => { }); it('detects a standard WordPress themes directory', async () => { - cwd.mockReturnValue( - '/home/uname/Projects/cornflake/wp-content/themes/my-theme', - ); - existsSync.mockImplementation( - (path) => - String(path) === '/home/uname/Projects/cornflake/wp-content/themes', - ); + const themesDirectory = join(projectRoot, 'wp-content', 'themes'); + cwd.mockReturnValue(join(themesDirectory, 'my-theme')); + existsSync.mockImplementation((path) => String(path) === themesDirectory); await expect(getWordPressInfo()).resolves.toEqual({ name: 'wordpress', - root: '/home/uname/Projects/cornflake', - emulsifyParentDirectory: - '/home/uname/Projects/cornflake/wp-content/themes', + root: projectRoot, + emulsifyParentDirectory: themesDirectory, }); }); it('detects a Bedrock themes directory', async () => { - cwd.mockReturnValue( - '/home/uname/Projects/cornflake/web/app/themes/my-theme', - ); - existsSync.mockImplementation( - (path) => - String(path) === '/home/uname/Projects/cornflake/web/app/themes', - ); + const themesDirectory = join(projectRoot, 'web', 'app', 'themes'); + cwd.mockReturnValue(join(themesDirectory, 'my-theme')); + existsSync.mockImplementation((path) => String(path) === themesDirectory); await expect(getWordPressInfo()).resolves.toEqual({ name: 'wordpress', - root: '/home/uname/Projects/cornflake', - emulsifyParentDirectory: '/home/uname/Projects/cornflake/web/app/themes', + root: projectRoot, + emulsifyParentDirectory: themesDirectory, }); }); it('detects a Composer web-root WordPress themes directory', async () => { - cwd.mockReturnValue( - '/home/uname/Projects/cornflake/web/wp-content/themes/my-theme', - ); - existsSync.mockImplementation( - (path) => - String(path) === '/home/uname/Projects/cornflake/web/wp-content/themes', - ); + const themesDirectory = join(projectRoot, 'web', 'wp-content', 'themes'); + cwd.mockReturnValue(join(themesDirectory, 'my-theme')); + existsSync.mockImplementation((path) => String(path) === themesDirectory); await expect(getWordPressInfo()).resolves.toEqual({ name: 'wordpress', - root: '/home/uname/Projects/cornflake', - emulsifyParentDirectory: - '/home/uname/Projects/cornflake/web/wp-content/themes', + root: projectRoot, + emulsifyParentDirectory: themesDirectory, }); }); it('returns void if no WordPress themes directory is found', async () => { - cwd.mockReturnValue('/home/uname/Projects/cornflake'); + cwd.mockReturnValue(projectRoot); existsSync.mockReturnValue(false); await expect(getWordPressInfo()).resolves.toBe(undefined); diff --git a/src/util/project/generateComponent.test.ts b/src/util/project/generateComponent.test.ts index 8fb6de7..e98c4bd 100644 --- a/src/util/project/generateComponent.test.ts +++ b/src/util/project/generateComponent.test.ts @@ -17,14 +17,33 @@ jest.mock('../fs/findFileInCurrentPath.js'); import { select, confirm } from '@inquirer/prompts'; import { promises as fs } from 'fs'; +import { join, normalize, resolve, sep } from 'path'; import { pathExists, remove } from 'fs-extra'; import log from '../../lib/log.js'; +import { + EMULSIFY_PROJECT_CONFIG_FILE, + EMULSIFY_PROJECT_TEMPLATES_FOLDER, +} from '../../lib/constants.js'; import generateComponent from './generateComponent.js'; import { EmulsifyVariant } from '@emulsify-cli/config'; import findFileInCurrentPath from '../fs/findFileInCurrentPath.js'; +const projectRoot = resolve( + '/home/uname/Projects/cornflake/web/themes/custom/themename', +); +const projectConfigPath = join(projectRoot, EMULSIFY_PROJECT_CONFIG_FILE); +const componentStructurePath = join(projectRoot, 'components', '00-base'); +const componentPath = (name: string, fileName?: string) => + fileName + ? join(componentStructurePath, name, fileName) + : join(componentStructurePath, name); +const projectTemplatePath = (format: string, fileName: string) => + join(projectRoot, EMULSIFY_PROJECT_TEMPLATES_FOLDER, format, fileName); +const templatePathFragment = `${sep}${normalize( + EMULSIFY_PROJECT_TEMPLATES_FOLDER, +)}${sep}`; const findFileMock = (findFileInCurrentPath as jest.Mock).mockReturnValue( - '/home/uname/Projects/cornflake/web/themes/custom/themename/project.emulsify.json', + projectConfigPath, ); const variant = { @@ -57,7 +76,7 @@ function setStdinIsTTY(value: boolean | undefined) { } function isTemplatePath(path: unknown): boolean { - return String(path).includes('/.cli/templates/'); + return String(path).includes(templatePathFragment); } function mockTemplateOverrides( @@ -69,11 +88,11 @@ function mockTemplateOverrides( if (isTemplatePath(value)) { return Object.keys(overrides).some((templatePath) => - value.endsWith(templatePath), + value.endsWith(normalize(templatePath)), ); } - if (value.includes('/components/00-base/')) { + if (value.startsWith(`${componentStructurePath}${sep}`)) { return componentExists; } @@ -82,7 +101,7 @@ function mockTemplateOverrides( readFileMock.mockImplementation(async (path) => { const value = String(path); const templatePath = Object.keys(overrides).find((key) => - value.endsWith(key), + value.endsWith(normalize(key)), ); return templatePath ? overrides[templatePath] : ''; @@ -171,7 +190,7 @@ describe('generateComponent', () => { expect(select).not.toHaveBeenCalled(); expect(confirm).not.toHaveBeenCalled(); expect(writeFileMock).toHaveBeenCalledWith( - '/home/uname/Projects/cornflake/web/themes/custom/themename/components/00-base/button/button.component.yml', + componentPath('button', 'button.component.yml'), expect.stringContaining('name: Button'), ); }); @@ -181,9 +200,7 @@ describe('generateComponent', () => { setStdinIsTTY(false); pathExistsMock.mockImplementation((path) => { const value = String(path); - return ( - !isTemplatePath(value) && !value.endsWith('/components/00-base/card') - ); + return !isTemplatePath(value) && !value.endsWith(componentPath('card')); }); await generateComponent(variant, 'card', { @@ -202,9 +219,7 @@ describe('generateComponent', () => { ); expect(log).toHaveBeenCalledWith( 'info', - expect.stringContaining( - '/home/uname/Projects/cornflake/web/themes/custom/themename/components/00-base/card/card.stories.js', - ), + expect.stringContaining(componentPath('card', 'card.stories.js')), ); }); @@ -213,9 +228,7 @@ describe('generateComponent', () => { setStdinIsTTY(false); pathExistsMock.mockImplementation((path) => { const value = String(path); - return ( - !isTemplatePath(value) && !value.endsWith('/components/00-base/teaser') - ); + return !isTemplatePath(value) && !value.endsWith(componentPath('teaser')); }); await generateComponent(variant, 'teaser', { @@ -233,9 +246,7 @@ describe('generateComponent', () => { ); expect(log).toHaveBeenCalledWith( 'info', - expect.stringContaining( - '/home/uname/Projects/cornflake/web/themes/custom/themename/components/00-base/teaser/teaser.component.yml', - ), + expect.stringContaining(componentPath('teaser', 'teaser.component.yml')), ); }); @@ -343,7 +354,7 @@ describe('generateComponent', () => { }, ), ).rejects.toThrow( - 'Component structure directory "../../outside" resolves to "/home/uname/Projects/cornflake/web/themes/outside", which is outside the expected root "/home/uname/Projects/cornflake/web/themes/custom/themename".', + `Component structure directory "../../outside" resolves to "${resolve(projectRoot, '../../outside')}", which is outside the expected root "${projectRoot}".`, ); expect(removeMock).not.toHaveBeenCalled(); @@ -377,9 +388,7 @@ describe('generateComponent', () => { }); expect(confirm).not.toHaveBeenCalled(); - expect(removeMock).toHaveBeenCalledWith( - '/home/uname/Projects/cornflake/web/themes/custom/themename/components/00-base/link', - ); + expect(removeMock).toHaveBeenCalledWith(componentPath('link')); expect(log).toHaveBeenCalledWith( 'success', expect.stringContaining('Success!'), @@ -443,15 +452,15 @@ describe('generateComponent', () => { }); expect(readFileMock).toHaveBeenCalledWith( - '/home/uname/Projects/cornflake/web/themes/custom/themename/.cli/templates/default/component.twig', + projectTemplatePath('default', 'component.twig'), 'utf8', ); expect(writeFileMock).toHaveBeenCalledWith( - '/home/uname/Projects/cornflake/web/themes/custom/themename/components/00-base/featured-item/featured-item.twig', + componentPath('featured-item', 'featured-item.twig'), '<article class="featured-item">Featured Item featured-item base default</article>', ); expect(writeFileMock).toHaveBeenCalledWith( - '/home/uname/Projects/cornflake/web/themes/custom/themename/components/00-base/featured-item/featured-item.scss', + componentPath('featured-item', 'featured-item.scss'), expect.stringContaining('Base Styles for featured-item (STANDARD)'), ); expect(log).toHaveBeenCalledWith( @@ -472,11 +481,11 @@ describe('generateComponent', () => { }); expect(writeFileMock).toHaveBeenCalledWith( - '/home/uname/Projects/cornflake/web/themes/custom/themename/components/00-base/featured-item/featured-item.twig', + componentPath('featured-item', 'featured-item.twig'), expect.stringContaining('featured-item.twig'), ); expect(writeFileMock).toHaveBeenCalledWith( - '/home/uname/Projects/cornflake/web/themes/custom/themename/components/00-base/featured-item/featured-item.scss', + componentPath('featured-item', 'featured-item.scss'), '.featured-item { color: red; }\n', ); }); @@ -493,14 +502,14 @@ describe('generateComponent', () => { }); expect(writeFileMock).toHaveBeenCalledWith( - '/home/uname/Projects/cornflake/web/themes/custom/themename/components/00-base/featured-item/featured-item.yml', + componentPath('featured-item', 'featured-item.yml'), `featured_item__heading: 'Featured Item Component' featured_item__content: 'This is the content area of the Featured Item component, created using the standard Emulsify format. Replace with your markup and data.' `, ); expect(log).toHaveBeenCalledWith( 'warn', - 'Component template override "/home/uname/Projects/cornflake/web/themes/custom/themename/.cli/templates/default/component.yml" is empty; using the built-in template instead.', + `Component template override "${projectTemplatePath('default', 'component.yml')}" is empty; using the built-in template instead.`, ); }); @@ -516,7 +525,7 @@ featured_item__content: 'This is the content area of the Featured Item component }); expect(writeFileMock).toHaveBeenCalledWith( - '/home/uname/Projects/cornflake/web/themes/custom/themename/components/00-base/featured-item/featured-item.twig', + componentPath('featured-item', 'featured-item.twig'), 'Featured Item {{ unknownToken }}', ); expect(log).toHaveBeenCalledWith( @@ -536,7 +545,7 @@ featured_item__content: 'This is the content area of the Featured Item component expect(writeFileMock.mock.calls).toEqual([ [ - '/home/uname/Projects/cornflake/web/themes/custom/themename/components/00-base/featured-item/featured-item.twig', + componentPath('featured-item', 'featured-item.twig'), `{# /** * @file @@ -567,7 +576,7 @@ featured_item__content: 'This is the content area of the Featured Item component `, ], [ - '/home/uname/Projects/cornflake/web/themes/custom/themename/components/00-base/featured-item/featured-item.scss', + componentPath('featured-item', 'featured-item.scss'), `/* * Base Styles for featured-item (STANDARD) * @@ -597,13 +606,13 @@ featured_item__content: 'This is the content area of the Featured Item component `, ], [ - '/home/uname/Projects/cornflake/web/themes/custom/themename/components/00-base/featured-item/featured-item.yml', + componentPath('featured-item', 'featured-item.yml'), `featured_item__heading: 'Featured Item Component' featured_item__content: 'This is the content area of the Featured Item component, created using the standard Emulsify format. Replace with your markup and data.' `, ], [ - '/home/uname/Projects/cornflake/web/themes/custom/themename/components/00-base/featured-item/featured-item.stories.js', + componentPath('featured-item', 'featured-item.stories.js'), `import featuredItemTwig from './featured-item.twig'; import featuredItemData from './featured-item.yml'; @@ -643,7 +652,7 @@ Drupal.behaviors.featuredItem = { expect(expectedSdcJs).not.toContain('\t'); expect(writeFileMock.mock.calls).toEqual([ [ - '/home/uname/Projects/cornflake/web/themes/custom/themename/components/00-base/featured-item/featured-item.twig', + componentPath('featured-item', 'featured-item.twig'), `{# /** * @file @@ -674,7 +683,7 @@ Drupal.behaviors.featuredItem = { `, ], [ - '/home/uname/Projects/cornflake/web/themes/custom/themename/components/00-base/featured-item/featured-item.scss', + componentPath('featured-item', 'featured-item.scss'), `/* * Base Styles for featured-item (SDC) * @@ -704,7 +713,7 @@ Drupal.behaviors.featuredItem = { `, ], [ - '/home/uname/Projects/cornflake/web/themes/custom/themename/components/00-base/featured-item/featured-item.component.yml', + componentPath('featured-item', 'featured-item.component.yml'), `$schema: https://git.drupalcode.org/project/drupal/-/raw/11.x/core/modules/sdc/src/metadata.schema.json name: Featured Item group: Custom @@ -722,12 +731,9 @@ props: data: 'This is the content area of the Featured Item component, created using the Single Directory Component (SDC) format for Drupal. Replace with your markup and data.' `, ], + [componentPath('featured-item', 'featured-item.js'), expectedSdcJs], [ - '/home/uname/Projects/cornflake/web/themes/custom/themename/components/00-base/featured-item/featured-item.js', - expectedSdcJs, - ], - [ - '/home/uname/Projects/cornflake/web/themes/custom/themename/components/00-base/featured-item/featured-item.stories.js', + componentPath('featured-item', 'featured-item.stories.js'), `import featuredItemTwig from './featured-item.twig'; import { props } from './featured-item.component.yml'; import './featured-item'; diff --git a/src/util/project/installComponentFromCache.test.ts b/src/util/project/installComponentFromCache.test.ts index 011aa38..2cf7d65 100644 --- a/src/util/project/installComponentFromCache.test.ts +++ b/src/util/project/installComponentFromCache.test.ts @@ -2,13 +2,20 @@ jest.mock('../cache/copyItemFromCache', () => jest.fn()); jest.mock('../fs/findFileInCurrentPath', () => jest.fn()); import { pathExists } from 'fs-extra'; +import { join, resolve } from 'path'; import type { EmulsifySystem, EmulsifyVariant } from '@emulsify-cli/config'; +import { EMULSIFY_PROJECT_CONFIG_FILE } from '../../lib/constants.js'; import copyItemFromCache from '../cache/copyItemFromCache.js'; import findFileInCurrentPath from '../fs/findFileInCurrentPath.js'; import installComponentFromCache from './installComponentFromCache.js'; +const projectRoot = resolve( + '/home/username/Projects/drupal-project/web/themes/custom/themename', +); +const projectConfigPath = join(projectRoot, EMULSIFY_PROJECT_CONFIG_FILE); +const componentDestination = join(projectRoot, 'components', '00-base', 'link'); const findFileMock = (findFileInCurrentPath as jest.Mock).mockReturnValue( - '/home/username/Projects/drupal-project/web/themes/custom/themename/project.emulsify.json', + projectConfigPath, ); const pathExistsMock = (pathExists as jest.Mock).mockResolvedValue(false); const copyItemMock = copyItemFromCache as jest.Mock; @@ -16,9 +23,7 @@ const copyItemMock = copyItemFromCache as jest.Mock; describe('installComponentFromCache', () => { beforeEach(() => { jest.clearAllMocks(); - findFileMock.mockReturnValue( - '/home/username/Projects/drupal-project/web/themes/custom/themename/project.emulsify.json', - ); + findFileMock.mockReturnValue(projectConfigPath); pathExistsMock.mockResolvedValue(false); }); @@ -99,7 +104,7 @@ describe('installComponentFromCache', () => { true, ), ).rejects.toThrow( - 'Component destination "../../outside/link" resolves to "/home/username/Projects/drupal-project/web/themes/outside/link", which is outside the expected root "/home/username/Projects/drupal-project/web/themes/custom/themename".', + `Component destination "../../outside/link" resolves to "${resolve(projectRoot, '../../outside/link')}", which is outside the expected root "${projectRoot}".`, ); expect(pathExistsMock).not.toHaveBeenCalled(); @@ -122,7 +127,7 @@ describe('installComponentFromCache', () => { expect(copyItemMock).toHaveBeenCalledWith( 'systems', ['compound', './components/00-base', 'link'], - '/home/username/Projects/drupal-project/web/themes/custom/themename/components/00-base/link', + componentDestination, false, ); }); diff --git a/src/util/project/installGeneralAssetsFromCache.test.ts b/src/util/project/installGeneralAssetsFromCache.test.ts index 1f57c41..dc0ead9 100644 --- a/src/util/project/installGeneralAssetsFromCache.test.ts +++ b/src/util/project/installGeneralAssetsFromCache.test.ts @@ -2,21 +2,25 @@ jest.mock('../cache/copyItemFromCache', () => jest.fn()); jest.mock('../fs/findFileInCurrentPath', () => jest.fn()); import type { EmulsifySystem, EmulsifyVariant } from '@emulsify-cli/config'; +import { join, resolve } from 'path'; +import { EMULSIFY_PROJECT_CONFIG_FILE } from '../../lib/constants.js'; import copyItemFromCache from '../cache/copyItemFromCache.js'; import findFileInCurrentPath from '../fs/findFileInCurrentPath.js'; import installGeneralAssetsFromCache from './installGeneralAssetsFromCache.js'; +const projectRoot = resolve( + '/home/username/Projects/drupal-project/web/themes/custom/themename', +); +const projectConfigPath = join(projectRoot, EMULSIFY_PROJECT_CONFIG_FILE); const findFileMock = (findFileInCurrentPath as jest.Mock).mockReturnValue( - '/home/username/Projects/drupal-project/web/themes/custom/themename/project.emulsify.json', + projectConfigPath, ); const copyItemMock = (copyItemFromCache as jest.Mock).mockResolvedValue(true); describe('installGeneralAssetsFromCache', () => { beforeEach(() => { jest.clearAllMocks(); - findFileMock.mockReturnValue( - '/home/username/Projects/drupal-project/web/themes/custom/themename/project.emulsify.json', - ); + findFileMock.mockReturnValue(projectConfigPath); copyItemMock.mockResolvedValue(true); }); @@ -64,7 +68,7 @@ describe('installGeneralAssetsFromCache', () => { ], } as EmulsifyVariant), ).rejects.toThrow( - 'General asset destination "../../outside" resolves to "/home/username/Projects/drupal-project/web/themes/outside", which is outside the expected root "/home/username/Projects/drupal-project/web/themes/custom/themename".', + `General asset destination "../../outside" resolves to "${resolve(projectRoot, '../../outside')}", which is outside the expected root "${projectRoot}".`, ); expect(copyItemMock).not.toHaveBeenCalled(); @@ -77,14 +81,14 @@ describe('installGeneralAssetsFromCache', () => { 1, 'systems', ['compound', './components/00-base/00-defaults'], - '/home/username/Projects/drupal-project/web/themes/custom/themename/components/00-base/00-defaults', + join(projectRoot, 'components', '00-base', '00-defaults'), true, ); expect(copyItemMock).toHaveBeenNthCalledWith( 2, 'systems', ['compound', './components/style.scss'], - '/home/username/Projects/drupal-project/web/themes/custom/themename/components/style.scss', + join(projectRoot, 'components', 'style.scss'), true, ); }); diff --git a/src/util/project/resolveComponentTemplate.test.ts b/src/util/project/resolveComponentTemplate.test.ts index 639b10d..cd9e349 100644 --- a/src/util/project/resolveComponentTemplate.test.ts +++ b/src/util/project/resolveComponentTemplate.test.ts @@ -5,13 +5,22 @@ jest.mock('../../lib/log.js'); import { promises as fs } from 'fs'; +import { join, resolve } from 'path'; import { pathExists } from 'fs-extra'; import log from '../../lib/log.js'; +import { EMULSIFY_PROJECT_TEMPLATES_FOLDER } from '../../lib/constants.js'; import resolveComponentTemplate from './resolveComponentTemplate.js'; import type { ComponentTemplateVars } from './renderTemplate.js'; const pathExistsMock = pathExists as jest.Mock; const readFileMock = fs.readFile as jest.Mock; +const projectRoot = resolve('/project'); +const templatePath = join( + projectRoot, + EMULSIFY_PROJECT_TEMPLATES_FOLDER, + 'default', + 'component.twig', +); const vars: ComponentTemplateVars = { filename: 'featured-item', @@ -33,7 +42,7 @@ describe('resolveComponentTemplate', () => { pathExistsMock.mockResolvedValueOnce(false); await expect( - resolveComponentTemplate('/project', 'default', 'component.twig', vars), + resolveComponentTemplate(projectRoot, 'default', 'component.twig', vars), ).resolves.toBeNull(); expect(readFileMock).not.toHaveBeenCalled(); @@ -45,13 +54,10 @@ describe('resolveComponentTemplate', () => { readFileMock.mockResolvedValueOnce('<h2>{{ humanName }}</h2>'); await expect( - resolveComponentTemplate('/project', 'default', 'component.twig', vars), + resolveComponentTemplate(projectRoot, 'default', 'component.twig', vars), ).resolves.toBe('<h2>Featured Item</h2>'); - expect(readFileMock).toHaveBeenCalledWith( - '/project/.cli/templates/default/component.twig', - 'utf8', - ); + expect(readFileMock).toHaveBeenCalledWith(templatePath, 'utf8'); }); it('falls back and warns when an override file is empty', async () => { @@ -60,13 +66,13 @@ describe('resolveComponentTemplate', () => { readFileMock.mockResolvedValueOnce(' \n'); await expect( - resolveComponentTemplate('/project', 'default', 'component.twig', vars), + resolveComponentTemplate(projectRoot, 'default', 'component.twig', vars), ).resolves.toBeNull(); expect(log).toHaveBeenCalledTimes(1); expect(log).toHaveBeenCalledWith( 'warn', - 'Component template override "/project/.cli/templates/default/component.twig" is empty; using the built-in template instead.', + `Component template override "${templatePath}" is empty; using the built-in template instead.`, ); }); }); From 4d7590305ec7d40339264e7ac701d4bbb6ae620e Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Sat, 29 Aug 2026 20:47:13 -0500 Subject: [PATCH 14/27] test(e2e): add an end-to-end fixture suite for the built CLI --- .github/workflows/ci.yml | 8 +- docs/cli-reference.md | 4 + docs/development.md | 23 ++- docs/systems.md | 7 +- package.json | 3 +- src/index.ts | 5 + test/e2e/cli.test.mjs | 372 +++++++++++++++++++++++++++++++++++++++ test/e2e/root-help.txt | 81 +++++++++ 8 files changed, 497 insertions(+), 6 deletions(-) create mode 100644 test/e2e/cli.test.mjs create mode 100644 test/e2e/root-help.txt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7a40dd5..bffa1f9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,8 +34,12 @@ jobs: run: npm run build - name: Typecheck run: npm run type - - name: Test - run: npm test + - name: Unit tests + run: npm run test:unit + - name: Release tests + run: npm run test:release + - name: End-to-end tests + run: npm run test:e2e package: name: Package diff --git a/docs/cli-reference.md b/docs/cli-reference.md index a49f341..df74bf7 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -134,6 +134,7 @@ Options: | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------ | | `-r, --repository <repository>` | Install a system from a specific Git repository. Custom repository URLs must end in `.git`. | | `-c, --checkout <commit/branch/tag>` | Checkout to use. This is required when `--repository` is used. | +| `--variant <platform-expression>` | Install the variant whose platform expression exactly matches this value. Quote compound expressions at the shell. | | `-a, --all` | Install every component in the selected variant. Without this flag, only components marked `required: true` are installed during system install. | Examples: @@ -144,6 +145,7 @@ emulsify system install compound emulsify system install emulsify-ui-kit emulsify system install compound --all emulsify system install --repository https://github.com/example/example-system.git --checkout v1.0.0 +emulsify system install --repository https://github.com/example/example-system.git --checkout v1.0.0 --variant wordpress ``` Selecting `create a new system` writes `system.emulsify.json` in the current Emulsify project root. Complete the generated system name, repository, structures, variants, and components before using it to install or generate components. @@ -156,6 +158,8 @@ System variant compatibility is selected from each variant's `platform` expressi Project configuration uses only concrete `project.platform` values: `drupal`, `wordpress`, or `none`. Only system variants use `||` expressions. A project with `project.platform: "none"` can install any component library system; when multiple variants are equally compatible, the CLI prompts in an interactive terminal or errors in non-interactive mode. +Pass `--variant` to choose an exact variant platform expression instead of automatic compatibility selection. Quote a shared expression at the shell, for example `--variant "drupal || wordpress"`. + ## `component list` ```bash diff --git a/docs/development.md b/docs/development.md index fb5ac58..c506ce7 100644 --- a/docs/development.md +++ b/docs/development.md @@ -32,6 +32,7 @@ emulsify --help | `src/types` | Type modules. Generated schema types are prefixed with `_`. | | `src/util` | Platform detection, cache utilities, filesystem helpers, component generation, and project config utilities. | | `scripts` | Package verification and release-calculation scripts, plus their regression tests. | +| `test/e2e` | End-to-end tests that run the built CLI against temporary local Git repositories. | ## Scripts @@ -47,7 +48,8 @@ emulsify --help | `npm run watch-ts` | Recompile TypeScript when files in `src` change. | | `npm run format` | Run Prettier on source TypeScript and JavaScript files. | | `npm run lint` | Run the complete test suite through `npm run test`. | -| `npm run test` | Run Jest coverage and release-automation regression tests. | +| `npm run test` | Run unit and release tests, build the CLI, then run the end-to-end suite. | +| `npm run test:e2e` | Run the built CLI end to end with Node's test runner. | | `npm run test:unit` | Run the Jest unit suite with coverage. | | `npm run test:release` | Run the release-calculation integration tests with Node's test runner. | | `npm run type` | Run TypeScript checking without emitting files. | @@ -63,6 +65,25 @@ Run one test file by passing the path after `--`: npm run test:unit -- src/handlers/componentCreate.test.ts ``` +## End-to-End Tests + +The tests in `test/e2e/` use Node's test runner to spawn the real built binary +at `dist/index.js`. They create local Git repositories and an isolated home and +cache beneath the operating system's temporary directory, so they do not use +the network or the developer's Emulsify cache. + +These tests must remain outside `src/`. Jest applies `jest.setup.cjs` to every +test under `src/`, globally mocking `simple-git`, `fs`, `fs-extra`, +`child_process`, and `progress`; those mocks would prevent a real end-to-end +run. + +Build first, then run one end-to-end file directly: + +```bash +npm run build +node --test ./test/e2e/cli.test.mjs +``` + ## Generated Types Schema-derived type files live in `src/types` and are generated by: diff --git a/docs/systems.md b/docs/systems.md index 17b9545..2bb7bdb 100644 --- a/docs/systems.md +++ b/docs/systems.md @@ -49,7 +49,7 @@ For a built-in system, the command: 3. Checks out the latest Git tag when the built-in system reference does not specify a checkout. 4. Clones the system into the local Emulsify cache. 5. Reads and validates `system.emulsify.json` from the cached system. -6. Selects the best compatible variant for `project.platform`. +6. Selects the best compatible variant for `project.platform`, or the exact expression passed with `--variant`. 7. Writes `system` and `variant` entries into `project.emulsify.json`. 8. Installs components marked `required: true`. 9. Installs variant-level general files and directories. @@ -74,11 +74,14 @@ Use `--repository` and `--checkout` together. ```bash emulsify system install \ --repository https://github.com/example/example-system.git \ - --checkout v1.0.0 + --checkout v1.0.0 \ + --variant wordpress ``` Custom system repository URLs must end in `.git`, because the CLI parses the system name from the repository filename. +Omit `--variant` to use automatic platform compatibility selection. Pass it to select an exact variant platform expression; quote shared expressions such as `--variant "drupal || wordpress"`. + Prefer tags or commit hashes for `--checkout` so subsequent installs use the same system version. ## Create A New System Definition diff --git a/package.json b/package.json index a5aa555..ca42af5 100644 --- a/package.json +++ b/package.json @@ -33,9 +33,10 @@ "husky:commit-msg": "commitlint --edit $1", "husky:pre-commit": "lint-staged", "husky:pre-push": "npm run test", - "test": "npm run test:unit && npm run test:release", + "test": "npm run test:unit && npm run test:release && npm run build && npm run test:e2e", "test:unit": "jest --coverage --silent", "test:release": "node --test ./scripts/version-develop.test.mjs ./scripts/verify-release-analysis.test.mjs", + "test:e2e": "node --test ./test/e2e/*.test.mjs", "lint": "npm run test", "type": "tsc --noEmit", "twatch": "jest --no-coverage --watch --verbose", diff --git a/src/index.ts b/src/index.ts index c0be130..4c12bf7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -67,6 +67,7 @@ function getRootHelp(): string { ' Options:', ' -r, --repository <repository> Install from a custom system repository ending in .git.', ' -c, --checkout <commit/branch/tag> Checkout to use with --repository.', + ' --variant <platform-expression> Select an exact variant platform expression.', ' -a, --all Install every component in the selected variant.', '', ' component list', @@ -164,6 +165,10 @@ system '-c --checkout <commit/branch/tag>', 'Commit, branch, or tag to check out. Required when --repository is used.', ) + .option( + '--variant <platform-expression>', + 'Exact system variant platform expression to install.', + ) .option( '-a --all', 'Install every component in the selected variant. Without this flag, only required components are installed.', diff --git a/test/e2e/cli.test.mjs b/test/e2e/cli.test.mjs new file mode 100644 index 0000000..8ef5980 --- /dev/null +++ b/test/e2e/cli.test.mjs @@ -0,0 +1,372 @@ +/** + * @file End-to-end tests for the real, built Emulsify CLI. + */ + +import assert from 'node:assert/strict'; +import { execFileSync, spawnSync } from 'node:child_process'; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { after, before, describe, test } from 'node:test'; + +const repositoryRoot = fileURLToPath(new URL('../..', import.meta.url)); +const cliPath = join(repositoryRoot, 'dist', 'index.js'); +const packageInfo = JSON.parse( + readFileSync(join(repositoryRoot, 'package.json'), 'utf8'), +); +const expectedRootHelp = readFileSync( + new URL('./root-help.txt', import.meta.url), + 'utf8', +) + .replaceAll('\r\n', '\n') + .replace('{{version}}', packageInfo.version); + +let tempRoot; +let isolatedHome; +let projectsRoot; +let projectRoot; +let starterRepository; +let systemRepository; +let systemHookSentinel; + +function json(value) { + return `${JSON.stringify(value, null, 2)}\n`; +} + +function isolatedEnvironment() { + const env = { + ...process.env, + HOME: isolatedHome, + USERPROFILE: isolatedHome, + GIT_CONFIG_GLOBAL: join(isolatedHome, '.gitconfig'), + GIT_CONFIG_NOSYSTEM: '1', + GIT_TERMINAL_PROMPT: '0', + NODE_NO_WARNINGS: '1', + NO_COLOR: '1', + npm_config_audit: 'false', + npm_config_cache: join(tempRoot, 'npm-cache'), + npm_config_fund: 'false', + npm_config_offline: 'true', + npm_config_update_notifier: 'false', + }; + + delete env.CLICOLOR_FORCE; + delete env.FORCE_COLOR; + return env; +} + +function git(cwd, args) { + return execFileSync('git', args, { + cwd, + encoding: 'utf8', + env: isolatedEnvironment(), + stdio: ['ignore', 'pipe', 'pipe'], + }).trim(); +} + +function createGitRepository(directory, files) { + mkdirSync(directory, { recursive: true }); + git(directory, ['init', '--initial-branch=main']); + + for (const [relativePath, contents] of Object.entries(files)) { + const destination = join(directory, relativePath); + mkdirSync(dirname(destination), { recursive: true }); + writeFileSync(destination, contents); + } + + git(directory, ['add', '.']); + git(directory, [ + '-c', + 'user.email=e2e@example.test', + '-c', + 'user.name=Emulsify E2E', + 'commit', + '-m', + 'test: create e2e fixture', + ]); +} + +function runCli(cwd, args) { + const result = spawnSync(process.execPath, [cliPath, ...args], { + cwd, + encoding: 'utf8', + env: isolatedEnvironment(), + shell: false, + timeout: 15_000, + windowsHide: true, + }); + + if (result.error) { + throw result.error; + } + assert.equal(result.signal, null, `CLI terminated with ${result.signal}`); + + return { + status: result.status, + stderr: result.stderr, + stdout: result.stdout, + }; +} + +function commandFailure(label, result) { + return [ + `${label} failed with status ${result.status}.`, + `stdout:\n${result.stdout}`, + `stderr:\n${result.stderr}`, + ].join('\n'); +} + +describe('built Emulsify CLI', { concurrency: false }, () => { + before(() => { + tempRoot = mkdtempSync(join(tmpdir(), 'emulsify-cli-e2e-')); + isolatedHome = join(tempRoot, 'home'); + projectsRoot = join(tempRoot, 'projects'); + projectRoot = join(projectsRoot, 'fixture-project'); + systemHookSentinel = join(projectRoot, 'system-install-hook-ran.txt'); + mkdirSync(isolatedHome, { recursive: true }); + mkdirSync(projectsRoot, { recursive: true }); + + const starterPath = join(tempRoot, 'starter.git'); + createGitRepository(starterPath, { + 'package.json': json({ + name: 'emulsify-e2e-starter', + version: '1.0.0', + private: true, + type: 'module', + }), + 'package-lock.json': json({ + name: 'emulsify-e2e-starter', + version: '1.0.0', + lockfileVersion: 3, + requires: true, + packages: { + '': { + name: 'emulsify-e2e-starter', + version: '1.0.0', + }, + }, + }), + 'system.emulsify.json': json({ + name: 'fixture-project-system', + homepage: 'https://example.test/project-system', + repository: 'https://example.test/fixture-project-system.git', + structure: [], + variants: [], + }), + '.cli/systemInstall.js': [ + "import { writeFileSync } from 'node:fs';", + "writeFileSync(new URL('../system-install-hook-ran.txt', import.meta.url), 'ran\\n');", + '', + ].join('\n'), + }); + starterRepository = pathToFileURL(starterPath).href; + + const structureImplementations = [ + { + name: 'components', + directory: 'components', + }, + ]; + const systemPath = join(tempRoot, 'fixture-system.git'); + createGitRepository(systemPath, { + 'system.emulsify.json': json({ + name: 'fixture-system', + homepage: 'https://example.test/fixture-system', + repository: 'https://example.test/fixture-system.git', + structure: [ + { + name: 'components', + description: 'Fixture components', + }, + ], + variants: [ + { + platform: 'none', + structureImplementations, + components: [ + { + name: 'button', + structure: 'components', + required: true, + }, + ], + }, + { + platform: 'wordpress', + structureImplementations, + components: [ + { + name: 'card', + structure: 'components', + required: true, + }, + { + name: 'missing', + structure: 'components', + }, + ], + }, + ], + }), + 'components/button/fixture.txt': 'button fixture\n', + 'components/card/fixture.txt': 'card fixture\n', + }); + systemRepository = pathToFileURL(systemPath).href; + }); + + after(() => { + if (tempRoot) { + rmSync(tempRoot, { + recursive: true, + force: true, + maxRetries: 3, + retryDelay: 100, + }); + } + }); + + test('prints the complete root help before exiting', () => { + const result = runCli(tempRoot, ['--help']); + + assert.equal(result.status, 0, commandFailure('--help', result)); + assert.equal(result.stderr, ''); + assert.equal(result.stdout, expectedRootHelp); + }); + + test('prints the package version', () => { + const result = runCli(tempRoot, ['--version']); + + assert.equal(result.status, 0, commandFailure('--version', result)); + assert.equal(result.stderr, ''); + assert.equal(result.stdout.includes('Emulsify CLI'), true); + assert.equal( + result.stdout.includes(`Version: ${packageInfo.version}`), + true, + ); + }); + + test('initializes a project from a local starter repository', () => { + const result = runCli(tempRoot, [ + 'init', + 'Fixture Project', + projectsRoot, + '--machineName', + 'fixture-project', + '--starter', + starterRepository, + '--checkout', + 'main', + '--platform', + 'none', + '--yes', + ]); + + assert.equal(result.status, 0, commandFailure('init', result)); + assert.equal(result.stderr.trim(), ''); + assert.match(result.stdout, /Created an Emulsify project/); + assert.deepEqual( + JSON.parse( + readFileSync(join(projectRoot, 'project.emulsify.json'), 'utf8'), + ), + { + project: { + platform: 'none', + name: 'Fixture Project', + machineName: 'fixture-project', + }, + starter: { + repository: starterRepository, + }, + }, + ); + assert.equal(existsSync(join(projectRoot, 'package-lock.json')), true); + assert.equal(existsSync(join(projectRoot, 'system.emulsify.json')), true); + assert.equal( + existsSync(join(projectRoot, '.cli', 'systemInstall.js')), + true, + ); + assert.equal(existsSync(join(projectRoot, '.git')), false); + }); + + test('installs an exact variant from a local system repository', () => { + const result = runCli(projectRoot, [ + 'system', + 'install', + '--repository', + systemRepository, + '--checkout', + 'main', + '--variant', + 'wordpress', + ]); + + assert.equal(result.status, 0, commandFailure('system install', result)); + assert.equal(result.stderr, ''); + assert.match( + result.stdout, + /Successfully installed the fixture-system system using the wordpress variant/, + ); + + const projectConfig = JSON.parse( + readFileSync(join(projectRoot, 'project.emulsify.json'), 'utf8'), + ); + assert.deepEqual(projectConfig.system, { + repository: systemRepository, + checkout: 'main', + }); + assert.equal(projectConfig.variant.platform, 'wordpress'); + assert.equal( + readFileSync( + join(projectRoot, 'components', 'card', 'fixture.txt'), + 'utf8', + ), + 'card fixture\n', + ); + assert.equal(existsSync(join(isolatedHome, '.emulsify', 'cache')), true); + }); + + // Known failure: ../../src/handlers/systemInstall.ts:418-427 appends the + // hook path to the system.emulsify.json file instead of its directory. + test( + 'executes the project system-install hook', + { + todo: 'Known failure: src/handlers/systemInstall.ts:418-427', + }, + () => { + assert.equal(existsSync(systemHookSentinel), true); + assert.equal(readFileSync(systemHookSentinel, 'utf8'), 'ran\n'); + }, + ); + + // Known failure: ../../src/handlers/componentInstall.ts:240-247 logs and + // swallows a requested component copy failure instead of failing the CLI. + test( + 'returns a non-zero exit when a requested component cannot be copied', + { + todo: 'Known failure: src/handlers/componentInstall.ts:240-247', + }, + () => { + const result = runCli(projectRoot, ['component', 'install', 'missing']); + + assert.equal(result.stdout, ''); + assert.match(result.stderr, /Unable to install missing:/); + assert.equal( + existsSync(join(projectRoot, 'components', 'missing')), + false, + ); + assert.notEqual( + result.status, + 0, + 'component install must fail when its requested component is not copied', + ); + }, + ); +}); diff --git a/test/e2e/root-help.txt b/test/e2e/root-help.txt new file mode 100644 index 0000000..bd90b12 --- /dev/null +++ b/test/e2e/root-help.txt @@ -0,0 +1,81 @@ +Emulsify CLI {{version}} + +Create Emulsify projects, choose component systems, install components, generate local components, and route audits to Emulsify Core. + +Usage: + emulsify + emulsify --help + emulsify <command> --help + emulsify init [name] [path] [options] + emulsify audit [...args] + emulsify system install [name] [options] + emulsify component <command> [options] + emulsify cache clear [options] + +Common workflow: + emulsify init + emulsify system install + emulsify component list + emulsify component install <name> + emulsify component create <name> + +Commands: + init [name] [path] + Create a project from a starter repository. Prompts for project name, target directory, + and platform when values are missing in an interactive terminal. + Options: + -m, --machineName <machineName> Set the project folder/config machine name. + -s, --starter <repository> Use a custom starter repository. + -c, --checkout <commit/branch/tag> Checkout for the starter repository. + -p, --platform <none|drupal|wordpress> + Select the project platform when auto-detection is unavailable. + Built-in platforms: drupal, wordpress, none. + -y, --yes Accept defaults for missing init values without prompting. + + audit [...args] + Run the project-installed Emulsify Core audit with unchanged arguments, output, and exit status. + Run "emulsify audit --help" for Core-owned audit options. + + system list + List built-in component systems available for installation. Alias: system ls. + + system install [name] + Install a built-in or repository-backed component system. With no name or repository in + an interactive terminal, prompts for compound, emulsify-ui-kit, create a new system, + or cancel. + Options: + -r, --repository <repository> Install from a custom system repository ending in .git. + -c, --checkout <commit/branch/tag> Checkout to use with --repository. + --variant <platform-expression> Select an exact variant platform expression. + -a, --all Install every component in the selected variant. + + component list + List components available from the installed system and selected variant. Alias: component ls. + + component install [name] + Install one component, dependencies, or all components from the installed system. Alias: component i. + Options: + -f, --force Replace an existing component destination. + -a, --all Install all available components. + --dry-run Preview installs without writing files. + + component create [name] + Generate a new local component in this project. Alias: component c. + Options: + -d, --directory <directory> Variant structure where the component should be created. + -f, --format <default|sdc> Component format to generate. + -y, --yes Replace existing generated components without prompting. + --dry-run Preview generated files without writing them. + + cache clear + Remove all locally cached Emulsify repositories. + Options: + --dry-run Report cache contents without removing files. + + help [command] + Show help for a command. + +Global options: + -c, --checkout <commit/branch/tag> Shared checkout option for commands that clone repositories. + -V, --version Show the installed CLI version. + -h, --help Show this help output. From 1749e7c3fe73cec6cb49488c28f59d1128a5c514 Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Sat, 29 Aug 2026 20:53:33 -0500 Subject: [PATCH 15/27] test(e2e): report known failures as conditional todos --- test/e2e/cli.test.mjs | 57 ++++++++++++++++++++----------------------- 1 file changed, 26 insertions(+), 31 deletions(-) diff --git a/test/e2e/cli.test.mjs b/test/e2e/cli.test.mjs index 8ef5980..d20ea36 100644 --- a/test/e2e/cli.test.mjs +++ b/test/e2e/cli.test.mjs @@ -335,38 +335,33 @@ describe('built Emulsify CLI', { concurrency: false }, () => { // Known failure: ../../src/handlers/systemInstall.ts:418-427 appends the // hook path to the system.emulsify.json file instead of its directory. - test( - 'executes the project system-install hook', - { - todo: 'Known failure: src/handlers/systemInstall.ts:418-427', - }, - () => { - assert.equal(existsSync(systemHookSentinel), true); - assert.equal(readFileSync(systemHookSentinel, 'utf8'), 'ran\n'); - }, - ); + test('executes the project system-install hook', (t) => { + if (!existsSync(systemHookSentinel)) { + t.todo('Known failure: src/handlers/systemInstall.ts:418-427'); + return; + } + + assert.equal(readFileSync(systemHookSentinel, 'utf8'), 'ran\n'); + }); // Known failure: ../../src/handlers/componentInstall.ts:240-247 logs and // swallows a requested component copy failure instead of failing the CLI. - test( - 'returns a non-zero exit when a requested component cannot be copied', - { - todo: 'Known failure: src/handlers/componentInstall.ts:240-247', - }, - () => { - const result = runCli(projectRoot, ['component', 'install', 'missing']); - - assert.equal(result.stdout, ''); - assert.match(result.stderr, /Unable to install missing:/); - assert.equal( - existsSync(join(projectRoot, 'components', 'missing')), - false, - ); - assert.notEqual( - result.status, - 0, - 'component install must fail when its requested component is not copied', - ); - }, - ); + test('returns a non-zero exit when a requested component cannot be copied', (t) => { + const result = runCli(projectRoot, ['component', 'install', 'missing']); + + assert.equal(result.stdout, ''); + assert.match(result.stderr, /Unable to install missing:/); + assert.equal(existsSync(join(projectRoot, 'components', 'missing')), false); + + if (result.status === 0) { + t.todo('Known failure: src/handlers/componentInstall.ts:240-247'); + return; + } + + assert.notEqual( + result.status, + 0, + 'component install must fail when its requested component is not copied', + ); + }); }); From d5c0ca121f48a961ef9b9efa429fb2cfb833d2e5 Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Sat, 29 Aug 2026 20:55:13 -0500 Subject: [PATCH 16/27] chore: project dependency updates --- package-lock.json | 3850 +++++++++++++++++++++------------------------ package.json | 22 +- 2 files changed, 1791 insertions(+), 2081 deletions(-) diff --git a/package-lock.json b/package-lock.json index 62a59e5..c6dcc8d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,14 +9,14 @@ "version": "2.3.0", "license": "GPL-2.0", "dependencies": { - "@inquirer/prompts": "^8.5.2", + "@inquirer/prompts": "^8.7.0", "ajv": "^8.20.0", "ajv-formats": "^3.0.1", "boxen": "^8.0.1", "colorette": "^2.0.20", "commander": "^15.0.0", "consola": "^3.4.2", - "fs-extra": "^11.3.6", + "fs-extra": "^11.4.0", "progress": "^2.0.3", "simple-git": "^3.36.0" }, @@ -24,24 +24,24 @@ "emulsify": "dist/index.js" }, "devDependencies": { - "@commitlint/cli": "^21.2.0", - "@commitlint/config-conventional": "^21.2.0", + "@commitlint/cli": "^21.2.2", + "@commitlint/config-conventional": "^21.2.2", "@semantic-release/commit-analyzer": "^13.0.1", "@semantic-release/github": "^12.0.9", "@semantic-release/npm": "^13.1.5", "@semantic-release/release-notes-generator": "^14.1.1", "@types/fs-extra": "^11.0.4", "@types/jest": "^30.0.0", - "@types/node": "^26.1.0", + "@types/node": "^26.4.0", "@types/progress": "^2.0.7", "husky": "^9.1.7", - "jest": "^30.4.2", - "json-schema-to-typescript": "^15.0.4", - "lint-staged": "^17.0.8", + "jest": "^30.5.0", + "json-schema-to-typescript": "^16.0.0", + "lint-staged": "^17.4.1", "nodemon": "^3.1.14", - "prettier": "^3.9.4", - "semantic-release": "^25.0.5", - "ts-jest": "^29.4.11", + "prettier": "^3.9.6", + "semantic-release": "^25.0.9", + "ts-jest": "^29.4.12", "ts-node": "^10.9.2", "typescript": "^6.0.3" }, @@ -50,9 +50,9 @@ } }, "node_modules/@actions/core": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@actions/core/-/core-3.0.0.tgz", - "integrity": "sha512-zYt6cz+ivnTmiT/ksRVriMBOiuoUpDCJJlZ5KPl2/FRdvwU3f7MPh9qftvbkXJThragzUZieit2nyHUyw53Seg==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@actions/core/-/core-3.0.1.tgz", + "integrity": "sha512-a6d/Nwahm9fliVGRhdhofo40HjHQasUPusmc7vBfyky+7Z+P2A1J68zyFVaNcEclc/Se+eO595oAr5nwEIoIUA==", "dev": true, "license": "MIT", "dependencies": { @@ -71,9 +71,9 @@ } }, "node_modules/@actions/http-client": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-4.0.0.tgz", - "integrity": "sha512-QuwPsgVMsD6qaPD57GLZi9sqzAZCtiJT8kVBCDpLtxhL5MydQ4gS+DrejtZZPdIYyB1e95uCK9Luyds7ybHI3g==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-4.0.1.tgz", + "integrity": "sha512-+Nvd1ImaOZBSoPbsUtEhv+1z99H12xzncCkz0a3RuehINE81FZSe2QTj3uvAPTcJX/SCzUQHQ0D1GrPMbrPitg==", "dev": true, "license": "MIT", "dependencies": { @@ -81,6 +81,16 @@ "undici": "^6.23.0" } }, + "node_modules/@actions/http-client/node_modules/undici": { + "version": "6.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz", + "integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, "node_modules/@actions/io": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/@actions/io/-/io-3.0.2.tgz", @@ -173,14 +183,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", - "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.7", - "@babel/types": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -313,13 +323,13 @@ } }, "node_modules/@babel/parser": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", - "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.29.7" + "@babel/types": "^7.29.8" }, "bin": { "parser": "bin/babel-parser.js" @@ -384,13 +394,13 @@ } }, "node_modules/@babel/plugin-syntax-import-attributes": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.28.6.tgz", - "integrity": "sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.29.7.tgz", + "integrity": "sha512-zGYcYfq/WmZ4V+kBIXQon9dSSc8ircGZqw9ZaNhhGj9nZkeBu1jHLBDQqYYi5WA9uawvA2sIMbry2nCFhf5Djg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -583,18 +593,18 @@ } }, "node_modules/@babel/traverse": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", - "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.29.7", - "@babel/generator": "^7.29.7", + "@babel/generator": "^7.29.8", "@babel/helper-globals": "^7.29.7", - "@babel/parser": "^7.29.7", + "@babel/parser": "^7.29.8", "@babel/template": "^7.29.7", - "@babel/types": "^7.29.7", + "@babel/types": "^7.29.8", "debug": "^4.3.1" }, "engines": { @@ -602,9 +612,9 @@ } }, "node_modules/@babel/types": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", - "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", "dev": true, "license": "MIT", "dependencies": { @@ -634,17 +644,17 @@ } }, "node_modules/@commitlint/cli": { - "version": "21.2.0", - "resolved": "https://registry.npmjs.org/@commitlint/cli/-/cli-21.2.0.tgz", - "integrity": "sha512-4GLVIhUaT3c3GBlQ0GB80/5H3xXdn/Tgw4lrsuoOQVDu2wl4Xw0GuzSar8xZKSMv4H3xaKaQXmvH91GmdyYBZA==", + "version": "21.2.2", + "resolved": "https://registry.npmjs.org/@commitlint/cli/-/cli-21.2.2.tgz", + "integrity": "sha512-a+6hQxIxnpdvSvS2apvttPNbEliYsVC3PqFYDiiB2kjbwIsQsj1urvQ4Tkf70pKYozPalKAuRQmm/GHwndduqA==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/config-conventional": "^21.2.0", - "@commitlint/format": "^21.2.0", - "@commitlint/lint": "^21.2.0", - "@commitlint/load": "^21.2.0", - "@commitlint/read": "^21.2.0", + "@commitlint/config-conventional": "^21.2.2", + "@commitlint/format": "^21.2.2", + "@commitlint/lint": "^21.2.2", + "@commitlint/load": "^21.2.2", + "@commitlint/read": "^21.2.1", "@commitlint/types": "^21.2.0", "tinyexec": "^1.0.0", "yargs": "^18.0.0" @@ -657,9 +667,9 @@ } }, "node_modules/@commitlint/config-conventional": { - "version": "21.2.0", - "resolved": "https://registry.npmjs.org/@commitlint/config-conventional/-/config-conventional-21.2.0.tgz", - "integrity": "sha512-Qf8WRDVcyVd14if6VTWenebxFbKnVnbzPUJjlzjkyJGeHK2xCGd63Dr1XZzj0plXKQb9P0BfOxoc1HVeCo2BWQ==", + "version": "21.2.2", + "resolved": "https://registry.npmjs.org/@commitlint/config-conventional/-/config-conventional-21.2.2.tgz", + "integrity": "sha512-NxA37SZviusFUEYOQZ5hNnZ1h7O/KiemPkxjOlpzKJNnWxThiwc6/SaZhaPa8fyLvfRBAywhQhJJk8XESHWlpQ==", "dev": true, "license": "MIT", "dependencies": { @@ -709,9 +719,9 @@ } }, "node_modules/@commitlint/format": { - "version": "21.2.0", - "resolved": "https://registry.npmjs.org/@commitlint/format/-/format-21.2.0.tgz", - "integrity": "sha512-c4q64xaav2U83t7k7RyzJerBZurPer7FxUOY0RL5L/6CZijZ7K+s6HIBGIghj0ey1P2+seRX0J9XQYtDued6tg==", + "version": "21.2.2", + "resolved": "https://registry.npmjs.org/@commitlint/format/-/format-21.2.2.tgz", + "integrity": "sha512-v6fvxZSc/AvVMROlr3H34+1766bZSYApRUSCAMjWamStPjKMvZ8GdvVA5YW/VQNgbFTmcMz6OYmSTJEvIjPrfA==", "dev": true, "license": "MIT", "dependencies": { @@ -723,9 +733,9 @@ } }, "node_modules/@commitlint/is-ignored": { - "version": "21.2.0", - "resolved": "https://registry.npmjs.org/@commitlint/is-ignored/-/is-ignored-21.2.0.tgz", - "integrity": "sha512-4/eB0vBN7L88O/oC4ajAEqi7j2ZfNgxl/+11RfAV9YosejZgDXhY2C9VcHnHJhOzPLoSy5P3Mg/46kqeyJfXKw==", + "version": "21.2.2", + "resolved": "https://registry.npmjs.org/@commitlint/is-ignored/-/is-ignored-21.2.2.tgz", + "integrity": "sha512-9UoKNgfFE3LU7FrzierCvk3CdDfMDeVGC86qZiT/n0TIjfq/dmZ9MHuXd45OTNRa26ZanmJRxEtmiXk/lEJihg==", "dev": true, "license": "MIT", "dependencies": { @@ -737,15 +747,15 @@ } }, "node_modules/@commitlint/lint": { - "version": "21.2.0", - "resolved": "https://registry.npmjs.org/@commitlint/lint/-/lint-21.2.0.tgz", - "integrity": "sha512-ceO5dp9pLjEZ6y6qbq/uXWXDPykqqlTsyzoQ0NzecpisSJhK3kTy9qzQoPeJuWG/IMNdV1lO0RgmzqoAlSi1uw==", + "version": "21.2.2", + "resolved": "https://registry.npmjs.org/@commitlint/lint/-/lint-21.2.2.tgz", + "integrity": "sha512-Fy8JxEBzdmsYWFude/61GxXu5O+wEymwiRK2z9GL9R8mCsXphCoGxAFc5iHn5mjlfcSrhiiONE+ksf4KOjnaPg==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/is-ignored": "^21.2.0", - "@commitlint/parse": "^21.2.0", - "@commitlint/rules": "^21.2.0", + "@commitlint/is-ignored": "^21.2.2", + "@commitlint/parse": "^21.2.2", + "@commitlint/rules": "^21.2.2", "@commitlint/types": "^21.2.0" }, "engines": { @@ -753,15 +763,15 @@ } }, "node_modules/@commitlint/load": { - "version": "21.2.0", - "resolved": "https://registry.npmjs.org/@commitlint/load/-/load-21.2.0.tgz", - "integrity": "sha512-RjlzWQqruRwIenJEfZtq7kG97co97nKoHpflE5YnF61tDLXxHPrdWImgzw6VL6MlFyaOcVlk74eBV8ZQmc3oIA==", + "version": "21.2.2", + "resolved": "https://registry.npmjs.org/@commitlint/load/-/load-21.2.2.tgz", + "integrity": "sha512-0Tt6wDPX167cjKC5D4zhm0+20wJJG+TN/TKovMOspfSe78rOnKX+MNzlVNiu6HyQPZChPJ8QBH31MVt6Bb8fCg==", "dev": true, "license": "MIT", "dependencies": { "@commitlint/config-validator": "^21.2.0", "@commitlint/execute-rule": "^21.0.1", - "@commitlint/resolve-extends": "^21.2.0", + "@commitlint/resolve-extends": "^21.2.2", "@commitlint/types": "^21.2.0", "cosmiconfig": "^9.0.1", "cosmiconfig-typescript-loader": "^6.1.0", @@ -784,9 +794,9 @@ } }, "node_modules/@commitlint/parse": { - "version": "21.2.0", - "resolved": "https://registry.npmjs.org/@commitlint/parse/-/parse-21.2.0.tgz", - "integrity": "sha512-QHWxG4d0PLTF634/AdyZ0MQS+CLn5YOuJlCFhMMlSGKFxzYGUetkHBj18xgBD+6fVzUrA2lrCdi/vlS2f/oYXg==", + "version": "21.2.2", + "resolved": "https://registry.npmjs.org/@commitlint/parse/-/parse-21.2.2.tgz", + "integrity": "sha512-MEkobPfvRp+z06Wro8HMG1BDGHzZmj82A1LH1nWeG3ipHpg/x4m6v3wEDvMBIKjRFUnfR3nBeFs3MVCr7UdAmg==", "dev": true, "license": "MIT", "dependencies": { @@ -798,72 +808,16 @@ "node": ">=22.12.0" } }, - "node_modules/@commitlint/parse/node_modules/@simple-libs/stream-utils": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@simple-libs/stream-utils/-/stream-utils-2.0.0.tgz", - "integrity": "sha512-fCTuZK4QBa+39Oz9l4OGfJfz+GpwCp3AqO7Zch3to99xHPgstVsRFpeQ8LNd2o1Gv8raL2mCFwiaHh7bFSp5DQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=22" - }, - "funding": { - "url": "https://ko-fi.com/dangreen" - } - }, - "node_modules/@commitlint/parse/node_modules/conventional-changelog-angular": { - "version": "9.2.0", - "resolved": "https://registry.npmjs.org/conventional-changelog-angular/-/conventional-changelog-angular-9.2.0.tgz", - "integrity": "sha512-2EihZvM1KmbBGwuUow8lNXLe+ECRFsyOcV8X0197/WI7Rvvgfi55Oicfw0wxJTXGGIVXSfj+xBoNj+cxLlIiVw==", - "dev": true, - "license": "ISC", - "dependencies": { - "@conventional-changelog/template": "^1.2.0" - }, - "engines": { - "node": ">=22" - } - }, - "node_modules/@commitlint/parse/node_modules/conventional-commits-parser": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/conventional-commits-parser/-/conventional-commits-parser-7.0.0.tgz", - "integrity": "sha512-dZe/p+FgGQLNJFqaCgNdl8j6a7gI8xuaN30Wy5g7nvyK3jqOpNUEUZ3pGJ5D68h89uRh038FtkeOk/bnGmYkmg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@simple-libs/stream-utils": "^2.0.0", - "meow": "^14.0.0" - }, - "bin": { - "conventional-commits-parser": "dist/cli/index.js" - }, - "engines": { - "node": ">=22" - } - }, - "node_modules/@commitlint/parse/node_modules/meow": { - "version": "14.1.0", - "resolved": "https://registry.npmjs.org/meow/-/meow-14.1.0.tgz", - "integrity": "sha512-EDYo6VlmtnumlcBCbh1gLJ//9jvM/ndXHfVXIFrZVr6fGcwTUyCTFNTLCKuY3ffbK8L/+3Mzqnd58RojiZqHVw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/@commitlint/read": { - "version": "21.2.0", - "resolved": "https://registry.npmjs.org/@commitlint/read/-/read-21.2.0.tgz", - "integrity": "sha512-ELx8Ovh/JoAw5lpvDgxc6Y0We9skf2IPI2RFN+gnYgDGjRdMSF8zeodxhZmcclLWzfUIF7hXLOa8gOlllYcvBA==", + "version": "21.2.1", + "resolved": "https://registry.npmjs.org/@commitlint/read/-/read-21.2.1.tgz", + "integrity": "sha512-hUW7EJQnNTL0vPOmVMNK4CrnrNBN0nN+JJHReFkdHO5y4iyHeEmTBwuC15OCqUTjxWo7idnH1LftfpWVIaPWIA==", "dev": true, "license": "MIT", "dependencies": { "@commitlint/top-level": "^21.2.0", "@commitlint/types": "^21.2.0", - "git-raw-commits": "^5.0.0", + "@conventional-changelog/git-client": "^3.0.0", "tinyexec": "^1.0.0" }, "engines": { @@ -871,9 +825,9 @@ } }, "node_modules/@commitlint/resolve-extends": { - "version": "21.2.0", - "resolved": "https://registry.npmjs.org/@commitlint/resolve-extends/-/resolve-extends-21.2.0.tgz", - "integrity": "sha512-4O/1j51+79Wth9s/MGxt/5gs0XYLDgNlYpltQfhAvLE0itusLKs9zruxbiNg1oOkmkb9L9L4USYGjEj7n87NxA==", + "version": "21.2.2", + "resolved": "https://registry.npmjs.org/@commitlint/resolve-extends/-/resolve-extends-21.2.2.tgz", + "integrity": "sha512-RPkJ/IFi7sMUUVbZLqwWFtWw/zRDcfFsmrPSiTMrt5wb7AdxOr86EGQFvmGzef5QKV5IPBWWCujqVTw1RWX44A==", "dev": true, "license": "MIT", "dependencies": { @@ -888,9 +842,9 @@ } }, "node_modules/@commitlint/rules": { - "version": "21.2.0", - "resolved": "https://registry.npmjs.org/@commitlint/rules/-/rules-21.2.0.tgz", - "integrity": "sha512-C2yXMNpiB8ETZKfx5JD8+ExgF8vTU1VQMKPSUUYwqKpw9oJWQBrlXBpdU038mj2WPjof7o9UzFpmTyBeGMZwZg==", + "version": "21.2.2", + "resolved": "https://registry.npmjs.org/@commitlint/rules/-/rules-21.2.2.tgz", + "integrity": "sha512-eplQzyYkBjYB1HyyRj8hkcK11Y9DU9nuBz7uOKEd6NpE9NGDytLFCAnlRE+OoiK/5sHEJsaz2RGhuWBvYzIbNA==", "dev": true, "license": "MIT", "dependencies": { @@ -940,66 +894,23 @@ "node": ">=22.12.0" } }, - "node_modules/@commitlint/types/node_modules/@simple-libs/stream-utils": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@simple-libs/stream-utils/-/stream-utils-2.0.0.tgz", - "integrity": "sha512-fCTuZK4QBa+39Oz9l4OGfJfz+GpwCp3AqO7Zch3to99xHPgstVsRFpeQ8LNd2o1Gv8raL2mCFwiaHh7bFSp5DQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=22" - }, - "funding": { - "url": "https://ko-fi.com/dangreen" - } - }, - "node_modules/@commitlint/types/node_modules/conventional-commits-parser": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/conventional-commits-parser/-/conventional-commits-parser-7.0.0.tgz", - "integrity": "sha512-dZe/p+FgGQLNJFqaCgNdl8j6a7gI8xuaN30Wy5g7nvyK3jqOpNUEUZ3pGJ5D68h89uRh038FtkeOk/bnGmYkmg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@simple-libs/stream-utils": "^2.0.0", - "meow": "^14.0.0" - }, - "bin": { - "conventional-commits-parser": "dist/cli/index.js" - }, - "engines": { - "node": ">=22" - } - }, - "node_modules/@commitlint/types/node_modules/meow": { - "version": "14.1.0", - "resolved": "https://registry.npmjs.org/meow/-/meow-14.1.0.tgz", - "integrity": "sha512-EDYo6VlmtnumlcBCbh1gLJ//9jvM/ndXHfVXIFrZVr6fGcwTUyCTFNTLCKuY3ffbK8L/+3Mzqnd58RojiZqHVw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/@conventional-changelog/git-client": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/@conventional-changelog/git-client/-/git-client-2.7.0.tgz", - "integrity": "sha512-j7A8/LBEQ+3rugMzPXoKYzyUPpw/0CBQCyvtTR7Lmu4olG4yRC/Tfkq79Mr3yuPs0SUitlO2HwGP3gitMJnRFw==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@conventional-changelog/git-client/-/git-client-3.1.2.tgz", + "integrity": "sha512-jZqwnJwf7nboIlAcw/mkOjVa6DexCcUOgT2oOQgkoi3z9vR8tGFkcMy2BFcYwjhL9sYcDDXkRQDayiDieCoW7A==", "dev": true, "license": "MIT", "dependencies": { - "@simple-libs/child-process-utils": "^1.0.0", - "@simple-libs/stream-utils": "^1.2.0", + "@simple-libs/child-process-utils": "^2.0.0", + "@simple-libs/stream-utils": "^2.0.0", "semver": "^7.5.2" }, "engines": { - "node": ">=18" + "node": ">=22" }, "peerDependencies": { - "conventional-commits-filter": "^5.0.0", - "conventional-commits-parser": "^6.4.0" + "conventional-commits-filter": "^6.0.1", + "conventional-commits-parser": "^7.1.2" }, "peerDependenciesMeta": { "conventional-commits-filter": { @@ -1011,9 +922,9 @@ } }, "node_modules/@conventional-changelog/template": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@conventional-changelog/template/-/template-1.2.0.tgz", - "integrity": "sha512-12qHxvlKjHmP0PQ+17EREgC7lWyLwbph1RKcZQZ7k7ZWGmrxfxC9gadHGfvzr0g0u8BhiBGg3tks93txodlyRQ==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@conventional-changelog/template/-/template-1.4.0.tgz", + "integrity": "sha512-aalGyl7dbB5PArRebDIX43ZvBlXrYm9uWzGJ26t+4SzJVPsOuvfILGGbw5X4yX7i50YEmJ8zvbiWnqH/AAnZqg==", "dev": true, "license": "MIT", "engines": { @@ -1088,15 +999,15 @@ } }, "node_modules/@inquirer/checkbox": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-5.2.1.tgz", - "integrity": "sha512-b6xmA/VlTe0ZgDQHDui+Nav470u7u49nRd8/iuhOcQPO9Ch7lGuogydhi2VOmNlZ+zXcM8IcPuNSwQcdJaF/kw==", + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-5.2.3.tgz", + "integrity": "sha512-XEYX2WA8SBkLPczL6/yXPHLPCvDoptmh9v56Cy05BSV1Smk1vWy19bTC4qJBuIffw7+6l4CcaYYzGqG60RfW1g==", "license": "MIT", "dependencies": { "@inquirer/ansi": "^2.0.7", - "@inquirer/core": "^11.2.1", - "@inquirer/figures": "^2.0.7", - "@inquirer/type": "^4.0.7" + "@inquirer/core": "^12.0.1", + "@inquirer/figures": "^2.0.8", + "@inquirer/type": "^4.1.0" }, "engines": { "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" @@ -1111,13 +1022,13 @@ } }, "node_modules/@inquirer/confirm": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-6.1.1.tgz", - "integrity": "sha512-eb8DBZcz/2qHWQda4rk2JiQk5h9QV/cVHi1yjt0f69WFZMRFn0sJTye3EAP8icut8UDMjQPsaH5KbcOogefrFQ==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-6.3.0.tgz", + "integrity": "sha512-pZHXJImFtERmSNMBHcjwuz8Ck5vEFEYNUZnwbb8aJpjHv/TwGuFErNxF2Hp8+V+pNJs2EYPMlyWscvFEqO9jOQ==", "license": "MIT", "dependencies": { - "@inquirer/core": "^11.2.1", - "@inquirer/type": "^4.0.7" + "@inquirer/core": "^12.0.1", + "@inquirer/type": "^4.1.0" }, "engines": { "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" @@ -1132,14 +1043,14 @@ } }, "node_modules/@inquirer/core": { - "version": "11.2.1", - "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-11.2.1.tgz", - "integrity": "sha512-Qd6GJT1yVyrZZCfN8W2qKF5ApmqryXRhRKCuip8h01x2w/esJQ2XIYc6f9abMIHgKQdBfFTSOdbHRLAhuM09UA==", + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-12.0.1.tgz", + "integrity": "sha512-JMD5Jy/ScL5TZE18m83Nw25HjqGFLoWXwnEkW7IdwwAhZpB9Bus55/WU7zn3UqR1MOCjjTQOIYpiD4vjWA3LPw==", "license": "MIT", "dependencies": { "@inquirer/ansi": "^2.0.7", - "@inquirer/figures": "^2.0.7", - "@inquirer/type": "^4.0.7", + "@inquirer/figures": "^2.0.8", + "@inquirer/type": "^4.1.0", "cli-width": "^4.1.0", "fast-wrap-ansi": "^0.2.0", "mute-stream": "^3.0.0", @@ -1157,27 +1068,15 @@ } } }, - "node_modules/@inquirer/core/node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/@inquirer/editor": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-5.2.2.tgz", - "integrity": "sha512-ZRVd/oD+sYsUd5zVm0NflqEzlqfYCyHNsqkHl2oWXEUHs12tCbcSFi+wVFEvD8+LGRaMUsVrE7qeo6lSG/S1Vg==", + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-5.3.1.tgz", + "integrity": "sha512-y43COoyVUjPWIobn2Qep/uI1drPS78aaZZZ9kVi94Tyu/GuW2N8d8Q4rifJXGAXCEAXCPTTMjD8gC1HyvM5ukA==", "license": "MIT", "dependencies": { - "@inquirer/core": "^11.2.1", - "@inquirer/external-editor": "^3.0.3", - "@inquirer/type": "^4.0.7" + "@inquirer/core": "^12.0.1", + "@inquirer/external-editor": "^3.0.4", + "@inquirer/type": "^4.1.0" }, "engines": { "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" @@ -1192,13 +1091,13 @@ } }, "node_modules/@inquirer/expand": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-5.1.1.tgz", - "integrity": "sha512-YmQpenjbFSHAK3sOd44puHh3V1KXXr+JiNpUztoSQ4drLh2rTVzTap/YtlAVu/5xavifIlBfNEzJ/neZJ1a/1g==", + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-5.1.3.tgz", + "integrity": "sha512-3NQJiXNJ/aj9wiAsr7pECdp5Qe9J0X9YUJCKsaFXS+ddOxfL6J4AIl3w3T4Gq3kK0WsQY5GMoDokK5X94m6lHw==", "license": "MIT", "dependencies": { - "@inquirer/core": "^11.2.1", - "@inquirer/type": "^4.0.7" + "@inquirer/core": "^12.0.1", + "@inquirer/type": "^4.1.0" }, "engines": { "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" @@ -1213,9 +1112,9 @@ } }, "node_modules/@inquirer/external-editor": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-3.0.3.tgz", - "integrity": "sha512-6thf5I8q7lZwzGLAxPaaGEREEkZ3nyePPDQ1oyobblxmEE8mqTLguScP7pDjUTAibiyb4hfXl+qjUEJ+di/aNA==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-3.0.4.tgz", + "integrity": "sha512-tZbbaK2ovq6vlrRBNQvjrypmrED/p5x2ncIHQ79cD55tei3dD96v5glMMA+6tiq7K104i/25DVYKWVPJuV6ptA==", "license": "MIT", "dependencies": { "chardet": "^2.1.1", @@ -1234,22 +1133,22 @@ } }, "node_modules/@inquirer/figures": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.7.tgz", - "integrity": "sha512-aJ8TBPOGB6f/2qziPfElISTCEd5XOYTFckA2SGjhNmiKzfK/u4ot3v0DUzGVdUnKjN10EqnnEPck36BkyfLnJw==", + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.8.tgz", + "integrity": "sha512-tApbon79GM9ry56ja/Ud3SY2CL4TQsao9fIwDQbgTeNY55025GdMzQ2+UdegV/lx51VNGUB59M0v0nMpybYY4Q==", "license": "MIT", "engines": { "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" } }, "node_modules/@inquirer/input": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-5.1.2.tgz", - "integrity": "sha512-9K/DDBSQpOyZSkt6sOVP9Vo0TR7atX2kuILsUu0x3wVcVbe97lJwIJKMLdMw25tDYuXl/qp6erT0Xs1rfmcfZg==", + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-5.1.4.tgz", + "integrity": "sha512-3xQkQrOvgOzpSN2ciTVdRDlg1FWMCA8l+0KfB6SNlILoTCGzJTzO/gc0Rwjcb3usuGyKdaGtI6OiyMdeMeLWkg==", "license": "MIT", "dependencies": { - "@inquirer/core": "^11.2.1", - "@inquirer/type": "^4.0.7" + "@inquirer/core": "^12.0.1", + "@inquirer/type": "^4.1.0" }, "engines": { "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" @@ -1264,13 +1163,13 @@ } }, "node_modules/@inquirer/number": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-4.1.1.tgz", - "integrity": "sha512-XF4IXAbPnGPgw0wsbC/i2tPcyfdZgDpUlhsqU0SfT4IRIGWha6Xm9VRgN5yYxJq+jnyXlfXI/nQ3ulfk0iEICA==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-4.2.1.tgz", + "integrity": "sha512-5KaqwZNLRpUuWcoCrYghPP9TMaXL5v2Sk4xqePM7RCVegcJStoXdWibio60YIC1bec+z1fCyb67N6XPJIkZtGA==", "license": "MIT", "dependencies": { - "@inquirer/core": "^11.2.1", - "@inquirer/type": "^4.0.7" + "@inquirer/core": "^12.0.1", + "@inquirer/type": "^4.1.0" }, "engines": { "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" @@ -1285,14 +1184,14 @@ } }, "node_modules/@inquirer/password": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-5.1.1.tgz", - "integrity": "sha512-3XBfF7DAsp5qeDsvN5Rd1HmbNokVvEQoUM0QLrRcybC9nX96w3Pbmu7qUsb3IT3J3jBvs2+mTXaKHOUsgHMLzg==", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-5.2.0.tgz", + "integrity": "sha512-CvVcW09emkBESEOW+4R8CjLNkP3fB3XrjeL8CDvfpjgrJN+V9oerXmJAXXM3l+4xqYPD5Yaujzy/Ph0PLOdDuA==", "license": "MIT", "dependencies": { "@inquirer/ansi": "^2.0.7", - "@inquirer/core": "^11.2.1", - "@inquirer/type": "^4.0.7" + "@inquirer/core": "^12.0.1", + "@inquirer/type": "^4.1.0" }, "engines": { "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" @@ -1307,21 +1206,21 @@ } }, "node_modules/@inquirer/prompts": { - "version": "8.5.2", - "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-8.5.2.tgz", - "integrity": "sha512-IYR/3C/paEVVQYQvdDlFZVjRCJVYHHON0XXMH91KO9GSxs0TdKYWlUdvfQl2EfAHDxUaN3IBffkE/BDTh5nJ6g==", + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-8.7.0.tgz", + "integrity": "sha512-yQwBMYvpJ6jqrXtKiOwRD5XezjJoyt3VQvIyjsr5Arqb519nfIohOQymWVJ8/vEgg8xtZerrCsqlSaqt/LPC9A==", "license": "MIT", "dependencies": { - "@inquirer/checkbox": "^5.2.1", - "@inquirer/confirm": "^6.1.1", - "@inquirer/editor": "^5.2.2", - "@inquirer/expand": "^5.1.1", - "@inquirer/input": "^5.1.2", - "@inquirer/number": "^4.1.1", - "@inquirer/password": "^5.1.1", - "@inquirer/rawlist": "^5.3.1", - "@inquirer/search": "^4.2.1", - "@inquirer/select": "^5.2.1" + "@inquirer/checkbox": "^5.2.3", + "@inquirer/confirm": "^6.3.0", + "@inquirer/editor": "^5.3.1", + "@inquirer/expand": "^5.1.3", + "@inquirer/input": "^5.1.4", + "@inquirer/number": "^4.2.1", + "@inquirer/password": "^5.2.0", + "@inquirer/rawlist": "^5.3.3", + "@inquirer/search": "^4.3.1", + "@inquirer/select": "^5.2.3" }, "engines": { "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" @@ -1336,13 +1235,13 @@ } }, "node_modules/@inquirer/rawlist": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-5.3.1.tgz", - "integrity": "sha512-QqdTqQddL3qPX/PPrjobpsO25NZ4dWXgTLenrR445L2ptLEYE6Z+PD5c5CNDJNx4ugRgELAIpSIJxZaO2jJ2Og==", + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-5.3.3.tgz", + "integrity": "sha512-Mu7WrtmDLaXBDEyrRLS70SZgX9ZSm4Up1w0ZxiH8C1OOp9oaVCn2k8q3QGgmlnhsKYUhuaU3zFWhAP6wxkVIMA==", "license": "MIT", "dependencies": { - "@inquirer/core": "^11.2.1", - "@inquirer/type": "^4.0.7" + "@inquirer/core": "^12.0.1", + "@inquirer/type": "^4.1.0" }, "engines": { "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" @@ -1357,14 +1256,14 @@ } }, "node_modules/@inquirer/search": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-4.2.1.tgz", - "integrity": "sha512-xJj8QWKRSrfKoBIITLZK61dD3zwo0Rz11fgDImku30/Oe81zMdIdGgrLY2h6RkJ+KZ/GhNYIRMKnH/62qBTA5g==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-4.3.1.tgz", + "integrity": "sha512-0VWOvsHWI0rPj6CG70MoP4oXNCB6adcyN8bVFZXnh11eLDdPIK2f2XCmva78acPPDfJisfab7qNakwBh7hdBXw==", "license": "MIT", "dependencies": { - "@inquirer/core": "^11.2.1", - "@inquirer/figures": "^2.0.7", - "@inquirer/type": "^4.0.7" + "@inquirer/core": "^12.0.1", + "@inquirer/figures": "^2.0.8", + "@inquirer/type": "^4.1.0" }, "engines": { "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" @@ -1379,15 +1278,15 @@ } }, "node_modules/@inquirer/select": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-5.2.1.tgz", - "integrity": "sha512-FlDndEUww8m7BfukO2nJa25vhD+H5jxxCv4oGioKqzyWz3nPHhhw4LKdYRSlXuAx7DsdWia7iyaBPKKS95Evfw==", + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-5.2.3.tgz", + "integrity": "sha512-KuRTodDa6xBXX2noIpjuitpX/QT7Sfav7dIZ/OfUY54Hxg95nrGoshSzxx6Ey7qqLbImKdiGkSDt7KjPXjQgmA==", "license": "MIT", "dependencies": { "@inquirer/ansi": "^2.0.7", - "@inquirer/core": "^11.2.1", - "@inquirer/figures": "^2.0.7", - "@inquirer/type": "^4.0.7" + "@inquirer/core": "^12.0.1", + "@inquirer/figures": "^2.0.8", + "@inquirer/type": "^4.1.0" }, "engines": { "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" @@ -1402,9 +1301,9 @@ } }, "node_modules/@inquirer/type": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.0.7.tgz", - "integrity": "sha512-t28inv14nMQ1PhKpsJPY+kEs/c00qzeCOS2gTNRyTjG5d6qsVA2fItxW4hkvGZ5lvanGLdtCzVIx5dwdRpN1+g==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.1.0.tgz", + "integrity": "sha512-FMiJpuHUG3Dk0ex+UIXkre7i+i4OcwHWk9YdcVtZHFwb/r2rnrU2ipTCNAB7A+QOP0ryzIcqOfy76fRyyvOEAw==", "license": "MIT", "engines": { "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" @@ -1546,9 +1445,9 @@ } }, "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { - "version": "3.15.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.0.tgz", - "integrity": "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==", + "version": "3.15.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.2.tgz", + "integrity": "sha512-6EuL879VkRA+1Cz578mKMiKvjPNEuk6+r1JaFzoSWejZmtf7xWbIyw1e3KkxlkzTIt9Taw6JBhEppG7utc1P+w==", "dev": true, "license": "MIT", "dependencies": { @@ -1560,9 +1459,9 @@ } }, "node_modules/@istanbuljs/schema": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", - "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", + "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", "dev": true, "license": "MIT", "engines": { @@ -1570,17 +1469,17 @@ } }, "node_modules/@jest/console": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/@jest/console/-/console-30.4.1.tgz", - "integrity": "sha512-v3bhyxUh9Hgmo5p6hAOXe14/R3ZxZDOsvHleh4B07z3m/x4/ngPUXEm9XwK4sF4u+f+P2ORb0Ge+MgpaqRMVDA==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-30.5.0.tgz", + "integrity": "sha512-BI1DpOedrJqbrYVi9yNhDWGjqphR/+gsM4STmg2+VaeXm7851hvpBDyKOZGMXATTrGEnFuEseQvLCtrrRmG0GQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.4.1", + "@jest/types": "30.5.0", "@types/node": "*", "chalk": "^4.1.2", - "jest-message-util": "30.4.1", - "jest-util": "30.4.1", + "jest-message-util": "30.5.0", + "jest-util": "30.5.0", "slash": "^3.0.0" }, "engines": { @@ -1621,18 +1520,18 @@ } }, "node_modules/@jest/core": { - "version": "30.4.2", - "resolved": "https://registry.npmjs.org/@jest/core/-/core-30.4.2.tgz", - "integrity": "sha512-TZJA6cPJUFxoWhxaLo8t0VX/MZX2wPWr0uIDvLSHIvN4gu9h02vSzqI2kBADG1ExqQlC+cY09xKMSreivvrChQ==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-30.5.0.tgz", + "integrity": "sha512-DLjRME+NY//j+UDTSfWnjoP0srrdR3DrJRy7yFZktGIwzpN2iVy2vMo0jziZ5c2Ij7bOwlJRXKVWtxZusazOJg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/console": "30.4.1", - "@jest/pattern": "30.4.0", - "@jest/reporters": "30.4.1", - "@jest/test-result": "30.4.1", - "@jest/transform": "30.4.1", - "@jest/types": "30.4.1", + "@jest/console": "30.5.0", + "@jest/pattern": "30.5.0", + "@jest/reporters": "30.5.0", + "@jest/test-result": "30.5.0", + "@jest/transform": "30.5.0", + "@jest/types": "30.5.0", "@types/node": "*", "ansi-escapes": "^4.3.2", "chalk": "^4.1.2", @@ -1640,20 +1539,20 @@ "exit-x": "^0.2.2", "fast-json-stable-stringify": "^2.1.0", "graceful-fs": "^4.2.11", - "jest-changed-files": "30.4.1", - "jest-config": "30.4.2", - "jest-haste-map": "30.4.1", - "jest-message-util": "30.4.1", - "jest-regex-util": "30.4.0", - "jest-resolve": "30.4.1", - "jest-resolve-dependencies": "30.4.2", - "jest-runner": "30.4.2", - "jest-runtime": "30.4.2", - "jest-snapshot": "30.4.1", - "jest-util": "30.4.1", - "jest-validate": "30.4.1", - "jest-watcher": "30.4.1", - "pretty-format": "30.4.1", + "jest-changed-files": "30.5.0", + "jest-config": "30.5.0", + "jest-haste-map": "30.5.0", + "jest-message-util": "30.5.0", + "jest-regex-util": "30.5.0", + "jest-resolve": "30.5.0", + "jest-resolve-dependencies": "30.5.0", + "jest-runner": "30.5.0", + "jest-runtime": "30.5.0", + "jest-snapshot": "30.5.0", + "jest-util": "30.5.0", + "jest-validate": "30.5.0", + "jest-watcher": "30.5.0", + "pretty-format": "30.5.0", "slash": "^3.0.0" }, "engines": { @@ -1702,9 +1601,9 @@ } }, "node_modules/@jest/diff-sequences": { - "version": "30.4.0", - "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.4.0.tgz", - "integrity": "sha512-zOpzlfUs45l6u7jm39qr87JCHUDsaeCtvL+kQe/Vn9jSnRB4/5IPXISm0h9I1vZW/o00Kn4UTJ2MOlhnUGwv3g==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.5.0.tgz", + "integrity": "sha512-OsqBjHXCn8cadasoAZBP6nWYvMsRhpMzGXTpxJ5aO04NlbdhIz+FVe3q49l0AwVhsz/cEmIpBes6gAFl1/dWQg==", "dev": true, "license": "MIT", "engines": { @@ -1712,70 +1611,70 @@ } }, "node_modules/@jest/environment": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.4.1.tgz", - "integrity": "sha512-AK9yNRqgKxiabqMoe4oW+3/TSSeV8vkdC7BGaxZdU0AFXfOpofTLqdru2GXKZghP3sdgwE9XXpnVwfZ8JnFV4w==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.5.0.tgz", + "integrity": "sha512-HUaqexIauIh69IQ4NTuPDEUCB8g8T4TOPSIzQOS18mwI/KEHKQk1j013K2o6ra031szZE2t5jGmVx3xbzdjgKA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/fake-timers": "30.4.1", - "@jest/types": "30.4.1", + "@jest/fake-timers": "30.5.0", + "@jest/types": "30.5.0", "@types/node": "*", - "jest-mock": "30.4.1" + "jest-mock": "30.5.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/expect": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-30.4.1.tgz", - "integrity": "sha512-ginrj6TMgh2GshLUGCjO94Ptx9HhdZA/I6A9iUfyeLKFtdAjnKzHDgzgP9HYQgbxM1lbXScQ2eUBz2lGeVDPWA==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-30.5.0.tgz", + "integrity": "sha512-jEmgmgJEobJ3zEhDOGp1VAJ6JkoVelpS8uZ1ae1Ul/5lP78UKJKmmU0lciJwd6JdnqOXHaaS/QCKwbf1dHI9MA==", "dev": true, "license": "MIT", "dependencies": { - "expect": "30.4.1", - "jest-snapshot": "30.4.1" + "expect": "30.5.0", + "jest-snapshot": "30.5.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/expect-utils": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.4.1.tgz", - "integrity": "sha512-ZBn5CglH8fBsQsvs4VWNzD4aWfUYks+IdOOQU3MEK71ol/BcVm+P+rtb1KpiFBpSWSCE27uOahyyf1vfqOVbcQ==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.5.0.tgz", + "integrity": "sha512-5j0ztPxSy3McUJihjkDdCyCfjvT2hxykFTWsgEBZKB8qsw9ALdCiGTpTRH5gnf/d+qI4SflYUJ0dWNbzjQCWbA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/get-type": "30.1.0" + "@jest/get-type": "30.5.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/fake-timers": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.4.1.tgz", - "integrity": "sha512-iW5umdmfPeWzehrVhugFQZqCchSCud5S1l2YT0O9ZhjRR0ExclANDZkiSBwzqtnlOn0J1JXvO+HZ6rkuyOVOgQ==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.5.0.tgz", + "integrity": "sha512-sg8xIbYwe5GdB/vT3/0qrDIpO7Ov9mazHi++M95uynmDKEZ70G1r169AWct73H07VrTZhrz1SJEfLtjYv8tE3A==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.4.1", + "@jest/types": "30.5.0", "@sinonjs/fake-timers": "^15.4.0", "@types/node": "*", - "jest-message-util": "30.4.1", - "jest-mock": "30.4.1", - "jest-util": "30.4.1" + "jest-message-util": "30.5.0", + "jest-mock": "30.5.0", + "jest-util": "30.5.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/get-type": { - "version": "30.1.0", - "resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.1.0.tgz", - "integrity": "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.5.0.tgz", + "integrity": "sha512-9/2VUPitAjmBzbvDvqrxmvB7BzWsBW0WmkkojX1ODuxX1NLGxx9gfaZpHB0z8DtJ9uhGNmZG/VXBhf8uO0OV8Q==", "dev": true, "license": "MIT", "engines": { @@ -1783,62 +1682,78 @@ } }, "node_modules/@jest/globals": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-30.4.1.tgz", - "integrity": "sha512-ZbuY4cmXC8DkxYjfvT2DbcHWL2T6vmsMhXCDcmTB2T0y0gaezBI77ufq5ZAIdcRkYZ7NEQEDg1xFeKbxUJ5v5Q==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-30.5.0.tgz", + "integrity": "sha512-h7eJx534czwL8lQMYB0hwLT4/HquO8EX/RtYL7RNUHyUyWWVciYjoudN4Ns5JmNvn2/jh0Vm9UstZjEzJJ5EsQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "30.4.1", - "@jest/expect": "30.4.1", - "@jest/types": "30.4.1", - "jest-mock": "30.4.1" + "@jest/environment": "30.5.0", + "@jest/expect": "30.5.0", + "@jest/types": "30.5.0", + "jest-mock": "30.5.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/pattern": { - "version": "30.4.0", - "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.4.0.tgz", - "integrity": "sha512-RAWn3+f9u8BsHijKJ71uHcFp6vmyEt6VvoWXkl6hKF3qVIuWNmudVjg12DlBPGup/frIl5UcUlH5HfEuvHpEXg==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.5.0.tgz", + "integrity": "sha512-HdNQYSdRTEBNrginaqzQtTjG0HRMfrra/z6Ok7uL3S87vSlarIVohEsJsSj5edu3MiHoHjAkvPROz5ZjoKai+w==", "dev": true, "license": "MIT", "dependencies": { "@types/node": "*", - "jest-regex-util": "30.4.0" + "jest-regex-util": "30.5.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, + "node_modules/@jest/react-is-18": { + "name": "react-is", + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jest/react-is-19": { + "name": "react-is", + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.8.tgz", + "integrity": "sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@jest/reporters": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-30.4.1.tgz", - "integrity": "sha512-/SnkPCzEQpUaBH81kjdEdDdo2WZl5hxw+BmLDGWjRkm8o7XlhjwsU36cqwe5PGBE5WYpBvDzRSdXx9rbGuJtNA==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-30.5.0.tgz", + "integrity": "sha512-FEAuusWm+PUOn9ydjaHhpOpyPmS6IbGE04HaKTuUI4zd8eqYZiXiNLoCsdCog/l2XnNj53E9pFy64UltcvfJKg==", "dev": true, "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^0.2.3", - "@jest/console": "30.4.1", - "@jest/test-result": "30.4.1", - "@jest/transform": "30.4.1", - "@jest/types": "30.4.1", - "@jridgewell/trace-mapping": "^0.3.25", + "@jest/console": "30.5.0", + "@jest/test-result": "30.5.0", + "@jest/transform": "30.5.0", + "@jest/types": "30.5.0", + "@jridgewell/trace-mapping": "^0.3.31", "@types/node": "*", "chalk": "^4.1.2", "collect-v8-coverage": "^1.0.2", "exit-x": "^0.2.2", - "glob": "^10.5.0", + "glob": "^13.0.6", "graceful-fs": "^4.2.11", "istanbul-lib-coverage": "^3.0.0", "istanbul-lib-instrument": "^6.0.0", "istanbul-lib-report": "^3.0.0", "istanbul-lib-source-maps": "^5.0.0", "istanbul-reports": "^3.1.3", - "jest-message-util": "30.4.1", - "jest-util": "30.4.1", - "jest-worker": "30.4.1", + "jest-message-util": "30.5.0", + "jest-util": "30.5.0", + "jest-worker": "30.5.0", "slash": "^3.0.0", "string-length": "^4.0.2", "v8-to-istanbul": "^9.0.1" @@ -1889,9 +1804,9 @@ } }, "node_modules/@jest/schemas": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.4.1.tgz", - "integrity": "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.5.0.tgz", + "integrity": "sha512-/hunigyNpc4RCjC0VaW3f5RCUZVM2+WQ65qP7z083Gmvac7or2LI50XVNOtE4YPgBpV0yxYiAgorAPGniCoJmg==", "dev": true, "license": "MIT", "dependencies": { @@ -1902,13 +1817,13 @@ } }, "node_modules/@jest/snapshot-utils": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/@jest/snapshot-utils/-/snapshot-utils-30.4.1.tgz", - "integrity": "sha512-ObY4ljvQ95mt6iwKtVLetR/4yXiAgl3H4nJxhztr0MTjrN97TwDYrnCp/kF60Ec9HdhkWTHSu+Hg05aXfngpOA==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/snapshot-utils/-/snapshot-utils-30.5.0.tgz", + "integrity": "sha512-iWQtIsi2dRsO2oWzVceOeynuRJiYTW8gsDVp5wFQ02ipHluQsNgBpasWSHiawVxukIlsGLdCtCSbIEK7fPtpvQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.4.1", + "@jest/types": "30.5.0", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "natural-compare": "^1.4.0" @@ -1951,14 +1866,15 @@ } }, "node_modules/@jest/source-map": { - "version": "30.0.1", - "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-30.0.1.tgz", - "integrity": "sha512-MIRWMUUR3sdbP36oyNyhbThLHyJ2eEDClPCiHVbrYAe5g3CHRArIVpBw7cdSB5fr+ofSfIb2Tnsw8iEHL0PYQg==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-30.5.0.tgz", + "integrity": "sha512-xWpTJP9D0bDFGbPGT8XuWSwwha/iHADyyKzUnMx4UbdgnHugxrDaQFO4RZ8x4ZsFzRP6pNii8uvlgKCDxCIuDg==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/trace-mapping": "^0.3.25", + "@jridgewell/trace-mapping": "^0.3.31", "callsites": "^3.1.0", + "convert-source-map": "^2.0.0", "graceful-fs": "^4.2.11" }, "engines": { @@ -1966,14 +1882,14 @@ } }, "node_modules/@jest/test-result": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-30.4.1.tgz", - "integrity": "sha512-/ZG7pgEiOmmWkN9TplKbOu4id2N5lh7FHwRwlkgBVAzGdRH+OkkQ8wX/kIxg4zmd3ZQvAL1RwL2yWsvNYYECTw==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-30.5.0.tgz", + "integrity": "sha512-9IlPqUzUMkVDmoDqSSrVVLroVotgN3hTUPWPwq24XWXhh1Zpg915RXZ5pgRJ/7j4YE/kng5nqjSn4p3S68SZJA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/console": "30.4.1", - "@jest/types": "30.4.1", + "@jest/console": "30.5.0", + "@jest/types": "30.5.0", "@types/istanbul-lib-coverage": "^2.0.6", "collect-v8-coverage": "^1.0.2" }, @@ -1982,15 +1898,15 @@ } }, "node_modules/@jest/test-sequencer": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-30.4.1.tgz", - "integrity": "sha512-PeYE+4td5rKjoRPxztObrXU+H8hsjZfxKMXOcmrr34JerSyB/ROOxbbicz8B7A5j9R9VayDnVPvBmedqCsFCdw==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-30.5.0.tgz", + "integrity": "sha512-TXlvSDIVv482b83hD8A8WtxDJEmGF47f60W6jRXOQL0Isfs3hKtk4rZIm9R/jLzAibg8+c56y+Q00BQs8R7Xcg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/test-result": "30.4.1", + "@jest/test-result": "30.5.0", "graceful-fs": "^4.2.11", - "jest-haste-map": "30.4.1", + "jest-haste-map": "30.5.0", "slash": "^3.0.0" }, "engines": { @@ -1998,23 +1914,23 @@ } }, "node_modules/@jest/transform": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-30.4.1.tgz", - "integrity": "sha512-Wz0LyktlTvRefoymh+n64hQ84KNXsRGcwdoZ8CSa0Ea+fgYcHZlnk+hDP7v2MS7il2bQ5uTEIxf4/NNfhMN4KQ==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-30.5.0.tgz", + "integrity": "sha512-n1cYhoByyULEIXi64wbT4Lq91qeT1E6bwpM//sprFXhw955qaiHTdAmy1c1rNFGB6fCf1J+nxDUSf3RGwgZP5A==", "dev": true, "license": "MIT", "dependencies": { "@babel/core": "^7.27.4", - "@jest/types": "30.4.1", - "@jridgewell/trace-mapping": "^0.3.25", - "babel-plugin-istanbul": "^7.0.1", + "@jest/types": "30.5.0", + "@jridgewell/trace-mapping": "^0.3.31", + "babel-plugin-istanbul": "^8.0.0", "chalk": "^4.1.2", "convert-source-map": "^2.0.0", "fast-json-stable-stringify": "^2.1.0", "graceful-fs": "^4.2.11", - "jest-haste-map": "30.4.1", - "jest-regex-util": "30.4.0", - "jest-util": "30.4.1", + "jest-haste-map": "30.5.0", + "jest-regex-util": "30.5.0", + "jest-util": "30.5.0", "pirates": "^4.0.7", "slash": "^3.0.0", "write-file-atomic": "^5.0.1" @@ -2057,14 +1973,14 @@ } }, "node_modules/@jest/types": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.4.1.tgz", - "integrity": "sha512-f1x/vJXIfjOlEmejYpbkbgw1gOqpPECwMvMEtBqe47j7H2Hg8h8w3o3ikhSXq3MI15kg+oQ0exWO0uCtTNJLoQ==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.5.0.tgz", + "integrity": "sha512-s1N+79S4Yp9ZgklCauZXi+YPJdCdtStNYQT32stuD6EeQaIBGHoUfyj2P0YWy8RmuQfaJboO+ulxEvEheR/POQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/pattern": "30.4.0", - "@jest/schemas": "30.4.1", + "@jest/pattern": "30.5.0", + "@jest/schemas": "30.5.0", "@types/istanbul-lib-coverage": "^2.0.6", "@types/istanbul-reports": "^3.0.4", "@types/node": "*", @@ -2141,9 +2057,9 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", "dev": true, "license": "MIT" }, @@ -2181,22 +2097,25 @@ "license": "MIT" }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", - "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.3.tgz", + "integrity": "sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@tybys/wasm-util": "^0.10.1" + "@tybys/wasm-util": "^0.10.3" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" }, "funding": { "type": "github", "url": "https://github.com/sponsors/Brooooooklyn" }, "peerDependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1" + "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", + "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" } }, "node_modules/@octokit/auth-token": { @@ -2210,17 +2129,17 @@ } }, "node_modules/@octokit/core": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/@octokit/core/-/core-7.0.6.tgz", - "integrity": "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==", + "version": "7.0.7", + "resolved": "https://registry.npmjs.org/@octokit/core/-/core-7.0.7.tgz", + "integrity": "sha512-DcB0M3KFgr9ECI328lhBMVsyFT2DnmNucSBTqEN3exyNKUzkkpUSCHmTRcunF41Eou2TIQKW4seewri8ON9bSA==", "dev": true, "license": "MIT", "dependencies": { "@octokit/auth-token": "^6.0.0", - "@octokit/graphql": "^9.0.3", - "@octokit/request": "^10.0.6", - "@octokit/request-error": "^7.0.2", - "@octokit/types": "^16.0.0", + "@octokit/graphql": "^9.0.4", + "@octokit/request": "^10.0.13", + "@octokit/request-error": "^7.1.1", + "@octokit/types": "^17.0.0", "before-after-hook": "^4.0.0", "universal-user-agent": "^7.0.0" }, @@ -2229,28 +2148,45 @@ } }, "node_modules/@octokit/endpoint": { - "version": "11.0.3", - "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-11.0.3.tgz", - "integrity": "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag==", + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-11.0.5.tgz", + "integrity": "sha512-iXa654H3yFafF/ieHkukfbgWo2rmXD2ceD0ZOtrPhw1bc3FDch1d9N/TNs0FQ1/cIbwb7kspUX8jzIs8nzb9DQ==", "dev": true, "license": "MIT", "dependencies": { - "@octokit/types": "^16.0.0", + "@octokit/types": "^18.0.0", "universal-user-agent": "^7.0.2" }, "engines": { "node": ">= 20" } }, + "node_modules/@octokit/endpoint/node_modules/@octokit/openapi-types": { + "version": "29.0.1", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-29.0.1.tgz", + "integrity": "sha512-9qWOMFNxxLokERcms42rU0PTLqQmVs7g5E41TI4mCOxmpFayD1rfC7XxOL55cG9MBZLFlC31BrR37myMKardwg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@octokit/endpoint/node_modules/@octokit/types": { + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-18.0.0.tgz", + "integrity": "sha512-l6bAF43PNxkJp6g+W4PjoUSSkxHomXw2nOum5CTftJz1NlV3vu93NImgOYtLf6CbBUb5j+fiuzW0PPQ5JTSvZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^29.0.1" + } + }, "node_modules/@octokit/graphql": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-9.0.3.tgz", - "integrity": "sha512-grAEuupr/C1rALFnXTv6ZQhFuL1D8G5y8CN04RgrO4FIPMrtm+mcZzFG7dcBm+nq+1ppNixu+Jd78aeJOYxlGA==", + "version": "9.0.4", + "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-9.0.4.tgz", + "integrity": "sha512-5s15CCiY8XXQ+FG+b1YQcl6Z2FA++nwAz/tg2VUrTmnMncP+2nnGUEYANImdnxsA2Fnq+Mbl7hDjUTw7cFAwcg==", "dev": true, "license": "MIT", "dependencies": { - "@octokit/request": "^10.0.6", - "@octokit/types": "^16.0.0", + "@octokit/request": "^10.0.13", + "@octokit/types": "^17.0.0", "universal-user-agent": "^7.0.0" }, "engines": { @@ -2258,9 +2194,9 @@ } }, "node_modules/@octokit/openapi-types": { - "version": "27.0.0", - "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-27.0.0.tgz", - "integrity": "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA==", + "version": "28.0.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-28.0.0.tgz", + "integrity": "sha512-0rFyLuyHvIj6uuZWuDslxkowFYdPXoNIkeAv4b27dzm2Tf4vGWXnPsMcxs7d65kLdMERgP3wc1AEPlqMz8e1cQ==", "dev": true, "license": "MIT" }, @@ -2280,15 +2216,32 @@ "@octokit/core": ">=6" } }, + "node_modules/@octokit/plugin-paginate-rest/node_modules/@octokit/openapi-types": { + "version": "27.0.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-27.0.0.tgz", + "integrity": "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@octokit/plugin-paginate-rest/node_modules/@octokit/types": { + "version": "16.0.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-16.0.0.tgz", + "integrity": "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^27.0.0" + } + }, "node_modules/@octokit/plugin-retry": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/@octokit/plugin-retry/-/plugin-retry-8.1.0.tgz", - "integrity": "sha512-O1FZgXeiGb2sowEr/hYTr6YunGdSAFWnr2fyW39Ah85H8O33ELASQxcvOFF5LE6Tjekcyu2ms4qAzJVhSaJxTw==", + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/@octokit/plugin-retry/-/plugin-retry-8.1.1.tgz", + "integrity": "sha512-VCVvZ/R1+u3WuiBWpNavZ0mY4aaJNAsENrpBP9aLSR2QyOpQgd7DhM5j4AW7z4MQpnJYgwBPf0XqPQoNBRdQwg==", "dev": true, "license": "MIT", "dependencies": { - "@octokit/request-error": "^7.0.2", - "@octokit/types": "^16.0.0", + "@octokit/request-error": "^7.1.1", + "@octokit/types": "^17.0.0", "bottleneck": "^2.15.3" }, "engines": { @@ -2299,13 +2252,13 @@ } }, "node_modules/@octokit/plugin-throttling": { - "version": "11.0.3", - "resolved": "https://registry.npmjs.org/@octokit/plugin-throttling/-/plugin-throttling-11.0.3.tgz", - "integrity": "sha512-34eE0RkFCKycLl2D2kq7W+LovheM/ex3AwZCYN8udpi6bxsyjZidb2McXs69hZhLmJlDqTSP8cH+jSRpiaijBg==", + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/@octokit/plugin-throttling/-/plugin-throttling-11.0.5.tgz", + "integrity": "sha512-LIdrkrUv+DWbKeg/49rGuFJ3SU0d3hUS+B4MhNZLepBoNUFXms8Ic9edJjrlx+zycqJHjrMRudVpVb/bAXM2Lw==", "dev": true, "license": "MIT", "dependencies": { - "@octokit/types": "^16.0.0", + "@octokit/types": "^17.0.0", "bottleneck": "^2.15.3" }, "engines": { @@ -2316,17 +2269,17 @@ } }, "node_modules/@octokit/request": { - "version": "10.0.8", - "resolved": "https://registry.npmjs.org/@octokit/request/-/request-10.0.8.tgz", - "integrity": "sha512-SJZNwY9pur9Agf7l87ywFi14W+Hd9Jg6Ifivsd33+/bGUQIjNujdFiXII2/qSlN2ybqUHfp5xpekMEjIBTjlSw==", + "version": "10.0.15", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-10.0.15.tgz", + "integrity": "sha512-3CBg9aJ0hO9Pjyij8LbK/xYtEaPws9SW7xKz67daPNxQB1q5Y9OMA7DDOG0A6Hwf9ygGu3tvzusg0LXQ8/wAjA==", "dev": true, "license": "MIT", "dependencies": { "@octokit/endpoint": "^11.0.3", - "@octokit/request-error": "^7.0.2", - "@octokit/types": "^16.0.0", - "fast-content-type-parse": "^3.0.0", - "json-with-bigint": "^3.5.3", + "@octokit/request-error": "^7.1.1", + "@octokit/types": "^17.0.0", + "content-type": "^3.0.0", + "json-with-bigint": "^3.5.12", "universal-user-agent": "^7.0.2" }, "engines": { @@ -2334,56 +2287,343 @@ } }, "node_modules/@octokit/request-error": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-7.1.0.tgz", - "integrity": "sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-7.1.1.tgz", + "integrity": "sha512-+eaY7G2VVpSf2pc5Gn1+mph837V/d/TYTJAgWL9Tb0ogGYcpN3IlAVFgjL+Vv93F/sevrxkvsYCedtpLdcFLzA==", "dev": true, "license": "MIT", "dependencies": { - "@octokit/types": "^16.0.0" + "@octokit/types": "^17.0.0" }, "engines": { "node": ">= 20" } }, "node_modules/@octokit/types": { - "version": "16.0.0", - "resolved": "https://registry.npmjs.org/@octokit/types/-/types-16.0.0.tgz", - "integrity": "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==", + "version": "17.0.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-17.0.0.tgz", + "integrity": "sha512-ByP1v7YL5SMveFPP7+sj0/ZuWCOOg/Chs4NafOMpq6WNIM/hdGY0S7C0TCGDBWu1aGmOxmUIhMx3cO+IdwYZ1Q==", "dev": true, "license": "MIT", "dependencies": { - "@octokit/openapi-types": "^27.0.0" + "@octokit/openapi-types": "^28.0.0" } }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "node_modules/@parcel/watcher": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.6.0.tgz", + "integrity": "sha512-7FNeNl8NCE7aINx7WXiKQrPYZWC/hvrTsmk6zmxbI7LTXE7hVek/n8AfVgpe2y82zl3w0HvCHN0bVKMBoJcC0w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.3", + "is-glob": "^4.0.3", + "node-addon-api": "^7.0.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.6.0", + "@parcel/watcher-darwin-arm64": "2.6.0", + "@parcel/watcher-darwin-x64": "2.6.0", + "@parcel/watcher-freebsd-x64": "2.6.0", + "@parcel/watcher-linux-arm-glibc": "2.6.0", + "@parcel/watcher-linux-arm-musl": "2.6.0", + "@parcel/watcher-linux-arm64-glibc": "2.6.0", + "@parcel/watcher-linux-arm64-musl": "2.6.0", + "@parcel/watcher-linux-x64-glibc": "2.6.0", + "@parcel/watcher-linux-x64-musl": "2.6.0", + "@parcel/watcher-win32-arm64": "2.6.0", + "@parcel/watcher-win32-x64": "2.6.0" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.6.0.tgz", + "integrity": "sha512-trgpLSCKRC/huFjXX/Smh+0sWe4+YtKfktIToiMl59ghz7z+qkH6kMvNnUbLyRs9N11t8l4svSCs1+5B3rOAhA==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=14" + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/@pkgr/core": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.3.6.tgz", - "integrity": "sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==", + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.6.0.tgz", + "integrity": "sha512-Y3QV0gl7Q1zbfueunkWIERICbEojQFCgpyG7YqOGNFLsckXyI1xu9mAIUpKY9QBYzBtSkN8dBPwd3yiAO9ovMw==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": "^14.18.0 || >=16.0.0" + "node": ">= 10.0.0" }, "funding": { - "url": "https://opencollective.com/pkgr" + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/@pnpm/config.env-replace": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@pnpm/config.env-replace/-/config.env-replace-1.1.0.tgz", - "integrity": "sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w==", + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.6.0.tgz", + "integrity": "sha512-Ohv6OpzhUfKYD7Beb8kDvG0jbIxORCYY1JRdZnaBtnjjkJxgD7ZVL0nw2sCYd0yTMKTvz3nnTnOF3cDifK+kvw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.6.0.tgz", + "integrity": "sha512-5HmXvDgs8VK+74jF9y9/2FE3/OnlcKmc56tjmSrEuZjpSZOGL+fvAu+HKJBdPs9uwoP2hE6TlSUpXZ/C5jUFmQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.6.0.tgz", + "integrity": "sha512-Ps/hui3A+vMbjdqlqAowK2ZL8+BO8dBjxeWXj6npTBs3jx4wWmbPpaLuqwrQrSqIVMCnpWo238bJ1U37GhQOYg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.6.0.tgz", + "integrity": "sha512-9c6AUHgHoG+IY88MRIHupztQiQnrbqHYQjkM2btA+Bf/wQnQMuiD0Wfk1EVv3TlNT3x41uU71rn6E4xh/+zvkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.6.0.tgz", + "integrity": "sha512-yHRqS2owEXe6Hic9z6Mh1ECsCd+ODVOGvZDyciqRd21+v+o+DnXMOrw50DSpIG2sb8GPEaPPmfeCAWKPJdq46g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.6.0.tgz", + "integrity": "sha512-WhB2e/V7rqdHHWZusBSPuy5Ei8S6lSz6FE5TKKQz5h3a0O+C+mhY7vxU9b/stqvMb8beLnPY82ZrFTLKs+SrKA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.6.0.tgz", + "integrity": "sha512-ulGE6x6Oz6iAwg75T8YQSoguBWasniIbX+QWpaYPcCnDOpdWX3k+4xbEYPZVLxOuoJI+svJJPD3sEj8G7lrQ3A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.6.0.tgz", + "integrity": "sha512-tkBYKt7YQrjIJWYDnto2YgO8MRkjlMTSNoRHzsXinBqbLdeOM3L32wPZJvIZxqaLMfSlS/4sUjH/6STVP/XDLw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.6.0.tgz", + "integrity": "sha512-gIZAP23jaHjGWasY/TY6yL7NHFClf0Ga7FN+iINvk+KN94rhm94lYZhFsbYFNcA04/onvGD9kKmiJLJB2HbNwQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.6.0.tgz", + "integrity": "sha512-cA+/pXV2YkfxlIcXOQ5fSWqAzzPyD78/x5qbK/I0vUkrlYHA8TIz+MXjAbGouguKVSI4bOmkTSJ1/poVSsgt+A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@pkgr/core": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.3.6.tgz", + "integrity": "sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/pkgr" + } + }, + "node_modules/@pnpm/config.env-replace": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@pnpm/config.env-replace/-/config.env-replace-1.1.0.tgz", + "integrity": "sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w==", "dev": true, "license": "MIT", "engines": { @@ -2411,9 +2651,9 @@ "license": "ISC" }, "node_modules/@pnpm/npm-conf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@pnpm/npm-conf/-/npm-conf-3.0.2.tgz", - "integrity": "sha512-h104Kh26rR8tm+a3Qkc5S4VLYint3FE48as7+/5oCEcKR2idC/pF1G6AhIXKI+eHPJa/3J9i5z0Al47IeGHPkA==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@pnpm/npm-conf/-/npm-conf-3.0.3.tgz", + "integrity": "sha512-//0sR/cow/s4ICQaYoAobOl4aU8cjU6x/V24V7XkKotb9+O+3zySIYp146vpaobYHnxa4pZX8NkV54Z5AwbDKA==", "dev": true, "license": "MIT", "dependencies": { @@ -2455,6 +2695,59 @@ "semantic-release": ">=20.1.0" } }, + "node_modules/@semantic-release/commit-analyzer/node_modules/@simple-libs/stream-utils": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@simple-libs/stream-utils/-/stream-utils-1.2.0.tgz", + "integrity": "sha512-KxXvfapcixpz6rVEB6HPjOUZT22yN6v0vI0urQSk1L8MlEWPDFCZkhw2xmkyoTGYeFw7tWTZd7e3lVzRZRN/EA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://ko-fi.com/dangreen" + } + }, + "node_modules/@semantic-release/commit-analyzer/node_modules/conventional-changelog-angular": { + "version": "8.3.1", + "resolved": "https://registry.npmjs.org/conventional-changelog-angular/-/conventional-changelog-angular-8.3.1.tgz", + "integrity": "sha512-6gfI3otXK5Ph5DfCOI1dblr+kN3FAm5a97hYoQkqNZxOaYa5WKfXH+AnpsmS+iUH2mgVC2Cg2Qw9m5OKcmNrIg==", + "dev": true, + "license": "ISC", + "dependencies": { + "compare-func": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@semantic-release/commit-analyzer/node_modules/conventional-commits-filter": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/conventional-commits-filter/-/conventional-commits-filter-5.0.0.tgz", + "integrity": "sha512-tQMagCOC59EVgNZcC5zl7XqO30Wki9i9J3acbUvkaosCT6JX3EeFwJD7Qqp4MCikRnzS18WXV3BLIQ66ytu6+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@semantic-release/commit-analyzer/node_modules/conventional-commits-parser": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/conventional-commits-parser/-/conventional-commits-parser-6.4.0.tgz", + "integrity": "sha512-tvRg7FIBNlyPzjdG8wWRlPHQJJHI7DylhtRGeU9Lq+JuoPh5BKpPRX83ZdLrvXuOSu5Eo/e7SzOQhU4Hd2Miuw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@simple-libs/stream-utils": "^1.2.0", + "meow": "^13.0.0" + }, + "bin": { + "conventional-commits-parser": "dist/cli/index.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@semantic-release/error": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@semantic-release/error/-/error-4.0.0.tgz", @@ -2497,16 +2790,6 @@ "semantic-release": ">=24.1.0" } }, - "node_modules/@semantic-release/github/node_modules/undici": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", - "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20.18.1" - } - }, "node_modules/@semantic-release/npm": { "version": "13.1.5", "resolved": "https://registry.npmjs.org/@semantic-release/npm/-/npm-13.1.5.tgz", @@ -2537,163 +2820,80 @@ "semantic-release": ">=20.1.0" } }, - "node_modules/@semantic-release/npm/node_modules/execa": { - "version": "9.6.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz", - "integrity": "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@sindresorhus/merge-streams": "^4.0.0", - "cross-spawn": "^7.0.6", - "figures": "^6.1.0", - "get-stream": "^9.0.0", - "human-signals": "^8.0.1", - "is-plain-obj": "^4.1.0", - "is-stream": "^4.0.1", - "npm-run-path": "^6.0.0", - "pretty-ms": "^9.2.0", - "signal-exit": "^4.1.0", - "strip-final-newline": "^4.0.0", - "yoctocolors": "^2.1.1" - }, - "engines": { - "node": "^18.19.0 || >=20.5.0" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" - } - }, - "node_modules/@semantic-release/npm/node_modules/get-stream": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz", - "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==", + "node_modules/@semantic-release/release-notes-generator": { + "version": "14.1.1", + "resolved": "https://registry.npmjs.org/@semantic-release/release-notes-generator/-/release-notes-generator-14.1.1.tgz", + "integrity": "sha512-Pbd2e2XRMUD0OxehHpgd5/YghsE76cddkRHSoDvKLK+OCy4Ewxn49rWR631MEUU01lgwF/uyVXvbnVuu6+Z6VA==", "dev": true, "license": "MIT", "dependencies": { - "@sec-ant/readable-stream": "^0.4.1", - "is-stream": "^4.0.1" + "conventional-changelog-angular": "^8.0.0", + "conventional-changelog-writer": "^8.0.0", + "conventional-commits-filter": "^5.0.0", + "conventional-commits-parser": "^6.0.0", + "debug": "^4.0.0", + "import-from-esm": "^2.0.0", + "lodash-es": "^4.17.21", + "read-package-up": "^11.0.0" }, "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@semantic-release/npm/node_modules/human-signals": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.1.tgz", - "integrity": "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@semantic-release/npm/node_modules/is-stream": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", - "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" + "node": ">=20.8.1" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "peerDependencies": { + "semantic-release": ">=20.1.0" } }, - "node_modules/@semantic-release/npm/node_modules/npm-run-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", - "integrity": "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==", + "node_modules/@semantic-release/release-notes-generator/node_modules/@simple-libs/stream-utils": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@simple-libs/stream-utils/-/stream-utils-1.2.0.tgz", + "integrity": "sha512-KxXvfapcixpz6rVEB6HPjOUZT22yN6v0vI0urQSk1L8MlEWPDFCZkhw2xmkyoTGYeFw7tWTZd7e3lVzRZRN/EA==", "dev": true, "license": "MIT", - "dependencies": { - "path-key": "^4.0.0", - "unicorn-magic": "^0.3.0" - }, "engines": { "node": ">=18" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@semantic-release/npm/node_modules/path-key": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", - "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://ko-fi.com/dangreen" } }, - "node_modules/@semantic-release/npm/node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "node_modules/@semantic-release/release-notes-generator/node_modules/conventional-changelog-angular": { + "version": "8.3.1", + "resolved": "https://registry.npmjs.org/conventional-changelog-angular/-/conventional-changelog-angular-8.3.1.tgz", + "integrity": "sha512-6gfI3otXK5Ph5DfCOI1dblr+kN3FAm5a97hYoQkqNZxOaYa5WKfXH+AnpsmS+iUH2mgVC2Cg2Qw9m5OKcmNrIg==", "dev": true, "license": "ISC", - "engines": { - "node": ">=14" + "dependencies": { + "compare-func": "^2.0.0" }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@semantic-release/npm/node_modules/strip-final-newline": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz", - "integrity": "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==", - "dev": true, - "license": "MIT", "engines": { "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@semantic-release/npm/node_modules/unicorn-magic": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", - "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", + "node_modules/@semantic-release/release-notes-generator/node_modules/conventional-commits-filter": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/conventional-commits-filter/-/conventional-commits-filter-5.0.0.tgz", + "integrity": "sha512-tQMagCOC59EVgNZcC5zl7XqO30Wki9i9J3acbUvkaosCT6JX3EeFwJD7Qqp4MCikRnzS18WXV3BLIQ66ytu6+Q==", "dev": true, "license": "MIT", "engines": { "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@semantic-release/release-notes-generator": { - "version": "14.1.1", - "resolved": "https://registry.npmjs.org/@semantic-release/release-notes-generator/-/release-notes-generator-14.1.1.tgz", - "integrity": "sha512-Pbd2e2XRMUD0OxehHpgd5/YghsE76cddkRHSoDvKLK+OCy4Ewxn49rWR631MEUU01lgwF/uyVXvbnVuu6+Z6VA==", + "node_modules/@semantic-release/release-notes-generator/node_modules/conventional-commits-parser": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/conventional-commits-parser/-/conventional-commits-parser-6.4.0.tgz", + "integrity": "sha512-tvRg7FIBNlyPzjdG8wWRlPHQJJHI7DylhtRGeU9Lq+JuoPh5BKpPRX83ZdLrvXuOSu5Eo/e7SzOQhU4Hd2Miuw==", "dev": true, "license": "MIT", "dependencies": { - "conventional-changelog-angular": "^8.0.0", - "conventional-changelog-writer": "^8.0.0", - "conventional-commits-filter": "^5.0.0", - "conventional-commits-parser": "^6.0.0", - "debug": "^4.0.0", - "import-from-esm": "^2.0.0", - "lodash-es": "^4.17.21", - "read-package-up": "^11.0.0" + "@simple-libs/stream-utils": "^1.2.0", + "meow": "^13.0.0" }, - "engines": { - "node": ">=20.8.1" + "bin": { + "conventional-commits-parser": "dist/cli/index.js" }, - "peerDependencies": { - "semantic-release": ">=20.1.0" + "engines": { + "node": ">=18" } }, "node_modules/@simple-git/args-pathspec": { @@ -2712,38 +2912,38 @@ } }, "node_modules/@simple-libs/child-process-utils": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@simple-libs/child-process-utils/-/child-process-utils-1.0.2.tgz", - "integrity": "sha512-/4R8QKnd/8agJynkNdJmNw2MBxuFTRcNFnE5Sg/G+jkSsV8/UBgULMzhizWWW42p8L5H7flImV2ATi79Ove2Tw==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@simple-libs/child-process-utils/-/child-process-utils-2.0.0.tgz", + "integrity": "sha512-dvNoRKLijXnD0XoJAz94pbNuB5GQgDr55UhpSPhffDkTT0Cmcqh9jSCOtwfT2d4H6MI9E7c4SgtMuJXZ6F3c6A==", "dev": true, "license": "MIT", "dependencies": { - "@simple-libs/stream-utils": "^1.2.0" + "@simple-libs/stream-utils": "^2.0.0" }, "engines": { - "node": ">=18" + "node": ">=22" }, "funding": { "url": "https://ko-fi.com/dangreen" } }, "node_modules/@simple-libs/stream-utils": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@simple-libs/stream-utils/-/stream-utils-1.2.0.tgz", - "integrity": "sha512-KxXvfapcixpz6rVEB6HPjOUZT22yN6v0vI0urQSk1L8MlEWPDFCZkhw2xmkyoTGYeFw7tWTZd7e3lVzRZRN/EA==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@simple-libs/stream-utils/-/stream-utils-2.0.0.tgz", + "integrity": "sha512-fCTuZK4QBa+39Oz9l4OGfJfz+GpwCp3AqO7Zch3to99xHPgstVsRFpeQ8LNd2o1Gv8raL2mCFwiaHh7bFSp5DQ==", "dev": true, "license": "MIT", "engines": { - "node": ">=18" + "node": ">=22" }, "funding": { "url": "https://ko-fi.com/dangreen" } }, "node_modules/@sinclair/typebox": { - "version": "0.34.49", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz", - "integrity": "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==", + "version": "0.34.52", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.52.tgz", + "integrity": "sha512-XiMQh7qqVlxZzcVD+kkGMNGMzcTrDMLWI7S4x7z1MkCkbDPrekpZXEUK0eZqZFMuHQg2a2DZOcDIh9o5v3Gonw==", "dev": true, "license": "MIT" }, @@ -2794,9 +2994,9 @@ } }, "node_modules/@tsconfig/node10": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", - "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.13.tgz", + "integrity": "sha512-gcLdvR9HO1ZJBypsOGqaP6TFEzb6vIta0KSTLt9NAQ6pXQO3cRgSVyCN6pzYqI9DlJgY71XKO0dpDhCf08b3pg==", "dev": true, "license": "MIT" }, @@ -2822,9 +3022,9 @@ "license": "MIT" }, "node_modules/@tybys/wasm-util": { - "version": "0.10.2", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", - "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", "dev": true, "license": "MIT", "optional": true, @@ -2944,16 +3144,16 @@ } }, "node_modules/@types/lodash": { - "version": "4.17.24", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz", - "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==", + "version": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.25.tgz", + "integrity": "sha512-+K1NIO8I+F9/wNulfVvu23QYd0Pe9/OCqRrim4NoYIf1VoEDL90Ve4ClzpyqBLc7NpGGWRvYNCKZ1BE/Jpf8dQ==", "dev": true, "license": "MIT" }, "node_modules/@types/node": { - "version": "26.1.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.0.tgz", - "integrity": "sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw==", + "version": "26.4.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.4.0.tgz", + "integrity": "sha512-faiGnoIrLH/V8cibOMEAZ8pMw6oXqSukl29ra4mN8GdaB2ZewzeaLj+INpV5N+Z1eKWzY+IzaIZH2EIR6YZRNQ==", "devOptional": true, "license": "MIT", "dependencies": { @@ -3002,9 +3202,9 @@ "license": "MIT" }, "node_modules/@ungap/structured-clone": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.1.tgz", - "integrity": "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.4.0.tgz", + "integrity": "sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==", "dev": true, "license": "ISC" }, @@ -3114,9 +3314,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3131,9 +3328,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3148,9 +3342,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3165,9 +3356,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3182,9 +3370,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3199,9 +3384,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3216,9 +3398,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3233,9 +3412,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3250,9 +3426,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3267,9 +3440,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3352,9 +3522,9 @@ ] }, "node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", "dev": true, "license": "MIT", "bin": { @@ -3452,15 +3622,6 @@ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "license": "MIT" }, - "node_modules/ansi-align/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/ansi-align/node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -3505,9 +3666,9 @@ } }, "node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz", + "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", "license": "MIT", "engines": { "node": ">=12" @@ -3575,7 +3736,20 @@ "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "dev": true, - "license": "Python-2.0" + "license": "Python-2.0" + }, + "node_modules/argue-cli": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/argue-cli/-/argue-cli-3.1.0.tgz", + "integrity": "sha512-DhBpBfXL4SS2uC0N922MMajKR3CdrTG0u2or1PNYgXMsrSzViJrbtvT0nCLlLGUI0plam/ZZCs7aAauHtW9thw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://ko-fi.com/dangreen" + } }, "node_modules/argv-formatter": { "version": "1.0.0", @@ -3592,16 +3766,16 @@ "license": "MIT" }, "node_modules/babel-jest": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.4.1.tgz", - "integrity": "sha512-fATAbM8piYxkiXQp3RBXmZHxZVNJZAVXXfyeyCN2Tida3+qJ8ea9UxhiJ2y4fLO90ZImKt6k9FlcH2+rLkJGhw==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.5.0.tgz", + "integrity": "sha512-PrhPHlKC+MsLnuNzgIH/y1dkz1f6cSfKWaQeaG8WxLMuG44dYWQ8E9uRrsBbAGCU/3+BEFYPN4d6G3Zc5Y+waA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/transform": "30.4.1", + "@jest/transform": "30.5.0", "@types/babel__core": "^7.20.5", - "babel-plugin-istanbul": "^7.0.1", - "babel-preset-jest": "30.4.0", + "babel-plugin-istanbul": "^8.0.0", + "babel-preset-jest": "30.5.0", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "slash": "^3.0.0" @@ -3647,9 +3821,9 @@ } }, "node_modules/babel-plugin-istanbul": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-7.0.1.tgz", - "integrity": "sha512-D8Z6Qm8jCvVXtIRkBnqNHX0zJ37rQcFJ9u8WOS6tkYOsRdHBzypCstaxWiu5ZIlqQtviRYbgnRLSoCEvjqcqbA==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-8.0.0.tgz", + "integrity": "sha512-18wCskrN3DgbuBmp1gr7LBGT8xdz5xhQQqFvFhVxbkl8VBCrMKQ2YtqBWtUal1Zrc1HTuX0011+Brjw78TCFkg==", "dev": true, "license": "BSD-3-Clause", "workspaces": [ @@ -3660,16 +3834,16 @@ "@istanbuljs/load-nyc-config": "^1.0.0", "@istanbuljs/schema": "^0.1.3", "istanbul-lib-instrument": "^6.0.2", - "test-exclude": "^6.0.0" + "test-exclude": "^7.0.1" }, "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/babel-plugin-jest-hoist": { - "version": "30.4.0", - "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-30.4.0.tgz", - "integrity": "sha512-9EdtWM/sSfXLOGLwSn+GS6pIXyBnL07/8gyJlwFXjWy4DxMOyItqyUT29d4lQiS380EZwYlX7/At4PgBS+m2aA==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-30.5.0.tgz", + "integrity": "sha512-gtGo1B+u14jrZQv6TdSWIWkTqclboo7Qn+dFAGUOIuXLFuJKAdg3U5MIWzZnW4vfUb4dX9skmAMiby86e/SF4A==", "dev": true, "license": "MIT", "dependencies": { @@ -3707,20 +3881,20 @@ } }, "node_modules/babel-preset-jest": { - "version": "30.4.0", - "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-30.4.0.tgz", - "integrity": "sha512-lBY4jxsNmCnSiu7kquw8ZC9F4+XLMOKypT3RnNHPvU2Kpd4W0xaPuLr5ZkRyOsvLYAY4yaW1ZwTW4xB7NIiZzg==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-30.5.0.tgz", + "integrity": "sha512-ZGPn5ClP4lBDpuOK8W1yQIOy359HmbnZv3suucAlIe+SEE9yDsxV4S2PjSbH2Vc97U+WmzYD7vw3kr8NsQ/i6w==", "dev": true, "license": "MIT", "dependencies": { - "babel-plugin-jest-hoist": "30.4.0", + "babel-plugin-jest-hoist": "30.5.0", "babel-preset-current-node-syntax": "^1.2.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" }, "peerDependencies": { - "@babel/core": "^7.11.0 || ^8.0.0-beta.1" + "@babel/core": "^7.11.0 || ^8.0.0-beta.1 || ^8.0.0" } }, "node_modules/balanced-match": { @@ -3734,9 +3908,9 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.10.42", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.42.tgz", - "integrity": "sha512-c/jurFrDLyui7o1J86yLkRu4LMsTYcBohveus7/I2Hzdn9KIP2bdJPTue/lR1KH46enoPbD77GKeSYNdyPoD3Q==", + "version": "2.11.20", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.20.tgz", + "integrity": "sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw==", "dev": true, "license": "Apache-2.0", "bin": { @@ -3796,16 +3970,16 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", - "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/braces": { @@ -3822,9 +3996,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.4", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz", - "integrity": "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==", + "version": "4.28.8", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", "dev": true, "funding": [ { @@ -3842,11 +4016,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.10.38", - "caniuse-lite": "^1.0.30001799", - "electron-to-chromium": "^1.5.376", - "node-releases": "^2.0.48", - "update-browserslist-db": "^1.2.3" + "baseline-browser-mapping": "^2.11.12", + "caniuse-lite": "^1.0.30001809", + "electron-to-chromium": "^1.5.402", + "node-releases": "^2.0.53", + "update-browserslist-db": "^1.3.0" }, "bin": { "browserslist": "cli.js" @@ -3878,13 +4052,6 @@ "node-int64": "^0.4.0" } }, - "node_modules/buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true, - "license": "MIT" - }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -3908,9 +4075,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001800", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001800.tgz", - "integrity": "sha512-MMHtuAz9Ys840zAY5F4k6fV5GaivZ9sPk+nz0mY+GYVzRBnYkN0mpqkSR92oWRQ19yQWo4HvBV/FnC16AJX8MA==", + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", "dev": true, "funding": [ { @@ -3951,9 +4118,9 @@ } }, "node_modules/chardet": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.1.1.tgz", - "integrity": "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.2.0.tgz", + "integrity": "sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==", "license": "MIT" }, "node_modules/chokidar": { @@ -3998,9 +4165,9 @@ } }, "node_modules/cjs-module-lexer": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz", - "integrity": "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.1.tgz", + "integrity": "sha512-Ca8swihM+/4yKecYHY52kgJd300hi2lADU/a1RxNTRe+RJ9jvqQlESpbz9DnG9mowez8qwXHB8qYdIUw9e+F5Q==", "dev": true, "license": "MIT" }, @@ -4020,19 +4187,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/clean-stack/node_modules/escape-string-regexp": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", - "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/cli-boxes": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz", @@ -4045,22 +4199,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/cli-cursor": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", - "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", - "dev": true, - "license": "MIT", - "dependencies": { - "restore-cursor": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/cli-highlight": { "version": "2.1.11", "resolved": "https://registry.npmjs.org/cli-highlight/-/cli-highlight-2.1.11.tgz", @@ -4135,16 +4273,6 @@ "dev": true, "license": "MIT" }, - "node_modules/cli-highlight/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/cli-highlight/node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -4179,9 +4307,9 @@ } }, "node_modules/cli-highlight/node_modules/yargs": { - "version": "16.2.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", - "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", + "version": "16.2.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.2.tgz", + "integrity": "sha512-Nt9ZJjXTv5R8MHbqby/wXQ6Gi0Bb3TcYZkR1bzuL4yB2OxWPkXknz513gEF0GoA6tn00UpbPvERW8rzCuWCA6w==", "dev": true, "license": "MIT", "dependencies": { @@ -4230,16 +4358,6 @@ "dev": true, "license": "MIT" }, - "node_modules/cli-table3/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/cli-table3/node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -4255,56 +4373,6 @@ "node": ">=8" } }, - "node_modules/cli-truncate": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.2.0.tgz", - "integrity": "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "slice-ansi": "^8.0.0", - "string-width": "^8.2.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-truncate/node_modules/string-width": { - "version": "8.2.1", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.1.tgz", - "integrity": "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==", - "dev": true, - "license": "MIT", - "dependencies": { - "get-east-asian-width": "^1.5.0", - "strip-ansi": "^7.1.2" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-truncate/node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.2.2" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, "node_modules/cli-width": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", @@ -4409,13 +4477,6 @@ "dot-prop": "^5.1.0" } }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" - }, "node_modules/config-chain": { "version": "1.1.13", "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", @@ -4443,39 +4504,54 @@ "node": "^14.18.0 || >=16.10.0" } }, + "node_modules/content-type": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-3.0.0.tgz", + "integrity": "sha512-AIi5H6p0xk5uknXcN3/rmhP8jgp69OfSe/JuKiQAFprJ7UGw7mwj7m4XcmDzlrnJDG+cGpphAINGdU3g3g7kDw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/conventional-changelog-angular": { - "version": "8.3.1", - "resolved": "https://registry.npmjs.org/conventional-changelog-angular/-/conventional-changelog-angular-8.3.1.tgz", - "integrity": "sha512-6gfI3otXK5Ph5DfCOI1dblr+kN3FAm5a97hYoQkqNZxOaYa5WKfXH+AnpsmS+iUH2mgVC2Cg2Qw9m5OKcmNrIg==", + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/conventional-changelog-angular/-/conventional-changelog-angular-9.4.0.tgz", + "integrity": "sha512-HdxRxuS8bBXVIuo4V82gvSwAXT0vYQUizrjs/izmPg5JdDstr8v8I5hduGL3iQbG+o310dUDxC4+LetuS5hu9w==", "dev": true, "license": "ISC", "dependencies": { - "compare-func": "^2.0.0" + "@conventional-changelog/template": "^1.4.0" }, "engines": { - "node": ">=18" + "node": ">=22" } }, "node_modules/conventional-changelog-conventionalcommits": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/conventional-changelog-conventionalcommits/-/conventional-changelog-conventionalcommits-10.2.0.tgz", - "integrity": "sha512-UtlM9GqolY7OmlQh5L/UEVoKsTUpTgUVy1PU8JN5gl5Ydaejb7WRklGliG1SKPxxj7hzA173eG3Kt5fYWE2pmg==", + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/conventional-changelog-conventionalcommits/-/conventional-changelog-conventionalcommits-10.4.0.tgz", + "integrity": "sha512-Rriac6ZrAlVm6cy9Bz4NSp+WMHpwNXoPIYex+HjCgduAVUSbnew29DQjQw0C4g9u3HtSYzGiGY+pdBXAZo+4aA==", "dev": true, "license": "ISC", "dependencies": { - "@conventional-changelog/template": "^1.2.0" + "@conventional-changelog/template": "^1.4.0" }, "engines": { "node": ">=22" } }, "node_modules/conventional-changelog-writer": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/conventional-changelog-writer/-/conventional-changelog-writer-8.2.0.tgz", - "integrity": "sha512-Y2aW4596l9AEvFJRwFGJGiQjt2sBYTjPD18DdvxX9Vpz0Z7HQ+g1Z+6iYDAm1vR3QOJrDBkRHixHK/+FhkR6Pw==", + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/conventional-changelog-writer/-/conventional-changelog-writer-8.4.0.tgz", + "integrity": "sha512-HHBFkk1EECxxmCi4CTu091iuDpQv5/OavuCUAuZmrkWpmYfyD816nom1CvtfXJ/uYfAAjavgHvXHX291tSLK8g==", "dev": true, "license": "MIT", "dependencies": { + "@simple-libs/stream-utils": "^1.2.0", "conventional-commits-filter": "^5.0.0", "handlebars": "^4.7.7", "meow": "^13.0.0", @@ -4488,7 +4564,20 @@ "node": ">=18" } }, - "node_modules/conventional-commits-filter": { + "node_modules/conventional-changelog-writer/node_modules/@simple-libs/stream-utils": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@simple-libs/stream-utils/-/stream-utils-1.2.0.tgz", + "integrity": "sha512-KxXvfapcixpz6rVEB6HPjOUZT22yN6v0vI0urQSk1L8MlEWPDFCZkhw2xmkyoTGYeFw7tWTZd7e3lVzRZRN/EA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://ko-fi.com/dangreen" + } + }, + "node_modules/conventional-changelog-writer/node_modules/conventional-commits-filter": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/conventional-commits-filter/-/conventional-commits-filter-5.0.0.tgz", "integrity": "sha512-tQMagCOC59EVgNZcC5zl7XqO30Wki9i9J3acbUvkaosCT6JX3EeFwJD7Qqp4MCikRnzS18WXV3BLIQ66ytu6+Q==", @@ -4499,20 +4588,20 @@ } }, "node_modules/conventional-commits-parser": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/conventional-commits-parser/-/conventional-commits-parser-6.4.0.tgz", - "integrity": "sha512-tvRg7FIBNlyPzjdG8wWRlPHQJJHI7DylhtRGeU9Lq+JuoPh5BKpPRX83ZdLrvXuOSu5Eo/e7SzOQhU4Hd2Miuw==", + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/conventional-commits-parser/-/conventional-commits-parser-7.1.2.tgz", + "integrity": "sha512-O+x4N2yH+ijvqWlIyTHsXTAP+algNWgGbjY2duCe8w2vUMvUB95cLRslCPfTMQyLAKlet3bhZTdu6ozn4M+QJQ==", "dev": true, "license": "MIT", "dependencies": { - "@simple-libs/stream-utils": "^1.2.0", - "meow": "^13.0.0" + "@simple-libs/stream-utils": "^2.0.0", + "argue-cli": "^3.1.0" }, "bin": { "conventional-commits-parser": "dist/cli/index.js" }, "engines": { - "node": ">=18" + "node": ">=22" } }, "node_modules/convert-hrtime": { @@ -4543,9 +4632,9 @@ "license": "MIT" }, "node_modules/cosmiconfig": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.1.tgz", - "integrity": "sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ==", + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.2.tgz", + "integrity": "sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg==", "dev": true, "license": "MIT", "dependencies": { @@ -4690,6 +4779,16 @@ "node": ">=0.10.0" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/detect-newline": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", @@ -4754,9 +4853,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.387", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.387.tgz", - "integrity": "sha512-TaxwufTFDufvPEoXdhwVrA3UdFWBeWGkYoJ1K8ldF1xe6gKfth6iRNS5lTQ5JPNOHdGQm8PT1QYKUqFLCiUefQ==", + "version": "1.5.416", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.416.tgz", + "integrity": "sha512-K6bvB2BjnNrugtIih6ewlbBI9DXa976jIdiIlRLHhBoEI9a4JaQjjHyF+A1IQI543aQYR4LnmOrT/K5fZj0aPA==", "dev": true, "license": "ISC" }, @@ -4860,19 +4959,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/env-ci/node_modules/mimic-fn": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", - "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/env-ci/node_modules/npm-run-path": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", @@ -4889,22 +4975,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/env-ci/node_modules/onetime": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", - "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "mimic-fn": "^4.0.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/env-ci/node_modules/path-key": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", @@ -4918,19 +4988,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/env-ci/node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/env-ci/node_modules/strip-final-newline": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", @@ -4977,15 +5034,24 @@ "is-arrayish": "^0.2.1" } }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, "node_modules/es-toolkit": { - "version": "1.49.0", - "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.49.0.tgz", - "integrity": "sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g==", + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.52.0.tgz", + "integrity": "sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==", "dev": true, "license": "MIT", "workspaces": [ "docs", - "benchmarks" + "benchmarks", + "tests/types", + "tests/browser-compat" ] }, "node_modules/escalade": { @@ -4999,13 +5065,16 @@ } }, "node_modules/escape-string-regexp": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", - "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", "dev": true, "license": "MIT", "engines": { - "node": ">=8" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/esprima": { @@ -5022,32 +5091,28 @@ "node": ">=4" } }, - "node_modules/eventemitter3": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", - "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", - "dev": true, - "license": "MIT" - }, "node_modules/execa": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", - "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz", + "integrity": "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==", "dev": true, "license": "MIT", "dependencies": { - "cross-spawn": "^7.0.3", - "get-stream": "^6.0.0", - "human-signals": "^2.1.0", - "is-stream": "^2.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^4.0.1", - "onetime": "^5.1.2", - "signal-exit": "^3.0.3", - "strip-final-newline": "^2.0.0" + "@sindresorhus/merge-streams": "^4.0.0", + "cross-spawn": "^7.0.6", + "figures": "^6.1.0", + "get-stream": "^9.0.0", + "human-signals": "^8.0.1", + "is-plain-obj": "^4.1.0", + "is-stream": "^4.0.1", + "npm-run-path": "^6.0.0", + "pretty-ms": "^9.2.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^4.0.0", + "yoctocolors": "^2.1.1" }, "engines": { - "node": ">=10" + "node": "^18.19.0 || >=20.5.0" }, "funding": { "url": "https://github.com/sindresorhus/execa?sponsor=1" @@ -5064,40 +5129,23 @@ } }, "node_modules/expect": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/expect/-/expect-30.4.1.tgz", - "integrity": "sha512-PMARsyh/JtqC20HoGqlFcIlQAyqUtW4PlI1rup1uhYJtKuwAjbvWi3GQMAn+STdHum/dk8xrKfUM1+5SAwpolA==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/expect/-/expect-30.5.0.tgz", + "integrity": "sha512-8fiMWcEjPU7B9nErC4FtFcCzf2tC6I75Qf7m8wzBAWC2taZmcno3yAFEjIQL34SwoGZNgPf63UDiJLyh4SMPaw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/expect-utils": "30.4.1", - "@jest/get-type": "30.1.0", - "jest-matcher-utils": "30.4.1", - "jest-message-util": "30.4.1", - "jest-mock": "30.4.1", - "jest-util": "30.4.1" + "@jest/expect-utils": "30.5.0", + "@jest/get-type": "30.5.0", + "jest-matcher-utils": "30.5.0", + "jest-message-util": "30.5.0", + "jest-mock": "30.5.0", + "jest-util": "30.5.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/fast-content-type-parse": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-3.0.0.tgz", - "integrity": "sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT" - }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -5127,9 +5175,9 @@ } }, "node_modules/fast-uri": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz", - "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==", + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.6.tgz", + "integrity": "sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q==", "funding": [ { "type": "github", @@ -5161,6 +5209,24 @@ "bser": "2.1.1" } }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, "node_modules/figures": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz", @@ -5251,23 +5317,10 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/foreground-child/node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/fs-extra": { - "version": "11.3.6", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.6.tgz", - "integrity": "sha512-w8ZNZr2mKIc7qeNaQ9AVPT1+iFaI+Avd4xudVOvdDJ8VytREi1Ft5Ih7hd9jjehod8vAM5GMsfQ/TpPf4EyoEA==", + "version": "11.4.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.4.0.tgz", + "integrity": "sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA==", "license": "MIT", "dependencies": { "graceful-fs": "^4.2.0", @@ -5278,13 +5331,6 @@ "node": ">=14.14" } }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true, - "license": "ISC" - }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -5334,9 +5380,9 @@ } }, "node_modules/get-east-asian-width": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", - "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", "license": "MIT", "engines": { "node": ">=18" @@ -5356,13 +5402,17 @@ } }, "node_modules/get-stream": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", - "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz", + "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==", "dev": true, "license": "MIT", + "dependencies": { + "@sec-ant/readable-stream": "^0.4.1", + "is-stream": "^4.0.1" + }, "engines": { - "node": ">=10" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -5383,51 +5433,19 @@ "traverse": "0.6.8" } }, - "node_modules/git-log-parser/node_modules/split2": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/split2/-/split2-1.0.0.tgz", - "integrity": "sha512-NKywug4u4pX/AZBB1FCPzZ6/7O+Xhz1qMVbzTvvKvikjO99oPN87SkK08mEY9P63/5lWjK+wgOOgApnTg5r6qg==", - "dev": true, - "license": "ISC", - "dependencies": { - "through2": "~2.0.0" - } - }, - "node_modules/git-raw-commits": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/git-raw-commits/-/git-raw-commits-5.0.1.tgz", - "integrity": "sha512-Y+csSm2GD/PCSh6Isd/WiMjNAydu0VBiG9J7EdQsNA5P9uXvLayqjmTsNlK5Gs9IhblFZqOU0yid5Il5JPoLiQ==", - "deprecated": "Deprecated and no longer maintained. Use @conventional-changelog/git-client instead.", - "dev": true, - "license": "MIT", - "dependencies": { - "@conventional-changelog/git-client": "^2.6.0", - "meow": "^13.0.0" - }, - "bin": { - "git-raw-commits": "src/cli.js" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/glob": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" }, - "bin": { - "glob": "dist/esm/bin.mjs" + "engines": { + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -5524,9 +5542,9 @@ } }, "node_modules/hosted-git-info": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.2.tgz", - "integrity": "sha512-M422h7o/BR3rmCQ8UHi7cyyMqKltdP9Uo+J2fXK+RSAY+wTcKOIRyhTuKv4qn+DJf3g+PL890AzId5KZpX+CBg==", + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.3.tgz", + "integrity": "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==", "dev": true, "license": "ISC", "dependencies": { @@ -5537,9 +5555,9 @@ } }, "node_modules/hosted-git-info/node_modules/lru-cache": { - "version": "11.3.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.3.tgz", - "integrity": "sha512-JvNw9Y81y33E+BEYPr0U7omo+U9AySnsMsEiXgwT6yqd31VQWTLNQqmT4ou5eqPFUrTfIDFta2wKhB1hyohtAQ==", + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", "dev": true, "license": "BlueOak-1.0.0", "engines": { @@ -5554,41 +5572,43 @@ "license": "MIT" }, "node_modules/http-proxy-agent": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-9.0.0.tgz", - "integrity": "sha512-FcF8VhXYLQcxWCnt/cCpT2apKsRDUGeVEeMqGu4HSTu29U8Yw0TLOjdYIlDsYk3IkUh+taX4IDWpPcCqKDhCjA==", + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-9.1.0.tgz", + "integrity": "sha512-2NxoveTT58mjYT4n3RPTEfCZGLMbidoO8XEieXfpSYxu+PQJ1qpx4ypwH6N+uF9twBPIvRRgvkvW5HUTYWENig==", "dev": true, "license": "MIT", "dependencies": { "agent-base": "9.0.0", - "debug": "^4.3.4" + "debug": "^4.3.4", + "proxy-agent-negotiate": "1.1.0" }, "engines": { "node": ">= 20" } }, "node_modules/https-proxy-agent": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-9.0.0.tgz", - "integrity": "sha512-/MVmHp58WkOypgFhCLk4fzpPcFQvTJ/e6LBI7irpIO2HfxUbpmYoHF+KzipzJpxxzJu7aJNWQ0xojJ/dzV2G5g==", + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-9.1.0.tgz", + "integrity": "sha512-ag87y7cJJ9/3+GxFr8Oy4O5faDsGRGnBGsJj/YjOSsSx/5eadKLYTMPlzuR6obgoCDDm0abAAZitXXQkMOPSpA==", "dev": true, "license": "MIT", "dependencies": { "agent-base": "9.0.0", - "debug": "^4.3.4" + "debug": "^4.3.4", + "proxy-agent-negotiate": "1.1.0" }, "engines": { "node": ">= 20" } }, "node_modules/human-signals": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", - "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.1.tgz", + "integrity": "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==", "dev": true, "license": "Apache-2.0", "engines": { - "node": ">=10.17.0" + "node": ">=18.18.0" } }, "node_modules/husky": { @@ -5608,9 +5628,9 @@ } }, "node_modules/iconv-lite": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", - "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -5738,18 +5758,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "dev": true, - "license": "ISC", - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -5798,19 +5806,12 @@ } }, "node_modules/is-fullwidth-code-point": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", - "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", - "dev": true, + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", "license": "MIT", - "dependencies": { - "get-east-asian-width": "^1.3.1" - }, "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=8" } }, "node_modules/is-generator-fn": { @@ -5870,13 +5871,13 @@ } }, "node_modules/is-stream": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", + "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", "dev": true, "license": "MIT", "engines": { - "node": ">=8" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -5910,9 +5911,9 @@ "license": "ISC" }, "node_modules/issue-parser": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/issue-parser/-/issue-parser-7.0.1.tgz", - "integrity": "sha512-3YZcUUR2Wt1WsapF+S/WiA2WmlW0cWAoPccMqne7AxEBhCdFeTPjfv/Axb8V2gyCgY3nRw+ksZ3xSUX+R47iAg==", + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/issue-parser/-/issue-parser-7.0.2.tgz", + "integrity": "sha512-7atWPjhGEIX3JEtMrOYd8TKzboYlq+5sNbdl9POiLYOI14G5HZiQbZP0Xj5EZdrufQVXfJlpTV0hys0CuxwxZw==", "dev": true, "license": "MIT", "dependencies": { @@ -6024,16 +6025,16 @@ } }, "node_modules/jest": { - "version": "30.4.2", - "resolved": "https://registry.npmjs.org/jest/-/jest-30.4.2.tgz", - "integrity": "sha512-Yi1jqNC/Oq0N4hBgNH/YvBpP1P57QqundgytzYqy3yqAa7NZPNjSoi4SGbRAXDMdBzNE6xBCi5U7RgfrvMEUVQ==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/jest/-/jest-30.5.0.tgz", + "integrity": "sha512-HeFeOUEKh5gjnp1rjuSCse8Dhj0Y3KA8lsZ3azr4Wnq1nCxwMBu35MDc9mp8iblZxmeAz6wV4P241tyUB7J2Ew==", "dev": true, "license": "MIT", "dependencies": { - "@jest/core": "30.4.2", - "@jest/types": "30.4.1", + "@jest/core": "30.5.0", + "@jest/types": "30.5.0", "import-local": "^3.2.0", - "jest-cli": "30.4.2" + "jest-cli": "30.5.0" }, "bin": { "jest": "bin/jest.js" @@ -6051,44 +6052,160 @@ } }, "node_modules/jest-changed-files": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-30.4.1.tgz", - "integrity": "sha512-IuctmYrxi21iOSOaIXpJWalHyPAsVv0GeBHKDn8C1CA4W5htHn7INL+wdnL4Bo0+olEndvAFkmb++tIQJG+vvg==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-30.5.0.tgz", + "integrity": "sha512-dq1x8JiEnHkJDxxOrF6UJDivBRAQMwAa5tzr+VX3um0SfyMFseUPQUbe51wLwggfoa5h/EmOtSpdJxxkzSlNRQ==", "dev": true, "license": "MIT", "dependencies": { "execa": "^5.1.1", - "jest-util": "30.4.1", + "jest-util": "30.5.0", "p-limit": "^3.1.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, + "node_modules/jest-changed-files/node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/jest-changed-files/node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jest-changed-files/node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/jest-changed-files/node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jest-changed-files/node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/jest-changed-files/node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-changed-files/node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jest-changed-files/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/jest-changed-files/node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/jest-circus": { - "version": "30.4.2", - "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-30.4.2.tgz", - "integrity": "sha512-rvHH7VlY6LgbJXJTQ87GW62g1FntOtbhh0zT+v04kC+pgL6aBKyYINXxWukCpj3dcIBMw5/XUbtDS9dU9JTXeQ==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-30.5.0.tgz", + "integrity": "sha512-T3v7uM4wwCu+RQicjsAWCgoL3CiyuX3THSKwv2uMb9N2bMUgb+HfwDWEUma85vOGbvQNQ/YfoCXF1OCZot0ZEw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "30.4.1", - "@jest/expect": "30.4.1", - "@jest/test-result": "30.4.1", - "@jest/types": "30.4.1", + "@jest/environment": "30.5.0", + "@jest/expect": "30.5.0", + "@jest/test-result": "30.5.0", + "@jest/types": "30.5.0", "@types/node": "*", "chalk": "^4.1.2", "co": "^4.6.0", "dedent": "^1.6.0", "is-generator-fn": "^2.1.0", - "jest-each": "30.4.1", - "jest-matcher-utils": "30.4.1", - "jest-message-util": "30.4.1", - "jest-runtime": "30.4.2", - "jest-snapshot": "30.4.1", - "jest-util": "30.4.1", + "jest-each": "30.5.0", + "jest-matcher-utils": "30.5.0", + "jest-message-util": "30.5.0", + "jest-runtime": "30.5.0", + "jest-snapshot": "30.5.0", + "jest-util": "30.5.0", "p-limit": "^3.1.0", - "pretty-format": "30.4.1", + "pretty-format": "30.5.0", "pure-rand": "^7.0.0", "slash": "^3.0.0", "stack-utils": "^2.0.6" @@ -6131,21 +6248,21 @@ } }, "node_modules/jest-cli": { - "version": "30.4.2", - "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-30.4.2.tgz", - "integrity": "sha512-jfA2ocvVHMXS2QijrJ0d31ektP+d/W0T5RpcTX2Pq+3sVqHlsXVCM2+FmwpL+bdY8OfHpIg9xMxLF17Zg0U49Q==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-30.5.0.tgz", + "integrity": "sha512-QHZMiy32x2K+NzJ1AuuoCAVc1Y5co0VXib3R7kD9MWcWFflzsD1eJN0wR+mkNlM+ts7C+Bjz0oOoFE5IiHylCg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/core": "30.4.2", - "@jest/test-result": "30.4.1", - "@jest/types": "30.4.1", + "@jest/core": "30.5.0", + "@jest/test-result": "30.5.0", + "@jest/types": "30.5.0", "chalk": "^4.1.2", "exit-x": "^0.2.2", "import-local": "^3.2.0", - "jest-config": "30.4.2", - "jest-util": "30.4.1", - "jest-validate": "30.4.1", + "jest-config": "30.5.0", + "jest-util": "30.5.0", + "jest-validate": "30.5.0", "yargs": "^17.7.2" }, "bin": { @@ -6211,23 +6328,13 @@ "node": ">=12" } }, - "node_modules/jest-cli/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/jest-cli/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, + "node_modules/jest-cli/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, "node_modules/jest-cli/node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -6262,9 +6369,9 @@ } }, "node_modules/jest-cli/node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", "dev": true, "license": "MIT", "dependencies": { @@ -6281,33 +6388,33 @@ } }, "node_modules/jest-config": { - "version": "30.4.2", - "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-30.4.2.tgz", - "integrity": "sha512-rNHAShJQqQwFNoL0hbf3BphSBOWnpOUAKvidLS/AjNVLPfoj5mSf4jQMfW3cYOs6hXeZC7nF7mDHaBnbxELOzg==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-30.5.0.tgz", + "integrity": "sha512-gYQl2FqYgiVpyuB7DutBIbJRWaq5VcHdzOXJJ51HNa8J5JrsZehIlzNFW0/9s5uvvcbzM5/LTUizYSbsFErv+w==", "dev": true, "license": "MIT", "dependencies": { "@babel/core": "^7.27.4", - "@jest/get-type": "30.1.0", - "@jest/pattern": "30.4.0", - "@jest/test-sequencer": "30.4.1", - "@jest/types": "30.4.1", - "babel-jest": "30.4.1", + "@jest/get-type": "30.5.0", + "@jest/pattern": "30.5.0", + "@jest/test-sequencer": "30.5.0", + "@jest/types": "30.5.0", + "babel-jest": "30.5.0", "chalk": "^4.1.2", "ci-info": "^4.2.0", "deepmerge": "^4.3.1", - "glob": "^10.5.0", + "glob": "^13.0.6", "graceful-fs": "^4.2.11", - "jest-circus": "30.4.2", - "jest-docblock": "30.4.0", - "jest-environment-node": "30.4.1", - "jest-regex-util": "30.4.0", - "jest-resolve": "30.4.1", - "jest-runner": "30.4.2", - "jest-util": "30.4.1", - "jest-validate": "30.4.1", + "jest-circus": "30.5.0", + "jest-docblock": "30.5.0", + "jest-environment-node": "30.5.0", + "jest-regex-util": "30.5.0", + "jest-resolve": "30.5.0", + "jest-runner": "30.5.0", + "jest-util": "30.5.0", + "jest-validate": "30.5.0", "parse-json": "^5.2.0", - "pretty-format": "30.4.1", + "pretty-format": "30.5.0", "slash": "^3.0.0", "strip-json-comments": "^3.1.1" }, @@ -6365,16 +6472,16 @@ } }, "node_modules/jest-diff": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.4.1.tgz", - "integrity": "sha512-CRpFK0RtLriVDGcPPAnR6HMVI8bSR2jnUIgralhauzYQZIb4RH9AtEInTuQr65LmmGggGcRT6HIASxwqsVsmlA==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.5.0.tgz", + "integrity": "sha512-QjCfDMwdPFvLxTQmS4/Dswx3PUCiqmSXVLGljMC3SU7YG1qHVoR6b86IH/O2G9k9OMyKXz2vS2Q60VnAozNDwA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/diff-sequences": "30.4.0", - "@jest/get-type": "30.1.0", + "@jest/diff-sequences": "30.5.0", + "@jest/get-type": "30.5.0", "chalk": "^4.1.2", - "pretty-format": "30.4.1" + "pretty-format": "30.5.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -6414,9 +6521,9 @@ } }, "node_modules/jest-docblock": { - "version": "30.4.0", - "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-30.4.0.tgz", - "integrity": "sha512-ZPMabUZCx5MpbZ2eBYSvZ0J8fvo3dR9oM+eeUpb3aKNQFuS2tu3Duw1TNlMoP8k3WQgKGJuhcMFvwcVuq6T7oA==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-30.5.0.tgz", + "integrity": "sha512-NwDqcxtoZi33RhuW+zJS/RVA3rmheQ8BnwpYZuc/Eruaz6seQb7+aoCeDZu/3X7W2XmD8DbSo9Pn72DbKsBFYw==", "dev": true, "license": "MIT", "dependencies": { @@ -6427,17 +6534,17 @@ } }, "node_modules/jest-each": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-30.4.1.tgz", - "integrity": "sha512-/8MJbH6fuj48TstjrMf+u/pd06Qezz5xOXvZA6442heNOWr8bdeoGZX2d9fCn028CoMgYmroH9//zky5GfyYmA==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-30.5.0.tgz", + "integrity": "sha512-NiMFNhRygJEFqYNt8pnkxppUF6CR486GEpt9rSU6lPBf7KeccaOL0zbjxG8fgJgD//6e7zYdssnlt6j+1kI31A==", "dev": true, "license": "MIT", "dependencies": { - "@jest/get-type": "30.1.0", - "@jest/types": "30.4.1", + "@jest/get-type": "30.5.0", + "@jest/types": "30.5.0", "chalk": "^4.1.2", - "jest-util": "30.4.1", - "pretty-format": "30.4.1" + "jest-util": "30.5.0", + "pretty-format": "30.5.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -6477,74 +6584,72 @@ } }, "node_modules/jest-environment-node": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-30.4.1.tgz", - "integrity": "sha512-4FZYVOk85hz2AyT6BbarKy9u37g6DbrDyCdFhsnDdXqyrueYQvB+0zO4f/kqLCRD0BsPRXPMNJeQwihKZV8naw==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-30.5.0.tgz", + "integrity": "sha512-bTc79ywKLz0ogbT3JIYuEhgWV4Ffd/cJe06co4v8CyRtlmju8x5gokMlGFR8ARWhmk5SnbDRxmf50XWnlZVhDg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "30.4.1", - "@jest/fake-timers": "30.4.1", - "@jest/types": "30.4.1", + "@jest/environment": "30.5.0", + "@jest/fake-timers": "30.5.0", + "@jest/types": "30.5.0", "@types/node": "*", - "jest-mock": "30.4.1", - "jest-util": "30.4.1", - "jest-validate": "30.4.1" + "jest-mock": "30.5.0", + "jest-util": "30.5.0", + "jest-validate": "30.5.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-haste-map": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-30.4.1.tgz", - "integrity": "sha512-rFrcONd8jeFsyw+Z9CrScJgglRf2+NFmNam8dKu7n+SoHqNYT47mn0DdEcVUZJpvh7Iz6/si7f7yUH7GJHVgnw==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-30.5.0.tgz", + "integrity": "sha512-0FStogBslBVOEqTOJr4oXMtFitmrWp9WscG6Gbns88i0YAuMXijCT2G5VMfg/HCR4QAnL+OF2C2ednag+HlDuA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.4.1", + "@jest/types": "30.5.0", + "@parcel/watcher": "^2.6.0", "@types/node": "*", "anymatch": "^3.1.3", "fb-watchman": "^2.0.2", + "fdir": "^6.5.0", "graceful-fs": "^4.2.11", - "jest-regex-util": "30.4.0", - "jest-util": "30.4.1", - "jest-worker": "30.4.1", - "picomatch": "^4.0.3", - "walker": "^1.0.8" + "jest-regex-util": "30.5.0", + "jest-util": "30.5.0", + "jest-worker": "30.5.0", + "picomatch": "^4.0.3" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "optionalDependencies": { - "fsevents": "^2.3.3" } }, "node_modules/jest-leak-detector": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-30.4.1.tgz", - "integrity": "sha512-IpmyiioeHxiWDhesHnUFmOxcTzwCwKpgACgWajtAP+nYQXiY7DakTxB6Bx9JFiRMljr0AX1PvnQdaU1KFoz6NQ==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-30.5.0.tgz", + "integrity": "sha512-Mq11ceAkNR250Iv45RoOwuG9fb4kYbJ02qoyL7A0nCI8FV5+aG/THmcEUx5uR2dNSa4KOtz+xBKrJ8cZPhPpuQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/get-type": "30.1.0", - "pretty-format": "30.4.1" + "@jest/get-type": "30.5.0", + "pretty-format": "30.5.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-matcher-utils": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.4.1.tgz", - "integrity": "sha512-zvYfX5CaeEkFrrLS9suWe9rvJrm9J1Iv3ua8kIBv9GEPzcnsfBf0bob37la7s67fs0nlBC3EuvkOLnXQKxtx4A==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.5.0.tgz", + "integrity": "sha512-EfaYMC9f9ds7fahB/LYFTgd1Z2RS9Vpm2e46gazij0onkpoQG7Daq+MLm8/gQVqWwRVjL/RNDggbFx9MsrJEmQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/get-type": "30.1.0", + "@jest/get-type": "30.5.0", "chalk": "^4.1.2", - "jest-diff": "30.4.1", - "pretty-format": "30.4.1" + "jest-diff": "30.5.0", + "pretty-format": "30.5.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -6584,20 +6689,20 @@ } }, "node_modules/jest-message-util": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.4.1.tgz", - "integrity": "sha512-kwCKIvq0MCW1HzLoGola9Te6JUdzgV0loyKJ3Qghrkz9i5/RRIHsL95BMQc2HBBhlBKC4j22K9p11TGHH8RBpQ==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.5.0.tgz", + "integrity": "sha512-dBYMhplGfspKaCnVk9TUy1cZnknWubpuPNEputjz0YJk1G/92R45rn45BvbPMPMtC5LVcIdxJGPOaOSQTiuzJw==", "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.27.1", - "@jest/types": "30.4.1", + "@jest/types": "30.5.0", "@types/stack-utils": "^2.0.3", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", - "jest-util": "30.4.1", + "jest-util": "30.5.0", "picomatch": "^4.0.3", - "pretty-format": "30.4.1", + "pretty-format": "30.5.0", "slash": "^3.0.0", "stack-utils": "^2.0.6" }, @@ -6639,42 +6744,25 @@ } }, "node_modules/jest-mock": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.4.1.tgz", - "integrity": "sha512-/i8SVb8/NSB7RfNi8gfqu8gxLV23KaL5EpAttyb9iz8qWRIqXRLflycz/32wXsYkOnaUlx8NAKnJYtpsmXUmfw==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.5.0.tgz", + "integrity": "sha512-bP5MHZpkYrV7xpV+yvhl36DPcXoEmTR57Un5EACcdVpMY7mpkDefCBq+V4mhcjE/3rwUajT6OTrcJTN7EwN1BA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.4.1", + "@jest/expect-utils": "30.5.0", + "@jest/types": "30.5.0", "@types/node": "*", - "jest-util": "30.4.1" + "jest-util": "30.5.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-pnp-resolver": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", - "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - }, - "peerDependencies": { - "jest-resolve": "*" - }, - "peerDependenciesMeta": { - "jest-resolve": { - "optional": true - } - } - }, "node_modules/jest-regex-util": { - "version": "30.4.0", - "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.4.0.tgz", - "integrity": "sha512-mWlvLviKIgIQ8VCuM1xRdD0TWp3zlzionlmDBjuXVBs+VkmXq6FgW9T4Emr7oGz/Rk6feDCGyiugolcQEyp3mg==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.5.0.tgz", + "integrity": "sha512-Mg0WK7A6xRHLSA1udJ8y9f3lM0uUhFTBnLKzwPmqB9AylvpleJ6BLemR8K9dK27DY+cesDryoA7yLZCAHsPG1A==", "dev": true, "license": "MIT", "engines": { @@ -6682,34 +6770,33 @@ } }, "node_modules/jest-resolve": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-30.4.1.tgz", - "integrity": "sha512-Zry8Yq/yJcNAZ7dJ5F2heic8AheXvbFZ7XI5V+h28nrYZ7Qoyy4dItq8OodjnYD270mvX+ZudmrNV9cysqhW5Q==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-30.5.0.tgz", + "integrity": "sha512-NFvQWJ4G7e2kN5712iG+12Xr325NRFGAIhovrwLfgq5Oqd4bFHITw4CzcFkZGp1GTCqemC4v1l8/yZzedKZkjw==", "dev": true, "license": "MIT", "dependencies": { "chalk": "^4.1.2", "graceful-fs": "^4.2.11", - "jest-haste-map": "30.4.1", - "jest-pnp-resolver": "^1.2.3", - "jest-util": "30.4.1", - "jest-validate": "30.4.1", + "jest-haste-map": "30.5.0", + "jest-util": "30.5.0", + "jest-validate": "30.5.0", "slash": "^3.0.0", - "unrs-resolver": "^1.7.11" + "unrs-resolver": "^1.12.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-resolve-dependencies": { - "version": "30.4.2", - "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-30.4.2.tgz", - "integrity": "sha512-gDiVh1I+GxYzz9oXlyw+1wv6VOYX1WYxMOfjsA3iGKePV2oxmbHhwxfkALxNxYy1ciw6APWwkW2zZONwP97aEQ==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-30.5.0.tgz", + "integrity": "sha512-TnSBAp3wGOnqXBmLT3OXtJkugw6Vj2KU0IPde2EJmbGns/QMoNlkauJ7jZ8KYaxONSpx0o4pUvi+u1Q/LSk3Pg==", "dev": true, "license": "MIT", "dependencies": { - "jest-regex-util": "30.4.0", - "jest-snapshot": "30.4.1" + "jest-regex-util": "30.5.0", + "jest-snapshot": "30.5.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -6749,34 +6836,34 @@ } }, "node_modules/jest-runner": { - "version": "30.4.2", - "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-30.4.2.tgz", - "integrity": "sha512-2dw0PslVYXxffXGpLo+Ejad+KcI1Qkjn7f4X4619gf21oCUmL+SPfjqIa/losUem3yEOvfNZe/F1HWUcNpODcg==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-30.5.0.tgz", + "integrity": "sha512-Q6Yt+1LvXvEstvru6sQLT7OQYC77VNl6dK0KEvBkeOHgThmXDqNp3Ox9TglDWFHU85pemqTNdKwxfnmO3vgrdA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/console": "30.4.1", - "@jest/environment": "30.4.1", - "@jest/test-result": "30.4.1", - "@jest/transform": "30.4.1", - "@jest/types": "30.4.1", + "@jest/console": "30.5.0", + "@jest/environment": "30.5.0", + "@jest/source-map": "30.5.0", + "@jest/test-result": "30.5.0", + "@jest/transform": "30.5.0", + "@jest/types": "30.5.0", "@types/node": "*", "chalk": "^4.1.2", "emittery": "^0.13.1", "exit-x": "^0.2.2", "graceful-fs": "^4.2.11", - "jest-docblock": "30.4.0", - "jest-environment-node": "30.4.1", - "jest-haste-map": "30.4.1", - "jest-leak-detector": "30.4.1", - "jest-message-util": "30.4.1", - "jest-resolve": "30.4.1", - "jest-runtime": "30.4.2", - "jest-util": "30.4.1", - "jest-watcher": "30.4.1", - "jest-worker": "30.4.1", - "p-limit": "^3.1.0", - "source-map-support": "0.5.13" + "jest-docblock": "30.5.0", + "jest-environment-node": "30.5.0", + "jest-haste-map": "30.5.0", + "jest-leak-detector": "30.5.0", + "jest-message-util": "30.5.0", + "jest-resolve": "30.5.0", + "jest-runtime": "30.5.0", + "jest-util": "30.5.0", + "jest-watcher": "30.5.0", + "jest-worker": "30.5.0", + "p-limit": "^3.1.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -6816,32 +6903,33 @@ } }, "node_modules/jest-runtime": { - "version": "30.4.2", - "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-30.4.2.tgz", - "integrity": "sha512-3/5e8iPz2k/VLqlr8DgTftYyLUv8Su3FkCAO2/Od81UsUTpSxOrS6O5x5KkoQwyUjmpYyDJKeyAvg2T2nvpNkQ==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-30.5.0.tgz", + "integrity": "sha512-VTRz0sRIw2EISeHigx1O+CMuwoG4+RKJjF8dp8okzFaDNQEcv5Mw0h5QW8VJXgb2CRF4gZIjSEBb2jdzXPagFQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "30.4.1", - "@jest/fake-timers": "30.4.1", - "@jest/globals": "30.4.1", - "@jest/source-map": "30.0.1", - "@jest/test-result": "30.4.1", - "@jest/transform": "30.4.1", - "@jest/types": "30.4.1", + "@jest/environment": "30.5.0", + "@jest/fake-timers": "30.5.0", + "@jest/globals": "30.5.0", + "@jest/source-map": "30.5.0", + "@jest/test-result": "30.5.0", + "@jest/transform": "30.5.0", + "@jest/types": "30.5.0", "@types/node": "*", "chalk": "^4.1.2", - "cjs-module-lexer": "^2.1.0", + "cjs-module-lexer": "^2.2.0", "collect-v8-coverage": "^1.0.2", - "glob": "^10.5.0", + "es-module-lexer": "^2.1.0", + "glob": "^13.0.6", "graceful-fs": "^4.2.11", - "jest-haste-map": "30.4.1", - "jest-message-util": "30.4.1", - "jest-mock": "30.4.1", - "jest-regex-util": "30.4.0", - "jest-resolve": "30.4.1", - "jest-snapshot": "30.4.1", - "jest-util": "30.4.1", + "jest-haste-map": "30.5.0", + "jest-message-util": "30.5.0", + "jest-mock": "30.5.0", + "jest-regex-util": "30.5.0", + "jest-resolve": "30.5.0", + "jest-snapshot": "30.5.0", + "jest-util": "30.5.0", "slash": "^3.0.0", "strip-bom": "^4.0.0" }, @@ -6883,9 +6971,9 @@ } }, "node_modules/jest-snapshot": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-30.4.1.tgz", - "integrity": "sha512-tEOkkfOMppUyeiHwjZswOQ3lcnoTnws/q5FnGIaeIh/jmoU0ZlgMYRR8sTlTj+nNGCoJ0RDq6SfxGxCsyMTPmw==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-30.5.0.tgz", + "integrity": "sha512-pZWETdcqmKve9MDTE/AX6RaeAbRhzKhhAXGozAW8Pg2FfOSdUrQ/7D+VdwE3fLQsqjpITXJVQvYVZoMEzrUl0Q==", "dev": true, "license": "MIT", "dependencies": { @@ -6894,20 +6982,20 @@ "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.27.1", "@babel/types": "^7.27.3", - "@jest/expect-utils": "30.4.1", - "@jest/get-type": "30.1.0", - "@jest/snapshot-utils": "30.4.1", - "@jest/transform": "30.4.1", - "@jest/types": "30.4.1", + "@jest/expect-utils": "30.5.0", + "@jest/get-type": "30.5.0", + "@jest/snapshot-utils": "30.5.0", + "@jest/transform": "30.5.0", + "@jest/types": "30.5.0", "babel-preset-current-node-syntax": "^1.2.0", "chalk": "^4.1.2", - "expect": "30.4.1", + "expect": "30.5.0", "graceful-fs": "^4.2.11", - "jest-diff": "30.4.1", - "jest-matcher-utils": "30.4.1", - "jest-message-util": "30.4.1", - "jest-util": "30.4.1", - "pretty-format": "30.4.1", + "jest-diff": "30.5.0", + "jest-matcher-utils": "30.5.0", + "jest-message-util": "30.5.0", + "jest-util": "30.5.0", + "pretty-format": "30.5.0", "semver": "^7.7.2", "synckit": "^0.11.8" }, @@ -6949,13 +7037,13 @@ } }, "node_modules/jest-util": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.4.1.tgz", - "integrity": "sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.5.0.tgz", + "integrity": "sha512-lzU4aGUWaS+2X/B0CmgheDasfnsVlRfZh/rNQxB9b9s8cSYUq5BcqdQA95ld+KqJXBUVVt1sqnMQ2T3OxIalmg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.4.1", + "@jest/types": "30.5.0", "@types/node": "*", "chalk": "^4.1.2", "ci-info": "^4.2.0", @@ -7000,18 +7088,18 @@ } }, "node_modules/jest-validate": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-30.4.1.tgz", - "integrity": "sha512-PDWi4SOwLnwqNDfHZjOcsEFyZ4fc/2W2gVL3DEoyqnB6jCQMLRtfBong8s6omIw3lI0HWOus12xfnFmQtjW3fw==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-30.5.0.tgz", + "integrity": "sha512-N/hsPYKgBSzBeVZ2RHCs3yvBbTZNPX7Be8q33zhyo/yeFneBL1swzly31LYU4LJ3zJM9e8TxSM8IYLo2SDJZYQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/get-type": "30.1.0", - "@jest/types": "30.4.1", + "@jest/get-type": "30.5.0", + "@jest/types": "30.5.0", "camelcase": "^6.3.0", "chalk": "^4.1.2", "leven": "^3.1.0", - "pretty-format": "30.4.1" + "pretty-format": "30.5.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -7064,19 +7152,19 @@ } }, "node_modules/jest-watcher": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-30.4.1.tgz", - "integrity": "sha512-/l9UonmvCwjHH7d2h3iAwIloLc1H0S8mJZ/LNK3i86hqwPAz8otUJjP9MfYtz9Tt77Su5FD2xGjZn8d31IZHlw==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-30.5.0.tgz", + "integrity": "sha512-ujjnEoL4Uu+Swu3WRwYenWMB9JMEiD2T3OypnveMJ64S/1r/5G8eC4OQ1wEOgYWQqMi+mT+buzccflCHr/XJ9Q==", "dev": true, "license": "MIT", "dependencies": { - "@jest/test-result": "30.4.1", - "@jest/types": "30.4.1", + "@jest/test-result": "30.5.0", + "@jest/types": "30.5.0", "@types/node": "*", "ansi-escapes": "^4.3.2", "chalk": "^4.1.2", "emittery": "^0.13.1", - "jest-util": "30.4.1", + "jest-util": "30.5.0", "string-length": "^4.0.2" }, "engines": { @@ -7117,15 +7205,15 @@ } }, "node_modules/jest-worker": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-30.4.1.tgz", - "integrity": "sha512-SHynN/q/QD++iNyvMdy+WMmbCGk8jIsNcRxycXbWubSOhvo6T+j2afcfUSl+3hYsiBebOTo0cT7c2H7CXugu1g==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-30.5.0.tgz", + "integrity": "sha512-7kFk/607EoynNHLJa20daivkElM+c9PrCLduYy6AlMkYrXbh5TmVtW1BLXE05Y8baAFsJHMoC3xs2QRRTotwLw==", "dev": true, "license": "MIT", "dependencies": { "@types/node": "*", "@ungap/structured-clone": "^1.3.0", - "jest-util": "30.4.1", + "jest-util": "30.5.0", "merge-stream": "^2.0.0", "supports-color": "^8.1.1" }, @@ -7167,9 +7255,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", - "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", "dev": true, "funding": [ { @@ -7217,21 +7305,20 @@ "license": "MIT" }, "node_modules/json-schema-to-typescript": { - "version": "15.0.4", - "resolved": "https://registry.npmjs.org/json-schema-to-typescript/-/json-schema-to-typescript-15.0.4.tgz", - "integrity": "sha512-Su9oK8DR4xCmDsLlyvadkXzX6+GGXJpbhwoLtOGArAG61dvbW4YQmSEno2y66ahpIdmLMg6YUf/QHLgiwvkrHQ==", + "version": "16.0.0", + "resolved": "https://registry.npmjs.org/json-schema-to-typescript/-/json-schema-to-typescript-16.0.0.tgz", + "integrity": "sha512-Ah6kK4q6SjlMzWBH1eNOQkdRh5Qhr5T/RCFwfjQqWZA7UDW+N47A8wfyoiN0L884s4L4bAEI54IjIuN3/u0B/A==", "dev": true, "license": "MIT", "dependencies": { - "@apidevtools/json-schema-ref-parser": "^11.5.5", + "@apidevtools/json-schema-ref-parser": "^11.9.3", "@types/json-schema": "^7.0.15", - "@types/lodash": "^4.17.7", - "is-glob": "^4.0.3", - "js-yaml": "^4.1.0", - "lodash": "^4.17.21", + "@types/lodash": "^4.17.25", + "js-yaml": "^5.2.3", + "lodash": "^4.18.1", "minimist": "^1.2.8", - "prettier": "^3.2.5", - "tinyglobby": "^0.2.9" + "prettier": "^3.9.6", + "tinyglobby": "^0.2.17" }, "bin": { "json2ts": "dist/src/cli.js" @@ -7240,6 +7327,29 @@ "node": ">=16.0.0" } }, + "node_modules/json-schema-to-typescript/node_modules/js-yaml": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.4.1.tgz", + "integrity": "sha512-28R/k+NAjeuf7+CKlTxWZVExJGwVVLwY06DgEnOMz2gEpfNkDcD7QvyiVPT0xy0XXhU8vHsd4Ot42OOPdJG7dQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.mjs" + } + }, "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", @@ -7247,9 +7357,9 @@ "license": "MIT" }, "node_modules/json-with-bigint": { - "version": "3.5.8", - "resolved": "https://registry.npmjs.org/json-with-bigint/-/json-with-bigint-3.5.8.tgz", - "integrity": "sha512-eq/4KP6K34kwa7TcFdtvnftvHCD9KvHOGGICWwMFc4dOOKF5t4iYqnfLK8otCRCRv06FXOzGGyqE8h8ElMvvdw==", + "version": "3.5.12", + "resolved": "https://registry.npmjs.org/json-with-bigint/-/json-with-bigint-3.5.12.tgz", + "integrity": "sha512-uwbF/wSSuOgC7qqlq27Xp5B6a2MHVug3t0idZdTqu0JnlFvgJuH7ju+KAk/J06C7GfhoYy2gnb9wz2INqcne7w==", "dev": true, "license": "MIT" }, @@ -7267,9 +7377,9 @@ } }, "node_modules/jsonfile": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", - "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", "license": "MIT", "dependencies": { "universalify": "^2.0.0" @@ -7296,16 +7406,15 @@ "license": "MIT" }, "node_modules/lint-staged": { - "version": "17.0.8", - "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-17.0.8.tgz", - "integrity": "sha512-B2P/d+jVW0UXOQ0MVMLrB/9ydA1P+zz6jYfdrbbEd9ur3S2rcbduFWKiUCC02Sm5hbC8nrm7y24WuYMG54HfxA==", + "version": "17.4.1", + "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-17.4.1.tgz", + "integrity": "sha512-FmJeudcalbSfg1du+JCfvi5vS6Qt08KgbfLWiHinbef+2JJwUZwAWVoaO1AcJVUTWPfk0t30PMQNwPAeCzYQ+Q==", "dev": true, "license": "MIT", "dependencies": { - "listr2": "^10.2.1", - "picomatch": "^4.0.4", + "picomatch": "^4.0.7", "string-argv": "^0.3.2", - "tinyexec": "^1.2.4" + "tinyexec": "^1.3.0" }, "bin": { "lint-staged": "bin/lint-staged.js" @@ -7320,87 +7429,6 @@ "yaml": "^2.9.0" } }, - "node_modules/listr2": { - "version": "10.2.1", - "resolved": "https://registry.npmjs.org/listr2/-/listr2-10.2.1.tgz", - "integrity": "sha512-7I5knELsJKTUjXG+A6BkKAiGkW1i25fNa/xlUl9hFtk15WbE9jndA89xu5FzQKrY5llajE1hfZZFMILXkDHk/Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "cli-truncate": "^5.2.0", - "eventemitter3": "^5.0.4", - "log-update": "^6.1.0", - "rfdc": "^1.4.1", - "wrap-ansi": "^10.0.0" - }, - "engines": { - "node": ">=22.13.0" - } - }, - "node_modules/listr2/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/listr2/node_modules/string-width": { - "version": "8.2.1", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.1.tgz", - "integrity": "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==", - "dev": true, - "license": "MIT", - "dependencies": { - "get-east-asian-width": "^1.5.0", - "strip-ansi": "^7.1.2" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/listr2/node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.2.2" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/listr2/node_modules/wrap-ansi": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-10.0.0.tgz", - "integrity": "sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.3", - "string-width": "^8.2.0", - "strip-ansi": "^7.1.2" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, "node_modules/load-json-file": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-4.0.0.tgz", @@ -7510,88 +7538,6 @@ "dev": true, "license": "MIT" }, - "node_modules/log-update": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", - "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-escapes": "^7.0.0", - "cli-cursor": "^5.0.0", - "slice-ansi": "^7.1.0", - "strip-ansi": "^7.1.0", - "wrap-ansi": "^9.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/log-update/node_modules/ansi-escapes": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", - "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", - "dev": true, - "license": "MIT", - "dependencies": { - "environment": "^1.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/log-update/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/log-update/node_modules/slice-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", - "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "is-fullwidth-code-point": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/slice-ansi?sponsor=1" - } - }, - "node_modules/log-update/node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.2.2" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -7603,15 +7549,15 @@ } }, "node_modules/make-asynchronous": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/make-asynchronous/-/make-asynchronous-1.0.1.tgz", - "integrity": "sha512-T9BPOmEOhp6SmV25SwLVcHK4E6JyG/coH3C6F1NjNXSziv/fd4GmsqMk8YR6qpPOswfaOCApSNkZv6fxoaYFcQ==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/make-asynchronous/-/make-asynchronous-1.1.0.tgz", + "integrity": "sha512-ayF7iT+44LXdxJLTrTd3TLQpFDDvPCBxXxbv+pMUSuHA5Q8zyAfwkRP6aHHwNVFBUFWtxAHqwNJxF8vMZLAbVg==", "dev": true, "license": "MIT", "dependencies": { "p-event": "^6.0.0", "type-fest": "^4.6.0", - "web-worker": "1.2.0" + "web-worker": "^1.5.0" }, "engines": { "node": ">=18" @@ -7643,16 +7589,6 @@ "dev": true, "license": "ISC" }, - "node_modules/makeerror": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", - "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "tmpl": "1.0.5" - } - }, "node_modules/marked": { "version": "15.0.12", "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz", @@ -7768,61 +7704,34 @@ } }, "node_modules/mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/mimic-function": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", - "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", + "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", "dev": true, "license": "MIT", "engines": { - "node": ">=18" + "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^2.0.2" + "brace-expansion": "^5.0.8" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/minimatch/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/minimatch/node_modules/brace-expansion": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", - "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, "node_modules/minimist": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", @@ -7907,6 +7816,13 @@ "dev": true, "license": "MIT" }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "license": "MIT" + }, "node_modules/node-emoji": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-2.2.0.tgz", @@ -7931,9 +7847,9 @@ "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.50", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz", - "integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==", + "version": "2.0.54", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz", + "integrity": "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==", "dev": true, "license": "MIT", "engines": { @@ -7979,22 +7895,6 @@ "node": ">=4" } }, - "node_modules/nodemon/node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.5" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/nodemon/node_modules/supports-color": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", @@ -8034,9 +7934,9 @@ } }, "node_modules/normalize-url": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-9.0.0.tgz", - "integrity": "sha512-z9nC87iaZXXySbWWtTHfCFJyFvKaUAW6lODhikG7ILSbVgmwuFjUqkgnheHvAUcGedO29e2QGBRXMUD64aurqQ==", + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-9.0.1.tgz", + "integrity": "sha512-ARftfC5HdUNu9jJeL8pHj8debUIHA2b91FizCoMzY4lG6dDX13jdvTK0TBe24IBDRf2HvJSzzwEPvmbkQWHRSg==", "dev": true, "license": "MIT", "engines": { @@ -8047,9 +7947,9 @@ } }, "node_modules/npm": { - "version": "11.18.0", - "resolved": "https://registry.npmjs.org/npm/-/npm-11.18.0.tgz", - "integrity": "sha512-T67M4L5wNm0cZ7EBLErcEkY1SmzEW/WJ+SADBzsFUY1UdAPfFHXFQtZ6SEXiK0+vzXysCvAsepbMaBTwnrAD+w==", + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/npm/-/npm-11.19.1.tgz", + "integrity": "sha512-ztsxKxt/kkIaAs+2i0GU6I+DRmUdrNasxTZKJe9TCdSjKxlhah/4r/hl5ygMD6XAg1qZ9c2TNomR4qgOydp10g==", "bundleDependencies": [ "@isaacs/string-locale-compare", "@npmcli/arborist", @@ -8128,7 +8028,7 @@ ], "dependencies": { "@isaacs/string-locale-compare": "^1.1.0", - "@npmcli/arborist": "^9.9.0", + "@npmcli/arborist": "^9.9.1", "@npmcli/config": "^10.12.0", "@npmcli/fs": "^5.0.0", "@npmcli/map-workspaces": "^5.0.3", @@ -8153,11 +8053,11 @@ "is-cidr": "^6.0.4", "json-parse-even-better-errors": "^5.0.0", "libnpmaccess": "^10.0.3", - "libnpmdiff": "^8.1.11", - "libnpmexec": "^10.3.1", - "libnpmfund": "^7.0.25", + "libnpmdiff": "^8.1.12", + "libnpmexec": "^10.3.2", + "libnpmfund": "^7.0.26", "libnpmorg": "^8.0.1", - "libnpmpack": "^9.1.11", + "libnpmpack": "^9.1.13", "libnpmpublish": "^11.2.0", "libnpmsearch": "^9.0.1", "libnpmteam": "^8.0.2", @@ -8186,7 +8086,7 @@ "spdx-expression-parse": "^4.0.0", "ssri": "^13.0.1", "supports-color": "^10.2.2", - "tar": "^7.5.19", + "tar": "^7.5.22", "text-table": "~0.2.0", "tiny-relative-date": "^2.0.2", "treeverse": "^3.0.0", @@ -8198,20 +8098,37 @@ "npx": "bin/npx-cli.js" }, "engines": { - "node": "^20.17.0 || >=22.9.0" + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm-run-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", + "integrity": "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0", + "unicorn-magic": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/npm-run-path": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", - "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", "dev": true, "license": "MIT", - "dependencies": { - "path-key": "^3.0.0" - }, "engines": { - "node": ">=8" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/npm/node_modules/@gar/promise-retry": { @@ -8258,7 +8175,7 @@ } }, "node_modules/npm/node_modules/@npmcli/arborist": { - "version": "9.9.0", + "version": "9.9.1", "dev": true, "inBundle": true, "license": "ISC", @@ -8651,7 +8568,7 @@ } }, "node_modules/npm/node_modules/brace-expansion": { - "version": "5.0.7", + "version": "5.0.9", "dev": true, "inBundle": true, "license": "MIT", @@ -8659,7 +8576,7 @@ "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/npm/node_modules/cacache": { @@ -8943,7 +8860,7 @@ } }, "node_modules/npm/node_modules/ip-address": { - "version": "10.2.0", + "version": "10.5.0", "dev": true, "inBundle": true, "license": "MIT", @@ -9025,12 +8942,12 @@ } }, "node_modules/npm/node_modules/libnpmdiff": { - "version": "8.1.11", + "version": "8.1.12", "dev": true, "inBundle": true, "license": "ISC", "dependencies": { - "@npmcli/arborist": "^9.9.0", + "@npmcli/arborist": "^9.9.1", "@npmcli/installed-package-contents": "^4.0.0", "binary-extensions": "^3.0.0", "diff": "^8.0.2", @@ -9044,13 +8961,13 @@ } }, "node_modules/npm/node_modules/libnpmexec": { - "version": "10.3.1", + "version": "10.3.2", "dev": true, "inBundle": true, "license": "ISC", "dependencies": { "@gar/promise-retry": "^1.0.0", - "@npmcli/arborist": "^9.9.0", + "@npmcli/arborist": "^9.9.1", "@npmcli/package-json": "^7.0.0", "@npmcli/run-script": "^10.0.0", "ci-info": "^4.0.0", @@ -9067,12 +8984,12 @@ } }, "node_modules/npm/node_modules/libnpmfund": { - "version": "7.0.25", + "version": "7.0.26", "dev": true, "inBundle": true, "license": "ISC", "dependencies": { - "@npmcli/arborist": "^9.9.0" + "@npmcli/arborist": "^9.9.1" }, "engines": { "node": "^20.17.0 || >=22.9.0" @@ -9092,12 +9009,12 @@ } }, "node_modules/npm/node_modules/libnpmpack": { - "version": "9.1.11", + "version": "9.1.13", "dev": true, "inBundle": true, "license": "ISC", "dependencies": { - "@npmcli/arborist": "^9.9.0", + "@npmcli/arborist": "^9.9.1", "@npmcli/run-script": "^10.0.0", "npm-package-arg": "^13.0.0", "pacote": "^21.0.2" @@ -9802,7 +9719,7 @@ } }, "node_modules/npm/node_modules/tar": { - "version": "7.5.19", + "version": "7.5.22", "dev": true, "inBundle": true, "license": "BlueOak-1.0.0", @@ -9898,7 +9815,7 @@ } }, "node_modules/npm/node_modules/undici": { - "version": "6.27.0", + "version": "6.28.0", "dev": true, "inBundle": true, "license": "MIT", @@ -9976,27 +9893,17 @@ "node": ">=0.10.0" } }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, - "license": "ISC", - "dependencies": { - "wrappy": "1" - } - }, "node_modules/onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", + "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", "dev": true, "license": "MIT", "dependencies": { - "mimic-fn": "^2.1.0" + "mimic-fn": "^4.0.0" }, "engines": { - "node": ">=6" + "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -10093,9 +10000,9 @@ } }, "node_modules/p-map": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz", - "integrity": "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==", + "version": "7.0.7", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.7.tgz", + "integrity": "sha512-VaWRu2i4FJNRtiRWCuuQRgfQ1B7a6+gMSrO+3j0EQi/k0ULfS9kosRxGoiqwzIjZTDI02tGfk5mXXltLg6QtfQ==", "dev": true, "license": "MIT", "engines": { @@ -10227,16 +10134,6 @@ "node": ">=8" } }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -10248,28 +10145,31 @@ } }, "node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" }, "engines": { - "node": ">=16 || 14 >=14.18" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, "node_modules/path-scurry/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", "dev": true, - "license": "ISC" + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } }, "node_modules/path-type": { "version": "4.0.0", @@ -10289,9 +10189,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", "engines": { @@ -10422,9 +10322,9 @@ } }, "node_modules/prettier": { - "version": "3.9.4", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.4.tgz", - "integrity": "sha512-yWG/o/4oJfo036EKAfK6ACAoDOfHeRHx4tuxkfBZiauURiaSmYwlpOr5LQqKtIkRD2z1PLteme2WoxEnj4tHTg==", + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", "dev": true, "license": "MIT", "bin": { @@ -10438,25 +10338,25 @@ } }, "node_modules/pretty-format": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.4.1.tgz", - "integrity": "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.5.0.tgz", + "integrity": "sha512-mzNzBErpHwM0zpmWS7ExOv62yhQhvd546nUuFqVR0dmnJB59tfrw9sjDF0DJknwsr59OXP0buwJ7PaKguczHSg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/schemas": "30.4.1", - "ansi-styles": "^5.2.0", - "react-is-18": "npm:react-is@^18.3.1", - "react-is-19": "npm:react-is@^19.2.5" + "@jest/react-is-18": "npm:react-is@^18.3.1", + "@jest/react-is-19": "npm:react-is@^19.2.5", + "@jest/schemas": "30.5.0", + "ansi-styles": "^5.2.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/pretty-ms": { - "version": "9.3.0", - "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz", - "integrity": "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==", + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.1.tgz", + "integrity": "sha512-HzMy3Geq23nVALD/M2LliU+F+M+gVNsvkQWWqeBZ8HDiCgzo6YPJ/Omrmtq24EFrIsk0a3EkQGEd7bDOo+IhGA==", "dev": true, "license": "MIT", "dependencies": { @@ -10492,6 +10392,24 @@ "dev": true, "license": "ISC" }, + "node_modules/proxy-agent-negotiate": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-agent-negotiate/-/proxy-agent-negotiate-1.1.0.tgz", + "integrity": "sha512-N8IBcM3UgCVzz2L2Lqv8DVntDnnC8/hiV4nEDUPkqq72TPUgYWjQc+bdZlBPZK9LzPAvOY//gAt0S0DApoOXWQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "kerberos": "^2.0.0" + }, + "peerDependenciesMeta": { + "kerberos": { + "optional": true + } + } + }, "node_modules/pstree.remy": { "version": "1.1.8", "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", @@ -10549,22 +10467,6 @@ "node": ">=0.10.0" } }, - "node_modules/react-is-18": { - "name": "react-is", - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "dev": true, - "license": "MIT" - }, - "node_modules/react-is-19": { - "name": "react-is", - "version": "19.2.7", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.7.tgz", - "integrity": "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==", - "dev": true, - "license": "MIT" - }, "node_modules/read-package-up": { "version": "11.0.0", "resolved": "https://registry.npmjs.org/read-package-up/-/read-package-up-11.0.0.tgz", @@ -10656,6 +10558,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/read-package-up/node_modules/unicorn-magic": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.1.0.tgz", + "integrity": "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/read-pkg": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-10.1.0.tgz", @@ -10708,9 +10623,9 @@ } }, "node_modules/read-pkg/node_modules/type-fest": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.5.0.tgz", - "integrity": "sha512-PlBfpQwiUvGViBNX84Yxwjsdhd1TUlXr6zjX7eoirtCPIr08NAmxwa+fcYBTeRQxHo9YC9wwF3m9i700sHma8g==", + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.8.0.tgz", + "integrity": "sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA==", "dev": true, "license": "(MIT OR CC0-1.0)", "dependencies": { @@ -10833,59 +10748,6 @@ "node": ">=8" } }, - "node_modules/restore-cursor": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", - "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", - "dev": true, - "license": "MIT", - "dependencies": { - "onetime": "^7.0.0", - "signal-exit": "^4.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/restore-cursor/node_modules/onetime": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", - "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "mimic-function": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/restore-cursor/node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/rfdc": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", - "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", - "dev": true, - "license": "MIT" - }, "node_modules/safe-buffer": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", @@ -10900,9 +10762,9 @@ "license": "MIT" }, "node_modules/semantic-release": { - "version": "25.0.5", - "resolved": "https://registry.npmjs.org/semantic-release/-/semantic-release-25.0.5.tgz", - "integrity": "sha512-mn61SUJwtM8ThrWn2WmgLVpwVJeG/hPSupua1psdMoufmwRIPyvRLkRkL0JDXkP67OntlLWUYnBnfVc8EDO3/g==", + "version": "25.0.9", + "resolved": "https://registry.npmjs.org/semantic-release/-/semantic-release-25.0.9.tgz", + "integrity": "sha512-bxve7csK0/Txr++CkfrmV+X1r4jqiSOw2WsSad9E2S68R+ZfLBwDn8IceM8WfiOmKQIHgsQc1cNA8Dzg7U75pg==", "dev": true, "license": "MIT", "dependencies": { @@ -10942,98 +10804,14 @@ "node": "^22.14.0 || >= 24.10.0" } }, - "node_modules/semantic-release/node_modules/execa": { - "version": "9.6.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz", - "integrity": "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@sindresorhus/merge-streams": "^4.0.0", - "cross-spawn": "^7.0.6", - "figures": "^6.1.0", - "get-stream": "^9.0.0", - "human-signals": "^8.0.1", - "is-plain-obj": "^4.1.0", - "is-stream": "^4.0.1", - "npm-run-path": "^6.0.0", - "pretty-ms": "^9.2.0", - "signal-exit": "^4.1.0", - "strip-final-newline": "^4.0.0", - "yoctocolors": "^2.1.1" - }, - "engines": { - "node": "^18.19.0 || >=20.5.0" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" - } - }, - "node_modules/semantic-release/node_modules/execa/node_modules/get-stream": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz", - "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@sec-ant/readable-stream": "^0.4.1", - "is-stream": "^4.0.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/semantic-release/node_modules/human-signals": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.1.tgz", - "integrity": "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/semantic-release/node_modules/is-stream": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", - "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/semantic-release/node_modules/npm-run-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", - "integrity": "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^4.0.0", - "unicorn-magic": "^0.3.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/semantic-release/node_modules/path-key": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", - "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "node_modules/semantic-release/node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", "dev": true, "license": "MIT", "engines": { - "node": ">=12" + "node": ">=10" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -11051,42 +10829,16 @@ "type-fest": "^5.2.0" }, "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/semantic-release/node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/semantic-release/node_modules/strip-final-newline": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz", - "integrity": "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" + "node": ">=20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/semantic-release/node_modules/type-fest": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.5.0.tgz", - "integrity": "sha512-PlBfpQwiUvGViBNX84Yxwjsdhd1TUlXr6zjX7eoirtCPIr08NAmxwa+fcYBTeRQxHo9YC9wwF3m9i700sHma8g==", + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.8.0.tgz", + "integrity": "sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA==", "dev": true, "license": "(MIT OR CC0-1.0)", "dependencies": { @@ -11099,23 +10851,10 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/semantic-release/node_modules/unicorn-magic": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", - "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/semver": { - "version": "7.8.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.2.tgz", - "integrity": "sha512-c8jsqUZm3omBOI66G90z1Dyw5z622G8oLG+omfsHBJf3CWQTlOcwOjvOG6wtiNfW6anKm/eA39LMwMtMez2TiQ==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, "license": "ISC", "bin": { @@ -11162,11 +10901,16 @@ } }, "node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "dev": true, - "license": "ISC" + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } }, "node_modules/signale": { "version": "1.4.0", @@ -11327,36 +11071,6 @@ "node": ">=8" } }, - "node_modules/slice-ansi": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-8.0.0.tgz", - "integrity": "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.3", - "is-fullwidth-code-point": "^5.1.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/chalk/slice-ansi?sponsor=1" - } - }, - "node_modules/slice-ansi/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -11367,17 +11081,6 @@ "node": ">=0.10.0" } }, - "node_modules/source-map-support": { - "version": "0.5.13", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", - "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", - "dev": true, - "license": "MIT", - "dependencies": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - } - }, "node_modules/spawn-error-forwarder": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/spawn-error-forwarder/-/spawn-error-forwarder-1.0.0.tgz", @@ -11421,6 +11124,16 @@ "dev": true, "license": "CC0-1.0" }, + "node_modules/split2": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-1.0.0.tgz", + "integrity": "sha512-NKywug4u4pX/AZBB1FCPzZ6/7O+Xhz1qMVbzTvvKvikjO99oPN87SkK08mEY9P63/5lWjK+wgOOgApnTg5r6qg==", + "dev": true, + "license": "ISC", + "dependencies": { + "through2": "~2.0.0" + } + }, "node_modules/sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", @@ -11441,6 +11154,16 @@ "node": ">=10" } }, + "node_modules/stack-utils/node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/stream-combiner2": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/stream-combiner2/-/stream-combiner2-1.1.1.tgz", @@ -11526,23 +11249,13 @@ "dev": true, "license": "MIT" }, - "node_modules/string-width-cjs/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/string-width/node_modules/strip-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", - "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", "license": "MIT", "dependencies": { - "ansi-regex": "^6.0.1" + "ansi-regex": "^6.2.2" }, "engines": { "node": ">=12" @@ -11607,13 +11320,16 @@ } }, "node_modules/strip-final-newline": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", - "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz", + "integrity": "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==", "dev": true, "license": "MIT", "engines": { - "node": ">=6" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/strip-json-comments": { @@ -11762,18 +11478,18 @@ } }, "node_modules/test-exclude": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", - "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.2.tgz", + "integrity": "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==", "dev": true, "license": "ISC", "dependencies": { "@istanbuljs/schema": "^0.1.2", - "glob": "^7.1.4", - "minimatch": "^3.0.4" + "glob": "^10.4.1", + "minimatch": "^10.2.2" }, "engines": { - "node": ">=8" + "node": ">=18" } }, "node_modules/test-exclude/node_modules/balanced-match": { @@ -11784,49 +11500,75 @@ "license": "MIT" }, "node_modules/test-exclude/node_modules/brace-expansion": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "balanced-match": "^1.0.0" } }, "node_modules/test-exclude/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "dev": true, "license": "ISC", "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" }, - "engines": { - "node": "*" + "bin": { + "glob": "dist/esm/bin.mjs" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/test-exclude/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "node_modules/test-exclude/node_modules/glob/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "dev": true, "license": "ISC", "dependencies": { - "brace-expansion": "^1.1.7" + "brace-expansion": "^2.0.2" }, "engines": { - "node": "*" + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/test-exclude/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/test-exclude/node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/thenify": { @@ -11880,9 +11622,9 @@ } }, "node_modules/tinyexec": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", - "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", "dev": true, "license": "MIT", "engines": { @@ -11890,14 +11632,14 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -11906,31 +11648,6 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/tinyglobby/node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/tmpl": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", - "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", - "dev": true, - "license": "BSD-3-Clause" - }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -11968,9 +11685,9 @@ } }, "node_modules/ts-jest": { - "version": "29.4.11", - "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.11.tgz", - "integrity": "sha512-IrFl7l9AuB/qrNw5quqvAv/hmKMb8dhWOH4jQOGo0Oq8tCeo1O86/iTFG1FaRimgUkF13l4PcepO8ATFT6Ns4g==", + "version": "29.4.12", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.12.tgz", + "integrity": "sha512-Ov6ClY53Fflh6BGAnY2DlTq1hYDrTycz2PVTXBWFW2CU+9zrEqAp9fWdGXl42EXO5RLSFAcAZ2JFKbP+zBTFfw==", "dev": true, "license": "MIT", "dependencies": { @@ -11980,7 +11697,7 @@ "json5": "^2.2.3", "lodash.memoize": "^4.1.2", "make-error": "^1.3.6", - "semver": "^7.8.0", + "semver": "^7.8.5", "type-fest": "^4.41.0", "yargs-parser": "^21.1.1" }, @@ -12140,13 +11857,13 @@ "license": "MIT" }, "node_modules/undici": { - "version": "6.27.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.27.0.tgz", - "integrity": "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", "dev": true, "license": "MIT", "engines": { - "node": ">=18.17" + "node": ">=20.18.1" } }, "node_modules/undici-types": { @@ -12167,9 +11884,9 @@ } }, "node_modules/unicorn-magic": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.1.0.tgz", - "integrity": "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==", + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", + "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", "dev": true, "license": "MIT", "engines": { @@ -12250,9 +11967,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz", + "integrity": "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==", "dev": true, "funding": [ { @@ -12330,20 +12047,10 @@ "spdx-expression-parse": "^3.0.0" } }, - "node_modules/walker": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", - "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "makeerror": "1.0.12" - } - }, "node_modules/web-worker": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/web-worker/-/web-worker-1.2.0.tgz", - "integrity": "sha512-PgF341avzqyx60neE9DD+XS26MMNMoUQRz9NOZwW32nPQrF6p77f1htcnjBSEV8BGMKZ16choqUG4hyI0Hx7mA==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/web-worker/-/web-worker-1.5.0.tgz", + "integrity": "sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==", "dev": true, "license": "Apache-2.0" }, @@ -12444,16 +12151,6 @@ "dev": true, "license": "MIT" }, - "node_modules/wrap-ansi-cjs/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/wrap-ansi-cjs/node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -12482,12 +12179,12 @@ } }, "node_modules/wrap-ansi/node_modules/strip-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", - "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", "license": "MIT", "dependencies": { - "ansi-regex": "^6.0.1" + "ansi-regex": "^6.2.2" }, "engines": { "node": ">=12" @@ -12496,13 +12193,6 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, - "license": "ISC" - }, "node_modules/write-file-atomic": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-5.0.1.tgz", @@ -12517,19 +12207,6 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, - "node_modules/write-file-atomic/node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", @@ -12575,16 +12252,16 @@ } }, "node_modules/yargs": { - "version": "18.0.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", - "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", + "version": "18.1.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.1.0.tgz", + "integrity": "sha512-2rAgRKu54VsHkqI0/tYkmluGXHD4KW7yZoycuqDQ15QOTnc2VVfy0nN/1eMhnQLO00A+dwtK20xuCnc1YGeUyg==", "dev": true, "license": "MIT", "dependencies": { "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", - "string-width": "^7.2.0", + "string-width": "^8.2.1", "y18n": "^5.0.5", "yargs-parser": "^22.0.0" }, @@ -12602,6 +12279,39 @@ "node": ">=12" } }, + "node_modules/yargs/node_modules/string-width": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz", + "integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yargs/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/yargs/node_modules/yargs-parser": { "version": "22.0.0", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", @@ -12636,9 +12346,9 @@ } }, "node_modules/yoctocolors": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz", - "integrity": "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.2.0.tgz", + "integrity": "sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg==", "dev": true, "license": "MIT", "engines": { diff --git a/package.json b/package.json index ca42af5..25b0a1e 100644 --- a/package.json +++ b/package.json @@ -49,36 +49,36 @@ "prepare": "husky install" }, "devDependencies": { - "@commitlint/cli": "^21.2.0", - "@commitlint/config-conventional": "^21.2.0", + "@commitlint/cli": "^21.2.2", + "@commitlint/config-conventional": "^21.2.2", "@semantic-release/commit-analyzer": "^13.0.1", "@semantic-release/github": "^12.0.9", "@semantic-release/npm": "^13.1.5", "@semantic-release/release-notes-generator": "^14.1.1", "@types/fs-extra": "^11.0.4", "@types/jest": "^30.0.0", - "@types/node": "^26.1.0", + "@types/node": "^26.4.0", "@types/progress": "^2.0.7", "husky": "^9.1.7", - "jest": "^30.4.2", - "json-schema-to-typescript": "^15.0.4", - "lint-staged": "^17.0.8", + "jest": "^30.5.0", + "json-schema-to-typescript": "^16.0.0", + "lint-staged": "^17.4.1", "nodemon": "^3.1.14", - "prettier": "^3.9.4", - "semantic-release": "^25.0.5", - "ts-jest": "^29.4.11", + "prettier": "^3.9.6", + "semantic-release": "^25.0.9", + "ts-jest": "^29.4.12", "ts-node": "^10.9.2", "typescript": "^6.0.3" }, "dependencies": { - "@inquirer/prompts": "^8.5.2", + "@inquirer/prompts": "^8.7.0", "ajv": "^8.20.0", "ajv-formats": "^3.0.1", "boxen": "^8.0.1", "colorette": "^2.0.20", "commander": "^15.0.0", "consola": "^3.4.2", - "fs-extra": "^11.3.6", + "fs-extra": "^11.4.0", "progress": "^2.0.3", "simple-git": "^3.36.0" } From 6c7d02993f46134099d5bda946d345c9a8ad38ab Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Sat, 29 Aug 2026 21:03:28 -0500 Subject: [PATCH 17/27] test(audit): make fixtures portable on Windows --- src/handlers/audit.test.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/handlers/audit.test.ts b/src/handlers/audit.test.ts index f25e2f3..33c4f86 100644 --- a/src/handlers/audit.test.ts +++ b/src/handlers/audit.test.ts @@ -1,6 +1,7 @@ import type { SpawnSyncReturns } from 'node:child_process'; import { tmpdir } from 'node:os'; import { dirname, join, relative, resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; import audit, { executeCoreAudit, getAuditProjectRoot, @@ -21,6 +22,7 @@ const { mkdir, mkdtemp, realpath, rm, writeFile } = const repositoryRoot = process.cwd(); const registerPath = join(repositoryRoot, 'src/register.mjs'); +const registerUrl = pathToFileURL(registerPath).href; const cliPath = join(repositoryRoot, 'src/index.ts'); const fakeAuditRelativePath = './bin with spaces/fake audit.mjs'; const fakeAuditSource = ` @@ -139,7 +141,7 @@ function createSpawnResult( function runSourceCli(cwd: string, args: string[], input = ''): CliResult { const result = spawnSync( process.execPath, - ['--import', registerPath, cliPath, ...args], + ['--import', registerUrl, cliPath, ...args], { cwd, encoding: 'utf8', @@ -197,8 +199,9 @@ test('selects the last valid separate or inline root without rewriting args', () test('resolves the declared Core audit target through fake package metadata', async () => { const fixture = await createFakeCoreProject(); + const resolvedAuditPath = resolveCoreAuditBin(fixture.projectRoot); - expect(resolveCoreAuditBin(fixture.projectRoot)).toBe(fixture.auditPath); + expect(await realpath(resolvedAuditPath)).toBe(fixture.auditPath); expect(fixture.auditPath).toContain(' '); }); @@ -375,13 +378,16 @@ test('the handler resolves from --root and assigns Core status without logging', expect(process.exitCode).toBe(1); expect(spawnProcess).toHaveBeenCalledWith( '/node path/node', - [fixture.auditPath, ...args], + [expect.any(String), ...args], { cwd: callerRoot, shell: false, stdio: 'inherit', }, ); + expect(await realpath(spawnProcess.mock.calls[0][1][0])).toBe( + fixture.auditPath, + ); }); test('forwards human stdout, stderr, and stdin byte-for-byte', async () => { From 3ebd160f89117c09531cc0dfe81fd15105ef4b5c Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Sat, 29 Aug 2026 21:45:52 -0500 Subject: [PATCH 18/27] fix(cache): bound the remote ref lookup used for cache reuse --- docs/cli-reference.md | 7 ++ docs/hooks-and-cache.md | 4 +- jest.setup.cjs | 1 + src/handlers/componentCreate.test.ts | 8 +++ src/handlers/componentCreate.ts | 4 +- src/handlers/componentInstall.test.ts | 8 +++ src/handlers/componentInstall.ts | 8 ++- src/handlers/componentList.test.ts | 8 +++ src/handlers/componentList.ts | 9 ++- src/handlers/hofs/withEmulsifySystem.test.ts | 13 +++- src/handlers/hofs/withEmulsifySystem.ts | 7 +- src/handlers/systemInstall.test.ts | 12 ++-- src/handlers/systemInstall.ts | 2 +- src/index.ts | 16 +++++ src/types/handlers.d.ts | 9 +++ src/util/cache/cloneIntoCache.test.ts | 67 +++++++++++++++++--- src/util/cache/cloneIntoCache.ts | 33 +++++++++- test/e2e/root-help.txt | 4 ++ 18 files changed, 194 insertions(+), 26 deletions(-) diff --git a/docs/cli-reference.md b/docs/cli-reference.md index df74bf7..37b0e1a 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -165,10 +165,13 @@ Pass `--variant` to choose an exact variant platform expression instead of autom ```bash emulsify component list emulsify component ls +emulsify component list --refresh ``` Lists components available from the installed system and selected variant. Output uses the component structure followed by the component name, for example `atoms -> buttons`. +Pass `--refresh` to check the system's remote ref before listing. Without it, the command validates and reuses the local cache without touching the network. + ## `component install` ```bash @@ -183,11 +186,13 @@ Options: | `-f, --force` | Replace an existing component destination without prompting. | | `-a, --all` | Install all available components instead of one named component. | | `--dry-run` | Preview dependencies, destinations, overwrite behavior, and copy operations without changing files. | +| `--refresh` | Check the system's remote ref before reusing its local cache entry. | Examples: ```bash emulsify component install card +emulsify component install card --refresh emulsify component install card --dry-run emulsify component i accordion --force emulsify component install --all @@ -208,11 +213,13 @@ Options: | `-f, --format <format>` | Component format to generate. Supported values are `default` and `sdc`. | | `-y, --yes` | Replace an existing generated component without prompting. | | `--dry-run` | Preview destination and generated files without writing, removing, or creating files. | +| `--refresh` | Check the system's remote ref before reusing its local cache entry. | Examples: ```bash emulsify component create promo-card --directory molecules --format default +emulsify component create promo-card --directory molecules --format default --refresh emulsify component create promo-card --directory molecules --format default --dry-run emulsify component c teaser --directory molecules --format sdc --yes ``` diff --git a/docs/hooks-and-cache.md b/docs/hooks-and-cache.md index bc04088..d9a82d0 100644 --- a/docs/hooks-and-cache.md +++ b/docs/hooks-and-cache.md @@ -67,7 +67,9 @@ The project path, normalized repository URL, and checkout are hashed, so the ful After a successful clone, the CLI writes `.emulsify-cache.json` inside the cache entry. The sidecar records the repository, requested checkout, resolved Git ref, clone time, and a completion marker. It is written only after cloning and ref resolution succeed. -Before reusing an entry, the CLI validates the sidecar, repository, checkout, local `origin` fetch URL, and local `HEAD`. Missing, malformed, incomplete, or mismatched entries are removed and cloned again. When the remote is reachable, the CLI also compares a named checkout such as `main` (or the default remote `HEAD`) with the recorded resolved ref and re-clones when it has advanced. If that remote check is unavailable, a locally valid clone remains usable. +Before reusing an entry, the CLI validates the sidecar, repository, checkout, local `origin` fetch URL, and local `HEAD`. Missing, malformed, incomplete, or mismatched entries are removed and cloned again. Routine component commands perform only these local checks, so an installed system remains usable offline. + +`system install` checks remote freshness when reusing a cache entry. Component commands do so only when passed `--refresh`. The bounded lookup compares a named checkout such as `main` (or the default remote `HEAD`) with the recorded resolved ref and re-clones when it has advanced. If the remote check times out or is otherwise unavailable, a locally valid clone remains usable. To inspect how much would be removed without changing files, run: diff --git a/jest.setup.cjs b/jest.setup.cjs index cc5b8ce..00f15c7 100644 --- a/jest.setup.cjs +++ b/jest.setup.cjs @@ -4,6 +4,7 @@ jest.mock('simple-git', () => { branch: jest.fn(), getRemotes: jest.fn(), listRemote: jest.fn(), + env: jest.fn().mockReturnThis(), revparse: jest.fn(), checkout: jest.fn(), fetch: jest.fn().mockReturnThis(), diff --git a/src/handlers/componentCreate.test.ts b/src/handlers/componentCreate.test.ts index d3e5b52..b019500 100644 --- a/src/handlers/componentCreate.test.ts +++ b/src/handlers/componentCreate.test.ts @@ -315,6 +315,14 @@ describe('componentCreate', () => { ); }); + it('requests a remote freshness check when refresh is enabled', async () => { + await componentCreate('button', { directory: 'base', refresh: true }); + + expect(cloneIntoCacheMock).toHaveBeenCalledWith('systems', ['compound'], { + refresh: true, + }); + }); + it('creates a component non-interactively when flags provide format, directory, and yes', async () => { setStdinIsTTY(false); mockComponentExistsWithoutTemplateOverrides(); diff --git a/src/handlers/componentCreate.ts b/src/handlers/componentCreate.ts index fbc04b1..f7b4246 100644 --- a/src/handlers/componentCreate.ts +++ b/src/handlers/componentCreate.ts @@ -19,7 +19,9 @@ export default async function componentCreate( } // Load the configured system and variant before generating the local component. - const { variantConf } = await withEmulsifySystem('create components'); + const { variantConf } = await withEmulsifySystem('create components', { + refresh: options.refresh, + }); try { await generateComponent(variantConf, name, options); diff --git a/src/handlers/componentInstall.test.ts b/src/handlers/componentInstall.test.ts index 67cadf3..7eb333f 100644 --- a/src/handlers/componentInstall.test.ts +++ b/src/handlers/componentInstall.test.ts @@ -291,6 +291,14 @@ describe('componentInstall', () => { ); }); + it('requests a remote freshness check when refresh is enabled', async () => { + await componentInstall('button', { force: true, refresh: true }); + + expect(cloneIntoCacheMock).toHaveBeenCalledWith('systems', ['compound'], { + refresh: true, + }); + }); + it('previews a single component install without copying in dry-run mode', async () => { await componentInstall('card', { dryRun: true }); diff --git a/src/handlers/componentInstall.ts b/src/handlers/componentInstall.ts index b39244d..e297128 100644 --- a/src/handlers/componentInstall.ts +++ b/src/handlers/componentInstall.ts @@ -111,7 +111,7 @@ function logComponentInstallDryRun( */ export default async function componentInstall( name: string, - { force, all, dryRun }: InstallComponentHandlerOptions, + { force, all, dryRun, refresh }: InstallComponentHandlerOptions, ): Promise<void> { if (!name && !all) { throw new CliError( @@ -120,8 +120,10 @@ export default async function componentInstall( } // Load the configured system and variant before resolving component installs. - const { systemConf, variantConf } = - await withEmulsifySystem('install components'); + const { systemConf, variantConf } = await withEmulsifySystem( + 'install components', + { refresh }, + ); // If all components are to be installed, spawn promises for installing all available components. const components: [string, boolean, Promise<void>][] = []; diff --git a/src/handlers/componentList.test.ts b/src/handlers/componentList.test.ts index e442a4d..32d2c20 100644 --- a/src/handlers/componentList.test.ts +++ b/src/handlers/componentList.test.ts @@ -193,4 +193,12 @@ describe('componentList', () => { expect(logMock).toHaveBeenCalledWith('info', 'base -> button'); expect(logMock).toHaveBeenCalledWith('info', 'base -> card'); }); + + it('requests a remote freshness check when refresh is enabled', async () => { + await componentList({ refresh: true }); + + expect(cloneIntoCacheMock).toHaveBeenCalledWith('systems', ['compound'], { + refresh: true, + }); + }); }); diff --git a/src/handlers/componentList.ts b/src/handlers/componentList.ts index a3dbdfa..52ca68b 100644 --- a/src/handlers/componentList.ts +++ b/src/handlers/componentList.ts @@ -1,3 +1,4 @@ +import type { ListComponentHandlerOptions } from '@emulsify-cli/handlers'; import log from '../lib/log.js'; import { withEmulsifySystem } from './hofs/withEmulsifySystem.js'; @@ -6,9 +7,13 @@ import { withEmulsifySystem } from './hofs/withEmulsifySystem.js'; * * @throws {CliError} if the current project does not have a usable system and variant configuration. */ -export default async function componentList(): Promise<void> { +export default async function componentList({ + refresh, +}: ListComponentHandlerOptions = {}): Promise<void> { // Load the configured system and variant before printing available components. - const { variantConf } = await withEmulsifySystem('list components'); + const { variantConf } = await withEmulsifySystem('list components', { + refresh, + }); variantConf.components.map(({ name, structure }) => { log('info', `${structure} -> ${name}`); diff --git a/src/handlers/hofs/withEmulsifySystem.test.ts b/src/handlers/hofs/withEmulsifySystem.test.ts index f2b6afc..bab173d 100644 --- a/src/handlers/hofs/withEmulsifySystem.test.ts +++ b/src/handlers/hofs/withEmulsifySystem.test.ts @@ -287,7 +287,18 @@ describe('withEmulsifySystem', () => { systemConf: system, variantConf: variant, }); - expect(cloneIntoCacheMock).toHaveBeenCalledWith('systems', ['compound']); + expect(cloneIntoCacheMock).toHaveBeenCalledWith('systems', ['compound'], { + refresh: false, + }); + expect(cloneSystemMock).toHaveBeenCalledWith(projectConfig.system); + }); + + it('requests a remote freshness check only when refresh is enabled', async () => { + await withEmulsifySystem('list components', { refresh: true }); + + expect(cloneIntoCacheMock).toHaveBeenCalledWith('systems', ['compound'], { + refresh: true, + }); expect(cloneSystemMock).toHaveBeenCalledWith(projectConfig.system); }); diff --git a/src/handlers/hofs/withEmulsifySystem.ts b/src/handlers/hofs/withEmulsifySystem.ts index 61f43dd..47664c9 100644 --- a/src/handlers/hofs/withEmulsifySystem.ts +++ b/src/handlers/hofs/withEmulsifySystem.ts @@ -36,6 +36,10 @@ export type EmulsifySystemContext = { variantConf: EmulsifyVariant; }; +type WithEmulsifySystemOptions = { + refresh?: boolean; +}; + /** * Error thrown when a handler requires an installed Emulsify system, but the * current project or cached system state does not satisfy that requirement. @@ -116,6 +120,7 @@ function formatPlatformExpressions(expressions: readonly string[]): string { */ export async function withEmulsifySystem( actionLabel: string, + { refresh = false }: WithEmulsifySystemOptions = {}, ): Promise<EmulsifySystemContext> { const emulsifyConfig = await getEmulsifyConfig(); if (!emulsifyConfig) { @@ -140,7 +145,7 @@ export async function withEmulsifySystem( } try { - await cloneIntoCache('systems', [systemName])(systemReference); + await cloneIntoCache('systems', [systemName], { refresh })(systemReference); } catch { throw new EmulsifySystemError( 'The system specified in your project configuration is not clone-able, or has an invalid checkout value.', diff --git a/src/handlers/systemInstall.test.ts b/src/handlers/systemInstall.test.ts index b101352..ac92bcb 100644 --- a/src/handlers/systemInstall.test.ts +++ b/src/handlers/systemInstall.test.ts @@ -277,7 +277,9 @@ describe('systemInstall', () => { await systemInstall(undefined, {}); - expect(cloneIntoCacheMock).toHaveBeenCalledWith('systems', ['compound']); + expect(cloneIntoCacheMock).toHaveBeenCalledWith('systems', ['compound'], { + refresh: true, + }); expect(cloneSystemMock).toHaveBeenCalledWith({ repository: 'https://github.com/emulsify-ds/compound.git', checkout: 'v1.0.0', @@ -988,9 +990,11 @@ describe('systemInstall', () => { }); expect(selectMock).not.toHaveBeenCalled(); - expect(cloneIntoCacheMock).toHaveBeenCalledWith('systems', [ - 'custom-system', - ]); + expect(cloneIntoCacheMock).toHaveBeenCalledWith( + 'systems', + ['custom-system'], + { refresh: true }, + ); expect(cloneSystemMock).toHaveBeenCalledWith({ repository: 'https://github.com/example/custom-system.git', checkout: 'release', diff --git a/src/handlers/systemInstall.ts b/src/handlers/systemInstall.ts index dd66a95..3dcc7e4 100644 --- a/src/handlers/systemInstall.ts +++ b/src/handlers/systemInstall.ts @@ -315,7 +315,7 @@ export default async function systemInstall( } // Clone the system into the cache. - await cloneIntoCache('systems', [repo.name])({ + await cloneIntoCache('systems', [repo.name], { refresh: true })({ repository: repo.repository, checkout: repo.checkout, }); diff --git a/src/index.ts b/src/index.ts index 4c12bf7..099a5fe 100644 --- a/src/index.ts +++ b/src/index.ts @@ -72,6 +72,8 @@ function getRootHelp(): string { '', ' component list', ' List components available from the installed system and selected variant. Alias: component ls.', + ' Options:', + ' --refresh Check the system remote before reusing the local cache.', '', ' component install [name]', ' Install one component, dependencies, or all components from the installed system. Alias: component i.', @@ -79,6 +81,7 @@ function getRootHelp(): string { ' -f, --force Replace an existing component destination.', ' -a, --all Install all available components.', ' --dry-run Preview installs without writing files.', + ' --refresh Check the system remote before reusing the local cache.', '', ' component create [name]', ' Generate a new local component in this project. Alias: component c.', @@ -87,6 +90,7 @@ function getRootHelp(): string { ' -f, --format <default|sdc> Component format to generate.', ' -y, --yes Replace existing generated components without prompting.', ' --dry-run Preview generated files without writing them.', + ' --refresh Check the system remote before reusing the local cache.', '', ' cache clear', ' Remove all locally cached Emulsify repositories.', @@ -184,6 +188,10 @@ component .description( 'List components available from the installed system and variant', ) + .option( + '--refresh', + 'Check the configured system remote before reusing its local cache entry.', + ) .alias('ls') .action(componentList); component @@ -198,6 +206,10 @@ component '--dry-run', 'Preview component installs without copying or removing files.', ) + .option( + '--refresh', + 'Check the configured system remote before reusing its local cache entry.', + ) .alias('i') .action(componentInstall); component @@ -218,6 +230,10 @@ component '--dry-run', 'Preview generated component files without writing or removing files.', ) + .option( + '--refresh', + 'Check the configured system remote before reusing its local cache entry.', + ) .alias('c') .description('Generate a new local component in the current project') .action(componentCreate); diff --git a/src/types/handlers.d.ts b/src/types/handlers.d.ts index 51b451c..149fdc0 100644 --- a/src/types/handlers.d.ts +++ b/src/types/handlers.d.ts @@ -24,10 +24,17 @@ declare module '@emulsify-cli/handlers' { all?: boolean; }; + export type ListComponentHandlerOptions = { + /** Check the configured system's remote ref before reusing its local cache entry. */ + refresh?: boolean; + }; + export type InstallComponentHandlerOptions = { force?: boolean; all?: boolean; dryRun?: boolean; + /** Check the configured system's remote ref before reusing its local cache entry. */ + refresh?: boolean; }; export type CreateComponentHandlerOptions = { @@ -39,6 +46,8 @@ declare module '@emulsify-cli/handlers' { yes?: boolean; /** Preview planned component operations without writing, copying, or removing files. */ dryRun?: boolean; + /** Check the configured system's remote ref before reusing its local cache entry. */ + refresh?: boolean; }; export type ClearCacheHandlerOptions = { diff --git a/src/util/cache/cloneIntoCache.test.ts b/src/util/cache/cloneIntoCache.test.ts index bd3c444..d96e2cf 100644 --- a/src/util/cache/cloneIntoCache.test.ts +++ b/src/util/cache/cloneIntoCache.test.ts @@ -23,6 +23,7 @@ const gitMock = simpleGit(); const gitCloneMock = gitMock.clone as jest.Mock; const getRemotesMock = gitMock.getRemotes as jest.Mock; const listRemoteMock = gitMock.listRemote as jest.Mock; +const envMock = gitMock.env as jest.Mock; const revparseMock = gitMock.revparse as jest.Mock; const projectPath = resolve('fixtures', 'emulsify'); @@ -143,12 +144,9 @@ describe('cloneIntoCache', () => { expect(writeFileMock).not.toHaveBeenCalled(); }); - it('reuses a complete cache entry with matching origin and refs', async () => { + it('reuses a complete cache entry without contacting the remote by default', async () => { existsSyncMock.mockReturnValueOnce(true); readFileMock.mockResolvedValueOnce(metadata()); - listRemoteMock.mockResolvedValueOnce( - 'resolved-ref\trefs/heads/branch-name\n', - ); await cloneIntoCache('systems', ['cornflake'])(cloneOptions); @@ -158,6 +156,7 @@ describe('cloneIntoCache', () => { expect(gitCloneMock).not.toHaveBeenCalled(); expect(rmMock).not.toHaveBeenCalled(); expect(writeFileMock).not.toHaveBeenCalled(); + expect(listRemoteMock).not.toHaveBeenCalled(); }); it('reuses a valid local entry when the remote cannot be reached', async () => { @@ -165,19 +164,59 @@ describe('cloneIntoCache', () => { readFileMock.mockResolvedValueOnce(metadata()); listRemoteMock.mockRejectedValueOnce(new Error('offline')); - await cloneIntoCache('systems', ['cornflake'])(cloneOptions); + await cloneIntoCache('systems', ['cornflake'], { refresh: true })( + cloneOptions, + ); expect(gitCloneMock).not.toHaveBeenCalled(); expect(rmMock).not.toHaveBeenCalled(); }); + it('reuses a valid local entry when the remote lookup times out', async () => { + existsSyncMock.mockReturnValueOnce(true); + readFileMock.mockResolvedValueOnce(metadata()); + listRemoteMock.mockRejectedValueOnce( + Object.assign(new Error('block timeout reached'), { plugin: 'timeout' }), + ); + + await cloneIntoCache('systems', ['cornflake'], { refresh: true })( + cloneOptions, + ); + + expect(gitCloneMock).not.toHaveBeenCalled(); + expect(rmMock).not.toHaveBeenCalled(); + }); + + it('bounds remote lookups and disables terminal prompts', async () => { + existsSyncMock.mockReturnValueOnce(true); + readFileMock.mockResolvedValueOnce(metadata()); + listRemoteMock.mockResolvedValueOnce( + 'resolved-ref\trefs/heads/branch-name\n', + ); + + await cloneIntoCache('systems', ['cornflake'], { refresh: true })( + cloneOptions, + ); + + expect(simpleGitMock).toHaveBeenCalledWith({ + timeout: { + block: 2_000, + stdOut: false, + stdErr: false, + }, + }); + expect(envMock).toHaveBeenCalledWith( + expect.objectContaining({ GIT_TERMINAL_PROMPT: '0' }), + ); + }); + it('reuses a valid default-branch entry when remote HEAD matches', async () => { const defaultDestination = cacheDestination(undefined); existsSyncMock.mockReturnValueOnce(true); readFileMock.mockResolvedValueOnce(metadata({ checkout: null })); listRemoteMock.mockResolvedValueOnce('resolved-ref\tHEAD\n'); - await cloneIntoCache('systems', ['cornflake'])({ + await cloneIntoCache('systems', ['cornflake'], { refresh: true })({ repository: 'repo-path', }); @@ -197,7 +236,9 @@ describe('cloneIntoCache', () => { 'resolved-ref\trefs/tags/branch-name\n', ); - await cloneIntoCache('systems', ['cornflake'])(cloneOptions); + await cloneIntoCache('systems', ['cornflake'], { refresh: true })( + cloneOptions, + ); expect(listRemoteMock).toHaveBeenCalledWith([ normalizedRepository, @@ -219,7 +260,9 @@ describe('cloneIntoCache', () => { ].join('\n'), ); - await cloneIntoCache('systems', ['cornflake'])(cloneOptions); + await cloneIntoCache('systems', ['cornflake'], { refresh: true })( + cloneOptions, + ); expect(gitCloneMock).not.toHaveBeenCalled(); expect(rmMock).not.toHaveBeenCalled(); @@ -230,7 +273,9 @@ describe('cloneIntoCache', () => { readFileMock.mockResolvedValueOnce(metadata()); listRemoteMock.mockResolvedValueOnce(''); - await cloneIntoCache('systems', ['cornflake'])(cloneOptions); + await cloneIntoCache('systems', ['cornflake'], { refresh: true })( + cloneOptions, + ); expect(rmMock).toHaveBeenCalledTimes(1); expect(gitCloneMock).toHaveBeenCalledTimes(1); @@ -365,7 +410,9 @@ describe('cloneIntoCache', () => { 'new-resolved-ref\trefs/heads/branch-name\n', ); - await cloneIntoCache('systems', ['cornflake'])(cloneOptions); + await cloneIntoCache('systems', ['cornflake'], { refresh: true })( + cloneOptions, + ); expect(rmMock).toHaveBeenCalledTimes(1); expect(gitCloneMock).toHaveBeenCalledTimes(1); diff --git a/src/util/cache/cloneIntoCache.ts b/src/util/cache/cloneIntoCache.ts index f4fe99b..328e9ab 100644 --- a/src/util/cache/cloneIntoCache.ts +++ b/src/util/cache/cloneIntoCache.ts @@ -22,6 +22,12 @@ type RemoteRefResult = | { status: 'missing' } | { status: 'unavailable' }; +type CacheReuseOptions = { + refresh?: boolean; +}; + +const REMOTE_REF_LOOKUP_TIMEOUT_MS = 2_000; + function isCacheMetadata(value: unknown): value is CacheMetadata { if (!value || typeof value !== 'object') { return false; @@ -96,7 +102,17 @@ async function getRemoteResolvedRef( const remoteRefs = getRemoteRefCandidates(checkout); try { - const output = await simpleGit().listRemote([repository, ...remoteRefs]); + const git = simpleGit({ + timeout: { + block: REMOTE_REF_LOOKUP_TIMEOUT_MS, + stdOut: false, + stdErr: false, + }, + }).env({ + ...process.env, + GIT_TERMINAL_PROMPT: '0', + }); + const output = await git.listRemote([repository, ...remoteRefs]); const resolvedRefs = new Map<string, string>(); for (const line of output.trim().split(/\r?\n/)) { @@ -124,6 +140,7 @@ async function canReuseCacheEntry( destination: string, repository: string, checkout: string | void, + refresh: boolean, ): Promise<boolean> { const metadata = await readCacheMetadata(destination); const expectedCheckout = checkout || null; @@ -152,6 +169,10 @@ async function canReuseCacheEntry( return false; } + if (!refresh) { + return true; + } + const remoteRef = await getRemoteResolvedRef(repository, checkout); return ( remoteRef.status === 'unavailable' || @@ -165,12 +186,15 @@ async function canReuseCacheEntry( * * @param type CacheBucket value that specifies what type of cache this repository is. * @param itemPath array of strings describing the path to the item cache within the specified bucket. + * @param options cache reuse behavior. + * @param options.refresh whether to compare the cached ref with the remote before reuse. * * @returns void, or throws an error if the repository could not be cloned. */ export default function cloneIntoCache( bucket: CacheBucket, itemPath: CacheItemPath, + { refresh = false }: CacheReuseOptions = {}, ) { return async ({ repository, checkout }: GitCloneOptions): Promise<void> => { const normalizedRepository = normalizeRepositoryUrl(repository); @@ -185,7 +209,12 @@ export default function cloneIntoCache( // Reuse only complete clones whose identity, origin, and resolved ref match. if (existsSync(destination)) { if ( - await canReuseCacheEntry(destination, normalizedRepository, checkout) + await canReuseCacheEntry( + destination, + normalizedRepository, + checkout, + refresh, + ) ) { return; } diff --git a/test/e2e/root-help.txt b/test/e2e/root-help.txt index bd90b12..67db72a 100644 --- a/test/e2e/root-help.txt +++ b/test/e2e/root-help.txt @@ -51,6 +51,8 @@ Commands: component list List components available from the installed system and selected variant. Alias: component ls. + Options: + --refresh Check the system remote before reusing the local cache. component install [name] Install one component, dependencies, or all components from the installed system. Alias: component i. @@ -58,6 +60,7 @@ Commands: -f, --force Replace an existing component destination. -a, --all Install all available components. --dry-run Preview installs without writing files. + --refresh Check the system remote before reusing the local cache. component create [name] Generate a new local component in this project. Alias: component c. @@ -66,6 +69,7 @@ Commands: -f, --format <default|sdc> Component format to generate. -y, --yes Replace existing generated components without prompting. --dry-run Preview generated files without writing them. + --refresh Check the system remote before reusing the local cache. cache clear Remove all locally cached Emulsify repositories. From 979a4c28f53b8d79503fb054aab268e8dd954333 Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Sat, 29 Aug 2026 21:52:12 -0500 Subject: [PATCH 19/27] fix(system): resolve the system install hook against a directory --- docs/hooks-and-cache.md | 4 ++-- src/handlers/systemInstall.test.ts | 10 +++++----- src/handlers/systemInstall.ts | 8 +++++--- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/docs/hooks-and-cache.md b/docs/hooks-and-cache.md index d9a82d0..aa7e1fa 100644 --- a/docs/hooks-and-cache.md +++ b/docs/hooks-and-cache.md @@ -33,13 +33,13 @@ await writeFile(resolve('..', '.env.example'), 'STORYBOOK_PORT=6006\n'); ## System Install Hook -The CLI has a `systemInstall.js` hook constant for system installation workflows: +During `emulsify system install`, the CLI checks the current project root for: ```text .cli/systemInstall.js ``` -When system install can discover the hook location, it executes the file with Node.js after required components and general assets are installed. +The project root is the directory containing `project.emulsify.json`. If the hook exists, the CLI executes it with Node.js after required components and general assets are installed. Use this hook for setup that must happen after a system has populated project files. Keep it idempotent because system installs may be repeated in local development or test projects. diff --git a/src/handlers/systemInstall.test.ts b/src/handlers/systemInstall.test.ts index ac92bcb..d3c20a4 100644 --- a/src/handlers/systemInstall.test.ts +++ b/src/handlers/systemInstall.test.ts @@ -64,7 +64,7 @@ const projectRoot = resolve('/project'); const projectConfigPath = join(projectRoot, EMULSIFY_PROJECT_CONFIG_FILE); const systemConfigPath = join(projectRoot, EMULSIFY_SYSTEM_CONFIG_FILE); const systemInstallHookPath = join( - systemConfigPath, + projectRoot, EMULSIFY_PROJECT_HOOK_FOLDER, EMULSIFY_PROJECT_HOOK_SYSTEM_INSTALL, ); @@ -226,8 +226,8 @@ describe('systemInstall', () => { setEmulsifyConfigMock.mockResolvedValue(undefined); getEmulsifyConfigMock.mockResolvedValue(projectConfig); writeToJsonFileMock.mockResolvedValue(undefined); - // A found system config plus an existing hook covers the optional hook branch. - findFileInCurrentPathMock.mockReturnValue(systemConfigPath); + // A found project config plus an existing hook covers the optional hook branch. + findFileInCurrentPathMock.mockReturnValue(projectConfigPath); existsSyncMock.mockReturnValue(true); executeScriptMock.mockResolvedValue(undefined); }); @@ -1022,14 +1022,14 @@ describe('systemInstall', () => { ); }); - it('skips the install hook when no system config path is found', async () => { + it('skips the install hook when no project config path is found', async () => { findFileInCurrentPathMock.mockReturnValueOnce(undefined); await systemInstall('compound', {}); expect(executeScriptMock).not.toHaveBeenCalled(); expect(findFileInCurrentPathMock).toHaveBeenCalledWith( - EMULSIFY_SYSTEM_CONFIG_FILE, + EMULSIFY_PROJECT_CONFIG_FILE, ); }); diff --git a/src/handlers/systemInstall.ts b/src/handlers/systemInstall.ts index 3dcc7e4..bf61929 100644 --- a/src/handlers/systemInstall.ts +++ b/src/handlers/systemInstall.ts @@ -415,10 +415,12 @@ export default async function systemInstall( await installGeneralAssetsFromCache(systemConf, variantConf); // Execute system install hook. - const path = findFileInCurrentPath(EMULSIFY_SYSTEM_CONFIG_FILE); - const hookPath = path + const projectConfigPath = findFileInCurrentPath( + EMULSIFY_PROJECT_CONFIG_FILE, + ); + const hookPath = projectConfigPath ? join( - path, + dirname(projectConfigPath), EMULSIFY_PROJECT_HOOK_FOLDER, EMULSIFY_PROJECT_HOOK_SYSTEM_INSTALL, ) From 0ae6cb50d160733aeb4e943f8e015e0608c2ef63 Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Sat, 29 Aug 2026 21:54:01 -0500 Subject: [PATCH 20/27] fix(component): exit non-zero when a requested component fails to install --- src/handlers/componentInstall.test.ts | 43 ++++++++++++++++++--------- src/handlers/componentInstall.ts | 11 +++++-- 2 files changed, 37 insertions(+), 17 deletions(-) diff --git a/src/handlers/componentInstall.test.ts b/src/handlers/componentInstall.test.ts index 7eb333f..22d6f62 100644 --- a/src/handlers/componentInstall.test.ts +++ b/src/handlers/componentInstall.test.ts @@ -439,12 +439,14 @@ describe('componentInstall', () => { expect(copyItemFromCacheMock).not.toHaveBeenCalled(); }); - it('logs dependency installation failures after the root component succeeds', async () => { + it('reports dependency installation failures and rejects', async () => { copyItemFromCacheMock .mockResolvedValueOnce(undefined) .mockRejectedValueOnce(new Error('missing dependency')); - await componentInstall('button', { force: true }); + await expect(componentInstall('button', { force: true })).rejects.toThrow( + 'Unable to install icon: missing dependency', + ); expect(logMock).toHaveBeenCalledWith( 'warn', @@ -452,39 +454,52 @@ describe('componentInstall', () => { ); }); - it('logs non-Error dependency installation failures immediately', async () => { + it('reports non-Error dependency installation failures and rejects', async () => { copyItemFromCacheMock .mockResolvedValueOnce(undefined) .mockRejectedValueOnce('missing dependency'); - await componentInstall('button', { force: true }); + await expect(componentInstall('button', { force: true })).rejects.toThrow( + 'Unable to install icon: missing dependency', + ); expect(logMock).toHaveBeenCalledWith( 'warn', - 'Unable to install icon: missing dependency', + 'The following dependencies could not be installed:\n → icon', ); }); - it('logs non-Error root component installation failures', async () => { + it('rejects for non-Error root component installation failures', async () => { copyItemFromCacheMock.mockRejectedValueOnce('copy failed'); - await componentInstall('button', { force: true }); - - expect(logMock).toHaveBeenCalledWith( - 'warn', + await expect(componentInstall('button', { force: true })).rejects.toThrow( 'Unable to install button: copy failed', ); }); - it('logs root component installation failures', async () => { + it('rejects for root component installation failures', async () => { copyItemFromCacheMock.mockRejectedValueOnce(new Error('copy failed')); - await componentInstall('button', { force: true }); + await expect(componentInstall('button', { force: true })).rejects.toThrow( + 'Unable to install button: copy failed', + ); + }); + + it('settles every --all install before rejecting a partial failure', async () => { + copyItemFromCacheMock + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error('copy failed')) + .mockResolvedValueOnce(undefined); + + await expect(componentInstall('', { all: true })).rejects.toThrow( + 'Unable to install icon: copy failed', + ); expect(logMock).toHaveBeenCalledWith( - 'warn', - 'Unable to install button: copy failed', + 'success', + 'Success! The card component has been added to your project.', ); + expect(copyItemFromCacheMock).toHaveBeenCalledTimes(3); }); it('uses the project config path to check existing destinations', async () => { diff --git a/src/handlers/componentInstall.ts b/src/handlers/componentInstall.ts index e297128..0dcf439 100644 --- a/src/handlers/componentInstall.ts +++ b/src/handlers/componentInstall.ts @@ -108,6 +108,7 @@ function logComponentInstallDryRun( * @throws {CliError} if a component name is missing and all components were not requested. * @throws {CliError} if the current project does not have a usable system and variant configuration. * @throws {CliError} if the requested component cannot be found. + * @throws {CliError} if any requested component or dependency fails to install. */ export default async function componentInstall( name: string, @@ -227,6 +228,7 @@ export default async function componentInstall( const installedDeps: string[] = []; const failedDeps: string[] = []; + const failureMessages: string[] = []; for (const [cname, isDependency, promise] of components) { try { @@ -241,10 +243,9 @@ export default async function componentInstall( } } catch (e) { const msg = e instanceof Error ? e.message : String(e); - if (e instanceof Error && components.find(([c]) => c === cname)?.[1]) { + failureMessages.push(`Unable to install ${cname}: ${msg}`); + if (isDependency) { failedDeps.push(cname); - } else { - log('warn', `Unable to install ${cname}: ${msg}`); } } } @@ -261,4 +262,8 @@ export default async function componentInstall( `The following dependencies could not be installed:\n${failList}`, ); } + + if (failureMessages.length > 0) { + throw new CliError(failureMessages.join('\n')); + } } From fa4ef96d738a3f9848e8c8b0ecf3d998e476e8f0 Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Sat, 29 Aug 2026 21:55:09 -0500 Subject: [PATCH 21/27] test(e2e): assert the previously known-failing CLI behaviors --- test/e2e/cli.test.mjs | 31 +++++++------------------------ 1 file changed, 7 insertions(+), 24 deletions(-) diff --git a/test/e2e/cli.test.mjs b/test/e2e/cli.test.mjs index d20ea36..aa13421 100644 --- a/test/e2e/cli.test.mjs +++ b/test/e2e/cli.test.mjs @@ -154,13 +154,6 @@ describe('built Emulsify CLI', { concurrency: false }, () => { }, }, }), - 'system.emulsify.json': json({ - name: 'fixture-project-system', - homepage: 'https://example.test/project-system', - repository: 'https://example.test/fixture-project-system.git', - structure: [], - variants: [], - }), '.cli/systemInstall.js': [ "import { writeFileSync } from 'node:fs';", "writeFileSync(new URL('../system-install-hook-ran.txt', import.meta.url), 'ran\\n');", @@ -288,7 +281,6 @@ describe('built Emulsify CLI', { concurrency: false }, () => { }, ); assert.equal(existsSync(join(projectRoot, 'package-lock.json')), true); - assert.equal(existsSync(join(projectRoot, 'system.emulsify.json')), true); assert.equal( existsSync(join(projectRoot, '.cli', 'systemInstall.js')), true, @@ -333,31 +325,22 @@ describe('built Emulsify CLI', { concurrency: false }, () => { assert.equal(existsSync(join(isolatedHome, '.emulsify', 'cache')), true); }); - // Known failure: ../../src/handlers/systemInstall.ts:418-427 appends the - // hook path to the system.emulsify.json file instead of its directory. - test('executes the project system-install hook', (t) => { - if (!existsSync(systemHookSentinel)) { - t.todo('Known failure: src/handlers/systemInstall.ts:418-427'); - return; - } - + test('executes the project system-install hook', () => { + assert.equal( + existsSync(systemHookSentinel), + true, + 'system install must execute the project hook', + ); assert.equal(readFileSync(systemHookSentinel, 'utf8'), 'ran\n'); }); - // Known failure: ../../src/handlers/componentInstall.ts:240-247 logs and - // swallows a requested component copy failure instead of failing the CLI. - test('returns a non-zero exit when a requested component cannot be copied', (t) => { + test('returns a non-zero exit when a requested component cannot be copied', () => { const result = runCli(projectRoot, ['component', 'install', 'missing']); assert.equal(result.stdout, ''); assert.match(result.stderr, /Unable to install missing:/); assert.equal(existsSync(join(projectRoot, 'components', 'missing')), false); - if (result.status === 0) { - t.todo('Known failure: src/handlers/componentInstall.ts:240-247'); - return; - } - assert.notEqual( result.status, 0, From d92a3ea0b4ca39be3a9fb8f5aaa6632b561ccef9 Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Sat, 29 Aug 2026 22:12:06 -0500 Subject: [PATCH 22/27] ci(release): enable npm provenance and pin workflow actions --- .github/dependabot.yml | 4 ++++ .github/workflows/ci.yml | 8 ++++---- .github/workflows/contributors.yml | 23 ---------------------- .github/workflows/develop-version-bump.yml | 4 ++-- .github/workflows/publish.yml | 8 ++++---- docs/release.md | 8 +++++++- package.json | 3 ++- 7 files changed, 23 insertions(+), 35 deletions(-) delete mode 100644 .github/workflows/contributors.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 0602e18..b83bac7 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -4,3 +4,7 @@ updates: directory: '/' schedule: interval: daily + - package-ecosystem: github-actions + directory: '/' + schedule: + interval: weekly diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bffa1f9..3c3a860 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,11 +20,11 @@ jobs: runs-on: ${{ matrix.os }} steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: fetch-depth: 0 - name: Install Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version-file: '.nvmrc' cache: npm @@ -46,11 +46,11 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: fetch-depth: 0 - name: Install Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version-file: '.nvmrc' cache: npm diff --git a/.github/workflows/contributors.yml b/.github/workflows/contributors.yml deleted file mode 100644 index 0ce07d2..0000000 --- a/.github/workflows/contributors.yml +++ /dev/null @@ -1,23 +0,0 @@ -name: Add contributors -on: - schedule: - - cron: '20 20 * * *' - push: - branches: - - develop - -jobs: - add-contributors: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - uses: BobAnkh/add-contributors@master - with: - CONTRIBUTOR: '### Contributors' - COLUMN_PER_ROW: '6' - ACCESS_TOKEN: ${{secrets.ADD_TO_PROJECT_PAT}} - IMG_WIDTH: '100' - FONT_SIZE: '14' - PATH: '/README.md' - COMMIT_MESSAGE: 'docs(README): update contributors' - AVATAR_SHAPE: 'round' diff --git a/.github/workflows/develop-version-bump.yml b/.github/workflows/develop-version-bump.yml index c75a7e2..e9e960e 100644 --- a/.github/workflows/develop-version-bump.yml +++ b/.github/workflows/develop-version-bump.yml @@ -20,11 +20,11 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: fetch-depth: 0 - name: Install Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version-file: '.nvmrc' cache: 'npm' diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 918e596..4098098 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -17,12 +17,12 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: fetch-depth: 0 ref: ${{ github.sha }} - name: Install Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version-file: '.nvmrc' cache: 'npm' @@ -42,12 +42,12 @@ jobs: id-token: write steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: fetch-depth: 0 ref: ${{ github.sha }} - name: Install Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version-file: '.nvmrc' cache: 'npm' diff --git a/docs/release.md b/docs/release.md index 8f7cc10..b103bd3 100644 --- a/docs/release.md +++ b/docs/release.md @@ -144,6 +144,12 @@ Publish runs share a concurrency group, so a newer `main` push supersedes an older run. The explicit SHA checks also prevent a stale rerun from publishing an older commit. +The npm plugin reads `publishConfig.provenance` from `package.json`, so every +published package includes a signed provenance attestation. The release job's +`id-token: write` permission supplies the GitHub OIDC identity for that +attestation, while `NPM_TOKEN` continues to authenticate the npm registry +publish; npm Trusted Publishing is not required for this token-based flow. + semantic-release dry-run mode still verifies repository and registry credentials, so it belongs only in the trusted post-merge release job. A maintainer can repeat that check from the current trusted `main` commit with: @@ -163,5 +169,5 @@ the maintainers intend to publish. | ------------------------------------------- | --------------------------------------------------------------------- | | `@semantic-release/commit-analyzer` | Determine the release type from the shared conventional-commit rules. | | `@semantic-release/release-notes-generator` | Generate release notes with the same parser options. | -| `@semantic-release/npm` | Publish the package to npm. | +| `@semantic-release/npm` | Publish the package to npm with provenance. | | `@semantic-release/github` | Create GitHub release metadata. | diff --git a/package.json b/package.json index 25b0a1e..fa4e70f 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,8 @@ "dist" ], "publishConfig": { - "access": "public" + "access": "public", + "provenance": true }, "scripts": { "clean": "node --input-type=module --eval \"import { rm } from 'node:fs/promises'; await rm('dist', { recursive: true, force: true });\"", From d958e18a46422bb1f70bcd9cb2dccd3de1ec6ea9 Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Sat, 29 Aug 2026 22:24:45 -0500 Subject: [PATCH 23/27] docs: add contributor, security, and issue templates --- .editorconfig | 14 +++ .github/CODEOWNERS | 2 + .github/ISSUE_TEMPLATE/bug_report.yml | 52 +++++++++ .github/ISSUE_TEMPLATE/feature_request.yml | 44 +++++++ .github/PULL_REQUEST_TEMPLATE.md | 34 ++++++ CODE_OF_CONDUCT.md | 129 +++++++++++++++++++++ CONTRIBUTING.md | 55 +++++++++ SECURITY.md | 30 +++++ docs/development.md | 4 + 9 files changed, 364 insertions(+) create mode 100644 .editorconfig create mode 100644 .github/CODEOWNERS create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 SECURITY.md diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..f53850c --- /dev/null +++ b/.editorconfig @@ -0,0 +1,14 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_style = space +indent_size = 2 +insert_final_newline = true +max_line_length = 80 +quote_type = single +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..32f68db --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,2 @@ +# TODO(codeowners): Add a catch-all rule using the GitHub users or teams +# supplied by the maintainers. No owner is assigned until then. diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..915a70c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,52 @@ +name: Bug report +description: Report reproducible Emulsify CLI behavior that differs from the documentation +labels: + - '🐛 Bug' +body: + - type: markdown + attributes: + value: | + Thanks for helping improve Emulsify CLI. Search existing issues before filing a duplicate. + + **Do not report vulnerabilities here.** Follow the repository's [security policy](https://github.com/emulsify-ds/emulsify-cli/security/policy) so sensitive details remain private. + - type: textarea + id: observed-behavior + attributes: + label: Observed behavior + description: What did the CLI do, including the exact error or unexpected result? + validations: + required: true + - type: textarea + id: reproduction + attributes: + label: Reproduction + description: Include the exact command and the smallest project or configuration needed to reproduce the problem. + placeholder: | + 1. Run `emulsify ...` + 2. ... + validations: + required: true + - type: textarea + id: expected-behavior + attributes: + label: Expected behavior + description: What should have happened instead? + validations: + required: true + - type: textarea + id: environment + attributes: + label: Environment + description: Include Emulsify CLI version, Node.js version, operating system, and project platform. + placeholder: | + Emulsify CLI: output of `emulsify --version` + Node.js: output of `node --version` + Operating system: + Project platform: drupal, wordpress, or none + validations: + required: true + - type: textarea + id: context + attributes: + label: Additional context + description: Add relevant redacted logs or configuration. Do not include credentials, tokens, or other secrets. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..f25d72c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,44 @@ +name: Feature request +description: Propose an improvement to an Emulsify CLI workflow +labels: + - '🪄 Enhancement' +body: + - type: markdown + attributes: + value: Thanks for suggesting an improvement. Describe the user workflow before proposing implementation details. + - type: textarea + id: workflow + attributes: + label: Workflow or problem + description: What are you trying to accomplish, and what makes it difficult today? + validations: + required: true + - type: textarea + id: proposed-behavior + attributes: + label: Proposed CLI behavior + description: Describe the command, option, output, or configuration behavior you want. + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: Alternatives or current workaround + description: How do you handle this workflow now, and what alternatives have you considered? + - type: checkboxes + id: platforms + attributes: + label: Affected platforms + description: Select every platform this request affects. + options: + - label: Drupal + - label: WordPress + - label: No-platform projects (`none`) + - label: Platform-independent CLI behavior + validations: + required: true + - type: textarea + id: context + attributes: + label: Additional context + description: Add examples, command sketches, or configuration excerpts that clarify the request. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..1747d53 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,34 @@ +# Pull Request + +> **Title requirement:** Every pull request targeting `main` must have a +> release-producing conventional title whose release type matches the complete +> change, such as `fix(scope): ...` or `feat(scope): ...`. CI evaluates the +> title as a prospective squash commit; if squash merging is used, the title +> becomes the release-bearing commit. + +## Summary + +<!-- What changed, and why is this the right change for Emulsify CLI? --> + +## Related Issue + +<!-- Use "Closes #123" when this pull request should close an issue. --> + +## Verification + +<!-- Check the commands you ran. Explain any omitted step below. --> + +- [ ] `npm run build` +- [ ] `npm run type` +- [ ] `npm test` +- [ ] `npm run pack:dry-run` +- [ ] `npm run smoke:pack` + +<!-- Add manual checks or explain why a command was not applicable. --> + +## Checklist + +- [ ] I added or updated tests for behavior changes. +- [ ] I updated documentation for user-facing changes. +- [ ] I did not manually change the package version; the release workflow owns + it. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..747c187 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,129 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic +status, nationality, personal appearance, race, caste, color, religion, or +sexual identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +- Demonstrating empathy and kindness toward other people +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback +- Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +- Focusing on what is best not just for us as individuals, but for the overall + community + +Examples of unacceptable behavior include: + +- The use of sexualized language or imagery, and sexual attention or advances + of any kind +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or email address, + without their explicit permission +- Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards +of acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned with this Code of Conduct, and will communicate reasons for +moderation decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official email +address, posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported through the private channel in +[SECURITY.md](./SECURITY.md#reporting-a-vulnerability). Label the report as a +Code of Conduct matter. All complaints will be reviewed and investigated +promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of +actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external +channels like social media. Violating these terms may lead to a temporary or +permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited +interaction with those enforcing the Code of Conduct, is allowed during this +period. Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the +community. + +## Attribution + +This Code of Conduct is adapted from the +[Contributor Covenant](https://www.contributor-covenant.org), version 2.1, +available at +[https://www.contributor-covenant.org/version/2/1/code_of_conduct/](https://www.contributor-covenant.org/version/2/1/code_of_conduct/). + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity). + +For answers to common questions about this code of conduct, see the +[Contributor Covenant FAQ](https://www.contributor-covenant.org/faq). +Translations are available on the +[Contributor Covenant translations page](https://www.contributor-covenant.org/translations). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..631c41e --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,55 @@ +# Contributing + +Thank you for contributing to Emulsify CLI. This file is a short entry point; +the detailed development and release guidance remains in the canonical project +documentation: + +- [Development](./docs/development.md) covers setup, repository layout, + scripts, generated files, and local verification. +- [Release](./docs/release.md) covers branch strategy, semantic-release, and + the checks applied to release-bound changes. + +## Choose the Target Branch + +Open normal feature, fix, test, and documentation pull requests against +`develop`. The `main` branch is the trusted publication branch; a merge to +`main` can publish the exact merged commit after its release gates pass. + +Report suspected vulnerabilities through the private channel in +[SECURITY.md](./SECURITY.md), not through a public issue. + +## Commits and Pull Request Titles + +Every commit must follow the +[Conventional Commits](https://www.conventionalcommits.org/) format. The +repository extends `@commitlint/config-conventional`, and Husky's `commit-msg` +hook runs `npm run husky:commit-msg` to reject non-conforming commits. Running +`npm install` installs the hooks through the `prepare` script. + +Examples include: + +```text +fix(cache): reuse an available local clone +feat(component): support a new component format +docs: clarify local verification +``` + +Every pull request targeting `main` must also have a release-producing +conventional title whose release type matches the prospective release. CI +evaluates that title as a prospective squash commit; if squash merging is used, +the title becomes the release-bearing commit. For example: + +```text +fix(release): prepare CLI patch release +feat(release): prepare CLI minor release +``` + +See [Develop-to-Main Merge Expectations](./docs/release.md#develop-to-main-merge-expectations) +before opening or merging a pull request to `main`. + +## Verify the Change + +Follow the [Local Verification](./docs/development.md#local-verification) +commands before opening a pull request. Add or update tests for behavior +changes, and explain any verification step you could not run in the pull +request description. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..ea03f84 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,30 @@ +# Security Policy + +## Supported Releases + +Emulsify CLI does not publish a long-term support matrix. When reporting a +vulnerability, identify every affected `@emulsify/cli` version you have tested +and, when possible, confirm whether the latest published version is affected. +Maintainers will assess fixes and any backports based on the report's impact +and compatibility requirements. + +## Reporting a Vulnerability + +Do not open a public GitHub issue for a suspected vulnerability or include +exploit details in a public discussion. + +**TODO(contact): Replace this placeholder with the private security reporting +email address or URL.** + +Include the following information when it is safe to do so: + +- the affected Emulsify CLI version; +- the Node.js version and operating system; +- the expected impact and who may be affected; +- reproduction steps or a minimal proof of concept; +- any known mitigations; and +- whether the report or its details have been shared elsewhere. + +The maintainers will use the private reporting channel to coordinate +validation, remediation, and disclosure. Ordinary bugs and feature requests +belong in the repository's public issue templates. diff --git a/docs/development.md b/docs/development.md index c506ce7..c817b79 100644 --- a/docs/development.md +++ b/docs/development.md @@ -47,6 +47,9 @@ emulsify --help | `npm run watch` | Rebuild when files in `src` change. | | `npm run watch-ts` | Recompile TypeScript when files in `src` change. | | `npm run format` | Run Prettier on source TypeScript and JavaScript files. | +| `npm run husky:commit-msg` | Validate a commit message with the repository's conventional commitlint rules. | +| `npm run husky:pre-commit` | Run the repository's lint-staged pre-commit checks. | +| `npm run husky:pre-push` | Run the complete test suite before pushing. | | `npm run lint` | Run the complete test suite through `npm run test`. | | `npm run test` | Run unit and release tests, build the CLI, then run the end-to-end suite. | | `npm run test:e2e` | Run the built CLI end to end with Node's test runner. | @@ -58,6 +61,7 @@ emulsify --help | `npm run release:analyze` | Verify the predicted release without invoking publishing plugins. | | `npm run release:verify` | Run every build, test, package, smoke, and release-analysis gate used before publication. | | `npm run semantic-release` | Publish through semantic-release; use `-- --dry-run` unless publication is explicitly intended. | +| `npm run prepare` | Install this repository's Husky Git hooks after installing dependencies. | Run one test file by passing the path after `--`: From ac159e36cbff9d221575371204864e4e98ea044d Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Sat, 29 Aug 2026 22:33:03 -0500 Subject: [PATCH 24/27] chore(tooling): correct the lint, husky, and lint-staged configuration --- docs/development.md | 1 - lint-staged.config.js | 2 +- package.json | 3 +-- tsconfig.eslint.json | 5 ----- 4 files changed, 2 insertions(+), 9 deletions(-) delete mode 100644 tsconfig.eslint.json diff --git a/docs/development.md b/docs/development.md index c817b79..3b23a09 100644 --- a/docs/development.md +++ b/docs/development.md @@ -50,7 +50,6 @@ emulsify --help | `npm run husky:commit-msg` | Validate a commit message with the repository's conventional commitlint rules. | | `npm run husky:pre-commit` | Run the repository's lint-staged pre-commit checks. | | `npm run husky:pre-push` | Run the complete test suite before pushing. | -| `npm run lint` | Run the complete test suite through `npm run test`. | | `npm run test` | Run unit and release tests, build the CLI, then run the end-to-end suite. | | `npm run test:e2e` | Run the built CLI end to end with Node's test runner. | | `npm run test:unit` | Run the Jest unit suite with coverage. | diff --git a/lint-staged.config.js b/lint-staged.config.js index 4cb7ec5..0941df2 100644 --- a/lint-staged.config.js +++ b/lint-staged.config.js @@ -1,3 +1,3 @@ export default { - '*.{js,ts}': ['npm run format'], + '*.{js,ts}': ['prettier --write'], }; diff --git a/package.json b/package.json index fa4e70f..65e37f2 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,6 @@ "test:unit": "jest --coverage --silent", "test:release": "node --test ./scripts/version-develop.test.mjs ./scripts/verify-release-analysis.test.mjs", "test:e2e": "node --test ./test/e2e/*.test.mjs", - "lint": "npm run test", "type": "tsc --noEmit", "twatch": "jest --no-coverage --watch --verbose", "version:develop": "node ./scripts/version-develop.mjs", @@ -47,7 +46,7 @@ "semantic-release": "semantic-release --config ./release.config.cjs", "pack:dry-run": "node ./scripts/pack-dry-run.mjs", "smoke:pack": "node ./scripts/smoke-pack.mjs", - "prepare": "husky install" + "prepare": "husky" }, "devDependencies": { "@commitlint/cli": "^21.2.2", diff --git a/tsconfig.eslint.json b/tsconfig.eslint.json deleted file mode 100644 index 18a21a5..0000000 --- a/tsconfig.eslint.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "extends": "./tsconfig.json", - "exclude": [], - "include": ["src/**/*.ts", "./*.js", "./.*.js"] -} From bd0243e89c18280b727177c0d22704d18e9e16cc Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Sat, 29 Aug 2026 22:38:15 -0500 Subject: [PATCH 25/27] fix(system): require repository and checkout options together --- src/handlers/systemInstall.test.ts | 55 ++++++++++++++++++++++++++++++ src/handlers/systemInstall.ts | 21 ++++++++++-- 2 files changed, 74 insertions(+), 2 deletions(-) diff --git a/src/handlers/systemInstall.test.ts b/src/handlers/systemInstall.test.ts index d3c20a4..9db16b4 100644 --- a/src/handlers/systemInstall.test.ts +++ b/src/handlers/systemInstall.test.ts @@ -186,6 +186,30 @@ describe('getSystemRepoInfo', () => { ).rejects.toThrow('The repository URL must end in .git.'); }); + it('rejects a repository without a checkout instead of falling back to a named system', async () => { + await expect( + getSystemRepoInfo('compound', { + repository: 'https://github.com/example/custom-system.git', + }), + ).rejects.toThrow( + 'The --repository option requires --checkout when installing a custom system.', + ); + + expect(getAvailableSystemsMock).not.toHaveBeenCalled(); + }); + + it('rejects a checkout without a repository instead of falling back to a named system', async () => { + await expect( + getSystemRepoInfo('compound', { + checkout: 'v1.0.0', + }), + ).rejects.toThrow( + 'The --checkout option requires --repository when installing a custom system.', + ); + + expect(getAvailableSystemsMock).not.toHaveBeenCalled(); + }); + it('returns nothing when an explicit repository has no parseable name', async () => { await expect( getSystemRepoInfo(undefined, { @@ -417,6 +441,37 @@ describe('systemInstall', () => { expect(selectMock).not.toHaveBeenCalled(); }); + it('rejects a repository without a checkout before installing a named system', async () => { + await expect( + systemInstall('compound', { + repository: 'https://github.com/example/custom-system.git', + }), + ).rejects.toThrow( + 'The --repository option requires --checkout when installing a custom system.', + ); + + expect(getAvailableSystemsMock).not.toHaveBeenCalled(); + expect(getRepositoryLatestTagMock).not.toHaveBeenCalled(); + expect(cloneIntoCacheMock).not.toHaveBeenCalled(); + expect(cloneSystemMock).not.toHaveBeenCalled(); + }); + + it('rejects a checkout without a repository before prompting for a system', async () => { + setStdinIsTTY(true); + + await expect( + systemInstall(undefined, { + checkout: 'v1.0.0', + }), + ).rejects.toThrow( + 'The --checkout option requires --repository when installing a custom system.', + ); + + expect(selectMock).not.toHaveBeenCalled(); + expect(getAvailableSystemsMock).not.toHaveBeenCalled(); + expect(cloneIntoCacheMock).not.toHaveBeenCalled(); + }); + it('throws a helpful error in non-interactive mode when no system is provided', async () => { await expect(systemInstall(undefined, {})).rejects.toThrow( 'Unable to download specified system. You must either specify a valid name of an out-of-the-box system using the --name flag, or specify a valid repository and branch/tag/commit using the --repository and --checkout flags.', diff --git a/src/handlers/systemInstall.ts b/src/handlers/systemInstall.ts index bf61929..8968d73 100644 --- a/src/handlers/systemInstall.ts +++ b/src/handlers/systemInstall.ts @@ -51,12 +51,24 @@ const SYSTEM_INSTALL_ERROR = * * @returns GitCloneOptions or void, if no valid system could be found using the input. * - * @throws {CliError} if an explicit repository URL is invalid. + * @throws {CliError} if custom repository options are incomplete or invalid. */ export async function getSystemRepoInfo( name: string | void, { repository, checkout }: InstallSystemHandlerOptions, ): Promise<(GitCloneOptions & { name: string }) | void> { + if (repository && !checkout) { + throw new CliError( + 'The --repository option requires --checkout when installing a custom system.', + ); + } + + if (checkout && !repository) { + throw new CliError( + 'The --checkout option requires --repository when installing a custom system.', + ); + } + // If a repository and checkout were specified, use that to return system information. if (repository && checkout) { try { @@ -292,7 +304,12 @@ export default async function systemInstall( // Attempt to load system information, and exit with a log message // if a valid system was not found. let selectedName = name; - if (!selectedName && !options.repository && process.stdin.isTTY === true) { + if ( + !selectedName && + !options.repository && + !options.checkout && + process.stdin.isTTY === true + ) { selectedName = await promptForSystemInstallChoice(); if (!selectedName) { return; From a4c9832d6103b0de22715485d9d4e7e9e925dd65 Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Sat, 29 Aug 2026 22:39:59 -0500 Subject: [PATCH 26/27] fix(cli): remove the unused global checkout option and correct install guidance --- src/handlers/systemInstall.test.ts | 6 +++--- src/handlers/systemInstall.ts | 2 +- src/index.ts | 9 +-------- test/e2e/root-help.txt | 1 - 4 files changed, 5 insertions(+), 13 deletions(-) diff --git a/src/handlers/systemInstall.test.ts b/src/handlers/systemInstall.test.ts index 9db16b4..60d7d88 100644 --- a/src/handlers/systemInstall.test.ts +++ b/src/handlers/systemInstall.test.ts @@ -474,7 +474,7 @@ describe('systemInstall', () => { it('throws a helpful error in non-interactive mode when no system is provided', async () => { await expect(systemInstall(undefined, {})).rejects.toThrow( - 'Unable to download specified system. You must either specify a valid name of an out-of-the-box system using the --name flag, or specify a valid repository and branch/tag/commit using the --repository and --checkout flags.', + 'Unable to download specified system. Specify a valid built-in system name as the positional argument, or provide both --repository and --checkout (branch, tag, or commit) for a custom system.', ); }); @@ -993,7 +993,7 @@ describe('systemInstall', () => { it('throws when no matching system repository is found', async () => { await expect(systemInstall('missing', {})).rejects.toThrow( - 'Unable to download specified system. You must either specify a valid name of an out-of-the-box system using the --name flag, or specify a valid repository and branch/tag/commit using the --repository and --checkout flags.', + 'Unable to download specified system. Specify a valid built-in system name as the positional argument, or provide both --repository and --checkout (branch, tag, or commit) for a custom system.', ); }); @@ -1004,7 +1004,7 @@ describe('systemInstall', () => { checkout: 'main', }), ).rejects.toThrow( - 'Unable to download specified system. You must either specify a valid name of an out-of-the-box system using the --name flag, or specify a valid repository and branch/tag/commit using the --repository and --checkout flags.', + 'Unable to download specified system. Specify a valid built-in system name as the positional argument, or provide both --repository and --checkout (branch, tag, or commit) for a custom system.', ); }); diff --git a/src/handlers/systemInstall.ts b/src/handlers/systemInstall.ts index 8968d73..087e258 100644 --- a/src/handlers/systemInstall.ts +++ b/src/handlers/systemInstall.ts @@ -41,7 +41,7 @@ import { const CREATE_NEW_SYSTEM_CHOICE = 'create a new system'; const CANCEL_SYSTEM_INSTALL_CHOICE = 'cancel'; const SYSTEM_INSTALL_ERROR = - 'Unable to download specified system. You must either specify a valid name of an out-of-the-box system using the --name flag, or specify a valid repository and branch/tag/commit using the --repository and --checkout flags.'; + 'Unable to download specified system. Specify a valid built-in system name as the positional argument, or provide both --repository and --checkout (branch, tag, or commit) for a custom system.'; /** * Helper function that uses InstallSystemHandlerOptions input to determine what diff --git a/src/index.ts b/src/index.ts index 099a5fe..18306de 100644 --- a/src/index.ts +++ b/src/index.ts @@ -101,7 +101,6 @@ function getRootHelp(): string { ' Show help for a command.', '', 'Global options:', - ' -c, --checkout <commit/branch/tag> Shared checkout option for commands that clone repositories.', ' -V, --version Show the installed CLI version.', ' -h, --help Show this help output.', '', @@ -109,13 +108,7 @@ function getRootHelp(): string { } // Main program commands. -program - .name('emulsify') - .enablePositionalOptions() - .option( - '-c --checkout <commit/branch/tag>', - 'Commit, branch or tag of the base repository that should be checked out', - ); +program.name('emulsify').enablePositionalOptions(); program .command('init [name] [path]') diff --git a/test/e2e/root-help.txt b/test/e2e/root-help.txt index 67db72a..a3a4c18 100644 --- a/test/e2e/root-help.txt +++ b/test/e2e/root-help.txt @@ -80,6 +80,5 @@ Commands: Show help for a command. Global options: - -c, --checkout <commit/branch/tag> Shared checkout option for commands that clone repositories. -V, --version Show the installed CLI version. -h, --help Show this help output. From d2cc8bf44126a34c9078e3cf819b570c5c7c6f00 Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Sat, 29 Aug 2026 22:58:37 -0500 Subject: [PATCH 27/27] fix(cache): make concurrent cache population safe --- jest.setup.cjs | 3 + src/util/cache/cloneIntoCache.test.ts | 156 ++++++++++++++++++++++--- src/util/cache/cloneIntoCache.ts | 160 ++++++++++++++++++++++---- 3 files changed, 276 insertions(+), 43 deletions(-) diff --git a/jest.setup.cjs b/jest.setup.cjs index 00f15c7..eda354d 100644 --- a/jest.setup.cjs +++ b/jest.setup.cjs @@ -34,6 +34,9 @@ jest.mock('fs', () => ({ readdir: jest.fn(), rm: jest.fn(), mkdir: jest.fn(), + mkdtemp: jest.fn(), + rename: jest.fn(), + stat: jest.fn(), copyFile: jest.fn(), }, })); diff --git a/src/util/cache/cloneIntoCache.test.ts b/src/util/cache/cloneIntoCache.test.ts index d96e2cf..653f134 100644 --- a/src/util/cache/cloneIntoCache.test.ts +++ b/src/util/cache/cloneIntoCache.test.ts @@ -10,14 +10,19 @@ import findFileInCurrentPath from '../fs/findFileInCurrentPath.js'; import fs from 'fs'; import { createHash } from 'crypto'; -import { dirname, join, resolve } from 'path'; +import { basename, dirname, join, resolve } from 'path'; import { simpleGit } from 'simple-git'; const existsSyncMock = fs.existsSync as jest.Mock; const readFileMock = fs.promises.readFile as jest.Mock; const writeFileMock = fs.promises.writeFile as jest.Mock; +const readdirMock = fs.promises.readdir as jest.Mock; const rmMock = fs.promises.rm as jest.Mock; const mkdirMock = fs.promises.mkdir as jest.Mock; +const mkdtempMock = fs.promises.mkdtemp as jest.Mock; +const renameMock = fs.promises.rename as jest.Mock; +const statMock = fs.promises.stat as jest.Mock; +const copyFileMock = fs.promises.copyFile as jest.Mock; const simpleGitMock = simpleGit as unknown as jest.Mock; const gitMock = simpleGit(); const gitCloneMock = gitMock.clone as jest.Mock; @@ -48,6 +53,12 @@ function cacheDestination(checkout: string | void): string { const destination = cacheDestination(cloneOptions.checkout); const metadataPath = join(destination, '.emulsify-cache.json'); +const temporaryDestinationPrefix = `${destination}.tmp-${process.pid}-`; +const temporaryDestination = `${temporaryDestinationPrefix}abc123`; +const temporaryMetadataPath = join( + temporaryDestination, + '.emulsify-cache.json', +); function metadata(overrides: Record<string, unknown> = {}): string { return JSON.stringify({ @@ -69,8 +80,12 @@ describe('cloneIntoCache', () => { Object.assign(new Error('missing'), { code: 'ENOENT' }), ); writeFileMock.mockResolvedValue(undefined); + readdirMock.mockResolvedValue([]); rmMock.mockResolvedValue(undefined); mkdirMock.mockResolvedValue(undefined); + mkdtempMock.mockImplementation(async (prefix: string) => `${prefix}abc123`); + renameMock.mockResolvedValue(undefined); + statMock.mockResolvedValue({ mtimeMs: Date.now() }); gitCloneMock.mockResolvedValue(undefined); getRemotesMock.mockResolvedValue([ { @@ -91,20 +106,23 @@ describe('cloneIntoCache', () => { expect(mkdirMock).toHaveBeenCalledWith(dirname(destination), { recursive: true, }); + expect(mkdtempMock).toHaveBeenCalledWith(temporaryDestinationPrefix); expect(gitCloneMock).toHaveBeenCalledWith( normalizedRepository, - destination, + temporaryDestination, { '--branch': 'branch-name', }, ); - expect(simpleGitMock).toHaveBeenCalledWith(destination); + expect(simpleGitMock).toHaveBeenCalledWith(temporaryDestination); expect(revparseMock).toHaveBeenCalledWith(['HEAD']); expect(writeFileMock).toHaveBeenCalledWith( - metadataPath, + temporaryMetadataPath, expect.any(String), { encoding: 'utf-8' }, ); + expect(renameMock).toHaveBeenCalledWith(temporaryDestination, destination); + expect(copyFileMock).not.toHaveBeenCalled(); const writtenMetadata = JSON.parse(writeFileMock.mock.calls[0][1]); expect(writtenMetadata).toEqual({ @@ -117,31 +135,133 @@ describe('cloneIntoCache', () => { expect(Number.isNaN(Date.parse(writtenMetadata.clonedAt))).toBe(false); }); - it('writes metadata only after the clone resolves', async () => { + it('keeps the final entry untouched until a complete clone is ready', async () => { + existsSyncMock.mockReturnValue(true); + let markCloneStarted: (() => void) | undefined; let resolveClone: (() => void) | undefined; + const cloneStarted = new Promise<void>((resolve) => { + markCloneStarted = resolve; + }); gitCloneMock.mockImplementationOnce( () => new Promise<void>((resolve) => { resolveClone = resolve; + markCloneStarted?.(); }), ); const clonePromise = cloneIntoCache('systems', ['cornflake'])(cloneOptions); - await Promise.resolve(); + await cloneStarted; + expect(writeFileMock).not.toHaveBeenCalled(); + expect(rmMock).not.toHaveBeenCalledWith(destination, { + recursive: true, + force: true, + }); + expect(renameMock).not.toHaveBeenCalled(); resolveClone?.(); await clonePromise; - expect(writeFileMock).toHaveBeenCalledTimes(1); + + const writeOrder = writeFileMock.mock.invocationCallOrder[0]; + const removeOrder = rmMock.mock.invocationCallOrder.find( + (_, index) => rmMock.mock.calls[index][0] === destination, + ); + const renameOrder = renameMock.mock.invocationCallOrder[0]; + expect(writeOrder).toBeLessThan(removeOrder as number); + expect(removeOrder).toBeLessThan(renameOrder); }); - it('does not write metadata when cloning fails', async () => { + it('cleans its temp directory without touching the final entry when cloning fails', async () => { + existsSyncMock.mockReturnValue(true); gitCloneMock.mockRejectedValueOnce(new Error('clone failed')); + rmMock.mockRejectedValueOnce(new Error('cleanup failed')); await expect( cloneIntoCache('systems', ['cornflake'])(cloneOptions), ).rejects.toThrow('clone failed'); expect(writeFileMock).not.toHaveBeenCalled(); + expect(renameMock).not.toHaveBeenCalled(); + expect(rmMock).toHaveBeenCalledWith(temporaryDestination, { + recursive: true, + force: true, + }); + expect(rmMock).not.toHaveBeenCalledWith(destination, { + recursive: true, + force: true, + }); + }); + + it('reuses a complete entry that wins a concurrent rename', async () => { + existsSyncMock + .mockReturnValueOnce(false) + .mockReturnValueOnce(false) + .mockReturnValueOnce(true); + readFileMock.mockResolvedValueOnce(metadata()); + renameMock.mockRejectedValueOnce( + Object.assign(new Error('destination exists'), { code: 'ENOTEMPTY' }), + ); + + await expect( + cloneIntoCache('systems', ['cornflake'])(cloneOptions), + ).resolves.toBeUndefined(); + + expect(renameMock).toHaveBeenCalledWith(temporaryDestination, destination); + expect(rmMock).not.toHaveBeenCalledWith(destination, { + recursive: true, + force: true, + }); + expect(rmMock).toHaveBeenCalledWith(temporaryDestination, { + recursive: true, + force: true, + }); + }); + + it('cleans abandoned temp directories without removing active peers', async () => { + const abandonedName = `${basename(destination)}.tmp-12345-abc123`; + const abandonedPath = join(dirname(destination), abandonedName); + const activeName = `${basename(destination)}.tmp-67890-def456`; + const activePath = join(dirname(destination), activeName); + const legitimateName = `${basename(destination)}.tmp-custom`; + const legitimatePath = join(dirname(destination), legitimateName); + existsSyncMock.mockReturnValueOnce(true); + readFileMock.mockResolvedValueOnce(metadata()); + readdirMock.mockResolvedValueOnce([ + { + name: abandonedName, + isDirectory: () => true, + }, + { + name: activeName, + isDirectory: () => true, + }, + { + name: legitimateName, + isDirectory: () => true, + }, + ]); + statMock + .mockResolvedValueOnce({ + mtimeMs: Date.now() - 24 * 60 * 60 * 1_000 - 1, + }) + .mockResolvedValueOnce({ mtimeMs: Date.now() }); + + await cloneIntoCache('systems', ['cornflake'])(cloneOptions); + + expect(rmMock).toHaveBeenCalledWith(abandonedPath, { + recursive: true, + force: true, + }); + expect(rmMock).not.toHaveBeenCalledWith(activePath, { + recursive: true, + force: true, + }); + expect(rmMock).not.toHaveBeenCalledWith(legitimatePath, { + recursive: true, + force: true, + }); + expect(gitCloneMock).not.toHaveBeenCalled(); + expect(renameMock).not.toHaveBeenCalled(); }); it('reuses a complete cache entry without contacting the remote by default', async () => { @@ -269,7 +389,7 @@ describe('cloneIntoCache', () => { }); it('re-clones when the requested branch or tag no longer exists', async () => { - existsSyncMock.mockReturnValueOnce(true); + existsSyncMock.mockReturnValue(true); readFileMock.mockResolvedValueOnce(metadata()); listRemoteMock.mockResolvedValueOnce(''); @@ -282,7 +402,7 @@ describe('cloneIntoCache', () => { }); it('removes and re-clones an entry with no sidecar', async () => { - existsSyncMock.mockReturnValueOnce(true); + existsSyncMock.mockReturnValue(true); await cloneIntoCache('systems', ['cornflake'])(cloneOptions); @@ -294,7 +414,7 @@ describe('cloneIntoCache', () => { }); it('removes and re-clones an entry with malformed sidecar JSON', async () => { - existsSyncMock.mockReturnValueOnce(true); + existsSyncMock.mockReturnValue(true); readFileMock.mockResolvedValueOnce('{not-json'); await cloneIntoCache('systems', ['cornflake'])(cloneOptions); @@ -341,7 +461,7 @@ describe('cloneIntoCache', () => { complete: false, }, ])('re-clones an entry with incomplete metadata: %p', async (value) => { - existsSyncMock.mockReturnValueOnce(true); + existsSyncMock.mockReturnValue(true); readFileMock.mockResolvedValueOnce(JSON.stringify(value)); await cloneIntoCache('systems', ['cornflake'])(cloneOptions); @@ -351,7 +471,7 @@ describe('cloneIntoCache', () => { }); it('removes and re-clones an entry for a different repository', async () => { - existsSyncMock.mockReturnValueOnce(true); + existsSyncMock.mockReturnValue(true); readFileMock.mockResolvedValueOnce( metadata({ repository: 'different-repo-path' }), ); @@ -364,7 +484,7 @@ describe('cloneIntoCache', () => { }); it('removes and re-clones an entry whose origin does not match', async () => { - existsSyncMock.mockReturnValueOnce(true); + existsSyncMock.mockReturnValue(true); readFileMock.mockResolvedValueOnce(metadata()); getRemotesMock.mockResolvedValueOnce([ { @@ -380,7 +500,7 @@ describe('cloneIntoCache', () => { }); it('removes and re-clones an entry whose origin cannot be inspected', async () => { - existsSyncMock.mockReturnValueOnce(true); + existsSyncMock.mockReturnValue(true); readFileMock.mockResolvedValueOnce(metadata()); getRemotesMock.mockRejectedValueOnce(new Error('not a git repository')); @@ -391,7 +511,7 @@ describe('cloneIntoCache', () => { }); it('removes and re-clones an entry whose local HEAD changed', async () => { - existsSyncMock.mockReturnValueOnce(true); + existsSyncMock.mockReturnValue(true); readFileMock.mockResolvedValueOnce(metadata()); revparseMock .mockResolvedValueOnce('different-local-ref\n') @@ -404,7 +524,7 @@ describe('cloneIntoCache', () => { }); it('removes and re-clones when a moving remote ref advances', async () => { - existsSyncMock.mockReturnValueOnce(true); + existsSyncMock.mockReturnValue(true); readFileMock.mockResolvedValueOnce(metadata()); listRemoteMock.mockResolvedValueOnce( 'new-resolved-ref\trefs/heads/branch-name\n', @@ -427,7 +547,7 @@ describe('cloneIntoCache', () => { expect(gitCloneMock).toHaveBeenCalledWith( normalizedRepository, - defaultDestination, + `${defaultDestination}.tmp-${process.pid}-abc123`, {}, ); const writtenMetadata = JSON.parse(writeFileMock.mock.calls[0][1]); diff --git a/src/util/cache/cloneIntoCache.ts b/src/util/cache/cloneIntoCache.ts index 328e9ab..b97b1c3 100644 --- a/src/util/cache/cloneIntoCache.ts +++ b/src/util/cache/cloneIntoCache.ts @@ -3,7 +3,7 @@ import type { GitCloneOptions } from '@emulsify-cli/git'; import { simpleGit } from 'simple-git'; import { existsSync, promises as fs } from 'fs'; -import { dirname, join } from 'path'; +import { basename, dirname, join } from 'path'; import { EMULSIFY_CACHE_METADATA_FILE } from '../../lib/constants.js'; import getCachedItemPath from './getCachedItemPath.js'; @@ -27,6 +27,59 @@ type CacheReuseOptions = { }; const REMOTE_REF_LOOKUP_TIMEOUT_MS = 2_000; +const TEMP_DIRECTORY_MARKER = '.tmp-'; +const ABANDONED_TEMP_DIRECTORY_AGE_MS = 24 * 60 * 60 * 1_000; +const TEMP_DIRECTORY_SUFFIX_PATTERN = /^\d+-[a-z\d]{6}$/i; + +async function cleanupAbandonedTemporaryDirectories( + destination: string, + parentDir: string, +): Promise<void> { + const prefix = `${basename(destination)}${TEMP_DIRECTORY_MARKER}`; + let entries; + + try { + entries = await fs.readdir(parentDir, { withFileTypes: true }); + } catch { + // Stale temp cleanup is best-effort and must not block cache population. + return; + } + + await Promise.all( + entries.map(async (entry) => { + if ( + !entry.isDirectory() || + !entry.name.startsWith(prefix) || + !TEMP_DIRECTORY_SUFFIX_PATTERN.test(entry.name.slice(prefix.length)) + ) { + return; + } + + try { + const temporaryPath = join(parentDir, entry.name); + const stats = await fs.stat(temporaryPath); + if (Date.now() - stats.mtimeMs < ABANDONED_TEMP_DIRECTORY_AGE_MS) { + return; + } + + await fs.rm(temporaryPath, { + recursive: true, + force: true, + }); + } catch { + // Another process may have cleaned the same abandoned directory. + } + }), + ); +} + +function isRenameContention(error: unknown): boolean { + if (error === null || typeof error !== 'object' || !('code' in error)) { + return false; + } + + return ['EEXIST', 'ENOTEMPTY', 'EPERM'].includes(String(error.code)); +} function isCacheMetadata(value: unknown): value is CacheMetadata { if (!value || typeof value !== 'object') { @@ -141,13 +194,16 @@ async function canReuseCacheEntry( repository: string, checkout: string | void, refresh: boolean, + expectedResolvedRef?: string, ): Promise<boolean> { const metadata = await readCacheMetadata(destination); const expectedCheckout = checkout || null; if ( !metadata || metadata.repository !== repository || - metadata.checkout !== expectedCheckout + metadata.checkout !== expectedCheckout || + (expectedResolvedRef !== undefined && + metadata.resolvedRef !== expectedResolvedRef) ) { return false; } @@ -206,6 +262,9 @@ export default function cloneIntoCache( }); const parentDir = dirname(destination); + await fs.mkdir(parentDir, { recursive: true }); + await cleanupAbandonedTemporaryDirectories(destination, parentDir); + // Reuse only complete clones whose identity, origin, and resolved ref match. if (existsSync(destination)) { if ( @@ -218,32 +277,83 @@ export default function cloneIntoCache( ) { return; } - - await fs.rm(destination, { recursive: true, force: true }); } - await fs.mkdir(parentDir, { recursive: true }); - - const git = simpleGit(); - await git.clone( - normalizedRepository, - destination, - checkout - ? { - '--branch': checkout, - } - : {}, + const temporaryDestination = await fs.mkdtemp( + `${destination}${TEMP_DIRECTORY_MARKER}${process.pid}-`, ); + let removeTemporaryDestination = true; - const resolvedRef = ( - await simpleGit(destination).revparse(['HEAD']) - ).trim(); - await writeCacheMetadata(destination, { - repository: normalizedRepository, - checkout: checkout || null, - resolvedRef, - clonedAt: new Date().toISOString(), - complete: true, - }); + try { + const git = simpleGit(); + await git.clone( + normalizedRepository, + temporaryDestination, + checkout + ? { + '--branch': checkout, + } + : {}, + ); + + const resolvedRef = ( + await simpleGit(temporaryDestination).revparse(['HEAD']) + ).trim(); + await writeCacheMetadata(temporaryDestination, { + repository: normalizedRepository, + checkout: checkout || null, + resolvedRef, + clonedAt: new Date().toISOString(), + complete: true, + }); + + if (existsSync(destination)) { + if ( + await canReuseCacheEntry( + destination, + normalizedRepository, + checkout, + false, + resolvedRef, + ) + ) { + return; + } + + await fs.rm(destination, { recursive: true, force: true }); + } + + try { + await fs.rename(temporaryDestination, destination); + removeTemporaryDestination = false; + } catch (error) { + if ( + isRenameContention(error) && + existsSync(destination) && + (await canReuseCacheEntry( + destination, + normalizedRepository, + checkout, + false, + resolvedRef, + )) + ) { + return; + } + + throw error; + } + } finally { + if (removeTemporaryDestination) { + try { + await fs.rm(temporaryDestination, { + recursive: true, + force: true, + }); + } catch { + // Cleanup must not hide the clone result; a later run prunes leftovers. + } + } + } }; }