@firenet-designs/fnd-cli 2.3.3 → 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.
@@ -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,108 @@ 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';
69
160
  /**
70
161
  * Bash lines (run on the REMOTE, from inside the workspace dir) that register the
71
162
  * chrome-devtools MCP with the `claude` CLI. Local scope keys off the current
@@ -91,14 +182,37 @@ const claudeDevtoolsAddScript = (remotePort, okMessage) => {
91
182
  return [`"\${SHELL:-bash}" -lc ${shQuote(body)}`];
92
183
  };
93
184
  /**
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
185
+ * Bash lines (run on the REMOTE, from inside the workspace dir) that register the
186
+ * --rpc local-shell MCP with the `claude` CLI as a Streamable HTTP server. The
187
+ * URL points at the reverse-tunnelled port, which the workspace's `ssh -R`
188
+ * forwards back to the RPC server on the calling machine. Same login-shell and
189
+ * idempotency reasoning as `claudeDevtoolsAddScript`.
190
+ */
191
+ const claudeRpcAddScript = (remotePort, okMessage) => {
192
+ const body = [
193
+ 'if command -v claude >/dev/null 2>&1; then',
194
+ ` claude mcp remove ${RPC_MCP_NAME} >/dev/null 2>&1 || true`,
195
+ ` claude mcp add --transport http ${RPC_MCP_NAME} http://127.0.0.1:${remotePort}/mcp`,
196
+ ` echo ${shQuote(okMessage)}`,
197
+ 'else',
198
+ ' echo "WARNING: claude CLI not found on the remote; skipped local-shell MCP config." >&2',
199
+ 'fi',
200
+ ].join('\n');
201
+ return [`"\${SHELL:-bash}" -lc ${shQuote(body)}`];
202
+ };
203
+ /**
204
+ * Bash lines (run on the REMOTE, from inside the workspace dir) that remove an
205
+ * MCP entry this workspace registered. Local scope keys off the current
96
206
  * directory, so it targets only the workspace project. No-op if the entry or the
97
207
  * claude CLI is absent. Runs under a login shell for the same PATH reason as
98
208
  * `claudeDevtoolsAddScript`.
99
209
  */
