@firenet-designs/fnd-cli 2.3.2 → 2.4.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 +64 -21
- package/dist/commands/workspace/cleanup.d.ts +3 -0
- package/dist/commands/workspace/cleanup.js +32 -4
- package/dist/commands/workspace/index.d.ts +3 -0
- package/dist/commands/workspace/index.js +80 -18
- package/dist/hooks/init/check-for-updates.js +1 -1
- 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/workspace.d.ts +66 -14
- package/dist/lib/workspace.js +154 -21
- package/oclif.manifest.json +48 -4
- package/package.json +4 -3
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
|
+
});
|
package/dist/lib/workspace.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { RpcConfig } from './rpc.js';
|
|
1
2
|
/**
|
|
2
3
|
* Mutagen-backed workspace helpers.
|
|
3
4
|
*
|
|
@@ -30,12 +31,15 @@ export interface SshTarget {
|
|
|
30
31
|
host: string;
|
|
31
32
|
user: string;
|
|
32
33
|
}
|
|
33
|
-
|
|
34
|
-
|
|
34
|
+
/** A reverse-tunnel port pair: `ssh -R <remote>:localhost:<local>`. */
|
|
35
|
+
export interface PortPair {
|
|
36
|
+
/** Port on the caller's LOCAL machine (the tunnel destination). */
|
|
35
37
|
local: number;
|
|
36
|
-
/** Port opened on the REMOTE (via `ssh -R`) that tunnels back to the local
|
|
38
|
+
/** Port opened on the REMOTE (via `ssh -R`) that tunnels back to the local port. */
|
|
37
39
|
remote: number;
|
|
38
40
|
}
|
|
41
|
+
/** For --devtools: `local` is the browser's remote-debugging port on the caller's machine. */
|
|
42
|
+
export type DevtoolsPorts = PortPair;
|
|
39
43
|
/**
|
|
40
44
|
* Which side wins when the same path changed on both ends since the last sync.
|
|
41
45
|
* `remote` = this server (the box where the workspace shell runs); `local` = the
|
|
@@ -47,6 +51,8 @@ export type SyncSource = 'local' | 'remote';
|
|
|
47
51
|
export interface WorkspaceContext {
|
|
48
52
|
/** Chrome DevTools MCP tunnel, when --devtools was passed; undefined otherwise. */
|
|
49
53
|
devtools?: DevtoolsPorts;
|
|
54
|
+
/** Mutagen ignore patterns derived from the project's .gitignore files (--ignore-vcs); undefined syncs everything. */
|
|
55
|
+
ignores?: string[];
|
|
50
56
|
/** Absolute path of the current dir on the LOCAL machine (one side of the sync). */
|
|
51
57
|
localCwd: string;
|
|
52
58
|
/** Basename of the local cwd — the leaf of the remote directory path. */
|
|
@@ -55,6 +61,8 @@ export interface WorkspaceContext {
|
|
|
55
61
|
localUser: string;
|
|
56
62
|
/** Where the mirror lives on the REMOTE, e.g. /home/fnd/<localUser>/<localDirName>. */
|
|
57
63
|
remoteDir: string;
|
|
64
|
+
/** Local-command RPC server + tunnel, when --rpc was passed; undefined otherwise. */
|
|
65
|
+
rpc?: RpcConfig;
|
|
58
66
|
/** Which endpoint wins conflicts (the Mutagen alpha in two-way-resolved); undefined flags conflicts instead. */
|
|
59
67
|
source?: SyncSource;
|
|
60
68
|
/** Unique Mutagen session name for this workspace. */
|
|
@@ -72,18 +80,48 @@ export declare const slugify: (value: string) => string;
|
|
|
72
80
|
/** A unique Mutagen session name for a workspace on the given local directory. */
|
|
73
81
|
export declare const buildSyncName: (dirName: string) => string;
|
|
74
82
|
/**
|
|
75
|
-
* Parse
|
|
76
|
-
* `remote:local`, where `local` is the caller's machine
|
|
77
|
-
*
|
|
83
|
+
* Parse a reverse-tunnel port value. Accepts `port` (same port on both ends) or
|
|
84
|
+
* `remote:local`, where `local` is the caller's machine and `remote` is the
|
|
85
|
+
* port opened on the workspace host. `flag` names the flag in error messages.
|
|
78
86
|
*/
|
|
79
|
-
export declare const
|
|
87
|
+
export declare const parsePortPair: (raw: string, flag: string) => PortPair;
|
|
80
88
|
/** Build the immutable facts for a workspace session from the local environment + flags. */
|
|
81
89
|
export declare const buildContext: (opts: {
|
|
82
90
|
cwd: string;
|
|
83
91
|
devtools?: DevtoolsPorts;
|
|
92
|
+
ignoreVcs?: boolean;
|
|
84
93
|
remoteBase: string;
|
|
94
|
+
rpc?: RpcConfig;
|
|
85
95
|
source?: SyncSource;
|
|
86
96
|
}) => WorkspaceContext;
|
|
97
|
+
/**
|
|
98
|
+
* Translate one .gitignore line into Mutagen ignore patterns. `base` is the
|
|
99
|
+
* directory holding the .gitignore, as a posix path relative to the sync root
|
|
100
|
+
* ('' for the root file). Mutagen's syntax already matches gitignore's for the
|
|
101
|
+
* pieces that pass through untouched (`*`, `**`, `?`, `[...]`, `!` negation,
|
|
102
|
+
* trailing `/` for directory-only) — what needs translating is scope:
|
|
103
|
+
*
|
|
104
|
+
* - A pattern with a slash is anchored to the .gitignore's own directory, so it
|
|
105
|
+
* becomes an absolute pattern under `base` (`/dist` in src/.gitignore →
|
|
106
|
+
* `/src/dist`).
|
|
107
|
+
* - A slashless pattern matches at any depth AT OR BELOW `base`. At the root
|
|
108
|
+
* that is exactly Mutagen's unanchored behavior, so it passes through as-is;
|
|
109
|
+
* under a subdirectory it becomes `/base/p` plus `/base/**\/p` (both forms, so
|
|
110
|
+
* the match doesn't depend on `**` matching zero segments).
|
|
111
|
+
*
|
|
112
|
+
* Returns [] for blanks and comments.
|
|
113
|
+
*/
|
|
114
|
+
export declare const translateGitignoreLine: (line: string, base: string) => string[];
|
|
115
|
+
/**
|
|
116
|
+
* Walk the project and turn every .gitignore into Mutagen ignore patterns, each
|
|
117
|
+
* resolved relative to the directory of the .gitignore that declared it. Files
|
|
118
|
+
* are ordered root-first so deeper .gitignore patterns come later — Mutagen
|
|
119
|
+
* gives later patterns precedence, which mirrors git. `.git` and `node_modules`
|
|
120
|
+
* are never descended into (git does not consult .gitignore files inside
|
|
121
|
+
* ignored or metadata directories), and symlinked directories are skipped to
|
|
122
|
+
* avoid cycles.
|
|
123
|
+
*/
|
|
124
|
+
export declare const collectVcsIgnores: (rootDir: string) => string[];
|
|
87
125
|
/** POSIX single-quote a string so it can be embedded safely in the remote shell script. */
|
|
88
126
|
export declare const shQuote: (value: string) => string;
|
|
89
127
|
/**
|
|
@@ -92,15 +130,27 @@ export declare const shQuote: (value: string) => string;
|
|
|
92
130
|
* optionally wires up the chrome-devtools MCP, and drops into a login shell.
|
|
93
131
|
*/
|
|
94
132
|
export declare const buildRemoteScript: (ctx: WorkspaceContext) => string;
|
|
95
|
-
/**
|
|
96
|
-
export
|
|
133
|
+
/** Options controlling what the remote-side teardown does. */
|
|
134
|
+
export interface RemoteCleanupOptions {
|
|
135
|
+
/** Delete the workspace dir after cleanup; by default the synced copy is left in place. */
|
|
136
|
+
deleteRemoteDir?: boolean;
|
|
137
|
+
/** Strip this project's chrome-devtools MCP entry — only when the workspace registered one (--devtools). */
|
|
138
|
+
removeDevtoolsMcp?: boolean;
|
|
139
|
+
/** Strip this project's local-shell MCP entry — only when the workspace registered one (--rpc). */
|
|
140
|
+
removeRpcMcp?: boolean;
|
|
141
|
+
}
|
|
142
|
+
/** Run the remote-side teardown over a fresh ssh connection. */
|
|
143
|
+
export declare const runRemoteCleanup: (target: string, remoteDir: string, opts?: RemoteCleanupOptions) => Promise<number>;
|
|
97
144
|
/**
|
|
98
|
-
* The remote-side teardown script
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
145
|
+
* The remote-side teardown script. Runs from inside the workspace dir so the
|
|
146
|
+
* `claude` CLI's local scope resolves to the right project. The chrome-devtools
|
|
147
|
+
* and local-shell MCP entries are stripped only when their flags are set — i.e.
|
|
148
|
+
* when the workspace registered them via --devtools / --rpc; without them we
|
|
149
|
+
* never touch the user's MCP config. The synced files themselves are left in
|
|
150
|
+
* place — they are a real copy, not a mount — unless `deleteRemoteDir` is set,
|
|
151
|
+
* in which case the workspace dir is removed after any MCP config is stripped.
|
|
102
152
|
*/
|
|
103
|
-
export declare const buildCleanupScript: (remoteDir: string) => string;
|
|
153
|
+
export declare const buildCleanupScript: (remoteDir: string, opts?: RemoteCleanupOptions) => string;
|
|
104
154
|
/** True if an `ssh` client is on PATH (works on Windows, macOS, Linux). */
|
|
105
155
|
export declare const hasSshClient: () => boolean;
|
|
106
156
|
/** True if the Mutagen CLI is on PATH and runnable. */
|
|
@@ -112,6 +162,8 @@ export declare const hasMutagen: () => boolean;
|
|
|
112
162
|
* the mode switches to two-way-resolved (alpha always wins conflicts), so
|
|
113
163
|
* `--source remote` puts the server first and `--source local` puts this
|
|
114
164
|
* machine first. Labels let `workspace cleanup` find and terminate orphans.
|
|
165
|
+
* `ctx.ignores` (from --ignore-vcs) excludes gitignored paths from the sync so
|
|
166
|
+
* each side keeps its own build artifacts and platform-specific binaries.
|
|
115
167
|
*/
|
|
116
168
|
export declare const buildMutagenCreateArgs: (ctx: WorkspaceContext, target: string) => string[];
|
|
117
169
|
/** Arguments for `mutagen sync flush <name>` — block until one full sync completes. */
|