bmad-cybersec 2.0.0

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.
@@ -0,0 +1,297 @@
1
+ import { createWriteStream } from 'fs';
2
+ import { pipeline } from 'stream/promises';
3
+ import { createHash } from 'crypto';
4
+ import { tmpdir } from 'os';
5
+ import { join } from 'path';
6
+ import { mkdir, rm, readFile } from 'fs/promises';
7
+ import ora from 'ora';
8
+ import { CONFIG } from './config.js';
9
+ import { logger } from './logger.js';
10
+
11
+ const GITHUB_API = 'https://api.github.com';
12
+
13
+ /**
14
+ * Allowed URL hosts for SSRF protection
15
+ * Only URLs from these domains are permitted for downloads
16
+ */
17
+ const ALLOWED_HOSTS = [
18
+ 'api.github.com',
19
+ 'github.com',
20
+ 'objects.githubusercontent.com',
21
+ 'github-releases.githubusercontent.com',
22
+ 'codeload.github.com'
23
+ ];
24
+
25
+ /**
26
+ * Validates a URL against the allowed hosts to prevent SSRF attacks
27
+ * @param {string} urlString - The URL to validate
28
+ * @returns {{ valid: boolean, error?: string }}
29
+ */
30
+ function validateDownloadUrl(urlString) {
31
+ try {
32
+ const url = new URL(urlString);
33
+
34
+ // Must be HTTPS
35
+ if (url.protocol !== 'https:') {
36
+ return {
37
+ valid: false,
38
+ error: `Only HTTPS URLs are allowed. Received: ${url.protocol}`
39
+ };
40
+ }
41
+
42
+ // Must be from allowed host
43
+ const hostname = url.hostname.toLowerCase();
44
+ const isAllowed = ALLOWED_HOSTS.some(allowed =>
45
+ hostname === allowed || hostname.endsWith('.' + allowed)
46
+ );
47
+
48
+ if (!isAllowed) {
49
+ return {
50
+ valid: false,
51
+ error: `URL host "${hostname}" is not in the allowed list: ${ALLOWED_HOSTS.join(', ')}`
52
+ };
53
+ }
54
+
55
+ return { valid: true };
56
+ } catch {
57
+ return {
58
+ valid: false,
59
+ error: `Invalid URL format: ${urlString}`
60
+ };
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Validates and fetches from a URL with SSRF protection
66
+ * @param {string} url - URL to fetch
67
+ * @param {Object} options - Fetch options
68
+ * @returns {Promise<Response>}
69
+ * @throws {Error} If URL validation fails
70
+ */
71
+ async function safeFetch(url, options = {}) {
72
+ const validation = validateDownloadUrl(url);
73
+ if (!validation.valid) {
74
+ throw new Error(`Security: ${validation.error}`);
75
+ }
76
+ return fetch(url, options);
77
+ }
78
+
79
+ /**
80
+ * Downloads a release tarball from GitHub
81
+ * @description Fetches release information from GitHub API, downloads the tarball asset,
82
+ * and optionally verifies the checksum. Falls back to source tarball if no release asset found.
83
+ * @param {Object} [options={}] - Download options
84
+ * @param {string} [options.version='latest'] - Release version tag (e.g., 'v2.0.0' or 'latest')
85
+ * @param {string|null} [options.branch=null] - Branch name for development builds (currently unused)
86
+ * @returns {Promise<string>} Path to the downloaded tarball in the temp directory
87
+ * @throws {Error} If release not found, download fails, or checksum verification fails
88
+ * @example
89
+ * // Download latest release
90
+ * const tarballPath = await downloadRelease();
91
+ *
92
+ * @example
93
+ * // Download specific version
94
+ * const tarballPath = await downloadRelease({ version: 'v2.0.0' });
95
+ */
96
+ export async function downloadRelease(options = {}) {
97
+ const { version = 'latest', branch = null } = options;
98
+ const spinner = ora();
99
+
100
+ try {
101
+ // 1. Get release info from GitHub API
102
+ spinner.start('Fetching release information...');
103
+ const release = await fetchReleaseInfo(version);
104
+ spinner.succeed(`Found release: ${release.tag_name}`);
105
+
106
+ // 2. Find tarball and checksum assets
107
+ const tarballAsset = release.assets.find(a => a.name.endsWith('.tar.gz'));
108
+ const checksumAsset = release.assets.find(a => a.name.endsWith('.sha256'));
109
+
110
+ if (!tarballAsset) {
111
+ // Fallback to source tarball
112
+ logger.info('No release tarball found, using source archive');
113
+ return await downloadSourceTarball(release.tarball_url, release.tag_name);
114
+ }
115
+
116
+ // 3. Download with progress
117
+ spinner.start(`Downloading ${tarballAsset.name}...`);
118
+ const tarballPath = await downloadWithProgress(
119
+ tarballAsset.browser_download_url,
120
+ tarballAsset.name,
121
+ tarballAsset.size
122
+ );
123
+ spinner.succeed('Download complete');
124
+
125
+ // 4. Verify checksum - MANDATORY for release assets
126
+ if (checksumAsset) {
127
+ spinner.start('Verifying checksum...');
128
+ await verifyChecksum(tarballPath, checksumAsset.browser_download_url);
129
+ spinner.succeed('Checksum verified');
130
+ } else {
131
+ // Security: Checksum verification is MANDATORY for release tarballs
132
+ spinner.fail('Security: No checksum file found');
133
+ await rm(tarballPath, { force: true });
134
+ throw new Error(
135
+ 'Security: Checksum verification is mandatory for release downloads. ' +
136
+ 'The release is missing a .sha256 checksum file. ' +
137
+ 'Use --from-git to clone directly if this is intentional.'
138
+ );
139
+ }
140
+
141
+ return tarballPath;
142
+
143
+ } catch (error) {
144
+ spinner.fail(`Download failed: ${error.message}`);
145
+ throw error;
146
+ }
147
+ }
148
+
149
+ async function fetchReleaseInfo(version) {
150
+ const endpoint = version === 'latest'
151
+ ? `${GITHUB_API}/repos/${CONFIG.GITHUB_OWNER}/${CONFIG.GITHUB_REPO}/releases/latest`
152
+ : `${GITHUB_API}/repos/${CONFIG.GITHUB_OWNER}/${CONFIG.GITHUB_REPO}/releases/tags/${version}`;
153
+
154
+ const headers = {
155
+ 'Accept': 'application/vnd.github.v3+json',
156
+ 'User-Agent': 'bmad-cyber-installer'
157
+ };
158
+
159
+ // Add auth token if available
160
+ if (process.env.GITHUB_TOKEN) {
161
+ headers['Authorization'] = `token ${process.env.GITHUB_TOKEN}`;
162
+ }
163
+
164
+ const response = await fetchWithRetry(endpoint, { headers });
165
+
166
+ if (!response.ok) {
167
+ if (response.status === 404) {
168
+ throw new Error(`Release ${version} not found`);
169
+ }
170
+ if (response.status === 403) {
171
+ throw new Error('GitHub API rate limit exceeded. Set GITHUB_TOKEN or try again later.');
172
+ }
173
+ throw new Error(`GitHub API error: ${response.status}`);
174
+ }
175
+
176
+ return response.json();
177
+ }
178
+
179
+ async function downloadSourceTarball(url, tagName) {
180
+ const tempDir = join(tmpdir(), CONFIG.TEMP_DIR_PREFIX);
181
+ await mkdir(tempDir, { recursive: true });
182
+
183
+ const filename = `${tagName}.tar.gz`;
184
+ const filePath = join(tempDir, filename);
185
+
186
+ const headers = {
187
+ 'User-Agent': 'bmad-cyber-installer'
188
+ };
189
+
190
+ if (process.env.GITHUB_TOKEN) {
191
+ headers['Authorization'] = `token ${process.env.GITHUB_TOKEN}`;
192
+ }
193
+
194
+ const response = await fetchWithRetry(url, { headers });
195
+
196
+ if (!response.ok) {
197
+ throw new Error(`Download failed: ${response.status}`);
198
+ }
199
+
200
+ const fileStream = createWriteStream(filePath);
201
+ await pipeline(response.body, fileStream);
202
+
203
+ return filePath;
204
+ }
205
+
206
+ async function downloadWithProgress(url, filename, expectedSize) {
207
+ const tempDir = join(tmpdir(), CONFIG.TEMP_DIR_PREFIX);
208
+ await mkdir(tempDir, { recursive: true });
209
+
210
+ const filePath = join(tempDir, filename);
211
+
212
+ const headers = {
213
+ 'User-Agent': 'bmad-cyber-installer'
214
+ };
215
+
216
+ if (process.env.GITHUB_TOKEN) {
217
+ headers['Authorization'] = `token ${process.env.GITHUB_TOKEN}`;
218
+ }
219
+
220
+ const response = await fetchWithRetry(url, { headers });
221
+
222
+ if (!response.ok) {
223
+ throw new Error(`Download failed: ${response.status}`);
224
+ }
225
+
226
+ const fileStream = createWriteStream(filePath);
227
+ await pipeline(response.body, fileStream);
228
+
229
+ return filePath;
230
+ }
231
+
232
+ async function verifyChecksum(filePath, checksumUrl) {
233
+ const headers = {
234
+ 'User-Agent': 'bmad-cyber-installer'
235
+ };
236
+
237
+ if (process.env.GITHUB_TOKEN) {
238
+ headers['Authorization'] = `token ${process.env.GITHUB_TOKEN}`;
239
+ }
240
+
241
+ // Download checksum file (with SSRF protection)
242
+ const response = await safeFetch(checksumUrl, { headers });
243
+ if (!response.ok) {
244
+ throw new Error(`Failed to download checksum file: ${response.status}`);
245
+ }
246
+ const checksumContent = await response.text();
247
+ const expectedHash = checksumContent.split(' ')[0].trim();
248
+
249
+ // Validate hash format (should be 64 hex characters for SHA256)
250
+ if (!/^[a-fA-F0-9]{64}$/.test(expectedHash)) {
251
+ throw new Error('Invalid checksum format in checksum file');
252
+ }
253
+
254
+ // Calculate actual hash
255
+ const fileBuffer = await readFile(filePath);
256
+ const hash = createHash('sha256').update(fileBuffer).digest('hex');
257
+
258
+ if (hash.toLowerCase() !== expectedHash.toLowerCase()) {
259
+ await rm(filePath);
260
+ throw new Error('Checksum verification failed. File may be corrupted or tampered with.');
261
+ }
262
+ }
263
+
264
+ async function fetchWithRetry(url, options = {}, retries = 3) {
265
+ for (let attempt = 1; attempt <= retries; attempt++) {
266
+ try {
267
+ // Use safeFetch for SSRF protection
268
+ return await safeFetch(url, options);
269
+ } catch (error) {
270
+ // Don't retry security validation errors
271
+ if (error.message.startsWith('Security:')) {
272
+ throw error;
273
+ }
274
+ if (attempt === retries) throw error;
275
+ const delay = Math.pow(2, attempt) * 1000; // Exponential backoff
276
+ logger.info(`Retry ${attempt}/${retries} after ${delay}ms...`);
277
+ await new Promise(r => setTimeout(r, delay));
278
+ }
279
+ }
280
+ }
281
+
282
+ /**
283
+ * Cleans up temporary download files
284
+ * @description Removes the temporary directory used for storing downloaded tarballs.
285
+ * Silently ignores any cleanup errors.
286
+ * @returns {Promise<void>}
287
+ * @example
288
+ * await cleanup();
289
+ */
290
+ export async function cleanup() {
291
+ const tempDir = join(tmpdir(), CONFIG.TEMP_DIR_PREFIX);
292
+ try {
293
+ await rm(tempDir, { recursive: true, force: true });
294
+ } catch {
295
+ // Ignore cleanup errors
296
+ }
297
+ }
@@ -0,0 +1,353 @@
1
+ import tar from 'tar';
2
+ import { existsSync, promises as fs, realpathSync } from 'fs';
3
+ import { join, dirname, basename, resolve, relative, normalize } from 'path';
4
+ import inquirer from 'inquirer';
5
+ import ora from 'ora';
6
+ import { logger } from './logger.js';
7
+
8
+ /**
9
+ * Validates that a path does not escape the target directory (zip-slip protection)
10
+ * @param {string} targetDir - The target extraction directory (absolute path)
11
+ * @param {string} entryPath - The path from the archive entry
12
+ * @returns {{ safe: boolean, resolvedPath?: string, error?: string }}
13
+ */
14
+ function validatePathSafety(targetDir, entryPath) {
15
+ // Normalize and resolve the full path
16
+ const normalizedEntry = normalize(entryPath).replace(/\\/g, '/');
17
+
18
+ // Reject paths with suspicious patterns BEFORE resolution
19
+ if (normalizedEntry.includes('..') ||
20
+ normalizedEntry.startsWith('/') ||
21
+ normalizedEntry.includes('//')) {
22
+ return {
23
+ safe: false,
24
+ error: `Path traversal detected: "${entryPath}"`
25
+ };
26
+ }
27
+
28
+ // Resolve to absolute path
29
+ const resolvedPath = resolve(targetDir, normalizedEntry);
30
+
31
+ // Ensure the resolved path is within the target directory
32
+ const relativePath = relative(targetDir, resolvedPath);
33
+ if (relativePath.startsWith('..') || resolve(targetDir, relativePath) !== resolvedPath) {
34
+ return {
35
+ safe: false,
36
+ error: `Path escapes target directory: "${entryPath}" resolves to "${resolvedPath}"`
37
+ };
38
+ }
39
+
40
+ return { safe: true, resolvedPath };
41
+ }
42
+
43
+ /**
44
+ * Validates that a symlink target stays within the extraction directory
45
+ * @param {string} targetDir - The target extraction directory
46
+ * @param {string} linkPath - The path where the symlink will be created
47
+ * @param {string} linkTarget - The target of the symlink
48
+ * @returns {{ safe: boolean, error?: string }}
49
+ */
50
+ function validateSymlinkSafety(targetDir, linkPath, linkTarget) {
51
+ // Resolve the symlink target relative to the link's directory
52
+ const linkDir = dirname(linkPath);
53
+ const resolvedTarget = resolve(linkDir, linkTarget);
54
+
55
+ // Ensure the target stays within the extraction directory
56
+ const relativePath = relative(targetDir, resolvedTarget);
57
+ if (relativePath.startsWith('..')) {
58
+ return {
59
+ safe: false,
60
+ error: `Symlink "${linkPath}" points outside target directory to "${linkTarget}"`
61
+ };
62
+ }
63
+
64
+ return { safe: true };
65
+ }
66
+
67
+ const ALWAYS_SKIP = [
68
+ '.git/',
69
+ '.github/',
70
+ 'node_modules/',
71
+ '*.test.js',
72
+ '*.test.ts',
73
+ '*.spec.js',
74
+ '*.spec.ts',
75
+ 'coverage/',
76
+ '.nyc_output/',
77
+ '__tests__/'
78
+ ];
79
+
80
+ const PRIORITY_FILES = [
81
+ '_bmad/',
82
+ '.claude/',
83
+ 'src/utility/tools/',
84
+ 'CLAUDE.md'
85
+ ];
86
+
87
+ const OPTIONAL_FILES = {
88
+ withDocs: ['Docs/'],
89
+ withDev: ['dev-tools/']
90
+ };
91
+
92
+ /**
93
+ * Extracts framework files from a tarball to the target directory
94
+ * @description Extracts files from a downloaded tarball, filtering out unnecessary files
95
+ * (tests, node_modules, etc.) and optionally handling file conflicts with existing files.
96
+ * @param {string} tarballPath - Path to the tarball file to extract
97
+ * @param {string} targetDir - Target directory to extract files into
98
+ * @param {Object} [options={}] - Extraction options
99
+ * @param {boolean} [options.overwrite=false] - Whether to overwrite existing files without prompting
100
+ * @param {boolean} [options.force=false] - Force extraction without conflict checking
101
+ * @param {boolean} [options.withDocs=false] - Include documentation files in extraction
102
+ * @param {boolean} [options.withDev=false] - Include development tools in extraction
103
+ * @param {boolean} [options.dryRun=false] - List files without extracting
104
+ * @returns {Promise<Object>} Extraction result object
105
+ * @returns {boolean} [returns.success] - True if extraction completed successfully
106
+ * @returns {boolean} [returns.cancelled] - True if user cancelled the operation
107
+ * @returns {boolean} [returns.dryRun] - True if this was a dry run
108
+ * @returns {number} returns.filesExtracted - Number of files extracted (0 for dry run or cancel)
109
+ * @returns {string[]} [returns.files] - List of files (only in dry run mode)
110
+ * @throws {Error} If tarball extraction fails
111
+ * @example
112
+ * // Basic extraction
113
+ * const result = await extractFramework('./release.tar.gz', './my-project');
114
+ *
115
+ * @example
116
+ * // Dry run to preview files
117
+ * const result = await extractFramework('./release.tar.gz', './my-project', { dryRun: true });
118
+ * console.log(result.files);
119
+ */
120
+ export async function extractFramework(tarballPath, targetDir, options = {}) {
121
+ const {
122
+ overwrite = false,
123
+ force = false,
124
+ withDocs = false,
125
+ withDev = false,
126
+ dryRun = false
127
+ } = options;
128
+
129
+ const spinner = ora();
130
+
131
+ // 1. Build file filter
132
+ const filter = buildFilter({ withDocs, withDev });
133
+
134
+ // 2. If not force, check for conflicts
135
+ if (!force && !dryRun) {
136
+ spinner.start('Checking for existing files...');
137
+ const conflicts = await findConflicts(tarballPath, targetDir, filter);
138
+ spinner.stop();
139
+
140
+ if (conflicts.length > 0) {
141
+ const action = await promptOverwrite(conflicts);
142
+ if (action === 'cancel') {
143
+ return { cancelled: true, filesExtracted: 0 };
144
+ }
145
+ if (action === 'skip') {
146
+ // Add conflicts to skip list
147
+ filter.skipFiles = conflicts;
148
+ }
149
+ }
150
+ }
151
+
152
+ // 3. Dry run - just list files
153
+ if (dryRun) {
154
+ spinner.start('Analyzing tarball contents...');
155
+ const files = await listTarballContents(tarballPath, filter);
156
+ spinner.stop();
157
+
158
+ logger.info('\nFiles that would be extracted:');
159
+ files.forEach(f => logger.info(` ${f}`));
160
+ logger.info(`\nTotal: ${files.length} files`);
161
+
162
+ return { dryRun: true, files, filesExtracted: 0 };
163
+ }
164
+
165
+ // 4. Extract with security validations
166
+ spinner.start('Extracting framework files...');
167
+ let fileCount = 0;
168
+ const securityViolations = [];
169
+ const absoluteTargetDir = resolve(targetDir);
170
+
171
+ await tar.extract({
172
+ file: tarballPath,
173
+ cwd: targetDir,
174
+ strip: 1, // Remove top-level directory
175
+ filter: (path, entry) => {
176
+ // First check normal filtering
177
+ if (!shouldExtract(path, filter)) {
178
+ return false;
179
+ }
180
+
181
+ // Security: Validate path does not escape target directory (zip-slip protection)
182
+ // After strip:1, we need to check the resulting path
183
+ const parts = path.split('/');
184
+ parts.shift(); // Remove top-level directory (strip: 1)
185
+ const strippedPath = parts.join('/');
186
+
187
+ if (strippedPath) {
188
+ const pathValidation = validatePathSafety(absoluteTargetDir, strippedPath);
189
+ if (!pathValidation.safe) {
190
+ securityViolations.push(pathValidation.error);
191
+ logger.warn(`Security: Skipping unsafe path - ${pathValidation.error}`);
192
+ return false;
193
+ }
194
+
195
+ // Security: Validate symlinks don't point outside target directory
196
+ if (entry.type === 'SymbolicLink' && entry.linkpath) {
197
+ const symlinkValidation = validateSymlinkSafety(
198
+ absoluteTargetDir,
199
+ pathValidation.resolvedPath,
200
+ entry.linkpath
201
+ );
202
+ if (!symlinkValidation.safe) {
203
+ securityViolations.push(symlinkValidation.error);
204
+ logger.warn(`Security: Skipping unsafe symlink - ${symlinkValidation.error}`);
205
+ return false;
206
+ }
207
+ }
208
+ }
209
+
210
+ fileCount++;
211
+ return true;
212
+ },
213
+ onentry: (entry) => {
214
+ // Preserve permissions
215
+ if (entry.mode) {
216
+ entry.mode = entry.mode;
217
+ }
218
+ }
219
+ });
220
+
221
+ if (securityViolations.length > 0) {
222
+ logger.warn(`\nSecurity: ${securityViolations.length} potentially malicious entries were blocked during extraction.`);
223
+ }
224
+
225
+ spinner.succeed(`Extracted ${fileCount} files`);
226
+
227
+ return { success: true, filesExtracted: fileCount };
228
+ }
229
+
230
+ function buildFilter({ withDocs, withDev }) {
231
+ const skip = [...ALWAYS_SKIP];
232
+ const include = [...PRIORITY_FILES];
233
+
234
+ if (withDocs) {
235
+ include.push(...OPTIONAL_FILES.withDocs);
236
+ } else {
237
+ skip.push('Docs/');
238
+ }
239
+
240
+ if (withDev) {
241
+ include.push(...OPTIONAL_FILES.withDev);
242
+ } else {
243
+ skip.push('dev-tools/');
244
+ }
245
+
246
+ return { skip, include, skipFiles: [] };
247
+ }
248
+
249
+ function shouldExtract(path, filter) {
250
+ // Normalize path
251
+ const normalizedPath = path.replace(/\\/g, '/');
252
+
253
+ // Check explicit skip files
254
+ if (filter.skipFiles.includes(normalizedPath)) {
255
+ return false;
256
+ }
257
+
258
+ // Check always-skip patterns
259
+ for (const pattern of filter.skip) {
260
+ if (pattern.endsWith('/')) {
261
+ if (normalizedPath.includes(pattern) || normalizedPath.startsWith(pattern)) {
262
+ return false;
263
+ }
264
+ } else if (pattern.startsWith('*')) {
265
+ const ext = pattern.slice(1);
266
+ if (normalizedPath.endsWith(ext)) {
267
+ return false;
268
+ }
269
+ } else if (normalizedPath === pattern || normalizedPath.includes(`/${pattern}`)) {
270
+ return false;
271
+ }
272
+ }
273
+
274
+ return true;
275
+ }
276
+
277
+ async function findConflicts(tarballPath, targetDir, filter) {
278
+ const conflicts = [];
279
+
280
+ // List tarball contents
281
+ const entries = [];
282
+ await tar.list({
283
+ file: tarballPath,
284
+ onentry: (entry) => {
285
+ if (entry.type === 'File' && shouldExtract(entry.path, filter)) {
286
+ // Remove top-level directory from path
287
+ const parts = entry.path.split('/');
288
+ parts.shift();
289
+ const relativePath = parts.join('/');
290
+ if (relativePath) {
291
+ entries.push(relativePath);
292
+ }
293
+ }
294
+ }
295
+ });
296
+
297
+ // Check for existing files
298
+ for (const entry of entries) {
299
+ const fullPath = join(targetDir, entry);
300
+ if (existsSync(fullPath)) {
301
+ conflicts.push(entry);
302
+ }
303
+ }
304
+
305
+ return conflicts;
306
+ }
307
+
308
+ async function promptOverwrite(conflicts) {
309
+ console.log('\n');
310
+ logger.warn(`Found ${conflicts.length} existing files that would be overwritten:`);
311
+
312
+ // Show first 10 conflicts
313
+ const shown = conflicts.slice(0, 10);
314
+ shown.forEach(f => logger.info(` - ${f}`));
315
+ if (conflicts.length > 10) {
316
+ logger.info(` ... and ${conflicts.length - 10} more`);
317
+ }
318
+
319
+ const { action } = await inquirer.prompt([
320
+ {
321
+ type: 'list',
322
+ name: 'action',
323
+ message: 'How would you like to handle existing files?',
324
+ choices: [
325
+ { name: 'Overwrite all', value: 'overwrite' },
326
+ { name: 'Skip existing files', value: 'skip' },
327
+ { name: 'Cancel installation', value: 'cancel' }
328
+ ]
329
+ }
330
+ ]);
331
+
332
+ return action;
333
+ }
334
+
335
+ async function listTarballContents(tarballPath, filter) {
336
+ const files = [];
337
+
338
+ await tar.list({
339
+ file: tarballPath,
340
+ onentry: (entry) => {
341
+ if (entry.type === 'File' && shouldExtract(entry.path, filter)) {
342
+ const parts = entry.path.split('/');
343
+ parts.shift();
344
+ const relativePath = parts.join('/');
345
+ if (relativePath) {
346
+ files.push(relativePath);
347
+ }
348
+ }
349
+ }
350
+ });
351
+
352
+ return files.sort();
353
+ }