create-mirta 0.3.4 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/dist/classic.mjs +120 -0
  2. package/dist/index.mjs +800 -690
  3. package/dist/mono.mjs +208 -0
  4. package/dist/resolver.mjs +843 -0
  5. package/locales/en-US.json +109 -0
  6. package/locales/ru-RU.json +109 -0
  7. package/package.json +25 -22
  8. package/templates/classic/starter/content/package.json +11 -0
  9. package/{dist/templates/config/typescript → templates/classic/starter/content}/tsconfig.json +0 -5
  10. package/{dist/templates/eslint/eslint.config.mjs.ejs → templates/classic/starter/features/eslint/eslint.config.mjs.tt} +11 -6
  11. package/{dist/templates/base/_env → templates/classic/starter/features/examples/_.env} +1 -1
  12. package/templates/classic/starter/features/examples/src/wb-rules/01-counter.ts +12 -0
  13. package/templates/classic/starter/features/examples/src/wb-rules-modules/counter.ts +42 -0
  14. package/templates/classic/starter/features/examples-vitest/tests/wb-rules-modules/counter.test.ts +56 -0
  15. package/templates/classic/starter/features/vitest/package.json +10 -0
  16. package/templates/classic/starter/features/vitest-eslint/package.json +5 -0
  17. package/templates/classic/starter/template.json +10 -0
  18. package/{dist/templates/config/store → templates/classic/store/content}/package.json +1 -1
  19. package/templates/classic/store/features/examples/src/wb-rules/01-counter.ts +13 -0
  20. package/templates/classic/store/features/examples/src/wb-rules/02-counter.ts +19 -0
  21. package/templates/classic/store/features/examples/src/wb-rules-modules/counter-store.ts +33 -0
  22. package/templates/classic/store/features/examples/src/wb-rules-modules/counter.ts +44 -0
  23. package/templates/classic/store/features/examples-vitest/tests/wb-rules-modules/counter.test.ts +49 -0
  24. package/templates/classic/store/template.json +14 -0
  25. package/templates/mono/core/content/package.json +6 -0
  26. package/templates/mono/core/content/pnpm-workspace.yaml +3 -0
  27. package/templates/mono/core/content/sites/.gitkeep +0 -0
  28. package/templates/mono/core/content/tsconfig.json +31 -0
  29. package/templates/mono/core/features/eslint/eslint.config.mjs.tt +126 -0
  30. package/templates/mono/core/features/vitest/package.json +10 -0
  31. package/templates/mono/core/template.json +5 -0
  32. package/templates/mono/package/features/package/packages/{{package}}/package.json.tt +25 -0
  33. package/templates/mono/package/features/package/packages/{{package}}/src/.gitkeep +0 -0
  34. package/templates/mono/package/features/package/packages/{{package}}/tsconfig.build.json +10 -0
  35. package/templates/mono/package/features/package/sites/{{package}}-demo/package.json.tt +19 -0
  36. package/templates/mono/package/features/package/sites/{{package}}-demo/src/wb-rules/.gitkeep +0 -0
  37. package/templates/mono/package/features/package/sites/{{package}}-demo/src/wb-rules-modules/.gitkeep +0 -0
  38. package/templates/mono/package/features/package/sites/{{package}}-demo/tsconfig.build.json +10 -0
  39. package/templates/mono/package/features/package-examples/packages/{{package}}/src/index.ts +2 -0
  40. package/templates/mono/package/features/package-examples/packages/{{package}}/src/thermostat.ts +99 -0
  41. package/templates/mono/package/features/package-examples/packages/{{package}}/src/types.ts +25 -0
  42. package/templates/mono/package/features/package-examples/sites/{{package}}-demo/src/wb-rules/01-thermo.ts.tt +11 -0
  43. package/templates/mono/package/features/package-examples-vitest/packages/{{package}}/tests/mirta-thermostat.test.ts +72 -0
  44. package/templates/mono/package/features/package-github/packages/{{package}}/package.json.tt +11 -0
  45. package/templates/mono/package/template.json +16 -0
  46. package/templates/mono/sites/content/mirta.config.json +37 -0
  47. package/templates/mono/sites/content/sites/home/package.json +17 -0
  48. package/templates/mono/sites/content/sites/home/src/wb-rules/.gitkeep +0 -0
  49. package/templates/mono/sites/content/sites/home/src/wb-rules-modules/.gitkeep +0 -0
  50. package/templates/mono/sites/content/sites/home-staging/package.json +17 -0
  51. package/templates/mono/sites/content/sites/home-staging/src/wb-rules/.gitkeep +0 -0
  52. package/templates/mono/sites/content/sites/home-staging/src/wb-rules-modules/.gitkeep +0 -0
  53. package/templates/mono/sites/template.json +9 -0
  54. package/{dist/templates/base/_editorconfig → templates/shared/base/content/_.editorconfig} +1 -1
  55. package/{dist/templates/base/_gitignore → templates/shared/base/content/_.gitignore} +1 -3
  56. package/templates/shared/base/content/_.node-version +1 -0
  57. package/{dist/templates/base/.vscode → templates/shared/base/content/_.vscode}/settings.json +12 -1
  58. package/templates/shared/base/content/package.json +22 -0
  59. package/templates/shared/base/features/connection/_.env.local.tt +1 -0
  60. package/templates/shared/base/features/connection/mirta.config.json +6 -0
  61. package/templates/shared/base/features/eslint/package.json +9 -0
  62. package/templates/shared/base/features/examples/mirta.config.json.tt +51 -0
  63. package/templates/shared/base/features/github/_.github/workflows/build.yml.tt +56 -0
  64. package/templates/shared/base/features/github-vitest/_.github/workflows/test.yml +30 -0
  65. package/templates/shared/base/template.json +16 -0
  66. package/dist/locales/en-US.json +0 -85
  67. package/dist/locales/ru-RU.json +0 -86
  68. package/dist/templates/base/package.json +0 -27
  69. package/dist/templates/base/rollup.config.mjs +0 -15
  70. package/dist/templates/config/typescript/package.json +0 -8
  71. package/dist/templates/config/vitest/package.json +0 -10
  72. package/dist/templates/config/vitest/tests/setup/dotenv.ts +0 -5
  73. package/dist/templates/config/vitest/tests/setup/mirta.ts +0 -41
  74. package/dist/templates/config/vitest/tests/tsconfig.json +0 -9
  75. package/dist/templates/config/vitest/vitest.config.ts +0 -37
  76. package/dist/templates/example/base/src/wb-rules/00-dotenv.ts +0 -52
  77. package/dist/templates/example/base/src/wb-rules/01-count.ts +0 -6
  78. package/dist/templates/example/base/src/wb-rules-modules/counter.ts +0 -14
  79. package/dist/templates/example/store/src/wb-rules/01-count.ts +0 -10
  80. package/dist/templates/example/store/src/wb-rules-modules/counter-store.ts +0 -7
  81. package/dist/templates/example/store/src/wb-rules-modules/counter.ts +0 -20
  82. package/dist/templates/example/vitest/base/tests/wb-rules-modules/counter.test.ts +0 -10
  83. package/dist/templates/example/vitest/store/tests/wb-rules-modules/counter-store.test.ts +0 -13
  84. /package/{dist/assets → assets}/logo.art +0 -0
  85. /package/{dist/templates/base → templates/classic/starter/content}/src/wb-rules/.gitkeep +0 -0
  86. /package/{dist/templates/base → templates/classic/starter/content}/src/wb-rules-modules/.gitkeep +0 -0
  87. /package/{dist/templates/base → templates/classic/starter/content}/types/env.d.ts +0 -0
  88. /package/{dist/templates/config → templates/classic/starter/features}/vitest/tests/wb-rules-modules/.gitkeep +0 -0
  89. /package/{dist/templates/config → templates/classic/starter/features}/vitest/tsconfig.json +0 -0
  90. /package/{dist/templates/base/_gitattributes → templates/shared/base/content/_.gitattributes} +0 -0
  91. /package/{dist/templates/base/_npmrc → templates/shared/base/content/_.npmrc} +0 -0
  92. /package/{dist/templates/base/.vscode → templates/shared/base/content/_.vscode}/extensions.json +0 -0
  93. /package/{dist/templates/base/.vscode → templates/shared/base/content/_.vscode}/tasks.json +0 -0
  94. /package/{dist/templates/config/eslint/.vscode → templates/shared/base/features/eslint/_.vscode}/extensions.json +0 -0
  95. /package/{dist/templates/config/eslint/.vscode → templates/shared/base/features/eslint/_.vscode}/settings.json +0 -0
  96. /package/{dist/templates/config/vitest/.vscode → templates/shared/base/features/vitest/_.vscode}/extensions.json +0 -0
