@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.
@@ -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
- export interface DevtoolsPorts {
34
- /** Remote-debugging port of the browser on the caller's LOCAL machine. */
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 browser. */
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 the --devtools value. Accepts `port` (same port on both ends) or
76
- * `remote:local`, where `local` is the caller's machine (where the browser runs)
77
- * and `remote` is the port opened on the workspace host.
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 parseDevtoolsPort: (raw: string) => DevtoolsPorts;
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
  /**
@@ -98,17 +136,27 @@ export interface RemoteCleanupOptions {
98
136
  deleteRemoteDir?: boolean;
99
137
  /** Strip this project's chrome-devtools MCP entry — only when the workspace registered one (--devtools). */
100
138
  removeDevtoolsMcp?: boolean;
139
+ /** Strip this project's local-shell MCP entry — only when the workspace registered one (--rpc). */
140
+ removeRpcMcp?: boolean;
101
141
  }
102
- /** Run the remote-side teardown over a fresh ssh connection. */
142
+ /**
143
+ * Run the remote-side teardown over a fresh ssh connection. Forces a remote PTY
144
+ * with `-tt`: the MCP-remove step runs under an *interactive* login shell
145
+ * (`$SHELL -lic`, so `claude2` aliases in rc files resolve — see
146
+ * `claudeMcpRemoveScript`), and an interactive bash without a PTY prints
147
+ * "cannot set terminal process group / no job control in this shell". `-tt`
148
+ * forces allocation even when this cleanup runs without a local TTY, mirroring
149
+ * the `-t` the interactive session ssh uses.
150
+ */
103
151
  export declare const runRemoteCleanup: (target: string, remoteDir: string, opts?: RemoteCleanupOptions) => Promise<number>;
104
152
  /**
105
153
  * The remote-side teardown script. Runs from inside the workspace dir so the
106
154
  * `claude` CLI's local scope resolves to the right project. The chrome-devtools
107
- * MCP entry is stripped only when `removeDevtoolsMcp` is set — i.e. when the
108
- * workspace registered one via --devtools; without it we never touch the user's
109
- * MCP config. The synced files themselves are left in place — they are a real
110
- * copy, not a mount — unless `deleteRemoteDir` is set, in which case the
111
- * workspace dir is removed after any MCP config is stripped.
155
+ * and local-shell MCP entries are stripped only when their flags are set — i.e.
156
+ * when the workspace registered them via --devtools / --rpc; without them we
157
+ * never touch the user's MCP config. The synced files themselves are left in
158
+ * place — they are a real copy, not a mount — unless `deleteRemoteDir` is set,
159
+ * in which case the workspace dir is removed after any MCP config is stripped.
112
160
  */
113
161
  export declare const buildCleanupScript: (remoteDir: string, opts?: RemoteCleanupOptions) => string;
114
162
  /** True if an `ssh` client is on PATH (works on Windows, macOS, Linux). */
@@ -121,7 +169,9 @@ export declare const hasMutagen: () => boolean;
121
169
  * winner. When a `source` is given, it becomes the Mutagen alpha endpoint and
122
170
  * the mode switches to two-way-resolved (alpha always wins conflicts), so
123
171
  * `--source remote` puts the server first and `--source local` puts this
124
- * machine first. Labels let `workspace cleanup` find and terminate orphans.
172
+ * machine first. Labels let `workspace --cleanup` find and terminate orphans.
173
+ * `ctx.ignores` (from --ignore-vcs) excludes gitignored paths from the sync so
174
+ * each side keeps its own build artifacts and platform-specific binaries.
125
175
  */
126
176
  export declare const buildMutagenCreateArgs: (ctx: WorkspaceContext, target: string) => string[];
127
177
  /** Arguments for `mutagen sync flush <name>` — block until one full sync completes. */
@@ -1,7 +1,8 @@
1
1
  import { spawn, spawnSync } from 'node:child_process';
2
2
  import { randomInt } from 'node:crypto';
3
+ import { readdirSync, readFileSync } from 'node:fs';
3
4
  import { userInfo } from 'node:os';
4
- import { basename } from 'node:path';
5
+ import { basename, join } from 'node:path';
5
6
  export const DEFAULT_MOUNT_BASE = '/home/fnd';
