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
@@ -0,0 +1,843 @@
1
+ import { isObject, isPlainObject } from '@mirta/basics';
2
+ import fs, { glob } from 'node:fs/promises';
3
+ import { join, relative, basename } from 'node:path';
4
+ import { i as isExistsAsync, F as FEATURE_ORIGIN_CLI, t, p as prompts, l as logger, O as OperationCanceledError } from './index.mjs';
5
+ import { createScanner } from 'jsonc-parser';
6
+ import chalk from 'chalk';
7
+ import { spawn } from 'node:child_process';
8
+
9
+ function sortDependencies(packageJson) {
10
+ const sorted = {};
11
+ const depTypes = [
12
+ 'dependencies',
13
+ 'devDependencies',
14
+ 'peerDependencies',
15
+ 'optionalDependencies',
16
+ ];
17
+ depTypes.forEach((depType) => {
18
+ if (isObject(packageJson[depType])) {
19
+ const sourceDeps = packageJson[depType];
20
+ const sortedDeps = sorted[depType] = {};
21
+ Object.keys(packageJson[depType])
22
+ .sort()
23
+ .forEach((name) => {
24
+ sortedDeps[name] = sourceDeps[name];
25
+ });
26
+ }
27
+ });
28
+ return {
29
+ ...packageJson,
30
+ ...sorted,
31
+ };
32
+ }
33
+
34
+ const mergeArrayWithDedupe = (a, b) => Array.from(new Set([...a, ...b]));
35
+ /**
36
+ * Recursively merge the content of the new object to the existing one
37
+ * @param {Object} target the existing object
38
+ * @param {Object} source the new object
39
+ */
40
+ function deepMerge(target, source) {
41
+ for (const key of Object.keys(source)) {
42
+ const oldVal = target[key];
43
+ const newVal = source[key];
44
+ if (Array.isArray(oldVal) && Array.isArray(newVal)) {
45
+ target[key] = mergeArrayWithDedupe(oldVal, newVal);
46
+ }
47
+ else if (isObject(oldVal) && isObject(newVal)) {
48
+ target[key] = deepMerge(oldVal, newVal);
49
+ }
50
+ else {
51
+ target[key] = newVal;
52
+ }
53
+ }
54
+ return target;
55
+ }
56
+
57
+ function parse(content) {
58
+ // TODO: Добавить валидацию.
59
+ return JSON.parse(content);
60
+ }
61
+ async function renderAsync$1(fromPath, toPath, content, resultHandler) {
62
+ const targetContent = await fs.readFile(toPath, 'utf-8');
63
+ const targetObject = parse(targetContent);
64
+ const sourceObject = content !== undefined
65
+ ? parse(content)
66
+ : parse(await fs.readFile(fromPath, 'utf-8'));
67
+ let mergedObject = deepMerge(targetObject, sourceObject);
68
+ if (resultHandler)
69
+ mergedObject = resultHandler(mergedObject);
70
+ await fs.writeFile(toPath, JSON.stringify(mergedObject, null, 2) + '\n');
71
+ }
72
+
73
+ function getReadable(token) {
74
+ switch (token) {
75
+ case 10 /* SyntaxKind.StringLiteral */:
76
+ return 'string';
77
+ case 11 /* SyntaxKind.NumericLiteral */:
78
+ return 'number';
79
+ case 8 /* SyntaxKind.TrueKeyword */:
80
+ return 'true';
81
+ case 9 /* SyntaxKind.FalseKeyword */:
82
+ return 'false';
83
+ case 7 /* SyntaxKind.NullKeyword */:
84
+ return 'null';
85
+ case 2 /* SyntaxKind.CloseBraceToken */:
86
+ return '}';
87
+ case 4 /* SyntaxKind.CloseBracketToken */:
88
+ return ']';
89
+ case 5 /* SyntaxKind.CommaToken */:
90
+ return ',';
91
+ case 6 /* SyntaxKind.ColonToken */:
92
+ return ':';
93
+ default:
94
+ return 'unknown';
95
+ }
96
+ }
97
+ function parseJsonc(text) {
98
+ const scanner = createScanner(text);
99
+ const comments = [];
100
+ let token = scanner.scan();
101
+ // === Вспомогательные функции ===
102
+ const advance = () => (token = scanner.scan());
103
+ const skipTrivia = () => {
104
+ while (token === 12 /* SyntaxKind.LineCommentTrivia */
105
+ || token === 13 /* SyntaxKind.BlockCommentTrivia */
106
+ || token === 14 /* SyntaxKind.LineBreakTrivia */
107
+ || token === 15 /* SyntaxKind.Trivia */) {
108
+ if (token === 12 /* SyntaxKind.LineCommentTrivia */
109
+ || token === 13 /* SyntaxKind.BlockCommentTrivia */) {
110
+ comments.push(scanner.getTokenValue().trim());
111
+ }
112
+ advance();
113
+ }
114
+ };
115
+ // Сканим и пропускаем тривиалы
116
+ const next = () => {
117
+ advance();
118
+ skipTrivia();
119
+ return token;
120
+ };
121
+ const takeComments = () => {
122
+ if (comments.length === 0)
123
+ return undefined;
124
+ const result = [...comments];
125
+ comments.length = 0;
126
+ return result;
127
+ };
128
+ const nodeWithComments = (node = {}) => {
129
+ const commentsBefore = takeComments();
130
+ if (commentsBefore)
131
+ node.comments = commentsBefore;
132
+ return node;
133
+ };
134
+ const consume = (expected, isOptional) => {
135
+ if (token === expected)
136
+ return next();
137
+ if (!isOptional)
138
+ throw new Error(`Expected ${getReadable(expected)}, got ${scanner.getTokenValue()} at position ${scanner.getPosition()}`);
139
+ };
140
+ // === Основные парсеры ===
141
+ const parseValue = () => {
142
+ const node = nodeWithComments({});
143
+ switch (token) {
144
+ case 10 /* SyntaxKind.StringLiteral */:
145
+ node.value = scanner.getTokenValue();
146
+ next();
147
+ break;
148
+ case 11 /* SyntaxKind.NumericLiteral */:
149
+ node.value = Number(scanner.getTokenValue());
150
+ next();
151
+ break;
152
+ case 8 /* SyntaxKind.TrueKeyword */:
153
+ node.value = true;
154
+ next();
155
+ break;
156
+ case 9 /* SyntaxKind.FalseKeyword */:
157
+ node.value = false;
158
+ next();
159
+ break;
160
+ case 7 /* SyntaxKind.NullKeyword */:
161
+ node.value = null;
162
+ next();
163
+ break;
164
+ case 1 /* SyntaxKind.OpenBraceToken */:
165
+ node.value = parseObject();
166
+ break;
167
+ case 3 /* SyntaxKind.OpenBracketToken */:
168
+ node.value = parseArray();
169
+ break;
170
+ default:
171
+ throw new Error(`Unexpected ${getReadable(token)} at position ${scanner.getPosition()}`);
172
+ }
173
+ return node;
174
+ };
175
+ const parseObject = () => {
176
+ next(); // skip '{'
177
+ const result = {};
178
+ while (token !== 2 /* SyntaxKind.CloseBraceToken */ && token !== 17 /* SyntaxKind.EOF */) {
179
+ // Проверяем, что текущий токен — строка
180
+ if (token !== 10 /* SyntaxKind.StringLiteral */)
181
+ throw new Error(`Expected key at position ${scanner.getPosition()}`);
182
+ // Сохраняем значение ДО продвижения
183
+ const key = scanner.getTokenValue();
184
+ next();
185
+ consume(6 /* SyntaxKind.ColonToken */);
186
+ result[key] = parseValue();
187
+ consume(5 /* SyntaxKind.CommaToken */, true);
188
+ }
189
+ consume(2 /* SyntaxKind.CloseBraceToken */);
190
+ return result;
191
+ };
192
+ const parseArray = () => {
193
+ next(); // skip '['
194
+ const result = [];
195
+ while (token !== 4 /* SyntaxKind.CloseBracketToken */ && token !== 17 /* SyntaxKind.EOF */) {
196
+ result.push(parseValue());
197
+ if (token === 5 /* SyntaxKind.CommaToken */) {
198
+ next();
199
+ }
200
+ }
201
+ consume(4 /* SyntaxKind.CloseBracketToken */);
202
+ return result;
203
+ };
204
+ // === Запуск ===
205
+ skipTrivia();
206
+ if (token === 1 /* SyntaxKind.OpenBraceToken */) {
207
+ comments.length = 0; // сброс комментариев до корневого объекта
208
+ return parseObject();
209
+ }
210
+ throw new Error('Root must be object');
211
+ }
212
+
213
+ function serializeNode(value, indentSize = 0) {
214
+ const indent = ' '.repeat(indentSize);
215
+ const nextIndent = ' '.repeat(indentSize + 1);
216
+ if (value === null)
217
+ return 'null';
218
+ if (typeof value === 'object') {
219
+ if (Array.isArray(value)) {
220
+ if (value.length === 0)
221
+ return '[]';
222
+ const items = value.map((item) => {
223
+ const comments = item.comments ?? [];
224
+ const serializedValue = serializeNode(item.value, indentSize + 1);
225
+ const commentLines = comments.map(c => `${nextIndent}${c}`).join('\n');
226
+ return commentLines
227
+ ? `${commentLines}\n${nextIndent}${serializedValue}`
228
+ : `${nextIndent}${serializedValue}`;
229
+ });
230
+ return `[\n${items.join(`,\n`)}\n${indent}]`;
231
+ }
232
+ // Это JsoncContainer
233
+ const container = value;
234
+ const keys = Object.keys(container);
235
+ if (keys.length === 0)
236
+ return '{}';
237
+ const entries = keys.map((key) => {
238
+ const node = container[key];
239
+ const comments = node.comments?.map(c => `${nextIndent}${c}`).join('\n');
240
+ const serializedValue = serializeNode(node.value, indentSize + 1);
241
+ const field = `${nextIndent}${JSON.stringify(key)}: ${serializedValue}`;
242
+ return [comments, field].filter(Boolean).join('\n');
243
+ });
244
+ return `{\n${entries.join(',\n')}\n${indent}}`;
245
+ }
246
+ // Простые значения
247
+ return JSON.stringify(value);
248
+ }
249
+ function stringify(container) {
250
+ return serializeNode(container, 0);
251
+ }
252
+
253
+ async function renderAsync(fromPath, toPath, content) {
254
+ const targetContent = await fs.readFile(toPath, 'utf-8');
255
+ const targetObject = parseJsonc(targetContent);
256
+ const sourceObject = content !== undefined
257
+ ? parseJsonc(content)
258
+ : parseJsonc(await fs.readFile(fromPath, 'utf-8'));
259
+ const mergedObject = deepMerge(targetObject, sourceObject);
260
+ await fs.writeFile(toPath, stringify(mergedObject) + '\n');
261
+ }
262
+
263
+ /** Папки и файлы, игнорируемые при обходе шаблона. */
264
+ const IGNORED_DIRS = [
265
+ 'node_modules/**',
266
+ '.git/**',
267
+ 'dist/**',
268
+ '*.log',
269
+ ];
270
+ /** JSONC-файлы, которые мержатся с существующими при копировании. */
271
+ const KNOWN_JSONC = [
272
+ 'tsconfig.json',
273
+ 'mirta.config.json',
274
+ ];
275
+ /** JSON-файлы, которые мержатся с существующими при копировании. */
276
+ const KNOWN_JSON = [
277
+ 'extensions.json',
278
+ 'settings.json',
279
+ 'tasks.json',
280
+ ];
281
+ /**
282
+ * Асинхронно перебирает файлы директории, исключая {@link IGNORED_DIRS}.
283
+ *
284
+ * @param rootDir Корневая директория шаблона.
285
+ * @yields Объекты {@link Dirent} для каждого элемента.
286
+ *
287
+ * @since 0.4.0
288
+ *
289
+ **/
290
+ async function* getDirectoryEntriesAsync(rootDir) {
291
+ for await (const entry of glob('**/*', {
292
+ cwd: rootDir,
293
+ exclude: IGNORED_DIRS,
294
+ withFileTypes: true,
295
+ })) {
296
+ const relativePath = relative(rootDir, entry.parentPath);
297
+ yield {
298
+ relativePath,
299
+ name: entry.name,
300
+ isDirectory: entry.isDirectory(),
301
+ };
302
+ }
303
+ }
304
+ const supportedTypes = ['string', 'number', 'boolean'];
305
+ function toTemplateData(source) {
306
+ const result = {};
307
+ // Инициализация: корневые свойства
308
+ const stack = Object.entries(source);
309
+ let nextItem;
310
+ while (stack.length > 0 && (nextItem = stack.pop())) {
311
+ const [path, value] = nextItem; // DFS
312
+ const valueType = typeof value;
313
+ if (supportedTypes.includes(valueType)) {
314
+ result[path] = value;
315
+ continue;
316
+ }
317
+ if (Array.isArray(value)) {
318
+ for (const item of value) {
319
+ if (typeof item === 'string')
320
+ result[`${path}.${item}`] = true;
321
+ }
322
+ continue;
323
+ }
324
+ if (!isPlainObject(value))
325
+ continue;
326
+ const entries = Object.entries(value);
327
+ for (let i = entries.length - 1; i >= 0; i--) {
328
+ const [key, nextValue] = entries[i];
329
+ stack.push([`${path}.${key}`, nextValue]);
330
+ }
331
+ }
332
+ return result;
333
+ }
334
+ /**
335
+ * Заменяет в строке `{{key}}` и `{{#if key}}...{{/if key}}` на значения параметра `data`.
336
+ *
337
+ * @param content Текст с шаблонами.
338
+ * @param data Данные для подстановки.
339
+ * @returns Обработанный текст.
340
+ *
341
+ * @since 0.4.0
342
+ *
343
+ **/
344
+ function applyTemplatedText(content, data) {
345
+ if (!content.includes('{{'))
346
+ return content;
347
+ // Сначала обрабатываем условные блоки: {{#if key}}...{{/if key}}
348
+ content = content.replace(/{{#if\s+([a-zA-Z0-9_.]+)}}\n?([\s\S]*?)\n?{{\/if\s+\1}}/g, (_, key, blockContent) => {
349
+ return key in data ? applyTemplatedText(blockContent, data) : '';
350
+ });
351
+ // Затем подставляем переменные: {{key}}
352
+ content = content.replace(/{{([a-zA-Z0-9_.]+)}}/g, (original, key) => {
353
+ return key in data ? String(data[key]) : original;
354
+ });
355
+ return content;
356
+ }
357
+ /**
358
+ * Копирует или обрабатывает файл в целевую директорию.
359
+ * Учитывает специальные случаи: `package.json`, `.gitignore`, скрытые файлы.
360
+ *
361
+ * @param fromPath Путь к исходному файлу.
362
+ * @param toPath Путь к целевому файлу.
363
+ * @param content Опциональное содержимое (если файл шаблонизирован).
364
+ *
365
+ * @since 0.4.0
366
+ *
367
+ **/
368
+ async function renderFileAsync(fromPath, toPath, content) {
369
+ const toFileName = basename(toPath);
370
+ // Мерж package.json с сортировкой зависимостей
371
+ if (toFileName === 'package.json' && await isExistsAsync(toPath)) {
372
+ await renderAsync$1(fromPath, toPath, content, json => sortDependencies(json));
373
+ return;
374
+ }
375
+ // Мерж других известных JSON
376
+ if (KNOWN_JSON.includes(toFileName) && await isExistsAsync(toPath)) {
377
+ await renderAsync$1(fromPath, toPath, content);
378
+ return;
379
+ }
380
+ // Мерж известных JSONC
381
+ if (KNOWN_JSONC.includes(toFileName) && await isExistsAsync(toPath)) {
382
+ await renderAsync(fromPath, toPath, content);
383
+ return;
384
+ }
385
+ // Дописывание в .gitignore
386
+ if (toFileName === '.gitignore' && await isExistsAsync(toPath)) {
387
+ const oldContent = await fs.readFile(toPath, 'utf-8');
388
+ const newContent = content ?? await fs.readFile(fromPath, 'utf-8');
389
+ const separator = oldContent.endsWith('\n') ? '' : '\n';
390
+ await fs.writeFile(toPath, oldContent + separator + newContent);
391
+ return;
392
+ }
393
+ // Запись обработанного контента
394
+ if (content) {
395
+ await fs.writeFile(toPath, content);
396
+ return;
397
+ }
398
+ // Для остальных файлов — простое копирование
399
+ await fs.copyFile(fromPath, toPath);
400
+ }
401
+ /**
402
+ * Обрабатывает `.tt`-файл: шаблонизирует и передаёт в {@link renderFileAsync}.
403
+ *
404
+ * @param fromPath Путь к `.tt`-файлу.
405
+ * @param toPath Путь к целевому файлу (без `.tt`).
406
+ * @param data Данные для шаблона.
407
+ *
408
+ * @since 0.4.0
409
+ *
410
+ **/
411
+ async function renderFileTemplatedAsync(fromPath, toPath, data) {
412
+ const content = await fs.readFile(fromPath, 'utf-8');
413
+ await renderFileAsync(fromPath, toPath, applyTemplatedText(content, data));
414
+ }
415
+ function unescapeDot(path) {
416
+ // Экранированные точки заменяются в любом месте пути.
417
+ return path.replace(/_\./g, '.');
418
+ }
419
+ /**
420
+ * Применяет шаблон из `fromRoot` в `toRoot` с подстановкой данных.
421
+ * Поддерживает `.tt`, `_ → .`, мерж JSON.
422
+ *
423
+ * @param fromRoot Корень шаблона.
424
+ * @param toRoot Целевая директория.
425
+ * @param data Данные для шаблонов.
426
+ *
427
+ * @since 0.4.0
428
+ *
429
+ **/
430
+ async function renderDirectoryAsync(fromRoot, toRoot, data) {
431
+ for await (const entry of getDirectoryEntriesAsync(fromRoot)) {
432
+ // === 1. Переданные директории воссоздаём в целевом расположении ===
433
+ if (entry.isDirectory) {
434
+ const toPath = unescapeDot(applyTemplatedText(join(toRoot, entry.relativePath, entry.name), data));
435
+ await fs.mkdir(toPath, { recursive: true });
436
+ continue;
437
+ }
438
+ // === 2. Обрабатываем файлы ===
439
+ // Формируем путь к исходному файлу.
440
+ const fromPath = join(fromRoot, entry.relativePath, entry.name);
441
+ // Формируем путь к целевому файлу.
442
+ const toPath = unescapeDot(applyTemplatedText(join(toRoot, entry.relativePath, entry.name), data));
443
+ // Если файл шаблонизирован, обрабатываем его особым образом.
444
+ if (toPath.endsWith('.tt')) {
445
+ await renderFileTemplatedAsync(fromPath, toPath.slice(0, -3), data);
446
+ continue;
447
+ }
448
+ await renderFileAsync(fromPath, toPath);
449
+ }
450
+ }
451
+
452
+ const featurePriority = {
453
+ 'optional': 0,
454
+ 'recommended': 1,
455
+ 'required': 2,
456
+ 'blocked': 3,
457
+ };
458
+ function assertIsFeatureEntry(entry) {
459
+ const [feature, state] = entry;
460
+ if (typeof feature !== 'string')
461
+ throw new Error(`Invalid feature name: expected string, got ${typeof feature}`);
462
+ if (typeof state !== 'string')
463
+ throw new Error(`Invalid feature state: expected string, got ${typeof state}`);
464
+ if ((!(state in featurePriority)))
465
+ throw new Error(`Unknown feature state "${state}", supported values: ${Object.keys(featurePriority).join(', ')}`);
466
+ }
467
+ function extractFeatures(sequence) {
468
+ const result = {};
469
+ for (const template of sequence) {
470
+ if (!template.features?.global)
471
+ continue;
472
+ for (const entry of Object.entries(template.features.global)) {
473
+ assertIsFeatureEntry(entry);
474
+ const [featureName, state] = entry;
475
+ if (!(featureName in result)) {
476
+ result[featureName] = { state, origin: template.name };
477
+ continue;
478
+ }
479
+ const { state: oldState, origin } = result[featureName];
480
+ if (state === 'required' && oldState === 'blocked')
481
+ throw new Error(`Unable to require feature "${featureName}": blocked by "${origin}"`);
482
+ if (state === 'blocked' && oldState === 'required')
483
+ throw new Error(`Unable to block feature "${featureName}": required by "${origin}"`);
484
+ if (featurePriority[state] > featurePriority[oldState]) {
485
+ result[featureName] = { state, origin: template.name };
486
+ }
487
+ }
488
+ }
489
+ return result;
490
+ }
491
+
492
+ async function selectFeaturesAsync(features) {
493
+ const required = [];
494
+ const choices = [];
495
+ for (const [featureName, { state, origin }] of Object.entries(features)) {
496
+ const isRequired = state === 'required';
497
+ const isBlocked = state === 'blocked';
498
+ const isRecommended = state === 'recommended';
499
+ if (isRequired)
500
+ required.push(featureName);
501
+ if (isRequired || origin === FEATURE_ORIGIN_CLI)
502
+ continue;
503
+ let hint = '';
504
+ if (isRecommended)
505
+ hint = ` (${t('hint.recommended').toLowerCase()})`;
506
+ choices.push({
507
+ title: t.plain(`features.${featureName}.name`),
508
+ description: t.plain(`features.${featureName}.description`) + hint,
509
+ value: featureName,
510
+ selected: isRecommended,
511
+ disabled: isBlocked,
512
+ });
513
+ }
514
+ const { selected } = await prompts({
515
+ type: 'multiselect',
516
+ name: 'selected',
517
+ message: t('features.select'),
518
+ instructions: '\n' + chalk.dim(t('features.instructions')),
519
+ warn: t('hint.blocked'),
520
+ choices: choices,
521
+ });
522
+ return [
523
+ ...required,
524
+ ...selected,
525
+ ];
526
+ }
527
+
528
+ function overrideState(key, isEnabled, features) {
529
+ const feature = features[key];
530
+ if (!feature) {
531
+ if (isEnabled)
532
+ logger.warn(t('feature.skipped', { feature: key }));
533
+ return false;
534
+ }
535
+ if (isEnabled) {
536
+ if (feature.state === 'blocked')
537
+ throw new Error(`Feature ${key} blocked by ${feature.origin}`);
538
+ feature.state = 'required';
539
+ feature.origin = FEATURE_ORIGIN_CLI;
540
+ }
541
+ else {
542
+ if (feature.state === 'required')
543
+ throw new Error(`Feature ${key} required by ${feature.origin}`);
544
+ feature.state = 'blocked';
545
+ feature.origin = FEATURE_ORIGIN_CLI;
546
+ }
547
+ return true;
548
+ }
549
+ async function resolveFeaturesAsync(context, overrides = {}) {
550
+ const features = extractFeatures(context.templates);
551
+ for (const [key, value] of Object.entries(overrides)) {
552
+ if (value === undefined)
553
+ continue;
554
+ overrideState(key, !!value, features);
555
+ }
556
+ return await selectFeaturesAsync(features);
557
+ }
558
+
559
+ /**
560
+ * Имя пользователя по умолчанию для SSH-подключения.
561
+ *
562
+ * Используется, когда значение отсутствует в строке подключения.
563
+ *
564
+ * @since 0.4.0
565
+ *
566
+ **/
567
+ const DEFAULT_SSH_USERNAME = 'root';
568
+ /**
569
+ * Адрес хоста по умолчанию для подключения к контроллеру.
570
+ *
571
+ * Используется, когда значение отсутствует в строке подключения.
572
+ *
573
+ * @since 0.4.0
574
+ *
575
+ **/
576
+ const DEFAULT_SSH_HOSTNAME = '192.168.42.1';
577
+ /**
578
+ * Стандартный порт SSH.
579
+ *
580
+ * @since 0.4.0
581
+ *
582
+ **/
583
+ const KNOWN_SSH_PORT = 22;
584
+
585
+ const urlRegex = /^(?:(?<username>[a-z][-a-z0-9_.]*)@)?(?:(?<hostname>[^:@\s]+))?(?::(?<port>\d+))?$/;
586
+ const usernameRegex = /^[a-z][-a-z0-9_.]*$/;
587
+ const hostnameRegex = /^[^:@\s]+$/;
588
+ function parseUrl(input) {
589
+ if (!input)
590
+ return {};
591
+ return (urlRegex.exec(input))?.groups ?? {};
592
+ }
593
+
594
+ const rutokenLib = 'pkcs11=/opt/aktivco/rutokenecp/amd64/librtpkcs11ecp.so';
595
+ function createConnectionString(input) {
596
+ let connection = `ssh://${input.username}@${input.hostname}`;
597
+ if (Number(input.port) !== KNOWN_SSH_PORT)
598
+ connection += `:${input.port}`;
599
+ if (input.rutoken)
600
+ connection += `;${rutokenLib}`;
601
+ return connection;
602
+ }
603
+ async function resolveConnectionStringAsync(input, rutoken) {
604
+ if (input) {
605
+ const parsed = parseUrl(input);
606
+ return createConnectionString({
607
+ username: parsed.username || DEFAULT_SSH_USERNAME,
608
+ hostname: parsed.hostname || DEFAULT_SSH_HOSTNAME,
609
+ port: parsed.port || KNOWN_SSH_PORT,
610
+ rutoken,
611
+ });
612
+ }
613
+ const questions = [
614
+ {
615
+ type: 'text',
616
+ name: 'username',
617
+ message: t('ssh.username'),
618
+ initial: DEFAULT_SSH_USERNAME,
619
+ validate: (value) => {
620
+ if (value.trim().length === 0)
621
+ return t('validation.required');
622
+ if (!usernameRegex.test(value))
623
+ return t('validation.invalidFormat');
624
+ return true;
625
+ },
626
+ },
627
+ {
628
+ type: 'text',
629
+ name: 'hostname',
630
+ message: t('ssh.hostname'),
631
+ initial: DEFAULT_SSH_HOSTNAME,
632
+ validate: (value) => {
633
+ if (value.trim().length === 0)
634
+ return t('validation.required');
635
+ if (!hostnameRegex.test(value))
636
+ return t('validation.invalidFormat');
637
+ return true;
638
+ },
639
+ },
640
+ {
641
+ type: 'number',
642
+ name: 'port',
643
+ message: t('ssh.port'),
644
+ initial: KNOWN_SSH_PORT,
645
+ min: 1,
646
+ max: 65535,
647
+ },
648
+ ];
649
+ if (rutoken === undefined)
650
+ questions.push({
651
+ type: 'toggle',
652
+ name: 'rutoken',
653
+ message: t('ssh.rutoken'),
654
+ initial: false,
655
+ active: t('yes'),
656
+ inactive: t('no'),
657
+ });
658
+ logger.step(t('connection.caption'));
659
+ const response = await prompts(questions);
660
+ return createConnectionString({
661
+ username: response.username,
662
+ hostname: response.hostname,
663
+ port: response.port,
664
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
665
+ rutoken: rutoken || response.rutoken,
666
+ });
667
+ }
668
+
669
+ /**
670
+ * Режим `stdio`: ввод игнорируется, `stdout` и `stderr` перехватываются.
671
+ *
672
+ * Используется для полного захвата вывода команды.
673
+ *
674
+ * @since 0.4.0
675
+ *
676
+ **/
677
+ const STDIO_CAPTURE_OUTPUT = ['ignore', 'pipe', 'pipe'];
678
+ /**
679
+ * Ошибка выполнения команды в shell.
680
+ *
681
+ * Возникает, когда команда завершилась с кодом, не входящим в `doneCodes` или `cancelCodes`.
682
+ *
683
+ * @since 0.4.0
684
+ *
685
+ **/
686
+ class ShellError extends Error {
687
+ constructor(message) {
688
+ super(message);
689
+ this.name = 'ShellError';
690
+ Error.captureStackTrace(this, ShellError);
691
+ }
692
+ }
693
+ /**
694
+ * Асинхронно выполняет команду и возвращает результат.
695
+ *
696
+ * Перехватывает `stdout` и `stderr`.
697
+ * Поддерживает кастомные коды успеха и отмены.
698
+ *
699
+ * @param command - Команда (например, `ls`, `rsync`).
700
+ * @param args - Аргументы команды.
701
+ * @param options - Опции запуска процесса.
702
+ * @returns Результат выполнения: код, вывод, ошибки.
703
+ * @throws {ShellError} Если команда завершилась с ошибкой.
704
+ * @throws {OperationCanceledError} Если операция была отменена пользователем.
705
+ *
706
+ * @since 0.4.0
707
+ *
708
+ **/
709
+ async function execAsync(command, args = [], options = {}) {
710
+ return new Promise((resolve, reject) => {
711
+ const { doneCodes = [0], cancelCodes = [130], ...spawnOptions } = options;
712
+ spawnOptions.stdio ??= STDIO_CAPTURE_OUTPUT;
713
+ spawnOptions.shell ??= false;
714
+ const runner = spawn(command, args, spawnOptions);
715
+ const stdoutChunks = [];
716
+ const stderrChunks = [];
717
+ runner.stdout?.on('data', (chunk) => {
718
+ stdoutChunks.push(chunk);
719
+ });
720
+ runner.stderr?.on('data', (chunk) => {
721
+ stderrChunks.push(chunk);
722
+ });
723
+ runner.on('error', reject);
724
+ runner.on('exit', (code) => {
725
+ const isDone = code !== null && doneCodes.includes(code);
726
+ const stdout = Buffer.concat(stdoutChunks).toString().trim();
727
+ const stderr = Buffer.concat(stderrChunks).toString().trim();
728
+ if (isDone) {
729
+ resolve({ isDone, code, stdout, stderr });
730
+ }
731
+ else {
732
+ const isCanceled = code !== null && cancelCodes.includes(code);
733
+ reject(isCanceled
734
+ ? new OperationCanceledError()
735
+ : new ShellError(`Failed to execute command ${command} ${args.join(' ')}: ${stderr}`));
736
+ }
737
+ });
738
+ });
739
+ }
740
+ /**
741
+ * Универсальная функция для запуска команд.
742
+ *
743
+ * @since 0.4.0
744
+ *
745
+ **/
746
+ const runCommandAsync = async (command, args, options = {}) => await execAsync(command, args, { ...options });
747
+
748
+ function getCurrentPackageManager() {
749
+ const userAgent = process.env.npm_config_user_agent;
750
+ if (!userAgent)
751
+ return;
752
+ const [name, version] = userAgent.split(' ')[0].split('/');
753
+ return {
754
+ name,
755
+ version,
756
+ };
757
+ }
758
+ function toAnswers(...managers) {
759
+ return managers.map(manager => ({
760
+ title: t('dependencies.answer.yesUsing', { manager }),
761
+ value: manager,
762
+ }));
763
+ }
764
+ async function promptInstallDependenciesAsync(cwd) {
765
+ const currentManager = getCurrentPackageManager();
766
+ const { manager } = await prompts({
767
+ type: 'select',
768
+ name: 'manager',
769
+ message: t('dependencies.prompt'),
770
+ hint: `(${t('hint.recommended').toLowerCase()})`,
771
+ choices: currentManager
772
+ ? [
773
+ {
774
+ title: t('dependencies.answer.yesUsing', { manager: currentManager.name }),
775
+ value: currentManager.name,
776
+ },
777
+ {
778
+ title: t('dependencies.answer.no'),
779
+ value: '',
780
+ },
781
+ ]
782
+ : [
783
+ ...toAnswers('pnpm', 'yarn', 'npm', 'bun'),
784
+ {
785
+ title: t('dependencies.answer.no'),
786
+ value: '',
787
+ },
788
+ ],
789
+ });
790
+ if (!manager)
791
+ return;
792
+ await runCommandAsync(manager, ['install'], { cwd, shell: true });
793
+ }
794
+
795
+ const DEFAULT_BRANCH = 'main';
796
+ async function resolveGithubInfoAsync(input) {
797
+ let owner;
798
+ let repository;
799
+ let branch;
800
+ logger.step(t('github.caption'));
801
+ const answer = await prompts([
802
+ {
803
+ type: 'text',
804
+ name: 'owner',
805
+ message: t('github.owner.prompt'),
806
+ initial: owner,
807
+ validate: (value) => {
808
+ if (value.trim().length === 0)
809
+ return t('validation.required');
810
+ else
811
+ return true;
812
+ },
813
+ },
814
+ {
815
+ type: 'text',
816
+ name: 'repository',
817
+ message: t('github.repository.prompt'),
818
+ initial: repository,
819
+ validate: (value) => {
820
+ if (value.trim().length === 0)
821
+ return t('validation.required');
822
+ else
823
+ return true;
824
+ },
825
+ },
826
+ {
827
+ type: 'text',
828
+ name: 'branch',
829
+ message: t('github.branch.prompt'),
830
+ initial: branch || DEFAULT_BRANCH,
831
+ },
832
+ ]);
833
+ owner = answer.owner.trim();
834
+ repository = answer.repository.trim();
835
+ branch = answer.branch?.trim();
836
+ return {
837
+ owner,
838
+ repository,
839
+ branch: branch || DEFAULT_BRANCH,
840
+ };
841
+ }
842
+
843
+ export { DEFAULT_SSH_USERNAME as D, resolveGithubInfoAsync as a, resolveConnectionStringAsync as b, renderDirectoryAsync as c, DEFAULT_SSH_HOSTNAME as d, promptInstallDependenciesAsync as p, resolveFeaturesAsync as r, toTemplateData as t };