@storewright/cli 0.14.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.
Files changed (67) hide show
  1. package/README.md +19 -0
  2. package/VERSION +1 -0
  3. package/bin/storewright.mjs +62 -0
  4. package/contracts/action-registry.json +175 -0
  5. package/contracts/capability-registry.json +63 -0
  6. package/contracts/workflow-manifest.json +207 -0
  7. package/lib/cli/storewright-cli.mjs +259 -0
  8. package/lib/internal/launch-envelope.mjs +223 -0
  9. package/lib/internal/multi-agent-contracts.mjs +137 -0
  10. package/lib/internal/operation-ledger.mjs +190 -0
  11. package/lib/internal/pricing/default-preview-pricing.mjs +181 -0
  12. package/lib/internal/run-state-helpers.mjs +313 -0
  13. package/lib/internal/shopify-operation-adapter.mjs +456 -0
  14. package/package.json +38 -0
  15. package/schemas/action-registry.schema.json +11 -0
  16. package/schemas/agent-report.schema.json +14 -0
  17. package/schemas/approval-grant.schema.json +16 -0
  18. package/schemas/base-theme-report.schema.json +25 -0
  19. package/schemas/brand-identity.schema.json +142 -0
  20. package/schemas/capability-registry.schema.json +11 -0
  21. package/schemas/competitor-audit.schema.json +38 -0
  22. package/schemas/design-direction.schema.json +64 -0
  23. package/schemas/external-operation.schema.json +34 -0
  24. package/schemas/intake-blocked-report.schema.json +76 -0
  25. package/schemas/launch-envelope.schema.json +25 -0
  26. package/schemas/launch-readiness.schema.json +73 -0
  27. package/schemas/media-file-inspection-report.schema.json +223 -0
  28. package/schemas/media-manifest.schema.json +84 -0
  29. package/schemas/merchandising-brief.schema.json +27 -0
  30. package/schemas/normalized-product-catalog.schema.json +42 -0
  31. package/schemas/product-content-generation-input.schema.json +40 -0
  32. package/schemas/product-content-generation-output.schema.json +43 -0
  33. package/schemas/raw-product-candidates.schema.json +32 -0
  34. package/schemas/shopify-access-preflight-report.schema.json +213 -0
  35. package/schemas/shopify-content-sync-report.schema.json +190 -0
  36. package/schemas/shopify-media-map.schema.json +87 -0
  37. package/schemas/shopify-media-upload-report.schema.json +96 -0
  38. package/schemas/shopify-operation-request.schema.json +81 -0
  39. package/schemas/shopify-preflight-report.schema.json +187 -0
  40. package/schemas/store-blueprint.schema.json +112 -0
  41. package/schemas/store-content-generation-output.schema.json +102 -0
  42. package/schemas/store-intake.schema.json +205 -0
  43. package/schemas/store-ops-plan.schema.json +82 -0
  44. package/schemas/storefront-preview-review.schema.json +227 -0
  45. package/schemas/supplier-access-report.schema.json +36 -0
  46. package/schemas/supplier-extraction-report.schema.json +185 -0
  47. package/schemas/theme-build-report.schema.json +43 -0
  48. package/schemas/theme-code-change-summary.schema.json +65 -0
  49. package/schemas/theme-plan.schema.json +26 -0
  50. package/schemas/theme-push-report.schema.json +151 -0
  51. package/schemas/theme-workspace-validation-report.schema.json +61 -0
  52. package/schemas/workflow-manifest.schema.json +29 -0
  53. package/scripts/audit-run-state.mjs +472 -0
  54. package/scripts/execute-shopify-operation.mjs +190 -0
  55. package/scripts/generate-image-assets-openai.mjs +342 -0
  56. package/scripts/generate-media-assets.mjs +121 -0
  57. package/scripts/init-run-state.mjs +69 -0
  58. package/scripts/inspect-media-files.mjs +334 -0
  59. package/scripts/prepare-launch-envelope.mjs +47 -0
  60. package/scripts/shopify-access-preflight.mjs +432 -0
  61. package/scripts/upload-shopify-media.mjs +831 -0
  62. package/scripts/validate-agent-report.mjs +46 -0
  63. package/scripts/validate-artifact.mjs +196 -0
  64. package/scripts/validate-launch-envelope.mjs +50 -0
  65. package/scripts/validate-registries.mjs +50 -0
  66. package/scripts/validate-workflow-manifest.mjs +38 -0
  67. package/scripts/version.mjs +192 -0
