@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.
Files changed (54) hide show
  1. package/README.md +103 -63
  2. package/bin/dev.js +1 -1
  3. package/dist/commands/alt-text.d.ts +64 -15
  4. package/dist/commands/alt-text.js +277 -65
  5. package/dist/commands/backfill-project.js +1 -1
  6. package/dist/commands/create-project.js +1 -1
  7. package/dist/commands/workspace/index.d.ts +3 -2
  8. package/dist/commands/workspace/index.js +96 -49
  9. package/dist/lib/alt-text.d.ts +33 -2
  10. package/dist/lib/alt-text.js +56 -4
  11. package/dist/lib/mcp/bracket-args.d.ts +37 -0
  12. package/dist/lib/mcp/bracket-args.js +65 -0
  13. package/dist/lib/mcp/define-tool.d.ts +52 -0
  14. package/dist/lib/mcp/define-tool.js +2 -0
  15. package/dist/lib/mcp/registry.d.ts +38 -0
  16. package/dist/lib/mcp/registry.js +98 -0
  17. package/dist/lib/mcp/server.d.ts +66 -0
  18. package/dist/lib/mcp/server.js +176 -0
  19. package/dist/lib/mcp/tools/shopify-common.d.ts +139 -0
  20. package/dist/lib/mcp/tools/shopify-common.js +167 -0
  21. package/dist/lib/mcp/tools/shopify-execute.d.ts +2 -0
  22. package/dist/lib/mcp/tools/shopify-execute.js +105 -0
  23. package/dist/lib/mcp/tools/shopify-file-delete.d.ts +2 -0
  24. package/dist/lib/mcp/tools/shopify-file-delete.js +49 -0
  25. package/dist/lib/mcp/tools/shopify-file-replace.d.ts +2 -0
  26. package/dist/lib/mcp/tools/shopify-file-replace.js +79 -0
  27. package/dist/lib/mcp/tools/shopify-file-search.d.ts +2 -0
  28. package/dist/lib/mcp/tools/shopify-file-search.js +199 -0
  29. package/dist/lib/mcp/tools/shopify-file-upload.d.ts +2 -0
  30. package/dist/lib/mcp/tools/shopify-file-upload.js +76 -0
  31. package/dist/lib/shopify/graphql/AccessScopes.graphql +7 -0
  32. package/dist/lib/shopify/graphql/CurrentBulkOperation.graphql +8 -0
  33. package/dist/lib/shopify/graphql/FileCreate.graphql +25 -0
  34. package/dist/lib/shopify/graphql/FileDelete.graphql +11 -0
  35. package/dist/lib/shopify/graphql/FileReplace.graphql +26 -0
  36. package/dist/lib/shopify/graphql/FileStatus.graphql +19 -0
  37. package/dist/lib/shopify/graphql/FilesBulkQuery.graphql +27 -0
  38. package/dist/lib/shopify/graphql/ProductsBulkQuery.graphql +27 -0
  39. package/dist/lib/shopify/graphql/SearchFiles.graphql +36 -0
  40. package/dist/lib/shopify/graphql/StagedUploadsCreate.graphql +20 -0
  41. package/dist/lib/shopify/graphql/StartBulkQuery.graphql +16 -0
  42. package/dist/lib/shopify/graphql/UpdateFileAlt.graphql +9 -0
  43. package/dist/lib/shopify/shopify.d.ts +228 -0
  44. package/dist/lib/shopify/shopify.js +662 -0
  45. package/dist/lib/workspace.d.ts +19 -8
  46. package/dist/lib/workspace.js +13 -13
  47. package/oclif.manifest.json +48 -46
  48. package/package.json +17 -10
  49. package/dist/hooks/init/check-for-updates.d.ts +0 -3
  50. package/dist/hooks/init/check-for-updates.js +0 -15
  51. package/dist/lib/kv-flag.d.ts +0 -15
  52. package/dist/lib/kv-flag.js +0 -75
  53. package/dist/lib/rpc.d.ts +0 -69
  54. package/dist/lib/rpc.js +0 -313
@@ -1,12 +1,33 @@
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';
1
6
  import { checkbox, input, password, select } from '@inquirer/prompts';
2
7
  import { Command, Flags } from '@oclif/core';
