@firenet-designs/fnd-cli 2.4.0 → 2.7.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 (60) hide show
  1. package/README.md +194 -57
  2. package/bin/dev.js +1 -1
  3. package/dist/commands/alt-text.d.ts +105 -0
  4. package/dist/commands/alt-text.js +616 -0
  5. package/dist/commands/backfill-project.js +1 -1
  6. package/dist/commands/create-project.js +48 -5
  7. package/dist/commands/workspace/index.d.ts +19 -2
  8. package/dist/commands/workspace/index.js +171 -56
  9. package/dist/lib/alt-text.d.ts +87 -0
  10. package/dist/lib/alt-text.js +196 -0
  11. package/dist/lib/image-filter.d.ts +43 -0
  12. package/dist/lib/image-filter.js +71 -0
  13. package/dist/lib/mcp/bracket-args.d.ts +37 -0
  14. package/dist/lib/mcp/bracket-args.js +65 -0
  15. package/dist/lib/mcp/define-tool.d.ts +52 -0
  16. package/dist/lib/mcp/define-tool.js +2 -0
  17. package/dist/lib/mcp/registry.d.ts +38 -0
  18. package/dist/lib/mcp/registry.js +98 -0
  19. package/dist/lib/mcp/server.d.ts +66 -0
  20. package/dist/lib/mcp/server.js +176 -0
  21. package/dist/lib/mcp/tools/shopify-common.d.ts +139 -0
  22. package/dist/lib/mcp/tools/shopify-common.js +167 -0
  23. package/dist/lib/mcp/tools/shopify-execute.d.ts +2 -0
  24. package/dist/lib/mcp/tools/shopify-execute.js +105 -0
  25. package/dist/lib/mcp/tools/shopify-file-delete.d.ts +2 -0
  26. package/dist/lib/mcp/tools/shopify-file-delete.js +49 -0
  27. package/dist/lib/mcp/tools/shopify-file-replace.d.ts +2 -0
  28. package/dist/lib/mcp/tools/shopify-file-replace.js +79 -0
  29. package/dist/lib/mcp/tools/shopify-file-search.d.ts +2 -0
  30. package/dist/lib/mcp/tools/shopify-file-search.js +199 -0
  31. package/dist/lib/mcp/tools/shopify-file-upload.d.ts +2 -0
  32. package/dist/lib/mcp/tools/shopify-file-upload.js +76 -0
  33. package/dist/lib/shopify/graphql/AccessScopes.graphql +7 -0
  34. package/dist/lib/shopify/graphql/CurrentBulkOperation.graphql +8 -0
  35. package/dist/lib/shopify/graphql/FileCreate.graphql +25 -0
  36. package/dist/lib/shopify/graphql/FileDelete.graphql +11 -0
  37. package/dist/lib/shopify/graphql/FileReplace.graphql +26 -0
  38. package/dist/lib/shopify/graphql/FileStatus.graphql +19 -0
  39. package/dist/lib/shopify/graphql/FilesBulkQuery.graphql +27 -0
  40. package/dist/lib/shopify/graphql/ProductsBulkQuery.graphql +27 -0
  41. package/dist/lib/shopify/graphql/SearchFiles.graphql +36 -0
  42. package/dist/lib/shopify/graphql/StagedUploadsCreate.graphql +20 -0
  43. package/dist/lib/shopify/graphql/StartBulkQuery.graphql +16 -0
  44. package/dist/lib/shopify/graphql/UpdateFileAlt.graphql +9 -0
  45. package/dist/lib/shopify/shopify.d.ts +228 -0
  46. package/dist/lib/shopify/shopify.js +662 -0
  47. package/dist/lib/webflow.d.ts +80 -0
  48. package/dist/lib/webflow.js +122 -0
  49. package/dist/lib/workspace.d.ts +29 -10
  50. package/dist/lib/workspace.js +74 -39
  51. package/oclif.manifest.json +162 -78
  52. package/package.json +21 -10
  53. package/dist/commands/workspace/cleanup.d.ts +0 -14
  54. package/dist/commands/workspace/cleanup.js +0 -84
  55. package/dist/hooks/init/check-for-updates.d.ts +0 -3
  56. package/dist/hooks/init/check-for-updates.js +0 -15
  57. package/dist/lib/kv-flag.d.ts +0 -15
  58. package/dist/lib/kv-flag.js +0 -75
  59. package/dist/lib/rpc.d.ts +0 -69
  60. package/dist/lib/rpc.js +0 -313
