feat: CLI-утилита для генерации файлов из шаблонов

Реализовано:
- Генерация файлов из папки .templates с подстановкой переменных
- Цветной вывод в терминал с иконками и деревом файлов
- Валидация аргументов и проверка существования папок
- Поддержка --dry-run, --overwrite, произвольных переменных
This commit is contained in:
2026-01-26 20:27:38 +03:00
commit c46122e62d
14 changed files with 1343 additions and 0 deletions

6
.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
node_modules/
dist/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*

View File

@@ -0,0 +1 @@
export * from './{{name.pascalCase}}'

View File

@@ -0,0 +1,4 @@
.wrapper {
}

View File

@@ -0,0 +1,16 @@
import { FC } from 'react'
import styles from './{{name.pascalCase}}.module.css'
import cl from 'clsx'
interface IOwnProps {
className?: string
}
export const {{name.pascalCase}}: FC<IOwnProps> = ({className}) => {
return (
<div className={cl(styles.wrapper, className)}>
{{name.pascalCase}}
</div>
)
}

592
package-lock.json generated Normal file
View File

@@ -0,0 +1,592 @@
{
"name": "@gromlab/create",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@gromlab/create",
"version": "0.1.0",
"dependencies": {
"archy": "^1.0.0",
"boxen": "^5.1.2",
"chalk": "^4.1.2",
"change-case-all": "^2.1.0",
"directory-tree": "^3.5.2",
"figures": "^3.2.0"
},
"bin": {
"gromlab-create": "dist/cli.js"
},
"devDependencies": {
"@types/archy": "^0.0.31",
"@types/node": "^20.11.24",
"typescript": "^5.8.3"
}
},
"node_modules/@types/archy": {
"version": "0.0.31",
"resolved": "https://registry.npmjs.org/@types/archy/-/archy-0.0.31.tgz",
"integrity": "sha512-v+dxizsFVyXgD3EpFuqT9YjdEjbJmPxNf1QIX9ohZOhxh1ZF2yhqv3vYaeum9lg3VghhxS5S0a6yldN9J9lPEQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "20.19.30",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.30.tgz",
"integrity": "sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/ansi-align": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.1.tgz",
"integrity": "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==",
"license": "ISC",
"dependencies": {
"string-width": "^4.1.0"
}
},
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/archy": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/archy/-/archy-1.0.0.tgz",
"integrity": "sha512-Xg+9RwCg/0p32teKdGMPTPnVXKD0w3DfHnFTficozsAgsvq2XenPJq/MYpzzQ/v8zrOyJn6Ds39VA4JIDwFfqw==",
"license": "MIT"
},
"node_modules/array-back": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/array-back/-/array-back-3.1.0.tgz",
"integrity": "sha512-TkuxA4UCOvxuDK6NZYXCalszEzj+TLszyASooky+i742l9TqsOdYCMJJupxRic61hwquNtppB3hgcuq9SVSH1Q==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/boxen": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/boxen/-/boxen-5.1.2.tgz",
"integrity": "sha512-9gYgQKXx+1nP8mP7CzFyaUARhg7D3n1dF/FnErWmu9l6JvGpNUN278h0aSb+QjoiKSWG+iZ3uHrcqk0qrY9RQQ==",
"license": "MIT",
"dependencies": {
"ansi-align": "^3.0.0",
"camelcase": "^6.2.0",
"chalk": "^4.1.0",
"cli-boxes": "^2.2.1",
"string-width": "^4.2.2",
"type-fest": "^0.20.2",
"widest-line": "^3.1.0",
"wrap-ansi": "^7.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/camelcase": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz",
"integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/change-case": {
"version": "5.4.4",
"resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz",
"integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==",
"license": "MIT"
},
"node_modules/change-case-all": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/change-case-all/-/change-case-all-2.1.0.tgz",
"integrity": "sha512-v6b0WWWkZUMHVuYk82l+WROgkUm4qEN2w5hKRNWtEOYwWqUGoi8C6xH0l1RLF1EoWqDFK6MFclmN3od6ws3/uw==",
"license": "MIT",
"dependencies": {
"change-case": "^5.2.0",
"sponge-case": "^2.0.2",
"swap-case": "^3.0.2",
"title-case": "^3.0.3"
}
},
"node_modules/cli-boxes": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-2.2.1.tgz",
"integrity": "sha512-y4coMcylgSCdVinjiDBuR8PCC2bLjyGTwEmPb9NHR/QaNU6EUOXcTY/s6VjGMD6ENSEaeQYHCY0GNGS5jfMwPw==",
"license": "MIT",
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"license": "MIT"
},
"node_modules/command-line-args": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/command-line-args/-/command-line-args-5.2.1.tgz",
"integrity": "sha512-H4UfQhZyakIjC74I9d34fGYDwk3XpSr17QhEd0Q3I9Xq1CETHo4Hcuo87WyWHpAF1aSLjLRf5lD9ZGX2qStUvg==",
"license": "MIT",
"dependencies": {
"array-back": "^3.1.0",
"find-replace": "^3.0.0",
"lodash.camelcase": "^4.3.0",
"typical": "^4.0.0"
},
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/command-line-usage": {
"version": "6.1.3",
"resolved": "https://registry.npmjs.org/command-line-usage/-/command-line-usage-6.1.3.tgz",
"integrity": "sha512-sH5ZSPr+7UStsloltmDh7Ce5fb8XPlHyoPzTpyyMuYCtervL65+ubVZ6Q61cFtFl62UyJlc8/JwERRbAFPUqgw==",
"license": "MIT",
"dependencies": {
"array-back": "^4.0.2",
"chalk": "^2.4.2",
"table-layout": "^1.0.2",
"typical": "^5.2.0"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/command-line-usage/node_modules/ansi-styles": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz",
"integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==",
"license": "MIT",
"dependencies": {
"color-convert": "^1.9.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/command-line-usage/node_modules/array-back": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/array-back/-/array-back-4.0.2.tgz",
"integrity": "sha512-NbdMezxqf94cnNfWLL7V/im0Ub+Anbb0IoZhvzie8+4HJ4nMQuzHuy49FkGYCJK2yAloZ3meiB6AVMClbrI1vg==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/command-line-usage/node_modules/chalk": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
"integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^3.2.1",
"escape-string-regexp": "^1.0.5",
"supports-color": "^5.3.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/command-line-usage/node_modules/color-convert": {
"version": "1.9.3",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
"integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==",
"license": "MIT",
"dependencies": {
"color-name": "1.1.3"
}
},
"node_modules/command-line-usage/node_modules/color-name": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
"integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==",
"license": "MIT"
},
"node_modules/command-line-usage/node_modules/has-flag": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
"integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/command-line-usage/node_modules/supports-color": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
"integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
"license": "MIT",
"dependencies": {
"has-flag": "^3.0.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/command-line-usage/node_modules/typical": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/typical/-/typical-5.2.0.tgz",
"integrity": "sha512-dvdQgNDNJo+8B2uBQoqdb11eUCE1JQXhvjC/CZtgvZseVd5TYMXnq0+vuUemXbd/Se29cTaUuPX3YIc2xgbvIg==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/deep-extend": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
"integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
"license": "MIT",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/directory-tree": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/directory-tree/-/directory-tree-3.6.0.tgz",
"integrity": "sha512-rTMWs+zxr0QEbzQKRfwV6SeEy+zIHFkorskI4bhG2o7ayr82c+FC7yWg3yLpurgp6Hs2NGy1NWrKIaDodr2r8A==",
"license": "MIT",
"dependencies": {
"command-line-args": "^5.2.0",
"command-line-usage": "^6.1.1"
},
"bin": {
"directory-tree": "bin/index.js"
},
"engines": {
"node": ">=10.0"
}
},
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/escape-string-regexp": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
"integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==",
"license": "MIT",
"engines": {
"node": ">=0.8.0"
}
},
"node_modules/figures": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz",
"integrity": "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==",
"license": "MIT",
"dependencies": {
"escape-string-regexp": "^1.0.5"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/find-replace": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/find-replace/-/find-replace-3.0.0.tgz",
"integrity": "sha512-6Tb2myMioCAgv5kfvP5/PkZZ/ntTpVK39fHY7WkWBgvbeE+VHd/tZuZ4mrC+bxh4cfOZeYKVPaJIZtZXV7GNCQ==",
"license": "MIT",
"dependencies": {
"array-back": "^3.0.1"
},
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/lodash.camelcase": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz",
"integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==",
"license": "MIT"
},
"node_modules/reduce-flatten": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/reduce-flatten/-/reduce-flatten-2.0.0.tgz",
"integrity": "sha512-EJ4UNY/U1t2P/2k6oqotuX2Cc3T6nxJwsM0N0asT7dhrtH1ltUxDn4NalSYmPE2rCkVpcf/X6R0wDwcFpzhd4w==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/sponge-case": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/sponge-case/-/sponge-case-2.0.3.tgz",
"integrity": "sha512-i4h9ZGRfxV6Xw3mpZSFOfbXjf0cQcYmssGWutgNIfFZ2VM+YIWfD71N/kjjwK6X/AAHzBr+rciEcn/L34S8TGw==",
"license": "MIT"
},
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/swap-case": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/swap-case/-/swap-case-3.0.3.tgz",
"integrity": "sha512-6p4op8wE9CQv7uDFzulI6YXUw4lD9n4oQierdbFThEKVWVQcbQcUjdP27W8XE7V4QnWmnq9jueSHceyyQnqQVA==",
"license": "MIT"
},
"node_modules/table-layout": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/table-layout/-/table-layout-1.0.2.tgz",
"integrity": "sha512-qd/R7n5rQTRFi+Zf2sk5XVVd9UQl6ZkduPFC3S7WEGJAmetDTjY3qPN50eSKzwuzEyQKy5TN2TiZdkIjos2L6A==",
"license": "MIT",
"dependencies": {
"array-back": "^4.0.1",
"deep-extend": "~0.6.0",
"typical": "^5.2.0",
"wordwrapjs": "^4.0.0"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/table-layout/node_modules/array-back": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/array-back/-/array-back-4.0.2.tgz",
"integrity": "sha512-NbdMezxqf94cnNfWLL7V/im0Ub+Anbb0IoZhvzie8+4HJ4nMQuzHuy49FkGYCJK2yAloZ3meiB6AVMClbrI1vg==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/table-layout/node_modules/typical": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/typical/-/typical-5.2.0.tgz",
"integrity": "sha512-dvdQgNDNJo+8B2uBQoqdb11eUCE1JQXhvjC/CZtgvZseVd5TYMXnq0+vuUemXbd/Se29cTaUuPX3YIc2xgbvIg==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/title-case": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/title-case/-/title-case-3.0.3.tgz",
"integrity": "sha512-e1zGYRvbffpcHIrnuqT0Dh+gEJtDaxDSoG4JAIpq4oDFyooziLBIiYQv0GBT4FUAnUop5uZ1hiIAj7oAF6sOCA==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.3"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/type-fest": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz",
"integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==",
"license": "(MIT OR CC0-1.0)",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/typical": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/typical/-/typical-4.0.0.tgz",
"integrity": "sha512-VAH4IvQ7BDFYglMd7BPRDfLgxZZX4O4TFcRDA6EN5X7erNJJq+McIEp8np9aVtxrCJ6qx4GTYVfOWNjcqwZgRw==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
"node_modules/widest-line": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/widest-line/-/widest-line-3.1.0.tgz",
"integrity": "sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg==",
"license": "MIT",
"dependencies": {
"string-width": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/wordwrapjs": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/wordwrapjs/-/wordwrapjs-4.0.1.tgz",
"integrity": "sha512-kKlNACbvHrkpIw6oPeYDSmdCTu2hdMHoyXLTcUKala++lx5Y+wjJ/e474Jqv5abnVmwxw08DiTuHmw69lJGksA==",
"license": "MIT",
"dependencies": {
"reduce-flatten": "^2.0.0",
"typical": "^5.2.0"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/wordwrapjs/node_modules/typical": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/typical/-/typical-5.2.0.tgz",
"integrity": "sha512-dvdQgNDNJo+8B2uBQoqdb11eUCE1JQXhvjC/CZtgvZseVd5TYMXnq0+vuUemXbd/Se29cTaUuPX3YIc2xgbvIg==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/wrap-ansi": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
}
}
}

