@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.
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Alt-text generation: fetch an image, make it something a vision model can
3
+ * read, and ask a locally-hosted Ollama model to describe it.
4
+ *
5
+ * Everything runs against the user's own Ollama host, so no image ever leaves
6
+ * their network and there is no per-image API cost — which is also why the
7
+ * caller drives this strictly sequentially: a single local model gains nothing
8
+ * from concurrent requests.
9
+ */
10
+ import type { ImageFilter, ImageMeta } from './image-filter.js';
11
+ export declare const DEFAULT_OLLAMA_HOST = "http://localhost:11434";
12
+ /**
13
+ * One image's worth of result. The caller already knows the URL, so this is
14
+ * everything else worth recording about the round trip.
15
+ *
16
+ * `bytes` is the size of what was actually sent to the model, i.e. after the
17
+ * PNG conversion below — not `meta.fileSize`, which is the (usually much
18
+ * smaller) WebP the site serves. `ms` is wall clock for the whole thing
19
+ * (download, convert, inference), because that is what a run's duration is
20
+ * actually made of.
21
+ */
22
+ export interface Description {
23
+ alt: string;
24
+ bytes: number;
25
+ meta: ImageMeta;
26
+ ms: number;
27
+ skipped: false;
28
+ tokens: number;
29
+ }
30
+ /** An image the filter rejected. It was downloaded, but never sent to the model. */
31
+ export interface Skipped {
32
+ meta: ImageMeta;
33
+ skipped: true;
34
+ }
35
+ export type Describe = (url: string) => Promise<Description | Skipped>;
36
+ /**
37
+ * Every model pulled on the host that can actually read an image.
38
+ *
39
+ * /api/tags (`list`) is the only endpoint that enumerates models, but it says
40
+ * nothing about what a model can do — the capability list lives on /api/show
41
+ * (`show`), so each tag is asked individually. The calls are local and run
42
+ * concurrently; a model whose show() fails (pulled but broken, or removed
43
+ * between the two calls) is simply left out rather than failing the run.
44
+ */
45
+ export declare const listVisionModels: (host: string) => Promise<string[]>;
46
+ /**
47
+ * Bind a describer to one Ollama host and model. The client is created once and
48
+ * reused so the connection (and the loaded model, via keep_alive) survives
49
+ * across images.
50
+ *
51
+ * `filter`, when given, decides whether an image is worth describing — it runs
52
+ * after the download (see fetchImage) but before inference, and a rejected
53
+ * image comes back as `{skipped: true}` rather than throwing, because being
54
+ * filtered out is a normal outcome and not a failure.
55
+ */
56
+ export declare const createDescriber: (host: string, model: string, filter?: ImageFilter) => Describe;
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Alt-text generation: fetch an image, make it something a vision model can
3
+ * read, and ask a locally-hosted Ollama model to describe it.
4
+ *
5
+ * Everything runs against the user's own Ollama host, so no image ever leaves
6
+ * their network and there is no per-image API cost — which is also why the
7
+ * caller drives this strictly sequentially: a single local model gains nothing
8
+ * from concurrent requests.
9
+ */
10
+ import { Ollama } from 'ollama';
11
+ export const DEFAULT_OLLAMA_HOST = 'http://localhost:11434';
12
+ /** How long Ollama keeps the model resident between images, in seconds. */
13
+ const KEEP_ALIVE = 60;
14
+ const PROMPT = "Make alt text for this image. The alt text should be no more than 1 sentence. Don't be overly descriptive. Include any text in the image in your description. Verbs should be in present tense.";
15
+ /**
16
+ * Everything the model sees is PNG.
17
+ *
18
+ * Which formats a vision model can actually decode is not documented and varies
19
+ * by model — WebP in particular gets accepted and then described as if it were
20
+ * noise. Re-encoding every image removes the question: the model only ever
21
+ * receives the one format they all handle. The cost is a decode + encode per
22
+ * image, which is nothing next to the inference that follows.
23
+ *
24
+ * Both converters are native modules with real startup cost, so they're
25
+ * imported lazily — the first image of a run pays for sharp, and a run with no
26
+ * SVGs never loads resvg at all.
27
+ */
28
+ const svgToPng = async (bytes) => {
29
+ const { Resvg } = await import('@resvg/resvg-js');
30
+ // Rendered on black: SVG icons are overwhelmingly dark-on-transparent, which
31
+ // flattens to invisible on the white the model would otherwise see.
32
+ const rendered = new Resvg(Buffer.from(bytes), { background: '#000000' }).render();
33
+ // An SVG has no intrinsic pixel size, so `width`/`height` in a filter mean the
34
+ // size resvg chose to rasterize at — the viewBox, in practice.
35
+ return { height: rendered.height, png: new Uint8Array(rendered.asPng()), width: rendered.width };
36
+ };
37
+ /** Raster → PNG. Covers WebP, JPEG, AVIF, GIF, TIFF and PNG itself. */
38
+ const rasterToPng = async (bytes) => {
39
+ const { default: sharp } = await import('sharp');
40
+ // Alpha is left alone here, unlike the SVG path: a transparent raster image
41
+ // could be light or dark, so there's no background that's safe to guess.
42
+ // resolveWithObject gets the dimensions out of the same decode as the encode.
43
+ const { data, info } = await sharp(Buffer.from(bytes)).png().toBuffer({ resolveWithObject: true });
44
+ return { height: info.height, png: new Uint8Array(data), width: info.width };
45
+ };
46
+ /**
47
+ * The `type` a filter expression sees: a bare format token, not a MIME type.
48
+ *
49
+ * Content-Type is trusted first and the extension is the fallback, since CDNs
50
+ * serve plenty of images from extensionless URLs. `jpg` is normalized to `jpeg`
51
+ * and `svg+xml` to `svg` so an expression doesn't have to spell both.
52
+ */
53
+ const detectType = (contentType, path) => {
54
+ const fromHeader = contentType.split(';')[0].trim().toLowerCase();
55
+ const subtype = fromHeader.startsWith('image/') ? fromHeader.slice('image/'.length) : '';
56
+ const raw = subtype || (path.includes('.') ? path.split('.').pop() : '');
57
+ const token = raw.replace('+xml', '');
58
+ return token === 'jpg' ? 'jpeg' : token;
59
+ };
60
+ /**
61
+ * Download an image, normalize it to bytes the model accepts, and measure it.
62
+ *
63
+ * The measuring has to happen here rather than before the download: width and
64
+ * height aren't knowable without the image itself, and Content-Length is absent
65
+ * often enough that fileSize is taken from the bytes we actually received. So a
66
+ * filter saves inference time — the expensive part — not bandwidth.
67
+ */
68
+ const fetchImage = async (url) => {
69
+ const resp = await fetch(url);
70
+ if (!resp.ok)
71
+ throw new Error(`Could not download image (${resp.status} ${resp.statusText})`);
72
+ const bytes = new Uint8Array(await resp.arrayBuffer());
73
+ const contentType = resp.headers.get('content-type') ?? '';
74
+ const path = new URL(url).pathname.toLowerCase();
75
+ const type = detectType(contentType, path);
76
+ // SVG is vector, so it goes through the rasterizer rather than sharp's decoder.
77
+ const { height, png, width } = type === 'svg' || contentType.includes('svg') || path.endsWith('.svg')
78
+ ? await svgToPng(bytes)
79
+ : await rasterToPng(bytes);
80
+ return { meta: { fileSize: bytes.byteLength, height, type, url, width }, png };
81
+ };
82
+ /**
83
+ * Every model pulled on the host that can actually read an image.
84
+ *
85
+ * /api/tags (`list`) is the only endpoint that enumerates models, but it says
86
+ * nothing about what a model can do — the capability list lives on /api/show
87
+ * (`show`), so each tag is asked individually. The calls are local and run
88
+ * concurrently; a model whose show() fails (pulled but broken, or removed
89
+ * between the two calls) is simply left out rather than failing the run.
90
+ */
91
+ export const listVisionModels = async (host) => {
92
+ const ollama = new Ollama({ host });
93
+ const { models } = await ollama.list();
94
+ const checked = await Promise.all(models.map(async ({ model }) => {
95
+ try {
96
+ const { capabilities } = await ollama.show({ model });
97
+ return capabilities?.includes('vision') ? model : undefined;
98
+ }
99
+ catch {
100
+ }
101
+ }));
102
+ return checked.filter((model) => model !== undefined).sort();
103
+ };
104
+ /**
105
+ * Bind a describer to one Ollama host and model. The client is created once and
106
+ * reused so the connection (and the loaded model, via keep_alive) survives
107
+ * across images.
108
+ *
109
+ * `filter`, when given, decides whether an image is worth describing — it runs
110
+ * after the download (see fetchImage) but before inference, and a rejected
111
+ * image comes back as `{skipped: true}` rather than throwing, because being
112
+ * filtered out is a normal outcome and not a failure.
113
+ */
114
+ export const createDescriber = (host, model, filter) => {
115
+ const ollama = new Ollama({ host });
116
+ return async (url) => {
117
+ const started = performance.now();
118
+ const { meta, png } = await fetchImage(url);
119
+ if (filter && !filter(meta))
120
+ return { meta, skipped: true };
121
+ const images = [Buffer.from(png).toString("base64")];
122
+ const resp = await ollama.chat({
123
+ // eslint-disable-next-line camelcase
124
+ keep_alive: KEEP_ALIVE,
125
+ messages: [{ content: PROMPT, images, role: 'user' }],
126
+ model,
127
+ stream: false,
128
+ // think: true,
129
+ });
130
+ const content = resp.message.content.trim();
131
+ if (!content)
132
+ throw new Error(`${model} returned an empty description`);
133
+ return {
134
+ alt: content,
135
+ bytes: png.byteLength,
136
+ meta,
137
+ ms: performance.now() - started,
138
+ skipped: false,
139
+ // Prompt tokens dominate here — an image is worth hundreds of them, the
140
+ // sentence that comes back is worth a few dozen — so both halves count.
141
+ tokens: (resp.prompt_eval_count ?? 0) + (resp.eval_count ?? 0),
142
+ };
143
+ };
144
+ };
@@ -0,0 +1,43 @@
1
+ /**
2
+ * The `--filter` expression for `fnd alt-text`.
3
+ *
4
+ * The expression is evaluated with `eval`, deliberately: it comes from the flag
5
+ * the person is typing into their own shell, so it is already code they are
6
+ * running on their own machine — there is no privilege boundary to cross and no
7
+ * mini-language worth inventing when JavaScript's operators are exactly what a
8
+ * filter needs. It is NOT safe to feed this a string from anywhere else (a
9
+ * config file pulled off the network, a CI variable someone else controls).
10
+ *
11
+ * What the expression can see is the image's metadata plus `sizes`; nothing is
12
+ * passed in from the surrounding scope, because the eval'd text is a standalone
13
+ * function expression whose only bindings are its own parameters.
14
+ */
15
+ /** Everything a filter expression can test. Sizes are bytes, dimensions pixels. */
16
+ export interface ImageMeta {
17
+ /** Bytes as served by the site — not the size of the PNG we convert it to. */
18
+ fileSize: number;
19
+ height: number;
20
+ /** Short format token: `webp`, `png`, `jpeg`, `svg`, `avif`, `gif`, … */
21
+ type: string;
22
+ url: string;
23
+ width: number;
24
+ }
25
+ export type ImageFilter = (meta: ImageMeta) => boolean;
26
+ /**
27
+ * Byte-count helpers for filter expressions: `sizes.KB(100)` is 100 * 1024.
28
+ *
29
+ * Every casing of every unit is registered (`KB`, `Kb`, `kB`, `kb`) and they all
30
+ * mean the same thing — kb is NOT kilobits. Nobody filtering image files means
31
+ * bits, and a silent factor of eight is a worse outcome than a redundant alias.
32
+ * Multipliers are binary (1024), matching what an OS reports for a file.
33
+ */
34
+ export declare const sizes: Record<string, (n: number) => number>;
35
+ /**
36
+ * Compile a filter expression once, up front.
37
+ *
38
+ * Building it eagerly means a typo (`filesize`, a stray paren) fails before the
39
+ * first image is downloaded rather than four hundred images into a run — the
40
+ * expression is both parsed and run once against a probe here, since a bad
41
+ * identifier is a runtime ReferenceError, not a syntax error.
42
+ */
43
+ export declare const createFilter: (expression: string) => ImageFilter;
@@ -0,0 +1,71 @@
1
+ /**
2
+ * The `--filter` expression for `fnd alt-text`.
3
+ *
4
+ * The expression is evaluated with `eval`, deliberately: it comes from the flag
5
+ * the person is typing into their own shell, so it is already code they are
6
+ * running on their own machine — there is no privilege boundary to cross and no
7
+ * mini-language worth inventing when JavaScript's operators are exactly what a
8
+ * filter needs. It is NOT safe to feed this a string from anywhere else (a
9
+ * config file pulled off the network, a CI variable someone else controls).
10
+ *
11
+ * What the expression can see is the image's metadata plus `sizes`; nothing is
12
+ * passed in from the surrounding scope, because the eval'd text is a standalone
13
+ * function expression whose only bindings are its own parameters.
14
+ */
15
+ const UNITS = {
16
+ b: 1,
17
+ gb: 1024 ** 3,
18
+ kb: 1024,
19
+ mb: 1024 ** 2,
20
+ tb: 1024 ** 4,
21
+ };
22
+ /**
23
+ * Byte-count helpers for filter expressions: `sizes.KB(100)` is 100 * 1024.
24
+ *
25
+ * Every casing of every unit is registered (`KB`, `Kb`, `kB`, `kb`) and they all
26
+ * mean the same thing — kb is NOT kilobits. Nobody filtering image files means
27
+ * bits, and a silent factor of eight is a worse outcome than a redundant alias.
28
+ * Multipliers are binary (1024), matching what an OS reports for a file.
29
+ */
30
+ export const sizes = Object.fromEntries(Object.entries(UNITS).flatMap(([unit, multiplier]) => {
31
+ const cased = unit.length === 1
32
+ ? [unit, unit.toUpperCase()]
33
+ : [unit, unit.toUpperCase(), unit[0].toUpperCase() + unit[1], unit[0] + unit[1].toUpperCase()];
34
+ return [...new Set(cased)].map((name) => [name, (n) => n * multiplier]);
35
+ }));
36
+ /** A metadata shape used only to smoke-test the expression at startup. */
37
+ const PROBE = {
38
+ fileSize: 0,
39
+ height: 0,
40
+ type: 'png',
41
+ url: 'https://example.com/probe.png',
42
+ width: 0,
43
+ };
44
+ /**
45
+ * Compile a filter expression once, up front.
46
+ *
47
+ * Building it eagerly means a typo (`filesize`, a stray paren) fails before the
48
+ * first image is downloaded rather than four hundred images into a run — the
49
+ * expression is both parsed and run once against a probe here, since a bad
50
+ * identifier is a runtime ReferenceError, not a syntax error.
51
+ */
52
+ export const createFilter = (expression) => {
53
+ let compiled;
54
+ try {
55
+ // Indirect eval: evaluated in global scope, so the expression can't reach
56
+ // anything local to this module even by accident.
57
+ // eslint-disable-next-line no-eval
58
+ compiled = (0, eval)(`(({ fileSize, height, sizes, type, url, width }) => (${expression}))`);
59
+ }
60
+ catch (error) {
61
+ throw new Error(`--filter is not valid JavaScript: ${error.message}`);
62
+ }
63
+ const run = (meta) => Boolean(compiled({ ...meta, sizes }));
64
+ try {
65
+ run(PROBE);
66
+ }
67
+ catch (error) {
68
+ throw new Error(`--filter could not be evaluated: ${error.message}. Available values: fileSize, width, height, url, type, sizes.`);
69
+ }
70
+ return run;
71
+ };
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Minimal Webflow Data API v2 client — only what `fnd alt-text` needs.
3
+ *
4
+ * Two kinds of images live in a Webflow site and they are updated through
5
+ * completely different endpoints:
6
+ *
7
+ * site asset library /v2/sites/:site/assets -> PATCH /v2/assets/:id {altText}
8
+ * CMS image fields /v2/collections/:id/items -> PATCH /v2/collections/:id/items {items[].fieldData}
9
+ *
10
+ * Read endpoints are async generators that paginate internally, so callers just
11
+ * `for await` and never deal with offsets.
12
+ *
13
+ * SECURITY: the API key is a site-wide bearer token. It is only ever put in an
14
+ * Authorization header — never logged, never included in an error message (we
15
+ * report the URL pathname, not the full URL, in case a token ever ends up in a
16
+ * query string).
17
+ */
18
+ export interface WebflowAuth {
19
+ apiKey: string;
20
+ siteId: string;
21
+ }
22
+ export interface Asset {
23
+ altText: null | string;
24
+ contentType: string;
25
+ displayName: string;
26
+ hostedUrl: string;
27
+ id: string;
28
+ originalFileName: string;
29
+ siteId: string;
30
+ }
31
+ export interface Collection {
32
+ displayName: string;
33
+ id: string;
34
+ singularName: string;
35
+ slug: string;
36
+ }
37
+ /** A single CMS image value. `alt` is null until someone (or this command) fills it in. */
38
+ export interface ImageField {
39
+ alt: null | string;
40
+ fileId: string;
41
+ url: string;
42
+ }
43
+ export interface CollectionItem {
44
+ fieldData: Record<string, ImageField | ImageField[] | unknown>;
45
+ id: string;
46
+ isArchived: boolean;
47
+ isDraft: boolean;
48
+ }
49
+ /**
50
+ * Every asset in the site's asset library, oldest page first.
51
+ *
52
+ * @yields each asset, one page of 100 at a time.
53
+ */
54
+ export declare function getAssets(auth: WebflowAuth, limit?: number): AsyncGenerator<Asset>;
55
+ /** Every CMS collection on the site. Not paginated by Webflow. */
56
+ export declare const getCollections: (auth: WebflowAuth) => Promise<Collection[]>;
57
+ /**
58
+ * Every item in a collection.
59
+ *
60
+ * Reads from STAGING by default (the `/live` endpoint is opt-in) to match
61
+ * `updateCollectionItem`, which also writes to staging — so a run's changes
62
+ * need publishing in Webflow before they show on the live site.
63
+ *
64
+ * @yields each item in the collection.
65
+ */
66
+ export declare function getCollectionItems(auth: WebflowAuth, collectionId: string, { limit, staging }?: {
67
+ limit?: number;
68
+ staging?: boolean;
69
+ }): AsyncGenerator<CollectionItem>;
70
+ /** Set the alt text on a site asset. */
71
+ export declare const updateAssetAltText: (auth: WebflowAuth, assetId: string, altText: string) => Promise<void>;
72
+ /** Patch one item's fieldData. Only the fields present in `fieldData` are touched. */
73
+ export declare const updateCollectionItem: (auth: WebflowAuth, collectionId: string, itemId: string, fieldData: Record<string, unknown>) => Promise<void>;
74
+ /**
75
+ * Webflow hands back untyped `fieldData`, so image fields are duck-typed: a
76
+ * single image is an object with a `url`, a multi-image field is an array of
77
+ * those (an empty array counts — it is still an image field, just empty).
78
+ */
79
+ export declare const isImageField: (value: unknown) => value is ImageField;
80
+ export declare const isImagesField: (value: unknown) => value is ImageField[];
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Minimal Webflow Data API v2 client — only what `fnd alt-text` needs.
3
+ *
4
+ * Two kinds of images live in a Webflow site and they are updated through
5
+ * completely different endpoints:
6
+ *
7
+ * site asset library /v2/sites/:site/assets -> PATCH /v2/assets/:id {altText}
8
+ * CMS image fields /v2/collections/:id/items -> PATCH /v2/collections/:id/items {items[].fieldData}
9
+ *
10
+ * Read endpoints are async generators that paginate internally, so callers just
11
+ * `for await` and never deal with offsets.
12
+ *
13
+ * SECURITY: the API key is a site-wide bearer token. It is only ever put in an
14
+ * Authorization header — never logged, never included in an error message (we
15
+ * report the URL pathname, not the full URL, in case a token ever ends up in a
16
+ * query string).
17
+ */
18
+ const API = 'https://api.webflow.com/v2';
19
+ /** How many times a 429 is retried before the request is allowed to fail. */
20
+ const RATE_LIMIT_RETRIES = 3;
21
+ /** Fallback wait when Webflow rate-limits us without a Retry-After header. */
22
+ const RATE_LIMIT_FALLBACK_MS = 15_000;
23
+ const sleep = (ms) => new Promise((resolve) => {
24
+ setTimeout(resolve, ms);
25
+ });
26
+ /**
27
+ * One request against the Webflow API, with a bounded retry on 429.
28
+ *
29
+ * A full site run is hundreds of sequential requests spread over however long
30
+ * the vision model takes, so hitting the per-minute cap is a matter of site
31
+ * size, not of anything the caller did wrong — dying on it would throw away all
32
+ * the work done so far.
33
+ */
34
+ const request = async (auth, url, init = {}) => {
35
+ const { pathname } = new URL(url);
36
+ const method = init.method ?? 'GET';
37
+ for (let attempt = 0;; attempt++) {
38
+ // eslint-disable-next-line no-await-in-loop
39
+ const resp = await fetch(url, {
40
+ ...init,
41
+ headers: { Authorization: `Bearer ${auth.apiKey}`, ...init.headers },
42
+ });
43
+ // eslint-disable-next-line no-await-in-loop
44
+ if (resp.ok)
45
+ return (await resp.json());
46
+ if (resp.status === 429 && attempt < RATE_LIMIT_RETRIES) {
47
+ const retryAfter = Number(resp.headers.get('retry-after'));
48
+ // eslint-disable-next-line no-await-in-loop
49
+ await sleep(Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1000 : RATE_LIMIT_FALLBACK_MS);
50
+ continue;
51
+ }
52
+ // eslint-disable-next-line no-await-in-loop
53
+ const body = await resp.text().catch(() => '');
54
+ throw new Error(`Webflow ${method} ${pathname} failed (${resp.status} ${resp.statusText})${body ? `: ${body.slice(0, 300)}` : ''}`);
55
+ }
56
+ };
57
+ /**
58
+ * Every asset in the site's asset library, oldest page first.
59
+ *
60
+ * @yields each asset, one page of 100 at a time.
61
+ */
62
+ export async function* getAssets(auth, limit = 100) {
63
+ for (let page = 0;; page++) {
64
+ const url = new URL(`${API}/sites/${auth.siteId}/assets`);
65
+ url.searchParams.set('offset', `${page * limit}`);
66
+ url.searchParams.set('limit', `${limit}`);
67
+ // eslint-disable-next-line no-await-in-loop
68
+ const data = await request(auth, url);
69
+ yield* data.assets;
70
+ if (page + 1 >= Math.ceil(data.pagination.total / data.pagination.limit))
71
+ return;
72
+ }
73
+ }
74
+ /** Every CMS collection on the site. Not paginated by Webflow. */
75
+ export const getCollections = async (auth) => {
76
+ const data = await request(auth, `${API}/sites/${auth.siteId}/collections`);
77
+ return data.collections;
78
+ };
79
+ /**
80
+ * Every item in a collection.
81
+ *
82
+ * Reads from STAGING by default (the `/live` endpoint is opt-in) to match
83
+ * `updateCollectionItem`, which also writes to staging — so a run's changes
84
+ * need publishing in Webflow before they show on the live site.
85
+ *
86
+ * @yields each item in the collection.
87
+ */
88
+ export async function* getCollectionItems(auth, collectionId, { limit = 100, staging = true } = {}) {
89
+ for (let page = 0;; page++) {
90
+ const url = new URL(`${API}/collections/${collectionId}/items${staging ? '' : '/live'}`);
91
+ url.searchParams.set('offset', `${page * limit}`);
92
+ url.searchParams.set('limit', `${limit}`);
93
+ // eslint-disable-next-line no-await-in-loop
94
+ const data = await request(auth, url);
95
+ yield* data.items;
96
+ if (page + 1 >= Math.ceil(data.pagination.total / data.pagination.limit))
97
+ return;
98
+ }
99
+ }
100
+ /** Set the alt text on a site asset. */
101
+ export const updateAssetAltText = async (auth, assetId, altText) => {
102
+ await request(auth, `${API}/assets/${assetId}`, {
103
+ body: JSON.stringify({ altText }),
104
+ headers: { 'Content-Type': 'application/json' },
105
+ method: 'PATCH',
106
+ });
107
+ };
108
+ /** Patch one item's fieldData. Only the fields present in `fieldData` are touched. */
109
+ export const updateCollectionItem = async (auth, collectionId, itemId, fieldData) => {
110
+ await request(auth, `${API}/collections/${collectionId}/items`, {
111
+ body: JSON.stringify({ items: [{ fieldData, id: itemId }] }),
112
+ headers: { 'Content-Type': 'application/json' },
113
+ method: 'PATCH',
114
+ });
115
+ };
116
+ /**
117
+ * Webflow hands back untyped `fieldData`, so image fields are duck-typed: a
118
+ * single image is an object with a `url`, a multi-image field is an array of
119
+ * those (an empty array counts — it is still an image field, just empty).
120
+ */
121
+ export const isImageField = (value) => typeof value === 'object' && value !== null && !Array.isArray(value) && 'url' in value;
122
+ export const isImagesField = (value) => Array.isArray(value) && value.every((entry) => isImageField(entry));
@@ -139,7 +139,15 @@ export interface RemoteCleanupOptions {
139
139
  /** Strip this project's local-shell MCP entry — only when the workspace registered one (--rpc). */
140
140
  removeRpcMcp?: boolean;
141
141
  }