@@ -0,0 +1,662 @@
1
+ /**
2
+ * Shopify support for `fnd alt-text`.
3
+ *
4
+ * The Webflow path talks to the Data API directly with a bearer token we hold.
5
+ * The Shopify path deliberately does NOT: it shells out to the user's own
6
+ * Shopify CLI (`shopify store execute` / `shopify store auth`), which owns the
7
+ * OAuth session and the token. We never see, store, or print a credential —
8
+ * which is exactly why `alt-text` takes no --api-key for Shopify.
9
+ *
10
+ * Files (the Shopify asset library) are read and captioned through Admin
11
+ * GraphQL, run via `shopify store execute --json`. GraphQL lives under graphql/,
12
+ * never string-interpolated here: named operations (one per file — see docPath)
13
+ * go to the CLI with --query-file and their parameters with --variables, while
14
+ * bulk query bodies (readBulkQuery) are handed to StartBulkQuery as a variable.
15
+ * Both the file list and the product→image map are read with bulk operations —
16
+ * one streamed JSONL each, no pagination and no per-minute throttle.
17
+ *
18
+ * Every CLI call has stderr routed to /dev/null (stdio 'ignore') on purpose:
19
+ * with --json the meaningful output is on stdout, and the CLI's progress/spinner
20
+ * chatter on stderr would otherwise corrupt what we try to JSON.parse.
21
+ */
22
+ import { spawn, spawnSync } from 'node:child_process';
23
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
24
+ import { tmpdir } from 'node:os';
25
+ import { basename, join } from 'node:path';
26
+ import { fileURLToPath } from 'node:url';
27
+ /**
28
+ * The scopes the run needs: list files (read) and set their alt text (write),
29
+ * plus read products so a file can be captioned with the context of the product
30
+ * it's attached to. Nothing here should grow without a matching need — the point
31
+ * of asking for the least is that a client store grants the least.
32
+ */
33
+ export const REQUIRED_SCOPES = ['read_files', 'write_files', 'read_products'];
34
+ /**
35
+ * A --dry run only lists the files to count them — it never writes alt text and
36
+ * never maps product context — so read_files is the whole ask. Keeping this
37
+ * minimal means a dry run works against a store that only ever granted the read
38
+ * scope, and doesn't provoke an auth prompt for write access it won't use.
39
+ */
40
+ export const DRY_RUN_SCOPES = ['read_files'];
41
+ /**
42
+ * Writing to the store's asset library — uploading, replacing a file's bytes in
43
+ * place, or deleting a file — only needs write access to files. `write_files`
44
+ * subsumes `read_files` (see missingScopes), so this single scope is the whole
45
+ * ask — the least a store has to grant for any of the Shopify `--with-tool` tools.
46
+ */
47
+ export const FILE_WRITE_SCOPES = ['write_files'];
48
+ /**
49
+ * The `shopify-file-search` workspace tool only reads file metadata, so
50
+ * `read_files` is its whole ask — the least a store grants for a read-only
51
+ * search, and (unlike the write tools) it never provokes a write-scope prompt.
52
+ */
53
+ export const FILE_READ_SCOPES = ['read_files'];
54
+ /**
55
+ * Which of `required` the installation is still missing, given the scopes it
56
+ * already `granted`. A write scope subsumes its read counterpart — a store that
57
+ * granted `write_files` can read files too — so `write_files` satisfies a
58
+ * `read_files` requirement without read_files being listed separately.
59
+ */
60
+ export const missingScopes = (granted, required) => required.filter((scope) => {
61
+ if (granted.includes(scope))
62
+ return false;
63
+ // `read_x` is implied by `write_x`; nothing implies a write scope.
64
+ return !(scope.startsWith('read_') && granted.includes(scope.replace('read_', 'write_')));
65
+ });
66
+ /**
67
+ * Absolute path to a bundled GraphQL document.
68
+ *
69
+ * Every document lives in the sibling graphql/ folder, resolved relative to this
70
+ * module — dist/lib/shopify under ts-node and when built, since the build copies
71
+ * graphql/ alongside the compiled JS. The document name and the file's basename
72
+ * are kept identical so a caller naming `'Files…'` always gets that file.
73
+ */
74
+ const docPath = (name) => fileURLToPath(new URL(`graphql/${name}.graphql`, import.meta.url));
75
+ /**
76
+ * The text of a bulk query body, cached after the first read. Unlike a named
77
+ * operation (handed to the CLI as a --query-file path), a bulk query body is
78
+ * passed by value as the $query variable, so its contents are read here.
79
+ */
80
+ const bulkQueryCache = new Map();
81
+ const readBulkQuery = (name) => {
82
+ let query = bulkQueryCache.get(name);
83
+ if (query === undefined) {
84
+ query = readFileSync(docPath(name), 'utf8');
85
+ bulkQueryCache.set(name, query);
86
+ }
87
+ return query;
88
+ };
89
+ /** `mystore`, `mystore.myshopify.com`, or `https://mystore.myshopify.com/` -> `mystore.myshopify.com`. */
90
+ export const normalizeStore = (raw) => {
91
+ const host = raw
92
+ .trim()
93
+ .replace(/^https?:\/\//, '')
94
+ .replace(/\/.*$/, '');
95
+ return host.endsWith('.myshopify.com') ? host : `${host}.myshopify.com`;
96
+ };
97
+ const executeArgs = (store, operation, { mutate = false, variables }) => {
98
+ const args = ['store', 'execute', '--store', store, '--json', '--query-file', docPath(operation)];
99
+ if (variables)
100
+ args.push('--variables', JSON.stringify(variables));
101
+ if (mutate)
102
+ args.push('--allow-mutations');
103
+ return args;
104
+ };
105
+ /** Turn a finished CLI run into the operation's `data`, or throw with the reason. */
106
+ const parseExecuteResult = (code, stdout, stderr) => {
107
+ if (code !== 0) {
108
+ // The CLI prints the real reason (auth, throttling, an invalid query) to
109
+ // stderr; fall back to stdout, then to nothing, so the message is useful.
110
+ const detail = (stderr || stdout || '').trim();
111
+ throw new Error(`shopify store execute failed (exit ${code ?? 'unknown'})${detail ? `: ${detail.slice(0, 500)}` : ''}`);
112
+ }
113
+ const json = JSON.parse(stdout);
114
+ if (json.errors)
115
+ throw new Error(`Shopify GraphQL error: ${JSON.stringify(json.errors).slice(0, 300)}`);
116
+ return json.data ?? json;
117
+ };
118
+ const execute = (bin, store, operation, opts = {}) => {
119
+ const result = spawnSync(bin, executeArgs(store, operation, opts), {
120
+ encoding: 'utf8',
121
+ // A page of file records is small, but headroom costs nothing and a
122
+ // truncated buffer would surface as an unhelpful JSON parse error.
123
+ maxBuffer: 64 * 1024 * 1024,
124
+ stdio: ['ignore', 'pipe', 'pipe'],
125
+ });
126
+ return parseExecuteResult(result.status, result.stdout ?? '', result.stderr ?? '');
127
+ };
128
+ const spawnAsync = (command, args, options = {}) => new Promise((resolve, reject) => {
129
+ const child = spawn(command, args, options);
130
+ let stdout = '';
131
+ let stderr = '';
132
+ child.stdout?.setEncoding('utf8').on('data', (chunk) => {
133
+ stdout += chunk;
134
+ });
135
+ child.stderr?.setEncoding('utf8').on('data', (chunk) => {
136
+ stderr += chunk;
137
+ });
138
+ child.on('error', (error) => reject(error));
139
+ child.on('close', (status, signal) => {
140
+ resolve({ signal, status, stderr, stdout });
141
+ });
142
+ });
143
+ /**
144
+ * Same as `execute`, but non-blocking — the CLI runs in a real child process and
145
+ * we await its exit rather than parking the event loop on spawnSync. Used on any
146
+ * path that runs while an ora spinner is up (the bulk-status poll), so the
147
+ * spinner keeps animating.
148
+ */
149
+ const executeAsync = async (bin, store, operation, opts = {}) => {
150
+ const result = await spawnAsync(bin, executeArgs(store, operation, opts), { stdio: ['ignore', 'pipe', 'pipe'] });
151
+ return parseExecuteResult(result.status, result.stdout, result.stderr);
152
+ };
153
+ /**
154
+ * Run an ARBITRARY Admin GraphQL document through the user's Shopify CLI and hand
155
+ * back the CLI's raw JSON output as text. This backs the `shopify-execute`
156
+ * workspace tool, whose whole point is to let the AI run a query it composed —
157
+ * unlike `execute`, the document isn't one of our bundled named operations, so it
158
+ * can't go through `--query-file <bundled>`; instead it's written to a throwaway
159
+ * temp file the CLI reads (and which we always delete). `store execute` refuses
160
+ * mutations unless `--allow-mutations`, so a mutation must set `mutate: true`.
161
+ *
162
+ * Deliberately never throws: the AI needs to SEE the failure text to fix its own
163
+ * query, so a non-zero exit or a GraphQL `errors` payload comes back as
164
+ * `{ ok: false, output }` (the message / the response body) rather than an
165
+ * exception. On success `output` is the response JSON verbatim.
166
+ */
167
+ export const runStoreExecute = (bin, store, opts) => {
168
+ const dir = mkdtempSync(join(tmpdir(), 'fnd-shopify-exec-'));
169
+ const file = join(dir, 'operation.graphql');
170
+ try {
171
+ writeFileSync(file, opts.query);
172
+ const args = ['store', 'execute', '--store', store, '--json', '--query-file', file];
173
+ if (opts.variables)
174
+ args.push('--variables', JSON.stringify(opts.variables));
175
+ if (opts.mutate)
176
+ args.push('--allow-mutations');
177
+ const result = spawnSync(bin, args, {
178
+ encoding: 'utf8',
179
+ maxBuffer: 64 * 1024 * 1024,
180
+ stdio: ['ignore', 'pipe', 'pipe'],
181
+ });
182
+ const stdout = (result.stdout ?? '').trim();
183
+ const stderr = (result.stderr ?? '').trim();
184
+ if (result.status !== 0) {
185
+ // Auth, an invalid query, a missing scope: the CLI puts the reason on
186
+ // stderr (stdout as a fallback). Hand it straight back so the AI can react.
187
+ const detail = stderr || stdout;
188
+ return { ok: false, output: `shopify store execute failed (exit ${result.status ?? 'unknown'})${detail ? `:\n${detail}` : ''}` };
189
+ }
190
+ // A clean exit can still carry GraphQL `errors` (a bad field, a scope the
191
+ // installation lacks); flag those as not-ok while returning the body so the
192
+ // AI sees the message, matching how a real error reads.
193
+ try {
194
+ const json = JSON.parse(stdout);
195
+ if (json && typeof json === 'object' && json.errors)
196
+ return { ok: false, output: stdout };
197
+ }
198
+ catch {
199
+ // Not JSON (unusual with --json) — return whatever the CLI printed as-is.
200
+ }
201
+ return { ok: true, output: stdout || '(no output)' };
202
+ }
203
+ finally {
204
+ rmSync(dir, { force: true, recursive: true });
205
+ }
206
+ };
207
+ /**
208
+ * The scopes the store's app installation currently grants.
209
+ *
210
+ * `authenticated` is false when `shopify store execute` exits non-zero — the
211
+ * task's contract for "this store needs `shopify store auth`". A store that IS
212
+ * authenticated but is missing a scope still comes back authenticated, just with
213
+ * that scope absent from the list, so the caller can tell the two apart.
214
+ */
215
+ export const getInstalledScopes = (bin, store) => {
216
+ const result = spawnSync(bin, ['store', 'execute', '--store', store, '--json', '--query-file', docPath('AccessScopes')], {
217
+ encoding: 'utf8',
218
+ stdio: ['ignore', 'pipe', 'ignore'],
219
+ });
220
+ if (result.status !== 0)
221
+ return { authenticated: false, scopes: [] };
222
+ try {
223
+ const json = JSON.parse(result.stdout);
224
+ const scopes = (json.data ?? json).currentAppInstallation?.accessScopes ?? [];
225
+ return { authenticated: true, scopes: scopes.map((scope) => scope.handle) };
226
+ }
227
+ catch {
228
+ // Authenticated (exit 0) but unparseable output — treat scopes as unknown
229
+ // rather than crash; the caller will just re-run auth to be safe.
230
+ return { authenticated: true, scopes: [] };
231
+ }
232
+ };
233
+ /**
234
+ * Grant `scopes` to the store via `shopify store auth`. This both authenticates
235
+ * a store that never was and adds any missing scopes to one that already is, so
236
+ * the caller always passes the full required set.
237
+ *
238
+ * stdin/stdout are inherited so the user can complete the browser/login flow the
239
+ * CLI may open; only stderr is suppressed, matching the task's `2> /dev/null`.
240
+ * Returns whether the CLI exited cleanly.
241
+ */
242
+ export const authenticate = (bin, store, scopes) => {
243
+ const result = spawnSync(bin, ['store', 'auth', '--json', '--store', store, '--scopes', scopes.join(',')], {
244
+ stdio: ['inherit', 'inherit', 'ignore'],
245
+ });
246
+ return result.status === 0;
247
+ };
248
+ /**
249
+ * Every image in the store's asset library, streamed from a single **bulk
250
+ * operation** rather than paginated — one JSONL export, no per-minute throttle,
251
+ * however large the library.
252
+ *
253
+ * `media_type:IMAGE` keeps videos, 3D models and generic files out. `alt` and
254
+ * `id` are on the File interface; the CDN `url`, `mimeType`, dimensions and
255
+ * `originalSource.fileSize` are on the MediaImage. The metadata rides along so a
256
+ * --dry run can filter without downloading (see ShopifyImageFile). A bulk query
257
+ * takes no variables, so — unlike the old paged query — it can't @skip those
258
+ * fields for a real run; they're a few scalars per row in a stream a real run
259
+ * ignores anyway (it measures the bytes it downloads instead).
260
+ *
261
+ * @yields each image file.
262
+ */
263
+ export async function* getImageFiles(bin, store) {
264
+ const jsonl = await runBulkQuery(bin, store, readBulkQuery('FilesBulkQuery'));
265
+ if (!jsonl)
266
+ return;
267
+ // Bulk output is one JSON object per line. The files connection has no nested
268
+ // connections in this query, so every line is a whole file node — `image` and
269
+ // `originalSource` are inline objects, not __parentId child rows.
270
+ for (const raw of jsonl.split('\n')) {
271
+ if (!raw.trim())
272
+ continue;
273
+ const node = JSON.parse(raw);
274
+ const url = node.image?.url;
275
+ // A MediaImage still processing has no url yet; skip it rather than fail.
276
+ if (url)
277
+ yield {
278
+ alt: node.alt ?? null,
279
+ fileSize: node.originalSource?.fileSize ?? null,
280
+ height: node.image?.height ?? null,
281
+ id: node.id,
282
+ mimeType: node.mimeType ?? null,
283
+ name: fileName(url),
284
+ url,
285
+ width: node.image?.width ?? null,
286
+ };
287
+ }
288
+ }
289
+ /**
290
+ * One `files` connection node → its metadata, or null when the file has no url
291
+ * yet (still processing). MediaImage carries image.url + dimensions +
292
+ * originalSource.fileSize; GenericFile carries url + originalFileSize.
293
+ */
294
+ const toFileMeta = (node) => {
295
+ const url = node.image?.url ?? node.url ?? undefined;
296
+ if (!url)
297
+ return null;
298
+ return {
299
+ alt: node.alt ?? null,
300
+ fileSize: node.originalSource?.fileSize ?? node.originalFileSize ?? null,
301
+ height: node.image?.height ?? null,
302
+ id: node.id,
303
+ mimeType: node.mimeType ?? null,
304
+ name: fileName(url),
305
+ url,
306
+ width: node.image?.width ?? null,
307
+ };
308
+ };
309
+ export async function* searchFiles(bin, store, opts) {
310
+ const first = opts.pageSize ?? 250;
311
+ let after = null;
312
+ for (;;) {
313
+ const variables = { after, first, query: opts.query, reverse: opts.reverse, sortKey: opts.sortKey };
314
+ // eslint-disable-next-line no-await-in-loop -- sequential cursor pagination
315
+ const data = (await executeAsync(bin, store, 'SearchFiles', { variables }));
316
+ for (const { node } of data.files?.edges ?? []) {
317
+ const meta = toFileMeta(node);
318
+ if (meta)
319
+ yield meta;
320
+ }
321
+ const pageInfo = data.files?.pageInfo;
322
+ if (!pageInfo?.hasNextPage || !pageInfo.endCursor)
323
+ return;
324
+ after = pageInfo.endCursor;
325
+ }
326
+ }
327
+ /** How long to wait between polls of the bulk export's status. */
328
+ const BULK_POLL_MS = 2000;
329
+ /** Give up on the bulk export after this long rather than poll forever. */
330
+ const BULK_TIMEOUT_MS = 5 * 60 * 1000;
331
+ const sleep = (ms) => new Promise((resolve) => {
332
+ setTimeout(resolve, ms);
333
+ });
334
+ /**
335
+ * Run one bulk query to completion and hand back its JSONL result (null when the
336
+ * export produced nothing to download). Starting a bulk query counts as a
337
+ * mutation to the CLI, so StartBulkQuery opts into --allow-mutations even though
338
+ * it only reads. A store runs at most one bulk query at a time, so the two
339
+ * callers (the file list and the product map) invoke this sequentially.
340
+ */
341
+ const runBulkQuery = async (bin, store, query) => {
342
+ // executeAsync (not execute) throughout: this whole flow runs under a spinner,
343
+ // so it must never block the event loop.
344
+ const started = (await executeAsync(bin, store, 'StartBulkQuery', { mutate: true, variables: { query } }));
345
+ const errors = started.bulkOperationRunQuery?.userErrors ?? [];
346
+ if (errors.length > 0)
347
+ throw new Error(`Could not start the bulk export: ${errors.map((e) => e.message).join('; ')}`);
348
+ const url = await waitForBulkQuery(bin, store);
349
+ return url ? downloadText(url) : null;
350
+ };
351
+ /**
352
+ * Map every product image to the product it belongs to, so a file can be
353
+ * captioned knowing it's (say) snowboard wax rather than a candle.
354
+ *
355
+ * The public Admin API has no field on a MediaImage pointing back to its
356
+ * product, so the relationship is walked from the product side via a **bulk
357
+ * operation** (see ProductsBulkQuery): the whole catalog streams into one JSONL
358
+ * file, which is the right tool when the map has to cover every product at once.
359
+ * The result is folded into a `{mediaImageId -> product}` lookup.
360
+ *
361
+ * An image shared by several products keeps the first product that claims it —
362
+ * a corner case whose only cost is a slightly-off hint, never a wrong caption.
363
+ */
364
+ export const getImageProductContext = async (bin, store) => {
365
+ const jsonl = await runBulkQuery(bin, store, readBulkQuery('ProductsBulkQuery'));
366
+ // A null result means the export finished with nothing to download (no products).
367
+ return jsonl ? parseProductJsonl(jsonl) : new Map();
368
+ };
369
+ /** Poll the query bulk operation until it finishes, returning its result URL. */
370
+ const waitForBulkQuery = async (bin, store) => {
371
+ const deadline = Date.now() + BULK_TIMEOUT_MS;
372
+ for (;;) {
373
+ // Async so the poll doesn't block the event loop — a spawnSync here would
374
+ // freeze the caller's ora spinner between polls.
375
+ // eslint-disable-next-line no-await-in-loop
376
+ const { currentBulkOperation } = (await executeAsync(bin, store, 'CurrentBulkOperation'));
377
+ const status = currentBulkOperation?.status;
378
+ if (status === 'COMPLETED')
379
+ return currentBulkOperation?.url ?? null;
380
+ if (status === 'CANCELED' || status === 'EXPIRED' || status === 'FAILED') {
381
+ const code = currentBulkOperation?.errorCode;
382
+ throw new Error(`Product bulk export ${status.toLowerCase()}${code ? ` (${code})` : ''}`);
383
+ }
384
+ if (Date.now() > deadline)
385
+ throw new Error('Timed out waiting for the product bulk export');
386
+ // eslint-disable-next-line no-await-in-loop
387
+ await sleep(BULK_POLL_MS);
388
+ }
389
+ };
390
+ /** Download a bulk operation's JSONL result. No auth — the URL is pre-signed. */
391
+ const downloadText = async (url) => {
392
+ const resp = await fetch(url);
393
+ if (!resp.ok)
394
+ throw new Error(`Could not download the product bulk export (${resp.status} ${resp.statusText})`);
395
+ return resp.text();
396
+ };
397
+ /**
398
+ * Fold a bulk export's JSONL into a `{mediaImageId -> product}` map.
399
+ *
400
+ * Bulk output is flat: each product is one line and each of its media is another
401
+ * line carrying `__parentId`, rather than nested. So products are collected
402
+ * first, then each media line is joined back to its product by that id.
403
+ */
404
+ const parseProductJsonl = (jsonl) => {
405
+ const products = new Map();
406
+ const media = [];
407
+ for (const raw of jsonl.split('\n')) {
408
+ if (!raw.trim())
409
+ continue;
410
+ const line = JSON.parse(raw);
411
+ // A product line has a title; a media line has a __parentId. Only image
412
+ // media carries a MediaImage id (the fragment leaves videos/3D without one).
413
+ if (line.title !== undefined && line.id) {
414
+ products.set(line.id, {
415
+ productType: line.productType ?? '',
416
+ tags: line.tags ?? [],
417
+ title: line.title,
418
+ vendor: line.vendor ?? '',
419
+ });
420
+ }
421
+ else if (line.__parentId && line.id?.startsWith('gid://shopify/MediaImage/')) {
422
+ media.push({ mediaId: line.id, parent: line.__parentId });
423
+ }
424
+ }
425
+ const map = new Map();
426
+ for (const { mediaId, parent } of media) {
427
+ const context = products.get(parent);
428
+ // First product to claim a shared image wins.
429
+ if (context && !map.has(mediaId))
430
+ map.set(mediaId, context);
431
+ }
432
+ return map;
433
+ };
434
+ /** A one-line context hint for the prompt, skipping whatever fields are empty. */
435
+ export const formatProductContext = (context) => {
436
+ const details = [];
437
+ if (context.productType)
438
+ details.push(`type: ${context.productType}`);
439
+ if (context.vendor)
440
+ details.push(`vendor: ${context.vendor}`);
441
+ if (context.tags.length > 0)
442
+ details.push(`tags: ${context.tags.join(', ')}`);
443
+ const suffix = details.length > 0 ? ` (${details.join('; ')})` : '';
444
+ return `It is used on the product "${context.title}"${suffix}`;
445
+ };
446
+ /** Set one file's alt text. Throws on any userErrors the mutation reports. */
447
+ export const updateFileAlt = (bin, store, id, alt) => {
448
+ const data = execute(bin, store, 'UpdateFileAlt', { mutate: true, variables: { alt, id } });
449
+ const errors = data.fileUpdate?.userErrors ?? [];
450
+ if (errors.length > 0)
451
+ throw new Error(`Shopify fileUpdate error: ${errors.map((error) => error.message).join('; ')}`);
452
+ };
453
+ /** Guess a MIME type from a filename extension — enough for staging an upload. */
454
+ const MIME_BY_EXT = {
455
+ avif: 'image/avif',
456
+ css: 'text/css',
457
+ csv: 'text/csv',
458
+ gif: 'image/gif',
459
+ heic: 'image/heic',
460
+ ico: 'image/x-icon',
461
+ jpeg: 'image/jpeg',
462
+ jpg: 'image/jpeg',
463
+ js: 'text/javascript',
464
+ json: 'application/json',
465
+ mov: 'video/quicktime',
466
+ mp4: 'video/mp4',
467
+ pdf: 'application/pdf',
468
+ png: 'image/png',
469
+ svg: 'image/svg+xml',
470
+ txt: 'text/plain',
471
+ webm: 'video/webm',
472
+ webp: 'image/webp',
473
+ };
474
+ const mimeFromName = (name) => {
475
+ const ext = name.split('.').pop()?.toLowerCase() ?? '';
476
+ return MIME_BY_EXT[ext] ?? 'application/octet-stream';
477
+ };
478
+ /**
479
+ * Shopify's resource/content-type buckets. Images and videos get their own
480
+ * first-class media types in the library; everything else is a generic FILE.
481
+ * The same value serves stagedUploadsCreate's `resource` and fileCreate's
482
+ * `contentType`, which share these enum members.
483
+ */
484
+ const resourceForMime = (mime) => {
485
+ if (mime.startsWith('image/'))
486
+ return 'IMAGE';
487
+ if (mime.startsWith('video/'))
488
+ return 'VIDEO';
489
+ return 'FILE';
490
+ };
491
+ /** Step 1: reserve a staging slot and return its pre-authorized upload target. */
492
+ const startStagedUpload = (bin, store, file) => {
493
+ const staged = execute(bin, store, 'StagedUploadsCreate', {
494
+ mutate: true,
495
+ variables: { input: [{ filename: file.filename, httpMethod: 'POST', mimeType: file.mimeType, resource: file.resource }] },
496
+ });
497
+ const errors = staged.stagedUploadsCreate?.userErrors ?? [];
498
+ if (errors.length > 0)
499
+ throw new Error(`Shopify stagedUploadsCreate error: ${errors.map((e) => e.message).join('; ')}`);
500
+ const target = staged.stagedUploadsCreate?.stagedTargets?.[0];
501
+ if (!target)
502
+ throw new Error('Shopify returned no staged upload target for the file.');
503
+ return target;
504
+ };
505
+ /** Step 2: multipart-POST the bytes to the staged target (params first, file last — GCS's rule). */
506
+ const postToStagedTarget = async (target, bytes, filename, mimeType) => {
507
+ const form = new FormData();
508
+ for (const param of target.parameters)
509
+ form.append(param.name, param.value);
510
+ form.append('file', new Blob([new Uint8Array(bytes)], { type: mimeType }), filename);
511
+ const resp = await fetch(target.url, { body: form, method: 'POST' });
512
+ if (!resp.ok) {
513
+ const detail = (await resp.text().catch(() => '')).slice(0, 300);
514
+ throw new Error(`Staged upload POST failed (${resp.status} ${resp.statusText})${detail ? `: ${detail}` : ''}`);
515
+ }
516
+ };
517
+ /** Step 3: register the staged upload as a real file, returning its id/url/status. */
518
+ const createFile = (bin, store, input) => {
519
+ const created = execute(bin, store, 'FileCreate', {
520
+ mutate: true,
521
+ variables: { files: [{ alt: input.alt, contentType: input.resource, originalSource: input.resourceUrl }] },
522
+ });
523
+ const errors = created.fileCreate?.userErrors ?? [];
524
+ if (errors.length > 0)
525
+ throw new Error(`Shopify fileCreate error: ${errors.map((e) => e.message).join('; ')}`);
526
+ const file = created.fileCreate?.files?.[0];
527
+ if (!file)
528
+ throw new Error('Shopify fileCreate returned no file.');
529
+ return { alt: file.alt ?? null, id: file.id, status: file.fileStatus ?? null, url: file.image?.url ?? file.url ?? null };
530
+ };
531
+ /** How long to wait for a freshly-created file to finish processing (get a url). */
532
+ const FILE_READY_TIMEOUT_MS = 60 * 1000;
533
+ const FILE_POLL_MS = 1500;
534
+ /**
535
+ * Re-read a just-created file until it has a live CDN url (fileCreate returns
536
+ * before processing finishes). Gives up after FILE_READY_TIMEOUT_MS and returns
537
+ * whatever it last saw, so the upload still succeeds — the caller just reports
538
+ * the url as not-ready-yet rather than blocking forever or failing.
539
+ */
540
+ const waitForFileUrl = async (bin, store, id) => {
541
+ const deadline = Date.now() + FILE_READY_TIMEOUT_MS;
542
+ for (;;) {
543
+ // eslint-disable-next-line no-await-in-loop -- sequential poll of one file's status
544
+ const { node } = (await executeAsync(bin, store, 'FileStatus', { variables: { id } }));
545
+ const status = node?.fileStatus ?? null;
546
+ const url = node?.image?.url ?? node?.url ?? null;
547
+ if (url)
548
+ return { status, url };
549
+ if (status === 'FAILED' || Date.now() > deadline)
550
+ return { status, url: null };
551
+ // eslint-disable-next-line no-await-in-loop -- pacing the poll loop
552
+ await sleep(FILE_POLL_MS);
553
+ }
554
+ };
555
+ /** The `shopify://shop_images/<filename>` reference a theme image_picker setting stores. */
556
+ const themeReference = (url) => {
557
+ if (!url)
558
+ return { filename: null, reference: null };
559
+ const name = fileName(url);
560
+ return name ? { filename: name, reference: `shopify://shop_images/${name}` } : { filename: null, reference: null };
561
+ };
562
+ /**
563
+ * Upload a local file into the store's asset library, in the three steps the
564
+ * Admin API requires (staged slot → POST bytes → register), then wait for it to
565
+ * finish processing so the returned url/reference are usable. The bytes are read
566
+ * from `path` on THIS machine (the caller); the GraphQL steps go through the
567
+ * user's Shopify CLI like every other operation here — no token ever passes
568
+ * through us. Throws with the CLI's or userErrors' reason on any failure.
569
+ *
570
+ * The result carries everything needed to then USE the file (e.g. reference it
571
+ * from a theme image_picker setting): the GID, CDN url, filename, and the
572
+ * `shopify://shop_images/…` reference. url/reference are null if processing
573
+ * hadn't finished within the wait window — the file still exists; poll later.
574
+ */
575
+ export const uploadFile = async (bin, store, opts) => {
576
+ const bytes = readFileSync(opts.path);
577
+ const filename = opts.filename?.trim() || basename(opts.path);
578
+ const mimeType = mimeFromName(filename);
579
+ const resource = resourceForMime(mimeType);
580
+ const target = startStagedUpload(bin, store, { filename, mimeType, resource });
581
+ await postToStagedTarget(target, bytes, filename, mimeType);
582
+ const created = createFile(bin, store, { alt: opts.alt, resource, resourceUrl: target.resourceUrl });
583
+ // fileCreate hands back the url only if processing already finished; otherwise
584
+ // poll until it does, so the AI gets a usable url/reference in one call.
585
+ const ready = created.url ? { status: created.status, url: created.url } : await waitForFileUrl(bin, store, created.id);
586
+ return {
587
+ alt: created.alt,
588
+ id: created.id,
589
+ status: ready.status ?? created.status,
590
+ url: ready.url,
591
+ ...themeReference(ready.url),
592
+ };
593
+ };
594
+ /**
595
+ * Swap a file's bytes in place: fileUpdate with a new `originalSource` keeps the
596
+ * same file GID (so a theme `shopify://shop_images/<filename>` reference stays
597
+ * valid) while replacing the contents. Parsed like createFile.
598
+ */
599
+ const replaceFileSource = (bin, store, input) => {
600
+ const updated = execute(bin, store, 'FileReplace', {
601
+ mutate: true,
602
+ variables: { files: [{ alt: input.alt, id: input.id, originalSource: input.resourceUrl }] },
603
+ });
604
+ const errors = updated.fileUpdate?.userErrors ?? [];
605
+ if (errors.length > 0)
606
+ throw new Error(`Shopify fileUpdate error: ${errors.map((error) => error.message).join('; ')}`);
607
+ const file = updated.fileUpdate?.files?.[0];
608
+ if (!file)
609
+ throw new Error('Shopify fileUpdate returned no file.');
610
+ return { alt: file.alt ?? null, id: file.id, status: file.fileStatus ?? null, url: file.image?.url ?? file.url ?? null };
611
+ };
612
+ /**
613
+ * Replace an existing file's contents with a new local file, in place — the same
614
+ * three-step staging as uploadFile, but the final step is fileUpdate on an
615
+ * existing `id` instead of fileCreate, so the file keeps its GID and (typically)
616
+ * its filename/theme reference. The new bytes are read from `path` on THIS
617
+ * machine; every GraphQL step goes through the user's Shopify CLI. Returns the
618
+ * updated file's id/url/reference/status, polling until processing finishes.
619
+ */
620
+ export const replaceFile = async (bin, store, opts) => {
621
+ const bytes = readFileSync(opts.path);
622
+ const filename = basename(opts.path);
623
+ const mimeType = mimeFromName(filename);
624
+ const resource = resourceForMime(mimeType);
625
+ const target = startStagedUpload(bin, store, { filename, mimeType, resource });
626
+ await postToStagedTarget(target, bytes, filename, mimeType);
627
+ const updated = replaceFileSource(bin, store, { alt: opts.alt, id: opts.id, resourceUrl: target.resourceUrl });
628
+ // fileUpdate can return before the swapped-in image finishes processing; poll
629
+ // the same file id until its url is live, mirroring the upload path.
630
+ const ready = updated.url ? { status: updated.status, url: updated.url } : await waitForFileUrl(bin, store, opts.id);
631
+ return {
632
+ alt: updated.alt,
633
+ id: updated.id,
634
+ status: ready.status ?? updated.status,
635
+ url: ready.url,
636
+ ...themeReference(ready.url),
637
+ };
638
+ };
639
+ /**
640
+ * Delete a file from the store's asset library by GID, returning the id Shopify
641
+ * reports as actually removed. Throws on any userErrors the mutation reports.
642
+ */
643
+ export const deleteFile = (bin, store, id) => {
644
+ const data = execute(bin, store, 'FileDelete', { mutate: true, variables: { fileIds: [id] } });
645
+ const errors = data.fileDelete?.userErrors ?? [];
646
+ if (errors.length > 0)
647
+ throw new Error(`Shopify fileDelete error: ${errors.map((error) => error.message).join('; ')}`);
648
+ const deleted = data.fileDelete?.deletedFileIds?.[0];
649
+ if (!deleted)
650
+ throw new Error(`Shopify fileDelete removed nothing for ${id}; the file may not exist.`);
651
+ return deleted;
652
+ };
653
+ /** A human label for a file, taken from the CDN URL's last path segment. */
654
+ const fileName = (url) => {
655
+ try {
656
+ const name = new URL(url).pathname.split('/').pop();
657
+ return name ? decodeURIComponent(name.split('?')[0]) : url;
658
+ }
659
+ catch {
660
+ return url;
661
+ }
662
+ };