30
package.json Normal file
View File

@@ -0,0 +1,30 @@
{
"name": "@gromlab/create",
"version": "0.1.0",
"description": "Template-based file generator CLI",
"bin": {
"gromlab-create": "dist/cli.js"
},
"main": "dist/cli.js",
"files": [
"dist"
],
"scripts": {
"build": "tsc -p tsconfig.json",
"dev": "tsc -p tsconfig.json -w",
"prepare": "npm run build"
},
"dependencies": {
"archy": "^1.0.0",
"boxen": "^5.1.2",
"chalk": "^4.1.2",
"change-case-all": "^2.1.0",
"directory-tree": "^3.5.2",
"figures": "^3.2.0"
},
"devDependencies": {
"@types/archy": "^0.0.31",
"@types/node": "^20.11.24",
"typescript": "^5.8.3"
}
}

101
src/args.ts Normal file
View File

@@ -0,0 +1,101 @@
import { ParsedArgs } from './types';
export function printHelp() {
const lines = [
'Использование:',
' npx @gromlab/create <template> [name] [options]',
'',
'Опции:',
' --<var> <value> Переменная шаблона (поддерживается любой --key <value>)',
' [name] Сокращение для --name',
' --templates <path> Папка шаблонов (по умолчанию: .templates)',
' --templates-path Алиас для --templates',
' --out <path> Папка вывода (по умолчанию: текущая директория)',
' --overwrite Перезаписывать существующие файлы',
' --dry-run Показать результат без записи на диск',
' -h, --help Показать эту справку',
'',
'Примеры:',
' npx @gromlab/create component --name test',
' npx @gromlab/create component --name test --out src/components',
' npx @gromlab/create component --name test --templates /path/to/.templates'
];
console.log(lines.join('\n'));
}
function consumeValue(args: string[], index: number, key: string): string {
const next = args[index + 1];
if (!next || next.startsWith('-')) {
throw new Error(`Missing value for --${key}`);
}
return next;
}
export function parseArgs(argv: string[]): ParsedArgs {
const parsed: ParsedArgs = {
vars: {},
overwrite: false,
dryRun: false,
help: false,
extra: []
};
const args = argv.slice(2);
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === '-h' || arg === '--help') {
parsed.help = true;
continue;
}
if (arg === '--overwrite') {
parsed.overwrite = true;
continue;
}
if (arg === '--dry-run') {
parsed.dryRun = true;
continue;
}
if (arg.startsWith('--')) {
const eqIndex = arg.indexOf('=');
const key = eqIndex === -1 ? arg.slice(2) : arg.slice(2, eqIndex);
const inlineValue = eqIndex === -1 ? undefined : arg.slice(eqIndex + 1);
if (!key) continue;
if (key === 'templates' || key === 'templatesPath' || key === 'templates-path') {
const value = inlineValue ?? consumeValue(args, i, key);
if (inlineValue === undefined) i++;
parsed.templatesPath = value;
continue;
}
if (key === 'out' || key === 'output') {
const value = inlineValue ?? consumeValue(args, i, key);
if (inlineValue === undefined) i++;
parsed.outDir = value;
continue;
}
const value = inlineValue ?? consumeValue(args, i, key);
if (inlineValue === undefined) i++;
parsed.vars[key] = value;
continue;
}
if (!parsed.templateName) {
parsed.templateName = arg;
continue;
}
if (!parsed.positionalName) {
parsed.positionalName = arg;
continue;
}
parsed.extra.push(arg);
}
return parsed;
}