3
8
  import chalk from 'chalk';
4
9
  import { mkdir, writeFile } from 'node:fs/promises';
5
10
  import { dirname, resolve } from 'node:path';
6
- import { createDescriber, DEFAULT_OLLAMA_HOST, listVisionModels } from '../lib/alt-text.js';
7
- import { createFilter } from '../lib/image-filter.js';
8
- import { getAssets, getCollectionItems, getCollections, isImageField, isImagesField, updateAssetAltText, updateCollectionItem, } from '../lib/webflow.js';
11
+ import ora from 'ora';
9
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
+ };
10
31
  /** Non-empty validator for the @inquirer prompts that stand in for required flags. */
11
32
  const required = (value) => (value.trim().length > 0 ? true : 'Required');
12
33
  /** Trim a URL down to something that fits a terminal line. */
@@ -86,24 +107,27 @@ const buildStatsMarkdown = (state, model, host, totalMs) => {
86
107
  };
87
108
  export default class AltText extends Command {
88
109
  static aliases = ['caption'];
89
- static description = 'Generate alt text for a site\'s images with a local Ollama vision model and write it back.\n\nWalks the Webflow site asset library and (with --cms) the image fields of CMS collection items, describes every image that needs alt text, and PATCHes the description back. Images are fetched and described one at a time — a single local model gains nothing from concurrency, and Webflow rate-limits. Nothing leaves your network except the Webflow API calls.\n\nCMS writes go to STAGING, so publish the site in Webflow to make them live. Any required flag you omit is prompted for.';
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.';
90
111
  static examples = [
91
- '<%= config.bin %> <%= command.id %> --webflow --api-key <key> --site-id <id> --ollama-host http://localhost:11434 --ollama-model qwen3-vl:8b',
92
- '<%= config.bin %> <%= command.id %> --webflow --skip --limit 20',
93
- '<%= config.bin %> <%= command.id %> --webflow --cms --select',
94
- '<%= config.bin %> <%= command.id %> --webflow --cms --only products --only sku',
95
- '<%= config.bin %> <%= command.id %> --webflow --output-stats ./alt-text-run.md',
96
- '<%= config.bin %> <%= command.id %> --webflow --filter "fileSize>=sizes.KB(100) && width>=100 && height>=100"',
97
- '<%= config.bin %> <%= command.id %> --webflow --filter "type !== \'svg\' && !url.includes(\'/icons/\')"',
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/\')"',
98
121
  ];
99
122
  static flags = {
100
123
  'api-key': Flags.string({
101
- dependsOn: ['webflow'],
102
- description: 'Webflow API token (site-scoped). Prompted for if omitted.',
124
+ description: 'Webflow API token (site-scoped). Webflow only — Shopify auth comes from the Shopify CLI. Prompted for if omitted.',
103
125
  }),
104
126
  cms: Flags.boolean({
105
- dependsOn: ['webflow'],
106
- description: 'also add alt text to the images in CMS collection items. Off by default — only the site asset library is walked.',
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.',
107
131
  }),
108
132
  filter: Flags.string({
109
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.',
@@ -127,40 +151,45 @@ export default class AltText extends Command {
127
151
  'output-stats': Flags.string({
128
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.',
129
153
  }),
154
+ platform: Flags.string({
155
+ description: 'which platform to run against. Prompted for if omitted.',
156
+ options: ['webflow', 'shopify'],
157
+ }),
130
158
  select: Flags.boolean({
131
159
  dependsOn: ['cms'],
132
160
  description: 'fetch the CMS collections and pick which ones to process interactively',
133
161
  exclusive: ['only'],
134
162
  }),
135
- shopify: Flags.boolean({
136
- description: 'run against a Shopify store (not implemented yet)',
137
- exclusive: ['webflow'],
138
- }),
139
163
  'site-id': Flags.string({
140
- dependsOn: ['webflow'],
141
- description: 'Webflow site ID. Prompted for if omitted.',
164
+ description: 'Webflow site ID, or the Shopify store (mystore or mystore.myshopify.com). Prompted for if omitted.',
142
165
  }),
143
166
  skip: Flags.boolean({
144
167
  description: 'leave images that already have alt text alone instead of overwriting them',
145
168
  }),
146
- webflow: Flags.boolean({
147
- description: 'run against a Webflow site',
148
- exclusive: ['shopify'],
149
- }),
150
169
  };
151
170
  async run() {
152
171
  const { flags } = await this.parse(AltText);
153
- if (await this.resolvePlatform(flags) === 'shopify') {
154
- this.log(chalk.yellow('Shopify is not implemented yet — nothing to do.'));
155
- return;
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' });
156
181
  }
157
- const auth = {
158
- apiKey: flags['api-key'] ?? (await password({ mask: true, message: 'Webflow API token', validate: required })),
159
- siteId: flags['site-id'] ?? (await input({ message: 'Webflow site ID', validate: required })),
160
- };
161
- const host = flags['ollama-host'] ??
162
- (await input({ default: DEFAULT_OLLAMA_HOST, message: 'Ollama host', validate: required }));
163
- const model = flags['ollama-model'] ?? (await this.pickModel(host));
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));
164
193
  // Compiled here, not at first use, so a broken expression costs nothing.
165
194
  let filter;
166
195
  if (flags.filter) {
@@ -171,30 +200,28 @@ export default class AltText extends Command {
171
200
  this.error(error.message, { code: '1' });
172
201
  }
173
202
  }
174
- // Resolved up front so a bad --only value fails before any transcription.
175
- const collections = flags.cms ? await this.pickCollections(auth, flags.select, flags.only) : [];
176
203
  const state = {
177
- describe: createDescriber(host, model, filter),
204
+ describe: createDescriber(host, model, filter, dry),
205
+ dry,
178
206
  failures: [],
207
+ filter,
179
208
  images: [],
180
209
  limit: flags.limit ?? Number.POSITIVE_INFINITY,
181
210
  skip: flags.skip ?? false,
182
211
  skipped: [],
183
212
  transcribed: 0,
184
213
  };
185
- this.log(chalk.dim(`Describing images with ${model} on ${host}`));
214
+ this.log(chalk.dim(dry ? 'Dry run — counting which images would be captioned, describing none' : `Describing images with ${model} on ${host}`));
186
215
  if (flags.filter)
187
216
  this.log(chalk.dim(`Only images matching: ${flags.filter}`));
188
217
  const started = performance.now();
189
- await this.processAssets(auth, state);
190
- for (const collection of collections) {
191
- if (atLimit(state))
192
- break;
193
- // eslint-disable-next-line no-await-in-loop
194
- await this.processCollection(auth, collection, state);
195
- }
218
+ const touchedCms = platform === 'shopify' ? await this.processShopify(flags, state) : await this.processWebflow(flags, state);
196
219
  const totalMs = performance.now() - started;
197
- this.report(state, collections.length > 0, totalMs);
220
+ if (dry) {
221
+ this.reportDry(state, totalMs);
222
+ return;
223
+ }
224
+ this.report(state, touchedCms, totalMs);
198
225
  if (flags['output-stats']) {
199
226
  await this.writeStats(flags['output-stats'], state, model, host, totalMs);
200
227
  }
@@ -203,35 +230,81 @@ export default class AltText extends Command {
203
230
  * Describe one image and account for it. Returns undefined when the model or
204
231
  * the download failed — one bad image must not abort a run that may have
205
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.
206
237
  */
207
- async describe(state, label, url) {
208
- const counter = Number.isFinite(state.limit)
209
- ? `${state.transcribed + 1}/${state.limit}`
210
- : `${state.transcribed + 1}`;
211
- this.log(chalk.dim(` [${counter}] ${label} ${shortUrl(url)}`));
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();
212
244
  try {
213
- const result = await state.describe(url);
214
- // Filtered out: no tokens spent, so it doesn't touch the transcribed
215
- // count and doesn't spend any of the --limit budget either.
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.
216
251
  if (result.skipped) {
217
252
  state.skipped.push(result.meta);
218
- this.log(chalk.dim(` – filtered out (${formatBytes(result.meta.fileSize)}, ${result.meta.width}x${result.meta.height}, ${result.meta.type})`));
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);
219
260
  return undefined;
220
261
  }
221
262
  const { alt, bytes, meta, ms, tokens } = result;
222
263
  state.transcribed++;
223
264
  state.images.push({ bytes, description: alt, meta, ms, tokens, url });
224
- this.log(` ${chalk.green('✓')} ${alt}`);
225
- this.log(chalk.dim(` ${formatCount(tokens)} tokens · ${formatMs(ms)} · ${formatBytes(bytes)} sent`));
265
+ this.log(` ${chalk.green('✓')} ${alt}`);
266
+ this.log(chalk.dim(` ${formatCount(tokens)} tokens · ${formatMs(ms)} · ${formatBytes(bytes)} sent`));
226
267
  return alt;
227
268
  }
228
269
  catch (error) {
270
+ this.stopSpinner(spinner);
229
271
  const reason = error.message;
230
272
  state.failures.push({ reason, url });
231
- this.log(chalk.yellow(` ⚠ skipped: ${reason}`));
273
+ this.log(chalk.yellow(` ⚠ skipped: ${reason}`));
232
274
  return undefined;
233
275
  }
234
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
+ }
235
308
  /** Which CMS collections this run should walk. */
236
309
  async pickCollections(auth, useSelect, only) {
237
310
  const collections = await getCollections(auth);
@@ -345,6 +418,114 @@ export default class AltText extends Command {
345
418
  return;
346
419
  await updateCollectionItem(auth, collection.id, item.id, fieldData);
347
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
+ }
348
529
  /** Totals and averages only — the per-image rows live in the --output-stats file. */
349
530
  report(state, touchedCms, totalMs) {
350
531
  const count = state.images.length;
@@ -369,20 +550,51 @@ export default class AltText extends Command {
369
550
  this.log(chalk.dim('CMS items were written to staging — publish the site in Webflow to make them live.'));
370
551
  }
371
552
  }
372
- /** --webflow / --shopify, or ask when neither was given. */
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. */
373
575
  async resolvePlatform(flags) {
374
- if (flags.shopify)
375
- return 'shopify';
376
- if (flags.webflow)
377
- return 'webflow';
576
+ if (flags.platform === 'shopify' || flags.platform === 'webflow')
577
+ return flags.platform;
378
578
  return select({
379
579
  choices: [
380
580
  { name: 'Webflow', value: 'webflow' },
381
- { description: 'Not implemented yet', name: 'Shopify', value: 'shopify' },
581
+ { name: 'Shopify', value: 'shopify' },
382
582
  ],
383
583
  message: 'Which platform?',
384
584
  });
385
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
+ }
386
598
  /**
387
599
  * Write the Markdown report. This runs after everything else, so a run that
388
600
  * gets this far has already written its alt text back to Webflow — failing to
@@ -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 }),
@@ -1,10 +1,10 @@
1
+ import { findShopifyBin, generateClaudeMd, GITIGNORE_CONTENT, initPromptPath, run, SHOPIFYIGNORE_CONTENT, which, } from '#lib/scaffold.js';
1
2
  import { Args, Command } from '@oclif/core';
2
3
  import chalk from 'chalk';
3
4
  import { spawnSync } from 'node:child_process';
4
5
  import { existsSync, writeFileSync } from 'node:fs';
5
6
  import { basename, dirname, join } from 'node:path';
6
7
  import { simpleGit } from 'simple-git';
7
- import { findShopifyBin, generateClaudeMd, GITIGNORE_CONTENT, initPromptPath, run, SHOPIFYIGNORE_CONTENT, which, } from '../lib/scaffold.js';
8
8
  export default class CreateProject extends Command {
9
9
  static args = {
10
10
  shop: Args.string({ description: 'Shopify store handle — pulls the live theme from <shop>.myshopify.com (omit to skip)', required: false }),
@@ -8,12 +8,13 @@ export default class Workspace extends Command {
8
8
  devtools: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
9
9
  'ignore-vcs': import("@oclif/core/interfaces").BooleanFlag<boolean>;
10
10
  'remote-base': import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
11
- rpc: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
11
+ 'site-id': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
12
12
  source: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
13
13
  ssh: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
14
+ 'with-tool': import("@oclif/core/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
14
15
  };
15
16
  run(): Promise<void>;
16
- /** Verify this machine can drive the sync before we connect. */
17
+ /** Verify this machine can drive the sync — and each tool's prerequisites — before we connect. */
17
18
  private preflight;
18
19
  private printPlan;
19
20
  /**