@scrymore/scry-deployer 0.2.1 → 0.3.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.
package/bin/cli.js CHANGED
@@ -18,6 +18,8 @@ const { runCoverageAnalysis, loadCoverageReport, extractCoverageSummary } = requ
18
18
  const { postPRComment } = require('../lib/pr-comment.js');
19
19
  const { runInit } = require('../lib/init.js');
20
20
  const { runUpdateWorkflows } = require('../lib/update-workflows.js');
21
+ const { runQueueImageUpload } = require('../lib/imageUpload.js');
22
+ const { runLocalImageProcessing } = require('../lib/localImageProcessing.js');
21
23
 
22
24
  async function runAnalysis(argv) {
23
25
  const logger = createLogger(argv);
@@ -123,9 +125,30 @@ async function runDeployment(argv) {
123
125
 
124
126
  await postPRComment(buildDeployResult(argv, coverageSummary, uploadResult), coverageSummary);
125
127
 
126
- logger.success('\nšŸŽ‰ Deployment successful! šŸŽ‰');
128
+ // Report only what actually completed. Uploading is synchronous;
129
+ // indexing is not. A build can fail in the queue seconds after this
130
+ // point — during one run the pipeline died 7s later on a revoked
131
+ // credential — and this command previously printed
132
+ // "Deployment successful" over it, sending people looking for the
133
+ // cause three steps downstream (ISSUES.md #4).
134
+ logger.success('\nāœ… Upload complete.');
127
135
  logUploadLinks(argv, coverageSummary, uploadResult, logger);
128
136
 
137
+ if (uploadResult?.metadataUpload?.queued) {
138
+ logger.info(
139
+ '\nā³ Indexing has been queued, not finished.\n' +
140
+ ' This command cannot confirm it succeeded. Components will not be\n' +
141
+ ' searchable until processing completes, and a failed build reports\n' +
142
+ ' nothing here. Before relying on search, confirm the build shows\n' +
143
+ " processingStatus 'completed' rather than 'failed'."
144
+ );
145
+ } else if (uploadResult?.metadataUpload) {
146
+ logger.warn(
147
+ '\nāš ļø Metadata was uploaded but not queued for processing.\n' +
148
+ ' The Storybook is hosted, but its components are NOT being indexed.'
149
+ );
150
+ }
151
+
129
152
  } finally {
130
153
  // 4. Clean up the local archive
131
154
  if (fs.existsSync(outPath)) {
@@ -182,7 +205,6 @@ async function main() {
182
205
  environment: process.env.NODE_ENV || 'production',
183
206
  });
184
207
 
185
- let config;
186
208
  try {
187
209
  const args = await yargs(hideBin(process.argv))
188
210
  .command('$0', 'Deploy Storybook static build', (yargs) => {
@@ -255,7 +277,7 @@ async function main() {
255
277
  });
256
278
  }, async (argv) => {
257
279
  // Load and merge configuration
258
- config = loadConfig(argv);
280
+ const config = loadConfig(argv);
259
281
 
260
282
  // Validate required fields
261
283
  if (!config.dir) {
@@ -311,7 +333,7 @@ async function main() {
311
333
  });
312
334
  }, async (argv) => {
313
335
  // Load and merge configuration
314
- config = loadConfig(argv);
336
+ const config = loadConfig(argv);
315
337
 
316
338
  await runAnalysis(config);
317
339
  })
@@ -422,6 +444,97 @@ async function main() {
422
444
 
423
445
  await runInit(initConfig);
424
446
  })
447
+ .command('upload-images', 'Upload a folder of images for search indexing', (yargs) => {
448
+ return yargs
449
+ .option('dir', {
450
+ describe: 'Path to the image directory',
451
+ type: 'string',
452
+ demandOption: true,
453
+ })
454
+ .option('project', {
455
+ describe: 'Project name/identifier',
456
+ type: 'string',
457
+ demandOption: true,
458
+ })
459
+ .option('local', {
460
+ describe: 'Process images locally instead of uploading to the queue',
461
+ type: 'boolean',
462
+ default: false,
463
+ })
464
+ .option('openai-api-key', {
465
+ describe: 'OpenAI API key (for --local mode)',
466
+ type: 'string',
467
+ })
468
+ .option('jina-api-key', {
469
+ describe: 'Jina API key (for --local mode)',
470
+ type: 'string',
471
+ })
472
+ .option('milvus-address', {
473
+ describe: 'Milvus/Zilliz endpoint (for --local mode)',
474
+ type: 'string',
475
+ })
476
+ .option('milvus-token', {
477
+ describe: 'Milvus/Zilliz auth token (for --local mode)',
478
+ type: 'string',
479
+ })
480
+ .option('milvus-collection', {
481
+ describe: 'Milvus collection name (for --local mode)',
482
+ type: 'string',
483
+ })
484
+ .option('api-key', {
485
+ describe: 'API key for the deployment service (queue mode)',
486
+ type: 'string',
487
+ })
488
+ .option('api-url', {
489
+ describe: 'Base URL for the deployment service API (queue mode)',
490
+ type: 'string',
491
+ })
492
+ .option('verbose', {
493
+ describe: 'Enable verbose logging',
494
+ type: 'boolean',
495
+ });
496
+ }, async (argv) => {
497
+ const config = loadConfig(argv);
498
+
499
+ if (!config.dir) {
500
+ throw new Error('--dir is required. Provide a path to the image directory.');
501
+ }
502
+
503
+ if (!fs.existsSync(config.dir)) {
504
+ throw new Error(`Directory not found: ${config.dir}`);
505
+ }
506
+ if (!fs.lstatSync(config.dir).isDirectory()) {
507
+ throw new Error(`Path is not a directory: ${config.dir}`);
508
+ }
509
+
510
+ if (config.local) {
511
+ // Local mode: process images directly via LLM + embeddings + Milvus
512
+ const requiredLocalKeys = {
513
+ openaiApiKey: { flag: '--openai-api-key', env: 'OPENAI_API_KEY' },
514
+ jinaApiKey: { flag: '--jina-api-key', env: 'JINA_API_KEY' },
515
+ milvusAddress: { flag: '--milvus-address', env: 'MILVUS_ADDRESS' },
516
+ milvusToken: { flag: '--milvus-token', env: 'MILVUS_TOKEN' },
517
+ milvusCollection: { flag: '--milvus-collection', env: 'MILVUS_COLLECTION' },
518
+ };
519
+
520
+ const resolved = {};
521
+ for (const [key, { flag, env }] of Object.entries(requiredLocalKeys)) {
522
+ resolved[key] = config[key] || process.env[env];
523
+ if (!resolved[key]) {
524
+ throw new Error(`${flag} or ${env} env var is required for local mode`);
525
+ }
526
+ }
527
+
528
+ await runLocalImageProcessing({
529
+ dir: config.dir,
530
+ project: config.project,
531
+ ...resolved,
532
+ verbose: config.verbose,
533
+ });
534
+ } else {
535
+ await runQueueImageUpload(config);
536
+ }
537
+ })
425
538
  .command('debug-sentry', 'Test Sentry integration by throwing an error', () => {}, () => {
426
539
  throw new Error('Sentry debug error from scry-node CLI');
427
540
  })
@@ -432,7 +545,7 @@ async function main() {
432
545
  .parse();
433
546
 
434
547
  } catch (error) {
435
- await handleError(error, config);
548
+ await handleError(error, error.config || {});
436
549
  }
437
550
  }
