@aifabrix/builder 2.0.0 → 2.0.2

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 (58) hide show
  1. package/README.md +6 -2
  2. package/bin/aifabrix.js +9 -3
  3. package/jest.config.integration.js +30 -0
  4. package/lib/app-config.js +157 -0
  5. package/lib/app-deploy.js +233 -82
  6. package/lib/app-dockerfile.js +112 -0
  7. package/lib/app-prompts.js +244 -0
  8. package/lib/app-push.js +172 -0
  9. package/lib/app-run.js +334 -133
  10. package/lib/app.js +208 -274
  11. package/lib/audit-logger.js +2 -0
  12. package/lib/build.js +209 -98
  13. package/lib/cli.js +76 -86
  14. package/lib/commands/app.js +414 -0
  15. package/lib/commands/login.js +304 -0
  16. package/lib/config.js +78 -0
  17. package/lib/deployer.js +225 -81
  18. package/lib/env-reader.js +45 -30
  19. package/lib/generator.js +308 -191
  20. package/lib/github-generator.js +67 -7
  21. package/lib/infra.js +156 -61
  22. package/lib/push.js +105 -10
  23. package/lib/schema/application-schema.json +30 -2
  24. package/lib/schema/infrastructure-schema.json +589 -0
  25. package/lib/secrets.js +229 -24
  26. package/lib/template-validator.js +205 -0
  27. package/lib/templates.js +305 -170
  28. package/lib/utils/api.js +329 -0
  29. package/lib/utils/cli-utils.js +97 -0
  30. package/lib/utils/dockerfile-utils.js +131 -0
  31. package/lib/utils/environment-checker.js +125 -0
  32. package/lib/utils/error-formatter.js +61 -0
  33. package/lib/utils/health-check.js +187 -0
  34. package/lib/utils/logger.js +53 -0
  35. package/lib/utils/template-helpers.js +223 -0
  36. package/lib/utils/variable-transformer.js +271 -0
  37. package/lib/validator.js +27 -112
  38. package/package.json +13 -10
  39. package/templates/README.md +75 -3
  40. package/templates/applications/keycloak/Dockerfile +36 -0
  41. package/templates/applications/keycloak/env.template +32 -0
  42. package/templates/applications/keycloak/rbac.yaml +37 -0
  43. package/templates/applications/keycloak/variables.yaml +56 -0
  44. package/templates/applications/miso-controller/Dockerfile +125 -0
  45. package/templates/applications/miso-controller/env.template +129 -0
  46. package/templates/applications/miso-controller/rbac.yaml +168 -0
  47. package/templates/applications/miso-controller/variables.yaml +56 -0
  48. package/templates/github/release.yaml.hbs +5 -26
  49. package/templates/github/steps/npm.hbs +24 -0
  50. package/templates/infra/compose.yaml +6 -6
  51. package/templates/python/docker-compose.hbs +19 -12
  52. package/templates/python/main.py +80 -0
  53. package/templates/python/requirements.txt +4 -0
  54. package/templates/typescript/Dockerfile.hbs +2 -2
  55. package/templates/typescript/docker-compose.hbs +19 -12
  56. package/templates/typescript/index.ts +116 -0
  57. package/templates/typescript/package.json +26 -0
  58. package/templates/typescript/tsconfig.json +24 -0
package/lib/secrets.js CHANGED
@@ -13,6 +13,9 @@ const fs = require('fs');
13
13
  const path = require('path');
14
14
  const yaml = require('js-yaml');
15
15
  const os = require('os');
16
+ const crypto = require('crypto');
17
+ const chalk = require('chalk');
18
+ const logger = require('./utils/logger');
16
19
 