6
7
  /** Parse a `user@host` string, throwing a friendly error otherwise. */
7
8
  export const parseSshTarget = (raw) => {
@@ -23,15 +24,15 @@ export const slugify = (value) => value
23
24
  /** A unique Mutagen session name for a workspace on the given local directory. */
24
25
  export const buildSyncName = (dirName) => `fnd-ws-${slugify(dirName)}-${randomInt(0, 1_000_000).toString(36)}`;
25
26
  /**
26
- * Parse the --devtools value. Accepts `port` (same port on both ends) or
27
- * `remote:local`, where `local` is the caller's machine (where the browser runs)
28
- * and `remote` is the port opened on the workspace host.
27
+ * Parse a reverse-tunnel port value. Accepts `port` (same port on both ends) or
28
+ * `remote:local`, where `local` is the caller's machine and `remote` is the
29
+ * port opened on the workspace host. `flag` names the flag in error messages.
29
30
  */
30
- export const parseDevtoolsPort = (raw) => {
31
+ export const parsePortPair = (raw, flag) => {
31
32
  const toPort = (value) => {
32
33
  const n = Number(value);
33
34
  if (!Number.isInteger(n) || n < 1 || n > 65_535) {
34
- throw new Error(`--devtools port must be an integer 1-65535 (got "${value}")`);
35
+ throw new Error(`${flag} port must be an integer 1-65535 (got "${value}")`);
35
36
  }
36
37
  return n;
37
38
  };
@@ -43,7 +44,7 @@ export const parseDevtoolsPort = (raw) => {
43
44
  if (parts.length === 2) {
44
45
  return { local: toPort(parts[1]), remote: toPort(parts[0]) };
45
46
  }
46
- throw new Error(`--devtools must be "port" or "remote:local" (got "${raw}")`);
47
+ throw new Error(`${flag} port must be "port" or "remote:local" (got "${raw}")`);
47
48
  };
48
49
  /** Build the immutable facts for a workspace session from the local environment + flags. */
49
50
  export const buildContext = (opts) => {
@@ -54,18 +55,131 @@ export const buildContext = (opts) => {
54
55
  const remoteDir = `${base}/${localUser}/${localDirName}`;
55
56
  return {
56
57
  devtools: opts.devtools,
58
+ ignores: opts.ignoreVcs ? collectVcsIgnores(localCwd) : undefined,
57
59
  localCwd,
58
60
  localDirName,
59
61
  localUser,
60
62
  remoteDir,
63
+ rpc: opts.rpc,
61
64
  source: opts.source,
62
65
  syncName: buildSyncName(localDirName),
63
66
  };
64
67
  };
68
+ /**
69
+ * Translate one .gitignore line into Mutagen ignore patterns. `base` is the
70
+ * directory holding the .gitignore, as a posix path relative to the sync root
71
+ * ('' for the root file). Mutagen's syntax already matches gitignore's for the
72
+ * pieces that pass through untouched (`*`, `**`, `?`, `[...]`, `!` negation,
73
+ * trailing `/` for directory-only) — what needs translating is scope:
74
+ *
75
+ * - A pattern with a slash is anchored to the .gitignore's own directory, so it
76
+ * becomes an absolute pattern under `base` (`/dist` in src/.gitignore →
77
+ * `/src/dist`).
78
+ * - A slashless pattern matches at any depth AT OR BELOW `base`. At the root
79
+ * that is exactly Mutagen's unanchored behavior, so it passes through as-is;
80
+ * under a subdirectory it becomes `/base/p` plus `/base/**\/p` (both forms, so
81
+ * the match doesn't depend on `**` matching zero segments).
82
+ *
83
+ * Returns [] for blanks and comments.
84
+ */
85
+ export const translateGitignoreLine = (line, base) => {
86
+ // Trailing whitespace is meaningless unless backslash-escaped.
87
+ let p = line.replace(/(?<![\\])[ \t]+$/, '');
88
+ if (p === '' || p.startsWith('#'))
89
+ return [];
90
+ let negated = false;
91
+ if (p.startsWith('!')) {
92
+ negated = true;
93
+ p = p.slice(1);
94
+ }
95
+ else if (p.startsWith(String.raw `\!`) || p.startsWith(String.raw `\#`)) {
96
+ p = p.slice(1);
97
+ }
98
+ let dirOnly = false;
99
+ if (p.endsWith('/')) {
100
+ dirOnly = true;
101
+ p = p.replace(/\/+$/, '');
102
+ }
103
+ const anchored = p.includes('/');
104
+ p = p.replace(/^\/+/, '');
105
+ if (p === '')
106
+ return [];
107
+ const withBase = (rel) => `/${[base, rel].filter(Boolean).join('/')}`;
108
+ const targets = anchored ? [withBase(p)] : base === '' ? [p] : [withBase(p), withBase(`**/${p}`)];
109
+ return targets.map((t) => `${negated ? '!' : ''}${t}${dirOnly ? '/' : ''}`);
110
+ };
111
+ /**
112
+ * Walk the project and turn every .gitignore into Mutagen ignore patterns, each
113
+ * resolved relative to the directory of the .gitignore that declared it. Files
114
+ * are ordered root-first so deeper .gitignore patterns come later — Mutagen
115
+ * gives later patterns precedence, which mirrors git. `.git` and `node_modules`
116
+ * are never descended into (git does not consult .gitignore files inside
117
+ * ignored or metadata directories), and symlinked directories are skipped to
118
+ * avoid cycles.
119
+ */
120
+ export const collectVcsIgnores = (rootDir) => {
121
+ const found = [];
122
+ const walk = (dir, base) => {
123
+ let entries;
124
+ try {
125
+ entries = readdirSync(dir, { withFileTypes: true });
126
+ }
127
+ catch {
128
+ return; // unreadable dir — nothing to collect there
129
+ }
130
+ for (const entry of entries) {
131
+ if (entry.isDirectory()) {
132
+ if (entry.name === '.git' || entry.name === 'node_modules')
133
+ continue;
134
+ walk(join(dir, entry.name), base === '' ? entry.name : `${base}/${entry.name}`);
135
+ }
136
+ else if (entry.name === '.gitignore') {
137
+ found.push({ base });
138
+ }
139
+ }
140
+ };
141
+ walk(rootDir, '');
142
+ found.sort((a, b) => a.base.split('/').filter(Boolean).length - b.base.split('/').filter(Boolean).length);
143
+ return found.flatMap(({ base }) => {
144
+ let content;
145
+ try {
146
+ content = readFileSync(join(rootDir, ...base.split('/').filter(Boolean), '.gitignore'), 'utf8');
147
+ }
148
+ catch {
149
+ return [];
150
+ }
151
+ return content.split('\n').flatMap((line) => translateGitignoreLine(line, base));
152
+ });
153
+ };
65
154
  /** POSIX single-quote a string so it can be embedded safely in the remote shell script. */
66
155
  export const shQuote = (value) => `'${value.replaceAll("'", `'\\''`)}'`;
67
156
  /** MCP server name registered for the workspace's chrome-devtools tunnel. */
68
157
  const DEVTOOLS_MCP_NAME = 'chrome-devtools';
158
+ /** MCP server name registered for the --rpc local-command tunnel. */
159
+ const RPC_MCP_NAME = 'local-shell';
160
+ /**
161
+ * The `claude` CLIs an MCP entry is registered with / removed from. `claude2` is
162
+ * an overflow instance some remotes run alongside `claude`; both need the same
163
+ * project-local MCP config so whichever the user opens sees the workspace tools.
164
+ * Each is guarded by its own `command -v` — a remote without a given CLI just
165
+ * skips it. Keep add and remove over the same list so cleanup is complete.
166
+ *
167
+ * These are emitted as LITERAL command words, never `for cli in …; do "$cli" …`.
168
+ * On some remotes `claude2` is a shell *alias* (defined in ~/.bashrc / ~/.zshrc),
169
+ * and aliases are only expanded when the command word is a literal read by the
170
+ * parser — an alias never expands from a variable like `"$cli"`. Alias support is
171
+ * also why the add/remove blocks run under an *interactive* login shell (see the
172
+ * `-lic` note below): rc files, where the alias lives, are sourced only for
173
+ * interactive shells.
174
+ */
175
+ const CLAUDE_CLIS = ['claude', 'claude2'];
176
+ /**
177
+ * Emit a guarded per-CLI block for each entry in {@link CLAUDE_CLIS}. `cli` is
178
+ * interpolated literally (not via a variable) so a shell-alias `claude2` still
179
+ * expands. `command -v` gates each one so a remote missing a CLI just skips it;
180
+ * `body(cli)` returns the lines to run inside the guard.
181
+ */
182
+ const forEachClaudeCli = (body) => CLAUDE_CLIS.flatMap((cli) => [`if command -v ${cli} >/dev/null 2>&1; then`, ...body(cli).map((l) => ` ${l}`), 'fi']);
69
183
  /**
70
184
  * Bash lines (run on the REMOTE, from inside the workspace dir) that register the
71
185
  * chrome-devtools MCP with the `claude` CLI. Local scope keys off the current
@@ -73,33 +187,60 @@ const DEVTOOLS_MCP_NAME = 'chrome-devtools';
73
187
  * A prior entry is cleared first so a re-connect after a crashed session is
74
188
  * idempotent. Skips gracefully if the claude CLI is missing.
75
189
  *
76
- * The block runs inside a login shell (`$SHELL -lc`): the outer script arrives as
77
- * a non-login ssh command whose PATH lacks the node/nvm/volta/Homebrew dirs that
78
- * login profiles add, so a bare `command -v claude` misses an installed CLI. A
79
- * login shell reproduces the same PATH the interactive session below gets.
190
+ * The block runs inside an *interactive* login shell (`$SHELL -lic`). Login (`-l`)
191
+ * because the outer script arrives as a non-login ssh command whose PATH lacks the
192
+ * node/nvm/volta/Homebrew dirs that login profiles add, so a bare `command -v
193
+ * claude` would miss an installed CLI. Interactive (`-i`) because on some remotes
194
+ * `claude2` is a shell alias defined in ~/.bashrc / ~/.zshrc, and those rc files
195
+ * are sourced only for interactive shells — a plain `-lc` command shell never sees
196
+ * the alias. The `ssh -t` PTY is what lets `-i` run without job-control warnings.
80
197
  */
81
198
  const claudeDevtoolsAddScript = (remotePort, okMessage) => {
82
199
  const body = [
83
- 'if command -v claude >/dev/null 2>&1; then',
84
- ` claude mcp remove ${DEVTOOLS_MCP_NAME} >/dev/null 2>&1 || true`,
85
- ` claude mcp add ${DEVTOOLS_MCP_NAME} -- npx -y chrome-devtools-mcp@latest --browserUrl http://127.0.0.1:${remotePort}`,
86
- ` echo ${shQuote(okMessage)}`,
87
- 'else',
88
- ' echo "WARNING: claude CLI not found on the remote; skipped chrome-devtools MCP config." >&2',
89
- 'fi',
200
+ 'configured=""',
201
+ ...forEachClaudeCli((cli) => [
202
+ `${cli} mcp remove ${DEVTOOLS_MCP_NAME} >/dev/null 2>&1 || true`,
203
+ `${cli} mcp add ${DEVTOOLS_MCP_NAME} -- npx -y chrome-devtools-mcp@latest --browserUrl http://127.0.0.1:${remotePort}`,
204
+ 'configured=1',
205
+ ]),
206
+ `if [ -n "$configured" ]; then echo ${shQuote(okMessage)}; else echo "WARNING: claude CLI not found on the remote; skipped chrome-devtools MCP config." >&2; fi`,
90
207
  ].join('\n');
91
- return [`"\${SHELL:-bash}" -lc ${shQuote(body)}`];
208
+ return [`"\${SHELL:-bash}" -lic ${shQuote(body)}`];
92
209
  };
93
210
  /**
94
- * Bash lines (run on the REMOTE, from inside the workspace dir) that remove the
95
- * chrome-devtools MCP this workspace registered. Local scope keys off the current
211
+ * Bash lines (run on the REMOTE, from inside the workspace dir) that register the
212
+ * --rpc local-shell MCP with the `claude` CLI as a Streamable HTTP server. The
213
+ * URL points at the reverse-tunnelled port, which the workspace's `ssh -R`
214
+ * forwards back to the RPC server on the calling machine. Same login-shell and
215
+ * idempotency reasoning as `claudeDevtoolsAddScript`.
216
+ */
217
+ const claudeRpcAddScript = (remotePort, okMessage) => {
218
+ const body = [
219
+ 'configured=""',
220
+ ...forEachClaudeCli((cli) => [
221
+ `${cli} mcp remove ${RPC_MCP_NAME} >/dev/null 2>&1 || true`,
222
+ `${cli} mcp add --transport http ${RPC_MCP_NAME} http://127.0.0.1:${remotePort}/mcp`,
223
+ 'configured=1',
224
+ ]),
225
+ `if [ -n "$configured" ]; then echo ${shQuote(okMessage)}; else echo "WARNING: claude CLI not found on the remote; skipped local-shell MCP config." >&2; fi`,
226
+ ].join('\n');
227
+ return [`"\${SHELL:-bash}" -lic ${shQuote(body)}`];
228
+ };
229
+ /**
230
+ * Bash lines (run on the REMOTE, from inside the workspace dir) that remove an
231
+ * MCP entry this workspace registered. Local scope keys off the current
96
232
  * directory, so it targets only the workspace project. No-op if the entry or the
97
233
  * claude CLI is absent. Runs under a login shell for the same PATH reason as
98
234
  * `claudeDevtoolsAddScript`.
99
235
  */
100
- const claudeDevtoolsRemoveScript = () => {
101
- const body = ['if command -v claude >/dev/null 2>&1; then', ` claude mcp remove ${DEVTOOLS_MCP_NAME}`, 'fi'].join('\n');
102
- return [`"\${SHELL:-bash}" -lc ${shQuote(body)}`];
236
+ const claudeMcpRemoveScript = (name) => {
237
+ const body = forEachClaudeCli((cli) => [
238
+ // Tolerate failure: if the entry was never registered (or already gone) the
239
+ // remove exits non-zero — that's success for us, so never let it abort the
240
+ // rest of the teardown (dir deletion, the other CLI, …).
241
+ `${cli} mcp remove ${name} >/dev/null 2>&1 || true`,
242
+ ]).join('\n');
243
+ return [`"\${SHELL:-bash}" -lic ${shQuote(body)}`];
103
244
  };
104
245
  /**
105
246
  * The bash script the remote runs for the interactive session. Mutagen already
@@ -111,38 +252,51 @@ export const buildRemoteScript = (ctx) => {
111
252
  const devtoolsSetup = ctx.devtools
112
253
  ? claudeDevtoolsAddScript(ctx.devtools.remote, `Configured chrome-devtools MCP for this workspace (browser via 127.0.0.1:${ctx.devtools.remote}).`)
113
254
  : [];
255
+ const rpcSetup = ctx.rpc
256
+ ? claudeRpcAddScript(ctx.rpc.ports.remote, `Configured local-shell MCP for this workspace (runs ${ctx.rpc.shell} commands on the calling machine via 127.0.0.1:${ctx.rpc.ports.remote}).`)
257
+ : [];
114
258
  return [
115
259
  'set -u',
116
260
  `DIR=${dir}`,
117
261
  // Mutagen creates the sync root, but ensure it exists so `cd` never races it.
118
262
  'mkdir -p "$DIR" || { echo "ERROR: could not create $DIR" >&2; exit 1; }',
119
263
  'cd "$DIR" || { echo "ERROR: could not enter $DIR" >&2; exit 1; }',
120
- // Register the MCP from inside $DIR: `claude mcp add` local scope keys off cwd.
264
+ // Register the MCPs from inside $DIR: `claude mcp add` local scope keys off cwd.
121
265
  ...devtoolsSetup,
266
+ ...rpcSetup,
122
267
  'echo "Workspace ready at $DIR — files sync in the background (exit to stop syncing)."',
123
268
  // eslint-disable-next-line no-template-curly-in-string -- shell parameter expansion, not a JS template
124
269
  '"${SHELL:-bash}" -l',
125
270
  ].join('\n');
126
271
  };
127
- /** Run the remote-side teardown over a fresh ssh connection. */
272
+ /**
273
+ * Run the remote-side teardown over a fresh ssh connection. Forces a remote PTY
274
+ * with `-tt`: the MCP-remove step runs under an *interactive* login shell
275
+ * (`$SHELL -lic`, so `claude2` aliases in rc files resolve — see
276
+ * `claudeMcpRemoveScript`), and an interactive bash without a PTY prints
277
+ * "cannot set terminal process group / no job control in this shell". `-tt`
278
+ * forces allocation even when this cleanup runs without a local TTY, mirroring
279
+ * the `-t` the interactive session ssh uses.
280
+ */
128
281
  export const runRemoteCleanup = (target, remoteDir, opts = {}) => new Promise((resolve, reject) => {
129
- const child = spawn('ssh', [target, buildCleanupScript(remoteDir, opts)], { stdio: 'inherit' });
282
+ const child = spawn('ssh', ['-tt', target, buildCleanupScript(remoteDir, opts)], { stdio: 'inherit' });
130
283
  child.once('error', reject);
131
284
  child.once('close', (code) => resolve(code ?? 0));
132
285
  });
133
286
  /**
134
287
  * The remote-side teardown script. Runs from inside the workspace dir so the
135
288
  * `claude` CLI's local scope resolves to the right project. The chrome-devtools
136
- * MCP entry is stripped only when `removeDevtoolsMcp` is set — i.e. when the
137
- * workspace registered one via --devtools; without it we never touch the user's
138
- * MCP config. The synced files themselves are left in place — they are a real
139
- * copy, not a mount — unless `deleteRemoteDir` is set, in which case the
140
- * workspace dir is removed after any MCP config is stripped.
289
+ * and local-shell MCP entries are stripped only when their flags are set — i.e.
290
+ * when the workspace registered them via --devtools / --rpc; without them we
291
+ * never touch the user's MCP config. The synced files themselves are left in
292
+ * place — they are a real copy, not a mount — unless `deleteRemoteDir` is set,
293
+ * in which case the workspace dir is removed after any MCP config is stripped.
141
294
  */
142
295
  export const buildCleanupScript = (remoteDir, opts = {}) => [
143
296
  `DIR=${shQuote(remoteDir)}`,
144
297
  'cd "$DIR" 2>/dev/null || { echo "Nothing to clean up: $DIR is gone." >&2; exit 0; }',
145
- ...(opts.removeDevtoolsMcp ? claudeDevtoolsRemoveScript() : []),
298
+ ...(opts.removeDevtoolsMcp ? claudeMcpRemoveScript(DEVTOOLS_MCP_NAME) : []),
299
+ ...(opts.removeRpcMcp ? claudeMcpRemoveScript(RPC_MCP_NAME) : []),
146
300
  // Remove the workspace dir last: cd out first so we don't rm the cwd out from
147
301
  // under the shell, then delete it. Only when explicitly requested.
148
302
  ...(opts.deleteRemoteDir
@@ -168,7 +322,9 @@ export const hasMutagen = () => {
168
322
  * winner. When a `source` is given, it becomes the Mutagen alpha endpoint and
169
323
  * the mode switches to two-way-resolved (alpha always wins conflicts), so
170
324
  * `--source remote` puts the server first and `--source local` puts this
171
- * machine first. Labels let `workspace cleanup` find and terminate orphans.
325
+ * machine first. Labels let `workspace --cleanup` find and terminate orphans.
326
+ * `ctx.ignores` (from --ignore-vcs) excludes gitignored paths from the sync so
327
+ * each side keeps its own build artifacts and platform-specific binaries.
172
328
  */
173
329
  export const buildMutagenCreateArgs = (ctx, target) => {
174
330
  const local = ctx.localCwd;
@@ -184,6 +340,7 @@ export const buildMutagenCreateArgs = (ctx, target) => {
184
340
  '--label=managed-by=fnd-workspace',
185
341
  `--label=dir=${slugify(ctx.localDirName)}`,
186
342
  `--sync-mode=${syncMode}`,
343
+ ...(ctx.ignores ?? []).map((p) => `--ignore=${p}`),
187
344
  alpha,
188
345
  beta,
189
346
  ];