package/dist/index.mjs CHANGED
@@ -1,191 +1,569 @@
1
- import { fileURLToPath } from 'node:url';
2
- import path, { resolve, dirname, basename } from 'node:path';
3
- import { parseArgs } from 'node:util';
4
- import fs, { existsSync, readdirSync, lstatSync, unlinkSync, rmdirSync, promises, readFileSync, writeFileSync } from 'node:fs';
5
- import * as p from '@clack/prompts';
6
- import { intro, text, confirm, multiselect, select, outro } from '@clack/prompts';
1
+ import cliPackage from '../package.json' with { type: 'json' };
2
+ import { createStagedArgs } from '@mirta/staged-args';
3
+ import { initLocalizationAsync } from '@mirta/i18n';
4
+ import { resolve, basename, dirname, sep } from 'node:path';
7
5
  import chalk from 'chalk';
8
- import isUnicodeSupported from 'is-unicode-supported';
9
- import merge from 'lodash.merge';
6
+ import { readFileSync } from 'node:fs';
10
7
  import { fruit } from 'gradient-string';
11
- import cliPackage from '../package.json' with { type: 'json' };
12
- import ejs from 'ejs';
13
- import { spawn } from 'node:child_process';
8
+ import p from 'prompts';
9
+ import { isString } from '@mirta/basics';
10
+ import fs from 'node:fs/promises';
14
11
 
