@firenet-designs/fnd-cli 2.6.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.
- package/README.md +103 -63
- package/bin/dev.js +1 -1
- package/dist/commands/alt-text.d.ts +64 -15
- package/dist/commands/alt-text.js +277 -65
- package/dist/commands/backfill-project.js +1 -1
- package/dist/commands/create-project.js +1 -1
- package/dist/commands/workspace/index.d.ts +3 -2
- package/dist/commands/workspace/index.js +96 -49
- package/dist/lib/alt-text.d.ts +33 -2
- package/dist/lib/alt-text.js +56 -4
- package/dist/lib/mcp/bracket-args.d.ts +37 -0
- package/dist/lib/mcp/bracket-args.js +65 -0
- package/dist/lib/mcp/define-tool.d.ts +52 -0
- package/dist/lib/mcp/define-tool.js +2 -0
- package/dist/lib/mcp/registry.d.ts +38 -0
- package/dist/lib/mcp/registry.js +98 -0
- package/dist/lib/mcp/server.d.ts +66 -0
- package/dist/lib/mcp/server.js +176 -0
- package/dist/lib/mcp/tools/shopify-common.d.ts +139 -0
- package/dist/lib/mcp/tools/shopify-common.js +167 -0
- package/dist/lib/mcp/tools/shopify-execute.d.ts +2 -0
- package/dist/lib/mcp/tools/shopify-execute.js +105 -0
- package/dist/lib/mcp/tools/shopify-file-delete.d.ts +2 -0
- package/dist/lib/mcp/tools/shopify-file-delete.js +49 -0
- package/dist/lib/mcp/tools/shopify-file-replace.d.ts +2 -0
- package/dist/lib/mcp/tools/shopify-file-replace.js +79 -0
- package/dist/lib/mcp/tools/shopify-file-search.d.ts +2 -0
- package/dist/lib/mcp/tools/shopify-file-search.js +199 -0
- package/dist/lib/mcp/tools/shopify-file-upload.d.ts +2 -0
- package/dist/lib/mcp/tools/shopify-file-upload.js +76 -0
- package/dist/lib/shopify/graphql/AccessScopes.graphql +7 -0
- package/dist/lib/shopify/graphql/CurrentBulkOperation.graphql +8 -0
- package/dist/lib/shopify/graphql/FileCreate.graphql +25 -0
- package/dist/lib/shopify/graphql/FileDelete.graphql +11 -0
- package/dist/lib/shopify/graphql/FileReplace.graphql +26 -0
- package/dist/lib/shopify/graphql/FileStatus.graphql +19 -0
- package/dist/lib/shopify/graphql/FilesBulkQuery.graphql +27 -0
- package/dist/lib/shopify/graphql/ProductsBulkQuery.graphql +27 -0
- package/dist/lib/shopify/graphql/SearchFiles.graphql +36 -0
- package/dist/lib/shopify/graphql/StagedUploadsCreate.graphql +20 -0
- package/dist/lib/shopify/graphql/StartBulkQuery.graphql +16 -0
- package/dist/lib/shopify/graphql/UpdateFileAlt.graphql +9 -0
- package/dist/lib/shopify/shopify.d.ts +228 -0
- package/dist/lib/shopify/shopify.js +662 -0
- package/dist/lib/workspace.d.ts +19 -8
- package/dist/lib/workspace.js +13 -13
- package/oclif.manifest.json +48 -46
- package/package.json +17 -10
- package/dist/hooks/init/check-for-updates.d.ts +0 -3
- package/dist/hooks/init/check-for-updates.js +0 -15
- package/dist/lib/kv-flag.d.ts +0 -15
- package/dist/lib/kv-flag.js +0 -75
- package/dist/lib/rpc.d.ts +0 -69
- 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
|
+
};
|