@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
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
|
/**
|
|
@@ -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
|
-
/**
|
|
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
|
|
108
|
-
* workspace registered
|
|
109
|
-
* MCP config. The synced files themselves are left in
|
|
110
|
-
* copy, not a mount — unless `deleteRemoteDir` is set,
|
|
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. */
|
package/dist/lib/workspace.js
CHANGED
|
@@ -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
|
|
27
|
-
* `remote:local`, where `local` is the caller's machine
|
|
28
|
-
*
|
|
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
|
|
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(
|
|
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(
|
|
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
|
|
77
|
-
* a non-login ssh command whose PATH lacks the
|
|
78
|
-
* login profiles add, so a bare `command -v
|
|
79
|
-
*
|
|
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
|
-
'
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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}" -
|
|
208
|
+
return [`"\${SHELL:-bash}" -lic ${shQuote(body)}`];
|
|
92
209
|
};
|
|
93
210
|
/**
|
|
94
|
-
* Bash lines (run on the REMOTE, from inside the workspace dir) that
|
|
95
|
-
*
|
|
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
|
|
101
|
-
const body =
|
|
102
|
-
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
137
|
-
* workspace registered
|
|
138
|
-
* MCP config. The synced files themselves are left in
|
|
139
|
-
* copy, not a mount — unless `deleteRemoteDir` is set,
|
|
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 ?
|
|
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
|
];
|