@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
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
const fieldMeta = (field) => (field.meta() ?? {});
|
|
2
|
+
const isOptional = (field) => field.def.type === 'default' || field.def.type === 'optional';
|
|
3
|
+
/** Unwrap default/optional/preprocess wrappers down to the schema describing the value. */
|
|
4
|
+
const unwrap = (field) => {
|
|
5
|
+
const { def } = field;
|
|
6
|
+
if (def.type === 'default' || def.type === 'optional') {
|
|
7
|
+
return unwrap(def.innerType);
|
|
8
|
+
}
|
|
9
|
+
if (def.type === 'pipe') {
|
|
10
|
+
return unwrap(def.out);
|
|
11
|
+
}
|
|
12
|
+
return field;
|
|
13
|
+
};
|
|
14
|
+
/** The <placeholder> for a field's value: explicit hint meta, enum options, or "value". */
|
|
15
|
+
const fieldHint = (field) => {
|
|
16
|
+
const { hint } = fieldMeta(field);
|
|
17
|
+
if (hint)
|
|
18
|
+
return hint;
|
|
19
|
+
const inner = unwrap(field);
|
|
20
|
+
if (inner.def.type === 'enum') {
|
|
21
|
+
return Object.values(inner.def.entries).join('|');
|
|
22
|
+
}
|
|
23
|
+
return 'value';
|
|
24
|
+
};
|
|
25
|
+
/** Usage string for the schema, required keys first: `a=<...>,b=<...>[,c=<...>]`. */
|
|
26
|
+
export const kvUsage = (schema) => {
|
|
27
|
+
const fields = Object.entries(schema.shape);
|
|
28
|
+
const required = fields.filter(([, f]) => !isOptional(f));
|
|
29
|
+
const optional = fields.filter(([, f]) => isOptional(f));
|
|
30
|
+
const pair = ([key, field]) => `${key}=<${fieldHint(field)}>`;
|
|
31
|
+
return required.map((f) => pair(f)).join(',') + optional.map((f) => `[,${pair(f)}]`).join('');
|
|
32
|
+
};
|
|
33
|
+
/** Example flag value built from each field's example meta (falls back to its hint), required keys first. */
|
|
34
|
+
export const kvExample = (schema, opts) => {
|
|
35
|
+
const fields = Object.entries(schema.shape).filter(([, field]) => !(opts?.requiredOnly && isOptional(field)));
|
|
36
|
+
return [...fields.filter(([, f]) => !isOptional(f)), ...fields.filter(([, f]) => isOptional(f))]
|
|
37
|
+
.map(([key, field]) => `${key}=${fieldMeta(field).example ?? fieldHint(field)}`)
|
|
38
|
+
.join(',');
|
|
39
|
+
};
|
|
40
|
+
/**
|
|
41
|
+
* Parse a key=value flag string against the schema. Keys are case-insensitive
|
|
42
|
+
* and order-independent; unknown keys, duplicate keys, and missing required
|
|
43
|
+
* keys are hard errors naming the flag and its usage.
|
|
44
|
+
*/
|
|
45
|
+
export const parseKvFlag = (flag, raw, schema) => {
|
|
46
|
+
const usage = `${flag} takes comma-separated key=value pairs: ${kvUsage(schema)}`;
|
|
47
|
+
const known = Object.keys(schema.shape);
|
|
48
|
+
const pairs = new Map();
|
|
49
|
+
for (const part of raw.trim().split(',')) {
|
|
50
|
+
const eq = part.indexOf('=');
|
|
51
|
+
if (eq === -1) {
|
|
52
|
+
throw new Error(`${usage} (got "${raw}")`);
|
|
53
|
+
}
|
|
54
|
+
const key = part.slice(0, eq).trim().toLowerCase();
|
|
55
|
+
if (!known.includes(key)) {
|
|
56
|
+
throw new Error(`${flag} has no "${key}" option. ${usage}`);
|
|
57
|
+
}
|
|
58
|
+
if (pairs.has(key)) {
|
|
59
|
+
throw new Error(`${flag} "${key}" was given more than once (got "${raw}")`);
|
|
60
|
+
}
|
|
61
|
+
pairs.set(key, part.slice(eq + 1).trim());
|
|
62
|
+
}
|
|
63
|
+
for (const key of known) {
|
|
64
|
+
if (!isOptional(schema.shape[key]) && !pairs.has(key)) {
|
|
65
|
+
throw new Error(`${flag} needs "${key}". ${usage}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const result = schema.safeParse(Object.fromEntries(pairs));
|
|
69
|
+
if (!result.success) {
|
|
70
|
+
const issue = result.error.issues[0];
|
|
71
|
+
const at = issue.path.join('.');
|
|
72
|
+
throw new Error(`${flag}${at ? ` ${at}` : ''}: ${issue.message}. ${usage}`);
|
|
73
|
+
}
|
|
74
|
+
return result.data;
|
|
75
|
+
};
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { PortPair } from './workspace.js';
|
|
3
|
+
/**
|
|
4
|
+
* The --rpc local-command server.
|
|
5
|
+
*
|
|
6
|
+
* Topology: this machine (the one that ran `fnd workspace`) runs a tiny MCP
|
|
7
|
+
* server — Streamable HTTP transport, implemented on node:http with no
|
|
8
|
+
* dependencies — bound to 127.0.0.1:<local>. The workspace's `ssh -R` reverse
|
|
9
|
+
* tunnel exposes it on the REMOTE at 127.0.0.1:<remote>, where the `claude` CLI
|
|
10
|
+
* registers it as an HTTP MCP server. When the AI on the remote calls the
|
|
11
|
+
* `run_local_command` tool, the command executes HERE, on the calling machine,
|
|
12
|
+
* under the shell chosen in the flag.
|
|
13
|
+
*
|
|
14
|
+
* The server binds loopback only; the sole way in from outside is the reverse
|
|
15
|
+
* tunnel, which lives exactly as long as the ssh session.
|
|
16
|
+
*/
|
|
17
|
+
declare const ShellSchema: z.ZodEnum<{
|
|
18
|
+
zsh: "zsh";
|
|
19
|
+
bash: "bash";
|
|
20
|
+
batch: "batch";
|
|
21
|
+
powershell: "powershell";
|
|
22
|
+
sh: "sh";
|
|
23
|
+
}>;
|
|
24
|
+
export declare const RPC_SHELLS: ("zsh" | "bash" | "batch" | "powershell" | "sh")[];
|
|
25
|
+
export type RpcShell = z.infer<typeof ShellSchema>;
|
|
26
|
+
/** Usage and example strings for the --rpc flag, derived from the schema. */
|
|
27
|
+
export declare const RPC_FLAG_USAGE: string;
|
|
28
|
+
export declare const RPC_FLAG_EXAMPLES: {
|
|
29
|
+
full: string;
|
|
30
|
+
required: string;
|
|
31
|
+
};
|
|
32
|
+
export interface RpcConfig {
|
|
33
|
+
/** local = port the server binds on this machine; remote = port opened on the workspace host via `ssh -R`. */
|
|
34
|
+
ports: PortPair;
|
|
35
|
+
/** Whether the shell loads its startup files (rc/profile). POSIX shells run with -i, powershell without -NoProfile. */
|
|
36
|
+
profile: boolean;
|
|
37
|
+
/** Shell used to execute commands on this machine. */
|
|
38
|
+
shell: RpcShell;
|
|
39
|
+
}
|
|
40
|
+
/** Handle for a running RPC server. */
|
|
41
|
+
export interface RpcServer {
|
|
42
|
+
close: () => Promise<void>;
|
|
43
|
+
}
|
|
44
|
+
export declare const RPC_TOOL_NAME = "run_local_command";
|
|
45
|
+
/**
|
|
46
|
+
* The shell `fnd workspace` was called from: the parent process when it is a
|
|
47
|
+
* supported shell, else $SHELL, else undefined (e.g. invoked from a script).
|
|
48
|
+
*/
|
|
49
|
+
export declare const detectCallingShell: () => RpcShell | undefined;
|
|
50
|
+
/**
|
|
51
|
+
* Parse the --rpc value: comma-separated key=value pairs per RpcFlagSchema.
|
|
52
|
+
* port=<port> | port=<remote>:<local> — required; `local` is this machine
|
|
53
|
+
* (where commands run), `remote` the port opened on the workspace host.
|
|
54
|
+
* The single-port form uses the same port on both ends.
|
|
55
|
+
* shell=<bash|batch|powershell|sh|zsh> — optional, defaults to the shell
|
|
56
|
+
* `fnd workspace` was called from.
|
|
57
|
+
* profile=<true|1|false|0> — optional, default true: the shell loads its
|
|
58
|
+
* startup files (rc/profile), so tools like nvm are available.
|
|
59
|
+
*/
|
|
60
|
+
export declare const parseRpcFlag: (raw: string) => RpcConfig;
|
|
61
|
+
/** True if the chosen shell is runnable on this machine. */
|
|
62
|
+
export declare const hasLocalShell: (shell: RpcShell, profile: boolean) => boolean;
|
|
63
|
+
/**
|
|
64
|
+
* Start the local RPC (MCP) server on 127.0.0.1:<ports.local>. Commands execute
|
|
65
|
+
* with `cwd` as their working directory — the local side of the workspace sync.
|
|
66
|
+
* Resolves once the port is bound; rejects if binding fails (e.g. port in use).
|
|
67
|
+
*/
|
|
68
|
+
export declare const startRpcServer: (config: RpcConfig, cwd: string) => Promise<RpcServer>;
|
|
69
|
+
export {};
|
package/dist/lib/rpc.js
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
2
|
+
import { createServer } from 'node:http';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { kvExample, kvUsage, parseKvFlag } from './kv-flag.js';
|
|
5
|
+
import { parsePortPair } from './workspace.js';
|
|
6
|
+
/**
|
|
7
|
+
* The --rpc local-command server.
|
|
8
|
+
*
|
|
9
|
+
* Topology: this machine (the one that ran `fnd workspace`) runs a tiny MCP
|
|
10
|
+
* server — Streamable HTTP transport, implemented on node:http with no
|
|
11
|
+
* dependencies — bound to 127.0.0.1:<local>. The workspace's `ssh -R` reverse
|
|
12
|
+
* tunnel exposes it on the REMOTE at 127.0.0.1:<remote>, where the `claude` CLI
|
|
13
|
+
* registers it as an HTTP MCP server. When the AI on the remote calls the
|
|
14
|
+
* `run_local_command` tool, the command executes HERE, on the calling machine,
|
|
15
|
+
* under the shell chosen in the flag.
|
|
16
|
+
*
|
|
17
|
+
* The server binds loopback only; the sole way in from outside is the reverse
|
|
18
|
+
* tunnel, which lives exactly as long as the ssh session.
|
|
19
|
+
*/
|
|
20
|
+
const ShellSchema = z.enum(['bash', 'batch', 'powershell', 'sh', 'zsh']);
|
|
21
|
+
export const RPC_SHELLS = ShellSchema.options;
|
|
22
|
+
const RpcFlagSchema = z.object({
|
|
23
|
+
port: z
|
|
24
|
+
.string()
|
|
25
|
+
.transform((v) => parsePortPair(v, '--rpc'))
|
|
26
|
+
.meta({ example: '7777:7700', hint: 'port|remote:local' }),
|
|
27
|
+
profile: z
|
|
28
|
+
.stringbool({ falsy: ['false', '0'], truthy: ['true', '1'] })
|
|
29
|
+
.default(true)
|
|
30
|
+
.meta({ example: 'false', hint: 'true|1|false|0' }),
|
|
31
|
+
shell: z
|
|
32
|
+
.preprocess((v) => String(v).toLowerCase(), ShellSchema)
|
|
33
|
+
.optional()
|
|
34
|
+
.meta({ example: 'zsh' }),
|
|
35
|
+
});
|
|
36
|
+
/** Usage and example strings for the --rpc flag, derived from the schema. */
|
|
37
|
+
export const RPC_FLAG_USAGE = kvUsage(RpcFlagSchema);
|
|
38
|
+
export const RPC_FLAG_EXAMPLES = {
|
|
39
|
+
full: kvExample(RpcFlagSchema),
|
|
40
|
+
required: kvExample(RpcFlagSchema, { requiredOnly: true }),
|
|
41
|
+
};
|
|
42
|
+
export const RPC_TOOL_NAME = 'run_local_command';
|
|
43
|
+
const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000;
|
|
44
|
+
const MAX_TIMEOUT_MS = 30 * 60 * 1000;
|
|
45
|
+
const MAX_OUTPUT_CHARS = 200_000;
|
|
46
|
+
const MAX_BODY_BYTES = 4 * 1024 * 1024;
|
|
47
|
+
/** Map a process name or path to a supported shell, if it is one. */
|
|
48
|
+
const asRpcShell = (name) => {
|
|
49
|
+
if (!name)
|
|
50
|
+
return undefined;
|
|
51
|
+
// Basename, minus the login-shell dash ("-zsh") and Windows ".exe" suffix.
|
|
52
|
+
const base = name
|
|
53
|
+
.split(/[/\\]/)
|
|
54
|
+
.pop()
|
|
55
|
+
.toLowerCase()
|
|
56
|
+
.replace(/^-/, '')
|
|
57
|
+
.replace(/\.exe$/, '');
|
|
58
|
+
if (base === 'cmd')
|
|
59
|
+
return 'batch';
|
|
60
|
+
if (base === 'pwsh')
|
|
61
|
+
return 'powershell';
|
|
62
|
+
const parsed = ShellSchema.safeParse(base);
|
|
63
|
+
return parsed.success ? parsed.data : undefined;
|
|
64
|
+
};
|
|
65
|
+
/** Name of the process that spawned us, when discoverable. */
|
|
66
|
+
const parentProcessName = () => {
|
|
67
|
+
const { ppid } = process;
|
|
68
|
+
if (!ppid)
|
|
69
|
+
return undefined;
|
|
70
|
+
const probe = process.platform === 'win32'
|
|
71
|
+
? spawnSync('powershell', ['-NoProfile', '-NonInteractive', '-Command', `(Get-Process -Id ${ppid}).ProcessName`], { encoding: 'utf8', windowsHide: true })
|
|
72
|
+
: spawnSync('ps', ['-p', String(ppid), '-o', 'comm='], { encoding: 'utf8' });
|
|
73
|
+
if (probe.error || probe.status !== 0)
|
|
74
|
+
return undefined;
|
|
75
|
+
return probe.stdout.trim() || undefined;
|
|
76
|
+
};
|
|
77
|
+
/**
|
|
78
|
+
* The shell `fnd workspace` was called from: the parent process when it is a
|
|
79
|
+
* supported shell, else $SHELL, else undefined (e.g. invoked from a script).
|
|
80
|
+
*/
|
|
81
|
+
export const detectCallingShell = () => asRpcShell(parentProcessName()) ?? asRpcShell(process.env.SHELL);
|
|
82
|
+
/**
|
|
83
|
+
* Parse the --rpc value: comma-separated key=value pairs per RpcFlagSchema.
|
|
84
|
+
* port=<port> | port=<remote>:<local> — required; `local` is this machine
|
|
85
|
+
* (where commands run), `remote` the port opened on the workspace host.
|
|
86
|
+
* The single-port form uses the same port on both ends.
|
|
87
|
+
* shell=<bash|batch|powershell|sh|zsh> — optional, defaults to the shell
|
|
88
|
+
* `fnd workspace` was called from.
|
|
89
|
+
* profile=<true|1|false|0> — optional, default true: the shell loads its
|
|
90
|
+
* startup files (rc/profile), so tools like nvm are available.
|
|
91
|
+
*/
|
|
92
|
+
export const parseRpcFlag = (raw) => {
|
|
93
|
+
const parsed = parseKvFlag('--rpc', raw, RpcFlagSchema);
|
|
94
|
+
const shell = parsed.shell ?? detectCallingShell();
|
|
95
|
+
if (!shell) {
|
|
96
|
+
throw new Error(`--rpc could not detect the calling shell; pass shell=<${RPC_SHELLS.join('|')}> explicitly.`);
|
|
97
|
+
}
|
|
98
|
+
return { ports: parsed.port, profile: parsed.profile, shell };
|
|
99
|
+
};
|
|
100
|
+
/** How to invoke the chosen shell for a one-shot command string. */
|
|
101
|
+
const shellInvocation = (shell, command, profile) => {
|
|
102
|
+
switch (shell) {
|
|
103
|
+
case 'batch': {
|
|
104
|
+
// /d skips the AutoRun registry commands — cmd's closest analog to a profile.
|
|
105
|
+
return { args: [...(profile ? [] : ['/d']), '/s', '/c', command], bin: 'cmd.exe' };
|
|
106
|
+
}
|
|
107
|
+
case 'powershell': {
|
|
108
|
+
return { args: [...(profile ? [] : ['-NoProfile']), '-NonInteractive', '-Command', command], bin: 'powershell' };
|
|
109
|
+
}
|
|
110
|
+
default: {
|
|
111
|
+
// -i: interactive, so rc files (~/.bashrc, ~/.zshrc) are sourced and tools
|
|
112
|
+
// that hook in there (nvm, rbenv, …) work without manual sourcing.
|
|
113
|
+
return { args: [...(profile ? ['-i'] : []), '-c', command], bin: shell };
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
/** True if the chosen shell is runnable on this machine. */
|
|
118
|
+
export const hasLocalShell = (shell, profile) => {
|
|
119
|
+
const { args, bin } = shellInvocation(shell, 'exit 0', profile);
|
|
120
|
+
const result = spawnSync(bin, args, { stdio: 'ignore' });
|
|
121
|
+
return !result.error && result.status === 0;
|
|
122
|
+
};
|
|
123
|
+
/** Append a chunk to captured output unless the cap is already reached. */
|
|
124
|
+
const appendCapped = (current, chunk) => current.length >= MAX_OUTPUT_CHARS ? current : current + chunk.toString();
|
|
125
|
+
/**
|
|
126
|
+
* Interactive bash/dash on a non-TTY stdin print job-control warnings on every
|
|
127
|
+
* run; drop them so they don't clutter the stderr the model sees.
|
|
128
|
+
*/
|
|
129
|
+
const stripInteractiveShellNoise = (stderr) => stderr
|
|
130
|
+
.replaceAll(/^bash: cannot set terminal process group \(-?\d+\):[^\n]*\n?/gm, '')
|
|
131
|
+
.replaceAll(/^bash: no job control in this shell\n?/gm, '')
|
|
132
|
+
.replaceAll(/^sh: \d+: can't access tty; job control turned off\n?/gm, '');
|
|
133
|
+
/** Execute a command on this machine under the configured shell, capturing output. */
|
|
134
|
+
const runLocalCommand = (config, command, cwd, timeoutMs) => new Promise((resolve) => {
|
|
135
|
+
const { args, bin } = shellInvocation(config.shell, command, config.profile);
|
|
136
|
+
const child = spawn(bin, args, { cwd, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
|
|
137
|
+
let stdout = '';
|
|
138
|
+
let stderr = '';
|
|
139
|
+
let timedOut = false;
|
|
140
|
+
child.stdout.on('data', (chunk) => {
|
|
141
|
+
stdout = appendCapped(stdout, chunk);
|
|
142
|
+
});
|
|
143
|
+
child.stderr.on('data', (chunk) => {
|
|
144
|
+
stderr = appendCapped(stderr, chunk);
|
|
145
|
+
});
|
|
146
|
+
const timer = setTimeout(() => {
|
|
147
|
+
timedOut = true;
|
|
148
|
+
child.kill('SIGKILL');
|
|
149
|
+
}, timeoutMs);
|
|
150
|
+
child.once('error', (error) => {
|
|
151
|
+
clearTimeout(timer);
|
|
152
|
+
resolve({ exitCode: null, stderr: `Could not spawn ${bin}: ${error.message}`, stdout: '', timedOut: false });
|
|
153
|
+
});
|
|
154
|
+
child.once('close', (code) => {
|
|
155
|
+
clearTimeout(timer);
|
|
156
|
+
resolve({ exitCode: code, stderr: stripInteractiveShellNoise(stderr), stdout, timedOut });
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
/** Clip captured output at the cap, marking the truncation. */
|
|
160
|
+
const clip = (value) => value.length >= MAX_OUTPUT_CHARS ? `${value.slice(0, MAX_OUTPUT_CHARS)}\n[output truncated]` : value;
|
|
161
|
+
/** Render a command result as the text block returned to the model. */
|
|
162
|
+
const formatResult = (result) => {
|
|
163
|
+
const status = result.timedOut ? 'killed (timed out)' : `exit code ${result.exitCode ?? 'unknown'}`;
|
|
164
|
+
return [
|
|
165
|
+
status,
|
|
166
|
+
'--- stdout ---',
|
|
167
|
+
clip(result.stdout) || '(empty)',
|
|
168
|
+
'--- stderr ---',
|
|
169
|
+
clip(result.stderr) || '(empty)',
|
|
170
|
+
].join('\n');
|
|
171
|
+
};
|
|
172
|
+
/** True for the POSIX shells we run with -i (rc files sourced). */
|
|
173
|
+
const isPosixShell = (shell) => shell !== 'batch' && shell !== 'powershell';
|
|
174
|
+
/** The MCP tool definition advertised to the remote AI. */
|
|
175
|
+
const toolDefinition = (config, cwd) => ({
|
|
176
|
+
description: `Run a shell command on the LOCAL machine — the computer that launched \`fnd workspace\`, NOT this remote box. ` +
|
|
177
|
+
`The command runs under ${config.shell}${config.profile && isPosixShell(config.shell) ? ' (interactive, so rc files and tools like nvm are already loaded)' : ''} ` +
|
|
178
|
+
`with working directory ${cwd}, and the result contains the exit code, stdout, and stderr.`,
|
|
179
|
+
inputSchema: {
|
|
180
|
+
properties: {
|
|
181
|
+
command: { description: `Command line to execute via ${config.shell} on the local machine.`, type: 'string' },
|
|
182
|
+
timeoutMs: {
|
|
183
|
+
description: `Optional timeout in milliseconds (default ${DEFAULT_TIMEOUT_MS}, max ${MAX_TIMEOUT_MS}); the process is killed when it elapses.`,
|
|
184
|
+
type: 'number',
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
required: ['command'],
|
|
188
|
+
type: 'object',
|
|
189
|
+
},
|
|
190
|
+
name: RPC_TOOL_NAME,
|
|
191
|
+
});
|
|
192
|
+
const rpcError = (id, code, message) => ({
|
|
193
|
+
error: { code, message },
|
|
194
|
+
id,
|
|
195
|
+
jsonrpc: '2.0',
|
|
196
|
+
});
|
|
197
|
+
const rpcResult = (id, result) => ({ id, jsonrpc: '2.0', result });
|
|
198
|
+
/**
|
|
199
|
+
* Handle one JSON-RPC message. Returns the response object for requests, or
|
|
200
|
+
* undefined for notifications (which get no response body).
|
|
201
|
+
*/
|
|
202
|
+
const handleRpcMessage = async (msg, config, cwd) => {
|
|
203
|
+
const isRequest = msg.id !== undefined && msg.id !== null;
|
|
204
|
+
if (msg.jsonrpc !== '2.0' || typeof msg.method !== 'string') {
|
|
205
|
+
return isRequest ? rpcError(msg.id, -32_600, 'Invalid request') : undefined;
|
|
206
|
+
}
|
|
207
|
+
if (!isRequest)
|
|
208
|
+
return undefined; // notifications (e.g. notifications/initialized) need no reply
|
|
209
|
+
const id = msg.id;
|
|
210
|
+
switch (msg.method) {
|
|
211
|
+
case 'initialize': {
|
|
212
|
+
const requested = msg.params?.protocolVersion;
|
|
213
|
+
return rpcResult(id, {
|
|
214
|
+
capabilities: { tools: {} },
|
|
215
|
+
protocolVersion: typeof requested === 'string' ? requested : '2025-03-26',
|
|
216
|
+
serverInfo: { name: 'fnd-local-shell', version: '1.0.0' },
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
case 'ping': {
|
|
220
|
+
return rpcResult(id, {});
|
|
221
|
+
}
|
|
222
|
+
case 'tools/call': {
|
|
223
|
+
const params = msg.params ?? {};
|
|
224
|
+
if (params.name !== RPC_TOOL_NAME) {
|
|
225
|
+
return rpcError(id, -32_602, `Unknown tool: ${String(params.name)}`);
|
|
226
|
+
}
|
|
227
|
+
const args = (params.arguments ?? {});
|
|
228
|
+
if (typeof args.command !== 'string' || args.command.length === 0) {
|
|
229
|
+
return rpcError(id, -32_602, 'The "command" argument must be a non-empty string');
|
|
230
|
+
}
|
|
231
|
+
const timeoutMs = typeof args.timeoutMs === 'number' && args.timeoutMs > 0
|
|
232
|
+
? Math.min(args.timeoutMs, MAX_TIMEOUT_MS)
|
|
233
|
+
: DEFAULT_TIMEOUT_MS;
|
|
234
|
+
const result = await runLocalCommand(config, args.command, cwd, timeoutMs);
|
|
235
|
+
return rpcResult(id, {
|
|
236
|
+
content: [{ text: formatResult(result), type: 'text' }],
|
|
237
|
+
isError: result.timedOut || result.exitCode !== 0,
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
case 'tools/list': {
|
|
241
|
+
return rpcResult(id, { tools: [toolDefinition(config, cwd)] });
|
|
242
|
+
}
|
|
243
|
+
default: {
|
|
244
|
+
return rpcError(id, -32_601, `Method not found: ${msg.method}`);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
};
|
|
248
|
+
/** Read a request body, rejecting when it exceeds the size cap. */
|
|
249
|
+
const readBody = (req) => new Promise((resolve, reject) => {
|
|
250
|
+
let size = 0;
|
|
251
|
+
const chunks = [];
|
|
252
|
+
req.on('data', (chunk) => {
|
|
253
|
+
size += chunk.length;
|
|
254
|
+
if (size > MAX_BODY_BYTES) {
|
|
255
|
+
reject(new Error('request body too large'));
|
|
256
|
+
req.destroy();
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
chunks.push(chunk);
|
|
260
|
+
});
|
|
261
|
+
req.once('end', () => resolve(Buffer.concat(chunks).toString()));
|
|
262
|
+
req.once('error', reject);
|
|
263
|
+
});
|
|
264
|
+
const handleHttpRequest = async (req, res, config, cwd) => {
|
|
265
|
+
// Streamable HTTP: clients POST JSON-RPC messages. We don't offer a
|
|
266
|
+
// server-initiated SSE stream, so GET (and anything else) gets 405.
|
|
267
|
+
if (req.method !== 'POST') {
|
|
268
|
+
res.writeHead(405, { allow: 'POST' }).end();
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
let parsed;
|
|
272
|
+
try {
|
|
273
|
+
parsed = JSON.parse(await readBody(req));
|
|
274
|
+
}
|
|
275
|
+
catch {
|
|
276
|
+
res
|
|
277
|
+
.writeHead(400, { 'content-type': 'application/json' })
|
|
278
|
+
.end(JSON.stringify(rpcError(null, -32_700, 'Parse error')));
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
const messages = (Array.isArray(parsed) ? parsed : [parsed]);
|
|
282
|
+
const responses = (await Promise.all(messages.map((m) => handleRpcMessage(m, config, cwd)))).filter((r) => r !== undefined);
|
|
283
|
+
// A body of nothing but notifications gets 202 Accepted with no content.
|
|
284
|
+
if (responses.length === 0) {
|
|
285
|
+
res.writeHead(202).end();
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
const payload = Array.isArray(parsed) ? responses : responses[0];
|
|
289
|
+
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(payload));
|
|
290
|
+
};
|
|
291
|
+
/**
|
|
292
|
+
* Start the local RPC (MCP) server on 127.0.0.1:<ports.local>. Commands execute
|
|
293
|
+
* with `cwd` as their working directory — the local side of the workspace sync.
|
|
294
|
+
* Resolves once the port is bound; rejects if binding fails (e.g. port in use).
|
|
295
|
+
*/
|
|
296
|
+
export const startRpcServer = (config, cwd) => new Promise((resolve, reject) => {
|
|
297
|
+
const server = createServer((req, res) => {
|
|
298
|
+
handleHttpRequest(req, res, config, cwd).catch(() => {
|
|
299
|
+
if (!res.headersSent)
|
|
300
|
+
res.writeHead(500);
|
|
301
|
+
res.end();
|
|
302
|
+
});
|
|
303
|
+
});
|
|
304
|
+
server.once('error', reject);
|
|
305
|
+
server.listen(config.ports.local, '127.0.0.1', () => {
|
|
306
|
+
resolve({
|
|
307
|
+
close: () => new Promise((done) => {
|
|
308
|
+
server.closeAllConnections();
|
|
309
|
+
server.close(() => done());
|
|
310
|
+
}),
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
});
|
|
@@ -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));
|