121
src/cli.ts Normal file
View File

@@ -0,0 +1,121 @@
#!/usr/bin/env node
import * as path from 'path';
import { parseArgs, printHelp } from './args';
import { printError, printWarnings, printSummary } from './output';
import { PlanItem } from './types';
import { normalizeArgs, resolveTemplateContext } from './validation';
import { buildPlan, getCollisions, getExistingDirs, getRoots, getTopLevelDirs, writePlan } from './plan';
function resolvePath(baseDir: string, inputPath: string): string {
if (path.isAbsolute(inputPath)) return path.normalize(inputPath);
return path.resolve(baseDir, inputPath);
}
function run() {
let parsed;
try {
parsed = parseArgs(process.argv);
} catch (error) {
console.error(String(error));
process.exitCode = 1;
return;
}
if (parsed.help) {
printHelp();
return;
}
const normalizedResult = normalizeArgs(parsed);
if (normalizedResult.error) {
printError(normalizedResult.error.title, normalizedResult.error.details, normalizedResult.error.hint);
if (normalizedResult.error.showHelp) {
printHelp();
}
process.exitCode = 1;
return;
}
const normalized = normalizedResult.normalized;
const cwd = process.cwd();
const templatesDir = resolvePath(cwd, normalized.templatesPath ?? '.templates');
const outDir = resolvePath(cwd, normalized.outDir ?? '.');
const templateResult = resolveTemplateContext(
templatesDir,
normalized.templateName!,
normalized.vars
);
if (templateResult.error) {
printError(templateResult.error.title, templateResult.error.details, templateResult.error.hint);
process.exitCode = 1;
return;
}
const templateContext = templateResult.context!;
const plan: PlanItem[] = buildPlan(templateContext.templateDir, outDir, normalized.vars, templateContext.files);
const topLevelDirs = getTopLevelDirs(outDir, plan);
const existingDirs = getExistingDirs(outDir, topLevelDirs);
const redWarnings: string[][] = [];
const warnings: string[][] = [];
if (existingDirs.length > 0) {
if (!normalized.overwrite && !normalized.dryRun) {
printError(
'Папка назначения уже существует',
existingDirs.map((dir) => path.join(outDir, dir)),
'Используйте --overwrite для перезаписи'
);
process.exitCode = 1;
return;
}
const warningLines = [
'Папка назначения уже существует:',
...existingDirs.map((dir) => ` - ${path.join(outDir, dir)}`),
'Используйте --overwrite для перезаписи.'
];
redWarnings.push(warningLines);
}
if (existingDirs.length === 0) {
const collisions = getCollisions(plan);
if (collisions.length > 0 && !normalized.overwrite) {
if (normalized.dryRun) {
warnings.push([
'Файлы уже существуют:',
...collisions.map((target) => ` - ${path.relative(outDir, target)}`),
'Используйте --overwrite для перезаписи.'
]);
} else {
printError(
'Файлы уже существуют',
collisions.map((target) => path.relative(outDir, target)),
'Используйте --overwrite для перезаписи'
);
process.exitCode = 1;
return;
}
}
}
const roots = getRoots(outDir, plan);
if (normalized.dryRun) {
printSummary(plan, outDir, normalized.vars, true, normalized.templateName!, roots);
printWarnings(redWarnings, warnings);
return;
}
writePlan(plan, normalized.vars, normalized.overwrite);
printSummary(plan, outDir, normalized.vars, false, normalized.templateName!, roots);
printWarnings(redWarnings, warnings);
}
try {
run();
} catch (error) {
console.error(String(error));
process.exitCode = 1;
}