438
551
 
package/lib/apiClient.js CHANGED
@@ -153,14 +153,15 @@ function validatePresignedUrl(presignedUrl) {
153
153
  * Upload a buffer to a presigned URL.
154
154
  *
155
155
  * @param {string} presignedUrl
156
- * @param {Buffer} buffer
156
+ * @param {Buffer|import('stream').Readable} data
157
157
  * @param {string} contentType
158
158
  * @returns {Promise<{status:number}>}
159
159
  */
160
- async function putToPresignedUrl(presignedUrl, buffer, contentType) {
161
- logger.debug(`Starting PUT upload to presigned URL. Size: ${buffer.length} bytes, Content-Type: ${contentType}`);
160
+ async function putToPresignedUrl(presignedUrl, data, contentType) {
161
+ const size = Buffer.isBuffer(data) ? `${data.length} bytes` : 'stream';
162
+ logger.debug(`Starting PUT upload to presigned URL. Size: ${size}, Content-Type: ${contentType}`);
162
163
 
163
- const uploadResponse = await axios.put(presignedUrl, buffer, {
164
+ const uploadResponse = await axios.put(presignedUrl, data, {
164
165
  headers: {
165
166
  'Content-Type': contentType,
166
167
  },
@@ -321,7 +322,7 @@ async function uploadMetadataZip(apiClient, target, metadataZipPath, customLogge
321
322
  };
322
323
  } catch (error) {
323
324
  const message = error.response?.data?.error || error.message || 'Unknown error';
324
- customLogger.warn(`Metadata ZIP upload failed: ${message}`);
325
+ customLogger.error(`Metadata ZIP upload failed: ${message}`);
325
326
  return { success: false, error: message };
326
327
  }
327
328
  }
@@ -0,0 +1,120 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const os = require('os');
4
+ const { zipDirectory } = require('./archive.js');
5
+ const { getApiClient, requestPresignedUrl, putToPresignedUrl } = require('./apiClient.js');
6
+ const { createLogger } = require('./logger.js');
7
+
8
+ const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg']);
9
+
10
+ /**
11
+ * Recursively find image files in a directory.
12
+ * Excludes __MACOSX and hidden files.
13
+ *
14
+ * @param {string} dir
15
+ * @returns {string[]} Array of absolute file paths
16
+ */
17
+ function findImageFiles(dir) {
18
+ const results = [];
19
+
20
+ function walk(currentDir) {
21
+ const entries = fs.readdirSync(currentDir, { withFileTypes: true });
22
+
23
+ for (const entry of entries) {
24
+ // Skip hidden files/dirs and __MACOSX
25
+ if (entry.name.startsWith('.') || entry.name === '__MACOSX') continue;
26
+
27
+ const fullPath = path.join(currentDir, entry.name);
28
+
29
+ if (entry.isDirectory()) {
30
+ walk(fullPath);
31
+ } else if (entry.isFile()) {
32
+ const ext = path.extname(entry.name).toLowerCase();
33
+ if (IMAGE_EXTENSIONS.has(ext)) {
34
+ results.push(fullPath);
35
+ }
36
+ }
37
+ }
38
+ }
39
+
40
+ walk(dir);
41
+ return results;
42
+ }
43
+
44
+ /**
45
+ * Run queue-mode image upload.
46
+ * ZIPs the directory, uploads via presigned URL, signals completion.
47
+ *
48
+ * @param {object} config
49
+ * @param {string} config.dir - Path to image directory
50
+ * @param {string} config.project - Project identifier
51
+ * @param {string} config.apiKey - API key
52
+ * @param {string} config.apiUrl - API base URL
53
+ * @param {boolean} [config.verbose]
54
+ */
55
+ async function runQueueImageUpload(config) {
56
+ const logger = createLogger(config);
57
+
58
+ // 1. Validate directory and count images
59
+ logger.info('Scanning for images...');
60
+ const imageFiles = findImageFiles(config.dir);
61
+ if (imageFiles.length === 0) {
62
+ throw new Error(`No image files (.png, .jpg, .jpeg) found in: ${config.dir}`);
63
+ }
64
+ logger.success(`Found ${imageFiles.length} images`);
65
+
66
+ const zipPath = path.join(os.tmpdir(), `scry-image-upload-${Date.now()}.zip`);
67
+
68
+ try {
69
+ // 2. ZIP the directory
70
+ logger.info('Creating ZIP archive...');
71
+ await zipDirectory(config.dir, zipPath);
72
+ const zipSize = fs.statSync(zipPath).size;
73
+ logger.success(`ZIP created: ${(zipSize / 1024 / 1024).toFixed(1)} MB`);
74
+ logger.debug(`ZIP path: ${zipPath}`);
75
+
76
+ // 3. Initialize upload — get presigned URL and uploadId
77
+ logger.info('Initializing upload...');
78
+ const apiClient = getApiClient(config.apiUrl, config.apiKey);
79
+
80
+ const initResponse = await apiClient.post(
81
+ `/upload-images/${config.project}`,
82
+ { imageCount: imageFiles.length },
83
+ { headers: { 'Content-Type': 'application/json' } }
84
+ );
85
+
86
+ const { uploadId, uploadNumber, presignedUrl, zipKey } = initResponse.data;
87
+ logger.info(`Upload #${uploadNumber} initialized (id: ${uploadId})`);
88
+ logger.debug(`Presigned URL received, zipKey: ${zipKey}`);
89
+
90
+ // 4. PUT ZIP to presigned URL (stream to avoid loading entire file into memory)
91
+ logger.info('Uploading ZIP to storage...');
92
+ const fileStream = fs.createReadStream(zipPath);
93
+ await putToPresignedUrl(presignedUrl, fileStream, 'application/zip');
94
+ logger.success('ZIP uploaded to storage');
95
+
96
+ // 5. Signal completion
97
+ logger.info('Signaling upload complete...');
98
+ await apiClient.post(
99
+ `/upload-images/${config.project}/complete`,
100
+ { uploadId, zipKey },
101
+ { headers: { 'Content-Type': 'application/json' } }
102
+ );
103
+
104
+ logger.success(`\nUpload #${uploadNumber} queued for processing (${imageFiles.length} images)`);
105
+ logger.info(`Upload ID: ${uploadId}`);
106
+
107
+ return { uploadId, uploadNumber, imageCount: imageFiles.length };
108
+ } finally {
109
+ // 6. Cleanup
110
+ if (fs.existsSync(zipPath)) {
111
+ fs.unlinkSync(zipPath);
112
+ logger.debug(`Cleaned up temporary ZIP: ${zipPath}`);
113
+ }
114
+ }
115
+ }
116
+
117
+ module.exports = {
118
+ findImageFiles,
119
+ runQueueImageUpload,
120
+ };
@@ -0,0 +1,663 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const crypto = require('crypto');
4
+ const { XMLParser } = require('fast-xml-parser');
5
+ const { createLogger } = require('./logger.js');
6
+ const { findImageFiles } = require('./imageUpload.js');
7
+
8
+ // ───────────────────────── XML Parsing ─────────────────────────
9
+
10
+ const xmlParser = new XMLParser({
11
+ ignoreAttributes: true,
12
+ trimValues: true,
13
+ });
14
+
15
+ function parseXmlToJson(xmlContent) {
16
+ const wrapped = `<root>${xmlContent}</root>`;
17
+ const parsed = xmlParser.parse(wrapped);
18
+ const root = parsed.root || {};
19
+
20
+ // Navigate into any wrapper element (e.g. component-documentation)
21
+ const doc = root['component-documentation'] || root;
22
+
23
+ const result = {};
24
+
25
+ if (doc['screen-name']) result.screenName = String(doc['screen-name']).trim();
26
+ if (doc.description) result.description = String(doc.description).trim();
27
+
28
+ if (doc.tags) {
29
+ const rawTags = doc.tags.tag;
30
+ result.tags = Array.isArray(rawTags)
31
+ ? rawTags.map(t => String(t).trim())
32
+ : rawTags ? [String(rawTags).trim()] : [];
33
+ }
34
+
35
+ if (doc['search-queries']) {
36
+ const rawQueries = doc['search-queries'].query;
37
+ result.searchQueries = Array.isArray(rawQueries)
38
+ ? rawQueries.map(q => String(q).trim())
39
+ : rawQueries ? [String(rawQueries).trim()] : [];
40
+ }
41
+
42
+ return result;
43
+ }
44
+
45
+ function parseBatchXmlResponse(xmlResponse, count) {
46
+ const wrapped = `<root>${xmlResponse}</root>`;
47
+ const parsed = xmlParser.parse(wrapped);
48
+ const root = parsed.root || {};
49
+ const results = [];
50
+
51
+ for (let i = 0; i < count; i++) {
52
+ const key = `component-${i + 1}`;
53
+ const component = root[key] || (root['batch-analysis'] && root['batch-analysis'][key]);
54
+ if (!component) {
55
+ results.push({});
56
+ continue;
57
+ }
58
+ // Re-serialize the component back to extract fields
59
+ const result = {};
60
+ if (component['screen-name']) result.screenName = String(component['screen-name']).trim();
61
+ if (component.description) result.description = String(component.description).trim();
62
+ if (component.tags) {
63
+ const rawTags = component.tags.tag;
64
+ result.tags = Array.isArray(rawTags)
65
+ ? rawTags.map(t => String(t).trim())
66
+ : rawTags ? [String(rawTags).trim()] : [];
67
+ }
68
+ if (component['search-queries']) {
69
+ const rawQueries = component['search-queries'].query;
70
+ result.searchQueries = Array.isArray(rawQueries)
71
+ ? rawQueries.map(q => String(q).trim())
72
+ : rawQueries ? [String(rawQueries).trim()] : [];
73
+ }
74
+ results.push(result);
75
+ }
76
+
77
+ return results;
78
+ }
79
+
80
+ // ───────────────────────── Prompt ─────────────────────────
81
+
82
+ const IMAGE_UPLOAD_INSPECTOR_PROMPT = `# UI Screenshot Documentation Instructions
83
+
84
+ ## Overview
85
+ You will analyze UI screenshots (mobile app screens, web pages, or other user interface captures) and generate standardized documentation in XML format consisting of a screen name, description, tags, and search queries.
86
+
87
+ ## Input Materials
88
+ - **Screenshot**: Visual representation of a UI screen
89
+
90
+ ## Output Format (XML Structure)
91
+
92
+ \`\`\`xml
93
+ <component-documentation>
94
+ <screen-name>
95
+ [Short, descriptive name for this screen, e.g. "Home Feed", "Login Page", "Settings Menu"]
96
+ </screen-name>
97
+ <description>
98
+ [2-3 sentence description following the template below]
99
+ </description>
100
+ <tags>
101
+ <tag>Screen Type</tag>
102
+ <tag>App Category</tag>
103
+ <tag>Visual Descriptor</tag>
104
+ <tag>UI Pattern</tag>
105
+ <tag>Functional Category</tag>
106
+ <!-- 8-15 total tags -->
107
+ </tags>
108
+ <search-queries>
109
+ <query>UI screen type keyword</query>
110
+ <query>Visual description phrase</query>
111
+ <query>User-friendly search phrase</query>
112
+ <query>Functional description</query>
113
+ <query>Design pattern query</query>
114
+ <!-- 5-7 total queries -->
115
+ </search-queries>
116
+ </component-documentation>
117
+ \`\`\`
118
+
119
+ ## Content Guidelines
120
+
121
+ ### 1. Screen Name (short label)
122
+ - A concise, human-readable name for the screen
123
+ - Examples: "Home Feed", "Product Detail", "Search Results", "User Profile", "Checkout Flow"
124
+
125
+ ### 2. Description (2-3 sentences)
126
+ - **First sentence**: Screen type and primary purpose
127
+ - **Second sentence**: Visual characteristics and key UI elements
128
+ - **Third sentence** (if needed): Notable features or interaction patterns
129
+
130
+ **Template**: "This is a [screen type] that [primary function]. It features [key UI elements and visual characteristics]. [Additional notable features]."
131
+
132
+ ### 3. Tags (8-15 keywords)
133
+ List relevant tags in this priority order:
134
+ 1. Screen type (Home, Settings, Profile, etc.)
135
+ 2. App category (Social, E-commerce, Productivity, etc.)
136
+ 3. Visual descriptors (colors, layout style)
137
+ 4. UI patterns (tab bar, card layout, list view, etc.)
138
+ 5. Functional categories (navigation, content, form, etc.)
139
+ 6. Platform indicators (iOS, Android, Web)
140
+
141
+ ### 4. Search Queries (5-7 phrases)
142
+ Create search-friendly phrases without quotes:
143
+ - Include screen type + descriptive terms
144
+ - Use common UI/UX terminology
145
+ - Consider both technical and casual language
146
+ - Include design pattern references
147
+
148
+ ## Quality Standards
149
+ - **Accuracy**: Description must match the visual exactly
150
+ - **Completeness**: Include all significant visual and functional aspects
151
+ - **Consistency**: Use the same terminology and format for similar screens
152
+ - **Searchability**: Tags and queries should help users find this screen easily
153
+ - **Valid XML**: Ensure all tags are properly closed and content is escaped if needed
154
+
155
+ Remember: Accuracy and consistency are critical. When in doubt, describe exactly what you see in the screenshot.`;
156
+
157
+ // ───────────────────────── Batch Processor ─────────────────────────
158
+
159
+ async function processBatches(items, batchSize, maxConcurrent, processFn, options = {}) {
160
+ if (items.length === 0) return [];
161
+
162
+ const batches = [];
163
+ for (let i = 0; i < items.length; i += batchSize) {
164
+ batches.push(items.slice(i, i + batchSize));
165
+ }
166
+
167
+ const results = [];
168
+ let batchIndex = 0;
169
+ let completedItems = 0;
170
+
171
+ const processBatch = async () => {
172
+ while (batchIndex < batches.length) {
173
+ const currentIndex = batchIndex++;
174
+ const batch = batches[currentIndex];
175
+
176
+ if (options.delayMs && currentIndex > 0) {
177
+ await new Promise(resolve => setTimeout(resolve, options.delayMs));
178
+ }
179
+
180
+ const batchResults = await processFn(batch);
181
+ results.push(...batchResults);
182
+ completedItems += batch.length;
183
+
184
+ if (options.onProgress) {
185
+ options.onProgress(completedItems, items.length);
186
+ }
187
+ }
188
+ };
189
+
190
+ const workers = Array.from(
191
+ { length: Math.min(maxConcurrent, batches.length) },
192
+ () => processBatch()
193
+ );
194
+ await Promise.all(workers);
195
+
196
+ return results;
197
+ }
198
+
199
+ // ───────────────────────── Vector Utils ─────────────────────────
200
+
201
+ const DEFAULT_TARGET_DIM = 2048;
202
+
203
+ function padVector(vector, targetDim) {
204
+ if (!vector || !Array.isArray(vector)) {
205
+ return new Array(targetDim).fill(0);
206
+ }
207
+ if (vector.length === targetDim) return vector;
208
+ if (vector.length > targetDim) return vector.slice(0, targetDim);
209
+ const padded = [...vector];
210
+ while (padded.length < targetDim) padded.push(0);
211
+ return padded;
212
+ }
213
+
214
+ // ───────────────────────── LLM Inspector ─────────────────────────
215
+
216
+ function createImageBatchPrompt(count) {
217
+ const batchInstructions = `
218
+
219
+ ## Batch Analysis Instructions
220
+
221
+ You will analyze ${count} UI screenshot(s) in a single request.
222
+
223
+ **Format your response as:**
224
+ \`\`\`xml
225
+ <batch-analysis>
226
+ <component-1>
227
+ <screen-name>...</screen-name>
228
+ <description>...</description>
229
+ <tags><tag>...</tag>...</tags>
230
+ <search-queries><query>...</query>...</search-queries>
231
+ </component-1>
232
+ ${count > 1 ? `<component-2>...</component-2>` : ''}
233
+ ${count > 2 ? `<!-- ... up to component-${count} -->` : ''}
234
+ </batch-analysis>
235
+ \`\`\`
236
+
237
+ - Component numbers must match the image order exactly (1st image = component-1, etc.)
238
+ - Each screenshot must have complete documentation including screen-name
239
+ `;
240
+
241
+ return IMAGE_UPLOAD_INSPECTOR_PROMPT + batchInstructions;
242
+ }
243
+
244
+ async function batchInspectImages(images, apiKey, options = {}) {
245
+ const model = options.model || 'gpt-5-mini';
246
+ const maxRetries = options.maxRetries ?? 2;
247
+
248
+ const batchPrompt = createImageBatchPrompt(images.length);
249
+
250
+ const content = [
251
+ {
252
+ type: 'text',
253
+ text: batchPrompt + `\n\nAnalyze these ${images.length} UI screenshots and provide numbered documentation for each.`,
254
+ },
255
+ ];
256
+
257
+ for (const image of images) {
258
+ const base64 = image.screenshotBytes.toString('base64');
259
+ const ext = image.filename.toLowerCase().endsWith('.png') ? 'png' : 'jpeg';
260
+ content.push({
261
+ type: 'image_url',
262
+ image_url: {
263
+ url: `data:image/${ext};base64,${base64}`,
264
+ detail: 'high',
265
+ },
266
+ });
267
+ }
268
+
269
+ let lastError = null;
270
+
271
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
272
+ try {
273
+ const response = await fetch('https://api.openai.com/v1/chat/completions', {
274
+ method: 'POST',
275
+ headers: {
276
+ 'Content-Type': 'application/json',
277
+ 'Authorization': `Bearer ${apiKey}`,
278
+ },
279
+ body: JSON.stringify({
280
+ model,
281
+ max_completion_tokens: 1500 * images.length,
282
+ messages: [{ role: 'user', content }],
283
+ }),
284
+ });
285
+
286
+ if (!response.ok) {
287
+ const errorText = await response.text();
288
+ throw new Error(`OpenAI API error ${response.status}: ${errorText}`);
289
+ }
290
+
291
+ const data = await response.json();
292
+ const xmlResponse = data.choices?.[0]?.message?.content;
293
+
294
+ if (!xmlResponse) {
295
+ throw new Error('No response content from OpenAI API');
296
+ }
297
+
298
+ const parsedComponents = parseBatchXmlResponse(xmlResponse, images.length);
299
+
300
+ return parsedComponents.map((parsed, i) => ({
301
+ screenName: parsed.screenName || images[i].filename.replace(/\.[^.]+$/, ''),
302
+ description: parsed.description || '',
303
+ tags: parsed.tags || [],
304
+ searchQueries: parsed.searchQueries || [],
305
+ metadata: {
306
+ imagePath: images[i].screenshotPath,
307
+ model,
308
+ timestamp: new Date().toISOString(),
309
+ batchIndex: i + 1,
310
+ },
311
+ }));
312
+ } catch (error) {
313
+ lastError = error instanceof Error ? error : new Error(String(error));
314
+ console.error(`[LLM] Image batch inspection attempt ${attempt + 1} failed:`, lastError.message);
315
+
316
+ if (attempt < maxRetries) {
317
+ await new Promise(resolve => setTimeout(resolve, 2000 * (attempt + 1)));
318
+ }
319
+ }
320
+ }
321
+
322
+ throw lastError || new Error('Image batch inspection failed');
323
+ }
324
+
325
+ // ───────────────────────── Searchable Text ─────────────────────────
326
+
327
+ function createSearchableTextFromImage(inspection) {
328
+ if (!inspection) return '';
329
+
330
+ const parts = [
331
+ inspection.screenName || '',
332
+ inspection.description || '',
333
+ ...(inspection.tags || []).map(tag => `${tag} element`),
334
+ ...(inspection.searchQueries || []),
335
+ 'mobile app screen',
336
+ 'UI screenshot',
337
+ 'user interface',
338
+ ];
339
+
340
+ return parts
341
+ .filter(Boolean)
342
+ .join(' ')
343
+ .toLowerCase()
344
+ .replace(/\s+/g, ' ')
345
+ .trim();
346
+ }
347
+
348
+ // ───────────────────────── Embedding Generator ─────────────────────────
349
+
350
+ async function callJinaEmbeddings(input, apiKey, maxRetries = 4) {
351
+ let lastError = null;
352
+
353
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
354
+ try {
355
+ const response = await fetch('https://api.jina.ai/v1/embeddings', {
356
+ method: 'POST',
357
+ headers: {
358
+ 'Content-Type': 'application/json',
359
+ 'Authorization': `Bearer ${apiKey}`,
360
+ },
361
+ body: JSON.stringify({
362
+ model: 'jina-embeddings-v4',
363
+ task: 'retrieval.document',
364
+ input,
365
+ }),
366
+ });
367
+
368
+ if (!response.ok) {
369
+ const errorText = await response.text();
370
+ const is429 = response.status === 429;
371
+ const err = new Error(`Jina API error ${response.status}: ${errorText}`);
372
+ if (is429 && attempt < maxRetries) {
373
+ const backoff = 15000 * Math.pow(2, attempt);
374
+ console.warn(`[EMBEDDINGS] Rate limited, waiting ${backoff / 1000}s before retry...`);
375
+ await new Promise(resolve => setTimeout(resolve, backoff));
376
+ lastError = err;
377
+ continue;
378
+ }
379
+ throw err;
380
+ }
381
+
382
+ const data = await response.json();
383
+ if (!data.data) {
384
+ throw new Error('No embedding data in Jina API response');
385
+ }
386
+ return data.data.map(d => d.embedding);
387
+ } catch (error) {
388
+ lastError = error instanceof Error ? error : new Error(String(error));
389
+ console.error(`[EMBEDDINGS] Attempt ${attempt + 1} failed:`, lastError.message);
390
+
391
+ if (attempt < maxRetries) {
392
+ await new Promise(resolve => setTimeout(resolve, 2000 * (attempt + 1)));
393
+ }
394
+ }
395
+ }
396
+
397
+ throw lastError || new Error('Embedding generation failed');
398
+ }
399
+
400
+ async function generateImageEmbeddings(imageBuffers, apiKey) {
401
+ const input = imageBuffers.map(buf => ({
402
+ image: `data:image/png;base64,${buf.toString('base64')}`,
403
+ }));
404
+ return callJinaEmbeddings(input, apiKey);
405
+ }
406
+
407
+ async function generateTextEmbeddings(texts, apiKey) {
408
+ const input = texts.map(text => ({ text }));
409
+ return callJinaEmbeddings(input, apiKey);
410
+ }
411
+
412
+ // ───────────────────────── Vector Inserter ─────────────────────────
413
+
414
+ function transformImageData(image, index, projectId, uploadId, targetDim) {
415
+ const timestamp = Date.now();
416
+
417
+ return {
418
+ primary_key: crypto.randomUUID(),
419
+ text_embedding: padVector(image.textEmbedding, targetDim),
420
+ image_embedding: padVector(image.imageEmbedding, targetDim),
421
+ searchable_text: (image.searchableText || '').substring(0, 65535),
422
+ component_name: image.screenName || 'unknown',
423
+ project_id: projectId,
424
+ timestamp,
425
+ json_content: {
426
+ source_type: 'upload',
427
+ uploadId,
428
+ filename: image.filename,
429
+ screenName: image.screenName,
430
+ screenshotPath: image.screenshotPath,
431
+ inspection: image.inspection,
432
+ },
433
+ };
434
+ }
435
+
436
+ async function insertImageVectors(images, projectId, uploadId, milvusConfig, options = {}) {
437
+ const batchSize = options.batchSize || 50;
438
+ const maxRetries = options.maxRetries || 1;
439
+ const targetDim = options.targetDim || DEFAULT_TARGET_DIM;
440
+ let totalInserted = 0;
441
+
442
+ for (let i = 0; i < images.length; i += batchSize) {
443
+ const batch = images.slice(i, i + batchSize);
444
+ const records = batch.map((image, idx) => transformImageData(image, i + idx, projectId, uploadId, targetDim));
445
+
446
+ let lastError = null;
447
+
448
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
449
+ try {
450
+ const base = milvusConfig.address.startsWith('https://') || milvusConfig.address.startsWith('http://')
451
+ ? milvusConfig.address
452
+ : `https://${milvusConfig.address}`;
453
+ const url = `${base.replace(/\/+$/, '')}/v2/vectordb/entities/insert`;
454
+
455
+ const response = await fetch(url, {
456
+ method: 'POST',
457
+ headers: {
458
+ 'Content-Type': 'application/json',
459
+ 'Authorization': `Bearer ${milvusConfig.token}`,
460
+ },
461
+ body: JSON.stringify({
462
+ collectionName: milvusConfig.collectionName,
463
+ data: records,
464
+ }),
465
+ });
466
+
467
+ if (!response.ok) {
468
+ const errorText = await response.text();
469
+ throw new Error(`Milvus HTTP error ${response.status}: ${errorText}`);
470
+ }
471
+
472
+ const result = await response.json();
473
+
474
+ if (result.code && result.code !== 0) {
475
+ throw new Error(`Milvus API error (code ${result.code}): ${result.message || JSON.stringify(result)}`);
476
+ }
477
+
478
+ const insertCount = result.data?.insertCount ?? 0;
479
+ totalInserted += insertCount;
480
+ lastError = null;
481
+ break;
482
+ } catch (error) {
483
+ lastError = error instanceof Error ? error : new Error(String(error));
484
+ console.error(`[MILVUS] Insert attempt ${attempt + 1} failed:`, lastError.message);
485
+
486
+ if (attempt < maxRetries) {
487
+ await new Promise(resolve => setTimeout(resolve, 2000));
488
+ }
489
+ }
490
+ }
491
+
492
+ if (lastError) throw lastError;
493
+ }
494
+
495
+ return { insertCount: totalInserted };
496
+ }
497
+
498
+ // ───────────────────────── Orchestrator ─────────────────────────
499
+
500
+ /**
501
+ * Run the full local image processing pipeline.
502
+ * Replicates the worker processUpload() flow but reads from local disk.
503
+ *
504
+ * @param {object} config
505
+ * @param {string} config.dir - Path to image directory
506
+ * @param {string} config.project - Project identifier
507
+ * @param {string} config.openaiApiKey - OpenAI API key
508
+ * @param {string} config.jinaApiKey - Jina API key
509
+ * @param {string} config.milvusAddress - Milvus/Zilliz endpoint
510
+ * @param {string} config.milvusToken - Milvus/Zilliz auth token
511
+ * @param {string} config.milvusCollection - Milvus collection name
512
+ * @param {boolean} [config.verbose]
513
+ */
514
+ async function runLocalImageProcessing(config) {
515
+ const logger = createLogger(config);
516
+ const startTime = Date.now();
517
+ const elapsed = () => `${((Date.now() - startTime) / 1000).toFixed(1)}s`;
518
+
519
+ // Generate a local upload ID
520
+ const uploadId = `local-${Date.now()}`;
521
+
522
+ logger.info('=== Local Image Processing Pipeline ===');
523
+ logger.info(`Project: ${config.project}`);
524
+ logger.info(`Upload ID: ${uploadId}`);
525
+
526
+ // Step 1: Find images
527
+ logger.info('Step 1: Scanning for images...');
528
+ const imageFiles = findImageFiles(config.dir);
529
+ if (imageFiles.length === 0) {
530
+ throw new Error(`No image files (.png, .jpg, .jpeg) found in: ${config.dir}`);
531
+ }
532
+ logger.success(`Found ${imageFiles.length} images (${elapsed()})`);
533
+
534
+ // Build image items with file bytes
535
+ const images = imageFiles.map(filePath => {
536
+ const relativePath = path.relative(config.dir, filePath);
537
+ return {
538
+ filename: path.basename(filePath),
539
+ screenshotPath: relativePath,
540
+ screenshotBytes: fs.readFileSync(filePath),
541
+ };
542
+ });
543
+
544
+ // Step 2: LLM Vision Inspection
545
+ logger.info(`Step 2: LLM inspection for ${images.length} images (batch=5, concurrency=2)...`);
546
+ const inspectionResults = await processBatches(
547
+ images,
548
+ 5,
549
+ 2,
550
+ async (batch) => batchInspectImages(batch, config.openaiApiKey),
551
+ {
552
+ delayMs: 2000,
553
+ onProgress: (done, total) => logger.debug(` LLM inspection: ${done}/${total} images`),
554
+ }
555
+ );
556
+ logger.success(`LLM inspection complete (${elapsed()})`);
557
+
558
+ // Step 3: Create searchable text
559
+ logger.info('Step 3: Creating searchable text...');
560
+ const searchableTexts = inspectionResults.map(inspection => createSearchableTextFromImage(inspection));
561
+ logger.success(`Searchable text created (${elapsed()})`);
562
+
563
+ // Step 4: Generate image embeddings
564
+ logger.info(`Step 4: Generating image embeddings (batch=3, delay=3s)...`);
565
+ const imageEmbeddings = await processBatches(
566
+ images.map(img => img.screenshotBytes),
567
+ 3,
568
+ 1,
569
+ async (batch) => generateImageEmbeddings(batch, config.jinaApiKey),
570
+ {
571
+ delayMs: 3000,
572
+ onProgress: (done, total) => logger.debug(` Image embeddings: ${done}/${total}`),
573
+ }
574
+ );
575
+ logger.success(`Image embeddings complete (${elapsed()})`);
576
+
577
+ // Step 5: Generate text embeddings
578
+ logger.info(`Step 5: Generating text embeddings (batch=5, delay=2s)...`);
579
+ const textEmbeddings = await processBatches(
580
+ searchableTexts,
581
+ 5,
582
+ 1,
583
+ async (batch) => generateTextEmbeddings(batch, config.jinaApiKey),
584
+ {
585
+ delayMs: 2000,
586
+ onProgress: (done, total) => logger.debug(` Text embeddings: ${done}/${total}`),
587
+ }
588
+ );
589
+ logger.success(`Text embeddings complete (${elapsed()})`);
590
+
591
+ // Step 6: Assemble
592
+ logger.info('Step 6: Assembling processed images...');
593
+ const processedImages = [];
594
+ let failedCount = 0;
595
+
596
+ for (let i = 0; i < images.length; i++) {
597
+ const image = images[i];
598
+ const inspection = inspectionResults[i];
599
+ const searchableText = searchableTexts[i];
600
+ const imageEmbedding = imageEmbeddings[i];
601
+ const textEmbedding = textEmbeddings[i];
602
+
603
+ if (!inspection || !imageEmbedding || !textEmbedding) {
604
+ failedCount++;
605
+ continue;
606
+ }
607
+
608
+ processedImages.push({
609
+ filename: image.filename,
610
+ screenName: inspection.screenName,
611
+ screenshotPath: image.screenshotPath,
612
+ inspection,
613
+ searchableText,
614
+ imageEmbedding,
615
+ textEmbedding,
616
+ });
617
+ }
618
+ logger.success(`Assembled: ${processedImages.length} ok, ${failedCount} failed (${elapsed()})`);
619
+
620
+ // Step 7: Insert into Milvus
621
+ if (processedImages.length > 0) {
622
+ logger.info(`Step 7: Inserting ${processedImages.length} vectors into Milvus...`);
623
+ const insertResult = await insertImageVectors(
624
+ processedImages,
625
+ config.project,
626
+ uploadId,
627
+ {
628
+ address: config.milvusAddress,
629
+ token: config.milvusToken,
630
+ collectionName: config.milvusCollection,
631
+ }
632
+ );
633
+ logger.success(`Milvus insert complete: ${insertResult.insertCount} records (${elapsed()})`);
634
+ } else {
635
+ logger.info('Step 7: No images to insert into Milvus');
636
+ }
637
+
638
+ // Summary
639
+ const status = failedCount === 0
640
+ ? 'completed'
641
+ : processedImages.length > 0
642
+ ? 'partial'
643
+ : 'failed';
644
+
645
+ logger.success(`\n=== Processing Complete (${elapsed()}) ===`);
646
+ logger.info(`Status: ${status}`);
647
+ logger.info(`Processed: ${processedImages.length}/${images.length} images`);
648
+ if (failedCount > 0) logger.info(`Failed: ${failedCount} images`);
649
+ logger.info(`Upload ID: ${uploadId}`);
650
+
651
+ return {
652
+ uploadId,
653
+ projectId: config.project,
654
+ totalImages: images.length,
655
+ processedImages: processedImages.length,
656
+ failedImages: failedCount,
657
+ status,
658
+ };
659
+ }
660
+
661
+ module.exports = {
662
+ runLocalImageProcessing,
663
+ };
package/lib/logger.js CHANGED
@@ -5,7 +5,7 @@ const chalk = require('chalk');
5
5
  * The logger's behavior is controlled by the arguments passed to the CLI.
6
6
  * @param {object} argv The arguments object from yargs.
7
7
  * @param {boolean} argv.verbose Whether to enable verbose (debug) logging.
8
- * @returns {{info: Function, error: Function, debug: Function, success: Function}}
8
+ * @returns {{info: Function, warn: Function, error: Function, debug: Function, success: Function}}
9
9
  */
10
10
  function createLogger({ verbose = false }) {
11
11
  const Sentry = require('@sentry/node');
@@ -31,6 +31,21 @@ function createLogger({ verbose = false }) {
31
31
  console.log(chalk.green(message));
32
32
  },
33
33
 
34
+ /**
35
+ * Logs a warning: something the operator must act on, but which did not
36
+ * fail the command. Distinct from error() so it does not read as a failed
37
+ * deployment.
38
+ * @param {string} message The message to log.
39
+ */
40
+ warn: (message) => {
41
+ console.warn(chalk.yellow(message));
42
+ Sentry.addBreadcrumb({
43
+ category: 'log',
44
+ message: message,
45
+ level: 'warning',
46
+ });
47
+ },
48
+
34
49
  /**
35
50
  * Logs an error message.
36
51
  * @param {string} message The message to log.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scrymore/scry-deployer",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "A CLI to automate the deployment of Storybook static builds.",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -43,6 +43,7 @@
43
43
  "axios": "^1.12.2",
44
44
  "chalk": "^4.1.2",
45
45
  "commander": "^11.1.0",
46
+ "fast-xml-parser": "^5.5.7",
46
47
  "form-data": "^4.0.0",
47
48
  "inquirer": "^8.2.6",
48
49
  "open": "^8.4.2",