142
- /** Run the remote-side teardown over a fresh ssh connection. */
142
+ /**
143
+ * Run the remote-side teardown over a fresh ssh connection. Forces a remote PTY
144
+ * with `-tt`: the MCP-remove step runs under an *interactive* login shell
145
+ * (`$SHELL -lic`, so `claude2` aliases in rc files resolve — see
146
+ * `claudeMcpRemoveScript`), and an interactive bash without a PTY prints
147
+ * "cannot set terminal process group / no job control in this shell". `-tt`
148
+ * forces allocation even when this cleanup runs without a local TTY, mirroring
149
+ * the `-t` the interactive session ssh uses.
150
+ */
143
151
  export declare const runRemoteCleanup: (target: string, remoteDir: string, opts?: RemoteCleanupOptions) => Promise<number>;
144
152
  /**
145
153
  * The remote-side teardown script. Runs from inside the workspace dir so the
@@ -161,7 +169,7 @@ export declare const hasMutagen: () => boolean;
161
169
  * winner. When a `source` is given, it becomes the Mutagen alpha endpoint and
162
170
  * the mode switches to two-way-resolved (alpha always wins conflicts), so
163
171
  * `--source remote` puts the server first and `--source local` puts this
164
- * machine first. Labels let `workspace cleanup` find and terminate orphans.
172
+ * machine first. Labels let `workspace --cleanup` find and terminate orphans.
165
173
  * `ctx.ignores` (from --ignore-vcs) excludes gitignored paths from the sync so
166
174
  * each side keeps its own build artifacts and platform-specific binaries.
167
175
  */