@ryanfw/prompt-orchestration-pipeline 0.10.0 → 0.11.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.
@@ -11,8 +11,8 @@
11
11
  />
12
12
  <title>Prompt Pipeline Dashboard</title>
13
13
  <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
14
- <script type="module" crossorigin src="/assets/index-DqkbzXZ1.js"></script>
15
- <link rel="stylesheet" crossorigin href="/assets/style-DBF9NQGk.css">
14
+ <script type="module" crossorigin src="/assets/index-DeDzq-Kk.js"></script>
15
+ <link rel="stylesheet" crossorigin href="/assets/style-aBtD_Yrs.css">
16
16
  </head>
17
17
  <body>
18
18
  <div id="root"></div>
package/src/ui/server.js CHANGED
@@ -12,7 +12,10 @@ import * as state from "./state.js";
12
12
  // Import orchestrator-related functions only in non-test mode
13
13
  let submitJobWithValidation;
14
14
  import { sseRegistry } from "./sse.js";
15
- import { resetJobToCleanSlate } from "../core/status-writer.js";
15
+ import {
16
+ resetJobToCleanSlate,
17
+ initializeJobArtifacts,
18
+ } from "../core/status-writer.js";
16
19
  import { spawn } from "node:child_process";
17
20
  import {
18
21
  getPendingSeedPath,
@@ -23,6 +26,7 @@ import {
23
26
  } from "../config/paths.js";
24
27
  import { handleJobList, handleJobDetail } from "./endpoints/job-endpoints.js";
25
28
  import { generateJobId } from "../utils/id-generator.js";
29
+ import { extractSeedZip } from "./zip-utils.js";
26
30
 
27
31
  // Get __dirname equivalent in ES modules
28
32
  const __filename = fileURLToPath(import.meta.url);
@@ -102,9 +106,14 @@ function hasValidPayload(seed) {
102
106
  * Handle seed upload directly without starting orchestrator (for test environment)
103
107
  * @param {Object} seedObject - Seed object to upload
104
108
  * @param {string} dataDir - Base data directory
109
+ * @param {Array} uploadArtifacts - Array of {filename, content} objects
105
110
  * @returns {Promise<Object>} Result object
106
111
  */
107
- async function handleSeedUploadDirect(seedObject, dataDir) {
112
+ async function handleSeedUploadDirect(
113
+ seedObject,
114
+ dataDir,
115
+ uploadArtifacts = []
116
+ ) {
108
117
  let partialFiles = [];
109
118
 
110
119
  try {
@@ -124,16 +133,39 @@ async function handleSeedUploadDirect(seedObject, dataDir) {
124
133
  return { success: false, message: "Required fields missing" };
125
134
  }
126
135
 
127
- // Validate name format
128
- const nameRegex = /^[a-zA-Z0-9_-]+$/;
129
- if (!nameRegex.test(seedObject.name)) {
136
+ // Validate name format using the same logic as seed validator
137
+ if (
138
+ !seedObject.name ||
139
+ typeof seedObject.name !== "string" ||
140
+ seedObject.name.trim() === ""
141
+ ) {
130
142
  return {
131
143
  success: false,
132
- message:
133
- "name must contain only alphanumeric characters, hyphens, and underscores",
144
+ message: "name field is required",
134
145
  };
135
146
  }
136
147
 
148
+ const trimmedName = seedObject.name.trim();
149
+ if (trimmedName.length > 120) {
150
+ return {
151
+ success: false,
152
+ message: "name must be 120 characters or less",
153
+ };
154
+ }
155
+
156
+ // Allow spaces and common punctuation for better UX
157
+ // Still disallow control characters and path traversal patterns
158
+ const dangerousPattern = /[\x00-\x1f\x7f-\x9f]/;
159
+ if (dangerousPattern.test(trimmedName)) {
160
+ return {
161
+ success: false,
162
+ message: "name must contain only printable characters",
163
+ };
164
+ }
165
+
166
+ // Update seedObject with validated trimmed name
167
+ seedObject.name = trimmedName;
168
+
137
169
  // Generate a random job ID
138
170
  const jobId = generateJobId();
139
171
 
@@ -197,6 +229,16 @@ async function handleSeedUploadDirect(seedObject, dataDir) {
197
229
  JSON.stringify(pipelineSnapshot, null, 2)
198
230
  );
199
231
 
232
+ // Initialize job artifacts if any provided
233
+ if (uploadArtifacts.length > 0) {
234
+ try {
235
+ await initializeJobArtifacts(currentJobDir, uploadArtifacts);
236
+ } catch (artifactError) {
237
+ // Don't fail the upload if artifact initialization fails, just log the error
238
+ console.error("Failed to initialize job artifacts:", artifactError);
239
+ }
240
+ }
241
+
200
242
  return {
201
243
  success: true,
202
244
  jobId,
@@ -298,6 +340,82 @@ function prioritizeJobStatusChange(changes = []) {
298
340
  return statusChange || normalized[0] || null;
299
341
  }
300
342
 
343
+ /**
344
+ * Normalize seed upload from various input formats
345
+ * @param {http.IncomingMessage} req - HTTP request
346
+ * @param {string} contentTypeHeader - Content-Type header
347
+ * @returns {Promise<{seedObject: Object, uploadArtifacts: Array<{filename: string, content: Buffer}>}>}
348
+ */
349
+ async function normalizeSeedUpload({ req, contentTypeHeader }) {
350
+ // Handle application/json uploads
351
+ if (contentTypeHeader.includes("application/json")) {
352
+ const buffer = await readRawBody(req);
353
+ try {
354
+ const seedObject = JSON.parse(buffer.toString("utf8") || "{}");
355
+ return {
356
+ seedObject,
357
+ uploadArtifacts: [{ filename: "seed.json", content: buffer }],
358
+ };
359
+ } catch (error) {
360
+ throw new Error("Invalid JSON");
361
+ }
362
+ }
363
+
364
+ // Handle multipart form data uploads
365
+ const formData = await parseMultipartFormData(req);
366
+ if (!formData.contentBuffer) {
367
+ throw new Error("No file content found");
368
+ }
369
+
370
+ // Check if this is a zip file
371
+ const isZipFile =
372
+ formData.contentType === "application/zip" ||
373
+ formData.filename?.toLowerCase().endsWith(".zip");
374
+
375
+ if (isZipFile) {
376
+ console.log("[UPLOAD] Detected zip upload", {
377
+ filename: formData.filename,
378
+ contentType: formData.contentType,
379
+ bufferSize: formData.contentBuffer.length,
380
+ });
381
+
382
+ // Handle zip upload
383
+ try {
384
+ const { seedObject, artifacts } = await extractSeedZip(
385
+ formData.contentBuffer
386
+ );
387
+ console.log("[UPLOAD] Zip extraction completed", {
388
+ artifactCount: artifacts.length,
389
+ artifactNames: artifacts.map((a) => a.filename),
390
+ seedKeys: Object.keys(seedObject),
391
+ });
392
+ return {
393
+ seedObject,
394
+ uploadArtifacts: artifacts,
395
+ };
396
+ } catch (error) {
397
+ console.log("[UPLOAD] Zip extraction failed", {
398
+ error: error.message,
399
+ filename: formData.filename,
400
+ });
401
+ // Re-throw zip-specific errors with clear messages
402
+ throw new Error(error.message);
403
+ }
404
+ } else {
405
+ // Handle regular JSON file upload
406
+ try {
407
+ const seedObject = JSON.parse(formData.contentBuffer.toString("utf8"));
408
+ const filename = formData.filename || "seed.json";
409
+ return {
410
+ seedObject,
411
+ uploadArtifacts: [{ filename, content: formData.contentBuffer }],
412
+ };
413
+ } catch (error) {
414
+ throw new Error("Invalid JSON");
415
+ }
416
+ }
417
+ }
418
+
301
419
  function broadcastStateUpdate(currentState) {
302
420
  try {
303
421
  const recentChanges = (currentState && currentState.recentChanges) || [];
@@ -365,7 +483,7 @@ function startHeartbeat() {
365
483
  /**
366
484
  * Parse multipart form data
367
485
  * @param {http.IncomingMessage} req - HTTP request
368
- * @returns {Promise<Object>} Parsed form data with file content
486
+ * @returns {Promise<Object>} Parsed form data with file content as Buffer
369
487
  */
370
488
  function parseMultipartFormData(req) {
371
489
  return new Promise((resolve, reject) => {
@@ -394,47 +512,54 @@ function parseMultipartFormData(req) {
394
512
  req.on("end", () => {
395
513
  try {
396
514
  const buffer = Buffer.concat(chunks);
397
- const data = buffer.toString("utf8");
398
- console.log("Raw multipart data length:", data.length);
399
- console.log("Boundary:", JSON.stringify(boundary));
400
515
 
401
- // Simple multipart parsing - look for file field
516
+ // Find file part in the buffer using string operations for headers
517
+ const data = buffer.toString(
518
+ "utf8",
519
+ 0,
520
+ Math.min(buffer.length, 1024 * 1024)
521
+ ); // First MB for header search
402
522
  const parts = data.split(boundary);
403
- console.log("Number of parts:", parts.length);
404
523
 
405
524
  for (let i = 0; i < parts.length; i++) {
406
525
  const part = parts[i];
407
- console.log(`Part ${i} length:`, part.length);
408
- console.log(
409
- `Part ${i} starts with:`,
410
- JSON.stringify(part.substring(0, 50))
411
- );
412
526
 
413
527
  if (part.includes('name="file"') && part.includes("filename")) {
414
- console.log("Found file part at index", i);
415
528
  // Extract filename
416
529
  const filenameMatch = part.match(/filename="([^"]+)"/);
417
- console.log("Filename match:", filenameMatch);
418
530
  if (!filenameMatch) continue;
419
531
 
420
532
  // Extract content type
421
533
  const contentTypeMatch = part.match(/Content-Type:\s*([^\r\n]+)/);
422
- console.log("Content-Type match:", contentTypeMatch);
423
-
424
- // Extract file content (everything after the headers)
425
- const contentStart = part.indexOf("\r\n\r\n") + 4;
426
- const contentEnd = part.lastIndexOf("\r\n");
427
- console.log(
428
- "Content start:",
429
- contentStart,
430
- "Content end:",
431
- contentEnd
534
+
535
+ // Find this specific part's start in the data string
536
+ const partIndexInData = data.indexOf(part);
537
+ const headerEndInPart = part.indexOf("\r\n\r\n");
538
+ if (headerEndInPart === -1) {
539
+ reject(
540
+ new Error("Could not find end of headers in multipart part")
541
+ );
542
+ return;
543
+ }
544
+
545
+ // Calculate the actual byte positions in the buffer for this part
546
+ const headerEndInData = partIndexInData + headerEndInPart + 4;
547
+
548
+ // Use binary buffer to find the next boundary
549
+ const boundaryBuf = Buffer.from(boundary, "ascii");
550
+ const nextBoundaryIndex = buffer.indexOf(
551
+ boundaryBuf,
552
+ headerEndInData
432
553
  );
433
- const fileContent = part.substring(contentStart, contentEnd);
434
- console.log("File content length:", fileContent.length);
435
- console.log(
436
- "File content:",
437
- JSON.stringify(fileContent.substring(0, 100))
554
+ const contentEndInData =
555
+ nextBoundaryIndex !== -1
556
+ ? nextBoundaryIndex - 2 // Subtract 2 for \r\n before boundary
557
+ : buffer.length;
558
+
559
+ // Extract the file content as Buffer
560
+ const contentBuffer = buffer.slice(
561
+ headerEndInData,
562
+ contentEndInData
438
563
  );
439
564
 
440
565
  resolve({
@@ -442,13 +567,12 @@ function parseMultipartFormData(req) {
442
567
  contentType: contentTypeMatch
443
568
  ? contentTypeMatch[1]
444
569
  : "application/octet-stream",
445
- content: fileContent,
570
+ contentBuffer: contentBuffer,
446
571
  });
447
572
  return;
448
573
  }
449
574
  }
450
575
 
451
- console.log("No file field found in form data");
452
576
  reject(new Error("No file field found in form data"));
453
577
  } catch (error) {
454
578
  console.error("Error parsing multipart:", error);
@@ -466,57 +590,64 @@ function parseMultipartFormData(req) {
466
590
  * @param {http.ServerResponse} res - HTTP response
467
591
  */
468
592
  async function handleSeedUpload(req, res) {
593
+ // Add logging at the very start of the upload handler
594
+ console.log("[UPLOAD] Incoming seed upload", {
595
+ method: req.method,
596
+ url: req.url,
597
+ contentType: req.headers["content-type"],
598
+ userAgent: req.headers["user-agent"],
599
+ });
600
+
469
601
  try {
470
602
  const ct = req.headers["content-type"] || "";
471
- let seedObject;
472
- if (ct.includes("application/json")) {
473
- const raw = await readRawBody(req);
474
- try {
475
- seedObject = JSON.parse(raw.toString("utf8") || "{}");
476
- } catch {
477
- res.writeHead(400, { "Content-Type": "application/json" });
478
- res.end(JSON.stringify({ success: false, message: "Invalid JSON" }));
479
- return;
480
- }
481
- } else {
482
- // Parse multipart form data (existing behavior)
483
- const formData = await parseMultipartFormData(req);
484
- if (!formData.content) {
485
- res.writeHead(400, { "Content-Type": "application/json" });
486
- res.end(
487
- JSON.stringify({ success: false, message: "No file content found" })
488
- );
489
- return;
490
- }
491
- try {
492
- seedObject = JSON.parse(formData.content);
493
- } catch {
494
- res.writeHead(400, { "Content-Type": "application/json" });
495
- res.end(JSON.stringify({ success: false, message: "Invalid JSON" }));
496
- return;
603
+
604
+ // Use the new normalization function to handle all upload formats
605
+ let normalizedUpload;
606
+ try {
607
+ normalizedUpload = await normalizeSeedUpload({
608
+ req,
609
+ contentTypeHeader: ct,
610
+ });
611
+ } catch (error) {
612
+ console.log("[UPLOAD] Normalization failed", {
613
+ error: error.message,
614
+ contentType: ct,
615
+ });
616
+
617
+ // Handle specific zip-related errors with appropriate messages
618
+ let errorMessage = error.message;
619
+ if (error.message === "Invalid JSON") {
620
+ errorMessage = "Invalid JSON";
621
+ } else if (error.message === "seed.json not found in zip") {
622
+ errorMessage = "seed.json not found in zip";
497
623
  }
624
+
625
+ res.writeHead(400, { "Content-Type": "application/json" });
626
+ res.end(JSON.stringify({ success: false, message: errorMessage }));
627
+ return;
498
628
  }
499
629
 
630
+ const { seedObject, uploadArtifacts } = normalizedUpload;
631
+
500
632
  // Use current PO_ROOT or fallback to DATA_DIR
501
633
  const currentDataDir = process.env.PO_ROOT || DATA_DIR;
502
634
 
503
635
  // For test environment, use simplified validation without starting orchestrator
504
- console.log("NODE_ENV:", process.env.NODE_ENV);
505
636
  if (process.env.NODE_ENV === "test") {
506
- console.log("Using test mode for seed upload");
507
637
  // Simplified validation for tests - just write to pending directory
508
- const result = await handleSeedUploadDirect(seedObject, currentDataDir);
509
- console.log("handleSeedUploadDirect result:", result);
638
+ const result = await handleSeedUploadDirect(
639
+ seedObject,
640
+ currentDataDir,
641
+ uploadArtifacts
642
+ );
510
643
 
511
644
  // Return appropriate status code based on success
512
645
  if (result.success) {
513
- console.log("Sending 200 response");
514
646
  res.writeHead(200, {
515
647
  "Content-Type": "application/json",
516
648
  Connection: "close",
517
649
  });
518
650
  res.end(JSON.stringify(result));
519
- console.log("Response sent successfully");
520
651
 
521
652
  // Broadcast SSE event for successful upload
522
653
  sseRegistry.broadcast({
@@ -524,17 +655,13 @@ async function handleSeedUpload(req, res) {
524
655
  data: { name: result.jobName },
525
656
  });
526
657
  } else {
527
- console.log("Sending 400 response");
528
658
  res.writeHead(400, {
529
659
  "Content-Type": "application/json",
530
660
  Connection: "close",
531
661
  });
532
662
  res.end(JSON.stringify(result));
533
- console.log("Response sent successfully");
534
663
  }
535
664
  return;
536
- } else {
537
- console.log("Using production mode for seed upload");
538
665
  }
539
666
 
540
667
  // Submit job with validation (for production)
@@ -546,6 +673,7 @@ async function handleSeedUpload(req, res) {
546
673
  const result = await submitJobWithValidation({
547
674
  dataDir: currentDataDir,
548
675
  seedObject,
676
+ uploadArtifacts,
549
677
  });
550
678
 
551
679
  // Send appropriate response
@@ -1082,7 +1210,6 @@ function serveStatic(res, filePath) {
1082
1210
  * Create and start the HTTP server
1083
1211
  */
1084
1212
  function createServer() {
1085
- console.log("Creating HTTP server...");
1086
1213
  const server = http.createServer(async (req, res) => {
1087
1214
  // Use WHATWG URL API instead of deprecated url.parse
1088
1215
  const { pathname, searchParams } = new URL(
@@ -1090,11 +1217,6 @@ function createServer() {
1090
1217
  `http://${req.headers.host}`
1091
1218
  );
1092
1219
 
1093
- // DEBUG: Log all API requests
1094
- if (pathname.startsWith("/api/")) {
1095
- console.log(`DEBUG: API Request: ${req.method} ${pathname}`);
1096
- }
1097
-
1098
1220
  // CORS headers for API endpoints
1099
1221
  if (pathname.startsWith("/api/")) {
1100
1222
  // Important for tests: avoid idle keep-alive sockets on short API calls
@@ -1593,10 +1715,6 @@ function createServer() {
1593
1715
  mode: "clean-slate",
1594
1716
  spawned: true,
1595
1717
  });
1596
-
1597
- console.log(
1598
- `Job ${jobId} restarted successfully, detached runner PID: ${child.pid}`
1599
- );
1600
1718
  } finally {
1601
1719
  // Always end restart guard
1602
1720
  endRestart(jobId);
@@ -1860,13 +1978,6 @@ function start(customPort) {
1860
1978
  */
1861
1979
  async function startServer({ dataDir, port: customPort }) {
1862
1980
  try {
1863
- console.log(
1864
- "DEBUG: startServer called with dataDir:",
1865
- dataDir,
1866
- "customPort:",
1867
- customPort
1868
- );
1869
-
1870
1981
  // Initialize config-bridge paths early to ensure consistent path resolution
1871
1982
  // This prevents path caching issues when dataDir changes between tests
1872
1983
  const { initPATHS } = await import("./config-bridge.node.js");
@@ -1897,8 +2008,6 @@ async function startServer({ dataDir, port: customPort }) {
1897
2008
  ? parseInt(process.env.PORT)
1898
2009
  : 0;
1899
2010
 
1900
- console.log("DEBUG: About to create server...");
1901
-
1902
2011
  // In development, start Vite in middlewareMode so the Node server can serve
1903
2012
  // the client with HMR in a single process. We dynamically import Vite here
1904
2013
  // to avoid including it in production bundles.
@@ -1916,22 +2025,15 @@ async function startServer({ dataDir, port: customPort }) {
1916
2025
  server: { middlewareMode: true },
1917
2026
  appType: "custom",
1918
2027
  });
1919
- console.log("DEBUG: Vite dev server started (middleware mode)");
1920
2028
  } catch (err) {
1921
2029
  console.error("Failed to start Vite dev server:", err);
1922
2030
  viteServer = null;
1923
2031
  }
1924
- } else if (process.env.NODE_ENV === "test") {
1925
- console.log("DEBUG: Vite disabled in test mode (API-only mode)");
1926
- } else if (process.env.DISABLE_VITE === "1") {
1927
- console.log("DEBUG: Vite disabled via DISABLE_VITE=1 (API-only mode)");
1928
2032
  }
1929
2033
 
1930
2034
  const server = createServer();
1931
- console.log("DEBUG: Server created successfully");
1932
2035
 
1933
2036
  // Robust promise with proper error handling and race condition prevention
1934
- console.log(`Attempting to start server on port ${port}...`);
1935
2037
  await new Promise((resolve, reject) => {
1936
2038
  let settled = false;
1937
2039
 
@@ -1955,7 +2057,6 @@ async function startServer({ dataDir, port: customPort }) {
1955
2057
  if (!settled) {
1956
2058
  settled = true;
1957
2059
  server.removeListener("error", errorHandler);
1958
- console.log(`Server successfully started on port ${port}`);
1959
2060
  resolve();
1960
2061
  }
1961
2062
  };
@@ -1981,18 +2082,10 @@ async function startServer({ dataDir, port: customPort }) {
1981
2082
  const address = server.address();
1982
2083
  const baseUrl = `http://localhost:${address.port}`;
1983
2084
 
1984
- console.log(`Server running at ${baseUrl}`);
1985
- if (dataDir) {
1986
- console.log(`Data directory: ${dataDir}`);
1987
- }
1988
-
1989
2085
  // Only initialize watcher and heartbeat in non-test environments
1990
2086
  if (process.env.NODE_ENV !== "test") {
1991
- console.log(`Watching paths: ${WATCHED_PATHS.join(", ")}`);
1992
2087
  initializeWatcher();
1993
2088
  startHeartbeat();
1994
- } else {
1995
- console.log("Server started in test mode - skipping watcher/heartbeat");
1996
2089
  }
1997
2090
 
1998
2091
  return {
@@ -2016,7 +2109,6 @@ async function startServer({ dataDir, port: customPort }) {
2016
2109
  try {
2017
2110
  await viteServer.close();
2018
2111
  viteServer = null;
2019
- console.log("DEBUG: Vite dev server closed");
2020
2112
  } catch (err) {
2021
2113
  console.error("Error closing Vite dev server:", err);
2022
2114
  }
@@ -0,0 +1,103 @@
1
+ import { unzipSync } from "fflate";
2
+ import path from "node:path";
3
+
4
+ /**
5
+ * Extract seed JSON and artifacts from a zip buffer using fflate
6
+ * @param {Buffer|Uint8Array} zipBuffer - Buffer containing zip data
7
+ * @returns {Promise<{seedObject: Object, artifacts: Array<{filename: string, content: Buffer}>}>}
8
+ */
9
+ export async function extractSeedZip(zipBuffer) {
10
+ // Normalize to Uint8Array for fflate
11
+ const zipData = Buffer.isBuffer(zipBuffer)
12
+ ? new Uint8Array(zipBuffer)
13
+ : zipBuffer;
14
+
15
+ console.log("[ZIP] Starting real zip parsing", {
16
+ bufferSize: zipData.length,
17
+ });
18
+
19
+ try {
20
+ // Check if this looks like a valid zip by looking for PK signature
21
+ if (zipData.length < 4 || zipData[0] !== 0x50 || zipData[1] !== 0x4b) {
22
+ throw new Error("Invalid ZIP file signature");
23
+ }
24
+
25
+ // Use fflate to extract all entries
26
+ const entries = unzipSync(zipData);
27
+ const artifacts = [];
28
+ let seedObject = null;
29
+ let seedJsonCount = 0;
30
+
31
+ console.log("[ZIP] Extracted entries from zip", {
32
+ entryCount: Object.keys(entries).length,
33
+ entryNames: Object.keys(entries),
34
+ });
35
+
36
+ // Process each entry
37
+ for (const [entryName, rawContent] of Object.entries(entries)) {
38
+ // Skip directory entries (names ending with /)
39
+ if (entryName.endsWith("/")) {
40
+ console.log("[ZIP] Skipping directory entry", { entryName });
41
+ continue;
42
+ }
43
+
44
+ // Derive filename using basename (flatten directory structure)
45
+ const filename = path.basename(entryName);
46
+ console.log("[ZIP] Processing entry", { entryName, filename });
47
+
48
+ // Convert Uint8Array to Buffer
49
+ const content = Buffer.from(rawContent);
50
+
51
+ // Add to artifacts
52
+ artifacts.push({ filename, content });
53
+
54
+ // Check if this is seed.json
55
+ if (filename === "seed.json") {
56
+ seedJsonCount++;
57
+ try {
58
+ const jsonContent = content.toString("utf8");
59
+ seedObject = JSON.parse(jsonContent);
60
+ console.log("[ZIP] Successfully parsed seed.json", {
61
+ seedName: seedObject.name,
62
+ seedPipeline: seedObject.pipeline,
63
+ });
64
+ } catch (parseError) {
65
+ console.error("[ZIP] Failed to parse seed.json", {
66
+ error: parseError.message,
67
+ filename,
68
+ });
69
+ throw new Error("Invalid JSON");
70
+ }
71
+ }
72
+ }
73
+
74
+ // Validate that we found at least one seed.json
75
+ if (seedJsonCount === 0) {
76
+ throw new Error("seed.json not found in zip");
77
+ }
78
+
79
+ if (seedJsonCount > 1) {
80
+ console.log(
81
+ "[ZIP] Warning: multiple seed.json files found, using last one",
82
+ {
83
+ count: seedJsonCount,
84
+ }
85
+ );
86
+ }
87
+
88
+ console.log("[ZIP] Zip extraction completed", {
89
+ artifactCount: artifacts.length,
90
+ artifactNames: artifacts.map((a) => a.filename),
91
+ seedKeys: seedObject ? Object.keys(seedObject) : [],
92
+ seedJsonCount,
93
+ });
94
+
95
+ return { seedObject, artifacts };
96
+ } catch (error) {
97
+ console.error("[ZIP] Zip extraction failed", {
98
+ error: error.message,
99
+ bufferSize: zipData.length,
100
+ });
101
+ throw error;
102
+ }
103
+ }