17
20
  /**
18
21
  * Loads environment configuration for docker/local context
@@ -81,12 +84,16 @@ async function resolveKvReferences(envTemplate, secrets, environment = 'local')
81
84
  const envConfig = loadEnvConfig();
82
85
  const envVars = envConfig.environments[environment] || envConfig.environments.local;
83
86
 
84
- let resolved = envTemplate;
87
+ // First, replace ${VAR} references in the template itself (for variables like DB_HOST=${DB_HOST})
88
+ let resolved = envTemplate.replace(/\$\{([A-Z_]+)\}/g, (match, envVar) => {
89
+ return envVars[envVar] || match;
90
+ });
91
+
85
92
  const kvPattern = /kv:\/\/([a-zA-Z0-9-_]+)/g;
86
93
  const missingSecrets = [];
87
94
 
88
95
  let match;
89
- while ((match = kvPattern.exec(envTemplate)) !== null) {
96
+ while ((match = kvPattern.exec(resolved)) !== null) {
90
97
  const secretKey = match[1];
91
98
  if (!(secretKey in secrets)) {
92
99
  missingSecrets.push(`kv://${secretKey}`);
@@ -97,9 +104,11 @@ async function resolveKvReferences(envTemplate, secrets, environment = 'local')
97
104
  throw new Error(`Missing secrets: ${missingSecrets.join(', ')}`);
98
105
  }
99
106
 
107
+ // Now replace kv:// references, and handle ${VAR} inside the secret values
100
108
  resolved = resolved.replace(kvPattern, (match, secretKey) => {
101
109
  let value = secrets[secretKey];
102
110
  if (typeof value === 'string') {
111
+ // Replace ${VAR} references inside the secret value
103
112
  value = value.replace(/\$\{([A-Z_]+)\}/g, (m, envVar) => {
104
113
  return envVars[envVar] || m;
105
114
  });
@@ -110,6 +119,197 @@ async function resolveKvReferences(envTemplate, secrets, environment = 'local')
110
119
  return resolved;
111
120
  }
112
121
 
122
+ /**
123
+ * Finds missing secret keys from template
124
+ * @function findMissingSecretKeys
125
+ * @param {string} envTemplate - Environment template content
126
+ * @param {Object} existingSecrets - Existing secrets object
127
+ * @returns {string[]} Array of missing secret keys
128
+ */
129
+ function findMissingSecretKeys(envTemplate, existingSecrets) {
130
+ const kvPattern = /kv:\/\/([a-zA-Z0-9-_]+)/g;
131
+ const missingKeys = [];
132
+ const seenKeys = new Set();
133
+
134
+ let match;
135
+ while ((match = kvPattern.exec(envTemplate)) !== null) {
136
+ const secretKey = match[1];
137
+ if (!seenKeys.has(secretKey) && !(secretKey in existingSecrets)) {
138
+ missingKeys.push(secretKey);
139
+ seenKeys.add(secretKey);
140
+ }
141
+ }
142
+
143
+ return missingKeys;
144
+ }
145
+
146
+ /**
147
+ * Generates secret value based on key name
148
+ * @function generateSecretValue
149
+ * @param {string} key - Secret key name
150
+ * @returns {string} Generated secret value
151
+ */
152
+ function generateSecretValue(key) {
153
+ const keyLower = key.toLowerCase();
154
+
155
+ if (keyLower.includes('password')) {
156
+ const dbPasswordMatch = key.match(/^databases-([a-z0-9-_]+)-\d+-passwordKeyVault$/i);
157
+ if (dbPasswordMatch) {
158
+ const appName = dbPasswordMatch[1];
159
+ const dbName = appName.replace(/-/g, '_');
160
+ return `${dbName}_pass123`;
161
+ }
162
+ return crypto.randomBytes(32).toString('base64');
163
+ }
164
+
165
+ if (keyLower.includes('url') || keyLower.includes('uri')) {
166
+ const dbUrlMatch = key.match(/^databases-([a-z0-9-_]+)-\d+-urlKeyVault$/i);
167
+ if (dbUrlMatch) {
168
+ const appName = dbUrlMatch[1];
169
+ const dbName = appName.replace(/-/g, '_');
170
+ return `postgresql://${dbName}_user:${dbName}_pass123@\${DB_HOST}:5432/${dbName}`;
171
+ }
172
+ return '';
173
+ }
174
+
175
+ if (keyLower.includes('key') || keyLower.includes('secret') || keyLower.includes('token')) {
176
+ return crypto.randomBytes(32).toString('base64');
177
+ }
178
+
179
+ return '';
180
+ }
181
+
182
+ /**
183
+ * Loads existing secrets from file
184
+ * @function loadExistingSecrets
185
+ * @param {string} resolvedPath - Path to secrets file
186
+ * @returns {Object} Existing secrets object
187
+ */
188
+ function loadExistingSecrets(resolvedPath) {
189
+ if (!fs.existsSync(resolvedPath)) {
190
+ return {};
191
+ }
192
+
193
+ try {
194
+ const content = fs.readFileSync(resolvedPath, 'utf8');
195
+ const secrets = yaml.load(content) || {};
196
+ return typeof secrets === 'object' ? secrets : {};
197
+ } catch (error) {
198
+ logger.warn(`Warning: Could not read existing secrets file: ${error.message}`);
199
+ return {};
200
+ }
201
+ }
202
+
203
+ /**
204
+ * Saves secrets file
205
+ * @function saveSecretsFile
206
+ * @param {string} resolvedPath - Path to secrets file
207
+ * @param {Object} secrets - Secrets object to save
208
+ * @throws {Error} If save fails
209
+ */
210
+ function saveSecretsFile(resolvedPath, secrets) {
211
+ const dir = path.dirname(resolvedPath);
212
+ if (!fs.existsSync(dir)) {
213
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
214
+ }
215
+
216
+ const yamlContent = yaml.dump(secrets, {
217
+ indent: 2,
218
+ lineWidth: 120,
219
+ noRefs: true,
220
+ sortKeys: false
221
+ });
222
+
223
+ fs.writeFileSync(resolvedPath, yamlContent, { mode: 0o600 });
224
+ }
225
+
226
+ /**
227
+ * Generates missing secret keys in secrets file
228
+ * Scans env.template for kv:// references and adds missing keys with secure defaults
229
+ *
230
+ * @async
231
+ * @function generateMissingSecrets
232
+ * @param {string} envTemplate - Environment template content
233
+ * @param {string} secretsPath - Path to secrets file
234
+ * @returns {Promise<string[]>} Array of newly generated secret keys
235
+ * @throws {Error} If generation fails
236
+ *
237
+ * @example
238
+ * const newKeys = await generateMissingSecrets(template, '~/.aifabrix/secrets.yaml');
239
+ * // Returns: ['new-secret-key', 'another-secret']
240
+ */
241
+ async function generateMissingSecrets(envTemplate, secretsPath) {
242
+ const resolvedPath = secretsPath || path.join(os.homedir(), '.aifabrix', 'secrets.yaml');
243
+ const existingSecrets = loadExistingSecrets(resolvedPath);
244
+ const missingKeys = findMissingSecretKeys(envTemplate, existingSecrets);
245
+
246
+ if (missingKeys.length === 0) {
247
+ return [];
248
+ }
249
+
250
+ const newSecrets = {};
251
+ for (const key of missingKeys) {
252
+ newSecrets[key] = generateSecretValue(key);
253
+ }
254
+
255
+ const updatedSecrets = { ...existingSecrets, ...newSecrets };
256
+ saveSecretsFile(resolvedPath, updatedSecrets);
257
+
258
+ logger.log(`✓ Generated ${missingKeys.length} missing secret key(s): ${missingKeys.join(', ')}`);
259
+ return missingKeys;
260
+ }
261
+
262
+ /**
263
+ * Loads environment template from file
264
+ * @function loadEnvTemplate
265
+ * @param {string} templatePath - Path to env.template
266
+ * @returns {string} Template content
267
+ * @throws {Error} If file not found
268
+ */
269
+ function loadEnvTemplate(templatePath) {
270
+ if (!fs.existsSync(templatePath)) {
271
+ throw new Error(`env.template not found: ${templatePath}`);
272
+ }
273
+ return fs.readFileSync(templatePath, 'utf8');
274
+ }
275
+
276
+ /**
277
+ * Processes environment variables and copies to output path if needed
278
+ * @function processEnvVariables
279
+ * @param {string} envPath - Path to generated .env file
280
+ * @param {string} variablesPath - Path to variables.yaml
281
+ * @throws {Error} If processing fails
282
+ */
283
+ function processEnvVariables(envPath, variablesPath) {
284
+ if (!fs.existsSync(variablesPath)) {
285
+ return;
286
+ }
287
+
288
+ const variablesContent = fs.readFileSync(variablesPath, 'utf8');
289
+ const variables = yaml.load(variablesContent);
290
+
291
+ if (!variables?.build?.envOutputPath || variables.build.envOutputPath === null) {
292
+ return;
293
+ }
294
+
295
+ let outputPath = path.resolve(process.cwd(), variables.build.envOutputPath);
296
+ if (!outputPath.endsWith('.env')) {
297
+ if (fs.existsSync(outputPath) && fs.statSync(outputPath).isDirectory()) {
298
+ outputPath = path.join(outputPath, '.env');
299
+ } else {
300
+ outputPath = path.join(outputPath, '.env');
301
+ }
302
+ }
303
+
304
+ const outputDir = path.dirname(outputPath);
305
+ if (!fs.existsSync(outputDir)) {
306
+ fs.mkdirSync(outputDir, { recursive: true });
307
+ }
308
+
309
+ fs.copyFileSync(envPath, outputPath);
310
+ logger.log(chalk.green(`✓ Copied .env to: ${variables.build.envOutputPath}`));
311
+ }
312
+
113
313
  /**
114
314
  * Generates .env file from template and secrets
115
315
  * Creates environment file for local development
@@ -119,44 +319,32 @@ async function resolveKvReferences(envTemplate, secrets, environment = 'local')
119
319
  * @param {string} appName - Name of the application
120
320
  * @param {string} [secretsPath] - Path to secrets file (optional)
121
321
  * @param {string} [environment='local'] - Environment context
322
+ * @param {boolean} [force=false] - Generate missing secret keys in secrets file
122
323
  * @returns {Promise<string>} Path to generated .env file
123
324
  * @throws {Error} If generation fails
124
325
  *
125
326
  * @example
126
- * const envPath = await generateEnvFile('myapp', '../../secrets.local.yaml');
327
+ * const envPath = await generateEnvFile('myapp', '../../secrets.local.yaml', 'local', true);
127
328
  * // Returns: './builder/myapp/.env'
128
329
  */
