@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
|
@@ -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
|
|
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\
|
|
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 %> --
|
|
93
|
-
'<%= config.bin %> <%= command.id %> --
|
|
94
|
-
'<%= config.bin %> <%= command.id %> --webflow --
|
|
95
|
-
'<%= config.bin %> <%= command.id %> --webflow --
|
|
96
|
-
'<%= config.bin %> <%= command.id %> --webflow --
|
|
97
|
-
'<%= config.bin %> <%= command.id %> --webflow --
|
|
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
|
-
|
|
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
|
-
|
|
106
|
-
|
|
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
|
-
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
const
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
|
|
215
|
-
|
|
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(`
|
|
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(`
|
|
225
|
-
this.log(chalk.dim(`
|
|
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(`
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
{
|
|
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
|
-
|
|
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
|
/**
|