@scrymore/scry-deployer 0.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.
@@ -0,0 +1,445 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+
4
+ // Directories to exclude from story file search
5
+ const EXCLUDED_DIRS = [
6
+ 'node_modules',
7
+ '.git',
8
+ 'dist',
9
+ 'build',
10
+ 'coverage',
11
+ '.next',
12
+ 'out',
13
+ '__screenshots__',
14
+ '.storybook',
15
+ 'public',
16
+ 'static'
17
+ ];
18
+
19
+ /**
20
+ * Checks if a file matches the story file pattern (.stories.*)
21
+ * @param {string} filename - Filename to check
22
+ * @returns {boolean} True if file is a story file
23
+ */
24
+ function isStoryFile(filename) {
25
+ return /\.stories\.(ts|tsx|js|jsx|mjs|cjs)$/i.test(filename);
26
+ }
27
+
28
+ /**
29
+ * Auto-detects the stories directory by searching for .stories.* files
30
+ * @param {string} searchRoot - Root directory to search from (default: current directory)
31
+ * @param {number} maxDepth - Maximum depth to search (default: 5)
32
+ * @returns {string|null} Path to detected stories directory or null
33
+ */
34
+ function autoDetectStoriesDir(searchRoot = '.', maxDepth = 5) {
35
+ const storyFiles = [];
36
+
37
+ function searchDir(dir, depth = 0) {
38
+ if (depth > maxDepth) return;
39
+
40
+ try {
41
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
42
+
43
+ for (const entry of entries) {
44
+ const fullPath = path.join(dir, entry.name);
45
+
46
+ if (entry.isDirectory()) {
47
+ // Skip excluded directories
48
+ if (!EXCLUDED_DIRS.includes(entry.name)) {
49
+ searchDir(fullPath, depth + 1);
50
+ }
51
+ } else if (entry.isFile() && isStoryFile(entry.name)) {
52
+ storyFiles.push(fullPath);
53
+ }
54
+ }
55
+ } catch (error) {
56
+ // Skip directories we can't read
57
+ }
58
+ }
59
+
60
+ searchDir(searchRoot);
61
+
62
+ if (storyFiles.length === 0) return null;
63
+
64
+ // Find common parent directory of all story files
65
+ const dirs = storyFiles.map(file => path.dirname(file));
66
+ const uniqueDirs = [...new Set(dirs)];
67
+
68
+ if (uniqueDirs.length === 1) {
69
+ return uniqueDirs[0];
70
+ }
71
+
72
+ // Find the most common parent directory
73
+ const commonPath = uniqueDirs.reduce((acc, dir) => {
74
+ if (!acc) return dir;
75
+ const accParts = acc.split(path.sep);
76
+ const dirParts = dir.split(path.sep);
77
+ const common = [];
78
+ for (let i = 0; i < Math.min(accParts.length, dirParts.length); i++) {
79
+ if (accParts[i] === dirParts[i]) {
80
+ common.push(accParts[i]);
81
+ } else {
82
+ break;
83
+ }
84
+ }
85
+ return common.join(path.sep) || '.';
86
+ });
87
+
88
+ return commonPath || '.';
89
+ }
90
+
91
+ /**
92
+ * Recursively finds all story files matching .stories.* pattern
93
+ * @param {string} dir - Directory to search
94
+ * @param {string[]} fileList - Accumulator for found files
95
+ * @returns {string[]} Array of story file paths
96
+ */
97
+ function findStoryFiles(dir, fileList = []) {
98
+ // If dir is null/undefined, try auto-detection
99
+ if (!dir || !fs.existsSync(dir)) {
100
+ console.log(`📂 Auto-detecting stories directory...`);
101
+ dir = autoDetectStoriesDir();
102
+ if (!dir) {
103
+ console.warn(`⚠️ No story files found in project`);
104
+ return [];
105
+ }
106
+ console.log(`✅ Found stories in: ${dir}`);
107
+ }
108
+
109
+ const files = fs.readdirSync(dir);
110
+
111
+ files.forEach(file => {
112
+ const filePath = path.join(dir, file);
113
+ const stat = fs.statSync(filePath);
114
+
115
+ if (stat.isDirectory()) {
116
+ // Skip excluded directories
117
+ if (!EXCLUDED_DIRS.includes(file)) {
118
+ findStoryFiles(filePath, fileList);
119
+ }
120
+ } else if (isStoryFile(file)) {
121
+ fileList.push(filePath);
122
+ }
123
+ });
124
+
125
+ return fileList;
126
+ }
127
+
128
+ /**
129
+ * Extracts component name from import statements or meta object
130
+ * @param {string} content - File content
131
+ * @returns {string|null} Component name
132
+ */
133
+ function extractComponentName(content) {
134
+ // Try to find component from import statement
135
+ const importMatch = content.match(/import\s+{\s*([^}]+)\s*}\s+from\s+['"]\.\/([^'"]+)['"]/);
136
+ if (importMatch) {
137
+ const importedNames = importMatch[1].split(',').map(name => name.trim());
138
+ // Find the component (usually the one that starts with uppercase)
139
+ const componentName = importedNames.find(name => /^[A-Z]/.test(name));
140
+ if (componentName) return componentName;
141
+ }
142
+
143
+ // Try to find component from meta.component
144
+ const metaMatch = content.match(/component:\s*([A-Za-z_$][A-Za-z0-9_$]*)/);
145
+ if (metaMatch) {
146
+ return metaMatch[1];
147
+ }
148
+
149
+ return null;
150
+ }
151
+
152
+ /**
153
+ * Extracts the title from story file content
154
+ * @param {string} content - File content
155
+ * @returns {string|null} The title from the meta object
156
+ */
157
+ function extractStoryTitle(content) {
158
+ const titleMatch = content.match(/title:\s*['"]([^'"]+)['"]/);
159
+ return titleMatch ? titleMatch[1] : null;
160
+ }
161
+
162
+ /**
163
+ * Extracts story exports and their line numbers from file content
164
+ * @param {string} content - File content
165
+ * @returns {Array} Array of story objects with name and location
166
+ */
167
+ function extractStories(content) {
168
+ const lines = content.split('\n');
169
+ const stories = [];
170
+
171
+ // Look for export statements that define stories
172
+ lines.forEach((line, index) => {
173
+ const lineNumber = index + 1;
174
+
175
+ // Match export const/let/var StoryName: Story = {
176
+ const exportMatch = line.match(/^export\s+(?:const|let|var)\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*:\s*Story\s*=/);
177
+ if (exportMatch) {
178
+ const storyName = exportMatch[1];
179
+
180
+ // Find the end of this story object
181
+ let endLine = lineNumber;
182
+ let braceCount = 0;
183
+ let inStory = false;
184
+
185
+ for (let i = index; i < lines.length; i++) {
186
+ const currentLine = lines[i];
187
+
188
+ // Count opening and closing braces
189
+ for (const char of currentLine) {
190
+ if (char === '{') {
191
+ braceCount++;
192
+ inStory = true;
193
+ } else if (char === '}') {
194
+ braceCount--;
195
+ }
196
+ }
197
+
198
+ // If we've closed all braces and we were in a story, we found the end
199
+ if (inStory && braceCount === 0) {
200
+ endLine = i + 1;
201
+ break;
202
+ }
203
+ }
204
+
205
+ stories.push({
206
+ testName: storyName,
207
+ location: {
208
+ startLine: lineNumber,
209
+ endLine: endLine
210
+ }
211
+ });
212
+ }
213
+ });
214
+
215
+ return stories;
216
+ }
217
+
218
+ /**
219
+ * Processes a single story file and extracts information
220
+ * @param {string} filePath - Path to the story file
221
+ * @returns {Object|null} Story file information
222
+ */
223
+ function processStoryFile(filePath) {
224
+ try {
225
+ const content = fs.readFileSync(filePath, 'utf8');
226
+ const componentName = extractComponentName(content);
227
+ const storyTitle = extractStoryTitle(content);
228
+ const stories = extractStories(content);
229
+
230
+ if (!componentName || stories.length === 0) {
231
+ return null;
232
+ }
233
+
234
+ return {
235
+ filepath: filePath,
236
+ componentName: componentName,
237
+ storyTitle: storyTitle,
238
+ stories: stories
239
+ };
240
+ } catch (error) {
241
+ console.error(`Error processing file ${filePath}:`, error.message);
242
+ return null;
243
+ }
244
+ }
245
+
246
+ /**
247
+ * Main function to crawl all story files and return results
248
+ * @param {string} storiesDir - Directory containing stories (optional, will auto-detect if not provided)
249
+ * @returns {Array} Array of story file information
250
+ */
251
+ function crawlStories(storiesDir = null) {
252
+ let storyFiles;
253
+
254
+ // If no directory provided or directory doesn't exist, try auto-detection
255
+ if (!storiesDir || !fs.existsSync(storiesDir)) {
256
+ if (storiesDir && !fs.existsSync(storiesDir)) {
257
+ console.warn(`⚠️ Specified directory '${storiesDir}' not found. Attempting auto-detection...`);
258
+ }
259
+ storyFiles = findStoryFiles(null);
260
+ } else {
261
+ storyFiles = findStoryFiles(storiesDir);
262
+ }
263
+
264
+ if (storyFiles.length === 0) {
265
+ console.warn(`⚠️ No story files found`);
266
+ return [];
267
+ }
268
+
269
+ console.log(`📚 Found ${storyFiles.length} story file(s)`);
270
+
271
+ const results = [];
272
+
273
+ storyFiles.forEach(filePath => {
274
+ const fileInfo = processStoryFile(filePath);
275
+ if (fileInfo) {
276
+ // Flatten the structure to match the requested format
277
+ fileInfo.stories.forEach(story => {
278
+ results.push({
279
+ filepath: fileInfo.filepath,
280
+ componentName: fileInfo.componentName,
281
+ storyTitle: fileInfo.storyTitle,
282
+ testName: story.testName,
283
+ location: story.location
284
+ });
285
+ });
286
+ }
287
+ });
288
+
289
+ return results;
290
+ }
291
+
292
+ /**
293
+ * Converts camelCase story names to space-separated screenshot names
294
+ * @param {string} storyName - The camelCase story name
295
+ * @returns {string} Space-separated name for screenshot
296
+ */
297
+ function convertStoryNameToScreenshotName(storyName) {
298
+ // Convert camelCase to space-separated (e.g., "LoggedIn" -> "Logged In")
299
+ return storyName.replace(/([a-z])([A-Z])/g, '$1 $2');
300
+ }
301
+
302
+ /**
303
+ * Recursively finds all screenshot files in the __screenshots__ directory
304
+ * @param {string} screenshotsDir - Path to screenshots directory
305
+ * @param {string[]} fileList - Accumulator for found files
306
+ * @returns {string[]} Array of screenshot file paths
307
+ */
308
+ function findScreenshotFiles(screenshotsDir, fileList = []) {
309
+ if (!fs.existsSync(screenshotsDir)) {
310
+ return fileList;
311
+ }
312
+
313
+ const files = fs.readdirSync(screenshotsDir);
314
+
315
+ files.forEach(file => {
316
+ const filePath = path.join(screenshotsDir, file);
317
+ const stat = fs.statSync(filePath);
318
+
319
+ if (stat.isDirectory()) {
320
+ findScreenshotFiles(filePath, fileList);
321
+ } else if (file.endsWith('.png') || file.endsWith('.jpg') || file.endsWith('.jpeg')) {
322
+ fileList.push(filePath);
323
+ }
324
+ });
325
+
326
+ return fileList;
327
+ }
328
+
329
+ /**
330
+ * Maps a story entry to its corresponding screenshot
331
+ * @param {Object} storyEntry - Story entry from crawl-stories
332
+ * @param {string} storyTitle - The title from the story meta object
333
+ * @param {string[]} screenshotFiles - Array of all screenshot file paths
334
+ * @returns {string|null} Path to the corresponding screenshot
335
+ */
336
+ function findMatchingScreenshot(storyEntry, storyTitle, screenshotFiles) {
337
+ const screenshotName = convertStoryNameToScreenshotName(storyEntry.testName);
338
+
339
+ // Build expected screenshot path
340
+ const expectedPath = path.join('__screenshots__', storyTitle, `${screenshotName}.png`);
341
+
342
+ // Try to find exact match
343
+ let matchingScreenshot = screenshotFiles.find(filePath =>
344
+ path.normalize(filePath) === path.normalize(expectedPath)
345
+ );
346
+
347
+ // If no exact match, try case-insensitive search
348
+ if (!matchingScreenshot) {
349
+ matchingScreenshot = screenshotFiles.find(filePath => {
350
+ const normalizedPath = path.normalize(filePath).toLowerCase();
351
+ const normalizedExpected = path.normalize(expectedPath).toLowerCase();
352
+ return normalizedPath === normalizedExpected;
353
+ });
354
+ }
355
+
356
+ // If still no match, try partial matching on filename only
357
+ if (!matchingScreenshot) {
358
+ const screenshotFileName = `${screenshotName}.png`.toLowerCase();
359
+ matchingScreenshot = screenshotFiles.find(filePath => {
360
+ const fileName = path.basename(filePath).toLowerCase();
361
+ return fileName === screenshotFileName;
362
+ });
363
+ }
364
+
365
+ return matchingScreenshot || null;
366
+ }
367
+
368
+ /**
369
+ * Main function to map stories to their screenshots
370
+ * @param {string} storiesDir - Directory containing stories (optional, will auto-detect if not provided)
371
+ * @param {string} screenshotsDir - Directory containing screenshots
372
+ * @returns {Array} Array of story entries with screenshot paths
373
+ */
374
+ function mapStoriesToScreenshots(storiesDir = null, screenshotsDir = './__screenshots__') {
375
+ // Get all story entries (will auto-detect if storiesDir is null)
376
+ const storyEntries = crawlStories(storiesDir);
377
+
378
+ // Get all screenshot files
379
+ const screenshotFiles = findScreenshotFiles(screenshotsDir);
380
+
381
+ // Map each story to its screenshot
382
+ const mappedEntries = storyEntries.map(storyEntry => {
383
+ // Find matching screenshot
384
+ const screenshotPath = findMatchingScreenshot(storyEntry, storyEntry.storyTitle, screenshotFiles);
385
+
386
+ return {
387
+ ...storyEntry,
388
+ screenshotPath: screenshotPath
389
+ };
390
+ });
391
+
392
+ return mappedEntries;
393
+ }
394
+
395
+ /**
396
+ * Analyzes storybook stories and maps them to screenshots
397
+ * @param {Object} config - Configuration options
398
+ * @param {string} config.storiesDir - Directory containing stories (optional, will auto-detect if not provided)
399
+ * @param {string} config.screenshotsDir - Directory containing screenshots
400
+ * @param {string} config.project - Project name
401
+ * @param {string} config.version - Version identifier
402
+ * @returns {Object} Analysis results with metadata
403
+ */
404
+ function analyzeStorybook(config) {
405
+ const {
406
+ storiesDir = null, // null enables auto-detection
407
+ screenshotsDir = './__screenshots__',
408
+ project,
409
+ version
410
+ } = config;
411
+
412
+ // Map stories to screenshots
413
+ const stories = mapStoriesToScreenshots(storiesDir, screenshotsDir);
414
+
415
+ // Calculate summary statistics
416
+ const totalStories = stories.length;
417
+ const withScreenshots = stories.filter(s => s.screenshotPath).length;
418
+
419
+ return {
420
+ project: project || 'unknown',
421
+ version: version || 'unknown',
422
+ timestamp: new Date().toISOString(),
423
+ summary: {
424
+ totalStories,
425
+ withScreenshots
426
+ },
427
+ stories
428
+ };
429
+ }
430
+
431
+ module.exports = {
432
+ findStoryFiles,
433
+ extractComponentName,
434
+ extractStoryTitle,
435
+ extractStories,
436
+ processStoryFile,
437
+ crawlStories,
438
+ convertStoryNameToScreenshotName,
439
+ findScreenshotFiles,
440
+ findMatchingScreenshot,
441
+ mapStoriesToScreenshots,
442
+ analyzeStorybook,
443
+ autoDetectStoriesDir,
444
+ isStoryFile
445
+ };
@@ -0,0 +1,130 @@
1
+ const axios = require('axios');
2
+ const fs = require('fs');
3
+ const { ApiError, UploadError } = require('./errors.js');
4
+
5
+ /**
6
+ * Creates a pre-configured axios instance for making API calls.
7
+ * @param {string} apiUrl The base URL of the API.
8
+ * @param {string} apiKey The API key for authentication (optional).
9
+ * @returns {axios.AxiosInstance} A configured axios instance.
10
+ */
11
+ function getApiClient(apiUrl, apiKey) {
12
+ // This is a mock check to allow testing of a 401 error case.
13
+ if (apiKey === 'fail-me-401') {
14
+ throw new ApiError('The provided API key is invalid or has expired.', 401);
15
+ }
16
+
17
+ const headers = {
18
+ 'Content-Type': 'application/json',
19
+ 'Accept': 'application/json',
20
+ };
21
+
22
+ // Only add X-API-Key header if API key is provided
23
+ if (apiKey) {
24
+ headers['X-API-Key'] = apiKey;
25
+ }
26
+
27
+ return axios.create({
28
+ baseURL: apiUrl,
29
+ headers: headers,
30
+ });
31
+ }
32
+
33
+ /**
34
+ * Uploads a file using a presigned URL workflow.
35
+ * @param {axios.AxiosInstance} apiClient The configured axios instance.
36
+ * @param {object} payload The metadata for the deployment.
37
+ * @param {string} payload.project The project name/identifier.
38
+ * @param {string} payload.version The version identifier.
39
+ * @param {string} filePath The local path to the file to upload.
40
+ * @returns {Promise<object>} A promise that resolves to the upload result.
41
+ */
42
+ async function uploadFileDirectly(apiClient, { project, version }, filePath) {
43
+ // This is a mock check to allow testing of a 500 server error.
44
+ if (project === 'fail-me-500') {
45
+ throw new ApiError('The deployment service encountered an internal error.', 500);
46
+ }
47
+
48
+ // Default to 'main' and 'latest' if not provided
49
+ const projectName = project || 'main';
50
+ const versionName = version || 'latest';
51
+
52
+ const fileBuffer = fs.readFileSync(filePath);
53
+ const fileName = 'storybook.zip';
54
+
55
+ try {
56
+ // Step 1: Request a presigned URL
57
+ const presignedUrlStartTime = Date.now();
58
+ console.log(`[DEBUG] Requesting presigned URL for /presigned-url/${projectName}/${versionName}/${fileName}`);
59
+ console.log(`[DEBUG] API Base URL: ${apiClient.defaults.baseURL}`);
60
+
61
+ // The server expects contentType in the JSON body, NOT as a header
62
+ // This contentType must match the Content-Type header used during the actual upload
63
+ const presignedResponse = await apiClient.post(
64
+ `/presigned-url/${projectName}/${versionName}/${fileName}`,
65
+ { contentType: 'application/zip' }, // Send contentType in the body
66
+ {
67
+ headers: {
68
+ 'Content-Type': 'application/json', // Request body is JSON
69
+ },
70
+ }
71
+ );
72
+
73
+ const presignedUrlDuration = Date.now() - presignedUrlStartTime;
74
+ console.log(`[TIMING] Presigned URL request: ${presignedUrlDuration}ms`);
75
+ console.log(`[DEBUG] Presigned URL response status: ${presignedResponse.status}`);
76
+ console.log(`[DEBUG] Presigned URL response data: ${JSON.stringify(presignedResponse.data)}`);
77
+
78
+ const presignedUrl = presignedResponse.data.url;
79
+ if (!presignedUrl || typeof presignedUrl !== 'string' || presignedUrl.trim() === '') {
80
+ throw new ApiError(
81
+ `Failed to get valid presigned URL from server response. ` +
82
+ `Received: ${JSON.stringify(presignedResponse.data)}`
83
+ );
84
+ }
85
+
86
+ // Validate URL format
87
+ try {
88
+ new URL(presignedUrl);
89
+ } catch (urlError) {
90
+ throw new ApiError(
91
+ `Received invalid URL format from server: "${presignedUrl}". ` +
92
+ `URL validation error: ${urlError.message}`
93
+ );
94
+ }
95
+
96
+ console.log(`[DEBUG] Received presigned URL, uploading file...`);
97
+ console.log(`[DEBUG] Presigned URL: ${presignedUrl.substring(0, 100)}...`);
98
+ console.log(`[DEBUG] File size: ${(fileBuffer.length / 1024 / 1024).toFixed(2)} MB`);
99
+
100
+ // Step 2: Upload the file to the presigned URL using PUT
101
+ const uploadStartTime = Date.now();
102
+ const uploadResponse = await axios.put(presignedUrl, fileBuffer, {
103
+ headers: {
104
+ 'Content-Type': 'application/zip',
105
+ },
106
+ maxContentLength: Infinity,
107
+ maxBodyLength: Infinity,
108
+ });
109
+
110
+ const uploadDuration = Date.now() - uploadStartTime;
111
+ const uploadSpeedMbps = ((fileBuffer.length / 1024 / 1024) / (uploadDuration / 1000)).toFixed(2);
112
+ console.log(`[TIMING] File upload: ${uploadDuration}ms (${uploadSpeedMbps} MB/s)`);
113
+ console.log(`[DEBUG] File uploaded successfully`);
114
+
115
+ return { success: true, url: presignedUrl, status: uploadResponse.status };
116
+ } catch (error) {
117
+ if (error.response) {
118
+ throw new ApiError(`Failed to upload file: ${error.response.status} ${error.response.statusText}${error.response.data ? ` - ${JSON.stringify(error.response.data)}` : ''}`, error.response.status);
119
+ } else if (error.request) {
120
+ throw new ApiError(`Failed to upload file: No response from server at ${apiClient.defaults.baseURL}`);
121
+ } else {
122
+ throw new ApiError(`Failed to upload file: ${error.message}`);
123
+ }
124
+ }
125
+ }
126
+
127
+ module.exports = {
128
+ getApiClient,
129
+ uploadFileDirectly,
130
+ };
package/lib/archive.js ADDED
@@ -0,0 +1,31 @@
1
+ const fs = require('fs');
2
+ const archiver = require('archiver');
3
+ const { FileSystemError } = require('./errors.js');
4
+
5
+ /**
6
+ * Zips a directory and saves it to the specified output path.
7
+ * @param {string} sourceDir The path to the directory to zip.
8
+ * @param {string} outPath The path to save the output zip file.
9
+ * @param {string|boolean} internalPath Optional internal path within the ZIP (default: false for root)
10
+ * @returns {Promise<void>} A promise that resolves when the zipping is complete.
11
+ */
12
+ function zipDirectory(sourceDir, outPath, internalPath = false) {
13
+ const archive = archiver('zip', {
14
+ zlib: { level: 9 } // Sets the compression level.
15
+ });
16
+ const stream = fs.createWriteStream(outPath);
17
+
18
+ return new Promise((resolve, reject) => {
19
+ archive
20
+ .directory(sourceDir, internalPath) // Adds files from sourceDir to the specified path in archive
21
+ .on('error', err => reject(new FileSystemError(`Failed to archive directory: ${err.message}`)))
22
+ .pipe(stream);
23
+
24
+ stream.on('close', () => {
25
+ resolve();
26
+ });
27
+ archive.finalize();
28
+ });
29
+ }
30
+
31
+ module.exports = { zipDirectory };
@@ -0,0 +1,95 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const archiver = require('archiver');
4
+ const { FileSystemError } = require('./errors.js');
5
+
6
+ /**
7
+ * Creates a master ZIP file containing staticsite, images, and metadata
8
+ * @param {Object} options - Configuration options
9
+ * @param {string} options.outPath - Output path for the master ZIP file
10
+ * @param {string} options.staticsiteDir - Path to storybook-static directory (optional)
11
+ * @param {string} options.screenshotsDir - Path to screenshots directory
12
+ * @param {Object} options.metadata - Metadata object to include as JSON
13
+ * @returns {Promise<void>} A promise that resolves when the zipping is complete
14
+ */
15
+ function createMasterZip(options) {
16
+ const {
17
+ outPath,
18
+ staticsiteDir,
19
+ screenshotsDir,
20
+ metadata
21
+ } = options;
22
+
23
+ const archive = archiver('zip', {
24
+ zlib: { level: 9 } // Sets the compression level
25
+ });
26
+ const stream = fs.createWriteStream(outPath);
27
+
28
+ return new Promise((resolve, reject) => {
29
+ archive
30
+ .on('error', err => reject(new FileSystemError(`Failed to create master archive: ${err.message}`)))
31
+ .pipe(stream);
32
+
33
+ // Add staticsite directory at root (CDN-compliant: index.html at root)
34
+ if (staticsiteDir && fs.existsSync(staticsiteDir)) {
35
+ archive.directory(staticsiteDir, false);
36
+ }
37
+
38
+ // Add screenshots/images directory if it exists
39
+ if (screenshotsDir && fs.existsSync(screenshotsDir)) {
40
+ archive.directory(screenshotsDir, 'images');
41
+ }
42
+
43
+ // Add metadata.json file
44
+ if (metadata) {
45
+ const metadataJson = JSON.stringify(metadata, null, 2);
46
+ archive.append(metadataJson, { name: 'metadata.json' });
47
+ }
48
+
49
+ stream.on('close', () => {
50
+ resolve();
51
+ });
52
+
53
+ archive.finalize();
54
+ });
55
+ }
56
+
57
+ /**
58
+ * Creates a ZIP file from a directory with a custom internal path
59
+ * @param {string} sourceDir - The path to the directory to zip
60
+ * @param {string} outPath - The path to save the output zip file
61
+ * @param {string} internalPath - The internal path within the ZIP (default: no prefix)
62
+ * @returns {Promise<void>} A promise that resolves when the zipping is complete
63
+ */
64
+ function zipDirectoryWithPath(sourceDir, outPath, internalPath = false) {
65
+ const archive = archiver('zip', {
66
+ zlib: { level: 9 }
67
+ });
68
+ const stream = fs.createWriteStream(outPath);
69
+
70
+ return new Promise((resolve, reject) => {
71
+ archive
72
+ .directory(sourceDir, internalPath)
73
+ .on('error', err => reject(new FileSystemError(`Failed to archive directory: ${err.message}`)))
74
+ .pipe(stream);
75
+
76
+ stream.on('close', () => {
77
+ resolve();
78
+ });
79
+
80
+ archive.finalize();
81
+ });
82
+ }
83
+
84
+ module.exports = {
85
+ createMasterZip,
86
+ zipDirectoryWithPath
87
+ };
88
+
89
+
90
+ /*
91
+ scry-cdn-service the viewer
92
+ scry-node
93
+ scry-developer-dashboard
94
+ scry-storybook-upload-service
95
+ */