129
- async function generateEnvFile(appName, secretsPath, environment = 'local') {
330
+ async function generateEnvFile(appName, secretsPath, environment = 'local', force = false) {
130
331
  const builderPath = path.join(process.cwd(), 'builder', appName);
131
332
  const templatePath = path.join(builderPath, 'env.template');
132
333
  const variablesPath = path.join(builderPath, 'variables.yaml');
133
334
  const envPath = path.join(builderPath, '.env');
134
335
 
135
- if (!fs.existsSync(templatePath)) {
136
- throw new Error(`env.template not found: ${templatePath}`);
336
+ const template = loadEnvTemplate(templatePath);
337
+
338
+ if (force) {
339
+ const resolvedSecretsPath = secretsPath || path.join(os.homedir(), '.aifabrix', 'secrets.yaml');
340
+ await generateMissingSecrets(template, resolvedSecretsPath);
137
341
  }
138
342
 
139
- const template = fs.readFileSync(templatePath, 'utf8');
140
343
  const secrets = await loadSecrets(secretsPath);
141
344
  const resolved = await resolveKvReferences(template, secrets, environment);
142
345
 
143
346
  fs.writeFileSync(envPath, resolved, { mode: 0o600 });
144
-
145
- if (fs.existsSync(variablesPath)) {
146
- const variablesContent = fs.readFileSync(variablesPath, 'utf8');
147
- const variables = yaml.load(variablesContent);
148
-
149
- if (variables?.build?.envOutputPath) {
150
- const outputPath = path.resolve(builderPath, variables.build.envOutputPath);
151
- const outputDir = path.dirname(outputPath);
152
-
153
- if (!fs.existsSync(outputDir)) {
154
- fs.mkdirSync(outputDir, { recursive: true });
155
- }
156
-
157
- fs.copyFileSync(envPath, outputPath);
158
- }
159
- }
347
+ processEnvVariables(envPath, variablesPath);
160
348
 
161
349
  return envPath;
162
350
  }
@@ -176,7 +364,23 @@ async function generateEnvFile(appName, secretsPath, environment = 'local') {
176
364
  * // Returns: '~/.aifabrix/admin-secrets.env'
177
365
  */
178
366
  async function generateAdminSecretsEnv(secretsPath) {
179
- const secrets = await loadSecrets(secretsPath);
367
+ let secrets;
368
+
369
+ try {
370
+ secrets = await loadSecrets(secretsPath);
371
+ } catch (error) {
372
+ // If secrets file doesn't exist, create default secrets
373
+ const defaultSecretsPath = secretsPath || path.join(os.homedir(), '.aifabrix', 'secrets.yaml');
374
+
375
+ if (!fs.existsSync(defaultSecretsPath)) {
376
+ logger.log('Creating default secrets file...');
377
+ await createDefaultSecrets(defaultSecretsPath);
378
+ secrets = await loadSecrets(secretsPath);
379
+ } else {
380
+ throw error;
381
+ }
382
+ }
383
+
180
384
  const aifabrixDir = path.join(os.homedir(), '.aifabrix');
181
385
  const adminEnvPath = path.join(aifabrixDir, 'admin-secrets.env');
182
386
 
@@ -276,6 +480,7 @@ module.exports = {
276
480
  loadSecrets,
277
481
  resolveKvReferences,
278
482
  generateEnvFile,
483
+ generateMissingSecrets,
279
484
  generateAdminSecretsEnv,
280
485
  validateSecrets,
281
486
  createDefaultSecrets
@@ -0,0 +1,205 @@
1
+ /**
2
+ * AI Fabrix Builder Template Validation and Management
3
+ *
4
+ * Validates template folders and copies template files to application directories
5
+ *
6
+ * @fileoverview Template validation and file copying utilities
7
+ * @author AI Fabrix Team
8
+ * @version 2.0.0
9
+ */
10
+
11
+ const fs = require('fs').promises;
12
+ const fsSync = require('fs');
13
+ const path = require('path');
14
+
15
+ /**
16
+ * Validates that a template exists and contains files
17
+ * @param {string} templateName - Template name to validate
18
+ * @returns {Promise<boolean>} True if template is valid
19
+ * @throws {Error} If template folder doesn't exist or is empty
20
+ */
21
+ async function validateTemplate(templateName) {
22
+ if (!templateName || typeof templateName !== 'string') {
23
+ throw new Error('Template name is required and must be a string');
24
+ }
25
+
26
+ const templatePath = path.join(__dirname, '..', 'templates', 'applications', templateName);
27
+
28
+ // Check if template folder exists
29
+ if (!fsSync.existsSync(templatePath)) {
30
+ throw new Error(`Template '${templateName}' not found. Expected folder: templates/applications/${templateName}/`);
31
+ }
32
+
33
+ // Check if it's a directory
34
+ const stats = fsSync.statSync(templatePath);
35
+ if (!stats.isDirectory()) {
36
+ throw new Error(`Template '${templateName}' exists but is not a directory`);
37
+ }
38
+
39
+ // Check if folder contains at least one file
40
+ const entries = await fs.readdir(templatePath);
41
+ const files = entries.filter(entry => {
42
+ const entryPath = path.join(templatePath, entry);
43
+ const entryStats = fsSync.statSync(entryPath);
44
+ return entryStats.isFile() && !entry.startsWith('.');
45
+ });
46
+
47
+ if (files.length === 0) {
48
+ throw new Error(`Template '${templateName}' folder exists but contains no files`);
49
+ }
50
+
51
+ return true;
52
+ }
53
+
54
+ /**
55
+ * Copies all files from template folder to application directory
56
+ * Preserves directory structure and skips hidden files
57
+ * @param {string} templateName - Template name to copy
58
+ * @param {string} appPath - Target application directory path
59
+ * @returns {Promise<string[]>} Array of copied file paths
60
+ * @throws {Error} If template validation fails or copying fails
61
+ */
62
+ async function copyTemplateFiles(templateName, appPath) {
63
+ // Validate template first
64
+ await validateTemplate(templateName);
65
+
66
+ const templatePath = path.join(__dirname, '..', 'templates', 'applications', templateName);
67
+ const copiedFiles = [];
68
+
69
+ async function copyDirectory(sourceDir, targetDir) {
70
+ // Ensure target directory exists
71
+ await fs.mkdir(targetDir, { recursive: true });
72
+
73
+ const entries = await fs.readdir(sourceDir);
74
+
75
+ for (const entry of entries) {
76
+ // Skip hidden files and directories
77
+ if (entry.startsWith('.')) {
78
+ continue;
79
+ }
80
+
81
+ const sourcePath = path.join(sourceDir, entry);
82
+ const targetPath = path.join(targetDir, entry);
83
+
84
+ const stats = await fs.stat(sourcePath);
85
+
86
+ if (stats.isDirectory()) {
87
+ // Recursively copy subdirectories
88
+ await copyDirectory(sourcePath, targetPath);
89
+ } else if (stats.isFile()) {
90
+ // Copy file
91
+ await fs.copyFile(sourcePath, targetPath);
92
+ copiedFiles.push(targetPath);
93
+ }
94
+ }
95
+ }
96
+
97
+ await copyDirectory(templatePath, appPath);
98
+
99
+ return copiedFiles;
100
+ }
101
+
102
+ /**
103
+ * Copies application files from language template directory
104
+ * Copies files like package.json, index.ts, requirements.txt, main.py from templates/{language}/
105
+ * @param {string} language - Language name (typescript or python)
106
+ * @param {string} appPath - Target application directory path
107
+ * @returns {Promise<string[]>} Array of copied file paths
108
+ * @throws {Error} If language template doesn't exist or copying fails
109
+ */
110
+ async function copyAppFiles(language, appPath) {
111
+ if (!language || typeof language !== 'string') {
112
+ throw new Error('Language is required and must be a string');
113
+ }
114
+
115
+ const normalizedLanguage = language.toLowerCase();
116
+ const languageTemplatePath = path.join(__dirname, '..', 'templates', normalizedLanguage);
117
+
118
+ // Check if language template folder exists
119
+ if (!fsSync.existsSync(languageTemplatePath)) {
120
+ throw new Error(`Language template '${normalizedLanguage}' not found. Expected folder: templates/${normalizedLanguage}/`);
121
+ }
122
+
123
+ const stats = fsSync.statSync(languageTemplatePath);
124
+ if (!stats.isDirectory()) {
125
+ throw new Error(`Language template '${normalizedLanguage}' exists but is not a directory`);
126
+ }
127
+
128
+ const copiedFiles = [];
129
+ const entries = await fs.readdir(languageTemplatePath);
130
+
131
+ // Copy only application files, skip Dockerfile and docker-compose templates
132
+ const appFiles = entries.filter(entry => {
133
+ const lowerEntry = entry.toLowerCase();
134
+ // Include .gitignore, exclude .hbs files and docker-related files
135
+ if (entry === '.gitignore') {
136
+ return true;
137
+ }
138
+ if (lowerEntry.endsWith('.hbs')) {
139
+ return false;
140
+ }
141
+ if (lowerEntry.startsWith('dockerfile') || lowerEntry.includes('docker-compose')) {
142
+ return false;
143
+ }
144
+ if (entry.startsWith('.')) {
145
+ return false;
146
+ }
147
+ return true;
148
+ });
149
+
150
+ for (const entry of appFiles) {
151
+ const sourcePath = path.join(languageTemplatePath, entry);
152
+ const targetPath = path.join(appPath, entry);
153
+
154
+ const entryStats = await fs.stat(sourcePath);
155
+ if (entryStats.isFile()) {
156
+ await fs.copyFile(sourcePath, targetPath);
157
+ copiedFiles.push(targetPath);
158
+ }
159
+ }
160
+
161
+ return copiedFiles;
162
+ }
163
+
164
+ /**
165
+ * Lists available templates
166
+ * @returns {Promise<string[]>} Array of available template names
167
+ */
168
+ async function listAvailableTemplates() {
169
+ const templatesDir = path.join(__dirname, '..', 'templates', 'applications');
170
+
171
+ if (!fsSync.existsSync(templatesDir)) {
172
+ return [];
173
+ }
174
+
175
+ const entries = await fs.readdir(templatesDir);
176
+ const templates = [];
177
+
178
+ for (const entry of entries) {
179
+ const entryPath = path.join(templatesDir, entry);
180
+ const stats = fsSync.statSync(entryPath);
181
+
182
+ if (stats.isDirectory()) {
183
+ // Check if directory contains at least one file
184
+ const subEntries = await fs.readdir(entryPath);
185
+ const hasFiles = subEntries.some(subEntry => {
186
+ const subPath = path.join(entryPath, subEntry);
187
+ const subStats = fsSync.statSync(subPath);
188
+ return subStats.isFile() && !subEntry.startsWith('.');
189
+ });
190
+
191
+ if (hasFiles) {
192
+ templates.push(entry);
193
+ }
194
+ }
195
+ }
196
+
197
+ return templates.sort();
198
+ }
199
+
200
+ module.exports = {
201
+ validateTemplate,
202
+ copyTemplateFiles,
203
+ copyAppFiles,
204
+ listAvailableTemplates
205
+ };