feat: автообновление, шаблон zustand-store, документация

- Добавлено автоматическое обновление со спиннером (ora)
- Новый шаблон zustand-store
- Документация (FEATURES.md, LICENSE, README)
This commit is contained in:
2026-01-27 12:33:11 +03:00
parent d58eb04456
commit bdb7180d92
14 changed files with 725 additions and 124 deletions

View File

@@ -0,0 +1,2 @@
export * from './{{name.camelCase}}Store.type';
export * from './{{name.camelCase}}Store';

View File

@@ -0,0 +1,27 @@
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';
import type { {{name.pascalCase}}State, {{name.pascalCase}}Store } from './{{name.camelCase}}Store.type';
export const defaultInitState: {{name.pascalCase}}State = {
deviceId: null,
};
const setDeviceId = (state: {{name.pascalCase}}Store, deviceId: string) => {
state.deviceId = deviceId;
};
export const use{{name.pascalCase}}Store = create<{{name.pascalCase}}Store>()(
devtools(
persist(
immer<{{name.pascalCase}}Store>((set) => ({
...defaultInitState,
setDeviceId: (deviceId) => set((state) => setDeviceId(state, deviceId)),
})),
{
name: '{{name.kebabCase}}-storage',
}
),
{ name: '{{name.pascalCase}}Store' }
)
);

View File

@@ -0,0 +1,9 @@
export type {{name.pascalCase}}State = {
deviceId: string | null;
};
export type {{name.pascalCase}}Actions = {
setDeviceId: (deviceId: string) => void;
};
export type {{name.pascalCase}}Store = {{name.pascalCase}}State & {{name.pascalCase}}Actions;

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Gromlab
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -2,23 +2,34 @@
CLI-утилита для генерации файлов из шаблонов.
## Установка
Глобально:
```bash
npm i -g @gromlab/create
```
При запуске CLI проверяет доступность новой версии. Если вы выбираете «нет»,
повторный запрос появится через 24 часа. Чтобы пропустить проверку, используйте
флаг `--skip-update`.
## Использование
```bash
npx @gromlab/create <шаблон> <имя> [опции]
npx @gromlab/create <шаблон> <имя> [путь] [опции]
```
Если `[путь]` не указан, файлы создаются в директории, где запущен CLI.
## Пример
```bash
# Создать компонент из шаблона
npx @gromlab/create component Button
# Указать папку вывода
npx @gromlab/create component Button --out src/components
# Превью без записи
npx @gromlab/create component Button --dry-run
# Указать папку вывода позиционно
npx @gromlab/create component Button src/components
```
## Шаблоны
@@ -43,12 +54,12 @@ npx @gromlab/create component Button --dry-run
- `{{name.kebab}}` — kebab-case
- `{{name.snake}}` — snake_case
Переменная `name` задается только позиционным аргументом `<имя>`. Использование `--name` запрещено.
## Опции
| Опция | Описание |
|-------|----------|
| `--out <путь>` | Папка вывода (по умолчанию: `.`) |
| `--templates <путь>` | Папка шаблонов (по умолчанию: `.templates`) |
| `--overwrite` | Перезаписать существующие файлы |
| `--dry-run` | Показать результат без записи |
| `--skip-update` | Не проверять обновления CLI |
| `--<переменная> <значение>` | Произвольная переменная шаблона |

52
docs/ru/FEATURES.md Normal file
View File

@@ -0,0 +1,52 @@
# Возможности CLI
Ниже перечислены текущие возможности `@gromlab/create`.
## Основные
- Генерация файлов и папок по шаблонам из каталога `.templates/`.
- Обязательный позиционный аргумент `<имя>` (переменная `name`).
- Опциональный позиционный `[путь]` — папка вывода (по умолчанию текущая директория).
- Произвольные переменные шаблонов через `--<var> <value>`.
- Подстановка переменных в **содержимом** файлов и **путях**.
## Модификаторы переменных
Поддерживаются модификаторы для `{{var}}`:
- `{{name.pascalCase}}`
- `{{name.camelCase}}`
- `{{name.snakeCase}}`
- `{{name.kebabCase}}`
- `{{name.screamingSnakeCase}}`
- `{{name.upperCase}}`
- `{{name.lowerCase}}`
- `{{name.upperCaseAll}}`
- `{{name.lowerCaseAll}}`
## Режимы и защита от перезаписи
- `--overwrite` — разрешает перезапись существующих файлов.
- Проверка коллизий файлов и существующих директорий (с понятными ошибками/предупреждениями).
## Отчёт и визуализация
- Печать итогового отчёта: имя, путь(и), дерево файлов, список переменных.
## Диагностика и подсказки
- Подробные ошибки валидации аргументов.
- Список доступных шаблонов, если запрошенный не найден.
- Подсказки для недостающих переменных.
- `-h`/`--help` — вывод справки.
- `--skip-update` — запуск без проверки обновлений.
## Обновления CLI
- При запуске (не через `npx`) проверяется доступность новой версии в npm.
- Если пользователь отвечает «нет», повторный запрос появится через 24 часа.
- В неинтерактивном режиме (без TTY) запрос не показывается.
## Бины
- Основной бин: `gromlab-create`.
- Дополнительный алиас: `create`.