@@ -0,0 +1,831 @@
1
+ #!/usr/bin/env node
2
+ import { spawn } from "node:child_process";
3
+ import { existsSync } from "node:fs";
4
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
5
+ import { dirname, isAbsolute, join } from "node:path";
6
+ import { pathToFileURL } from "node:url";
7
+
8
+ const defaultApiVersion = "2026-04";
9
+ const defaultStagedUploadTimeoutMs = 120000;
10
+ const defaultStagedUploadMaxAttempts = 6;
11
+ const defaultStagedUploadRetryDelayMs = 1000;
12
+ const defaultGraphqlCommandMaxAttempts = 3;
13
+ const defaultGraphqlCommandRetryDelayMs = 1000;
14
+
15
+ function parseArgs(argv) {
16
+ const args = {};
17
+ for (let index = 0; index < argv.length; index += 1) {
18
+ const arg = argv[index];
19
+ if (arg === "--run-dir") {
20
+ args.runDir = argv[index + 1];
21
+ index += 1;
22
+ } else if (arg === "--manifest") {
23
+ args.manifestPath = argv[index + 1];
24
+ index += 1;
25
+ } else if (arg === "--store") {
26
+ args.store = argv[index + 1];
27
+ index += 1;
28
+ } else if (arg === "--output-report") {
29
+ args.outputReportPath = argv[index + 1];
30
+ index += 1;
31
+ } else if (arg === "--api-version") {
32
+ args.apiVersion = argv[index + 1];
33
+ index += 1;
34
+ } else if (arg === "--staged-upload-timeout-ms") {
35
+ args.stagedUploadTimeoutMs = Number(argv[index + 1]);
36
+ index += 1;
37
+ } else if (arg === "--staged-upload-max-attempts") {
38
+ args.stagedUploadMaxAttempts = Number(argv[index + 1]);
39
+ index += 1;
40
+ } else if (arg === "--staged-upload-retry-delay-ms") {
41
+ args.stagedUploadRetryDelayMs = Number(argv[index + 1]);
42
+ index += 1;
43
+ }
44
+ }
45
+ return args;
46
+ }
47
+
48
+ function resolveRunPath(runDir, filePath) {
49
+ if (!filePath || isAbsolute(filePath)) return filePath;
50
+ return join(runDir, filePath);
51
+ }
52
+
53
+ function serializeCommand(command, args) {
54
+ return [command, ...args].join(" ");
55
+ }
56
+
57
+ function runBufferedCommand(command, args, options = {}) {
58
+ return new Promise((resolve) => {
59
+ const child = spawn(command, args, options.spawnOptions ?? {});
60
+ let stdout = "";
61
+ let stderr = "";
62
+ let timedOut = false;
63
+ let settled = false;
64
+
65
+ const timeout = setTimeout(() => {
66
+ timedOut = true;
67
+ child.kill("SIGTERM");
68
+ }, options.timeoutMs ?? 300000);
69
+
70
+ const finish = (result) => {
71
+ if (settled) return;
72
+ settled = true;
73
+ clearTimeout(timeout);
74
+ resolve(result);
75
+ };
76
+
77
+ child.stdout.on("data", (chunk) => {
78
+ stdout += chunk;
79
+ if (options.streamOutput) process.stdout.write(chunk);
80
+ });
81
+ child.stderr.on("data", (chunk) => {
82
+ stderr += chunk;
83
+ if (options.streamOutput) process.stderr.write(chunk);
84
+ });
85
+ child.on("error", (error) => {
86
+ stderr = stderr || error.message;
87
+ if (options.streamOutput) process.stderr.write(`${error.message}\n`);
88
+ finish({
89
+ exitCode: typeof error.code === "number" ? error.code : 1,
90
+ stdout,
91
+ stderr,
92
+ timedOut
93
+ });
94
+ });
95
+ child.on("close", (exitCode, signal) => {
96
+ finish({
97
+ exitCode: exitCode ?? 1,
98
+ stdout,
99
+ stderr,
100
+ timedOut: timedOut || signal === "SIGTERM"
101
+ });
102
+ });
103
+ });
104
+ }
105
+
106
+ function defaultRunCommand(command, args, options = {}) {
107
+ return runBufferedCommand(command, args, { ...options, streamOutput: true });
108
+ }
109
+
110
+ function describeError(error) {
111
+ const message = error instanceof Error ? `${error.name}: ${error.message}` : String(error ?? "Unknown error");
112
+ const cause = error?.cause;
113
+ if (!cause) return message;
114
+ if (cause instanceof Error) return `${message}; cause: ${cause.name}: ${cause.message}`;
115
+ if (typeof cause !== "object") return `${message}; cause: ${String(cause)}`;
116
+
117
+ const details = ["code", "errno", "syscall", "hostname", "host", "port", "address"]
118
+ .filter((key) => cause[key] !== undefined)
119
+ .map((key) => `${key}=${cause[key]}`)
120
+ .join(" ");
121
+ return details ? `${message}; cause: ${details}` : `${message}; cause: ${JSON.stringify(cause)}`;
122
+ }
123
+
124
+ async function defaultUploadStagedFile({
125
+ stagedTarget,
126
+ localPath,
127
+ mimeType = "application/octet-stream",
128
+ timeoutMs = defaultStagedUploadTimeoutMs
129
+ }) {
130
+ const args = [
131
+ "--http1.1",
132
+ "--silent",
133
+ "--show-error",
134
+ "--max-time",
135
+ String(Math.max(1, Math.ceil(timeoutMs / 1000))),
136
+ "--write-out",
137
+ "\n%{http_code}",
138
+ "--request",
139
+ "POST"
140
+ ];
141
+ for (const parameter of stagedTarget.parameters ?? []) {
142
+ args.push("--form-string", `${parameter.name}=${parameter.value}`);
143
+ }
144
+ args.push("--form", `file=@${localPath};type=${mimeType}`);
145
+ args.push(stagedTarget.url);
146
+
147
+ const result = await runBufferedCommand("curl", args, { timeoutMs: timeoutMs + 1000 });
148
+ const stdout = result.stdout ?? "";
149
+ const statusMatch = stdout.match(/\n(\d{3})\s*$/);
150
+ const status = statusMatch ? Number(statusMatch[1]) : 0;
151
+ const body = statusMatch ? stdout.slice(0, statusMatch.index) : stdout;
152
+ const stderr = result.stderr || (result.timedOut ? `staged upload timed out after ${timeoutMs}ms` : "");
153
+ return {
154
+ ok: result.exitCode === 0 && status >= 200 && status < 300,
155
+ status,
156
+ stdout: body,
157
+ stderr
158
+ };
159
+ }
160
+
161
+ function defaultWait(ms) {
162
+ return new Promise((resolve) => {
163
+ setTimeout(resolve, ms);
164
+ });
165
+ }
166
+
167
+ function commandStatus(result) {
168
+ if (result.timedOut) return "timeout";
169
+ return result.exitCode === 0 ? "success" : "error";
170
+ }
171
+
172
+ function toAttempt(command, args, result, metadata = {}) {
173
+ return {
174
+ command: serializeCommand(command, args),
175
+ status: commandStatus(result),
176
+ exitCode: result.exitCode ?? 1,
177
+ stdout: result.stdout ?? "",
178
+ stderr: result.stderr ?? "",
179
+ ...metadata
180
+ };
181
+ }
182
+
183
+ function retryableShopifyCliFailure(result) {
184
+ if (!result) return false;
185
+ const evidence = `${result.stderr ?? ""}\n${result.stdout ?? ""}`;
186
+ if (/access denied|required access|missing access|scope|token|login|unauthor/i.test(evidence)) {
187
+ return false;
188
+ }
189
+ if (result.timedOut) return true;
190
+ if (result.exitCode === 0) return false;
191
+ return /network socket|TLS|SSL|ECONN|ETIMEDOUT|ENOTFOUND|EAI_AGAIN|fetch failed|aborted a request|check your internet connection|shopify\/cli\/dist\/index\.js/i.test(evidence);
192
+ }
193
+
194
+ function graphqlTopErrors(json) {
195
+ return Array.isArray(json?.errors) ? json.errors : [];
196
+ }
197
+
198
+ function graphqlPayload(json, operationName) {
199
+ return json?.data?.[operationName] ?? json?.[operationName];
200
+ }
201
+
202
+ function parseJsonObject(text) {
203
+ const raw = String(text ?? "").trim();
204
+ try {
205
+ return JSON.parse(raw);
206
+ } catch {
207
+ let lastParsed = null;
208
+ for (let start = 0; start < raw.length; start += 1) {
209
+ if (raw[start] !== "{") continue;
210
+ let depth = 0;
211
+ let inString = false;
212
+ let escaped = false;
213
+ for (let index = start; index < raw.length; index += 1) {
214
+ const char = raw[index];
215
+ if (inString) {
216
+ if (escaped) {
217
+ escaped = false;
218
+ } else if (char === "\\") {
219
+ escaped = true;
220
+ } else if (char === "\"") {
221
+ inString = false;
222
+ }
223
+ continue;
224
+ }
225
+ if (char === "\"") {
226
+ inString = true;
227
+ } else if (char === "{") {
228
+ depth += 1;
229
+ } else if (char === "}") {
230
+ depth -= 1;
231
+ if (depth === 0) {
232
+ try {
233
+ lastParsed = JSON.parse(raw.slice(start, index + 1));
234
+ } catch {
235
+ // Keep scanning for the final valid JSON object in noisy CLI output.
236
+ }
237
+ start = index;
238
+ break;
239
+ }
240
+ }
241
+ }
242
+ }
243
+ return lastParsed;
244
+ }
245
+ }
246
+
247
+ function parseShopifyJson(result) {
248
+ return parseJsonObject(result.stdout);
249
+ }
250
+
251
+ function safeOperationName(operationName) {
252
+ return String(operationName ?? "operation")
253
+ .replace(/[^A-Za-z0-9_.-]+/g, "-")
254
+ .replace(/^-+|-+$/g, "") || "operation";
255
+ }
256
+
257
+ async function writeGraphqlOperationFiles({ operationFileDir, commandAttempts, operationName, query, variables }) {
258
+ const basename = `${String(commandAttempts.length + 1).padStart(2, "0")}-${safeOperationName(operationName)}`;
259
+ const queryFile = join(operationFileDir, `${basename}.graphql`);
260
+ const variableFile = join(operationFileDir, `${basename}-variables.json`);
261
+ await mkdir(operationFileDir, { recursive: true });
262
+ await writeFile(queryFile, `${query.trim()}\n`);
263
+ await writeFile(variableFile, `${JSON.stringify(variables ?? {}, null, 2)}\n`);
264
+ return { queryFile, variableFile };
265
+ }
266
+
267
+ async function runRecorded({ commandAttempts, runCommand, command, args, options, metadata }) {
268
+ const result = await runCommand(command, args, options);
269
+ commandAttempts.push(toAttempt(command, args, result, metadata));
270
+ return result;
271
+ }
272
+
273
+ async function runGraphql({
274
+ commandAttempts,
275
+ runCommand,
276
+ store,
277
+ apiVersion,
278
+ operationFileDir,
279
+ operationName,
280
+ query,
281
+ variables,
282
+ allowMutations = true,
283
+ wait = defaultWait,
284
+ graphqlCommandMaxAttempts = defaultGraphqlCommandMaxAttempts,
285
+ graphqlCommandRetryDelayMs = defaultGraphqlCommandRetryDelayMs
286
+ }) {
287
+ const args = [
288
+ "store",
289
+ "execute",
290
+ "--store",
291
+ store
292
+ ];
293
+ if (allowMutations) args.push("--allow-mutations");
294
+ if (operationFileDir) {
295
+ const { queryFile, variableFile } = await writeGraphqlOperationFiles({
296
+ operationFileDir,
297
+ commandAttempts,
298
+ operationName,
299
+ query,
300
+ variables
301
+ });
302
+ args.push(
303
+ "--json",
304
+ "--version",
305
+ apiVersion,
306
+ "--query-file",
307
+ queryFile,
308
+ "--variable-file",
309
+ variableFile
310
+ );
311
+ } else {
312
+ args.push(
313
+ "--json",
314
+ "--version",
315
+ apiVersion,
316
+ "--query",
317
+ query,
318
+ "--variables",
319
+ JSON.stringify(variables)
320
+ );
321
+ }
322
+ const maxAttempts = Math.max(1, Number.isFinite(graphqlCommandMaxAttempts) ? graphqlCommandMaxAttempts : defaultGraphqlCommandMaxAttempts);
323
+ const retryDelayMs = Math.max(0, Number.isFinite(graphqlCommandRetryDelayMs) ? graphqlCommandRetryDelayMs : defaultGraphqlCommandRetryDelayMs);
324
+ let result = null;
325
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
326
+ result = await runRecorded({
327
+ commandAttempts,
328
+ runCommand,
329
+ command: "shopify",
330
+ args,
331
+ metadata: maxAttempts > 1 ? { attempt, maxAttempts } : {}
332
+ });
333
+ if (!retryableShopifyCliFailure(result) || attempt >= maxAttempts) return result;
334
+ if (retryDelayMs > 0) await wait(retryDelayMs);
335
+ }
336
+ return result;
337
+ }
338
+
339
+ function stagedUploadsCreateMutation() {
340
+ return `mutation stagedUploadsCreate($input: [StagedUploadInput!]!) {
341
+ stagedUploadsCreate(input: $input) {
342
+ stagedTargets {
343
+ url
344
+ resourceUrl
345
+ parameters {
346
+ name
347
+ value
348
+ }
349
+ }
350
+ userErrors {
351
+ field
352
+ message
353
+ }
354
+ }
355
+ }`;
356
+ }
357
+
358
+ function fileCreateMutation() {
359
+ return `mutation fileCreate($files: [FileCreateInput!]!) {
360
+ fileCreate(files: $files) {
361
+ files {
362
+ id
363
+ fileStatus
364
+ alt
365
+ ... on MediaImage {
366
+ image {
367
+ url
368
+ }
369
+ }
370
+ }
371
+ userErrors {
372
+ field
373
+ message
374
+ }
375
+ }
376
+ }`;
377
+ }
378
+
379
+ function fileNodeQuery() {
380
+ return `query fileNode($id: ID!) {
381
+ node(id: $id) {
382
+ ... on MediaImage {
383
+ id
384
+ fileStatus
385
+ alt
386
+ image {
387
+ url
388
+ }
389
+ }
390
+ }
391
+ }`;
392
+ }
393
+
394
+ function userErrorMessages(errors) {
395
+ return (errors ?? []).map((error) => error.message).filter(Boolean);
396
+ }
397
+
398
+ function collectUserErrors(graphqlUserErrors, errors) {
399
+ for (const error of errors ?? []) {
400
+ graphqlUserErrors.push({
401
+ message: error.message,
402
+ ...(Array.isArray(error.field) ? { field: error.field } : {})
403
+ });
404
+ }
405
+ }
406
+
407
+ function baseAsset(asset) {
408
+ return {
409
+ assetId: asset.assetId,
410
+ ...(asset.slotId ? { slotId: asset.slotId } : {}),
411
+ ...(asset.candidateId ? { candidateId: asset.candidateId } : {}),
412
+ localPath: asset.localPath,
413
+ ...(asset.seoFilename ? { seoFilename: asset.seoFilename } : {}),
414
+ ...(asset.altText ? { altText: asset.altText } : {})
415
+ };
416
+ }
417
+
418
+ function shopifyFileUrl(file) {
419
+ return file?.image?.url || file?.url || null;
420
+ }
421
+
422
+ function uploadMetadataWarning(asset) {
423
+ if (!asset.seoFilename) {
424
+ return "ready-for-upload asset is missing seoFilename";
425
+ }
426
+ if (!/^[a-z0-9]+(?:-[a-z0-9]+)*\.[a-z0-9]+$/.test(asset.seoFilename)) {
427
+ return `seoFilename must be a lowercase hyphenated filename with extension: ${asset.seoFilename}`;
428
+ }
429
+ if (/^image-\d+\.[a-z0-9]+$/.test(asset.seoFilename)) {
430
+ return `seoFilename must be descriptive, not a raw downloaded image filename: ${asset.seoFilename}`;
431
+ }
432
+ if (!String(asset.altText ?? "").trim()) {
433
+ return "ready-for-upload asset is missing altText";
434
+ }
435
+ if (/\bproduct image\s*\d+\b/i.test(asset.altText) || /^image\s*\d+$/i.test(asset.altText)) {
436
+ return `altText must be descriptive, not generic altText: ${asset.altText}`;
437
+ }
438
+ return null;
439
+ }
440
+
441
+ function shopifyIdKey(id) {
442
+ return id?.includes("/MediaImage/") ? "shopifyMediaId" : "shopifyFileId";
443
+ }
444
+
445
+ function stagedTargetContentType(stagedTarget) {
446
+ return (stagedTarget.parameters ?? []).find((parameter) => parameter.name.toLowerCase() === "content-type")?.value;
447
+ }
448
+
449
+ async function resolveMediaImageUrl({
450
+ file,
451
+ commandAttempts,
452
+ graphqlUserErrors,
453
+ runCommand,
454
+ store,
455
+ apiVersion,
456
+ operationFileDir,
457
+ wait,
458
+ pollFileStatusAttempts,
459
+ pollFileStatusDelayMs,
460
+ graphqlCommandMaxAttempts,
461
+ graphqlCommandRetryDelayMs
462
+ }) {
463
+ const immediateUrl = shopifyFileUrl(file);
464
+ if (!file?.id || immediateUrl || !file.id.includes("/MediaImage/")) {
465
+ return { file, shopifyUrl: immediateUrl, warnings: [] };
466
+ }
467
+
468
+ let lastStatus = file.fileStatus;
469
+ for (let attempt = 0; attempt < pollFileStatusAttempts; attempt += 1) {
470
+ if (attempt > 0 && pollFileStatusDelayMs > 0) await wait(pollFileStatusDelayMs);
471
+
472
+ const nodeResult = await runGraphql({
473
+ commandAttempts,
474
+ runCommand,
475
+ store,
476
+ apiVersion,
477
+ operationFileDir,
478
+ operationName: "fileNode",
479
+ query: fileNodeQuery(),
480
+ variables: { id: file.id },
481
+ allowMutations: false,
482
+ wait,
483
+ graphqlCommandMaxAttempts,
484
+ graphqlCommandRetryDelayMs
485
+ });
486
+
487
+ if (nodeResult.exitCode !== 0) {
488
+ return {
489
+ file,
490
+ shopifyUrl: null,
491
+ warnings: [nodeResult.stderr || nodeResult.stdout || "MediaImage URL status check failed"]
492
+ };
493
+ }
494
+
495
+ const nodeJson = parseShopifyJson(nodeResult);
496
+ const nodeTopErrors = graphqlTopErrors(nodeJson);
497
+ collectUserErrors(graphqlUserErrors, nodeTopErrors);
498
+ if (nodeTopErrors.length > 0) {
499
+ return { file, shopifyUrl: null, warnings: userErrorMessages(nodeTopErrors) };
500
+ }
501
+
502
+ const node = graphqlPayload(nodeJson, "node");
503
+ lastStatus = node?.fileStatus ?? lastStatus;
504
+ const nodeUrl = shopifyFileUrl(node);
505
+ if (node?.id && nodeUrl) return { file: node, shopifyUrl: nodeUrl, warnings: [] };
506
+ }
507
+
508
+ return {
509
+ file,
510
+ shopifyUrl: null,
511
+ warnings: [`MediaImage URL was not ready after ${pollFileStatusAttempts} status check(s); last fileStatus: ${lastStatus ?? "unknown"}`]
512
+ };
513
+ }
514
+
515
+ async function uploadReadyAsset({
516
+ asset,
517
+ runDir,
518
+ store,
519
+ apiVersion,
520
+ commandAttempts,
521
+ graphqlUserErrors,
522
+ runCommand,
523
+ operationFileDir,
524
+ uploadStagedFile,
525
+ wait,
526
+ pollFileStatusAttempts,
527
+ pollFileStatusDelayMs,
528
+ stagedUploadTimeoutMs,
529
+ stagedUploadMaxAttempts,
530
+ stagedUploadRetryDelayMs,
531
+ graphqlCommandMaxAttempts,
532
+ graphqlCommandRetryDelayMs
533
+ }) {
534
+ const localPath = resolveRunPath(runDir, asset.localPath);
535
+ const warnings = [];
536
+
537
+ const stagedResult = await runGraphql({
538
+ commandAttempts,
539
+ runCommand,
540
+ store,
541
+ apiVersion,
542
+ operationFileDir,
543
+ operationName: "stagedUploadsCreate",
544
+ query: stagedUploadsCreateMutation(),
545
+ variables: {
546
+ input: [{
547
+ filename: asset.seoFilename,
548
+ mimeType: asset.mimeType ?? "image/webp",
549
+ resource: "IMAGE",
550
+ httpMethod: "POST"
551
+ }]
552
+ },
553
+ wait,
554
+ graphqlCommandMaxAttempts,
555
+ graphqlCommandRetryDelayMs
556
+ });
557
+
558
+ if (stagedResult.exitCode !== 0) {
559
+ warnings.push(stagedResult.stderr || stagedResult.stdout || "stagedUploadsCreate failed");
560
+ return { ...baseAsset(asset), uploadStatus: "error", warnings };
561
+ }
562
+
563
+ const stagedJson = parseShopifyJson(stagedResult);
564
+ const stagedTopErrors = graphqlTopErrors(stagedJson);
565
+ collectUserErrors(graphqlUserErrors, stagedTopErrors);
566
+ if (stagedTopErrors.length > 0) {
567
+ return { ...baseAsset(asset), uploadStatus: "error", warnings: userErrorMessages(stagedTopErrors) };
568
+ }
569
+
570
+ const stagedPayload = graphqlPayload(stagedJson, "stagedUploadsCreate");
571
+ const stagedErrors = stagedPayload?.userErrors ?? [];
572
+ collectUserErrors(graphqlUserErrors, stagedErrors);
573
+ if (stagedErrors.length > 0) {
574
+ return { ...baseAsset(asset), uploadStatus: "error", warnings: userErrorMessages(stagedErrors) };
575
+ }
576
+
577
+ const stagedTarget = stagedPayload?.stagedTargets?.[0];
578
+ if (!stagedTarget?.url || !stagedTarget?.resourceUrl) {
579
+ return { ...baseAsset(asset), uploadStatus: "error", warnings: ["stagedUploadsCreate returned no staged target"] };
580
+ }
581
+
582
+ let uploadResult = null;
583
+ const uploadWarnings = [];
584
+ const maxAttempts = Math.max(1, Number.isFinite(stagedUploadMaxAttempts) ? stagedUploadMaxAttempts : defaultStagedUploadMaxAttempts);
585
+ const retryDelayMs = Math.max(0, Number.isFinite(stagedUploadRetryDelayMs) ? stagedUploadRetryDelayMs : defaultStagedUploadRetryDelayMs);
586
+ const mimeType = asset.mimeType ?? stagedTargetContentType(stagedTarget) ?? "application/octet-stream";
587
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
588
+ try {
589
+ uploadResult = await uploadStagedFile({
590
+ stagedTarget,
591
+ localPath,
592
+ mimeType,
593
+ timeoutMs: stagedUploadTimeoutMs
594
+ });
595
+ } catch (error) {
596
+ uploadResult = {
597
+ ok: false,
598
+ status: 0,
599
+ stdout: "",
600
+ stderr: describeError(error)
601
+ };
602
+ }
603
+ commandAttempts.push({
604
+ command: `POST ${stagedTarget.url}`,
605
+ status: uploadResult.ok ? "success" : "error",
606
+ exitCode: uploadResult.ok ? 0 : uploadResult.status || 1,
607
+ stdout: uploadResult.stdout ?? "",
608
+ stderr: uploadResult.stderr ?? "",
609
+ attempt,
610
+ maxAttempts
611
+ });
612
+ if (uploadResult.ok) break;
613
+ uploadWarnings.push(uploadResult.stderr || "staged upload failed");
614
+ if (attempt < maxAttempts && retryDelayMs > 0) await wait(retryDelayMs);
615
+ }
616
+ if (!uploadResult.ok) {
617
+ return { ...baseAsset(asset), uploadStatus: "error", warnings: uploadWarnings.length > 0 ? uploadWarnings : ["staged upload failed"] };
618
+ }
619
+
620
+ const fileResult = await runGraphql({
621
+ commandAttempts,
622
+ runCommand,
623
+ store,
624
+ apiVersion,
625
+ operationFileDir,
626
+ operationName: "fileCreate",
627
+ query: fileCreateMutation(),
628
+ variables: {
629
+ files: [{
630
+ alt: asset.altText,
631
+ contentType: "IMAGE",
632
+ duplicateResolutionMode: "RAISE_ERROR",
633
+ filename: asset.seoFilename,
634
+ originalSource: stagedTarget.resourceUrl
635
+ }]
636
+ },
637
+ wait,
638
+ graphqlCommandMaxAttempts,
639
+ graphqlCommandRetryDelayMs
640
+ });
641
+
642
+ if (fileResult.exitCode !== 0) {
643
+ return { ...baseAsset(asset), uploadStatus: "error", warnings: [fileResult.stderr || fileResult.stdout || "fileCreate failed"] };
644
+ }
645
+
646
+ const fileJson = parseShopifyJson(fileResult);
647
+ const fileTopErrors = graphqlTopErrors(fileJson);
648
+ collectUserErrors(graphqlUserErrors, fileTopErrors);
649
+ if (fileTopErrors.length > 0) {
650
+ return { ...baseAsset(asset), uploadStatus: "error", warnings: userErrorMessages(fileTopErrors) };
651
+ }
652
+
653
+ const filePayload = graphqlPayload(fileJson, "fileCreate");
654
+ const fileErrors = filePayload?.userErrors ?? [];
655
+ collectUserErrors(graphqlUserErrors, fileErrors);
656
+ if (fileErrors.length > 0) {
657
+ return { ...baseAsset(asset), uploadStatus: "error", warnings: userErrorMessages(fileErrors) };
658
+ }
659
+
660
+ const createdFile = filePayload?.files?.[0];
661
+ const resolvedFile = await resolveMediaImageUrl({
662
+ file: createdFile,
663
+ commandAttempts,
664
+ graphqlUserErrors,
665
+ runCommand,
666
+ store,
667
+ apiVersion,
668
+ operationFileDir,
669
+ wait,
670
+ pollFileStatusAttempts,
671
+ pollFileStatusDelayMs,
672
+ graphqlCommandMaxAttempts,
673
+ graphqlCommandRetryDelayMs
674
+ });
675
+
676
+ if (!resolvedFile.file?.id || !resolvedFile.shopifyUrl) {
677
+ return {
678
+ ...baseAsset(asset),
679
+ uploadStatus: "error",
680
+ warnings: resolvedFile.warnings.length > 0 ? resolvedFile.warnings : ["fileCreate returned no Shopify file ID or URL"]
681
+ };
682
+ }
683
+
684
+ const idKey = shopifyIdKey(resolvedFile.file.id);
685
+ return {
686
+ ...baseAsset(asset),
687
+ [idKey]: resolvedFile.file.id,
688
+ shopifyUrl: resolvedFile.shopifyUrl,
689
+ uploadStatus: "uploaded"
690
+ };
691
+ }
692
+
693
+ export async function uploadShopifyMedia({
694
+ runDir,
695
+ manifestPath,
696
+ store,
697
+ outputReportPath,
698
+ apiVersion = defaultApiVersion,
699
+ runCommand = defaultRunCommand,
700
+ uploadStagedFile = defaultUploadStagedFile,
701
+ wait = defaultWait,
702
+ pollFileStatusAttempts = 6,
703
+ pollFileStatusDelayMs = 5000,
704
+ stagedUploadTimeoutMs = defaultStagedUploadTimeoutMs,
705
+ stagedUploadMaxAttempts = defaultStagedUploadMaxAttempts,
706
+ stagedUploadRetryDelayMs = defaultStagedUploadRetryDelayMs,
707
+ graphqlCommandMaxAttempts = defaultGraphqlCommandMaxAttempts,
708
+ graphqlCommandRetryDelayMs = defaultGraphqlCommandRetryDelayMs
709
+ }) {
710
+ if (!runDir) throw new Error("Missing --run-dir argument");
711
+ if (!manifestPath) throw new Error("Missing --manifest argument");
712
+ if (!store) throw new Error("Missing --store argument");
713
+
714
+ const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
715
+ const resolvedOutputPath = outputReportPath ?? join(runDir, "shopify-media-upload-report.json");
716
+ const operationFileDir = join(dirname(resolvedOutputPath), "shopify-media-upload-graphql");
717
+ const commandAttempts = [];
718
+ const graphqlUserErrors = [];
719
+ const uploadedAssets = [];
720
+ const readySeoFilenameCounts = new Map();
721
+ for (const asset of manifest.media?.assets ?? []) {
722
+ if (asset.status === "ready-for-upload" && asset.seoFilename) {
723
+ const key = asset.seoFilename.toLowerCase();
724
+ readySeoFilenameCounts.set(key, (readySeoFilenameCounts.get(key) ?? 0) + 1);
725
+ }
726
+ }
727
+
728
+ for (const asset of manifest.media?.assets ?? []) {
729
+ if (asset.status !== "ready-for-upload") {
730
+ uploadedAssets.push({
731
+ ...baseAsset(asset),
732
+ uploadStatus: "skipped",
733
+ warnings: [`asset status is ${asset.status ?? "unknown"}, not ready-for-upload`]
734
+ });
735
+ continue;
736
+ }
737
+
738
+ if (/^https?:\/\//i.test(asset.localPath ?? "")) {
739
+ uploadedAssets.push({
740
+ ...baseAsset(asset),
741
+ uploadStatus: "error",
742
+ warnings: ["localPath must be a run-local file path; raw supplier URL cannot be final Shopify media"]
743
+ });
744
+ continue;
745
+ }
746
+
747
+ const metadataWarning = uploadMetadataWarning(asset);
748
+ if (metadataWarning) {
749
+ uploadedAssets.push({
750
+ ...baseAsset(asset),
751
+ uploadStatus: "error",
752
+ warnings: [metadataWarning]
753
+ });
754
+ continue;
755
+ }
756
+
757
+ if (readySeoFilenameCounts.get(asset.seoFilename.toLowerCase()) > 1) {
758
+ uploadedAssets.push({
759
+ ...baseAsset(asset),
760
+ uploadStatus: "error",
761
+ warnings: [`duplicate seoFilename among ready-for-upload assets: ${asset.seoFilename}`]
762
+ });
763
+ continue;
764
+ }
765
+
766
+ const localPath = resolveRunPath(runDir, asset.localPath);
767
+ if (!existsSync(localPath)) {
768
+ uploadedAssets.push({
769
+ ...baseAsset(asset),
770
+ uploadStatus: "error",
771
+ warnings: [`localPath does not exist: ${asset.localPath}`]
772
+ });
773
+ continue;
774
+ }
775
+
776
+ uploadedAssets.push(await uploadReadyAsset({
777
+ asset,
778
+ runDir,
779
+ store,
780
+ apiVersion,
781
+ commandAttempts,
782
+ graphqlUserErrors,
783
+ runCommand,
784
+ operationFileDir,
785
+ uploadStagedFile,
786
+ wait,
787
+ pollFileStatusAttempts,
788
+ pollFileStatusDelayMs,
789
+ stagedUploadTimeoutMs,
790
+ stagedUploadMaxAttempts,
791
+ stagedUploadRetryDelayMs,
792
+ graphqlCommandMaxAttempts,
793
+ graphqlCommandRetryDelayMs
794
+ }));
795
+ }
796
+
797
+ const report = {
798
+ schemaVersion: "1.0.0",
799
+ runId: manifest.runId,
800
+ authoring: {
801
+ mode: "worker-authored",
802
+ createdBy: "shopify-media-upload-worker",
803
+ sourceInputArtifacts: ["media-manifest.json"]
804
+ },
805
+ shopifyMediaUpload: {
806
+ targetStore: store,
807
+ apiVersion,
808
+ commandAttempts,
809
+ graphqlUserErrors,
810
+ assets: uploadedAssets
811
+ }
812
+ };
813
+
814
+ await mkdir(dirname(resolvedOutputPath), { recursive: true });
815
+ await writeFile(resolvedOutputPath, `${JSON.stringify(report, null, 2)}\n`);
816
+ return { outputReportPath: resolvedOutputPath, assetCount: uploadedAssets.length };
817
+ }
818
+
819
+ async function main() {
820
+ try {
821
+ const result = await uploadShopifyMedia(parseArgs(process.argv.slice(2)));
822
+ console.log(JSON.stringify(result));
823
+ } catch (error) {
824
+ console.error(error.message);
825
+ process.exitCode = 1;
826
+ }
827
+ }
828
+
829
+ if (import.meta.url === pathToFileURL(process.argv[1]).href) {
830
+ await main();
831
+ }