176
src/output.ts Normal file
View File

@@ -0,0 +1,176 @@
import * as fs from 'fs';
import * as path from 'path';
import archy = require('archy');
import chalk = require('chalk');
import directoryTree = require('directory-tree');
import figures = require('figures');
import { PlanItem } from './types';
type TreeNode = {
name: string;
children: Map<string, TreeNode>;
isFile: boolean;
};
function buildTreeFromPaths(rootLabel: string, paths: string[]): string {
const root: TreeNode = { name: rootLabel, children: new Map(), isFile: false };
for (const rawPath of paths) {
const parts = rawPath.split(path.sep).filter(Boolean);
let current = root;
parts.forEach((part, index) => {
const isLast = index === parts.length - 1;
let child = current.children.get(part);
if (!child) {
child = { name: part, children: new Map(), isFile: isLast };
current.children.set(part, child);
}
if (isLast) {
child.isFile = true;
}
current = child;
});
}
const toArchyNode = (node: TreeNode): archy.Data => {
const entries = Array.from(node.children.values()).sort((a, b) => a.name.localeCompare(b.name));
const isDir = node.children.size > 0;
return {
label: isDir ? chalk.cyan(`${node.name}/`) : chalk.white(node.name),
nodes: entries.map((child) => toArchyNode(child))
};
};
return archy(toArchyNode(root));
}
function buildTreeFromDirectory(rootPath: string): string {
const tree = directoryTree(rootPath, { attributes: ['type'] }) as directoryTree.DirectoryTree | null;
if (!tree) return '';
const toArchyNode = (node: directoryTree.DirectoryTree): archy.Data => {
const nodes = (node.children ?? [])
.sort((a, b) => a.name.localeCompare(b.name))
.map((child) => toArchyNode(child));
const isDir = node.type === 'directory';
const label = isDir ? chalk.cyan(`${node.name}/`) : chalk.white(node.name);
return { label, nodes };
};
return archy(toArchyNode(tree));
}
export function printError(title: string, details?: string[], hint?: string) {
console.error('');
console.error(chalk.red(`${figures.cross} ${title}`));
if (details && details.length > 0) {
console.error('');
for (const detail of details) {
console.error(chalk.red(` ${figures.pointer} ${detail}`));
}
}
if (hint) {
console.error('');
console.error(chalk.dim(` ${figures.info} ${hint}`));
}
console.error('');
}
function printRedLines(lines: string[]) {
for (const line of lines) {
console.log(chalk.red(line));
}
}
export function printWarnings(redBlocks: string[][], blocks: string[][]) {
if (redBlocks.length === 0 && blocks.length === 0) return;
console.log('');
console.log(chalk.yellow(`${figures.warning} Предупреждения:`));
for (const lines of redBlocks) {
printRedLines(lines);
}
for (const lines of blocks) {
for (const line of lines) {
console.log(chalk.yellow(line));
}
}
}
function buildVariablesList(vars: Record<string, string>): string[] {
const entries = Object.entries(vars);
if (entries.length === 0) return [chalk.dim(' (нет)')];
return entries.map(([key, value]) =>
` ${chalk.dim('--')}${chalk.blue(key)}${chalk.dim(':')} ${chalk.green(value)}`
);
}
export function printSummary(
plan: PlanItem[],
outDir: string,
vars: Record<string, string>,
isDryRun: boolean,
templateName: string,
roots: string[]
) {
const nameValue = vars.name;
const displayName = nameValue ?? templateName;
console.log('');
// Заголовок с иконкой
const statusIcon = isDryRun ? figures.info : figures.tick;
const statusColor = isDryRun ? chalk.blue : chalk.green;
const statusText = isDryRun ? 'Планируется генерация' : 'Успешно создан';
console.log(statusColor(`${statusIcon} ${statusText}: `) + chalk.bold.white(displayName));
console.log('');
// Путь/пути
if (roots.length === 1) {
console.log(chalk.dim(' Путь: ') + chalk.underline(roots[0]));
} else if (roots.length > 1) {
console.log(chalk.dim(' Пути:'));
for (const rootPath of roots) {
console.log(` ${figures.pointer} ${chalk.underline(rootPath)}`);
}
}
console.log('');
// Дерево файлов
console.log(chalk.dim(' Структура файлов:'));
console.log('');
if (roots.length === 0) {
console.log(chalk.dim(' (пусто)'));
} else {
for (const rootPath of roots) {
const treeOutput = (!isDryRun && fs.existsSync(rootPath))
? buildTreeFromDirectory(rootPath)
: buildTreeFromPaths(path.basename(rootPath) || rootPath, plan
.map((item) => path.relative(rootPath, item.target))
.filter((rel) => rel && !rel.startsWith('..')));
// Добавляем отступ к дереву
const indentedTree = treeOutput
.trimEnd()
.split('\n')
.map(line => ' ' + line)
.join('\n');
console.log(indentedTree);
if (roots.length > 1) {
console.log('');
}
}
}
console.log('');
// Переменные
console.log(chalk.dim(' Переменные:'));
const varsLines = buildVariablesList(vars);
for (const line of varsLines) {
console.log(' ' + line);
}
console.log('');
}

