@firenet-designs/fnd-cli 2.4.0 → 2.6.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 +147 -50
- package/dist/commands/alt-text.d.ts +56 -0
- package/dist/commands/alt-text.js +404 -0
- package/dist/commands/create-project.js +47 -4
- package/dist/commands/workspace/index.d.ts +16 -0
- package/dist/commands/workspace/index.js +87 -19
- package/dist/lib/alt-text.d.ts +56 -0
- package/dist/lib/alt-text.js +144 -0
- package/dist/lib/image-filter.d.ts +43 -0
- package/dist/lib/image-filter.js +71 -0
- package/dist/lib/webflow.d.ts +80 -0
- package/dist/lib/webflow.js +122 -0
- package/dist/lib/workspace.d.ts +10 -2
- package/dist/lib/workspace.js +64 -29
- package/oclif.manifest.json +155 -73
- package/package.json +5 -1
- package/dist/commands/workspace/cleanup.d.ts +0 -14
- package/dist/commands/workspace/cleanup.js +0 -84
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
import { checkbox, input, password, select } from '@inquirer/prompts';
|
|
2
|
+
import { Command, Flags } from '@oclif/core';
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
5
|
+
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';
|
|
9
|
+
const atLimit = (state) => state.transcribed >= state.limit;
|
|
10
|
+
/** Non-empty validator for the @inquirer prompts that stand in for required flags. */
|
|
11
|
+
const required = (value) => (value.trim().length > 0 ? true : 'Required');
|
|
12
|
+
/** Trim a URL down to something that fits a terminal line. */
|
|
13
|
+
const shortUrl = (url) => {
|
|
14
|
+
const name = url.split('?')[0].split('/').pop() ?? url;
|
|
15
|
+
return name.length > 60 ? `${name.slice(0, 57)}…` : name;
|
|
16
|
+
};
|
|
17
|
+
const formatMs = (ms) => (ms < 1000 ? `${Math.round(ms)}ms` : `${(ms / 1000).toFixed(1)}s`);
|
|
18
|
+
const formatBytes = (bytes) => bytes < 1024 * 1024 ? `${Math.round(bytes / 1024)} KB` : `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
19
|
+
const formatCount = (n) => n.toLocaleString('en-US');
|
|
20
|
+
/**
|
|
21
|
+
* Make a value safe to drop in a Markdown table cell: pipes would end the cell
|
|
22
|
+
* early and a newline would end the row. Descriptions are model output, so both
|
|
23
|
+
* are possible even though the prompt asks for one plain sentence.
|
|
24
|
+
*/
|
|
25
|
+
const cell = (value) => value.replaceAll('|', String.raw `\|`).replaceAll(/\s*\n\s*/g, ' ').trim();
|
|
26
|
+
const sum = (values) => values.reduce((total, value) => total + value, 0);
|
|
27
|
+
/** The thumbnail that stands in for a URL in every table. */
|
|
28
|
+
const thumbnail = (url) => cell(`<img src="${url}" style="object-fit: contain; object-position: center; max-width: 256px; max-height: 256px" />`);
|
|
29
|
+
/**
|
|
30
|
+
* The --output-stats report: totals, a row per image, the filtered-out images,
|
|
31
|
+
* then the failures.
|
|
32
|
+
*
|
|
33
|
+
* Sizes throughout are the file as the site serves it (`meta.fileSize`) — the
|
|
34
|
+
* same number the --filter sees, so a report can be read against the expression
|
|
35
|
+
* that produced it. The PNG we actually send the model gets its own column.
|
|
36
|
+
*/
|
|
37
|
+
const buildStatsMarkdown = (state, model, host, totalMs) => {
|
|
38
|
+
const tokens = sum(state.images.map((image) => image.tokens));
|
|
39
|
+
const described = sum(state.images.map((image) => image.ms));
|
|
40
|
+
const count = state.images.length;
|
|
41
|
+
const skippedBytes = sum(state.skipped.map((meta) => meta.fileSize));
|
|
42
|
+
const skippedCount = state.skipped.length;
|
|
43
|
+
const lines = [
|
|
44
|
+
'# Alt text run',
|
|
45
|
+
'',
|
|
46
|
+
`- Model: \`${model}\` on \`${host}\``,
|
|
47
|
+
`- Run duration: ${formatMs(totalMs)}`,
|
|
48
|
+
'',
|
|
49
|
+
'## Summary',
|
|
50
|
+
'',
|
|
51
|
+
'| Metric | Value |',
|
|
52
|
+
'| --- | --- |',
|
|
53
|
+
`| Images modified | ${formatCount(count)} |`,
|
|
54
|
+
`| Images skipped by filter | ${formatCount(skippedCount)} |`,
|
|
55
|
+
`| Images failed | ${formatCount(state.failures.length)} |`,
|
|
56
|
+
`| Tokens used | ${formatCount(tokens)} |`,
|
|
57
|
+
`| Average tokens per image | ${count > 0 ? formatCount(Math.round(tokens / count)) : '—'} |`,
|
|
58
|
+
`| Average time per image | ${count > 0 ? formatMs(described / count) : '—'} |`,
|
|
59
|
+
`| Skipped image total size | ${formatBytes(skippedBytes)} |`,
|
|
60
|
+
`| Skipped image average size | ${skippedCount > 0 ? formatBytes(skippedBytes / skippedCount) : '—'} |`,
|
|
61
|
+
'',
|
|
62
|
+
'## Images',
|
|
63
|
+
'',
|
|
64
|
+
];
|
|
65
|
+
if (count === 0) {
|
|
66
|
+
lines.push('_No images were transcribed._', '');
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
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} |`), '');
|
|
70
|
+
}
|
|
71
|
+
lines.push('## Skipped by filter', '');
|
|
72
|
+
if (skippedCount === 0) {
|
|
73
|
+
lines.push('_None._', '');
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
lines.push('| URL | Size | Type | Dimensions |', '| --- | --- | --- | --- |', ...state.skipped.map((meta) => `| ${thumbnail(meta.url)} | ${formatBytes(meta.fileSize)} | ${cell(meta.type)} | ${meta.width}x${meta.height} |`), '');
|
|
77
|
+
}
|
|
78
|
+
lines.push('## Failures', '');
|
|
79
|
+
if (state.failures.length === 0) {
|
|
80
|
+
lines.push('_None._', '');
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
lines.push('| URL | Reason |', '| --- | --- |', ...state.failures.map((failure) => `| ${cell(failure.url)} | ${cell(failure.reason)} |`), '');
|
|
84
|
+
}
|
|
85
|
+
return lines.join('\n');
|
|
86
|
+
};
|
|
87
|
+
export default class AltText extends Command {
|
|
88
|
+
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.';
|
|
90
|
+
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/\')"',
|
|
98
|
+
];
|
|
99
|
+
static flags = {
|
|
100
|
+
'api-key': Flags.string({
|
|
101
|
+
dependsOn: ['webflow'],
|
|
102
|
+
description: 'Webflow API token (site-scoped). Prompted for if omitted.',
|
|
103
|
+
}),
|
|
104
|
+
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.',
|
|
107
|
+
}),
|
|
108
|
+
filter: Flags.string({
|
|
109
|
+
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.',
|
|
110
|
+
}),
|
|
111
|
+
limit: Flags.integer({
|
|
112
|
+
description: 'stop after this many images have been transcribed (counted across assets and CMS together)',
|
|
113
|
+
min: 1,
|
|
114
|
+
}),
|
|
115
|
+
'ollama-host': Flags.string({
|
|
116
|
+
description: `base URL of the Ollama server. Prompted for if omitted.`,
|
|
117
|
+
}),
|
|
118
|
+
'ollama-model': Flags.string({
|
|
119
|
+
description: 'the model used to describe the images. Omit it to pick from the vision-capable models pulled on the host.',
|
|
120
|
+
}),
|
|
121
|
+
only: Flags.string({
|
|
122
|
+
dependsOn: ['cms'],
|
|
123
|
+
description: 'only touch these CMS collections, matched case-insensitively against a collection\'s name or slug. Repeat the flag for more than one.',
|
|
124
|
+
exclusive: ['select'],
|
|
125
|
+
multiple: true,
|
|
126
|
+
}),
|
|
127
|
+
'output-stats': Flags.string({
|
|
128
|
+
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
|
+
}),
|
|
130
|
+
select: Flags.boolean({
|
|
131
|
+
dependsOn: ['cms'],
|
|
132
|
+
description: 'fetch the CMS collections and pick which ones to process interactively',
|
|
133
|
+
exclusive: ['only'],
|
|
134
|
+
}),
|
|
135
|
+
shopify: Flags.boolean({
|
|
136
|
+
description: 'run against a Shopify store (not implemented yet)',
|
|
137
|
+
exclusive: ['webflow'],
|
|
138
|
+
}),
|
|
139
|
+
'site-id': Flags.string({
|
|
140
|
+
dependsOn: ['webflow'],
|
|
141
|
+
description: 'Webflow site ID. Prompted for if omitted.',
|
|
142
|
+
}),
|
|
143
|
+
skip: Flags.boolean({
|
|
144
|
+
description: 'leave images that already have alt text alone instead of overwriting them',
|
|
145
|
+
}),
|
|
146
|
+
webflow: Flags.boolean({
|
|
147
|
+
description: 'run against a Webflow site',
|
|
148
|
+
exclusive: ['shopify'],
|
|
149
|
+
}),
|
|
150
|
+
};
|
|
151
|
+
async run() {
|
|
152
|
+
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;
|
|
156
|
+
}
|
|
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));
|
|
164
|
+
// Compiled here, not at first use, so a broken expression costs nothing.
|
|
165
|
+
let filter;
|
|
166
|
+
if (flags.filter) {
|
|
167
|
+
try {
|
|
168
|
+
filter = createFilter(flags.filter);
|
|
169
|
+
}
|
|
170
|
+
catch (error) {
|
|
171
|
+
this.error(error.message, { code: '1' });
|
|
172
|
+
}
|
|
173
|
+
}
|
|
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
|
+
const state = {
|
|
177
|
+
describe: createDescriber(host, model, filter),
|
|
178
|
+
failures: [],
|
|
179
|
+
images: [],
|
|
180
|
+
limit: flags.limit ?? Number.POSITIVE_INFINITY,
|
|
181
|
+
skip: flags.skip ?? false,
|
|
182
|
+
skipped: [],
|
|
183
|
+
transcribed: 0,
|
|
184
|
+
};
|
|
185
|
+
this.log(chalk.dim(`Describing images with ${model} on ${host}`));
|
|
186
|
+
if (flags.filter)
|
|
187
|
+
this.log(chalk.dim(`Only images matching: ${flags.filter}`));
|
|
188
|
+
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
|
+
}
|
|
196
|
+
const totalMs = performance.now() - started;
|
|
197
|
+
this.report(state, collections.length > 0, totalMs);
|
|
198
|
+
if (flags['output-stats']) {
|
|
199
|
+
await this.writeStats(flags['output-stats'], state, model, host, totalMs);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Describe one image and account for it. Returns undefined when the model or
|
|
204
|
+
* the download failed — one bad image must not abort a run that may have
|
|
205
|
+
* hundreds of good ones behind it.
|
|
206
|
+
*/
|
|
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)}`));
|
|
212
|
+
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.
|
|
216
|
+
if (result.skipped) {
|
|
217
|
+
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})`));
|
|
219
|
+
return undefined;
|
|
220
|
+
}
|
|
221
|
+
const { alt, bytes, meta, ms, tokens } = result;
|
|
222
|
+
state.transcribed++;
|
|
223
|
+
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`));
|
|
226
|
+
return alt;
|
|
227
|
+
}
|
|
228
|
+
catch (error) {
|
|
229
|
+
const reason = error.message;
|
|
230
|
+
state.failures.push({ reason, url });
|
|
231
|
+
this.log(chalk.yellow(` ⚠ skipped: ${reason}`));
|
|
232
|
+
return undefined;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
/** Which CMS collections this run should walk. */
|
|
236
|
+
async pickCollections(auth, useSelect, only) {
|
|
237
|
+
const collections = await getCollections(auth);
|
|
238
|
+
if (collections.length === 0) {
|
|
239
|
+
this.log(chalk.yellow('This site has no CMS collections.'));
|
|
240
|
+
return [];
|
|
241
|
+
}
|
|
242
|
+
const label = (collection) => `${collection.displayName} (${collection.slug})`;
|
|
243
|
+
if (only) {
|
|
244
|
+
const wanted = only.map((name) => name.trim().toLowerCase());
|
|
245
|
+
const matches = (collection, name) => collection.displayName.toLowerCase() === name ||
|
|
246
|
+
collection.singularName.toLowerCase() === name ||
|
|
247
|
+
collection.slug.toLowerCase() === name;
|
|
248
|
+
const missing = wanted.filter((name) => !collections.some((collection) => matches(collection, name)));
|
|
249
|
+
if (missing.length > 0) {
|
|
250
|
+
this.error(`No CMS collection named ${missing.map((name) => `"${name}"`).join(', ')}. This site has: ${collections
|
|
251
|
+
.map((collection) => label(collection))
|
|
252
|
+
.join(', ')}.`, { code: '1' });
|
|
253
|
+
}
|
|
254
|
+
return collections.filter((collection) => wanted.some((name) => matches(collection, name)));
|
|
255
|
+
}
|
|
256
|
+
if (useSelect) {
|
|
257
|
+
return checkbox({
|
|
258
|
+
choices: collections.map((collection) => ({ name: label(collection), value: collection })),
|
|
259
|
+
message: 'Which CMS collections should get alt text?',
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
return collections;
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Ask which model to use, offering only the vision-capable models the host has
|
|
266
|
+
* already pulled — a text-only model would happily accept the request and
|
|
267
|
+
* describe nothing, and a model that isn't pulled would stall the run behind a
|
|
268
|
+
* silent download.
|
|
269
|
+
*/
|
|
270
|
+
async pickModel(host) {
|
|
271
|
+
let models;
|
|
272
|
+
try {
|
|
273
|
+
models = await listVisionModels(host);
|
|
274
|
+
}
|
|
275
|
+
catch (error) {
|
|
276
|
+
this.error(`Could not list models on ${host}: ${error.message}`, { code: '1' });
|
|
277
|
+
}
|
|
278
|
+
if (models.length === 0) {
|
|
279
|
+
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' });
|
|
280
|
+
}
|
|
281
|
+
return select({ choices: models, message: 'Which Ollama model should describe the images?' });
|
|
282
|
+
}
|
|
283
|
+
/** The site asset library pass. Always runs; --cms only adds work on top of it. */
|
|
284
|
+
async processAssets(auth, state) {
|
|
285
|
+
this.log(chalk.bold('\nSite assets'));
|
|
286
|
+
for await (const asset of getAssets(auth)) {
|
|
287
|
+
if (atLimit(state))
|
|
288
|
+
return;
|
|
289
|
+
// The asset library also holds PDFs, fonts and the like.
|
|
290
|
+
if (!asset.contentType.startsWith('image/'))
|
|
291
|
+
continue;
|
|
292
|
+
if (state.skip && asset.altText)
|
|
293
|
+
continue;
|
|
294
|
+
const alt = await this.describe(state, asset.displayName || asset.originalFileName, asset.hostedUrl);
|
|
295
|
+
if (alt !== undefined)
|
|
296
|
+
await updateAssetAltText(auth, asset.id, alt);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
async processCollection(auth, collection, state) {
|
|
300
|
+
this.log(chalk.bold(`\nCollection: ${collection.displayName}`));
|
|
301
|
+
for await (const item of getCollectionItems(auth, collection.id)) {
|
|
302
|
+
if (atLimit(state))
|
|
303
|
+
return;
|
|
304
|
+
await this.processItem(auth, collection, item, state);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* Fill in every image field on one item, then PATCH the item once. Fields that
|
|
309
|
+
* didn't change are left out of the payload entirely, and an item with no
|
|
310
|
+
* changed fields is never PATCHed.
|
|
311
|
+
*/
|
|
312
|
+
async processItem(auth, collection, item, state) {
|
|
313
|
+
const fieldData = {};
|
|
314
|
+
for (const [fieldName, value] of Object.entries(item.fieldData)) {
|
|
315
|
+
if (atLimit(state))
|
|
316
|
+
break;
|
|
317
|
+
if (isImageField(value)) {
|
|
318
|
+
if (state.skip && value.alt)
|
|
319
|
+
continue;
|
|
320
|
+
// eslint-disable-next-line no-await-in-loop
|
|
321
|
+
const alt = await this.describe(state, `${collection.displayName} > ${fieldName}`, value.url);
|
|
322
|
+
if (alt !== undefined)
|
|
323
|
+
fieldData[fieldName] = { ...value, alt };
|
|
324
|
+
}
|
|
325
|
+
else if (isImagesField(value)) {
|
|
326
|
+
// A multi-image field has to be written back whole, so every image is
|
|
327
|
+
// carried over — only the ones we actually described come back changed.
|
|
328
|
+
const next = [];
|
|
329
|
+
let changed = false;
|
|
330
|
+
for (const image of value) {
|
|
331
|
+
if (atLimit(state) || (state.skip && image.alt)) {
|
|
332
|
+
next.push(image);
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
// eslint-disable-next-line no-await-in-loop
|
|
336
|
+
const alt = await this.describe(state, `${collection.displayName} > ${fieldName}`, image.url);
|
|
337
|
+
next.push(alt === undefined ? image : { ...image, alt });
|
|
338
|
+
changed ||= alt !== undefined;
|
|
339
|
+
}
|
|
340
|
+
if (changed)
|
|
341
|
+
fieldData[fieldName] = next;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
if (Object.keys(fieldData).length === 0)
|
|
345
|
+
return;
|
|
346
|
+
await updateCollectionItem(auth, collection.id, item.id, fieldData);
|
|
347
|
+
}
|
|
348
|
+
/** Totals and averages only — the per-image rows live in the --output-stats file. */
|
|
349
|
+
report(state, touchedCms, totalMs) {
|
|
350
|
+
const count = state.images.length;
|
|
351
|
+
const tokens = sum(state.images.map((image) => image.tokens));
|
|
352
|
+
const described = sum(state.images.map((image) => image.ms));
|
|
353
|
+
this.log('');
|
|
354
|
+
this.log(chalk.green(`✓ ${count} image${count === 1 ? '' : 's'} transcribed in ${formatMs(totalMs)}`));
|
|
355
|
+
if (count > 0) {
|
|
356
|
+
this.log(chalk.dim(` ${formatCount(tokens)} tokens total, ${formatCount(Math.round(tokens / count))} avg`));
|
|
357
|
+
this.log(chalk.dim(` ${formatMs(described / count)} avg per image`));
|
|
358
|
+
this.log(chalk.dim(` ${formatBytes(sum(state.images.map((image) => image.meta.fileSize)) / count)} avg source size`));
|
|
359
|
+
}
|
|
360
|
+
if (state.skipped.length > 0) {
|
|
361
|
+
const skippedBytes = sum(state.skipped.map((meta) => meta.fileSize));
|
|
362
|
+
this.log(chalk.dim(`↷ ${state.skipped.length} image${state.skipped.length === 1 ? '' : 's'} skipped by filter`));
|
|
363
|
+
this.log(chalk.dim(` ${formatBytes(skippedBytes)} total, ${formatBytes(skippedBytes / state.skipped.length)} avg source size`));
|
|
364
|
+
}
|
|
365
|
+
if (state.failures.length > 0) {
|
|
366
|
+
this.log(chalk.yellow(`⚠ ${state.failures.length} image${state.failures.length === 1 ? '' : 's'} could not be described`));
|
|
367
|
+
}
|
|
368
|
+
if (touchedCms) {
|
|
369
|
+
this.log(chalk.dim('CMS items were written to staging — publish the site in Webflow to make them live.'));
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
/** --webflow / --shopify, or ask when neither was given. */
|
|
373
|
+
async resolvePlatform(flags) {
|
|
374
|
+
if (flags.shopify)
|
|
375
|
+
return 'shopify';
|
|
376
|
+
if (flags.webflow)
|
|
377
|
+
return 'webflow';
|
|
378
|
+
return select({
|
|
379
|
+
choices: [
|
|
380
|
+
{ name: 'Webflow', value: 'webflow' },
|
|
381
|
+
{ description: 'Not implemented yet', name: 'Shopify', value: 'shopify' },
|
|
382
|
+
],
|
|
383
|
+
message: 'Which platform?',
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* Write the Markdown report. This runs after everything else, so a run that
|
|
388
|
+
* gets this far has already written its alt text back to Webflow — failing to
|
|
389
|
+
* save the report loses the record, not the work, and the message says so.
|
|
390
|
+
*/
|
|
391
|
+
async writeStats(path, state, model, host, totalMs) {
|
|
392
|
+
const target = resolve(path);
|
|
393
|
+
try {
|
|
394
|
+
await mkdir(dirname(target), { recursive: true });
|
|
395
|
+
await writeFile(target, buildStatsMarkdown(state, model, host, totalMs), 'utf8');
|
|
396
|
+
}
|
|
397
|
+
catch (error) {
|
|
398
|
+
this.error(`Alt text was written to Webflow, but the stats file could not be saved: ${error.message}`, {
|
|
399
|
+
code: '1',
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
this.log(chalk.dim(`Stats written to ${target}`));
|
|
403
|
+
}
|
|
404
|
+
}
|
|
@@ -9,7 +9,7 @@ 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 }),
|
|
11
11
|
};
|
|
12
|
-
static description = 'Scaffold a new client project: git on branch production, ignore files, Shopify theme pull, a Claude-generated CLAUDE.md, then a private GitHub repo under the FireNet-Designs org.\n\nRequires the claude CLI (npm install -g @anthropic-ai/claude-code) for the CLAUDE.md step.\n\nGitHub auth comes from YOUR environment — run `gh auth login` once, or export GH_TOKEN in your shell profile. This CLI never stores credentials. Override the org with FND_GH_ORG.';
|
|
12
|
+
static description = 'Scaffold a new client project: git on branch production, ignore files, Shopify theme pull, a Claude-generated CLAUDE.md, then a private GitHub repo under the FireNet-Designs org with production and staging branches pushed.\n\nRequires the claude CLI (npm install -g @anthropic-ai/claude-code) for the CLAUDE.md step.\n\nGitHub auth comes from YOUR environment — run `gh auth login` once, or export GH_TOKEN in your shell profile. This CLI never stores credentials. Override the org with FND_GH_ORG.';
|
|
13
13
|
static examples = [
|
|
14
14
|
'<%= config.bin %> <%= command.id %>',
|
|
15
15
|
'<%= config.bin %> <%= command.id %> my-store',
|
|
@@ -106,17 +106,60 @@ export default class CreateProject extends Command {
|
|
|
106
106
|
if (createCode !== 0) {
|
|
107
107
|
this.error('gh repo create failed — see output above', { code: '1' });
|
|
108
108
|
}
|
|
109
|
+
// gh pointed 'origin' at the URL GitHub returned, which carries the name
|
|
110
|
+
// the repo was ACTUALLY created under — GitHub normalizes names
|
|
111
|
+
// server-side ('my client' → 'my-client'). Recover it before forcing SSH
|
|
112
|
+
// so the remote, the default-branch edit, and the printed link all track
|
|
113
|
+
// the real repo, not the raw directory name.
|
|
114
|
+
const originUrl = (await git.raw(['remote', 'get-url', 'origin']).catch(() => '')).trim();
|
|
115
|
+
const canonicalRepo = /github\.com[/:](.+?)(?:\.git)?$/.exec(originUrl)?.[1] ?? repo;
|
|
109
116
|
// Force SSH so the push authenticates with the user's key, not the
|
|
110
117
|
// create-only token.
|
|
111
|
-
await git.raw(['remote', 'set-url', 'origin', `git@github.com:${
|
|
118
|
+
await git.raw(['remote', 'set-url', 'origin', `git@github.com:${canonicalRepo}.git`]);
|
|
112
119
|
this.log(chalk.blue("⬆️ Pushing 'production' to origin over SSH…"));
|
|
113
120
|
const pushCode = await run('git', ['push', '-u', 'origin', 'production']);
|
|
114
121
|
if (pushCode === 0) {
|
|
115
|
-
spawnSync('gh', ['repo', 'edit',
|
|
116
|
-
this.log(chalk.green(`✅ Created + pushed. origin = git@github.com:${
|
|
122
|
+
spawnSync('gh', ['repo', 'edit', canonicalRepo, '--default-branch', 'production'], { stdio: 'ignore' });
|
|
123
|
+
this.log(chalk.green(`✅ Created + pushed. origin = git@github.com:${canonicalRepo}.git`));
|
|
124
|
+
// 6. Staging branch cut explicitly from 'production' — HEAD isn't a safe
|
|
125
|
+
// start point because a pre-existing repo (init step skipped) may be
|
|
126
|
+
// checked out elsewhere. No checkout, so the working tree stays put.
|
|
127
|
+
// A pre-existing 'staging' is pushed as-is (force-resetting it could
|
|
128
|
+
// destroy work), with divergence from production called out instead of
|
|
129
|
+
// silently published. try/catch so a git failure here can't swallow the
|
|
130
|
+
// URL line below — the repo exists either way.
|
|
131
|
+
try {
|
|
132
|
+
const { all: localBranches } = await git.branchLocal();
|
|
133
|
+
const stagingExisted = localBranches.includes('staging');
|
|
134
|
+
if (!stagingExisted) {
|
|
135
|
+
await git.raw(['branch', 'staging', 'production']);
|
|
136
|
+
}
|
|
137
|
+
this.log(chalk.blue("⬆️ Pushing 'staging' to origin over SSH…"));
|
|
138
|
+
const stagingPushCode = await run('git', ['push', '-u', 'origin', 'staging']);
|
|
139
|
+
if (stagingPushCode !== 0) {
|
|
140
|
+
this.warn("push of 'staging' failed — create it manually: git push -u origin staging");
|
|
141
|
+
}
|
|
142
|
+
else if (stagingExisted) {
|
|
143
|
+
this.log(chalk.green("✅ pre-existing 'staging' branch pushed"));
|
|
144
|
+
const [stagingSha, productionSha] = (await git.raw(['rev-parse', 'staging', 'production'])).trim().split('\n');
|
|
145
|
+
if (stagingSha !== productionSha) {
|
|
146
|
+
this.warn("'staging' does not match 'production' — it was pushed at its current commit");
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
else {
|
|
150
|
+
this.log(chalk.green("✅ 'staging' branch created + pushed"));
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
this.warn("could not create 'staging' — create it manually: git branch staging production && git push -u origin staging");
|
|
155
|
+
}
|
|
117
156
|
}
|
|
118
157
|
else {
|
|
119
158
|
this.warn(`push failed — check your SSH access to the ${org} org`);
|
|
120
159
|
}
|
|
160
|
+
// https form, not the SSH remote, and printed even when a push failed (the
|
|
161
|
+
// repo itself exists by now) — this line is for copy-pasting into Jira
|
|
162
|
+
// tickets and browsers.
|
|
163
|
+
this.log(`🔗 GitHub: ${chalk.green(`https://github.com/${canonicalRepo}`)}`);
|
|
121
164
|
}
|
|
122
165
|
}
|
|
@@ -3,6 +3,7 @@ export default class Workspace extends Command {
|
|
|
3
3
|
static description: string;
|
|
4
4
|
static examples: string[];
|
|
5
5
|
static flags: {
|
|
6
|
+
cleanup: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
6
7
|
'delete-remote-dir': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
7
8
|
devtools: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
8
9
|
'ignore-vcs': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
@@ -15,6 +16,21 @@ export default class Workspace extends Command {
|
|
|
15
16
|
/** Verify this machine can drive the sync before we connect. */
|
|
16
17
|
private preflight;
|
|
17
18
|
private printPlan;
|
|
19
|
+
/**
|
|
20
|
+
* Tear down leftovers from a session that dropped before cleaning up (--cleanup).
|
|
21
|
+
* Terminates this directory's Mutagen sync sessions locally, then reaches the
|
|
22
|
+
* remote to strip the MCP entries the matching flags imply and, with
|
|
23
|
+
* --delete-remote-dir, remove the synced dir. devtools/rpc are read for
|
|
24
|
+
* presence only — their port values are irrelevant to removal.
|
|
25
|
+
*/
|
|
26
|
+
private runCleanup;
|
|
18
27
|
/** Run the interactive ssh session, inheriting the TTY so the remote shell is fully interactive. */
|
|
19
28
|
private runSsh;
|
|
29
|
+
/**
|
|
30
|
+
* Best-effort: reach back to the remote after the session to strip any MCP
|
|
31
|
+
* config this run registered and, with --delete-remote-dir, remove the synced
|
|
32
|
+
* dir. No-op when the session left nothing behind to clean. A failure here is
|
|
33
|
+
* logged, not thrown — the local sync is already torn down by this point.
|
|
34
|
+
*/
|
|
35
|
+
private teardownRemote;
|
|
20
36
|
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { Command, Flags } from '@oclif/core';
|
|
2
2
|
import chalk from 'chalk';
|
|
3
3
|
import { spawn } from 'node:child_process';
|
|
4
|
+
import { basename } from 'node:path';
|
|
4
5
|
import { hasLocalShell, parseRpcFlag, RPC_FLAG_EXAMPLES, RPC_FLAG_USAGE, startRpcServer } from '../../lib/rpc.js';
|
|
5
|
-
import { browserDebugInstructions, buildContext, buildMutagenCreateArgs, buildMutagenFlushArgs, buildMutagenTerminateArgs, buildRemoteScript, DEFAULT_MOUNT_BASE, hasMutagen, hasSshClient, isLocalDebugPortLive, mutagenInstallInstructions, parsePortPair, parseSshTarget, runMutagen, runRemoteCleanup, } from '../../lib/workspace.js';
|
|
6
|
+
import { browserDebugInstructions, buildContext, buildMutagenCreateArgs, buildMutagenFlushArgs, buildMutagenTerminateArgs, buildMutagenTerminateSelectorArgs, buildRemoteScript, DEFAULT_MOUNT_BASE, hasMutagen, hasSshClient, isLocalDebugPortLive, mutagenInstallInstructions, parsePortPair, parseSshTarget, runMutagen, runRemoteCleanup, slugify, } from '../../lib/workspace.js';
|
|
6
7
|
export default class Workspace extends Command {
|
|
7
8
|
static description = 'Open a remote workspace: two-way sync the current directory to a remote Linux box with Mutagen and drop into a shell there, tearing the sync down on exit.\n\nBoth sides keep a real copy on local disk and only deltas cross the network, so the remote reads files at native speed. Conflicting edits on both ends are flagged rather than silently overwritten; pass --source to auto-resolve them in favour of one side. Mutagen connects the normal direction (this machine → remote over SSH) and auto-deploys its agent to the remote, so no local SSH server, reverse tunnel, or authorized_keys trust is required. You need the Mutagen CLI installed on THIS machine.';
|
|
8
9
|
static examples = [
|
|
@@ -15,8 +16,13 @@ export default class Workspace extends Command {
|
|
|
15
16
|
`<%= config.bin %> <%= command.id %> --ssh user@host --rpc ${RPC_FLAG_EXAMPLES.required}`,
|
|
16
17
|
`<%= config.bin %> <%= command.id %> --ssh user@host --rpc ${RPC_FLAG_EXAMPLES.full}`,
|
|
17
18
|
'<%= config.bin %> <%= command.id %> --ssh user@host --delete-remote-dir',
|
|
19
|
+
'<%= config.bin %> <%= command.id %> --ssh user@host --rpc 7700 --cleanup',
|
|
18
20
|
];
|
|
19
21
|
static flags = {
|
|
22
|
+
cleanup: Flags.boolean({
|
|
23
|
+
default: false,
|
|
24
|
+
description: "tear down a leftover session instead of opening one — don't connect, just terminate this directory's Mutagen sync and (using whatever other flags are set) strip the remote --devtools/--rpc MCP entries and, with --delete-remote-dir, remove the remote dir. Re-run your original command with --cleanup appended after a session that dropped without cleaning up.",
|
|
25
|
+
}),
|
|
20
26
|
'delete-remote-dir': Flags.boolean({
|
|
21
27
|
default: false,
|
|
22
28
|
description: 'on exit, delete the remote workspace directory instead of leaving the synced copy in place',
|
|
@@ -48,6 +54,14 @@ export default class Workspace extends Command {
|
|
|
48
54
|
const { flags } = await this.parse(Workspace);
|
|
49
55
|
const target = parseSshTarget(flags.ssh);
|
|
50
56
|
const target2 = `${target.user}@${target.host}`;
|
|
57
|
+
// --cleanup short-circuits everything: no connection, no sync, just tear down
|
|
58
|
+
// leftovers from a session that dropped before it could clean up. The other
|
|
59
|
+
// flags are reused for *what* to clean up (devtools/rpc MCP entries, dir), and
|
|
60
|
+
// any that don't matter here are ignored — so `↑` + ` --cleanup` just works.
|
|
61
|
+
if (flags.cleanup) {
|
|
62
|
+
await this.runCleanup(target2, flags);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
51
65
|
const devtools = flags.devtools === undefined ? undefined : parsePortPair(flags.devtools, '--devtools');
|
|
52
66
|
const rpc = flags.rpc === undefined ? undefined : parseRpcFlag(flags.rpc);
|
|
53
67
|
if (devtools && rpc && devtools.remote === rpc.ports.remote) {
|
|
@@ -102,24 +116,7 @@ export default class Workspace extends Command {
|
|
|
102
116
|
await runMutagen(buildMutagenTerminateArgs(ctx.syncName)).catch(() => 1);
|
|
103
117
|
// Reach back to the remote if we left MCP config there to remove, or if we
|
|
104
118
|
// were asked to delete the synced directory.
|
|
105
|
-
|
|
106
|
-
const actions = [
|
|
107
|
-
ctx.devtools ? 'removing the remote chrome-devtools MCP config' : undefined,
|
|
108
|
-
ctx.rpc ? 'removing the remote local-shell MCP config' : undefined,
|
|
109
|
-
deleteRemoteDir ? 'deleting the remote dir' : undefined,
|
|
110
|
-
].filter(Boolean);
|
|
111
|
-
this.log(chalk.dim(`${actions.join(' and ')}…`.replace(/^./, (c) => c.toUpperCase())));
|
|
112
|
-
try {
|
|
113
|
-
await runRemoteCleanup(target2, ctx.remoteDir, {
|
|
114
|
-
deleteRemoteDir,
|
|
115
|
-
removeDevtoolsMcp: Boolean(ctx.devtools),
|
|
116
|
-
removeRpcMcp: Boolean(ctx.rpc),
|
|
117
|
-
});
|
|
118
|
-
}
|
|
119
|
-
catch (error) {
|
|
120
|
-
this.log(chalk.yellow(`Could not reach the remote to clean up (${error.message}).`));
|
|
121
|
-
}
|
|
122
|
-
}
|
|
119
|
+
await this.teardownRemote(ctx, target2, deleteRemoteDir);
|
|
123
120
|
}
|
|
124
121
|
this.log('');
|
|
125
122
|
this.log(code === 0
|
|
@@ -180,6 +177,51 @@ export default class Workspace extends Command {
|
|
|
180
177
|
}
|
|
181
178
|
this.log('');
|
|
182
179
|
}
|
|
180
|
+
/**
|
|
181
|
+
* Tear down leftovers from a session that dropped before cleaning up (--cleanup).
|
|
182
|
+
* Terminates this directory's Mutagen sync sessions locally, then reaches the
|
|
183
|
+
* remote to strip the MCP entries the matching flags imply and, with
|
|
184
|
+
* --delete-remote-dir, remove the synced dir. devtools/rpc are read for
|
|
185
|
+
* presence only — their port values are irrelevant to removal.
|
|
186
|
+
*/
|
|
187
|
+
async runCleanup(target, flags) {
|
|
188
|
+
if (!hasSshClient()) {
|
|
189
|
+
this.error('No `ssh` client found on PATH. Install OpenSSH client and try again.', { code: '1' });
|
|
190
|
+
}
|
|
191
|
+
const removeDevtoolsMcp = flags.devtools !== undefined;
|
|
192
|
+
const removeRpcMcp = flags.rpc !== undefined;
|
|
193
|
+
const deleteRemoteDir = flags['delete-remote-dir'];
|
|
194
|
+
const { remoteDir } = buildContext({ cwd: process.cwd(), remoteBase: flags['remote-base'] });
|
|
195
|
+
const slug = slugify(basename(remoteDir));
|
|
196
|
+
this.log(chalk.bold('Cleaning up workspace'));
|
|
197
|
+
this.log(` ${chalk.dim('remote:')} ${target}`);
|
|
198
|
+
this.log(` ${chalk.dim('remote dir:')} ${remoteDir}`);
|
|
199
|
+
this.log('');
|
|
200
|
+
// Terminate any lingering sync sessions for this directory (a local Mutagen op).
|
|
201
|
+
if (hasMutagen()) {
|
|
202
|
+
this.log(chalk.dim('Terminating any leftover Mutagen sync sessions…'));
|
|
203
|
+
await runMutagen(buildMutagenTerminateSelectorArgs(slug)).catch(() => 1);
|
|
204
|
+
}
|
|
205
|
+
else {
|
|
206
|
+
this.log(chalk.yellow('Mutagen CLI not found on PATH — skipping sync termination.'));
|
|
207
|
+
}
|
|
208
|
+
if (removeDevtoolsMcp) {
|
|
209
|
+
this.log(chalk.dim('Removing any leftover chrome-devtools MCP config on the remote…'));
|
|
210
|
+
}
|
|
211
|
+
if (removeRpcMcp) {
|
|
212
|
+
this.log(chalk.dim('Removing any leftover local-shell MCP config on the remote…'));
|
|
213
|
+
}
|
|
214
|
+
if (deleteRemoteDir) {
|
|
215
|
+
this.log(chalk.dim('Deleting the remote workspace directory…'));
|
|
216
|
+
}
|
|
217
|
+
const code = await runRemoteCleanup(target, remoteDir, { deleteRemoteDir, removeDevtoolsMcp, removeRpcMcp });
|
|
218
|
+
if (code === 0) {
|
|
219
|
+
this.log(chalk.green('✓ Done.'));
|
|
220
|
+
}
|
|
221
|
+
else {
|
|
222
|
+
this.error(`Remote cleanup ssh session exited with code ${code}.`, { code: '1' });
|
|
223
|
+
}
|
|
224
|
+
}
|
|
183
225
|
/** Run the interactive ssh session, inheriting the TTY so the remote shell is fully interactive. */
|
|
184
226
|
runSsh(target, script, ctx) {
|
|
185
227
|
// Reverse tunnels: remote 127.0.0.1:<remote> → this machine's 127.0.0.1:<local>.
|
|
@@ -199,4 +241,30 @@ export default class Workspace extends Command {
|
|
|
199
241
|
child.once('close', (code) => resolve(code ?? 0));
|
|
200
242
|
});
|
|
201
243
|
}
|
|
244
|
+
/**
|
|
245
|
+
* Best-effort: reach back to the remote after the session to strip any MCP
|
|
246
|
+
* config this run registered and, with --delete-remote-dir, remove the synced
|
|
247
|
+
* dir. No-op when the session left nothing behind to clean. A failure here is
|
|
248
|
+
* logged, not thrown — the local sync is already torn down by this point.
|
|
249
|
+
*/
|
|
250
|
+
async teardownRemote(ctx, target, deleteRemoteDir) {
|
|
251
|
+
if (!ctx.devtools && !ctx.rpc && !deleteRemoteDir)
|
|
252
|
+
return;
|
|
253
|
+
const actions = [
|
|
254
|
+
ctx.devtools ? 'removing the remote chrome-devtools MCP config' : undefined,
|
|
255
|
+
ctx.rpc ? 'removing the remote local-shell MCP config' : undefined,
|
|
256
|
+
deleteRemoteDir ? 'deleting the remote dir' : undefined,
|
|
257
|
+
].filter(Boolean);
|
|
258
|
+
this.log(chalk.dim(`${actions.join(' and ')}…`.replace(/^./, (c) => c.toUpperCase())));
|
|
259
|
+
try {
|
|
260
|
+
await runRemoteCleanup(target, ctx.remoteDir, {
|
|
261
|
+
deleteRemoteDir,
|
|
262
|
+
removeDevtoolsMcp: Boolean(ctx.devtools),
|
|
263
|
+
removeRpcMcp: Boolean(ctx.rpc),
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
catch (error) {
|
|
267
|
+
this.log(chalk.yellow(`Could not reach the remote to clean up (${error.message}).`));
|
|
268
|
+
}
|
|
269
|
+
}
|
|
202
270
|
}
|