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 1/9] 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 2/9] 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 3/9] 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 4/9] 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 5/9] 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 6/9] 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 7/9] 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 8/9] 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 9/9] 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')