@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.
- package/README.md +19 -0
- package/VERSION +1 -0
- package/bin/storewright.mjs +62 -0
- package/contracts/action-registry.json +175 -0
- package/contracts/capability-registry.json +63 -0
- package/contracts/workflow-manifest.json +207 -0
- package/lib/cli/storewright-cli.mjs +259 -0
- package/lib/internal/launch-envelope.mjs +223 -0
- package/lib/internal/multi-agent-contracts.mjs +137 -0
- package/lib/internal/operation-ledger.mjs +190 -0
- package/lib/internal/pricing/default-preview-pricing.mjs +181 -0
- package/lib/internal/run-state-helpers.mjs +313 -0
- package/lib/internal/shopify-operation-adapter.mjs +456 -0
- package/package.json +38 -0
- package/schemas/action-registry.schema.json +11 -0
- package/schemas/agent-report.schema.json +14 -0
- package/schemas/approval-grant.schema.json +16 -0
- package/schemas/base-theme-report.schema.json +25 -0
- package/schemas/brand-identity.schema.json +142 -0
- package/schemas/capability-registry.schema.json +11 -0
- package/schemas/competitor-audit.schema.json +38 -0
- package/schemas/design-direction.schema.json +64 -0
- package/schemas/external-operation.schema.json +34 -0
- package/schemas/intake-blocked-report.schema.json +76 -0
- package/schemas/launch-envelope.schema.json +25 -0
- package/schemas/launch-readiness.schema.json +73 -0
- package/schemas/media-file-inspection-report.schema.json +223 -0
- package/schemas/media-manifest.schema.json +84 -0
- package/schemas/merchandising-brief.schema.json +27 -0
- package/schemas/normalized-product-catalog.schema.json +42 -0
- package/schemas/product-content-generation-input.schema.json +40 -0
- package/schemas/product-content-generation-output.schema.json +43 -0
- package/schemas/raw-product-candidates.schema.json +32 -0
- package/schemas/shopify-access-preflight-report.schema.json +213 -0
- package/schemas/shopify-content-sync-report.schema.json +190 -0
- package/schemas/shopify-media-map.schema.json +87 -0
- package/schemas/shopify-media-upload-report.schema.json +96 -0
- package/schemas/shopify-operation-request.schema.json +81 -0
- package/schemas/shopify-preflight-report.schema.json +187 -0
- package/schemas/store-blueprint.schema.json +112 -0
- package/schemas/store-content-generation-output.schema.json +102 -0
- package/schemas/store-intake.schema.json +205 -0
- package/schemas/store-ops-plan.schema.json +82 -0
- package/schemas/storefront-preview-review.schema.json +227 -0
- package/schemas/supplier-access-report.schema.json +36 -0
- package/schemas/supplier-extraction-report.schema.json +185 -0
- package/schemas/theme-build-report.schema.json +43 -0
- package/schemas/theme-code-change-summary.schema.json +65 -0
- package/schemas/theme-plan.schema.json +26 -0
- package/schemas/theme-push-report.schema.json +151 -0
- package/schemas/theme-workspace-validation-report.schema.json +61 -0
- package/schemas/workflow-manifest.schema.json +29 -0
- package/scripts/audit-run-state.mjs +472 -0
- package/scripts/execute-shopify-operation.mjs +190 -0
- package/scripts/generate-image-assets-openai.mjs +342 -0
- package/scripts/generate-media-assets.mjs +121 -0
- package/scripts/init-run-state.mjs +69 -0
- package/scripts/inspect-media-files.mjs +334 -0
- package/scripts/prepare-launch-envelope.mjs +47 -0
- package/scripts/shopify-access-preflight.mjs +432 -0
- package/scripts/upload-shopify-media.mjs +831 -0
- package/scripts/validate-agent-report.mjs +46 -0
- package/scripts/validate-artifact.mjs +196 -0
- package/scripts/validate-launch-envelope.mjs +50 -0
- package/scripts/validate-registries.mjs +50 -0
- package/scripts/validate-workflow-manifest.mjs +38 -0
- 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
|
+
}
|