@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,616 @@
1
+ import { createDescriber, DEFAULT_OLLAMA_HOST, detectType, listVisionModels } from '#lib/alt-text.js';
2
+ import { createFilter } from '#lib/image-filter.js';
3
+ import { findShopifyBin } from '#lib/scaffold.js';
4
+ import { authenticate, DRY_RUN_SCOPES, formatProductContext, getImageFiles, getImageProductContext, getInstalledScopes, missingScopes, normalizeStore, REQUIRED_SCOPES, updateFileAlt, } from '#lib/shopify/shopify.js';
5
+ import { getAssets, getCollectionItems, getCollections, isImageField, isImagesField, updateAssetAltText, updateCollectionItem, } from '#lib/webflow.js';
6
+ import { checkbox, input, password, select } from '@inquirer/prompts';
7
+ import { Command, Flags } from '@oclif/core';
8
+ import chalk from 'chalk';
9
+ import { mkdir, writeFile } from 'node:fs/promises';
10
+ import { dirname, resolve } from 'node:path';
11
+ import ora from 'ora';
12
+ const atLimit = (state) => state.transcribed >= state.limit;
13
+ /**
14
+ * Build filter metadata from what Shopify's GraphQL already told us about a file,
15
+ * so a --dry run can judge it without downloading. Returns undefined when any of
16
+ * the four fields is missing (a MediaImage still processing) — the caller's cue
17
+ * to fall back to the download. Only used for --dry; a real run measures the
18
+ * downloaded bytes instead, so a stale CDN value can never reach the model.
19
+ */
20
+ const shopifyImageMeta = (file) => {
21
+ if (file.mimeType === null || file.width === null || file.height === null || file.fileSize === null)
22
+ return undefined;
23
+ return {
24
+ fileSize: file.fileSize,
25
+ height: file.height,
26
+ type: detectType(file.mimeType, file.url),
27
+ url: file.url,
28
+ width: file.width,
29
+ };
30
+ };
31
+ /** Non-empty validator for the @inquirer prompts that stand in for required flags. */
32
+ const required = (value) => (value.trim().length > 0 ? true : 'Required');
33
+ /** Trim a URL down to something that fits a terminal line. */
34
+ const shortUrl = (url) => {
35
+ const name = url.split('?')[0].split('/').pop() ?? url;
36
+ return name.length > 60 ? `${name.slice(0, 57)}…` : name;
37
+ };
38
+ const formatMs = (ms) => (ms < 1000 ? `${Math.round(ms)}ms` : `${(ms / 1000).toFixed(1)}s`);
39
+ const formatBytes = (bytes) => bytes < 1024 * 1024 ? `${Math.round(bytes / 1024)} KB` : `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
40
+ const formatCount = (n) => n.toLocaleString('en-US');
41
+ /**
42
+ * Make a value safe to drop in a Markdown table cell: pipes would end the cell
43
+ * early and a newline would end the row. Descriptions are model output, so both
44
+ * are possible even though the prompt asks for one plain sentence.
45
+ */
46
+ const cell = (value) => value.replaceAll('|', String.raw `\|`).replaceAll(/\s*\n\s*/g, ' ').trim();
47
+ const sum = (values) => values.reduce((total, value) => total + value, 0);
48
+ /** The thumbnail that stands in for a URL in every table. */
49
+ const thumbnail = (url) => cell(`<img src="${url}" style="object-fit: contain; object-position: center; max-width: 256px; max-height: 256px" />`);
50
+ /**
51
+ * The --output-stats report: totals, a row per image, the filtered-out images,
52
+ * then the failures.
53
+ *
54
+ * Sizes throughout are the file as the site serves it (`meta.fileSize`) — the
55
+ * same number the --filter sees, so a report can be read against the expression
56
+ * that produced it. The PNG we actually send the model gets its own column.
57
+ */
58
+ const buildStatsMarkdown = (state, model, host, totalMs) => {
59
+ const tokens = sum(state.images.map((image) => image.tokens));
60
+ const described = sum(state.images.map((image) => image.ms));
61
+ const count = state.images.length;
62
+ const skippedBytes = sum(state.skipped.map((meta) => meta.fileSize));
63
+ const skippedCount = state.skipped.length;
64
+ const lines = [
65
+ '# Alt text run',
66
+ '',
67
+ `- Model: \`${model}\` on \`${host}\``,
68
+ `- Run duration: ${formatMs(totalMs)}`,
69
+ '',
70
+ '## Summary',
71
+ '',
72
+ '| Metric | Value |',
73
+ '| --- | --- |',
74
+ `| Images modified | ${formatCount(count)} |`,
75
+ `| Images skipped by filter | ${formatCount(skippedCount)} |`,
76
+ `| Images failed | ${formatCount(state.failures.length)} |`,
77
+ `| Tokens used | ${formatCount(tokens)} |`,
78
+ `| Average tokens per image | ${count > 0 ? formatCount(Math.round(tokens / count)) : '—'} |`,
79
+ `| Average time per image | ${count > 0 ? formatMs(described / count) : '—'} |`,
80
+ `| Skipped image total size | ${formatBytes(skippedBytes)} |`,
81
+ `| Skipped image average size | ${skippedCount > 0 ? formatBytes(skippedBytes / skippedCount) : '—'} |`,
82
+ '',
83
+ '## Images',
84
+ '',
85
+ ];
86
+ if (count === 0) {
87
+ lines.push('_No images were transcribed._', '');
88
+ }
89
+ else {
90
+ lines.push('| URL | Description | Tokens | Time | Size | Sent | Type | Dimensions |', '| --- | --- | --- | --- | --- | --- | --- | --- |', ...state.images.map((image) => `| ${thumbnail(image.url)} | ${cell(image.description)} | ${formatCount(image.tokens)} | ${formatMs(image.ms)} | ${formatBytes(image.meta.fileSize)} | ${formatBytes(image.bytes)} | ${cell(image.meta.type)} | ${image.meta.width}x${image.meta.height} |`), '');
91
+ }
92
+ lines.push('## Skipped by filter', '');
93
+ if (skippedCount === 0) {
94
+ lines.push('_None._', '');
95
+ }
96
+ else {
97
+ lines.push('| URL | Size | Type | Dimensions |', '| --- | --- | --- | --- |', ...state.skipped.map((meta) => `| ${thumbnail(meta.url)} | ${formatBytes(meta.fileSize)} | ${cell(meta.type)} | ${meta.width}x${meta.height} |`), '');
98
+ }
99
+ lines.push('## Failures', '');
100
+ if (state.failures.length === 0) {
101
+ lines.push('_None._', '');
102
+ }
103
+ else {
104
+ lines.push('| URL | Reason |', '| --- | --- |', ...state.failures.map((failure) => `| ${cell(failure.url)} | ${cell(failure.reason)} |`), '');
105
+ }
106
+ return lines.join('\n');
107
+ };
108
+ export default class AltText extends Command {
109
+ static aliases = ['caption'];
110
+ static description = 'Generate alt text for a site\'s images with a local Ollama vision model and write it back.\n\nPick a platform with --platform (webflow or shopify). For Webflow, walks the site asset library and (with --cms) the image fields of CMS collection items and PATCHes each description back through the Data API. For Shopify, walks the store\'s file library and writes alt text back through the Shopify CLI (which owns the auth — no --api-key). Images are fetched and described one at a time; nothing but the platform API calls leaves your network.\n\nWebflow CMS writes go to STAGING, so publish the site in Webflow to make them live. Any required flag you omit is prompted for.';
111
+ static examples = [
112
+ '<%= config.bin %> <%= command.id %> --platform webflow --api-key <key> --site-id <id> --ollama-host http://localhost:11434 --ollama-model qwen3-vl:8b',
113
+ '<%= config.bin %> <%= command.id %> --platform shopify --site-id mystore',
114
+ '<%= config.bin %> <%= command.id %> --platform shopify --site-id mystore --dry',
115
+ '<%= config.bin %> <%= command.id %> --platform webflow --skip --limit 20',
116
+ '<%= config.bin %> <%= command.id %> --platform webflow --cms --select',
117
+ '<%= config.bin %> <%= command.id %> --platform webflow --cms --only products --only sku',
118
+ '<%= config.bin %> <%= command.id %> --platform webflow --output-stats ./alt-text-run.md',
119
+ '<%= config.bin %> <%= command.id %> --platform webflow --filter "fileSize>=sizes.KB(100) && width>=100 && height>=100"',
120
+ '<%= config.bin %> <%= command.id %> --platform webflow --filter "type !== \'svg\' && !url.includes(\'/icons/\')"',
121
+ ];
122
+ static flags = {
123
+ 'api-key': Flags.string({
124
+ description: 'Webflow API token (site-scoped). Webflow only — Shopify auth comes from the Shopify CLI. Prompted for if omitted.',
125
+ }),
126
+ cms: Flags.boolean({
127
+ description: 'also add alt text to the images in CMS collection items. Webflow only. Off by default — only the site asset library is walked.',
128
+ }),
129
+ dry: Flags.boolean({
130
+ description: 'report how many images the run would caption without describing or writing anything. Every image is still downloaded and run through --filter so the count is accurate, but nothing reaches the model. The --ollama-host/--ollama-model and --output-stats flags are ignored in this mode, so --dry can be added to (and removed from) a full command without changing the rest.',
131
+ }),
132
+ filter: Flags.string({
133
+ description: 'a JavaScript expression deciding which images are worth describing, e.g. "fileSize>=sizes.KB(100) && width>=100 && height>=100". Available: fileSize (bytes), width, height (pixels), url, type ("webp", "png", "jpeg", "svg", …), and sizes.KB/MB/GB helpers. Images that fail it are skipped and reported separately. Evaluated after the download, since dimensions can\'t be known before it.',
134
+ }),
135
+ limit: Flags.integer({
136
+ description: 'stop after this many images have been transcribed (counted across assets and CMS together)',
137
+ min: 1,
138
+ }),
139
+ 'ollama-host': Flags.string({
140
+ description: `base URL of the Ollama server. Prompted for if omitted.`,
141
+ }),
142
+ 'ollama-model': Flags.string({
143
+ description: 'the model used to describe the images. Omit it to pick from the vision-capable models pulled on the host.',
144
+ }),
145
+ only: Flags.string({
146
+ dependsOn: ['cms'],
147
+ description: 'only touch these CMS collections, matched case-insensitively against a collection\'s name or slug. Repeat the flag for more than one.',
148
+ exclusive: ['select'],
149
+ multiple: true,
150
+ }),
151
+ 'output-stats': Flags.string({
152
+ description: 'also write the run stats to this file as Markdown: totals, a row per image (url, description, tokens, time, size), and the failures. Parent directories are created.',
153
+ }),
154
+ platform: Flags.string({
155
+ description: 'which platform to run against. Prompted for if omitted.',
156
+ options: ['webflow', 'shopify'],
157
+ }),
158
+ select: Flags.boolean({
159
+ dependsOn: ['cms'],
160
+ description: 'fetch the CMS collections and pick which ones to process interactively',
161
+ exclusive: ['only'],
162
+ }),
163
+ 'site-id': Flags.string({
164
+ description: 'Webflow site ID, or the Shopify store (mystore or mystore.myshopify.com). Prompted for if omitted.',
165
+ }),
166
+ skip: Flags.boolean({
167
+ description: 'leave images that already have alt text alone instead of overwriting them',
168
+ }),
169
+ };
170
+ async run() {
171
+ const { flags } = await this.parse(AltText);
172
+ const platform = await this.resolvePlatform(flags);
173
+ const dry = flags.dry ?? false;
174
+ // --api-key and --cms are Webflow concepts. Shopify auth (and everything
175
+ // else) comes from the Shopify CLI, so reject them rather than ignore them
176
+ // silently — a passed flag that does nothing is a bug from the user's seat.
177
+ if (platform === 'shopify') {
178
+ const stray = ['api-key', 'cms', 'select', 'only'].find((name) => flags[name]);
179
+ if (stray)
180
+ this.error(`--${stray} is a Webflow option and can't be used with --platform shopify.`, { code: '1' });
181
+ }
182
+ // A dry run never reaches the model and never writes stats, so the model
183
+ // flags and --output-stats simply do nothing here — deliberately NOT an
184
+ // error (like --cleanup on `workspace`), so you can drop --dry onto a full
185
+ // command and pull it back off without touching the rest of the flags.
186
+ //
187
+ // Only needed when a real run will actually describe images.
188
+ const host = dry
189
+ ? ''
190
+ : flags['ollama-host'] ??
191
+ (await input({ default: DEFAULT_OLLAMA_HOST, message: 'Ollama host', validate: required }));
192
+ const model = dry ? '' : flags['ollama-model'] ?? (await this.pickModel(host));
193
+ // Compiled here, not at first use, so a broken expression costs nothing.
194
+ let filter;
195
+ if (flags.filter) {
196
+ try {
197
+ filter = createFilter(flags.filter);
198
+ }
199
+ catch (error) {
200
+ this.error(error.message, { code: '1' });
201
+ }
202
+ }
203
+ const state = {
204
+ describe: createDescriber(host, model, filter, dry),
205
+ dry,
206
+ failures: [],
207
+ filter,
208
+ images: [],
209
+ limit: flags.limit ?? Number.POSITIVE_INFINITY,
210
+ skip: flags.skip ?? false,
211
+ skipped: [],
212
+ transcribed: 0,
213
+ };
214
+ this.log(chalk.dim(dry ? 'Dry run — counting which images would be captioned, describing none' : `Describing images with ${model} on ${host}`));
215
+ if (flags.filter)
216
+ this.log(chalk.dim(`Only images matching: ${flags.filter}`));
217
+ const started = performance.now();
218
+ const touchedCms = platform === 'shopify' ? await this.processShopify(flags, state) : await this.processWebflow(flags, state);
219
+ const totalMs = performance.now() - started;
220
+ if (dry) {
221
+ this.reportDry(state, totalMs);
222
+ return;
223
+ }
224
+ this.report(state, touchedCms, totalMs);
225
+ if (flags['output-stats']) {
226
+ await this.writeStats(flags['output-stats'], state, model, host, totalMs);
227
+ }
228
+ }
229
+ /**
230
+ * Describe one image and account for it. Returns undefined when the model or
231
+ * the download failed — one bad image must not abort a run that may have
232
+ * hundreds of good ones behind it.
233
+ *
234
+ * Also the fallback for a --dry run: when an image's metadata wasn't known up
235
+ * front (so the caller couldn't filter it cheaply), the describer downloads it
236
+ * just far enough to measure it, and this counts it via recordDry.
237
+ */
238
+ async describe(state, label, url, context) {
239
+ this.logImageLine(state, label, url);
240
+ // A spinner over the two silent, slow phases: the download, then (for a real
241
+ // run) the model. onDownloaded flips the text at the boundary between them.
242
+ // Indented one level under the image line; each result line sits at the same 4.
243
+ const spinner = ora({ indent: 4, text: 'Downloading…' }).start();
244
+ try {
245
+ const result = await state.describe(url, context, () => {
246
+ spinner.text = 'Generating alt text…';
247
+ });
248
+ this.stopSpinner(spinner);
249
+ // Real run only: the describer applies --filter after the download. A
250
+ // filtered image spends no tokens and no --limit budget.
251
+ if (result.skipped) {
252
+ state.skipped.push(result.meta);
253
+ this.log(chalk.dim(` – filtered out (${formatBytes(result.meta.fileSize)}, ${result.meta.width}x${result.meta.height}, ${result.meta.type})`));
254
+ return undefined;
255
+ }
256
+ // Dry run: the download only served to learn the metadata this image
257
+ // didn't carry up front. Count it here and write nothing back.
258
+ if (state.dry) {
259
+ this.recordDry(state, result.meta);
260
+ return undefined;
261
+ }
262
+ const { alt, bytes, meta, ms, tokens } = result;
263
+ state.transcribed++;
264
+ state.images.push({ bytes, description: alt, meta, ms, tokens, url });
265
+ this.log(` ${chalk.green('✓')} ${alt}`);
266
+ this.log(chalk.dim(` ${formatCount(tokens)} tokens · ${formatMs(ms)} · ${formatBytes(bytes)} sent`));
267
+ return alt;
268
+ }
269
+ catch (error) {
270
+ this.stopSpinner(spinner);
271
+ const reason = error.message;
272
+ state.failures.push({ reason, url });
273
+ this.log(chalk.yellow(` ⚠ skipped: ${reason}`));
274
+ return undefined;
275
+ }
276
+ }
277
+ /**
278
+ * Guarantee the store's app installation has the scopes this run needs before
279
+ * any captioning. `required` is the minimal set for the run — the full
280
+ * REQUIRED_SCOPES for a real run, just DRY_RUN_SCOPES (read_files) for a --dry
281
+ * one, which never writes or maps product context. A non-zero exit from the
282
+ * scope probe is the CLI's way of saying the store isn't authenticated at all;
283
+ * a clean exit with a scope missing means it's authenticated but under-scoped.
284
+ * Either way `shopify store auth` with `required` fixes it — it adds only
285
+ * what's missing, and a write scope already covers its read counterpart.
286
+ */
287
+ ensureShopifyScopes(bin, store, required) {
288
+ this.log(chalk.dim(`Checking Shopify access for ${store}…`));
289
+ const { authenticated, scopes } = getInstalledScopes(bin, store);
290
+ const missing = missingScopes(scopes, required);
291
+ if (authenticated && missing.length === 0) {
292
+ this.log(chalk.dim(` ${chalk.green('✓')} ${required.join(', ')} already granted`));
293
+ return;
294
+ }
295
+ const reason = authenticated ? `missing ${missing.join(', ')}` : 'not authenticated';
296
+ this.log(chalk.dim(` ${reason} — running shopify store auth`));
297
+ if (!authenticate(bin, store, required)) {
298
+ this.error(`Could not authenticate ${store}. Try it directly: shopify store auth --store ${store} --scopes ${required.join(',')}`, { code: '1' });
299
+ }
300
+ }
301
+ /** The `[n/limit] label — file` progress line, shared by real and dry counting. */
302
+ logImageLine(state, label, url) {
303
+ const counter = Number.isFinite(state.limit)
304
+ ? `${state.transcribed + 1}/${state.limit}`
305
+ : `${state.transcribed + 1}`;
306
+ this.log(chalk.dim(` [${counter}] ${label} — ${shortUrl(url)}`));
307
+ }
308
+ /** Which CMS collections this run should walk. */
309
+ async pickCollections(auth, useSelect, only) {
310
+ const collections = await getCollections(auth);
311
+ if (collections.length === 0) {
312
+ this.log(chalk.yellow('This site has no CMS collections.'));
313
+ return [];
314
+ }
315
+ const label = (collection) => `${collection.displayName} (${collection.slug})`;
316
+ if (only) {
317
+ const wanted = only.map((name) => name.trim().toLowerCase());
318
+ const matches = (collection, name) => collection.displayName.toLowerCase() === name ||
319
+ collection.singularName.toLowerCase() === name ||
320
+ collection.slug.toLowerCase() === name;
321
+ const missing = wanted.filter((name) => !collections.some((collection) => matches(collection, name)));
322
+ if (missing.length > 0) {
323
+ this.error(`No CMS collection named ${missing.map((name) => `"${name}"`).join(', ')}. This site has: ${collections
324
+ .map((collection) => label(collection))
325
+ .join(', ')}.`, { code: '1' });
326
+ }
327
+ return collections.filter((collection) => wanted.some((name) => matches(collection, name)));
328
+ }
329
+ if (useSelect) {
330
+ return checkbox({
331
+ choices: collections.map((collection) => ({ name: label(collection), value: collection })),
332
+ message: 'Which CMS collections should get alt text?',
333
+ });
334
+ }
335
+ return collections;
336
+ }
337
+ /**
338
+ * Ask which model to use, offering only the vision-capable models the host has
339
+ * already pulled — a text-only model would happily accept the request and
340
+ * describe nothing, and a model that isn't pulled would stall the run behind a
341
+ * silent download.
342
+ */
343
+ async pickModel(host) {
344
+ let models;
345
+ try {
346
+ models = await listVisionModels(host);
347
+ }
348
+ catch (error) {
349
+ this.error(`Could not list models on ${host}: ${error.message}`, { code: '1' });
350
+ }
351
+ if (models.length === 0) {
352
+ this.error(`No vision-capable models are pulled on ${host}. Pull one (e.g. "ollama pull qwen3-vl:8b") or pass --ollama-model.`, { code: '1' });
353
+ }
354
+ return select({ choices: models, message: 'Which Ollama model should describe the images?' });
355
+ }
356
+ /** The site asset library pass. Always runs; --cms only adds work on top of it. */
357
+ async processAssets(auth, state) {
358
+ this.log(chalk.bold('\nSite assets'));
359
+ for await (const asset of getAssets(auth)) {
360
+ if (atLimit(state))
361
+ return;
362
+ // The asset library also holds PDFs, fonts and the like.
363
+ if (!asset.contentType.startsWith('image/'))
364
+ continue;
365
+ if (state.skip && asset.altText)
366
+ continue;
367
+ const alt = await this.describe(state, asset.displayName || asset.originalFileName, asset.hostedUrl);
368
+ if (alt !== undefined)
369
+ await updateAssetAltText(auth, asset.id, alt);
370
+ }
371
+ }
372
+ async processCollection(auth, collection, state) {
373
+ this.log(chalk.bold(`\nCollection: ${collection.displayName}`));
374
+ for await (const item of getCollectionItems(auth, collection.id)) {
375
+ if (atLimit(state))
376
+ return;
377
+ await this.processItem(auth, collection, item, state);
378
+ }
379
+ }
380
+ /**
381
+ * Fill in every image field on one item, then PATCH the item once. Fields that
382
+ * didn't change are left out of the payload entirely, and an item with no
383
+ * changed fields is never PATCHed.
384
+ */
385
+ async processItem(auth, collection, item, state) {
386
+ const fieldData = {};
387
+ for (const [fieldName, value] of Object.entries(item.fieldData)) {
388
+ if (atLimit(state))
389
+ break;
390
+ if (isImageField(value)) {
391
+ if (state.skip && value.alt)
392
+ continue;
393
+ // eslint-disable-next-line no-await-in-loop
394
+ const alt = await this.describe(state, `${collection.displayName} > ${fieldName}`, value.url);
395
+ if (alt !== undefined)
396
+ fieldData[fieldName] = { ...value, alt };
397
+ }
398
+ else if (isImagesField(value)) {
399
+ // A multi-image field has to be written back whole, so every image is
400
+ // carried over — only the ones we actually described come back changed.
401
+ const next = [];
402
+ let changed = false;
403
+ for (const image of value) {
404
+ if (atLimit(state) || (state.skip && image.alt)) {
405
+ next.push(image);
406
+ continue;
407
+ }
408
+ // eslint-disable-next-line no-await-in-loop
409
+ const alt = await this.describe(state, `${collection.displayName} > ${fieldName}`, image.url);
410
+ next.push(alt === undefined ? image : { ...image, alt });
411
+ changed ||= alt !== undefined;
412
+ }
413
+ if (changed)
414
+ fieldData[fieldName] = next;
415
+ }
416
+ }
417
+ if (Object.keys(fieldData).length === 0)
418
+ return;
419
+ await updateCollectionItem(auth, collection.id, item.id, fieldData);
420
+ }
421
+ /**
422
+ * The Shopify path: make sure the CLI is present and the store is authenticated
423
+ * with the file scopes, then caption every image in the file library. Always
424
+ * returns false — there's no separate staging step to warn about.
425
+ */
426
+ async processShopify(flags, state) {
427
+ const bin = findShopifyBin();
428
+ if (!bin) {
429
+ this.error('The Shopify CLI is required for --platform shopify but was not found. Install it and run `shopify login` first: https://shopify.dev/docs/api/shopify-cli', { code: '1' });
430
+ }
431
+ const store = normalizeStore(flags['site-id'] ?? (await input({ message: 'Shopify store (e.g. mystore)', validate: required })));
432
+ this.ensureShopifyScopes(bin, store, state.dry ? DRY_RUN_SCOPES : REQUIRED_SCOPES);
433
+ // Best-effort: the product a file is attached to is the strongest context
434
+ // for a caption, but a hiccup mapping it shouldn't cost the whole run — a
435
+ // file with no product just falls back to its file-name hint. A dry run
436
+ // never captions, so the context would never be used — skip the (costly)
437
+ // bulk-operation mapping entirely.
438
+ let context = new Map();
439
+ if (!state.dry) {
440
+ // The mapping is a bulk operation that can take a while — spin while it runs.
441
+ const spinner = ora('Mapping product context…').start();
442
+ try {
443
+ context = await getImageProductContext(bin, store);
444
+ spinner.succeed(`${context.size} product image${context.size === 1 ? '' : 's'} mapped`);
445
+ }
446
+ catch (error) {
447
+ spinner.warn(`product context unavailable: ${error.message}`);
448
+ }
449
+ }
450
+ this.log(chalk.bold('\nStore files'));
451
+ // getImageFiles runs a bulk export before it yields anything; spin until the
452
+ // first file lands (or the stream ends), then let each image's own spinner take over.
453
+ const listing = ora('Exporting file library…').start();
454
+ try {
455
+ for await (const file of getImageFiles(bin, store)) {
456
+ if (listing.isSpinning)
457
+ listing.stop();
458
+ if (atLimit(state))
459
+ return false;
460
+ if (state.skip && file.alt)
461
+ continue;
462
+ // Dry run fast path: Shopify already told us the mimeType, dimensions and
463
+ // size, so we can filter and count without fetching a single byte. Only
464
+ // when a field is missing (an image still processing) do we fall through
465
+ // to the download. A real run skips this entirely — see the note below.
466
+ if (state.dry) {
467
+ const meta = shopifyImageMeta(file);
468
+ if (meta) {
469
+ this.logImageLine(state, file.name, file.url);
470
+ this.recordDry(state, meta);
471
+ continue;
472
+ }
473
+ }
474
+ // A real run always downloads: the bytes are needed for the model anyway,
475
+ // so they're the source of truth for the filter too, and a stale CDN value
476
+ // never decides anything. (In --dry this is only the metadata-missing
477
+ // fallback; describe() counts it without describing.)
478
+ const product = context.get(file.id);
479
+ const alt = await this.describe(state, file.name, file.url, product ? formatProductContext(product) : undefined);
480
+ if (alt !== undefined)
481
+ updateFileAlt(bin, store, file.id, alt);
482
+ }
483
+ }
484
+ finally {
485
+ // Reached with the spinner still up only when the library had no images.
486
+ if (listing.isSpinning)
487
+ listing.stop();
488
+ }
489
+ return false;
490
+ }
491
+ /**
492
+ * The Webflow path: the site asset library, then (with --cms) whichever CMS
493
+ * collections the flags select. Returns whether any CMS work was done, so the
494
+ * summary can remind the user to publish staging.
495
+ */
496
+ async processWebflow(flags, state) {
497
+ const auth = {
498
+ apiKey: flags['api-key'] ?? (await password({ mask: true, message: 'Webflow API token', validate: required })),
499
+ siteId: flags['site-id'] ?? (await input({ message: 'Webflow site ID', validate: required })),
500
+ };
501
+ // Resolved up front so a bad --only value fails before any transcription.
502
+ const collections = flags.cms ? await this.pickCollections(auth, flags.select, flags.only) : [];
503
+ await this.processAssets(auth, state);
504
+ for (const collection of collections) {
505
+ if (atLimit(state))
506
+ break;
507
+ // eslint-disable-next-line no-await-in-loop
508
+ await this.processCollection(auth, collection, state);
509
+ }
510
+ return collections.length > 0;
511
+ }
512
+ /**
513
+ * Count one image toward a --dry total from metadata already in hand — no
514
+ * download, no model. Applies --filter to decide caption-vs-skip; the caller
515
+ * has already logged the image line.
516
+ */
517
+ recordDry(state, meta) {
518
+ const detail = `${formatBytes(meta.fileSize)}, ${meta.width}x${meta.height}, ${meta.type}`;
519
+ // Bound locally so this reads as calling the filter, not Array#filter.
520
+ const { filter } = state;
521
+ if (filter && !filter(meta)) {
522
+ state.skipped.push(meta);
523
+ this.log(chalk.dim(` – filtered out (${detail})`));
524
+ return;
525
+ }
526
+ state.transcribed++;
527
+ this.log(chalk.dim(` ${chalk.green('✓')} would be captioned (${detail})`));
528
+ }
529
+ /** Totals and averages only — the per-image rows live in the --output-stats file. */
530
+ report(state, touchedCms, totalMs) {
531
+ const count = state.images.length;
532
+ const tokens = sum(state.images.map((image) => image.tokens));
533
+ const described = sum(state.images.map((image) => image.ms));
534
+ this.log('');
535
+ this.log(chalk.green(`✓ ${count} image${count === 1 ? '' : 's'} transcribed in ${formatMs(totalMs)}`));
536
+ if (count > 0) {
537
+ this.log(chalk.dim(` ${formatCount(tokens)} tokens total, ${formatCount(Math.round(tokens / count))} avg`));
538
+ this.log(chalk.dim(` ${formatMs(described / count)} avg per image`));
539
+ this.log(chalk.dim(` ${formatBytes(sum(state.images.map((image) => image.meta.fileSize)) / count)} avg source size`));
540
+ }
541
+ if (state.skipped.length > 0) {
542
+ const skippedBytes = sum(state.skipped.map((meta) => meta.fileSize));
543
+ this.log(chalk.dim(`↷ ${state.skipped.length} image${state.skipped.length === 1 ? '' : 's'} skipped by filter`));
544
+ this.log(chalk.dim(` ${formatBytes(skippedBytes)} total, ${formatBytes(skippedBytes / state.skipped.length)} avg source size`));
545
+ }
546
+ if (state.failures.length > 0) {
547
+ this.log(chalk.yellow(`⚠ ${state.failures.length} image${state.failures.length === 1 ? '' : 's'} could not be described`));
548
+ }
549
+ if (touchedCms) {
550
+ this.log(chalk.dim('CMS items were written to staging — publish the site in Webflow to make them live.'));
551
+ }
552
+ }
553
+ /**
554
+ * The --dry summary: how many images a real run would caption, the ones the
555
+ * --filter would skip, and any that couldn't even be downloaded. No tokens,
556
+ * time, or descriptions to report — nothing was described.
557
+ */
558
+ reportDry(state, totalMs) {
559
+ const count = state.transcribed;
560
+ this.log('');
561
+ this.log(chalk.green(`✓ ${formatCount(count)} image${count === 1 ? '' : 's'} would be captioned (dry run, ${formatMs(totalMs)})`));
562
+ if (Number.isFinite(state.limit) && count >= state.limit) {
563
+ this.log(chalk.dim(` stopped at --limit ${state.limit}; a run without it may caption more`));
564
+ }
565
+ if (state.skipped.length > 0) {
566
+ const skippedBytes = sum(state.skipped.map((meta) => meta.fileSize));
567
+ this.log(chalk.dim(`↷ ${state.skipped.length} image${state.skipped.length === 1 ? '' : 's'} skipped by filter`));
568
+ this.log(chalk.dim(` ${formatBytes(skippedBytes)} total, ${formatBytes(skippedBytes / state.skipped.length)} avg source size`));
569
+ }
570
+ if (state.failures.length > 0) {
571
+ this.log(chalk.yellow(`⚠ ${state.failures.length} image${state.failures.length === 1 ? '' : 's'} could not be downloaded`));
572
+ }
573
+ }
574
+ /** The --platform value, or ask when it was omitted. */
575
+ async resolvePlatform(flags) {
576
+ if (flags.platform === 'shopify' || flags.platform === 'webflow')
577
+ return flags.platform;
578
+ return select({
579
+ choices: [
580
+ { name: 'Webflow', value: 'webflow' },
581
+ { name: 'Shopify', value: 'shopify' },
582
+ ],
583
+ message: 'Which platform?',
584
+ });
585
+ }
586
+ /**
587
+ * Stop a spinner and undo ora's parting cursor move. On stop, ora's clear()
588
+ * runs cursorTo(indent) on its stream (stderr) — and since stdout shares the
589
+ * terminal's cursor, the very next this.log would start at that column and be
590
+ * pushed right by `indent` spaces. Resetting to column 0 keeps the following
591
+ * line at exactly the indent it asks for.
592
+ */
593
+ stopSpinner(spinner) {
594
+ spinner.stop();
595
+ if (process.stderr.isTTY)
596
+ process.stderr.cursorTo(0);
597
+ }
598
+ /**
599
+ * Write the Markdown report. This runs after everything else, so a run that
600
+ * gets this far has already written its alt text back to Webflow — failing to
601
+ * save the report loses the record, not the work, and the message says so.
602
+ */
603
+ async writeStats(path, state, model, host, totalMs) {
604
+ const target = resolve(path);
605
+ try {
606
+ await mkdir(dirname(target), { recursive: true });
607
+ await writeFile(target, buildStatsMarkdown(state, model, host, totalMs), 'utf8');
608
+ }
609
+ catch (error) {
610
+ this.error(`Alt text was written to Webflow, but the stats file could not be saved: ${error.message}`, {
611
+ code: '1',
612
+ });
613
+ }
614
+ this.log(chalk.dim(`Stats written to ${target}`));
615
+ }
616
+ }
@@ -1,8 +1,8 @@
1
+ import { generateClaudeMd, GITIGNORE_CONTENT, initPromptPath, SHOPIFYIGNORE_CONTENT, } from '#lib/scaffold.js';
1
2
  import { Args, Command } from '@oclif/core';
2
3
  import chalk from 'chalk';
3
4
  import { existsSync, writeFileSync } from 'node:fs';
4
5
  import { join } from 'node:path';
5
- import { generateClaudeMd, GITIGNORE_CONTENT, initPromptPath, SHOPIFYIGNORE_CONTENT, } from '../lib/scaffold.js';
6
6
  export default class BackfillProject extends Command {
7
7
  static args = {
8
8
  shop: Args.string({ description: 'Shopify store handle, passed to Claude as a hint', required: false }),