71
src/plan.ts Normal file
View File

@@ -0,0 +1,71 @@
import * as fs from 'fs';
import * as path from 'path';
import { renderTemplate } from './templateUtils';
import { PlanItem } from './types';
export function buildPlan(
templateDir: string,
outDir: string,
vars: Record<string, string>,
files: string[]
): PlanItem[] {
return files.map((source) => {
const relPath = path.relative(templateDir, source);
const targetRelPath = renderTemplate(relPath, vars);
if (!targetRelPath) {
throw new Error(`Rendered path is empty for template file: ${relPath}`);
}
return {
source,
target: path.join(outDir, targetRelPath)
};
});
}
export function getTopLevelDirs(outDir: string, plan: PlanItem[]): string[] {
const topLevelDirs = new Set<string>();
for (const item of plan) {
const relPath = path.relative(outDir, item.target);
const parts = relPath.split(path.sep).filter(Boolean);
if (parts.length > 1) {
topLevelDirs.add(parts[0]);
}
}
return Array.from(topLevelDirs);
}
export function getExistingDirs(outDir: string, topLevelDirs: string[]): string[] {
return topLevelDirs.filter((dir) => {
const fullPath = path.join(outDir, dir);
return fs.existsSync(fullPath) && fs.statSync(fullPath).isDirectory();
});
}
export function getCollisions(plan: PlanItem[]): string[] {
return plan
.map((item) => item.target)
.filter((target) => fs.existsSync(target));
}
export function getRoots(outDir: string, plan: PlanItem[]): string[] {
const rootPaths = new Set<string>();
for (const item of plan) {
const relPath = path.relative(outDir, item.target);
const parts = relPath.split(path.sep).filter(Boolean);
if (parts.length <= 1) {
rootPaths.add(outDir);
} else {
rootPaths.add(path.join(outDir, parts[0]));
}
}
return Array.from(rootPaths);
}
export function writePlan(plan: PlanItem[], vars: Record<string, string>, overwrite: boolean) {
for (const item of plan) {
const content = fs.readFileSync(item.source, 'utf8');
const rendered = renderTemplate(content, vars);
fs.mkdirSync(path.dirname(item.target), { recursive: true });
fs.writeFileSync(item.target, rendered, { flag: overwrite ? 'w' : 'wx' });
}
}

