@firenet-designs/fnd-cli 2.3.3 → 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 +183 -56
- 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 +18 -0
- package/dist/commands/workspace/index.js +144 -30
- package/dist/hooks/init/check-for-updates.js +1 -1
- 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/kv-flag.d.ts +15 -0
- package/dist/lib/kv-flag.js +75 -0
- package/dist/lib/rpc.d.ts +69 -0
- package/dist/lib/rpc.js +313 -0
- package/dist/lib/webflow.d.ts +80 -0
- package/dist/lib/webflow.js +122 -0
- package/dist/lib/workspace.d.ts +64 -14
- package/dist/lib/workspace.js +191 -34
- package/oclif.manifest.json +171 -66
- package/package.json +8 -3
- package/dist/commands/workspace/cleanup.d.ts +0 -13
- package/dist/commands/workspace/cleanup.js +0 -75
|
@@ -1,18 +1,28 @@
|
|
|
1
1
|
import { Command, Flags } from '@oclif/core';
|
|
2
2
|
import chalk from 'chalk';
|
|
3
3
|
import { spawn } from 'node:child_process';
|
|
4
|
-
import {
|
|
4
|
+
import { basename } from 'node:path';
|
|
5
|
+
import { hasLocalShell, parseRpcFlag, RPC_FLAG_EXAMPLES, RPC_FLAG_USAGE, startRpcServer } from '../../lib/rpc.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';
|
|
5
7
|
export default class Workspace extends Command {
|
|
6
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.';
|
|
7
9
|
static examples = [
|
|
8
10
|
'<%= config.bin %> <%= command.id %> --ssh user@203.0.113.4',
|
|
9
11
|
'<%= config.bin %> <%= command.id %> --ssh user@host --source local',
|
|
10
12
|
'<%= config.bin %> <%= command.id %> --ssh user@host --remote-base /home/fnd',
|
|
13
|
+
'<%= config.bin %> <%= command.id %> --ssh user@host --ignore-vcs',
|
|
11
14
|
'<%= config.bin %> <%= command.id %> --ssh user@host --devtools 9222',
|
|
12
15
|
'<%= config.bin %> <%= command.id %> --ssh user@host --devtools 9333:9222',
|
|
16
|
+
`<%= config.bin %> <%= command.id %> --ssh user@host --rpc ${RPC_FLAG_EXAMPLES.required}`,
|
|
17
|
+
`<%= config.bin %> <%= command.id %> --ssh user@host --rpc ${RPC_FLAG_EXAMPLES.full}`,
|
|
13
18
|
'<%= config.bin %> <%= command.id %> --ssh user@host --delete-remote-dir',
|
|
19
|
+
'<%= config.bin %> <%= command.id %> --ssh user@host --rpc 7700 --cleanup',
|
|
14
20
|
];
|
|
15
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
|
+
}),
|
|
16
26
|
'delete-remote-dir': Flags.boolean({
|
|
17
27
|
default: false,
|
|
18
28
|
description: 'on exit, delete the remote workspace directory instead of leaving the synced copy in place',
|
|
@@ -20,10 +30,17 @@ export default class Workspace extends Command {
|
|
|
20
30
|
devtools: Flags.string({
|
|
21
31
|
description: 'expose your LOCAL browser to Claude on the remote via the chrome-devtools MCP. Value is "port" (same port both ends) or "remote:local" (local = this machine, where the browser runs). Your browser must already be listening with --remote-debugging-port=<local>.',
|
|
22
32
|
}),
|
|
33
|
+
'ignore-vcs': Flags.boolean({
|
|
34
|
+
default: false,
|
|
35
|
+
description: "don't sync paths matched by the project's .gitignore files (node_modules, build output, …) so each side keeps its own platform-specific artifacts. Every .gitignore in the tree is honoured relative to its directory, like git does. The .git directory itself still syncs, so the remote stays a working repo.",
|
|
36
|
+
}),
|
|
23
37
|
'remote-base': Flags.string({
|
|
24
38
|
default: DEFAULT_MOUNT_BASE,
|
|
25
39
|
description: 'base dir on the remote; the workspace lands at <base>/<local-user>/<dir-name>',
|
|
26
40
|
}),
|
|
41
|
+
rpc: Flags.string({
|
|
42
|
+
description: `expose a run_local_command MCP tool to Claude on the remote that executes commands back on THIS machine (the one running fnd workspace). Value is ${RPC_FLAG_USAGE} — port opens a reverse tunnel (ssh -R <remote>:localhost:<local>) to a command server started here; shell defaults to the shell fnd workspace was called from; profile (default true) controls whether the shell loads its startup files — with it on, POSIX shells run interactively (-i) so rc files like ~/.bashrc or ~/.zshrc are sourced and tools such as nvm work.`,
|
|
43
|
+
}),
|
|
27
44
|
source: Flags.string({
|
|
28
45
|
description: 'which side wins on conflict: "remote" = this server (where the workspace shell runs), "local" = the machine you ran fnd workspace from. Omit to flag conflicts instead of auto-resolving them.',
|
|
29
46
|
options: ['remote', 'local'],
|
|
@@ -37,14 +54,28 @@ export default class Workspace extends Command {
|
|
|
37
54
|
const { flags } = await this.parse(Workspace);
|
|
38
55
|
const target = parseSshTarget(flags.ssh);
|
|
39
56
|
const target2 = `${target.user}@${target.host}`;
|
|
40
|
-
|
|
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
|
+
}
|
|
65
|
+
const devtools = flags.devtools === undefined ? undefined : parsePortPair(flags.devtools, '--devtools');
|
|
66
|
+
const rpc = flags.rpc === undefined ? undefined : parseRpcFlag(flags.rpc);
|
|
67
|
+
if (devtools && rpc && devtools.remote === rpc.ports.remote) {
|
|
68
|
+
this.error(`--devtools and --rpc cannot share remote port ${rpc.ports.remote}.`, { code: '1' });
|
|
69
|
+
}
|
|
41
70
|
const ctx = buildContext({
|
|
42
71
|
cwd: process.cwd(),
|
|
43
72
|
devtools,
|
|
73
|
+
ignoreVcs: flags['ignore-vcs'],
|
|
44
74
|
remoteBase: flags['remote-base'],
|
|
75
|
+
rpc,
|
|
45
76
|
source: flags.source,
|
|
46
77
|
});
|
|
47
|
-
await this.preflight(devtools);
|
|
78
|
+
await this.preflight(devtools, rpc);
|
|
48
79
|
this.printPlan(ctx, target2);
|
|
49
80
|
// Start the two-way sync. Mutagen auto-deploys its agent to the remote over SSH.
|
|
50
81
|
this.log(chalk.dim('Starting the Mutagen sync session…'));
|
|
@@ -53,6 +84,7 @@ export default class Workspace extends Command {
|
|
|
53
84
|
this.error(`mutagen sync create failed (exit ${createCode}). Check that the remote is reachable over SSH and try again.`, { code: '1' });
|
|
54
85
|
}
|
|
55
86
|
const deleteRemoteDir = flags['delete-remote-dir'];
|
|
87
|
+
let rpcServer;
|
|
56
88
|
let code;
|
|
57
89
|
try {
|
|
58
90
|
// Block until the first full sync lands so the files exist before the shell opens.
|
|
@@ -61,33 +93,30 @@ export default class Workspace extends Command {
|
|
|
61
93
|
if (flushCode !== 0) {
|
|
62
94
|
this.error(`Initial mutagen sync flush failed (exit ${flushCode}).`, { code: '1' });
|
|
63
95
|
}
|
|
96
|
+
// Start the local command server the --rpc tunnel points back to.
|
|
97
|
+
if (ctx.rpc) {
|
|
98
|
+
try {
|
|
99
|
+
rpcServer = await startRpcServer(ctx.rpc, ctx.localCwd);
|
|
100
|
+
this.log(chalk.dim(`Local RPC command server (${ctx.rpc.shell}) listening on 127.0.0.1:${ctx.rpc.ports.local}.`));
|
|
101
|
+
}
|
|
102
|
+
catch (error) {
|
|
103
|
+
this.error(`Could not start the local RPC server on 127.0.0.1:${ctx.rpc.ports.local} (${error.message}). Is the port already in use?`, { code: '1' });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
64
106
|
const script = buildRemoteScript(ctx);
|
|
65
|
-
code = await this.runSsh(target2, script, ctx
|
|
107
|
+
code = await this.runSsh(target2, script, ctx);
|
|
66
108
|
}
|
|
67
109
|
finally {
|
|
68
|
-
// Best-effort: flush the last edits back, then
|
|
110
|
+
// Best-effort: stop serving local commands, flush the last edits back, then
|
|
111
|
+
// tear the session down.
|
|
112
|
+
await rpcServer?.close().catch(() => { });
|
|
69
113
|
this.log('');
|
|
70
114
|
this.log(chalk.dim('Flushing final changes and stopping the sync…'));
|
|
71
115
|
await runMutagen(buildMutagenFlushArgs(ctx.syncName)).catch(() => 1);
|
|
72
116
|
await runMutagen(buildMutagenTerminateArgs(ctx.syncName)).catch(() => 1);
|
|
73
|
-
// Reach back to the remote if we left
|
|
74
|
-
//
|
|
75
|
-
|
|
76
|
-
const actions = [
|
|
77
|
-
ctx.devtools ? 'removing the remote chrome-devtools MCP config' : undefined,
|
|
78
|
-
deleteRemoteDir ? 'deleting the remote dir' : undefined,
|
|
79
|
-
].filter(Boolean);
|
|
80
|
-
this.log(chalk.dim(`${actions.join(' and ')}…`.replace(/^./, (c) => c.toUpperCase())));
|
|
81
|
-
try {
|
|
82
|
-
await runRemoteCleanup(target2, ctx.remoteDir, {
|
|
83
|
-
deleteRemoteDir,
|
|
84
|
-
removeDevtoolsMcp: Boolean(ctx.devtools),
|
|
85
|
-
});
|
|
86
|
-
}
|
|
87
|
-
catch (error) {
|
|
88
|
-
this.log(chalk.yellow(`Could not reach the remote to clean up (${error.message}).`));
|
|
89
|
-
}
|
|
90
|
-
}
|
|
117
|
+
// Reach back to the remote if we left MCP config there to remove, or if we
|
|
118
|
+
// were asked to delete the synced directory.
|
|
119
|
+
await this.teardownRemote(ctx, target2, deleteRemoteDir);
|
|
91
120
|
}
|
|
92
121
|
this.log('');
|
|
93
122
|
this.log(code === 0
|
|
@@ -97,7 +126,7 @@ export default class Workspace extends Command {
|
|
|
97
126
|
: chalk.yellow(`Session ended with exit code ${code}. Cleanup attempted above.`));
|
|
98
127
|
}
|
|
99
128
|
/** Verify this machine can drive the sync before we connect. */
|
|
100
|
-
async preflight(devtools) {
|
|
129
|
+
async preflight(devtools, rpc) {
|
|
101
130
|
if (!hasSshClient()) {
|
|
102
131
|
this.error('No `ssh` client found on PATH. Install OpenSSH client and try again.', { code: '1' });
|
|
103
132
|
}
|
|
@@ -120,6 +149,13 @@ export default class Workspace extends Command {
|
|
|
120
149
|
this.error('Local browser remote-debugging port is required for --devtools. Aborting.', { code: '1' });
|
|
121
150
|
}
|
|
122
151
|
}
|
|
152
|
+
// With --rpc, confirm the chosen shell is runnable on THIS machine — it's the
|
|
153
|
+
// shell the remote AI's commands will execute under, right here.
|
|
154
|
+
if (rpc && !hasLocalShell(rpc.shell, rpc.profile)) {
|
|
155
|
+
this.error(`--rpc shell "${rpc.shell}" is not runnable on this machine. Aborting before connecting.`, {
|
|
156
|
+
code: '1',
|
|
157
|
+
});
|
|
158
|
+
}
|
|
123
159
|
}
|
|
124
160
|
printPlan(ctx, target) {
|
|
125
161
|
const sync = ctx.source === undefined
|
|
@@ -130,20 +166,72 @@ export default class Workspace extends Command {
|
|
|
130
166
|
this.log(` ${chalk.dim('local dir:')} ${ctx.localCwd}`);
|
|
131
167
|
this.log(` ${chalk.dim('remote dir:')} ${ctx.remoteDir}`);
|
|
132
168
|
this.log(` ${chalk.dim('sync:')} ${sync}`);
|
|
169
|
+
if (ctx.ignores) {
|
|
170
|
+
this.log(` ${chalk.dim('ignoring:')} ${ctx.ignores.length} pattern${ctx.ignores.length === 1 ? '' : 's'} from .gitignore files (--ignore-vcs)`);
|
|
171
|
+
}
|
|
133
172
|
if (ctx.devtools) {
|
|
134
173
|
this.log(` ${chalk.dim('devtools:')} remote 127.0.0.1:${ctx.devtools.remote} → local browser 127.0.0.1:${ctx.devtools.local}`);
|
|
135
174
|
}
|
|
175
|
+
if (ctx.rpc) {
|
|
176
|
+
this.log(` ${chalk.dim('rpc:')} remote 127.0.0.1:${ctx.rpc.ports.remote} → local ${ctx.rpc.shell} commands via 127.0.0.1:${ctx.rpc.ports.local}`);
|
|
177
|
+
}
|
|
136
178
|
this.log('');
|
|
137
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
|
+
}
|
|
138
225
|
/** Run the interactive ssh session, inheriting the TTY so the remote shell is fully interactive. */
|
|
139
|
-
runSsh(target, script,
|
|
226
|
+
runSsh(target, script, ctx) {
|
|
227
|
+
// Reverse tunnels: remote 127.0.0.1:<remote> → this machine's 127.0.0.1:<local>.
|
|
228
|
+
// --devtools points one at the local browser's debug port; --rpc points one at
|
|
229
|
+
// the local command server, so the remote's MCPs can reach them.
|
|
230
|
+
const forwards = [ctx.devtools, ctx.rpc?.ports].filter((f) => f !== undefined);
|
|
140
231
|
const args = [
|
|
141
232
|
'-t', // allocate a remote PTY for the interactive shell session
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
...(devtools
|
|
145
|
-
? ['-o', 'ExitOnForwardFailure=yes', '-R', `${devtools.remote}:localhost:${devtools.local}`]
|
|
146
|
-
: []),
|
|
233
|
+
...(forwards.length > 0 ? ['-o', 'ExitOnForwardFailure=yes'] : []),
|
|
234
|
+
...forwards.flatMap((f) => ['-R', `${f.remote}:localhost:${f.local}`]),
|
|
147
235
|
target,
|
|
148
236
|
script,
|
|
149
237
|
];
|
|
@@ -153,4 +241,30 @@ export default class Workspace extends Command {
|
|
|
153
241
|
child.once('close', (code) => resolve(code ?? 0));
|
|
154
242
|
});
|
|
155
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
|
+
}
|
|
156
270
|
}
|
|
@@ -9,7 +9,7 @@ const hook = async function (opts) {
|
|
|
9
9
|
});
|
|
10
10
|
});
|
|
11
11
|
if (newestVersion !== opts.config.pjson.version) {
|
|
12
|
-
console.log(chalk.yellow('💡 Version', chalk.green(newestVersion), 'available! Run', chalk.green('`npm i -g
|
|
12
|
+
console.log(chalk.yellow('💡 Version', chalk.green(newestVersion), 'available! Run', chalk.green('`npm i -g @firenet-designs/fnd-cli`'), 'to update to the latest version!\n'));
|
|
13
13
|
}
|
|
14
14
|
};
|
|
15
15
|
export default hook;
|
|
@@ -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,15 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
type KvShape = Record<string, z.ZodType>;
|
|
3
|
+
/** Usage string for the schema, required keys first: `a=<...>,b=<...>[,c=<...>]`. */
|
|
4
|
+
export declare const kvUsage: (schema: z.ZodObject<KvShape>) => string;
|
|
5
|
+
/** Example flag value built from each field's example meta (falls back to its hint), required keys first. */
|
|
6
|
+
export declare const kvExample: (schema: z.ZodObject<KvShape>, opts?: {
|
|
7
|
+
requiredOnly?: boolean;
|
|
8
|
+
}) => string;
|
|
9
|
+
/**
|
|
10
|
+
* Parse a key=value flag string against the schema. Keys are case-insensitive
|
|
11
|
+
* and order-independent; unknown keys, duplicate keys, and missing required
|
|
12
|
+
* keys are hard errors naming the flag and its usage.
|
|
13
|
+
*/
|
|
14
|
+
export declare const parseKvFlag: <T extends KvShape>(flag: string, raw: string, schema: z.ZodObject<T>) => z.output<z.ZodObject<T>>;
|
|
15
|
+
export {};
|