15
- function canSkipDir(targetDir) {
16
- if (!existsSync(targetDir))
17
- return true;
18
- const files = readdirSync(targetDir);
19
- if (files.length === 0)
20
- return true;
21
- if (files.length === 1 && files[0] === '.git')
22
- return true;
23
- return false;
24
- }
25
- function postOrderDirectoryTraverse(dir, dirCallback, fileCallback) {
26
- for (const filename of readdirSync(dir)) {
27
- if (filename === '.git') {
28
- continue;
29
- }
30
- const fullpath = resolve(dir, filename);
31
- if (lstatSync(fullpath).isDirectory()) {
32
- postOrderDirectoryTraverse(fullpath, dirCallback, fileCallback);
33
- dirCallback(fullpath);
34
- continue;
12
+ const { t, getLocale, setLocaleAsync } = await initLocalizationAsync({
13
+ cwd: resolve(import.meta.dirname, '../'),
14
+ });
15
+
16
+ function assertNoParseErrors(result) {
17
+ if (!result.hasErrors)
18
+ return;
19
+ const lines = [
20
+ t('args.errorHeader', { count: result.errors.length }),
21
+ ];
22
+ for (const error of result.errors) {
23
+ switch (error.type) {
24
+ case 'unknown-option':
25
+ if (error.suggestion) {
26
+ lines.push(t('args.unknownOptionSuggest', {
27
+ option: chalk.bold(error.option),
28
+ suggestion: chalk.bold(`--${error.suggestion}`),
29
+ }));
30
+ }
31
+ else {
32
+ lines.push(t('args.unknownOption', {
33
+ option: chalk.bold(error.option),
34
+ }));
35
+ }
36
+ break;
37
+ case 'missing-value':
38
+ lines.push(t('args.missingValue', {
39
+ option: chalk.bold(error.option),
40
+ }));
41
+ break;
35
42
  }
36
- fileCallback(fullpath);
37
43
  }
38
- }
39
- function emptyDir(targetDir) {
40
- if (!existsSync(targetDir))
41
- return;
42
- postOrderDirectoryTraverse(targetDir, (path) => {
43
- rmdirSync(path);
44
- }, (path) => {
45
- unlinkSync(path);
46
- });
44
+ throw new Error(lines.join('\n'));
47
45
  }
48
46
 
49
- const fallbackLocale = 'en-US';
50
- const localesPath = './locales';
51
- let currentLocale = '';
52
47
  /**
48
+ * Символ-разделитель, используемый в префиксе логов.
53
49
  *
54
- * Used to link obtained locale with correct locale file.
50
+ * @since 0.3.0
55
51
  *
56
- * @param locale Obtained locale
57
- * @returns locale that linked with correct name
58
- */
59
- function linkLocale(locale) {
60
- if (locale === 'C')
61
- return fallbackLocale;
62
- let linkedLocale;
63
- try {
64
- linkedLocale = Intl.getCanonicalLocales(locale)[0];
52
+ **/
53
+ const dot = '•';
54
+ /**
55
+ * Баннер, отображаемый в начале логов по умолчанию.
56
+ *
57
+ * @since 0.3.0
58
+ *
59
+ **/
60
+ const banner$1 = `Mirta ${dot}`;
61
+ /**
62
+ * Цвета текста для каждого уровня логирования.
63
+ *
64
+ * @since 0.4.0
65
+ *
66
+ **/
67
+ const colors = {
68
+ debug: chalk.magenta,
69
+ info: chalk.cyan,
70
+ warn: chalk.yellow,
71
+ error: chalk.red,
72
+ success: chalk.green,
73
+ cancel: chalk.red,
74
+ step: chalk.dim,
75
+ note: chalk.yellowBright,
76
+ };
77
+ /**
78
+ * Цвета фона для "pill" (подсветки метки уровня).
79
+ *
80
+ * @since 0.4.0
81
+ *
82
+ **/
83
+ const bgColors = {
84
+ debug: chalk.bgMagenta.black,
85
+ info: chalk.bgCyan.black,
86
+ warn: chalk.bgYellow.black,
87
+ error: chalk.bgRed.white,
88
+ success: chalk.bgGreen.black,
89
+ cancel: chalk.bgRed,
90
+ };
91
+ /**
92
+ * Приоритет уровней логирования. Определяет, какие сообщения будут отображаться
93
+ * при установленном уровне детализации.
94
+ *
95
+ * @since 0.4.0
96
+ *
97
+ **/
98
+ const levelPriority = [
99
+ 'debug',
100
+ 'info',
101
+ 'warn',
102
+ 'error',
103
+ 'success',
104
+ 'cancel',
105
+ 'step',
106
+ 'note',
107
+ ];
108
+ /**
109
+ * Целевой уровень логирования. Сообщения с уровнем ниже указанного — игнорируются.
110
+ *
111
+ * @since 0.4.0
112
+ *
113
+ **/
114
+ let targetLevel = 0;
115
+ /**
116
+ * Проверяет, должно ли сообщение быть залогировано, исходя из текущего уровня.
117
+ *
118
+ * @param level - Уровень логирования сообщения.
119
+ * @returns {boolean} `true`, если сообщение удовлетворяет текущему уровню детализации.
120
+ *
121
+ * @since 0.4.0
122
+ *
123
+ **/
124
+ function shouldLog(level) {
125
+ const currentLevel = levelPriority.indexOf(level);
126
+ return currentLevel === -1 || currentLevel >= targetLevel;
127
+ }
128
+ /**
129
+ * Создаёт функцию для формирования "pill" — цветной метки с названием уровня.
130
+ *
131
+ * @param level - Уровень логирования.
132
+ * @returns Функция, возвращающая отформатированную метку.
133
+ *
134
+ * @since 0.4.0
135
+ *
136
+ **/
137
+ function createPill(level) {
138
+ const bgColor = bgColors[level] ?? ((text) => text);
139
+ return (...text) => {
140
+ const filteredText = text
141
+ .filter(x => x !== undefined)
142
+ .join(' ');
143
+ if (filteredText.length === 0)
144
+ return '';
145
+ return bgColor(` ${filteredText} `) + ` ${dot} `;
146
+ };
147
+ }
148
+ /**
149
+ * Определяет, нужно ли применять цвет к строке сообщения.
150
+ *
151
+ * @param colorScope - Режим применения цвета.
152
+ * @param lineIndex - Индекс строки (для многострочных сообщений).
153
+ * @returns `true`, если цвет следует применить.
154
+ *
155
+ * @since 0.4.0
156
+ *
157
+ **/
158
+ function shouldColorLine(colorScope, lineIndex) {
159
+ if (colorScope === 'all')
160
+ return true;
161
+ if (colorScope === 'first-line')
162
+ return lineIndex === 0;
163
+ return false;
164
+ }
165
+ /**
166
+ * Форматирует сообщение с учётом уровня, опций и цветов.
167
+ *
168
+ * @param level - Уровень логирования.
169
+ * @param message - Сообщение для логирования. Может быть любого типа.
170
+ * @param labelOrOptions - Метка (строка) или опции форматирования.
171
+ * @param options - Опции форматирования (если первый параметр — метка).
172
+ * @returns Отформатированная строка для вывода в консоль.
173
+ *
174
+ * @since 0.4.0
175
+ *
176
+ **/
177
+ function formatMessage(level, message, labelOrOptions, options) {
178
+ let label;
179
+ let finalOptions;
180
+ if (typeof labelOrOptions === 'string') {
181
+ label = labelOrOptions;
182
+ finalOptions = {};
65
183
  }
66
- catch (error) {
67
- console.warn(`${JSON.stringify(error)}, invalid language tag: "${locale}"`);
184
+ else {
185
+ finalOptions = labelOrOptions ?? {};
68
186
  }
69
- switch (linkedLocale) {
70
- default:
71
- linkedLocale = locale;
187
+ const { indent = 0, includePrefix = true, colorScope = 'first-line', colorOverride, } = finalOptions;
188
+ const actualLevel = colorOverride ?? level;
189
+ const color = colors[actualLevel];
190
+ const pill = createPill(actualLevel);
191
+ let text = '';
192
+ if (Array.isArray(message)) {
193
+ text = message.map(x => String(x)).join(' ');
72
194
  }
73
- return linkedLocale;
74
- }
75
- function getLocale() {
76
- if (currentLocale)
77
- return currentLocale;
78
- const shellLocale = process.env.LC_ALL
79
- ?? process.env.LC_MESSAGES
80
- ?? process.env.LANG
81
- ?? Intl.DateTimeFormat().resolvedOptions().locale;
82
- return currentLocale = linkLocale(shellLocale.split('.')[0].replace('_', '-'));
83
- }
84
- async function loadLanguageFile(filePath) {
85
- return await promises.readFile(filePath, 'utf-8').then((content) => {
86
- const data = JSON.parse(content);
87
- return data;
88
- });
195
+ else {
196
+ text = String(message);
197
+ }
198
+ const lineIndent = ' '.repeat(indent);
199
+ const lines = text.split('\n');
200
+ let prefix = includePrefix ? `${banner$1} ${pill(label)}` : '';
201
+ if (prefix && colorScope !== 'none')
202
+ prefix = color(prefix);
203
+ return lines
204
+ .map((line, lineIndex) => {
205
+ line = line.trim();
206
+ if (shouldColorLine(colorScope, lineIndex))
207
+ line = color(line);
208
+ return lineIndex === 0
209
+ ? lineIndent + prefix + line
210
+ : lineIndent + `${' '.repeat(2)}${line}`;
211
+ })
212
+ .join('\n');
89
213
  }
90
- async function loadLocale(localesRoot) {
91
- currentLocale = getLocale();
92
- const fallbackFilePath = resolve(localesRoot, `${fallbackLocale}.json`);
93
- const targetFilePath = resolve(localesRoot, `${currentLocale}.json`);
94
- const messages = await loadLanguageFile(fallbackFilePath);
95
- if (!messages)
96
- throw Error('Fallback locale file not found.');
97
- if (existsSync(targetFilePath))
98
- merge(messages, await loadLanguageFile(targetFilePath));
99
- return messages;
214
+ /**
215
+ * Основная функция логирования. Проверяет уровень и выводит сообщение.
216
+ *
217
+ * @param level - Уровень логирования.
218
+ * @param value - Сообщение.
219
+ * @param labelOrOptions - Метка или опции.
220
+ * @param options - Опции (если метка передана отдельно).
221
+ *
222
+ * @since 0.4.0
223
+ *
224
+ **/
225
+ function log(level, value, labelOrOptions, options) {
226
+ if (!shouldLog(level))
227
+ return;
228
+ console.log(formatMessage(level, value, labelOrOptions));
100
229
  }
101
- let localized;
102
- async function getLocalized() {
103
- const path = fileURLToPath(new URL(localesPath, import.meta.url));
104
- return localized ??= await loadLocale(path);
230
+ /**
231
+ * Публичный интерфейс логгера. Предоставляет методы для логирования на разных уровнях.
232
+ *
233
+ * @example
234
+ * ```ts
235
+ * logger.info('Команда запущена')
236
+ * logger.warn('Устаревший режим', 'DEPRECATED')
237
+ * logger.step('Сборка...', { indent: 2 })
238
+ * ```
239
+ *
240
+ * @since 0.4.0
241
+ *
242
+ **/
243
+ const logger = {
244
+ /**
245
+ * Устанавливает минимальный уровень логирования.
246
+ *
247
+ * @param level - Уровень, начиная с которого выводятся сообщения.
248
+ *
249
+ **/
250
+ setLevel: (level) => {
251
+ targetLevel = levelPriority.indexOf(level);
252
+ },
253
+ /**
254
+ * Логирует нейтральное сообщение с визуальным оформлением успеха.
255
+ * Использует уровень `info`, но цвет `success` (только в префиксе).
256
+ *
257
+ **/
258
+ log: (value) => {
259
+ log('info', value, {
260
+ colorScope: 'prefix',
261
+ colorOverride: 'success',
262
+ });
263
+ },
264
+ /**
265
+ * Логирует отладочное сообщение.
266
+ *
267
+ * @param value - Сообщение.
268
+ * @param label - Настраиваемая метка.
269
+ *
270
+ **/
271
+ debug: (value, label = t('label.debug')) => {
272
+ log('debug', value, label);
273
+ },
274
+ /**
275
+ * Логирует информационное сообщение.
276
+ *
277
+ * @param value - Сообщение.
278
+ * @param label - Настраиваемая метка.
279
+ *
280
+ **/
281
+ info: (value, label = t('label.info')) => {
282
+ log('info', value, label);
283
+ },
284
+ /**
285
+ * Логирует предупреждение.
286
+ *
287
+ * @param value - Сообщение.
288
+ * @param label - Настраиваемая метка.
289
+ *
290
+ **/
291
+ warn: (value, label = t('label.warning')) => {
292
+ log('warn', value, label);
293
+ },
294
+ /**
295
+ * Логирует ошибку.
296
+ *
297
+ * @param value - Сообщение.
298
+ * @param label - Настраиваемая метка.
299
+ *
300
+ **/
301
+ error: (value, label = t('label.error')) => {
302
+ log('error', value, label);
303
+ },
304
+ /**
305
+ * Логирует сообщение об успешном завершении.
306
+ *
307
+ * @param value - Сообщение.
308
+ * @param label - Настраиваемая метка.
309
+ *
310
+ **/
311
+ success: (value, label = t('label.success')) => {
312
+ log('success', value, label);
313
+ },
314
+ /**
315
+ * Логирует сообщение об отмене действия.
316
+ *
317
+ * @param value - Сообщение.
318
+ * @param label - Настраиваемая метка.
319
+ *
320
+ **/
321
+ cancel: (value, label = t('label.canceled')) => {
322
+ log('cancel', value, label);
323
+ },
324
+ /**
325
+ * Логирует шаг процесса. Без префикса, цветной текст, с отступом.
326
+ *
327
+ * @param value - Сообщение.
328
+ * @param options - Настройка отступа.
329
+ *
330
+ **/
331
+ step: (value, options = { indent: 0 }) => {
332
+ log('step', value, {
333
+ includePrefix: false,
334
+ colorScope: 'all',
335
+ indent: options.indent,
336
+ });
337
+ },
338
+ /**
339
+ * Логирует вспомогательную заметку. Цвет применяется только к префиксу.
340
+ *
341
+ * @param value - Сообщение.
342
+ * @param options - Опции форматирования.
343
+ *
344
+ **/
345
+ note: (value, options = { indent: 0, includePrefix: true }) => {
346
+ log('note', value, {
347
+ includePrefix: options.includePrefix,
348
+ colorScope: 'prefix',
349
+ indent: options.indent,
350
+ });
351
+ },
352
+ };
353
+
354
+ /**
355
+ * Имя текущего пакета в формате, используемом в npm-реестре.
356
+ *
357
+ * @since 0.4.0
358
+ *
359
+ * @internal
360
+ *
361
+ **/
362
+ const THIS_PACKAGE_NAME = 'create-mirta';
363
+ /**
364
+ * Источник фичи - CLI.
365
+ *
366
+ * @since 0.4.0
367
+ *
368
+ **/
369
+ const FEATURE_ORIGIN_CLI = 'cli';
370
+
371
+ /**
372
+ * Специализированный класс для обработки ошибок, связанных с работой локализации.
373
+ *
374
+ * Предоставляет структурированные и типизированные ошибки с использованием кодов, что упрощает
375
+ * программную обработку исключений в инструментах, работающих с пакетами.
376
+ *
377
+ * @example
378
+ * ```ts
379
+ * throw CreationError.get('template.notFound', 'fullstack')
380
+ * ```
381
+ * @since 0.4.0
382
+ *
383
+ **/
384
+ class CreationError extends Error {
385
+ /**
386
+ * Код ошибки для программной идентификации.
387
+ *
388
+ * Позволяет точно определить причину ошибки в обработчиках `try/catch`.
389
+ *
390
+ **/
391
+ code;
392
+ /**
393
+ * Приватный конструктор, используемый только внутри
394
+ * класса для создания экземпляров ошибки.
395
+ *
396
+ * @param message - Полное сообщение об ошибке.
397
+ * @param code - Код ошибки для идентификации.
398
+ * @param scope - Пространство имён или модуль, в котором возникла ошибка.
399
+ * По умолчанию — {@link THIS_PACKAGE_NAME}.
400
+ *
401
+ **/
402
+ constructor(message, code) {
403
+ super(`[${THIS_PACKAGE_NAME}] ${message}`);
404
+ this.name = 'CreationError';
405
+ this.code = code;
406
+ // Захватываем стек вызовов, исключая фабричный метод `get`,
407
+ // чтобы улучшить читаемость трассировки.
408
+ //
409
+ if ('captureStackTrace' in Error)
410
+ // eslint-disable-next-line @typescript-eslint/unbound-method
411
+ Error.captureStackTrace(this, CreationError.get);
412
+ }
413
+ /** Карта кодов ошибок с соответствующими сообщениями. */
414
+ static codeMappings = {
415
+ 'load.noTemplates': (templateType) => t('load.noTemplates', { type: templateType }),
416
+ 'template.notFound': (templateName) => t('template.notFound', { name: templateName }),
417
+ 'template.invalidConfig': (templateName) => `Invalid template config of ${templateName}`,
418
+ 'template.duplicateName': (templateName) => `Duplicate template name ${templateName}`,
419
+ 'project.outsideRoot': () => t('project.outsideRoot'),
420
+ 'project.denyOverwrite': () => t('project.denyOverwrite'),
421
+ };
422
+ /**
423
+ * Фабричный метод для создания экземпляра ошибки по её коду.
424
+ *
425
+ * Автоматически подставляет сообщение из `codeMappings` и формирует ошибку с заданными параметрами.
426
+ *
427
+ * @template T - Ограниченный ключами `codeMappings` тип, гарантирующий корректность кода.
428
+ * @param code - Код ошибки (например, `'alreadyDefined'`).
429
+ * @param args - Аргументы, соответствующие параметрам функции сообщения из `codeMappings`.
430
+ * @returns Новый экземпляр {@link CreationError} с шаблонным сообщением.
431
+ *
432
+ **/
433
+ static get(code, ...args) {
434
+ const messageFn = this.codeMappings[code];
435
+ const message = messageFn(...args);
436
+ return new CreationError(message, code);
437
+ }
105
438
  }
106
439
 
107
- const { red: red$1, green, bgRed, bgGreen, } = chalk;
108
- const dot = '•';
109
- const banner$1 = `Mirta ${dot}`;
110
- green(banner$1);
111
- red$1(banner$1);
112
- const successPill = (message) => message
113
- ? bgGreen.black(` ${message} `) + ' '
114
- : '';
115
- const errorPill = (message) => message
116
- ? bgRed.white(` ${message} `) + ' '
117
- : '';
118
- const formatSuccess = (message, title) => message ? `${successPill(title)}${green(dot, message)}` : '';
119
- const formatError = (message, title) => message ? `${errorPill(title)}${red$1(dot, message)}` : '';
120
-
121
- const unicode = isUnicodeSupported();
122
- const unicodeOr = (c, fallback) => (unicode ? c : fallback);
123
- const S_BAR = unicodeOr('│', '|');
124
- function usePrompts(messages) {
125
- function cancel(message = messages.errors.operationCanceled) {
126
- p.cancel(formatError(message, messages.status.canceled));
440
+ /**
441
+ * Ошибка, указывающая на отмену операции (например, через Ctrl+C).
442
+ *
443
+ * Соответствует коду выхода 130 (SIGINT).
444
+ *
445
+ * @since 0.4.0
446
+ *
447
+ **/
448
+ class OperationCanceledError extends Error {
449
+ constructor() {
450
+ super();
451
+ this.name = 'OperationCanceledError';
452
+ Error.captureStackTrace(this, OperationCanceledError);
127
453
  }
128
- async function prompt(cancellablePromise) {
129
- const result = await cancellablePromise;
130
- if (p.isCancel(result)) {
131
- cancel();
132
- process.exit(1);
133
- }
134
- return result;
454
+ }
455
+
456
+ class PromptCanceledError extends Error {
457
+ constructor() {
458
+ super();
459
+ this.name = 'PromptCanceledError';
460
+ Error.captureStackTrace(this, PromptCanceledError);
135
461
  }
136
- function inlineSub(message) {
137
- return `\n${chalk.gray(S_BAR)} ${message}`;
462
+ }
463
+
464
+ async function loadAsync(type) {
465
+ switch (type) {
466
+ case 'classic':
467
+ return await import('./classic.mjs');
468
+ case 'mono':
469
+ return await import('./mono.mjs');
470
+ default:
471
+ throw new Error(`Unknown project type: ${type}`);
138
472
  }
139
- return {
140
- cancel,
141
- prompt,
142
- step: p.log.step,
143
- message: p.log.message,
144
- inlineSub,
145
- };
473
+ }
474
+ async function resolveRunnerAsync(projectType) {
475
+ return await loadAsync(projectType);
146
476
  }
147
477
 
148
478
  const canUseColors = process.stdout.isTTY
149
479
  && process.stdout.getColorDepth() > 8;
150
- const data = readFileSync(new URL('./assets/logo.art', import.meta.url), 'utf-8');
480
+ const data = readFileSync(new URL('../assets/logo.art', import.meta.url), 'utf-8');
151
481
  const banner = canUseColors
152
482
  ? fruit(data)
153
483
  : data;
154
484
 
155
- const { dim: dim$1, yellow: yellow$1 } = chalk;
156
- const locale$1 = getLocale();
485
+ const finalMessageEn = `\
486
+ ${chalk.green('Welcome to your new wb-rules project!')} 🎉
487
+ Open it in VSCode or your favourite editor and start building.
488
+
489
+ 📚 Documentation:
490
+ https://dzen.ru/wihome
491
+
492
+ 💡 Mirta is powered entirely by community support.
493
+ To keep the project alive and growing, your help is essential.
494
+
495
+ 💖 A recurring subscription on Boosty is the best way to support:
496
+ https://boosty.to/wihome
497
+
498
+ ☕ One-time tips are also appreciated:
499
+ https://pay.cloudtips.ru/p/58512cca
500
+
501
+ ⭐ Your star on GitHub helps others discover Mirta:
502
+ https://github.com/wb-mirta/core
503
+
504
+ Thank you for using Mirta!
505
+ `;
506
+ const finalMessageRu = `\
507
+ ${chalk.green('Добро пожаловать в ваш новый проект wb-rules!')} 🎉
508
+ Откройте его в VSCode или другом редакторе и начинайте разработку.
509
+
510
+ 📚 Документация:
511
+ https://dzen.ru/wihome
512
+
513
+ Мирта развивается только за счёт добровольных взносов.
514
+ Чтобы проект жил и рос — нужна ваша поддержка.
515
+
516
+ 💖 Регулярная подписка на Boosty — лучший способ помочь:
517
+ https://boosty.to/wihome
518
+
519
+ ☕ Также можно поддержать разовым платежом:
520
+ https://pay.cloudtips.ru/p/58512cca
521
+
522
+ ⭐ Нравится фреймворк? Поставьте ему звёздочку на GitHub:
523
+ https://github.com/wb-mirta/core
524
+
525
+ Спасибо, что выбрали Мирту!
526
+ `;
527
+ function getFinalMessage() {
528
+ const locale = getLocale();
529
+ return locale === 'ru-RU'
530
+ ? finalMessageRu
531
+ : finalMessageEn;
532
+ }
533
+
534
+ const { dim, yellow } = chalk;
157
535
  const helpMessageEn = `\
158
536
  Creates a new wb-rules project with the Mirta Framework
159
537
 
160
538
  Starts the CLI in interactive mode when no feature flags is provided,
161
539
  or if the directory argument is not a valid project name.
162
540
 
163
- ${yellow$1('Usage:')}
541
+ ${yellow('Usage:')}
164
542
  create-mirta [feature_flags...] [options...] [directory]
165
543
 
166
- ${yellow$1('Feature flags:')}
544
+ ${yellow('Feature flags:')}
167
545
  --default
168
- ${dim$1('Create a project with the default configuration without any additional features')}
546
+ ${dim('Create a project with the default configuration without any additional features')}
169
547
  --eslint
170
- ${dim$1('Add ESLint for code quality')}
548
+ ${dim('Add ESLint for code quality')}
171
549
  --vitest
172
- ${dim$1('Add Vitest for unit testing')}
550
+ ${dim('Add Vitest for unit testing')}
173
551
  --store
174
- ${dim$1('Add store for state management')}
552
+ ${dim('Add store for state management')}
175
553
 
176
- ${yellow$1('Options:')}
554
+ ${yellow('Options:')}
177
555
  --ssh
178
- ${dim$1('Set SSH destination of deployment process as [username@][hostname][:port]')}
556
+ ${dim('Set SSH destination of deployment process as [username@][hostname][:port]')}
179
557
  --rutoken
180
- ${dim$1('Use Rutoken ECP as encrypted store of SSH private key')}
558
+ ${dim('Use Rutoken ECP as encrypted store of SSH private key')}
181
559
  -v, --version
182
- ${dim$1('Display the version number of this CLI')}
560
+ ${dim('Display the version number of this CLI')}
183
561
  -f, --force
184
- ${dim$1('Create the project even if the directory is not empty')}
562
+ ${dim('Create the project even if the directory is not empty')}
185
563
  -b, --bare
186
- ${dim$1('Create a barebone project without any code')}
564
+ ${dim('Create a barebone project without any code')}
187
565
  -h, --help
188
- ${dim$1('Display this help message')}
566
+ ${dim('Display this help message')}
189
567
  `;
190
568
  const helpMessageRu = `\
191
569
  Создаёт новый проект wb-rules с использованием Mirta Framework
@@ -193,612 +571,344 @@ const helpMessageRu = `\
193
571
  Запускает CLI в интерактивном режиме, если не указано флагов функционала
194
572
  или название каталога не подходит в качестве названия проекта.
195
573
 
196
- ${yellow$1('Использование:')}
574
+ ${yellow('Использование:')}
197
575
  create-mirta [feature_flags...] [options...] [directory]
198
576
 
199
- ${yellow$1('Флаги функционала:')}
577
+ ${yellow('Флаги функционала:')}
200
578
  --default
201
- ${dim$1('Создаёт проект с конфигурацией по умолчанию, без дополнений')}
579
+ ${dim('Создаёт проект с конфигурацией по умолчанию, без дополнений')}
202
580
  --eslint
203
- ${dim$1('Добавить ESLint для контроля качества кода')}
581
+ ${dim('Добавить ESLint для контроля качества кода')}
204
582
  --vitest
205
- ${dim$1('Добавить Mirta Testing для юнит-тестирования')}
583
+ ${dim('Добавить Mirta Testing для юнит-тестирования')}
206
584
  --store
207
- ${dim$1('Добавить хранилище состояний')}
585
+ ${dim('Добавить хранилище состояний')}
208
586
 
209
- ${yellow$1('Опции:')}
587
+ ${yellow('Опции:')}
210
588
  --ssh
211
- ${dim$1('Настроить подключение SSH для деплоя в формате [username@][hostname][:port]')}
589
+ ${dim('Настроить подключение SSH для деплоя в формате [username@][hostname][:port]')}
212
590
  --rutoken
213
- ${dim$1('Использовать Рутокен ЭЦП в качестве хранилища для закрытого ключа SSH')}
591
+ ${dim('Использовать Рутокен ЭЦП в качестве хранилища для закрытого ключа SSH')}
214
592
  -v, --version
215
- ${dim$1('Отобразить номер версии данного CLI')}
593
+ ${dim('Отобразить номер версии данного CLI')}
216
594
  -f, --force
217
- ${dim$1('Создать проект, даже если целевая директория содержит файлы')}
595
+ ${dim('Создать проект, даже если целевая директория содержит файлы')}
218
596
  -b, --bare
219
- ${dim$1('Создать проект-заготовку, без кода примеров')}
597
+ ${dim('Создать проект-заготовку, без кода примеров')}
220
598
  -h, --help
221
- ${dim$1('Отобразить данное сообщение')}
599
+ ${dim('Отобразить данное сообщение')}
222
600
  `;
223
- const helpMessage = locale$1 === 'ru-RU'
224
- ? helpMessageRu
225
- : helpMessageEn;
226
-
227
- const featureFlags = ({
228
- default: {
229
- type: 'boolean',
230
- },
231
- eslint: {
232
- type: 'boolean',
233
- },
234
- vitest: {
235
- type: 'boolean',
236
- },
237
- store: {
238
- type: 'boolean',
239
- },
240
- });
241
- const allOptions = ({
242
- ...featureFlags,
243
- ssh: {
244
- type: 'string',
245
- },
246
- rutoken: {
247
- type: 'boolean',
248
- },
249
- version: {
250
- type: 'boolean',
251
- short: 'v',
252
- },
253
- force: {
254
- type: 'boolean',
255
- short: 'f',
256
- },
257
- bare: {
258
- type: 'boolean',
259
- short: 'b',
260
- },
261
- help: {
262
- type: 'boolean',
263
- short: 'h',
264
- },
265
- });
266
-
267
- const urlRegex = /(?:(?<username>.+?)@)?(?:(?<hostname>[^:@\s]+))?(?::(?<port>\d+))?/;
268
- function parseUrl(value) {
269
- if (!value)
270
- return {};
271
- return (urlRegex.exec(value))?.groups ?? {};
601
+ function getHelpMessage() {
602
+ const locale = getLocale();
603
+ return locale === 'ru-RU'
604
+ ? helpMessageRu
605
+ : helpMessageEn;
272
606
  }
273
607
 
274
- const isObject = (val) => typeof val === 'object';
608
+ async function prompts(questions, options = {}) {
609
+ const po = {
610
+ onCancel: () => {
611
+ throw new PromptCanceledError();
612
+ },
613
+ ...options,
614
+ };
615
+ return await p(questions, po);
616
+ }
275
617
 
276
- function sortDependencies(packageJson) {
277
- const sorted = {};
278
- const depTypes = [
279
- 'dependencies',
280
- 'devDependencies',
281
- 'peerDependencies',
282
- 'optionalDependencies',
283
- ];
284
- depTypes.forEach((depType) => {
285
- if (isObject(packageJson[depType])) {
286
- const sourceDeps = packageJson[depType];
287
- const sortedDeps = sorted[depType] = {};
288
- Object.keys(packageJson[depType])
289
- .sort()
290
- .forEach((name) => {
291
- sortedDeps[name] = sourceDeps[name];
292
- });
618
+ // Префиксы для определения типа
619
+ const MONO_PREFIX = /^(mono)-/;
620
+ async function pickProjectAsync(templateInput) {
621
+ if (isString(templateInput)) {
622
+ if (MONO_PREFIX.test(templateInput)) {
623
+ return {
624
+ type: 'mono',
625
+ templateName: templateInput.replace(MONO_PREFIX, ''),
626
+ };
293
627
  }
628
+ // Всё остальное — классический тип проекта,
629
+ // полное название шаблона.
630
+ //
631
+ return {
632
+ type: 'classic',
633
+ templateName: templateInput.replace(/^classic-/, ''),
634
+ };
635
+ }
636
+ const { projectType } = await prompts({
637
+ type: 'select',
638
+ name: 'projectType',
639
+ message: t('projectType.prompt'),
640
+ hint: t('hint.select'),
641
+ choices: [
642
+ {
643
+ title: t('projectType.classic'),
644
+ value: 'classic',
645
+ },
646
+ {
647
+ title: t('projectType.mono'),
648
+ value: 'mono',
649
+ },
650
+ ],
294
651
  });
295
- return {
296
- ...packageJson,
297
- ...sorted,
298
- };
652
+ // Шаблон будет выбран позже.
653
+ return { type: projectType };
299
654
  }
300
655
 
301
- const mergeArrayWithDedupe = (a, b) => Array.from(new Set([...a, ...b]));
302
- /**
303
- * Recursively merge the content of the new object to the existing one
304
- * @param {Object} target the existing object
305
- * @param {Object} source the new object
306
- */
307
- function deepMerge(target, source) {
308
- for (const key of Object.keys(source)) {
309
- const oldVal = target[key];
310
- const newVal = source[key];
311
- if (Array.isArray(oldVal) && Array.isArray(newVal)) {
312
- target[key] = mergeArrayWithDedupe(oldVal, newVal);
656
+ const templatesDir = resolve(import.meta.dirname, '../templates');
657
+ function assertConfigIsValid(value) {
658
+ if (typeof value !== 'object' || value === null)
659
+ throw new Error('Template config must be an object');
660
+ }
661
+ async function discoverTemplatesAsync(type) {
662
+ const pathPattern = resolve(templatesDir, `{shared,${type}}/*/template.json`);
663
+ const templates = new Map();
664
+ for await (const filePath of fs.glob(pathPattern)) {
665
+ let rawConfig;
666
+ try {
667
+ rawConfig = JSON.parse(await fs.readFile(filePath, 'utf-8'));
313
668
  }
314
- else if (isObject(oldVal) && isObject(newVal)) {
315
- target[key] = deepMerge(oldVal, newVal);
669
+ catch {
670
+ throw CreationError.get('template.invalidConfig', basename(dirname(filePath)));
316
671
  }
317
- else {
318
- target[key] = newVal;
672
+ assertConfigIsValid(rawConfig);
673
+ const rootDir = dirname(filePath);
674
+ const name = (rawConfig.name || basename(rootDir));
675
+ if (templates.has(name))
676
+ throw CreationError.get('template.duplicateName', name);
677
+ templates.set(name, {
678
+ ...rawConfig,
679
+ type: type,
680
+ name: name,
681
+ rootDir: rootDir,
682
+ displayName: rawConfig.displayName ?? name,
683
+ description: rawConfig.description ?? '',
684
+ order: rawConfig.order ?? Number.POSITIVE_INFINITY,
685
+ });
686
+ }
687
+ return templates;
688
+ }
689
+
690
+ function buildSequence(target, templates) {
691
+ const stack = [target];
692
+ // Иерархическая последовательность шаблонов.
693
+ const sequence = [];
694
+ // Для предотвращения зацикливания.
695
+ const seen = new Set();
696
+ let template;
697
+ // Извлекаем последний шаблон из стека.
698
+ while ((template = stack.pop())) {
699
+ if (seen.has(template.name))
700
+ throw new Error('Cyclic template inheritance');
701
+ seen.add(template.name);
702
+ if (template.extends) {
703
+ const parent = templates.get(template.extends);
704
+ if (!parent)
705
+ throw new Error(`Unknown parent template ${template.extends} in ${template.name}`);
706
+ stack.push(parent);
319
707
  }
708
+ sequence.push(template);
320
709
  }
321
- return target;
710
+ // Инвертированная последовательность начинается с корня.
711
+ return sequence.reverse();
322
712
  }
323
713
 
324
- function renderJson(source, targetFilePath, handle) {
325
- const existingData = JSON.parse(fs.readFileSync(targetFilePath, 'utf-8'));
326
- const newData = typeof source === 'string'
327
- ? JSON.parse(fs.readFileSync(source, 'utf-8'))
328
- : source;
329
- let mergedData = deepMerge(existingData, newData);
330
- if (handle)
331
- mergedData = handle(mergedData);
332
- fs.writeFileSync(targetFilePath, JSON.stringify(mergedData, null, 2) + '\n');
714
+ async function pickTargetAsync(templates, templateName) {
715
+ if (templateName) {
716
+ const template = templates.get(templateName);
717
+ if (!template)
718
+ throw CreationError.get('template.notFound', templateName);
719
+ return template;
720
+ }
721
+ if (templates.size === 1)
722
+ return templates.values().next().value;
723
+ const response = await prompts({
724
+ type: 'select',
725
+ name: 'templateName',
726
+ message: t('template.select'),
727
+ hint: t('hint.select'),
728
+ choices: [...templates.values()]
729
+ .filter(x => !x.hidden)
730
+ .sort((a, b) => (a.order - b.order))
731
+ .map(x => ({
732
+ title: t.plain(`templates.${x.name}.name`, x.displayName),
733
+ description: t.plain(`templates.${x.name}.description`, x.description),
734
+ value: x.name,
735
+ })),
736
+ });
737
+ const template = templates.get(response.templateName);
738
+ if (!template)
739
+ throw CreationError.get('template.notFound', response.templateName);
740
+ return template;
333
741
  }
334
742
 
335
- function renderTemplate(sourcePath, targetPath) {
336
- const stats = fs.statSync(sourcePath);
337
- if (stats.isDirectory()) {
338
- if (path.basename(sourcePath) === 'node_modules')
339
- return;
340
- fs.mkdirSync(targetPath, { recursive: true });
341
- for (const file of fs.readdirSync(sourcePath)) {
342
- if (file === '.gitkeep')
343
- continue;
344
- renderTemplate(path.resolve(sourcePath, file), path.resolve(targetPath, file));
345
- }
346
- return;
347
- }
348
- const filename = path.basename(sourcePath);
349
- if (filename === 'package.json' && fs.existsSync(targetPath)) {
350
- renderJson(sourcePath, targetPath, result => sortDependencies(result));
351
- return;
352
- }
353
- if (['extensions.json', 'settings.json', 'tasks.json', 'tsconfig.json'].includes(filename) && fs.existsSync(targetPath)) {
354
- renderJson(sourcePath, targetPath);
355
- return;
356
- }
357
- if (filename.startsWith('_')) {
358
- // rename `_file` to `.file`
359
- targetPath = path.resolve(path.dirname(targetPath), filename.replace(/^_/, '.'));
360
- }
361
- if (filename === '_gitignore' && fs.existsSync(targetPath)) {
362
- const existingContent = fs.readFileSync(targetPath, 'utf-8');
363
- const newContent = fs.readFileSync(sourcePath, 'utf-8');
364
- fs.writeFileSync(targetPath, existingContent + '\n' + newContent);
365
- return;
366
- }
367
- fs.copyFileSync(sourcePath, targetPath);
743
+ async function resolveTemplateSequenceAsync(selection) {
744
+ const { type, templateName } = selection;
745
+ const templates = await discoverTemplatesAsync(type);
746
+ if (templates.size === 0)
747
+ throw CreationError.get('load.noTemplates', type);
748
+ const target = await pickTargetAsync(templates, templateName);
749
+ return buildSequence(target, templates);
368
750
  }
369
751
 
370
- const __dirname = dirname(fileURLToPath(import.meta.url));
371
- function renderEjs(filePath, data) {
372
- const fullPath = resolve(__dirname, filePath);
373
- const template = readFileSync(fullPath, 'utf-8');
374
- return ejs.render(template, data, {});
752
+ async function confirmOverwriteAsync(projectRoot) {
753
+ const locationText = t('overwrite.notEmpty', { path: chalk.yellow(projectRoot) });
754
+ const promptText = chalk.red(t('overwrite.prompt'));
755
+ const { canOverwrite } = await prompts({
756
+ type: 'confirm',
757
+ name: 'canOverwrite',
758
+ message: `${locationText}\n ${promptText}`,
759
+ initial: false,
760
+ });
761
+ return canOverwrite;
375
762
  }
376
763
 
377
- function pickExisting(source, keys) {
378
- return keys.reduce((acc, key) => {
379
- if (key in source)
380
- acc[key] = source[key];
381
- return acc;
382
- }, {});
764
+ async function promptProjectFolderAsync(defaultValue) {
765
+ const { projectFolder } = await prompts({
766
+ type: 'text',
767
+ name: 'projectFolder',
768
+ message: t('projectFolder.prompt'),
769
+ initial: defaultValue,
770
+ validate: (value) => {
771
+ return value.trim().length === 0
772
+ ? t('validation.required')
773
+ : true;
774
+ },
775
+ });
776
+ return projectFolder.trim();
383
777
  }
384
- const devDependencies = cliPackage.devDependencies;
385
- const pickDependencies = (keys) => pickExisting(devDependencies, keys);
386
- function createConfig(templateDir, options = {}) {
387
- const { styleGuide = 'default', addVitest, additionalConfigs = [], } = options;
388
- const pkg = {
389
- devDependencies: pickDependencies([
390
- '@eslint/js',
391
- '@stylistic/eslint-plugin',
392
- 'eslint',
393
- 'globals',
394
- 'typescript-eslint',
395
- ]),
396
- scripts: {},
397
- };
398
- const fileExtensions = ['ts', 'mts', 'tsx'];
399
- if (addVitest) {
400
- additionalConfigs.unshift({
401
- devDependencies: pickDependencies(['@vitest/eslint-plugin']),
402
- });
778
+
779
+ /**
780
+ * Асинхронно проверяет, существует ли файл или директория по указанному пути.
781
+ *
782
+ * Использует `fs.access`, чтобы обойти ограничения `fs.existsSync` в асинхронной среде.
783
+ *
784
+ * @param path - Путь к файлу или директории.
785
+ * @returns `true`, если путь существует и доступен, иначе `false`.
786
+ *
787
+ * @since 0.4.0
788
+ *
789
+ **/
790
+ async function isExistsAsync(path) {
791
+ try {
792
+ await fs.access(path);
793
+ return true;
403
794
  }
404
- for (const config of additionalConfigs) {
405
- deepMerge(pkg.devDependencies, config.devDependencies ?? {});
795
+ catch (e) {
796
+ if (e && typeof e === 'object' && 'code' in e && e.code === 'ENOENT')
797
+ return false;
798
+ throw e;
406
799
  }
407
- const templateData = {
408
- styleGuide,
409
- addVitest,
410
- fileExtensions,
411
- };
412
- function renderEjsEntry(fileName) {
413
- const filePath = resolve(templateDir, fileName);
414
- return [
415
- fileName.replace(/\.ejs$/, ''),
416
- renderEjs(filePath, templateData),
417
- ];
418
- }
419
- const entries = [
420
- renderEjsEntry('eslint.config.mjs.ejs'),
421
- ];
422
- return {
423
- pkg,
424
- files: Object.fromEntries(entries),
425
- };
426
800
  }
427
- function renderEslintConfig(templateRoot, rootDir, options = {}) {
428
- const { addVitest } = options;
429
- const templateDir = resolve(templateRoot, 'eslint');
430
- const { pkg, files } = createConfig(templateDir, {
431
- styleGuide: 'default',
432
- addVitest,
433
- });
434
- const targetPkgPath = resolve(rootDir, 'package.json');
435
- renderJson(pkg, targetPkgPath, result => sortDependencies(result));
436
- for (const [fileName, content] of Object.entries(files)) {
437
- const fullPath = resolve(rootDir, fileName);
438
- writeFileSync(fullPath, content, 'utf-8');
439
- }
801
+ async function isDirEmptyAsync(targetDir) {
802
+ const files = await fs.readdir(targetDir);
803
+ return !files.length || (files.length === 1 && files[0] === '.git');
440
804
  }
441
-
442
- function runCommand(command, args, options) {
443
- return new Promise((resolve, reject) => {
444
- const runner = spawn(command, args, {
445
- cwd: options.root,
446
- stdio: 'inherit',
447
- shell: true,
448
- ...options,
449
- });
450
- runner.on('exit', (code) => {
451
- console.log();
452
- if (code) {
453
- console.log(` ${command} FAILED...`);
454
- console.log();
455
- reject(new Error());
456
- }
457
- else {
458
- resolve();
459
- }
805
+ async function clearDirAsync(targetDir) {
806
+ for (const filename of await fs.readdir(targetDir)) {
807
+ if (filename === '.git')
808
+ continue;
809
+ await fs.rm(resolve(targetDir, filename), {
810
+ recursive: true,
811
+ force: true,
460
812
  });
461
- });
813
+ }
462
814
  }
463
815
 
464
- function getRunningPackageManager() {
465
- const userAgent = process.env.npm_config_user_agent;
466
- if (!userAgent)
467
- return;
468
- const [name, version] = userAgent.split(' ')[0].split('/');
816
+ async function resolveProjectContextAsync(selection, options = {}) {
817
+ const { cwd = process.cwd(), barebone, } = options;
818
+ const projectName = options.projectFolder || await promptProjectFolderAsync(`wb-mirta-${selection.type}`);
819
+ if (options.projectFolder)
820
+ logger.step(`${t('projectFolder.prompt')}: ${options.projectFolder}`);
821
+ const projectRoot = resolve(cwd, projectName);
822
+ if (!projectRoot.startsWith(cwd.endsWith('/') ? cwd : cwd + sep) && projectRoot !== cwd)
823
+ throw CreationError.get('project.outsideRoot');
824
+ const isExists = await isExistsAsync(projectRoot);
825
+ const isEmpty = !isExists || await isDirEmptyAsync(projectRoot);
826
+ let shouldOverwrite = false;
827
+ if (!isEmpty) {
828
+ shouldOverwrite
829
+ = options.forceOverwrite === true || await confirmOverwriteAsync(projectRoot);
830
+ if (!shouldOverwrite)
831
+ throw new OperationCanceledError();
832
+ }
833
+ const templates = await resolveTemplateSequenceAsync(selection);
469
834
  return {
470
- name,
471
- version,
835
+ rootDir: projectRoot,
836
+ name: projectName,
837
+ shouldOverwrite: shouldOverwrite,
838
+ shouldCreate: !isExists,
839
+ templates: templates,
840
+ barebone: barebone,
472
841
  };
473
842
  }
474
- const runningPackageManager = getRunningPackageManager();
475
- async function installDependencies(scope) {
476
- if (!scope.packageManager)
477
- return;
478
- const args = ['install'];
479
- await runCommand(scope.packageManager, args, { root: scope.projectRoot });
480
- }
481
-
482
- const locale = getLocale();
483
- const finalMessageEn = `\
484
- To get started, open your project in VSCode or other code editor.
485
- Documentation can be found at: https://dzen.ru/wihome
486
-
487
- Please, give a star on GitHub if you appreciate this work:
488
- https://github.com/wb-mirta/core
489
- `;
490
- const finalMessageRu = `\
491
- Откройте проект в VSCode или другом подходящем редакторе.
492
- Документация публикуется на Дзене: https://dzen.ru/wihome
493
-
494
- Если вам нравится фреймворк, поставьте ему звёздочку на Гитхабе:
495
- https://github.com/wb-mirta/core
496
-
497
- Поблагодарить разработчика можно через Boosty:
498
- https://boosty.to/wihome/donate
499
843
 
500
- Также доступен сервис для безналичных чаевых от Т‑Банка и CloudPayments:
501
- https://pay.cloudtips.ru/p/58512cca
502
- `;
503
- const finalMessage = locale === 'ru-RU'
504
- ? finalMessageRu
505
- : finalMessageEn;
506
-
507
- const { dim, red, yellow } = chalk;
508
- const templatesPath = './templates';
509
- const messages = await getLocalized();
510
- const featureOptions = [
511
- {
512
- value: 'eslint',
513
- label: messages.addEslint.message,
514
- hint: messages.addEslint.hint,
844
+ const initialSchema = ({
845
+ // === Common options ===
846
+ version: {
847
+ type: 'boolean',
848
+ short: 'v',
515
849
  },
516
- {
517
- value: 'store',
518
- label: messages.addStore.message,
519
- hint: messages.addStore.hint,
850
+ help: {
851
+ type: 'boolean',
852
+ short: 'h',
520
853
  },
521
- {
522
- value: 'vitest',
523
- label: messages.addVitest.message,
524
- hint: messages.addVitest.hint,
854
+ locale: {
855
+ type: 'string',
525
856
  },
526
- ];
527
- const { prompt, cancel, step, message, inlineSub } = usePrompts(messages);
528
- function isValidPackageName(packageName) {
529
- return /^(?:@[a-z0-9-*~][a-z0-9-*._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/
530
- .test(packageName);
531
- }
532
- function toValidPackageName(packageName) {
533
- return packageName
534
- .trim()
535
- .toLowerCase()
536
- .replace(/\s+/g, '-')
537
- .replace(/^[._]+/, '')
538
- .replace(/[^a-z0-9-~]+/g, '-');
539
- }
857
+ template: {
858
+ type: 'string',
859
+ },
860
+ force: {
861
+ type: 'boolean',
862
+ },
863
+ bare: {
864
+ type: 'boolean',
865
+ },
866
+ });
540
867
  async function run() {
541
- const cwd = process.cwd();
542
- const args = process.argv.slice(2);
543
- const { values: argv, positionals } = parseArgs({
544
- args,
545
- options: allOptions,
546
- allowPositionals: true,
547
- });
548
- if (argv.help) {
549
- console.log(helpMessage);
550
- process.exit(0);
551
- }
868
+ const args = createStagedArgs(process.argv.slice(2));
869
+ const parseResult = args.parse(initialSchema);
870
+ assertNoParseErrors(parseResult);
871
+ const { values: argv, positionals, stagedArgs: runnerArgs } = parseResult.data;
872
+ if (argv.locale)
873
+ await setLocaleAsync(argv.locale);
552
874
  if (argv.version) {
553
875
  console.log(`${cliPackage.name} v${cliPackage.version}`);
554
- process.exit(0);
876
+ return;
877
+ }
878
+ if (argv.help) {
879
+ console.log(getHelpMessage());
880
+ return;
555
881
  }
556
- // Если какой-либо функционал указан заранее, пропустить этот вопрос.
557
- const isFeatureFlagsUsed = Object.keys(featureFlags)
558
- .some(flag => typeof argv[flag] === 'boolean');
559
- let targetDir = positionals[0];
560
- const hasPositionalDir = targetDir && targetDir !== '.';
561
- const defaultProjectName = hasPositionalDir ? targetDir : 'wb-rules-mirta';
562
- const shouldOverwrite = argv.force;
563
- const sshAddress = parseUrl(argv.ssh);
564
- const sshDefaultUser = 'root';
565
- const sshDefaultHost = '10.200.200.1';
566
- const sshDefaultPort = '22';
567
- const sshFullyDefined = !!(sshAddress.username && sshAddress.hostname);
568
- const scope = {
569
- projectName: defaultProjectName,
570
- projectRoot: '',
571
- packageName: defaultProjectName,
572
- shouldOverwrite,
573
- features: [],
574
- sshUsername: sshAddress.username,
575
- sshHostname: sshAddress.hostname,
576
- sshPort: sshAddress.port,
577
- rutoken: argv.rutoken,
578
- };
579
882
  console.log(banner);
580
- console.log(messages.title);
883
+ console.log(t('title'));
581
884
  console.log();
582
- intro(chalk.bgBlackBright.black(` ${messages.captions.intro} `));
583
- if (!targetDir) {
584
- const answer = await prompt(text({
585
- message: messages.projectName.message,
586
- placeholder: defaultProjectName,
587
- defaultValue: defaultProjectName,
588
- validate: (value) => {
589
- if (value.length !== 0 && value.trim().length === 0)
590
- return messages.validation.required;
591
- },
592
- }));
593
- targetDir = scope.projectName = scope.packageName = answer.trim();
594
- }
595
- const root = scope.projectRoot = resolve(cwd, targetDir);
596
- const baseName = basename(root);
597
- if (hasPositionalDir) {
598
- step(`${messages.projectName.message}\n${dim(baseName)}`);
599
- }
600
- // Защита от выхода за пределы рабочей директории.
601
- if (!root.startsWith(cwd)) {
602
- cancel(messages.errors.rootIsNotRelative);
603
- process.exit(1);
604
- }
605
- if (!isValidPackageName(targetDir)) {
606
- scope.packageName = await prompt(text({
607
- message: messages.packageName.message,
608
- initialValue: toValidPackageName(baseName),
609
- validate: value => isValidPackageName(value)
610
- ? undefined
611
- : messages.packageName.errorMessage,
612
- }));
613
- }
614
- if (!canSkipDir(targetDir) && !shouldOverwrite) {
615
- scope.shouldOverwrite = await prompt(confirm({
616
- message: `${targetDir === '.'
617
- ? messages.shouldOverwrite.directory.current
618
- : messages.shouldOverwrite.directory.target} "${baseName}" ${messages.shouldOverwrite.message} ${red(messages.shouldOverwrite.confirmDelete)}`,
619
- initialValue: false,
620
- }));
621
- if (!scope.shouldOverwrite) {
622
- cancel();
623
- process.exit(0);
624
- }
625
- }
626
- message(chalk.bgBlackBright.black(` ${messages.captions.deploy} `));
627
- if (!scope.sshUsername) {
628
- scope.sshUsername = await prompt(text({
629
- message: messages.ssh.username,
630
- placeholder: sshDefaultUser,
631
- defaultValue: sshDefaultUser,
632
- validate: (value) => {
633
- if (value.length !== 0 && value.trim().length === 0)
634
- return messages.validation.required;
635
- },
636
- }));
637
- }
638
- else {
639
- step(`${messages.ssh.username}\n${dim(scope.sshUsername)}`);
640
- }
641
- if (!scope.sshHostname) {
642
- scope.sshHostname = await prompt(text({
643
- message: messages.ssh.host,
644
- placeholder: sshDefaultHost,
645
- defaultValue: sshDefaultHost,
646
- validate: (value) => {
647
- if (value.length !== 0 && value.trim().length === 0)
648
- return messages.validation.required;
649
- },
650
- }));
651
- }
652
- else {
653
- step(`${messages.ssh.host}\n${dim(scope.sshHostname)}`);
654
- }
655
- if (!sshFullyDefined && !scope.sshPort) {
656
- scope.sshPort = await prompt(text({
657
- message: messages.ssh.port,
658
- placeholder: sshDefaultPort,
659
- defaultValue: sshDefaultPort,
660
- validate: (value) => {
661
- if (value.length !== 0 && value.trim().length === 0)
662
- return messages.validation.required;
663
- },
664
- }));
665
- }
666
- else if (scope.sshPort) {
667
- step(`${messages.ssh.port}\n${dim(scope.sshPort)}`);
668
- }
669
- if (!sshFullyDefined && !scope.rutoken) {
670
- scope.rutoken = await prompt(confirm({
671
- message: `${messages.ssh.useRutoken} ${dim(messages.accent.ifConfigured)}`,
672
- initialValue: false,
673
- }));
674
- }
675
- else if (scope.rutoken) {
676
- step(`${messages.ssh.useRutoken} ${dim(messages.accent.ifConfigured)}\n${dim('Yes')}`);
677
- }
678
- if (!isFeatureFlagsUsed) {
679
- scope.features = await prompt(multiselect({
680
- message: `${messages.featureSelection.message}${inlineSub(dim(messages.featureSelection.hint))}`,
681
- options: featureOptions,
682
- required: false,
683
- }));
684
- }
685
- const { features } = scope;
686
- const addEslint = argv.eslint === true || features.includes('eslint');
687
- const addStore = argv.store === true || features.includes('store');
688
- const addVitest = argv.vitest === true || features.includes('vitest');
689
- // Очистка целевой директории
690
- if (fs.existsSync(root)) {
691
- if (scope.shouldOverwrite)
692
- emptyDir(root);
693
- }
694
- else {
695
- fs.mkdirSync(root);
696
- }
697
- step(`${messages.status.scaffolding} ${yellow(root)}`);
698
- const pkg = {
699
- name: scope.packageName,
700
- version: '0.0.0',
701
- scripts: {
702
- 'wb:deploy': '',
703
- },
704
- };
705
- const deployScript = ['rsync'];
706
- if (process.platform === 'win32') {
707
- // Run rsync trough WSL on Windows systems.
708
- deployScript.unshift('wsl ');
709
- }
710
- if (scope.rutoken || scope.sshPort) {
711
- deployScript.push(' -e \'ssh');
712
- if (scope.rutoken)
713
- deployScript.push(' -I /usr/lib/librtpkcs11ecp.so');
714
- if (scope.sshPort)
715
- deployScript.push(` -p ${scope.sshPort}`);
716
- deployScript.push('\'');
717
- }
718
- deployScript.push(' -rltzvgO --progress --delete --exclude=\'alarms.conf\'');
719
- deployScript.push(' --groupmap=\'*:developers\' dist/es5/*');
720
- deployScript.push(` '${scope.sshUsername}@${scope.sshHostname}:/mnt/data/etc/'`);
721
- pkg.scripts['wb:deploy'] = deployScript.join('');
722
- fs.writeFileSync(resolve(root, 'package.json'), JSON.stringify(pkg, null, 2));
723
- const templateRoot = fileURLToPath(new URL(templatesPath, import.meta.url));
724
- const render = function (templateName) {
725
- const templateDir = resolve(templateRoot, templateName);
726
- renderTemplate(templateDir, root);
727
- };
728
- // Create basic project structure
729
- render('base');
730
- // Add TypeScript support
731
- render('config/typescript');
732
- if (addEslint) {
733
- renderEslintConfig(templateRoot, root, {
734
- addVitest,
735
- });
736
- render('config/eslint');
737
- }
738
- if (!argv.bare) {
739
- render('example/base');
885
+ // Определяем тип шаблона - по аргументам или через вопрос пользователю.
886
+ const selection = await pickProjectAsync(argv.template?.toLowerCase());
887
+ const runner = await resolveRunnerAsync(selection.type);
888
+ const context = await resolveProjectContextAsync(selection, {
889
+ projectFolder: positionals[0],
890
+ forceOverwrite: argv.force,
891
+ barebone: argv.bare,
892
+ });
893
+ await runner.runAsync(runnerArgs, context);
894
+ console.log();
895
+ console.log(getFinalMessage());
896
+ }
897
+ run().catch((e) => {
898
+ if (e instanceof PromptCanceledError || e instanceof OperationCanceledError) {
899
+ logger.cancel(t('step.canceled'));
740
900
  }
741
- if (addStore) {
742
- render('config/store');
743
- if (!argv.bare) {
744
- render('example/store');
745
- }
901
+ else if (e instanceof CreationError) {
902
+ logger.error(e.message);
746
903
  }
747
- if (addVitest) {
748
- render('config/vitest');
749
- if (!argv.bare) {
750
- render('example/vitest/base');
751
- if (addStore) {
752
- render('example/vitest/store');
753
- }
754
- }
904
+ else if (e instanceof Error) {
905
+ // Unexpected internal error - rethrow to preserve stack trace
906
+ throw e;
755
907
  }
756
- step(formatSuccess(messages.status.scaffolded, messages.status.success));
757
- scope.packageManager ??= await prompt(select({
758
- message: `${messages.dependencies.question} ${dim(messages.accent.recommended)}`,
759
- options: runningPackageManager
760
- ? [
761
- {
762
- value: runningPackageManager.name,
763
- label: `${messages.dependencies.current.message} ${runningPackageManager.name}`,
764
- },
765
- {
766
- value: '',
767
- label: messages.dependencies.no.message,
768
- },
769
- ]
770
- : [
771
- {
772
- value: 'pnpm',
773
- label: messages.dependencies.pnpm.message,
774
- hint: messages.dependencies.pnpm.hint,
775
- },
776
- {
777
- value: 'yarn',
778
- label: messages.dependencies.yarn.message,
779
- },
780
- {
781
- value: 'npm',
782
- label: messages.dependencies.npm.message,
783
- },
784
- {
785
- value: 'bun',
786
- label: messages.dependencies.bun.message,
787
- },
788
- {
789
- value: '',
790
- label: messages.dependencies.no.message,
791
- },
792
- ],
793
- }));
794
- if (scope.packageManager) {
795
- outro(messages.status.installingDependencies);
796
- await installDependencies(scope);
908
+ else if (typeof e === 'string') {
909
+ logger.error(e);
797
910
  }
798
- console.log();
799
- console.log(finalMessage);
800
- }
801
- run().catch((e) => {
802
- console.error(e);
803
911
  process.exit(1);
804
912
  });
913
+
914
+ export { FEATURE_ORIGIN_CLI as F, OperationCanceledError as O, assertNoParseErrors as a, clearDirAsync as c, isExistsAsync as i, logger as l, prompts as p, t };