72
src/templateUtils.ts Normal file
View File

@@ -0,0 +1,72 @@
import * as fs from 'fs';
import * as path from 'path';
import { camelCase, pascalCase, snakeCase, kebabCase, constantCase, upperCase, lowerCase } from 'change-case-all';
export const CASE_MODIFIERS: Record<string, (input: string) => string> = {
pascalCase,
camelCase,
snakeCase,
kebabCase,
screamingSnakeCase: constantCase,
upperCase,
lowerCase,
upperCaseAll: (value: string) => value.replace(/[-_\s]+/g, '').toUpperCase(),
lowerCaseAll: (value: string) => value.replace(/[-_\s]+/g, '').toLowerCase()
};
const VARIABLE_PATTERN = /{{\s*([a-zA-Z0-9_]+)(?:\.([a-zA-Z0-9_]+))?\s*}}/g;
export function readDirRecursive(dir: string): string[] {
const entries = fs.readdirSync(dir, { withFileTypes: true });
const files: string[] = [];
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
files.push(...readDirRecursive(fullPath));
} else if (entry.isFile()) {
files.push(fullPath);
}
}
return files;
}
export function collectTemplateVariables(templateDir: string): Set<string> {
const vars = new Set<string>();
const files = readDirRecursive(templateDir);
for (const file of files) {
const relPath = path.relative(templateDir, file);
{
const pathRegex = /{{\s*([a-zA-Z0-9_]+)(?:\.[a-zA-Z0-9_]+)?\s*}}/g;
let match: RegExpExecArray | null;
while ((match = pathRegex.exec(relPath)) !== null) {
vars.add(match[1]);
}
}
{
const content = fs.readFileSync(file, 'utf8');
const contentRegex = /{{\s*([a-zA-Z0-9_]+)(?:\.[a-zA-Z0-9_]+)?\s*}}/g;
let match: RegExpExecArray | null;
while ((match = contentRegex.exec(content)) !== null) {
vars.add(match[1]);
}
}
}
return vars;
}
export function renderTemplate(input: string, vars: Record<string, string>): string {
return input.replace(VARIABLE_PATTERN, (_match, varName: string, modifier: string | undefined) => {
const value = vars[varName];
if (value === undefined) return '';
if (modifier && CASE_MODIFIERS[modifier]) {
return CASE_MODIFIERS[modifier](value);
}
return value;
});
}
export function listTemplateNames(templatesDir: string): string[] {
if (!fs.existsSync(templatesDir)) return [];
const entries = fs.readdirSync(templatesDir, { withFileTypes: true });
return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort();
}