100
- const claudeDevtoolsRemoveScript = () => {
101
- const body = ['if command -v claude >/dev/null 2>&1; then', ` claude mcp remove ${DEVTOOLS_MCP_NAME}`, 'fi'].join('\n');
210
+ const claudeMcpRemoveScript = (name) => {
211
+ const body = [
212
+ 'if command -v claude >/dev/null 2>&1; then',
213
+ ` claude mcp remove ${name}`,
214
+ 'fi'
215
+ ].join('\n');
102
216
  return [`"\${SHELL:-bash}" -lc ${shQuote(body)}`];
103
217
  };
104
218
  /**
@@ -111,14 +225,18 @@ export const buildRemoteScript = (ctx) => {
111
225
  const devtoolsSetup = ctx.devtools
112
226
  ? claudeDevtoolsAddScript(ctx.devtools.remote, `Configured chrome-devtools MCP for this workspace (browser via 127.0.0.1:${ctx.devtools.remote}).`)
113
227
  : [];
228
+ const rpcSetup = ctx.rpc
229
+ ? 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}).`)
230
+ : [];
114
231
  return [
115
232
  'set -u',
116
233
  `DIR=${dir}`,
117
234
  // Mutagen creates the sync root, but ensure it exists so `cd` never races it.
118
235
  'mkdir -p "$DIR" || { echo "ERROR: could not create $DIR" >&2; exit 1; }',
119
236
  '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.
237
+ // Register the MCPs from inside $DIR: `claude mcp add` local scope keys off cwd.
121
238
  ...devtoolsSetup,
239
+ ...rpcSetup,
122
240
  'echo "Workspace ready at $DIR — files sync in the background (exit to stop syncing)."',
123
241
  // eslint-disable-next-line no-template-curly-in-string -- shell parameter expansion, not a JS template
124
242
  '"${SHELL:-bash}" -l',
@@ -133,16 +251,17 @@ export const runRemoteCleanup = (target, remoteDir, opts = {}) => new Promise((r
133
251
  /**
134
252
  * The remote-side teardown script. Runs from inside the workspace dir so the
135
253
  * `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.
254
+ * and local-shell MCP entries are stripped only when their flags are set — i.e.
255
+ * when the workspace registered them via --devtools / --rpc; without them we
256
+ * never touch the user's MCP config. The synced files themselves are left in
257
+ * place — they are a real copy, not a mount — unless `deleteRemoteDir` is set,
258
+ * in which case the workspace dir is removed after any MCP config is stripped.
141
259
  */
142
260
  export const buildCleanupScript = (remoteDir, opts = {}) => [
143
261
  `DIR=${shQuote(remoteDir)}`,
144
262
  'cd "$DIR" 2>/dev/null || { echo "Nothing to clean up: $DIR is gone." >&2; exit 0; }',
145
- ...(opts.removeDevtoolsMcp ? claudeDevtoolsRemoveScript() : []),
263
+ ...(opts.removeDevtoolsMcp ? claudeMcpRemoveScript(DEVTOOLS_MCP_NAME) : []),
264
+ ...(opts.removeRpcMcp ? claudeMcpRemoveScript(RPC_MCP_NAME) : []),
146
265
  // Remove the workspace dir last: cd out first so we don't rm the cwd out from
147
266
  // under the shell, then delete it. Only when explicitly requested.
148
267
  ...(opts.deleteRemoteDir
@@ -169,6 +288,8 @@ export const hasMutagen = () => {
169
288
  * the mode switches to two-way-resolved (alpha always wins conflicts), so
170
289
  * `--source remote` puts the server first and `--source local` puts this
171
290
  * machine first. Labels let `workspace cleanup` find and terminate orphans.
291
+ * `ctx.ignores` (from --ignore-vcs) excludes gitignored paths from the sync so
292
+ * each side keeps its own build artifacts and platform-specific binaries.
172
293
  */
173
294
  export const buildMutagenCreateArgs = (ctx, target) => {
174
295
  const local = ctx.localCwd;
@@ -184,6 +305,7 @@ export const buildMutagenCreateArgs = (ctx, target) => {
184
305
  '--label=managed-by=fnd-workspace',
185
306
  `--label=dir=${slugify(ctx.localDirName)}`,
186
307
  `--sync-mode=${syncMode}`,
308
+ ...(ctx.ignores ?? []).map((p) => `--ignore=${p}`),
187
309
  alpha,
188
310
  beta,
189
311
  ];
@@ -186,11 +186,12 @@
186
186
  "workspace:cleanup": {
187
187
  "aliases": [],
188
188
  "args": {},
189
- "description": "Tear down a leftover workspace — use this if a `workspace` session dropped before it could clean up after itself.\n\nTerminates any Mutagen sync sessions this machine started for the directory. Pass --devtools to also strip the matching chrome-devtools MCP entry from the remote (only do this if the dropped session used --devtools). With no --remote-dir, it targets the same path `workspace` would use for the current directory. The synced files themselves are left in place unless you pass --delete-remote-dir.",
189
+ "description": "Tear down a leftover workspace — use this if a `workspace` session dropped before it could clean up after itself.\n\nTerminates any Mutagen sync sessions this machine started for the directory. Pass --devtools or --rpc to also strip the matching MCP entries from the remote (only do this if the dropped session used those flags). With no --remote-dir, it targets the same path `workspace` would use for the current directory. The synced files themselves are left in place unless you pass --delete-remote-dir.",
190
190
  "examples": [
191
191
  "<%= config.bin %> <%= command.id %> --ssh user@host",
192
192
  "<%= config.bin %> <%= command.id %> --ssh user@host --remote-dir /home/fnd/cole/fnd-cli",
193
193
  "<%= config.bin %> <%= command.id %> --ssh user@host --devtools",
194
+ "<%= config.bin %> <%= command.id %> --ssh user@host --rpc",
194
195
  "<%= config.bin %> <%= command.id %> --ssh user@host --delete-remote-dir"
195
196
  ],
196
197
  "flags": {
@@ -221,6 +222,12 @@
221
222
  "multiple": false,
222
223
  "type": "option"
223
224
  },
225
+ "rpc": {
226
+ "description": "also strip the local-shell MCP entry from the remote (only if the dropped session used --rpc)",
227
+ "name": "rpc",
228
+ "allowNo": false,
229
+ "type": "boolean"
230
+ },
224
231
  "ssh": {
225
232
  "description": "remote to connect to, as user@host",
226
233
  "name": "ssh",
@@ -254,8 +261,11 @@
254
261
  "<%= config.bin %> <%= command.id %> --ssh user@203.0.113.4",
255
262
  "<%= config.bin %> <%= command.id %> --ssh user@host --source local",
256
263
  "<%= config.bin %> <%= command.id %> --ssh user@host --remote-base /home/fnd",
264
+ "<%= config.bin %> <%= command.id %> --ssh user@host --ignore-vcs",
257
265
  "<%= config.bin %> <%= command.id %> --ssh user@host --devtools 9222",
258
266
  "<%= config.bin %> <%= command.id %> --ssh user@host --devtools 9333:9222",
267
+ "<%= config.bin %> <%= command.id %> --ssh user@host --rpc port=7777:7700",
268
+ "<%= config.bin %> <%= command.id %> --ssh user@host --rpc port=7777:7700,profile=false,shell=zsh",
259
269
  "<%= config.bin %> <%= command.id %> --ssh user@host --delete-remote-dir"
260
270
  ],
261
271
  "flags": {
@@ -272,6 +282,12 @@
272
282
  "multiple": false,
273
283
  "type": "option"
274
284
  },
285
+ "ignore-vcs": {
286
+ "description": "don't sync paths matched by the project's .gitignore files (node_modules, build output, …) so each side keeps its own platform-specific artifacts. Every .gitignore in the tree is honoured relative to its directory, like git does. The .git directory itself still syncs, so the remote stays a working repo.",
287
+ "name": "ignore-vcs",
288
+ "allowNo": false,
289
+ "type": "boolean"
290
+ },
275
291
  "remote-base": {
276
292
  "description": "base dir on the remote; the workspace lands at <base>/<local-user>/<dir-name>",
277
293
  "name": "remote-base",
@@ -280,6 +296,13 @@
280
296
  "multiple": false,
281
297
  "type": "option"
282
298
  },
299
+ "rpc": {
300
+ "description": "expose a run_local_command MCP tool to Claude on the remote that executes commands back on THIS machine (the one running fnd workspace). Value is port=<port|remote:local>[,profile=<true|1|false|0>][,shell=<bash|batch|powershell|sh|zsh>] — port opens a reverse tunnel (ssh -R <remote>:localhost:<local>) to a command server started here; shell defaults to the shell fnd workspace was called from; profile (default true) controls whether the shell loads its startup files — with it on, POSIX shells run interactively (-i) so rc files like ~/.bashrc or ~/.zshrc are sourced and tools such as nvm work.",
301
+ "name": "rpc",
302
+ "hasDynamicHelp": false,
303
+ "multiple": false,
304
+ "type": "option"
305
+ },
283
306
  "source": {
284
307
  "description": "which side wins on conflict: \"remote\" = this server (where the workspace shell runs), \"local\" = the machine you ran fnd workspace from. Omit to flag conflicts instead of auto-resolving them.",
285
308
  "name": "source",
@@ -317,5 +340,5 @@
317
340
  ]
318
341
  }
319
342
  },
320
- "version": "2.3.3"
343
+ "version": "2.4.0"
321
344
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@firenet-designs/fnd-cli",
3
3
  "description": "A new CLI generated with oclif",
4
- "version": "2.3.3",
4
+ "version": "2.4.0",
5
5
  "author": "Cole Denslow",
6
6
  "contributors": [
7
7
  "Justin Schellenberg"
@@ -16,7 +16,8 @@
16
16
  "@oclif/plugin-plugins": "^5",
17
17
  "chalk": "^5.6.2",
18
18
  "ora": "^9.3.0",
19
- "simple-git": "^3.32.2"
19
+ "simple-git": "^3.32.2",
20
+ "zod": "^4.4.3"
20
21
  },
21
22
  "devDependencies": {
22
23
  "@eslint/compat": "^1",
@@ -91,7 +92,7 @@
91
92
  "postpack": "shx rm -f oclif.manifest.json",
92
93
  "posttest": "npm run lint",
93
94
  "prepack": "oclif manifest && oclif readme",
94
- "version": "oclif readme && git add README.md",
95
+ "version": "oclif readme",
95
96
  "prerelease": "npm run build && npm run prepack",
96
97
  "release": "npm run build && npm run prepack && npm publish"
97
98
  },