296
package-lock.json generated
View File

@@ -1,21 +1,24 @@
{
"name": "@gromlab/create",
"version": "0.1.0",
"version": "0.1.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@gromlab/create",
"version": "0.1.0",
"version": "0.1.1",
"license": "MIT",
"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"
"figures": "^3.2.0",
"ora": "^5.4.1"
},
"bin": {
"create": "dist/cli.js",
"gromlab-create": "dist/cli.js"
},
"devDependencies": {
@@ -89,6 +92,37 @@
"node": ">=6"
}
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/bl": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
"license": "MIT",
"dependencies": {
"buffer": "^5.5.0",
"inherits": "^2.0.4",
"readable-stream": "^3.4.0"
}
},
"node_modules/boxen": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/boxen/-/boxen-5.1.2.tgz",
@@ -111,6 +145,30 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/buffer": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.1.13"
}
},
"node_modules/camelcase": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz",
@@ -169,6 +227,39 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/cli-cursor": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz",
"integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==",
"license": "MIT",
"dependencies": {
"restore-cursor": "^3.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/cli-spinners": {
"version": "2.9.2",
"resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz",
"integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==",
"license": "MIT",
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/clone": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz",
"integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==",
"license": "MIT",
"engines": {
"node": ">=0.8"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -306,6 +397,18 @@
"node": ">=4.0.0"
}
},
"node_modules/defaults": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz",
"integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==",
"license": "MIT",
"dependencies": {
"clone": "^1.0.2"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/directory-tree": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/directory-tree/-/directory-tree-3.6.0.tgz",
@@ -373,6 +476,32 @@
"node": ">=8"
}
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "BSD-3-Clause"
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"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",
@@ -382,12 +511,110 @@
"node": ">=8"
}
},
"node_modules/is-interactive": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz",
"integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/is-unicode-supported": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz",
"integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"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/log-symbols": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz",
"integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==",
"license": "MIT",
"dependencies": {
"chalk": "^4.1.0",
"is-unicode-supported": "^0.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/mimic-fn": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz",
"integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/onetime": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz",
"integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==",
"license": "MIT",
"dependencies": {
"mimic-fn": "^2.1.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/ora": {
"version": "5.4.1",
"resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz",
"integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==",
"license": "MIT",
"dependencies": {
"bl": "^4.1.0",
"chalk": "^4.1.0",
"cli-cursor": "^3.1.0",
"cli-spinners": "^2.5.0",
"is-interactive": "^1.0.0",
"is-unicode-supported": "^0.1.0",
"log-symbols": "^4.1.0",
"strip-ansi": "^6.0.0",
"wcwidth": "^1.0.1"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/reduce-flatten": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/reduce-flatten/-/reduce-flatten-2.0.0.tgz",
@@ -397,12 +624,60 @@
"node": ">=6"
}
},
"node_modules/restore-cursor": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz",
"integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==",
"license": "MIT",
"dependencies": {
"onetime": "^5.1.0",
"signal-exit": "^3.0.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/signal-exit": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
"license": "ISC"
},
"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_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
@@ -537,6 +812,21 @@
"dev": true,
"license": "MIT"
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/wcwidth": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz",
"integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==",
"license": "MIT",
"dependencies": {
"defaults": "^1.0.3"
}
},
"node_modules/widest-line": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/widest-line/-/widest-line-3.1.0.tgz",

View File

@@ -1,16 +1,22 @@
{
"name": "@gromlab/create",
"version": "0.1.0",
"version": "0.1.1",
"description": "Template-based file generator CLI",
"license": "MIT",
"publishConfig": {
"access": "public"
},
"bin": {
"gromlab-create": "dist/cli.js"
"gromlab-create": "dist/cli.js",
"create": "dist/cli.js"
},
"main": "dist/cli.js",
"files": [
"dist"
],
"scripts": {
"build": "tsc -p tsconfig.json",
"clear": "node -e \"require('fs').rmSync('dist', { recursive: true, force: true })\"",
"build": "npm run clear && tsc -p tsconfig.json",
"dev": "tsc -p tsconfig.json -w",
"prepare": "npm run build"
},
@@ -20,7 +26,8 @@
"chalk": "^4.1.2",
"change-case-all": "^2.1.0",
"directory-tree": "^3.5.2",
"figures": "^3.2.0"
"figures": "^3.2.0",
"ora": "^5.4.1"
},
"devDependencies": {
"@types/archy": "^0.0.31",

View File

@@ -3,22 +3,22 @@ import { ParsedArgs } from './types';
export function printHelp() {
const lines = [
'Использование:',
' npx @gromlab/create <template> [name] [options]',
' npx @gromlab/create <шаблон> <имя> [путь] [опции]',
'',
'Аргументы:',
' <шаблон> Имя шаблона',
' <имя> Значение переменной name (обязательный аргумент)',
' [путь] Папка вывода (по умолчанию: текущая директория)',
'',
'Опции:',
' --<var> <value> Переменная шаблона (поддерживается любой --key <value>)',
' [name] Сокращение для --name',
' --templates <path> Папка шаблонов (по умолчанию: .templates)',
' --templates-path Алиас для --templates',
' --out <path> Папка вывода (по умолчанию: текущая директория)',
' --overwrite Перезаписывать существующие файлы',
' --dry-run Показать результат без записи на диск',
' --skip-update Не проверять обновления CLI',
' -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'
' npx @gromlab/create component Button',
' npx @gromlab/create component Button src/components'
];
console.log(lines.join('\n'));
}
@@ -35,7 +35,7 @@ export function parseArgs(argv: string[]): ParsedArgs {
const parsed: ParsedArgs = {
vars: {},
overwrite: false,
dryRun: false,
skipUpdate: false,
help: false,
extra: []
};
@@ -52,11 +52,10 @@ export function parseArgs(argv: string[]): ParsedArgs {
parsed.overwrite = true;
continue;
}
if (arg === '--dry-run') {
parsed.dryRun = true;
if (arg === '--skip-update') {
parsed.skipUpdate = true;
continue;
}
if (arg.startsWith('--')) {
const eqIndex = arg.indexOf('=');
const key = eqIndex === -1 ? arg.slice(2) : arg.slice(2, eqIndex);
@@ -65,17 +64,11 @@ export function parseArgs(argv: string[]): ParsedArgs {
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;
throw new Error('Опция --templates не поддерживается');
}
if (key === 'out' || key === 'output') {
const value = inlineValue ?? consumeValue(args, i, key);
if (inlineValue === undefined) i++;
parsed.outDir = value;
continue;
throw new Error('Опция --out не поддерживается');
}
const value = inlineValue ?? consumeValue(args, i, key);
@@ -94,6 +87,11 @@ export function parseArgs(argv: string[]): ParsedArgs {
continue;
}
if (!parsed.positionalOutDir) {
parsed.positionalOutDir = arg;
continue;
}
parsed.extra.push(arg);
}

View File

@@ -5,13 +5,19 @@ import { printError, printWarnings, printSummary } from './output';
import { PlanItem } from './types';
import { normalizeArgs, resolveTemplateContext } from './validation';
import { buildPlan, getCollisions, getExistingDirs, getRoots, getTopLevelDirs, writePlan } from './plan';
import { maybeHandleUpdate } from './update';
function resolvePath(baseDir: string, inputPath: string): string {
if (path.isAbsolute(inputPath)) return path.normalize(inputPath);
return path.resolve(baseDir, inputPath);
}
function run() {
async function run() {
const shouldContinue = await maybeHandleUpdate(process.argv.slice(2));
if (!shouldContinue) {
return;
}
let parsed;
try {
parsed = parseArgs(process.argv);
@@ -38,8 +44,8 @@ function run() {
const normalized = normalizedResult.normalized;
const cwd = process.cwd();
const templatesDir = resolvePath(cwd, normalized.templatesPath ?? '.templates');
const outDir = resolvePath(cwd, normalized.outDir ?? '.');
const templatesDir = resolvePath(cwd, '.templates');
const outDir = resolvePath(cwd, normalized.positionalOutDir ?? '.');
const templateResult = resolveTemplateContext(
templatesDir,
@@ -57,10 +63,9 @@ function run() {
const existingDirs = getExistingDirs(outDir, topLevelDirs);
const redWarnings: string[][] = [];
const warnings: string[][] = [];
if (existingDirs.length > 0) {
if (!normalized.overwrite && !normalized.dryRun) {
if (!normalized.overwrite) {
printError(
'Папка назначения уже существует',
existingDirs.map((dir) => path.join(outDir, dir)),
@@ -81,13 +86,6 @@ function run() {
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)),
@@ -97,25 +95,16 @@ function run() {
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);
printSummary(plan, outDir, normalized.vars, normalized.templateName!, roots);
printWarnings(redWarnings, []);
}
try {
run();
} catch (error) {
run().catch((error) => {
console.error(String(error));
process.exitCode = 1;
}
});

View File

@@ -6,44 +6,6 @@ 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 '';
@@ -109,7 +71,6 @@ export function printSummary(
plan: PlanItem[],
outDir: string,
vars: Record<string, string>,
isDryRun: boolean,
templateName: string,
roots: string[]
) {
@@ -119,9 +80,9 @@ export function printSummary(
console.log('');
// Заголовок с иконкой
const statusIcon = isDryRun ? figures.info : figures.tick;
const statusColor = isDryRun ? chalk.blue : chalk.green;
const statusText = isDryRun ? 'Планируется генерация' : 'Успешно создан';
const statusIcon = figures.tick;
const statusColor = chalk.green;
const statusText = 'Успешно создан';
console.log(statusColor(`${statusIcon} ${statusText}: `) + chalk.bold.white(displayName));
console.log('');
@@ -145,11 +106,12 @@ export function printSummary(
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 treeOutput = fs.existsSync(rootPath) ? buildTreeFromDirectory(rootPath) : '';
if (!treeOutput) {
console.log(chalk.dim(' (пусто)'));
continue;
}
// Добавляем отступ к дереву
const indentedTree = treeOutput

View File

@@ -1,11 +1,10 @@
export type ParsedArgs = {
templateName?: string;
positionalName?: string;
templatesPath?: string;
outDir?: string;
positionalOutDir?: string;
vars: Record<string, string>;
overwrite: boolean;
dryRun: boolean;
skipUpdate: boolean;
help: boolean;
extra: string[];
};

225
src/update.ts Normal file
View File

@@ -0,0 +1,225 @@
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import * as https from 'https';
import * as readline from 'readline';
import { spawnSync } from 'child_process';
import ora = require('ora');
const PACKAGE_NAME = '@gromlab/create';
const UPDATE_TIMEOUT_MS = 2000;
const UPDATE_PROMPT_COOLDOWN_MS = 24 * 60 * 60 * 1000;
function isNpxInvocation(): boolean {
const argv1 = process.argv[1] ?? '';
const npmCommand = process.env.npm_command ?? '';
const npmExecPath = process.env.npm_execpath ?? '';
if (argv1.includes(`${path.sep}_npx${path.sep}`)) return true;
if (npmCommand === 'exec') return true;
if (npmExecPath.includes('npx-cli.js')) return true;
return false;
}
function getUpdateStatePath(): string {
const xdgHome = process.env.XDG_CONFIG_HOME;
if (xdgHome) {
return path.join(xdgHome, 'gromlab-create', 'update.json');
}
const appData = process.env.APPDATA;
if (appData) {
return path.join(appData, 'gromlab-create', 'update.json');
}
return path.join(os.homedir(), '.gromlab-create', 'update.json');
}
function readDeclinedAt(): number | undefined {
const statePath = getUpdateStatePath();
try {
const raw = fs.readFileSync(statePath, 'utf8');
const data = JSON.parse(raw) as { declinedAt?: number };
if (typeof data.declinedAt === 'number') return data.declinedAt;
} catch {
return undefined;
}
return undefined;
}
function writeDeclinedAt(timestamp: number) {
const statePath = getUpdateStatePath();
try {
fs.mkdirSync(path.dirname(statePath), { recursive: true });
fs.writeFileSync(statePath, JSON.stringify({ declinedAt: timestamp }), 'utf8');
} catch {
// ignore write errors
}
}
function clearDeclinedAt() {
const statePath = getUpdateStatePath();
try {
fs.unlinkSync(statePath);
} catch {
// ignore remove errors
}
}
function readCurrentVersion(): string | undefined {
try {
const packageJsonPath = path.resolve(__dirname, '..', 'package.json');
const raw = fs.readFileSync(packageJsonPath, 'utf8');
const data = JSON.parse(raw) as { version?: string };
return typeof data.version === 'string' ? data.version : undefined;
} catch {
return undefined;
}
}
function parseSemver(version: string): number[] | null {
const clean = version.split('+')[0].split('-')[0].trim();
if (!clean) return null;
const parts = clean.split('.').map((part) => Number(part));
if (parts.some((value) => Number.isNaN(value))) return null;
return [parts[0] ?? 0, parts[1] ?? 0, parts[2] ?? 0];
}
function compareSemver(a: string, b: string): number {
const aParts = parseSemver(a);
const bParts = parseSemver(b);
if (!aParts || !bParts) return 0;
for (let i = 0; i < 3; i++) {
if (aParts[i] > bParts[i]) return 1;
if (aParts[i] < bParts[i]) return -1;
}
return 0;
}
function fetchLatestVersion(packageName: string, timeoutMs: number): Promise<string | undefined> {
const encodedName = encodeURIComponent(packageName);
const url = `https://registry.npmjs.org/${encodedName}/latest`;
return new Promise((resolve) => {
let settled = false;
const finish = (value?: string) => {
if (settled) return;
settled = true;
resolve(value);
};
const req = https.get(url, { headers: { Accept: 'application/json' } }, (res) => {
if (res.statusCode !== 200) {
res.resume();
finish();
return;
}
let data = '';
res.on('data', (chunk) => {
data += chunk;
});
res.on('end', () => {
try {
const json = JSON.parse(data) as { version?: string };
const version = typeof json.version === 'string' ? json.version : undefined;
finish(version);
} catch {
finish();
}
});
});
req.on('error', () => finish());
req.setTimeout(timeoutMs, () => {
req.destroy();
finish();
});
});
}
function isInteractive(): boolean {
return Boolean(process.stdin.isTTY && process.stdout.isTTY);
}
function askYesNo(question: string): Promise<boolean> {
return new Promise((resolve) => {
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
rl.question(question, (answer) => {
rl.close();
const normalized = answer.trim().toLowerCase();
resolve(['y', 'yes', 'д', 'да'].includes(normalized));
});
});
}
function runUpdate(packageName: string): boolean {
const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm';
const result = spawnSync(npmCmd, ['i', '-g', `${packageName}@latest`], { stdio: 'inherit' });
if (result.error) {
console.error(String(result.error));
return false;
}
return result.status === 0;
}
function rerunCommand(args: string[]): never {
const binName = process.platform === 'win32' ? 'gromlab-create.cmd' : 'gromlab-create';
const result = spawnSync(binName, args, { stdio: 'inherit' });
if (result.error) {
const nodeResult = spawnSync(process.execPath, [process.argv[1], ...args], { stdio: 'inherit' });
process.exit(nodeResult.status ?? 1);
}
process.exit(result.status ?? 0);
}
function hasSkipUpdateFlag(args: string[]): boolean {
return args.some((arg) => arg === '--skip-update' || arg.startsWith('--skip-update='));
}
export async function maybeHandleUpdate(args: string[]): Promise<boolean> {
if (isNpxInvocation()) return true;
if (hasSkipUpdateFlag(args)) return true;
if (!isInteractive()) return true;
const currentVersion = readCurrentVersion();
if (!currentVersion) return true;
const declinedAt = readDeclinedAt();
if (declinedAt && Date.now() - declinedAt < UPDATE_PROMPT_COOLDOWN_MS) return true;
const spinner = ora('Проверка обновлений...').start();
const latestVersion = await fetchLatestVersion(PACKAGE_NAME, UPDATE_TIMEOUT_MS);
if (!latestVersion) {
spinner.stop();
return true;
}
if (compareSemver(latestVersion, currentVersion) <= 0) {
spinner.stop();
return true;
}
spinner.succeed(`Доступна новая версия: ${currentVersion}${latestVersion}`);
const shouldUpdate = await askYesNo('Обновить сейчас? [y/N] ');
if (!shouldUpdate) {
writeDeclinedAt(Date.now());
return true;
}
const updateSpinner = ora('Обновление...').start();
const updated = runUpdate(PACKAGE_NAME);
if (!updated) {
updateSpinner.fail('Не удалось обновить');
return true;
}
updateSpinner.succeed('Обновлено');
clearDeclinedAt();
rerunCommand(args);
return false;
}

View File

@@ -20,19 +20,28 @@ export function normalizeArgs(parsed: ParsedArgs): { normalized: ParsedArgs; err
};
}
if (normalized.positionalName && normalized.vars.name) {
if (normalized.vars.name) {
return {
normalized,
error: {
title: 'name задан дважды',
details: ['позиционно и через --name']
title: 'Переменная name задается только позиционно',
details: ['уберите --name и укажите <имя> после шаблона']
}
};
}
if (normalized.positionalName && !normalized.vars.name) {
normalized.vars.name = normalized.positionalName;
if (!normalized.positionalName) {
return {
normalized,
error: {
title: 'Требуется имя',
details: ['укажите <имя> после шаблона'],
showHelp: true
}
};
}
normalized.vars.name = normalized.positionalName;
if (normalized.extra.length > 0) {
return {