28
src/types.ts Normal file
View File

@@ -0,0 +1,28 @@
export type ParsedArgs = {
templateName?: string;
positionalName?: string;
templatesPath?: string;
outDir?: string;
vars: Record<string, string>;
overwrite: boolean;
dryRun: boolean;
help: boolean;
extra: string[];
};
export type PlanItem = {
source: string;
target: string;
};
export type ValidationError = {
title: string;
details?: string[];
hint?: string;
showHelp?: boolean;
};
export type TemplateContext = {
templateDir: string;
files: string[];
};

109
src/validation.ts Normal file
View File

@@ -0,0 +1,109 @@
import * as fs from 'fs';
import * as path from 'path';
import { collectTemplateVariables, listTemplateNames, readDirRecursive } from './templateUtils';
import { ParsedArgs, ValidationError, TemplateContext } from './types';
export function normalizeArgs(parsed: ParsedArgs): { normalized: ParsedArgs; error?: ValidationError } {
const normalized: ParsedArgs = {
...parsed,
vars: { ...parsed.vars },
extra: [...parsed.extra]
};
if (!normalized.templateName) {
return {
normalized,
error: {
title: 'Требуется имя шаблона',
showHelp: true
}
};
}
if (normalized.positionalName && normalized.vars.name) {
return {
normalized,
error: {
title: 'name задан дважды',
details: ['позиционно и через --name']
}
};
}
if (normalized.positionalName && !normalized.vars.name) {
normalized.vars.name = normalized.positionalName;
}
if (normalized.extra.length > 0) {
return {
normalized,
error: {
title: 'Неожиданные аргументы',
details: normalized.extra
}
};
}
return { normalized };
}
export function resolveTemplateContext(
templatesDir: string,
templateName: string,
vars: Record<string, string>
): { context?: TemplateContext; error?: ValidationError } {
if (!fs.existsSync(templatesDir) || !fs.statSync(templatesDir).isDirectory()) {
return {
error: {
title: 'Папка шаблонов не найдена',
details: [templatesDir]
}
};
}
const availableTemplates = listTemplateNames(templatesDir);
if (availableTemplates.length === 0) {
return {
error: {
title: 'В папке шаблонов нет шаблонов'
}
};
}
if (!availableTemplates.includes(templateName)) {
return {
error: {
title: `Шаблон не найден: ${templateName}`,
details: availableTemplates,
hint: 'Доступные шаблоны указаны выше'
}
};
}
const templateDir = path.join(templatesDir, templateName);
const requiredVars = collectTemplateVariables(templateDir);
const missingVars = Array.from(requiredVars).filter((name) => {
const value = vars[name];
return value === undefined || value.length === 0;
});
if (missingVars.length > 0) {
return {
error: {
title: 'Не заданы переменные шаблона',
details: missingVars.map((name) => `--${name} <value>`)
}
};
}
const files = readDirRecursive(templateDir);
if (files.length === 0) {
return {
error: {
title: 'Шаблон пустой'
}
};
}
return { context: { templateDir, files } };
}

16
tsconfig.json Normal file
View File

@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "CommonJS",
"moduleResolution": "Node",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true
},
"include": [
"src/**/*.ts"
]
}